실습 환경

이 코스(Step 01 ~ Step 20)의 모든 .ts 파일은 이 폴더의 의존성과 설정으로 실행됩니다. 여러분이 매 스텝마다 npm install 을 다시 할 필요는 없습니다. 여기서 한 번 준비해 두면 20개 스텝이 전부 그 위에서 돕니다.

이 페이지는 그 환경이 어떻게 구성되어 있는지를 설명합니다. Step 01에서 처음 세팅할 때, 그리고 "왜 내 환경에서는 이 import 를 못 찾지?" 싶을 때 다시 찾아오면 됩니다.

전체 구성

docs/reference/langchain/
├── package.json          ← 워크스페이스 루트 (node_modules 가 여기 생깁니다)
├── node_modules/         ← 설치 결과. step-*/ 에서도 여기를 찾습니다
├── project/              ← 이 페이지가 설명하는 폴더
│   ├── package.json      ← 실제 의존성 선언
│   ├── tsconfig.json     ← TypeScript 설정
│   ├── .env.example      ← API 키 서식 (커밋함)
│   ├── .env              ← 실제 키 (커밋 안 함, 직접 만듭니다)
│   ├── .gitignore
│   └── src/lib/print.ts  ← 공용 출력 헬퍼
├── step-01-setup/
│   ├── practice.ts
│   ├── exercise.ts
│   └── solution.ts
├── step-02-chat-models/
└── ...

package.json두 개인 것이 눈에 띌 겁니다. 이유가 있습니다.

파일역할
langchain/package.json워크스페이스 루트. 의존성을 선언하지 않고, node_modules 를 이 위치로 끌어올리는 일만 합니다
langchain/project/package.json실제 의존성(langchain, @langchain/anthropic ...)을 선언하는 곳

왜 이렇게 나눴습니까? 실습 파일이 project/ 바깥의 step-01-setup/ 에 있기 때문입니다.

Node.js 는 import { createAgent } from "langchain" 같은 bare import 를 만나면 그 파일이 있는 디렉터리에서부터 위로 올라가며 node_modules 를 찾습니다. 만약 node_modulesproject/ 안에만 있다면, step-01-setup/practice.ts 는 위로 올라가도 그걸 못 만납니다 — project/ 는 부모가 아니라 형제니까요. 그래서 워크스페이스 루트를 하나 두어 node_moduleslangchain/ 레벨로 올렸습니다. 이제 어느 스텝 폴더에서든 위로 한 칸만 올라가면 찾습니다.

