AI 파이프라인 네 레이어 연결해 코드젠 SSE 스트리밍 완성
목차
Week 3 D1~D3 작업을 한 번에 머지했다. AI 파이프라인 end-to-end 연결 작업이었는데, 생각보다 물려 있는 레이어가 많아서 사흘치를 하나의 커밋으로 묶었다.
왜 한 덩어리로 묶었나
D1, D2, D3를 별도로 커밋하는 게 원칙이지만, 이번엔 파이프라인이 중간 상태로 머지되면 팀 전체 dev 환경이 broken 상태로 남는 구조였다. LLM 패키지 → runs API → SSE 스트림 → 클라이언트 렌더링까지 네 레이어가 모두 맞물려야 처음으로 E2E가 동작하는 구조라, "절반만 올라간" 상태는 팀원 누가 봐도 동작 확인이 불가능하다.
팀 리딩 관점에서 이런 선택은 항상 트레이드오프다. 짧은 커밋 단위를 포기하는 대신 "팀원이 dev 브랜치를 pull 했을 때 반드시 실행 가능한 상태"를 보장하는 쪽을 택했다. 코드 리뷰어 입장에서도 파이프라인 흐름 전체를 한 PR에서 읽을 수 있으니 컨텍스트 스위칭이 줄어든다. 다음번엔 각 레이어를 좀 더 독립적으로 stub 처리해서 단계별 머지가 가능하게 설계하는 게 숙제다.
변경 파일별 역할
| 파일 | 레이어 | 주요 역할 |
|---|---|---|
packages/llm/package.json |
LLM 패키지 | AI 모델 연동 의존성 정의 |
apps/web/src/app/api/projects/[projectId]/runs/route.ts |
API — Run 생성 | 프로젝트 기준 코드젠 실행 요청 처리 |
apps/web/src/app/api/runs/[runId]/stream/route.ts |
API — SSE 스트림 | 실행 결과를 클라이언트에 실시간 전달 |
apps/web/src/app/projects/[projectId]/builder-client.tsx |
클라이언트 | 스트림 수신 후 UI에 코드 렌더링 |
apps/web/package.json |
앱 의존성 | 패키지 참조 정렬 |
md/DAILY/2026-05-25.md |
일지 | 작업 맥락 기록 |
핵심 흐름은 runs route → stream route → builder-client 세 구간이다. runs route에서 LLM 실행을 트리거하고, 결과를 ReadableStream으로 내보낸 뒤 stream route가 SSE로 감싸서 내려보낸다. builder-client는 EventSource 혹은 fetch + reader로 수신하면서 코드 블록을 점진적으로 그린다.
// stream route 패턴 (간략화)
export async function GET(req: Request, { params }: { params: { runId: string } }) {
const stream = new ReadableStream({
async start(controller) {
for await (const chunk of runLLMStream(params.runId)) {
controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`));
}
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
이 패턴에서 제일 신경 쓴 건 에러 핸들링이다. LLM 스트림 중간에 네트워크가 끊기거나 모델이 에러를 뱉으면 클라이언트가 error 이벤트를 제대로 받아야 한다. 스트림이 그냥 닫혀버리면 클라이언트는 무한 대기에 빠지거나 빈 화면만 보게 된다. controller.error()로 명시적으로 끝내는 경로를 모든 예외 분기에 넣었다.
SSE vs WebSocket, 그리고 이번 선택의 이유
코드젠 스트리밍에서 SSE와 WebSocket 중 뭘 쓸지는 팀 내에서도 한 번 얘기가 나왔다. 정리하면 이렇다.
- SSE — 단방향, HTTP 그대로, 재연결 내장, Next.js Route Handler에서 바로 쓸 수 있음
- WebSocket — 양방향, 별도 서버 필요, 실시간 상호작용이 많아질 때 유리
지금 단계에서 클라이언트 → 서버 방향의 실시간 메시지가 없고, 빌드 진행 상황을 일방적으로 흘려보내는 구조라 SSE로 충분하다. WebSocket은 나중에 "실행 중 취소" 같은 인터랙션이 붙을 때 재검토할 것 같다.
회고
사흘치 작업을 하면서 제일 시간을 잡아먹은 건 패키지 간 타입 정렬이었다. packages/llm에서 올라오는 스트림 청크 타입이 runs route에서 쓰는 타입과 미묘하게 달라서, 런타임엔 문제없이 돌다가 타입 체크에서 깨지는 상황이 반복됐다. 모노레포에서 패키지 경계를 넘는 타입은 초반에 잡아두는 게 맞다는 걸 다시 느꼈다. 리뷰 때 "이 타입 어디서 오는 거야?" 질문이 나오면 설계가 명확하지 않다는 신호다.
builder-client.tsx가 지금은 스트림 수신 + 렌더링을 한 컴포넌트에서 다 하는 상태인데, 다음 주에 이 부분을 커스텀 훅으로 분리할 예정이다. 비즈니스 로직이 UI 컴포넌트 안에 얽히면 테스트도 어렵고 나중에 다른 팀원이 건드릴 때 진입 장벽이 생긴다.
다음
🛒 이 글과 어울리는 추천 상품
*위 링크는 쿠팡파트너스 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.
댓글 0
첫 댓글 달아줘.