Testing · Reliability

유닛 테스트가 보지 못하는
버그들

Mock Repository는 Postgres 커넥션을 열지 않는다. Fake HTTP 클라이언트로는 JDK의 재시도 버그가 일어나지 않는다. 여기 모은 버그 4개는 모두 실제 인프라가 있어야만 생길 수 있었다.

네 버그 모두 그 시점에 있던 유닛 테스트를 전부 통과했다. 유닛 테스트가 잘못한 건 없다. 자기가 닿을 수 있는 곳을 테스트하고 있었을 뿐이다. 어떤 실패는 코드와 실제 시스템이 만나는 경계에서만 생긴다. 데이터베이스 커넥션의 생명주기, HTTP 클라이언트의 재시도 로직, 메시지 큐의 중복 제거(dedup) 윈도우 같은 것들이다. Fake는 인터페이스를 대신해 줄 뿐, 그 실패까지 대신해 주지는 않는다.

너무 일찍 닫힌 Lob 스트림

Outbox를 같은 프로세스 안에서 동기로 비우던 방식에서, SQS를 사이에 둔 비동기 poller/consumer 구조로 옮기다가 회귀가 하나 생겼다. 실제 데이터베이스로 검증할 때만 드러난 버그였다. OutboxPoller.poll()에 @Transactional이 빠져 있었다. payload 컬럼은 지연 로딩되는 JPA @Lob이다. 그런데 메서드가 트랜잭션 경계가 아니니, 루프가 payload를 스트림으로 읽으려 할 때는 쿼리에 쓴 세션이 이미 닫혀 있었다.

// Why @Transactional is needed: OutboxEvent.payload, loaded by
// findByProcessedFalseOrderByCreatedAtAsc(), is an @Lob column — if this method itself isn't a
// transaction boundary, the session/connection used for the query is already returned by the
// time the loop below tries to lazily stream event.getPayload(), causing an
// "Unable to access lob stream" exception and silently publishing nothing.
@Scheduled(fixedDelay = 1000)
@Transactional
public void poll() { /* ... */ }

Mock Repository는 인메모리 객체를 돌려줄 뿐이다. 닫힐 세션도, 스트림으로 읽을 LOB도 없으니 이 문제를 재현할 길이 없다. 이 예외는 Postgres 커넥션 위에서 Hibernate 세션이 트랜잭션 경계에 닫힐 때만 생긴다.

아무도 테스트해 본 적 없던 401

인증 우회 버그를 고치려면 401 응답을 단언하는 테스트가 필요했다. Spring Boot로 만든 두 구현 어디에도 그런 테스트는 처음이었다. 그 테스트가 곧바로 다른 버그에 걸렸다. Spring의 기본 TestRestTemplate 요청 팩토리는 JDK의 HttpURLConnection 위에서 돈다. 이 클라이언트는 POST가 401을 받는 순간 IOException: cannot retry due to server authentication, in streaming mode를 던진다. 테스트 대상 코드의 문제가 아니라 JDK 클라이언트에 원래 있는 한계다.

@BeforeEach
void useApacheHttpClientRequestFactory() {
    // The default JDK HttpURLConnection-based factory can't handle a 401 response to a POST.
    // Swap in the httpclient5-based factory, which doesn't have this limitation.
    restTemplate.getRestTemplate().setRequestFactory(new HttpComponentsClientHttpRequestFactory())
}

Mock이나 Fake HTTP 클라이언트는 JDK의 request/response 상태 기계를 돌리지 않는다. 이 버그는 그 상태 기계 안에 있다. 소켓과 떠 있는 서버, 진짜 401 응답이 한꺼번에 맞물릴 때만 나타난다.

머지하지 않았어도 남은 교훈

이런 버그가 전부 프로덕션 코드에 들어간 건 아니다. 같은 백엔드 설계를 5개 언어로 나란히 구현해 둔 내 예제 프로젝트에서, AI 에이전트에게 언어마다 같은 "정기 송금(recurring transfer)" 기능 스펙을 주고 결과를 비교해 본 적이 있다. 이 기능을 호출할 곳이 아직 없어서 일부러 머지하지 않았다. 그 실험에서 두 건이 더 나왔고, 수정 커밋 대신 1인칭 엔지니어링 로그로 남겼다.

Go 구현은 32자 hex ID에 -YYYY-MM 접미사를 붙인 40자짜리 참조 ID를 reference_id VARCHAR(36) 컬럼에 넣고 있었다. Postgres는 이 값을 거부했는데, 두 번째 달 실행부터였다. 특정 길이 패턴의 첫 삽입은 우연히 들어맞을 수 있어서다. 인메모리 Fake Repository는 컬럼 길이 제약을 아예 검사하지 않으니, Postgres에 닿기 전에는 잡을 방법이 없었다.

java-springboot 구현에서는 @Test 메서드 3개가 한 테스트 실행 안에서 같은 월간 스케줄러를 각각 호출했다. 스케줄러의 dedup ID를 월 단위 날짜로 만들어서 세 호출 모두 값이 같았다. SQS FIFO의 5분짜리 중복 제거 윈도우가 두 번째와 세 번째 메시지를 에러 없이 버렸다. 큐에 들어간 Task는 첫 번째 테스트 것 하나뿐이었다.

머지하지 않은 버그가 바꾼 실제 코드

VARCHAR(36)에서 얻은 교훈은 이론으로 끝나지 않았다. 같은 날 계좌 송금(account-transfer) 기능을 머지해 배포했는데, 그 Go 구현은 실험에서 본 함정을 일부러 피해 간다. 접미사를 붙이면 컬럼 길이를 넘을 수 있어서, 32자 원본 ID를 접미사 없이 그대로 쓴다. 코드는 버린 실험이었지만, 거기서 얻은 교훈이 그날 오후 프로덕션 코드를 바꿨다.

네 버그의 공통점

넷 다 특이한 사례는 아니다. 빠진 애노테이션, 클라이언트 라이브러리의 알려진 한계, 컬럼 길이 제약, 중복 제거 윈도우는 모두 평범한 인프라 동작이다. 시스템을 시험하려고 지어낸 엣지 케이스와는 거리가 멀다. 넷의 공통점은 Mock이나 Fake가 만들어진 방식상 이런 실패 지점을 구현하지 않는다는 것이다. 일찍 닫힐 세션도, HTTP 클라이언트 상태 기계도, 컬럼도, dedup 윈도우도 없다.

기능이 동작한다고 믿으려면 그 기능이 기대는 실제 대상 위에서 적어도 한 번은 돌려 봐야 한다. 유닛 테스트가 틀려서 하는 말은 아니다. 유닛 테스트는 처음부터 이 부분을 보고 있지 않았다.

더 볼 자료

docs/architecture/testing.md(같은 백엔드 설계(DDD, CQRS, Outbox)를 5개 언어로 나란히 구현해 둔 내 예제 프로젝트의 Domain/Application/E2E 테스트 전략. 이 버그들은 그 바깥에 있었다) · OutboxPoller.java(Lob 스트림 버그를 고친 코드) · docs/benchmark.md(머지하지 않은 버그 두 건이 나온 AI 에이전트 실험을 1인칭으로 적은 기록) · 인증 우회 버그(401 테스트를 처음 쓰게 만든 버그)