별책부록 4편
별책부록 4편
Node.js + discord.js 봇
TypeScript + Anthropic SDK + Position C 완전 boilerplate
📑 이 챕터에서 다룰 내용

서버 보안 셋업이 끝났습니다. 이제 봇 코드를 직접 작성합니다. discord.js + @anthropic-ai/sdk를 결합하고, 시리즈 메타 원칙인 ⚖️ Position C와 LogOnTable을 일관되게 적용합니다.

항목내용
예상 시간1~2시간 (boilerplate + 첫 작동)
완성 결과디스코드 채널에서 !ask 명령 → Claude API 호출 → 답변
3-1 Node.js 설치 (NVM 권장) 🔗

Node.js는 NVM(Node Version Manager)으로 설치하는 것을 권장합니다. 버전 전환이 쉽고 권한 문제가 없어요.

💻 코드
# NVM 설치
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh | bash

# 셸 재시작 (또는 source)
source ~/.bashrc

# Node.js LTS 설치 (2026년 5월 기준 LTS = 22.x)
nvm install 22
nvm use 22
nvm alias default 22

# 확인
node --version  # v22.x.x
npm --version   # 10.x
3-2 디스코드 봇 토큰 발급 🔗

Discord Developer Portal에서 봇 토큰을 발급받습니다. 토큰은 1회만 노출되니 반드시 안전하게 보관하세요.

📘 Discord Developer Portal 단계별 안내
[Discord Developer Portal]
1. https://discord.com/developers/applications 접속
2. "New Application" 클릭
3. 이름 입력 (예: "TSV Assistant")
4. 좌측 "Bot" 메뉴
5. "Reset Token" → 토큰 복사 ★ (★ 1회만 노출, 안전 보관)
6. ★ "Privileged Gateway Intents" 모두 ON:
   - PRESENCE INTENT
   - SERVER MEMBERS INTENT
   - MESSAGE CONTENT INTENT (★ 필수)

[봇 초대 link 생성]
7. 좌측 "OAuth2" → "URL Generator"
8. SCOPES: "bot" 체크
9. BOT PERMISSIONS:
   - Read Messages/View Channels
   - Send Messages
   - Read Message History
10. 하단 URL 복사 → 브라우저 → 본인 서버 초대
⚠️ Privileged Intents 설정 필수

MESSAGE CONTENT INTENT를 활성화하지 않으면 봇이 message.content를 읽을 수 없습니다.

반드시 3개 Privileged Gateway Intents 모두 ON으로 설정하세요.

3-3 프로젝트 구조 🔗

작업 디렉토리를 만들고 필요한 패키지를 설치합니다.

💻 코드
# 작업 디렉토리
mkdir ~/tsv-bot
cd ~/tsv-bot

# package.json 초기화
npm init -y

# 패키지 설치
npm install discord.js @anthropic-ai/sdk dotenv

# 추가 (운영용)
npm install --save-dev typescript @types/node ts-node nodemon
npx tsc --init

디렉토리 구조

💻 코드
~/tsv-bot/
├── .env                  # API 키 (★ git ignore)
├── .gitignore
├── package.json
├── tsconfig.json
├── src/
│   ├── bot.ts           # 메인 봇 코드
│   ├── claude.ts        # Anthropic SDK wrapper
│   ├── logger.ts        # LogOnTable 로깅
│   └── messages.ts      # ⚖️ Position C 본문 메시지
└── logs/                # 로그 디렉토리 (★ git ignore)
3-4 .env + .gitignore (보안) 🔗

API 키는 절대 코드에 직접 쓰지 마세요. .env 파일에 보관하고 .gitignore로 git에서 제외합니다.

💻 코드
# .env
cat > .env << 'EOF'
DISCORD_TOKEN=your_discord_bot_token_here
ANTHROPIC_API_KEY=your_anthropic_api_key_here
ANTHROPIC_MODEL=claude-sonnet-4-6
LOG_DIR=./logs
EOF

# .gitignore
cat > .gitignore << 'EOF'
node_modules/
.env
logs/
dist/
*.log
.DS_Store
EOF

