728x90

1. 왜 지금 MCP 로드맵을 봐야 하나

MCP(Model Context Protocol)를 처음 붙였을 때 느낌은 대체로 비슷하다. "어, 생각보다 별거 없네." JSON-RPC 기반으로 tools/list 받아오고 tools/call 던지면 끝이다. 사내 위키 검색, Jira 티켓 조회 정도 붙이는 데는 반나절이면 충분하다.

문제는 그다음이다. 실제로 운영에 넣어보면 이런 것들이 터진다.

  • Terraform plan/apply를 도구로 노출했더니 3분 넘게 걸리는 호출에서 커넥션이 끊긴다.
  • 배포 승인 같은 사람 개입(human-in-the-loop)이 필요한 작업을 요청·응답 한 번으로 표현할 방법이 없다.
  • 에이전트가 사내 API를 호출하는데, 그 요청의 주체가 누구인지 감사 로그에서 구분이 안 된다. 전부 서비스 계정 하나로 찍힌다.
  • 팀별로 MCP 서버를 만들다 보니 툴이 300개를 넘어갔고, tools/list 응답만 수만 토큰이 되면서 모델이 엉뚱한 툴을 고르기 시작한다.

이번 MCP 로드맵이 짚은 지점이 정확히 여기다. 요약에 따르면 앞으로 수개월간 장시간 실행되는 에이전트 작업, 에이전트 신원·권한 위임, 대규모 Tool 탐색을 핵심 과제로 잡고, 요청·응답 중심 구조를 넘어 서버 주도 이벤트, Webhook·Channel, 실행 중 작업 제어를 강화하겠다는 것이다.

이건 사실상 "MCP를 RPC 라이브러리에서 분산 작업 시스템 프로토콜로 확장하겠다"는 선언이다. 우리가 지난 15년간 메시지 큐, 워크플로 엔진, OAuth 인가 서버로 풀어온 문제들이 그대로 올라와 있다. 그래서 인프라 하는 사람이 볼 값어치가 있다.

참고: 아래 내용 중 구체적인 메시지 스키마·필드명은 확정 명세가 아니라 설계 개념을 설명하기 위한 예시다. 실제 필드명과 릴리스 일정은 공식 명세 문서 확인 필요.

2. 핵심 개념: 요청·응답을 넘어서면 뭐가 달라지나

장시간 작업 = "티켓 발급" 패턴

비유하자면 지금까지의 MCP tool call은 편의점 계산대다. 물건 내밀면 바로 결제되고 끝. 반면 장시간 작업은 세탁소에 가깝다. 옷 맡기면 접수증(task id)을 주고, 나중에 찾으러 가거나 "다 됐어요" 문자를 받는다.

HTTP 세계에서 이미 표준화된 패턴이 있다. 202 Accepted + Location 헤더로 상태 조회 URL을 주는 방식이다. MCP도 결국 비슷한 모양으로 갈 것으로 보인다. 개념적으로는 이렇다.

// 1) 툴 호출 → 즉시 완료 대신 작업 접수
{"jsonrpc":"2.0","id":7,"result":{
  "task":{"id":"task_9f3a","status":"working","pollInterval":5}
}}

// 2) 상태 조회 (개념 예시)
{"jsonrpc":"2.0","id":8,"method":"tasks/get","params":{"taskId":"task_9f3a"}}
{"jsonrpc":"2.0","id":8,"result":{
  "status":"input_required",
  "message":"prod 클러스터 apply 승인이 필요합니다"
}}

// 3) 실행 중 취소
{"jsonrpc":"2.0","id":9,"method":"tasks/cancel","params":{"taskId":"task_9f3a"}}

여기서 인프라 관점의 핵심은 상태를 어디에 두느냐다. task id를 발급한 순간부터 MCP 서버는 stateless가 아니다. 서버 인스턴스 3대를 ALB 뒤에 두고 있는데 task 상태를 프로세스 메모리에 들고 있으면, 다음 폴링이 다른 인스턴스로 가는 순간 끝이다.

$ curl -s -X POST https://mcp.internal/rpc \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":8,"method":"tasks/get","params":{"taskId":"task_9f3a"}}'

{"jsonrpc":"2.0","id":8,"error":{"code":-32602,"message":"Unknown task: task_9f3a"}}

이 에러를 스테이징에서 만나면 십중팔구 스티키 세션이 없거나 상태 저장소가 로컬인 경우다. Redis나 DB로 빼고, task 레코드에 TTL을 반드시 걸어라. 걸지 않으면 취소되지 않은 좀비 task가 쌓여서 결국 메모리나 테이블이 부푼다.

서버 주도 이벤트: 폴링을 걷어낸다

로드맵이 Webhook과 Channel을 언급한 건, 폴링만으로는 감당이 안 되기 때문이다. 에이전트 10개가 각각 5초마다 20개 task를 폴링하면 초당 40 RPS가 아무 일도 안 하면서 나간다. 게다가 폴링은 클라이언트가 살아있을 때만 동작한다. 배치성 에이전트가 잠들어 있는 동안 작업이 끝나면 알 방법이 없다.

세 가지 전달 방식의 트레이드오프를 정리하면 이렇다.

방식장점대가
폴링구현 단순, 방화벽 무관지연·낭비, 클라이언트 상시 기동 필요
SSE/스트리밍 채널실시간, 연결 유지 중엔 최고LB 타임아웃·재연결·resume 처리 필요
Webhook클라이언트가 죽어도 도착수신 엔드포인트 필요, 서명 검증·재시도·멱등성 필수

Webhook을 쓰기로 했다면 세 가지는 무조건 챙겨야 한다. 서명 검증(HMAC), 멱등키(같은 이벤트 두 번 와도 안전하게), 재시도 시 지수 백오프. 이건 MCP라서 특별한 게 아니라 Stripe·GitHub Webhook 다뤄본 사람이면 몸에 익은 것들이다. 그대로 가져오면 된다.

# 수신 측 서명 검증 (FastAPI 예시)
import hmac, hashlib
from fastapi import Request, HTTPException

SECRET = b"whsec_..."

async def verify(req: Request) -> bytes:
    raw = await req.body()
    sig = req.headers.get("x-mcp-signature", "")
    ts  = req.headers.get("x-mcp-timestamp", "")
    expect = hmac.new(SECRET, ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expect, sig):
        raise HTTPException(401, "signature mismatch")
    return raw

헤더 이름은 구현체마다 다르니 공식 명세 확인 필요하지만, 구조는 어디나 같다. 타임스탬프를 서명 대상에 포함시켜 리플레이를 막는 것까지가 세트다.

신원과 권한 위임: "누가 이 API를 부른 건가"

이게 로드맵에서 가장 실무적으로 아픈 부분이다. 지금 대부분의 사내 MCP 서버는 이렇게 생겼다.

[사용자] → [LLM 에이전트] → [MCP 서버] --(서비스 계정 토큰)--> [사내 API]
                                          ↑ 여기서 신원이 증발한다

사내 API 감사 로그에는 svc-mcp-bot만 찍힌다. 김대리가 요청한 건지 박과장이 요청한 건지 모른다. 보안팀이 이걸 발견하는 순간 프로젝트는 멈춘다. 실제로 겪어봤다.

제대로 하려면 토큰 교환(RFC 8693 Token Exchange) 개념이 필요하다. 사용자의 토큰을 MCP 서버가 받아서, 다운스트림 API용 토큰으로 교환하되 스코프는 좁히는 방식이다.

$ curl -s -X POST https://idp.internal/oauth2/token \
  -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
  -d subject_token="$USER_ACCESS_TOKEN" \
  -d subject_token_type=urn:ietf:params:oauth:token-type:access_token \
  -d audience=https://api.internal/deploy \
  -d scope="deploy:read deploy:plan" \
  -u "mcp-server:$CLIENT_SECRET"

{"access_token":"eyJhbGciOi...","issued_token_type":"urn:ietf:params:oauth:token-type:access_token",
 "token_type":"Bearer","expires_in":300,"scope":"deploy:read deploy:plan"}

발급된 토큰의 클레임에는 원 사용자(sub)와 대리 주체(act, actor)가 함께 들어간다. 다운스트림 API는 "박과장을 대신해 mcp-server가 호출함"을 그대로 로깅할 수 있다. 감사 요건이 여기서 해결된다.

여기에 더해 스코프를 툴 단위로 쪼개는 설계가 필요하다. 실수하기 쉬운 게 MCP 서버 전체에 하나의 스코프를 주는 것이다. 그러면 읽기 툴 하나 쓰려고 쓰기 권한까지 딸려온다. 최소한 read / write / destructive 세 등급으로는 나누고, destructive 계열은 사람 승인 없이는 실행 불가로 강제하는 게 안전하다. 앞서 말한 input_required 상태가 이 지점에서 쓰인다.

수천 개 Tool: 카탈로그를 검색 문제로 바꾸기

툴이 300개 넘어가면 tools/list를 통째로 컨텍스트에 넣는 방식은 무너진다. 토큰도 문제지만 더 큰 문제는 정확도다. 이름이 비슷한 툴 40개 중에서 모델이 맞는 걸 고르길 기대하는 건 무리다.

해법 방향은 결국 두 단계다.

  1. 계층화: 카테고리/네임스페이스 단위로 먼저 좁히고, 그 안에서 툴 목록을 편다. infra.k8s.*, infra.terraform.*, biz.jira.* 식으로 접두어 규칙을 강제하면 이후에 뭘 하든 편해진다.
  2. 검색형 탐색: 툴 설명을 임베딩해 두고 "지금 하려는 일"에 맞는 상위 N개만 노출한다. 사실상 툴 카탈로그를 RAG로 다루는 것이다.

여기서 자주 하는 실수. 툴 목록이 매 호출마다 바뀌면 캐시가 다 깨진다. 프롬프트 캐싱을 쓰고 있다면 툴 정의 블록은 안정적으로 유지되는 게 유리하다. 세션 시작 시점에 툴 세트를 고정하고, 정말 필요할 때만 notifications/tools/list_changed 같은 변경 알림으로 갱신하는 편이 낫다. 매번 동적으로 셔플하면 캐시 히트율이 바닥을 친다.

3. 실무 관점: 도입 전 체크리스트와 흔한 함정

흔한 함정 1 — LB/프록시 타임아웃

SSE나 장시간 스트리밍을 붙이면 거의 반드시 만나는 게 이거다.

upstream timed out (110: Connection timed out) while reading upstream,
client: 10.0.3.41, server: mcp.internal, request: "POST /rpc HTTP/1.1"

클라이언트 쪽에서는 이렇게 보인다.

McpError: MCP error -32001: Request timed out
    at Client._onresponse (.../client/index.js)

nginx면 proxy_read_timeout, proxy_buffering off, ALB면 idle timeout을 손봐야 한다. 다만 타임아웃을 늘리는 건 임시방편이다. 근본 해법은 장시간 작업을 task로 분리해서 커넥션 수명과 작업 수명을 떼어놓는 것이고, 로드맵이 가려는 방향도 그쪽이다.

흔한 함정 2 — 인증 실패를 툴 에러로 삼키기

MCP 서버가 다운스트림 401을 그냥 텍스트로 감싸서 모델에게 돌려주면, 모델은 "권한이 없나 보다" 하고 다른 툴로 우회를 시도한다. 이건 위험하다. 인증/인가 실패는 프로토콜 레벨 에러로 명확히 올리고, 재인증 플로우로 유도해야 한다.

{"jsonrpc":"2.0","id":12,"error":{
  "code":-32603,
  "message":"Unauthorized",
  "data":{"reason":"insufficient_scope","required":"deploy:apply",
          "authorizeUrl":"https://idp.internal/authorize?..."}}}

필드 구조는 구현마다 다르니 공식 명세 확인 필요. 중요한 건 "모델이 알아서 판단하게 두지 말고, 클라이언트가 처리할 수 있는 신호로 내려보내라"는 원칙이다.

흔한 함정 3 — 멱등성 없는 재시도

Webhook 재시도, 에이전트 자동 재시도, 사용자 수동 재시도가 겹치면 같은 배포가 세 번 돈다. 상태 변경 툴에는 클라이언트가 생성한 멱등키를 파라미터로 받아라. 서버는 그 키로 24시간 정도 결과를 캐싱하고, 같은 키면 실행 없이 이전 결과를 돌려준다. 이 한 줄이 사고를 몇 번 막아준다.

운영 체크리스트

  • 관측성: trace id를 사용자 → 에이전트 → MCP 서버 → 다운스트림 API 전 구간에 전파. 툴 이름·소요시간·토큰 사용량을 span attribute로. OpenTelemetry 붙여두면 나중에 "어떤 툴이 제일 자주 실패하나"를 바로 본다.
  • 레이트 리밋: 에이전트는 사람보다 훨씬 빠르고 집요하다. 서버 전체 + 사용자별 + 툴별 3계층으로. 특히 destructive 툴은 별도로 조여라.
  • 타임아웃 계층: 툴 실행 타임아웃 < MCP 요청 타임아웃 < LB idle 타임아웃 순서가 지켜지는지 확인. 역전되면 원인 파악이 지옥이 된다.
  • 킬 스위치: 툴 단위로 즉시 비활성화하는 플래그. 사고 났을 때 서버 전체를 내리지 않고 문제 툴만 끄는 수단이 반드시 필요하다.
  • 버전 고정: MCP 명세가 활발히 움직이는 중이다. 클라이언트/서버 SDK 버전을 lock 하고, 업그레이드는 스테이징에서 프로토콜 버전 네고 로그부터 확인한 뒤에.

지금 당장 해야 하나?

