IoC 컨테이너 빈 생명주기·스코프 AOP 내부 @Transactional 동기화·이벤트 📝 문제풀이
◀ 심화 CH 03 📋 목차 심화 CH 05 ▶
🌱 심화 CHAPTER 04 · 5년차 트랙

스프링은 왜 프록시로 감싸고, 왜 자기 호출엔 안 먹을까?

@Transactional 하나 붙였는데 롤백이 안 되고, @Async가 조용히 무시되고, 싱글톤에 넣은 prototype이 안 바뀌는 경험 — 대부분 스프링의 내부 동작(IoC·프록시·트랜잭션 전파)을 몰라서 생겨요. 이번 장은 "왜 그렇게 동작하는가"를 파고듭니다.

🎯 이 장을 끝내면
📦
IoC 컨테이너
누가 객체를 만들고 연결하는가
BeanFactory vs ApplicationContext · 순환 참조

스프링의 핵심은 제어의 역전(IoC)이에요. 객체 생성과 의존 관계 연결을 개발자가 new로 하지 않고 컨테이너에 맡깁니다. 그 컨테이너의 두 얼굴이 BeanFactoryApplicationContext예요.

구분BeanFactoryApplicationContext
위치컨테이너의 최상위 인터페이스(기본 뼈대)BeanFactory를 상속·확장한 상위 인터페이스
빈 생성 시점지연(lazy) — 실제 요청(getBean) 시 생성싱글톤 빈을 기동 시 미리(eager) 생성
부가 기능DI만 담당(최소 기능)국제화(i18n)·이벤트 발행·BeanPostProcessor 자동 등록·리소스 로딩 등
실무직접 쓸 일 드묾거의 항상 이걸 사용(스프링 부트 기본)
🧭
ApplicationContext는 BeanFactory의 상위(확장) 개념이에요. "BeanFactory가 더 크다"고 헷갈리기 쉬운데, 기능·스펙 측면에서 ApplicationContext가 BeanFactory를 포함합니다. 그래서 실무에서 그냥 ApplicationContext(정확히는 AnnotationConfigApplicationContext 등)를 쓰면 돼요.
🔁 순환 참조(Circular Reference) A가 B를, B가 A를 필요로 하면 순환 참조예요. 생성자 주입이면 서로를 만들 수 없어 스프링 부트 2.6+에서는 기동 시 BeanCurrentlyInCreationException으로 바로 실패합니다(기본적으로 순환 참조 금지). 수정자(setter)·필드 주입이면 미완성 빈을 임시로 넣어 뚫리기도 하지만, 근본 해법은 설계를 고치는 것이에요(책임 분리, 이벤트로 분리, @Lazy는 임시방편). 순환 참조 자체가 강한 결합의 신호입니다.
♻️
빈 생명주기 · 스코프
언제 태어나고, 몇 개가 살고, 언제 죽는가
싱글톤에 prototype을 넣으면 왜 안 바뀔까

빈은 생성 → 의존성 주입 → 초기화 콜백 → 사용 → 소멸 콜백의 순서로 살아요. 초기화·소멸 시점에 끼어드는 콜백이 세 가지 방식으로 제공돼요.

시점방식비고
초기화@PostConstruct권장(JSR-250 표준). 의존성 주입이 끝난 뒤 호출
InitializingBean.afterPropertiesSet()스프링 인터페이스 결합(요즘은 잘 안 씀)
소멸@PreDestroy권장. 컨테이너 종료 시 호출
DisposableBean.destroy() / @Bean(destroyMethod)인터페이스 방식 / 외부 라이브러리 정리용
⚠️
@PostConstruct는 생성자가 아니에요. 생성자 실행 시점엔 의존성이 아직 주입되지 않을 수 있어요(특히 필드·수정자 주입). 주입된 의존성을 이용한 초기화 로직은 반드시 의존성 주입 완료 후@PostConstruct에서 해야 안전합니다.

스코프는 빈이 몇 개 존재하고 얼마나 사는지를 정해요.

