개발자로 후회없는 삶 살기

spring PART.트랜잭션 전파 활용 본문

[백엔드]/[spring | 학습기록]

spring PART.트랜잭션 전파 활용

몽이장쥰 2023. 5. 23. 01:24

서론

※ 이 포스트는 다음 강의의 학습이 목표임을 밝힙니다.

https://www.inflearn.com/roadmaps/373

 

본론

- 트랜잭션 활용

지금까지 배운 트랜잭션 전파에 대한 내용을 실제 예제로 알아봅니다.

 

 

- 요구사항

예제를 위한 간단한 요구사항을 만듭니다. 회원을 등록하고 조회할 수 있어야하고 회원에 대한 변경 이력을 추적할 수 있도록 회원 데이터가 변경될 때 변경 이력을 DB LOG 테이블에 남겨야 합니다.(회원가입을 하면 회원 DB에도 저장이되고 로그 DB에도 저장이 되어야합니다.) 회원을 가입하거나 수정할 때 왜 가입하고 왜 수정했는지 테이블에 남겨야하는게 요구사항입니다. 여기서는 가입시에만 남깁니다.

 

- 구현
-> 도메인

@Entity
@Getter
@Setter
public class Member {
    @Id
    @GeneratedValue
    private Long id;
    private String username;

    public Member() {
    }

    public Member(String username) {
        this.username = username;
    }
}

회원 가입을 위한 Member 객체를 만듭니다. id와 이름 필드가 있고 jpa는 스펙상 기본 생성자가 있어야합니다.

 

-> 리포지토리

public class MemberRepository {
    private final EntityManager em;

    @Transactional
    public void save(Member member) {
        log.info("member 저장");
        em.persist(member);
    }

    public Optional<Member> find(String username) {
        return em.createQuery("select m from Member m where m.username = :username")
                .setParameter("username", username)
                .getResultList().stream().findAny();
    }
}

이를 관리하는 레포를 만듭니다. 스데제 말고 순수 jpa로 레포를 만들 것입니다. 저장, 이름으로 조회를 만듭니다. 이름으로 조회는 하나만 조회할 것이라서 getSingleList()를 써도 되는데 이것은 값이 없으면 예외를 터뜨려버려서 stream에서 any를 써서 여러개 찾았을 경우 그 중 먼저 찾은 하나를 반환하도록 합니다.

 

-> 로그 엔터티

@Entity
public class Log {
    @Id
    @GeneratedValue
    private Long id;
    private String message;

    public Log() {
    }

    public Log(String message) {
        this.message = message;
    }
}

로그를 남길 엔터티를 만듭니다. id와 message 필드를 가집니다.

 

@Slf4j
@Repository
@RequiredArgsConstructor
public class LogRepository {
    private final EntityManager em;

    @Transactional
    public void save(Log logMessage) {
        log.info("log 저장");
        em.persist(logMessage);

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

이를 관리하는 레포를 만듭니다. 테이블에 로그를 저장하는 메서드를 만듭니다. 매개변수로 들어온 로그 이름이 "로그 예외"라면 로그 저장시 예외 발생으로 런타임 예외를 던집니다. 이러면 @트랜잭션에서 발생한 런타임 예외라서 롤백될 것입니다.

 

public Optional<Log> find(String message) {
    return em.createQuery("select l from Log l where l.message = :message", Log.class)
            .setParameter("message", message)
            .getResultList().stream().findAny();
}

find 메서드도 만듭니다.

 

-> 서비스

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 호출 종료 ==");
}

회원가입을 수행하는 서비스를 만듭니다. 멤버 서비스는 회원이니 회원을 저장하고 find 하는 게 있을 것입니다. 가입은 이름만 있으면 가입되게 하고 로그 메세지를 로그 DB에 남겨야하는데 로그 메세지를 사용자 명으로 할 것입니다. 멤버 레포에도 저장하고 로그 레포에도 저장합니다. 지금 서비스에 트랜잭션을 붙이지 않고 멤버 레포의 save와 log 레포의 save에 각각 트랜잭션을 붙였습니다. 트랜잭션 2개를 각각 사용하는 예제입니다. 나중에는 서비스에도 트랜잭션을 넣고 할 것입니다.

 

> join을 V1, V2를 가지게 할 것입니다. V2는 로그를 저장하다가 회원 가입이 롤백되는게 싫은 경우입니다. 지금 "예외로그"로 로그가 저장되면 @트랜잭션에서 롤백이 터지는데 이게 외부 내부가 생겨서 물리 트랜잭션이 생기면 로그 저장하다가 전체 트랜잭션이 롤백하는 상황이 올 것이고 그게 싫어서 v2를 만듭니다. 그래서 try catch로 잡아서 계속 던져지지 않게 하는 코드를 넣습니다.

마치 이전에 작성한 레포에서 발생한 스프링 추상화 예외를 서비스에서 잡는 것과 같은 것으로 비즈니스 로직상으로 잡는 것입니다.

 

 

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.getMessage());
        log.info("정상 흐름 반환");
    }
    log.info("== logRepository 호출 종료 ==");
}

