스프링 트랜잭션 전파 활용

바그다드·2023년 8월 4일
0

스프링 트랜잭션

목록 보기
5/5

지난 포스팅에서 스프링 트랜잭션 전파에 대해서 알아보았다. 이번 포스팅에서는 문제가 발생했을 때 상황에 따라 트랜잭션 전파를 활용해 해결하는 방법에 대해 알아보자.

1. 트랜잭션 분리

  • 각 리포지토리에서 트랜잭션을 생성하는 상황이다.

MemberService

    public void joinV1(String username) {
    	// 회원 정보 저장
        memberRepository.save(member);
        // 로그 정보 저장
        logRepository.save(logMessage);
    }

MemberRepository

  • 회원 정보 저장 기능
    @Transactional
    public void save(Member member) {
    }

    public Optional<Member> find(String username) {
    }

LogRepository

  • 로그 정보 저장 기능
    @Transactional()
    public void save(Log logMessage) {
    }

    public Optional<Log> find(String message) {
    }

트랜잭션을 리포지토리에서 시작하므로,
memberRepository.save와 logRepository.save는 각각 다른 트랜잭션으로 처리된다.
따라서 로그 저장이 실패하더라도 회원 저장은 영향을 받지 않는다.

2. 외부 트랜잭션

  • 서비스에서 트랜잭션을 시작하는 상황이다.

MemberService

    @Transactional
    public void joinV1(String username) {
    	// 회원 정보 저장
        memberRepository.save(member);
        // 로그 정보 저장
        logRepository.save(logMessage);
    }

MemberRepository

    public void save(Member member) {
    }

    public Optional<Member> find(String username) {
    }

LogRepository

    public void save(Log logMessage) {
    }

    public Optional<Log> find(String message) {
    }

트랜잭션을 서비스에서 시작하므로,
memberRepository.save와 logRepository.save는 하나의 물리 트랜잭션으로 묶인다.
따라서 하나의 작업이라도 예외가 발생하면 트랜잭션은 롤백된다.

여기까지는 되게 단순한 상황이다.
그런데 만약, 로그만 따로 저장하고 싶다면? 아니면 회원 정보만 따로 저장하고 싶다면? 내부 트랜잭션에서 예외가 발생했다면? 다른 서비스 계층에서 MemberService를 참조한다면?
여기서부터는 점점 뇌에 과부하가 오기 시작한다.

3. 전파 커밋

위의 예시는 서비스단에서만 트랜잭션을 사용하였기 때문에 자연스럽게 두개의 작업도 하나의 트랜잭션으로 묶여 처리되었다. 하지만 아래의 상황이라면 어떨까?

만약 트랜잭션 전파가 없다면
클라이언트 A는 MemberService를 그대로 사용하면 되지만, 클라이언트 B와 C는 리포지토리에 접근하기 때문에 트랜잭션 없이 작업을 진행해야 한다.
때문에 개발자는 MemberRepository와 LogRepository에 @Transactional이 있는 메서드와 없는 메서드를 각각 따로 만들어야 할 것이다.
아래와 같이 더 복잡한 상황이 발생할 수도 있다.

이런 복잡한 문제를 해결할 수 있는 방법이 트랜잭션 전파이다. 코드로 확인해보자.

MemberService

    @Transactional
    public void joinV1(String username) {
        memberRepository.save(member);
        
        logRepository.save(logMessage);
    }

MemberRepository

    @Transactional
    public void save(Member member) {
    }

    public Optional<Member> find(String username) {
    }

LogRepository

    @Transactional
    public void save(Log logMessage) {
    }

    public Optional<Log> find(String message) {
    }
  • 2번의 예시와는 달리 리포지토리에도 @Transactional이 걸려있다.
    MemberRepository만 사용하는 클라이언트는 MemberRepository.save를 사용해도 트랜잭션을 적용할 수 있다.
    LogRepository만 사용하는 클라이언트도 LogRepository.save만 사용하더라도 트랜잭션을 적용할 수 있다.

  • 이것이 가능한 이유는 트랜잭션 전파 때문이다.

  • @Transactional을 확인해보면 REQUIRES가 default값으로 설정이 되어 있는데, 이 값은 지난 포스팅에서 얘기했듯이
    트랜잭션이 없으면 새로운 트랜잭션을 생성하고,
    트랜잭션이 있다면 기존 트랜잭션에 참여한다고 했다.

  • 따라서 클라이언트가 MemberService에 접근을 하든,MemberRepository에 접근을 하든, LogRepository에 접근을 하든 유연하게 트랜잭션을 적용할 수 있는 것이다.

물론 논리 트랜잭션 중 하나의 트랜잭션이라도 실패하면 물리 트랜잭션은 롤백이 될 것이다.

  • 하지만 서비스에서 예외를 복구하려고 하면 어떻게 해야할까?
    이 상황에서 직면할 수 있는 문제와 해결 방법에 대해서 알아보자.

4. 전파 롤백 - 예외 처리 실패

  • 서비스에서 트랜잭션을 시작
  • 리포지토리는 기존 트랜잭션에 참여
  • 로그 저장중에 예외가 발생시 서비스에서 예외 복구 시도

