리플렉션: 스프링의 DI는 어떻게 동작하는걸까?

Suyeon Jin·2022년 3월 6일
8

이제까지 자바와 스프링으로 개발을 해왔지만, 한번도 의존성 주입이 어떻게 이루어지는지 궁금해하지 않고 당연한 것처럼 써왔다.
이번 기회를 통해, 스프링 내부 동작 방식에 대해 공부해보려고 한다.

DI 내부 동작방식 ?

스프링으로 개발을 할 때, 우리는 기본적으로 Service와 Repository라는 컴포넌트를 만들고
Service에 @Autowired 라는 어노테이션을 통해 Repository를 주입시킨다.

이러한 과정을 DI(Dependency Injection) 즉, 의존성 주입이라고 한다.

실제로 테스트 클래스를 만들어보면,

package com.example.demo;

import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

@RunWith(SpringRunner.class)
@SpringBootTest
public class BookServiceTest {

    @Autowired
    BookService bookService;

    @Test
    public void di() {
    	// 스프링이 bookRepository를 생성하여, bookService에 DI 해줌
        Assert.assertNotNull(bookService);
        Assert.assertNotNull(bookService.bookRepository);
    }
}

테스트가 정상 통과가 되고, bookService와 bookRepository가 null이 아님을 알 수 있다.

그렇다면, 스프링은 DI를 어떻게 하는 것일까?

리플렉션(Reflection)

1) 구체적인 클래스 타입을 알지 못해도 그 클래스의 메소드, 타입, 변수들을 접근할 수 있도록 해주는 자바 API 이며,
2) 컴파일 시간이 아닌 실행 시간에 동적으로 특정 클래스의 정보를 추출해낼 수 있는 프로그램 기법 이다.

자바는 컴파일 시점에 타입을 결정하는 정적 언어인데, 동적으로 클래스를 사용해야 할 때 리플렉션이 사용된다.
즉, 작성 시점에는 어떤 클래스를 사용해야 할지 모르지만 런타임 시점에서 클래스를 실행해야 할 경우 사용된다.

대표적인 예시로, 스프링이 리플렉션을 이용하여 런타임 시에 개발자가 등록한 빈을 어플리케이션에서 가져와 사용할 수 있도록 한다.

  • 스프링 프레임워크: DI에 사용
  • MVC: View에서 넘어오는 데이터를 객체에 바인딩할 때 사용
  • Hibernate: @Entity 클래스에 setter가 없으면 해당 필드에 값을 바로 주입
  • JUnit: 아예 ReflectionUtils 라는 클래스를 내부적으로 정의하여 사용

그렇다면 이제 리플렉션 사용법에 대해 알아보자!

리플렉션 API 1 : 클래스 정보 조회

자바에서 리플렉션은 Class<?> 인스턴스로 접근이 가능하다. (리플렉션이 제공하는 API)
https://docs.oracle.com/javase/8/docs/api/java/lang/Class.html

아래의 예제를 통해, 리플렉션을 어떻게 사용하는지 알아보자.


public class Book {

    private static String a = "aBOOK";

    private static final String b = "bBOOK";

    private String c = "cBOOK";

    public String d = "dBOOK";

    protected String e = "eBOOK";

    public Book() {
    }

    public Book(String c) {
        this.c = c;
    }

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

    private void privateMethod() {
        System.out.println("privateVoidMethod");
    }

    public void publicMethod() {
        System.out.println("publicVoidMethod");
    }

    public int sum(int l, int r) {
        return l + r;
    }
}
  • 리플렉션을 사용하여, 클래스가 가지고 있는 '필드, 메서드, 생성자, 접근 지시자'와 같은 다양한 정보들에 접근할 수 있다.
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.Arrays;

