spring-ioc-clone_v2

정용현·2026년 4월 22일

프로그래머스

목록 보기
2/3

클론용 레포
제출 pr

요구사항

  • 요구사항 v2 - 리포지터리에서 포크 후 클론
  • build.gradle.kts 에 기술된 라이브러리만 사용할 수 있습니다.
  • ApplicationContextTest.java 의 모든 테스트케이스 통과
  • src/test 폴더 안의 소스코드는 수정할 수 없음
  • 빈 생성시 하드코딩방식을 사용금지
  • org.reflections:reflections 라이브러리를 활용해서 컴포넌트를 스캔하여 빈 생성정보 수집

ApplicationContextTest.java

package com.ll.framework.ioc;

import com.ll.domain.testPost.testPost.repository.TestPostRepository;
import com.ll.domain.testPost.testPost.service.TestFacadePostService;
import com.ll.domain.testPost.testPost.service.TestPostService;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

public class ApplicationContextTest {
    private static ApplicationContext applicationContext;

    @BeforeAll
    public static void beforeAll() {
        applicationContext = new ApplicationContext("com.ll");
        applicationContext.init();
    }

    @Test
    @DisplayName("ApplicationContext 객체 생성")
    public void t1() {
        System.out.println(applicationContext);
    }

    @Test
    @DisplayName("testPostService 빈 얻기")
    public void t2() {
        TestPostService testPostService = applicationContext
                .genBean("testPostService");

        assertThat(testPostService).isNotNull();
    }

    @Test
    @DisplayName("testPostService 빈을 다시 얻기, 싱글톤이어야 함")
    public void t3() {
        TestPostService testPostService1 = applicationContext
                .genBean("testPostService");

        TestPostService testPostService2 = applicationContext
                .genBean("testPostService");

        assertThat(testPostService1).isNotNull();
        assertThat(testPostService2).isNotNull();
        assertThat(testPostService1).isSameAs(testPostService2);
    }

    @Test
    @DisplayName("testPostRepository")
    public void t4() {
        TestPostRepository testPostRepository = applicationContext
                .genBean("testPostRepository");

        assertThat(testPostRepository).isNotNull();
    }

    @Test
    @DisplayName("testPostService has testPostRepository")
    public void t5() {
        TestPostService testPostService = applicationContext
                .genBean("testPostService");

        assertThat(testPostService).hasFieldOrPropertyWithValue(
                "testPostRepository",
                applicationContext.genBean("testPostRepository")
        );
    }

    @Test
    @DisplayName("testFacadePostService has testPostService, testPostRepository")
    public void t6() {
        TestFacadePostService testFacadePostService = applicationContext
                .genBean("testFacadePostService");

        assertThat(testFacadePostService).hasFieldOrPropertyWithValue(
                "testPostService",
                applicationContext.genBean("testPostService")
        );

        assertThat(testFacadePostService).hasFieldOrPropertyWithValue(
                "testPostRepository",
                applicationContext.genBean("testPostRepository")
        );
    }
}

ApplicationContext

public class ApplicationContext {
    private final Reflections reflections;
    // 1. 빈의 설계도(Class)를 저장 (이름 -> 클래스)
    private final Map<String, Class<?>> beanDefinitions = new HashMap<>();
    // 2. 실제로 생성된 빈(객체)을 저장 (이름 -> 인스턴스, 싱글톤 보장용)
    private final Map<String, Object> beans = new HashMap<>();

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

    // 여기서 Reflections가 활약한다
    public void init() {
        // 어노테이션이 붙은 클래스들을 스캔
        Set<Class<?>> classes = reflections.getTypesAnnotatedWith(Service.class);
        classes.addAll(reflections.getTypesAnnotatedWith(Repository.class));

        // 클래스 이름을 키로 사용하여 맵에 저장
        for (Class<?> clazz : classes) {
            String beanName = Ut.str.lcfirst(clazz.getSimpleName());
            beanDefinitions.put(beanName, clazz);
        }
    }
    
