배경

회사에서 100개가 넘는 컬럼들을 다루어야 하는 경우가 있었다.

100개가 넘는 컬럼들 중 섹션별로 일부의 데이터를 추가하거나 수정할 수 있어야 했고, 기존의 데이터와 서로 다른지 판단할 수 있어야 하며 이를 로그로도 남겨야 하는 요구사항이 있었다.

 

예를 들면 Entity 가 아래와 같은데, @ExampleSection 이라는 Annotation 으로 섹션을 분리한 뒤 사용자가 원하는 Section 별로 데이터를 저장하거나 수정할 수 있어야 하는 것이다.

@Entity(name = "exampleEntity")
@Table(name = "EXAMPLE_ENTITY", schema = "GREATSCHEMA")
@DynamicInsert @DynamicUpdate
@Builder
@Getter
@NoArgsConstructor @AllArgsConstructor
public class ExampleEntity {
    @Id
    @Column(name = "seqno")
    @ExampleSection(description = "고유 번호", section = ExampleSection.COMMON)
    private Long seqNo;

    @Column(name = "price")
    @ExampleSection(description = "가격", section = ExampleSection.BASIC)
    private BigDecimal idnFlag;

    @Column(name = "len")
    @ExampleSection(description = "길이", section = ExampleSection.BASIC)
    private Integer len;

    @Column(name = "some_date")
    @ExampleSection(description = "날짜", section = ExampleSection.LIFECYCLE)
    private LocalDateTime someDate;
    
    @Column(name = "some_custom_flag")
    @ExampleSection(description = "무언가", section = ExampleSection.LIFECYCLE)
    private YesNoFlag someCustomFlag;
}

 

이런 경우, 당시 Reflection 을 사용하는 것이 효과적일 것이라고 생각했었다. 컴파일 시점에 오류를 확인할 수 없는 문제가 있지만, 이런 많은 데이터들을 직접 매핑하고 로그를 남기는 메서드를 일일히 나열하는 것보다는 수 많은 중복 코드를 줄이고 시간을 절약하는 것이 더 나을 거라고 생각했다.

게다가 마이그레이션 작업이었기 때문에 기존 DB 에서 사용되는 Flag 값들이 전부 한 글자였고 이를 객체지향적으로 프로그래밍하기 위해 Enum 작성 후 이름은 길게 풀어서 쓰되 대신 필드를 하나 주어서 따로 값으로 사용했다. 예를 들면 아래와 같이 말이다.

@Getter
public enum YesNoFlag implements LegacyEnum {
    YES("Y"),
    NO("N");

    private final String value;

    YesNoFlag(String value) {
        this.value = value;
    }
}

위 코드는 예/아니오로 예시를 들었지만 실제 이런 경우는 boolean 으로 취급하는 것이 더 낫다고 생각한다. 그냥 예시다.

 

여담으로 이를 JPA 와 연동해서 사용하기 위해 Converter 까지 같이 만들었다. 해당 글을 참조했다.

이렇게 해서 인터페이스로 LegacyEnum 을 가져올 수 있도록 Converter 를 추가했다.

 

값을 추가하거나 수정할 때는 Map 으로 적되 Entity 필드의 이름을 Key 로, 값은 문자열 또는 숫자 리터럴로 적어 요청하도록 했다. 그리고 로직에서는 해당 객체의 field 이름을 참조해서 직접 데이터 값을 바꾸는 형태로 진행되도록 했었다.

dto 클래스를 만들어서 Entity 와 분리하는 것도 좋아보였지만 내부 컬럼이 바뀔 때마다 하나같이 전부 바꾸어야 할 것 같아 위와 같이 진행했다.

{
    "section": "BASIC",
    "data": {
        "someData": "A",
        "someData2": 39
    }
}

 

문제

이렇게 하다보니 값을 변경할 객체의 데이터 타입을 참조해서 값을 넣어야 했고 아래처럼 클래스를 만들게 되었다.

public static void setStringValueToField(final Object entity, final Field field, final Object dataValue) throws IllegalAccessException {
    if (dataValue == null || dataValue.toString().isBlank())
        field.set(entity, null);
    else if (field.getType().isAssignableFrom(Long.class)) {
        field.set(entity, Long.valueOf(dataValue.toString()));
    }
    else if (field.getType().isAssignableFrom(Integer.class)) {
        field.set(entity, Integer.valueOf(dataValue.toString()));
    }
    else if (field.getType().isAssignableFrom(String.class)) {
        field.set(entity, dataValue);
    }
    ...
}

 

그런데 위 코드는 일일히 타입별로 대응을 해주어야 했고, custom 한 타입이 있을 경우 추가적으로 대응을 해주어야 하는 문제가 있었다.

예를 들어, 아까 위의 Enum 의 경우 Enum 이름 그대로가 아닌 내부 특정 값으로 사용하는데 아래와 같이 사용할 수도 있는 것이다.

else if (field.getType().isEnum() && field.getType().isAssignableFrom(LegacyEnum.class)) {
    field.set(entity, LegacyEnumConverter.ofValue(LegacyEnum.class, dataValue.toString()));
    //field.set(entity, LegacyEnumConverter.ofValue(((Class)((ParameterizedType) field.getType().getGenericSuperclass()).getActualTypeArguments()[0]), dataValue.toString()));
}

 

굳이 이래야 하나 싶어서 생각해보다가 Spring MVC 에서 타입을 자동으로 conversion 하는 것을 떠올렸다.

Spring MVC 에서는 ArgumentResolver 를 통해 들어온 요청을 적절한 타입으로 변환해주고 있는데(AbstractNamedValueMethodArgumentResolver) 이것으로 대신 conversion 하면 어떨까 싶었다.

 

Spring 에서는 위 역할을 ConversionService 라는 곳에서 담당하고 있고 보통 우리가 요청 시 Conversion 하는 곳은 GenericConversionService 에서 진행된다.

 

그리고 이 GenericConversionService 는 Spring Boot 로드 시에 WebMvcConfigurationSupport 에서 Bean 으로 등록되는데, 아래의 FormattingConversionService 는 GenericConversionService 을 상속한 클래스이다. 따라서 해당 Bean 을 가져오면 Spring MVC 에 등록한 Converter 를 사용할 수 있다.

즉, 아주 간단하게 아래와 같이 사용할 수 있다.

@Component
@RequiredArgsConstructor
public class SampleServiceImpl {
    private final ConversionService conversionService;
    
    public void test() {
        String givenText = "Y";
        YesNoFlag converted = conversionService.convert(givenText, YesNoFlag.class);
    }
}

 

해결

Reflection 을 사용할 때 아래와 같이 활용하여 진행했다.

public ExampleEntity modifyEntityField(Map<String, Object> params) throws IllegalAccessException {
    ExampleEntity exampleEntity;
    for (Field field: ruleRawInfo.getClass().getDeclaredFields()) {
        field.setAccessible(true);

        Object paramValue = params.get(field.getName());
        field.set(ruleRawInfo, conversionService.convert(paramValue, field.getType()));
    }
}

 

References