<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>개발하는 TeEm0</title>
    <link>https://teem0.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Thu, 16 Jul 2026 14:36:29 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>TeEm0</managingEditor>
    <image>
      <title>개발하는 TeEm0</title>
      <url>https://tistory1.daumcdn.net/tistory/4430435/attach/43d37f02f5eb4eceba26a6c3132c283f</url>
      <link>https://teem0.tistory.com</link>
    </image>
    <item>
      <title>Cloudflare Precursor: 체크포인트 봇 탐지를 버리고 전체 세션 행동을 스트림으로 보는 이유</title>
      <link>https://teem0.tistory.com/92</link>
      <description>&lt;p&gt;실무에서 봇 방어를 붙여본 사람이라면 다들 겪는 순간이 있다. 로그인·가입·결제 지점에 CAPTCHA나 Turnstile을 걸어놨는데, 어느 날 로그가 이상해진다. Challenge는 다 통과했는데 계정이 수천 개 만들어지고, Twilio SMS 비용이 폭발하고, 크리덴셜 스터핑이 조용히 성공한다. &quot;분명히 검증 지점은 다 막았는데 왜?&quot; 이 질문에 대한 Cloudflare의 답이 Precursor다.&lt;/p&gt;

&lt;h2&gt;1. 도입: 왜 지금 화제이고 어떤 문제를 푸는가&lt;/h2&gt;

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

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

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

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

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

&lt;h2&gt;2. 핵심: 동작 원리를 예시로&lt;/h2&gt;

&lt;h3&gt;2-1. 전체 세션을 연속 스트림으로 본다&lt;/h3&gt;

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

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

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

&lt;h3&gt;2-2. 왜 마우스 움직임이 신호가 되는가&lt;/h3&gt;

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

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

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

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

&lt;pre&gt;&lt;code&gt;# 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 {
        &quot;speed_std&quot;: round(float(np.std(speeds)), 3),
        &quot;turn_mean&quot;: 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(&quot;bot   :&quot;, path_features(bot))
print(&quot;human :&quot;, path_features(human))
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;실행 결과:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;$ python3 mouse_signal_demo.py
bot   : {'speed_std': 0.0, 'turn_mean': 0.0}
human : {'speed_std': 1.834, 'turn_mean': 0.2216}
&lt;/code&gt;&lt;/pre&gt;

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

&lt;h3&gt;2-3. 교차 검증 — 단일 이벤트에 의존하지 않는다&lt;/h3&gt;

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

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

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

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

&lt;h3&gt;3-1. 어떻게 켜는가&lt;/h3&gt;

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

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

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

&lt;h3&gt;3-2. 관찰 모드로 시작하는 이유 — 실제 검증 명령&lt;/h3&gt;

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

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

$ curl -sI https://example.com/ | grep -i -E &quot;cf-ray|x-bot-score|server&quot;
server: cloudflare
cf-ray: 8a3f1c2d9e7b4a21-ICN
x-bot-score: 1
&lt;/code&gt;&lt;/pre&gt;

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

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

&lt;pre&gt;&lt;code&gt;$ curl -s https://example.com/ | grep -o 'cloudflare[^&quot;]*\.js' | head
cdn-cgi/challenge-platform/scripts/jsd/main.js
&lt;/code&gt;&lt;/pre&gt;

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

&lt;h3&gt;3-3. 흔한 함정 — 접근성 사용자와 오탐&lt;/h3&gt;

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

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

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

&lt;pre&gt;&lt;code&gt;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 &amp;amp; security by Cloudflare
&lt;/code&gt;&lt;/pre&gt;

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

&lt;pre&gt;&lt;code&gt;Refused to execute inline script because it violates the following
Content Security Policy directive: &quot;script-src 'self'&quot;.
Either the 'unsafe-inline' keyword, a hash ('sha256-...'), or a
nonce ('nonce-...') is required to enable inline execution.
&lt;/code&gt;&lt;/pre&gt;

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

&lt;h3&gt;3-4. 트레이드오프 정리&lt;/h3&gt;

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

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

&lt;h3&gt;3-5. 대안&lt;/h3&gt;

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

&lt;h2&gt;4. 정리&lt;/h2&gt;

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

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

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

&lt;h2&gt;참고 자료&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://news.hada.io/topic?id=31437&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Precursor: 전체 세션에서 에이전트 행동을 탐지하는 Cloudflare 시스템 (GeekNews)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://blog.cloudflare.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Cloudflare Engineering Blog (원문 출처)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developers.cloudflare.com/bots/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Cloudflare Bot Management 공식 문서&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developers.cloudflare.com/turnstile/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;</description>
      <category>Tech_News</category>
      <category>cloudflare</category>
      <category>precursor</category>
      <author>TeEm0</author>
      <guid isPermaLink="true">https://teem0.tistory.com/92</guid>
      <comments>https://teem0.tistory.com/92#entry92comment</comments>
      <pubDate>Thu, 16 Jul 2026 09:00:05 +0900</pubDate>
    </item>
    <item>
      <title>lobste.rs는 왜 MariaDB를 버리고 SQLite로 갔나 &amp;mdash; DB를 줄여서 얻은 것들</title>
      <link>https://teem0.tistory.com/91</link>
      <description>&lt;p&gt;&quot;DB 서버를 하나 더 붙이자&quot;는 얘기는 많이 들어봤어도 &quot;DB 서버를 아예 없애자&quot;는 얘기는 드물다. lobste.rs가 정확히 후자를 했다. MariaDB VPS를 통째로 내리고 애플리케이션 서버 안의 SQLite 파일 하나로 옮겼다. 결과는 CPU·메모리 사용량 감소, 체감 응답속도 개선, 그리고 VPS 비용 절반.&lt;/p&gt;

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

&lt;h2&gt;1. 왜 MariaDB를 버렸나 — 동기와 배경&lt;/h2&gt;

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

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

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

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

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

&lt;h2&gt;2. SQLite는 프로덕션에서 쓸 수 있나 — 오해와 실제&lt;/h2&gt;

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

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

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

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

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

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

&lt;h2&gt;3. 마이그레이션 과정 — 스키마 변환과 첫 배포 실패&lt;/h2&gt;

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

&lt;h3&gt;첫 배포가 CPU 100% 찍고 롤백된 이유&lt;/h3&gt;

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

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

&lt;p&gt;원인은 결국 두 가지였다.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;대형 테이블에서 두 개의 쿼리가 &lt;strong&gt;전체 테이블 스캔(full table scan)&lt;/strong&gt;을 일으킴&lt;/li&gt;
&lt;li&gt;별도의 &lt;strong&gt;N+1 문제&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

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

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

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

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

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

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

&lt;h3&gt;스키마 변환에서 걸린 호환성 문제&lt;/h3&gt;

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

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

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

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

&lt;h2&gt;4. 운영 결과 — 실제로 뭐가 좋아졌나&lt;/h2&gt;

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

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

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

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

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

&lt;h2&gt;5. SQLite 프로덕션 운영 시 주의할 점 — 흔한 함정&lt;/h2&gt;

&lt;h3&gt;기본 설정으로 쓰면 안 된다&lt;/h3&gt;

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

&lt;pre&gt;&lt;code&gt;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;&lt;/code&gt;&lt;/pre&gt;

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

&lt;h3&gt;흔한 함정: SQLITE_BUSY&lt;/h3&gt;

&lt;p&gt;SQLite를 서버에서 처음 굴리면 거의 반드시 만나는 에러가 이거다.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;SQLite3::BusyException: database is locked (SQLite3::BusyException)&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;또는 로그에 이렇게 뜬다.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;Error: database is locked (5) (SQLITE_BUSY)&lt;/code&gt;&lt;/pre&gt;

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

&lt;ol&gt;
&lt;li&gt;&lt;code&gt;busy_timeout&lt;/code&gt;이 설정 안 됐거나 너무 짧다&lt;/li&gt;
&lt;li&gt;트랜잭션이 지연 모드(deferred)로 시작돼서 잠금 획득 시점이 꼬였다&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;Rails라면 database.yml에서 이렇게 걸 수 있다.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# 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&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;흔한 함정: NVMe와 안 맞는 기본값&lt;/h3&gt;

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

&lt;h3&gt;백업은 어떻게?&lt;/h3&gt;

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

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

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

# 정상이면 출력
ok&lt;/code&gt;&lt;/pre&gt;

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

&lt;h2&gt;6. 정리 — 언제 이 선택이 옳은가&lt;/h2&gt;

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

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

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

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

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

&lt;h2&gt;참고 자료&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://news.hada.io/topic?id=31413&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;lobste.rs, MariaDB에서 SQLite로 전환 (GeekNews)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://lobste.rs/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;lobste.rs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/lobsters/lobsters/issues/539&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;GitHub Issue #539 — 마이그레이션 논의 원본&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://fractaledmind.github.io/2024/04/&quot; target=&quot;_blank&quot; rel=&quot;no</description>
      <category>Tech_News</category>
      <category>db</category>
      <category>lobste.rs</category>
      <category>mariaDB</category>
      <category>SQLite</category>
      <author>TeEm0</author>
      <guid isPermaLink="true">https://teem0.tistory.com/91</guid>
      <comments>https://teem0.tistory.com/91#entry91comment</comments>
      <pubDate>Wed, 15 Jul 2026 09:00:16 +0900</pubDate>
    </item>
    <item>
      <title>Claude Code가 프롬프트도 읽기 전에 33k 토큰을 태우는 이유 &amp;mdash; OpenCode와 실측 비교</title>
      <link>https://teem0.tistory.com/90</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;월말에 Anthropic 사용량 대시보드를 열었다가 &quot;우리가 이걸 이렇게 많이 썼나?&quot; 싶었던 적 있으면 이 글이 도움이 될 거다. Systima에서 Claude Code와 OpenCode를 같은 모델&amp;middot;같은 머신&amp;middot;같은 태스크에 물려놓고, API 경계에서 오가는 요청 페이로드를 전부 뜯어본 실측 리포트를 냈다. 결론부터 말하면 &lt;b&gt;Claude Code는 사용자가 프롬프트를 치기도 전에 약 33,000 토큰을 소비&lt;/b&gt;하고, OpenCode는 약 7,000 토큰이었다. 약 4.7배 차이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 &quot;Claude Code가 비싸다&quot;로 끝날 얘기가 아니다. 이 오버헤드가 어디서 나오는지, 캐싱은 뭘 살려주고 뭘 못 살려주는지, 그리고 실무에서 어떤 셋업일 때 이게 재앙이 되는지를 알아야 도구를 제대로 고를 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 왜 지금 이 얘기가 나오는가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI 코딩 에이전트를 팀에 도입해본 사람이면 알 거다. 처음엔 &quot;토큰? 얼마 안 나오겠지&quot; 하다가, 여러 명이 하루종일 돌리기 시작하면 청구서가 예상 밖으로 튄다. 문제는 &lt;b&gt;토큰 오버헤드가 곧 비용이자 지연시간이자 컨텍스트 예산&lt;/b&gt;이라는 점이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에이전트가 매 턴마다 보내는 건 여러분의 프롬프트만이 아니다. 시스템 프롬프트, 툴 스키마(도구 정의 JSON), 그리고 각종 주입된 스캐폴딩이 앞에 붙는다. 이 베이스라인은 &lt;b&gt;매 요청마다 다시 전송되거나 캐시에서 다시 읽힌다&lt;/b&gt;. 즉 33k 토큰짜리 베이스라인이면, 200k 컨텍스트 윈도우의 약 1/6을 코드 한 줄 넣기도 전에 이미 잡아먹고 시작하는 셈이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 EU AI Act Article 12처럼 에이전트 동작을 로깅하고 설명할 수 있어야 하는 규제 환경이라면, &quot;내 에이전트가 실제로 뭘 보내는가&quot;를 소문이 아니라 데이터로 답할 수 있어야 한다. 이 리포트가 딱 그걸 한 거다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 33k는 어디서 나오는가 &amp;mdash; 시스템 프롬프트 해부&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Systima는 하네스(harness, 여기선 Claude Code / OpenCode)와 모델 엔드포인트 사이에 로깅 프록시를 끼워넣었다. 구조는 이렇다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;harness (Claude Code / OpenCode)
    &amp;rarr; logging proxy (요청 페이로드 + 응답 usage 캡처)
        &amp;rarr; model endpoint&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프록시는 두 가지를 기록한다. 하네스가 실제로 내보낸 JSON 페이로드(시스템 블록, 툴 스키마, 메시지)와, API가 반환한 usage 블록(input 토큰, cache write, cache read, output 토큰). 페이로드는 &quot;무엇을 보냈나&quot;의 원본 진실, usage는 &quot;무엇이 과금됐나&quot;의 원본 진실이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;T1: &quot;OK라고만 답해&quot; &amp;mdash; 순수 오버헤드 측정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 극단적인 테스트다. 프롬프트는 딱 22자, &quot;Reply with exactly: OK&quot;. 여기에 각 하네스가 뭘 얹어 보냈는지 보자.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse;&quot; border=&quot;1&quot; cellpadding=&quot;6&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구성요소&lt;/th&gt;
&lt;th&gt;Claude Code&lt;/th&gt;
&lt;th&gt;OpenCode&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;시스템 프롬프트&lt;/td&gt;
&lt;td&gt;27,344자 (3블록)&lt;/td&gt;
&lt;td&gt;9,324자 (1블록)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;툴 스키마&lt;/td&gt;
&lt;td&gt;27개 툴, 99,778자&lt;/td&gt;
&lt;td&gt;10개 툴, 20,856자&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;첫 메시지 스캐폴딩&lt;/td&gt;
&lt;td&gt;7,997자 (system-reminder 블록)&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;실제 프롬프트&lt;/td&gt;
&lt;td&gt;22자&lt;/td&gt;
&lt;td&gt;22자&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;첫 턴 페이로드(보정치)&lt;/td&gt;
&lt;td&gt;약 32,800 토큰&lt;/td&gt;
&lt;td&gt;약 6,900 토큰&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 &lt;b&gt;두 하네스 모두 툴 스키마가 지배적&lt;/b&gt;이라는 점이다. Claude Code의 약 33k 중 약 24,000이 툴 정의고, OpenCode의 약 6,900 중 약 4,800이 툴 정의다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 Claude Code가 이렇게 무거운가? 27개 툴이 코딩 코어만이 아니라 백그라운드 에이전트/오케스트레이션 스위트 전체를 포함하기 때문이다. CronCreate, Monitor, Task 패밀리, worktree 관리, 푸시 알림까지 다 들어있다. 게다가 첫 유저 메시지 앞에 세 개의 리마인더 블록(위임용 에이전트 카탈로그, 사용 가능 스킬 카탈로그, 유저 컨텍스트)이 주입된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 OpenCode는 거의 미니멀하다. &quot;You are OpenCode, the best coding agent on the planet&quot;으로 시작하는 시스템 블록 하나, 클래식한 코딩 툴 10개, 그리고 여러분의 프롬프트가 유일한 유저 콘텐츠다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;툴을 다 꺼도 여전히 3배&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;툴을 빼고 시스템 프롬프트 자체만 비교하면, Claude Code는 26,891자(약 6.5k 토큰), OpenCode는 8,811자(약 2.0k 토큰)다. 툴을 전부 제거해도 Claude Code의 명령어 세트가 3배 이상 크다. 나머지는 톤 규칙, 안전 가이드, 태스크 관리 지침, 환경 설명 같은 &quot;행동 강령&quot;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;직접 확인하고 싶다면 툴을 끄고 돌려볼 수 있다.&lt;/p&gt;
&lt;pre class=&quot;vala&quot;&gt;&lt;code&gt;# Claude Code에서 툴 비활성화
claude --tools

