우아한테크코스 테코톡

하로의 단위 테스트로 나누는 것이 반드시 좋은 것일까?

https://youtu.be/Aq4Ga7pfDJo?si=Nqz__FC3gpgHEgQu

하로의 단위 테스트로 나누는 것이 반드시 좋은 것일까?


단위 테스트로 잘게 나누는 것이 항상 좋은 선택일까?

개발을 배우다 보면 테스트 코드의 중요성을 빠르게 접하게 된다.

테스트 코드는 코드가 의도대로 동작하는지 검증하고, 변경으로 인해 기존 기능이 깨지는 것을 방지한다. 특히 단위 테스트는 실행 속도가 빠르고 실패 원인을 쉽게 찾을 수 있다는 장점 때문에 테스트 전략의 기본처럼 설명되곤 한다.

하지만 여기서 한 가지 질문을 던져볼 수 있다.

모든 코드를 작은 단위로 나누어 테스트하는 것이 정말 항상 좋은 방법일까?

단위 테스트가 많다고 해서 실제 서비스가 정상적으로 동작한다고 단정할 수는 없다. 개별 부품이 모두 정상이어도 부품을 연결하는 과정에서 문제가 발생할 수 있고, 내부 구현을 개선했을 뿐인데 기존 테스트가 대량으로 깨질 수도 있다.

이 글에서는 단위 테스트와 통합 테스트의 차이, 테스트 피라미드의 철학, 단위 테스트의 한계와 함께 통합 테스트를 강조하는 테스팅 트로피에 대해 알아본다.


테스트 코드를 작성하는 이유

테스트 코드는 단순히 코드 커버리지를 높이기 위해 작성하는 것이 아니다.

테스트의 궁극적인 목적은 코드에 대한 신뢰와 변경에 대한 자신감을 얻는 것이다.

코드의 동작을 검증한다

테스트 코드는 작성한 코드가 설계한 대로 동작하는지 확인한다.

예를 들어 이름을 입력받아 마스킹하는 기능을 구현했다면 다음과 같은 동작을 검증할 수 있다.

입력: 홍길동
출력: 홍**

기대한 결과와 실제 결과가 일치하는지 자동으로 확인할 수 있다.

버그를 조기에 발견한다

테스트가 없다면 기능을 실제로 실행하거나 배포한 뒤에야 오류를 발견할 가능성이 높다.

반면 테스트를 작성하면 개발 단계에서 잘못된 동작을 빠르게 확인할 수 있다.

코드 작성
→ 테스트 실행
→ 실패 확인
→ 원인 수정

운영 환경에서 버그를 발견하는 것보다 훨씬 낮은 비용으로 문제를 해결할 수 있다.

문제의 범위를 빠르게 좁힐 수 있다

큰 프로젝트에서 오류가 발생하면 원인을 찾기 위해 수많은 파일과 실행 흐름을 확인해야 할 수 있다.

하지만 기능별 테스트가 존재하면 어떤 영역에서 문제가 발생했는지 빠르게 좁힐 수 있다.

회원가입 테스트 실패
→ 회원가입 관련 로직 우선 확인

테스트는 일종의 오류 위치 안내판 역할을 한다.

리팩터링에 대한 자신감을 준다

테스트가 충분히 작성되어 있다면 내부 구조를 변경한 뒤에도 기존 동작이 유지되는지 확인할 수 있다.

리팩터링
→ 테스트 전체 실행
→ 기존 동작 유지 확인

결국 테스트 코드가 제공하는 가장 큰 가치는 신뢰도 상승이다.


단위 테스트란 무엇인가

단위 테스트는 프로그램의 가장 작은 기능 단위를 독립적으로 검증하는 테스트다.

자동차에 비유하면 다음과 같다.

자동차 문이 정상적으로 열리는가?
바퀴가 제대로 회전하는가?
헤드라이트가 켜지는가?

각 부품이 다른 부품과 연결되지 않은 상태에서도 설계된 대로 동작하는지 확인하는 것이다.

