티스토리 뷰

300x250
핵심 요약
이 글에서 바로 이해할 것 @Transactional이 무엇을 보장하고, 어디에 붙일 때 의미가 살아나는지 정리합니다.
이 글에서 바로 해결할 것 롤백이 안 되는 이유, readOnly = true를 붙여도 왜 수정이 되는 것처럼 보이는지 실무 관점으로 설명합니다.
바로 확인할 설정 예외 타입, 프록시 호출 구조, 메서드 접근제한자, 트랜잭션 경계 위치를 체크합니다.
핵심 결론 @Transactional은 "붙이면 끝"이 아니라 어디서 시작되고 어떤 예외에서 끝나는지를 알아야 제대로 쓸 수 있습니다.
728x90
한눈에 보는 개념 / 구조
항목 설명 실무 포인트
@Transactional 여러 DB 작업을 하나의 트랜잭션으로 묶어 성공하면 커밋하고, 실패하면 롤백하도록 만드는 Spring 선언형 트랜잭션 기능입니다. 서비스 계층에서 "어디까지를 하나의 작업 단위로 볼지"를 먼저 정한 뒤 붙여야 합니다.
롤백 기본 규칙 기본적으로 RuntimeException, Error에서 롤백합니다. 체크 예외는 기본 롤백 대상이 아닙니다. 비즈니스 예외가 체크 예외라면 rollbackFor를 명시하지 않으면 의도와 다르게 커밋될 수 있습니다.
readOnly = true 읽기 전용 트랜잭션 힌트를 줘서 flush, dirty checking, DB 최적화 가능성을 높이는 옵션입니다. "쓰기 차단 스위치"로 오해하면 안 됩니다. JPA/DB/드라이버 조합에 따라 동작 체감이 달라집니다.
동작 방식 Spring AOP 프록시가 메서드 진입 전 트랜잭션을 열고, 종료 시 커밋 또는 롤백합니다. 같은 클래스 내부에서 자기 메서드를 직접 호출하면 프록시를 우회해서 트랜잭션이 적용되지 않을 수 있습니다.
Controller
   ↓
Service(@Transactional)
   ↓
Repository / JPA / JDBC
   ↓
정상 종료 → COMMIT
예외 발생 → ROLLBACK
쉽게 말하면 이렇게 이해하면 됩니다
주문 생성, 재고 차감, 결제 내역 저장이 한 번에 처리돼야 하는데 중간에 하나라도 실패하면 어떻게 해야 할까요?
이럴 때 @Transactional은 이 작업들을 하나의 묶음으로 보고, 중간에 문제가 생기면 전부 되돌리게 만듭니다.
언제 붙이고, 어디에 붙여야 할까?

실무에서는 보통 Service 계층 메서드에 붙입니다. 이유는 트랜잭션의 기준이 "하나의 비즈니스 작업"이기 때문입니다.

위치 추천도 이유
Controller 보통 비추천 웹 요청 범위와 비즈니스 작업 범위가 섞여 경계가 애매해지기 쉽습니다.
Service 가장 일반적 주문, 회원가입, 결제 같은 비즈니스 단위를 묶기 좋습니다.
Repository 상황별 개별 DB 작업 보호에는 의미가 있지만, 여러 작업을 묶는 큰 경계로는 부족할 수 있습니다.
예시로 보면 더 쉽습니다
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final StockService stockService;
private final PaymentHistoryRepository paymentHistoryRepository;

@Transactional
public void placeOrder(OrderRequest request) {
    orderRepository.save(Order.create(request));
    stockService.decrease(request.productId(), request.quantity());
    paymentHistoryRepository.save(PaymentHistory.of(request));
}

}

위 메서드는 "주문 처리"라는 하나의 작업입니다. 셋 중 하나라도 실패하면 전체를 되돌리는 쪽이 맞기 때문에 @Transactional 경계를 여기 두는 것이 자연스럽습니다.

롤백은 어떻게 동작할까?

많이 헷갈리는 부분이 바로 이것입니다. 예외가 발생했다고 무조건 롤백되는 것은 아닙니다.

예외 타입 기본 롤백 여부 실무 해석
RuntimeException 기본 롤백 IllegalArgumentException, IllegalStateException 같은 예외는 기본적으로 롤백됩니다.
Error 기본 롤백 애플리케이션이 정상 복구하기 어려운 수준입니다.
Checked Exception 기본 미롤백 IOException, Exception 상속 계열은 별도 지정이 없으면 커밋될 수 있습니다.
실무에서 자주 나오는 실수
@Transactional
public void issueCoupon() throws Exception {
    couponRepository.save(coupon);
if (alreadyIssued) {
    throw new Exception("이미 발급된 쿠폰입니다.");
}

}

위 코드는 예외가 발생해도 체크 예외이기 때문에 기본 설정만으로는 롤백되지 않을 수 있습니다. 이럴 때는 아래처럼 명시해야 합니다.

@Transactional(rollbackFor = Exception.class)
public void issueCoupon() throws Exception {
    couponRepository.save(coupon);
if (alreadyIssued) {
    throw new Exception("이미 발급된 쿠폰입니다.");
}

}

readOnly = true는 정확히 무엇일까?

이 옵션도 오해가 많습니다. readOnly = true는 보통 읽기 전용 트랜잭션이라는 힌트를 주는 개념입니다.

