2023년 1월 1일
08:00 AM
Buffering ...

최근 글 👑

@Transactional 동작 원리 / AOP 프록시 · self-invocation · readOnly 최적화

2026. 4. 16. 10:37ㆍ스프링

데일리 CS 학습

더보기
  • Q. JPA란 무엇이고, JDBC와의 차이점은 무엇인가요?
  • A. JPA는 자바의 ORM 표준 인터페이스입니다. JDBC는 SQL을 직접 작성해야 하지만,  JPA는 객체 중심으로 데이터를 다루며 SQL을 자동 생성합니다. Hibernate는 JPA의 대표적인 구현체입니다.

  • ORM이란 무엇인가요?
  • 객체와 관계형 데이터베이스 테이블을 매핑해주는 기술입니다. 자바 객체 조작만으로 DB를 다룰 수 있어 생산성이 높아지지만, 복잡한 쿼리나 성능 튜닝 시에는 직접 SQL을 사용하기도 합니다.

  • Q. JPA 영속성 컨텍스트를 설명해주세요.
  • A. 엔티티를 영구 저장하는 환경으로 1차 캐시, 변경 감지, 지연 로딩 등의 기능을 제공합니다. 같은 트랜잭션 내에서 동일 엔티티 조회 시 DB가 아닌 1차 캐시에서 반환합니다.

  • Q. 즉시 로딩과 지연 로딩의 차이와 권장 방식을 설명해 주세요.
  • A. EAGER는 연관 엔티티를 즉시 함께 로딩하는 것으로 N + 1 문제를 유발할 수 있습니다. LAZY는 실제 접근 시점에 로딩합니다. 기본적으로 LAZY 로딩을 사용하고, 필요 시 JPQL의 fetch join으로 함께 조회하는 것을 권장합니다.

  • Q. N + 1 문제란 무엇이고 어떻게 해결하나요?
  • A. 1번의 쿼리 후 연고나 엔티티를 졸회하기 위해  N번의 추가 쿼리가 발생하는 문제입니다. Fetch Join, @EntityGraph, @BatchSize 로 해결합니다.
  • Q. 트랜잭션의 ACID 특성에는 어떤 것이 있나요?
  • A. 원자성(Atomicity 전부 성공 or 전부 실패), 일관성(Consistency, 트랜잭션 전후 DB 규칙 일관), 격리성(Isolation, 동시 트랜잭션이 서로 영향 못줌), 지속성(Durability, 커밋 후 영구 저장)입니다.

 

Today I Learned · 2026 · Spring Backend

@Transactional 동작 원리
AOP 프록시 · self-invocation · readOnly 최적화

CGLIB 프록시가 어떻게 트랜잭션을 끼워넣는지, 왜 같은 클래스 호출이 위험한지, readOnly가 왜 빠른지 — 내부 원리부터 실무 패턴까지

Java Spring Boot AOP Transaction JPA 성능최적화

01 @Transactional이 동작하는 방식 — CGLIB 프록시

@Transactional은 마법이 아닙니다. Spring이 해당 빈을 CGLIB 프록시 객체로 감싸서 컨테이너에 등록하고, 실제 메서드 호출 앞뒤에 트랜잭션 시작·커밋·롤백 로직을 끼워넣는 방식으로 동작합니다. 이것이 AOP(Aspect Oriented Programming)의 핵심 메커니즘입니다.

Controller
호출자
CGLIB 프록시
① BEGIN TRANSACTION
실제 Service
비즈니스 로직
CGLIB 프록시
② COMMIT / ROLLBACK
// 우리가 작성하는 원본 클래스 @Service public class OrderService { @Transactional public void placeOrder(OrderRequest request) { // 비즈니스 로직 } } // Spring이 내부적으로 생성하는 CGLIB 프록시 (개념적 표현) public class OrderService$$SpringCGLIB$$0 extends OrderService { @Override public void placeOrder(OrderRequest request) { TransactionStatus status = txManager.getTransaction(txDef); // BEGIN try { super.placeOrder(request); // 실제 비즈니스 로직 실행 txManager.commit(status); // COMMIT } catch (RuntimeException e) { txManager.rollback(status); // ROLLBACK throw e; } } }Java
CGLIB vs JDK Dynamic Proxy
Spring은 두 가지 방식으로 프록시를 만듭니다. JDK Dynamic Proxy는 인터페이스 기반으로 동작하고, CGLIB는 바이트코드를 조작해 클래스를 상속하는 방식입니다. Spring Boot 2.x 이후 기본값은 CGLIB입니다. 인터페이스가 없어도 프록시 생성이 가능하고, 성능도 더 우수하기 때문입니다.

