리플렉션과 어노테이션을 활용한 레스트독스 자동화 도입기

byeol·2024년 9월 14일

도입 배경

RestDocs를 도입하면서 팀원들과 너무 불편하다는 이야기를 자주 나눴다.
불편한 점은 대략적으로 아래와 같다.

  1. 응답과 요청의 필드 개수가 N개라면 코드도 N줄 추가되어야 한다.
  2. 관리 포인트가 증가한다. 코드 변경 시 RestDocs도 함께 수정해야 하며, 요청과 응답 필드의 의미를 RestDocs로 확인할 수 있어 실제 Response와 Request에 중복 주석을 추가하게 된다.

이러한 두 가지 불편사항을 개선하고자 어노테이션과 리플렉션을 활용한 자동화 도입을 결정했다. 도입 과정에서 어노테이션과 리플렉션에 대해 학습했으며, 이 글에서는 그 내용과 도입 과정을 정리하고자 한다.

해결 방법

RestDocs의 가장 큰 단점은 요청/응답 필드마다 일일이 문서를 수작업으로 작성해야 한다는 점이다.
이를 해결하기 위해 어노테이션 + 리플렉션 기반의 자동화 구조를 도입했다.

핵심 아이디어는 다음과 같다:

  1. DTO 필드에 커스텀 어노테이션(@ApiRequest, @ApiResponse)을 붙인다.

    • 설명(description) 속성을 통해 문서화용 메타데이터를 지정한다.
  2. 리플렉션을 통해 해당 어노테이션이 붙은 필드를 런타임에 추출한다.

    • @Retention(RUNTIME)으로 설정해 런타임에도 어노테이션이 유지되도록 했다.
    • 재귀적으로 내부 객체까지 추출해 문서화할 수 있도록 했다.
  3. RestDocs의 FieldDescriptor 리스트를 자동 생성한다.

    • 수작업 없이도 모든 필드에 대한 문서가 자동으로 생성된다.
    • 중첩 객체, 배열(List<>) 필드까지 지원한다.
  4. 테스트 코드에서는 이 유틸 메서드만 호출하면 끝.

    • RestDocs 문서화에 필요한 필드 정보가 자동으로 주입된다.

이 방식은 다음과 같은 장점을 제공한다.

  • 필드 하나당 한 줄씩 문서를 작성해야 하는 불편 해소
  • 코드와 문서의 일관성 유지 (DTO 어노테이션만 최신화하면 됨)
  • 중첩 객체, 리스트 구조까지 대응 가능
  • 테스트 코드가 간결해짐

이후에는 구현코드와 어노테이션과 리플렉션의 개념을 정리하고, 실제로 어떻게 적용했는지를 하나씩 설명한다.

구현 코드

어노테이션은 아래와 같다.
프론트엔드 개발 리더님과 상의해서 API 문서에서 필요한 필드가 무엇인지 정리해서 반영하였다.

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

@Retention(RetentionPolicy.RUNTIME)
public @interface ApiResult {
    String description() default "";

    String format() default "";

    boolean isOptional() default false;
}
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

@Retention(RetentionPolicy.RUNTIME)
public @interface ApiParam {
    String description() default "";

    String format() default "";

    boolean isOptional() default false;
}

그리고 앞 서 언급한 리플렉션을 통해서 클래스의 어노테이션 정보를 가져오는 유틸 클래스는 아래와 같다.

package kr.co.bomapp.server.wings.utils;

import com.fasterxml.jackson.annotation.JsonProperty;
import kr.co.bomapp.server.common.docs.ApiParam;
import kr.co.bomapp.server.common.docs.ApiResult;
import kr.co.bomapp.server.wings.controller.document.util.RestDocsUtils;
import org.springframework.restdocs.payload.FieldDescriptor;
import org.springframework.restdocs.payload.JsonFieldType;
import org.springframework.restdocs.request.ParameterDescriptor;
import org.springframework.restdocs.request.RequestDocumentation;

import java.lang.reflect.Field;
import java.lang.reflect.ParameterizedType;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

import static javax.management.openmbean.SimpleType.STRING;
import static kr.co.bomapp.server.wings.controller.document.util.RestDocsUtils.formatAttr;
import static kr.co.bomapp.server.wings.controller.document.util.RestDocsUtils.typeAttr;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;

public class NextApiDocsUtils {
    private static final String START_PREFIX = "";

    /**
     * 응답
     */
    public static List<FieldDescriptor> generateResponseFieldDescriptors(Class<?> clazz) {
        return generateListResponseFieldDescriptors(clazz, START_PREFIX);
    }

