728x90

내부 DNS는 인프라 하는 사람들에게 오랫동안 애매하게 방치된 영역이었다. 공개 DNS는 Cloudflare나 Route53 같은 걸 쓰고, 사내망은 CoreDNS나 BIND, 클라우드 안에서는 각 벤더의 Private Hosted Zone... 이렇게 서너 개 시스템이 각자 놀고 있는 경우가 대부분이다. 2026년 7월 GA된 Cloudflare Internal DNS는 바로 이 파편화를 하나의 제어 평면으로 묶겠다는 이야기다. 실무자 입장에서 이게 진짜 쓸만한 건지, 어디서 삽질하게 될지를 정리해봤다.

1. 왜 지금 이게 화제인가 — 내부 DNS 파편화라는 오래된 통증

혼합 환경을 굴려본 사람이라면 이 그림이 익숙할 거다.

  • 공개 도메인(example.com)은 외부 DNS 제공자
  • 사내망(*.corp.internal)은 온프레미스 CoreDNS 또는 BIND
  • AWS 안쪽은 Route53 Private Hosted Zone
  • GCP는 Cloud DNS의 private zone
  • 보안 정책은 또 별도 방화벽/프록시 계층에서

문제는 이 시스템들이 공통 제어 평면을 공유하지 않는다는 점이다. 특히 Split-horizon DNS(같은 호스트명을 내부/외부 사용자에게 다르게 응답하는 구성)를 하면 지옥문이 열린다. 내부용 뷰와 외부용 뷰를 각각 별도 시스템에서 관리하다가, 어느 한쪽만 레코드를 수정하는 순간 두 시스템이 어긋나기(drift) 시작한다. 그리고 그 drift는 꼭 금요일 저녁에 장애로 터진다.

Cloudflare가 미는 메시지는 명확하다. "공개 DNS, 내부 DNS, Zero Trust 정책을 같은 글로벌 네트워크와 하나의 API에서 다루자." 원문에 따르면 Enterprise 고객은 Cloudflare Gateway에 추가 비용 없이 포함된다고 한다. 이 부분은 계약 형태에 따라 다를 수 있으니 영업/공식 문서 확인은 필요하다.

2. 동작 원리 — Gateway Resolver + Internal Authoritative DNS

구조를 단순화하면 두 개의 컴포넌트로 나뉜다.

  • Gateway Resolver: 재귀 해석(recursive)과 정책 평가를 담당. 2020년에 나온 그 Gateway 맞고, 공개 해석은 1.1.1.1 인프라를 그대로 탄다. 표현식(expression) 기반으로 쿼리를 필터링하거나 다른 업스트림으로 리다이렉트할 수 있다.
  • Internal Authoritative DNS: 내부 존의 권한 있는(authoritative) 레코드를 서빙. Cloudflare가 10년 넘게 굴려온 그 authoritative 플랫폼 위에 올라간다.

여기서 다루는 객체가 세 가지인데, 이걸 이해하면 절반은 끝난다.

  • Internal Zones: 내부 리소스의 실제 레코드(앱, 서비스 엔드포인트, DB 등)를 담는 존
  • DNS Views: 특정 사용자/디바이스 집합이 봐야 할 "해석 컨텍스트". Split-horizon이 별도 시스템 없이 되는 핵심.
  • Resolver Policies: Gateway 안에 존재하며, 매칭되는 쿼리를 특정 View로 라우팅

중요한 개념이 Zone reference다. intranet.local 같은 공용 존을 한 번만 정의하고 여러 View에서 참조(reference)한다. 기존 Split-horizon이 강요하던 "레코드 복붙 → drift 발생" 패턴을 DRY하게 바꾸는 게 이 참조 구조다. 개인적으로 이 부분이 제일 실용적이라고 본다. CoreDNS로 뷰 나눌 때 존 파일 두 벌 관리하다가 한쪽만 고쳐서 사고 나는 걸 실제로 몇 번 봤기 때문이다.

쿼리 한 방이 흘러가는 경로

클라이언트 쿼리가 들어오면 이렇게 처리된다.

  1. 먼저 Gateway Resolver에 도착해서 정책이 평가된다.
  2. Resolver policy가 매칭되고 내부 View를 가리키면 → Internal Authoritative DNS로 라우팅, 해당 View의 존에서 응답.
  3. 정책이 차단(block)하면 → resolver 단에서 드롭.
  4. 둘 다 아니면 → 공개 경로로, 1.1.1.1이 공개 DNS 계층에서 해석.

여기서 편한 게, View는 내부에서 이름을 못 찾으면 공개 해석으로 폴백(fallback)할 수 있다. 즉 클라이언트는 이게 내부 이름인지 공개 이름인지 몰라도 되고, 하나의 리졸버가 둘 다 처리한다. 이걸 CoreDNS로 하려면 forwardfallthrough 조합을 손으로 짜야 하는데, 그걸 정책으로 추상화한 셈이다.

변경 전파 경로

레코드 변경은 대시보드든 Terraform이든 직접 API 호출이든 전부 동일한 DNS Records API를 탄다. 쓰기 경로가 하나라 감사(audit)와 추론이 단순해진다. 변경은 코어 데이터센터에 저장·검증된 뒤 글로벌 네트워크로 복제되고, 영향받은 캐시 엔트리가 무효화되어 TTL 만료를 기다리지 않고 몇 초 안에 반영된다고 한다. 이건 온프레미스 BIND에서 TTL 걸어놓고 "왜 아직 옛날 값 나와?" 하던 경험을 아는 사람에겐 꽤 매력적인 부분이다.

3. 기존 솔루션과 비교 — CoreDNS / Route53 PHZ / Pi-hole

