spring-ioc-clone_v3

정용현·2026년 4월 27일

프로그래머스

목록 보기
3/3

클론용 레포
제출 PR

package com.ll.framework.ioc;

import com.ll.framework.ioc.annotations.Bean;
import com.ll.framework.ioc.annotations.Configuration;
import com.ll.framework.ioc.annotations.Repository;
import com.ll.framework.ioc.annotations.Service;
import com.ll.standard.util.Ut;
import org.reflections.Reflections;

import java.lang.reflect.Method;

import java.lang.reflect.Constructor;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;

public class ApplicationContext {
    // 프로젝트 전체를 스캔해서 클래스 정보를 긁어올 탐지기
    private final Reflections reflections;

    // 일반 클래스 빈의 설계도(Class)를 저장 (이름 -> 클래스 객체)
    private final Map<String, Class<?>> beanDefinitions = new HashMap<>();

    // 실제로 생성된 객체(Bean)를 저장 (이름 -> 인스턴스) -> 싱글톤 보장용
    private final Map<String, Object> beans = new HashMap<>();

    // @Bean 메서드 그 자체를 저장 (이름 -> Method 객체)
    private final Map<String, Method> beanMethods = new HashMap<>();

    // @Bean 메서드가 어느 Configuration 클래스에 속해있는지 저장 (이름 -> Configuration 클래스)
    private final Map<String, Class<?>> beanMethodOwners = new HashMap<>();

    // DI(의존성 주입)를 위해 '타입'으로 '빈 이름'을 찾기 위한 전화번호부
    private final Map<Class<?>, String> typeToBeanName = new HashMap<>();

    public ApplicationContext(String basePackage) {
        this.reflections = new Reflections(basePackage);
    }

    public void init() {
        // 1. 모든 컴포넌트 클래스 스캔 (Service, Repository, Configuration)
        Set<Class<?>> componentClasses = reflections.getTypesAnnotatedWith(Service.class);
        componentClasses.addAll(reflections.getTypesAnnotatedWith(Repository.class));
        componentClasses.addAll(reflections.getTypesAnnotatedWith(Configuration.class));

        for (Class<?> clazz : componentClasses) {
            // 2. 일반 클래스들이라면 바로 빈으로 만듬
            getClassBean(clazz);

            // 3. 만약 @Configuration 클래스라면, 그 내부의 @Bean 메서드들도 스캔
            if (clazz.isAnnotationPresent(Configuration.class)) {
                getMethodBeanFromConfiguration(clazz);
            }
        }
    }

    public <T> T genBean(String beanName) {
        // 싱글톤 캐시 확인: 이미 만들어진 놈 있으면 걔를 반환
        if (beans.containsKey(beanName)) {
            return (T) beans.get(beanName);
        }

        // CASE 1: 그냥 클래스 빈일 때
        if (beanDefinitions.containsKey(beanName)) {
            return genClassBean(beanName);
        }

        // CASE 2: @Bean 메서드에서 생성되는 빈일 때
        if (beanMethods.containsKey(beanName)) {
            return genMethodBean(beanName);
        }

        return null;
    }

    private void getClassBean(Class<?> clazz) {
        // 클래스 이름으로 빈 이름 생성 (ex: TestPostService -> testPostService)
        String beanName = Ut.str.lcfirst(clazz.getSimpleName());

        // 설계도 저장 및 타입 등록
        beanDefinitions.put(beanName, clazz);
        typeToBeanName.put(clazz, beanName);
    }

    private void getMethodBeanFromConfiguration(Class<?> clazz) {
        for (Method method : clazz.getDeclaredMethods()) {
            if (method.isAnnotationPresent(Bean.class)) {
                // 메서드 이름이 곧 빈 이름
                String beanNameFromMethod = method.getName();

                // 메서드 정보와 그 메서드를 가진 클래스(owner) 저장
                beanMethods.put(beanNameFromMethod, method);
                beanMethodOwners.put(beanNameFromMethod, clazz);

                // 리턴 타입으로도 찾을 수 있게 등록 (DI 핵심)
                typeToBeanName.put(method.getReturnType(), beanNameFromMethod);
            }
        }
    }


