Annotation과 Reflection, 메타 프로그래밍 시작기

RequestMapping은 내부에서 어떻게 동작할까?

#spring#sprout#reflection

이 글에서 해볼 것은 스프링 개발을 해봤다면 누구나 아는 @RequestMapping 만들기이다. 이게 어떻게 그렇게 마법처럼 동작하는 걸까? 우선 @ 이러한 형태의 객체를 메서드나, 클래스나 혹은 파라미터에 붙여 사용하는 경우가 많다. 이를 annotation 이라 부른다.

Annotation 이란?

어노테이션이란, 간단히 말해 자바 소스 코드에 추가하여 사용하는 메타데이터의 일종이다. 이는 클래스, 메서드, 변수 등에 달려 특정 동작을 유도하거나 정보를 제공하는 형태로 사용하게 된다. 주석이라고 설명하기도 하는데, 주석은 일반적으로 개발자들이 읽지만 어노테이션은 컴파일러가 읽는 주석인 것이다.

메타데이터란? 어떤 데이터의 특성을 설명하는 데이터이다. 예를 들어, 사진 파일의 메타데이터는 촬영 일시, 카메라 모델, 노출 정보 등이 있을 것이다. 자바 어노테이션은 소스 코드에 이러한 '메타데이터'를 주입하는 역할을 한다. 즉, 클래스나 메서드에 추가적인 정보를 부여하여 특정 동작을 유도하거나 도구에게 힌트를 주는 방식인 것이다.

Annotation 종류

  1. 표준 Annotation 이는 자바가 기본적으로 제공하는 어노테이션을 말한다. @Override, @Deprecated, @SuppressWarnings 등이 있다
  2. 메타 Annotation 다른 어노테이션을 정의할 때 사용되는 어노테이션이다. 간단히 말하면 어노테이션에 사용하는 어노테이션. @Target, @Retention, @Documented 등이 있다.
  3. 사용자 정의 Annotation 개발자가 직접 정의하는 어노테이션이다. Spring에서 제공하는 어노테이션들도 사실 사용자 어노테이션에 속한다. @Controller, @Component, @Bean 등의 스프링에서 사용되는 어노테이션을 생각하면 된다.

사용자 Annotation

앞으로 개발하면서 아주 많이 만들 수 밖에 없다. 스프링은 어노테이션을 적극적으로 활용하여 객체들을 관리하게 설계되어 있고, 나는 이걸 모방할 것이기 때문이다.

java 프로젝트(스프링 프로젝트 아님)을 만들어서 어노테이션을 직접 만들면 위와 같이 만들어 볼 수 있다. 앞서, 메타 어노테이션은 어노테이션에 붙이는 어노테이션이라 했는데, 해당 어노테이션에 메타 어노테이션을 붙여서 메타데이터를 추가해보자.

그럼 이 메타 어노테이션에 대해 더 자세히 알 필요가 있다. 각각의 특징을 살펴보자.

@Target

어노테이션 적용 대상을 지정할 때, 어노테이션을 적용할 수 있는 대상을 지정하기 위해 사용한다.

  • ElementType.TYPE 클래스, 인터페이스, Enum 에 적용할 수 있다
  • ElementType.ANNOTATION_TYPE 다른 어노테이션에 적용할 수 있다
  • ElementType.FIELD 멤버 변수에 적용할 수 있다.

이 외에도 사실 CONSTRUCTOR, METHOD, PARAMETER 등이 있다. 각각 생성자, 메서드, 매개변수. 잘 살펴보면 ElementType 이 들어간다는 걸 알 수 있는데, ElementType도 사실 열거형 중 하나일 뿐이다. java에서 해당 ENUM을 실제로 열어보면 아래와 같다. (주석은 전부 제거했다.)

package java.lang.annotation; public enum ElementType { TYPE, FIELD, METHOD, PARAMETER, CONSTRUCTOR, LOCAL_VARIABLE, ANNOTATION_TYPE, TYPE_PARAMETER, TYPE_USE, MODULE, RECORD_COMPONENT; }

