728x90

왜 지금 다시 압축 이야기인가

압축 알고리즘은 인프라 엔지니어에게 "한 번 정하면 몇 년 안 건드리는" 영역이다. 로그는 gzip, 배포 아티팩트는 tar.gz, 아카이브는 xz. 대충 이 조합으로 굴러간다. 그런데 이 결정이 생각보다 비싸다.

내가 겪은 사례 하나. 애플리케이션 로그를 하루 단위로 gzip 압축해서 S3에 올리는 파이프라인이 있었다. JSON 라인 포맷, 하루 원본 수백 GB 규모. gzip -6으로 대략 10:1 정도 나왔는데, 스토리지 비용 자체보다 문제였던 건 재처리였다. 장애 분석할 때 특정 기간 로그를 다시 훑어야 하면 압축 해제에 시간이 갈렸고, Glacier 전환 전 Standard-IA 구간에 쌓이는 용량이 계속 늘었다.

여기서 선택지가 갈린다. 압축률을 더 짜내려면 xz로 가야 하는데 압축 시간이 폭증한다. 속도를 원하면 zstd인데 최고 압축률에서도 xz를 이기긴 어렵다. 이 트레이드오프 곡선 위에서 매번 저울질하는 게 실무다.

BZip3는 이 곡선에 새로운 점을 하나 찍겠다는 프로젝트다. 이름에서 짐작되듯 bzip2의 정신적 후속작을 표방하고, 특히 텍스트와 소스 코드 압축에서 강점을 주장한다. 핵심 구성 요소는 세 가지다.

  • 0차 문맥 혼합(context mixing) 엔트로피 코더
  • 접미사 배열(suffix array) 기반 Burrows-Wheeler 변환
  • RLE 및 LZP(Lempel-Ziv + Prediction) 전처리

bzip2를 써본 사람이면 알겠지만, bzip2는 2000년대 초반 이후 사실상 멈춰 있었다. 멀티코어 시대에 단일 스레드로 900KB 블록을 붙들고 있고, 압축률은 xz에 밀리고 속도는 gzip에 밀린다. "이도 저도 아닌 애매한 위치"라는 평가가 괜히 나온 게 아니다. BZip3는 그 BWT 계열의 아이디어를 현대적으로 다시 구현한 시도로 보면 된다.

동작 원리: 세 단계 파이프라인

BZip3의 압축 경로를 단계별로 보면 이해가 빠르다. 각 단계가 왜 있는지를 알아야 블록 크기 같은 튜닝 파라미터를 제대로 만질 수 있다.

1단계: LZP 전처리 — "이미 봤던 거지?"

LZP는 Lempel-Ziv 계열이지만 일반 LZ77과 다르다. LZ77은 "몇 바이트 뒤로 가서 몇 바이트를 복사해라"라는 (거리, 길이) 쌍을 저장한다. LZP는 거리를 저장하지 않는다. 대신 직전 N바이트의 문맥을 해시로 만들어, "이 문맥 다음에 뭐가 왔었는지"를 테이블에 기록해둔다. 압축할 때 예측이 맞으면 길이만 기록하고, 틀리면 원본 바이트를 그대로 흘린다.

디코더도 같은 방식으로 같은 테이블을 만들 수 있으니 거리 정보가 필요 없다. 로그 파일처럼 같은 패턴이 반복되는 데이터에서 특히 효율적이다. 예를 들어 이런 로그를 보자.

2024-01-15T03:22:11Z INFO  [order-service] request completed status=200 latency=12ms
2024-01-15T03:22:11Z INFO  [order-service] request completed status=200 latency=15ms
2024-01-15T03:22:12Z INFO  [order-service] request completed status=200 latency=11ms

"Z INFO [order-service] request completed status=200 latency=" 부분은 앞 문맥만 보면 거의 확정적으로 예측된다. LZP가 여기를 통째로 걷어내고 나면, 뒤 단계에 남는 건 타임스탬프 끝자리와 latency 숫자 정도다. 즉 LZP는 BWT에 들어갈 데이터 양 자체를 줄여주는 역할이다. BWT는 블록 크기에 비례해서 무거워지니까, 이 전처리가 속도에 직접 기여한다.

2단계: 접미사 배열 BWT — 비슷한 문맥끼리 모으기

Burrows-Wheeler 변환은 데이터를 압축하지 않는다. 재배열할 뿐이다. 입력의 모든 회전(rotation)을 사전순 정렬한 뒤 마지막 열만 뽑아낸다. 결과적으로 "비슷한 문맥 뒤에 오는 글자들"이 한곳에 모인다.

영어 텍스트에서 "the"라는 문자열이 수천 번 나오면, "he " 문맥으로 시작하는 회전들이 정렬 후 인접하게 되고, 그 앞 글자인 't'가 연속으로 쭉 늘어선다. ttttttttttt... 같은 런이 생기는 것이다. 이걸 RLE와 엔트로피 코딩으로 넘기면 압축이 잘 먹는다.

여기서 bzip2와 갈리는 지점이 정렬 알고리즘이다. bzip2는 자체 구현한 정렬 루틴을 쓰는데, 반복 패턴이 극단적으로 많은 입력(예: 같은 바이트 수백만 개)에서 성능이 나빠지는 특성이 있어서 fallback 경로를 따로 뒀다. BZip3는 접미사 배열 구축 알고리즘(libsais 계열로 알려져 있으나 정확한 채택 라이브러리와 버전은 공식 저장소 확인 필요)을 쓴다. 접미사 배열은 선형에 가까운 시간에 구축 가능하고 최악 케이스가 훨씬 안정적이다. 그래서 BZip3는 bzip2의 900KB보다 훨씬 큰 블록을 감당할 수 있다.

블록이 크다는 게 왜 중요한가? BWT는 블록 내부에서만 패턴을 찾는다. 100MB짜리 로그를 900KB 블록으로 자르면, 1번 블록과 50번 블록에 똑같은 스택트레이스가 있어도 서로를 못 본다. 블록이 커질수록 멀리 떨어진 중복까지 잡아낸다. 이게 BZip3가 압축률에서 우위를 주장하는 구조적 근거다.

3단계: 문맥 혼합 엔트로피 코더

마지막은 실제로 비트를 줄이는 단계다. 전통적인 bzip2는 MTF(Move-To-Front) + 허프만 조합을 쓴다. BZip3는 문맥 혼합 방식의 산술 코더를 쓴다.

문맥 혼합은 PAQ 계열 압축기에서 유명해진 접근이다. "다음 비트가 1일 확률"을 여러 개의 예측 모델이 각각 내놓고, 그 예측들을 가중 평균으로 섞어서 최종 확률을 만든다. 예측이 정확할수록 산술 코더가 그 심볼에 배정하는 비트 수가 줄어든다. 허프만은 심볼당 최소 1비트라는 하한이 있지만 산술 코딩은 그 제약이 없어서, 편향이 심한 데이터에서 유리하다.

BZip3는 여기에 "0차"라는 수식어를 붙였다. PAQ처럼 수십 개 모델을 돌리는 무거운 방식은 아니고, BWT 출력이라는 이미 잘 정리된 데이터를 대상으로 적당히 가벼운 혼합을 한다는 뜻으로 읽힌다. 압축률과 속도 사이 균형점을 이쪽에 잡았다는 얘기다. 정확한 모델 구성은 공식 문서 확인 필요.

직접 돌려보기

설치는 배포판 패키지 매니저나 소스 빌드로 한다. Debian/Ubuntu 계열은 저장소에 올라와 있는 경우가 있고, 없으면 소스에서 빌드한다.

# 소스 빌드 (autotools 기반)
git clone https://github.com/kspalaiologos/bzip3
cd bzip3
./bootstrap.sh && ./configure && make -j$(nproc)
sudo make install

$ bzip3 --version
bzip3 1.x.x
# 실제 버전 문자열은 빌드 시점에 따라 다름

기본 사용법은 gzip과 거의 같다. 표준 입출력 파이프도 지원한다.

# 블록 크기 16MiB, 4스레드로 압축
$ bzip3 -e -b 16 -j 4 app.log
$ ls -l app.log*
-rw-r--r-- 1 dev dev  4194304 Jan 15 10:22 app.log.bz3

# 압축 해제
$ bzip3 -d app.log.bz3

# 파이프로 tar와 조합
$ tar -cf - ./logs | bzip3 -e -b 32 -j 8 > logs.tar.bz3
$ bzip3 -d < logs.tar.bz3 | tar -xf -

# tar에서 직접 호출
$ tar --use-compress-program='bzip3 -e -b 16 -j 4' -cf logs.tar.bz3 ./logs

비교 측정할 때는 이런 식으로 스크립트를 짜서 돌린다. 숫자는 데이터 성격에 따라 완전히 달라지니 반드시 본인 데이터로 재보라.

#!/bin/bash
F=$1
for CMD in "gzip -6 -c" "zstd -19 -T0 -c" "xz -9 -T0 -c" "bzip3 -e -b 16 -j 4 -c"; do
  /usr/bin/time -f "%e s  %M KB" -o /tmp/t \
    bash -c "$CMD $F > /tmp/out" 2>/dev/null
  SZ=$(stat -c%s /tmp/out)
  printf "%-28s %10d bytes  %s\n" "$CMD" "$SZ" "$(cat /tmp/t)"
done

내가 실제로 돌려본 감각을 정리하면 이렇다. 수치를 못 박지 않는 이유는 데이터 편차가 워낙 크기 때문이다.

  • 구조화된 텍스트 로그, JSON 라인: BZip3가 xz -9와 비슷하거나 더 나은 압축률을 내면서 압축 시간은 훨씬 짧은 경우가 있었다. BZip3의 홈그라운드다.
  • 소스 코드 아카이브: 여기도 유리하다. 반복되는 키워드와 들여쓰기 패턴이 BWT와 잘 맞는다.
  • 이미 압축된 바이너리(JPEG, 컨테이너 레이어 tar.gz, DB 페이지 파일): 어떤 압축기를 써도 별 차이 없다. 오히려 CPU만 태운다.
  • 메모리: 블록 크기 × 스레드 수에 대략 비례해서 늘어난다. 이게 실무에서 제일 자주 발목 잡는 부분이다.

실무 적용: 트레이드오프와 흔한 함정

함정 1: 메모리 폭발

BZip3의 압축률은 큰 블록에서 나온다. 그런데 블록 크기를 키우면 메모리도 같이 커진다. BWT는 입력 블록 전체를 메모리에 올린 채 접미사 배열까지 만들어야 한다. 접미사 배열 자체가 인덱스당 4바이트(또는 그 이상)를 먹으니, 블록 크기의 몇 배가 필요하다.

여기에 -j로 스레드를 늘리면 각 스레드가 각자의 블록을 잡는다. 메모리는 블록 크기 × 스레드 수로 곱해진다. 컨테이너에 512Mi 메모리 리밋을 걸어두고 CronJob으로 압축을 돌리다가 이런 걸 보게 된다.

$ bzip3 -e -b 256 -j 8 huge.log
bzip3: PANIC: Failed to allocate memory.

# 또는 커널이 먼저 죽인다
$ dmesg | tail -1
[12345.678] Out of memory: Killed process 4821 (bzip3) total-vm:6291456kB, ...

# Kubernetes에서는
$ kubectl describe pod log-archiver-xxxxx
    State:          Terminated
      Reason:       OOMKilled
      Exit Code:    137

Exit Code 137은 SIGKILL(128+9)이다. 압축 잡이 조용히 실패하고 로그만 안 쌓이는 사고로 이어진다. 블록 크기와 스레드 수를 정할 때는 반드시 컨테이너 메모리 리밋을 역산해서 잡아라. 나는 리밋의 절반 이하를 목표로 잡고, `-j`를 먼저 줄이는 쪽을 택한다. 복호화 쪽도 마찬가지로 메모리가 필요하니, "압축은 빌드 서버에서 널널하게, 해제는 리소스 빡빡한 파드에서"라는 구성은 위험하다.

함정 2: 생태계 호환성

이게 제일 크다. gzip은 30년 넘게 어디에나 있다. zstd는 최근 몇 년 사이 커널, 파일시스템, 패키지 매니저까지 스며들었다. BZip3는 아직 그 위치가 아니다.

  • S3 Select, Athena, Glue: gzip/bzip2/zstd 등은 알아듣지만 bz3는 못 읽는다. 쿼리 대상 로그를 bz3로 말면 그때부터 전처리 파이프라인을 따로 만들어야 한다.
  • 언어별 라이브러리: Python gzip/bz2/lzma는 표준 라이브러리다. BZip3는 별도 바인딩이 필요하고 성숙도도 다르다. 지원 현황은 공식 저장소 확인 필요.
  • OCI 컨테이너 이미지: 레이어 미디어 타입에 gzip과 zstd는 정의돼 있다. bz3는 없다. 컨테이너 배포에는 후보가 아니다.
  • 운영 서버의 기본 설치 여부: 장애 대응 중에 bzip3: command not found를 보는 상황을 상상해보라. 압축 포맷을 고를 때 "새벽 3시에 이 파일을 열어야 하는 사람"을 항상 고려해야 한다.

함정 3: 스트리밍과 랜덤 액세스

BWT 계열은 블록 단위로 완결된다. 블록이 다 모여야 변환이 시작되니, 진짜 의미의 스트리밍 압축(들어오는 대로 즉시 출력)에는 맞지 않는다. 256MiB 블록을 쓰면 256MiB가 쌓일 때까지 출력이 안 나온다. 실시간 로그 전송 파이프라인 중간에 끼우면 지연이 튄다. 이럴 땐 zstd가 낫다.

반대로 블록 단위라는 게 장점이 되기도 한다. bzip2에 pbzip2가 있었듯, 블록 경계에서 자를 수 있으면 병렬 처리와 부분 해제 가능성이 열린다. BZip3의 부분 복호화 지원 여부와 인덱싱 방식은 공식 문서 확인 필요.

함정 4: 검증 없이 원본 삭제

이건 포맷과 무관하게 압축 파이프라인의 고전적 사고다. 새 압축기를 도입할 때는 특히 조심해야 한다. 아카이브 잡에서는 항상 이 순서를 지킨다.

# 압축 → 무결성 검증 → 해시 비교 → 그 다음에 원본 삭제
bzip3 -e -b 16 -j 4 -c app.log > app.log.bz3
bzip3 -t app.log.bz3 || { echo "integrity check failed"; exit 1; }

ORIG=$(sha256sum < app.log | cut -d' ' -f1)
REST=$(bzip3 -d -c app.log.bz3 | sha256sum | cut -d' ' -f1)
[ "$ORIG" = "$REST" ] || { echo "checksum mismatch"; exit 1; }
rm -f app.log