public class Main {
    public static void main(String[] args) throws ClassNotFoundException, NoSuchFieldException {
        // 생성한 객체에 접근하려면, class가 필요하다. 클래스 인스턴스에 접근하는 방법은 3가지다.

        // 1) 타입을 통해 클래스 객체를 가져오는 방법
        // 클래스 로딩이 끝나면, 클래스 타입의 인스턴스를 만들어서 heap에 저장하므로 인스턴스를 가져올 수 있음
        Class<Book> bookClass = Book.class;

        // 2) 인스턴스를 통해 클래스 객체를 가져오는 방법
        // 인스턴스가 이미 만들어져있는 경우, getClass() 를 사용하여 가져올 수 있음
        Book book = new Book();
        Class<? extends Book> aClass = book.getClass();

        // 3) 경로를 통해 클래스 객체를 가져오는 방법
        // 클래스가 없으면 ClassNotFoundException 이 발생함
        Class<?> aClass1 = Class.forName("com.example.demo.Book");

		////////////////////////////////////////////////////////////////////////////////////
        // 필드 목록 가져오기
        System.out.println("-----모든 필드 목록 가져오기-----");
        Arrays.stream(bookClass.getDeclaredFields()).forEach(System.out::println);
        System.out.println();

        System.out.println("-----public 필드 목록만 가져오기-----");
        Arrays.stream(bookClass.getFields()).forEach(System.out::println);
        System.out.println();

        System.out.println("-----특정 이름을 가진 필드 목록만 가져오기-----");
        // 필드가 없으면 NoSuchFieldException 이 발생함
        Arrays.stream(new Field[]{bookClass.getDeclaredField("b")}).forEach(System.out::println);
        System.out.println();

        System.out.println("-----필드가 가지고 있는 값 목록 가져오기 (값을 가져올 때는 객체가 있어야함)-----");
        Arrays.stream(bookClass.getDeclaredFields()).forEach(f -> {
            try {
                f.setAccessible(true); // reflection으로는 이렇게 접근 지시자를 무시할 수 있음
                System.out.printf("%s %s \n", f, f.get(book));
            } catch (IllegalAccessException e) {
                e.printStackTrace();
            }
        });
        System.out.println();

        // 메서드 목록 가져오기
        System.out.println("-----모든 메서드 목록 가져오기-----");
        // 직접 정의한 public 메서드 외에도, Object로부터 상속받은 메서드도 출력됨
        // private 메서드도 출력하려면 getDeclaredMethods() 
        Arrays.stream(bookClass.getMethods()).forEach(System.out::println); 
        System.out.println();

        // 생성자 목록 가져오기
        System.out.println("-----모든 생성자 목록 가져오기-----");
        Arrays.stream(bookClass.getDeclaredConstructors()).forEach(System.out::println);
        System.out.println();

        // 접근 지시자 확인하기
        System.out.println("-----접근 지시자 확인하기-----");
        Arrays.stream(bookClass.getDeclaredFields()).forEach(f -> {
            int modifiers = f.getModifiers();
            System.out.print(f + "-> ");
            System.out.print("private? " + Modifier.isPrivate(modifiers));
            System.out.println(", static? " + Modifier.isStatic(modifiers));
        });
    }
}
-----모든 필드 목록 가져오기-----
private static java.lang.String com.example.demo.Book.a
private static final java.lang.String com.example.demo.Book.b
private java.lang.String com.example.demo.Book.c
public java.lang.String com.example.demo.Book.d
protected java.lang.String com.example.demo.Book.e

-----public 필드 목록만 가져오기-----
public java.lang.String com.example.demo.Book.d

-----특정 이름을 가진 필드 목록만 가져오기-----
private static final java.lang.String com.example.demo.Book.b

-----필드가 가지고 있는 값 목록 가져오기 (값을 가져올 때는 객체가 있어야함)-----
private static java.lang.String com.example.demo.Book.a aBOOK 
private static final java.lang.String com.example.demo.Book.b bBOOK 
private java.lang.String com.example.demo.Book.c cBOOK 
public java.lang.String com.example.demo.Book.d dBOOK 
protected java.lang.String com.example.demo.Book.e eBOOK 

-----모든 메서드 목록 가져오기-----
public int com.example.demo.Book.sum(int,int)
public void com.example.demo.Book.publicMethod()
public final native void java.lang.Object.wait(long) throws java.lang.InterruptedException
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 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()

-----모든 생성자 목록 가져오기-----
public com.example.demo.Book(java.lang.String,java.lang.String,java.lang.String)
public com.example.demo.Book(java.lang.String)
public com.example.demo.Book()

