다수의 Validators 역할 위임 방식 회고 - Chain 패턴과 Optional을 이용한 우아한 플로우 탐색기
0. 목차
- 개요
- 직면한 문제
- 첫 번째 구현: Chain of Responsibility 패턴 적용
- 첫 번째 구현의 문제점
- 두 번째 구현: 서비스 레벨의 방어 로직
- 두 번째 구현의 문제점
- 세 번째 구현:
recorder
초기화 로직의 이동 - 세 번째 구현의 문제점
- 네 번째 구현: 체인 형식의
support
검증 - 결론
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
를 호출 하고 요청을 전달하도록 하여, 요청을 처리하는 객체가 동적으로 결정되도록 하는 것입니다.
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)));
}
validator
의 thenValidate()
메서드에 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 validator
가 support
하는 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
메서드에 support
와 next
를 호출하는 것 까지는 그러려니 해도 이제는 정말 결이 다른 새로운 기능이 추가되어버렸습니다.
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.");
}
}
}
마지막 validator
로 null
인 것이 반드시 하나 존재하게 되므로 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
, m
은 validator
와 template
의 수입니다. 체인 패턴을 사용한 방식에서도 여전히 O(n^2)
인데 내부적으로 순차 탐색을 진행하기 때문입니다.
따라서 효율 면에서는 성능 개선이 이루어졌다고 보기 힘들 것입니다. 성능 효율 개선을 위해서는 다른 알고리즘이 필요할 것입니다. 한가지 예로서, 검증기를 미리 템플릿 종류에 따라 구성해두는 것입니다. Map
의 key
와 value
로 템플릿과 validator
를 매칭시켜서 해당 각각의 validator
가 미리 자신에게 맞는 템플릿을 인지하고 있도록 하면, 템플릿에 따라 적절한 검증기를 바로 찾아낼 수 있게 될 것입니다. 이렇게 하면 1) validator에게 템플릿 인지시키기 -> O(n)
2) 순차탐색으로 template에 맞는 validator 찾기 ->O(n)
으로 시간 복잡도를 O(n)
으로 개선할 수 있습니다.
해당 프로그램을 만들 당시에는 n
과 m
이 크지 않을 것으로 기대되었기 때문에 개선의 필요성이 크지 않다고 느껴 필요시 개선을 고려하는 방식으로 넘겨두었습니다.
'이슈와해결' 카테고리의 다른 글
리팩토링 회고 - 상태 패턴을 이용해서 복잡한 비즈니스 시나리오 검증 로직을 개선...! (1) | 2023.12.11 |
---|---|
28개(+α) 클래스를 검증해야 한다면? - 커스텀 애노테이션을 사용한 Payload 검증 방식 도전기 (1) | 2023.12.11 |
도메인 주도 개발 방법론(DDD)을 적용하여 3티어 아키텍처를 변경해보자 (1) | 2023.06.26 |
GetMapping시 URI에 PK 식별자가 노출되는 문제: 대체키를 사용한 해결 (0) | 2023.06.24 |
If 분기문 문제를 객체지향, 함수형 프로그래밍을 이용해 해결하기(feat. 우아한테크코스, 스프링 시큐리티) (0) | 2023.06.22 |