    /**
     * 바디 요청
     */
    public static List<FieldDescriptor> generateRequestFieldDescriptors(Class<?> clazz) {
        List<FieldDescriptor> fieldDescriptors = new ArrayList<>();
        for (Field field : clazz.getDeclaredFields()) {
            if (field.isAnnotationPresent(ApiParam.class)) {
                ApiParam apiParam = field.getAnnotation(ApiParam.class);
                JsonFieldType typeName = determineFieldType(field);
                String fieldName = getFieldName(field);

                FieldDescriptor fieldDescriptor;

                if (field.getType().isEnum()) {
                    fieldDescriptor = fieldWithPath(fieldName)
                            .description(apiParam.description())
                            .attributes(RestDocsUtils.formatAttr(getEnumValues(field.getType())))
                            .type(JsonFieldType.STRING);
                } else if (isCustomObject(field.getType())) {
                    String prefix = fieldName + ".";
                    fieldDescriptors.addAll(generateRequestFieldDescriptorsWithPrefix(field.getType(), prefix));
                    continue;
                } else if (typeName == JsonFieldType.ARRAY) {
                    String prefix = fieldName + "[].";
                    Class<?> listType = getGenericType(field);
                    if (listType != null && listType != String.class && !isPrimitiveOrWrapper(listType)) {
                        fieldDescriptors.addAll(generateRequestFieldDescriptorsWithPrefix(listType, prefix));
                        continue;
                    } else {
                        fieldDescriptor = fieldWithPath(fieldName)
                                .description(apiParam.description())
                                .type(typeName);
                    }
                } else {
                    fieldDescriptor = fieldWithPath(fieldName)
                            .description(apiParam.description())
                            .attributes(RestDocsUtils.formatAttr(apiParam.format()))
                            .type(typeName);
                }

                if (apiParam.isOptional()) {
                    fieldDescriptor = fieldDescriptor.optional();
                }

                fieldDescriptors.add(fieldDescriptor);
            }
        }
        return fieldDescriptors;
    }


    /**
     * 파라미터 요청
     */
    public static List<ParameterDescriptor> generateQueryParamDescriptors(Class<?> clazz) {
        List<ParameterDescriptor> parameterDescriptors = new ArrayList<>();
        for (Field field : clazz.getDeclaredFields()) {
            if (field.isAnnotationPresent(ApiParam.class)) {
                ApiParam apiParam = field.getAnnotation(ApiParam.class);

                String paramName = getFieldName(field);
                JsonFieldType fieldType = determineFieldType(field);

                if (field.getType().isEnum()) {
                    ParameterDescriptor fieldDescriptor = RequestDocumentation.parameterWithName(paramName)
                            .description(apiParam.description())
                            .attributes(RestDocsUtils.formatAttr(getEnumValues(field.getType())))
                            .attributes(RestDocsUtils.typeAttr(JsonFieldType.STRING));
                    parameterDescriptors.add(fieldDescriptor);
                } else {
                    ParameterDescriptor parameterDescriptor = RequestDocumentation.parameterWithName(paramName)
                            .description(apiParam.description())
                            .attributes(RestDocsUtils.formatAttr(apiParam.format()))
                            .attributes(typeAttr(fieldType));

                    parameterDescriptors.add(parameterDescriptor);
                }
            }
        }
        return parameterDescriptors;
    }


    private static List<FieldDescriptor> generateRequestFieldDescriptorsWithPrefix(Class<?> clazz, String prefix) {
        List<FieldDescriptor> fieldDescriptors = new ArrayList<>();
        for (Field field : clazz.getDeclaredFields()) {
            if (field.isAnnotationPresent(ApiParam.class)) {
                ApiParam apiParam = field.getAnnotation(ApiParam.class);
                JsonFieldType typeName = determineFieldType(field);

                String fieldName = getFieldName(field);

                FieldDescriptor fieldDescriptor;

                if (field.getType().isEnum()) {
                    fieldDescriptor = fieldWithPath(prefix + fieldName)
                            .description(apiParam.description())
                            .attributes(formatAttr(getEnumValues(field.getType())))
                            .type(STRING);
                } else if (isCustomObject(field.getType())) {
                    String nestedPrefix = prefix + fieldName + ".";
                    fieldDescriptors.addAll(generateRequestFieldDescriptorsWithPrefix(field.getType(), nestedPrefix));
                    continue;

                } else if (typeName == JsonFieldType.ARRAY) {
                    String nestedPrefix = prefix + fieldName + "[].";
                    Class<?> listType = getGenericType(field);
                    if (listType != null && listType != String.class && !isPrimitiveOrWrapper(listType)) {
                        fieldDescriptors.addAll(generateRequestFieldDescriptorsWithPrefix(listType, nestedPrefix));
                        continue;
                    } else {
                        fieldDescriptor = fieldWithPath(prefix + fieldName)
                                .description(apiParam.description())
                                .attributes(RestDocsUtils.formatAttr(apiParam.format()))
                                .type(typeName);
                    }
                } else {
                    fieldDescriptor = fieldWithPath(prefix + fieldName)
                            .description(apiParam.description())
                            .attributes(RestDocsUtils.formatAttr(apiParam.format()))
                            .type(typeName);
                }

                if (apiParam.isOptional()) {
                    fieldDescriptor = fieldDescriptor.optional();
                }

                fieldDescriptors.add(fieldDescriptor);
            }
        }
        return fieldDescriptors;
    }


