본문 바로가기

Spring

Reflection과 Custom Annotation 활용기 1 - @RequestMapping 구현하기

커스텀 어노테이션과 Reflection은 강력한 기능으로, 프로그래머들에게 많은 편의와 유연성을 제공합니다. 이번 포스팅에서는 Java의 커스텀 어노테이션과 Reflection에 대해 알아보고, 그 활용에 대해 살펴보겠습니다.

Annotation이란?


어노테이션이란 코드에 부가적인 메타데이터를 표현하는 방법으로, 컴파일러, 런타임, reflection 등에서 사용되어 코드의 동작이나 처리를 제어하거나 정보를 제공하는 기능을 수행합니다. 코드에 부가적인 메타데이터를 부여한다는 관점에서 주석과 비슷하지만, 사용 목적과 처리 시점에서 가장 큰 차이가 있습니다.

 

주석은 개발자 간 소통이나 혹은 작성한 코드의 이해를 돕기 위해 작성되는 반면, 어노테이션은 컴파일러, 런타임에 동작을 위한 부가적 정보를 부여하기 위해서입니다.

처리 시점의 관점에서는 어노테이션은 컴파일러나 런타임에 처리되며, 특정 동작이나 처리를 수행할 수 있지만, 주석은 컴파일 또는 실행 과정에서는 무시되며, 코드의 문서화나 이해를 돕기 위해 사용됩니다.

어노테이션은 대표적으로 @Override 어노테이션이 있습니다. 메소드가 오버라이딩한다는 것을 메타데이터로서 표현하고, 해당 오버라이딩이 적절하게 이루어졌는지 검사하는데 사용됩니다. 비슷하게 Spring Framework에서의 @Controller, @Component나 Junit의 @Test 어노테이션 등 많은 어노테이션이 있습니다.


Custom Annotation이란?


커스텀 어노테이션은 개발자가 직접 정의하여 사용할 수 있는 어노테이션으로, 소스 코드에 메타데이터를 추가하는 방식으로 사용됩니다. 이를 통해 개발자는 코드에 대한 부가적인 정보를 제공하거나, 코드의 동작을 제어할 수 있습니다. 

 

제일 먼저, Custom Annotation을 정의할 때 필요한 주요 어노테이션을 살펴보겠습니다.

 

@Retention

어노테이션의 유지 정책을 지정합니다. RetentionPolicy 항목을 선택해서 어노테이션이 유지되는 시점을 지정할 수 있습니다. 아래의 항목 중에서 선택하며, Reflection으로 런타임에 정보에 접근이 필요할 시 런타임 옵션을 사용합니다.

RetentionPolicy 항목 설명
RetentionPolicy.SOURCE 컴파일 시점에서 어노테이션 정보가 제거됨. 런타임에 사용 불가능.
RetentionPolicy.CLASS 클래스 파일에 어노테이션 정보가 유지되지만, 런타임에는 사용 불가능.
RetentionPolicy.RUNTIME 런타임 시점에서도 어노테이션 정보가 유지되어 사용 가능.


@Target

어노테이션을 적용할 대상을 지정합니다. ElementType 항목에 따라 Annotation의 위치를 custom할 수 있습니다.

 

ElementType 항목 설명
ElementType.ANNOTATION_TYPE 어노테이션 타입을 표시함.
ElementType.CONSTRUCTOR 생성자를 표시함.
ElementType.FIELD 필드(멤버 변수)를 표시함.
ElementType.LOCAL_VARIABLE 지역 변수를 표시함.
ElementType.METHOD 메소드를 표시함.
ElementType.PARAMETER 메소드나 생성자의 파라미터를 표시함.
ElementType.TYPE 클래스, 인터페이스, 열거형 등의 타입을 표시함.


@Documented

어노테이션에 대한 문서화를 활성화합니다. 이 어노테이션이 적용된 어노테이션은 Javadoc 등의 문서화 도구에서 문서화됩니다.

 

Custom Annotation은 아래와 같이 작성할 수 있습니다. 다음 코드는 Class와 같은 Type이나 메소드에 작성할 수 있고 runtime에도 남아있는 annotation의 예시입니다.

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
@Documented
public @interface CustomAnnotation {
    // 어노테이션의 내용
}

 


Custom Annotation과 Reflection



커스텀 어노테이션은 Reflection을 통해 런타임에 동적으로 사용될 수 있다는 점에서 강력합니다. Reflection은 Java의 강력한 기능 중 하나로, 실행 중인 프로그램의 클래스, 인터페이스, 메소드, 필드 등의 정보를 동적으로 분석하고 조작할 수 있습니다. Reflection에 대해서는 추후에 관련 내용을 포스팅해보겠습니다. 아직까지 뭔가 잘 와닿지가 않으니 어디에 쓰이는지 살펴보겠습니다.

 

커스텀 어노테이션과 Reflection은 프레임워크나 라이브러리에서 많이 사용되는 기술 중 하나입니다. 예를 들면, 스프링 프레임워크에서는 커스텀 어노테이션과 Reflection을 통해 AOP(Aspect Oriented Programming) 기능을 구현하고 있습니다. 커스텀 어노테이션과 Reflection을 활용하여 메소드 호출 전후에 횡단 관심사를 처리해 Aspect의 분리를 하는데, 여기에도 Annotation과 Reflection이 숨어있습니다. 또한, JUnit 프레임워크에서는 테스트 케이스를 작성할 때 커스텀 어노테이션과 Reflection을 사용하여 테스트 메소드를 동적으로 실행하고, 테스트 결과를 분석하는 등의 기능을 제공한다. 어노테이션만 붙였는데 어떻게 알아서 실행되지?하는 기능은 reflection을 이용한다고 보시면 될 것 같습니다.

 

