좋은 테스트란 무엇일까, 개발자라면 모두가 마주치는 테스트 이야기
좋은 테스트란 무엇일까.
PiKi를 만들면서 코드보다 이걸 더 오래 고민한 것 같다. 어디까지 테스트를 짜야 잘 짠 걸까. 다 짜면 잘한 걸까.
구글에 "is TDD required?"를 쳐 보면 AI가 이렇게 답한다. "Technically, no."

엄격히 필수는 아니지만, 실무에선 코드 품질과 유지보수를 위한 기본기로 본다는 거다. 한국어로 "TDD는 필수"를 쳐도 비슷하다. 실무에서 정말 유용한가, 진짜 그럴 가치가 있나, AI가 코드를 짜 주는 시대에도 TDD가 필수인 이유는 뭔가. 다들 여전히 묻고 있다.

그만큼 유행했고, 그만큼 아직도 논쟁적이다. 그래서 내 테스트 이야기도 TDD에서 시작할 수밖에 없다.
먼저, TDD는 좋은 방법이다
내가 개발 공부를 시작했을 때 TDD가 한창 유행이었다. TDD 관련 블로그 글만 해도 수백, 수천 개가 쏟아졌고, "야 너 TDD 알아?" 같은 말도 꽤 들었던 것 같다. 그렇게 번진 데는 그럴 만한 이유가 있다.
테스트를 먼저 쓰면 "이 코드가 무엇을 해야 하는지"를 구현보다 먼저 정의하게 된다. 그래서 인터페이스가 깔끔해지고, 빨강에서 초록으로 가는 작은 사이클을 반복하니 디버깅 범위도 좁다. 커버리지는 자연스럽게 따라오고, 한 번 깔아 둔 테스트가 안전망이 되어서 나중에 리팩터링할 때 두렵지 않다.
그래서 TDD를 원칙으로 삼는 개발자가 많고, 나도 그게 좋은 방법이라는 데는 이견이 없다.
그런데 이상과 현실은 트레이드오프가 있다
문제는 이상적인 이론은 다들 알고 있다는 거다. 진짜 변수는 그걸 어디서 쓰느냐다.
현실의 프로젝트는 대부분 마감이 빡세게 정해져 있다. 그리고 소프트웨어 생명주기에서 내 시간만 쓸 수 있는 게 아니다. 구현하고, 프론트로 핸드오프하고, 그 사이 요구사항이 바뀌고, 기획이 수정되고, QA에 입고하고, 거기서 또 이슈가 올라온다. 이 모든 단계가 정해진 기간 안에 맞물려 돌아간다.
이런 상황에서 모든 코드를 TDD로 빈틈없이 덮으면, 커버리지는 완벽해지지만 정작 기능이 늦게 나온다. 신뢰도를 얻는 대신 속도를 내주는 거래다.
디프만처럼 기간이 정해진 프로젝트에서, 나는 그 둘의 균형을 어디에 둘지부터 정해야 했다.
균형의 기준, 결함이 몰리는 곳
균형을 잡을 때 거창한 원칙을 세우진 않았다. 소프트웨어 테스트에서 흔히 말하는 두 가지 정도만 머릿속에 두고 짠다.
하나는 결함 집중이다. 버그는 코드 전체에 고르게 퍼지지 않고 특정 구간에 몰린다. 다른 하나는 살충제 패러독스다. 같은 테스트만 반복하면 그게 잡던 버그는 사라지고 안 보던 곳에서 새 버그가 나온다.
이 둘을 합치면 답은 단순해진다. 해피패스를 여러 번 확인하는 것보다, 버그가 실제로 튀어나오는 자리를 막는 게 같은 시간 대비 신뢰도가 높다. 그리고 내 경험상 서버에서 사고가 나는 자리는 거의 정해져 있었다. 정상 응답이 아니라 에러 응답 쪽이다.
API 에러를 설계하는 방식, 복구 가능한 에러는 전부 4xx로
테스트 전략을 말하기 전에, 그 전제가 되는 API 설계 방식을 먼저 말해야 한다.
나는 에러를 두 종류로 나눈다. 복구 가능한 에러와 복구 불가능한 에러다. 클라이언트가 입력을 고치거나 다른 요청을 보내면 해결되는 에러는 복구 가능한 쪽이다. 반대로 서버가 직접 손봐야만 하는 것, 애초에 예상하지 못한 것은 복구 불가능한 쪽이다.
그래서 복구 가능한 에러는 가능한 한 전부 커스텀 예외로 등록해서 4xx로 명시한다. "이 요청은 이래서 거절됐다"를 코드와 응답에 분명히 박아 두는 거다. 그리고 5xx는 정말로 서버가 책임져야 하는, 매핑되지 않았거나 예상 못 한 에러에만 남겨 둔다. 이쪽은 숨기지 않고 그대로 터지게 둔다. fail fast에 가까운 태도다. 조용히 삼켜서 200인 척하는 것보다, 빨리 드러나서 고쳐지는 게 낫다.
이렇게 나눠 두면 에러가 곧 API 계약의 일부가 된다. 그리고 계약이 명확해지면 테스트할 대상도 명확해진다.
그래서 목표는 모든 4xx·5xx를 검증하는 것
PiKi 테스트의 1차 목표는 여기서 나온다. 컨트롤러가 200을 잘 내려주는 건 당연한 거고, 그 위에 내가 정의한 모든 에러 응답이 의도한 모양으로 나가는지를 검증한다. 커스텀 예외가 제대로 그 status와 에러 코드로 변환되는지까지.
이게 다짐이 아니라 실제 테스트로 박혀 있다. 예외 처리에 이런 갭이 있었다. Spring 표준 MVC 예외들이 catch-all 핸들러에 먼저 먹혀서 전부 500으로 새 나가던 거다. 깨진 JSON을 보내도 500, 지원 안 하는 Content-Type을 보내도 500. 클라이언트는 자기가 뭘 잘못 보냈는지 알 길이 없다.
그걸 막는 회귀 테스트가 GlobalExceptionHandlerIntegrationTest다. 각 케이스가 올바른 status와 응답 바디 계약(status·code·detail)을 같이 단언한다.
@Test
fun `깨진 JSON body 는 500 이 아니라 400 으로 매핑된다`() {
mockMvc()
.perform(post("/api/v1/auth/login/google").contentType(APPLICATION_JSON).content("{ broken json "))
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.detail").isString)
}
@Test
fun `검증 실패(@Valid 위반)는 400 으로 매핑되고 detail 에 위반 필드 메시지를 노출한다`() {
mockMvc()
.perform(post("/api/v1/auth/token/refresh").contentType(APPLICATION_JSON).content("""{"refreshToken":""}"""))
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.detail").value("다시 로그인해 주세요."))
}깨진 JSON은 400, 잘못된 Content-Type은 415, UUID 자리에 문자열을 넣으면 400, POST 전용 엔드포인트에 GET을 때리면 405. 하나하나 일부러 깨뜨려 보면서 "이 입력이 들어오면 이 에러가 이 모양으로 나간다"를 고정했다. 한 번 잡아 두면 나중에 핸들러를 건드려도 같은 자리에서 다시 새지 않는다.
단위보다 통합, 그래서 진짜 DB를 띄운다
테스트 파일을 세어 보니 141개인데 대부분이 통합 테스트다. 도메인 규칙처럼 순수한 로직은 단위로 짜지만, API 흐름은 거의 통합으로 간다.
검증하고 싶은 게 "이 메서드가 이 값을 리턴한다"가 아니라 "요청이 필터, 시큐리티, 컨트롤러, 서비스, DB를 거쳐 의도한 응답으로 나간다"이기 때문이다. 그 경로 중간을 Mock으로 잘라 버리면, 정작 사고가 나는 연결 지점이 테스트에서 빠진다.
그래서 DB도 가짜로 안 띄운다. Testcontainers로 진짜 MySQL을 띄워 돌린다. 인메모리 DB로 바꾸면 빠르지만 실제 MySQL과 동작이 미묘하게 달라서, 테스트는 통과하는데 운영에서 깨지는 함정이 생긴다.
통합 테스트는 컨텍스트 띄우는 비용이 비싸서, 모든 통합 테스트가 하나의 공유 컨텍스트를 쓰게 했다. IntegrationTestSupport 하나를 상속하면 같은 스프링 컨텍스트가 캐시돼 재사용된다.
@SpringBootTest(
properties = [
"admin.enabled=true",
"admin.local-bypass=true",
"admin.scheduler-auto-dispatch=false",
],
)
@Import(TestcontainersConfig::class, IntegrationStubs::class)
abstract class IntegrationTestSupport여기서 scheduler-auto-dispatch=false가 작은 디테일인데 중요하다. 예약 발송 스케줄러가 테스트 도중 백그라운드로 발사되면 결과가 들쭉날쭉해진다. 그래서 주기 폴링을 꺼 두고, 예약 로직은 테스트가 직접 호출해 결정적으로 검증한다.
동시성은 진짜로 부딪혀 봐야 잡힌다
진짜 DB를 띄우는 게 가장 빛을 보는 곳이 동시성이다. 단위 테스트로는 절대 못 잡는 종류의 버그가 여기 몰려 있다.
PiKi는 구글, 카카오, 애플 세 가지 provider로 소셜 로그인을 지원한다. 그래서 auth 도메인에 이런 케이스가 있었다. 같은 소셜 계정으로 첫 로그인이 동시에 여러 번 들어오는 경우다. 각 요청은 "이 소셜에 연결된 user가 없네, 그럼 만들자"를 실행하는데, 이게 동시에 일어나면 user가 중복으로 생기거나 UNIQUE 제약에 부딪혀 500이 난다. 평소엔 멀쩡하다가 트래픽이 몰리는 순간에만 터지는 부류라, 한 번 짜 두지 않으면 운영에서야 발견한다.
그래서 스레드 네 개를 같은 출발선에 세워 두고 동시에 출발시키는 테스트로 고정했다.
@Test
fun `같은 소셜로 동시 첫 로그인해도 한 user 로 합류하고 500 이 터지지 않는다`() {
val start = CountDownLatch(1)
val resolvedIds = synchronizedList(mutableListOf<UUID>())
repeat(4) {
pool.submit {
ready.countDown()
start.await() // 네 스레드가 같은 순간에 출발
runCatching { socialAccountService.resolveUser(userInfo, null).id }
.onSuccess { resolvedIds.add(it) }
.onFailure { errors.add(it) }
}
}
start.countDown()
assertEquals(emptyList(), errors) // 예외(500)가 나면 안 되고
assertEquals(4, resolvedIds.size) // 모두 user 를 받았고
assertEquals(1, resolvedIds.toSet().size) // 전부 같은 한 user 로 합류해야 한다
}여기엔 두 가지 디테일이 있다. 하나는 이 테스트가 @Transactional 자동 롤백을 안 쓴다는 거다. 각 스레드의 트랜잭션이 실제로 커밋돼야 충돌이 재현되기 때문에, 끝나고 만든 행을 직접 지운다. 다른 하나는 출발 전에 네 스레드가 모두 준비됐는지 CountDownLatch로 강제하는 거다. 이걸 안 하면 느린 CI에서 일부 스레드가 준비되기 전에 출발이 풀려 동시성이 약해지고, race가 안 터진 채로 테스트가 거짓 양성으로 통과해 버린다. 살충제 패러독스를 막으려고 짠 테스트가 정작 아무것도 못 잡는 셈이다.
Mock 라이브러리 대신 손으로 짠 stub
PiKi 테스트에는 Mockito도 mockk도 거의 없다. 찾아보면 0건이다.
대신 외부 경계만 손으로 만든 stub으로 막는다. FCM, 애플 알림 검증, Gemini 호출처럼 내가 통제할 수 없는 진짜 바깥만 가짜로 두고, 그 안쪽(서비스, 도메인, DB)은 전부 실제 빈으로 돌린다.
Mock 프레임워크를 안 쓰는 이유는 구현과 강하게 묶이기 때문이다. 내부 호출을 하나하나 흉내 내기 시작하면, 리팩터링으로 내부가 바뀔 때마다 테스트가 같이 깨진다. 테스트가 "동작"이 아니라 "구현"을 검증하게 되는 거다.
손으로 짠 stub은 인터페이스만 만족시키면 된다. 그리고 공유 컨텍스트라 stub이 단일 인스턴스인 만큼, 기본 동작을 그냥 두지 않고 예외를 던지게 해 뒀다.
var onSend: (List<String>, Notification, Int) -> List<String> = { _, _, _ ->
error("stub.onSend 를 테스트 본문에서 명시 세팅해야 한다.")
}이러면 각 테스트가 자기 시나리오를 본문에서 명시적으로 세팅하게 강제된다. 깜빡하면 즉시 터지니까, 이전 테스트가 남긴 stub 상태가 다음 테스트에 슬쩍 영향을 주는 함정이 원천 차단된다.
정리하며
개발자는 각자의 테스트 전략을 가지고 있을 거다. 나는 PiKi를 만들면서 이 전략으로 갔고, 덕분에 에러 응답이 흐트러지지 않는다는 걸, 핸들러를 고쳐도 같은 자리에서 다시 새지 않는다는 걸 확인하면서 개발할 수 있었다.
아무리 실력이 좋은 개발자여도 100% 이슈 없는 개발을 할 순 없다. 그래도 컴퓨터는 거짓말을 안 하니까, 테스트 전략을 확실하게 가져가면 신뢰할 수 있는 소프트웨어에 가까워지지 않을까 생각한다.
그렇다고 모든 코드에 테스트를 다는 건 비효율적이다. 거기에 매몰되지 않으면서, 합리적이고 실용적인 나만의 기준을 갖는 게 중요하다고 본다. TDD가 거의 필수처럼 여겨지는 지금도, 마감이 있는 현실에서는 결국 어디까지 테스트할지 선을 그어야 한다. PiKi에선 지금의 기준으로 그었고, 다음 프로젝트에선 또 조정하게 될 것 같다.
no comments yet.