Skip to content

시니어도 헷갈리는 @Transactional 실수 5가지

시니어도 헷갈리는 @Transactional 실수 5가지




TL;DR

  • 문제: Checked Exception 발생 시 데이터가 롤백되지 않고 일부만 저장됨 (데이터 정합성 깨짐)
  • 원인: Spring @Transactional이 기본적으로 RuntimeException과 Error만 롤백하고, Private 메서드나 예외 삼키기 등 다양한 함정 존재
  • 해결: RuntimeException 사용, Public 메서드에만 적용, 예외 삼키기 금지, 전파 속성 이해
  • 효과: 트랜잭션 관련 버그 90% 감소, 데이터 정합성 보장, 팀 컨벤션 확립
  • 한계: Spring AOP 프록시 방식의 근본적 제약, 러닝 커브, 프레임워크 종속성
  • 원칙: 도메인/비즈니스 예외는 Runtime 계층으로 통일, 인프라 Checked(IO/SQL 등)는 Service에서 래핑


환경

  • 프레임워크: Spring Boot 3.x
  • DB: MySQL 8.0, InnoDB
  • 격리수준: READ COMMITTED
  • 트랜잭션 관리: Spring @Transactional (AOP 프록시 방식)




글 머리말

입사 첫 달, 저는 황당한 버그를 만났습니다.

분명 예외가 발생했는데 데이터베이스에는 일부 데이터만 저장되어 있었습니다. 주문은 생성됐는데 결제 정보는 없고, 사용자 정보는 업데이트됐는데 로그는 남지 않았죠. 원인을 파악하는 데만 이틀이 걸렸고, 범인은 제가 전혀 의심하지 않았던 Checked Exception이었습니다.

그 후로 7년 동안 트랜잭션 관련 버그를 수없이 봤습니다. 재밌는 건, 시니어 개발자들도 비슷한 실수를 반복한다는 점입니다. @Transactional은 한 줄로 트랜잭션을 적용할 수 있어서 편리하지만, 그 편리함 뒤에 숨은 함정들이 있거든요.

이번 글에서는 시니어 개발자들도 자주 헷갈리는 @Transactional 실수 5가지를 실무 경험을 바탕으로 정리했습니다.


한눈에 보는 5가지 실수

graph TB
    subgraph 예외 관련
        M1[실수 1<br/>Checked Exception<br/>롤백 안 됨]
        M3[실수 3<br/>예외 삼키기<br/>롤백 안 됨]
    end

    subgraph 프록시 관련
        M2[실수 2<br/>Private 메서드<br/>트랜잭션 미적용]
    end

    subgraph 설정 관련
        M4[실수 4<br/>readOnly에서<br/>쓰기 작업]
        M5[실수 5<br/>전파 속성<br/>미이해]
    end

    M1 --> R1[부분 커밋]
    M2 --> R1
    M3 --> R1
    M4 --> R2[DB별 동작 다름]
    M5 --> R3[의도치 않은 롤백]

    style M1 fill:#f8d7da
    style M2 fill:#f8d7da
    style M3 fill:#f8d7da
    style M4 fill:#fff3cd
    style M5 fill:#fff3cd




실수 1: Checked Exception은 롤백 안 됨

한눈에 요약

  • 기본 롤백 대상: RuntimeException + Error, Checked는 기본 커밋
  • 도메인/비즈니스 예외는 Runtime 계층으로 통일, 인프라 Checked(IO/SQL)는 Service에서 래핑
  • rollbackFor는 예외적 케이스에만 명시적으로 사용

문제 상황

Spring의 @Transactional은 기본적으로 RuntimeException과 Error만 롤백합니다. Checked Exception이 발생하면 롤백하지 않고 커밋해버립니다.

저는 이걸 모르고 입사 첫 달에 큰 낭패를 봤습니다. 솔직히 “예외가 발생하면 당연히 롤백되는 거 아닌가?”라고 생각했거든요.


실제 코드

문제가 발생한 코드입니다. “예외가 발생하면 당연히 롤백될 거야”라고 생각하고 작성했습니다.

@Service
public class OrderService {

    @Transactional
    public void processOrder(Order order) throws Exception {
        orderRepository.save(order);           // 1. 주문 저장
        paymentRepository.save(payment);       // 2. 결제 정보 저장

        if (payment.isFailed()) {
            throw new Exception("결제 실패");   // ⚠️ Checked Exception
        }

        logRepository.save(log);               // 3. 로그 저장
    }
}

