728x90

지난주에 사내 코드 리뷰 자동화 PoC 돌리다가 또 한 번 깨달은 게 있다. 클로드든 GPT든 외부 API로 보내는 순간, 보안팀에서 "그 코드 어디로 나갑니까"라는 질문이 날아온다는 거다. 사내 레포지토리 코드를 그대로 미국 회사 서버로 토큰화해서 던지는 걸, 규제 산업에선 절대 통과 못 시킨다. 그래서 로컬 LLM을 계속 만지작거리는데, 솔직히 그동안은 다 실망스러웠다. 7B, 13B 모델들은 "데모용"이지 실무용이 아니었으니까.

그런데 Quesma 블로그의 Piotr Migdał이 쓴 "Qwen 3.6 27B is the sweet spot for local development" 글이 HN 프론트에 올라온 걸 보고, 이건 좀 진지하게 봐야겠다 싶었다. 정리해본다.

왜 지금 로컬 LLM인가 — 비용·보안·레이턴시 트레이드오프

로컬 LLM을 고민하게 되는 시점은 보통 셋 중 하나다.

  • 비용: 원문에서도 지적하듯, 지금 프론티어 모델들은 "massive subsidy(대규모 보조금)" 위에서 돌아간다. 월 100달러 내면 토큰 가치로는 수천 달러어치를 쓴다. 이 할인이 영원할 거라 가정하고 파이프라인을 설계하면, 가격 정책 바뀌는 날 CI 비용이 폭발한다. CI에서 PR마다 LLM 코드 리뷰를 돌리면 토큰 소모가 생각보다 빠르게 누적된다.
  • 보안: 사내 코드, 의료 데이터, 미공개 데이터를 외부로 안 보내야 하는 경우. 원문 저자도 "미국이나 중국과 내 깊은 비밀, 의료 데이터를 공유하는 게 불편할 때"라고 직설적으로 쓴다.
  • 레이턴시·오프라인: 네트워크 왕복 없이 로컬에서 즉시 응답. 오프라인 환경, 폐쇄망 개발 환경.

핵심은, 로컬 모델은 "뺏기지 않는다"는 점이다. 원문에서 언급한 Claude Fable 5가 내려간 것처럼, 의존하던 모델이 어느 날 사라지면 워크플로우 전체가 망가진다. 로컬 가중치는 한번 받아두면 내 디스크에 남는다.

Qwen 3.6 27B 아키텍처 해부 — Dense가 MoE보다 나은 상황

Qwen 3.6은 두 가지 변형으로 나온다. 이게 선택의 핵심이다.

  • Qwen 3.6 35B A3B — Mixture-of-Experts(MoE). 전체 35B 파라미터지만 추론 시엔 일부 전문가(A3B = active 3B 추정)만 활성화. 그래서 빠르다.
  • Qwen 3.6 27B — Dense 모델. 모든 파라미터를 매번 쓴다. 느리지만 더 강력하다. 원문 저자가 추천하는 쪽이다.

비유하자면 MoE는 "회의에 필요한 전문가만 호출하는 컨설팅 회사"고, Dense는 "전원이 항상 모든 회의에 들어오는 작은 정예팀"이다. 전자는 빠르고 효율적이지만, 가끔 엉뚱한 전문가가 호출되거나 지시를 놓친다. 후자는 느리지만 일관되게 깊다.

원문의 실제 일화가 이 차이를 잘 보여준다. OpenCode에서 "pnpm으로 헥사고날 지뢰찾기 만들어줘"라고 시켰을 때:

  • 27B (Dense): 단일 프롬프트로 한 번에 성공. 제대로 된 Node 패키지로 만들어냈다.
  • 35B A3B (MoE): 더 빨랐지만, "패키지로 만들라"는 지시를 무시하고 그냥 단일 index.html에 박아넣었다.

여기서 실무 교훈. 제약 조건 준수(instruction following)가 중요한 태스크일수록 Dense가 유리하다. CI에서 "이 컨벤션 지켜서 리팩토링해", "이 디렉토리 구조로 생성해" 같은 까다로운 지시를 던질 거면, 속도를 좀 포기하더라도 27B가 안전하다. 반면 빠른 초안 생성, 채팅형 보조에는 35B A3B가 낫다.

벤치마크로 보면 (원문의 Artificial Analysis 인용):

Gemma 4 31B        : 29  (≈ 2024 말, o1 / Claude 3.5 Sonnet 급)
Qwen3.6-35B-A3B    : 32  (≈ 2025 초, o3 / Claude 4 Sonnet 급)
Qwen3.6-27B        : 37  (≈ 2025 중, GPT-5 / Claude Sonnet 4.5 급)
DeepSeek-V4-Flash  : 40  (≈ 2025 말, GPT-5.2 / Claude Opus 4.5 급)

주의: 이 점수와 "≈ 급" 비교는 원문 기준이며, 미래 시점 모델명이 섞여 있다(원문 작성일이 2026년). 그대로 인용한 거지 내가 검증한 수치는 아니다. 다만 27B가 같은 로컬 디폴트로 많이 쓰이는 Gemma 4 31B를 벤치마크·여론 양쪽에서 큰 차이로 앞선다는 게 원문의 주장이다.

하드웨어 요구사항과 추론 런타임 — 실제로 돌려보기

원문 저자는 llama.cpp를 추천한다. Ollama는 윤리적 이유로 권하지 않는다고 명시했는데, 이 부분은 저자 개인 입장이니 각자 판단하면 된다. 다만 기술적으로 llama.cpp는 직접적이고 오픈소스이며, Apple Silicon 전용인 mlx-lm보다도 더 빨랐다는 게 원문 측정 결과다.

먼저 모델 받아서 서버 띄우기. 8-bit 양자화에 멀티토큰 예측(MTP) 지원 버전을 쓴다:

llama-server -hf unsloth/Qwen3.6-27B-MTP-GGUF:Q8_0 \
  --spec-type draft-mtp -ngl 999 -fa on -c 65536 --port 8080

각 옵션 의미:

  • -hf unsloth/Qwen3.6-27B-MTP-GGUF:Q8_0 — Hugging Face에서 8-bit 양자화 모델을 받는다. 다음 실행부터는 캐시 재사용.
  • --spec-type draft-mtp — 빠른 보조 모델로 다음 토큰을 미리 예측(speculative decoding). 속도 향상.
  • -ngl 999 — 모든 레이어를 GPU에 올린다.
  • -fa on — flash attention 켜기.
  • -c 65536 — 컨텍스트 64k 토큰. Qwen 3.6 27B의 네이티브 컨텍스트는 256k라 더 늘릴 수도 있다.
  • --port 8080 — 포트 고정. 다른 설정에서 재사용하니까.

서버 뜨면 http://127.0.0.1:8080에서 바로 채팅 가능하다. 성능 측정치(MacBook M5 Max 128GB 기준, 원문):

모델                          tok/s    RAM
Qwen3.6-27B (llama.cpp)       18       41 GB
Qwen3.6-27B (llama.cpp+MTP)   32       42 GB
Qwen3.6-35B-A3B (llama.cpp+MTP) 105     45 GB

여기서 짚을 점 두 가지.

첫째, MTP가 27B에서 거의 2배 속도(18→32 tok/s)를 낸다. MTP 빼고 돌리면 답답할 수 있으니 꼭 켜라.

둘째, 35B A3B가 27B보다 3배 빠르다(105 vs 32). 그래도 원문 저자는 27B를 택한다. "1/3만큼 코드를 생성하더라도 더 높은 품질을 원한다"는 이유다. 이 선택은 팀 성향에 달렸다 — 빠른 반복이 중요하냐, 한 방의 정확도가 중요하냐.

Nvidia RTX 카드라면 양자화를 더 공격적으로 해야 한다. HN 댓글의 gfosco는 RTX 5090에서 Q6_K 양자화 + Q4_0 KV로 123k 컨텍스트에서 50 tok/s를 LM Studio로 뽑았다고 한다(약 28/32GB VRAM 사용). 즉 27B는 48GB Apple Silicon 공유 메모리 안에서 돌고, 소비자급 GPU에서도 양자화만 잘 맞추면 충분히 돌아간다.

OpenCode 같은 에이전트에 물리려면 ~/.config/opencode/opencode.jsonc에:

{
  "$schema": "https://opencode.ai/config.json",
  "provider": {
    "llama": {
      "name": "llama.cpp (local)",
      "npm": "@ai-sdk/openai-compatible",
      "options": {
        "baseURL": "http://127.0.0.1:8080/v1",
        "apiKey": "local"
      },
      "models": {
        "qwen3.6-27b": { "name": "Qwen3.6-27B Q8 +MTP" }
      }
    }
  },
  "model": "llama/qwen3.6-27b"
}

핵심은 llama-server가 OpenAI 호환 엔드포인트(/v1)를 노출한다는 거다. 그래서 baseURL만 로컬로 돌리면 기존 OpenAI SDK 쓰는 코드/툴 대부분이 그대로 붙는다.

실무 관점 — 흔한 함정과 트레이드오프

함정 1: VRAM 초과로 레이어가 CPU로 떨어진다

-ngl 999로 전부 GPU에 올리려 했는데 VRAM이 모자라면, llama.cpp가 일부 레이어를 CPU로 오프로드한다. 그럼 속도가 절벽처럼 떨어진다. 보통 이런 로그가 뜬다:

llama_model_load: error loading model: unable to allocate CUDA0 buffer
ggml_backend_cuda_buffer_type_alloc_buffer: allocating 8192.00 MiB on device 0: cudaMalloc failed: out of memory

이때 해법은 (1) 더 공격적인 양자화로 내리거나(Q8_0 → Q6_K → Q4_K_M), (2) -ngl 값을 실제 올릴 수 있는 레이어 수로 줄이거나, (3) 컨텍스트 -c를 줄여서 KV 캐시 메모리를 절약하는 것이다. 컨텍스트 64k가 메모리를 꽤 먹으니, 코드 리뷰처럼 짧은 입력이면 -c 16384 정도로도 충분할 때가 많다.

함정 2: MTP 옵션 누락 또는 draft 모델 미지원

--spec-type draft-mtp를 줬는데 받은 GGUF가 MTP를 지원 안 하는 버전이면 speculative decoding이 동작 안 하거나 경고가 뜬다. 반드시 모델 이름에 -MTP-가 들어간 변형(Qwen3.6-27B-MTP-GGUF)을 받아야 한다. 일반 양자화 받아놓고 MTP 옵션만 켜면 속도 이득(18→32)을 못 본다.

함정 3: 양자화 수준과 품질의 함정

원문 기준 8-bit(Q8_0)는 품질 손실이 거의 없다. 하지만 RTX 카드에서 메모리 맞추려고 Q4 이하로 내리면 코드 생성 품질이 눈에 띄게 떨어질 수 있다. 특히 긴 컨텍스트에서 더 그렇다. "왜 우리 로컬 모델은 클로드보다 한참 멍청하지?"의 범인이 사실은 모델이 아니라 과도한 양자화인 경우가 많다. 벤치마크 점수도 8-bit 기준이라는 걸 기억하자.

함정 4: CI에 통합할 때 동시성

llama-server 한 개 인스턴스에 PR 빌드 여러 개가 동시에 붙으면 큐가 밀린다. Dense 27B는 그렇잖아도 32 tok/s 수준이라, 동시 요청이 쌓이면 CI 잡이 타임아웃 난다. 이럴 땐 처리량(throughput) 최적화가 강한 vLLM으로 갈아타거나, GPU를 여러 장 두고 인스턴스를 늘리는 걸 고려해야 한다. 원문은 단일 개발자 워크플로우 중심이라 이 부분은 다루지 않으니, 팀 단위 CI 통합이라면 처리량 벤치마크를 별도로 잡아봐야 한다(공식 문서 확인 필요).