이 루트 package.json 에는 "type": "module" 도 들어 있습니다. 이게 없으면 step-*/*.ts 가 CommonJS 로 인식되어 top-level await 가 컴파일 에러가 납니다.

준비하기

cd docs/reference/langchain
npm install

node_modulesproject/ 가 아니라 docs/reference/langchain/ 에 생깁니다. 정상입니다.

이어서 API 키를 넣습니다.

cd project
cp .env.example .env
# 편집기로 .env 를 열어 ANTHROPIC_API_KEY 를 채웁니다

키 발급은 console.anthropic.com 에서 합니다. 자세한 건 Step 01 — 환경 구축과 첫 모델 호출 에서 다룹니다.

실행하기

항상 project/ 에서 실행합니다.

cd docs/reference/langchain/project
npx tsx ../step-01-setup/practice.ts

project/ 를 작업 디렉터리로 삼는 이유는 dotenv 때문입니다. import "dotenv/config".envprocess.cwd() 기준으로 찾습니다. 즉 "파일이 어디 있느냐"가 아니라 "어느 디렉터리에서 명령을 쳤느냐"가 기준입니다. 저장소 루트에서 npx tsx docs/reference/langchain/step-01-setup/practice.ts 를 치면 dotenv 는 저장소 루트의 .env 를 찾다가 실패하고, 키가 없다며 죽습니다. .env 는 멀쩡히 project/ 에 있는데도요.

⚠️ 함정: "키를 분명히 넣었는데 못 읽는다"의 절반은 import "dotenv/config" 를 빠뜨린 것이고, 나머지 절반이 이 cwd 문제입니다. 파일 위치가 아니라 실행 위치가 기준이라는 걸 기억하세요.

타입 검사는 따로 돌립니다. tsx타입을 검사하지 않고 트랜스파일만 하기 때문에, 타입이 틀려도 그냥 실행됩니다.

cd docs/reference/langchain/project
npm run typecheck        # tsc --noEmit

검증된 버전

아래 조합에서 이 코스의 모든 예제가 동작하는 것을 확인했습니다 (2026-07 기준).

항목버전
Node.js22.22.0 (22 이상 필수)
langchain1.5.3
@langchain/core1.2.3
@langchain/anthropic1.5.1
@langchain/openai1.5.5
@langchain/langgraph1.4.8
zod4.4.3
typescript5.9.x
tsx4.20.x

파일별 설명

package.json

의존성 선언의 원본입니다. 세 덩어리로 읽으면 됩니다.

  • langchain + @langchain/core — 프레임워크 본체. 이 둘은 언제나 같이 갑니다.
  • @langchain/anthropic / @langchain/openai — 제공자 어댑터. 쓰는 것만 깔면 됩니다. 이 코스는 Anthropic 을 기본으로 하고 OpenAI 를 대안으로 씁니다.
  • @langchain/langgraphcreateAgent 밑에서 돌아가는 실행 엔진. Step 10(메모리)부터 직접 import 합니다.

"type": "module" 이 핵심입니다. 이게 있어야 이 폴더의 .ts 가 ESM 으로 취급됩니다.

@langchain/* 패키지들의 버전을 서로 맞춰 두는 것도 중요합니다. 어긋나면 @langchain/core 가 두 벌 설치되어 instanceof 검사가 조용히 깨집니다 (Step 01 의 함정 참고). 확인은 npm run check:core 로 합니다 — deduped 만 보이면 안전합니다.

{
  "name": "langchain-course",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "description": "LangChain(TypeScript) 학습 코스 Step 01~20 실습 환경",
  "engines": {
    "node": ">=22"
  },
  "scripts": {
    "step": "tsx",
    "typecheck": "tsc --noEmit",
    "check:core": "npm ls @langchain/core"
  },
  "dependencies": {
    "@hono/node-server": "^2.0.10",
    "@langchain/anthropic": "^1.5.1",
    "@langchain/classic": "^1.0.40",
    "@langchain/core": "^1.2.3",
    "@langchain/langgraph": "^1.4.8",
    "@langchain/langgraph-checkpoint-postgres": "^1.0.4",
    "@langchain/openai": "^1.5.5",
    "@langchain/textsplitters": "^1.0.1",
    "dotenv": "^17.2.1",
    "hono": "^4.12.30",
    "langchain": "^1.5.3",
    "zod": "^4.1.5"
  },
  "devDependencies": {
    "@types/node": "^22.15.3",
    "tsx": "^4.20.3",
    "typescript": "^5.9.2"
  }
}

tsconfig.json

module/moduleResolutionNodeNext 로 둔 것이 이 파일의 전부라고 해도 됩니다. Node.js 의 실제 ESM 해석 규칙을 그대로 흉내내므로, "타입 검사는 통과했는데 실행하면 모듈을 못 찾는" 사고를 막아 줍니다. 대신 규칙 하나를 받아들여야 합니다 — 로컬 파일을 import 할 때 확장자를 .js 로 씁니다.

import { printSection } from "../project/src/lib/print.js";   // .ts 가 아니라 .js

.ts 파일을 가리키면서 .js 라고 쓰는 게 이상해 보이지만, TypeScript 가 "컴파일된 뒤의 경로"를 기준으로 해석하기 때문입니다. 확장자를 빼거나 .ts 로 쓰면 tsc 가 에러를 냅니다.

include"../step-*/**/*.ts" 가 들어 있어서, npm run typecheck 한 번으로 20개 스텝의 모든 실습 파일을 한꺼번에 검사합니다.

{
  "compilerOptions": {
    /* ── 모듈 시스템 ───────────────────────────────────────────────
       package.json 의 "type": "module" 과 짝을 이룹니다.
       NodeNext 는 Node.js 의 실제 ESM 해석 규칙을 그대로 따라가므로,
       "타입체크는 통과했는데 실행하면 못 찾는" 사고를 막아 줍니다. */
    "module": "NodeNext",
    "moduleResolution": "NodeNext",

    /* ── 언어 수준 ────────────────────────────────────────────────
       Node 22 는 ES2022 를 전부 지원합니다. top-level await 도 됩니다. */
    "target": "ES2022",
    "lib": ["ES2022"],

    /* ── 엄격 모드 ────────────────────────────────────────────────
       usage_metadata 처럼 optional 인 필드를 그냥 읽으면 바로 잡아 줍니다.
       LangChain 타입의 진가는 strict 에서 나옵니다. */
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,

    /* ── 실행 편의 ────────────────────────────────────────────────
       tsx 가 트랜스파일만 하고 타입체크는 하지 않으므로 noEmit 으로 둡니다.
       타입 검사는 `npm run typecheck` 로 따로 돌립니다. */
    "noEmit": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "types": ["node"]
  },
  "include": ["src/**/*.ts", "../step-*/**/*.ts"],
  "exclude": ["node_modules"]
}

