[TypeScript] 복구(롤백) 로직이 있는 경량 워크플로우 시스템 적용하기
일련의 작업들이 모두 다 잘 진행되어야 비로소 성공하는, 하지만 각 단계에서 실패를 적절히 처리해야 하는 워크플로우 시스템을 간단히 만들어봅시다

목차
간단 용어 설명
워크플로우는 일반적인 용어이지만 보통 반복되는 일련의 작업들을 뜻합니다.
- 반복: 재사용할 수 있다는 의미입니다.
- 일련: 특정한 순서가 있다는 의미입니다.
여기서 하나의 작업은 보통 스텝(Step)이라는 용어로 지칭합니다.
워크플로우가 필요한 시나리오
요구사항은 다음과 같습니다.
- 상품을 구매했을 때 연관 상품을 추천해주는 캠페인 하나를 진행하고자 합니다.
- 이 캠페인으로는 각 고객에게 메시지가 딱 한번만 나가야 합니다.
- 고객에게 메시지를 보내려면 적절한 이미지를 미리 업로드해야 합니다.
이를 바탕으로 로직을 한번 생각해봅시다.
- 보낸 적이 있는지 체크합니다. (DB에서 확인합니다)
- 보내지 않았다면 보냈다는 체크를 우선 해놓습니다. (DB에 기록을 insert 합니다)
- 이미지를 업로드합니다. (이미지 링크를 얻습니다)
- 이미지 링크를 포함하여 메시지를 전송합니다.
자, 여기서 오류가 발생했을 시 다음과 같은 처리도 필요합니다.
- 3번 이미지 업로드에 실패한다면 2번에서 해놨던 체크를 되돌려야 합니다.(DB Delete)
- 4번 메시지 전송에 실패한다면 2번에서 해놨던 체크를 되돌리고(DB Delete) 업로드한 이미지도 제거합니다.
이를 코드로 표현하면 다음과 같습니다.
async function runCampaign() {const sendLogId = await checkAndInsertLog();if (!sendLogId) {console.warn("이미 메시지를 보냈어요.");return;}let uploadedImage: UploadedImage | null = null;try {uploadedImage = await uploadImage();await sendMessage({ imageUrl: uploadedImage.url });} catch (e) {if (uploadedImage) {await deleteImage(uploadedImage);}await deleteLog(sendLogId);throw e;}}
문제의 핵심: 복잡한 try/catch
아래 두 가지가 가장 크게 아쉬웠습니다.
1) 중첩 try/catch로 가독성 급락
Step이 늘어날수록 들여쓰기와 예외 처리가 겹겹이 쌓여서 “정상 흐름”을 파악하기가 어렵습니다.
async function runCampaign() {const sendLogId = await checkAndInsertLog();if (!sendLogId) {return;}try {const uploadedImage = await uploadImage();try {const templateId = await createTemplate(); // step이 하나 늘었다고 가정try {await sendMessage();} catch (e) {await deleteTemplate(templateId);throw e;}} catch (e) {await deleteImage(uploadedImage);throw e;}} catch (e) {await deleteLog(sendLogId);throw e;}}
2) 실패 지점별 롤백 로직 누락/순서 실수 가능
롤백 로직은 “반드시” 넣어야 하는데, 중첩 구조에서는 특정 실패 지점에서 빠뜨리기 쉽습니다.
async function runCampaign() {const sendLogId = await checkAndInsertLog();if (!sendLogId) {return;}try {const uploadedImage = await uploadImage();await sendMessage();} catch (e) {// 실수: 업로드 이미지 삭제를 누락함await deleteLog(sendLogId);throw e;}}
또한 실제 로직과 롤백 로직의 거리가 멀어질수록 놓치기 쉬운데, 위 예시처럼 중간에 early exit 분기(예: 이미 보낸 고객이면 return)가 추가되면 흐름이 더 복잡해져 롤백 로직을 빠뜨리기 쉽습니다. 혹은 순서를 잘못 잡아 도메인 규칙을 깨기도 합니다(예: 메시지 로그를 먼저 지운 뒤 이미지 삭제가 실패하면 추적이 어려워짐).
해결 아이디어: Step + rollback
핵심은 “업무 로직(action)과 롤백 로직(rollback)을 하나의 step으로 묶는 것”입니다. 워크플로우 엔진은 단순히 step을 순차 실행하고, 실패 시 역순으로 rollback만 호출합니다.
Early exit (정상 종료) 사례
조건을 만족하지 않으면 “실패가 아니라 정상 종료”로 처리하고 싶을 때가 있습니다. 예를 들어, 이미 메시지를 보낸 고객, 혹은 캠페인 대상이 아닌 고객이라면 그 즉시 종료하고 rollback은 돌리지 않는 것이 맞습니다.
공통 컨텍스트를 쓰는 이유
각 step의 리턴 타입이 다음 step의 인자 타입에 영향을 주도록 만들면 TypeScript에서 유연하게 표현하기가 어렵습니다. 연구가 더 필요한 영역이라, 여기서는 타입 시스템 복잡도를 최소화하기 위해 공통 ctx 객체에 결과를 적재/참조하는 방식으로 우회했습니다.
대신 아래와 같은 구조적인 단점이 생깁니다.
- 타입으로 표현되지 못하는 의존성 증가: 어떤 step이
ctx.userId를 읽기만 하는지, 쓰는지를 코드만 보고 즉시 구분하기 어렵습니다. 따라서 어떤 값이 어떤 역할을 하는지 이름으로 잘 표현해야 합니다. - 순서 민감:
ctx가 변경 가능하니 step의 실행 순서가 곧 데이터 흐름이 됩니다. 순서가 바뀌면 결과가 달라질 수 있습니다. - 캡슐화 제한: step간 의존성을 표현할 방법이 딱히 없습니다. ctx에 step간 상태 추적을 위한 값이 추가되어야 할 수 있는데요, 이 값 또한 타입으로 노출되므로 Workflow를 사용하는 입장에서는 불필요한 정보를 볼 수 있습니다.
구현
아래는 간단 구현입니다. 전체 코드는 GitHub에 공개해 두었습니다. simple-workflow 저장소
먼저 Step의 최소 계약을 정의합니다. action의 결과는 “나중에 롤백에 쓸 수 있는 값”으로 취급합니다.
export interface IStep<C, A> {action: (ctx: C) => Promise<A> | A;rollback?: (ctx: C, arg: A) => Promise<void>;}
action: 현재 컨텍스트ctx를 읽고 다음 스텝에 넘길 결과A를 만듭니다.rollback: 해당 step이 “성공했을 때의 결과(A)”를 인자로 받아 되돌립니다. 실패 지점이 여기까지 왔을 때만 호출됩니다.
다음은 정상 종료를 표현하는 값입니다.
export class WorkflowStop {constructor(public message?: string) {Object.setPrototypeOf(this, WorkflowStop.prototype);}}
message는 디버깅/로그용입니다.instanceof WorkflowStop이 안정적으로 동작하도록 프로토타입을 재설정합니다.
이제 워크플로우 본체를 만듭니다.
export class Workflow<C> {static exit(message?: string) {return new WorkflowStop(message);}private steps: Array<IStep<C, any>>;constructor(steps: Array<IStep<C, any>>) {this.steps = steps;}}
Workflow.exit(...)는 “정상 조기 종료”를 명시하는 값입니다. (정적 메소드)steps는 실행 순서를 유지하는 단순 배열입니다.
마지막으로 실행 로직입니다. 구현 순서대로 읽으면 사고하기 쉬워집니다.
export class Workflow<C> {// ...앞부분 생략public async execute(ctx: C): Promise<void> {const completed: Array<{ step: IStep<C, any>; arg: any }> = [];for (const step of this.steps) {let res: any;try {res = await step.action(ctx);} catch (err) {for (const { step: s, arg } of completed.reverse()) {try {await s.rollback?.(ctx, arg);} catch (e) {console.error("Rollback failed:", e);}}throw err;}if (res instanceof WorkflowStop) {return;}completed.push({ step, arg: res });}}}
completed배열에 “성공한 step과 결과”를 쌓아 둡니다. 롤백은 이 배열을 역순으로 순회합니다.action이 성공하면 결과를 받습니다. 실패하면completed를 역순으로 돌며 롤백합니다.- rollback 자체 실패는 로깅만 하고 계속 진행합니다(최대한 되돌리기 우선).
WorkflowStop이면 실패가 아닌 정상 종료로 보고 즉시 반환합니다.- 정상 완료한 step은
completed에 push합니다.
execute는 결과를 반환하지 않는다는 점에 유의하세요(무조건 Promise<void>).결과는 step에서 ctx의 어떤 값으로 대입하는 식으로 전달되어야 합니다.
마지막으로 Step 생성 헬퍼입니다. 사용 시 문법을 깔끔하게 해줍니다.
export function createStep<C, A>(action: (ctx: C) => Promise<A> | A,rollback?: (ctx: C, arg: A) => Promise<void>,): IStep<C, A> {return { action, rollback };}
createStep을 쓰면 제네릭 추론이 좀 더 잘 되며, 호출부가 간결해집니다.
한계 및 의도적으로 하지 않은 것
- idempotency, 재시도 로직은 고려하지 않음: 이 글의 구현은 “개념 정리와 최소 구현”이 목적입니다.
- 운영 안정성까지 다루려면 전문 도구가 더 적합: 이 수준을 넘어서면 직접 만들기보다 이미 검증된 워크플로우 엔진/라이브러리를 쓰는 것이 낫다고 생각합니다. 특히 저는 Temporal을 유의깊게 보고 있습니다.