| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
- 백엔드
- GitOps
- JPA
- helm
- SpringBoot
- 성능 최적화
- Kubernetes
- 데이터베이스
- grafana
- prometheus
- webflux
- 동시성제어
- spring boot
- RDBMS
- netty
- redis
- NIO
- mysql
- selector
- DevOps
- 백엔드개발
- 성능최적화
- 트랜잭션
- Kotlin
- CloudNative
- monitoring
- jvm
- kafka
- docker
- Java
- Today
- Total
유성
테스트 코드, '작동'을 넘어 '지속 가능한 시스템'으로 본문
개발자라면 항상 고민하는 문제가 있다.
"빠르게 변하는 비즈니스 요구사항 속에서도, 어떻게 시스템의 안정성을 지킬 것인가?" 이다.
테스트 코드는 그 질문에 대한 가장 확실한 대답이였다.
그러나 현실적으로 '커버리지 100%'를 채우는 것이 능사는 아니다. 개발자에게 시간은 한정적이다.
오늘은 실무에서 느낀 테스트 코드의 진짜 목적과, 복잡한 분산 환경에서 테스트를 작성하며 마주한 난관들에 대해 이야기해보려 한다.
1. 왜 테스트 코드를 작성해야 하는가?
테스트 코드의 장점으로 '버그 예방'을 생각할 수 있으나, 내가 경험한 가장 큰 가치는 '과감한 리팩터링을 가능하게 하는 안전장치' 이다.
지금 막 시작한 프로젝트라고 한다면 테스트 코드를 쓴다는 것이 어려울 수 있다.
기능이 몇번 사용되고 버려진다거나 구조가 바뀐다거나 프로젝트의 개발기간이 짧다거나 말이다.
개발 관점에서는 참 아쉬운 일이지만, 사업 관점에서는 불가피한 선택이라는것도 이해가 된다.
그리고 서비스가 단순한 상태라고 한다면 크게 문제가 발생하지 않을 수 있다.
하지만 시스템이 성장하는 단계에서 내가 정의한 테스트 코드는 다음과 같다.
"레거시 청산의 유일한 무기"
최근 진행했던 프로젝트에서 Java 1.6 기반의 레거시 시스템을 17로 마이그레이션하고, Azure Cloud로 전환하는 작업을 주도했다.
10년 넘게 쌓인 비즈니스 로직을 건드리는 것은 마치 젠가 게임과 같았다.
구조가 변경되고 ibatis 대신 mybatis를 사용하면서 점진적인 개선이 아니라 1000개가 넘는 컴파일 에러를 한번에 잡아야 하는 문제가 발생했다.
이 때 가장 먼저 한 일은 코드를 수정하는 것이 아니라, 기존 동작을 보장하는 테스트 코드를 작성하는 것이였다.
무작정 버전을 올리고 에러를 잡는 방식 대신, 다음과 같은 안전한 순차적 접근을 택했다.
- 기존 환경(Java 1.6)에서 동작을 보장하는 테스트 작성
- Mapper 정상 작동 확인
- 버전 교체 (1.6 → 17) 및 컴파일 에러 수정
- 테스트 재실행을 통한 기능 검증
테스트가 통과하는 '초록불'을 보며 비로소 안심하고 구조를 뜯어고칠 수 있었고, 만일 테스트 코드가 없었다면 클라우드 전환과 빌드 구조 개선은 불가능했을 것이다.
"살아있는 문서"
복잡한 인증 로직이나 특수한 정책은 코드만 보고서는 이해하기 어렵다. 예외 처리가 덕지덕지 붙어있는 트랜잭션 스크립트 패턴의 코드라면, 분석하는 시간보다 새로 만드는 게 더 빠를 것 같다는 생각이 들기도 한다.
여기서 잘 짜인 테스트 코드는 그 자체로 '실행 가능한 명세서'가 된다. "이럴 땐 어떻게 동작해요?" 라는 질문에 말로 설명하는 대신, 테스트 케이스를 보여주는 것이 훨씬 명확한 방법이다.
2. 어디서부터, 어떻게 시작해야 하는가?
"모든 것을 테스트할 수 없다. 하지만 중요한 것은 반드시 테스트해야 한다."
핵심 도메인부터 시작하자
테스트 코드를 처음 도입할 때 가장 흔한 실수는 Getter/Setter나 단순 CRUD부터 작성하는 것이다.
나는 '장애가 났을 때 가장 치명적인 로직' 부터 시작한다.
리스크 순위를 매기면 다음과 같다.
- 1순위: 과금, 결제, 정산 로직
- 2순위: 문제가 발생하면 서비스 이용이 어려운 로직 (예: 로그인)
- 3순위: 문제가 발생해도 우회 가능하거나 비중이 크지 않은 로직 (예: 닉네임 변경 등)
만약 개발할 기능에 대한 명세의 명확성이 크다면 테스트 코드를 먼저 작성하는 TDD도 전략적인 방법이 될 수 있다.
테스트의 계층 전략
실무에서 다음과 같은 비율로 테스트 작성을 지향한다.

