나 JAVA 봐라

@Transactional과 DataIntegrityViolationException 본문

Spring/Spring JPA

@Transactional과 DataIntegrityViolationException

cool_code 2024. 1. 10. 20:54

Service에서 reviewReaction 엔티티를 DB에 저장하는 메소드를 아래와 같이 작성했다. 

reviewReaction 엔티티는 review, user 엔티티를 참조하고 있기 때문에 revieReaction 엔티티 생성 시에 참조하고 있는 값이 DB에 존재하지 않으면 java.sql.SQLIntegrityConstraintViolationException을 던진다. 

  • 해당 연관관계는 sql 스크립트로 FK를 맺어주고 엔티티에는 JPA 연관관계를 따로 명시하지 않고 있다.
  • 따라서 위의 Exception은 데이터베이스에서 던지는 에러이며, SQL 실행 중 무결성 제약 조건이 위반되었을 때 발생한다. 
@Transactional
    public void createReviewReaction(ReviewReactionDto reviewReactionDto) {
        try {
            ReviewReaction reviewReaction = reviewReactionDto.toEntity();
            reviewReactionRepository.save(reviewReaction);
        } catch (DataIntegrityViolationException e) {
            throw new BusinessException(ReviewReactionErrorCode.BAD_REQUEST);
        }
    }

 

여튼,,, 위와 같이 해서 Exception 처리를 해주려고 했는데, 계속해서 Exception을 잡아주지 못해서 같은 에러가 발생했다.

위에서 설명한 대로 참조하고 있는 값이 DB에 존재하지 않아서 생기는 에러다.

더보기

java.sql.SQLIntegrityConstraintViolationException: Cannot add or update a child row: a foreign key constraint fails (orury.review_reaction, CONSTRAINT FK_user_TO_review_reaction_1 FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE)

왜 try-catch에서 Exception을 잡아주지 않을까? 

범인은 @Transactional 어노테이션 이었다. 

 

@Transactional 이 어떻게 동작하는지 알기 위해, 먼저 트랜잭션이 뭔지 간단히 정리해봤다.

 

트랜잭션

  • 데이터베이스에서 데이터를 처리하는 하나의 논리적인 작업 단위
  • 트랜잭션에는 여러 개의 쿼리를 포함할 수 있고, 하나라도 실패할 경우 전체 트랜잭션이 롤백되어서 데이터의 일관성을 보장한다.

@Transactional의 동작 순서

  1. @Transactional 이 붙은 메소드가 호출되면, Spring은 트랜잭션을 시작한다. 이 때, 새로운 연결을 데이터베이스에서 가져오거나 이미 존재하는 연결을 재사용할 수 있다.
  2. 메소드 내에서 데이터베이스에 대한 쿼리를 수행하면, 이 쿼리는 모두 해당 트랜잭션 안에서 실행된다. 이 때, 각 쿼리의 결과는 '즉시' 데이터베이스에 반영되지 않고, 트랜잭션 내에서만 이뤄진다.
  3. 메소드가 종료되면, Spring은 트랜잭션을 커밋하려고 시도한다. 이 때, 트랜잭션 내에서 실행된 모든 쿼리가 성공적으로 수행되었는지를 확인하게 된다. 외래 키 제약조건 확인 등의 데이터베이스 무결성 검사도 이 때 이뤄진다.
  4. 모든 쿼리가 성공적으로 수행되었다면, 트랜잭션은 커밋되어 데이터베이스에 반영된다. 만약 하나의 쿼리라도 실패하거나, 데이터베이스 무결성 검사에서 문제가 발견되면 트랜잭션은 롤백되고, 트랜잭션 내에서 수행된 모든 쿼리의 결과는 데이터베이스에 반영되지 않는다.

-> 따라서 @Transactional 을 사용하면, 여러 쿼리를 하나의 논리적인 작업 단위로 묶고, 작업의 성공 여부에 따라 한번에 커밋하거나 롤백하도록 해준다. 이를 통해 데이터 일관성 보장이 가능해지고, 여러 사용자가 동시에 데이터를 변경하더라도 데이터베이스의 상태를 안정적으로 유지할 수 있다. 

 

 

