Home

슬랙에서 요청하면 PR이 올라오는 봇을 만들었어요

개발하다 보면 반복되는 류의 요청들이 있어요. "이 텍스트 문구만 바꿔주세요", "이 버튼 조건 하나만 수정해주세요" 같은 것들. 개발자 입장에선 중요도가 낮고, 요청한 사람 입장에서는 왜 이렇게 오래 걸리나 싶은 것들이요. 이런 류의 작업을 슬랙 멘션 하나로 처리할 수 있으면 어떨까 싶었습니다. 요청하면 알아서 코드 짜고 PR 올려주는 봇.

만들면서 Anthropic의 Managed Agents API와 Slack의 새 어시스턴트 API를 쓰게 됐는데, 둘 다 아직 많이 알려지지 않은 것 같아서 같이 정리해봅니다.


Anthropic Managed Agents

Anthropic SDK를 보면 client.beta.sessions라는 API가 있어요. Claude를 직접 호출하는 게 아니라, Anthropic이 관리하는 환경에서 에이전트를 실행시키고, 그 에이전트와 대화를 이어가는 방식입니다.

일반 Claude API와 뭐가 다르냐면, 에이전트가 MCP(Model Context Protocol)를 통해 GitHub, Linear 같은 도구를 직접 쓸 수 있어요. 코드를 짜고 커밋을 날리는 게 Claude API 호출 한 번으로 끝나는 게 아니라, 에이전트가 알아서 도구를 골라 쓰면서 여러 단계를 처리합니다.

핵심 개념은 세 가지예요.

Environment — 에이전트가 실행될 환경입니다. Anthropic 콘솔에서 미리 만들어두는 것으로, 어떤 MCP 서버를 쓸지, 어떤 인프라에서 실행될지를 정의합니다. 코드에서는 DEV_ENV_ID로 참조합니다.

Agent — 시스템 프롬프트와 설정을 담은 에이전트 정의입니다. 역시 콘솔에서 미리 만들어두고, 코드에선 agent_xxx 형태의 ID로 참조합니다.

Session — 에이전트와 실제로 대화를 주고받는 단위입니다. 하나의 작업 요청이 하나의 세션에 대응됩니다.

const session = await anthropic.beta.sessions.create({
  agent_id: agentId,
  environment_id: envId,
  metadata: {
    slack_channel: channelId,
    slack_thread_ts: threadTs,
    status: "RECEIVED",
  },
});

세션을 만들고 나면 events.send()로 메시지를 보내고, events.stream()으로 에이전트의 응답을 스트리밍으로 받습니다.

// 사용자 메시지 전송
await anthropic.beta.sessions.events.send(sessionId, {
  type: "user.message",
  content: [{ type: "text", text: userMessage }],
});

// 응답 스트리밍
const stream = await anthropic.beta.sessions.events.stream(sessionId);
for await (const event of stream) {
  if (event.type === "agent.message") {
    // 슬랙에 전달
  }
}

에이전트가 도구를 쓰다가 뭔가 허락이 필요한 경우엔 requires_action 이벤트가 오는데, 자동 승인할지 여부를 봇에서 결정합니다. 이 봇에서는 기본적으로 전부 자동 승인했어요.


Sessions를 DB처럼 쓰기

처음 설계할 때 가장 고민한 건 상태 저장이었어요. "이 슬랙 스레드가 어떤 세션이다", "지금 어느 단계까지 왔다" 같은 걸 어딘가에 저장해야 하는데, 새 DB를 도입하기가 번거로웠습니다.

Managed Sessions API에는 metadataevents라는 게 있어요. 세션에 키-값 형태로 메타데이터를 붙일 수 있고, 이 값을 언제든 업데이트할 수 있습니다. 여기에 필요한 상태를 전부 넣었어요.

await anthropic.beta.sessions.update(sessionId, {
  metadata: {
    status: "IMPLEMENTING",
    target_repo: "my-org/service",
    branch_name: "ABC-123/fix-display-condition",
    pr_url: "https://github.com/...",
  },
});

슬랙 이벤트가 들어오면 channel:thread_ts 조합으로 세션 ID를 찾는데, 이것도 외부 DB 없이 인메모리 캐시와 sessions.list() 폴백으로 해결했어요. 서버가 재시작되면 최근 2일치 세션을 스캔해서 진행 중이던 것들을 복구합니다.

// 세션 찾기 - 캐시 miss 시 sessions.list로 폴백
async function resolveSession(channelId: string, threadTs: string) {
  const cached = sessionCache.get(`${channelId}:${threadTs}`);
  if (cached) return cached;

  const sessions = await anthropic.beta.sessions.list({
    created_at: { gte: twoDaysAgo },
  });

  return sessions.find(
    (s) =>
      s.metadata.slack_channel === channelId &&
      s.metadata.slack_thread_ts === threadTs
  );
}

Slack의 새 어시스턴트 API

Slack이 최근 AI 어시스턴트용 API를 따로 내놓았어요. 기존 chat.postMessage만으로는 AI 봇다운 경험을 만들기가 어려웠는데, 이걸 쓰면 좀 달라집니다.

assistant.threads.setStatus() — 스레드에 "응답 생성 중..." 같은 상태를 표시할 수 있어요. AI가 처리 중임을 사용자한테 자연스럽게 알려줄 수 있습니다.

await client.assistant.threads.setStatus({
  channel_id: channelId,
  thread_ts: threadTs,
  status: "코드 분석 중...",
});