02 프록시 생성 과정 — 스프링 컨테이너 내부

Spring 컨테이너가 빈을 등록할 때 @Transactional이 붙은 빈을 어떻게 처리하는지 단계별로 살펴봅니다.

1
컴포넌트 스캔 & 빈 등록

@Service, @Component 등을 스캔해 빈 정의(BeanDefinition)를 ApplicationContext에 등록합니다.

2
BeanPostProcessor 개입

InfrastructureAdvisorAutoProxyCreator가 빈 초기화 후 후처리기로 개입합니다. 해당 빈의 메서드에 @Transactional이 존재하는지 확인합니다.

3
CGLIB 프록시 생성

@Transactional이 감지되면 CGLIB가 원본 클래스를 상속하는 프록시 클래스를 런타임에 바이트코드로 생성합니다. 컨테이너에는 원본이 아닌 프록시 객체가 등록됩니다.

4
의존성 주입 시 프록시 제공

다른 빈이 @Autowired로 주입받으면, 원본 객체가 아닌 프록시 객체가 주입됩니다. 따라서 모든 외부 호출은 자동으로 프록시를 통하게 됩니다.

// 주입된 orderService는 실제로 프록시 객체 @RestController public class OrderController { @Autowired private OrderService orderService; // 실제: OrderService$$SpringCGLIB$$0 @PostMapping("/order") public void order(OrderRequest req) { orderService.placeOrder(req); // 프록시 메서드 호출 → 트랜잭션 시작 } } // 확인 방법: 실제 주입된 객체 타입 출력 System.out.println(orderService.getClass().getName()); // 출력: com.example.OrderService$$SpringCGLIB$$0Java
⚠ CGLIB의 제약 — final 클래스 / final 메서드
CGLIB는 클래스를 상속해서 프록시를 만들기 때문에, final 클래스나 final 메서드는 오버라이딩이 불가능해 프록시 생성에 실패합니다. Kotlin 클래스는 기본이 final이라 open 키워드를 붙이거나 allopen 플러그인을 사용해야 합니다.
⚠ private 메서드에는 @Transactional이 동작하지 않습니다
CGLIB 프록시는 메서드를 오버라이딩해서 트랜잭션 로직을 끼워넣습니다. private 메서드는 오버라이딩이 불가능하므로, 붙여도 트랜잭션이 적용되지 않습니다. 반드시 public 메서드에만 사용하세요.

03 self-invocation 문제

가장 많이 실수하는 부분입니다. 같은 클래스 내에서 @Transactional 메서드를 this.메서드()로 직접 호출하면 프록시를 거치지 않으므로 트랜잭션이 적용되지 않습니다.

왜 발생하는가