# OpenCode에서 툴 비활성화 (설정)
# opencode 설정 JSON에
{ &quot;tools&quot;: { &quot;*&quot;: false } }&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 캐싱은 어디까지 살려주나 &amp;mdash; 여기가 진짜 함정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;어차피 프롬프트 캐싱 걸리니까 괜찮은 거 아니야?&quot;라고 생각하기 쉽다. 여기가 실무에서 제일 많이 착각하는 지점이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OpenCode는 캡처된 모든 실행에서 &lt;b&gt;요청 프리픽스가 바이트 단위로 동일&lt;/b&gt;했다. 세션당 한 번 캐시에 쓰고, 이후엔 몇 푼짜리 cache read로 다시 읽는다. 이상적인 캐시 활용이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 Claude Code는 세션 중간에 &lt;b&gt;수만 토큰의 prompt-cache 토큰을 다시 썼다&lt;/b&gt;. 같은 태스크에서 OpenCode보다 최대 54배 많은 cache write를 기록했다. cache write는 프리미엄 가격으로 과금된다. 대시보드 숫자가 치솟는 주범이 바로 이거다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;왜 다시 쓰는가:&lt;/b&gt; Claude Code는 대화가 진행되면서 system-reminder 블록을 추가로 주입한다. 첫 턴엔 3개, 첫 툴 라운드트립엔 4개로 늘어난다. 스캐폴딩이 턴 수에 따라 자라니까, 프리픽스가 바뀌고 캐시가 깨지는 것으로 보인다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요한 사실 하나. &lt;b&gt;세 가지는 캐시 할인과 무관하게 무조건 스케일한다.&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;첫 턴의 cache write (프리미엄 과금)&lt;/li&gt;
&lt;li&gt;매 턴의 cache read (싸긴 하지만 0은 아님)&lt;/li&gt;
&lt;li&gt;컨텍스트 윈도우 소비 &amp;mdash; &lt;b&gt;이건 어떤 캐시 할인으로도 줄지 않는다&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 번째가 핵심이다. 33k 베이스라인은 매 턴이 200k 윈도우의 1/6 지점에서 시작한다는 뜻이고, 캐싱이 아무리 잘 돼도 이 컨텍스트 압박은 절대 안 줄어든다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;모델을 바꾸면 그림이 달라진다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Systima가 T1을 Claude Fable 5로 재실행했더니 격차가 줄었다. 이유가 흥미롭다. &lt;b&gt;Claude Code의 시스템 프롬프트는 모델 조건부&lt;/b&gt;다. Sonnet에는 27,787자를 보냈지만 Fable에는 10,526자만 보냈고, 툴 스키마도 99,778자에서 82,283자로 줄었다. 같은 27개 툴인데 doctrine이 훨씬 적다. OpenCode 페이로드는 두 모델에서 바이트 단위로 동일했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과적으로 페이로드 기준 격차가 Sonnet에서 4.7배, Fable에서 약 3.3배로 좁혀졌다. 여전히 훨씬 무겁지만, &lt;b&gt;배수는 모델에 따라 다르다&lt;/b&gt;는 점을 기억하자.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 실무 관점 &amp;mdash; 멀티플라이어와 흔한 함정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;T1은 최선의 시나리오다. 현실 세션은 저렇게 린하게 시작해서 짧게 끝나지 않는다. 실무에서 얹히는 레이어들을 보자.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;멀티플라이어 1: 지시 파일 (AGENTS.md / CLAUDE.md)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로덕션 레포의 72KB 지시 파일을 워크스페이스에 넣고 T1을 재실행하니, &lt;b&gt;양쪽 다 요청당 20,000 토큰 이상&lt;/b&gt;이 추가됐다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;OpenCode: 13,152 &amp;rarr; 33,336 토큰&lt;/li&gt;
&lt;li&gt;Claude Code: 39,005 &amp;rarr; 59,243 토큰&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기 첫 번째 함정. &lt;b&gt;Claude Code 2.1.207은 AGENTS.md를 완전히 무시했다.&lt;/b&gt; CLAUDE.md로 이름을 바꿔야만 읽었고, 그것도 시스템 프롬프트가 아니라 첫 유저 메시지에 주입했다. OpenCode는 두 파일명을 다 읽고 시스템 프롬프트에 넣는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 왜 무서운가 하면, &lt;b&gt;무시된 지시 파일은 아무 에러도 안 낸다&lt;/b&gt;. 팀 컨벤션을 열심히 적어놨는데 에이전트가 조용히 무시하고 있는 상황을 상상해보라. 파일명을 잘못 쓰면 이런 식으로 조용히 아무 일도 안 일어난다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;# 실무 체크: 하네스가 어떤 파일명을 실제로 먹는지 확인
$ ls -la
-rw-r--r--  1 dev  staff  73728  AGENTS.md   # Claude Code가 무시함!

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

# 해결: CLAUDE.md로 심볼릭 링크 또는 리네임
$ ln -s AGENTS.md CLAUDE.md
$ ls -la
lrwxr-xr-x  1 dev  staff      9  CLAUDE.md -&amp;gt; AGENTS.md
-rw-r--r--  1 dev  staff  73728  AGENTS.md&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;멀티플라이어 2: MCP 서버&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MCP 서버를 붙이면 스키마가 프롬프트에 추가된다. 스키마는 하네스 간 동일하니 세금도 거의 같다. &lt;b&gt;작은 서버당 요청당 약 1,000~1,400 토큰&lt;/b&gt;. 5개 서버를 붙였더니 Claude Code는 페이로드 기준 4,900 토큰, OpenCode는 metered 6,967 토큰이 늘었고, 툴 개수가 각각 27&amp;rarr;69, 10&amp;rarr;52로 불었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기 두 번째 조용한 함정. &lt;b&gt;Claude Code는 print 모드에서 프로젝트 스코프 &lt;code&gt;.mcp.json&lt;/code&gt;을 조용히 무시&lt;/b&gt;했다. &lt;code&gt;--mcp-config&lt;/code&gt; 플래그를 명시해야 읽었다. 서버가 붙어있다고 가정했는데 실제론 안 붙어있는 상황이 생긴다.&lt;/p&gt;
&lt;pre class=&quot;vala&quot;&gt;&lt;code&gt;# .mcp.json이 있는데도 print 모드에서 서버가 안 잡히는 경우
$ claude -p &quot;list available tools&quot;
# &amp;rarr; MCP 툴이 목록에 안 보임 (에러도 없음)

# 명시적으로 config를 넘겨야 함
$ claude -p &quot;list available tools&quot; --mcp-config .mcp.json
# &amp;rarr; 이제 MCP 서버 툴이 스키마에 포함됨&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공교육용 교훈은 하나다. &lt;b&gt;서버가 붙었다고 가정하지 말고 경계(API boundary)에서 검증하라.&lt;/b&gt; 프록시 로그든 툴 목록이든, 실제 페이로드를 눈으로 확인해야 한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;멀티플라이어 3: 프레임워크 템플릿&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;BMAD 같은 스토리 기반 워크플로우 프레임워크는 슬래시 커맨드를 페르소나&amp;middot;프로토콜&amp;middot;체크리스트로 가득한 큰 템플릿으로 확장한다. 8,405자짜리 대표 템플릿을 T3 프롬프트로 넣어봤더니, 템플릿 자체는 약 2,100 토큰이지만 &lt;b&gt;대화 히스토리에 들어가서 이후 모든 요청이 다시 실어 나른다&lt;/b&gt;. 9-요청 세션이면 9번 재전송된다. 프레임워크 세금 = 템플릿 크기 &amp;times; 요청 수, 그리고 이건 위의 모든 것 위에 쌓인다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;멀티플라이어 4: 서브에이전트 &amp;mdash; 여기서 총액이 폭발&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 제일 무섭다. 작업을 서브에이전트 2개로 팬아웃했더니, &lt;b&gt;직접 하면 121,000 토큰이던 작은 태스크가 513,000 토큰으로 뛰었다&lt;/b&gt;. 서브에이전트마다 자기 부트스트랩 비용이 있고, 부모가 다시 그 트랜스크립트를 소비하기 때문이다. &quot;병렬로 나누면 빠르겠지&quot;라고 서브에이전트를 남발하면 토큰 청구서가 4배 넘게 튀는 걸 각오해야 한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;반전: 멀티스텝에선 Claude Code가 더 쌀 수도 있다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 게 Claude Code 불리로만 흐르는 건 아니다. T3(write-run-test-fix 루프)에서는 예상이 뒤집혔다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse;&quot; border=&quot;1&quot; cellpadding=&quot;6&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;지표&lt;/th&gt;
&lt;th&gt;Claude Code&lt;/th&gt;
&lt;th&gt;OpenCode&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;모델 요청 수&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;9 (+타이틀 콜 1)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;툴 호출 방식&lt;/td&gt;
&lt;td&gt;한 라운드트립에 병렬 배치&lt;/td&gt;
&lt;td&gt;턴당 툴 1개&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;누적 metered input&lt;/td&gt;
&lt;td&gt;약 121,000 토큰&lt;/td&gt;
&lt;td&gt;약 132,000 토큰&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Claude Code는 전체 작업(파일 쓰기 2개 + 스크립트 실행 2개)을 단일 병렬 툴 라운드트립으로 배치했다. OpenCode는 턴당 정확히 툴 하나씩 9번 돌았다. &lt;b&gt;베이스라인은 매 요청마다 재전송되므로 요청 수가 베이스라인을 곱한다.&lt;/b&gt; OpenCode는 약 7k 베이스라인을 9번 냈고, Claude Code는 약 33k를 3번 냈다. 결과적으로 총액이 수렴했다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공식: 전체 작업 input &amp;asymp; 베이스라인 &amp;times; 요청 수 + 대화 성장분. 큰 베이스라인 + 공격적 배칭 하네스와, 작은 베이스라인 + 직렬 하네스가 같은 지점에 착지할 수 있다. &lt;b&gt;미터는 높은 데서 시작하지만, 세션이 어떻게 전개되느냐가 누가 더 쓸지를 결정한다.&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 정리 &amp;mdash; 누가 언제 뭘 써야 하나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 줄 요약: &lt;b&gt;Claude Code는 시작 오버헤드가 크지만 툴 배칭이 강하고, OpenCode는 베이스라인이 가볍지만 턴마다 그 비용을 반복 지불한다.&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;짧고 단순한 반복 작업이 많다면 (커밋 메시지, 소규모 리팩터, 단발성 질의):&lt;/b&gt; OpenCode가 유리하다. 33k를 매번 태울 이유가 없다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;복잡한 멀티스텝 작업을 배치로 처리한다면:&lt;/b&gt; Claude Code의 병렬 배칭이 요청 수를 줄여 총액을 낮출 수 있다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;컨텍스트 윈도우가 빡빡한 대형 코드베이스라면:&lt;/b&gt; 캐시 할인과 무관하게 33k가 윈도우를 잡아먹는다는 걸 기억하라. 여기선 베이스라인이 가벼운 쪽이 실질적으로 유리하다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;규제 환경(EU AI Act 등)이라면:&lt;/b&gt; 로깅 프록시를 경계에 두고 실제 페이로드를 캡처하라. &quot;무엇을 보내는가&quot;를 데이터로 답할 수 있어야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 도입 전 반드시 확인할 것 세 가지: ① 지시 파일 파일명을 하네스가 실제로 먹는지(AGENTS.md vs CLAUDE.md), ② MCP 서버가 경계에서 진짜 붙었는지, ③ 서브에이전트 팬아웃이 정말 필요한지(4배 폭발 각오). 이 셋만 챙겨도 예상 밖 청구서는 크게 줄어든다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로, 이 모든 수치는 특정 버전(Claude Code 2.1.207, OpenCode 1.17.18, 2026년 7월 claude-sonnet-4-5 / claude-fable-5 기준)에서 나온 것이다. 하네스와 모델은 계속 바뀌므로, 여러분 환경에서는 &lt;b&gt;직접 프록시를 끼워 측정하는 게 정답&lt;/b&gt;이다. 소문 말고 데이터로.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고 자료&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://systima.ai/blog/claude-code-vs-opencode-token-overhead&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Claude Code Sends 4.7x More Tokens Than OpenCode Before Reading Your Prompt &amp;mdash; Systima Blog (원문)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://news.ycombinator.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Hacker News (원문 소개 출처)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Tech_News</category>
      <category>Claude</category>
      <category>claude code</category>
      <category>클로드</category>
      <category>클로드 코드</category>
      <author>TeEm0</author>
      <guid isPermaLink="true">https://teem0.tistory.com/90</guid>
      <comments>https://teem0.tistory.com/90#entry90comment</comments>
      <pubDate>Tue, 14 Jul 2026 09:00:58 +0900</pubDate>
    </item>
    <item>
      <title>PgBouncer를 4배 빠르게: so_reuseport 기반 멀티프로세스 풀러 아키텍처 뜯어보기</title>
      <link>https://teem0.tistory.com/89</link>
      <description>&lt;h2&gt;왜 지금 이 얘기가 나왔나&lt;/h2&gt;
&lt;p&gt;PgBouncer 돌려본 사람이면 다 알겠지만, 이 녀석은 PostgreSQL 앞단에 세우는 가장 흔한 커넥션 풀러다. 근데 여기엔 오래된 함정이 하나 있다. &lt;strong&gt;PgBouncer는 단일 스레드다.&lt;/strong&gt; 프로세스 하나가 CPU 코어 하나만 쓴다. 16 vCPU짜리 인스턴스에 올려놔도 실제로는 코어 하나만 죽어라 일하고 나머지 15개는 놀고 있다.&lt;/p&gt;

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

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

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

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

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

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

