Ch.21 유사 사례와 키워드 정리¶
앞에서 Mock 과다 테스트의 위험성, Test Pyramid, Test Double의 종류, Mock을 써야 하는 곳과 쓰면 안 되는 곳의 경계를 확인했다. 같은 원리가 적용되는 유사 사례를 보고, 실무에서 어떻게 하는지 정리한다.
21-6. 유사 사례¶
Spring @DataJpaTest: DB Integration Test의 정석¶
Java Spring 생태계에서는 @DataJpaTest라는 어노테이션이 있다. JPA(ORM) 관련 컴포넌트만 로딩해서 실제 DB(보통 H2 인메모리)와 연결한 상태에서 Repository를 테스트한다.
@DataJpaTest
class OrderRepositoryTest {
@Autowired
private OrderRepository orderRepository;
@Test
void testSaveAndFind() {
Order order = new Order(userId: 1, productId: 1, quantity: 2);
orderRepository.save(order);
Order found = orderRepository.findById(order.getId()).orElseThrow();
assertEquals(2, found.getQuantity());
}
}
(Python 사용자라면 pytest + SQLAlchemy + SQLite 조합이 같은 역할이다. 앞에서 본 db_session fixture 패턴이 정확히 이 구조다.)
@DataJpaTest가 하는 일: 테스트용 DB를 자동으로 띄운다 -> 엔티티에 맞는 테이블을 생성한다 -> 테스트를 실행한다 -> 끝나면 롤백한다. Repository의 쿼리가 실제로 동작하는지 확인하는 가장 가벼운 Integration Test다.
핵심: Mock으로 Repository를 대체하지 않고, 실제 DB(H2)를 쓴다. 이것만으로도 컬럼명 불일치, 쿼리 문법 오류, 트랜잭션 문제를 배포 전에 잡을 수 있다.
Testcontainers: 실제 DB를 Docker로 띄우는 테스트¶
H2나 SQLite 같은 인메모리 DB는 실제 운영 DB(MySQL, PostgreSQL)와 동작이 다를 수 있다. MySQL에서만 동작하는 쿼리가 SQLite에서는 에러 나는 경우가 있다.
Testcontainers는 이 문제를 해결한다. 테스트 실행 시 Docker로 실제 MySQL/PostgreSQL 컨테이너를 띄우고, 테스트가 끝나면 자동으로 내린다.
# Python testcontainers 예시
from testcontainers.mysql import MySqlContainer
def test_with_real_mysql():
with MySqlContainer("mysql:8.0") as mysql:
engine = create_engine(mysql.get_connection_url())
# 실제 MySQL 8.0에서 테스트 실행
...
(Java에서는 @Testcontainers 어노테이션으로 같은 걸 한다. Go에서는 testcontainers-go 패키지가 있다.)
장점: 운영과 동일한 DB 엔진으로 테스트한다. MySQL 특유의 문법이나 Isolation Level 차이를 잡을 수 있다. 단점: Docker를 띄우니까 느리다. 컨테이너 시작에 수초가 걸린다. CI 환경에 Docker가 있어야 한다.
Ch.22에서 Docker를 다룰 때, Testcontainers의 원리(namespace, cgroup으로 격리된 프로세스를 띄우는 것)가 더 분명해진다.
API Contract Testing: 외부 API의 "대본"을 관리하는 방법¶
앞의 사례에서 결제 API의 응답 형식이 바뀌어서 장애가 났다. Mock의 "대본"이 현실과 달라진 거다. 이 문제를 어떻게 해결하는가?
Contract Testing은 "API 제공자와 소비자가 응답 형식(Contract)에 대해 합의하고, 양쪽이 각자 이 계약을 검증하는 테스트"다. Pact라는 도구가 대표적이다.
소비자 측: "결제 API가 이 형식으로 응답할 거라고 기대한다"
→ Contract 파일을 생성
제공자 측: "이 Contract대로 응답하는지 내 서버를 검증한다"
→ Contract 파일을 읽고 실제 서버에서 확인
Contract가 바뀌면 양쪽 테스트가 깨진다. "API가 바뀌었으니 소비자 코드도 바꿔야 한다"는 사실을 배포 전에 알 수 있다.
실무에서 Contract Testing을 완전히 도입하는 팀은 아직 많지 않다. 하지만 최소한, Mock의 "대본"을 별도 파일로 관리하고, API 버전이 올라갈 때 대본도 업데이트하는 습관은 가져야 한다.
그래서 실무에서는 어떻게 하는가¶
1. Mock은 경계에서만, 핵심 로직은 실제 의존성으로¶
# 실무 패턴: 외부만 Mock, 내부는 실제
@pytest.fixture
def order_service(db_session):
order_repo = OrderRepository(db_session) # 실제 DB
inventory_repo = InventoryRepository(db_session) # 실제 DB
mock_payment = MagicMock() # 외부 결제만 Mock
mock_payment.charge.return_value = {
"data": {"id": "pay_test", "status": "approved"}
}
return OrderService(order_repo, mock_payment, inventory_repo)
def test_full_order_flow(order_service, seed_products):
result = order_service.create_order(user_id=1, product_id=1, quantity=2)
assert result["order_id"] is not None
# DB에 실제로 저장됐는지 확인
# 재고가 실제로 줄었는지 확인
2. CI에서 Integration Test를 반드시 돌린다¶
# GitHub Actions 예시
jobs:
test:
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: test
MYSQL_DATABASE: test_db
steps:
- run: poetry run pytest tests/unit -v # Unit: 빠르게
- run: poetry run pytest tests/integration -v # Integration: DB 포함
Unit Test와 Integration Test를 디렉토리로 분리하고, CI에서 둘 다 돌린다. Unit은 빠르니까 먼저, Integration은 DB가 필요하니까 services에 MySQL을 띄운 뒤에 실행한다.
3. 테스트 디렉토리 구조¶
tests/
unit/
test_order_service.py # Mock으로 로직만 검증
test_price_calculator.py # Mock 없이 순수 함수 검증
integration/
test_order_repository.py # 실제 DB 연결
test_order_flow.py # Service + Repository + DB
e2e/
test_order_checkout.py # 서버 띄우고 전체 플로우
conftest.py # 공통 fixture
이 구조를 가져가면, pytest tests/unit으로 빠른 피드백을 받고, pytest tests/integration으로 연동을 확인하고, pytest tests/e2e로 전체를 점검할 수 있다.
4. 테스트 실패 vs 운영 장애: 시점별 비용 차이¶
같은 결함이 언제 발견되느냐에 따라 비용이 달라진다. 통상 비율로 정리한다.
| 발견 시점 | 상대 비용 | 부수 비용 |
|---|---|---|
| 로컬 (테스트 작성 중) | 1x | 본인 시간 수 분 |
| 코드 리뷰 (PR 단계) | 약 3x | 리뷰어 시간 추가 |
| CI 빌드 실패 | 약 5x | 빌드 재실행, 다른 PR 블로킹 |
| 스테이징 (수동 QA) | 약 10x | QA 시간, 재배포 |
| 운영 배포 직후 (분 단위) | 약 50x | 롤백, 모니터링 인력, 일부 사용자 영향 |
| 운영 (며칠 후 사용자 문의) | 약 100x 이상 | 사고 보고서, 신뢰도 손실, 데이터 복구 |
(상대 비용은 Boehm "Software Engineering Economics"(1981)의 결함 발견 시점별 비용 모델에서 시작된 통상 비율이다. 정확한 배수는 조직마다 다르지만 "왼쪽으로 갈수록 싸다"는 방향성은 일관되게 보고된다.)
이게 Integration Test의 가치다. Unit Test가 0.5초, Integration이 5초 더 걸려서 CI 시간이 늘어도, 운영 사고 한 건의 100x 비용에 비하면 압도적으로 싸다.
5. 도구별 비교 정리¶
Python 생태계에서 주로 쓰는 테스트 도구를 한 표로 정리한다.
| 도구 | 카테고리 | 강점 | 약점 | 1 test 실행 시간 (참고) |
|---|---|---|---|---|
unittest |
Python 표준 라이브러리 | 별도 설치 불필요, JUnit 스타일 | Fixture 표현이 장황, 픽스처 의존성 관리가 어려움 | < 1 ms |
pytest |
3rd-party 테스트 러너 | Fixture/마커/파라미터화 강력, assert 메시지 자동화 | 설치 필요 (표준 아님) | < 1 ms |
unittest.mock.MagicMock |
Mock 라이브러리 (표준) | Stub/Mock/Spy 모두 가능 | 과사용 시 깨지기 쉬운 테스트 | < 1 ms |
| SQLite in-memory + SQLAlchemy | Integration용 경량 DB | 즉시 기동, 디스크 없이 동작 | MySQL/PostgreSQL과 문법·Isolation 차이 | 약 1 ms |
| Testcontainers (Python) | Integration용 실제 DB 컨테이너 | 운영 DB와 100% 동일 엔진 | 초기 기동 약 3~10초, Docker 필요 | 약 5~50 ms |
pytest-asyncio |
async 코드용 pytest 플러그인 | async def 테스트 지원 |
이벤트 루프 관리 주의 필요 | < 1 ms |
httpx AsyncClient |
API E2E (FastAPI) | 서버 없이 ASGI 직접 호출 가능 | 진짜 네트워크는 안 거침 | 약 1~10 ms |
Playwright |
브라우저 E2E | 실제 브라우저 시나리오 검증 | 무겁고 느리고 깨지기 쉬움 | 약 1~수십 초 |
(실행 시간은 Apple Silicon arm64, macOS 26.3.1, Python 3.12.3 기준의 통상 범위다. 도구 자체 오버헤드만 잡은 수치로, 테스트 로직이 무거우면 더 늘어난다.)
언어별 대응:
| 카테고리 | Python | Java | Go |
|---|---|---|---|
| 단위 테스트 러너 | pytest, unittest | JUnit 5 | testing |
| Mock 라이브러리 | unittest.mock | Mockito | gomock, testify/mock |
| Integration DB | SQLAlchemy + SQLite | @DataJpaTest (H2) |
testify + sqlmock |
| 실제 DB 컨테이너 | testcontainers-python | Testcontainers Java | testcontainers-go |
| E2E (브라우저) | Playwright Python | Selenium, Playwright | chromedp |
6. coverage 숫자에 집착하지 않는다¶
coverage 90%인데 장애가 나는 게 앞의 사례였다. coverage가 높다는 건 "코드를 많이 실행했다"는 뜻이지, "코드가 잘 동작한다"는 뜻이 아니다.
의미 있는 테스트의 기준:
- "이 테스트가 실패하면, 진짜 버그가 있는 건가?"
- "이 테스트가 통과하면, 이 기능이 동작한다고 확신할 수 있는가?"
Mock으로 도배한 테스트는 두 질문 모두에 "아니오"다.
7. 면접 Q&A (이 챕터로 답할 수 있는 단골 질문)¶
Unit Test와 Integration Test, E2E Test의 경계는 어떻게 잡나요?
Unit은 함수/메서드 단위로 외부 의존성을 Mock으로 끊고 로직만 검증한다(ms 단위). Integration은 "내 코드 + 실제 DB"처럼 여러 모듈을 연결해서 쿼리·트랜잭션·스키마 호환성을 확인한다. E2E는 실제 서버를 띄우고 사용자 시나리오를 처음부터 끝까지 돌린다. 디렉토리도 tests/unit, tests/integration, tests/e2e로 분리해서 CI에서 각각 따로 돌리는 게 일반적이다.
Test Pyramid가 뭔가요? 왜 피라미드인가요?
Martin Fowler가 정리한 모델로, Unit Test를 가장 많이, Integration Test를 적당히, E2E Test를 가장 적게 쓰라는 비율 가이드다. 피라미드가 뒤집히면(E2E가 가장 많으면) 테스트가 느려서 피드백이 안 오고, 깨지기 쉬워서 유지보수가 고통스러워진다. 핵심은 "비용 대비 효과"의 균형이다.
Test Double의 종류를 구분해서 설명해주세요.
Gerard Meszaros가 정리한 5가지가 있다. Dummy는 자리만 채우는 객체, Stub은 미리 정해진 응답을 반환(상태 검증), Mock은 호출 여부와 인자를 기록·검증(행위 검증), Spy는 실제 객체를 감싸서 호출을 기록, Fake는 실제 시스템을 간략화한 동작하는 구현(in-memory DB 등)이다. Python MagicMock은 설정에 따라 Stub처럼도 Mock처럼도 쓸 수 있다.
Mock과 Stub의 차이가 뭔가요?
검증 관점이 다르다. Stub은 "상태 검증"으로 "이 함수를 호출하면 이 값이 나오는가"를 본다. Mock은 "행위 검증"으로 "이 함수가 몇 번, 어떤 인자로 호출됐는가"를 본다. Martin Fowler가 "Mocks Aren't Stubs"(2007)에서 이 차이를 분명히 했다. 결제 호출 횟수를 검증하면 Mock, 결제 결과로 상태가 어떻게 바뀌었는지를 검증하면 Stub 성격에 가깝다.
Mock을 쓰면 안 되는 곳은 어디인가요?
내가 통제 가능한 영역, 특히 DB·내부 로직은 Mock으로 대체하면 안 된다. Repository를 Mock으로 갈아 끼우면 컬럼명 불일치, 쿼리 문법 오류, N+1, 트랜잭션 문제를 못 잡는다. Mock은 외부 API, 이메일 발송, 결제 게이트웨이처럼 "내가 통제할 수 없는 경계"에서만 써야 한다. DB는 실제 DB(인메모리든 Testcontainers든)를 쓰는 게 원칙이다.
Coverage 90%인데 운영에서 장애가 났다면 뭐가 문제인가요?
Coverage는 "코드가 실행됐다"는 지표이지 "코드가 잘 동작한다"는 보장이 아니다. Mock으로 도배된 테스트는 라인을 다 지나가니까 Coverage는 높지만, Mock의 "대본"이 현실과 다르면 실제 동작은 검증되지 않는다. 의미 있는 테스트의 기준은 "실패하면 진짜 버그가 있는가, 통과하면 동작에 확신할 수 있는가" 두 질문이다.
DB 테스트할 때 인메모리 SQLite를 쓰는 것과 실제 MySQL을 띄우는 것, 어떤 게 맞나요?
가능하면 운영과 같은 엔진을 쓰는 게 안전하다. SQLite/H2는 빠르지만 MySQL/PostgreSQL과 문법·Isolation Level·인덱스 동작이 달라서 환경에 따라 결과가 갈린다. Testcontainers는 Docker로 실제 MySQL 컨테이너를 띄워서 이 차이를 없애준다. 컨테이너 시작이 수초 걸려서 느리다는 단점은 있지만, 운영과 동일한 엔진으로 검증할 수 있다는 가치가 크다.
오늘의 키워드 정리¶
새 키워드¶
Unit Test (단위 테스트)
함수/메서드 단위로 격리된 동작을 검증하는 테스트다. 외부 의존성을 Mock이나 Stub으로 대체해서, 오직 해당 로직만 확인한다. 실행이 빠르고(ms 단위), 실패 시 원인 파악이 쉽다. 하지만 함수 간 연동이나 DB 호환성은 검증하지 못한다. Test Pyramid의 기저를 이루는 층으로, 가장 많이 작성해야 한다.
Integration Test (통합 테스트)
여러 모듈이나 시스템이 함께 동작하는 것을 검증하는 테스트다. "내 코드 + 실제 DB"를 연결해서 쿼리 동작, 트랜잭션, 스키마 호환성을 확인한다. Unit Test보다 느리지만, Mock이 가려버린 연동 문제를 잡을 수 있다. Spring의 @DataJpaTest, Python의 pytest + SQLAlchemy fixture가 대표적인 도구다.
E2E Test (End-to-End Test)
사용자 시나리오 전체를 처음부터 끝까지 검증하는 테스트다. 실제 서버, 실제 DB, 실제 플로우를 실행한다. Test Pyramid의 꼭대기 층으로, 가장 적게 작성해야 한다. 비용이 크고 깨지기 쉬워서, 결제 같은 핵심 비즈니스 플로우에만 적용하는 게 현실적이다.
Test Double (테스트 더블)
테스트에서 실제 객체를 대체하는 가짜 객체의 총칭이다. Dummy, Stub, Spy, Mock, Fake 5가지 종류가 있다. 영화의 스턴트 더블에서 따온 용어로, Gerard Meszaros가 "xUnit Test Patterns"(2007)에서 체계화했다.
Mock (모의 객체)
호출 여부와 인자를 기록하고 검증하는 Test Double이다. "행위 검증"이 핵심이다. Python에서는 unittest.mock.MagicMock()으로 생성한다. 외부 API, 이메일 발송처럼 "내가 통제할 수 없는 것"을 대체할 때 적절하다. DB 쿼리나 내부 로직까지 Mock으로 대체하면 테스트가 실제 동작을 검증하지 못하게 된다.
Stub (스텁)
미리 정해진 응답을 반환하는 Test Double이다. Mock과 달리 "상태 검증"이 핵심이다. "이 함수를 호출하면 이 값이 나오는가?"를 확인한다. Python에서는 MagicMock(return_value=...)으로 Stub처럼 쓸 수 있다. Martin Fowler의 "Mocks Aren't Stubs"(2007)에서 둘의 차이를 설명한다.
Fake (페이크)
실제 시스템의 동작을 간략화한 대체 구현이다. Mock이나 Stub과 달리 "실제로 동작하는 로직"이 있다. In-memory DB, dict 기반 저장소가 대표적이다. Mock보다 구현 비용이 크지만, 실제 동작에 가까운 테스트가 가능하다.
Test Pyramid (테스트 피라미드)
Martin Fowler가 정리한 테스트 전략 모델이다. Unit Test를 가장 많이, Integration Test를 적당히, E2E Test를 가장 적게 작성하는 구조다. 핵심은 "비용 대비 효과"의 균형이다. 피라미드가 뒤집히면 테스트가 느리고 유지보수가 고통이다.
TDD (Test-Driven Development)
"테스트를 먼저 쓰고, 그 다음에 코드를 짠다"는 개발 방법론이다. Red(실패하는 테스트) -> Green(통과시키는 최소 코드) -> Refactor(정리) 사이클을 반복한다. Kent Beck이 "Test Driven Development: By Example"(2002)에서 체계화했다. 강제하지는 않지만, "테스트를 생각하면서 코드를 짜는 습관"은 설계를 개선한다.
Fixture (픽스처)
테스트에 필요한 사전 조건(데이터, 환경)을 준비하는 코드다. pytest에서는 @pytest.fixture 데코레이터로 정의한다. DB 세션 생성, 테스트 데이터 삽입, 테스트 후 정리 등을 자동화한다. 여러 테스트에서 같은 fixture를 공유할 수 있고, fixture끼리 의존 관계를 가질 수도 있다.
Contract Testing (계약 테스트)
API 제공자와 소비자가 응답 형식(Contract)에 합의하고, 양쪽이 각자 이 계약을 검증하는 테스트 방식이다. Mock의 "대본"이 현실과 달라지는 문제를 방지한다. Pact가 대표적인 도구다. 외부 API의 응답 형식이 바뀌면 계약 위반으로 테스트가 실패해서, 배포 전에 알 수 있다.
재등장 키워드¶
| 키워드 | 최초 등장 | 이번 챕터에서의 역할 |
|---|---|---|
| Code Review | Ch.9 | 테스트 코드도 리뷰 대상이다. Mock 과다 사용을 리뷰에서 잡아야 한다 |
| YAGNI | Ch.9 | Mock으로 100가지 시나리오를 미리 만드는 것도 YAGNI 위반이 될 수 있다 |
| Race Condition | Ch.5 | Mock 테스트는 동시성 문제를 검증하지 못한다. 실제 환경에서만 잡힌다 |
| N+1 Problem | Ch.8 | Mock으로 Repository를 대체하면 N+1을 절대 잡을 수 없다 |
| God Class | Ch.20 | 테스트하기 어려운 코드의 대표. 의존성이 뒤엉켜 있어서 격리 불가능 |
| DI (Dependency Injection) | Ch.20 | DI가 없으면 Mock을 끼워넣을 수 없다. DI는 테스트 가능성의 전제 조건 |
| SRP | Ch.20 | 책임이 하나인 클래스는 테스트도 명확하다. SRP가 곧 테스트 용이성 |
키워드 연관 관계¶
graph LR
TP["Test Pyramid"] --> UT["Unit Test"]
TP --> IT["Integration Test"]
TP --> E2E["E2E Test"]
TD["Test Double"] --> MOCK["Mock"]
TD --> STUB["Stub"]
TD --> FAKE["Fake"]
MOCK -->|"행위 검증"| BV["assert_called"]
STUB -->|"상태 검증"| SV["assert result"]
MOCK -->|"과도하면"| ANTI["실제 동작 미검증"]
IT -->|"해결"| ANTI
TDD -->|"Red-Green-Refactor"| UT
FIX["Fixture"] -->|"데이터 관리"| IT
CT["Contract Testing"] -->|"대본 관리"| MOCK
GC["God Class<br/>(Ch.20)"] -.->|"테스트 불가"| UT
DI["DI<br/>(Ch.20)"] -.->|"전제 조건"| TD
SRP["SRP<br/>(Ch.20)"] -.->|"테스트 용이성"| UT
RC["Race Condition<br/>(Ch.5)"] -.->|"Mock으로 못 잡음"| IT
N1["N+1<br/>(Ch.8)"] -.->|"Mock으로 못 잡음"| IT
style ANTI fill:#f96,stroke:#333
여기까지의 지도 (누적)¶
위의 "키워드 연관 관계"가 이번 챕터 안의 연결이라면, 아래는 Ch.1부터 지금까지 도메인별로 쌓인 키워드다. 빈 레인은 앞으로 그 챕터에서 채워진다 — 다 채워지는 과정이 곧 이 강의의 지도다. (이번 챕터에서 추가된 키워드는 굵게.)
| 도메인 | 누적 키워드 (등장 챕터) |
|---|---|
| OS·실행 | System Call·Kernel·Mode Switch·Buffer·File Descriptor(2) → CPU/IO Bound·GIL·Event Loop·async/await·Thread/Process Pool·Context Switch·IPC(3) → Process·Thread·PCB/TCB·Memory Layout·Virtual Memory·Page·TLB·OOM(4) |
| 동시성·트랜잭션 | Race Condition·Critical Section·Mutex/Lock·Deadlock·Semaphore·Starvation(5) → ACID·Transaction·Isolation Level·Dirty/Phantom Read·Pessimistic/Optimistic Lock(15) |
| 자료구조·알고리즘 | Hash Table·Time/Space Complexity·Linear Search·Load Factor(10) → Binary Search·B-Tree/B+Tree·Index·Full Table Scan·Tim Sort·EXPLAIN(11) → BFS·DFS·DAG·Topological Sort·Trie·Adjacency List(12) |
| DB·캐시·튜닝 | ORM·N+1·Lazy/Eager Loading·QEP·CBO(13) → Covering/Composite Index·Cardinality·Hash Index(14) → Slow Query·Pagination·Partitioning·Sharding·Read Replica(16) → Cache·Hit/Miss·TTL·Eviction·Cache-Aside·Redis(17) → Local/Remote Cache·Cache Invalidation·CDN(18) |
| 네트워크·분산 | TCP/IP·Socket·3/4-Way Handshake·Connection Pool·Keep-Alive·TIME_WAIT(6) → Bottleneck·Amdahl's Law·Scale-Up/Out(19) |
| 설계·AI·품질·보안 | Computational Thinking·CS 키워드(1) → LLM·Token·Context Window·Hallucination·Prompt Engineering(7) → 키워드 사전·DNS·Load Balancing·Circuit Breaker·CQRS(8) → Code Review·YAGNI·Cache Stampede(9) → SOLID·Clean Architecture·DI/IoC·God Class(20) → Test Pyramid·Unit/Integration/E2E·Mock/Stub(21) |
다음에 이어지는 이야기¶
이번 챕터에서는 테스트의 전략과 경계를 다뤘다. Mock은 경계에서만 쓰고, 핵심 로직은 실제 의존성으로 검증하고, Test Pyramid의 비율을 지킨다.
테스트까지 했다. 코드를 분리했고(Ch.20), 테스트를 짰고(Ch.21), 이제 배포만 하면 된다.
"내 컴퓨터에서는 되는데요."
이 말이 나오는 순간, 아무리 테스트가 잘 짜여 있어도 소용이 없다. 개발 환경과 운영 환경이 다르기 때문이다. Python 버전이 다르거나, 시스템 라이브러리가 없거나, 환경 변수가 빠져 있거나.
Ch.22에서는 분산 시스템의 기초를 다룬다. Docker가 왜 필요한지, Container가 뭔지, "내 컴퓨터에서는 되는데요"를 어떻게 해결하는지 파고든다.