본문 바로가기
  • 개발하는 곰돌이
Development/JPA

[JPA] Service 계층에서 DataIntegrityViolationException을 처리하기

by 개발하는 곰돌이 2023. 4. 25.

목차

    문제의 배경

    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()를 호출해줘야 할 것이다.

    참조 링크

     

    [JPA] 쓰기 지연으로 인한 쿼리 실행 시점과 예외처리 및 기본키 생성 전략

    최근에 흥미로운 글을 봤다. JPA 쓰기지연 기능 때문에 커스터마이징한 예외가 발생하지 않고 DataIntegrityViolationException 예외가 발생한다는 글이었다. service에서 repository의 delete 메서드를 실행하고

    kth990303.tistory.com

     

    @Transactional 과 JPA 사용 시 주의점

    @Transactional 과 영속성 컨텍스트의 쿼리 실행 시점에 대해서 알아봅니다 :)

    velog.io

     

    Service에서 DataIntegrityViolationException을 Catch 못함

    문제 상황 동시에 같은 좌석이 예약이 되는 경우 적절한 예외를 발생시키고 싶었다. 현재 프로젝트에서 동시에 같은 좌석이 예약되는 경우 DataIntegrityViolationException이 발생한다 id의 조합으로 Stri

    taesan94.tistory.com

     

    댓글