///
Search
🌝

Reflection 사용하여 Category 변경에 대응하기

태그
Project
E-Room

Situation

E-room 서비스는 리뷰를 텍스트로도 쓸 수 있지만, Category 별로 ‘좋은 점’과 ‘싫은 점’을 나누어서 태깅할 수도 있고, 별점을 메길 수도 있습니다.
이 과정에서 키워드가 바뀔 수도 있기에 저런 키워드 카테고리를 저희는 DB에도 저장했지만, 서버에서 Enum으로 관리를 해주고 있었습니다.
이 때, 프런트에서 저 Score를 Dto에 실어서 보내주면 이를 저장하는 로직을 만들었어야했습니다.
다만 이 카테고리가 계속 바뀔 수도 있다는 점, 그 때마다 코드를 변경해야한다는 점과 더불어서 수동으로 get, set을 반복하면서 코드를 짜는 것은 비효율적이라 생각이 들었습니다.
이에 ReviewScoreDto와 ReviewCategoryEnum 두 개를 적절히 매핑하는 함수를 짜야할 필요가 있다고 생각했고 이에 ReviewScoreDto 내 필드명과 ReviewCategoryEnum의 이름들을 매칭시킨 후, ReviewScoreDto의 필드와 그 값을 Reflection으로 접근하여 뽑아오는 방식으로 코드를 수정하였습니다.
ReviewScoreDto
package com.project.Project.controller.review.dto; import lombok.*; import javax.validation.constraints.DecimalMax; import javax.validation.constraints.DecimalMin; import javax.validation.constraints.NotNull; @NoArgsConstructor @AllArgsConstructor @Getter @Setter @Builder public class ReviewScoreDto { /** * 교통점수 */ @NotNull @DecimalMin(value = "0.0") @DecimalMax(value = "5.0") private Double traffic; /** * 건물 및 단지 점수 */ @NotNull @DecimalMin(value = "0.0") @DecimalMax(value = "5.0") private Double buildingComplex; /** * 주변 및 환경 점수 */ @NotNull @DecimalMin(value = "0.0") @DecimalMax(value = "5.0") private Double surrounding; /** * 내부 점수 */ @NotNull @DecimalMin(value = "0.0") @DecimalMax(value = "5.0") private Double internal; /** * 생활 및 입지 점수 */ @NotNull @DecimalMin(value = "0.0") @DecimalMax(value = "5.0") private Double livingLocation; /** * 해당 거주지 만족도 */ @NotNull @DecimalMin(value = "0.0") @DecimalMax(value = "5.0") private Double residenceSatisfaction; }
Java
복사
ReviewCategoryEnum
package com.project.Project.domain.enums; import lombok.Getter; @Getter public enum ReviewCategoryEnum { TRAFFIC("교통"), BUILDINGCOMPLEX("건물 및 단지"), SURROUNDING("주변 및 환경"), INTERNAL("내부"), LIVINGLOCATION("생활 및 입지"), RESIDENCESATISFACTION("해당 거주지 만족도"); private String description; ReviewCategoryEnum(String description) { this.description = description; } public static boolean contains(String test) { for (ReviewCategoryEnum r : ReviewCategoryEnum.values()) { if (r.name().equalsIgnoreCase(test)) { return true; } } return false; } public static boolean contains(ReviewCategoryEnum test) { for (ReviewCategoryEnum r : ReviewCategoryEnum.values()) { if (r.equals(test)) { return true; } } return false; } }
Java
복사

수정된 코드 Snippet

