본문 바로가기
이슈와해결

다수의 Validators 역할 위임 방식 회고 - Chain 패턴과 Optional을 이용한 우아한 플로우 탐색기

by Renechoi 2023. 11. 16.

다수의 Validators 역할 위임 방식 회고 - Chain 패턴과 Optional을 이용한 우아한 플로우 탐색기

0. 목차

  1. 개요
  2. 직면한 문제
  3. 첫 번째 구현: Chain of Responsibility 패턴 적용
  4. 첫 번째 구현의 문제점
  5. 두 번째 구현: 서비스 레벨의 방어 로직
  6. 두 번째 구현의 문제점
  7. 세 번째 구현: recorder 초기화 로직의 이동
  8. 세 번째 구현의 문제점
  9. 네 번째 구현: 체인 형식의 support 검증
  10. 결론

1. 개요

사내 업무 효율성 증진을 위해 펌웨어 검증을 자동화하는 프로그램을 만들 기회가 있었습니다. 이 글은 검증 자동화 툴을 만드는 과정에서 고민했던 Validation 로직을 다룹니다. 여러가지 검증 시나리오를 각각의 책임에 따라 검증하는 다수의 Validators 구현체들의 구현 책임을 위임하는 방식에서 맞닿뜨렸던 문제점과 해결 방안을 소개합니다.

 

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

2. 직면한 문제

ValidationService는 다음과 같이 다수의 validators를 갖고 있으면서 요청에 따라 해당 validators에게 validation 로직 역할을 위임하여 결과 값을 받는 책임을 갖고 있습니다.

 

문제는 validateTemplate 메서드와 같이 다수의 Template을 받을 경우 해당 템플릿에 맞는 Validator가 해당 템플릿을 받아 validation로직을 수행하도록 해야하므로 다음과 같이 2중 반복문이 발생하게 된다는 것입니다.

 

@Service
public class SimpleValidationService implements ValidationService {


    private final List<Validator> validators;

    // ... 

    @Override
    public void validateMultipleTemplates(List<Event> events, List<Template> templates) {
        templates.forEach(template -> validators.stream()
                .filter(validator -> validator.supports(template))
                .findFirst()
                .orElseThrow(() -> new ValidatorNotFoundException(template.getInitialAction().getName()))
                .given(template)
                .whenTestedWith(events)
                .thenValidate());
    }
}

 

메서드를 다음과 같이 분리해볼 수는 있으나 여전히 이중 반복문이 발생합니다. 코드 상으로도 복잡하고 n^2의 시간 복잡도로서 효율성에서 개선이 필요해보입니다.

 

   @Override
    public void validateTemplate(List<Event> events, List<Template> templates) {
        templates.forEach(template -> {
            Validator validator = findSupportedValidator(template);
            if (validator != null) {
                validator.withRecorder().given(template).whenTestedWith(events).thenValidate();
            }
        });
    }

    private Validator findSupportedValidator(TestTemplate template) {
        for (Validator validator : validators) {
            if (validator.supports(template)) {
                return validator;
            }
        }
        return null;
    }

이를 해결하는 방법으로서 ChainofResponsibility 패턴을 응용한 방식을 생각해볼 수 있었습니다.

 

즉, 각각의 validators를 반복문으로 돌리면서 일일히 support를 확인해주는 것이 아니라, 하나의 validator를 호출 하고 요청을 전달하도록 하여, 요청을 처리하는 객체가 동적으로 결정되도록 하는 것입니다.

 

 

created by chatGPT DALLE-3

 

 

 

3. 첫 번째 구현

먼저 다음과 같이 validators를 체인으로 연결해줍니다.

    @Autowired
    public SimpleValidationService(List<Validator> validators) {
        this.validators = validators;
        setupValidatorChain();
    }

    private void setupValidatorChain() {
        IntStream.range(0, validators.size() - 1)
                .forEach(i -> validators.get(i).setNext(validators.get(i + 1)));
    }

 

