톰캣처럼 동작하는 서블릿 기반 WAS를 구현해보면서, 자연스럽게 내부 구조에 대한 의문이 들기 시작했다.
톰캣은 잘 만들어진 소프트웨어고, 실제 현업에서도 수많은 트래픽을 안정적으로 처리한다. 그런데 구조를 따라 만들다 보니, 이런 생각이 들었다:
"톰캣은 내부 의존성을 전부 직접 생성 하면서 관리했을까?"
톰캣은 그렇게 하고 있었다.
서블릿 컨테이너, 디스패처, 매핑 객체들 간 의존성이 필요할 때마다 new로 생성하거나 세터로 주입하며 연결해 나간다.
DI 컨테이너 같은 건 없었다.
그래서 오히려 궁금해졌다.
객체지향이 널리 쓰이기 시작한 뒤, 코드가 클래스 단위로 나뉜다고 해서 자동으로 유지보수가 쉬워지는 건 아니라는 문제가 금방 드러났다. 클래스는 많아졌는데 수정 한 번 하려면 여기저기 같이 바뀌고, 테스트는 어렵고, 상위 모듈이 하위 구현에 강하게 묶여서 변경 비용이 커졌다.
의존성을 어떤 방향으로 둘 것인가
이 맥락에서 정리된 대표적인 기준이 SOLID다.
DI 컨테이너 구현에서 크게 느낀 건 SRP와 DIP였다.
예를 들어 어떤 클래스가 자기 역할도 수행하고, 동시에 필요한 의존 객체를 직접 생성까지 하고 있으면 책임이 섞인다. 그리고 그 생성 코드 안에는 구체 구현이 박혀버린다. 그러면 테스트도 불편하고, 확장도 불편하고, 교체도 어렵다.
SOLID는 규모가 커질수록 생기는 유지보수 문제를 줄이기 위해 나온 실전 규칙에 가깝다고 생각한다.
IoC(Inversion of Control)는 제어의 흐름을 객체 자신이 아니라 외부가 잡는다는 이야기다. 예전에는 클래스가 내부에서 new를 호출해 필요한 객체를 직접 만들었다면, IoC 환경에서는 객체가 필요한 것을 "선언"하고 실제 생성과 연결은 바깥이 담당한다.
DI(Dependency Injection)는 그 IoC를 구현하는 대표적인 방법이다. 필요한 의존성을 생성자나 세터를 통해 주입한다.
예전 방식은 대체로 이런 느낌이다.
public class UserFacade {
private final UserDao userDao = new UserDao();
private final EncryptUtil encryptUtil = new EncryptUtil();
}
DI 방식은 이런 쪽에 가깝다.
public class UserFacade {
private final UserDao userDao;
private final EncryptUtil encryptUtil;
public UserFacade(UserDao userDao, EncryptUtil encryptUtil) {
this.userDao = userDao;
this.encryptUtil = encryptUtil;
}
}
DI의 이점.
톰캣은 어디까지나 Servlet 스펙을 구현한 WAS다.
HTTP 요청을 받아 서블릿을 실행하는 역할에 집중하며, 그 외의 객체 생성이나 의존성 관리는 개발자에게 맡긴다.
- Its lightweight and efficient design makes it ideal for many Java web applications.
- Tomcat servlet containers are meant to take care of multi-threading, http request handling and any other things related to non‑business logic, while you implement a servlet with your business logic to run inside the container.
즉, 아마 이런 철학이었다고 한다.
실제로 Spring MVC도 Tomcat 위에서 돌아간다. 요청을 받는 역할은 Tomcat이 하고, 컨트롤러/서비스/리포지토리 같은 객체의 생성과 wiring은 Spring이 맡는다.
그래서 DI는 처음부터 톰캣의 역할이 아니라고 보는 게 맞긴 하다.
직접 설계하다보니, WAS용 컴포넌트들도 생각보다 많은 의존성을 가지게 됐다.
Dispatcher는 StaticServlet, AppServlet에 의존한다FilterConfig는 여러 필터 객체를 의존한다개발할수록 불편함이 쌓일 것이다.
그래서 결국 이런 생각이 들었다.
이 부분은 Spring을 따라서, 의존성을 주입해보자.
@Singleton이 붙은 클래스는 컨테이너가 하나만 생성한다initialize()로 주요 싱글톤을 미리 올린다핵심 흐름은 아래와 같다.
public static <T> T getInstance(Class<T> clazz) {
if (clazz.isAnnotationPresent(Singleton.class)) {
Object existing = singletonInstances.get(clazz);
if (existing != null && existing != PLACEHOLDER) {
return (T) existing;
}
if (underConstruction.get().putIfAbsent(clazz, true) != null) {
return (T) existing;
}
try {
singletonInstances.put(clazz, PLACEHOLDER);
T instance = createInstance(clazz);
singletonInstances.put(clazz, instance);
return instance;
} finally {
underConstruction.get().remove(clazz);
}
}
return createInstance(clazz);
}
생성 자체는 생성자 기반 주입이다.
private static <T> T createInstance(Class<T> clazz) {
Constructor<?> constructor = clazz.getDeclaredConstructors()[0];
constructor.setAccessible(true);
Object[] dependencies =
Arrays.stream(constructor.getParameterTypes())
.map(DIContainer::getInstance)
.toArray();
return (T) constructor.newInstance(dependencies);
}
초기 구동 시점에는 필요한 싱글톤을 명시적으로 등록한다.
private static void initializeDependencies() {
DIContainer.initialize(
ConnectionManager.class,
FilterConfig.class,
Dispatcher.class
);
}
실제로 Dispatcher 같은 객체는 생성자만 선언해두면 된다.
@Singleton
public class Dispatcher {
private final StaticServlet defaultServlet;
private final AppServlet appServlet;
public Dispatcher(StaticServlet staticServlet, AppServlet appServlet) {
this.defaultServlet = staticServlet;
this.appServlet = appServlet;
}
}
이 구조는 객체가 자기 의존성을 직접 만들지 않아도 되고, 생성자 시그니처만 봐도 어떤 객체가 필요한지 드러난다.
프로젝트가 커질수록 new를 여기저기 마구마구 쓰는 문제는 해결된다.
만들자마자 나타난 문제가 있다.
java.lang.IllegalStateException: Recursive update
초기에는 싱글톤을 computeIfAbsent()로 만들고 있었다.
return (T) singletonInstances.computeIfAbsent(clazz, c -> {
log.info("[DI] 싱글톤 생성 시작: {}", c.getName());
return createInstance(c);
});
어떤 싱글톤을 생성하는 도중 그 싱글톤이 다시 필요해지는 순간이 생길 수 있다. computeIfAbsent()는 이런 재진입 상황을 "같은 key에 대한 중복 갱신"으로 보고 바로 예외를 던진다.
의존성 그래프를 따라 들어가며 객체를 조립하는 컨테이너에는 맞지 않았다.
생성 완료 전 placeholder를 먼저 등록했다.
private static final Object PLACEHOLDER = new Object();
if (clazz.isAnnotationPresent(Singleton.class)) {
Object existing = singletonInstances.get(clazz);
if (existing != null && existing != PLACEHOLDER) {
return (T) existing;
}
if (underConstruction.get().putIfAbsent(clazz, true) != null) {
return (T) existing;
}
try {
singletonInstances.put(clazz, PLACEHOLDER);
T instance = createInstance(clazz);
singletonInstances.put(clazz, instance);
return instance;
} finally {
underConstruction.get().remove(clazz);
}
}
PLACEHOLDER를 먼저 넣는다ThreadLocal로 현재 생성 중인 타입을 추적한다이렇게 바꾸니 "초기화 중 동일 싱글톤 재요청" 때문에 터지는 문제는 피할 수 있었다.
다만 아직 프록시도 없고, 생성 도중 참조된 placeholder를 실제 안전한 객체처럼 다룰 수 있는 것도 아니다.
DI 컨테이너에서 고려할 점은 생성 시점과 초기화 상태를 관리하는 것이다. 라는 점은 확실히 배웠다.
직접 부딪혀보니 왜 Spring이 큰 프레임워크가 되었는지 조금 이해가 갔다.
Spring은 단순히 리플렉션으로 생성자를 호출하는 구조가 아니었고, 다음과 같은 핵심 컴포넌트로 DI를 제공한다.
BeanDefinition: 어떤 클래스를 어떤 스코프로 어떤 방식으로 만들지에 대한 메타데이터DefaultListableBeanFactory: 빈 정의를 보관하고 조회하는 중심 팩토리AbstractAutowireCapableBeanFactory: 생성자 선택, 의존성 주입, 초기화 수행DefaultSingletonBeanRegistry: 싱글톤 캐시와 생성 중 상태 관리생성 도중 다시 같은 빈이 필요해지는 상황을 Spring은 훨씬 정교하게 다룬다.
Spring의 싱글톤 관리는 아래와 같음
// 개념 요약
singletonObjects // 완전히 생성 완료된 객체
earlySingletonObjects // 생성은 되었지만 초기화가 덜 끝난 조기 참조
singletonFactories // 조기 참조를 만들어내는 팩토리
beforeSingletonCreation(beanName);
Object bean = createBeanInstance(beanName, mbd);
addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));
populateBean(beanName, mbd, bean);
initializeBean(beanName, bean, mbd);
addSingleton(beanName, bean);
afterSingletonCreation(beanName);
내가 넣은 PLACEHOLDER 하나와 비교하면, Spring은 아예 조기 노출용 캐시와 조기 참조를 만드는 팩토리를 별도로 둔다. 그래서 AOP 프록시가 필요한 경우에도, 나중에 바뀔 객체가 아니라 처음부터 노출해야 할 참조 형태를 더 세밀하게 맞출 수 있다.
다만 중요한 점도 있다.
Spring도 생성자 기반 순환 의존은 원칙적으로 해결하지 못한다. 공식 문서에서도 생성자 주입 중심의 순환 참조는 런타임에 감지되어 예외가 난다고 설명한다. 즉 Spring이 만능으로 다 풀어주는 게 아니라, 설계 자체가 강하게 꼬여 있으면 거기서 멈춰 세운다.
여기가 스프링 객체 캐시의 중심이다.
singletonObjects
earlySingletonObjects
singletonFactories
Spring 소스에서도 이 클래스 안에 각각 “완성된 singleton cache”, “early singleton cache”, “singleton factory registry”로 필드가 잡혀 있다. 즉 순환참조랑 조기 노출 구조를 보려면 여기를 먼저 봐야 한다
주요 메서드
getSingleton(String beanName)
getSingleton(String beanName, boolean allowEarlyReference)
getSingleton(String beanName, ObjectFactory<?> singletonFactory)
addSingletonFactory(...)
addSingleton(...)
beforeSingletonCreation(...)
afterSingletonCreation(...)
흐름
singletonObjects 조회
→ 없고 생성 중이면 earlySingletonObjects 조회
→ 그래도 없고 allowEarlyReference면 singletonFactories 조회
→ factory.getObject()
→ earlySingletonObjects에 넣음
→ singletonFactories에서 제거
Object bean = createBeanInstance(beanName, mbd);
addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));
populateBean(beanName, mbd, bean);
initializeBean(beanName, bean, mbd);
doCreateBean()이 bean instance를 만들고, 순환참조 해결을 위해 addSingletonFactory(beanName, () -> getEarlyBeanReference(...))를 등록한 뒤, populateBean()과 initializeBean()을 호출한다.
흐름
createBeanInstance()
→ 객체 생성, 아직 의존성 주입 전
addSingletonFactory()
→ 순환참조 대비용 조기 참조 팩토리 등록
populateBean()
→ 필드/세터 의존성 주입
initializeBean()
→ Aware, BeanPostProcessor, initMethod, 프록시 후처리 등
beforeSingletonCreation(beanName);
singletonFactory.getObject();
afterSingletonCreation(beanName);
addSingleton(beanName, singletonObject);
실제로 beforeSingletonCreation()은 singletonsCurrentlyInCreation에 beanName을 추가하고, 이미 생성 중이면 BeanCurrentlyInCreationException을 던진다.
afterSingletonCreation()은 생성 완료 후 그 beanName을 생성 중 목록에서 제거한다.
beforeSingletonCreation()
→ "이 빈 지금 생성 중임" 표시
afterSingletonCreation()
→ "이제 생성 중 아님" 표시
isSingletonCurrentlyInCreation(beanName) 로 판단해서 조기 참조 노출 여부를 결정한다.
톰캣이 DI 없이 설계한 걸 보면 내가 미처 이해하지 못한 설계 철학이나 운영상 이유가 있을 수도 있다.
톰캣이 DI 없이도 충분히 동작하는 걸 보면, 내가 굳이 WAS 레벨에서 이걸 넣은 게 과한 선택일 수도 있다.
정말 lightweight하게 가져가려면 명시적 조립이 더 나을 수도 있다. 반대로, 내가 만든 구조처럼 내부 컴포넌트 수가 계속 늘어난다면 생성과 연결을 한 곳에서 관리하는 편이 훨씬 편해질 수도 있다.
톰캣은 최소한의 플랫폼으로서의 철학을 따랐고, 나는 개발자의 관점에서 편리한 조립 구조를 원해서 DI를 붙여봤다. 둘 중 하나만 정답이라고 보긴 어렵다. 다만 직접 만들어보니 "왜 Spring이 필요해졌는가", 그리고 "왜 DI 컨테이너가 단순한 편의 기능이 아닌가"는 확실히 이해됐다.
결국 이번 DI 컨테이너는 완성품이라기보다 출발점에 가깝다. 그래도 적어도 이제는, 의존성 주입이 왜 등장했는지와 싱글톤 초기화가 왜 생각보다 어려운지에 대해서는 예전보다 훨씬 선명하게 말할 수 있게 되었다.
솔직히 잘 한 건지는 모르겠다..
분명 커지면 문제가 생길 수 도..?
하지만 고민해보고, 필요하다고 느낀 기능을 만들어봤다는 점에서 보람이 있었다.
이 DI 컨테이너를 더 발전시킬지, 아니면 다른 방식으로 갈지는 아직 모른다.
하지만 이번 경험을 통해, "왜 스프링이 나왔는가"에 대해 조금은 느낄 수 있었던 것 같다.