0. 목차
- 개요
- 문제점
- 개선 1 - 필수 여부, 스트링 길이
- 개선 2 - Enum 타입
- 더 완벽한 캡슐화를 위하여
- What remains...
- 결론
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();
}
}
}
key
와 value
필드에 @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()
메서드는 Java
의 Reflection
기능을 활용하여 각 필드의 애노테이션을 검사하고 필요한 조건을 만족하는지를 확인합니다.
satisfyRequired
메서드는 @Required
애노테이션이 붙은 경우 해당 필드의 값이 null
이 아니고 문자열인 경우에는 비어있지 않아야 함을 확인합니다.
이때 클래스의 선언된 모든 필드를 먼저 가져온 후 @Required
애노테이션이 존재하는 필드를 필터링 하는 방식으로 가져옵니다.
이러한 방식으로 OCPP1.6
프로토콜에서 정의한 다양한 기본 검증을 같은 로직으로 수행할 수 있게 됩니다. 즉, String50
외 20, 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
이지만 Enum
의 name
은 자바 컨벤션에 따라 대문자 + 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...
모든 게 좋아졌습니다. 그런데 한가지 걸리는 게 있습니다.
어떤 Payload
는 Required
, 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
컨벤션을 지키면서 명확하게 제약 사항을 나타냅니다. 또한 직접 구현한 애노테이션은 추후 프로토콜의 버전이 변경 혹은 비즈니스의 필요성에 따른 다른 제약 요건 발생시 등 유연하게 사용할 수 있을 것입니다.
'이슈와해결' 카테고리의 다른 글
리팩토링 회고 - 스프링 Bean 주입을 활용해 Validator 확장성 개선하기 (0) | 2023.12.11 |
---|---|
리팩토링 회고 - 상태 패턴을 이용해서 복잡한 비즈니스 시나리오 검증 로직을 개선...! (1) | 2023.12.11 |
다수의 Validators 역할 위임 방식 회고 - Chain 패턴과 Optional을 이용한 우아한 플로우 탐색기 (0) | 2023.11.16 |
도메인 주도 개발 방법론(DDD)을 적용하여 3티어 아키텍처를 변경해보자 (1) | 2023.06.26 |
GetMapping시 URI에 PK 식별자가 노출되는 문제: 대체키를 사용한 해결 (0) | 2023.06.24 |