예상: 예외가 발생하면 주문과 결제 정보가 모두 롤백될 것이다.

현실: 주문과 결제 정보는 그대로 커밋되고, 로그만 저장되지 않았습니다. Spring은 Checked Exception을 “복구 가능한 예외”로 보기 때문에 롤백하지 않습니다. 데이터 정합성이 완전히 깨져버린 거죠.


왜 이런 정책인가

Spring 기본 정책은 자바 진영의 “Recoverable(Checked) vs Bug(Runtime)” 구분을 따릅니다. Checked Exception은 복구 가능(recoverable)하니 커밋, RuntimeException/Error는 프로그래밍 오류이니 롤백한다는 관점입니다. 하지만 실무에서는 “결제 실패”처럼 복구 가능 여부가 맥락에 따라 달라 애매합니다.


해결 방법

⚠️두 가지 해결책

방법 1: 커스텀 도메인/비즈니스 예외는 모두 RuntimeException으로

public class OrderException extends RuntimeException {
    public OrderException(String message) {
        super(message);
    }
}

방법 2: rollbackFor 속성 명시

@Transactional(rollbackFor = Exception.class)
public void processOrder(Order order) throws Exception {
    // ...
}

저희 팀은 방법 1을 선택했습니다. 도메인 예외를 아예 RuntimeException 계층으로 통일하고, 인프라에서 오는 Checked(IO/SQL 등)는 Service 계층에서 잡아 BusinessException으로 래핑합니다. 매번 rollbackFor를 기억하는 것보다 실수 가능성을 없애는 편이었습니다.


동작 원리

flowchart LR
    A[예외 발생] --> B{예외 타입}
    B -->|RuntimeException| C[자동 롤백]
    B -->|Checked Exception| D[커밋됨]
    D --> E[데이터 정합성 깨짐]

    style C fill:#d4edda
    style D fill:#f8d7da
    style E fill:#f8d7da

핵심 요약

  • Checked Exception은 기본적으로 롤백되지 않음
  • 커스텀 예외는 처음부터 RuntimeException으로 만들 것
  • 또는 @Transactional(rollbackFor = Exception.class) 명시




실수 2: Private 메서드에 @Transactional 붙이기

한눈에 요약

  • 프록시 기반이라 public + 프록시 경유 호출만 트랜잭션 적용
  • self-invocation이면 protected/public이어도 미적용 (private와 동일)
  • AspectJ 위빙은 예외지만, 본 글은 기본 Proxy 모드 기준

문제 상황

이건 저도 2년 차 때까지 몰랐던 사실인데, Private 메서드에 @Transactional을 붙여도 작동하지 않습니다.

더 무서운 건 컴파일 에러도, 런타임 에러도 없다는 겁니다. 조용히 트랜잭션 없이 실행됩니다.


실제 코드

@Service
public class UserService {

    public void updateUser(Long userId) {
        updateUserProfile(userId);  // private 메서드 호출
    }

    @Transactional  // 작동 안 함!
    private void updateUserProfile(Long userId) {
        User user = userRepository.findById(userId).orElseThrow();
        user.updateProfile();
        userRepository.save(user);
    }
}

위 코드에서 updateUserProfile 메서드는 트랜잭션이 적용되지 않습니다.


왜 이런 일이 발생하나

Spring은 프록시 방식으로 트랜잭션을 적용합니다. 프록시는 외부에서 호출되는 public 메서드만 가로챌 수 있습니다.

graph TB
    A[클라이언트] -->|호출| B[Spring Proxy]
    B -->|public만 가로챔| C[updateUser public]
    C -->|내부 호출| D[updateUserProfile private]
    D -->|프록시 우회| E[트랜잭션 미적용]

    style B fill:#fff3cd
    style E fill:#f8d7da

this.updateUserProfile() 형태의 내부 호출은 프록시를 거치지 않기 때문에 트랜잭션이 적용되지 않습니다. 사실 이 문제는 private뿐 아니라 protected/public이라도 “같은 클래스 내부(self-invocation)”이면 동일하게 발생합니다. (AspectJ 위빙 모드에서는 비-public도 적용 가능하지만, 여기서는 기본 Proxy 모드 기준입니다.)


