Mastra Workflow로 병렬 실행·Human-in-the-Loop·멀티 에이전트를 TypeScript 하나로 연결하는 법
AI 파이프라인을 직접 짜본 분이라면 한 번쯤 이런 상황을 겪어봤을 겁니다. LLM 호출 3개를 순서대로 줄 세웠는데, 알고 보니 앞의 두 개는 서로 아무 의존 관계가 없어서 병렬로 돌려도 됐던 것이죠. 혹은 반대로, 자동화를 잔뜩 해놨더니 "이 결과는 사람이 봐야 할 것 같은데"라는 상황에서 멈출 방법이 없어 난감했던 경험도 있을 겁니다.
Mastra는 TypeScript-native AI 에이전트 프레임워크입니다. 워크플로우, 에이전트, RAG, 메모리를 하나의 패키지로 묶어두었는데, 그중 Workflow가 특히 흥미롭습니다. 실행 순서, 분기, 병렬성을 코드로 명확하게 정의하는 결정론적 파이프라인이거든요. Python 없이 TypeScript 하나로 AI 파이프라인 전체를 다룰 수 있다는 게 실무에서는 꽤 큰 의미입니다.
이 글에서는 병렬 실행(Parallel Execution), Human-in-the-Loop(HITL), 멀티 에이전트 오케스트레이션이라는 세 가지 패턴을 실제 코드로 살펴보고, 하나의 TypeScript 파이프라인 안에서 이것들을 어떻게 조합할 수 있는지 보여드립니다. TypeScript로 백엔드나 풀스택을 개발하면서 AI 파이프라인 구축에 관심 있는 분들이라면 바로 활용할 수 있을 겁니다.
목차
세 가지 패턴: 병렬 실행, HITL, 멀티 에이전트
병렬 실행 (Parallel Execution)
실제로 워크플로우를 짜다 보면, "이 두 스텝은 서로 아무 관계도 없는데 굳이 기다려야 하나?" 싶은 순간이 생각보다 자주 옵니다. 콘텐츠 생성 파이프라인에서 "주제 조사"와 "경쟁사 분석"은 완전히 독립적인 작업이니까요.
.parallel() API는 여러 스텝을 동시에 실행시키고, 모두 완료될 때까지 다음 단계로 넘어가지 않습니다.
import { createWorkflow } from '@mastra/core/workflows';
const contentWorkflow = createWorkflow({ id: 'content-pipeline' })
.addStep(fetchTopicStep) // 주제 받아오기
.parallel([researchStep, competitorAnalysisStep]) // 동시 실행
.addStep(editorialStep) // 두 결과 합산 후 편집
.commit();구버전(v0.3 이하)에서는 .after()로 분기를 표현했습니다. 현재도 동작하지만, 신규 프로젝트라면 .parallel()을 쓰는 것이 의도가 더 명확하게 드러납니다.
// 레거시 방식: .after()로 fan-out 표현
myWorkflow
.step(fetchTopicStep)
.then(researchStep)
.after(fetchTopicStep)
.step(competitorAnalysisStep)
.commit();주의: 병렬 블록 안에서 스텝 하나가 예외를 던지면 블록 전체가 실패합니다. 부분 실패를 허용하고 싶다면 각 스텝 내부에서
try/catch로 처리한 뒤 에러 상태를 반환값에 담아야 합니다.
Human-in-the-Loop (HITL)
자동화 파이프라인에서 가장 까다로운 지점이 여기입니다. "특정 조건이면 사람이 봐야 한다"는 요구사항은 흔하지만, 구현하려면 워크플로우를 어딘가에 멈춰두고, 외부 이벤트(HTTP 요청, UI 버튼 클릭 등)를 받은 뒤 재개해야 합니다.
Mastra는 suspend() / resume() 메커니즘으로 이를 처리합니다.
HITL 코드를 보기 전에 동작 원리를 먼저 이해하면 훨씬 읽기가 편합니다. suspend()를 호출하면 워크플로우가 멈추고, 나중에 resume()으로 재개될 때 동일한 execute 함수가 처음부터 다시 실행됩니다. 이때 resumeData가 채워진 상태로 들어오기 때문에, if (resumeData?.approved === undefined) 같은 분기로 "첫 실행인지, 재개인지"를 구분하는 것이 핵심 패턴입니다.
import { createStep, createWorkflow } from '@mastra/core/workflows';
import { z } from 'zod';
const approvalStep = createStep({
id: 'approval-step',
inputSchema: z.object({ content: z.string() }),
resumeSchema: z.object({
approved: z.boolean(),
feedback: z.string().optional(),
}),
suspendSchema: z.object({ reason: z.string() }),
async execute({ inputData, resumeData, suspend }) {
// 첫 실행: 아직 승인 여부가 없으면 일시정지
if (resumeData?.approved === undefined) {
await suspend({ reason: '검토자 승인이 필요합니다.' });
return;
}
// 재개 후: resumeData에 사람의 판단이 담겨 있음
if (!resumeData.approved) {
throw new Error(`거절됨: ${resumeData.feedback ?? '피드백 없음'}`);
}
return { result: inputData.content };
},
});재개는 외부에서 workflow.resume()을 호출합니다. HTTP 엔드포인트나 웹훅 핸들러에서 아래처럼 쓸 수 있습니다.
// 예: POST /approve 핸들러에서 호출
await workflow.resume({
runId: 'run-123',
stepId: 'approval-step',
resumeData: { approved: true, feedback: '확인 완료' },
});저도 처음엔 "그게 진짜 되나?" 싶었는데, 일시정지 시점의 전체 실행 상태가 스냅샷으로 스토리지에 저장되기 때문에 서버가 재시작되거나 배포가 이루어져도 워크플로우는 그대로 복원됩니다.
스냅샷(Snapshot):
suspend()를 호출하는 순간, 현재 실행 컨텍스트 전체(입력값, 중간 결과, 실행 포인터)가 직렬화되어 LibSQL이나 PostgreSQL 같은 영속 스토리지에 저장됩니다.resume()시 해당 스냅샷을 불러와 정확히 멈췄던 지점부터 재개합니다. 별도의 분산 워크플로우 인프라 없이 이게 기본 동작이라는 점이 꽤 실용적입니다.
멀티 에이전트 오케스트레이션
에이전트 하나가 모든 걸 다 하려 하면 금방 한계에 부딪힙니다. 컨텍스트가 너무 길어지거나, 전혀 다른 역할을 하나의 프롬프트에 우겨넣게 되거든요. Mastra는 Supervisor 패턴을 공식 API로 지원합니다. 슈퍼바이저 에이전트가 사용자 요청을 분석해서 전문 서브에이전트에게 위임하는 방식입니다.
import { Agent } from '@mastra/core/agent';
import { anthropic } from '@ai-sdk/anthropic';
// 전문 서브에이전트
const researchAgent = new Agent({
name: 'ResearchAgent',
instructions: '주어진 주제를 심층 조사하고 핵심 내용을 요약한다.',
model: anthropic('claude-sonnet-4-6'),
});
const writerAgent = new Agent({
name: 'WriterAgent',
instructions: '조사 결과를 바탕으로 독자 친화적인 블로그 포스트를 작성한다.',
model: anthropic('claude-sonnet-4-6'),
});
// 슈퍼바이저: agents 배열로 위임 대상 등록
const supervisorAgent = new Agent({
name: 'SupervisorAgent',
instructions: '사용자 요청을 분석하여 적절한 전문 에이전트에게 위임한다.',
model: anthropic('claude-opus-4-7'), // 라우팅 판단은 더 강력한 모델로
agents: [researchAgent, writerAgent],
});onDelegationStart 훅은 슈퍼바이저가 서브에이전트에게 위임하는 직전 시점을 가로챌 수 있습니다. 이 훅이 반환하는 값이 실제로 서브에이전트에 전달되는 메시지가 된다는 점이 중요합니다. 반환값을 바꾸면 서브에이전트가 받는 컨텍스트 자체가 바뀌는 것이므로, 아래처럼 메시지 수정이나 로깅 용도로 활용할 수 있습니다.
const result = await supervisorAgent.stream('AI 트렌드 블로그 글 작성해줘', {
onDelegationStart({ agent, messages }) {
console.log(`→ ${agent.name}에게 위임 시작`);
return messages; // 수정 없이 그대로 전달; 수정하려면 변환 후 반환
},
});워크플로우 안에서 에이전트를 스텝으로 직접 쓰는 것도 됩니다. 이때 mastra.getAgent()를 사용하는데, 이 에이전트가 Mastra 인스턴스에 미리 등록되어 있어야 합니다.
import { Mastra } from '@mastra/core';
import { createStep } from '@mastra/core/workflows';
// 에이전트를 Mastra 인스턴스에 등록
const mastra = new Mastra({
agents: { ResearchAgent: researchAgent },
});
const researchStep = createStep({
id: 'research-step',
inputSchema: z.object({ query: z.string() }),
outputSchema: z.object({ result: z.string() }),
async execute({ inputData, mastra }) {
const agent = mastra.getAgent('ResearchAgent'); // 등록된 에이전트 참조
const response = await agent.generate(inputData.query);
return { result: response.text };
},
});에이전트 네트워크: 슈퍼바이저가 서브에이전트를 단순히 "호출"하는 게 아니라, 서브에이전트도 다시 도구나 하위 에이전트를 가질 수 있습니다. 이렇게 계층 구조를 이루면 복잡한 작업을 책임 단위로 잘게 분리할 수 있습니다.
실전 적용
예시 1: 콘텐츠 생성 파이프라인 — 병렬 조사 + HITL 품질 검토
실무에서 자주 맞닥뜨리는 상황입니다. 주제 조사와 경쟁사 분석을 병렬로 돌리고, 편집 결과가 품질 기준에 미달하면 사람이 검토한 뒤 재개하는 흐름입니다.
import { createWorkflow, createStep } from '@mastra/core/workflows';
import { z } from 'zod';
// fetchTopicStep: 이 예시에서는 외부에서 주입된다고 가정 (스캐폴딩 혹은 별도 파일)
// 최소 stub 예시:
// const fetchTopicStep = createStep({
// id: 'fetch-topic', inputSchema: z.object({ keyword: z.string() }),
// outputSchema: z.object({ topic: z.string() }),
// async execute({ inputData }) { return { topic: inputData.keyword }; },
// });
// 1. 병렬로 실행될 두 스텝
const researchStep = createStep({
id: 'research',
inputSchema: z.object({ topic: z.string() }),
outputSchema: z.object({ summary: z.string() }),
async execute({ inputData, mastra }) {
const agent = mastra.getAgent('ResearchAgent');
const res = await agent.generate(`${inputData.topic}에 대해 조사해줘`);
return { summary: res.text };
},
});
const competitorStep = createStep({
id: 'competitor-analysis',
inputSchema: z.object({ topic: z.string() }),
outputSchema: z.object({ analysis: z.string() }),
async execute({ inputData, mastra }) {
const agent = mastra.getAgent('ResearchAgent');
const res = await agent.generate(`${inputData.topic} 관련 경쟁사 현황 분석`);
return { analysis: res.text };
},
});
// assessQuality: 초안 품질을 0~1 사이 점수로 반환하는 함수.
// 실제 구현은 LLM 평가, 규칙 기반 체크(길이, 키워드 밀도 등) 등 프로젝트에 따라 다름.
// 아래는 글자 수 기반 간단한 더미 예시:
function assessQuality(text: string): number {
if (text.length < 300) return 0.4;
if (text.length < 800) return 0.6;
return 0.85;
}
// 2. 편집 + 품질 검토(HITL 포함) 스텝
const editorialStep = createStep({
id: 'editorial',
inputSchema: z.object({ summary: z.string(), analysis: z.string() }),
resumeSchema: z.object({ approved: z.boolean(), feedback: z.string().optional() }),
suspendSchema: z.object({ draft: z.string(), reason: z.string() }),
async execute({ inputData, resumeData, suspend, mastra }) {
// 재개 경로: 사람이 판단을 내린 경우
if (resumeData !== undefined) {
if (!resumeData.approved) {
// 이 에러는 워크플로우 전체를 실패 상태로 만듭니다.
// 재시도 로직이 필요하다면 워크플로우 레벨에서 별도 처리가 필요합니다.
throw new Error(`편집 거절: ${resumeData.feedback}`);
}
return { published: true };
}
// 초기 실행: 초안 생성
const agent = mastra.getAgent('WriterAgent');
const draft = await agent.generate(
`조사 내용: ${inputData.summary}\n경쟁사 분석: ${inputData.analysis}\n\n이를 바탕으로 블로그 초안을 작성해줘`
);
const qualityScore = assessQuality(draft.text);
// 품질 기준 미달 시 사람 검토 요청
if (qualityScore < 0.7) {
await suspend({ draft: draft.text, reason: `품질 점수 미달: ${qualityScore}` });
return;
}
return { published: true, draft: draft.text };
},
});
// 3. 워크플로우 조립
const contentPipelineWorkflow = createWorkflow({ id: 'content-pipeline' })
.addStep(fetchTopicStep)
.parallel([researchStep, competitorStep]) // 병렬 실행
.addStep(editorialStep) // 합산 후 편집 + HITL
.commit();| 구성 요소 | 역할 |
|---|---|
.parallel([researchStep, competitorStep]) |
두 스텝을 동시 실행, 모두 완료 후 다음으로 진행 |
resumeData !== undefined 분기 |
재개 여부를 구분하는 핵심 패턴 |
suspend({ draft, reason }) |
실행 상태 전체를 스냅샷으로 저장하고 대기 |
assessQuality(draft.text) < 0.7 |
HITL 트리거 조건 — 명확한 임계값으로 자동/수동 분기 |
예시 2: 멀티 에이전트 고객 지원 — Supervisor 위임 + 민감 정보 마스킹
앞의 예시가 콘텐츠 생성 도메인이었다면, 이번엔 고객 지원으로 도메인을 바꿔보겠습니다. 고객 문의를 분류해서 기술 지원, 결제, 환불 전문 에이전트에 위임하는 패턴입니다.
솔직히 이런 구조를 직접 짜려면 라우팅 로직, 컨텍스트 전달, 에러 처리가 뒤섞여서 금방 복잡해지는데, Supervisor 패턴으로 상당히 깔끔하게 정리됩니다.
import { Agent } from '@mastra/core/agent';
import { anthropic } from '@ai-sdk/anthropic';
const techSupportAgent = new Agent({
name: 'TechSupportAgent',
instructions: '기술적인 문제를 진단하고 해결 방법을 안내한다.',
model: anthropic('claude-sonnet-4-6'),
});
const billingAgent = new Agent({
name: 'BillingAgent',
instructions: '결제 관련 문의를 처리한다. 개인정보는 마스킹된 형태로만 다룬다.',
model: anthropic('claude-sonnet-4-6'),
});
const refundAgent = new Agent({
name: 'RefundAgent',
instructions: '환불 요청을 검토하고 처리 절차를 안내한다.',
model: anthropic('claude-sonnet-4-6'),
});
const supportSupervisor = new Agent({
name: 'SupportSupervisor',
instructions: `고객 문의를 분석하여 적절한 전문 에이전트에게 위임한다.
- 기술 문제 → TechSupportAgent
- 결제 문의 → BillingAgent
- 환불 요청 → RefundAgent`,
model: anthropic('claude-opus-4-7'), // 라우팅 판단은 강력한 모델이 맡고
agents: [techSupportAgent, billingAgent, refundAgent],
});
// onDelegationStart의 반환값이 서브에이전트에 실제로 전달되는 메시지입니다.
// 결제/환불 에이전트에는 카드번호를 마스킹해서 전달합니다.
const result = await supportSupervisor.stream(customerMessage, {
onDelegationStart({ agent, messages }) {
if (['BillingAgent', 'RefundAgent'].includes(agent.name)) {
return messages.map((msg) => ({
...msg,
content:
typeof msg.content === 'string'
? msg.content.replace(/\d{4}-\d{4}-\d{4}-\d{4}/g, '****-****-****-****')
: msg.content,
}));
}
return messages;
},
});| 구성 요소 | 역할 |
|---|---|
agents: [...] |
슈퍼바이저가 위임할 수 있는 에이전트 목록 |
onDelegationStart 반환값 |
이 훅이 반환하는 메시지 배열이 서브에이전트에 그대로 전달됨 |
| 카드번호 정규식 마스킹 | 민감 데이터가 서브에이전트 컨텍스트에 노출되지 않도록 차단 |
| 슈퍼바이저 모델 = Opus 4.7 | 라우팅 판단은 더 강력한 모델, 실행은 효율적인 모델로 분리 |
장단점 분석
써보면서 체감한 장점들을 정리하면 이렇습니다.
장점
| 항목 | 내용 | 참고 |
|---|---|---|
| TypeScript 네이티브 | Python 없이 풀스택 TypeScript로 AI 파이프라인 구성 가능 | 백엔드·프론트엔드 팀이 같은 코드베이스 공유 |
| 타입 안전성 | Zod 스키마로 스텝 입출력 검증, 런타임 오류를 조기에 발견 | inputSchema, outputSchema 강제 |
| 상태 영속성 | 스냅샷 기반 suspend/resume, 서버 재시작 후에도 워크플로우 복원 | LibSQL, PostgreSQL 지원 |
| 배포 속도 | npx mastra deploy로 Vercel 배포가 빠르게 완료 |
Cloudflare Workers도 지원 |
| Supervisor 패턴 공식 지원 | stream() / generate() 레벨에서 에이전트 위임을 직접 선언 가능 |
v0.4+ 공식 API |
다만 아직 눈에 걸리는 지점도 분명 있습니다.
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| API 불안정 | v0.3 → v0.4 워크플로우 API가 대규모 변경됨 | 버전 고정 후 마이그레이션 가이드 꼼꼼히 확인 |
| 병렬 실패 전파 | 병렬 블록 중 하나만 실패해도 전체 블록 중단 | 각 스텝 내부에서 try/catch로 부분 실패 처리 |
| 생태계 미성숙 | 서드파티 통합 50~60개 (LangChain 수백 개 대비) | MCP를 통한 외부 툴 연결로 상당 부분 보완 가능 |
| 문서 공백 | 메모리 영속성 등 고급 기능은 공식 문서보다 Discord 의존 | 공식 Discord와 GitHub Issues를 병행 참고 |
| Python 미지원 | Python 생태계(scikit-learn 등) 직접 활용 불가 | Python 도구는 MCP 서버나 별도 마이크로서비스로 노출 |
MCP (Model Context Protocol): Anthropic이 제안한 표준 프로토콜로, AI 에이전트가 외부 툴·데이터 소스에 일관된 방식으로 연결할 수 있게 해줍니다. Mastra는 MCP를 통해 Python 기반 도구도 간접적으로 활용할 수 있습니다. 생태계 격차를 상당 부분 메워주는 경로입니다.
실무에서 가장 흔한 실수
-
resumeData분기를 빠뜨리는 것 —suspend()이후 재개될 때execute()가 처음부터 다시 호출됩니다.resumeData유무를 체크하지 않으면 무한 루프처럼 동작합니다.if (resumeData !== undefined)분기가 HITL 스텝의 필수 패턴입니다. -
병렬 블록에서 부분 실패를 그냥 두는 것 — 외부 API 호출 스텝이 병렬 블록에 있으면 타임아웃이나 네트워크 오류로 전체 블록이 멈출 수 있습니다. 중요도가 낮은 스텝은 내부에서 오류를 삼키고 빈 결과를 반환하도록 처리해두는 것이 안전합니다. 앞서 설명한 병렬 실패 전파 동작과 맥락이 연결되는 지점입니다.
-
슈퍼바이저와 서브에이전트 모두 최고 성능 모델 쓰기 — 라우팅·판단은 Opus 같은 강력한 모델이 맡고, 실행은 Sonnet 계열로 나누면 성능과 비용을 동시에 잡을 수 있습니다. 예시 코드에서도 의도적으로 이렇게 분리해두었습니다.
마치며
Mastra Workflow는 병렬 실행, 사람 개입, 멀티 에이전트 위임이라는 세 가지 복잡한 문제를 하나의 TypeScript 코드베이스 안에서 일관된 방식으로 다룰 수 있게 해줍니다.
아직 API가 안정화 단계에 있고 생태계도 성숙하는 중이라 프로덕션 도입 시 버전 관리에 신경 써야 하는 건 사실입니다. 그럼에도 TypeScript 기반 팀이 AI 파이프라인을 Python 없이 직접 구축·운영하고 싶다면, 현시점에서 가장 현실적인 선택지 중 하나입니다.
직접 시작해볼 수 있는 경로를 단계별로 정리해보면 이렇습니다.
- 설치 및 프로젝트 초기화 —
npx create-mastra@latest로 프로젝트를 생성해볼 수 있습니다. 에이전트 하나와 기본 워크플로우가 포함된 스캐폴딩이 함께 만들어집니다. - HITL 패턴 체험 — 위에서 소개한
approvalStep코드를 붙여넣고pnpm mastra dev로 Mastra Studio를 띄워보시면 됩니다. Studio에서 suspend/resume을 GUI로 직접 테스트할 수 있어서 동작 원리를 이해하는 데 도움이 됩니다. - 병렬 실행 추가 — 이미 순차적으로 동작하는 워크플로우가 있다면, 의존 관계 없는 스텝 두 개를
.parallel([stepA, stepB])로 감싸볼 수 있습니다. 실행 시간이 어떻게 달라지는지 Mastra Studio의 타임라인 뷰에서 확인해볼 수 있습니다.
참고 자료
- Workflows 개요 | Mastra 공식 문서
- Control Flow (병렬 실행) | Mastra 공식 문서
- Human-in-the-Loop | Mastra 공식 문서
- Suspend & Resume | Mastra 공식 문서
- Supervisor Agents | Mastra 공식 문서
- Multi-agent Systems 개념 | Mastra 공식 문서
- Supervisor Agent 예제 | Mastra
- Human in the Loop 예제 | Mastra
- Changelog 2026-02-26 | Mastra 블로그
- Mastra Agent Workflow: Human In The Loop, Suspend and Resume | Medium
- Orchestrating Agents with Mastra Workflows (Why I Stopped Using Temporal) | Substack
- AI Agent Framework 비교 | Speakeasy
- Mastra in 2026: What It Is, When to Use It, and How It Compares | DEV.to
- Mastra Agents with Memory Sharing | Trigger.dev
- mastra-ai/mastra | GitHub