솔직히 말하면, 로드맵 항목들은 아직 움직이는 표적이다. 명세가 확정되기 전에 예상 스키마에 맞춰 구현하면 나중에 다 뜯어야 한다. 대신 프로토콜과 무관한 부분은 지금 해두면 손해가 없다.

  • task 상태를 외부 저장소로 빼기 (어차피 필요)
  • 툴 네이밍 규칙과 read/write/destructive 등급 정하기
  • 토큰 교환 기반 위임 구조 설계 (IdP 쪽 작업이라 MCP와 독립)
  • trace 전파와 툴별 메트릭

반대로 Webhook 수신 엔드포인트, Channel resume 로직처럼 명세에 직접 묶인 것은 확정 릴리스를 기다리는 편이 낫다.

4. 정리

한 줄 요약: MCP가 "함수 호출 프로토콜"에서 "분산 작업 프로토콜"로 넘어가는 중이고, 그 과정에서 우리가 이미 아는 큐·워크플로·OAuth 문제들이 그대로 재등장한다.

누가 언제 봐야 하냐면 —

  • 사내 에이전트 플랫폼을 설계 중이라면 지금. 상태 저장 위치와 권한 위임 구조는 나중에 바꾸기가 정말 어렵다.
  • MCP 서버를 이미 몇 개 굴리고 있다면 지금. 툴 네이밍과 권한 등급 정리는 서버가 5개일 때 하는 것과 50개일 때 하는 것의 난이도가 다르다.
  • PoC 단계라면 급하지 않다. 짧은 요청·응답 툴 몇 개로 가치를 먼저 증명하고, 위 내용은 "다음 단계에 이런 게 온다" 정도로 알아두면 충분하다.

개인적으로 가장 중요하다고 보는 건 권한 위임이다. 나머지는 기술적으로 풀면 되지만, 신원이 증발하는 구조는 보안 리뷰 한 번에 프로젝트를 통째로 멈추게 만든다. 아키텍처 다이어그램 그릴 때 "이 화살표에 실린 신원은 누구인가"를 화살표마다 적어보길 권한다. 빈칸이 나오는 곳이 나중에 터질 곳이다.

참고 자료

728x90
728x90

장애 대응 중에 구글에서 에러 메시지 검색했는데, 상위 3개가 전부 "이 기사를 계속 읽으려면 구독하세요"였던 경험. 다들 한 번쯤 있을 거다. 나는 새벽 3시에 Kafka 컨슈머 랙 관련 아티클 찾다가 미디엄 페이월에 세 번 연속 막히고 나서 그냥 소스 코드를 읽기 시작했다.

2026년 8월 21일 Kagi 체인지로그에 짧게 한 줄이 올라왔다. "we've added a setting for removing paywalled links from search results automatically." Stocks 위젯 개편 소식에 딸려 나온 부록 같은 문장인데, HN에서 반응이 꽤 컸다. 왜 이 한 줄이 검색 품질 논의의 핵심인지, 그리고 인프라 하는 사람 입장에서 뭘 배울 수 있는지 정리해본다.

왜 지금 이게 화제인가

검색 결과에 페이월이 섞이는 건 단순한 불편이 아니라 정보 비용의 문제다. 구독 안 한 사이트가 상위에 뜨면, 사용자는 클릭 → 로딩 → 오버레이 확인 → 뒤로가기라는 4단계를 낭비한다. 트러블슈팅 중이면 이 왕복이 체감상 훨씬 크다.

그런데 왜 구글은 이걸 안 해줄까. 구조적인 이유가 있다.

  • 광고 기반 검색엔진은 퍼블리셔가 곧 생태계 파트너다. 유료 언론사 링크를 자동으로 빼는 건 그 관계를 정면으로 건드린다.
  • 구글은 오히려 반대 방향으로 갔다. NewsArticle 스키마의 isAccessibleForFree 필드는 페이월 콘텐츠를 배제하기 위한 게 아니라, 클로킹으로 오해받지 않게 정상 색인시키기 위한 장치에 가깝다.
  • Kagi는 구독료로 굴러간다. 사용자가 유일한 고객이니까 "이거 빼주세요"를 그냥 들어줄 수 있다. 인센티브 구조가 다르다.

이건 기술 난이도의 문제가 아니라 비즈니스 모델의 문제였다는 뜻이다. Kagi가 AI 기능 전체 off 토글(settings/ai)을 넣은 것도 같은 맥락이다. 체인지로그에서 직접 "we want to stay true to putting you in control of your search engine"이라고 썼다.

페이월을 어떻게 판별하나 — 동작 원리

Kagi가 정확히 어떤 방식을 쓰는지는 공식적으로 공개된 게 없다. 체인지로그 한 줄이 전부다. 다만 이 문제를 푸는 방법은 업계적으로 몇 가지로 수렴한다. 내가 사내 문서 크롤러 만들 때 겪은 것과 겹치는 부분이 많다.

1) 구조화 데이터 (가장 싸고 가장 안 믿음직함)

schema.org의 isAccessibleForFree: false. 퍼블리셔가 스스로 붙이는 값이라 신뢰도는 자진신고 수준이다.

curl -s https://example-news.com/article/123 \
  | grep -o '"isAccessibleForFree":[^,}]*'

# 출력 예시
"isAccessibleForFree":false

붙어 있으면 확실하지만, 안 붙어 있다고 무료라는 보장이 전혀 없다. 재현율(recall)이 형편없다.

2) 도메인 리스트 (실용적, 유지보수 지옥)

NYT, WSJ, Bloomberg, Medium... 알려진 페이월 도메인을 리스트로 관리한다. 단순하지만 두 가지가 깨진다. 하나는 같은 도메인 안에서 무료 기사와 유료 기사가 섞이는 경우(NYT 오피니언 일부, Medium의 non-member 스토리), 다른 하나는 미터링 페이월이다. "이번 달 3개까지 무료"는 서버가 아니라 브라우저 로컬스토리지 상태에 달려 있어서 크롤러 입장에서 판정 자체가 불가능하다.

3) 콘텐츠 시그널 (제일 정확, 제일 비쌈)

본문 텍스트에서 패턴을 잡는다. 실제로 돌려볼 수 있는 최소 버전:

#!/usr/bin/env bash
# paywall-probe.sh — 페이월 의심 시그널 간이 탐지
URL="$1"
BODY=$(curl -sL -A "Mozilla/5.0" --max-time 10 "$URL")

echo "== body length: $(echo "$BODY" | wc -c) bytes"
echo "$BODY" | grep -o '"isAccessibleForFree":[^,}]*' | head -1
echo "$BODY" | grep -io -E 'subscribe to (continue|read)|구독하고 계속|paywall|메타 회원 전용' \
  | sort -u | head -5
$ ./paywall-probe.sh https://some-news.example/a/1
== body length: 48213 bytes
"isAccessibleForFree":false
Subscribe to continue
paywall

실제 검색엔진 규모에서는 이걸 색인 시점에 미리 계산해서 플래그로 저장해둔다. 쿼리 시점에 페이지를 다시 긁을 수는 없으니까. 그래서 이 기능은 "필터"라기보다 "이미 있던 메타데이터를 노출하는 토글"에 가깝다고 보는 게 맞다. Kagi는 이전부터 검색 결과에 페이월 배지를 붙여왔는데, 그 판정 결과를 재활용해 아예 결과에서 빼는 스위치를 추가한 것으로 보인다.

비유하자면

Nginx의 deny 리스트를 쿼리마다 실시간 평가하느냐, 아니면 빌드 타임에 map 파일로 구워두느냐의 차이다. 후자가 검색엔진이 택할 수밖에 없는 방식이고, 대신 플래그가 낡을 수 있다는 부작용이 따라온다. 어제까지 무료였던 기사가 오늘 유료로 바뀌어도 다음 크롤까지는 그대로다. 반대도 마찬가지고.

실무 관점 — 도입 판단과 흔한 함정

켜기 전에 생각할 것

나는 켜놓고 쓰는 쪽인데, 무조건 추천하진 않는다. 정리하면 이렇다.

  • 켜면 좋은 경우: 기술 트러블슈팅, 문서 검색, 스택오버플로/깃허브/공식 docs 위주 탐색. 페이월 소스가 거의 없는 영역이라 부작용이 적다.
  • 끄는 게 나은 경우: 특정 사건의 1차 보도를 찾을 때. 국내외 주요 언론 단독 기사는 대부분 유료다. 필터를 켜면 원본이 사라지고 그걸 받아쓴 어그리게이터만 남는다. 이게 제일 무섭다. 신뢰도 낮은 재탕 소스로 결과가 채워지는 역효과.
  • 구독 중인 사이트가 있으면: 필터는 내 구독 상태를 모른다. WSJ 구독 중인데 WSJ이 통째로 사라지는 상황이 생긴다. 이럴 땐 전역 필터 대신 Kagi의 도메인별 랭킹 조정(Raise/Lower/Block)을 쓰는 게 낫다.

흔한 함정 1: 필터를 켜면 결과가 아예 안 나온다

페이월 필터 + 다른 필터(렌즈, 블록 도메인)를 겹치면 결과 0건이 뜬다. 특히 좁은 쿼리에서 자주 본다. 게다가 체인지로그를 보면 관련 버그가 실제로 있었다.

Blocked domains are used as sources in Quick Answer  #11257 @bausauce

차단 도메인이 일반 결과에선 빠지는데 Quick Answer의 소스로는 들어가던 이슈다. 필터링 계층이 여러 군데(랭킹, 결과 렌더링, AI 요약 파이프라인)에 흩어져 있으면 이런 누락이 반드시 생긴다. 우리가 만드는 시스템도 똑같다. WAF에서 막았는데 내부 서비스 메시 경유 트래픽은 안 막히는 것과 구조적으로 동일한 실수다.

흔한 함정 2: 크롤러 관점에서 페이월 판정 자체가 실패

직접 파이프라인을 만들어본 사람이면 이 에러를 만난다. Kagi도 체인지로그에 같은 문구를 버그로 올려놨다.

Upstream connect error or disconnect/reset before headers. reset reason: connection termination  #10680 @TheToby

Envoy 기반 프록시에서 나오는 전형적인 메시지다. 크롤러가 타임아웃/리셋을 맞으면 그 페이지는 "본문 없음"으로 처리되고, 본문 길이가 짧다는 이유로 페이월로 오판될 수 있다. 실제로 같은 체인지로그에 이런 것도 있다.

Extract API returns empty data for an entire batch when one page times out  #11176 @fredcy

배치 하나가 타임아웃 나면 배치 전체가 빈 데이터로 돌아오는 버그. 이게 페이월 판정 파이프라인에 물려 있으면 무고한 도메인 수백 개가 한꺼번에 페이월로 낙인찍힌다. 교훈은 명확하다. "데이터 없음"과 "차단됨"을 절대 같은 값으로 취급하지 말 것. 3-state로 가야 한다.

# 나쁜 설계
paywalled = (len(body_text) < 500)

# 최소한 이렇게
if fetch_status != 200:
    state = "UNKNOWN"      # 필터에서 제외하지 않음
elif has_paywall_signal(body):
    state = "PAYWALLED"
else:
    state = "FREE"

운영에서 이 원칙을 어겨서 사고 난 적이 있다. 헬스체크 엔드포인트가 타임아웃 났는데 그걸 "unhealthy"로 처리해서 정상 파드를 전부 내려버린 케이스. 도메인은 다르지만 실수의 모양은 똑같다.

함정 3: 필터가 켜졌는지 본인이 잊는다

제일 흔하고 제일 짜증난다. "왜 이 검색어에 결과가 없지?" 하고 30분 삽질했는데 알고 보니 몇 달 전에 켜둔 토글 때문. 이건 Kagi만의 문제가 아니라 모든 사용자 설정형 필터의 숙명이다. 대응책은 하나다. 계정 설정을 주기적으로 덤프해서 어딘가에 기록해두는 것.

# 필터 없는 결과를 확인할 때는 렌즈를 명시적으로 지정
# (2026-08-21 체인지로그: 렌즈 URL이 숫자 → 이름으로 바뀜)
https://kagi.com/search?q=kafka+consumer+lag&lens=forums

# 포럼 렌즈는 애초에 페이월 소스가 거의 없다 →
# 페이월 필터를 전역으로 켜는 대신 렌즈로 좁히는 게 부작용이 적다

개인적으로는 이 방식을 더 선호한다. 전역 토글은 "빼기"고, 렌즈는 "고르기"다. 빼기는 뭐가 빠졌는지 안 보이지만 고르기는 내가 뭘 골랐는지 URL에 남는다. 디버깅 가능성 측면에서 후자가 훨씬 낫다.

대안 정리

  • 브라우저 확장: uBlock 필터로 오버레이만 제거. 검색 결과는 그대로라 정보 손실이 없지만, 서버사이드 페이월(본문 자체를 안 내려주는 경우)엔 무력하다.
  • archive.today / Wayback: 링크는 보이되 우회. 저작권 이슈는 각자 판단.
  • Kagi 도메인 랭킹 조정: 완전 배제 대신 순위만 낮추기. 가장 균형 잡힌 선택이라고 본다.
  • SearXNG 자체 호스팅: 필터 로직을 직접 짜고 싶으면 이쪽. 대신 결과 품질과 레이트리밋 관리가 온전히 내 몫이 된다.

정리

한 줄 요약: 페이월 필터는 기술적 돌파구가 아니라 "사용자가 고객인 검색엔진"만 만들 수 있는 기능이다.

  • 기술 검색 위주인 사람 → 켜라. 부작용 거의 없고 체감 개선이 크다.
  • 뉴스·리서치를 검색으로 하는 사람 → 켜지 마라. 1차 소스가 통째로 사라지고 재탕 기사만 남는다.
  • 언론사 구독 중 → 전역 토글 대신 도메인별 랭킹 조정으로.
  • 비슷한 필터를 직접 만들 사람 → 판정 실패와 차단을 분리해라. UNKNOWN 상태 없이 만들면 크롤 실패가 그대로 오탐이 된다. 이게 이 글에서 가져갈 유일한 설계 교훈이다.

