들어가며
이번 글에서는 스프링의 또 다른 핵심 기능인 AOP(Aspect-Oriented Programming)를 구현해보고, 이 과정에서 기존 IoC 컨테이너를 어떻게 개선했는지 공유하고자 합니다. AOP 기능의 목표는 앞으로 구현할 선언적 트랜잭션의 기반을 마련하는 것입니다.
AOP를 구현하려면 특정 메서드가 호출될 때 공통 로직(부가 기능)을 실행시켜야 합니다. 스프링에서는 이를 프록시(Proxy) 객체를 통해 해결합니다. 즉, 실제 빈(Bean) 대신 프록시 객체를 IoC 컨테이너에 등록하고, 의존성을 주입할 때도 이 프록시 객체를 주입해야 합니다.
여기서 기존 구현 방식의 한계가 드러났습니다.
기존 방식은 @Component가 붙은 클래스를 모두 스캔한 뒤, (1) 생성자 주입이 필요 없는 빈들을 먼저 전부 인스턴스화하고, (2) 이후 필드/세터 주입을 하고, (3) 마지막으로 위상 정렬을 통해 생성자 주입이 필요한 빈들을 생성했습니다. 이 과정에서 빈 생성 로직이 분산되어 있어서, 프록시 객체를 생성하는 로직을 추가하기 힘들었습니다.
이 문제를 해결하기 위해, 저는 스프링이 실제로 동작하는 방식과 유사한 재귀적(Recursive) 빈 생성 방식으로 IoC 컨테이너의 초기화 로직을 전면 리팩토링하기로 결정했습니다. 이 방식은 빈 생성을 요청받으면, 해당 빈이 의존하는 다른 빈들을 먼저 생성하고, 그 결과를 바탕으로 현재 빈을 완성해나가는 통합된 구조입니다.
🏮 CGLIB: JDK17 과의 호환성
스프링은 AOP를 구현할 때, 대상 객체가 인터페이스를 구현했다면 JDK Dynamic Proxy를, 그렇지 않은 일반 클래스라면 CGLIB를 사용합니다. 저는 일반 클래스에 AOP를 적용하는 것을 목표로 했기에 CGLIB를 선택했습니다.
하지만 CGLIB를 도입하는 과정에서 예상치 못한 난관에 부딪혔습니다.
1. 오래된 라이브러리: CGLIB는 3.3.0 버전 이후로 사실상 유지보수가 중단된 상태였습니다. GitHub 저장소에서는 JDK 17+ 환경에서 호환성 이슈가 발생할 수 있으니 Byte Buddy 사용을 권장하고 있었습니다.
2. 실제 발생한 에러: 아니나 다를까, 제 개발 환경(JDK 17)에서 CGLIB를 사용해 프록시 객체를 생성하려고 하니 InaccessibleObjectException이 발생했습니다.
다행히 이 문제는 cglib 이슈 트래커에서 해답을 찾을 수 있었습니다. VM 옵션에 --add-opens java.base/java.lang=ALL-UNNAMED를 추가하여 임시로 해결했습니다.
문득 '그럼 스프링은 어떻게 이 문제를 해결했을까?' 궁금해졌습니다. 확인해보니, 스프링은 CGLIB를 외부 라이브러리로 사용하는 것이 아니라, 내부적으로 코드를 fork하여 직접 커스텀해서 사용하고 있었습니다. 관련 커밋 내역을 보니, JDK 17을 정식 지원하기 위한 작업의 일환으로 내부 CGLIB 코드의 호환성 문제까지 모두 수정한 것을 확인할 수 있었습니다.
환경
- JDK17
- CGLIB 3.3.0

