IT/Spring
@Transactional이란 무엇인가? | 롤백, readOnly, 주의점까지 실무 기준으로 이해하기
PARK_90
2026. 4. 12. 14:57
300x250
핵심 요약
이 글에서 바로 이해할 것
@Transactional이 무엇을 보장하고, 어디에 붙일 때 의미가 살아나는지 정리합니다.이 글에서 바로 해결할 것 롤백이 안 되는 이유,
readOnly = true를 붙여도 왜 수정이 되는 것처럼 보이는지 실무 관점으로 설명합니다.바로 확인할 설정 예외 타입, 프록시 호출 구조, 메서드 접근제한자, 트랜잭션 경계 위치를 체크합니다.
핵심 결론
@Transactional은 "붙이면 끝"이 아니라 어디서 시작되고 어떤 예외에서 끝나는지를 알아야 제대로 쓸 수 있습니다.728x90
한눈에 보는 개념 / 구조
Controller
↓
Service(@Transactional)
↓
Repository / JPA / JDBC
↓
정상 종료 → COMMIT
예외 발생 → ROLLBACK
쉽게 말하면 이렇게 이해하면 됩니다
주문 생성, 재고 차감, 결제 내역 저장이 한 번에 처리돼야 하는데 중간에 하나라도 실패하면 어떻게 해야 할까요?
이럴 때
이럴 때
@Transactional은 이 작업들을 하나의 묶음으로 보고, 중간에 문제가 생기면 전부 되돌리게 만듭니다.언제 붙이고, 어디에 붙여야 할까?
실무에서는 보통 Service 계층 메서드에 붙입니다. 이유는 트랜잭션의 기준이 "하나의 비즈니스 작업"이기 때문입니다.
예시로 보면 더 쉽습니다
@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 경계를 여기 두는 것이 자연스럽습니다.
롤백은 어떻게 동작할까?
많이 헷갈리는 부분이 바로 이것입니다. 예외가 발생했다고 무조건 롤백되는 것은 아닙니다.
실무에서 자주 나오는 실수
@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는 보통 읽기 전용 트랜잭션이라는 힌트를 주는 개념입니다.
@Transactional(readOnly = true)
public MemberDetailResponse getMemberDetail(Long memberId) {
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new IllegalArgumentException("회원이 없습니다."));
return MemberDetailResponse.from(member);
}
즉 조회 메서드에는 적극적으로 붙일 수 있지만, 읽기 전용이니 실수로 엔티티를 바꿔도 무조건 안전하다고 생각하면 안 됩니다.
비교 / 차이 정리
자주 막히는 포인트 / 문제해결
설정 / 확인 체크리스트
- 트랜잭션 경계를 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