AI 에이전트가 보내는 모든 이메일을 감사 로그(Audit-log)로 기록하기
요약
자율 에이전트가 이메일을 사용할 때 보안과 규제 준수를 위해 필수적인 감사 로그(Audit-log) 구축 아키텍처를 설명합니다. 라이브 편지함과 별개로 수정 불가능한 전용 감사 저장소를 구축하여 에이전트의 모든 통신 기록을 보존하는 방법을 다룹니다.
핵심 포인트
- 라이브 편지함은 가변적이므로 감사 로그로 사용할 수 없음
- 에이전트의 모든 송수신 메시지를 위한 불변 기록(Immutable record) 필요
- 감사 저장소는 추가 전용(Append-only) 및 단회 쓰기(Write-once) 방식으로 설계해야 함
- WORM 객체 저장소나 해시 체인 로그 등을 활용한 보안 강화 권장
자율 에이전트(autonomous agent)가 자체 이메일 주소를 갖게 될 때, 보안 팀이 던지는 첫 번째 질문은 "메일을 보낼 수 있는가?"가 아닙니다. 그 질문은 "6개월 뒤에도 에이전트가 정확히 누구에게 무엇을 말했는지 증명할 수 있는가?"입니다.
이는 "작동하는가"의 문제와는 다른 문제입니다. 몇 개의 고객 지원 답장을 보내는 데모는 스프린트 리뷰(sprint review)에서 멋져 보일 수 있습니다. 하지만 실제 고객이 "당신의 봇이 나에게 환불을 약속했다"라고 말하거나, 규제 기관이 자동화된 시스템이 정보 주체(data subject)에게 전달한 내용의 전체 기록을 요구하는 순간, 당신에게는 방어 가능한 추적 경로(defensible trail) — 즉, 에이전트가 삭제할 수도 있는 편지함 외부에서 캡처된, 에이전트가 접한 모든 송수신 메시지의 불변 기록(immutable record)이 필요합니다.
저는 Nylas CLI를 다루기 때문에 아래의 터미널 명령어들은 제가 실제로 사용하는 것들입니다. 하지만 여기서 핵심적인 아키텍처 관점은 제공자(provider)에 구애받지 않으며, 대부분의 "AI 이메일" 튜토리얼이 생략하는 부분입니다: 라이브 편지함(live mailbox)은 당신의 감사 로그(audit log)가 아닙니다. 그것은 가변적(mutable)이며, 보관 기간(retention limits)이 있고, 메일을 보내는 바로 그 에이전트가 메일을 삭제할 수도 있습니다. 만약 에이전트가 수행한 작업에 대한 유일한 기록이 받은 편지함(inbox)에만 있다면, 당신은 감사 추적 경로(audit trail)를 가진 것이 아니라 단지 작업 복사본(working copy)을 가진 것뿐입니다.
"모든 것을 감사 로그로 기록한다"는 것의 실제 의미
이 설계에는 두 개의 저장소(stores)가 있으며, 이 둘을 분리하는 것이 핵심입니다.
- 라이브 편지함 (The live mailbox) — 에이전트 계정 권한(Agent Account grant). 메시지가 이곳으로 들어오고 나갑니다. 쿼리(queryable)가 가능하고 실시간이며, _가변적(mutable)_입니다. 플래그(Flags)가 변경되고, 메시지가 폴더로 이동하며, 항목들이 삭제됩니다. 무료 플랜의 경우 보관 기간도 제한됩니다: 받은 편지함은 30일, 스팸은 7일입니다.
- 감사 저장소 (The audit store) — 당신의 시스템.
message_id와thread_id를 키(key)로 사용하는 추가 전용(append-only), 단회 쓰기(write-once) 로그입니다. 일반적인 운영 중에 이 안의 어떤 것도 업데이트되거나 삭제되지 않습니다. 이것이 바로 당신이 검토자에게 전달할 기록입니다.
감사 저장소(Audit store)는 여러분이 직접 구축해야 하는 것입니다. Nylas는 두 가지 캡처 지점인 '전송 응답(send response)'과 '인바운드 웹훅(inbound webhook)'을 제공하지만, 불변성(Immutability)을 유지하는 것은 여러분의 책임입니다. 즉, WORM (write-once-read-many, 한 번 쓰면 여러 번 읽기) 객체 저장소, 앱 역할(app role)에 UPDATE/DELETE 권한이 없는 추가 전용(append-only) 테이블, 또는 각 항목이 이전 항목의 해시(hash)를 커밋하여 조작 여부를 감지할 수 있는 해시 체인 로그(hash-chained log) 중 하나를 선택해야 합니다. 여러분의 컴플라이언스(Compliance) 요구 사항에 따라 원하는 방식을 선택하십시오. 아래의 캡처 패턴은 무엇을 선택하든 동일합니다.
미리 솔직하게 말씀드리고 싶은 점이 하나 있습니다: 에이전트 계정(Agent Accounts)에서는 커스텀 metadata가 지원되지 않습니다. 일반적인 연결된 권한(connected grant)에서는 메시지 메타데이터에 감사 ID(audit ID)를 찍어두고 나중에 이를 필터링하고 싶은 유혹을 느낄 수 있습니다. 하지만 여기서는 그 문이 닫혀 있으며, 어쨌든 그것은 잘못된 접근 방식입니다. 메타데이터는 여러분이 감사를 수행하려는 대상인, 변경 가능한(mutable) 편지함과 동일한 곳에 존재하기 때문입니다. 별도의 저장소를 사용하는 것은 임시방편이 아니라 올바른 설계입니다.
권한(Grant)은 여러분에게 필요한 유일한 추상화입니다
권한 범위(grant-scoped)의 Nylas 엔드포인트를 사용해 본 적이 있다면, 데이터 평면(data plane) 측면에서 새로운 것은 없습니다. **에이전트 계정(Agent Account)**은 단지 grant_id를 가진 권한일 뿐입니다. 이는 다른 모든 권한과 마찬가지로 메시지(Messages), 스레드(Threads), 폴더(Folders), 첨부 파일(Attachments) 등 동일한 /v3/grants/{grant_id}/* 경로를 사용합니다. 인증 헤더(auth header)와 페이로드(payload)도 동일합니다. 감사 서브시스템은 이러한 경로 중 두 개와 하나의 앱 수준 웹훅(app-level webhook)에 연결됩니다.
따라서 이 글의 핵심은 간단합니다:
- 전송 캡처(Capture sends) — 에이전트가 메시지를 보낼 때, 반환된
message_id와thread_id및 페이로드를 기록합니다. - 인바운드 캡처(Capture inbound) —
message.created웹훅이 발생하면, 전체 메시지를 가져와 기록합니다. - 다시 읽기(Read back) — 사고(incident)가 발생하면, 저장소에서
thread_id를 기준으로 대화 내용을 재구성합니다.
이것이 전부입니다. 그 외의 모든 것은 저장소를 불변(immutable)으로 만들기 위한 배관 작업(plumbing)입니다.
시작하기 전에
등록된 도메인(커스텀 도메인 또는 Nylas의 *.nylas.email 체험용 서브도메인)과 해당 도메인에 연결된 에이전트 계정(Agent Account)이 필요합니다. 새로운 도메인은 완전한 발송 평판(sending reputation)을 갖추기까지 약 4주간의 워밍업(warm-up) 기간이 필요하므로, 미리 준비해 두시기 바랍니다.
POST /v3/connect/custom을 사용하여 API로 프로비저닝(provision)합니다:
curl --request POST \
--url 'https://api.us.nylas.com/v3/connect/custom' \
--header 'Authorization: Bearer <NYLAS_API_KEY>' \
...
응답에는 data.id로 권한 ID(grant id)가 포함됩니다(`{
200 응답에는 data.id (message_id)와 data.thread_id가 포함됩니다. 이 응답을 받는 즉시 다음과 같은 레코드를 추가 전용 저장소 (append-only store)에 기록하세요:
{
"direction": "outbound",
"message_id": "<data.id에서 가져온 값>",
...
본문과 함께 body_sha256을 저장하는 것은 저렴한 보험과 같습니다. 이는 전체 로그에 대해 변조 방지 증거 (tamper-evidence)를 확보하고자 할 때, 각 레코드마다 체인처럼 연결할 수 있는 약속입니다.
CLI 방식의 경우, 응답을 캡처 단계로 바로 파이프 (pipe) 할 수 있도록 --json 옵션을 사용합니다:
nylas email send support@yourcompany.com \
--to customer@example.com \
--subject "Re: your refund request" \
...
에이전트가 기존 대화 내에서 답장을 보낼 때는 reply_to_message_id를 사용하여 전송함으로써 새 메시지가 동일한 thread_id를 유지하도록 하세요. 이러한 스레드 연속성 (thread continuity) 덕분에 나중에 감사 저장소 (audit store)로부터 전체적인 주고받기 대화를 재구성할 수 있습니다:
curl --request POST \
--url 'https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/send' \
--header 'Authorization: Bearer <NYLAS_API_KEY>' \
...
CLI 방식에서는 --reply-to를 사용합니다:
nylas email send support@yourcompany.com \
--to customer@example.com \
--reply-to <ORIGINAL_MESSAGE_ID> \
...
감사관들이 자주 질문하는 첨부 파일에 관한 참고 사항입니다. API를 통해 JSON의 attachments 배열에 Base64 문자열을 첨부하거나, 파일 폼 필드 이름이 file이 아닌 attachment인 멀티파트 (multipart) 방식을 사용합니다. nylas email send에는 첨부 파일 플래그가 없습니다. 만약 에이전트가 파일을 보내야 한다면, nylas email drafts create --attach를 사용하여 메시지를 초안 (draft)으로 작성한 뒤 이를 전송하세요. 어떤 방식이든 첨부 파일의 메타데이터 (파일명, 크기, 콘텐츠 해시)를 감사 항목에 기록하십시오. 로그에 바이트 자체를 직접 복사하는 경우는 거의 없습니다.
모든 수신 메시지 캡처하기
수신 메시지는 별도의 저장소를 운영하는 규율이 진가를 발휘하는 부분입니다. 왜냐하면 수신 메일은 에이전트가 나중에 수정할 수 있거나, 보존 정책 (retention)에 의해 사라질 수 있는 편지함으로 들어오기 때문입니다.
캡처 지점은 message.created 웹훅 (webhook)입니다. Nylas 웹훅에 관한 두 가지 사실이 이 시스템을 어떻게 연결할지를 결정하며, 이를 정확히 구현하는 것이 깔끔한 감사 로그 (audit log)와 불안정한 로그를 가르는 차이점이 됩니다.
curl --request POST \
--url 'https://api.us.nylas.com/v3/webhooks' \
--header 'Authorization: Bearer <NYLAS_API_KEY>' \
...
또는 CLI를 사용하여:
nylas webhook create \
--url https://yourapp.com/hooks/nylas \
--triggers message.created \
...
로컬 개발을 위해 nylas webhook server --tunnel cloudflared --secret <your-webhook-secret> 명령어로 수신기를 실행할 수 있습니다. 이 명령은 들어오는 요청의 서명 (signature)을 검증하고 각 이벤트 (event)를 출력하므로, 프로덕션 (production) 환경에 적용하기 전에 캡처 로직을 확인하는 데 유용합니다.
웹훅 바디 (webhook body)를 신뢰하지 마세요 — ID로 가져오기
문제를 방지하기 위한 규칙은 다음과 같습니다: 메시지 본문 (message body)을 위해 웹훅 페이로드 (payload)에 의존하지 마세요. 본문이 필요할 때 전체 메시지를 직접 가져오십시오 (fetch). Nylas 문서에서는 본문이 인라인 (inline)으로 전달되는지에 대해 약간의 일관성이 없습니다 (일반 웹훅 문서에서는 메시지가 약 1MB를 초과하지 않는 한 인라인으로 전달된다고 명시하며, 초과할 경우 트리거가 message.created.truncated로 변경되고 본문은 생략된다고 합니다. 반면 에이전트 계정 (agent-accounts) 참조 문서에서는 요약 필드 (summary fields)를 사용하는 경향이 있습니다). 두 문서 모두 권장하는 _작업 (action)_은 동일하므로, 다음과 같이 수행하십시오:
message.created이벤트 발생 시,data.object.id(message_id)와grant_id를 읽습니다.- 트리거에 따라 분기합니다: 만약
message.created.truncated라면 본문이 누락된 것이므로 반드시 다시 가져와야 (re-fetch) 합니다. - 정식 (canonical) 전체 메시지를 가져와서 그것을 감사 저장소 (audit store)에 기록합니다:
curl --request GET \
--url 'https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/<MESSAGE_ID>' \
--header 'Authorization: Bearer <NYLAS_API_KEY>'
CLI 대응 명령어:
nylas email read <MESSAGE_ID> support@yourcompany.com --json
ID로 가져오기(Fetching by id)를 사용하면 인라인 페이로드(inline payload)를 잘린 페이로드(truncated payload)와 대조하려고 시도하는 대신, 본문에 대한 단일 진실 공급원(source of truth)인 전체 메시지를 얻을 수 있습니다. 감사 로그(Audit log)를 위해서는 각 메시지에 대해 정확히 하나의 정전적 버전(canonical version)이 필요하며, 이것이 바로 그 방법을 얻는 방식입니다.
알림 ID(notification id)를 통한 중복 제거 (Dedup)
Nylas는 최소 한 번(at-least-once) 전달을 보장합니다. 즉, 동일한 이벤트가 최대 세 번까지 도착할 수 있습니다. 추가 전용 저장소(append-only store)의 경우 이는 실제 위험 요소가 됩니다. 단순한 코드는 동일한 수신 메시지를 세 번 기록할 것이고, 그러면 여러분의 "불변(immutable)" 로그에는 중복 데이터가 생기게 됩니다.
**최상위 알림 id(top-level notification id)**를 기준으로 중복을 제거하세요. 해당 ID는 하나의 이벤트에 대한 모든 재시도 과정에서 일정하게 유지되므로, 올바른 전달 중복 제거 키(delivery-dedup key)가 됩니다. 처리된 알림 ID 세트(DB의 유니크 제약 조건 또는 짧은 TTL 캐시)를 유지하고, 이미 확인한 ID는 버리십시오. 두 번째 방어책으로, 내부의 data.object.id(message_id)를 키로 사용할 수도 있습니다. 이렇게 하면 서로 다른 이벤트 사이에서도 동일한 메시지에 대해 두 번 동작하는 일을 방지할 수 있습니다. 이 두 가지를 함께 사용하면 최소 한 번(at-least-once) 전달 방식 위에서 정확히 한 번(exactly-once)의 의미론(semantics)을 구현할 수 있습니다.
그리고 무엇인가를 처리하기 전에 서명(signature)을 검증하십시오. Nylas는 웹훅(webhook) secret을 사용하여 원시(raw) 요청 본문에 대한 16진수 HMAC-SHA256인 X-Nylas-Signature로 각 웹훅에 서명합니다. (재직렬화된 JSON 객체가 아닌) 원시 바이트(raw bytes)에 대해 이를 다시 계산하고 상수 시간(constant time) 내에 비교하십시오. Node의 crypto.timingSafeEqual을 사용하는 경우, 길이 불일치 시 오류가 발생하므로 두 버퍼의 길이가 같은지 먼저 확인하는 방어 코드를 작성해야 합니다. CLI를 사용하면 캡처된 페이로드를 대신 확인할 수 있습니다:
nylas webhook verify \
--payload-file ./captured-body.json \
--signature <X-Nylas-Signature value> \
...
각 감사 항목의 일부로 secret이 아닌 _검증 결과(verification result)_를 기록하십시오. 검사자는 기록된 모든 수신 이벤트가 인증되었음을 확인하고 싶어 할 것입니다.
흔적 역추적하기 (Reading the trail back)
사고(incident)가 발생했을 때, 여러분은 메일함이 아니라 감사 저장소(audit store)를 통해 내용을 확인해야 합니다. 기록들을 thread_id별로 그룹화하면 전체 대화 내용을 파악할 수 있습니다. 즉, 에이전트가 생성한 모든 수신 메시지와 모든 발신 답장을 순서대로, 당시 캡처한 본문(body) 및 이후 변경되지 않았음을 증명하는 해시(hash)와 함께 확인할 수 있습니다.
메시지가 보관 기간(retention window) 내에 있는 동안에는 실제 메일함과 교차 검증을 할 수 있습니다 — GET /v3/grants/<GRANT_ID>/messages?thread_id=<THREAD_ID> 또는 nylas email list support@yourcompany.com --json — 하지만 이는 편의를 위한 수단일 뿐, 공식 기록으로 취급해서는 안 됩니다. 보관 기간이 지났거나 에이전트가 무언가를 삭제한 후에는 메일함이 답을 줄 수 없습니다. 하지만 여러분의 추가 전용(append-only) 저장소는 답을 줄 수 있습니다. 그것이 바로 이 저장소가 존재하는 유일한 이유입니다.
주의해야 할 몇 가지 사항
메일함에서의 "삭제"는 영구적이지 않습니다 — 하지만 괜찮습니다. 어차피 여러분의 감사 저장소가 기록이기 때문입니다. DELETE /v3/grants/{id}/messages/{id}는 ?hard_delete=true를 전달하지 않는 한 메시지를 휴지통(Trash)으로 이동시킵니다. CLI의 nylas email delete 또한 휴지통으로 보낼 뿐입니다. 스레드(Thread) 삭제 역시 휴지통으로 보낼 뿐이며, 하드 삭제(hard-delete) 옵션 자체가 아예 없습니다. 유일한 완전 삭제는 권한(grant) 자체를 삭제하는 것입니다 (DELETE /v3/grants/{id} / nylas agent account delete). 이 중 그 어떤 것도 별도의 감사 로그(audit log)에는 영향을 주지 않으며, 이것이 바로 여러분이 원하는 분리(separation)입니다. 즉, 실제 메일함에서의 삭제와 감사 저장소에서의 보관은 두 개의 독립적인 결정입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기