자바 Reflection

디우·2022년 10월 11일
1

레벨1부터 계속해서 Java Reflection 이라는 말을 들어왔다. 하지만 실행 중인 자바 코드를 조작하는 기법으로 동적인 프로그래밍이 가능하도록 돕기도 하지만, 잘못 사용하는 경우 치명적으로 다가올 수 있다 정도만 알고 있었다.

그리고 JSON 파싱 과정에서 리플렉션을 활용한다 정도로 어렴풋이 알고 넘어갔었다.

하지만 리플렉션을 제대로 알고 활용하면 큰 이점을 얻을 수 있다. 간단히 리플렉션을 사용하여 할 수 있는 작업을 정리해보면 다음과 같다.

  • 사용자의 요청과 응답을 처리하는 Controller를 어노테이션 방식으로 선언적으로 지원해줄 수 있게 된다.
  • JUnit 에서와 같이 @Test 어노테이션이 붙은 메소드들을 찾아 테스트를 실행할 수 있다.
  • 실행 중인 객체의 필드나 메소드에 대한 접근이 가능해진다.

즉, 제대로된 리플렉션 사용 방법에 대해서 알지 못하고 있었고, 이번에 자바 리플렉션을 직접 사용해보며 학습 테스트를 진행할 기회를 얻어 이를 정리해보려고 한다.

실습을 진행한 저장소

들어가기 전에 기본적으로 Reflection은 Class.java (java.lang) 을 많이 활용하므로 필요할 때 해당 클래스 메소드를 보고 설명을 읽어 이해하며 리플렉션을 활용하면 도움이 될 것 같다.


JUnit3TestRunner

JUnit3TestRunner 학습 테스트는 특정 클래스(Junit3Test)에서 test 로 시작하는 메소드를 찾고, 이를 실행시키는 것이 요구사항이다.

public class Junit3Test {
    public void test1() throws Exception {
        System.out.println("Running Test1");
    }

    public void test2() throws Exception {
        System.out.println("Running Test2");
    }

    public void three() throws Exception {
        System.out.println("Running Test3");
    }
}
class Junit3TestRunner {

    @Test
    void run() throws Exception {
        Class<Junit3Test> clazz = Junit3Test.class;

        // TODO Junit3Test에서 test로 시작하는 메소드 실행
    }
}

이를 위해서는 해당 클래스의 메소드를 찾는 작업이 필요하고, 메소드를 호출하기 위해서는 Junit3Test 인스턴스가 먼저 생성되어야하므로 클래스 정보로부터 인스턴스를 생성하는 과정이 필요하다.

우리는 위의 코드에서 clazz.getConstructor() 를 통해서 기본 생성자를 가져온다. 만약 기본생성자가 아닌 파라미터를 받는 생성자를 가져오고 싶으면 파라미터로 생성자의 파라미터 타입을 전달해주면 된다.

생성자 정보를 가져온 이후에는 newInstance() 메소드를 통해서 인스턴스를 생성해줄 수 있다.

class Junit3TestRunner {

    @Test
    void run() throws Exception {
        Class<Junit3Test> clazz = Junit3Test.class;
        final Constructor<Junit3Test> constructor = clazz.getConstructor();
        final Junit3Test node = constructor.newInstance();

        // TODO Junit3Test에서 test로 시작하는 메소드 실행
    }
}

이렇게 해서 메소드 호출을 위한 준비는 끝이 났고, 이제 메소드 정보를 찾아와서 test 로 시작하는 메소드의 경우에는 실행을 시켜주면 된다.

이 과정은 getMethods() 메소드를 통해서 쉽게 해결해줄 수 있다. 해당 메소드는 "클래스 또는 인터페이스에 의해 선언된 메서드 또는 슈퍼클래스 및 슈퍼인터페이스에서 상속된 메서드를 포함하여 모든 public 메서드를 반영하는 메서드 개체를 포함하는 배열을 반환합니다." 라고 설명되어 있다.

즉, 상속되는 메소드 등 public 메소드 목록을 배열 형태로 반환해준다. 이제 우리는 iter 를 돌면서 메소드의 이름(method.getName())이 test로 시작하는 메소드를 찾아 invoke() 메소드를 통해서 실행시켜주면 된다.

class Junit3TestRunner {

    @Test
    void run() throws Exception {
        Class<Junit3Test> clazz = Junit3Test.class;
        final Constructor<Junit3Test> constructor = clazz.getConstructor();
        final Junit3Test node = constructor.newInstance();

        // TODO Junit3Test에서 test로 시작하는 메소드 실행
        final Method[] methods = clazz.getMethods();
        for (Method method : methods) {
            if (method.getName().startsWith("test")) {
                method.invoke(node);
            }
        }
    }
}

