본문 바로가기
이슈와해결

28개(+α) 클래스를 검증해야 한다면? - 커스텀 애노테이션을 사용한 Payload 검증 방식 도전기

by Renechoi 2023. 12. 11.

0. 목차

  1. 개요
  2. 문제점
  3. 개선 1 - 필수 여부, 스트링 길이
  4. 개선 2 - Enum 타입
  5. 더 완벽한 캡슐화를 위하여
  6. What remains...
  7. 결론

1. 개요

본 글은 사내 업무 효율성 향상을 목적으로 펌웨어 검증 과정을 자동화하기 위한 프로그램 개발 경험을 공유합니다. 특히, Payload 객체의 구현과 검증 로직 개발 과정에서 발생한 다양한 문제들과 이에 대한 해결 방안을 중심으로 논의합니다. 이 과정에서 여러 Payload 클래스들을 효과적으로 검증하는 방법에 대한 고민과 그 해결책을 제시하고자 합니다.

 

  • 예시 코드는 실제 코드가 아닌 컨셉 코드로 대체하였습니다.

 

2. 문제점

먼저 28개의 클래스 중 하나인 ChangeSomethingPayload 클래스를 예시를 살펴보겠습니다.

 

해당 클래스는 Payload 클래스입니다. 요청과 응답을 규정하고 있는 클래스로서 필드는 OCPP1.6 프로토콜에서 정의한 내용을 포함하고 있습니다. 요청 페이로드는 key와 value 두 개의 필드를 가지고 있고, 두 필드 모두 필수값이면서 문자열 길이 50 이내라는 제한 사항을 갖고 있습니다. 한편 응답 페이로드는 status라는 하나의 필드를 가지고 있습니다. status는 설정 변경 요청에 대한 응답 상태를 나타내며, SomethingStatus라는 Enum 클래스의 값 중 하나여야 힙니다.

 

위와 같은 제약 조건에 대해 애노테이션을 사용하지 않고 검증 로직을 개별 클래스 내에 구현하는 경우 다음과 같은 코드를 작성할 수 있을 것입니다.

 

public class ChangeSomethingPayload extends Payload{
    private String key;
    private String value;

    @Override
    public boolean isValid() {
        return keyIsValid() && valueIsValid();
    }

    private boolean keyIsValid() {
        return key != null && !key.trim().isEmpty() && key.length() <= 50;
    }

    private boolean valueIsValid() {
        return value != null && !value.trim().isEmpty() && value.length() <= 50;
    }


    public static class Response extends Payload {
        private String status;

        @Override
        public boolean isValid() {
            return statusIsValid();
        }

        private boolean statusIsValid() {
            return status != null && SomethingStatus.contains(status);
        }
    }
}

 

SomethingStatus Enum 은 다음과 같이 정의합니다.

 

public enum SomethingStatus {

    ACCEPTED("Accepted"),  
    REJECTED("Rejected"),
    ... 
    ;

    private final String value;

    public static boolean contains(String request){
        return Arrays.stream(values()).anyMatch(status -> status.getValue().equals(request.trim()));
    }
}

 

이렇게 구현할 경우 무엇이 문제일지 생각해 봅시다. 만약 Payload 클래스가 이것 하나라면 크게 문제될 일이 없을 것 같습니다. 그런데 이와 같은 Payload가 28개나 존재하며, 내부에 가진 클래스까지 더하면 그보다 훨씬 많은 수의 클래스가 존재한 상황입니다. 각각의 Payload는 비슷한 제약 조건, 즉 필수여부, 문자열 제한, Enum 타입 필드 등을 갖고 있기 때문에 중복되는 로직을 매번 복사 붙여넣기 해야하는 문제가 생깁니다.

 

결과적으로 각 필드의 유효성을 검사하는 로직이 클래스 내에 흩어져 있어 코드의 가독성이 떨어지고, 중복된 코드가 발생합니다. 만에 하나 필드의 유효성을 검사하는 조건이 변경된다면 어떨까요? 해당 필드를 검증하는 모든 코드를 찾아서 수정해야 합니다.

 

이러한 문제점을 애노테이션을 사용한 검증 방식을 사용해 하나 하나 수정해보겠습니다.

 

3. 개선 1 - 필수 여부, 스트링 길이

먼저 필수 여부와 스트링 길이를 애노테이션 방식으로 변경해 봅니다. 다음과 같은 애노테이션 설정 클래스를 작성할 수 있을 것입니다.

 

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Required {}
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface String50 {
}

 

