
오늘의 포스팅은 Design Pattern 중 Command 및 Strategy Pattern 을 사용하면서, Design Pattern 에 대한 간략한 정리 및 사례를 소개해보고자 한다. 🐳
Design Pattern은 반복해서 등장하는 설계 문제 에 붙인 이름이다.
단순히 “이렇게 코드를 짜라”는 템플릿이라기보다, 개발자들이 자주 겪는 문제를 설명하고 해결 방향을 공유하기 위한 언어에 가깝다.
예를 들어 “조건문이 계속 늘어나는 변경 판단 로직을 어떻게 분리할까?”, “실행할 작업을 객체로 남겨두고 나중에 처리하려면 어떻게 할까?” 같은 문제는 여러 프로젝트에서 반복된다.
객체지향 프로그래밍이 널리 사용되면서, 개발자들은 비슷한 구조적 문제를 계속 마주했다.
처음에는 간단한 if 문으로 충분했던 코드도 기능이 늘어나면 점점 복잡해진다. 하나의 클래스가 판단, 실행, 기록, 저장까지 모두 담당하기 시작하면 수정 범위가 커지고 테스트도 어려워진다.
Design Pattern은 이런 반복 문제를 정리한 결과물이다.
개발자들이 Design Pattern을 사용하는 이유는 크게 세 가지다.
변경 범위를 줄이기 위해서다. 새로운 정책이 추가될 때 기존 코드를 크게 수정하지 않고 새 구현을 추가하는 쪽이 안전하다.
의사소통 비용을 줄이기 위해서다. “이 부분은 Strategy로 분리했습니다”라고 말하면, 동료 개발자는 대략적인 구조와 의도를 바로 이해할 수 있다.
테스트하기 쉬운 단위로 나누기 위해서다. 하나의 거대한 메서드보다, 변경 판단 로직과 실행 로직이 분리된 코드가 훨씬 검증하기 쉽다.
이번 작업 브랜치에서는 특정 데이터의 변경 여부를 판단하고, 실제 마스터 데이터를 업데이트하는 Spring batch 로직을 다뤘다.
처음에는 “식별번호가 바뀌었는가?”, “이름이 바뀌었는가?” 정도만 판단하면 됐다. 하지만 여기에 “외부 코드도 업데이트해야 하는가?”라는 정책이 추가되면서 Processor가 점점 많은 책임을 가지게 됐다.
Processor가 모든 변경 규칙을 직접 알면 다음 문제가 생긴다. 🤔
그래서 변경 “판단”은 Strategy로 분리하고, 실제 “적용할 변경”은 Command로 표현했다.
Command Pattern은 실행할 작업을 객체로 캡슐화하는 패턴이다.
이번 구조를 단순화하면 아래와 같다.
public interface RecordChangeCommand {
ChangeType type();
void applyTo(TargetRecord record, LocalDateTime updatedAt, String updatedBy);
}
각 변경은 별도 Command로 표현된다.
public record IdentifierChangeCommand(
String identifier,
String regionCode
) implements RecordChangeCommand {
@Override
public ChangeType type() {
return ChangeType.IDENTIFIER;
}
@Override
public void applyTo(TargetRecord record, LocalDateTime updatedAt, String updatedBy) {
record.updateIdentifier(identifier, regionCode, updatedBy, updatedAt);
}
}
public record ExternalCodeChangeCommand(
String externalCode
) implements RecordChangeCommand {
@Override
public ChangeType type() {
return ChangeType.EXTERNAL_CODE;
}
@Override
public void applyTo(TargetRecord record, LocalDateTime updatedAt, String updatedBy) {
record.updateExternalCode(externalCode, updatedBy, updatedAt);
}
}
여기서 중요한 점은 Command가 SQL이나 JDBC 파라미터를 알지 않는다는 것이다. Command는 “도메인 객체에 어떤 변경을 적용할지”만 안다.
이번 코드에서 Command Pattern이 필요했던 이유는 “변경 결과”를 단순 boolean으로 표현하기 어려워졌기 때문이다.
기존에는 이런 식의 구조가 가능했다.
boolean identifierChanged;
boolean nameChanged;
하지만 변경 대상이 늘어나면 필드가 계속 추가된다.
boolean identifierChanged;
boolean nameChanged;
boolean externalCodeChanged;
boolean anotherFieldChanged;
이 구조는 확장에 약하다. Writer도 각 boolean을 확인해야 하고, Processor도 어떤 변경이 실제로 적용됐는지 계속 알고 있어야 한다.
그래서 결과를 이렇게 바꿨다.
public record UpdateResult(
TargetRecord record,
RecordHistory history,
List<RecordChangeCommand> commands
) {
public boolean hasChange(ChangeType type) {
return commands.stream()
.anyMatch(command -> command.type() == type);
}
}
이제 변경 결과는 “boolean 여러 개”가 아니라 “실행된 Command 목록”이다.
Processor는 선택된 Command들을 실행하고, Writer는 Command의 타입을 보고 어떤 업데이트가 필요한지 판단한다.
Processor의 핵심 흐름은 아래처럼 단순해진다.
List<RecordChangeCommand> commands = strategies.stream()
.map(strategy -> strategy.evaluate(context))
.flatMap(Optional::stream)
.toList();
if (commands.isEmpty()) {
return null;
}
LocalDateTime updatedAt = LocalDateTime.now();
RecordHistory history = RecordHistory.snapshot(record, updatedAt);
commands.forEach(command -> command.applyTo(record, updatedAt, auditUser));
return new UpdateResult(record, history, commands);
이 코드에서 Processor는 개별 변경 방식까지 알지 않는다.
IdentifierChangeCommand 가 식별번호를 바꾸는지ExternalCodeChangeCommand 가 외부 코드를 바꾸는지위 내용은 각 Command가 안다.
Processor는 “선택된 Command를 같은 시각, 같은 감사 사용자 기준으로 실행한다”는 공통 흐름만 책임진다.
Writer에서도 Command 목록은 유용하다.
List<UpdateResult> externalCodeItems = items.stream()
.filter(item -> item.hasChange(ChangeType.EXTERNAL_CODE))
.toList();
Strategy Pattern은 알고리즘이나 정책을 별도 객체로 분리하고, 같은 인터페이스로 교체 가능하게 만드는 패턴이다.
이번 구조에서는 “이 데이터는 업데이트 대상인가?”를 판단하는 정책이 Strategy다.
public interface RecordChangeStrategy {
Optional<RecordChangeCommand> evaluate(RecordChangeContext context);
}
각 Strategy는 같은 입력을 받지만, 판단 기준은 다르다.
public class IdentifierChangeStrategy implements RecordChangeStrategy {
@Override
public Optional<RecordChangeCommand> evaluate(RecordChangeContext context) {
if (!context.identifierChanged()) {
return Optional.empty();
}
String selectedIdentifier = selectIdentifier(context);
String regionCode = resolveRegionCode(selectedIdentifier);
return Optional.of(new IdentifierChangeCommand(selectedIdentifier, regionCode));
}
}
public class ExternalCodeChangeStrategy implements RecordChangeStrategy {
@Override
public Optional<RecordChangeCommand> evaluate(RecordChangeContext context) {
ExternalCodeSelection selection = selector.select(
context.currentExternalCode(),
context.sourceExternalCode(),
context.recordName()
);
if (!selection.selected()) {
summary.recordSkipped(selection.reason());
return Optional.empty();
}
summary.recordSelected();
return Optional.of(new ExternalCodeChangeCommand(selection.externalCode()));
}
}
변경 판단 정책은 필드마다 달랐다.
이 모든 판단을 Processor에 넣으면 Processor는 금방 복잡해진다.
Strategy로 분리하면 Processor는 아래 정도만 알면 된다.
List<RecordChangeStrategy> strategies;
그리고 Step별로 필요한 Strategy만 조합할 수 있다.
new RecordChangeProcessor(
repository,
List.of(identifierChangeStrategy, nameChangeStrategy),
jobParameter
);
new RecordChangeProcessor(
repository,
List.of(externalCodeChangeStrategy),
jobParameter
);
이 구조의 장점은 명확하다. 새로운 변경 정책이 생기면 새 Strategy와 Command를 추가하면 된다. Processor의 공통 흐름은 그대로 유지된다.
Processor는 모든 Strategy를 순회하면서 변경 Command를 수집한다.
List<RecordChangeCommand> commands = strategies.stream()
.map(strategy -> strategy.evaluate(context))
.flatMap(Optional::stream)
.toList();
이 한 줄이 Strategy Pattern의 핵심이다.
Processor 입장에서는 어떤 Strategy가 들어왔는지 중요하지 않다. 각 Strategy가 Optional를 반환한다는 약속만 지키면 된다.
업데이트 대상이 아니면 Optional.empty()를 반환한다.
if (!context.nameChanged()) {
return Optional.empty();
}
업데이트 대상이면 Command를 반환한다.
return Optional.of(new NameChangeCommand(selectedName));
결과적으로 판단 로직은 Strategy에 있고, 실행 로직은 Command에 있으며, Processor는 배치 처리 흐름만 담당한다.
Command Pattern은 “실행할 작업”을 객체로 표현하고 싶을 때 적합하다.
이번 작업에서는 변경 결과를 boolean 필드로 들고 있는 대신, 실제 적용할 변경을 Command 목록으로 들고 있게 했다.
특히 아래 상황에서 Command Pattern이 잘 맞는다.
Strategy Pattern은 같은 흐름 안에서 여러 판단 정책이나 알고리즘이 바뀔 수 있을 때 적합하다.
이번 브랜치에서는 변경 대상마다 판단 기준이 달랐다. 그래서 Processor가 모든 규칙을 직접 알지 않도록, 각 변경 정책을 Strategy로 분리했다.
특히 아래 상황에서 Strategy Pattern이 잘 맞는다.
결국 이번 구조에서 Command와 Strategy는 역할이 다르다.
Strategy는 “변경할지 말지”를 결정한다.
Command는 “결정된 변경을 어떻게 적용할지”를 표현한다.
이 둘을 분리하니 Processor는 더 이상 모든 비즈니스 규칙을 끌어안지 않아도 됐다.
Processor는 흐름을 관리하고, Strategy는 판단하고, Command는 실행한다.
이 정도의 책임 분리가 이번 브랜치에서 Design Pattern을 사용한 가장 큰 이유였다.
피드백은 언제나 환영입니다.