8 분 소요


예제 프로젝트 시작

비즈니스 요구사항

  • 회원을 등록하고 조회한다.
  • 회원에 대한 변경 이력을 추적할 수 있도록 회원 데이터가 변경될 때 변경 이력을 DB LOG 테이블에 남겨야 한다.

Member

@Entity
@Getter @Setter
public class Member {

    @Id @GeneratedValue
    private Long id;
    private String username;

    public Member() {

    }

    public Member(String username) {
        this.username = username;
    }
}
  • JPA를 통해 관리하는 회원 엔티티

MemberRepository

@Slf4j
@Repository
@RequiredArgsConstructor
public class MemberRepository {

    private final EntityManager em;

    @Transactional
    public void save(Member member) {
        // 생략..
    }

    public Optional<Member> find(String username) {
        // 생략..
    }
}

Log

@Entity
@Getter @Setter
public class Log {

    @Id @GeneratedValue
    private Long id;
    private String message;

    public Log() {

    }

    public Log(String message) {
        this.message = message;
    }
}
  • JPA를 통해 관리하는 로그 엔티티

LogRepository

@Slf4j
@Repository
@RequiredArgsConstructor
public class LogRepository {

    private final EntityManager em;

    @Transactional
    public void save(Log logMessage) {
        em.persist(logMessage);

        if (logMessage.getMessage().contains("로그예외")) {
            throw new RuntimeException("예외 발생");
        }
    }

    public Optional<Log> find(String message) {
        // 생략...
    }
}
  • JPA를 사용하는 LogRepository는 저장과 조회 기능을 제공한다.
  • 중간에 예외 상황을 재현하기 위해 로그예외라고 입력하는 경우 예외를 발생시킨다.

MemberService

@Slf4j
@Service
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;
    private final LogRepository logRepository;

    public void joinV1(String username) {
        Member member = new Member(username);
        Log logMessage = new Log(username);

        log.info("== memberRepository 호출 시작 ==");
        memberRepository.save(member);
        log.info("== memberRepository 호출 종료 ==");

        log.info("== logRepository 호출 시작 ==");
        logRepository.save(logMessage);
        log.info("== logRepository 호출 종료 ==");
    }

        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.getMessage());
            log.info("정상 흐름 변환");
        }
        log.info("== logRepository 호출 종료 ==");
    }
}
  • 회원을 등록하면서 동시에 회원 등록에 대한 DB 로그도 함께 남긴다.
  • joinV1
    • 회원과 DB 로그를 함께 남기는 비즈니스 로직이다.
  • joinV2
    • joinV1()과 같은 기능을 수행한다.
    • DB 로그 저장시 예외가 발생하면 예외를 복구한다.

테스트 코드

@Slf4j
@SpringBootTest
class MemberServiceTest {

    @Autowired MemberService memberService;
    @Autowired MemberRepository memberRepository;
    @Autowired LogRepository logRepository;

    /**
    * MemberService @Transactional:OFF
    * MemberRepository @Transactional:ON
    * LogRepository @Transactional:ON
    */
    @Test
    void outerTxOff_success() {

        //given
        String username = "outerTxOff_success";
        
        //when
        memberService.joinV1(username);

        //then: 모든 데이터가 정상 저장된다.
        assertTrue(memberRepository.find(username).isPresent());
        assertTrue(logRepository.find(username).isPresent());
    }
}

참고

  • JPA의 구현체인 하이버네이트가 테이블을 자동으로 생성해준다.
  • 메모리 DB이기 때문에 모든 테스트가 완료된 이후에 DB는 사라진다.

JPA와 데이터 변경

  • JPA를 통한 모든 데이터 변경(등록, 수정, 삭제)에는 트랜잭션이 필요하다.(조회는 트랜잭션 없이 가능하다.)
    • 현재 코드에서 서비스 계층에 트랜잭션이 없기 때문에 리포지토리에 트랜잭션이 있다.


커밋, 롤백

서비스 계층(X) - 커밋

상황

  • 서비스 계층에 트랜잭션이 없다.
  • 회원, 로그 리포지토리가 각각 트랜잭션을 가지고 있다.
  • 회원, 로그 리포지토리 둘 다 커밋에 성공한다.
/**
* MemberService @Transactional:OFF
* MemberRepository @Transactional:ON
* LogRepository @Transactional:ON
*/
@Test
void outerTxOff_success() {

    //given
    String username = "outerTxOff_success";
    
    //when
    memberService.joinV1(username);
    
    //then: 모든 데이터가 정상 저장된다.
    assertTrue(memberRepository.find(username).isPresent());
    assertTrue(logRepository.find(username).isPresent());
}

