트랜잭션 사용법 in Spring
트랜잭션이란? DB의 상태를 변화시키기 위해 수행하는 작업의 단위
public void executeQuery() throws SQLException {
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
// 트랜잭션 시작
try {
// 쿼리 실행
...
transactionManager.commit(status);
} catch (Exception e) {
transactionManager.rollback(status);
}
}
트랜잭션 시작
트랜잭션은 하나의 DB Connection을 가져와 사용하다가 닫는 사이에서 일어난다.
Spring 에서는 DB커넥션을 갖고있는 추상화된 트랜잭션 매니저를 이용해서 트랜잭션을 시작한다.
transactionManager.getTransaction(new DefaultTransactionDefinition());
트랜잭션 종료
commit() 또는 rollback() 호출될 때 까지가 하나의 트랜잭션.
Spring 에서는 이 트랜잭션을 선언적 트랜잭션 @Transactional 을 사용하여 쉽게 트랜잭션을 이용할 수 있다.
아래 개념을 간단히 알고가자
[ 물리 트랜잭션과 논리 트랜잭션 ]
트랜잭션은 데이터베이스에서 제공하는 기술이므로 커넥션 객체를 통해 처리한다. 그래서 1개의 트랜잭션을 사용한다는 것은 하나의 DB 커넥션 객체를 사용한다는 것이고,
실제 데이터베이스의 트랜잭션을 사용한다는 점에서 물리 트랜잭션이라고도 한다. 물리 트랜잭션은 실제 커넥션에 롤백/커밋을 호출하는 것이므로 해당 트랜잭션이 끝나는 것이다.
하지만 스프링의 입장에서는 트랜잭션 매니저를 통해 트랜잭션을 처리하는 곳이 2군데이다. 그래서 실제 데이터베이스 트랜잭션과 스프링이 처리하는 트랜잭션 영역을 구분하기 위해 스프링은 논리 트랜잭션이라는 개념을 추가하였다
물리 트랜잭션: 실제 데이터베이스에 적용되는 트랜잭션으로, 커넥션을 통해 커밋/롤백하는 단위
논리 트랜잭션: 스프링이 트랜잭션 매니저를 통해 트랜잭션을 처리하는 단위
모든 논리 트랜잭션이 커밋되어야 물리 트랜잭션이 커밋됨
하나의 논리 트랜잭션이라도 롤백되면 물리 트랜잭션은 롤백됨
그러면 보통 트랜잭션은 언제 사용할까?
요구사항)
- 미래기술1팀 부서명을 변경하고, 동시에 부서에 포함된 부서원들도 그에 맞게 부서명을 변경해줘야한다.
- 회원가입을 하고, 회원가입 축하 이벤트를 유저에게 알려준다.
고려사항)
- 부서명을 변경하고, 부서원들의 부서명을 변경하던중 예상치 못한 오류가 발생했다면?
- 회원가입을 하고, 축하 이벤트를 알려주는 도중에 DB 커넥션이 잠시 끊겼다면?
public void updateDepartment(){
//부서명 변경
departmentService.update(...);
//부서원 부서 변경
List<User> users = userService.findByDepartmentName(..);
for(User user : users){
userService.update(...);
}
}
이런경우 트랜잭션을 적용해서 요구사항을 만족시킬수 있다.(실제로 트랜잭션을 적용했을때 위와 같은 코드로 작성하면 안된다..단순 예시일뿐)
그러나 트랜잭션에도 몇가지 부수적인 개념이 존재하는데 알고 사용하자.
트랜잭션 전파 속성
트랜잭션 전파 속성에는 7가지 속성이 존재한다. 그중 주로 사용하는 3가지에 대해서만 설명한다.

REQUIRED

- @Transactional의 기본 속성이므로 따로 명시해줄 필요 없다.
- 기존 트랜잭션이 없으면 새로운 트랜잭션을 만든다.
- 기존 트랜잭션이 있으면 기존 트랜잭션에 포함된다.
- 기존 트랜잭션에 포함되었을 때는 기존 트랜잭션의 커밋이 완료될 때 커밋되고, 롤백시 함께 취소된다.
REQUIRES_NEW

