Published on

LangChain 스트리밍 중복·끊김 버그 해결 가이드

Authors

서버에서 LLM 응답을 스트리밍으로 흘려보내면 UX가 좋아지지만, 운영에 들어가면 꼭 한 번은 겪습니다.

  • 같은 문장이 두 번씩 붙는 중복 스트리밍
  • 문장 중간에서 끊기거나, 마지막이 비는 부분 응답(끊김)
  • 네트워크는 정상인데 프론트에서만 누락되는 렌더링/하이드레이션 이슈

이 글은 LangChain 기반(특히 JS/TS)에서 발생하는 스트리밍 중복·끊김을 원인별로 분해하고, 재현 가능한 체크리스트와 함께 안전한 스트리밍 파이프라인을 만드는 방법을 정리합니다.

참고로 에이전트를 쓰는 경우에는 툴콜이 연쇄로 발생하며 스트리밍이 더 복잡해집니다. 에이전트 무한루프/툴콜 폭주 방어는 별도 글인 LangChain 에이전트 무한루프·툴콜 폭주 차단법도 함께 보세요.

증상 패턴을 먼저 분류하자

현상은 비슷해 보여도 원인이 다릅니다. 아래 중 어디에 가까운지부터 확인하세요.

1) 토큰/문장이 중복된다

  • 같은 구간이 두 번 append 된다
  • 줄바꿈 단위로 동일한 chunk가 반복된다
  • 스트림 재연결 후 이전 chunk가 다시 온다

대표 원인:

  • 서버가 동일 요청을 두 번 처리(클라이언트 재시도, 라우트 중복 호출, React Strict Mode 개발환경)
  • LangChain 콜백을 두 군데에서 동시에 write
  • 프론트에서 setState(prev + chunk)동시에 여러 스트림이 건드림
  • SSE/Fetch 스트림 파서가 chunk 경계를 잘못 처리해 버퍼를 재방출

2) 중간에서 끊긴다(마지막이 비거나 문장 중간에서 종료)

  • finish_reason이 오기 전에 연결이 닫힘
  • 특정 길이에서 항상 끊김
  • 프록시(Nginx/ALB/Cloudflare) 뒤에서만 끊김

대표 원인:

  • 타임아웃(서버 함수/프록시 idle timeout)
  • 백프레셔 처리 미흡으로 write 실패 또는 flush 지연
  • 스트리밍을 Response.json() 같은 버퍼링 API로 감싸서 사실상 비스트리밍
  • 에러가 발생했는데 스트림에 에러 이벤트를 못 실어 조용히 종료

3) 서버 로그는 정상인데 화면만 중복/누락

대표 원인:

  • Next.js/React 쪽에서 Hydration/리렌더로 동일 메시지를 재적용
  • 클라이언트가 스트림을 취소(AbortController)했다가 재요청

이 경우 스트리밍 자체보다 UI 상태 관리가 원인인 경우가 많습니다. Next.js 렌더링 관련 이슈는 Next.js Hydration failed 원인 7가지와 해결도 같이 참고하세요.

재현 가능한 최소 예제로 진단하기

문제 해결의 핵심은 “LangChain이 중복을 보내는가?” vs “내 파이프라인이 중복을 만드는가?”를 가르는 겁니다.

아래는 Node.js에서 원본 스트림 이벤트를 그대로 로그로 남기는 최소 예제입니다.

import { ChatOpenAI } from "@langchain/openai";
import { HumanMessage } from "@langchain/core/messages";

const model = new ChatOpenAI({
  model: "gpt-4o-mini",
  temperature: 0,
});

async function debugStream() {
  const stream = await model.stream([new HumanMessage("한국어로 5줄 시를 써줘")]);

  let i = 0;
  for await (const chunk of stream) {
    const text = chunk.content ?? "";
    console.log(i++, JSON.stringify(text));
  }
}

debugStream().catch(console.error);
  • 여기서부터 중복이 보이면 LLM 공급자/SDK/네트워크 계층 문제 가능성이 있습니다.
  • 여기서는 정상인데, Next.js API 라우트나 프론트에서만 중복/끊김이면 전송/파싱/상태관리 문제입니다.

서버에서 중복이 생기는 6가지 흔한 원인과 해결

원인 1) 요청이 실제로 두 번 들어온다

가장 흔합니다. 특히 다음 상황에서 잘 발생합니다.

  • 클라이언트에서 버튼 더블클릭, 자동 재시도
  • Next.js 개발환경에서 특정 코드가 두 번 실행(Strict Mode는 주로 클라이언트지만, 로직 배치에 따라 혼동이 생깁니다)
  • 네트워크 레이어에서 재전송