35
36

  1. MemberService에서 MemberRepository를 호출한다. MemberRepository에는 @Transactional 애노테이션이 있으므로, 트랜잭션 AOP가 작동한다. 여기서 트랜잭션 매니저를 통해 트랜잭션을 시작한다.
    • 트랜잭션 매니저에 트랜잭션을 요청하면 데이터소스를 통해 커넥션을 획득하고, 해당 커넥션을 수동 커밋 모드로 변경해서 트랜잭션을 시작한다.
    • 그리고 트랜잭션 동기화 매니저를 통해 트랜잭션을 시작한 커넥션을 보관한다.
  2. MemberRepository는 JPA를 통해 회원을 저장하는데, 이때 JPA는 트랜잭션이 시작된 con1을 사용해서 회원을 저장한다.
  3. MemberRepository가 정상 응답을 반환했기 때문에 트랜잭션 AOP는 트랜잭션 매니저에 커밋을 요청한다.
  4. 트랜잭션 매니저는 con1을 통해 물리 트랜잭션을 커밋한다.
    • 이 시점에 신규 트랜잭션 여부, rollbackOnly 여부를 모두 체크한다.

@Transactional과 REQUIRED

  • 트랜잭션 전파의 기본 값은 REQUIRED이다. 따라서, 다음 둘은 같다.
    • @Transactional
    • @Transactional(propagation = Propagation.REQUIRED)
  • REQUIRED는 기존 트랜잭션이 없으면 새로운 트랜잭션을 만들고, 기존 트랜잭션이 있으면 참여한다.

서비스 계층(X) - 롤백

상황

  • 서비스 계층에 트랜잭션이 없다.
  • 회원, 로그 리포지토리가 각각 트랜잭션을 가지고 있다.
  • 회원 리포지토리는 정상 동작하지만 로그 리포지토리는 예외가 발생한다.
/**
 * MemberService @Transactional:OFF
 * MemberRepository @Transactional:ON
 * LogRepository @Transactional:ON Exception
*/
@Test
void outerTxOff_fail() {

    //given
    String username = "로그예외_outerTxOff_fail";

    //when
    assertThatThrownBy(() -> memberService.joinV1(username))
            .isInstanceOf(RuntimeException.class);

    //then: 완전히 롤백되지 않고, member 데이터가 남아서 저장된다.
    assertTrue(memberRepository.find(username).isPresent());
    assertTrue(logRepository.find(username).isEmpty());
}
  • 사용자 이름에 로그예외라는 단어가 포함되어 있으면 LogRepository에서 런타임 예외가 발생한다.
  • 트랜잭션 AOP는 해당 런타임 예외를 확인하고 롤백 처리한다.

37
38

  • MemberService에서 LogRepository를 호출하는데, 로그예외라는 이름을 전달한다.

LogRepository 응답 로직

  1. LogRepository는 트랜잭션C와 관련된 con2를 사용한다.
  2. 로그예외라는 이름을 전달해서 LogRepository에 런타임 예외가 발생한다.
  3. LogRepository는 해당 예외를 밖으로 던진다. 이 경우 트랜잭션 AOP가 예외를 받게된다.
  4. 런타임 예외가 발생해서 트랜잭션 AOP는 트랜잭션 매니저에 롤백을 호출한다.
  5. 트랜잭션 매니저는 신규 트랜잭션이므로 물리 롤백을 호출한다.

이 경우 회원은 저장되지만, 회원 이력 로그는 롤백되기 때문에, 데이터 정합성에 문제가 발생할 수 있다.

단일 트랜잭션

/**
 * MemberService @Transactional:ON
 * MemberRepository @Transactional:OFF
 * LogRepository @Transactional:OFF
*/
@Test
void singleTx() {

    //given
    String username = "singleTx";

    //when
    memberService.joinV1(username);

    //then: 모든 데이터가 정상 저장된다.
    assertTrue(memberRepository.find(username).isPresent());
    assertTrue(logRepository.find(username).isPresent());
}
  • MemberService@Transactional을 추가하고, 나머지 MemberRepository, LogRepository에서 @Transactional을 빼면서 단일 트랜잭션으로 설정했다.

39

  • 이렇게 하면 MemberService를 시작할 때 부터 종료할 때 까지의 모든 로직을 하나의 트랜잭션으로 묶을 수 있다.
    • MemberServiceMemberRepository, LogRepository를 호출하므로 이 로직들은 같은 트랜잭션을 사용한다.

40

  • @TransactionalMemberService에만 붙어있기 때문에 여기에만 트랜잭션 AOP가 적용된다.
    • MemberRepository, LogRepository는 트랜잭션 AOP가 적용되지 않는다.
  • MemberService의 시작부터 끝까지, 관련 로직은 해당 트랜잭션이 생성한 커넥션을 사용하게 된다.
    • 따라서, MemberService가 호출하는 MemberRepository, LogRepository도 같은 커넥션을 사용하면서 자연스럽게 트랜잭션 범위에 포함된다.

