TL;DR — LCEL(LangChain Expression Language)은 prompt | model | parser처럼 | 로 컴포넌트를 잇는 선언적 합성입니다. 잇는 모든 조각이 Runnable이라 invoke · batch · stream · astream_events라는 동일한 메서드를 노출합니다. 거기에 with_retry · with_fallbacks · 캐싱을 얹어 운영 코드로 만듭니다. 순환·동적 분기가 필요하면 LCEL이 아니라 LangGraph(3편)로 갑니다. 옛 자료의 LLMChain · SequentialChain은 1.0에서 langchain-classic으로 옮겨졌습니다. 신규 코드는 LCEL을 씁니다.
이 글은 1편에서 본 파이프(|)를 제대로 쓰고 싶은 개발자가 LCEL이 무엇이고 Runnable 인터페이스가 왜 가치인지를 이해하고, 실행·합성·운영(폴백·재시도·캐싱)·LangGraph 전환 기준까지 손에 쥐도록 돕습니다.
이 글에서 다루는 것
- LCEL 파이프 기본과 Runnable 표준 인터페이스(invoke/batch/stream/astream_events)
- 조합 빌딩 블록(Sequence/Parallel/Passthrough.assign/Branch/Lambda)과 자동 타입 강제
- 운영용 합성(with_fallbacks/with_retry/configurable/bind)과 캐싱
- LCEL vs LangGraph 선택 기준과 레거시 체인 폐기 지도
이 글에서 다루지 않는 것
- LangGraph 상태 그래프·HITL·영속성 상세 (3편에서)
- create_agent·미들웨어·RAG·LangSmith (4~6편에서)
1. 왜 LCEL인가 — 파이프 한 줄의 정체
1편 마지막에서 첫 코드를 chain = prompt | model 한 줄로 이었습니다. 그때는 "옛 LLMChain 대신 이렇게 쓴다"만 짚고 넘어갔는데, 이번 편의 주제가 바로 그 파이프(|)입니다. 이 한 글자가 무엇을 하는지, 그리고 왜 LangChain 1.0이 이걸 표준으로 삼았는지를 봅니다.
LCEL(LangChain Expression Language)은 | 로 컴포넌트를 잇는 선언적 합성입니다. 단순~중간 규모 오케스트레이션의 표준입니다. 순환이나 동적 분기가 필요해지면 LangGraph(3편)로 넘어갑니다. 즉 LCEL은 "끝판왕"이 아니라 적정 도구이고 그 경계를 아는 것이 8절의 핵심입니다.
비유하자면 LCEL은 유닉스 셸의 파이프와 같습니다. cat | grep | sort에서 각 명령이 표준 입출력만 맞으면 자유롭게 연결되듯, LCEL에서는 모든 조각이 Runnable이라는 같은 규격을 지켜서 자유롭게 이어집니다. 파이프가 강력한 진짜 이유는 | 기호가 아니라, 양쪽 끝의 규격이 같다는 데 있습니다. 그래서 3절의 Runnable이 이 글의 중심입니다.
2. LCEL 파이프 기본 — prompt | model | parser
가장 기본형은 프롬프트 → 모델 → 파서를 파이프로 잇는 것입니다. 코드를 보기 전에 실행 환경부터 정리합니다.
환경
- 언어/런타임: Python 3.10 이상(1.x 요구)
- 핵심: langchain · langchain-core · 파트너 패키지(예: langchain-openai)
- 주요 import: langchain_core.runnables · langchain_core.output_parsers
| 로 이으면 LangChain은 이를 하나의 RunnableSequence로 묶습니다. 입력은 첫 조각(prompt)으로 들어가 모델을 거쳐 파서로 흐릅니다. 이때 각 단계의 출력이 다음 단계의 입력이 됩니다. 여기서 StrOutputParser는 모델의 메시지 응답을 마지막에 순수 문자열로 뽑아내는 역할입니다.
한 가지 짚을 점은 파서의 위상입니다. StrOutputParser · JsonOutputParser는 langchain-core에 여전히 유효합니다(파이프 끝의 텍스트화·관대한 JSON 파싱에 정상). 다만 "스키마 강제·검증"이 목적이라면 파서로 후처리하지 말고 1편에서 본 with_structured_output이 표준입니다. 둘은 경쟁이 아니라 용도가 다릅니다.
3. Runnable 표준 인터페이스 ★
LCEL의 실질 가치는 파이프 기호가 아니라, 파이프로 묶은 결과물도 다시 하나의 Runnable이라는 데 있습니다. 모든 컴포넌트(Runnable)는 아래 메서드를 동일하게 노출합니다. 모델 하나든, 파이프로 묶은 거대한 체인이든 똑같이 부릅니다.
| 메서드 | 용도 |
|---|---|
| invoke / ainvoke | 단건 실행(동기/비동기) |
| batch / abatch | 병렬 다건(max_concurrency) |
| stream / astream | 토큰 스트리밍 |
| astream_events(version="v2") | 체인 내부 단계별 이벤트(토큰·도구·검색) — 풍부한 UX/디버깅 |
이 표가 LCEL을 쓰는 진짜 이유입니다. 한 번 체인을 만들면 동기 호출(invoke)·비동기·대량 병렬(batch)·스트리밍이 코드 수정 없이 전부 따라옵니다. 특히 astream_events는 최종 출력만이 아니라 체인 내부의 모델 토큰·도구 호출·검색 이벤트까지 흘려줘, 단계가 보이는 UI나 디버깅에 씁니다.
위 루프는 체인을 거치는 동안 발생하는 이벤트를 받아, 그중 모델 토큰 이벤트(on_chat_model_stream)만 골라 한 글자씩 출력합니다. 모든 실행에는 config= 인자로 RunnableConfig(tags · callbacks · metadata · run_name · max_concurrency)를 넘겨 추적·필터링을 붙일 수 있습니다. 이 추적 정보가 6편의 LangSmith로 이어집니다.
⚙️ 스트리밍 전파 주의 — astream_events로 내부 토큰을 받으려면 체인의 모든 단계가 스트리밍을 지원해야 합니다. 중간에 비스트리밍 단계(일부 파서·동기 함수)가 끼면 토큰이 버퍼링돼 한꺼번에 옵니다. 또 토큰 텍스트만 뽑을 땐 .content 대신 .text를 쓰면 멀티모달·콘텐츠블록 응답에서도 안전하게 문자열을 얻습니다.
4. 조합 빌딩 블록 — 순차·병렬·주입·분기
파이프(|)는 순차 연결 하나만 있는 게 아닙니다. LCEL은 몇 가지 프리미티브(Runnable)를 조합해 순차·병렬·입력 보존·조건 분기를 표현합니다.
| 프리미티브 | 동작 |
|---|---|
| RunnableSequence ( a | b ) | 순차 — 출력 → 입력 |
| RunnableParallel ( dict 자동변환 ) | 동시 — 같은 입력을 여러 갈래로 |
| RunnablePassthrough / .assign() | 입력 보존 + 새 키 추가 (RAG context 주입 표준) |
| RunnableBranch | 조건 라우팅 (레거시 RouterChain 대체) |
| RunnableLambda(func) / @chain | 함수 → Runnable 승격(추적 보존) |
이 다섯이 LCEL 합성의 어휘입니다. 순차는 파이프로, 병렬은 dict로, 입력에 새 값을 얹는 건 RunnablePassthrough.assign()으로 표현합니다. 특히 .assign()은 원래 입력을 보존한 채 새 키(예: 검색한 context)를 끼워 넣는 방식이라, RAG에서 컨텍스트를 주입하는 표준 패턴입니다(상세는 5편). RunnableBranch는 옛 RouterChain을 대체하는 조건 분기인데, 여기에는 한계가 있습니다 — 순환이나 다단계 동적 라우팅은 못 합니다. 그 지점이 LangGraph로 넘어가는 신호입니다.
5. 자동 타입 강제 — dict와 함수를 그냥 넣어도 된다
LCEL을 쓰다 보면 "왜 평범한 dict나 함수를 파이프에 그냥 끼워 넣어도 동작하지?"라는 의문이 생깁니다. 이는 LCEL의 자동 타입 강제 덕분입니다.
{"a": ..., "b": ...}를 넣으면 자동으로 각 값이 같은 입력을 받아 동시 실행되는 병렬 갈래가 됩니다.RunnableLambda로 승격되어, 추적이 보존된 채 체인의 한 단계가 됩니다.덕분에 RunnableParallel(...)이나 RunnableLambda(func)로 매번 감싸지 않고도 dict·함수를 파이프에 직접 둘 수 있습니다. 다만 "자동으로 변환된다"는 건 편의이지 마법이 아닙니다. 변환 규칙(dict는 병렬, 함수는 Lambda)을 모르고 쓰면 의도와 다른 그래프가 만들어질 수 있으니, 변환 결과가 무엇인지는 알고 쓰는 편이 안전합니다.
6. 운영용 합성 — 재시도 · 폴백 · 런타임 설정
개념용 파이프와 운영용 파이프의 차이는 실패를 어떻게 다루느냐입니다. Runnable에는 합성 메서드가 붙어 있어, 체인을 고치지 않고도 재시도·폴백·런타임 설정을 얹을 수 있습니다.
with_retry는 일시적 오류에 정해진 횟수만큼 다시 시도합니다 — 운영에서는 wait_exponential_jitter=True로 지수 백오프+지터를 켭니다. 재시도 대상은 RateLimit·타임아웃 같은 일시 오류로 좁혀 장애 증폭(thundering herd)을 막는 것이 표준입니다. with_fallbacks는 그래도 실패하면 대체 모델로 넘어갑니다. configurable_fields / configurable_alternatives는 런타임에 파라미터나 구현 자체를 갈아끼웁니다. bind는 stop 토큰·도구 같은 인자를 미리 고정합니다.
왜 이게 중요한지는 폴백 유무를 나란히 두면 분명해집니다. 같은 LLM 호출도, 외부 API 장애 한 번에 사용자 화면이 멈추느냐 / 조용히 대체 경로로 흘러가느냐가 갈립니다.
7. 캐싱 — 같은 호출을 두 번 하지 않기
반복되는 동일 호출은 캐시로 줄입니다. LCEL/Runnable과 별개로, LLM 호출 레벨에서 전역 캐시를 켤 수 있습니다.
set_llm_cache로 캐시를 한 번 등록하면 이후 동일 프롬프트의 호출은 캐시에서 답합니다. SQLiteCache는 파일에 저장돼 프로세스를 다시 띄워도 유지됩니다. 반면 InMemoryCache는 세션이 끝나면 사라집니다. 이 캐시는 "완전히 같은 입력"에만 적중하는 한계가 있어, 동적 컨텍스트가 매번 바뀌는 호출에는 효과가 적습니다. 또 캐시 키에 temperature까지 포함되어 정확 일치에만 적중하므로, 샘플링 다양성이 필요한 생성(temperature>0)에는 캐시가 같은 답만 반복하게 만들 수 있어 주의가 필요합니다.
한편 시스템 프롬프트나 도구 정의처럼 안정적으로 반복되는 큰 블록은 프로바이더 네이티브 프롬프트 캐싱이 유리합니다. 둘은 경쟁이 아니라 캐시 대상이 다릅니다 — set_llm_cache는 출력(응답 전체)을 정확 일치로 캐시하고, 프롬프트 캐싱은 입력 프리픽스 토큰의 재처리 비용을 줄여 둘을 함께 씁니다. 1.0에서는 이를 미들웨어(AnthropicPromptCachingMiddleware · BedrockPromptCachingMiddleware)로 붙입니다. 미들웨어는 create_agent와 함께 4편에서 본격적으로 다룹니다.
8. LCEL vs LangGraph — 언제 무엇을
LCEL의 경계가 이 글의 마지막 질문입니다. LCEL은 선형 파이프(DAG)입니다. 순환·동적 분기가 필요하면 LangGraph로 갑니다. 둘은 대립이 아니라 층이 다릅니다 — LangGraph 노드 안에서 LCEL을 씁니다.
| 구분 | LCEL | LangGraph |
|---|---|---|
| 형태 | 선형 파이프(DAG) | 상태 그래프(순환·분기 가능) |
| 적합 | 프롬프트 → 모델 → 파서, 단순 RAG | 에이전트 루프, 다중 액터, HITL, 영속성 |
| 관계 | Runnable 합성 | 상태 런타임 (노드 내부에서 LCEL 사용) |
정리하면 입력 → 출력이 한 방향으로 흐르는 작업은 LCEL로 충분합니다. "도구를 부르고 결과를 보고 다시 판단"하는 루프나 사람이 중간에 끼는 흐름(HITL)·상태 영속이 필요하면 LangGraph입니다. LCEL로 RunnableBranch를 겹겹이 쌓아 순환을 흉내 내려는 순간이 바로 LangGraph로 넘어갈 신호입니다. 그 런타임은 3편에서 본격적으로 봅니다.
🕰️ 레거시 체인 — LLMChain(0.1.17 deprecated) · SequentialChain · SimpleSequentialChain은 1.0에서 langchain-classic으로 이동했습니다. 신규 코드는 LCEL(prompt | model | parser)과 RunnablePassthrough.assign을 씁니다. from langchain.chains import LLMChain은 1.x에서 ImportError가 납니다. 살려야 한다면 from langchain_classic.chains import LLMChain으로 import 경로만 바꾸면 됩니다(임시방편).
시리즈: 01 LangChain 1.0 시작하기 → 02 LCEL & Runnable → 03 LangGraph → 04 create_agent → 05 현대 RAG → 06 LangSmith
9. 정리 · 다음 편
LCEL의 가치는 파이프 기호가 아니라 그 양쪽 끝의 규격이 같다는 데 있습니다. 모든 조각이 Runnable이라 invoke·batch·stream·astream_events가 공짜로 따라옵니다. 거기에 재시도·폴백·캐싱을 얹으면 개념용 파이프가 운영용 체인이 됩니다. 그리고 LCEL이 표현하지 못하는 순환·분기·상태가 보이는 순간, 다음 편의 주제인 LangGraph — 상태·영속성·스트리밍·HITL을 다루는 상태 그래프 런타임으로 넘어갑니다.
- langchain-ai/langchain — 공식 저장소(소스·릴리스·CHANGELOG)
- LangChain — Chat models(모델·메시지 가이드)
- LangChain Core API 레퍼런스(LCEL · Runnable)
'LangChain' 카테고리의 다른 글
| LLM 앱 운영 5종 세트 — 트레이싱·Async 콜백·LLM-as-judge·usage_metadata·캐싱 (0) | 2026.07.01 |
|---|---|
| 현대 RAG — 로드·분할부터 하이브리드 검색과 Agentic RAG까지 (0) | 2026.06.30 |
| create_agent & 미들웨어 — LangChain 1.0 에이전트 표준 (0) | 2026.06.29 |
| LangGraph 입문 — LCEL로 안 되던 순환·기억·사람 개입을 그래프로 풀기 (0) | 2026.06.26 |
| LangChain 1.0 시작하기 (0) | 2026.06.21 |