해결 방법

방법 1: public으로 변경 (가장 간단)

@Service
public class UserService {

    @Transactional
    public void updateUserProfile(Long userId) {
        // ...
    }
}

방법 2: 별도 Service 클래스로 분리 (권장)

@Service
public class UserProfileService {

    @Transactional
    public void updateUserProfile(Long userId) {
        // ...
    }
}

@Service
public class UserService {

    private final UserProfileService userProfileService;

    public void updateUser(Long userId) {
        userProfileService.updateUserProfile(userId);  // 외부 호출
    }
}

방법 3: Self-Injection (비권장)

@Service
public class UserService {

    @Autowired
    private UserService self;  // 자기 자신 주입

    public void updateUser(Long userId) {
        self.updateUserProfile(userId);  // 프록시를 거침
    }

    @Transactional
    public void updateUserProfile(Long userId) {
        // ...
    }
}

단, 이 방법은 순환 참조와 혼란을 야기할 수 있어서 권장하지 않습니다.


저희 팀은 방법 2를 선택했습니다. 책임을 명확하게 분리하고, 코드 의도도 더 명확해지기 때문입니다. 그리고 나중에 다른 서비스에서 재사용하기도 쉽고요.


핵심 요약

  • Private 메서드에 @Transactional은 무의미
  • Spring Proxy는 외부 호출(public 메서드)만 가로챔
  • 별도 Service로 분리하거나 public으로 변경할 것




실수 3: 예외를 잡아서 처리하면 롤백 안 됨

한눈에 요약

  • catch만 하면 롤백 마크가 안 찍혀 부분 커밋 위험
  • 외부 API 실패 시 예외 재던지기 또는 setRollbackOnly() 필요
  • 코드 리뷰 체크리스트에 “예외 삼키기 금지”를 넣어 재발 방지

문제 상황

당연한 얘기지만, 예외를 try-catch로 잡아버리면 Spring은 예외가 발생한 줄 모릅니다. 그래서 롤백하지 않습니다.

이건 시니어 개발자도 자주 실수하는 부분입니다. 특히 외부 API 호출 시 예외 처리를 하다가 실수로 롤백을 막는 경우가 많습니다.


실제 코드

@Service
public class OrderService {

    @Transactional
    public void processOrder(Long orderId) {
        Order order = orderRepository.findById(orderId).orElseThrow();
        order.updateStatus(OrderStatus.PROCESSING);
        orderRepository.save(order);  // 1. 주문 상태 업데이트

        try {
            externalApiClient.sendOrder(order);  // 2. 외부 API 호출
        } catch (Exception e) {
            log.error("외부 API 호출 실패", e);  // 예외 삼킴!
        }

        logRepository.save(createLog(order));  // 3. 로그 저장
    }
}

외부 API 호출이 실패하면?

  • 주문 상태는 PROCESSING으로 업데이트됨
  • 로그는 저장됨
  • 문제: 외부 시스템에는 주문 정보가 없음

데이터 정합성이 깨집니다. 주문 시스템에서는 “처리 중”인데, 외부 시스템에서는 주문이 없는 상태죠.


해결 방법

🚨예외를 삼키지 마세요

예외를 catch한 후 다시 던지거나, RuntimeException으로 감싸서 던져야 합니다.

@Service
public class OrderService {

    @Transactional
    public void processOrder(Long orderId) {
        Order order = orderRepository.findById(orderId).orElseThrow();
        order.updateStatus(OrderStatus.PROCESSING);
        orderRepository.save(order);

        try {
            externalApiClient.sendOrder(order);
        } catch (Exception e) {
            log.error("외부 API 호출 실패", e);
            throw new ExternalApiException("주문 전송 실패", e);  // 다시 던지기
        }

        logRepository.save(createLog(order));
    }
}

실제 개선 결과

저희 팀에서 코드 리뷰 체크리스트에 “예외 삼키기 금지” 항목을 추가한 후:

항목BeforeAfter
부분 커밋 이슈월 3~4건0건
트랜잭션 관련 코드 리뷰 질문PR당 3~4개거의 없음

체크리스트 하나로 이 정도 효과가 있었습니다.


핵심 요약

  • 예외를 catch만 하면 Spring은 롤백하지 않음
  • catch 후 반드시 예외를 다시 던질 것
  • 또는 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() 호출




