Spring

Java 리플렉션과 스프링 DI 동작 원리 정리 : 직접 만들어보며 이해하기

경딩 2025. 9. 27. 20:04

 

스프링의 @Autowired는 어떻게 동작할까?
이 궁금증에서 시작된 리플렉션 탐험기.
단순 사용법을 익히는 것이 아니라, 직접 DI 컨테이너를 만들어 프레임워크 내부 원리를 깊이 이해해보자.

리플렉션 기초

리플렉션이란?

리플렉션은 런타임에 클래스의 메타데이터 (필드, 메서드, 생성자 등) 에 접근할 수 있게 해주는 Java API이다.

Class<T> API를 통해 클래스의 정보를 동적으로 분석하고 조작할 수 있다.

 

런타임의 객체정보를 컴파일 타입에 접근할 수 있다.

 

Java Platform SE 8 - Class Documentation

 

Class 문서를 살펴보면, 메서드를 살펴보면 메서드를 통해 필드, 또는 상위 클래스 , 클래스가 구현하고 있는 인터페이스 ,

메소드 목록들에 접근할 수 있다는 사실을 알 수 있다.


Class<T> 객체 얻는 3가지 방법

 

방법 코드 특징
타입 직접 참조 Book.class 가장 안전, 컴파일 타임 체크 가능
인스턴스에서 가져오기 book.getClass() 이미 객체가 있을 때 사용
문자열로 로딩 Class.forName("org.example.Book") 런타임 동적 로딩, 클래스 없으면 ClassNotFoundException

 

// 1. 타입.class로 가져오기
Class<Book> bookClass = Book.class;

// 2. 인스턴스.getClass()로 가져오기
Book book = new Book();
Class<? extends Book> aClass = book.getClass();

// 3. Class.forName("FQCN") - 문자열로 가져오기
Class<?> aClass1 = Class.forName("org.example.Book");

문자열 기반 로딩은 유연하지만 안정성은 낮다. IoC/ 플러그인 구조에서 자주 쓰임.

 

클래스 로딩이 완료되면 JVM은 해당 클래스의 Class 타입의 인스턴스를 힙 메모리에 생성한다.

이 메타데이터 객체를 통해 클래스의 모든 정보에 접근할 수 있다. 


리플렉션으로 할 수 있는 것들

Class<T>  인스턴스에 접근 시 참조할 수 있는 정보는 여러가지가 있다.

 

Class<T> 를 통해 할 수 있는 것

  • 필드 (목록) 가져오기
  • 메소드 (목록) 가져오기
  • 상위 클래스 가져오기
  • 인터페이스 (목록) 가져오기
  • 애노테이션 가져오기
  • 생성자 가져오기

 

1. 인스턴스에 정의된  public 필드만 가져오기

        Class<Book> bookClass = Book.class;

        Arrays.stream(bookClass.getFields()).forEach(System.out::println);

 

실행 결과

public java.lang.String org.example.Book.d

 

d 만 출력되는 이유는 해당 메서드는 public 멤버변수만 리턴하기 때문이다.

 

 

2. 인스턴스에 정의된 모든 접근 제어자의 필드 가져오기

       Arrays.stream(bookClass.getDeclaredFields()).forEach(System.out::println);

 

실행결과

private java.lang.String org.example.Book.a
private static java.lang.String org.example.Book.B
private static final java.lang.String org.example.Book.C
public java.lang.String org.example.Book.d
protected java.lang.String org.example.Book.e

 

 

3. 인스턴스에 정의된 필드 및 필드 값 가져오기

        //Book 클래스의 Class 객체를 얻음 (리플렉션 API 사용)
        Class<Book> bookClass = Book.class;

        Book book = new Book();

        // Book 클래스의 선언된 모든 필드를 가져와 순회
        Arrays.stream(bookClass.getDeclaredFields()).forEach( f -> {
            try {
                f.setAccessible(true); // private 필드도 접근 가능하게 설정
                System.out.printf("%s , %s\n ", f, f.get(book)); // 필드 정보와 해당 필드의 값 출력
            } catch (IllegalAccessException e) {
                throw new RuntimeException(e);
            }

        });

 