    private static List<FieldDescriptor> generateListResponseFieldDescriptors(Class<?> clazz, String prefix) {
        List<FieldDescriptor> fieldDescriptors = new ArrayList<>();

        for (Field field : clazz.getDeclaredFields()) {
            if (field.isAnnotationPresent(ApiResult.class)) {
                ApiResult apiResult = field.getAnnotation(ApiResult.class);
                JsonFieldType typeName = determineFieldType(field);

                String fieldName = getFieldName(field);

                FieldDescriptor fieldDescriptor;

                if (field.getType().isEnum()) {
                    fieldDescriptor = fieldWithPath(prefix + fieldName)
                            .description(apiResult.description())
                            .attributes(formatAttr(getEnumValues(field.getType())))
                            .type(typeName);
                } else if (isCustomObject(field.getType())) {
                    String nestedPrefix = prefix + fieldName + ".";
                    fieldDescriptors.addAll(generateListResponseFieldDescriptors(field.getType(), nestedPrefix));
                    continue;
                } else if (typeName == JsonFieldType.ARRAY) {
                    String nestedPrefix = prefix + fieldName + "[].";
                    Class<?> listType = getGenericType(field);
                    if (listType != null && listType != String.class && !isPrimitiveOrWrapper(listType)) {
                        fieldDescriptors.addAll(generateListResponseFieldDescriptors(listType, nestedPrefix));
                        continue;
                    } else {
                        fieldDescriptor = fieldWithPath(prefix + fieldName)
                                .description(apiResult.description())
                                .type(typeName);
                    }
                } else {
                    fieldDescriptor = fieldWithPath(prefix + fieldName)
                            .description(apiResult.description())
                            .attributes(RestDocsUtils.formatAttr(apiResult.format()))
                            .type(typeName);
                }

                if (apiResult.isOptional()) {
                     fieldDescriptor.optional();
                }

                fieldDescriptors.add(fieldDescriptor);
            }
        }
        return fieldDescriptors;
    }

    private static JsonFieldType determineFieldType(Field field) {
        Class<?> fieldType = field.getType();

        if (String.class.equals(fieldType) || LocalDateTime.class.equals(fieldType) || Enum.class.equals(fieldType) || fieldType.isEnum()) {
            return JsonFieldType.STRING;
        } else if (Number.class.isAssignableFrom(fieldType) ||
                fieldType.isPrimitive() && !fieldType.equals(boolean.class) ||
                Long.class.equals(fieldType) || fieldType.equals(int.class) || fieldType.equals(long.class)) {
            return JsonFieldType.NUMBER;
        } else if (Boolean.class.equals(fieldType) || fieldType.equals(boolean.class)) {
            return JsonFieldType.BOOLEAN;
        } else if (fieldType.isArray() || List.class.isAssignableFrom(fieldType)) {
            return JsonFieldType.ARRAY;
        } else {
            return JsonFieldType.OBJECT;
        }
    }

    private static Class<?> getGenericType(Field field) {
        if (field.getGenericType() instanceof ParameterizedType) {
            ParameterizedType parameterizedType = (ParameterizedType) field.getGenericType();
            return (Class<?>) parameterizedType.getActualTypeArguments()[0];
        }
        return null;
    }

    private static boolean isPrimitiveOrWrapper(Class<?> clazz) {
        return clazz.isPrimitive() || clazz.equals(Boolean.class) || clazz.equals(Byte.class) ||
                clazz.equals(Character.class) || clazz.equals(Double.class) || clazz.equals(Float.class) ||
                clazz.equals(Integer.class) || clazz.equals(Long.class) || clazz.equals(Short.class);
    }

    private static boolean isCustomObject(Class<?> clazz) {
        return !clazz.isPrimitive() && !clazz.getPackageName().startsWith("java") && !clazz.isEnum();
    }

    private static String getFieldName(Field field) {
        if (field.isAnnotationPresent(JsonProperty.class)) {
            JsonProperty jsonProperty = field.getAnnotation(JsonProperty.class);
            return jsonProperty.value();
        }
        return field.getName();
    }

    /**
     * Enum 클래스의 가능한 값을 문자열로 반환
     */
    private static String getEnumValues(Class<?> enumClass) {
        return Arrays.stream(enumClass.getEnumConstants())
                .map(Object::toString)
                .collect(Collectors.joining(", "));
    }

}

해당 유틸 클래스를 만들면서 Enum 값들을 format에 넣는 방법이 프론트엔드 개발자 입장에서 좋을 거 같아 그 부분도 반영하게 되었다.

사용 예시

참고로 객체 안에 객체가 있다면 그 객체에도 제가 만든 어노테이션이 붙어 있다.

  • Request
import kr.co.bomapp.domain.rds.chat.model.chatbot.ChatBotBlockType;
import kr.co.bomapp.domain.rds.chat.service.chatbotblock.dto.param.ChatBotBlockCreateParam;
import kr.co.bomapp.server.common.docs.ApiParam;
import kr.co.bomapp.server.common.docs.ApiResult;
import lombok.Getter;

import java.util.List;
import java.util.stream.Collectors;