    // 여기서 Constructor와 Class 객체를 꺼내서 쓴다
    public <T> T genBean(String beanName) {
        // 1. 이미 생성된 객체가 있다면 반환 (싱글톤 보장)
        if (beans.containsKey(beanName)) {
            return (T) beans.get(beanName);
        }

        // 2. 존재하지 않는 빈이라면 생성 시도
        Class<?> clazz = beanDefinitions.get(beanName);
        if (clazz == null) return null;

        try {
            // 3. 의존성 해결 (생성자 인자 가져오기)
            Constructor<?> constructor = clazz.getDeclaredConstructors()[0];
            Class<?>[] parameterTypes = constructor.getParameterTypes();
            Object[] args = new Object[parameterTypes.length];

            for (int i = 0; i < parameterTypes.length; i++) {
                // 재귀 호출: 생성자에 필요한 의존성 빈을 먼저 찾아옴(genBean)
                String paramBeanName = Ut.str.lcfirst(parameterTypes[i].getSimpleName());
                args[i] = genBean(paramBeanName);
            }

            // 4. 객체 생성 및 캐싱
            T instance = (T) constructor.newInstance(args);
            beans.put(beanName, instance);
            return instance;

        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

새로 알게 된 것들

v1과 달리 이번에는 빈으로 등록해야할 클래스들이 테스트 폴더에 있었다.
요구사항에 적힌 org.reflections.Reflections를 알아보고 사용하며 테스트 폴더에 있던 서비스,레포지토리 클래스들을 불러올 수 있었으며 또한 Constructor를 사용해 보았다.
직접 구현하려 하니 총 Reflection, Class, Constructor, Reflections 라이브러리 네가지가 필요하더라.
또한 jvm의 동작원리도 함 파봐야했다.
메모리상에서 언제 어떻게 객체들이 존재하는지 알아야 했다.

Reflections 라이브러리

보통 우리가 객체를 생성할 때는 new MyClass()와 같이 코드를 작성한다. 하지만 프레임워크는 내가 만든 클래스가 뭔지도 모르는데 어떻게 객체를 만들어줄까? 컴파일 타임에 결정되지 않은 클래스를 런타임에 동적으로 다루는 기술, 이것이 바로 리플렉션이다.
참고로 자바도 표준라이브러리인 java.lang.reflect를 제공해주나 외부 라이브러리인 org.reflections.Reflections랑은 용도가 조금 다른거 같다.

구분java.lang.reflect (표준 API)org.reflections.Reflections (외부 라이브러리)
성격Java 표준 라이브러리 (JDK 포함)오픈소스 라이브러리 (별도 설치 필요)
역할관찰 및 조작 (이미 알고 있는 클래스를 분석)탐색 (어떤 클래스가 있는지 찾기)
핵심 기능Constructor, Method, Field 접근/호출getTypesAnnotatedWith, getSubTypesOf

1. Java Reflection이란?

리플렉션을 한마디로 정의하면 "클래스 정보를 분석하고 조작하는 API"다.

자바는 실행될 때 .class 파일들을 메모리에 로드하는데, 이 정보를 관리하는 것이 Class 객체다. 리플렉션을 사용하면 컴파일 타임에 모르는 클래스라도 런타임에 이 Class 객체를 통해 다음이 가능하다.

  • 생성자 찾기: 어떤 생성자가 있는지 확인하고 객체 생성 (newInstance)
  • 메서드 호출: 객체의 메서드 이름만으로 호출 (invoke)
  • 필드 접근: 객체의 필드값 읽기/수정 (get, set)

2. 왜 Reflections 라이브러리를 쓰는가?

Java 기본 java.lang.reflect만으로는
특정 어노테이션(@Service, @Repository)이 붙은 모든 클래스를 찾아내기 어렵다.
클래스 이름을 모르면 메모리상에 로드할 수 없기 때문이다.

프로젝트 내의 모든 클래스를 다 뒤져서 @Service가 붙은 놈들만 찾오려면, 일일이 클래스 로더를 뒤져야 한다.
여기서 등장하는 게 org.reflections 라이브러리다.
Java 기본 API가 도구라면, 이 라이브러리는 탐지기다.

  • 핵심 기능: 클래스패스 스캔

우리가 new Reflections("com.ll")라고 쓴 순간, 라이브러리는 다음을 수행한다.
지정한 패키지(com.ll)부터 하위 경로의 모든 .class 파일을 읽는다.
각 클래스에 붙은 어노테이션, 상속 관계, 메서드 시그니처 등을 미리 인덱싱한다.

이 인덱스를 바탕으로 우리가 원하는 클래스를 쿼리하듯 가져올 수 있게 해줍니다.

즉, 개발자는 클래스패스를 직접 뒤지는 복잡한 로직을 짤 필요 없이, 라이브러리가 미리 분석해둔 정보를 쏙 뽑아오기만 하면 된다.

3. Reflections 활용

1) 기본 설정 및 스캔

// com.ll" 패키지 아래의 모든 클래스를 분석하겠다 (이때 시간이 좀 걸림)
Reflections reflections = new Reflections("com.ll");

2) 어노테이션으로 찾기 (이번에 쓴 방법)

// @Service가 붙은 클래스를 전부 가져와라.
Set<Class<?>> serviceClasses = reflections.getTypesAnnotatedWith(Service.class);

이렇게 하면 해당 어노테이션이 붙은 모든 클래스가 Set에 담긴다. 이걸 활용해서 빈 목록을 만들거다.

3) 서브타입으로 찾기

