728x90

인프라 하다 보면 "언어 갈아엎자"는 소리를 한 번쯤 듣게 된다. 대부분은 회의실 농담으로 끝나는데, Roc 컴파일러 팀은 진짜로 했다. Rust로 짠 30만 줄짜리 컴파일러를 Zig로 통째로 다시 썼고, 그 기록을 직접 공개했다.

이게 왜 우리 같은 인프라/백엔드 엔지니어한테 흥미로운가. 단순히 "어떤 언어가 더 좋냐" 싸움이 아니라, 대규모 코드베이스를 재작성할 때의 판단 근거, 빌드 타임과 메모리 할당 전략, 재현 가능한 크로스 컴파일 같은 인프라 실무와 직결되는 결정들이 잔뜩 들어 있어서다. 최근 Bun 팀이 반대 방향(Zig → Rust)으로 옮긴 회고를 낸 시점과 맞물려서 더 화제가 됐다.

참고로 하나 짚고 가자. Bun은 약 50만 줄을 11일 만에 옮겼고, Roc은 30만 줄을 487일 걸렸다. 얼핏 보면 Roc이 40배 느린 것 같지만, Bun은 거의 그대로 옮기는 직역(port)이었고 Roc은 아키텍처 자체를 갈아엎는 재작성(rewrite)이었다. 성격이 완전히 다른 작업이라 이 숫자를 그대로 비교하면 안 된다. 원문 저자도 이 점을 분명히 못박는다.

1. 왜 Rust를 버리고 Zig를 선택했나: 구조적 결함의 실체

먼저 오해 방지. 저자(Richard Feldman)는 Rust 강사 출신이고 지금도 Zed에서 매일 Rust를 쓴다. Rust 안티가 아니다. 핵심 메시지는 이거다. "한 프로젝트에 맞는 언어가 다른 프로젝트엔 안 맞을 수 있다. 만능은 없다."

그럼 왜 갈아엎기로 했나. 재작성 결정의 방아쇠는 언어가 아니라 아키텍처 결함이었다. Roc은 람다 셋 특수화(lambda set specialization)를 통한 다형적 디펑셔널라이제이션(polymorphic defunctionalization)이라는 최적화를 쓴다. 쉽게 말하면 함수형 언어에서 클로저 캡처가 힙 할당을 안 하게 만드는 기법인데, 함수형 언어에서 이건 인라이닝처럼 후속 최적화를 줄줄이 터뜨리는 핵심이다.

문제는 이 시스템이 여러 컴파일러 단계에 걸쳐 있어서 버그가 계속 터졌다는 거다. Ayaz Hafiz가 OCaml로 새 아키텍처를 프로토타이핑하고 나서야 원인을 찾았는데, 결론이 "이거 고치려면 컴파일러 대부분을 다시 써야 함"이었다. 마침 여러 기여자가 각자 다른 이유로 컴파일러 여기저기를 다시 쓸 계획이었고, "어차피 거의 다 다시 쓸 거면 통째로 하자"가 된 거다.

여기서 실무 포인트 하나. 컴파일러는 스크래치 재작성이 정상인 특이한 도메인이다. 대부분의 메인스트림 컴파일러가 언젠가 한 번씩 밑바닥부터 다시 썼다(TypeScript가 Go로 옮긴 것도 그 사례). 하지만 여러분의 마이크로서비스는 컴파일러가 아니다. 이 "재작성 정상론"을 일반 백엔드 서비스에 그대로 적용하면 큰일 난다. 이건 뒤에서 다시 짚는다.

언어를 Zig로 정한 이유는 네 가지로 정리된다.

  • 빌드 타임. cargo build가 코드베이스 커질수록 증분 빌드조차 아팠다. 이게 실무에서 진짜 크다. 컴파일러 개발은 빌드-테스트 루프를 하루에 수백 번 도는데 여기가 막히면 생산성이 통째로 죽는다.
  • 메모리 제어. 이들은 컴파일 전 과정에서 여러 allocator(특히 arena)와 struct-of-arrays 레이아웃을 쓴다. Rust 생태계는 대체로 단일 글로벌 allocator를 가정하는데(soa_rs조차), Zig 생태계는 세분화된 allocator가 기본 전제다.
  • 생태계 적합성. Rust 생태계가 전체적으로 훨씬 크지만, 이들이 원한 니치한 것들(LLVM C++ 라이브러리 래핑 없이 비트코드 뽑기 같은)은 Zig 쪽에 더 있었다.
  • 메모리 언세이프 코드 지원. 이 부분이 핵심이다. 아래에서 별도로 본다.

Rust는 unsafe 코드를 드물게 격리시키고 miri나 Valgrind로 검증하라는 철학이다. 그런데 기계어를 뽑아내는 컴파일러는 메모리 언세이프한 짓이 업무의 본질이다. Roc의 Rust 코드 30만 줄 중 unsafe가 약 1,200군데였다. 비교하자면 rustc는 350만 줄에 unsafe가 약 4만 군데. Roc 입장에선 unsafe가 "드문 예외"가 아니라 일상이었고, 그 영역에서 Zig가 더 많은 도움을 준다고 판단한 거다.

2. 30만 줄 재작성 전략과 동작 원리

재작성 전략을 얘기할 때 항상 나오는 게 빅뱅 vs 점진적(Ship of Theseus, 부분 교체) 전환이다. Roc 팀은 결국 빅뱅에 가까운 스크래치 재작성을 택했다. 이유는 위에서 봤듯 결함이 아키텍처 전반에 퍼져 있어서 점진 교체로는 해결이 안 됐기 때문이다.

이 선택이 실제로 뭘 가져왔는지, 새 컴파일러가 뽑아낸 결과물로 보자. WASM-4 게임 Rocci Bird를 새 컴파일러로 빌드했더니:

# 사이즈 최적화 빌드
roc build --opt=size

# 결과: 31KB wasm 바이너리
# (기존 Rust 컴파일러는 두 배 이상 크기였다)

같은 게임 소스인데 바이너리가 절반 이하로 줄었다. 이게 재작성으로 얻은 구체적 성과 중 하나다. 다만 원문 기준으로 명확히 하면, 이건 피처 패리티(기존 기능 동등성) 달성 마일스톤이지 정식 릴리스가 아니다. 0.1.0은 그해 말 목표로 잡혀 있다.

여기서 대규모 재작성의 첫 번째 교훈. "피처 패리티"를 마일스톤으로 명확히 정의하라. 30만 줄짜리를 다시 쓸 때 "다 됐다"의 기준이 모호하면 끝이 안 난다. 이 팀은 "1000줄 미만 게임 하나가 새 컴파일러로 돌아간다"는 아주 구체적인 통과 기준을 잡았다. 작지만 컴파일러의 여러 기능을 실제로 요구하는 실전 예제를 게이트로 쓴 거다. 이건 우리 실무에서 마이그레이션할 때도 그대로 써먹을 수 있는 패턴이다. 추상적 완성도 대신 "이 대표 워크로드가 신 시스템에서 돈다"를 게이트로 삼는 것.

3. Zig가 가져다준 것들: 실제 동작 예시

핫 코드 로딩 + 크로스 컴파일

새 컴파일러의 킬러 피처는 개발 중 핫 코드 로딩이다. 서버를 띄운 채로 코드를 바꾸면, 다음 요청부터 자동으로 새 코드로 처리된다.

# 서버 실행
roc server.roc

# 이 상태에서 소스를 수정하고 저장하면
# 다음 요청부터 새 코드로 처리됨

Python 같은 인터프리터 언어에선 당연한 거지만, LLVM 최적화 바이너리를 뽑는 고성능 컴파일 언어에서 이걸 지원하는 건 흔치 않다. 배포할 땐 그냥 최적화된 자립 바이너리를 뽑는다.

인프라 관점에서 진짜 눈에 들어오는 건 재현 가능한 크로스 컴파일이다.

# Alpine Linux용 정적 바이너리 빌드
roc build --target=x64musl

# 핵심: 같은 소스 바이트 → 같은 출력 바이트
# Mac에서 돌리든 다른 시스템에서 돌리든 동일한 결과물

이게 왜 중요한가. CI/CD에서 빌드 재현성(reproducible build)은 공급망 보안과 캐시 효율의 핵심이다. "내 로컬에선 되는데 CI에선 다른 바이너리가 나온다" 같은 지옥을 겪어봤으면 안다. 원문도 "모든 컴파일러가 이걸 보장하진 않는다"고 콕 집는다. 크로스 컴파일이 이렇게 한 줄 플래그로 되는 건 Zig 툴체인의 강점을 그대로 물려받은 부분으로 보인다.

패턴 매칭 안의 문자열 보간

새로 들어간 언어 기능 중 인상적인 게 패턴 매칭 안에서의 문자열 보간이다. HTTP 라우팅 코드를 보자.

match (verb, path) {
    ("GET", "/users/${id}/${page}") = match page {
        "" | "profile" = ok(id)
        "settings"     = ok(with_default(user_agent, id))
        "posts/${post_id}" = ok("Post ID: ${post_id}")
        _ = not_found
    }
    ("GET", "/users/${id}") = ok(id)
    ("POST", "/posts/new")  = created(with_default(…))
    _ = not_found
}

여기서 "/users/${id}"는 런타임에 템플릿 문자열을 파싱하는 게 아니다. 컴파일 타임에 타입 안전하게 검사되는 언어 기능이고, 이 스니펫 전체가 힙 할당 제로다. 저자 말로는 핫 코드 로딩 되는 보통 언어라면 이런 코드가 줄당 1회 정도는 할당할 거라는데, Roc은 순수 함수의 컴파일 타임 실행을 써서 라우팅을 제로 할당으로 만들었다.

백엔드 하는 사람 입장에서 라우팅 경로 파싱이 할당 없이 타입 세이프하게 된다는 건 꽤 매력적이다. 고 트래픽 API 게이트웨이에서 요청당 할당 하나하나가 GC/RC 압력으로 쌓이는 걸 겪어봤다면 더 그렇다.

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

메모리 안전성이라는 큰 트레이드오프

가장 큰 트레이드오프는 명백하다. Zig에는 Rust의 borrow checker가 없다. 원문도 인용하는 유명한 2019 Microsoft 자료에 따르면, 매년 보안 업데이트로 다루는 취약점의 약 70%가 메모리 안전성 문제다.

Roc 팀은 이 리스크를 감수하는 대신 Zig가 제공하는 다른 안전 장치들(디버그 빌드에서의 각종 런타임 체크 등)에 기댄다. 하지만 냉정하게 보자. borrow checker가 컴파일 타임에 잡아주던 걸, 이제 런타임 체크와 테스트, 퍼저(fuzzer), Valgrind 같은 도구로 사람이 메꿔야 한다. 원문 감사 인사에도 "퍼저 에러를 체계적으로 재현하고 조사한" 기여자가 등장한다. 즉, 재작성 후에도 메모리 관련 버그 사냥은 계속되고 있다는 뜻이다.

이걸 실무로 번역하면 이렇다. Zig를 택한다는 건 "언어가 못 막아주는 부분을 팀의 규율과 CI 파이프라인(퍼징, 새니타이저, ASAN/Valgrind)으로 막겠다"는 조직적 약속이다. 팀에 그런 문화와 인프라가 없으면 Rust가 컴파일 타임에 공짜로 주던 안전망을 잃기만 한다.

흔한 함정: Zig의 use-after-free는 컴파일러가 안 막는다

Rust 쓰다 Zig 오면 가장 먼저 데는 지점이다. 아래 같은 코드는 Rust면 컴파일 자체가 안 되지만, Zig에선 컴파일이 통과하고 런타임에 터진다.

const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();

    const buf = try allocator.alloc(u8, 16);
    allocator.free(buf);
    buf[0] = 42; // 이미 free된 메모리에 접근
    std.debug.print("{d}\n", .{buf[0]});
}

디버그 빌드로 돌리면 GeneralPurposeAllocator가 이런 식으로 잡아준다:

error(gpa): Double free detected. Allocation:
error(gpa): Use after free:
thread panic: incorrect alignment
Segmentation fault at address 0x0

여기서 핵심 함정 두 가지.

  • 이건 런타임에, 그것도 그 코드 경로가 실제로 실행돼야만 잡힌다. Rust의 borrow checker는 실행 안 해도 컴파일 타임에 잡는다. 테스트 커버리지가 낮은 경로면 프로덕션에서 처음 만날 수도 있다.
  • 릴리스 빌드(-Doptimize=ReleaseFast)에선 GPA의 안전 체크가 꺼진다. 디버그에서 잘 돌던 게 릴리스에서 조용히 UB(정의되지 않은 동작)로 바뀐다. "디버그에선 되는데 릴리스에서만 이상함"의 전형적 원인이다. 그래서 CI에서 디버그/세이프 빌드로 테스트를 반드시 돌려야 한다.

또 다른 함정: 빌드 타임은 빨라도 학습 곡선과 생태계는 대가다

