공부하고 기록하는, 경제학과 출신 개발자의 노트

학습일지/Language

Java Reflection 정리

inspirit941 2020. 10. 26. 17:37
반응형

인프런 '더 자바 - 코드를 조작하는 다양한 방법' 백기선님 강의내용 정리

Reflection

코드를 조작하는 다양한 방법 중 하나.

  • 정의된 클래스 / 메소드 / annotation 정보를 확인하고 참조하기
  • 인스턴스를 생성하고, 필드값을 변경하며, 메소드를 실행할 수 있는 방법.

스프링부트 프로젝트 생성. dependency에 스프링 웹 추가.

BookService와 BookRepository라는 두 개의 컴포넌트를 생성하고,

BookService에 Autowired로 BookRepository를 추가한 뒤

BookServiceTest 코드를 아래처럼 작성한다.

package com.inspirit941.thejavaspringreflection;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit.jupiter.SpringExtension;

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

@ExtendWith(SpringExtension.class)
@SpringBootTest
public class BookServiceTest {
    @Autowired
    BookService bookService;

    // 스프링이 bookRepository 생성해서 bookService에 DI 해주었음을 확인하는 코드
    // 생성해서 DI를 넣었기 때문에 NotNull이 True를 반환한다.
    @Test
    public void di(){
        assertNotNull(bookService.bookRepository);
    }
}

테스트를 통과한다. 즉 BookService 클래스에 BookRepository가 성공적으로 DI 완료됨. 이걸 스프링에서 어떻게 하는지가 Reflection 기능의 핵심이다.

Reflection API 1

maven architype quickstart로 새 프로젝트 생성.

reflection을 사용하려면 class Class< T > 문서를 확인한다.

reflection으로 원하는 정보를 참조하는 방법

package com.inspirit941;

public class Book {
    private static String staticName = "staticName";
    private static final String staticFinalName = "staticFinalName";
    private String privateName = "privateName";
    public String publicName = "publicName";

    public Book() {
    }
    public Book(String privateName, String publicName) {
        this.privateName = privateName;
        this.publicName = publicName;
    }
    private void privateVoidFunc() {

    }
    public void publicVoidFunc() {

    }
    public int publicIntFunc(){
        return 100;
    }
}
/// 인터페이스 정의
package com.inspirit941;

public interface MyInterface {
}
// 상속받은 구현체 클래스 정의
package com.inspirit941;

public class MyBook extends Book implements MyInterface {
}
package com.inspirit941;

import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.Arrays;

public class App 
{
    public static void main( String[] args ) throws ClassNotFoundException {
        // 생성한 객체에 접근하려면 class가 필요. 클래스 인스턴스에 접근하는 방법은 아래와 같다.
        // 1. 클래스 로딩이 끝나면, 클래스 타입의 인스턴스를 heap에 저장한다.
        // 클래스를 로딩만 해도 인스턴스가 생성됨. 클래스 타입의 인스턴스를 가져오는 방법은 아래와 같음.
        Class<Book> bookClass = Book.class;
        // 인스턴스가 이미 있을 경우, getClass()로 클래스 타입을 가져올 수 있다.
        Book book = new Book();
        book.getClass();

        // FQDN만 알고 있다면 (예컨대 com.inspirit941.Book 문자열만 알고 있으면)
        // 클래스 타입의 인스턴스를 Class.forName으로 찾을 수 있다. 클래스 없으면 notfoundException.
        // 패키지 경로의 클래스명을 입력하는 걸 스프링에서 해봤다 -> 이런 식으로 내부동작할 가능성이 높음.
        Class<?> aClass1 = Class.forName("com.inspirit941.Book");

        // 참조할 수 있는 정보들.
        // 필드 배열 확인하기.
        Field[] fields = bookClass.getFields();
        Arrays.stream(bookClass.getFields()).forEach(System.out::println);
        // return publicName. 필드 하나만 나온다.
        System.out.println("======");
        // getFields() 메소드는 public Scope만 리턴하기 때문.

        // 필드를 다 가져오려면 getDeclaredFields("특정한 필드명만 가져오려면 여기에 입력");
        Arrays.stream(bookClass.getDeclaredFields()).forEach(field -> {
            try {
                // 접근제한자 속성을 풀려면 true 입력. reflection으로는 이렇게 접근지시자를 무시할 수 있음.
                field.setAccessible(true);
                // 실제로 필드를 가져오려면, 인스턴스가 있어야 한다. get(book)으로 인스턴스를 불러옴.
                System.out.printf("%s %s\n", field, field.get(book));
                // getModifiers() 메소드로 해당 메소드의 scope를 알 수 있다.
                int modifiers = field.getModifiers();
                System.out.println(Modifier.isPrivate(modifiers));
                System.out.println(Modifier.isStatic(modifiers));
            } catch (IllegalAccessException e) {
                e.printStackTrace();
            }
        });

        // 메소드 가져오기
        // 직접 정의한 것 외에도 Object에서 상속받은 메소드도 전부 출력
        Arrays.stream(bookClass.getDeclaredMethods()).forEach(System.out::println);
        // 생성자: getDeclaredConstructors(), 부모클래스: getSuperclass()
        System.out.println("부모클래스 가져오기");
        System.out.println(MyBook.class.getSuperclass());
        // 인터페이스 : getInterfaces()
        Arrays.stream(MyBook.class.getInterfaces()).forEach(System.out::println);
    }
}