    // [클래스 생성 전략] 생성자를 통한 인스턴스화
    private <T> T genClassBean(String beanName) {
        try {
            Class<?> clazz = beanDefinitions.get(beanName);
            // 생성자 하나만 있다고 가정하고 가져옴
            Constructor<?> constructor = clazz.getDeclaredConstructors()[0];

            // 생성자에 필요한 인자들 재귀적으로 조립
            Object[] args = resolveArgs(constructor.getParameterTypes());

            T instance = (T) constructor.newInstance(args); // 실제 생성
            beans.put(beanName, instance); // 생성한 객체 저장 (캐싱)
            return instance;
        } catch (Exception e) { throw new RuntimeException(e); }
    }

    // [메서드 실행 전략] @Bean 메서드 실행을 통한 인스턴스화
    private <T> T genMethodBean(String beanName) {
        try {
            Method method = beanMethods.get(beanName);
            Class<?> ownerClazz = beanMethodOwners.get(beanName);

            // @Bean 메서드는 Configuration 클래스 인스턴스가 있어야 호출 가능하므로,
            // 이 설정 클래스 자체를 먼저 빈으로 생성함 (재귀 호출)
            Object ownerInstance = genBean(Ut.str.lcfirst(ownerClazz.getSimpleName()));

            // 메서드 인자 의존성 조립
            Object[] args = resolveArgs(method.getParameterTypes());

            // Invoke(동사) 법/규칙을 적용/들먹이다, (권리/도움 등을) 호소/불러내다,
            // (IT) 함수나 프로그램을 호출하다라는 뜻
            // 메서드 실행 (invoke)해서 결과물(Bean) 반환
            T instance = (T) method.invoke(ownerInstance, args);
            beans.put(beanName, instance); // 생성한 객체 저장
            return instance;
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    // 의존성 주입의 핵심 : 타입 정보를 가지고 빈을 재귀적으로 다 찾아옴
    private Object[] resolveArgs(Class<?>[] parameterTypes) {
        Object[] args = new Object[parameterTypes.length];
        for (int i = 0; i < parameterTypes.length; i++) {
            // (typeToBeanName)에서 타입에 맞는 빈 이름 획득
            String paramBeanName = typeToBeanName.get(parameterTypes[i]);

            // 못 찾았으면 이름 기반(lcfirst)으로 fallback
            if (paramBeanName == null) {
                paramBeanName = Ut.str.lcfirst(parameterTypes[i].getSimpleName());
            }

            // 찾아낸 빈 이름으로 다시 genBean 호출 (의존성 체인 조립)
            args[i] = genBean(paramBeanName);
        }
        return args;
    }
}

2차과제와 달라진 점

@Configuration
public class TestJacksonConfig {
    @Bean
    public JavaTimeModule testBaseJavaTimeModule() {
        return new JavaTimeModule();
    }

    @Bean
    public ObjectMapper testBaseObjectMapper(JavaTimeModule testBaseJavaTimeModule) {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.registerModule(testBaseJavaTimeModule);
        return objectMapper;
    }
}

위와 같이 요구사항중에 @Configuration + @Bean이라는 메서드 단위 스캔도 추가가 되었다.

V2처럼 클래스 단위 스캔만으로는 ObjectMapper 같은 외부 라이브러리 객체를 빈으로 등록하기 어렵다(내 코드가 아니니까 클래스에 어노테이션을 붙일 수 없기에).

v3의 목표: @Configuration과 @Bean을 이용해 외부 라이브러리도 우리 컨테이너 안으로 끌어들인다.

클래스 vs 메서드: 왜 탐색 방식이 달라야 하는가?

  • v2 (Class): Class -> Constructor -> newInstance(). 설계도만 있으면 생성자를 호출해서 찍어내면 끝이었다.

  • v3 (Method): Method -> invoke(ownerInstance, args).

@Bean 메서드는 혼자 존재할 수 없다. '그 메서드가 들어있는 @Configuration 클래스의 인스턴스'가 먼저 있어야 그 메서드를 호출(invoke)할 수 있다.
'소유자(Owner) 의존성'을 해결하는 게 이번 v3의 핵심이다.

구현의 핵심 (typeToBeanName)

  • 문제: @Bean 메서드가 리턴하는 타입(JavaTimeModule)과 메서드 이름(testBaseJavaTimeModule)은 다르다.
    빈을 찾을 때는 이름으로 찾는 genBean("testBaseJavaTimeModule")
    , 의존성 주입(resolveArgs) 단계에선
    타입(JavaTimeModule)만 알고 있다.
    컨테이너 입장에서는 'JavaTimeModule 타입인 놈이 누군데?'라고 물어보면 이름(testBaseJavaTimeModule)을 바로 찾아낼 수 있는 '타입-이름 매핑 테이블'이 필요했다."
  • 해결: 그래서 Map<Class<?>, String> typeToBeanName을 도입했다. 이게 이번 v3의 핵심이다.
  • 등록 단계 (init): @Bean 메서드를 스캔할 때, 그 메서드의 리턴 타입(method.getReturnType())을 키(Key)로, 메서드 이름(빈 이름)을 값(Value)으로 맵에 넣어둔다.
typeToBeanName.put(method.getReturnType(), beanNameFromMethod);
  • 조회 단계 (resolveArgs): 파라미터 타입을 보고 typeToBeanName에서 곧바로 "아, 이 타입이면 이 이름의 빈을 달라고 하는 거구나!" 하고 빈 이름을 찾아내는 거다.
String paramBeanName = typeToBeanName.get(parameterTypes[i]); // 타입 -> 이름 변환

스프링과 다른점

위의 코드는 같은 타입의 빈이 여러개면 터진다.
스프링은 이런 상황에서 크게 두 가지 전략을 쓴다.

  • 1: NoUniqueBeanDefinitionException
    스프링은 타입을 기반으로 빈을 찾았는데, 똑같은 타입이 2개 이상 나오면 그냥 에러를 뱉어버린다.

  • 2: @Qualifier 또는 @Primary
    "이름이 2개니까 Qualifier로 이름 힌트를 주던가, 아니면 둘 중에 우선순위가 높은 놈(Primary)을 알려줘."라고 해결하는 방식이지.

@Primary

@Component
@Primary // 이걸 붙이면 빈이 충돌 날 때 얘가 1순위로 뽑힘
public class RateDiscountPolicy implements DiscountPolicy {
    // ...
}
@Component
public class FixDiscountPolicy implements DiscountPolicy {
    // ...
}

장점: 설정이 아주 간단하다. 어디서 주입받든(어디서 의존성을 쓰든) 똑같이 동작한다.
단점: 정말로 다른 놈을 쓰고 싶을 때는 또 다른 방법을 찾아야 한다.

@Qualifier

// 빈 등록 시 별명 붙이기
@Component
@Qualifier("mainDiscountPolicy")
public class RateDiscountPolicy implements DiscountPolicy { ... }
@Component
@Qualifier("fixDiscountPolicy")
public class FixDiscountPolicy implements DiscountPolicy { ... }
// 사용하는 쪽에서 콕 집어서 주입받기
@Service
public class OrderService {
    private final DiscountPolicy discountPolicy;
    @Autowired
    public OrderService(@Qualifier("mainDiscountPolicy") DiscountPolicy discountPolicy) {
        this.discountPolicy = discountPolicy; // 반드시 RateDiscountPolicy가 들어옴
    }
}

장점: 매우 명확하다. 의존성을 맺는 곳마다 콕 집어서 선택할 수 있다.
단점: 주입받는 곳마다 일일이 @Qualifier를 적어줘야 해서 좀 귀찮다.

profile
청년치매 예방기

0개의 댓글