0. 목차
- 개요
- 문제 상황
- 해결 방법: Bean 주입을 활용한 리팩토링
- 테스트 코드에 적용하기
- 결론
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
를 구현해주어야 하는 문제가 발생했습니다.
다음은 Validator1
와 Validator2
를 별도로 구현한 예시입니다.
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 등 다르게 표현되어야 하는 코드들
}
이와 같은 방식으로 동일한 로직이 여러 클래스에 중복되는 것을 해결할 수 있습니다.
그런데 공통되는 점과 다른 부분이 명확하게 차이가 나기 때문에 이보다 더 간단한 방식을 고려할 수 있었습니다. 바로 생성자 인자로 필요한 값들만 전달하여 시나리오에 맞는 다른 역할의 클래스를 동적으로 만드는 것이죠. Spring
의 Bean
주입 기능을 활용하면 별도의 클래스를 생성할 필요도 없이 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);
}
}
ScenarioBasedValidator
는 TestTemplate
인스턴스의 유형을 확인하고, 해당 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
의 정체성을 규정하는 TestTemplate
과 Recorder
만 변경하여 주입해주는 것입니다.
이렇게 하면, 새로운 비즈니스 시나리오가 추가될 때마다 새로운 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
에 사용하던 동일한 ResultRecorder
와 TestTemplate
구현을 사용할 수 있습니다.
다음과 같이 하나의 클래스에서 여러 개의 인스턴스의 단위 테스트를 할 수 있습니다.
@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)
을 준수하는 데 도움이 되었습니다.
'이슈와해결' 카테고리의 다른 글
고도화 회고 - 유효성 검증 로직에서 최소한의 변화로 리턴 타입 변경하기 (0) | 2023.12.12 |
---|---|
순차 탐색 중 이미 지나친 정보를 알고 싶다면? 자료구조를 활용한 메모리 캐싱 직접 구현해보기 (feat. 알고리즘 vs 자료구조) (1) | 2023.12.11 |
리팩토링 회고 - 상태 패턴을 이용해서 복잡한 비즈니스 시나리오 검증 로직을 개선...! (1) | 2023.12.11 |
28개(+α) 클래스를 검증해야 한다면? - 커스텀 애노테이션을 사용한 Payload 검증 방식 도전기 (1) | 2023.12.11 |
다수의 Validators 역할 위임 방식 회고 - Chain 패턴과 Optional을 이용한 우아한 플로우 탐색기 (0) | 2023.11.16 |