Zig가 빌드 빠르고 메모리 제어 좋은 건 맞지만, 공짜가 아니다.

  • Zig는 아직 1.0 이전이다. 언어와 표준 라이브러리 API가 버전 사이에 깨질 수 있다. 실제로 std` 시그니처가 바뀌어서 빌드가 안 되는 상황을 만날 수 있다(정확한 호환성 정책은 공식 문서 확인 필요). 프로덕션 인프라의 핵심 컴포넌트로 넣을 거면 이 리스크를 계산에 넣어야 한다.
  • 생태계가 작다. Roc 팀은 "우리한테 필요한 니치한 건 오히려 Zig에 더 있었다"고 했지만, 그건 그들의 특수 상황이다. 일반적인 웹/백엔드 라이브러리(ORM, HTTP 프레임워크, 인증 등)는 Rust 생태계가 압도적으로 넓다.

대안

정리하면 선택지는 대략 이렇다.

  • 메모리 안전성이 최우선이고 팀이 Rust에 익숙하다 → Rust 유지. borrow checker의 컴파일 타임 안전망은 진짜 자산이다.
  • 커스텀 allocator/arena/SoA를 극한으로 다뤄야 하고, 빌드 타임이 병목이며, 팀에 퍼징·새니타이저 문화가 있다 → Zig 검토 가치 있음.
  • 일반적인 백엔드 서비스 → 솔직히 둘 다 아닐 확률이 높다. Roc 팀도 "컴파일러라서 이런 결정이 말이 된다"고 여러 번 강조한다. 이 회고를 여러분의 CRUD API 재작성 명분으로 쓰지 마라.

5. 대규모 언어 마이그레이션, 실무 교훈 정리

한 줄 요약: 이건 "Zig가 Rust보다 좋다"는 얘기가 아니라, "특정 도메인(컴파일러)의 특정 요구(세분화된 메모리 제어, 빌드 타임, 잦은 unsafe)에는 Zig가 더 맞았다"는 케이스 스터디다.

재작성을 고민하는 실무자가 가져갈 것들:

  1. 재작성의 방아쇠는 언어가 아니라 아키텍처였다. Roc도 "언어가 싫어서"가 아니라 "결함이 여러 단계에 퍼져서 부분 수정이 불가능해서" 재작성했다. 여러분 시스템이 그 정도로 구조적으로 망가졌는지부터 냉정하게 봐라. 언어 취향은 재작성 명분이 될 수 없다.
  2. 487일이라는 숫자를 기억하라. 30만 줄 재작성은 1년 반이 걸렸고, 그것도 원저작자들이 붙어서다. 일정 산정할 때 낙관하지 마라.
  3. 피처 패리티 게이트를 구체적 워크로드로 정의하라. "다 됐다"의 기준을 실제 대표 예제가 신 시스템에서 도는 것으로 잡아라.
  4. 언어가 안 막아주는 건 파이프라인이 막아야 한다. borrow checker를 버렸으면 퍼징·새니타이저·재현 가능 빌드를 CI에 박아 넣어라. 안전은 언어 아니면 프로세스, 둘 중 하나는 반드시 담당해야 한다.
  5. 재현 가능한 크로스 컴파일은 그 자체로 인프라 자산이다. --target=x64musl 한 줄로 결정론적 정적 바이너리가 나오는 건, 컴파일러 얘기를 떠나 우리 빌드 파이프라인이 지향해야 할 이상향이다.

개인적으로 가장 와닿은 건 마지막 태도다. "만능 언어는 없다. 프로젝트마다 맞는 게 다르다." 인터넷 언어 전쟁에 시간 쓰지 말고, 우리 워크로드의 실제 병목(빌드 타임인지, 메모리 제어인지, 안전성인지, 생태계인지)을 먼저 정의한 다음 도구를 고르라는 거다. 지극히 당연한데 실무에선 자주 거꾸로 한다.

참고 자료

728x90

1. 도입: 월 $5 쓰던 계정에 $1.7B이 찍혔다

얼마 전 Hacker News에 재밌으면서도 등골 서늘한 글이 올라왔다. 평소 월 $5 쓰던 AWS 계정에 이번 달 예상 청구액이 $1.7 billion(17억 달러)으로 찍혔다는 제보다. 스크린샷 하나로 1000점 넘게 받은 걸 보면 다들 남 일 같지 않았던 모양이다.

댓글 중에 AWS 내부에서 비슷한 사고를 직접 처리해봤다는 사람(donavanm)의 설명이 핵심을 짚는다. 정리하면 이렇다.

"단위(unit) 에러였다. 원래 GB당 5센트를 매기려 했는데 단위 지정을 빼먹었고, 빌링 시스템이 기본값인 바이트(byte)로 계산해버렸다. 결과적으로 바이트당 5센트가 되어 몇 시간 만에 수백만 달러짜리 청구서가 나왔다."

여기서 우리가 실무자로서 챙겨야 할 포인트는 "AWS도 실수하네 ㅋㅋ"가 아니다. 우리가 매일 쳐다보는 Cost Explorer, Budgets 알람, 예상 청구액이 실제로 어떻게 계산되고, 어디서 틀어질 수 있는가다. 국내 많은 팀이 Budgets 알람 하나 걸어두고 "이거 울리면 대응하면 되지"라고 안심하는데, 그 알람이 참조하는 데이터 자체가 예상값(estimate)이라는 걸 잊고 있다.

2. 핵심: 예상 청구 데이터는 어떻게 만들어지는가

AWS 빌링은 대략 3단계 파이프라인으로 이해하면 편하다.

  1. Metering(계측): 각 서비스가 "이 계정이 이 리전에서 이 SKU를 얼마만큼 썼다"는 사용량 레코드를 뿜어낸다. 이때 나오는 값은 가격이 아니라 순수 사용량이다. (예: 데이터 전송 100바이트)
  2. Pricing Plan 조인: 이 계측 레코드를 account id, region, sku 등을 키로 "가격 계획"과 조인한다. 가격 계획에는 단위 타입(unit type), 리전, 단위당 가격이 정의돼 있다.
  3. 계산 및 집계: 사용량 × 단위당 가격을 곱해 라인 아이템 금액을 만들고, 이걸 계속 누적해서 "이번 달 예상 청구액"을 갱신한다.

이번 사고는 2번에서 터졌다. 계측은 "100바이트"라고 정확히 보냈는데, 가격 계획의 단위 타입이 GB여야 할 게 Byte로 잡혀 있으면(혹은 비어서 기본값 바이트로 떨어지면), 단위 환산이 통째로 날아간다. GB 기준 5센트가 바이트 기준 5센트가 되니까 10억 배 차이가 나는 거다.

비유하자면 이렇다. 주유소에서 "리터당 1,700원"을 매기려 했는데 시스템이 "밀리리터당 1,700원"으로 읽으면, 40리터 넣고 6만8천원 낼 게 6천8백만원이 나오는 셈이다. 계량기(metering)는 정확했지만 단가표(pricing plan)의 단위가 틀렸다.

HN 댓글의 CobrastanJorji 지적이 특히 실무적으로 와닿는다.

"테스트는 있었을 거다. 다만 end-to-end 테스트가 없었을 것이다. 계측 팀 테스트는 '100바이트 작업했더니 100바이트 빌링 콜이 나갔다, 통과'를 확인하고, 빌링 팀 테스트는 'SKU#12345로 100GB 들어오면 $17이다, 통과'를 확인한다. 그런데 이 둘을 붙여서 테스트하진 않는다. 팀 관리 체계가 다르고 더 어렵기 때문이다."

여기서 얻을 교훈: 각 컴포넌트가 개별적으로 정상이어도, 그걸 이어붙인 최종 숫자는 검증 안 될 수 있다. 이건 AWS만의 문제가 아니라 우리 팀 비용 파이프라인에도 똑같이 적용된다.

3. 실무 관점: 예상 청구를 맹신하면 안 되는 이유와 흔한 함정

3-1. 예상값(estimated)과 확정값(final)은 애초에 다른 데이터다

Cost Explorer나 Billing 콘솔에서 보는 "이번 달" 숫자는 대부분 estimated다. 월이 마감되고 최종 청구가 확정되기 전까지는 계속 흔들린다. 이유는 여러 가지다.

  • 계측 데이터가 실시간이 아니라 지연되어 들어온다 (서비스에 따라 수 시간~하루 이상).
  • Savings Plans, RI 적용, 크레딧, 할인은 마감 시점에 재계산되는 경우가 있다.
  • 세금, 지원 플랜 비용 등은 나중에 붙는다.

그래서 "어제까지 $300이었는데 오늘 갑자기 $500이네?" 같은 상황은 사고가 아니라 정상일 수 있다. 반대로 이번 HN 사례처럼 진짜 버그일 수도 있다. 이 둘을 구분하려면 estimate 하나만 봐선 안 되고 사용량 자체를 봐야 한다.

3-2. 흔한 함정: 데이터 나오는 시점을 착각하고 짠 알람

가장 자주 겪는 함정은 CUR(Cost and Usage Report)나 Cost Explorer API를 배치로 긁는데, 데이터가 아직 안 채워진 구간을 조회해서 "비용 0"으로 오탐/미탐하는 경우다. 예를 들어 오늘 날짜를 End에 넣으면 이런 걸 보게 된다.

$ aws ce get-cost-and-usage \
    --time-period Start=2024-06-01,End=2024-06-01 \
    --granularity DAILY \
    --metrics "UnblendedCost"

An error occurred (ValidationException) when calling the GetCostAndUsage operation:
Start date (and hour) should be before end date (and hour)

Cost Explorer의 기간은 End가 exclusive라서 Start와 End를 같게 주면 위 에러가 난다. 하루치를 보려면 End를 다음 날로 줘야 한다. 이거 모르고 Start=오늘, End=오늘로 짜서 배치가 조용히 죽는 케이스, 실무에서 흔하다.

3-3. 사용량과 금액을 분리해서 본다 (단위 에러를 잡는 핵심)

이번 17억 달러 사고의 본질은 "단위 환산이 깨져서 같은 사용량인데 금액만 폭발"한 거였다. 이걸 우리 쪽에서 감지하려면 금액만 보면 안 되고 UsageQuantity(사용량)를 같이 봐야 한다.

아래는 서비스별로 사용량과 비용을 함께 뽑아, 단위당 금액이 비정상적으로 튀는지 눈으로 확인하는 예시다.

$ aws ce get-cost-and-usage \
    --time-period Start=2024-06-01,End=2024-06-02 \
    --granularity DAILY \
    --metrics "UnblendedCost" "UsageQuantity" \
    --group-by Type=DIMENSION,Key=SERVICE \
    --query 'ResultsByTime[0].Groups[?Metrics.UnblendedCost.Amount!=`0`].[Keys[0],Metrics.UnblendedCost.Amount,Metrics.UsageQuantity.Amount]' \
    --output table

-------------------------------------------------------------------
|                        GetCostAndUsage                          |
+---------------------------+-----------------+-------------------+
|  Amazon S3                |  0.4213         |  120.5            |
|  AmazonCloudWatch         |  0.0021         |  3402.0           |
|  Amazon EC2               |  12.8842        |  24.0             |
+---------------------------+-----------------+-------------------+

만약 여기서 S3 사용량(UsageQuantity)은 평소와 같은데 Amount만 갑자기 몇 자릿수 뛰었다면, 이건 우리가 뭘 더 쓴 게 아니라 단가 쪽이 이상하다는 신호다. 이번 AWS 사고 유형이 딱 이거다. 반대로 UsageQuantity 자체가 폭발했으면 우리 쪽 코드가 뭔가 폭주(예: 무한 루프로 API 호출)한 거고.

참고로 UsageQuantity는 서비스마다 단위가 뒤섞여 있어서(GB, 시간, 요청 수 등) 서비스를 뭉뚱그려 합산하면 의미가 없다. 반드시 서비스/사용 타입(USAGE_TYPE) 단위로 쪼개서 봐야 한다.

3-4. 조직/문화적 함정도 있다

HN 스레드에서 전직 AWS 엔지니어들이 나눈 얘기도 실무자 입장에선 흘려들을 게 아니다. 한쪽에선 "이런 사고는 COE(Correction of Errors) 보고서를 강제해서 재발 방지 액션이 붙는다"고 하고, 다른 쪽에선 "인센티브 구조상 조용히 버그 고치는 것보다 터지고 나서 영웅처럼 수습하는 게 이득이라 예방을 안 한다"는 얘기가 나온다.

어느 쪽이 진실이든, 우리가 배울 건 하나다. 공급자(AWS)의 내부 검증을 신뢰의 최종 방어선으로 삼지 마라. 우리 청구서는 우리가 검증해야 한다. 벤더 알람만 믿고 있으면, 벤더가 실수했을 때 그대로 얻어맞는다.

4. 신뢰할 수 있는 비용 모니터링 아키텍처 설계

정리하면 계층을 나눠서 방어선을 여러 개 두는 게 핵심이다.

4-1. 1차 방어선: AWS Budgets — 빠르지만 거칠다

Budgets는 예상 청구액 기준으로 임계치 넘으면 알려준다. 반응이 빠르지만 estimate 기반이라 오차가 크다. "뭔가 크게 잘못됐다"를 놓치지 않는 용도로만 쓴다.

$ aws budgets create-budget \
    --account-id 123456789012 \
    --budget '{
      "BudgetName": "monthly-hard-cap",
      "BudgetLimit": {"Amount": "1000", "Unit": "USD"},
      "TimeUnit": "MONTHLY",
      "BudgetType": "COST"
    }' \
    --notifications-with-subscribers '[{
      "Notification": {
        "NotificationType": "FORECASTED",
        "ComparisonOperator": "GREATER_THAN",
        "Threshold": 120,
        "ThresholdType": "PERCENTAGE"
      },
      "Subscribers": [{"SubscriptionType": "EMAIL", "Address": "oncall@example.com"}]
    }]'

여기서 FORECASTED는 AWS가 이번 달 말 예상액을 계산해서 판단한다. 초반 데이터가 적을 때 예측이 널뛰기 때문에, ACTUAL 타입 알람도 하나 더 걸어두는 걸 권장한다. Forecast만 믿으면 초반에 false positive로 알람 피로가 쌓인다.

4-2. 2차 방어선: Cost Anomaly Detection — 패턴 이탈 감지

AWS Cost Anomaly Detection은 서비스별 지출 패턴을 학습해서 평소와 다른 스파이크를 잡아준다. 이번 HN 댓글에서 akdev1l이 "청구액이 1000만% 뛰면 누가 좀 봐야 하는 거 아니냐"고 했는데, 딱 이 도구가 그 역할을 노린다. 절대 금액 임계치가 아니라 상대적 이상 탐지라 단위 에러 같은 폭발형 사고에 강하다.

4-3. 3차 방어선: CUR + 자체 검증 — 가장 신뢰할 수 있는 근거

CUR(Cost and Usage Report)은 라인 아이템 수준의 원장(ledger)이다. S3에 떨어지고 Athena로 쿼리할 수 있다. 여기서 사용량과 단가를 분리해서 검증하는 게 단위 에러를 잡는 마지막 열쇠다.

-- Athena: 사용 타입별 '단위당 실효 단가'가 튀는 항목 찾기
SELECT
    line_item_product_code            AS service,
    line_item_usage_type              AS usage_type,
    SUM(line_item_usage_amount)       AS usage_qty,
    SUM(line_item_unblended_cost)     AS cost_usd,
    SUM(line_item_unblended_cost)
      / NULLIF(SUM(line_item_usage_amount), 0) AS effective_unit_price
FROM cur_table
WHERE line_item_usage_start_date
      >= date_trunc('day', current_timestamp - interval '1' day)
GROUP BY 1, 2
ORDER BY effective_unit_price DESC
LIMIT 20;

effective_unit_price(실효 단가 = 비용 / 사용량)를 어제와 그제로 비교해서, 특정 usage_type의 실효 단가가 갑자기 몇 자릿수 뛰면 그게 바로 "단위가 깨졌다"는 증거다. Budgets나 Cost Explorer의 총액만 보고 있으면 이걸 못 잡지만, 실효 단가로 내려가면 원인 위치까지 좁혀진다.

5. 인프라 엔지니어를 위한 비용 검증 체크리스트

  • estimate와 actual을 구분해서 본다. 이번 달 콘솔 숫자는 흔들린다는 걸 팀에 공유. "어제보다 늘었다"만으로 놀라지 않기.
  • 금액과 사용량(UsageQuantity)을 항상 함께 조회한다. 금액만 튀고 사용량은 그대로면 단가/단위 문제, 사용량이 튀면 우리 쪽 문제.
  • Budgets는 FORECASTED + ACTUAL 두 개를 건다. Forecast 단독은 초반 오탐이 심하다.
  • Cost Anomaly Detection을 켠다. 절대 임계치가 못 잡는 패턴 이탈용.
  • CUR을 Athena에 붙이고 실효 단가(비용/사용량) 대시보드를 만든다. 자릿수 단위 이상 탐지가 목적.
  • Cost Explorer API의 End는 exclusive라는 걸 배치 코드에 반영한다. Start date should be before end date 에러로 배치 죽는 거 방지.
  • 벤더 알람을 최종 방어선으로 삼지 않는다. HN 사례가 증명했듯 공급자도 단위 하나로 17억 달러를 틀린다.

정리

한 줄 요약: AWS 예상 청구는 "사용량 × 가격 계획"으로 계산되는데, 가격 계획의 단위 하나가 틀어지면 사용량이 정확해도 금액이 수십억 배 폭발할 수 있다. 그러니 금액만 보지 말고 사용량과 실효 단가를 분리해서 검증하는 자체 방어선을 반드시 갖춰라.

누가 언제 써야 하나: Budgets 알람 하나에 비용 관리를 의존하고 있는 모든 팀. 특히 월 청구 규모가 커서 하루 오차가 큰 조직이라면, CUR + Athena 실효 단가 검증은 선택이 아니라 필수다. 최소한 Cost Anomaly Detection이라도 오늘 켜두자. 이번 17억 달러 사건은 남 일처럼 보이지만, 정작 우리 계정에서 비슷한 게 터졌을 때 "이게 진짜 청구되는 건지 아닌지" 30초 안에 판단할 근거가 있느냐가 진짜 실력 차이다.

참고 자료

※ 본문의 명령어 출력 예시 수치는 형식 설명을 위한 예시이며 실제 요금이 아니다. 각 API의 정확한 파라미터/동작은 위 공식 문서에서 최종 확인하기 바란다.

728x90
728x90

1. 도입: GPU 없이 LLM을 돌린다는 것

얼마 전 Hacker News에 올라온 글 하나가 인프라 하는 사람들 사이에서 돌았다. 제목이 "Running Gemma 4 26B at 5 tokens/sec on a 13-year-old Xeon with no GPU"다. 요약하면 이렇다. 2013년산 HP StoreVirtual 스토리지 박스(듀얼 Ivy Bridge Xeon E5-2690 v2, DDR3, GPU 없음)에서 Google의 Gemma 4 26B MoE 모델을 초당 5토큰가량으로 돌렸다는 얘기다. 박스 값은 300달러도 안 든다.

왜 이게 우리한테 의미가 있냐. 온프레미스 굴리는 팀이라면 데이터센터나 사무실 랙에 은퇴 직전이거나 이미 은퇴한 엔터프라이즈 서버가 한두 대씩 굴러다닌다. 스토리지 노드 교체하고 남은 구형 박스, 리스 만료돼서 반납 안 하고 방치한 서버 같은 것들. 이걸 폐기하거나 창고에 처박아 두는 대신 "LLM 폴백 서버"나 "야간 배치용 추론 노드"로 재활용할 수 있다면 얘기가 달라진다.

여기서 짚고 넘어갈 점. 원문 저자도 강조하지만 이건 "GPU 대신 CPU 쓰면 됩니다" 같은 단순한 얘기가 아니다. MoE 아키텍처 모델이라 활성 파라미터가 적어서(26B-A4B, 즉 총 26B지만 토큰당 활성 4B) CPU에서도 견딜 만한 속도가 나온 거고, 저자의 CPU는 AVX2도 FMA3도 없는 세대라 기존 최적화 코드가 아예 빌드조차 안 됐다. 그걸 패치해서 겨우 돌린 사례다. 그냥 따라한다고 되는 게 아니라는 뜻이다.

2. 핵심: CPU 추론이 왜 되고, 왜 느린가

MoE 덕분에 26B가 CPU에서 견딘다

Gemma 4 26B-A4B는 Mixture-of-Experts 모델이다. 전체 파라미터는 26B지만 토큰 하나 생성할 때 실제로 계산에 참여하는 건 4B(A4B의 A가 active) 정도만 활성화된다. 이게 CPU 추론에서 결정적이다.

비유하자면 이렇다. Dense 모델(모든 파라미터가 매 토큰마다 다 도는 모델)이 26명 전원이 매번 회의에 들어오는 조직이라면, MoE는 안건마다 관련 전문가 8명(원문 기준 layer당 8 active experts)만 불러 모으는 구조다. 나머지는 메모리에 대기만 하고 연산은 안 한다. CPU는 GPU만큼 병렬 연산이 세지 않으니, 매 토큰마다 도는 실제 연산량이 줄어드는 MoE 구조가 CPU에겐 큰 선물이다.

진짜 병목은 연산이 아니라 메모리 대역폭

여기가 CPU 추론의 핵심이다. LLM 디코딩(토큰 하나씩 뽑는 과정)은 대부분 연산 성능(FLOPS)이 아니라 메모리 대역폭에 묶인다(memory-bandwidth-bound). 원문 저자도 fused 커널을 못 써서 matmul을 두 번 나눠 돌리게 됐지만 "이 CPU는 어차피 메모리 대역폭에 묶여 있어서 손해 본 게 없다"고 명시했다.

왜 그런가. 토큰 하나 생성하려면 활성화된 가중치(Q8_0면 파라미터당 약 1바이트)를 전부 메모리에서 읽어와야 한다. 4B 활성 파라미터면 대략 4GB를 매 토큰마다 RAM에서 CPU로 긁어와야 한다는 뜻이다. DDR3 메모리 대역폭이 실효 40~50GB/s 수준이라 치면, 산술적으로 초당 10토큰 언저리가 물리적 상한선이 된다. 저자가 5.2 tok/s를 얻은 게 이 대역폭 벽을 감안하면 납득이 간다.

실무에서 이걸 기억해야 하는 이유. CPU 추론 튜닝할 때 "코어 수를 늘리면 빨라지겠지"라고 생각하기 쉬운데, 대역폭에 묶인 워크로드는 코어를 아무리 늘려도 메모리 컨트롤러가 못 따라와서 어느 지점부터는 스레드를 늘려도 속도가 안 오르거나 오히려 캐시 경합으로 느려진다. 물리 코어 수보다 메모리 채널 수와 대역폭이 더 중요하다.

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

llama.cpp / ollama 기본 세팅

먼저 AVX2가 있는 평범한(비교적 최신) CPU라면 굳이 원문의 고생을 따라할 필요 없다. Ollama나 llama.cpp 최신 빌드를 그대로 쓰면 된다. 가장 흔한 진입 경로는 Ollama다.

# Ollama 설치 (Linux)
curl -fsSL https://ollama.com/install.sh | sh

# CPU 정보 먼저 확인 - AVX2 지원 여부가 갈림길
lscpu | grep -o 'avx[0-9a-z_]*' | sort -u

출력이 이렇게 나오면 최신 최적화 커널을 쓸 수 있는 세대다.

avx
avx2
avx512f
avx512_vnni

반대로 원문 저자의 박스처럼 이렇게만 나오면 얘기가 완전히 달라진다.

avx

avx2가 안 보이고 avx만 있으면 Ivy Bridge(2013) 혹은 그 이전 세대다. 이 경우 대부분의 최신 추론 엔진 빌드가 런타임에 죽거나 아예 컴파일이 안 된다. 원문의 핵심 고생이 정확히 이 지점이다.

llama.cpp를 직접 빌드해서 스레드를 조절하려면 이런 식이다. (모델 경로와 파일명은 각자 환경에 맞게)

# llama.cpp 빌드 (AVX2 있는 일반적인 서버 기준)
git clone https://github.com/ggml-org/llama.cpp
cd llama.cpp
cmake -B build
cmake --build build --config Release -j$(nproc)

# 실행: 물리 코어 수에 맞춰 스레드 지정
./build/bin/llama-cli \
  -m ./models/gemma-model-Q8_0.gguf \
  -p "온프레미스 서버 재활용 아이디어 3가지" \
  -t 20 \
  -c 4096

여기서 -t 20이 스레드 수다. 흔한 함정 하나: -t를 논리 코어(하이퍼스레딩 포함) 전부에 맞추면 오히려 느려지는 경우가 많다. 물리 코어 수부터 맞춰보고, 거기서 하나씩 조절하며 tok/s를 실측하는 게 정석이다. 앞서 말했듯 대역폭 병목이라 코어 다 갈아넣는다고 안 빨라진다.

양자화(Quantization): Q4 vs Q8 트레이드오프

원문은 Q8_0으로 돌렸다. 여기서 실무자가 알아야 할 트레이드오프를 정리한다. (아래 원리는 일반적으로 통용되는 내용이고, 원문에 없는 구체 벤치마크 수치는 지어내지 않았다.)

  • Q8_0: 파라미터당 약 8비트(1바이트). 품질 손실이 거의 없다시피 하지만 메모리를 많이 먹고, 매 토큰마다 읽어야 할 데이터가 크다 → 대역폭 병목 환경에선 속도가 상대적으로 느려진다.
  • Q4_K_M 등 4비트 계열: 파라미터당 약 4비트. 메모리 사용량과 대역폭 부담이 절반 수준으로 줄어 같은 하드웨어에서 디코딩 속도가 더 나올 여지가 크다. 대신 품질(특히 정밀한 추론, 코드 생성)에서 미세한 손실이 생길 수 있다.

대역폭에 묶인 CPU 환경에서는 "읽어야 할 바이트 수 = 속도"에 직결되기 때문에, Q8에서 Q4로 내리면 속도 이득이 GPU 환경보다 더 체감된다. 다만 원문 저자가 Q8_0을 쓴 이유가 있을 수 있다 — AVX1 폴백 경로에서 특정 양자화 포맷만 검증됐거나, 4비트 K-quant 계열이 해당 하드웨어의 스칼라 폴백에서 제대로 도는지 별도 확인이 필요하다. Q4로 바꿔 돌릴 땐 반드시 출력 품질을 눈으로 검증하고 나서 프로덕션에 올려라.

흔한 함정: AVX2 없는 하드웨어의 조용한 오작동

원문에서 가장 무서운 대목이 이거다. 빌드 자체가 죽으면 차라리 낫다. 진짜 문제는 빌드도 되고 실행도 되는데 출력이 조용히 쓰레기가 나오는 경우다.

저자의 경우 GGML_USE_IQK_MULMAT를 끄고 빌드했는데, 그래프 빌더는 여전히 MOE_FUSED_UP_GATE 같은 fused 연산을 뱉는데 dispatcher의 switch문엔 해당 op에 대한 case가 없어서 default로 빠졌고, 그 결과 모든 expert FFN의 결과 텐서가 계산되지 않고 메모리 쓰레기값으로 남았다. 30 layer × 8 active experts = 매 forward pass마다 약 240개 텐서가 초기화 안 된 버퍼값이었다는 것.

증상이 특히 교활하다. NaN도 안 뜨고, 크래시도 안 나고, temperature 0에서 결정적(deterministic)으로 나오는데 결과물이 이런 식이다:

# 증상: 유창해 보이는 다국어 gibberish
# 태국어, 한국어, 안 쓰는 sentinel 토큰, 영어 조각이
# 262K vocabulary 전체에 균일하게 흩뿌려짐
Prompt: What is the capital of France?
Output: สวัสดี 국가  the の paris ที่ 답 ▁▁ करता...

이게 왜 무서우냐. 크래시가 나면 "뭔가 잘못됐네"하고 바로 알지만, 그럴싸한 텍스트가 나오면 세팅이 잘 된 줄 착각하기 쉽다. 저자는 sampling 직전 raw logits를 찍어서 첫 토큰의 mean logit이 0 근처여야 하는데 +16으로 튀어 있고 vocabulary의 약 80%가 양수 logit인 걸 보고 "residual stream의 큰 덩어리가 초기화 안 된 메모리(작은 양수 float들)"라고 진단했다.

실무 교훈: 비표준 하드웨어에서 추론 엔진을 돌릴 땐 반드시 "정답을 아는 프롬프트"로 sanity check를 먼저 해라. "2 + 2는?", "한국의 수도는?" 같은 걸로 정상적인 답이 나오는지부터 확인하고, 그 다음 실제 워크로드를 태워야 한다. 크래시 안 났다고 정상이라 믿으면 안 된다.

또 하나의 함정: --run-time-repack

원문이 명시적으로 경고하는 지점. --run-time-repack 플래그는 시작할 때 양자화 가중치를 Q8_0_R8이라는 AVX2 전용 인터리브 레이아웃으로 재배열한다. AVX1 CPU에서 이걸 켜면 위와 똑같은 gibberish가 다시 나온다. AVX2 없는 박스에서 돌릴 거면 이 플래그를 빼야 한다. (AVX2 있는 서버라면 오히려 켜서 이득 보는 옵션이니 무조건 빼라는 얘기는 아니다.)

대안

  • 그냥 최신 CPU 서버를 쓴다: Haswell(2014, v3) 이상이면 AVX2/FMA3가 있어서 이 고생을 안 해도 된다. 사내에 놀고 있는 v3~v4 세대 Xeon이 있다면 그걸 먼저 노려라. 패치 없이 Ollama 바로 돌아간다.
  • MoE 모델을 우선 고른다: Dense 26B를 CPU에서 돌리면 A4B MoE보다 훨씬 느리다. CPU 서빙 계획이면 애초에 MoE 계열 모델을 선택하는 게 전략적으로 맞다.
  • 배치/비동기 워크로드로 한정: 5 tok/s는 사람이 읽는 속도지 대화형 실시간 서비스엔 부족하다. 야간 배치 요약, 로그 분류, 문서 태깅 같은 지연 시간이 관대한 작업에 붙이는 게 현실적이다.

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

한 줄 요약: MoE 모델과 메모리 대역폭 특성 덕분에 GPU 없는 구형 Xeon에서도 26B LLM을 읽는 속도(약 5 tok/s)로 돌릴 수 있지만, AVX2 없는 세대는 엔진 패치와 조용한 오작동 검증이라는 대가가 따른다.

이럴 때 써라:

  • 사무실/데이터센터에 놀고 있는 구형 서버가 있고, 유료 API가 다운됐을 때의 로컬 폴백이나 비용 민감한 야간 배치 추론이 필요한 경우
  • 토큰당 과금이 부담스럽고 지연 시간에 관대한 대량 텍스트 처리(분류, 요약, 태깅) 워크로드
  • 데이터를 외부로 못 내보내는 규제 환경에서 저비용 온프레미스 추론이 필요한 경우

이럴 땐 쓰지 마라:

  • 실시간 대화형 챗봇처럼 응답 속도가 UX인 서비스 (5 tok/s는 답답하다)
  • 가진 서버가 Haswell 이전 세대인데 C++ 커널 디버깅까지 감당할 팀이 없는 경우 — 이땐 그냥 GPU 인스턴스 빌리거나 최신 CPU 서버 쓰는 게 인건비 대비 싸다

가장 중요한 건 원문 저자가 던진 메시지다. "AI를 잘한다"는 게 구독료 내는 걸 뜻하는 시대에, 진짜 레버리지는 남이 안 만들어준 문제에 모델을 겨눌 줄 알고, 나온 답이 진짜 맞는지 판별할 줄 아는 것이라는 점. 이번 사례에서도 실제 커널 패치는 Claude가 짰지만, 실험을 설계하고 "logits mean이 +16이면 이상하다"를 아는 건 사람의 몫이었다. 인프라 하는 우리한테 딱 필요한 감각이다.

참고 자료

728x90
728x90

실무에서 봇 방어를 붙여본 사람이라면 다들 겪는 순간이 있다. 로그인·가입·결제 지점에 CAPTCHA나 Turnstile을 걸어놨는데, 어느 날 로그가 이상해진다. Challenge는 다 통과했는데 계정이 수천 개 만들어지고, Twilio SMS 비용이 폭발하고, 크리덴셜 스터핑이 조용히 성공한다. "분명히 검증 지점은 다 막았는데 왜?" 이 질문에 대한 Cloudflare의 답이 Precursor다.

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

기존 봇 방어의 근본 전제는 "특정 지점에서 한 번 검증하면 통과"였다. 로그인 폼에 CAPTCHA, 가입 폼에 Turnstile, 결제 직전에 3DS. 이걸 나는 실무에서 "체크포인트 방식"이라고 부른다. 문제는 요즘 자동화가 이 체크포인트를 아주 잘 넘는다는 거다.

왜 넘는가? 현대 봇은 더 이상 curl로 요청 날리는 스크립트가 아니다. Playwright나 Puppeteer로 진짜 Chromium을 띄우고, 실제 JavaScript를 실행하고, 실제 브라우저 지문을 가진다. brightdata, zenrows 같은 상용 서비스는 수억 개 가정용 IP에 사람 같은 브라우저 지문까지 얹어 판다. 그래서 "짧은 순간"의 스냅샷만 보면 봇과 사람을 구분할 수 없다.

Cloudflare의 관점 전환이 여기서 나온다. 원문 요약을 그대로 옮기면 이렇다:

현대 자동화는 JavaScript를 실행하고 실제 브라우저 환경을 사용하며 개별 CAPTCHA도 통과할 수 있어, 짧은 구간에서는 정상 사용자처럼 보일 수 있음. 세션 전체에서 일관된 인간 행동을 재현하기는 더 어려우며, Precursor는 이러한 행동의 연속성을 사기와 악용 탐지 신호로 활용함.

핵심은 "짧은 순간은 흉내 낼 수 있어도, 세션 전체를 일관되게 흉내 내긴 어렵다"는 통찰이다. Cloudflare는 하루 1조 건 이상 요청을 처리하고 웹의 20% 이상을 커버한다고 하니, 이 규모에서 세션 단위 행동 데이터를 모으는 건 확실히 그들만 할 수 있는 접근이긴 하다.

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

2-1. 전체 세션을 연속 스트림으로 본다

Precursor의 아키텍처를 단계별로 뜯어보면 이렇다. (아래는 원문 발췌 기반으로 정리한 흐름이다.)

  1. 클라이언트 주입: Precursor를 켜면 Cloudflare 네트워크를 통과하는 사이트의 HTML 응답에 경량 스크립트가 자동으로 삽입된다. 별도 설정, 별도 네트워크 연결, 제3자 임베딩 불필요. 번들은 작고 난독화돼 있으며 응답마다 동적으로 조립된다.
  2. 신호 수집: 삽입된 스크립트가 이벤트 리스너로 포인터 이동, 키보드 활동, 포커스 변화, 페이지 표시 상태(visibilitychange)를 잡는다. 이걸 압축 형식으로 직렬화해 메모리에 버퍼링한다.
  3. 전송: 버퍼가 정기적으로 엣지 평가 계층으로 올라간다.
  4. 엣지 평가: 엣지 서버가 페이로드를 역직렬화하고, 디스패처가 여러 평가기(evaluator)를 돌린다. 각 평가기는 필요한 스트림을 읽고 공유 탐지 레지스트리에 신호를 등록한다.
  5. 세션 누적: 데이터가 세션 범위로 누적되므로, 봇이 페이지를 새로고침하거나 Challenge부터 다시 시작해도 행동 서명을 초기화할 수 없다.

5번이 개인적으로 가장 영리하다고 본다. 체크포인트 방식의 약점은 "봇이 검증 지점만 통과하면 그 뒤론 자유"라는 건데, 세션 범위로 누적하면 리셋 자체가 안 된다.

2-2. 왜 마우스 움직임이 신호가 되는가

여기가 재밌는 부분이다. 봇 개발자들은 마우스 경로에 가우시안 노이즈나 균일한 랜덤 지연을 넣어 "자연스럽게" 만든다. 하지만 진짜 사람의 움직임에는 단순 노이즈로는 재현 안 되는 물리·인지적 제약이 있다:

  • 손목 회전축: 손목 가동 범위와 팔뚝 회전 때문에 마우스가 흔히 호(arc) 형태로 움직인다. 직선이 아니다.
  • 인지 부하: 체크박스를 본 뒤 클릭하기까지 측정 가능한 지연이 있다.
  • 생리적 손떨림: 안정된 손에서도 특정 주파수의 미세 진동이 나타난다.

반면 자동화는 직선 보간이나 수학적으로 이상적인 Bézier 곡선을 쓰고, 사람이 못 낼 정밀도로 클릭한다. 예로 든 자동화 라이브러리는 "마우스를 완전한 직선으로 움직이고, 항상 원점으로 돌아가며, 같은 속도로 반응"한다. 개별 클릭은 그럴듯해도 세션 전체에서는 이 패턴 차이가 드러난다.

실무자 입장에서 이 발상을 코드로 직관화해보자. 아래는 Precursor 내부 구현이 아니라, "직선 vs 사람 곡선"의 차이를 감 잡기 위한 개념 데모다. 실제 판별 로직과는 무관하다.

# mouse_signal_demo.py — 직선 이동과 사람 유사 경로의 곡률/속도 분산 비교 (개념 데모)
import numpy as np

def path_features(points):
    p = np.array(points, dtype=float)
    deltas = np.diff(p, axis=0)
    speeds = np.linalg.norm(deltas, axis=1)
    # 방향 변화(곡률 대용): 연속 벡터의 각도 차
    angles = np.arctan2(deltas[:,1], deltas[:,0])
    turn = np.abs(np.diff(angles))
    return {
        "speed_std": round(float(np.std(speeds)), 3),
        "turn_mean": round(float(np.mean(turn)), 4),
    }

# 봇: 직선, 등속
bot = [(x, x) for x in range(0, 100, 5)]

# 사람: 호를 그리며 속도가 들쭉날쭉 + 목표 초과 후 보정
rng = np.random.default_rng(42)
t = np.linspace(0, 1, 20)
human = [(100*t[i] + rng.normal(0, 1.5),
          40*np.sin(t[i]*np.pi) + rng.normal(0, 1.2)) for i in range(len(t))]

print("bot   :", path_features(bot))
print("human :", path_features(human))

실행 결과:

$ python3 mouse_signal_demo.py
bot   : {'speed_std': 0.0, 'turn_mean': 0.0}
human : {'speed_std': 1.834, 'turn_mean': 0.2216}

봇은 속도 분산도 0, 방향 변화도 0으로 딱 떨어진다. 이 speed_stdturn_mean이 정확히 0에 수렴한다는 것 자체가 강력한 이상 신호다. Precursor는 이런 걸 세션 전체에 걸쳐, 여러 상호작용에 걸쳐 본다는 게 요점이다. (다만 원문에 "마우스 움직임은 여러 신호 중 하나일 뿐"이라고 세 번 강조돼 있다는 점은 꼭 기억하자. 마우스 하나로 판정하지 않는다.)

2-3. 교차 검증 — 단일 이벤트에 의존하지 않는다

Precursor가 단순 곡선 분석과 다른 지점은 서로 다른 신호를 교차 검증한다는 거다. 원문에서 든 예:

  • 포인터 활동이 페이지가 실제 표시된 시간과 일치하는가? (탭이 백그라운드인데 마우스가 움직이면 이상)
  • 텍스트 필드에 포커스가 있을 때만 키보드 이벤트가 발생하는가? (포커스 없는데 타이핑 이벤트가 오면 이상)

이 상관관계 검증은 봇 개발자를 정말 괴롭힌다. 마우스만 흉내 내면 되던 게, 이제 "포커스-키보드-포인터-visibility"를 전부 일관되게 조율해야 하기 때문이다.

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

3-1. 어떻게 켜는가

공식 발표 기준으로 Precursor는 Enterprise Bot Management 기능에 포함되며, 대시보드에서 영역(zone)별로 켤 수 있고 올해 말 GA 전까지 무료다. 활성화 방식은 두 가지:

  • 마찰 낮은 모드: 백그라운드에서 행동만 관찰. 애플리케이션 변경 불필요.
  • 강제 검증 모드: 검증된 세션이 없으면 Challenge를 강제.

대부분의 팀은 무조건 관찰 모드부터 시작하는 게 맞다. 바로 Challenge 강제로 켜면 오탐(false positive)이 정상 사용자를 때리는 순간 CS 티켓이 쏟아진다. Cloudflare는 Precursor 데이터를 기존 봇 점수·Challenge 판단·보안 규칙으로 직접 전달한다고 하니, 며칠 관찰 모드로 돌리며 Security Analytics의 세션 기반 뷰에서 "우리 사이트 정상 세션이 어떻게 생겼는지" 먼저 파악한 뒤 임계값을 조이는 걸 권한다.

3-2. 관찰 모드로 시작하는 이유 — 실제 검증 명령

Precursor 자체는 대시보드 기반이지만, 활성화 후 봇 점수가 실제 요청에 반영되는지, HTML에 스크립트가 주입되는지 정도는 커맨드라인으로 빠르게 확인할 수 있다. 아래는 Cloudflare 봇 점수를 응답 헤더로 노출하도록 Transform Rule을 걸어둔 뒤 확인하는 예다.

# Cloudflare 봇 점수를 응답 헤더로 노출하는 Transform Rule을 걸었다고 가정
# (대시보드 > Rules > Transform Rules > Modify Response Header:
#  x-bot-score = cf.bot_management.score)

$ curl -sI https://example.com/ | grep -i -E "cf-ray|x-bot-score|server"
server: cloudflare
cf-ray: 8a3f1c2d9e7b4a21-ICN
x-bot-score: 1

x-bot-score가 1에 가까우면 봇 확률이 높다는 뜻이다(Cloudflare 봇 점수는 1=봇, 99=사람에 가까움). 정상 브라우저로 접근했는데 점수가 낮게 나온다면 오탐 소지가 있는 것이고, 스크립트로 접근했는데 높게 나온다면 탐지가 작동 중이라는 신호다.

HTML에 경량 스크립트가 주입되는지도 눈으로 확인할 수 있다:

$ curl -s https://example.com/ | grep -o 'cloudflare[^"]*\.js' | head
cdn-cgi/challenge-platform/scripts/jsd/main.js

(주입되는 스크립트 경로/이름은 계정·설정·시점에 따라 달라진다. 정확한 식별자는 공식 문서 확인 필요. 위 경로는 예시다.)

3-3. 흔한 함정 — 접근성 사용자와 오탐

이게 가장 조심해야 할 지점이다. HN 토론에서 반복해서 나온 우려인데, 행동 기반 탐지는 비전형적 입력 사용자를 봇으로 오인할 수 있다:

  • ThinkPad 트랙포인트, 터치스크린 등 비전통적 입력 장치
  • 시선 추적(eye-tracking) 등 보조 기술을 쓰는 장애인
  • 키보드 전용 사용자 (마우스 신호가 아예 없음)
  • 한 손만 쓰는 사용자

실제로 Cloudflare 오탐 때문에 페이지가 무한 로딩에 걸리는 상황을 겪은 사람들의 증언이 토론에 있다. Starlink 같은 위성 회선에서 더 자주 차단당한다는 얘기도 나온다. 이럴 때 사용자가 마주치는 전형적인 화면은 이런 식이다:

example.com에 연결 중...

Checking your browser before accessing example.com.
This process is automatic. Your browser will redirect to your
requested content shortly. Please allow up to 5 seconds...

Ray ID: 8a3f1c2d9e7b4a21
Performance & security by Cloudflare

이 화면에서 계속 redirect만 되고 콘텐츠로 안 넘어가는 상태가 오탐의 대표 증상이다. 로그를 파보면 브라우저 콘솔에서 이런 에러를 보게 되는 경우도 있다:

Refused to execute inline script because it violates the following
Content Security Policy directive: "script-src 'self'".
Either the 'unsafe-inline' keyword, a hash ('sha256-...'), or a
nonce ('nonce-...') is required to enable inline execution.

이건 Precursor가 주입하는 인라인 스크립트가 사이트의 CSP(Content Security Policy)에 걸려 실행 자체가 막히는 상황이다. 이렇게 되면 신호 수집이 안 돼서 오히려 "행동 데이터 없음 → 봇 의심"으로 이어지는 최악의 루프가 생길 수 있다. Cloudflare 관리형 스크립트 주입을 쓴다면 CSP에 nonce나 해당 도메인 허용을 반영했는지 반드시 확인해야 한다. (구체적 CSP 설정 방법은 공식 문서 확인 필요.)

3-4. 트레이드오프 정리

구분장점비용/리스크
탐지 정밀도세션 전체를 보므로 체크포인트 회피 봇을 잡음접근성 사용자 오탐 위험
운영 부담앱 코드 변경 없이 엣지에서 처리CSP·확장프로그램·비전통 입력과의 충돌
공격자 비용봇이 세션 전체를 흉내 내야 해 비용 급증정교한 흔들림 합성으로 우회 시도 가능
프라이버시실제 키값 대신 타이밍·리듬만, 계정과 미연결손목 상태·주손·모국어 등 추정 가능성 논란

프라이버시 설계는 원문 기준으로 나쁘지 않다. 실제 누른 키가 아니라 타이밍·리듬만 수집하고, 사용자 계정·로그인 신원·영구 프로필과 연결하지 않으며, 고객 대시보드에 직접 노출도 안 한다고 명시돼 있다. 다만 HN 토론에서는 "그 신호로 이론상 주손·대략 나이·모국어·부상 여부까지 추정 가능하지 않냐"는 우려가 나왔다. 이건 Cloudflare가 실제로 그렇게 쓴다는 게 아니라 가능성에 대한 지적이니 구분해서 받아들이자.

3-5. 대안

같은 시장(행동 기반 봇/에이전트 탐지)에는 이미 여러 제품이 있다. 토론에서 언급된 것들: DataDome, Kasada, HUMAN, Castle, Fingerprint, Foil, Google Cloud Fraud Defense(사실상 reCAPTCHA 후속), Darwinium 등. hCaptcha는 6년 전에 유사 기능(같은 사람의 다중 계정·다중 카드 시도 탐지)을 구현했다는 증언도 있고, reCAPTCHA v3도 "백그라운드에서 조용히 감시"가 핵심 셀링 포인트였다. 즉 Precursor의 발상 자체가 완전히 새로운 건 아니다. 다만 Cloudflare의 네트워크 규모(웹 20%+, 일 1조 요청)와 앱 변경 없는 엣지 통합이 차별점이다.

4. 정리

한 줄 요약: Precursor는 "검증 지점 한 번 통과"라는 체크포인트 패러다임을 버리고, 세션 전체의 행동 연속성을 엣지에서 스트림으로 분석해 봇 흉내의 비용을 폭발적으로 올리는 시스템이다.

누가 언제 써야 하나:

  • 이미 Cloudflare Bot Management / Turnstile을 쓰는데, 검증 지점은 다 통과하는데 크리덴셜 스터핑·가짜 계정·무료체험 악용·스크래핑이 새는 팀 → 켜볼 가치 충분하다. GA 전까지 무료다.
  • 단, 접근성이 중요한 서비스(공공·금융·의료)는 반드시 관찰 모드로 오래 돌리고, 세션 분석으로 정상 사용자 분포를 확인한 뒤에 Challenge 강제를 켜라. 키보드 전용·보조기술 사용자를 봇으로 때리면 그건 탐지 실패가 아니라 서비스 사고다.
  • CSP를 빡세게 걸어둔 사이트는 인라인 스크립트 정책부터 점검하고 도입하라.

봇 방어는 결국 공격자 적응과 방어자 대응이 끝없이 반복되는 적대적 경쟁이다. 흔들림 합성으로 우회하려는 시도가 나올 거고, Cloudflare는 더 많은 실제 데이터로 역흔들림 대응을 할 거다. 우리 실무자 입장에서 중요한 건, 이 무기를 켜는 순간 정상 사용자에게 새로운 마찰을 얹을 수 있다는 걸 잊지 않는 것이다. 관찰부터, 그다음 강제. 순서 지키자.

참고 자료

728x90
728x90

"DB 서버를 하나 더 붙이자"는 얘기는 많이 들어봤어도 "DB 서버를 아예 없애자"는 얘기는 드물다. lobste.rs가 정확히 후자를 했다. MariaDB VPS를 통째로 내리고 애플리케이션 서버 안의 SQLite 파일 하나로 옮겼다. 결과는 CPU·메모리 사용량 감소, 체감 응답속도 개선, 그리고 VPS 비용 절반.

이게 왜 재밌냐면, 우리가 인프라 최적화라고 하면 보통 "더 추가"하는 방향으로만 생각하기 때문이다. 캐시 레이어 추가, 리드 레플리카 추가, 커넥션 풀러 추가. lobste.rs는 반대로 "빼는" 결정으로 단순성과 비용을 동시에 잡았다. 이번 글에서 그 과정과, 우리가 실제로 따라 하려 할 때 밟게 될 지뢰를 정리한다.

1. 왜 MariaDB를 버렸나 — 동기와 배경

사실 이 마이그레이션은 갑자기 나온 게 아니다. 논의는 2019년 이슈 #539에서 시작됐다. 당시엔 MariaDB에서 벗어나자는 얘기였고, 대안으로 PostgreSQL이 유력했다. PostgreSQL은 대부분의 팀에서 "고민 없이 고르는 기본값"이니까 자연스러운 선택이었다.

그런데 2025년에 실제로 작업할 지원자가 나타났을 때, 그 사람이 SQLite로 가고 싶어 했다. 여기서 lobste.rs 팀의 판단이 흥미롭다. 원문 댓글에서 관리자가 직접 밝힌 이유는 이렇다.

PostgreSQL은 평소 기본 선택이지만 별도 서비스를 운영·조정·유지해야 하는 부담이 있음. 예상 수요보다 크고 복잡한 해법을 피하고 싶어 SQLite를 선택함.

이게 핵심이다. lobste.rs는 단일 서버로 충분히 돌아가는 규모의 커뮤니티 사이트다. 이런 서비스에 PostgreSQL을 붙인다는 건 곧 별도 프로세스, 별도 VPS, 커넥션 관리, 백업 파이프라인, 버전 업그레이드 운영을 짊어진다는 뜻이다. "우리 규모에서 그게 정말 필요한가?"라는 질문에 정직하게 아니라고 답한 결과가 SQLite였다.

실무에서 우리도 이 질문을 자주 회피한다. "일단 Postgres 깔자, 나중에 커지면 편하니까." 그런데 그 "나중"이 안 오는 서비스가 훨씬 많다. lobste.rs는 오지 않을 미래를 위해 지금의 운영 복잡도를 지불하기를 거부한 거다.

2. SQLite는 프로덕션에서 쓸 수 있나 — 오해와 실제

여기서 대부분의 엔지니어가 멈칫한다. "SQLite는 임베디드용 아냐? 동시성이 안 되잖아." 반은 맞고 반은 틀리다.

SQLite의 동시성 모델을 정확히 이해하는 게 중요하다. 원문 FAQ 논의를 정리하면 이렇다.

  • 읽기는 여러 프로세스에서 동시에 가능
  • 쓰기는 한 번에 하나만 가능 (파일 시스템 잠금으로 보장)
  • 쓰기가 겹치면 명시적 큐가 아니라 "잠깐 대기 후 재시도" 방식으로 처리된다

비유하자면 화장실이 하나인 카페다. 읽기(구경)는 여러 명이 동시에 해도 되지만, 쓰기(화장실)는 한 명씩 순서대로다. lobste.rs 같은 사이트는 읽기가 쓰기보다 압도적으로 많은 read-heavy 워크로드다. 게시물 하나 쓰면 수백 번 읽힌다. 이런 패턴에서는 "쓰기 하나만 가능"이 실질적 병목이 되지 않는다.

그리고 "SQLite는 단일 서버여야 한다"는 제약도 있다. NFS로 파일 공유하는 방식은 공식적으로 권장되지 않는다. 그래서 SQLite를 쓴다는 건 곧 "우리 서비스는 한 대로 돌린다"는 아키텍처 선언이기도 하다. 요즘 서버 한 대 스펙이면 상당한 규모를 감당하니, 생각보다 많은 서비스가 여기 해당한다.

실제 규모 감을 위해: lobste.rs의 SQLite 파일 크기는 원문 댓글 기준 약 3.8GB라고 언급됐다. 커뮤니티 사이트 전체 데이터가 4GB 미만이라는 얘기다. 이 정도면 파일 하나로 충분히 관리된다.

3. 마이그레이션 과정 — 스키마 변환과 첫 배포 실패

이번 사례에서 가장 배울 게 많은 부분이 여기다. 첫 배포는 실패했다.

첫 배포가 CPU 100% 찍고 롤백된 이유

2월 21일 첫 배포에서, 읽기 전용 트래픽만으로 모든 CPU가 100%에 도달했다. 사이트를 읽기 전용 상태로 열었는데도 서버가 못 버틴 거다. 원인을 못 찾아 롤백했다.

가장 뼈아팠던 조건은 이거다. 팀이 프로덕션 DB에 접근할 수 없었다. 실제 데이터로 성능을 미리 재현할 방법이 없었다는 뜻이다. 그래서 로컬에서 실제 데이터 절반 크기의 테스트 데이터를 생성했는데, 이걸 만드는 데만 일주일이 걸렸다.

원인은 결국 두 가지였다.

  1. 대형 테이블에서 두 개의 쿼리가 전체 테이블 스캔(full table scan)을 일으킴
  2. 별도의 N+1 문제

여기서 실무 교훈. MariaDB에서 잘 돌던 쿼리가 SQLite에서 똑같이 잘 돈다는 보장이 없다. 옵티마이저가 다르고, 인덱스 활용 방식이 다르다. MariaDB가 알아서 커버해주던 비효율 쿼리가 SQLite로 오면 그대로 풀스캔으로 터질 수 있다.

lobste.rs 팀이 회고에서 남긴 가장 실용적인 조언이 이거다. "테스트 중 전체 테이블 스캔이 발생하면 테스트가 실패하도록 설정할 수 있었다면 첫 배포 문제를 미리 잡았을 것." 이건 우리도 바로 적용할 수 있다. SQLite는 쿼리 플랜을 확인할 수 있다.

# 쿼리가 인덱스를 타는지 풀스캔하는지 확인
sqlite3 lobsters.db "EXPLAIN QUERY PLAN
  SELECT * FROM stories WHERE user_id = 42 ORDER BY created_at DESC;"

# 인덱스 잘 타는 경우 출력 예시
QUERY PLAN
`--SEARCH stories USING INDEX index_stories_on_user_id (user_id=?)