트레이드오프 정리

  • 품질 우선, 지시 준수 중요 → Qwen 3.6 27B Dense + Q8_0 + MTP
  • 속도 우선, 채팅/초안 → Qwen 3.6 35B A3B (MoE)
  • 소비자 GPU(VRAM 24~32GB) → Q6_K 정도로 타협, KV는 Q4_0
  • 팀 CI 처리량 필요 → vLLM 계열 검토

정리 — 누가 언제 써야 하나

한 줄 요약: Qwen 3.6 27B는 "외부로 코드 못 보내는 환경에서, 단일 개발자/소규모 팀이 품질 좋은 로컬 코딩 어시스턴트를 쓰고 싶을 때" 현실적인 첫 선택지다.

이런 사람에게 권한다:

  • 48GB Apple Silicon이나 24GB+ VRAM GPU를 가진 개발자 — 바로 돌려볼 수 있다.
  • 보안/규제 때문에 클라우드 API를 못 쓰는 백엔드·인프라 팀의 PoC 단계.
  • 외부 API 비용 보조금이 사라질 위험에 대비해 로컬 백업 워크플로우를 두려는 팀.

반대로 지금 당장은 보류할 사람: 수백 명이 동시에 붙는 대규모 CI 처리량이 필요하거나, 프론티어 최상위 모델 품질이 반드시 필요한 경우. 그땐 원문이 언급한 GLM 5.2 같은 더 큰 오픈웨이트(단일 맥북/5090으론 안 돌고 회사 예산급 하드웨어 필요)를 보거나, 그냥 클라우드 API가 답이다.

개인적으로는, "내가 통제하고 뺏기지 않는 모델"이라는 가치 하나만으로도 한 번 세팅해둘 값어치는 충분하다고 본다. 명령어 몇 줄이면 끝나니, 주말에 5090이든 맥북이든 한번 띄워보고 사내 코드 한 조각 리뷰시켜보는 걸 추천한다.

참고 자료

※ 본문의 벤치마크 점수, tok/s, RAM 수치는 모두 원문(Quesma 블로그) 측정값을 인용한 것이며, 미래 시점 모델명이 섞여 있으니 실제 도입 전 최신 공식 문서로 재확인 바란다.

728x90
728x90

실무에서 "이 함수 왜 이렇게 느려?"를 파다 보면 결국 메모리 접근 패턴 문제로 귀결되는 경우가 정말 많다. 알고리즘 복잡도는 똑같은데 실측 시간이 10배씩 차이 나는 일이 흔하다. 최근 GeekNews에 올라온 글이 이걸 아주 극단적으로 보여줬다. 똑같은 정수 합산 루프에서 데이터 자체는 그대로 두고 접근 순서(순열)만 바꿨더니, 선형 접근 1.33억 사이클 vs 무작위 접근 15.7억 사이클로 10배 이상 벌어졌고, 더 나아가 무작위보다 33% 더 느린 패턴까지 의도적으로 만들어냈다는 내용이다.

이게 단순 호기심 실험이 아니라 우리가 매일 만지는 코드와 직결된다. 연결 리스트 순회, 해시맵 조회, 2차원 배열 행/열 순회, DB 인덱스 스캔까지 전부 같은 원리 위에 있다. 이번 글에서는 원문 실험을 따라가며 왜 이런 차이가 나는지 하드웨어 동작으로 풀어보고, 실무에서 어떻게 적용할지 정리한다.

1. 도입: 왜 접근 순서가 성능을 지배하는가

먼저 숫자부터 보자. 원문 실험은 Intel Core Ultra 7 268V 머신에서 2^26개(약 6700만 개)의 uint32_t를 합산하면서, positions 배열의 순서만 바꿔 측정했다. rdtsc 사이클 카운트 기준이다.

linear                                            :   132,752,394
fisher_yates_shuffle (무작위)                      : 1,572,108,618
separated_by_a_cacheline (캐시라인 간격)            :   718,804,156
separated_by_a_page (페이지 간격)                   : 1,411,153,154
separated_by_a_page_and_cacheline                 : 1,408,519,172
stride=8 pages_and_cacheline                       : 2,058,425,640
stride bank_conflicts_and_cacheline               : 2,082,308,014

데이터 양도 같고, 더하는 연산 횟수도 같고, 누산 함수 코드도 한 글자 안 바뀌었다. 오직 "몇 번째 원소를 언제 읽느냐"만 달라졌는데 16배 차이가 난다. CPU 입장에서는 같은 일을 시켰는데 어떤 순서는 빠르게 처리하고 어떤 순서는 미친 듯이 답답해한다는 뜻이다. 이걸 이해하려면 캐시 계층을 알아야 한다.

2. 핵심: 캐시 구조와 접근 패턴이 만나는 지점

L1/L2/L3와 캐시 라인

CPU는 DRAM을 직접 읽으면 너무 느리니까 중간에 캐시를 둔다. 코어 가까운 순서로 L1, L2, L3가 있고, 멀어질수록 용량은 크지만 느리다. 원문 머신 기준:

  • L1d(데이터): 코어당 48KB, 12-way, 64 set
  • L2: 코어당 약 2.5MB
  • L3: 12MB 공유

핵심은 캐시가 바이트 단위가 아니라 캐시 라인(보통 64바이트) 단위로 움직인다는 점이다. uint32_t 하나(4바이트)를 읽으려고 메모리에 접근하면, CPU는 그 4바이트만 가져오는 게 아니라 64바이트 라인 전체를 통째로 끌어온다. 즉 정수 16개가 한 번에 캐시로 올라온다.

그래서 선형 접근이 빠르다. data[0]을 읽는 순간 data[0]~data[15]가 캐시에 들어와 있고, 다음 15번의 접근은 메모리까지 안 가도 된다. 비유하자면 도서관에서 책 한 권 빌리러 가면서 같은 책장의 책 16권을 한꺼번에 들고 오는 것과 같다. 어차피 차례대로 읽을 거면 엄청 효율적이다.

프리페처: CPU의 패턴 예측기

여기에 더해 하드웨어 프리페처가 있다. CPU가 "얘가 0, 64, 128, 192바이트를 순서대로 읽네? 다음은 256이겠군" 하고 미리 캐시에 당겨놓는다. 선형 접근이 압도적으로 빠른 진짜 이유가 이거다. 메모리 지연이 연산 뒤에 숨어버린다.

무작위 접근은 이 두 가지가 다 무너진다. 매번 다른 캐시 라인을 건드리니 라인 안의 나머지 15개는 버려지고, 다음 위치를 예측할 수 없으니 프리페처도 무력하다. 매 접근이 캐시 미스가 되고 DRAM 지연(수백 사이클)을 그대로 맞는다. 원문에서 10배 차이가 여기서 나온다.

무작위보다 더 나쁜 패턴 만들기

원문이 흥미로운 건 "무작위보다 나쁜 순열"을 의도적으로 만든 부분이다. 핵심 아이디어 세 가지만 정리하면:

  1. 집합 연관 캐시 악용: L1d가 64 set × 12 way 구조라, 주소 A와 A+4096은 같은 set에 매핑된다(4096 = 64 set × 64B). 페이지 단위(4096B) stride로 접근하면 같은 set만 계속 두드리게 되고, set당 12 way밖에 없으니 13번째부터는 기존 라인을 쫓아낸다. 48KB 캐시인데 실제로 쓰는 용량은 12 × 64B = 768B뿐이 되는 셈이다.
  2. PTE 캐시 라인까지 파괴: 가상 주소를 물리 주소로 바꾸는 PTE는 8바이트라 캐시 라인 하나에 8개가 들어간다. 페이지 stride를 8로 두면 PTE 지역성까지 깨져서 데이터뿐 아니라 주소 변환 캐시 라인도 매번 새로 가져온다. 그래서 stride=8에서 20.6억 사이클이라는 피크가 나왔다.
  3. DRAM bank/row 충돌: 같은 bank의 다른 row를 번갈아 치면 precharge + activation이 매번 발생해 DRAM 응답이 느려진다. 다만 물리 주소 → DRAM 매핑이 플랫폼 의존적이라 완전 통제는 어렵고, 원문도 근사치(20.8억 사이클)만 얻었다.

실무자가 외울 필요는 없다. 핵심 교훈은 하나다. 접근 거리가 멀어질수록(재사용 거리가 커질수록) 캐시는 무용지물이 된다.

3. 실무 관점: 어디서 만나고 어떻게 피하는가

직접 재현해보기 (벤치마크 코드)

원리를 체감하려면 직접 돌려보는 게 최고다. 행/열 우선 순회 차이를 보여주는 간단한 C 예제를 준비했다. 같은 2차원 배열을 행 우선과 열 우선으로 합산한다.

// access.c
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

#define N 8192

static int *m;

double now() {
    struct timespec t;
    clock_gettime(CLOCK_MONOTONIC, &t);
    return t.tv_sec + t.tv_nsec / 1e9;
}

int main() {
    m = malloc((size_t)N * N * sizeof(int));
    for (size_t i = 0; i < (size_t)N * N; i++) m[i] = 1;

    // 행 우선: 메모리 연속 접근 (캐시 친화적)
    double t0 = now();
    long long sum1 = 0;
    for (int i = 0; i < N; i++)
        for (int j = 0; j < N; j++)
            sum1 += m[(size_t)i * N + j];
    double t1 = now();

    // 열 우선: N칸씩 건너뜀 (캐시 미스 폭발)
    long long sum2 = 0;
    for (int j = 0; j < N; j++)
        for (int i = 0; i < N; i++)
            sum2 += m[(size_t)i * N + j];
    double t2 = now();

    printf("row-major : %.3f s (sum=%lld)\n", t1 - t0, sum1);
    printf("col-major : %.3f s (sum=%lld)\n", t2 - t1, sum2);
    free(m);
    return 0;
}

빌드하고 실행한다. 출력은 머신마다 다르지만 내 노트북(x86_64) 기준 이런 식으로 나온다.

$ gcc -O2 access.c -o access
$ ./access
row-major : 0.071 s (sum=67108864)
col-major : 0.512 s (sum=67108864)

결과 합은 똑같은데(67108864 = 8192×8192) 시간은 7배 넘게 차이 난다. 열 우선 순회는 매 접근마다 N × 4 = 32KB씩 건너뛰니 캐시 라인을 거의 재사용하지 못한다. 원문의 "page stride 접근"과 본질적으로 같은 함정이다. 행렬 연산, 이미지 처리, 텐서 다루는 코드에서 인덱스 순서 한 번 잘못 짜면 이게 그대로 터진다.

perf로 캐시 미스 눈으로 확인하기

"느린 건 알겠는데 진짜 캐시 미스 때문이야?"를 증명하려면 측정해야 한다. Linux에서는 perf가 표준이다.

$ perf stat -e cache-references,cache-misses,L1-dcache-load-misses ./access
row-major : 0.070 s (sum=67108864)
col-major : 0.509 s (sum=67108864)

 Performance counter stats for './access':

       145,332,118      cache-references
        18,442,901      cache-misses          #   12.69 % of all cache refs
       210,558,334      L1-dcache-load-misses

       0.612 seconds time elapsed

열 우선 구간에서 L1 d-cache load miss가 폭증하는 게 보인다. 두 루프를 분리해서 따로 측정하면 차이가 더 명확하다.