실무에서 자주 쓰는 것들과 성격을 비교해보면 이렇다. (수치성 벤치마크는 원문에 없어 생략, 성격 위주로만 정리한다.)

항목Cloudflare Internal DNSCoreDNSRoute53 Private Hosted ZonePi-hole
주 용도내부/공개 통합 + Zero Trust 정책K8s/자체 내부 DNSAWS VPC 내부 DNS가정/소규모 광고차단 DNS
제어 평면Cloudflare 단일 평면직접 운영AWS 콘솔/API직접 운영
Split-horizonView + Zone reference로 네이티브view 플러그인/수동 구성VPC 단위로 사실상 horizon사실상 미지원
Zero Trust 연동Gateway 정책과 통합없음(별도 계층)없음없음
클라우드 종속없음(어느 연결이든)없음AWS 락인없음
운영 부담매니지드본인이 다 짊어짐매니지드(AWS 한정)직접 운영

정리하면, CoreDNS는 K8s 클러스터 내부용으로는 여전히 최고지만 조직 전체의 사용자·디바이스 정책까지 다루지는 않는다. Route53 PHZ는 AWS 안에서는 훌륭하지만 온프레미스와 다른 클라우드를 아우르는 순간 벽에 부딪힌다. Cloudflare Internal DNS의 차별점은 "DNS 해석 + 누가 접근 가능한가"를 같은 정책 엔진에서 결정한다는 지점이다. Pi-hole은 애초에 결이 다르니 비교표에 넣긴 했지만 엔터프라이즈 대안은 아니다.

4. 실전 구성 — Split-horizon DNS 세 단계로 세우기

원문 기준으로 설정은 크게 세 단계다. 존 생성 → 뷰 생성 → 리졸버 정책 정의. API로 가보자.

4-1. 내부 존과 첫 레코드 생성

# 1) 내부 존 생성
curl -X POST "https://api.cloudflare.com/client/v4/accounts/zones" \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "account": { "id": "'"$ACCOUNT_ID"'" },
    "name": "corp.internal",
    "type": "internal"
  }'

기대 출력(예시):

{
  "success": true,
  "errors": [],
  "messages": [],
  "result": {
    "id": "023e105f4ecef8ad9ca31a8372d0c353",
    "name": "corp.internal",
    "type": "internal",
    "status": "active"
  }
}
# 2) 내부 A 레코드 추가 (예: DB 엔드포인트)
curl -X POST "https://api.cloudflare.com/client/v4/zones/023e105f4ecef8ad9ca31a8372d0c353/dns_records" \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "A",
    "name": "db.corp.internal",
    "content": "10.0.1.50",
    "ttl": 300
  }'

4-2. DNS View 생성 후 존 연결

curl -X POST "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/internal_dns/views" \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "production-view",
    "zones": ["023e105f4ecef8ad9ca31a8372d0c353"]
  }'

기대 출력(예시):

{
  "success": true,
  "result": {
    "id": "9f7b3c1a55d24e6f8a0b1c2d3e4f5a6b",
    "name": "production-view",
    "zones": ["023e105f4ecef8ad9ca31a8372d0c353"]
  }
}

4-3. Gateway Resolver Policy로 라우팅

마지막은 Zero Trust 대시보드에서 Gateway location을 만들고, 조건을 정한 뒤 해석 방식(resolution method)을 Internal DNS View로 지정, 위에서 만든 production-view를 선택하면 된다. 여기서부터 정책에 매칭되는 쿼리는 내부 존에서 해석된다.

4-4. Terraform으로 IaC 관리

Terraform 지원이 있고, Terraform 역시 동일한 DNS Records API를 타기 때문에 전파 경로가 대시보드/직접 API와 완전히 같다. 즉 IaC로 바꿔도 별도 반영 경로를 따로 신경 쓸 필요가 없다. 아래는 개념 예시이며, 실제 리소스명·인자는 공식 프로바이더 문서 확인이 필요하다.

# 개념 예시 (실제 스키마는 provider 문서 확인 필요)
resource "cloudflare_dns_record" "db_internal" {
  zone_id = "023e105f4ecef8ad9ca31a8372d0c353"
  type    = "A"
  name    = "db.corp.internal"
  content = "10.0.1.50"
  ttl     = 300
}

연결 방식은 뭐든 된다

Gateway Resolver로 DNS 트래픽이 흐르기만 하면 된다. Cloudflare One Client(구 WARP), DoH, DoT, 표준 53번 포트 DNS, PAC 파일, Cloudflare WAN 모두 지원한다. 특히 Cloudflare WAN을 쓰면 연결된 네트워크의 모든 디바이스가 개별 클라이언트 설치 없이 내부 호스트명을 해석할 수 있다고 한다. 레거시 장비가 깔린 지점망/공장망처럼 에이전트 설치가 곤란한 환경에서 이 부분이 꽤 유용할 것으로 보인다.

5. 운영 관점 — 트레이드오프와 흔한 함정

흔한 함정 1: 폴백을 믿고 존을 어설프게 나눴을 때

View가 내부에서 못 찾으면 공개로 폴백한다는 게 편하긴 한데, 이게 함정이 될 수도 있다. 내부 존에 존재해야 할 레코드가 오타나 누락으로 빠지면, 차단되지 않고 조용히 공개 해석으로 넘어가 엉뚱한 외부 IP를 물어오는 경우가 생긴다. 이런 건 에러가 안 나서 더 위험하다. 배포 후 내부 이름은 반드시 dig로 응답 소스를 검증하는 습관을 들이는 게 좋다.

