예제 및 테스트 코드는 github 에서 확인 가능합니다.
트랜잭션 REQUIRES_NEW 옵션에서의 예외 및 롤백
Overview
이번에는 스프링 환경에서 @Transactional
의 Propagation
옵션인 REQUIRES_NEW
와 해당 옵션을
사용할때의 예외/롤백에 대해 알아보겠습니다.
우선 트랜잭션 전파(Transaction Propagation
)에 대해 먼저 알아보겠습니다.
트랜잭션 전파는 한 트랜잭션이 실행중에 다른 트랜잭션을 실행할 경우 어떻게 동작할지를 결정 하는것입니다.
트랜잭션 전파의 종류는 다음과 같습니다.
- REQUIRED (Default)
- REQUIRES_NEW
- SUPPORTS
- NOT_SUPPORTED
- MANDATORY
- NEVER
- NESTED
전파옵션의 기본값은 REQUIRED
이며 별도의 트랜잭션이 실행되어도 부모 트랜잭션에 종속되어 실행됩니다.
저희가 알아볼 REQUIRES_NEW
옵션은 부모 트랜잭션과는 별도의 트랜잭션을 생성하여 동작합니다.
그렇다면 REQUIRES_NEW
를 사용한 자식 트랜잭션에서 예외가 발생한다면 어떻게 될까요?
부모와 자식 트랜잭션 모두 롤백이 될까요, 아니면 별도의 트랜잭션이니 자식 트랜잭션만 롤백이 될까요?
예제 코드를 통해 실제 자식 트랜잭션에서 예외가 발생했을때 어떻게 동작할지 한 번 알아보겠습니다.
에제 코드
- Spring Boot 3.2.1
- Java 17
- MySQL 8
예제는 부모 트랜잭션에서 Team
객체를 저장하고 자식 트랜잭션에서 TeamHistory
에
저장/변경 이력이 쌓이게끔 했습니다.
Team.java
@Getter
@Entity(name = "teams")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column
private String name;
private Team(String name) {
this.name = name;
}
public static Team from(String name) {
return new Team(name);
}
}
TeamHistory.java
@Getter
@Entity(name = "team_histories")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class TeamHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id", nullable = false)
private Team team;
private TeamHistory(Team team) {
this.name = team.getName();
this.team = team;
}
public static TeamHistory from(Team team) {
return new TeamHistory(team);
}
}
TeamService.java
@Service
@RequiredArgsConstructor
public class TeamService {
private final TeamRepository teamRepository;
private final TeamHistoryService teamHistoryService;
@Transactional
public void save(String name) {
Optional<Team> optionalTeam = teamRepository.findByName(name);
if (optionalTeam.isPresent()) {
throw new RuntimeException();
}
Team team = Team.from(name);
teamRepository.save(team);
teamHistoryService.saveHistory(team);
}
}
TeamHistoryService.java
@Service
@RequiredArgsConstructor
public class TeamHistoryService {
private final TeamHistoryRepository teamHistoryRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveHistory(Team team) {
TeamHistory teamHistory = TeamHistory.from(team);
teamHistoryRepository.save(teamHistory);
}
}
TeamController.java
@RestController
@RequiredArgsConstructor
@RequestMapping(value = "/teams")
public class TeamController {
private final TeamService teamService;
@PostMapping
public ResponseEntity<Void> saveTeam(@RequestBody String name) {
teamService.save(name);
return new ResponseEntity<>(HttpStatus.CREATED);
}
}
시나리오 검증
시나리오는 다음과 같이 정의하겠습니다.
curl --location 'http://localhost:8080/teams' \
--header 'Content-Type: application/json' \
--data 'test-team'
다음 curl 을 이용해서 각 예외상황을 한번 테스트 해보겠습니다.
그리고 다음과 같은 시나리오를 가정하고 예제 코드를 만들어 테스트 해보겠습니다.
- 부모 트랜잭션 실행
- 자식 트랜잭션 실행(REQUIRES_NEW)
- 자식 트랜잭션 예외 발생
예외 발생을 위해 코드를 다음과 같이 수정하겠습니다.
TeamService.java
@Service
@RequiredArgsConstructor
public class TeamService {
private final TeamRepository teamRepository;
private final TeamHistoryService teamHistoryService;
@Transactional
public void save(String name) {
Optional<Team> optionalTeam = teamRepository.findByName(name);
if (optionalTeam.isPresent()) {
throw new RuntimeException();
}
Team team = Team.from(name);
teamRepository.save(team);
teamHistoryService.saveHistory(team);
}
}
TeamHistoryService.java
@Service
@RequiredArgsConstructor
public class TeamHistoryService {
private final TeamHistoryRepository teamHistoryRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveHistory(Team team) {
TeamHistory teamHistory = TeamHistory.from(team);
teamHistoryRepository.save(teamHistory);
throw new RuntimeException();
}
}
다음 코드의 실행결과를 확인해보겠습니다.
// response
curl --location 'http://localhost:8080/teams/v2' \
--header 'Content-Type: application/json' \
--data 'test-team'
{
"timestamp": "2024-01-07T09:09:06.109+00:00",
"status": 500,
"error": "Internal Server Error",
"path": "/teams/v2"
}
에러가 발생하였고 Team, TeamHistory
테이블 모두 아무런 데이터가 들어가지 않았습니다.
REQUIRES_NEW
는 분명 별도의 트랜잭션으로 동작하는것으로 알고있는데요 부모 트랜잭션까지 롤백이 발생했습니다. 왜 부모 트랜잭션도 같이 롤백 되었을까요?
이유는 간단합니다. 바로 자식 트랜잭션에서 발생한 예외가 부모 트랜잭션에게 까지 전파되었기 때문입니다.
자바에서는 예외가 발생하면 중간에 별도의 예외처리를 하지 않는 이상
콜 스택을 따라 처음 호출한곳 까지 예외가 전파되는데요.
TeamService
의 teamHistoryService.saveHistory(team);
로 호출한 부분까지 예외가 전파되어 결국 부모 트랜잭션 내에서도 예외가 발생하여 롤백이 진행된 것이었습니다.
로그를 확인해보면 자식 트랜잭션인 saveHistory
에서 RuntimeException 을 발생시키고 이후 해당 예외가
부모 트랜잭션은 save
까지 전파되어 롤백이 진행되는것을 확인할 수 있습니다.
트랜잭션에서는 실제 예외가 발생하면 해당 트랜잭션에 롤백 마킹을 하게 되는데요TransactionAspectSupport.java
클래스의 completeTransactionAfterThrowing
메소드를 한번 살펴보겠습니다.
rollbackOn method
해당 예외가 트랜잭션에서 롤백하게 지정된 예외인지 확인하고 맞다면 롤백을 진행합니다.
이렇게 자식 트랜잭션에서 발생한 예외가 부모 트랜잭션까지 전파되었기 때문에 두 트랜잭션 모두 롤백마킹이 되어
모두 롤백되게 되었습니다.
그러면 이번에는 시나리오를 조금 바꿔 예외를 던지지 않고 캐치하면 어떻게 될까요?
- 부모 트랜잭션 실행
- 자식 트랜잭션 실행(REQUIRES_NEW)
- 자식 트랜잭션 예외 발생 후 캐치
테스를 위해 코드를 다음과 같이 수정하겠습니다.
TeamService.java
@Service
@RequiredArgsConstructor
public class TeamService {
private final TeamRepository teamRepository;
private final TeamHistoryService teamHistoryService;
@Transactional
public void save(String name) {
Optional<Team> optionalTeam = teamRepository.findByName(name);
if (optionalTeam.isPresent()) {
throw new RuntimeException();
}
Team team = Team.from(name);
teamRepository.save(team);
teamHistoryService.saveHistory(team);
}
}
TeamHistoryService.java
@Service
@RequiredArgsConstructor
public class TeamHistoryService {
private final TeamHistoryRepository teamHistoryRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveHistory(Team team) {
try {
TeamHistory teamHistory = TeamHistory.from(team);
teamHistoryRepository.save(teamHistory);
throw new RuntimeException();
} catch (Exception e) {
log.error("error message {}", e.getMessage());
}
}
}
다시 API 를 호출해보겠습니다.
// response
curl --location 'http://localhost:8080/teams/v2' \
--header 'Content-Type: application/json' \
--data 'test-team'
예외처리를 하여 자식 트랜잭션, 부모 트랜잭션에서 예외를 던지지 않으니 모두 롤백없이
정상적으로 커밋이 된 것을 확인할 수 있습니다.
그렇다면 예외 발생에 대해 순서를 조금 바꿔보면 어떨까요?
- 부모 트랜잭션 실행
- 자식 트랜잭션 실행(REQUIRES_NEW)
- 부모 트랜잭션 예외 발생
예외 발생을 위해 코드를 다음과 같이 수정하겠습니다.
TeamService.java
@Service
@RequiredArgsConstructor
public class TeamService {
private final TeamRepository teamRepository;
private final TeamHistoryService teamHistoryService;
@Transactional
public void save(String name) {
Optional<Team> optionalTeam = teamRepository.findByName(name);
if (optionalTeam.isPresent()) {
throw new RuntimeException();
}
Team team = Team.from(name);
teamRepository.save(team);
teamHistoryService.saveHistory(team);
throw new RuntimeException();
}
}
TeamHistoryService.java
@Service
@RequiredArgsConstructor
public class TeamHistoryService {
private final TeamHistoryRepository teamHistoryRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveHistory(Team team) {
TeamHistory teamHistory = TeamHistory.from(team);
teamHistoryRepository.save(teamHistory);
}
}
다음 코드의 실행결과를 확인해보겠습니다.
curl --location 'http://localhost:8080/teams/v3' \
--header 'Content-Type: application/json' \
--data 'test-team'
{
"timestamp":"2024-01-10T11:55:44.007+00:00",
"status":500,
"error":"Internal Server Error",
"path":"/teams/v3"
}
자식 트랜잭션에서 저장한 TeamHistory
는 저장되었지만
예외가 발생한 부모 트랜잭션에서 저장한 Team
은 롤백되었습니다.
자식 트랜잭션에서는 예외가 발생하지 않아 롤백되지 않았지만 이후 부모 트랜잭션에서 예외가 발생했을때의 시점에는 자식 트랜잭션은 이미 커밋이 완료된 상태이기 때문입니다.
이렇게 부모, 자식 트랜잭션 관계에서 예외가 발생하는 위치, 그리고 예외를 발생시키는 주체에 따라 롤백이 다르게 된 것을 확인할 수 있습니다.
후기
이렇게 트랜잭션의 REQUIRES_NEW
옵션과 예외상황에 따라 어떻게 동작하는지, 그리고 롤백이 어떻게 되는지
몇가지 케이스를 통해 알아보았습니다.
REQUIRES_NEW
옵션을 사용하게 된다면 롤백을 어느 범위까지 발생시킬지, 예외 전파를 어떻게 해야할지에 대한 고민도 할 수 있었습니다.
비즈니스에 적용할때도 성격에 맞게 위 부분을을 같이 고민하면서 사용하면 더 안전하고 효율적이게 사용할 수 있을것 같다는 생각이 들었습니다.
감사합니다.
reference
'Spring' 카테고리의 다른 글
[Spring] ContextCaching 으로 Test 성능 개선하기 (@MockBean, @SpyBean) (0) | 2024.06.02 |
---|---|
[Spring] @Component vs @Configuration (0) | 2024.05.21 |
[Spring] 서로 다른 테스트 클래스에서 테스트 데이터를 공유하는 방법 (0) | 2023.11.05 |
SpEL(Spring Expression Langauge) 사용법 + 어노테이션에 SpEL로 값 전달하기 (1) | 2023.05.06 |
스프링 Redis 테스트 환경 구축하기 (Embedded Redis, TestContainer) (0) | 2022.09.17 |