다음은 리뷰를 등록하는 함수입니다.
public static Review createReview(ReviewRequestDto.ReviewCreateDto request, Member member, Building building) { //reviewToReviewCategoryList 생성 List<ReviewToReviewCategory> reviewToReviewCategoryList = createReviewToReviewCategoryList(request); // ReviewSummary ReviewSummary reviewSummary = initialReviewSummary(); // ReviewToReviewKeywordList List<ReviewToReviewKeyword> selectedReviewAdvantageKeywordList = createKeywordList(request, request.getAdvantageKeywordList(), DTypeEnum.ADVANTAGE); List<ReviewToReviewKeyword> selectedReviewDisadvantageKeywordList = createKeywordList(request, request.getDisadvantageKeywordList(), DTypeEnum.DISADVANTAGE); //AnonymousStatus AnonymousStatus status = createAnonymousStatus(request.getReviewBaseDto().getIsAnonymous()); //Review Entity Review review = createReviewEntity(request, member, building, reviewSummary, status); mappingEntities(reviewToReviewCategoryList, reviewSummary, selectedReviewAdvantageKeywordList, selectedReviewDisadvantageKeywordList, review); if (!request.getReviewImageList().isEmpty()) { // ReviewImageList 생성 createAndMapReviewImage(request, review); } return review; }
Java
복사
여기서 createReviewToReviewCategoryList 를 보게 되면 다음과 같습니다
private static List<ReviewToReviewCategory> createReviewToReviewCategoryList(ReviewRequestDto.ReviewCreateDto request) { // ReviewToReviewCategoryList 생성 ArrayList<ReviewCategory> allReviewCategory = (ArrayList) staticReviewCategoryRepository.findAll(); ReviewScoreDto reviewScores = request.getReviewScoreDto(); //parallel stream 써보기 return Arrays.stream(reviewScores.getClass().getDeclaredFields()).filter(field -> ReviewCategoryEnum.contains(field.getName())) .map((field) -> { field.setAccessible(true); try { Double score = (Double) field.get(reviewScores); ArrayList<ReviewCategory> clonedAllReviewCategory = (ArrayList) allReviewCategory.clone(); ReviewCategory targetReviewCategory = clonedAllReviewCategory.stream().filter(reviewCategory -> reviewCategory.getType().equals(ReviewCategoryEnum.valueOf(field.getName().toUpperCase(Locale.ROOT)))).findFirst().orElseThrow(() -> new RuntimeException()); ReviewToReviewCategory temp = ReviewToReviewCategory .builder() .score(score.doubleValue()) .build(); temp.setReviewCategory(targetReviewCategory); return temp; } catch (IllegalAccessException e) { throw new RuntimeException(e); } }).collect(Collectors.toList()); }
Java
복사
여기서 보면 reviewScores를 getReviewScoreDto로 우선 뽑아낸 후, reviewScores.getClass().getDeclaredFields() 로 필드에 접근합니다. 이후 stream API로 각 field를 순회하면서 Enum에 있는 필드만 뽑아낸 후, 이를 ReviewCategory라는 도메인 객체로 바꿉니다. 이후 연결 Entity 에 매핑한 후 return 하는 식으로 Review에 대해서 ReviewToReviewCategory, ReviewCategory를 모두 연결해줍니다.

Result

이를 통해 코드 작성에 편의성을 획득할 수 있었습니다. 왜냐하면 ReviewScoreDto에서 각 필드를 하나씩 꺼내오지 않아도 되기 때문입니다. 예를 들어서 ReviewCategory.set(reviewScoreDto.getTraffic())을 안 해도 되는 것입니다.

Reflection

하지만 코드를 작성하고 보니 마치 바퀴를 다시 만든 느낌이 들었습니다.
찾아보니 MapStruct라는 라이브러리가 있었습니다.
MapStruct는 자바 빈 간의 변환을 지원하는 라이브러리입니다.(링크)
@Mapper public interface CarMapper { @Mapping(target = "manufacturer", source = "make") @Mapping(target = "seatCount", source = "numberOfSeats") CarDto carToCarDto(Car car); @Mapping(target = "fullName", source = "name") PersonDto personToPersonDto(Person person); }
Java
복사
가장 기본적으로는 위에처럼 두개의 dto에 대해서 @Mapping으로 변환할 프로퍼티를 적어주면 이를 변환하는 방식입니다. 조금 더 편한 방식으로는 아래 처럼 interface로 정의하고 static instance를 사용하는 방식입니다.
@Mapper public interface CarMapper { CarMapper INSTANCE = Mappers.getMapper( CarMapper.class ); CarDto carToCarDto(Car car); }
Java
복사
Spring DI 시스템에도 붙을 수 있습니다.
@Mapper(componentModel = MappingConstants.ComponentModel.CDI) public interface CarMapper { CarDto carToCarDto(Car car); }
Java
복사
spring: the generated mapper is a Spring bean and can be retrieved via @Autowired
해당 componentModel에 spring을 붙이면 위 설명처럼 Spring Bean으로 등록된다고 합니다.
아래는 MapStruct 전체 구현의 일부입니다.
public class Mappers { private static final String IMPLEMENTATION_SUFFIX = "Impl"; private Mappers() { } /** * Returns an instance of the given mapper type. * * @param clazz The type of the mapper to return. * @param <T> The type of the mapper to create. * * @return An instance of the given mapper type. */ public static <T> T getMapper(Class<T> clazz) { try { List<ClassLoader> classLoaders = collectClassLoaders( clazz.getClassLoader() ); return getMapper( clazz, classLoaders ); } catch ( ClassNotFoundException | NoSuchMethodException e ) { throw new RuntimeException( e ); } } ... }
Java
복사
여기서 볼 수 있는 것은 classLoader를 일단 가져온 후, getMapper라는 함수를 통해 ~~~Impl이라고 붙은 이름의 구현체를 발견하는 것입니다.
private static <T> T doGetMapper(Class<T> clazz, ClassLoader classLoader) throws NoSuchMethodException { try { @SuppressWarnings( "unchecked" ) Class<T> implementation = (Class<T>) classLoader.loadClass( clazz.getName() + IMPLEMENTATION_SUFFIX ); Constructor<T> constructor = implementation.getDeclaredConstructor(); constructor.setAccessible( true ); return constructor.newInstance(); } catch (ClassNotFoundException e) { return getMapperFromServiceLoader( clazz, classLoader ); } catch ( InstantiationException | InvocationTargetException | IllegalAccessException e) { throw new RuntimeException( e ); } }
Java
복사
실제로 getMapper는 doGetMapper라는 함수를 부르게 되는데, 해당 클래스에서는 적절한 constructor를 찾아 accessible을 true로 해준다음 instance를 반환하게 됩니다.
그럼 저 Impl은 어디서 만들어줄까요?
바로 아래 경로에 있는 MappingProcessor에서 만들어줍니다.
@Override public boolean process(final Set<? extends TypeElement> annotations, final RoundEnvironment roundEnvironment) { // nothing to do in the last round if ( !roundEnvironment.processingOver() ) { RoundContext roundContext = new RoundContext( annotationProcessorContext ); // process any mappers left over from previous rounds Set<TypeElement> deferredMappers = getAndResetDeferredMappers(); processMapperElements( deferredMappers, roundContext ); // get and process any mappers from this round Set<TypeElement> mappers = getMappers( annotations, roundEnvironment ); processMapperElements( mappers, roundContext ); } else if ( !deferredMappers.isEmpty() ) { // If the processing is over and there are deferred mappers it means something wrong occurred and // MapStruct didn't generate implementations for those for ( DeferredMapper deferredMapper : deferredMappers ) { TypeElement deferredMapperElement = deferredMapper.deferredMapperElement; Element erroneousElement = deferredMapper.erroneousElement; String erroneousElementName; if ( erroneousElement instanceof QualifiedNameable ) { erroneousElementName = ( (QualifiedNameable) erroneousElement ).getQualifiedName().toString(); } else { erroneousElementName = erroneousElement != null ? erroneousElement.getSimpleName().toString() : null; } // When running on Java 8 we need to fetch the deferredMapperElement again. // Otherwise the reporting will not work properly deferredMapperElement = annotationProcessorContext.getElementUtils() .getTypeElement( deferredMapperElement.getQualifiedName() ); processingEnv.getMessager() .printMessage( Kind.ERROR, "No implementation was created for " + deferredMapperElement.getSimpleName() + " due to having a problem in the erroneous element " + erroneousElementName + "." + " Hint: this often means that some other annotation processor was supposed to" + " process the erroneous element. You can also enable MapStruct verbose mode by setting" + " -Amapstruct.verbose=true as a compilation argument.", deferredMapperElement ); } } return ANNOTATIONS_CLAIMED_EXCLUSIVELY; }
Java
복사
해당 코드는 AbstractProcessor라는 어노테이션 프로세서를 상속 받음으로써 Annotation 처리를 하는 코드입니다. 핵심 부분은 다음 부분인데요.
// get and process any mappers from this round Set<TypeElement> mappers = getMappers( annotations, roundEnvironment ); processMapperElements( mappers, roundContext );
Java
복사
RoundEnvironment와 현재 annotations를 가지고 Mapper를 만들어내는 것입니다.
구체적인 코드를 생략하고 보게 된다면 getMapper를 통해 어노테이션이 달려있는 그 클래스 자체를 들고 올 수 있는 것 같습니다. 이 때 여러 개의 annotation을 순차적으로 처리해야할 수도 있으므로 annotations라는 리스트를 받고, 순차적으로 처리하는 것으로 보입니다.
이렇게 얻어낸 어노테이션이 달려있는 클래스들을 processMapperElements에 넣고 작업을 이어나가게 되는 건데,
private void processMapperElements(Set<TypeElement> mapperElements, RoundContext roundContext) { for ( TypeElement mapperElement : mapperElements ) { try { // create a new context for each generated mapper in order to have imports of referenced types // correctly managed; // note that this assumes that a new source file is created for each mapper which must not // necessarily be the case, e.g. in case of several mapper interfaces declared as inner types // of one outer interface List<? extends Element> tst = mapperElement.getEnclosedElements(); ProcessorContext context = new DefaultModelElementProcessorContext( processingEnv, options, roundContext, getDeclaredTypesNotToBeImported( mapperElement ), mapperElement ); processMapperTypeElement( context, mapperElement ); } .... } } }
Java
복사
이걸 또 보게 되면 getEnclosedElements라는 함수를 통해서 TypeElement의 모든 메소드와 필드 정보를 가져옵니다. (링크)
이후 ModelElementProcessorContext를 생성해서 processMapperTypeElement에 넣게 되는데, 파일을 살펴보면 ModelElementProcessContext는 각 processor를 돌면서 처리할 때, 정보를 넘겨주는 context인 거 같습니다. processMapperTypeElementd에서는 각 processor 별로 process 함수를 호출하면서 적절한 process를 호출해주게 되는데요.
private void processMapperTypeElement(ProcessorContext context, TypeElement mapperTypeElement) { Object model = null; for ( ModelElementProcessor<?, ?> processor : getProcessors() ) { try { model = process( context, processor, mapperTypeElement, model ); } catch ( AnnotationProcessingException e ) { processingEnv.getMessager() .printMessage( Kind.ERROR, e.getMessage(), e.getElement(), e.getAnnotationMirror(), e.getAnnotationValue() ); break; } } } private <P, R> R process(ProcessorContext context, ModelElementProcessor<P, R> processor, TypeElement mapperTypeElement, Object modelElement) { @SuppressWarnings("unchecked") P sourceElement = (P) modelElement; return processor.process( context, mapperTypeElement, sourceElement ); }
Java
복사
각 프로세서는 아래처럼 여러 파일이 종류별로 있습니다.
아무래도 MapStruct를 사용하는 방법이 여러개이다 보니 그에 맞는 processor를 선택해서 구현을 하는 것 같습니다. 가장 기본적인 Annotation 기반 구현부터 Injection을 처리하는 Processor도 있는 것으로 보이고, MapperRenderingProcessor는 실제로 File을 만드는 Processor 이며 Mapper에 관한 정보를 모든 담는 mapperCreationProcessor도 있습니다.
또한 해당 processor가 도는 순서는 각 process 내부에 getPriority라는 함수가 있는데, 이를 comparator로 삼아서 비교합니다.
예를 들어 MapperRenderingProcessor는 맨 마지막에 놓이기 위해 9999를 반환하고 MethodRetrieverProcessor 라는 이름으로 필요한 Method를 만드는 processor는 1을 반환합니다.
뿐만 아니라 mapstruct는 자체적으로 Class를 만들기 위해 필요한 요소들을 구현하고 있는데 이는 모두 ModelElement라는 abstract class를 구현합니다.
/* * Copyright MapStruct Authors. * * Licensed under the Apache License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 */ package org.mapstruct.ap.internal.model.common; import java.util.Set; import org.mapstruct.ap.internal.writer.FreeMarkerWritable; import org.mapstruct.ap.internal.writer.Writable; /** * Base class of all model elements. Implements the {@link Writable} contract to write model elements into source code * files. * * @author Gunnar Morling */ public abstract class ModelElement extends FreeMarkerWritable { /** * Returns a set containing those {@link Type}s referenced by this model element for which an import statement needs * to be declared. * * @return A set with type referenced by this model element. Must not be {@code null}. */ public abstract Set<Type> getImportTypes(); }
Java
복사
이미 있는 타입들과 만들어낸 타입들을 구분해서 관리하기 위해 GeneratedType을 따로 정의하기도 합니다.
/* * Copyright MapStruct Authors. * * Licensed under the Apache License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 */ package org.mapstruct.ap.internal.model; import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.SortedSet; import java.util.TreeSet; import javax.lang.model.type.TypeKind; import org.mapstruct.ap.internal.model.common.Accessibility; import org.mapstruct.ap.internal.model.common.ModelElement; import org.mapstruct.ap.internal.model.common.Type; import org.mapstruct.ap.internal.model.common.TypeFactory; import org.mapstruct.ap.internal.option.Options; import org.mapstruct.ap.internal.util.ElementUtils; import org.mapstruct.ap.internal.util.Strings; import org.mapstruct.ap.internal.version.VersionInformation; /** * A type generated by MapStruct, e.g. representing a mapper type. * * @author Gunnar Morling */ public abstract class GeneratedType extends ModelElement { private static final String JAVA_LANG_PACKAGE = "java.lang"; protected abstract static class GeneratedTypeBuilder<T extends GeneratedTypeBuilder> { private T myself; protected TypeFactory typeFactory; protected ElementUtils elementUtils; protected Options options; protected VersionInformation versionInformation; protected SortedSet<Type> extraImportedTypes; protected List<MappingMethod> methods; GeneratedTypeBuilder(Class<T> selfType) { myself = selfType.cast( this ); } // ... // } private final String packageName; private final String name; private final Type mapperDefinitionType; private final List<Annotation> annotations; private final List<GeneratedTypeMethod> methods; private final SortedSet<Type> extraImportedTypes; private final boolean suppressGeneratorTimestamp; private final boolean suppressGeneratorVersionComment; private final VersionInformation versionInformation; private final Accessibility accessibility; private List<Field> fields; private Constructor constructor; /** * Type representing the {@code @Generated} annotation */ private final Type generatedType; private final boolean generatedTypeAvailable; // CHECKSTYLE:OFF protected GeneratedType(TypeFactory typeFactory, String packageName, String name, Type mapperDefinitionType, List<MappingMethod> methods, List<Field> fields, Options options, VersionInformation versionInformation, boolean suppressGeneratorTimestamp, Accessibility accessibility, SortedSet<Type> extraImportedTypes, Constructor constructor) { this.packageName = packageName; this.name = name; this.mapperDefinitionType = mapperDefinitionType; this.extraImportedTypes = extraImportedTypes; this.annotations = new ArrayList<>(); this.methods = new ArrayList<>(methods); this.fields = fields; this.suppressGeneratorTimestamp = suppressGeneratorTimestamp; this.suppressGeneratorVersionComment = options.isSuppressGeneratorVersionComment(); this.versionInformation = versionInformation; this.accessibility = accessibility; if ( versionInformation.isSourceVersionAtLeast9() && typeFactory.isTypeAvailable( "javax.annotation.processing.Generated" ) ) { this.generatedType = typeFactory.getType( "javax.annotation.processing.Generated" ); this.generatedTypeAvailable = true; } else if ( typeFactory.isTypeAvailable( "javax.annotation.Generated" ) ) { this.generatedType = typeFactory.getType( "javax.annotation.Generated" ); this.generatedTypeAvailable = true; } else { this.generatedType = null; this.generatedTypeAvailable = false; } this.constructor = constructor; } // getter, setter 생략 }
Java
복사
아래는 ModelElement를 상속한 다른 타입들입니다.
따라서 전반적인 구조를 요약해보자면, 아래처럼 sequence Diagram을 그려볼 수 있을 것 같습니다.