개발을 시작한 이래로 항상 강조되는 명제가 있었다. 바로 "테스트 코드를 작성해야 한다"는 것이다. 그러나 그동안은 로컬 서버를 구동해 포스트맨(Postman)으로 몇 차례 API를 호출해 보며 정상 작동을 확인하는 수준에 그치기 일쑤였다.
하지만 최근 경매 및 정가 차량 수출 플랫폼 프로젝트를 진행하며 핵심 비즈니스 레이어 6개(Vehicle, Transport, Order, Buyer, Inspection, Seller)에 대한 스프링 부트 통합 테스트(Integration Test)를 바닥부터 구축하는 경험을 했다.
이 과정에서 외부 인프라 의존성 문제, 데이터베이스 제약 조건, 그리고 실제 서비스 로직에 숨어 있던 치명적인 타이밍 버그까지 테스트 코드로 잡아내며 통합 테스트의 진정한 가치를 온몸으로 깨닫게 되었다. 그 삽질의 기록과 기술적 회고를 공유하고자 한다.
1. 첫 번째 장벽: 외부 인프라 의존성 격리 (AmqpConnectException)
@SpringBootTest를 선언하고 무작정 통합 테스트를 실행했을 때 마주한 것은 붉은색의 예외 로그였다.
Caused by: org.springframework.amqp.AmqpConnectException: Connection refused: getsockopt
비즈니스 로직 내부에 경매 마감 처리를 위한 RabbitMQ 지연 큐(Delayed Message)와 동시성 제어를 위한 Redisson 분산 락이 긴밀하게 얽혀 있다 보니, 테스트 컨텍스트가 로딩되는 시점에 실제 인프라 브로커 서버를 찾으려다 연결 거부(Connection refused)가 발생한 것이다. 이외에도 소셜 로그인 설정(OAuth2)과 메일 발송 빈(JavaMailSender) 역시 주입 실패의 원인이 되었다.
💡 깨달음과 해결 방안: 통합 테스트의 목적은 외부 인프라 서버의 구동 상태를 점검하는 것이 아닌, "우리가 작성한 비즈니스 로직과 데이터베이스 정합성이 올바르게 작동하는가"를 검증하는 데 있다. 이를 해결하기 위해 스프링 부트 3.4+의 새로운 모킹 표준인 @MockitoBean을 도입하여 외부 의존성을 완전히 격리했다.
@MockitoBean protected RabbitTemplate rabbitTemplate;
@MockitoBean protected AmqpAdmin amqpAdmin;
@MockitoBean protected JavaMailSender javaMailSender;
실제 브로커 환경을 가짜(Mock) 객체로 대체함으로써 로컬 환경에 RabbitMQ나 Redis를 구동하지 않고도 H2 인메모리 DB만을 활용한 독립적이고 쾌적한 테스트 샌드박스 환경을 구축할 수 있었다.
2. 두 번째 장벽: 연쇄 외래키(FK) 제약 조건과 도메인 라이프사이클
인프라 문제를 해결하자 이번에는 데이터베이스 무결성 제약 조건이 걸림돌이 되었다.
NULL not allowed for column "MEMBER_ID" in table "SELLER_PROFILE"
차량 매물(Listing) 하나를 등록하여 검증하고 싶어도, 이를 위해서는 Vehicle이 필요하고, Vehicle은 SellerProfile에 의존하며, SellerProfile은 최상위 부모인 Member 엔티티가 디비에 먼저 영속화되어 있어야만 했다. 촘촘한 외래키 사슬에 묶여 있었던 것이다.
💡 깨달음과 해결 방안: 순차적인 엔티티 생성을 효율적으로 관리하기 위해 공통 추상 클래스인 BaseIntegrationTest를 설계했다. 그리고 부모 엔티티들을 관계 순서에 따라 빌드하고 save 해주는 공통 데이터 팩터리 메서드를 상속 구조로 이관했다.
Member savedMember = memberRepository.save(member);
SellerProfile sellerProfile = sellerProfileRepository.save(SellerProfile.builder().member(savedMember).build());
Vehicle savedVehicle = vehicleRepository.save(Vehicle.builder().sellerProfile(savedSeller)...build());
테스트 환경을 세팅하기 위해 데이터 흐름을 추적하는 과정에서, 자연스럽게 서비스 전반의 도메인 간 결합도와 데이터가 생성되는 순서(Lifecycle)를 명확하게 정렬하고 이해하는 계기가 되었다.
3. 하이라이트: 영속성 컨텍스트의 타이밍 버그 검거 🚨
이번 테스트 코드 구축 과정에서 가장 유의미했던 순간은 Mockito 검증 단계에서 발생한 인자 불일치 에러를 해결할 때였다.
Wanted: rabbitTemplate.convertAndSend(..., 1L, ...);
Actual: rabbitTemplate.convertAndSend(..., null, ...); // ❌ Auction ID가 null로 주입됨
정적 코드 리뷰 단계에서는 전혀 인지하지 못했던 부분이었다. 경매 객체를 정상적으로 생성하여 매물에 바인딩하고 있었기 때문이다. 하지만 실제 서비스 로직의 순서는 다음과 같이 꼬여 있었다.
// ❌ 기존 버그 코드
listing.setAuction(auction);
scheduleAuctionClose(auction.getId(), request.endTime()); // 🔥 오류 발생: 아직 DB 저장 전이라 식별자(ID)가 null 상태임
listingRepository.save(listing); // 이 시점에 비로소 ID가 발급됨
데이터베이스에 save를 호출하여 고유 식별자(PK)를 발급받기도 전에, auction.getId()를 호출해 지연 마감 큐로 전송하고 있었던 것이다. 만약 테스트 코드를 통한 검증 없이 이대로 상용 서버에 배포되었다면, 모든 경매 매물이 마감 시간에 자동 종료되지 않고 영원히 방치되는 치명적인 장애로 이어졌을 것이다.
눈으로 확인하기 어려운 트랜잭션과 영속성 컨텍스트의 식별자 생성 타이밍 이슈를 테스트 코드가 정확히 포착해 낸 순간이었다. 덕분에 아래와 같이 저장 순서를 보정하여 버그를 완벽히 해결할 수 있었다.
// 안전하게 수정된 코드
Listing savedListing = listingRepository.save(listing); // 1. 먼저 영속화하여 진짜 고유 ID 발급
if (request.saleType() == Listing.SaleType.AUCTION) {
scheduleAuctionClose(savedListing.getAuction().getId(), request.endTime()); // 2. 발급된 ID로 안전하게 스케줄링
}
✍ 결론: 초록불(Passed)이 주는 확신과 자신감
모든 비즈니스 결함과 인프라 방어벽을 세운 후 전체 테스트 세트를 구동했을 때, 6개 도메인의 모든 테스트 케이스 옆에 표시된 초록색 체크 마크(✔ Passed)를 확인할 수 있었다.
이번 경험을 통해 깨달은 테스트 코드의 진정한 가치는 단순한 오류 발견을 넘어, 개발자 본인에게 "내 코드는 데이터베이스 무결성을 해치지 않으며 내부 인프라와도 오차 없이 맞물려 돌아간다"는 단단한 확신을 제공한다는 점에 있다.
견고한 테스트 수트라는 방패가 구축되었기에, 향후 새로운 기능을 추가하거나 대대적인 리팩터링을 진행하더라도 두려움 없이 기민하게 대응할 수 있을 것 같다. 안정적인 애플리케이션을 지향하는 개발자라면 소프트웨어가 내 실수를 직접 검거하는 짜릿한 경험을 꼭 느껴보길 권한다.🚀
개발을 시작한 이래로 항상 강조되는 명제가 있었다. 바로 "테스트 코드를 작성해야 한다"는 것이다. 그러나 그동안은 로컬 서버를 구동해 포스트맨(Postman)으로 몇 차례 API를 호출해 보며 정상 작동을 확인하는 수준에 그치기 일쑤였다.
하지만 최근 경매 및 정가 차량 수출 플랫폼 프로젝트를 진행하며 핵심 비즈니스 레이어 6개(Vehicle, Transport, Order, Buyer, Inspection, Seller)에 대한 스프링 부트 통합 테스트(Integration Test)를 바닥부터 구축하는 경험을 했다.
이 과정에서 외부 인프라 의존성 문제, 데이터베이스 제약 조건, 그리고 실제 서비스 로직에 숨어 있던 치명적인 타이밍 버그까지 테스트 코드로 잡아내며 통합 테스트의 진정한 가치를 온몸으로 깨닫게 되었다. 그 삽질의 기록과 기술적 회고를 공유하고자 한다.
1. 첫 번째 장벽: 외부 인프라 의존성 격리 (AmqpConnectException)
@SpringBootTest를 선언하고 무작정 통합 테스트를 실행했을 때 마주한 것은 붉은색의 예외 로그였다.
Caused by: org.springframework.amqp.AmqpConnectException: Connection refused: getsockopt
비즈니스 로직 내부에 경매 마감 처리를 위한 RabbitMQ 지연 큐(Delayed Message)와 동시성 제어를 위한 Redisson 분산 락이 긴밀하게 얽혀 있다 보니, 테스트 컨텍스트가 로딩되는 시점에 실제 인프라 브로커 서버를 찾으려다 연결 거부(Connection refused)가 발생한 것이다. 이외에도 소셜 로그인 설정(OAuth2)과 메일 발송 빈(JavaMailSender) 역시 주입 실패의 원인이 되었다.
💡 깨달음과 해결 방안: 통합 테스트의 목적은 외부 인프라 서버의 구동 상태를 점검하는 것이 아닌, "우리가 작성한 비즈니스 로직과 데이터베이스 정합성이 올바르게 작동하는가"를 검증하는 데 있다. 이를 해결하기 위해 스프링 부트 3.4+의 새로운 모킹 표준인 @MockitoBean을 도입하여 외부 의존성을 완전히 격리했다.
@MockitoBean protected RabbitTemplate rabbitTemplate;
@MockitoBean protected AmqpAdmin amqpAdmin;
@MockitoBean protected JavaMailSender javaMailSender;
실제 브로커 환경을 가짜(Mock) 객체로 대체함으로써 로컬 환경에 RabbitMQ나 Redis를 구동하지 않고도 H2 인메모리 DB만을 활용한 독립적이고 쾌적한 테스트 샌드박스 환경을 구축할 수 있었다.
2. 두 번째 장벽: 연쇄 외래키(FK) 제약 조건과 도메인 라이프사이클
인프라 문제를 해결하자 이번에는 데이터베이스 무결성 제약 조건이 걸림돌이 되었다.
NULL not allowed for column "MEMBER_ID" in table "SELLER_PROFILE"
차량 매물(Listing) 하나를 등록하여 검증하고 싶어도, 이를 위해서는 Vehicle이 필요하고, Vehicle은 SellerProfile에 의존하며, SellerProfile은 최상위 부모인 Member 엔티티가 디비에 먼저 영속화되어 있어야만 했다. 촘촘한 외래키 사슬에 묶여 있었던 것이다.
💡 깨달음과 해결 방안: 순차적인 엔티티 생성을 효율적으로 관리하기 위해 공통 추상 클래스인 BaseIntegrationTest를 설계했다. 그리고 부모 엔티티들을 관계 순서에 따라 빌드하고 save 해주는 공통 데이터 팩터리 메서드를 상속 구조로 이관했다.
Member savedMember = memberRepository.save(member);
SellerProfile sellerProfile = sellerProfileRepository.save(SellerProfile.builder().member(savedMember).build());
Vehicle savedVehicle = vehicleRepository.save(Vehicle.builder().sellerProfile(savedSeller)...build());
테스트 환경을 세팅하기 위해 데이터 흐름을 추적하는 과정에서, 자연스럽게 서비스 전반의 도메인 간 결합도와 데이터가 생성되는 순서(Lifecycle)를 명확하게 정렬하고 이해하는 계기가 되었다.
3. 하이라이트: 영속성 컨텍스트의 타이밍 버그 검거 🚨
이번 테스트 코드 구축 과정에서 가장 유의미했던 순간은 Mockito 검증 단계에서 발생한 인자 불일치 에러를 해결할 때였다.
Wanted: rabbitTemplate.convertAndSend(..., 1L, ...);
Actual: rabbitTemplate.convertAndSend(..., null, ...); // ❌ Auction ID가 null로 주입됨
정적 코드 리뷰 단계에서는 전혀 인지하지 못했던 부분이었다. 경매 객체를 정상적으로 생성하여 매물에 바인딩하고 있었기 때문이다. 하지만 실제 서비스 로직의 순서는 다음과 같이 꼬여 있었다.
// ❌ 기존 버그 코드
listing.setAuction(auction);
scheduleAuctionClose(auction.getId(), request.endTime()); // 🔥 오류 발생: 아직 DB 저장 전이라 식별자(ID)가 null 상태임
listingRepository.save(listing); // 이 시점에 비로소 ID가 발급됨
데이터베이스에 save를 호출하여 고유 식별자(PK)를 발급받기도 전에, auction.getId()를 호출해 지연 마감 큐로 전송하고 있었던 것이다. 만약 테스트 코드를 통한 검증 없이 이대로 상용 서버에 배포되었다면, 모든 경매 매물이 마감 시간에 자동 종료되지 않고 영원히 방치되는 치명적인 장애로 이어졌을 것이다.
눈으로 확인하기 어려운 트랜잭션과 영속성 컨텍스트의 식별자 생성 타이밍 이슈를 테스트 코드가 정확히 포착해 낸 순간이었다. 덕분에 아래와 같이 저장 순서를 보정하여 버그를 완벽히 해결할 수 있었다.
// 안전하게 수정된 코드
Listing savedListing = listingRepository.save(listing); // 1. 먼저 영속화하여 진짜 고유 ID 발급
if (request.saleType() == Listing.SaleType.AUCTION) {
scheduleAuctionClose(savedListing.getAuction().getId(), request.endTime()); // 2. 발급된 ID로 안전하게 스케줄링
}
✍ 결론: 초록불(Passed)이 주는 확신과 자신감
모든 비즈니스 결함과 인프라 방어벽을 세운 후 전체 테스트 세트를 구동했을 때, 6개 도메인의 모든 테스트 케이스 옆에 표시된 초록색 체크 마크(✔ Passed)를 확인할 수 있었다.
이번 경험을 통해 깨달은 테스트 코드의 진정한 가치는 단순한 오류 발견을 넘어, 개발자 본인에게 "내 코드는 데이터베이스 무결성을 해치지 않으며 내부 인프라와도 오차 없이 맞물려 돌아간다"는 단단한 확신을 제공한다는 점에 있다.
견고한 테스트 수트라는 방패가 구축되었기에, 향후 새로운 기능을 추가하거나 대대적인 리팩터링을 진행하더라도 두려움 없이 기민하게 대응할 수 있을 것 같다. 안정적인 애플리케이션을 지향하는 개발자라면 소프트웨어가 내 실수를 직접 검거하는 짜릿한 경험을 꼭 느껴보길 권한다.🚀
'개발 공부 > Java-Spring' 카테고리의 다른 글
| [JPA] 엔티티의 생명주기 3단계 (0) | 2026.05.10 |
|---|---|
| [Java] final, static, static final 차이점 완벽 정리 (0) | 2026.05.09 |
| Spring의 핵심, IoC와 DI: 제어의 역전과 의존성 주입 완벽 가이드 (0) | 2026.01.23 |
| 자바 접근 제어자및 스프링부트에서 사용예시 (0) | 2026.01.20 |
| [JPA] N+1 문제, 원인과 결과로 완벽하게 이해하기 (0) | 2026.01.20 |