• 카테고리

    질문 & 답변
  • 세부 분야

    백엔드

  • 해결 여부

    해결됨

무결성 제약조건이 위배되는 경우에 대한 예외처리에 대해 질문드립니다

23.04.27 11:47 작성 23.04.27 14:32 수정 조회수 862

2

안녕하세요 강사님. Kotlin + JPA에 대해 마땅히 질문남길 곳이 없어서 질문드리게 되었습니다😅

 

Book 엔티티에서 name을 유니크 키로 지정해서 중복된 값을 설정하려고 하면 아래처럼IllegalArgumentException을 던져주게 하는 코드를 작성하고자 했습니다.

@Transactional
fun saveBook(request: BookRequest) {
    val book = Book(request.name, request.type)
    try {
        bookRepository.save(book)
    } catch (e: DataIntegrityViolationException) {
        throw IllegalArgumentException("이미 등록된 도서입니다.")
    }
}

그런데 JPA의 영속성 컨텍스트로 인해 @Transactional이 붙은 메소드에서 DataIntegrityViolationException를 처리해주기 위해선 flush()를 직접 호출해서 쿼리를 실행시켜야 한다는 것을 알게되어 아래와 같이 코드를 수정했고, 테스트 코드에서 정상적으로 IllegalArgumentException이 처리되는 것을 확인할 수 있었습니다.

@Transactional
fun saveBook(request: BookRequest) {
    val book = Book(request.name, request.type)
    try {
        bookRepository.save(book)
        bookRepository.flush()
    } catch (e: DataIntegrityViolationException) {
        throw IllegalArgumentException("이미 등록된 도서입니다.")
    }
}

이렇게 코드를 작성해놓고 보니 하나의 메소드에서만 이렇게 무결성 제약조건 위배에 대한 처리를 한다면 상관이 없겠지만 여러 메소드에서 무결성 제약조건을 위배하는 경우에 대해 각기 다른 메시지를 담은 예외를 던지게 된다면 중복 코드가 너무 많이 발생할 것 같다는 생각이 들더라구요.

 

이런 경우에 아래처럼 확장함수와 람다를 사용해서 예외 처리를 해도 괜찮을까요?

inline fun <reified T, ID, R> JpaRepository<T, ID>.flushOrThrow(exception: Throwable, block: JpaRepository<T, ID>.() -> R): R {
    try {
        val result = block()
        flush()
        return result
    } catch (e: DataIntegrityViolationException) {
        throw exception
    }
}
@Transactional
fun saveBook(request: BookRequest) {
    val book = Book(request.name, request.type)
    bookRepository.flushOrThrow(IllegalArgumentException("이미 등록된 도서입니다.")) { save(book) }
}

이런 방법을 썼을 때 코드가 너무 복잡해지진 않을지, 협업을 하는 경우에 문제가 되진 않을지, 이런 경우가 발생하면 다른 분들은 어떤 방법을 사용하시는지 고민되어 질문 남깁니다!🙏

답변 1

답변을 작성해보세요.

2

안녕하세요, 콜라곰님!!! 좋은 질문 감사드립니다~~ 😊 질문은 항상 환영이죠~!!! 🙏

 

질문과 관련된 결론부터 빠르게 말씀드려보겠습니다!!

flush + try catch의 로직을 공통화 하고 싶은 경우 확장함수와 람다를 사용해서 예외 처리를 해도 괜찮을까요?

네네!! 매우 좋은 접근이라고 생각합니다!! 오히려 중복을 줄이는 좋은 접근이고, 함수 하나를 사용해 중복을 줄이는 기법은 굉장히 많이 사용되다보니 다른 개발자 분들도 코드를 보셨을 때 바로 이해가 되실 정도일 것 같아요!! 😊

 

자 그런데, 질문 외에 생각해볼만한 내용이 있습니다!

 

[1. 꼭 Service에서 flush + try catch를 해야 하는가?!]

현재 로직은 매우 간단합니다! 어떠한 Entity를 저장하고 unique key에 의해 중복이 감지되면, try catch 를 통해 예외 변환을 해주게 되죠. 하지만 이러한 예외 변환이 꼭 service 계층에서 되어야 하는 것은 아닙니다!

예를 들어, @RestControllerAdvice 와 같은 어노테이션을 활용해 전역적인 예외 처리를 한다면, DataIntegrityViolationException 을 잡아서 한 번에 IllegalArgumentException으로 변환할 수 있습니다. 그렇게 되면 Service 로직에서 직접 flush를 호출하거나 try catch 구문을 작성해주지 않아도 되죠. 물론 이 경우, 어떠한 entity가 중복인지 메시지에 추가로 담기에는 한계가 있을 수 있지만 API를 사용하는 클라이언트 입장에서는 약속한 HTTP 응답 status 혹은 약속된 포맷으로 "중복"임을 알 수 있다면, API의 조합과 "중복 에러"를 통해 유추할 수 있을겁니다!

가령, POST /api/v1/user 로 API를 호출했는데 응답이 400 상태로 오고 응답 body가

{
  "code": "DUPLICATE_ENTITY"
}

라면, 아~ user를 만들려다가 중복이 되었구나~ 라고 추측할 수 있습니다.

 

[2. 엇 그런데 @RestControllerAdvice 를 사용하면, 예외처리는 되지만 로직은 넣을 수 없겠죠?!]

네 맞습니다! @RestControllerAdvice 는 어디까지나 지금 catch 해서 해주는 작업 (단순 예외 변환) 을 대체하기에 적절한 기술입니다. 만약, catch를 해준 이후 duplicate_try 라는 테이블에 중복 저장 시도 횟수를 기록해야 한다거나, 다른 DB에 접근해야 하는 일이 있다면, @RestControllerAdvice 는 부적절할 수 있습니다.

다만, @RestControllerAdvice 역시 하나의 Bean이기 때문에 모니터링 시스템에 alert을 보내거나 외부 component에 신호를 보내는 기능 등은 스프링 DI를 통해 쉽게 구현할 수 있습니다! (비즈니스 로직이 들어가는 것이 Layered Architecture에 위배될 뿐이죠!)

 

[3. 그럼 중복이 발생한 경우 로직을 넣고 싶으면 무조건 flush + try catch를 써야겠군요?]

이 역시 꼭 그렇지는 않습니다! flush() 를 사용하건, flush가 포함되어 있는 saveAndFlush() 등을 사용하건 flush + Unique Key 조합 외에도 중복을 잡을 수 있는 방법이 몇 가지 더 존재합니다!

예를 들어, MySQL을 사용하고 있다면 유저 락을 이용할 수 있습니다! 유저 락을 활용하면, 서버 여러 대가 동작하고 있더라도 특정 Service 로직이 한 곳에서만 호출되고 있음을 보장할 수 있습니다. 이를 응용하면, 책을 저장하기 전, 동일한 이름으로 책이 저장되어 있는지 확인하는 코드를 100% 활용할 수 있게 되죠. 물론, 중복이 발생했는데 추가적으로 로직을 넣는 경우는 거의 드뭅니다!

 

질문 주신 내용에 대해 제가 떠오르는 내용은 이정도인 것 같습니다!

더 궁금한점 생기시면 또 편하게 댓글 남겨주세요!! 감사합니다!! 🙇🙇

콜라곰님의 프로필

콜라곰

질문자

2023.04.28

와... 제가 생각치도 못한 방법이 더 있었네요. @RestControllerAdvice는 AOP에 아직 익숙하지 않다 보니 생각하지 못했는데 AOP와 예외 처리에 대해선 더 많이 공부해봐야 할 것 같아요!

 

항상 친절하게 답해주셔서 감사합니다! ☺