실수 4: 읽기 전용 트랜잭션에서 쓰기 작업

한눈에 요약

  • 동작은 DB/드라이버 설정 의존 (PostgreSQL 예외, MySQL 경고)
  • Hibernate는 flush를 억제해 쓰기 SQL이 안 나갈 뿐, 강제 flush하면 나갈 수 있음
  • readOnly 힌트는 Routing DS와 결합해 Replica 라우팅용으로 활용 가능

문제 상황

@Transactional(readOnly = true)는 읽기 전용 트랜잭션입니다. 성능 최적화를 위해 사용하는데, 여기서 쓰기 작업을 하면 동작이 DB마다 다릅니다. (대표적인 설정 기준이며, JDBC 드라이버/DB 설정에 따라 달라질 수 있습니다.)

  • PostgreSQL: 예외 발생
  • MySQL (InnoDB): 쓰기 작동함 (경고만 로그에 남음)

저는 이걸 몰라서 로컬(MySQL)에서는 잘 되다가 스테이징(PostgreSQL)에서 터진 경험이 있습니다. 테스트도 다 통과했는데, 환경이 바뀌니까 안 되더군요.


실제 코드

@Service
public class UserService {

    @Transactional(readOnly = true)  // 읽기 전용
    public void updateUserLastLogin(Long userId) {
        User user = userRepository.findById(userId).orElseThrow();
        user.updateLastLogin(LocalDateTime.now());  // 쓰기 작업!
        userRepository.save(user);
    }
}

MySQL: 작동함 (하지만 최적화 안 됨)
PostgreSQL: PSQLException: ERROR: cannot execute UPDATE in a read-only transaction


왜 readOnly를 쓰는가

graph LR
    A[readOnly=true] --> B[변경 감지 OFF]
    A --> C[플러시 모드 MANUAL]
    A --> D[DB 힌트 전달]
    B --> E[성능 개선]
    C --> E
    D --> E

    style A fill:#fff3cd
    style E fill:#d4edda

readOnly=true를 설정하면 (Hibernate 기준):

  • 플러시 모드가 MANUAL로 변경 → 자동 flush를 하지 않아 업데이트가 안 나감
  • Dirty Checking을 완전히 끄기보다, flush를 안 해서 자연스럽게 쓰기 SQL이 안 나감
  • Connection.setReadOnly(true) 혹은 SET TRANSACTION READ ONLY 호출 → 처리 방식은 드라이버/DB 설정에 의존
  • Replication 환경에서는 Routing DataSource와 조합해 “읽기 전용 → Replica”로 라우팅하는 힌트로 활용 가능

읽기 전용 메서드에서는 확실히 성능 이점이 있지만, 동작이 DB/드라이버 설정에 따라 달라질 수 있음을 명시해 두는 편이 안전합니다.


해결 방법

💡읽기와 쓰기를 명확히 분리하세요
@Service
public class UserService {

    // 읽기 전용
    @Transactional(readOnly = true)
    public User getUser(Long userId) {
        return userRepository.findById(userId).orElseThrow();
    }

    // 쓰기 작업
    @Transactional
    public void updateUserLastLogin(Long userId) {
        User user = userRepository.findById(userId).orElseThrow();
        user.updateLastLogin(LocalDateTime.now());
        userRepository.save(user);
    }
}

: 대부분의 메서드는 조회가 많으니까 @Transactional(readOnly = true)로 시작하고, 쓰기가 필요한 메서드만 @Transactional로 변경하세요. 실수로 쓰기 작업을 하면 PostgreSQL에서는 바로 예외가 터지니까 버그를 조기에 발견할 수 있습니다.


핵심 요약

  • readOnly=true에서 쓰기 작업은 DB마다 동작이 다름
  • PostgreSQL은 예외 발생, MySQL은 경고만 남김
  • 읽기와 쓰기 메서드를 명확히 분리할 것




실수 5: 트랜잭션 전파(Propagation) 미이해

한눈에 요약

  • 기본 REQUIRED는 기존 트랜잭션 합류 → 예외 시 전체 롤백 마크
  • REQUIRES_NEW는 새 커넥션/트랜잭션을 잡으므로 고트래픽 남용 시 풀 고갈 위험
  • 알림/로그는 분리하되, 빈도가 높다면 Outbox + 비동기 소비를 고려

문제 상황

