👀 서비스 로직을 개발할 때 여러 작업을 하나로 묶어서 모두 성공하거나 모두 실패하게 처리해야 할 때가 있다.
ex) 회원가입과 동시에 포인트를 적립하는 상황에서 회원만 등록되고 포인트 적립이 실패하면 안됨
👉 이러한 문제를 해결하기 위해 트랜잭션을 사용한다.
1️⃣ 스프링 트랜잭션의 핵심: AOP 프록시 + @Transactional
스프링에서 @Transactional 을 메소드에 붙이면, 해당 메소드는 프록시(proxy) 객체를 통해 호출된다. 이 프록시는 메소드 실행 전후에 트랜잭션의 시작, 커밋, 롤백을 자동으로 처리해준다.
🤷♀️ 프록시란?
진짜 객체 대신 앞에서 대리로 행동하는 가짜 객체
ex) 직접 은행에 가서 돈을 인출하는 대신 나 대신 일을 처리해주는 직원(프록시)에게 부탁할 수 있음
✅ 동작 흐름
[Service 메소드 호출]
↓
[스프링 프록시 객체]
├─ 트랜잭션 시작
├─ 비즈니스 로직 실행
└─ 예외 없으면 커밋 / 예외 발생 시 롤백
↓
[실제 로직 실행 완료]
✅ 예시 코드
@Service
public class UserService {
@Transactional // 트랜잭션 시작~종료를 자동으로 처리
public void registerUser(User user) {
userRepository.save(user); // 1. DB insert
pointService.addWelcomePoint(user); // 2. 포인트 적립
}
}
👉 registerUser() 안에서 예외가 발생하면, DB insert까지 포함해서 모두 롤백된다.
@Transactional 이 없는 상태에서 addWelcomePoint()에서 예외가 발생하면 이미 커밋된 1번 insert는 그대로 DB에 남는다.
2️⃣ @Transactional 기본 옵션 정리
| 옵션 | 기본값 | 설명 |
| propagation | REQUIRED | 트랜잭션 전파 방식 (기존 트랜잭션 참여 , 없으면 새로 생성) |
| isolation | DB 기본 | 동시에 실행될 때 데이터 격리 수준 |
| rollbackFor | RuntimeException | 어떤 예외가 발생하면 롤백할지 지정 |
| readOnly | false | 읽기 전용 여부 (성능 최적화용) |
| timeout | -1 | 트랜잭션 최대 지속 시간 (초 단위) |
3️⃣ 트랜잭션 전파 (Propagation)
이미 트랜잭션이 진행 중일 때, 새로 호출되는 메소드가 어떤 방식으로 참여할지 결정
| 옵션 | 설명 | 트랜잭션 존재여부에 따른 동작 | 사용 예시 |
| REQUIRED (Default) | 기존 트랜잭션이 있으면 참여, 없으면 새로 시작 | 있으면 참여 / 없으면 새로 생성 | 서비스 계층의 메소드 대부분 |
| REQUIRES_NEW | 항상 새 트랜잭션을 시작, 기존 트랜잭션은 일시 중단 | 무조건 새로 시작 | 로그 저장, 이메일 발송 이력 등 메인 트랜잭션과 분리해 실패해도 남겨야 하는 작업 |
| NESTED | 기존 트랜잭션 내부에 저장점(savepoint) 생성 후 부분 롤백 가능 | 있으면 저장점 생성 / 없으면 새 트랜잭션 | 메인 처리는 유지하되, 일부 보조 로직만 실패 시 되돌리고 싶은 경우 |
| SUPPORTS | 트랜잭션이 있으면 참여, 없으면 트랜잭션 없이 실행 | 있으면 참여 / 없으면 비트랜잭션 | 조회 전용 로직이나 트랜잭션이 선택사항인 경우 (readOnly = true 옵션으로 해결되는 경우가 많아 잘 쓰이지X) |
| NOT_SUPPORTED | 트랜잭션이 있으면 일시정지 하고 비트랜잭션으로 실행 | 있으면 정지 후 비트랜잭션 / 없으면 그냥 실행 | 별도의 비트랜잭션으로 무거운 쿼리를 돌릴 경우 ex) 대용량 배치 조회 |
4️⃣ 격리 수준 (Isolation)
👉 여러 트랜잭션이 동시에 실행될 때 데이터 정합성을 어떻게 보장할지 결정한다.
| 수준 | 설명 | 주의점 |
| READ_UNCOMMITTED | 커밋 안 된 데이터도 읽음 | 더티 리드 발생 가능 |
| READ_COMMITTED | 커밋된 데이터만 읽음 | 대부분 DB 기본값 |
| REPEATABLE_READ | 한 트랜잭션 내에서 조회 결과가 항상 같음 | 팬텀 리드 가능성 |
| SERIALIZABLE | 완벽한 정합성 보장 | 동시성 👇, 성능👇 |
5️⃣ 롤백 규칙 & 주의점
스프링은 기본적으로 RuntimeException이나 Error가 발생하면 롤백하고 Checked Exception은 롤백하지 않는다.
@Transactional(rollbackFor = {IOException.class})
public void someMethod() throws IOException {
userRepository.save(...);
throw new IOException("Checked 예외 발생!");
}
이렇게 하면 checked exception도 롤백 대상으로 포함할 수 있다.
⚠️ 주의할 점: 내부 호출(self-invocation)
@Service
public class UserService {
@Transactional
public void outer() {
inner(); // ❌ 프록시 안 거침 (this.inner())
}
@Transactional
public void inner() {
// 트랜잭션 적용 기대했지만 안 됨
}
}
👉@Transactional 은 프록시를 거쳐야 동작하기 때문에, 같은 클래스 안에서 자기 자신 메소드를 직접 호출하면 트랜잭션이 적용되지 않는다.
이런 경우엔 메소드를 다른 클래스로 분리하거나, 프록시를 통해 호출하는 방식으로 우회해야 한다.
✅ 해결책 1: 메소드를 다른 클래스로 분리
@Service
public class InnerService {
@Transactional
public void inner() {
System.out.println("InnerService 트랜잭션 적용");
}
}
@Service
public class UserService {
private final InnerService innerService;
public UserService(InnerService innerService) {
this.innerService = innerService;
}
@Transactional
public void outer() {
System.out.println("outer 실행");
innerService.inner(); // ✅ 프록시 객체를 통해 호출됨
}
}
✅ 해결책 2: 자기 자신을 프록시로 주입받아 호출
@Service
public class UserService {
private final UserService self; // 자기 자신 프록시 주입
public UserService(UserService self) {
this.self = self;
}
@Transactional
public void outer() {
System.out.println("outer 실행");
self.inner(); // ✅ 프록시 객체로 inner() 호출
}
@Transactional
public void inner() {
System.out.println("inner 트랜잭션 적용됨 ✅");
}
}
💡 단, 이 방식은 순환참조 설정에 주의해야 하고, 가독성 면에서 분리 방식(1번)이 좀 더 권장됩니다.
'Language > Java' 카테고리의 다른 글
| Spring (0) | 2024.10.02 |
|---|---|
| [JAVA] 퀵 정렬 (Quick Sort) (0) | 2022.12.14 |
| [JAVA] 삽입 정렬 (Insertion Sort) (2) | 2022.12.14 |
| [JAVA] 선택정렬 (Selection Sort) (0) | 2022.12.14 |
| [JAVA] 거품 정렬 (Bubble Sort) (0) | 2022.12.09 |