AI 에이전트에게 "이거 정리해서 메일로 보내줘"를 시키고 싶었습니다. 작업 결과나 회의 노트를 구조화해서 제 메일함으로 받아보는, 단순한 워크플로우입니다. 그런데 막상 붙여 보니 생각보다 결이 깊은 문제였습니다. 이 글은 그 과정을 처음부터 끝까지 정리한 기록입니다.

발단초안은 되는데 발송이 안 된다

에이전트에는 이미 호스팅형 Gmail 연동이 붙어 있었습니다. 그런데 노출된 도구를 살펴보니 초안(draft) 생성은 있어도 발송(send)이 없었습니다.

처음엔 빠진 기능인 줄 알았는데, 곧 의도된 설계라는 걸 깨달았습니다. AI가 사람 확인 없이 메일을 곧장 쏴 버리는 시나리오는 위험합니다. 그래서 "초안까지만 만들고, 실제 발송 버튼은 사람이 누른다"로 경계를 그어 둔 것입니다. 안전 관점에서는 타당한 선택입니다.

문제는 제 목적이 정확히 그 경계 너머에 있었다는 점입니다. 사람이 매번 발송 버튼을 눌러야 한다면 자동화의 의미가 절반으로 줄어듭니다. 발송까지 자동으로 가야 했습니다.

설계호스팅 서버는 못 고친다, 그러니 따로 세운다

가장 먼저 떠오른 생각은 "기존 연동에 발송 도구를 추가하자"였습니다. 하지만 호스팅형 연동은 고정된 원격 서버입니다. 클라이언트 쪽에서 도구를 더 끼워 넣을 수 없습니다. 서버가 열어 준 도구 목록이 곧 전부입니다.

그래서 방향을 틀었습니다. 발송 기능만 가진 작은 MCP 서버를 직접 하나 세우는 것입니다. 직접 만들면 두 가지를 제가 통제할 수 있습니다.

  • 권한 범위: 메일 발송 권한 하나(gmail.send)만 요청합니다. 받은 편지함을 읽거나 라벨을 바꾸는 권한은 일절 받지 않습니다. 토큰이 유출되더라도 할 수 있는 일이 "메일 보내기"로 제한됩니다.
  • 자격 증명 위치: 토큰을 어디에 어떤 권한으로 저장할지 제가 정합니다.

권한은 적게 가질수록 사고가 나도 피해가 작습니다. 편의를 위해 넓은 권한을 받아 두는 습관이 가장 흔한 보안 부채입니다.

역할 분리도 이때 정리했습니다. 메일 내용을 보기 좋게 정리하는 일실제로 발송하는 일을 나눴습니다. 정리는 에이전트의 스킬이 맡고, 발송은 MCP 서버가 맡습니다. 서버는 받은 내용을 그대로 메일로 만들어 보낼 뿐, 무엇을 어떻게 정리할지는 모릅니다. 관심사가 깔끔하게 갈립니다.

구현send 도구 하나짜리 서버

서버는 MCP SDK와 Google API 클라이언트만으로 구성했습니다. 노출하는 도구는 send_message 하나입니다. 받는 사람, 제목, 본문(플레인/HTML)을 받아 한 통 보내고 메시지 ID를 돌려줍니다.

겉보기엔 간단하지만, 실제 까다로운 부분은 메일 한 통을 규격에 맞게 조립하는 일이었습니다. 메일은 그냥 문자열이 아니라 RFC 2822 형식의 구조를 가집니다. 헤더와 본문, 그리고 한글 제목과 HTML 본문을 다루려면 몇 가지 규칙을 지켜야 합니다.

한글 제목RFC 2047 인코딩

메일 헤더는 역사적으로 ASCII만 담을 수 있습니다. 한글 제목을 그대로 넣으면 깨집니다. 그래서 비ASCII 제목은 RFC 2047 방식으로 인코딩해야 합니다.

function encodeHeader(value) {
  if (!value) return "";
  // ASCII면 그대로, 아니면 base64로 감싼 RFC 2047 형식으로
  if (/^[\x00-\x7F]*$/.test(value)) return value;
  return `=?UTF-8?B?${Buffer.from(value, "utf-8").toString("base64")}?=`;
}

=?UTF-8?B?...?=라는 낯선 형태가 나오지만, 받는 쪽 메일 클라이언트가 자동으로 디코딩해 원래 한글로 보여 줍니다.

플레인 + HTML 동시 발송multipart/alternative

본문은 플레인 텍스트와 HTML을 함께 보냈습니다. multipart/alternative 구조로 두 버전을 같이 담으면, 메일 클라이언트가 자신이 표현할 수 있는 가장 풍부한 쪽을 골라 보여 줍니다. 보통은 HTML, 텍스트만 지원하는 환경(일부 접근성 도구 포함)에서는 플레인이 뜹니다.

const boundary = `b_${someUniqueSuffix}`;
const mime =
  `--${boundary}\r\n` +
  `Content-Type: text/plain; charset="UTF-8"\r\n\r\n` +
  `${body}\r\n` +
  `--${boundary}\r\n` +
  `Content-Type: text/html; charset="UTF-8"\r\n\r\n` +
  `${htmlBody}\r\n` +
  `--${boundary}--\r\n`;

