목차
문제의 배경
JPA 예제를 작성하면서 회원 등록 기능을 구현할 때 전화번호를 유일한 값으로 지정하고 싶었다. 그래서 DB 상에서 전화번호를 유일키로 지정하고 이미 등록된 전화번호를 등록하려고 하면 IllegalArgumentException을 발생시켜서 요청값이 잘못되었다는 것을 알려주려고 했다.
이를 위해 무결성 제약조건을 위배하면 발생하는 DataIntegrityViolationException을 try-catch
로 잡아내서 새로운 IllegalArgumentException를 발생시키려고 했는데 try-catch
에서 DataIntegrityViolationException을 잡아내지 못하는 상황이었다.
문제 상황의 코드
MemberService.kt
@Service
class MemberService(private val memberRepository: MemberRepository) {
...
@Transactional
fun insertMember(request: MemberRequest): Member =
try {
memberRepository.save(Member.of(request))
} catch (e: DataIntegrityViolationException) {
throw IllegalArgumentException("이미 등록된 전화번호입니다.")
}
...
}
기본키인 ID는 UUID로 자동 생성되게 해놓았고 유일키는 전화번호 뿐이기 때문에 전화번호에 대한 예외 메시지만 던지게 했다.
MemberServiceTest.kt
@TestContainer
class MemberServiceTest @Autowired constructor(private val memberService: MemberService) {
...
@Test
fun `회원의 전화번호는 중복될 수 없다`() {
// given
val request1 = MemberRequest("AAA", 20, "12345", "서울시 강남구", "상세 주소", "010-1234-5678", "requestor")
memberService.insertMember(request1)
// when
val request2 = MemberRequest("BBB", 30, "67890", "서울시 중구", "새 상세 주소", "010-1234-5678", "requestor")
// then
assertThrows<IllegalArgumentException> {
memberService.insertMember(request2)
}.also {
assertThat(it.message).isEqualTo("이미 등록된 전화번호입니다.")
}
}
...
}
새로운 회원이 기존 회원이 등록한 전화번호를 사용하려고 하면 IllegalArgumentException이 발생할거라고 기대하고 테스트 코드를 짰다.
하지만 바로 테스트 실패...
DataIntegrityViolationException을 잡아내서 IllegalArgumentException을 발생시킬 것이라는 기대와 달리 DataIntegrityViolationException을 잡아내지 못하고 그대로 해당 예외가 발생해버렸다.
원인
알고보니 이 문제는 JPA의 영속성 컨텍스트로 인한 문제였다.
@Transactional
이 붙은 메소드에서 사용되는 CrudRepository의 save()
와 saveAll()
은 메소드는 호출될 때 바로 쿼리를 실행하지 않고 쓰기 지연 상태가 되어 1차 캐시에만 우선적으로 엔티티 객체를 저장한다.
이후 메소드가 정상적으로 종료되었다면 쿼리가 실행되어 실제 DB에 반영된다. 이로 인해 try-catch
블럭 내부에서는 쿼리가 실행되지 않으니 잡아낼 DataIntegrityViolationException도 발생하지 않았다가, 메소드가 종료된 후에 쿼리가 실행되면서 DataIntegrityViolationException이 발생한다.
해결 방법
이를 해결하기 위해선 두가지 방법이 있다.
- 메소드에서
@Transactional
제거 try-catch
블럭 내부에서flush()
를 호출하여 강제로 쿼리 실행
첫번째 방법은 해당 메소드 전체에서 영속성 컨텍스트를 사용할 수 없고, 트랜잭션 처리가 불가능하다는 문제가 있기 때문에 두번째 방법을 사용하기로 했다.
@Service
class MemberService(private val memberRepository: MemberRepository) {
...
@Transactional
fun insertMember(request: MemberRequest): Member =
try {
memberRepository.saveAndFlush(Member.of(request))
} catch (e: DataIntegrityViolationException) {
throw IllegalArgumentException("이미 등록된 전화번호입니다.")
}
...
}
이 예제에서는 save()
와 동시에 쿼리를 실행하기 위해 saveAndFlush()
를 사용했지만 실제 코드에서는 save()
를 호출하고 여러가지 비즈니스 로직을 수행한 후에 따로 flush()
를 호출할 수도 있을 것이다.
saveAll()
의 경우에도 saveAllAndFlush()
를 통해 쿼리를 바로 실행할 수 있다.
그 외에 Controller 계층에서 DataIntegrityViolationException를 잡아내도록 할 수도 있긴 하지만 이렇게 하면 Service 계층에서 미리 예외를 잡아내서 별도의 메시지를 담은 예외를 던질 수 없기 때문에 Service 계층의 단위 테스트로는 검증할 수 없다.
마치며
사실 이렇게 영속성 컨텍스트의 쓰기 지연 기능으로 인해 Service 계층의 try-catch
블록에서 DataIntegrityViolationException를 잡아내지 못하는 문제는 save()
뿐만 아니라 delete()
와 변경 감지를 통해 발생하는 UPDATE 쿼리 역시 공유하는 문제이다.
따라서 JPA를 사용하면서 발생하는 여러 DataIntegrityViolationException에 대해 서비스 계층에서 별도의 예외를 던져주도록 처리하려면 추가적으로 flush()
를 호출해줘야 할 것이다.
참조 링크
'Development > JPA' 카테고리의 다른 글
[Spring Data JPA] Java의 record 객체를 @EmbeddedId로 사용할 때 Could not set value of type 문제가 발생하는 경우 (0) | 2024.08.02 |
---|---|
[JPA] 트랜잭션의 전파와 UnexpectedRollbackException 이슈 (0) | 2024.01.29 |
[Kotlin/JPA] 코틀린과 JPA를 함께 사용할 때 추가적으로 설정해야 하는 것들과 Data class (0) | 2023.03.29 |
[JPA] 양방향 연관 관계에서 연관 관계의 주인 설정과 주의 사항 (0) | 2023.03.22 |
[JPA] hibernate의 ddl-auto 속성의 종류와 주의해야할 점 (6) | 2023.03.14 |
댓글