bzip2 시절에 bzip2: Data integrity error when decompressing. 로 며칠치 백업을 날린 사례를 여럿 봤다. 압축률 몇 퍼센트보다 이 검증 단계가 훨씬 값어치 있다.

정리: 언제 무엇을 쓸 것인가

한 줄 요약: BZip3는 "장기 보관용 텍스트 아카이브"라는 좁고 명확한 자리에서 xz의 강력한 경쟁자다. 그 밖의 자리에서는 zstd가 여전히 기본값이다.

워크로드별 판단 기준을 정리하면 이렇다.

  • 실시간 로그 전송, API 응답, RPC 페이로드 → zstd 낮은 레벨(1~3) 또는 lz4. 지연이 전부다. BZip3는 후보 아님.
  • 컨테이너 이미지, 패키지 배포 → gzip 또는 zstd. 생태계 지원이 결정적이다. 한 번 압축하고 수만 번 푸는 워크로드라 해제 속도가 중요하다.
  • Athena/Spark로 쿼리할 데이터 레이크 → Parquet + zstd/snappy. 컬럼 포맷의 인코딩 이득이 압축기 선택보다 훨씬 크다.
  • 몇 년 보관할 텍스트 로그/소스 아카이브, 접근 빈도 극히 낮음 → 여기가 BZip3를 진지하게 검토할 자리. xz -9와 나란히 놓고 본인 데이터로 벤치마크할 만하다. 단, 5년 뒤에도 디코더를 구할 수 있는지 함께 고민할 것.
  • 기존 bzip2를 쓰고 있다면 → 사실상 갈아탈 이유가 충분하다. bzip2가 이기는 지점이 거의 없다. 다만 목적지가 BZip3일지 zstd일지는 접근 패턴에 달렸다.

도입한다면 이 순서를 권한다. ① 실제 프로덕션 데이터 샘플(최소 수 GB) 확보 → ② gzip/zstd/xz/bzip3 4종 벤치마크, 압축률·시간·피크 메모리 모두 기록 → ③ 복호화 시간과 메모리도 별도 측정 → ④ 비긴급 아카이브 일부에만 먼저 적용, 원본은 최소 한 달 병행 보관 → ⑤ 복구 리허설(실제로 풀어서 써보기)까지 하고 전환.

압축기 교체는 "압축률 몇 퍼센트" 게임이 아니라 "몇 년 뒤에 이 파일을 열 수 있는가" 게임이다. BZip3는 기술적으로 흥미롭고 특정 데이터에서 확실히 강하지만, 그 판단은 항상 본인 데이터와 본인 운영 환경 위에서 내려야 한다.

참고 자료

728x90
728x90

Dev.to에 올라온 "Two Bugs Later: What It Actually Took to Replace a DNS Library"를 읽고 한참 동안 예전 장애 티켓들이 떠올랐다. 글쓴이는 Go로 재귀 DNS 리졸버 hollow를 만들면서 사실상 표준인 github.com/miekg/dns를 안 쓰고 코덱을 직접 짰다. 그 과정에서 네트워크에서 도달 가능한 보안 버그 두 개를 스스로 만들어냈고, 그 얘기를 솔직하게 풀어놨다.

핵심 문장은 이거다. "라이브러리는 코드가 아니다. 누군가 이미, 올바르게, 그리고 당신에게 결정 중이라는 사실조차 알려주지 않고 내린 서른~마흔 개의 결정이다." 인프라 하는 사람이라면 이 문장이 왜 아픈지 안다. 우리가 매년 하는 일이 그거니까 — 라이브러리 갈아끼우기, 사이드카 교체, 리졸버 변경.

왜 지금 이 글인가: 의존성 교체는 코드 교체가 아니다

miekg/dns는 known importer가 16,234개다. CoreDNS가 이걸로 만들어졌다. 인기 있는 선택지가 아니라 유일한 선택지다. 그걸 지우고 나니 프로젝트는 이렇게 됐다.

  • 코덱 구현 1,341줄 / 코덱 테스트 1,925줄
  • 전체 9,984줄 Go / 11,033줄 테스트 / -race 하에서 342개 테스트
  • go.modrequire 블록 없음, go.sum 없음, vendor/ 없음

구현보다 테스트가 더 많다는 게 첫 번째 힌트다. 저자 말대로 "DNS는 1987년 포맷이고 문서는 넘치도록 있다. 코덱 쓰는 건 어렵지 않다. 소켓에 붙여도 되는 코덱을 쓰는 건 다른 일이다."

실무로 옮겨보자. 우리가 DNS 관련 스택을 건드릴 때 실제로 바뀌는 건 API 시그니처가 아니다. 리졸버 순서(/etc/nsswitch.conf), 타임아웃과 재시도 횟수, UDP 512바이트 초과 시 TCP 폴백 여부, EDNS0 버퍼 크기 협상, search domain 처리, 캐시 TTL 해석 — 이 전부가 기존 라이브러리가 대신 내려주던 결정이다. 갈아끼우는 순간 전부 내 책상 위로 돌아온다.

동작 원리: 재귀 리졸버가 실제로 걷는 길

hollow는 8.8.8.8한테 물어보고 답을 앵무새처럼 반복하는 forwarder가 아니라, 루트 서버부터 시작해 referral을 따라 내려가는 재귀 리졸버다. 원문의 trace 출력이 이 구조를 잘 보여준다.

$ hollow trace www.github.com
. (root)
+- 193.0.14.129:53  17ms udp, referral, 839 B, 13 NS + 26 glue,
   asked as WWW.GitHUB.com.
com.
+- c.gtld-servers.net. (192.26.92.30:53)  85ms udp, referral, 310 B,
   asked as wWw.gItHuB.cOm.
github.com.
+- ns-421.awsdns-52.com. (205.251.193.165:53)  35ms udp, answer, 296 B,
   asked as www.gIthuB.coM.

www.github.com. 3600 IN CNAME github.com.
github.com.       60 IN A     20.207.73.82

3 queries, 3 zones, 0 answers from cache, 136ms

여기서 눈여겨볼 게 asked as 줄이다. 쿼리 이름의 대소문자를 매 쿼리마다 랜덤화해서 nonce로 쓴다(0x20 인코딩). 응답이 그 대소문자를 정확히 그대로 되돌려주지 않으면 버린다. 스푸핑된 응답을 막는 값싼 방어다.

같은 걸 여러분 환경에서 확인하고 싶으면 dig로 루트부터 따라가 보면 된다. 쿠버네티스 노드나 아무 리눅스 박스에서 바로 실행된다.

$ dig +trace +nodnssec www.github.com A | head -20

; <<>> DiG 9.18.x <<>> +trace +nodnssec www.github.com A
.                       518400  IN      NS      a.root-servers.net.
...
;; Received 239 bytes from 198.41.0.4#53(a.root-servers.net) in 12 ms

com.                    172800  IN      NS      a.gtld-servers.net.
;; Received 1170 bytes from 192.5.5.241#53(f.root-servers.net) in 35 ms

github.com.             172800  IN      NS      ns-421.awsdns-52.com.
;; Received 640 bytes from 192.26.92.30#53(c.gtld-servers.net) in 28 ms

중요한 개념 세 개만 짚자. referral은 에러가 아니라 정상 응답이다. glue 레코드는 최적화가 아니라 부트스트랩 문제 때문에 존재한다(ns1.github.com의 주소를 알려면 github.com을 물어봐야 하는데 그러려면 ns1이 필요한 순환). resolution은 테이블 조회가 아니라 트리를 걸어 내려가는 행위다. 이 그림이 머릿속에 없으면 hex dump 앞에서 RFC는 아무 도움이 안 된다.

버그 #1 — 이름 압축 키를 잘못 잡아서 자기 방어를 깨먹다

DNS 메시지 안의 이름은 앞서 나온 동일 suffix를 가리키는 2옥텟 포인터로 대체할 수 있다. 인코더는 "이미 쓴 suffix"를 맵에 들고 있다가 나중에 가리킨다. 문제는 맵 키였다.

버전 1은 대소문자를 접었다(case-fold). DNS 이름은 대소문자 구분 없이 매칭되니 맞는 것 같지만 틀렸다. 포인터는 이름을 가리키는 게 아니라 특정 오프셋에 있는 바이트를 가리킨다. 두 철자를 같은 키로 취급하면 두 번째 이름이 첫 번째를 가리키게 되고, 결과적으로 다른 철자가 와이어로 나간다. 대부분의 프로그램에선 그냥 미관 문제지만, 이 프로젝트에선 위에서 말한 0x20 nonce 방어가 통째로 무너진다. 자기가 만든 방어를 자기 인코더가 조용히 깨는 것이다.

버전 2는 라벨을 점으로 이었다. 이건 더 나쁘다. DNS 라벨은 점을 포함할 수 있다(presentation form에선 \.로 이스케이프). 그래서 단일 라벨 a.b와 두 라벨 a, b가 같은 문자열로 납작해진다. 키는 같은데 이름은 다르다. 최종 해법은 인코딩된 옥텟 자체를 키로 쓰는 것.

func suffixKey(labels [][]byte) string {
    var b strings.Builder
    for _, l := range labels {
        b.WriteByte(byte(len(l))) // 길이 프리픽스 포함이 핵심
        b.Write(l)
    }
    return b.String()
}

이러면 두 suffix가 충돌하는 경우는 정확히 "둘 사이 포인터가 올바른 경우"뿐이다. 처음부터 기대고 있던 그 속성 그대로다.

버그 #2 — bailiwick 체크를 한 줄로 짜서 캐시 포이즈닝 구멍을 내다

이건 코덱이 아니라 "이 네임서버 말을 믿을 것인가"를 판단하는 로직이다. referral과 함께 오는 glue 주소를 전부 믿으면 안 된다. com 서버는 com 안쪽 이름에 대해서만 말할 자격이 있지, bank.example.org의 주소를 알려줄 자격은 없다. 이 검사가 bailiwick이고, 틀리면 교과서적인 캐시 포이즈닝이다. 누구나 처음 쓰는 버전은 이거다.

strings.HasSuffix(name, zone)   // 읽기엔 멀쩡하다. 구멍이다.

또 이스케이프된 점이다. evil\.com단일 라벨이고 com의 자식이 아니라 형제인데, 바이트로 보면 어쨌든 com.으로 끝난다. suffix 검사는 com referral 안에서 ns1.evil\.com의 glue를 기꺼이 받아들인다. 정확히 이 검사가 걸러내라고 존재하는 입력을 통과시킨다. 제대로 된 버전은 언이스케이프해서 라벨 단위로 오른쪽부터 비교한다.

for i := range zl {
    if !strings.EqualFold(string(nl[len(nl)-len(zl)+i]), string(zl[i])) {
        return false
    }
}

호출당 할당 하나가 더 든다. 대신 한 줄짜리 버전에 대해 실패하는 테스트가 있다. 저자의 정리가 좋다. "한 줄짜리는 누구나 처음 쓰는 버전이다. 그걸 잡아내는 건 모든 응답은 신뢰할 수 없는 입력이라고 결정한 다음, 그 가정이 실제로 강제되지 않는 지점을 코드에서 다시 찾아 나서는 일이다."

두 버그를 잡은 건 리뷰가 아니라 퍼저였다

둘 다 네트워크에서 도달 가능했고, 코드 리뷰도 못 잡았고, 이미 작성해둔 20개의 malformed 메시지 테스트도 못 잡았다. 잡은 건 Go 내장 testing.F다. 디코더에 3,840만 회 실행을 돌렸는데 크래시가 없었다. 근데 이 퍼저는 크래시를 보는 게 아니다. 라운드트립 불변식을 검증한다.

  • 디코드되는 것은 반드시 재인코딩되어야 한다
  • 다시 디코드하면 동일한 메시지여야 한다
  • 두 번째 인코딩은 첫 번째 인코딩과 바이트 단위로 동일해야 한다

첫 인코딩이 입력과 달라지는 건 허용된다(인코더가 압축 대상을 스스로 고르니까). 두 번째부터는 인코더 자신의 출력이 되돌아 들어가는 거라 허용 안 된다. 이 fixed point가 압축 키 충돌을 잡았다. 이름이 재작성되면 round-trip이 깨지니까.

포인터 루프 종료 증명 — visited set 없이

압축 포인터는 아무 데나 가리킬 수 있다. 자기 자신도, 루프도. 잘못 다루면 40바이트 패킷 하나가 리졸버를 멈춰 세운다. 흔한 방어는 "포인터는 뒤로만 가야 한다"인데 이것으로는 부족하다. 포인터가 뒤로 점프해도 라벨 워크가 커서를 다시 앞으로 밀기 때문이다. 오프셋 20의 포인터가 15를 가리키고, 15의 라벨을 걷다 보니 다시 20에 도착하는 구성은 매 스텝 "뒤로"를 만족하면서 영원히 돈다.

해법은 현재 읽기 위치가 아니라 직전 포인터의 타깃과 비교하는 것. 타깃들이 음이 아닌 정수의 순감소 수열이 되므로 워크는 반드시 종료한다. visited set도, 점프 예산도, 튜닝할 값도 없다. 그리고 정당한 입력을 하나도 거부하지 않는다. 유효한 포인터는 같은 메시지에서 앞서 나온 이름만 참조할 수 있으니까. 저자의 마무리가 정확하다. "miekg/dns는 이걸 공짜로 준다. 그리고 여기에 논쟁거리가 있었다는 사실조차 모르고 지나간다."

실무 관점: 라이브러리가 대신 내려주던 결정들과 흔한 함정

원문에서 가장 실무적인 부분은 "라이브러리가 나 대신 결정해줬을 것들" 목록이다. 전부 README의 한 줄이 됐다고 한다. 우리 환경으로 번역해보면 이렇다.

결정 지점 선택과 이유
UDP 수신 큐가 찼을 때 블로킹하지 말고 드롭. 블로킹하면 커널 수신 큐를 못 비워서 느린 쿼리 하나가 그 박스의 모든 클라이언트를 멈춘다. UDP는 원래 드롭을 허용한다.
response 비트가 켜진 채 도착한 메시지 절대 응답하지 않는다. 응답하면 서로를 가리킨 두 서버가 패킷 하나를 영원히 주고받고, source에 피해자 주소를 넣은 쪽은 트래픽 발사기를 얻는다.
드롭 로그 첫 건만 로그, 나머지는 카운트. 안 그러면 패킷 플러드가 디스크 플러드로 바뀐다. 타깃만 바뀐 같은 공격이다.
레이트 리밋 초과 refuse가 아니라 드롭. 에러도 응답이고, 응답이야말로 앰프 공격이 원한 것. 다만 두 번째마다 truncated로 응답해서 진짜 클라이언트는 TCP로 재시도해 성공하고 스푸핑된 소스는 핸드셰이크를 못 끝내게 한다.
캐시 TTL 내보낼 때 남은 초로 매번 재작성. 안 하면 1분 간격 두 조회가 같은 카운트다운을 보고한다. 눈에 보이는 거짓말이고 제일 먼저 들킨다. 측정값: example.com 콜드 268ms, 웜 0ms.

