테스트는 각각 독립적으로 통과했는데, 전체 테스트 스위트로 실행하면 일부 테스트가 실패하는 일이 있다. 이런 문제를 처음 만나면 테스트 순서, 실행 속도, 공유 Mock, 혹은 CI 환경을 의심하기 쉽다.
이번에는 조금 달랐다. 원인은 데이터베이스에 남은 주문 테스트의 고아 행이었다.
이상한 실패는 전체 실행에서만 나타났다
문제의 출발점은 테스트 결과의 불일치였다.
특정 주문 테스트만 실행하면 통과했다. 문제가 의심되는 다른 테스트도 단독으로 실행하면 통과했다. 그런데 전체 스위트를 실행하면 후속 테스트가 실패했다.
이런 패턴은 테스트 코드 자체가 항상 틀렸다는 뜻이 아니다. 오히려 테스트가 외부 상태에 의존하고 있을 가능성을 보여준다.
특히 주문·결제·구독처럼 여러 테이블을 함께 변경하는 기능은 다음과 같은 데이터를 남긴다.
-
주문 본체
-
주문 항목
-
회원 또는 구매 정보
-
결제 상태
-
포인트·쿠폰 사용 내역
-
구독 또는 열람권
-
주문 메모와 로그
주문 생성 도중 예외가 발생하거나 테스트 정리가 완전히 수행되지 않으면, 일부 테이블에만 행이 남을 수 있다. 이른바 고아 행이다.
고아 행은 단순한 찌꺼기가 아니었다
처음에는 남은 데이터 한두 건을 테스트 데이터 찌꺼기로 생각하기 쉽다. 하지만 문제는 그 행이 다음 테스트의 조건을 바꾼다는 데 있다.
예를 들어 후속 테스트가 다음과 같은 전제를 갖고 있다고 하자.
-
신규 회원은 주문이 없어야 한다.
-
특정 상품의 미결제 주문은 하나만 존재해야 한다.
-
주문 번호 또는 외부 식별자는 중복되지 않아야 한다.
-
특정 회원의 구매 이력이 비어 있어야 한다.
-
결제 대기 주문을 조회하면 테스트에서 생성한 한 건만 나와야 한다.
앞선 테스트가 실패하면서 주문 행만 남겼다면, 후속 테스트는 자신이 만든 데이터 외에 이전 테스트의 흔적까지 보게 된다.
그 결과는 다양하게 나타난다.
-
count()가 예상보다 커진다. -
유일성 검증이 실패한다.
-
가장 최근 주문을 조회하는 코드가 다른 주문을 가져온다.
-
상태 전이가 이미 처리된 것처럼 보인다.
-
외래 키가 가리키는 대상은 사라졌는데 자식 행만 남는다.
-
삭제·갱신 테스트가 예상보다 많은 행을 건드린다.
즉, 실패한 테스트는 그 시점에서 끝나지 않는다. 데이터베이스에 흔적을 남기면 이후 테스트의 세계를 바꾼다.
진짜 문제는 테스트 순서가 아니었다
전체 실행에서만 실패하면 흔히 테스트 순서를 바꾸거나, 실패하는 테스트를 먼저 실행해 보는 방식으로 문제를 좁힌다. 이 방법은 원인을 찾는 데 도움은 되지만 해결책은 아니다.
테스트 순서 변경으로 문제가 사라졌다면, 테스트가 독립적이지 않다는 사실이 가려졌을 뿐이다.
이번 문제의 핵심은 다음과 같았다.
테스트가 실패해도 데이터 정리가 보장된다는 전제가 깨져 있었다.
테스트는 성공할 때만 정리되는 구조일 수 있다. 또는 정리 로직이 주문 본체만 삭제하고 관련 테이블을 빠뜨릴 수 있다. 트랜잭션을 사용하더라도 실제 코드가 별도의 커넥션을 사용하거나, 트랜잭션 범위 밖에서 데이터를 기록하면 롤백이 모든 흔적을 되돌리지 못할 수 있다.
따라서 “테스트가 끝나면 데이터가 사라진다”는 막연한 기대가 아니라, 어떤 경로에서도 데이터 격리가 보장되는지 확인해야 한다.
조사할 때 봐야 할 것
이런 종류의 스위트 오염은 실패한 assertion만 봐서는 찾기 어렵다. 실패가 발생하기 전후의 데이터 상태를 함께 봐야 한다.
우선 다음 질문을 순서대로 확인하는 것이 효과적이다.
1. 실패 테스트를 단독 실행하면 통과하는가
단독 실행은 테스트 자체의 기본 동작을 확인하는 데 유용하다. 하지만 단독 통과가 독립성까지 보장하지는 않는다.
2. 실패 테스트 앞에 어떤 테스트가 실행되는가
테스트 이름이나 파일 순서를 기준으로 앞선 테스트를 하나씩 추가 실행한다. 특정 테스트를 추가했을 때만 실패한다면, 그 테스트가 오염을 발생시켰을 가능성이 높다.
3. 실패 직전 데이터베이스에 무엇이 남아 있는가
주문 테이블만 볼 것이 아니라 주문과 연결된 모든 테이블을 확인해야 한다.
-
부모 행은 존재하는가?
-
자식 행만 남아 있지는 않은가?
-
상태가 초기값이 아닌 행이 남아 있지는 않은가?
-
회원·상품·쿠폰·포인트 데이터가 함께 남아 있지는 않은가?
-
테스트가 사용하는 고정 식별자와 충돌하는 행은 없는가?
4. 예외 경로에서도 정리가 실행되는가
정상 종료 시점의 정리만 확인하면 부족하다.
-
assertion 실패
-
도메인 예외
-
DB 예외
-
외부 의존성 Mock 실패
-
중간 단계의 validation 실패
이 모든 경우에 정리가 실행되어야 한다.
5. 트랜잭션 경계가 실제 코드와 맞는가
테스트가 트랜잭션 안에서 실행된다고 해서 모든 변경이 자동으로 롤백되는 것은 아니다.
확인할 항목은 다음과 같다.
-
실제 쓰기 작업이 같은 DB 커넥션을 사용하는가
-
Service 내부에서 별도 커넥션을 열지 않는가
-
비동기 처리나 이벤트 핸들러가 테스트 트랜잭션 밖에서 실행되지 않는가
-
커밋이 테스트 격리 경계를 깨고 있지 않은가
가장 중요한 교훈: 고아 행은 데이터 무결성 문제다
이번 문제를 단순히 “테스트가 더러워졌다”고 표현하면 해결 범위가 좁아진다. 고아 행은 테스트 환경의 불편함을 넘어 데이터 무결성 문제를 드러낸다.
운영 코드에서도 다음과 같은 상황은 충분히 발생할 수 있다.
-
주문 본체가 생성된다.
-
주문 항목 또는 결제 기록 저장 중 오류가 발생한다.
-
보상 처리 또는 트랜잭션 롤백이 실패한다.
-
불완전한 주문이 남는다.
-
이후 조회·재처리·만료 배치가 잘못된 상태를 읽는다.
테스트 스위트 오염은 이 문제를 빠르게 보여주는 작은 운영 시뮬레이션이다. 테스트가 남긴 고아 행은 “테스트 데이터 청소 실패”이면서 동시에 “주문 생성 과정의 원자성이 충분히 보장되는가?”라는 질문이다.
해결 방향은 정리 코드 추가가 전부가 아니다
가장 단순한 해결책은 테스트 종료 후 관련 테이블을 직접 삭제하는 것이다. 그러나 삭제 목록이 늘어날수록 빠뜨릴 가능성도 커진다.
더 중요한 것은 데이터 생성과 정리의 책임을 명확히 하는 것이다.
테스트 데이터는 생성 경로를 통일한다
각 테스트가 여러 Model을 직접 호출해 데이터를 만들면 어떤 데이터가 파생되는지 추적하기 어렵다. 주문 테스트라면 주문 생성에 필요한 테스트 헬퍼나 Factory를 통해 일관된 데이터를 만들고, 그 반대편에 명확한 정리 전략을 둬야 한다.
트랜잭션 격리를 우선한다
가능한 경우 테스트 하나를 트랜잭션으로 감싸고 종료 시 롤백한다. 다만 애플리케이션의 모든 DB 쓰기가 같은 트랜잭션에 들어오는지 확인해야 한다.
외래 키와 삭제 정책을 확인한다
부모 주문이 삭제될 때 자식 데이터가 함께 삭제되어야 하는지, 아니면 명시적으로 정리해야 하는지 스키마의 의도를 분명히 해야 한다.
외래 키가 없거나 삭제 정책이 모호하면 고아 행은 다시 생긴다.
실패 후 상태를 관찰할 수 있게 한다
테스트 격리를 강화하는 것과 별개로, 실패 시 남은 데이터를 추적할 수 있어야 한다. 테스트 DB를 완전히 초기화하기 전에 식별 가능한 테스트 접두사나 실행 ID를 사용하면 원인 분석이 쉬워진다.
테스트는 서로를 믿을 수 있어야 한다
좋은 테스트 스위트는 테스트 순서가 바뀌어도 결과가 달라지지 않는다. 앞선 테스트가 성공했을 때만 통과하는 테스트는 독립적인 테스트가 아니다.
이번 사례에서 가장 중요한 발견은 실패한 assertion이 아니었다. 실패한 테스트가 남긴 한 건의 주문 데이터가 뒤의 테스트에 영향을 주고 있었다는 사실이었다.
테스트가 서로를 망가뜨릴 때는 실패한 테스트 하나를 고치는 것만으로 충분하지 않다. 다음 세 가지를 함께 봐야 한다.
-
데이터가 어디에서 생성되는가
-
예외가 발생했을 때 어디까지 롤백되는가
-
테스트가 끝난 뒤 어떤 상태가 남는가
주문·결제·구독처럼 상태가 여러 테이블에 걸쳐 있는 기능에서는 이 원칙이 특히 중요하다.
테스트 격리는 편의를 위한 부가 기능이 아니다. 그것은 데이터 무결성과 재현 가능한 검증을 위한 기본 조건이다.
그리고 고아 행 하나는 종종 이렇게 말해 준다.
지금 깨진 것은 테스트 하나가 아니라, 시스템의 원자성에 대한 믿음일 수 있다.
댓글 0개