스코프의미
singleton기본값. 컨테이너당 단 하나의 인스턴스를 공유
prototype요청(주입·getBean)마다 새 인스턴스. 스프링은 생성·주입까지만 관리하고 소멸 콜백은 호출 안 함
request / session웹 스코프. HTTP 요청/세션 단위 인스턴스
🪤 싱글톤 안에 prototype 주입 함정 싱글톤 빈에 prototype 빈을 일반 필드로 주입하면, 주입은 싱글톤이 생성될 때 딱 한 번 일어나요. 그래서 싱글톤이 그 필드를 계속 재사용하면 매번 새 인스턴스를 기대해도 항상 같은 놈이 나옵니다("매번 새로"가 안 먹음). 해결: 매 호출마다 새 prototype이 필요하면 주입 시점이 아니라 사용 시점에 꺼내야 해요. 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();
    }
}
💡
prototype의 소멸은 내 책임. prototype 빈은 스프링이 생성·의존성 주입까지만 관리하고 그 뒤엔 손을 뗍니다. @PreDestroy자동 호출되지 않아요. 자원 해제가 필요하면 직접 정리하거나 커스텀 처리를 해야 합니다.
🖼️ 그림으로 보기 — 빈 생명주기와 스코프
① 생명주기 — 생성부터 소멸까지 생성 인스턴스화(new) 의존성 주입 DI 완료 @PostConstruct 초기화 콜백 사용 비즈니스 로직 @PreDestroy 소멸 콜백 ② 스코프 — 컨테이너 안에 몇 개가 사는가 singleton ⭐ (기본) 컨테이너당 1개 · 모두 공유 · 소멸 콜백 O 주입 A 주입 B 주입 C 하나의 Bean prototype 요청마다 새 인스턴스 · 소멸 콜백 미호출 주입 A 주입 B 주입 C 새 Bean #1 새 Bean #2 새 Bean #3
🎭
AOP 내부
스프링 AOP는 프록시로 감싼다
JDK 동적 프록시 vs CGLIB · 자기 호출 한계

@Transactional·@Cacheable·@Async 같은 기능은 마법이 아니라 프록시예요. 스프링은 대상 빈을 대신하는 프록시 객체를 만들어 컨테이너에 등록하고, 실제 호출 전후에 부가 로직(트랜잭션 시작/커밋 등)을 끼워 넣습니다.

📞
호출자
빈을 주입받아 메서드 호출
🎭
프록시
트랜잭션·캐시·비동기 부가 로직
🎯
실제 빈(target)
비즈니스 로직 본체
프록시 종류언제 쓰이나방식
JDK 동적 프록시대상이 인터페이스를 구현할 때java.lang.reflect.Proxy. 인터페이스 기반으로 생성
CGLIB인터페이스가 없을 때(구체 클래스만)대상 클래스를 상속(서브클래싱)해 프록시 생성. final 클래스·메서드엔 못 씀
🧭
Spring Boot는 기본이 CGLIB이에요. 원래 스프링은 "인터페이스가 있으면 JDK 프록시"였지만, 스프링 부트는 proxyTargetClass=true를 기본값으로 둬서 인터페이스가 있어도 CGLIB로 프록시를 만듭니다(일관성·자기주입 이슈 회피). 순수 스프링에서는 인터페이스 유무로 갈린다는 원칙을 기억하세요.
🚫 self-invocation(자기 호출) 한계 — 가장 흔한 함정 프록시는 외부에서 들어오는 호출만 가로챌 수 있어요. 같은 빈 안에서 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 미적용
🖼️ 그림으로 보기 — 자기 호출은 프록시를 우회한다
외부 호출자 빈을 주입받아 호출 🎭 프록시 트랜잭션 시작/커밋 실제 빈 (target) run() generate() @Transactional 미적용 ❌ this.generate() → 프록시 우회 ✅ 초록: 프록시가 가로챈 외부 진입 ❌ 빨강(점선): 내부 자기 호출 — 프록시 건너뜀
💾
@Transactional 심화
전파 · 격리 · 롤백 규칙
"롤백이 왜 안 되지?"의 90%가 여기

전파(propagation)는 "이미 트랜잭션이 진행 중일 때 새 트랜잭션 경계를 어떻게 처리할지"를 정해요.