@Required 애노테이션은 해당 필드가 필수적으로 값이 있어야 함을 나타냅니다. 이 애노테이션이 붙은 필드에는 반드시 값이 있어야 한다.

 

@String50 애노테이션은 해당 필드의 문자열 길이가 50 이하임을 나타냅니다. 이 애노테이션이 붙은 필드는 반드시 문자열 길이가 50 이하이어야 합니다.

 

이제 이 애노테이션을 사용하여 ChangeSomethingPayload 클래스를 리팩토링 해보면 다음과 같이 변경됩니다.

 

public class ChangeSomethingPayload extends Payload{
    @Required
    @String50
    private String key;

    @Required
    @String50
    private String value;

    @Override
    public boolean isValid() {
        return satisfyRequired() && satisfyStringLimit();
    }


    public static class Response extends Payload {
        @Required
        @EnumValidation(enumClass = SomethingStatus.class)
        private String status;

        @Override
        public boolean isValid() {
            return satisfyRequired() && satisfyEnumValidation();
        }
    }
}

 

keyvalue 필드에 @Required@String50 애노테이션을 붙임으로써 이 필드들이 반드시 값이 있어야 하며, 그 길이가 50을 넘지 않아야 함을 명시적으로 나타낼 수 있게 되었습니다.

 

검증 로직은 Payload 추상 클래스의 satisfyRequired()satisfyStringLimit() 메소드에 정의되어 있습니다. 이 메소드들은 해당 클래스의 필드 중에서 각 애노테이션이 붙은 필드를 찾아 해당 조건을 만족하는지를 확인합니다.

 

추상 클래스 선언부를 보면 다음과 같습니다.

 

    protected boolean satisfyRequired() {
        return Arrays.stream(this.getClass().getDeclaredFields())
                .filter(field -> field.isAnnotationPresent(Required.class))
                .allMatch(field -> {
                    try {
                        field.setAccessible(true);
                        Object value = field.get(this);
                        return value != null && !(value instanceof String && ((String) value).isEmpty());
                    } catch (IllegalAccessException e) {
                        throw new RuntimeException(e);
                    }
                });
    }

 

Reflection을 이용해 주어진 포착된 필드들에 대한 검증 로직을 수행합니다.

 

    protected boolean satisfyStringLimit50() {
        return Arrays.stream(this.getClass().getDeclaredFields())
                .filter(field -> field.isAnnotationPresent(String50.class))
                .allMatch(field -> {
                    try {
                        field.setAccessible(true);
                        Object value = field.get(this);
                        return  value == null || ((String) field.get(this)).length() <=50;
                    } catch (IllegalAccessException e) {
                        throw new RuntimeException(e);
                    }
                });
    }

 

Payload 클래스의 satisfyRequired()satisfyStringLimit50() 메서드는 JavaReflection 기능을 활용하여 각 필드의 애노테이션을 검사하고 필요한 조건을 만족하는지를 확인합니다.

 

satisfyRequired 메서드는 @Required 애노테이션이 붙은 경우 해당 필드의 값이 null 이 아니고 문자열인 경우에는 비어있지 않아야 함을 확인합니다.

 

이때 클래스의 선언된 모든 필드를 먼저 가져온 후 @Required 애노테이션이 존재하는 필드를 필터링 하는 방식으로 가져옵니다.

 

이러한 방식으로 OCPP1.6 프로토콜에서 정의한 다양한 기본 검증을 같은 로직으로 수행할 수 있게 됩니다. 즉, String5020, 25, 500 등에 대한 제한도 같은 방식으로 검증할 수 있습니다.

 

이로써 코드의 중복을 줄이고 유지 관리를 매우매우매우매우x100 편하게 할 수 있게 되었습니다 !

 

👋🏻👋🏻👋🏻

 

다만, satisfy 메서드를 구현하는 부분에서 코드 길이를 줄여보고자 했지만 더 줄이기가 힘들었습니다. Reflection 기능을 사용하다 보니 private 메서드에 대한 접근을 허용하게 해주어야 하는 부분이나, try-catch로 잡아야 하는 부분에서 가독성을 해치는 코드들이 들어갔습니다.

 

4. 개선 2 - Enum 타입

이제 보다 Enum 타입에 대한 검증도 애노테이션 방식으로 변경해보겠습니다. 다음과 같은 애노테이션을 작성합니다.

 

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface EnumValidation {
    Class<? extends Enum<?>> enumClass();
}

 

