본문 바로가기
이슈와해결

리팩토링 회고 - 스프링 Bean 주입을 활용해 Validator 확장성 개선하기

by Renechoi 2023. 12. 11.

0. 목차

  1. 개요
  2. 문제 상황
  3. 해결 방법: Bean 주입을 활용한 리팩토링
  4. 테스트 코드에 적용하기
  5. 결론

1. 개요

본 글에서는 비즈니스 시나리오를 검증하는 Validator의 설계 및 구현 방식에 대해 다룹니다. 여러 개의 다양한 비즈니스 시나리오들이 존재하며 앞으로도 생성될 것을 고려한 확장성이 필요한 상황에서 처음 방식과 개선된 방식을 소개합니다.

 

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

 

2. 문제 상황

문제의 핵심은 코드의 중복이었습니다. 동일한 기능을 하는 서로 다른 Validator들이 중복으로 존재했는데요.

 

비즈니스 시나리오는 다양한 시나리오가 존재할 수 있습니다. 예를 들어 "어떤 특정" 시나리오를 살펴 보면 다음과 같습니다.

  • Payload1 -> 응답 -> Payload2 -> 응답

 

이러한 시퀀스는 TestTemplate으로 규정됩니다.

 

/**
 * 시나리오
 */
public class 시나리오1TestTemplate extends TestTemplate {
    public 시나리오1TestTemplate()
    {
        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)));
        transitions.put(new ScenarioAction(Payload2.Response.class), Set.of(new ScenarioAction(Payload2.class)));
        transitions.put(new ScenarioAction(Payload3.class), Set.of(new ScenarioAction(Payload3.Response.class)));
        transitions.put(new ScenarioAction(Payload1.Response.class), Set.of());
        return transitions;
    }
}

 

다른 시나리오도 있을 수 있겠지요. 이를 테면 "두번째 또 다른" 시나리오는 다음과 같은 템플릿을 갖습니다.

 

/**
 * 시나리오 2
 */
public class 시나리오2TestTemplate extends TestTemplate {
    public 시나리오2TestTemplate() {
        super(new ScenarioAction(Payload2.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)));
        transitions.put(new ScenarioAction(Payload2.Response.class), Set.of(new ScenarioAction(Payload2.class)));
...
        return transitions;
    }

}

 

 

Validator는 하나의 템플릿을 받아 이를 기반으로 검증 로직을 수행하도록 구현되어 있습니다. 따라서 템플릿마다 계속해서 새로운 클래스로 Validator를 구현해주어야 하는 문제가 발생했습니다.

 

다음은 Validator1Validator2 를 별도로 구현한 예시입니다.

 

public class Validator1 implements Validator {

    private TestTemplate testTemplate;
    private List<LogEvent> logEvents;
    private Validator nextValidator;
    private ResultRecorder resultRecorder;

    public Validator1(@Qualifier("시나리오1ResultRecorder") ResultRecorder resultRecorder) {
        this.resultRecorder = resultRecorder;
    }

    @Override
    public boolean supports(TestTemplate testTemplate) {
        return testTemplate instanceof 시나리오1TestTemplate;
    }

    // ... 

 

 

 

@Component
@Order(4)
public class 시나리오2Validator implements Validator {

    private TestTemplate testTemplate;
    private List<LogEvent> logEvents;
    private Validator nextValidator;
    private ResultRecorder resultRecorder;

    public 시나리오2Validator(@Qualifier("시나리오2ResultRecorder") ResultRecorder resultRecorder) {
        this.resultRecorder = resultRecorder;
    }

    @Override
    public boolean supports(TestTemplate testTemplate) {
        return testTemplate instanceof 시나리오2TestTemplate;
    }

    // ... 

 

Validator@Component를 통해 Bean으로 등록되고 @Order를 통해 순서가 정의됩니다.

 

이러한 구현에서는 새로운 시나리오가 추가될 때마다 새로운 Validator를 작성해야 하는데, 문제는 내부의 thenValidate()메서드는 완전히 동일한 코드로 작성되어 있다는 점입니다.

 

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

            if (state instanceof SuccessSequenceValidationState) {
                // 상태 전이 로직... 
            } else if (state instanceof FailureSequenceValidationState) {
                // 상태 전이 로직... 
            }
        }

        if ( state instanceof NextSequenceValidationState) {
            // 상태 처리 로직... 
        }

        callNext();
    }

 

같은 코드로 작성되어 같은 기능을 하는 여러 개의 Validator를 매번 새롭게 클래스를 만들어 작성해야 하는 것은 너무 비효율적이었습다.

 

3. 해결 방법: Bean 주입을 활용한 리팩토링

이 문제를 해결하기 위해 공통된 로직을 재사용하고 확장성을 강화하는 방식을 고려했습니다.

 

