IDE: IntelliJ
Spring Boot: 3.4.5
Java: 21
java.lang.IllegalStateException: Duplicate key 001
테스트 코드를 작성하고 실행시켜보던 중에 IllegalStateException 예외가 발생했다. 로그를 확인해 보니 Map을 생성하는 과정에서 key 값이 중복되어 예외가 발생한 것이었다. 문제가 된 코드는 다음과 같다.
private List<Product> findProductsBy(List<String> productNumbers) {
List<Product> products = productRepository.findAllByProductNumberIn(productNumbers);
Map<String, Product> productMap = products.stream()
.collect(Collectors.toMap(Product::getProductNumber, p -> p));
List<Product> duplicateProducts = productNumbers.stream()
.map(productNumber -> productMap.get(productNumber))
.toList();
return duplicateProducts;
}
그런데 한가지 이상한 점은 테스트 클래스 내에 작성한 여러개의 테스트 중 하나만 단독으로 실행했을 때는 예외가 발생하지 않는다는 것이었다. 즉, 테스트 클래스 전체를 실행시킬 때만 해당 예외가 발생하는 것이었다. 바로 여기에서 문제 해결의 실마리를 찾을 수 있었다.
@ActiveProfiles("test")
@SpringBootTest
class OrderServiceTest {
@Autowired private ProductRepository productRepository;
@Autowired private OrderService orderService;
@DisplayName("상품번호 리스트를 받아 주문을 생성한다.")
@Test
void createOrder() throws Exception {
Product product1 = createProduct(HANDMADE,"001",1000);
Product product2 = createProduct(HANDMADE,"002",3000);
Product product3 = createProduct(HANDMADE,"003",5000);
productRepository.saveAll(List.of(product1, product2, product3));
... 이하 생략
}
@DisplayName("중복되는 상품번호 리스트로 주문을 생성할 수 있다.")
@Test
void createOrderWithDuplicateProductNumbers() throws Exception {
Product product1 = createProduct(HANDMADE,"001",1000);
Product product2 = createProduct(HANDMADE,"002",3000);
Product product3 = createProduct(HANDMADE,"003",5000);
productRepository.saveAll(List.of(product1, product2, product3));
... 이하 생략
}
테스트 코드를 보면 두 테스트 메서드 모두 같은 상품 번호 "001", "002", "003"을 사용하고 있다. 같은 상품 번호를 가진 객체를 생성한 뒤에 productRepository.saveAll()로 DB에 저장하고 있는데, 문제는 @SpringBootTest로 테스트 컨텍스트를 실행하고 있기 때문에 전체 테스트를 한꺼번에 실행할 경우 데이터가 완전히 초기화되지 않고 남아 있게 되어 DB에 데이터가 중복으로 저장되어 버린다.
정리하면,
- 첫 번째 테스트에서 상품 번호가 "001", "002", "003"인 상품이 DB에 저장됨
- 두 번째 테스트에서도 상품 번호가 "001", "002", "003"인 상품이 DB에 저장됨
- 결과적으로 DB에 상품 번호가 "001", "002", "003"인 상품이 각각 2개씩 저장됨
이렇게 데이터가 중복된 상태에서 아래 코드가 실행되면 어떻게 되는지 살펴보자.
List<Product> products = productRepository.findAllByProductNumberIn(List.of("001"));
Map<String, Product> productMap = products.stream()
.collect(Collectors.toMap(Product::getProductNumber, p -> p));
첫번째 코드가 실행되면 DB에서 상품번호가 "001"인 상품을 찾아와 products 리스트에 저장한다. 그러면 products 리스트에는 2개의 상품이 담기게 된다.
두번째 코드가 실행되면 products 리스트에 담긴 상품을 차례로 Map에 담게 되는데 이때 키 값인 "001"이 중복되어 예외가 발생하게 되는 것이다.
그렇다면 개별 테스트 간에 데이터가 공유되지 않도록 하면 문제가 해결될 것이라는 걸 예상할 수 있다. 해결 방법은 생각보다 간단하다.
@Transactional
@ActiveProfiles("test")
@SpringBootTest
class OrderServiceTest {
... 생략
}
이렇게 테스트 클래스 상단에 @Transactional 어노테이션만 추가해주면 문제가 해결된다!
@Transactional을 테스트 메서드에 붙이면 각 테스트는 트랜잭션 안에서 실행되게 된다. 테스트가 끝나면 해당 트랜잭션은 자동으로 롤백되므로 DB에 남는 데이터가 없는 것이다. 따라서 다음 테스트 실행 시 깨끗한 상태로 시작되므로 충돌이 발생하지 않는 것이다.
Collection의 Map을 사용할 때 키 값이 중복되면 기존 값을 덮어 썼던거 같은데 왜 예외가 발생하지?.. 라는 의문이 든 사람이 있을 것이다. 나 또한 그랬다.
일반적인 Map은 키 값이 중복돼도 예외를 발생시키지 않는다. 하지만 Collectors.toMap()의 경우 병합 전략을 따로 지정해 주지 않으면 키 값이 중복되었을 때 예외를 발생 시킨다.