6 분 소요

 인프런 스프링 DB 2편 - 데이터 접근 활용 기술편을 학습하고 정리한 내용 입니다.

스프링은 @Transactional이 적용되어 있으면 기본으로 REQUIRED라는 전파 옵션을 사용한다.

둘 이상의 트랜잭션이 하나의 물리 트랜잭션에 묶이게 되면 둘을 구분하기 위해 논리 트랜잭션과 물리 트랜잭션으로 구분한다.

  • 이 경우 외부에 있는 신규 트랜잭션만 실제 물리 트랜잭션을 시작하고 커밋한다.
  • 내부에 있는 트랜잭션은 물리 트랜잭션 시작하거나 커밋하지 않는다.

  • 모든 논리 트랜잭션을 커밋해야 물리 트랜잭션도 커밋된다. 하나라도 롤백되면 물리 트랜잭션은 롤백된다.

outerTxOn_success

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**  
 * MemberService    @Transactional:ON 
 * MemberRepository @Transactional:ON 
 * LogRepository    @Transactional:ON 
 * */
@Test  
void outerTxOn_success() {  
    //given  
    String username = "outerTxOn_success";  
  
    //when  
    memberService.joinV1(username);  
  
    //then: 모든 데이터가 정상 저장된다.  
    assertTrue(memberRepository.find(username).isPresent());  
    assertTrue(logRepository.find(username).isPresent());  
}

클래스 위의 주석을 확인해서 모든 곳에 트랜잭션을 적용하자.

  • 클라이언트A(여기서는 테스트 코드)가 MemberService를 호출하면서 트랜잭션 AOP가 호출된다.
    • 여기서 신규 트랜잭션이 생성되고, 물리 트랜잭션도 시작한다.
  • MemberRepository를 호출하면서 트랜잭션 AOP가 호출된다.
    • 이미 트랜잭션이 있으므로 기존 트랜잭션에 참여한다.
  • MemberRepository의 로직 호출이 끝나고 정상 응답하면 트랜잭션 AOP가 호출된다.
    • 트랜잭션 AOP는 정상 응답이므로 트랜잭션 매니저에 커밋을 요청한다. 이 경우 신규 트랜잭션이 아니므로 실제 커밋을 호출하지 않는다.
  • LogRepository를 호출하면서 트랜잭션 AOP가 호출된다.
    • 이미 트랜잭션이 있으므로 기존 트랜잭션에 참여한다.
  • LogRepository의 로직 호출이 끝나고 정상 응답하면 트랜잭션 AOP가 호출된다.
    • 트랜잭션 AOP는 정상 응답이므로 트랜잭션 매니저에 커밋을 요청한다. 이 경우 신규 트랜잭션이 아니므로 실제 커밋을 호출하지 않는다.
  • MemberService의 로직 호출이 끝나고 정상 응답하면 트랜잭션 AOP가 호출된다.
    • 트랜잭션 AOP는 정상 응답이므로 트랜잭션 매니저에 커밋을 요청한다. 이 경우 신규 트랜잭션이므로 물리 커밋을 호출한다.

여기까진 평범한 전파 흐름이다.

이제 롤백을 보자.

트랜잭션 전파 활용5 - 전파 롤백

이번에는 로그 리포지토리에서 예외가 발생해서 전체 트랜잭션이 롤백되는 경우를 알아보자.

outerTxOn_fail

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**  
 * memberService    @Transactional : ON 
 * memberRepository @Transactional : ON 
 * LogRepository    @Transactional : ON EXCEPTION 
 * */
@Test  
void outerTxOn_fail() {  
    //given  
    String username = "로그예외_outerTxOn_fail";  
  
    //when  
    assertThatThrownBy(() -> memberService.joinV1(username))  
            .isInstanceOf(RuntimeException.class);  
    //then  
    assertTrue(memberRepository.find(username).isEmpty());  
    assertTrue(logRepository.find(username).isEmpty());  
}

