Spring 핵심 — 객체를 누가 만들고 연결하나
앞 편에서 아키텍처가 구조를 정하면 Spring이 그 *배선(wiring)*을 대신한다고 했다. 이번 편이 바로 그 배선의 정체다. 객체를 만들고 인터페이스에 구현을 꽂는 일을 컨테이너가 대신 하고(IoC/DI), 트랜잭션·로깅 같은 여러 곳에 공통으로 필요한 관심사를 프록시로 끼운다(AOP). 그런데 이 두 가지의 원리 — 특히 빈이 어떻게 만들어지고, 프록시가 어떻게 끼는가 — 를 바이트코드 수준까지 이해하지 못하면, Spring에서 가장 악명 높은 함정들(자기 호출, 순환 참조, final 프록시 실패)에 줄줄이 걸린다. 그래서 이번 편은 원리를 깊게 파고들며 그 함정들이 왜 생기는지까지 끝까지 따라간다.
new를 내가 하지 않는다 — IoC
평소 자바라면 new OrderService(new JpaOrderRepository(...))처럼 내가 직접 객체를 만들고 의존을 연결한다. **제어의 역전(IoC)**은 바로 그 제어권을 컨테이너에 넘기는 것이다 — 객체의 생성·연결·생명주기를 이제 Spring 컨테이너가 맡는다.
컨테이너가 관리하는 이 객체들을 **빈(Bean)**이라 부른다. @Component·@Service·@Repository·@Bean으로 등록해 두면, 컨테이너가 기동할 때 빈을 만들고 서로 연결한다 — 앞 편에서 본 "포트에 어댑터를 꽂는" 그 배선을 자동으로 해 주는 것이다. 그런데 왜 굳이 제어를 넘길까? 객체가 자기 의존을 직접 만들면(new JpaRepository()) 그 구현에 단단히 묶여 버린다. 반대로 컨테이너가 바깥에서 주입해 주면, 코드는 인터페이스만 알고 구현은 설정으로 갈아 끼울 수 있다 — 결합도가 낮아지고 테스트·교체가 쉬워진다. 앞 편의 DIP를 실제로 실현하는 도구가 바로 이 IoC다.
의존을 넣는 세 가지 길 — 그리고 생성자 주입
의존을 주입하는 방법은 셋이다. 필드 주입(@Autowired 필드)은 가장 짧지만 함정이 많고, 세터 주입은 선택적·변경 가능한 의존에 쓰며, 생성자 주입이 정석이다.
@Service
public class OrderService {
private final OrderRepository repository; // final — 불변
private final PaymentClient payment;
public OrderService(OrderRepository repository, PaymentClient payment) {
this.repository = repository;
this.payment = payment;
}
}
// 생성자가 하나면 @Autowired 생략 가능, Lombok @RequiredArgsConstructor로 더 간결
생성자 주입이 권장되는 데는 서로 맞물린 이유가 여럿이다. final로 둘 수 있어 한 번 주입되면 바뀌지 않으니 불변이고, 의존 없이는 객체 생성 자체가 안 되므로 "주입 안 된 빈"이라는 NPE가 원천 차단된다. 또 new OrderService(mockRepo, mockPay)처럼 컨테이너 없이 단위 테스트를 만들 수 있고(필드 주입이면 리플렉션·컨테이너가 필요하다 — 테스트 편에서 다시 만날 이야기다), 생성자 시그니처가 곧 필요한 의존 목록이라 의존이 한눈에 드러난다. 게다가 생성자 인자가 대여섯 개로 불어나면 그건 "이 클래스가 책임이 너무 많다"는 설계 냄새를 눈앞에 보여 주는 것이라, 오히려 좋은 신호다. 마지막으로, 생성자 주입은 순환 의존을 기동 시점에 즉시 드러낸다 — 왜 그런지는 잠시 뒤 빈 생성 과정을 보면 정확히 이해된다.
빈은 어떻게 태어나는가 — 생성의 여러 단계
"컨테이너가 빈을 만든다"는 말은 사실 여러 단계를 거치는 정교한 과정이다. 이 순서를 알아야 프록시가 언제 끼는지, @PostConstruct 시점에 무엇을 쓸 수 있는지가 보인다.
핵심은 두 가지다. 첫째, 프로퍼티 주입(②)이 끝난 뒤에야 초기화 콜백(⑤~⑦)이 돌기 때문에, @PostConstruct 시점에는 주입된 의존을 안전하게 쓸 수 있다(생성자에서 주입 안 된 필드를 만지면 안 되는 이유와 짝을 이룬다). 둘째, AOP 프록시는 마지막 단계 ⑧, 즉 BeanPostProcessor.postProcessAfterInitialization에서 만들어진다 — AbstractAutoProxyCreator라는 BeanPostProcessor가 "이 빈이 @Transactional 같은 어드바이스 대상인가?"를 보고, 그렇다면 원본 빈을 프록시로 감싸 그 프록시를 반환한다. 그래서 컨테이너가 최종적으로 들고 있는 것, 그리고 다른 빈에 주입되는 것은 원본이 아니라 프록시다 — 이 사실이 뒤의 자기 호출 함정의 뿌리가 된다.
닭과 달걀 — 순환 참조와 3단계 캐시
A가 B를, B가 A를 필요로 하면 어떻게 될까? A를 만들려면 B가 있어야 하고, B를 만들려면 A가 있어야 한다 — 닭과 달걀이다. Spring은 세터/필드 주입에 한해 이를 **3단계 캐시(three-level cache)**로 푼다. 이 내부를 들여다보면 Spring이 빈을 만드는 방식이 한층 또렷해진다.
흐름은 이렇다. A를 *인스턴스화(①)*하자마자, Spring은 아직 프로퍼티도 안 채워진 A를 만들어 줄 ObjectFactory를 3차 캐시에 미리 등록한다. 그다음 A의 프로퍼티를 채우다가(②) B가 필요해 getBean(B)로 B 생성에 들어간다. B도 프로퍼티를 채우다가 A가 필요해 getBean(A)를 부르는데 — 이때 A는 아직 생성 중이라 1차 캐시엔 없지만, 3차 캐시의 ObjectFactory가 A의 조기 참조를 만들어 준다. B는 그 조기 참조를 받아 완성되고, 제어가 A로 돌아와 A도 완성된다. 닭과 달걀이 풀리는 것이다.
그런데 왜 굳이 3단계일까? 순환만 풀 거면 2단계(조기 객체를 그냥 저장)로 충분하다. 3차 캐시의 ObjectFactory가 필요한 진짜 이유는 AOP다. 만약 A가 프록시 대상이라면, B에 주입돼야 하는 건 원본 A가 아니라 프록시 A여야 한다. ObjectFactory는 호출되는 순간 "A가 프록시 대상이면 지금 미리 프록시를 만들어 돌려준다" — 그래서 순환 상황에서도 B가 올바른 프록시 참조를 받는다. 2단계만으로는 원본이 새어 나가 프록시가 안 걸린 A가 주입되는 사고가 난다. (출처: Spring circular dependency & three-level cache.)
이제 생성자 주입이 왜 순환을 못 푸는지도 분명해진다. 3단계 캐시의 트릭은 "인스턴스화는 됐지만 미완성인 조기 참조를 빌려주는" 것인데, 생성자 주입은 인스턴스화 그 자체에 상대가 필요하다 — 빌려줄 조기 참조조차 아직 없다. 그래서 생성자 순환은 풀 길이 없어 기동 시 즉시 실패한다. 이건 단점이 아니라 설계 결함을 조기에 드러내는 장점이다(필드 주입은 이 순환을 조용히 풀어 버려 문제를 숨긴다). 진짜 해법은 캐시에 기대는 게 아니라, 공통 책임을 제3의 빈으로 추출하거나 이벤트로 결합을 끊는 것이다.
빈은 기본 싱글톤 — 그래서 무상태로
방금 본 "완성된 빈은 1차 캐시(싱글톤 레지스트리)에 등록된다"가 빈의 기본 스코프를 말해 준다 — 싱글톤, 컨테이너당 딱 하나의 인스턴스를 모두가 공유한다(요청마다 새로 만드는 prototype, 웹의 request·session도 있다). 여기서 반드시 새겨야 할 실무 원칙이 나온다 — 싱글톤 빈에 가변 상태를 두지 마라. 빈은 모든 요청과 스레드가 공유하는 단 하나의 객체라, 거기에 요청별로 바뀌는 필드를 두면 여러 요청이 그 필드를 동시에 건드려 데이터가 섞이는 동시성 버그가 난다(jcode 동시성 편의 "공유 가변 상태"가 바로 이것이다). 요청에 딸린 상태는 메서드 지역 변수나 파라미터로 들고 다니고, 빈 자체는 *무상태(stateless)*로 둔다.
가끔 싱글톤 빈에 요청 스코프 빈을 주입해야 할 때가 있는데, 싱글톤은 한 번만 주입받으므로 그 요청 빈도 하나로 고정돼 버린다. 이땐 스코프 프록시(@Scope(proxyMode = TARGET_CLASS))를 쓴다 — 싱글톤에는 프록시가 주입되고, 그 프록시가 호출 시점마다 현재 요청의 진짜 빈을 찾아 위임한다. 여기서도 결국 프록시가 일한다.
횡단 관심사를 프록시로 끼운다 — AOP의 내부
트랜잭션·로깅·보안·캐시처럼 여러 메서드에 공통으로 필요한 관심사를 비즈니스 코드 곳곳에 흩뿌리면 중복과 결합이 생긴다. AOP는 이런 관심사를 따로 떼어 두었다가 메서드 호출을 가로채 적용한다. 그 비결이 앞서 빈 생성 ⑧단계에서 만들어진 프록시다.
Spring이 프록시를 만드는 방식은 둘인데, 바이트코드 수준에서 다르다. JDK 동적 프록시는 대상이 인터페이스를 구현할 때 쓴다 — java.lang.reflect.Proxy가 런타임에 그 인터페이스를 구현하는 프록시 클래스를 만들고, 모든 호출을 InvocationHandler로 보내 부가 동작을 끼운 뒤 원본에 위임한다. 반면 CGLIB은 인터페이스가 없을 때(또는 Spring Boot 기본) 쓰는데, 대상 클래스를 상속한 서브클래스를 바이트코드로 생성하고 메서드를 오버라이드해 가로챈다(MethodInterceptor). 바로 이 "상속해서 오버라이드한다"는 점 때문에 final 클래스나 final·private 메서드는 CGLIB이 오버라이드할 수 없어 프록시가 걸리지 않는다 — 흔히 놓치는 함정이다. (출처: Spring Proxying Mechanisms.)
여러 어드바이스가 한 메서드에 걸리면, 프록시는 이들을 체인(ReflectiveMethodInvocation)으로 엮어 순서대로 통과시킨다(이 순서는 @Order로 제어한다). 그래서 @Transactional·@Cacheable·@Async·@Secured가 모두 이 한 프록시 위에서 함께 돈다 — 그리고 바로 이 구조에서 Spring의 가장 유명한 함정이 나온다.
가장 유명한 함정 — 자기 호출, 바이트코드로 보면
@Service
public class OrderService {
public void place(Order o) {
validate(o);
save(o); // this.save() — 프록시를 통과하지 않는다!
}
@Transactional
public void save(Order o) { repository.save(o); }
}
place() 안에서 save()를 부르면 트랜잭션이 안 걸린다. 왜인지를 바이트코드 수준에서 보면 의심의 여지가 없다. 앞에서 봤듯 컨테이너가 들고 있고 다른 빈에 주입되는 건 프록시다. 그런데 place() 안의 save(o)는 컴파일되면 this에 대한 invokevirtual, 즉 원본 객체 자신의 메서드 호출이 된다 — *프록시 참조가 아니라 this*다. 프록시는 바깥에서 주입된 참조를 거쳐 들어오는 호출만 가로채는데, this.save()는 그 프록시를 거치지 않는 평범한 JVM 메서드 호출이라 어드바이스가 끼어들 자리가 없다. @Cacheable·@Async도 정확히 같은 이유로 무력화된다. ("this로 메서드를 부르는 순간 프록시를 통째로 우회한다" — DEV: Why @Transactional Sometimes Fails.)
고치는 길은 결국 호출이 프록시를 거치게 만드는 것이다. 가장 깔끔한 건 트랜잭션 메서드를 다른 빈으로 분리해 주입받아 호출하는 것이고(그러면 그 호출은 프록시를 거친다), 자신을 주입받아 self.save()로 부르거나, 프로그래밍 방식의 TransactionTemplate을 쓰거나(이건 private에도 된다), 아예 AspectJ 위빙으로 바이트코드에 직접 짜 넣어 프록시 의존을 없앨 수도 있다.
실무에서는
이 원리들이 현장에서 어떻게 사고가 되는지 몇 가지를 짚어 두자. 가장 흔한 건 위의 자기 호출로 트랜잭션이 안 걸려 데이터 정합이 깨지는 경우다 — @Transactional을 붙였는데 "롤백이 안 된다"는 신고의 상당수가 같은 클래스 내부 호출이거나 private 메서드에 붙인 것이다. 또 기존에 잘 돌던 클래스를 final로 바꿨더니 CGLIB 프록시 생성이 깨지거나, Kotlin에서 클래스가 기본 final이라 @Transactional이 조용히 안 걸리는 일도 잦다(Kotlin은 all-open 플러그인으로 푼다). 그리고 빈 수가 수천 개로 늘면 기동 시 모든 싱글톤을 생성·프록시·후처리하는 비용이 커져 부팅이 느려지는데, 정말 무거운 빈은 @Lazy로 첫 사용 시점까지 생성을 미뤄 기동을 앞당긴다(대신 첫 요청이 느려지고 설정 오류가 늦게 드러나는 트레이드오프가 따른다).
정리하면, Spring 핵심은 객체를 만들고 연결하는 일을 컨테이너에 넘기고(IoC), 그 의존을 생성자로 주입하며(불변·테스트·순환 가시화), 빈은 여러 단계를 거쳐 태어나 마지막에 프록시로 감싸진 채 공유 싱글톤으로 등록된다. 순환 참조는 3단계 캐시가 (세터/필드 한정으로) 풀되 그 트릭의 중심에도 프록시가 있고, 횡단 관심사 역시 JDK/CGLIB 프록시로 끼운다 — 결국 Spring의 거의 모든 마법이 프록시 하나로 수렴하고, 그래서 자기 호출은 그 프록시를 우회해 가로채지 못한다는 가장 흔한 함정도 같은 뿌리에서 나온다.