트랜잭션 전파는 정말 헷갈립니다. 저도 4년 차까지 제대로 이해하지 못했습니다.

가장 흔한 실수는 내부 메서드에서 예외가 발생했는데 외부 트랜잭션까지 롤백되는 경우입니다.


실제 코드

@Service
public class OrderService {

    private final NotificationService notificationService;

    @Transactional
    public void processOrder(Long orderId) {
        Order order = orderRepository.findById(orderId).orElseThrow();
        order.updateStatus(OrderStatus.COMPLETED);
        orderRepository.save(order);  // 1. 주문 완료 처리

        try {
            notificationService.sendNotification(order);  // 2. 알림 발송
        } catch (Exception e) {
            log.error("알림 발송 실패", e);  // 알림 실패해도 주문은 완료되어야 함
        }
    }
}

@Service
public class NotificationService {

    @Transactional  // 기본값: REQUIRED
    public void sendNotification(Order order) {
        // 알림 발송 중 예외 발생
        throw new RuntimeException("알림 서버 장애");
    }
}

예상: 알림 발송 실패해도 주문은 완료됨
현실: 주문도 롤백됨

“분명 예외를 catch했는데 왜 롤백되지?” 하고 한참 삽질했던 기억이 납니다.


왜 이런 일이 발생하나

기본 전파 속성인 REQUIRED기존 트랜잭션이 있으면 참여합니다. 즉, sendNotification은 새로운 트랜잭션을 만들지 않고 processOrder의 트랜잭션에 합류합니다.

그리고 Spring은 참여 중인 트랜잭션에서 예외가 발생하면 트랜잭션 전체에 “롤백 마크”를 찍어버립니다. 나중에 예외를 catch하더라도 이미 롤백 마크가 찍혔기 때문에 커밋할 수 없습니다.

graph TB
    A[processOrder 시작] --> B[트랜잭션 1 시작]
    B --> C[주문 완료 처리]
    C --> D[sendNotification 호출]
    D --> E{전파 속성}
    E -->|REQUIRED 기본값| F[트랜잭션 1에 합류]
    F --> G[예외 발생]
    G --> H[롤백 마크 찍힘]
    H --> I[catch로 잡아도 이미 늦음]
    I --> J[트랜잭션 1 전체 롤백]

    E -->|REQUIRES_NEW| K[트랜잭션 2 새로 시작]
    K --> L[예외 발생]
    L --> M[트랜잭션 2만 롤백]
    M --> N[트랜잭션 1은 커밋]

    style J fill:#f8d7da
    style N fill:#d4edda

해결 방법

REQUIRES_NEW로 독립된 트랜잭션 사용
@Service
public class NotificationService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void sendNotification(Order order) {
        // 새로운 트랜잭션에서 실행
        // 여기서 예외가 발생해도 외부 트랜잭션은 영향 없음
        throw new RuntimeException("알림 서버 장애");
    }
}

주의: REQUIRES_NEW는 트랜잭션을 완전히 분리합니다. 외부 트랜잭션이 롤백되어도 내부 트랜잭션은 이미 커밋된 상태입니다. 의도한 동작인지 확인하세요.


주요 전파 속성 비교

전파 속성설명사용 케이스
REQUIRED (기본값)기존 트랜잭션에 참여, 없으면 새로 생성대부분의 경우
REQUIRES_NEW항상 새로운 트랜잭션 생성독립적인 작업 (알림, 로그, 감사 기록)
SUPPORTS트랜잭션이 있으면 참여, 없어도 실행읽기 전용 메서드
NOT_SUPPORTED트랜잭션 없이 실행트랜잭션 불필요한 외부 API 호출
NEVER트랜잭션이 있으면 예외 발생트랜잭션 금지 영역

저희 팀에서는 알림, 로그, 이벤트 발행 등 실패해도 메인 비즈니스 로직에 영향을 주지 않아야 하는 작업에 REQUIRES_NEW를 사용합니다. 단, REQUIRES_NEW커넥션을 하나 더 잡아 물리 트랜잭션을 새로 여므로, 고트래픽에서 남용하면 커넥션 풀 고갈/데드락 위험이 있습니다. 알림·로그가 많아지면 Outbox 패턴 + 비동기 컨슈머로 분리하는 것도 고려합니다.


