본문 바로 가기

스프링 직접 구현하기 #2: 생성자 주입

들어가며

Spring Framework의 핵심 기능인 의존성 주입(DI)을 직접 구현하는 두번째 편입니다. 이전 글에서는 필드 주입을 구현했고, 이번에는 setter 주입과 가장 권장되는 방식인 생성자 주입을 구현해보겠습니다.

 

생성자 주입은 다른 주입 방식들과 달리 객체 생성 시점에 의존성이 주입되어야 한다는 특징이 있습니다. 이로 인해 구현 난이도가 높아지지만, 객체의 불변성과 필수 의존성을 보장할 수 있다는 장점이 있습니다. 특히 순환 의존성 문제를 해결하기 위해 위상 정렬 알고리즘을 활용한 점이 이번 구현의 핵심입니다.

🍠 Setter 주입

생성자 주입을 다루기 전에, 먼저 구현한 setter 주입 코드를 간단히 살펴보겠습니다.

private void injectBySetter() {
    for (Class<?> clazz : SpringContext.BEAN_MAP.keySet()) {
        Method[] methods = clazz.getMethods();

        for (Method method : methods) {
            if(!method.isAnnotationPresent(AutoWire.class)) {
                continue;
            }
            Class<?>[] parameterTypes = method.getParameterTypes();
            Object[] injectInstances = new Object[parameterTypes.length];

            for(int i = 0; i < parameterTypes.length; i++) {
                injectInstances[i] = Optional.ofNullable(SpringContext.BEAN_MAP.get(parameterTypes[i]))
                    .orElseThrow(ContextInitializeException::new);
            }

            try {
                method.invoke(SpringContext.BEAN_MAP.get(clazz), injectInstances);
            } catch (IllegalAccessException | InvocationTargetException e) {
                throw new ContextInitializeException(e);
            }
        }
    }
}

setter 주입은 field 주입과 동작 원리가 유사합니다. @AutoWire 어노테이션이 붙은 메서드들을 찾아 해당 메서드의 파라미터에 주입할 인스턴스들을 모아서 메서드를 실행합니다.

🐏 생성자 주입

구현 목표

다음과 같은 계층적 구조를 가진 클래스들을 생성자 주입 방식으로 인스턴스화하는 것이 목표입니다.

@Component
public class WhileEagleRepository {
}
----
@Component
public class BlackEagleRepository {
}
----
@Component
public class BlackEagleService {
    private final BlackEagleRepository blackEagleRepository;

    @AutoWire
    public BlackEagleService(BlackEagleRepository blackEagleRepository) {
        this.blackEagleRepository = blackEagleRepository;
    }

    public void fly() {
        System.out.println("black eagle flying");
    }
}
----
@Component
public class WhiteEagleService {
    private final WhileEagleRepository repository;

    @AutoWire
    public WhiteEagleService(WhileEagleRepository repository) {
        this.repository = repository;
    }

    public void fly() {
        System.out.println("white eagle flying");
    }
}
----
@Component
public class EagleController {
    private final BlackEagleService blackEagleService;
    private final WhiteEagleService whiteEagleService;

    @AutoWire
    public EagleController(BlackEagleService blackEagleService, WhiteEagleService whiteEagleService) {
        this.blackEagleService = blackEagleService;
        this.whiteEagleService = whiteEagleService;
    }

    public void fly() {
        blackEagleService.fly();
        whiteEagleService.fly();
    }
}

이를 구현하기 위해서는 이전 방식처럼 단순히 모든 Component 객체를 생성한 후 의존성을 주입하는 방식으로는 불가능합니다. 생성자 주입의 경우 객체 생성과 의존성 주입이 동시에 이루어져야 하기 때문입니다.

 

따라서 객체 생성 순서를 정해야 하며, 이를 위해 위상 정렬 알고리즘을 도입했습니다.

진입 차수(In-degree): 생성자에 필요한 의존성 중 아직 인스턴스화되지 않은 개수
진입 차수가 0인 객체: 모든 의존성이 준비되어 생성자 주입이 가능한 상태

 

구현 플로우
전체 구현 플로우는 다음과 같이 변경되었습니다:

  1. 생성자 주입이 필요하지 않은 객체들을 먼저 생성 (의존성이 없거나, field/setter 주입만 사용하는 경우)
  2. 모든 객체를 탐색하여 진입 차수 초기화
  3. 진입 차수가 0인 객체부터 위상 정렬 시작
  4. 정렬 순서대로 인스턴스화
  5. 생성자 주입 완료 또는 순환 의존성이 발견되면 프로그램 종료

이 전체 프로세스를 명확하게 표현하기 위해 init 메서드를 구현했습니다:

public void init(Set<Class<?>> scannedComponents) {
    createInstancesForNoConstructorInjectionRequired(scannedComponents);
    injectToFields();
    injectBySetter();

    // 위상정렬을 위한 진입 차수 초기화
    Queue<Class<?>> topologyQueue = initializeInDegreeForConstructor(scannedComponents);
    // 위상 정렬을 진행하며 생성자 주입 인스턴스 생성
    int initializedInstances = injectConstructorWithTopologySort(topologyQueue);

    // 순환 의존성 체크
    if(initializedInstances != scannedComponents.size()) {
        throw new ContextInitializeException("cycle detected: " + scannedComponents.size() + " components were initialized");
    }
}

