기본 콘텐츠로 건너뛰기

단위 테스트와 통합 테스트, 무엇을 어디까지 검증해야 할까

단위 테스트와 통합 테스트, 무엇을 어디까지 검증해야 할까

빠른 답

  • 단위 테스트는 한 함수, 클래스, 정책 객체처럼 작은 책임을 빠르게 검증하고 외부 의존성은 보통 테스트 대역으로 분리한다.
  • 통합 테스트는 DB, HTTP API, 파일 시스템, 메시지 브로커처럼 실제 연결 지점이 함께 동작하는지 확인한다.
  • 슬라이스 테스트는 웹 계층이나 저장소 계층처럼 일부 구성만 로드해 단위 테스트보다 현실적이고 전체 통합 테스트보다 가볍게 검증한다.
  • CI에서는 빠른 테스트를 먼저 실행하고, 통합·E2E·스모크 테스트는 변경 위험과 배포 단계에 맞춰 품질 게이트로 나누는 편이 관리하기 쉽다.

한눈에 비교

검증 범위
단위 테스트는 작은 책임 단위, 슬라이스 테스트는 특정 계층, 통합 테스트는 여러 구성 요소의 연결을 본다.
실행 비용
단위 테스트는 빠르고 자주 돌리기 좋으며, 통합 테스트는 환경 준비와 I/O 때문에 상대적으로 느리다.
실패 원인
단위 테스트는 실패 지점이 좁고, 통합 테스트는 설정, 데이터, 네트워크, 트랜잭션까지 원인 후보가 넓어진다.
의존성 처리
단위 테스트는 대역 객체를 자주 쓰고, 통합 테스트는 실제 DB나 컨테이너, 테스트용 외부 API 서버를 사용한다.
신뢰도 성격
단위 테스트는 로직 회귀를 빠르게 잡고, 통합 테스트는 배포 후 연결 문제를 줄이는 데 도움이 된다.

왜 경계가 헷갈리기 쉬운가

테스트 이름은 단순하지만 실제 코드에서는 경계가 자주 흐려진다. 예를 들어 OrderService 하나만 테스트한다고 해도 그 안에서 재고 조회, 결제 요청, 주문 저장이 모두 일어나면 클래스 하나를 테스트한다는 사실만으로 단위 테스트라고 부르기 어렵다. 테스트가 묻는 질문이 “할인 규칙이 맞는가”인지, “DB 저장까지 이어지는가”인지, “외부 결제 API 장애를 처리하는가”인지에 따라 범위가 달라진다.

흔한 혼동은 세 가지에서 생긴다. 클래스 하나를 대상으로 하면 모두 단위 테스트라고 생각하거나, 애플리케이션 컨텍스트를 띄우면 모두 통합 테스트라고 보거나, mock을 쓰면 좋은 단위 테스트라고 여기는 경우다. 더 실용적인 기준은 테스트가 검증하는 경계와 실패했을 때 남기는 증거다.

단위 테스트는 작은 책임의 동작을 빠르게 확인한다. 통합 테스트는 실제 연결 지점이 맞물리는지 확인한다. 슬라이스 테스트는 그 사이에서 웹 계층, 영속성 계층, 직렬화 경계처럼 특정 부분만 골라 검증한다. 이름보다 “실패하면 무엇을 알 수 있는가”를 먼저 정하면 테스트 종류를 고르기 쉬워진다.

선택 기준 매트릭스