-----접근 지시자 확인하기-----
private static java.lang.String com.example.demo.Book.a-> private? true, static? true
private static final java.lang.String com.example.demo.Book.b-> private? true, static? true
private java.lang.String com.example.demo.Book.c-> private? true, static? false
public java.lang.String com.example.demo.Book.d-> private? false, static? false
protected java.lang.String com.example.demo.Book.e-> private? false, static? false
  • 또한, 리플렉션으로 클래스가 가지고 있는 '부모 클래스, 인터페이스'와 같은 다양한 정보들에도 접근할 수 있다.
public interface MyInterface {}
public interface BookInterface {}

public class MyBook extends Book implements MyInterface, BookInterface {}
import java.util.Arrays;

public class Main {
    public static void main(String[] args) {
    
        Class<MyBook> myBookClass = MyBook.class;
        
        // 상위 클래스 목록 가져오기
        System.out.println("-----부모클래스 목록 가져오기-----");
        System.out.println(myBookClass.getSuperclass());
        System.out.println();

        // 인터페이스 목록 가져오기
        System.out.println("-----모든 인터페이스 목록 가져오기-----");
        Arrays.stream(myBookClass.getInterfaces()).forEach(System.out::println);
        System.out.println();
    }
}
-----부모클래스 목록 가져오기-----
class com.example.demo.Book

-----모든 인터페이스 목록 가져오기-----
interface com.example.demo.MyInterface
interface com.example.demo.BookInterface

Reflection 과 Annotation

  • 리플렉션으로 클래스가 가지고 있는 '어노테이션' 정보 또한 확인할 수 있다.

하지만 Book 클래스가 가지고 있는 어노테이션 정보를 확인하였을 때, 실제 결과는 아무것도 출력되지 않는다. (?_?)

public @interface MyAnnotation {}

@MyAnnotation
public class Book {}
import java.util.Arrays;

public class Main {
    public static void main(String[] args) {
        // 어노테이션 목록 가져오기
        System.out.println("-----모든 어노테이션 목록 가져오기-----");
        Arrays.stream(Book.class.getAnnotations()).forEach(System.out::println);
        System.out.println();

    }
}
-----모든 어노테이션 목록 가져오기-----

어노테이션은 근본적으로 주석과 같은 취급을 받기 때문에, 어노테이션 정보가 클래스, 소스까지는 남지만 바이트 코드를 로딩할 때 메모리 상에 남지 않게 된다. (즉, 어노테이션 정보는 빼고 읽어오게 된다.)

@Retention

그래서 런타임까지 어노테이션 정보를 유지하고 싶다면, @Retention 어노테이션을 사용하면 된다.

  • 바이트 코드에 어노테이션 정보가 있는지 확인하고 싶다면, java -c -v 클래스_절대경로.class 를 사용하면 된다.
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

// Retention 기본값은 RetentionPolicy.CLASS
@Retention(RetentionPolicy.RUNTIME) // 런타임까지 어노테이션을 유지한다.
public @interface MyAnnotation {
}
-----모든 어노테이션 목록 가져오기-----
@com.example.demo.MyAnnotation()

@Target

어노테이션을 적용할 수 있는 범위를 설정할 수도 있다.

아래 예제에서 MyAnnotation을 생성자나 메서드에 적용하려고 하면, 에러가 발생한다.

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

@Target({ElementType.TYPE, ElementType.FIELD}) // 어노테이션을 사용할 수 있는 곳은 클래스와 필드
@Retention(RetentionPolicy.RUNTIME) // 런타임까지 어노테이션을 유지한다.
public @interface MyAnnotation {
}

@Inherited

어노테이션을 하위 클래스까지 전달할 것인지, 상속 여부를 결정할 수도 있다.

아래 예제를 보면, Book 클래스는 MyAnnotation이 적용되어 있으며,
MyBook 클래스는 BookAnnotation이 적용되어 있음과 동시에 Book 클래스를 상속받고 있다.

import java.lang.annotation.*;

@Inherited // 상속이 가능하도록 한다.
@Target({ElementType.TYPE, ElementType.FIELD}) // 어노테이션을 사용할 수 있는 곳은 클래스와 필드
@Retention(RetentionPolicy.RUNTIME) // 런타임까지 어노테이션을 유지한다.
public @interface MyAnnotation {
}