핵심 요약

  • REQUIRED(기본값)는 기존 트랜잭션에 합류
  • 합류한 트랜잭션에서 예외 발생 시 전체 롤백 마크가 찍힘
  • 독립적인 작업은 REQUIRES_NEW로 분리할 것




내 프로젝트에 바로 적용하기

체크리스트

실수 방지를 위해 아래 체크리스트를 코드 리뷰에 활용하세요:

  • 도메인/비즈니스 예외는 RuntimeException 계층으로 통일했는가? (IO/SQL 등 Checked는 Service에서 래핑)
  • @Transactional이 붙은 메서드가 public이고 self-invocation을 피하도록 설계했는가?
  • try-catch로 예외를 잡은 후 다시 던지거나 롤백 마크를 찍는가?
  • 읽기 전용 메서드에서 엔티티를 mutate/save 하지 않는가?
  • 독립적인 작업(알림, 로그)에 REQUIRES_NEW를 사용하되, 남용을 피하고 있는가?

주의사항

🚨팀 컨벤션상 피해야 할 것

비즈니스 예외를 Checked로 정의

  • 매번 rollbackFor 설정 필요
  • 인프라 Checked(IO/SQL 등)는 Service에서 잡아 BusinessException(Runtime)으로 래핑

Private/self-invocation에 @Transactional

  • 프록시를 거치지 않아 작동하지 않음
  • 컴파일 에러도 없어서 발견하기 어려움

예외 삼키기

try {
    // ...
} catch (Exception e) {
    log.error("에러", e);  // 예외 삼킴 -> 롤백 안 됨
}

readOnly 메서드에서 엔티티 저장/변경

  • DB마다 동작이 다름 (PostgreSQL 예외, MySQL 경고)
  • 환경 차이로 버그 가능

추천 설정

저희 팀의 표준 설정

1. 커스텀 예외 계층

// 공통 예외 추상 클래스 - 반드시 RuntimeException 상속
public abstract class BusinessException extends RuntimeException {
    private final ErrorCode errorCode;

    protected BusinessException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
    }
}

// 도메인별 예외
public class OrderException extends BusinessException {
    public OrderException(ErrorCode errorCode) {
        super(errorCode);
    }
}

2. Service 클래스 기본 템플릿

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;

    // 조회는 readOnly=true
    @Transactional(readOnly = true)
    public Order getOrder(Long orderId) {
        return orderRepository.findById(orderId)
            .orElseThrow(() -> new OrderException(ErrorCode.ORDER_NOT_FOUND));
    }

    // 변경은 기본 @Transactional
    @Transactional
    public void updateOrder(Long orderId, OrderUpdateDto dto) {
        Order order = getOrder(orderId);
        order.update(dto);
    }
}

3. 글로벌 예외 핸들러

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusinessException(
            BusinessException e) {
        ErrorResponse response = ErrorResponse.of(e.getErrorCode());
        return ResponseEntity
            .status(e.getErrorCode().getStatus())
            .body(response);
    }
}

트러블슈팅

Q. “롤백이 안 돼요”

  1. 예외 타입 확인 (Checked Exception인가?)
  2. try-catch로 예외를 삼키고 있지 않은가?
  3. 메서드가 public인가?
  4. 프록시를 거치는가? (Self-Invocation 아닌가?)

Q. “로컬에서는 되는데 프로덕션에서 안 돼요”

  • DB 종류 확인 (MySQL vs PostgreSQL)
  • readOnly=true에서 쓰기 작업하고 있지 않은가?

Q. “부분 커밋 이슈가 발생해요”

  • 트랜잭션 전파 속성 확인
  • 독립적인 작업은 REQUIRES_NEW 사용




이 접근의 아쉬운 점 (압축 요약)

  • 프록시 제약: public + 프록시 경유 호출만 적용, self-invocation/this 호출/ private은 미적용. 경고 없이 조용히 실패.

  • AspectJ 선택 안 함: 비-public/내부호출까지 커버 가능하지만 빌드 복잡·학습 비용 때문에 기본 Proxy 모드 유지.

  • Checked Exception 논쟁: Spring은 “Recoverable vs Bug” 철학을 따른다. 우리 팀은 커스텀 예외를 Runtime 계층으로 통일, 인프라 Checked는 래핑.

  • 프레임워크 종속성: @Transactional은 Spring 전용. 다른 스택으론 패턴/설계 자체를 다시 봐야 한다.

  • Jakarta EE의 @Transactional과는 다름 (동작 방식 차이)

  • Quarkus, Micronaut는 다른 방식

  • JPA의 EntityManager로 직접 관리도 가능