계산 규칙이 복잡한 상황
단위 테스트를 우선한다. 입력과 출력이 분명한 로직은 빠른 테스트가 회귀를 잘 잡는다.
SQL, 트랜잭션, 매핑이 중요한 상황
통합 테스트를 둔다. 실제 DB와 만났을 때 깨지는 문제는 대역 객체로 확인하기 어렵다.
HTTP 요청 바인딩과 응답 계약이 중요한 상황: 웹 슬라이스 테스트를 선택한다. 전체 서버를 띄우지 않고도 라우팅, 검증, 직렬화 형태를 확인할 수 있다.
외부 API 장애 대응이 중요한 상황
운영 API에 직접 의존하지 말고 테스트용 서버, stub 서버, 계약 테스트를 둔다. 테스트 안정성과 재현성이 중요하다.
배포 직전 신뢰도가 필요한 조건
스모크 테스트와 핵심 통합 테스트를 품질 게이트로 둔다. 모든 테스트를 한 단계에 몰아넣기보다 위험도에 따라 나누는 편이 추적하기 쉽다.

테스트 범위 정하는 흐름

테스트를 작성하기 전에 실패했을 때 알고 싶은 사실을 먼저 정한다. “배송비 계산이 틀렸는가”, “주문 저장 SQL이 깨졌는가”, “HTTP 응답 코드가 계약과 다른가”는 서로 다른 질문이다. 질문이 다르면 테스트 범위와 준비해야 할 환경도 달라진다.

점검 순서는 다음처럼 잡을 수 있다.

  1. 변경된 코드의 책임을 한 문장으로 적는다.
  2. 외부 시스템 없이 검증 가능한 로직은 단위 테스트로 둔다.
  3. DB, 파일, HTTP, 메시지 브로커와 맞물리는 부분은 통합 테스트 후보로 분리한다.
  4. 요청 검증, 직렬화, 라우팅처럼 특정 계층의 동작은 슬라이스 테스트로 확인한다.
  5. CI에서는 빠른 테스트를 먼저 실패시키고, 느린 테스트는 위험도와 배포 단계에 맞춰 뒤에 둔다.

이 흐름은 테스트 개수를 늘리는 것보다 원인 추적을 빠르게 만드는 데 목적이 있다. 모든 테스트가 전체 애플리케이션을 띄우면 실제 환경과 비슷해 보이지만 피드백이 느려지고 실패 원인도 넓어진다. 반대로 모든 테스트를 mock 기반 단위 테스트로만 만들면 실제 DB 스키마, 설정, 직렬화 문제를 놓칠 수 있다.

스택 중립 예시로 보는 테스트 경계

아래 예시는 JavaScript를 사용하지만 프런트엔드 테스트를 뜻하지 않는다. 언어와 런타임을 떠나, 외부 의존성이 없는 순수한 계산 규칙은 단위 테스트에 잘 맞는다는 점을 보여주는 예시다.

function shippingFee(orderAmount) {
  if (orderAmount >= 50000) {
    return 0;
  }

  return 3000;
}

test("5만 원 이상이면 배송비가 무료다", () => {
  expect(shippingFee(50000)).toBe(0);
});

test("5만 원 미만이면 배송비가 붙는다", () => {
  expect(shippingFee(49900)).toBe(3000);
});

이 테스트가 실패하면 원인 후보는 좁다. 임계값이 바뀌었거나 함수 구현이 잘못되었을 가능성이 크다. 실행이 빠르고 실패 메시지도 로직에 가까우므로 CI의 앞단에 두기 좋다.

반대로 실제 저장과 조회가 맞물려야 의미가 있는 코드는 통합 테스트에 가깝다. 저장소 함수를 mock으로 대체하면 SQL 문법, 컬럼명, 제약 조건, 트랜잭션 문제를 놓치기 쉽다.

CREATE TABLE orders (
  id BIGINT PRIMARY KEY,
  customer_id BIGINT NOT NULL,
  status VARCHAR(20) NOT NULL,
  created_at TIMESTAMP NOT NULL
);

INSERT INTO orders (id, customer_id, status, created_at)
VALUES (1, 10, 'CREATED', CURRENT_TIMESTAMP);

SELECT id, status
FROM orders
WHERE customer_id = 10;

이 검증은 단위 테스트보다 느릴 수 있다. 대신 DB 마이그레이션, 인덱스 변경, ORM 매핑 변경처럼 연결부에서 자주 깨지는 문제를 잡는 데 도움이 된다. 단위 테스트가 넓은 바닥이라면 통합 테스트는 실제 연결부를 확인하는 중간층에 가깝다.