List를 구현한 모든 클래스를 찾거나, BaseRepository를 상속받은 놈들을 찾을 때 쓴다.

Set<Class<? extends List>> listImpls = reflections.getSubTypesOf(List.class);

4.코드의 흐름

ApplicationContext 코드를 다시 복기해 보겠다.

  • 스캔 (Reflections): 프로젝트를 뒤져서 빈 후보들을 찾는다.

  • 분석 (Constructor): clazz.getDeclaredConstructors()[0]로 "이 객체는 어떻게 태어나는가?"를 확인한다.

  • 의존성 주입 (parameterTypes): constructor.getParameterTypes()로 "태어나려면 누가 필요한가?"를 확인한다.

  • 객체 생성 (newInstance): 위 정보를 바탕으로 new를 대신 호출해준다.

TestFacadePostService 생성 시도 -> TestPostService 필요 -> TestPostService 생성 시도 -> TestPostRepository 필요 -> TestPostRepository 생성 성공 -> TestPostService 완성 -> TestFacadePostService 완성!

5. 주의할 점

리플렉션은 강력하지만 장단점이 있다.

  • 성능 오버헤드: new를 직접 호출하는 것보다 리플렉션을 통한 생성은 훨씬 느리다. (하지만, 컨테이너 초기화 시 딱 한 번만 수행하므로 큰 문제는 안 된다.)

  • 보안 이슈: private 필드나 메서드도 억지로 접근(setAccessible(true))할 수 있어 캡슐화를 깨뜨릴 수 있다.

  • 컴파일 타임 에러 체크 불가: 클래스 이름을 문자열로 다루거나 런타임에 찾기 때문에, 오타가 나면 컴파일 에러가 안 뜨고 런타임에 죽는다.

Constructor

java.lang.reflect 패키지에 있다.

우리가 평소에 쓰는 new 키워드는 컴파일 타임에 이미 "어떤 클래스의 어떤 생성자를 쓸지" 박혀 있다.
하지만 리플렉션을 쓰면 우리는 런타임이 되기 전까지 어떤 클래스를 쓸지 모른다.