# 풀스캔 나는 경우 출력 예시 (이게 나오면 위험 신호)
QUERY PLAN
`--SCAN stories

출력에 SCAN이 뜨고 그 대상이 큰 테이블이면 인덱스를 검토해야 한다. SEARCH ... USING INDEX가 나오면 정상이다. 이걸 CI 테스트에 엮어서 "큰 테이블에 SCAN이 뜨면 빌드 실패"로 만들면 lobste.rs가 겪은 사고를 예방할 수 있다.

스키마 변환에서 걸린 호환성 문제

MariaDB와 SQLite는 타입 시스템과 함수가 다르다. lobste.rs가 실제로 부딪힌 것들:

  • unsigned bigint 미지원: MariaDB에서 일부 ID에 쓰던 unsigned bigint를 SQLite가 지원하지 않아 그냥 bigint로 변경했다.
  • 없는 함수 직접 구현: SQLite에 없던 regexp, if, stddev를 SQLite gem의 사용자 정의 함수(UDF)로 구현했다. (참고로 최신 SQLite에는 iif 함수가 이미 있어서, 이 부분은 버전 확인이 필요하다.)
  • 정렬/대소문자 규칙: MariaDB에서 utf8mb4_general_ci를 쓰던 걸 SQLite에서는 NOCASE로 바꿨는데, NOCASE는 ASCII 문자만 대소문자를 구분 없이 처리한다. 전체 UTF 대소문자 접기(case folding)는 안 된다.

이 마지막 항목은 한국어 서비스에 특히 중요하다. 한글은 대소문자 개념이 없어서 직접 영향은 적지만, 영문/유니코드가 섞인 검색·정렬에서 MariaDB와 다르게 동작할 수 있다. "왜 대문자로 검색하면 결과가 다르지?" 같은 버그가 여기서 나온다.

또 전문 검색(full-text search)은 SQLite FTS를 쓰는데, 순위 산정 알고리즘이 MariaDB와 달라서 검색 결과 순서가 바뀔 수 있다. lobste.rs 팀도 "검색 테스트는 있지만 순위 검증은 적어서 기능적 변화 가능성이 있다"고 인정했다. 마이그레이션 후 검색 결과가 미묘하게 달라지는 건 각오해야 한다.

4. 운영 결과 — 실제로 뭐가 좋아졌나

7월 11일 두 번째 배포는 성공했다. 결과를 원문 기준으로 정리하면:

  • 전환 후 사이트 정상 운영, CPU·메모리 사용량 안정
  • 월요일 트래픽 급증(주간 피크)에서도 문제 없음
  • CPU·메모리 사용량 감소, 체감 응답성 개선
  • MariaDB VPS 종료로 VPS 비용 절반으로 감소

여기서 짚어야 할 포인트. 관리자도 "놀랍게도 CPU와 RAM이 모두 감소했다"고 했다. 직관과 반대다. DB를 앱 서버 안으로 끌고 왔는데 왜 리소스가 줄었을까?

추측이지만 합리적인 설명은 이렇다. MariaDB 시절엔 별도 VPS와 앱 서버 사이에 네트워크 왕복, 커넥션 관리, 직렬화/역직렬화 오버헤드가 있었다. SQLite는 같은 프로세스 안에서 함수 호출로 데이터를 읽으니 이 오버헤드가 통째로 사라진다. 네트워크를 안 타는 게 생각보다 크다. (단, 이건 이번 사례에 대한 해석이고, 모든 워크로드에서 재현되는 일반 법칙은 아니다.)

주의할 점: 이 "비용 절반, 리소스 감소"는 lobste.rs 규모와 read-heavy 패턴에서 나온 결과다. 원문에 구체적 수치(전후 CPU %, 응답시간 ms)는 차트로만 언급됐고 텍스트로는 없다. 우리 서비스에 그대로 대입하지 말고 방향성만 참고하자.

5. SQLite 프로덕션 운영 시 주의할 점 — 흔한 함정

기본 설정으로 쓰면 안 된다

SQLite를 서버에서 제대로 쓰려면 PRAGMA 튜닝이 필수다. 관리자가 원문에 공개한 lobste.rs의 실제 설정이 좋은 출발점이다.

PRAGMA foreign_keys=ON;
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = 5000;
PRAGMA temp_store = MEMORY;
PRAGMA mmap_size = 134217728;
PRAGMA journal_size_limit = 67108864;
PRAGMA cache_size = 2000;

핵심만 보면:

  • journal_mode = WAL: Write-Ahead Logging. 이게 없으면 읽기가 쓰기를 블록한다. 서버용이면 필수다.
  • busy_timeout = 5000: 쓰기가 겹쳐서 잠겼을 때 5초까지 재시도하고 기다린다. 이게 없으면 바로 에러가 터진다.
  • synchronous = NORMAL: WAL 모드와 조합하면 성능과 안정성의 균형점.

흔한 함정: SQLITE_BUSY

SQLite를 서버에서 처음 굴리면 거의 반드시 만나는 에러가 이거다.

SQLite3::BusyException: database is locked (SQLite3::BusyException)

또는 로그에 이렇게 뜬다.

Error: database is locked (5) (SQLITE_BUSY)

쓰기 트랜잭션이 겹쳤는데 대기 시간을 초과했을 때 발생한다. 원인은 보통 둘 중 하나다.

  1. busy_timeout이 설정 안 됐거나 너무 짧다
  2. 트랜잭션이 지연 모드(deferred)로 시작돼서 잠금 획득 시점이 꼬였다

관리자가 중요한 힌트를 남겼다. "busy_timeout이 제대로 작동하려면 즉시 트랜잭션 모드(immediate transaction)도 켜야 한다." 기본 deferred 트랜잭션은 첫 쓰기 순간에야 잠금을 잡으려 하는데, 이때 이미 다른 쓰기가 진행 중이면 busy_timeout이 기대대로 안 먹힐 수 있다. immediate 모드는 트랜잭션 시작 시점에 쓰기 잠금을 확보하러 가서 이 경합을 깔끔하게 처리한다.

Rails라면 database.yml에서 이렇게 걸 수 있다.

# config/database.yml
production:
  adapter: sqlite3
  database: storage/production.sqlite3
  timeout: 5000
  # 트랜잭션 기본 모드를 IMMEDIATE로
  default_transaction_mode: IMMEDIATE
  pragmas:
    journal_mode: WAL
    synchronous: NORMAL
    foreign_keys: true
    busy_timeout: 5000
    cache_size: 2000
    temp_store: MEMORY

흔한 함정: NVMe와 안 맞는 기본값

관리자가 희미하게 기억한다며 남긴 얘기가 하나 있다. SQLite의 디스크 쓰기 방식 일부가 HDD에는 유리하지만 NVMe에는 부적합했던 부분이 있었다는 거다. 요즘 서버는 대부분 NVMe SSD니까, 기본값을 무조건 신뢰하지 말고 워크로드로 벤치마크해보는 게 안전하다. (구체적으로 어떤 PRAGMA인지는 원문에 명시 안 됐다. 공식 문서 확인 필요.)

백업은 어떻게?

파일 하나라서 백업이 오히려 단순하다. lobste.rs는 매일 밤 실행되는 작업이 restic을 호출하는 방식을 쓴다. 다만 WAL 모드에서는 파일을 그냥 복사하면 WAL에 아직 반영 안 된 데이터가 누락될 수 있으니, 안전하게 하려면 SQLite의 백업 API나 온라인 백업 명령을 쓰는 게 좋다.

# WAL을 고려한 안전한 온라인 백업 (사이트 돌아가는 중에도 OK)
sqlite3 production.sqlite3 ".backup '/backups/production-$(date +%F).sqlite3'"

# 백업 파일 무결성 검증
sqlite3 /backups/production-2025-01-15.sqlite3 "PRAGMA integrity_check;"

# 정상이면 출력
ok

실시간 복제/PITR이 필요하면 원문 댓글에서도 언급된 litestream 같은 도구를 검토하면 된다. lobste.rs는 restic 야간 백업으로 충분하다고 판단한 케이스다.

6. 정리 — 언제 이 선택이 옳은가

한 줄 요약: read-heavy이고 단일 서버로 충분한 규모라면, SQLite로 옮기는 게 비용과 운영 복잡도를 동시에 줄이는 현실적 선택이 될 수 있다.

이 선택이 맞는 경우:

  • 읽기가 쓰기보다 압도적으로 많은 워크로드 (커뮤니티, 블로그, 문서 사이트, 대시보드)
  • 단일 서버 아키텍처로 감당되는 규모 (데이터 수 GB 수준, 앞으로도 수평 확장 계획 없음)
  • 별도 DB 서버 운영 부담과 비용을 줄이고 싶은 소규모 팀

이 선택이 위험한 경우:

  • 쓰기가 매우 많거나 여러 서버가 동시에 써야 하는 워크로드 → 쓰기 직렬화가 병목이 된다
  • 수평 확장이 전제인 서비스 → SQLite는 단일 서버 가정을 강제한다
  • MariaDB/MySQL의 특정 함수·타입·정렬 규칙에 깊게 의존하는 코드베이스 → 호환성 작업 비용이 크다

lobste.rs 사례의 진짜 교훈은 SQLite 자체보다 그 판단 과정에 있다고 본다. "기본값으로 PostgreSQL"이라는 관성을 의심하고, 우리 서비스의 실제 규모와 접근 패턴을 정직하게 본 다음, 오지 않을 미래를 위한 복잡도를 지불하지 않기로 한 결정. 그리고 프로덕션 DB 접근 없이 배포했다가 CPU 100%로 롤백한 실패 경험까지 투명하게 공개한 것. 이 두 가지가 이 글이 단순 릴리스 공지가 아닌 이유다.

덧붙여, 마이그레이션 전에 꼭 하고 넘어갈 것 하나만 고르라면 "실제 데이터 규모의 테스트 셋으로 EXPLAIN QUERY PLAN을 돌려 풀스캔을 사전에 잡는 것"이다. lobste.rs가 일주일 데이터 생성하고 CPU 100% 맞고 나서야 배운 걸, 우리는 미리 하면 된다.

참고 자료

728x90

월말에 Anthropic 사용량 대시보드를 열었다가 "우리가 이걸 이렇게 많이 썼나?" 싶었던 적 있으면 이 글이 도움이 될 거다. Systima에서 Claude Code와 OpenCode를 같은 모델·같은 머신·같은 태스크에 물려놓고, API 경계에서 오가는 요청 페이로드를 전부 뜯어본 실측 리포트를 냈다. 결론부터 말하면 Claude Code는 사용자가 프롬프트를 치기도 전에 약 33,000 토큰을 소비하고, OpenCode는 약 7,000 토큰이었다. 약 4.7배 차이다.

단순히 "Claude Code가 비싸다"로 끝날 얘기가 아니다. 이 오버헤드가 어디서 나오는지, 캐싱은 뭘 살려주고 뭘 못 살려주는지, 그리고 실무에서 어떤 셋업일 때 이게 재앙이 되는지를 알아야 도구를 제대로 고를 수 있다.

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

AI 코딩 에이전트를 팀에 도입해본 사람이면 알 거다. 처음엔 "토큰? 얼마 안 나오겠지" 하다가, 여러 명이 하루종일 돌리기 시작하면 청구서가 예상 밖으로 튄다. 문제는 토큰 오버헤드가 곧 비용이자 지연시간이자 컨텍스트 예산이라는 점이다.

에이전트가 매 턴마다 보내는 건 여러분의 프롬프트만이 아니다. 시스템 프롬프트, 툴 스키마(도구 정의 JSON), 그리고 각종 주입된 스캐폴딩이 앞에 붙는다. 이 베이스라인은 매 요청마다 다시 전송되거나 캐시에서 다시 읽힌다. 즉 33k 토큰짜리 베이스라인이면, 200k 컨텍스트 윈도우의 약 1/6을 코드 한 줄 넣기도 전에 이미 잡아먹고 시작하는 셈이다.

특히 EU AI Act Article 12처럼 에이전트 동작을 로깅하고 설명할 수 있어야 하는 규제 환경이라면, "내 에이전트가 실제로 뭘 보내는가"를 소문이 아니라 데이터로 답할 수 있어야 한다. 이 리포트가 딱 그걸 한 거다.

2. 33k는 어디서 나오는가 — 시스템 프롬프트 해부

Systima는 하네스(harness, 여기선 Claude Code / OpenCode)와 모델 엔드포인트 사이에 로깅 프록시를 끼워넣었다. 구조는 이렇다.

harness (Claude Code / OpenCode)
    → logging proxy (요청 페이로드 + 응답 usage 캡처)
        → model endpoint

프록시는 두 가지를 기록한다. 하네스가 실제로 내보낸 JSON 페이로드(시스템 블록, 툴 스키마, 메시지)와, API가 반환한 usage 블록(input 토큰, cache write, cache read, output 토큰). 페이로드는 "무엇을 보냈나"의 원본 진실, usage는 "무엇이 과금됐나"의 원본 진실이다.

T1: "OK라고만 답해" — 순수 오버헤드 측정

가장 극단적인 테스트다. 프롬프트는 딱 22자, "Reply with exactly: OK". 여기에 각 하네스가 뭘 얹어 보냈는지 보자.

구성요소 Claude Code OpenCode
시스템 프롬프트 27,344자 (3블록) 9,324자 (1블록)
툴 스키마 27개 툴, 99,778자 10개 툴, 20,856자
첫 메시지 스캐폴딩 7,997자 (system-reminder 블록) 없음
실제 프롬프트 22자 22자
첫 턴 페이로드(보정치) 약 32,800 토큰 약 6,900 토큰

핵심은 두 하네스 모두 툴 스키마가 지배적이라는 점이다. Claude Code의 약 33k 중 약 24,000이 툴 정의고, OpenCode의 약 6,900 중 약 4,800이 툴 정의다.

왜 Claude Code가 이렇게 무거운가? 27개 툴이 코딩 코어만이 아니라 백그라운드 에이전트/오케스트레이션 스위트 전체를 포함하기 때문이다. CronCreate, Monitor, Task 패밀리, worktree 관리, 푸시 알림까지 다 들어있다. 게다가 첫 유저 메시지 앞에 세 개의 리마인더 블록(위임용 에이전트 카탈로그, 사용 가능 스킬 카탈로그, 유저 컨텍스트)이 주입된다.

반면 OpenCode는 거의 미니멀하다. "You are OpenCode, the best coding agent on the planet"으로 시작하는 시스템 블록 하나, 클래식한 코딩 툴 10개, 그리고 여러분의 프롬프트가 유일한 유저 콘텐츠다.

툴을 다 꺼도 여전히 3배

툴을 빼고 시스템 프롬프트 자체만 비교하면, Claude Code는 26,891자(약 6.5k 토큰), OpenCode는 8,811자(약 2.0k 토큰)다. 툴을 전부 제거해도 Claude Code의 명령어 세트가 3배 이상 크다. 나머지는 톤 규칙, 안전 가이드, 태스크 관리 지침, 환경 설명 같은 "행동 강령"이다.

직접 확인하고 싶다면 툴을 끄고 돌려볼 수 있다.

# Claude Code에서 툴 비활성화
claude --tools

# OpenCode에서 툴 비활성화 (설정)
# opencode 설정 JSON에
{ "tools": { "*": false } }

3. 캐싱은 어디까지 살려주나 — 여기가 진짜 함정

"어차피 프롬프트 캐싱 걸리니까 괜찮은 거 아니야?"라고 생각하기 쉽다. 여기가 실무에서 제일 많이 착각하는 지점이다.

OpenCode는 캡처된 모든 실행에서 요청 프리픽스가 바이트 단위로 동일했다. 세션당 한 번 캐시에 쓰고, 이후엔 몇 푼짜리 cache read로 다시 읽는다. 이상적인 캐시 활용이다.

반면 Claude Code는 세션 중간에 수만 토큰의 prompt-cache 토큰을 다시 썼다. 같은 태스크에서 OpenCode보다 최대 54배 많은 cache write를 기록했다. cache write는 프리미엄 가격으로 과금된다. 대시보드 숫자가 치솟는 주범이 바로 이거다.

왜 다시 쓰는가: Claude Code는 대화가 진행되면서 system-reminder 블록을 추가로 주입한다. 첫 턴엔 3개, 첫 툴 라운드트립엔 4개로 늘어난다. 스캐폴딩이 턴 수에 따라 자라니까, 프리픽스가 바뀌고 캐시가 깨지는 것으로 보인다.

중요한 사실 하나. 세 가지는 캐시 할인과 무관하게 무조건 스케일한다.

  • 첫 턴의 cache write (프리미엄 과금)
  • 매 턴의 cache read (싸긴 하지만 0은 아님)
  • 컨텍스트 윈도우 소비 — 이건 어떤 캐시 할인으로도 줄지 않는다

세 번째가 핵심이다. 33k 베이스라인은 매 턴이 200k 윈도우의 1/6 지점에서 시작한다는 뜻이고, 캐싱이 아무리 잘 돼도 이 컨텍스트 압박은 절대 안 줄어든다.

모델을 바꾸면 그림이 달라진다

Systima가 T1을 Claude Fable 5로 재실행했더니 격차가 줄었다. 이유가 흥미롭다. Claude Code의 시스템 프롬프트는 모델 조건부다. Sonnet에는 27,787자를 보냈지만 Fable에는 10,526자만 보냈고, 툴 스키마도 99,778자에서 82,283자로 줄었다. 같은 27개 툴인데 doctrine이 훨씬 적다. OpenCode 페이로드는 두 모델에서 바이트 단위로 동일했다.

결과적으로 페이로드 기준 격차가 Sonnet에서 4.7배, Fable에서 약 3.3배로 좁혀졌다. 여전히 훨씬 무겁지만, 배수는 모델에 따라 다르다는 점을 기억하자.

4. 실무 관점 — 멀티플라이어와 흔한 함정

T1은 최선의 시나리오다. 현실 세션은 저렇게 린하게 시작해서 짧게 끝나지 않는다. 실무에서 얹히는 레이어들을 보자.

멀티플라이어 1: 지시 파일 (AGENTS.md / CLAUDE.md)

프로덕션 레포의 72KB 지시 파일을 워크스페이스에 넣고 T1을 재실행하니, 양쪽 다 요청당 20,000 토큰 이상이 추가됐다.

  • OpenCode: 13,152 → 33,336 토큰
  • Claude Code: 39,005 → 59,243 토큰

여기 첫 번째 함정. Claude Code 2.1.207은 AGENTS.md를 완전히 무시했다. CLAUDE.md로 이름을 바꿔야만 읽었고, 그것도 시스템 프롬프트가 아니라 첫 유저 메시지에 주입했다. OpenCode는 두 파일명을 다 읽고 시스템 프롬프트에 넣는다.

이게 왜 무서운가 하면, 무시된 지시 파일은 아무 에러도 안 낸다. 팀 컨벤션을 열심히 적어놨는데 에이전트가 조용히 무시하고 있는 상황을 상상해보라. 파일명을 잘못 쓰면 이런 식으로 조용히 아무 일도 안 일어난다.

# 실무 체크: 하네스가 어떤 파일명을 실제로 먹는지 확인
$ ls -la
-rw-r--r--  1 dev  staff  73728  AGENTS.md   # Claude Code가 무시함!

# 로그를 보면 지시 파일 관련 주입이 아예 없다
# (에러 메시지가 안 나오는 게 오히려 함정)

# 해결: CLAUDE.md로 심볼릭 링크 또는 리네임
$ ln -s AGENTS.md CLAUDE.md
$ ls -la
lrwxr-xr-x  1 dev  staff      9  CLAUDE.md -> AGENTS.md
-rw-r--r--  1 dev  staff  73728  AGENTS.md

멀티플라이어 2: MCP 서버

MCP 서버를 붙이면 스키마가 프롬프트에 추가된다. 스키마는 하네스 간 동일하니 세금도 거의 같다. 작은 서버당 요청당 약 1,000~1,400 토큰. 5개 서버를 붙였더니 Claude Code는 페이로드 기준 4,900 토큰, OpenCode는 metered 6,967 토큰이 늘었고, 툴 개수가 각각 27→69, 10→52로 불었다.

여기 두 번째 조용한 함정. Claude Code는 print 모드에서 프로젝트 스코프 .mcp.json을 조용히 무시했다. --mcp-config 플래그를 명시해야 읽었다. 서버가 붙어있다고 가정했는데 실제론 안 붙어있는 상황이 생긴다.

# .mcp.json이 있는데도 print 모드에서 서버가 안 잡히는 경우
$ claude -p "list available tools"
# → MCP 툴이 목록에 안 보임 (에러도 없음)

# 명시적으로 config를 넘겨야 함
$ claude -p "list available tools" --mcp-config .mcp.json
# → 이제 MCP 서버 툴이 스키마에 포함됨

공교육용 교훈은 하나다. 서버가 붙었다고 가정하지 말고 경계(API boundary)에서 검증하라. 프록시 로그든 툴 목록이든, 실제 페이로드를 눈으로 확인해야 한다.

멀티플라이어 3: 프레임워크 템플릿

BMAD 같은 스토리 기반 워크플로우 프레임워크는 슬래시 커맨드를 페르소나·프로토콜·체크리스트로 가득한 큰 템플릿으로 확장한다. 8,405자짜리 대표 템플릿을 T3 프롬프트로 넣어봤더니, 템플릿 자체는 약 2,100 토큰이지만 대화 히스토리에 들어가서 이후 모든 요청이 다시 실어 나른다. 9-요청 세션이면 9번 재전송된다. 프레임워크 세금 = 템플릿 크기 × 요청 수, 그리고 이건 위의 모든 것 위에 쌓인다.

멀티플라이어 4: 서브에이전트 — 여기서 총액이 폭발

이게 제일 무섭다. 작업을 서브에이전트 2개로 팬아웃했더니, 직접 하면 121,000 토큰이던 작은 태스크가 513,000 토큰으로 뛰었다. 서브에이전트마다 자기 부트스트랩 비용이 있고, 부모가 다시 그 트랜스크립트를 소비하기 때문이다. "병렬로 나누면 빠르겠지"라고 서브에이전트를 남발하면 토큰 청구서가 4배 넘게 튀는 걸 각오해야 한다.

반전: 멀티스텝에선 Claude Code가 더 쌀 수도 있다

모든 게 Claude Code 불리로만 흐르는 건 아니다. T3(write-run-test-fix 루프)에서는 예상이 뒤집혔다.

지표 Claude Code OpenCode
모델 요청 수 3 9 (+타이틀 콜 1)
툴 호출 방식 한 라운드트립에 병렬 배치 턴당 툴 1개
누적 metered input 약 121,000 토큰 약 132,000 토큰

Claude Code는 전체 작업(파일 쓰기 2개 + 스크립트 실행 2개)을 단일 병렬 툴 라운드트립으로 배치했다. OpenCode는 턴당 정확히 툴 하나씩 9번 돌았다. 베이스라인은 매 요청마다 재전송되므로 요청 수가 베이스라인을 곱한다. OpenCode는 약 7k 베이스라인을 9번 냈고, Claude Code는 약 33k를 3번 냈다. 결과적으로 총액이 수렴했다.

공식: 전체 작업 input ≈ 베이스라인 × 요청 수 + 대화 성장분. 큰 베이스라인 + 공격적 배칭 하네스와, 작은 베이스라인 + 직렬 하네스가 같은 지점에 착지할 수 있다. 미터는 높은 데서 시작하지만, 세션이 어떻게 전개되느냐가 누가 더 쓸지를 결정한다.

5. 정리 — 누가 언제 뭘 써야 하나

한 줄 요약: Claude Code는 시작 오버헤드가 크지만 툴 배칭이 강하고, OpenCode는 베이스라인이 가볍지만 턴마다 그 비용을 반복 지불한다.

  • 짧고 단순한 반복 작업이 많다면 (커밋 메시지, 소규모 리팩터, 단발성 질의): OpenCode가 유리하다. 33k를 매번 태울 이유가 없다.
  • 복잡한 멀티스텝 작업을 배치로 처리한다면: Claude Code의 병렬 배칭이 요청 수를 줄여 총액을 낮출 수 있다.
  • 컨텍스트 윈도우가 빡빡한 대형 코드베이스라면: 캐시 할인과 무관하게 33k가 윈도우를 잡아먹는다는 걸 기억하라. 여기선 베이스라인이 가벼운 쪽이 실질적으로 유리하다.
  • 규제 환경(EU AI Act 등)이라면: 로깅 프록시를 경계에 두고 실제 페이로드를 캡처하라. "무엇을 보내는가"를 데이터로 답할 수 있어야 한다.

그리고 도입 전 반드시 확인할 것 세 가지: ① 지시 파일 파일명을 하네스가 실제로 먹는지(AGENTS.md vs CLAUDE.md), ② MCP 서버가 경계에서 진짜 붙었는지, ③ 서브에이전트 팬아웃이 정말 필요한지(4배 폭발 각오). 이 셋만 챙겨도 예상 밖 청구서는 크게 줄어든다.

마지막으로, 이 모든 수치는 특정 버전(Claude Code 2.1.207, OpenCode 1.17.18, 2026년 7월 claude-sonnet-4-5 / claude-fable-5 기준)에서 나온 것이다. 하네스와 모델은 계속 바뀌므로, 여러분 환경에서는 직접 프록시를 끼워 측정하는 게 정답이다. 소문 말고 데이터로.

참고 자료

728x90
728x90

왜 지금 이 얘기가 나왔나

PgBouncer 돌려본 사람이면 다 알겠지만, 이 녀석은 PostgreSQL 앞단에 세우는 가장 흔한 커넥션 풀러다. 근데 여기엔 오래된 함정이 하나 있다. PgBouncer는 단일 스레드다. 프로세스 하나가 CPU 코어 하나만 쓴다. 16 vCPU짜리 인스턴스에 올려놔도 실제로는 코어 하나만 죽어라 일하고 나머지 15개는 놀고 있다.

실무에서 이걸 언제 만나느냐. 서비스 초반엔 문제없다. 근데 트래픽이 붙어서 초당 트랜잭션이 수만 건씩 들어오기 시작하면, Postgres는 아직 여유가 있는데 이상하게 처리량이 안 올라가는 구간이 온다. top 찍어보면 PgBouncer 프로세스 하나가 CPU 97~100%에 붙어있고, 인스턴스 전체 사용률은 10%도 안 되는 그림이 나온다. 이게 바로 Postgres가 아니라 풀러가 병목인 상황이다.

ClickHouse가 Managed Postgres를 만들면서 이 문제를 정면으로 풀었고, 그 결과를 공유한 게 이번 글이다. 핵심 숫자만 먼저 보면, 동일한 16 vCPU 인스턴스(c7i.4xlarge)에서 단일 프로세스는 최대 약 8.7만 TPS에서 막히고 오히려 부하가 늘수록 7.7만 TPS로 떨어지는데, 프로세스를 16개로 늘린 fleet 구성은 약 33.6만 TPS까지 올라갔다. 대략 4배다.

동작 원리: 코어 하나짜리 계산대를 여러 개로

비유하자면 이렇다. PgBouncer 단일 프로세스는 손님이 몰리는 대형마트에 계산대를 딱 하나만 열어놓은 거다. 마트 건물(16 vCPU)은 널찍한데 계산원 한 명이 모든 손님을 처리한다. 손님이 적을 땐 문제없지만, 몰리기 시작하면 그 계산대 앞에 줄이 무한정 길어진다.

해결책은 단순하다. 계산대를 코어 수만큼 여러 개 열면 된다. 문제는 "손님이 어느 계산대로 갈지 어떻게 나누느냐"인데, 여기서 리눅스 커널의 SO_REUSEPORT가 등장한다.

SO_REUSEPORT: 같은 포트를 여러 프로세스가 공유

원래는 하나의 포트를 하나의 프로세스만 bind할 수 있다. 근데 SO_REUSEPORT 옵션을 켜면 여러 프로세스가 같은 포트에 동시에 bind할 수 있고, 커널이 들어오는 연결을 프로세스들에게 알아서 분산해준다. 클라이언트 입장에선 여전히 엔드포인트 하나(예: 6432 포트)에 접속하는 거고, 뒤에 PgBouncer가 16개 떠 있다는 걸 전혀 모른다.

이게 PgBouncer 공식 문서가 멀티코어 활용법으로 안내하는 정석 방법이다. PgBouncer 설정 파일에서 다음 항목을 켜면 된다.

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
so_reuseport = 1

; 트랜잭션 모드로 커넥션 반납을 빠르게
pool_mode = transaction

그리고 동일한 설정으로 프로세스를 코어 수만큼 띄운다. 확인은 이렇게 한다.

$ sudo ss -tlnp | grep 6432
LISTEN 0  128  0.0.0.0:6432  0.0.0.0:*  users:(("pgbouncer",pid=2841,fd=8))
LISTEN 0  128  0.0.0.0:6432  0.0.0.0:*  users:(("pgbouncer",pid=2842,fd=8))
LISTEN 0  128  0.0.0.0:6432  0.0.0.0:*  users:(("pgbouncer",pid=2843,fd=8))
...

이렇게 같은 0.0.0.0:6432를 여러 PID가 물고 있으면 so_reuseport가 제대로 걸린 거다. 만약 두 번째 프로세스 띄울 때 아래 에러가 뜨면 옵션이 안 켜진 거다.

FATAL: cannot bind socket: Address already in use

진짜 골치는 쿼리 취소(cancel)

여기가 이 글에서 제일 실무적인 포인트다. Postgres의 쿼리 취소 요청은 쿼리를 실행 중인 연결이 아니라, cancel key를 들고 완전히 새로운 연결로 들어온다. 그런데 so_reuseport는 이 새 연결을 커널이 아무 프로세스에나 던질 수 있다. 그러면 cancel 요청이 실제로 세션을 들고 있는 프로세스가 아니라 엉뚱한 프로세스로 도착하고, 그 프로세스는 "그런 쿼리 모르는데?" 하고 아무 일도 안 일어난다.

실무에서 이건 치명적이다. 사용자가 애플리케이션에서 무거운 쿼리를 취소했는데 실제 DB에선 계속 돌고 있는 상황이 벌어진다. ClickHouse는 이걸 peering으로 해결했다고 한다. 프로세스들이 서로의 존재를 알고 있어서, cancel이 엉뚱한 프로세스에 도착하면 실제로 세션을 소유한 프로세스로 전달(forward)해준다. fleet 전체에 걸쳐 취소가 정상 동작한다는 얘기다.

다만 이 peering 자체는 ClickHouse Managed Postgres의 구현으로 보이고, 오픈소스 PgBouncer를 직접 so_reuseport로 여러 개 띄우는 경우엔 이 cancel 문제를 별도로 신경 써야 한다. 직접 구성할 거라면 이 지점은 공식 문서 확인이 필요하다.

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

1. 커넥션 예산(budget)을 반드시 나눠라

가장 흔하게 사고 나는 지점이다. 프로세스를 16개 띄웠는데 각 프로세스의 max_client_connmax_db_connections를 그대로 두면, fleet 전체로는 예산이 16배가 된다. 그러면 Postgres 쪽 max_connections를 순식간에 초과해서 오버서브스크립션(oversubscription)이 터진다.

원문에서도 명확히 짚는다. max_client_connmax_db_connections프로세스 개수로 나눠서 설정해야 fleet 전체가 Postgres를 안전한 범위 안에서만 쓴다. 예를 들어 전체 목표가 클라이언트 4096 연결, DB 커넥션 400개라면 프로세스당 이렇게 잡는다.

[pgbouncer]
; 프로세스 16개로 나눈 값
max_client_conn = 256      ; 4096 / 16
max_db_connections = 25    ; 400 / 16 (올림)
default_pool_size = 25
pool_mode = transaction
so_reuseport = 1

이 계산을 빼먹으면 부하 테스트 중에 Postgres 쪽에서 이런 에러를 만난다.

FATAL: sorry, too many clients already

2. 단일 프로세스의 커넥션 한계 에러

반대로 예산을 너무 짜게 나누거나, 애초에 단일 프로세스로 버티다가 한계에 부딪히면 PgBouncer가 클라이언트를 이렇게 쳐낸다. 이 메시지는 애플리케이션 로그에서 자주 보게 되니 기억해두면 좋다.

FATAL: no more connections allowed (max_client_conn)

이 에러가 보이면 두 가지 중 하나다. (1) max_client_conn이 너무 낮거나, (2) 커넥션을 제때 반납 안 하는 애플리케이션이 있거나. 무작정 값만 올리기 전에 SHOW POOLS;cl_activecl_waiting부터 확인하는 게 순서다.

3. pool_mode는 transaction이 기본이어야 멀티프로세스가 산다

fleet 구성의 성능이 나오는 전제 조건이 transaction 모드다. 트랜잭션이 커밋되는 순간 서버 커넥션을 풀로 반납하기 때문에 적은 DB 커넥션으로도 많은 클라이언트를 돌릴 수 있다. session 모드로 두면 클라이언트가 연결을 물고 있는 내내 서버 커넥션을 점유해서, 코어를 늘려도 커넥션이 금방 고갈된다.

단, transaction 모드에선 세션 레벨 기능(SET 세션 변수, prepared statement, advisory lock 등)이 깨질 수 있다. 애플리케이션이나 ORM이 세션 상태에 의존하는지 먼저 점검해야 한다. 이건 코어 늘리기와 별개로 transaction 모드로 넘어갈 때 항상 걸리는 함정이다.

4. 언제부터가 fleet 도입 타이밍인가

원문 벤치마크 표를 보면 판단 기준이 명확하게 나온다.

Clients  Single TPS   Single CPU   Fleet TPS   Fleet CPU
   8        8,910        0.8%        6,450       2.9%
  32       54,203        5.2%       64,244      12.3%
  64       86,570        8.3%      219,439      31.9%
 128       83,463        8.1%      320,547      45.9%
 256       76,893        7.7%      336,469      48.9%

주목할 점: 클라이언트 8개일 때는 오히려 단일 프로세스가 더 빠르다. 병렬화할 게 없는데 fleet은 연결이 여러 프로세스로 얇게 흩어지니 손해다. 격차가 벌어지는 건 클라이언트 64개부터, 즉 진짜 동시성이 붙어서 코어 하나가 벽이 되는 지점이다.

그러니까 트래픽 적은 서비스에 무작정 16개 띄우는 건 오버엔지니어링이다. pidstat로 PgBouncer 프로세스가 코어 하나에 붙어서 안 떨어지는 게 확인될 때 도입을 검토하는 게 맞다.

$ pidstat -p $(pgrep -f pgbouncer | head -1) 1
Linux 6.x  (pooler-01)   x86_64  (16 CPU)

  UID   PID    %usr  %system  %CPU   CPU  Command
 1001  2841   72.00   25.00   97.00    3  pgbouncer

이렇게 %CPU가 97~100%에 계속 붙어있고 인스턴스 전체 사용률은 10% 아래라면, 코어 한 개짜리 벽에 부딪힌 전형적인 신호다.

대안

PgBouncer 멀티프로세스 구성 말고도 선택지는 있다. PgCat(Rust 기반 멀티스레드 풀러)이나 Odyssey(Yandex, 멀티스레드) 같은 애들은 애초에 멀티스레드로 설계돼서 프로세스를 여러 개 띄우는 번거로움 없이 코어를 다 쓴다. cancel forwarding이나 예산 분배 같은 걸 직접 관리하기 싫다면 이쪽도 검토할 만하다. 다만 PgBouncer만큼 검증되고 자료가 많은 건 아니라, 운영 안정성과 커뮤니티 지원을 저울질해야 한다.

정리

한 줄 요약: PgBouncer는 단일 스레드라 코어 하나가 병목이 되며, so_reuseport로 코어 수만큼 프로세스를 띄우고 커넥션 예산을 나누면 처리량을 최대 4배까지 끌어올릴 수 있다.

  • 이걸 써야 하는 사람: 16 vCPU 이상 인스턴스에 PgBouncer 올렸는데, Postgres는 여유 있는데 풀러 프로세스가 코어 하나에 붙어 처리량이 막힌 팀.
  • 아직 안 써도 되는 사람: 동시 연결이 수십 개 수준인 소규모 서비스. 오히려 단일 프로세스가 간단하고 미세하게 빠르다.
  • 도입 전 체크리스트: (1) so_reuseport = 1 켜고 ss로 확인 → (2) max_client_conn·max_db_connections를 프로세스 수로 나눴는가 → (3) transaction 모드 전환 시 세션 상태 의존성 점검 → (4) 쿼리 cancel이 fleet 전체에서 동작하는지 검증(직접 구성 시 특히).

참고 자료

728x90
728x90

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

2026년 3월, Kubernetes SIG Network가 관리하던 ingress-nginx 컨트롤러가 공식 은퇴했다. 여기서 오해하면 안 되는 게 하나 있다. Ingress API 자체가 없어지는 게 아니다. Ingress 리소스(networking.k8s.io/v1)는 여전히 살아있고 잘 동작한다. 은퇴하는 건 커뮤니티가 유지보수하던 ingress-nginx 컨트롤러 구현체다.

이게 왜 실무에서 큰 문제냐면, 국내 대부분의 온프렘·EKS·GKE 클러스터가 사실상 이 컨트롤러 하나로 트래픽을 다 받고 있기 때문이다. Helm chart 한 번 깔고 nginx.ingress.kubernetes.io/* 어노테이션으로 리라이트, 타임아웃, 인증서 설정 다 해온 팀이 압도적으로 많다. 은퇴 이후 상황을 원문은 이렇게 정리한다:

  • CVE 미패치 — 새 취약점이 터져도 공식 패치가 안 나온다. 인그레스는 클러스터 진입점이라 이게 제일 무섭다.
  • 기능 업데이트 중단 — 새 Kubernetes 버전과의 호환성 검증도 더 이상 보장 안 된다.
  • 커뮤니티 지원 종료 — 이슈 올려도 답이 안 온다.

당장 클러스터가 죽는 건 아니다. 하지만 "동작하니까 놔둔다"는 시한폭탄이다. 특히 금융·공공처럼 보안 감사받는 환경이면 미패치 CVE가 감사 지적사항으로 바로 걸린다.

2. 먼저 우리 클러스터 영향 범위부터 파악하자

대안을 고르기 전에, 지금 뭘 얼마나 쓰고 있는지 진단부터 해야 한다. 마이그레이션 난이도는 순전히 proprietary 어노테이션을 얼마나 썼느냐에 달려있다.

먼저 클러스터에서 nginx 어노테이션을 쓰는 인그레스가 몇 개나 되는지 훑어보자.

kubectl get ingress -A -o json \
  | jq -r '.items[]
    | select(.metadata.annotations != null)
    | .metadata.namespace + "/" + .metadata.name + " -> " +
      (.metadata.annotations | keys | map(select(startswith("nginx.ingress.kubernetes.io"))) | join(", "))'

실제 출력은 이런 식으로 나온다:

default/web-app -> nginx.ingress.kubernetes.io/rewrite-target, nginx.ingress.kubernetes.io/ssl-redirect
payment/api-gw -> nginx.ingress.kubernetes.io/proxy-body-size, nginx.ingress.kubernetes.io/proxy-read-timeout, nginx.ingress.kubernetes.io/configuration-snippet
monitoring/grafana -> nginx.ingress.kubernetes.io/auth-type, nginx.ingress.kubernetes.io/auth-secret

여기서 configuration-snippet이나 server-snippet이 보이면 긴장해야 한다. 이건 raw nginx 설정을 그대로 박아넣은 거라서, 어떤 대안 컨트롤러로 가든 1:1 자동 변환이 안 된다. 손으로 다시 짜야 한다. 이게 마이그레이션에서 제일 시간 잡아먹는 부분이다.

어노테이션 사용 빈도를 카운트해서 우선순위를 매기면 계획 세우기 편하다:

kubectl get ingress -A -o json \
  | jq -r '.items[].metadata.annotations // {} | keys[]' \
  | grep '^nginx.ingress.kubernetes.io' \
  | sort | uniq -c | sort -rn
     14 nginx.ingress.kubernetes.io/ssl-redirect
     11 nginx.ingress.kubernetes.io/rewrite-target
      8 nginx.ingress.kubernetes.io/proxy-body-size
      3 nginx.ingress.kubernetes.io/configuration-snippet
      1 nginx.ingress.kubernetes.io/auth-url

이 표만 봐도 "snippet 3개, auth 1개만 손보면 나머지는 표준 매핑으로 넘어가겠구나"가 보인다.

3. 대안 컨트롤러 비교와 두 갈래 길

원문은 큰 틀에서 두 가지 경로를 제시한다. 여기에 실무에서 자주 후보로 오르는 Traefik, Nginx Ingress Operator까지 얹어서 정리한다.

Path A: Lift-and-Shift — Ingress API 유지하고 컨트롤러만 교체

기존 Ingress YAML은 그대로 두고, ingressClass만 다른 Envoy 기반 컨트롤러(원문에서는 Contour를 예로 듦)로 바꾸는 방식이다. 라우팅 정의를 갈아엎지 않으니 당장의 충격이 적다.

단, 원문이 명확히 짚는 함정이 있다. base Ingress 리소스는 그대로여도, nginx.ingress.kubernetes.io/* 어노테이션은 전부 무효가 된다. Contour는 이걸 자기 어노테이션이나 CRD(HTTPProxy)로 다시 만들어야 한다. Traefik으로 가도 마찬가지로 traefik.ingress.kubernetes.io/* 체계로 갈아타야 한다.

Path B: Gateway API로 아키텍처 현대화

Gateway API는 원문 표현으로 "Ingress의 upstream-backed 후계자"다. 핵심은 역할 분리(role-oriented design)다.

비유하자면 이렇다. 기존 Ingress는 인프라팀이 건물 전체 설계도(모놀리식 정의)를 혼자 들고 있는 구조였다. Gateway API는 이걸 나눈다:

  • 인프라팀Gateway 리소스로 "이 건물의 출입구(리스너, 포트, 인증서)"를 관리하고
  • 개발팀HTTPRoute로 "내 사무실로 가는 복도(경로 라우팅)"를 각자 관리한다.

원문에 따르면 Gateway API는 traffic splitting, 고급 header matching, 안전한 cross-namespace 라우팅을 코어 스펙에 내장하고 있다. ingress-nginx 시절 어노테이션 지옥으로 풀던 걸 표준 API로 처리한다는 게 가장 큰 차이다.

비교표 (원문 기준 + 실무 코멘트)

항목 Path A: Contour/Traefik (Ingress API) Path B: Gateway API
마이그레이션 공수 낮음~중간 (어노테이션 번역) 높음 (라우팅 매니페스트 전면 재작성)
운영 패러다임 단일 소유자 — Ops가 모놀리식 정의 관리 역할 기반 — Ops는 Gateway, Dev는 HTTPRoute
미래 대응성 낮음 — Ingress API는 feature-frozen 높음 — upstream 활발히 개발 중
기능 proprietary 어노테이션에 크게 의존 고급 기능이 코어 스펙에 내장

실무 코멘트: Nginx Ingress Operator나 NGINX가 상업적으로 관리하는 별도 컨트롤러 라인도 존재하는데, 이건 커뮤니티판 ingress-nginx와는 별개 프로젝트다. 다만 라이선스·지원 정책이 다를 수 있으니 도입 전 반드시 공식 문서 확인이 필요하다. 원문은 이 부분을 상세히 다루지 않으니 여기서 단정하지 않겠다.

4. 마이그레이션 아키텍처 설계 전략

원문이 제시한 마이그레이션 전략은 3단계다. 실무 관점 살을 붙였다.

① Audit (감사)

위 2번 섹션에서 뽑은 어노테이션 인벤토리가 곧 기술부채 목록이다. configuration-snippet, server-snippet, auth-url(외부 인증) 같은 건 자동 변환이 안 되니 별도 트랙으로 뺀다.

② Tooling — ingress2gateway

Gateway API로 갈 거라면 ingress2gateway로 기존 Ingress를 자동 변환할 수 있다. 완벽하진 않지만 표준 필드는 잘 옮겨준다.

ingress2gateway print --input-file=web-app-ingress.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: web-app-gateway
  namespace: default
spec:
  gatewayClassName: contour
  listeners:
  - name: http
    port: 80
    protocol: HTTP
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: web-app
  namespace: default
spec:
  parentRefs:
  - name: web-app-gateway
  rules:
  - matches:
    - path:
        type: PathPrefix
        value: /
    backendRefs:
    - name: web-app-svc
      port: 80

주의할 건, rewrite-target이나 커스텀 snippet은 이 도구가 변환 못 하고 조용히 빠뜨리는 경우가 있다. 변환 결과를 그대로 믿지 말고 반드시 diff 떠서 검증해야 한다.

③ Incremental Rollout (점진적 전환)

원문의 핵심 권고다. 새 컨트롤러를 기존 것과 병렬로 띄우고, 비핵심 워크로드부터 옮긴다. ingressClass를 분리하면 두 컨트롤러가 한 클러스터에 공존할 수 있다. 결제 API 같은 건 맨 마지막에 옮긴다.

5. 흔한 함정과 실제 에러들

함정 1: ingressClassName 안 걸어서 아무도 안 받음

새 컨트롤러 깔았는데 트래픽이 안 온다. Ingress 오브젝트에 ingressClassName이 비어있거나 옛날 값이면 새 컨트롤러가 무시한다. 병렬 운영 중엔 특히 자주 터진다. 확인:

kubectl get ingress -A -o custom-columns=NS:.metadata.namespace,NAME:.metadata.name,CLASS:.spec.ingressClassName

함정 2: snippet 어노테이션이 그냥 씹힌다

Contour나 Traefik으로 옮긴 뒤 로그를 보면 이런 게 뜬다:

Warning  IngressUpdate  ingress default/web-app: annotation "nginx.ingress.kubernetes.io/configuration-snippet" is not supported by this controller and will be ignored

에러가 아니라 warning이라 CI/CD에서 그냥 통과되고, 배포는 성공한 것처럼 보인다. 그런데 실제로는 rate limit이나 커스텀 헤더 설정이 통째로 날아간 상태다. 프로덕션에서 "왜 갑자기 헤더가 안 붙지?" 하고 새벽에 깨는 전형적인 케이스다.

함정 3: Gateway API CRD 안 깔고 HTTPRoute 적용

Gateway API는 CRD를 별도로 설치해야 한다. 안 깔고 매니페스트 적용하면:

error: resource mapping not found for name: "web-app" namespace: "default" from "httproute.yaml":
no matches for kind "HTTPRoute" in version "gateway.networking.k8s.io/v1"
ensure CRDs are installed first

CRD부터 설치해야 한다:

kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.0/standard-install.yaml

(버전 번호는 예시다. 실제 최신 버전은 Gateway API 릴리스 페이지에서 확인 필요.)

함정 4: cross-namespace 참조가 막혀있다

Gateway API의 장점인 cross-namespace 라우팅은 기본적으로 차단돼 있다. 다른 네임스페이스의 Gateway를 HTTPRoute가 참조하려면 ReferenceGrant를 명시적으로 만들어야 한다. 이걸 몰라서 "HTTPRoute는 Accepted인데 트래픽이 안 온다"고 헤매는 경우가 많다. 상태를 보면 힌트가 나온다:

kubectl describe httproute web-app -n default
Status:
  Parents:
    Conditions:
      Type:     ResolvedRefs
      Status:   False
      Reason:   RefNotPermitted
      Message:  Cross-namespace reference to Gateway is not allowed; create a ReferenceGrant

6. 정리: 누가 어느 길로 가야 하나

한 줄 요약: ingress-nginx는 은퇴했지만 Ingress API는 살아있다. 급하면 Contour/Traefik으로 옆걸음(Path A), 여유 있고 플랫폼 현대화 계획이 있으면 Gateway API로 갈아타라(Path B).

  • 시간 없고 리팩터 인력 부족 → Path A (Contour/Traefik). 원문 표현대로 "stopgap", 즉 임시방편이다. Ingress API는 feature-frozen이라 언젠가는 또 옮겨야 한다. 하지만 미패치 CVE 리스크를 당장 없애면서 계획 세울 시간을 번다.
  • 이미 플랫폼 현대화 중이거나 멀티팀 운영 → Path B (Gateway API). 공수는 크지만 역할 분리와 upstream 지원이라는 장기적 가치가 크다. 한 번 제대로 옮기면 어노테이션 지옥에서 벗어난다.

어느 쪽이든 공통 원칙은 같다. 감사 → 병렬 배포 → 비핵심부터 점진 전환. 결제·인증 게이트웨이는 절대 첫 타자로 옮기지 말 것. 그리고 전환 후엔 각 컨트롤러의 4xx/5xx 비율, 응답 지연을 옛날 nginx 지표와 나란히 놓고 최소 며칠은 비교 모니터링하자. snippet으로 걸어뒀던 숨은 설정이 빠졌는지는 트래픽을 태워봐야 드러난다.

참고 자료

※ 버전 번호와 명령어 출력은 예시이며, 실제 적용 전 각 프로젝트 공식 릴리스 문서에서 최신 버전과 호환성을 반드시 확인하세요.

728x90

+ Recent posts