chatStream() — 에이전트 응답을 청크 단위로 스트리밍할 수 있어요. 응답이 길 때 한 번에 딱 뜨는 게 아니라 타이핑되듯이 올라옵니다. task_update로 진행 상황을 업데이트하고, markdown_text로 실제 내용을 흘려보내는 방식이에요.

const stream = await client.chatStream({
  channel: channelId,
  thread_ts: threadTs,
  buffer_size: 256,
});

for await (const event of agentStream) {
  if (event.type === "agent.message") {
    for (const block of event.content) {
      if (block.type === "text") {
        await stream.append({ type: "markdown_text", text: block.text });
      }
    }
  }
}

await stream.close();

chatStream()이 실패하는 경우도 있어서 (postMessage로 폴백), 둘 다 처리하는 코드를 짜야 했어요. 메시지가 3500자를 넘으면 잘라서 안내 메시지를 붙이는 것도 직접 처리했습니다.


봇은 그냥 중계자

아키텍처에서 가장 핵심 결정은 "봇은 아무것도 판단하지 않는다"였어요.

요청이 버그인지 기능인지, 어느 레포를 건드려야 하는지, 테스트를 어떻게 짜야 하는지, PR 설명을 어떻게 써야 하는지 — 이걸 전부 에이전트 시스템 프롬프트로 넘겼습니다. 봇 코드는 슬랙 이벤트를 받아서 세션에 전달하고, 세션 응답을 슬랙에 흘려보내는 것 뿐이에요.

덕분에 워크플로를 바꾸고 싶을 때 봇 배포 없이 에이전트 프롬프트만 수정하면 됩니다. 실제로 초반에 요구사항 수집 → 구현 흐름을 여러 번 바꿨는데, 코드는 손 안 댔어요.

그 대신 시스템 프롬프트가 꽤 길어졌습니다. 500줄 넘어요. "머지는 절대 자동으로 하지 말 것", "main 브랜치에 직접 푸시 금지", "allow_auto_change가 false인 레포는 확인 먼저" 같은 가드레일을 전부 프롬프트에 적었습니다. 이게 코드 레벨 안전장치보다 유연하게 동작하더라고요.


파일 첨부 처리

슬랙에 이미지나 파일을 첨부해서 요청하는 경우가 있어요. 슬랙 파일 URL을 에이전트한테 그냥 넘기면 에이전트가 슬랙 인증 없이는 접근을 못합니다.

그래서 봇이 중간에서 파일을 받아서 Anthropic Files API에 올리고, 세션 리소스로 마운트하는 방식을 썼어요.

// 슬랙에서 다운로드
const file = await fetch(slackFile.url_private_download, {
  headers: { Authorization: `Bearer ${SLACK_BOT_TOKEN}` },
});

// Anthropic Files API에 업로드
const uploaded = await anthropic.beta.files.upload({
  file: new File([await file.arrayBuffer()], slackFile.name),
});

// 세션 리소스로 마운트
await anthropic.beta.sessions.resources.add(sessionId, {
  type: "file",
  file_id: uploaded.id,
  mount_path: `/mnt/session/uploads/${slackFile.name}`,
});

에이전트는 /mnt/session/uploads/ 경로에서 파일을 직접 읽을 수 있어요. 슬랙 URL이 에이전트 컨텍스트에 노출되지 않고, 인증 토큰도 안 넘어갑니다.


겪은 것들

세션 스트리밍이 끊기는 경우events.stream()은 롱폴링 방식이라 네트워크 문제가 생기면 끊깁니다. events.list()로 폴백하는 코드를 짰는데, 커서 기반으로 마지막으로 받은 이벤트 다음부터 가져오는 방식이에요. 서버 재시작 후 복구에도 같은 로직을 씁니다.

Slack 이벤트 중복 — 슬랙은 응답이 늦으면 같은 이벤트를 3번까지 재전송합니다. 이벤트 ID를 TTL 캐시에 넣어서 중복을 걸러냈어요.

Graceful shutdown — 에이전트가 코드 짜는 도중에 서버가 꺼지면 안 되니까, SIGTERM을 받으면 HTTP 수신을 멈추고 진행 중인 릴레이가 전부 끝날 때까지 최대 90초 기다렸다가 종료합니다.

단일 인스턴스 강제 — 세션 메타데이터를 여러 인스턴스가 동시에 업데이트하면 last-write-wins 문제가 생겨서, 지금은 단일 인스턴스로 고정해 뒀습니다. 나중에 분산 락이 필요하면 그때 해결하는 걸로요.


써보니

실제로 쓰기 시작하면서 느끼는 건, 생각보다 프롬프트 설계가 전체 품질을 좌우한다는 거예요. 코드가 아무리 잘 짜여져 있어도 프롬프트가 허술하면 엉뚱한 레포에 커밋을 날리거나, 확인도 없이 PR을 올립니다.

Managed Agents를 쓰면 좋은 점은, 에이전트 실행 환경 관리를 Anthropic이 해주니까 "어디서 코드를 실행하나"를 직접 고민 안 해도 된다는 거예요. MCP 연동도 봇 코드에서 처리하는 게 아니라 환경 설정에서 끝납니다.

Slack의 새 어시스턴트 API는 아직 많이 쓰는 걸 못 봤는데, chatStream()이 특히 사용자 경험 차이가 꽤 납니다. AI 응답이 그냥 탁 뜨는 것보다 흘러들어오는 게 기다리는 느낌이 훨씬 덜하더라고요.