@Component는 어떻게 Spring Bean으로 등록될까
@Component(혹은 @Service, @Repository 등)를 붙이면 Spring이 알아서 객체를 만들어 관리해 준다. 이 "알아서"의 내부 과정을 SpringApplication.run()부터 싱글톤 캐시에 올라가기까지 단계별로 따라가 본다.
본격적으로 들어가기 전에 Bean이 무엇인지부터 짚고 시작하자.
1. Bean이란?
Spring에서는 객체를 직접 new 해서 쓰지 않는다.
MemberService memberService = new MemberService();
이런 코드를 개발자 대신 Spring이 수행하고, 생명주기까지 관리한다. 이렇게 Spring 컨테이너가 생성·관리하는 객체를 Bean이라고 부른다.
2. 등록 과정의 시작점
@SpringBootApplication
public class App {
public static void main(String[] args) {
SpringApplication.run(App.class, args);
}
}
이 코드가 실행되면 SpringApplication.run()이 호출되고, Spring Boot는 내부적으로 ApplicationContext 를 생성한다. 이 ApplicationContext가 컨테이너의 핵심이며, 다음 역할을 담당한다.
- Bean 생성
- Bean 저장
- Bean 조회
- DI(의존성 주입) 수행
- 생명주기 관리
3. @SpringBootApplication 뜯어보기
@SpringBootApplication은 사실 아래 세 어노테이션을 합쳐 놓은 것이다.
@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan
이 중 Bean 등록과 직접 관련된 것은 @ComponentScan 이다.
4. @ComponentScan — 후보 탐색
컴포넌트 스캔은 @ComponentScan이 붙은 클래스의 패키지를 기준으로, 그 하위 패키지의 클래스들을 전부 탐색한다.
이때 ClassPath를 스캔하는데, 핵심은 클래스를 로딩하지 않는다는 점이다. 실제 클래스 파일(.class)을 읽되 ASM으로 바이트코드를 파싱해서 어노테이션 정보만 빠르게 확인한다. JVM에 클래스를 적재(load)하지 않고 "읽기만" 하기 때문에 가볍고 빠르다.
이렇게 스캔하면서 @Component가 붙은 후보들을 찾아낸다. 대표적으로 다음이 있다.
- @Service
- @Repository
- @Controller
- @RestController
이들을 한 번에 잡아낼 수 있는 이유는, 이 어노테이션들이 내부적으로 @Component를 메타 어노테이션으로 달고 있기 때문이다.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Component // ← 이 메타 어노테이션 덕분에 스캔 대상이 된다
public @interface Service {
// 실제로는 @AliasFor("value") 등이 더 있지만 핵심은 @Component
}
5. BeanDefinition 생성 — 객체가 아니라 "설계도"
후보를 찾았다고 바로 객체를 만들지는 않는다. 먼저 BeanDefinition 을 만든다. BeanDefinition은 Bean의 설계도라고 보면 된다. 담기는 정보는 대략 다음과 같다.
| 클래스 이름 | 어떤 클래스로 만들지 |
| Scope | Singleton / Prototype 등 |
| 생성 방식 | 생성자 / 팩토리 메서드 등 |
| 생성자 정보 | 어떤 생성자를 쓸지 |
| Lazy 여부 | 지연 초기화 대상인지 |
| Primary 여부 | 동일 타입 충돌 시 우선순위 |
6. BeanDefinitionRegistry 등록
만들어진 BeanDefinition은 BeanDefinitionRegistry 에 저장된다.
memberService → BeanDefinition(MemberService)
즉 "이 클래스를 나중에 Bean으로 만들 예정"이라고 예약해 둔 상태다. 이때 Bean 이름은 기본적으로 클래스명의 첫 글자를 소문자로 바꾼 형태가 된다.
MemberService → memberService
참고: URLParser처럼 앞 두 글자가 모두 대문자인 경우엔 그대로 URLParser로 유지된다. (Introspector.decapitalize() 규칙)
7. BeanFactory가 객체를 생성한다
지금까지(4~6번)는 "설계도 등록" 단계였고, 이제부터 실제 객체가 만들어진다. 컨테이너 초기화 흐름은 다음과 같다.
refresh()
├─ invokeBeanFactoryPostProcessors()
│ └─ ConfigurationClassPostProcessor ← 여기서 @ComponentScan 실행 (4~6번)
│
└─ finishBeanFactoryInitialization()
└─ preInstantiateSingletons() ← 여기서 객체 생성 (7~11번)
preInstantiateSingletons()에서 싱글톤 Bean 생성이 시작된다.
8. 생성자 호출 (+ 생성자 주입)
생성자에 필요한 파라미터를 분석하고, 해당 Bean들을 찾아 주입하면서 객체를 만든다.
// 개념적으로는
new MemberService(memberRepository);
// 실제로는 Reflection 사용
Constructor.newInstance(찾은_Bean들);
여기서 생성자 주입은 객체 생성 시점에 함께 이루어진다. 이 부분도 AutowiredAnnotationBeanPostProcessor가 쓸 생성자를 골라준다(determineCandidateConstructors). 여기까지 오면 객체 생성 자체는 완료된다.
9. 의존성 주입 (필드 / setter)
객체가 만들어진 뒤, AutowiredAnnotationBeanPostProcessor 가 그 Bean을 훑어서 @Autowired가 붙은 필드나 setter에 필요한 Bean을 찾아 주입한다.
그런데 필드 주입은 안티패턴으로 분류되며, 생성자 주입이 권장된다. 이유는 다음과 같다.
① final을 못 붙인다 → 불변성이 깨진다
필드 주입은 객체를 만든 뒤에 값을 꽂는 방식이라 final을 쓸 수 없다. 생성자 주입은 final로 선언해서, 한 번 주입되면 바꾸지 못하게 막을 수 있다.
② 테스트 시 객체를 순수하게 못 만든다
MemberService s = new MemberService(); // repo가 null → NPE
필드 주입은 생성자에 의존성이 없으니, 순수하게 new로 만들면 필드가 비어 있다. 테스트하려면 Spring 컨테이너를 띄우거나 Reflection을 써야 한다. 생성자 주입이면 new MemberService(mockRepo) 한 줄로 끝난다.
③ 순환참조를 (조용히) 허용할 수 있다
A↔B 순환참조가 있을 때, 생성자 주입은 시작 시점에 무조건 에러로 터뜨려서 "설계가 잘못됐다"고 바로 알려준다.
⚠️ 버전 주의: 흔히 "필드 주입은 순환참조가 있어도 에러 없이 굴러간다"고 설명하는데, 이는 Spring Boot 2.6 미만에서의 이야기다. Spring Boot 2.6 / Spring Framework 5.3부터는 spring.main.allow-circular-references 기본값이 false 라서, 필드 주입이어도 기본 설정에서는 시작 시점에 BeanCurrentlyInCreationException이 발생한다. 옛날처럼 통과시키려면 해당 옵션을 true로 켜거나 @Lazy를 써야 한다. 즉 필드 주입은 "옵션을 켜면 순환참조를 숨길 수 있는" 반면, 생성자 주입은 아예 끌 수조차 없이 항상 막힌다는 점이 차이다.
④ 의존성이 숨겨진다
생성자 주입은 생성자 시그니처만 봐도 이 클래스가 무엇을 필요로 하는지 한눈에 드러난다. 필드 주입은 클래스 여기저기 흩어진 @Autowired를 전부 찾아봐야 한다.
10. 초기화 후처리 (@PostConstruct → AOP Proxy)
DI가 끝난 뒤 초기화 콜백과 프록시 처리가 이어진다.
@Autowired 주입 끝
→ @PostConstruct 실행 (postProcessBeforeInitialization 시점)
→ (InitializingBean.afterPropertiesSet / init-method)
→ AOP Proxy 생성 (postProcessAfterInitialization 시점)
- @PostConstruct : "주입이 다 끝났으니 이제 초기화 로직을 돌려라"는 콜백.
- AOP Proxy : @Transactional, @Async 같은 게 걸려 있으면 원본 객체를 프록시로 감싸서 바꿔치기한다. 초기화가 끝난 다음에 감싸진다는 게 포인트.
11. Singleton Cache 저장
완성된 Bean은 1차 캐시에 들어간다. DefaultSingletonBeanRegistry의 singletonObjects인데, ConcurrentHashMap<String, Object> 구조다.
// Spring 내부 소스
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);
singletonObjects: { "memberService" → MemberService 객체 }
이제 getBean("memberService")를 호출하면 매번 새로 만드는 게 아니라 이 캐시에서 꺼내준다. 그래서 싱글톤이 "딱 하나"로 유지되는 것이다.
MemberService service = context.getBean(MemberService.class); // 캐시에서 반환
참고: 싱글톤 3단계 캐시 사실 DefaultSingletonBeanRegistry에는 캐시가 세 개 있고, 이 구조가 9번에서 본 순환참조 해소의 핵심이다.
- 1차 singletonObjects : 완성된 Bean
- 2차 earlySingletonObjects : 생성은 됐지만 아직 미완성인(주입 진행 중) Bean
- 3차 singletonFactories : 미완성 Bean을 반환할 수 있는 ObjectFactory
생성 도중인 Bean을 3차 → 2차로 "조기 노출(early exposure)"해서 순환참조를 풀어내는 구조다. (단, 10번에서 본 것처럼 기본 설정에서는 순환참조 자체가 막혀 있다.)
정리
전체 흐름을 한 줄로 요약하면 이렇다.
@ComponentScan (ASM 스캔)
→ 후보 발견
→ BeanDefinition 생성
→ BeanDefinitionRegistry 등록
→ preInstantiateSingletons()
→ 생성자 호출 (+ 생성자 주입)
→ 필드/setter 주입
→ @PostConstruct → AOP Proxy
→ singletonObjects(1차 캐시) 저장
@Component 하나가 컨테이너 안에서 거치는 길이 생각보다 길다. 특히 "BeanDefinition 등록 단계"와 "실제 객체 생성 단계"가 분리되어 있다는 점, 그리고 생성자 주입과 필드 주입의 시점 차이를 이해하면 DI 관련 동작 대부분이 설명된다.
'Spring' 카테고리의 다른 글
| Redis와 사용법 (0) | 2025.06.29 |
|---|---|
| OAuth2.0 google, kakao, naver (1) | 2024.12.02 |
| [오르미 백엔드7기] (Spring Security , Cors,CSRF) (1) | 2024.11.29 |
| [오르미 백엔드7기](Spring 프록시객체, 지연로딩 ,즉시로딩, 영속성전이, N+1문제) (1) | 2024.11.25 |
| [오르미 백엔드 7기] (Spring Entity 개념, 연관관계, 공통속성정의) (0) | 2024.11.25 |