@EnumValidation 애노테이션은 필드에 적용되며, enumClass라는 속성을 가지고 있습니다. 이 속성은 해당 필드가 어떤 Enum 클래스의 값 중 하나임을 나타냅니다. 이 애노테이션이 붙은 필드는 반드시 해당 Enum 클래스의 값 중 하나여야 합니다. 예를 들어, status 필드의 값은 SomethingStatus Enum 클래스의 값 중 하나인 ACCEPTED("Accepted"), REJECTED("Rejected"), ... 중 하나여야 함을 나타냅니다.

 

Payload 추상 클래스의 satisfyEnumValidation() 메소드는 이 애노테이션을 검사하고 해당 필드의 값이 애노테이션의 enumClass 속성에서 지정한 Enum 클래스의 값 중 하나인지를 확인합니다. 구현 메서드는 다음과 같습니다.

 

    protected boolean satisfyEnumValidation() {
        return Arrays.stream(this.getClass().getDeclaredFields())
                .filter(this::hasEnumValidationAnnotation)
                .allMatch(this::isValueInEnum);
    }

    private boolean hasEnumValidationAnnotation(Field field) {
        return field.isAnnotationPresent(EnumValidation.class);
    }

    private boolean isValueInEnum(Field field) {
        try {
            field.setAccessible(true);
            Object value = field.get(this);
            if (value == null) {
                return true;
            }
            return isValueInEnumConstants(field, value);
        } catch (IllegalAccessException e) {
            throw new RuntimeException(e);
        }
    }

    private boolean isValueInEnumConstants(Field field, Object value) {
        Class<? extends Enum<?>> enumClass = field.getAnnotation(EnumValidation.class).enumClass();
        return Stream.of(enumClass.getEnumConstants())
                .map(Enum::name)
                .anyMatch(enumName -> enumName.equals(value));
    }

 

마찬가지로 Reflection을 통해 각 필드를 가져온 후,@EnumValidation 애노테이션이 존재하는 필드를 필터링하여 포착합니다. 이후 필드의 값과 Enum 클래스를 비교하여 필드 값이 Enum 에 정의된 상수 중 하나인지를 확인합니다.

 

이때 주의할 점은 와일드타입의 Enum 으로 가져왔기 때문에 Runtime 시점에 동적으로 해당 Enum이 어떤 Enum인지 알지만 컴파일 시점에는 알 수 없으므로 해당 Enum에서 정의한 메서드를 미리 사용할 수 없다는 점입니다. 그래서 다음과 같은 상수의 value 값을 가져오지 못합니다.

 

  AVAILABLE("Available"),
  PREPARING("Preparing"),
  CHARGING("Charging"),
  ...

 

그렇게 되면

                .map(Enum::name)

 

부분에서 제대로 검증하지 못하는 문제가 발생하는데 이유는 채워진 필드는 CamelCase이지만 Enumname은 자바 컨벤션에 따라 대문자 + snake 케이스로 되어 있기 때문입니다. 이를 해결하는 방법은 두 가지가 있을 텐데 첫 번째는 다음과 같이 직접 변환을 해주는 것입니다.

 

                .map(constant -> ((HasValue) constant).getValue())
               .map(Enum::name)
               .map(Payload::convertConstantToCamelCase)
                .anyMatch(enumName -> enumName.equals(value));

   private static String convertConstantToCamelCase(String s) {
        String[] words = s.split("_");
        return Arrays.stream(words).map(word -> capitalize(word.toLowerCase()))
                .collect(Collectors.joining());
    }

    private static String capitalize(String s) {
        return Character.toUpperCase(s.charAt(0)) + s.substring(1);
    }

 

그런데 이렇게 하면 또 다른 문제가 언젠가 발생할 수 있는데 바로 다음과 같은 케이스들이 있기 때문입니다.

 

  SUSPENDED_EVSE("SuspendedEVSE"),
  EV_DISCONNECTED("EVDisconnected"),

 

즉, 고유명사인 단어들은 컨벤션의 예외로서 존재하기 때문에 이와 같은 필드들에 대해서는 검증기는 false를 리턴하게 됩니다.

 

그렇다면 이것들을 다음과 같이 각각 정의해두고 예외 적용을 해주면 되지 않을까요?

 