그렇지만 Reflection을 사용하여 커스텀 어노테이션을 동적으로 처리할 때 주의할 점도 있습니다. Reflection은 클래스를 로딩하므로 성능상의 이슈가 있을 수 있으며, 코드의 가독성을 낮출 수 있다는 단점이 있습니다. 따라서 Reflection을 사용할 때는 성능과 유지보수성을 고려하여 적절하게 사용해야 하겠습니다. 또한, Reflection은 컴파일 타임에 발생하는 오류를 런타임에 발견하게 되는 위험이 있으므로, 주의가 필요합니다.

 

이번 포스팅에서는 커스텀 어노테이션과 reflection으로 웹 서버에서 URL의 라우팅 경로를 Annotation에서 읽어들여 mapping하도록 구현해보겠습니다.


웹 서버에서 URL Mapping하기


루트 URL로 접근 시 index.html을 보여주는 controller를 생각해보겠습니다. 스프링에서는 다음과 같이 작성할 수 있겠죠.

@Controller
public HomeController() {
	@GetMapping("/")
	public String home() {
		return "/index.html";
	}
}

SpringBoot에서는 이렇게 작성하면 나머지 부분을 스프링이 처리해줄 것이지만, Spring 없이 Custom Annotation을 구현하려면 아래의 요청 응답 과정을 고려해서 코드를 작성해야 합니다.

 

위 그림을 살펴보면 HTTP 요청이 들어왔을 때 FrontController가 우선적으로 요청을 받아 핸들러 매핑 정보를 조회하고, 적절한 핸들러를 골라서 요청을 위임할 것입니다. 

 

이를 크게 나누면 아래와 같습니다.

1. Custom Annotation을 정의하고, 작성해줍니다.

2. Controller에 있는 Annotation 정보를 읽어옵니다.

3. FrontController에서 들어온 요청의 URL을 읽어들인 annotation map에서 조회합니다.

4. 일치하는 Controller에 요청을 전달합니다.

 

위 순서에 따라서 구현해보겠습니다.

Custom Annotation 정의

제일 먼저, RequestMapping Annotation을 정의해줍니다.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequestMapping {
    String url() default "";
}

Controller에 방금 정의한 Annotation을 추가하고, Get이 호출되었을 때 수행할 로직을 구현합니다.

@RequestMapping(url = "/")
public class ArticleController implements Controller {
    @Override
    public String doGet(HttpRequest httpRequest) {
        //index.html이라는 뷰를 반환한다.
        return httpRequest.getUrl();
    }
}

 

Annotation Parsing

코드가 살짝 복잡합니다. 가독성이 안좋아진다는 말이 있는 것도 이해가 됩니다. 간단하게 요약하면 controller 패키지 경로에서 controller 인터페이스를 구현하는 구현체를 모두 읽어들여 (어노테이션 URL, 컨트롤러)의 Map에 등록합니다. 여기에서 Reflection이 필요합니다.

/**
     * Reflection을 이용해 controller 패키지에서 Controller 인터페이스를 제외한 구현체 class를 읽어옵니다.
     * class의 Annotation URL 값을 가져와 매핑을 등록해줍니다.
     */
private void initMapping() {
        try {
            ClassLoader classLoader = ClassLoader.getSystemClassLoader();
            Class<?> controllerInterface = Controller.class;

            String packageName = "controller"; // 패키지명
            String packagePath = packageName.replace('.', '/'); // 패키지명을 디렉토리 경로로 변환

            //패키지 URL을 불러온다.
            URL packageUrl = classLoader.getResource(packagePath);
            //패키지에서 URLClassLoader 객체를 생성한다.
            URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{packageUrl}, classLoader);

            //package 디렉토리를 읽어온다.
            File packageDir = new File(packageUrl.getFile());

            //package 경로에서 class 파일을 읽어온다.
            File[] classFiles = packageDir.listFiles(file -> file.getName().endsWith(".class"));

            //모든 클래스의 Mapping 정보를 업데이트한다.
            for (File classFile : classFiles) {
                String className = packageName + "." + classFile.getName().replace(".class", "");
                Class<?> controllerImpl = urlClassLoader.loadClass(className);

                //Controller 인터페이스를 구현하면서 Controller 인터페이스가 아닌 클래스 구현체만 mapping시킨다.
                if (controllerInterface.isAssignableFrom(controllerImpl) && !controllerImpl.equals(
                    Controller.class)) {
                    mapper.put(controllerImpl.getDeclaredAnnotation(RequestMapping.class).url(),
                        (Controller) controllerImpl.newInstance());
                }
            }
        } catch (ClassNotFoundException | InstantiationException | IllegalAccessException e) {
            throw new RuntimeException(e);
        }
    }

 

Dispatch

마지막으로 들어온 요청을 HandlerMapping에서 조회하고 적합한 Controller에게 위임하는 과정입니다.

/**
     * 들어온 요청을 적합한 Controller에게 위임한다.
     * @param httpRequest
     * @return
     */
    public String dispatch(HttpRequest httpRequest) {
        if (!hasMapping(httpRequest)) {
            return httpRequest.getUrl();
        }
        Controller controller = getHandler(httpRequest);
        String viewName;
        try {
            viewName = controller.process(httpRequest);
        } catch (NoSuchMethodException | InvocationTargetException | IllegalAccessException |
                 InstantiationException e) {
            e.printStackTrace();
            throw new IllegalArgumentException("지원하지 않는 요청입니다.");
        }

        return viewName;
    }

 

정리하면, Annotation을 정의하고, reflection을 통해 런타임에 클래스와 어노테이션을 읽어와 HandlerMapping 정보를 저장합니다. 요청이 들어오면 Mapping 정보에 맞는 Controller로 요청을 전달할 수 있게 됩니다.