1. 제어의 역전 (IoC: Inversion of Control)
내가 만들지 않고, 스프링이 대신 만들고 관리한다.
- 기존 방식 (제어권이 개발자에게 있음): 개발자가 필요한 객체를 직접 생성(new Service())하고 다른 객체와의 의존관계를 직접 주입함(강한 결합). 프로그램의 흐름을 개발자가 완전히 제어하는 형태 (초기화 까지)
- 역전된 방식 (IoC): 내가 객체를 직접 만들지 않고, 스프링 컨테이너(프레임 워크)라는 제3자에게 프로그램의 제어권을 넘겨주는 것. 즉, IoC가 적용되면, 컨테이너가 객체를 언제 생성하고, 언제 소멸시키며, 어떤 객체와 연결해 줄지를 알아서 결정.
- 동작 방식: 개발자가 구성 메타데이터(Configuration Metadata)를 작성해 주면, 컨테이너가 이를 명세서로 삼아 객체를 생성하고 조립.
- 과거에는 XML 기반 설정을 썼으나, 현대에는 Java 기반 설정(@Configuration, @Bean) 및 컴포넌트 스캔을 주로 사용.
- 핵심 인터페이스: org.springframework.context 패키지의 ApplicationContext가 대표적인 IoC 컨테이너 구현체. (내부적으로 상위 인터페이스인 BeanFactory를 확장함)
- 동작 방식: 개발자가 구성 메타데이터(Configuration Metadata)를 작성해 주면, 컨테이너가 이를 명세서로 삼아 객체를 생성하고 조립.
- 핵심 이점: 개발자는 비즈니스 로직에만 집중할 수 있으며, 객체 간의 결합도가 낮아져(POJO) 유연한 코드를 작성할 수 있음.
2. 의존관계 주입 (DI: Dependency Injection)
내가 넣는 게 아닌, 필요한 객체를 외부(스프링)에서 넣어준다.
- 개념: IoC라는 개념을 실제 구현하는 방법. 어떤 객체(A)가 작동하기 위해 다른 객체(B)를 필요로 할 때, 이를 "A가 B에 의존한다"는 의미. 즉, A가 직접 B를 생성하는 것이 아니라, 스프링 컨테이너가 외부에서 생성된 B 객체를 A에게 주입(전달)해 주는 방식.
- 개발자는 어떤 의존성이 필요하다는 코드를 작성할 뿐, 실제 메서드나 생성자를 호출하며 파라미터(아규먼트)를 전달하여 주입하는 주체는 IoC 컨테이너. (느슨한 결합)
- 주입 방법: 주로 생성자 주입, 수정자 주입(Setter), 필드 주입 등이 있으며, 스프링에서는 안정성이 높은 생성자 주입을 주로 권장.
- @Autowired, 롬복의 @RequiredArgsConstructor가 바로 이 생성자 주입을 편하게 쓰기 위한 도구들
- 핵심 이점: 객체 간의 결합도를 낮추기 위해서. 코드의 유연성이 높아져 부품을 갈아끼우듯 유지보수하기 쉬워지고, 테스트 코드 작성이 수월해짐.
3. 스프링 컨테이너 (Spring Container)
1) Spring IoC Container (ApplicationContext)
- 정의 (공식문서): "ApplicationContext(IoC 컨테이너)는 애플리케이션의 핵심을 형성하는 빈(Beans)을 생성하고, 구성하고, 조립하는 책임을 집니다. 그리고 이 빈들은 여러분이 컨테이너에 공급한 구성 메타데이터와 결합된 POJO들입니다."
- IoC와 DI가 일어나는 공간이자 관리자. ApplicationContext라는 인터페이스를 스프링 컨테이너라고 부름
- 스프링 빈(Bean): 스프링 컨테이너가 생성하고 관리하는 자바 객체를 의미. POJO(순수 자바 객체)가 컨테이너에 들어와 생명주기를 부여받으면 빈이 됨.
- 하는 일: @Component, @Bean 등이 붙은 클래스를 인식하여 객체로 생성하고, 필요한 의존관계를 주입(DI)하며, 전체적인 생명주기를 관리.
- 스프링 컨테이너라는 거대한 '객체 관리용 지도(Map 형태의 객체)' 역시 힙 영역에 생성됨. @Bean으로 등록한 객체들은 이 스프링 컨테이너(Map)가 절대 놓아주지 않음. 덕분에 애플리케이션이 종료될 때까지 가비지 컬렉터에게 잡혀가지 않고 안전하게 살아남을 수 있는 것.
2) 싱글톤 컨테이너 (Singleton Container)
- 개념(관리 방식): 스프링 컨테이너는 기본적으로 객체(Bean)를 싱글톤(Singleton)으로 관리한다.
- 디자인 패턴의 싱글톤과 다르게, JVM 전체에서 유일한 객체가 아니라, 스프링 컨테이너당 딱 하나의 객체만 생성되어 관리되는 방식 (컨테이너 구동 시점에 생)
- 스프링은 내부적으로 ConcurrentHashMap 같은 레지스트리에 생성된 빈을 저장하고, 요청(@Autowired나 getBean())이 올 때마다 동일한 인스턴스를 반환
- 싱글톤 패턴이란? 애플리케이션 내에서 특정 클래스의 인스턴스를 딱 하나만 생성해서 공유하는 디자인 패턴
- 왜 스프링은 싱글톤으로 만들까? 웹 애플리케이션은 수많은 고객이 동시에 요청을 보내는 데, 요청이 올 때마다 객체를 새로 만들면(new) 메모리 낭비와 서버 부하가 오기 때문.
- 스프링의 해결책 (아무리 많이 요청해도 한 개의 객체만 돌려씀): 스프링 컨테이너는 내부적으로 객체(Bean)를 단 하나만 생성해 두고, 요청이 올 때마다 공유해서 재사용함 개발자가 복잡하게 싱글톤 코드를 짜지 않아도, 컨테이너가 알아서 싱글톤으로 안전하게 관리해 줌.
- ⚠️ 주의할 점 (Stateful 금지): 여러 사용자가 하나의 객체를 공유(여러 쓰레드가 동시에 이 객체에 접근)하므로, 싱글톤 빈은 절대 내부에 상태(변경 가능한 필드 변수)를 가지게 설계하면 안 됨. 무조건 무상태(Stateless)로 설계해야 동시성 문제가 터지지 않음.
💡@Configuration과 CGLIB 프록시의 원리
1) 핵심 요약
@Configuration 클래스 내부에서 @Bean 메서드 간에 서로를 호출(메서드간 참조)하더라도, 순수 자바 코드처럼 매번 새로운 인스턴스를 생성하지 않음. 스프링은 싱글톤(Singleton)을 보장하기 위해 컨테이 내부적으로 CGLIB(바이트코드 조작 라이브러리)을 사용하여 프록시(자식 클래스)를 만들어 냄.
- 메서드 간 참조의 리디렉션: memoryRepository() 같은 빈 생성 메서드를 직접 호출하더라도, 실행 흐름이 단순히 해당 자바 메서드로 점프하지 않고 스프링 컨테이너의 라이프사이클 관리로 리디렉션됨.
- 프록시 객체의 역할: 컨테이너는 기존 클래스를 상속받은 가짜 프록시 클래스(AppConfig$$SpringCGLIB)를 런타임에 바이트코드 조작으로 생성하고 이를 컨테이너에 등록함.
- 개발자가 만든 원본 클래스는 빈으로 등록되지 않음.
- 실제 비즈니스 로직(코드)는 결국 개발자가 작성한 원본 클래스(인스턴스)에만 들어있기 때문에 결국 최종 호출은 원본 객체에게 돌아와야함 (아래 인터셉트 동작 참고)
- 사용 이유(인터셉트): @Bean 메서드가 여러 번 호출되더라도, 가짜 클래스(Proxy)가 가로채서 항상 동일한 하나의 객체(Singleton)만 반환되도록 보장하기 위함. (AOP와 같은 공통 관심사 분리에도 이 프록시 기술이 핵심으로 쓰임)
2) *ApplicationContext에 AppConfig.class를 전달하는 이유 (아래 추가 설명)
인자로 넘기는 것 자체가 CGLIB만을 위한 목적은 아님
- 컨테이너는 클래스 정보(.class)를 분석하여 내부에 어떤 @Bean 메서드가 있는지, 어떤 의존성이 필요한지 파악.
- 그다음에 싱글톤을 보장하기 위해 이 정보를 기반으로 런타임에 부모 클래스를 상속하는 자식 클래스(AppConfig$$SpringCGLIB)를 만듦. (CGLIB으로 자식 클래스를 조작)
3) CGLIB 내부 동작 가상 코드 (인터셉트 개념)
- 컨테이너는 intercept() 메서드를 통해 아래와 같은 로직으로 싱글톤을 유지. (CGLIB 라이브러리의 핵심 인터페이스인 MethodInterceptor에 정의된 메서드)
- 가짜 프록시 객체(CGLIB)의 내부 intercept() 실행: 이미 컨테이너에 등록된 빈인가에 대해 검증
- 처음 빈에 등생성하는 거라면 ➡️ 원본 메서드를 깨워야 함
public MemoryRepository memoryRepository() {
if (스프링 컨테이너에 이미 MemoryRepository 빈이 등록되어 있다면?) {
return 컨테이너에서 기존에 있던 빈을 찾아서 반환; // 싱글톤 유지
} else {
return 원래 순수 자바 코드대로 new MemoryRepository()를 호출해서 반환; // 최초 1회 생성
}
}
3) ✨스프링 컨테이너 생성자 입력 매개변수 원리
ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
- 핵심 질문: 스프링을 실행할 때 위와 같이 컨테이너를 생성하는 데, 왜 new AppConfig() 인스턴스를 넘기지 않고 AppConfig.class를 넘길까?
- 이유: 컨테이너에게 필요한 것은 이미 만들어진 단순한 객체(인스턴스)가 아니라, 클래스의 메타데이터(구조 정보)이기 때문.
- 원리: .class 키워드를 넘기면 자바 리플렉션(Reflection) 정보가 전달됨. 컨테이너는 이 클래스 정보를 분석(파싱)하여 내부에 어떤 @Bean 메서드가 있는지(어떤 의존성이 필요한지) 파악한 후, 제어권을 가져가 직접 객체를 생성.
- AppConfig.class라는 자바 키워드를 사용하면, 컴파일러가 Class<AppConfig>라는 클래스의 정보를 담은 객체(.class 리플렉션 정보)로 변환하여 전달.
ℹ️ 동적 호출 (Dynamic Invocation)
코드 상에 명시적으로 호출하도록 작성하지 않았으나, 런타임 시점에 자바 리플렉션 기술 등을 활용해 동적으로 클래스를 로드하고 메서드를 호출하는 방식.
💡 JVM의 클래스 로딩과 리플렉션(Reflection) API
스프링이 런타임에 상속 구조의 자식 클래스를 만들고 메타데이터를 분석할 수 있는 것은 JVM의 클래스 로딩 및 리플렉션 메커니즘 덕분.
- 클래스 로딩: 애플리케이션 로더에 의해 A.class 파일이 JVM에 로드.
- 메타데이터 생성: JVM이 클래스를 파싱하여 구조(부모 클래스, 패키지, 어노테이션, 메서드, 필드 등)를 읽고, 리플렉션 API에 의해 Class<A> 타입의 인스턴스를 생성.
─ 클래스 정보를 얻는 방법
- A.class: 컴파일 시점에 클래스가 결정될 때 사용.
- getClass(): 실행 시점(런타임)의 실제 타입을 얻을 때 사용.
- Class.forName("클래스 이름"): 문자열 이름을 통해 동적으로 로딩할 때 사용.
- 이 외에도 getSuperclass(), getMethods() 등을 통해 클래스 구조를 추적한다.
4. 스프링 컨테이너의 핵심 계층 구조
스프링 IoC 컨테이너는 대략 다음과 같은 구조로 인터페이스와 구현체가 나뉜다.
- BeanFactory (최상위 인터페이스)
- 스프링 빈을 관리하고 조회하는 가장 기본적인 컨테이너 기능(메서드)을 정의해 둔 인터페이스.
- ApplicationContext (인터페이스)
- BeanFactory를 상속받은 인터페이스. 빈 관리 기능 외에도 국제화(MessageSource), 이벤트 게시, 리소스 로딩 등 실제 애플리케이션을 만들 때 필요한 부가 기능들을 추가로 정의해 둠. 보통 '스프링 컨테이너'라고 부르는 대상이 바로 이 인터페이스.
- AnnotationConfigApplicationContext (구현 클래스)
- ApplicationContext 인터페이스를 실제로 구현한 구체적인 자바 클래스(구현체)
- 이름 그대로 어노테이션(@Configuration, @Bean 등) 기반의 자바 설정 클래스를 읽어서 빈을 생성하고 의존관계를 주입하는 역할을 직접 수행.
[BeanFactory] (최상위 인터페이스: 기본적인 빈 관리/조회)
↓
└── [ApplicationContext] (인터페이스: BeanFactory 상속 + 부가 기능 확장)
↓
└── [AnnotationConfigApplicationContext] (구체 클래스: 자바 설정 기반 실제 구현체)
5. 스프링 컨테이너와 설정 메타데이터
스프링 프레임워크의 핵심은 IoC(제어의 역전)이며, 이를 구현한 주체가 ApplicationContext(컨테이너)이다. 컨테이너가 객체를 생성하고 관리하려면 개발자가 설정 메타데이터(Configuration Metadata)를 제공해야 한다.
설정 메타데이터를 제공하는 2가지 방식 (XML 제외)
- @Configuration ➡️ 수동 빈 등록 (자바 기반 설정 방식)
- 명시적 구성: 어떤 객체가 어떻게 생성되고, 어떤 의존성을 주입받는지 개발자가 코드로 일일이 명시
- 주요 용도: 외부 라이브러리(내가 소스코드를 고칠 수 없는 경우)를 빈으로 등록하거나, 공통 설정(Security, 데이터베이스 커넥션, 싱글톤 패턴 유지 등)을 세밀하게 제어할 때 사용
- @Configuration이 붙은 클래스는 스프링에게 "여기에 생성할 빈(Bean) 정보와 의존성 정보가 있다"라고 알려주는 역할을 한다.
- 개발자가 작성한 클래스를 스프링이 알아서 발견(Scan)하고, 자동으로 스프링 컨테이너에 빈으로 등록하게 만드는 어노테이션.
- 클래스 내부 메서드에 @Bean을 붙이면, 이 메서드는 빈을 생성하는 팩토리 메서드가 됨. 스프링 컨테이너가 이 메서드를 호출하여 반환된 객체를 빈으로 등록하고 관리.
- @Component ➡️ 자동 빈 등록 (컴포넌트 스캔 방식)
- 선언적 구성: 어노테이션만 달면 스프링이 런타임에 알아서 수집해 감
- 주요 용도: 비즈니스 로직을 담은 클래스들(@Service, @Repository, @Controller 등)에 주로 사용
- 빈으로 등록되길 원하는 클래스 자체에 @Component 어노테이션을 붙여서 스프링이 자동으로 찾아내어(Component Scan) 관리.
6. 컨텍스트 내부 생성 과정 분석
AnnotationConfigApplicationContext 생성자 코드는 스프링 컨테이너가 내부적으로 초기화 되는 핵심 3단계를 보여준다.
1) 각 단계별 상세 동작
- this(): 내부적으로 빈을 읽어들일 AnnotatedBeanDefinitionReader와 ClassPathBeanDefinitionScanner를 준비.
- register(componentClasses):
- 파라미터로 들어온 클래스 배열이 비어있지 않은지 검증(Assert.notEmpty).
- 스프링 부트의 모니터링 시스템(StartupStep)에 등록 과정을 기록함.
- this.reader.register(...)를 통해 전달받은 설정 클래스(AppConfig.class)의 정보를 컨테이너의 빈 정의서(Bean Definition)에 등록.
- refresh(): 등록된 메타데이터를 바탕으로 실제 빈 객체들을 인스턴스화하고, 의존성을 주입(DI)하며 컨테이너를 완전히 활성화하는 가장 중요한 단계. (이 과정에서 콜스택이 매우 깊어짐.)
public AnnotationConfigApplicationContext(Class<?>... componentClasses) {
this(); // 1단계: 기본 생성자 호출 (reader, scanner 초기화)
register(componentClasses); // 2단계: 설정 클래스(AppConfig 등) 등록
refresh(); // 3단계: 컨테이너 새로고침 (빈 생성 및 의존성 주입 완료)
}
2) 스프링 생성자 기반 DI 과정과 컨테이너 초기화 (refresh() 내부의 세부 동작)
스프링 ApplicationContext(내부적으로 핵심 구현체인 DefaultListableBeanFactory를 필드로 가짐)가 구동되며 빈을 생성하고 의존성을 주입하는 전체 흐름.
- 구성 메타데이터 로드: 개발자가 작성한 @Configuration, @Component, XML 설정을 컨테이너가 인식.
- BeanDefinition 생성: 메타데이터를 바탕으로 클래스 타입, 스코프, 생성자 아규먼트 등의 정보를 담은 빈 메타데이터(BeanDefinition)로 변환.
- ApplicationContext 초기화: 빈을 생성하기 전 컨테이너 자체를 준비. (BeanFactoryPostProcessor가 구동되는 시점)
- 생성자 Dependency Injection 수행: 생성자의 아규먼트를 분석하고, @Autowired나 자카르타 엔터프라이즈 표준인 @Inject 등의 의존성 매핑을 확인.
- 원리: 스프링이 컨테이너에서 알맞은 의존성(빈 참조)을 찾아 자바 리플렉션(Reflection) API에 전달하여 실제 힙(Heap) 메모리에 객체를 생성.
- 주의: 기본형 타입(int, String 등)은 일반적인 빈이 될 수 없으므로 @Value를 통해 외부 설정 값을 주입받아야 함.
- 빈 인스턴스 생성 완료: 필수 의존성이 모두 채워진 상태가 됨.
- 초기화 콜백 수행: 객체 생성 직후 개발자가 추가 세팅을 할 수 있도록 @PostConstruct나 InitializingBean 인터페이스의 콜백을 수행.
- 빈 리턴 및 구동 완료: 부트스트랩(초기화 작업)이 끝나면 AbstractApplicationContext 등을 통해 완성된 빈을 컨테이너에서 꺼내 사용할 수 있음.
[1. 메타데이터 읽기] -> [2. BeanDefinition 변환] -> [3. 컨테이너 초기화]
↓
[6. 초기화 콜백 수행] <- [5. 인스턴스 생성 완료] <- [4. 생성자 DI 수행 (Reflection)]
↓
[7. 완성된 빈 리턴]
7. 컨테이너 사용법에 대한 주의점
- getBean() 지양: context.getBean()을 직접 호출해 빈을 꺼내는 방식은 스프링 프레임워크에 코드가 종속되므로 지양하는 것이 좋음.
- 스프링 부트의 해결책: 스프링 부트에서는 개발자가 직접 컨테이너를 구동하지 않음 (ApplicationContext 구현체를 생성하고 관리 X). 대신, SpringApplication.run( MainApplication.class, args) 이라는 강력한 내장 메서드가 웹 환경에 특화된 컨텍스트를 알아서 생성하고 자동으로 구동해 줌.
'Tech Stack > Backend' 카테고리의 다른 글
| 🍃간단 Spring Core & 아키텍처의 이해 (0) | 2026.06.19 |
|---|---|
| 🍃빌드 시스템(Maven/Gradle) 이해 및 프로젝트 기본 구조 (0) | 2026.06.19 |
| 🍃어노테이션 기반 수동 빈 등록 (AppConfig) & 스프링 어노테이션(@Autowired) 없이 수동 주입 (0) | 2026.06.18 |
| 🍃스프링 어노테이션(DI/MVC/롬복) 가이드 (참고용) (0) | 2026.06.18 |
| 🍃자동 의존관계 주입 방법과 컴포넌트 스캔 (0) | 2026.06.17 |