"테스트요? 네, 나중에 붙이려고요."

이 글을 쓰게 된 계기
저는 한동안 테스트 코드를 '있으면 좋은 것' 정도로만 생각했습니다.
게시글 수정 API 하나를 확인한다고 가정해 보겠습니다. 서버를 띄우고, 로그인해서 토큰을 받고, Postman으로 요청을 보내고, 응답을 확인하고, 혹시 몰라서 DB도 한 번 열어봅니다. 한 번 하는 건 괜찮습니다. 그런데 이걸 예외 처리를 추가할 때도, 리팩터링할 때도, 버그를 고칠 때도 반복하고 있으면 슬슬 회의감이 생깁니다.
'이거 매번 내가 손으로 해야 하나?'
결정적이었던 건, 한 번은 서비스 로직을 좀 정리했는데 기존에 잘 되던 기능이 깨져 있었던 적이 있었습니다. 제가 수정한 부분은 멀쩡했는데, 그 로직을 쓰고 있던 다른 쪽에서 터진 것이었습니다. 수동으로 확인할 때는 제가 고친 곳만 보게 되니까, 이런 건 놓칠 수밖에 없습니다.
그때부터 테스트 코드를 진지하게 생각하기 시작했습니다. 이 글은 그 과정에서 정리한 것들을 모아본 시도입니다.
테스트 코드를 내가 이해한 방식
테스트 코드가 뭐냐고 물으면 보통 "내 코드가 의도대로 동작하는지 확인하는 코드"라고 답합니다. 틀린 말은 아닌데, 실무에서 체감한 정의는 좀 다릅니다.
제가 느낀 테스트 코드의 핵심은 두 가지입니다.
첫째, 검증을 제가 아니라 코드가 합니다. 제가 눈으로 응답 보고 "이거 맞네" 하는 게 아니라, assert가 대신 판단해줍니다. 둘째, '이 기능은 이렇게 동작해야 한다'는 기대가 실행 가능한 형태로 남습니다. 노션 문서에 "로그인 실패 시 401을 반환한다"고 적어놔봤자 코드가 바뀌면 그 문서는 아무도 고치지 않습니다. 그런데 테스트 코드는 코드가 바뀌면 같이 깨집니다. 그래서 테스트가 통과하고 있다는 건, 적어도 그 시점에 그 동작이 보장되고 있다는 뜻이기도 합니다.
반복 가능한 검증 절차이면서,
동시에 현재 시스템이 보장해야 하는 동작을 표현하는 실행 가능한 문서.
저는 이 정도가 테스트 코드에 대한 정의라고 생각합니다.
테스트 코드가 필요한 이유 — 체감이 컸던 것부터
테스트의 이점을 나열하자면 한도 끝도 없는데, 실제로 겪어보면서 '이건 진짜 크다'고 느꼈던 것 위주로 정리해 보겠습니다.
1. 리팩터링이 가능해집니다
이게 제일 컸습니다.
테스트가 없으면 구조를 바꾸는 게 두렵습니다. 오래된 코드, 중첩 조건이 세네 겹 들어간 메서드, 의도를 알 수 없는 변수명들. 건드리고 싶은데 건드리면 어디서 터질지 모릅니다. 그래서 결국 안 건드리게 됩니다. 코드는 점점 나빠지고, 나중에는 더 건드릴 수 없게 됩니다.
그런데 테스트가 있으면 얘기가 다릅니다. 구조를 바꾸고 테스트를 돌려봅니다. 통과하면 '적어도 기존 동작은 유지되고 있다'는 근거가 생깁니다. 이 안전망이 있느냐 없느냐가, 코드를 개선할 수 있느냐 없느냐를 사실상 결정합니다.
2. 수정하지 않은 곳이 깨졌는지 알 수 있습니다
앞에서도 말씀드렸지만, 수동 테스트의 가장 큰 함정은 제가 고친 곳만 확인하게 된다는 점입니다. 회원가입 쪽 로직을 살짝 고쳤는데, 그게 주문 쪽에 영향을 줄 수도 있습니다. 주문 쪽은 확인하지 않습니다. 테스트가 있었으면 잡혔을 문제를, 운영에 올라간 뒤에야 발견하게 됩니다.
테스트 코드를 전체로 돌리면 이런 사각지대가 줄어듭니다. 완전히 없앨 수는 없지만, 적어도 커버된 영역에서의 사이드 이펙트는 빠르게 잡힙니다.
3. 설계가 나빠지고 있다는 신호를 줍니다
이건 좀 나중에 깨달은 건데, 테스트를 쓰려고 하면 자연스럽게 코드 구조에 대한 질문이 생깁니다.
- 이 메서드 안에서 현재 시간도 읽고, 외부 API도 호출하고, DB도 건드리는데 이걸 어떻게 테스트하지?
- 하나의 클래스가 너무 많은 일을 하고 있는 건 아닌가?
- 이 의존성을 주입받게 바꾸면 테스트가 쉬워질 텐데?
테스트를 붙이기 어려운 코드는 대개 설계도 좋지 않습니다. 그래서 테스트를 작성하려는 시도 자체가 설계 피드백이 됩니다. 이건 직접 겪어봐야 체감이 옵니다.
그 외에도...
- 살아있는 문서 역할: 주석이나 위키는 코드가 바뀌어도 그대로인 경우가 많습니다. 테스트는 코드가 바뀌면 같이 깨지니까, 최신 동작을 반영하는 문서에 가장 가깝습니다.
- 버그 재발 방지: 운영에서 터진 버그를 고치고 그 케이스를 테스트로 남겨두면, 같은 문제가 다시 생겼을 때 자동으로 걸립니다.
- 비용: 버그는 빨리 발견할수록 저렴합니다. 개발 중에 잡으면 코드 수정이면 끝이지만, 운영에서 터지면 핫픽스에 장애 대응에 커뮤니케이션 비용까지 붙습니다.
존재하는 것과 좋은 것은 다릅니다
테스트를 좀 붙여보고 나면 다음 질문이 생깁니다. '이 테스트가 진짜 도움이 되고 있는 건가?'
실행 시간이 너무 느려서 아무도 안 돌리는 테스트. 로컬에서는 되는데 CI에서는 실패하는 테스트. 실패했는데 뭐가 잘못된 건지 알 수 없는 테스트. 구현을 살짝만 바꿔도 와르르 깨지는 테스트. 이런 것들이 쌓이면 팀은 테스트 결과를 신뢰하지 않게 됩니다. "아 그거 원래 깨져 있어요"라는 말이 나오기 시작하면 테스트는 품질 장치가 아니라 관리 부채가 됩니다.
결국 좋은 테스트는 '존재하는 테스트'가 아니라 실제로 믿고 돌릴 수 있는 테스트입니다.
FIRST 원칙
좋은 테스트의 기준으로 FIRST 원칙이라는 게 있습니다. Fast, Independent, Repeatable, Self-validating, Timely. 용어 자체는 교과서적인데, 각각이 왜 필요한지를 체감한 순서대로 정리해 보겠습니다.
1. Repeatable (가장 첫번째로 마주했던 문제)
로컬에서 테스트를 돌렸을 때는 통과했는데, CI에서 돌리면 실패합니다. 같은 코드인데 왜?
원인은 대부분 환경 의존이었습니다. LocalDateTime.now()를 직접 호출해서 테스트 결과가 시간대에 따라 달라진다든가, 외부 API 응답이 그날그날 다르다든가, 랜덤 값에 기대고 있다든가.
이런 테스트는 실패했을 때 "코드가 잘못된 건지, 환경이 달라서 그런 건지" 구분이 안 됩니다. 신뢰할 수 없습니다.
좋은 테스트는 같은 코드를 두고 결과가 바뀌지 않아야 합니다. 시간은 Clock으로 주입하고, 랜덤은 고정하고, 외부 응답은 테스트 더블로 제어합니다. 실패의 원인은 환경이 아니라 코드 변경이어야 합니다.
2. Independent (순서가 꼬이면 지옥이다)
A 테스트가 만든 데이터를 B 테스트가 씁니다. A를 먼저 돌려야 B가 통과합니다. 이런 구조가 되면, 한 테스트만 골라서 돌릴 수도 없고, 병렬 실행도 안 되고, 실패 원인 파악도 어려워집니다.
각 테스트는 자기가 필요한 데이터를 자기가 만들고, 자기가 검증하고, 자기가 정리해야 합니다. Given-When-Then 구조를 유지하면 자연스럽게 이렇게 됩니다.
3. Fast (느리면 안 돌린다)
단순한 도메인 규칙 하나 검증하는데 @SpringBootTest로 전체 컨텍스트를 띄웁니다. 외부 API를 직접 호출합니다. 이러면 테스트 하나에 몇 초씩 걸리고, 전체를 돌리면 몇 분이 날아갑니다.
느린 테스트는 안 돌리게 됩니다. 안 돌리는 테스트는 없는 테스트입니다.
핵심은 모든 테스트를 가볍게 만들라는 게 아닙니다. 검증 범위에 비해 과도하게 무겁지 않게 만들라는 것입니다. 순수한 도메인 로직은 유닛 테스트로, 외부 의존성은 Fake나 Stub으로, 통합 테스트는 정말 필요한 경계에서만.
4. Self-validating (assert가 없으면 테스트가 아닙니다)
System.out.println으로 결과를 출력해놓고 눈으로 확인하는 코드를 본 적이 있습니다. 그건 테스트가 아니라 그냥 실행 스크립트입니다. CI는 출력을 읽고 의미를 해석하지 않습니다.
assertThat, assertEquals, assertThrows로 기대 결과를 명시해야 합니다. 성공과 실패가 코드 안에서 결정되어야 자동화된 검증이라고 부를 수 있습니다.
5. Timely (나중에 쓰겠다는 건 작성하지 않겠다는 것)
이건 짧게 말씀드릴 수 있습니다. 기능 개발 끝나고 몰아서 테스트를 쓰겠다는 생각은, 경험상 거의 실행되지 않습니다. 구현이 굳어진 뒤에는 테스트하기 어려운 구조를 그대로 따라가게 되고, 설계 피드백도 이미 늦습니다. TDD를 엄격하게 따르지 않더라도, 최소한 기능 구현과 테스트를 같은 시점에 고려하는 습관은 필요하다고 느꼈습니다.
한때 잘못 생각했던 테스트에 대한 오해
"커버리지 높으면 된 거 아닌가?" — 아닙니다. 커버리지는 코드가 실행되었는지를 보여주는 지표일 뿐입니다. assert 없이 그냥 메서드를 호출만 해도 커버리지는 올라갑니다. 중요한 건 숫자가 아니라 핵심 정책과 예외를 실제로 검증하고 있느냐입니다.
"통합 테스트로 다 묶으면 더 안전하지 않나?" — 겉보기에는 그렇습니다. 그런데 하나의 통합 테스트에 분기가 너무 많이 들어가면 실행도 느리고, 실패 원인 분리도 어렵고, 다른 레이어에서 이미 검증한 걸 또 검증하게 됩니다. 좋은 전략은 모든 걸 한 종류에 몰아넣는 게 아니라, 무엇을 어디에서 검증할지 역할을 나누는 것에 가깝습니다.
마무리하면서
이 글을 쓰면서 느낀 건, 테스트에 대한 이해는 결국 직접 겪어봐야 온다는 것입니다.
글로 읽을 때는 '당연한 소리 아닌가?' 싶은 것들이, 실제로 수동 테스트를 반복하다 지쳐보고, 리팩터링하다 기존 기능이 깨져보고, CI에서 원인 모를 실패를 디버깅해보고 나서야 진짜로 와닿습니다.
'테스트는 비용이 아니라 투자'라는 말이 있는데, 저는 거기에 한 가지를 더 붙이고 싶습니다. 테스트는 변경을 두려워하지 않게 만들어주는 장치입니다. 코드를 고치는 게 두렵지 않아야 코드가 좋아질 수 있습니다. 그 용기를 주는 게 테스트라는 생각을 남겨두고 싶습니다.
다음 글에서는 이어서 이런 주제를 다뤄볼 예정입니다.
- 테스트 더블은 언제 사용하고 언제 남용이 되는가
- 레이어별 테스트 전략은 어떻게 나누어야 하는가
- Fixture와 실행 전략은 왜 유지보수성에 직접 영향을 주는가
참고 자료
'[ 인사이트 ]' 카테고리의 다른 글
| 테스트 더블은 언제 사용하고 언제 남용이 되는가 (0) | 2026.01.16 |
|---|---|
| [Spring JPA] JPA One-to-Many 관계, 양방향은 최후의 수단이다 (0) | 2025.11.26 |