@Retention(RetentionPolicy.RUNTIME) // 런타임까지 어노테이션을 유지한다.
public @interface BookAnnotation {
}
@MyAnnotation
public class Book {}

@BookAnnotation
public class MyBook extends Book implements MyInterface, BookInterface {}

이 상태에서 MyBook 클래스의 어노테이션 목록을 출력하면, Book 클래스에 적용된 MyAnnotation 도 같이 하위 클래스인 MyBook 클래스에 적용되어 출력되게 된다.
부모 클래스가 아닌, 해당(자식) 클래스에 명시된 어노테이션 목록만 따로 출력할 수도 있다.

import java.util.Arrays;

public class Main {
        System.out.println("----모든 어노테이션 목록 가져오기-----");
        Arrays.stream(MyBook.class.getAnnotations()).forEach(System.out::println);
        System.out.println();

        System.out.println("----명시된 어노테이션 목록 가져오기-----");
        Arrays.stream(MyBook.class.getDeclaredAnnotations()).forEach(System.out::println);
        System.out.println();
    }
}
----모든 어노테이션 목록 가져오기-----
@com.example.demo.MyAnnotation()
@com.example.demo.BookAnnotation()

----명시된 어노테이션 목록 가져오기-----
@com.example.demo.BookAnnotation()

리플렉션 API 2 : 클래스 정보 수정 및 실행

리플렉션으로 클래스의 정보를 가져오는 것 뿐만 아니라,
1) 생성자로 인스턴스를 만들 수 있고 2) 필드 값을 가져오거나 수정할 수 있으며 3) 메소드를 실행할 수 있다.

import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

public class Main2 {
    public static void main(String[] args) throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException,
            InstantiationException, IllegalAccessException, NoSuchFieldException {

        Class<?> bookClass = Class.forName("com.example.demo.Book");

        // 1) 생성자를 사용하여 인스턴스 생성하기 Constructor.newInstance(params)
        Constructor<?> constructor = bookClass.getConstructor(null); // 기본 생성자를 사용하여 인스턴스를 만들겠다.
        Book book = (Book) constructor.newInstance();
        System.out.println(book);

        // 2) static 필드 값 가져오기 Field.get(object)
        Field a = Book.class.getDeclaredField("a");
        a.setAccessible(true); // private scope 이기 때문에 접근 지시자를 무시하도록 설정해줘야 함
        System.out.println(a.get(null)); // static 변수이므로, 특정 인스턴스를 넘겨줄 것이 없다. 그러므로 null을 넘긴다.

        // 2) static 필드 값 수정하기 Field.set(object, value)
        a.set(null, "abook");
        System.out.println(a.get(null));

        // 2) 필드 값 가져오기 Field.get(object)
        // 특정 인스턴스가 가지고 있는 값을 가져오는 것이기 때문에, 인스턴스가 미리 만들어져 있어야한다.
        Field d = Book.class.getDeclaredField("d");
        System.out.println(d.get(book)); // 미리 만들어져 있던 Book 클래스 객체(book)를 사용하여 해당 객체의 필드 값을 가져옴

        // 2) 필드 값 수정하기 Field.set(object, value)
        d.set(book, "dbook");
        System.out.println(d.get(book));

        // 3) 메소드 실행하기 Method.invoke(object, params)
        Method privateMethod = Book.class.getDeclaredMethod("privateMethod");
        privateMethod.setAccessible(true);
        privateMethod.invoke(book);

        // 3) 파라미터와 리턴 값이 있는 메서드 실행하기 Method.invoke(object, params)
        Method sum = Book.class.getDeclaredMethod("sum", int.class, int.class);
        int result = (int) sum.invoke(book, 10, 20);
        System.out.println(result);
    }
}
com.example.demo.Book@3f0ee7cb
aBOOK
abook
dBOOK
dbook
privateVoidMethod
30

리플렉션을 사용하여 DI 프레임워크 만들기

  • test/java
package com.example.di;

public class BookRepository {}

