[Huge Traffic Handling] 트랜잭션과 @Transactional — Spring은 왜 이걸 대신 해주는가

Raha·2026년 4월 19일

Huge Traffic Handling

목록 보기
5/9

들어가며

지난 글에서는 Spring AI의 Advisor 패턴과 Function Calling을 다뤘다. 이번 글에서는 잠시 AI 주제에서 벗어나, 백엔드 개발의 가장 기본적인 안전장치 중 하나인 트랜잭션을 짚고 넘어간다.

이런 질문들을 생각해보자.

  • 트랜잭션이 왜 필요한가?
  • Spring은 트랜잭션을 어떻게 대신 처리해주는가?
  • 직접 제어해야 할 때는 언제인가?

1. 트랜잭션이란

트랜잭션은 여러 DB 작업을 하나의 논리적 단위로 묶는 것이다. 핵심은 All or Nothing — 전부 성공하거나, 전부 실패하거나.

주문 처리를 예로 들면, 주문 테이블 INSERT와 재고 UPDATE는 반드시 함께 성공하거나 함께 실패해야 한다. 주문만 들어가고 재고가 안 줄면 데이터가 엉망이 된다.

2. ACID 원칙

트랜잭션이 보장해야 하는 4가지 원칙이다.

원칙의미예시
Atomicity (원자성)전부 성공 또는 전부 롤백주문+재고 둘 다 성공해야
Consistency (일관성)트랜잭션 전후 규칙 만족이체 전후 총액 동일
Isolation (격리성)트랜잭션 간 간섭 없음동시 주문도 독립 처리
Durability (지속성)커밋된 데이터는 영구 보존서버 재시작 후에도 유지

이 중 AutoCommit이 켜진 상태에서 가장 먼저 무너지는 건 원자성(A)이다. SQL 한 줄마다 자동으로 커밋되니, 중간에 에러가 나도 이미 커밋된 앞 작업은 되돌릴 수 없다.

그래서 JDBC 트랜잭션 코드에서 가장 먼저 하는 게 이것이다.

connection.setAutoCommit(false); // 커밋 타이밍을 내가 직접 제어할게

3. 트랜잭션 관리 방식 두 가지

3-1. 프로그래밍 방식

개발자가 트랜잭션의 시작, 커밋, 롤백을 코드로 직접 제어하는 방식이다. Spring의 PlatformTransactionManager를 사용한다.

TransactionStatus status = transactionManager.getTransaction(
    new DefaultTransactionDefinition()); // 트랜잭션 시작

try {
    product.reduceStock(quantity);
    productRepository.save(product);

    transactionManager.commit(status);  // 성공 시 커밋

} catch (Exception ex) {
    transactionManager.rollback(status); // 실패 시 롤백
    throw ex;
}

서비스 메서드가 100개라면 저 try-catch 블록이 100번 반복된다. 누락될 위험도 있고, 비즈니스 로직과 트랜잭션 로직이 뒤섞인다.

그럼에도 이 방식이 필요한 경우가 있다. 100개 배치 작업 중 1개가 실패해도 나머지 99개는 커밋해야 하는 부분 롤백 같은 경우다. 조건에 따라 트랜잭션 경계를 유연하게 조정해야 할 때 직접 제어가 빛을 발한다.

3-2. 선언적 방식 (@Transactional)

Spring이 AOP Proxy를 통해 자동으로 처리하는 방식이다. 메서드 앞뒤를 Proxy가 감싸서 트랜잭션을 시작하고, 성공하면 커밋, 예외가 나면 롤백한다.

// Before: 트랜잭션 코드가 비즈니스 로직과 섞임
public void updateStock(Long id, int qty) {
    TransactionStatus status = transactionManager.getTransaction(...);
    try {
        product.reduceStock(qty);
        transactionManager.commit(status);
    } catch (Exception e) {
        transactionManager.rollback(status);
    }
}

// After: 비즈니스 로직만 남음
@Transactional
public void updateStock(Long id, int qty) {
    product.reduceStock(qty);
}

읽기 전용 메서드에는 readOnly = true를 붙인다. 이렇게 하면 Spring이 Dirty Checking(변경 감지)을 생략해서 불필요한 스냅샷 비교 오버헤드를 줄인다.

@Transactional(readOnly = true)
public Product getProduct(Long id) {
    return productRepository.findById(id)
        .orElseThrow(() -> new DomainException(PRODUCT_NOT_FOUND));
}

주의할 점은 AOP 기반이라는 것이다. private 메서드나 같은 클래스 내부 호출(self-invocation)에는 Proxy가 개입하지 못해서 트랜잭션이 적용되지 않는다. 이건 6단계에서 다뤘던 self-invocation 문제와 같은 맥락이다.

4. 두 방식 비교

항목선언적 (@Transactional)프로그래밍 방식
코드 간결성어노테이션 한 줄try-catch 반복
유지보수비즈니스 로직과 분리혼재 가능성
제어 유연성속성으로 설정조건부 커밋/롤백 가능
적합한 상황일반적인 CRUD부분 롤백, 복잡한 로직

마치며

트랜잭션은 결국 데이터 무결성을 지키는 안전장치다. Spring은 @Transactional이라는 어노테이션 하나로 그 안전장치를 AOP Proxy가 자동으로 달아준다. 단순 CRUD는 선언적 방식으로 충분하고, 세밀한 제어가 필요할 때만 프로그래밍 방식을 꺼내면 된다.

profile
Backend Developer | Aspiring Full-Stack Enthusiast

0개의 댓글