마지막 한 줄base64url

조립한 메시지 전체는 Gmail API가 요구하는 대로 URL-safe base64(패딩 제거)로 바꿔 보냅니다. 일반 base64와 달리 +, /, =를 쓰지 않는 변형입니다.

const raw = Buffer.from(fullMessage, "utf-8")
  .toString("base64")
  .replace(/\+/g, "-")
  .replace(/\//g, "_")
  .replace(/=+$/, "");

여기까지가 "메일 한 통"의 정체입니다. 평소엔 메일 클라이언트가 가려 주던 부분을 직접 만지니, 한 통을 보낸다는 게 생각보다 여러 규약의 합의라는 걸 새삼 느꼈습니다.

인증한 번만 브라우저, 그 다음은 조용히

발송에는 OAuth 인증이 필요합니다. 데스크톱용 OAuth 클라이언트를 만들고, 최초 1회만 브라우저 동의를 거치는 흐름으로 짰습니다.

  1. 로컬에 임시 콜백 서버를 잠깐 띄웁니다.
  2. 브라우저로 동의 화면을 열고, 발송 권한 하나만 요청합니다.
  3. 동의가 끝나면 콜백으로 코드가 돌아오고, 이를 교환해 refresh token을 받습니다.
  4. 토큰은 로컬 파일에 사용자만 읽을 수 있는 권한으로 저장합니다.

핵심은 refresh token입니다. 이게 있으면 access token이 만료돼도 라이브러리가 알아서 갱신합니다. 즉 최초 한 번만 브라우저를 거치고, 그 뒤로는 사람 개입 없이 조용히 보냅니다.

등록한 번 세우고 모든 곳에서 쓰기

이 서버는 특정 프로젝트가 아니라 사용자 단위로 등록했습니다. 그러면 어느 프로젝트에서 에이전트를 켜든 발송 도구를 쓸 수 있습니다.

여기서 한 가지 함정이 있었습니다. 에이전트는 세션이 시작될 때 도구 목록을 한 번 읽어옵니다. 서버를 새로 등록한 직후, 같은 세션에서는 그 도구가 아직 보이지 않았습니다. 등록은 됐는데 호출이 안 돼서 잠깐 헤맸습니다. 답은 단순했습니다: 세션을 다시 시작하면 새 도구가 목록에 올라옵니다.

마지막 함정계정 보안 정책이라는 벽

코드도 다 됐고 인증 흐름도 맞췄는데, 동의 화면에서 발송이 막혔습니다. 화면에는 익숙한 OAuth 경고가 아니라 계정 보안 정책에 의한 차단이 떴습니다. 검증을 받지 않은 자체 OAuth 앱은 정책상 아예 승인이 거부된다는 메시지였습니다.

이건 코드 문제가 아니었습니다. 동의 화면 설정도, 테스트 사용자 등록도 다 맞았습니다. 강화된 계정 보안 기능이 "검증 안 된 외부 앱"을 통째로 막고 있었던 것입니다.

정공법(앱 검증 신청)은 시간이 오래 걸리고, 우회 가능한 길은 하나였습니다.

  • 계정 보안 기능을 잠시 해제합니다.
  • 그 사이에 OAuth 동의를 끝내 refresh token을 발급받습니다.
  • 보안 기능을 다시 켭니다.

핵심은, 강화된 보안 정책은 동의(consent) 시점에만 작동한다는 점입니다. 이미 발급된 토큰으로 발송하는 것까지는 막지 않습니다. 그래서 토큰을 받는 그 짧은 순간만 보안을 내려 두면, 이후엔 보안을 다시 올려도 발송이 계속 동작합니다.

막힌 곳이 코드인지 정책인지부터 가르는 게 먼저입니다. 에러 메시지의 출처를 잘못 읽으면 멀쩡한 코드를 며칠씩 의심하게 됩니다.

한 가지 더 있습니다. 검증 안 된 앱의 refresh token은 수명이 짧을 수 있습니다. 만료되면 같은 우회 절차(보안 해제 → 재인증 → 보안 복구)를 다시 밟으면 됩니다. 번거롭지만 예측 가능한 비용입니다.

마무리

돌아보면 가장 잘한 결정은 발송과 정리를 분리한 것이었습니다. 메일을 보기 좋게 만드는 일과 실제로 보내는 일은 성격이 다릅니다. 전자는 자주 바뀌고 취향을 타지만, 후자는 한 번 제대로 만들면 손댈 일이 거의 없습니다. 둘을 한 덩어리로 묶었다면, 메일 디자인을 손볼 때마다 발송 로직까지 들춰야 했을 것입니다.

기능 자체는 "메일 한 통 보내기"라는 흔한 일입니다. 그런데 그 한 통 뒤에 호스팅 서버의 안전 설계, 메일 규약, OAuth, 계정 보안 정책까지 네 겹의 맥락이 겹쳐 있었습니다. 흔해 보이는 일도 경계를 하나씩 넘다 보면 배울 게 남습니다.