이 표를 보면서 든 생각: 우리가 쿠버네티스에서 CoreDNS 튜닝할 때 만지는 값들이 정확히 이 목록이다. cache 플러그인의 TTL 처리, forwardmax_concurrent, prefer_udp/TCP 폴백, loop 플러그인. 우리는 이 결정들을 안 내리는 게 아니라 CoreDNS가 이미 내려준 걸 쓰고 있는 것이다.

흔한 함정 1 — hosts 파일/블록리스트 파서가 localhost를 막는다

원문에서 제일 실용적인 경고다. 진짜 hosts 파일은 이렇게 시작한다.

127.0.0.1       localhost
127.0.0.1       localhost.localdomain
127.0.0.1       local
255.255.255.255 broadcasthost
::1             localhost

두 번째 필드를 그냥 집어삼키는 파서는 localhost를 블록리스트에 넣고, 자기가 돌고 있는 머신을 죽인다. 문자열 localhost만 필터링하는 것도 부족하다. localbroadcasthost가 그대로 통과한다. 컨테이너 안에서 이게 터지면 이런 에러를 보게 된다.

$ curl http://localhost:8080/healthz
curl: (6) Could not resolve host: localhost

$ getent hosts localhost
(출력 없음, exit code 2)

헬스체크가 localhost로 되어 있는 파드라면 CrashLoopBackOff로 직행이다. 원인이 "블록리스트 파서"라는 걸 떠올리기까지가 오래 걸린다.

흔한 함정 2 — bufio.Scanner가 조용히 파일을 잘라먹는다

블록리스트를 읽는 가장 뻔한 방법이 bufio.Scanner인데, 버퍼 한도를 넘는 라인을 만나면 에러를 반환하고 더 이상 진행하지 않는다. 생성된 리스트에 말도 안 되게 긴 줄 하나가 섞여 있으면 나머지 파일 전체가 잘리는데 로드는 성공으로 보고된다. 절반짜리 블록리스트가 자기를 완전하다고 믿는 상태다. 실제 에러 메시지는 이거다.

bufio.Scanner: token too long

Go 코드에서 이걸 놓치지 않으려면 Scan() 루프가 끝난 뒤 Err()를 반드시 확인해야 한다. 대부분의 예제 코드가 이 부분을 생략하고 있다는 게 함정이다.

sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) // 상한을 명시적으로 올린다
for sc.Scan() {
    line := strings.TrimSpace(sc.Text())
    if line == "" || strings.HasPrefix(line, "#") {
        continue
    }
    // ... 파싱
}
if err := sc.Err(); err != nil {
    return fmt.Errorf("blocklist truncated: %w", err) // 여기를 빼먹으면 조용히 반쪽
}

쿠버네티스 환경에서 좁혀 들어가기

DNS 동작이 의심될 때 실제로 쓰는 순서. 파드 안에서 /etc/resolv.conf부터 본다.

$ kubectl exec -it mypod -- cat /etc/resolv.conf
nameserver 10.96.0.10
search default.svc.cluster.local svc.cluster.local cluster.local
options ndots:5

$ kubectl exec -it mypod -- dig +short api.example.com
# 응답이 느리거나 비어 있으면, search 도메인이 먼저 붙어 나가는지 확인
$ kubectl exec -it mypod -- dig +search +trace api.example.com | head

ndots:5는 점이 5개 미만인 이름에 대해 search 도메인을 먼저 다 붙여본다는 뜻이다. api.example.com은 점이 2개니까 api.example.com.default.svc.cluster.local부터 시도한다. NXDOMAIN 왕복이 여러 번 발생하고, 이게 지연과 CoreDNS 부하로 나타난다. 외부 도메인을 많이 호출하는 워크로드라면 파드 스펙의 dnsConfigndots를 낮추거나 FQDN 끝에 점을 붙이는 게 흔한 대응이다. 다만 클러스터 내부 서비스 디스커버리에 영향이 가니 서비스별로 판단해야 한다.

여기서도 원문의 교훈이 그대로 적용된다. ndots:5도, search 도메인 순서도, UDP 우선도 누군가 이미 내려놓은 결정이고, 우리는 그게 결정이었다는 걸 장애가 나야 알게 된다.

그래서 직접 만들어야 하나?

대부분의 경우 아니다. 원문 저자도 miekg/dns를 깎아내리지 않는다. 오히려 "그건 인기 있는 선택이 아니라 유일한 선택"이라고 인정하고 시작한다. 판단 기준을 정리하면 이렇다.

  • 직접 짤 만한 경우: 의존성 제로가 요구사항이거나(공급망/감사 이슈), DNS 자체가 제품의 핵심이고 라이브러리의 결정을 다르게 내려야 할 때. 그리고 퍼징으로 불변식을 검증할 역량과 시간이 있을 때. 테스트가 구현보다 많아지는 게 정상이라는 걸 받아들일 수 있어야 한다.
  • 쓰지 말아야 할 경우: 나머지 전부. 특히 bailiwick 같은 보안 경계를 다시 구현해야 한다면 재고하는 게 맞다. 원문 저자조차 한 줄짜리 HasSuffix로 구멍을 냈다.

정리

한 줄 요약: 라이브러리를 걷어낸다는 건 코드를 다시 쓰는 게 아니라, 누군가 대신 내려줬던 수십 개의 결정을 하나씩 내 이름으로 다시 내리는 일이다.

실무자에게 남는 건 이 세 가지다.

  1. 불변식 기반 퍼징은 지금 당장 도입할 값어치가 있다. Go에선 build tag도, 별도 코퍼스 저장소도, 두 번째 툴체인도 필요 없다. 시드 코퍼스는 캡처한 패킷 두 개면 시작된다. 파서를 다루는 코드가 있다면 "라운드트립하면 동일해야 한다" 같은 불변식 하나만 걸어도 리뷰가 못 잡는 걸 잡는다.
  2. 모든 외부 응답은 적대적 입력이라고 선언하고, 그 가정이 강제되지 않는 지점을 코드에서 되짚어라. HasSuffix 한 줄이 캐시 포이즈닝이 될 수 있다.
  3. 의존성을 바꿀 땐 API가 아니라 동작 계약을 먼저 문서화하라. 큐가 차면 드롭인가 블로킹인가, 레이트 리밋 초과는 refuse인가 드롭인가, TTL은 재작성하는가. 새 라이브러리가 이걸 다르게 결정했다면 그게 다음 장애다. 가능하면 섀도 트래픽으로 양쪽 응답을 비교해보고 점진적으로 롤아웃하는 게 안전하다.

마지막으로 저자가 언급한 Kurose & Ross의 Computer Networking: A Top-Down Approach. 팩트는 RFC에 있다. 책이 주는 건 모양(shape)이다. hex dump 깊숙한 곳에서 RFC는 안 구해준다. 그게 뭘 묘사하는지 이미 그림을 갖고 있는 상태가 구해준다.

참고 자료

728x90
728x90

1. 6주에 Flash 세 번, 이게 무슨 상황인가

2026년 9월 2일에 Google이 Gemini 3.8 Flash와 3.8 Flash Cyber를 발표했다. 공지 첫 문단이 좀 웃긴데, "3주 전에 나온 3.7 Flash의 기세를 이어" "6주 만에 세 번째 Flash 릴리스"라고 스스로 적어놨다. 모델 벤더가 릴리스 주기를 자랑거리로 쓰는 시대다.

운영하는 입장에서 이건 자랑이 아니라 리스크다. 6주에 세 번 바뀌면 우리가 붙여둔 프롬프트, 토큰 예산, 레이턴시 SLO가 6주에 세 번 흔들린다. 실제로 3.7 → 3.8 올리면서 겪을 문제 중 가장 현실적인 게 토큰 소비량 증가인데, 이건 뒤에서 자세히 다루겠다.

발표 내용을 실무 관점으로 압축하면 세 줄이다.

  • 3.8 Flash: 3.7과 같은 가격($0.75/1M input, $3.75/1M output, 프로모션 가격은 2026-12-31까지. 2027-01-01부터 $1.50 / $7.50)에 코딩·에이전트 성능만 올린 버전. DeepSWE v1.1, Vals Finance Agent V2, Harvey Legal Agent Benchmark에서 개선, HLE-Verified 54.9%.
  • 3.8 Flash Cyber: 취약점 탐지·자동 패치 특화 변종. 아무나 못 쓰고 Fairwind Program 승인된 "신뢰할 수 있는 방어자"에게만 열린다.
  • 둘 다 같은 기반 모델을 공유하고, 사이버보안 도메인 훈련이 오히려 일반 코딩·추론 성능까지 끌어올렸다는 게 Google 주장이다.

마지막 줄이 개인적으로 가장 흥미롭다. 취약점을 찾고 고치는 훈련은 "코드를 끝까지 읽고, 가설 세우고, 반증하고, 다시 읽는" 일이다. 이건 그냥 CRUD 코드 짜기보다 훨씬 긴 호흡의 추론이라 일반 SWE 태스크에 전이가 될 만하다. 물론 이건 Google의 설명이고 우리가 검증한 건 아니다.

2. "더 열심히 일하는 모델"이라는 말의 진짜 의미

공지에서 제일 정직한 문장은 이거다.

These performance gains stem from a core design choice: 3.8 Flash works harder. ... At times, the model might use more tokens to maximize performance, especially at higher effort levels.

번역하면 "성능은 올랐는데 토큰을 더 씁니다"다. 단가는 같은데 소비량이 늘면 청구서는 늘어난다. 이건 버그가 아니라 설계다.

비유하자면 이렇다. 3.7이 "주니어인데 시키는 만큼만 딱 하고 퇴근"이라면, 3.8은 "시켰더니 관련 파일 12개를 더 뒤져보고 테스트도 돌려보고 온" 사람이다. 결과물 품질은 좋은데 인건비(토큰)가 더 나간다. 그래서 Google도 대놓고 "효율이 최우선이면 effort level을 낮추거나 그냥 3.7 Flash 계속 쓰세요, 3.7은 계속 지원합니다"라고 써놨다. 모델 벤더가 구버전 계속 쓰라고 권하는 건 흔치 않다.

실무에서 이게 어떻게 드러나는지 보려면, 같은 프롬프트를 3.7과 3.8에 던져서 usageMetadata를 비교하는 게 제일 빠르다.

curl -s "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.8-flash:generateContent" \
  -H "x-goog-api-key: $GEMINI_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "contents": [{"parts": [{"text": "이 Terraform 모듈의 IAM 정책에서 과도한 권한을 찾아 수정안을 제시해줘: ..."}]}]
  }' | jq '.usageMetadata'
{
  "promptTokenCount": 1842,
  "candidatesTokenCount": 1130,
  "thoughtsTokenCount": 4207,
  "totalTokenCount": 7179
}

여기서 봐야 할 건 thoughtsTokenCount다. 사고 토큰은 출력 단가로 과금되는데, 위 예시라면 실제 답변(1,130)보다 사고 과정(4,207)이 4배 가까이 많다. 3.7에서 3.8로 올리고 나서 "응답은 비슷한데 비용이 왜 튀지?" 싶으면 십중팔구 여기다. 필드 이름과 정확한 과금 정책은 릴리스마다 달라질 수 있으니 본인 프로젝트의 결제 콘솔과 최신 API 문서로 반드시 재확인해야 한다.

3. Flash Cyber: 취약점 찾기와 패치, 그리고 Fairwind라는 게이트

Cyber 변종 쪽 숫자를 정리하면 이렇다.

  • CyberGym(C/C++ 중심 취약점 발견 벤치마크)에서 3.5 Flash Cyber는 물론 훨씬 큰 프런티어 모델들도 앞섰다.
  • C/C++만으론 현실을 못 담으니 20개 언어 코드베이스로 만든 내부 벤치마크도 돌렸고, 성공률 70% 초과.
  • 패치 쪽 외부 벤치마크 CWE-Bench(Collinear 운영)에서 pass@1 47.2%. 1위 모델 47.8%에 살짝 못 미치지만 비용이 훨씬 싸서 Pareto frontier에 있다는 표현을 썼다.
  • 실사례: Chrome Security 팀 기준 훨씬 큰 상용 모델 대비 정확한 패치 2.6배. Wiz 내부 침투테스트 벤치마크에서 recall +7.5~9.7%, 비용은 2.3~5.2배 저렴. Google Cloud 취약점 연구팀은 보통 몇 달 걸리는 핵심 취약점을 2시간 안에 찾았다고.

여기서 실무자가 오해하기 쉬운 지점. pass@1 47.2%는 "패치 절반은 틀린다"는 뜻이다. Chrome 팀 사례도 "2.6배 더 많은 correct patch"지 "전부 correct"가 아니다. 즉 이건 사람 리뷰어를 없애는 도구가 아니라, 리뷰 큐에 올릴 후보를 대량으로 뽑아주는 도구다. 자동 패치 파이프라인을 짤 거면 반드시 사람 승인 단계를 남겨야 한다.

그리고 Cyber 모델은 API 키만 있다고 못 쓴다. Fairwind Program 심사를 통과한 정부기관, 핵심 인프라 운영자, 소프트웨어 메인테이너에게만 우선 접근이 주어진다. 공지에도 "3.8 Flash Cyber는 사이버 영역에서 더 느슨한(permissive) 완화 정책을 적용하기 때문에 trusted defender에게만 제공한다"고 명시돼 있다. 공격 능력(exploitation)보다 수정(fixing)을 우선 투자했다는 서술도 함께 나온다.

그래서 일반 팀이 당장 할 수 있는 건 일반 3.8 Flash로 코드 리뷰/시크릿 스캔 보조를 붙이는 것까지다. 예를 들어 PR diff를 던져서 위험 패턴만 뽑는 CI 스텝은 이렇게 짤 수 있다.

# .github/workflows/ai-review.yml (핵심만)
- name: AI security review on diff
  env:
    GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
  run: |
    DIFF=$(git diff origin/main...HEAD -- '*.tf' '*.yaml' '*.py' | head -c 60000)
    jq -n --arg d "$DIFF" '{
      contents:[{parts:[{text:("아래 diff에서 하드코딩된 크리덴셜, 과도한 IAM 권한, 미검증 입력만 지적. 없으면 NONE만 출력.\n"+$d)}]}],
      generationConfig:{temperature:0}
    }' > req.json
    curl -sf -X POST \
      "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.8-flash:generateContent" \
      -H "x-goog-api-key: $GEMINI_API_KEY" -H 'Content-Type: application/json' \
      --data @req.json | jq -r '.candidates[0].content.parts[-1].text'