코드에서는 클래스나 메서드 단위로 테스트하는 경우가 많다.

@Test
void 이름의_성을_제외한_부분을_마스킹한다() {
    Name name = new Name("홍길동");

    String result = name.mask();

    assertThat(result).isEqualTo("홍**");
}

단위 테스트는 일반적으로 다음 장점을 가진다.

실행 속도가 빠르다
실패 원인을 찾기 쉽다
외부 시스템의 영향을 적게 받는다
다양한 경계값을 세밀하게 검증할 수 있다

통합 테스트란 무엇인가

통합 테스트는 여러 컴포넌트나 모듈이 연결되었을 때 올바르게 동작하는지 검증한다.

자동차를 예로 들면 다음과 같다.

시속 30km를 넘으면 모든 문이 자동으로 잠기는가?
핸들을 오른쪽으로 돌리면 바퀴도 오른쪽으로 움직이는가?
브레이크를 밟으면 엔진과 바퀴가 함께 감속하는가?

각 부품이 개별적으로 정상인지보다, 부품들이 함께 연결되었을 때 원하는 흐름이 완성되는지를 확인한다.

백엔드 애플리케이션에서는 다음과 같은 테스트가 통합 테스트에 해당할 수 있다.

Controller → Service → Repository 흐름
애플리케이션과 데이터베이스 연결
트랜잭션 커밋과 롤백
JSON 직렬화와 역직렬화
외부 API 클라이언트와 응답 처리

예를 들어 회원가입 기능은 다음 구성요소가 함께 동작해야 한다.

HTTP 요청
→ 요청 데이터 검증
→ 회원 중복 확인
→ 비밀번호 암호화
→ 데이터베이스 저장
→ HTTP 응답 반환

각 구성요소의 단위 테스트가 모두 통과하더라도 이 흐름 전체가 정상이라고 보장할 수는 없다.


테스트 피라미드란 무엇인가

테스트 피라미드는 테스트 종류별 권장 비중을 피라미드 모양으로 나타낸 전략이다.

일반적으로 다음 구조로 설명된다.

          E2E 테스트
       통합 테스트
    단위 테스트

가장 아래에 있는 단위 테스트의 비중이 가장 크고, 위로 올라갈수록 테스트 수가 줄어든다.

핵심 철학은 다음과 같다.

빠르고 저렴한 테스트는 많이 작성한다
느리고 비용이 큰 테스트는 적게 작성한다

단위 테스트는 외부 서버나 실제 데이터베이스를 사용하지 않는 경우가 많아 매우 빠르게 실행할 수 있다.

반면 E2E 테스트는 실제 사용자와 유사한 흐름으로 시스템 전체를 실행하므로 환경 구축 비용이 크고 실행 시간이 길어질 수 있다.

테스트 피라미드는 이런 비용 차이를 고려해 단위 테스트 중심의 구조를 권장한다.


테스트 피라미드가 전제하는 생각

테스트 피라미드에는 다음과 같은 생각이 어느 정도 포함되어 있다.

각 부품이 정확하게 동작한다면
조립된 시스템도 정상적으로 동작할 가능성이 높다

하지만 실제 소프트웨어에서는 부품 자체보다 부품 사이의 연결에서 문제가 발생하는 경우가 많다.

예를 들면 다음과 같다.

메서드 이름이나 파라미터 불일치
트랜잭션 범위 오류
잘못된 JSON 필드명
DB 컬럼 제약 조건 충돌
프레임워크 설정 누락
Mock과 실제 구현의 차이

따라서 단위 테스트를 많이 작성하더라도 통합 과정에서 발생하는 문제까지 모두 검증할 수는 없다.


단위 테스트의 한계 1: 내부 구현에 결합된 테스트

단위 테스트를 작성할 때 내부 메서드까지 세밀하게 검증하고 싶은 유혹이 생긴다.