덧붙이면, Kagi 체인지로그는 사용자 피드백 번호(#11296 같은)를 항목마다 달아둔다. 요청 → 이슈 번호 → 릴리즈 노트가 한 줄로 이어지는 구조인데, 사내 플랫폼팀 운영해본 사람이면 이게 얼마나 유지하기 어려운지 안다. 기능보다 이 운영 방식이 더 인상적이었다.

참고 자료

728x90
728x90

사무실에서 PC로 문서 보면서 폰으로 음악 듣는 사람, 생각보다 많다. 멀티포인트 지원 헤드셋이면 PC에 우선권을 주고 PC가 조용할 때 폰 소리가 나오는 식으로 알아서 왔다 갔다 한다. 그런데 어느 순간부터 폰 음악이 뚝 끊긴다. 브라우저 탭을 하나씩 닫다 보니 범인이 알리익스프레스 탭이었다 — 이 이야기가 최근 HN에 올라온 laserphile 블로그 글의 출발점이다.

재밌는 건 원인이 광고 자동재생이 아니라는 점이다. 페이지에 <audio><video>도 없다. 탭 음소거를 눌러도 소용없다. 실제 범인은 게인 0으로 물려 있는 WebAudio 그래프, 즉 소리는 안 나지만 오디오 파이프라인은 계속 살아 있는 핑거프린팅 코드였다.

왜 지금 이 얘기가 화제인가

핑거프린팅 자체는 새로운 게 아니다. canvas, WebGL, 폰트, 하드웨어 스펙 긁어가는 스크립트는 10년 넘게 있었다. 이번 건이 눈에 띈 이유는 두 가지다.

  • 추적 코드가 물리 하드웨어의 동작을 바꿔버렸다. 브라우저 안에서 끝나는 게 아니라, OS 오디오 스택 → 블루투스 A2DP/HFP 링크 → 헤드셋 멀티포인트 중재 로직까지 영향을 줬다. 사용자 입장에서는 "헤드셋이 고장났나?" 싶은 증상이다.
  • 브라우저가 제공하는 통제 수단이 안 먹혔다. 탭 음소거는 미디어 엘리먼트를 대상으로 하지, "이 페이지의 AudioContext를 정지"시키는 버튼이 아니다. Firefox도 Chrome도 마찬가지였다고 원문은 적고 있다.

원인 스크립트는 두 개로 특정됐다. 둘 다 Alibaba의 AWSC(안티어뷰즈/보안) 계열로 보인다.

https://assets.aliexpress-media.com/g/AWSC/uab/1.140.0/collina.js
https://assets.aliexpress-media.com/g/AWSC/fireyejs/1.231.67/fireyejs.js

동작 원리: 소리 안 나는데 오디오가 도는 이유

원문에서 리버스한 그래프 구조는 이렇다.

Sawtooth Oscillator
    → AnalyserNode          (통과한 신호를 측정)
    → ScriptProcessorNode
    → GainNode (gain = 0)   (사람 귀에는 무음)
    → AudioContext.destination

핵심 트릭은 마지막 줄이다. 측정만 할 거면 OfflineAudioContext로 렌더링해도 되는데, 굳이 실시간 AudioContext를 만들고 destination까지 연결한다. destination에 물리는 순간 브라우저는 "이 페이지는 지금 라이브 오디오를 처리 중"이라고 판단하고 오디오 콜백을 실제 하드웨어 클럭에 맞춰 계속 돌린다. 게인이 0이라 파형 값은 전부 0이지만, 스트림 자체는 살아 있다.

비유하자면 이렇다. 스피커 볼륨만 0으로 내려놓고 앰프 전원은 켜둔 상태다. 소리는 안 나지만 앰프는 "지금 재생 중"이라고 상위 시스템에 계속 알린다. Windows 오디오 세션이 활성 상태로 유지되고, 블루투스 링크가 유휴로 떨어지지 않으니, 헤드셋 입장에서는 "PC가 아직 재생 중"이므로 폰으로 우선권을 넘길 이유가 없다. 원문 저자의 환경에서 관측된 결과가 딱 이거다.

왜 오디오로 지문을 뜨냐면, 같은 톱니파를 넣어도 브라우저 버전·OS 오디오 라이브러리·리샘플링 경로·부동소수점 처리 차이로 미세하게 다른 스펙트럼 값이 나오기 때문이다. 이거 하나로 개인 식별은 안 되지만 canvas 해시, WebGL 렌더러 문자열, hardwareConcurrency, deviceMemory, 지원 코덱, WebRTC 동작, 마우스/스크롤 이벤트 패턴과 조합하면 엔트로피가 꽤 올라간다. 쿠키처럼 지운다고 리셋되지도 않는다.

직접 확인해보기

남의 분석을 믿기 전에 자기 브라우저에서 재현해보는 게 빠르다. DevTools 콘솔에 페이지 로드 전에 이 스니펫을 넣어두면 된다(Chrome이면 Sources → Snippets, 또는 DevTools 설정에서 "Preserve log" 켜고 about:blank에서 실행 후 이동).

// AudioContext 생성 + destination 연결을 전부 로깅
const OrigAC = window.AudioContext;
window.AudioContext = class extends OrigAC {
  constructor(...a) {
    super(...a);
    console.warn('[AC] created', this.state, new Error().stack);
  }
};
const origConnect = AudioNode.prototype.connect;
AudioNode.prototype.connect = function (dest, ...rest) {
  if (dest instanceof AudioDestinationNode) {
    console.error('[AC] -> destination', this.constructor.name,
                  new Error().stack);
  }
  return origConnect.call(this, dest, ...rest);
};

알리 홈페이지를 열고 수 초간 가만히 두면 로그가 뜬다. 즉시 뜨지 않는 게 포인트라 성급하게 "안 나오네" 하고 닫으면 놓친다. 대략 이런 출력이 나온다.

[AC] created running
    at new AudioContext (<anonymous>)
    at .../AWSC/uab/1.140.0/collina.js:1:38412
[AC] -> destination GainNode
    at .../AWSC/uab/1.140.0/collina.js:1:38790
[AC] created running
    at .../AWSC/fireyejs/1.231.67/fireyejs.js:1:...

실시간으로 몇 개나 살아 있는지 보고 싶으면 이렇게 카운터를 하나 더 붙여두는 것도 편하다.

window.__acs = [];
const OrigAC2 = window.AudioContext;
window.AudioContext = class extends OrigAC2 {
  constructor(...a) { super(...a); window.__acs.push(this); }
};
// 나중에 콘솔에서
// __acs.map(c => [c.state, c.sampleRate, c.currentTime])
// → [["running", 48000, 12.37], ["running", 48000, 11.90]]

currentTime이 계속 증가하고 staterunning이면, 그 탭은 지금 이 순간에도 오디오 클럭을 붙잡고 있는 것이다.

실무 관점: 막을 것인가, 감수할 것인가

차단 룰

원문에서 제시한 uBlock Origin 커스텀 필터는 딱 두 줄이다. 대시보드 → 내 필터에 넣고 "변경 사항 적용".

! AliExpress AWSC fingerprinting scripts
||assets.aliexpress-media.com/g/AWSC/uab/*/collina.js$script,domain=aliexpress.com
||assets.aliexpress-media.com/g/AWSC/fireyejs/*/fireyejs.js$script,domain=aliexpress.com

버전 부분을 와일드카드로 빼놓은 게 실무적으로 중요하다. 1.140.0을 하드코딩하면 다음 배포에 바로 뚫린다. 반대로 ||assets.aliexpress-media.com^$script 같은 광범위 차단은 상품 이미지 CDN이나 결제 위젯까지 같이 죽일 위험이 있어서 권하지 않는다.

흔한 함정 1 — 룰 적용했는데 그대로다

가장 많이 걸리는 부분. 이미 만들어진 AudioContext는 스크립트를 차단해도 죽지 않는다. 네트워크 요청 차단은 앞으로의 요청에만 적용되고, 이미 메모리에 올라간 오디오 그래프와는 무관하다. 룰 적용 후에는 반드시 열려 있는 알리 탭을 전부 닫고 다시 열어야 한다. 하드 리로드(Ctrl+Shift+R)로도 대개 해결되지만, 확실하게 하려면 탭 종료가 낫다.

흔한 함정 2 — 안티프로드가 튕겨낸다

이 스크립트들은 봇 탐지/부정거래 방지 파이프라인의 일부로 보인다. 즉, 막으면 사이트가 나를 "측정 불가능한 클라이언트"로 취급할 수 있다. 원문 저자도 홈 브라우징과 상품 페이지는 멀쩡했지만 로그인·결제에서 문제가 생기면 룰을 일시 해제하겠다고 적었다. 실제로 이런 계열 스크립트가 차단됐을 때 콘솔에서 자주 보는 형태는 이렇다.

GET https://assets.aliexpress-media.com/g/AWSC/uab/1.140.0/collina.js
    net::ERR_BLOCKED_BY_CLIENT

Uncaught (in promise) TypeError: Cannot read properties of undefined
    (reading 'getUA')
    at HTMLFormElement.<anonymous> (login.js:1:20714)

net::ERR_BLOCKED_BY_CLIENT가 보이면 확장 프로그램이 막은 것이다(Firefox는 NS_ERROR_FAILURE나 "요청 중단됨"으로 표시되기도 한다). 그 뒤에 이어지는 getUA, getToken 같은 전역 객체 undefined 에러가 로그인 폼 제출을 막고 있다면, 결제할 때만 uBlock 아이콘에서 해당 사이트를 잠시 끄는 게 현실적인 타협이다. 로그인 실패를 계정 문제로 오해해서 비밀번호를 세 번 틀리고 계정 잠기는 게 최악의 시나리오다.

흔한 함정 3 — 원인을 하드웨어로 오진한다

헬프데스크 운영해본 사람은 공감할 텐데, "폰 음악이 안 나와요"는 100이면 99가 헤드셋 펌웨어나 블루투스 드라이버 이슈로 분류된다. 펌웨어 업데이트하고, 페어링 지우고 다시 잡고, 드라이버 재설치까지 다 해도 안 고쳐진다. 증상이 특정 사이트 탭을 열었을 때만 재현되는지를 먼저 확인하는 게 훨씬 빠르다. Windows라면 소리 설정의 볼륨 믹서에서 브라우저 프로세스가 활성 오디오 세션으로 잡혀 있는지 보면 힌트가 된다(무음이라 미터는 안 움직여도 세션은 떠 있는 경우가 있다).

대안들과 트레이드오프

  • uBlock Origin 좁은 룰 — 부작용 최소, 대신 스크립트 경로가 바뀌면 유지보수 필요. 지금으로선 가장 현실적.
  • Firefox의 강화 추적 방지 "엄격" — 알려진 핑거프린팅 도메인 차단 목록 기반이라 이 두 스크립트가 목록에 있는지는 별도 확인이 필요하다. 만능은 아니다.
  • AudioContext 자체를 패치하는 사용자 스크립트destination 연결을 무력화하는 방식. 이론적으로 가능하지만 정상 사이트(유튜브, 화상회의, 웹 게임)까지 깨질 수 있고 탐지도 쉽다. 도메인 한정으로만 쓰는 걸 권한다.
  • 그냥 감수하기 — 결제 흐름을 절대 건드리기 싫다면 알리 탭을 필요할 때만 열고 바로 닫는 운영이 제일 안전하다. 실제로 탭 닫으면 즉시 복구된다.

프론트엔드/보안 담당자에게 주는 교훈

이건 알리 욕하고 끝낼 얘기가 아니다. 우리가 봇 탐지 SDK나 상용 안티프로드 스크립트를 서비스에 붙일 때도 똑같은 일이 벌어질 수 있다. 벤더 스크립트 하나 붙였는데 사용자 헤드셋 동작이 바뀌고, 그 CS는 우리 팀으로 들어온다. 도입 전 체크리스트는 단순하다.

  • 벤더 SDK가 AudioContext를 만드는지, 만든다면 OfflineAudioContext가 아닌 실시간 컨텍스트인지
  • 측정 끝난 뒤 ctx.close()를 호출하는지 (안 하면 그냥 leak이다)
  • 측정 타이밍이 홈 화면 idle 시점인지, 로그인·결제 같은 민감 액션 직전인지
  • 차단당했을 때 우아하게 degrade 되는지, 아니면 TypeError 뿜고 폼 제출이 막히는지

특히 두 번째. destination에 붙일 이유가 없는 측정이라면 OfflineAudioContext로 렌더링하고 끝내면 된다. 그러면 하드웨어 오디오 경로를 건드리지 않는다. 굳이 실시간 컨텍스트를 쓰는 건 측정 정확도 때문일 수도 있고, 오프라인 렌더링을 후킹하는 프라이버시 확장을 피하려는 의도일 수도 있다 — 후자는 추측이고 원문도 서버 측 의도까지는 확인하지 못했다고 명시하고 있다.

정리

한 줄 요약: 알리익스프레스 홈에서 도는 난독화된 안티어뷰즈 스크립트(collina.js, fireyejs.js)가 게인 0짜리 WebAudio 그래프를 실제 오디오 destination에 연결한 채로 유지하는데, 이게 PC 쪽 블루투스 오디오 경로를 계속 붙잡아 멀티포인트 헤드셋이 폰으로 넘어가지 못하게 만든다. 탭 음소거로는 안 잡히고, 탭을 닫거나 스크립트를 차단해야 한다.

이 글이 필요한 사람:

  • 멀티포인트 헤드셋 쓰는데 이유 없이 폰 음악이 끊기는 사람 → 브라우저 탭부터 의심하고, 위 후킹 스니펫으로 확인
  • 사내 헬프데스크/IT 운영자 → "블루투스 이상" 티켓의 오진 후보 하나 추가
  • 서비스에 서드파티 안티프로드/봇탐지 SDK 붙이는 프론트엔드·보안 담당자 → 벤더 스크립트의 AudioContext 사용 여부를 릴리스 전에 한 번 확인

차단 룰은 언제든 깨질 수 있다. 원문 저자도 "그때 가서 다시 보겠다"고 했고, 그게 맞는 태도다. 다만 최소한 내 브라우저가 지금 뭘 돌리고 있는지 확인할 수 있는 도구는 손에 쥐고 있는 게 좋다. AudioContext 후킹 스니펫은 딱 그런 용도다.

참고 자료

    728x90
    728x90

    주말에 HN 올라온 글 하나 읽고 한참 멍하니 있었다. 2018년에 "장난삼아" 산 sondehub.org 도메인 하나가, 결국 미 국방부(Office of the Secretary of War)와 NTSB, FAA 관제탑, 그리고 우크라이나 심층타격팀까지 엮이는 인프라가 되어버린 이야기다.

    기술적으로 대단한 스택이 나오는 글은 아니다. 그런데 "내가 만든 공개 API를 내가 통제할 수 없는 사람들이 쓰기 시작했을 때 무슨 일이 벌어지는가"에 대해서는, 내가 본 어떤 아키텍처 문서보다 생생하다. 5년차 인프라 하다 보면 사이드 프로젝트 하나쯤은 공개 엔드포인트로 열어두게 되는데, 그 엔드포인트가 어디까지 갈 수 있는지 감각을 잡아두는 게 좋다.

    장난 도메인이 어쩌다 전략 자산이 됐나

    흐름을 요약하면 이렇다.

    • 2018.05 — Habhub(아마추어 고고도 풍선 추적 사이트)가 기상 관측용 라디오존데를 기본 필터로 감췄다. 필터 해제 쿼리 파라미터를 매번 치기 귀찮아서 sondehub.org를 사면서 Habhub으로 리다이렉트만 걸었다. 진짜 그게 전부였다.
    • 2018.07 — 리다이렉트만으로는 안 되겠어서 데이터 수집을 프록시로 받기 시작. 자체 OpenSearch 클러스터에 쌓았지만 노출은 안 했다. 저자 본인 표현으로 "AWS 서비스 놀아보는 장난감" 수준.
    • 2019 — Habhub과 aprs.fi 서버가 버티질 못함. 자체 서비스로 전환 시작. 이때부터 정부기관 데이터 요청이 들어온다. (라디오존데가 말에 맞아서 말이 울타리 뚫고 도망간 보험 청구 건... 이런 게 온다)
    • 2021 — 역예측(reverse prediction) 기능 도입. 그리고 군에서 이메일이 온다.
    • 2023.02 — 중국 정찰풍선 사태 + 미군이 아마추어 무선 풍선을 AIM-9X로 격추. Washington Post에 링크되면서 트래픽 폭증.
    • 2024.12 — 예측 API에 알람 폭탄. 추적해보니 우크라이나 쪽 심층타격 작전이었다.

    여기서 결정적 분기점이 역예측이다. 원래 예측기는 "지금 이 풍선이 어디로 갈까"를 바람 모델로 계산한다. 이걸 시간축을 뒤집어서 돌리면, 이미 관측된 풍선의 발사 지점이 역산된다. 문서화 안 된 발사장을 여럿 찾아냈고 — 문제는, 상층 바람 데이터는 기상 예보용만이 아니라 포병 사거리 계산에도 쓰인다는 것.

    즉 이들이 무심코 만든 건 "미문서화 기상 관측소 지도"가 아니라 포병 진지 지도였다. 2021년에 군에서 "민감/군사 시설이니 지도에 명시적으로 표시하지 말아달라"는 메일을 받는다. 역예측 기능은 유지하되, 정당한 요청이 오면 해당 발사장을 삭제하는 방식으로 타협했다고 한다.

    핵심 원리: 파생 데이터는 원본보다 위험하다

    이 사례의 기술적 교훈은 딱 하나로 압축된다. 공개 데이터를 모아서 가공하면, 개별 데이터에는 없던 민감도가 생긴다. 인프라 쪽 용어로는 aggregation / inference 문제다.

    비유하자면 이렇다. 사내 액세스 로그 한 줄은 아무 의미 없다. 근데 6개월치를 모아서 "이 IP대역이 새벽 3시에만 특정 엔드포인트를 친다"는 패턴을 뽑으면, 그건 배치 스케줄이자 곧 인프라 구조도다. 라디오존데도 똑같다. 개별 좌표 하나는 공개 기상 데이터인데, 수만 건을 역방향 물리 모델에 넣으면 발사 원점이 나온다.

    SondeHub이 왜 기존 도구보다 데이터가 좋았냐면 — 지면에 닿을 때까지 끝까지 추적했기 때문이다. 당시 공식 소프트웨어는 그렇지 않았다. 그래서 보험 청구 조사까지 가능했던 거고, 그래서 역예측 정밀도도 나온 거다. 관측 커버리지를 늘렸더니 부작용으로 정보 가치가 폭발한 케이스.

    본인 인프라에서 이걸 확인하고 싶다면, 로그에서 "단일 소스가 예측 API만 반복 호출"하는 패턴을 뽑아보는 게 첫 단계다. CloudFront/ALB 액세스 로그 기준 예시:

    # ALB 액세스 로그에서 예측 엔드포인트 상위 호출자 뽑기
    awk '$13 ~ /predict/ {split($4,a,":"); print a[1]}' alb.log \
      | sort | uniq -c | sort -rn | head -5
    
      48213 13.54.x.x
        902 203.0.113.9
        311 198.51.100.44
         87 192.0.2.17
         52 172.16.8.3
    

    1등과 2등이 50배 차이 나면 그건 사람이 아니다. 원문에서도 로깅을 켜자마자 단일 IP에서 오고 있었다고 나온다. 처음엔 어떤 사설 업체가 백엔드를 무단으로 쓰는 줄 알고 항의 메일까지 보냈는데, 그쪽은 진범이 아니었다. LLM 스크래퍼 봇이겠거니 하고 예측 요청 좌표를 지도에 찍어봤더니 — 그림이 나왔다.

    실무 관점: 공개 API 운영자가 실제로 마주치는 것들

    1. 차단이 정답이 아닌 경우가 있다

    보통 이런 트래픽 보면 WAF 룰 하나 박고 끝낸다. 근데 이 케이스에서 운영자가 AWS 서포트에 보낸 문장이 이거다.

    "HTTP 요청 데이터가 배포되지 않는 것이 대단히 중요합니다. 또한 소스 AWS 계정이 차단·레이트리밋·해지되지 않는 것도 중요합니다 — 인명 손실이 발생할 수 있습니다."

    서포트 티켓에 이런 문장을 쓸 일이 있으리라곤 본인도 상상 못 했다고 한다. 요구사항이 두 개인데 서로 충돌한다. (a) 서비스를 끊으면 안 된다 (b) 요청 로그가 새어나가면 발사 지점이 노출된다. 결국 AWS를 통해 "당신 람다가 스크래핑으로 플래그됐으니 운영자에게 연락하라"는 형태로 채널이 열렸고, 로컬 예측기 실행 문서를 넘겨주는 걸로 정리됐다.

    2. 진짜 해법은 "떠나게 만드는 것"

    여기서 제일 실무적인 판단이 나온다. 저자는 차단 대신 docker compose 파일을 급하게 만들어서 누구나 자기 예측기를 돌릴 수 있게 풀었다. 헤비 유저를 내쫓는 게 아니라 셀프호스팅 경로를 열어준 것.

    이건 사내 공용 API 운영할 때도 그대로 적용된다. 특정 팀이 공용 계산 API를 초당 수백 번 때리면, 429 때리기 전에 "그 계산 라이브러리를 너희 파드 안에서 돌려라"를 먼저 제안하는 게 총 비용이 싸다. 사이드카/로컬 배포 경로가 있으면 대부분 알아서 옮겨간다.

    # 헤비 유저에게 넘길 최소 셀프호스팅 스택 예시
    cat > compose.yml <<'EOF'
    services:
      predictor:
        image: ghcr.io/example/predictor:latest
        ports: ["8080:8080"]
        environment:
          - GFS_CACHE_DIR=/data/gfs
        volumes: ["./gfs:/data/gfs"]
    EOF
    
    docker compose up -d
    curl -s localhost:8080/healthz
    
    {"status":"ok","wind_model":"loaded"}
    

    ※ 위 이미지 경로는 예시다. SondeHub 실제 이미지 태그는 공식 저장소 확인 필요.

    3. 흔한 함정: 레이트리밋 걸고 나서 벌어지는 일

    API Gateway나 CloudFront 앞단에 usage plan 걸면 이런 게 뜬다.

    HTTP/1.1 429 Too Many Requests
    x-amzn-ErrorType: LimitExceededException
    {"message":"Too Many Requests"}
    

    문제는 이걸 받는 쪽 클라이언트가 대개 백오프를 구현 안 했다는 것이다. 429를 받으면 재시도를 더 빨리 돌려서 부하가 오히려 튄다. 원문에서도 "API를 올바르게 사용하지 않는 것 같다"는 의심이 나온다. 그리고 이런 상황에서 진짜 답답한 건, 연락할 방법이 없다는 점이다. User-Agent도 없고 API 키도 없으면 그냥 IP 하나뿐이다.

    결국 이들은 우크라이나어로 밀리터리 채팅방에 메시지를 뿌려서 컨택을 잡았다. "파이썬 스크립트로 오픈소스 바람 예측 엔진 쓰는 심층타격팀 아시는 분 연락 주세요, 차단될 수 있으니 조치가 필요합니다" — 이게 실제 컨택 경로였다.

    여기서 얻어갈 실무 룰:

    • 익명 접근을 허용하더라도 키 발급은 무료로라도 열어둬라. 차단이 목적이 아니라 연락 채널 확보가 목적이다.
    • 429 본문에 사람이 읽을 수 있는 컨택 정보와 셀프호스팅 링크를 반드시 넣어라. 봇 운영자도 결국 사람이 로그를 본다.
    • 레이트리밋은 계정 단위로, 익명은 IP 단위로. NAT 뒤에 있으면 IP 단위는 애먼 사람 잡는다.

    4. 로그 자체가 기밀이 되는 순간

    평소엔 액세스 로그를 S3에 던져놓고 라이프사이클 정책만 걸어둔다. 근데 이 케이스에선 요청 파라미터(위경도)가 곧 작전 정보였다. 클라우드 벤더 서포트에게 로그를 넘기는 순간 그게 유출이 된다.

    지도에 찍을 때도 좌표 정밀도를 의도적으로 낮췄고, 데이터셋은 상당히 오래된 것만, 블로그 발행 자체를 "풍선 전쟁이 충분히 널리 알려질 때까지" 미뤘다고 한다. 이 판단 자체가 운영 프로세스의 일부다.

    대응 원칙 정하고 가자:

    • 쿼리 파라미터를 액세스 로그에 통째로 남길지 여부를 설계 시점에 결정. 좌표·검색어·식별자는 기본적으로 마스킹 후보다.
    • 서드파티(벤더 서포트, APM SaaS)로 나가는 로그 필드를 화이트리스트로 관리. 블랙리스트는 언젠가 샌다.
    • 보존 기간 짧게. 없는 데이터는 유출도 안 되고 소환장에도 안 걸린다.

    5. 예상 못 한 이해관계자들

    운영하다 보면 상상 못 한 곳에서 메일이 온다. 원문에 나온 것만 봐도:

    • NTSB(2025.09) — "10/16/2025 1200~1300 UTC에 유타 지역 풍선 정보 있나요?" 데이터를 넘겼고, SondeHub 추적 풍선과는 안 맞았지만 Windborne 풍선이 근처에 있었다는 정보를 전달. 나중에 Windborne이 항공기 충돌 가능성을 확인하고 시스템을 수정했다.
    • FAA 관제탑 — "하늘에 항공기가 많은데 이 풍선들 가까이 가고 싶지 않다. 통제 주체와 직접 조율하거나 사전에 임무 정보를 받을 수 있나?" → 기상 풍선은 정기 스케줄로 올라가고 조종되지 않으며 FAA 규정 Part 101.D 범위라고 설명해야 했다. 저자 표현: "FAA에 기상 풍선의 존재를 설명하게 될 줄은 몰랐다."
    • 뺑소니 민원 — 아이오와 아나모사에서 누가 라디오존데 회수하다가 건물 들이받고 쪽지도 안 남기고 도망감. 건물주가 범인 찾아달라고 연락.
    • 미 국방부(2025) — 데이터 요청. 커뮤니티 상호 이익이 있으면 보통 무료로 가공해서 푸는데, 이건 그게 아니라 인보이스를 끊었다. 결과: 결제도 후속 연락도 없었다.

    이게 왜 실무 얘기냐면, 공개 서비스를 운영한다는 건 지원 채널을 운영한다는 뜻이기 때문이다. 스펙에도 없고 SLA도 없는 요청이 계속 들어온다. 답변 템플릿과 에스컬레이션 기준, "이건 우리 소관 아님"이라고 말할 수 있는 문서화된 경계선이 없으면 운영자 한 명이 갈려나간다.

    정리

    한 줄 요약: 공개 API는 어느 순간 당신이 승인하지 않은 용도로 쓰이며, 그때는 차단보다 셀프호스팅 경로 제공과 연락 채널 확보가 더 나은 해법일 수 있다.

    체크리스트로 남긴다.

    • 공개 엔드포인트를 열 거면 1일차에 로깅·식별·컨택 경로를 같이 설계해라. 사고 터진 뒤 켜는 로깅은 이미 늦다.
    • 원본 데이터가 공개라고 파생 데이터도 공개해도 되는 건 아니다. 집계·역산으로 새 정보가 생기는지 한 번은 따져봐라.
    • 헤비 유저는 적이 아니라 배포 형태를 잘못 고른 사용자다. 도커 컴포즈 하나가 WAF 룰 열 개보다 낫다.
    • 액세스 로그 필드는 유출 자산으로 취급하고, 서드파티로 나가는 항목은 화이트리스트로.
    • 취미 프로젝트라도 인보이스 끊을 각오는 해두되, 정부기관이 그걸 낼 거라는 기대는 접어라.

    솔직히 나는 이 글에서 제일 인상 깊었던 게 아키텍처가 아니라 "AWS 서포트에게 이 계정을 차단하지 말아달라, 인명 손실이 발생할 수 있다"는 문장이었다. 우리가 짜는 레이트리밋 룰 한 줄이 어디까지 가닿을 수 있는지, 가끔은 생각해볼 만하다.

    참고 자료

    728x90
    728x90

    HN 프론트에 에이수스 자전거 부스터가 올라온 걸 보고 좀 웃었다. 메인보드랑 라우터 만들던 회사가 왜 자전거를... 싶다가, 스펙 페이지를 읽어보니 이건 딱 우리가 매일 하는 짓이었다. 레거시 시스템을 갈아엎지 않고 사이드카를 붙여서 기능을 추가하는 것.

    기존 자전거를 e-bike로 바꾸는 방법은 크게 둘이다. (1) 자전거를 새로 산다 = 리라이트. (2) 허브모터 킷을 달려고 휠을 통째로 재조립하고 컨트롤러 배선을 프레임에 케이블타이로 감는다 = 인플레이스 마이그레이션. 둘 다 다운타임이 길고 롤백이 어렵다. Oxiis는 세 번째 길을 판다. 시트포스트에 클램프로 물려서 롤러가 뒷타이어를 눌러 돌리는 프릭션 드라이브(friction drive) 방식이고, 공식 문구로는 "기어나 브레이크에 대한 변경 없음(Zero modifications to gears or brakes)"이다. 붙였다 떼면 원래 자전거로 돌아간다. 무상태(stateless) 사이드카에 가깝다.

    핵심: 마찰 롤러 + 158Wh 배터리라는 리소스 예산

    동작 원리는 단순하다. 모터가 롤러를 돌리고, 롤러가 타이어 표면을 압착해서 마찰로 토크를 전달한다. 체인·스프라켓·BB를 건드리지 않으니 설치가 5분이고, 대신 동력 전달이 타이어 표면 상태에 100% 의존한다. 이게 뒤에서 얘기할 모든 트레이드오프의 뿌리다.

    스펙에 나온 숫자만 가지고 리소스 예산을 뽑아보자. 배터리 158.4Wh / 36V, 정격 250W / 피크 500W, 주행거리 에코 50km ~ 스포츠 10km.

    $ python3 - <<'EOF'
    WH, V, RATED, PEAK = 158.4, 36, 250, 500
    print(f"배터리 용량   : {WH/V:.2f} Ah @ {V}V")
    print(f"정격 연속 출력: {WH/RATED*60:.1f} 분")
    print(f"피크 출력     : {WH/PEAK*60:.1f} 분")
    for mode, km in (("eco", 50), ("sport", 10)):
        print(f"{mode:5s} {km:2d}km -> {WH/km:5.2f} Wh/km")
    EOF
    배터리 용량   : 4.40 Ah @ 36V
    정격 연속 출력: 38.0 분
    피크 출력     : 19.0 분
    eco   50km ->  3.17 Wh/km
    sport 10km -> 15.84 Wh/km
    

    여기서 읽어야 할 건 두 가지다. 첫째, 모드에 따라 소모 전력이 5배 차이난다. 오토스케일링 정책에서 min/max 인스턴스 차이가 5배인 것과 같다. "50km 간다"는 마케팅 숫자는 평지·순풍·에코 기준이고, 언덕 낀 출퇴근에서 스포츠 모드를 남발하면 예산은 순식간에 녹는다. 둘째, 정격 250W를 계속 물리면 38분이다. 즉 이건 모터가 주 동력이 아니라 사람 다리가 주 동력이고 모터는 버스트 크레딧이라는 뜻이다. t 시리즈 인스턴스의 CPU 크레딧을 생각하면 정확하다. 크레딧 다 쓰면? 3.7kg짜리 데드웨이트를 싣고 페달을 밟는다.

    충전 쪽은 USB-C PD 100W — 케이블을 확인해라

    158Wh를 2시간에 채우려면 평균 79W 이상이 실제로 들어가야 한다. 그래서 "100W PD 충전기 필요"라고 못을 박아둔 것으로 보인다. 여기서 노트북 쓰는 사람들이 익히 아는 함정이 그대로 재현된다. 100W(20V/5A)는 e-marker 칩이 들어간 5A 케이블이 있어야 협상이 된다. 서랍에 굴러다니는 60W(3A) 케이블을 꽂으면 협상 결과가 20V/3A로 떨어지고, 충전 시간은 2시간이 아니라 그 이상이 된다.

    리눅스 노트북이 있다면 PD 협상 결과를 직접 볼 수 있다. 자전거 부스터가 아니라 충전기·케이블 조합을 검증하는 용도다.

    $ ls /sys/class/typec/
    port0  port0-partner
    $ cat /sys/class/typec/port0-partner/usb_power_delivery/source-capabilities/*/*
    # 예시 출력 (충전기가 광고하는 PDO 목록)
    5000mV  3000mA
    9000mV  3000mA
    15000mV 3000mA
    20000mV 5000mA   <-- 100W 지원
    $ cat /sys/class/power_supply/ucsi-source-psy-USBC000:001/voltage_now
    20000000
    

    여기서 20V/5A PDO가 안 보이면 충전기 또는 케이블 둘 중 하나가 병목이다. 커널·펌웨어에 따라 경로 이름은 다르니 grep -r . /sys/class/typec/ 2>/dev/null로 훑는 게 빠르다.

    실무 관점: 도입 전 호환성 체크와 흔한 함정

    1) 사전 조건 검증부터 (= 인프라 프리플라이트 체크)

    공식 페이지가 명시한 제약이다. 하나라도 어긋나면 그냥 설치가 안 되거나, 되더라도 위험하다.

    • 타이어 폭 60mm 이하, 휠 사이즈 16~29인치 및 700C
    • 시트포스트 지름 25.4~34.9mm (스페이서 동봉)
    • 카본 시트포스트 불가 — 안전상 금지라고 명시돼 있다
    • 풀서스펜션 MTB 불가 (하드테일은 가능)
    • 슬릭/세미슬릭/텍스처 타이어 OK, 노비(knobby) 타이어 비권장
    • 방수 IPX4 — 생활방수, 소나기·침수 불가

    여러 대를 굴리는 상황(가족, 소규모 배달 팀 등)이라면 스펙시트를 만들어두고 기계적으로 걸러라. 인벤토리를 CSV로 관리하는 것과 다를 게 없다.

    $ cat bikes.csv
    name,seatpost_mm,tire_mm,post_material,suspension
    commuter,27.2,32,alloy,none
    gravel,31.6,45,alloy,none
    roadbike,27.2,28,carbon,none
    mtb,30.9,58,alloy,full
    $ awk -F, 'NR>1 {
      ok = ($2>=25.4 && $2<=34.9) && ($3<=60) && ($4!="carbon") && ($5!="full")
      printf "%-10s %s\n", $1, (ok ? "PASS" : "FAIL")
    }' bikes.csv
    commuter   PASS
    gravel     PASS
    roadbike   FAIL
    mtb        FAIL
    

    실측 팁 하나. 시트포스트 지름은 프레임 스티커나 제조사 스펙 믿지 말고 버니어 캘리퍼스로 직접 재라. 27.2mm인 줄 알았는데 31.6mm였다는 얘기가 흔하다. 클램프 체결력이 잘못 잡히면 주행 중 부스터가 돌아간다.

    2) 앱 연동 — BLE는 원래 안 붙는다

    모드 전환은 본체 버튼 또는 전용 앱(Android/iOS)으로 한다. 스마트 액세서리 붙여본 사람은 알겠지만, BLE 페어링은 첫 시도에 되는 게 오히려 예외다. 리눅스에서 BLE 기기를 훑어볼 때 자주 만나는 화면은 대략 이렇다.

    $ bluetoothctl
    [bluetooth]# scan on
    Discovery started
    [NEW] Device XX:XX:XX:XX:XX:XX Oxiis-XXXX
    [bluetooth]# connect XX:XX:XX:XX:XX:XX
    Attempting to connect to XX:XX:XX:XX:XX:XX
    Failed to connect: org.bluez.Error.Failed br-connection-profile-unavailable
    

    br-connection-profile-unavailable은 "BLE 전용 기기인데 클래식 BR/EDR 프로파일로 붙으려 했다"는 뜻이 대부분이다. connect 대신 주소 타입을 명시하거나(bluetoothctl --agent + LE 스캔), bluetoothctl remove로 캐시된 페어링 정보를 지우고 다시 붙이면 풀리는 경우가 많다. 폰 쪽에서도 마찬가지다. 안 붙으면 앱 재설치보다 페어링 정보 삭제가 먼저다. 참고로 Oxiis의 BLE 서비스 UUID나 프로토콜은 공개된 게 없어서, 서드파티 연동은 기대하지 않는 게 좋다(공식 문서 확인 필요).

    3) 진짜 트레이드오프

    • 비 오는 날. 프릭션 드라이브의 아킬레스건이다. 타이어 표면이 젖으면 마찰계수가 떨어져 롤러가 헛돈다. 공식 문구에도 "동적 압력으로 자동 그립"이라고는 하지만, 물리 법칙을 이기지는 못한다. 게다가 IPX4는 생활방수 수준이다. 장마철 출퇴근 주력으로 쓸 물건은 아니라고 본다.
    • 타이어 마모. 구동력을 타이어 트레드로 받는다. 마모 속도가 빨라지는 건 구조상 피할 수 없다. 소모품 교체 주기를 예산에 넣어라.
    • 무게 3.7kg. 배터리 포함이고, 시트포스트 = 자전거에서 가장 높은 위치에 붙는다. 무게중심이 위로 올라가면 핸들링이 달라진다. 특히 저속 코너와 정지 상태 밸런스.
    • 항공 반입. 158Wh는 100Wh를 넘는다. 일반적으로 100~160Wh 구간은 항공사 사전 승인이 필요하고 위탁수하물은 불가, 기내 반입만 가능하다. 공식 페이지도 "airline approval 필요"라고 적어놨다. 출장 갈 때 그냥 캐리어에 넣으면 게이트에서 잡힌다.
    • 법규. 최고속도 32km/h인데 "특정 지역에서는 25km/h로 제한"이라고 돼 있다. 한국은 페달 보조(PAS) 25km/h 기준이 있고, 부착형 개조 장치가 국내 전기자전거 정의와 안전 인증 요건을 충족하는지는 별도로 확인이 필요하다. 에이수스도 "지역 교통법규 준수 책임은 사용자에게 있고, 출력·속도 제한을 임의 개조하면 보증 무효"라고 못을 박아놨다. 이 문장, 우리 업계로 치면 "SLA 예외 조항"이다.

    4) 대안 비교

    고르는 기준은 결국 "얼마나 바꾸고, 얼마나 되돌릴 수 있느냐"다.

    • 완성형 e-bike — 통합도·안정성 최고, 대신 비싸고 기존 자산은 버린다. 그린필드 신규 구축.
    • 허브모터 컨버전 킷 — 효율 좋고 비 와도 동작하지만 휠 재조립·배선·컨트롤러 마운트가 필요하고 되돌리기 어렵다. 인플레이스 마이그레이션.
    • Oxiis 같은 프릭션 부스터 — 설치 5분, 롤백 5분, 여러 자전거에 돌려쓰기 가능. 대신 우천 취약, 타이어 마모, 배터리 예산 빡빡. 사이드카.

    정리

    한 줄 요약: 자전거를 갈아엎지 않고 붙였다 뗄 수 있는 250W 사이드카. 대신 날씨와 타이어라는 외부 의존성이 생긴다.

    쓰면 좋을 사람은 명확하다. 평지~완만한 언덕의 10~20km 도심 출퇴근, 아끼는 자전거의 지오메트리는 그대로 두고 싶고, 주말엔 떼고 순수 자전거로 타고 싶은 사람. 여러 대 굴리는데 한 대만 전동화하기 애매한 사람.

    반대로 비 오는 날에도 무조건 굴러야 하거나, 왕복 40km 이상이거나, 노비 타이어 MTB·카본 시트포스트·풀서스펜션이면 후보에서 빼라. 스펙시트가 이미 "이건 지원 안 합니다"라고 다 적어놨다. 인프라나 하드웨어나, 제약 조건을 먼저 읽고 프리플라이트 체크를 돌리는 쪽이 항상 덜 고생한다.

    가격·출시 지역·국내 정식 유통 여부는 이 글을 쓰는 시점에 확인이 안 됐다. 구매 전 반드시 공식 페이지와 국내 판매처 안내를 확인하길 권한다.

    참고 자료

    728x90
    728x90

    얼마 전 Hacker News에 올라온 글 하나가 눈에 걸렸다. GPU Mode가 Core Automation과 같이 연 "오토리서치" 주제 대회에서, 저자(sankalp)가 Codex를 돌려 baseline 대비 232배 빠른 QR 분해 커널을 만들고 183명 중 12위를 했다는 이야기다. 14일 동안 1500번 넘게 제출했다고 한다.

    커널 최적화 자체는 내 밥벌이가 아니다. 그런데 이 글을 읽다 보니 결국 우리가 매일 하는 일이더라. 피드백 루프를 얼마나 촘촘하고 싸게 만드느냐. 그 관점에서 정리해본다.

    1. 왜 지금 이 글이 화제인가

    요즘 "에이전트에게 코드 짜게 시켰다" 류 글은 널렸다. 이 글이 다른 지점은 목적함수가 숫자 하나로 딱 떨어진다는 점이다.

    • 정답 판정: 체커가 torch.linalg.householder_product(H, tau)로 Q를 복원하고 A ≈ QR, QᵀQ ≈ I를 검증한다. 통과 아니면 탈락, 애매함이 없다.
    • 점수: 여러 shape에 대한 런타임의 기하평균. 512×512부터 1024, 2048, 4096까지.
    • 제출 채널: popcorn CLI. 에이전트가 직접 테스트·벤치·리더보드 제출까지 한다.
    • 피드백: 전체 기하평균뿐 아니라 shape별 타이밍까지 돌려준다.

    이게 왜 중요하냐면, 에이전트가 hill-climbing을 하려면 "지금 것이 이전 것보다 나은가"를 사람 개입 없이 판정할 수 있어야 한다. 원문 표현대로 "Agents yearn for tight feedback loops"다. 우리 판으로 옮기면 이렇다.

    • ❌ "이 Terraform 코드 좀 더 깔끔하게 해줘" → 판정 불가, 루프 못 돈다
    • ✅ "이 Helm 차트 렌더링 결과가 conftest 정책 22개를 다 통과하고, kustomize build 시간이 짧을수록 좋다" → 루프 가능

    즉 이 글의 진짜 주제는 GPU가 아니라 "검증 가능한 목표 + 무제한에 가까운 시행횟수"라는 환경을 어떻게 세팅했나다. 저자 본인도 "loop engineering이라 불러도 상관없다"고 인정한다.

    2. 핵심 원리: 순차 의존을 깨고, 루프를 돌린다

    문제 쪽 — 왜 이 커널이 느렸나

    Householder QR은 열(column) 하나씩 처리한다. j번째 reflector가 만든 결과 위에서 j+1번째 reflector를 만들어야 한다. 즉 순차 의존 체인이다. 원문의 표현을 빌리면, 이 직렬 matrix-vector 연산은 SM의 느린 벡터 레인에서 돌고 텐서 코어는 놀고 있다.

    DevOps로 치면 이런 배포 파이프라인이다.

    service-a 배포 → 헬스체크 → service-b 배포 → 헬스체크 → ... (n번 반복)
    

    각 단계가 앞 단계 결과에 의존하니 병렬화가 안 된다. 노드는 100대 있는데 한 번에 한 대만 일한다. 고전적인 해법은 blocked 알고리즘이다. b개 열(예: 32, 64)을 하나의 panel로 묶어 직렬 작업은 그 좁은 패널 안에서만 끝내고, 나머지 거대한 trailing 블록은 WY 형태의 행렬곱 한 방(GEMM)으로 갱신한다. "직렬 구간을 최대한 좁게 만들고, 나머지는 통째로 병렬화한다" — 배포 파이프라인에서 canary 1대만 순차로 검증하고 나머지 99대는 한꺼번에 밀어버리는 것과 정확히 같은 발상이다.

    루프 쪽 — 사람이 한 일

    주목할 부분은 저자가 먼저 공부했다는 점이다. Claude랑 왔다 갔다 하고 유튜브를 보면서 Gram-Schmidt와 Householder의 차이, blocked Householder + trailing WY-update가 메인 아키텍처가 되어야 한다는 것까지 잡고 들어갔다. 원문의 이 문장이 핵심이다.

    더 잘 알수록 LLM에게 더 잘 프롬프트할 수 있다. 왜냐하면 unknown unknowns를 known unknowns로 바꾸기 때문이다.

    이건 인프라에서도 똑같다. "쿠버네티스 파드가 자꾸 죽어요"라고 던지면 에이전트는 온갖 삽질을 한다. "OOMKilled인데 JVM 힙은 여유가 있고 native memory 쪽 같다, RSS 추이 좀 보자"라고 하면 탐색 공간이 100분의 1로 줄어든다. 도메인 지식은 에이전트의 탐색 공간을 잘라내는 가지치기 도구다.

    동시에 저자는 솔직하다. "도메인 지식 없이도 이 대회는 가능했다. Top 10은 못 가겠지만 harness/에이전트 루프만으로도 baseline 대비 그럴듯한 speedup은 나온다." 딱 이 정도가 현재 에이전트의 실력 위치라고 보면 된다.

    루프를 흉내내보기

    거창할 것 없다. "변경 → 검증 → 점수 비교 → 좋으면 채택"이 전부다. 인프라 쪽에서 바로 돌려볼 수 있는 최소 형태를 만들어보자.

    #!/usr/bin/env bash
    # bench-loop.sh — 후보 매니페스트를 검증하고 렌더 시간을 점수로 기록
    set -euo pipefail
    CAND="${1:?usage: ./bench-loop.sh candidates/v3}"
    
    # 1) 정답성: 정책 위반 있으면 즉시 실패 (점수 계산 안 함)
    if ! kustomize build "$CAND" | conftest test -p policy/ - >/tmp/gate.log 2>&1; then
      echo "REJECT $CAND :: $(tail -n1 /tmp/gate.log)"; exit 1
    fi
    
    # 2) 성능: 5회 반복 후 최소값을 점수로
    best=99999
    for i in $(seq 5); do
      s=$( { /usr/bin/time -f "%e" kustomize build "$CAND" >/dev/null; } 2>&1 )
      best=$(echo "$s $best" | awk '{print ($1<$2)?$1:$2}')
    done
    echo "ACCEPT $CAND score=${best}s" | tee -a results.tsv
    
    $ ./bench-loop.sh candidates/v2
    REJECT candidates/v2 :: FAIL - Deployment/api must set resources.limits.memory
    
    $ ./bench-loop.sh candidates/v3
    ACCEPT candidates/v3 score=0.84s
    

    포인트는 두 가지다. ① 정답성 게이트를 통과 못 하면 점수 자체를 안 낸다(빠른 오답에 낚이지 않게). ② 점수를 results.tsv에 누적해서 에이전트가 과거 이력을 읽을 수 있게 한다. 원문의 체커가 shape별 타이밍을 돌려준 것과 같은 역할이다.

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

    비용은 진짜 나간다

    원문에서 가장 실무적인 대목은 이거다. 주최 측이 사실상 무제한 제출을 허용했는데, 다들 미친 듯이 던지는 바람에 큐가 밀려서 모두의 실행이 타임아웃났고, 어느 시점엔 워크스페이스의 Modal 크레딧이 바닥났다. 그래서 "제출 간격을 띄우라"는 조건이 붙었다.

    이걸 사내 CI로 옮기면 그림이 선명하다. 에이전트 루프를 GitHub Actions나 Jenkins에 붙이는 순간, 러너 슬롯을 독차지한다. 실제로 겪는 순서는 대충 이렇다.

    1. 에이전트가 잘 돈다 → 신나서 병렬도를 올린다
    2. 다른 팀 배포 파이프라인이 큐에서 대기하기 시작한다
    3. 슬랙에 "CI 왜 이렇게 느려요?" 가 올라온다
    4. 비용 대시보드에서 이번 달 청구서를 본다

    대책은 평범하지만 확실하다. 전용 러너 풀 분리 + concurrency 제한 + 세션당 하드 예산 상한. 특히 예산 상한은 "몇 달러"가 아니라 "몇 회 실행"으로 거는 게 관리하기 쉽다.

    # .github/workflows/agent-loop.yml (발췌)
    concurrency:
      group: agent-loop-${{ github.ref }}
      cancel-in-progress: true   # 옛 루프가 러너를 붙잡고 있지 않게
    jobs:
      optimize:
        runs-on: [self-hosted, agent-pool]   # 배포용 러너와 물리적으로 분리
        timeout-minutes: 45                  # 무한 루프 방어선
        steps:
          - uses: actions/checkout@v4
          - run: ./bench-loop.sh candidates/latest
    

    흔한 함정 1: 정답성 게이트가 헐거우면 에이전트가 그리로 샌다

    루프의 목적함수가 "빠를수록 좋다"인데 정답 판정이 느슨하면, 에이전트는 계산을 건너뛰는 방향으로 최적화한다. 이 대회는 체커가 QᵀQ ≈ I와 A ≈ QR을 모두 확인했기 때문에 그 도망길이 막혀 있었다. 우리 파이프라인에도 같은 장치가 필요하다. "테스트 통과"만 걸어두면 테스트를 skip 처리하는 커밋이 올라온다. 진짜다.

    수치 계산 쪽이라면 이런 에러가 게이트 역할을 한다. 저정밀도(FP16/FP8 등)를 내부적으로 쓰다가 최종 검증에서 터지는 전형적인 패턴이다.

    AssertionError: Tensor-likes are not close!
    
    Mismatched elements: 1837 / 262144 (0.7%)
    Greatest absolute difference: 0.0134 at index (0, 3, 511) (up to 1e-05 allowed)
    Greatest relative difference: 0.0091 at index (0, 3, 511) (up to 1.3e-06 allowed)
    

    대회 규칙상 내부 연산에 저비트를 쓰는 건 허용됐지만 반환되는 factor는 FP32 기준 QR 검사를 통과해야 했다. "빠르지만 틀린 답"을 걸러내는 선이 정확히 여기다. 인프라 쪽 대응물은 "이 최적화가 정책/보안/SLO 중 하나라도 깨면 점수 0"이다.

    흔한 함정 2: 로컬 최대값에 갇힌다

    원문에도 나오는 부분이다. 같은 방향으로만 계속 개선하면 어느 순간 0.5%씩 줄어들다가 멈춘다. 저자는 아이디어 다양성(idea diversity)을 일부러 주입해서 빠져나왔다고 적었다. 실무 번역: 같은 프롬프트, 같은 시작점으로 계속 돌리지 말고 구조가 다른 후보를 병렬로 굴려라. 브랜치를 3~4개 파서 서로 다른 아키텍처 가정(예: 블록 크기 다르게, 아예 다른 라이브러리)을 각각 밀어보는 식이다. 이건 하이퍼파라미터 서치의 random restart와 발상이 같다.

    흔한 함정 3: 벤치 노이즈를 점수로 착각한다

    공유 러너에서 시간을 재면 옆 잡 때문에 20~30% 흔들리는 건 예사다. 그러면 에이전트는 "노이즈로 우연히 빨랐던 후보"를 채택하고 그 위에 계속 쌓는다. 며칠 뒤 돌아보면 baseline보다 느린 코드가 최적해로 등극해 있다. 방어책:

    • 여러 번 측정 후 최소값 또는 중앙값 사용(위 스크립트가 최소값을 쓴 이유)
    • 개선 폭이 측정 편차보다 작으면 채택하지 않기
    • 주기적으로 baseline을 재측정해서 러너 자체가 느려졌는지 확인

    언제 안 쓰는 게 맞나 (대안)

    이 방식이 안 먹히는 영역이 분명히 있다.

    • 정답 판정이 사람 판단인 일: 아키텍처 설계, 장애 원인 보고서, 온콜 정책. 루프가 돌 수 없다.
    • 1회 실행 비용이 큰 일: 스테이징 전체 재구축에 40분 걸리면 1500회는 불가능하다. 이 경우엔 먼저 싼 프록시 지표(정적 검증, 드라이런, 축소 데이터셋)를 만드는 게 선행 과제다.
    • 롤백이 어려운 일: 프로덕션 DB 마이그레이션에 루프를 붙이지 마라. 당연한 말인데 실제로 시도하는 사람이 있다.

    반대로 잘 맞는 건 명확하다. 컨테이너 이미지 크기 줄이기, 빌드 시간 단축, 쿼리 튜닝, IaC 정책 통과율 — 전부 숫자 하나로 줄어들고 롤백이 공짜인 작업들이다.

    4. 정리

    한 줄 요약: "에이전트가 잘하는 게 아니라, 검증 가능한 목적함수와 값싼 반복이 있는 문제가 잘 풀리는 것이다."

    이 글에서 챙길 것 세 가지.

    1. 루프의 품질은 체커의 품질이다. 232배는 Codex의 성능이 아니라, 정답성과 속도를 자동 판정해주는 체커가 1500번 돌아간 결과다. 우리 일에서 먼저 만들 것도 에이전트가 아니라 게이트다.
    2. 도메인 지식은 여전히 순위를 가른다. 저자는 blocked Householder + WY-update라는 방향을 미리 잡고 들어갔다. 지식 없이도 baseline은 넘지만 상위권은 못 간다는 게 저자 본인의 진단이다.
    3. 공용 자원부터 격리하라. 크레딧이 바닥나고 큐가 밀려 전원이 타임아웃난 건 대회 얘기지만, 사내 CI에서 똑같이 재현된다. 전용 러너, concurrency 제한, 실행 횟수 상한은 시작 전에 걸어두는 것이다.

    지금 당장 해볼 만한 건, 팀에서 제일 자주 손대는 반복 작업 하나를 골라 "합격/불합격 + 점수 한 줄"을 뱉는 스크립트를 만드는 일이다. 에이전트를 붙이는 건 그다음이다. 그 스크립트가 없으면 에이전트는 그냥 비싼 난수 생성기다.

    참고 자료

    ※ 커널 구현 세부(블록 크기, 정밀도 조합 등)는 원문과 대회 리더보드를 직접 확인하는 걸 권한다. 이 글은 인프라/DevOps 관점에서 "루프 설계" 부분만 뽑아 정리한 것이다.

    728x90
    728x90

    지난 분기에 우리 팀에서 실제로 있었던 일이다. 인증 흐름 하나 바꾸는 작업을 RFC로 먼저 쪼갰다. 이슈 6개, PR 6개. 3번째 PR을 머지하고 나서야 "아, 2번에서 만든 인터페이스로는 프론트에서 못 쓴다"는 걸 알았다. 앞의 두 개를 되돌리고 다시 만들었다. 리뷰어 시간까지 합치면 이틀은 날아갔다.

    GeekNews에 올라온 "넓게 만들고, 좁게 배포하라(Build Wide, Ship Narrow)"는 딱 이 문제를 건드린다. 핵심 주장은 간단하다. 무엇을 만들지는 구현 전에 정하되, 어떻게 PR로 쪼갤지는 구현이 끝난 뒤에 정하자.

    왜 우리는 코드도 없는데 PR 경계를 먼저 정해왔나

    전통적 흐름은 RFC 작성 → 작은 이슈 분해 → 순차 구현이었다. 합리적으로 보이지만 근본적 문제가 있다. 아직 코드가 한 줄도 없는, 정보가 가장 부족한 시점에 구조적 경계를 결정해야 한다는 것이다.

    • 어떤 부분이 실제로 분리 가능한지 모른다
    • 각 부분의 복잡도를 추측만 할 수 있다
    • 뒷 단계에서 발견한 문제가 앞 설계를 뒤집을지 알 수 없다

    그런데도 왜 미리 쪼갰나? 원문은 이렇게 답한다. 완성된 브랜치를 나중에 작은 리뷰 단위로 다시 푸는 일이 너무 비쌌기 때문이다. 사전 분해는 구현을 쉽게 하려던 게 아니라 "사람이 리뷰할 수 있는 크기"를 확보하려던 현실적 타협이었다.

    AI가 바꾼 건 딱 이 비용이다. 원문이 꼽는 싸진 것 세 가지: 구현, 설계 검토, 그리고 완성된 작업의 재분해. 일주일간 얽혀 만든 브랜치를 작은 PR 여러 개로 다시 나누는 게 프롬프트로 처리 가능한 수준이 됐다는 것이다.

    반대로 안 싸진 것도 명확하다. 코드 리뷰의 판단 부분(이 코드가 여기 있는 게 맞나, 이 API가 6개월 뒤에도 괜찮나)과 제품 검증이다. 봇이 PR을 승인하는 것과 사람이 코드를 이해하는 것은 다르다는 문장이 이 글의 핵심이라고 본다.

    동작 원리: 작업 브랜치는 '버려도 되는 연습장'

    전체 흐름은 다섯 단계다.

    1. 설계 검토 — 계획에 실제 결정이 충분히 들어갈 때까지. 새 설계면 명세를 코드보다 먼저 커밋
    2. Build Wide — 브랜치 하나에서 백엔드/프론트 가로질러 끝까지 구현
    3. 데모 먼저 — PR 열기 전에 Slack 영상이나 프리뷰 배포로 제품 피드백
    4. Ship Narrow — 완성된 코드가 드러낸 경계로 작은 PR 재구성
    5. 정리 PR — 기존 코드 삭제는 마지막 별도 PR

    여기서 인프라 하는 사람이 가장 거부감 느낄 지점이 2번이다. 원문 저자는 리팩터링 하나에서 하루 동안 수십 개 파일을 고치며 커밋 12개 이상을 만들었다고 한다. Git 이력 깔끔하게 관리하던 사람 입장에선 끔찍하다.

    근데 전제가 다르다. 이 작업 브랜치의 이력은 최종 기록으로 남지 않는다. 최종 PR은 전부 main에서 새로 만든다. 작업 브랜치는 버리는 연습장이고, 커밋은 "위험한 시도 전 되돌아갈 지점" 용도다. 구현 중 필요한 이력과 리뷰어가 읽어야 할 이력을 분리하는 것이다.

    실제 분해는 이런 식으로 간다. 원문의 5개 PR 사례:

    # 작업 브랜치(feat/wide)에서 기능 완성 후, main 기준으로 PR별 worktree 생성
    git worktree add ../pr1-backend-a -b pr/backend-endpoint-a main
    git worktree add ../pr2-backend-b -b pr/backend-endpoint-b main
    
    # 프론트는 실제 의존성이 있으므로 해당 백엔드 PR 위에 스택
    git worktree add ../pr3-front-a -b pr/frontend-view-a pr/backend-endpoint-a
    
    # 삭제만 담당하는 마지막 PR
    git worktree add ../pr5-cleanup -b pr/remove-legacy-path main
    
    git worktree list
    
    /home/dev/app              a1b2c3d [main]
    /home/dev/pr1-backend-a    a1b2c3d [pr/backend-endpoint-a]
    /home/dev/pr2-backend-b    a1b2c3d [pr/backend-endpoint-b]
    /home/dev/pr3-front-a      e4f5g6h [pr/frontend-view-a]
    /home/dev/pr5-cleanup      a1b2c3d [pr/remove-legacy-path]
    

    독립적인 백엔드 엔드포인트 2개는 각각 main에서 직접 분기, 프론트 뷰 2개는 자기가 쓰는 백엔드 위에 스택, 마지막 PR은 기존 경로 삭제만. 원문에 따르면 이 마지막 삭제 PR에서 제거한 코드가 새 기능 전체가 추가한 코드보다 수백 줄 더 많았다고 한다.

    이 사례가 말하는 게 뭐냐면, "기존 코드 삭제 PR"은 사전 분해로는 절대 못 만드는 단위라는 것이다. 새 경로를 전부 만들고 동작을 확인해야만 무엇을 지울 수 있는지 알 수 있으니까.

    실무 관점: 어디에 쓰고 어디서 터지는가

    잘 맞는 작업은 원문 기준으로 세 가지다. 백엔드/프론트를 가로지르는 기능, 끝까지 해보기 전엔 구조를 모르는 리팩터링, 사전에 경계를 정하려면 추측만 가능한 작업.

    덜 맞는 작업이 더 중요하다. 인프라 하는 입장에선 여기가 지뢰밭이다.

    • 운영 순서가 제약인 마이그레이션과 스키마 변경 — 어떤 변경을 먼저 적용해야 하는지가 실제 제약이다. 사후에 예쁘게 쪼개봐야 배포 순서는 여전히 강제된다
    • 처음부터 나눌 위치가 명백한 작업 — 굳이 왕복할 이유가 없다
    • 첫 단계만 따로는 절대 배포 못 하는 기능 — 사후 분해로 얻는 건 점진적 리뷰뿐이다

    세 번째 항목은 원문도 솔직하게 인정한다. 예시로 든 5개 PR은 글 쓰는 시점에도 전부 열려 있었고, 결국 같은 날 다 머지될 수도 있다고 한다. PR을 잘 나눴다고 자동으로 점진 배포가 되는 게 아니다. "리뷰하기 쉬움"과 "독립적으로 머지할 가치가 있음"은 다른 조건이고, 에이전트가 도와줄 수 있는 건 앞의 것뿐이다.

    흔한 함정 1: 편하다고 스택하기

    원문 규칙은 명확하다. 실제 의존성이 있을 때만 스택한다. 단순히 편해서 스택하면 아래 PR에 리뷰 피드백이 들어오는 순간 위쪽 브랜치 전부를 연쇄 리베이스해야 한다. 실무에서 이렇게 터진다.

    $ git rebase pr/backend-endpoint-a
    Auto-merging src/api/client.ts
    CONFLICT (content): Merge conflict in src/api/client.ts
    error: could not apply e4f5g6h... feat: add frontend view A
    hint: Resolve all conflicts manually, mark them as resolved with
    hint: "git add/rm <conflicted_files>", then run "git rebase --continue".
    Could not apply e4f5g6h... feat: add frontend view A
    

    3~4단 스택에서 이게 나면 아래에서부터 순서대로 다 풀어야 한다. 실제 의존성이 없는데 편의로 쌓아둔 거면 그냥 손해다. 원문은 최초 분해를 수행한 채팅 세션을 유지해두면 그 컨텍스트로 위쪽 브랜치 업데이트도 다시 맡길 수 있다고 하는데, 이건 도구 의존적인 얘기라 각자 환경에서 검증이 필요해 보인다.

    흔한 함정 2: 데모를 건너뛰기

    이게 실무에서 가장 많이 생략되는 단계인데, 사실 이 방식의 절반이다. 코드 리뷰 단계에서 "UI가 헷갈린다", "이 API 프론트에서 쓰기 불편하다"는 피드백이 나오면 리뷰어 시간과 개발자 시간이 동시에 날아간다. 데모 단계에서 나오면 코드 구조를 크게 바꿔도 부담이 없다.

    인프라 관점에서 이건 PR 프리뷰 환경이 있냐 없냐의 문제로 직결된다. 없으면 이 워크플로 자체가 안 돌아간다. CI에서 PR 브랜치별 임시 환경을 띄우는 파이프라인이 선행 조건이라고 보면 된다.

    # .github/workflows/preview.yml (핵심만)
    on:
      push:
        branches: ['feat/**']   # PR 열기 전 작업 브랜치도 대상
    jobs:
      preview:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - name: Deploy preview
            run: |
              NS="pv-$(echo $GITHUB_REF_NAME | tr '/' '-' | cut -c1-40)"
              kubectl create ns "$NS" --dry-run=client -o yaml | kubectl apply -f -
              helm upgrade --install app ./chart -n "$NS" \
                --set image.tag=$GITHUB_SHA --set ingress.host=$NS.preview.example.com
              echo "https://$NS.preview.example.com"
    

    포인트는 on.push.branches에 PR이 아닌 작업 브랜치를 넣었다는 것이다. Build Wide 단계에선 PR이 아직 없으니까. 다만 네임스페이스 무한 증식은 막아야 한다. TTL 붙여서 자동 정리하는 job은 별도로 필요하다.

    흔한 함정 3: 분해를 AI에 맡기고 안 읽기

    원문이 강조하는 부분인데, PR로 나누는 과정 자체가 작성자 본인의 첫 코드 리뷰다. diff를 머릿속에 담을 수 있는 크기로 하나씩 확인하는 과정이 "AI가 쓴 코드를 그냥 배포한 것"과 "이해하고 배포한 것"을 가른다. 여기서 분해까지 통째로 자동화하고 넘기면 그냥 2,000줄 PR을 다섯 조각 낸 것에 불과하다.

    그리고 오해하기 쉬운 게, Build Wide가 "모든 PR이 작아진다"는 뜻이 아니다. 새 데이터 모델, 새 API 표면, 신뢰 경계가 걸린 백엔드 PR은 여전히 무겁고 무거워야 한다. 목표는 균일한 크기가 아니라 리뷰어의 주의를 실제 위험이 있는 곳에 몰아주는 구조다.

    대안 — 안 바꿔도 되는 경우

    기존 트렁크 기반 개발 + 피처 플래그 조합이 이미 잘 돌고 있고, 도메인이 안정적이라 경계가 자명하다면 굳이 갈아탈 이유가 없다. 이 방식이 값을 하는 건 "해보기 전엔 모르겠는" 작업이다. 반대로 팀에 프리뷰 환경도 없고 리뷰가 며칠씩 밀리는 상황이면, 이 워크플로 도입보다 리뷰 SLA부터 잡는 게 순서다.

    정리

    한 줄로: 구현 전에 추측으로 하던 PR 경계 결정을, 실제 코드가 존재하는 시점까지 미루는 것. 구조적 판단을 없애는 게 아니라 뒤로 옮기는 거다. 설계를 건너뛰라는 얘기가 절대 아니고, 오히려 원문 저자는 편집기 열기 전에 계획을 세우고 새 설계면 명세를 문서 PR로 먼저 머지해 팀이 이의 제기할 기회를 준다고 한다.

    이런 팀에 권한다.

    • AI 코딩 에이전트를 이미 일상적으로 쓰고 있는데 PR이 자꾸 비대해지는 팀
    • 풀스택 기능 하나 만드는 데 이슈를 5~6개로 쪼개다가 중간에 설계가 뒤집힌 경험이 있는 팀
    • PR 프리뷰 환경이 이미 있거나, 만들 여력이 있는 팀

    반대로 스키마 마이그레이션이 주 업무거나, 배포 순서가 규제로 정해져 있는 도메인이면 얻는 게 별로 없다. 그리고 도입한다면 프리뷰 환경 → 데모 문화 → 분해 자동화 순서로 가는 걸 권한다. 마지막 것부터 손대면 그냥 큰 PR을 잘게 썬 것으로 끝난다.

    참고 자료

    728x90
    728x90

    1. 6개월, 19건. "에러 없이 성공한 쓰기"가 사라졌다

    Tailscale이 제어 평면(control plane) 데이터베이스에서 겪은 손상 사고 이야기가 화제다. 요약하면 이렇다. 2024년 8월 S3 백업을 읽는 파이프라인에서 오류가 났고, PRAGMA integrity_check를 돌려보니 진짜 손상이었다. 그 뒤 6개월 동안 서로 무관해 보이는 손상 사고가 19번 터졌다. 결론은 SQLite의 체크포인트와 쓰기 트랜잭션 사이에 존재하던 데이터 경쟁, SQLite 개발진이 WAL-Reset 버그라 이름 붙인, 최소 16년 묵은 결함이었다.

    구성부터 보자. Tailscale의 제어 평면은 여러 샤드로 나뉘고, 각 샤드는 자기 SQLite 파일을 하나 갖는다. 접근하는 프로세스는 Go 프로세스 하나뿐이다. 단일 작성자 — SQLite가 가장 잘하는 형태다. 백업은 몇 분마다 전체 스냅샷을 떠서 S3에 올린다. 2023년 초부터 사고 없이 잘 돌던 구성이다.

    운영 담당자 입장에서 이 사건이 무서운 건 증상이 아니라 증상의 성질이다. 정리하면:

    • 재현 조건이 없다. 특정 샤드·고객·기능·시간대·부하와 상관관계가 안 나왔다.
    • 발생 간격이 몇 시간에서 몇 주까지 제멋대로다. 10월~12월엔 6주간 조용하다가 크리스마스 즈음 다시 터졌다.
    • 가장 결정적인 단서: 백업 복구용으로 만든 트랜잭션 로그를 재생했더니 깨끗하게 적용되지 않았다. 어떤 트랜잭션이 기록하고 커밋한 데이터가, 뒤이은 트랜잭션에서는 안 보였다. 애플리케이션에는 에러가 하나도 안 올라왔는데 쓰기가 증발한 것이다.
    • 지표도 이상했다. WAL에 10페이지가 있는데 체크포인트는 20페이지를 복사했다고 집계했다.

    영향은 메타데이터 범위였다. 사용자 개인키나 트래픽은 DB에 없다. 다만 손상된 샤드를 복구·복원하는 동안 제어 평면 프로세스를 멈춰야 했고, 초기엔 1시간 넘는 중단이 났다. 이미 연결된 WireGuard 피어는 살아 있지만 새로 켠 장치는 피어 목록을 못 받아 연결을 못 만든다. 그리고 반복되는 상태 페이지 공지 자체가 신뢰를 갉아먹었다.

    2. WAL과 체크포인트, 그리고 리셋 순간의 틈

    복습부터. WAL 모드에서 쓰기는 메인 DB 파일에 바로 가지 않고 -wal 파일에 페이지 단위로 append된다. 읽기 트랜잭션은 자기 스냅샷 시점까지의 WAL 프레임과 메인 파일을 조합해 본다. WAL이 무한정 커질 수 없으니 주기적으로 체크포인트가 WAL 페이지를 메인 DB로 복사하고, 아무도 그 프레임을 안 읽고 있으면 WAL을 리셋(처음부터 다시 쓰기 시작)한다.

    여기서 조율을 담당하는 게 -shm 파일에 mmap된 wal-index다. 일반 파일 잠금이 아니라 xShmLock 같은 공유 메모리 원시연산으로 조정한다. 그래서 이 경로는 연결이 둘 이상 열려 있어야만 밟힌다. Tailscale은 프로세스가 하나지만, 쓰기와 체크포인트를 서로 다른 스레드의 서로 다른 커넥션에서 수행했을 것으로 보인다(HN 토론의 해석). 실제로 SQLite 측 버그 설명도 다중 연결 조건을 든다.

    버그의 모양은 이렇다. 체크포인트가 진행되는 특정 시점에 쓰기 트랜잭션이 끼어들어 WAL을 리셋하면, 체크포인트 로직은 실제로 메인 DB에 복사하지 않은 페이지를 복사했다고 착각한다. 복사 안 된 페이지는 메인 파일에 영원히 안 들어간다. 그런데 그 페이지를 참조하는 인덱스 등 다른 페이지는 정상적으로 기록된다. 결과는 두 가지가 한꺼번에 온다 — 데이터 소실 + 구조 불일치(= 손상).

    비유하자면, 이삿짐 목록에 체크 표시를 하는 사람과 실제 박스를 옮기는 사람이 다른데, 중간에 누가 목록을 새 종이로 갈아끼웠고 체크 표시만 그대로 옮겨 적힌 상황이다. 목록상으론 다 옮겼는데 박스 몇 개가 옛날 집에 남아 있고, 그 박스를 가리키는 색인표는 새 집에 도착해 있다.

    왜 16년이나 안 걸렸나. 이 코드 경로는 xShmMap/xShmLock/xShmBarrier가 실제로 동작하는 VFS에서만 밟힌다. 현실에서 쓰이는 구현은 Unix와 Windows 정도고, 나머지 대체 VFS(브라우저/WASM/OPFS 등)는 locking_mode=exclusive로 우회해 wal-index를 힙에 둔다. 즉 Unix에서, 체크포인트를 수동으로, 공격적으로 돌리는 누군가가 나타나야 발견되는 버그였다. Tailscale은 빠르고 일관된 백업을 위해 체크포인트를 직접 제어하고 있었고, 그래서 당첨됐다.

    3. 재현 안 되는 버그를 어떻게 잡았나 — 관측을 프로덕션에 심는다

    이 사건의 진짜 볼거리는 디버깅 방법론이다. 합성 환경 재현을 포기하고 프로덕션에 수동적 포렌식 텔레메트리를 심는 쪽으로 방향을 틀었다.

    1. 사고 대응 시간을 먼저 줄였다. 손상 감지 시 샤드를 즉시 강제 중지, 백업마다 PRAGMA integrity_check를 돌리는 자동 백업 모니터 배포, 런북과 온콜 교육 개선. 이걸로 대응을 1시간 미만으로 낮췄다. 원인 못 찾는 동안 서비스는 돌려야 하니까.
    2. 트랜잭션 로그 파이프라인. DB를 바꾸는 모든 SQL 문을 별도 로그로 스트리밍. 단일 작성자 + 직렬화 가능 트랜잭션이라 기록이 선형적·결정적이다(멀티 라이터인 Postgres/MySQL에선 이 성질이 안 성립한다). 마지막 정상 백업에 로그를 재생해 손상 파일을 우회하고 최신 상태를 복원했다. 그리고 이 재생이 실패한 두 번의 사고가 결정적 단서가 됐다.
    3. tmstmpvfs shim. SQLite 개발진이 기존 VFS를 감싸 DB 변경 추적 로그를 남기는 shim을 만들었고, Tailscale이 이걸 프로덕션에 배포해 다음 사고의 로그를 확보했다. 여기서 경쟁 상태가 잡혔다.
    4. 수정 검증도 관측으로. "6주간 조용했던 전례"가 있었으니 재발 없음만으로 해결 선언을 안 했다. 대신 쓰기 트랜잭션과 WAL 리셋이 겹치면 경고를 남기도록 드라이버를 고쳤다. 배포 두 달 뒤 실제로 그 경고가 떴고 — DB는 멀쩡했다. 조건은 성립하는데 손상은 안 난다, 즉 패치가 사고를 막고 있다는 능동적 증거다.

    여기서 훔쳐올 만한 원칙 하나: "안 터졌다"는 수정의 증거가 아니다. 위험 조건이 발생했다는 사실 자체를 계측해서, 조건 발생 + 무사고를 함께 봐야 한다.

    4. 실무 점검: 지금 우리 SQLite는 안전한가

    SQLite는 국내에서도 엣지 노드, 모니터링 에이전트, 사이드카 캐시, 온디바이스 저장소에 넘치게 쓰인다. 당장 확인할 것부터.

    4-1. 저널 모드와 체크포인트 운용 확인

    $ sqlite3 /var/lib/agent/state.db \
      "PRAGMA journal_mode; PRAGMA wal_autocheckpoint; PRAGMA synchronous;"
    wal
    1000
    2
    
    # 수동 체크포인트를 돌린다면 반환값을 반드시 확인
    $ sqlite3 /var/lib/agent/state.db "PRAGMA wal_checkpoint(TRUNCATE);"
    0|10|10
    

    세 컬럼은 순서대로 busy, WAL 프레임 수, 메인 DB로 복사된 프레임 수다. 첫 값이 1이면 체크포인트가 완료되지 못했다는 뜻인데, 이걸 무시하고 "백업 떴다"고 넘어가는 스크립트가 현장에 정말 많다. 그리고 이번 사건에서 2번째와 3번째 값이 안 맞는 것이 이상 신호였다는 점을 기억하자. 백업 잡에서 이 세 값을 로그·메트릭으로 남겨두면 공짜 조기경보다.

    수동 체크포인트를 고빈도로 돌리고 있다면 그게 곧 비표준 운용이다. 문서화되고 지원되는 기능이라도, 일반 배포 경로보다 검증 범위가 좁다는 사실을 Tailscale이 비싸게 증명했다.

    4-2. 백업은 파일 복사가 아니라 검증까지

    #!/bin/bash
    set -euo pipefail
    DB=/var/lib/agent/state.db
    OUT=/backup/state-$(date +%Y%m%d%H%M).db
    
    # 실행 중 DB를 cp/tar로 긁는 것은 금물. 일관 스냅샷 API를 쓴다.
    sqlite3 "$DB" "VACUUM INTO '$OUT';"       # 3.27+ 지원
    
    # 업로드 전에 반드시 검증
    if ! sqlite3 "$OUT" "PRAGMA integrity_check;" | grep -qx "ok"; then
      echo "CORRUPT backup: $OUT" >&2
      exit 1
    fi
    aws s3 cp "$OUT" s3://backups/agent/ --only-show-errors
    

    핵심은 마지막 두 단계다. 검증하지 않은 백업은 백업이 아니다. Tailscale도 손상을 처음 발견한 게 DB 자체가 아니라 S3 백업을 읽던 파이프라인이었다. 백업 모니터가 없었다면 19건이 아니라 훨씬 뒤에, 훨씬 크게 터졌을 것이다.

    4-3. 흔한 함정과 실제로 보게 될 에러

    손상이 나면 애플리케이션 로그에 이런 게 뜬다. 검색해서 여기 온 사람이 있다면 반갑다.

    Error: database disk image is malformed (11)
    # Go 드라이버 기준
    sql: transaction: SQLITE_CORRUPT: database disk image is malformed
    
    $ sqlite3 state.db "PRAGMA integrity_check;"
    *** in database main ***
    Page 4213: btreeInitPage() returns error code 11
    row 90210 missing from index idx_nodes_tailnet
    wrong # of entries in index sqlite_autoindex_devices_1
    

    그리고 함정 하나 더. 위 출력에서 row N missing from index만 잔뜩 나오고 페이지 에러는 없다면, 진짜 손상이 아닐 수 있다. Tailscale은 3.52.0 배포 직후 13개 DB에서 손상 경고를 받았는데 실제 손상이 아니었다. 원인은 오래된 표현식 인덱스였다. 계산값 위에 인덱스를 만들어놓고 나중에 계산 방식이 바뀌면 인덱스 값과 실제 값이 어긋나고, integrity_check는 이걸 손상으로 판정한다. 이들은 고정밀 타임스탬프를 텍스트로 저장하고 VIRTUAL 생성 열에서 부동소수점으로 변환하고 있었는데, 3.52.0의 최적화가 텍스트→부동소수점 반올림 동작을 미세하게 바꿔버렸다. 카나리 샤드엔 해당 타임스탬프가 없어서 단계적 배포로도 못 걸렀다.

    SQLite 개발진은 이 거짓 경고 때문에 3.52.0을 철회하고, WAL-Reset 수정만 담은 3.51.3을 냈다. 3.53.0에는 오래된 표현식 인덱스를 막는 자동 자체 복구 기능이 들어갔다. Tailscale은 타임스탬프 정밀도를 정수 초로 낮춰 모호하지 않은 텍스트-정수 변환으로 바꿨다.

    여기서 실무 교훈 두 개. 첫째, 생성 열/표현식 인덱스는 "계산 방식이 언젠가 바뀔 수 있는가"를 기준으로 설계하라. 부동소수점 변환, 로케일·콜레이션 의존, 커스텀 함수 결과 위에 인덱스를 얹는 건 시한폭탄이다. 둘째, "온 김에" 다른 변경을 한 배포에 끼워 넣지 마라. 이번엔 크리티컬 버그 픽스와 최적화가 같은 릴리스에 있었고, 그 결과 픽스를 검증하려던 순간에 무관한 알람이 쏟아졌다.

    대안도 짚어두자. 멀티 라이터가 필요하거나 쓰기 QPS가 계속 오른다면 애초에 SQLite가 답이 아니다(Postgres/MySQL). 반대로 SQLite를 유지하되 복제·백업을 자동화하고 싶다면 Litestream/rqlite/LiteFS 같은 선택지가 있는데, 이들도 결국 WAL과 체크포인트를 어떤 방식으로 다루는지가 안정성의 핵심이니 도입 전 체크포인트 운용 방식이 표준 경로인지 공식 문서로 확인하는 게 좋다.

    5. 정리

    한 줄 요약: SQLite에서 체크포인트를 직접, 공격적으로 제어하고 있다면 16년 된 WAL-Reset 레이스에 걸릴 수 있으니 3.51.3 이상으로 올리고, 백업마다 integrity_check를 돌려라.

    누가 지금 확인해야 하나. (1) WAL 모드 + 수동 wal_checkpoint를 짧은 주기로 돌리는 서비스, (2) 한 프로세스 안에서 여러 커넥션을 열어 쓰기와 체크포인트를 나눠 처리하는 구조, (3) 백업을 뜨기만 하고 무결성 검증은 안 하는 파이프라인. 셋 다 해당한다면 오늘 안에 위 명령 두 개는 돌려보길 권한다.

    마지막으로 개인적으로 가장 남는 대목. Tailscale은 오픈소스 유지보수자에게 공짜 수정을 요구하지 않고 SQLite 유료 지원 계약을 샀고, 그 과정에서 나온 tmstmpvfs shim은 오픈소스로 남아 다음 사람이 비슷한 버그를 잡는 데 쓰인다. "검증된 기술도 비표준으로 굴리면 위험하다"는 교훈과 함께, 문제 해결에 돈과 시간을 제대로 쓰는 방식의 좋은 사례다.

    참고 자료

    728x90

    + Recent posts