롤백 온리를 확인하니깐 롤백함.

  • 클라이언트A가 MemberService를 호출하면서 트랜잭션 AOP가 호출된다.
    • 여기서 신규 트랜잭션이 생성되고, 물리 트랜잭션도 시작한다.
  • MemberRepository를 호출하면서 트랜잭션 AOP가 호출된다.
    • 이미 트랜잭션이 있으므로 기존 트랜잭션에 참여한다.
  • MemberRepository의 로직 호출이 끝나고 정상 응답하면 트랜잭션 AOP가 호출된다.
    • 트랜잭션 AOP는 정상 응답이므로 트랜잭션 매니저에 커밋을 요청한다. 이 경우 신규 트랜잭션이 아니므로 실제 커밋을 호출하지 않는다.
  • LogRepository를 호출하면서 트랜잭션 AOP가 호출된다.
    • 이미 트랜잭션이 있으므로 기존 트랜잭션에 참여한다.
  • LogRepository로직에서 런타임 예외가 발생한다. 예외를 던지면 트랜잭션 AOP가 해당 예외를 받게 된다.
    • 트랜잭션 AOP는 런타임 예외가 발생했으므로 트랜잭션 매니저에 롤백을 요청한다. 이 경우 신규 트랜잭션이 아니므로 물리 롤백을 호출하지는 않는다. 대신에 rollbackOnly를 설정한다.
  • MemberService에서도 런타임 예외를 받게 되는데, 여기 로직에서는 해당 런타임 예외를 처리하지 않고 밖으로 던진다.
    • 트랜잭션 AOP는 런타임 예외가 발생했으므로 트랜잭션 매니저에 롤백을 요청한다. 이 경우 신규 트랜잭션이므로 물리 롤백을 호출한다.
    • 참고로 이 경우 어차피 롤백이 되었기 때문에, rollbackOnly설정은 참고하지 않는다.
    • MemberService가 예외를 던졌기 때문에 트랜잭션 AOP도 해당 예외를 그대로 밖으로 던진다.
  • 클라이언트A는 LogRepository부터 넘어온 런타임 예외를 받게 된다.

이제 복구를 시도해보자.

트랜잭션 전파 활용 6 - 복구 REQUIRED

회원 이력 로그를 DB에 남기는 작업에 가끔 문제가 발생해서 회원 가입 자체가 안되는 경우가 가끔 발생하게 되었다.

그래서 사용자들이 회원 가입에 실패해서 이탈하는 문제가 발생하기 시작했다.

회원 이력 로그의 경우 여러가지 방법으로 추후에 복구가 가능할 것으로 보인다.

요구 사항 : 회원 가입을 시도한 로그를 남기는데 실패하더라도 회원 가입은 유지되어야 한다.

  • 단순하게 생각해보면 LogRepository에서 예외가 발생하면 그것을 MemberService에서 예외를 잡아서 처리하면 될 것 같다.
  • 이렇게 하면 MemberService에서 정상 흐름으로 바꿀 수 있기 때문에 MemberService의 트랜잭션 AOP에서 커밋을 수행할 수 있다.
  • 이 방법이 실패할 것으로 생각했다면, 지금까지 제대로 학습한 것이다.

이 방법이 왜 실패하는지 예제를 통해서 알아보자. 참고로 실무에서 많은 개발자가 이 방법을 사용해서 실패한다.

recoverException_fail

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**  
 * memberService    @Transactional : ON 
 * memberRepository @Transactional : ON 
 * LogRepository    @Transactional : ON EXCEPTION 
 */
@Test  
void recoverException_fail() {  
    //given  
    String username = "로그예외_recoverException_fail";  
  
    //when  
    assertThatThrownBy(() -> memberService.joinV2(username))  
            .isInstanceOf(RuntimeException.class);  
    //then : 모든 데이터가 롤벡된다.  
    assertTrue(memberRepository.find(username).isEmpty());  
    assertTrue(logRepository.find(username).isEmpty());  
}