해결: 요청 단위 idempotency 키를 두고 중복 처리 차단

  • 클라이언트가 x-idempotency-key를 보내고
  • 서버는 해당 키로 “이미 스트리밍 시작했는지”를 저장소(메모리/Redis)에서 체크

간단한 메모리 예시(단일 인스턴스에서만 유효):

const inflight = new Set<string>();

export async function handle(req: Request) {
  const key = req.headers.get("x-idempotency-key") ?? "";
  if (!key) return new Response("missing key", { status: 400 });

  if (inflight.has(key)) {
    return new Response("duplicate", { status: 409 });
  }

  inflight.add(key);
  try {
    // streaming...
    return new Response("ok");
  } finally {
    inflight.delete(key);
  }
}

운영에서는 Redis 같은 외부 저장소를 권장합니다. “중복 실행 방지” 자체는 사가/아웃박스에서 다루는 문제와 결이 같습니다. 중복 방지 관점은 Saga 패턴 보상 트랜잭션 중복 실행 방지법도 사고방식에 도움이 됩니다.

원인 2) LangChain 콜백과 stream을 동시에 사용해 이중 write

LangChain에서는 스트리밍을 받는 방법이 여러 개입니다.

  • model.stream(...)for await
  • 콜백(예: handleLLMNewToken)으로 토큰 이벤트 수신

둘을 동시에 붙여서 같은 res.write를 하면 중복이 됩니다.

해결: “토큰 소스”를 하나로 통일

  • 서버는 stream만 사용하거나
  • 콜백만 사용하거나

둘 중 하나만 선택하세요.

원인 3) 프록시/서버가 버퍼링해서 chunk 경계가 깨진다

SSE나 fetch 스트림은 chunk 경계가 보장되지 않습니다.

  • 한 토큰이 여러 chunk로 쪼개질 수 있고
  • 여러 토큰이 한 chunk로 합쳐질 수 있습니다.

여기서 “chunk를 줄 단위로 파싱” 같은 로직을 잘못 짜면, 이전 버퍼를 재사용하면서 중복이 생깁니다.

해결: 명확한 프레이밍을 가진 프로토콜을 사용

가장 쉬운 선택지는 SSE입니다. 이벤트 단위로 data:를 보내고, 클라이언트는 SSE 파서로 이벤트 단위로만 append 합니다.

Next.js(Route Handler)에서 안전하게 SSE 스트리밍하기

아래는 Next.js app 라우트 핸들러에서 SSE로 LangChain 스트리밍을 전달하는 예시입니다.

핵심 포인트:

  • 헤더에 text/event-stream, no-cache, keep-alive
  • 토큰을 data: ...\n\n 형태로 프레이밍
  • 종료 시 event: done을 명시
  • 에러도 이벤트로 보내서 클라이언트가 “끊김”과 “정상 종료”를 구분
import { ChatOpenAI } from "@langchain/openai";
import { HumanMessage } from "@langchain/core/messages";

export const runtime = "nodejs";

function sseData(payload: unknown) {
  return `data: ${JSON.stringify(payload)}\n\n`;
}

export async function POST(req: Request) {
  const { prompt } = await req.json();

  const model = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0 });

  const encoder = new TextEncoder();

  const stream = new ReadableStream<Uint8Array>({
    async start(controller) {
      try {
        const lcStream = await model.stream([new HumanMessage(String(prompt))]);

        for await (const chunk of lcStream) {
          const text = chunk.content ?? "";
          controller.enqueue(encoder.encode(sseData({ type: "token", text })));
        }

        controller.enqueue(encoder.encode(`event: done\n` + sseData({ ok: true })));
        controller.close();
      } catch (err: any) {
        controller.enqueue(
          encoder.encode(`event: error\n` + sseData({ message: err?.message ?? "unknown" }))
        );
        controller.close();
      }
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream; charset=utf-8",
      "Cache-Control": "no-cache, no-transform",
      Connection: "keep-alive",
    },
  });
}

끊김 방지 팁: 주기적 ping 이벤트

로드밸런서/프록시가 idle connection을 끊는 환경에서는, 토큰이 잠시 안 나오는 동안 연결이 끊길 수 있습니다.

이때는 10초~20초 간격으로 event: ping 같은 이벤트를 보내면 도움이 됩니다.

const ping = setInterval(() => {
  controller.enqueue(encoder.encode(`event: ping\n` + sseData({ t: Date.now() })));
}, 15000);

try {
  // stream loop...
} finally {
  clearInterval(ping);
}

