"동시성해봤어?"
"응, @Transactional붙여서 하다가
에러나면 롤백 자동으로 해주는 거 아니야?"
트랜잭션이란 깨질 수 없는 하나의 흐름이니까, 문제가 생기면 다 롤백되는 거라고 생각했다. 그러다 직접 실험해보면서 정리가 되었다
@Transactional이 보장하는 것
트랜잭션은 ACID를 보장한다. 그런데 Isolation(격리성) 은 "어느 수준까지" 격리할지 설정에 따라 다르다.
Spring의 기본 격리 수준은 DB 기본값을 따른다. MySQL InnoDB 기준으로는 REPEATABLE READ다.
@Transactional // 기본값: Isolation.DEFAULT → DB 기본값 사용
public void doSomething() { ... }
@Transactional(isolation = Isolation.SERIALIZABLE) // 명시적 지정
public void doSomethingSafe() { ... }
격리 수준별로 허용되는 이상 현상이 다르다.
| 격리 수준 | Dirty Read | Non-Repeatable Read | Phantom Read | 비용 |
|---|---|---|---|---|
| READ UNCOMMITTED | 발생 | 발생 | 발생 | 가장 낮음 |
| READ COMMITTED | 방지 | 발생 | 발생 | 낮음 |
| REPEATABLE READ | 방지 | 방지 | 거의 방지 | 중간 |
| SERIALIZABLE | 방지 | 방지 | 방지 | 가장 높음 |
*InnoDB는 Gap Lock으로 Phantom Read를 대부분 방지한다.
📌 왜 항상 REPEATABLE READ 얘기만 나올까?
📌 왜 항상 REPEATABLE READ 얘기만 나올까?
격리 수준은 높을수록 안전하지만, 그만큼 동시 처리 성능이 떨어진다. 그래서 "어디서 타협할 것인가"가 핵심이다.
1. 현실적인 균형점
`SERIALIZABLE`은 완벽하게 안전하지만, 읽기마다 락을 걸기 때문에 트래픽이 조금만 몰려도 병목이 생긴다. 반대로 `READ COMMITTED` 이하는 같은 트랜잭션 안에서 같은 쿼리를 두 번 날렸을 때 결과가 달라질 수 있다. `REPEATABLE READ`는 그 중간 어딘가에서, 락 대신 MVCC(Multi-Version Concurrency Control) 로 스냅샷을 떠서 일관성을 보장한다. 성능을 크게 포기하지 않으면서도 꽤 많은 문제를 막아준다.
2. InnoDB는 Phantom Read도 거의 막아준다
표준 SQL 스펙상 REPEATABLE READ는 Phantom Read를 허용한다. 그런데 MySQL InnoDB는 Gap Lock + Next-Key Lock 덕분에 이 케이스도 대부분 방지한다. 사실상 SERIALIZABLE 수준의 안전성을 훨씬 낮은 비용으로 제공하는 셈이다.
3. 복제 환경에서의 안정성
MySQL 복제는 바이너리 로그 기반으로 동작하는데, `READ COMMITTED` 이하에서 Statement 방식 복제를 쓰면 Master와 Replica 간 데이터가 달라지는 문제가 생길 수 있다. REPEATABLE READ는 이런 위험을 줄여준다.
결국 실무에서 현실적인 선택지는 `READ COMMITTED`와 `REPEATABLE READ` 둘 중 하나다.
`READ UNCOMMITTED`는 Dirty Read를 허용하니 쓸 일이 거의 없고,
`SERIALIZABLE`은 성능 이슈로 특수한 경우 아니면 기피한다.
MySQL 기본값이 REPEATABLE READ이고, 대부분의 서비스가 별도 설정 없이 그대로 쓰다 보니 동시성 이슈 얘기가 나오면 자연스럽게 이 수준이 기준점이 된다.
💡 한 줄 요약: 성능도 포기하기 싫고, 정합성도 챙기고 싶을 때의 현실적인 답이 REPEATABLE READ다. MySQL이 기본값으로 선택한 것도 그 이유에서다.
REPEATABLE READ는 트랜잭션 시작 시점의 스냅샷을 읽는다. 다른 트랜잭션이 중간에 값을 바꿔도 내가 처음 읽은 값이 유지된다. 이게 동시성 문제를 막아줄 것 같지만, 실제로는 "언제 읽느냐"의 문제를 해결하지 못한다.
T1 시작 → stock = 1 읽음
T2 시작 → stock = 1 읽음 ← T1이 아직 커밋 안 했으니까
T1 → stock = 0 으로 저장 후 커밋
T2 → stock = 1 읽은 게 그대로 → 조건 통과 → stock = 0 으로 저장
결과: 재고 1개인데 두 명이 주문 성공
@Transactional이 있어도 동시성 문제는 생긴다.
함정 1: Checked Exception은 롤백이 안 된다
@Transactional
public void createOrder(Long userId) throws Exception {
orderRepository.save(new Order(userId)); // DB에 저장됨
throw new Exception("뭔가 잘못됨"); // Checked Exception 발생
// → 롤백이 안 된다. 주문은 저장된 상태로 남는다.
}
repository 안에 DB 저장을 되돌리는 기능이 있겠지, 라고 막연하게 생각했다.
실제로는 롤백이 안 된다.
이유는 Java 예외 계층 구조에 있다.
Throwable
├── Error (JVM 레벨 심각한 오류)
└── Exception
├── Checked Exception (컴파일러가 처리를 강제 → throws 선언 필요)
│ └── IOException, SQLException ...
└── RuntimeException (Unchecked, 컴파일러가 강제 안 함)
└── NullPointerException, IllegalArgumentException ...
Checked Exception은 개발자가 예상하고 직접 판단해서 던지는 예외다. Spring은 이를 "개발자가 의도한 상황"으로 보고 롤백하지 않는다.
Spring의 @Transactional은 기본적으로 RuntimeException과 Error만 롤백 대상으로 본다.
Exception을 던져도 커밋이 일어난다.
// 해결 방법 1: rollbackFor 명시
@Transactional(rollbackFor = Exception.class)
public void createOrder(Long userId) throws Exception { ... }
// 해결 방법 2: RuntimeException으로 래핑
@Transactional
public void createOrder(Long userId) {
try {
orderRepository.save(new Order(userId));
riskyOperation();
} catch (Exception e) {
throw new RuntimeException(e); // 이제 롤백된다
}
}
테스트로 직접 확인하면 이렇다.
@Test
void checked_exception_은_롤백_안_된다() {
assertThatThrownBy(() -> orderService.createOrder(userId))
.isInstanceOf(Exception.class);
// 롤백됐다면 0건이어야 하지만 → 1건이 조회된다
assertThat(orderRepository.count()).isEqualTo(1L);
}
그래서 도메인 예외는 RuntimeException을 상속한다
이 이유로 프로젝트의 도메인 예외들은 모두 RuntimeException을 상속하도록 설계한다.
// 기반 예외: RuntimeException 상속
public abstract class BusinessException extends RuntimeException {
private final String errorCode;
public BusinessException(String errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
}
// 도메인 예외: BusinessException → RuntimeException 계열
public class CouponNotFoundException extends BusinessException {
public CouponNotFoundException(Long couponId) {
super("COUPON_NOT_FOUND", "쿠폰을 찾을 수 없습니다: " + couponId);
}
}
두 가지를 한번에 챙길 수 있다.
- @Transactional이 자동으로 롤백해준다
- 모든 메서드에 throws XxxException을 달지 않아도 된다
함정 2: 같은 클래스 내부 호출은 트랜잭션이 적용되지 않는다
이건 처음에 전혀 몰랐던 부분이다.
@Service
public class OrderService {
public void placeOrder(Long userId) {
createOrder(userId); // 내부 호출 → @Transactional 무시됨!
}
@Transactional
public void createOrder(Long userId) {
orderRepository.save(new Order(userId));
throw new RuntimeException("실패");
// RuntimeException이니까 롤백되겠지?
// → 실제로는 저장된 채로 남는다
}
}
RuntimeException이니까 롤백될 거라고 생각했다. 하지만 롤백이 안 된다.
이유를 이해하려면 Spring이 @Transactional을 어떻게 동작시키는지 알아야 한다.
Spring은 프록시로 트랜잭션을 끼워넣는다
Spring은 @Transactional이 붙은 메서드가 있으면, 진짜 객체를 감싸는 프록시 객체를 만든다. 트랜잭션 시작/종료를 이 프록시가 담당한다. Spring @Transactional은 AOP 프록시 기반으로 동작한다.
.... 프으록시? AOP???
AOP(Aspect-Oriented Programming) 는 "여러 곳에 반복되는 부가 기능을 코드에 직접 안 쓰고, 외부에서 끼워넣는 것"이다. 트랜잭션 시작/종료, 로깅, 캐싱 같은 것들이 대표적이다.
@Transactional도 AOP로 동작한다. 개발자가 직접 begin() / commit() / rollback()을 쓰지 않아도 Spring이 알아서 끼워넣어준다.
그 끼워넣기를 담당하는 게 프록시(Proxy) 다. 프록시는 진짜 객체 앞에 세워두는 대리인 객체다.
외부 요청 → [프록시] → [진짜 객체]
↑
"트랜잭션 시작할게요"
진짜 메서드 실행
"트랜잭션 끝낼게요"
택배로 비유하면 이렇다.
고객(외부) → 대리점(프록시) → 본사(진짜 객체)
고객이 대리점에 요청하면, 대리점이 접수 처리(트랜잭션 시작)를 하고 본사에 전달한다. 본사 작업이 끝나면 대리점이 마무리 처리(트랜잭션 종료)를 한다.
Spring은 @Transactional이 붙은 메서드가 있으면 빈을 등록할 때 자동으로 프록시를 만든다.
// Spring이 내부적으로 만드는 프록시 (개념적으로)
public class OrderServiceProxy extends OrderService {
@Override
public void placeOrder(Long userId) {
super.placeOrder(userId); // @Transactional 없으니 그냥 통과
}
@Override
public void createOrder(Long userId) {
트랜잭션시작();
super.createOrder(userId); // 진짜 메서드 실행
트랜잭션종료();
}
}
@Autowired로 주입받으면 실제로 이 프록시 객체가 들어온다.
@Autowired
OrderService orderService;
System.out.println(orderService.getClass());
// 출력: class OrderService$$SpringCGLIB$$0 ← 프록시!
OrderService를 달라고 했는데 OrderService$$SpringCGLIB$$0가 온다. Spring이 만든 프록시다.
this 호출은 프록시를 우회한다
여기서 내부 호출 문제가 생긴다.
placeOrder()가 호출되면 어떤 일이 일어나는지 단계별로 따라가보자.
1. 외부에서 orderService.placeOrder() 호출
→ orderService는 프록시 → 프록시.placeOrder() 실행
2. 프록시.placeOrder()는 @Transactional이 없으니 그냥 super.placeOrder() 호출
→ 이 순간 진짜 OrderService 안으로 들어옴
3. 진짜 OrderService.placeOrder() 실행 중
→ this.createOrder() 호출
→ this는 "지금 이 코드가 실행되고 있는 객체" = 진짜 OrderService
4. 진짜 OrderService.createOrder() 바로 실행
→ 프록시의 createOrder()를 거치지 않음
→ 트랜잭션 없음
대리점(프록시) 비유로 다시 설명하면 이렇다.
고객 → 대리점 → 본사 직원 A (placeOrder)
↓
본사 직원 A가 옆자리 직원 B한테 직접 말을 건넴 (this.createOrder)
↓
본사 직원 B (createOrder) 바로 처리
← 대리점을 거치지 않음 → 접수 처리 없음
본사 내부에서 직원끼리 직접 말을 걸면, 대리점(프록시)은 끼어들 기회가 없다.
this.createOrder()가 바로 이 상황이다. 이미 진짜 객체 안에 들어와 있기 때문에, this는 프록시를 모르고 자기 클래스에 선언된 createOrder()를 바로 실행해버린다.
외부 → 프록시.placeOrder() → 진짜.placeOrder() → 진짜.createOrder()
↑
프록시를 다시 안 거침 → 트랜잭션 없음
반면 외부에서 createOrder()를 직접 호출하면 프록시를 거친다.
외부 → 프록시.createOrder()
↓
트랜잭션시작()
super.createOrder() ← 진짜 메서드 실행
트랜잭션종료() ← 트랜잭션 있음!!!
즉, 프록시의 createOrder()와 진짜 객체의 createOrder()는 다른 메서드이다. 트랜잭션은 프록시 버전에만 있다.
같은 createOrder()인데 누가 호출하느냐에 따라 트랜잭션 적용 여부가 달라진다.
프록시가 호출 → 트랜잭션 O (고객이 대리점을 통해 요청)
진짜 객체가 호출 → 트랜잭션 X (직원이 옆자리 직원한테 직접 말을 건넴)
그림으로 그리면

이걸 해결 하려면?
별도 클래스에서 호출한다
// 해결 방법: 트랜잭션 경계가 필요한 메서드를 별도 클래스로 분리
@Service
public class OrderFacade {
private final OrderService orderService; // 프록시 객체가 주입됨
public void placeOrder(Long userId) {
orderService.createOrder(userId); // 프록시를 거침 → @Transactional 적용됨
}
}
OrderFacade는 "여러 도메인 서비스를 하나의 흐름으로 모으는 역할"이기도 하지만, 이 구조 덕분에 자연스럽게 프록시를 거치게 되어 트랜잭션도 올바르게 동작한다. 의도하지 않았어도 결과적으로 올바른 설계가 된 셈이다.
정리
@Transactional은 마법이 아니다. 정확히 무엇을 보장하는지 알고 써야 한다.
1. 롤백 대상을 확인하라
- 기본적으로 RuntimeException과 Error만 롤백된다
- Exception(Checked)은 rollbackFor를 명시하거나 RuntimeException으로 래핑해야 한다
- 도메인 예외를 RuntimeException 계열로 설계하면 자연스럽게 해결된다
2. 내부 호출은 트랜잭션이 적용되지 않는다
- @Transactional은 프록시 기반이라 this.method() 호출은 프록시를 우회한다
- 트랜잭션 경계가 필요한 메서드는 반드시 외부 클래스(Facade 등)에서 호출해야 한다
3. 격리 수준은 동시성 문제를 다 막아주지 않는다
- 기본값 REPEATABLE READ는 트랜잭션 시작 시점의 스냅샷을 읽는다
- 여러 트랜잭션이 같은 데이터를 동시에 읽고 쓰면 여전히 문제가 생긴다
- 이 경우엔 격리 수준을 올리거나 락이 필요하다
시리즈 목차
- @Transactional 붙이면 동시성이 다 해결될 줄 알았다 ← 현재 글
- 스투시 반팔티를 10명이 동시에 주문했을 때 발생한 일
- 비관적 락 vs 낙관적 락, 선택 기준은 무엇이었나
- 쿠폰 사용에 대한 처리는 어떤 락이 적절할까
- 브랜드에서 전화가 왔다, "우리 상품 좋아요가 한번에 수십개씩 빠져요!"
'개발 > 개발' 카테고리의 다른 글
| [Java / Spring Boot] 비관적 락 vs 낙관적 락, 선택 기준은 무엇이었나 - 3편 (0) | 2026.03.06 |
|---|---|
| [Java / Spring Boot] 스투시 반팔티를 10명이 동시에 주문했을 때 발생한 일-2편 (1) | 2026.03.06 |
| [TDD] TDD를 하다 보니 DIP와 VO를 이해해야 했다 (1) | 2026.02.27 |
| [스크랩 기능] 스크랩은 HardDelete vs SoftDelete? (0) | 2026.02.26 |
| [스크랩 기능] 스크랩 삭제 API 설계 – scrapId만으로 충분할까? (0) | 2026.02.26 |