1. 도입: 왜 지금 이게 화제인가

요 몇 년 사이에 "남이 짠(혹은 AI가 짠) 코드를 내 인프라에서 안전하게 실행해줘야 하는" 서비스가 부쩍 늘었다. AI 코딩 어시스턴트, 온라인 IDE, 데이터 분석 노트북 플랫폼, 취약점 스캐너, 유저 스크립트를 받는 게임 서버까지. 공통점은 하나다. 각 사용자/세션마다 격리된 실행 환경을 따로 쥐어줘야 한다는 것.

여기서 실무자가 항상 부딪히는 삼각딜레마가 있다.

  • VM(EC2 등): 격리는 확실한데 부팅에 분 단위가 걸린다. 유저가 "Run" 누르고 1분 기다리게 할 수는 없다.
  • 컨테이너: 빠르게 뜨지만 커널을 공유한다. 신뢰할 수 없는 코드를 돌리려면 seccomp, gVisor, 네임스페이스 하드닝을 직접 다 발라야 한다. 이거 제대로 하려면 보안 전담 엔지니어 한 명이 통째로 붙는다.
  • 일반 Lambda 함수: 이벤트-요청/응답 모델에 최적화돼 있어서, 세션 동안 메모리·디스크 상태를 유지해야 하는 장시간 인터랙티브 워크로드엔 맞지 않는다.

그래서 지금까지는 다들 Firecracker 위에 자체 오케스트레이션을 직접 짜서 굴렸다. 스타트업이 이걸 직접 만들면 제품 만들 시간을 인프라 빌딩에 다 갈아넣게 된다. 이번에 나온 AWS Lambda MicroVMs가 정확히 이 틈을 메우는 물건이다. Lambda 함수가 이미 매달 15조 회 이상의 호출을 굴려온 그 Firecracker를 그대로 가져다, "이미지 만들고 → 띄우고 → 상태 유지하고 → 쉬면 멈추는" 라이프사이클을 API로 노출했다.

2. 핵심: 동작 원리를 예시로

image-then-launch 모델

일반 Lambda는 "코드 zip 올리면 끝"이지만, MicroVM은 두 단계다.

  1. MicroVM Image 생성: Dockerfile + 코드 zip을 S3에 올리면, Lambda가 Dockerfile을 빌드하고 애플리케이션을 실제로 한 번 띄운 다음, 그 시점의 메모리·디스크 상태를 Firecracker 스냅샷으로 떠둔다.
  2. MicroVM 실행(launch): 그 이미지에서 인스턴스를 띄울 때, 콜드 부팅을 하는 게 아니라 미리 초기화된 스냅샷에서 resume한다. 그래서 띄우는 순간 이미 앱이 떠 있는 상태로 시작한다.

비유하자면, 일반 컨테이너가 매번 "전원 켜고 OS 부팅하고 앱 띄우기"라면, MicroVM은 "노트북 덮개 닫았다가 다시 여는" 식이다. 절전(suspend)됐다가 깨어날 때 열어둔 탭이 그대로 살아있는 그 경험을 인프라 단에 적용했다고 보면 된다.

격리 원리: 커널을 공유하지 않는다

핵심은 여기다. 컨테이너는 호스트 커널 하나를 여러 컨테이너가 나눠 쓴다. 그래서 커널 취약점 하나 터지면 컨테이너 탈출(escape) 시나리오가 현실이 된다. 반면 MicroVM은 세션마다 독립된 게스트 커널과 가상화된 하드웨어를 가진다. 하이퍼바이저(KVM) 경계로 격리되기 때문에, A 유저가 던진 악성 코드가 B 유저 환경이나 호스트로 넘어가는 경로가 원천적으로 막힌다. 이게 "VM-level isolation"의 의미다.

실제 만들어보기

원문 예제 기준으로 Flask 앱을 MicroVM으로 만드는 흐름이다. 먼저 Dockerfile은 MicroVM 전용 베이스 이미지를 쓴다는 점이 포인트다.

# Dockerfile
FROM public.ecr.aws/lambda/microvms:al2023-minimal
RUN dnf install -y python3 python3-pip && dnf clean all
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 5000
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"]

이미지 생성 명령. 코드 zip은 S3에 올려두고 URI로 참조한다.

aws lambda-microvms create-microvm-image \
  --code-artifact uri=s3://my-bucket/path/to/artifact.zip \
  --name flask-sandbox \
  --base-image-arn arn:aws:lambda:us-east-1:aws:microvm-image:al2023-1 \
  --build-role-arn arn:aws:iam::123456789012:role/MicroVMBuildRole

빌드 로그는 CloudWatch /aws/lambda/microvms/<image-name>로 실시간 스트리밍된다. 빌드가 끝나면 ARN과 버전이 콘솔에 뜬다. 빌드 완료 후 실행:

aws lambda-microvms run-microvm \
  --image-identifier arn:aws:lambda:us-east-1:123456789012:microvm-image:flask-sandbox \
  --execution-role-arn arn:aws:iam::123456789012:role/MicroVMExecutionRole \
  --idle-policy '{"maxIdleDurationSeconds":900,"suspendedDurationSeconds":300,"autoResumeEnabled":true}'

idle-policy는 "15분(900초) 동안 요청 없으면 자동 suspend, 그 뒤 5분(300초) suspend 상태 유지, 요청 들어오면 자동 resume"이라는 뜻이다. 별도 네트워킹 세팅 없이 Lambda가 고유 ID와 전용 엔드포인트 URL을 돌려준다. 응답은 대략 이런 형태로 보인다(필드명은 공식 문서 확인 필요):

{
    "microVmId": "mvm-0a1b2c3d4e5f67890",
    "endpointUrl": "https://mvm-0a1b2c3d4e5f67890.lambda-microvms.us-east-1.amazonaws.com",
    "state": "RUNNING"
}

트래픽을 보낼 땐 단명(short-lived) 인증 토큰을 만들어 X-aws-proxy-auth 헤더에 붙인 평범한 HTTPS 요청을 던진다.

curl https://mvm-0a1b2c3d4e5f67890.lambda-microvms.us-east-1.amazonaws.com/ \
  -H "X-aws-proxy-auth: <short-lived-token>"

# 응답
{"message":"Hello, World!"}

여기서 인상적인 건, idle 임계치를 넘겨 suspend된 뒤 다시 요청을 보내도 애플리케이션 상태가 그대로 살아서 resume된다는 점이다. 클라이언트 입장에선 멈췄던 적이 없는 것처럼 보인다. 설치한 패키지, 로딩한 모델, 작업 중이던 파일이 다 그 자리에 있다.

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

스냅샷 모델이 만드는 함정 — 초기화 코드를 의심하라

가장 먼저 발 헛디딜 곳이 여기다. MicroVM은 "한 번 초기화한 상태"를 스냅샷으로 떠서 모든 인스턴스가 그걸 복제한다. 즉 이미지 빌드 시점에 단 한 번 실행된 코드의 결과물이 모든 세션에 그대로 복사된다.

이게 왜 문제냐. 초기화 단계에서 다음을 하는 코드는 위험하다.

  • 고유값 생성: 부팅 시 UUID, 랜덤 시드, 세션 키를 한 번 만들어 캐싱하는 코드 → 모든 MicroVM이 동일한 값을 갖게 된다. 난수 생성기 상태까지 복제되므로 보안적으로 치명적일 수 있다.
  • 네트워크 커넥션 선점: 초기화 때 DB 커넥션 풀이나 keep-alive 소켓을 열어두면, 스냅샷에 박제된 끊긴 커넥션을 resume 후 그대로 들고 있게 된다.
  • 초기화 시점 외부 데이터 로딩: 빌드 시점 환경에서 받아온 ephemeral 데이터가 그대로 굳는다.

원문도 이 부분을 명시한다 — "고유 콘텐츠 생성, 네트워크 연결 수립, ephemeral 데이터 로딩을 하는 앱은 서비스가 제공하는 hook과 연동해야 호환된다"고. 일반 Lambda의 SnapStart를 써본 사람이라면 익숙한 패턴이다. 네트워크 연결과 난수 시드 같은 건 스냅샷 이후, 즉 resume 시점에 다시 초기화하도록 코드를 짜야 한다. 이걸 모르고 그냥 컨테이너 코드 들고 오면 이런 식의 증상을 만난다:

# resume 후 박제된 죽은 DB 커넥션을 그대로 사용하다 터지는 전형적 에러
psycopg2.OperationalError: server closed the connection unexpectedly
	This probably means the server terminated abnormally
	before or while processing the request.

# 혹은 redis 클라이언트의 경우
redis.exceptions.ConnectionError: Error 104 while writing to socket. Connection reset by peer.

해결은 lazy connection(첫 요청 때 연결)이나 resume hook에서 풀 재생성. 일반 Lambda의 afterRestore 훅과 비슷한 메커니즘이 제공될 것으로 보이나, 정확한 API는 공식 Developer Guide 확인이 필요하다.

베이스 이미지 잘못 쓰면 빌드부터 막힌다

MicroVM은 일반 Lambda 베이스 이미지가 아니라 public.ecr.aws/lambda/microvms:... 계열을 써야 한다. 기존 Lambda 컨테이너 이미지(public.ecr.aws/lambda/python 등)를 그대로 가져오면 빌드/실행 단계에서 막힌다. 빌드 IAM 역할 권한이 부족하면 보통 이런 식으로 떨어진다:

An error occurred (AccessDeniedException) when calling the CreateMicrovmImage operation:
User: arn:aws:iam::123456789012:role/MicroVMBuildRole is not authorized to perform:
s3:GetObject on resource: arn:aws:s3:::my-bucket/path/to/artifact.zip

build-role과 execution-role을 헷갈려서 권한을 한쪽에만 몰아주는 실수가 잦다. 빌드 역할은 S3 아티팩트 읽기 + CloudWatch 로그 쓰기, 실행 역할은 런타임에 필요한 권한으로 나눠 생각하는 게 깔끔하다.

리소스·리전·아키텍처 제약

  • 아키텍처: ARM64만 지원한다. x86 전용 바이너리(특정 ML 라이브러리 등) 쓰던 팀은 빌드 단계에서 멀티아키 대응이 필요하다. 이거 의외로 마이그레이션 발목 자주 잡는다.
  • 스펙 상한: MicroVM당 최대 16 vCPU / 32GB 메모리 / 32GB 디스크. 무거운 LLM을 풀로 메모리에 올리기엔 32GB가 빠듯할 수 있다.
  • 총 런타임: 최대 8시간. 이걸 넘기는 장시간 세션(종일 켜두는 워크스테이션 같은)에는 부적합. 세션 종료/재시작 설계를 미리 해둬야 한다.
  • 리전: 출시 시점 기준 버지니아 북부, 오하이오, 오레곤, 아일랜드, 도쿄. 서울 리전은 아직 없다. 한국 서비스라면 도쿄로 붙이거나 정식 출시(GA)/서울 확장을 기다려야 한다. 지연 시간 민감한 인터랙티브 워크로드면 이게 실질적 결정 요인이 된다.

비용 관점

핵심 비용 절감 포인트는 suspend다. idle 상태로 멈춰두면 running 비용이 낮은 idle 비용으로 떨어지고 상태는 보존된다. 즉 "유저가 잠깐 자리 비운 동안 풀로 과금"되는 걸 피할 수 있다. 다만 suspend 상태에서도 스냅샷 저장 비용은 발생할 것으로 보이며, 구체 단가는 Lambda 요금 페이지 확인이 필요하다. 인터랙티브 세션이 많고 idle 비율이 높은 서비스일수록 이득이 크다.

그래서 언제 일반 Lambda를 쓰고 언제 MicroVM을 쓰나

  • 이벤트 기반, 짧은 요청/응답, 무상태 → 그냥 일반 Lambda 함수. MicroVM은 오버킬이고 더 비싸다.
  • 신뢰 못 할 코드 실행 + 세션 상태 유지 + 빠른 시작MicroVM.
  • 장시간(8시간 초과) 상시 가동, 풀 커스텀 OS 제어, GPU → 여전히 EC2 혹은 ECS/EKS on EC2.
  • 빠른 시작이 필요하지만 신뢰된 자사 코드만 돌림컨테이너(ECS/Fargate)로 충분한 경우가 많다.

원문 표현대로 둘은 경쟁이 아니라 보완 관계다. 이벤트 백본은 Lambda 함수로 두고, 신뢰 못 할 코드를 격리 실행해야 하는 구간만 MicroVM을 호출하는 조합이 정석으로 보인다.

4. 정리

한 줄 요약: MicroVM은 "VM 수준 격리 + 컨테이너 수준 시작 속도 + 세션 상태 보존"을 직접 빌드하지 않고 API로 얻는 서버리스 프리미티브다.

누가 써야 하나 — 멀티테넌트 SaaS에서 유저/AI가 생성한 코드를 안전하게 돌려야 하는 팀. AI 코딩 어시스턴트, 온라인 IDE, 데이터 분석 플랫폼, 취약점 스캐너가 1순위 후보다. 지금까지 Firecracker 위에 자체 샌드박스 오케스트레이션을 직접 짜서 굴리던 팀이라면, 그 유지보수 부담을 통째로 덜 수 있다는 게 가장 큰 가치다.

반대로, 무상태 이벤트 처리만 하거나 8시간 넘는 상시 워크로드, 혹은 서울 리전 저지연이 필수인 서비스라면 아직은 보류하거나 다른 선택지를 봐야 한다. 스냅샷 기반 초기화의 함정(난수·커넥션 박제)은 도입 전에 반드시 코드 리뷰로 걸러야 할 부분이다.

참고 자료

※ 본문의 응답 JSON 필드명, idle-policy 세부 스펙, resume hook API 등은 출시 직후 시점 기준 추정이 포함돼 있습니다. 실제 도입 전 Lambda MicroVMs Developer Guide의 최신 내용을 반드시 확인하세요.

728x90

왜 지금 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

+ Recent posts