2025. 6. 15. 23:37ㆍFramework/NestJS
이번 글에서는 상반기 동안 진행한 WMS 프로젝트에서 제가 경험한 문제 상황과, 이를 디자인 패턴 – 전략 패턴(Strategy Pattern)을 적용해 해결한 과정을 공유드리고자 합니다.
이전 포스팅에서는 DDD 설계를 주제로, Use Case 기반의 Application Layer 구현과 트랜잭션 관리에 대해 다뤘습니다.
이번 글에서는 전략 패턴을 활용한 설계 개선 경험을 중심으로 이야기해보려 합니다.
Design Pattern(디자인 패턴)과 Strategy Pattern(전략 패턴)
디자인 패턴이란 소프트웨어 개발 과정에서 반복적으로 마주치는 문제들을 유형화하여, 그에 대한 검증된 해결 방식을 정리해 놓은 설계 템플릿입니다. 결국 궁극적인 목적은 코드 품질을 향상하고, 유지보수 용이한 코드를 설계해 협업의 효율을 증가하는 것입니다.
디자인 패턴은 크게 세 가지 유형으로 나눌 수 있습니다:
| 생성 패턴 | 객체 생성에 관한 유연한 설계 |
| 구조 패턴 | 클래스나 객체를 조합하는 구조 설계 |
| 행위 패턴 | 객체 간 책임 분산 및 행위 구조화 |
디자인 패턴은 크게 생성, 구조, 행위 패턴으로 구분할 수 있으며 전략 패턴은 이 중에서 행위 패턴에 포함되는 디자인 패턴입니다. 행위 패턴은 객체 간의 책임을 명확히 분산하는데 초점을 두고 있습니다. 이번에 제가 메인으로 담당한 피킹 지시 관련 로직을 전략 패턴을 적용해서 코드를 설계했고 결론적으로 유지보수하기 쉬운 코드라는 평가를 받을 수 있었습니다.
문제 상황: 피킹 지시 로직의 복잡도 증가
WMS에서 피킹(Picking)은 창고 내에 보관 중인 상품을 고객의 주문에 맞게 꺼내는 작업을 의미합니다. 즉, 출고를 위한 준비 단계로 주문서에 맞게 제품을 정확한 수량만큼 선별하는 과정입니다.
피킹은 작업 단위(예. 주문 단위), 작업 공간(예. 존, 로케이션의 위치 단위) 등, 다양한 기준으로 피킹 전략을 세우고 지시를 내릴 수 있습니다. 즉, 피킹 방식은 물류 효율과 정확도를 높이기 위한 전략적 선택이며 한 가지 또는 여러 가지의 개념을 혼합하여 피킹 전략을 선택합니다.
제가 설계해야 할 피킹 방식은 총 4가지로 정리하면 다음과 같습니다.
| 오더 피킹 (Order Picking) |
주문 단위로 하나씩 피킹하는 방식 | - 단순한 로직 - 주문별 맞춤 처리 |
| 단건 피킹 (Single-Item Picking) |
동일한 상품을 1개만 주문한 건들을 모아 한번에 피킹 | - 다수의 단순 주문에 매우 효율적 - 작업 동선 최적화 |
| 배치 피킹 (Batch Picking) |
동일한 상품 구성을 주문한 건들을 묶어 한번에 피킹 (총 수량이 2 이상일 수도 있음) |
- 구성 기준으로 피킹 효율 향상 - 중복 작업 최소화 |
| 토탈 피킹 (Total Picking) |
분배(패킹) 로케이션 기준으로 묶인 주문군에 대해, 품목 종류 및 수량을 파악해 일괄 피킹 | - 작업 분배 최적화 - 후속 공정 연계(패킹) 용이 |
이번 프로젝트 범위 내에서는 4가지 피킹 전략만을 구현하면 됐지만, 앞으로 이 외에도 더 다양한 피킹 방식을 구현해야 했기에 확장 가능하면서 각 피킹 방식별 알고리즘을 독립적으로 구현 및 설계하지 않으면 코드 복잡도가 매우 커질 것으로 예상했었습니다.
처음에는 단순히 기능 구현에 초점을 두고 개발을 했습니다. 코드를 보면 if else 구문(물론 switch를 사용한다고 크게 달라지지 않겠지만)을 기준으로 파라미터로 전달받은 type에 따라 각각의 피킹 방식을 호출하는 PickingManager 클래스를 구현했습니다. (실제 구현했던 코드는 현재 리팩토링 되어서 남아있지 않지만, 대략 if else 구문을 토대로 피킹 방식별로 호출되는 건 비슷합니다.)
type OrderItem = {
sku: string;
quantity: number;
};
type Order = {
id: string;
items: OrderItem[];
};
class PickingManager {
generatePickingInstruction(type: string, orders: Order[]) {
if (type === 'order') {
// 오더 피킹: 주문 단위 피킹
for (const order of orders) {
this.createInstruction(order);
}
} else if (type === 'single-item') {
// 단건 피킹: 1개의 상품만 주문한 경우
for (const order of orders) {
if (order.items.length === 1 && order.items[0].quantity === 1) {
this.createInstruction(order);
}
}
} else if (type === 'batch') {
// 배치 피킹: 동일한 상품 구성 묶음
const batchGroups: Map<string, Order[]> = new Map();
for (const order of orders) {
const key = this.generateBatchKey(order);
if (!batchGroups.has(key)) {
batchGroups.set(key, []);
}
batchGroups.get(key)!.push(order);
}
for (const group of batchGroups.values()) {
if (group.length >= 3) {
this.createInstruction(group);
}
}
} else if (type === 'total') {
// 토탈 피킹: 품목별 총합 계산
const totalItems: Map<string, number> = new Map();
for (const order of orders) {
for (const item of order.items) {
const prev = totalItems.get(item.sku) || 0;
totalItems.set(item.sku, prev + item.quantity);
}
}
this.createInstruction(totalItems);
} else {
throw new Error(`Unknown picking type: ${type}`);
}
}
private createInstruction(order: Order): void {
console.log(`[오더 피킹] 주문 ID: ${order.id}`);
}
private createInstruction(orders: Order[]): void {
console.log(`[배치 피킹] 주문 수: ${orders.length}`);
}
private createInstruction(items: Map<string, number>): void {
console.log(`[토탈 피킹] 품목 수: ${items.size}`);
}
private generateBatchKey(order: Order): string {
return order.items
.sort((a, b) => a.sku.localeCompare(b.sku))
.map(item => `${item.sku}:${item.quantity}`)
.join('|');
}
}
이 코드는 일단 여러 가지 문제를 갖고 있는데
- 피킹 방식이 추가될 때마다 조건절을 추가해야 하기 때문에 if else 체인이 길어져 확장에 용이하지 않습니다.
- PickingManager라는 단일 클래스에 모든 피킹 방식에 대한 구현이 물려 있어 단일 책임 원칙을 위배하고 있습니다.
- 새로운 피킹 방식을 추가할 때 기존 코드의 수정이 필요할 수 있다는 점에서 개방 폐쇄 원칙을 위배하고 있습니다.
- 각각의 피킹 방식별로 테스트를 하려면 여러 상태 조작이 필요하기 때문에 테스트하기도 어렵습니다.
이 외에도 가독성도 매우 안좋고 딱 봐도 유지보수하기는 어려운 코드였습니다. 저는 이러한 문제들을 해결하기 위해서 전략 패턴(Strategy Pattern)을 도입했습니다.
전략 패턴 도입 이유와 피킹 방식 리팩토링
제가 디자인 패턴중 전략 패턴을 선택한 이유는 해결하고자 하는 문제의 본질에 가장 부합하는 선택이라고 생각했기 때문입니다.
- 피킹 방식은 변경 가능하고 확장 가능하기 때문입니다.
- 피킹 방식은 WMS 운영 중 비즈니스 전략 변경, 물류 센터 효율 개선, 고객사별 요구 사항 변경 등 다양한 이유로 추후 변경될 가능성이 높다고 생각했습니다.
- 이런 변화에 대응하기 위해서는 기존 코드를 건드리지 않고도 새로운 전략을 손쉽게 추가할 수 있는 구조로써 전략 패턴이 부합하다고 생각했습니다.
- 조건문 분기를 없애고 개방 폐쇄 원칙을 만족할 수 있기 때문입니다.
- 기존에는 if else로 피킹 방식마다 분기 처리 했기 때문에 새로운 방식이 추가될수록 조건문은 더 복잡해지고 유지보수는 어려워질 거라고 생각했습니다.
- 때문에 조건문 자체를 제거하고 피킹 방식을 하나의 객체로 설계해 각 피킹 방식을 선택하는 행위로 설계를 변경해야 한다고 생각했습니다.
- 피킹 방식 별로 비즈니스 규칙이 달라지기 때문에 각 방식을 별도의 클래스로 분리하는 것이 필요하다고 생각했습니다.
- 이렇게 하면 각 클래스가 단일 책임을 갖게 되고, 독립적으로 테스트할 수 있어 유지보수 하기 쉬워질 수 있다고 판단했습니다.
전략 패턴의 핵심은 알고리즘(또는 행위)을 객체로 분리하는 것입니다. 그래서 알고리즘을 클래스로 캡슐화하고, 인터페이스 또는 추상 클래스를 통해 실행 주체에서 분리할 수 있어야 합니다.
앞서 말했던 것처럼 전략 패턴에서 알고리즘은 클래스로 캡슐화되어 있는 상태여야 하기 때문에 자기 자신을 직접 실행하는 것이 불가능합니다. (즉, 실행 주체에서 분리되어있어야 합니다.)
export class PickingManager {
constructor(
private readonly manager: EntityManager
) {}
async create(
mode: WavePickingMode,
): Promise<BasePickingStrategy> {
switch (mode) {
case WavePickingMode.SINGLE_ITEM_PICKING:
return new SingleItemPickingStrategy(this.manager);
case WavePickingMode.BATCH_PICKING:
return new BatchPickingStrategy(this.manager);
case WavePickingMode.TOTAL_PICKING:
return new TotalPickingStrategy(this.manager);
case WavePickingMode.SINGLE_ORDER_PICKING:
return new SingleOrderPickingStrategy(this.manager);
default:
throw new Error('Invalid Picking Mode');
}
}
}
await createInstructions(pickingDto) {
const pickingStrategies = [
{ mode: SINGLE_ITEM_PICKING, isEnabled: pickingDto.isSinglePickingEnabled },
{ mode: BATCH_PICKING, isEnabled: pickingDto.isBatchPickingEnabled },
{ mode: TOTAL_PICKING, isEnabled: pickingDto.isTotalPickingEnabled },
{ mode: SINGLE_ORDER_PICKING, isEnabled: !pickingDto.isTotalPickingEnabled },
];
const pickingManager = new PickingManager(pickingDto);
for (const { mode, isEnabled } of pickingStrategies) {
if (!isEnabled) continue;
const strategy = await pickingManager.create(mode);
// 피킹 방식별 대상 주문 선별
const filteredOrders = await strategy.filterOrders(orders);
if (!filteredOrders.length < 1) continue;
// 피킹 지시서 생성
await strategy.createInstruction(orders);
}
}
때문에 아래 코드처럼 피킹 방식별로 해당 피킹 지시가 생성되어야 하는지를 외부로부터 판단해서 각각의 피킹 방식별로 피킹지시를 생성하도록 각 클래스에 책임을 위임하는 코드로 리팩터링 했습니다.
먼저 BasePickingStrategy라는 추상 클래스를 만들고 각 피킹 방식별로 반드시 구현해야 하는 메서드 두 개를 정의했습니다.
- 피킹 지시서 생성
- 피킹 대상이 되는 주문 필터링
export abstract class BasePickingStrategy {
constructor(protected readonly manager: EntityManager) {}
abstract createInstruction(orders): void;
abstract filterOrders(orders);
...
}
그리고 인터페이스가 아닌 추상 클래스로 만든 이유는 각 피킹 방식별 공통 로직을 한 곳에서 구현하기 위해서였습니다.
// 단건 피킹
export class SingleItemPickingStrategy extends BasePickingStrategy {
constructor(manager: EntityManager) {
super(manager);
}
createInstruction(orders): void {
// 단건 피킹을 위한 지시 생성 로직 구현 ...
}
filterOrders(orders) {
// 단건 피킹에 맞는 주문 필터링 로직 구현 ...
}
// 단건 피킹에서만 필요한 메서드 목록
...
}
이렇게 각 피킹 방식별로 알고리즘을 캡슐화함으로써 특정 피킹 방식의 구현이 수정되거나 새로운 피킹방식이 추가되더라도 클래스 간에 의존성을 완전히 분리함으로써 유지보수하기 쉬운 코드로 설계할 수 있었습니다. 더불어 클래스가 분리되니 테스트 코드를 작성하는 것도 용이해졌습니다. (피킹 방식별로 목업 데이터 생성과 내부 구현을 테스트하는 것이 용이했습니다.)
// single-item-picking.strategy.spec.ts
describe('SingleItemPickingStrategy', () => {
let strategy: SingleItemPickingStrategy;
let mockEntityManager: Partial<EntityManager>;
beforeEach(async () => {
mockEntityManager = {
// 필요한 EntityManager 메서드들 Mock 처리 가능
};
const module: TestingModule = await Test.createTestingModule({
providers: [
SingleItemPickingStrategy,
{ provide: EntityManager, useValue: mockEntityManager },
],
}).compile();
strategy = module.get<SingleItemPickingStrategy>(SingleItemPickingStrategy);
});
it('should be defined', () => {
expect(strategy).toBeDefined();
});
it('should call createInstruction without errors', () => {
const orders = [{ /* 테스트용 주문 데이터 */ }];
expect(() => strategy.createInstruction(orders)).not.toThrow();
});
it('should call filterOrders and return expected result', () => {
const orders = [{ /* 테스트용 주문 데이터 */ }];
const result = strategy.filterOrders(orders);
// 내부 구현이 없으니 그냥 결과를 undefined로 예상
expect(result).toBeUndefined();
});
});
마무리
이번 경험을 통해 전략 패턴을 실제 업무에 적용함으로써 코드의 유연성과 확장성, 그리고 가독성을 크게 개선할 수 있었습니다. 무엇보다도 변경에 강하고, 각 피킹 방식에 따른 책임을 명확히 분리한 구조 덕분에 유지보수와 협업이 훨씬 수월해졌습니다. 앞으로도 복잡한 비즈니스 로직을 다룰 때에는 디자인 패턴의 도입을 적극적으로 고려하려고 합니다.
글을 끝내기 전에 디자인 패턴에 대한 제 생각을 잠깐 언급하자면, 디자인 패턴은 정해진 정답이 있지는 않다고 생각합니다. 상황에 맞게 패턴을 선택하고 선택했다면 왜 그 디자인 패턴을 선택했는지 타당한 이유가 있어야 한다고 생각합니다. 만약 잘못된 선택을 했다면 그 선택을 과감히 버리고 다른 방법을 찾기 위해 노력해야한다고 생각합니다.
'Framework > NestJS' 카테고리의 다른 글
| 2025 상반기 사내 컨퍼런스 - 도메인을 지키는 설계 (0) | 2025.07.01 |
|---|---|
| DDD 설계: Use Case 기반의 Application Layer 구현과 트랜잭션 관리 (2) | 2025.06.12 |
| NestJs AOP - @toss/nestjs-aop vs 함수형 프로그래밍 패러다임 (0) | 2025.04.06 |
| NestJS의 Provider vs Spring의 Component: 등록 및 관리 방식의 핵심 차이 및 분석 (1) | 2025.03.23 |
| NestJs는 AOP를 지원할까? Spring AOP와의 비교 분석 (0) | 2025.03.03 |