데일리 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, 커밋 후 영구 저장)입니다.
@Transactional 동작 원리
AOP 프록시 · self-invocation · readOnly 최적화
CGLIB 프록시가 어떻게 트랜잭션을 끼워넣는지, 왜 같은 클래스 호출이 위험한지, readOnly가 왜 빠른지 — 내부 원리부터 실무 패턴까지
01 @Transactional이 동작하는 방식 — CGLIB 프록시
@Transactional은 마법이 아닙니다. Spring이 해당 빈을 CGLIB 프록시 객체로 감싸서 컨테이너에 등록하고, 실제 메서드 호출 앞뒤에 트랜잭션 시작·커밋·롤백 로직을 끼워넣는 방식으로 동작합니다. 이것이 AOP(Aspect Oriented Programming)의 핵심 메커니즘입니다.
호출자
① BEGIN TRANSACTION
비즈니스 로직
② 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;
}
}
}JavaSpring은 두 가지 방식으로 프록시를 만듭니다. JDK Dynamic Proxy는 인터페이스 기반으로 동작하고, CGLIB는 바이트코드를 조작해 클래스를 상속하는 방식입니다. Spring Boot 2.x 이후 기본값은 CGLIB입니다. 인터페이스가 없어도 프록시 생성이 가능하고, 성능도 더 우수하기 때문입니다.
02 프록시 생성 과정 — 스프링 컨테이너 내부
Spring 컨테이너가 빈을 등록할 때 @Transactional이 붙은 빈을 어떻게 처리하는지 단계별로 살펴봅니다.
@Service, @Component 등을 스캔해 빈 정의(BeanDefinition)를 ApplicationContext에 등록합니다.
InfrastructureAdvisorAutoProxyCreator가 빈 초기화 후 후처리기로 개입합니다. 해당 빈의 메서드에 @Transactional이 존재하는지 확인합니다.
@Transactional이 감지되면 CGLIB가 원본 클래스를 상속하는 프록시 클래스를 런타임에 바이트코드로 생성합니다. 컨테이너에는 원본이 아닌 프록시 객체가 등록됩니다.
다른 빈이 @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$$0JavaCGLIB는 클래스를 상속해서 프록시를 만들기 때문에,
final 클래스나 final 메서드는 오버라이딩이 불가능해 프록시 생성에 실패합니다. Kotlin 클래스는 기본이 final이라 open 키워드를 붙이거나 allopen 플러그인을 사용해야 합니다.CGLIB 프록시는 메서드를 오버라이딩해서 트랜잭션 로직을 끼워넣습니다.
private 메서드는 오버라이딩이 불가능하므로, 붙여도 트랜잭션이 적용되지 않습니다. 반드시 public 메서드에만 사용하세요.03 self-invocation 문제
가장 많이 실수하는 부분입니다. 같은 클래스 내에서 @Transactional 메서드를 this.메서드()로 직접 호출하면 프록시를 거치지 않으므로 트랜잭션이 적용되지 않습니다.
왜 발생하는가
TX 시작
실제 객체
TX 커밋
TX 시작
실제 객체
프록시 우회! TX 없음
@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- 책임 분리 측면에서도 좋음
- 순환 참조 없음
- 코드 가독성 우수
- 분리 없이 해결 가능
- 순환 참조 발생 가능
- 코드 가독성 떨어짐
- 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트랜잭션 프록시는 메서드 밖으로 예외가 전파될 때 롤백합니다. 메서드 내부에서 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();
}
}Java05 readOnly 최적화 — 왜 빠른가
@Transactional(readOnly = true)는 단순히 "읽기 전용이에요" 표시가 아닙니다. 내부적으로 세 가지 실질적인 최적화가 일어납니다.
변경 감지를 위한 스냅샷 비교 연산 자체를 수행하지 않습니다. 엔티티가 많을수록 효과가 큽니다.
DB 드라이버에 read-only 힌트를 보내면, DB가 내부적으로 불필요한 락을 줄이거나 리드 레플리카로 라우팅할 수 있습니다.
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 안 함JavaRead Replica 라우팅 패턴
읽기 전용 트랜잭션을 읽기 전용 DB(Secondary/Replica)로 자동 라우팅하면 Master DB 부하를 줄일 수 있습니다. AbstractRoutingDataSource와 LazyConnectionDataSourceProxy를 조합해 구현합니다.
// 트랜잭션의 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를 붙이세요. 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의 내부 동작 원리를 설명하세요.
@Transactional이 붙은 빈을 CGLIB 프록시로 감싸서 컨테이너에 등록합니다. CGLIB는 바이트코드 조작을 통해 원본 클래스를 상속하는 프록시 클래스를 생성하고, 메서드 호출 전후에 TransactionManager를 통해 트랜잭션 시작·커밋·롤백 로직을 끼워 넣습니다. 다른 빈에서 의존성 주입 시 프록시 객체가 주입되므로 외부 호출은 항상 프록시를 통하게 됩니다.Q2. @Transactional이 동작하지 않는 경우를 설명하세요.
this.method()로 실행되어 프록시를 우회합니다. 둘째, private 메서드 — CGLIB는 상속 기반이므로 private 메서드는 오버라이딩 불가합니다. 셋째, 체크 예외 — 기본 롤백 대상이 RuntimeException이므로 체크 예외는 rollbackFor를 별도 지정해야 합니다.Q3. self-invocation 문제를 어떻게 해결하나요?
@Autowired private OrderService self처럼 자기 자신을 빈으로 주입받아 호출하는 방법이 있지만, 순환 참조와 가독성 문제가 있어 선호하지 않습니다.Q4. readOnly = true가 성능에 어떤 영향을 주나요?
MANUAL로 변경되어 dirty checking(변경 감지) 자체를 수행하지 않습니다. 엔티티 스냅샷 생성과 비교 연산이 사라져 메모리와 CPU를 절약합니다. 둘째, DB 드라이버에 read-only 힌트를 전달해 DB가 불필요한 락을 줄일 수 있습니다. 셋째, LazyConnectionDataSourceProxy와 함께 사용하면 read replica 라우팅이 가능합니다.Q5. 예외를 catch했는데 롤백이 안 되는 이유는?
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 재정의
'스프링' 카테고리의 다른 글
| 예외 처리 전략 @ControllerAdvice · @ExceptionHandler · 공통 에러 응답 설계 (1) | 2026.04.20 |
|---|---|
| JPQL & QueryDSL객체지향 쿼리부터 동적 쿼리까지 (1) | 2026.04.17 |
| @Transactional 동작 원리AOP 프록시 · self-invocation · readOnly 최적화 (0) | 2026.04.15 |
| JPA N+1 문제발생 원인과 3가지 해결 전략 (0) | 2026.04.13 |
| 트랜잭션 전파(Propagation) &격리 수준(Isolation Level) (0) | 2026.04.10 |