- 트랜잭션 전파 활용1 - 예제 프로젝트 시작
- 비즈니스 요구사항
1. 회원을 등록하고 조회한다.
2. 회원에 대한 변경 이력을 추적할 수 있도록 회원 데이터가 변경될 때 변경 이력을 DB LOG 테이블에 남겨야 한다.
2.1. 여기서는 예제를 단순화 하기 위해 회원 등록시에만 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) {
log.info("member 저장");
em.persist(member);
}
public Optional<Member> find(String username) {
//id가 pk이기 때문에 id로 찾으면 find JPA에서 제공하는 find로 찾으면 되는데
//이름으로 찾기 때문에 jpql을 사용한다.
return em.createQuery("select m from Member m where m.username = :username", Member.class)
.setParameter("username", username)
.getResultList().stream().findAny();
}
}
- JPA를 사용하는 회원 리포지토리이다. 저장과 조회 기능을 제공한다
- 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) {
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();
}
}
- JPA를 사용하는 로그 리포지토리이다. 저장과 조회 기능을 제공한다.
- 중간에 예외 상황을 재현하기 위해 로그예외 라고 입력하는 경우 예외를 발생시킨다.
- 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()
1. 회원과 DB로그를 함께 남기는 비즈니스 로직이다.
2. 현재 별도의 트랜잭션은 설정하지 않는다.
- joinV2()
1. joinV1() 과 같은 기능을 수행한다.
2. DB로그 저장시 예외가 발생하면 예외를 복구한다.
3. 현재 별도의 트랜잭션은 설정하지 않는다.
- MemberServiceTest
@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());
}
- 참고
1. JPA의 구현체인 하이버네이트가 테이블을 자동으로 생성해준다.
2. 메모리 DB이기 때문에 모든 테스트가 완료된 이후에 DB는 사라진다.
3. 여기서는 각각의 테스트가 완료된 시점에 데이터를 삭제하지 않는다. 따라서 username 은 테스트별로 각각 다르게 설정해야 한다. 그렇지 않으면 다음 테스트에 영향을 준다. (모든 테스트가 완료되어야 DB가 사라진다.)
- JPA와 데이터 변경
1. JPA를 통한 모든 데이터 변경(등록, 수정, 삭제)에는 트랜잭션이 필요하다. (조회는 트랜잭션 없이 가능하다.)
1.1. 현재 코드에서 서비스 계층에 트랜잭션이 없기 때문에 리포지토리에 트랜잭션이 있다.
- 트랜잭션 전파 활용2 - 커밋, 롤백
- 상황 서비스 계층에 트랜잭션이 없다. 회원, 로그 리포지토리가 각각 트랜잭션을 가지고 있다. 회원, 로그 리포지토리 둘다 커밋에 성공한다.
1. MemberService 에서 MemberRepository 를 호출한다. MemberRepository 에는 @Transactional 애노테이션이 있으므로 트랜잭션 AOP가 작동한다. 여기서 트랜잭션 매니저를 통해 트랜잭션을 시작한다. 이렇게 시작한 트랜잭션을 트랜잭션B라 하자.
1.1. 그림에서는 생략했지만, 트랜잭션 매니저에 트랜잭션을 요청하면 데이터소스를 통해 커넥션 con1 을 획득하고, 해당 커넥션을 수동 커밋 모드로 변경해서 트랜잭션을 시작한다.
1.2. 그리고 트랜잭션 동기화 매니저를 통해 트랜잭션을 시작한 커넥션을 보관한다.
1.3. 트랜잭션 매니저의 호출 결과로 status 를 반환한다. 여기서는 신규 트랜잭션 여부가 참이 된다.
2. MemberRepository 는 JPA를 통해 회원을 저장하는데, 이때 JPA는 트랜잭션이 시작된 con1 을 사용해서 회원을 저장한다.
3. MemberRepository 가 정상 응답을 반환했기 때문에 트랜잭션 AOP는 트랜잭션 매니저에 커밋을 요청한다.
4. 트랜잭션 매니저는 con1 을 통해 물리 트랜잭션을 커밋한다.
4.1. 물론 이 시점에 앞서 설명한 신규 트랜잭션 여부, rollbackOnly 여부를 모두 체크한다.
- 이렇게 해서 MemberRepository 와 관련된 모든 데이터는 정상 커밋되고, 트랜잭션B는 완전히 종료된다. 이후에 LogRepository 를 통해 트랜잭션C를 시작하고, 정상 커밋한다. 결과적으로 둘다 커밋되었으므로 Member , Log 모두 안전하게 저장된다.
- @Transactional과 REQUIRED
1. 트랜잭션 전파의 기본 값은 REQUIRED 이다. 따라서 다음 둘은 같다.
1.1. @Transactional(propagation = Propagation.REQUIRED)
1.2. @Transactional
2. REQUIRED 는 기존 트랜잭션이 없으면 새로운 트랜잭션을 만들고, 기존 트랜잭션이 있으면 참여한다
- 서비스 계층에 트랜잭션이 없을 때 - 롤백
- 상황
1. 서비스 계층에 트랜잭션이 없다.
2. 회원, 로그 리포지토리가 각각 트랜잭션을 가지고 있다.
3. 회원 리포지토리는 정상 동작하지만 로그 리포지토리에서 예외가 발생한다.
- outerTxOff_fail
/**
* 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
assertTrue(memberRepository.find(username).isPresent());
assertTrue(logRepository.find(username).isEmpty());
}
- memberrepository에는 정상적으로 커밋된 것을 확인 할 수 있다.
- logRepository에는 '로그예외'가 포함되어 있기 때문에 예외 조건에 걸려 롤백된 것을 확인 할 수 있다.
- LogRepository 응답 로직
1. LogRepository 는 트랜잭션C와 관련된 con2 를 사용한다.
2. 로그예외 라는 이름을 전달해서 LogRepository 에 런타임 예외가 발생한다.
3. LogRepository 는 해당 예외를 밖으로 던진다. 이 경우 트랜잭션 AOP가 예외를 받게된다.
4. 런타임 예외가 발생해서 트랜잭션 AOP는 트랜잭션 매니저에 롤백을 호출한다.
5. 트랜잭션 매니저는 신규 트랜잭션이므로 물리 롤백을 호출한다.
- 트랜잭션 전파 활용3 - 단일 트랜잭션
- 트랜잭션 하나만 사용하기
1. 회원 리포지토리와 로그 리포지토리를 하나의 트랜잭션으로 묶는 가장 간단한 방법은 이 둘을 호출하는 회원 서비스에만 트랜잭션을 사용하는 것이다.
/**
* 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 - joinV1()
@Transactional
public void joinV1(String username) {
Member member = new Member(username);
Log logMessage = new Log(username);
- MemberRepository - save()
// @Transactional
public void save(Member member) {
log.info("member 저장");
em.persist(member);
}
- LogRepository - save()
// @Transactional
public void save(Log logMessage) {
log.info("log 저장");
em.persist(logMessage);
if (logMessage.getMessage().contains("로그예외")) {
log.info("log 저장시 예외 발생");
throw new RuntimeException("예외 발생");
}
}
1. 이렇게 하면 MemberService 를 시작할 때 부터 종료할 때 까지의 모든 로직을 하나의 트랜잭션으로 묶을 수 있다.
1.1. 물론 MemberService 가 MemberRepository , LogRepository 를 호출하므로 이 로직들은 같은 트랜잭션을 사용한다.
2. MemberService 만 트랜잭션을 처리하기 때문에 앞서 배운 논리 트랜잭션, 물리 트랜잭션, 외부 트랜잭션, 내부 트랜잭션, rollbackOnly , 신규 트랜잭션, 트랜잭션 전파와 같은 복잡한 것을 고민할 필요가 없다. 아주 단순하고 깔끔하게 트랜잭션을 묶을 수 있다.
1. @Transactional 이 MemberService 에만 붙어있기 때문에 여기에만 트랜잭션 AOP가 적용된다.
1.1. MemberRepository , LogRepository 는 트랜잭션 AOP가 적용되지 않는다.
2. MemberService 의 시작부터 끝까지, 관련 로직은 해당 트랜잭션이 생성한 커넥션을 사용하게 된다.
2.1. MemberService 가 호출하는 MemberRepository , LogRepository 도 같은 커넥션을 사용하면서 자연스럽게 트랜잭션 범위에 포함된다.
- 하나의 물리 트랜잭션이 실행되었다가 커밋 후 종료되는 것을 확인 할 수 있다.
- 트랜잭션 전파 활용4 - 전파 커밋
- 스프링은 @Transactional 이 적용되어 있으면 기본으로 REQUIRED 라는 전파 옵션을 사용한다. 이 옵션은 기존 트랜잭션이 없으면 트랜잭션을 생성하고, 기존 트랜잭션이 있으면 기존 트랜잭션에 참여한다. 참여한다는 뜻은 해당 트랜잭션을 그대로 따른다는 뜻이고, 동시에 같은 동기화 커넥션을 사용한다는 뜻이다.
1. 이 경우 외부에 있는 신규 트랜잭션만 실제 물리 트랜잭션을 시작하고 커밋한다.
2. 내부에 있는 트랜잭션은 물리 트랜잭션 시작하거나 커밋하지 않는다
/**
* 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());
}
- member, log Repository에 전부 @Transactional이 붙을 경우 로그에서 보는 것과 같이 가장 먼저 생성된 Service 트랜잭션에 Repository의 트랜잭션들이 참여하는 것을 확인 할 수 있다.
- 내부 트랜잭션에서 전부 정상적으로 Commit이 되고 신규 트랜잭션 또한 정상적으로 Commit이 된 경우 물리 트랜잭션이 정상적으로 Commit되는 것을 확인 할 수 있다.
- 트랜잭션 전파 활용5 - 전파 롤백
/**
* 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());
}
- 내부 트랜잭션에서 예외가 발생하여 rollback이 발생한 경우 rollback-only=true를 트랜잭션 매니저에 전달한다.
- 신규 트랜잭션에서 Commit을 할 때 rollback-only를 확인하고 rollback-only=true일 경우 물리 트랜잭션은 Commit을 하지 않고 rollback하게 된다.
- 트랜잭션 전파 활용6 - 복구 REQUIRED
- 조건
1. 회원 가입을 시도한 로그를 남기는데 실패하더라도 회원 가입은 유지되어야 한다.
/**
* 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
@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.getMessage());
log.info("정상 흐름 반환");
}
log.info("== logRepository 호출 종료 ==");
}
- 위의 MemberService의 코드를 확인해보면 내부 트랜잭션에서 예외가 발생하여 신규 트랜잭션으로 전달 되었을 때 예외를 잡아서 처리하는 코드를 작성하였다.
- 이론상 예외를 처리했기 떄문에 신규 트랜잭션은 아무런 예외를 위로 던지지 않고, 그렇다면 신규 트랜잭션이 Commit을 하게 되면 물리 트랜잭션 또한 아무 이상 없이 Commit이 되어야 한다.
- 하지만 결과 로그를 확인해보면 물리 트랜잭션이 rollback 한 것을 확인 할 수 있다.
- 물리 트랜잭션이 rollback한 이유는 내부 트랜잭션에서 rollback이 발생할 경우 트랜잭션 매니저에 rollback-only=true가 전달되기 때문에 예외 처리와 관계 없이 물리 트랜잭션은 rollback을 실행하게 된다.
1. 논리 트랜잭션 중 하나라도 롤백되면 전체 트랜잭션은 롤백된다.
2. 내부 트랜잭션이 롤백 되었는데, 외부 트랜잭션이 커밋되면 UnexpectedRollbackException 예외가 발생한다.
3. rollbackOnly 상황에서 커밋이 발생하면 UnexpectedRollbackException 예외가 발생한다.
- 트랜잭션 전파 활용7 - 복구 REQUIRES_NEW
- 회원 가입을 시도한 로그를 남기는데 실패하더라도 회원 가입은 유지되어야 한다.
- 이 요구사항을 만족하기 위해서 로그와 관련된 물리 트랜잭션을 별도로 분리해보자. 바로 REQUIRES_NEW 를 사용하는 것이다.
/**
* 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 : member 저장, log 롤백
assertTrue(memberRepository.find(username).isPresent());
assertTrue(logRepository.find(username).isEmpty());
}
- LogRepository
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void save(Log logMessage) {
log.info("log 저장");
em.persist(logMessage);
if (logMessage.getMessage().contains("로그예외")) {
log.info("log 저장시 예외 발생");
throw new RuntimeException("예외 발생");
}
}
- 내부 트랜잭션(memberRepository)가 신규 트랜잭션에 참여하여 정상적으로 commit 한 것을 확인 할 수 있다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
- REQUIRES_NEW가 적용된 (logRepository)의 경우 새롭게 신규 트랜잭션을 생성한다.
- 예외가 발생하여 밖으로 던지고, rollback을 실행하지만 새롭게 생성된 트랜잭션이기 때문에 rollbackonly를 트랜잭션 매니저에 전달하지 않는다.
- 신규 트랜잭션은 넘어온 예외를 처리하고 정상적으로 Commit을 실행한다.
- 물리 트랜잭션 또한 예외가 전부 처리되고 Commit을 하려고 트랜잭션 매니저의 rollback-only를 확인해도 아무 문제가 없는 것을 확인했기 때문에 Commit을 실행한다.
1. MemberRepository 는 REQUIRED 옵션을 사용한다. 따라서 기존 트랜잭션에 참여한다.
2. LogRepository 의 트랜잭션 옵션에 REQUIRES_NEW 를 사용했다.
3. REQUIRES_NEW 는 항상 새로운 트랜잭션을 만든다. 따라서 해당 트랜잭션 안에서는 DB 커넥션도 별도로 사용하게 된다.
1. REQUIRES_NEW 를 사용하게 되면 물리 트랜잭션 자체가 완전히 분리되어 버린다.
2. 그리고 REQUIRES_NEW 는 신규 트랜잭션이므로 rollbackOnly 표시가 되지 않는다. 그냥 해당 트랜잭션이 물리 롤백되고 끝난다.
1. LogRepository 에서 예외가 발생한다. 예외를 던지면 LogRepository 의 트랜잭션 AOP가 해당 예외를 받는다.
2. REQUIRES_NEW 를 사용한 신규 트랜잭션이므로 물리 트랜잭션을 롤백한다. 물리 트랜잭션을 롤백했으므로 rollbackOnly 를 표시하지 않는다. 여기서 REQUIRES_NEW 를 사용한 물리 트랜잭션은 롤백되고 완전히 끝이 나버린다.
3. 이후 트랜잭션 AOP는 전달 받은 예외를 밖으로 던진다.
4. 예외가 MemberService 에 던져지고, MemberService 는 해당 예외를 복구한다. 그리고 정상적으로 리턴한다.
5. 정상 흐름이 되었으므로 MemberService 의 트랜잭션 AOP는 커밋을 호출한다.
6. 커밋을 호출할 때 신규 트랜잭션이므로 실제 물리 트랜잭션을 커밋해야 한다. 이때 rollbackOnly 를 체크한다.
7. rollbackOnly 가 없으므로 물리 트랜잭션을 커밋한다.
8. 이후 정상 흐름이 반환된다.
- 결과적으로 회원 데이터는 저장되고, 로그 데이터만 롤백 되는 것을 확인할 수 있다
- 정리
1. 논리 트랜잭션은 하나라도 롤백되면 관련된 물리 트랜잭션은 롤백되어 버린다.
2. 이 문제를 해결하려면 REQUIRES_NEW 를 사용해서 트랜잭션을 분리해야 한다.
3. 참고로 예제를 단순화 하기 위해 MemberService 가 MemberRepository , LogRepository 만 호출하지만 실제로는 더 많은 리포지토리들을 호출하고 그 중에 LogRepository 만 트랜잭션을 분리한다고 생각해보면 이해하는데 도움이 될 것이다.
- 주의
1. REQUIRES_NEW 를 사용하면 하나의 HTTP 요청에 동시에 2개의 데이터베이스 커넥션을 사용하게 된다. 따라서 성능이 중요한 곳에서는 이런 부분을 주의해서 사용해야 한다.
2. REQUIRES_NEW 를 사용하지 않고 문제를 해결할 수 있는 단순한 방법이 있다면, 그 방법을 선택하는 것이 더 좋다.
출처 : 인프런 - 우아한 형제들 기술이사 김영한의 스프링 완전 정복 (스프링 DB 2편 - 데이터 접근 활용 기술)
'Programming > Spring' 카테고리의 다른 글
Spring Data JPA - 쿼리 메소드 (메소드 이름으로 쿼리 생성, @Query) (0) | 2023.07.24 |
---|---|
Spring Data JPA - 공통 인터페이스 기능 (0) | 2023.07.24 |
Spring - 스프링 트랜잭션 전파1 - 기본 (0) | 2023.07.08 |
Spring - 스프링 트랜잭션 이해 (0) | 2023.07.07 |
Spring - 데이터 접근 기술 - 활용 방안 (0) | 2023.07.07 |