본문 바로 가기

스프링 직접 구현하기 #1: IoC와 DI 구현

들어가며

스프링에 대한 더 깊은 이해를 얻기 위해 스프링의 핵심 기능들을 직접 구현해보려고 합니다. 중요 기능을 우선적으로 구현해 나가며, 처음에는 단순하게 시작하지만 점차 기능을 확장해 나갈 예정입니다. 필요할 경우 스프링 코드와 직접 비교하며 배울 점을 찾아보려 합니다. 외부 라이브러리는 최소한으로 사용할 계획입니다.

 

구현한 코드는 지속적으로 다음 저장소에 업데이트할 예정입니다:

https://github.com/youngho9999/spring-imitator

개발 환경

JDK 17

🎬 이번 구현 목표

이번 글에서는 스프링의 가장 핵심 패러다임인 IoC(Inversion of Control)를 가능하게 하는 기능인 Component Scan과 Dependency Injection을 구현해보려고 합니다. 그 중에서도 현재는 권장되지 않지만 개념 이해에 도움이 되는 필드 인젝션(Field Injection)을 먼저 구현해보겠습니다.

아래와 같은 코드를 실행할 수 있도록 만드는 것이 목표입니다:

@Component
public class CatController {

    @AutoWire
    private CatService catService;

    public void cat() {
        catService.cat();
    }
}
@Component
public class CatService {

    public void cat() {
        System.out.println("Meow Meow Meow");
    }
}

결과적으로 CatController를 통해 CatServicecat() 메서드를 실행하는 것이 목적입니다.
이를 위해 다음 두 가지 핵심 동작이 필요합니다:

  1. Component Scan - @Component 어노테이션이 달린 클래스들을 찾아 모두 인스턴스화
  2. Dependency Injection - 필요한 의존성을 필드에 자동으로 주입

🌳 Component Scan 구현하기

먼저, @Component 어노테이션을 정의합니다:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Component {
}
  • @Retention(RetentionPolicy.RUNTIME): 런타임에도 이 어노테이션 정보가 유지되도록 설정
  • @Target(ElementType.TYPE): 클래스, 인터페이스 등 타입 레벨에 적용 가능한 어노테이션임을 명시

다음으로, 컴포넌트를 스캔하는 기능을 구현합니다:

public class ComponentScanner {

    public static Set<Class<?>> scanAllComponents() {
        String basePackage = Main.class.getPackage().getName();
        Reflections reflections = new Reflections(basePackage, Scanners.SubTypes.filterResultsBy(c -> true)); // 모든 하위 타입 스캔

        // basePackage 및 그 하위 패키지의 모든 클래스 가져오기
        Set<Class<?>> allTypes = reflections.getSubTypesOf(Object.class);

        return allTypes.stream()
                .filter(c -> c.isAnnotationPresent(Component.class))
                .collect(Collectors.toSet());
    }
}

패키지를 스캐닝하는 작업이 생각보다 복잡하여 reflections 라이브러리를 활용했습니다. 이 부분은 추후에 직접 구현해볼 예정입니다. 현재는 모든 클래스 파일을 검색하여 @Component 어노테이션이 적용된 클래스만 필터링해 Set으로 반환합니다.

🌸 Dependency Injection 구현하기

@AutoWire 어노테이션을 정의합니다:

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoWire {
}

빈(Bean)들을 관리할 컨텍스트 객체를 생성합니다:

public class SpringContext {
    public static final Map<Class<?>, Object> BEAN_MAP = new HashMap<>();
}

클래스 타입과 해당 인스턴스를 Map으로 관리합니다. 이렇게 하면 리플렉션을 사용할 때 필요한 인스턴스를 클래스 타입으로 쉽게 찾을 수 있습니다.

이제 컨텍스트를 초기화하고 의존성을 주입하는 클래스를 구현합니다:

public class SpringContextInitiator {

    public void createInstances(Set<Class<?>> scannedComponents) {
        for (Class<?> clazz : scannedComponents) {
            try {
                Object o = clazz.getDeclaredConstructor().newInstance();
                SpringContext.BEAN_MAP.put(clazz, o);
            } catch (InstantiationException | InvocationTargetException | IllegalAccessException | NoSuchMethodException e) {
                throw new ContextInitializeException(e);
            }
        }
    }

    public void injectDependencies() {
        for (Class<?> clazz : SpringContext.BEAN_MAP.keySet()) {
            Field[] declaredFields = clazz.getDeclaredFields();

            for (Field field : declaredFields) {
                if (!field.isAnnotationPresent(AutoWire.class))
                    continue;

                Class<?> target = field.getType();
                Object targetInstance = Optional.ofNullable(SpringContext.BEAN_MAP.get(target))
                                                .orElseThrow(ContextInitializeException::new);

                field.setAccessible(true);
                try {
                    field.set(SpringContext.BEAN_MAP.get(clazz), targetInstance);
                } catch (IllegalAccessException e) {
                    throw new ContextInitializeException(e);
                }
                field.setAccessible(false);
            }
        }
    }
}

두 개의 주요 메서드를 가지고 있습니다:

  1. createInstances(): 스캔된 컴포넌트 클래스들을 인스턴스화하여 BEAN_MAP에 저장합니다.
  2. injectDependencies(): 모든 빈을 순회하면서 @AutoWire 어노테이션이 달린 필드를 찾아 해당 타입의 빈을 주입합니다.

field.set(주입받을객체, 주입할객체)로 실제 주입을 수행합니다.

🎶 실행 테스트

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

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

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

        // 3. 실행 테스트
        CatController catController = (CatController) SpringContext.BEAN_MAP.get(CatController.class);
        catController.cat();
    }
}

실행 결과:

Meow Meow Meow

성공적으로 의존성 주입이 이루어져 CatControllerCatService의 메서드를 호출할 수 있게 되었습니다!

마치며

실제 스프링은 훨씬 복잡하고 정교한 구현을 가지고 있지만, 이런 기본적인 구현을 통해 스프링의 핵심 개념을 더 깊이 이해할 수 있습니다.

리플렉션 API를 실제로 사용해본 것은 이번이 처음이었는데, 이런 프레임워크를 만들 때 얼마나 유용한지 깨달을 수 있었습니다. 일반적인 웹 개발에서는 클래스를 동적으로 조작할 일이 많지 않지만, 프레임워크 개발에서는 필수적인 기술임을 느꼈습니다.

 

다음 글에서는 생성자 주입 방식을 구현해보도록 하겠습니다.

👉깃허브 소스코드