- 단위 테스트 (Unit Test, 70%): Mockito 등을 활용해 도메인 로직(POJO) 검증에 집중합니다. 속도가 빠르고 피드백이 즉각적이며 환경에 구애받지 않는다.
- 통합 테스트 (Integration Test, 20%): 실제 DB나 Spring Context가 로드된 상태에서의 동작을 검증한다.
@SpringBootTest를 사용하며, 트랜잭션 롤백 등을 신경 쓴다. - E2E 테스트 (End-to-End, 10%): 핵심 사용자 시나리오(로그인 -> 주문 -> 결제)를 검증합니다.
단위 테스트의 비중을 높게 두는 이유는 '빠른 피드백 루프' 때문이다.
검증 범위가 좁아 원인 파악이 쉽고, 실행 속도가 빨라 개발 과정에서 반복적으로 수행하기 부담이 없다.
반면 통합/E2E 테스트는 환경 구축(Redis, MySQL 등) 및 타 부서 협업 비용이 발생하므로 꼭 필요한 시나리오 위주로 점진적 구성한다.
- 통합 테스트: CI/CD 환경에서의 임시 컨테이너(Redis, MySQL 등) 세팅 및 테스트 데이터 관리 비용 발생
- E2E 테스트: 연관 부서의 협조 요청 등의 비용 발생
3. 테스트의 어려움과 극복 (분산 환경과 비결정성)
로컬에서는 잘 되던 테스트가, 실제 운영 환경과 유사한 구조에서는 깨지기 시작한다. 대표적인 어려움들이다.
1) 외부 의존성 제어의 어려움 (TestContainers)
MySQL, Redis, Kafka 등을 사용하는 코드를 테스트할 때, 인메모리 DB(H2)는 실제 운영 환경의 동작(특정 함수 지원 여부, 락 동작 방식 등)과 다를 수 있다.
이를 해결하기 위해 TestContainers 도입을 적극 권장한다. Docker를 이용해 테스트 실행 시점에 실제와 동일한 버전의 DB 컨테이너를 띄우고 테스트 후 파기한다.
결과: 실제 프로덕션과 동일한 PostgreSQL 환경을 컨테이너로 띄워 인덱스 동작을 정확히 검증할 수 있다.
2) 동시성 문제 (Concurrency)
"가끔 실패하는 테스트"는 최악이다. 선착순 쿠폰 발급이나 재고 차감 같은 로직은 동시에 여러 요청이 몰릴 때만 문제가 발생한다.
이를 해결하기 위해 CountDownLatch나 ExecutorService를 활용해 의도적으로 동시에 수백 개의 스레드를 발생시키는 테스트를 작성한다.
결과: 단위 테스트에서 부하를 검증할 수 있다.
3) 비동기 시스템 테스트 (Kafka)
Kafka와 같은 메시지 큐를 타는 로직은 '요청'과 '결과'의 시점이 다르다. "메시지를 보냈다"는 확인되지만, "처리가 완료되었다"는 어떻게 보장할까?
Awaitility 라이브러리를 사용하여 "특정 상태가 될 때까지 기다림(Polling)"을 구현하거나, 테스트용 Consumer Group을 별도로 두어 메시지 발행 여부를 검증한다.
또 이벤트 기반 처리에서 자주 발생하는 문제가 있다.
아래는 SOA 아키텍처 내에서 동기 또는 비동기로 이벤트 처리를 한다고 가정하자.
- A (DB 데이터 삽입) → B 에게 비동기 이벤트 발행
- B (DB 검색 - 데이터 없음)
- A (DB 트랜잭션 커밋)
이벤트 처리 관련해서 가장 많이 발생하는 문제로 A가 데이터를 삽입했지만, B가 해당 트랜잭션 ID를 Read View와 비교하고 Empty Result를 반환한 경우에 해당한다. (단순하게 표현해서 commit을 안했으니 읽을 데이터가 없는 경우에 해당)
이러한 타이밍 이슈는 분산 환경에서 빈번히 발생한다. 따라서 @TransactionalEventListener(phase = AFTER_COMMIT)을 활용해 DB 커밋이 확실히 보장된 후에 이벤트를 발행하거나, 'Transactional Outbox 패턴'을 고려해야 한다.
시스템의 복잡도를 낮추기 위해 섣부른 추상화보다는 명확한 인과관계를 코드에 드러내는 것이 중요하다.
마치며
테스트 코드는 단순히 버그를 잡는 망치가 아니다. 시스템의 '지속 가능한 성장'을 담보하는 기반이다.
대규모 데이터를 다루고 높은 신뢰성이 요구되는 환경일수록, 테스트는 선택이 아닌 필수 생존 전략이다.
나는 앞으로도 "동작하는 코드"에 만족하지 않고, "검증된 코드"를 통해 사용자에게 신뢰를 주는 서비스를 지향할 것이다.
요즘은 AI를 활용해 테스크 커버리지를 높이 유지하는 방법도 가능하다. 구조가 변경될 때 테스트 코드를 수정하지 않고 새로 만드는 방법도 어렵지 않은 방법이 되었다.
다음 글에서는 어떤 코드가 테스트코드를 작성하기에 용이하고, 분산 환경의 어려움은 어떤것이 있는지 알아보자.
'테스트코드 & 정적분석' 카테고리의 다른 글
| 테스트 코드 작성 2편 동시성 이슈와 외부 의존성 제어 (0) | 2025.12.23 |
|---|---|
| 테스트 코드 작성 1편 테스트가 고통스럽다면, 설계를 의심하라 (feat. POJO & Time) (0) | 2025.12.22 |
| 문제 발생시 커널을 조사하는 방법 (0) | 2025.12.08 |
| 캐시와 메모리 모델: 자바 멀티스레드 핵심 3가지 이슈 (3) | 2025.07.21 |
| JVM 메모리 참조 – GC와 참조 상태의 변화 흐름 (2) | 2025.07.16 |