흔한 함정 1: perf 권한 에러

perf 처음 돌리면 십중팔구 이 에러부터 만난다.

$ perf stat ./access
Error:
Access to performance monitoring and observability operations is limited.
Consider adjusting /proc/sys/kernel/perf_event_paranoid setting to open
access to performance monitoring and observability operations for processes
without CAP_PERFMON, CAP_SYS_PTRACE or CAP_SYS_ADMIN Linux capability.

해결은 커널 파라미터 조정이다. 개발 머신이면 임시로 낮춰서 쓴다(운영 서버는 보안 정책 확인 필수).

$ sudo sysctl -w kernel.perf_event_paranoid=1
kernel.perf_event_paranoid = 1

컨테이너 안에서 돌릴 때는 추가로 cache-misses 같은 하드웨어 이벤트가 막혀 있을 수 있다. 그 경우 이런 메시지가 뜬다.

<not supported>      cache-misses

이건 가상화 환경(특히 클라우드 VM이나 컨테이너)에서 PMU 카운터가 노출 안 될 때 나온다. 베어메탈이나 PMU를 패스스루한 환경에서 측정해야 한다. 클라우드에서 캐시 단위 프로파일링이 안 되는 건 꽤 자주 겪는 한계다.

흔한 함정 2: AoS와 SoA 선택

실무에서 가장 자주 만나는 게 구조체 배열 설계다. 객체 지향적으로 짜면 자연스럽게 AoS(Array of Structures)가 된다.

// AoS - 객체 하나가 메모리에 뭉쳐 있음
struct Particle { float x, y, z; float vx, vy, vz; int id; ... };
struct Particle particles[N];

// x좌표만 전부 합산하려는데...
for (int i = 0; i < N; i++) sum += particles[i].x;

x만 필요한데 캐시 라인에는 y, z, vx... id까지 딸려 온다. 필요한 4바이트 쓰자고 라인 64바이트 중 대부분을 낭비한다. 특정 필드만 대량 순회하는 패턴이면 SoA(Structure of Arrays)가 유리하다.

// SoA - 같은 필드끼리 모음
struct Particles {
    float x[N], y[N], z[N];
    float vx[N], vy[N], vz[N];
    int id[N];
};
// x[]가 연속이라 캐시 라인 낭비 없음 + 벡터화도 잘 먹음
for (int i = 0; i < N; i++) sum += p.x[i];

트레이드오프가 분명하다. SoA는 "필드별 대량 처리"에 강하지만, "객체 하나의 모든 필드 접근"이 잦으면 오히려 AoS가 낫다. 게임 엔진, 시뮬레이션, 컬럼 지향 DB가 SoA를 택하는 이유고, 일반 비즈니스 로직은 보통 AoS가 무난하다. 무조건 SoA가 답이 아니다. 접근 패턴을 보고 정해야 한다.

실무에서 만나는 다른 나쁜 패턴들

  • 연결 리스트 순회: 노드가 힙 여기저기 흩어져 있어 next 따라가는 게 사실상 무작위 접근이다. 원소 수가 많으면 배열/벡터로 바꾸는 것만으로 몇 배 빨라진다. "삽입/삭제가 O(1)이라 리스트가 빠르다"는 교과서 논리가 실측에서 자주 깨지는 이유다.
  • 해시맵 대량 순회: 해시맵은 키 조회 한 방엔 강하지만, 전체를 순회하면 버킷이 흩어져 있어 캐시 효율이 나쁘다. 순회가 핫패스면 정렬된 배열이나 별도 인덱스를 두는 게 낫다.
  • DB 인덱스 스캔 vs 시퀀셜 스캔: 옵티마이저가 인덱스를 안 타고 풀스캔을 택하는 게 비합리적으로 보일 때가 있는데, 넓은 범위를 읽을 땐 디스크/페이지의 순차 접근이 랜덤 인덱스 점프보다 빠르기 때문이다. 이것도 결국 같은 지역성 원리다.

대안: 루프 타일링(블로킹)

행렬 곱처럼 큰 데이터를 반복 접근하는 경우, 캐시에 들어갈 만한 작은 블록 단위로 쪼개서 처리하면 재사용 거리가 줄어 캐시 히트가 올라간다. 다만 블록 크기 튜닝이 머신 의존적이고 코드가 복잡해지니, 직접 짜기보다 BLAS 같은 검증된 라이브러리나 컴파일러 최적화를 먼저 쓰는 걸 권한다.

4. 정리

한 줄 요약: 알고리즘이 같아도 메모리 접근 순서가 성능을 지배한다. 가능하면 데이터를 연속으로, 순차적으로 읽어라. CPU와 프리페처가 알아서 빠르게 만들어준다.

누가 언제 신경 써야 하나:

  • 대량 데이터를 반복 순회하는 핫패스를 가진 사람(배치 처리, 데이터 파이프라인, 게임/시뮬레이션, 수치 연산) → 무조건 본다.
  • "복잡도는 같은데 왜 느리지?"를 디버깅 중인 사람 → perf stat으로 cache-miss부터 찍어봐라.
  • 일반 CRUD 웹 백엔드 → 대부분 네트워크/DB가 병목이라 우선순위가 낮다. 단, 직렬화 포맷이나 in-memory 캐시 자료구조를 설계할 땐 알아두면 좋다.

마지막으로 강조하고 싶은 건 측정 없는 최적화는 하지 말라는 거다. 원문도 결국 rdtsc로 실측했고, 우리도 perf로 확인했다. SoA 같은 패턴은 가독성을 희생하니, 진짜 병목으로 확인된 곳에만 적용하는 게 맞다.

참고 자료

728x90
728x90

일반 CRUD 백엔드만 굴리다가 결제·정산 시스템에 처음 투입되면 멘붕이 온다. "그냥 잔액 칼럼 하나 두고 더하고 빼면 되는 거 아니야?"라고 생각하다가, 환불이 중복으로 두 번 나가고, 정산 금액이 1원씩 안 맞고, 외부 PG 웹훅이 두 번 와서 같은 거래가 두 번 찍히는 걸 보게 된다. 이게 다 같은 뿌리에서 나온 문제다.

최근 GeekNews에 올라온 핀테크 엔지니어링 핸드북은 이 바닥에서 반복적으로 터지는 함정들을 세 가지 원칙으로 깔끔하게 정리했다. 이 글에서는 그 원칙을 실무 코드와 실제로 만날 법한 에러까지 붙여서 풀어본다.

1. 왜 금융 시스템은 일반 백엔드와 다르게 설계해야 하는가

원문이 제시하는 세 가지 원칙이 전부다. 외워두면 설계 회의에서 싸울 때 근거가 된다.

  • No invented data: 돈은 없던 데서 생기지 않는다. 중복 처리와 임의 잔액 변경을 막아야 한다.
  • No lost data: 돈에 일어난 모든 일은 추적되고 영속화돼야 한다.
  • No trust: 외부 제공자도, 내부 컴포넌트도, 현실 세계도 믿지 말고 검증한다.

일반 CRUD에서는 "최신 상태"가 진실이다. 행 하나 업데이트하면 끝. 그런데 금융 시스템은 "어떻게 그 상태가 됐는지"가 더 중요하다. 규제 감사가 들어오면 "지금 잔액 100만 원입니다"로는 부족하고, "이 100만 원이 어떤 거래들의 합으로 만들어졌는지" 몇 년 전 것까지 재구성할 수 있어야 한다. 그래서 추적성·불변성·검증 가능성이 성능이나 편의성보다 앞선다.

핵심 차이를 한 줄로 요약하면: 일반 백엔드는 상태를 덮어쓰지만, 금융 백엔드는 상태를 누적한다.

2. 금액을 float로 저장하면 안 되는 이유

가장 흔하고 가장 치명적인 실수다. 신입한테 "왜 float 쓰면 안 돼요?"라고 물으면 대부분 "정밀도 문제요"라고 답하는데, 실제로 무슨 일이 벌어지는지 보여주는 게 빠르다.

# Python에서 IEEE-754 double 동작 확인
$ python3 -c "print(0.1 + 0.2)"
0.30000000000000004

$ python3 -c "print(0.1 + 0.2 == 0.3)"
False

이게 왜 무섭냐면, 단건 거래에서는 안 보이다가 수십만 건을 합산하는 정산 배치에서 갑자기 터진다. 외부 정산 데이터랑 1원, 2원씩 안 맞기 시작하는데 원인 찾는 데 며칠 날린다. 원문 표현대로 "예측하기 어려운 정밀도 손실"이라 디버깅이 지옥이다.

해결책은 책임에 따라 조합한다

  • 최소 단위 정수 저장: €12.34를 1234(센트)로 저장. 중앙은행 시스템과 같은 고정 정밀도 방식이다. 단, ISO 4217 자릿수를 따라야 하고 "항상 소수점 2자리"라고 가정하면 안 된다. JPY는 0자리, 일부 통화는 3자리다.
  • BigDecimal 같은 임의 정밀도 타입: 반올림 위치를 명시적으로 제어할 수 있어 FX·이자처럼 연산이 줄줄이 이어지는 중간 계산에 적합하다.
  • 유리수(Rational): 정밀도 손실이 절대 허용 안 될 때 가장 강력하지만 느리고 변환이 까다롭다.

저장 방식과 계산 방식은 별개 결정이다. 정수로 저장하고 BigDecimal로 중간 계산하는 조합이 흔하다.

// Java: BigDecimal로 안전하게 계산 (반올림 명시)
import java.math.BigDecimal;
import java.math.RoundingMode;

BigDecimal a = new BigDecimal("0.1");
BigDecimal b = new BigDecimal("0.2");
System.out.println(a.add(b));  // 0.3 (정확)

// 수수료 3.5% 계산 후 소수점 2자리 반올림
BigDecimal amount = new BigDecimal("12340");  // 최소단위(센트)
BigDecimal fee = amount.multiply(new BigDecimal("0.035"))
                       .setScale(0, RoundingMode.HALF_EVEN);
System.out.println(fee);  // 432

주의: Java에서 new BigDecimal(0.1)처럼 double을 직접 넣으면 float 문제가 그대로 따라온다. 반드시 new BigDecimal("0.1")처럼 문자열로 넣어야 한다.

직렬화 경계에서 다시 터지는 함정

내부에서 BigDecimal 잘 써놓고 안심하다가, JSON으로 내보내는 순간 무너진다. 일반 JSON 숫자는 대부분의 파서에서 IEEE-754 double로 파싱되기 때문이다. 그래서 돈은 JSON에서 12.34 같은 문자열이나 최소 단위 정수로 보내야 한다.

// 나쁜 예: 숫자로 직렬화 → 수신측에서 double로 파싱되어 정밀도 손실
{ "amount": 12.34, "currency": "EUR" }

// 좋은 예: 문자열 또는 최소단위 정수
{ "amount": "12.34", "currency": "EUR" }
{ "amount_minor": 1234, "currency": "EUR" }

그리고 돈은 숫자만으로 표현하면 안 된다. 항상 통화와 함께 Money 타입으로 묶어라. 서로 다른 통화 덧셈은 금지하고, 변환은 통제된 환율로만 명시적으로 한다. 이걸 안 하면 USD 금액에 KRW 금액을 더하는 버그가 코드 리뷰를 통과해버린다.

3. 멱등성 설계: 결제 재시도와 중복 처리를 막는 패턴

분산 시스템에서는 exactly-once delivery를 보장할 수 없다. 그래서 재시도가 필요하고, 재시도는 중복 전달을 만든다. 이 모순을 푸는 게 멱등성(Idempotency)이다. 같은 메시지가 두 번 와도 효과는 한 번만 나게 하는 성질.