dig db.corp.internal +short
# 기대: 10.0.1.50
# 만약 공인 IP가 나온다면 → 폴백으로 새어나간 것. 존/뷰/정책 매칭 재점검

흔한 함정 2: 존 타입/이름 충돌 에러

같은 이름의 존을 중복 생성하거나, 이미 공개 존으로 등록된 이름을 내부 존으로 다시 만들려 할 때 대략 이런 형태의 응답을 만나게 된다(에러 형식은 실제와 다를 수 있으니 참고용):

{
  "success": false,
  "errors": [
    {
      "code": 1061,
      "message": "zone with name 'corp.internal' already exists"
    }
  ],
  "result": null
}

이럴 땐 새로 만들지 말고 기존 존을 여러 View에서 참조(Zone reference)하는 방향으로 가야 한다. Split-horizon을 한다고 존을 복제 생성하는 순간 Cloudflare가 굳이 없애려던 drift를 스스로 다시 만드는 꼴이다.

흔한 함정 3: 정책이 매칭 안 돼서 전부 공개로 나가는 경우

Resolver Policy의 location 조건(소스 IP 범위, 디바이스 상태 등)이 실제 클라이언트와 안 맞으면, 쿼리는 정책에 걸리지 않고 그냥 공개 경로로 간다. "내부 이름이 하나도 안 잡혀요" 증상의 대부분은 존/레코드 문제가 아니라 정책 매칭 조건 문제다. Gateway의 DNS 로그(단일 대시보드)를 열어서 해당 쿼리가 어떤 정책에 매칭됐는지부터 확인하는 게 순서다. 이게 로그가 한 군데 모인다는 통합 제어 평면의 실질적 이점이기도 하다.

트레이드오프

  • 종속성: 결국 Cloudflare 제어 평면에 DNS 해석 경로가 묶인다. 멀티 벤더 전략을 중시하는 조직은 이 점을 감안해야 한다.
  • Enterprise 중심: 원문상 Enterprise + Gateway 사용자가 대상이다. 소규모 팀이 가볍게 쓰기엔 결이 안 맞을 수 있다.
  • 클러스터 내부 DNS는 여전히 CoreDNS: K8s 내부의 서비스 디스커버리까지 이걸로 대체하는 그림은 아니다. 조직 레벨 이름 해석과 클러스터 내부 해석은 계층이 다르다.

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

한 줄 요약: 공개 DNS·내부 DNS·Zero Trust 정책이 각자 따로 놀아서 drift와 장애에 시달려온 조직이라면, 이 셋을 하나의 API/정책 엔진/감사 로그로 합쳐준다는 게 Cloudflare Internal DNS의 핵심 가치다.

이런 팀에 잘 맞는다.

  • 온프레미스 + 여러 클라우드가 섞여 있고, 내부 DNS가 환경마다 파편화된 조직
  • Split-horizon을 두 벌 존 관리로 버티다가 drift 장애를 겪어본 팀
  • 이미 Cloudflare Zero Trust / Gateway / WAN을 쓰고 있어 제어 평면을 하나로 모으고 싶은 곳
  • 레거시 DNS 어플라이언스 리프레시 주기와 스케일링 병목을 걷어내고 싶은 경우

반대로 K8s 클러스터 내부 해석만 필요하거나, 소규모여서 CoreDNS/자체 리졸버로 충분한 팀이라면 굳이 지금 갈아탈 이유는 크지 않다. 다만 원문 마지막이 예고하듯 "이름 해석 → 서비스 도달 → 접근 인가"를 하나의 플랫폼에서 결정하는 방향으로 통합이 더 진행될 걸로 보이니, Cloudflare 생태계를 이미 쓰는 조직이라면 흐름은 지켜볼 가치가 있다.

참고 자료

※ 본문의 API 응답·에러 메시지 일부는 형식 이해를 돕기 위한 예시이며, 실제 응답 스키마와 다를 수 있다. 정확한 스키마·리소스명·요금 조건은 공식 문서 확인이 필요하다.

728x90
728x90

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

Hacker News에 올라온 Show HN: I replaced a $120k bowling center system with $1,600 in ESP32s가 1,200점 넘게 받으면서 화제가 됐다. 요약하면 이렇다. 어떤 SRE가 미국 시골의 폐업한 8레인 볼링장을 $105k에 사들였는데, 정작 점수 계산·핀 감지·기계 제어를 담당하는 스코어링 시스템 교체 견적이 $80k~$120k가 나왔다는 것이다. 건물값이랑 맞먹는다.

여기서 실무자 입장에서 눈이 번쩍 뜨이는 대목이 있다. 글쓴이가 뜯어보니 이 6자리 견적짜리 시스템이 70년 된 핀세터 기계에 대해 실제로 하는 일은 릴레이 하나를 딸깍 켜는 것이었다. 나머지는 전부 기계식이다. 카메라 기반 핀 감지, 볼 스피드 계산, 궤적 트래킹 같은 화려한 기능이 붙어 있지만, 기계 제어의 본질은 "단일 릴레이 트리거"였다는 얘기다.

이건 볼링장만의 이야기가 아니다. 우리가 현장에서 만나는 레거시 SCADA, PLC, 오래된 산업용 게이트웨이, 벤더 락인된 관제 시스템 대부분이 이 구조다. 겉은 블랙박스인데 실제 인터페이스는 릴레이 몇 개, 시리얼 몇 줄, 접점 신호 몇 개인 경우가 많다. "이거 우리도 라즈베리파이랑 ESP32로 만들 수 있는 거 아니야?"라는 생각을 실제로 실행에 옮긴 사례라서 참고할 게 많다.

2. 핵심: 그래서 어떻게 동작하는가

레거시 시스템의 실제 구조