이때 그 클래스는 어떻게 생성되어야 하는가? 에 대한 정보를 담고 있는 객체가 바로 Constructor다.
생성자도 설계도 데이터(Object)로 취급할 수 있다.

1) 왜 굳이 Constructor 객체를 꺼내는가?

클래스 정보를 담고 있는 Class<?> 객체만으로는 객체를 만들 수 없다.

  • 코드에서의 생성자 (Static)
TestPostService service = new TestPostService(repository);

여기서 TestPostService(...)는 그냥 명령어다. 컴파일러가 "아, 이 클래스 생성자 호출하라는 거구나" 하고 바로 기계어로 바꿔버리기에 개발자가 이 생성자의 내부 정보(파라미터가 몇 개고 타입이 뭔지)를 런타임에 굳이 궁금해할 필요가 없다. 이미 다 정해져 있기에.

    1. 리플렉션에서의 Constructor 객체 (Dynamic)
Constructor<?> constructor = clazz.getDeclaredConstructors()[0];

여기서 constructor는 메모리에 올라가 있는 하나의 '객체'다.
자바의 java.lang.reflect.Constructor라는 클래스의 인스턴스다.
[0]을 쓴 건, 우리 예제에서는 생성자가 하나뿐이니까 일단 첫 번째 걸 쓰겠다는 뜻이다.생성자가 여러 개일 수도 있다면 어노테이션(@Autowired 같은 거)을 보고 골라내야 하지만, 지금은 가장 첫 번째 생성자가 이 클래스의 매개변수라고 가정한 거다.

왜 이렇게 하는가

  • 조사 가능: constructor.getParameterTypes()를 호출할 수 있다. 해당 클래스가 생성될때 어떤놈들을 주입 받는지 해당 클래스의 생성자를 보고 Class<?>[] 배열을 반환해준다.
  • 재조립 가능: constructor.newInstance(args)를 호출할 수 있다. new 키워드는 컴파일 타임에 어떤 클래스를 만들지 고정되어야 하지만, newInstance는 내가 런타임에 모아둔 args 배열을 던져주기만 하면 어떤 객체든 뚝딱 만들어낼 수 있다.

설계 의도

ApplicationContext의 핵심은 클래스가 지 의존성을 직접 찾지 않게 만드는 것이다. 그게 IoC니까.

1. 제어의 역전

필드값들을 스스로 선언하는게 아닌 생성자를 통해 외부에서 주입받는것을 제어의 역전이라고 한다.

2. 재귀를 통한 의존성 자동 조립 (Recursive Dependency Injection)

genBean을 호출할 때, 내부에 필요한 놈들을 다 찾아내서 먼저 genBean을 다시 호출하게 짰다.

이 로직 덕분에 우리는 TestFacadePostService -> TestPostService -> TestPostRepository로 이어지는 3단 의존성도, 순서를 일일이 고민할 필요 없이 호출 한 번으로 해결 가능했다. 의존성이 아무리 깊어져도 재귀적으로 파고들어가서 바닥(레포지토리)부터 조립해서 올라오니까.

3. Lazy Loading & Caching (싱글톤 보장)

beans라는 Map을 만든 이유다.

  • Lazy Loading: 프로그램이 켜지자마자 모든 빈을 다 만드는 건 메모리 낭비일 수 있다. 그래서 genBean을 호출했을 때(즉 사용될때) 비로소 해당 빈을 생성하도록 했다.

  • Caching: 한 번 만든 놈은 beans 맵에 박아두고, 다음 요청 때 또 만들지 않고 꺼내서 쓰도록 했다. 이렇게 한번만 생성되고 재사용하도록 하여 싱글톤을 보장했다.

4. 하드코딩 탈출 (Annotation Driven)

new를 직접 쓰지 않고 Reflections를 씀으로서, 나중에 빈이 100개가 되어도 코드를 한 줄도 안 바꿔도 되었다. 어노테이션(@Service, @Repository)만 붙여두면 컨테이너가 알아서 스캔해서 추가하니까.

