본문 바로가기
이슈와해결

리팩토링 회고 - 상태 패턴을 이용해서 복잡한 비즈니스 시나리오 검증 로직을 개선...!

by Renechoi 2023. 12. 11.

0. 목차

  1. 개요
  2. 로직의 목표
  3. 초기 구현 코드
    • 2.1. 템플릿 정의 방식
    • 2.2. Validator의 메인 로직
  4. 상태 패턴을 적용하기
    • 4.1. 고민한 내용
    • 4.2. 상태 패턴
    • 4.3. 구조 설계
    • 4.4. 적용 결과
  5. 여전히 남은 문제들
  6. 결론

 

1. 개요

본 글에서는 사내 업무 시스템 개발 과정에서 겪은 리팩토링 경험을 공유합니다. 비즈니스 프로세스의 순서를 검증하는 로직을 구현했는데 너무 복잡하여 이를 상태 패턴을 적용하여 개선해보는 내용입니다. 만족할 만큼의 클린 코드에는 달성하지 못해 결과적으로는 기술 부채로 남겨두었지만 나름 많은 고민을 했던 내용이라 작성해보았습니다.

 

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

 

1. 로직의 목표

먼저 리팩토링의 대상이 되는 로직의 주 목표에 대해서 생각해보겠습니다. 목표는 주어진 비즈니스 시나리오의 순서를 검증하는 것입니다.

 

특히, 비즈니스 시나리오에 따라 발생한 로그 메시지의 순서를 검증하는데, 이때 메시지 내부에 정의된 페이로드(Payload) 시퀀스가 기준이 됩니다. 여기서 페이로드는 서버와 클라이언트 간에 전송되는 메시지를 의미하며, 각 페이로드는 특정 비즈니스 시나리오의 한 단계를 나타냅니다.

 

예를 들어, "특정 시나리오"라는 시나리오를 생각해보겠습니다. 이 시나리오에서는 아래와 같은 페이로드 순서가 정의되어 있습니다.

 

  1. 서버에서 클라이언트로 CertainPayload 타입의 메시지 전달
  2. 클라이언트에서 이에 대한 응답
  3. 클라이언트에서 NotificationPayload 타입의 메시지를 서버로 전달
  4. 서버에서 이에 대한 응답

 

이 경우, 총 4개의 페이로드가 서버와 클라이언트 간에 교환됩니다.

 

다른 예로 "충전 사용 번호 입력" 시나리오를 보면 아래와 같은 페이로드 순서가 정의됩니다.

 

  1. 인증요청Payload 전달 및 응답
  2. 데이터전송Payload 전달 및 응답
  3. 상태알림Payload 전달 및 응답

 

이 경우, 총 3개의 페이로드가 서버와 클라이언트 간에 교환된다.

 

이렇게 각 비즈니스 시나리오에 따라 정의된 페이로드 순서를 준수하는지를 검증하는 것이 이 로직의 주 목표입니다. 그리고 모든 요청과 응답은 한 쌍으로 이루어져야 하며, 요청과 응답 사이에 다른 메시지가 삽입되는 경우 이를 실패로 간주합니다. 이 모든 검증 과정을 통해 비즈니스 시나리오의 순서가 제대로 이루어지는지를 확인합니다.

 

2. 초기 구현 코드

1. 템플릿 정의 방식

먼저 비즈니스 시나리오 테스트의 기준을 설정하는 SampleTemplate을 살펴보겠습니다.

 

각각의 ValidatorSampleTemplateList<LogEvent>를 받고 템플릿에서 정의된 내용을 바탕으로 LogEvent를 검증합니다.

 

 

예를 들어 비즈니스 시나리오 중 하나인 "특정" 시나리오를 검증하는 기준으로서 CertainTestTemplate을 구현합니다. 이 클래스는 SampleTemplate 클래스를 상속받아 비즈니스 로직을 정의합니다.

 

먼저 SampleTemplate을 살펴보면 다음과 같습니다.

 

public abstract class SampleTemplate {

    private final TestAction initialAction;
    private final Map<TestAction, Set<TestAction>> actionTransitions;

    public Set<TestAction> nextActions(TestAction currentAction) {
        return actionTransitions.getOrDefault(currentAction, Set.of());
    }

}

 

SampleTemplate 클래스에는 initialActionactionTransitions이라는 두 개의 멤버 변수가 있습니다.

 

initialAction은 테스트 시나리오가 시작되는 초기 동작을 나타내며, actionTransitions은 각 동작이 이루어진 후에 이어질 수 있는 동작들의 맵을 나타냅니다.

 

