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

이러한 두 가지 불편사항을 개선하고자 어노테이션과 리플렉션을 활용한 자동화 도입을 결정했다. 도입 과정에서 어노테이션과 리플렉션에 대해 학습했으며, 이 글에서는 그 내용과 도입 과정을 정리하고자 한다.
RestDocs의 가장 큰 단점은 요청/응답 필드마다 일일이 문서를 수작업으로 작성해야 한다는 점이다.
이를 해결하기 위해 어노테이션 + 리플렉션 기반의 자동화 구조를 도입했다.
핵심 아이디어는 다음과 같다:
DTO 필드에 커스텀 어노테이션(@ApiRequest, @ApiResponse)을 붙인다.
리플렉션을 통해 해당 어노테이션이 붙은 필드를 런타임에 추출한다.
@Retention(RUNTIME)으로 설정해 런타임에도 어노테이션이 유지되도록 했다.RestDocs의 FieldDescriptor 리스트를 자동 생성한다.
테스트 코드에서는 이 유틸 메서드만 호출하면 끝.
이 방식은 다음과 같은 장점을 제공한다.
이후에는 구현코드와 어노테이션과 리플렉션의 개념을 정리하고, 실제로 어떻게 적용했는지를 하나씩 설명한다.
어노테이션은 아래와 같다.
프론트엔드 개발 리더님과 상의해서 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에 넣는 방법이 프론트엔드 개발자 입장에서 좋을 거 같아 그 부분도 반영하게 되었다.
참고로 객체 안에 객체가 있다면 그 객체에도 제가 만든 어노테이션이 붙어 있다.
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
);
}
}
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)
)));
}
리플렉션을 사용하면 최선의 선택이었을까에 대해서 고민했다.
왜냐하면 리플렉션은 아래와 같은 장단점을 가지고 있기 때문이다.
장점
단점
단점에도 불구하고 리플렉션은 테스트 코드에만 부분적으로 사용되므로 성능 저하가 큰 문제가 되지 않을 것이라고 판단했다. 또한, 코드 가독성 측면에서 리플렉션을 사용하는 코드 자체는 다소 복잡할 수 있지만, DTO 주석이 최신화되지 않은 경우보다는 오히려 가독성이 높다고 생각했다.
실제로 회사에서는 API 문서화를 위한 RestDocs 도입을 초반에 꺼려했다고 한다. 그 이유는 100개가 넘는 필드의 이름과 자료형이 일치하지 않으면 실패가 발생해 많은 시간이 소요되었기 때문이다. 하지만 자동으로 생성해주는 편리한 방법을 제공하자 팀원들이 감사를 표했다.
또한 이를 계기로 리플렉션에 대해 자세히 알게 되었고, 기존에 내가 가지고 있던 잘못된 개념인 CGLIB이 리플렉션을 사용하지 않고 바이트코드를 조작한다고 착각했던 부분을 바로잡을 수 있어 좋은 경험이 되었다.
마지막으로 이 내용을 스터디 시간에 발표해 팀원들과 지식을 공유하는 시간을 가졌고, 내가 사용한 기술을 더 깊이 들여다볼 수 있었다.
추후에 내가 보기 위해서
그리고 이 글에 등장하는 개념(리플렉션, 어노테이션, RestDocs)이 이해되지 않는 사람들을 위해서 정리한다.
Spring Rest Docs는 테스트 코드 기반으로 Restful 문서 생성을 돕는 도구로 기본적으로 Asciidoctor를 사용하여 HTML 문서를 사용한다
이러한 상황에서 저는 장점은 유지하면서 단점을 보완할 방법을 고민했습니다. 그 결과, 어노테이션과 리플렉션을 활용한 자동화 도입을 결정하게 되었습니다.
프로그램 내에서 주석과 유사하게 프로그래밍 언어에는 영향을 미치지 않으면서 프로그램/프로그래머에게 유의미한 정보를 제공하는 역할을 한다.
@Target : 어노테이션이 적용 가능한 대상@Documented : 어노테이션 정보가 javadoc으로 작성된 문서에 포함되도록 한다.@Retention : 어노테이션이 유지되는 범위로 이번 자동화 도입에서 중요하게 볼 부분은 이 어노테이션이다.@Inherited : 어노테이션의 상속 여부@Repeatable : 어노테이션을 반복되게 적용할 수 있는지 여부자동화를 도입하면서 자세하게 볼 내용은 @Retention이기 때문에 밑에 좀 더 자세히 살펴보도록 하자.

.java파일 까지만 유지되는 어노테이션을 설정하고 싶다면 SOURCE 로 설정하면 된다.
.class파일 까지만 유지하고 싶다면CLASS 로 설정하면 된다.
런타임까지 유지하고 싶다면RUNTIME으로 설정하면 된다.
표준 어노테이션들은 어떤 RetentionPolicy를 선택했을까 살펴보면서 몇 가지 의문점들이 발생했고 아래와 같다.
@Getter나 @Setter는 RetentionPolicy.SOURCE인데 어떻게 우리가 런타임에서 get과 set을 사용할 수 있는걸까?이는 롬복이 @Getter나 @Setter 어노테이션을 통해 바이트 코드에 직접 get과 set 메서드를 생성하기 때문이다. 실제로 바이트 코드를 디컴파일한 .class 파일을 확인해보면, 이러한 get과 set 메서드가 생성되어 있음을 볼 수 있다.

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

롬복에서 제공하는@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");
필드 접근하기
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);
}

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은 바이트코드를 조작하여 오버헤드가 발생하지 않는다"라고 읽었기에, 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 다이나믹 프록시에 비해 크지 않음 |
| 인터페이스 구현 필수 여부 | ⭕️ | ❌ |
| 스프링의 디폴트 설정 | ✔️ |
스프링 프레임워크는 의존성 주입 시 리플렉션을 활용한다. 이 방식은 내가 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();
}
}

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