.env.example

이 파일은 커밋합니다. .env 는 절대 커밋하지 않습니다. 값이 아니라 "어떤 키가 필요한지"를 알려 주는 문서이기 때문입니다. 새로 합류한 사람이 cp .env.example .env 한 줄로 시작할 수 있게 하는 것이 목적입니다.

ANTHROPIC_API_KEY 만 있으면 Step 01~20 이 전부 돌아갑니다. 나머지는 선택입니다.

# ─────────────────────────────────────────────────────────────
#  이 파일을 .env 로 복사한 뒤 실제 키를 채우세요.
#
#      cp .env.example .env
#
#  .env 는 .gitignore 에 등록되어 있습니다. 절대 커밋하지 마세요.
#  반대로 이 .env.example 은 커밋합니다 — 값이 아니라 "어떤 키가 필요한지"를
#  팀에 알려 주는 문서 역할을 합니다.
# ─────────────────────────────────────────────────────────────

# ── 기본 모델 제공자 (이 코스의 표준) ────────────────────────
# 발급: https://console.anthropic.com/settings/keys
# 형식: sk-ant-api03-... (앞뒤 공백/따옴표 없이 그대로 붙여넣기)
ANTHROPIC_API_KEY=sk-ant-api03-여기에-발급받은-키를-붙여넣으세요

# ── 대안 제공자 (선택) ────────────────────────────────────────
# OpenAI 로 실습하려면 채우세요. 안 쓸 거면 줄 전체를 지우거나 그대로 두면 됩니다.
# 발급: https://platform.openai.com/api-keys
# OPENAI_API_KEY=sk-proj-...

# ── LangSmith 관측 (선택, Step 19 에서 사용) ──────────────────
# 켜면 모든 모델 호출이 LangSmith 대시보드에 기록됩니다.
# LANGSMITH_TRACING=true
# LANGSMITH_API_KEY=lsv2_pt_...
# LANGSMITH_PROJECT=langchain-course

.gitignore

.env 를 막는 세 줄의 순서가 중요합니다.

.env
.env.*
!.env.example

.env.*.env.local, .env.production 까지 전부 막은 다음, !.env.example 로 예시 파일만 다시 꺼냅니다. 나중 줄이 앞 줄을 덮어쓰므로 순서를 바꾸면 .env.example 이 커밋되지 않습니다.

# ── 비밀 정보 (가장 중요) ─────────────────────────────────────
# .env 로 시작하는 모든 파일을 막되, .env.example 만 예외로 되돌립니다.
# 순서가 중요합니다 — 나중 줄이 앞 줄을 덮어씁니다.
.env
.env.*
!.env.example

