왜 지금 io_uring 이야기가 다시 나오는가

고성능 서버를 만들다 보면 결국 한 군데로 수렴한다. "syscall을 어떻게 줄일까." 연결 1만 개, 10만 개를 받기 시작하면 read/write 하나하나의 컨텍스트 스위치 비용이 눈에 보이게 누적된다. 이게 epoll이 20년 넘게 사랑받았던 이유이자, 동시에 io_uring이 등장한 이유다.

최근 TinyGate라는 교육용 리버스 프록시 프로젝트 사례가 화제가 됐다. 워커 기반 → epoll 기반으로 바꾸며 성능을 크게 끌어올렸지만, 그래도 nginx/haproxy를 못 넘었고 결국 io_uring으로 처음부터 다시 작성했다는 이야기다. 단순한 "신기술 좋아요" 글이 아니라 "왜 epoll의 천장을 만났고, 무엇이 달랐는가"를 보여줘서 곱씹어 볼 만하다.

이 글에서는 둘의 동작 모델 차이를 실무자 시선으로 정리하고, 실제로 코드를 돌려보면서 어떤 함정을 만나는지까지 짚어본다. 참고로 io_uring은 kernel v5.1+에서 지원되고, epoll은 2002년부터 리눅스에 들어가 있던 오래된 친구다.

핵심: 준비 상태 모델 vs 완료 모델

둘의 차이는 딱 한 문장으로 요약된다.

  • epoll = 준비 상태(readiness) 모델 — "이제 읽을 수 있어"라고 알려준다. 실제 read는 네가 직접 호출해라.
  • io_uring = 완료(completion) 모델 — "읽기 작업 끝났고 데이터 여기 있어"라고 알려준다.

비유하자면 epoll은 택배 도착 알림 문자다. "물건이 경비실에 왔습니다" 알림을 받으면, 내가 직접 내려가서(=read syscall) 가져와야 한다. io_uring은 집 앞까지 배달해주는 방식이다. "현관에 놓고 갔습니다" 사진(=완료 큐 엔트리)이 오면 끝. 따로 내려갈 필요가 없다.

epoll의 흐름

전형적인 epoll 루프는 이렇게 돈다.

// 1회성: fd 등록
int epfd = epoll_create1(0);
struct epoll_event ev = { .events = EPOLLIN, .data.fd = STDIN_FILENO };
epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &ev);

// 반복: 이벤트마다 epoll_wait + read 쌍이 돈다
struct epoll_event events[16];
int n = epoll_wait(epfd, events, 16, -1);   // syscall #1: "읽을 수 있음" 통지
for (int i = 0; i < n; i++) {
    char buf[1024];
    read(events[i].data.fd, buf, sizeof(buf)); // syscall #2: 실제 읽기
}

핵심은 epoll_ctl은 등록 시 1회만 호출하지만, 실제 I/O 이벤트마다 epoll_wait + read(또는 write)가 계속 붙는다는 점이다. 연결이 늘어날수록 이 syscall 쌍이 곱연산으로 늘어나고, 그때마다 유저 모드 ↔ 커널 모드 컨텍스트 전환 비용을 낸다.

io_uring의 흐름

io_uring은 커널과 유저 공간이 공유 메모리의 링 버퍼 두 개를 같이 쓴다. 제출 큐(SQ)에 "이 작업 해줘"를 넣고, 커널이 끝내면 완료 큐(CQ)에 결과를 올린다.

#include <liburing.h>

struct io_uring ring;
io_uring_queue_init(8, &ring, 0);

char buf[1024];
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, STDIN_FILENO, buf, sizeof(buf), 0); // 읽기 작업 준비
io_uring_submit(&ring);                                     // 제출

struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);   // 완료 대기 — 별도 read() 없음!
if (cqe->res < 0) {
    fprintf(stderr, "read failed: %s\n", strerror(-cqe->res));
} else {
    // cqe->res 바이트만큼 buf에 이미 채워져 있다
}
io_uring_cqe_seen(&ring, cqe);

여기서 epoll과 결정적으로 다른 두 가지를 보자.

  1. 완료 시점에 별도 read를 안 한다. io_uring_wait_cqe가 돌아왔을 때 데이터는 이미 buf에 들어 있다.
  2. 여러 작업을 한 번에 제출/회수할 수 있다. SQE를 여러 개 채워 넣고 io_uring_submit 한 번으로 보내면 syscall 한 번에 N개 작업이 나간다. epoll의 "작업마다 syscall 쌍" 구조와 근본적으로 다르다.

그리고 IORING_SETUP_SQPOLL을 켜면 커널 스레드가 제출 큐를 알아서 폴링한다. 정상 운용 상태에서는 io_uring_enter() 호출조차 거의 없앨 수 있다. 다만 공짜는 아니다 — 큐가 비어 있어도 커널 스레드가 돌기 때문에 CPU를 먹는다. sq_thread_idle 시간이 지나면 sleep으로 물러나지만 비용 자체가 사라지진 않는다.

실무 관점: 트레이드오프와 흔한 함정

오류 처리 모델이 완전히 다르다 (가장 흔한 함정)

epoll 시절 습관대로 짜면 반드시 한 번 걸린다. epoll에서는 read()가 실패하면 -1을 리턴하고 errno를 본다. io_uring은 동기 리턴값이 아니라 완료 큐 엔트리의 res 필드로 비동기적으로 오류가 돌아온다. 게다가 res는 음수의 errno 값이라 부호를 뒤집어야 한다.

// 잘못된 습관 — io_uring_submit 리턴값만 보고 안심
ret = io_uring_submit(&ring);
if (ret < 0) { /* ... */ }
// submit이 성공해도 정작 read는 실패했을 수 있다!

// 올바른 처리 — cqe->res를 확인
if (cqe->res < 0) {
    fprintf(stderr, "operation failed: %s\n", strerror(-cqe->res));
}

이걸 놓치면 "분명 submit은 성공했는데 데이터가 안 들어온다"는 식으로 디버깅이 산으로 간다.

SQE가 NULL을 리턴하는 경우

제출 큐가 가득 차면 io_uring_get_sqe()NULL을 반환한다. 예제 코드들이 단순화를 위해 흔히 빼먹는 부분인데, 실제 부하 상황에서 이걸 검사 안 하면 NULL 역참조로 죽는다.

struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
if (!sqe) {
    // 큐가 꽉 찼다 — 먼저 submit해서 비우거나 백프레셔를 걸어야 한다
    io_uring_submit(&ring);
    sqe = io_uring_get_sqe(&ring);
}

빌드 환경에서 만나는 실제 에러

io_uring 코드를 처음 빌드하면 liburing 헤더가 없어서 십중팔구 이걸 본다.

$ gcc uring_example.c -o uring_example
uring_example.c:1:10: fatal error: liburing.h: No such file or directory
    1 | #include <liburing.h>
      |          ^~~~~~~~~~~~~
compilation terminated.

liburing을 설치하고 링크 플래그를 붙여야 한다.

# Ubuntu/Debian
$ sudo apt install liburing-dev

# 빌드 시 -luring 링크 필수
$ gcc uring_example.c -o uring_example -luring

커널이 io_uring을 막아둔 경우

이게 실무에서 진짜 발목을 잡는다. io_uring은 커널과 유저 공간이 메모리를 직접 공유하는 구조라 공격 표면이 넓고, 보안 이슈로 아예 비활성화해둔 환경이 많다. 먼저 현재 상태를 확인하자.

# 커널 파라미터 확인 (커널/배포판에 따라 존재 여부가 다름)
$ sysctl kernel.io_uring_disabled
kernel.io_uring_disabled = 2

# 값 의미 (참고): 0=전체 허용, 1=제한적, 2=완전 비활성

2로 막혀 있으면 io_uring_queue_init 단계에서 권한 오류가 난다.

$ ./uring_example
io_uring_queue_init: Operation not permitted

HN 토론에서도 이 점이 반복적으로 지적됐다. "더 빠르지만 대가는 잠재적 익스플로잇 가능성"이라는 말이 핵심이다. Go 같은 프로젝트도 io_uring을 합리적 기본값으로 깊게 넣지 않는 이유가 여기 있다. 다만 최근 흐름은 나아지고 있다 — 원문 토론에 따르면 RHEL 9/10은 이제 기본적으로 io_uring을 완전 지원하고, 최신 RC에 cBPF 기반으로 "전부 끄는 대신 실행 가능한 작업만 제한"하는 기능이 들어갔다고 한다. (배포판별 정확한 지원 범위는 각 벤더 문서 확인 필요.)

제로카피를 쓰려면 추가 준비가 필요하다

io_uring의 진짜 성능은 zero-copy에서 나오는데, 그냥 켜진다고 되는 게 아니다.

  • io_uring_register_buffers()로 버퍼를 미리 등록해야 커널이 작업마다 메모리를 다시 매핑하는 비용을 피한다.
  • 네트워크 전송 제로카피는 kernel 6.0+의 IORING_OP_SEND_ZC가 필요하다.

즉 "io_uring 썼으니 빨라지겠지"가 아니라, 버퍼 등록·SQPOLL·제로카피 옵션을 워크로드에 맞게 조율해야 비로소 epoll 대비 우위가 나온다.

TinyGate 마이그레이션이 주는 교훈

원문 사례를 정리하면 이렇다.

  1. v0: 단순 워커 기반 — 교육용으로는 동작하지만 nginx/haproxy 대비 아키텍처 한계가 컸다.
  2. v1: epoll 기반 — v0보다 성능이 크게 향상. 그래도 벤치마크에서 nginx/haproxy는 못 넘었다.
  3. v2: io_uring 기반 — epoll의 한계 때문에 처음부터 다시 작성. 아키텍처 선택 자체가 완전히 달라졌다.

여기서 주목할 점은 "라이브러리만 바꾸면 되는 일이 아니었다"는 것이다. 완료 모델은 준비 모델과 사고방식이 다르기 때문에 이벤트 루프 설계, 버퍼 생명주기, 오류 처리 경로를 통째로 다시 짜야 했다. HN 댓글에서도 어떤 사람은 Asio의 epoll 백엔드를 io_uring으로 바꿨더니 오히려 CPU 사용률이 확 올랐다고 했고, 또 어떤 사람은 RPS가 약 20% 빨라졌다고 했다. 즉 통합 방식에 따라 결과가 크게 갈린다. 무조건 빨라지는 마법이 아니다.

정리: 누가 언제 써야 하는가

한 줄 요약: epoll은 "읽을 수 있다"를 알려주고 read를 네가 하는 모델, io_uring은 "읽기 끝났다"를 알려주는 완료 모델이다. 후자가 syscall을 배치로 묶어 줄여서 고부하에서 유리하다.

  • io_uring을 선택할 때: kernel v5.1+ 환경에서 새 프로젝트를 처음부터 만들고, 커널에서 io_uring을 켤 수 있으며, 고연결·고처리량 워크로드(리버스 프록시, 고성능 파일 서버 등)를 다룰 때. 원문 결론도 "이런 환경에서는 epoll을 고를 이유가 많지 않다"고 본다.
  • epoll을 유지할 때: 오래된 커널 지원이 필요하거나, 보안 정책상 io_uring을 켤 수 없는 인프라이거나, 이미 잘 도는 안정적 시스템을 굳이 갈아엎을 이유가 없을 때. epoll은 20년간 검증됐고 어디서나 돈다.
  • 둘 다 과한 경우: 연결당 스레드 + 블로킹 소켓이 여전히 가장 단순하고, 트래픽이 많지 않다면 이게 정답일 때도 많다. 추상화를 뚫고 내려갈수록 모든 게 더 어려워진다.

실무 권장 순서는 이렇다. (1) 먼저 sysctl kernel.io_uring_disabled로 배포 환경이 io_uring을 허용하는지 확인하고, (2) PoC를 짜서 실제 워크로드 벤치마크로 epoll 대비 이득을 측정한 뒤, (3) 오류 처리 경로(cqe->res)와 SQE NULL 처리를 꼼꼼히 다지고 나서 도입하라. "더 빠른 모델"이라는 말만 믿고 갈아탔다가 CPU만 오르는 사례가 실제로 있으니, 측정 없는 마이그레이션은 금물이다.

참고 자료

728x90

1. 도입: 사람을 위한 인증 벽에 머리를 박는 에이전트

요즘 코드를 짜는 건 절반쯤 에이전트가 한다. 문제는 코드를 짜는 것까지는 잘 되는데, 그걸 어딘가에 배포하려는 순간 벽에 부딪힌다는 거다. Cloudflare 블로그 표현이 정확한데, 에이전트가 배포하려고 보면 "브라우저 기반 OAuth 플로우, 클릭해서 넘어가야 하는 대시보드, 복붙해야 하는 API 토큰, 60초 안에 처리하라는 MFA 프롬프트"가 기다리고 있다.