원문에서 파악되는 기존 시스템(2008년 설치)의 구성은 이렇다.

  • 카메라 기반 핀 감지 (object detection + 삼각법을 IC 레벨에서)
  • 볼 스피드/궤적 계산
  • 파울 라인 감지(fouling)
  • 애니메이션, 스코어 표시
  • 핀세터 머신 + 볼 리턴 제어 → 결국 릴레이 액추에이션

부품값이 레인 2개당 $4,000. 업그레이드나 서비스 계약은 별도, 모든 기능이 라인 아이템으로 따로 청구되는 전형적인 벤더 락인 구조다. 2008년 기술에 2024년 가격을 받는 셈이다.

ESP32 대체 아키텍처

글쓴이가 만든 OpenLaneLink의 구조를 원문 기준으로 정리하면 이렇다.

[센서/제어 노드들]           [게이트웨이]        [레인 컴퓨터]
ESP32 + 릴레이/옵토커플러  ──ESPNow──▶  ESP32   ──UART──▶  Raspberry Pi
ESP32 + IR break-beam 센서   (스타 토폴로지 메시)          (Redis + 상태머신)
       │                                                        │
       └── RS485 (RF 노이즈 환경 대비 유선 폴백)            React WebSocket
                                                              pub-sub UI

핵심 설계 포인트를 뜯어보자.

  • 노드 계층: 각 ESP32가 센서 이벤트를 방출하고 제어 명령을 수신한다. 노드마다 살짝 다른 펌웨어가 돌아간다(IR 센서 노드, 릴레이 제어 노드 등).
  • 통신 계층: ESPNow 스타 토폴로지 메시가 기본. RF가 지저분한 환경(전기 설비가 계속 서지 튀는 오래된 건물)을 대비해 RS485 유선 폴백을 깔아뒀다. 이 이중화 결정이 실무적으로 핵심이다.
  • 수집 계층: 게이트웨이 ESP32가 UART로 라즈베리파이에 연결. RX 패킷을 파싱해서 Redis에 던진다.
  • 애플리케이션 계층: Redis에 데이터가 들어오면 그 다음부터는 우리에게 익숙한 세계다. WebSocket pub-sub, React UI. "어떤 React 개발자든 자기만의 볼링 애니메이션을 만들 수 있다"는 게 글쓴이 표현이다.

비유하자면, 벤더의 통짜 모놀리식을 엣지(센서/제어) → 게이트웨이(프로토콜 변환) → 이벤트 스트림(Redis) → 프론트로 계층 분리한 것이다. 백엔드 하는 사람이면 마이크로서비스에서 하던 "관심사 분리"를 물리 세계에 그대로 적용했다고 보면 편하다.

IR break-beam으로 핀 감지를 대체한다는 발상

기존 시스템은 카메라 + 객체 탐지로 핀을 셌다. 글쓴이는 IR break-beam 센서로 대체했다. 핀이 서 있으면 빛줄기가 막히고, 넘어지면 통과하는 단순한 원리다. 카메라 비전 파이프라인 대신 GPIO 인터럽트 하나로 끝난다. 아래는 개념을 보여주는 ESP32(Arduino 프레임워크) 펌웨어 스니펫이다.

// pin_sensor_node.ino - IR break-beam 기반 핀 상태 감지 (개념 예시)
#include <esp_now.h>
#include <WiFi.h>

#define NUM_PINS 10
const int sensorPins[NUM_PINS] = {32, 33, 25, 26, 27, 14, 12, 13, 15, 4};
uint16_t pinState = 0;   // bit=1 이면 핀 서있음

// 게이트웨이 MAC (예시)
uint8_t gatewayMac[] = {0x24, 0x6F, 0x28, 0xAA, 0xBB, 0xCC};

typedef struct {
  uint8_t  msgType;   // 0x01 = pin_state
  uint16_t pins;      // 10비트 사용
  uint32_t seq;
} __attribute__((packed)) PinMsg;

uint32_t seqCounter = 0;

void setup() {
  Serial.begin(115200);
  for (int i = 0; i < NUM_PINS; i++) pinMode(sensorPins[i], INPUT_PULLUP);

  WiFi.mode(WIFI_STA);
  if (esp_now_init() != ESP_OK) {
    Serial.println("ESPNow init failed");
    return;
  }
  esp_now_peer_info_t peer = {};
  memcpy(peer.peer_addr, gatewayMac, 6);
  peer.channel = 1;
  peer.encrypt = false;
  esp_now_add_peer(&peer);
}

void loop() {
  uint16_t current = 0;
  for (int i = 0; i < NUM_PINS; i++) {
    // break-beam: LOW = 빔 차단 = 핀 서있음
    if (digitalRead(sensorPins[i]) == LOW) current |= (1 << i);
  }
  // 상태가 바뀔 때만 전송 (엣지 트리거)
  if (current != pinState) {
    pinState = current;
    PinMsg msg = { 0x01, pinState, seqCounter++ };
    esp_now_send(gatewayMac, (uint8_t*)&msg, sizeof(msg));
    Serial.printf("pin_state=0x%03X seq=%u\n", pinState, seqCounter);
  }
  delay(20);  // 디바운스 겸
}

이걸 플래시하고 시리얼 모니터를 열면 대략 이런 출력이 나온다.

$ pio device monitor -b 115200
--- Terminal on /dev/ttyUSB0 | 115200 8-N-1
pin_state=0x3FF seq=1
pin_state=0x3FE seq=2
pin_state=0x37A seq=3
pin_state=0x000 seq=4