사실 개인적으로 method.invoke(node) 의 호출 방식(?)이 어색하게 느껴졌었는데 (왜냐하면 우리는 보통 '인스턴스.메소드호출' 과 같은 식으로 사용하는데 순서가 반대인 느낌이 들어서), 클래스에 들어가서 설명을 보면 다음과 같다.

"지정된 매개 변수가 있는 지정된 개체에서 이 메서드 개체로 표시되는 기본 메서드를 호출합니다. 개별 매개 변수는 원시 형식 매개 변수와 일치하도록 자동으로 언랩되며, 원시 매개 변수와 참조 매개 변수 모두 필요에 따라 메서드 호출 변환의 대상이 됩니다."
즉, 매개변수로 지정해주는 node가 메소드를 호출할 객체로 지정되는 것으로 이해해줄 수 있다. 즉 invoke() 메소드를 호출하는 메소드를 실행시킬 객체를 파라미터를 통해서 전달해주는 형식이다.


JUnit4TestRunner

public class Junit4Test {

    @MyTest
    public void one() throws Exception {
        System.out.println("Running Test1");
    }

    @MyTest
    public void two() throws Exception {
        System.out.println("Running Test2");
    }

    public void testThree() throws Exception {
        System.out.println("Running Test3");
    }
}
class Junit4TestRunner {

    @Test
    void run() throws Exception {
        Class<Junit4Test> clazz = Junit4Test.class;

        // TODO Junit4Test에서 @MyTest 애노테이션이 있는 메소드 실행
    }
}

Junit4Test 클래스에서 @MyTest 어노테이션이 있는 메소드를 실행하는 것이 학습 테스트의 요구사항이다.
앞서 우리는 Junit3TestRunner 를 통해서 메소드를 실행하기 위해서는 클래스 정보로 부터 생성자 그리고 인스턴스를 생성해야 한다는 것을 알고 있다. 따라서 해당 부분까지는 동일하게 진행해주면 된다.

class Junit4TestRunner {

    @Test
    void run() throws Exception {
        Class<Junit4Test> clazz = Junit4Test.class;
		final Constructor<Junit4Test> constructor = clazz.getConstructor();
        final Junit4Test node = constructor.newInstance();

        // TODO Junit4Test에서 @MyTest 애노테이션이 있는 메소드 실행
    }
}

문제는 앞서는 메소드 이름을 통해서 호출할 메소드를 결정해주었었는데, 이제는 어노테이션이 달려있는지 여부에 따라서 판단해 메소드를 호출해야한다는 것이다. 이 또한 걱정할 거 없이 isAnnotationPresent() 메소드를 통해서 쉽게 해결해줄 수 있다.

class Junit4TestRunner {

    @Test
    void run() throws Exception {
        Class<Junit4Test> clazz = Junit4Test.class;
        final Constructor<Junit4Test> constructor = clazz.getConstructor();
        final Junit4Test node = constructor.newInstance();

        // TODO Junit4Test에서 @MyTest 애노테이션이 있는 메소드 실행
        final Method[] methods = clazz.getMethods();
        for (Method method : methods) {
            if (method.isAnnotationPresent(MyTest.class)) {
                method.invoke(node);
            }
        }
    }
}

isAnnotationPresent() 메소드는 해당 메소드를 호출하는 method가 파라미터로 넘겨주는 형식의 어노테이션이 있으면 true 를 반환해주고 없다면 false를 반환해주는 메소드이다.


ReflectionTest

실습을 진행한 내용이 많아 구현한 코드를 우선 보이고, 각 메소드에 대해서 설명하는 방식으로 정리를 진행하겠습니다.

class ReflectionTest {

    @Test
    void givenObject_whenGetsClassName_thenCorrect() {
        final Class<Question> clazz = Question.class;

        assertThat(clazz.getSimpleName()).isEqualTo("Question");
        assertThat(clazz.getName()).isEqualTo("reflection.Question");
        assertThat(clazz.getCanonicalName()).isEqualTo("reflection.Question");
    }

    @Test
    void givenClassName_whenCreatesObject_thenCorrect() throws ClassNotFoundException {
        final Class<?> clazz = Class.forName("reflection.Question");

        assertThat(clazz.getSimpleName()).isEqualTo("Question");
        assertThat(clazz.getName()).isEqualTo("reflection.Question");
        assertThat(clazz.getCanonicalName()).isEqualTo("reflection.Question");
    }

    @Test
    void givenObject_whenGetsFieldNamesAtRuntime_thenCorrect() {
        final Object student = new Student();
        final Field[] fields = student.getClass().getDeclaredFields();
        final List<String> actualFieldNames = Arrays.stream(fields)
                .map(Field::getName)
                .collect(toList());

        assertThat(actualFieldNames).contains("name", "age");
    }