실무에서 언제 만나냐면:

  • 클라이언트가 결제 요청 보냈는데 응답 타임아웃 → 사용자가 결제 버튼 다시 누름 → 두 번 결제
  • PG 웹훅이 redelivery policy 때문에 같은 이벤트를 2~3번 보냄
  • Kafka consumer가 처리 후 offset commit 전에 crash → 재시작 시 같은 메시지 재처리

명시적 idempotency key가 정답

원문은 payload 기반 deduplication보다 명시적 idempotency key가 보통 더 단순하고 안전하다고 본다. 핵심은 key를 특정 operation과 client 범위로 제한하는 것.

-- idempotency key를 DB unique 제약으로 강제
CREATE TABLE idempotency_keys (
    key         VARCHAR(255) NOT NULL,
    client_id   VARCHAR(64)  NOT NULL,
    operation   VARCHAR(64)  NOT NULL,
    response    JSONB,
    created_at  TIMESTAMPTZ  NOT NULL DEFAULT now(),
    PRIMARY KEY (client_id, operation, key)
);

-- 결제 처리 트랜잭션 안에서 먼저 키를 선점
INSERT INTO idempotency_keys (key, client_id, operation)
VALUES ('pay-20240601-abc123', 'merchant-42', 'charge');

두 번째 요청이 같은 키로 들어오면 unique 제약에 걸린다. 흔히 마주치는 에러가 이거다:

ERROR:  duplicate key value violates unique constraint "idempotency_keys_pkey"
DETAIL:  Key (client_id, operation, key)=(merchant-42, charge, pay-20240601-abc123) already exists.

이 에러를 예외로 흘려보내면 안 되고, "아 이미 처리된 요청이구나" 하고 저장해둔 원래 응답(response 컬럼)을 그대로 돌려줘야 한다. 이걸 처리 안 하고 500 에러로 던지면 클라이언트가 또 재시도하는 무한 루프에 빠진다.

흔한 함정들

  • 오류를 재생할지 재처리할지: 영구 오류(잔액 부족 등)는 그대로 재생하는 게 보통 단순하다. 일시 오류만 재처리.
  • 24시간 idempotency window: 구현은 단순해지지만 correctness 비용이 크다. window 밖에서 들어온 재시도는 중복으로 안 잡힌다.
  • 동시성: 대규모에서는 같은 키가 동시에 두 번 들어올 때 atomic barrier가 필요하다. unique 제약이 그 역할을 해준다.
  • out-of-order retry: 재시도가 원본보다 먼저 도착할 수도 있다. 테스트에 반드시 포함해라.

4. 이중 장부(Double-Entry Accounting)와 불변 원장

잔액 칼럼 하나 두는 설계가 왜 망하는지 여기서 명확해진다. 잔액은 저장하지 않고, 돈의 이동에서 파생해야 한다.

복식부기는 모든 거래를 (credit account, debit account, amount) 형태의 entry로 저장한다. 모든 entry가 한 계정에서 다른 계정으로 같은 금액을 옮기므로 장부는 항상 균형을 이룬다. 돈에는 항상 출처와 목적지가 있다. 외부 PG조차 전용 계정을 가져야 시스템 안팎으로 흐르는 돈을 추적할 수 있다.

-- 불변 원장: append-only, UPDATE/DELETE 금지
CREATE TABLE ledger_entries (
    id              BIGSERIAL PRIMARY KEY,
    debit_account   VARCHAR(64)  NOT NULL,
    credit_account  VARCHAR(64)  NOT NULL,
    amount_minor    BIGINT       NOT NULL,  -- 최소단위 정수
    currency        CHAR(3)      NOT NULL,
    value_time      TIMESTAMPTZ  NOT NULL,  -- 거래가 실제 발생한 시점
    booking_time    TIMESTAMPTZ  NOT NULL DEFAULT now(),  -- 기록된 시점
    settlement_time TIMESTAMPTZ,            -- 돈이 실제 이전된 시점 (T+X)
    reverses_id     BIGINT REFERENCES ledger_entries(id)  -- 정정 시 원본 연결
);

-- 잔액은 SELECT로 파생
SELECT
    SUM(CASE WHEN credit_account = 'user-42' THEN amount_minor ELSE 0 END)
  - SUM(CASE WHEN debit_account  = 'user-42' THEN amount_minor ELSE 0 END)
    AS balance_minor
FROM ledger_entries
WHERE currency = 'KRW';

시간을 created_at 하나로 합치지 마라

이게 진짜 자주 하는 실수다. 거래에는 보통 2~3개의 타임스탬프가 붙는다.

  • Value time: 거래가 실제 발생한 시점
  • Booking time: 시스템에 기록된 시점
  • Settlement time: 돈이 실제 이전된 시점 (T+2면 value date로부터 2일 뒤)

카드 결제를 예로 들면 T1에 결제 발생, T2에 시스템 기록, T3에 PG가 실제 입금. 이걸 created_at 하나로 뭉개면 나중에 재구성 불가능한 정보를 잃는다. 비즈니스 보고서는 value time이나 settlement time을 보는데, created_at밖에 없으면 월별 정산이 틀어진다.

정정은 덮어쓰지 말고 상쇄 entry로

posted entry는 관례상 불변이다. 잘못 찍혔으면 원본을 수정하는 게 아니라 compensating entry를 추가하고 원본과 양방향 연결한다.

  • Reversal: 원본을 경제적으로 없었던 것처럼 완전히 상쇄. 단, 원본과 reversal 둘 다 이력에 남는다.
  • Correction/adjustment: 실제 값과 올바른 값의 차이를 booking하거나, 되돌린 뒤 다시 posting.

정정은 원본과 다른 보고 기간에 들어갈 수 있다. 이미 닫힌(외부에 보고된) 보고 기간에 backdate하는 건 보통 금지다. 그래서 연결 정보가 있어야 "실제 활동"과 "cleanup"을 보고서에서 구분할 수 있다.

5. 분산 환경에서의 정합성: 2PC vs Saga vs Outbox

"상태 변경은 DB에 commit됐는데 Kafka publish가 실패했다" 또는 "publish는 성공했는데 응답을 못 받아서 DB를 rollback했다" — 이 문제를 어떻게 풀 것인가.

방식동작실무 평가
2PC / 분산 트랜잭션 여러 시스템을 하나의 원자적 트랜잭션으로 묶음 교과서 정답이지만 복잡성·표준화 어려움 때문에 실무에선 드물게 쓰임
Outbox 패턴 상태 변경과 "publish 의도"를 같은 트랜잭션으로 전용 테이블에 기록, 별도 relay가 나중에 발행 가장 실용적. DB 트랜잭션 하나로 원자성 확보
CDC DB의 WAL/replication log를 읽어 commit된 변경을 event로 변환 (Debezium, AWS DMS) raw row 형태라 내부 schema 누출 막으려면 postprocessing 필요
Saga 외부 효과가 영구 실패하면 compensating action을 posting해서 보상 외부 호출은 rollback 불가능하므로 roll-forward 또는 보상 거래로 처리

Outbox 패턴 핵심

-- 비즈니스 상태 변경과 outbox 기록을 같은 트랜잭션에
BEGIN;
  INSERT INTO ledger_entries (debit_account, credit_account, amount_minor, currency, value_time)
  VALUES ('user-42', 'merchant-7', 50000, 'KRW', now());

  INSERT INTO outbox (event_id, topic, payload)
  VALUES (gen_random_uuid(), 'payment.completed', '{"order_id":"o-123"}');
COMMIT;
-- relay 프로세스가 outbox를 polling하여 Kafka로 발행 후 마킹

어떤 메커니즘을 고르든 delivery는 at-least-once다. relay가 publish 후 기록 전에 죽으면 재시작 때 또 보낸다. 그래서 consumer는 stable event id로 deduplicate하고 멱등적으로 동작해야 한다. 3번 섹션의 멱등성이 여기서 다시 필요해진다. 결국 다 연결돼 있다.

재시작 가능한 흐름과 funds reservation

돈 흐름은 여러 단계를 거치고 단계 사이 어디서든 죽을 수 있다고 가정해야 한다. 진행 상태를 메모리가 아니라 영속 저장소에 두고, 명시적 state machine으로 모델링하고, 각 단계 완료를 다음 단계 시작 전에 commit한다. Temporal, Camunda, AWS Step Functions 같은 durable-execution engine을 쓰거나 직접 만든다.

외부 호출 전에 race condition을 막으려면 funds reservation(hold-and-release)을 쓴다. 외부 상호작용 전에 자금을 예약하고, 성공하면 settle, 실패하면 release. 이때 available = total - reserved로 가용 잔액을 구분한다. 중요한 건 잔액 확인과 reservation 기록이 linearizable해야 한다는 것. stale read에서는 두 거래가 모두 잔액 확인을 통과해 같은 자금을 두 번 쓰게 된다.

6. 실무 체크리스트: 시나리오별 설계 포인트

웹훅 처리 (PG·custodian 연동)

  • 순서 가정 금지 — out-of-order로 오거나 stale data를 담는다. 받은 웹훅으로 상태를 덮어쓰지 마라.
  • 웹훅 본문은 trigger로만 쓰고, authoritative state는 API를 다시 조회해서 확인. (단 API도 eventually consistent라 retry 필요)
  • 빠르게 2xx로 ack하고 비동기 처리. raw payload는 그대로 durable store에 저장.
  • 서명 검증은 재직렬화한 payload가 아니라 받은 raw bytes 위에서 해야 한다. 이거 틀리면 HMAC 검증이 간헐적으로 실패한다.
  • 웹훅은 "진실"이 아니라 "뭔가 일어났다는 hint"로 취급.

환불·정정

  • 원본 entry 수정 금지. compensating entry로 reversal 또는 correction.
  • 이미 닫힌 보고 기간에 backdate 가능한지 보고 일정 확인.
  • reversal이 자금 빠져나간 뒤 들어오면 의도치 않은 overdraft가 생긴다. 음수 잔액을 0으로 clamp하면 돈을 만들어내는 꼴이니 절대 금지.

정산·대사(Reconciliation)

  • 두 시스템 이상의 상태가 어긋나는 data drift는 필연. ledger·PG·bank 셋 이상일 수 있다.
  • settlement가 T+3이면 record는 3일간 unreconciled 상태가 정상 — 이걸 process에 반영 안 하면 불필요한 alert가 쏟아진다.
  • discrepancy를 단순 overwrite로 맞추지 마라. correction record로 원인을 이해하고 고쳐야 한다.
  • external provider id를 내
728x90
728x90

도입: "잘 돌아가는데요?"가 가장 위험한 말이다

운영하다 보면 진짜 무서운 장애는 에러 로그를 토하는 코드가 아니다. 에러를 내는 코드는 차라리 고맙다. 알아서 자기가 망가졌다고 소리치니까. CI에서 빨갛게 뜨고, 배포가 막히고, 슬랙에 알림이 온다. 피드백 루프가 빠르다.

진짜 무서운 건 아무 소리 없이 잘못된 일을 정확하게 수행하는 코드다. Dev.to에 올라온 Dimitris Kyrkos의 글 "Functional doesn't mean correct"가 바로 이 지점을 정확히 찌른다. 코드는 돈다. 인터페이스도 멀쩡하다. 테스트도 통과한다. 그런데 시스템이 조용히 엉뚱한 문제를 풀고 있다.