# 혹시 모를 키 파일들
*.pem
*.key
secrets/

# ── 의존성 / 빌드 산출물 ──────────────────────────────────────
node_modules/
dist/
build/
*.tsbuildinfo

# ── 로컬 캐시 / 로그 ──────────────────────────────────────────
.cache/
*.log
npm-debug.log*
pnpm-debug.log*
yarn-error.log*

# ── 에디터 / OS ───────────────────────────────────────────────
.DS_Store
.idea/
.vscode/*
!.vscode/extensions.json

src/lib/print.ts

모든 스텝이 공유하는 출력 헬퍼입니다. AIMessage 를 그냥 console.log 하면 내부 필드가 수십 줄로 쏟아져서 정작 봐야 할 내용이 묻히기 때문에 만들었습니다.

함수하는 일
printSection(title)[1-5] 같은 절 구분선. 본문 소제목 번호와 1:1 대응합니다
printMessages(msg | msgs)메시지를 역할별 색으로 출력. tool_calls, tool_call_id, 토큰 수까지
printUsage(msg)토큰 사용량 상세 (캐시 읽기, 추론 토큰 포함)
printJson(label, value)아무 객체나 들여쓴 JSON 으로
printKV(rows)"키: 값" 표 정렬 출력
requireEnv(name)환경변수 확인. 없으면 원인 후보를 알려주고 종료

이 파일에서 눈여겨볼 대목이 두 군데 있습니다.

첫째, getToolCalls / getUsageinstanceof AIMessage쓰지 않습니다. @langchain/core 가 두 벌 설치되면 instanceof 는 예외도 없이 조용히 false 가 되기 때문입니다. 대신 "그 필드가 실제로 있는지"만 보는 구조적 검사를 씁니다. 코어가 몇 벌이든 항상 동작합니다.

둘째, usage_metadata 를 읽기 전에 반드시 undefined 확인을 합니다. 이 필드는 optional 이고, 스트리밍 청크(Step 09)나 일부 제공자에서는 정말로 안 옵니다.

/**
 * 출력 포맷 헬퍼 — LangChain 코스 Step 01~20 공용
 *
 * 이 코스의 실습 파일들은 "모델이 뭘 돌려줬는지"를 눈으로 확인하는 게 전부입니다.
 * 그런데 AIMessage 를 그냥 console.log 하면 내부 필드가 수십 줄로 쏟아져서
 * 정작 봐야 할 content 와 토큰 수가 묻힙니다. 그걸 정리해 주는 파일입니다.
 *
 * 사용:
 *   import { printSection, printMessages } from "../project/src/lib/print.js";
 *                                                                      ^^^
 *   ESM(NodeNext) 에서는 .ts 파일을 import 할 때도 확장자를 ".js" 로 씁니다.
 *   TypeScript 가 "컴파일 후의 경로"를 기준으로 해석하기 때문입니다.
 *   확장자를 빼거나 ".ts" 로 쓰면 tsc 가 에러를 냅니다.
 */

import type { BaseMessage, UsageMetadata } from "@langchain/core/messages";

/* ===== 색상 ===== */

// 터미널이 색을 지원하지 않거나 출력이 파일로 리다이렉트되면 색 코드를 빼야
// 깨진 글자(ESC[36m 같은 것)가 안 보입니다.
const useColor = process.stdout.isTTY === true && process.env["NO_COLOR"] === undefined;

const ESC = "\u001b"; // ANSI 이스케이프 시작 문자
const paint = (code: string, s: string): string =>
  useColor ? `${ESC}[${code}m${s}${ESC}[0m` : s;

const dim = (s: string) => paint("2", s);
const bold = (s: string) => paint("1", s);
const cyan = (s: string) => paint("36", s);
const green = (s: string) => paint("32", s);
const yellow = (s: string) => paint("33", s);
const magenta = (s: string) => paint("35", s);

/* ===== 섹션 구분선 ===== */