    @Test
    void givenClass_whenGetsMethods_thenCorrect() {
        final Class<?> animalClass = Student.class;
        final Method[] methods = animalClass.getDeclaredMethods();
        final List<String> actualMethods = Arrays.stream(methods)
                .map(Method::getName)
                .collect(toList());

        assertThat(actualMethods)
                .hasSize(3)
                .contains("getAge", "toString", "getName");
    }

    @Test
    void givenClass_whenGetsAllConstructors_thenCorrect() {
        final Class<?> questionClass = Question.class;
        final Constructor<?>[] constructors = questionClass.getConstructors();

        assertThat(constructors).hasSize(2);
    }

    @Test
    void givenClass_whenInstantiatesObjectsAtRuntime_thenCorrect() throws Exception {
        final Class<?> questionClass = Question.class;

        final Constructor<?> firstConstructor = questionClass.getConstructor(String.class, String.class, String.class);
        final Constructor<?> secondConstructor = questionClass.getConstructor(long.class, String.class, String.class,
                String.class, Date.class, int.class);

        final Question firstQuestion = (Question) firstConstructor.newInstance("gugu", "제목1", "내용1");
        final Question secondQuestion = (Question) secondConstructor.newInstance(1L, "gugu", "제목2", "내용2",
                Date.from(Instant.now()), 5);

        assertThat(firstQuestion.getWriter()).isEqualTo("gugu");
        assertThat(firstQuestion.getTitle()).isEqualTo("제목1");
        assertThat(firstQuestion.getContents()).isEqualTo("내용1");
        assertThat(secondQuestion.getWriter()).isEqualTo("gugu");
        assertThat(secondQuestion.getTitle()).isEqualTo("제목2");
        assertThat(secondQuestion.getContents()).isEqualTo("내용2");
    }

    @Test
    void givenClass_whenGetsPublicFields_thenCorrect() {
        final Class<?> questionClass = Question.class;
        final Field[] fields = questionClass.getFields();

        assertThat(fields).hasSize(0);
    }

    @Test
    void givenClass_whenGetsDeclaredFields_thenCorrect() {
        final Class<?> questionClass = Question.class;
        final Field[] fields = questionClass.getDeclaredFields();

        assertThat(fields).hasSize(6);
        assertThat(fields[0].getName()).isEqualTo("questionId");
    }

    @Test
    void givenClass_whenGetsFieldsByName_thenCorrect() throws Exception {
        final Class<?> questionClass = Question.class;
        final Field field = questionClass.getDeclaredField("questionId");

        assertThat(field.getName()).isEqualTo("questionId");
    }

    @Test
    void givenClassField_whenGetsType_thenCorrect() throws Exception {
        final Field field = Question.class.getDeclaredField("questionId");
        final Class<?> fieldClass = field.getType();

        assertThat(fieldClass.getSimpleName()).isEqualTo("long");
    }

    @Test
    void givenClassField_whenSetsAndGetsValue_thenCorrect() throws Exception {
        final Class<?> studentClass = Student.class;
        final Student student = (Student) studentClass.getConstructor().newInstance();
        final Field field = studentClass.getDeclaredField("age");

        // todo field에 접근 할 수 있도록 만든다.
        field.setAccessible(true);
        assertThat(field.getInt(student)).isZero();
        assertThat(student.getAge()).isZero();

        field.set(student, 99);

        assertThat(field.getInt(student)).isEqualTo(99);
        assertThat(student.getAge()).isEqualTo(99);
    }
}

givenObject_whenGetsClassName_thenCorrect

어떠한 클래스가 주어졌을 때 그 클래스의 이름을 가져오는 방법에 대해서 익히는 학습테스트이다.

  • getSimpleName() : 소스 코드에 지정된 기본 클래스의 단순 이름을 반환한다. 만약 익명 클래스라면 빈 문자열이 반환되게 된다.
  • getName() : 패키지명을 포함한 이름을 반환하게 되며, 예시로 String.class.getName() 의 경우에는 "java.lang.String" 이 반환되게 된다.
  • getCanonicalName() : 자바 언어 사양에서 정의한 기본 클래스의 표준 이름을 반환한다. 기본 클래스에 표준 이름이 없으면 null을 반환한다. (ex. local class, anonymous class, hidden class)

givenClassName_whenCreatesObject_thenCorrect

Class.forName() 을 통해서 클래스 정보를 가져오는 것에 대한 학습테스트이다.

forName() 메소드는 Class 클래스에 정의된 static 메소드로 파라미터로 주어진 문자열 이름을 가진 클래스 또는 인터페이스와 연결된 Class 객체를 반환한다.

givenObject_whenGetsFieldNamesAtRuntime_thenCorrect