포인트는 두 개다. diff를 head -c로 자르는 것(안 자르면 대형 PR에서 토큰 폭탄), 그리고 parts[-1]로 마지막 파트를 꺼내는 것. 사고 과정이 앞쪽 파트로 오는 구성일 때 parts[0]만 보면 엉뚱한 게 나온다.

4. 도입 시 고려사항과 흔한 함정

함정 1. 모델 이름 하드코딩. 6주에 세 번 나오는 속도면 코드에 gemini-3.7-flash가 박혀 있는 순간 기술부채다. 환경변수나 ConfigMap으로 빼고, 카나리로 5% 트래픽만 신모델에 태우는 구조가 맞다. 그리고 아직 안 열린 이름을 미리 박아두면 이런 걸 만난다.

{
  "error": {
    "code": 404,
    "message": "models/gemini-3.8-flash-cyber is not found for API version v1beta, or is not supported for generateContent. Call ListModels to see the list of available models and their supported methods.",
    "status": "NOT_FOUND"
  }
}

Cyber 변종은 Fairwind 승인 전엔 이 404가 정상이다. 권한 문제인지 이름 오타인지 헷갈리면 먼저 ListModels로 확인하자.

$ curl -s "https://generativelanguage.googleapis.com/v1beta/models" \
    -H "x-goog-api-key: $GEMINI_API_KEY" | jq -r '.models[].name' | grep flash
models/gemini-3.7-flash
models/gemini-3.8-flash

함정 2. 타임아웃 설정을 그대로 둠. "works harder" 모델은 실제로 더 오래 생각한다. 3.7 기준으로 맞춰둔 인그레스/게이트웨이 타임아웃(예: nginx 60초)에 그대로 물리면 이런 게 뜬다.

upstream timed out (110: Connection timed out) while reading response header from upstream

스트리밍(streamGenerateContent)으로 바꾸거나 타임아웃을 늘리거나, effort level을 낮추는 세 갈래 중 골라야 한다. 사용자 대면 API라면 스트리밍이 정답에 가깝다.

함정 3. 429를 리트라이로만 때움. 토큰당 요금이 같아도 요청당 토큰이 늘면 TPM(분당 토큰) 쿼터를 먼저 때린다.

{"error":{"code":429,"message":"Resource has been exhausted (e.g. check quota).","status":"RESOURCE_EXHAUSTED"}}

여기서 무지성 지수 백오프 리트라이를 걸면 같은 무거운 요청이 반복돼 상황이 악화된다. 리트라이 전에 이 요청이 정말 3.8이어야 하는가를 먼저 물어야 한다. 라우팅 계층에서 요약·분류 같은 가벼운 작업은 3.7이나 낮은 effort로 내리는 게 실질적인 해법이다.

함정 4. 프롬프트 인젝션은 "개선"이지 "해결"이 아니다. 공지에 Gray Swan 측정 기준으로 프롬프트 인젝션 견고성이 크게 좋아졌다고 나오는데, 이걸 "이제 외부 문서 그냥 먹여도 된다"로 읽으면 안 된다. 에이전트가 셸이나 클라우드 API를 쥐고 있는 구조라면 모델 방어와 별개로 권한 자체를 좁히는 게 1차 방어선이다. 읽기 전용 크리덴셜, 별도 서비스 계정, 네트워크 이그레스 제한. 이건 모델 버전이 올라가도 안 바뀌는 원칙이다.

대안 관점. 코딩 에이전트라면 3.8 Flash가 "프런티어 모델 급 성능을 Flash 가격에"라는 포지션이라 비용 대비 매력이 있지만, 지연시간과 토큰 예산이 빡빡한 벌크 처리(로그 요약, 분류, 임베딩 전처리)는 3.7 Flash를 유지하는 게 낫다. Google 스스로 그렇게 권한다. 보안 스캐닝은 Cyber 모델 접근권이 없으면 기존 SAST/DAST(Semgrep, CodeQL, Trivy 등)를 1차로 두고 LLM은 트리아지 보조로 쓰는 조합이 현실적이다.

5. 정리

한 줄 요약: 3.8 Flash는 "같은 단가에 더 똑똑하지만 토큰을 더 쓰는" 모델이고, 3.8 Flash Cyber는 심사받은 방어자만 쓸 수 있는 취약점 탐지·패치 특화 변종이다.

  • 지금 바꿔라: 장기 실행 코딩 에이전트, 멀티스텝 리서치/리포팅, 복잡한 리팩터링 파이프라인. 품질 차이가 비용 증가분을 상쇄할 가능성이 높다.
  • 그대로 둬라: 분류·요약·라우팅처럼 대량·저지연이 핵심인 워크로드. 3.7 Flash는 계속 지원된다.
  • 먼저 할 일: 모델 이름 설정화 → 카나리 5% → usageMetadata로 토큰 변화 측정 → 타임아웃/쿼터 재산정. 이 순서를 건너뛰면 비용 알람으로 배운다.
  • Cyber는: Fairwind 신청 대상이 되는 조직인지부터 확인. 아니라면 일반 3.8 Flash + 기존 SAST 조합으로 트리아지 자동화를 먼저 만들어두는 게 순서다.

마지막으로, 벤치마크 수치는 전부 벤더 발표 기준이다. CWE-Bench pass@1 47.2%처럼 절반 가까이 틀리는 숫자를 보고도 "자동 패치니까 머지"로 가면 사고 난다. 우리 코드베이스에서 20~30건 샘플로 직접 재현해보고 정확도를 재는 게, 남의 벤치마크 표 열 개보다 낫다.

참고 자료

728x90
728x90

주말에 Business Insider가 "Hugging Face가 인수 제안을 받고 있다"고 흘리더니, 며칠 뒤 Nvidia가 130억 달러 이상 밸류로 협상 중이라는 후속 기사가 나왔다. 아직 딜이 체결된 건 아니고 깨질 수도 있다는 단서가 붙어 있다. 그럼에도 HN 프론트에 하루 종일 올라와 있던 이유는 단순하다. 우리 대부분이 from_pretrained() 한 줄로 남의 회사 CDN에 프로덕션 빌드를 의존시켜 놓고 있기 때문이다.

왜 지금 이 뉴스가 인프라 이슈인가

기사에 나온 팩트만 정리하면 이렇다.

  • Nvidia가 Hugging Face 인수를 최근 몇 주간 논의했고, 밸류에이션은 130억 달러 이상.
  • 아직 합의된 딜은 없고 무산될 수 있다.
  • Nvidia는 이미 2023년 2.35억 달러 라운드(밸류 45억 달러)에 참여한 기존 투자자다.
  • 작년 말 Nvidia가 70억 달러 밸류로 5억 달러 투자를 제안했으나 Hugging Face가 거절했다. 이유는 "의사결정을 좌우할 지배적 투자자를 원치 않는다"였다.
  • Microsoft도 미팅을 했지만 현재 논의는 진행 중이 아니다.
  • 기사 자체가 지적하는 리스크: Hugging Face의 강점인 중립성. 이 플랫폼은 AMD, Intel 등 Nvidia 경쟁사 하드웨어도 지원한다.

여기서 실무자가 봐야 할 지점은 "누가 누굴 샀다"가 아니다. 우리 파이프라인에서 huggingface.co가 사실상 단일 장애점(SPOF)이자 단일 벤더 의존점이라는 사실이 다시 드러난 것이다. 나는 예전에 HF Hub 장애로 새벽에 배포가 통째로 밀린 적이 있다. 이미지 빌드 단계에서 토크나이저를 받아오게 해놨는데, Hub가 5xx를 뱉으니 CI가 전부 빨간불이 됐다. 그때 알았다. 우리는 모델을 "설치"하는 게 아니라 매 빌드마다 "다운로드"하고 있었다는 걸.

인수 여부와 무관하게, 이 뉴스는 그 의존성을 점검하라는 알림 정도로 받아들이면 된다.

핵심: Hugging Face는 라이브러리가 아니라 레지스트리다

Transformers를 pip으로 깔았다고 해서 의존성이 끝난 게 아니다. HF는 구조적으로 Docker Hub나 npm registry에 훨씬 가깝다. 코드는 로컬에 있지만, 가중치와 설정 파일은 런타임에 원격에서 끌어온다. 컨테이너 이미지를 만들었는데 FROM 베이스 이미지를 매번 인터넷에서 pull 하도록 방치한 것과 같은 상태다.

실제로 뭐가 어디서 오는지 확인해보자.

# 캐시가 실제로 어디에 얼마나 쌓여 있는지
$ pip install -U "huggingface_hub[cli]"
$ hf cache scan

REPO ID                     REPO TYPE SIZE ON DISK NB FILES LAST_MODIFIED
--------------------------- --------- ------------ -------- -------------
sentence-transformers/all-M model            90.9M        6 2 days ago
BAAI/bge-m3                 model             2.2G        9 3 weeks ago

Done in 0.0s. Scanned 2 repo(s) for a total of 2.3G.

(CLI 명령 이름은 버전에 따라 huggingface-cli scan-cache 형태였다가 최근 hf로 정리되는 흐름이다. 설치된 버전에서 hf --help로 확인하는 걸 권한다.)

중요한 건 이 캐시가 빌드 산출물이 아니라 런타임 사이드 이펙트라는 점이다. 컨테이너를 새로 띄우면 캐시는 비어 있고, 첫 요청 때 수 GB를 받는다. 스케일 아웃 시 파드 10개가 동시에 같은 2GB 모델을 받으면 egress 비용도 비용이지만 rate limit에 걸린다.

그래서 실무에서 해야 할 일은 명확하다. 모델을 아티팩트로 취급하고, 우리 스토리지에 고정(pin)해서 넣는 것. 컨테이너 이미지를 사내 레지스트리에 미러링하는 것과 완전히 같은 논리다.

# 1) 리비전을 커밋 해시로 고정해서 받는다 (main은 언제든 바뀐다)
$ hf download BAAI/bge-m3 \
    --revision 5617a9f61b028005a4858fdac845db406aefb181 \
    --local-dir ./models/bge-m3

# 2) 사내 S3(또는 MinIO)에 올린다
$ aws s3 sync ./models/bge-m3 s3://ml-artifacts/models/bge-m3/5617a9f6/ \
    --endpoint-url https://minio.internal

upload: models/bge-m3/config.json to s3://.../config.json
upload: models/bge-m3/model.safetensors to s3://.../model.safetensors

그리고 런타임에서는 네트워크를 아예 끊어버린다.

# 오프라인 강제. Hub를 쳐다보지도 않게 만든다.
export HF_HUB_OFFLINE=1
export HF_HOME=/opt/hf-cache

# 로컬 경로에서만 로드
python - <<'PY'
from transformers import AutoModel, AutoTokenizer
p = "/opt/models/bge-m3"
tok = AutoTokenizer.from_pretrained(p, local_files_only=True)
mdl = AutoModel.from_pretrained(p, local_files_only=True)
print("loaded:", mdl.config.model_type)
PY
# 출력: loaded: xlm-roberta

이렇게 해두면 인수가 되든 정책이 바뀌든 CDN이 죽든, 우리 서비스는 영향을 안 받는다.

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

중립성 리스크는 "당장"보다 "누적"으로 온다

기사가 짚은 대로 HF의 자산은 중립성이다. AMD ROCm, Intel Gaudi 계열 백엔드도 붙어 있고, Optimum 계열 통합도 여러 벤더가 걸려 있다. Nvidia가 인수한다고 해서 다음 날 AMD 지원이 삭제될 리는 없다. 그런 건 커뮤니티 반발만 부른다.

현실적으로 우려되는 건 더 미묘한 쪽이다. 기본값과 문서에서 밀어주는 경로, 최적화 리소스가 어디로 가느냐. 지금도 CUDA 경로가 가장 잘 굴러가는데, 그 격차가 유지보수 우선순위 차이로 더 벌어지는 시나리오. 다만 이건 어디까지나 가설이고, 딜 자체가 성사되지 않았다. 지금 아키텍처를 갈아엎을 근거는 없다. 대신 "우리가 CUDA 전용 코드에 얼마나 묶여 있나"를 한 번 세어보는 계기 정도는 된다.

흔한 함정 1: 오프라인 전환했는데 갑자기 터지는 케이스

HF_HUB_OFFLINE=1을 켜고 배포했더니 스테이징에서만 죽는 경우가 있다. 대표적으로 이 에러다.

OSError: We couldn't connect to 'https://huggingface.co' to load the files, and it looks like
/opt/models/bge-m3 is not the path to a directory containing a file named config.json.
Checkout your internet connection or see how to run the library in offline mode at
'https://huggingface.co/docs/transformers/installation#offline-mode'.

원인 90%는 hf download--local-dir을 안 줘서 캐시 디렉터리(~/.cache/huggingface)에만 들어갔고, 컨테이너에서 HF_HOME이 다른 경로를 보고 있는 것이다. 빌드 스테이지와 런타임 스테이지의 HF_HOME이 다르면 멀티스테이지 Dockerfile에서 특히 잘 터진다. 캐시를 COPY 할 거면 경로를 양쪽에서 동일하게 맞춰야 한다.

흔한 함정 2: rate limit / 인증

CI에서 병렬 잡을 돌리면 이걸 만난다.

huggingface_hub.errors.HfHubHTTPError: 429 Client Error: Too Many Requests for url:
https://huggingface.co/api/models/...

혹은 gated 모델(라이선스 동의 필요)일 때:

huggingface_hub.errors.GatedRepoError: 403 Client Error.
Cannot access gated repo for url https://huggingface.co/api/models/.../revision/main.
Access to model ... is restricted. You must have access to it and be authenticated to access it.

gated 모델은 계정 단위로 동의가 걸려 있어서, 개인 계정 토큰으로 CI를 돌리다가 그 사람이 퇴사하면 파이프라인이 죽는다. 실제로 겪었다. 조직 계정 + 조직 토큰으로 옮기고, 애초에 사내 미러에 한 번 받아두는 게 근본 대책이다.

흔한 함정 3: 리비전 미고정

from_pretrained("org/model")는 기본이 main이다. 업스트림이 가중치를 갱신하면 코드 변경 없이 추론 결과가 바뀐다. 임베딩 모델이면 벡터 DB에 저장된 기존 임베딩과 차원/분포가 어긋나서 검색 품질이 조용히 망가진다. 로그에 에러가 안 찍히니 발견도 늦다. revision=에 커밋 해시를 박아라. 이건 latest 태그 쓰지 말라는 얘기와 정확히 같다.

