본문 바로가기
개발/개발

[Java / Spring Boot] @Transactional 붙이면 동시성이 다 해결될 줄 알았다 -1편

by 글쓰는 개발자 2026. 3. 5.

"동시성해봤어?"

"응, @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는 트랜잭션 시작 시점의 스냅샷을 읽는다
  • 여러 트랜잭션이 같은 데이터를 동시에 읽고 쓰면 여전히 문제가 생긴다
  • 이 경우엔 격리 수준을 올리거나 락이 필요하다

 

시리즈 목차
  1. @Transactional 붙이면 동시성이 다 해결될 줄 알았다 ← 현재 글
  2. 스투시 반팔티를 10명이 동시에 주문했을 때 발생한 일
  3. 비관적 락 vs 낙관적 락, 선택 기준은 무엇이었나
  4. 쿠폰 사용에 대한 처리는 어떤 락이 적절할까
  5. 브랜드에서 전화가 왔다, "우리 상품 좋아요가 한번에 수십개씩 빠져요!"

 

반응형