스프링 핵심 원리 (7) - @Configuration과 바이트코드 조작
1. 개요
AppConfig의 자바 코드를 그대로 읽어보면 이상한 부분이 있다. 여러 @Bean 메서드가 서로를 코드로 직접 호출하고 있는데, 그렇다면 의존 객체가 여러 번 new 되어야 하는 것이어야 하지 않나 라고 생각할 수 있다. @Configuration이 바이트코드 조작을 통해 싱글톤을 보장하는 원리를 정리한다.
2. 의심스러운 지점
AppConfig를 보면 memberService()와 orderService()가 모두 memberRepository()를 호출한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Configuration
public class AppConfig {
@Bean
public MemberService memberService() {
return new MemberServiceImpl(memberRepository());
}
@Bean
public OrderService orderService() {
return new OrderServiceImpl(memberRepository(), discountPolicy());
}
@Bean
public MemberRepository memberRepository() {
return new MemoryMemberRepository();
}
}
자바 코드만 놓고 보면 memberRepository()가 호출될 때마다 new MemoryMemberRepository()가 실행되므로, memberService가 참조하는 리포지토리와 orderService가 참조하는 리포지토리는 서로 다른 인스턴스여야 할 것 같다. 즉 싱글톤이 깨지는 것처럼 보인다. 실제로 그런지 테스트로 확인해보자. MemberServiceImpl, OrderServiceImpl에 내부 memberRepository를 꺼내볼 수 있는 조회용 메서드를 하나씩 추가한 뒤 비교해보면, memberService가 들고 있는 리포지토리, orderService가 들고 있는 리포지토리, 컨테이너에서 직접 조회한 memberRepository 빈이 모두 같은 인스턴스를 가리키고 있음을 확인할 수 있다.
1
2
assertThat(memberService.getMemberRepository()).isSameAs(memberRepository);
assertThat(orderService.getMemberRepository()).isSameAs(memberRepository);
분명 코드상으로는 memberRepository()가 세 번(등록용 1번 + memberService에서 1번 + orderService에서 1번) 호출되어야 할 것 같은데, 로그를 찍어보면 실제로는 단 한 번만 호출된다.
3. 비밀은 CGLIB 바이트코드 조작
스프링 컨테이너는 싱글톤 레지스트리이기 때문에 빈이 싱글톤이 되도록 보장해야 한다. 하지만 스프링이 개발자가 작성한 자바 소스 코드 자체를 고칠 수는 없다. 대신 스프링은 CGLIB라는 바이트코드 조작 라이브러리를 이용해서, @Configuration이 붙은 설정 클래스를 상속받는 임의의 클래스를 런타임에 동적으로 만들고, 그 클래스를 실제 스프링 빈으로 등록한다.
1
2
3
4
5
ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
AppConfig bean = ac.getBean(AppConfig.class);
System.out.println("bean = " + bean.getClass());
// bean = class hello.core.AppConfig$$EnhancerBySpringCGLIB$$xxxxx
원래대로라면 class hello.core.AppConfig가 출력되어야 하는데, 실제로는 EnhancerBySpringCGLIB가 붙은 이름이 출력된다. 이는 스프링이 AppConfig를 상속한 자식 클래스(AppConfig@CGLIB)를 만들어서 빈으로 등록했기 때문이다. 이 자식 클래스가 @Bean 메서드 호출을 가로채, 대략 다음과 같은 로직으로 동작한다.
1
2
3
4
5
6
7
8
9
@Bean
public MemberRepository memberRepository() {
if (스프링 컨테이너에 memberRepository 빈이 이미 등록되어 있으면) {
return 컨테이너에서 찾아서 반환;
} else {
// 기존 로직을 호출해서 MemoryMemberRepository를 생성하고 컨테이너에 등록
return 생성한 빈을 반환;
}
}
즉 @Bean 메서드가 두 번째 이후로 호출될 때는 실제로 new를 실행하는 대신, 이미 컨테이너에 등록된 빈을 그대로 반환하도록 코드가 조작되어 있다. 이 덕분에 memberService()와 orderService()가 각각 memberRepository()를 호출해도 매번 같은 인스턴스를 돌려받게 되고, 결과적으로 싱글톤이 보장된다. AppConfig@CGLIB는 AppConfig를 상속한 자식 타입이므로 AppConfig 타입으로도 문제없이 조회할 수 있다.
4. @Configuration을 빼고 @Bean만 쓰면?
그렇다면 @Configuration 없이 @Bean만 붙이면 어떻게 될까? 이 경우 CGLIB 조작이 일어나지 않기 때문에, 빈으로 조회한 AppConfig의 클래스도 순수하게 class hello.core.AppConfig로 출력된다. 그리고 memberRepository()는 실제로 3번 호출되며, memberService, orderService, 컨테이너에서 직접 조회한 memberRepository가 각각 서로 다른 MemoryMemberRepository 인스턴스를 참조하게 된다. @Bean만으로도 각 메서드의 반환값이 스프링 빈으로 등록되는 것은 맞지만, 다른 빈 안에서 그 메서드를 자바 코드로 직접 호출하는 부분까지는 싱글톤을 보장해주지 못하는 것이다.
| 방식 | 클래스 조회 결과 | memberRepository() 호출 횟수 | 싱글톤 보장 |
|---|---|---|---|
@Configuration + @Bean | AppConfig$$EnhancerBySpringCGLIB$$... | 1번 | O |
@Bean만 사용 | AppConfig | 3번 | X |
5. 정리
@Bean만 있어도 해당 메서드의 반환 객체는 스프링 빈으로 등록되지만, 그 메서드를 다른 @Bean 메서드 안에서 의존관계 주입용으로 직접 호출하는 경우까지 싱글톤을 지켜주지는 못한다. 이 문제를 해결하는 것이 바로 @Configuration이다. @Configuration이 붙은 설정 클래스는 CGLIB로 바이트코드가 조작된 자식 클래스로 대체되어 스프링 빈에 등록되고, 이 자식 클래스가 @Bean 메서드 호출을 가로채 이미 등록된 빈이 있으면 그것을 그대로 반환하는 방식으로 싱글톤을 보장한다.