데일리 CS 학습
- Q. MVC 패턴이란 무엇인가요?
- A. model(비즈니스 로직, 데이터),View(화면 표현), Controller(요청 처리, 흐름 제어)로 관심사를 분리하는 아키텍처 패턴입니다. 유지보수성과 테스트 용이성이 향상됩니다.
- Q. Spring MVC의 요청 처리 흐름을 설명해주세요.
- A. 클라이언트 요청 -> DispatcherServlet -> HandlerMapping(컨트롤러 탐색) -> HandlerAdapter(컨트롤러 실행) -> ViewResolver(뷰 결정) -> View 렌더링 -> 응답 순서로 처리됩니다.
- Q. REST API란 무엇이고 REST의 원칙을 설명해주세요.
- A. HTTP 기반으로 자원을 URI로 표현하, HTTP 메서드로 행위를 정의하는 아키텍처 스타일입니다. 무상태(Stateless), 클라이언트 - 서버 분리, 캐시 가능, 일관된 인터페이스가 핵심 원칙입니다.
- Q. @RestController와 @Controller의 차이는 무엇인가요?
- A. @Controller는 View를 반환하는 MVC 컨트롤러, @RestController는 @Controller + @ResponseBody의 조합으로 JSON/XML 등 데이터를 직접 응답 본문으로 반환합니다. REST API 개발 시 @RestController를 사용합니다.
- Q. @RequestParam과 @PathVariable의 차이를 설명해주세요.
- A. @PathVariable은 URL 경로의 일부를 변수로 받습니다. @RequestParam은 URL 쿼리 파라미터를 받습니다. RESTful 설계에서 리소스 식별에는 PathVariable, 검색/필터에서는 RequestParam을 씁니다.
- Q. DTO를 사용하는 이유는 무엇인가요?
- A. 엔티티를 직접 노출하면 민감한 정부 누출, 불필요한 데이터 전송, 순환 참조 등의 문제가 생깁니다. DTO로 필요한 데이터만 전송하면 API 계층과 도메인 계층을 분리할 수 있습니다.
- Q. 예외 처리를 위한 @ExceptionHandler와 @ControllerAdvice를 설명해주세요.
- A. @ExceptionHandler는 특정 컨트롤러 내의 예외를 처리합니다. @ControllerAdivce는 전역 예외 처리기로, 모든 컨트롤러에서 발생하는 예외를 한 곳에서 처리할 수 있습니다.
- Q. Spring에서 CORS(Cross-Origin Resource Shharing)는 어떻게 설정하나요?
- A. @CrossOrigin 어노테이션으로 컨트롤러나 메서드 단위 설정, 또는 WebMvcConfigurer의 addCorsMappings()로 전역 설정이 가능합니다. Security를 쓸 경우 Security 설정에서도 CORS를 허용해야 합니다.
@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;
}
}
}Java
Spring은 두 가지 방식으로 프록시를 만듭니다. 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$$0Java
CGLIB는 클래스를 상속해서 프록시를 만들기 때문에,
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();
}
}Java
05 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 안 함Java
Read 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 재정의
'스프링' 카테고리의 다른 글
| JPQL & QueryDSL객체지향 쿼리부터 동적 쿼리까지 (1) | 2026.04.17 |
|---|---|
| @Transactional 동작 원리 / AOP 프록시 · self-invocation · readOnly 최적화 (0) | 2026.04.16 |
| JPA N+1 문제발생 원인과 3가지 해결 전략 (0) | 2026.04.13 |
| 트랜잭션 전파(Propagation) &격리 수준(Isolation Level) (0) | 2026.04.10 |
| 스프링 인증/인가..! JWt..? (0) | 2024.09.10 |