validatorthenValidate() 메서드에 callNext를 통해 다음 validator를 호출해줍니다.

 

그럼으로써 연쇄적으로 validator들이 이어지도록 합니다.

 

@Override
    public void thenValidate() {
        TestAction currentAction = testTemplate.getInitialAction();
        for (int i = 0; i < events.size(); i += 2) {
            Event firstEvent = events.get(i);
            Event secondEvent = (i + 1 < events.size()) ? events.get(i + 1) : null;

            if (validateFirstEvent(currentAction, firstEvent)) {
                validateSecondEvent(currentAction, secondEvent);
            }
        }
//        callNext();
    }

 

    private void callNext() {
        Stream.iterate(nextValidator, Objects::nonNull, Validator::getNext).filter(validator -> validator.supports(template)).findFirst().ifPresent(validator -> validator.given(template).whenTestedWith(events).thenValidate());
    }

 

서비스 코드에서는 이제 모든 validators를 호출할 필요가 없으므로 다음과 같이 변경할 수 있습니다.

    @Override
    public void validateTemplate(List<Event> events, List<Template> templates) {
        templates.forEach(template -> validators.get(0).given(template).whenTestedWith(events).thenValidate());
    }

 

4. 첫 번째 구현의 문제점

 

이와 같은 방식으로 구현하면 문제는 support 를 통해 해당 template을 지원하는지를 각각의 validator가 인지하지 못한다는 것입니다. 그럼으로써 start validatator는 항상 thenValidate로직을 수행하게 되고, 맞지 않는 template이 들어왔을 때 NPE 같은 예외를 발생시키게 됩니다.

 

5. 두 번째 구현

체인 방식으로 구현의 목적은 service에서 validators 호출시 반복문을 1회로 줄이는 것입니다. 따라서 service 상의 코드에서 다음과 같이 방어 로직을 구현하는 것은 배제하였습니다.

 

   @Override
    public void validateTemplateWith(List<Event> events, List<Template> templates) {
        templates.forEach(template -> {
            if (validators.get(0).supports(template)) {
                validators.get(0).given(template).whenTestedWith(events).thenValidate();
            }
        });
    }

 

물론 이렇게 구현하면 start validatorsupport 하는 template에 대해서만 검증을 하게 되므로 원하는 결과를 얻기에도 적절하지도 않습니다.

 

요점은 service에서는 다음과 같은 복잡성의 수준을 유지하면서 체인으로 연결된 validators들이 각각의 template에 대해서만 수행을 하면서도 다음 validator들로 책임을 잘 넘겨주어야 한다는 것입니다.

 

    @Override
    public void validateTemplateWith(List<Event> events, List<Template> templates) {
        templates.forEach(template -> validators.get(0).given(template).whenTestedWith(Events).thenValidate());
    }

 

따라서 이를 위해 thenValidate()에 다음과 같은 코드를 추가했습니다.

 

@Override
    public void thenValidate() {
        if (!supports(template)) {
            callNext();
            return;
        }

        // 로직 수행 

        callNext();
    }

 

이렇게 하면 validation 로직을 수행하기 직전에 스스로의 템플릿을 지원하는지를 먼저 체크하기 때문에 타입 안정성을 보장합니다. 또한 로직이 끝난 이후 callNext()를 호출하여 다음 validator도 정상적으로 호출합니다.

 

결과적으로 모든 문제를 만족하는 것 같은 해결책이 되는 것 같았지만. . .

 

6. 두 번째 구현의 문제점

또 다른 문제에 직면합니다. 문제는 thenValidate() 메서드가 역할을 너무 많이 하게 된다는 것이죠. 로직 수행이 종료된 이후 callNext()를 부르는 것은 넘어간다 쳐도 수행 전에 support를 확인하는 로직이 추가되어 해당 메서드가 3가지 일을 하게 되었습니다.

 

그런데 이 문제는 기술 부채로 넘겨버린다 쳐도 기능상의 문제가 발생했습니다.

 

