초기 스타트업에선 한 사람이 열 가지 일을 한다
MCP 이야기를 하기 전에, 우리 팀이 일하는 방식부터 짚어야겠다.
우리는 초기 스타트업이다. 역할 경계가 뚜렷하지 않다. 나부터도 하루 안에 웹 개발, 앱 개발, 서버 개발을 오가고, 틈틈이 기획을 만지고, 에러가 올라오면 CS까지 본다. 화면이 필요하면 디자인도 직접 한다. 사람이 부족한 조직에서는 자연스러운 일이다.
이렇게 역할을 계속 갈아타다 보면 예상치 못한 곳에서 시간이 샌다. 정작 코드를 짜는 시간보다, “지금 뭐부터 해야 하지”를 정리하는 시간이 더 커진다. 오늘 처리할 이슈는 뭐고, 어제 서버에서 터진 에러는 뭐였고, 리뷰만 기다리는 PR은 없는지 — 이걸 파악하는 것 자체가 매일 반복되는 진짜 일이 됐다.
문제는 그 정보가 도구마다 흩어져 있다는 점이다.
오늘 뭘 해야 하는지 한번 파악하려면 콘솔 네 개를 열고, 각각에서 필요한 걸 눈으로 훑고, 머릿속에서 합쳐야 했다. 특히 서버에서 뭔가 터졌을 때 AWS 콘솔을 열어 로그 그룹을 고르고 시간대를 맞춰가며 뒤지는 일은, 그날 하려던 진짜 작업을 시작하기도 전에 오전을 통째로 잡아먹곤 했다.
그럼 이 “정리”를 매번 사람이 직접 콘솔을 돌며 해야만 할까?
흩어진 건 도구고, 정리는 한 곳에서 해야 했다
도구가 흩어져 있는 건 어쩔 수 없다. Linear는 이슈를, Sentry는 에러를 다루는 데 최적화돼 있으니 굳이 하나로 합칠 이유가 없다. 흩어져야 하는 건 도구고, 하나로 모여야 하는 건 판단이었다. “그래서 지금 뭐부터 하지”라는 질문에 답하려고 네 곳을 사람이 직접 돌 필요는 없다.
여기서 Model Context Protocol(MCP)이 등장한다. MCP는 LLM이 외부 도구와 데이터를 표준화된 방식으로 호출하게 해주는 프로토콜이다. 도구마다 제각각인 API를 LLM이 이해할 수 있는 하나의 규격으로 감싸면, 어떤 도구를 언제 호출할지는 LLM이 스스로 판단한다.
그래서 방향을 이렇게 잡았다. 역할을 오가느라 바쁜 내가 매번 직접 조회하는 대신, AI 에이전트가 이 도구들을 가로질러 조회하고 정리하게 하자.
flowchart LR
subgraph before["도입 전"]
direction TB
P1[사람] --> L1[Linear 콘솔]
P1 --> A1[AWS 콘솔]
P1 --> S1[Sentry 콘솔]
P1 --> K1[Slack]
L1 -.->|복붙| H1((머릿속에서 합치기))
A1 -.->|복붙| H1
S1 -.->|복붙| H1
K1 -.->|복붙| H1
end
subgraph after["도입 후"]
direction TB
P2[사람] -->|"자연어 한 마디"| AI["AI 에이전트"]
AI --> M["MCP"]
M --> L2[Linear]
M --> A2[AWS]
M --> S2[Sentry]
M --> K2[Slack]
end
이 발상을 실제로 굴러가게 만든 게 요비스(yongbis)다. 아이언맨의 자비스에서 따온 이름으로, 흩어진 도구를 대신 돌며 내 개발 잡무를 봐주는 비서를 목표로 했다. 하나씩 풀어보자.
한 코어, 여러 표면으로 지었다
요비스는 Bun 기반 모노레포다. 설계에서 가장 중요하게 잡은 원칙은 순수 로직과 진입점을 분리하는 것이었다. 도구를 붙이는 방식은 여러 개일 수 있어도, 실제 API를 호출하는 로직은 한 곳에만 있어야 한다.
flowchart TD
subgraph core["packages/core — 순수 로직"]
C1["telegram · linear · sentry<br/>slack · aws 서비스"]
C2["zod 스키마로<br/>입출력 검증"]
end
subgraph tools["packages/mcp-tools — 툴 등록 공용"]
T1["registerYongbisTools()"]
end
subgraph surfaces["진입점 (표면)"]
S1["packages/cli<br/>CI · 크론"]
S2["packages/local-mcp<br/>stdio · 내 PC의 Claude"]
S3["apps/remote-mcp<br/>Hono HTTP · 팀 공용"]
end
core --> tools
tools --> S2
tools --> S3
core --> S1
같은 sendTelegramMessage 함수를 CLI도, 로컬 MCP도, 원격 MCP도 공유한다. 새 채널이나 기능을 붙일 때 코어 함수 하나와 어댑터 한 줄이면 끝나도록 만들었다.
이 구조 덕분에 로컬 MCP 서버 자체는 놀랄 만큼 짧다.
// packages/local-mcp/src/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { loadSecretsFromEnv, registerYongbisTools } from "yongbis-mcp-tools";
const server = new McpServer({ name: "yongbis-local", version: "0.0.0" });
// 시크릿은 환경 변수에서 주입된다 (Bun이 루트 .env를 자동 로드).
// 자격 증명이 설정된 서비스만 툴로 노출된다.
registerYongbisTools(server, loadSecretsFromEnv());
const transport = new StdioServerTransport();
await server.connect(transport);tsMcpServer를 만들고, 툴을 등록하고, 트랜스포트에 연결한다. 이게 전부다. 서버가 하는 일이 이렇게 얇은 이유는, 무거운 로직이 전부 코어에 있고 툴 등록이 공용으로 빠져 있기 때문이다.
그럼 그 registerYongbisTools 안에서는 무슨 일이 벌어질까?
도구 하나가 곧 시크릿 하나다
MCP 툴을 설계할 때 가장 신경 쓴 건 에이전트가 쓸 수 없는 도구를 아예 보지 못하게 하는 것이었다. Linear API 키가 없는데 linear_search_issues 툴이 목록에 떠 있으면, LLM은 그걸 호출했다가 실패하고 엉뚱한 추론을 시작한다.
그래서 규칙을 하나 세웠다. 설정된 시크릿이 있는 서비스만 툴로 등록한다.
// packages/mcp-tools/src/index.ts (일부 발췌)
export function loadSecretsFromEnv(env = process.env): YongbisSecrets {
const secrets: YongbisSecrets = {};
if (env.TELEGRAM_BOT_TOKEN) secrets.telegramBotToken = env.TELEGRAM_BOT_TOKEN;
if (env.LINEAR_API_KEY) secrets.linearApiKey = env.LINEAR_API_KEY;
if (env.SENTRY_AUTH_TOKEN && env.SENTRY_ORG) {
secrets.sentry = {
authToken: env.SENTRY_AUTH_TOKEN,
org: env.SENTRY_ORG,
baseUrl: env.SENTRY_URL || undefined,
};
}
// ...slack, aws
return secrets;
}ts그리고 툴을 등록할 때 이 시크릿을 조건으로 건다. 시크릿이 있으면 registerTool, 없으면 그냥 건너뛴다.
// packages/mcp-tools/src/index.ts (일부 발췌)
if (secrets.linearApiKey) {
const apiKey = secrets.linearApiKey;
server.registerTool(
"linear_search_issues",
{
title: "Linear · Search issues",
description:
"Full-text search Linear issues. Read-only. Returns identifier, title, state, assignee, team and URL.",
inputSchema: linearSearchIssuesInputSchema.shape, // zod 스키마의 shape
},
async (input) => {
const result = await searchLinearIssues({ ...input, apiKey });
return textResult(`Found ${result.count} Linear issue(s).`, result);
},
);
}ts두 가지가 눈에 띈다. 하나는 inputSchema에 zod 스키마의 .shape를 그대로 넘긴다는 점이다. 코어에서 이미 zod로 정의한 입력 스키마를 MCP 툴 스키마로 재사용하니, LLM이 넘긴 인자도 같은 규격으로 검증된다. 다른 하나는 description이다. 이건 사람이 아니라 LLM이 읽고 어떤 툴을 쓸지 판단하는 문장이라, Read-only라는 점과 무엇을 돌려주는지 명확히 적었다.
이렇게 외부 서비스에 붙인 툴은 지금 여섯 개다.
| 툴 | 대상 | 성격 |
|---|---|---|
telegram | Telegram | 쓰기 (메시지 발송) |
linear_search_issues | Linear | 읽기 |
sentry_search_issues | Sentry | 읽기 |
sentry_get_issue | Sentry | 읽기 |
slack_search_messages | Slack | 읽기 |
aws_query_logs | AWS CloudWatch Logs | 읽기 |
쓰기는 telegram 하나뿐이고 나머지는 전부 읽기 전용이다. 조회는 마음껏 하되, 외부에 영향을 주는 건 최소한으로 좁혔다.
각 툴의 실제 몸통은 코어에 있고, 하나같이 같은 모양이다. 입력을 zod로 검증하고, fetch로 API를 부르고, 응답을 다시 zod로 검증해 정규화된 객체를 돌려준다.
// packages/core/src/services/telegram.ts (일부 발췌)
export async function sendTelegramMessage(input: TelegramMessageOptions) {
const parsed = telegramMessageOptionsSchema.parse(input); // 입력 검증
const response = await fetch(
`https://api.telegram.org/bot${parsed.botToken}/sendMessage`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chat_id: parsed.chatId, text: parsed.message }),
},
);
const data = telegramSendMessageResponseSchema.parse(await response.json()); // 응답 검증
if (!response.ok || !data.ok || !data.result) {
throw new Error(data.description ?? "Telegram message request failed");
}
return { ok: true, chatId: parsed.chatId, messageId: data.result.message_id };
}ts입출력 양쪽을 전부 zod로 검증하는 이유는, LLM이 호출하는 경계이기 때문이다. 사람이 직접 부를 때보다 예상 밖의 인자가 들어올 여지가 크니, 경계에서 한 번 걸러야 안심하고 돌릴 수 있다.
그런데 서버 로그는 어떻게 했을까? Sentry는 에러의 요약을 주지만, 실제 서버에서 그 순간 무슨 일이 있었는지는 서버 로그를 봐야 안다.
서버 로그는 SSH 없이 읽기 전용으로만 봤다
가장 고민했던 부분이다. 서버에서 무슨 일이 있었는지 보려면 결국 서버 안쪽 데이터에 닿아야 한다. 그런데 흔히 하듯 ssh로 붙어 뒤지게 하면, 그건 AI 에이전트에게 셸을 통째로 쥐여주는 것이다. 셸이 열리는 순간 문제가 한둘이 아니다.
- 임의 명령 실행이 가능하다. 셸이 있으면 조회든 수정이든 삭제든 원칙적으로 뭐든 된다. 읽기만 하리라는 보장은 “그렇게 프롬프트를 짰으니까”뿐인데, 그건 강제가 아니라 부탁이다.
- 권한을 좁히기 어렵다.
ssh는 사실상 전부 아니면 전무다. “로그만 읽어라”를 셸 레벨에서 못 박기가 까다롭고, 결국 필요한 것보다 훨씬 넓은 권한을 내주게 된다. 최소 권한 원칙과 정반대다. - 프롬프트 인젝션의 피해가 곧 원격 코드 실행이 된다. 에이전트는 로그와 이슈 본문 같은 외부 텍스트를 읽는데, 거기에 악의적인 지시가 섞여 있을 수 있다. 셸이 없으면 프롬프트 인젝션이 성공해도 잘못된 답변에 그치지만, 셸이 있으면 그대로 서버에서 명령이 실행된다. 피해 범위 자체가 다르다.
- 자격 증명이 넓고 오래 산다. 에이전트가 쥔 SSH 키 하나가 서버 전체에 대한 장기 통행증이 된다. 유출되면 잃는 게 로그 조회 권한이 아니라 서버 그 자체다.
그래서 접근 방식을 뒤집었다. 서버에 명령을 실행하는 통로를 여는 대신, 에이전트에게 필요한 능력만 좁은 인터페이스로 내줬다. 필요한 건 딱 두 가지, 애플리케이션 데이터와 로그를 읽는 것이다.
애플리케이션 상태와 데이터는 읽기 전용 복제본(read replica)에서 본다. 앞의 여섯 개 툴에 쓴 원칙을 우리 서버 데이터에도 그대로 적용한 것이다. 복제본에 조회 전용 계정으로만 붙고, 그 위에 SELECT만 통과시키는 얇은 MCP 툴을 얹었다. 복제본을 보게 했으니 조회 부하가 운영 DB에 닿지도 않는다.
// apps/remote-mcp/src/tools/db.ts (예시)
// 읽기 전용 계정으로만 연결한다. 쓰기 권한 자체를 DB 레벨에서 뺀다.
const pool = new Pool({ connectionString: process.env.READONLY_DATABASE_URL });
server.registerTool(
"db_query",
{
title: "Read-only DB query",
description:
"Run a read-only SQL query against application data. SELECT only.",
inputSchema: dbQueryInputSchema.shape,
},
async ({ sql }) => {
// SELECT 이외의 구문은 애플리케이션 레벨에서도 한 번 더 막는다
if (!/^\s*select\b/i.test(sql)) {
throw new Error("Only SELECT queries are allowed.");
}
const inner = sql.replace(/;\s*$/, "");
// 서브쿼리로 감싸 결과량을 강제로 제한한다 (원본에 LIMIT이 있어도 안전)
const { rows } = await pool.query(`SELECT * FROM (${inner}) AS _q LIMIT 200`);
return textResult(`${rows.length} row(s).`, { rows });
},
);ts핵심은 안전장치가 한 겹이 아니라는 점이다. DB 계정 자체가 읽기 전용이라 쓰기 구문은 DB 레벨에서 거부되고, 애플리케이션 레벨에서 한 번 더 SELECT가 아닌 구문을 막고, 결과 행 수까지 강제로 제한한다. 한 겹이 뚫려도 다음 겹이 받치는 셈이다. 이렇게 해두면 설령 프롬프트 인젝션으로 에이전트가 엉뚱한 쿼리를 시도해도, 할 수 있는 최악이 “행 몇 개를 더 읽는 것”에 그친다. 셸을 줬을 때와 달리 피해 범위가 처음부터 조회로 갇혀 있다.
인프라·런타임 로그는 앞의 여섯 개 툴 중 하나인 aws_query_logs가 맡는다. CloudWatch Logs Insights 쿼리를 실행하고 결과를 폴링해서 돌려줄 뿐, 로그를 조회하는 것 외에는 아무것도 하지 않는다. DB든 CloudWatch든, 서버 안쪽을 들여다보는 통로는 전부 읽기 전용이라는 같은 원칙 위에 있다.
이제 도구들이 전부 읽기 전용으로 안전하게 묶였다. 그럼 이걸 실제 대응 흐름에 어떻게 엮었을까?
반복 대응은 파이프라인으로 묶었다
에러가 하나 올라왔을 때 하는 일은 매번 비슷하다. Sentry에서 어떤 에러인지 확인하고, 원인이 될 만한 커밋을 찾고, 그 시각의 서버 로그를 확인하고, 정리해서 기록하고, 팀에 알린다. 이 반복을 하나의 워크플로로 묶었다.
flowchart LR
A["① Sentry<br/>트리아지"] --> B["② 원인 커밋 진단<br/>GitHub + git log/blame"]
B --> C["③ 로그 상관분석<br/>읽기 전용 DB"]
C --> D["④ 문서화<br/>Notion"]
D --> E["⑤ 알림<br/>Telegram"]
여기서 재미있는 건, 이 한 세션 안에서 여러 MCP가 함께 물린다는 점이다. 요비스의 읽기 툴(Sentry, DB)뿐 아니라 GitHub MCP로 커밋을 훑고, Notion MCP로 문서를 남기고, 마지막에 요비스의 쓰기 툴인 telegram으로 네 줄짜리 요약을 보낸다. 조회는 여러 도구를 가로지르지만, 사람이 보는 결과는 텔레그램 알림 하나로 수렴한다.
에러 대응이 이렇게 정리되고 나니, 자연스럽게 다음 질문이 따라왔다. 에러 대응만 이렇게 물을 게 아니라, “오늘 뭐부터 하지” 자체도 이렇게 물어볼 수 있지 않을까?
이제 “오늘 뭐부터 하지”를 텔레그램에 묻는다
역할을 오가다 보면 컨텍스트가 자꾸 흩어진다. 앱을 만지다가 서버 에러를 보고, 다시 기획으로 넘어가면 아까 뭘 하려 했는지 놓친다. 그럴 때 콘솔 네 개를 다시 여는 대신, 늘 켜져 있는 텔레그램에 그냥 물어본다.
초기의 요비스는 보내기 전용이었다. 여기에 수신 → LLM 오케스트레이션 → 응답의 양방향 루프를 얹으니, 텔레그램이 알림 채널에서 대화형 업무 인터페이스로 바뀌었다.
핵심은 두뇌 역할을 하는 부분이다. Claude에 GitHub·Linear·Sentry MCP를 붙이면, 어떤 도구를 쓸지 LLM이 스스로 판단한다.
// packages/agent/src/brain.ts (예시)
export async function handleTelegramMessage(chatId: string, text: string) {
const history = histories.get(chatId) ?? [];
history.push({ role: "user", content: text });
const response = await anthropic.beta.messages.create({
model: "claude-opus-4-8",
max_tokens: 4096,
betas: ["mcp-client-2025-11-20"],
system: "너는 팀의 업무 비서야. 일상어로 물으면 도구를 조회해 핵심만 정리해.",
mcp_servers: [
{ type: "url", name: "github", url: "https://api.githubcopilot.com/mcp/" },
{ type: "url", name: "linear", url: "https://mcp.linear.app/mcp" },
{ type: "url", name: "sentry", url: "https://mcp.sentry.dev/mcp" },
],
tools: [
{ type: "mcp_toolset", mcp_server_name: "github" },
{ type: "mcp_toolset", mcp_server_name: "linear" },
{ type: "mcp_toolset", mcp_server_name: "sentry" },
],
messages: history,
});
const reply = textOf(response);
history.push({ role: "assistant", content: response.content });
histories.set(chatId, history); // 채팅방별 히스토리로 멀티턴 유지
// 요비스의 발송 기능을 그대로 재사용한다
await sendTelegramMessage({ botToken: process.env.TELEGRAM_BOT_TOKEN!, chatId, message: reply });
}ts채팅방별로 대화 히스토리를 유지하니 자연스러운 멀티턴이 된다. 그 위에 수신 루프 하나만 얹으면 끝이다.
// packages/agent/src/loop.ts (예시)
let offset: number | undefined;
while (true) {
const updates = await getTelegramUpdates(token, offset); // long-polling 수신
for (const u of updates) {
offset = u.update_id + 1;
if (u.message?.text) {
await handleTelegramMessage(String(u.message.chat.id), u.message.text);
}
}
}ts결과적으로 텔레그램에서 이런 대화가 오간다.
나 : 오늘 나 뭐부터 하면 돼?
봇 : 📋 Linear 기준 오늘 할 일 3개:
• [ENG-142] 결제 콜백 재시도 로직 (In Progress)
• [ENG-150] 회원가입 이메일 검증 (Todo)
• [ENG-151] 대시보드 차트 깨짐 (Todo)
나 : 142 관련 PR 있어?
봇 : GitHub에 열린 PR 1개 — #318 "retry payment callback"
리뷰 1명 승인, CI 통과. 머지만 하면 돼요.
나 : 어제 에러 많이 났어?
봇 : Sentry 기준 어제 5xx 47건, 대부분 결제 콜백 타임아웃(142와 동일 원인).
142 머지하면 같이 해소될 가능성이 높아요.
나 : 좋아, 이 내용 팀 슬랙에도 공유해줘
봇 : ✅ #dev 채널에 요약 전송했어요.한 대화 안에서 Linear 조회, GitHub 조회, Sentry 조회, Slack 발송이 이어졌다. 나는 콘솔을 한 번도 열지 않았고, 흩어진 역할들 사이에서 “뭐부터 할지”를 정리하는 일을 봇에게 넘겼다.
그런데 이걸 내가 물어보기 전에, 봇이 먼저 정리해두면 어떨까?
아침 7시, 봇이 먼저 브리핑한다
나는 보통 오전 7시쯤 일을 시작한다. 그래서 그 전에 봇이 먼저 하루를 정리해두게 했다. 크론이 정해진 시각에 에이전트 세션을 깨워, 밤사이 쌓인 걸 요약해 텔레그램으로 보낸다.
// scripts/morning-digest.ts (예시) — 크론이 오전 7시에 실행
const summary = await runAgent(
"Linear의 오늘 할 일, 어제 Sentry 신규 에러, 리뷰 대기 중인 PR을 " +
"각각 조회해서 아침 브리핑으로 5줄 이내로 요약해줘.",
);
// 요약은 LLM이, 발송은 요비스가 담당한다
await sendTelegramMessage({
botToken: process.env.TELEGRAM_BOT_TOKEN!,
chatId: process.env.TELEGRAM_CHAT_ID!,
message: summary,
});ts여기서도 역할이 깔끔하게 나뉜다. 무엇을 어떻게 요약할지는 LLM이 판단하고, 요비스는 그 결과를 텔레그램으로 실어 나르는 I/O 레이어에 충실하다. 자리에 앉으면 이미 “오늘의 브리핑”이 도착해 있으니, 콘솔을 뒤지는 대신 바로 첫 작업을 시작한다.
여기까지가 완성된 워크플로다. 그런데 이걸 만들면서, MCP 서버를 직접 짤 때 무엇을 챙겨야 하는지도 함께 배웠다.
MCP 서버를 만들 때 무엇을 고려했나
MCP 공식 문서를 기준으로, 서버를 만들 때 실제로 고민해야 했던 지점을 정리한다.
트랜스포트 — 로컬은 stdio, 원격은 Streamable HTTP
MCP는 두 가지 트랜스포트를 규정한다. 로컬용 stdio는 클라이언트가 서버를 자식 프로세스로 띄우고 표준 입출력으로 통신한다. 네트워크 오버헤드가 없어 내 PC의 Claude에 붙이기에 가장 빠르다. 원격용 Streamable HTTP는 HTTP POST로 메시지를 주고받고, 필요하면 SSE로 스트리밍한다. 예전의 SSE 단독 트랜스포트는 이제 레거시다.
요비스도 이 둘을 그대로 나눴다. 내 PC용은 stdio, 팀 공용은 Hono로 띄운 HTTP 서버다.
// apps/remote-mcp/src/index.ts (일부 발췌)
app.post("/mcp", async (c) => {
const server = createServer(); // 요청마다 새 McpServer + registerYongbisTools
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true, // 상태를 남기지 않는 stateless HTTP
});
await server.connect(transport);
try {
return await transport.handleRequest(c.req.raw);
} finally {
await server.close();
}
});ts원격 서버의 보안 — Origin 검증부터
MCP 스펙은 HTTP 트랜스포트에 대해 몇 가지를 강하게 요구한다. 서버는 들어오는 연결의 Origin 헤더를 검증해 DNS 리바인딩 공격을 막아야 하고, 위반 시 403을 돌려야 한다. 로컬에서 띄울 때는 외부에 노출하지 말고 127.0.0.1에만 바인딩하는 게 좋다. 그리고 모든 연결에는 인증이 있어야 한다.
툴 설계와 보안
툴 쪽에서 스펙이 요구하는 건 네 가지다. 입력 검증, 접근 제어, 레이트 리밋, 출력 새니타이즈. 요비스는 입력을 zod로 검증하고, 읽기 전용 DB 툴에서 봤듯 결과량을 강제로 제한한다. 여기에 하나 더, 툴의 description은 LLM이 읽는 문장이므로 작고 명확한 입출력과 함께 신경 써서 적어야 한다.
인증 — PAT와 OAuth 2.1
토큰이 곧 신원이다. 서버는 요청에 실린 토큰을 토큰 → user_id로 해석해, 그 사용자의 데이터만 돌려준다. 운영 방식은 두 갈래다.
- PAT(개인 액세스 토큰): 나 혹은 우리 팀만 쓰는 내부용에 적합하다. 발급 → 사용 → 유출이 의심되면 폐기 후 재발급. 단순하고 충분하다.
- OAuth 2.1: 외부 고객이 각자 붙이는 공개 제품이면 이쪽이 정석이다. MCP는 인증 토큰을 얻는 표준으로 OAuth를 권장한다. 연결할 때 한 번 로그인·동의하면, 이후 만료된 access token은 refresh token으로 자동 갱신된다.
어느 쪽이든 원칙은 같다. 최소 권한 스코프, 만료 강제, 토큰은 원문 대신 해시로 저장, 전달은 URL 경로보다 Authorization 헤더로, 그리고 항상 HTTPS.
휴먼 인 더 루프
마지막으로, 조회는 마음껏 하되 외부에 영향을 주는 작업은 먼저 확인을 받게 했다. 요비스가 읽기 위주로 설계되고 쓰기가 telegram 하나로 좁혀진 것도 같은 이유다.
돌아보면, 탭 네 개에서 대화 하나로
돌아보면 이 작업은 새로운 도구를 만든 게 아니라, 흩어진 도구들 사이에서 판단을 한 곳으로 모은 것이었다.
초기 스타트업에서 한 사람이 웹·앱·서버·기획·CS·디자인을 오가다 보면, 정작 큰 시간은 일 자체가 아니라 “지금 뭐부터 하지”를 정리하는 데서 샜다. 그 정리를 하려고 매일 아침 콘솔 네 개를 열어 로그를 뒤지고 이슈를 훑고 머릿속에서 합쳤다.
이제는 그 일을 대화 하나로, 혹은 아침에 도착해 있는 브리핑 하나로 넘긴다. AWS 콘솔을 열어 로그 그룹을 헤매던 오전이, 텔레그램 한 줄로 줄었다.
flowchart LR
B["콘솔 네 개를 오가며<br/>'뭐부터 하지' 정리"] -->|"MCP로 묶기"| A["대화 한 번 · 아침 브리핑 하나"]
A --> R["정리에 쓰던 힘을<br/>실제 일에 쓴다"]
도구는 여전히 흩어져 있다. 다만 그걸 가로질러 정리하는 일은, 이제 내가 아니라 에이전트가 한다.