LangChain 없이 주문, 환불 및 고객 지원을 처리하는 AI 에이전트 구축하기
요약
LangChain과 같은 추상화 도구 없이 TypeScript를 사용하여 이커머스 지원 AI 에이전트를 밑바닥부터 구축하는 방법을 설명합니다. while 루프와 Claude의 Tool Use 기능을 활용하여 도구 호출의 원리를 직접 구현하는 과정을 다룹니다.
핵심 포인트
- 에이전트의 핵심은 LLM의 응답에 따라 도구를 호출하는 while 루프 구조임
- LangChain의 추상화 대신 직접적인 도구 호출(Tool-calling) 로직 구현
- TypeScript의 타입 가드를 활용한 안전한 도구 호출 처리 방법
- 확장성을 고려한 에이전트 및 도구(Tools)의 폴더 구조 설계
내가 LangChain을 건너뛴 이유
내가 찾은 모든 AI 에이전트 튜토리얼은 다음 두 가지 중 하나였습니다.
당신이 이해해야 할 바로 그 부분을 추상화해 버리는 LangChain을 사용하거나, 아니면 너무 단순해서 기본적으로 if/else 라우팅(routing)을 사용하는 챗봇이면서 스스로를 "에이전트"라고 부르는 것이었습니다.
나는 코드 레벨에서 에이전트가 실제로 무엇인지 이해하고 싶었습니다. 그래서 처음부터 직접 구축했습니다.
결과적으로 이 모든 것은 while 루프(while loop)였습니다.
while (true) {
const response = await llm(messages);
if (noToolCalls) break; // Claude가 답변함 — 완료
...
그것이 바로 에이전트입니다. 그 외의 모든 것은 그에 매달려 있는 잘 작성된 도구(tools)일 뿐입니다.
우리가 만든 것
다음과 같은 기능을 수행할 수 있는 이커머스(ecommerce) 지원 에이전트:
🔍 자연어 질의(natural language query)로 제품 검색
📦 주문 ID로 주문 상태 확인
📋 반품 정책 질문에 답변
🎫 고객 지원 티켓 생성 (액션을 작성하며, Command 패턴을 예고함)
에이전트는 어떤 도구를 호출할지 스스로 판단하며, 때로는 단일 메시지 내에서 여러 도구를 호출하기도 합니다. 코드 어디에도 if (message.includes("order"))와 같은 구문을 작성하지 않습니다.
도구 호출(tool-calling)이 실제로 작동하는 방식
이 부분은 대부분의 튜토리얼이 대충 넘어가는 부분입니다.
anthropic.messages.create()에 도구들을 전달하면, Claude의 응답은 단순한 텍스트가 아니라 각각 type이 태그된 콘텐츠 블록(content blocks)의 배열이 됩니다.
{
"content": [
{ "type": "text", "text": "확인해 드리겠습니다." },
...
당신의 코드는 type을 기준으로 필터링합니다.
const toolCalls = response.content.filter(
(block): block is Anthropic.ToolUseBlock => block.type === "tool_use"
);
TypeScript 타입 가드(type guard)인 (block): block is Anthropic.ToolUseBlock에 주목하세요. 이것은 단순한 런타임 체크가 아닙니다. 이는 TypeScript에게 타입을 좁혀서(narrow) call.name과 call.input이 any가 아닌 적절한 타입으로 지정되도록 알려줍니다. SDK는 ContentBlock = TextBlock | ToolUseBlock을 적절한 판별된 유니온(discriminated union)으로 내보내므로, 이를 활용하세요.
만약 toolCalls.length === 0이라면, Claude는 일반 텍스트로 응답한 것입니다. 그것이 종료 조건입니다. 루프가 끝납니다.
폴더 구조 — 확장을 고려한 구성
src/
├── agent/
│ └── EcommerceAgent.ts # 클래스로 구현된 while(true) 루프
├── tools/
│ ├── types.ts # 공유되는 Tool 타입
│ ├── index.ts # 레지스트리 (registry) — 모든 도구를 알고 있는 유일한 파일
│ ├── searchProducts.ts
│ ├── getOrderStatus.ts
│ └── getReturnPolicy.ts
├── data/
│ ├── products.ts # 나중에 Postgres로 교체할 대상
│ ├── orders.ts
│ └── policy.ts
└── cli.ts
핵심 결정 사항: 각 도구를 별도의 파일로 분리했습니다. 새로운 도구를 추가하려면 새 파일 하나와 tools/index.ts에 한 줄을 추가하기만 하면 됩니다. 에이전트 루프는 전혀 변경되지 않습니다.
// tools/index.ts — 레지스트리 (registry)
export const tools: Tool[] = [
searchProductsTool,
...
// 에이전트 루프 (agent loop) — 개별 도구에 직접 접근하지 않음
const tool = this.toolset.find((t) => t.name === call.name);
이것은 팩토리 패턴 (Factory pattern)의 실제 적용 사례입니다. 레지스트리가 어떤 도구를 인스턴스화할지 결정하며, 에이전트는 단순히 이름으로 호출할 뿐입니다.
눈에 잘 띄지 않지만 숨어 있는 디자인 패턴들
디자인 패턴을 구현하려고 의도한 것은 아니었습니다. 하지만 구조를 제대로 잡다 보면 자연스럽게 나타납니다.
전략 패턴 (Strategy) — 교체 가능한 LLM 제공자 (provider)
class EcommerceAgent {
constructor(
...
Anthropic을 OpenAI로 바꾸고 싶나요? 생성자 인자 하나만 바꾸면 됩니다. 다른 부분은 전혀 움직이지 않습니다.
팩토리 (Factory), 도구 레지스트리 (tool registry)
tools/index.ts 파일이 바로 여러분의 팩토리입니다. 에이전트는 결코 new SearchProductsTool()을 직접 호출하지 않으며, 이름으로 조회합니다. 도구를 추가하는 것은 기존 코드를 수정하는 것이 아니라, 새로운 것을 덧붙이는(additive) 작업입니다.
레포지토리 패턴 (Repository pattern) (유사함) — 데이터 격리
typescript// src/data/products.ts export const products: Product[] = [ ... ];
현재는 배열 형태입니다. 프로덕션 환경에서는 Postgres 쿼리가 됩니다. data/에서 임포트하는 도구 파일들은 변경되지 않으며, 오직 데이터 파일 자체만 변경됩니다. 이것이 레포지토리 패턴의 핵심 목적입니다.
커맨드 패턴 (Command) — 쓰기 작업(write actions)은 특별한 처리가 필요함
getOrderStatus는 읽기(read) 작업입니다. getReturnPolicy도 읽기 작업입니다. 하지만 createSupportTicket은 무언가를 변경(mutate)합니다. 프로덕션 환경에서는 다음과 같은 처리가 필요합니다:
감사 로그 (Audit logging)
실행 전 확인 (Confirmation before execution)
멱등성 (Idempotency) (한 번의 클릭으로 티켓이 두 개 생성되지 않도록 함)
이것이 바로 커맨드 패턴 (Command pattern)입니다. 쓰기 작업 (write actions)을 단순히 도구 레지스트리 (tool registry)의 또 다른 함수로 두는 것이 아니라, 자체적인 검증과 로깅을 갖춘 객체로 래핑(wrap)하는 것입니다.
이미 자연스럽게 분리된 CQRS
여러분의 읽기 도구 (search, status, policy)는 하나의 데이터 소스를 호출합니다. 반면 쓰기 도구 (create ticket)는 완전히 다른 경로를 호출합니다. 분리는 이미 존재하며, CQRS는 이를 의도적이고 명시적으로 만들어줄 뿐입니다.
모두가 틀리는 단 한 가지 질문
"이것에 WebSockets나 SSE가 필요한가요?"
아니요. 그 이유는 다음과 같습니다.
에이전트 루프 (agent loop)는 완전히 서버 측 (server-side)에서 실행됩니다. 여러 번의 Anthropic API 호출, 도구 실행, 결과 피딩 (result feeding) 등 이 모든 과정이 하나의 비동기 함수 (async function) 내부에서 일어납니다. 클라이언트 관점에서는 다음과 같습니다:
클라이언트가 하나의 요청을 보냄
→ 서버가 내부적으로 전체 while(true) 루프를 수행함
→ 서버가 하나의 응답을 다시 보냄
SSE는 UX 업그레이드 (사용자가 3초 동안 빈 화면을 바라보지 않도록 토큰이 도착하는 대로 스트리밍하는 것)일 뿐, 기술적 요구 사항은 아닙니다. 에이전트는 SSE 없이도 표준 요청/응답 (request/response) 방식으로 완벽하게 작동합니다.
프로덕션 환경에서 "정적 데이터 (static data)"의 의미
영상에서는 하드코딩된 배열을 사용합니다:
// today
const products = [ { id: "p1", title: "Wireless Earbuds Pro", ... } ];
...
도구 파일은 변경되지 않습니다. 임포트 (import)가 변경될 뿐입니다. 그것이 전부입니다.
이것이 바로 "기본적이지만 확장 가능한 (basic but scalable)"의 실제 의미입니다. 단순한 버전이 바로 프로덕션에 적합하다는 뜻이 아니라, 업그레이드가 필요한 경계 지점이 명확하고 깔끔하다는 뜻입니다.
전체 에이전트 루프, 전체 그림
export class EcommerceAgent {
constructor(
private readonly client: Anthropic = new Anthropic(),
...
스택 (Stack)
런타임 (Runtime): Node.js + tsx (빌드 단계 불필요)
LLM: Anthropic SDK — claude-sonnet-4-6
언어 (Language): TypeScript (strict mode)
데이터 (Data): 인메모리 배열 (In-memory arrays) (프로덕션: Postgres + pgvector)
다음 단계는 프로덕션 버전입니다
이 영상은 에이전트 레이어 (agent layer)를 다룹니다. 전체 프로덕션 버전에는 다음이 추가됩니다:
(Udemy)
Postgres + pgvector — 임베딩 (embeddings)을 활용한 실제 시맨틱 제품 검색 (semantic product search)
Redis — 서버 재시작 후에도 유지되는 대화 기록 (conversation history)
Repository pattern — 적절한 데이터 추상화 계층 (data abstraction layer)
Command pattern — 감사 추적 (audit trails) 기능이 포함된 액션 작성
CQRS — 명시적인 읽기/쓰기 분리 (read/write split)
RAG pipeline — 실제 검색을 위한 정책 문서의 청킹 (chunk) 및 임베딩 (embed)
Resources
📂 전체 소스 코드: 추가 필요
🎥 YouTube 영상: https://youtu.be/rxPrtcl42to
📖 Anthropic 도구 호출 (tool-calling) 문서: https://docs.anthropic.com/en/docs/tool-use
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기