이게 지금 화제인 이유는 단순하다. AI가 코드를 뽑아내는 속도가 사람이 검증하는 속도를 압도해버렸기 때문이다. 원문 댓글에 나온 사례처럼 50만 LOC 코드베이스의 82%가 생성 코드라면, 더 이상 한 줄씩 눈으로 리뷰하는 건 불가능하다. 그런데 이 "동작하지만 틀린" 코드는 인프라/백엔드 실무자가 코드 리뷰와 파이프라인에서 매일 마주치는 현실적인 위험이다. 오늘은 이걸 왜, 언제 만나는지 그리고 어떻게 막을지 실무 관점에서 풀어본다.

핵심: 사람이 코드를 짤 때 생기는 '마찰'이 사라졌다

원문에서 가장 핵심적인 통찰은 이거다. 사람이 코드를 짜는 행위 자체가 요구사항과의 마찰(friction)을 만든다는 점.

스펙을 읽고, 생각하고, 로직으로 옮기는 과정에서 "어? 이 요구사항 말이 안 되는데?", "이 엣지 케이스는 스펙에 안 적혀 있네?", "고객이 요청한 거랑 실제로 필요한 게 다른데?" 같은 의문이 자연스럽게 떠오른다. 이 마찰이 바로 오해가 수면 위로 드러나는 지점이다.

AI는 이 단계를 통째로 건너뛴다. 프롬프트를 주면 구조적으로 그럴듯한 출력을 즉시 뱉는다. 문제는 "프롬프트와 구조적으로 일치한다"와 "실제 문제를 푼다"는 완전히 다른 것이라는 점이다.

비유하자면 이렇다. 신입한테 "할인 계산 함수 만들어줘"라고 시켰다고 치자. 눈치 빠른 신입은 "근데 도매 고객이랑 소매 고객 할인율 다른데, 둘 다 처리할까요?"라고 되묻는다. AI는 안 묻는다. 그냥 프롬프트에 적힌 대로 하나의 할인 로직을 깔끔하게 만들어서 던진다. 코드는 완벽하게 돈다. 도매 고객 결제할 때 조용히 틀린 금액을 계산할 뿐이다.

원문이 정리한 세 가지 대표 실패 패턴은 실무에서 거의 그대로 만난다:

  • 요구사항을 문자 그대로 해석한다 — "검색 기능"을 요청했더니 정확히 일치하는 문자열만 찾는 함수를 만든다. 사용자는 오타 허용, 유사어 처리를 기대했는데.
  • 비즈니스 규칙이 납작해진다 — 팀원 모두가 아는 예외("레거시 계정엔 grandfather 조항 적용", "지원팀 임시 권한 상승")가 코드 어디에도 안 적혀 있으니 AI는 모른다.
  • 엣지 케이스가 happy path로 처리된다 — 흔한 케이스는 잘 돈다. 실제 장애를 일으키는 드문 케이스는 "크래시는 안 나지만 조용히 틀린 결과"를 낸다.

실무 관점: 인프라 코드에서 특히 무서운 이유와 가드레일 설계

왜 IaC와 쉘 스크립트에서 더 위험한가

애플리케이션 코드는 그나마 낫다. 틀리면 사용자가 항의라도 한다. 그런데 인프라 코드는 다르다. Terraform이나 쉘 스크립트가 "동작하지만 틀린" 상태로 머지되면, 그 결과가 인프라 전체에 조용히 퍼진다. 보안 그룹이 너무 열려 있어도 트래픽은 잘 흐른다. S3 버킷이 퍼블릭이어도 앱은 멀쩡히 돈다.

실제로 AI가 자주 만드는 위험한 Terraform 패턴을 보자. "보안 그룹에서 웹 트래픽 허용해줘"라고 했을 때 흔히 나오는 결과다:

# AI가 뽑은, 동작하지만 위험한 코드
resource "aws_security_group" "web" {
  name = "web-sg"

  ingress {
    from_port   = 0
    to_port     = 65535
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]   # 전 세계에 모든 포트 개방
  }
}

이 코드는 terraform apply가 깔끔하게 성공한다. 웹 서비스도 잘 뜬다. 데모도 통과한다. 그런데 모든 포트가 인터넷 전체에 열려 있다. 사람이 짰다면 "80, 443만 열면 되지 왜 전체 포트를?"이라고 멈췄을 지점이다.

그래서 우리 팀은 머지 전에 정책 검사를 강제로 통과시킨다. conftest(OPA 기반)로 Terraform plan을 검사하는 예시:

# plan을 JSON으로 추출
terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > tfplan.json

# 정책 검사 실행
conftest test tfplan.json --policy policy/

policy/security.rego에 "0.0.0.0/0 + 전체 포트"를 막는 규칙을 넣어두면 출력은 이렇게 나온다:

FAIL - tfplan.json - main - Security group 'web-sg' allows ingress from 0.0.0.0/0 on all ports

1 test, 0 passed, 0 warnings, 1 failure

핵심은 이거다. AI가 코드를 짜더라도, AI가 게임할 수 없는 적대적(adversarial) 검증 레이어를 따로 두는 것. 원문 댓글의 UnitBuilds 사례도 같은 결론에 도달했다. AI가 만든 테스트는 "AI 자신의 가정을 테스트"하는 자기만족 시스템(self-gratifying system)이 되기 쉽다. 코드 작성자와 코드 파괴자를 분리해야 한다.

CI/CD 파이프라인 가드레일 구성

실무에서 쓰는 최소 구성은 이렇게 잡는다. AI가 생성했든 사람이 짰든 상관없이 모든 변경이 통과해야 하는 관문이다:

# .github/workflows/guardrails.yml (발췌)
jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      # 1. 정적 보안 분석 (IaC)
      - name: tfsec scan
        run: tfsec . --soft-fail=false

      # 2. 쉘 스크립트 린트
      - name: shellcheck
        run: shellcheck scripts/*.sh

      # 3. 정책 검사
      - name: conftest
        run: conftest test tfplan.json --policy policy/

여기서 중요한 건 --soft-fail=false다. 경고만 띄우고 통과시키면 가드레일이 아니라 장식이다. 막을 거면 확실히 막아야 한다.

흔한 함정: AI가 짠 쉘 스크립트의 침묵하는 실패

AI가 뽑은 배포 스크립트에서 진짜 자주 겪는 함정이다. 이런 코드를 받았다고 하자:

#!/bin/bash
cd /opt/app/releases/$VERSION
rm -rf ./cache/*
./deploy.sh

문제가 뭔지 보이는가? $VERSION이 비어 있으면 어떻게 될까. cd /opt/app/releases/로 이동하거나, cd가 실패해도 다음 줄이 그대로 실행된다. 운이 나쁘면 rm -rf ./cache/*가 엉뚱한 디렉토리에서 돈다. AI는 set -e나 변수 검증을 안 넣는 경우가 흔하다. happy path만 봤기 때문이다.

shellcheck를 돌리면 이런 경고가 그대로 나온다:

In deploy.sh line 2:
cd /opt/app/releases/$VERSION
                     ^------^ SC2086: Double quote to prevent globbing and word splitting.

In deploy.sh line 2:
cd /opt/app/releases/$VERSION
^-- SC2164: Use 'cd ... || exit' in case cd fails.

제대로 고치면 이렇게 된다:

#!/bin/bash
set -euo pipefail   # 미정의 변수, 에러 시 즉시 중단

: "${VERSION:?VERSION is required}"   # 빈 값이면 여기서 멈춤

cd "/opt/app/releases/${VERSION}" || exit 1
rm -rf ./cache/*
./deploy.sh

이 차이가 운영에서 "조용히 망가지는 배포"와 "명확하게 멈추는 배포"를 가른다.

트레이드오프와 대안

물론 가드레일을 다 깔면 속도는 느려진다. 원문 표현대로 "vibe coding은 속도를, validation은 정확성을 준다. 둘은 다른 것이고 하나가 다른 하나를 대체하지 못한다."

현실적인 선택은 이거다. 코드의 영향 범위에 따라 검증 강도를 다르게 가져가는 것. 인프라/권한/결제 관련 코드는 가드레일을 빡세게, 내부 도구나 일회성 스크립트는 느슨하게. 모든 곳에 똑같은 강도를 적용하면 팀이 검증 자체를 우회하기 시작한다. 그게 가장 나쁜 결말이다.

코드 리뷰 체크리스트: AI 생성 코드를 볼 때

원문이 제안하는 검증의 핵심 질문은 "프롬프트가 아니라 실제 요구사항을 푸는가"다. 이걸 실무 체크리스트로 옮기면:

  • 프롬프트 말고 진짜 요구사항을 봤는가 — AI가 푼 건 내가 적은 프롬프트지, 비즈니스가 필요로 하는 게 아닐 수 있다.
  • 이 코드가 조용히 틀린 결과를 낼 수 있는가 — 크래시 말고, 에러 없이 그냥 잘못된 값을 뱉을 가능성. 이게 가장 중요하다.
  • 팀의 암묵지 예외가 반영됐는가 — 코드에 안 적혀 있고 사람 머릿속에만 있는 비즈니스 규칙들.
  • 테스트가 진짜 요구사항을 검증하는가, AI의 해석을 검증하는가 — 97% 커버리지가 "코드가 실행됐다"는 말이지 "올바르다"는 말은 아니다.

정리: 한 줄 요약과 누가 언제 써야 하는가

한 줄 요약: AI는 구조적으로 그럴듯한 코드를 잘 만든다. 그리고 당신이 풀 필요 없는 문제를 자신 있게 푸는 코드도 잘 만든다. 그 둘 사이의 간극이 바로 엔지니어링 판단이 사는 곳이다. AI는 그 판단의 필요성을 없앤 게 아니라, "코드가 돈다"와 "시스템이 실제로 작동한다" 사이에 남은 유일한 방어선으로 만들었다.

누가 언제 써야 하나:

  • AI 코드 생성을 적극 도입한 팀이라면, 생성량이 많을수록 자동 가드레일은 선택이 아니라 필수다. 수동 리뷰로 33만 줄을 못 본다.
  • 인프라/결제/권한처럼 "조용히 틀리면 치명적"인 도메인일수록 적대적 검증 레이어(정책 검사, 퍼저, 정적 분석)를 코드 작성자와 분리해서 둬야 한다.
  • 반대로 일회성 스크립트나 프로토타입이라면 속도를 택하는 게 맞다. 검증 강도는 영향 범위에 비례시켜라.

마지막으로 원문이 던진 질문을 그대로 옮긴다. "당신의 팀은 AI 생성 코드가 아무 문제가 아니라 올바른 문제를 푼다는 걸 어떻게 검증하는가?" 이 질문에 자동화된 답을 가지고 있지 않다면, 지금 파이프라인부터 손볼 때다.

참고 자료

728x90
728x90

1. 도입: 방문자 14명에 $31, 뭔가 단단히 잘못됐다

사이드 프로젝트 하나쯤 굴려본 사람이라면 이 시나리오가 남 일 같지 않을 거다. Dev.to에 올라온 "My app didn't go viral. My AWS bill did." 글이 딱 그 케이스다. Umami 애널리틱스는 한 달에 방문자 14명을 찍었는데, AWS는 $31을 청구했다. 방문자 한 명당 $2.21. 무료 도구치고는 캘리포니아에서 제일 비싼 학습 도구가 된 셈이다.

여기서 핵심은 결말이다. 그 14명 방문자는 청구서와 아무 상관이 없었다. 진짜 범인은 같은 주말에 글쓴이가 작업하던 전혀 다른 프로젝트(vigil-crest)였고, Bedrock의 과금 구조 때문에 그 비용이 엉뚱한 앱 이름표를 달고 있었던 것뿐이다.

이 글이 인프라 엔지니어한테 와닿는 이유는, "비용이 늘었다"는 단순한 사건이 아니라 대시보드가 거짓말을 하고, AI 비용 추적 도구가 잘못된 모델명을 들이밀고, 과금이 엉뚱한 프로젝트에 귀속되는 복합적인 함정을 정확히 짚었기 때문이다. 실무에서 비용 폭탄 원인을 추적해본 사람은 안다. 진짜 어려운 건 "얼마 나왔냐"가 아니라 "누가, 어디서, 왜 썼냐"를 증거로 못 박는 일이다.

2. 핵심: 왜 작은 트래픽에서도 청구서가 터지는가 — 과금 구조의 비결정성

원문이 잘 짚은 포인트 두 개를 인프라 관점으로 풀어보자.

(1) 트래픽 수와 비용은 직결되지 않는다

Umami 같은 클라이언트 사이드 애널리틱스는 "내 자바스크립트를 실행한 브라우저"만 센다. 봇도, 백엔드 API 호출도, Bedrock 모델 호출도 못 본다. 즉 방문자 수와 실제 과금 사이엔 인과관계가 없는데, 사람은 자꾸 이 둘을 묶어서 본다. 글쓴이 표현대로 "서로 무관한 두 숫자를 엮어 스스로를 겁줬다."

실무에서도 똑같다. CloudWatch 대시보드의 요청 수가 평소랑 같은데 비용만 튀는 경우, 십중팔구 범인은 트래픽이 아니라 데이터 전송, NAT Gateway, 로그 보존, 항상 켜진 인스턴스 같은 "조용한 비용"이다. 댓글에 달린 사례처럼 Cloud Logging 로그 보존만으로 이틀에 $50이 나오기도 한다.

(2) 모델/리소스 과금은 "프로젝트"를 모른다

원문에서 제일 중요한 기술적 통찰이 이거다. Bedrock 과금은 계정 레벨이다. 어떤 함수가 호출했든, 그 모델 비용은 호출한 리소스의 태그를 물려받지 않는다. Application Inference Profile을 따로 설정하고 그걸 호출하지 않는 한, 모든 모델 비용은 "프로젝트 없음" 버킷에 떨어지고, 뭔가가 그걸 떠안게 된다. 글쓴이의 경우 비용 추적 도구가 그걸 멋대로 Clew Directive 탓으로 돌렸다.

비유하자면 회사 공용 법인카드 명세서 같은 거다. 카드 한 장으로 여러 팀이 긁었는데, 명세서엔 "누가 긁었는지"가 안 찍히면 회계가 추정으로 갖다 붙인다. 그 추정이 틀리면 엉뚱한 팀이 누명을 쓴다.

(3) 비용의 "모양"을 읽는 법

원문에서 제일 실무적으로 배울 만한 건 토큰 사용량 분해다. 28M 토큰, 8일 활동, 그중 이틀이 70%를 차지. 그리고 그 모양이:

Cache writes: 4.1M tokens, $15.33 (55%)
Cache reads:  23.8M tokens, $7.14 (26%)
Output:       346K tokens, $5.20
Input:        120K tokens, $0.36

웹 앱이 14명한테 응답하는 비용은 절대 이렇게 안 생겼다. 앞단에 캐시 쓰기가 무겁고, 뒤로 캐시 읽기가 무겁고, 실제 입출력은 거의 없는 모양은 "큰 고정 컨텍스트를 캐싱해두고 매 턴마다 다시 읽는 에이전트"의 지문이다. 댓글에서 다른 엔지니어가 정확히 짚었듯, 이 패턴은 깜짝 놀랄 모델 청구서를 만났을 때 CloudTrail을 한 줄씩 읽기 전에 던질 수 있는 좋은 첫 가설이다.

3. 실무 관점: 조기 감지와 흔한 함정

(1) AWS Budgets로 비용 경보부터 걸어라

가장 먼저 할 일은 청구서가 터지기 전에 알림을 받는 거다. 콘솔에서도 되지만, CLI로 예산을 거는 게 재현 가능해서 좋다. 아래는 월 $10 임계치에 80% 도달 시 이메일 알림을 거는 예시다.

# budget.json
{
  "BudgetName": "monthly-cost-guard",
  "BudgetLimit": { "Amount": "10", "Unit": "USD" },
  "TimeUnit": "MONTHLY",
  "BudgetType": "COST"
}

# notifications.json
[
  {
    "Notification": {
      "NotificationType": "ACTUAL",
      "ComparisonOperator": "GREATER_THAN",
      "Threshold": 80,
      "ThresholdType": "PERCENTAGE"
    },
    "Subscribers": [
      { "SubscriptionType": "EMAIL", "Address": "you@example.com" }
    ]
  }
]
$ aws budgets create-budget \
    --account-id 123456789012 \
    --budget file://budget.json \
    --notifications-with-subscribers file://notifications.json

# (성공 시 출력 없음 — exit code 0)
$ echo $?
0

여기서 흔한 함정 하나. 권한이 부족하면 이런 에러를 만난다:

An error occurred (AccessDeniedException) when calling the CreateBudget operation:
User: arn:aws:iam::123456789012:user/devops is not authorized to perform:
budgets:CreateBudget on resource: arn:aws:budgets::123456789012:budget/monthly-cost-guard

이 경우 IAM 정책에 budgets:CreateBudget, budgets:ModifyBudget 액션이 필요하다. 그리고 Budgets는 글로벌 서비스라 리전을 us-east-1 기준으로 다루는 게 안전하다(공식 문서 확인 권장).

(2) Cost Explorer로 "무엇이" 쓰는지 분해하기

경보가 울렸으면 다음은 분해다. 글쓴이가 Amazon Q로 했던 일을 CLI로 직접 하면 이렇다. 서비스별 일자별 비용을 뽑아보자.

$ aws ce get-cost-and-usage \
    --time-period Start=2024-05-01,End=2024-06-01 \
    --granularity DAILY \
    --metrics "UnblendedCost" \
    --group-by Type=DIMENSION,Key=SERVICE \
    --query 'ResultsByTime[].{date:TimePeriod.Start, groups:Groups[?Metrics.UnblendedCost.Amount > `0.5`].[Keys[0],Metrics.UnblendedCost.Amount]}'

출력 예시(특정 이틀에 비용이 몰린 모양을 보고 싶을 때):

[
  {
    "date": "2024-05-24",
    "groups": [
      ["Amazon Bedrock", "11.20"],
      ["Amazon Elastic Compute Cloud - Compute", "0.21"]
    ]
  },
  {
    "date": "2024-05-25",
    "groups": [
      ["Amazon Bedrock", "8.40"]
    ]
  }
]

여기서 핵심 교훈. Cost Explorer가 깨끗한 데이터를 줘도, 그 위에 얹는 "이야기"는 틀릴 수 있다. 원문에서 Amazon Q는 정확한 증거(역할명, 인스턴스, 타임스탬프, 모델)를 가져왔지만 세 번이나 잘못된 범인을 지목했다. "앱이 비싸다 → 앱 로그를 봐라" 같은 그럴듯한 서사를 붙인 거다. 데이터는 문제가 아니었고, 데이터 위의 내러티브가 문제였다.

그래서 글쓴이의 룰: "Trust retrieval, verify recall." 도구가 가져온 것(retrieve)은 믿되, 도구가 기억하는 것(recall)은 검증하라. 같은 Q가 Haiku 가격을 실제의 1/4로 잘못 인용하기도 했다. 모델명, 단가 같은 "외운 값"은 항상 코드와 IAM 정책으로 대조해야 한다.

(3) 흔한 함정 모음

  • 비용 추적 도구의 모델명/단가를 믿어버린다. 원문처럼 "Sonnet이 비싸니까 범인이다" 하고 종결하면 틀린다. IAM 정책이 Nova ARN으로만 스코프돼 있으면 Sonnet 호출은 AccessDenied로 막히니, 그 함수는 물리적으로 Sonnet을 청구할 수 없다. 코드와 정책이 최종 증거다.
  • 애널리틱스 방문자 수와 인프라 비용을 엮는다. 둘은 별개 시스템이다.
  • 조용한 비용을 놓친다. 토큰 폭주는 시끄럽고 자기 한정적이다(노트북 닫으면 끝남). 진짜 무서운 건 항상 켜진 t3.micro처럼 시간당 과금되는 리소스다. 싸고 잊혀진 게 표준 비용으로 슬금슬금 쌓인다. 댓글의 로그 보존 비용($50/2일)도 같은 부류다.
  • 리전을 잘못 골라서 데이터 전송/빌드 비용을 더 낸다. 댓글 사례처럼 US Central이 아닌 곳에서 빌드하다 추가 과금이 붙기도 한다.

(4) 대안과 트레이드오프

모델 비용을 프로젝트별로 추적하고 싶다면 Application Inference Profile을 설정해서 태그가 붙는 프로파일을 호출하는 방식이 있다. 다만 설정 부담이 늘고, 호출 코드를 프로파일 ARN으로 바꿔야 한다. 작은 사이드 프로젝트라면 차라리 프로젝트별로 AWS 계정을 분리(또는 Organizations + 계정 분리)하는 게 비용 귀속을 가장 깔끔하게 만든다. 비용이 계정 경계에서 자연스럽게 갈리기 때문이다.

4. 사이드 프로젝트용 비용 방어 — 배포 전 체크리스트

원문 교훈을 인프라 체크리스트로 압축하면 이렇다.

  • AWS Budgets 경보부터 건다. 임계치 50%/80%/100% 세 단계. 청구서보다 알림이 먼저 와야 한다.
  • "항상 켜진" 리소스 목록을 안다. EC2, NAT Gateway, RDS, ElastiCache는 트래픽 0이어도 돈을 먹는다. 안 쓰면 끈다.
  • 로그 보존 기간과 verbosity를 점검한다. 디버깅용으로 켠 verbose 로그가 청구서의 주범이 되는 게 흔하다.
  • IAM 정책으로 모델/리소스를 스코프한다. 비싼 모델 ARN 접근을 아예 막아두면, 사고로라도 비싼 호출이 안 나간다(원문의 Nova-only 스코핑).
  • 비용이 어느 프로젝트에 귀속되는지 미리 정한다. 계정 분리 또는 Inference Profile/태깅 전략. "프로젝트 없음" 버킷을 줄여라.
  • 비용 도구의 결론을 코드/정책/CloudTrail로 교차 검증한다. 첫 번째 확신에 찬 문장을 받아들이지 마라.

5. 정리: 한 줄 요약과 적용 대상

한 줄 요약: AWS 비용 폭탄의 진짜 어려움은 "얼마"가 아니라 "누가 왜 썼는지를 증거로 못 박는 것"이다. 대시보드도, 비용 추적 AI도 깨끗한 데이터 위에 틀린 이야기를 얹을 수 있으니, 가져온 것은 믿되 기억한 것은 검증하라.

누가 언제 써야 하나:

  • 사이드 프로젝트/스타트업을 AWS에 굴리는 개인 개발자 — 배포 전에 Budgets 경보와 항상 켜진 리소스 목록부터 챙겨라.
  • Bedrock 등 모델 API를 여러 프로젝트에서 공유하는 팀 — 지금 비용 귀속 전략(계정 분리 or Inference Profile)을 정해라. "프로젝트 없음" 버킷은 사고의 온상이다.
  • 이미 청구서가 튄 사람 — Cost Explorer로 비용의 모양(캐시 쓰기/읽기/입출력 비율)부터 보고, 그게 앱 트래픽 모양인지 에이전트 모양인지 판단해라.

마지막으로 원문 글쓴이의 결론이 인상적이다. 범인은 바이럴이 아니라 "EC2로 만든 트렌치코트를 입고 다음 프로젝트를 만들던 나 자신"이었다고. 비용 추적의 끝은 대개 거울 속에 있다.

참고 자료

728x90
728x90

Hacker News에 Bunny DNS가 무료화됐다는 글이 올라왔다. "또 무료 떡밥이냐" 싶을 수 있는데, DNS는 인프라 비용에서 의외로 뒤통수 맞기 쉬운 영역이라 한 번 짚고 갈 만하다. 5년차 굴린 입장에서 실무 관점으로 풀어본다.

1. 도입: 왜 지금 화제이고 어떤 문제를 푸는지

DNS 비용은 평소엔 존재감이 없다. 월 청구서 보면 CDN, 컴퓨트, 스토리지가 압도적이고 DNS는 몇 달러 수준이라 신경도 안 쓴다. 문제는 트래픽이 튀거나 공격받을 때 터진다.

AWS Route53을 예로 들면 쿼리당 과금이다. 공식 요금 기준으로 표준 쿼리는 100만 건당 $0.40 (첫 10억 쿼리까지). 평소엔 푼돈인데, 봇이나 DDoS성 DNS 증폭 쿼리가 들어오면 이 숫자가 갑자기 뛴다. 실제로 잘못 설정한 헬스체크나 짧은 TTL 때문에 쿼리량이 폭증해서 청구서 보고 놀라는 케이스가 있다.

Bunny가 이번에 한 건 DNS 쿼리 과금 자체를 없앤 것이다. 원문 기준 계정당 500개 도메인까지 무료 호스팅, 쿼리 제한·건당 과금 없음, smart record와 헬스 모니터링도 포함. 단 bunny.net 공통 정책인 월 $1 최소 사용료는 적용된다(DNS 자체엔 사용량 과금이 없을 뿐).

요금 구조를 거칠게 정리하면 이렇다. (수치는 각 사 공식 요금이 시점에 따라 바뀌니 직접 확인 필요)

서비스호스팅 비용쿼리 과금비고
Route53호스팅 존당 월 $0.50쿼리당 과금 있음존·쿼리 많아지면 누적
Cloudflare무료 플랜 존재기본 무료고급 기능은 유료 플랜
Bunny DNS500도메인까지 무료없음(쿼리 과금 폐지)계정당 월 $1 최소 사용료

핵심은 "쿼리 폭증 = 비용 폭증"이라는 불안 요소를 제거했다는 점이다. 비용 예측 가능성은 인프라 운영에서 생각보다 큰 가치다.

2. 핵심: Anycast와 스마트 라우팅, 어떻게 동작하는가

Bunny DNS의 출발점은 자기네 CDN을 위한 내부 라우팅 엔진이었다고 한다. 원문 표현대로 "단순 레코드 조회 테이블을 글로벌 분산 스마트 라우팅 엔진으로 업그레이드"한 것이 핵심이다.

Anycast가 뭐길래 빠른가

비유하자면 이렇다. 일반 Unicast는 "서울 본사 전화번호 하나"라서 부산 사람도 서울로 전화해야 한다. Anycast는 같은 IP(전화번호)를 전 세계 119개 지점이 동시에 광고해서, 네트워크가 알아서 "가장 가까운 지점"으로 연결해준다. 부산 사람은 부산 지점이 받는다.

실제로 Anycast가 도는지는 같은 도메인의 권한 네임서버를 다른 지역에서 traceroute 떠보면 경로가 달라지는 걸로 간접 확인할 수 있다. dig로 응답 지연을 보는 게 가장 간단하다.

$ dig @8.8.8.8 example.com A +stats

;; ANSWER SECTION:
example.com.		300	IN	A	93.184.216.34

;; Query time: 12 msec
;; SERVER: 8.8.8.8#53(8.8.8.8)
;; WHEN: Tue Jun 24 14:03:21 KST 2026
;; MSG SIZE  rcvd: 56

여기서 Query time이 권한 서버까지 포함한 응답 시간이다. Anycast가 잘 깔린 DNS는 한국에서 쏴도 보통 한 자릿수~수십 ms 안에 들어온다. 캐시 미스 상태에서 권한 서버를 직접 때려봐야 진짜 레이턴시가 나온다.

Smart record / 헬스체크

원문에서 강조하는 건 latency 데이터, 헬스체크, 심지어 JavaScript로 응답을 동적 결정한다는 점이다. 쉽게 말해 "이 사용자가 어디서 왔고 어느 오리진이 살아있는지 보고 A 레코드를 즉석에서 골라준다"는 거다. Route53의 Latency-based routing + Health check 조합과 개념적으로 비슷한데, Bunny는 이걸 무료 티어에 포함시켰다는 게 차별점이다.

3. 실무 관점: 마이그레이션, 트레이드오프, 흔한 함정

마이그레이션 체크리스트

DNS 이전은 잘못하면 도메인 전체가 죽는 작업이다. 순서가 중요하다.

  1. 현재 존 레코드 전량 백업 — 이게 1순위다.
  2. 새 DNS(Bunny)에 동일 레코드 전부 입력 (Bunny는 자동 존 스캔 + BIND 파일 업로드 지원)
  3. 이전 전에 양쪽 응답이 일치하는지 검증
  4. 이전 며칠 전부터 SOA/NS의 TTL을 짧게 낮춰두기
  5. 레지스트라에서 네임서버 변경
  6. 전 세계 전파 모니터링 (보통 수십 분~48시간)

기존 존을 통째로 뽑는 가장 확실한 방법은 AXFR(존 전송)이지만, 대부분 매니지드 DNS는 보안상 AXFR을 막아둔다. 막혀있으면 콘솔에서 BIND 파일 export를 쓰거나, 주요 레코드를 직접 dig로 긁어야 한다.

# 기존 네임서버에서 존 전체를 BIND 형식으로 받아보기 (AXFR 허용 시)
$ dig @ns-old.example.com example.com AXFR > zone_backup.txt

# AXFR이 막혀있으면 이런 에러를 만난다 ↓
$ dig @ns-old.example.com example.com AXFR

; <<>> DiG 9.18.18 <<>> @ns-old.example.com example.com AXFR
;; global options: +cmd
; Transfer failed.

; Transfer failed.가 떴다면 AXFR이 거부된 거다. 당황하지 말고 콘솔 export로 우회하면 된다.

흔한 함정 1: 네임서버를 바꿨는데 안 바뀐다

가장 자주 보는 증상. 레지스트라에서 NS를 바꿨는데 한참 옛날 IP가 돌아온다. 이건 보통 리졸버 캐시거나, 존 안에 박혀있는 NS 레코드레지스트라(상위 위임)의 NS 레코드가 불일치할 때 생긴다. 두 곳을 다 맞춰야 한다.

위임이 제대로 됐는지는 상위(부모) 존에 직접 물어봐야 한다.

# .com 권한 서버에 위임 정보를 직접 물어보기
$ dig +trace example.com NS

example.com.		172800	IN	NS	ns1.bunny.net.
example.com.		172800	IN	NS	ns2.bunny.net.
;; Received 100 bytes from 192.5.6.30#53(a.gtld-servers.net) in 145 ms

여기서 a.gtld-servers.net(상위 서버)이 알려주는 NS가 Bunny 것으로 나오면 위임은 성공한 거다. 그래도 내 PC에서 옛날 값이 나온다면 십중팔구 로컬/사내 리졸버 캐시다.

흔한 함정 2: DNSSEC 켜고 SERVFAIL

DNSSEC은 켜는 순간 사고가 잘 난다. 가장 흔한 건 DS 레코드와 DNSKEY 불일치다. DNS 사업자를 옮기면서 한쪽엔 DNSSEC이 켜져있고 DS는 옛날 키를 가리키면 전체 도메인이 검증 실패로 죽는다.

$ dig example.com A +dnssec

;; ->>HEADER<<- opcode: QUERY, status: SERVFAIL, id: 41552
;; flags: qr rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 0, ADDITIONAL: 1

status: SERVFAIL이 DNSSEC 체인 깨졌을 때 전형적으로 보는 화면이다. DNS 사업자 마이그레이션 시에는 옮기기 전에 기존 쪽 DNSSEC을 끄고(레지스트라의 DS도 제거), TTL 기다린 뒤 옮기고, 새 쪽에서 다시 켜는 순서가 안전하다.

참고로 Bunny는 DNSSEC을 NSEC Black Lies 방식으로 구현했다고 한다. 전통 NSEC은 존 전체 레코드를 추측(zone walking)당할 수 있는데, Black Lies는 그걸 막으면서 검증은 유지한다. 보안에 민감한 조직이면 의미 있는 디테일이다.

트레이드오프: 무료의 대가

  • SLA 보장 수준 — DNS는 다운되면 서비스 전체가 죽는다. 무료 티어에 명시적 가용성 SLA가 어떻게 걸리는지는 계약서/약관 직접 확인 필요. "무료니까 보장 안 해도 할 말 없다"는 리스크는 항상 있다.
  • 벤더 락인 — 1-Click Acceleration으로 DNS에서 CDN을 바로 켜는 구조는 편한 만큼 Bunny 생태계에 묶인다. CDN까지 같이 쓸 거면 장점, DNS만 떼서 쓸 거면 굳이일 수 있다.
  • 지원 — 장애 났을 때 응답 속도. 미션 크리티컬이면 유료 엔터프라이즈 지원이 깔린 곳이 마음 편하다.

대안은 명확하다. AWS에 다 몰빵돼 있으면 Route53이 통합 면에서 편하고, 글로벌 무료 + 안정성 실적이면 Cloudflare가 검증돼 있다. Bunny는 CDN을 Bunny로 쓰거나, 쿼리 과금 불안에서 벗어나고 싶은 경우에 매력적이다.

TTL 설계 한 줄 팁

평상시엔 A/AAAA TTL을 300~3600초로 적당히 길게(쿼리 줄여 안정·비용↓), 마이그레이션이나 페일오버 직전 며칠은 60~300초로 낮춰서 빠른 전파를 확보하는 게 정석이다. Bunny는 쿼리 과금이 없으니 TTL을 짧게 가져가는 부담이 상대적으로 적다는 게 이번 변화의 실무적 의미다.

4. 정리: 누가 언제 써야 하나

한 줄 요약: Bunny DNS 무료화는 "쿼리 폭증 = 비용 폭증" 공포를 없앤 변화이고, CDN까지 Bunny로 묶을 거면 특히 합이 좋다.

  • 쓰면 좋은 경우: Bunny CDN/Shield를 이미 쓰거나 검토 중, 트래픽 변동성이 커서 쿼리 과금이 부담, 짧은 TTL로 빠른 페일오버가 필요한 스타트업~스케일업.
  • 굳이 안 옮겨도 되는 경우: AWS에 모든 게 묶여 Route53 통합이 더 가치 있을 때, 이미 Cloudflare로 잘 돌고 있고 옮길 이유가 없을 때, 계약상 명시적 DNS SLA가 반드시 필요한 금융/엔터프라이즈.

결론적으로 "무료니까 일단 테스트 존 하나 올려서 dig로 응답 시간 재보고 판단하라"가 현실적인 답이다. 메인 도메인부터 옮기지 말고, 안 중요한 도메인으로 먼저 굴려보자.

참고 자료

※ 본문의 요금·수치는 각 사 정책 변경에 따라 달라질 수 있으니 도입 전 공식 페이지에서 반드시 재확인하세요.

728x90
728x90

며칠 전 Claude Code 세션 로그를 감사 추적용으로 긁어 모으는 작업을 하다가 좀 황당한 걸 발견했다. thinking block에 분명히 추론 내용이 들어 있을 줄 알았는데, 까보니 사람이 읽을 수 없는 600자짜리 base64스러운 문자열만 덩그러니 박혀 있더라. "ctrl+o로 보던 그 사고 과정은 어디 갔지?" 싶었는데, 알고 보니 그건 진짜 추론이 아니라 요약이었다.

이게 단순한 트리비아가 아닌 이유는, AI 코딩 도구를 컴플라이언스 환경에서 굴리는 사람한테는 "에이전트가 왜 이 코드를 짰는지 재현 가능한가?"라는 질문에 직결되기 때문이다. 오늘은 그 얘기를 실무 관점에서 풀어본다.

1. 왜 지금 이게 화제인가

Claude Code는 세션을 디스크에 JSON 형태로 차곡차곡 기록한다. 그래서 많은 사람들이 자연스럽게 "이걸 감사 로그로 쓰면 되겠네"라고 생각한다. 코드 리뷰에서 "이 변경 왜 했어?"라는 질문이 나왔을 때, 에이전트의 사고 과정을 그대로 보여주면 깔끔하니까.

그런데 발단이 된 글(patrickmccanna.net)의 저자가 로컬 로그를 직접 까봤더니, thinking block 안에 있던 건 실제 추론 텍스트가 아니라 600자 길이의 signature였다. Anthropic 공식 문서를 따라가 보면 구조가 이렇다:

  • Claude는 추론을 signature 안에 암호화한다.
  • 복호화 키는 Anthropic이 보유한다.
  • 사용자 기기는 키를 받지 못한다.
  • API가 돌려주는 건 추론 원문이 아니라 reasoning summary(요약)다.
  • 전체 thinking output을 받으려면 enterprise agreement가 필요하다.

즉, ctrl+o로 보던 그 예쁜 사고 과정은 모델이 실제로 행동을 구동한 추론 그 자체가 아니라, 그걸 압축·요약한 결과물이다. 그리고 압축 과정에서 정보 손실이 일어난다.

2. 동작 원리 — 직접 까보자

백문이 불여일견이니 실제 세션 로그를 한번 들여다보자. Claude Code 세션은 보통 홈 디렉터리 아래 프로젝트별로 떨어진다.

# 세션 로그 위치 확인 (환경에 따라 경로 다를 수 있음, 확인 필요)
$ ls -la ~/.claude/projects/

drwxr-xr-x  3 user staff   96 Feb 10 14:22 -Users-user-work-myapp

해당 디렉터리의 jsonl 파일에서 thinking block만 추려보자. jq로 type이 thinking인 것만 필터링한다.

# thinking 타입 메시지만 추출해서 signature 길이 확인
$ cat ~/.claude/projects/-Users-user-work-myapp/*.jsonl \
  | jq -r 'select(.message.content[]?.type == "thinking")
           | .message.content[]
           | select(.type == "thinking")
           | {has_thinking: (.thinking | length), sig_len: (.signature | length)}'

{
  "has_thinking": 142,
  "sig_len": 612
}

여기서 핵심은 thinking 필드에 든 142자는 요약된 사고 과정이고, signature 필드의 612자가 실제 암호화된 원문이라는 점이다. signature를 그대로 출력해보면 이런 식이다:

$ cat *.jsonl | jq -r 'select(.message.content[]?.type=="thinking")
  | .message.content[] | select(.type=="thinking") | .signature' | head -c 200

EuYBCkYIBxgCKkBxJ9... (이하 사람이 읽을 수 없는 인코딩 문자열)

이걸 base64로 디코딩해봐야 의미 있는 텍스트가 안 나온다. Anthropic의 키로 암호화돼 있으니 당연한 결과다.

jpeg 비유는 사실 거꾸로다

원문에서는 이 과정을 "jpeg를 bmp로 저장했다가 다시 jpeg로 내보내는 것 같은 데이터 손실"에 비유했는데, HN 댓글에서 정확히 지적했듯 비유 방향이 반대다. bmp는 무손실, jpeg가 손실 포맷이니까. 핵심만 가져가자면 "요약 과정에서 비가역적인 정보 손실이 일어난다"는 것이다. 요약본을 아무리 들여다봐도 원본 추론을 복원할 수 없다.

그리고 이건 Anthropic만의 문제가 아니다. HN 댓글에서 여러 사람이 짚었듯 OpenAI, Google도 동일하게 원시 추론(raw CoT)을 숨긴다. 이유는 대체로 두 가지로 정리된다: (1) 증류(distillation) 방지 — 경쟁사가 사고 사슬을 학습 데이터로 가져가 모델을 복제하는 걸 막으려는 것, (2) 정렬되지 않은 중간 출력 노출 방지. 한 댓글은 "1~2월쯤 변경됐고 이유가 명시적으로 증류 방지였다"고 회고했다(버전·시점은 댓글 회고라 공식 확인 필요).

3. 실무 관점 — 도입 전에 반드시 짚을 것

감사 로그로 쓸 거면 전제부터 바꿔라

가장 중요한 결론부터. Claude Code 세션 파일만으로는 에이전트의 실제 논리를 재현할 수 없다. 감사 추적이 필요한 환경(금융, 의료, 공공)에서 "AI가 이 결정을 내린 근거"를 제출해야 한다면, 로컬 로그에 있는 thinking 요약은 법적/규제적으로 "근거"가 되기 어렵다. 실제 추론은 암호화돼 있고 키는 당신에게 없으니까.

그래서 감사가 필요하면 추론 자체가 아니라 관찰 가능한 행동을 별도로 로깅해야 한다. 입력, 출력, 실제 파일 변경, 실행된 명령어 같은 것들. 예를 들어 git을 활용해 에이전트가 만든 모든 변경을 강제로 추적하는 식이다:

# 에이전트 작업 전후를 별도 브랜치/커밋으로 강제 스냅샷
$ git add -A && git commit -m "pre-agent snapshot" --allow-empty

# Claude Code 실행 후
$ git diff HEAD~1 --stat

 src/auth/login.ts | 23 +++++++++++++----------
 src/config/db.ts  |  8 ++++----
 2 files changed, 18 insertions(+), 13 deletions(-)

추론은 못 가져와도 "무엇을 바꿨는가"는 이렇게 100% 재현 가능하게 남길 수 있다. 감사 관점에서는 사실 이게 추론 텍스트보다 더 확실한 증거다.

흔한 함정 — signature 재사용 에러

API를 직접 다루면서 thinking block을 멀티턴 대화에 그대로 끼워 넣을 때 자주 만나는 함정이 있다. signature 검증에 실패하는 케이스다. 모델 버전이 바뀌었거나, thinking 텍스트를 임의로 잘라서 보내거나, signature를 누락하면 이런 에러가 난다:

{
  "type": "error",
  "error": {
    "type": "invalid_request_error",
    "message": "messages.0.content.0.thinking: Input tag 'thinking' found using 'type'
                does not match any of the expected signatures, or the thinking block
                signature could not be verified."
  }
}

이게 뜨는 전형적인 이유:

  • thinking block의 text만 보내고 signature 필드를 빼먹은 경우. 둘은 세트라 같이 보내야 한다.
  • thinking 내용을 후처리(트리밍, 마스킹)해서 보내면 signature와 불일치해 검증 실패.
  • 이전 턴을 다른 모델 버전으로 생성한 뒤 현재 턴에서 다른 버전으로 이어붙인 경우.

핵심 교훈: thinking block은 당신이 편집할 수 있는 데이터가 아니다. 받은 그대로 보존해서 다음 턴에 돌려줘야만 한다. 로그를 가공·익명화하려다 이 에러를 만나는 경우가 의외로 많다.

보안 관점 — 숨겨진 추론의 리스크

HN 댓글에서 나온 우려 중 실무적으로 새겨들을 만한 게 있다. 프롬프트 주입으로 추론 사슬에 비밀 목표를 심으면, 그게 요약과 최종 출력에서는 가려질 수 있다는 점이다. 사용자에게 보이는 요약은 멀쩡한데 내부 추론에선 다른 의도가 작동할 가능성을 검증할 방법이 없다.

다만 같은 댓글 스레드에서 반론도 나왔다. "도구 호출(tool call)은 어차피 클라이언트가 실행해야 하니 숨겨질 수 없다"는 것. 즉 추론은 가려져도 실제 실행되는 명령은 클라이언트에 노출된다. 그래서 도구 호출 레벨에서 화이트리스트/감사를 거는 게 현실적인 방어선이다. 추론을 못 보는 대신, 실행 권한을 좁히는 쪽으로 가는 게 맞다.

대안 — 신뢰 모델이 다른 선택지

  • 요약 제한이 없는 모델 선택: 원문 댓글에 따르면 Sonnet 계열은 이 제한이 없다는 언급이 있다(공식 확인 필요). 워크로드에 따라 모델을 나눠 쓰는 것도 방법이다.
  • 명시적 CoT 프롬프트로 우회: thinking 기능을 끄고 일반 프롬프트 안에 "답하기 전에 단계별로 생각해라"를 넣으면 GPT-3 시절처럼 사고 과정이 본문에 그대로 노출된다는 회고가 있다. 추론을 본문 텍스트로 받으니 로깅·감사가 쉬워진다. 대신 모델 품질과 토큰 비용은 트레이드오프다.
  • 오픈 가중치 모델: DeepSeek, GLM 등은 사고 사슬이 그대로 노출된다(읽으면 "이게 무슨 소리야" 싶은 경우도 많지만). 데이터 주권이 절대 우선인 온프레미스 환경이면 고려 대상이다.
  • enterprise agreement: 전체 thinking output이 정말 필요하면 결국 이 길이다. 비용·계약 협상이 필요하다.

4. 정리

한 줄 요약: Claude Code의 thinking block에 보이는 건 암호화된 추론의 "요약본"일 뿐, 모델 행동을 실제로 구동한 추론 원문이 아니다. 키는 Anthropic에 있고 당신에겐 없다.

누가 언제 신경 써야 하나:

  • 컴플라이언스/감사가 필요한 팀: 추론 텍스트를 증거로 쓸 생각 버리고, git diff·명령 실행 로그 같은 관찰 가능한 행동을 별도로 강제 로깅하라.
  • API를 직접 통합하는 백엔드: thinking block은 절대 가공하지 말고 받은 그대로 보존·재전송하라. 안 그러면 signature 검증 에러를 만난다.
  • 온프레미스/데이터 주권 우선 환경: 추론이 외부 키로 암호화돼 재현 불가라는 점을 도입 결정 전에 명확히 인지하고, 필요하면 오픈 가중치 모델을 저울질하라.

일반적인 사이드 프로젝트나 사내 도구 수준이면 솔직히 크게 문제될 일은 아니다. 하지만 "AI가 왜 그렇게 했는지를 증명해야 하는" 순간이 오는 조직이라면, 도입 전에 이 신뢰 모델을 팀과 공유해두는 게 나중에 곤란해지지 않는 길이다.

참고 자료

※ 본문의 경로, 버전, 시점 관련 일부 내용은 원문 댓글 회고나 환경별 차이가 있을 수 있어 실제 적용 전 공식 문서 확인을 권장한다.

728x90
728x90

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

+ Recent posts