그니깐 지금 트랜잭션 여러개 써서 하나 롤백하면 다 롤백되는 것은 아닌데 @트랜잭션 프록시 코드가 기본적으로 try catch해서 롤백하고 런타임 예외 던지니깐 레포에서 발생한 예외가 던져져서 서비스로 올라올 것이고 그것을 단순히 잡는 것입니다. 로그저장이 실패했지만 정상흐름으로 바꿀 것 입니다. 로그를 계속 던지는 V1, 로그 정도는 그냥 잡자는 V2를 만든 것입니다.

 

- 테스트

서비스 비즈 로직을 테스트합니다. 

1) outerTxOff_success

/**
 * MemberService @Transactional:OFF
 * MemberRepository @Transactional:ON
 * LogRepository @Transactional:ON
 */

@Test
void outerTxOff_success() {
    String username = "outerTxOff_success";

    memberService.joinV1(username);

    Assertions.assertTrue(memberRepository.find(username).isPresent());
    Assertions.assertTrue(logRepository.find(username).isPresent());
}

이 테스트는 밖에 트랜잭션이 없는 경우로 서비스에 트랜잭션이 없고 레포 2개에 트랜잭션이 있는 상황입니다. 2개의 트랜잭션은 연관이 없고 각각 진행됩니다.

> 사용자를 저장하면 모든 데이터가 정상 저장되어야합니다. Assertions junit에 assertTrue라고 있고 find의 반환이 옵셔널인데 옵셔널은 상자에 담고 있는 개념이라서 존재하냐, 비었냐 등 여러가지 메서드가 있습니다. 회원이랑 로그 둘 다 find 했는데 존재하면 assertTrue 테스트가 성공입니다. 

+ 이렇게 어플리케이션에 jpa를 쓰니 정말 정말 경의로운 기술입니다. 엔터티 정보봐서 테이블 만들어주고 내장 DB 사용하고 select, insert 쿼리 다 날려줍니다. 

> 다음부터 상황들을 하나씩 늘려가면서 지금까지 배운 트랜잭션 전파, 트랜잭션 원리를 확인해보겠습니다.

 

- 커밋

서비스 계층에 트랜잭션이 없을 때 커밋하는 것을 볼 것이고 이때 트랜잭션이 각각 어떻게 동작하는 지 볼것입니다.

 

이 예제는 예전에 각각 트랜잭션이 돌아가는 예제 입니다. 아예 상관없이 순서대로 호출되는 것입니다. 방금 실행한 것이 이 예제입니다.

 

-> 그림

서비스에서 멤버 레포의 트랜잭션을 먼저 호출하고 여기서 트랜잭션 매니저를 통해서 커낵션 생성하고 동기화 매니저에 저장한 후 내부적으로 멤버 트랜잭션이 생성한 커낵션을 사용하고 커밋을 호출하면 매니저에 커밋을 요청하고 db에 회원이 저장됩니다. 로그 레포도 마찬가지로 동작합니다.

 

/**
 * MemberService @Transactional:OFF
 * MemberRepository @Transactional:ON
 * LogRepository @Transactional:ON
 */
@Test
void outerTxOff_success() {
    String username = "outerTxOff_success";

    memberService.joinV1(username);

    Assertions.assertTrue(memberRepository.find(username).isPresent());
    Assertions.assertTrue(logRepository.find(username).isPresent());
}