대안은 뭐가 있나

  • 사내 미러 + 오브젝트 스토리지: 가장 현실적. S3/MinIO에 넣고 initContainer로 내려받아 emptyDir이나 PVC에 꽂는다. 노드 로컬 디스크에 캐시하면 콜드 스타트도 줄어든다.
  • OCI 아티팩트로 패키징: 모델을 컨테이너 이미지나 ORAS 아티팩트로 만들어 사내 레지스트리에 올린다. 이미 있는 이미지 스캐닝·서명·권한 체계를 그대로 재활용할 수 있다는 게 크다.
  • MLflow / 자체 모델 레지스트리: 모델 버전 관리와 승격 워크플로가 필요하면. 다만 운영 부담이 늘어난다.
  • 추론 런타임 다변화: vLLM, llama.cpp, ONNX Runtime 등 가중치 포맷 표준(safetensors, GGUF, ONNX)에 기대는 구성이면 백엔드 교체 여지가 커진다. 반대로 특정 벤더 전용 최적화 포맷에 파이프라인을 통째로 맞춰놓으면 나중에 이사 비용이 크다.

트레이드오프는 분명하다. 미러링은 공짜가 아니다. 스토리지 비용, 동기화 잡 관리, "업스트림에 새 버전 나왔는데 우리 미러엔 없다"는 운영 부담이 생긴다. 모델을 한두 개만 쓰는 소규모 팀이면 굳이 안 해도 된다. 다만 프로덕션 크리티컬 경로에 있는 모델만큼은 반드시 고정하고 미러링하길 권한다. 개수로 치면 보통 3~5개면 충분하다.

정리

한 줄 요약: 인수는 아직 확정이 아니지만, huggingface.co를 런타임 의존성으로 두고 있다면 지금이 아티팩트로 승격시킬 타이밍이다.

누가 언제 해야 하나:

  • 지금 당장 — 프로덕션 추론 서비스에서 from_pretrained("org/model")을 리비전 없이 호출하는 팀. 인수설과 무관하게 이미 위험하다.
  • 이번 분기 안에 — CI/CD 빌드 단계에서 Hub를 때리는 팀. 외부 장애가 곧 배포 중단이다.
  • 관망해도 되는 쪽 — 연구/프로토타이핑 환경. 여기까지 통제하면 개발 속도만 죽는다.
  • 같이 점검 — AMD/Intel 하드웨어를 쓰거나 검토 중인 팀은, 딜 진행 상황과 각 백엔드의 릴리스 주기를 한동안 지켜볼 필요가 있다. 다만 지금 판단을 바꿀 근거는 아직 없다.

덧붙이면, 이런 뉴스가 나올 때마다 아키텍처를 뒤집는 건 좋은 습관이 아니다. 대신 "이 벤더가 내일 사라지거나 정책을 바꾸면 우리는 며칠 안에 복구 가능한가"를 문서 한 장으로 답할 수 있게 만들어두는 것. 그게 이번 뉴스에서 건질 실질적인 액션이다.

참고 자료

728x90
728x90

1. netcat으로는 안 되는 순간들

온프렘 랙에 있는 서버 하나에서 로그 3GB를 클라우드 쪽 분석 VM으로 넘겨야 하는 상황. 예전 같으면 nc -l 9000 > dump.tar 하고 반대쪽에서 nc 10.0.0.5 9000 < dump.tar 하면 끝났다. 문제는 요즘 어떤 환경에서도 이게 잘 안 된다는 거다.

  • 양쪽 다 NAT 뒤에 있고, 인바운드 포트를 열려면 방화벽 변경 요청서를 써야 한다. 보통 이틀 걸린다.
  • 클라우드 쪽은 Security Group을, 온프렘 쪽은 L4 방화벽 정책을 각각 다른 팀이 관리한다.
  • 어렵게 뚫어도 netcat은 평문이다. 사내망이라도 요즘은 감사에서 걸린다.
  • 임시로 열었던 포트를 닫는 걸 까먹는다. 이게 제일 흔하다.

그래서 다들 SSH 터널, ngrok, 아니면 그냥 Tailscale을 깐다. 그런데 Tailscale은 계정이 필요하고 노드가 tailnet에 등록된다. "일회성 디버깅 하나 하려고 회사 tailnet에 이 서버를 붙여야 하나?" 하는 지점에서 걸린다.

Tailcat은 Tailscale이 직접 만든 도구인데, 자기네 표현이 재밌다. "Tailscale without Tailscale, by Tailscale". Tailscale의 데이터 평면(magicsock)만 떼어내서 netcat처럼 쓰게 만든 물건이다. 계정 없고, 제어 서버(coordination server) 없고, root 권한도 필요 없다. 라우팅 테이블이나 DNS를 건드리지 않는 순수 유저스페이스 CLI/라이브러리다.

2. 데이터 평면만 떼어낸다는 게 무슨 뜻인가

Tailscale은 크게 두 덩어리로 나뉜다.

  • 제어 평면(control plane): 누가 어떤 tailnet에 속하는지, 어떤 노드가 어떤 IP를 갖는지, ACL은 뭔지를 관리. 여기가 계정과 로그인이 붙는 곳이다.
  • 데이터 평면(data plane): 실제 패킷을 나르는 부분. 내부적으로 magicsock이라 부르는 컴포넌트가 WireGuard 암호화 터널을 만들고, DERP 릴레이를 사이드 채널 삼아 NAT 홀펀칭을 시도한다. 홀펀칭이 실패하면 DERP가 최후의 릴레이로 남는다.

Tailcat은 이 중 아래쪽만 쓴다. 그럼 제어 평면이 하던 "상대방 공개키와 위치를 알려주는" 일은 누가 하냐? 사람이 한다. 정확히는 out-of-band로 알아서 전달한다. 슬랙, 메신저, DNS TXT 레코드, 뭐든 상관없다.

연결 토큰의 정체

서버 쪽에서 tailcat을 실행하면 토큰 하나가 나온다.

$ tailcat
# Selected bootstrap relay region 302, San Francisco
# 🐈 Server listening with new address: tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFpGQEu
(hangs, waiting...)

이 문자열이 뭔지는 tailcat parse로 직접 까볼 수 있다.

$ tailcat parse tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFpGQEu
{
  "ServerPublic": "nodekey:9c8d2e6728da80a1dd37e275a82595b42d9a838610bc53f74a7670d1610f2e34",
  "RegionID": 302
}

딱 두 개다. 서버의 WireGuard 공개키어느 DERP 리전에서 만나면 되는지. 제어 평면이 API로 내려주던 정보를 base64 비슷한 한 줄로 압축해서 사람이 복붙하게 만든 것뿐이다. 이걸 보고 나면 구조가 단순해서 오히려 허탈하다.

클라이언트는 이 토큰을 받아 해당 DERP 리전으로 붙고, 거기서 서버와 만나 WireGuard 핸드셰이크를 한다. 핸드셰이크가 끝나면 magicsock이 서로의 실제 IP:포트를 교환하면서 직접 UDP 경로를 뚫으려 시도한다. 성공하면 DERP를 버리고 P2P로 올라탄다. 이게 실제로 되는지는 ping으로 확인할 수 있다.

$ tailcat ping --until-direct tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFpGQEu
pong in 42.1ms via DERP(sfo)
pong in 1.2ms via 203.0.113.7:41641

첫 번째 pong은 릴레이 경유(42ms), 두 번째는 직접 경로(1.2ms). NAT 트래버설이 성공하는 순간이 로그에 그대로 찍힌다. --until-direct는 직접 경로가 뚫릴 때까지 계속 핑을 날리고, 타임아웃(기본 10초) 안에 못 뚫으면 non-zero로 종료한다. 스크립트에서 "직접 연결 가능한지" 사전 체크용으로 쓸 만하다.

토큰의 두 가지 형태

