본문 바로가기
이슈와해결

고도화 회고 - 유효성 검증 로직에서 최소한의 변화로 리턴 타입 변경하기

by Renechoi 2023. 12. 12.

0. 목차

  1. 개요
  2. 문제점
  3. 첫 시도
  4. 첫 시도의 문제점
  5. 두 번째 시도
  6. 두 번째 시도의 문제점
  7. 최종 해결 방식
  8. 결론

1. 개요

이번 글에서는 유효성 검증 로직에서 변화를 최소화하면서 고도화하는 내용을 다룹니다. 기존 boolean 값만을 리턴하는 로직을 변경해 실패 상세 detail 메시지를 전달하는 것이 목표였습니다. 이를 달성하는 과정에서 시도한 여러가지 방법과 최종적으로 채택한 방식에 대해 소개합니다.

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

 

2. 문제점

기존 시스템에서는 다음과 같이 boolean 값을 체크하고 이에 대한 결과를 리턴했습니다.

 

public abstract class Payload {

    public boolean isInValid() {
        return !isValid();
    }


    public boolean isValid() {
        return satisfyRequired() && satisfyStringLimit() &&satisfyStringsLimit50() && satisfyEnumValidation() && allFieldsSatisfy(this::fieldIsSatisfactory);
    }

    protected boolean satisfyRequired() {
        return Arrays.stream(this.getClass().getDeclaredFields())
          // 필드의 검증 로직 수행 결과 리턱 
                });
    }

    // ... 

 

 

@Component
@Order(2)
public class SampleValidator implements Validator {
    // ... 

        private Optional<String> validatePayloadContent(ValidationContext context) {
        Payload payload = context.payload();
        if (payload == null || payload.isInValid()) {
            return Optional.of("Invalid payload: " + context.logEvent.payLoadSimpleName());
        }
        return Optional.empty();
    }

    // ...

 

이러한 방식은 어떤 검증이 실패했는지에 대한 상세 정보를 제공할 수 없다는 단점이 있었습니다.

 

예를 들어 각각의 Payload는 다음과 같이 다양한 조건들을 애노테이션으로서 설정하고 있는데, 이에 대한 검증 중 하나라도 실패한다면 실패로 판단되지만, 구체적으로 어느 조건에서 실패했는지에 대해서는 내부 필드 값들을 직접 확인해 따져야만 했던 것입니다.

 

public class DataTransferPayload extends Payload{
    @Required
    @String25
    private String vId;
    @String50
    private String mId;

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

        private String data;
    }
}

 

3. 첫 시도

 

맨 처음 시도한 방식은 각 검증 메서드가 실패한 검증에 대한 상세 정보를 별도 결과 객체로 만들어 리턴하도록 수정하는 것입니다. 즉, 다음과 같은 ValidationResult 객체를 만들어 메시지를 포함하여 리턴합니다.

 

public class ValidationResult {
    private final boolean valid;
    private final String message;

    private ValidationResult(boolean valid, String message) {
        this.valid = valid;
        this.message = message;
    }

    public static ValidationResult valid() {
        return new ValidationResult(true, "");
    }

    public static ValidationResult invalid(String message) {
        return new ValidationResult(false, message);
    }

    public boolean isValid() {
        return valid;
    }

    public String getMessage() {
        return message;
    }
}

 

각 메서드는 이 객체를 활용해서 boolean 값 대신 ValidationResult 객체를 전달합니다.

 

protected ValidationResult satisfyRequired() {
    for (Field field : this.getClass().getDeclaredFields()) {
                // 로직 처리 후 리턴 
    }
    return ValidationResult.valid();
}

 

isValid() 메서드 대신 validate() 를 다음과 같이 작성합니다.

 

public ValidationResult validate() {
    ValidationResult validationResult;

    validationResult = satisfyRequired();
    if (!validationResult.isValid()) {
        return validationResult;
    }

    validationResult = satisfyStringLimit20();
    if (!validationResult.isValid()) {
        return validationResult;
    }

    //... 

    return ValidationResult.valid();
}

 

모든 검증 메서드들은 validate() 메서드에서 호출되며, 이 메서드는 유효하지 않은 ValidationResult를 반환하는 메서드를 찾아내고, 그 결과를 반환합니다. 만약 모든 검증 메서드가 ValidationResult.valid()를 반환하면, validate 메서드 역시 ValidationResult.valid()를 반환합니다.

 

이 방식으로 각 필드에 대한 검증 실패 시 이유를 알 수 있게 되므로 디테일한 정보를 보여주려는 목적에 부합하게 됩니다.

 

그래서... 성공 ?!

 

4. 첫 시도의 문제점

그러나 이 방식은 전체 검증 메서드를 수정해야 하는 큰 변경이 필요했습니다.

 

모든 검증 메서드가 기존에는 boolean 값을 리턴했는데 ValidationResult 객체를 반환하도록 수정해야 했던 것이죠. 이는 Payload 클래스 바깥으로도 수정 사항이 전파되어야 하기 때문에 큰 변경이 되었습니다.

 

또한 기존의 간결하고 명확한 boolean 리턴 타입을 사용하지 못하는 것도 단점으로 다가왔습니다.

 

5. 두 번째 시도

그래서 다음 방식으로 생각해 본 것은 AOP를 이용하는 것입니다. 특정 메서드 실행 전후를 가로채는 @Around 어노테이션을 isValid()에 적용하여 false를 반환하는 메서드를 포착해, 해당 메서드의 네임을 reflection으로 읽어오는 방식을 고려했습니다.

 