물론 이 시점에 커낵션 매니저가 rollbackonly, NEW 등 모든 것을 신경씁니다. 하지만 각각 쓰는 상황이라서 트랜잭션 매니저가 관리하는 물리, 논리 트랜잭션을 생각할 필요가 없습니다.

 

- 롤백

위는 커밋이 일어난 경우고 서비스 계층에 트랜잭션이 없을 때 롤백이 일어나는 것을 보겠습니다. 멤버 레포는 커밋하는데 로그 레포에서 롤백이 일어난 상황입니다.

 

-> 테스트

/**
 * MemberService @Transactional:OFF
 * MemberRepository @Transactional:ON
 * LogRepository @Transactional:ON Exception
 */

@Test
void outerTxOff_fail() {
    String username = "로그예외outerTxOff_fail";

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


    Assertions.assertTrue(memberRepository.find(username).isPresent());
    Assertions.assertTrue(logRepository.find(username).isEmpty());
}

로그 레포에서 예외를 터뜨리기 위해서 로그 예외를 사용자 명으로 넣어야합니다. 그러면 로그 레포에서 런타임 예외를 던져서 롤백처리할 것입니다. 

실행해보면 예외가 터지는 게 맞습니다. 로그 레포에서 롤백이 터져서 멤버는 저장이 되지만 멤버를 롤백이 되어서 find 결과 isEmpty합니다. 이것이 트랜잭션이 각각 동작하기에 별도록 커밋과 롤백을 따로 합니다.

 

 

회원은 정상적으로 잘 동작했습니다. @트랜잭션에서 로직이 정상 작동해서 커밋하고 트랜잭션을 끝냅니다. 그러면 순차적으로 호출해서 로그 레포의 save를 하는데 또 신규 트랜잭션이라서 런타임 예외가 발생해서 롤백이 일어난 것입니다.

 

> 로그레포에서 예외가 발생하여 트랜잭션이 롤백을 요청한 것이고 매니저에 요청해서 신규인지 확인했는데 별도의 신규 트랜잭션이라서 실제 물리적으로 DB에 롤백합니다. 만약 요구사항이 로그와 회원 저장을 무조건 맞춰야한다면 이 둘을 하나의 트랜잭션으로 묶어야합니다. 

 

- 단일 트랜잭션

위를 하나의 트랜잭션으로 묶습니다. 회원, 로그 레포에 하나의 트랜잭션을 쓰게하려면 이 둘을 호출하는 서비스에 트랜잭션을 쓰는 것입니다.

 

-> 테스트

/**
 * MemberService @Transactional:ON
 * MemberRepository @Transactional:OFF
 * LogRepository @Transactional:OFF
 */
@Test
void outerTxOff_single() {
    String username = "outerTxOff_single";

    memberService.joinV1(username);

    Assertions.assertTrue(memberRepository.find(username).isPresent());
    Assertions.assertTrue(logRepository.find(username).isPresent());
}

singleTx 메서드를 만들고 이 경우는 서비스에서만 트랜잭션을 쓰고 나머지 레포 둘은 트랜잭션을 안 쓰는 경우입니다. 레포 2개의 @트랜잭션을 없애고 서비스에 @트랜잭션을 붙입니다. 

 

이렇게 하고 처음에 한 성공 로직을 돌려보면 트랜잭션을 처음에만 만들고 로그 레포까지 완전히 종료하고 난 후에 커밋합니다.

 

-> 그림

서비스에서 트랜잭션을 호출했으므로 내가 호출하는 로직은 다 내 트랜잭션 범위 안으로 들어오는 것입니다. 멤버 서비스를 시작부터 종료할 때까지 모든 로직을 하나의 트랜잭션으로 묶을 수 있습니다. 멤버 서비스만 트랜잭션을 처리하기 때문에 앞서 배운 논리, 물리, 외부, 내부, rollbackonly, 신규, 전파 등 복잡한 고민을 할 필요가 없습니다. 아주 깔끔하게 트랜을 묶을 수 있습니다.

 

서비스에만 트랜잭션 AOP가 적용되고 로그와 멤버는 순수한 실제 코드입니다. 멤버 서비스의 시작부터 끝까지, 관련 로직은 해당 트랜잭션의 영역으로 해당 트랜잭션이 생성한 커낵션을 사용하게 됩니다. 멤버 서비스의 트랜잭션을 시작할 때 동기화 매니저에 커낵션을 넣을 텐데 이것을 비즈 로직이 사용한다고 했고 지금 그 이후의 비즈 로직이 멤버 레포, 로그 레포이고 이것들이 다 같은 트랜잭션 범위로 적용되어 멤버 서비스 트랜잭션 시작할 때 만든 커낵션을 사용합니다. 예전에 다 배운 것인데 예제로 보니 또 달라 보입니다.

 

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