private static String specialCapitalize(String s) {
    List<String> specialCases = Arrays.asList("ev", "evse");

    if (specialCases.contains(s)) {
        return s.toUpperCase();
    }

       return Character.toUpperCase(s.charAt(0)) + s.substring(1);
}

 

그럴 수도 있겠지만 이와 같은 하드코딩은 유지보수성을 저해할 것 같습니다. 언제 또 무엇이 추가될 줄 알며, 이 단어들만 관리하기도 번거롭습니다.

 

따라서 차라리 다음과 같이 Enum의 추상성을 한단계 더 높여서 공통된 getter 기능을 해주는 인터페이스를 상위에서 선언하고 이 타입으로 캐스팅을 하도록 하는 방법을 선택했습니다.

 

이렇게 할 경우 캐스팅 전에 타입 체크를 해주는 것이 안전할 것이지만,

if (!HasValue.class.isAssignableFrom(enumClass)) {
        throw new IllegalArgumentException("Enum must implement HasValue interface");
    }

 

어차피 예외를 던져도 그 예외를 잡아주는 것을 또 구현해주어야 할 것이고 현재 Payload에서 사용되는 Enum은 전부 PayloadType에 불과하므로 타입 안정성 체크 없이 바로 캐스팅을 했습니다.

 

    private boolean isValueInEnumConstants(Field field, String value) {
        Class<? extends Enum<?>> enumClass = field.getAnnotation(EnumValidation.class).enumClass();
        return Stream.of(enumClass.getEnumConstants())
                .map(constant -> ((PayloadType) constant).getValue())
                .anyMatch(enumName -> enumName.equals(value));
    }

 

이제 이 애노테이션을 사용하여 리팩토링해보면 다음과 같습니다.

 

    public static class Response extends Payload {
        @Required
        @EnumValidation(enumClass = SomethingStatus.class)
        private String status;


        @Override
        public boolean isValid() {
            return satisfyRequired() && satisfyEnumValidation();
        }

    }

 

이렇게 애노테이션을 사용하여 필드의 유효성 검사를 진행하면 각 클래스마다 동일한 유효성 검사 로직을 중복해서 작성하지 않아도 됩니다.

 

이것만으로도 벌써 엄청난 노가다 비용을 절감할 수 있었습니다.

 

5. 더 완벽한 캡슐화를 위하여

 

그래서 기존 검증 메서드를 전부 애노테이션으로 치환한 전체 클래스의 모습은 다음과 같습니다. .

 

public class ChangeSomethingPayload extends Payload{
    @Required
    @String50
    private String key;

    @Required
    @String50
    private String value;

    @Override
    public boolean isValid() {
        return satisfyRequired() && satisfyStringLimit50();
    }

    public static class Response extends Payload {
        @Required
        @EnumValidation(enumClass = SomethingStatus.class)
        private String status;


        @Override
        public boolean isValid() {
            return satisfyRequired() && satisfyEnumValidation();
        }

    }
}

 

기존에 덕지덕지 붙던 메서드들은 사라지고 추상클래스에서 정의한 isValid() 메서드만 갖게 되었습니다 !

 

그런데...

 

isValid() 메서드를 가만히 보아하니, 결국 애노테이션에서 붙은 내용을 검증해주는 로직입니다.

 

그렇다면 이것 역시 개별 클래스들에서 굳이 구현해주어야 할 필요가 있을까요? 이것 역시 추상클래스로 정의해버린다면... 정말 깔끔하게 애노테이션 선언 만으로 검증 로직을 완성할 수 있지 않을까요?

 

즉, Payload 클래스의 isValid() 메서드를 더 이상 abstract로 선언하지 않고 구체 클래스로 선언해버리는 것입니다.

 

public abstract class Payload {
    //...

    public boolean isValid() {
        return satisfyRequired() && satisfyStringLimit50() && satisfyEnumValidation();
    }
}

 

이제 더 이상 구체 클래스들은 isValid() 메서드를 구현하지 않아도 되게 되었습니다.

 

public class ChangeSomethingPayload extends Payload{
    @Required
    @String50
    private String key;

    @Required
    @String50
    private String value;

    public static class Response extends Payload {
        @Required
        @EnumValidation(enumClass = SomethingStatus.class)
        private String status;
    }
}

 

각각의 Payload는 이제 눈으로 보이기에 그냥 순수한 Payload가 되었습니다.

 

검증에 관한 모든 내용을 추상 클래스 내부로 숨겨버렸습니다.

 

6. What remains...