- 트랜잭션의 존재 여부와 상관없이 무조건 자신만의 새로운 트랜잭션을 생성한다.
- 기존 트랜잭션이 존재한다면 기존 트랜잭션을 보류하고 새로운 트랜잭션을 생성한다.
- 롤백시 서로의 trx에 영향을 주지 않는다.
- 이 방법은 내부 트랜잭션이 외부에 영향을 주지 않고, 서로 각각의 디비 커넥션을 가진다. 즉, 내부 트랜잭션이 생성되면 하나의 HTTP 요청에 대해 2개의 디비 커넥션을 쓰게되는 것이다. 따라서 이 전파레벨은 디비 커넥션을 고갈 시킬 가능성이 있다.
NESTED

- 기존 트랜잭션이 없으면 새로운 트랜잭션을 만든다.
- 기존 트랜잭션이 있으면 중첩 트랜잭션을 만든다.
- 커밋 시점은 부모 트랜잭션(기존 트랜잭션)이 완료될 때이지만, 롤백시엔 부모 트랜잭션에 영향을 주지 않는다.
- 만약 부모 트랜잭션에서 롤백이 발생하면 이 트랜잭션도 롤백이 발생한다.
NESTED
는 JDBC의 세이브 포인트라는 기능을 이용해서 동작한다. 세이브 포인트를 이용해 부모 트랜잭션을 잠시 멈추고 자식 트랜잭션을 실행한다.- 일부 DB에서만 적용되는 방식이다.(mysql)
전파 조합을 구성했을때 생성되는 트랜잭션의 개수를 확인해보자
전파 조합 | 트랜잭션 기준은? | 트랜잭션 개수 |
---|---|---|
REQUIRED → REQUIRED | 최초 호출 메서드 | 1개 |
REQUIRED → REQUIRES_NEW | 각각 별도 | 2개 |
REQUIRES_NEW → REQUIRED | REQUIRES_NEW 메서드가 기준 | 1개 |
REQUIRES_NEW → REQUIRES_NEW | REQUIRES_NEW 메서드가 기준 | 2개 |
실제로 코드를 보면서 트랜잭션 테스트를 해보자
회원과 회원가입알람 엔티티와 그에 맞는 서비스 기능들이 존재한다.
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
public class UserEntity {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long seq;
private String email;
private String password;
}
@Repository
public interface UserRepository extends JpaRepository<UserEntity, Long> {
UserEntity findByEmail(String email);
}
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
public class UserAlarmEntity {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
private String message;
}
@Repository
public interface UserAlarmRepository extends JpaRepository<UserAlarmEntity, Long> {
}
테스트1:회원가입 성공 + 회원가입 성공알람 실패
@Slf4j
@RequiredArgsConstructor
@Service
public class UserService {
...
@Transactional//default 전파속성은 REQUIRED
public void signupV1(UserEntity userEntity){
//회원가입
userRepository.save(userEntity);
//회원가입 축하 알림
userAlarmService.notifySignupSuccessV1(userEntity);
}
...
}
@RequiredArgsConstructor
@Service
public class UserAlarmService {
...
@Transactional(propagation = Propagation.REQUIRED)
public void notifySignupSuccessV1(UserEntity userEntity){
UserAlarmEntity alramCreate = UserAlarmEntity.builder()
.message(userEntity.getEmail()+" 님 회원가입을 축하합니다.")
.build();
userAlarmRepository.save(alramCreate);
throw new RuntimeException("예외 발생");
}
...
}
@DisplayName("회원가입(2중 트랜잭션: 부모(REQUIRE) + 자식(REQUIRE)")
@Test
void signUpTestV1(){
//given
UserEntity create = UserEntity.builder()
.email("yjy@ibleaders.co.kr")
.password("1234")
.build();
//when-then
assertThrows(RuntimeException.class, () -> {
userService.signupV1(create);
});
assertThrows(NoSuchElementException.class, () -> {
userRepository.findById(1L).get();
});
assertThrows(NoSuchElementException.class, () -> {
userAlarmRepository.findById(1L).get();
});
}
부모 메서드(signupV1)에 트랜잭션을 걸고, 자식메서드(notifySignupSuccessV1)에도 트랜잭션을 걸었다.
전파 속성은 둘다 디폴트이다.
회원가입 알람 성공이 실패했기 때문에, 회원가입 또한 실패했다.
테스트2:회원가입 성공 + 회원가입 성공알람 실패
@Slf4j
@RequiredArgsConstructor
@Service
public class UserService {
...
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void signupV2(UserEntity userEntity){
//회원가입
userRepository.save(userEntity);
//회원가입 축하 알림
userAlarmService.notifySignupSuccessV1(userEntity);
}
...
}
@RequiredArgsConstructor
@Service
public class UserAlarmService {
...
@Transactional(propagation = Propagation.REQUIRED)
public void notifySignupSuccessV1(UserEntity userEntity){
UserAlarmEntity alramCreate = UserAlarmEntity.builder()
.message(userEntity.getEmail()+" 님 회원가입을 축하합니다.")
.build();
userAlarmRepository.save(alramCreate);
throw new RuntimeException("예외 발생");
}
...
}
@DisplayName("회원가입(2중 트랜잭션:부모(REQUIRES_NEW) + 자식(REQUIRE))")
@Test
void signUpTestV2(){
//given
UserEntity create = UserEntity.builder()
.email("yjy@ibleaders.co.kr")
.password("1234")
.build();
//when-then
assertThrows(RuntimeException.class, () -> {
userService.signupV2(create);
});
assertThrows(NullPointerException.class, () -> {
UserEntity findUser = userRepository.findByEmail(create.getEmail());
System.out.println("findUser.toString() = " + findUser.toString());
});
assertThrows(NoSuchElementException.class, () -> {
userAlarmRepository.findById(1L).get();
});
}
부모 메서드(signupV1)에 트랜잭션을 걸고, 자식메서드(notifySignupSuccessV1)에도 트랜잭션을 걸었다.
전파 속성은 부모 메서드에는 REQUIRES_NEW, 자식 메서드는 디폴트이다.
회원가입 알림이 실패했고, 회원가입 또한 실패했다.
왜그럴까? REQUIRES_NEW로 트랜잭션을 실행하고 자식메서드의 트랜잭션이 실행됬으나 디폴트의 경우, 기존 트랜잭션이 존재한다면 그 트랜잭션에 포함되기 때문에 결국 하나의 트랜잭션으로 묶인것이다. 때문에 회원가입 알림이 실패하게 되면 회원가입 또한 실패하게 된다.
테스트3:회원가입 성공 + 회원가입 성공알람 실패
@Slf4j
@RequiredArgsConstructor
@Service
public class UserService {
...
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void signupV3(UserEntity userEntity){
//회원가입
userRepository.save(userEntity);
//회원가입 축하 알림
userAlarmService.notifySignupSuccessV2(userEntity);
}
...
}
@RequiredArgsConstructor
@Service
public class UserAlarmService {
...
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void notifySignupSuccessV2(UserEntity userEntity){
UserAlarmEntity alramCreate = UserAlarmEntity.builder()
.message(userEntity.getEmail()+" 님 회원가입을 축하합니다.")
.build();
userAlarmRepository.save(alramCreate);
throw new RuntimeException("예외 발생");
}
...
}
@DisplayName("회원가입(2중 트랜잭션:부모(REQUIRES_NEW) + 자식(REQUIRES_NEW))")
@Test
void signUpTestV3(){
//given
UserEntity create = UserEntity.builder()
.email("yjy@ibleaders.co.kr")
.password("1234")
.build();
//when-then
assertThrows(RuntimeException.class, () -> {
userService.signupV3(create);
});
assertThrows(NullPointerException.class, () -> {
UserEntity findUser = userRepository.findByEmail(create.getEmail());
System.out.println("findUser.toString() = " + findUser.toString());
});
assertThrows(NoSuchElementException.class, () -> {
userAlarmRepository.findById(1L).get();
});
}
부모 메서드(signupV1)에 트랜잭션을 걸고, 자식메서드(notifySignupSuccessV1)에도 트랜잭션을 걸었다.
전파 속성은 둘다 REQUIRES_NEW 이다.
회원가입 알림이 실패했고, 회원가입 또한 실패했다.
어? 원래라면 회원가입만 성공해야하는데 왜 둘다 실패하지??
이유는 예외 전파로 인해 rollback 된것이다.
✔ REQUIRES_NEW
는 트랜잭션을 나눌 수 있어도
❗ 예외 전파를 막지 않으면 상위 트랜잭션도 날아간다
➤ try-catch는 필수!
✅ Spring 트랜잭션이 rollback 하는 기준
조건 | rollback 발생? |
---|---|
RuntimeException 또는 그 하위 | ✅ 자동 rollback |
Checked Exception (IOException , SQLException 등) |
❌ 자동 rollback ❌ (원한다면 직접 설정 필요) |
@Transactional(rollbackFor = ...) 사용 |
✅ 해당 예외에도 rollback 가능 |
예외 발생 → catch 처리 O | ❌ rollback 안 함 (commit 됨) |
테스트4:회원가입 성공 + 회원가입 성공알람 실패
@Slf4j
@RequiredArgsConstructor
@Service
public class UserService {
...
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void signupV4(UserEntity userEntity){
//회원가입
userRepository.save(userEntity);
//회원가입 축하 알림
try {
userAlarmService.notifySignupSuccessV2(userEntity);
}catch (Exception e){
log.error("축하 알람은 error 발생");
}
}
...
}
@RequiredArgsConstructor
@Service
public class UserAlarmService {
...
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void notifySignupSuccessV2(UserEntity userEntity){
UserAlarmEntity alramCreate = UserAlarmEntity.builder()
.message(userEntity.getEmail()+" 님 회원가입을 축하합니다.")
.build();
userAlarmRepository.save(alramCreate);
throw new RuntimeException("예외 발생");
}
...
}
@DisplayName("회원가입(2중 트랜잭션:부모(REQUIRES_NEW) + 자식(REQUIRES_NEW))+try catch")
@Test
void signUpTestV4(){
//given
UserEntity create = UserEntity.builder()
.email("yjy@ibleaders.co.kr")
.password("1234")
.build();
//when
userService.signupV4(create);
UserEntity findUser = userRepository.findByEmail(create.getEmail());
//then
assertEquals(create.getEmail(), findUser.getEmail());
assertThrows(NoSuchElementException.class, () -> {
userAlarmRepository.findById(1L).get();
});
}
부모 메서드(signupV1)에 트랜잭션을 걸고, 자식메서드(notifySignupSuccessV1)에도 트랜잭션을 걸었다.
전파 속성은 둘다 REQUIRES_NEW 이다. 그리고 예외 전파를 방지하고자 try catch를 사용했다.
회원가입은 성공했고, 알림만 실패했다.==> 의도대로 기능이 동작했다.
그런데 매번 회원가입이 2개의 트랜잭션이 생성된다는것이 조금은 꺼림직하다..
테스트5:회원가입 성공 + 회원가입 성공알람 실패
@Slf4j
@RequiredArgsConstructor
@Service
public class UserService {
...
public void signupV5(UserEntity userEntity){
//회원가입
userServiceV1.signupV1(userEntity);
//회원가입 축하 알림
userAlarmService.notifySignupSuccessV2(userEntity);
}
...
}
@Slf4j
@RequiredArgsConstructor
@Service
public class UserServiceV1 {
...
@Transactional
public void signupV1(UserEntity userEntity){
//회원가입
userRepository.save(userEntity);
}
}
@RequiredArgsConstructor
@Service
public class UserAlarmService {
...
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void notifySignupSuccessV2(UserEntity userEntity){
UserAlarmEntity alramCreate = UserAlarmEntity.builder()
.message(userEntity.getEmail()+" 님 회원가입을 축하합니다.")
.build();
userAlarmRepository.save(alramCreate);
throw new RuntimeException("예외 발생");
}
...
}
@DisplayName("회원가입(2가지 트랜잭션이 적용된 구현체를 따로 만들고 호출)" +
"require + requires_new")
@Test
void signUpTestV5(){
//given
UserEntity create = UserEntity.builder()
.email("yjy@ibleaders.co.kr")
.password("1234")
.build();
//when-then
assertThrows(RuntimeException.class, () -> {
userService.signupV5(create);
});
UserEntity findUser = userRepository.findByEmail(create.getEmail());
assertEquals(create.getEmail(), findUser.getEmail());
assertThrows(NoSuchElementException.class, () -> {
userAlarmRepository.findById(1L).get();
});
}
이번엔 조금은 다른방식이다.
부모 메서드에는 트랜잭션이 없으며 2개의 자식구현체를 호출하고있다.
2개의 자식 메서드에는 각각 다른 전파속성이 존재한다.
회원가입은 성공했고, 알림만 실패했다.==> 의도대로 기능이 동작했다.
아직 2개의 트랜잭션이 동시에 생성됬는데 이것을 해결하고 싶다,,
테스트6:회원가입 성공 + 회원가입 성공알람 실패
@Slf4j
@RequiredArgsConstructor
@Service
public class UserService {
...
public void signupV11(UserEntity userEntity){
//회원가입
userServiceV1.signupV1(userEntity);
//회원가입 축하 알림
userAlarmService.notifySignupSuccessV1(userEntity);
}
...
}
@Slf4j
@RequiredArgsConstructor
@Service
public class UserServiceV1 {
...
@Transactional
public void signupV1(UserEntity userEntity){
//회원가입
userRepository.save(userEntity);
}
}
@RequiredArgsConstructor
@Service
public class UserAlarmService {
...
@Transactional
public void notifySignupSuccessV1(UserEntity userEntity){
UserAlarmEntity alramCreate = UserAlarmEntity.builder()
.message(userEntity.getEmail()+" 님 회원가입을 축하합니다.")
.build();
userAlarmRepository.save(alramCreate);
throw new RuntimeException("예외 발생");
}
...
}
@DisplayName("회원가입(2가지 트랜잭션이 적용된 구현체를 따로 만들고 호출)" +
"require + require")
@Test
void signUpTestV6(){
//given
UserEntity create = UserEntity.builder()
.email("yjy@ibleaders.co.kr")
.password("1234")
.build();
//when
assertThrows(RuntimeException.class, () -> {
userService.signupV11(create);
});
UserEntity findUser = userRepository.findByEmail(create.getEmail());
assertEquals(create.getEmail(), findUser.getEmail());
assertThrows(NoSuchElementException.class, () -> {
userAlarmRepository.findById(1L).get();
});
}
부모 메서드에는 트랜잭션이 없으며 2개의 자식구현체를 호출하고있다.
전파 속성은 자식 구현체 둘다 디폴트이다.
회원가입은 성공했고, 알림만 실패했다.==> 의도대로 기능이 동작했다.
트랜잭션도 1개로 드디어 되었다. try catch도 안했는데도 불구하고 예외 전파로 인해서 비정상적으로 rollback 되지도 않았다.
그렇다 이것이 이상적이면서 좀 더 안정적인 방법이다.(https://www.youtube.com/watch?v=mB3g3l-EQp0)
테스트7:회원가입 실패 + 회원가입 성공알람 성공
@Slf4j
@RequiredArgsConstructor
@Service
public class UserService {
...
public void signupV6(UserEntity userEntity){
//회원가입
userServiceV1.signupV2(userEntity);
//회원가입 축하 알림
userAlarmService.notifySignupSuccessV3(userEntity);
}
...
}
@Slf4j
@RequiredArgsConstructor
@Service
public class UserServiceV1 {
...
@Transactional
public void signupV2(UserEntity userEntity){
//회원가입
userRepository.save(userEntity);
throw new RuntimeException("예외 발생");
}
}
@RequiredArgsConstructor
@Service
public class UserAlarmService {
...
@Transactional
public void notifySignupSuccessV3(UserEntity userEntity){
UserAlarmEntity alramCreate = UserAlarmEntity.builder()
.message(userEntity.getEmail()+" 님 회원가입을 축하합니다.")
.build();
userAlarmRepository.save(alramCreate);
}
...
}
@DisplayName("회원가입(2가지 트랜잭션이 적용된 구현체를 따로 만들고 호출)" +
"require + require")
@Test
void signUpTestV7(){
//given
UserEntity create = UserEntity.builder()
.email("yjy@ibleaders.co.kr")
.password("1234")
.build();
//when
assertThrows(RuntimeException.class, () -> {
userService.signupV6(create);
});
assertThrows(NullPointerException.class, () -> {
UserEntity findUser = userRepository.findByEmail(create.getEmail());
System.out.println(findUser.toString());
});
assertThrows(NoSuchElementException.class, () -> {
userAlarmRepository.findById(1L).get();
});
}
부모 메서드에는 트랜잭션이 없으며 2개의 자식구현체를 호출하고있다.
전파 속성은 자식 구현체 둘다 디폴트이다.
회원가입은도 실패했고, 알림도 실패했다. ========> 의도대로 기능이 동작하지 않았다
왜 그럴까? 이유는 예외 전파 때문에 그렇다. 테스트6 과 테스트7을 비교해보면
테스트6은 회원가입 알림 기능에서 예외가 터졌고 테스트 7은 회원가입에서 예외가 터졌다.
예외 전파는 순서에 영향을 받으며 예외가 터진 이후의 메서드는 호출되지 않는다
테스트8:회원가입 실패 + 회원가입 성공알람 성공
@Slf4j
@RequiredArgsConstructor
@Service
public class UserService {
...
public void signupV8(UserEntity userEntity){
//회원가입
try{
userServiceV1.signupV2(userEntity);
}catch (Exception e){
log.error("회원가입 실패");
}
//회원가입 축하 알림
userAlarmService.notifySignupSuccessV3(userEntity);
}
...
}
@Slf4j
@RequiredArgsConstructor
@Service
public class UserServiceV1 {
...
@Transactional
public void signupV2(UserEntity userEntity){
//회원가입
userRepository.save(userEntity);
throw new RuntimeException("예외 발생");
}
}
@RequiredArgsConstructor
@Service
public class UserAlarmService {
...
@Transactional
public void notifySignupSuccessV3(UserEntity userEntity){
UserAlarmEntity alramCreate = UserAlarmEntity.builder()
.message(userEntity.getEmail()+" 님 회원가입을 축하합니다.")
.build();
userAlarmRepository.save(alramCreate);
}
...
}
@DisplayName("회원가입(2가지 트랜잭션이 적용된 구현체를 따로 만들고 호출" +
"require + require + try catch - 회원가입은 실패, 회원가입 알림은 성공)")
@Test
void signUpTestV8(){
//given
UserEntity create = UserEntity.builder()
.email("yjy@ibleaders.co.kr")
.password("1234")
.build();
//when
userService.signupV8(create);
assertThrows(NullPointerException.class, () -> {
UserEntity findUser = userRepository.findByEmail(create.getEmail());
System.out.println(findUser.toString());
});
UserAlarmEntity findAlarm = userAlarmRepository.findById(1L).get();
assertNotNull(findAlarm.getMessage());
}
회원가입은 실패했고, 회원가입 알림은 성공했다 ========> 의도대로 기능이 동작했다.
트랜잭션이 필요할땐 호출부와 구현부(트랜잭션)을 분리하고, 필요에 따라서 try catch를 사용하는것이 간편하고 안정적인 방법인거 같다.
래퍼런스:
'SPRING > 스프링부트' 카테고리의 다른 글
ApplicationEventPublisher, @EventListener 사용하여 동기,강결합된 기능 리펙토링 하기 (0) | 2025.04.02 |
---|---|
@MockBean vs @Mock,@InjectMocks (0) | 2025.01.06 |
Spring에서 request에 대한 검증 방법 2가지 (0) | 2024.05.07 |
RestTemplate, Spring MVC vs WebClient, Spring WebFlux 성능테스트 (0) | 2024.04.04 |
동시성 해결방법 (0) | 2024.03.26 |