|
| 1 | +import ArticleHeader from "@modules/article/ArticleHeader"; |
| 2 | +import ArticleImage from "@modules/article/ArticleImage"; |
| 3 | +import getArticleHeaderProps from "@modules/metadata/getArticleHeaderProps"; |
| 4 | +import getArticleJsonLdProps from "@modules/metadata/getArticleJsonLdProps"; |
| 5 | +import getArticleMetadata from "@modules/metadata/getArticleMetadata"; |
| 6 | +import { ArticleJsonLd } from "next-seo"; |
| 7 | +import { item } from "./metadata"; |
| 8 | +import i1 from "./w.jpg"; |
| 9 | + |
| 10 | +export const metadata = getArticleMetadata(item); |
| 11 | + |
| 12 | +<ArticleJsonLd {...getArticleJsonLdProps(item)} /> |
| 13 | + |
| 14 | +<ArticleHeader {...getArticleHeaderProps(item)} /> |
| 15 | + |
| 16 | +<ArticleImage |
| 17 | + img={i1} |
| 18 | + border |
| 19 | + alt="" |
| 20 | + caption={ |
| 21 | + <> |
| 22 | + 사진:{" "} |
| 23 | + <a href="https://unsplash.com/ko/%EC%82%AC%EC%A7%84/%ED%99%94%EC%9D%B4%ED%8A%B8-%EB%B3%B4%EB%93%9C%EC%97%90-%EA%B8%80%EC%9D%84-%EC%93%B0%EB%8A%94-%EB%82%A8%EC%9E%90---kQ4tBklJI?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash"> |
| 24 | + Unsplash |
| 25 | + </a>{" "} |
| 26 | + by{" "} |
| 27 | + <a href="https://unsplash.com/ko/@campaign_creators?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash"> |
| 28 | + Campaign Creators |
| 29 | + </a> |
| 30 | + </> |
| 31 | + } |
| 32 | +/> |
| 33 | + |
| 34 | +## 목차 |
| 35 | + |
| 36 | +## 간단 용어 설명 |
| 37 | + |
| 38 | +워크플로우는 일반적인 용어이지만 보통 **반복**되는 **일련**의 작업들을 뜻합니다. |
| 39 | + |
| 40 | +- 반복: 재사용할 수 있다는 의미입니다. |
| 41 | +- 일련: 특정한 순서가 있다는 의미입니다. |
| 42 | + |
| 43 | +여기서 하나의 작업은 보통 스텝(Step)이라는 용어로 지칭합니다. |
| 44 | + |
| 45 | +## 워크플로우가 필요한 시나리오 |
| 46 | + |
| 47 | +요구사항은 다음과 같습니다. |
| 48 | + |
| 49 | +- 상품을 구매했을 때 연관 상품을 추천해주는 캠페인 하나를 진행하고자 합니다. |
| 50 | +- 이 캠페인으로는 각 고객에게 메시지가 딱 한번만 나가야 합니다. |
| 51 | +- 고객에게 메시지를 보내려면 적절한 이미지를 미리 업로드해야 합니다. |
| 52 | + |
| 53 | +이를 바탕으로 로직을 한번 생각해봅시다. |
| 54 | + |
| 55 | +1. 보낸 적이 있는지 체크합니다. (DB에서 확인합니다) |
| 56 | +2. 보내지 않았다면 보냈다는 체크를 우선 해놓습니다. (DB에 기록을 insert 합니다) |
| 57 | +3. 이미지를 업로드합니다. (이미지 링크를 얻습니다) |
| 58 | +4. 이미지 링크를 포함하여 메시지를 전송합니다. |
| 59 | + |
| 60 | +자, 여기서 오류가 발생했을 시 다음과 같은 처리도 필요합니다. |
| 61 | + |
| 62 | +- 3번 이미지 업로드에 실패한다면 2번에서 해놨던 체크를 되돌려야 합니다.(DB Delete) |
| 63 | +- 4번 메시지 전송에 실패한다면 2번에서 해놨던 체크를 되돌리고(DB Delete) 업로드한 이미지도 제거합니다. |
| 64 | + |
| 65 | +이를 코드로 표현하면 다음과 같습니다. |
| 66 | + |
| 67 | +```typescript |
| 68 | +async function runCampaign() { |
| 69 | + const checkResult = await checkCanSendMessage(); // 보낸 적이 있는지 체크. `true`면 진행. |
| 70 | + if (!checkResult) { |
| 71 | + console.warn("이미 메시지를 보냈어요."); |
| 72 | + return; |
| 73 | + } |
| 74 | + |
| 75 | + const insertedLogId = await insertLog(); |
| 76 | + try { |
| 77 | + const uploadedImage = await uploadImage(); // 이미지 업로드 |
| 78 | + try { |
| 79 | + return await sendMessage(); |
| 80 | + } catch (e) { |
| 81 | + deleteImage(uploadedImage); // 업로드한 이미지를 삭제합니다. |
| 82 | + throw e; |
| 83 | + } |
| 84 | + } catch (e) { |
| 85 | + deleteLog(insertedLogId); // 체크를 되돌립니다 (DB Delete) |
| 86 | + throw e; |
| 87 | + } |
| 88 | +} |
| 89 | +``` |
| 90 | + |
| 91 | +자자자... 이제 약간 단점이 보입니다. |
| 92 | + |
| 93 | +1. 예 |
| 94 | + |
| 95 | +## 요약 |
| 96 | + |
| 97 | +sdflaksd |
| 98 | + |
| 99 | +````typescript |
| 100 | +/** |
| 101 | + * Workflow Step 인터페이스 |
| 102 | + * |
| 103 | + * @template C 모든 Step에서 공유하는 컨텍스트 타입 |
| 104 | + * @template A 이 Step의 action이 반환하는 rollback argument 타입 |
| 105 | + */ |
| 106 | +export interface IStep<C, A> { |
| 107 | + /** |
| 108 | + * 실제 업무 로직 |
| 109 | + * - ctx에 결과를 기록하고, rollback에 넘길 값을 리턴 |
| 110 | + * - 타입스크립트가 ctx의 타입과 반환값을 자동으로 추론하도록 구현할 것 |
| 111 | + * @param ctx 공용 컨텍스트 객체 |
| 112 | + * @returns rollback 실행 시 필요한 데이터 (예: 생성된 리소스 ID, 변경 전 상태 등) |
| 113 | + */ |
| 114 | + action: (ctx: C) => Promise<A> | A; |
| 115 | + |
| 116 | + /** |
| 117 | + * 실패 시 롤백 로직 (선택적) |
| 118 | + * - 이후 step에서 에러 발생 시 역순으로 호출됨 |
| 119 | + * - action의 반환값을 받아서 롤백 작업 수행 |
| 120 | + * @param ctx 공용 컨텍스트 객체 |
| 121 | + * @param arg action에서 반환된 값 |
| 122 | + */ |
| 123 | + rollback?: (ctx: C, arg: A) => Promise<void>; |
| 124 | +} |
| 125 | + |
| 126 | +/** |
| 127 | + * Workflow 조기 종료를 위한 신호 클래스 |
| 128 | + * |
| 129 | + * Step의 action에서 이 객체를 반환하면 나머지 step들을 건너뛰고 |
| 130 | + * workflow를 정상 종료합니다 (rollback은 실행되지 않음) |
| 131 | + * |
| 132 | + * @example |
| 133 | + * ```typescript |
| 134 | + * const checkConditionStep = createStep( |
| 135 | + * async (ctx: MyContext) => { |
| 136 | + * if (ctx.shouldSkip) { |
| 137 | + * return Workflow.exit("조건이 맞지 않아 워크플로우를 종료합니다"); |
| 138 | + * } |
| 139 | + * return null; |
| 140 | + * } |
| 141 | + * ); |
| 142 | + * ``` |
| 143 | + */ |
| 144 | +export class WorkflowStop { |
| 145 | + constructor(public message?: string) { |
| 146 | + // Set the prototype explicitly to ensure instanceof works correctly! |
| 147 | + Object.setPrototypeOf(this, WorkflowStop.prototype); |
| 148 | + } |
| 149 | +} |
| 150 | + |
| 151 | +/** |
| 152 | + * 여러 Step을 순차 실행하고, 실패 시 역순으로 rollback을 수행하는 Workflow 클래스 |
| 153 | + * |
| 154 | + * **주의사항: rollback이 필요없는 상황에서는 이 Workflow를 사용하지 마세요!** |
| 155 | + * - 단순한 순차 처리만 필요한 경우 일반적인 함수 호출을 사용하세요 |
| 156 | + * - 이 클래스는 복잡한 트랜잭션에서 rollback이 필요할 때만 사용하는 것이 목적입니다 |
| 157 | + * |
| 158 | + * @template C 모든 Step에서 공유하는 컨텍스트 타입 |
| 159 | + * |
| 160 | + * @example |
| 161 | + * ```typescript |
| 162 | + * interface MyContext { |
| 163 | + * userId: string; |
| 164 | + * createdResourceIds: string[]; |
| 165 | + * } |
| 166 | + * |
| 167 | + * const workflow = new Workflow<MyContext>([ |
| 168 | + * createResourceStep, // DB에 리소스 생성 |
| 169 | + * uploadFileStep, // 파일 업로드 |
| 170 | + * sendNotificationStep // 알림 발송 |
| 171 | + * ]); |
| 172 | + * |
| 173 | + * try { |
| 174 | + * await workflow.execute({ userId: "123", createdResourceIds: [] }); |
| 175 | + * } catch (error) { |
| 176 | + * // 실패한 지점부터 역순으로 rollback이 자동 실행됨 |
| 177 | + * console.error("Workflow failed:", error); |
| 178 | + * } |
| 179 | + * ``` |
| 180 | + */ |
| 181 | +export class Workflow<C> { |
| 182 | + /** |
| 183 | + * Workflow 조기 종료를 위한 헬퍼 메서드 |
| 184 | + * @param message 종료 사유 (선택적) |
| 185 | + * @returns WorkflowStop 인스턴스 |
| 186 | + */ |
| 187 | + static exit(message?: string) { |
| 188 | + return new WorkflowStop(message); |
| 189 | + } |
| 190 | + |
| 191 | + /** |
| 192 | + * Workflow를 구성하는 Step들의 배열 |
| 193 | + */ |
| 194 | + // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 195 | + private steps: Array<IStep<C, any>>; |
| 196 | + |
| 197 | + /** |
| 198 | + * Workflow 생성자 |
| 199 | + * @param steps 순차 실행할 Step들의 배열 |
| 200 | + */ |
| 201 | + // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 202 | + constructor(steps: Array<IStep<C, any>>) { |
| 203 | + this.steps = steps; |
| 204 | + } |
| 205 | + |
| 206 | + /** |
| 207 | + * Workflow 실행 메서드 |
| 208 | + * |
| 209 | + * 1. Step들을 순차적으로 실행 |
| 210 | + * 2. 각 Step의 action 결과를 rollback 용으로 저장 |
| 211 | + * 3. 실패 시 완료된 Step들을 역순으로 rollback 실행 |
| 212 | + * 4. WorkflowStop이 반환되면 조기 종료 (정상 종료) |
| 213 | + * |
| 214 | + * @param ctx 모든 Step에서 공유하는 컨텍스트 객체 |
| 215 | + * @throws 실행 중 발생한 에러 (rollback 실행 후 재전파) |
| 216 | + */ |
| 217 | + public async execute(ctx: C): Promise<void> { |
| 218 | + // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 219 | + const completed: Array<{ step: IStep<C, any>; arg: any }> = []; |
| 220 | + |
| 221 | + for (const step of this.steps) { |
| 222 | + // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 223 | + let res: any; |
| 224 | + try { |
| 225 | + res = await step.action(ctx); |
| 226 | + } catch (err) { |
| 227 | + // 에러면 rollback만 하고 에러 재전파 |
| 228 | + for (const { step: s, arg } of completed.reverse()) { |
| 229 | + try { |
| 230 | + await s.rollback?.(ctx, arg); |
| 231 | + } catch (e) { |
| 232 | + // rollback 실패는 무시하고 계속 진행 |
| 233 | + // eslint-disable-next-line no-console |
| 234 | + console.error("Rollback failed:", e); |
| 235 | + } |
| 236 | + } |
| 237 | + throw err; |
| 238 | + } |
| 239 | + |
| 240 | + // early exit 신호면, 그냥 종료 |
| 241 | + if (res instanceof WorkflowStop) { |
| 242 | + return; |
| 243 | + } |
| 244 | + |
| 245 | + // 정상 compArg를 쌓고 다음 스텝으로 |
| 246 | + completed.push({ step, arg: res }); |
| 247 | + } |
| 248 | + } |
| 249 | +} |
| 250 | + |
| 251 | +/** |
| 252 | + * Step 생성을 위한 헬퍼 함수 |
| 253 | + * |
| 254 | + * TypeScript의 타입 추론을 활용하여 action 함수의 매개변수와 반환값을 자동으로 추론합니다. |
| 255 | + * |
| 256 | + * @template C 컨텍스트 타입 (action 함수의 매개변수에서 자동 추론) |
| 257 | + * @template A action 반환값 타입 (action 함수의 반환값에서 자동 추론) |
| 258 | + * @param action Step의 실행 로직 |
| 259 | + * @param rollback Step의 롤백 로직 (선택적) |
| 260 | + * @returns IStep 인터페이스를 구현한 객체 |
| 261 | + * |
| 262 | + * @example |
| 263 | + * ```typescript |
| 264 | + * // 타입이 자동으로 추론됨: IStep<{ userId: string; resourceId?: string }, string> |
| 265 | + * // \@returns ctx.resourceId |
| 266 | + * export const createResourceStep = createStep( |
| 267 | + * async (ctx: { userId: string; resourceId?: string }) => { |
| 268 | + * const resourceId = await createResource(ctx.userId); |
| 269 | + * ctx.resourceId = resourceId; |
| 270 | + * return resourceId; // rollback에서 삭제할 때 사용 |
| 271 | + * }, |
| 272 | + * async (ctx, resourceId) => { |
| 273 | + * await deleteResource(resourceId); |
| 274 | + * } |
| 275 | + * ); |
| 276 | + * |
| 277 | + * // rollback이 없는 단순한 step |
| 278 | + * // @returns ctx.vendorInfo |
| 279 | + * export const getVendorInfoStep = createStep( |
| 280 | + * async (ctx: { sitePublicId: string; vendorInfo?: VendorInfo }) => { |
| 281 | + * ctx.vendorInfo = await getVendorInfo(ctx.sitePublicId); |
| 282 | + * return ctx.vendorInfo; |
| 283 | + * } |
| 284 | + * ); |
| 285 | + * ``` |
| 286 | + */ |
| 287 | +export function createStep<C, A>( |
| 288 | + action: (ctx: C) => Promise<A> | A, |
| 289 | + rollback?: (ctx: C, arg: A) => Promise<void>, |
| 290 | +): IStep<C, A> { |
| 291 | + return { action, rollback }; |
| 292 | +} |
| 293 | +```` |
| 294 | + |
| 295 | +- https://help.asana.com/s/article/what-is-a-workflow |
0 commit comments