예를 들어 성을 제외한 이름을 마스킹하기 위해 다음처럼 구현했다고 가정해보자.

public String mask(String name) {
    String familyName = getFamilyName(name);
    String hiddenName = getHiddenName(name);

    return familyName + hiddenName;
}

그리고 다음 메서드를 각각 테스트한다.

getFamilyName()이 성을 정확히 반환하는가?
getHiddenName()이 별표 개수를 정확히 반환하는가?
mask()가 최종 결과를 반환하는가?

이후 더 간결한 구현을 발견해 다음처럼 리팩터링할 수 있다.

public String mask(String name) {
    return name.replaceRange(1, name.length(), "*".repeat(name.length() - 1));
}

최종 기능은 동일하다.

홍길동 → 홍**

하지만 기존 보조 메서드가 사라졌기 때문에 내부 메서드를 검증하던 테스트는 모두 깨진다.

이런 테스트는 기능의 행동보다 구현 구조에 결합되어 있다.

요구사항 변경 없음
외부 동작 변경 없음
내부 구현만 개선
그런데 테스트 실패

이런 테스트를 깨지기 쉬운 테스트(Brittle Test) 라고 부를 수 있다.


구현을 테스트할 것인가, 행동을 테스트할 것인가

좋은 테스트는 가능한 한 내부 구현보다 외부에서 관찰 가능한 행동을 검증해야 한다.

구현 중심 테스트는 다음을 묻는다.

이 메서드가 호출되었는가?
내부에서 이 함수를 사용했는가?
특정 순서대로 메서드가 실행되었는가?

행동 중심 테스트는 다음을 묻는다.

사용자에게 기대한 결과를 반환했는가?
데이터가 원하는 상태로 변경되었는가?
실패 시 적절한 예외가 발생했는가?

앞선 이름 마스킹 기능이라면 내부 메서드를 따로 테스트하기보다 공개된 기능의 결과를 검증하는 편이 변경에 강하다.

@Test
void 성을_제외한_이름을_마스킹한다() {
    assertThat(mask("홍길동")).isEqualTo("홍**");
}

내부 구현이 두 개의 보조 메서드를 사용하든 replaceRange() 하나를 사용하든 외부 행동이 같다면 테스트는 유지된다.


단위 테스트의 한계 2: Mock 객체가 주는 거짓 확신

단위 테스트에서는 테스트 대상을 외부 의존성으로부터 격리하기 위해 Mock이나 Stub을 자주 사용한다.

예를 들어 주문 서비스를 테스트하면서 Repository를 Mock으로 대체할 수 있다.

given(orderRepository.save(any()))
    .willReturn(savedOrder);

이렇게 하면 실제 데이터베이스 없이 빠르게 테스트할 수 있다.

하지만 Mock은 실제 구현이 아니다.

개발자가 예상한 동작을 그대로 돌려주는 가짜 객체다.

Mock Repository는 저장 성공을 반환
실제 Repository는 제약 조건 위반으로 실패

단위 테스트가 모두 통과했더라도 실제 통합 환경에서는 다음 문제가 발생할 수 있다.

SQL 문법 오류
컬럼 이름 불일치
외래 키 제약 조건 위반
트랜잭션 경계 오류
직렬화 문제
잘못된 프레임워크 설정

Mock은 테스트 대상의 판단 로직을 검증하는 데 유용하지만, 시스템이 실제로 연결되는지까지 증명하지는 못한다.


단위 테스트의 한계 3: 상호작용 오류를 잡기 어렵다

개별 부품이 모두 정상이어도 연결 방식이 잘못될 수 있다.

예를 들어 다음 테스트가 모두 통과했다고 하자.

Controller 단위 테스트 통과
Service 단위 테스트 통과
Repository 단위 테스트 통과

그럼에도 실제 요청은 실패할 수 있다.

Controller DTO 필드명과 JSON 요청 불일치
Service의 트랜잭션 어노테이션 누락
Repository 쿼리의 컬럼 이름 오류
응답 직렬화 실패