처음에 생각한 방식은 상속을 사용하는 방식입니다. 공통된 로직이 포함되는 추상 클래스를 생성하고 해당 클래스에 thenValidate() 메서드를 구현합니다.

 

또한 특정 시나리오에 맞게 동작을 변경할 수 있는 메서드를 둡니다.

 

public abstract class ScenarioBasedValidator implements Validator {

    @Override
    public void thenValidate() {
        // 공통 로직 작성 
    }

    public abstract boolean supports(TestTemplate testTemplate);
    public abstract void recordSuccess();
    public abstract void recordFailure(String reason, LogEvent logEvent);
}

@Component
@Order(3)
public class 시나리오1Validator extends ScenarioBasedValidator {
    // 구체적인 개별 로직들 -> recorder, template 등 다르게 표현되어야 하는 코드들
}

@Component
@Order(4)
public class 시나리오1ScenarioValidator extends ScenarioBasedValidator {
    // 구체적인 개별 로직들 -> recorder, template 등 다르게 표현되어야 하는 코드들
}

 

이와 같은 방식으로 동일한 로직이 여러 클래스에 중복되는 것을 해결할 수 있습니다.

 

그런데 공통되는 점과 다른 부분이 명확하게 차이가 나기 때문에 이보다 더 간단한 방식을 고려할 수 있었습니다. 바로 생성자 인자로 필요한 값들만 전달하여 시나리오에 맞는 다른 역할의 클래스를 동적으로 만드는 것이죠. SpringBean 주입 기능을 활용하면 별도의 클래스를 생성할 필요도 없이 Configuration 설정만으로도 매우 간단하게 이를 구현할 수 있었습니다.

 

Validator의 공통 로직을 추상화하고 하나의 범용 Validator를 만듭니다. 해당 클래스의 이름은 ScenarioBasedValidator 입니다. 상위 개념을 보다 더 잘 표현하고 있기 때문에 구조적으로 적절한 계층관계를 표현하기에도 적절한 상황이 되었습니다.

 

public class ScenarioBasedValidator implements Validator {
    private final ResultRecorder resultRecorder;
    private final Class<? extends TestTemplate> supportedTemplateClass;
    private TestTemplate testTemplate;
    private List<LogEvent> logEvents;
    private Validator nextValidator;

    public ScenarioBasedValidator(ResultRecorder resultRecorder, Class<? extends TestTemplate> supportedTemplateClass) {
        this.resultRecorder = resultRecorder;
        this.supportedTemplateClass = supportedTemplateClass;
    }

    @Override
    public boolean supports(TestTemplate testTemplate) {
        return supportedTemplateClass.isInstance(testTemplate);
    } 
}

 

ScenarioBasedValidatorTestTemplate 인스턴스의 유형을 확인하고, 해당 TestTemplate를 처리하는 데 필요한 모든 로직을 포함하도록 설계되었습니다. 즉, Validator가 검증해야 하는 시나리오에 대한 정보는 외부에서 주입 받는 것은 동일하지만 설정 클래스에서 구체적으로 지정해주어 시나리오에 맞는 적절한 템플릿을 가진 각기 다른 여러 가지의 ScenarioBasedValidator를 만들 수 있게 됩니다.

 

@Configuration
public class ValidatorConfiguration {
    @Bean
    @Order(3)
    public Validator 시나리오1Validator(@Qualifier("시나리오1ResultRecorder") ResultRecorder resultRecorder) {
        return new ScenarioBasedValidator(resultRecorder, 시나리오1TestTemplate.class);
    }

    @Bean
    @Order(4)
    public Validator 시나리오2Validator(@Qualifier("시나리오2ResultRecorder") ResultRecorder resultRecorder) {
        return new ScenarioBasedValidator(resultRecorder, 시나리오2TestTemplate.class);
    }
}

 

Recorder도 다른 부분이었기 때문에 적절한 것으로 주입해줍니다.

 

즉, 검증 로직을 비롯해 반복해서 공통으로 사용하는 로직은 공통으로 사용하면서 해당 Validator의 정체성을 규정하는 TestTemplateRecorder만 변경하여 주입해주는 것입니다.

 

이렇게 하면, 새로운 비즈니스 시나리오가 추가될 때마다 새로운 Validator 클래스를 작성하는 대신, 새롭게 작성한 TestTemplate 클래스와 적절한 Recorder만 등록해주면 됩니다.

 

결론적으로 새로운 시나리오가 추가될 때마다 매번 새로운 Validator 클래스를 작성할 필요가 없어졌습니다.

4. 테스트 코드에 적용하기

기존의 서로 다른 Validators를 이제 제거하고 @configuration 설정만 해주면 되기 때문에 따로 작성해야 하는 코드 없이 바로 적용이 가능합니다.

 

테스트 코드에서는 어떨까요?

 

기존에는 테스트 코드도 각기 다른 테스트 클래스를 가져야 했습니다.

 