0x3FF은 10핀 모두 서있음(스페어 프레임 시작), 0x000은 스트라이크로 다 넘어간 상태다. 이렇게 상태 변화만 이벤트로 흘려보내면 대역폭도 아끼고 라즈베리파이 쪽 상태머신도 깔끔해진다.

Redis로 이벤트를 받아 상태를 관리하기

게이트웨이 ESP32가 UART로 넘긴 이벤트를 라즈베리파이가 파싱해서 Redis Pub/Sub으로 흘린다. 파이썬 쪽 골격은 이 정도다.

import redis, serial, struct

r = serial.Serial('/dev/ttyAMA0', 115200, timeout=1)
rds = redis.Redis(host='localhost', port=6379, decode_responses=True)

def parse(frame: bytes):
    # msgType(1) + pins(2, LE) + seq(4, LE)
    msg_type, pins, seq = struct.unpack('<BHI', frame[:7])
    return msg_type, pins, seq

while True:
    line = r.read(7)
    if len(line) < 7:
        continue
    mt, pins, seq = parse(line)
    if mt == 0x01:
        # 채널로 publish + 최신 상태 저장
        rds.publish('lane:3:pins', pins)
        rds.set('lane:3:pins:latest', pins)
        print(f"[lane3] pins=0x{pins:03X} seq={seq}")

구독 쪽은 redis-cli로 바로 확인 가능하다.

$ redis-cli subscribe lane:3:pins
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "lane:3:pins"
3) (integer) 1
1) "message"
2) "lane:3:pins"
3) "1018"     # 0x3FA
1) "message"
2) "lane:3:pins"
3) "0"        # 스트라이크

여기까지 오면 프론트엔드는 그냥 WebSocket으로 이 채널을 구독해서 애니메이션 트리거하면 된다. 물리 세계 이벤트가 완전히 소프트웨어 문제로 환원된 것이다. 이 지점이 이 아키텍처의 진짜 강점이다.

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

트레이드오프 1: RF vs 유선, 왜 RS485 폴백을 깔았나

글쓴이가 "전기 설비가 계속 서지를 튄다"고 했다. 오래된 산업 환경에서 ESPNow(2.4GHz) 하나만 믿으면 안 되는 이유다. 핀세터 모터가 돌 때 나오는 EMI, 형광등 안정기, 낡은 배선에서 나오는 노이즈가 2.4GHz 대역을 그냥 갉아먹는다. 그래서 무선 우선 + 유선 폴백 이중화가 정석이다.

여기서 실무 판단이 갈린다. 레인이 8개고 노드가 수십 개인데 전부 RS485로 깔면 배선 지옥이 된다. 반대로 무선만 쓰면 결정적 순간(스트라이크 감지)에 패킷이 유실된다. 글쓴이의 선택—평소엔 ESPNow, 노이즈 심하면 RS485—은 비용과 안정성의 합리적 절충으로 보인다.

흔한 함정 1: ESPNow 채널 불일치와 peer add 실패

ESPNow에서 가장 많이 밟는 지뢰가 Wi-Fi 채널 불일치다. 송신·수신 노드의 채널이 다르면 조용히 패킷이 안 간다. 로그도 안 남아서 삽질하기 딱 좋다. 또 하나는 peer를 안 넣고 send를 때리는 경우다.

E (12453) ESPNOW: Peer not found, add peer first
E (12453) ESPNOW: esp_now_send failed, ret=12395

ret=12395ESP_ERR_ESPNOW_NOT_FOUND 계열이다. 대상 MAC을 esp_now_add_peer()로 먼저 등록했는지, 그리고 STA 모드에서 채널을 명시적으로 고정했는지 확인해야 한다. AP에 붙는 순간 채널이 바뀌어버리는 것도 함정이다. 그래서 순수 ESPNow 메시라면 esp_wifi_set_channel()로 채널을 박아버리는 게 안전하다.

흔한 함정 2: UART 프레이밍 깨짐

게이트웨이 ESP32 → 라즈베리파이 UART 구간에서 위 파이썬 예제처럼 read(7)로 고정 길이만 믿으면 반드시 언젠가 프레임이 밀린다. 노이즈로 1바이트가 씹히면 그 뒤로 모든 프레임이 어긋난다. 시리얼 모니터에 이런 게 찍히면 프레이밍 문제다.

[lane3] pins=0x2AF seq=1
[lane3] pins=0xFF3A seq=3355443    # seq가 갑자기 튐 = 프레임 밀림
struct.error: unpack requires a buffer of 7 bytes

대응은 정석대로다. 스타트 바이트(예: 0xAA) + 길이 + 페이로드 + CRC 프레이밍을 넣고, 수신 측에서 스타트 바이트를 찾아 재동기화하는 로직을 둔다. 이거 안 하고 데모까지는 잘 돌다가 현장 나가면 터진다. 물리 계층 붙이는 프로젝트의 90% 함정이 여기다.

트레이드오프 2: 안전(safety) 문제는 별개다

이건 원문에 명시된 건 아니지만 실무자로서 반드시 짚어야 한다. 볼링 핀세터는 회전하는 무거운 기계다. 릴레이를 SW로 제어한다는 건 곧 버그가 물리적 사고로 직결될 수 있다는 뜻이다. 댓글에서도 예전에 "핀세터가 두 번 연속 리셋되는 글리치가 있었다, 릴레이가 너무 오래 닫혀 있었던 것 같다"는 경험담이 나온다.