이제 CertainTestTemplate을 살펴보겠습니다.

 

/**
 * 특정 시나리오
 */
public class CertainTestTemplate extends SampleTemplate {
    public CertainTestTemplate()
    {
        super(new ScenarioAction(Payload1.class), generateActionTransitions());
    }

    private static Map<TestAction, Set<TestAction>> generateActionTransitions() {
        Map<TestAction, Set<TestAction>> transitions = new LinkedHashMap<>();
        transitions.put(new ScenarioAction(Payload1.class), Set.of(new ScenarioAction(Payload1.Response.class)));
        ...
        return transitions;
    }

}

 

생성자에서 SampleTemplate 클래스의 생성자를 호출할 때, 인자로 Payload1.class 타입의 객체와 generateActionTransitions() 메서드를 넘겨줍니다.

 

generateActionTransitions() 메서드는 각 동작에 대응하는 이후 가능한 동작들을 맵 형태로 반환합니다. 이를 통해 페이로드의 순서를 정의하며, 이 순서는 비즈니스 로직을 검증하는데 사용됩니다.

 

이제 이렇게 정의한 TemplateCertainValidator이 인자로 받습니다.

 

2. Validator의 메인 로직

비즈니스 순서 검증이라는 문제를 해결하는 메인 로직을 살펴보겠습니다.

 

앞서 예로든 "특정 시나리오"을 계속 생각해보면 다음과 같은 순서의 정합성을 체크해야 합니다.

 

1. 서버에서 클라이언트로 CertainPayload 타입의 메시지 전달
2. 클라이언트에서 이에 대한 응답
3. 클라이언트에서 NotificationPayload 타입의 메시지를 서버로 전달
4. 서버에서 이에 대한 응답

 

CertainValidatorthenValidate 메서드는 각각의 로그를 순회하면서 다음 조건을 검증합니다.

  1. 현재 로그 이벤트가 테스트 템플릿의 초기 동작과 일치하는지 확인합니다.
  2. 다음 로그 이벤트가 현재 로그 이벤트의 응답 페이로드인지 확인합니다.
  3. 다음 로그 이벤트가 테스트 템플릿의 nextActions 중 하나와 일치하는지 확인합니다.
  4. 그 다음 로그 이벤트가 응답 페이로드인지 확인합니다.

 

@Override
    public void thenValidate() {
        TestAction currentAction = testTemplate.getInitialAction();
        int initialIndex = 0;
        for (int i = 0; i < logEvents.size(); i++) {
            LogEvent currentLogEvent = logEvents.get(i);

            if (currentAction.matches(currentLogEvent)) {
                if (isLastLogEvent(i)) {
                    resultRecorder.recordFailure("Scenario Based Sequence does not match", currentLogEvent);
                    break;
                }

                LogEvent nextLogEvent = logEvents.get(i + 1);
                currentAction = checkNextActionsAndReturnNextAction(currentAction, nextLogEvent, i, initialIndex, currentLogEvent);

                if (currentAction == null) {
                    i = initialIndex;
                    currentAction = testTemplate.getInitialAction();
                }
            }
        }

        callNext();
    }

    private boolean isLastLogEvent(int i) {
        return i == logEvents.size() - 1;
    }

    private TestAction checkNextActionsAndReturnNextAction(TestAction currentAction, LogEvent nextLogEvent, int i, int initialIndex, LogEvent currentLogEvent) {
        Set<TestAction> nextActions = testTemplate.nextActions(currentAction);

        if (nextActions == null) {
            resultRecorder.recordSuccess();
            return null;
        }

        Optional<TestAction> nextActionOpt = nextActions.stream()
                .filter(nextAction -> nextAction.matches(nextLogEvent))
                .findAny();

        if (nextActionOpt.isPresent()) {
            return nextActionOpt.get();
        } else {
            resultRecorder.recordFailure("Scenario Based Sequence does not match", currentLogEvent);
            i = initialIndex;
        }
        return null;
    }

 

벌써 너무 복잡합니다... 그래도 한 번 살펴보면...

 