reflection과 annotation

중요 Annotation

  • @Retention : 해당 annotation을 언제까지 유지할 것인가 (소스, 클래스, 런타임)
  • @Inherit : 하위 클래스까지 어노테이션을 전달할 것인가
  • @Target : 어디에 사용할 수 있는가

Annotation은 인터페이스를 정의할 때 @Interface로 변경하면 바로 사용할 수 있고, 클래스 정의부분 바로 위에 붙여서 사용한다.

public @interface MyAnnotation {
}

@MyAnnotation
public class Book {
    //...
}

바이트코드에서 annotation 정보까지 보려면 javap -c -v 클래스_절대경로.class를 터미널에 입력하면 됨.

package com.inspirit941;

import java.lang.annotation.*;

// Retention의 기본값은 Class. 실행 시까지 정보가 유지되려면 Runtime
@Retention(RetentionPolicy.RUNTIME)
// @Target으로 해당 Annotation의 적용가능한 범위 설정. type과 field만 설정할 경우 생성자 / 메소드 등에는 불가능.
@Target({ElementType.TYPE, ElementType.FIELD})
// 만약 MyBook에서도 Book에 정의된 annotation정보를 가져와야 할 경우에 @Inherited 명시.
@Inherited
public @interface MyAnnotation {
    // annotation에는 제한된 타입의 값만 정의할 수 있다. primitive type & reference, list 등.
    // 디폴트 값 설정도 가능. 기본값 정의하지 않으면, Annotation을 사용하는 곳에서 반드시 값을 입력해야 함.
    // 여기 정의된 필드명은 기본적으로 annotation 사용하는 측에서 값을 할당할 때 명시해야 한다.
    // @MyAnnotation(value = "name", number = 10) 처럼
    String value() default "donggeonlee";
    int number() default 100;

}
package com.inspirit941;

import java.lang.reflect.Array;
import java.util.Arrays;

public class testAnnotation {
    public static void main(String[] args) {
        // annotation을 조회하려고 아래 코드를 실행하면, 기본적으로는 아무것도 보이지 않는다.
        Arrays.stream(Book.class.getAnnotations()).forEach(System.out::println);
        System.out.println("======");
        // Annotation 자체는 주석과 같은 취급이기 때문.
        // 소스, 클래스까지는 해당 정보가 남지만, 바이트코드를 로딩했을 때 메모리에는 남지 않기 때문.
        // 런타임에도 이 값을 유지하려면 @Retention()에 Runtime을 추가해야 한다.
        // 사용할 수 있는 위치를 지정하는 @Target.

        // MyAnnotation에 @Inherited가 붙어 있으면
        // MyBook에서 getAnnotation을 수행해도 annotation값을 볼 수 있다.
        // 상속받은 거 말고 해당 클래스에 정의된 것만 불러오고 싶으면 getDeclaredAnnotations()
        Arrays.stream(MyBook.class.getAnnotations()).forEach(System.out::println);
        System.out.println("======");

        // 필드 / 메소드에 붙은 Annotation을 확인하려면?
        // ex) 필드에 붙은 Annotation설정 가져오기
        Arrays.stream(Book.class.getDeclaredFields()).forEach(f -> {
            Arrays.stream(f.getAnnotations()).forEach(annotation -> {
                System.out.println(annotation);
                // return MyAnnotation(value = "publicNameField", number = 10)

                // 필드에 붙은 Annotation은 Annoation() 타입.
                // 내부에 정의된 값을 따로 조회하거나 참조할 수 있음.
                if (annotation instanceof MyAnnotation) {
                    MyAnnotation myAnnotation = (MyAnnotation) annotation;
                    System.out.println(myAnnotation.value());
                    System.out.println(myAnnotation.number());
                }
            });
        });
    }
}

