Spring Boot 3.x에서 @Transactional이 프록시로 동작하는 방식, readOnly의 실제 효과, 전파 옵션(Propagation) 선택 기준과 현업에서 자주 하는 실수를 코드로 정리합니다.
도입 (문제 상황)
서비스 메서드에 @Transactional을 붙였는데도 “왜 롤백이 안 되지?” 혹은 “readOnly로 했는데도 업데이트 쿼리가 나가네?” 같은 상황을 한 번쯤 겪으셨을 거예요. 특히 계층형 구조(Controller/Service/Repository)를 잘 나눠도, 트랜잭션 경계가 어긋나면 데이터 정합성이 쉽게 깨집니다.
이번 글에서는 @Transactional이 **어떻게 동작하는지(프록시)**부터 readOnly의 진짜 의미, 전파 옵션 선택, 자주 하는 실수를 실무 관점으로 정리해 봅니다.
핵심 개념: Spring Boot @Transactional이 “왜” 중요한가
@Transactional은 단순히 “DB 작업을 묶는 애노테이션”이 아니라, **데이터 변경의 원자성(Atomicity)**과 **일관성(Consistency)**을 지키는 경계선입니다. 이 경계가 애매하면 다음 문제가 자주 생깁니다.
- 일부만 커밋되고 일부는 실패(부분 성공) → 장애 대응이 매우 어려움
- 읽기 API가 의도치 않게 쓰기 락/플러시를 유발 → 성능 저하
- 트랜잭션이 중첩되면서 롤백 규칙이 꼬임 → “왜 롤백 안 됨?” 이슈
1) 프록시 기반 동작: “호출 경로”가 핵심입니다
Spring의 선언적 트랜잭션은 기본적으로 AOP 프록시로 구현됩니다. 즉, 빈을 감싼 프록시가 메서드 호출 전후로 트랜잭션을 시작/커밋/롤백합니다.
sequenceDiagram
participant C as "Controller"
participant P as "Service Proxy"
participant S as "Service Target"
participant TM as "Transaction Manager"
participant R as "Repository"
C->>P: "call service()"
P->>TM: "begin"
P->>S: "invoke"
S->>R: "DB access"
P->>TM: "commit/rollback"