이게 왜 실무에서 진짜 문제냐면, 백그라운드 에이전트 세션엔 사람이 없기 때문이다. 내가 옆에서 보고 있는 코파일럿이라면 토큰 한 번 복붙해주면 된다. 그런데 CI 파이프라인이나 cron으로 도는 에이전트, 혹은 "이거 만들어서 배포까지 해놔" 던져놓고 자리 비운 상황에서는 브라우저 OAuth 단계가 곧 hard stop이다. 거기서 멈춰버리거나, 더 나쁘게는 에이전트가 "여기 막혔으니 다른 데 배포하자"고 판단해버린다.

인프라 하는 사람 입장에서 이건 단순 UX 문제가 아니다. 본질은 "사람을 전제로 설계된 인증/권한 모델을, 사람 없는 워크로드에 어떻게 끼워 맞출 것인가"다. 우리가 그동안 서비스 계정, IRSA, 워크로드 아이덴티티로 풀어온 그 문제가 에이전트 레이어에서 다시 터진 거다.

2026년 6월 Cloudflare가 내놓은 Temporary Cloudflare Accounts for Agents는 이 문제에 대한 한 가지 답이다. 핵심은 간단하다: wrangler deploy --temporary 한 줄로 계정 가입 없이 Worker를 띄우고, 60분 안에 사람이 "claim"하면 자기 계정으로 영구 전환, 안 하면 자동 삭제.

2. 핵심: 동작 원리를 예시로 뜯어보기

전체 플로우

먼저 그림을 그려보자. 원문 기준으로 흐름은 이렇다.

  1. 에이전트가 그냥 wrangler deploy를 친다. 인증이 안 돼 있으니 막힌다.
  2. 이때 Wrangler가 출력 메시지로 "--temporary 플래그가 있다"고 에이전트에게 알려준다. (이게 영리한 부분이다. 뒤에서 다시 설명한다.)
  3. 에이전트가 그걸 읽고 wrangler deploy --temporary로 다시 친다.
  4. Cloudflare가 임시 계정을 프로비저닝하고, Wrangler에 API 토큰을 발급하고, 사람에게 돌려줄 claim URL을 준다.
  5. 배포된 Worker는 60분간 살아있다. 그 안에 에이전트는 코드 고치고 재배포를 몇 번이든 반복할 수 있다(같은 임시 계정 재사용).
  6. 사람이 claim URL을 클릭해 로그인/가입하면 그 계정과 리소스(Worker, DB, 바인딩 포함)가 영구히 자기 것이 된다. 안 하면 60분 뒤 통째로 삭제.

실제로 어떻게 보이나

에이전트한테 던지는 프롬프트는 원문 예시 그대로 쓸 수 있다.

# 에이전트에게 주는 프롬프트
Make a very simple hello world Cloudflare Worker in TypeScript and deploy it using wrangler, don't ask me questions, do the best you can

에이전트가 내부적으로 실행하게 되는 명령과 출력은 대략 이런 식이다. (아래는 플로우 이해를 돕기 위한 예시이며, 실제 출력 문구는 Wrangler 버전에 따라 다를 수 있으니 공식 문서 확인 필요)

$ npx wrangler deploy
⛅️ wrangler 4.x.x
-------------------
✘ [ERROR] You are not authenticated.

  To deploy without signing in, you can create a temporary account:

      wrangler deploy --temporary

  This account stays live for 60 minutes. Claim it to keep it.

$ npx wrangler deploy --temporary
⛅️ Provisioning a temporary account...
✔ Temporary account created (expires in 60 minutes)

Total Upload: 0.19 KiB / gzip: 0.16 KiB
Uploaded hello-world (1.2 sec)
Deployed hello-world triggers (0.8 sec)
  https://hello-world..workers.dev

⚠ This is a TEMPORARY deployment. To keep it, claim your account:
  https://dash.cloudflare.com/claim/temp_xxxxxxxxxxxxxxxx

여기서 에이전트의 강점이 발동한다. 배포된 프리뷰 URL을 받았으니 자기가 직접 curl로 결과를 확인하고, 코드와 일치하는지 검증한다. 이게 원문에서 말하는 "trial-and-error is the agent's superpower"의 실체다. write → deploy → verify 루프가 사람 개입 없이 닫힌다.

$ curl https://hello-world..workers.dev
Hello World!

그리고 수정 요청:

# 추가 프롬프트
Now change hello world to hello cloudflare and redeploy

이때는 새 계정을 또 만드는 게 아니라 아까 만든 임시 계정을 재사용해서 새 버전을 올린다. 60분 윈도우 안에서는 계속 같은 샌드박스에 머문다.

비유: "체험판 키오스크"

나는 이걸 무인 매장 체험 키오스크에 비유한다. 회원가입 없이 바로 써볼 수 있고, 마음에 들면 그 자리에서 "내 계정으로 등록"을 누르면 지금까지 만든 게 그대로 넘어온다. 안 누르면 카트가 비워진다. 에이전트한테 딱 맞는 모델이다. 가입이라는 사람 전용 절차를 배포 이후로 미뤄버린 게 핵심 아이디어다.

왜 "Wrangler 출력 메시지"가 영리한가

원문에서 던지는 질문이 좋다. "에이전트가 --temporary 플래그가 존재하는지 어떻게 아느냐?" 사람이 따로 안 알려줬는데 말이다.

답은 단순하다. 에러 메시지 자체를 LLM이 읽을 수 있는 안내문으로 만들었다. 에이전트는 명령 실행 후 stdout/stderr를 다시 읽어서 다음 행동을 결정하는데, 거기에 "이렇게 하면 된다"를 박아놓으면 별도 학습이나 문서 없이도 알아서 다음 스텝을 밟는다. 이건 앞으로 CLI 도구를 만들 때 참고할 만한 패턴이다 — 에러 메시지를 에이전트 친화적으로 설계하는 것 자체가 하나의 인터페이스가 된다.

3. 실무 관점: 도입 시 고려사항과 흔한 함정

최소 권한과 격리 관점에서 보면

임시 계정의 진짜 가치는 "편함"보다 격리(isolation)에 있다. 에이전트가 폭주하거나, 프롬프트 인젝션으로 엉뚱한 짓을 하더라도 그 폭발 반경이 임시 계정 안에 갇힌다. 60분 뒤면 통째로 사라지니까. 이건 내 메인 Cloudflare 계정에 에이전트용 API 토큰을 박아두는 것보다 훨씬 안전하다.

실무에서 에이전트에 권한 줄 때 흔히 하는 실수가, 귀찮아서 전역 API 키(Global API Key)나 넓은 스코프 토큰을 그냥 던져주는 거다. 그 토큰이 에이전트 로그나 컨텍스트 윈도우에 노출되면 그대로 사고다. 임시 계정 모델은 애초에 "버려질 자격증명"을 전제로 하니 이 위험을 구조적으로 낮춘다.

그래도 짚어야 할 트레이드오프

  • 60분은 짧다. 빠른 프로토타이핑엔 충분하지만, 사람이 자리를 오래 비운 백그라운드 작업에선 claim 전에 만료돼서 결과물이 날아갈 수 있다. claim URL을 받자마자 안전한 곳(알림, 이슈 코멘트 등)으로 빼두는 자동화가 필요하다.
  • 능력 제한이 있다. 원문도 "temporary accounts have some limitations, and their capabilities may change over time"이라고 명시했다. 어떤 바인딩/기능이 임시 계정에서 막히는지는 공식 developer documentation 확인이 필수다. 추측으로 프로덕션 플로우 짜면 안 된다.
  • 감사 로그 추적성. claim 전 단계는 "사람 없는 익명 계정"이다. 누가, 어떤 에이전트가, 무슨 코드를 배포했는지에 대한 추적은 claim 이후에야 자기 계정 로그로 들어온다. 컴플라이언스 빡센 환경이라면 claim 전 임시 단계의 행위를 별도로 기록하는 장치를 우리 쪽에서 추가로 둬야 한다.

흔한 함정 (에러 메시지 포함)

함정 1 — 만료된 임시 계정에 재배포 시도. 60분 지나고 나서 같은 세션으로 또 배포하려 하면 토큰이 죽어 있다. 에이전트가 이걸 "내가 코드를 잘못 짰나?"로 오해하고 엉뚱한 디버깅 루프에 빠지는 경우가 있다.

$ npx wrangler deploy --temporary
✘ [ERROR] Authentication error [code: 10000]

  The temporary account associated with this token has expired or
  was not claimed within the 60-minute window.

  Run `wrangler deploy --temporary` to provision a new temporary account.

대응: 에이전트 워크플로우에 "배포 실패 시 만료 여부를 먼저 의심하고 새 임시 계정을 다시 띄운다"는 분기를 넣어두는 게 좋다. (위 에러 문구는 일반적인 Wrangler 인증 에러 형태를 바탕으로 한 예시이며, 실제 코드/문구는 버전에 따라 다를 수 있으니 확인 필요)

함정 2 — Wrangler 버전이 낮아서 --temporary를 모름. 원문에서도 "Make sure you're using the latest Wrangler release"를 강조한다. 구버전이면 플래그를 인식 못 한다.

$ npx wrangler deploy --temporary
✘ [ERROR] Unknown argument: temporary

  Run `wrangler deploy --help` to see available options.

대응: 에이전트 환경 부트스트랩 단계에서 npx wrangler@latest를 강제하거나, 최소 버전을 핀(pin)해둬라. 에이전트는 버전 문제를 잘 못 알아채고 다른 데서 헤맨다.

함정 3 — claim URL을 사람에게 전달하는 채널이 없음. 에이전트가 stdout에만 claim URL을 뱉고 끝나면, 백그라운드 세션에선 그 출력을 아무도 안 본다. 60분 뒤 조용히 삭제된다. claim URL을 Slack/이슈/이메일로 라우팅하는 후처리를 반드시 붙여라. 이게 실무에서 제일 자주 까먹는 포인트다.

대안과의 비교

이미 비슷한 문제를 다른 결로 풀어온 도구들이 있다. 결이 다르니 비교해두면 설계 판단에 도움이 된다.

  • AWS STS (임시 자격증명): AssumeRole로 짧은 수명의 토큰을 받는 모델. Cloudflare 임시 계정과 닮은 점은 "수명이 짧고 만료된다"는 것. 다른 점은 STS는 이미 존재하는 IAM 권한 체계 안에서 권한을 빌려오는 거고, Cloudflare 임시 계정은 계정 자체가 가입 전에 없던 것을 즉석에서 만든다는 점이다. STS는 "권한 위임", Cloudflare는 "가입 지연(deferred signup)"에 가깝다.
  • HashiCorp Vault Dynamic Secrets: 요청 시점에 DB 계정 같은 걸 동적으로 만들고 TTL 끝나면 회수하는 모델. "필요할 때 생성, 짧게 살고 소멸"이라는 철학은 거의 동일하다. 차이는 Vault는 내가 운영하는 시크릿 엔진이고 백엔드 시스템에 자격증명을 발급하는 반면, Cloudflare는 플랫폼 제공자가 가입 절차 자체를 우회시켜주는 SaaS 레벨 기능이라는 점.
  • OAuth 기반(auth.md, WorkOS 연계): 원문이 같이 언급한 흐름. 이건 "임시"가 아니라 제대로 된 계정을 표준 OAuth로 에이전트가 대행 생성하는 방향이다. Stripe 파트너십도 같은 맥락(결제·도메인·토큰까지 대행). 즉 Cloudflare는 임시 계정 / OAuth 대행 프로비저닝 두 갈래를 동시에 밀고 있다고 보면 된다.

정리하면, 임시 계정은 STS/Vault의 "짧은 수명" 철학을 가입 절차가 없던 신규 사용자 온보딩 영역으로 확장한 거다. 기존 도구들은 "이미 있는 너에게 짧게 빌려준다"였다면, 이건 "아직 없는 너를 위해 임시로 만들어준다"는 차이.

4. 정리: 한 줄 요약과 누가 써야 하나

한 줄 요약: Cloudflare 임시 계정은 "사람용 가입 절차"를 배포 이후로 미뤄, 에이전트가 인증 벽 없이 write→deploy→verify 루프를 돌리게 해주는 기능이다.

이럴 때 써라:

  • 에이전트로 빠르게 프로토타입 만들고 즉시 배포·검증하는 루프가 필요할 때
  • 일회성/실험성 워크로드라 영구 계정에 흔적 남기기 싫을 때
  • 에이전트에게 넓은 권한 토큰을 직접 쥐여주기 부담스러울 때 (격리 목적)