이 Reflection을 사용해서

  • 실제 인스턴스를 만들고
  • 필드값 변경 / 메소드 실행이 가능하다.

Reflection API 2

book2 클래스 정의

package com.inspirit941;

public class Book2 {
    private static String staticName = "staticName";
    private static final String staticFinalName = "staticFinalName";
    private String privateName = "privateName";
    public String publicName = "publicName";
    public Book2() {
    }
    public Book2(String privateName, String publicName) {
        this.privateName = privateName;
        this.publicName = publicName;
    }
    private void privateVoidFunc() {
    }
    public void publicVoidFunc() {
        System.out.println(publicName);
    }
    public int publicIntFunc(int left, int right){
        return left + right;
    }
}

reflection으로

  1. 생성자 사용해 인스턴스 생성하기
  2. 필드값 가져오고 수정하기
  3. 메소드 가져오고 실행하기
package com.inspirit941;

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

public class testAnnotation2 {
    public static void main(String[] args) throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException, NoSuchFieldException {
        // 1. 인스턴스 생성하기.
        Class<?> bookClass = Class.forName("com.inspirit941.Book2");
        // 기본 권장사항은 Constructor 사용. -> 기본생성자일 경우 null을 파라미터로 입력.
        Constructor<?> constructor = bookClass.getConstructor(null);
        Book2 defaultBook2 = (Book2) constructor.newInstance();
        System.out.println(defaultBook2);
        // 문자열 입력받는 생성자를 가져와야 할 경우 String.class를 파라미터로 입력.
        Constructor<?> stringConstructor = bookClass.getConstructor(String.class, String.class);
        Book2 stringBook = (Book2) stringConstructor.newInstance("privateName", "publicName");
        System.out.println(stringBook);

        // 필드값 가져오기
        // 1. static : 모든 클래스가 같이 쓰는 변수이므로 특정 오브젝트를 넘겨줄 필요가 없음.
        // get 메소드 안에 null입력.
        Field staticName = Book2.class.getDeclaredField("staticName");
        // private이라서 accessible 설정.
        staticName.setAccessible(true);
        System.out.println(staticName.get(null));
        // 값 변경하기.
        staticName.set(null, "setStaticNameOnMainMethod");
        System.out.println(staticName.get(null));

        // 2. 특정 인스턴스의 필드 가져오기.
        Field publicName = Book2.class.getDeclaredField("publicName");
        // 인스턴스가 있어야 가져올 수 있는 값. 위에서 정의한 stringBook을 인자로 가져온다.
        System.out.println(publicName.get(stringBook));
        publicName.set(stringBook, "setPublicFieldByMainMethod");
        System.out.println(publicName.get(stringBook));

        // 3. 메소드 가져오기.
        // 메소드를 실행하려면 invoke(). 파라미터로는 메소드를 실행할 인스턴스 & 메소드 실행에 필요한 파라미터.
        Method publicVoidFunc = Book2.class.getDeclaredMethod("publicVoidFunc");
        publicVoidFunc.invoke(stringBook);
        // 메소드 파라미터가 있다면, 파라미터도 같이 인풋으로 지정한다. 여기서 primitive / reference 타입을 구분해야 한다.
        Method publicIntFunc = Book2.class.getDeclaredMethod("publicIntFunc", int.class, int.class);
        int returnValue = (int) publicIntFunc.invoke(stringBook, 1, 2);
        System.out.println(returnValue);
    }
}

DI 프레임워크 생성하기

@Inject라는 Annotation으로 필드값을 주입하는 컨테이너 생성하기.

di라는 하위패키지를 생성하고, Inject Annotation과 ContainerService 등 구현에 필요한 객체를 생성한다. 테스트를 위해 ContainerServiceTest도 생성.

