본 콘텐츠는 사용자의 편의를 고려해 자동 기계 번역 서비스를 사용하였습니다. 영어 원문과 다른 오류, 누락 또는 해석상의 미묘한 차이가 포함될 수 있습니다. 필요하시다면 영어 원문을 참조하시기를 바랍니다.
Cloudflare Workflows 는 우리의 관점을 반영한 "Durable Execution"입니다. Cloudflare 개발자 플랫폼으로 구동되는 서버리스 엔진을 제공하여 장애가 발생하더라도 작동하는 다단계 장기 애플리케이션을 구축할 수 있습니다. 올해 초 Workflows가 일반적으로 사용 가능하게 되었을 때 개발자는 기존의 상태 비저장 기능으로는 관리하기 어렵거나 불가능한 복잡한 프로세스를 오케스트레이션할 수 있습니다. Workflows는 상태, 재시도, 긴 대기 시간을 처리하므로 비즈니스 로직에 집중할 수 있습니다.
그러나 복잡한 오케스트레이션의 안정성을 위해서는 강력한 테스트가 필요합니다. 현재까지 Workflows 테스트는 블랙박스 프로세스였습니다. await to the status를 통해 Workflow 인스턴스가 완료되었는지 테스트할 수는 있지만, 중간 단계를 볼 수는 없었습니다. 이 때문에 디버깅이 매우 어려워졌습니다. 결제 처리 단계가 성공했나요? 단계에 따라 데이터를 올바르게 수신했나요? 외부 시스템이나 로그를 검사하지 않고는 확신할 수 없습니다.
개발자 자신으로서, 저희는 신뢰할 수 있는 코드를 보장해야 할 필요성을 이해합니다. 저희는 Workflows를 테스트하기 위한 개발자 경험이 더 나아져야 한다는 여러분의 피드백을 크고 명확하게 들었습니다.
테스트의 블랙 박스 특성이 문제의 일부였습니다. 그러나 그 한계를 넘어 테스트 기간이 제한되자 높은 비용을 지불했습니다. 프로젝트에 워크플로우를 추가한 경우, 워크플로우를 직접 테스트하는 것이 아니더라도 Cloudflare에서 테스트 간의 격리를 보장할 수 없었으므로 격리 저장소를 비활성화해야 했습니다. 격리된 스토리지는 각 테스트가 다른 테스트의 부작용 없이 깨끗하고 예측 가능한 환경에서 실행되도록 보장하기 위한 바이테스트-풀-작업자 기능입니다. 강제로 비활성화한다는 것은 테스트 사이에 상태가 누출되어 비정상적이고 예측할 수 없으며 디버깅하기 어려운 실패로 이어질 수 있다는 것을 의미했습니다.
이로 인해 복잡한 애플리케이션을 구축하는 개발자들은 선택에 어려움을 겪었습니다. 프로젝트에서 Workflows와 함께 Workers, Durable Objects, R2 를 사용한 경우, 전체 프로젝트 에 대한 격리된 테스트를 포기하거나 테스트를 건너뛰어야 했습니다. 이 마찰로 인해 테스트 환경이 열악해지고, 결국 Workflows 채택을 저해했습니다. 이 문제를 해결한 것은 단순한 개선에 불과한 것이 아니었으며, Cloudflare 애플리케이션의 Workflows를 통합하는 데 중요한 단계 였습니다.
Cloudflare에서는 Workers 런타임 workerd에서 테스트 실행을 지원하는 테스트 프레임워크인 vitest-full-workers를 통해 Workflows에 대한 포괄적이고 세분화된 격리된 테스트를 수행할 수 있는 새로운 APIs를 도입합니다. 이를 통해 네트워크 연결에 의존하지 않고 빠르고 안정적이며 저렴하게 테스트를 실행할 수 있습니다.
@cloudflare/vitest-pool-workers 버전 0.9.0 이상에서 cloudflare:test 모듈을 통해 사용할 수 있습니다. 새로운 테스트 모듈에서는 Workflows를 자체적으로 검사할 수 있는 두 가지 주요 기능을 제공합니다.
실용적인 예를 살펴보겠습니다.
간단한 블로그 운영 Workflow를 상상해 보세요. 사용자가 댓글을 제출하면 Workflow는 workers-ai에서 검토를 요청합니다. 반환된 위반 점수에 따라 평가자가 댓글을 승인하거나 거부할 때까지 기다립니다. 승인되면 step.do 를 호출하여 외부 API를 통해 주석을 게시합니다.
새로운 API 없이 이를 테스트하는 것은 불가능합니다. 단계의 결과를 시뮬레이션하고 사회자의 승인을 시뮬레이션할 수 있는 직접적인 방법이 없습니다. 이제 모든 것을 조롱할 수 있습니다.
다음은 알려진 인스턴스 ID로 introspectWorkflow 인스턴스를 사용하는 테스트 코드입니다.
import { env, introspectWorkflowInstance } from "cloudflare:test";
it("should mock a an ambiguous score, approve comment and complete", async () => {
// CONFIG
await using instance = await introspectWorkflowInstance(
env.MODERATOR,
"my-workflow-instance-id-123"
);
await instance.modify(async (m) => {
await m.mockStepResult({ name: "AI content scan" }, { violationScore: 50 });
await m.mockEvent({
type: "moderation-approval",
payload: { action: "approved" },
});
await m.mockStepResult({ name: "publish comment" }, { status: "published" });
});
await env.MODERATOR.create({ id: "my-workflow-instance-id-123" });
// ASSERTIONS
expect(await instance.waitForStepResult({ name: "AI content scan" })).toEqual(
{ violationScore: 50 }
);
expect(
await instance.waitForStepResult({ name: "publish comment" })
).toEqual({ status: "published" });
await expect(instance.waitForStatus("complete")).resolves.not.toThrow();
});
이 테스트는 Workers AI를 호출하는 'AI 콘텐츠 스캔'과 외부 블로그 API를 호출하는 '코멘트 게시' 단계와 같이 외부 API 호출이 필요한 단계의 결과를 모의합니다.
무작위로 생성된 ID를 가진 하나 또는 다수의 Workflow 인스턴스를 시작하는 worker 요청을 생성하므로 인스턴스 ID를 알 수 없는 경우 introspectWorkflow(env.MY_WORKFLOW)를 호출할 수 있습니다. 다음은 Workflow 인스턴스를 하나만 만드는 시나리오의 테스트 코드입니다.
it("workflow mock a non-violation score and be successful", async () => {
// CONFIG
await using introspector = await introspectWorkflow(env.MODERATOR);
await introspector.modifyAll(async (m) => {
await m.disableSleeps();
await m.mockStepResult({ name: "AI content scan" }, { violationScore: 0 });
});
await SELF.fetch(`https://mock-worker.local/moderate`);
const instances = introspector.get();
expect(instances.length).toBe(1);
// ASSERTIONS
const instance = instances[0];
expect(await instance.waitForStepResult({ name: "AI content scan" })).toEqual({ violationScore: 0 });
await expect(instance.waitForStatus("complete")).resolves.not.toThrow();
});
두 예제 모두 await를 사용하여 내성을 호출하고 있습니다. 이는 최신 JavaScript의 명시적 리소스 관리 구문입니다. 테스트가 끝날 때 자체 검사기 개체가 범위를 벗어나면 처리 메서드가 자동으로 호출되므로 여기에서 중요합니다. 이러한 방식으로 각 테스트가 격리된 자체 스토리지에서 작동하는지 확인합니다.
수정 및 수정모든 함수는 인스턴스 제어를 위한 관문입니다. 콜백 안에서 단계 결과, 이벤트를 모킹하고 절전 모드를 비활성화하는 등 동작을 삽입하는 메서드가 있는 수정자 객체에 액세스할 수 있습니다.
Workers Cloudflare 문서에서 자세한 문서를 찾아볼 수 있습니다.
Vitest를 Workflows 엔진에 연결한 방법
솔루션을 이해하려면 먼저 로컬 아키텍처를 이해해야 합니다. wrangler dev를 실행하면 Cloudflare Workers 테스트용 시뮬레이션 장치인 Miniflare와 workerd에 의해 Workflows가 구동됩니다. 실행 중인 각 워크플로우 인스턴스는 우리가 "Engine DO"라고 부르는 자체 SQLite Durable Object의 지원을 받습니다. 이 엔진 DO는 단계를 실행하고, 상태를 유지하며, 인스턴스의 수명 주기를 관리하는 역할을 합니다. 이 개체는 로컬로 격리된 Workers 런타임 내에 있습니다.
한편, Vitest 테스트 러너는 workerd 외부에 있는 별도의 Node.js 프로세스입니다. 이것이 바로 vitest-pool-workers라는 workerd 내에서 테스트를 실행할 수 있는 Vitest 커스텀 풀을 사용하는 이유입니다. Vitest-pool-workers에는 Runner Worker가 있는데, 이는 사용자 wrangler.json 파일에 지정된 모든 항목에 바인딩하여 테스트를 실행하는 워커입니다. 이 worker는 'cloudflare:test' 모듈에서 APIs에 액세스할 수 있습니다. WebSocket/RPC를 통해 Runner Object라는 특별한 DO를 통해 Node.js와 통신합니다.
우리가 가장 먼저 고려했던 접근 방식은 테스트 러너 worker를 사용하는 것이었습니다. 현재 상태에서 runner worker는 wrangler 파일에 정의된 Workflows의 Workflow 바인딩에 액세스할 수 있습니다. 또한 각 Workflow의 Engine DO 네임스페이스를 이 러너 worker에 바인딩하는 것도 고려했습니다. 이렇게 하면, 엔진 메서드를 직접 호출할 수 있는 바이테스트-풀-workers가 엔진 DO에 직접 액세스할 수 있습니다.
이 접근 방식은 유망하지만, Miniflare 및 vitest-ful-workers의 핵심을 바람직하지 않게 변경해야 했기 때문에 이 단일 기능을 사용하기에는 침입성이 너무 높았습니다.
먼저, Miniflare의 Durable Objects에 새로운 unsafe 필드를 추가해야 했습니다. 이것의 유일한 목적은 엔진의 서비스 이름을 지정하여 Miniflare가 기본 사용자 접두사를 적용하는 것을 방지하는 것입니다. 그렇지 않으면 지속형 객체를 찾을 수 없게 됩니다.
둘째, vitest-full-workers는 테스트되지 않은 Workflows를 포함하여 프로젝트의 Workflows로부터 모든 Engine DO를 러너에 바인딩해야 했습니다. 이렇게 하면 테스트 환경에 원치 않는 바인딩이 도입되어 사용자의 테스트 환경에 노출되지 않도록 추가 정리가 필요합니다.
혁신
해결책은 권한 있는 로컬 전용 APIs와 원격 프로시저 호출(RPC)의 조합을 이용하는 것입니다.
먼저, 안전하지 않은 함수 세트를 Workflows 바인딩의 로컬 구현에 추가했는데, 프로덕션 환경에서 사용할 수 없는 함수입니다. 이들은 테스트 환경에서 액세스할 수 있는 제어된 액세스 포인트로 기능하며, 테스트 실행자가 인스턴스 ID를 제공하여 특정 엔진 DO에 대한 스텁을 가져올 수 있도록 합니다.
테스트 러너가 이 스텁을 확보하면, RPC를 사용하여 WorkflowInstanceModifier라는 특수 RpcTarget을 통해 엔진 DO에서 특정 신뢰할 수 있는 메서드를 호출합니다. RpcTarget을 확장하는 모든 클래스는 스텁으로 대체된 개체가 있습니다. 이 스텁에서 메서드를 호출하면 다시 원래 개체로 RPC가 이루어집니다.
이 간단한 접근 방식은 Workflows 환경에만 국한되어 향후 모든 기능 변경 사항이 안전하게 격리되므로 침입이 훨씬 적습니다.
ID를 알 수 없는 Workflows 자체 검사
create() 또는 createBatch() 를 통해 Workflows 인스턴스를 만들 때 개발자는 특정 ID를 제공하거나 자동으로 생성하도록 할 수 있습니다. 이 ID는 Workflow 인스턴스를 식별한 다음 연결된 Engine DO ID를 생성하는 데 사용됩니다.
인스턴스 ID를 미리 알고 있기 때문에 구현을 위한 논리적 출발점은 introspectWorkflow 인스턴스(바인딩, 인스턴스ID)였습니다. 이를 통해 Cloudflare는 해당 Workflow 인스턴스와 연결된 엔진을 식별하는 데 필요한 Engine DO ID를 생성할 수 있습니다.
하지만 애플리케이션의 한 부분(예: HTTP 엔드포인트)에서 무작위로 생성된 ID로 Workflow 인스턴스를 생성하는 경우가 많습니다. 인스턴스를 생성한 후에야 ID를 알 수 있는데, 인스턴스를 어떻게 자체 검사할 수 있을까요?
해답은 JavaScript의 강력한 기능인 프록시 개체를 사용하는 것이었습니다.
introspectWorkflow(바인딩)을 사용하면 Workflow 바인딩이 프록시에 래핑됩니다. 이 프록시는 바인딩에 대한 모든 호출을 비파트적으로 가로채며, 특히 for.create() and .createBatch(). 테스트가 워크플로 생성을 트리거하면 프록시는 호출을 검사합니다. 이 인스턴스는 사용자가 제공했거나 무작위로 생성된 인스턴스 ID(사용자가 제공한 ID 또는 무작위로 생성된 인스턴스 ID)를 캡처하고 즉시 해당 ID에 대한 자체 검사를 설정하여 여러분이modifyAll 호출에서 정의한 모든 수정 사항을 적용합니다. 그러면 원래의 생성 호출이 정상적으로 진행됩니다.
env[workflow] = new Proxy(env[workflow], {
get(target, prop) {
if (prop === "create") {
return new Proxy(target.create, {
async apply(_fn, _this, [opts = {}]) {
// 1. Ensure an ID exists
const optsWithId = "id" in opts ? opts : { id: crypto.randomUUID(), ...opts };
// 2. Apply test modifications before creation
await introspectAndModifyInstance(optsWithId.id);
// 3. Call the original 'create' method
return target.create(optsWithId);
},
});
}
// Same logic for createBatch()
}
}
introspectWorkflow() 의 await 사용 블록이 완료되거나 테스트 종료 시 disuse() 메서드가 호출되면 자체 검사가 삭제되고 프록시가 제거되어 바인딩이 원래 상태로 유지됩니다. 이는 개발자 경험과 장기적인 유지 관리 가능성을 우선시하는 영향이 적은 접근 방식입니다.
Workflows에 테스트를 추가할 준비가 되셨나요? 시작하는 방법은 다음과 같습니다.
종속성 업데이트: @cloudflare/vitest-pool-workers 버전 0.9.0 이상을 사용하고 있는지 확인하세요. 프로젝트에서 다음 명령어를 실행하세요: npm install @cloudflare/vitest-pool-workers@latest
테스트 환경 구성: Workers에서 테스트하는 것이 처음이라면, 가이드에 따라 첫 번째 테스트를 작성하세요.
테스트 작성: 테스트 파일의 cloudflare:test에서 introspectWorkflowinstance 또는 introspectWorkflow를 가져오고 이 게시물에 표시된 패턴을 사용하여 Workflow의 동작을 조롱하고, 제어하며, 어설션합니다. 공식 API 참조도 확인하세요.