기본 토큰에는 리전 ID만 들어있어서, 클라이언트가 DERP map(https://tailcat.dev/derpmap.json)을 한 번 받아와야 한다. 이걸 미리 풀어서 릴레이 호스트명·IP까지 박아 넣은 긴 토큰을 만들 수 있다.

$ tailcat resolve tcomFwWCC...FpGQEu
tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFygaFhToGjYWhudGMzMDJhLmlwbi5kZXZhNG0yMDguMTExLjM5LjM4YTZzMjYwNzpmNzQwOjA6M2Y6OjcyMA

파싱해보면 tc302a.ipn.dev, IPv4/IPv6가 그대로 들어있다. 서버가 처음부터 이 형태로 출력하게 하려면 --full-address를 쓴다. 길어지지만 DERP map 조회 왕복이 없어서 연결이 빨라진다.

3. 실무에서 어떻게 쓰나 — 그리고 어디서 넘어지나

시나리오 A: 방화벽 뒤 웹 서버 임시 노출

온프렘 서버에서 돌던 애플리케이션이 이상하게 동작해서, 클라우드에 있는 내 개발 VM에서 직접 찔러보고 싶다. 인바운드 포트는 못 연다.

# [온프렘 서버]
$ tailcat --serve=8080,8443
# 🐈 Server listening with new address: tcXXXXXXXXX

# [클라우드 VM] 토큰 슬랙으로 받아서
$ tailcat tcXXXXXXXXX 8080
GET / HTTP/1.1
Host: foo

HTTP/1.1 200 OK
....

더 실용적인 건 SOCKS5 모드다. curl이나 다른 CLI 도구를 그대로 터널 위로 태울 수 있다. 토큰 자체를 호스트명처럼 쓸 수 있다는 점이 재밌다.

$ tailcat socks tcXXXXXXXXX curl http://server.tailcat:8081/
# 또는 토큰을 호스트명 자리에 직접
$ tailcat socks curl http://tcXXXXXXXXX:8081/

주의할 점: 토큰은 대소문자를 구분한다. curl 같은 CLI에서는 문제없지만 브라우저는 호스트명을 소문자로 바꿔버려서 안 된다. 원문에 명시된 제약이다.

시나리오 B: 포트 안 열고 SSH 서버 운영

이게 개인적으로 제일 인상 깊었던 예제다. WireGuard가 SSH 데몬보다 먼저 클라이언트를 인증하기 때문에, 허용되지 않은 상대는 SSH 서버가 돌고 있다는 사실조차 알 수 없다.

# [클라이언트] 클라이언트 신원 키 생성 (공개키만 서버에 알려주면 됨)
client$ tailcat genkey --client
# wrote file to ~/.config/tailcat/keys/client-default.private.json
nodekey:cfb6bfa77a0654d7450947fd6acef17d2cd848da1d30b2540b13dac272ddfd16

# [서버] 리전 고정한 서버 키 생성 후, 저 클라이언트만 허용
server$ tailcat genkey --fixed-region
# wrote file to ~/.config/tailcat/keys/default.private.json
tcXXXXXXXXX
server$ tailcat --serve=22 --allow=nodekey:cfb6bf...ddfd16
# 🐈 Server listening with saved key "default": tcXXXXXXXXX

토큰을 DNS TXT 레코드로 올려두면 이름으로 접속된다.

my-server.example.com.  300  IN  TXT  "tailcat=tcXXXXXXXXX"

client$ tailcat ssh my-server.example.com

클라이언트는 client-default 키가 있으면 알아서 쓰기 때문에 추가 플래그가 없다. 포트 노킹이나 포트 포워딩 없이 이게 된다는 게 꽤 매력적이다.

흔한 함정

(1) default 키의 마법 이름 문제. 이게 제일 사고 나기 쉽다. tailcat genkey를 한 번 해두면 ~/.config/tailcat/keys/default.private.json이 생기고, 그 뒤로는 그냥 tailcat만 쳐도 조용히 그 키를 쓴다. 즉 예전에 어딘가 슬랙에 뿌렸던 토큰이 그대로 살아난다. 시작 로그에서 반드시 구분하자.

# 새 일회용 키 — 프로세스 죽으면 주소도 영구 소멸
# 🐈 Server listening with new address: tcXXXXXXXXX

# 저장된 키 — 과거에 토큰 받은 사람 전부 접속 가능
# 🐈 Server listening with saved key "default": tcXXXXXXXXX

저장 키를 쓸 거면 --allow로 클라이언트를 제한하는 게 사실상 필수다. 일회용이 필요하면 --key=new를 명시하고, 키를 지우려면 tailcat genkey --delete --key=default, 목록은 tailcat genkey --list.

(2) DNS에 올릴 토큰은 리전을 고정해야 한다. 그냥 tailcat genkey--region=auto가 기본이라 "시작할 때 고른다"가 키 파일에 박힌다. 서버가 재시작되면서 다른 리전을 고르면 DNS에 게시한 토큰이 어긋난다. 그래서 위 예제에서 --fixed-region을 쓴 거다. 이건 genkey 시점에 가장 가까운 리전을 한 번 탐색해서 ID를 토큰과 키 파일 양쪽에 박아둔다. 명시적으로 고르려면 --region=이름, 목록은 --region=list.

참고로 README에도 "DERP map이 시간이 지나 바뀌면 클라이언트가 더 견고해져야 한다"는 TODO(이슈 #7)가 남아 있다. 장기 운영용으로 DNS에 박아둘 거면 이 부분은 이슈 트래킹을 해두는 게 좋겠다.

(3) 토큰 하나가 곧 접근 권한이다. --allow를 안 걸면 토큰 아는 사람은 누구나 붙는다. 특히 --serve=no-auth-ssh는 이름 그대로 인증 없는 SSH다. 잠깐 디버깅용으로는 편하지만, 이걸 사내 서버에서 저장 키와 조합해서 띄우는 건 사고다. 인증이 필요하면 --serve=22로 시스템 SSH에 프록시하는 쪽을 쓰자. --serve=exit-node도 마찬가지로, 클라이언트가 서버 쪽 네트워크 전체로 나갈 수 있게 되는 옵션이라 사내망에서는 신중해야 한다.

(4) 직접 연결이 항상 되는 건 아니다. README도 "usually!"라고만 쓴다. 대칭 NAT나 빡빡한 아웃바운드 정책이 걸린 사내망에서는 DERP 릴레이 경유로 남을 수 있다. 이때 기본 릴레이는 Tailscale의 무료 rate-limited 릴레이다. 3GB 로그 덤프 같은 걸 릴레이로 밀면 어떻게 될지는 예상 가능하다. 대용량 전송 전에 tailcat ping --until-direct로 직접 경로부터 확인하고, 상시로 쓸 거면 자체 DERP를 세우자.

# 자체 derper(TLS 인증서 필요, Let's Encrypt 자동 발급 가능) 지정
server$ tailcat genkey --region=derp.example.com
tcomFwWCCAIsKOqPUux6ClG2RM4A_vOq4VBzGgHGGjq9OsJuFKSWFygaFhToGhYWhwZGVycC5leGFtcGxlLmNvbQ
server$ tailcat --serve=22

이렇게 하면 토큰 안에 릴레이 호스트명이 박혀서, 클라이언트는 Tailscale의 DERP map 서버나 릴레이에 전혀 접속하지 않는다. rate limit도 내 것만 적용된다. 릴레이가 여러 대면 직접 DERP map JSON을 서빙하고 양쪽에 --derpmap-url로 물려주면 된다. 사내 도입을 검토한다면 사실상 이 구성이 기본이 되어야 한다고 본다.

(5) 아웃바운드가 막혀 있으면 아무것도 안 된다. 이건 도구 문제가 아니라 정책 문제다. DERP 부트스트랩은 HTTPS(443)로 나가고 P2P는 UDP다. 아웃바운드 443조차 프록시 강제인 환경에서는 다음처럼 실패한다.

$ tailcat tcomFwWCCcjS5nKNqAod034nWoJZW0LZqDhhC8U_dKdnDRYQ8uNGFpGQEu
tailcat: failed to connect: context deadline exceeded

혹은 토큰을 잘못 복붙했을 때(줄바꿈이 섞이거나 대소문자가 뭉개진 경우) 이런 식의 파싱 실패를 먼저 의심하자.

$ tailcat parse tcomfwwccjs5nkNqAod034nWoJZW0LZ
tailcat: invalid address

정확한 메시지 문구는 버전에 따라 다를 수 있으니 실제 출력 기준으로 확인이 필요하다. 다만 원인은 대체로 아웃바운드 차단 아니면 토큰 복붙 사고 둘 중 하나다. 토큰이 대소문자 구분이라는 점 때문에, 메신저 자동 링크화나 대문자 변환이 끼면 조용히 깨진다.

다른 도구와 언제 갈리나

  • SSH 포트포워딩(ssh -L/-R): 이미 SSH로 닿을 수 있으면 이게 제일 간단하다. 문제는 양쪽 다 NAT 뒤라 SSH 자체가 안 닿을 때. Tailcat은 그 지점을 푼다.
  • ngrok/Cloudflare Tunnel: HTTP 노출과 공개 URL이 목적이면 여전히 이쪽이 낫다. Tailcat 토큰은 브라우저에 못 넣는다.
  • Tailscale 본체: 상시 운영, ACL, 감사 로그, 팀 단위 관리가 필요하면 그냥 Tailscale을 써야 한다. Tailcat에는 중앙 정책 관리가 없다. 신뢰 모델이 "토큰을 아는 사람 + --allow 목록"으로 끝난다.
  • 순정 WireGuard: 성능과 통제는 최고지만 양쪽 공인 IP/포트 개방과 root 권한이 필요하다. Tailcat은 root 없이 유저스페이스에서 돌고 라우팅 테이블을 안 건드린다. 이게 "남의 서버에 잠깐 올려서 쓰기" 관점에서는 큰 차이다.

4. 정리

한 줄 요약: Tailcat은 "계정도 제어 서버도 없이, root 없이, 방화벽 뒤 두 머신을 WireGuard로 직접 잇는 netcat"이다. 제어 평면이 하던 키·위치 교환을 사람이 토큰 복붙으로 대신하는 게 전부고, 그래서 단순하고 그래서 위험 요소도 명확하다.

이럴 때 써라:

  • 온프렘–클라우드 간 일회성 디버깅 터널. 기본값(에페메랄 키)이 정확히 이 용도로 설계되어 있다.
  • 방화벽 정책 변경 리드타임을 못 기다리는 장애 대응 상황.
  • 인바운드 포트를 절대 못 여는 서버에 SSH를 붙여야 할 때(--allow + 자체 DERP 필수).
  • Go 라이브러리(github.com/tailscale/tailcat)로 내부 툴에 P2P 채널을 심을 때. Server{OnTCP: ...} 하나로 시작되는 API가 꽤 얇다.

이럴 때는 쓰지 마라:

  • 상시 운영 인프라의 정식 접근 경로. 중앙 ACL·감사가 없다.
  • 브라우저로 접근해야 하는 서비스 노출.
  • 기본 릴레이 위로 대용량 전송. 자체 DERP를 먼저 세우자.

개인적으로는 "노트북에 넣고 다니는 비상용 도구" 포지션이 딱 맞다고 본다. 저장소가 공개된 지 얼마 안 됐고 README에도 TODO가 남아 있으니, 프로덕션 상시 경로로 넣기 전에는 최신 이슈 목록을 한 번 훑어보길 권한다.

참고 자료

728x90
728x90

결제·주문·정산 같은 장기 실행 로직을 다루는 팀이면 최근 1~2년 사이에 한 번쯤 "Temporal 도입 검토"라는 문서를 봤을 거다. 그리고 그 문서 옆에 요즘 Restate가 같이 올라온다. 원문 글의 요지는 딱 한 줄로 압축된다. "쓸 코드로 고르지 말고, 운영할 것으로 골라라(pick on what you have to operate, not what you have to write)."

실제로 두 엔진 모두 각 단계를 저널(journal)에 기록하고 크래시 후 리플레이해서 "카드는 긁혔는데 재고는 안 잡힌" 상태를 방지한다. 내구성 자체를 차별점으로 파는 영업은 걸러도 된다. 차이는 저널 주변에 있다. 어디에 저장하고, 무엇이 그걸 돌리고, 내 앱이 어떤 모양으로 쪼개져야 하는가.

Durable Execution이 필요해지는 순간

주문 처리 핸들러를 예로 들자. 카드 승인 → 재고 예약 → 메일 발송 → 배송 상태 갱신. 이 중간에 파드가 OOMKill 되면 메모리에 있던 진행 상태는 전부 날아간다. 그래서 우리는 보통 이런 걸 직접 만든다.

  • 진행 상태를 담는 order_saga 테이블과 상태 enum
  • 멱등키 테이블 + PG사 재조회로 "이미 승인됐나?" 확인
  • 실패 단계만 다시 태우는 재시도 워커, 그리고 지수 백오프 큐
  • 보상 트랜잭션(환불, 재고 해제) 코드와 그 코드의 실패 처리

이게 나쁘다는 게 아니다. 다만 이 코드는 도메인 로직보다 빨리 늘어나고, 장애 리포트의 절반은 여기서 나온다. Durable execution 엔진은 이 "상태 저장 + 재시도 + 이어서 실행"을 프레임워크로 흡수한다. 완료된 스텝은 저널에 남고, 프로세스가 되살아나면 저널을 리플레이해 끝난 단계를 건너뛰고 정확히 멈춘 지점부터 이어간다.

Temporal은 클러스터, Restate는 바이너리 한 개(단, 별표 붙음)

Temporal: 4개 서비스 + 외부 DB + 내 워커

Temporal 서버는 한 프로세스가 아니다. 게이트웨이 역할의 Frontend, 워크플로 상태와 타이머를 소유하는 History, 태스크 큐를 호스팅하는 Matching, 그리고 Temporal 자체 시스템 워크플로용 내부 Worker. 각각 별도 프로세스에 자기 gRPC 엔드포인트를 가지며, 규모가 커졌을 때 독립적으로 스케일하려고 이렇게 쪼개 놨다.

그리고 이 클러스터는 스스로 아무것도 기억하지 못한다. 영속성은 외부 DB(실무에선 PostgreSQL/MySQL, 로컬 개발용 SQLite)가 담당한다. 오래된 블로그 보고 Cassandra를 떠올렸다면 접어라. 원문에 따르면 Cassandra는 Server v1.21에서 deprecated, v1.24에서 제거됐다. 검색(Visibility)도 오해가 많은데, Server v1.20부터 SQL DB가 Advanced Visibility를 지원하므로 Elasticsearch가 필수는 아니다. "볼륨이 커지면 결국 쓰게 될 것"이 정직한 표현이다.

여기에 하나 더. 내 워크플로 코드는 Worker라는 별도 배포 단위에서 돌고, 클러스터의 태스크 큐를 long-poll한다. 즉 도입은 "서버 하나 띄우기"가 아니라 클러스터 + DB + (나중에) 검색 클러스터 + API/워커 분리다.

// workflows.ts — 오케스트레이션. 결정적(deterministic)이어야 한다
import { proxyActivities } from '@temporalio/workflow';
import type * as activities from './activities';

const { greet } = proxyActivities<typeof activities>({
  startToCloseTimeout: '1 minute',
});

export async function example(name: string): Promise<string> {
  // Date.now(), Math.random(), 직접 I/O 전부 금지
  return await greet(name);
}

greet를 직접 부르지 않고 proxyActivities를 통과시키는 이유가 핵심이다. 워크플로 코드는 복구할 때마다 이벤트 히스토리로 리플레이되므로, 같은 입력이면 항상 같은 커맨드 시퀀스를 뱉어야 한다. 아니면 Temporal이 비결정성 에러를 내고 진행을 거부한다. 대신 이 모델 덕분에 "30일 sleep 후 로컬 변수 그대로 깨어나기"가 가능하다. 프로세스를 살려두는 게 아니라 히스토리를 다시 재생해 상태를 복원하니까.

Restate: DB가 바이너리 안에 들어있다

Restate는 반대쪽에 베팅한다. Rust로 쓰인 단일 바이너리에 스트림 처리 아키텍처, 그리고 저널과 durable state를 담는 내장 RocksDB가 함께 들어있다. 프로비저닝할 Postgres도, 돌봐야 할 Elasticsearch도 없다.

다만 원문이 짚은 정직한 별표: "단일 바이너리"가 "프로세스 하나로 끝"을 뜻하지는 않는다. HA를 원하면 그 바이너리 여러 인스턴스를 띄우고, RocksDB가 주기적으로 오브젝트 스토어(S3/GCS/Azure Blob)에 스냅샷을 남겨야 한다. 그래야 노드가 죽어도 다른 노드가 상태를 복구하고 로그를 트림할 수 있다. 그래서 실제 비교는 "1 프로세스 vs 클러스터"가 아니라 "같은 바이너리 몇 개 + 버킷 하나" vs "4종 서비스 + RDB + (옵션) 검색 클러스터 + 쪼갠 워커"다. 그래도 운영 표면적 차이는 여전히 극적이다.

import * as restate from "@restatedev/restate-sdk";

// Virtual Object: id로 키잉된 상태 + 단일 writer 보장
export const myObject = restate.object({
  name: "MyObject",
  handlers: {
    myHandler: async (ctx: restate.ObjectContext, greeting: string) => {
      // ctx.sleep(), awakeable 등 durable 연산은 ctx 경유
      return `${greeting} ${ctx.key}!`;
    },
  },
});

restate.serve({ services: [myObject] });

Restate의 간판 프리미티브인 Virtual Object는 id로 키잉된 상태 엔티티다. 자기만의 KV 상태를 갖고, 특정 객체 상태는 한 번에 하나의 핸들러만 변경하는 단일 writer 보장이 붙는다. 별도 상태 저장소 없이 keyed state를 얻는 셈이다. 외부 이벤트를 기다리는 durable promise(awakeable), ctx.sleep 기반 durable timer도 있고, SDK는 TypeScript/Java·Kotlin/Python/Go/Rust를 지원한다. 중요한 건 Temporal식 강한 결정성 계약을 핸들러에 같은 방식으로 강요하지 않는다는 점이다. durable 스텝은 context를 통과하고, 프레임워크가 저널을 리플레이한다.

실무 관점: 5분 실습, 운영 표면적, 그리고 함정

일단 띄워보기

$ temporal server start-dev --db-filename temporal.db
CLI 1.x.x (Server 1.x.x, UI 2.x.x)

Server:  localhost:7233
UI:      http://localhost:8233
Metrics: http://localhost:64000/metrics

(버전 문자열과 포트는 릴리스마다 다르다. 이건 로컬 개발용 dev 서버이고, 프로덕션은 앞서 말한 4종 서비스 + RDB 구성이다. 여기서 "로컬은 한 줄인데 왜 운영은 이렇게 복잡하냐"는 갭이 도입 검토에서 가장 자주 새는 지점이다.)

$ docker run --name restate -d --rm \
    -p 8080:8080 -p 9070:9070 -p 9071:9071 \
    docker.io/restatedev/restate:latest

$ restate deployments register http://host.docker.internal:9080
✅ Successfully registered deployment  (services: MyObject)

# 서비스 호출 (ingress)
$ curl -s localhost:8080/MyObject/alice/myHandler -H 'content-type: application/json' -d '"Hello"'
"Hello alice!"

포트/플래그는 버전에 따라 달라질 수 있으니 공식 문서 확인이 필요하다. 다만 흐름 자체는 "바이너리 실행 → 내 핸들러 프로세스 등록 → HTTP 호출"이 끝이다.

운영 표면적 비교

  • 배포 토폴로지: Temporal = Frontend/History/Matching/내부 Worker + 내 Worker Deployment + API Deployment. Restate = 바이너리 N개 + 내 핸들러 서비스.
  • 스토리지 의존성: Temporal = PostgreSQL/MySQL(+ 볼륨 커지면 ES/OpenSearch). Restate = 로컬 디스크(RocksDB) + 오브젝트 스토리지 버킷.
  • 커플링: Temporal 구성은 API·워커·서버 셋이 서로를 물고 있어 하나가 죽으면 나머지가 같이 흔들린다. 원문이 인용한 DBOS 벤치마크(경쟁사가 자기 제품과 비교한 자료다)에서는 샘플 앱에 Temporal을 붙이며 100줄 이상 수정, 110→187줄 증가, 두 서비스 분리 + 세 번째(Temporal 서버) 런타임 의존이 생겼다고 한다. 자연법칙처럼 인용할 숫자는 아니고, Restate 수치로 옮겨 붙이면 더 안 된다. 다만 밑에 깔린 아키텍처 주장은 Temporal 공식 문서로도 검증되는 사실이다.
  • 장애 복구: Temporal은 DB가 진실의 원천이라 DB HA/백업 전략이 곧 워크플로 복구 전략이다. Restate는 노드 로컬 RocksDB + 오브젝트 스토어 스냅샷이라, 스냅샷 대상 버킷을 설정하지 않은 채 단일 노드로 굴리는 것이 가장 위험한 구성이다.

흔한 함정

1) 코드 한 줄 고쳤는데 진행 중 워크플로가 멈춘다. Temporal 도입 팀의 통과의례다. 워크플로 함수 안에서 액티비티 호출 순서를 바꾸거나 분기를 추가하면 리플레이 시 커맨드 시퀀스가 어긋난다. SDK·버전에 따라 문구는 다르지만 대략 이런 형태의 에러를 보게 된다.

DeterminismViolationError: Workflow activation completed with a different
command list than the one in the event history.
  expected: ScheduleActivityTask(greet)
  actual:   StartTimer

io.temporal.worker.NonDeterministicException: Failure handling event 5 of type
'EVENT_TYPE_ACTIVITY_TASK_SCHEDULED' during replay

해법은 정공법뿐이다. 워크플로 로직 변경은 버전 게이팅(patch/versioning API)으로 감싸고, 태스크 큐를 분리해 신규 워크플로만 새 코드로 받는 식으로 배포한다. Date.now()·Math.random()·직접 HTTP 호출은 워크플로 안에서 전부 금지어라는 걸 팀 컨벤션과 린트로 못 박아야 한다.

2) long-poll과 프록시 타임아웃. Temporal 워커는 태스크 큐를 long-poll 한다. 앞에 idle timeout이 짧은 LB/프록시(또는 사내 방화벽)를 두면 폴이 계속 끊기면서 context deadline exceeded 류의 로그가 도배된다. gRPC 경로에는 가급적 L4로 통과시키고 idle timeout을 폴 주기보다 넉넉히 잡는 편이 안전하다(정확한 기본값은 SDK 문서 확인 필요).