지금은 서비스에서 단일 트랜잭션을 걸었습니다. 근데 각각 트랜잭션이 필요한 상황이면 어떻게 할까요?

 

ex) 예를들어서 클라 A는 서비스를 호출하는데 B는 멤버 레포를 바로 호출하여 멤버 레포 범위 안에서만 트랜잭션을 쓰고 싶은 것입니다.

> 클라 A는 서비스 + 멤버 + 로그 레포를 묶어서 하나의 트랜잭션으로 쓰고 싶은데 클라 B는 멤버 레포만 호출하고 여기만 트랜잭션을 적용하고 싶습니다. 클라 A만 생각하면 서비스에만 @트랜잭션을 적용하고 레포에는 @트랜잭션을 다 빼면 됩니다. 하지만 이렇게 하면 클라 B, C가 호출하는 레포는 트랜잭션을 적용할 수 없습니다.

 

 

memberRepository.save()를 했는데 트랜잭션이 안 되고 근데 필요한데 넣자니 서비스의 트랜잭션과 겹치고 안넣자니 충돌이 일어나는 그런 상황입니다.

 

-> 해결

그래서 트랜잭션 전파가 있는 것입니다. 전파가 없었으면 트랜잭션이 있는 메서드와 없는 메서드를 각각 만들어야 할 것입니다. 이렇게 실무에서는 모든 서비스와 레포가 각각 어떻게 호출될 지 모르는 복잡한 상황들이 계층으로 발생합니다.

 

- 트랜잭션 전파 커밋

스프링은 @트랜잭션이 있으면 기본으로 참여하고 없으면 트랜잭션을 생성하는 전파 옵션을 사용합니다. 참여한다는 것은 해당 트랜잭션을 그대로 따른다는 것이고 동시에 같은 동기화 커낵션을 사용한다는 뜻입니다.

이렇게 둘 이상의 트랜잭션이 하나의 물리 트랜잭션으로 묶이면 논리, 물리 트랜잭션을 구분해야합니다. 트랜잭션이 시작한 서비스가 외부 트랜잭션이고 레포가 내부 트랜잭션입니다.

 

-> 신규 트랜잭션

이제 레포의 @트랜잭션을 작성하고 서비스에도 @트랜잭션이 있습니다.  이제는 서비스와 레포 다 트랜잭션 프록시가 만들어집니다. 그리고 서비스가 외부 트랜잭션으로 신규 트랜잭션이고 여기서만 물리 트랜잭션을 관리할 수 있습니다. 레포는 참여를 해서 물리 트랜잭션에 관여를 할 수 없고 논리 트랜잭션이 다 커밋해야 최종적으로 다 커밋하고 하나라도 롤백하면 외부에서 롤백하면 자연스럽게 롤백하고 내부에서 롤백하면 rollbackonly로 롤백할 것입니다.

 

- 테스트

/**
 * MemberService @Transactional:ON
 * MemberRepository @Transactional:ON
 * LogRepository @Transactional:ON
 */
@Test
void outerTxOn_success() {
    String username = "outerTxOn_success";

    memberService.joinV1(username);

    Assertions.assertTrue(memberRepository.find(username).isPresent());
    Assertions.assertTrue(logRepository.find(username).isPresent());
}

모든 논리 트랜잭션이 커밋되는 정상 상황을 만들어봅니다. outerTxOn_success 메서드를 만들고 모두 트랜잭션을 사용하고 서비스와 2개의 레포가 다 성공하는 경우를 만듭니다.

 

-> 그림

처음에 요청이 멤버 서비스에 joinV1으로 가면 서비스 프록시에서 트랜잭션을 시작하면서 매니저가 커낵션 만드는데 신규인지 봐서 신규라서 동기화 매니저에 커낵션을 넣습니다. 이후 로직을 수행하고 논리 트랜잭션인 회원 레포의 트랜잭션을 시작하고 신규인지 봤는데 아니라서 기존 트랜잭션에 참여하고 커낵션을 만들지 않고 동기화 매니저에 서비스에서 만든 커낵션을 사용합니다. 