[참고]
같은 쓰레드를 사용하면 트랜잭션 동기화 매니저는 같은 커넥션을 사용한다.

각각 트랜잭션이 필요한 상황

41

트랜잭션 적용 범위
42

  • 클라이언트A는 MemberService부터 MemberRepository, LogRepository를 모두 하나의 트랜잭션으로 묶고 싶다.
  • 클라이언트B는 MemberRepository만 호출하고, 여기에만 트랜잭션을 사용하고 싶다.
  • 클라이언트C는 LogRepository만 호출하고, 여기에만 트랜잭션을 사용하고 싶다.

  • 클라이언트A만 생각하면 MemberService에 트랜잭션 코드를 남기고, MemberRepository, LogRepository의 트랜잭션 코드를 제거하면 깔끔하게 하나의 트랜잭션을 적용할 수 있다.
  • 하지만, 이렇게 되면 클라이언트 B, C가 호출하는 MemberRepository, LogRepository에는 트랜잭션을 적용할 수 없다.

트랜잭션 전파 없이 이런 문제를 해결하려면 트랜잭션이 있는 메서드와 트랜잭션이 없는 메서드를 각각 만들어야 할 것이다.

전파 커밋

44

신규 트랜잭션
45

  • 외부에 있는 신규 트랜잭션만 실제 물리 트랜잭션을 시작하고 커밋한다.
  • 내부에 있는 트랜잭션은 물리 트랜잭션을 시작하거나 커밋하지 않는다.
  • 모든 논리 트랜잭션을 커밋해야 물리 트랜잭션도 커밋되고, 하나라도 롤백되면 물리 트랜잭션은 롤백된다.
/**
 * 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());
}

흐름

47

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

전파 롤백

48

/**
 * 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());
}
  • 로그예외라고 넘겼기 때문에 LogRepository에서 런타임 예외가 발생한다.

흐름

49

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

정리

회원과 회원 이력 로그를 처리하는 부분을 하나의 트랜잭션으로 묶은 덕분에 문제가 발생했을 때 회원과 회원 이력 로그가 모두 함께 롤백된다. 따라서, 데이터 정합성에 문제가 발생하지 않는다.

복구 REQUIRED

하나의 트랜잭션으로 묶어서 데이터 정합성 문제를 깔끔하게 해결했지만, 회원 이력 로그를 DB에 남기는 작업은 가끔 문제가 발생해서 회원 가입 자체가 안되는 경우가 발생한다.
회원 이력 로그의 경우 추후에 복구가 가능할 것으로 보기에 회원 가입을 시도한 로그를 남기는데 실패하더라도 회원 가입은 유지되도록 비즈니스 요구사항을 변경했다.

50

  • 단순하게 생각하면 LogRepository에서 예외가 발생하면 MemberService에서 예외를 잡아서 문제를 처리할 수 있을것만 같다.
  • 하지만 이 방법으로 하면 실패하게 된다.
/**
 * 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(UnexpectedRollbackException.class);

    //then: 모든 데이터가 롤백된다.
    assertTrue(memberRepository.find(username).isEmpty());
    assertTrue(logRepository.find(username).isEmpty());
}

memberService.joinV2()

try {
    logRepository.save(logMessage);
} catch (RuntimeException e) {
    log.info("log 저장에 실패했습니다. logMessage={}", logMessage);
    log.info("정상 흐름 변환");
}

흐름

52

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

정리

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

복구 REQUIRES_NEW

회원 가입을 시도한 로그를 남기는데 실패하더라도 회원 가입은 유지되어야 한다.
요구사항을 만족하기 위해서 로그와 관련된 물리 트랜잭션을 별도로 분리한다.

/**
 * MemberService @Transactional:ON
 * MemberRepository @Transactional:ON
 * LogRepository @Transactional(REQUIRES_NEW) Exception
*/
@Test
void recoverException_success() {

    //given
    String username = "로그예외_recoverException_success";

    //when
    memberService.joinV2(username);

    //then: member 저장, log 롤백
    assertTrue(memberRepository.find(username).isPresent());
    assertTrue(logRepository.find(username).isEmpty());
}

LogRepository - save()

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void save(Log logMessage){...}

기존 트랜잭션에 참여하는 REQUIRED대신에, 항상 신규 트랜잭션을 생성하는 REQUIRES_NEW를 적용.

REQUIRES_NEW - 물리 트랜잭션 분리
53

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

REQUIRES_NEW - 복구
54

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

흐름

55

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

정리

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

[주의]

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

예를 들면 다음과 같이 REQUIRES_NEW를 사용하지 않고, 구조를 변경하는 것이다.
56
이렇게 하면 HTTP 요청에 동시에 2개의 커넥션을 사용하지 않고, 순차적으로 사용하고 반환하게 된다.


<출처 : 인프런 - 스프링 DB 2편 : 데이터 접근 활용 기술(김영한)>

댓글남기기