🍝 프록시 기반 AOP 구현
먼저, 메서드 호출을 가로채 공통 로직을 실행할 MethodInterceptor를 구현합니다. 지금은 간단히 메서드 실행 전후로 로그만 출력하도록 만들었습니다.
public class TransactionalInterceptor implements MethodInterceptor {
@Override
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
System.out.println("before----");
// 프록시를 통해 원본 객체의 메서드를 호출
Object invoke = proxy.invokeSuper(obj, args);
System.out.println("after----");
return invoke;
}
}
다음은 이 인터셉터를 사용하여 프록시 객체를 생성해주는 ProxyFactory입니다.
// ProxyFactory.java
public class ProxyFactory {
public static <T> T createProxy(Class<T> targetClass, MethodInterceptor interceptor, Object... constructorArgs) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(targetClass);
enhancer.setCallback(interceptor);
// 기본 생성자로 생성하는 경우
if(constructorArgs == null || constructorArgs.length == 0) {
return (T) enhancer.create();
}
// 특정 생성자로 생성하는 경우
Class<?>[] argTypes = Arrays.stream(constructorArgs)
.map(Object::getClass)
.toArray(Class[]::new);
return (T) enhancer.create(argTypes, constructorArgs);
}
}
여기서 중요한 점은, CGLIB는 이미 생성된 인스턴스를 감싸는 방식(데코레이터 패턴)이 아니라, 대상 클래스를 상속받는 새로운 프록시 클래스를 동적으로 만들어낸다는 것입니다. 따라서 프록시 객체가 필요하다면, 처음부터 프록시 형태로 인스턴스를 생성해야 합니다. 이 지점이 바로 IoC 컨테이너의 리팩토링이 필수적이었던 이유입니다.
💇 변경된 IoC 컨테이너: 재귀적 빈 생성 로직
IoC 컨테이너 초기화 로직입니다. 모든 복잡했던 과정이 하나의 진입점으로 통합되었습니다.
// SpringContextInitiator.java
public void init(Set<Class<?>> scannedComponents) {
for (Class<?> clazz : scannedComponents) {
// 모든 컴포넌트에 대해 getBean을 호출하여 빈 생성을 트리거
getBean(clazz);
}
}
getBean
가장 핵심적인 역할을 하는 getBean 메서드입니다. 이 메서드는 빈의 생성과 조립을 책임지는 중앙 처리 장치와 같습니다.
// SpringContextInitiator.java
public <T> T getBean(Class<T> clazz) {
// 1. 이미 컨테이너에 등록된 빈이라면 즉시 반환 (재귀의 탈출 조건)
if(SpringContext.BEAN_MAP.containsKey(clazz)) {
return (T) SpringContext.BEAN_MAP.get(clazz);
}
T beanInstance = null;
try {
// 2. 빈 인스턴스 생성 (의존성 해결 및 프록시 생성 포함)
beanInstance = createBean(clazz);
// 3. 생성된 인스턴스에 필드 및 세터 주입 수행
checkFieldInjection(clazz, beanInstance);
checkSetterInjection(clazz, beanInstance);
} catch (Exception e) {
System.err.println("Could not instantiate bean: " + clazz.getName());
}
// 4. 완성된 빈을 컨테이너에 등록
SpringContext.BEAN_MAP.put(clazz, beanInstance);
return beanInstance;
}
getBean의 흐름은 다음과 같습니다.
- 컨테이너 확인: 먼저 빈이 이미
BEAN_MAP에 존재하는지 확인합니다. 존재한다면, 재귀 호출을 멈추고 해당 인스턴스를 반환합니다. 이것이 재귀의 가장 중요한 탈출 조건입니다. - 빈 생성: 캐시에 없다면
createBean메서드를 호출하여 인스턴스화를 시작합니다. - 의존성 주입: 인스턴스가 생성되면, 필드 및 세터 주입을 마저 처리합니다.
- 컨테이너 등록: 모든 준비가 끝난 빈을
BEAN_MAP에 등록하고 반환합니다.
createBean
이제 빈 생성의 실질적인 로직을 담고 있는 createBean 메서드를 살펴보겠습니다.
private <T> T createBean(Class<T> clazz) throws Exception {
boolean isConstructorInjection = Arrays.stream(clazz.getConstructors())
.anyMatch(c -> c.isAnnotationPresent(AutoWire.class));
// AOP 적용 대상인지 확인
boolean isProxy = clazz.isAnnotationPresent(Aspect.class);
TransactionalInterceptor interceptor = new TransactionalInterceptor();
// Case 1: 생성자 주입이 없는 경우
if(!isConstructorInjection) {
if(isProxy) {
return ProxyFactory.createProxy(clazz, interceptor, new Object[0]);
}
return clazz.getDeclaredConstructor().newInstance();
}
// Case 2: 생성자 주입이 있는 경우
Constructor<?> constructor = Arrays.stream(clazz.getConstructors())
.filter(c -> c.isAnnotationPresent(AutoWire.class))
.findFirst()
.orElseThrow(() -> new ContextInitializeException("No AutoWire constructor found for " + clazz.getName()));
Class<?>[] parameterTypes = constructor.getParameterTypes();
Object[] injectInstances = new Object[parameterTypes.length];
// 생성자 파라미터에 필요한 의존성을 재귀적으로 요청
for(int i = 0; i < parameterTypes.length; i++) {
injectInstances[i] = getBean(parameterTypes[i]);
}
// AOP 대상이면 프록시 객체 생성
if(isProxy) {
return ProxyFactory.createProxy(clazz, interceptor, injectInstances);
}
// 아니면 일반 객체 생성
Object injectedInstance = constructor.newInstance(injectInstances);
return (T) injectedInstance;
}
createBean 메서드는 생성자 주입 여부와 프록시 적용 여부에 따라 분기하여 적절한 방식으로 객체를 생성합니다. 여기서 가장 중요한 부분은 생성자 파라미터의 의존성을 해결하기 위해 다시 getBean을 호출하는 부분입니다. 이 재귀적 호출을 통해 A -> B -> C와 같은 복잡한 의존성 체인이 자연스럽게 해결됩니다.
마무리
선언적 트랜잭션 구현을 위한 준비 단계로 AOP를 도입하고, 이 과정에서 IoC 컨테이너를 대대적으로 리팩토링했습니다. 위상 정렬을 사용했던 분리된 초기화 방식에서, 스프링과 유사한 통합된 재귀적 생성 방식으로 전환하며 다음과 같은 것들을 얻을 수 있었습니다.
- 견고한 설계: 빈 생성 로직이
getBean으로 통합되어 코드가 더 단순해지고 확장성이 좋아졌습니다. - AOP 적용: 프록시 객체를 빈 생성 시점에 자연스럽게 만들어 컨테이너에 등록할 수 있게 되었습니다.
- 프레임워크에 대한 깊은 이해: 스프링이 왜 재귀적인 방식으로 빈을 생성하는지에 대한 이유를 몸소 체감할 수 있었습니다.
물론 현재 구현은 실제 스프링처럼 정교한 Pointcut을 처리하거나 순환 참조 문제를 완벽하게 해결하지는 못합니다. 하지만 프록시 객체가 IoC 컨테이너와 어떻게 상호작용하는지에 대한 핵심 원리를 파악한 것만으로도 큰 수확이라고 생각합니다.
다음 글에서는 오늘 만든 AOP 인프라를 바탕으로 선언적 트랜잭션 방식이 실제로 동작하도록 만드는 과정을 보여드리겠습니다. 긴 글 읽어주셔서 감사합니다.
'프로젝트 > 스프링 직접 구현하기' 카테고리의 다른 글
| 스프링 직접 구현하기 #5: 톰캣 - I/O Multiplexing 구현 (0) | 2025.06.04 |
|---|---|
| 스프링 직접 구현하기 #4: DispatcherServlet v2, 그리고 실제 스프링과의 비교 (0) | 2025.05.25 |
| 스프링 직접 구현하기 #3: DispatcherServlet 구현 (0) | 2025.05.16 |
| 스프링 직접 구현하기 #2: 생성자 주입 (0) | 2025.05.10 |
| 스프링 직접 구현하기 #1: IoC와 DI 구현 (0) | 2025.05.10 |