> 응답에서는 멤버 레포에서 먼저 커밋을 요청하는데 신규가 아니라서 커밋을 하지 않고 서비스까지 오고 서비스가 신규라서 커밋합니다. 이전에 배운 것과 정확이 일치합니다.

 

- 트랜잭션 전파 롤백

전파할 때의 롤백하는 경우를 알아봅니다. 로그 레포에서 예외가 터져서 전체 트랜잭션이 롤백되는 경우를 보겠습니다.

 

-> 그림

멤버 레포는 커밋했는데 로그에서 롤백해서 전체적으로 롤백해야합니다. 논리 트랜잭션 중 하나라도 롤백하면 전체가 다 롤백한다고 했습니다. 로그에서 롤백이 터지면 서비스로 날라와서 서비스에서도 런타임 예외가 터져서 또 롤백이 될 것입니다.

 

- 테스트

/**
 * MemberService @Transactional:ON
 * MemberRepository @Transactional:ON
 * LogRepository @Transactional:ON Exception
 */
@Test
void outerTxOn_fail() {
    String username = "로그예외outerTxOff_fail";

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


    Assertions.assertTrue(memberRepository.find(username).isEmpty());
    Assertions.assertTrue(logRepository.find(username).isEmpty());
}

outerTxOn_fail 메서드를 만듭니다. 전부 다 트랜잭션을 사용하는데 로그 레포에서 예외가 발생합니다. joinV1을 하는데 회원은 잘 저장하고 로그를 저장하려고 하는데 "로그예외"가 회원 이름에 있어서 예외가 터집니다. 그러면 내부 트랜잭션인 로그 레포 트랜잭션은 물리 트랜잭션에 영향을 끼칠 수 없어서 롤백 마크를 할 것입니다.

 

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

그런데 그 런타임예외가 서비스에도 올라옵니다. 그래서 서비스에서도 물리적 롤백이 터지는 것이고 서비스에서는 커밋해서 기대하지 않은 롤백 예외가 터지는 상황은 아닙니다. 결국 내부 트랜잭션에서 롤백이 터지니 멤버 레포도 롤백되어 전체 트랜잭션이 다 롤백되어야 합니다.

 

-> 그림

흐름을 보면 커낵션을 처음 만든 것은 서비스이고 서비스가 외부, 신규 트랜잭션입니다. 서비스는 멤버와 로그 레포가 다 성공해야 커밋합니다. 서비스의 로직이 멤버, 로그 레포를 사용하기 때문입니다. 따라서 멤버 레포를 먼저하는데 여기서는 커밋을 해도 신규가 아니라서 커밋 안합니다. 로그 레포도 롤백이 일어나도 실제 롤백을 하지 않고 rollbackonly를 남깁니다.

 

서비스에까지 그 예외가 던져져서 서비스에서도 예외가 터져서 롤백이 터지고 서비스는 신규라서 물리 트랜잭션에 롤백합니다. 따라서 rollbackonly 때문에 롤백도 일어날 거였지만 서비스에서도 터져서 물리 롤백이 일어난 것입니다. 따라서 서비스에서 롤백하는 시점에 트랜잭션 매니저가 동기화 매니저의 rollbackonly 설정를 참고하지 않습니다. > 서비스에서 잡았으면 서비스에서는 예외가 발생하지 않았을 것입니다.

 

- 정리

회원과 회원 이력 로그를 처리하는 부분을 하나의 트랜잭션으로 묶은 덕분에 회원과 로그가 모두 함께 롤백됩니다. 따라서 데이터 정합성인 하나는 저장되고 다른 하나는 저장안되는 상황이 발생되지 않습니다.

 

- 복구 REQUIRED

레포에서 예외가 터졌을 때 서비스에서 잡는 것을 합니다. 실무에서 진짜 실수 많이 하는 내용입니다.

 

앞서 정합성 문제를 트랜잭션을 묶어서 회원과 로그 중 하나라도 롤백되면 다 같이 롤백되게 해서 해결했습니다. 그런데 로그를 남기는 작업에 문제가 발생하면 전체 롤백이 되어서 멤버 레포에 저장한 것까지 안 됩니다. 즉 회원 가입 자체가 안되는 상황이 발생합니다. 그래서 사용자들이 회원 가입에 실패해서 이탈하는 문제가 발생하기 시작했습니다.

 