단위 테스트는 특정 기능을 빠르고 정밀하게 검증하지만, 시스템 경계와 연결 지점의 문제를 검증하는 데는 한계가 있다.


단위 테스트를 많이 작성하면 테스트 품질이 높아질까?

테스트 수가 많다고 반드시 높은 신뢰도를 제공하는 것은 아니다.

다음과 같은 테스트가 수백 개 있어도 실제 가치는 낮을 수 있다.

구현 세부사항만 검증하는 테스트
Mock 호출 횟수만 확인하는 테스트
의미 없는 Getter와 Setter 테스트
내부 메서드 구조에 강하게 의존하는 테스트
실패 가능성이 없는 단순 코드 테스트

반대로 핵심 사용자 흐름을 검증하는 통합 테스트 몇 개가 더 큰 신뢰를 줄 수도 있다.

예를 들어 결제 서비스에서는 다음 테스트가 중요할 수 있다.

결제 승인 요청이 성공하면 주문 상태가 결제 완료로 변경되는가?
결제 실패 시 주문 상태가 유지되고 재고가 복구되는가?
동일한 결제 요청이 중복 처리되지 않는가?

테스트 전략은 테스트 개수보다 어떤 위험을 검증하는가가 더 중요하다.


대안으로 등장한 테스팅 트로피

테스팅 트로피는 단위 테스트보다 통합 테스트의 비중을 더 크게 두는 테스트 전략이다.

구조는 다음과 같이 표현할 수 있다.

          E2E
      통합 테스트
        단위 테스트
     정적 분석

트로피의 가장 넓은 부분에 통합 테스트가 위치한다.

테스트 피라미드가 속도와 비용을 중요하게 생각한다면, 테스팅 트로피는 실제 동작에 대한 확신을 더 강조한다.

테스트 피라미드
→ 빠른 단위 테스트를 많이 작성

테스팅 트로피
→ 실제 컴포넌트 간 통합을 더 많이 검증

테스팅 트로피의 핵심 철학

테스팅 트로피는 사용자와 가까운 방식으로 시스템을 검증하는 것을 중요하게 본다.

사용자는 내부 메서드가 몇 개로 나뉘어 있는지 알지 못한다.

사용자가 관심 있는 것은 다음과 같다.

로그인이 되는가?
주문이 정상적으로 생성되는가?
결제가 성공하면 이용 권한이 부여되는가?
오류가 발생하면 데이터가 올바르게 복구되는가?

통합 테스트는 이런 실제 흐름을 검증하는 데 유리하다.

또한 내부 구현이 바뀌더라도 외부 행동이 동일하다면 테스트가 유지될 가능성이 높다.


테스트 피라미드와 테스팅 트로피 중 무엇이 정답일까?

둘 중 하나가 절대적인 정답은 아니다.

시스템의 성격과 기술 환경에 따라 적절한 비중은 달라진다.

단위 테스트 비중을 높이는 것이 유리한 경우

다음과 같은 로직은 단위 테스트가 효과적이다.

복잡한 금액 계산
할인 정책
날짜 계산
상태 전이 규칙
알고리즘
경계값이 많은 검증 로직

이런 로직은 입력과 출력이 명확하고 외부 의존성이 적다.

단위 테스트를 통해 수많은 경우의 수를 빠르게 검증할 수 있다.

통합 테스트 비중을 높이는 것이 유리한 경우

다음과 같은 영역은 통합 테스트가 더 큰 신뢰를 줄 수 있다.

Controller와 요청·응답 변환
JPA 매핑과 실제 쿼리
트랜잭션 처리
Spring Security 인증 흐름
메시지 브로커 연동
외부 시스템과의 계약

이런 영역은 프레임워크, 설정, 네트워크, 데이터베이스와의 연결이 핵심이다.

Mock만으로는 실제 동작을 충분히 검증하기 어렵다.


현실적인 테스트 전략