3) "ES 없이 가자"고 결정한 다음 운영에서 후회. v1.20부터 SQL만으로 Advanced Visibility가 되지만, 워크플로 수가 늘고 커스텀 속성으로 조회하기 시작하면 DB 부하로 돌아온다. "ES 필수"는 미신, "결국 필요해질 것"은 사실 — 이 중간 지점을 도입 문서에 명시해 두자.

4) Restate에서 상태를 ctx 밖에 두기. 핸들러 안에서 전역 변수나 외부 캐시에 진행 상태를 들고 있으면 리플레이 시 그 값은 복원되지 않는다. durable하게 남길 값은 반드시 context를 경유해야 한다. Virtual Object 키 설계도 초기에 잡아야 한다. 단일 writer 보장은 같은 키에 대한 것이라, 키를 너무 굵게 잡으면 그게 곧 직렬화 병목이 된다(주문번호 단위 vs 사용자 단위 같은 결정).

정리: 무엇을 언제 고를까

한 줄 요약: 코드 델타는 생각보다 작고, 운영 델타는 생각보다 크다. 새벽 2시에 당신이 붙잡을 대상이 무엇인지로 고르면 후회가 적다.

  • Restate 쪽에 기울 때: 팀에 전담 플랫폼 인력이 없다. 이미 굴리는 RDB/ES에 또 하나를 얹기 부담스럽다. 필요한 건 "이 핸들러를 크래시에 강하게 만들고 약간의 durable state를 붙이기"이고, 전면적인 워크플로 오케스트레이션 세계관까지는 원치 않는다. 마이크로서비스/이벤트 핸들러/에이전트처럼 백엔드 전반에 얇게 깔고 싶다.
  • Temporal 쪽에 기울 때: 30일 대기·복잡한 보상 트랜잭션·child workflow가 얽힌 정말 험한 장기 오케스트레이션이 있다. 재시도/타임아웃 설정을 세밀하게 통제해야 한다. 조직에 이미 Postgres·ES 운영 역량과 SRE가 있다. 혹은 그냥 Temporal Cloud에 돈을 내고 클러스터 운영을 아웃소싱할 수 있다(규모 있게 갈 거라면 보통 이게 합리적이다).

국내 환경 도입 팁

  • EKS: Temporal은 History/Matching/Frontend를 별도 Deployment + HPA로 잡고, 내 워커는 태스크 큐별로 분리해 스케일 축을 나눈다. RDS(Postgres) 파라미터·커넥션 수 산정을 초기에 해두지 않으면 워커 스케일아웃이 곧 DB 커넥션 고갈로 이어진다. Restate는 상태를 로컬 디스크에 두므로 StatefulSet + 적절한 PVC(가급적 gp3 이상), 스냅샷용 S3 버킷과 IRSA 권한이 세트다.
  • 온프레미스: 오브젝트 스토리지가 없으면 Restate HA 전제가 흔들린다. MinIO 같은 S3 호환 스토리지를 함께 검토하고, 지원 여부는 공식 문서로 확인하자.
  • 마이그레이션: 기존 사가 테이블을 한 번에 걷어내지 말 것. 신규 주문 흐름만 durable execution으로 받고 기존 건은 옛 워커가 소진시키는 이중 운영 기간을 두는 게 안전하다. 그리고 어느 쪽을 택하든 워크플로 히스토리/저널 보존 기간과 스토리지 증가율을 도입 첫 주에 측정해 두자. 이걸 놓치면 반년 뒤 디스크가 먼저 알려준다.

참고 자료

728x90
728x90

1. 무슨 일이 있었나 — 그리고 왜 남 얘기가 아닌가

GitHub Actions와 GitHub Pages가 6시간 넘게 대규모 장애·성능 저하를 겪었다. 증상은 대략 이렇게 정리된다.

  • 워크플로우 실행 실패와 지연, 큐 적체
  • 웹훅 처리량이 정상 대비 약 15% 수준까지 떨어져 push/PR 기반 CI 트리거 자체가 안 걸림
  • 자체 호스팅(셀프호스티드) 러너까지 작업 할당이 마비

세 번째 항목이 핵심이다. "우리는 러너를 우리 EC2/온프렘에 띄워놨으니 GitHub 죽어도 빌드는 돌겠지"라고 믿고 있던 팀들이 그날 같이 멈췄다. 나도 예전 팀에서 비용 절감 목적으로 셀프호스티드로 옮기면서 은근히 "가용성도 같이 좋아지겠네"라고 생각했었는데, 그건 착각이었다.

Hacker News 논의에서 나온 배경 설명도 흥미롭다. AI 코딩 에이전트의 자동 커밋·PR·백그라운드 폴링이 폭증하면서 시스템 용량 한계를 넘었다는 분석, 그리고 GitHub COO 발언 인용으로 Actions 주간 사용량이 2023년 5억 분에서 2026년 21억 분으로 늘었다는 수치가 언급됐다. Azure 인프라 이관 과정의 병목과 컨트롤 플레인 집중 구조에 대한 비판도 함께 나왔다. 다만 이건 커뮤니티 분석이고, 공식 포스트모템 기준의 근본 원인은 GitHub 상태 페이지/인시던트 리포트 확인이 필요하다.

어쨌든 실무자 입장에서 결론은 하나다. CI/CD가 외부 SaaS 단일 컨트롤 플레인에 물려 있으면, 그건 우리 배포 파이프라인의 SPOF다. 이 글에서는 왜 셀프호스티드 러너까지 죽는지 구조를 뜯어보고, 우리 파이프라인의 SPOF를 진단하는 방법과 장애 중에도 배포를 이어가는 현실적 우회 전략을 정리한다.

2. 셀프호스티드 러너가 같이 죽는 이유 — 컨트롤 플레인 의존 구조

오해부터 풀자. 셀프호스티드 러너는 "내 서버에서 도는 빌드 머신"이지 "독립적인 CI 시스템"이 아니다. 러너 프로세스가 하는 일을 순서대로 보면 이렇다.

  1. 러너가 GitHub의 Actions 서비스에 롱폴링으로 붙어서 대기한다 (아웃바운드 HTTPS)
  2. 이벤트(push/PR/schedule)가 발생하면 GitHub 쪽 스케줄러가 잡을 큐에 넣고 러너에 할당한다
  3. 러너가 잡 메시지를 받아 워크플로우 YAML을 해석하고 스텝을 실행한다
  4. 로그 스트림, 스텝 상태, 아티팩트 업로드를 계속 GitHub API로 올린다

1~2번이 전부 GitHub 컨트롤 플레인이다. 비유하자면 셀프호스티드 러너는 내 차고에 세워둔 차인데 시동 키는 본사에 있는 구조다. 차는 멀쩡한데 키 발급 서버가 죽으면 그냥 주차장 장식이다. 웹훅 처리량이 15%로 떨어졌다는 건 애초에 "출발 지시" 자체가 안 나갔다는 뜻이고, 스케줄러가 흔들리면 지시가 나가도 러너에 배정이 안 된다.

러너가 살아 있는지, 컨트롤 플레인과 붙어 있는지는 러너 호스트에서 바로 확인할 수 있다.

# 러너 서비스 상태와 최근 로그
$ sudo systemctl status actions.runner.myorg-myrepo.runner-01
● actions.runner.myorg-myrepo.runner-01.service - GitHub Actions Runner
     Active: active (running) since Tue 2026-02-10 09:12:03 KST; 3h 41min ago

$ tail -n 5 ~/actions-runner/_diag/Runner_*.log
[INFO] Listening for Jobs
[WARN] Retrying connection to the server. Attempt 3
[ERR ] GitHub Actions is temporarily unavailable. Retrying in 30 seconds.

여기서 Listening for Jobs가 찍혀 있는데도 잡이 안 내려오면 러너 문제가 아니라 상류 문제다. 이때 러너를 재시작하는 건 아무 소용이 없다(오히려 등록 API까지 흔들리면 재등록이 안 돼서 상황이 더 나빠진다). 장애 중 흔한 자해 행위 1순위가 "일단 러너 재시작"이다.

상태를 스크립트로 물어볼 때는 GitHub 상태 API를 쓰는 게 정확하다.

$ curl -s https://www.githubstatus.com/api/v2/components.json \
  | jq -r '.components[] | select(.name|test("Actions|Pages|Webhooks")) 
           | "\(.name)\t\(.status)"'
Actions         major_outage
Webhooks        degraded_performance
Pages           partial_outage

이 한 줄을 슬랙 알림에 붙여두면, 장애 때 "우리 코드가 문제인가?"로 낭비하는 30분을 아낄 수 있다. 실제로 장애 초기에 팀 채널에 가장 많이 올라오는 질문이 "제 PR만 안 도는 건가요?"다.

3. 우리 파이프라인의 SPOF 진단과 내구성 설계

3-1. SPOF 체크리스트

배포 경로를 단계별로 쪼개서, 각 단계가 GitHub에 얼마나 물려 있는지 표로 그려보길 권한다. 실제로 그려보면 생각보다 GitHub 의존이 촘촘하다.

  • 트리거: push/PR 웹훅. → GitHub 의존 100%. 웹훅이 안 오면 아무것도 시작 안 됨
  • 소스: actions/checkout이 github.com에서 clone. → 미러 레포가 없으면 의존 100%
  • 빌드 캐시: actions/cache는 GitHub 캐시 서비스. → 캐시 미스 시 빌드 시간 폭증
  • 아티팩트: upload/download-artifact, GHCR(ghcr.io). → GHCR도 GitHub 도메인
  • 시크릿: Repository/Org Secrets. → 여기가 진짜 위험. GitHub이 죽으면 수동 배포 시 시크릿 값을 꺼낼 방법이 없다
  • 배포 승인: Environments의 required reviewers. → GitHub UI 의존

이 중 우선순위를 딱 하나만 고르라면 시크릿이다. 빌드는 로컬에서 어떻게든 돌리지만, DB 접속 정보나 배포 키가 GitHub Secrets에만 있으면 긴급 배포 자체가 불가능하다. Secrets는 쓰기 전용이라 UI에서 값을 다시 볼 수도 없다. Vault, AWS Secrets Manager, GCP Secret Manager 중 아무거나 SoT(Source of Truth)로 두고 GitHub Secrets는 거기서 동기화된 사본으로 취급하는 게 맞다.

3-2. 아티팩트·레지스트리 이중화

컨테이너 이미지를 GHCR에만 올리고 있다면, 롤백조차 못 하는 상황이 생긴다. 푸시를 두 곳으로 나누는 건 몇 줄이면 된다.

- name: Build and push (multi-registry)
  uses: docker/build-push-action@v6
  with:
    push: true
    tags: |
      ghcr.io/${{ github.repository }}:${{ github.sha }}
      ${{ secrets.ECR_REGISTRY }}/myapp:${{ github.sha }}

비용은 스토리지 두 벌 + 푸시 트래픽 정도. 롤백 가능성을 사는 값으로는 싸다. 여기서 트레이드오프는 태그 정합성인데, 한쪽 푸시만 성공하고 다른 쪽이 실패하는 부분 성공 케이스를 어떻게 처리할지 정해둬야 한다. 우리는 "ECR 푸시 실패는 파이프라인 실패"로 잡고, 대신 재시도 3회를 걸어뒀다.

3-3. 백업 파이프라인은 "존재"가 아니라 "훈련"이 중요하다

GitLab CI나 Jenkins를 백업으로 두는 팀은 꽤 있는데, 1년에 한 번도 안 돌려본 백업 파이프라인은 장애 때 반드시 깨져 있다. 크리덴셜 만료, 러너 이미지 노후화, 빌드 스크립트 드리프트가 쌓인다. 최소한 주 1회 스케줄로 백업 파이프라인에서 빌드까지만이라도 돌려 녹슬지 않게 해야 한다. 배포까지는 안 해도 된다. 빌드가 성공하는지만 보면 드리프트의 90%는 잡힌다.

원문에서 언급된 Forgejo, GitLab, Woodpecker CI, Depot 같은 대안 이관 움직임도 같은 맥락이다. 다만 전면 이관은 러너 관리·시크릿·권한 체계를 통째로 옮기는 일이라 만만치 않다. 현실적으로는 완전 이관보다 핵심 서비스만 이중 파이프라인이 비용 대비 효율이 좋다고 본다.

3-4. 흔한 함정 — 실제로 만나는 에러들

함정 1: 재시도를 걸었는데 큐만 더 막는다. 장애 중 if: failure()로 재실행을 자동화해두면, 복구 직후 몰린 잡이 한꺼번에 터지면서 동시성 한도를 넘긴다. 그때 나오는 에러가 이거다.

The job was not started because the runner group does not have 
enough capacity. Waiting for a runner to pick up this job...

##[error]The self-hosted runner: runner-01 lost communication with the server. 
Verify the machine is running and has a healthy network connection. 
Anything in your workflow that terminates the runner process, 
starves it for CPU/Memory, or blocks its network access can cause this error.

lost communication with the server 메시지가 특히 헷갈린다. 문구만 보면 우리 머신 네트워크 문제 같은데, 컨트롤 플레인 장애 때도 똑같이 뜬다. 러너 호스트에서 ping, curl https://api.github.com이 멀쩡한지부터 확인하고, 멀쩡하면 상류 장애로 판단하고 손을 떼는 게 낫다.

함정 2: 웹훅 유실은 조용히 지나간다. 웹훅 처리량이 15%로 떨어졌다는 건, 나머지 85%의 push가 실패 표시도 없이 그냥 CI가 안 돈다는 뜻이다. PR 화면에 체크가 안 뜨는 게 아니라 아예 아무 체크도 안 붙는다. 브랜치 보호에서 required status check을 걸어놨다면 머지가 전부 막히고, 안 걸어놨다면 테스트 없이 머지된 커밋이 흘러 들어간다. 후자가 더 무섭다. 복구 후에 반드시 해당 시간대 머지 커밋을 훑고 워크플로우를 수동 재실행해야 한다.

