Theme:

@Configuration 클래스에서 @Bean 메서드를 두 번 호출하면 객체가 두 개 만들어질까요, 아니면 같은 객체가 반환될까요? 스프링은 이걸 어떻게 제어할까요?

개념 정의

@Configuration은 단순한 설정 클래스 표시가 아닙니다. 스프링이 이 클래스를 CGLIB 프록시로 감싸서, @Bean 메서드를 호출할 때 싱글톤을 보장하는 메커니즘입니다. 이것을 Full 모드라고 합니다.

왜 필요한가

다음 코드를 봅시다.

JAVA
@Configuration
public class AppConfig {

    @Bean
    public DataSource dataSource() {
        return new HikariDataSource(); // 커넥션 풀 생성
    }

    @Bean
    public UserRepository userRepository() {
        return new UserRepository(dataSource()); // dataSource() 호출
    }

    @Bean
    public OrderRepository orderRepository() {
        return new OrderRepository(dataSource()); // dataSource() 또 호출
    }
}

일반 자바 코드라면 dataSource()가 두 번 호출되므로 HikariDataSource가 두 개 생깁니다. 커넥션 풀이 두 개라는 뜻이죠. 이건 의도한 동작이 아닙니다.

@Configuration의 CGLIB 프록시가 이 문제를 해결합니다. dataSource()를 아무리 많이 호출해도 컨테이너에 등록된 하나의 빈만 반환됩니다.

내부 동작

CGLIB 프록시가 하는 일

스프링은 @Configuration 클래스를 로드할 때 다음과 같이 처리합니다.

PLAINTEXT
1. AppConfig 클래스 발견
2. CGLIB으로 AppConfig의 서브클래스(프록시) 생성
   → AppConfig$$SpringCGLIB$$0
3. 이 프록시 클래스를 빈으로 등록
4. @Bean 메서드 호출 시 프록시가 가로챔
   → 이미 빈이 있으면 기존 빈 반환
   → 없으면 원본 메서드 실행 후 빈으로 등록

개념적으로 프록시가 하는 일을 코드로 표현하면 이렇습니다.

JAVA
// CGLIB 프록시의 동작 (개념적 표현)
public class AppConfig$$SpringCGLIB$$0 extends AppConfig {

    @Override
    public DataSource dataSource() {
        if (beanFactory.containsBean("dataSource")) {
            return beanFactory.getBean("dataSource", DataSource.class);
        }
        // 최초 호출 시에만 실제 메서드 실행
        DataSource ds = super.dataSource();
        beanFactory.registerSingleton("dataSource", ds);
        return ds;
    }
}

Full 모드 vs Lite 모드

구분Full 모드Lite 모드
설정@Configuration (기본)@Configuration(proxyBeanMethods = false)
CGLIB 프록시생성됨생성 안 됨
메서드 간 호출싱글톤 보장매번 새 인스턴스 생성
성능약간 느림 (프록시 오버헤드)약간 빠름
용도빈 간 의존관계가 있을 때독립적인 빈 등록

Lite 모드의 함정

JAVA
@Configuration(proxyBeanMethods = false) // Lite 모드
public class AppConfig {

    @Bean
    public DataSource dataSource() {
        return new HikariDataSource();
    }

    @Bean
    public UserRepository userRepository() {
        return new UserRepository(dataSource()); // 새 HikariDataSource 생성!
    }

    @Bean
    public OrderRepository orderRepository() {
        return new OrderRepository(dataSource()); // 또 새 HikariDataSource 생성!
    }
}

Lite 모드에서는 CGLIB 프록시가 없으므로 dataSource()가 일반 자바 메서드처럼 동작합니다. HikariDataSource가 3개 생기게 됩니다.

Lite 모드에서 안전하게 의존성 연결하기

Lite 모드를 쓰면서도 의존성을 올바르게 연결하려면, 메서드 파라미터로 받아야 합니다.

JAVA
@Configuration(proxyBeanMethods = false)
public class AppConfig {

    @Bean
    public DataSource dataSource() {
        return new HikariDataSource();
    }

    @Bean
    public UserRepository userRepository(DataSource dataSource) {
        // 파라미터로 주입받으면 컨테이너의 싱글톤 빈을 받음
        return new UserRepository(dataSource);
    }