인스턴스 필드를 가져올때 는 인스턴스가 필요하다.

 f.get(book) 인스턴스를 통해 값을 가져올 수 있다.

 

리플렉션은 setAccessible(true) 를 통해 접근지시자를 무시할 수 있다.  이는 캡슐화를 깨뜨릴 수 있는 강력한 기능이다.

 

 

3. 인스턴스에 정의된 메서드 가져오기

        //Book 클래스의 Class 객체를 얻음 (리플렉션 API 사용)
        Class<Book> bookClass = Book.class;

        Book book = new Book();

        // Book 클래스의 선언된 모든 메소드를 가져와 순회
        Arrays.stream(bookClass.getMethods()).forEach(System.out::println);

 

실행결과

public void org.example.Book.g()
public final void java.lang.Object.wait(long,int) throws java.lang.InterruptedException
public final void java.lang.Object.wait() throws java.lang.InterruptedException
public final native void java.lang.Object.wait(long) throws java.lang.InterruptedException
public boolean java.lang.Object.equals(java.lang.Object)
public java.lang.String java.lang.Object.toString()
public native int java.lang.Object.hashCode()
public final native java.lang.Class java.lang.Object.getClass()
public final native void java.lang.Object.notify()
public final native void java.lang.Object.notifyAll()

 

4. 인스턴스에 정의된 생성자 가져오기

 

        //Book 클래스의 Class 객체를 얻음 (리플렉션 API 사용)
        Class<Book> bookClass = Book.class;

        Book book = new Book();

        // Book 클래스의 선언된 생성자를 가져와 순회
        Arrays.stream(bookClass.getDeclaredConstructors()).forEach(System.out::println);
public org.example.Book()
public org.example.Book(java.lang.String,java.lang.String,java.lang.String)

 

코드에 정의했던 빈 생성자, 파라미터 3개가 있는 생성자 모두 잘 가져온다.

    public Book() {
    }

    public Book(String a, String d, String e) {
        this.a = a;
        this.d = d;
        this.e = e;
    }

 

5. 상위 클래스 가져오기

        //Book 클래스의 Class 객체를 얻음 (리플렉션 API 사용)
        Class<? super MyBook> superclass = MyBook.class.getSuperclass();
        System.out.println(superclass);

 

실행 결과

class org.example.Book

 

6. 인터페이스 가져오기

        //Book 클래스의 Class 객체를 얻음 (리플렉션 API 사용)
        Class<?>[] interfaces = MyBook.class.getInterfaces();
        Arrays.stream(interfaces).forEach(System.out::println);

 

 

7. 접근 제어자 확인하기

        Arrays.stream( Book.class.getDeclaredFields()).forEach(f -> {
            int modifiers = f.getModifiers();
            System.out.println(f);
            System.out.println(Modifier.isPrivate(modifiers));
            System.out.println(Modifier.isStatic(modifiers));
        });

 

 

private java.lang.String org.example.Book.a
true
false
private static java.lang.String org.example.Book.B
true
true
private static final java.lang.String org.example.Book.C
true
true
public java.lang.String org.example.Book.d
false
false
protected java.lang.String org.example.Book.e
false
false

 

 

리플렉션 API 를 사용해 클래스가 가지고 있는 정보를 접근하는 방법을 살펴보았다.


리플렉션을 사용하여 실제 인스터스 만들기, 필드 값 변경하기, 메서드 실행하기

 

리플렉션 - 클래스 정보 수정 또는 실행

 

동적 인스턴스 생성

// 기본 생성자를 통한 생성
Constructor<?> constructor = bookClass.getConstructor();
Book book = (Book) constructor.newInstance();

// 파라미터가 있는 생성자를 통한 생성
Constructor<?> paramConstructor = bookClass.getConstructor(String.class, String.class, String.class);
Book book2 = (Book) paramConstructor.newInstance("Java Complete", "김자바", "테크출판사");

 

