본문 바로가기
카테고리 없음

💡 기록된 실패가, 지금의 단단한 코드를 만든다

by 우주최강건덕 2025. 3. 26.

처음엔 테스트 코드를 작성하는 것이 번거롭기만 했다.

눈앞의 기능을 빠르게 구현하는 게 중요했고 "잘 돌아가니까 괜찮겠지"라는 생각이 익숙했다.

하지만 기능이 쌓이고, 팀원이 바뀌고, 코드를 리팩토링하는 순간이 올 때마다 깨닫게 됐다.


"이 코드.. 진짜 제대로 동작하고 있는 걸까?"


그래서 나는 테스트 코드를 다시 바라보기 시작했다.

특히 테스트 주도 개발이라는 방식은 나에게 전혀 다른 시야를 열어줬다.

 


✅ 테스트 코드 없이 겪었던 일들

  • 기능이 동작하는 것처럼 보였지만 실제로는 예상치 못한 경로에서 에러가 발생했다.
  • 리팩토링은 공포 그 자체였다. 작게 고친 줄 알았던 코드가 전혀 다른 기능을 깨뜨렸다.
  • 자신 있게 "이건 절대 안 깨져" 라고 말할 수 있는 코드가 없었다.

 

예전에 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를 하다 보면 테스트는 단순한 검증 수단이 아니라
기획자, 디자이너, 개발자 사이의 명확한 소통 수단이 된다.

실패한 테스트는 잘못된 방향을 알려주고 통과한 테스트는 우리가 가야 할 길을 말해준다.

 

오늘도 한 줄의 테스트로 한 줄의 코드를 더 믿어보려 한다.

 

기록된 실패가 있었기에 지금의 단단한 코드가 가능했다.

 

그리고 앞으로도 나는 그 실패를 가장 먼저 기록하는 개발자이고 싶다.