ESP32가 죽거나(WDT 리셋), 프리징되거나, 릴레이 GPIO가 부팅 순간 HIGH로 튀는(ESP32의 특정 스트래핑 핀은 부팅 시 상태가 불안정하다) 상황을 반드시 설계에 반영해야 한다. 실제 상용 전환이라면:

  • 기계 자체의 기계식 리미트 스위치/E-Stop은 그대로 살려둔다(SW가 우회 못 하게).
  • 릴레이는 워치독 하드웨어로 페일세이프(전원 나가면 OFF) 걸리게.
  • 부팅 시 GPIO가 뜨는 핀은 릴레이 제어에 쓰지 않는다.

취미로 내 볼링장 돌리는 것과, 이걸 오픈소스로 뿌려서 남들이 자기 시설에 붙이는 건 안전 책임 레벨이 완전히 다르다. OpenLaneLink가 상용 수준으로 가려면 이 부분이 가장 어려운 관문일 것으로 보인다.

대안은 없나

  • ScoreMore 같은 기존 SW와 통합: 원문 댓글의 vikbez가 실제로 한 방식이다. 하드웨어는 아두이노로 바꾸되 검증된 스코어링 SW에 붙였다. 글쓴이도 검토했으나 파울 감지 유닛 하나가 $750인 게 걸려서 완전 자작으로 갔다고 한다. 리스크를 줄이려면 통합이 합리적이다.
  • 산업용 PLC + HMI: 안정성·인증 측면에선 정석이지만 비용이 다시 올라간다. 벤더 락인의 다른 버전이 될 수 있다.
  • ESP32 대신 라즈베리파이 GPIO 직결: 노드가 적으면 오히려 단순하다. 다만 레인마다 배선 끌고 가야 해서 분산 환경엔 안 맞는다.

비용 구조: CapEx만 보면 안 된다

원문 수치를 정리하면 이렇다(글쓴이 주장 기준).

  • 벤더 1:1 교체: $80k ~ $120k (업그레이드/서비스 계약 별도)
  • 벤더 부품: 레인 2개당 $4,000
  • 자작 프로토타입: 레인 2개당 약 $200 (풀옵션 $400)
  • 수리 시간: 5분 / 레인 페어 교체: 10분 이내

여기서 인프라 엔지니어가 놓치기 쉬운 포인트. CapEx($200 vs $120k)만 보면 압도적이지만, 진짜 가치는 운영 비용과 소유권에 있다. 부품을 서랍에 미리 플래시해서 쟁여둘 수 있고, 벤더 콜 없이 5분이면 고친다. 데이터를 내가 소유한다. Tron 테마로 바꾸고 싶으면 그냥 React 컴포넌트 갈아끼우면 된다. 벤더 시스템에선 이게 다 "새 라인 아이템"이었다.

반대로 숨은 비용도 정직하게 봐야 한다. 펌웨어·프로토콜 작성이 "진짜 어려운 부분"이라고 글쓴이 본인이 말한다. 이 엔지니어링 시간(글쓴이는 SRE다)이 공짜가 아니다. 사내에 이 역량이 없으면 $120k 견적이 오히려 싸게 느껴질 수도 있다. 레거시 현대화의 손익분기는 언제나 "우리 팀이 이걸 유지보수할 수 있는가"에서 갈린다.

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

한 줄 요약: 겉이 화려한 레거시 산업 시스템도 실제 인터페이스는 릴레이·시리얼·접점 몇 개인 경우가 많고, ESP32 + 이벤트 스트리밍(Redis) 조합으로 계층을 분리하면 비용을 두 자릿수 배수로 줄이면서 소유권과 유연성을 되찾을 수 있다.

이럴 때 써라

  • 레거시 시스템의 실제 I/O가 단순한데(릴레이/접점/시리얼) 벤더 견적만 비싼 경우
  • 사내에 임베디드/펌웨어를 감당할 인력이 있고, 장기 유지보수 의지가 있는 경우
  • 안전 리스크가 낮거나, 기계식 안전장치가 별도로 살아있는 경우

이럴 땐 다시 생각하라

  • 인명·안전 인증이 필수인 시스템(SW 버그 = 물리 사고). 페일세이프 설계와 검증에 드는 비용이 CapEx 절감분을 넘길 수 있다.
  • 사내에 펌웨어·프로토콜 유지보수 역량이 없어서 결국 또 다른 "1인 의존" 락인이 되는 경우
  • 다운타임 허용치가 극도로 낮은 미션 크리티컬 라인

개인적으로 가장 인상 깊었던 건 "물리 이벤트를 Redis 채널로 밀어넣는 순간, 그 뒤는 우리가 매일 하는 웹 개발 문제가 된다"는 접근이다. 백엔드/인프라 엔지니어가 임베디드를 두려워하지 말아야 하는 이유가 여기 있다. 어려운 건 물리

728x90
728x90

1. 왜 지금 이 사건이 화제인가

Gamers Nexus가 LG UltraGear 34GX900A-B 모니터를 물려서 32번 연속 부팅했더니 31번 McAfee 구독 광고가 떴다는 게 이번 사건의 핵심이다. 남은 1번은 LG 자체 모니터 유틸리티 광고였다. 사용자가 뭘 클릭한 것도 아니고, 동의 창도 없었다. 그냥 HDMI(혹은 USB)로 모니터를 꽂았을 뿐인데 LG Monitor App Installer가 Windows Update를 타고 내려와서 McAfee 30일 체험 → 유료 전환을 밀어넣은 거다.

인프라 하는 사람 입장에서 이게 왜 짜증나는 지점이냐면, 우리가 지금까지 "Windows Update = 신뢰할 수 있는 패치 채널"이라고 전제하고 정책을 짜왔기 때문이다. WSUS 승인 목록, Intune 업데이트 링, 다 그 신뢰를 기반으로 돌아간다. 그런데 그 채널로 McAfee 번들이 내려온다면? 우리가 관리한다고 믿었던 엔드포인트에 검증 안 된 서드파티 앱이 관리자 개입 없이 들어온다는 뜻이다.