생성자를 가져와서 newInstance 를 호출하면 인스턴스가 생성된다.

 

필드에 있는 값 동적 조작

        Class<?> bookClass = Class.forName("org.example.create.Book");

        // Book(String b) 생성자를 이용해서 인스턴스 생성
        Constructor<?> constructor = bookClass.getConstructor(String.class);
        Book book = (Book) constructor.newInstance("Test");

        // -------------------------------
        // static 필드 접근
        // -------------------------------
        Field staticField = Book.class.getField("A"); // public static 필드 A
        System.out.println(staticField.get(null));    // 값 읽기
        staticField.set(null, "AA");                  // 값 수정
        System.out.println(staticField.get(null));    // 수정된 값 확인


        // -------------------------------
        // private 인스턴스 필드 접근
        // -------------------------------
        Field instanceField = Book.class.getDeclaredField("B"); // private 필드라 getDeclaredField 사용
        instanceField.setAccessible(true); // private 접근 허용

        // 인스턴스 필드이므로 객체 필요
        System.out.println(instanceField.get(book)); // 현재 값 읽기
        instanceField.set(book, "BBB"); // 값 수정
        System.out.println(instanceField.get(book)); // 수정된 값 확인

 

실행결과

A
AA
Test
BBB

 


메소드 접근해서 호출해보기

        // Book 클래스 메타데이터 가져오기
        Class<?> bookClass = Class.forName("org.example.create.Book");

        // Book(String b) 생성자를 이용해서 인스턴스 생성
        Constructor<?> constructor = bookClass.getConstructor(String.class);
        Book book = (Book) constructor.newInstance("Test");

        // ----------------------------
        // void c() 메서드 호출
        // ----------------------------
        Method cMethod = bookClass.getDeclaredMethod("c"); // 메서드 이름과 파라미터 타입 지정
        cMethod.setAccessible(true); // private 메서드일 경우 접근 허용
        cMethod.invoke(book); // book 인스턴스 대상으로 메서드 실행

        // ----------------------------
        // int sum(int left, int right) 메서드 호출
        // ----------------------------
        Method sumMethod = bookClass.getDeclaredMethod("sum", int.class, int.class);
        int result = (int)sumMethod.invoke(book, 1, 2); // book.sum(1, 2) 과 동일
        System.out.println("result = " + result); // 출력: 3

애노테이션과 리플렉션 : 메타데이터의 활용

기본 애노테이션 생성

package org.example;

public @interface MyAnnotation {
}

 

중요 : 애노테이션은 주석과 같다. 태그와 같은 역할이다.
  @Retention(RetentionPolicy.RUNTIME)이 없으면 컴파일된 바이트코드에서 메모리로 로딩될 때 사라진다.

 


런타임 애노테이션 유지

애노테이션이 런타입까지 유지되려면  

@Retention(RetentionPolicy.RUNTIME) 을 지정해야 한다. 기본 값은 클래스 이다.

 

package org.example;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

@Retention(RetentionPolicy.RUNTIME)
public @interface MyAnnotation {
}
  Arrays.stream( Book.class.getAnnotations()).forEach(System.out::println);

애노테이션 탐지

기본값은 클래스이기 때문에 코드를 컴파일 하고 바이트 코드를 열어보면 

특정 어노테이션에 붙은 필드를 찾을 수 있다. 필드에 붙어있는 애노테이션에 붙어 있는 정보도 조회할 수 있다.

        Arrays.stream( Book.class.getDeclaredFields()).forEach(f -> {
            Arrays.stream(f.getAnnotations()).forEach( a-> {
                if (a instanceof MyAnnotation) {
                    MyAnnotation annotation = (MyAnnotation) a;
                    System.out.println("annotation.value() = " + annotation.value());
                    System.out.println("annotation.number() = " + annotation.number());
                }
            });
        });

 

 

메타 애노테이션  이해: 

@Retention : 해당 애노테이션을 언제까지 유지할 것인가? (SOURCE, CLASS, RUNTIME)

