봄가을 블로그

| 기술

[TypeScript] 복구(롤백) 로직이 있는 경량 워크플로우 시스템 적용하기

일련의 작업들이 모두 다 잘 진행되어야 비로소 성공하는, 하지만 각 단계에서 실패를 적절히 처리해야 하는 워크플로우 시스템을 간단히 만들어봅시다

사진: Unsplash by Campaign Creators

목차

간단 용어 설명

워크플로우는 일반적인 용어이지만 보통 반복되는 일련의 작업들을 뜻합니다.

  • 반복: 재사용할 수 있다는 의미입니다.
  • 일련: 특정한 순서가 있다는 의미입니다.

여기서 하나의 작업은 보통 스텝(Step)이라는 용어로 지칭합니다.

워크플로우가 필요한 시나리오

요구사항은 다음과 같습니다.

  • 상품을 구매했을 때 연관 상품을 추천해주는 캠페인 하나를 진행하고자 합니다.
  • 이 캠페인으로는 각 고객에게 메시지가 딱 한번만 나가야 합니다.
  • 고객에게 메시지를 보내려면 적절한 이미지를 미리 업로드해야 합니다.

이를 바탕으로 로직을 한번 생각해봅시다.

  1. 보낸 적이 있는지 체크합니다. (DB에서 확인합니다)
  2. 보내지 않았다면 보냈다는 체크를 우선 해놓습니다. (DB에 기록을 insert 합니다)
  3. 이미지를 업로드합니다. (이미지 링크를 얻습니다)
  4. 이미지 링크를 포함하여 메시지를 전송합니다.

자, 여기서 오류가 발생했을 시 다음과 같은 처리도 필요합니다.

  • 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 });
}
}
}
  1. completed 배열에 “성공한 step과 결과”를 쌓아 둡니다. 롤백은 이 배열을 역순으로 순회합니다.
  2. action이 성공하면 결과를 받습니다. 실패하면 completed를 역순으로 돌며 롤백합니다.
  3. rollback 자체 실패는 로깅만 하고 계속 진행합니다(최대한 되돌리기 우선).
  4. WorkflowStop이면 실패가 아닌 정상 종료로 보고 즉시 반환합니다.
  5. 정상 완료한 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을 유의깊게 보고 있습니다.

참고