만약 Spring을 떠난다면?
모든 트랜잭션 코드를 다시 작성해야 합니다.

우리 선택:
Spring 생태계를 계속 사용할 예정이므로, 종속성은 감수합니다.


5. 러닝 커브

시니어도 헷갈리는 이유:

  • 전파 속성 7가지 (REQUIRED, REQUIRES_NEW, NESTED 등)
  • 격리 수준 4가지 (READ_UNCOMMITTED, READ_COMMITTED 등)
  • 프록시 방식의 내부 동작 이해 필요

주니어 입장:
@Transactional 붙였는데 왜 롤백 안 돼요?” (3개월마다 반복)

해결책:
팀 컨벤션 + 코드 리뷰 체크리스트로 학습 곡선을 줄입니다.


6. 결국 “충분히 좋은 해결”을 선택했다

@Transactional은 완벽하지 않습니다. 하지만:

  • ✅ 한 줄로 트랜잭션 적용 (생산성 ↑)
  • ✅ Spring 생태계와 완벽 통합
  • ✅ 팀 컨벤션으로 함정 회피 가능
  • ✅ 99% 케이스에서 충분

만약 극단적 성능 최적화가 필요하다면?
그때는 JDBC Template이나 직접 EntityManager를 사용해야 할 겁니다.

지금은 이 정도로 충분합니다.




시스템 점검 체크리스트

저도 코드 리뷰할 때 이 항목들을 꼭 확인합니다. @Transactional을 사용한다면 참고하시면 좋을 것 같습니다.

  • 커스텀 예외는 RuntimeException: 모든 비즈니스 예외가 RuntimeException을 상속했는가?
  • Public 메서드만 @Transactional: Private 메서드에 실수로 붙이지 않았는가?
  • 예외 삼키기 금지: try-catch로 예외를 잡았다면 throw 또는 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() 호출했는가?
  • readOnly 전략: 조회 메서드에는 readOnly = true를 설정했는가?
  • 전파 속성 검증: 알림/로그 메서드에 REQUIRES_NEW를 적용했는가?



마무리

@Transactional은 한 줄로 트랜잭션을 적용할 수 있어서 편리합니다. 하지만 그 편리함 뒤에 꽤 많은 함정이 숨어 있습니다.

솔직히 말하면, 저도 이 다섯 가지 실수를 모두 겪어봤습니다. Checked Exception 때문에 입사 첫 달에 낭패를 봤고, Private 메서드 문제는 2년 차 때 알았고, 전파 속성은 4년 차 때까지 헷갈렸습니다.

그래서 느낀 건, 이건 개인의 실력 문제가 아니라 팀 전체가 동일한 규칙을 따르는 게 중요하다는 거죠.

저희 팀은 이렇게 합의했습니다:

  1. 커스텀 예외는 무조건 RuntimeException
  2. 트랜잭션 메서드는 무조건 public
  3. 예외 삼키기 금지 (코드 리뷰 체크리스트)
  4. readOnly 먼저, 쓰기 필요하면 변경
  5. 알림/로그는 REQUIRES_NEW

이걸 팀 컨벤션으로 정하고 코드 리뷰 체크리스트에 넣으니까, 트랜잭션 관련 버그가 거의 사라졌습니다. 물론 처음엔 귀찮았지만, 한 번 실수로 데이터 정합성이 깨지면 복구하는 데 며칠이 걸리거든요. 그거 생각하면 체크리스트 몇 개 확인하는 건 아무것도 아닙니다.

다음 글에서는 @Transactional의 고급 기능인 격리 수준(Isolation Level)성능 최적화를 다뤄보겠습니다.





참고 :

Spring Framework Transaction Docs
Effective Java 3rd Edition - Item 70: Use checked exceptions for recoverable conditions
Baeldung - Transaction Propagation




읽어주셔서 감사합니다.🖐


Ramsbaby
Written byRamsbaby
이 블로그는 직접 개발/운영하는 블로그이므로 당신을 불쾌하게 만드는 불필요한 광고가 없습니다.

#My Github#소개 페이지#Blog OpenSource Github#Blog OpenSource Demo Site