LangChain.js로 LLM 앱 만들기: Runnable과 LCEL, 문서 분할·임베딩, Pinecone·pgvector RAG
이 글의 핵심
LangChain.js는 TypeScript/JavaScript 생태계에서 LLM 호출, 프롬프트 구성, 검색 증강 생성(RAG), 벡터 스토어 연동, 스트리밍까지 한 흐름으로 묶어 주는 프레임워크입니다. 이 글에서는 핵심 개념(LCEL·Runnable)부터 Pinecone·Supabase 연동, Next.js App Router와의 결합, 실전 챗봇 패턴까지 단계적으로 정리합니다.
들어가며
LangChain.js는 JavaScript/TypeScript 환경에서 대규모 언어 모델(LLM) 애플리케이션을 구조화하기 위한 오픈소스 프레임워크입니다. 단순히 OpenAI 등의 SDK를 호출하는 것을 넘어, 프롬프트 템플릿, 체인(Chain), 검색기(Retriever), 벡터 스토어, 에이전트 등을 조합해 재사용 가능한 파이프라인으로 만드는 데 초점이 있습니다.
전제: Node.js 18+ 환경, TypeScript 사용, OpenAI(또는 호환 API) 키, Pinecone·Supabase는 해당 서비스 계정이 있다고 가정합니다. 패키지 버전은 릴리스마다 import 경로가 바뀔 수 있으므로, 공식 문서의 JS 통합 가이드와 함께 확인하는 것이 좋습니다.
LangChain.js란 무엇인가
Python 버전과의 관계
LangChain은 원래 Python 생태계에서 널리 사용되며, LangChain.js는 이를 JavaScript/TypeScript로 이식·확장한 구현입니다. 체인 구성, 프롬프트 관리, 문서 로더·분할기, 벡터 스토어 같은 개념은 대응하지만, 패키지 이름과 세부 API는 차이가 있으므로 Python 문서의 코드를 그대로 복사하면 동작하지 않는 경우가 많습니다. 새 프로젝트는 반드시 JS 전용 문서를 기준으로 합니다.
패키지 구조(개략)
실무에서는 보통 다음과 같이 역할이 나뉩니다.
| 영역 | 대표 패키지 | 역할 |
|---|---|---|
| 코어 | @langchain/core | Runnable, 메시지, 프롬프트, 출력 파서 등 추상화 |
| 통합 | @langchain/openai, @langchain/anthropic … | 각 LLM·임베딩 제공자 |
| 커뮤니티 | @langchain/community | 다양한 로더·벡터 스토어·도구 |
| 상위 체인 | langchain | 검색 체인, 에이전트 등 조합 레이어 |
| 전용 | @langchain/pinecone 등 | 특정 벡터 DB와의 얇은 어댑터 |
package.json에는 같은 메이저 계열로 맞추는 것이 안전합니다. 예: @langchain/core와 langchain의 버전 호환은 릴리스 노트를 확인합니다.
핵심 개념: Runnable과 LCEL
Runnable
LangChain.js의 실행 단위는 Runnable입니다. LLM, 프롬프트, 파서, 검색기는 모두 Runnable로 취급되며, .pipe()로 연결합니다. 이 패턴을 LCEL(LangChain Expression Language) 이라고 부릅니다.
모든 Runnable이 같은 인터페이스를 갖기 때문에, 작은 블록을 조립한 체인도 하나의 Runnable처럼 다룰 수 있습니다. 한 번 실행하는 invoke, 여러 입력을 한꺼번에 처리하는 batch, 부분 출력을 받는 stream과 중간 단계 이벤트까지 받는 streamEvents가 모두 Promise나 비동기 이터레이터를 돌려주므로, 체인을 조립한 뒤 실행 방식만 바꿔 쓸 수 있습니다. “한 번의 함수 호출”이 아니라 데이터가 왼쪽에서 오른쪽으로 흐르는 파이프로 생각하는 것이 LangChain.js의 기본 사고방식입니다.
메시지와 채팅 모델
챗봇에서는 HumanMessage, SystemMessage, AIMessage 등 채팅 메시지를 사용합니다. ChatOpenAI는 이러한 메시지 배열을 받아 응답 메시지를 반환합니다. 단순 문자열 입출력이 아니라 역할이 구분된 대화를 모델에 전달할 수 있어, 시스템 프롬프트·안전 지침을 안정적으로 넣을 수 있습니다.
설치와 기본 설정
프로젝트 루트에서 의존성을 추가합니다. 사용하는 벡터 DB에 맞춰 선택적으로 설치합니다.
npm install langchain @langchain/core @langchain/openai @langchain/community @langchain/textsplitters
# Pinecone 사용 시
npm install @langchain/pinecone @pinecone-database/pinecone
# Supabase 벡터 사용 시
npm install @supabase/supabase-js
환경 변수는 .env에 두고, Node에서는 process.env로 읽습니다. Next.js에서 NEXT_PUBLIC_ 접두사가 붙은 변수는 빌드할 때 클라이언트 번들에 문자열로 박히므로, API 키는 반드시 접두사 없이 두고 서버(라우트 핸들러·서버 컴포넌트·Server Action)에서만 읽어야 합니다.
프롬프트 템플릿과 체인
ChatPromptTemplate
변수 자리를 둔 프롬프트는 ChatPromptTemplate으로 정의합니다. 나중에 formatMessages 또는 체인에 넘길 입력 객체로 값이 채워집니다.
import { ChatOpenAI } from "@langchain/openai";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { StringOutputParser } from "@langchain/core/output_parsers";
const model = new ChatOpenAI({
model: "gpt-4o-mini",
temperature: 0.2,
});
const prompt = ChatPromptTemplate.fromMessages([
[
"system",
"당신은 {domain} 분야 전문가입니다. 간결한 한국어로 답하세요.",
],
["human", "{question}"],
]);
// LCEL: prompt → model → 문자열 파서
const chain = prompt.pipe(model).pipe(new StringOutputParser());
const answer = await chain.invoke({
domain: "클라우드 인프라",
question: "LangChain.js에서 LCEL이 의미하는 바를 한 문장으로 설명해 주세요.",
});
위 코드에서 pipe는 앞 단계의 출력 형식이 뒤 단계의 입력과 맞아야 합니다. ChatPromptTemplate은 메시지 목록을 만들고, ChatOpenAI는 메시지를 받아 AIMessage를 반환하며, StringOutputParser는 그중 텍스트만 뽑아 문자열로 넘깁니다. 이렇게 단계별 책임이 분리되면, 나중에 검색 단계만 끼워 넣어 RAG로 확장하기 쉽습니다.
체인이라는 말의 의미
문서에서는 “체인”이 순차 Runnable의 조합을 가리키는 경우가 많습니다. 예를 들어 “질문 분류 → 검색 → 답변 생성”은 세 개의 Runnable을 파이프로 이은 체인입니다. 별도의 LLMChain 클래스에 의존하기보다, LCEL 파이프가 최신 권장 패턴입니다.
문서 분할과 임베딩
RAG에 앞서 원본 텍스트를 적절한 크기의 청크로 나누고, 각 청크를 임베딩 벡터로 변환해 벡터 스토어에 넣습니다. 청크가 너무 크면 검색 정밀도가 떨어지고, 너무 작으면 문맥이 잘려 답변 품질이 나빠질 수 있습니다.
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
import { Document } from "@langchain/core/documents";
import { OpenAIEmbeddings } from "@langchain/openai";
const raw = "… 긴 문서 문자열 …";
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 1000,
chunkOverlap: 150,
});
const splits = await splitter.splitText(raw);
const docs = splits.map(
(text, i) =>
new Document({
pageContent: text,
metadata: { chunkIndex: i },
})
);
const embeddings = new OpenAIEmbeddings({
model: "text-embedding-3-small",
});
임베딩 모델은 검색 단계와 반드시 동일해야 합니다. 나중에 모델을 바꾸면 기존 인덱스는 호환되지 않으므로 재임베딩·재적재가 필요합니다.
Vector Store: Pinecone
Pinecone은 호스팅형 벡터 데이터베이스로, 인덱스·네임스페이스·메타데이터 필터링 등을 API로 다룹니다. LangChain.js에서는 PineconeStore로 연동합니다.
사전 준비: Pinecone 콘솔에서 인덱스를 만들고, 차원 수가 사용 중인 임베딩 모델의 차원과 일치하는지 확인합니다.
import { Pinecone as PineconeClient } from "@pinecone-database/pinecone";
import { PineconeStore } from "@langchain/pinecone";
import { OpenAIEmbeddings } from "@langchain/openai";
import type { Document } from "@langchain/core/documents";
const pinecone = new PineconeClient({
apiKey: process.env.PINECONE_API_KEY!,
});
async function buildPineconeStore(docs: Document[]) {
const embeddings = new OpenAIEmbeddings({ model: "text-embedding-3-small" });
const index = pinecone.Index("your-index-name");
return await PineconeStore.fromDocuments(docs, embeddings, {
pineconeIndex: index,
// namespace: "optional-ns",
});
}
인덱싱이 끝난 뒤에는 asRetriever로 검색기를 만들어 체인에 연결합니다. k 값은 가져올 문서 개수로, 너무 크면 프롬프트가 길어져 토큰 한도에 걸리기 쉽습니다.
Vector Store: Supabase(pgvector)
Supabase는 Postgres 위에 pgvector 확장을 제공합니다. 애플리케이션 데이터와 같은 DB에 벡터를 두고 싶을 때 유리합니다. LangChain.js에서는 SupabaseVectorStore를 사용합니다.
사전 준비: Supabase SQL 에디터에서 vector 확장과 테이블·함수를 문서대로 생성합니다. 임베딩 차원 컬럼(vector(1536) 등)은 모델에 맞춥니다.
import { createClient } from "@supabase/supabase-js";
import { OpenAIEmbeddings } from "@langchain/openai";
import { SupabaseVectorStore } from "@langchain/community/vectorstores/supabase";
import type { Document } from "@langchain/core/documents";
export async function buildSupabaseStore(docs: Document[]) {
const client = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY! // 서버 전용
);
const embeddings = new OpenAIEmbeddings({ model: "text-embedding-3-small" });
return await SupabaseVectorStore.fromDocuments(docs, embeddings, {
client,
tableName: "documents",
queryName: "match_documents",
});
}
보안: SERVICE_ROLE 키는 RLS를 우회하므로 클라이언트에 노출하면 안 됩니다. Next.js에서는 API Route나 Server Action 등 서버 측에서만 사용합니다. 공개 앱이라면 Supabase RLS와 anon 키 기반의 제한된 쿼리 설계를 검토합니다.
RAG 패턴 구현
RAG는 검색(Retrieval)과 생성(Generation)을 결합합니다. 흐름은 다음과 같습니다.
- 사용자 질문을 임베딩하거나, 질문 텍스트로 유사 문서를 검색
- 검색된
Document목록을 프롬프트에 주입 - LLM이 주어진 문맥만 근거로 답변(필요 시 인용·불확실성 표시)
버전별로 createRetrievalChain 같은 헬퍼 위치가 달라질 수 있으므로, 여기서는 LCEL만으로 2단계 RAG를 구성합니다. RunnablePassthrough.assign으로 검색 결과를 context에 넣으며, 같은 입력의 question을 프롬프트로 넘깁니다.
import { ChatOpenAI } from "@langchain/openai";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { StringOutputParser } from "@langchain/core/output_parsers";
import { RunnablePassthrough, RunnableSequence } from "@langchain/core/runnables";
import type { BaseRetriever } from "@langchain/core/retrievers";
function buildRagChain(retriever: BaseRetriever) {
const llm = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0 });
const prompt = ChatPromptTemplate.fromMessages([
[
"system",
"다음 컨텍스트만을 근거로 답하세요. 컨텍스트에 없으면 모른다고 말하세요.\n\n{context}",
],
["human", "{question}"],
]);
const retrievalChain = RunnableSequence.from([
RunnablePassthrough.assign({
context: async (input: { question: string }) => {
const docs = await retriever.invoke(input.question);
return docs.map((d) => d.pageContent).join("\n\n---\n\n");
},
}),
prompt,
llm,
new StringOutputParser(),
]);
return retrievalChain;
}
// 사용 예: const rag = buildRagChain(vectorStore.asRetriever({ k: 4 }));
// await rag.invoke({ question: "사용자 질문" });
시스템 지시에서 “컨텍스트에 없으면 모른다고 말하라”를 명확히 하면 근거 없는 답변이 줄어들지만 완전히 막지는 못하므로, 중요한 서비스라면 답변에 출처 청크를 함께 보여 주는 편이 좋습니다. k와 청크 크기는 서로 맞물려 있어(청크가 작으면 k를 늘려야 같은 양의 문맥이 들어감) 함께 조정해야 하고, 사용자별·제품별로 검색 범위를 제한해야 한다면 사용하는 벡터 스토어의 Retriever가 메타데이터 필터를 지원하는지 먼저 확인합니다.
스트리밍과 토큰 제한
스트리밍
챗봇 UX에서는 토큰이 생성되는 대로 화면에 붙이는 것이 일반적입니다. Runnable의 stream을 사용하면 청크 단위로 스트림을 받을 수 있습니다.
import { ChatOpenAI } from "@langchain/openai";
const model = new ChatOpenAI({
model: "gpt-4o-mini",
streaming: true,
});
const stream = await model.stream([
{ role: "user", content: "스트리밍 테스트로 짧은 시 한 수를 써 주세요." },
]);
for await (const chunk of stream) {
process.stdout.write(chunk.content?.toString() ?? "");
}
Next.js에서는 이 이터레이터를 ReadableStream으로 감싸 Response에 넣거나, Vercel AI SDK 등과 어댑터로 연결합니다. 중요한 것은 SSE/청크 인코딩 규약을 프론트엔드와 맞추는 것입니다.
토큰·컨텍스트 한도
모델마다 컨텍스트 길이와 출력 토큰 상한이 있습니다. ChatOpenAI 생성 시 maxTokens(또는 모델별 옵션)으로 출력 길이를 제한할 수 있습니다.
출력 상한과 별개로 입력 쪽도 관리해야 합니다. RAG에서는 검색 문서 총합과 질문, 지시문, 대화 이력을 합친 길이가 컨텍스트 한도와 출력 상한의 합을 넘지 않아야 합니다. 긴 대화 이력은 이전 구간을 요약하거나 최근 N턴만 남겨 잘라 내고, 비용과 지연을 줄이려면 검색 쿼리 재작성처럼 단순한 단계는 작은 모델에, 최종 답변만 큰 모델에 맡기는 구성을 고려합니다.
Next.js(App Router)와 통합
서버에서만 LangChain 실행
LangChain과 API 키는 서버에서 실행하는 것이 원칙입니다. App Router에서는 app/api/.../route.ts의 POST 핸들러에서 체인을 호출합니다.
// app/api/chat/route.ts (개념 예시)
import { NextResponse } from "next/server";
import { ChatOpenAI } from "@langchain/openai";
import { HumanMessage } from "@langchain/core/messages";
export const runtime = "nodejs"; // 일부 네이티브 의존성은 edge 대신 node 권장
export async function POST(req: Request) {
const { message } = await req.json();
const model = new ChatOpenAI({ model: "gpt-4o-mini" });
const res = await model.invoke([new HumanMessage(message)]);
return NextResponse.json({ reply: res.content });
}
스트리밍 응답
스트리밍 시에는 NextResponse에 ReadableStream을 넘기거나, 표준 Response를 반환합니다. 클라이언트는 fetch + ReadableStream 또는 이벤트 소스 패턴으로 읽습니다. CORS·버퍼링은 배포 환경(Vercel, Cloudflare 등)에 따라 다르므로 프로덕션에서 반드시 검증합니다.
실전 챗봇: 구조 잡기
최소 실전 구성은 다음과 같습니다.
- API 레이어: 질문 수신 → (선택) RAG 검색 → LLM 호출 → (스트리밍) 응답
- 상태: 세션 ID별로 대화 이력 저장(서버 메모리, Redis, DB)
- 가드레일: 입력 길이 제한, 금지 주제, PII 마스킹
대화 이력을 LangChain에 넣을 때는 ChatPromptTemplate에 MessagesPlaceholder로 히스토리를 끼우거나, RunnableWithMessageHistory 패턴(버전별 제공 여부 확인)을 사용합니다. 장기 세션에서는 요약 + 최근 메시지 조합이 비용 대비 효과가 좋습니다.
import { ChatPromptTemplate, MessagesPlaceholder } from "@langchain/core/prompts";
import { ChatOpenAI } from "@langchain/openai";
import { HumanMessage, AIMessage } from "@langchain/core/messages";
const chatPrompt = ChatPromptTemplate.fromMessages([
["system", "친절한 고객 지원 봇입니다. 정책 위반 요청은 거절합니다."],
new MessagesPlaceholder("history"),
["human", "{input}"],
]);
const model = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0.3 });
const chain = chatPrompt.pipe(model);
const history = [
new HumanMessage("환불 기간이 어떻게 되나요?"),
new AIMessage("구매일로부터 7일 이내 미사용 시 환불 가능합니다."),
];
const reply = await chain.invoke({
history,
input: "그럼 부분 환불은요?",
});
운영 단계에서는 LangSmith 등으로 트레이스를 남겨 프롬프트·검색 품질을 개선합니다.
운영할 때 주의할 점
LangChain.js는 릴리스가 잦고 패키지가 @langchain/core, 통합 패키지, langchain으로 나뉘어 있어, 서로 다른 버전의 @langchain/core가 중복 설치되면 “instanceof” 검사가 실패하는 식의 이상한 에러가 납니다. lockfile로 버전을 고정하고, 패키지 매니저의 overrides/resolutions로 @langchain/core를 하나로 맞추는 것이 안전합니다. Edge Runtime에서는 Node 전용 API에 의존하는 벡터 클라이언트가 동작하지 않을 수 있으므로 앞의 예제처럼 runtime = "nodejs"를 명시하거나 의존성 호환을 확인합니다. LLM 출력은 매번 달라지므로 체인 테스트는 정확한 문자열 비교보다, 출력 형식(JSON 스키마)이나 핵심 사실 포함 여부를 검사하는 회귀용 질문 세트로 만드는 편이 현실적입니다.
자주 묻는 질문 (FAQ)
Q. 임베딩 모델을 바꾸면 기존에 저장한 벡터는 그대로 써도 되나요?
A. 그대로 쓰면 안 됩니다. 임베딩 모델마다 벡터 차원과 의미 공간이 달라서, 다른 모델로 만든 질의 벡터와 기존 문서 벡터를 비교하면 유사도 검색 결과가 엉뚱해지거나 차원 불일치 오류가 납니다. 모델을 바꾸면 문서를 새 모델로 다시 임베딩해 별도 인덱스(또는 테이블)에 넣고, 검색 품질을 확인한 뒤 전환하는 것이 안전합니다.
참고 자료
같이 보면 좋은 글
- LangChain 1.x in Practice
- OpenAI ChatGPT API Basics: First Request, Streaming, Function Calling and Prompt Patterns