@Getter
public class ChatBotBlockCreateRequest {
    @ApiParam(description = "시나리오 ID")
    private final Long scenarioId;

    @ApiParam(description = "행 위치")
    private final int row;

    @ApiParam(description = "열 위치")
    private final int col;

    @ApiParam(description = "블럭 제목", isOptional = true)
    private final String blockTitle;

    @ApiParam(description = "블록 타입")
    private final ChatBotBlockType blockType;

    @ApiParam(description = "이미지 경로", isOptional = true)
    private final String imageUrl;

    @ApiParam(description = "블럭 내용", isOptional = true)
    private final String message;

    @ApiParam(description = "버튼 목록")
    private final List<ChatBotBlockButtonCreateRequest> buttons;

    @ApiParam(description = "서브 블록 ID")
    private final Long subBlockId;

    private ChatBotBlockCreateRequest(
            Long scenarioId,
            int row,
            int col,
            String blockTitle,
            ChatBotBlockType blockType,
            String imageUrl,
            String message,
            List<ChatBotBlockButtonCreateRequest> buttons,
            Long subBlockId) {
        this.scenarioId = scenarioId;
        this.row = row;
        this.col = col;
        this.blockTitle = blockTitle;
        this.blockType = blockType;
        this.imageUrl = imageUrl;
        this.message = message;
        this.buttons = buttons;
        this.subBlockId = subBlockId;
    }

    public static ChatBotBlockCreateRequest of(
            Long scenarioId,
            int row,
            int col,
            String blockTitle,
            ChatBotBlockType blockType,
            String imageUrl,
            String message,
            List<ChatBotBlockButtonCreateRequest> buttons,
            Long subBlockId
    ) {
        return new ChatBotBlockCreateRequest(
                scenarioId,
                row,
                col,
                blockTitle,
                blockType,
                imageUrl,
                message,
                buttons,
                subBlockId
        );
    }

    public ChatBotBlockCreateParam toChatBotBlockCreateParam(Long byPlannerId) {
        return ChatBotBlockCreateParam.of(
                scenarioId,
                row,
                col,
                blockTitle,
                blockType,
                imageUrl,
                message,
                buttons.stream()
                        .map(ChatBotBlockButtonCreateRequest::toChatBotBlockButtonCreateParam)
                        .collect(Collectors.toList()),
                subBlockId,
                byPlannerId
        );

    }

}
  • Response
import kr.co.bomapp.domain.rds.chat.model.chatbot.ChatBotBlockType;

import kr.co.bomapp.domain.rds.chat.service.chatbotblock.dto.result.ChatBotBlockCreateResult;
import kr.co.bomapp.server.common.docs.ApiResult;
import lombok.Getter;

import java.util.List;
import java.util.stream.Collectors;

@Getter
public class ChatBotBlockCreateResponse {
    @ApiResult(description = "시나리오 ID")
    private final Long scenarioId;

    @ApiResult(description = "생성된 블럭 ID")
    private final Long blockId;

    @ApiResult(description = "행 위치")
    private final int row;

    @ApiResult(description = "열 위치")
    private final int col;

    @ApiResult(description = "블럭 제목")
    private final String blockTitle;

    @ApiResult(description = "블럭 타입")
    private final ChatBotBlockType blockType;

    @ApiResult(description = "이미지 경로")
    private final String imageUrl;

    @ApiResult(description = "블럭 메시지")
    private final String message;

    @ApiResult(description = "버튼 목록들")
    private final List<ChatBotBlockButtonCreateResponse> buttons;

    @ApiResult(description = "서브 블록 ID")
    private final Long subBlockId;

    @ApiResult(description = "시작 블럭 여부")
    private final Boolean isStartBlock;

    private ChatBotBlockCreateResponse(
            Long scenarioId,
            Long blockId,
            int row,
            int col,
            String blockTitle,
            ChatBotBlockType blockType,
            String imageUrl,
            String message,
            List<ChatBotBlockButtonCreateResponse> buttons,
            Long subBlockId, Boolean isStartBlock) {
        this.scenarioId = scenarioId;
        this.blockId = blockId;
        this.row = row;
        this.col = col;
        this.blockTitle = blockTitle;
        this.blockType = blockType;
        this.imageUrl = imageUrl;
        this.message = message;
        this.buttons = buttons;
        this.subBlockId = subBlockId;
        this.isStartBlock = isStartBlock;
    }