슬라이스 테스트를 둘 위치

Spring Boot에서는 @WebMvcTest, @DataJpaTest 같은 슬라이스 테스트가 자주 쓰인다. 특정 프레임워크 문법을 외우는 것이 목적은 아니다. 웹 계층만 볼지, 저장소 계층만 볼지, 전체 요청 흐름을 볼지에 따라 테스트 크기를 조절하는 선택지로 보면 된다.

예를 들어 컨트롤러의 요청 검증과 응답 상태만 확인하고 싶다면 웹 슬라이스 테스트가 잘 맞는다. 서비스 내부 계산이나 DB 저장까지 이 테스트에 모두 넣으면 실패 원인이 넓어진다.

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    MockMvc mockMvc;

    @MockBean
    OrderService orderService;

    @Test
    void 주문_생성_요청이_정상이면_201을_응답한다() throws Exception {
        given(orderService.create(any()))
            .willReturn(new OrderResponse(1L, "CREATED"));

        mockMvc.perform(post("/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                    {"customerId":10,"productId":100,"quantity":2}
                    """))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.status").value("CREATED"));
    }
}

이 테스트는 HTTP 요청 바디가 컨트롤러에 잘 전달되는지, 응답 코드와 JSON 모양이 기대와 맞는지를 본다. 실제 DB 저장은 검증하지 않는다. 그 책임은 저장소 테스트나 컨테이너 기반 통합 테스트에 맡기면 테스트 목적이 더 선명해진다.

CI 품질 게이트 나누기

CI에서 모든 테스트를 한 번에 실행하면 처음에는 단순하다. 하지만 프로젝트가 커질수록 피드백이 느려지고, 실패 원인을 찾는 데 시간이 걸린다. 보통은 단위 테스트를 모든 pull request의 기본 게이트로 두고, 통합 테스트와 E2E 테스트는 main 브랜치, 배포 후보, 야간 빌드처럼 별도 단계로 나눈다.

name: test

on:
  pull_request:
  push:
    branches: [main]

jobs:
  unit-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ./gradlew test

  integration-test:
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - run: ./gradlew integrationTest

품질 게이트는 “테스트 통과”만 의미하지 않는다. 실패한 테스트 이름, assertion 메시지, 로그, 테스트 리포트, 커버리지 변화가 함께 남아야 한다. 그래야 테스트가 변경을 막는 장벽이 아니라 변경 위험을 설명하는 자료가 된다.

테스트 종류별 역할은 다음처럼 나눌 수 있다.

  • 단위 테스트: 계산 규칙, 분기, 예외 처리, 작은 정책 객체의 회귀를 잡는다.
  • 슬라이스 테스트: 요청 바인딩, 직렬화, 검증, 저장소 매핑처럼 특정 계층의 계약을 본다.
  • 통합 테스트: DB, 캐시, 메시지 브로커, 외부 API 대체 서버와의 연결을 확인한다.
  • E2E 테스트: 사용자 또는 외부 시스템 관점의 핵심 흐름이 끊기지 않는지 본다.
  • 스모크 테스트: 배포 직후 서버가 뜨고 핵심 엔드포인트가 최소 응답을 하는지 확인한다.
  • 회귀 테스트: 과거 장애나 버그가 다시 생기지 않도록 실패 조건을 증거로 남긴다.

실패 출력으로 원인 좁히기

좋은 테스트는 실패했을 때 다음 행동을 알려준다. 같은 주문 생성 실패라도 단위 테스트 실패와 통합 테스트 실패는 의미가 다르다. 단위 테스트의 assertion 실패는 정책 로직 문제일 가능성이 크고, 통합 테스트의 연결 실패는 설정, 인프라, 매핑, 데이터 초기화 문제일 가능성이 커진다.

OrderPolicyTest > 5만 원 이상이면 배송비가 무료다 FAILED
  expected: 0
   but was: 3000

OrderRepositoryTest > 고객의 주문을 상태별로 조회한다 FAILED
  org.springframework.dao.DataIntegrityViolationException:
  could not execute statement

Caused by: org.postgresql.util.PSQLException:
  ERROR: null value in column "created_at" violates not-null constraint

OrderControllerTest > 주문 생성 요청이 정상이면 201을 응답한다 FAILED
  Status expected:<201> but was:<400>

Resolved Exception:
  MethodArgumentNotValidException: quantity must be greater than 0

첫 번째 실패는 배송비 정책의 임계값이나 반환값을 보면 된다. 두 번째 실패는 서비스 로직보다 DB 제약 조건과 엔티티 매핑을 먼저 확인하게 한다. created_at이 필수 컬럼인데 테스트 데이터 생성 코드가 값을 넣지 않았거나, 자동 생성 설정이 실제 DB와 맞지 않을 수 있다. 세 번째 실패는 라우팅, 요청 검증, 예외 핸들러, 응답 직렬화 쪽을 먼저 보게 한다.

이처럼 실패 증거가 구체적이면 테스트는 운영 검증에도 연결된다. 배포 전 CI에서 어떤 계약이 깨졌는지 확인할 수 있고, 배포 후 스모크 테스트에서 어떤 엔드포인트가 비정상인지 빠르게 좁힐 수 있다.

테스트 대역과 실제 환경의 균형

테스트 대역은 외부 의존성을 분리해 빠르고 안정적인 테스트를 만들 수 있게 해준다. 다만 대역 객체가 실제 동작과 멀어지면 테스트는 통과하지만 운영에서 실패하는 상황이 생긴다. repository, HTTP client, message publisher를 모두 mock으로 대체하면 “우리 코드가 호출했다”는 사실은 확인할 수 있지만 “실제로 연결되었는가”는 검증하지 못한다.

대역을 쓸 때는 검증하려는 책임을 좁혀야 한다. 서비스가 할인 정책을 적용한 뒤 저장소를 호출하는지 보는 테스트라면 저장소 mock이 도움이 된다. SQL이 맞는지, 트랜잭션 롤백이 되는지, JSON 필드명이 외부 API 계약과 맞는지는 실제에 가까운 환경이 필요하다.

실제 DB를 쓰는 테스트는 신뢰도가 높지만 비용이 있다. 테스트 데이터 격리, 초기화 속도, 병렬 실행, 마이그레이션 적용 순서가 문제가 될 수 있다. 컨테이너를 쓰면 운영 DB와 유사한 환경을 만들 수 있지만 모든 테스트에 붙이면 CI가 느려진다. 연결부 검증이 필요한 테스트에 집중해서 두는 편이 운영 비용을 낮추는 데 도움이 된다.

주의할 점도 함께 봐야 한다.

  • mock이 많다고 단위 테스트 품질이 올라가지는 않는다. 구현 세부사항만 고정하면 리팩터링에 약한 테스트가 된다.
  • 통합 테스트가 많다고 배포 신뢰도가 자동으로 높아지지는 않는다. 느리고 불안정한 테스트는 CI 신뢰도를 낮춘다.
  • 슬라이스 테스트는 전체 통합 테스트의 대체재가 아니다. 특정 계층의 계약을 빠르게 보는 도구다.
  • 커버리지 수치가 높아도 결제 실패, 중복 요청, 타임아웃, 권한 오류 같은 위험 흐름이 빠질 수 있다.

운영 검증까지 이어지는 테스트 전략

테스트 전략은 어떤 테스트를 더 많이 쓰느냐보다 변경 위험을 어느 단계에서 잡을지에 가깝다. 작은 로직 변경은 단위 테스트에서 빨리 잡고, 저장소나 설정 변경은 통합 테스트에서 확인하고, 배포 직후에는 스모크 테스트로 핵심 흐름이 살아 있는지 본다. 장애가 한 번 난 경로는 회귀 테스트로 남겨 같은 문제가 다시 들어오지 않게 한다.

백엔드 서비스라면 pull request에서 단위 테스트와 주요 슬라이스 테스트를 실행하고, main 브랜치 병합 후 통합 테스트와 일부 회귀 테스트를 실행하는 구성이 흔하다. 배포 직후에는 로그인, 주문 생성, 상태 조회 같은 핵심 엔드포인트를 스모크 테스트로 확인한다. 프런트엔드나 CLI 도구, 데이터 파이프라인도 원리는 같다. 작은 로직, 연결부, 사용자 관점 흐름, 배포 후 생존 확인을 나누어 본다.

테스트 우선순위를 정할 때는 다음 질문이 도움이 된다.

  • 이 코드는 실패했을 때 사용자나 외부 시스템에 어떤 영향을 주는가?
  • 실패 원인을 단위 테스트만으로 충분히 좁힐 수 있는가?
  • 실제 DB, HTTP, 메시지 브로커와의 연결이 변경에 포함되어 있는가?
  • CI에서 이 테스트가 매번 돌 만큼 빠르고 안정적인가?
  • 실패했을 때 로그와 리포트가 다음 행동을 알려주는가?

단위 테스트, 슬라이스 테스트, 통합 테스트는 서로 경쟁하는 선택지가 아니다. 단위 테스트는 “이 작은 책임이 맞는가”를 묻고, 슬라이스 테스트는 “이 계층의 계약이 맞는가”를 묻고, 통합 테스트는 “실제 연결이 함께 동작하는가”를 묻는다. 이 질문을 분리해두면 테스트 코드는 읽기 쉬워지고, CI는 더 빠르게 실패하며, 배포 전 확인해야 할 위험도 선명해진다.

원문 참고

https://www.maeil-mail.kr/question/83

댓글

이 블로그의 인기 게시물

아이콘 폰트 (icomoon 사용법)

 장난감 프로젝트를 만들다 보면, 아이콘이 필요한 경우가 있다. 간단하게 아이콘을 인터넷에서 검색하여, 이미지로 넣어두고 이미지 태그를 이용하여, 사용하는 경우가 일반적이였지만...  요즘에는 대부분 폰트를 이용하여 아이콘을 노출 한다. 나 같은 경우에도 기본적으로  https://material.io/resources/icons 를 참고하여 아이콘 폰트를 이용할 수 있도록 처리하고, 추가적으로 필요한 아이콘이고, 일상적으로 사용 되지 않는 아이콘의 경우에는  https://icomoon.io 에서 제작하여, 아이콘 폰트로 이용 하곤 한다.  그래서 이번에는 아이콘  https://icomoon.io 의 사용법을 간단히 공유하고자 한다.   들어가자 마자 위의 icoMoonApp버튼을 누르면 아래와 같은 화면이 나타난다.  icomoon에서 무료로 제공하는 아이콘들이 보이면 위에 파란색으로 표시 되어있는 집 모양 세가지를 선택한 후, 아래의 빨간색으로 표시되어있는 Generate Font를 눌러보자.  그리고 나서 바로 다운로드를 요청해보자. icomoon.zip이 다운로드가 될텐데, 압축을 해제해 보면, 아래의 폴더 및 파일들이 있다. 아래에서 중요한 것은 font 폴더와 style.css이다. demo-files fonts demo.html Read Me.txt selection.json style.css <!doctype html > <html> <head> <link rel ="stylesheet" href ="style.css" ></head> </head> <body> <span class ="icon-home" ></span> <span class ="icon-home2" ></span> <span class ="icon-hom...

Chart js와 amchart 비교

Chart js 특징은 위의 그림으로 대체 할 수 있을 듯 하다. 오픈 소스이고, 기본으로 제공하는 차트 종류가 8가지 Canv a s를 이용해서 차트를 그리고, 반응형을 지원한다. amchart amchart는 기본적으로 유료이며, 기본으로 제공하는 차트 종류가 기본적인 차트 + 주식 처럼 보이는 차트 + 지도에 관련된 차트(?) 까지 하면, 기본 제공 하는 종류가 20개 내외 이려나, 일일이 세기에는 양이 좀 많아 보인다. 렌더링은 svg를 통하여 그려지고, 당연 반응형도 지원이 된다. 그러면, 이 둘중에 어떤것이 내 프로젝트에 적합 하냐는 것이 문제이다. 일단, 주식 처럼 보이는 차트나 지도에 관련된 차트(?)가 필요하면, amchart를 선택해야 되는 것은 맞다. 그건 당연한 것이니 빼고 얘기 해보자! 여러 종류의 차트가 필요하다면, 일단은 amchart를 염두해 두는 것이 좋다. 돈 낸 만큼은 하는 듯 하다. 하지만, 기본적인 막대 그래프, 도넛 차트 등, 아주 기본적인 차트들인데, Chart js도 amchart도 그러한 차트가 없을 때가 문제가 된다. 그렇다면, 조금이라도 커스텀이 용이한 것을 찾는 것이 좋을 것이다.  일단 amchart에서 custom이라고 검색 하였을 때, 검색 결과가 61가지가 나온다. 차트의 종류도 많고, 각 차트마다 들어가는 속성이 매우 많기 때문에, 웬만한 내용들은 속성 값을 어떻게 주느냐에 따라서 변경이 가능 하게 된다. 커스텀의 예를 들면, 기본적으로 도넛 파이의 형태를 띄면서, 화살표로 목표를 표시해주는 차트가 필요하다고 생각 해보자. 이것은 amchart로 만든 그래프이고 이것은 chart js로 만든 그래프이다. 모양이 살짝 다르긴 하지만, 완벽하게 똑같이 구현 할 수도 있다. amchart로 만든 그래프의 경우, 저것은 도넛그래프가 아닌 guage 그래프이다. 원래 게이지 그래프는 이와 같...

javascript 압축 파일 다운로드

이번에는 전 게시글의 응용판? 이라고 해야하나....? 어쨋든! 우리는 각각의 파일들을 다운로드 해보았다. 그런데 생각보다 귀찮음?을 느꼇을 것이다. 파일을 각각 다운 받아야 한다는 현실때문에! 그래 파일 두개야 뭐 그렇다 치지... 하지만, 개발자도 사용자도 게으름뱅이이다. 자 결국, 우리가 해야 하는 것은 파일을 한 번에 둘다! 다운 받는 것이다. 물론, 클릭 한번에 여러개의 함수를 엮어서 다운받게 하면 되지만! 크롬에서 자주 봤듯이, 여러개의 파일을 다운로드를 시도하면 <- 여러개의 파일을 다운로드 합니다. 허용 합니까? 하고 물어보는 것을 볼 수 있다. 게다가 다운로드 한 파일들을 찾기도 귀찮다는 것. 자 해결책을 제시해보자면, https://github.com/Stuk/jszip 클라이언트 단에서 파일을 zip파일로 압축을 할 수가 있다! 필요한 작업은 아래와 같다. 0. 데이터 준비 1. BLOB(binary large object)를 만든다. 2. Blob을 URL.createObjectURL을 사용하여, 해당 binary의 주소를 생성. 3. 다운로드가 필요한 파일들을 Zip 객체에 셋팅! 4. a태그를 이용하여, 해당 url 셋팅 하고, 다운로드. 전 게시물과 별로 달라진게 없네... 자 그럼 샘플! 샘플을 보자! http://embed.plnkr.co/NMprnRxqYG0fkHa2J55D/ var util = {} function fixBinary(bin) { //binary to arrayBuffer var length = bin.length var buf = new ArrayBuffer(length) var arr = new Uint8Array(buf) for (var i = 0; i < length; i++) { arr[i] = bin.charCodeAt(i) } return buf } window.onload = function() { ...