public class BookService {
    @Inject // 필드(BookRepository) 주입
    BookRepository bookRepository;
    
    BookRepository bookRepositoryWithOutInject;
}
package com.example.di;

import org.junit.Test;

import static org.junit.Assert.assertNotNull;

public class ContainerServiceTest {

    @Test
    public void getObject_BookRepository() {
    	// @Inject 를 적용하지 않은 객체
        BookRepository bookRepository = ContainerService.getObject(BookRepository.class);
        assertNotNull(bookRepository);
    }

    @Test
    public void getObject_BookService() {
    	// @Inject 를 적용하여 객체를 주입한 객체
        BookService bookService = ContainerService.getObject(BookService.class);
        assertNotNull(bookService);
        assertNotNull(bookService.bookRepository);
        assertNotNull(bookService.bookRepositoryWithOutInject); // FAIL 
    }
}
  • src/main/java
    1) @Inject
    필드 주입에 사용되는 객체인지 확인하기 위해, @Inject 라는 어노테이션을 만든다.
    런타임 시 참조해야하므로 @Retention 어노테이션도 적용해준다.
package com.example.di;

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

@Retention(RetentionPolicy.RUNTIME)
public @interface Inject {
}

2) ContainerService
getObject 메서드를 통해 classType 에 해당하는 타입의 객체를 만들어준다.
단, 해당 객체의 필드 중에 @Inject 어노테이션이 있다면 해당 필드도 같이 만들어 제공한다.

package com.example.di;

import java.util.Arrays;

public class ContainerService {

	// 클래스 타입이 들어오면, 해당 클래스의 인스턴스를 리턴하도록 Generic 타입으로 정의함
    public static <T> T getObject(Class<T> classType) {
        T instance = createInstance(classType); // 리플렉션을 이용하여 인스턴스를 생성한다.
        Arrays.stream(classType.getDeclaredFields()).forEach(f -> { // 클래스 타입의 필드들을 확인하면서,
            if (f.getAnnotation(Inject.class) != null) { // 필드에 적용된 어노테이션이 @Inject 이면
                Object fieldInstance = createInstance(f.getType()); // 필드 타입에 맞는 클래스 인스턴스를 생성하고
                f.setAccessible(true); // 접근 지시자를 무시하도록 설정하고
                try {
                    f.set(instance, fieldInstance); // 해당 필드에 객체를 주입한다.
                } catch (IllegalAccessException e) {
                    throw new RuntimeException(e);
                }
            }
        });

        return instance;
    }

    private static <T> T createInstance(Class<T> classType) { // 리플렉션 이용-> 생성자를 만들어 리턴해준다.
        try {
            return classType.getConstructor(null).newInstance();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

위의 예제는 예외 처리, 캐싱과 같은 최적화를 위한 기법이 들어가지 않아 효율적인 IOC 컨테이너는 아니다. 실제로 스프링이 DI를 할 때는 훨씬 복잡하고 정교하게 이루어져 있다.

하지만, 스프링이 DI를 수행하는 방법은 위와 같은 리플렉션을 활용한 객체 생성 및 주입임을 기억하고 가자.

정리

리플렉션은 강력한 기능인 만큼, 지나치게 사용하거나 잘못 사용한 경우에는 성능 이슈를 야기할 수 있다.

  • 인스턴스는 이미 생성되어 있고 그걸 계속 활용할 수 있으면, 리플렉션으로 접근하는 방법은 불필요하다. (리플렉션을 계속 사용하면, 새로운 객체를 계속 생성하는 것이기 때문에 비효율적임)
  • 컴파일 시 확인하지 않고 런타임 시에만 발생하는 문제가 발생할 수 있다.
  • 접근 지시자를 무시할 수 있다. (캡슐화를 무시하게 됨)

하지만 잘못 사용했을 때 성능 이슈가 발생한다는 것이지, 잘만 사용한다면 리플렉션은 강력한 기능이다.
남발하지 말고 적재적소에 사용하도록 하자 ! (생각하는 개발자가 되자 @_ㅠ; )


이 포스팅은 백기선님의 더 자바, "코드를 조작하는 다양한 방법"을 수강하고 정리한 내용입니다.

0개의 댓글