현재의 구현에서 validator는 각각의 역할에 맞는 recorder를 의존합니다. 즉, 다음과 같이 BasicSequenceValidator라면 BasicSequenceResultRecorder 를 의존합니다.

 

그 이유는 다음과 같이 validation 로직을 수행하는 과정에서 발생하는 결과를 기록해야 하기 때문입니다.

 

    public BasicSequenceValidator(@Qualifier("basicSequenceResultRecorder") ResultRecorder resultRecorder) {
        this.resultRecorder = resultRecorder;
    }

    // ... 

private boolean validateFirstEvent(TestAction currentAction, Event firstEvent) {
        if (currentAction.notMatches(firstEvent)) {
            resultRecorder.recordFailure();
            return false;
        }
        return true;
    }

    private void validateSecondEvent(TestAction currentAction, Event secondEvent) {
        boolean isMatched = secondEvent != null && template.nextActions(currentAction).stream().anyMatch(action -> action.matches(secondEvent));
        resultRecorder.records(isMatched);
    }

 

 

@Component
public class BasicSequenceResultRecorder implements ResultRecorder {
    private int totalSuccessCount = 0;
    private int totalFailureCount = 0;

// ...

 

또한 해당 recorder는 비즈니스의 요구사항에 따라 여러번 호출되어야 하기 때문에 validation 로직을 수행할 때마다 초기화되고, 매번 결과값을 리턴해주게 되어 있었고, 따라서 다음과 같이 service에서 체인으로 기능 수행을 요구받을 때마다 같이 초기화되도록 구현했었습니다.

 

    @Override
    public void validateTemplate(List<Event> events, List<Template> templates) {
        templates.forEach(template -> validators.get(0).init().given(template).whenTestedWith(events).thenValidate());
    }

 

이때, 위에서 보다시피 매번 start validator가 호출될 때마다 init()을 통해 초기화되어 버리고 결과적으로 이전의 결과 기록을 날려버려 항상 첫 번째 recorder는 0을 리턴하게 됩니다.

 

7. 세 번째 구현

 

recorder 초기화 로직을 단순히 thenValidate()로 옮겨 주는 것으로 간단히 해결됩니다.

 

@Override
    public void thenValidate() {
        if (!supports(template)) {
            callNext();
            return;
        }

        this.resultRecorder.init();

        TestAction currentAction = testTemplate.getInitialAction();
        for (int i = 0; i < events.size(); i += 2) {
            Event event = events.get(i);
            Event event = (i + 1 < events.size()) ? events.get(i + 1) : null;

            if (validateFirstEvent(currentAction, firstEvent)) {
                validateSecondEvent(currentAction, secondEvent);
            }
        }
        callNext();
    }

 

8. 세 번째 구현의 문제점

 

이 방식은 이전에 언급한 메서드 복잡성의 문제를 심화시킵니다. thenValidate 메서드에 supportnext를 호출하는 것 까지는 그러려니 해도 이제는 정말 결이 다른 새로운 기능이 추가되어버렸습니다.

 

9. 네 번째 구현

궁극적으로 이를 해결하는 방법을 살펴보겠습니다. 바로 service에서 support 검증을 체인 형식으로 호출하는 것입니다. 다음과 같은 메서드를 validator 내부에 추가합니다.

 

@Override
public Validator ifSupport(Template template) {
    if (supports(template)) {
        return this;
    } else {
        if (nextValidator != null) {
            return nextValidator.ifSupport(template);
        } else {
            throw new IllegalArgumentException("No validator supports the provided template.");
        }
    }
}

 

마지막 validatornull인 것이 반드시 하나 존재하게 되므로 null처리를 해주어야 하는 번거로움이 생깁니다. 그런데 여기서 예외를 바깥으로 던져버리면 service에서 잡아야 합니다. 그러면 또 블럭이 늘어나고 가독성면에서 크게 이득을 보지 못합니다. 따라서 다음과 같이 Optional을 이용해 해결해보았습니다.

 

@Override
    public Optional<Validator> ifSupport(Template template) {
        if (supports(template)) {
            return Optional.of(this);
        }

        if (nextValidator != null) {
            return nextValidator.ifSupport(template);
        }

        return Optional.empty();
    }

 

이렇게 하면 호출하는 쪽에서 반환 값이 null인지 아닌지를 체크할 필요 없이 Optional을 통해 안전하게 값을 전달할 수 있습니다. 이로써 다음과 같이 체인을 연결할 수 있게 되었습니다.

 

template -> validators.get(0).ifSupport(template)  // ... 

 

순서상으로 support가 나온 이후에 recorder를 초기화시켜줄 수 있으므로 체인으로 recorder 초기화도 해줄 수 있습니다.

 

ifSupport(template).withRecorder() //...

 

결과적으로 then validate 메서드는 다음과 같이 깔끔하게 validation 기능만 수행하도록 만들 수 있습니다.