✅ 정상 — 외부에서 호출
Controller
Proxy
TX 시작
outer()
실제 객체
Proxy
TX 커밋
❌ self-invocation — 프록시 우회
Proxy
TX 시작
outer()
실제 객체
this.inner()
프록시 우회! TX 없음
inner()
@Transactional 무시
@Service public class OrderService { // ❌ self-invocation — inner()의 @Transactional 무시됨 public void outer() { inner(); // == this.inner() → 프록시 우회, 트랜잭션 없음 } @Transactional public void inner() { // 트랜잭션이 걸렸다고 착각하기 쉬움 — 실제로는 없음! } }Java

해결법 3가지

해결법 1 — 별도 클래스로 분리 (가장 권장)

// ✅ inner 로직을 별도 빈으로 분리 → 외부 호출이 되므로 프록시 정상 적용 @Service public class OrderInnerService { @Transactional public void inner() { // 별도 빈이므로 OrderService에서 주입받아 호출하면 프록시 거침 } } @Service @RequiredArgsConstructor public class OrderService { private final OrderInnerService orderInnerService; public void outer() { orderInnerService.inner(); // 외부 빈 호출 → 프록시 정상 통과 } }Java

해결법 2 — ApplicationContext에서 self 주입

// ✅ 자기 자신을 스프링 빈(프록시)으로 주입받아 호출 @Service public class OrderService { @Autowired private OrderService self; // 프록시 객체가 주입됨 (순환 참조 주의) public void outer() { self.inner(); // 프록시를 통한 호출 → 트랜잭션 적용됨 } @Transactional public void inner() { ... } }Java

해결법 3 — AopContext.currentProxy() 사용

// ✅ ThreadLocal에 저장된 현재 프록시 객체를 꺼내 호출 // @EnableAspectJAutoProxy(exposeProxy = true) 설정 필요 @Service public class OrderService { public void outer() { ((OrderService) AopContext.currentProxy()).inner(); } @Transactional public void inner() { ... } }Java
클래스 분리
가장 권장
  • 책임 분리 측면에서도 좋음
  • 순환 참조 없음
  • 코드 가독성 우수
self 주입
차선책
  • 분리 없이 해결 가능
  • 순환 참조 발생 가능
  • 코드 가독성 떨어짐
AopContext
비권장
  • Spring에 강하게 결합
  • 설정 추가 필요
  • 테스트하기 어려움

04 롤백 정책과 예외 처리

@Transactional의 기본 롤백 대상은 RuntimeException과 Error뿐입니다. 체크 예외는 롤백하지 않습니다.

예외 종류 예시 기본 동작
RuntimeException NullPointerException, IllegalArgumentException 🔴 롤백
Error OutOfMemoryError, StackOverflowError 🔴 롤백
체크 예외 Exception IOException, SQLException 🟢 커밋 (기본)
// ❌ IOException 발생해도 트랜잭션 롤백 안 됨 (기본 설정) @Transactional public void save() throws IOException { repository.save(entity); throw new IOException("파일 오류"); // 체크 예외 → 커밋됨! } // ✅ rollbackFor 명시 — 체크 예외도 롤백 @Transactional(rollbackFor = Exception.class) public void save() throws IOException { repository.save(entity); throw new IOException("파일 오류"); // → 롤백됨 } // ✅ noRollbackFor — 특정 RuntimeException은 롤백 제외 @Transactional(noRollbackFor = CustomException.class) public void process() { // CustomException이 발생해도 커밋 }Java
⚠ 예외를 catch하면 롤백이 안 됩니다
트랜잭션 프록시는 메서드 밖으로 예외가 전파될 때 롤백합니다. 메서드 내부에서 try-catch로 예외를 삼켜버리면 프록시는 정상 종료로 인식해 커밋합니다. 예외를 catch하면서도 롤백하려면 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()를 호출하거나, 잡은 예외를 다시 던져야 합니다.
// ❌ 예외를 삼키면 롤백 안 됨 @Transactional public void process() { try { riskyOperation(); } catch (RuntimeException e) { log.error("오류 발생", e); // 예외가 프록시까지 전파 안 됨 → 커밋! } } // ✅ 방법 1 — 예외를 다시 던지기 @Transactional public void process() { try { riskyOperation(); } catch (RuntimeException e) { log.error("오류 발생", e); throw e; // 다시 던져야 롤백됨 } } // ✅ 방법 2 — 수동 롤백 마킹 @Transactional public void process() { try { riskyOperation(); } catch (RuntimeException e) { log.error("오류 발생", e); TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); } }Java

05 readOnly 최적화 — 왜 빠른가

@Transactional(readOnly = true)는 단순히 "읽기 전용이에요" 표시가 아닙니다. 내부적으로 세 가지 실질적인 최적화가 일어납니다.

① Hibernate flush MANUAL
dirty checking 비활성화

변경 감지를 위한 스냅샷 비교 연산 자체를 수행하지 않습니다. 엔티티가 많을수록 효과가 큽니다.

② DB 드라이버 최적화
read-only 힌트 전달

DB 드라이버에 read-only 힌트를 보내면, DB가 내부적으로 불필요한 락을 줄이거나 리드 레플리카로 라우팅할 수 있습니다.

③ 커넥션 지연 획득
LazyConnectionDataSourceProxy

LazyConnectionDataSourceProxy와 함께 쓰면 실제 쿼리가 발생하기 전까지 DB 커넥션 획득을 미룹니다.

dirty checking 비용이 왜 큰가

// readOnly = false (기본값)일 때 Hibernate 내부 동작 // 1. 엔티티 조회 시 1차 캐시(EntityManager)에 원본 스냅샷을 복사해 저장 // 2. flush() 시점에 현재 상태와 스냅샷을 필드 하나하나 비교 // 3. 변경된 필드가 있으면 UPDATE SQL 생성 및 실행 // // 엔티티 1개 = 스냅샷 객체 1개 생성 + 필드 비교 N회 // 엔티티 1000개 조회 = 1000개 스냅샷 + 대규모 비교 연산 // readOnly = true일 때 // → 스냅샷 생성 자체를 안 함 → 메모리 절약 + 비교 연산 없음 // → flush 모드 MANUAL → 트랜잭션 종료 시 flush 안 함Java

Read Replica 라우팅 패턴

읽기 전용 트랜잭션을 읽기 전용 DB(Secondary/Replica)로 자동 라우팅하면 Master DB 부하를 줄일 수 있습니다. AbstractRoutingDataSourceLazyConnectionDataSourceProxy를 조합해 구현합니다.

// 트랜잭션의 readOnly 여부에 따라 DataSource 분기 public class RoutingDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { boolean isReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly(); return isReadOnly ? "replica" : "master"; // readOnly = true → replica DB로 라우팅 // readOnly = false → master DB로 라우팅 } } // 사용법 — 서비스 코드에서는 그냥 readOnly만 붙이면 됨 @Transactional(readOnly = true) // → replica DB로 자동 라우팅 public List<OrderDto> getOrders(Long userId) { ... } @Transactional // → master DB로 자동 라우팅 public void placeOrder(OrderRequest req) { ... }Java
✅ readOnly = true 사용 기준
조회만 하는 메서드라면 항상 readOnly = true를 붙이세요. dirty checking 비용 제거, 메모리 절약, DB 최적화 힌트 등 손해볼 게 없습니다. 특히 대량 조회 API일수록 효과가 명확합니다.

06 실무 패턴 — 클래스 레벨 + 메서드 레벨 조합

가장 많이 쓰이는 실무 패턴은 클래스 레벨에 readOnly = true를 걸고, 쓰기 메서드에만 @Transactional을 재정의하는 방식입니다. 조회가 대부분인 서비스에서 실수로 readOnly를 빠뜨리는 것을 방지할 수 있습니다.

@Service @Transactional(readOnly = true) // 클래스 전체 기본값: readOnly @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; // 조회 메서드 — 클래스 레벨 readOnly = true 상속 public List<OrderDto> getOrders(Long userId) { return orderRepository.findByUserId(userId).stream() .map(OrderDto::from) .toList(); } public OrderDto getOrder(Long orderId) { return OrderDto.from(orderRepository.findById(orderId).orElseThrow()); } // 쓰기 메서드 — @Transactional로 재정의 (readOnly = false) @Transactional public OrderDto placeOrder(OrderRequest request) { Order order = Order.create(request); return OrderDto.from(orderRepository.save(order)); } @Transactional public void cancelOrder(Long orderId) { Order order = orderRepository.findById(orderId).orElseThrow(); order.cancel(); } }Java
메서드 레벨이 클래스 레벨보다 우선순위가 높습니다
클래스에 @Transactional(readOnly = true)가 있고 메서드에 @Transactional이 있으면, 메서드 레벨 설정이 적용됩니다. 즉 readOnly = false(기본값)로 오버라이드됩니다.

@Transactional 주요 속성 한눈에 보기

속성 기본값 설명
propagation REQUIRED 기존 트랜잭션이 있으면 참여, 없으면 새로 생성
isolation DEFAULT DB 드라이버 기본 격리 수준 사용 (MySQL = REPEATABLE_READ)
readOnly false true 설정 시 dirty checking 비활성화, DB 힌트 전달
rollbackFor RuntimeException, Error 추가로 롤백할 예외 클래스 지정
noRollbackFor 특정 예외는 롤백하지 않도록 제외
timeout -1 (무제한) 트랜잭션 최대 허용 시간(초). 초과 시 자동 롤백

07 면접 예상 질문

Q1. @Transactional의 내부 동작 원리를 설명하세요.

Spring은 @Transactional이 붙은 빈을 CGLIB 프록시로 감싸서 컨테이너에 등록합니다. CGLIB는 바이트코드 조작을 통해 원본 클래스를 상속하는 프록시 클래스를 생성하고, 메서드 호출 전후에 TransactionManager를 통해 트랜잭션 시작·커밋·롤백 로직을 끼워 넣습니다. 다른 빈에서 의존성 주입 시 프록시 객체가 주입되므로 외부 호출은 항상 프록시를 통하게 됩니다.

Q2. @Transactional이 동작하지 않는 경우를 설명하세요.

세 가지 주요 케이스가 있습니다. 첫째, self-invocation — 같은 클래스 내 메서드를 직접 호출하면 this.method()로 실행되어 프록시를 우회합니다. 둘째, private 메서드 — CGLIB는 상속 기반이므로 private 메서드는 오버라이딩 불가합니다. 셋째, 체크 예외 — 기본 롤백 대상이 RuntimeException이므로 체크 예외는 rollbackFor를 별도 지정해야 합니다.

Q3. self-invocation 문제를 어떻게 해결하나요?

가장 권장하는 방법은 별도 클래스(빈)로 분리하는 것입니다. 로직을 다른 스프링 빈으로 이동하면 외부 호출이 되어 프록시를 정상적으로 통과합니다. 이 방법은 단일 책임 원칙(SRP)에도 부합합니다. 차선책으로는 @Autowired private OrderService self처럼 자기 자신을 빈으로 주입받아 호출하는 방법이 있지만, 순환 참조와 가독성 문제가 있어 선호하지 않습니다.

Q4. readOnly = true가 성능에 어떤 영향을 주나요?

세 가지 최적화가 일어납니다. 첫째, Hibernate의 flush 모드가 MANUAL로 변경되어 dirty checking(변경 감지) 자체를 수행하지 않습니다. 엔티티 스냅샷 생성과 비교 연산이 사라져 메모리와 CPU를 절약합니다. 둘째, DB 드라이버에 read-only 힌트를 전달해 DB가 불필요한 락을 줄일 수 있습니다. 셋째, LazyConnectionDataSourceProxy와 함께 사용하면 read replica 라우팅이 가능합니다.

Q5. 예외를 catch했는데 롤백이 안 되는 이유는?

트랜잭션 프록시는 메서드 밖으로 예외가 전파될 때 롤백합니다. 메서드 내부에서 try-catch로 예외를 잡으면 프록시에게 예외가 전달되지 않아 정상 종료로 인식하고 커밋합니다. 해결 방법은 두 가지입니다. 잡은 예외를 다시 던지거나(re-throw), 또는 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()를 호출해 트랜잭션에 직접 롤백 마크를 세웁니다.
핵심 요약
  • @Transactional = CGLIB 프록시 — Spring Boot 기본값. 원본 클래스를 상속해 메서드 전후에 트랜잭션 로직 삽입
  • private 메서드 ❌ — 상속 오버라이딩 불가로 프록시 생성 불가. 반드시 public 메서드에만 사용
  • self-invocation ❌ — this.method() 호출은 프록시 우회. 별도 빈으로 분리가 가장 깔끔한 해결책
  • 롤백 기본 대상 — RuntimeException, Error만 롤백. 체크 예외는 rollbackFor 명시 필요
  • readOnly = true — dirty checking 제거 + DB 힌트 + replica 라우팅 가능. 조회 메서드엔 항상 붙이기
  • 실무 권장 패턴 — 클래스 레벨 readOnly = true, 쓰기 메서드에만 @Transactional 재정의
오늘의 핵심 — @Transactional은 프록시다. 프록시를 우회하는 순간 트랜잭션은 없다. 조회엔 readOnly, 쓰기엔 명시적 @Transactional, self-invocation은 클래스 분리로 해결.
728x90