본문 바로가기
Java/Java

Reflection과 Annotation을 이해하고 DI 컨테이너 만들기

by oneny 2023. 8. 13.

Reflection

JVM은 클래스 정보를 클래스 로더를 통해 읽어와서 해당 정보를 JVM 메모리에 저장한다. 그렇게 저장된 클래스에 대한 정보가 마치 거울에 투영된 모습과 닮아있어, 리플렉션이라는 이름을 가지게 되었다. 리플렉션을 사용하면 생성자, 메서드, 필드 등 클래스에 대한 정보를 아주 자세히 알아낼 수 있다.

리플렉션을 사용한 애노테이션은 여러 라이브러리, 프레임워크에서 많이 사용된다. 스프링에서는 의존성 주입, MVC 뷰에서 넘어온 데이터를 객체에 바인할 때, 하이버네이트에서는 @Entity 클래스에 Setter가 없다면 리플렉션을 사용한다. 그리고 클래스 로더에 대한 개념에 대해 어느정도 이해할 필요가 있어 아래 블로그 글을 정리하였다.

 

JVM 메모리 구조

JVM 성능에 관심이 있다면 기본적인 JVM 기술 스택의 구조를 이해해야 할 필요가 있다. JVM 기술을 이해하면 더 좋은 소프트웨어를 개발할 수 있고 성능 이슈를 탐구할 때 필요한 이론적 배경지식

oneny.tistory.com

 

스프링의 @Autowired 애노테이션

어떻게 아래처럼 @Autowired 애노테이션을 사용하면 자동으로 Dependency Injection이 동작할까?

@Service
public class BookService {

  @Autowired
  BookRepository bookRepository;
}

위 코드는 보통 스프링을 사용하면 볼 수 있는 흔한 코드이다. 이 때 bookRepository 인스턴스는 어떻게 null이 아니고, 스프링은 어떻게 BookService 인스턴스에 BookRepository 인스턴스를 넣어준 것일까?

 

@ExtendWith(SpringExtension.class)
@SpringBootTest
class BookServiceTest {

  @Autowired
  BookService bookService;

  @Test
  void di() {
    Assertions.assertAll(
            () -> assertThat(bookService).isNotNull(),
            () -> assertThat(bookService.bookRepository).isNotNull()
    );
  }
}

실제 그런것인지 위 코드처럼 테스트 코드를 작성해서 테스트했을 때에도 통과한 것을 확인할 수 있다. 원래 null인 것이 정상적인데 어떻게 bookService도 그렇고 @Autowired 어노테이션을 추가한 것만으로 bookService에 있는 bookRepository도 null이 아닌 것일까? 애노테이션 자체로는 아무런 역할도 하지 않는다. 이렇게 할 수 있는 이유는 자바에서 제공하는 Reflection 덕분이다. 이 Reflection에 대해서 알아보자.

 

Reflection API를 사용한 클래스 정보 조회

 

Class (Java Platform SE 8 )

Determines if the specified Class object represents a primitive type. There are nine predefined Class objects to represent the eight primitive types and void. These are created by the Java Virtual Machine, and have the same names as the primitive types tha

docs.oracle.com

Reflection은 Class<T>라는 API를 사용하면 된다. Class 문서를 보면 다양한 메서드들이 있는데 이 메서드를 통해서 클래스에 있는 필드, 상위 클래스, 클래스가 구현하고 있는 인터페이스, 메서드 목록 등을 모두 접근할 수 있다. 

 

Class<T>에 접근하는 방법은 다음과 같다.

public class App {

  public static void main(String[] args) throws ClassNotFoundException {
    Class<Book> bookClass = Book.class;

    Book book = new Book();
    Class<? extends Book> aClass = book.getClass();

    Class<?> aClass1 = Class.forName("org.example.reflection.Book");
  }
}
  • 모든 클래스를 로딩한 다음 Class<T>의 인스턴스가 생기면 Heap 영역에 생성된다. 타입.class로 접근할 수 있다.
  • 모든 인스턴스는 getClass() 메서드를 가지고 있다. 인스턴스.getClass()로 접근할 수 있다.
  • 클래스를 문자열로 읽어오는 방법
    • Class.forName("FQCN")
      • FQCN: Fully Qualified Class Name 약자로, 클래스가 속한 패키지명을 모두 포함한 이름을 말한다.
    • 보통 개발자가 작성한 클래스이므로 AppClassLoader에서부터 찾기 시작한다.
    • 클래스패스에 해당 클래스가 없다면 ClassNotFoundException이 발생한다.

 

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