여기서 memberService.joinV2()를 호출하는 부분을 주의해야 한다. joinV2()에는 예외를 잡아서 정상 흐름으로 변환하는 로직이 추가되어 있다.

joinV2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Transactional  
public void joinV2(String username) {  
    Member member = new Member(username);  
    Log logMessage = new Log(username);  
  
    log.info("== memberRepository 호출 시작 ==");  
    memberRepository.save(member);  
    log.info("== memberRepository 호출 종료 ==");  
  
    log.info("== logRepository 호출 시작 ==");  
    try {  
        logRepository.save(logMessage);      
} catch (RuntimeException e) {  
        log.info("log 저장에 실패했습니다. logMessage={}",logMessage);  
        log.info("정상 흐름 반환");  
    }  
    log.info("== logRepository 호출 종료 ==");  
}

  • 내부 트랜잭션에서 rollbackOnly를 설정하기 때문에 결과적으로 정상 흐름 처리를 해서 외부 트랜잭션에서 커밋을 호출해도 물리 트랜잭션은 롤백된다.
  • 그리고 UnexpectedRollbackException이 던져 진다.

  • LogRepository에서 예외가 발생한다. 예외를 던지면 LogRepository의 트랜잭션 AOP가 해당 예외를 받는다.
  • 신규 트랜잭션이 아니므로 물리 트랜잭션을 롤백하지는 않고, 트랜잭션 동기화 매니저에 rollbackOnly를 표시한다.
  • 이후 트랜잭션 AOP는 전달 받은 예외를 밖으로 던진다.
  • 예외가 MemberService에 던져지고, MemberService는 해당 예외를 복구한다. 그리고 정상적으로 리턴한다.
  • 정상 흐름이 되었으므로 MemberService의 트랜잭션 AOP는 커밋을 호출한다.
  • 커밋을 호출할 때 신규 트랜잭션이므로 실제 물리 트랜잭션을 커밋해야 한다. 이때 rollbackOnly를 체크한다.
  • rollbackOnly가 체크 되어 있으므로 물리 트랜잭션을 롤백한다.
  • 트랜잭션 매니저는 UnexpectedRollbackException예외를 던진다.
  • 트랜잭션 AOP도 전달받은 UnexpectedRollbackException을 클라이언트에 던진다.

정리

  • 논리 트랜잭션 중 하나라도 롤백되면 전체 트랜잭션은 롤백된다.
  • 내부 트랜잭션이 롤백 되었는데, 외부 트랜잭션이 커밋되면 UnexpectedRollbackException예외가 발생 한다.
  • rollbackOnly상황에서 커밋이 발생하면 UnexpectedRollbackException예외가 발생한다.

트랜잭션 전파 활용 7 - 복구 REQUIRES_NEW

그렇다면 어떻게 해야 다음 요구사항을 만족할 수 있을까?

회원 가입을 시도한 로그를 남기는데 실패하더라도 회원 가입은 유지되어야 한다.

이 요구사항을 만족하기 위해서 로그와 관련된 물리 트랜잭션을 별도로 분리해보자. 바로 REQUIRES_NEW를 사용하는 것이다.

recoverException_success

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**  
 * memberService    @Transactional : ON 
 * memberRepository @Transactional : ON 
 * LogRepository    @Transactional : ON (REQUIRES_NEW) EXCEPTION 
 * */
@Test  
void recoverException_success() {  
    //given  
    String username = "로그예외_recoverException_success";  
  
    //when  
    memberService.joinV2(username);  
  
    //then : 로그는 없고, 멤버는 있는 상황이 되야 한다.  
    assertTrue(memberRepository.find(username).isPresent());  
    assertTrue(logRepository.find(username).isEmpty());  
}