알고리즘은 다음과 같이 진행됩니다.

  • 로그 이벤트를 순회하면서 먼저 if (currentAction.matches(currentLogEvent))를 통해, 현재 로그 이벤트가 현재 테스트 액션과 일치하는지 확인합니다. 여기서 true를 리턴받으면 설정한 시나리오의 첫 번째 시퀀스 Payload가 시작되는 것으로 보며 실질적인 시나리오 순서 검증이 시작됩니다.
  • 현재 로그 이벤트가 마지막 로그 이벤트이고, 해당 로그 이벤트가 테스트 템플릿의 초기 액션과 일치한다면, 시나리오 기반 순서가 일치하지 않는다는 실패를 기록하고 검증을 종료합니다.
  • 일치하는 경우, 다음 로그 이벤트를 가져와 checkNextActionsAndReturnNextAction() 메서드를 통해 다음 테스트 액션의 정합성 여부를 검증합니다. 이때 실패로 확인되면 이를 기록하고 TestAction을 반환합니다.
  • checkNextActionsAndReturnNextAction()에 코어 로직이 작성되어 있습니다. 현재 테스트 액션에 이어질수 있는 테스트 액션들을 가져옵니다. 만약 가져온 이어질 수 있는 테스트 액션들이 null이라면 템플릿에서 정의한 연쇄 체인의 모든 검증이 끝났음을 의미하므로 성공을 기록하고 null을 반환합니다. 그렇지 않다면 이어질 수 있는 테스트 액션들 중에서 다음 로그 이벤트와 일치하는 테스트 액션을 찾고, 찾았다면 해당 테스트 액션을 반환해 다시 다음 로그와의 검증을 계속 수행할 수 있도록 합니다. 일치하는 테스트 액션을 찾지 못한 경우, 실패를 기록하고 null을 반환합니다.
  • 위의 함수 호출이 종료되면 if (currentAction == null)를 통해, 현재 테스트 액션이 null인지 확인합니다. null인 경우는 두 가지 상황이 있습니다. 하나는 checkNextActionsAndReturnNextAction() 메서드에서 검증이 성공하여 모든 액션이 완료된 경우, 다른 하나는 검증이 실패하여 중간에 검증이 종료된 경우입니다.
  • null인 경우 여기서 수행하는 것은 롤백 로직입니다. 즉, 탐색했던 모든 로그 이후부터 다시 검증을 시작하는 것이 아니라, 맨 처음 시작한 로그의 그 다음 부터 다시 탐색할 수 있도록 합니다.

이 과정을 통해 페이로드의 순서가 올바른지를 검증합니다. 만약 순서가 잘못되어 있다면 실패를 기록하고, 그렇지 않으면 성공을 기록합니다.

 

이렇게 검증된 결과는 ResultRecorder를 통해 기록되어, 나중에 결과를 요약하여 출력할 수 있습니다.

 

3. 상태 패턴을 적용하기

1. 고민한 내용

첫 번째는 복잡한 로직입니다. CertainTemplateLogEvent의 일치 여부를 확인하고, 이를 바탕으로 다음 TestAction을 결정하는 과정을 거치는 로직이 너부 복잡했습니다. 이 과정에서 현재 상태와 다음 상태를 직접 관리해야 했기 때문에 메서드를 분리하였다 해도 책임 분산이 힘든 상황이었습니다.

 

두 번째는 로직의 특성입니다.LogEvent가 템플릿에서 정의된 내용과의 일치성 여부를 검증하는 것은 다른 말로 이야기하면 특정한 로그는 특정한 상태여야 함을 의미하지 않을까요? 현재 로그와 다음 로그는 연쇄적으로 이어지는 과정이라면 이는 상태의 전이를 의미하지 않을까요? 이와 같은 고민에서 상태 패턴을 해결책으로 고려하게 되었습니다.

 

2. 상태 패턴

상태 패턴(State Pattern)은 객체의 내부 상태에 따라 객체의 행동을 변경하는 패턴입니다. 즉, 객체의 상태를 클래스로 표현하고, 이 상태가 변화함에 따라 객체의 행동이 달라지는 방식을 제공하죠. 이 패턴을 사용하면 시스템의 상태를 객체로 표현하고 이러한 상태들이 사이에서 발생할 수 있는 전환을 명확하게 표현할 수 있습니다.

 

3. 구조 설계

상태 패턴을 적용하기 위해 우선 SequenceValidationState라는 인터페이스를 정의했습니다. 이 인터페이스는 상태 패턴에서 상태를 표현하는 클래스가 가져야 할 기본적인 메소드인 next()getIndex()를 정의하고 있습니다. 이 인터페이스를 구현하는 클래스는 각각의 상태를 표현하며, 각 상태 클래스는 다음 상태로의 전이를 담당하는 next() 메소드를 구현합니다.

 