Reflection API을 사용하면 아래와 같은 많은 정보들을 참조할 수 있다.

public class App {

  public static void main(String[] args) throws ClassNotFoundException {
    Class<Book> bookClass = Book.class;

    Field[] fields = bookClass.getFields();
    // public java.lang.String org.example.reflection.Book.d, public만 필드만 출력한다.
    Arrays.stream(fields).forEach(System.out::println);

    System.out.println("---------------------------------------------------");

    // 모든 필드 출력
    Arrays.stream(bookClass.getDeclaredFields()).forEach(System.out::println);

    System.out.println("---------------------------------------------------");

    Book book = new Book();
    Arrays.stream(bookClass.getDeclaredFields()).forEach(f -> {
      try {
        // Reflection으로는 접근 지시자 무시도 가능
        f.setAccessible(true); // 접근 불가한 필드를 접근 가능하게 설정
        System.out.printf("%s = %s\n", f, f.get(book));

        // 현재 접근 제어자가 무엇인지, static 인지 등 확인 가능
        int modifiers = f.getModifiers();
        System.out.println(Modifier.isPrivate(modifiers));
        System.out.println(Modifier.isStatic(modifiers));
      } catch (IllegalAccessException e) {
        e.printStackTrace();
      }
    });

    System.out.println("---------------------------------------------------");

    // Object에 상속받은 메서드까지 모두 출력
    Arrays.stream(bookClass.getMethods()).forEach(method -> {
      System.out.println("method = " + method);

      // 메서드 역시 필드와 마찬가지로 접근 지시자 정보를 얻을 수 있다.
      int modifiers = method.getModifiers();
      System.out.println(Modifier.isPublic(modifiers));
      
      // 리턴타입 정보도 가져올 수 있다.
      Class<?> returnType = method.getReturnType();
      Class<?>[] parameterTypes = method.getParameterTypes(); // 파라미터 타입들도 가능..
    });

    System.out.println("---------------------------------------------------");

    // 생성자 출력
    Arrays.stream(bookClass.getDeclaredConstructors()).forEach(System.out::println);

    System.out.println("---------------------------------------------------");

    // 부모 클래스 출력
    System.out.println(MyBook.class.getSuperclass());

    System.out.println("---------------------------------------------------");

    // 인터페이스 출력
    Arrays.stream(MyBook.class.getInterfaces()).forEach(System.out::println);
  }
}
  • 필드 (목록) 가져오기
  • 메소드 (목록) 가져오기
  • 상위 클래스 가져오기
  • 인터페이스 (목록) 가져오기
  • 애노테이션 가져오기
  • 생성자 가져오기
  • 등등...

 

Reflection 사용 시 주의점

  • 지나친 사용은 불필요한 성능 이슈를 야기할 수 있기 때문에 반드시 필요한 경우에만 사용한다.
  • 컴파일 타임에 확인되지 않고 런타임 시에만 발생하는 문제를 만들 가능성이 있다.
  • 접근 지시자를 무시할 수 있다.

 

Annotation과 Reflection

애노테이션은 사전적 의미로는 주석이라는 뜻이다. 자바에서 사용될 때의 애노테이션은 코드 사이에 주석처럼 쓰여서 특별한 의미, 기능을 수행하도록 하는 기술이다. 즉, 프로그램에서 추가적인 정보를 제공해주는 메타데이터라고 볼 수 있다. 따라서 애노테이션이 런타임에도 기능을 하기 위해서는 추가적인 정보를 설정해야 한다.

 

중요 애노테이션

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

 

@Retention 살펴보기

public @interface MyAnnotation {
}

 

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

 

 

public class App {

  public static void main(String[] args) {
    Arrays.stream(Book.class.getAnnotations()).forEach(System.out::println);
  }
}

@MyAnnotation 애노테이션을 만들고, Book 클래스에 @MyAnnotation을 붙였다. 하지만 App 클래스를 실행시키면 아무것도 출력되지 않은 것을 확인할 수 있다. 왜 출력되지 않은 것일까?

 

javap -c -v (경로)/Book