/**
 * 본문의 소제목([1-5] 같은 것)에 대응하는 구분선을 찍습니다.
 * practice.ts 를 통째로 실행했을 때 "지금 출력이 몇 번 절의 결과인지"를
 * 스크롤하며 찾을 수 있게 해 줍니다.
 *
 * printSection("[1-5] 첫 모델 호출")
 * →
 * ────────────────────────────────────────────────────────
 *  [1-5] 첫 모델 호출
 * ────────────────────────────────────────────────────────
 */
export function printSection(title: string): void {
  const line = "─".repeat(60);
  console.log(`\n${dim(line)}`);
  console.log(` ${bold(cyan(title))}`);
  console.log(dim(line));
}

/* ===== 타입 가드 =====
 *
 * BaseMessage 에는 tool_calls 나 usage_metadata 가 없습니다. 그 필드들은
 * AIMessage / ToolMessage 에만 있습니다. instanceof 로 좁힐 수도 있지만,
 * instanceof 는 @langchain/core 가 두 벌 설치되면 조용히 false 가 됩니다
 * (Step 01 의 함정 참고). 그래서 여기서는 "그 필드가 실제로 있는지"만 보는
 * 구조적(structural) 검사를 씁니다 — 코어가 몇 벌이든 항상 동작합니다.
 */

interface ToolCallLike {
  name: string;
  args: Record<string, unknown>;
  id?: string;
}

function getToolCalls(m: BaseMessage): ToolCallLike[] {
  const calls = (m as { tool_calls?: unknown }).tool_calls;
  return Array.isArray(calls) ? (calls as ToolCallLike[]) : [];
}

function getToolCallId(m: BaseMessage): string | undefined {
  const id = (m as { tool_call_id?: unknown }).tool_call_id;
  return typeof id === "string" ? id : undefined;
}

function getUsage(m: BaseMessage): UsageMetadata | undefined {
  return (m as { usage_metadata?: UsageMetadata }).usage_metadata;
}

/* ===== 메시지 출력 ===== */

// getType() 이 돌려주는 역할 문자열별 색.
// 모르는 타입이 와도 죽지 않도록 fallback 을 둡니다.
const ROLE_STYLE: Record<string, (s: string) => string> = {
  system: magenta,
  human: green,
  ai: cyan,
  tool: yellow,
};

function styleRole(role: string): string {
  const fn = ROLE_STYLE[role] ?? bold;
  return fn(role.toUpperCase().padEnd(6));
}

/**
 * 메시지 하나 또는 배열을 사람이 읽을 수 있게 출력합니다.
 *
 * - 역할(system/human/ai/tool)을 색으로 구분
 * - 본문은 .text 로 뽑습니다 (content 가 문자열이든 블록 배열이든 안전)
 * - AIMessage 에 tool_calls 가 있으면 이름과 인자를 같이 보여줍니다
 * - usage_metadata 가 있으면 토큰 수를 꼬리에 붙입니다
 *
 *   printMessages(response);
 *   printMessages(result.messages);
 */
export function printMessages(input: BaseMessage | BaseMessage[]): void {
  const messages = Array.isArray(input) ? input : [input];

  for (const m of messages) {
    const body = m.text.trim();
    console.log(`${styleRole(m.getType())}${body === "" ? dim("(빈 내용)") : body}`);

    for (const call of getToolCalls(m)) {
      console.log(
        `       │ ${dim("→ tool")} ${yellow(call.name)}(${dim(JSON.stringify(call.args))})`,
      );
    }

    // ToolMessage 는 어떤 tool_call 에 대한 답인지 tool_call_id 로 연결됩니다.
    // 이 값이 어긋나면 대화가 조용히 깨집니다 — Step 07 에서 자세히 다룹니다.
    const callId = getToolCallId(m);
    if (callId !== undefined) {
      console.log(`       │ ${dim(`↳ tool_call_id: ${callId}`)}`);
    }

    const usage = getUsage(m);
    if (usage !== undefined) {
      const line = `tokens: in=${usage.input_tokens} out=${usage.output_tokens} total=${usage.total_tokens}`;
      console.log(`       │ ${dim(line)}`);
    }
  }
}