전파 속성동작
REQUIRED기본값. 기존 트랜잭션이 있으면 참여(합류), 없으면 새로 생성. 하나라도 실패하면 같이 롤백
REQUIRES_NEW기존 트랜잭션을 잠시 보류하고 항상 별도의 새 트랜잭션 시작. 독립적으로 커밋/롤백(바깥이 롤백돼도 안쪽은 이미 커밋되어 남을 수 있음)
NESTED기존 트랜잭션 안에 세이브포인트(savepoint)를 만들어 부분 롤백. 안쪽만 롤백해도 바깥은 유지(JDBC savepoint 지원 필요)
SUPPORTS / MANDATORY / NEVER있으면 참여/없어도 진행 · 반드시 있어야 함 · 있으면 예외
💡
REQUIRES_NEW의 실전 용도. "본 작업이 실패해 롤백돼도, 감사 로그·이력은 반드시 남겨야 할 때" 로그 저장을 REQUIRES_NEW로 분리하면 바깥 롤백과 무관하게 커밋됩니다. 단, 별도 커넥션을 잡으므로 커넥션 풀 고갈·데드락을 유의하세요.
🖼️ 그림으로 보기 — REQUIRED vs REQUIRES_NEW
REQUIRED (기본) — 합류 하나의 트랜잭션 (TX1) 바깥 메서드 안쪽 (REQUIRED) 기존 TX에 참여 하나라도 실패 → 함께 롤백 REQUIRES_NEW — 독립 바깥 TX1 잠시 보류(suspend) 안쪽 TX2 새 트랜잭션 안쪽 독립 커밋 — 바깥 롤백돼도 남음
⛔ 롤백 규칙 — 가장 많이 틀리는 지점 스프링 @Transactional기본적으로:
✅ 언체크 예외(RuntimeException)와 Error자동 롤백
❌ 체크 예외(Checked Exception, 예: IOException)롤백하지 않고 커밋 체크 예외에도 롤백하려면 @Transactional(rollbackFor = Exception.class)를 명시해야 해요. 반대로 특정 런타임 예외는 noRollbackFor로 제외할 수 있습니다. "예외 던졌는데 데이터가 남아있다"의 원인 1위가 바로 체크 예외 기본 커밋이에요.
🖼️ 그림으로 보기 — 롤백 규칙 — 예외 종류에 따라 갈린다
@Transactional 메서드에서 예외 throw 언체크 예외 RuntimeException · Error 자동 롤백 ✅ DB 변경이 되돌려짐 체크 예외 IOException 등 (Checked) 기본: 커밋 ⚠️ DB 변경이 그대로 남음 rollbackFor=Exception.class 지정 → 롤백
⚠️
@Transactional이 무시되는 3대 조건.자기 호출(프록시 우회), ② private/final 메서드(프록시가 오버라이드/가로채기 불가 — CGLIB은 final 불가, 기본 프록시는 public 메서드 대상), ③ 체크 예외를 던졌는데 rollbackFor 미지정. 코드가 멀쩡해 보여도 트랜잭션이 안 걸릴 수 있으니 이 세 가지를 먼저 의심하세요.
옵션의미
isolation격리수준(READ_COMMITTED 등). 기본은 DB 기본값(DEFAULT) 사용. 낮으면 dirty/non-repeatable/phantom read 위험
readOnly = true읽기 전용 힌트. flush 생략·JPA 더티체킹 스냅샷 최적화 등으로 성능↑(DB 드라이버에 따라 최적화 정도 다름)
timeout트랜잭션 제한 시간(초). 초과 시 롤백
rollbackFor / noRollbackFor롤백/비롤백 예외 목록 커스터마이즈
📮
동기화 · 이벤트
커밋 이후에 안전하게 무언가 하기
TransactionSynchronizationManager · @TransactionalEventListener

흔한 실수: 트랜잭션 안에서 결제 저장 직후 알림 메일·외부 API를 바로 호출하는 것. 만약 그 뒤에 트랜잭션이 롤백되면, DB는 되돌아갔는데 메일은 이미 나가버려요. "커밋이 확정된 다음"에 실행할 방법이 필요합니다.

🔗 TransactionSynchronizationManager 현재 스레드에 묶인 트랜잭션 리소스·동기화 콜백을 관리하는 저수준 중심축이에요. 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()); // 안전: 커밋 확정 후
}
💡
phase 종류. BEFORE_COMMIT·AFTER_COMMIT(기본)·AFTER_ROLLBACK·AFTER_COMPLETION. 메일·알림·검색 색인 등 부수효과는 대개 AFTER_COMMIT이 정답이에요. 단, AFTER_COMMIT 리스너 안에서 다시 DB를 쓰려면 이미 트랜잭션이 끝났으므로 새 트랜잭션(REQUIRES_NEW)이 필요합니다.
⚠️
진짜 확실한 전달은 이걸론 부족. AFTER_COMMIT에서 메일을 보내도, 커밋 직후 서버가 죽으면 이벤트는 유실될 수 있어요. "반드시 한 번은 전달"이 필요하면 아웃박스 패턴(Transactional Outbox)으로 이벤트를 같은 트랜잭션에 DB로 저장한 뒤 별도 발송기로 재시도하는 구조가 정석입니다.
🧠 이 장 핵심 요약
📝
문제풀이 · 점검
시니어 관점으로 점검
면접·장애대응에서 실제로 묻는 결의 문제예요. 틀려도 바로 해설이 나옵니다.
🧪
개념을 "안다"와 "설명할 수 있다"는 달라요. 네 유형(객관식 · O/X · 빈칸 · 코드결과)으로 점검해보세요.
심화 CH 05 🗄️ JPA 심화 — 영속성 컨텍스트, 지연 로딩·N+1, 페치 조인, 트랜잭션과 flush (제작 예정)