@Inherit : 해당 애노테이션을 하위 클래스까지 전달할 것인가?

@Target : 어디에 적용할 수 있는가? (TYPE, FIELD, METHOD 등)

DI와 프레임워크에서는 런타임 애노테이션이 핵심 역할

나만의 DI 프레임워크 만들기

설계 목표

  • 스프링의 Autowired 처럼 필드 자동 주입 구현
  • 예제 클래스
public  class  BookService  { 
    @Inject 
    BookRepository  bookRepository; 
}

@Inject  애노테이션을 붙이면 BookRespository 객체가 자동 주입되게 만들어보자.

 

 

ContainerService 구현

/**
 * 제네릭을 활용한 객체 생성/반환 컨테이너 예제
 * 입력으로 클래스 타입(Class<T>) 을 받아 인스턴스를 반환하는 구조
 */
public class ContainerService {



    //  classType  생성하고자 하는 클래스 타입
    // <T> 반환 타입을 제네릭으로 지정
    public static <T> T getObject(Class<T> classType) {
    	return createInstance(classType);
    }

    // 실제 객체를 리플렉션으로 생성하는 메서드
    // getDeclaredConstructor(null) : 기본 생성자 호출
    // newInstance(): 객체 생성
    private static <T> T createInstance(Class<T> classType){
        try {
            return classType.getDeclaredConstructor(null).newInstance();
        } catch (InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException e) {
            throw new RuntimeException(e);
        }
    }

}

classType 에 해당하는 타입의 객체를 만들어준다.

단, 해당 객체의 필드 중에 @Inject 가 있다면 해당 필드로 같이 만들어 제공한다.

 

 

package org.example.di;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class ContainerServiceTest {

    @Test
    public void getObject_BookRespository(){
        // BookRepository 클래스 타입을 전달하여 객체 생성
        BookRepository bookRepository = ContainerService.getObject(BookRepository.class);
        // ContainerService Test 폴더에 있는  BookRepository 를 직접 참조하지 않아도
        // 리플렉션을 통해 객체를 생성함
        assertNotNull(bookRepository); // 객체 정상 생성 확인
    }

}

 

@Inject 로 내부 필드 객체 생성 전

    @Test
    public void getObject_BookService(){
        // BookService 클래스 타입을 전달하여 객체 생성
        BookService bookService = ContainerService.getObject(BookService.class);
        // ContainerService Test 폴더에 있는  BookRepository 를 직접 참조하지 않아도
        // 리플렉션을 통해 객체를 생성함
        assertNotNull(bookService); // 객체 정상 생성 확인
        assertNotNull(bookService.bookRepository); // null 값 들어와서 테스트 실패
    }

 

 

의존성 주입 기능 추가

 

@Inject 탐지하여 해당 타입의 인스턴스 생성 후 부모 객체에 넣어주기

/**
 * 제네릭을 활용한 객체 생성/반환 컨테이너 예제
 * 입력으로 클래스 타입(Class<T>) 을 받아 인스턴스를 반환하는 구조
 */
public class ContainerService {


    private static Object instance;

    //  classType  생성하고자 하는 클래스 타입
    // <T> 반환 타입을 제네릭으로 지정
    public static <T> T getObject(Class<T> classType) {
        T instance = createInstance(classType);

        // classType 내부에 선언된 모든 필드(멤버 변수) 를 순회
        Arrays.stream(classType.getDeclaredFields()).forEach(field -> {
            // 만약 해당 필들에 @Inject 어노테이션이 붙어 있다면 -> 의존성 주입 대상
            if ( field.getAnnotation(Inject.class) != null) {

                // 필드의 타입 가져오기 (예: BookRepository)
                Class<?> type = field.getType();

                // 필드 타입에 맞는 인스턴스 생성
                Object fieldInstance = createInstance(type);
                field.setAccessible(true);
                try {
                    // instance(부모 객체) 의 해당 필드에 fieldInstance 주입
                    field.set(instance, fieldInstance);
                } catch (IllegalAccessException e) {
                    throw new RuntimeException(e);
                }

            }
        });
        // 최종적으로 의존성이 주입된 instance 반환
        return instance;
    }

