처음엔 테스트 코드를 작성하는 것이 번거롭기만 했다.
눈앞의 기능을 빠르게 구현하는 게 중요했고 "잘 돌아가니까 괜찮겠지"라는 생각이 익숙했다.
하지만 기능이 쌓이고, 팀원이 바뀌고, 코드를 리팩토링하는 순간이 올 때마다 깨닫게 됐다.
"이 코드.. 진짜 제대로 동작하고 있는 걸까?"
그래서 나는 테스트 코드를 다시 바라보기 시작했다.
특히 테스트 주도 개발이라는 방식은 나에게 전혀 다른 시야를 열어줬다.
✅ 테스트 코드 없이 겪었던 일들
- 기능이 동작하는 것처럼 보였지만 실제로는 예상치 못한 경로에서 에러가 발생했다.
- 리팩토링은 공포 그 자체였다. 작게 고친 줄 알았던 코드가 전혀 다른 기능을 깨뜨렸다.
- 자신 있게 "이건 절대 안 깨져" 라고 말할 수 있는 코드가 없었다.
예전에 DRM 해제 기능을 개발했을 때 특정 파일 확장자만 처리해야 했지만 확장자 체크 로직에 대한 테스트가 부족했던 탓에 엉뚱한 형식의 파일이 처리되면서 오류가 발생했다.
특정 상황에선 DRM 해제가 되지 않거나 반대로 지원하지 않는 파일도 처리되는 보안 이슈로 이어질 수 있는 문제였다.
단위 테스트에서 확장자 검증이 명확히 포함되었더라면 해당 로직은 처음부터 안전하게 구현됐을 것이다.
// DRMProcessorTest.java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class DRMProcessorTest {
@Test
void 지원하지_않는_파일_확장자는_예외가_발생한다() {
DRMProcessor processor = new DRMProcessor();
String fileName = "malicious.exe";
assertThrows(UnsupportedFileTypeException.class, () -> {
processor.decrypt(fileName);
});
}
@Test
void 지원되는_파일_확장자는_정상_처리된다() {
DRMProcessor processor = new DRMProcessor();
String fileName = "document.pdf";
assertDoesNotThrow(() -> {
processor.decrypt(fileName);
});
}
}
// DRMProcessor.java
public class DRMProcessor {
private static final List<String> SUPPORTED_EXTENSIONS = List.of("pdf", "docx", "xlsx");
public void decrypt(String fileName) {
String extension = extractExtension(fileName);
if (!SUPPORTED_EXTENSIONS.contains(extension)) {
throw new UnsupportedFileTypeException("지원하지 않는 파일 형식입니다: " + extension);
}
// DRM 해제 로직 실행
}
private String extractExtension(String fileName) {
int lastDot = fileName.lastIndexOf('.');
if (lastDot == -1 || lastDot == fileName.length() - 1) {
return "";
}
return fileName.substring(lastDot + 1).toLowerCase();
}
}
📊 테스트 커버리지를 믿지 말자 (단독으로는)
반대로 테스트 코드는 있었지만 믿을 수 없었던 적도 있었다.
- 테스트 커버리지는 80%를 넘겼지만 정작 중요한 비즈니스 로직은 검증되지 않았다.
- 테스트가 있으니 안심하고 리팩토링했지만 엉뚱한 곳에서 버그가 터졌다.
- 심지어 테스트는 전부 통과했는데 서비스는 실제로 정상 동작하지 않았다.
이유는 간단했다.
“커버리지를 채우는 데에만 집중하고 진짜 검증이 되는지 확인하지 않았다.”
예를 들어 다음과 같은 테스트 코드가 있었다:
@Test
void testDiscountApplied() {
Order order = new Order();
order.applyDiscount(0.1);
assertTrue(order.getPrice() > 0); // ???
}
겉보기엔 할인 기능이 동작하는 것처럼 보이지만 이 테스트는 실제로 얼마가 할인되어야 하는지, 정확한 값이 맞는지를 전혀 검증하지 않는다.
커버리지는 올라가지만 신뢰는 떨어지는 테스트다.
이때부터 나는 깨달았다.
커버리지는 '지도'가 될 수는 있어도 '보증서'는 될 수 없다.
✅ 커버리지는 “결과”이지 “목표”가 아니다
테스트 커버리지는 어디가 테스트되었고 어디가 빠졌는지를 시각적으로 알려주는 “확인 도구”일 뿐이다.
하지만 그 수치만을 목표로 개발하면 테스트는 그저 통과를 위한 장식이 된다.
실제로 좋은 테스트는 아래와 같은 특성을 가진다:
- 실패할 땐 분명한 이유가 보인다 (모호하지 않음)
- 비즈니스 로직의 경계를 정확히 검증한다
- 외부 변화에 과민하지 않으며 코드 구조를 단단하게 만든다
// Bad Test
assertTrue(user.isActive());
// Good Test
assertEquals("ACTIVE", user.getStatus());
assertEquals("활성 사용자입니다", user.getMessage());
테스트는 기능을 위한 안전망이 아니라 설계를 위한 나침반이 되어야 한다.
커버리지를 채우기보다 의도를 정확히 검증하는 테스트를 작성하자.
🧪 TDD는 단순히 테스트를 먼저 작성하는 게 아니다
TDD는 단순한 순서의 문제가 아니다. 실패할 테스트를 먼저 작성하면서 우리는 기능의 목적과 경계를 명확히 하게 되고
- 도메인 간 역할 분리
- 계층 간 책임 분리
- 더 작고 명확한 인터페이스 설계
가 따라오게 된다.
TDD는 설계를 강요하지 않지만 결국 더 좋은 설계로 데려다주는 개발 방식이라고 생각한다.
예시로 사용자 포인트 차감 로직을 구현할 때 다음과 같은 방식으로 접근해보자:
@Test
void 사용자의_포인트가_부족하면_예외가_발생해야_한다() {
PointService service = new PointService(new FakeRepository(잔액: 0));
assertThrows(InsufficientPointException.class, () -> service.usePoint(1000));
}
이처럼 실패하는 테스트를 먼저 작성하고 이를 통과시키는 최소한의 로직을 구현한 뒤 중복을 제거하며 리팩토링해 나간다.
이 과정에서 로직은 작고 테스트 가능한 단위로 나뉘고 자연스럽게 계층 간 책임도 명확해졌다.
🔄 내가 경험한 TDD 전과 후의 변화
Before (기능 중심 개발) | After (TDD 적용 개발) |
---|---|
테스트는 사후 작업 | 테스트는 기능 정의의 시작 |
설계보다 구현이 먼저 | 테스트가 설계를 유도 |
높은 커버리지에도 불안 | 낮은 커버리지여도 신뢰감 |
리팩토링이 무서움 | 리팩토링이 일상이 됨 |
TDD는 테스트만을 위한 도구가 아니라 내가 설계를 주도하고 더 나은 코드를 작성하게 도와주는 나침반이다.
🧩 테스트 코드, 이렇게 바꿔보자
- 단순히 커버리지를 채우는 것이 아니라 중요한 로직 경로부터 테스트하자
- 도메인 경계가 명확하면 테스트도 자연스럽게 간결해진다
- 테스트가 어렵다면 코드가 테스트하기 어렵게 작성된 건 아닌지 돌아보자
- 모호한 assert는 피하고 구체적이고 의미 있는 결과를 검증하자
🎯 마무리: 테스트는 기록이고, TDD는 대화다
TDD를 하다 보면 테스트는 단순한 검증 수단이 아니라
기획자, 디자이너, 개발자 사이의 명확한 소통 수단이 된다.
실패한 테스트는 잘못된 방향을 알려주고 통과한 테스트는 우리가 가야 할 길을 말해준다.
오늘도 한 줄의 테스트로 한 줄의 코드를 더 믿어보려 한다.
기록된 실패가 있었기에 지금의 단단한 코드가 가능했다.
그리고 앞으로도 나는 그 실패를 가장 먼저 기록하는 개발자이고 싶다.