다음과 같이 애노테이션을 만들고

 

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidationFailure {
}

 

검증 실패시 실패 메시지를 기록하는 AOP 어드바이스를 작성합니다.

 

@Aspect
@Component
public class ValidationAspect {

    private final ValidationFailureRecorder recorder;

    @Around("@annotation(ValidationFailure)")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        try {
            return joinPoint.proceed();
        } catch (ValidationException e) {
            recorder.recordFailure(e.getMessage());
            throw e;
        }
    }
}

 

이렇게 하면 thenValidate()에서 큰 수정 없이 기록된 메시지만을 가져와서 사용할 수 있습니다.

 

private Optional<String> validatePayloadContent(ValidationContext context) {
    Payload payload = context.payload();
    if (payload == null || payload.isInValid()) {
       // 처리 로직 
    }
    return Optional.empty();
}

 

6. 두 번째 시도의 문제점

그런데 이렇게 하면 애노테이션을 Aspect가 캐치하는 것은 가능하지만 디테일한 메시지를 생성하기 위해서는 예외를 일으켜야 한다는 부수효과가 발생합니다.

 

protected void satisfyRequired() {
    Arrays.stream(this.getClass().getDeclaredFields())
       /// 중간 생략 
                if (value == null || (value instanceof String && ((String) value).isEmpty())) {
                    throw new ValidationException(String.format("The field '%s' is required but it was '%s'", field.getName(), value));
                }
            } catch (IllegalAccessException e) {
                throw new RuntimeException(e);
            }
        });
}

 

따라서 isValid() 메서드는 다음과 같이 바뀌겠죠.

 

public boolean isValid() {
    try {
        satisfyRequired();
        satisfyStringLimit();
        //... 

        return true;
    } catch (ValidationException e) {
        return false;
    }
}

 

그런데 이렇게 변경하면 첫 번째 케이스와 마찬가지로 Payload 내부의 변경으로 바깥 코드의 수정이 필요하게 되고, boolean 리턴 방식의 일관성도 깨지게 됩니다.

 

또한 AOP의 적절한 사용인지에 대해서도 고민해볼 필요가 있습니다. AOP는 보통 메서드의 반환값을 변경하지 않는 부수적인 작업을 수행하는데 사용됩니다. 검증에 대한 결과를 예외를 던져 포착하는 것이 논리적으로 옳은 수순은 아니라는 생각이 들었습니다. 만약 예외가 아니라 메서드 호출 자체를 가로채서 false인 경우, 해당 메서드의 이름을 저장하는 방법을 구현한다고 하더라도 더 복잡해질 것 같았습니다.

 

7. 최종 해결 방식

마지막으로 도달한 해결책은 기존 검증 메서드를 감싸는 새로운 메서드를 만드는 것입니다. Payload 내부에 validate()라는 상위 메서드를 만들고 각각의 검증 로직 메서드를 감쌌습니다. 이러한 변경은 기존의 검증 메서드를 수정하지 않고, 필요한 실패 이유를 추가하며, 동시에 기존의 boolean 리턴 값을 유지할 수도 있게 해줍니다.

 

바로 다음과 같습니다.

 

public boolean isValid() {
    return validate(this::satisfyRequired, "not satisfy required") &&
            validate(this::satisfyStringLimit, "not satisfy string limit") &&
            validate(this::satisfyStringsLimit50, "not satisfy string limit") &&
            validate(this::satisfyEnumValidation, "not satisfy enum condition") &&
            validate(()-> allFieldsSatisfy(this::fieldIsSatisfactory), "not satisfy inner field validation");
}

 

여기서 validate() 메서드는 BooleanSupplier 타입의 검증 메서드와 실패 이유를 문자열로 받아서 검증을 실행하고, 실패 시 실패 이유를 기록합니다.

 

    private boolean validate(BooleanSupplier validationMethod, String detailReason) {
        boolean isValid = validationMethod.getAsBoolean();
        if (!isValid) {
            ValidationFailureDetails.recordFailure("Validation failed: " + detailReason);
        }
        return isValid;
    }

 

이 방식은 개별 검증 메서드를 수정하지 않고 isValid() 메서드에서 각 검증 메서드를 validate() 메서드로 감쌀 수 있게 해주므로, 코드 변경이 최소화할 수 있습니다. 또한, 필요한 실패 이유를 추가하면서 기존의 boolean 리턴 타입을 유지할 수 있게 해줍니다.

이런 변경으로 인해 validatePayloadContent 메서드에서는 검증 실패 시 실패 이유를 사용자에게 전달할 수 있게 되었습니다.

 

    private Optional<String> validatePayloadContent(ValidationContext context) {
        Payload payload = context.payload();
        if (payload == null || payload.isInValid()) {
            return Optional.of("Invalid payload: " + context.logEvent.payLoadSimpleName() + " (" + ValidationFailureDetails.getFailureMessage() + ")");
        }
        return Optional.empty();
    }

 

8. 결론

만약 코드를 맨 처음 작성할 때 디테일 메시지를 전달할 것을 고려했다면 처음부터 별도의 Result 객체를 활용하도록 작성했을 것입니다. 하지만 그렇지 못한 상황이었고 변화 자체가 큰 변화는 아니었지만 작성된 코드들이 많아 영향도가 적지 않았기에 최소한의 변경으로 요구사항을 달성해야 하는 상황이었습니다. 최선의 방식을 찾기까지는 몇 번의 삽질이 필요했지만 결과적으로는 전체 코드의 큰 수정 없이 디테일 메시지 전달이라는 목적을 달성할 수 있었습니다.

 

반응형