클라이언트에서 중복이 생기는 5가지 흔한 원인과 해결

원인 1) 스트림을 두 번 열었다

  • 컴포넌트 마운트 시 자동 요청 + 버튼 클릭 요청
  • 의존성 배열 실수로 useEffect가 여러 번 실행

해결: 단일 진입점과 AbortController로 이전 요청 취소

let currentAbort: AbortController | null = null;

async function startStream(prompt: string, onToken: (t: string) => void) {
  if (currentAbort) currentAbort.abort();
  currentAbort = new AbortController();

  const res = await fetch("/api/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ prompt }),
    signal: currentAbort.signal,
  });

  const reader = res.body?.getReader();
  if (!reader) throw new Error("no body");

  const decoder = new TextDecoder();
  let buf = "";

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;

    buf += decoder.decode(value, { stream: true });

    // SSE 프레이밍: 빈 줄(\n\n) 기준으로 이벤트 분리
    const parts = buf.split("\n\n");
    buf = parts.pop() ?? "";

    for (const part of parts) {
      const line = part.split("\n").find((l) => l.startsWith("data: "));
      if (!line) continue;
      const json = line.slice("data: ".length);
      const msg = JSON.parse(json);
      if (msg.type === "token") onToken(msg.text);
    }
  }
}

포인트는 “chunk 단위”가 아니라 “이벤트 단위”로만 append 한다는 점입니다.

원인 2) 상태 업데이트 경쟁으로 중복 append

React에서 스트리밍 토큰을 받는 즉시 setText(text + token)처럼 쓰면 오래된 클로저를 잡아 중복/누락이 날 수 있습니다.

해결: 함수형 업데이트 사용

setText((prev) => prev + token);

또는 토큰을 버퍼에 쌓고 requestAnimationFrame이나 일정 주기(예: 50ms)로 합쳐서 렌더링하면 성능과 안정성이 좋아집니다.

원인 3) 라인 파싱 실수로 이전 버퍼를 다시 처리

위 클라이언트 예제처럼 buf를 유지할 때, 처리한 부분을 정확히 제거하지 않으면 같은 이벤트를 두 번 처리하게 됩니다.

  • split 후 마지막 조각을 buf로 되돌리는 패턴을 지키고
  • 이벤트 구분자를 반드시 명확히(예: \n\n) 하세요.

LangChain에서 “중복처럼 보이는” 정상 동작도 있다

가끔은 실제 중복이 아니라 다음 케이스입니다.

  • 모델이 문장을 생성하다가 스스로 수정하면서 비슷한 구절을 다시 말함
  • 툴콜 결과를 반영하는 과정에서 요약/재진술이 발생

이건 스트리밍 버그가 아니라 프롬프트/에이전트 설계 문제입니다. 특히 에이전트가 같은 도구를 반복 호출하면 출력이 반복될 수 있으니, 툴 호출 횟수 제한이나 중복 방지 가드가 필요합니다(관련 내용은 앞서 링크한 글 참고).

운영 체크리스트: 중복·끊김을 체계적으로 없애기

서버 체크

  • 스트림 write 경로가 하나인지(콜백과 stream 동시 사용 금지)
  • SSE라면 Content-Typeno-transform 설정
  • ping 이벤트로 idle timeout 방지
  • 에러를 이벤트로 전달하고 정상 종료와 구분
  • 요청 idempotency 키로 중복 실행 차단

클라이언트 체크

  • 스트림 요청이 한 번만 열리는지(이전 요청 abort)
  • 파서는 “chunk”가 아니라 “이벤트 프레임” 기준으로 처리하는지
  • 상태 업데이트는 함수형 업데이트인지
  • 개발환경에서만 발생하면 Strict Mode/리렌더의 영향인지 분리

마무리: 해결 순서는 “관측”부터

LangChain 스트리밍 문제는 대부분 LangChain 자체 버그라기보다,

  1. 요청이 중복 실행되거나
  2. 스트림 프레이밍이 불명확하거나
  3. 클라이언트 상태 업데이트가 경쟁 상태를 만들거나
  4. 인프라 타임아웃이 연결을 끊는

전송 계층 이슈인 경우가 많습니다.

가장 빠른 해결 루트는 다음 순서입니다.

  • LangChain 원본 스트림을 로컬에서 로그로 확인
  • 서버에서 SSE처럼 프레이밍을 강제
  • 클라이언트 파서를 이벤트 단위로 단순화
  • idempotency/ping/abort로 운영 환경 변수를 제거

이렇게 구성하면 “중복·끊김”이 재발할 여지를 구조적으로 줄일 수 있습니다.