RECENT POSTS

최근에 작성한 글

01 / 03

전체 글

전체 보기

[Refactor] saveAndFlush() 적용 및 테스트 자동화

오늘 한 일

오늘은 Hub Service의 CodeRabbit 리뷰를 반영하면서 코드의 안정성을 개선하고 테스트 환경을 정비했다.

주요 작업은 다음과 같다.

  • CodeRabbit 리뷰 반영
  • Hub/HubRoute 초기화 로직 개선
  • Directions API 응답 Null Safety 추가
  • 단위 테스트 수정
  • Gradle 테스트 자동화 환경 구축

1. save() 대신 saveAndFlush()를 사용한 이유

CodeRabbit에서 다음과 같은 리뷰를 받았다.

복합 Unique 제약 조건이 존재하지만 동시 요청이 들어오는 경우 DataIntegrityViolationException이 Internal Server Error로 처리될 수 있습니다.

기존에는 다음과 같이 저장만 수행하고 있었다.

HubRoute savedHubRoute = hubRouteRepository.save(hubRoute);

하지만 save()는 실제 INSERT SQL이 트랜잭션 종료 시점까지 지연될 수 있다.

즉,

  1. 중복 여부 조회(exists)
  2. save()

사이에 다른 요청이 먼저 INSERT를 수행하면 Unique Constraint가 발생한다.

그런데 이 예외는 서비스 메서드 밖에서 발생하기 때문에 원하는 예외로 변환하기 어려웠다.

따라서 저장 시점을 강제로 앞당기는 saveAndFlush()를 사용하도록 변경하였다.

try {
    HubRoute savedHubRoute = hubRouteRepository.saveAndFlush(hubRoute);

    ...
} catch (DataIntegrityViolationException e) {
    throw new ApiException(ErrorResponseCode.HUB_ROUTE_ALREADY_EXISTS);
}

배운 점

사전 조회(existsBy...)는 사용자 경험을 위한 검증일 뿐이며, 실제 데이터 무결성은 DB의 Unique Constraint가 최종적으로 보장해야 한다.

또한 동시성 상황까지 고려한다면 saveAndFlush()를 이용하여 예외가 서비스 계층에서 발생하도록 만드는 것이 적절하다는 것을 배웠다.


2. 초기 데이터 생성 로직 개선

기존에는 단순히

if (hubRepository.count() > 0)

만 확인하여 초기화를 수행하였다.

하지만 일부 데이터만 존재하는 경우에도 초기화가 건너뛰어질 수 있다는 문제가 있었다.

그래서 HubSeed 기준으로

  • 존재하지 않는 허브는 생성
  • 존재하지만 시드 데이터와 다르면 예외 발생

하도록 변경하였다.

이렇게 하면 초기화 로직을 여러 번 실행하더라도 동일한 결과를 보장하는 멱등성(Idempotency) 을 확보할 수 있다.


3. Directions API Null Safety 추가

네이버 Directions API 응답을 그대로 사용하고 있었는데,

CodeRabbit에서 다음과 같은 리뷰를 받았다.

route 또는 trafast가 null이면 NullPointerException이 발생할 수 있습니다.

기존에는 바로 Summary를 가져왔다.

route.getTrafasts().get(0).getSummary();

이를 아래와 같이 변경하였다.

if (route == null ||
    route.getTrafasts() == null ||
    route.getTrafasts().isEmpty()) {
    throw new ApiException(ErrorResponseCode.DIRECTION_NOT_FOUND);
}

Trafast trafast = route.getTrafasts().get(0);

if (trafast == null || trafast.getSummary() == null) {
    throw new ApiException(ErrorResponseCode.DIRECTION_NOT_FOUND);
}

외부 API는 항상 정상 응답을 준다고 가정하면 안 된다는 점을 다시 한번 느꼈다.


4. 테스트 자동화 환경 구축

기존에는

./gradlew test

만 사용할 수 있었다.

이번에는 테스트 목적에 따라 실행할 수 있도록 Gradle Task를 분리하였다.

./gradlew unitTest
./gradlew integrationTest
./gradlew externalTest
./gradlew allTests

또한 JUnit5 Tag를 이용하여 테스트를 분류하였다.

@Tag("unit")
@Tag("integration")
@Tag("external")

이렇게 분리해두면 필요한 테스트만 빠르게 실행할 수 있고, 향후 GitHub Actions에서도 그대로 활용할 수 있다.


5. 트러블 슈팅

save()를 saveAndFlush()로 변경한 이후 모든 단위 테스트가 실패하였다.

원인은 Mockito Mock이 기존 save() 기준으로 작성되어 있었기 때문이다.

기존

when(repository.save(any()))

변경 후

when(repository.saveAndFlush(any()))

또한 verify 역시 동일하게 수정하였다.

verify(repository).saveAndFlush(any());

서비스 코드의 변경에 따라 테스트 코드도 함께 수정되어야 한다는 점을 다시 한번 경험했다.


오늘 배운 점

  • DB 무결성은 애플리케이션이 아닌 DB Constraint가 최종적으로 보장한다.
  • save()와 saveAndFlush()의 실행 시점 차이를 이해하게 되었다.
  • 초기화 로직은 단순 count 확인보다 멱등성을 고려하여 설계하는 것이 안전하다.
  • 외부 API 응답은 항상 Null Safety를 고려해야 한다.
  • 테스트 역시 서비스 코드 변경에 맞추어 함께 유지보수되어야 한다.
  • JUnit Tag와 Gradle Custom Task를 이용하면 테스트를 목적별로 효율적으로 관리할 수 있다.