&lt;pre&gt;&lt;code&gt;[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
so_reuseport = 1

; 트랜잭션 모드로 커넥션 반납을 빠르게
pool_mode = transaction
&lt;/code&gt;&lt;/pre&gt;

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

&lt;pre&gt;&lt;code&gt;$ sudo ss -tlnp | grep 6432
LISTEN 0  128  0.0.0.0:6432  0.0.0.0:*  users:((&quot;pgbouncer&quot;,pid=2841,fd=8))
LISTEN 0  128  0.0.0.0:6432  0.0.0.0:*  users:((&quot;pgbouncer&quot;,pid=2842,fd=8))
LISTEN 0  128  0.0.0.0:6432  0.0.0.0:*  users:((&quot;pgbouncer&quot;,pid=2843,fd=8))
...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이렇게 같은 &lt;code&gt;0.0.0.0:6432&lt;/code&gt;를 여러 PID가 물고 있으면 &lt;code&gt;so_reuseport&lt;/code&gt;가 제대로 걸린 거다. 만약 두 번째 프로세스 띄울 때 아래 에러가 뜨면 옵션이 안 켜진 거다.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;FATAL: cannot bind socket: Address already in use
&lt;/code&gt;&lt;/pre&gt;

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

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

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

&lt;h2&gt;실무 관점: 도입 시 고려사항과 흔한 함정&lt;/h2&gt;

&lt;h3&gt;1. 커넥션 예산(budget)을 반드시 나눠라&lt;/h3&gt;
&lt;p&gt;가장 흔하게 사고 나는 지점이다. 프로세스를 16개 띄웠는데 각 프로세스의 &lt;code&gt;max_client_conn&lt;/code&gt;과 &lt;code&gt;max_db_connections&lt;/code&gt;를 그대로 두면, fleet 전체로는 예산이 16배가 된다. 그러면 Postgres 쪽 &lt;code&gt;max_connections&lt;/code&gt;를 순식간에 초과해서 오버서브스크립션(oversubscription)이 터진다.&lt;/p&gt;

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

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

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

&lt;pre&gt;&lt;code&gt;FATAL: sorry, too many clients already
&lt;/code&gt;&lt;/pre&gt;

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

&lt;pre&gt;&lt;code&gt;FATAL: no more connections allowed (max_client_conn)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 에러가 보이면 두 가지 중 하나다. (1) &lt;code&gt;max_client_conn&lt;/code&gt;이 너무 낮거나, (2) 커넥션을 제때 반납 안 하는 애플리케이션이 있거나. 무작정 값만 올리기 전에 &lt;code&gt;SHOW POOLS;&lt;/code&gt;로 &lt;code&gt;cl_active&lt;/code&gt;와 &lt;code&gt;cl_waiting&lt;/code&gt;부터 확인하는 게 순서다.&lt;/p&gt;

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

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

&lt;h3&gt;4. 언제부터가 fleet 도입 타이밍인가&lt;/h3&gt;
&lt;p&gt;원문 벤치마크 표를 보면 판단 기준이 명확하게 나온다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Clients  Single TPS   Single CPU   Fleet TPS   Fleet CPU
   8        8,910        0.8%        6,450       2.9%
  32       54,203        5.2%       64,244      12.3%
  64       86,570        8.3%      219,439      31.9%
 128       83,463        8.1%      320,547      45.9%
 256       76,893        7.7%      336,469      48.9%
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;주목할 점: &lt;strong&gt;클라이언트 8개일 때는 오히려 단일 프로세스가 더 빠르다.&lt;/strong&gt; 병렬화할 게 없는데 fleet은 연결이 여러 프로세스로 얇게 흩어지니 손해다. 격차가 벌어지는 건 클라이언트 64개부터, 즉 진짜 동시성이 붙어서 코어 하나가 벽이 되는 지점이다.&lt;/p&gt;

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

&lt;pre&gt;&lt;code&gt;$ pidstat -p $(pgrep -f pgbouncer | head -1) 1
Linux 6.x  (pooler-01)   x86_64  (16 CPU)

  UID   PID    %usr  %system  %CPU   CPU  Command
 1001  2841   72.00   25.00   97.00    3  pgbouncer
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이렇게 &lt;code&gt;%CPU&lt;/code&gt;가 97~100%에 계속 붙어있고 인스턴스 전체 사용률은 10% 아래라면, 코어 한 개짜리 벽에 부딪힌 전형적인 신호다.&lt;/p&gt;

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

&lt;h2&gt;정리&lt;/h2&gt;
&lt;p&gt;한 줄 요약: &lt;strong&gt;PgBouncer는 단일 스레드라 코어 하나가 병목이 되며, &lt;code&gt;so_reuseport&lt;/code&gt;로 코어 수만큼 프로세스를 띄우고 커넥션 예산을 나누면 처리량을 최대 4배까지 끌어올릴 수 있다.&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;이걸 써야 하는 사람:&lt;/strong&gt; 16 vCPU 이상 인스턴스에 PgBouncer 올렸는데, Postgres는 여유 있는데 풀러 프로세스가 코어 하나에 붙어 처리량이 막힌 팀.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;아직 안 써도 되는 사람:&lt;/strong&gt; 동시 연결이 수십 개 수준인 소규모 서비스. 오히려 단일 프로세스가 간단하고 미세하게 빠르다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;도입 전 체크리스트:&lt;/strong&gt; (1) &lt;code&gt;so_reuseport = 1&lt;/code&gt; 켜고 &lt;code&gt;ss&lt;/code&gt;로 확인 → (2) &lt;code&gt;max_client_conn&lt;/code&gt;·&lt;code&gt;max_db_connections&lt;/code&gt;를 프로세스 수로 나눴는가 → (3) transaction 모드 전환 시 세션 상태 의존성 점검 → (4) 쿼리 cancel이 fleet 전체에서 동작하는지 검증(직접 구성 시 특히).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;참고 자료&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://clickhouse.com/blog/pgbouncer-clickhouse-managed-postgres&quot;&gt;How we scale PgBouncer in ClickHouse Managed Postgres (ClickHouse Blog, 원문)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.pgbouncer.org/config.html&quot;&gt;PgBouncer 공식 설정 문서 (config.html)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/postgresml/pgcat&quot;&gt;PgCat - 멀티스레드 PostgreSQL 풀러 (대안)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/yandex/odyssey&quot;&gt;Odyssey - Yandex의 멀티스레드 커넥션 풀러 (대안)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Tech_News</category>
      <category>pgbouncer</category>
      <category>PostgreSQL</category>
      <author>TeEm0</author>
      <guid isPermaLink="true">https://teem0.tistory.com/89</guid>
      <comments>https://teem0.tistory.com/89#entry89comment</comments>
      <pubDate>Mon, 13 Jul 2026 09:33:34 +0900</pubDate>
    </item>
    <item>
      <title>ingress-nginx 은퇴, 이제 뭘 써야 하나: Gateway API&amp;middot;Contour&amp;middot;Traefik 실전 마이그레이션</title>
      <link>https://teem0.tistory.com/88</link>
      <description>&lt;h2&gt;1. 왜 지금 이 얘기가 나오는가&lt;/h2&gt;

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

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

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

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

&lt;h2&gt;2. 먼저 우리 클러스터 영향 범위부터 파악하자&lt;/h2&gt;

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

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

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

&lt;p&gt;실제 출력은 이런 식으로 나온다:&lt;/p&gt;

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

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

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

&lt;pre&gt;&lt;code&gt;kubectl get ingress -A -o json \
  | jq -r '.items[].metadata.annotations // {} | keys[]' \
  | grep '^nginx.ingress.kubernetes.io' \
  | sort | uniq -c | sort -rn
&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code&gt;     14 nginx.ingress.kubernetes.io/ssl-redirect
     11 nginx.ingress.kubernetes.io/rewrite-target
      8 nginx.ingress.kubernetes.io/proxy-body-size
      3 nginx.ingress.kubernetes.io/configuration-snippet
      1 nginx.ingress.kubernetes.io/auth-url
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;이 표만 봐도 &quot;snippet 3개, auth 1개만 손보면 나머지는 표준 매핑으로 넘어가겠구나&quot;가 보인다.&lt;/p&gt;

&lt;h2&gt;3. 대안 컨트롤러 비교와 두 갈래 길&lt;/h2&gt;

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

&lt;h3&gt;Path A: Lift-and-Shift — Ingress API 유지하고 컨트롤러만 교체&lt;/h3&gt;

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

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

&lt;h3&gt;Path B: Gateway API로 아키텍처 현대화&lt;/h3&gt;

&lt;p&gt;Gateway API는 원문 표현으로 &quot;Ingress의 upstream-backed 후계자&quot;다. 핵심은 &lt;strong&gt;역할 분리(role-oriented design)&lt;/strong&gt;다.&lt;/p&gt;

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

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;인프라팀&lt;/strong&gt;은 &lt;code&gt;Gateway&lt;/code&gt; 리소스로 &quot;이 건물의 출입구(리스너, 포트, 인증서)&quot;를 관리하고&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;개발팀&lt;/strong&gt;은 &lt;code&gt;HTTPRoute&lt;/code&gt;로 &quot;내 사무실로 가는 복도(경로 라우팅)&quot;를 각자 관리한다.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;비교표 (원문 기준 + 실무 코멘트)&lt;/h3&gt;

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

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

&lt;h2&gt;4. 마이그레이션 아키텍처 설계 전략&lt;/h2&gt;

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

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

&lt;h3&gt;② Tooling — ingress2gateway&lt;/h3&gt;
&lt;p&gt;Gateway API로 갈 거라면 &lt;code&gt;ingress2gateway&lt;/code&gt;로 기존 Ingress를 자동 변환할 수 있다. 완벽하진 않지만 표준 필드는 잘 옮겨준다.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;ingress2gateway print --input-file=web-app-ingress.yaml&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code&gt;apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: web-app-gateway
  namespace: default
spec:
  gatewayClassName: contour
  listeners:
  - name: http
    port: 80
    protocol: HTTP
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: web-app
  namespace: default
spec:
  parentRefs:
  - name: web-app-gateway
  rules:
  - matches:
    - path:
        type: PathPrefix
        value: /
    backendRefs:
    - name: web-app-svc
      port: 80
&lt;/code&gt;&lt;/pre&gt;

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

&lt;h3&gt;③ Incremental Rollout (점진적 전환)&lt;/h3&gt;
&lt;p&gt;원문의 핵심 권고다. &lt;strong&gt;새 컨트롤러를 기존 것과 병렬로 띄우고, 비핵심 워크로드부터 옮긴다.&lt;/strong&gt; ingressClass를 분리하면 두 컨트롤러가 한 클러스터에 공존할 수 있다. 결제 API 같은 건 맨 마지막에 옮긴다.&lt;/p&gt;

&lt;h2&gt;5. 흔한 함정과 실제 에러들&lt;/h2&gt;

&lt;h3&gt;함정 1: ingressClassName 안 걸어서 아무도 안 받음&lt;/h3&gt;
&lt;p&gt;새 컨트롤러 깔았는데 트래픽이 안 온다. Ingress 오브젝트에 &lt;code&gt;ingressClassName&lt;/code&gt;이 비어있거나 옛날 값이면 새 컨트롤러가 무시한다. 병렬 운영 중엔 특히 자주 터진다. 확인:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;kubectl get ingress -A -o custom-columns=NS:.metadata.namespace,NAME:.metadata.name,CLASS:.spec.ingressClassName&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;함정 2: snippet 어노테이션이 그냥 씹힌다&lt;/h3&gt;
&lt;p&gt;Contour나 Traefik으로 옮긴 뒤 로그를 보면 이런 게 뜬다:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;Warning  IngressUpdate  ingress default/web-app: annotation &quot;nginx.ingress.kubernetes.io/configuration-snippet&quot; is not supported by this controller and will be ignored&lt;/code&gt;&lt;/pre&gt;

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

&lt;h3&gt;함정 3: Gateway API CRD 안 깔고 HTTPRoute 적용&lt;/h3&gt;
&lt;p&gt;Gateway API는 CRD를 별도로 설치해야 한다. 안 깔고 매니페스트 적용하면:&lt;/p&gt;

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

&lt;p&gt;CRD부터 설치해야 한다:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.0/standard-install.yaml&lt;/code&gt;&lt;/pre&gt;

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

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

&lt;pre&gt;&lt;code&gt;kubectl describe httproute web-app -n default&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code&gt;Status:
  Parents:
    Conditions:
      Type:     ResolvedRefs
      Status:   False
      Reason:   RefNotPermitted
      Message:  Cross-namespace reference to Gateway is not allowed; create a ReferenceGrant
&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;6. 정리: 누가 어느 길로 가야 하나&lt;/h2&gt;

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

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

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

&lt;h2&gt;참고 자료&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.cncf.io/blog/2026/07/09/navigating-the-ingress-nginx-retirement/&quot;&gt;Navigating the ingress-NGINX retirement — Sunny Chan, TCC Consulting (CNCF Blog)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://gateway-api.sigs.k8s.io/&quot;&gt;Kubernetes Gateway API 공식 문서&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/kubernetes-sigs/ingress2gateway&quot;&gt;ingress2gateway (변환 도구)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://projectcontour.io/&quot;&gt;Project Contour 공식 사이트&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;※ 버전 번호와 명령어 출력은 예시이며, 실제 적용 전 각 프로젝트 공식 릴리스 문서에서 최신 버전과 호환성을 반드시 확인하세요.&lt;/em&gt;&lt;/p&gt;</description>
      <category>Tech_News</category>
      <category>Ingress</category>
      <category>nginx</category>
      <author>TeEm0</author>
      <guid isPermaLink="true">https://teem0.tistory.com/88</guid>
      <comments>https://teem0.tistory.com/88#entry88comment</comments>
      <pubDate>Sat, 11 Jul 2026 09:00:40 +0900</pubDate>
    </item>
    <item>
      <title>TypeScript 7이 Go로 재작성된 진짜 이유와, 우리 빌드 파이프라인에 미칠 영향</title>
      <link>https://teem0.tistory.com/87</link>
      <description>&lt;p&gt;어제 사내 Slack에 &quot;TypeScript 7 정식 나왔다는데 이거 우리 CI 다시 봐야 하는 거 아니냐&quot;는 메시지가 올라왔다. 프론트 팀이 아니라 인프라 팀에서 먼저 반응한 게 인상적이었다. 이유는 간단하다. TypeScript 7은 컴파일러 자체를 Go로 다시 짠 물건이라, 결국 &lt;strong&gt;빌드 시간&lt;/strong&gt;과 &lt;strong&gt;CI 러너 비용&lt;/strong&gt;에 직접 꽂히는 이야기이기 때문이다.&lt;/p&gt;

&lt;p&gt;이 글은 원문(&lt;a href=&quot;https://news.hada.io/topic?id=31242&quot;&gt;TypeScript v7 Released | GeekNews&lt;/a&gt;) 발표 내용을 기준으로, 빌드/CI를 운영하는 입장에서 뭘 확인하고 뭘 조심해야 하는지 정리한 것이다. 벤치마크 수치는 공식 발표 자료를 각자 확인하는 걸 전제로 하고, 이 글에서는 임의로 수치를 지어내지 않는다.&lt;/p&gt;

&lt;h2&gt;1. 도입: 왜 지금 화제이고 어떤 문제를 푸는지&lt;/h2&gt;

&lt;p&gt;기존 TypeScript 컴파일러(&lt;code&gt;tsc&lt;/code&gt;)는 TypeScript/JavaScript로 작성돼 있었다. 즉 &lt;strong&gt;TS로 만든 컴파일러가 TS를 컴파일하는&lt;/strong&gt; 구조였다. 이게 우아하긴 한데, 실무에서 프로젝트가 커지면 아픈 지점이 명확하게 나타난다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;단일 스레드 JS 런타임 위에서 돌기 때문에, 대형 모노레포에서 &lt;code&gt;tsc --build&lt;/code&gt;가 수십 초에서 분 단위로 늘어난다.&lt;/li&gt;
  &lt;li&gt;에디터에서 타입 힌트가 뜨는 데 걸리는 지연(Language Server 응답)이 프로젝트 규모에 비례해 커진다.&lt;/li&gt;
  &lt;li&gt;CI에서 타입 체크 스텝이 전체 파이프라인 병목이 되는 경우가 흔하다.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;TypeScript 7은 이 컴파일러를 Go로 재작성한 버전이다(프로젝트 코드네임은 흔히 &lt;code&gt;tsgo&lt;/code&gt;로 불린다). Go를 택한 핵심 이유는 네이티브 컴파일과 &lt;strong&gt;동시성(goroutine 기반 병렬 처리)&lt;/strong&gt;이다. JS 런타임의 단일 스레드 한계에서 벗어나 파싱·타입 체크 단계를 병렬화할 여지가 생긴다는 게 요지다.&lt;/p&gt;

&lt;p&gt;주의할 점 하나. 원문 댓글에서도 지적됐듯이, &lt;strong&gt;제대로 된 programmatic API는 7.0이 아니라 7.1에 공개될 예정&lt;/strong&gt;이라고 한다. 즉 지금 시점에 &quot;완제품 다 갈아끼우면 된다&quot;가 아니라, API 표면이 아직 흔들리는 과도기라는 점을 전제로 봐야 한다.&lt;/p&gt;

&lt;h2&gt;2. 핵심: 동작 원리를 예시·비유로&lt;/h2&gt;

&lt;p&gt;비유하자면 기존 &lt;code&gt;tsc&lt;/code&gt;는 &quot;한 명의 숙련된 번역가가 문서를 처음부터 끝까지 혼자 번역하는&quot; 방식이었다. Go 재작성 버전은 &quot;번역가 여러 명이 문서를 챕터별로 나눠 동시에 번역하고, 마지막에 취합하는&quot; 방식에 가깝다. 파일 파싱과 타입 검사처럼 병렬화가 가능한 구간을 코어 개수만큼 나눠 처리할 수 있게 된 것이다.&lt;/p&gt;

&lt;p&gt;파이프라인 관점에서 컴파일러가 하는 일은 크게 세 단계다. 이건 기존과 개념적으로 동일하다.&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;파서(Parser)&lt;/strong&gt;: 소스 코드를 AST로 변환&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;타입 체커(Type Checker)&lt;/strong&gt;: AST를 순회하며 타입 검증 — 여기가 가장 무겁다&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;에밋(Emit)&lt;/strong&gt;: 검증된 결과를 JS로 출력(또는 &lt;code&gt;--noEmit&lt;/code&gt;이면 생략)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Go 버전이 노리는 지점은 대부분 2번 타입 체커와 대형 프로젝트의 파일 로딩/파싱 병렬화다. 실제 설치는 별도 패키지로 배포되는 형태다. 실무에서 먼저 해볼 수 있는 건 기존 프로젝트를 건드리지 않고 &lt;strong&gt;병렬로 타입 체크만 돌려보는 것&lt;/strong&gt;이다.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# 기존 프로젝트는 그대로 두고, Go 기반 컴파일러만 별도 설치해서 체크
npm install -D @typescript/native-preview

# 타입 체크만 실행 (에밋 없이)
npx tsgo --noEmit -p tsconfig.json&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;정상적으로 통과하면 출력은 조용하다. 타입 에러가 있으면 기존 &lt;code&gt;tsc&lt;/code&gt;와 유사한 형태로 뜬다.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;src/user/service.ts:42:18 - error TS2322: Type 'string | undefined' is not assignable to type 'string'.
  Type 'undefined' is not assignable to type 'string'.

42     const name: string = user.displayName;
                    ~~~~

Found 1 error in src/user/service.ts:42&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;여기서 확인할 포인트는 두 가지다. 첫째, &lt;strong&gt;기존 &lt;code&gt;tsc&lt;/code&gt;와 진단 결과(에러 개수/코드)가 일치하는가&lt;/strong&gt;. 둘째, 실행 시간이 얼마나 줄어드는가. 시간 비교는 아주 간단하게 붙일 수 있다.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# 기존 tsc
time npx tsc --noEmit -p tsconfig.json

# Go 기반
time npx tsgo --noEmit -p tsconfig.json&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;수치는 프로젝트 규모와 러너 사양에 따라 천차만별이니, 반드시 &lt;strong&gt;본인 레포에서 직접 측정&lt;/strong&gt;하길 권한다. 남의 벤치마크는 참고용일 뿐이다.&lt;/p&gt;

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

&lt;h3&gt;트레이드오프: 빠른 대신 아직 다 갖춰지지 않았다&lt;/h3&gt;

&lt;p&gt;속도는 매력적이지만, 현재 시점에서 무시할 수 없는 트레이드오프가 있다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;programmatic API 미완성&lt;/strong&gt;: 원문에서 언급됐듯 완전한 API는 7.1 예정이다. &lt;code&gt;ts.createProgram()&lt;/code&gt; 같은 API에 직접 의존하는 도구(커스텀 트랜스포머, 코드 생성기 등)는 지금 바로 갈아타기 어렵다.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;생태계 호환성&lt;/strong&gt;: 많은 빌드 도구가 TS의 내부 API나 타입 정보에 의존한다. 대표적으로 아래 도구들은 확인이 필요하다.&lt;/li&gt;
&lt;/ul&gt;

&lt;table border=&quot;1&quot; cellpadding=&quot;6&quot; style=&quot;border-collapse:collapse&quot;&gt;
  &lt;tr&gt;&lt;th&gt;도구&lt;/th&gt;&lt;th&gt;영향 포인트&lt;/th&gt;&lt;/tr&gt;
  &lt;tr&gt;&lt;td&gt;esbuild / swc&lt;/td&gt;&lt;td&gt;원래 타입 체크를 하지 않고 트랜스파일만 함 → 영향 적음. 타입 체크는 별도 스텝&lt;/td&gt;&lt;/tr&gt;
  &lt;tr&gt;&lt;td&gt;ts-node&lt;/td&gt;&lt;td&gt;TS 컴파일러 API 의존 → 새 API 안정화 전까지 호환성 확인 필요&lt;/td&gt;&lt;/tr&gt;
  &lt;tr&gt;&lt;td&gt;webpack (ts-loader)&lt;/td&gt;&lt;td&gt;내부 API 사용 방식에 따라 영향 → 로더 버전 호환 확인&lt;/td&gt;&lt;/tr&gt;
  &lt;tr&gt;&lt;td&gt;ESLint (typescript-eslint)&lt;/td&gt;&lt;td&gt;타입 정보 기반 룰은 파서 연동 방식 확인 필요&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;

&lt;p&gt;여기서 중요한 실무 감각. 상당수 팀은 이미 &lt;strong&gt;트랜스파일(esbuild/swc)과 타입 체크(tsc)를 분리&lt;/strong&gt;해서 운영한다. 이런 구조라면 타입 체크 스텝만 &lt;code&gt;tsgo&lt;/code&gt;로 실험 삼아 바꿔보기가 훨씬 수월하다. 반대로 &lt;code&gt;ts-loader&lt;/code&gt;나 &lt;code&gt;ts-node&lt;/code&gt;로 컴파일과 실행을 통째로 묶어놓은 레거시 구성은 마이그레이션 난이도가 올라간다.&lt;/p&gt;

&lt;h3&gt;흔한 함정 1: tsconfig 옵션 미지원/차이&lt;/h3&gt;

&lt;p&gt;Go 재작성 초기 단계에서는 일부 컴파일러 옵션이나 엣지 케이스 동작이 기존과 다를 수 있다. 알 수 없는 옵션을 만나면 이런 식으로 튕긴다.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;error: Unknown compiler option 'someLegacyOption'.

# 또는 tsconfig 파싱 단계에서
error TS5023: Unknown compiler option '...'.&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;대응: 기존 &lt;code&gt;tsc --noEmit&lt;/code&gt;과 &lt;code&gt;tsgo --noEmit&lt;/code&gt;의 &lt;strong&gt;에러 개수·에러 코드를 diff&lt;/strong&gt;로 비교해서 결과가 갈리는 파일이 있는지 먼저 확인한다. 결과가 다르면 그 파일은 옵션 해석 차이 또는 미지원 케이스일 가능성이 높다.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# 두 결과를 파일로 떨궈서 비교
npx tsc   --noEmit -p tsconfig.json 2&amp;gt; tsc.log   || true
npx tsgo  --noEmit -p tsconfig.json 2&amp;gt; tsgo.log  || true
diff &amp;lt;(sort tsc.log) &amp;lt;(sort tsgo.log)&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;흔한 함정 2: CI 캐시 키를 안 바꿔서 옛날 결과가 통과됨&lt;/h3&gt;

&lt;p&gt;컴파일러를 바꿨는데 CI 캐시 키에 컴파일러 버전이 안 들어가 있으면, 예전 캐시가 적중해서 &quot;실제로는 안 돌았는데 통과한 것처럼&quot; 보이는 사고가 난다. 이건 도구 문제가 아니라 파이프라인 설계 문제인데, 컴파일러 교체 시점에 특히 잘 터진다. 캐시 키에 컴파일러 패키지 버전을 반드시 포함시켜라.&lt;/p&gt;

&lt;h3&gt;CI/CD 마이그레이션은 병렬 검증 스텝으로 시작하라&lt;/h3&gt;

&lt;p&gt;한 번에 갈아엎지 말고, &lt;strong&gt;기존 tsc는 그대로 두고 tsgo를 non-blocking 스텝으로 추가&lt;/strong&gt;하는 게 안전하다. GitHub Actions 예시.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;jobs:
  typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci

      # 기존: 실패하면 파이프라인 중단 (source of truth)
      - name: Type check (tsc)
        run: npx tsc --noEmit -p tsconfig.json

      # 신규: 결과만 관찰, 실패해도 파이프라인 안 막음
      - name: Type check (tsgo, experimental)
        run: npx tsgo --noEmit -p tsconfig.json
        continue-on-error: true&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;이 상태로 몇 주 돌려보면서 두 스텝의 결과가 &lt;strong&gt;지속적으로 일치&lt;/strong&gt;하고 시간 이득이 유의미하면, 그때 &lt;code&gt;tsgo&lt;/code&gt;를 정식 스텝으로 승격하고 &lt;code&gt;tsc&lt;/code&gt;를 내리면 된다. 인프라 관점에서 이런 &quot;그림자 실행(shadow run)&quot; 패턴은 리스크를 크게 줄여준다.&lt;/p&gt;

&lt;h3&gt;대안&lt;/h3&gt;

&lt;p&gt;지금 당장 타입 체크 속도만 급하고 리스크를 최소화하고 싶다면, 컴파일러 교체 대신 다음도 고려할 수 있다.&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;code&gt;tsc --build&lt;/code&gt;의 &lt;strong&gt;프로젝트 레퍼런스 + incremental&lt;/strong&gt; 설정으로 증분 빌드 최적화&lt;/li&gt;
  &lt;li&gt;트랜스파일은 esbuild/swc로 넘기고 타입 체크만 별도 스텝으로 분리(이미 표준적인 구성)&lt;/li&gt;
  &lt;li&gt;모노레포라면 Turborepo/Nx 캐시로 타입 체크 결과 자체를 캐싱&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;4. 정리: 한 줄 요약 + 누가 언제 써야 하는가&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;한 줄 요약:&lt;/strong&gt; TypeScript 7은 Go로 재작성해 속도·병렬성을 노린 컴파일러이고, 지금은 programmatic API가 완성되기 전(7.1 예정)이라 &quot;관찰용으로 붙여보되 소스 오브 트루스는 아직 기존 tsc&quot;로 두는 게 안전하다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;지금 실험해도 좋은 팀:&lt;/strong&gt; 트랜스파일과 타입 체크가 이미 분리돼 있고, CI 타입 체크 시간이 병목인 대형 프로젝트. non-blocking 스텝으로 붙여 비교하기 좋다.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;서두르지 말아야 할 팀:&lt;/strong&gt; ts-node/ts-loader나 커스텀 트랜스포머 등 &lt;strong&gt;컴파일러 내부 API에 강하게 의존&lt;/strong&gt;하는 도구를 쓰는 곳. 7.1 API 안정화 이후를 보는 게 낫다.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;공통 원칙:&lt;/strong&gt; 벤치마크는 남의 걸 믿지 말고 본인 레포에서 &lt;code&gt;time&lt;/code&gt;으로 직접 재라. 에러 개수/코드가 기존과 일치하는지 diff로 검증하는 절차를 반드시 넣어라.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;정리하면, 이건 &quot;버전 올려서 끝&quot;이 아니라 컴파일러 아키텍처가 바뀐 사건이다. 프론트 팀만의 이슈로 넘기지 말고, 빌드 시간과 CI 비용을 보는 인프라/백엔드 입장에서 미리 그림자 실행으로 데이터를 쌓아두면 나중에 전환 결정할 때 근거가 된다.&lt;/p&gt;

&lt;h2&gt;참고 자료&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://news.hada.io/topic?id=31242&quot;&gt;TypeScript v7 Released | GeekNews&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://devblogs.microsoft.com/typescript/&quot;&gt;Microsoft TypeScript Dev Blog (공식 발표 원문 확인)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.npmjs.com/package/@typescript/native-preview&quot;&gt;@typescript/native-preview (npm, 설치명·최신 상태는 직접 확인 필요)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;※ 벤치마크 수치와 세부 옵션 지원 범위는 버전에 따라 달라질 수 있으니, 도입 전 반드시 공식 문서와 본인 레포 측정 결과를 기준으로 판단하기 바란다.&lt;/em&gt;&lt;/p&gt;</description>
      <category>Tech_News</category>
      <category>Pipeline</category>
      <category>typescript</category>
      <category>TypeScript7</category>
      <author>TeEm0</author>
      <guid isPermaLink="true">https://teem0.tistory.com/87</guid>
      <comments>https://teem0.tistory.com/87#entry87comment</comments>
      <pubDate>Fri, 10 Jul 2026 09:00:16 +0900</pubDate>
    </item>
    <item>
      <title>브라우저에서 임베딩 검색을 돌린다고? Ternlight로 보는 WASM 온디바이스 ML의 현실</title>
      <link>https://teem0.tistory.com/86</link>
      <description>&lt;h2&gt;1. 왜 지금 이게 화제인가&lt;/h2&gt;

&lt;p&gt;임베딩 기반 시맨틱 검색을 붙여본 사람이라면 다들 겪는 패턴이 있다. 사용자 입력 → 임베딩 API 호출(OpenAI든 자체 모델이든) → 벡터 DB 조회 → 결과 정렬. 여기서 첫 번째 임베딩 호출이 항상 목에 걸린다. 네트워크 왕복 100~300ms, API 요율 제한, 그리고 검색 트래픽이 늘수록 선형으로 늘어나는 비용. FAQ 매칭이나 문서 검색 같은 &quot;가벼운&quot; 기능 하나 붙이자고 임베딩 서버 인프라를 굴려야 하는 상황이 꽤 흔하다.&lt;/p&gt;

&lt;p&gt;Ternlight는 이 지점을 정면으로 건드린다. 엔진과 가중치를 합쳐 &lt;strong&gt;7MB&lt;/strong&gt;(mini 변형은 5MB), GPU 없이 CPU에서만 돌고, 서버 호출이 아예 없다. 텍스트를 넣으면 브라우저 안에서 384차원 벡터가 나오고, 코사인 유사도로 관련성을 판단한다. 원문 기준 임베딩당 약 5ms(mini는 약 2.5ms)라고 한다. MiniLM에서 작은 문장 인코더를 증류하고, ternary 양자화 인식 학습(quantization-aware training)을 적용한 뒤 추론 엔진을 Rust로 직접 짜서 WASM SIMD로 배포한 구조다.&lt;/p&gt;

&lt;p&gt;인프라 엔지니어 입장에서 이게 흥미로운 이유는 명확하다. &lt;strong&gt;임베딩 서버를 통째로 없앨 수 있는 아키텍처 선택지&lt;/strong&gt;가 생긴다는 것. 다만 &quot;없앨 수 있다&quot;와 &quot;없애야 한다&quot;는 완전히 다른 얘기고, 그 경계를 아는 게 이 글의 핵심이다.&lt;/p&gt;

&lt;h2&gt;2. 동작 원리: 텍스트가 벡터가 되기까지&lt;/h2&gt;

&lt;h3&gt;WASM에서 ML 추론이 가능한 이유&lt;/h3&gt;

&lt;p&gt;먼저 &quot;브라우저에서 모델이 돈다&quot;는 게 마법이 아니라는 점부터. 브라우저는 이제 WASM 바이트코드를 거의 네이티브에 가까운 속도로 실행한다. 여기에 &lt;strong&gt;WASM SIMD&lt;/strong&gt;(단일 명령으로 여러 데이터를 병렬 처리)가 붙으면 벡터·행렬 연산이 크게 빨라진다. 임베딩 모델 추론은 결국 행렬 곱셈 덩어리라서 SIMD가 잘 먹힌다.&lt;/p&gt;

&lt;p&gt;Ternlight가 Rust로 엔진을 직접 짠 이유가 여기 있다고 본다. Transformers.js처럼 ONNX 런타임을 통째로 얹으면 편하지만 번들이 커진다. 필요한 연산만 Rust로 구현하고 WASM SIMD로 컴파일하면 7MB까지 줄일 수 있는 것이다. 여기서 &quot;7MB&quot;는 대부분 양자화된 가중치 무게라고 보는 게 맞다.&lt;/p&gt;

&lt;h3&gt;7MB로 줄이는 핵심: ternary 양자화&lt;/h3&gt;

&lt;p&gt;보통 모델 가중치는 float32(4바이트)로 저장한다. ternary 양자화는 가중치를 -1, 0, +1 세 가지 값으로 압축한다. 정보 손실은 있지만, 양자화 인식 학습으로 그 손실을 학습 단계에서 보정한다. 이게 크기를 극적으로 줄이는 핵심이다.&lt;/p&gt;

&lt;p&gt;HN 댓글에서 나온 mini 버전 분석도 참고할 만하다. mini는 내부적으로 384가 아니라 &lt;strong&gt;256요소 벡터&lt;/strong&gt;를 쓰고, 마지막에 호환성을 위해 384로 투영하는 것으로 보인다. 크기는 1/3 줄지만 정보 손실은 선형적이지 않아서 1/3보다 적어 보인다는 관찰이다. (공식 문서 확인 필요 — 어디까지나 커뮤니티 추정이다.)&lt;/p&gt;

&lt;h3&gt;실제 파이프라인&lt;/h3&gt;

&lt;p&gt;사용 흐름은 정말 단순하다. npm 패키지 하나 설치하고 함수 두 개 가져오면 끝이다.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;npm install @ternlight/base&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code&gt;import { embed, similar } from '@ternlight/base';

const recipes = [
  '10분 안에 만드는 파스타',
  '주말 브런치용 팬케이크 레시피',
  '평일 저녁 간단한 볶음밥',
];

// 쿼리와 후보 배열을 넣으면 상위 K개가 정렬돼서 나온다
const results = similar('easy weeknight dinner ideas', recipes, { topK: 3 });
console.log(results);
// → ranked matches · ~5 ms · zero network&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;여기서 중요한 포인트. &lt;code&gt;similar()&lt;/code&gt;는 내부적으로 쿼리와 후보들을 전부 &lt;code&gt;embed()&lt;/code&gt;해서 코사인 유사도를 계산한다. 즉 &lt;strong&gt;후보가 매번 다시 임베딩된다면&lt;/strong&gt; 후보 개수만큼 임베딩 비용이 든다. React 문서 2천 개를 검색하는 데모가 있는데, 이건 후보 임베딩을 미리 계산해두고 재사용하는 구조여야 실용적이다. 실무에서는 이렇게 나눠 쓴다.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;import { embed, similar } from '@ternlight/base';

// 1. 색인 단계: 문서 임베딩은 한 번만 계산해서 캐시
//    (빌드 타임에 서버에서 돌려 JSON으로 내려도 된다)
const docs = ['문서 A 내용...', '문서 B 내용...', /* ... 2000개 */];
const docEmbeddings = docs.map(d =&gt; embed(d));  // 최초 1회

// 2. 검색 단계: 쿼리만 임베딩하고 미리 만든 벡터와 비교
const queryVec = embed('how to use createContext');
// 코사인 유사도로 상위 K 정렬 (라이브러리 API는 저장소 확인 필요)&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;HN 스레드에서도 &quot;30초 걸리는 임베딩 생성을 서버에서 미리 해두고 벡터만 프런트로 보낼 수 있냐&quot;는 질문에 저자가 &quot;가능하다, 서버에서 한 번만 색인 돌리고 임베딩만 프런트엔드로 보내면 된다&quot;고 답했다. 이게 정석적인 사용 패턴이다.&lt;/p&gt;

&lt;h2&gt;3. 실무 관점: 트레이드오프와 흔한 함정&lt;/h2&gt;

&lt;h3&gt;서버리스 vs 클라이언트 사이드 추론 — 비용/레이턴시 계산&lt;/h3&gt;

&lt;p&gt;단순히 &quot;서버 없애면 좋다&quot;가 아니라 실제로 계산해봐야 한다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;서버 임베딩 방식&lt;/strong&gt;: 첫 검색 레이턴시에 네트워크 왕복(100~300ms) + 서버 추론 시간이 붙는다. 대신 클라이언트는 아무것도 다운로드하지 않는다. 트래픽 비례 비용 발생.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Ternlight(클라이언트) 방식&lt;/strong&gt;: 첫 진입 시 &lt;strong&gt;5~7MB 다운로드 + WASM 컴파일/인스턴스화&lt;/strong&gt;라는 초기 비용이 크다. 대신 그 이후 검색은 네트워크 0회, 서버 비용 0. 사용자 CPU를 빌려 쓴다.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;핵심은 &lt;strong&gt;초기 로딩 비용이 검색 횟수로 상각되는가&lt;/strong&gt;다. 사용자가 페이지에서 검색 한 번 하고 나갈 서비스라면 7MB 다운로드가 오히려 손해다. 반대로 문서 사이트에서 세션 내내 여러 번 검색하는 패턴이면 클라이언트 방식이 압도적으로 유리하다.&lt;/p&gt;

&lt;h3&gt;흔한 함정 1: SIMD 경로로 안 떨어지면 성능이 폭락한다&lt;/h3&gt;

&lt;p&gt;이게 가장 실질적인 함정이다. HN에서 한 사용자가 i5-4570 + Firefox 환경에서 &quot;주장한 초당 400개가 아니라 초당 35개 임베딩밖에 안 나온다&quot;고 보고했다. 저자가 &lt;strong&gt;SIMD가 아닌 경로(fallback)로 떨어지는 것 아니냐&lt;/strong&gt;는 의심이 나왔다. 즉 WASM SIMD를 지원하지 않는 브라우저/환경에서는 스칼라 경로로 폴백하면서 성능이 10배 이상 느려질 수 있다는 얘기다.&lt;/p&gt;

&lt;p&gt;WASM SIMD를 지원하지 않는 환경에서 SIMD 바이너리를 로드하려 하면 대략 이런 에러를 만난다.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;CompileError: WebAssembly.instantiate(): Compiling function #12 failed:
Invalid opcode 0xfd (SIMD not enabled) @+1834&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;또는 Node.js 구버전에서 플래그 없이 돌릴 때:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;CompileError: WebAssembly.compile(): invalid value type 'Simd128', enable with --experimental-wasm-simd @+42&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;대응은 두 가지. 우선 타깃 사용자의 브라우저 SIMD 지원 여부를 배포 전에 파악해야 한다. 크롬/파폭/사파리 최신 버전은 SIMD를 지원하지만, 구형 기기나 특정 임베디드 웹뷰는 다르다. 다음 코드로 런타임에서 감지할 수 있다.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;// wasm-feature-detect 없이 최소 체크
async function hasWasmSimd() {
  try {
    // SIMD 명령(v128.const)이 든 최소 모듈 바이트
    const bytes = new Uint8Array([
      0,97,115,109,1,0,0,0,1,5,1,96,0,1,123,3,
      2,1,0,10,10,1,8,0,65,0,253,15,253,98,11
    ]);
    await WebAssembly.instantiate(bytes);
    return true;
  } catch {
    return false;
  }
}

hasWasmSimd().then(ok =&gt;
  console.log(ok ? 'SIMD 지원 — 빠른 경로' : 'SIMD 미지원 — 성능 폭락 주의')
);&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;흔한 함정 2: 번들 로딩 전략을 안 짜면 첫 화면이 죽는다&lt;/h3&gt;

&lt;p&gt;7MB를 초기 번들에 그냥 넣으면 First Contentful Paint가 박살난다. WASM 모듈은 반드시 &lt;strong&gt;지연 로딩&lt;/strong&gt;해야 한다. 검색창에 포커스가 갔을 때, 혹은 사용자가 첫 글자를 입력했을 때 로드를 시작하는 식이다.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;let ternlight = null;

async function ensureTernlight() {
  if (!ternlight) {
    // 동적 import로 초기 번들에서 분리
    ternlight = await import('@ternlight/base');
  }
  return ternlight;
}

// 검색 인풋 포커스 시점에 미리 워밍업
searchInput.addEventListener('focus', () =&gt; { ensureTernlight(); }, { once: true });

async function search(query, corpus) {
  const { similar } = await ensureTernlight();
  return similar(query, corpus, { topK: 5 });
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;추가로 신경 쓸 것들:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;CDN 캐싱&lt;/strong&gt;: 5~7MB 파일은 immutable 캐시 헤더(&lt;code&gt;Cache-Control: public, max-age=31536000, immutable&lt;/code&gt;)로 내려서 재방문 시 재다운로드를 막아야 한다. 파일명에 해시를 박는 건 기본.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;스트리밍 컴파일&lt;/strong&gt;: 라이브러리 내부가 &lt;code&gt;WebAssembly.instantiateStreaming&lt;/code&gt;을 쓰는지 확인하면 좋다. 이걸 쓰면 다운로드와 컴파일이 동시에 진행돼 체감 로딩이 빨라진다. 이때 서버가 &lt;code&gt;Content-Type: application/wasm&lt;/code&gt;을 정확히 내려주지 않으면 다음 경고가 뜬다:&lt;/li&gt;
&lt;/ul&gt;

&lt;pre&gt;&lt;code&gt;Uncaught (in promise) TypeError: WebAssembly.instantiateStreaming failed
because your server does not serve wasm with application/wasm MIME type.
Falling back to ArrayBuffer instantiation.&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;이건 에러가 아니라 폴백 경고지만, 폴백되면 전체를 메모리에 버퍼링한 뒤 컴파일하므로 느려진다. nginx라면 &lt;code&gt;types { application/wasm wasm; }&lt;/code&gt; 한 줄 추가하면 된다.&lt;/p&gt;

&lt;h3&gt;흔한 함정 3: 검색 품질을 과신하면 안 된다&lt;/h3&gt;

&lt;p&gt;작은 증류 모델이라 정확도 한계가 분명하다. HN 데모에서 &quot;how to use typescript with createContext&quot;를 검색했더니 상위 결과가 typescript 항목만 나와서 유사도 검색이 실패한 것처럼 보인다는 지적이 있었다. 즉 &lt;strong&gt;복합 의도나 긴 쿼리&lt;/strong&gt;에서는 큰 모델만큼 안 나온다.&lt;/p&gt;

&lt;p&gt;실무 대응은 &lt;strong&gt;하이브리드 검색&lt;/strong&gt;이다. HN에서도 언급됐듯, 네이티브 키워드 검색(SQLite FTS5/BM25 등)과 Ternlight의 의미 검색을 Reciprocal Rank Fusion으로 합치는 방식이다. 키워드 매칭이 놓치는 걸 의미 검색이 잡고, 의미 검색이 애매한 걸 키워드가 잡아준다.&lt;/p&gt;

&lt;h3&gt;언어와 대안&lt;/h3&gt;

&lt;p&gt;다국어 성능은 확실치 않다. HN에서도 &quot;영어 외 언어를 얼마나 잘 처리하는지&quot; 질문이 나왔고 명확한 답은 없었다. 한국어 서비스라면 반드시 자체 검증이 필요하다. 참고로 함께 소개된 &lt;em&gt;Garu&lt;/em&gt;(브라우저에서 도는 1.7MB 한국어 형태소 분석기)처럼 한국어 특화 클라이언트 도구와 조합하는 것도 방법이다.&lt;/p&gt;

&lt;p&gt;대안 후보:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Transformers.js + ONNX(MiniLM/MPNet)&lt;/strong&gt;: 더 무겁지만 성숙하고 모델 선택지가 넓다. HN에서 실제로 클라이언트 측에서 잘 돌았다는 후기가 있다.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;서버 임베딩 + Pagefind류 정적 검색&lt;/strong&gt;: 검색이 드물거나 정확도가 중요하면 여전히 유효.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;정적 벡터 검색(portable-hnsw + HTTP Range 요청)&lt;/strong&gt;: 대규모 코퍼스를 정적 호스팅으로 검색하는 실험적 방향. 필요한 청크만 범위 요청으로 받는다.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;4. 정리: 누가 언제 써야 하나&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;한 줄 요약:&lt;/strong&gt; Ternlight는 임베딩 서버 없이 브라우저 CPU만으로 시맨틱 검색을 돌리는 7MB(mini 5MB) 라이브러리로, 초기 다운로드 비용을 세션 내 다수 검색으로 상각할 수 있는 서비스에 잘 맞는다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;쓰면 좋은 경우&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;문서 사이트/기술 문서 검색처럼 사용자가 한 세션에서 여러 번 검색하는 서비스&lt;/li&gt;
  &lt;li&gt;프라이버시가 중요해서 입력 텍스트를 서버로 보내면 안 되는 경우(온디바이스가 곧 장점)&lt;/li&gt;
  &lt;li&gt;임베딩 API 비용/요율 제한이 부담스러운 소규모~중규모 검색 기능&lt;/li&gt;
  &lt;li&gt;후보 임베딩을 빌드 타임에 미리 계산해 정적으로 내려줄 수 있는 구조&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;피해야 하는 경우&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;검색 한 번 하고 이탈하는 랜딩성 페이지(7MB 다운로드가 순손해)&lt;/li&gt;
  &lt;li&gt;SIMD 미지원 구형 기기/웹뷰 비중이 높은 사용자층(성능 폭락)&lt;/li&gt;
  &lt;li&gt;복합 의도·긴 쿼리에 대한 높은 검색 정확도가 필수인 경우(큰 모델 또는 하이브리드 필요)&lt;/li&gt;
  &lt;li&gt;한국어 등 비영어 검색 품질을 검증하지 않은 상태의 프로덕션 투입&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;개인적으로는 &quot;임베딩 서버를 없앨 수 있다&quot;는 선택지 자체가 아키텍처 판단의 폭을 넓혀준다는 게 제일 크다고 본다. 다만 도입 전에 반드시 (1) 타깃 브라우저 SIMD 지원, (2) 7MB 로딩 전략과 CDN 캐싱, (3) 한국어 검색 품질, 이 세 가지는 직접 재보고 결정하자. 데모 페이지 열자마자 팬이 미친 듯이 돌더라는 HN 후기도 괜히 나온 게 아니다 — CPU를 진짜로 쓴다.&lt;/p&gt;

&lt;h2&gt;참고 자료&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://news.hada.io/topic?id=31218&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Ternlight, 브라우저(WASM)에서 실행되는 7MB 임베딩 모델 — GeekNews&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://ternlight-demo.vercel.app&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Ternlight 데모 (React 문서 2천 개 클라이언트 검색)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/soycaporal/ternlight&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Ternlight 저장소 (기술 세부사항·학습 파이프라인·MIT 라이선스)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://pagefind.app/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Pagefind — 정적 사이트용 검색(비교 참고)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/jasonjmcghee/portable-hnsw&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;portable-hnsw — 정적 Parquet + HTTP Range 벡터 검색&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;※ 본문 중 mini 버전 내부 구조(256→384 투영)와 SIMD 폴백 관련 성능 수치는 HN 커뮤니티 관찰·저자 답변에 기반한 것으로, 정확한 사양은 공식 저장소 문서 확인이 필요하다.&lt;/em&gt;&lt;/p&gt;</description>
      <category>Tech_News</category>
      <category>Ternlight</category>
      <category>wasm</category>
      <author>TeEm0</author>
      <guid isPermaLink="true">https://teem0.tistory.com/86</guid>
      <comments>https://teem0.tistory.com/86#entry86comment</comments>
      <pubDate>Thu, 9 Jul 2026 09:58:13 +0900</pubDate>
    </item>
    <item>
      <title>당신의 LLM API 키는 지금 어디에 살고 있나 &amp;mdash; 의존성 하나 털리면 벌어지는 일</title>
      <link>https://teem0.tistory.com/85</link>
      <description>&lt;h2&gt;1. 도입: 왜 지금 이 얘기를 꺼내는가&lt;/h2&gt;

&lt;p&gt;질문 하나 던지고 시작하자. &lt;strong&gt;오늘 당신 프로젝트의 의존성 하나가 털렸다고 치자. 공격자가 OpenAI, Anthropic, Gemini API 키를 빼갈 수 있을까?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;이 질문의 답은 어떤 LLM 프로바이더를 쓰느냐, 코드가 얼마나 견고하냐에 달려 있지 않다. 대부분의 팀이 한 번도 진지하게 고민 안 한 &lt;strong&gt;딱 하나의 아키텍처 결정&lt;/strong&gt;에 달려 있다. 바로 &quot;앱이 돌아가는 동안 프로바이더 API 키가 실제로 어디에 존재하느냐&quot;다.&lt;/p&gt;

&lt;p&gt;요즘 실무에서 LLM API 도입이 폭발적으로 늘고 있다. 사내 챗봇, RAG 파이프라인, 코드 리뷰 봇까지. 그런데 대부분은 &lt;code&gt;OPENAI_API_KEY&lt;/code&gt;를 환경변수에 박고 SDK 초기화해서 끝낸다. 나도 처음엔 그랬다. 문제는 이 방식이 공급망 공격(supply chain attack)에 통째로 노출된다는 점이다.&lt;/p&gt;

&lt;p&gt;원문(Dev.to, Hadil Ben Abdallah)에서는 2026년 3월 LiteLLM 공급망 사고를 예로 든다. 이 사고가 주목받은 건 LiteLLM이 유독 취약해서가 아니라, &lt;strong&gt;AI 인프라가 이제 얼마나 값진 공격 타깃이 됐는지&lt;/strong&gt;, 그리고 &lt;strong&gt;털린 의존성 하나가 얼마나 빠르게 고가치 크레덴셜에 도달하는지&lt;/strong&gt;를 보여줬기 때문이다. 이 글에서는 두 가지 게이트웨이 아키텍처를 비교하고, 실제로 돌려볼 수 있는 데모, 그리고 실무에서 시크릿을 어떻게 관리할지까지 정리한다.&lt;/p&gt;

&lt;h2&gt;2. 핵심: 키가 어디 사느냐가 전부다&lt;/h2&gt;

&lt;p&gt;모든 LLM 애플리케이션은 결국 같은 일을 한다. 모델 프로바이더에 요청 보내고, API 키로 인증한다. 중요한 건 &quot;API 키를 쓰느냐&quot;가 아니라 &lt;strong&gt;&quot;요청하는 순간 그 키가 어디에 존재하느냐&quot;&lt;/strong&gt;다. 크게 두 가지 패턴이 있다.&lt;/p&gt;

&lt;h3&gt;패턴 A: 앱이 프로바이더 키를 직접 들고 있다&lt;/h3&gt;

&lt;p&gt;우리가 제일 익숙한 방식이다. 환경변수에서 키 읽고, SDK 초기화하고, 프로바이더로 직접 쏜다.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;import os
from openai import OpenAI

api_key = os.environ[&quot;PROVIDER_API_KEY&quot;]
client = OpenAI(api_key=api_key)
response = client.responses.create(...)
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;구현 빠르고 이해하기 쉽다. 많은 프로젝트에서 충분히 합리적이다. 하지만 핵심은 이거다. &lt;strong&gt;프로바이더 키가 이제 애플리케이션 프로세스 안에 산다.&lt;/strong&gt; 그 프로세스에서 돌아가는 모든 패키지, 프레임워크, 플러그인, 의존성이 &lt;strong&gt;같은 권한&lt;/strong&gt;으로 실행된다. 그중 하나만 털려도 악성 코드는 당신 앱과 똑같은 환경에서 키를 읽을 수 있다.&lt;/p&gt;

&lt;h3&gt;패턴 B: 앱이 네트워크 프록시와 대화한다&lt;/h3&gt;

&lt;p&gt;인증을 앱에서 분리하는 방식이다. 앱은 프로바이더로 직접 쏘지 않고 게이트웨이/프록시로 보낸다. 프록시가 프로바이더 키를 들고 있다가, 자기 프로세스 안에서만 키를 주입해 업스트림 요청을 대신 처리한다.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;Application
   │
   ▼
Gateway / Proxy   ← 여기에만 프로바이더 키가 산다
   │
   ▼
LLM Provider
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;앱 관점에서 흐름은 거의 똑같다. 차이는 &lt;strong&gt;앱이 무엇을 안 갖고 있느냐&lt;/strong&gt;다. 앱은 프로바이더 키 대신 &lt;strong&gt;스코프가 제한된 게이트웨이 토큰&lt;/strong&gt;만 들고 있다. 프록시가 이 토큰을 검증하고, 라우팅/정책을 적용한 뒤, 자기 프로세스 안에서 진짜 프로바이더 키를 붙여 보낸다.&lt;/p&gt;

&lt;p&gt;이게 사고의 결과를 바꾼다. 악성 코드가 앱 프로세스에서 실행돼도, 프로바이더 API 키는 애초에 거기 없다. 물론 게이트웨이 토큰이 털리는 것도 보안 사고다. 하지만 게이트웨이 토큰은 &lt;strong&gt;좁게 스코프를 걸 수 있고, 중앙에서 즉시 폐기할 수 있고, 앱 재배포 없이 로테이션할 수 있고, 특정 작업만 허용하도록 제한&lt;/strong&gt;할 수 있다. 프로바이더 키를 갈아끼우는 것과는 회복 난이도가 완전히 다르다.&lt;/p&gt;

&lt;h3&gt;같은 악성 의존성을 두 아키텍처에 돌려보자&lt;/h3&gt;

&lt;p&gt;원문의 데모를 재현해보자. 아래는 임포트되는 순간 프로세스 환경변수를 스캔해서 크레덴셜처럼 생긴 걸 출력하는 &quot;악성&quot; 패키지다. 실제로는 네트워크 요청도, 파일 쓰기도 안 하지만 개념 증명엔 충분하다.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# malicious_dep.py
import os

_INTERESTING = (&quot;API_KEY&quot;, &quot;SECRET&quot;, &quot;TOKEN&quot;, &quot;PASSWORD&quot;, &quot;PRIVATE_KEY&quot;)

def harvest():
    found = {
        k: v for k, v in os.environ.items()
        if any(marker in k.upper() for marker in _INTERESTING)
    }
    for name, value in found.items():
        shown = value[:8] + &quot;...&quot; if len(value) &gt; 12 else value
        print(f&quot;[malicious_dep@import] EXFILTRATED {name}={shown}&quot;)

harvest()
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;이제 시나리오 A. 앱이 프로바이더 키를 직접 환경변수에 들고 있는 경우다.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;$ export PROVIDER_API_KEY=&quot;sk-provider-abc123xyz&quot;
$ python -c &quot;import malicious_dep&quot;
[malicious_dep@import] EXFILTRATED PROVIDER_API_KEY=sk-provi...3xyz
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;별 대단한 짓을 한 게 아니다. 인증 우회도, 메모리 손상 익스플로잇도, 다른 서비스 침투도 없다. 그냥 &lt;strong&gt;이미 자기가 실행되는 프로세스 안에 있던 데이터를 읽었을 뿐&lt;/strong&gt;이다. 공격자 입장에선 그걸로 충분하다.&lt;/p&gt;

&lt;p&gt;이번엔 시나리오 B. 앱은 게이트웨이 토큰만 들고 있다.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;$ unset PROVIDER_API_KEY
$ export GATEWAY_TOKEN=&quot;gw-scoped-read-only-789&quot;
$ python -c &quot;import malicious_dep&quot;
[malicious_dep@import] EXFILTRATED GATEWAY_TOKEN=gw-scope...-789
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;주목할 건 &lt;strong&gt;무엇이 안 나왔는지&lt;/strong&gt;다. 프로바이더 API 키가 없다. 애초에 앱 프로세스에 존재한 적이 없으니까. 앱은 여전히 모델 응답을 정상적으로 받지만, 프로바이더 인증은 프록시 안에서 일어난다.&lt;/p&gt;

&lt;p&gt;여기서 &quot;그럼 프록시가 문제를 해결했네&quot;라고 결론 내리면 안 된다. 의존성은 여전히 크레덴셜을 하나 훔쳤다. 게이트웨이 토큰은 진짜고, 공격자가 이걸로 프록시를 통해 요청을 날릴 수도 있다. 핵심은 &lt;strong&gt;&quot;뭐가 샜냐&quot;가 아니라 &quot;샌 게 뭘 할 수 있고, 얼마나 빨리 복구되냐&quot;&lt;/strong&gt;다. 프로바이더 키가 새면 프로바이더 콘솔 들어가서 키 재발급하고 전 서비스 재배포해야 한다. 게이트웨이 토큰은 프록시에서 한 줄로 폐기하고 새 토큰 발급하면 끝이다.&lt;/p&gt;

&lt;h2&gt;3. 실무 관점: 도입 시 고려사항과 흔한 함정&lt;/h2&gt;

&lt;h3&gt;키 노출 경로별 위험도 비교&lt;/h3&gt;

&lt;p&gt;실무에서 키가 새는 경로는 생각보다 다양하다. 경로별로 정리하면 이렇다.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;앱 프로세스 환경변수&lt;/strong&gt;: 위에서 봤듯 같은 프로세스의 모든 의존성이 읽는다. 가장 흔하고 가장 넓은 공격면.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CI/CD 파이프라인 변수&lt;/strong&gt;: GitHub Actions Secrets, GitLab CI Variables 등. 로그에 실수로 &lt;code&gt;echo $OPENAI_API_KEY&lt;/code&gt; 찍으면 그대로 노출된다. PR에서 돌아가는 워크플로에 시크릿 노출되면 포크에서 탈취 가능.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;컨테이너 런타임&lt;/strong&gt;: &lt;code&gt;docker inspect&lt;/code&gt;나 &lt;code&gt;/proc/1/environ&lt;/code&gt;로 환경변수 다 보인다. 이미지 레이어에 &lt;code&gt;ENV OPENAI_API_KEY=...&lt;/code&gt; 박아넣으면 이미지 pull 가능한 누구나 본다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;.env 파일 커밋&lt;/strong&gt;: &lt;code&gt;.gitignore&lt;/code&gt;에 안 넣고 커밋했다가 GitHub 스크래핑 봇한테 몇 분 만에 털리는 클래식.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;공통점은 &quot;키가 앱 프로세스가 도달 가능한 위치에 평문으로 존재&quot;한다는 것이다. 패턴 B(프록시)는 이 문제를 프록시 프로세스 하나로 격리시켜 &lt;strong&gt;폭발 반경(blast radius)&lt;/strong&gt;을 줄인다.&lt;/p&gt;

&lt;h3&gt;흔한 함정 1: 컨테이너 환경변수는 다 보인다&lt;/h3&gt;

&lt;p&gt;많은 사람이 컨테이너 안에 환경변수로 넣으면 &quot;격리됐다&quot;고 착각한다. 아니다. 같은 호스트에서 docker 접근 권한만 있으면 이렇게 보인다.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;$ docker inspect my-llm-app --format '{{range .Config.Env}}{{println .}}{{end}}'
PATH=/usr/local/bin:/usr/local/sbin
PROVIDER_API_KEY=sk-provider-abc123xyz
LANG=C.UTF-8
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;또 이미지 빌드 단계에서 &lt;code&gt;ENV&lt;/code&gt;로 박으면 레이어 히스토리에 영구히 남는다. &lt;code&gt;docker history&lt;/code&gt;로 확인 가능하다. 빌드 시점 시크릿은 반드시 &lt;code&gt;--secret&lt;/code&gt; 마운트(BuildKit)를 쓰거나 런타임 주입해야 한다.&lt;/p&gt;

&lt;h3&gt;흔한 함정 2: Vault 붙였는데 결국 환경변수로 다시 흘린다&lt;/h3&gt;

&lt;p&gt;Vault를 도입해놓고 앱 시작 스크립트에서 이렇게 하는 경우가 진짜 많다.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;export PROVIDER_API_KEY=$(vault kv get -field=api_key secret/llm)
python app.py
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;이러면 Vault를 쓰든 말든 결국 키가 앱 프로세스 환경변수로 다시 들어온다. 의존성 공격 관점에선 아무것도 안 바뀐 것이다. Vault의 진짜 가치는 &lt;strong&gt;중앙 관리, 감사 로그, 자동 로테이션, 동적 시크릿&lt;/strong&gt;이지, &quot;환경변수에 넣기 전 잠깐 안전&quot;이 아니다.&lt;/p&gt;

&lt;h3&gt;흔한 함정 3: AWS Secrets Manager 권한 에러&lt;/h3&gt;

&lt;p&gt;ECS/EKS에서 Secrets Manager 붙이다 보면 십중팔구 이 에러를 만난다.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;ResourceInitializationError: unable to pull secrets or registry auth:
execution resource retrieval failed: unable to retrieve secret from asm:
service call has been retried 1 time(s):
AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/ecsTaskExecutionRole/...
is not authorized to perform: secretsmanager:GetSecretValue
on resource: arn:aws:secretsmanager:ap-northeast-2:123456789012:secret:llm/openai-key-AbCdEf
because no identity-based policy allows the secretsmanager:GetSecretValue action
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;범인은 대부분 &lt;strong&gt;Task Execution Role&lt;/strong&gt;에 &lt;code&gt;secretsmanager:GetSecretValue&lt;/code&gt; 권한이 없어서다. 그리고 KMS 커스텀 키로 암호화한 시크릿이면 &lt;code&gt;kms:Decrypt&lt;/code&gt;도 추가로 필요하다. 이걸 놓쳐서 한참 헤매는 사람 많다. 최소 권한 정책 예시는 이렇다.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;{
  &quot;Version&quot;: &quot;2012-10-17&quot;,
  &quot;Statement&quot;: [
    {
      &quot;Effect&quot;: &quot;Allow&quot;,
      &quot;Action&quot;: &quot;secretsmanager:GetSecretValue&quot;,
      &quot;Resource&quot;: &quot;arn:aws:secretsmanager:ap-northeast-2:123456789012:secret:llm/openai-key-*&quot;
    },
    {
      &quot;Effect&quot;: &quot;Allow&quot;,
      &quot;Action&quot;: &quot;kms:Decrypt&quot;,
      &quot;Resource&quot;: &quot;arn:aws:kms:ap-northeast-2:123456789012:key/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx&quot;
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;안전한 시크릿 관리 옵션 비교&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;HashiCorp Vault&lt;/strong&gt;: 동적 시크릿, 자동 로테이션, 세밀한 정책. 자체 운영 부담이 크다. Vault Agent나 CSI Provider로 파일 마운트하면 환경변수보다 낫다(단, 파일도 프로세스가 읽으면 의존성이 읽음).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AWS Secrets Manager&lt;/strong&gt;: AWS 생태계면 통합이 편하다. 자동 로테이션 지원. IAM 권한 설정이 함정.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SOPS&lt;/strong&gt;: 암호화된 시크릿을 Git에 커밋해서 GitOps로 관리. KMS/age로 암호화. 배포 시점 복호화. 인프라 부담이 적어 소규모 팀에 좋다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;LLM 프록시 게이트웨이(패턴 B)&lt;/strong&gt;: LiteLLM Proxy, 자체 프록시 등. 근본적으로 폭발 반경을 줄이는 유일한 방법. 다만 프록시 자체가 새 SPOF가 되고 운영 포인트가 하나 늘어난다.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;정직하게 말하면, 어느 것도 만능이 아니다. 프록시를 써도 게이트웨이 토큰은 여전히 프로세스 안에 있다. 관건은 &quot;완전 제거&quot;가 아니라 &lt;strong&gt;&quot;털렸을 때 피해를 얼마나 좁히고, 얼마나 빨리 복구하느냐&quot;&lt;/strong&gt;다.&lt;/p&gt;

&lt;h3&gt;SOPS로 키를 Git에 안전하게 넣는 실전 예시&lt;/h3&gt;

&lt;p&gt;소규모 팀이라면 프록시 세우기 전에 SOPS만 붙여도 평문 커밋 사고는 막는다.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;$ cat secrets.yaml
openai_api_key: sk-provider-abc123xyz

$ sops --encrypt --age age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8j \
    secrets.yaml &gt; secrets.enc.yaml

$ cat secrets.enc.yaml
openai_api_key: ENC[AES256_GCM,data:xJk2...,iv:...,tag:...,type:str]
sops:
    age:
        - recipient: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8j
          enc: |
            -----BEGIN AGE ENCRYPTED FILE-----
            ...
            -----END AGE ENCRYPTED FILE-----
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;이제 &lt;code&gt;secrets.enc.yaml&lt;/code&gt;은 안심하고 커밋해도 된다. 배포 시점에 복호화한다.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;$ export SOPS_AGE_KEY_FILE=~/.config/sops/age/keys.txt
$ sops --decrypt secrets.enc.yaml
openai_api_key: sk-provider-abc123xyz
&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;4. 정리: 한 줄 요약과 선택 기준&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;한 줄 요약: LLM API 키 보안의 90%는 &quot;키가 앱 프로세스가 읽을 수 있는 곳에 있느냐&quot;로 결정된다. 프록시로 프로바이더 키를 앱 밖으로 빼내면 의존성이 털려도 폭발 반경이 줄어든다.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;혼자/소규모, 빠른 프로토타입&lt;/strong&gt;: 최소한 SOPS나 Secrets Manager로 평문 커밋·환경변수 하드코딩부터 막아라.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;여러 앱이 같은 프로바이더 키 공유, 감사·로테이션 필요&lt;/strong&gt;: 프록시 게이트웨이(패턴 B)를 진지하게 검토하라. 앱은 스코프 토큰만 들고, 폐기·로테이션을 중앙화한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;규제/보안 요구가 높은 조직&lt;/strong&gt;: Vault 동적 시크릿 + 프록시 조합. 운영 부담은 크지만 회복 속도와 감사 추적이 최고다.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;핵심은 마인드셋 전환이다. &quot;우리 코드는 안전하다&quot;가 아니라 &lt;strong&gt;&quot;우리 의존성 중 하나가 이미 털렸다고 가정하면, 그때 무슨 크레덴셜이 손에 잡히나?&quot;&lt;/strong&gt;를 물어야 한다. 이 질문에 답이 안 나온다면, 오늘 &lt;code&gt;docker inspect&lt;/code&gt; 한 번 돌려보는 것부터 시작하자.&lt;/p&gt;

&lt;h2&gt;참고 자료&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://dev.to/hadil/where-do-your-llm-api-keys-actually-live-2cjm&quot;&gt;Where Do Your LLM API Keys Actually Live? — Hadil Ben Abdallah, Dev.to (원문)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.vaultproject.io/docs&quot;&gt;HashiCorp Vault Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.aws.amazon.com/secretsmanager/&quot;&gt;AWS Secrets Manager Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/getsops/sops&quot;&gt;SOPS: Secrets OPerationS (GitHub)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.docker.com/build/building/secrets/&quot;&gt;Docker BuildKit — Build secrets&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Tech_News</category>
      <category>API키</category>
      <category>LLM</category>
      <author>TeEm0</author>
      <guid isPermaLink="true">https://teem0.tistory.com/85</guid>
      <comments>https://teem0.tistory.com/85#entry85comment</comments>
      <pubDate>Wed, 8 Jul 2026 09:00:55 +0900</pubDate>
    </item>
    <item>
      <title>Terraform 보안 자동화: Checkov와 GitHub Actions로 배포 전에 설정 오류 잡아내기</title>
      <link>https://teem0.tistory.com/84</link>
      <description>&lt;p&gt;배포하고 나서 &quot;아 저 S3 버킷 퍼블릭이었네&quot; 하고 알아차리는 순간만큼 등골 서늘한 게 없다. 나도 예전에 스테이징 환경 버킷 하나를 실수로 &lt;code&gt;public-read&lt;/code&gt;로 열어놨다가, 다행히 내부 리뷰에서 걸려서 넘어간 적이 있다. 문제는 이런 게 사람 눈에만 의존하면 언젠가는 반드시 새어나간다는 거다. 이번 글은 Terraform 코드가 머지되기 &lt;strong&gt;전에&lt;/strong&gt; 설정 오류를 자동으로 걸러내는 파이프라인을 Checkov와 GitHub Actions로 짜는 이야기다.&lt;/p&gt;

&lt;h2&gt;1. 왜 지금 IaC 보안 자동화인가&lt;/h2&gt;

&lt;p&gt;Terraform, Pulumi, OpenTofu 같은 도구로 인프라를 코드로 정의하는 게 표준이 된 지 오래다. 버전 관리 되고, 반복 가능하고, 리뷰도 가능하다. 좋은 얘기다. 그런데 코드로 정의된다는 건 &lt;strong&gt;오류도 코드로 복제된다&lt;/strong&gt;는 뜻이다. 모듈 하나 잘못 만들어놓고 열 개 프로젝트에서 갖다 쓰면, 취약점이 열 배로 퍼진다.&lt;/p&gt;

&lt;p&gt;실무에서 자주 보는 사고 패턴은 대충 이렇다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;S3 버킷을 급하게 만들면서 암호화 설정을 빼먹음&lt;/li&gt;
  &lt;li&gt;보안 그룹에 &lt;code&gt;0.0.0.0/0&lt;/code&gt;으로 22번 포트를 열어둠 (테스트한다고 열었다가 안 닫음)&lt;/li&gt;
  &lt;li&gt;IAM 정책에 &lt;code&gt;&quot;Action&quot;: &quot;*&quot;&lt;/code&gt;를 박아놓고 &quot;나중에 좁혀야지&quot; 하고 잊어버림&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;이런 건 리뷰어가 &lt;code&gt;terraform plan&lt;/code&gt; diff를 눈으로 훑어서 잡기엔 한계가 있다. diff가 100줄 넘어가면 사람은 그냥 &quot;LGTM&quot; 누른다. 그래서 정적 분석 도구(SAST)로 코드 자체를 스캔해서, 배포 파이프라인 왼쪽 끝(개발 단계)에서 막아버리는 &quot;shift-left&quot; 접근이 필요하다. Checkov가 바로 그 역할을 한다.&lt;/p&gt;

&lt;h2&gt;2. Checkov는 어떻게 동작하나&lt;/h2&gt;

&lt;p&gt;Checkov는 Bridgecrew(현재 Prisma Cloud 소속)가 만든 오픈소스 정적 분석 도구다. Terraform 파일뿐 아니라 CloudFormation, Kubernetes 매니페스트, Dockerfile, Serverless 프레임워크 등을 스캔한다. AWS/Azure/GCP에 대한 수백 개의 내장 정책을 가지고 있다.&lt;/p&gt;

&lt;p&gt;동작 원리를 비유하자면, &lt;strong&gt;맞춤법 검사기&lt;/strong&gt;랑 비슷하다. 문서를 실제로 &quot;실행&quot;하지 않고 텍스트 구조만 읽어서 &quot;여기 틀렸어요&quot;를 지적한다. Checkov도 Terraform을 실제로 &lt;code&gt;apply&lt;/code&gt;하지 않는다. HCL 코드를 파싱해서 리소스 블록을 추출하고, 각 리소스마다 미리 정의된 정책(check)을 하나씩 대입해본다.&lt;/p&gt;

&lt;p&gt;정책 하나하나에는 &lt;code&gt;CKV_AWS_20&lt;/code&gt; 같은 ID가 붙어 있다. 예를 들면:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code&gt;CKV_AWS_20&lt;/code&gt;: S3 버킷 ACL이 퍼블릭 READ 접근을 허용하는지&lt;/li&gt;
  &lt;li&gt;&lt;code&gt;CKV_AWS_19&lt;/code&gt;: S3 버킷 데이터가 저장 시 암호화되는지&lt;/li&gt;
  &lt;li&gt;&lt;code&gt;CKV_AWS_21&lt;/code&gt;: S3 버킷 버저닝이 활성화됐는지&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;각 정책은 &quot;이 리소스 타입에서 이 속성이 이 값이어야 통과&quot; 같은 규칙으로 구성된다. 코드를 실행하는 게 아니라 &lt;strong&gt;정적으로 속성 값만 검사&lt;/strong&gt;하기 때문에 빠르고, CI에서 돌리기 부담이 없다. 대신 런타임 상태(실제 배포된 리소스가 지금 어떤지)는 모른다는 한계도 여기서 나온다. 이건 뒤에서 다시 얘기한다.&lt;/p&gt;

&lt;h2&gt;3. 로컬에서 직접 돌려보기&lt;/h2&gt;

&lt;p&gt;일단 손으로 해봐야 감이 온다. 원문 예제를 그대로 따라가되, 실제 출력까지 확인하자.&lt;/p&gt;

&lt;h3&gt;취약한 Terraform 만들기&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;main.tf&lt;/code&gt;를 이렇게 작성한다. 일부러 퍼블릭 버킷을 만든다.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# main.tf
provider &quot;aws&quot; {
  region = &quot;us-east-1&quot;
}

resource &quot;aws_s3_bucket&quot; &quot;my_vulnerable_bucket&quot; {
  bucket = &quot;my-company-public-data-bucket-12345&quot;
}

# 일부러 넣은 취약점: 퍼블릭 READ 접근
resource &quot;aws_s3_bucket_acl&quot; &quot;example&quot; {
  bucket = aws_s3_bucket.my_vulnerable_bucket.id
  acl    = &quot;public-read&quot;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Checkov 설치 및 실행&lt;/h3&gt;

&lt;p&gt;Python의 pip로 설치한다. (파이썬 3.7 이상 환경 권장. 정확한 최소 버전은 공식 문서 확인 필요)&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;pip install checkov
checkov -d .
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;디렉터리를 스캔하면 이런 리포트가 나온다.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;       _               _
   ___| |__   ___  ___| | _______   __
  / __| '_ \ / _ \/ __| |/ / _ \ \ / /
 | (__| | | |  __/ (__|   &lt; (_) \ V /
  \___|_| |_|\___|\___|_|\_\___/ \_/

By Prisma Cloud | version: 3.1.0

terraform scan results:

Passed checks: 0, Failed checks: 3, Skipped checks: 0

Failed checks:

1. Check: CKV_AWS_20: &quot;S3 Bucket has an ACL defined which allows public READ access.&quot;
   FAILED for resource: aws_s3_bucket_acl.example
   File: /main.tf:10-13
   Guide: https://docs.prismacloud.io/en/enterprise-edition/policy-reference/aws-policies/s3-policies/s3-1-acl-read-access

2. Check: CKV_AWS_19: &quot;Ensure all data stored in the S3 bucket is securely encrypted at rest&quot;
   FAILED for resource: aws_s3_bucket.my_vulnerable_bucket
   File: /main.tf:6-8

3. Check: CKV_AWS_21: &quot;Ensure all data stored in the S3 bucket have versioning enabled&quot;
   FAILED for resource: aws_s3_bucket.my_vulnerable_bucket
   File: /main.tf:6-8
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;여기서 눈여겨볼 점: 나는 퍼블릭 ACL &lt;strong&gt;하나만&lt;/strong&gt; 일부러 넣었는데, Checkov는 암호화 미설정(CKV_AWS_19)과 버저닝 미설정(CKV_AWS_21)까지 &lt;strong&gt;세 개&lt;/strong&gt;를 잡아냈다. 즉 &quot;내가 인식조차 못 한 문제&quot;를 알려준다. 이게 정적 분석의 진짜 가치다.&lt;/p&gt;

&lt;h3&gt;고쳐서 통과시키기&lt;/h3&gt;

&lt;p&gt;세 가지를 다 해결한 버전은 이렇다.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# main.tf
provider &quot;aws&quot; {
  region = &quot;us-east-1&quot;
}

resource &quot;aws_s3_bucket&quot; &quot;my_secure_bucket&quot; {
  bucket = &quot;my-company-secure-data-bucket-12345&quot;
}

# Fix 1: private ACL
resource &quot;aws_s3_bucket_acl&quot; &quot;example&quot; {
  bucket = aws_s3_bucket.my_secure_bucket.id
  acl    = &quot;private&quot;
}

# Fix 2: 버저닝 활성화
resource &quot;aws_s3_bucket_versioning&quot; &quot;versioning_example&quot; {
  bucket = aws_s3_bucket.my_secure_bucket.id
  versioning_configuration {
    status = &quot;Enabled&quot;
  }
}

# Fix 3: 서버사이드 암호화
resource &quot;aws_s3_bucket_server_side_encryption_configuration&quot; &quot;example&quot; {
  bucket = aws_s3_bucket.my_secure_bucket.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = &quot;AES256&quot;
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;이제 다시 &lt;code&gt;checkov -d .&lt;/code&gt;를 돌리면 &lt;code&gt;Passed checks&lt;/code&gt; 쪽으로 넘어간다.&lt;/p&gt;

&lt;h2&gt;4. GitHub Actions 파이프라인에 붙이기&lt;/h2&gt;

&lt;p&gt;로컬에서 돌리는 건 개인의 성실함에 의존한다. 성실함은 배포 압박 앞에서 쉽게 무너진다. 그래서 PR마다 자동으로 돌게 만들어야 한다. &lt;code&gt;.github/workflows/checkov.yml&lt;/code&gt;을 만든다.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;name: Checkov IaC Scan

on:
  push:
    branches: [ &quot;main&quot; ]
  pull_request:
    branches: [ &quot;main&quot; ]

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v3

      - name: Run Checkov action
        uses: bridgecrewio/checkov-action@master
        with:
          directory: .
          framework: terraform
          output_format: cli
          soft_fail: false   # true로 하면 취약점 있어도 빌드 통과
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;단계별로 보면:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;트리거&lt;/strong&gt;: main 브랜치로의 push, PR에서 실행. 실무에선 PR 트리거가 핵심이다. 머지되기 전에 막아야 의미가 있으니까.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Checkout&lt;/strong&gt;: 코드를 러너로 가져온다.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Checkov Action&lt;/strong&gt;: 공식 &lt;code&gt;bridgecrewio/checkov-action&lt;/code&gt;으로 현재 디렉터리를 스캔한다.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;soft_fail: false&lt;/strong&gt;: 취약점 발견 시 잡을 실패시킨다. 브랜치 보호 규칙에서 이 잡을 필수 체크로 걸어두면 취약한 코드는 머지 자체가 안 된다.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;한 가지 실무 팁: &lt;code&gt;bridgecrewio/checkov-action@master&lt;/code&gt;처럼 브랜치를 그대로 참조하면 어느 날 액션이 바뀌면서 파이프라인이 갑자기 깨질 수 있다. 재현 가능성 측면에서 특정 릴리스 태그나 커밋 SHA로 핀 고정하는 걸 권한다. 이건 Checkov만의 문제가 아니라 서드파티 액션 전반의 공급망 위생 문제다.&lt;/p&gt;

&lt;h2&gt;5. 실무 관점 — 트레이드오프와 흔한 함정&lt;/h2&gt;

&lt;h3&gt;오탐과 SAST 노이즈, 그리고 예외 처리&lt;/h3&gt;

&lt;p&gt;Checkov를 실제 레거시 저장소에 처음 돌리면 십중팔구 이런 광경을 본다. &lt;strong&gt;실패 체크 수백 개.&lt;/strong&gt; 이 중 상당수는 &quot;우리 맥락에선 문제 없는데?&quot; 하는 것들이다. 예를 들어 사내망 전용 로그 버킷에 KMS 대신 AES256을 쓴다든가, 특정 정책이 우리 아키텍처엔 아예 해당 안 되는 경우다.&lt;/p&gt;

&lt;p&gt;이럴 때 쓰는 게 인라인 억제(suppression)다. 특정 리소스에서 특정 체크만 건너뛴다.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;resource &quot;aws_s3_bucket&quot; &quot;internal_logs&quot; {
  bucket = &quot;internal-logs-bucket-12345&quot;

  # checkov:skip=CKV_AWS_21:내부 로그 버킷, 버저닝 불필요 (보안팀 승인 2024-01, 티켓 SEC-123)
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;여기서 &lt;strong&gt;흔한 함정&lt;/strong&gt;. skip 주석의 형식이 조금이라도 틀리면 조용히 무시된다. Checkov 버전에 따라 이런 경고를 뱉기도 한다.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;WARNING: Failed to parse suppression comment
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;혹은 아예 아무 로그 없이 스킵이 안 먹어서, &quot;분명 skip 넣었는데 왜 계속 FAILED로 뜨지?&quot; 하고 한참 헤매게 된다. 규칙은 간단하다. &lt;code&gt;#&lt;/code&gt; 뒤에 공백 하나, 그다음 &lt;code&gt;checkov:skip=&amp;lt;체크ID&amp;gt;:&amp;lt;사유&amp;gt;&lt;/code&gt;. 체크 ID를 &lt;code&gt;CKV_AWS_21&lt;/code&gt;이 아니라 대충 &lt;code&gt;AWS_21&lt;/code&gt;로 쓰거나, 콜론 위치를 틀리면 안 먹는다.&lt;/p&gt;

&lt;p&gt;그리고 억제는 &lt;strong&gt;양날의 검&lt;/strong&gt;이다. 개발 속도를 위해선 필요하지만, 관리 안 하면 어느새 코드베이스가 &lt;code&gt;checkov:skip&lt;/code&gt; 천지가 된다. 그러면 도구를 도입한 의미가 사라진다. 그래서 나는 이렇게 운영한다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;skip 주석에는 &lt;strong&gt;반드시 사유와 승인 근거(티켓 번호 등)&lt;/strong&gt;를 남긴다.&lt;/li&gt;
  &lt;li&gt;skip 라인을 CODEOWNERS로 보안팀 리뷰 대상에 걸거나, 정기적으로 &lt;code&gt;grep -rn &quot;checkov:skip&quot; .&lt;/code&gt;로 집계해서 리뷰한다.&lt;/li&gt;
  &lt;li&gt;조직 전체 공통 예외는 인라인이 아니라 중앙 설정 파일(&lt;code&gt;.checkov.yaml&lt;/code&gt;)이나 커스텀 정책으로 관리한다. 그래야 개발자가 임의로 핵심 통제를 우회 못 한다.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;도입 초기 현실적인 전략&lt;/h3&gt;

&lt;p&gt;레거시에 처음 붙일 때 &lt;code&gt;soft_fail: false&lt;/code&gt;로 시작하면 팀 전체가 폭동을 일으킨다. 모든 PR이 빨간불이 되니까. 현실적인 순서는:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;1단계&lt;/strong&gt;: &lt;code&gt;soft_fail: true&lt;/code&gt;로 시작. 리포트만 뜨고 빌드는 통과. 현황 파악.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;2단계&lt;/strong&gt;: 명백히 심각한 것부터 고치거나 예외 처리.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;3단계&lt;/strong&gt;: 어느 정도 정리되면 &lt;code&gt;soft_fail: false&lt;/code&gt;로 전환해서 새 위반은 못 들어오게 막는다.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;SAST의 근본적 한계&lt;/h3&gt;

&lt;p&gt;Checkov는 &lt;strong&gt;코드만&lt;/strong&gt; 본다. 이미 배포돼서 콘솔에서 손으로 바꿔놓은 리소스(drift)는 모른다. 또 정적 분석이라 &quot;이 변수가 런타임에 어떤 값이 될지&quot;를 완벽히 추적하진 못한다. 그래서 Checkov는 첫 번째 방어선이지, 유일한 방어선이 아니다. 런타임 상태 점검은 별도 도구(클라우드 네이티브 보안 스캐너 등)로 보완해야 한다.&lt;/p&gt;

&lt;h3&gt;대안과 확장&lt;/h3&gt;

&lt;p&gt;원문은 tfsec을 일부러 피하고 Checkov를 다뤘다. 실무 선택지를 정리하면:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;tfsec / Trivy&lt;/strong&gt;: Trivy는 이제 IaC 스캔도 하고 컨테이너 이미지, 의존성까지 통합해서 본다. 툴 개수 줄이고 싶으면 고려할 만하다.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;OPA (Open Policy Agent) + Conftest&lt;/strong&gt;: 내장 정책으로 부족하고 &lt;strong&gt;조직 고유의 정책&lt;/strong&gt;을 Rego로 직접 정의하고 싶을 때. 예: &quot;모든 리소스에 &lt;code&gt;CostCenter&lt;/code&gt; 태그 필수&quot; 같은 사내 규칙.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Terraform Cloud/Enterprise의 Sentinel&lt;/strong&gt;: HashiCorp 상용 제품 라인을 쓴다면 plan 결과에 대한 policy-as-code를 붙일 수 있다.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;참고로 Checkov 자체도 커스텀 정책(파이썬 또는 YAML 기반)을 지원한다. 사내 표준을 강제하고 싶으면 굳이 OPA까지 안 가고 Checkov 커스텀 정책으로 해결되는 경우도 많다.&lt;/p&gt;

&lt;h2&gt;6. 정리&lt;/h2&gt;

&lt;p&gt;한 줄 요약: &lt;strong&gt;Checkov + GitHub Actions는 Terraform 설정 오류를 배포 전에 막는 가장 가성비 좋은 첫 방어선이다.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;누가 언제 쓰면 좋은가:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Terraform으로 클라우드 인프라를 관리하는 모든 팀 — 사실상 안 쓸 이유가 없다. 설치와 CI 연동이 30분이면 끝난다.&lt;/li&gt;
  &lt;li&gt;다만 레거시가 크다면 &lt;code&gt;soft_fail: true&lt;/code&gt;로 부드럽게 시작하고, 예외 처리에 대한 거버넌스(누가 skip을 승인하는가)를 반드시 함께 정해라. 이걸 안 정하면 6개월 뒤 skip 주석 무덤을 마주하게 된다.&lt;/li&gt;
  &lt;li&gt;사내 고유 정책이 많다면 처음부터 커스텀 정책이나 OPA 연계를 염두에 두고 설계하는 게 낫다.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;결국 핵심은 도구가 아니라 습관이다. 보안을 파이프라인 왼쪽으로 당겨서, 개발자가 프로덕션 사고를 내기 전에 스스로 고치게 만드는 것. Checkov는 그 습관을 자동화해주는 도구일 뿐이다.&lt;/p&gt;

&lt;h2&gt;참고 자료&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://dev.to/cristhiancarlosmamanic/securing-your-terraform-infrastructure-with-checkov-and-github-actions-2c86&quot;&gt;Securing Your Terraform Infrastructure with Checkov and GitHub Actions (원문, Dev.to)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/Cristhian465/Research-Team-Work-N-02-Sast-tools-for-infraestructure&quot;&gt;원문 데모 저장소 (GitHub)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.checkov.io/&quot;&gt;Checkov 공식 사이트&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/bridgecrewio/checkov-action&quot;&gt;bridgecrewio/checkov-action (GitHub Action)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Tech_News</category>
      <category>IAC</category>
      <category>terraform</category>
      <author>TeEm0</author>
      <guid isPermaLink="true">https://teem0.tistory.com/84</guid>
      <comments>https://teem0.tistory.com/84#entry84comment</comments>
      <pubDate>Tue, 7 Jul 2026 09:00:06 +0900</pubDate>
    </item>
    <item>
      <title>htop/top의 숫자, 진짜로 읽을 줄 아나요? &amp;mdash; CPU&amp;middot;메모리&amp;middot;Load Average 완전 분석</title>
      <link>https://teem0.tistory.com/82</link>
      <description>&lt;h2&gt;1. htop 켜놓고 숫자만 멍하니 보고 있진 않나요?&lt;/h2&gt;
&lt;p&gt;운영 중인 서버가 느려졌다는 알람이 오면 십중팔구 SSH로 붙어서 &lt;code&gt;htop&lt;/code&gt;부터 친다. 그런데 막상 화면을 보면 뭘 봐야 할지 애매한 순간이 많다. CPU 막대가 빨간색으로 꽉 찼는데 정작 CPU를 잡아먹는 프로세스는 안 보이고, Load Average는 8인데 CPU 사용률은 20%밖에 안 되고, 메모리 VIRT는 20GB라는데 실제 서버 램은 8GB인 상황 같은 것들 말이다.&lt;/p&gt;
&lt;p&gt;이런 게 헷갈리는 이유는 대부분 &lt;strong&gt;각 지표가 커널 입장에서 실제로 뭘 세고 있는지&lt;/strong&gt;를 모르기 때문이다. 원문(&lt;a href=&quot;https://peteris.rocks/blog/htop/&quot;&gt;peteris.rocks/blog/htop&lt;/a&gt;)의 저자도 처음엔 &quot;2코어 머신에서 Load Average 1.0이면 CPU 50%겠지&quot;라고 생각했다가 그게 틀렸다는 걸 알고 전부 파헤쳐봤다고 한다. 나도 실무에서 이 착각 때문에 엉뚱한 데를 붙잡고 시간 날린 적이 여러 번 있다.&lt;/p&gt;
&lt;p&gt;이 글에서는 htop/top의 각 지표가 커널의 프로세스 스케줄링, 메모리 구조와 어떻게 연결되는지 실무 시선에서 정리한다. 2019년 원문이지만 이 내용은 커널 근본 동작이라 지금도 그대로 유효하다.&lt;/p&gt;

&lt;h2&gt;2. 핵심: 지표들이 실제로 세고 있는 것&lt;/h2&gt;

&lt;h3&gt;CPU 상태값 — us, sy, ni, id, wa, hi, si, st&lt;/h3&gt;
&lt;p&gt;top 상단 CPU 라인에 나오는 이 알파벳들은 CPU가 시간을 어디에 썼는지를 백분율로 쪼갠 것이다. htop에서는 막대 색으로도 표현된다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;us (user)&lt;/strong&gt;: 유저 공간 프로세스 실행 시간. 애플리케이션 코드가 CPU를 쓰는 시간.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;sy (system)&lt;/strong&gt;: 커널 공간 실행 시간. 시스템 콜, 네트워크 스택 처리 등.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ni (nice)&lt;/strong&gt;: nice 값이 조정된(우선순위 낮춘) 프로세스의 유저 시간.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;id (idle)&lt;/strong&gt;: CPU가 놀고 있는 시간.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;wa (iowait)&lt;/strong&gt;: CPU가 디스크/네트워크 I/O 완료를 기다리며 놀고 있는 시간. 이게 높으면 CPU가 아니라 I/O가 병목이다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;hi (hardware irq)&lt;/strong&gt;: 하드웨어 인터럽트 처리 시간.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;si (software irq)&lt;/strong&gt;: 소프트웨어 인터럽트 처리 시간.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;st (steal)&lt;/strong&gt;: 가상화 환경에서 하이퍼바이저가 다른 VM에 CPU를 줘서 내 VM이 뺏긴 시간.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;실무에서 특히 중요한 건 &lt;strong&gt;wa&lt;/strong&gt;와 &lt;strong&gt;st&lt;/strong&gt;다. wa가 높으면 CPU를 아무리 증설해도 소용없고 디스크나 네트워크를 봐야 한다. st가 꾸준히 높으면 클라우드에서 &quot;이웃 VM 때문에 내 CPU가 굶고 있다&quot;는 신호다 — AWS 같은 데서 t 계열 인스턴스 크레딧 소진이나 노이지 네이버 문제를 의심해볼 수 있다.&lt;/p&gt;
&lt;p&gt;순간 CPU 사용률만 딱 보고 싶으면 &lt;code&gt;mpstat&lt;/code&gt;가 편하다. 원문에서도 이 도구를 쓴다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ sudo apt install sysstat -y
$ mpstat 1
Linux 4.4.0-47-generic (hostname)   12/03/2016   _x86_64_  (1 CPU)

10:16:21 PM  CPU  %usr %nice %sys %iowait %irq %soft %steal %guest %gnice %idle
10:16:21 PM  all  0.00  0.00 100.00 0.00  0.00  0.00   0.00   0.00   0.00  0.00&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;위 예시는 &lt;code&gt;cat /dev/urandom &amp;gt; /dev/null&lt;/code&gt;을 돌리는 중이라 %sys가 100%다. urandom 생성이 커널 작업이라 유저 시간이 아니라 시스템 시간으로 잡힌 것. 이런 식으로 어느 컬럼이 튀는지 보면 병목 지점이 보인다.&lt;/p&gt;

&lt;h3&gt;메모리 — VIRT, RES, SHR의 차이&lt;/h3&gt;
&lt;p&gt;이 세 개를 헷갈리면 &quot;메모리 누수 났다!&quot;라고 오진하기 딱 좋다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;VIRT (Virtual)&lt;/strong&gt;: 프로세스가 예약(reserve)한 전체 가상 메모리 주소 공간. 실제로 물리 메모리에 올라와 있지 않은 것도 포함된다. mmap한 파일, 아직 안 건드린 힙, 공유 라이브러리까지 다 합친 숫자라 실제 램보다 훨씬 클 수 있다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;RES (Resident)&lt;/strong&gt;: 실제로 물리 RAM에 올라와 있는 크기. &lt;strong&gt;실무에서 &quot;이 프로세스가 진짜 램을 얼마나 먹나&quot;를 볼 때 보는 값이 이거다.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SHR (Shared)&lt;/strong&gt;: RES 중에서 다른 프로세스와 공유 가능한 부분(공유 라이브러리 등).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;JVM이나 Go처럼 큰 가상 주소 공간을 미리 잡아두는 런타임을 보면 VIRT가 수십 GB로 찍히는 게 정상이다. VIRT 숫자에 놀라지 말고 RES를 봐야 한다.&lt;/p&gt;

&lt;h3&gt;프로세스 상태값 — R, S, D, Z, T&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;ps&lt;/code&gt;의 STAT 컬럼이나 htop의 S 컬럼에 나오는 값이다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;R (Running)&lt;/strong&gt;: CPU에서 실행 중이거나 실행 대기열에 올라와 순서를 기다리는 상태.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;S (Sleeping)&lt;/strong&gt;: 인터럽트 가능한 대기. 뭔가 이벤트를 기다리며 자고 있음. 대부분의 유휴 프로세스가 여기.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;D (Uninterruptible sleep)&lt;/strong&gt;: 주로 디스크/네트워크 I/O를 기다리는 중단 불가 대기. &lt;strong&gt;이게 많으면 I/O 병목 신호다.&lt;/strong&gt; kill -9로도 잘 안 죽는 경우가 이 상태다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Z (Zombie)&lt;/strong&gt;: 종료됐지만 부모가 아직 exit 코드를 회수(wait) 안 한 상태. 프로세스 슬롯만 차지.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;T (Stopped)&lt;/strong&gt;: 시그널로 멈춘 상태(Ctrl+Z 등).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;원문 설명대로 Load Average의 &quot;load number&quot;는 &lt;strong&gt;R 상태 + D 상태&lt;/strong&gt; 프로세스 수를 센다. 그래서 CPU가 한가해도 D 상태가 잔뜩 쌓이면 Load Average가 치솟는 것이다.&lt;/p&gt;

&lt;h3&gt;Load Average — 코어 수와 함께 봐야 의미가 산다&lt;/h3&gt;
&lt;p&gt;원문에서 가장 강조하는 포인트다. Load Average는 실행 중이거나 실행 대기 중인 프로세스(R) + 중단 불가 대기 프로세스(D)의 개수를 지수 감쇠 이동평균으로 낸 값이다. 순수 CPU 사용률이 아니다.&lt;/p&gt;
&lt;p&gt;핵심 규칙: &lt;strong&gt;Load Average를 코어 수로 나눠서 봐라.&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;1코어 머신에서 Load 1.00 → CPU가 딱 포화 (한 번에 프로세스 하나만 돌리니까).&lt;/li&gt;
&lt;li&gt;2코어 머신에서 Load 1.00 → 절반만 쓰는 셈.&lt;/li&gt;
&lt;li&gt;2코어 머신에서 CPU 100% 포화 상태의 Load는 2.00.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;내 코어 수는 이렇게 확인한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ nproc
2
$ cat /proc/loadavg
1.00 0.69 0.35 2/124 1679&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;앞 세 숫자가 각각 1분/5분/15분 평균이고, 네 번째 &lt;code&gt;2/124&lt;/code&gt;는 (실행 중 프로세스 수)/(전체 프로세스 수), 마지막은 최근 할당된 PID다. 여기서 Load 1.00은 원문 예시처럼 &lt;code&gt;cat /dev/urandom&lt;/code&gt; 같은 CPU 바운드 프로세스 하나가 1코어를 꽉 잡고 있는 상황이다.&lt;/p&gt;
&lt;p&gt;단, 원문이 명확히 짚듯이 Load Average에는 D 상태(I/O 대기)도 포함되므로 &lt;strong&gt;Load Average만 보고 CPU 사용률을 역산하는 건 부정확하다.&lt;/strong&gt; &quot;Load는 높은데 CPU는 놀고 있는&quot; 상황이 바로 이 때문이다.&lt;/p&gt;

&lt;h2&gt;3. 실무 관점: 함정과 트러블슈팅&lt;/h2&gt;

&lt;h3&gt;흔한 함정 1 — Load 8인데 CPU는 20%, 대체 왜?&lt;/h3&gt;
&lt;p&gt;D 상태 프로세스가 원인일 가능성이 높다. 디스크가 느리거나(EBS 스로틀링, 노후 HDD), NFS 마운트가 응답을 안 하거나 할 때 프로세스들이 I/O를 기다리며 D 상태로 쌓인다. 이때 CPU %는 낮은데 Load만 치솟는다. 우선 &lt;code&gt;wa&lt;/code&gt; 값과 D 상태 프로세스를 확인해라.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# D 상태(uninterruptible) 프로세스만 골라보기
$ ps -eo pid,stat,comm | awk '$2 ~ /D/ {print}'
  1834 D    mysqld
  2001 D    kworker/u8:2&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;NFS가 죽어서 프로세스가 D 상태로 박히면 &lt;code&gt;ls&lt;/code&gt; 같은 명령조차 이런 식으로 무한 대기하거나, 강제 종료 시 아래 메시지를 만난다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ kill -9 1834
$ ps -o pid,stat,comm -p 1834
  PID STAT COMMAND
 1834 D    mysqld
# kill -9를 보내도 D 상태 프로세스는 즉시 죽지 않는다.
# 커널 I/O가 완료(또는 타임아웃)될 때까지 반환되지 않기 때문.&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;또 NFS 하드마운트 환경에서 자주 보는 커널 로그:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;nfs: server 10.0.1.20 not responding, still trying&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 메시지가 dmesg나 /var/log/messages에 뜨면 CPU를 볼 게 아니라 스토리지/네트워크를 봐야 한다는 확실한 신호다.&lt;/p&gt;

&lt;h3&gt;흔한 함정 2 — &quot;메모리 누수다!&quot; (사실은 VIRT를 본 것)&lt;/h3&gt;
&lt;p&gt;모니터링 알람이나 htop에서 VIRT 숫자만 보고 메모리 누수로 오진하는 경우가 정말 많다. 앞서 말했듯 VIRT는 예약된 가상 공간이라 실제 소비량과 다르다. 반드시 RES를 기준으로 봐라. RES가 시간에 따라 계단식으로 꾸준히 증가하고 GC나 재시작으로도 안 돌아온다면 그때 진짜 누수를 의심한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# RES 기준 상위 메모리 소비 프로세스 (ps로)
$ ps -eo pid,comm,rss,vsz --sort=-rss | head -5
  PID COMMAND           RSS      VSZ
 1834 java          2450112 12583920   # RES 약 2.4GB, VIRT 약 12GB
 2201 postgres       310220   890112
# RSS = htop의 RES(KB), VSZ = htop의 VIRT(KB)&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;흔한 함정 3 — 가상 CPU steal(st) 무시하기&lt;/h3&gt;
&lt;p&gt;온프레미스에서 클라우드로 옮긴 뒤 &quot;같은 스펙인데 왜 느리지?&quot; 싶으면 st를 봐라. 하이퍼바이저가 CPU를 뺏어가는 시간이 st로 잡힌다. 특히 버스트형 인스턴스는 크레딧이 소진되면 성능이 조여지는데, 이게 애플리케이션 지표로는 원인이 안 보이고 st 상승으로만 드러나는 경우가 있다. (인스턴스별 정확한 크레딧 동작은 클라우드 공식 문서 확인 필요.)&lt;/p&gt;

&lt;h3&gt;대안 도구&lt;/h3&gt;
&lt;p&gt;htop이 만능은 아니다. 상황별로 이런 도구가 낫다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;mpstat&lt;/strong&gt;: 순간 CPU 사용률을 상태별로 정확히.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;iostat / iotop&lt;/strong&gt;: wa가 높을 때 어느 디스크·프로세스가 I/O를 잡는지.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;pidstat&lt;/strong&gt;: 특정 프로세스의 CPU/메모리/I/O를 시계열로.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;/proc/&amp;lt;pid&amp;gt;/&lt;/strong&gt; 직접 읽기: 원문이 강조하듯 htop/top/ps 전부 결국 여기서 정보를 읽는다. 스크립트로 자동화할 땐 파일을 직접 파싱하는 게 낫다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;# 특정 프로세스의 상태 직접 확인
$ cat /proc/1834/status | grep -E 'State|VmRSS|VmSize'
State:   D (disk sleep)
VmSize:  12583920 kB
VmRSS:    2450112 kB&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;4. 정리&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;한 줄 요약:&lt;/strong&gt; htop의 숫자는 커널이 세고 있는 대상을 알아야 의미가 생긴다 — CPU는 wa/st를, 메모리는 VIRT 아닌 RES를, Load Average는 코어 수로 나눠서 D 상태와 함께 봐라.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;CPU 병목 의심&lt;/strong&gt; → us/sy 높음 + Load ≈ 코어 수. mpstat로 확인.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;I/O 병목 의심&lt;/strong&gt; → wa 높음 + D 상태 프로세스 다수 + Load만 치솟음. iostat/iotop으로.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;메모리 누수 의심&lt;/strong&gt; → RES가 지속 증가. VIRT는 무시.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;클라우드에서 원인 불명 성능 저하&lt;/strong&gt; → st 확인.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이 원리는 신규 인프라 엔지니어가 반드시 익혀두면 트러블슈팅 속도가 확 빨라지는 기본기다. 지표 하나하나가 커널의 어떤 상태를 반영하는지 알면, 알람을 받았을 때 엉뚱한 데를 파는 시간이 줄어든다.&lt;/p&gt;

&lt;h2&gt;참고 자료&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://peteris.rocks/blog/htop/&quot;&gt;htop explained — Explanation of everything you can see in htop/top on Linux (peteris.rocks)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/torvalds/linux/blob/master/kernel/sched/loadavg.c&quot;&gt;Linux kernel: kernel/sched/loadavg.c (Load Average 계산 원본)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://man7.org/linux/man-pages/man1/top.1.html&quot;&gt;man top(1) — 각 CPU 상태·필드 공식 설명&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://man7.org/linux/man-pages/man5/proc.5.html&quot;&gt;man proc(5) — /proc 파일 시스템 레퍼런스&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Tech_News</category>
      <category>htop</category>
      <category>TOP</category>
      <author>TeEm0</author>
      <guid isPermaLink="true">https://teem0.tistory.com/82</guid>
      <comments>https://teem0.tistory.com/82#entry82comment</comments>
      <pubDate>Mon, 6 Jul 2026 09:00:04 +0900</pubDate>
    </item>
  </channel>
</rss>