    public static ChatBotBlockCreateResponse of(
            ChatBotBlockCreateResult result
    ) {
        return new ChatBotBlockCreateResponse(
                result.getScenarioId(),
                result.getBlockId(),
                result.getRow(),
                result.getCol(),
                result.getBlockTitle(),
                result.getBlockType(),
                result.getImageUrl(),
                result.getMessage(),
                result.getButtons()
                        .stream()
                        .map(ChatBotBlockButtonCreateResponse::of)
                        .collect(Collectors.toList()),
                result.getSubBlockId(),
                result.getIsStartBlock()
        );
    }

}
  • 대망의 테스트 코드
   @Test
    @DisplayName("시나리오 블럭을 만들 수 있다.")
    @WithPlannerMockCustomUser(role = Role.BOMAPP_ADMIN)
    void createBlock() throws Exception {
        //given
        given(chatBotBlockService.create(any())).willReturn(ChatBotFixture.chatBotBlockCreateResult());

        // when & then
        mockMvc.perform(post("/admin/chatbots/scenarios/blocks")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(ChatBotFixture.chatBotBlockCreateRequest())))
                .andDo(document("create-scenario-block",
                        requestFields(
                                NextApiDocsUtils.generateRequestFieldDescriptors(ChatBotBlockCreateRequest.class)
                        ),
                        responseFields(
                                NextApiDocsUtils.generateResponseFieldDescriptors(ChatBotBlockCreateResponse.class)
                        )));
    }

최선의 선택일까?

리플렉션을 사용하면 최선의 선택이었을까에 대해서 고민했다.
왜냐하면 리플렉션은 아래와 같은 장단점을 가지고 있기 때문이다.

장점

  1. 프로그램의 유연성 높임
  2. 프레임워크나 라이브러리 개발
    1. 동적 프록시
    2. 스프링 DI

단점

  1. 성능 저하
  2. 프로그램의 안정성 저하
  3. 보안 문제 - private
  4. 코드의 가독성과 유지보수성 저하

단점에도 불구하고 리플렉션은 테스트 코드에만 부분적으로 사용되므로 성능 저하가 큰 문제가 되지 않을 것이라고 판단했다. 또한, 코드 가독성 측면에서 리플렉션을 사용하는 코드 자체는 다소 복잡할 수 있지만, DTO 주석이 최신화되지 않은 경우보다는 오히려 가독성이 높다고 생각했다.

느낀점

실제로 회사에서는 API 문서화를 위한 RestDocs 도입을 초반에 꺼려했다고 한다. 그 이유는 100개가 넘는 필드의 이름과 자료형이 일치하지 않으면 실패가 발생해 많은 시간이 소요되었기 때문이다. 하지만 자동으로 생성해주는 편리한 방법을 제공하자 팀원들이 감사를 표했다.

또한 이를 계기로 리플렉션에 대해 자세히 알게 되었고, 기존에 내가 가지고 있던 잘못된 개념인 CGLIB이 리플렉션을 사용하지 않고 바이트코드를 조작한다고 착각했던 부분을 바로잡을 수 있어 좋은 경험이 되었다.

마지막으로 이 내용을 스터디 시간에 발표해 팀원들과 지식을 공유하는 시간을 가졌고, 내가 사용한 기술을 더 깊이 들여다볼 수 있었다.

번외) 관련 개념

추후에 내가 보기 위해서
그리고 이 글에 등장하는 개념(리플렉션, 어노테이션, RestDocs)이 이해되지 않는 사람들을 위해서 정리한다.

RestDocs란

Spring Rest Docs는 테스트 코드 기반으로 Restful 문서 생성을 돕는 도구로 기본적으로 Asciidoctor를 사용하여 HTML 문서를 사용한다

  • 장점
    • 프로덕션 코드에 영향을 주지 않는다. (Swagger와 비교하면 이해하기 쉽다.)
    • 테스트 코드가 성공해야만 문서 작성이 가능하다. (테스트 주도 개발과 신뢰성 향상)
  • 단점
    • 테스트 코드 작성에 많은 시간과 노력이 필요하다.
    • 코드 수정 시 테스트 코드도 함께 수정해야 해서 관리가 어렵다.

이러한 상황에서 저는 장점은 유지하면서 단점을 보완할 방법을 고민했습니다. 그 결과, 어노테이션과 리플렉션을 활용한 자동화 도입을 결정하게 되었습니다.

어노테이션이란

프로그램 내에서 주석과 유사하게 프로그래밍 언어에는 영향을 미치지 않으면서 프로그램/프로그래머에게 유의미한 정보를 제공하는 역할을 한다.

종류

  • 표준 어노테이션 : @Override, @Deprecated와 같이 표준적으로 쓰이는 어노테이션
  • 메타 어노테이션 : 어노테이션을 위한 어노테이션
    • @Target : 어노테이션이 적용 가능한 대상
    • @Documented : 어노테이션 정보가 javadoc으로 작성된 문서에 포함되도록 한다.
    • @Retention : 어노테이션이 유지되는 범위로 이번 자동화 도입에서 중요하게 볼 부분은 이 어노테이션이다.
    • @Inherited : 어노테이션의 상속 여부
    • @Repeatable : 어노테이션을 반복되게 적용할 수 있는지 여부

자동화를 도입하면서 자세하게 볼 내용은 @Retention이기 때문에 밑에 좀 더 자세히 살펴보도록 하자.

어노테이션이 유지되는 범위

  • .java파일 까지만 유지되는 어노테이션을 설정하고 싶다면 SOURCE 로 설정하면 된다.

  • .class파일 까지만 유지하고 싶다면CLASS 로 설정하면 된다.

  • 런타임까지 유지하고 싶다면RUNTIME으로 설정하면 된다.