위와 같은 것들이 들어가는 것이다.

@Retention

이는 유지 기간을 설정한다.

  • Retention.SOURCE
  • Retention.CLASS
  • Retention.RUNTIME

RetentionPolicy 열거형에 들어가봐도 실제로 이 3개뿐이다.

package java.lang.annotation; public enum RetentionPolicy { /** * Annotations are to be discarded by the compiler. */ SOURCE, /** * Annotations are to be recorded in the class file by the compiler * but need not be retained by the VM at run time. This is the default * behavior. */ CLASS, /** * Annotations are to be recorded in the class file by the compiler and * retained by the VM at run time, so they may be read reflectively. * * @see java.lang.reflect.AnnotatedElement */ RUNTIME }

주석을 읽어보면, @RUNTIME의 경우 컴파일러에 의해 클래스 파일에 기록되고 실행 시 VM에 의해 유지되므로 반사적으로 읽을 수 있다고 한다. 이 포스트에서 살펴볼 reflection으로 해당 어노테이션을 처리하고 싶다면, RUNTIME으로 설정해야 함을 알 수 있다.

SOURCE는 컴파일러에 의해 삭제 될 것이고, CLASS는 자바의 실질적 실행 파일(.class)에 기록된다고 하지만, 실행 시점엔 없다. 그리고 아무 설정을 하지 않는다면 CLASS가 기본값.

@Documented

어노테이션이 Documented로 주석을 달면 기본적으로 javadoc과 같은 도구가 해당 인터페이스의 어노테이션을 출력에 표시하고 Documented가 없는 어노테이션의 주석은 표시되지 않는다. 라고 실제로 적혀있다.

@Inherited

이는 어노테이션이 자식 클래스에 상속되는지 여부를 결정하는 용도로 사용한다. 하지만, 클래스에만 적용되는 어노테이션에서만 의미를 갖는다. 그니까, @Inherited@MyAnno에 붙였다고 하면, @MyAnno를 붙인 클래스의 하위 자식 클래스들도 자동으로 인식이 되는 것이다. 그래서 자식 클래스에서 또 @MyAnno를 선언하지 않아도 되는 것이다.

사실 스프링에서는 많이 안쓴다. 스프링은 주로 인터페이스 기반 프로그래밍과 컴포지션 기반 구조를 선호하고 클래스 계층에서 어노테이션 상속보다는 직접 명시하는 구조를 선택하기 때문이다.

@Repeatable

이는 하나의 어노테이션을 한 대상에 "여러 번" 붙일 수 있게 해주는 것. 언제 사용하냐 싶겠지만 다음과 같은 사용처가 있을 수 있다.

@Role("ADMIN") @Role("USER") public class AccountController {}

실제로 스프링에 없는 기능이지만, 사용자 어노테이션으로 만들고 해당 클래스에 저 Role이 속해야만 접근할 수 있게 만들 수 있다. 만약 권한을 여러개 처리하고 싶다면 위처럼 해도 되고 아니면 어노테이션에서 배열로 받을 수도 있다. 실전에선 Swagger 설정에서 반복 어노테이션을 지원하기도 한다.

Java의 Annotation 처리 방법

큰 틀에서, 자바 어노테이션은 언제, 어디서 읽어들이냐에 따라 크게 세 가지로 처리 방법이 나뉜다.

  1. 컴파일 시점
  • 어노테이션 프로세서가 소스 코드를 스캔하여 새 소스 or 클래스 파일을 생성한다
  • 컴파일 에러나 워닝도 출력 가능하다
  • 런타임 오버헤드가 없다
  • 이때 사용하는게, APT이다
    • 이를 활용하는게 누구나 아는 Lombok이다
  1. 런타임 리플렉션
  • @Retention(RUNTIME)이 붙은 어노테이션만 대상이 된다.
  • 코드 변경 없이 런타임에서 메타데이터를 읽어 행동을 결정한다
  • 빠르지만 아주 대규모 서비스에서는 리플렉션 비용이 이슈가 될 수 있다
  • 여기에서 사용되는게 Reflection
    • 주로 스프링, JPA에서 사용한다
  1. 바이트코드 조작 & 프록시
  • **클래스 로딩 직후(혹은 직전)**에 바이트코드를 변형하여 메서드 바디나 바이트 코드 자체를 수정한다
  • AOP 프록시, 히스토그램 삽입, 성능 계측 등에 활용된다
  • 코드 난이도..도 높지만 런타임 리플렉션보다 빠르다
  • CGLIB(바이트코드 조작), JDK 동적 프록시, Byte Buddy, ASM(java의 바이트코드 조작에 쓰이는 로우 레벨 라이브러리) 등에서 사용한다.
    • 참고로, Spring AOP에서 이들을 사용하는데, 인터페이스에 사용하면 JDK 동적 프록시를 사용하고 구현체에 적용하면 CGLIB을 사용한다.