/* ===== 토큰 사용량 ===== */

/**
 * 토큰 사용량만 따로, 조금 더 자세히 출력합니다.
 * 캐시 읽기(input_token_details.cache_read)나 추론 토큰
 * (output_token_details.reasoning)처럼 제공자가 얹어 주는 세부 항목이 있으면
 * 같이 보여줍니다.
 *
 * usage_metadata 는 optional 입니다. 스트리밍 청크나 일부 제공자에서는
 * 아예 안 실려 오므로 반드시 존재 확인을 하고 읽어야 합니다.
 */
export function printUsage(m: BaseMessage): void {
  const u = getUsage(m);
  if (u === undefined) {
    console.log(dim("usage_metadata 없음 (제공자가 보내주지 않았거나 스트리밍 청크입니다)"));
    return;
  }

  console.log(`${dim("입력 토큰")}  ${String(u.input_tokens).padStart(7)}`);
  console.log(`${dim("출력 토큰")}  ${String(u.output_tokens).padStart(7)}`);
  console.log(`${dim("합계")}      ${String(u.total_tokens).padStart(7)}`);

  if (u.input_token_details !== undefined) {
    console.log(`${dim("입력 상세")}  ${JSON.stringify(u.input_token_details)}`);
  }
  if (u.output_token_details !== undefined) {
    console.log(`${dim("출력 상세")}  ${JSON.stringify(u.output_token_details)}`);
  }
}

/* ===== 잡다한 것 ===== */

/**
 * 아무 객체나 보기 좋게 들여쓴 JSON 으로 출력합니다.
 * response_metadata 처럼 제공자마다 모양이 다른 걸 들여다볼 때 씁니다.
 */
export function printJson(label: string, value: unknown): void {
  console.log(dim(label));
  console.log(JSON.stringify(value, null, 2));
}

/**
 * "키: 값" 표를 정렬해서 출력합니다.
 */
export function printKV(rows: Record<string, unknown>): void {
  const keys = Object.keys(rows);
  if (keys.length === 0) return;
  const width = Math.max(...keys.map((k) => k.length));
  for (const k of keys) {
    console.log(`${dim(k.padEnd(width))}  ${String(rows[k])}`);
  }
}

/**
 * 필수 환경변수가 있는지 확인하고, 없으면 무엇을 해야 하는지 알려 주며 종료합니다.
 *
 * 이게 없으면 키를 안 넣었을 때 제공자 SDK 가 던지는 난해한 에러를 보게 됩니다.
 * 처음 겪는 사람은 그게 "키가 없다"는 뜻인지 알아채기 어렵습니다.
 */
export function requireEnv(name: string): string {
  const value = process.env[name];
  if (value === undefined || value.trim() === "") {
    console.error(`\n${yellow("환경변수가 없습니다:")} ${bold(name)}\n`);
    console.error("확인할 것:");
    console.error("  1. project/.env 파일이 있습니까?  (cp .env.example .env)");
    console.error(`  2. 그 안에 ${name}=... 이 채워져 있습니까?`);
    console.error('  3. 실행 파일 맨 위에 import "dotenv/config"; 가 있습니까?  ← 가장 흔한 원인');
    console.error("");
    process.exit(1);
  }
  return value;
}

문제가 생기면

증상원인해결
Cannot find module 'langchain'npm installproject/ 에서 했음cd docs/reference/langchain && npm install
ANTHROPIC_API_KEY 없다고 나옴cwd 가 project/ 가 아님cd project 후 실행
The current file is a CommonJS module루트 package.json"type": "module" 누락루트 package.json 확인
Relative import paths need explicit file extensions.js 확장자 누락from "./print.js"
instanceof 가 false@langchain/core 중복npm ls @langchain/core 확인

환경을 완전히 초기화하려면:

cd docs/reference/langchain
rm -rf node_modules package-lock.json
npm install

.env.gitignore 되어 있으므로 이 명령으로 지워지지 않습니다. 안심하고 실행하세요.