> 열심히 가입했는데 오류가 난 것입니다. 그래서 "회원가입을 시도한 로그를 남기는데 실패하더라도 회원 가입은 유지되어야한다."고 요구사항을 바꿉니다. > 정합성 문제를 생각해서 둘이 같이 저장되고 같이 롤백되게 했었는데 그러지 말고 로그는 롤백되고 회원은 커밋되기를 원하는 것입니다.

 

단순히 생각하면 로그 레포에서 예외가 터지면 서비스에서 잡아 정상흐름으로 바꾸면 서비스에서 롤백하지 않고 커밋을 할 수 있습니다. 하지만 이 방법은 실패합니다. 참고로 실무에서는 진짜 복잡한 로직이 많을 텐데 이 방법을 사용해서 실패합니다. 로그의 rollbackonly 때문에 전체가 롤백할 것입니다.

 

- 테스트

@Test
void recoverException_fail() {
    String username = "로그예외recoverException_fail";

    assertThatThrownBy(() -> memberService.joinV2(username))
            .isInstanceOf(UnexpectedRollbackException.class);

    Assertions.assertTrue(memberRepository.find(username).isEmpty());
    Assertions.assertTrue(logRepository.find(username).isEmpty());
}

이제 joinV2를 사용합니다. 회원 save하고 로그에서 런타임 예외가 터지는데 서비스에서 잡습니다. 그리고 정상흐름으로 반환합니다. 예외를 잡아서 복구한 것이니 트랜잭션이 정상 커밋할 것이라고 생각할 수 있습니다. 근데 돌려보면  UnexpectedRollbackException 발생이 True로 성공합니다. 롤백이 된 것입니다. 전체가 다 롤백되어서 로그와 회원 데이터가 다 없습니다. 정상흐름으로 반환할 줄 알았는데 롤백된 것입니다.

 

-> 복구가 안 된 이유

// 서비스 코드
try {
    logRepository.save(logMessage);
} catch (RuntimeException e) {
    log.info("log 저장에 실패 = {}", logMessage.getMessage());
    log.info("정상 흐름 반환");
}

바로 내부 트랜잭션인 로그 트랜잭션에서 롤백이 되는데 신규가 아니라서 롤백이 안되고 setRollBackOnly를 동기화 매니저의 커낵션에 설정합니다. 서비스 입장에서는 try catch로 예외를 잡았으니 정상이니 커밋이 될 줄 알았는데

 

커밋을 하려고 매니저에게 요청하니 매니저가 동기화 매니저를 봐서 rollbackonly를 보고 커밋하면 안되는 구나 하고 롤백을 하고 UnexpectedRollbackException 예외를 터뜨립니다. 


- 정리

논리 트랜잭션 중 하나라도 롤백하면 전체가 다 롤백합니다. 내부 트랜잭션이 롤백됐는데, 외부 트랜잭션이 커밋하면 UnexpectedRollbackException이 발생합니다. 개발하다가 갑자기 UnexpectedRollbackException이 예외가 터지면 이 어디선가 rollbackonly 마크를 했겠구나를 알 수 있을 것입니다.

 

- 복구 REQUIRES_NEW

그렇다면 요구사항을 만족하려면 어떻게 해야할까요 로그를 남기는데는 실패하지만 회원 저장은 유지되어야합니다. REQUIRES_NEW 옵션을 가지고 이 문제를 해결합니다. 이를 만족하기 위해서 로그와 관련된 물리 트랜잭션을 별도로 분리할 수 있습니다. 이거 한 줄 넣으면 끝납니다.

 

- 테스트

/**
 * MemberService @Transactional:ON
 * MemberRepository @Transactional:ON
 * LogRepository @Transactional:ON(REQUIRES_NEW) Exception
 */
@Test
void recoverException_success() {
    String username = "로그예외recoverException_success";

    memberService.joinV2(username);

    Assertions.assertTrue(memberRepository.find(username).isPresent());
    Assertions.assertTrue(logRepository.find(username).isEmpty());
}

로그 트랜잭션을 분리하는 것입니다. 회원은 저장되는데 로그를 롤백되기를 원합니다. 회원은 present하고 로그는 empty해야합니다.

 