    // 실제 객체를 리플렉션으로 생성하는 메서드
    // getDeclaredConstructor(null) : 기본 생성자 호출
    // newInstance(): 객체 생성
    private static <T> T createInstance(Class<T> classType){
        try {
            return classType.getDeclaredConstructor(null).newInstance();
        } catch (InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException e) {
            throw new RuntimeException(e);
        }
    }

}

 

 

실제 사용 테스트

    @Test
    public void getObject_BookService(){
        // BookService 클래스 타입을 전달하여 객체 생성
        BookService bookService = ContainerService.getObject(BookService.class);

        assertNotNull(bookService);

        // BookService 내부에 선언된 BookRepository 필드도 @Inject가 붙어 있으면
        // 자동으로 인스턴스가 주입되어야 함
        assertNotNull(bookService.bookRepository);
    }

핵심 : 리플렉션으로 의존성 탐지 -> 인스턴스 생성 -> 필드 주입 수행

 

 

다른 프로젝트에서도 활용 

# JAR 파일 생성
./gradlew build

build/libs/reflection-example-1.0-SNAPSHOT.jar 파일이 생긴것 을 확인할 수 있다.

 

새로운 프로젝트 만들어서 build.gradle에 넣어주면 해당 코드를 가져다 쓸 수 있다.

// build.gradle에 의존성 추가
dependencies {
    implementation files('path/to/reflection-example-1.0-SNAPSHOT.jar')
}

 

우리가 직접 만든 ContainerService 를 사용해  Ioc Container 를 생성해보자.

package hello.diexample;

public class AccountRepository {

    public void save() {
        System.out.println("Repo.save");
    }
}

 

package hello.diexample;

import org.example.di.Inject;

public class AccountService {

    // @Inject -> ContainerService 가 이 필드에 AccountRepository 객체를 자동으로 주입
    @Inject
    AccountRepository accountRepository;

    public void join() {
        System.out.println("Service.join");
        accountRepository.save();
    }
}
// 실제 프로젝트에서 사용
@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        // 우리가 만든 DI 컨테이너 사용
        AccountService service = ContainerService.getObject(AccountService.class);
        service.processAccount(); // 의존성이 자동 주입된 상태로 실행
    }
}

메타프로그래밍의 철학적 의미

리플렉션 학습을 통해 깨달은 가장 중요한 점은 코드도 데이터라는 것이다.

// 일반적인 사고: 코드와 데이터는 분리된다
String data = "Hello";           // 데이터
method.invoke(object, data);     // 코드

// 리플렉션적 사고: 코드 자체가 조작 가능한 데이터
Class<?> clazz = Class.forName("MyClass");  // 클래스도 데이터
Method method = clazz.getMethod("myMethod"); // 메서드도 데이터

트레이드오프의 깊은 이해

편의성 vs 복잡성:

  • 개발자: @Inject 한 줄로 해결
  • 프레임워크: 수천 줄의 복잡한 리플렉션 로직

컴파일 타임 vs 런타임:

  • 정적 타입 검사 포기 → 런타임 유연성 획득
  • Early Binding → Late Binding

성능 vs 유연성:

  • 직접 호출: 빠르지만 경직적
  • 리플렉션: 느리지만 동적

6. 실무에서의 활용과 주의사항

주요 프레임워크의 활용 사례

Spring Framework:

  • @Autowired, @Component: 의존성 주입 자동화
  • @RequestMapping: HTTP 요청과 메서드 매핑
  • @Transactional: AOP를 통한 트랜잭션 관리

Hibernate/JPA:

  • @Entity: 클래스와 테이블 매핑
  • Setter 없는 필드 접근: 리플렉션을 통한 직접 필드 조작
  • Lazy Loading: 프록시 객체 생성

Jackson:

  • JSON ↔ Object 변환 시 private 필드 접근
  • 애노테이션 기반 직렬화/역직렬화 커스터마이징