@ExtendWith(MockitoExtension.class)
class 시나리오1ValidatorTest {


    @Mock
    private MockValidatorReturningFalse mockValidatorReturningFalse;


    private ResultRecorder resultRecorder;

    private 시나리오1Validator 시나리오1Validator;


    @BeforeEach
    public void setUp() {
        resultRecorder = new 시나리오1ResultRecorder();
        시나리오1Validator = new 시나리오1Validator(resultRecorder);
    }

// ...

 

 

 

@ExtendWith(MockitoExtension.class)
class 시나리오2ScenarioValidatorTest {

// ... 

 

테스트 코드에서도 이제 하나의 클래스만 있어도 됩니다.

 

@ExtendWith(MockitoExtension.class)
class ScenarioBasedValidatorTest {

    @Mock
    private MockValidatorReturningFalse mockValidatorReturningFalse;

    private ResultRecorder resultRecorder;
    private ScenarioBasedValidator scenarioBasedValidator_시나리오1Scenario;
    private ScenarioBasedValidator scenarioBasedValidator_시나리오2Scenario;

    @BeforeEach
    public void setUp() {
        resultRecorder = new 시나리오2ScenarioResultRecorder();
        scenarioBasedValidator_시나리오1Scenario = new ScenarioBasedValidator( resultRecorder, 시나리오1TestTemplate.class);
        scenarioBasedValidator_시나리오2Scenario = new ScenarioBasedValidator( resultRecorder, 시나리오2TestTemplate.class);
    }

 

ScenarioBasedValidator 인스턴스를 생성하고, 이전에 시나리오1Validator , 시나리오1Validator에 사용하던 동일한 ResultRecorderTestTemplate 구현을 사용할 수 있습니다.

 

다음과 같이 하나의 클래스에서 여러 개의 인스턴스의 단위 테스트를 할 수 있습니다.

 

    @DisplayName("thenValidate 메서드 테스트 -> 성공 케이스 : 시나리오1 -> 시나리오1.Response -> 시나리오2 -> 시나리오2.Response 순으로 정상 순서로 메시지가 확인됨")
    @Test
    void thenValidateTest_Success_1_시나리오1() {
        // Given
        List<LogEvent> logEvents = LogEventsFixtureCreator.logEvents_For시나리오1Test_Success_1();

        scenarioBasedValidator_시나리오1Scenario.given(new 시나리오1TestTemplate());
        scenarioBasedValidator_시나리오1Scenario.whenTestedWith(logEvents);
        scenarioBasedValidator_시나리오1Scenario.setNext(mockValidatorReturningFalse);

        // When
        scenarioBasedValidator_시나리오1Scenario.thenValidate();

        // Then
        assertEquals(1, resultRecorder.getTotalSuccessCount());
        assertEquals(0, resultRecorder.getTotalFailureCount());
    }

 

 

    @Test
    @DisplayName("thenValidate 메서드 테스트 -> 성공 케이스 : 시나리오2 -> 시나리오2.Response -> 시나리오3 -> 시나리오3.Response -> 시나리오4 -> 시나리오4.Response 순으로 정상 순서로 메시지가 확인됨")
    void thenValidateTest_Success_1_시나리오2() {
        // Given
        List<LogEvent> logEvents = LogEventsFixtureCreator.logEvents_시나리오2ScenarioTest_Success();

        scenarioBasedValidator_시나리오2Scenario.given(new 시나리오2TestTemplate());
        scenarioBasedValidator_시나리오2Scenario.whenTestedWith(logEvents);
        scenarioBasedValidator_시나리오2Scenario.setNext(mockValidatorReturningFalse);

        // When
        scenarioBasedValidator_시나리오2Scenario.thenValidate();

        // Then
        assertEquals(1, resultRecorder.getTotalSuccessCount());
        assertEquals(0, resultRecorder.getTotalFailureCount());
    }

 

5. 결론

 

공통된 로직을 재사용하고, 특정 비즈니스 시나리오에 따라 달라지는 부분만 주입하여 처리하는 방식으로 코드의 중복을 크게 줄일 수 있었습니다. 스프링의 Bean 주입 기능 덕분에 훨씬 더 유연하고 손쉬운 설계가 가능했습니다.

 

여기서 사용한 리팩토링을 회고해보면 일반화와 특수화라는 객체 지향 프로그래밍의 개념을 이해하고 실제 코드에 적용해볼 수 있었던 경험인 듯 합니다. 공통된 로직을 범용 클래스(ScenarioBasedValidator)에 두고, 특정 비즈니스 시나리오에 따라 달라지는 부분만 특수화하여 처리하는 방식으로 코드의 확장성을 높일 수 있습니다. 확장성을 개선해 개방-폐쇄 원칙(Open-Closed Principle)을 준수하는 데 도움이 되었습니다.

반응형