@Slf4j
@Repository
@RequiredArgsConstructor
public class LogRepository {
    private final EntityManager em;

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

로그 레포의 save 메서드에 @트랜잭션에 REQUIRES_NEW를 넣고 테스트하면 테스트에 성공합니다. 롤백이 단독으로 터져야 하는 내부 트랜잭션에 REQUIRES_NEW를 넣어야합니다. 멤버는 저장이 되고 로그만 롤백이 됩니다. 

 

-> 설명

서비스에서 트랜잭션을 시작해서 멤버 레포의 save를 하고 끝나고 로그 save를 했는데 예외가 호출해서 내부 트랜잭션이니 rollbackonly하려고 했는데 REQUIRES_NEW입니다. 그러면 이 트랜잭션은 완전 별도의 트랜잭션 커낵션을 사용하는 별도의 영역이 되는 것입니다. 여기서 롤백하면 여기만 롤백되고 밖에 영향을 주지 않습니다. 

 

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

그런데 롤백이 되더라도 로그의 save에서 런타임 예외가 발생한 것이라서 이게 던져져서 서비스로 갑니다. 하지만 서비스에서 잡고 정상흐름으로 반환해서 커밋이 되고 rollbackonly가 없어서 정상 커밋이 됩니다.

 

-> 그림

REQUIRES_NEW를 해서 서비스와 레포는 하나의 커낵션을 쓰는 하나의 트랜잭션으로 묶이게 되고 로그는 별도의 트랜잭션을 씁니다. 로그는 완전히 새로운 물리 트랜잭션을 쓰는 것이라서 여기서 롤백하면 물리 트랜잭션에 논리 트랜잭션 자체가 물리 트랜잭션인 신규 트랜잭션이 되는 상황이기 때문에 롤백이나 커밋되면 바로 DB에 적용하고 끝내버립니다. 근데 예외는 던져지고 서비스가 이걸 잡으니 서비스와 멤버 레포는 정상 커밋이 되는 것입니다.

제가 이전에 한 위 코드도 레포와 서비스에 둘 다 @트랜잭션을 붙이고 둘 다 예외가 발생해서 서비스에서 잡더라도 롤백이 되는 것이었습니다. 레포에서 rollbackonly가 붙기 때문입니다.

 

- 정리

논리 트랜잭션은 하나라도 롤백되면 관련 물리 트랜잭션이 전체가 다 롤백되어 버립니다. 그래서 이 문제를 해결하려면 REQUIRES_NEW를 사용해서 트랜잭션을 분리해야 합니다. 로그 레포만 분리해서 롤백하게 하는 것이라고 이해하면 됩니다.

 

※ 주의

근데 REQUIRES_NEW를 쓰면 하나의 HTTP 요청에서 2개의 커낵션을 씁니다. 서비스의 커낵션이 있고 로그의 커낵션을 쓸 때 서비스 트랜잭션을 보류 상태로 만드는 것이라서 서비스 커낵션을 잠깐 안 쓰고 로그 커낵션을 쓰는 것입니다. 그런데 이러면 DB 입장에서는 커낵션이 둘 다 걸려있는 것입니다. 어플입장에서도 커낵션 풀에서 커낵션을 2개를 가져다 쓰고 있는 것입니다. 따라서 성능에 주의를 해야하고 REQUIRES_NEW 대신에 더 단순한 방법이 있다면 그 방법을 쓰는 것이 좋습니다.

 

예를 들어서 이렇게 할 수 있습니다. 멤버 서비스 앞 단에 뭘 하나 만들고 서비스는 멤버 서비스로 멤버 레포만 건들고 로그 레포는 안 건드리는 것입니다. 멤버 서비스와 로그 레포에 트랜잭션 붙이고 Facade에는 붙이지 않아 애초에 둘을 묶지 않고 분리를 하면 회원 저장하고 끝나면 로그 저장하는 맨 처음에 트랜잭션이 묶이지 않고 순서대로 따로 따로 동작하는 예시처럼 되고 따라서 두개 동시에 커낵션을 사용하는 일이 필요없습니다. 편한 구조를 원하면 REQUIRES_NEW를 쓰고 성능을 원하면 처음부터 분리하는게 좋습니다.

Comments