Class객체

Class 객체는 JVM이 클래스를 로드할 때 메모리영역(Metaspace)에 생성되는 클래스 설계도다.

우리가 흔히 new MyClass()를 호출하면, 자바는 먼저 이 Class 객체를 확인해서 "아, 이런 모양의 객체를 찍어내면 되는구나" 하고 인스턴스를 만든다.
리플렉션을 쓴다는 건, 우리가 이 설계도(Class)를 직접 손에 쥐고 주무르겠다는 소리다.

Class<?> clazz

여기서 ?는 와일드카드다. "어떤 클래스인지는 모르겠지만, 어쨌든 '설계도 객체'를 담겠다"는 뜻이다.

메타데이터의 집합

이 객체 하나만 있으면 클래스 이름, 어노테이션, 생성자 정보, 메서드 정보 등 클래스에 대한 모든 정보를 다 캐낼 수 있다.

왜 beanDefinitions에 저장하는가?

beanDefinitions.put(beanName, clazz); 이 코드를 보면, 우린 객체(Instance)를 저장한 게 아니다. 객체를 찍어낼 수 있는 설계도를 저장한 거다.
그래야 나중에 필요할 때(Lazy Loading) 꺼내서 실제 객체를 찍어낼 수 있으니까.

1) 왜 클래스 객체를 직접 다뤄야 하는가?
보통 자바에서 new를 쓰면 컴파일러가 알아서 다 해주지만, 우린 지금 컨테이너를 만들고 있다.
컴파일 타임(Compile Time): 코드를 짤 때 new Service()라고 적으면, 컴파일러는 이 클래스가 뭔지 이미 다 알고 있다.

런타임(Run Time): 우리 컨테이너는 실행되기 전까지는 사용자가 어떤 클래스를 만들었는지 모른다.
그래서 Class 객체라는 설계도를 이용해, 실행 중에 어? 이 클래스에 @Service 붙어있네? 그럼 내가 객체 만들어야지"라고 판단하는 거다.

JVM이 사용하는 런타임 영역

결국 스프링이 어떻게 객체를 만들고 의존성을 주입해주는지 알려면 JVM 메모리 구조를 좀 알아야 한다. 그냥 막연하게 메모리라고 하지 말고, 어디에 뭐가 사는지 확실히 알아야 코드가 보인다.

영역담당하는 것프로젝트에서의 역할
Metaspace (DATA)클래스 메타데이터(설계도)Class<?> 객체, 어노테이션 정보 보관
Heap실제 데이터, 인스턴스(객체)new로 만든 Bean 인스턴스 저장
Stack메서드 호출, 로직 실행genBean 재귀 호출 시 변수/흐름 제어
Code명령어(바이트코드)JVM이 실행할 코드의 내용물

사실 JVM의 메모리 구조는 OS의 전통적인 4대 메모리 영역(Code, Data, Heap, Stack)을 기반으로 한다. 다만 자바는 클래스 단위의 객체 관리가 중요하기 때문에, Data 영역을 Method Area(Metaspace)로 확장하여 클래스 설계도와 정적 데이터를 관리하고, Heap 영역은 GC가 관리하는 특수한 저장소로 분리한 것뿐이다.

1. Metaspace 데이터영역 (Class 객체가 있는 곳)

우리가 앞서 말한 Class 객체(클래스 설계도)는 Metaspace라는 영역에 저장된다.

  • 뭐가 있나: 클래스 이름, 부모 클래스 정보, 필드 정보, 메서드 정보, 그리고 프로그램에서 쓴 어노테이션 정보 등이 여기 다 들어있다.

  • 참고: 옛날 자바(Java 7 이전)에서는 이 영역을 PermGen이라고 불렀지만, 지금(Java 8+)은 Metaspace라고 한다.
    이름은 바뀌었지만 클래스 메타데이터를 저장하는 역할은 같다.

  • 왜 중요한가: reflections.getTypesAnnotatedWith(...)를 호출할 때, 라이브러리는 JVM이 이미 메모리(Metaspace)에 올려둔 클래스 메타데이터를 스캔해서 긁어오는 거다.