public interface SequenceValidationState {
    SequenceValidationState next(LogEvent logEvent, Template template);
    int getIndex();
}

 

플로우는 다음과 같습니다. CertainValidator는 로그 이벤트를 순회하면서 현재 상태(SequenceValidationState)의 next() 메소드를 호출하며 상태 전이를 수행한다. 이 과정에서 InitialSequenceValidationState, NextSequenceValidationState, SuccessSequenceValidationState, FailureSequenceValidationState 네 가지 상태를 거치도록 합니다.

 

각 상태는 검증 과정의 다른 단계를 표현하며, 이들은 테스트 템플릿과 로그 이벤트를 기반으로 다음 상태로의 전이를 결정합니다.

 

4. 적용 결과

먼저 각각의 상태를 보겠습니다.

@Getter
public class FailureSequenceValidationState implements SequenceValidationState {

    private int index;

    public FailureSequenceValidationState(int index) {
        this.index = index;
    }

    @Override
    public SequenceValidationState next(LogEvent logEvent, TestTemplate testTemplate) {
        return this;
    }

}

@Getter
public class InitialSequenceValidationState implements SequenceValidationState {
    private TestAction currentAction;
    private int index;

    public InitialSequenceValidationState(TestAction initialAction, int index) {
        this.currentAction = initialAction;
        this.index = index;
    }

    @Override
    public SequenceValidationState next(LogEvent logEvent, TestTemplate testTemplate) {
        if (currentAction.matches(logEvent)) {
            return new NextSequenceValidationState(currentAction, index);
        }
        return this;
    }


}

@Getter
public class NextSequenceValidationState implements SequenceValidationState {
    private TestAction currentAction;
    private int index;

    public NextSequenceValidationState(TestAction currentAction, int index) {
        this.currentAction = currentAction;
        this.index = index;
    }
        @Override
        public SequenceValidationState next(LogEvent logEvent, TestTemplate testTemplate) {
            Set<TestAction> nextActions = testTemplate.nextActions(currentAction);

            if (nextActions == null) {
                return new SuccessSequenceValidationState(index);
            }

            Optional<TestAction> nextActionOpt = nextActions.stream()
                    .filter(nextAction -> nextAction.matches(logEvent))
                    .findAny();

            return nextActionOpt.map(testAction -> testTemplate.nextActions(testAction).isEmpty() ? new SuccessSequenceValidationState(index) : new NextSequenceValidationState(testAction, index)).orElseGet(()->new FailureSequenceValidationState(index));
        }

}


@Getter
public class SuccessSequenceValidationState implements SequenceValidationState {

    private int index;

    public SuccessSequenceValidationState(int index) {
        this.index = index;
    }

    @Override
    public SequenceValidationState next(LogEvent logEvent, TestTemplate testTemplate) {
        return this;
    }

}

 

여기서 유의미한 변화는 로직을 클래스로 나누어 책임을 분리했다는 점이 아닐까 싶은데요. 물론 복잡도 자체는 여전해 보입니다.

 

기존에 thenValidate()에서 수행하던 일부 검증 로직이 상태 내부로 옮겨갔습니다. 이는 해당하는 상태가 그 상태에 맞는 검증을 스스로 해야 함을 의미합니다.

 

각 상태 클래스는 다음과 같은 역할을 합니다.

  • InitialSequenceValidationState: 페이로드 검증이 시작되는 상태입니다. 이 상태에서는 주어진 로그 이벤트가 초기 테스트 액션과 일치하는지 확인합니다. 일치하는 경우, 다음 상태인 NextSequenceValidationState로 전이합니다.
  • NextSequenceValidationState: 이 상태에서는 주어진 로그 이벤트가 현재 테스트 액션의 다음 액션들 중 하나와 일치하는지 확인합니다. 일치하는 경우, 그 액션이 더 이상의 다음 액션을 가지지 않는 경우 SuccessSequenceValidationState로, 그렇지 않은 경우 다시 NextSequenceValidationState로 전이한다. 일치하지 않는 경우, FailureSequenceValidationState로 전이합니다.
  • SuccessSequenceValidationState: 이 상태는 검증이 성공적으로 완료된 상태를 의미합니다. 이 상태에서는 더 이상의 상태 전이가 발생하지 않습니다.
  • FailureSequenceValidationState: 이 상태는 검증이 실패한 상태를 의미합니다. 이 상태에서도 더 이상의 상태 전이가 발생하지 않습니다.