어떤 객체가 주어졌을 때 필드 정보를 가져오는 것에 대한 학습테스트이다.

  • getDeclaredFields() : 이 클래스 개체가 나타내는 클래스 또는 인터페이스에 의해 선언된 "모든 필드"를 반영하는 필드 개체의 배열을 반환합니다. 즉, public, protected, defualt, private 접근 제어자에 관계없이 필드 정보를 불러오지만, 상속된 필드는 제외된다.
  • getName() : Field 오브젝트의 필드 이름을 반환해준다.

givenClass_whenGetsMethods_thenCorrect

Class 가 주어졌을 때 메소드 정보를 찾을 수 있는지를 묻는 학습테스트이다.

getDeclaredMethods() 메소드를 이용해서 메소드 정보를 가져올 수 있다. 해당 메소드는 필드에서와 마찬가지로 상속된 메소드는 제외하고 접근 제어자(access modifier)에 관계없이 모든 메소드 정보를 가져온다.

givenClass_whenGetsAllConstructors_thenCorrect

클래스에 정의된 모든 생성자 정보를 가져올 수 있는지를 묻는 학습테스트이다.

Class 클래스에 정의되어있는 getConstructors() 를 통해서 클래스의 모든 public 생성자 정보를 조회해올 수 있다.

givenClass_whenInstantiatesObjectsAtRuntime_thenCorrect

클래스가 주어졌을 때(ex. Question.class), Question 클래스에 있는 다양한 파라미터의 생성자를 찾고, 그 생성자를 통해서 인스턴스를 생성하는 학습테스트이다.

public class Question {
	...
    
        public Question(String writer, String title, String contents) {
        this(0, writer, title, contents, new Date(), 0);
    }

    public Question(long questionId, String writer, String title, String contents, Date createdDate,
                    int countOfComment) {
        this.questionId = questionId;
        this.writer = writer;
        this.title = title;
        this.contents = contents;
        this.createdDate = createdDate;
        this.countOfComment = countOfComment;
    }
}

앞서 getConstructor() 메소드와 newInstance() 메소드에 대해서 정리했으므로 특별하게 짚고 넘어갈 것은 없는 것 같다.

givenClass_whenGetsPublicFields_thenCorrect

getFields()getDeclaredFields() 메소드의 차이를 알기 위한 목적의 테스트이다.
getDeclaredFields() 와는 다르게 getFields()는 public 접근제어자를 가지는 필드들만 조회해온다.
따라서 Question 클래스의 private 필드들에 대한 정보를 얻지 못한다.

givenClass_whenGetsDeclaredFields_thenCorrect

givenClass_whenGetsPublicFields_thenCorrect 와는 반대로 getDeclaredFields() 를 활용하는 학습테스트이다. public 필드 뿐 아니라 private을 포함한 모든 필드에 접근이 가능하다.

givenClassField_whenGetsType_thenCorrect

Field 클래스에 대해서 getType() 메소드를 호출하면 해당 필드의 타입 정보를 얻을 수 있다.

givenClassField_whenSetsAndGetsValue_thenCorrect

필드가 주어졌을 때, private 필드는 바로 접근할 수 없다. 따라서 setAccessible() 메소드의 인자로 true 값을 주어야 접근이 가능해지게 되며 변경도 가능하다.


ReflectionsTest

class ReflectionsTest {

    private static final Logger log = LoggerFactory.getLogger(ReflectionsTest.class);

    @Test
    void showAnnotationClass() throws Exception {
        Reflections reflections = new Reflections("examples");

        // TODO 클래스 레벨에 @Controller, @Service, @Repository 애노테이션이 설정되어 모든 클래스 찾아 로그로 출력한다.
        final Set<Class<?>> annotatedWithController = reflections.getTypesAnnotatedWith(Controller.class);
        final Set<Class<?>> annotatedWithService = reflections.getTypesAnnotatedWith(Service.class);
        final Set<Class<?>> annotatedWithRepository = reflections.getTypesAnnotatedWith(Repository.class);

        log.info("{}", annotatedWithController);
        log.info("{}", annotatedWithService);
        log.info("{}", annotatedWithRepository);
    }
}

각각 Controller, Service, Repository 어노테이션이 붙은 클래스를 모두 찾고 로그로 출력해보는 학습 테스트이다.
Reflections reflections = new Reflections("examples"); 는 인자로 넘겨준 지정된 패키지 접두사에 따라 Reflection 인스턴스를 구성하게 된다.
그리고 getTypesAnnotatedWith() 메소드는 인자로 주어지는 어노테이션을 단 클래스 및 어노테이션들의 타입(유형)을 Class의 Set 형식으로 반환해준다. (get types annotated with the given annotation, both classes and annotations)


이렇게 자바 리플렉션에 대해서 학습하고, 이를 통한 학습 테스트도 진행해보았다. 어노테이션 기반의 MVC 을 구현하는 미션에서 자바 리플렉션을 적용하고 활용해볼 것이다.

profile
꾸준함에서 의미를 찾자!

0개의 댓글