실무에서는 피라미드나 트로피의 모양을 그대로 복제하기보다, 코드의 위험도에 따라 테스트를 배치하는 것이 효과적이다.

1. 순수 비즈니스 규칙은 단위 테스트로 검증한다

금액 계산
상태 변경 규칙
유효성 검사
도메인 정책

빠르고 세밀한 단위 테스트가 적합하다.

2. 시스템 경계는 통합 테스트로 검증한다

DB 쿼리
HTTP API
트랜잭션
직렬화
인증과 권한

실제 구성요소를 연결한 테스트가 필요하다.

3. 핵심 사용자 여정은 소수의 E2E 테스트로 검증한다

회원가입 → 로그인 → 상품 구매
구독 신청 → 결제 → 권한 부여
주문 생성 → 결제 승인 → 알림 발송

전체 흐름의 주요 경로를 검증하되, 너무 많은 E2E 테스트로 피드백 속도를 떨어뜨리지 않도록 조절한다.

4. 구현 세부사항보다 외부 행동을 검증한다

테스트는 내부 메서드 호출 방식보다 결과와 상태 변화를 확인해야 한다.

5. Mock은 필요한 만큼만 사용한다

모든 의존성을 무조건 Mock으로 대체하기보다, 테스트의 목적에 맞춰 사용해야 한다.

외부 결제 API → Mock 또는 Stub이 유용
내부 Repository → 실제 DB 테스트가 더 유용할 수 있음

테스트를 평가하는 가장 중요한 질문

테스트 전략을 결정할 때 가장 중요한 질문은 다음과 같다.

이 테스트가 실패하면 실제로 중요한 문제가 발견된 것인가?

그리고 하나를 더 질문해볼 수 있다.

이 테스트가 통과하면 배포할 수 있다는 확신이 높아지는가?

테스트가 내부 메서드 이름 변경만으로 깨진다면 유지보수 비용만 높일 수 있다.

반대로 테스트가 실제 사용자의 중요한 흐름을 검증한다면 실행 시간이 조금 더 들더라도 높은 가치를 제공할 수 있다.


단위 테스트를 버려야 한다는 뜻은 아니다

단위 테스트의 한계를 이야기한다고 해서 단위 테스트가 불필요하다는 뜻은 아니다.

단위 테스트는 여전히 매우 강력하다.

빠르다
실패 원인이 명확하다
복잡한 경우의 수를 검증하기 쉽다
개발 과정에서 즉각적인 피드백을 준다

문제는 단위 테스트를 목적이 아니라 규칙처럼 받아들이는 데 있다.

모든 메서드를 단위 테스트해야 한다
Mock을 사용해야 진짜 단위 테스트다
단위 테스트 수가 많을수록 좋다

이런 접근은 오히려 테스트를 변경에 취약하게 만들 수 있다.


마무리

단위 테스트는 코드의 신뢰도를 높이는 중요한 도구다. 하지만 내부 구현에 강하게 결합되면 리팩터링할 때 쉽게 깨지고, Mock 중심의 테스트는 실제 시스템의 연결 문제를 검증하지 못할 수 있다.

테스트 피라미드는 빠르고 저렴한 단위 테스트를 강조한다. 반면 테스팅 트로피는 실제 사용자 흐름과 컴포넌트 간 상호작용을 검증하기 위해 통합 테스트의 비중을 높인다.

하지만 중요한 것은 피라미드와 트로피 중 어느 모양을 선택하는지가 아니다.

이 테스트가 어떤 위험을 막아주는가?
이 테스트가 변경에 얼마나 견고한가?
이 테스트가 실제 동작에 대한 확신을 주는가?

이 질문에 답할 수 있어야 한다.

결국 좋은 테스트 전략은 테스트를 가장 작은 단위로 나누는 것이 아니라, 가장 가치 있는 동작을 적절한 크기로 검증하는 것이다.


© 2020. All rights reserved.

SIKSIK