이럴 땐 다시 생각해라:

  • 장시간 무인 작업이라 60분 안에 claim을 보장 못 할 때 → claim URL 라우팅 자동화부터 만들고 시작
  • 감사 로그·컴플라이언스가 빡센 환경 → claim 전 익명 단계의 추적성을 별도로 확보
  • 임시 계정에서 막히는 바인딩/기능에 의존하는 워크로드 → 문서 먼저 확인

개인적으로 이건 단발 기능 공지라기보다, "에이전트를 1급 시민(first-class citizen)으로 대접하는 인프라"로 가는 흐름의 한 조각으로 본다. 에러 메시지를 LLM이 읽게 설계하고, 가입을 지연시키고, 권한을 일회용으로 격리하는 — 이 패턴들은 우리가 자체 도구를 에이전트 친화적으로 만들 때도 그대로 가져올 만하다.

참고 자료

728x90

도입: 왜 지금 DuckDB가 화제인가

요즘 데이터 엔지니어링 채널이나 Hacker News를 보면 DuckDB 얘기가 끊이질 않는다. 처음엔 "또 새 DB냐" 하고 넘겼는데, 실제로 굴려보면 왜 다들 떠드는지 금방 이해된다. 노트북에서 6GB짜리 Parquet 파일을 1초 안에 풀스캔하는 걸 보면, 솔직히 클러스터 깔던 시절이 좀 허무해진다.

DuckDB는 한 줄로 정의하면 인프로세스(in-process) 분석용 SQL 데이터베이스다. 두 단어가 핵심이다.

  • 분석용(Analytical, OLAP): 단일 레코드를 PK로 찾는 게 아니라, 수백만 행을 스캔해서 필터·집계·조인하는 워크로드에 최적화돼 있다. Postgres/MySQL 같은 OLTP DB와 정반대 방향이다.
  • 인프로세스(In-process): 서버가 없다. 접속하는 게 아니라 라이브러리로 불러온다. NumPy나 Polars를 import하듯이 import duckdb 하면 끝이다.

채택이 빠른 이유는 단순하다. 진짜 쓰기 쉽다. 의존성 없는 20MB 미만 단일 바이너리고, pip install duckdb 한 방이면 설치 끝. Parquet/CSV/JSON 디렉터리를 마치 이미 SQL 테이블인 것처럼 열어준다. MotherDuck(클라우드 웨어하우스), Hex/Omni/Evidence(BI 인앱 엔진), Fivetran(데이터 레이크 라이터의 머지·컴팩션) 같은 곳에서 실제 프로덕트로 굴리고 있다.

이 글에서는 단순 사용법이 아니라 "그래서 왜 빠른가"를 내부 구조 관점에서 본다. 원문(Greybeam의 DuckDB Internals Part 1)을 따라가되, 실무에서 만나는 함정까지 같이 정리했다.

핵심: DuckDB는 왜 빠른가 — 동작 원리

1) 서버가 없다는 것의 진짜 의미 (직렬화 비용 제거)

일반적인 분석 DB(Snowflake, BigQuery, Redshift)는 전부 서버다. 커넥션 열고, SQL을 TCP로 보내고, 결과를 받는다. 이 과정에서 결과의 모든 값이 직렬화 → 네트워크 전송 → 역직렬화를 거친다.

이게 왜 문제냐. 2017년 Raasveldt와 Mühleisen의 "Don't Hold My Data Hostage" 논문에서 측정한 바로는, ODBC/JDBC 같은 클라이언트 프로토콜 자체가 쿼리 전체에서 가장 느린 단계인 경우가 흔했다. DB가 답을 계산하는 시간보다 결과를 꺼내 오는 시간이 더 길었다는 거다.

두 가지 비용이 원인이다.

  • 대역폭: 기가비트 이더넷이 약 125MB/s가 한계라, 큰 결과셋은 전송 시간이 계산 시간을 넘긴다.
  • 값당 오버헤드: ODBC/JDBC는 행 하나, 값 하나씩 돌려준다. 1억 행 결과면 필드마다 함수 호출이 수억 번 발생하고, 각 호출마다 메모리 복사·타입 체크·문자열 할당이 따라붙는다.

DuckDB는 클라이언트와 같은 프로세스에 살기 때문에 이 두 병목을 통째로 우회한다. Python에서 pandas 데이터프레임을 쿼리할 때 replacement scan이라는 기능을 쓰는데, 데이터프레임을 내부 테이블로 복사하지 않고 쿼리 실행 시점에 데이터프레임을 직접 읽는 함수로 테이블 참조를 치환한다. 운이 좋으면 Python 프로세스가 이미 들고 있는 버퍼를 그대로 읽는다. 이게 zero-copy다.

실제로 돌려보자.

import duckdb
import pandas as pd

df = pd.DataFrame({"id": range(1_000_000), "v": range(1_000_000)})

# df를 복사하지 않고 직접 쿼리 (replacement scan)
result = duckdb.sql("SELECT sum(v) FROM df WHERE id % 2 = 0").fetchall()
print(result)
[(249999500000,)]

여기서 FROM dfdf는 미리 만든 테이블이 아니라 그냥 Python 변수다. DuckDB가 알아서 잡아간다.

다만 주의할 점. 진짜 zero-copy인지는 데이터프레임의 물리적 레이아웃·컬럼 타입·null 표현·문자열 저장 방식에 따라 다르다. 타입이나 레이아웃이 안 맞으면 일부 컬럼은 변환 버퍼를 새로 할당한다. 가장 깔끔한 건 Arrow다. Arrow는 애초에 시스템 간 데이터 공유를 위한 컬럼형·타입 지정 메모리 포맷이라, 결과를 Arrow로 받거나 Arrow 기반 데이터를 쿼리하면 행 단위 변환 오버헤드를 대부분 피할 수 있다.

2) SQL이 들어오면 무슨 일이 벌어지나: parse → bind → plan → optimize

SQL이 엔진에 도착하면 정해진 단계를 거친다.

  • 파싱(Parse): SQL 문자열을 AST(추상 구문 트리)로 바꾼다. DuckDB는 Postgres 파서를 포크해서 쓴다. 그래서 SQL 방언이 Postgres랑 익숙하게 느껴지는 거다.
  • 바인딩(Bind): AST의 모든 이름을 카탈로그에 매칭한다. lineitem은 특정 스키마를 가진 테이블이 되고, l_quantity는 특정 타입의 컬럼이 된다. 타입 체크도 여기서 일어난다. 컬럼 미해결, 모호한 참조, 타입 불일치 에러가 이 단계에서 터진다.
  • 최적화(Optimize): 작고 집중된 변환 패스들의 연속이다.

DuckDB의 좋은 점은 옵티마이저를 직접 들여다보고 끌 수 있다는 거다.

D SELECT name FROM duckdb_optimizers();
┌────────────────────────────┐
│            name            │
│          varchar           │
├────────────────────────────┤
│ expression_rewriter        │
│ filter_pushdown            │
│ join_order                 │
│ row_group_pruner           │
│ join_filter_pushdown       │
│ statistics_propagation     │
│ ...                        │
└────────────────────────────┘
33 rows

특정 패스만 끄고 동작을 관찰할 수도 있다.

SET disabled_optimizers = 'filter_pushdown, join_order';

실무에서 중요한 옵티마이저 몇 개만 짚자.

  • Filter pushdown: WHERE 술어를 스캔 가까이로 밀어서 최대한 일찍 데이터를 쳐낸다. DuckDB는 일단 필터를 위로 끌어올려 합치고 재배열한 다음, 다시 최대한 아래로 밀어내린다.
  • Dynamic join-filter pushdown: 해시 조인에서 build side를 먼저 다 읽어야 probe side가 시작된다. DuckDB는 이 순서를 이용해서, build side가 메모리에 올라온 뒤 조인 키의 min/max를 계산하고 그 범위를 probe side 스캔에 런타임 필터로 밀어넣는다. build side에 100~200 사이 값만 있었다면, probe 스캔은 zonemap을 보고 그 범위 밖 row group을 아예 안 읽는다. distinct 값이 50개 미만이면 min-max 대신 IN 리스트로 바꿔서 더 정밀하게 건너뛴다.
  • Join order optimization: 옵티마이저가 내리는 가장 영향력 큰 결정이다. 테이블 6개 조인이면 가능한 트리 모양이 30,240가지고, 최선과 최악의 차이가 런타임으로 수십~수백 배 날 수 있다. DuckDB는 쿼리를 그래프로 모델링한다(테이블=노드, 조인 술어=엣지).

3) 컬럼 단위 압축 저장 + zonemap

왜 컬럼형이 분석에 유리한가. SELECT sum(l_quantity) 같은 쿼리는 한 컬럼만 필요하다. 행 단위 저장이면 필요 없는 컬럼까지 메모리에 끌고 오지만, 컬럼 저장이면 딱 그 컬럼만 읽는다. 게다가 같은 컬럼은 같은 타입·비슷한 값이라 압축률도 훨씬 좋다.

여기에 zonemap이 붙는다. 데이터를 row group 단위로 나누고, 각 그룹의 컬럼별 min/max를 메타데이터로 들고 있다. WHERE l_shipdate > '2024-01-01' 같은 필터가 들어오면, max가 그 날짜보다 작은 row group은 아예 읽지도 않고 건너뛴다. 앞서 본 dynamic join-filter pushdown이 이 zonemap을 이용해 동작하는 거다.

실무 관점: 도입 시 고려사항, 트레이드오프, 흔한 함정

언제 쓰는가

  • 로그/이벤트 분석 애드혹 쿼리: S3나 로컬에 쌓인 Parquet/CSV를 클러스터 없이 바로 질의. ETL 중간 검증용으로 특히 좋다.
  • ETL 파이프라인의 변환 단계: pandas로 메모리 터뜨리던 집계·조인을 SQL 한 줄로 처리. Fivetran이 데이터 레이크 라이터 내부에서 머지·컴팩션에 쓰는 게 같은 맥락이다.
  • CI 테스트 러너 / 임베디드 분석: 서버 띄울 필요 없으니 테스트 환경이 가볍다.

실제 로그 분석 예시. S3에 쌓인 Parquet을 바로 집계한다.

-- httpfs 익스텐션으로 S3 직접 쿼리
INSTALL httpfs;
LOAD httpfs;

SELECT
    status_code,
    count(*) AS cnt
FROM read_parquet('s3://my-bucket/logs/2024-*/*.parquet')
WHERE event_date >= '2024-01-01'
GROUP BY status_code
ORDER BY cnt DESC;
┌─────────────┬──────────┐
│ status_code │   cnt    │
│    int32    │  int64   │
├─────────────┼──────────┤
│         200 │ 18342211 │
│         404 │   210334 │
│         500 │    12044 │
└─────────────┴──────────┘

트레이드오프와 한계

  • OLTP가 아니다: 단건 조회·고빈도 동시 쓰기 워크로드에는 부적합하다. PK로 한 줄 찾는 건 Postgres가 할 일이다.
  • 단일 노드: 강력한 단일 노드 엔진이지만, 한 머신 메모리/디스크를 넘어서는 진짜 빅데이터는 분산 엔진 영역이다. (다만 메모리보다 큰 데이터도 디스크 스필로 처리는 한다.)
  • 동시성 모델: 한 파일에 대해 한 프로세스가 쓰기 위주. 여러 프로세스가 같은 DB 파일에 동시에 붙는 시나리오는 설계상 제약이 있으니 공식 문서 확인이 필요하다.

흔한 함정 (에러 메시지 포함)

함정 1) DB 파일을 다른 프로세스가 잡고 있을 때. 로컬에서 DuckDB CLI로 파일 열어놓고, 동시에 Python에서 같은 파일을 쓰기 모드로 열면 이걸 만난다.

duckdb.IOException: IO Error: Could not set lock on file
"/data/analytics.duckdb": Conflicting lock is held in
/usr/bin/python3 (PID 12345). See also
https://duckdb.org/docs/connect/concurrency

해결은 단순하다. 읽기만 할 거면 read_only=True로 열어라. 쓰기는 한 번에 한 프로세스만.

con = duckdb.connect("/data/analytics.duckdb", read_only=True)

함정 2) 메모리보다 큰 집계를 돌릴 때 OOM. 거대한 GROUP BY나 정렬을 메모리 안에서 다 처리하려다 터진다.

duckdb.OutOfMemoryException: Out of Memory Error: failed to
allocate data of size ... (X GB/Y GB used)

memory_limit를 명시적으로 잡고, 디스크 스필이 가능하도록 temp_directory를 지정하면 메모리를 넘는 쿼리도 디스크로 흘려가며 처리한다.

SET memory_limit = '8GB';
SET temp_directory = '/data/duckdb_tmp';

