workstream-kb — 업무 지식 베이스 자동화: MS365 수집부터 Claude 리포트 생성까지¶
개요¶
하루에 수십 통의 메일과 수백 개의 Teams 메시지가 오간다. 다음 날 아침에 "어제 뭐 했지?"를 되짚으려면 채팅방 여러 개를 뒤져야 하고, 메일함을 다시 스크롤해야 한다. 이걸 자동화했다.
workstream-kb는 2-Layer 하이브리드 시스템이다.
- Layer 1 (Fetcher): 30분마다 MS Graph API로 메일/Teams 채팅/Teams 채널을 수집하여
inbox/에 JSON으로 저장한다. AI 비용이 발생하지 않는다. - Layer 2 (Processor): 매일 07:00에 inbox 전체를 Claude CLI 1회 호출로 종합 업무 리포트로 변환한다.
daily/{date}.md에 저장된다.
추가로 Archiver가 6개월 이전 데이터를 archive/로 이동하고, Docsify 뷰어가 브라우저에서 리포트를 탐색할 수 있게 한다.
전체 아키텍처¶
flowchart LR
MS365["MS Graph API<br/>메일 + Teams"]
Fetcher["Fetcher<br/>30분마다 cron"]
Inbox["inbox/<br/>mail / teams-chat / teams-channel"]
Processor["Processor<br/>매일 07:00"]
Claude["Claude CLI<br/>1회 호출"]
Daily["daily/<br/>YYYY-MM-DD.md"]
Viewer["Docsify Viewer<br/>nginx + Docker"]
Archive["archive/<br/>6개월 이후"]
MS365 -->|"Graph API"| Fetcher
Fetcher -->|"JSON 저장"| Inbox
Inbox --> Processor
Processor -->|"프롬프트 + 입력 JSON"| Claude
Claude -->|"마크다운 리포트"| Daily
Daily --> Viewer
Daily -->|"Archiver"| Archive
핵심 설계 결정은 수집과 처리의 분리다. Fetcher는 AI를 사용하지 않고 raw 데이터만 쌓는다. AI 비용은 하루 1회, Processor에서만 발생한다. Fetcher가 30분마다 돌아도 Claude API 비용은 0원이다.
Layer 1: Fetcher — MS Graph API 데이터 수집¶
토큰 관리: MS365 MCP 서버와 캐시 공유¶
별도의 OAuth 인증 플로우를 구현하지 않았다. MS365 MCP 서버(~/.config/ms-365-mcp/)의 토큰 캐시를 그대로 공유한다.
export class TokenManager {
constructor() {
this.msalApp = new PublicClientApplication({
auth: {
clientId: MS365_CLIENT_ID,
authority: MS365_AUTHORITY,
},
});
}
async getAccessToken() {
// 1. MCP 서버의 토큰 캐시 파일 로드
await this.loadTokenCache();
// 2. 선택된 계정 정보 읽기
const selectedAccount = this.readSelectedAccount();
// 3. 캐시에서 계정 매칭
const accounts = await this.msalApp.getTokenCache().getAllAccounts();
const account = accounts.find(a =>
a.homeAccountId === accountId || a.username === selectedAccount.username
);
// 4. Silent token 획득 (MSAL이 만료 시 자동 갱신)
const result = await this.msalApp.acquireTokenSilent({
account,
scopes: MS365_SCOPES,
});
// 5. 갱신된 토큰 캐시 저장
await this.saveTokenCache();
return result.accessToken;
}
}
acquireTokenSilent가 refresh token으로 자동 갱신해 주므로, 한 번 MCP 서버에서 로그인하면 이후에는 수동 인증 없이 계속 동작한다. 토큰 갱신 실패 시에는 macOS terminal-notifier로 데스크톱 알림을 보내서 재로그인을 유도한다.
Graph Client: 재시도와 Rate Limit¶
MS Graph API 호출을 담당하는 클라이언트다. 세 가지 에러 상황을 구분한다.
export class GraphClient {
async makeRequest(method, endpoint, options = {}) {
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
const token = await this.tokenManager.getAccessToken();
const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
// 401: 인증 실패 — 재시도 의미 없음, 즉시 에러
if (response.status === 401) throw new AuthError(`인증 실패: ${endpoint}`);
// 429: Rate limit — Retry-After 헤더 존중
if (response.status === 429) {
const retryAfter = parseInt(response.headers.get('Retry-After') || '5');
await this.sleep(retryAfter * 1000);
continue;
}
// 5xx: 서버 오류 — exponential backoff
if (response.status >= 500) {
await this.sleep(INITIAL_BACKOFF_MS * Math.pow(2, attempt - 1));
continue;
}
const data = await response.json();
return this.stripOdataProperties(data);
}
}
}
stripOdataProperties는 Graph API 응답에 포함된 @odata.context, @odata.nextLink 같은 메타데이터를 재귀적으로 제거한다. inbox JSON 파일의 용량을 줄이고, 이후 Processor에서 불필요한 노이즈가 Claude에게 전달되는 것을 방지한다.
메일 수집: HTML을 Markdown으로¶
MailFetcher는 Graph API /me/messages를 호출하여 메일을 가져온다.
async fetchNewMails() {
// DATA_START_DATE 이전 데이터는 무시
const filterDate = candidate < startFloor ? startFloor : candidate;
const data = await this.graphClient.get('/me/messages', {
$filter: `receivedDateTime ge ${filterDate}`,
$top: String(MAIL_FETCH_LIMIT),
$orderby: 'receivedDateTime desc',
$select: 'id,subject,from,toRecipients,receivedDateTime,body,bodyPreview,hasAttachments,importance',
});
for (const msg of messages) {
if (this.dedupManager.isProcessed('mail', msg.id)) continue;
const attachments = await this.processAttachments(msg);
const bodyMarkdown = this.convertBodyToMarkdown(msg.body);
// ...
}
}
중복 방지는 DedupManager가 처리한다. processed-ids.json에 처리 완료된 메시지 ID를 기록하고, 다음 실행에서 건너뛴다. 30분마다 실행되니 같은 메일을 두 번 가져오면 안 된다.
메일 본문은 HTML인 경우가 많다. Turndown 라이브러리로 Markdown 변환하되, 변환 실패 시 HTML 태그를 regex로 제거하는 폴백이 있다.
convertBodyToMarkdown(body) {
if (body.contentType === 'html') {
try {
return turndownService.turndown(body.content);
} catch (err) {
return body.content.replace(/<[^>]+>/g, '').trim();
}
}
return body.content;
}
첨부파일은 10MB 이하만 저장한다. contentBytes(base64)를 디코딩하여 inbox/mail/attachments/에 저장하고, 나중에 Processor가 daily/attachments/{date}/로 복사한다.
Teams 수집: 채팅과 채널¶
TeamsFetcher는 두 가지 경로로 메시지를 수집한다.
채팅 (/me/chats): 1:1 DM과 그룹 채팅을 가져온다. 채팅 목록을 먼저 조회하고, 각 채팅의 메시지를 순회한다. 시스템 메시지(messageType !== 'message')는 건너뛴다.
for (const chat of chats) {
const msgData = await this.graphClient.get(`/chats/${chat.id}/messages`, {
$top: String(TEAMS_MESSAGE_LIMIT),
});
for (const msg of messages) {
if (msg.messageType && msg.messageType !== 'message') continue;
if (msg.createdDateTime && msg.createdDateTime <= filterDate) continue;
// ...
}
}
채널 (/me/joinedTeams → /teams/{id}/channels → /channels/{id}/messages): 가입된 팀 → 채널 → 메시지 3단계 순회다. 접근 불가 채널(403/404)은 경고만 남기고 건너뛴다.
Teams 메시지의 HTML은 메일보다 단순하다. <br>을 줄바꿈으로, <p>/<div>를 줄바꿈으로 변환하고 나머지 태그를 제거하는 regex 기반 처리로 충분하다. Turndown까지 쓸 필요가 없다.
데이터 구조¶
Fetcher가 생성하는 inbox JSON은 소스 타입별로 분리된다.
inbox/
├── mail/
│ ├── {message-id}.json
│ └── attachments/
│ └── {message-id}_{filename}
├── teams-chat/
│ └── {chat-id}_{message-id}.json
└── teams-channel/
└── {team-id}_{channel-id}_{message-id}.json
파일 하나가 메시지 하나에 대응한다. Processor가 디렉토리 전체를 읽어서 한 번에 처리하는 구조이므로, 개별 파일의 크기보다 전체 파일 수가 중요하다.
Layer 2: Processor — Claude CLI로 일일 리포트 생성¶
데이터 전처리¶
Processor는 inbox의 모든 JSON을 읽은 뒤 세 단계의 전처리를 거친다.
1. 채팅방별 그룹화: 메일, Teams 채팅, Teams 채널 메시지를 "방" 단위로 묶는다. Teams 채팅은 chatId 기준, 채널은 teamId_channelId 기준, 메일은 하나의 mail 방으로 통합한다. 1:1 DM은 상대방 이름으로 표시하고, 그룹 채팅은 참여자 이름을 조합한다.
function groupByRoom(items) {
const rooms = new Map();
for (const item of items) {
// teams-chat → chatId 기준
// teams-channel → teamId_channelId 기준
// mail → 단일 'mail' 방
}
// 2차 패스: displayName 미결정 채팅방 처리
for (const [roomKey, room] of rooms) {
if (room.displayName) continue;
const otherMembers = members.filter(m => m !== MY_DISPLAY_NAME);
if (otherMembers.length === 1) {
room.displayName = `DM: ${otherMembers[0]}`;
}
}
}
2. 노이즈 필터링: "네", "넵", "ㅋㅋ", "확인" 같은 단답과 시스템 발신자(noreply@, mailer-daemon) 메일을 제거한다.
const NOISE_PATTERNS = /^(네|넵|넹|ㅇㅇ|ㅋㅋ+|ㅎㅎ+|ok|확인|감사|수고|👍|👌)$/i;
const SYSTEM_SENDERS = /noreply|no-reply|microsoft|mailer-daemon|postmaster/i;
필터링 후 메시지가 0건인 방은 아예 삭제한다. Claude에게 보내는 입력 크기를 줄이는 것이 목적이다.
3. 입력 JSON 구성: Claude에게 보낼 구조화된 입력을 만든다.
function buildReportInput(rooms, dateStr) {
const roomsData = rooms.map(room => ({
name: room.displayName,
type: room.roomType,
participants: [...room.members],
messages: room.messages.map(m => ({
from: m.from?.name,
time: extractTime(m.createdDateTime),
content: (m.content || m.bodyPreview || '').slice(0, 500),
// 메일은 subject, to, attachments 추가
})),
}));
return { date: dateStr, myName: MY_DISPLAY_NAME, stats, rooms: roomsData };
}
메시지 본문은 500자로 잘린다. 하루에 수백 개 메시지가 올 수 있고, Claude의 입력 크기를 제어해야 한다.
Claude CLI 호출과 품질 검증¶
프롬프트 템플릿(prompts/daily-report.md)에 입력 JSON을 삽입하여 Claude CLI를 1회 호출한다.
function generateDailyReport(rooms) {
const promptTemplate = readFileSync(join(PROMPTS_DIR, 'daily-report.md'), 'utf-8');
const inputJson = JSON.stringify(reportInput, null, 2);
const fullPrompt = promptTemplate
.replace('{INPUT}', inputJson)
.replace('{MY_NAME}', MY_DISPLAY_NAME)
.replace(/{DATE}/g, today);
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
const result = execFileSync(CLAUDE_CLI, [
'-p', fullPrompt,
'--output-format', 'json',
'--no-session-persistence',
'--dangerously-skip-permissions',
], { timeout: CLAUDE_TIMEOUT_MS, maxBuffer: 10 * 1024 * 1024, env: getClaudeEnv() });
const reportContent = extractMarkdownFromResponse(result);
const validation = validateReport(reportContent);
if (!validation.valid) {
// 디버그용 raw 응답 저장
writeFileSync(join(LOGS_DIR, `${today}-raw-attempt${attempt}.txt`), result);
if (attempt < MAX_RETRIES) continue;
}
writeFileAtomic(reportPath, reportContent);
return { date: today, path: `daily/${today}.md`, totalMessages };
}
}
여기서 getClaudeEnv()가 흥미롭다. process.env를 복사하되 CLAUDECODE 환경 변수를 삭제한다. Claude Code 안에서 Processor를 실행할 때, 내부 Claude CLI 호출이 Claude Code 세션과 충돌하는 것을 방지하기 위해서다.
품질 검증은 세 가지를 확인한다:
function validateReport(content) {
if (!content || content.length < 2000)
return { valid: false, reason: `응답이 너무 짧음 (${content?.length || 0}자)` };
if (!content.includes('---'))
return { valid: false, reason: 'front-matter(---) 없음' };
if (!content.includes('# 업무 일일 리포트'))
return { valid: false, reason: '"# 업무 일일 리포트" 헤딩 없음' };
return { valid: true };
}
초기에는 Claude가 "리포트를 생성했습니다"라는 메타 설명만 반환하는 경우가 있었다. 검증 실패 시 최대 2회 재시도하고, raw 응답을 logs/에 저장하여 디버깅할 수 있게 했다. 프롬프트에도 "리포트 본문 자체를 출력하세요"라는 명시적 지시를 추가하여 이 문제를 해결했다.
프롬프트 설계¶
daily-report.md 프롬프트는 약 150줄이다. 단순히 "요약해줘"가 아니라, 리포트의 정확한 구조와 품질 기준을 정의한다.
핵심 원칙:
- 구체성: "논의함" 대신 "A가 B에게 ~를 요청, C 방식으로 결정" 수준
- 맥락 보존: 나중에 다시 봤을 때 "이게 무슨 건이었지?"가 안 되도록 배경/이유 포함
- 핵심 발언 인용: 중요한 의사결정/수치는 > 인용 형식으로 원문 보존
- 추적 가능성: 누가 → 누구에게 → 무엇을 → 왜 → 언제까지 5W 구조
리포트 구조:
## 핵심 요약 (3~5개 bullet)
## 오늘의 의사결정
## 액션 아이템
### 내가 처리할 건 (테이블)
### 다른 사람에게 요청한 건 (테이블)
## 프로젝트별 현황
### {프로젝트명} — 오늘 진행/기술 상세/이슈/다음 단계
## 외부 대응 (고객사/파트너)
## 팀 활동 (팀원별)
## 주요 기술 메모
## 일정
## 통계
MY_DISPLAY_NAME을 프롬프트에 전달하여 "내가 처리할 건" vs "다른 사람에게 요청한 건"을 구분한다. 보안 관련으로 비밀번호/토큰/API 키는 ****로 마스킹하도록 지시한다.
첨부파일은 Docsify의 라우팅 규칙에 맞게 [파일명](daily/attachments/{DATE}/파일명 ':ignore') 형식으로 링크하도록 지시한다. :ignore가 없으면 Docsify가 .docx 파일을 마크다운으로 파싱하려고 시도하여 에러가 난다.
처리 후 정리¶
리포트 생성이 성공하면 inbox 파일을 삭제한다. 실패하면 inbox를 보존하여 다음 날 재처리할 수 있게 한다.
// 리포트 성공 시에만 inbox 정리
if (reportResult) {
cleanupInbox(items);
} else {
log('warn', '리포트 생성 실패 — inbox 파일을 보존합니다');
}
이 결정이 중요하다. Claude CLI 호출이 타임아웃되거나 검증에 실패해도 원본 데이터는 살아있다.
Archiver: 데이터 보존 정책¶
6개월 이전 리포트를 archive/daily/로 이동하는 단순한 스크립트다. index.json의 경로도 함께 업데이트한다.
function archiveDaily(cutoffMonth) {
const files = readdirSync(DAILY_DIR)
.filter(f => /^\d{4}-\d{2}-\d{2}\.md$/.test(f));
for (const file of files) {
const fileMonth = file.slice(0, 7); // "2026-03"
if (fileMonth >= cutoffMonth) continue;
renameSync(join(DAILY_DIR, file), join(archiveDailyDir, file));
}
}
DATA_START_DATE로 수집 시작일을 제한하고, ARCHIVE_AFTER_MONTHS로 보존 기간을 설정한다. 기본값은 2026-02-01 시작, 6개월 보존이다.
Docsify 뷰어: 빌드 없는 문서 브라우저¶
리포트를 브라우저에서 탐색할 수 있도록 Docsify 기반 뷰어를 Docker로 제공한다.
# docker-compose.yml
services:
viewer:
build: ./docker
container_name: workstream-kb-viewer
ports:
- "3000:80"
volumes:
- ./index.html:/usr/share/nginx/html/index.html:ro
- ./homepage.md:/usr/share/nginx/html/homepage.md:ro
- ./_sidebar.md:/usr/share/nginx/html/_sidebar.md:ro
- ./daily:/usr/share/nginx/html/daily:ro
- ./archive:/usr/share/nginx/html/archive:ro
- ./index.json:/usr/share/nginx/html/index.json:ro
nginx 설정에서 핵심은 두 가지다:
index.json은 캐시하지 않는다 (새 리포트 반영을 위해)- SPA fallback으로 Docsify 라우팅을 지원한다
location = /index.json {
expires -1;
add_header Cache-Control "no-cache";
}
location / {
try_files $uri $uri/ /index.html;
}
_sidebar.md는 generate-sidebar.mjs가 daily/ 디렉토리를 스캔하여 자동 생성한다. Processor가 리포트를 저장한 후 자동으로 sidebar를 재생성한다. 파일명에서 날짜를 추출하고, front-matter에서 제목을 읽어서 [날짜 - 제목](daily/날짜.md) 형식의 링크를 만든다.
Atomic Write 패턴¶
전체 시스템에서 파일 쓰기는 atomic write 패턴을 일관되게 사용한다.
function writeFileAtomic(filePath, content) {
const tmpPath = filePath + '.tmp.' + Date.now();
writeFileSync(tmpPath, content, 'utf-8');
renameSync(tmpPath, filePath);
}
임시 파일에 먼저 쓰고 rename으로 교체한다. 쓰기 중 프로세스가 죽어도 원본 파일이 손상되지 않는다. cron으로 30분마다 실행되는 스크립트에서 이런 안전장치는 필수적이다. sync-state.json, processed-ids.json, index.json, inbox JSON, 리포트 파일 모두 이 패턴을 쓴다.
Graceful Degradation¶
시스템 전체에서 일관된 에러 처리 철학이 있다: 개별 항목의 실패가 전체를 중단하지 않는다.
Fetcher에서:
// 메일이 실패해도 Teams는 계속 수집
try { mailCount = await mailFetcher.fetchNewMails(); }
catch (err) { hasError = true; }
try { chatCount = await teamsFetcher.fetchChats(); }
catch (err) { hasError = true; }
Processor에서:
// 리포트 생성, 첨부파일 복사, index 업데이트, inbox 정리를 각각 try-catch
try { reportResult = generateDailyReport(rooms); }
catch (err) { log('error', `리포트 생성 실패: ${err.message}`); }
try { copyAttachments(items, today); }
catch (err) { log('error', `첨부파일 복사 실패: ${err.message}`); }
메일 1통 처리에 실패해도 나머지 49통은 정상 처리된다. Teams 403 에러로 특정 채널에 접근 못 해도 다른 채널 메시지는 수집된다. 이 방식이 cron 기반 자동화에서는 가장 현실적이다.
설정 중앙화¶
모든 설정은 scripts/lib/config.mjs에서 .env 기반으로 관리한다.
export const MS365_TOKEN_CACHE_PATH = expandHome(
env('MS365_TOKEN_CACHE_PATH', '~/.config/ms-365-mcp/.token-cache.json')
);
export const MAIL_FETCH_LIMIT = envInt('MAIL_FETCH_LIMIT', 50);
export const TEAMS_CHAT_LIMIT = envInt('TEAMS_CHAT_LIMIT', 30);
export const DATA_START_DATE = env('DATA_START_DATE', '2026-02-01');
export const ARCHIVE_AFTER_MONTHS = envInt('ARCHIVE_AFTER_MONTHS', 6);
export const CLAUDE_TIMEOUT_MS = envInt('CLAUDE_TIMEOUT_MS', 300000);
모든 export에 합리적인 기본값이 있어서 .env 없이도 동작한다. 하드코딩이 없으므로 환경(개발/운영)에 따라 .env만 바꾸면 된다. Claude CLI 타임아웃(5분), 첨부파일 크기 제한(10MB), 데이터 보존 기간(6개월)이 모두 설정 가능하다.
스케줄링: cron 설정¶
리눅스에서는 crontab으로 설정한다.
# 30분마다 데이터 수집
*/30 * * * * node /home/son/projects/personal/workstream-kb/scripts/fetcher.mjs
# 매일 07:00 리포트 생성
0 7 * * * node /home/son/projects/personal/workstream-kb/scripts/processor.mjs
macOS에서는 launchd plist를 사용한다. config/com.kb.fetcher.plist와 config/com.kb.processor.plist가 준비되어 있다. Fetcher의 exit code 2(인증 오류)를 감지하면 terminal-notifier로 알림을 보낸다.
회고¶
잘 된 것¶
2-Layer 분리: Fetcher와 Processor를 분리한 것이 정확한 판단이었다. Fetcher가 매번 Claude를 호출했다면 하루에 48회(30분 × 24시간) API 비용이 발생했을 것이다. 지금은 하루 1회다. 데이터 수집의 빈도와 AI 처리의 빈도를 독립적으로 조절할 수 있다.
프롬프트의 구체성: "요약해줘"가 아니라 150줄짜리 상세 프롬프트를 작성한 덕분에, 리포트 품질이 일관적이다. 특히 5W 구조와 인용 원칙이 효과적이다. 3개월 전 리포트를 다시 봤을 때 "이게 뭔 소리지?"가 되지 않는다.
Atomic write + Graceful degradation: cron 기반 자동화에서 이 둘은 선택이 아니라 필수다. 한 달 운영하면서 데이터 손실이 한 건도 없었다.
개선할 것¶
Graph API 페이지네이션: 현재 $top 파라미터로 최대 건수만 제한하고, @odata.nextLink를 따라가는 페이지네이션은 구현하지 않았다. 하루에 50통 이상 메일이 올 경우 누락이 발생할 수 있다.
벡터 검색: 현재는 Docsify의 전문 검색뿐이다. 리포트가 쌓이면 "3월에 제주은행 관련 논의가 뭐 있었지?" 같은 시맨틱 검색이 필요해질 것이다. 기존에 운영 중인 Qdrant를 연동하면 해결할 수 있다.
멀티 계정: 토큰 캐시를 MS365 MCP 서버와 공유하는 방식은 단일 계정만 지원한다. 여러 MS365 계정의 데이터를 동시에 수집하려면 TokenManager를 확장해야 한다.
관련 글
- MkDocs 블로그에 온톨로지 기반 Knowledge Graph 구현하기
ForceAtlas2GraphologyKnowledge Graph - GitHub Pages 블로그에 SEO + GEO 최적화 적용하기
GEOGitHub PagesGoogle Search Console - Orama BM25로 MkDocs 블로그 검색을 완전히 교체한 과정
BM25JavaScriptMkDocs - GitHub Pages 커스텀 도메인 마이그레이션 + GoatCounter 조회수 연동
DNSGitHub PagesGoatCounter - CLAUDE.md로 AI 코딩 어시스턴트 가이드라인 작성하기
AICLAUDE.mdClaude Code