# 권한 (보안)
chmod 600 .env
⚠️ .env는 절대 git push 금지

.env를 GitHub에 push하면 Discord가 자동으로 토큰을 무효화합니다.

.gitignore에 반드시 포함하고, push 전에 확인하세요.

3-5 ⚖️ Position C 본문 메시지 (시리즈 메타 일관) 🔗

모든 봇 응답 끝에는 ⚖️ Position C disclaimer가 붙습니다. 시리즈 메타 원칙을 일관되게 적용하는 핵심 파일입니다.

💻 src/messages.ts
// src/messages.ts
// ⚖️ Position C — 시리즈 메타 원칙 일관

export const POSITION_C_FOOTER = `
━━━━━━━━━━━━━━━━━━━━━━━━━━━
이 봇은 분석 콘텐츠 보조 도구이며,
법률·의료·금융 등 전문 조언을 제공하지 않습니다.
중요한 결정은 전문가와 상의하시기 바랍니다.
━━━━━━━━━━━━━━━━━━━━━━━━━━━
`.trim();

export const SYSTEM_PROMPT = `
당신은 TSV 운영자의 보조 도구입니다.

[원칙]
- 사실 단일 출처 (확실하지 않은 사실 추측 X)
- 베팅·픽 추천 표현 X
- 법률·의료·금융 단정 표현 X (~할 수 있다, ~검토 권장)
- 사용자 질문에 정확하고 간결하게 답변

[톤]
- 한국어 기본
- 전문적이고 친근함
- 마크다운 활용 가능

[제약]
- 답변 길이 1500자 이내 (디스코드 메시지 한도 고려)
- 코드 블록 사용 권장
`.trim();

export const ERROR_MESSAGES = {
  rate_limit: '⏰ 잠시 후 다시 시도해주세요. (rate limit)',
  api_error: '⚠️ 일시적 오류가 발생했습니다.',
  too_long: '📝 질문이 너무 깁니다. 1000자 이내로 줄여주세요.',
};
3-6 Anthropic SDK Wrapper 🔗

Anthropic API 호출을 담당하는 wrapper 파일입니다. rate limit·API 오류·질문 길이 초과를 각각 별도로 처리합니다.

💻 src/claude.ts
// src/claude.ts
import Anthropic from '@anthropic-ai/sdk';
import { SYSTEM_PROMPT } from './messages.js';

const client = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY!,
});

const MODEL = process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-6';

export async function askClaude(question: string): Promise<string> {
  if (question.length > 1000) {
    throw new Error('TOO_LONG');
  }

  try {
    const response = await client.messages.create({
      model: MODEL,
      max_tokens: 1500,
      system: SYSTEM_PROMPT,
      messages: [
        { role: 'user', content: question }
      ],
    });

    // text 블록만 추출
    const textBlock = response.content.find(b => b.type === 'text');
    if (!textBlock || textBlock.type !== 'text') {
      return '⚠️ 답변을 생성할 수 없습니다.';
    }

    return textBlock.text;
  } catch (err: any) {
    if (err.status === 429) throw new Error('RATE_LIMIT');
    if (err.status >= 500) throw new Error('API_ERROR');
    throw err;
  }
}
3-7 ★ LogOnTable 로깅 (시리즈 메타 일관) 🔗

모든 요청과 응답을 JSONL 형식으로 일별 파일에 기록합니다. timestamp·user·question·status 4 요소가 핵심입니다.

💻 src/logger.ts
// src/logger.ts
import fs from 'fs';
import path from 'path';

const LOG_DIR = process.env.LOG_DIR || './logs';

if (!fs.existsSync(LOG_DIR)) {
  fs.mkdirSync(LOG_DIR, { recursive: true });
}

export interface LogEntry {
  timestamp: string;
  user_id: string;
  channel_id: string;
  question: string;
  response_length: number;
  duration_ms: number;
  status: 'success' | 'rate_limit' | 'too_long' | 'api_error';
}

export function logTrace(entry: LogEntry): void {
  // 일별 파일 분리
  const date = new Date().toISOString().slice(0, 10);
  const filepath = path.join(LOG_DIR, `${date}.jsonl`);

  const line = JSON.stringify(entry) + '\n';
  fs.appendFileSync(filepath, line);
}

