ML 코드를 짜다 보면 처음에는 단순한 for문과 함수 몇 개로 충분해 보인다
하지만 실험 대상 모델이 늘어나고, 데이터셋이 많아지고, 추론 시간이 길어지고, CPU/GPU 작업을 나눠야 하는 순간이 오면 코드 구조가 금방 복잡해진다
특히 머신러닝에서는 다음과 같은 상황이 자주 생긴다
- 모델을 계속 바꿔야 함 - 알고리즘을 실험마다 다르게 적용해야 함 - 평가 지표와 저장 방식을 표준화해야 함 - 실패한 실험만 다시 돌려야 함 - CPU 전처리와 GPU 추론을 분리하고 싶음 - 여러 작업을 병렬 또는 비동기로 처리하고 싶음
이럴 때 자주 활용되는 디자인 패턴이 있다
대표적으로 다음 네 가지를 많이 조합해서 사용한다
Factory Pattern Strategy Pattern Command Pattern Producer-Consumer Pattern
1. Factory Pattern
Factory Pattern이란?
Factory Pattern은 어떤 객체를 만들지 결정하는 책임을 분리하는 패턴이다
ML에서는 보통 설정값에 따라 모델이나 알고리즘 객체를 생성할 때 사용한다
예를 들어 config에 이렇게 적혀 있다고 하자
model: transnetv2
그러면 코드에서는 이런 식으로 모델을 만든다
model = ModelFactory.create("transnetv2")
즉 사용하는 쪽에서는 TransNetV2()를 직접 만들지 않고, Factory에게 맡긴다
ML에서 Factory가 필요한 이유
ML 실험에서는 모델이 자주 바뀐다
PySceneDetect TransNetV2 OmniShotCut BaSSL Qwen3-VL XGBoost BERT ResNet
이런 모델들을 코드 곳곳에서 직접 생성하면 코드가 금방 지저분해진다
if model_name == "transnetv2":
model = TransNetV2()
elif model_name == "bassl":
model = BaSSL()
elif model_name == "qwen_vl":
model = QwenVL()
이런 조건문이 여러 파일에 흩어지면 유지보수가 어려워진다
그래서 객체 생성 책임을 Factory로 모은다
class ModelFactory:
@staticmethod
def create(model_name: str):
if model_name == "transnetv2":
return TransNetV2Model()
if model_name == "bassl":
return BaSSLModel()
if model_name == "qwen_vl":
return QwenVLModel()
raise ValueError(f"Unknown model: {model_name}")
사용하는 쪽은 단순해진다
model = ModelFactory.create(config.model_name)
Factory Pattern 한 문장 정리
Factory는 설정값에 따라 어떤 모델 또는 알고리즘 객체를 만들지 결정하는 패턴이다
2. Strategy Pattern
Strategy Pattern이란?
Strategy Pattern은 같은 인터페이스를 사용하지만, 실제 알고리즘은 다르게 실행되도록 만드는 패턴이다
ML에서는 모델마다 내부 알고리즘이 다르지만, 사용하는 쪽에서는 같은 함수 이름으로 호출하고 싶을 때 사용한다
예를 들어 모든 모델이 run()이라는 메서드를 가진다고 하자
prediction = model.run(video_path)
사용하는 쪽 코드는 항상 동일하다
하지만 실제 모델이 무엇이냐에 따라 내부 동작은 달라진다
TransNetV2Strategy.run() BaSSLStrategy.run() QwenVLStrategy.run()
이게 Strategy Pattern이다
Strategy 예시
from abc import ABC, abstractmethod
class InferenceStrategy(ABC):
@abstractmethod
def run(self, video_path: str):
pass
class TransNetV2Strategy(InferenceStrategy):
def run(self, video_path: str):
print("TransNetV2 방식으로 추론")
return {"boundaries": []}
class BaSSLStrategy(InferenceStrategy):
def run(self, video_path: str):
print("BaSSL 방식으로 scene segmentation")
return {"scenes": []}
class QwenVLStrategy(InferenceStrategy):
def run(self, video_path: str):
print("Qwen3-VL 방식으로 MLLM 추론")
return {"segments": []}
사용하는 쪽은 모델이 무엇인지 몰라도 된다
strategy = TransNetV2Strategy()
result = strategy.run("video.mp4")
나중에 BaSSL로 바꿔도 호출 방식은 같다
strategy = BaSSLStrategy()
result = strategy.run("video.mp4")
Factory와 Strategy의 관계
Factory는 객체를 만든다
strategy = StrategyFactory.create("transnetv2")
Strategy는 만들어진 객체가 실제 알고리즘을 실행한다
prediction = strategy.run(video_path)
즉 둘은 보통 같이 쓰인다
strategy = StrategyFactory.create(config.method_name) prediction = strategy.run(video_path)
Strategy Pattern 한 문장 정리
Strategy는 선택된 모델이나 알고리즘이 같은 인터페이스로 서로 다른 동작을 수행하게 만드는 패턴이다
3. Command Pattern
Command Pattern이란?
Command Pattern은 여러 동작을 하나의 실행 가능한 작업 단위로 묶는 패턴이다
ML 실험에서는 단순히 모델만 실행하는 것이 아니라 보통 여러 단계가 함께 실행된다
모델 실행 평가 지표 계산 결과 저장 로그 기록 실패 처리 latency 측정
이런 것들을 하나의 작업으로 묶으면 관리하기 쉬워진다
Command가 없는 경우
Command Pattern 없이 실험 코드를 짜면 보통 이런 식이 된다
for dataset in datasets:
for video in dataset.videos:
for method in methods:
strategy = StrategyFactory.create(method)
prediction = strategy.run(video.path)
metrics = evaluate(prediction, video.gt)
save_prediction(prediction)
save_metrics(metrics)
write_log(dataset.name, video.id, method, metrics)
처음에는 괜찮아 보이지만, 실험이 커지면 문제가 생긴다
- for문이 점점 커짐 - 실험 단위가 불명확해짐 - 실패한 작업만 다시 실행하기 어려움 - 나중에 queue나 worker로 옮기기 어려움 - 로그, 저장, 평가 코드가 여기저기 섞임
Command를 사용한 경우
실험 하나를 ExperimentCommand로 묶을 수 있다
from dataclasses import dataclass
import time
@dataclass
class ExperimentCommand:
dataset_name: str
video_id: str
video_path: str
method_name: str
output_dir: str
def execute(self):
strategy = StrategyFactory.create(self.method_name)
start_time = time.time()
prediction = strategy.run(self.video_path)
latency = time.time() - start_time
metrics = evaluate(prediction, self.dataset_name, self.video_id)
save_prediction(prediction, self.output_dir)
save_metrics(metrics, latency, self.output_dir)
write_log(
dataset_name=self.dataset_name,
video_id=self.video_id,
method_name=self.method_name,
metrics=metrics,
latency=latency,
)
이제 실행하는 쪽은 단순해진다
for command in commands:
command.execute()
여기서 중요한 점은 command.execute()만 호출하면 된다는 것이다
실행하는 쪽은 내부에서 어떤 모델을 쓰는지, 어떤 평가를 하는지, 어디에 저장하는지 몰라도 된다
Command와 Strategy의 차이
둘이 헷갈릴 수 있다
간단히 구분하면 다음과 같다
Strategy = 방법 Command = 작업
예를 들어 Video Scene Segmentation 실험이라면
Strategy = TransNetV2 방식 = BaSSL 방식 = Qwen3-VL 방식
Command = BBC 데이터셋의 video_001을 TransNetV2로 돌리고 평가하고 저장하는 작업 = OVSD 데이터셋의 video_003을 Qwen3-VL로 돌리고 평가하고 저장하는 작업
즉 Strategy는 “어떤 알고리즘으로 할 것인가”이고, Command는 “무슨 일을 끝까지 수행할 것인가”이다
Command Pattern 한 문장 정리
Command는 Strategy 실행, 평가, 저장, 로깅 등 여러 동작을 하나의 작업 단위로 묶는 패턴이다
4. Producer-Consumer Pattern
Producer-Consumer Pattern이란?
Producer-Consumer Pattern은 작업을 만드는 쪽과 작업을 처리하는 쪽을 Queue로 분리하는 패턴이다
여기서 Producer는 작업을 생성해서 Queue에 넣는 역할을 하고, Consumer는 Queue에서 작업을 꺼내 실제로 처리하는 역할을 한다
Producer = 작업을 만들어 Queue에 넣는 쪽 Queue = 작업 대기열 Consumer = Queue에서 작업을 꺼내 처리하는 쪽
구조는 다음과 같다
Producer ↓ Queue ↓ Consumer
Consumer가 여러 개라면 이런 구조가 된다
Producer ↓ Queue ↓ ↓ ↓ C1 C2 C3
ML 시스템에서는 보통 하나의 프로세스나 worker가 하나의 Consumer 역할을 한다
예를 들어 추론 작업을 처리하는 프로세스가 있다면, 이 프로세스는 Queue에서 InferenceCommand를 꺼내 실행하는 Consumer가 된다
Inference Queue ↓ Inference Consumer Process
왜 Queue가 필요한가?
가장 쉽게 말하면, Queue는 작업을 줄 세우고, 처리 가능한 Consumer가 하나씩 가져가게 해주는 장치다
for문 기반 코드는 보통 이런 식으로 동작한다
video1: 전처리 → 추론 → 저장 video2: 전처리 → 추론 → 저장 video3: 전처리 → 추론 → 저장
이 경우 video1의 모든 과정이 끝나야 video2가 시작된다
즉 앞 작업이 오래 걸리면 뒤 작업은 계속 기다려야 한다
반면 Queue를 사용하면 각 단계가 작업을 넘겨주고, 가능한 Consumer가 바로 다음 작업을 처리할 수 있다
CPU 전처리: video1 → video2 → video3 GPU 추론: video1 → video2 → video3 저장 작업: video1 → video2 → video3
이렇게 되면 CPU가 전처리하는 동안 GPU는 이전 데이터를 추론할 수 있고, GPU가 추론하는 동안 CPU는 다음 데이터를 준비하거나 이전 결과를 저장할 수 있다
즉 Queue를 사용하면 CPU/GPU/저장 작업이 서로 기다리는 시간을 줄이고 전체 처리량을 높일 수 있다
Producer-Consumer와 Command Pattern의 관계
Producer-Consumer Pattern은 Command Pattern과 함께 쓰기 좋다
Command Pattern을 사용하면 하나의 작업을 객체로 표현할 수 있다
command = InferenceCommand(video_path="video_001.mp4")
Producer는 이 Command를 Queue에 넣는다
queue.put(command)
Consumer는 Queue에서 Command를 꺼내 실행한다
command = queue.get() command.execute()
즉 Producer-Consumer 구조에서는 보통 다음 흐름이 된다
Producer ↓ Command 생성 ↓ Queue에 넣기 ↓ Consumer가 꺼냄 ↓ command.execute()
Command를 잘 만들어두면 처음에는 for문으로 실행할 수 있다
for command in commands:
command.execute()
나중에 병렬 처리나 비동기 처리가 필요해지면 Queue에 넣는 방식으로 바꿀 수 있다
for command in commands:
queue.put(command)
이렇게 하면 기존 Command의 execute() 로직은 그대로 두고 실행 방식만 바꿀 수 있다
Producer-Consumer Pattern에서 Queue를 사용하는 세 가지 방식
Producer-Consumer Pattern에서 Queue를 사용하는 방식은 크게 세 가지로 나눌 수 있다
1. 같은 작업을 여러 Consumer가 나눠 처리하는 방식 2. 다른 작업이지만 서로 독립적인 Consumer들이 각각 처리하는 방식 3. 다른 작업이 순서대로 이어지는 파이프라인 방식
프로세스 하나가 하나의 Consumer 역할을 한다고 가정하고 각각을 살펴보자
1. 같은 작업을 여러 Consumer가 나눠 처리하는 방식
첫 번째 방식은 하나의 Queue에 같은 종류의 Command를 넣고, 여러 Consumer가 같은 Queue를 함께 소비하는 방식이다
구조는 다음과 같다
inference_queue ↓ ↓ ↓ Consumer1 Consumer2 Consumer3
예를 들어 여러 비디오에 대해 같은 모델 추론을 수행한다고 하자
InferenceCommand(video_001) InferenceCommand(video_002) InferenceCommand(video_003) InferenceCommand(video_004) InferenceCommand(video_005)
Producer는 이 Command들을 하나의 Queue에 넣는다
Producer ↓ inference_queue
그리고 여러 Consumer 프로세스가 같은 Queue에서 작업을 하나씩 꺼내 실행한다
Consumer1 → video_001 추론 Consumer2 → video_002 추론 Consumer3 → video_003 추론 Consumer1 → video_004 추론 Consumer2 → video_005 추론
즉 이 방식은 같은 종류의 작업을 여러 worker가 나눠 처리하는 구조다
언제 사용하면 좋은가?
이 방식은 같은 종류의 작업이 많이 쌓이는 경우에 좋다
- 이미지 10,000장에 대해 같은 모델로 추론 - 여러 비디오에 대해 같은 scene segmentation 실행 - 여러 문서에 대해 같은 OCR 모델 실행 - 여러 요청에 대해 같은 embedding 생성
이 방식의 목적은 처리량 증가다
Consumer 수를 늘리면 동시에 처리할 수 있는 작업 수가 늘어난다
Consumer 1개 = 한 번에 작업 1개 처리 Consumer 4개 = 최대 작업 4개 동시 처리
다만 GPU 작업에서는 Consumer 수를 무작정 늘리면 안 된다
GPU가 1개인데 GPU Consumer를 너무 많이 띄우면 모델이 여러 번 올라가거나 동시에 너무 많은 추론이 실행되어 CUDA OOM이 발생할 수 있다
따라서 GPU 작업은 보통 GPU 개수나 메모리에 맞춰 Consumer 수를 제한한다
코드 예시
from multiprocessing import Process, Queue
def inference_consumer(worker_id: int, inference_queue: Queue):
model = load_model_once()
while True:
command = inference_queue.get()
if command is None:
break
print(f"InferenceConsumer-{worker_id} executes command")
command.execute(model)
inference_queue = Queue()
workers = [
Process(target=inference_consumer, args=(i, inference_queue))
for i in range(3)
]
for worker in workers:
worker.start()
for command in inference_commands:
inference_queue.put(command)
for _ in workers:
inference_queue.put(None)
for worker in workers:
worker.join()
이 구조에서는 InferenceCommand가 하나의 Queue에 쌓이고, 여러 Consumer가 나눠서 처리한다
하나의 Queue + 같은 종류의 Command + 여러 Consumer = 같은 작업을 병렬로 나눠 처리
2. 다른 작업이지만 서로 독립적인 Consumer들이 각각 처리하는 방식
두 번째 방식은 작업 종류는 다르지만 서로 결과를 기다릴 필요가 없는 경우다
예를 들어 하나의 비디오에 대해 다음 작업들을 수행한다고 하자
- Shot Detection - 썸네일 생성 - 메타데이터 추출 - 오디오 전사
이 작업들이 서로 독립적이라면 굳이 순서대로 실행할 필요가 없다
Shot Detection 결과가 썸네일 생성에 필요하지 않고, 메타데이터 추출 결과가 오디오 전사에 필요하지 않다면 각각 따로 처리하면 된다
구조는 다음과 같다
Producer ├─ shot_queue → ShotDetectionConsumer ├─ thumbnail_queue → ThumbnailConsumer ├─ metadata_queue → MetadataConsumer └─ audio_queue → AudioConsumer
각 작업은 자기 Queue와 자기 Consumer를 가진다
shot_queue ↓ ShotDetectionConsumer thumbnail_queue ↓ ThumbnailConsumer metadata_queue ↓ MetadataConsumer audio_queue ↓ AudioConsumer
이 방식은 서로 다른 작업을 독립적으로 병렬 실행하고 싶을 때 좋다
언제 사용하면 좋은가?
이 방식은 작업 종류는 다르지만 서로 의존하지 않는 경우에 사용한다
- 썸네일 생성과 메타데이터 추출을 동시에 하고 싶을 때 - 영상 분석과 오디오 분석을 독립적으로 실행할 때 - 모델 A 추론과 모델 B 추론이 서로의 결과를 필요로 하지 않을 때 - 저장 작업과 통계 계산 작업을 별도로 처리하고 싶을 때
예를 들어 비디오 업로드 후 여러 분석 작업을 동시에 돌리는 시스템이라면 다음처럼 나눌 수 있다
VideoUploadedEvent ├─ ShotDetectionCommand ├─ ThumbnailCommand ├─ MetadataExtractCommand └─ AudioTranscriptionCommand
각 Command는 독립적으로 실행된다
이 방식의 목적은 서로 다른 종류의 작업을 동시에 처리하는 것이다
코드 예시
def shot_detection_consumer(shot_queue):
while True:
command = shot_queue.get()
if command is None:
break
command.execute()
def thumbnail_consumer(thumbnail_queue):
while True:
command = thumbnail_queue.get()
if command is None:
break
command.execute()
def metadata_consumer(metadata_queue):
while True:
command = metadata_queue.get()
if command is None:
break
command.execute()
shot_queue.put(ShotDetectionCommand(video_path))
thumbnail_queue.put(ThumbnailCommand(video_path))
metadata_queue.put(MetadataExtractCommand(video_path))
이 구조에서는 각 Consumer가 자기 역할만 수행한다
다른 Queue + 다른 Command + 다른 Consumer = 독립 작업 병렬 처리
3. 다른 작업이 순서대로 이어지는 파이프라인 방식
세 번째 방식은 앞 단계의 결과가 다음 단계의 입력이 되는 경우다
예를 들어 ML 추론 파이프라인에서는 보통 다음 단계가 이어진다
전처리 → 추론 → 후처리 → 저장
전처리가 끝나야 추론을 할 수 있고, 추론이 끝나야 후처리와 저장을 할 수 있다
이런 경우에는 단계별로 Queue와 Consumer를 둔다
preprocess_queue ↓ PreprocessConsumer ↓ inference_queue ↓ InferenceConsumer ↓ postprocess_queue ↓ PostprocessConsumer
각 Consumer는 서로 다른 Command를 처리한다
PreprocessConsumer = PreprocessCommand 처리 InferenceConsumer = InferenceCommand 처리 PostprocessConsumer = PostprocessCommand 처리
파이프라인에서 중요한 점
파이프라인 구조에서는 중간 Consumer가 동시에 Producer 역할도 한다
예를 들어 PreprocessConsumer는 preprocess_queue에서 Command를 꺼내 처리한다
이때는 Consumer다
preprocess_queue ↓ PreprocessConsumer
하지만 전처리가 끝나면 다음 단계인 InferenceCommand를 만들어 inference_queue에 넣는다
이때는 Producer다
PreprocessConsumer ↓ inference_queue
즉 중간 단계는 다음과 같은 역할을 동시에 한다
PreprocessConsumer = preprocess_queue의 Consumer = inference_queue의 Producer InferenceConsumer = inference_queue의 Consumer = postprocess_queue의 Producer
이 구조를 사용하면 각 단계가 독립적으로 계속 움직일 수 있다
ML 추론 파이프라인 예시
비디오 추론 시스템이라면 다음처럼 나눌 수 있다
1단계: PreprocessCommand - 비디오 로딩 - 프레임 추출 - chunking - resize / normalize - 모델 입력 생성 2단계: InferenceCommand - 모델 로드 - GPU 추론 - prediction 생성 3단계: PostprocessCommand - 결과 정리 - 평가 지표 계산 - 결과 저장 - 로그 기록
흐름은 다음과 같다
PreprocessCommand(video_001) ↓ preprocess_queue ↓ PreprocessConsumer ↓ InferenceCommand(video_001_preprocessed) ↓ inference_queue ↓ InferenceConsumer ↓ PostprocessCommand(video_001_prediction) ↓ postprocess_queue ↓ PostprocessConsumer
for문으로 처리하면 다음처럼 된다
video1 전처리 video1 추론 video1 저장 video2 전처리 video2 추론 video2 저장
하지만 파이프라인으로 처리하면 다음처럼 각 단계가 겹쳐서 동작할 수 있다
CPU 전처리: video1 준비 | video2 준비 | video3 준비 GPU 추론: video1 추론 | video2 추론 | video3 추론 CPU 후처리: video1 저장 | video2 저장
이렇게 하면 GPU가 놀지 않도록 CPU가 미리 입력을 준비하고, GPU가 추론하는 동안 CPU는 다음 입력을 만들거나 이전 결과를 저장할 수 있다
코드 예시
def preprocess_consumer(preprocess_queue, inference_queue):
while True:
command = preprocess_queue.get()
if command is None:
inference_queue.put(None)
break
inference_command = command.execute()
inference_queue.put(inference_command)
def inference_consumer(inference_queue, postprocess_queue):
model = load_model_once()
while True:
command = inference_queue.get()
if command is None:
postprocess_queue.put(None)
break
postprocess_command = command.execute(model)
postprocess_queue.put(postprocess_command)
def postprocess_consumer(postprocess_queue):
while True:
command = postprocess_queue.get()
if command is None:
break
command.execute()
이 방식에서는 Command가 단계별로 바뀐다
PreprocessCommand → InferenceCommand → PostprocessCommand
즉 하나의 Command가 모든 일을 다 하는 것이 아니라, 각 단계가 끝날 때 다음 단계 Command를 만들어 넘긴다
여러 Queue + 서로 다른 Command + 단계별 Consumer = 파이프라인 처리
세 가지 방식 비교
| 방식 | Queue 구조 | 작업 관계 | 목적 | 예시 |
|---|---|---|---|---|
| 같은 작업 병렬 처리 | 하나의 Queue + 여러 Consumer | 같은 종류의 독립 작업 | 처리량 증가 | 여러 비디오를 같은 모델로 추론 |
| 다른 작업 독립 처리 | 작업별 Queue + Consumer | 서로 다른 독립 작업 | 서로 다른 작업을 동시에 실행 | 썸네일 생성, 메타데이터 추출, 오디오 전사 |
| 파이프라인 처리 | 단계별 Queue 연결 | 앞 단계 결과가 다음 단계 입력 | 단계별 작업을 겹쳐 실행 | 전처리 → 추론 → 후처리 |
Producer-Consumer Pattern 한 문장 정리
Producer-Consumer Pattern은 다음과 같이 정리할 수 있다
Producer-Consumer는 Command를 Queue에 넣고 Consumer가 꺼내 실행하게 만드는 패턴이다
Queue를 사용하는 방식은 크게 세 가지다
1. 같은 Command를 하나의 Queue에 넣고 여러 Consumer가 나눠 처리한다 2. 서로 다른 Command가 독립적이면 Command별 Queue와 Consumer를 두고 병렬로 처리한다 3. 앞 단계 결과가 다음 단계 입력이면 단계별 Queue를 연결해 파이프라인처럼 처리한다
ML 추론 프레임워크에서는 이 패턴이 특히 자주 쓰인다
왜냐하면 ML 작업은 보통 다음처럼 단계와 리소스가 나뉘기 때문이다
CPU 전처리 → GPU 추론 → CPU 후처리 → 저장 및 로깅
Queue를 사용하면 각 단계가 끝난 작업을 다음 Queue로 넘기고, 자신은 바로 다음 작업을 처리할 수 있다
그래서 CPU와 GPU가 서로 기다리는 시간을 줄이고, 전체 처리량을 높일 수 있다
최종 정리
ML 엔지니어가 자주 사용하는 디자인 패턴을 한 문장씩 정리하면 다음과 같다
Factory= 설정값에 따라 어떤 모델/알고리즘 객체를 만들지 결정한다 Strategy= 선택된 모델/알고리즘이 같은 인터페이스로 서로 다른 동작을 수행하게 한다 Command= 모델 실행, 평가, 저장, 로깅 등 여러 동작을 하나의 작업 단위로 묶는다 Producer-Consumer= Command를 Queue에 넣고 Consumer가 꺼내 실행하게 한다
조금 더 자연스럽게 연결하면 다음과 같다
설정값에 따라 모델을 선택하기 위해 Factory를 쓰고, 모델별 알고리즘을 동일한 인터페이스로 실행하기 위해 Strategy를 쓰며, 추론·평가·저장을 하나의 작업으로 묶기 위해 Command를 쓰고, 그 작업들을 Queue에 쌓아 여러 Consumer가 처리하게 하기 위해 Producer-Consumer를 쓴다
ML 추론 프레임워크에서는 이 조합이 특히 유용하다
모델은 자주 바뀌고실험 단위는 많아지고추론 작업은 오래 걸리고CPU/GPU 작업은 분리해야 하기 때문
따라서 실전적으로는 다음 순서로 발전시키는 것이 좋다
1단계 Factory + Strategy 2단계 Factory + Strategy + Command 3단계 Factory + Strategy + Command + Producer-Consumer 4단계 CPU Queue / GPU Queue를 분리한 비동기 파이프라인
처음부터 복잡한 구조를 만들 필요는 없다
하지만 모델과 실험이 늘어날 것이 확실하다면, 최소한 Factory + Strategy + Command 정도는 초기에 잡아두는 것이 좋다
그러면 나중에 Queue, Worker, 병렬 처리, 재시도, 상태 관리로 확장하기 쉬워진다