# 장애 시간대 이후 커밋에 워크플로우 수동 재실행
$ gh run list --branch main --limit 5
STATUS  NAME       WORKFLOW  BRANCH  EVENT  ID
X       fix: cache CI        main    push   1029384756
-       chore: bump CI       main    push   1029384700

$ gh workflow run ci.yml --ref main -f reason="post-incident replay"
✓ Created workflow_dispatch event for ci.yml at main

이걸 하려면 워크플로우에 workflow_dispatch: 트리거가 미리 들어 있어야 한다. 없으면 장애 중에 트리거를 추가하는 커밋을 푸시해야 하는데, 그 푸시의 웹훅도 안 갈 수 있다. 지금 당장 모든 배포 워크플로우에 workflow_dispatch를 넣어두자. 비용 0, 효과 확실.

함정 3: Pages를 상태 페이지로 쓰고 있다. 의외로 많다. 서비스 장애 공지 페이지를 GitHub Pages로 호스팅해두면, 이번처럼 Actions와 Pages가 동시에 죽을 때 공지를 띄울 수단이 사라진다. 상태 페이지는 반드시 본 서비스와 다른 사업자에 둬야 한다.

3-5. 장애 중 배포를 이어가는 현실적 우회

순서를 정해두면 당황하지 않는다. 우리 팀 런북은 대략 이 순서다.

  1. 판단(5분): githubstatus API 확인 → 상류 장애면 개별 디버깅 중단, 채널 공지
  2. 동결: 급하지 않은 배포는 전부 홀드. 장애 중 배포는 롤백 수단도 같이 죽어 있다는 걸 잊지 말 것
  3. 핫픽스만 우회: 이미 레지스트리에 올라간 이미지가 있으면 그 태그로 배포. 없으면 로컬 빌드 → 백업 레지스트리 푸시 → 배포 도구(ArgoCD/Helm 등)로 직접 배포
  4. 기록: 우회 배포한 커밋 SHA와 이미지 태그를 채널에 남긴다. 복구 후 정규 파이프라인과 상태를 맞추기 위한 필수 정보다
  5. 복구 후 정합성 확인: 우회 배포분을 정규 파이프라인으로 재빌드해 SHA가 일치하는지 검증

3번에서 로컬 빌드로 배포할 때 가장 자주 사고 나는 게 재현성이다. 평소 CI 러너 이미지와 로컬 환경이 다르면 결과물이 달라진다. 그래서 빌드 환경을 컨테이너로 고정해두는 게 장애 때 빛을 본다. docker run --rm -v $PWD:/src build-env:pinned make build 정도로 CI와 동일한 이미지에서 빌드할 수 있게 만들어두자.

5번의 정합성 확인을 빼먹으면, 몇 주 뒤에 "왜 프로덕션 이미지가 main 브랜치와 다르지?"라는 미스터리로 돌아온다. 실제로 겪어봤고, 원인 찾는 데 하루 날렸다.

4. 정리 — 한 줄 요약과 적용 우선순위

한 줄 요약: 셀프호스티드 러너는 데이터 플레인만 우리 것이고 컨트롤 플레인은 여전히 GitHub이다. 그래서 GitHub이 죽으면 우리 러너도 같이 죽는다.

전면 이중화를 당장 하라는 얘기는 아니다. 팀 규모와 배포 빈도에 따라 필요한 수준이 다르다. 투자 대비 효과 순으로 정리하면 이렇다.

  • 모든 팀 — 오늘 당장(1시간 내): 배포 워크플로우에 workflow_dispatch 추가, githubstatus API 슬랙 알림, 상태 페이지를 Pages 밖으로 이전
  • 배포가 주 1회 이상인 팀 — 이번 스프린트: 시크릿 SoT를 외부 시크릿 매니저로 이전, 컨테이너 레지스트리 이중 푸시, 장애 런북 문서화
  • 배포 중단이 곧 매출 손실인 팀 — 분기 과제: 백업 파이프라인 구축 + 주 1회 자동 검증, 빌드 환경 컨테이너 고정, 소스 미러 레포

반대로 안 해도 되는 것도 짚자. 사내 배포용 툴이나 주 1회 미만 배포하는 레포까지 이중화하는 건 관리 비용만 늘린다. "6시간 멈춰도 되는 것"과 "30분도 안 되는 것"을 먼저 분류하는 게 첫 작업이다.

그리고 이번 장애의 배경으로 지목된 AI 에이전트발 트래픽 폭증은, 사실이라면 앞으로도 반복될 가능성이 높은 종류의 문제다. 우리 조직도 Copilot이나 각종 에이전트를 붙이면서 CI 실행 횟수가 알게 모르게 늘고 있는지, 한 번쯤 사용량을 뽑아볼 만하다. 남의 부하 문제가 아니라 우리도 그 부하의 일부일 수 있다.

참고 자료

728x90
728x90

이번에 고른 글은 코드 한 줄 없는 글이다. Stephen Wolfram(Mathematica·Wolfram Language를 만든 그 사람)이 아내 Elise Cawley를 추모하며 쓴 글이고, 제목에 1961–202636 Wonderful Years가 들어가 있다. Hacker News 상단에 올라와 있었다.

먼저 분명히 해둘 것: 원문 본문을 가져오지 못했다. 그래서 글에 어떤 이야기가 담겼는지, 어떤 문장이 인상적이었는지는 쓰지 않는다. 개인의 죽음과 가족사를 추측으로 채우는 건 하면 안 되는 일이다. 내용은 원문을 직접 읽는 쪽을 권한다. 세부 사실은 공식 게시물 확인 필요.

도입: 부고를 읽고 나서 인프라 담당자가 떠올린 질문

이런 글이 HN에 오르면 댓글창은 대체로 애도로 채워지지만, 나는 매번 직업병처럼 다른 생각이 든다. 사람이 갑자기 없어졌을 때, 그 사람에게만 묶여 있던 것들은 어떻게 되는가.

거창한 이야기가 아니다. 5년 굴려보면 이런 일은 생각보다 자주 만난다.

  • 퇴사자 GPG 키로만 암호화된 secrets.enc.yaml
  • 개인 계정 MFA에 묶인 AWS 루트, 도메인 레지스트라, 앱스토어 계정
  • 10년 전에 한 사람이 짜고 아무도 안 건드린 배치 스크립트
  • 사내 위키에 없고 그 사람 머릿속에만 있던 "장애 나면 이거 먼저 확인" 순서

퇴사는 최소한 예고가 있다. 사고나 병은 없다. 재해복구 계획에 리전 장애는 있는데 사람 장애는 없는 조직이 대다수다. 그래서 이 글은 부고에 대한 감상문이 아니라, 그걸 계기로 실제로 돌려본 점검 절차 정리다.

핵심: 버스 팩터는 "지식"이 아니라 "복호화 가능성"의 문제다

버스 팩터(bus factor)는 보통 "몇 명이 빠지면 프로젝트가 멈추나"로 설명된다. 실무에서 더 정확한 정의는 이렇다.

어떤 자산에 대해, 접근 경로가 단 하나의 사람 계정에만 존재하는가.

지식은 그래도 시간을 들이면 복원된다. 코드를 읽고, 로그를 뒤지고, 재구성한다. 복원 불가능한 건 암호학적으로 잠긴 것이다. 비유하면, 창고 위치를 모르는 건 며칠 걸리는 문제고 창고 열쇠가 세상에 한 개뿐이었던 건 영구 손실이다.

그래서 우선순위는 이 순서가 맞다.

  1. 복구 불가: 단일 GPG/age 키로 암호화된 시크릿, 개인 TOTP에만 걸린 루트 계정, 개인 소유 도메인
  2. 복구 비싸다: 문서 없는 수동 운영 절차, 로컬에만 있는 terraform state
  3. 복구 가능: 코드 이해, 아키텍처 파악

1번부터 손대야 하는데, 실제로는 3번(문서화 스프린트)만 하고 끝내는 조직을 너무 많이 봤다.

실무 관점: 30분이면 돌려보는 점검 3종과 흔한 함정

1) 리포지토리 단독 소유자 스캔

파일별로 커밋한 사람이 한 명뿐인 경로를 뽑는다. 문서화 우선순위 정하는 데 이만한 근거가 없다.

#!/usr/bin/env bash
# bus-factor.sh — 최근 3년간 커밋 저자가 1명뿐인 파일 목록
git ls-files | while read -r f; do
  n=$(git log --since="3 years ago" --format='%ae' -- "$f" | sort -u | wc -l)
  [ "$n" -eq 1 ] && printf '%s\t%s\n' \
    "$(git log -1 --format='%ae' -- "$f")" "$f"
done | sort | uniq -c | sort -rn | head
$ ./bus-factor.sh
     47 kim@example.com   deploy/legacy-batch/
     12 kim@example.com   scripts/db-failover.sh
      9 park@example.com  terraform/modules/vpn/
$ # kim 한 사람이 68개 파일의 유일한 저자 → 여기부터 페어링

주의: uniq -c로 묶으려면 경로를 디렉터리 단위로 자르는 게 실용적이다. 위 출력은 형태 예시로, 숫자는 각자 리포에 따라 완전히 달라진다.

2) 시크릿 복호화 권한자 확인

SOPS를 쓴다면 암호화 파일 메타데이터에 수신자 목록이 그대로 박혀 있다. 이걸 안 보고 넘어가는 팀이 정말 많다.

$ grep -A4 'pgp:' secrets/prod.enc.yaml | grep fp
    fp: 9A1B2C3D4E5F60718293A4B5C6D7E8F901234567

$ gpg --list-keys 9A1B2C3D4E5F60718293A4B5C6D7E8F901234567
pub   rsa4096 2019-04-02 [SC] [expired: 2024-04-01]
      9A1B2C3D4E5F60718293A4B5C6D7E8F901234567
uid           [ expired] Former Dev <kim@example.com>

수신자가 한 명이거나, 만료됐거나, 이미 퇴사자면 그 파일은 사실상 시한폭탄이다. 이럴 때 CI에서 튀어나오는 게 이 에러다.

$ sops -d secrets/prod.enc.yaml
Failed to get the data key required to decrypt the SOPS file.

Group 0: FAILED
  9A1B2C3D4E5F60718293A4B5C6D7E8F901234567: FAILED
    - | could not decrypt data key with PGP key:
      | github.com/ProtonMail/go-crypto/openpgp error: Could not
      | load secring: open /home/runner/.gnupg/secring.gpg: no
      | such file or directory

Recovery failed because no master key was able to decrypt the
file. In order for SOPS to recover the file, at least one key
has to be successful, but none were.

이 메시지를 배포 직전에 처음 보면 그날 릴리스는 끝난 거다. 해결은 결국 수신자를 늘려두는 것뿐이다. 개인 키 대신 KMS/Vault 같은 서비스 키를 1차로 두고, 개인 키는 보조로만 넣는 구성을 권한다.

$ sops updatekeys secrets/prod.enc.yaml   # .sops.yaml 규칙 반영
$ sops -d secrets/prod.enc.yaml | head -1
db_password: ENC-was-here

3) 개인에게 묶인 클라우드 접근 경로

$ aws iam generate-credential-report >/dev/null
$ aws iam get-credential-report --query Content --output text \
  | base64 -d | awk -F, 'NR==1||$4=="false"||$8=="true"' \
  | cut -d, -f1,4,8,9 | column -t -s,
user            mfa_active  access_key_1_active  access_key_1_last_rotated
<root_account>  true        false                N/A
kim             false       true                 2021-03-11T04:22:00+00:00
ci-deployer     false       true                 2025-06-02T11:03:00+00:00

여기서 kim처럼 MFA 없고 4년 넘게 안 돌린 키가 나오면 보안 문제이면서 동시에 승계 문제다. 컬럼 인덱스는 credential report 포맷에 따라 다르니 base64 -d | head -1로 헤더 먼저 확인하는 게 안전하다. 정확한 필드 순서는 AWS 공식 문서 확인 필요.

트레이드오프와 대안

여기서 늘 부딪히는 게 승계 가능성 vs 최소 권한이다. 아무나 프로덕션 시크릿을 열 수 있게 만들면 그건 다른 종류의 사고다. 현실적인 타협안 세 가지:

  • break-glass 계정: 평소엔 잠겨 있고 사용 시 알림이 터지는 비상 계정. TOTP 시드를 종이로 인쇄해 금고에 넣는 방식이 여전히 유효하다. 대신 연 1회 실제로 열어보는 훈련이 없으면 무용지물이다.
  • 키 분할: Vault unseal key처럼 Shamir 방식으로 나눠 3명 중 2명이 모이면 복구. 운영 부담이 커서 정말 최상위 자산에만 쓴다.
  • 소유권 대장: 도메인, 결제 수단, SaaS 관리자, 코드 서명 인증서를 표 하나로 관리. 지루하지만 투자 대비 효과가 가장 크다. 도메인은 whois example.com | grep -i expir 정도만 크론에 걸어둬도 최악은 피한다.

흔한 함정 하나 더. 계정을 즉시 비활성화했다가 그 계정이 소유하던 리소스가 같이 죽는 케이스다. Terraform state를 개인 버킷에 두고 있었으면 이런 걸 보게 된다.

$ terraform plan
Error: Failed to get existing workspaces: Unable to list objects in
S3 bucket "kim-tfstate-2020" with prefix "env:/": operation error S3:
ListObjectsV2, https response error StatusCode: 403, api error
AccessDenied: Access Denied

그래서 오프보딩 순서는 "삭제"가 아니라 정지 → 소유 리소스 이전 → 삭제다. 계정을 비활성화하되 30~90일 보존하는 유예 기간을 정책으로 박아두는 게 낫다.

정리

한 줄 요약: 재해복구 시나리오에 "핵심 인력 한 명이 오늘부로 영구히 부재" 항목을 넣고, 지식보다 암호학적 단일 소유권을 먼저 없애라.

언제 해야 하나. 팀이 5명을 넘어가고 프로덕션 시크릿이 생긴 순간부터다. 이미 늦은 팀이라면 위 3종(단독 저자 스캔 / SOPS 수신자 점검 / MFA·오래된 키 리포트)만 이번 주에 돌려봐도 대체로 한두 개는 걸린다. 걸리면 그게 당신 조직의 진짜 SPOF다.

그리고 이 글의 출발점이 된 원문은 기술 문서가 아니다. 링크는 아래에 두었고, 애도를 전한다.

참고 자료

728x90

+ Recent posts