함정 3) 타입 추론 어긋남. CSV를 읽을 때 자동 타입 추론이 첫 N행만 보고 결정하는 경우가 있어, 뒤쪽에 다른 타입이 나오면 캐스팅 에러가 난다.

Conversion Error: Could not convert string 'N/A' to INT64

이럴 땐 read_csv에서 types를 직접 지정하거나 sample_size를 키워라.

SELECT * FROM read_csv('data.csv',
    types={'amount': 'VARCHAR'},
    sample_size=-1);  -- -1이면 전체 스캔으로 추론

대안과의 비교

  • Polars: 같은 인프로세스 컬럼형 처리지만 DataFrame API 중심. SQL이 익숙하면 DuckDB, 함수형 체이닝이 편하면 Polars. 둘이 Arrow로 잘 섞인다.
  • ClickHouse: 서버형 분산 OLAP. 대규모 동시 쿼리·항상 켜져 있는 분석 서비스라면 이쪽. 임베디드·애드혹이면 DuckDB가 가볍다.
  • Postgres: OLTP가 메인이면 그냥 Postgres. DuckDB는 OLTP 대체재가 아니다.

정리

한 줄 요약: DuckDB는 서버 없이 같은 프로세스에서 도는 컬럼형 분석 엔진으로, 직렬화 비용 제거 + 컬럼 압축 + zonemap 기반 pruning + 똑똑한 옵티마이저 덕분에 단일 노드에서 클러스터급 분석 속도를 낸다.

누가 언제 쓰나:

  • 노트북/CI/ETL에서 Parquet·CSV를 클러스터 없이 빠르게 집계하고 싶은 데이터·백엔드 엔지니어 → 강력 추천.
  • pandas로 메모리 터뜨리며 집계하던 변환 단계를 SQL로 정리하고 싶을 때 → 딱이다.
  • 단건 조회·고빈도 쓰기·다중 프로세스 동시 쓰기가 핵심이면 → Postgres나 서버형 DB로 가라.

이번 글은 SQL이 들어와서 엔진이 실행 준비를 마치는 지점까지, 그리고 그 쿼리가 읽을 스토리지 레이어까지 다뤘다. 실제 쿼리 실행(벡터화 실행, morsel 기반 병렬성)은 원문 기준 Part 2에서 이어진다고 하니, 그쪽도 같이 보면 그림이 완성된다.

참고 자료

728x90

도입: 트래픽 스파이크 때마다 한 박자 늦던 그 답답함

ECS로 서비스 운영해본 사람이라면 한 번쯤 겪어봤을 거다. 트래픽이 갑자기 튀는데 태스크는 한참 뒤에야 늘어나고, 그 사이에 응답 지연이나 5xx가 줄줄이 터지는 상황. 모니터링 대시보드 보면서 "왜 아직도 안 늘어나?" 하다가 결국 평소에 태스크를 넉넉하게 띄워놓는 식으로 땜빵하게 된다. 비용은 비용대로 나가고.

이 문제의 근본 원인 중 하나가 CloudWatch 메트릭의 해상도였다. 기존 ECS 서비스 오토스케일링은 기본적으로 1분(60초) 단위 메트릭에 의존했다. CPU가 임계치를 넘어도 메트릭이 집계되고 알람이 평가되고 스케일링이 트리거되기까지 분 단위 지연이 깔려 있었다는 얘기다.

이번에 AWS가 ECS 서비스 오토스케일링에 20초 해상도 고해상도 메트릭을 지원하기 시작했다. 단순한 숫자 변경이 아니라, AWS 자체 벤치마크 기준으로 스케일 아웃 트리거 시간이 363초 → 86초(76% 단축, 4.2배), 새 태스크 프로비저닝까지 포함한 전체 시간이 386초 → 109초(72% 단축, 3.5배)로 줄었다고 한다. 트래픽 대응과 비용 최적화에 직결되는 변화라 안 짚고 넘어갈 수가 없다.

핵심: 1분에서 20초로, 무엇이 달라지나

왜 1분 메트릭이 느렸나

타깃 트래킹 스케일링이 동작하는 흐름을 쪼개보면 이렇다.

  1. ECS/CloudWatch가 CPU·메모리 같은 지표를 수집하고 집계한다.
  2. CloudWatch 알람이 일정 주기로 메트릭을 평가한다.
  3. 임계치를 넘으면 Application Auto Scaling이 스케일링 액션을 발동한다.
  4. ECS가 새 태스크를 띄우고 ALB에 등록하고 헬스체크를 통과한다.

1분 메트릭에서는 1~2단계에서만 이미 수십 초~1분 이상이 깔린다. 메트릭이 1분에 한 번 찍히니, 트래픽이 튄 직후의 데이터 포인트가 다음 분이 되어야 반영된다. 거기에 알람 평가 주기까지 더해지면 "지표상 부하가 올랐다"는 사실을 인지하는 데만 수 분이 걸린다.

고해상도 메트릭의 동작

고해상도 메트릭은 이 집계·평가 주기를 20초 단위로 당긴다. 비유하자면, 기존이 1분마다 한 번 창밖을 내다보고 비가 오는지 판단하던 거라면, 고해상도는 20초마다 내다본다. 비 오기 시작하면 더 빨리 우산을 펼 수 있는 거다.

이번 기능에서 새로 추가된 미리 정의된 메트릭은 두 개다.

  • ECSServiceAverageCPUUtilizationHighResolution
  • ECSServiceAverageMemoryUtilizationHighResolution

이름에서 보이듯 평균 CPU 사용률과 평균 메모리 사용률 기반이다. 타깃 트래킹 정책에서 이 메트릭을 고르면 ECS 서비스가 20초 간격으로 스케일링 결정을 평가하게 된다. AWS Fargate, ECS Managed Instances, EC2 등 모든 컴퓨트 옵션에서 동작한다.

설정은 두 단계

핵심은 두 가지다. (1) 서비스에 20초 해상도 메트릭을 켠다. (2) 그 메트릭을 쓰는 타깃 트래킹 정책을 건다. 콘솔이라면 서비스 생성/수정 시 Monitoring 섹션에서 20초 해상도 메트릭을 추가하고, Service auto scaling 섹션에서 Target Tracking을 고른 뒤 위의 HighResolution 메트릭을 선택하면 끝이다.

CLI로 한다면 대략 이런 흐름이다. 먼저 서비스에 고해상도 메트릭을 활성화한다.

aws ecs update-service \
  --cluster my-cluster \
  --service my-web-service \
  --service-connect-configuration ... \
  --enable-high-resolution-metrics 2>&1 || true

# 실제 플래그 명칭/위치는 최신 CLI 버전에 따라 다를 수 있으니
# `aws ecs update-service help` 로 확인하는 걸 권장한다.

※ 위 플래그명은 환경/버전에 따라 다를 수 있어 공식 문서 확인이 필요하다. 메트릭이 켜진 뒤 배포가 완료되면 고해상도 메트릭이 생성되기 시작한다.

그다음 Application Auto Scaling으로 타깃 트래킹 정책을 건다. CPU 60%를 타깃으로 잡는 예시다.

# 1) 스케일 대상 등록
aws application-autoscaling register-scalable-target \
  --service-namespace ecs \
  --resource-id service/my-cluster/my-web-service \
  --scalable-dimension ecs:service:DesiredCount \
  --min-capacity 2 \
  --max-capacity 20

# 2) 고해상도 메트릭 기반 타깃 트래킹 정책 생성
cat > policy.json <<'EOF'
{
  "TargetValue": 60.0,
  "PredefinedMetricSpecification": {
    "PredefinedMetricType": "ECSServiceAverageCPUUtilizationHighResolution"
  },
  "ScaleInCooldown": 60,
  "ScaleOutCooldown": 60
}
EOF

aws application-autoscaling put-scaling-policy \
  --service-namespace ecs \
  --resource-id service/my-cluster/my-web-service \
  --scalable-dimension ecs:service:DesiredCount \
  --policy-name cpu-highres-tt \
  --policy-type TargetTrackingScaling \
  --target-tracking-scaling-policy-configuration file://policy.json

정상적으로 들어가면 정책 ARN과 생성된 CloudWatch 알람 정보가 출력된다.

{
    "PolicyARN": "arn:aws:autoscaling:ap-northeast-2:111122223333:scalingPolicy:...:resource/ecs/service/my-cluster/my-web-service:policyName/cpu-highres-tt",
    "Alarms": [
        {
            "AlarmName": "TargetTracking-service/my-cluster/my-web-service-AlarmHigh-xxxxxxxx",
            "AlarmARN": "arn:aws:cloudwatch:ap-northeast-2:111122223333:alarm:..."
        },
        {
            "AlarmName": "TargetTracking-service/my-cluster/my-web-service-AlarmLow-xxxxxxxx",
            "AlarmARN": "arn:aws:cloudwatch:ap-northeast-2:111122223333:alarm:..."
        }
    ]
}

예전에는 이 정도의 공격적인 스케일링을 하려면 스텝 스케일링 정책을 손으로 정교하게 짜야 했는데, 이제는 타깃 트래킹 + 고해상도 메트릭 조합으로 설정 한 번에 비슷한 반응성을 얻을 수 있다는 게 AWS의 설명이다. 그동안 커스텀 엔지니어링으로 땜빵하던 부분이 설정 하나로 대체되는 셈이다.

실무 관점: 트레이드오프와 흔한 함정

비용 — 공짜가 아니다

오해하기 쉬운 부분인데, "빠른 오토스케일링" 기능 자체는 추가 비용이 없다. 다만 고해상도 메트릭이 새로운 과금 차원을 만든다. 표준 해상도(60초) 메트릭은 무료지만, 20초 해상도 메트릭은 CloudWatch 비용이 추가로 발생한다. 정확한 단가는 원문에 명시되어 있지 않으니 CloudWatch 요금 페이지를 직접 확인해야 한다.

그래서 트레이드오프 판단이 중요하다. 고해상도 메트릭 비용을 더 내더라도, 평소 태스크를 넉넉하게 띄워두던 "선제적 패딩"을 줄일 수 있다면 전체 컴퓨트 비용은 오히려 내려갈 수 있다. 스케일 아웃이 충분히 빨라지니까 미리 capacity를 깔아둘 필요가 줄어드는 거다. 반대로 트래픽이 늘 잔잔하고 스파이크가 거의 없는 서비스라면 굳이 고해상도까지 켤 이유가 없다.

워크로드별 권장 (개인 판단 기준)

  • 스파이크가 잦고 급격한 서비스(이벤트성 트래픽, 외부 캠페인 연동 API 등): 고해상도 켤 만하다. 스케일 아웃 지연이 곧 장애로 이어지는 케이스.
  • 완만하게 증감하는 서비스: 60초로도 충분한 경우가 많다. 비용 대비 이득이 작다.
  • 큐 기반 워커(SQS depth 등 커스텀 메트릭): 이번에 추가된 건 CPU/메모리 고해상도 프리디파인드 메트릭이라, 큐 깊이 같은 커스텀 메트릭은 별도로 고해상도로 publish하는 설계가 필요하다. 적용 가능 여부는 문서 확인 필요.

흔한 함정

함정 1 — 메트릭을 안 켜고 정책부터 만든다. 서비스에 고해상도 메트릭을 활성화하고 배포가 완료되기 전에는 HighResolution 메트릭에 데이터 포인트가 안 찍힌다. 이 상태로 타깃 트래킹을 걸면 알람이 데이터 부족 상태로 빠지고 스케일링이 동작하지 않는다. 콘솔이나 알람에서 이런 메시지를 보게 된다.

State: INSUFFICIENT_DATA
StateReason: Insufficient Data: 1 datapoint were unknown.

순서가 핵심이다. 메트릭 활성화 → 배포 완료 → 메트릭 생성 확인 → 정책 연결 순으로 가야 한다.

함정 2 — 쿨다운을 그대로 둔다. 메트릭 평가는 20초로 빨라졌는데 ScaleOutCooldown을 기존처럼 300초로 길게 잡아두면, 정작 추가 스케일 아웃이 쿨다운에 막혀서 고해상도의 이점이 반감된다. 위 예시처럼 쿨다운도 짧게(예: 60초) 재검토하는 게 좋다. 단, 너무 짧으면 출렁임(flapping)이 생길 수 있으니 실측하며 조정해야 한다.

함정 3 — 다운스트림 한계를 무시한다. ECS 태스크는 빨리 늘어나는데 DB 커넥션 풀이나 외부 API rate limit이 그걸 못 받쳐주면, 빠른 스케일 아웃이 오히려 다운스트림을 때려서 장애를 키운다. 스케일링이 빨라질수록 "내 서비스의 진짜 병목이 어디인가"를 다시 봐야 한다.