👽 initializeInDegreeForConstructor

위상 정렬을 위한 준비 단계로, 진입 차수와 그래프 탐색 구조를 설정합니다:

public Queue<Class<?>> initializeInDegreeForConstructor(Set<Class<?>> scannedComponents) {
    // 진입차수 저장 Map
    clazzInDegree = new HashMap<>();
    // 그래프 탐색을 위한 Map
    clazzGraph = new HashMap<>();

    // 각 클래스별 진입 차수 계산
    for (Class<?> clazz : scannedComponents) {
        initializeEachClassForTopologySort(clazz);
    }

    // 진입차수가 0인 클래스를 시작점으로 큐에 추가
    Queue<Class<?>> topologySortQueue = new ArrayDeque<>();
    for(Class<?> clazz : clazzInDegree.keySet()) {
        if(clazzInDegree.get(clazz) == 0) {
            topologySortQueue.add(clazz);
        }
    }

    return topologySortQueue;
}

🎂 initializeEachClassForTopologySort

각 클래스별로 진입 차수와 그래프 구조를 초기화합니다:

private void initializeEachClassForTopologySort(Class<?> clazz) {
    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();
    int inDegreeCount = 0;

    for(Class<?> parameter : parameterTypes) {
        // 이미 생성된 빈은 진입 차수에 포함하지 않음
        if(SpringContext.BEAN_MAP.containsKey(parameter)) {
            continue;
        }
        inDegreeCount++;

        // 그래프 탐색을 위한 역방향 매핑 설정
        if(!clazzGraph.containsKey(parameter)) {
            clazzGraph.put(parameter, new HashSet<>());
        }
        clazzGraph.get(parameter).add(clazz);
    }

    //진입차수 설정
    clazzInDegree.put(clazz, inDegreeCount);
}

😶injectConstructorWithTopologySort

실제 위상 정렬을 수행하며 생성자 주입을 통해 인스턴스를 생성합니다:

private int injectConstructorWithTopologySort(Queue<Class<?>> topologySortQueue) {
    int initializedInstances = 0;

    while(!topologySortQueue.isEmpty()) {
        Class<?> clazz = topologySortQueue.poll();

        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] = Optional.ofNullable(SpringContext.BEAN_MAP.get(parameterTypes[i]))
                    .orElseThrow(ContextInitializeException::new);
        }

        // 생성자를 통한 인스턴스 생성
        try {
            Object injectedInstance = constructor.newInstance(injectInstances);
            SpringContext.BEAN_MAP.put(clazz, injectedInstance);
            initializedInstances++;
        } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) {
            throw new ContextInitializeException(e);
        }

        // 현재 클래스에 의존하는 다른 클래스들의 진입 차수 감소
        if(!clazzGraph.containsKey(clazz)) {
            continue;
        }

        for(Class<?> nextClazz : clazzGraph.get(clazz)) {
            Integer nextClazzIndegree = clazzInDegree.computeIfPresent(nextClazz, (k, v) -> v - 1);
            if(nextClazzIndegree == 0) {
                topologySortQueue.add(nextClazz);
            }
        }
    }

    return initializedInstances;
}

🎶 실행 테스트

이제 구현한 코드를 실행해봅시다:

public static void main(String[] args) {
    // 1. 컴포넌트 스캔
    Set<Class<?>> scannedComponents = ComponentScanner.scanAllComponents();

    // 2. 컨텍스트 초기화 및 의존성 주입
    SpringContextInitiator initiator = new SpringContextInitiator();
    initiator.init(scannedComponents);

    //테스트
    EagleController eagle = (EagleController) SpringContext.BEAN_MAP.get(EagleController.class);
    eagle.fly();

}

실행 결과:

black eagle flying
white eagle flying
성공적으로 순차적 의존성 주입이 이루어져 EagleController 가 BlackEagleService와 WhiteEagleService의 메서드를 호출할 수 있게 되었습니다!

🍡Spring Framework의 구현 방식

Spring Framework(6.2.x 버전)가 내부적으로 어떻게 구현되어 있는지 살펴보았습니다. Spring은 lazy loading을 고려하여 재귀적으로 구현되어 있었습니다. 빈 생성 시 필요한 의존성이 있다면 해당 의존성을 먼저 생성하려고 시도합니다.

간단한 pseudo 코드로 표현하면:

createBean() {
    if(의존성 필요) {
        createBean() // 재귀 호출
    }
    // 현재 빈 생성
}

실제 구현은 다음과 같은 흐름을 따릅니다:
getBean → (인스턴스 없을 시) createBean → resolveAutowiredArgument → getBean
더 자세한 구현이 궁금하다면 ConstructorResolver 클래스의 autowireConstructor 메서드를 확인해보시기 바랍니다:

ConsturctorResolver.java

마치며

생성자 주입 기반 DI 구현이 생각보다 복잡했습니다. 특히 위상 정렬 알고리즘을 실제 코드에 적용해본 것은 이번이 처음이었습니다.
알고리즘 문제를 풀 때는 많이 접했지만 실무에서 사용해볼 기회가 없어 괴리감을 느꼈었는데, 이렇게 직접 활용해보니 보람을 느꼈습니다.

👉깃허브 소스코드

Reference

https://github.com/spring-projects/spring-framework/blob/6.2.x/spring-beans/src/main/java/org/springframework/beans/factory/support/ConstructorResolver.java#L240