즉, @Transactional 로 인해 reviewReactionRepository.save(reviewReaction); 에 해당되는 쿼리가 실제로는 메소드가 종료된 이후 수행되기 때문에, try-catch에서 익셉션을 처리해줄 수 없는 것이었다.

 

해결 방법은 무엇이 있을까? 생각해본 방법으론 두 가지가 있다.

  1. @Transactional 어노테이션을 지운다.
  2. 해당 메소드에서 참조하는 값(review, user)이 실제로 존재하는지 검증하는 로직을 추가한다. 아래와 같은 코드로 구현될 수 있다. 
  3. + 이 후에 생각난 방법으로는, @ControllerAdvice와 @ExceptionHandler를 사용해서 DataIntegrityViolationException을 처리해주는 방법도 있을 듯 하다. 
@Transactional
public void createReviewReaction(ReviewReactionDto reviewReactionDto) {
    Optional<User> user = userRepository.findById(reviewReactionDto.getUserId());
    Optional<Review> review = reviewRepository.findById(reviewReactionDto.getReviewId());

    if (!user.isPresent() || !review.isPresent()) {
        throw new BusinessException(ReviewReactionErrorCode.BAD_REQUEST);
    }

    ReviewReaction reviewReaction = reviewReactionDto.toEntity();
    reviewReactionRepository.save(reviewReaction);
}

 

방법은 위의 두 가지인데, 상황에 따라 선택할 방식이 다를 것 같아 장단점을 정리해봤다. 

  @Transaction 어노테이션을 지운다. 참조 값을 검증하는 로직을 추가한다.
장점 - DB에 한 번만 접근하기 때문에 성능 측면에서 유리하다. 트래픽이 많거나 성능이 중요하다면 DB 접근을 최소화하기 위해 이 방식을 택할 수 있다.  - 외래 키 제약조건 위반을 미리 방지하여 더 명확한 에러 메시지를 제공할 수 있다. 데이터 무결성을 보장하는 것이 중요하다면 이 방식을 택할 수 있다. 
단점 - 외래키 제약조건을 검사하기 위해 데이터베이스 내부적으로는 여러 번의 조회가 발생한다. (user, review 테이블 조회) -> 그러나 이런 조회는 데이터베이스 엔진이 최적화해서 수행하기 때문에 일반적으로 애플리케이션 레벨에서 별도로 조회하는 것보다는 빠르게 처리된다. (그닥 단점 아닌듯)

- DB에 세 번이나 접근해야한다는 단점이 있다. (user 찾기 조회, review 찾기 조회, reviewReaction 저장) -> 네트워크 지연과 같은 추가적인 오버헤드가 발생할 수 있는 문제도 있다. 데이터베이스가 애플리케이션과 분리되어 있거나, 분산 시스템 한경에서는 중요하게 생각할 요인이다. 

 

문제의 코드의 경우 save() 코드 한 줄만 실행하기에 쿼리가 하나만 생성된다. 따라서 @Transactional 을 사용할 필요가 없다고 판단하여, @Transactional 어노테이션을 지워서 간단하게 해결하기로 했다. 

 

 

 

 

추가적으로 @Transactional 을 사용하고 싶다면 .save()가 아닌 .saveAndFlush()를 사용하면 된다. flush()를 사용하면, 메소드가 끝난 후에 일괄적으로 쿼리를 보내는 것이 아니라 '즉시' 데이터베이스에 반영할 수 있다.

 

이 때, 문득 궁금해진 것이 생겼다. 예를 들어, @Transactional 이 달린 메소드에서 flush()가 두 번 쓰인다. 이 때, 첫번째 flush는 예외없이 실행되었지만, 두번째 flush에서 예외가 발생했을 경우, 첫번째 flush를 통해 DB에 반영된 것은 롤백이 될까?

-> 롤백 된다 !