export function logError(error: Error, context: any): void {
  const date = new Date().toISOString().slice(0, 10);
  const filepath = path.join(LOG_DIR, `${date}-errors.jsonl`);

  const entry = {
    timestamp: new Date().toISOString(),
    error: error.message,
    stack: error.stack,
    context,
  };

  fs.appendFileSync(filepath, JSON.stringify(entry) + '\n');
}
3-8 ★ 메인 봇 코드 (bot.ts) 🔗

봇의 핵심 코드입니다. !ask 명령을 감지해서 Claude API를 호출하고, 응답에 ⚖️ Position C footer를 붙여 전송합니다. graceful shutdown도 구현합니다.

💻 src/bot.ts
// src/bot.ts
import 'dotenv/config';
import { Client, GatewayIntentBits, Message } from 'discord.js';
import { askClaude } from './claude.js';
import { logTrace, logError, LogEntry } from './logger.js';
import { POSITION_C_FOOTER, ERROR_MESSAGES } from './messages.js';

const client = new Client({
  intents: [
    GatewayIntentBits.Guilds,
    GatewayIntentBits.GuildMessages,
    GatewayIntentBits.MessageContent,
  ],
});

const COMMAND_PREFIX = '!ask ';

client.once('ready', () => {
  console.log(`✅ Bot ready: ${client.user?.tag}`);
});

client.on('messageCreate', async (msg: Message) => {
  // 봇 자기 메시지 무시
  if (msg.author.bot) return;

  // 명령 prefix 체크
  if (!msg.content.startsWith(COMMAND_PREFIX)) return;

  const question = msg.content.slice(COMMAND_PREFIX.length).trim();

  if (!question) {
    await msg.reply('질문을 입력해주세요. 예: `!ask 안녕`');
    return;
  }

  // 타이핑 표시
  await msg.channel.sendTyping();

  const start = Date.now();
  let status: LogEntry['status'] = 'success';
  let response = '';

  try {
    response = await askClaude(question);

    // 디스코드 2000자 제한 (한 메시지)
    const trimmed = response.length > 1900
      ? response.slice(0, 1900) + '...\n(답변 잘림)'
      : response;

    // ⚖️ Position C 본문 추가
    const fullResponse = `${trimmed}\n\n${POSITION_C_FOOTER}`;

    await msg.reply(fullResponse);
  } catch (err: any) {
    if (err.message === 'RATE_LIMIT') {
      status = 'rate_limit';
      await msg.reply(ERROR_MESSAGES.rate_limit);
    } else if (err.message === 'TOO_LONG') {
      status = 'too_long';
      await msg.reply(ERROR_MESSAGES.too_long);
    } else if (err.message === 'API_ERROR') {
      status = 'api_error';
      await msg.reply(ERROR_MESSAGES.api_error);
    } else {
      status = 'api_error';
      await msg.reply(ERROR_MESSAGES.api_error);
      logError(err, { user: msg.author.id, question });
    }
  }

  // ★ LogOnTable 로깅
  logTrace({
    timestamp: new Date().toISOString(),
    user_id: msg.author.id,
    channel_id: msg.channel.id,
    question,
    response_length: response.length,
    duration_ms: Date.now() - start,
    status,
  });
});

// ★ Graceful shutdown
process.on('SIGINT', () => {
  console.log('🛑 SIGINT received, shutting down...');
  client.destroy();
  process.exit(0);
});

process.on('SIGTERM', () => {
  console.log('🛑 SIGTERM received, shutting down...');
  client.destroy();
  process.exit(0);
});

client.login(process.env.DISCORD_TOKEN!);
3-9 tsconfig.json + package.json scripts 🔗

TypeScript 컴파일 설정과 npm scripts를 구성합니다. "type": "module"이 핵심이에요.