참고로 이건 LG만의 문제가 아니다. 원문에서도 Dell Alienware 모니터를 DisplayPort로 꽂으면 Alienware Command Center가 반복 설치된다는 사례가 같이 나온다. 프린터·마우스 벤더는 Vista/7 시절부터 이 짓을 해왔다. Razer 마우스 꽂으면 1.5GB짜리 소프트웨어가 딸려온 사례도 언급된다. LG는 그중에서도 유독 노골적이었을 뿐이다.

2. 동작 원리: 설치 체인 역추적

이게 어떻게 가능한지 이해하려면 Windows의 Device Metadata 메커니즘을 알아야 한다. 순서는 대략 이렇다.

  1. 장치를 연결하면 Windows가 USB/DisplayPort의 VendorID(VID)와 ProductID(PID)를 읽는다.
  2. 이 VID/PID를 기반으로 Windows Update에 "이 장치와 연계된 드라이버 및 앱"을 질의한다.
  3. LG 확장 패키지 + 소프트웨어 구성 요소 패키지가 먼저 설치된다.
  4. Reliability Monitor 기록상 약 1분 뒤 LG Monitor App Installer가 실행된다.
  5. 부팅할 때마다 이 인스톨러가 McAfee 광고를 띄운다.

여기서 핵심은 "동의 창이 없다"는 점이다. AutoRun(옛날 USB 꽂으면 자동 실행되던 그거)과 뭐가 다르냐고 물으면, 오히려 더 교묘하다. AutoRun은 검증 없이 아무 파일이나 실행했지만, 이번 건은 Microsoft의 서명·검증을 통과한 "신뢰받는 벤더"가 제공한 패키지라서 정상 경로로 조용히 들어온다. 즉 보안 게이트를 뚫은 게 아니라, 게이트를 통과할 자격이 있는 벤더가 그 자격을 남용한 거다.

비유하자면, 아파트 관리사무소(Microsoft)가 "이 택배기사(LG)는 믿을 만하니 공동현관 비밀번호 알려줘도 돼"라고 등록해놨는데, 그 택배기사가 택배 대신 광고 전단지를 집집마다 몰래 붙이고 다니는 상황이다. 현관 보안이 뚫린 게 아니라 등록된 신뢰가 오남용된 거다.

Microsoft Store 상 이 앱의 권한 설명에는 "인터넷 및 모든 시스템 리소스 접근"이라고 적혀 있다. 샌드박스도 없고 부팅할 때마다 돈다. 원문 HN 댓글에서 이걸 그냥 스파이웨어라고 부르는 이유가 여기 있다.

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

3-1. GPO로 차단 (도메인 환경 / Pro 이상)

가장 확실한 건 Device Installation 정책을 손대는 거다. 원문에 나온 경로 그대로다.

Computer Configuration
  → Administrative Templates
    → System
      → Device Installation
        → "Prevent automatic download of applications associated with device metadata"
          → Enabled

이걸 활성화하면 Windows가 연결된 하드웨어와 연계된 앱을 자동 다운로드하지 못한다. GUI 대신 gpedit.msc를 직접 열어도 되고, 대량 배포라면 아래처럼 레지스트리로 밀어도 된다. (이 정책의 백엔드 레지스트리 키다.)

PS C:\> reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Device Metadata" /v PreventDeviceMetadataFromNetwork /t REG_DWORD /d 1 /f

작업을 완료했습니다.

PS C:\> reg query "HKLM\SOFTWARE\Policies\Microsoft\Windows\Device Metadata"

HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows\Device Metadata
    PreventDeviceMetadataFromNetwork    REG_DWORD    0x1

트레이드오프: 이걸 켜면 정말 필요한 모니터/주변기기 유틸리티도 자동으로 안 온다. 예를 들어 색보정 프로파일이나 KVM 소프트웨어가 필요한 환경이라면 벤더 사이트에서 직접 받아서 배포해야 한다. 개인적으로는 기업 환경에서 이게 오히려 정상이라고 본다. 어차피 검증 안 된 걸 자동으로 받는 것보다, 승인한 것만 골라 배포하는 게 관리 원칙에 맞다.

3-2. Home 에디션이라면

gpedit.msc가 없는 Home에서는 sysdm.cpl → 하드웨어 탭 → "장치 설치 설정"에서 제조사 앱 자동 다운로드 질문에 아니요를 선택하면 된다. 원문 HN 댓글의 지적처럼, 일반 소비자가 이걸 찾아서 끄기를 기대하는 것 자체가 무리다. "이 설정을 찾을 줄 아는 사람이면 차라리 Linux 깔지"라는 냉소가 괜히 나온 게 아니다.

3-3. 흔한 함정 — 껐는데도 다시 깔린다

여기가 진짜 실무에서 발목 잡히는 부분이다. 정책을 켜고 드라이버를 지워도 재부팅하면 다시 설치되는 경우가 있다. 원인은 Windows가 이미 받아둔 패키지를 Driver Store에 캐시해뒀기 때문이다. 원문 Reddit 사례에서도 "앱 자동 다운로드 끄고 드라이버 지우고 재부팅했는데 2분 만에 전부 다시 설치됐다"는 8년 전 케이스가 언급된다.

그래서 정책만 켜지 말고 캐시된 드라이버 패키지까지 확인해야 한다.

PS C:\> pnputil /enum-drivers

Microsoft PnP 유틸리티