항목 기대 효과 주의점
JPA flush 최소화 불필요한 변경 감지를 줄이는 데 도움이 될 수 있습니다. 구현체나 설정에 따라 체감이 다릅니다.
DB 최적화 힌트 일부 DB/드라이버는 읽기 전용 연결 힌트를 받을 수 있습니다. 모든 DB가 동일하게 강제 차단해주지는 않습니다.
의도 표현 "이 메서드는 조회 전용"이라는 팀 공통 신호가 됩니다. 그렇다고 코드상 쓰기를 절대 막아준다고 믿으면 위험합니다.
@Transactional(readOnly = true)
public MemberDetailResponse getMemberDetail(Long memberId) {
    Member member = memberRepository.findById(memberId)
        .orElseThrow(() -> new IllegalArgumentException("회원이 없습니다."));
return MemberDetailResponse.from(member);

}

즉 조회 메서드에는 적극적으로 붙일 수 있지만, 읽기 전용이니 실수로 엔티티를 바꿔도 무조건 안전하다고 생각하면 안 됩니다.

비교 / 차이 정리
비교 항목 일반 @Transactional @Transactional(readOnly = true)
주 용도 등록, 수정, 삭제, 상태 변경 목록 조회, 상세 조회, 검색
변경 감지 일반 동작 최적화 힌트가 적용될 수 있음
실수 방지 효과 쓰기 작업에 적합 의도 표현에는 좋지만, 물리적 쓰기 차단으로 믿으면 안 됨
자주 막히는 포인트 / 문제해결
문제 원인 확인 방법 해결
예외가 났는데 롤백이 안 됨 체크 예외이거나, 예외를 내부에서 잡고 삼킨 경우 throw 타입, catch 후 재던지는지 여부 확인 rollbackFor 지정 또는 예외 처리 구조 재정리
어노테이션을 붙였는데 효과가 없음 같은 클래스 내부 self-invocation으로 프록시 우회 this.someMethod() 구조인지 확인 트랜잭션 메서드를 별도 빈으로 분리
private 메서드에 붙였는데 동작 안 함 프록시 기반 AOP 특성상 외부 호출 대상이 아님 메서드 접근제한자 확인 public 메서드 기준으로 경계 재설계
readOnly = true인데 데이터가 바뀐 것 같음 읽기 전용이 강제 차단이 아니라 힌트이기 때문 flush 시점, 영속성 컨텍스트, DB 설정 확인 조회 메서드에서는 엔티티 변경 자체를 피하도록 코드 구조 개선
설정 / 확인 체크리스트
  • 트랜잭션 경계를 Controller가 아니라 Service에 두고 있는지 확인해요.
  • 롤백 대상 예외가 RuntimeException인지, 체크 예외라면 rollbackFor를 지정했는지 봐야 합니다.
  • catch로 예외를 잡아 로그만 남기고 끝내지 않았는지 확인해요.
  • 같은 클래스 내부 메서드 호출로 프록시를 우회하지 않았는지 봐야 합니다.
  • 조회 메서드는 readOnly = true로 의도를 분명히 하고, 수정 메서드는 일반 트랜잭션으로 분리해두면 관리가 쉬워집니다.
실무 예제로 정리해보면
@Service
@RequiredArgsConstructor
public class MemberPointService {
private final MemberRepository memberRepository;
private final PointHistoryRepository pointHistoryRepository;

@Transactional
public void charge(Long memberId, int amount) {
    Member member = memberRepository.findById(memberId)
        .orElseThrow(() -> new IllegalArgumentException("회원이 없습니다."));

    member.charge(amount);
    pointHistoryRepository.save(PointHistory.charge(memberId, amount));
}

@Transactional(readOnly = true)
public PointSummary getSummary(Long memberId) {
    Member member = memberRepository.findById(memberId)
        .orElseThrow(() -> new IllegalArgumentException("회원이 없습니다."));

    return PointSummary.from(member);
}

}

위처럼 변경 작업조회 작업을 메서드 수준에서 분리해두면, 코드 의도도 선명해지고 문제 추적도 쉬워집니다. 이것이 실무에서 가장 관리하기 편한 방식 중 하나입니다.

FAQ
  • Q. @Transactional은 클래스에 붙이는 게 좋을까요, 메서드에 붙이는 게 좋을까요?
    → 공통 정책이 분명하다면 클래스에 붙일 수 있지만, 실무에서는 메서드별로 조회/수정 의도가 다르기 때문에 메서드 단위가 더 명확한 경우가 많습니다.
  • Q. 예외를 try-catch로 잡으면 무조건 롤백이 안 되나요?
    → 내부에서 잡고 끝내면 정상 종료로 인식될 수 있습니다. 잡더라도 다시 던지거나, 상황에 맞게 롤백 정책을 별도로 설계해야 합니다.
  • Q. readOnly = true면 UPDATE가 아예 차단되나요?
    → 보통 그렇게 단정하면 안 됩니다. 구현체와 DB 설정에 따라 다르고, 대부분은 "조회 전용 힌트"로 이해하는 것이 안전합니다.
  • Q. Repository에도 트랜잭션이 있는데 왜 Service에 또 붙이나요?
    → 개별 쿼리 보호와 비즈니스 작업 경계는 다릅니다. 여러 저장/수정 작업을 하나로 묶으려면 Service 경계가 더 자연스럽습니다.
결론
  • @Transactional은 여러 DB 작업을 하나의 비즈니스 단위로 묶는 도구입니다.
  • 롤백은 기본적으로 RuntimeException 기준이므로, 체크 예외는 별도 설정이 필요할 수 있습니다.
  • readOnly = true는 조회 최적화와 의도 표현에 유용하지만, 쓰기 금지 장치로 과신하면 안 됩니다.
  • 가장 중요한 것은 어노테이션 자체보다 트랜잭션 경계와 예외 설계입니다.
실무에서 @Transactional은 "붙였다"보다 "어디에 왜 붙였는가"가 훨씬 더 중요합니다.
728x90
댓글