함정 4 — 프로비저닝 한계를 메트릭으로 해결하려 한다. 벤치마크에서 트리거는 86초로 줄었지만 전체 시간은 109초였다. 즉 태스크 이미지 풀, 컨테이너 기동, ALB 등록·헬스체크에 걸리는 시간은 여전히 남는다. 이미지 슬림화, 헬스체크 주기 튜닝 같은 기본기가 같이 받쳐줘야 체감 효과가 산다.

모니터링 대시보드 팁

고해상도 전환 후에는 대시보드 위젯의 period도 같이 손봐야 한다. 위젯을 60초 period로 두면 모처럼 켠 20초 데이터의 디테일이 뭉개진다. CPU/메모리 위젯은 20초 또는 그에 맞는 짧은 period로 보고, 옆에 DesiredCount와 RunningCount, ALB의 TargetResponseTime/5xx를 나란히 두면 "지표가 오름 → 태스크가 늚 → 지연이 잡힘"이라는 인과를 한눈에 검증할 수 있다.

정리

한 줄 요약: ECS 서비스 오토스케일링이 20초 해상도 메트릭을 지원하면서 스케일 아웃 반응이 약 4배 빨라졌고, 그동안 스텝 스케일링으로 땜빵하던 공격적 스케일링을 타깃 트래킹 설정 하나로 대체할 수 있게 됐다.

누가 언제: 트래픽 스파이크가 잦아서 평소 태스크를 넉넉히 깔아두던 서비스라면 지금 검토할 가치가 충분하다. 고해상도 메트릭 비용은 추가되지만, 선제적 패딩을 줄여 전체 컴퓨트 비용을 낮추는 방향으로 충분히 상쇄할 수 있다. 반대로 트래픽이 완만한 서비스나, 병목이 ECS 태스크가 아니라 DB·외부 API 쪽인 경우엔 효과가 제한적이니 무작정 켜지 말고 트레이드오프를 따져보자. 적용할 땐 메트릭 활성화 순서와 쿨다운 재조정을 꼭 챙겨야 한다.

참고 자료

728x90

새벽 2시, 컨테이너 안에 curl이 없을 때

한 번쯤 겪어봤을 상황이다. 운영 중인 서비스에서 컨테이너 간 통신이 안 되는 것 같아서 `kubectl exec`로 파드에 들어갔는데, curl도 wget도 nc도 없다. distroless나 scratch 기반으로 알뜰하게 말아둔 이미지라 셸조차 없는 경우도 있다.

이게 왜 흔한 일이냐면, 요즘 운영 이미지는 의도적으로 도구를 다 빼기 때문이다. 공격 표면(attack surface)을 줄이려고 distroless를 쓰고, 이미지 크기를 줄이려고 alpine을 쓴다. curl 하나가 끌고 오는 라이브러리 의존성도 무시 못 하고, 컨테이너 안에 디버깅 도구가 많다는 건 그만큼 침해 시 공격자가 쓸 무기도 많다는 뜻이다. 그래서 "최소 이미지"라는 철학 자체는 옳다. 문제는 그 철학이 디버깅하는 우리를 엿먹인다는 점이다.

그런데 의외로 많은 이미지에 `bash`는 들어있다. 그리고 bash에는 잘 안 알려진 기능이 하나 있다. /dev/tcp/host/port 리다이렉션이다. 이걸로 패키지 설치 없이 TCP 연결을 열고 HTTP 요청을 손으로 짜서 보낼 수 있다. GeekNews에 올라온 글(원문)이 딱 이 트릭을 다룬다.

/dev/tcp는 진짜 파일이 아니다

핵심부터 말하면, /dev/tcp는 디스크에 존재하는 파일이 아니다. bash가 리다이렉션 구문을 파싱할 때 특별 취급하는 "가짜 경로"다. 그래서 이런 게 안 된다.

# 일반 파일처럼 접근하면 실패한다
$ ls /dev/tcp
ls: cannot access '/dev/tcp': No such file or directory

# dash(데비안의 /bin/sh)에서도 안 된다
$ /bin/sh -c 'cat /dev/tcp/example.com/80'
/bin/sh: 1: cannot create /dev/tcp/example.com/80: Directory nonexistent

bash 매뉴얼에 따르면, /dev/tcp/host/port 형태의 리다이렉션을 만나면 bash가 직접 DNS 조회를 하고 connect(2) 시스템 콜로 TCP 소켓을 연다. 비유하자면, 파일을 여는 척하는 문법을 빌려서 실제로는 네트워크 소켓을 파일 디스크립터에 매달아 놓는 것이다. 한번 디스크립터에 묶이면 그 다음부터는 평범한 파일처럼 읽고 쓰면 된다. 유닉스의 "모든 것은 파일이다" 철학을 bash가 셸 레벨에서 흉내 낸 셈이다.

실제로 example.com에 GET 요청을 날려보자.

# 파일 디스크립터 3번에 소켓을 연다
exec 3<>/dev/tcp/example.com/80

# HTTP/1.1 요청을 직접 작성해서 보낸다
printf 'GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n' >&3

# 응답을 읽는다
cat <&3

실행하면 이런 응답이 나온다.

HTTP/1.1 200 OK
Content-Type: text/html
ETag: "..."
Cache-Control: max-age=...
Date: ...
Connection: close

<!doctype html>
<html>
...

응답에는 상태 줄, 헤더, 빈 줄, 본문이 한꺼번에 들어온다. 헤더를 더 붙이고 싶으면 요청을 끝내는 빈 줄(\r\n\r\n) 앞에 줄을 추가하면 된다. 예를 들어 인증 토큰을 실어야 한다면:

exec 3<>/dev/tcp/api-service/8080
printf 'GET /v1/models HTTP/1.1\r\nHost: api-service\r\nAuthorization: Bearer %s\r\nConnection: close\r\n\r\n' "$API_KEY" >&3
cat <&3

여기서 Connection: close가 진짜 중요하다. HTTP/1.1은 기본적으로 connection을 유지(keep-alive)하기 때문에, 이 헤더가 없으면 서버가 연결을 안 끊고 cat <&3이 EOF를 기다리며 영원히 멈춰 있는다. 디버깅하다가 터미널이 멈춰서 당황하지 말자.

실무 헬스체크 스크립트로 만들기

한 줄짜리 명령으로 끝내지 말고, 내부 서비스 연결성 확인용으로 다듬어보자. 타임아웃과 상태 코드 파싱까지 넣은 버전이다.

#!/usr/bin/env bash
# check_health.sh — curl 없는 컨테이너에서 헬스체크
HOST="${1:-service}"
PORT="${2:-8642}"
PATH_="${3:-/health}"