게시된 이름:            oem42.inf
원래 이름:              lgmonitor.inf
공급자 이름:           LG Electronics Inc.
클래스 이름:           Extension
클래스 GUID:           {e2f84ce7-8efa-411c-aa69-97454ca4cb57}
드라이버 버전:         09/12/2024 1.0.2401.0
서명자 이름:           Microsoft Windows Hardware Compatibility Publisher

여기서 나온 게시된 이름(oem42.inf 같은 형태)을 지정해서 삭제한다. 삭제할 때 자주 만나는 에러가 이거다.

PS C:\> pnputil /delete-driver oem42.inf /force

Microsoft PnP 유틸리티

드라이버 패키지를 삭제하지 못했습니다: 요소를 찾을 수 없습니다.

이 "요소를 찾을 수 없습니다"(원문 영문으로는 Element not found.)는 보통 게시된 이름이 아니라 원래 이름을 넣었거나, 현재 장치가 그 드라이버를 사용 중이라 /force 없이 지우려 할 때 뜬다. /enum-drivers로 나온 게시된 이름(oemXX.inf)을 정확히 쓰고, 그래도 안 지워지면 장치를 뽑은 상태에서 시도하거나 /uninstall 옵션을 붙여야 한다. 캐시를 안 지우면 정책 켜놔도 재부팅 시 되살아나니, 이 순서를 꼭 지키자.

3-4. WSUS / Intune 관점

기업이라면 애초에 클라이언트가 Microsoft Update에 직접 붙는 걸 막고 사내 승인 채널만 태워야 한다. 원문 HN 댓글에서도 "많은 기업은 Windows Update를 끄고 사내 WSUS를 통과한 것만 허용한다"는 언급이 나오는데, 이게 정석이다. 다만 함정도 같이 언급된다 — 이렇게 하면 정작 필요한 최신 Intel Wi-Fi 드라이버가 안 내려와서 문제가 생긴 적도 있다는 거다. 즉 서드파티 번들을 막으려다 정당한 드라이버 업데이트까지 놓치는 트레이드오프가 항상 따라온다.

Intune에서는 "드라이버 및 펌웨어 업데이트" 정책과 별개로 Device Metadata 정책을 카탈로그로 배포할 수 있다. 개인적으로 추천하는 조합은 다음과 같다.

  • Device Metadata 자동 다운로드는 차단 (위 GPO/레지스트리).
  • 드라이버는 WSUS/Intune에서 명시적으로 승인한 것만 배포.
  • 모니터 유틸리티가 정말 필요하면 사내 패키지 저장소(예: winget/Chocolatey 내부 미러)에서 검증 후 배포.

3-5. 공급망 보안 관점

이 사건의 본질은 "OEM 벤더가 Microsoft의 서명 신뢰를 마케팅 채널로 전용했다"는 거다. SolarWinds급의 침해는 아니지만 구조는 닮았다. 신뢰받는 배포 채널에 검증 안 된 페이로드가 실린다는 점에서 공급망 문제로 봐야 한다. 그래서 대응도 "특정 앱 하나 지우기"가 아니라 "이 채널로 뭐가 들어올 수 있는지 자체를 통제"하는 방향이어야 한다.

다만 여기서 확실하지 않은 부분은 짚고 넘어가자. 정확히 어떤 LG 모델 범위가 이 동작을 하는지는 원문 기준으로도 확인되지 않았다. 3년 전 구매한 32UN880-B에서도 팝업이 떴다는 제보가 있으니 신제품 한정은 아닌 걸로 보인다. 지역차(스페인 사용자는 광고를 못 봤다는 제보)도 있는데, 이게 SKU 차이인지 지역별 배포 정책 차이인지는 공식 확인이 필요하다.

4. 정리 — 한 줄 요약과 체크리스트

한 줄 요약: Windows Update의 Device Metadata 경로는 서명된 OEM 벤더가 앱을 자동으로 밀어넣을 수 있는 합법적 통로이며, LG는 이걸로 McAfee 번들을 동의 없이 설치했다. 정책 하나로 막을 수 있지만 캐시까지 손봐야 완전히 끊긴다.

누가 언제 해야 하나:

  • 기업 엔드포인트 관리자 → 지금 당장 Device Metadata 정책 배포 + WSUS/Intune 승인 채널 점검.
  • 개인/소규모 → 새 모니터·주변기기 꽂기 전에 sysdm.cpl에서 자동 다운로드 끄기.
  • 이미 감염(?) 의심 → Reliability Monitor로 설치 이력 확인 → pnputil /enum-drivers로 캐시 확인 → 정책 켜고 캐시 삭제.

즉시 점검 체크리스트:

  1. reg query "HKLM\SOFTWARE\Policies\Microsoft\Windows\Device Metadata"로 정책 적용 여부 확인.
  2. pnputil /enum-drivers에서 공급자 이름이 LG/Dell 등 OEM인 Extension 클래스 드라이버가 있는지 확인.
  3. 설치된 앱 목록에서 LG Monitor App Installer / McAfee 체험판 제거.
  4. WSUS/Intune에서 드라이버 자동 승인 규칙에 서드파티 앱이 딸려오지 않는지 확인.
  5. 재부팅 후 다시 안 깔리는지 최종 검증(캐시 삭제가 됐는지 확인).

솔직히 이건 벤더 신뢰의 문제라 우리가 근본적으로 고칠 순 없다. 하지만 최소한 우리가 관리하는 자산에서는 "동의 없이 뭐가 들어오는 채널"을 알고 통제할 수 있어야 한다. 이번 사건은 그 채널의 존재를 다시 상기시켜준 것뿐이다.

참고 자료

728x90
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% 맞고 나서야 배운 걸, 우리는 미리 하면 된다.

참고 자료

+ Recent posts