결론적으로, 컴파일 시점에 사용하면 APT, 클래스 로딩 직전에 바이트코드 변형을 일으킬 것인지, 혹은 런타임 중 사용할 것인지에 따라 이 시간축 위에서 선택하면 된다. 롬복같은 경우는 코드를 직접 삽입하면서 동작하기 때문에 미리 코드가 생성되어 있어야 한다. 그래서 APT가 적절한 것이다.

APT(Annotation Processing Tool)

APT는 Java Compiler API 안에 속한다. 컴파일 시점에 소스 코드에 붙어있는 어노테이션을 처리하는 도구로, 개발자가 어노테이션을 사용하여 메타데이터를 소스 코드에 추가하고, APT 프로세서가 이 메타데이터를 분석하여 새로운 소스 파일이나 설정 파일 등을 생성하는 등 기존 코드를 변경하는 작업을 자동화할때 사용한다. (걍 롬복 생각하면 됨)

특징
  1. 컴파일 시점 검증 가능 런타임에 발생할 수 있는 오류를 컴파일 시점에 발견 가능하다. 어노테이션이 이상한 위치에 사용되었다던가, 필수 속성이 누락되었다던가 등
  2. 플러그인 방식 개발자가 직접 AnnotationProcessor을 구현하여 원하는 로직을 추가할 수 있다
사용해보려면?
  1. 어노테이션 정의
  2. 어노테이션 사용
  3. 프로세서 구현 AbstractProcessor 클래스를 상속받아 어노테이션을 처리하는 로직을 구현한다. 이 프로세서는 process() 메서드에서 어노테이션이 적용된 요소를 스캔하고 필요한 작업을 수행한다.
  4. 서비스 프로바이더 등록 구현한 프로세서를 META-INF/services/javax.annotation.processing.Processor 파일에 등록하여 JVM이 해당 프로세서를 인식하도록 하면 된다.
  5. 컴파일 Java 컴파일러(javac)가 소스 코드를 컴파일 하는 과정에서 등록된 APT 프로세서를 찾아 실행한다. 프로세서는 어노테이션을 분석하여 필요한 코드를 생성하거나 검증 작업을 수행하는 형식으로 진행된다.

이것도 직접 구현해보면 좋을 것 같지만, 논지에서 벗어날 것 같으니 다음 기회에😓

바이트코드 조작 & 프록시

바이트코드 조작과 프록시는 주로 런타임(Runtime) 또는 클래스 로딩 시점에 동작하여 어노테이션에 따라 기존 클래스의 동작을 변경하거나 확장하는 방식이다.

바이트코드 조작

이는 바이트코드를 직접 읽고 수정하는 기술이다. 이를 통해 런타임에 클래스의 메서드나 필드, 생성자 등을 동적으로 추가하거나 삭제, 혹은 수정할 수 있다. 어노테이션은 이러한 바이트코드 조작의 트리거 역할을 할 수 있다. 특정 어노테이션이 붙은 클래스나 메서드를 찾아 해당 바이트 코드를 변경하는 방식.

