서문
Spring은 3레벨 캐시를 사용하여 빈 간의 순환 참조를 해결하므로 문제가 발생합니다. 3레벨 캐시는 어떻게 작동할까요? 각 레벨의 역할은 무엇인가요? 3레벨을 사용해야 하나요? 2레벨 캐시는 괜찮나요?
순환 참조 이해
"순환 참조"는 루프를 형성하는 빈 간의 종속성(예: a가 b에 종속되고, b가 a에 종속되는 경우)입니다.
@Component
public class A {
@Autowired
B b;
}
@Component
public class A {
@Autowired
B b;
}
이러한 종속성은 "단일 책임"이라는 설계 원칙을 위반하고 코드의 복잡성과 유지 관리 비용을 증가시키므로 개발자는 설계 단계에서 순환 참조를 피하려고 노력해야 합니다.
설계 원칙은 제쳐두고, 순환 참조는 Spring에 종속성 주입 문제를 일으킵니다. 이 예제에서 Spring은 a를 인스턴스화하기 위해 찾은 b를 인스턴스화하기 위해 서둘러 b를 인스턴스화했는데, 이때는 a가 생성되지 않았기 때문에 데드락과 같은 데드 루프에 빠지게 됩니다.
다단계 캐싱이 순환 참조 문제를 해결할 수 있는 이유는 무엇일까요?
다단계 캐싱 이해
3 레벨 캐시의 스프링은 실제로 3 개의 맵 컨테이너이며, 먼저 순환 참조 문제를 해결하기 위해 다중 레벨 캐시 구성표의 설계를 사용하면 어떻게해야합니까?
퍼스트 레벨 캐시
1레벨 캐시의 목적은 특이성을 보장하는 것입니다. Spring에서 Bean은 기본적으로 싱글톤이며, getBean()을 여러 번 호출하면 동일한 인스턴스가 생성됩니다. 이 규칙에 따라 첫 번째 수준 캐시를 사용하여 특이성을 보장하는 IOC 컨테이너를 구현할 수 있습니다.
public class Ioc1 {
private final Map<Class, Object> singletonObjects = new ConcurrentHashMap<>();
public synchronized <T> T getBean(Class<T> clazz) {
Object bean = singletonObjects.get(clazz);
if (bean == null) {
singletonObjects.put(clazz, bean = createBean(clazz));
}
return (T) bean;
}
@SneakyThrows
private <T> Object createBean(Class<T> clazz) {
T bean = clazz.newInstance();
return bean;
}
}
L2 캐시
1레벨 캐시는 단일 인스턴스만 보장할 수 있으며 순환 참조에 대한 해결책이 없습니다. 따라서 순환 참조 문제를 해결하기 위해 다른 수준의 캐시를 추가하고 두 번째 수준의 캐시를 도입할 수 있습니다. 따라서 초기화되지 않은 빈을 두 번째 레벨 캐시에 미리 노출하고, 종속성 주입 중에 반제품 빈을 주입할 수 있도록 하는 것이 핵심 아이디어로 두 번째 버전의 IOC 컨테이너가 구현되었습니다.
public class Ioc2 {
private final Map<Class, Object> singletonObjects = new ConcurrentHashMap<>();
private final Map<Class, Object> earlySingletonObjects = new ConcurrentHashMap<>();
public synchronized <T> T getBean(Class<T> clazz) {
Object bean = singletonObjects.get(clazz);
if (bean == null) {
bean = earlySingletonObjects.get(clazz);
if (bean == null) {
singletonObjects.put(clazz, bean = createBean(clazz));
}
}
return (T) bean;
}
@SneakyThrows
private <T> Object createBean(Class<T> clazz) {
T bean = clazz.newInstance();
// 초기화되지 않은 빈을 L2 캐시에 미리 노출하기
earlySingletonObjects.put(clazz, bean);
populateBean(bean);
return bean;
}
// 속성 주입
@SneakyThrows
private <T> void populateBean(T bean) {
for (Field field : bean.getClass().getDeclaredFields()) {
Object fieldValue = getBean(field.getType());
field.setAccessible(true);
field.set(bean, fieldValue);
}
}
}
단계 캐시
대부분의 경우 보조 캐시로 충분하지만 Spring에는 강력한 기능이 있습니다. 바로 향상된 기능을 수행하기 위해 빈을 기반으로 프록시 객체를 생성하는 것입니다.
이 시점에서 2차 캐시를 사용하면 프록시 객체가 언제 생성될 것인가라는 문제에 직면하게 됩니다.
- 프록시를 생성하기 전에 빈이 초기화될 때까지 기다리는 경우, 주입된 프로퍼티가 프록시되지 않은 빈이라는 것은 분명 용납할 수 없습니다.
- 빈이 초기화되기 전에 프록시 객체를 생성하면 주입된 프로퍼티도 프록시되는 빈인지 확인할 수 있지만 이는 Spring의 설계 원칙에 맞지 않습니다.
프록시 객체를 미리 생성하는 것이 Spring의 설계 원칙에 맞지 않는 이유는 무엇일까요?
Spring의 빈 및 AOP 라이프사이클에서는 먼저 빈을 인스턴스화하고 초기화한 다음 BeanPostProcessor의 하위 클래스인 AbstractAutoProxyCreator의 포스트프로세서 메서드를 호출하여 프록시 객체를 생성해야 하기 때문입니다. 초기화되지 않은 반제품 빈을 기반으로 프록시 객체를 미리 생성하는 것은 Spring의 설계 원칙에 위배됩니다. 포스트 프로세서는 빈을 완전한 빈으로 확장해야 하며, 빈의 프로퍼티에 액세스해야 하는데 null이 되면 프로그램 오류가 발생할 수 있습니다.
따라서 프록시 빈이 미리 생성되어 두 번째 수준의 캐시에 노출되는 경우 Spring 순환 참조는 두 번째 수준의 캐시만 있으면 완벽하게 괜찮다는 결론을 내릴 수 있습니다. 기능은 문제가 되지 않지만 이 점은 스프링의 설계 원칙에 위배되므로 스프링은 3단계 캐시를 도입하기 전에 이 문제를 피하기 위해 노력했습니다.
Spring이 제공하는 해결책은 순환 참조가 발생할 경우를 대비해 프록시를 최대한 미리 생성하지 않도록 3단계 캐시를 도입하고, 최후의 수단으로 미리 생성할 수 있는 프록시만 생성하는 것입니다.
이제 IOC 구현의 최종 버전을 소개할 수 있는데, 로직은 기본적으로 Spring과 동일합니다. 핵심 아이디어는 빈이 인스턴스화된 후 순환 참조가 없는 경우 프록시 객체가 미리 생성되지 않고 일관성을 유지하기 위해 빈의 수명 주기인 3레벨 캐시에 ObjectFactory를 미리 노출한다는 것입니다. 순환 참조가 발생하면 프록시 객체를 미리 생성하고 3레벨 캐시에서 2레벨 캐시로 이동하여 반복 생성을 피할 수 있으며, 다른 Bean 주입도 Bean 이후에 프록시됩니다.
public class Ioc3 {
private final Map<Class, Object> singletonObjects = new ConcurrentHashMap<>();
private final Map<Class, Object> earlySingletonObjects = new ConcurrentHashMap<>();
private final Map<Class, ObjectFactory<?>> singletonFactories = new ConcurrentHashMap<>();
// 프록시 캐싱
private final Map<Class, Object> proxyCache = new ConcurrentHashMap<>();
public synchronized <T> T getBean(Class<T> clazz) {
Object bean = singletonObjects.get(clazz);
if (bean == null) {
bean = earlySingletonObjects.get(clazz);
if (bean == null) {
ObjectFactory<?> objectFactory = singletonFactories.get(clazz);
if (objectFactory != null) {
bean = objectFactory.getObject();
earlySingletonObjects.put(clazz, bean);
singletonFactories.remove(clazz);
} else {
bean = createBean(clazz);
singletonObjects.put(clazz, bean);
}
}
}
return (T) bean;
}
@SneakyThrows
private <T> Object createBean(Class<T> clazz) {
T bean = clazz.newInstance();
singletonFactories.put(clazz, () -> wrapIfNecessary(bean));
populateBean(bean);
return wrapIfNecessary(bean);
}
private <T> T wrapIfNecessary(final T bean) {
if (true) {
Object proxy = proxyCache.get(bean.getClass());
if (proxy == null) {
proxy = ProxyFactory.getProxy(bean.getClass(), new MethodInterceptor() {
@Override
public Object intercept(Object o, Method method, Object[] args, MethodProxy methodProxy) throws Throwable {
if (method.getDeclaringClass().isAssignableFrom(Object.class)) {
return method.invoke(this, args);
}
System.err.println("before...");
Object result = method.invoke(bean, args);
System.err.println("after...");
return result;
}
});
proxyCache.put(bean.getClass(), proxy);
}
return (T) proxy;
}
return bean;
}
@SneakyThrows
private <T> void populateBean(T bean) {
for (Field field : bean.getClass().getDeclaredFields()) {
Object fieldValue = getBean(field.getType());
field.setAccessible(true);
field.set(bean, fieldValue);
}
}
}
스프링 3단계 캐시 구현
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);
private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>(16);
private final Map<String, Object> earlySingletonObjects = new ConcurrentHashMap<>(16);
싱글톤 빈을 가져올 때 Spring은 먼저 1차 및 2차 캐시를 찾은 다음, 3차 캐시가 없는 경우 3차 캐시를 찾고, 캐시를 찾으면 프록시 객체를 미리 생성하여 반환합니다.
protected Object getSingleton(String beanName, boolean allowEarlyReference) {
// 첫 번째 수준 캐시 확인
Object singletonObject = this.singletonObjects.get(beanName);
// 아니요, 빈이 생성 중입니다.
if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) {
// L2 캐시에서
singletonObject = this.earlySingletonObjects.get(beanName);
// 아니요, 반제품 빈에 대한 참조 허용
if (singletonObject == null && allowEarlyReference) {
synchronized (this.singletonObjects) {
// double check
singletonObject = this.singletonObjects.get(beanName);
if (singletonObject == null) {
singletonObject = this.earlySingletonObjects.get(beanName);
if (singletonObject == null) {
// 세 번째 수준 캐시가 있는 경우 프록시 객체를 생성하여 두 번째 수준 캐시로 이동합니다.
ObjectFactory<?> singletonFactory = this.singletonFactories.get(beanName);
if (singletonFactory != null) {
singletonObject = singletonFactory.getObject();
this.earlySingletonObjects.put(beanName, singletonObject);
this.singletonFactories.remove(beanName);
}
}
}
}
}
}
return singletonObject;
}
순환 참조가 없는 경우 ObjectFactory#getObject 호출되지 않고 프록시 객체가 미리 생성되지 않습니다.
doCreateBean() 메서드에서 초기화되지 않은 반제품 빈은 ObjectFactory로 감싸서 3차 캐시에 미리 노출됩니다:
protected Object doCreateBean(String beanName, RootBeanDefinition mbd, @Nullable Object[] args){
......
boolean earlySingletonExposure = (mbd.isSingleton() && this.allowCircularReferences &&
isSingletonCurrentlyInCreation(beanName));
if (earlySingletonExposure) {
addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));
}
......
}
라고도 하는 getObject() 메서드는 포스트프로세서 getEarlyBeanReference() 호출하여 프록시 객체를 생성하며, 현재 구현 클래스는 AbstractAutoProxyCreator 하나뿐입니다:
@Override
public Object getEarlyBeanReference(Object bean, String beanName) {
Object cacheKey = getCacheKey(bean.getClass(), beanName);
this.earlyProxyReferences.put(cacheKey, bean);
return wrapIfNecessary(bean, beanName, cacheKey);
}
Tail
Spring은 순환 참조 문제를 해결하기 위해 3단계 캐시를 설계했습니다. 1레벨 캐시의 역할은 싱글톤을 보장하는 것이고, 2레벨 캐시의 역할은 순환 참조를 해결하는 것이며, 3레벨 캐시는 반쯤 완성된 빈을 기반으로 프록시 객체가 생성되지 않도록 미리 방지하는 것입니다. 기능적으로 보면, 프록시된 빈이 모두 프록시 객체를 미리 생성한다면 두 번째 수준 캐시만 사용해도 문제가 되지 않지만, 이는 Spring의 설계 원칙에 위배됩니다. Spring 빈 라이프사이클에서 빈은 포스트 프로세서에 의해 확장되기 전에 인스턴스화되고 초기화되어야 하며, 초기화되지 않은 빈을 확장하면 해당 속성에 액세스하려는 경우 프로그램 오류가 발생할 수 있습니다.Spring의 접근 방식은 순환 참조가 없는 경우 ObjectFactory 객체를 미리 노출하는 것입니다. Spring은 ObjectFactory 객체를 미리 노출하여 순환 참조가 발생하지 않으면 해당 객체의 getObject()가 호출되지 않고 프록시 객체가 미리 생성되지 않으므로 가능한 한 일관된 빈 수명 주기를 보장합니다.
그러나 순환 참조가 발생하고 참조된 빈을 프록시해야 하는 경우 프록시 객체는 미리 생성할 수만 있습니다. 이는 Spring의 설계 원칙에 위배되므로 최신 버전의 Spring에서는 더 이상 순환 참조를 기본적으로 허용하지 않으며 수동으로 활성화해야 합니다.