애노테이션은 위에서 설명했듯이 주석보다 기능이 좀 더 있지만 근본적으로 주석이랑 비슷하다고 생각하면 된다. 위 사진은 javap -c -v 명령어를 실행했을 때 나온 결과로 애노테이션이 RuntimeInvisibleAnnotations인 것을 확인할 수 있다. 따라서 @MyAnnotation 정보가 컴파일하고 바이트 코드를 열어보면 남아있지만 바이트 코드를 로딩했을 때 애노테이션 정보는 빼고 읽어보기 때문에 메모리 상에는 남지 않는다. 이는 기본적으로 @Retention(RetentionPolicy.CLASS)이기 때문이다.

 

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

만약 런타임까지 애노테이션 정보를 유지하고 싶다면 위처럼 @Retenion(RetentionPolicy.RUNTIME) 애노테이션을 줘야한다. 그러면 애노테이션이 출력되는 것을 확인할 수 있다. 그리고 바이트 코드를 살펴보면 RuntimeVisibleAnnotations로 바뀐 것을 확인할 수 있다.

 

@Target 살펴보기

 

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.FIELD})
public @interface MyAnnotation {
}

애노테이션에는 해당 애노테이션을 사용할 수 있는 위치를 @Target 애노테이션을 사용하여 지정할 수 있다. 만약 위처럼 설정했다면 타입과 필드에만 해당 애노테이션을 붙일 수 있다.

 

만약 타입과 필드가 아닌 생성자나 메서드에 붙이는 경우에는 적용할 수 있다는 컴파일 에러가 발생하는 것을 확인할 수 있다.

 

@Inherited

public class MyBook extends Book implements MyInterface {
}

MyBook이 Book을 상속받고 있으면 MyBook에서도 getAnnotations()를 호출하면 Book에 붙어있는 애노테이션까지 가져오는 방법이 있다.

 

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.FIELD})
@Inherited
public @interface MyAnnotation {
}

 

public class App {

  public static void main(String[] args) {
    Arrays.stream(MyBook.class.getAnnotations()).forEach(System.out::println);
  }
}

@Inherited 애노테이션을 붙이면 다음처럼 상속받은 클래스에서도 애노테이션을 확인할 수 있는 것을 알 수 있다. 만약 MyBook 클랫스에 붙어있는 애노테이션만 가져오고 싶은 경우에는 getDeclaredAnnotations() 메서드를 사용하면 된다.

 

기타

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.FIELD})
public @interface MyAnnotation {
  
  String value() default "default";

  String name() default "oneny";

  int number() default 28;

}

 

@MyAnnotation("custom")
public class Book {
  // ...
}

그리고 애노테이션은 프리미티브 타입으로 값(String 포함)을 가질 수 있다. 그리고 value 같은 경우에는 어떤 값을 설정한 것인지 생략이 가능한데 만약 name, number의 값도 바꿀 경우에는 @MyAnnotation(value = "custom", name = "twony")로 설정해줘야 한다.

 

public class App {

  public static void main(String[] args) {
    Arrays.stream(Book.class.getDeclaredFields()).forEach(field -> {
      Arrays.stream(field.getAnnotations()).forEach(System.out::println);
    });
  }
}

그리고 위에서 생성자에는 애노테이션을 붙일 수 없는 것을 보여줄 때 필드에도 애노테이션을 붙였는데 필드에 붙은 애노테이션 정보를 가져오기 위해서는 위 코드처럼 가져올 수 있다. 특정 애노테이션만 가져오고 싶은 경우에는 getAnnotation() 메서드를 사용하고, 타입을 넘겨주면 된다.

 

public class App {

  public static void main(String[] args) {
    Arrays.stream(Book.class.getDeclaredFields()).forEach(field -> {
      Arrays.stream(field.getAnnotations()).forEach(annotation -> {
        if (annotation instanceof MyAnnotation) {
          MyAnnotation myAnnotation = (MyAnnotation) annotation;
          System.out.println(myAnnotation.name());
          System.out.println(myAnnotation.number());
        }
      });
    });
  }
}

필드에 붙은 애노테이션은 Annotation 타입으로 반환하는데 이를 활용하여 애노테이션에 넣어놓은 정보들(name, value, number)를 위 코드처럼 꺼내서 참조할 수 있다.

 

클래스 정보 수정 또는 실행