표준 어노테이션들은 어떤 RetentionPolicy를 선택했을까 살펴보면서 몇 가지 의문점들이 발생했고 아래와 같다.

의문점 1. 롬복의 @Getter@Setter는 RetentionPolicy.SOURCE인데 어떻게 우리가 런타임에서 get과 set을 사용할 수 있는걸까?

이는 롬복이 @Getter나 @Setter 어노테이션을 통해 바이트 코드에 직접 get과 set 메서드를 생성하기 때문이다. 실제로 바이트 코드를 디컴파일한 .class 파일을 확인해보면, 이러한 get과 set 메서드가 생성되어 있음을 볼 수 있다.

의문점 2. RetetionPolicy.CLASS는 왜 사용하는 걸까?

굳이 컴파일 타임까지 유지되어야 하는 이유가 있을까? 라는 의문이 들었다.

롬복에서 제공하는@NonNull

어노테이션을 보면 RetentionPolicy.CLASS인 것을 확인할 수 있었고 이를 보며 위와 같은 의문이 발생했다. 하지만 해당 내용을 검색했을 때 나온 블로그(https://jeong-pro.tistory.com/234)의 댓글을 통해서 이유를 알 수 있었다.

jar 파일에는 소스 파일이 아닌 .class 파일이 포함되어져 있고 이에 따라서 IDE 부가 기능 사용을 위해RetentionPolicy.CLASS인 것이었다.

이제 어노테이션의 개념을 알아봤으니 리플렉션 개념에 대해서 짚어보자

리플렉션이란

Reflection은 "거울에 비추다" 또는 "반사하다"라는 사전적 의미를 가지고 있다. 자바에서 이는 런타임 시점에 JVM 메모리 영역에 있는 클래스의 정보를 추출하는 API를 의미한다.

이 리플렉션을 통해 메서드, 필드, 어노테이션, Enum, 부모 클래스 등의 다양한 정보를 추출할 수 있다. 이러한 기능은 프록시 구현과 스프링의 의존성 주입(DI) 등 다양한 곳에서 활용된다.

사용방법

public class Toss {
    private final String phoneNumber = "1599-4905";

    public final String address = "131 Teheran-ro, Gangnam District, Seoul";

    public String provideInsuranceAnalyze() {
        return "토스는 보장분석을 제공한다.";
    }

    private String provideHealthAnalyze() {
        return "토스는 건강 정보를 제공한다..";
    }
}

Toss 클래스의 정보를 JVM 메모리 영역에서 추출해보자.
Declared가 붙으면 private까지 접근할 수 있으나 본인까지만 추출하고

붙지 않는다면 public만 가능하지만 부모의 public까지 접근 가능하다.

  • 클래스 정보 접근하기

          // (1) 클래스 타임.class
          final Class<Toss> tossClass = Toss.class;
    
          // (2) 인스턴스.getClass()
          final Toss toss = new Toss();
          final Class<? extends Toss> tossClass1 = toss.getClass();
    
          // (3) Class.forName(풀 패키지 경로)
          final Class<?> tossClass2 = Class.forName("com.reflection_aop.reflection_aop.Toss");
    
  • 필드 접근하기

    • 자신의 public, private 필드
      위에서 field.setAccessible(true);로 설정하지 않으면 IllegalAccessException이 발생한다.
        final Class<?> tossClass2 = Class.forName("com.reflection_aop.reflection_aop.Toss");
      
         for (Field field : tossClass2.getDeclaredFields()) {
               field.setAccessible(true); //IllegalAccessException
               String fieldValue = field.get(toss).toString();
               System.out.println(fieldValue);
           }
      
    • 본인 및 부모의 public 필드
         for (Field field : tossClass2.getFields()) {
                String fieldValue = field.get(toss).toString();
                System.out.println(fieldValue);
            }
      
  • 메서드 접근

    • 본인의 priave, public 메서드

        for (Method method : tossClass2.getDeclaredMethods()) {
                System.out.println(method.getName());
                System.out.println(method);
                System.out.println("=======");
            }
      

    • 부모와 본인의 public 메서드

       for (Method method : tossClass2.getMethods()) {
                System.out.println(method.getName());
                System.out.println(method);
                System.out.println("=======");
            }
      

    최고 조상인 Object에 있는 notify, wait, toString, equals 등의 메서드까지 접근할 수 있음을 확인할 수 있다.

그러면 이제부터 구체적으로 어떻게 활용되는지 알아보자.

다이나믹 프록시

만약에 모든 메서드 혹은 여러 개의 메서드의 시작 시간과 끝나는 시간을 알고 싶다고 가정하자
그러면 핵심 로직(메서드) 위 아래로 시작 시간과 끝나는 시간을 출력해야 할 것이다.
이는 중복된 코드가 여러 곳에서 발생한다.

이렇게 여러 곳에 걸쳐 있는 기능들을 횡단 관심사라고 한다.
그리고 횡단 관심사와 관련된 프로그래밍 기법이 AOP(Aspect Oriented Programming)라고 볼 수 있다.
AOP 관점 지향 프로그래밍은 말 그대로, 어떤 로직을 기준으로 핵심적인 관점과 부가적인 관점으로 나누어서 보고, 그 관점을 기준으로 각각 모듈화 하겠다는 것이다.

AOP를 구현하는 방법에는 여러 가지가 있지만 스프링은 프록시를 통해서 구현한다.

프록시란 무엇일까? 말 그대로 중개한다는 의미를 가지고 있다.
부동산 중개인을 떠올려 본다면 집을 구경하기 위해서(진짜)는 부동산 중개인을 통해서 약속을 잡아야 하고
집을 사기 위해서(진짜)는 부동산 중개인을 통해서 계약을 맺어야 한다. 그러면 결국 프록시는 "진짜"를 찾아가기 위해 거쳐야 하는 것이며 이 "진짜"에 대한 정보를 리플렉션을 통해서 알아낸다.

프록시의 종류는 아래와 같은데 정적 프록시는 구현하기에 복잡하기 때문에 스프링은 동적 프록시를 제공한다. ( 그 중에서도 CGLIB 다이나믹 프록시를 제공한다.)

  • 정적 프록시
  • 동적 프록시
    • JDK 다이나믹 프록시
    • CGLIB 다이나믹 프록시

여러 글에서 "JDK 다이나믹 프록시는 리플렉션을 사용하여 오버헤드가 발생하지만 CGLIB은 바이트코드를 조작하여 오버헤드가 발생하지 않는다"라고 읽었기에, CGLIB 다이나믹 프록시는 리플렉션을 사용하지 않는다고 생각했다. 그러나 실제로는 둘 다 리플렉션을 사용한다.

JDK 다이나믹 프록시와 CGLIB 다이나믹 프록시의 주요 차이점은 리플렉션 사용 빈도에 있다.

CGLIB 다이나믹 프록시는 리플렉션을 통해 원본 객체의 메타데이터를 얻고, 이를 바탕으로 바이트코드를 조작해 원본 객체의 하위 클래스를 생성한다. 구체적으로, 리플렉션으로 원본 객체의 메서드와 필드 정보를 가져와 이를 오버라이드하도록 바이트코드를 조작한다.

프록시가 생성되면, 오버라이드된 메서드가 직접 호출되므로 더 이상 리플렉션이 필요하지 않다. 즉, 리플렉션은 프록시 생성 단계에서만 사용되며, 이후에는 오버라이드된 메서드가 바이트코드로 직접 실행된다.

요약하면, JDK 다이나믹 프록시는 매번 리플렉션을 사용하지만, CGLIB 다이나믹 프록시는 초기 생성 시에만 리플렉션을 사용한다.

여기서 CGLIB은 바이트코드를 조작하여 원본 객체의 메서드를 오버라이드한다고 했다. 이것이 @Entity가 붙은 클래스의 기본 생성자가 private일 수 없고, @Transactional어노테이션이 붙은 메서드가 private이면 작동하지 않는 이유다.

각각 구현 예시는 아래와 같다.

  • JDK 다이나믹 프록시 구현 방법

    import lombok.extern.slf4j.Slf4j;
    
    import java.lang.reflect.InvocationHandler;
    import java.lang.reflect.Method;
    
    @Slf4j
    public class JdkLogInterceptor implements InvocationHandler {
        private final Object target;
    
        public JdkLogInterceptor(Object target) {
            this.target = target;
        }
    
        @Override
        public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
            if (method.getName().startsWith("save")) {
                log.info("Executing 조회 메서드 호출");
                return method.invoke(target, args);
            }
            log.info("Executing 조회가 아닌 메서드 호출");
            return method.invoke(target, args);
        }
    }
    public interface SampleJdkService {
        void save();
    }
    @Service
    public class SampleJdkServiceImpl implements SampleJdkService{
    
        public void save() {}
    }
    @SpringBootTest
    @EnableAutoConfiguration(exclude = { DataSourceAutoConfiguration.class })
    public class JdkProxyTest {
        @Autowired
        SampleJdkService sampleJdkService;
    
        @DisplayName("jdk proxy test")
        @Test
        void testJdkProxy2() {
            SampleJdkService sampleJdkService = (SampleJdkService) Proxy.newProxyInstance(
                    JdkProxyTest.class.getClassLoader(),
                    new Class[]{SampleJdkService.class},
                    new JdkLogInterceptor(new SampleJdkServiceImpl())
            );
            sampleJdkService.save();
        }
    
    }

  • cglib 다이나믹 프록시 구현 예시

    import lombok.extern.slf4j.Slf4j;
    import org.springframework.cglib.proxy.MethodInterceptor;
    import org.springframework.cglib.proxy.MethodProxy;
    
    import java.lang.reflect.Method;
    
    @Slf4j
    public class LogInterceptor implements MethodInterceptor {
        private final Object target;
    
        public LogInterceptor(Object target) {
            this.target = target;
        }
    
        @Override
        public Object intercept(Object obj, Method method, Object[] args, MethodProxy methodProxy) throws Throwable {
                log.info("Before method (using MethodProxy)");
                Object result = methodProxy.invokeSuper(obj, args);  
                log.info("After method (using MethodProxy)");
                return result;
        }
    }
    @Service
    public class SampleService {
    
        public void save() {
    
        }
    }
    
    @SpringBootTest
    @EnableAutoConfiguration(exclude = { DataSourceAutoConfiguration.class })
    class CglibTest {
    
        @Autowired
        SampleService sampleService;
    
        @DisplayName("cglib test")
        @Test
        void testCglib() {
            SampleService sampleService = (SampleService) Enhancer.create(
                    SampleService.class,
                    new LogInterceptor(new SampleService())
            );
    
            sampleService.save();
        }
     }

    근데 이 예시는 위에 JDK 다이나믹 프록시 예시와 상황이 좀 다르다. 위 예시는 save로 시작하는 메서드에만 로그가 찍히도록 했기 때문이다.

    그렇다면 CGLIB 다이나믹 프록시에도 save로 시작하는 메서드에 로그를 찍도록 바꾸고 싶을 수도 있다.

    import lombok.extern.slf4j.Slf4j;
    import org.springframework.cglib.proxy.MethodInterceptor;
    import org.springframework.cglib.proxy.MethodProxy;
    
    import java.lang.reflect.Method;
    
    @Slf4j
    public class LogInterceptor implements MethodInterceptor {
        private final Object target;
    
        public LogInterceptor(Object target) {
            this.target = target;
        }
    
        @Override
        public Object intercept(Object obj, Method method, Object[] args, MethodProxy methodProxy) throws Throwable {
            if (method.getName().startsWith("save")) {
                log.info("Before method (using MethodProxy)");
                Object result = methodProxy.invokeSuper(obj, args);  
                log.info("After method (using MethodProxy)");
                return result;
            } else {
                return methodProxy.invokeSuper(obj, args);
            }
        }
    }

    Method.getName()을 호출하는 것도 리플렉션을 사용하는 것이므로, 결국 리플렉션이 매번 호출된다. 이로 인해 일정 수준의 성능 오버헤드가 발생할 수 있다. 다만, Method.getName()은 단순히 메소드 이름을 가져오는 작업이기 때문에, 실제 메소드를 호출하는 리플렉션에 비해 오버헤드가 크지 않다.