# timeout으로 감싸서 연결이 안 끊겨도 6초 후 빠져나오게 한다
response=$(timeout 6 bash -c "
  exec 3<>/dev/tcp/${HOST}/${PORT} || exit 1
  printf 'GET ${PATH_} HTTP/1.1\r\nHost: ${HOST}\r\nConnection: close\r\n\r\n' >&3
  cat <&3
")

if [ $? -ne 0 ]; then
  echo "연결 실패: ${HOST}:${PORT}"
  exit 1
fi

# 상태 줄에서 코드만 뽑기
status=$(echo "$response" | head -1 | awk '{print $2}')
echo "상태 코드: ${status}"
echo "$response" | head -1

실행 결과:

$ ./check_health.sh api-service 8080 /health
상태 코드: 200
HTTP/1.1 200 OK

여기서 timeout 6 bash -c '...'로 감싼 게 핵심이다. 앞에서 말한 keep-alive 멈춤 문제를 안전망으로 막아준다. 무인 환경(예: 라이브니스 프로브 같은 데)에 쓸 거라면 이런 타임아웃은 필수다.

흔한 함정과 한계

1. bash가 아니라 dash/sh로 실행하면 안 된다. /dev/tcp는 POSIX 표준이 아니라 bash 고유 기능이다. 데비안의 /bin/sh는 dash로 링크돼 있어서 무심코 sh script.sh로 돌리면 이런 에러를 만난다.

$ sh check_health.sh
check_health.sh: 9: cannot create /dev/tcp/service/8642: Directory nonexistent

반드시 bash로 직접 호출하거나 셔뱅을 #!/usr/bin/env bash로 정확히 박아야 한다.

2. bash 빌드 옵션이 꺼져 있을 수 있다. /dev/tcp는 bash를 컴파일할 때 --enable-net-redirections 옵션으로 켜진다. 대부분의 주류 빌드는 켜져 있지만, 데비안은 과거 오랫동안 이걸 꺼둔 적이 있다. 원문 댓글에 따르면 2009년경(Debian Bug #146464) 입장이 바뀌어 활성화된 것으로 보인다. 오래된 베이스 이미지를 쓰고 있다면 동작 안 할 가능성을 염두에 두자.

3. HTTPS는 안 된다. /dev/tcp는 평문 TCP 소켓만 연다. TLS 핸드셰이크를 직접 할 능력이 없으니 443 포트에 이걸로 붙으면 서버가 못 알아듣고 깨진다. HTTPS를 손으로 확인해야 한다면 openssl s_client가 필요한데, 그건 또 distroless엔 없을 가능성이 높다.

4. 진짜 HTTP 클라이언트가 아니다. 이건 리다이렉트(3xx), chunked transfer-encoding, gzip 압축, 재시도를 하나도 처리 못 한다. 응답이 chunked로 오면 본문에 청크 크기 숫자가 그대로 섞여 나온다. 그러니 무인 자동화에 진지하게 쓰면 언젠가 발등 찍힌다. 어디까지나 "지금 이 포트가 열려 있고 HTTP로 응답하나?" 정도를 빠르게 보는 디버깅 도구로만 쓰자.

대안은 이런 것들이 있다.

  • kubectl debug / ephemeral container: 쿠버네티스 1.25+ 환경이라면 kubectl debug로 디버깅용 사이드카를 임시로 붙여서 curl이 든 이미지를 끼워 넣을 수 있다. 운영 이미지를 건드리지 않는 가장 깔끔한 방법이다.
  • nsenter: 노드에 SSH로 들어갈 수 있다면 호스트에서 대상 컨테이너의 네트워크 네임스페이스에 진입해 호스트의 도구로 확인할 수 있다.
  • 그냥 운영 이미지에 도구를 넣기: 원문 댓글에서도 "운영 이미지에 curl 정도는 거의 필수"라는 의견이 나온다. 보안 트레이드오프를 따져보고, 도구를 빼서 얻는 이득이 디버깅 비용보다 작다면 넣는 게 맞을 수도 있다.

정리

한 줄 요약: exec 3<>/dev/tcp/host/port는 패키지 설치 없이 TCP 연결성과 평문 HTTP 응답을 즉석에서 확인하는 bash 내장 트릭이다.

이걸 써야 할 때는 명확하다. distroless나 alpine 같은 최소 이미지 안에서, 도구 설치가 막혀 있고, 지금 당장 "저 서비스에 닿는지"만 빠르게 보고 싶을 때다. 반대로 HTTPS가 필요하거나, 리다이렉트·압축을 처리해야 하거나, 자동화 파이프라인에 진지하게 넣을 거라면 손대지 말고 제대로 된 클라이언트를 쓰거나 ephemeral container를 띄워라. 화재 진압용 손도끼지, 일상 작업 공구가 아니다.

참고 자료

728x90

최근 "JWT 사용을 중단하라"는 글이 다시 돌면서 댓글창이 또 한 번 불타올랐다. 매번 반복되는 논쟁인데, 핵심 메시지는 늘 같다. "브라우저 사용자 로그인 세션 유지에 JWT를 쓰지 마라." 서비스 간 통신(M2M)이나 SSO 전송에는 JWT가 멀쩡히 잘 쓰이는데, 정작 한국 백엔드 실무에서 가장 흔하게 보이는 패턴은 "로그인하면 access token + refresh token을 발급해서 localStorage에 박아두는" 그 패턴이다. 이 글은 그게 왜 잘못된 선택인지, 그리고 어떻게 정리할지를 실무자 관점에서 풀어본다.

1. 왜 지금 또 이 얘기가 나오나

이 논쟁의 출처는 GeekNews에 올라온 gist 글이다. 주장은 간단하다. JWT는 무상태(stateless) 인증을 약속하지만, 정작 안전하게 운영하려면 어차피 서버 측 상태가 필요하고, 그럴 거면 그냥 평범한 쿠키 세션이 더 단순하고 안전하다는 것이다.

실무에서 이 패턴을 만나는 순간은 거의 정해져 있다. 신규 프로젝트 킥오프 때 누군가 "요즘은 무상태로 가야죠, JWT 박읍시다"라고 말하면서 시작된다. MSA를 한다는 명분, 모바일 앱이 붙는다는 명분, 서버 메모리에 세션 안 들고 싶다는 명분. 그런데 1년쯤 지나서 "강제 로그아웃 기능 넣어주세요", "탈취된 토큰 즉시 무효화해주세요", "이 사용자만 전체 기기에서 로그아웃시켜주세요" 같은 요구가 들어오면 그제야 무상태의 환상이 깨진다.

한 가지 짚고 넘어갈 것. 원문 발췌에 나온 "JWT 스펙은 5분 이하 짧은 수명 토큰만을 위해 설계됐다"는 주장은 HN 댓글에서도 반박이 나온다. RFC 7519를 봐도 그런 명시적 규정은 없다는 지적이다. 그러니 이 부분은 글쓴이의 강한 의견 정도로 받아들이고, "JWT는 짧은 수명일 때 가장 안전하게 동작한다"는 실무적 경험칙으로 이해하는 게 맞다. 스펙 레벨의 단정은 공식 문서 확인이 필요하다.

2. JWT는 어떻게 동작하고, 세션은 어떻게 동작하나

비유부터. JWT는 위조 방지 도장이 찍힌 출입증이다. 한 번 발급하면 서버가 명부를 보지 않아도 도장만 검증해서 통과시킨다. 빠르고 편하다. 문제는 이미 발급한 출입증을 회수할 방법이 없다는 것. 도장은 유효 기간 끝날 때까지 살아있다.

반면 쿠키 세션은 코인 보관함 번호표다. 번호표 자체엔 아무 정보가 없고, 서버가 보관함(세션 스토어)을 열어봐야 누군지 안다. 번호표를 회수(세션 삭제)하면 그 즉시 무효다.

실제 JWT 구조를 디코드해보자. JWT는 그냥 base64url로 인코딩된 세 덩어리(header.payload.signature)다. 암호화가 아니다. 누구나 디코드해서 내용을 읽을 수 있다.

# JWT의 payload 부분만 디코드해보기 (서명 검증 아님, 단순 디코드)
echo "eyJzdWIiOiIxMjM0IiwibmFtZSI6IkhvbmciLCJpYXQiOjE3MDAwMDAwMDB9" | base64 -d

# 출력:
{"sub":"1234","name":"Hong","iat":1700000000}

여기서 첫 번째 함정이 보인다. localStorage에 JWT를 넣어두면 XSS 한 방에 이 토큰이 통째로 털린다. 그리고 payload는 평문이나 다름없으니, 민감 정보(권한, 이메일 등)를 넣었다면 그것도 같이 노출된다. 그래서 원문도 강조한다. "인증 자격 증명은 localStorage나 sessionStorage에 저장하지 말 것."

그럼 쿠키 세션은? 제대로 설정한 세션 쿠키는 이렇게 생긴다.

# 로그인 응답의 Set-Cookie 헤더 확인
curl -i -X POST https://example.com/login \
  -d 'username=hong&password=secret'

# 출력 (헤더 일부):
HTTP/2 200
set-cookie: sid=8f3a9c1e-...; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=86400

HttpOnly가 붙으면 자바스크립트(document.cookie)로 접근이 막힌다. 즉 XSS가 터져도 세션 ID는 못 빼간다. 이게 localStorage JWT와의 결정적 차이다.

3. 실무 관점 — 트레이드오프, 흔한 함정, 대안

무상태의 함정: 로그아웃과 탈취 대응

JWT 세션의 가장 큰 거짓말은 "무상태"다. 실무에서 진짜로 무상태로 운영되는 인증 시스템은 거의 없다. 왜냐하면:

  • 강제 로그아웃: 토큰을 무효화하려면 결국 서버 어딘가에 폐기 목록(blocklist)이나 minimum_issued_at 같은 상태를 둬야 한다.
  • 실시간 권한 변경: 사용자 권한을 회수했는데 JWT에 박힌 권한은 만료 전까지 그대로 살아있다. 그래서 요청마다 DB에서 사용자 객체를 다시 조회하게 된다.
  • 탈취 대응: 토큰이 털린 걸 알아도 만료 전까지 막을 수 없다. blocklist에 넣어야 한다.

HN 댓글의 한 사람이 정곡을 찌른다. "요청마다 사용자 객체를 조회하는 순간, JWT의 핵심 장점은 사라진다. 그럴 거면 그냥 불투명한 세션 ID 쓰고 DB 조회하면 된다." 세션 조회는 인덱스 탄 SELECT 한 번, 0~1행 반환이다. 대부분의 서비스에서 이건 병목이 아니다.

흔한 함정 — 실제로 만나는 에러들

JWT를 세션으로 굴리다 보면 거의 반드시 만나는 에러들이 있다. 검색 유입 차원에서 그대로 박아둔다.

(1) access token 만료 시점 처리 실패

JsonWebTokenError: jwt expired
    at /app/node_modules/jsonwebtoken/verify.js:152:21
    name: 'TokenExpiredError',
    expiredAt: 2024-01-15T03:22:11.000Z

refresh 로직을 프론트에 제대로 안 깔아두면, 사용자가 작업 중에 갑자기 401을 맞고 튕긴다. silent refresh 구현이 생각보다 까다롭고, 동시 요청이 여러 개 날아갈 때 refresh가 race condition으로 중복 호출되는 버그가 흔하다.

(2) 서명 알고리즘 혼동 — 역사적 취약점

JsonWebTokenError: invalid algorithm
    at /app/node_modules/jsonwebtoken/verify.js:104:19

과거 일부 라이브러리는 alg: none을 받아들이거나(서명 검증 없이 통과), 공개 키를 HMAC 공유 비밀로 오인해 토큰 위조를 허용하는 결함이 있었다(원문에서 언급된 CVE-2022-23540, CVE-2024-54150 계열). 요즘 주요 라이브러리는 기본값이 정상화됐지만, 검증 시 algorithms 옵션을 명시하지 않으면 여전히 위험하다.

// 위험: 알고리즘 미지정
jwt.verify(token, secret);

// 안전: 허용 알고리즘 명시
jwt.verify(token, publicKey, { algorithms: ['RS256'] });

(3) 쿠키에 JWT 넣었는데 헤더 크기 초과

upstream sent too big header while reading response header from upstream
431 Request Header Fields Too Large

payload에 권한, 클레임을 잔뜩 넣다 보면 토큰이 비대해지고, Nginx 기본 헤더 버퍼 한도를 넘긴다. proxy_buffer_size 튜닝을 하게 되는데, 이쯤 되면 "그냥 세션 ID 한 줄 쓸걸"이라는 생각이 든다.

그럼 JWT는 언제 쓰나 — 올바른 적용

JWT를 버리라는 게 아니다. 맞는 자리에 쓰라는 거다. 원문과 댓글에서 합의되는 정당한 사용처:

  • 서비스 간 통신(M2M): 인증 서비스가 RS256으로 서명한 짧은 수명 토큰을 발급하면, 하위 서비스는 공개 키로 검증만 한다. 인증 DB에 접근할 필요가 없다. 하위 서비스가 털려도 인증 DB까지 노출되진 않는다.
  • SSO 전송 수단: 한 호스트의 세션을 다른 호스트로 넘길 때. 구글이 JWT를 쓰는 방식도 브라우저 세션이 아니라 이쪽이다.
  • OIDC ID 토큰: OIDC 토큰은 전부 JWT다. 다만 이건 "한 번 검증하고 자기 세션으로 교환"하는 용도지, 그 자체를 세션 쿠키처럼 매 요청 굴리는 게 아니다.

그리고 짧은 수명 서명 토큰이 정말 필요하면, 원문은 JWT 대신 PASETO를 권한다. 알고리즘 협상 같은 footgun을 설계 단계에서 제거한 사양이다. 단, PASETO도 세션 용도로 쓰지 말라는 건 동일하다.

4. 정리 — 한 줄 요약과 선택 기준

한 줄 요약: 브라우저 로그인 세션은 HttpOnly 쿠키 세션으로, JWT는 서비스 간 통신·SSO·OIDC 같은 짧은 수명 신원 전달에만.

  • 쿠키 세션을 써라: 일반적인 웹/모바일 백엔드, 강제 로그아웃·실시간 권한 회수가 필요한 모든 서비스. 즉 거의 대부분.
  • JWT를 써도 된다: MSA 내부 서비스 간 인증, SSO 토큰 전송, OIDC 연동. 단 RS256 + 짧은 수명 + audience 설정 + 검증 시 알고리즘 명시는 기본.
  • 절대 하지 마라: JWT를 localStorage에 저장, payload에 민감정보 적재, 검증 시 알고리즘 미지정, "무상태니까 무효화 안 해도 돼"라는 가정.

참고로 댓글 중엔 "잘 구현하면 JWT도 충분히 안전하다"는 반론도 강하게 나온다. 맞는 말이다. 다만 "올바르게 잡고 쓰면 안전하다"는 건 좋은 기본값이 아니라는 뜻이기도 하다. 평범한 세션 쿠키는 프레임워크 기본값만 따라가도 안전한데, JWT는 매번 footgun을 피해 다녀야 한다. 팀 규모와 보안 리소스가 구글급이 아니라면, 단순한 도구를 고르는 게 5년 굴려본 입장에서의 권고다.

전환 가이드 — 기존 JWT 세션을 쿠키 세션으로

한 번에 갈아엎지 말고 점진적으로 가는 게 안전하다. 대략 이런 순서다.

  1. 세션 스토어 준비: Redis나 RDB(Postgres/MySQL). 원문은 Express 기준 express-session + 적절한 store connector(예: connect-session-knex)를 권한다.
  2. 병행 인증 기간: 신규 로그인은 쿠키 세션 발급, 기존 JWT는 만료될 때까지 검증만 허용. 듀얼 검증 미들웨어를 둔다.
  3. 저장 위치 이전: 프론트의 localStorage 토큰 읽기 코드를 제거하고, 쿠키는 브라우저가 알아서 싣게 한다.
  4. CSRF 대비: 쿠키 인증으로 가면 CSRF가 새 과제가 된다. SameSite=Lax/Strict를 기본으로 깔고, 상태 변경 요청엔 CSRF 토큰을 추가한다. (원문도 CSRF는 별도 학습이 필요하다고 명시한다.)
// Express 기준 최소 세션 설정 예시
const session = require('express-session');

app.use(session({
  store: /* connect-session-knex 등 영속 스토어 */ ,
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,   // JS 접근 차단 (XSS 방어)
    secure: true,     // HTTPS 전용
    sameSite: 'lax',  // CSRF 1차 방어
    maxAge: 1000 * 60 * 60 * 24  // 24시간
  }
}));

// "모든 기기 로그아웃"이 필요하면 해당 유저 세션을 스토어에서 일괄 삭제하면 끝.

마지막으로 현실적인 조언. 이미 JWT로 잘 돌아가고 큰 보안 요구가 없는 서비스를 무리해서 갈아엎을 필요는 없다. 다만 신규 프로젝트에서 관성적으로 "일단 JWT"를 외치는 건 멈추자. "우리가 진짜 무상태가 필요한가? 강제 로그아웃 요구가 들어올까?"를 먼저 물어보면, 대부분의 답은 평범한 쿠키 세션이다.

참고 자료

728x90

도입: 200만 토큰이라는 숫자에 속지 마라

벤더들이 "컨텍스트 창 1M, 2M" 하고 광고하는 걸 보면, 이제 RAG 파이프라인에서 청킹 같은 거 신경 안 써도 되는 거 아닌가 싶은 생각이 든다. 그냥 문서 통째로 다 때려넣으면 알아서 잘 답해주겠지, 라고. 실무에서 한 번이라도 긴 세션을 굴려본 사람이라면 그게 착각이라는 걸 안다.

핵심은 간단하다. 광고되는 컨텍스트 창 크기 = 실제로 모델이 똑똑하게 쓸 수 있는 범위가 아니다. 원문(큰 컨텍스트 창을 신뢰하지 마라)에서 짚는 건 컨텍스트 창이 두 구간으로 나뉜다는 점이다. 모델이 예리하게 작동하는 "스마트 구간"과, 앞에서 한 지시를 슬슬 까먹기 시작하는 "둔한 구간". 그 경계가 대략 100k 토큰 부근이라는 얘기다.

이게 왜 지금 중요하냐면, 요즘 코딩 에이전트(Claude Code, Cursor 등)는 토큰을 미친 듯이 소모한다. 파일 몇 개 읽고, 긴 디버깅 세션 한 번 돌리고, 테스트 전체 한 번 실행하면 100k는 우습게 넘긴다. 즉 우리가 일상적으로 굴리는 작업이 자기도 모르게 "둔한 구간"에 진입한다는 거다.

핵심: 100k 임계점과 둔한 구간의 정체

스마트 구간 vs 둔한 구간

비유를 하나 들자면, 회의에 들어가서 처음 10분은 다들 집중해서 안건을 또렷이 기억한다. 그런데 두 시간짜리 회의가 끝나갈 무렵엔 "아 그래서 아까 그 결정이 뭐였죠?" 하는 사람이 나온다. LLM도 비슷하다. 컨텍스트 창 앞쪽 일부 청크는 또렷하게 처리하지만, 창을 채워나갈수록 주의력이 분산되면서 성능이 점진적으로 떨어진다.

원문에서 인용하는 RULER 벤치마크와 Chroma의 context rot 보고서가 이걸 데이터로 보여준다. 요지는 유효 컨텍스트(effective context)는 광고된 숫자의 일부에 불과하고, 창을 채울수록 성능이 떨어진다는 것. 단, 원문에서도 명시하듯 이 연구들은 2024~2025년 자료라 최신 Claude 모델에 그대로 적용된다고 보긴 어렵다. 모델 버전마다 어텐션 구조와 장기 컨텍스트 학습량이 다르기 때문이다. 그래서 "100k"라는 숫자도 절대값이라기보단 경험적 가이드라인으로 받아들이는 게 맞다.

왜 잊어버리는가 — Attention 관점

Transformer의 self-attention은 입력 토큰 전부에 대해 서로의 관련도를 계산한다. 토큰이 N개면 attention 계산은 대략 N² 스케일로 늘어난다. 문제는 토큰이 많아질수록 "지금 이 작업에 진짜 중요한 토큰"에 쏠려야 할 주의력이, 관련 없는 수많은 토큰들과 나눠 가지게 된다는 점이다.

원문 HN 댓글 중에 이걸 잘 짚은 게 있다. "컨텍스트 창 안에서 어떤 것이 자주 등장하면, 설령 틀린 것이어도 가중치가 생긴다." 즉 디버깅 세션에서 실패한 접근법이 컨텍스트에 계속 쌓이면, 모델이 그 실패한 접근을 자꾸 다시 시도하는 현상이 생긴다. 절대적인 컨텍스트 크기보다 창 안에 찌꺼기와 잘못된 방향 지시가 쌓여서 정작 중요한 내용을 덮어버리는 것이 더 큰 문제라는 지적이다. 이거 실무에서 정말 자주 겪는다.

실무 관점: 어디서 터지고, 어떻게 막는가

흔한 함정 1 — 코딩 에이전트가 둔한 구간에서 압축을 시작한다

Claude Code의 auto-compact가 대표적이다. 세션이 길어지면 기록을 요약하고 새로 시작하는 기능인데, 함정은 이미 둔한 구간에 들어간 다음에 작동한다는 점이다. 게다가 요약을 만드는 모델 자체가 이미 성능이 저하된 상태다. 멍청해진 모델이 만든 요약을 들고 다음 세션을 시작하니, 인계 품질이 떨어진다.

실제로 긴 세션에서 이런 메시지를 만나게 된다:

Context low (8% remaining) · Run /compact to compact & continue

이게 뜨기 전에 이미 작업 품질이 떨어지고 있었다는 게 핵심이다. 그리고 도구 호출이 실패하기 시작하면 이런 것도 본다(에이전트/하네스마다 메시지는 다르다):

Error: Input is too long for requested model.
prompt token count (215431) exceeds the maximum (200000)

이 에러를 만났다면 이미 한참 전부터 모델이 흐리멍덩해진 상태로 작업하고 있었다고 봐야 한다.

흔한 함정 2 — "메모리 시스템"이 오히려 모델을 멍청하게 만든다

원문 댓글에서 강하게 동의가 모인 지점이다. "모델에는 메모리가 있는 게 아니라 컨텍스트만 있다." 관련 없는 사실을 메모리랍시고 컨텍스트에 계속 밀어 넣으면, 정작 현재 문제에 쓸 컨텍스트가 줄어든다. 방해가 적을수록 결과가 좋아진다는 것.

한 댓글은 이런 웃픈 상황도 공유한다 — Opus가 메모리에 뭔가 계속 써놓긴 하는데, 정작 같은 실수를 반복하기 전에 그 메모리를 확인하는 걸 까먹는다. 그래서 "메모리 확인하라고 기억해!"가 또 메모리로 저장된다. 잘 작동하는 시스템이 아니라는 거다.

대응 전략 1 — written artifact로 정보를 세션 밖에 빼라

가장 실용적인 처방은 자동 요약에 기대지 말고, 사람이 직접 쓴 명세(spec)를 다음 세션에 인계하는 것이다. 직접 쓴 명세는 자동 요약보다 신호가 강하다. 무엇이 중요한지 사람이 직접 결정했기 때문이다.

예를 들어 작업 시작 전에 이런 식으로 PRD나 계획을 Markdown으로 남기고 저장소에 커밋한다:

# 작업 명세: 결제 모듈 리팩토링

## 목표
- PaymentService의 동기 호출을 비동기 큐 기반으로 전환

## 이미 시도했고 실패한 접근 (다시 하지 말 것)
- Redis Pub/Sub 직접 사용 → at-least-once 보장 안 됨
- 트랜잭션 내부에서 큐 발행 → 롤백 시 유령 메시지 발생

## 확정된 방향
- Transactional Outbox 패턴 사용
- 편집 대상: src/payment/outbox.ts, src/payment/PaymentService.ts

## 체크리스트
- [ ] Outbox 테이블 마이그레이션
- [ ] Outbox poller 구현
- [ ] 기존 동기 호출 제거

핵심은 "실패한 접근" 섹션이다. 이걸 명시해두면 새 세션에서 모델이 같은 실패를 반복하는 걸 막는다. 이게 원문에서 말하는 breadcrumb(빵부스러기) 접근이다 — 다음 세션이나 다음 사람이 깔끔하게 이어받을 산출물을 남기는 것.

대응 전략 2 — 메인 스레드는 조율만, 무거운 작업은 하위 에이전트로

원문 댓글 중 가장 인사이트 있던 기법이다. 사용자와의 최상위 대화 스레드에서는 도구 호출을 막고, 토큰을 잡아먹는 작업(파일 읽기, 데이터 수집, 테스트 실행)은 하위 에이전트의 재귀 호출 안에서만 돌린 뒤 요약된 결과만 메인 스레드로 반환하는 방식이다.

이렇게 하면 하위 에이전트가 5천만 토큰을 태워도 루트 대화 스레드는 10만 토큰도 안 건드릴 수 있다. 100만 LOC 코드베이스에서도 메인 대화는 스마트 구간에 계속 머무른다. 개념을 의사코드로 표현하면:

# 안티패턴: 모든 걸 평면 컨텍스트에 다 들고 다님
main_context = []
main_context += read_file("a.ts")      # +20k 토큰
main_context += read_file("b.ts")      # +30k 토큰
main_context += run_tests()            # +50k 토큰
# → 메인 스레드가 순식간에 둔한 구간으로

# 권장: 메인은 조율, 무거운 작업은 격리
def main_agent(task):
    # 도구 호출 금지. sub_agent 결과 요약만 받음
    summary = sub_agent(task="b.ts 분석 후 핵심만 보고")
    # summary는 2~3k 토큰. 메인은 스마트 구간 유지
    return decide_next_step(summary)

Claude Code에서는 이걸 부분적으로 자동으로 한다. "컨텍스트 많이 먹겠다" 싶은 휴리스틱이 작동하면 하위 에이전트(subagent)로 넘기는 식이다. 데이터 수집·집계를 하위 에이전트에 던지고 요약만 꺼내오는 패턴을 자주 볼 수 있다.

다만 트레이드오프도 있다. 원문 댓글에서 솔직하게 짚듯 재귀 깊이 1을 넘어서면 실제 성능 이득을 보기 어렵다. 외부의 기호적 재귀는 최전선 모델이 학습한 대상이 아닌 듯하다는 관찰이다. 그리고 하위 에이전트가 매번 "부트스트랩"하는 재작업 비용이 든다. 그래도 거대한 평면 컨텍스트를 들고 다니는 것보단 훨씬 효율적이라는 게 중론이다.

RAG 파이프라인이라면

RAG 운영자 입장에서 시사점은 분명하다. "컨텍스트 창 커졌으니 청킹/리랭킹 대충 해도 된다"는 유혹을 거부해야 한다. 검색해온 청크를 무작정 많이 넣을수록, 관련 없는 청크가 정작 중요한 청크의 주의력을 빼앗는다. top_k를 늘리는 게 항상 정답이 아니다. 리랭커로 정말 관련 높은 소수의 청크만 추리고, 컨텍스트를 예산처럼 다루는 게 맞다. 도움이 되는 부분은 앞쪽 일부 청크라고 가정하고 설계하는 편이 안전하다.

정리: 무엇을 봐야 하는가

한 줄 요약: 컨텍스트 창 크기는 마케팅 숫자에 가깝다. 실제로 봐야 할 건 "유효 컨텍스트를 스마트 구간 안에 유지하는 워크플로"다.

  • 코딩 에이전트를 굴리는 사람: 긴 세션을 무작정 이어가지 말고, 작업 단위마다 직접 쓴 명세(특히 "실패한 접근" 기록)를 남기고 새 세션으로 인계하라. auto-compact는 보험이지 전략이 아니다.
  • RAG 파이프라인 운영자: 컨텍스트 창이 커졌다고 청킹/리랭킹을 느슨하게 하지 마라. top_k는 신중하게. 관련 없는 청크는 노이즈다.
  • 에이전트 설계자: 메인 대화는 조율자로, 토큰 먹는 작업은 하위 에이전트로 격리하라. 단 재귀 깊이는 1로 제한하는 게 현실적이다.

마지막으로 균형을 위해 덧붙이면 — 원문 댓글에는 "최신 Opus(예: 4.8)에서는 50만 토큰을 편하게 넘긴 세션도 있었다"는 반론도 있다. 모델 버전, 하네스, 작업 종류에 따라 체감 임계점은 크게 다르다. 그러니 "100k"를 신앙처럼 외우지 말고, 본인 환경에서 어느 지점부터 회상 실수가 시작되는지 직접 로깅해서 감을 잡는 것이 가장 확실하다. 결국 컨텍스트 엔지니어링은 아직 다들 숙련도만 다른 땜장이 단계라는 자조 섞인 댓글이, 어쩌면 가장 정직한 현실 인식일지도 모른다.

참고 자료

728x90

1. 왜 지금 다시 이메일 인증인가

몇 년 전까지만 해도 SPF/DKIM/DMARC는 "메일 잘 가게 하려면 박아두는 DNS 레코드" 정도였다. 한 번 설정하고 잊는 영역. 그런데 2024년 초 Google과 Yahoo가 대량 발송자(하루 5천 통 이상)에게 DMARC 구성을 사실상 의무화하면서 상황이 바뀌었다. 이제 인증은 "안 하면 메일이 안 가는" 전제 조건이 됐다. 원문(Fastmail)에서는 이걸 웹의 HTTPS가 걸어온 길에 비유한다. 모범 사례 → 기대사항 → 인프라. 자물쇠 없는 사이트가 경고로 취급되듯, 인증 없는 메일도 점점 그렇게 돼간다.

여기에 한 겹 더 얹힌 게 AI다. 원문이 짚는 핵심은 이거다. 예전엔 사람이 의심스러운 메일을 직접 읽으며 "도메인에 글자 하나 더 붙었네", "긴급하다는 말투가 어색하네" 같은 신호를 잡아냈다. 그런데 받은편지함을 요약하고, 할 일을 뽑아내고, 답장 초안을 쓰고, 경우에 따라 사용자를 대신해 행동까지 하는 AI 어시스턴트가 표준 기능이 되면 그 휴리스틱이 사라진다. AI는 내용과 긴급성을 읽고 그대로 실행해버린다. 그래서 설득력 있는 스푸핑 메일은 받은편지함에 도달하기 전에 인증으로 막아야 한다는 게 글의 논지다.

솔직히 말하면 원문은 Fastmail의 포지셔닝성 글이라 "그래서 뭘 발표한다는 거냐"는 댓글 반응이 많았다. 나도 동의한다. 다만 인프라 엔지니어 입장에서 보면 인증 체계 자체는 여전히 우리가 직접 설정하고 운영하고 트러블슈팅해야 하는 실무 영역이고, AI 필터가 인증 결과를 핵심 입력값으로 쓰기 시작했다는 맥락은 충분히 시의성이 있다. 그 지점만 떼어내서 실무 관점으로 풀어보자.

2. SPF·DKIM·DMARC 동작 원리 — 세 개가 어떻게 맞물리나

이 셋은 따로 노는 표준이 아니라 체인으로 물려 있다. 비유하자면 이렇다.

  • SPF: "이 봉투를 부친 우체국이 우리 회사 지정 우체국이 맞나?" — 발송 서버의 권한 검증
  • DKIM: "봉투 안 편지에 우리 회사 봉인 도장이 찍혀 있고, 운송 중에 뜯긴 흔적이 없나?" — 메시지의 무결성 + 서명 검증
  • DMARC: "SPF랑 DKIM 둘 중 하나라도 통과했고, 그게 From 도메인이랑 일치하나? 실패하면 어떻게 처리할까?" — 정책 결합 + 실패 시 처리 방침

SPF — 어느 서버가 보낼 권한이 있는가

도메인의 TXT 레코드에 발송 권한이 있는 서버 목록을 적어둔다. 수신 서버는 메일을 받을 때 실제 접속해온 IP가 이 목록에 있는지 본다.

$ dig +short TXT example.com
"v=spf1 include:_spf.google.com include:sendgrid.net ~all"

여기서 ~all(SoftFail)이냐 -all(HardFail)이냐가 운영 정책의 핵심이다. ~all은 "목록에 없는 서버에서 와도 일단 통과는 시키되 의심해", -all은 "목록에 없으면 거부해". 처음 도입할 땐 ~all로 시작하는 게 안전하다.

SPF의 가장 큰 함정은 DNS 조회 10회 제한이다. include가 중첩되면서 조회 횟수가 10을 넘으면 SPF 평가 자체가 permerror로 떨어진다. SaaS 메일 서비스 서너 개만 물려도 금방 초과한다. 이건 뒤 함정 섹션에서 에러까지 같이 본다.

DKIM — 메시지에 서명을 붙인다

발송 서버가 개인키로 메일 헤더+본문 일부에 서명을 만들어 DKIM-Signature 헤더에 넣는다. 공개키는 DNS에 올려둔다. 수신 서버는 그 공개키로 서명을 검증해서 전송 중 변조가 없었는지 확인한다.

$ dig +short TXT selector1._domainkey.example.com
"v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC..."

여기서 selector1 부분이 셀렉터(selector)다. 한 도메인이 여러 발송 서비스를 쓸 때 셀렉터로 키를 구분한다. Google은 google, SendGrid는 s1/s2 같은 식으로 자기들 셀렉터를 쓴다. 메일 원본 헤더의 DKIM-Signature에서 s= 값을 보면 어떤 셀렉터를 조회해야 하는지 나온다.

DMARC — 둘을 묶고, 실패 시 행동을 지시한다

DMARC가 추가하는 핵심 개념이 alignment(정렬)다. 단순히 SPF/DKIM이 통과했다고 끝이 아니라, 그 통과한 도메인이 사용자에게 보이는 From: 도메인과 일치해야 한다. 이게 스푸핑 방어의 진짜 핵심이다.

$ dig +short TXT _dmarc.example.com
"v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com; adkim=s; aspf=r; pct=100"

읽어보면 이렇다.

  • p=quarantine: 실패한 메일은 격리(스팸함). 정책은 none(관찰만) → quarantinereject 순으로 강화한다.
  • rua=mailto:: 집계 리포트(aggregate report)를 받을 주소. 이게 4번 섹션의 핵심이다.
  • adkim=s: DKIM 정렬을 strict로. r(relaxed)이면 서브도메인까지 허용한다.
  • pct=100: 정책을 적용할 메일 비율. 롤아웃 초기엔 pct=10처럼 낮춰서 점진 적용한다.

3. 실무 관점 — 트레이드오프와 흔한 함정

흔한 함정 1: SPF DNS 조회 10회 초과 (permerror)

실무에서 제일 자주 만나는 사고다. 마케팅팀이 새 메일 SaaS를 붙여달라고 해서 include를 하나 더 추가했더니, 어느 날부터 갑자기 정상 메일까지 SPF가 깨지기 시작한다. 원인은 조회 횟수 초과. DMARC 리포트나 메일 헤더의 Authentication-Results에 이런 식으로 찍힌다.

Authentication-Results: mx.google.com;
       spf=permerror (google.com: permanent error in processing during
       lookup of user@example.com: DNS error) smtp.mailfrom=example.com

permerror는 "SPF 레코드 자체가 RFC 위반 상태라 평가 불가"라는 뜻이다. ~all이든 -all이든 평가가 무의미해진다. 해결은 (1) 안 쓰는 include 정리, (2) SPF flattening(include를 실제 IP 대역으로 펼쳐 조회 횟수를 줄임) 정도다. flattening은 SaaS 쪽 IP가 바뀌면 깨지므로 자동 갱신 도구 없이 손으로 하면 나중에 폭탄이 된다. 트레이드오프가 분명하니 도입 전 팀 내 합의가 필요하다.

흔한 함정 2: 포워딩이 SPF를 깨뜨린다

메일링 리스트나 .forward로 메일을 전달하면 발송 IP가 중간 서버로 바뀐다. 그러면 원래 도메인의 SPF에는 그 중간 서버 IP가 없으니 SPF가 실패한다. 이래서 DMARC는 SPF 또는 DKIM 둘 중 하나만 정렬+통과해도 PASS로 친다. DKIM 서명은 본문이 안 바뀌면 포워딩돼도 살아남기 때문이다. 그런데 메일링 리스트가 본문에 footer를 붙이거나 제목에 [list]를 끼워넣으면 DKIM 서명까지 깨진다. 원문이 언급한 ARC(Authenticated Received Chain) 실험이 바로 이 "복잡한 전달 경로에서 인증을 어떻게 이어붙일까" 문제를 다루려던 거다. 다만 원문 댓글에서도 ARC가 완전한 해법은 아니라는 지적이 나온다.

흔한 함정 3: DMARC를 처음부터 p=reject로 박는다

의욕 넘쳐서 처음부터 p=reject로 가면 거의 100% 사고난다. 사내에 미처 파악 못 한 발송 경로(레거시 배치 서버, 사내 모니터링 알림, 협력사가 대신 보내는 메일)가 한두 개는 꼭 있고, 그게 다 거부당해서 "왜 우리 알림 메일이 안 와요" 티켓이 폭주한다. 반드시 p=none으로 시작해 리포트로 발송원을 다 파악한 뒤 단계적으로 올려야 한다.

대안/한계 — 인증은 신원이지 의도가 아니다

원문이 솔직하게 인정하는 한계이자, 댓글에서 제일 날카롭게 파고든 지점이다. SPF/DKIM/DMARC는 "이 메일이 진짜 그 도메인에서 왔는가"는 검증하지만 "그 내용이 사기인가"는 검증하지 못한다. 댓글에 나온 실제 공격 시나리오가 인상적이다. 공격자가 PayPal·Stripe 같은 진짜 결제 플랫폼이 메일을 발송하게 만든 뒤, 회사명 필드에 "문제 발생, 이 번호로 전화하세요"를 넣는다. 그러면 진짜 PayPal에서 나가는, 모든 인증을 통과하는 정상 메일에 그 문구가 박힌다. DMARC로는 절대 못 잡는다. 인증은 사칭의 비용과 복잡성을 높일 뿐, 만능이 아니라는 걸 분명히 알고 가야 한다.

4. DMARC 리포트로 위협 모니터링하기

rua로 받는 집계 리포트는 압축된 XML이다. 메일 수신자(Google, Microsoft 등)가 "당신 도메인을 From으로 쓴 메일을 이런 IP에서 이만큼 봤고 인증 결과는 이랬다"를 하루 단위로 보내준다. 사람이 XML 원본을 읽긴 힘들어서 보통 파서나 SaaS(예: dmarcian, Postmark 무료 분석 등)를 쓴다. 구조는 대략 이렇게 생겼다.

<record>
  <row>
    <source_ip>203.0.113.55</source_ip>
    <count>128</count>
    <policy_evaluated>
      <disposition>none</disposition>
      <dkim>fail</dkim>
      <spf>fail</spf>
    </policy_evaluated>
  </row>
  <identifiers>
    <header_from>example.com</header_from>
  </identifiers>
</record>

이 record를 보면 모르는 IP 203.0.113.55가 우리 도메인을 From으로 128통이나 보냈는데 SPF/DKIM 둘 다 fail이다. 두 가지 가능성이다. (1) 우리가 깜빡한 정상 발송 경로 → SPF/DKIM에 추가해줘야 한다. (2) 진짜 스푸핑 시도 → 정책을 강화하면 이 IP의 메일은 막힌다. 리포트를 안 보면 이 구분이 안 된다. 그래서 p=none 관찰 기간이 중요한 거다.

로컬에서 빠르게 까보고 싶으면 받은 첨부(gzip)를 이렇게 풀어볼 수 있다.

$ gunzip -c example.com\!google.com\!1700000000.xml.gz | \
    grep -E "source_ip|dkim|spf|disposition"
      <source_ip>209.85.220.41</source_ip>
      <dkim>pass</dkim>
      <spf>pass</spf>
      <disposition>none</disposition>

이런 식으로 IP별 pass/fail만 빠르게 훑고, 의심스러운 IP는 따로 조사하면 된다. 양이 많아지면 결국 파서를 붙이게 되지만, 처음 감 잡을 땐 이걸로 충분하다.

5. AI 필터·어시스턴트 환경에서 인증 실패가 미치는 영향

원문의 논점을 인프라 관점으로 다시 정리하면 이렇다. 과거에 인증 실패는 "스팸함에 들어갈 확률이 좀 올라간다" 정도의 약한 신호였다. 그런데 두 가지가 동시에 바뀌고 있다.

  1. AI 필터가 인증 결과를 점점 더 핵심 입력값으로 쓴다. 인증이 깨진 도메인은 발송원 평판과 묶여 더 강하게 페널티를 받는다. 즉, 우리 회사 메일의 DKIM 셀렉터 키를 실수로 안 올렸다거나 SPF가 permerror면, 단순히 "일부 차단"이 아니라 도메인 평판 자체가 깎이는 방향으로 작동할 수 있다.
  2. AI 어시스턴트는 인증을 통과한 메일의 내용을 그대로 신뢰하고 행동(요약, 일정 등록, 답장 초안)한다. 사람이라면 걸렀을 미묘한 사칭을 그대로 실행할 위험이 있다.

결국 인프라 담당자 입장에서 시사점은 명확하다. 인증은 "메일 잘 가게 하는 옵션"이 아니라 "우리 도메인 평판을 지키는 기본 위생"이 됐다. 그리고 원문이 언급한 BIMI(검증된 발신자가 받은편지함에 자기 로고를 표시하는 표준)는 DMARC를 p=quarantine 이상으로 강화한 도메인만 쓸 수 있다. AI 생성 피싱이 내용만으로 구분하기 어려워진 시점에, 시각적 신뢰 신호를 얻으려면 인증 체계가 단단해야 한다는 뜻이다. 다만 BIMI는 VMC(검증된 마크 인증서) 비용 등 도입 장벽이 있어서, 조직 규모와 필요에 따라 판단할 영역이다(공식 요구사항은 각 메일 사업자 문서 확인 필요).

6. 정리 — 누가 언제 어떻게

한 줄 요약: SPF/DKIM/DMARC는 이제 선택이 아니라 도메인 평판과 메일 도달성의 전제 조건이고, AI가 메일을 자율 처리할수록 "내용은 못 막아도 발송원 사칭의 비용은 올린다"는 가치가 커진다. 단, 인증은 신원 검증이지 의도 검증이 아니라는 한계를 알고 써야 한다.

프로덕션 점진 롤아웃 체크리스트:

  1. 현재 발송 경로 전수 조사 (사내 배치, 알림 서버, 협력사 대행 발송까지)
  2. SPF 레코드 정리 — include 조회 횟수 10회 이내 확인, 처음엔 ~all
  3. 모든 발송 서비스에 DKIM 셀렉터 설정 + DNS 공개키 확인
  4. DMARC를 p=none으로 배포하고 rua 리포트 수집 시작
  5. 1~2주 리포트 분석 → 누락된 정상 발송원을 SPF/DKIM에 추가
  6. pct를 낮춰가며 p=quarantinep=reject로 단계 강화
  7. 필요 시 BIMI 검토 (DMARC 강화 정책 선행 필요)

이건 누구에게나 필요하다. 대량 발송하는 서비스라면 Google/Yahoo 정책 때문에 이미 필수다. 사내 전용 도메인이라도 우리 도메인을 사칭한 피싱이 협력사·고객에게 날아가는 걸 막으려면 최소 p=quarantine은 가는 게 맞다. 다만 처음부터 p=reject로 박지 말고, 리포트로 발송원을 다 파악한 뒤 단계적으로. 그 인내심이 티켓 폭주와 평온한 금요일을 가른다.

참고 자료

728x90

+ Recent posts