지금까지 필드, 메서드, 어노테이션 등에 접근하는 방법에 대해서 살펴보았는데 이번에는 정보 수정, 실행하는 방법에 대해서 알아보자. 

 

public class Book {

  public static String A = "A";

  private String B = "B";

  public Book() {
  }

  public Book(String b) {
    B = b;
  }

  public void c() {
    System.out.println("C");
  }

  public int sum(int left, int right) {
    return left + right;
  }
}

다음과 같은 Book 클래스가 있다고 가정해보자. 이 Book 클래스를 가지고 Reflection API를 이용하여 클래스 정보를 수정하거나 실행할 예정이다.

 

public class App {

  public static void main(String[] args) throws InvocationTargetException, InstantiationException, IllegalAccessException, ClassNotFoundException, NoSuchMethodException, NoSuchFieldException {
    Class<?> bookClass = Class.forName("org.example.reflection.Book");

    // Class타입로부터 인스턴스 생성하는 방법: 생성자를 가져와서 newInstance() 메서드로 인스턴스 생
    Constructor<?> constructor = bookClass.getConstructor(null);
    Book book = (Book) constructor.newInstance();

    // 파라미터가 있는 생성자로부터 인스턴스 생성
    Constructor<?> constructor1 = bookClass.getConstructor(String.class);
    constructor1.newInstance("myBook");

    // Book 클래스의 필드를 가져와서 출력
    Field field = Book.class.getDeclaredField("A");
    // 값을 꺼낼 때 필드가 특정 인스턴스에 해당하는 필드면 인자로 인스턴스를 넘겨줄 수 있는데 A 필드는 static하므로 null을 넘겨준다.
    System.out.println(field.get(null)); // A
    // 값을 변경할 수도 있다.
    field.set(null, "AAAAAAA");
    System.out.println(field.get(null)); // AAAAAAA <-- 값이 변경됨

    // 특정 인스턴스에 해당하는 private 필드 가져오기
    Field bField = Book.class.getDeclaredField("B");
    bField.setAccessible(true); // 접근 지시자 때문에 접근할 수 없는 필드를 접근할 수 있도록 설정할 수 있다.
    System.out.println(bField.get(book)); // B
    bField.set(book, "onenyBook");
    System.out.println(bField.get(book)); // onenyBook

    // getMethod()는 public한 메서드 밖에 접근하지 못함
    Method cMethod = Book.class.getDeclaredMethod("c");
    cMethod.setAccessible(true);
    cMethod.invoke(book); // 메서드도 특정 인스턴스에 해당하는 메서드면 인스턴스를 넘겨줘야 한다.

    // 파라미터가 있는 경우에는 타입들을 넘겨줘야 한다.
    Method sumMethod = Book.class.getDeclaredMethod("sum", int.class, int.class);
    int result = (int) sumMethod.invoke(book, 1, 2);
    System.out.println(result);
  }
}

위처럼 Reflection API를 이용하면 클래스의 정보를 수정하거나 실행할 수 있다.

 

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

 

public class BookService {

  @Inject
  BookRepository bookRepository;
}

지금까지 사용한 API를 이용하여 @Inject 커스텀 애노테이션을 만들어 위처럼 필드 주입 해주는 컨테이너 서비스를 만들어보자.

 

ContainerService 만들기

public class ContainerService {

  /**
   * 클래스 타입이 들어오면 그 안에 들어있는 타입을 반환한다.
   */
  public static <T> T getObject(Class<T> classType) {
    return createInstance(classType);
  }

  private static <T> T createInstance(Class<T> classType) {
    try {
      return classType.getConstructor(null).newInstance();
    } catch (InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException e) {
      throw new RuntimeException(e);
    }
  }
}

ContainerService 클래스는 주어진 클래스 타입에 대한 객체를 생성하고 반환하는 간단한 컨테이너 서비스를 구현한 것이다. createInstance() 메서드를 살펴보면 classType.getConstructor()로 파라미터가 없는 기본 생성자를 가져와서 newInstance() 메서드를 호출하여 객체를 생성한 후 반환한다.

 

테스트

class ContainerServiceTest {

  @Test
  void getObject_BookRepository() {
    BookRepository bookRepository = ContainerService.getObject(BookRepository.class);
    assertThat(bookRepository).isNotNull();
  }