이제 Validator 내부에 변경된 thenValidate()를 보면 다음과 같습니다.

 

    @Override
    public void thenValidate() {
        SequenceValidationState state = new InitialSequenceValidationState(template.getInitialAction(), 0);
        for (int i = 0; i < logEvents.size(); i++) {
            LogEvent logEvent = logEvents.get(i);
            state = state.next(logEvent, template);

            if (state instanceof SuccessSequenceValidationState) {
                resultRecorder.recordSuccess();
                state = new InitialSequenceValidationState(template.getInitialAction(),i+1);
            } else if (state instanceof FailureSequenceValidationState) {
                resultRecorder.recordFailure("Scenario Based Sequence does not match", logEvent);
                i = state.getIndex();
                state = new InitialSequenceValidationState(template.getInitialAction(),i+1);
            }
        }

        if ( state instanceof NextSequenceValidationState    ) {
            resultRecorder.recordFailure("Scenario Based Sequence does not match", logEvents.get(logEvents.size() - 1));
        }

        callNext();
    }

 

CertainValidator는 이제 각 로그 이벤트를 순회하면서 상태 전이를 수행합니다. 각 상태는 자신에게 맞는 검증 로직을 수행하고, 그 결과에 따라 다음 상태로의 전이를 결정합니다.

 

4. 여전히 남은 문제들

첫 번째는 그럼에도 불구하고 복잡하다는 것입니다. 상태 패턴을 적용하여 로직의 검증 책임을 각각의 상태로 분류하였고 명확한 역할을 분리하여 책임을 분산하는 것은 가능했습니다. 하지만 검증의 진행을 제어하고 결과를 판단하는 과정에서 여전히 다수의 if 분기문이 등장하고 복잡한 흐름이 존재합니다.

 

두 번째는 기록과 검증 기능을 동시에 수행해야 한다는 것입니다. 이는 SRP 원칙을 위배하는 사항으로 첫 번째 이유인 복잡성을 증대시키는 원인이 되기도 합니다. 기록과 검증 로직은 본 알고리즘에서 타이트한 연관성이 있는 것이 맞지만, 이 둘을 분리할 수는 정말 없을까를 생각했습니다.

 

세 번째는 검증을 수행하는 로직 자체에 대한 것입니다. 즉 비즈니스 시나리오 순서 정합성 검증에 있어서 현재는 단순히 순차 기반의 검증을 수행합니다. 첫 번째 로그가 이러이러하다면 두 번째 로그는 이러이러해야 한다는 것입니다. 그렇기 때문에 첫 로그 이후 기대한 로그가 세 번째 로그에 나타나고 중간 두 번째 로그에는 관련 없는 로그가 끼어든다면 해당 시나리오는 실패한 것으로 간주됩니다. 하지만 여러 세션이 동시에 접근하는 경우 클라이언트 입장에서는 정확한 순서를 제공하였지만 서버 쪽 로그에서는 순서대로 찍히지 않았을 수도 있습니다. 이러한 경우에 실패로 볼 것인가, 성공으로 본다면 어떤 정책적 기준으로 어디까지를 성공으로 볼 것인가의 문제가 있습니다.

 

5. 결론

단순 순서 검증이지만 여러 가지 실패 조건들을 고려하다 보니 알고리즘이 복잡해지는 문제가 있었고, 이를 디자인 패턴 중 하나인 상태 패턴을 적용해 개선하는 과정을 작성해보았습니다. 상태 패턴은 상태의 전이를 명확하게 표현하고, 각각의 상태 마다 행동을 캡슐화할 수 있게 해주어 기존의 방식보다 훨씬 나은 가독성과 유연성을 제공했습니다.

 

여전히 개선점이 남아 있어 사실상 기술 부채로 남겨둔 케이스입니다.

 

다음 번에 개선을 시도한다면 코드의 복잡성을 더 줄일 필요가 있어 보이고 그것은 검증과 기록을 분리하는 리팩토링에 중점을 맞추어야 할 것 같습니다. 상태를 좀 더 수월하게 구현 하는 방식으로 유한상태머신(FSM)을 모사해서 다시 새롭게 구현해보거나 스프링에서 제공하는 stateMachine을 생각해보는 것이 아닐까 합니다. 또한 순서 정합성을 풀이하는 접근 방식으로 리팩토링 방향을 바꿔본다면 uuid 기반의 pair 저장, 관리, 매핑 등의 방식을 사용해서 단순 순차 기반의 검증보다 좀 더 나은 설계 방안을 고려해보는 것도 가능할 것 같습니다.

 

 

반응형