    @Override
    public void thenValidate() {
        TestAction currentAction = template.getInitialAction();
        for (int i = 0; i < events.size(); i += 2) {
            Event event = events.get(i);
            Event event = (i + 1 < events.size()) ? events.get(i + 1) : null;

            if (validateFirstEvent(currentAction, firstEvent)) {
                validateSecondEvent(currentAction, secondEvent);
            }
        }
        callNext();
    }

 

마지막으로 Optional을 반환하고 있으므로 한 번 감싸주는 로직이 필요합니다. Optional에서 제공하는 ifPresent 메서드를 사용하면 손쉽게 처리할 수 있습니다.

 

  @Override
    public void validateTemplate(List<Event> events, List<Template> templates) {
        templates.forEach(template -> validators.get(0).ifSupport(template)
                .ifPresent(validator -> validator.withRecorder().given(template).whenTestedWith(events).thenValidate()));
    }

10. 결론

Validator 인스턴스를 체인으로 연결하고, 각 Validator가 자신이 처리할 수 있는 요청을 판단하여 적절한 Validator에게 요청을 처리하도록 위임하는 방식을 소개하였습니다. 이를 위해 체인 패턴을 응용하고, Optional을 활용하여 메서드 호출 결과의 null 가능성을 안전하게 다루는 방법을 제시하였습니다.

 

결과적으로 코드의 가독성을 향상시키고, 각 Validator가 집중해야 할 로직에만 집중할 수 있도록 구현할 수 있었습니다.

 

다만, 이 방식으로는 시간 복잡도를 개선할 수 없었는데요. 이전의 방식에서는 템플릿을 지원하는 validator를 찾기 위해 모든 validator를 순회해야 했으므로 시간 복잡도는 O(mxn)이었습니다(O(n^2)). 여기서 n, mvalidatortemplate의 수입니다. 체인 패턴을 사용한 방식에서도 여전히 O(n^2)인데 내부적으로 순차 탐색을 진행하기 때문입니다.

 

따라서 효율 면에서는 성능 개선이 이루어졌다고 보기 힘들 것입니다. 성능 효율 개선을 위해서는 다른 알고리즘이 필요할 것입니다. 한가지 예로서, 검증기를 미리 템플릿 종류에 따라 구성해두는 것입니다. Mapkeyvalue로 템플릿과 validator를 매칭시켜서 해당 각각의 validator가 미리 자신에게 맞는 템플릿을 인지하고 있도록 하면, 템플릿에 따라 적절한 검증기를 바로 찾아낼 수 있게 될 것입니다. 이렇게 하면 1) validator에게 템플릿 인지시키기 -> O(n) 2) 순차탐색으로 template에 맞는 validator 찾기 ->O(n)으로 시간 복잡도를 O(n)으로 개선할 수 있습니다.

 

해당 프로그램을 만들 당시에는 nm이 크지 않을 것으로 기대되었기 때문에 개선의 필요성이 크지 않다고 느껴 필요시 개선을 고려하는 방식으로 넘겨두었습니다.

created by chatGPT DALLE-3

반응형