MemberService

  • 예외 처리 로직 추가
    @Transactional
    public void joinV2(String username) {
    	// 회원 정보 저장
        memberRepository.save(member);
        
        // 로그 정보 저장
        try {
            logRepository.save(logMessage);
        } catch (RuntimeException e) {
            log.info("log 저장에 실패했습니다. logMessage={}",logMessage.getMessage());
            log.info("정상 흐름 반환");
        }
    }

MemberRepository

    @Transactional
    public void save(Member member) {
    }

    public Optional<Member> find(String username) {
    }

LogRepository

  • 예외 발생 로직 추가
    @Transactional
    public void save(Log logMessage) {
    	if (logMessage.getMessage().contains("로그예외")) {
            log.info("log 저장시 예외 발생");
            throw new RuntimeException("예외 발생");
        }
    }

    public Optional<Log> find(String message) {
    }

흐름을 자세히 알아보자

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

5. 전파 커밋 - 예외 처리 성공

  • 서비스에서 트랜잭션을 시작
  • 멤버 리포지토리는 기존 트랜잭션에 참여
  • 로그 리포지토리는 새로운 트랜잭션 생성
  • 로그 저장중에 예외가 발생시 서비스에서 예외 복구 시도

MemberService

    @Transactional
    public void joinV2(String username) {
        memberRepository.save(member);
        
        try {
            logRepository.save(logMessage);
        } catch (RuntimeException e) {
            log.info("log 저장에 실패했습니다. logMessage={}",logMessage.getMessage());
            log.info("정상 흐름 반환");
        }
    }

MemberRepository

    @Transactional
    public void save(Member member) {
    }

    public Optional<Member> find(String username) {
    }

LogRepository

  • 예외 발생
  • REQUIRES_NEW - 새로운 트랜잭션 생성
    @Transactional(propagation=Propagation.REQUIRES_NEW)
    public void save(Log logMessage) {
    	if (logMessage.getMessage().contains("로그예외")) {
            log.info("log 저장시 예외 발생");
            throw new RuntimeException("예외 발생");
        }
    }

    public Optional<Log> find(String message) {
    }

자세한 흐름을 보자

  • LogRepository에서 예외가 발생한다. 이 예외는 LogRepository의 트랜잭션 AOP가 전달받는다.
  • 이 트랜잭션 AOP는 REQUIRES_NEW로 새로 생성된 트랜잭션이므로 실제 롤백을 수행하고 해당 트랜잭션은 종료된다.
  • 트랜잭션 AOP가 예외를 서비스로 던진다.
  • 서비스에서는 예외를 복구하고, 정상 흐름을 MemberService의 트랜잭션 AOP에 전달한다.
  • 트랜잭션 AOP는 정상 흐름을 전달 받았으므로 커밋을 호출한다.
  • 신규 트랜잭션이므로 트랜잭션 매니저는 커밋을 하기 전에 rollbackOnly를 체크한다.
  • rollbackOnly가 없으므로 실제 커밋을 수행한다.
  • 이후 클라이언트에 정상 흐름이 반환된다.

주의사항

  • REQUIRES_NEW를 사용하면 하나의 HTTP요청에 2개의 DB 커넥션이 동시에 사용된다.
    따라서 성능이 중요한 곳에서는 주의가 필요하다.
  • REQUIRES_NEW를 사용하지 않고 해결할 수 있는 방법이 있다면, 그 방법을 선택하는 것이 더 좋다.

REQUIRES_NEW를 사용하지 않고 구조를 변경하기

  • 이 경우에는 MemberFacade가 다른 두개의 물리 트랜잭션을 순차적으로 사용하고, 커넥션을 반환하므로 동시에 2개의 커넥션을 획득하지는 않는다.
    다만, 구조상으로는 REQUIRES_NEW가 더 깔끔하므로 이러한 장단점을 고려해서 사용하자.

이번 포스팅에서는 트랜잭션 전파를 활용해 상황에 따라 문제를 해결하는 방법에 대해 알아보았다. 트랜잭션 전파에 대해서 알기 전에는 이런 문제가 발생할 수 있다는 생각을 하지 못했다. 실제로 프로젝트를 진행하면서 서비스 계층에 @Transactional을 사용하면 리포지토리에 @Transactional을 사용할 필요가 없지 않나? 하는 생각을 하였고, 리포지토리에서는 @Transactional을 사용하지 않았다.

하지만 실무에서 트랜잭션 적용에 있어서 직면할 수 있는 상황이 수십 수백가지가 될 수 있다는 것을 생각해봤을 때 다양한 방법을 고려해보는 과정이 필요하다는 것을 알게 되었다.

이번 트랜잭션 전파에 대해 공부하면서 여러 상황에서 직면할 수 있는 문제에 대한 해결 방법을 알게 되었고, 어플리케이션을 구성할 때 여러 방법을 고려해볼 수 있게 되는 시간이었다.

출처 : 김영한 - 스프링 DB 2편

profile
꾸준히 하자!

0개의 댓글