로그 리포지토리에 기존 트랜잭션에 참여하는 REQUIRED대신에, 항상 신규 트랜잭션을 생성하는 REQUIRES_NEW를 적용 하자.

로그쪽이 실패했는데, 거기 안에서 롤백하고 트랜잭션을 변경했다.

그래서 아래쪽 물리 트랜잭션쪽으로 다시 전환되고 거기서는 제대로 커밋했다.

  • MemberRepositoryREQUIRED옵션을 사용한다. 따라서 기존 트랜잭션에 참여한다.
  • LogRepository의 트랜잭션 옵션에 REQUIRES_NEW를 사용했다.
  • REQUIRES_NEW는 항상 새로운 트랜잭션을 만든다. 따라서 해당 트랜잭션 안에서는 DB 커넥션도 별도로 사용하게 된다.

  • REQUIRES_NEW를 사용하게 되면 물리 트랜잭션 자체가 완전히 분리되어 버린다.
  • 그리고 REQUIRES_NEW는 신규 트랜잭션이므로 rollbackOnly표시가 되지 않는다. 그냥 해당 트랜잭션이 물리 롤백되고 끝난다.

  • LogRepository에서 예외가 발생한다. 예외를 던지면 LogRepository의 트랜잭션 AOP가 해당 예외를 받는다.
  • REQUIRES_NEW를 사용한 신규 트랜잭션이므로 물리 트랜잭션을 롤백한다. 물리 트랜잭션을 롤백했으므로 rollbackOnly를 표시하지 않는다. 여기서 REQUIRES_NEW를 사용한 물리 트랜잭션은 롤백되고 완전히 끝이 나버린다.
  • 이후 트랜잭션 AOP는 전달 받은 예외를 밖으로 던진다.
  • 예외가 MemberService에 던져지고, MemberService는 해당 예외를 복구한다. 그리고 정상적으로 리턴한다.
  • 정상 흐름이 되었으므로 MemberService의 트랜잭션 AOP는 커밋을 호출한다.
  • 커밋을 호출할 때 신규 트랜잭션이므로 실제 물리 트랜잭션을 커밋해야 한다. 이때 rollbackOnly를 체크한다.
  • rollbackOnly가 없으므로 물리 트랜잭션을 커밋한다.
  • 이후 정상 흐름이 반환된다.

결과적으로 회원 데이터는 저장되고, 로그 데이터만 롤백 되는 것을 확인할 수 있다.

정리

  • 논리 트랜잭션은 하나라도 롤백되면 관련된 물리 트랜잭션은 롤백되어 버린다.
  • 이 문제를 해결하려면 REQUIRES_NEW를 사용해서 트랜잭션을 분리해야 한다.
  • 참고로 예제를 단순화 하기 위해 MemberServiceMemberRepository, LogRepository만 호출하지만 실제로는 더 많은 리포지토리들을 호출하고 그 중에 LogRepository만 트랜잭션을 분리한다고 생각해보면 이해가 된다.

주의

  • REQUIRES_NEW를 사용하면 하나의 HTTP 요청에 동시에 2개의 데이터베이스 커넥션을 사용하게 된다. 따라서 성능이 중요한 곳에서는 이런 부분을 주의해서 사용해야 한다.
  • REQUIRES_NEW를 사용하지 않고 문제를 해결할 수 있는 단순한 방법이 있다면, 그 방법을 선택하는 것이 더 좋다.

예를 들면 다음과 같이 REQUIRES_NEW를 사용하지 않고 구조를 변경하는 것이다.

이렇게 하면 HTTP 요청에 동시에 2개의 커넥션을 사용하지는 않는다. 순차적으로 사용하고 반환하게 된다.

물론 구조상 REQUIRES_NEW를 사용하는 것이 더 깔끔한 경우도 있으므로 각각의 장단점을 이해하고 적절하게 선택해서 사용하면 된다.

댓글남기기