그래서 이 둘을 아래 표로 정리해보았다.

JDK 다이나믹 프록시CGLIB 다이나믹 프록시
리플랙션 호출 빈도매번 호출처음 프록시를 생성할 때 메타정보를 가져오기 위해 리플렉션을 호출하지만 그 이후는 호출하지 않음.
오버헤드발생JDK 다이나믹 프록시에 비해 크지 않음
인터페이스 구현 필수 여부⭕️
스프링의 디폴트 설정✔️

스프링 DI

스프링 프레임워크는 의존성 주입 시 리플렉션을 활용한다. 이 방식은 내가 RestDocs 자동화에 구현한 방식과 유사하다. 결국, 어노테이션을 런타임까지 유지하고, 리플렉션을 통해 해당 어노테이션의 존재 여부를 판단하기 때문이다.

간단하게 스프링 프레임워크의 DI를 구현하면 아래와 같다.

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

@Retention(RetentionPolicy.RUNTIME)
public @interface Inject {

}
public class AccountRepository {
    public void save() {
        System.out.println("Repo.save");
    }
}
public class AccountService {
    @Inject
    AccountRepository accountRepository;

    public void join() {
        System.out.println("Service.join");
        accountRepository.save();
    }
}
public class ContainerService {
    public static <T> T getObject(Class<T> classType) {
        T instance = createInstance(classType);
        Arrays.stream(classType.getDeclaredFields()).forEach(f -> {
            if (f.getAnnotation(Inject.class) != null) {
                Object fieldInstance = createInstance(f.getType());
                f.setAccessible(true);
                try {
                    f.set(instance, fieldInstance);
                } catch (IllegalAccessException e) {
                    throw new RuntimeException(e);
                }
            }
        });
        return instance;
    }
    private static <T> T createInstance(Class<T> classType) {
        try {
            return classType.getConstructor().newInstance();
        } catch (InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException e) { // 멀티 catch 블럭
            throw new RuntimeException(e);
        }
    }
}
import com.reflection_aop.reflection_aop.di.AccountService;
import com.reflection_aop.reflection_aop.di.ContainerService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
@EnableAutoConfiguration(exclude = { DataSourceAutoConfiguration.class })
class DiTest {

    @DisplayName("di의 작동방법을 익힌다.")
    @Test
    void test() {
        AccountService accountService = ContainerService.getObject(AccountService.class);
        accountService.join();
    }

}

profile
꾸준하게 Ready, Set, Go!

4개의 댓글

comment-user-thumbnail
2024년 9월 15일

오랜만에 들렀는데 역시...👍

1개의 답글
comment-user-thumbnail
2024년 9월 29일

와웅 별님 멋진 경험 공유해주셔서 감사합니다👏

1개의 답글