프록시가 “가로채서” 트랜잭션을 열고 닫는 흐름입니다.
여기서 중요한 포인트는 프록시를 거쳐야만 @Transactional이 적용된다는 점입니다. 그래서 아래가 대표적인 함정입니다.
- 같은 클래스 내부에서
this.someTxMethod()로 호출(= self-invocation)하면 프록시를 우회 → 트랜잭션이 안 걸릴 수 있음 private메서드에 붙여도 보통 의미 없음(프록시가 가로채기 어려움)- Bean으로 관리되지 않는 객체에 붙여도 적용되지 않음
2) readOnly=true: “쓰기 금지”가 아니라 “쓰기 최적화 힌트”에 가깝습니다
@Transactional(readOnly = true)는 DB에 “절대 쓰지 마”를 강제하는 만능 스위치가 아닙니다. 보통 다음 효과를 기대할 수 있습니다.
- (JPA/Hibernate) Flush 모드 조정 등으로 불필요한 변경 감지를 줄여 성능에 도움
- DB 드라이버/DB 설정에 따라 read-only 트랜잭션 최적화가 적용될 수도 있음(항상 보장되진 않음)
하지만 다음은 오해입니다.
- readOnly를 붙이면 UPDATE/INSERT가 무조건 막힌다 → 아닙니다. (환경/구현체에 따라 다르고, 애플리케이션 레벨에서 완전 차단을 보장하지 않습니다)
- readOnly면 트랜잭션이 없는 것과 같다 → 아닙니다. 트랜잭션은 열립니다. (일관된 읽기, Lazy 로딩 등에서 의미가 큼)
정리하면 readOnly는 “안전장치”라기보다 “성능과 의도를 표현하는 설정”에 가깝습니다. 정말 쓰기를 막고 싶다면 DB 권한/분리(읽기 전용 계정, 리드 레플리카) 같은 운영 설계가 필요합니다.
3) 전파(Propagation): “이미 트랜잭션이 있을 때” 어떻게 할지 결정합니다
전파 옵션은 “현재 호출이 기존 트랜잭션 안에서 실행되는가?”를 다룹니다. 실무에서 자주 쓰는 옵션만 비교하면 다음과 같습니다.
| 전파 옵션 | 기존 트랜잭션이 있으면 | 없으면 | 주 사용처 |
|---|---|---|---|
| REQUIRED(기본) | 기존에 참여 | 새로 생성 | 대부분의 서비스 메서드 |
| REQUIRES_NEW | 기존을 잠시 중단하고 새로 생성 | 새로 생성 | “로그는 반드시 남겨야 함” 같은 독립 커밋 |
| SUPPORTS | 있으면 참여 | 트랜잭션 없이 실행 | 읽기 API에서 선택적으로 |
| MANDATORY | 반드시 참여 | 예외 발생 | “무조건 트랜잭션 안”을 강제할 때 |
| NEVER | 트랜잭션이 있으면 예외 | 트랜잭션 없이 실행 | 트랜잭션을 절대 열면 안 되는 작업(드묾) |
특히 REQUIRES_NEW는 강력하지만 비용이 있습니다. 커넥션을 새로 확보하고(혹은 별도 트랜잭션을 열고) 기존 트랜잭션과 운명을 분리하므로, 남용하면 성능/데드락/일관성 측면에서 복잡해집니다.
4) 롤백 규칙: “어떤 예외에서 롤백되는가”
Spring의 기본 규칙은 다음입니다.
- RuntimeException / Error → 롤백
- Checked Exception → 커밋(기본)
Checked Exception에서도 롤백하려면 rollbackFor를 명시해야 합니다. 이 때문에 “예외는 났는데 커밋돼 버림” 이슈가 종종 발생합니다.
코드 예제: 전파/읽기전용/프록시 함정까지 한 번에 재현하기 (Spring Boot 3.x)
아래 예제는 H2 + Spring Data JPA로 구성했고, 실행 후 간단한 엔드포인트 호출로 다음을 확인할 수 있습니다.
readOnly=true에서 쓰기를 시도했을 때의 동작(환경에 따라 “막힘”이 보장되지 않음을 체감)REQUIRES_NEW로 감사 로그를 “독립 커밋”하는 패턴- 같은 클래스 내부 호출(self-invocation)로
@Transactional이 적용되지 않는 함정
build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '3.3.2'
id 'io.spring.dependency-management' version '1.1.6'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
repositories { mavenCentral() }
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'com.h2database:h2'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') { useJUnitPlatform() }
application.yml
spring:
datasource:
url: jdbc:h2:mem:txdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: create-drop
properties:
hibernate:
format_sql: true
h2:
console:
enabled: true
logging:
level:
org.hibernate.SQL: debug
org.hibernate.orm.jdbc.bind: trace
도메인/리포지토리
package com.example.tx;
import jakarta.persistence.*;
import java.time.Instant;
@Entity
public class Member {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String email;
protected Member() {}
public Member(String email) {
this.email = email;
}
public Long getId() { return id; }
public String getEmail() { return email; }
public void changeEmail(String email) { this.email = email; }
}
@Entity
public class AuditLog {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String message;
@Column(nullable = false)
private Instant createdAt = Instant.now();
protected AuditLog() {}
public AuditLog(String message) {
this.message = message;
}
public Long getId() { return id; }
public String getMessage() { return message; }
public Instant getCreatedAt() { return createdAt; }
}
package com.example.tx;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByEmail(String email);
}
public interface AuditLogRepository extends JpaRepository<AuditLog, Long> {
}
서비스: 전파/읽기전용/프록시 함정