2. Heap (메모리를 할당받은 객체(인스턴스)가 있는 곳)

constructor.newInstance(args)를 호출하는 순간, 힙(Heap) 메모리에 실제 객체(인스턴스)가 탄생한다.

  • 뭐가 있나: new 키워드로 생성된 모든 객체, 즉 실제 데이터와 상태값을 가진 녀석들이 여기 있다.(우리가 만드는 클래스라던가 자바가 제공해주는 String등의 객체들)

  • 왜 중요한가: beans라는 Map에 담기는 녀석들은 전부 이 힙 영역에 있는 객체들의 주소값(Reference)이다.

3. Stack (스택 - 메서드 실행의 무대)

스택은 메서드가 호출될 때마다 '스택 프레임'이라는 단위로 메모리에 쌓이는 공간이다.

  • 뭐가 있나: 메서드 안에서 선언된 지역 변수(Local Variables), 매개변수, 메서드 호출 정보(리턴 주소)가 들어간다.

  • 왜 중요한가: genBean을 재귀적으로 호출할 때,
    호출할 때마다 새로운 스택 프레임이 생성된다.
    거기엔 각 단계의 beanName, clazz, args 같은 지역 변수들이 저장된다.

  • 특징: LIFO(Last-In, First-Out) 구조다. 메서드가 끝나면 그 프레임은 즉시 메모리에서 제거(Pop)된다.

  • 이번 IoC 프로젝트와의 관계: 만약 의존성 관계가 너무 꼬여서 A -> B -> A 식으로 무한 재귀가 발생하면, 스택에 계속 프레임이 쌓이다가 공간이 꽉 차서 StackOverflowError가 터지는 거다.
    스택은 '현재 실행 중인 로직의 흐름'을 담당한다고 보면 된다.

4. Code / Method Area (코드/메서드 영역 - 명령어 저장소)

엄밀히 말하면 요즘 JVM(Java 8+)에서는 Metaspace가 메서드 영역의 역할을 대부분 흡수했다. 그래도 개념적으로는 분리해서 이해해두는 게 좋다.

  • 뭐가 있나: 우리가 짠 코드, 즉 바이트코드(Bytecode)가 여기에 저장된다. 메서드의 실제 로직(if문, for문, newInstance 호출 등)이 기계어 명령어 형태로 저장되어 있다.

  • 왜 중요한가: CPU가 "다음 줄에 뭐 실행해야 해?"라고 물어보면 JVM이 여기서 명령어를 읽어온다.

  • 특징: 공유 자원이다.
    모든 스레드가 이 영역의 코드를 읽어서 실행한다. 데이터(인스턴스)가 아니라 실행할 로직(명령어)이 들어있는 곳이라고 이해하면 된다.

genBean("service")를 호출하는 순간 벌어지는 전체 흐름은 아래와 같다.
1. Stack: genBean 메서드가 호출되면서 새로운 스택 프레임이 생성된다. 여기에 beanName="@@service"라는 변수가 저장된다.
2. Code (Metaspace): JVM은 genBean 메서드의 로직(바이트코드)을 코드 영역에서 읽어와 실행한다.
3. Metaspace: beanDefinitions 맵에서 클래스 설계도(Class)를 찾는다. (여기에 클래스 구조 정보가 다 있으니까)
4. Heap: 설계도를 보고 constructor.newInstance()를 실행한다. 이때 실제 Service 객체가 힙 메모리에 뿅 하고 탄생한다.
5. Stack: 메서드가 끝나면 스택 프레임은 사라지고, 힙에 생성된 객체의 주소값만 beans 맵(이것도 힙에 있음)에 저장된다.

profile
청년치매 예방기

0개의 댓글