사용 목적
  1. AOP 로깅, 보안, 트랜잭션 등 횡단 관심사를 코드에 직접 넣지 않고 모듈화 하여 적용될 때 사용한다. Spring AOP가 사용한 방식이다.
  2. 성능 모니터링 메서드 실행 시간 측정 등을 위해 코드 계측을 삽입하기도 한다
  3. 코드 최적화 런타임 시 불필요한 코드를 제거하거나 성능 향상을 위한 최적화를 수행하기도 한다
  4. 테스팅 프레임워크 목(mock) 객체를 생성하거나 테스트 대역을 만들 때 사용한다
동작 시점
  1. 런타임 클래스 로더가 .class 파일 로딩시 java.lang.instrment 패키지의 Instrumentation API를 사용하여 바이트코드를 변환할 수 있다(JVM Agent)
  2. 빌드 시점 ANT, Maven 플러그인 등을 통해 빌드 과정에서 .class파일을 수정할 수 있다.

이 방법은 기존 코드 변경없이 클래스의 동작을 제어할 수 있고 재배포 없이도 런타임에 변경 사항을 적용할 수 있다. 하지만 바이트코드를 직접 다루는 건 매우 복잡하고 오류 발생이 쉽다.. 또한 디버깅도 어렵다.

프록시(Proxy)

프록시는 특정 객체에 대한 접근을 제어하거나 객체 기능 확장 및 가로채기 등을 수행하기 위해 사용되는 디자인 패턴이다. 프록시는 실제 객체를 대신해 클라이언트 요청을 받아 처리한다.

사용 목적
  • 지연 로딩 JPA가 이를 사용합니다. 실제 객체가 필요할 때까지 생성을 지연
  • 접근 제어
  • 로깅이나 모니터링
  • 캐싱
  • 트랜잭션 관리
  • AOP 바이트 코드 조작과 함께 AOP를 구현하는 데에 많이 사용된다. 앞서 말했듯, JDK 동적 프록시 or CGLIB 프록시
동작 시점

주로 런타임에 동적으로 생성

사용해보려면?
  1. JDK 동적 프록시
  • java.lang.reflect.Proxy 클래스와 java.lang.reflect.InvocationHandler 인터페이스를 사용한다
  • 반드시 인터페이스를 구현하는 클래스에 대해서만 프록시를 생성할 수 있다
  • 컴파일 시점에 존재하지 않는 클래스에 대한 프록시를 런타임에 동적으로 생성한다
  1. CGLIB 프록시
  • 인터페이스 없이 클래스 기반으로 프록시를 생성할 수 있다. (대상 클래스를 상속받아 서브클래스를 생성하는 방식)
  • 대상 클래스가 final이거나 private 메서드를 가지고 있으면 프록시 생성이 어려움..
  • 내부적으로 바이트코드 조작 라이브러리(ASM 등)를 사용하여 런타임에 새로운 클래스 파일을 생성한다

실제 지금 프로젝트에서도 자바 동적 프록시를 사용하여 AOP를 구성하였는데, 스프링과 당연히 완전히 같은 구조는 아니다. 리팩토링 과정에서 CGLIB을 사용해볼지는 모르겠는데, 적어도 동적 프록시는 앞으로 소개한 후 직접 사용해볼 계획이다.

첨언하자면, 스프링의 @Transactional 어노테이션이 대표적인 예시이다. 이 어노테이션이 붙은 메서드는 실제 트랜잭션 로직을 감싸는 프록시 객체를 통해 실행된다.

본론으로 돌아가서,

Reflection으로 @RequestMapping 만들기

스프링의 @RequestMapping은 다음과 같다. 주석은 너무 길어서 제거했다.

package org.springframework.web.bind.annotation; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import org.springframework.aot.hint.annotation.Reflective; import org.springframework.core.annotation.AliasFor; @Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented @Mapping @Reflective(ControllerMappingReflectiveProcessor.class) public @interface RequestMapping { @AliasFor("path") String[] value() default {}; @AliasFor("value") String[] path() default {}; RequestMethod[] method() default {}; String[] params() default {}; String[] headers() default {}; String[] consumes() default {}; String[] produces() default {}; String version() default ""; }

https://github.com/spring-projects/spring-framework/blob/main/spring-web/src/main/java/org/springframework/web/bind/annotation/RequestMapping.java

