트랜잭션 전파 포스트에서 설명한 트랜잭션 전파를 활용하는 방법을 코드로 알아본다.
비즈니스 요구사항
- 회원 등록 및 조회
- 회원에 대한 변경 이력을 추적할 수 있도록 회원 데이터가 변경될 때 변경 이력을 DB log 테이블에 남겨야 한다.
트랜잭션 전파가 없을 때의 문제
Member와 Log 클래스는 JPA를 활용하도록 되어있고, 각각 id와 username을 갖는다.
Repository
- MemberRepository
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Slf4j
@Repository
@RequiredArgsConstructor
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", Member.class)
.setParameter("username", username)
.getResultList().stream().findAny();
}
}
- LogRepository
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Repository
@Slf4j
@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 m.message = :message", Log.class)
.setParameter("message", message)
.getResultList().stream().findAny();
}
}
로그를 저장할 때 예외가 발생하는 상황을 넣어주었다. 그리고 각각 save() 메서드에 @Transactional 적용되어 있다. (복습: 트랜잭션 학습도 학습이지만 JPA를 사용하려면 @Transactional을 붙여야 한다.)
트랜잭션 각각 사용
MemberService
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@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 호출 종료 ==");
}
}
우선 트랜잭션을 각각 호출해 각각의 트랜잭션을 사용하는 상황을 본다.
서비스 계층에 @Transactional이 없고 회원, 로그 리포지토리가 각각 트랜잭션을 가지고 있어 둘 다 커밋/롤백에 성공하게 된다.
- 로그 예외 상황 Test
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* memberService @Transactional:OFF
* memberRepository @Transactional:ON
* logRepository @Transactional:ON
*/
@Test
void outerTxOff_success() {
//given
String username = "로그예외_outerTxOff_success";
//when
//memberService.joinV1(username);
assertThatThrownBy(() -> memberService.joinV1(username))
.isInstanceOf(RuntimeException.class);
//then
assertTrue(memberRepository.find(username).isPresent());
assertTrue(logRepository.find(username).isEmpty());
}
로그에서 예외 상황이 발생하면 memberRepository의 트랜잭션은 정상적으로 커밋하고, logRepository의 트랜잭션은 롤백하게 된다.
런타임예외가 발생하고, 이 예외를 밖으로 던지게 되고 그것을 트랜잭션 AOP가 받아 롤백을 호출하게 된다.
트랜잭션이 분리되어 각각의 커넥션을 사용하는 상황이기 때문이다.
단일 트랜잭션 사용
위의 경우 LogRepository와 MemberRepository가 각각의 트랜잭션을 사용했다. 이번엔 하나의 트랜잭션으로 묶어본다.
논리 트랜잭션들을 물리 트랜잭션으로 묶는 과정이 아닌 방식이다.
- LogRepository @Transactional 제거
- MemberRepository @Transactional 제거
- MemberService @Transactional 추가
단순하게 서비스가 시작한 트랜잭션 내에서 리포지토리의 로직들이 같은 트랜잭션을 사용하게 된다.
서비스에만 트랜잭션 AOP가 적용되고, 리포지토리에는 트랜잭션 AOP가 적용되지 않는 것이다.
그러나 트랜잭션이 각각 필요한 상황이라면?
트랜잭션이 각각 필요한 상황
- 클라이언트 A는 Service로부터 리포지토리들을 모두 하나의 트랜잭션으로 묶고싶어 한다.
- 클라이언트 B는 MemberRepository만 사용하고 해당 리포지토리에서만 트랜잭션을 사용하고 싶어 한다.
- 클라이언트 C는 LogRepository만 사용하고 해당 리포지토리에서만 트랜잭션을 사용하고 싶어 한다.
클라이언트 A의 경우 리포지토리를 제외하고 서비스에만 @Transactional을 붙여 해결할 수 있다.
그러나 이 경우 B나 C를 따로 호출해서 사용하면 트랜잭션이 적용될 수 없다.
이런 경우를 트랜잭션 전파 개념이 없는 상태에서 해결하려면 리포지토리의 save()를 saveTx(), saveNonTx() 와 같이 메서드를 두 개 만들어 해결해야 하는 상황이 온다. 서비스가 커진다면 매우 복잡해질 수 있다.
트랜잭션 전파의 활용
트랜잭션 전파를 활용해 위와 같이 상황에 따라 트랜잭션을 유연하게 사용하고 싶을 때 해결할 수 있다.
- 외부에 있는 신규 트랜잭션만 실제 물리 트랜잭션을 시작하고 커밋한다.
- 내부에 있는 트랜잭션은 물리 트랜잭션을 시작하거나 커밋하지 않는다.
전파 커밋
MemberService, MemberRepository, LogRepository 모두에 @Transactional이 붙는다.
이렇게 하고 MemberService에서 각 Repository를 호출하면 각 리포지토리의 트랜잭션은 서비스의 물리 트랜잭션에 참가하게 된다.
- 클라이언트 A에서 MemberService를 호출하며 트랜잭션 AOP가 호출된다.
- 신규 트랜잭션이 생성되고, 물리 트랜잭션이 시작한다.
- MemberRepository를 호출하며 트랜잭션 AOP가 호출된다.
- 이미 트랜잭션이 있으므로 기존 트랜잭션에 참여한다.
- MemberRepository의 로직 호출이 끝나고 정상 응답하면 트랜잭션 AOP가 호출된다.
- 트랜잭션 AOP는 정상 응답이므로 트랜잭션 매니저에 커밋을 요청한다. 물리 트랜잭션이 아니므로 실제 커밋은 아니다.
- LogRepository도 MemberRepository와 같은 과정을 따른다.
- 실제 커밋은 하지 않는다.
- MemberService의 로직 호출이 끝나고 정상 응답하면 트랜잭션 AOP가 호출된다.
- 물리 트랜잭션이고, 정상 응답이므로 실제 물리 커밋을 호출한다.
전파 롤백
로그 리포지토리에서 롤백이 발생해 전체 트랜잭션이 롤백되는 경우를 보자.
Test
1
2
3
4
5
6
7
8
9
10
11
12
13
@Test
void outerTxOn_fail() {
//given
String username = "로그예외_outerTxOff_fail";
//when
assertThatThrownBy(() -> memberService.joinV1(username))
.isInstanceOf(RuntimeException.class);
//then
assertTrue(memberRepository.find(username).isEmpty());
assertTrue(logRepository.find(username).isEmpty());
}
RuntimeException이 발생하고, find의 결과가 Empty 인 것을 확인할 수 있다.
정상적으로 모든 트랜잭션이 롤백 되었다는 것을 볼 수 있다.
동작
- 클라이언트 A가 MemberService를 호출하며 트랜잭션 AOP가 호출된다.
- MemberRepository를 호출하면서 트랜잭션 AOP가 호출된다. 기존 트랜잭션에 참여한다.
- 로직이 끝나고 실제 커밋이 아닌 커밋요청을 한다.
- LogRepository를 호출하면서 트랜잭션 AOP가 호출된다. 기존 트랜잭션에 참여한다.
- 로직 중 런타임 예외가 발생했다. 예외를 던지면 트랜잭션 AOP가 예외를 받는다.
- 트랜잭션 AOP는 런타임 예외이므로 트랜잭션 매니저에 롤백을 요청한다. 이 때 물리 트랜잭션이 아니므로 실제 롤백을 하진 않고, rollbackOnly를 설정한다.
- LogRepository가 예외를 던졌기 때문에 트랜잭션 AOP도 해당 예외를 그대로 밖으로 던진다.
- MemberService에서도 런타임 예외를 받게 되는데, 서비스 로직에 예외를 처리하는 로직이 없으므로 예외를 밖으로 던진다.
- 서비스에서의 트랜잭션 AOP는 런타임 예외를 받았으므로 트랜잭션 매니저에 롤백을 요청한다. 물리 롤백을 호출하게 된다.
- 이 경우 어차피 롤백을 요청받았기 때문에 rollbackOnly 설정은 참고하지 않는다. (서비스에서 커밋 요청을 했다고 가정하면 rollbackOnly 설정을 참고해 롤백하게 된다.)
- 이후 트랜잭션 AOP도 예외를 밖으로 던진다.
- 클라이언트 A는 LogRepository로부터 넘어온 런타임 예외를 받게 된다.
정리
회원과 회원 이력 로그를 처리하는 부분을 하나의 트랜잭션으로 묶었기 때문에 문제가 발생했을 때 회원과 회원 이력 로그가 모두 롤백되어 데이터 정합성에 문제가 발생하지 않도록 했다.
그런데 로그에 오류가 발생했는데 회원가입 기능이 실패하는게 맞을까..?
복구 REQUIRED_NEW
실제 상황이라고 본다면 회원 이력 로그를 남기는 부분에 오류가 발생했는데 회원가입이 함께 안되는 결과가 나타난다.
회원 입장에서는 로그 때문에 회원가입에 실패해 서비스를 이탈할 수 있다. 회원 이력 로그의 경우 여러가지 방법으로 복구가 가능하다고 가정하자.
그렇다면 회원 이력 로그를 남기는데 실패하더라도 회원 가입이 유지되도록 하는 상황을 보자.
단순하게 생각하면 LogRepository에서 예외가 발생했을 때 던져진 예외를 Service에서 잡아 처리하면 될 것 같다.
하지만 Service에서 이 예외를 잡아 처리해 정상 로직으로 흐름을 바꾸고, 커밋을 요청하게 된다면 어떻게 될까?
트랜잭션 AOP가 정상 커밋을 요청해도, 이미 로그 리포지토리에서의 트랜잭션 AOP가 rollbackOnly 마킹을 해놓았기 때문에 롤백된다.
실무에서 많은 개발자가 이러한 부분을 놓치고 위와 같은 방법을 시도해 실패한다. 어떻게 해결하는 지 알아보자.
해결
트랜잭션 전파의 기본 옵션인 REQUIRED는 기존 트랜잭션이 없다면 트랜잭션을 생성하고 기존 트랜잭션이 있다면 트랜잭션에 참여하도록 되어 있다.
위와 같은 경우 트랜잭션을 분리해야 한다. 로그 리포지토리의 트랜잭션이 분리되어야 한다. 따라서 REQUIRES_NEW 옵션을 사용해 해결한다.
1
2
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void save(Log logMessage) {}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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 호출 종료 ==");
}
@Test
void recoverException_success() {
//given
String username = "로그예외_recoverException_success";
//then
memberService.joinV2(username);
//when
assertTrue(memberRepository.find(username).isPresent());
assertTrue(logRepository.find(username).isEmpty());
}
- joinV2() 메서드에서 예외를 잡아 정상흐름으로 바꾸어준다.
- 따라서 Test에서도 예외가 발생하지 않는 것을 볼 수 있다.
- 그러나 logRepository = Empty이고, memberRepository는 Present인 결과를 볼 수 있다. 의도한 대로 트랜잭션이 분리되어 로그 리포지토리는 예외 발생에 따라 롤백, 멤버 리포지토리는 커밋이 되었다.
동작 흐름
- MemberService를 호출하며 트랜잭션 AOP가 호출된다.
- 신규 트랜잭션이 생성되고, 물리 트랜잭션이 시작한다.
- MemberRepository에서 트랜잭션 AOP가 호출되고 트랜잭션에 참여한다. 그리고 로직이 정상흐름이기 때문에 실제 커밋이 아닌 커밋을 호출한다.
- LogRepository에서 예외가 발생한다. 예외를 던지면 LogRepository의 트랜잭션 AOP가 해당 예외를 받는다.
- REQUIRES_NEW를 사용했으므로 신규 트랜잭션이다. 따라서 물리 트랜잭션을 롤백한다.
- 롤백했으므로 이 트랜잭션은 종료된다.
- 이후 트랜잭션 AOP는 전달받은 예외를 밖으로 던진다.
- 예외가 Service에 던져지고, Service는 해당 예외를 복구한다. 그리고 정상적으로 리턴한다.
- 정상 흐름이므로 MemberService의 트랜잭션 AOP는 커밋을 요청한다. 물리 트랜잭션이므로 물리 커밋을 요청한다.
- rollbackOnly가 없으므로 정상 커밋한다.
결과적으로 회원 데이터는 저장되고, 로그 데이터만 롤백되었다.
주의
다시 강조하지만 REQUIRES_NEW를 사용하면 하나의 HTTP 요청에 2개의 DB 커넥션을 사용하게 되어 성능이 중요한 곳에서는 주의해서 사용해야 한다.
REQUIRES_NEW를 사용하지 않고 문제를 해결할 수 있는 단순한 방법이 있다면 그 방법을 선택하는 것이 좋다.
예를 들면 위와 같은 구조로 해결하는 것이다.
이렇게 하면 HTTP 요청 하나에 두 개의 커넥션을 동시에 사용하지는 않는다. 순차적으로 사용하고 반환하게 된다.
물론 구조 상 REQUIRES_NEW를 사용하는 것이 깔끔한 경우도 있으므로 각각의 장단점을 이해하고 적절하게 선택해서 사용하자.