cf. Test 디렉토리에 있는 객체는 src 참조가 가능하지만, 그 역은 성립하지 않는다.

Test 디렉토리

package com.inspirit941.di;

public class BookService {
    // 필드 주입
    @Inject
    BookRepository bookRepository;
}

package com.inspirit941.di;

public class BookRepository {
}

package com.inspirit941.di;

import org.junit.jupiter.api.Test;

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

public class ContainerServiceTest {

    @Test
    // inject 쓰지 않은 객체.
    public void getObject_BookRepository(){
        BookRepository bookRepository = ContainerService.getObject(BookRepository.class);
        assertNotNull(bookRepository);
    }

    @Test
    // inject로 객체를 주입한 객체를 생성.
    public void getObject_BookService(){
        BookService bookService = ContainerService.getObject(BookService.class);
        assertNotNull(bookService);
        assertNotNull(bookService.bookRepository);
    }
}

src 코드

package com.inspirit941.di;

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

// 런타임에 참조해야 하므로
@Retention(RetentionPolicy.RUNTIME)
public @interface Inject {
}


package com.inspirit941.di;

import java.lang.reflect.InvocationTargetException;
import java.util.Arrays;

public class ContainerService {
    // Generic Type 선언. 클래스 타입이 들어오면, 해당 클래스의 인스턴스를 리턴하도록.
    // 테스트 코드에 정의된 객체를 리턴하기 위해 reflection 사용.
    public static <T> T getObject(Class<T> classType) {
        T instance = createInstance(classType);
        // 생성한 인스턴스의 필드를 확인한다.
        Arrays.stream(classType.getDeclaredFields()).forEach(f -> {
            Inject annotation = f.getAnnotation(Inject.class);
            if (annotation != null){
                // BookService객체를 생성할 경우, 여기에는 @inject로 주입받은
                // BookRepository 객체가 getType() 메소드의 리턴값이 된다.
                Object annotationInstance = createInstance(f.getType());
                // 접근 가능하도록 설정해놓고
                f.setAccessible(true);
                try {
                    // 객체 주입.
                    f.set(instance, annotationInstance);
                } catch (IllegalAccessException e) {
                    throw new RuntimeException();
                }
            }
        });
        return instance;
    }
    public static <T> T createInstance(Class<T> classType) {
        try {
            return classType.getConstructor(null).newInstance();
        } catch (InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException e) {
            throw new RuntimeException();
        }
    }
}

이렇게 생성한 IoC 컨테이너 사용하기

  1. mvn install 실행. 실행하면 로컬 maven에 jar파일이 들어간다.
  2. 새 프로젝트를 생성하고, 저장한 프로젝트의 groupId / artifactId / version값을 dependency에 넣는다. mvn package를 새 프로젝트 터미널에서 실행했을 때 External Library에 배포한 프로젝트 jar파일이 있으면 됨.

cf. version에 에러표시가 뜰 수 있는데, snapshot을 지우고 1.0.0.RELEASE 같은 이름으로 바꾸고 1부터 진행한다.

훨씬 복잡하고 정교하게 이루어져 있지만, 스프링이 DI를 수행하는 방법의 요체는 위와 같은 reflection을 활용한 객체 생성 및 주입이다.

정리 및 활용

reflection으로

  1. 클래스의 인스턴스 생성
  2. 필드 세팅 / 메소드 실행 등이 가능함.

사용처

  • 스프링은 의존성 주입에 사용하고,
  • MVC의 경우 View에서 넘어오는 데이터를 특정 객체에 바인딩할 때,
  • Hibernate에서도 특정 Entity에 setter가 없으면 해당 필드에 값을 바로 주입(설정)한다.
  • JUnit의 경우 아에 ReflectionUtils라는 클래스를 내부적으로 정의해서 씀.

유의점

  • 기능은 강력하지만, 지나치게 / 잘못 쓸 경우 성능이슈 발생.
    • 예컨대 인스턴스가 이미 생성되어 있고 그걸 계속 활용할 수 있으면, reflection으로 접근하는 방법은 불필요함.
    • 컴파일에서는 확인되지 않고, 런타임에서만 발생하는 문제가 발생.
    • 접근지시자를 무시할 수 있다. (캡슐화 무시)

잘못 쓰면 문제라는 거지, 리플렉션 자체가 잘못되었다는 건 아님. 유의!

반응형