아래 두 문단은 읽지 않으셔도 됩니다

앞서 메타 어노테이션에 대해 살펴봤고, 추가적으로 @Mapping은 은 스프링 내부에서 쓰이는 것인데, RequestMappingHandlerMapping 클래스에서 이걸 기준으로 핸들러를 추출한다.

@Reflective(ControllerMappingReflectiveProcessor.class) 어노테이션은 Spring Framework 6부터 도입된 Spring AOT 컴파일 기능과 관련된 어노테이션이다. 간단하게, GraalVM Native Image에서 애플리케이션을 컴파일 시점에 완벽한 실행 파일로 만드는데 그 과정에서 해당 어노테이션이 없으면 최종 실행 파일에서 제거할 수도 있다고 한다.. 정확한 의미는 Spring AOT가 RequestMapping 어노테이션을 발견했을 때, ControllerMappingReflectiveProcessor라는 AOT 프로세서를 실행하여 리플렉션 힌트를 생성해야 한다는 의미. 스프링 프레임워크 6부터 사용되는 것이다.

@RequestMapping 만들기

나의 목적은, 스프링에서 @RequestMappingpath를 지정하고 해당 경로로 요청을 보내면 정말 그 메서드가 실행되는데, 이게 정확히 어떻게 이루어지는 것인지 살펴보는 것이다. 결론적으로 우리에게 필요한 것은 @Retention@Target이다. 실제로 스프링과 똑같이 만들어보자.

@Retention(RetentionPolicy.RUNTIME) @Target({ElementType.METHOD, ElementType.TYPE}) public @interface RequestMapping { }

그리고 내부적으로, path를 보유할 수 있는 필드가 필요하기에 그것까지만 똑같이 구현해보자.

@Retention(RetentionPolicy.RUNTIME) @Target({ElementType.METHOD, ElementType.TYPE}) public @interface RequestMapping { String[] path() default {}; }

이렇게 명시하면

@RequestMapping(path = {"/api/hello", "/api/hi"})

이와 같이 사용 가능하다.

지금은 요청을 받아 처리하는 WAS가 없기 때문에 콘솔에 경로를 입력받으면 실제 저 어노테이션이 붙은 메서드를 실행해 보는 것으로 해보자.

이와 같이 처리가 가능하다.

소스코드는 아래와 같다.

import annotation.RequestMapping; public class DemoController { @RequestMapping(path = {"/api/hello", "/api/hi"}) public void hello() { System.out.println("hello"); System.out.println("hi"); } }
import annotation.RequestMapping; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.Scanner; public class Main { public static void main(String[] args) throws InvocationTargetException, IllegalAccessException { Scanner scanner = new Scanner(System.in); String path = scanner.nextLine(); DemoController controller = new DemoController(); for (Method method : DemoController.class.getDeclaredMethods()) { if (method.isAnnotationPresent(RequestMapping.class)) { RequestMapping mapping = method.getAnnotation(RequestMapping.class); for (String mappingPath : mapping.path()) { if (path.equals(mappingPath)) { method.invoke(controller); } } } } } }

차례로, DemoController에 선언된 메서드들(getDeclaredMethodsgetMethods와 달리 상속받지 않고 실제 구현체 내에 존재하는 메서드들을 가져온다)를 순회하면서, RequestMapping.class가 있는지 확인한다. 이후 해당 어노테이션에 존재하는 배열을 순회하며, 입력으로 들어온 path 값과 일치한다면 해당 메서드를 실행시키는 것이다.

현재는 요청을 받아 처리하는 WAS를 만들기 전이기 때문에 추후엔, HTTP 요청을 String으로 받아 파싱할 건데, 이때의 path 값으로 바꿔주기만 하면 되는 것이다.