💻 tsconfig.json
// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "node",
    "esModuleInterop": true,
    "strict": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "skipLibCheck": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"]
}
💻 package.json (scripts 추가)
// package.json (scripts 추가)
{
  "type": "module",
  "scripts": {
    "build": "tsc",
    "start": "node dist/bot.js",
    "dev": "nodemon --exec ts-node src/bot.ts",
    "lint": "echo 'Add eslint if needed'"
  }
}
⚠️ ESM/CJS 충돌 주의

package.json"type": "module"을 설정하면 모든 import 경로 끝에 .js를 명시해야 합니다.

예: import { askClaude } from './claude.js'; (소스는 .ts지만 경로는 .js)

3-10 ★ 첫 실행 + 검증 🔗

빌드 후 실행하고 디스코드 채널에서 테스트합니다. 로그 파일도 확인하세요.

💻 코드
# 빌드
npm run build

# 실행
npm start
# ✅ Bot ready: TSV Assistant#1234

# ★ 디스코드에서 테스트
# 봇 초대된 채널에서:
#   !ask 안녕

# 봇 응답:
# 안녕하세요! 무엇을 도와드릴까요?
#
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 이 봇은 분석 콘텐츠 보조 도구이며,
# 법률·의료·금융 등 전문 조언을 제공하지 않습니다.
# 중요한 결정은 전문가와 상의하시기 바랍니다.
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━

# 로그 확인
ls logs/
cat logs/2026-05-09.jsonl | head
# {"timestamp":"...","user_id":"...","question":"안녕",...}
🎉 첫 작동 성공!

!ask 안녕에 봇이 응답하면 성공입니다.

logs/ 폴더에 JSONL 파일이 생성되면 LogOnTable도 정상 작동하고 있어요.

3-11 ★ 함정 + 트러블슈팅 🔗

자주 발생하는 문제와 해결 방법을 정리했습니다. 막히면 여기서 먼저 확인하세요.

⚠️ Discord 봇 함정 5가지

[함정 1] MESSAGE CONTENT INTENT 활성화 안 됨

  • 증상: 봇이 message.content가 비어있음
  • 해결: Discord Developer Portal → Bot → Privileged Intents 모두 ON

[함정 2] dotenv 로딩 안 됨

  • 증상: process.env.DISCORD_TOKEN undefined
  • 해결: bot.ts 첫 줄 import 'dotenv/config';

[함정 3] ESM/CJS 충돌

  • 증상: SyntaxError: Cannot use import statement
  • 해결: package.json "type": "module" + 모든 import 끝 .js 명시

[함정 4] Anthropic API rate limit

  • 증상: 429 에러 자주 발생
  • 해결: 사용자 별 cooldown (5초) 추가

[함정 5] 봇 토큰 노출

  • 증상: GitHub push 시 Discord 자동 무효화
  • 해결: .env 절대 git push 금지 (.gitignore 일관)
📌 4편 정리
  • 1️⃣ Node.js 22 LTS 설치 — NVM 권장, 버전 전환 쉬움
  • 2️⃣ Discord 봇 토큰 + Privileged Intents — 3개 모두 ON 필수
  • 3️⃣ 프로젝트 구조 — .env·gitignore·src·logs
  • 4️⃣ ⚖️ Position C 본문 메시지 — 시리즈 메타 원칙 일관
  • 5️⃣ Anthropic SDK wrapper — rate limit·API 오류·질문 길이 다층 처리
  • 6️⃣ LogOnTable 로깅 — 일별 JSONL, 4 요소 (timestamp·user·question·status)
  • 7️⃣ 메인 봇 코드 — graceful shutdown 포함
  • 8️⃣ 현재 상태: 봇 작동 ✅ (종료 시 봇 중단 — 6편 systemd에서 해결)
📘 핵심 한 줄

Node.js + discord.js + Anthropic SDK = !ask 명령 봇 첫 작동.

시리즈 메타 원칙 (⚖️ Position C · LogOnTable · 다층 보호) 일관 적용이 핵심이에요.

다음 편: 5편 — Python + python-telegram-bot 봇 boilerplate (Telegram)

📘
별책부록 도우미
질문하기 OK
안녕하세요! Discord 봇 설정에 대해 무엇이든 물어보세요. 본문에서 찾아 답변해드릴게요. 👇