date
slug
author
status
tags(최대 3개)
summary
type
thumbnail
category
updatedAt
지금 여러분의 테스트 코드에는 Mock이 몇 개나 있나요?
어필리에이트 백엔드 앱 3개에는 한때 Mock이 1,336개나 있었습니다. 두 달 만에 2,199개까지 불어나더니, 한 달도 안 돼 단 4개만 남았습니다. 돌이켜보니 단순히 Mock을 지운 게 아니라 Mock이 필요 없는 구조를 만드는 과정이었습니다. 통합 테스트부터 리팩토링, Lint까지 차근차근 쌓아 올린 기록을 공유합니다.
시작점: 통합 테스트 0개, Mock 1,336개
어필리에이트 서비스는 TypeScript 서버 3개로 구성되어 있습니다.
항목 | 26년 5월 |
스펙 파일 | 140개 |
통합 테스트 | 0개 |
vi.fn() 사용 | 약 1,336개 |
테스트 하나당 Mock이 평균 10개에 달했습니다. 이 점보다 중요한 건 통합 테스트가 단 하나도 없었습니다.
DB에 데이터가 제대로 저장되었는지 직접 확인할 방법이 없으니, 검증할 수 있는 수단이라곤 리포지토리의 어떤 메서드가 어떤 인자로 호출 되었는지 였습니다. 결국 Mock은 누군가의 결과물이 아니라, 당시 환경에서 시도할 수 있었던 유일한 검증 방식이었습니다.
AI가 부추기는 관성
하지만 Mock의 개수가 곧바로 줄어든 것은 아닙니다. 두 달 동안은 오히려 Mock이 더 빠르게 늘어났습니다. 6월 말에는 2,199개로 정점을 찍었습니다. 흥미로운 점은 이 기간에 작성된 테스트 커밋 246건 중 88%(216건)가 AI와 함께 만든 코드였다는 사실입니다.
AI 에이전트에게 서비스 테스트 작성을 요청하면 Mock을 적극 활용하는 방향으로 코드를 만들어냅니다. AI가 왜 이렇게까지 Mock을 양산하는지 네 가지 원인을 분석했습니다.
첫째, Mock은 파일 하나만 알면 만들 수 있습니다.
실제 DB 위에서 검증하려면 테스트 환경, 스키마, 시드 빌더, 격리 전략을 알아야 합니다. 코드베이스 전체를 아는 지식이죠. 반면 Mock은 테스트 대상의 생성자 시그니처만 보면 즉시 만들어집니다. 한 번에 읽을 수 있는 양이 정해진 에이전트에게 Mock은 가장 효율적인 경로입니다.
둘째, 테스트 통과가 곧 완료 신호입니다.
에이전트는 테스트가 통과했다를 작업 종료 조건으로 삼습니다. Mock으로 만든 테스트는 느리지 않고, 환경에 의존하지 않고, 간헐적으로 실패하지도 않습니다. 통과를 가장 확실하게 보장하는 방법이 Mock입니다. 문제는 그 통과가 무엇을 증명하는지까지는 종료 조건에 들어 있지 않습니다.
셋째, 에이전트는 옆 파일을 모방합니다.
이 세 번째가 가장 강력합니다. Mock이 1,336개 있는 코드베이스에서 새 테스트를 요청받으면, 에이전트는 그 관례를 따르는 것이 옳다고 판단합니다. 대체로 맞는 판단이기도 합니다. 결과적으로 Mock이 많은 코드베이스는 AI를 쓸수록 Mock이 더 빨리 늘어납니다. 자기강화 루프입니다.
넷째, 학습된 기본값 자체가 Mock입니다.
테스트 튜토리얼과 프레임워크 문서의 지배적 형태가 Mock 기반 유닛 테스트입니다. 별도 지시가 없으면 그 형태로 수렴합니다.
AGENTS.md에 Mock을 쓰지 말라고 적는 것으로는 막을 수 없습니다. 다음 세션의 에이전트는 그 문장을 읽고도 옆 파일에서 Mock을 보고, Mock이 이 코드베이스의 관례라고 판단합니다. 자연어 지시는 관성을 이기지 못했습니다.결론부터 말하면 이 글의 목적은 Mock을 쓰지 말자가 아니라 Mock을 쓸 수 없게 만드는 것입니다. 이어서 다룰 lint 규칙과 포트 분리는 전부 그 목적을 위한 장치입니다.
Mock을 쓰는 자리와 쓰지 않는 자리
Mock을 사용하는 경우입니다.
외부 인프라에는 Mock을 씁니다.
결제사 API, 메시지 발송, S3처럼 프로세스 밖에 있는 경계입니다. 테스트에서 진짜로 호출할 수도 없고 호출해서도 안 됩니다. 남의 서버에 실제로 문자를 보내면서 테스트할 수는 없으니까요.
재현할 수 없는 상황에도 Mock을 씁니다.
Redis가 죽었을 때 우리 코드가 어떻게 구는지는 실물 컨테이너로 만들기 어렵습니다. 컨테이너를 일부러 죽이는 것보다 명령이 항상 실패하는 Redis를 하나 두는 쪽이 정확하고 빠릅니다.
애플리케이션에서 통제 가능한 것에는 Mock을 쓰지 않습니다.
우리가 고칠 수 있는 리포지토리, 도메인 서비스, 유틸입니다. 단위 테스트는 Mock 대신 인메모리 리포지토리를 만들어 쓰고, 저장된 값에 대한 결과를 봐야 한다면 실제 DB를 띄워 통합 테스트로 검증합니다.
여기서 문제는 직접 통제할 수 있는 것을 Mock으로 만드는 일입니다. 이 글에서 말하는 Mock은 이 경우를 가리키고, 정당한 두 자리는 외부 경계의 Mock처럼 밝혀 쓰겠습니다. 참고로 Mock·Stub·Fake는 엄밀히 구분하지 않고 통틀어 Mock이라고 부르겠습니다.
Mock을 세운 결과
당시
InternalBrandsService는 목록·상세·수정을 한 클래스에 갖고 있었습니다. 생성자 인자는 세 기능이 쓰는 의존성의 합집합이고, 스펙은 it 하나하나가 그 인자를 위치로 직접 채웠습니다. 자기가 쓰지 않는 자리는 빈 객체로 채웠습니다.6월에 브랜드 상세와 수정에 플랫폼 수수료 기능을 넣으면서 의존성 두 개가 늘었습니다. 그러자 수수료와 아무 상관 없는 목록 테스트가 이런 변경을 받았습니다.
it('브랜드 목록을 매핑한다', async () => { const service = new InternalBrandsService( … categoryRepository as any, + {} as any, + {} as any, );
검증 대상도, 단언도, 출력도 그대로입니다. 인자 개수를 맞추려고 빈 객체 두 개를 넣은 것이 전부입니다. 이 테스트는 그 두 의존성을 쓰지 않는데도 바뀌어야 했습니다.
그렇게 빈 객체로 채워 둔 자리는 나흘 뒤에 테스트를 실패시켰고, 아래와 같은 커밋을 올렸습니다.
listBrands가feeResolverService.resolveBrandPlatformFeeMap을 호출하는데 "브랜드 목록을 매핑한다" 테스트는 6번째 인자를{}로 넘겨 "is not a function"으로 실패했다.resolveBrandPlatformFeeMapmock을 추가해 그린으로 복구한다.
그때 목록 테스트의 조립부는 이런 모양이었습니다.
const service = new InternalBrandsService( brandRepository as never, brandProductRepository as never, userLinkRepository as never, settlementRepository as never, categoryRepository as never, {} as never, // ← 이 테스트는 안 쓴다고 보고 비워 둔 자리 {} as never, );
listBrands에 호출 한 줄이 추가되면서 여섯 번째 {}가 실제로 쓰이기 시작했습니다. as never가 타입 검사를 꺼 놓았으니 컴파일러는 아무 말도 하지 않았고, 실패는 런타임에서 is not a function으로 났습니다.그런데 이 변경으로 목록 응답에는
platformFeeRate 필드가 새로 생겼습니다. 원래 해야 할 일은 그 필드를 단언에 추가하는 것이었죠. Mock이 알려준 것은 출력이 늘었다가 아니라 여섯 번째 자리가 비었다였고, 그래서 Mock을 하나 더 세워 그린으로 되돌린 것으로 끝났습니다. 새 필드는 지금도 아무도 검증하지 않습니다.눈여겨볼 것은 수정한 곳이 단언이 아니라 Mock을 세우는 부분이었다는 점입니다. 무엇을 검증하는지는 한 번도 바뀌지 않았고, 매번 바뀐 건 Mock을 어떻게 세우느냐였습니다. Stub을 써도 마찬가지입니다. 스펙마다 생성자 인자를 정의하고 있으니, 그 목록이 바뀌면 채워 둔 곳을 전부 손봐야 합니다.
Mock 없이 검증할 자리 만들기
Mock을 지우려면 Mock 없이 검증할 환경이 먼저 있어야 합니다. 그래서 처음 한 일은 테스트 작성이 아니라
Testcontainers와 WireMock으로 통합 테스트 환경을 만드는 것이었습니다. MySQL 컨테이너를 띄워 운영 엔티티로 스키마를 만들고, 외부 HTTP는 WireMock으로 세우고, 유닛 테스트와 통합 테스트를 별도 러너로 분리했습니다.컨테이너는 스펙이 시작되기 전에 한 번 띄우고, 붙을 주소를 스펙 쪽에 넘겨줍니다.
// test/integration/setup/global-setup.ts — vitest globalSetup, suite 당 한 번 실행된다. mysqlContainer = await new MySqlContainer('mysql:8.0') .withDatabase('affiliate_test') .withUsername('test') .withUserPassword('test') .start(); wiremockContainer = await new GenericContainer('wiremock/wiremock:3.10.0') .withExposedPorts(8080) .withWaitStrategy(Wait.forHttp('/__admin/mappings', 8080).forStatusCode(200)) .start(); // 스키마는 운영 엔티티 정의로 만든다. synchronize 는 테스트 전용이고, 운영은 마이그레이션을 쓴다. const bootstrap = buildIntegrationDataSource({ /* … */ synchronize: true }); await bootstrap.initialize(); provide('mysqlPort', mysqlContainer.getPort()); provide('wiremockBaseUrl', wiremockUrl(wiremockContainer));
포트를 고정하지 않고 매핑된 포트를 받아 넘기는 게 핵심입니다. 로컬에 뭐가 떠 있든 충돌하지 않고 CI에서도 그대로 동작합니다. 스키마를 운영 엔티티 기준으로 만드는 것도 중요합니다. 테스트용 DDL을 따로 두면 그 순간부터 두 스키마가 갈라지니까요.
WireMock은 외부 API 자리에 세웁니다.
// test/integration/harness/fixtures.ts — 계좌는 여러 스펙이 공유하므로 한 곳에 둔다. export const KNOWN_BANK_ACCOUNT = { accountNumber: '110123456789', bankCode: '004' } as const; // test/integration/infrastructure/personal-auth.int.spec.ts // 경로와 응답 규격은 인증사가 정한 것이라 경계 대역에 모아 둔다. import { personalAuthServer, VERIFY_ACCOUNT_HOLDER_NAME_PATH } from '../../support/doubles/personal-auth-server.js'; import { KNOWN_BANK_ACCOUNT } from '../harness/fixtures.js'; const PAYLOAD = { ...KNOWN_BANK_ACCOUNT, accountHolderName: 'HONG GILDONG' }; it('인증사가 성공을 주면 검증 결과를 그대로 돌려준다', async () => { // 이 경로로 POST 가 오면 이 응답을 돌려주라고 WireMock 에 등록한다. await ctx.wiremock.register( { method: 'POST', urlPath: VERIFY_ACCOUNT_HOLDER_NAME_PATH }, personalAuthServer.respondVerified(PAYLOAD), ); await expect(service.verifyAccountHolderName(PAYLOAD).then((r) => r.status)).resolves.toBe('SUCCESS'); // 응답만 보고 끝내지 않는다. 무엇이 나갔는지도 본다. const sent = (await ctx.wiremock.getReceivedRequests()).find((r) => r.url === VERIFY_ACCOUNT_HOLDER_NAME_PATH); expect(JSON.parse(sent.body)).toEqual(PAYLOAD); // 계좌 정보가 그대로 전달됐다 });
마지막 두 줄에서 계좌번호·은행코드·예금주명이 손실 없이 그대로 나갔는지를 봅니다. WireMock이 받은 요청을 기록하니 Mock을 세우지 않고도 나간 값을 단언할 수 있습니다.
한 가지 덧붙이면, 병렬 워커가 WireMock 한 대를 공유하므로 실제로는 경로 앞에 워커별 접두어를 붙입니다.
resetAll()을 쓰면 다른 워커가 걸어 둔 stub을 지우기 때문입니다.앞에서 세운 기준을 이제 실행 가능한 갈래로 옮길 수 있게 됐습니다.
테스트 작성 및 대역 활용 기준
- 저장·조회 결과를 봐야 하는 로직: 실제 DB를 이용한 통합 테스트
- DB 없이 확정할 수 있는 순수 규칙: 단위 테스트 (유닛 테스트)
- 프로세스 밖 경계 (HTTP·Redis·S3·Temporal): Mock을 두되, '호출 여부'가 아닌 '나간 값'을 기록해 검증
특히 세 번째, 프로세스 밖 경계가 중요합니다. Mock을 두더라도 단언은 호출했다가 아니라 무엇이 나갔다로 씁니다. 알림 라우팅 스펙을 예로 들겠습니다. 같은 파일의 Before/After입니다.
// Before — Mock. "sendSms 가 한 번 불렸다" const services = { smsService: { sendSms: vi.fn().mockResolvedValue(undefined) }, alimtalkService: { send: vi.fn().mockResolvedValue(undefined) }, }; it('channel="LMS"는 smsService.sendSms로 라우팅한다', async () => { await handler.handleNotification({ channel: 'LMS', receiver: '01012345678', message: '본문' }); expect(services.smsService.sendSms).toHaveBeenCalledTimes(1); });
이 단언은
sendSms라는 메서드 이름과 호출 횟수에 묶여 있습니다. 발송 어댑터를 갈아 끼우면서 메서드 이름이 바뀌면 동작이 그대로여도 깨집니다. 반대로 LMS로 보내야 할 메시지가 SMS로 나가도 이 테스트는 통과합니다. 정작 무엇이 나갔는지는 아무도 보고 있지 않으니까요.// After — 기록형 Mock. "무엇이 나갔다" class RecordingSmsTransport { public readonly sent: Record<string, unknown>[] = []; sendSms = async (payload: Record<string, unknown>) => { this.sent.push(payload); return { result_code: 1 }; }; } it('채널을 메시지 종류로 실어 보낸다', async () => { const { handler, sms } = buildHandler(); await handler.handleNotification(smsMessage({ channel: 'LMS', sender: '028888', title: '제목' })); expect(sms.sent[0]).toMatchObject({ receiver: '01012345678', message: '내용', messageType: 'LMS', title: '제목' }); });
Mock이 하는 일은 보낸 척하면서 Payload를 쌓아 두는 것뿐입니다. 단언은 쌓인 값을 봅니다. 이제 라우팅을 어떻게 구현하든, 메서드 이름을 무엇으로 바꾸든 테스트는 그대로 통과하고,
messageType이 틀리는 순간 실패합니다. 전자는 구현에 묶이고 후자는 계약에 묶이는 차이입니다.테스트 이름도 같이 바뀌었습니다.
channel="LMS"는 smsService.sendSms로 라우팅한다에는 발송 서비스의 메서드 이름이 들어 있습니다. Mock으로 짜면 테스트 이름까지 구현을 따라갔습니다.Mock 걷어내기
몇 주 전 100개가 넘는 유스케이스를 Mock 기반 유닛에서 실제 DB 위 통합 테스트로 옮겼습니다. 금액을 다루는 코드가 섞여 있어서 순서를 지켰습니다.
먼저 특성화 테스트(characterization test)를 만들었습니다. 코드가 무엇을 해야 하는지가 아니라 지금 실제로 무엇을 하고 있는지를 그대로 기록하는 테스트입니다. 정산 계산, 수수료, 브랜드 인보이스의 현재 출력값을 스냅샷으로 기록합니다.
부가세가 붙는 경로와 안 붙는 경로는 의도된 별개 규칙이므로 각 경로의 현재 값을 그대로 잠근다.
스냅샷이 깨지면 금액이 바뀐 것이므로 회귀로 간주한다.
리팩토링으로 금액이 1원이라도 바뀌면 곧바로 실패하는 테스트를 먼저 두고, 코드를 살펴봤습니다. 참고로 코드베이스에서는 이 테스트들을
test/unit/golden-master 아래에 모아 둡니다. 골든 마스터 테스트라고도 부르는데, 이 글에서는 특성화 테스트로 통일하겠습니다.정산 금액 계산을 예로 들겠습니다. 금액마다 크리에이터 요율과 플랫폼 요율을 모두 짝지어 계산한 뒤, 나온 값을 통째로 스냅샷에 기록합니다.
금액 x가지 × 크리에이터 요율 y가지 × 플랫폼 요율 z가지가 한 번에 박힙니다.// test/unit/golden-master/money-calculations.spec.ts /** 실데이터 분포를 흉내 낸 금액 그리드: 0·경계·전형(원 단위)·소수 혼합. 지면상 몇 개만 옮겼습니다. */ const SETTLEMENT_AMOUNTS = [0, 1, 707.4, 1000, 4900.5, 9999, 22515, 49900, 123456.789, 12345678.1234]; const EDGE_AMOUNTS = [-1000, Number.NaN, Number.POSITIVE_INFINITY]; const CREATOR_RATES = [0, 3.3, 5, 10, 12.3456, 20, 33]; const PLATFORM_RATES = [0, 5, 10, 20]; it('buildSettlementFromRates — 금액×커미션율×플랫폼율 전 조합', () => { const lines = SETTLEMENT_AMOUNTS.flatMap((price) => CREATOR_RATES.flatMap((creatorRate) => PLATFORM_RATES.map( (platformRate) => `price=${price} cr=${creatorRate} pr=${platformRate} => ${JSON.stringify(buildSettlementFromRates(price, creatorRate, platformRate))}`, ), ), ); expect(lines).toMatchSnapshot(); });
그러면 스냅샷 파일에 계산된 값이 저장됩니다.
// __snapshots__/money-calculations.spec.ts.snap exports[`골든마스터: BrandInvoiceEntity VAT (원 단위 trunc) > depositFromSupplyPrice 1`] = ` [ "supplyPrice=0 => 0", "supplyPrice=9 => 9", "supplyPrice=10 => 11", "supplyPrice=99 => 108", "supplyPrice=707.4 => 777", ] `;
로직의 정답 여부를 당장 몰라도 괜찮습니다. 현재 도출되는 결과를 그대로 잠가두면, 리팩토링 도중 단 1원의 오차라도 발생하는 순간 Diff로 즉시 튀어나오기 때문입니다. 경계값(
0, 음수, NaN, Infinity)과 소수점을 섞어 테스트 케이스를 구성한 이유도 이 때문입니다.그 다음 lint를 통과 상태로 만들었습니다. 당시 eslint는 에러 860여 건으로 항상 실패했습니다. 게이트가 이미 실패하고 있다면 새 규칙을 추가해도 아무것도 막지 못합니다. 테스트 코드의 lint 부채 777건을 먼저 소거하고 CI 게이트를 걸었습니다.
마지막으로 Mock 기반 유닛 테스트를 삭제했습니다. 삭제 기준은 유닛 테스트가 보던 것을 통합 테스트가 전부 커버하는지 확인하고 지웠습니다.
마치며
스냅샷으로 금액 계산을 잠그고, CI를 막던 777건의 lint 부채까지 모두 치웠습니다. 이제 기존 동작을 깨뜨리지 않고 안전하게 Mock을 지울 수 있는 판이 깔렸습니다.
하지만 안전망이 생겼다고 해서 AI가 편리한 Mock을 내려놓았을까요? 그렇지 않았습니다.
다음 글에서 컨벤션 문서 대신 lint와 컴파일러로 규칙을 강제한 과정, 아키텍처 개편, 그리고 타입 체커를 통과해 들어오던 오류를 컴파일 단계에서 차단한 이야기로 이어집니다.
강대훈 Backend Engineer
아임웹에서 어필리에이트 서비스를 개발하고 있습니다. 맨체스터 유나이티드를 응원합니다.