  @Test
  void getObject_BookService() {
    BookService bookService = ContainerService.getObject(BookService.class);
    assertThat(bookService).isNotNull();
    assertThat(bookService.bookRepository).isNotNull();
  }
}

 

위처럼 테스트 코드를 작성하여 테스트하면 클래스 타입을 인자로 넘겨주어 해당 타입의 인스턴스를 반환받는다. 하지만 아직은 @Inject 애노테이션이 아무런 기능을 하지 않기 때문에 bookService의 bookRepository 필드는 null이므로 테스트를 통과하지 못한 것을 확인할 수 있다.

 

다시 ContainerService 살펴보기

public class ContainerService {

  /**
   * 클래스 타입이 들어오면 그 안에 들어있는 타입을 반환한다.
   */
  public static <T> T getObject(Class<T> classType) {
    T instance = createInstance(classType);

    Arrays.stream(classType.getDeclaredFields()).forEach(field -> {
      if (field.getAnnotation(Inject.class) != null) { // 필드 중에 Inject 어노테이션이 있는지 확인
        Object fieldInstance = createInstance(field.getType());
        field.setAccessible(true); // private 접근 지시자일 수 있기 때문에
        try {
          field.set(instance, fieldInstance);
        } catch (IllegalAccessException e) {
          throw new RuntimeException(e);
        }
      }
    });

    return instance;
  }
  
  // ...
}

클래스 타입이 들어오면 그 안에 들어있는 타입의 인스턴스를 생성했까지는 작성했으므로 해당 인스턴스에 들어있는 필드들을 살펴봐야 할 필요가 있다. Reflection API 중 getDeclaredFields() 메서드를 사용하여 public이 아닌 필드까지 조회할 수 있도록 모든 필드를 배열로 가져오고, getAnnotation(Inject.class) 메서드를 통해 필드 하나하나 @Inject 애노테이션이 붙어있는지를 확인한다. 만약 있는 경우에는 해당 필드의 타입을 가져와 인스턴스로 생성하고, 해당 필드가 public이 아닐 수 있기 때문에 setAccessible(true)를 통해 접근할 수 있도록 설정한다. 마지막으로 해당 필드의 값을 아까 만든 인스턴스로 할당하여 필드에 @Inject 애노테이션이 있는 경우에는 자동으로 주입할 수 있는 ContainerService를 만들었다.

 

내가 만든 ContainerService 다른 프로젝트에 적용해보기

Gadle 설정에서 maven 로컬 저장소를 사용하도록 한다.

 

plugins {
    id 'java'
    id "me.champeau.jmh" version "0.7.1"
    id 'maven-publish'
}

group 'org.example'
version '1.0'

repositories {
    mavenCentral()
}

publishing {
    publications {
        maven(MavenPublication) {

            groupId = 'org.example'
            artifactId = 'containerservice'
            version = '1.0'

            from components.java
        }
    }
}

build.gradle에서 다음과 같이 설정하면 maven 로컬 저장소를 사용하므로 빌드된 파일은 ~./m2 폴더에 저장된다.

 

./gradlew clean publishToMavenLocal

gradle wrapper를 이용하여 빌드하면 .m2 폴더 아래 저장된 것을 확인할 수 있다.

 

plugins {
    id 'java'
}

group 'org.example'
version '1.0-SNAPSHOT'

repositories {
    mavenLocal()
    mavenCentral()
}

그리고 라이브러리를 사용하기 위해 다른 프로젝트를 생성하여 build.gradle에서 maven 로컬 저장소를 사용하도록 지정한다(mavenLocal()). 그리고 다시 라이브러리를 프로젝트에 사용할 수 있도록 돌리면 라이브러리가 들어간 것을 확인할 수 있다.

 

class MusicRepositoryTest {

  @Test
  void getObject() {
    MusicService musicService = ContainerService.getObject(MusicService.class);

    assertAll(
            () -> assertThat(musicService).isNotNull(),
            () -> assertThat(musicService.musicRepository).isNotNull()
    );
  }
}

그리고 새로 만든 프로젝트에 @Inject 애노테이션과 ContainerService를 사용하여 필드 주입이 되는 것을 확인한 결과 테스트를 통과한 것을 확인할 수 있었다.

 

출처

더 자바, 코드를 조작하는 다양한 방법

[Gradle] 라이브러리 만들고 사용하기(Maven Local Repository)

자바 리플렉션(Reflection) 기초