package com.example.tx;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Service
public class MemberService {
private final MemberRepository memberRepository;
private final AuditService auditService;
public MemberService(MemberRepository memberRepository, AuditService auditService) {
this.memberRepository = memberRepository;
this.auditService = auditService;
}
// 기본 전파(REQUIRED): 대부분의 쓰기 로직은 이 패턴이 기본값입니다.
@Transactional
public Long register(String email) {
Member saved = memberRepository.save(new Member(email));
return saved.getId();
}
// readOnly는 "쓰기 금지"가 아니라 "읽기 최적화 힌트"에 가깝습니다.
@Transactional(readOnly = true)
public String findEmailAndTryToWrite(Long memberId, String newEmail) {
Member m = memberRepository.findById(memberId)
.orElseThrow(() -> new IllegalArgumentException("not found"));
// 의도적으로 수정 시도: 환경에 따라 flush 시점에 예외가 나거나,
// 또는 특정 설정에서는 UPDATE가 나갈 수도 있습니다(= readOnly가 강제 차단이 아님).
m.changeEmail(newEmail);
return m.getEmail();
}
// 전파 예시: 메인 로직은 실패해도 감사 로그는 남기고 싶은 경우
@Transactional
public void changeEmailWithAudit(Long memberId, String newEmail) {
Member m = memberRepository.findById(memberId)
.orElseThrow(() -> new IllegalArgumentException("not found"));
m.changeEmail(newEmail);
// 감사 로그는 독립 트랜잭션으로 커밋
auditService.writeRequiresNew("email change requested: memberId=" + memberId);
// 일부러 실패시켜 롤백 유도
throw new RuntimeException("boom - main tx rollback");
}
// 프록시 함정: 같은 클래스 내부 호출은 프록시를 우회할 수 있습니다.
public void outerCallsInnerDirectly(Long memberId, String newEmail) {
// 아래 호출은 this.innerTransactional(...)과 동일한 경로가 될 수 있어
// @Transactional이 기대대로 적용되지 않는 대표 케이스입니다.
innerTransactional(memberId, newEmail);
}
@Transactional
public void innerTransactional(Long memberId, String newEmail) {
Member m = memberRepository.findById(memberId)
.orElseThrow(() -> new IllegalArgumentException("not found"));
m.changeEmail(newEmail);
}
}
package com.example.tx;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Service
public class AuditService {
private final AuditLogRepository auditLogRepository;
public AuditService(AuditLogRepository auditLogRepository) {
this.auditLogRepository = auditLogRepository;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void writeRequiresNew(String message) {
auditLogRepository.save(new AuditLog(message));
}
}
컨트롤러: 호출용 엔드포인트
package com.example.tx;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/tx")
public class TxController {
private final MemberService memberService;
private final MemberRepository memberRepository;
private final AuditLogRepository auditLogRepository;
public TxController(MemberService memberService,
MemberRepository memberRepository,
AuditLogRepository auditLogRepository) {
this.memberService = memberService;
this.memberRepository = memberRepository;
this.auditLogRepository = auditLogRepository;
}
@PostMapping("/members")
public Long register(@RequestParam String email) {
return memberService.register(email);
}
@PostMapping("/readonly-write")
public String readOnlyWrite(@RequestParam Long memberId, @RequestParam String newEmail) {
return memberService.findEmailAndTryToWrite(memberId, newEmail);
}
@PostMapping("/requires-new")
public String requiresNew(@RequestParam Long memberId, @RequestParam String newEmail) {
try {
memberService.changeEmailWithAudit(memberId, newEmail);
return "ok";
} catch (Exception e) {
long auditCount = auditLogRepository.count();
String currentEmail = memberRepository.findById(memberId).map(Member::getEmail).orElse("n/a");
return "main rolled back, auditCount=" + auditCount + ", emailNow=" + currentEmail;
}
}
@PostMapping("/self-invocation")
public String selfInvocation(@RequestParam Long memberId, @RequestParam String newEmail) {
memberService.outerCallsInnerDirectly(memberId, newEmail);
return "called";
}
}
실행/확인 방법(간단)
- 회원 생성
POST /tx/members?email=a@a.com - REQUIRES_NEW 확인
POST /tx/requires-new?memberId=1&newEmail=b@b.com
→ 메인 변경은 롤백되어 이메일이 그대로일 수 있지만, 감사 로그는auditCount=1처럼 남는 것을 확인할 수 있습니다. - self-invocation 함정은 로그/디버깅으로 확인해 보세요.
/tx/self-invocation호출 시innerTransactional이 프록시를 거치지 않으면 트랜잭션 경계가 기대와 달라질 수 있습니다. (이 케이스는 “왜 트랜잭션이 안 열렸지?”를 재현할 때 유용합니다.)
실무 팁
💡 실무에서는
트랜잭션은 Service 계층에만 두는 규칙을 먼저 세우는 게 좋습니다. Controller에@Transactional을 붙이면 웹 요청 범위 전체가 트랜잭션이 되어, 불필요하게 길어지고(외부 API 호출/파일 처리까지 포함), 락 경합이나 커넥션 고갈로 이어지기 쉽습니다. Repository에는 보통 붙이지 않아도 됩니다(호출자가 트랜잭션을 열어주는 구조가 더 예측 가능해요).
💡 실무에서는
REQUIRES_NEW는 “감사 로그/알림 기록” 같은 독립 커밋이 반드시 필요한 곳에만 제한적으로 쓰는 게 안전합니다. 남용하면 트랜잭션이 쪼개져서 “메인 데이터는 롤백됐는데 부가 데이터는 남는” 상태가 늘어나고, 운영 중 정합성 이슈로 되돌아오는 경우가 많습니다. 정말 필요한지 먼저 질문해 보시고, 필요하다면 “왜 분리 커밋이 맞는가”를 코드 주석/문서로 남겨두는 편이 좋습니다.
핵심 요약
@Transactional은 프록시 기반이라 프록시를 거치는 호출 경로가 아니면 적용되지 않을 수 있습니다.readOnly=true는 대개 최적화 힌트이며 “쓰기 완전 차단”을 보장하지 않습니다.- 전파 옵션은 트랜잭션 중첩의 규칙이며,
REQUIRES_NEW는 강력하지만 정합성/운영 비용을 동반합니다.
다음 글: #15 Spring Data JPA 시작(Repository 인터페이스)