@Transactional 하나 붙였는데 롤백이 안 되고, @Async가 조용히 무시되고, 싱글톤에 넣은 prototype이 안 바뀌는 경험 — 대부분 스프링의 내부 동작(IoC·프록시·트랜잭션 전파)을 몰라서 생겨요. 이번 장은 "왜 그렇게 동작하는가"를 파고듭니다.
ObjectProvider로 해결하는 법을 알아요.@Transactional의 전파(REQUIRED/REQUIRES_NEW/NESTED)·격리·readOnly·롤백 규칙을 구분해요.@TransactionalEventListener(AFTER_COMMIT)로 커밋 후 처리를 안전하게 짤 수 있어요.스프링의 핵심은 제어의 역전(IoC)이에요. 객체 생성과 의존 관계 연결을 개발자가 new로 하지 않고 컨테이너에 맡깁니다. 그 컨테이너의 두 얼굴이 BeanFactory와 ApplicationContext예요.
| 구분 | BeanFactory | ApplicationContext |
|---|---|---|
| 위치 | 컨테이너의 최상위 인터페이스(기본 뼈대) | BeanFactory를 상속·확장한 상위 인터페이스 |
| 빈 생성 시점 | 지연(lazy) — 실제 요청(getBean) 시 생성 | 싱글톤 빈을 기동 시 미리(eager) 생성 |
| 부가 기능 | DI만 담당(최소 기능) | 국제화(i18n)·이벤트 발행·BeanPostProcessor 자동 등록·리소스 로딩 등 |
| 실무 | 직접 쓸 일 드묾 | 거의 항상 이걸 사용(스프링 부트 기본) |
ApplicationContext(정확히는 AnnotationConfigApplicationContext 등)를 쓰면 돼요.BeanCurrentlyInCreationException으로 바로 실패합니다(기본적으로 순환 참조 금지).
수정자(setter)·필드 주입이면 미완성 빈을 임시로 넣어 뚫리기도 하지만, 근본 해법은 설계를 고치는 것이에요(책임 분리, 이벤트로 분리, @Lazy는 임시방편). 순환 참조 자체가 강한 결합의 신호입니다.
빈은 생성 → 의존성 주입 → 초기화 콜백 → 사용 → 소멸 콜백의 순서로 살아요. 초기화·소멸 시점에 끼어드는 콜백이 세 가지 방식으로 제공돼요.
| 시점 | 방식 | 비고 |
|---|---|---|
| 초기화 | @PostConstruct | 권장(JSR-250 표준). 의존성 주입이 끝난 뒤 호출 |
InitializingBean.afterPropertiesSet() | 스프링 인터페이스 결합(요즘은 잘 안 씀) | |
| 소멸 | @PreDestroy | 권장. 컨테이너 종료 시 호출 |
DisposableBean.destroy() / @Bean(destroyMethod) | 인터페이스 방식 / 외부 라이브러리 정리용 |
@PostConstruct는 생성자가 아니에요. 생성자 실행 시점엔 의존성이 아직 주입되지 않을 수 있어요(특히 필드·수정자 주입). 주입된 의존성을 이용한 초기화 로직은 반드시 의존성 주입 완료 후인 @PostConstruct에서 해야 안전합니다.스코프는 빈이 몇 개 존재하고 얼마나 사는지를 정해요.
| 스코프 | 의미 |
|---|---|
| singleton ⭐ | 기본값. 컨테이너당 단 하나의 인스턴스를 공유 |
| prototype | 요청(주입·getBean)마다 새 인스턴스. 스프링은 생성·주입까지만 관리하고 소멸 콜백은 호출 안 함 |
| request / session | 웹 스코프. HTTP 요청/세션 단위 인스턴스 |
ObjectProvider<T>(스프링), javax/jakarta Provider<T>(JSR-330), 또는 @Lookup 메서드를 씁니다.
@Component // singleton
class OrderService {
private final ObjectProvider<PriceCalculator> calculatorProvider; // prototype
OrderService(ObjectProvider<PriceCalculator> p) { this.calculatorProvider = p; }
void handle() {
PriceCalculator calc = calculatorProvider.getObject(); // 호출마다 새 prototype
calc.calculate();
}
}
@PreDestroy도 자동 호출되지 않아요. 자원 해제가 필요하면 직접 정리하거나 커스텀 처리를 해야 합니다.@Transactional·@Cacheable·@Async 같은 기능은 마법이 아니라 프록시예요. 스프링은 대상 빈을 대신하는 프록시 객체를 만들어 컨테이너에 등록하고, 실제 호출 전후에 부가 로직(트랜잭션 시작/커밋 등)을 끼워 넣습니다.
| 프록시 종류 | 언제 쓰이나 | 방식 |
|---|---|---|
| JDK 동적 프록시 | 대상이 인터페이스를 구현할 때 | java.lang.reflect.Proxy. 인터페이스 기반으로 생성 |
| CGLIB | 인터페이스가 없을 때(구체 클래스만) | 대상 클래스를 상속(서브클래싱)해 프록시 생성. final 클래스·메서드엔 못 씀 |
proxyTargetClass=true를 기본값으로 둬서 인터페이스가 있어도 CGLIB로 프록시를 만듭니다(일관성·자기주입 이슈 회피). 순수 스프링에서는 인터페이스 유무로 갈린다는 원칙을 기억하세요.this.method()로 내부 메서드를 직접 부르면 프록시를 거치지 않아 부가 로직이 통째로 무시됩니다.
그래서 같은 클래스 안에서 호출한 @Transactional·@Cacheable·@Async가 조용히 안 먹어요. 해결: ① 해당 메서드를 다른 빈으로 분리, ② 자기 자신을 프록시로 주입(@Autowired로 self 주입 / ObjectProvider), ③ AopContext.currentProxy()(권장도 낮음).
@Service
class ReportService {
public void run() {
generate(); // ⚠️ this.generate() 와 같음 → 프록시 안 거침
}
@Transactional // 자기 호출로 들어오면 트랜잭션이 시작되지 않는다!
public void generate() { /* DB 작업 */ }
}
// run()을 외부에서 호출해도, 내부의 generate()에는 @Transactional 미적용
전파(propagation)는 "이미 트랜잭션이 진행 중일 때 새 트랜잭션 경계를 어떻게 처리할지"를 정해요.
| 전파 속성 | 동작 |
|---|---|
| REQUIRED ⭐ | 기본값. 기존 트랜잭션이 있으면 참여(합류), 없으면 새로 생성. 하나라도 실패하면 같이 롤백 |
| REQUIRES_NEW | 기존 트랜잭션을 잠시 보류하고 항상 별도의 새 트랜잭션 시작. 독립적으로 커밋/롤백(바깥이 롤백돼도 안쪽은 이미 커밋되어 남을 수 있음) |
| NESTED | 기존 트랜잭션 안에 세이브포인트(savepoint)를 만들어 부분 롤백. 안쪽만 롤백해도 바깥은 유지(JDBC savepoint 지원 필요) |
| SUPPORTS / MANDATORY / NEVER | 있으면 참여/없어도 진행 · 반드시 있어야 함 · 있으면 예외 |
REQUIRES_NEW로 분리하면 바깥 롤백과 무관하게 커밋됩니다. 단, 별도 커넥션을 잡으므로 커넥션 풀 고갈·데드락을 유의하세요.@Transactional은 기본적으로:@Transactional(rollbackFor = Exception.class)를 명시해야 해요. 반대로 특정 런타임 예외는 noRollbackFor로 제외할 수 있습니다. "예외 던졌는데 데이터가 남아있다"의 원인 1위가 바로 체크 예외 기본 커밋이에요.
private/final 메서드(프록시가 오버라이드/가로채기 불가 — CGLIB은 final 불가, 기본 프록시는 public 메서드 대상), ③ 체크 예외를 던졌는데 rollbackFor 미지정. 코드가 멀쩡해 보여도 트랜잭션이 안 걸릴 수 있으니 이 세 가지를 먼저 의심하세요.| 옵션 | 의미 |
|---|---|
isolation | 격리수준(READ_COMMITTED 등). 기본은 DB 기본값(DEFAULT) 사용. 낮으면 dirty/non-repeatable/phantom read 위험 |
readOnly = true | 읽기 전용 힌트. flush 생략·JPA 더티체킹 스냅샷 최적화 등으로 성능↑(DB 드라이버에 따라 최적화 정도 다름) |
timeout | 트랜잭션 제한 시간(초). 초과 시 롤백 |
rollbackFor / noRollbackFor | 롤백/비롤백 예외 목록 커스터마이즈 |
흔한 실수: 트랜잭션 안에서 결제 저장 직후 알림 메일·외부 API를 바로 호출하는 것. 만약 그 뒤에 트랜잭션이 롤백되면, DB는 되돌아갔는데 메일은 이미 나가버려요. "커밋이 확정된 다음"에 실행할 방법이 필요합니다.
registerSynchronization(...)으로 beforeCommit·afterCommit·afterCompletion 콜백을 등록하면, 트랜잭션 커밋/완료 시점에 맞춰 실행됩니다.
트랜잭션이 스레드 로컬(ThreadLocal)에 바인딩된다는 사실도 여기서 드러나요 — 그래서 다른 스레드(@Async 등)로 넘어가면 같은 트랜잭션이 이어지지 않습니다.
이 저수준 API를 선언적으로 감싼 게 @TransactionalEventListener예요. 이벤트를 발행해두면 지정한 트랜잭션 단계에서 리스너가 실행돼요.
// 1) 트랜잭션 안에서 이벤트만 발행
@Transactional
public void placeOrder(Order o) {
orderRepository.save(o);
eventPublisher.publishEvent(new OrderPlacedEvent(o.getId()));
}
// 2) 커밋이 성공적으로 끝난 뒤에만 실행 (롤백되면 실행 안 됨)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderPlaced(OrderPlacedEvent e) {
mailService.sendConfirmation(e.getOrderId()); // 안전: 커밋 확정 후
}
BEFORE_COMMIT·AFTER_COMMIT(기본)·AFTER_ROLLBACK·AFTER_COMPLETION. 메일·알림·검색 색인 등 부수효과는 대개 AFTER_COMMIT이 정답이에요. 단, AFTER_COMMIT 리스너 안에서 다시 DB를 쓰려면 이미 트랜잭션이 끝났으므로 새 트랜잭션(REQUIRES_NEW)이 필요합니다.AFTER_COMMIT에서 메일을 보내도, 커밋 직후 서버가 죽으면 이벤트는 유실될 수 있어요. "반드시 한 번은 전달"이 필요하면 아웃박스 패턴(Transactional Outbox)으로 이벤트를 같은 트랜잭션에 DB로 저장한 뒤 별도 발송기로 재시도하는 구조가 정석입니다.ObjectProvider/Provider/@Lookup으로 사용 시점에 꺼내기.rollbackFor로 변경).@TransactionalEventListener(AFTER_COMMIT). 확실한 전달은 아웃박스 패턴.