    @Bean
    public OrderRepository orderRepository(DataSource dataSource) {
        return new OrderRepository(dataSource); // 같은 DataSource 인스턴스
    }
}

코드 예제

Full 모드 동작 확인

JAVA
@Configuration
public class FullModeConfig {

    @Bean
    public SimpleBean simpleBean() {
        System.out.println("simpleBean 생성");
        return new SimpleBean();
    }

    @Bean
    public CompoundBean compoundBean() {
        SimpleBean bean1 = simpleBean();
        SimpleBean bean2 = simpleBean();
        System.out.println("같은 인스턴스인가? " + (bean1 == bean2)); // true
        return new CompoundBean(bean1);
    }
}

출력:

PLAINTEXT
simpleBean 생성          ← 한 번만 출력됨
같은 인스턴스인가? true

Lite 모드 동작 확인

JAVA
@Configuration(proxyBeanMethods = false)
public class LiteModeConfig {

    @Bean
    public SimpleBean simpleBean() {
        System.out.println("simpleBean 생성");
        return new SimpleBean();
    }

    @Bean
    public CompoundBean compoundBean() {
        SimpleBean bean1 = simpleBean();
        SimpleBean bean2 = simpleBean();
        System.out.println("같은 인스턴스인가? " + (bean1 == bean2)); // false
        return new CompoundBean(bean1);
    }
}

출력:

PLAINTEXT
simpleBean 생성          ← 세 번 출력됨 (빈 등록 1번 + 메서드 호출 2번)
simpleBean 생성
simpleBean 생성
같은 인스턴스인가? false

@Component 안의 @Bean은 Lite 모드

JAVA
@Component // @Configuration이 아니라 @Component
public class ServiceConfig {

    @Bean
    public SomeService someService() {
        return new SomeService();
    }

    @Bean
    public AnotherService anotherService() {
        // someService()를 호출하면 새 인스턴스가 생성됨 (Lite 모드)
        return new AnotherService(someService());
    }
}

@Component 안에서도 @Bean을 사용할 수 있지만, CGLIB 프록시가 적용되지 않습니다. 이것도 Lite 모드입니다.

실무에서의 선택 기준

JAVA
// 빈 간 의존관계가 있을 때 → Full 모드 (기본)
@Configuration
public class DataSourceConfig {
    @Bean
    public DataSource dataSource() { ... }

    @Bean
    public JdbcTemplate jdbcTemplate() {
        return new JdbcTemplate(dataSource()); // 안전
    }
}

// 독립적인 빈만 등록할 때 → Lite 모드 (성능 이점)
@Configuration(proxyBeanMethods = false)
public class UtilConfig {
    @Bean
    public ObjectMapper objectMapper() {
        return new ObjectMapper();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

스프링부트 Auto-Configuration은 대부분 Lite 모드

스프링부트의 자동 설정 클래스들을 보면 대부분 proxyBeanMethods = false입니다.

JAVA
// 스프링부트 내부 코드
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(DataSource.class)
public class DataSourceAutoConfiguration {
    // ...
}

부팅 속도를 빠르게 하기 위해 불필요한 CGLIB 프록시 생성을 피합니다. 대신 의존성은 모두 메서드 파라미터로 주입받는 패턴을 사용합니다.

CGLIB 프록시 확인 방법

JAVA
@SpringBootApplication
public class App {
    public static void main(String[] args) {
        var context = SpringApplication.run(App.class, args);
        var config = context.getBean(AppConfig.class);
        System.out.println(config.getClass());
        // com.example.AppConfig$$SpringCGLIB$$0 ← 프록시 클래스
    }
}

정리

  • @Configuration은 CGLIB 프록시를 생성하여 @Bean 메서드 간 호출 시 싱글톤을 보장합니다 (Full 모드)
  • proxyBeanMethods = false로 설정하면 CGLIB 프록시 없이 동작합니다 (Lite 모드)
  • Lite 모드에서 빈 간 의존관계가 필요하면 메서드 파라미터로 주입받아야 합니다
  • @Component 안의 @Bean은 자동으로 Lite 모드입니다
  • 스프링부트 Auto-Configuration은 성능을 위해 대부분 Lite 모드를 사용합니다
댓글 로딩 중...