모든 게 좋아졌습니다. 그런데 한가지 걸리는 게 있습니다.

 

어떤 PayloadRequired, String 제한, Enum 제한 중 한 가지 혹은 두 가지만 수행해도 됩니다. 즉, isValid 메서드를 호출 할 때 굳이 필요 없는 검증에 대한 메서드도 호출이 된다는 것입니다. 이것은 비용 낭비로 이어지는데, 각각의 satisfy 메서드는 메서드를 시작하면서 먼저 애노테이션 필드를 순회하기 때문입니다.

 

    protected boolean satisfyRequired() {
        return Arrays.stream(this.getClass().getDeclaredFields())
                .filter(field -> field.isAnnotationPresent(Required.class))
                //... 
                });

 

이처럼 필요하지 않은 검증 로직에 대해서도 필드를 순회하면서 해당 애노테이션이 존재하는지 확인하는 작업이 이루어집니다. 이런 점에서 이 방법이 소비하는 리소스가 너무 크다는 생각이 들게 됩니다.

 

하지만 실제로 이 비용이 얼마나 클까?를 다시 생각해 보았습니다. 대부분의 경우, 검증해야 하는 필드의 수는 그리 많지 않다는 점에서 필드 순회 작업으로 인한 부하가 실질적으로 크지 않을 것입니다. 만약 정말로 비용 문제가 발생한다면 애노테이션 정보를 캐싱하는 등의 방법을 통한 최적화도 시도해볼 수 있을 것입니다.

 

또 한가지 이슈는 리플렉션 기능을 사용하면서 발생할 수 있는 성능 저하 문제입니다. 이점에 대해서는 어떨까요?

 

리플렉션은 실행 중인 Java 프로그램 내부를 조사하고 조작할 수 있어 강력한 기능을 제공하지만, JVM이 동적으로 타입을 체크하고 제약 사항을 검증하는 과정에서 오버헤드를 줄 수 있고, 추가 정보를 메모리에 유지해야 하기 때문에 메모리 사용량을 증가시킬 수 있습니다.

그럼에도 괜찮다고 결론을 내렸습니다. 각 필드의 수는 평균 5개 미만, 클래스 역시 50개 내외이므로 전체를 순회한다고 해도 큰 비용 문제는 발생하지 않을 것이라 판단한 것입니다.

 

7. 결론

이 글에서는 28개의 클래스에 대한 유효성 검사 로직을 어떻게 효율적으로 구현할 수 있는지를 알아보았습니다. 애노테이션과 리플렉션을 활용함으로써, 각 클래스마다 유효성 검사 로직을 중복해서 작성할 필요 없이, 필드에 대한 요구사항만을 애노테이션으로 선언함으로써 검증 로직을 간결하게 만들 수 있었습니다.

 

코드의 중복을 줄이고 가독성을 향상시키는 것의 이점이 잘 발휘될 수 있는 케이스가 아닐까 합니다. 개발자의 실수를 줄여줄 수 있기 때문입니다. OCPP1.6 프로토콜 한페이지 한페이지를 읽어가면서 반복되는 작업으로서 각 Payload를 구현하는 과정에서 검증 로직 마저 하나 하나씩 구현해주어야 한다면... 쓰면서도 현타가 오겠고, 다 써놓고도 내 코드를 내가 신뢰하지 못할 것 같습니다.

 

마지막으로 스프링에서 제공하는 애노테이션이 있음에도 굳이 커스텀 애노테이션을 사용한 이유에 대해 이야기하고 마치겠습니다.

 

간단히 말해서 스프링의 Bean Validation은 간단한 필드 검증에 있어서 대부분의 경우 충분하지만, 이 경우에 충분하지 못했기 때문입니다. OCPP1.6 프로토콜에서 요구하는 다양한 검증 사항을 정확하고 명확하게 만족시키기 위해 커스텀 애노테이션을 도입했습니다. 예를 들어 Spring 애노테이션의 @Size(max=50)은 이 필드의 문자열 길이가 50을 넘지 않아야 한다는 제약을 동일하게 표현할 수 있겠지만, @String50은 보다 직관적으로 그 이름 자체에서 OCPP1.6 컨벤션을 지키면서 명확하게 제약 사항을 나타냅니다. 또한 직접 구현한 애노테이션은 추후 프로토콜의 버전이 변경 혹은 비즈니스의 필요성에 따른 다른 제약 요건 발생시 등 유연하게 사용할 수 있을 것입니다.

 

 


 

반응형