실제 스프링(6.x)에선 RequestMappingHandlerMapping 클래스가 사용된다. 이 클래스는 애플리케이션 시작 시 @Controller 또는 @RestController로 선언된 빈을 스캔하고, 그 안의 @RequestMapping 또는 그 변형 애너테이션(@GetMapping, @PostMapping 등)이 붙은 메서드를 찾아 등록한다. 등록한 매핑 정보는 RequestMappingInfo 객체로 관리되며, 내부적으로 맵 구조에 저장되어 요청이 들어올 때 빠르게 해당하는 핸들러 메서드를 찾도록 지원하고 있다.

스프링의 RequestMappingHandlerMapping이 매핑 정보를 등록하는 과정을 정리하자면, 다음과 같다.

1. 핸들러 스캔
	@Controller 또는 @RestController로 주석이 달린 빈의 애플리케이션 컨텍스트를 스캔
2. 핸들러 메서드 식별
	감지된 각 컨트롤러에 대해 @RequestMapping 또는 바로 가기로 어노테이션이 달린 메서드를 식별
3. 빌딩 매핑 정보
	URL 경로, HTTP 메서드, 헤더, 미디어 유형과 같은 세부 정보를 캡슐화하는 RequestMappingInfo 객체를 구성
4. 매핑 등록하기
	이러한 RequestMappingInfo 객체는 해당 핸들러 메서드와 연결되어 요청 처리 중 빠른 조회를 위해 매핑 레지스트리에 저장

RequestMappingInfo는 메타데이터 컨테이너 역할을 하며, 
특정 HTTP 요청을 컨트롤러 메서드로 라우팅하는 방법에 대한 모든 필요한 정보를 저장한다. 
이 정보는 데이터 구조(종종 맵)에 저장되며, 여기서 키는 RequestMappingInfo이고 값은 HandlerMethod이다.

https://github.com/spring-projects/spring-framework/blob/main/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.java 구현된 스프링 실제 코드

https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-controller/ann-requestmapping.html?utm_source=chatgpt.com 공식 문서

지금 (내가 만든)프로젝트에서는, @Controller 어노테이션이 붙은 클래스를 모두 찾아 저장해둔 뒤, 요청이 들어오면 해당 List를 순회한다. (단순히 전부 뒤져서 path 비교만함) 맞는 경로를 찾으면 해당 메서드가 원하는 파라미터를 resolve하고, exceptionHandler로 감싼 뒤에 실행한다.

+0607 추가 생각해보니, controller마다 인터페이스를 구현하게 하고 해당 인터페이스를 구현했다면을 기준으로 만들었다. DI는 어노테이션으로 처리.

추후에 RequestMappingInfo 객체를 가지고 찾을 수 있도록 개선하면 확실히 성능적으로 효율적일 것이다. 해당 방향으로 리팩토링 한 후, 다시 더 자세한 설명을 덧 붙이면 좋을 것 같다.

내가 현재 이를 구현한 부분은 아래 링크에 첨부

https://github.com/yyubin/sprout/blob/main/src/main/java/http/request/RequestHandler.java

결론적으론.. 이러한 리플렉션을 사용해서, @RequestMapping은 물론 @Component@Controller, @Service, @Repository 혹은 @RequestParam이나 @PathVariable 등을 더 만들어 볼 것이다. 굳이 리플렉션이 아니더라도, 스프링에서 프록시나 바이트코드를 사용한 곳에는 실제로 해당 API를 사용할 예정이다.

(리플렉션에 대해서 자세히 살펴보는 것은 이 포스팅이 마지막일 것 같은데, 다만 리플렉션의 "속도와 비용"에 대해서는 더 자세히 알고싶은 마음이 크기 때문에, 바로 다음 포스팅으로 가볍게 JMH로 **직접 호출 vs. Method.invoke**를 벤치마킹 테스트를 하여 어느정도의 비용이 소모되는 지 관찰해보려고 한다.)

이러한 과정을 통해 스프링이 어떻게 동작하는지 깊이 있게 이해하고, 나아가 더 견고하고 효율적인 애플리케이션을 설계하는 통찰을 얻을 수 있을 것이라 생각한다. 다음 포스팅은 "리플렉션 벤치마킹 테스트" 이후 **"IoC 컨테이너"**를 만들어 보는 포스팅을 예정하고 있다.