[{"content":"지난 글에서 Claude Code 의 세부 설정은 별도 글로 미뤘다. 그 별도 글이 이 글이다.\n처음 Claude Code 를 커스터마이징하려고 열어보면 표면이 흩어져 있다. settings.json, CLAUDE.md, slash commands, subagents, hooks, plugins — 같은 의도를 담을 자리가 여러 곳이라 \u0026ldquo;어디에 넣어야 하는가\u0026rdquo; 부터 막힌다. 한 파일에 다 몰면 CLAUDE.md 가 비대해지고, 나누면 어느 설정이 언제 활성화되는지 추적이 안 된다.\n축 하나만 잡으면 이 문제가 거의 사라진다. 언제 개입하는가. 이 글은 그 축으로 설정을 네 레이어로 정돈하는 이야기다.\n레이어 구조 레이어 언제 개입하나 책임 CLAUDE.md + Rules 항상 (매 턴 컨텍스트 로드) 컨벤션·가드라인 암묵지 Agents Skill 또는 모델이 위임할 때 컨텍스트 격리된 전문 역할 Skills 내가 호출할 때 재사용 가능한 workflow Hooks 도구 이벤트 전/후 자동 검증·자동화·가드레일 이 표가 글의 전부라고 해도 된다. 나머지는 각 레이어가 이 축 위에서 어떻게 자리 잡는지, 그리고 하나의 워크플로우에서 네 레이어가 어떻게 맞물리는지다.\nCLAUDE.md + Rules Claude 가 매 턴 컨텍스트에 싣고 시작하는 지식이다. 호출하지 않아도 항상 적용된다. 두 계층으로 나뉜다.\nCLAUDE.md 는 프로젝트/유저 단위의 최상위 컨텍스트다. 프로젝트 루트(./CLAUDE.md), .claude/CLAUDE.md, ~/.claude/CLAUDE.md 에 둘 수 있고, 여럿이면 계층 순으로 병합된다. 내 CLAUDE.md 에는 언어 독립적인 행동 규칙이 들어간다.\n# CLAUDE.md (발췌) - 접근법 거부 시: 즉시 멈추고 방향을 물어볼 것 - 변경 범위: 명시적으로 요청된 것만 변경할 것 - 커밋 금지: 명시적으로 요청받기 전까지 절대 커밋하지 말 것 - 접근법 사전 제안: 3개 이상 파일을 수정하거나 아키텍처에 영향을 주는 변경은 코드 수정 전에 접근법을 먼저 제안하고 승인 후 실행할 것 rules/*.md 는 언어·도메인별로 쪼개진 하위 규칙이다. ~/.claude/rules/ 또는 .claude/rules/ 에 .md 로 두면 재귀 탐색되어 로드된다. paths frontmatter 를 쓰면 특정 파일 패턴에만 적용되는 scoped rule 도 가능하다.\n# rules/go.md (발췌) - Get prefix 금지: GetName() ❌ → Name() ✅ - 에러 wrapping: return err 금지. fmt.Errorf(\u0026#34;context: %w\u0026#34;, err) - panic 금지 (라이브러리): main/테스트에서만 허용 # rules/typescript.md (발췌) - Destructuring 우선: 함수 파라미터 ({ server, db }: Config) - 중괄호 필수: if (x) return; ❌ → if (x) { return; } ✅ - 데이터 형태 → type, 구현 계약 → interface - enum 사용: as const 객체 대신 enum # rules/code-principles.md (발췌, 언어 공통) - fail-fast: validation 실패 시 즉시 raise/throw - 조기 반환: 중첩 if 대신 Guard Clause - 불변성 우선: mutation 필요 시 명시적 범위 한정 - 순수 함수 우선: side effect 는 호출 경계로 밀어내기 - Any 타입 금지: 제네릭, union, 구체 타입으로 해결 언어 규칙을 CLAUDE.md 에 직접 쓰는 대신 rules/ 에 파일 단위로 나눠두면 CLAUDE.md 가 부풀지 않는다.\n구분 포인트: \u0026ldquo;호출 여부와 무관하게 항상 깔려 있어야 하는가\u0026rdquo; 가 이 레이어의 기준이다.\nAgents ~/.claude/agents/\u0026lt;name\u0026gt;.md 에 정의한다. Skill 이나 모델이 위임하면 별도 컨텍스트 윈도우 에서 실행되고 결과만 돌려준다. 메인 세션에 agent 의 작업 과정이 들어오지 않는 것이 핵심이다. @agent-name 으로 직접 멘션해 호출할 수도 있다.\n# agents/architect.md (frontmatter) name: architect description: 아키텍처 분석, 디버깅 근본 원인 진단 tools: [\u0026#34;Read\u0026#34;, \u0026#34;Grep\u0026#34;, \u0026#34;Glob\u0026#34;] model: opus 내 설정의 agent 목록이다.\nAgent 역할 호출되는 곳 architect 구조 분석, 설계 리뷰, 디버깅 /code Stage Pre planner 작업 분해, 실행 계획 수립 /code Stage Pre code-reviewer 스펙 준수 + 코드 품질 리뷰 /code Stage Post, PR review security-reviewer OWASP Top 10 기반 보안 취약점 분석 /code Stage Post database-reviewer 스키마, 쿼리, 마이그레이션 리뷰 /code Stage Post (DB 변경 시) verify-agent 빌드 → 타입 → 린트 → 테스트 파이프라인 /code Stage Post/Fix refactor-cleaner dead code 제거, 코드 정리 /code Stage Clean Skill 은 오케스트레이션, Agent 는 한 책임의 깊은 실행. 다음 Skills 섹션의 다이어그램에서 이들이 어떻게 호출되는지 보인다.\n구분 포인트: \u0026ldquo;메인 컨텍스트와 분리되어야 하는가\u0026rdquo; 가 Agent 의 기준이다.\nSkills Skills 는 명시적으로 호출하는 워크플로우 레시피다. ~/.claude/skills/\u0026lt;name\u0026gt;/SKILL.md 에 정의하고 /skill-name 으로 부른다. 프롬프트, 허용 도구, 모델 지정이 한 파일에 묶인 단위다. 매번 같은 지시를 타이핑하는 대신 \u0026ldquo;이 상황에서는 이 skill\u0026rdquo; 이라는 레시피가 대신 선다.\n/code-brainstorming → /code 가장 잘 작동하는 자리는 반복되는 개발 워크플로우다. 내 설정에서 가장 무거운 skill 쌍인 /code-brainstorming 과 /code 를 파이프로 잇는 예시로 본다.\n/code-brainstorming 은 구현 전 단계를 담는다. 요구사항 탐색, 분리 여부 판단, 설계 문서(DESIGN.md) + sub-task 별 계획 파일(NN-\u0026lt;task\u0026gt;.md) + 의존성 그래프(_dag.yaml) 를 .claude/plans/\u0026lt;topic\u0026gt;/ 아래에 쌓고, architect 에이전트로 설계 리뷰까지 돌린 뒤 멈춘다. 산출물은 다음 skill 의 입력이다.\nflowchart TD I[\"아이디어 입력/code-brainstorming\"] --\u003e C[\"컨텍스트 수집프로젝트 타입 · CLAUDE.md · git log\"] C --\u003e R[\"요구사항 탐색AskUserQuestion 1:1\"] R --\u003e A[\"접근법 2-3개 제시 + 추천\"] A --\u003e PM[\"pm-code-agent분리 판단\"] PM --\u003e|SINGLE| D1[\"DESIGN.md + _dag.yaml01-main.md\"] PM --\u003e|SPLIT| D2[\"DESIGN.md + _dag.yamlNN-task.md × N\"] D1 --\u003e AR[\"architect agent 리뷰\"] D2 --\u003e AR AR --\u003e|NEEDS REVISION| D2 AR --\u003e|APPROVED| S[\"statusdraft → ready\"] S --\u003e O[(\".claude/plans/\u0026lt;topic\u0026gt;/\")] /code 는 그 디렉토리를 받아 구현 파이프라인을 자체 실행한다. 여기서 흥미로운 점은 상세 로직을 SKILL.md 본문에 담지 않고 references/stage-*.md 로 분리했다는 것. 필요한 순간에만 Read 로 불러온다. 덕분에 평소에는 컨텍스트에 오케스트레이션만 상주하고, 해당 단계에 진입할 때만 stage 문서가 로드된다.\n_dag.yaml 의 sub-task 가 위상정렬된 뒤, 각 sub-task 마다 다섯 stage 를 거친다.\nflowchart TD I[(\"/code 입력.claude/plans/\u0026lt;topic\u0026gt;/\")] --\u003e L[\"_dag.yaml 로드위상정렬 + status 게이트\"] L --\u003e PRE[\"Stage Prearchitect agent+ planner agent\"] PRE --\u003e IMP[\"Stage Impl병렬 구현(에이전트 팀)\"] IMP --\u003e POST[\"Stage Post (병렬)code-reviewersecurity-reviewerdatabase-reviewerverify-agent\"] POST --\u003e|FAIL| FIX[\"Stage Fixverify-agent자동 수정\"] FIX --\u003e POST POST --\u003e|PASS| CLEAN[\"Stage Cleanrefactor-cleaneragent\"] CLEAN --\u003e DONE[\"statusready → done\"] DONE --\u003e N{\"다음 sub-task?\"} N --\u003e|있음| PRE N --\u003e|없음| R[\"종합 리포트\"] Pre (stage-pre.md) — architect + planner 를 순차 호출해 구조 분석과 실행 계획을 수립한다. 결과를 sub-task 문서에 ## Plan 으로 append. Impl (stage-impl.md) — 계획을 바탕으로 병렬 구현 을 실행한다. 단순 작업이면 리더가 직접, 복잡하면 팀원 에이전트를 spawn 한다. Post (stage-post.md) — code-reviewer, security-reviewer, database-reviewer, verify-agent 를 병렬 호출 해 종합 검증한다. PASS / NEEDS ATTENTION / FAIL 판정이 여기서 나온다. Fix (stage-fix.md, 조건부) — Post 가 FAIL 이면 verify-agent 를 돌려 fixable 에러를 자동 수정한 뒤 Post 를 재실행한다. retry-policy.md 의 상한(기본 3 회)과 \u0026ldquo;동일 에러 2 회 연속 → 정체 탐지\u0026rdquo; 규칙을 따른다. Clean (stage-clean.md) — refactor-cleaner 로 dead code, 미사용 import, 중복을 정리한다. 여기서 실패는 non-critical 로 취급해 warning 만 남기고 진행한다. 단, clean 이 빌드를 깨면 Post 재실행에서 잡힌다. 한 sub-task 가 다섯 stage 를 통과하면 해당 NN-\u0026lt;task\u0026gt;.md 의 frontmatter status 가 ready → done 으로 승격된다. 이 전환이 재실행 방지 의 장치다 — 같은 plan 을 다시 /code 로 돌려도 done 인 task 는 스킵되고 남은 것만 실행된다.\n이 두 skill 이 Skill 레이어의 기본 패턴을 보여준다. 상태 있는 파일을 입출력으로 주고받는 파이프라인, 그리고 상세 로직을 references 로 분리 해 컨텍스트를 아끼는 구조.\n/github-ship — branch 에서 merge 까지 구현이 끝나면 코드를 올려야 한다. /github-ship 은 branch 생성부터 merge 까지를 하나의 파이프라인으로 묶는다. 원래 /git-branch, /git-commit, /github-pr-push 세 skill 로 나눠져 있던 것을 하나로 통합한 결과다.\n다섯 phase 를 거친다.\nBranch — 변경 내용의 관심사를 분석해 PR 분리 여부를 결정하고 컨벤션에 맞는 브랜치를 생성한다. Commit — staged diff 를 리뷰해 관심사별로 분리하고 컨벤션에 맞춘 메시지를 쓴다. Push \u0026amp; PR — 정적 분석(lint/typecheck) 후 push, gh pr create 로 PR 을 올린다. Review — 변경 규모(TRIVIAL/SMALL/MEDIUM/LARGE)에 따라 리뷰 강도를 조절해 에이전트 병렬 호출. 이슈 발견 시 수정 → push → 재리뷰 루프. Merge — 리뷰 이슈가 모두 해결되면 squash/merge 선택 후 머지. /code 는 전체 sub-task 가 PASS 이면 /github-ship 실행 여부를 자동으로 물어본다. 승인하면 코드 구현부터 PR merge 까지 끊김 없이 이어진다.\n별도로 남아 있는 PR 관련 skill 은 둘이다.\n/github-pr-review \u0026lt;PR번호\u0026gt; — 이미 올라간 PR 을 심층 리뷰한다. github-ship 내부 Phase 4 와 같은 에이전트를 쓰지만 독립 호출용. /github-pr-respond — PR 리뷰 코멘트를 순차로 돌며 반영 여부를 확인하고 답변을 게시한다. CLI 선택 이유 도구를 붙이는 방식은 크게 MCP 서버와 CLI 호출 두 갈래다. git / GitHub 영역에는 양쪽 구현이 다 있다. 그런데 위 skill 들은 전부 gh, git 같은 CLI 를 Bash(...:*) allowed-tools 로 호출한다. 이유는 컨텍스트 절약 이다.\nMCP 서버는 연결되는 순간 자기 tool 카탈로그를 컨텍스트에 상주시킨다. tool 하나당 수백 토큰, 서버 하나가 20 여 개 도구를 노출하면 \u0026ldquo;아무것도 하지 않아도\u0026rdquo; 수천 토큰이 소비된다. 서버 세 개를 붙이면 타이핑 한 글자 하기 전에 4,000 토큰 이상이 소비된다는 측정이 있다 (Scott Spence). 반면 CLI 는 호출 시점에만 토큰을 소비한다. 실제 비교에서 CLI 는 같은 작업을 두고 MCP 대비 토큰 소모를 약 68% 줄인 것으로 보고되며 (BSWEN — MCP vs CLI), 월 운영 비용 기준으로는 4~32 배 차이 까지 나타났다 (BSWEN — Token usage).\nAnthropic 도 이 비용을 인지해 Tool Search 같은 지연 로딩 최적화를 도입했고, MCP 사용 시 에이전트 토큰 소모를 46.9% 줄였다고 밝혔다 (Joe Njenga, Medium). 그럼에도 git 과 GitHub 처럼 이미 성숙한 CLI 가 있는 도메인은 skill + Bash 조합이 여전히 가장 가볍다. github-ship 을 비롯한 git/GitHub skill 들이 MCP 없이 CLI 로만 구성된 이유다.\n구분 포인트: \u0026ldquo;내가 호출할 것인가, 모델이 알아서 부를 수 있는가\u0026rdquo; 의 두 층이 Skill 레이어 내부의 축이다. disable-model-invocation 플래그가 그 경계를 그린다 — 위험하거나 되돌리기 어려운 쓰기 작업은 잠가두고, 일상적으로 auto-trigger 되어야 하는 것은 풀어둔다.\nHooks Hooks 는 호출되지 않는다. 도구 이벤트에 반응해서 자동으로 실행된다. settings.json 의 hooks 에 PreToolUse / PostToolUse 로 등록하면, 특정 도구 호출 전후에 셸 스크립트가 개입한다.\nHooks 의 자리는 크게 둘이다.\n가드레일 — 위험한 명령을 실행 전에 차단한다. remote-command-guard.sh 는 Bash 호출 전(PreToolUse)에 끼어들어 rm -rf, curl | sh, /etc/passwd 접근 같은 카테고리를 검사하고, 걸리면 exit 2 로 차단한다. 자동화 — 파일 수정 후에 포매터를 돌리거나(format-file.sh), 보안 관련 파일 수정 시 리뷰를 권고하거나(security-auto-trigger.sh), 모든 도구 출력에서 시크릿을 마스킹하는(output-secret-filter.sh) 일이 여기 들어간다. \u0026ldquo;매번 손으로 하고 싶지 않지만 매번 해야 하는 것\u0026rdquo; 의 자리다. Permission allow/deny 는 Hooks 와 짝을 이룬다. settings.json 의 permissions.deny 는 정적 필터다. Bash(*rm -rf*) 같은 패턴을 선언하면 매칭되는 명령은 아예 도구 호출로 넘어가지도 않는다. 그 위에 Hooks 가 동적 검사를 추가한다. 정적 필터로 거르기 어려운 맥락 의존적 위험(특정 경로로의 redirect, 조건부 조합 같은 것)을 스크립트가 판단한다. 정적 선언 + 동적 검사 의 두 층이 한 레이어로 묶여 가드레일 구실을 한다.\n구분 포인트: \u0026ldquo;도구 이벤트에 자동으로 개입해야 하는가\u0026rdquo; 가 Hooks 의 기준이다. 호출이 없어야 한다는 조건이 Skills/Agents 와 Hooks 를 가른다.\n통합 워크플로우 레이어 하나씩 보면 각자의 책임이 선명하지만, 실제로는 하나의 작업 흐름에서 네 레이어가 동시에 실행된다. 한 가지 플로우로 그려본다.\n/code-brainstorming \u0026quot;새 인증 모듈 설계\u0026quot; 를 친다 → Skill 이 움직인다. 그 Skill 이 내부에서 architect 와 planner 를 dispatch 한다 → Agents 가 격리 컨텍스트에서 설계 분석과 단계 분해를 한다. 이 과정 내내 매 턴 컨텍스트에 언어별 규칙과 코드 원칙이 로드되어 있다 → Rules 가 조용히 깔려 있다. 설계가 끝나 구현으로 들어가면 /code \u0026lt;plan-dir\u0026gt; 이 파일을 쓰기 시작한다 → 매번 Edit / Write 가 호출된다. 그때마다 Hooks 가 반응한다. format-file.sh 가 포매터를 돌리고, code-quality-reminder.sh 가 에러 핸들링·불변성 점검을 상기시키고, 보안 파일이면 security-auto-trigger.sh 가 리뷰를 권고한다. 파이프라인 마지막에 /code 는 다시 verify-agent 를 호출한다 → 빌드·타입·린트·테스트가 격리 실행되어 결과만 돌아온다. 전체 PASS 이면 /code 가 /github-ship 실행 여부를 묻는다 → 승인하면 다시 Skill 이 움직여 branch → commit → push → review → merge 까지 이어진다. 한 작업에 네 레이어가 전부 참여하지만 각자의 책임은 겹치지 않는다. 내가 부른 것 (Skills), Skill 이 위임한 것 (Agents), 항상 깔려 있던 것 (Rules), 이벤트에 자동으로 반응한 것 (Hooks) — 네 가지는 서로의 자리를 침범하지 않는다.\n정리 새 설정을 어디에 넣을지 결정할 때 네 질문만 거치면 된다.\n항상 깔려야 하는가? → CLAUDE.md + Rules 도구 이벤트에 자동 반응해야 하는가? → Hooks 호출해서 실행되는 workflow 인가? → Skills 메인 컨텍스트와 분리 실행되어야 하는가? → Agents 둘 이상에 걸리면 책임을 쪼갠다. 설정 전체는 .dotfiles/claude/ 를 참고.\n","permalink":"https://wid-blog.pages.dev/posts/tech/devenv/claude-code-config-layers/","summary":"settings.json, CLAUDE.md, slash commands, subagents, hooks. Claude Code 커스터마이징 표면은 \u0026lsquo;언제 개입하는가\u0026rsquo;라는 축 하나로 네 레이어로 정돈된다.","title":"Claude Code 설정을 레이어로 나누기"},{"content":"Claude Code 를 본격적으로 쓰기 시작하면서 CLI 기반 에디터의 활용도가 부쩍 높아졌다. GUI 에디터에서 터미널을 띄우던 흐름이, 터미널 안에서 모든 것을 띄우는 흐름으로 뒤집혔다.\n이 글은 위 화면을 만드는 dotfiles 의 구성이다. 같은 환경을 다른 머신에 그대로 옮기는 방법, 즉 git clone 한 줄과 bash setup.sh 한 줄로 끝나는 부트스트랩에 대한 글이기도 하다.\nClaude Code 의 세부 설정 — skills, agents, hooks, MCP 같은 것들 — 은 별도 글로 정리할 예정이라 이번에는 짧게만 짚는다.\n스택 구성 터미널 에뮬레이터는 alacritty. 그 창 안에서 화면을 셋으로 나누는 것이 tmux. 각 패인에서 실행되는 셸이 zsh. 좌상 패인의 에디터가 LazyVim 기반의 nvim, 우 30% 패인에서 Claude Code 가 AI 페어로 동작한다.\n도구 역할 alacritty 터미널 에뮬레이터 tmux 세션/창/패인 멀티플렉서 zsh 셸 nvim (LazyVim) 에디터 (좌상 패인) Claude Code AI 페어 (우 30% 패인) 이 글은 그 순서대로 — alacritty, tmux, zsh, nvim, Claude Code — 각 도구가 어떤 역할을 맡고 왜 그렇게 선택했는지 본다.\nalacritty alacritty는 Rust 로 작성된 크로스플랫폼 GPU 가속 터미널 에뮬레이터다. 자체 기능을 최소화하고, 화면 분할이나 세션 관리는 다른 도구에 위임하는 설계를 따른다.\n터미널 에뮬레이터로 alacritty 를 골랐다. 이유는 셋이다.\nGPU rendering — OpenGL 기반 렌더링으로 입력 지연이 작다. config-as-code — 단 하나의 alacritty.toml 에 모든 설정이 모인다. 어디 저장됐는지 찾아 헤맬 일이 없다. 단순함 — alacritty 는 탭, 분할, 세션 같은 기능을 의도적으로 포함하지 않는다. 이 자리는 tmux 가 채운다. 마지막 항목이 핵심이다. 분할과 세션을 alacritty 에 맡기지 않고 tmux 에 위임하면 같은 추상이 macOS 와 Linux 양쪽에서 동일하게 동작한다. 아래 계층이 단순할수록 위 계층의 이식성이 높아진다고 봤다.\n공식 기본 설정은 거의 비어 있다. 색상도 폰트도 창 장식도 사용자가 직접 채워 넣기 전까지 alacritty 는 가장 순수한 \u0026ldquo;터미널\u0026rdquo; 에 가깝다. 이 빈 상태가 config-as-code 의 출발점이 된다.\n내가 적용한 커스터마이징은 단순하다. 창 장식을 꺼서 macOS 의 타이틀 바를 없앴고, 여백을 빼서 픽셀 군더더기를 없앴고, 폰트는 nvim 의 devicons 가 렌더되도록 nerd font 계열로 두었고, 색상은 catppuccin mocha 를 toml 에 직접 적었다. 외부 yml/include 없이 한 파일에서 끝난다.\n키바인딩은 Cmd 키 조합을 ESC 시퀀스로 변환한다.\n[keyboard] bindings = [ { chars = \u0026#34;\\u001Bh\u0026#34;, key = \u0026#34;H\u0026#34;, mods = \u0026#34;Command\u0026#34; }, { chars = \u0026#34;\\u001Bl\u0026#34;, key = \u0026#34;L\u0026#34;, mods = \u0026#34;Command\u0026#34; }, { chars = \u0026#34;\\u001Bw\u0026#34;, key = \u0026#34;W\u0026#34;, mods = \u0026#34;Command\u0026#34; }, ] macOS 만의 문제가 하나 있다. Cmd+H 가 OS 메뉴 레벨에서 \u0026ldquo;Hide Application\u0026rdquo; 으로 가로채진다. 이 키는 alacritty 의 키바인딩을 통해 ESC+h (즉 vim 의 M-h) 로 변환되어 nvim 에 전달돼야 하는데, AppKit 이 먼저 소비하면 그 변환이 일어나지 않는다. 그래서 setup-macos.sh 가 마지막에 한 줄을 추가한다.\ndefaults write org.alacritty NSUserKeyEquivalents -dict-add \u0026#34;Hide Alacritty\u0026#34; \u0026#34;\u0026#34; 이 한 줄로 alacritty 의 nvim 통합이 macOS 에서도 동작한다. 이 결정은 설정 파일로 해결되지 않아 설치 스크립트에 들어갔다.\ntmux tmux는 터미널 멀티플렉서다. 하나의 터미널 안에서 여러 세션, 창, 패인을 관리하고, 세션을 detach 해도 프로세스가 유지된다.\nalacritty 위에서 화면을 나누는 일은 tmux 가 한다. 그래서 alacritty 에는 탭이 없고, 대신 tmux 세션 / 창 / 패인이 그 자리를 채운다.\n설정은 기본값에서 크게 벗어나지 않는다. prefix 는 C-b 그대로 유지한다 (C-a 는 readline 의 line-start 와 충돌해 셸에서 방해가 된다). copy mode 는 vim 키로 동작하게 하고, nvim 과의 ESC 지연은 제거했다. 마지막 항목은 작은 설정이지만 nvim 사용자에게 효과가 크다.\ntmux 는 ESC 키를 prefix 나 meta 시퀀스의 시작으로 해석하기 위해 대기 시간을 둔다. set -gs escape-time 0 으로 이를 제거하면 nvim 에서 모드 전환이 즉시 반영된다.\n분할 키바인딩에 두 가지 결정이 글의 흐름과 직결된다. 우측에 Claude Code 패인을 띄우는 단축키, 그리고 그 배치를 한 키로 정리하는 단축키다. 뒤에 설명할 nc 함수는 이 분할을 함수 한 번으로 자동으로 구성하는 상위 도구에 해당한다.\nbind i split-window -fh -p 30 -c \u0026#34;#{pane_current_path}\u0026#34; \u0026#34;claude\u0026#34; bind o split-window -v -l 15% -c \u0026#34;#{pane_current_path}\u0026#34; prefix i 는 우측에 Claude Code 패인을, prefix o 는 하단에 셸 패인을 생성한다. nc 함수가 자동화하는 분할을 수동으로도 실행할 수 있다.\n또 하나 중요한 결정은 vim 과 tmux 의 패인 이동을 같은 키로 통합한 것이다. C-h/j/k/l 을 prefix 없이 누르면, nvim 안에서는 nvim 의 좌측 창으로, 셸 패인에서는 tmux 의 좌측 패인으로 이동한다. 현재 pane 의 프로세스가 vim 계열인지를 tmux 쪽에서 검사해 자동으로 분기한다. 도구 경계가 사라진다. 손가락이 prefix 키를 거치지 않고 세 패인 사이를 이동할 수 있다.\nis_vim=\u0026#34;ps -o state= -o comm= -t \u0026#39;#{pane_tty}\u0026#39; \\ | grep -iqE \u0026#39;^[^TXZ ]+ +(\\\\S+\\\\/)?g?(view|n?vim?x?)(diff)?$\u0026#39;\u0026#34; bind-key -n \u0026#39;C-h\u0026#39; if-shell \u0026#34;$is_vim\u0026#34; \u0026#39;send-keys C-h\u0026#39; \u0026#39;select-pane -L\u0026#39; bind-key -n \u0026#39;C-j\u0026#39; if-shell \u0026#34;$is_vim\u0026#34; \u0026#39;send-keys C-j\u0026#39; \u0026#39;select-pane -D\u0026#39; bind-key -n \u0026#39;C-k\u0026#39; if-shell \u0026#34;$is_vim\u0026#34; \u0026#39;send-keys C-k\u0026#39; \u0026#39;select-pane -U\u0026#39; bind-key -n \u0026#39;C-l\u0026#39; if-shell \u0026#34;$is_vim\u0026#34; \u0026#39;send-keys C-l\u0026#39; \u0026#39;select-pane -R\u0026#39; is_vim 이 패인의 프로세스를 검사한다. vim 계열이면 키 입력을 nvim 에 전달하고, 아니면 tmux pane 이동을 실행한다.\nzsh zsh는 Bash 호환 셸로, 강력한 자동완성과 확장된 글로빙, 플러그인 생태계가 특징이다. macOS Catalina 이후 기본 셸이기도 하다.\nzsh 는 두 가지로 구성된다. PATH/환경 을 잡는 .zshrc 와 함수/alias 를 모아둔 aliases.zsh. ZDOTDIR 로 ~/.config/zsh 를 가리킨 뒤, .zshrc 가 그 디렉토리의 *.zsh 를 모두 source 한다.\nZDOTDIR=$HOME/.config/zsh for _zsh_conf in $ZDOTDIR/*.zsh(N); do source \u0026#34;$_zsh_conf\u0026#34; done 이 패턴 덕분에 alias / 함수 / 플러그인 설정을 파일로 분리해서 추가할 수 있다. 새 함수가 생기면 새 .zsh 파일을 만들고 끝이다. .zshrc 자체는 거의 안 건드린다.\nnc 함수의 정의는 다음과 같다.\nfunction nc() { if [[ -z \u0026#34;$TMUX\u0026#34; ]]; then echo \u0026#34;Not inside a tmux session. Run from within tmux.\u0026#34; return 1 fi local target=\u0026#34;${1:-$PWD}\u0026#34; local dir if [[ -d \u0026#34;$target\u0026#34; ]]; then dir=\u0026#34;$(realpath \u0026#34;$target\u0026#34;)\u0026#34; else dir=\u0026#34;$(realpath \u0026#34;$(dirname \u0026#34;$target\u0026#34;)\u0026#34;)\u0026#34; fi local nvim_pane nvim_pane=\u0026#34;$(tmux display-message -p \u0026#39;#{pane_id}\u0026#39;)\u0026#34; tmux split-window -h -c \u0026#34;$dir\u0026#34; -l 30% \u0026#34;claude; exec $SHELL\u0026#34; tmux select-pane -L tmux split-window -v -c \u0026#34;$dir\u0026#34; -l 15% tmux select-pane -t \u0026#34;$nvim_pane\u0026#34; nvim \u0026#34;$@\u0026#34; } 함수를 단계별로 살펴보자.\n첫 번째 가드는 tmux 안인지 확인한다. tmux 밖에서 nc 를 호출하면 분할 자체가 의미가 없으므로 즉시 종료한다. 메시지를 한 줄 출력하고 1 을 반환한다.\n다음은 작업 디렉토리 결정이다. 인자가 없으면 $PWD, 디렉토리면 그대로, 파일이면 그 부모 디렉토리를 사용한다. realpath 로 절대 경로화한다. 이 dir 이 세 패인 모두의 cwd 로 지정된다. nvim 으로 파일을 열고 옆 패인에서 git status 를 입력하면 같은 repo 가 보인다.\n다음은 현재 패인의 ID 저장이다. 분할이 끝난 뒤 nvim 으로 포커스를 되돌리려면 원래 패인의 id 가 필요한데, 분할 도중에 id 가 바뀔 수 있으므로 미리 저장한다.\n이후 분할을 두 번 수행한다.\n첫 번째 분할은 tmux split-window -h -c \u0026quot;$dir\u0026quot; -l 30% \u0026quot;claude; exec $SHELL\u0026quot; 로, 우측에 30% 폭의 패인을 만들고 claude 를 실행한다. exec $SHELL 을 붙이면 claude 종료 후 패인이 즉시 사라지지 않고 셸로 전환된다.\n두 번째 분할은 tmux select-pane -L 으로 좌측 (원래 nvim 자리) 으로 돌아간 뒤, tmux split-window -v -c \u0026quot;$dir\u0026quot; -l 15% 로 위/아래로 나눈다. 아래 15% 가 작은 터미널 패인이 된다.\n마지막으로 nvim 을 실행한다. 저장해둔 nvim_pane 으로 포커스를 이동한 뒤 (이제 좌상 패인이다) nvim \u0026quot;$@\u0026quot; 으로 에디터를 연다. 인자가 파일이면 해당 파일이 열리고, 디렉토리면 LazyVim 의 대시보드가 표시된다.\n결과적으로 아래와 같은 배치가 nc 한 번으로 구성된다.\n┌───────────────────────────┬──────────────┐ │ │ │ │ nvim │ claude │ │ (LazyVim) │ (30%) │ │ │ │ ├───────────────────────────┤ │ │ shell (15%) │ │ └───────────────────────────┴──────────────┘ 이 함수를 호출하는 alias 가 몇 개 있다.\nalias zrc=\u0026#34;nc ~/.config/zsh/\u0026#34; alias nvimrc=\u0026#34;nc ~/.config/nvim/\u0026#34; alias alc=\u0026#34;nc ~/.config/alacritty/\u0026#34; alias tlc=\u0026#34;nc ~/.tmux.conf\u0026#34; 즉 zrc 한 번이면 zsh 설정 디렉토리를 nvim 으로 열면서 옆에 Claude Code 가 떠 있는 상태가 된다. 설정 파일을 고치다가 바로 옆 pane 에서 검토를 받을 수 있다.\n다른 alias 도 몇 가지 있다. f 는 fzf 로 파일을 골라 nvim 으로 여는 단축키, g 는 lazygit, ?? 는 fabric-ai, ? 는 w3m 검색이다. 그 중 nc 가 이 글의 중심인 이유는, 함수 한 줄이 다섯 도구의 자리를 동시에 정의하기 때문이다.\nnvim Neovim은 Vim 의 리팩토링 포크다. 비동기 플러그인, 내장 LSP 클라이언트, Lua 기반 설정이 추가되었다. LazyVim은 그 위에 합리적 기본값과 모듈식 extras 시스템을 제공하는 설정 프레임워크다.\n에디터는 LazyVim 베이스의 nvim 이다. 처음부터 init.lua 를 짜는 대신 LazyVim 의 합리적 기본값을 받는 쪽을 골랐다. LSP, treesitter, finder, mason 기반 LSP 설치가 이미 묶여 있어서 직접 짜면 며칠이 걸린다. 그리고 언어별 extras 가 lazyvim.json 의 줄 단위로 켜고 꺼지기 때문에, 새 언어가 필요해지면 한 줄 추가 + :Lazy sync 한 번으로 LSP / treesitter / 포매터까지 한꺼번에 설치된다. 현재 14개 언어와 코딩/에디터/포매팅/테스트 extras 를 포함해 32개가 활성화되어 있다.\n커스터마이징은 두 디렉토리로 나뉜다. lua/config/* 는 LazyVim 의 기본값을 재정의하는 위치 (keymap, option, autocmd), lua/plugins/* 는 새 플러그인이나 extras 의 추가 옵션을 배치하는 위치다. 언어별 설정은 plugins/language/\u0026lt;lang\u0026gt;.lua 한 파일로 분리했다. go 를 더 쓰지 않게 되면 그 파일만 지우면 된다.\nlua/plugins/language/ ├── go.lua ├── html.lua ├── java.lua ├── markdown.lua └── typescript.lua nvim 자체에 대해서는 더 깊이 들어가지 않는다. 키맵, LSP 설정, 디버거 통합, snacks.nvim picker, harpoon2 워크플로우 등 각각이 별도 글 분량이다. 이 글의 범위는 \u0026ldquo;LazyVim 베이스에 모듈식으로 구성하는 패턴\u0026rdquo; 까지다.\nClaude Code 우측 30% 패인의 위치는 Claude Code 가 채운다. brew cask 한 줄 (cask \u0026quot;claude-code\u0026quot;) 로 설치되고, nc 함수가 그 자리를 구성한다. 화면 안에서의 역할은 단순하다. 좌측에서 코드를 편집하는 동안 우측에서 같은 디렉토리의 컨텍스트로 함께 작업하는 페어다.\ndotfiles 의 claude/ 모듈은 이보다 더 많은 것을 포함한다. ~/.claude/ 아래에 settings, agents, hooks, rules, skills 가 stow 되며, 이 파일들이 Claude Code 의 동작을 세부적으로 조정한다. agents 는 작업 단위 위임, hooks 는 파일 저장 시점의 자동화, skills 는 재사용 가능한 워크플로우, rules 는 언어별/공통 코드 컨벤션을 담당한다.\n다만 이번 글의 범위는 \u0026ldquo;다섯 도구가 한 화면에 모이는 패턴\u0026rdquo; 까지다. Claude Code 의 각 컴포넌트 (settings, agents, hooks, skills, MCP, output styles) 는 별도 글로 정리할 예정이다.\n이 글에서 정리할 한 가지는 단순하다. Claude Code 는 nc 함수가 만든 우 30% 패인 안에서 실행된다. 그 이상도 그 이하도 아니다. 배치가 도구를 호출하고, 도구는 배치 위에서 동작한다.\n한계와 트레이드오프 이 셋업이 안 맞는 경우가 몇 있다.\nGUI 디버거에 의존하는 작업. 브라우저 devtools, 대형 IDE 의 시각 디버거가 일상 도구라면 터미널 중심 배치는 두 도구 사이를 자주 왕복하게 만든다. 이 구성은 코드 편집 + 셸 + AI 페어가 99% 를 차지한다는 가정 위에 서 있다.\n협업 스크린쉐어. 동료에게 화면을 보여줄 때 nvim 키바인딩의 의도가 전달되지 않는 경우가 자주 있다. dd 가 한 줄을 삭제하는 모습을 처음 보는 사람은 낯설어한다. 페어 프로그래밍이 잦다면 GUI 에디터의 사회적 비용이 더 적다.\nLinux 셋업과의 차이. 같은 dotfiles 가 Linux 에서도 동작하지만, 이 글이 다루지 않은 부분 (Hyprland 윈도우 매니저, Kime 입력기, Linux 전용 패키지) 은 별개다. macOS 에서는 alacritty 가 터미널 에뮬레이터 역할을 맡지만, Linux 에서는 Hyprland 윈도우 매니저가 그 역할을 일부 담당한다.\n빠진 조각도 솔직히 짚는다. 입력기 (kime), 윈도우 매니저 (hypr), 키매핑 (karabiner) 은 이 글의 범위 밖이라 넣지 않았다. 한국어 개발자에게는 입력기 결정이 결국 영향을 주지만, 이건 분리된 글이 더 적합하다고 판단했다.\n부트스트랩 부트스트랩은 두 단계로 나뉜다. OS 별 패키지 설치 와 stow 기반 dotfiles 심링크.\n진입점은 setup.sh 다. 이 스크립트는 uname -s 로 OS 를 분기한 다음 setup-macos.sh 또는 setup-linux.sh 를 source 한다. macOS 라면 다음 순서로 일이 일어난다.\nXcode Command Line Tools — 없으면 설치, 있으면 통과. Homebrew — 없으면 공식 스크립트로 설치, 있으면 통과. brew bundle — Brewfile 에 적힌 패키지를 일괄 설치한다. alacritty, tmux, neovim, zsh 플러그인 매니저 (zinit), fzf/fd/ripgrep/zoxide 같은 검색 도구, lazygit, gh, claude-code 가 함께 설치된다. alacritty defaults write — macOS 의 Cmd+H 가 AppKit 메뉴에 가로채지지 않도록 NSUserKeyEquivalents 를 비운다. 그래야 alacritty 가 Cmd+H 를 받아서 vim 의 M-h 로 변환할 수 있다. OS 별 설치가 끝나면 setup.sh 본체로 돌아온다. 거기서 GNU stow 가 모듈별로 dotfiles 를 심링크한다.\nCOMMON_MODULES=(claude git lazygit nvim tmux obsidian zsh) for m in $COMMON_MODULES; do stow --restow \u0026#34;$m\u0026#34; done 각 모듈 디렉토리는 \u0026lt;module\u0026gt;/.config/\u0026lt;tool\u0026gt;/... 형태다. 예를 들어 nvim/.config/nvim/init.lua 는 stow 가 ~/.config/nvim/init.lua 로 심링크한다. XDG_CONFIG_HOME 규약을 그대로 따르므로 모듈 추가/제거가 디렉토리 단위로 깔끔하다.\nmacOS 에서는 여기에 alacritty, karabiner 가 추가로 stow 되고, 마지막으로 second-brain Obsidian vault 를 clone 해 obsidian-cli 의 기본 vault 로 등록한다. 이 두 단계가 끝나면 exec zsh 한 번으로 모든 설정이 적용된다.\n마무리 시작 지점으로 돌아가면 nc 한 번에 셋으로 분할된 화면이 있고, 그 안에 다섯 도구가 각자의 역할을 맡는다. 그 한 번 뒤에는 bash setup.sh 한 줄로 재현되는 dotfiles 가 있고, dotfiles 는 alacritty / tmux / zsh / nvim / Claude Code 를 어떤 역할로 배치할지에 대한 결정의 묶음이다.\n저장소는 다음과 같다.\ngit clone https://github.com/byunghak/.dotfiles.git ~/.dotfiles cd ~/.dotfiles \u0026amp;\u0026amp; bash setup.sh 같은 환경을 새 머신에 옮기는 데 두 줄이면 충분하다.\n다음 글들에서는 Claude Code 의 세부 (settings, agents, hooks, skills, MCP) 를 하나씩 다룬다. 이 글이 layout 의 역할 배치에 대한 글이라면, 다음 글들은 그 역할 안에서 실행되는 도구의 결정을 다루는 글이 된다.\n","permalink":"https://wid-blog.pages.dev/posts/tech/devenv/macos-dev-environment/","summary":"alacritty + tmux + nvim + zsh + Claude Code 다섯 도구가 한 화면에 모이는 개발 환경. setup.sh 한 줄로 재현되는 dotfiles 의 구성.","title":"macOS 개발 환경: dotfiles 공개"},{"content":"1년 넘게 쓰던 경품 에어팟을 버렸다.\n2022년, 백엔드 엔지니어로 지금 회사에 입사했다. 새로운 영역에서 배울 것이 많았고, 주어진 과제를 하나씩 해결하는 데 집중했다.\n기능을 만들고, 이슈를 해결하고, 다음 스프린트로 넘어갔다. 그 반복 자체는 문제가 아니었다.\n문제는 그 사이, 귀를 닫고 있었다는 것이다.\n기술적 맥락이나 판단 근거를 동료에게 전달할 때, \u0026ldquo;전달했다\u0026quot;고 생각했지만 실제로는 \u0026ldquo;전달되지 않은\u0026rdquo; 경우가 많았다. 코드 리뷰에서, 기술 논의에서, 장애 대응에서 — 머릿속에 있는 것을 상대방이 이해할 수 있는 형태로 풀어내는 데 서툴렀고, 다른 사람의 의견을 그 사람의 기준에서 듣는 데 서툴렀다.\n자신만의 틀에 갇혀 일하는 것이 습관이 되었고, 번아웃이 찾아왔다.\n여러 고민 끝에 3개월 휴직을 제안드렸다.\n번아웃을 단순히 쉬는 것으로 마무리하고 싶지 않았다. 부족한 부분이 무엇인지 회고하고, 개선해 나가는 시간을 가지려 한다.\n회사를 다니면서 문서화를 참 싫어했다. 필요한 건 알았지만, 글로 남기는 일 자체가 번거로웠다.\n그런데 커뮤니케이션은 머리로 이해한다고 느는 것이 아니었다. 꺼내놓고, 전달해보고, 상대의 반응을 마주해야 조금씩 쌓이는 것이었다.\n그래서 블로그를 시작한다. 잘 쓰려는 것이 아니라, 쓰고 내보내는 경험 자체를 쌓기 위해서다.\n기술적인 것이든 — 일하며 느낀 것이든 — 내 생각과 경험을 꺼내놓는 연습을 해보려 한다.\n\u0026ldquo;좋은 엔지니어 = 기술을 잘 아는 사람\u0026quot;이라고 생각했다. 틀린 말은 아니지만, 부족한 정의였다.\n내가 아는 것이 팀에 전달되지 않으면, 그 지식은 팀에 존재하지 않는 것과 같다.\n좋은 엔지니어는 기술을 잘 아는 사람이 아니라, 그 기술을 팀과 나눌 수 있는 사람이라는 것을 알게 되었다.\n그래서 에어팟을 버렸다. 귀를 기울이고, 적극적으로 소통해보기 위해서다.\n","permalink":"https://wid-blog.pages.dev/posts/career/dable/starting-sabbatical/","summary":"좋은 엔지니어는 기술을 잘 아는 사람이 아니라, 그 기술을 팀과 나눌 수 있는 사람이라는 것을 알게 되었다.","title":"에어팟을 버렸다"},{"content":"작년 말, DSP Fallback의 성과를 개선하기 위해 CTR 예측 모델을 도입하는 이야기가 나왔다.\nFallback은 메인 DSP가 내보낼 광고가 없다고 판단한 경우에 동작한다. 광고 슬롯 대비 노출 비율(Fillrate)을 끌어올리는 것이 목적이다.\n나는 백엔드 엔지니어였다. AI 배경은 없었다.\n모델 자체보다 주변 시스템을 엮는 작업이 더 많을 거라는 판단이 있었고, 그래서 내가 맡게 됐다.\n이 글은 그때 내린 기술 결정과 그 근거, 그리고 AI 비전문가가 ML 인프라를 만들며 배운 것들을 정리한 기록이다.\n모델 선택: Logistic Regression 모델은 Logistic Regression으로 정했다.\n광고 CTR 개선이다보니 클릭 여부를 학습시키면 됐다. 이항 분류 문제로 풀 수 있었다.\n사내에서는 LR과 LightGBM을 권장했다. 광고 플랫폼에서 일반적으로 쓰이는 두 모델이다. 하지만 이 프로젝트는 초기 버전이었고, 복잡한 튜닝과 운영 부담을 처음부터 짊어지고 싶지 않았다.\n그래서 더 단순한 LR을 택했다.\n언어 및 프레임워크 선택 Python과 sklearn으로 정했다. 학습 배치와 인퍼런스 서버 모두.\n처음에는 ONNX + Go를 생각했다. 사내 백엔드가 성능을 이유로 Node에서 Go로 마이그레이션을 검토 중이었고, 새 프로젝트라면 Go로 시작해볼 수 있을 것 같았다. 인퍼런스는 ONNX로 빼면 framework 독립성과 성능 이점을 함께 얻는다.\n그런데 사내 ML 운영 환경이 Python 중심이었다. 참고할 사례, 공유할 코드, 배포 패턴이 전부 Python이었다. 조언과 리뷰가 필요한 상황에서는 같은 언어가 맞겠다고 생각했다. 성능 이점은 뒤로 미루고 운영 연속성을 택했다.\n프레임워크도 비슷한 논리였다. ONNX가 sklearn보다 인퍼런스 성능이 낫다는 건 알고 있었지만, LR 같은 경량 모델에서는 그 이득이 크지 않을 거라고 봤다. sklearn만으로 학습과 저장이 충분하다고 생각했고, 가벼운 모델에 무거운 파이프라인을 얹는 것은 과잉 설계라고 판단했다.\nML Lifecycle 아키텍처: 3단으로 나눴다 ML Lifecycle을 세 개의 컴포넌트로 분리했다.\n학습 배치: 주기적으로 LR 모델을 학습하고, 학습된 모델을 모델 저장소에 push한다. 모델 저장소: MLflow 기반. 학습 배치가 쓴 모델을 버전별로 보관한다. 인퍼런스 서버: 모델 저장소에서 최신 모델을 로드하고, 실시간 predict를 제공한다. flowchart LR A[\"학습 배치\"] --\u003e|\"① 모델 push② champion alias 이동\"| B[\"모델 저장소(MLflow)\"] A --\u003e|\"③ Argo Rollouts API 호출\"| C[\"인퍼런스 서버\"] B -.-\u003e|\"④ POD 기동 시 champion 로드\"| C 흐름은 단순하다. 학습 배치 → 모델 저장소 → 인퍼런스 서버. 세 컴포넌트는 모델 파일을 통해서만 연결되고, 학습 주기와 인퍼런스는 서로 독립적으로 동작한다.\n학습 배치 내부와 Promotion Gate 학습 배치 안쪽은 그냥 \u0026ldquo;학습 → 저장\u0026quot;이 아니었다. 학습이 끝난 모델이 자동으로 배포되지 않고, Promotion Gate라는 품질 검증 단계를 통과해야 champion alias가 교체된다.\nflowchart LR A[\"데이터 로딩\"] --\u003e B[\"전처리\"] --\u003e C[\"학습\"] --\u003e D[\"평가\"] --\u003e E{\"Promotion Gate\"} E --\u003e|\"PASS\"| F[\"champion alias 교체+ Rollout 트리거\"] E --\u003e|\"FAIL\"| G[\"기존 champion 유지\"] 기준은 단순했다. 학습된 모델의 평가 지표가 미리 정한 임계값을 넘으면 PASS, 그렇지 않으면 FAIL. PASS면 champion alias를 새 버전으로 옮기고 배포를 트리거한다. FAIL이면 새 모델은 registry에 기록만 남기고 기존 champion이 계속 서비스된다.\n이 덕분에 성능이 떨어진 모델이 프로덕션에 실수로 나가는 상황을 코드 변경 없이 막을 수 있었다.\n배포: Argo Rollouts 새 모델을 인퍼런스 서버에 반영하는 과정에서는 Argo Rollouts를 썼다. k8s 위에 있었기 때문에 자연스러운 선택이었다.\nsequenceDiagram participant T as 학습 배치 participant R as MLflow participant I as 인퍼런스 서버 POD T-\u003e\u003eR: ① 새 모델 등록 T-\u003e\u003eR: ② champion alias 새 버전으로 이동 T-\u003e\u003eI: ③ Argo Rollouts API 호출 Note over I: ④ Rollout이 POD 순차 교체 I-\u003e\u003eR: ⑤ 새 POD가 champion 모델 로드 R--\u003e\u003eI: 모델 + 메타데이터 Note over I: ⑥ 새 모델로 서비스 재개 MLflow의 alias는 모델 버전에 \u0026ldquo;champion\u0026rdquo; 같은 별칭을 붙여 현재 production 모델을 가리키는 기능이다. 학습 배치는 Promotion Gate에서 PASS를 받으면 champion alias를 새 버전으로 옮기고, 이어서 Argo Rollouts API를 호출해 배포를 트리거한다. Rollout이 인퍼런스 서버 POD를 순차 교체하고, 새 POD는 기동 시 champion alias가 달린 모델을 로드해서 서비스에 들어간다.\n회고 LR + sklearn + MLflow 조합은 단순했지만 가볍고 빠르게 돌아갔다.\n가장 아쉬웠던 것은 Python + sklearn을 택한 결정이었다. 현재는 FastAPI 기반 Python 서버를 k8s에 올려 운영 중이다. 각 POD는 싱글 코어로 동작하고, 모델을 각자 로드한다. Feature가 늘어나면 인퍼런스 비용이 올라가고, 가용 POD 수도 함께 늘어났다. ONNX + Go 조합으로 한 프로세스 안에서 멀티 코어를 활용했다면 같은 부하를 더 적은 POD로 처리할 수 있었을지 모른다. 당시에는 운영 연속성을 택하는 게 맞다고 판단했지만, 그 결정의 비용이 운영 단계에서 드러났다.\n시작할 때 가장 큰 걱정은 \u0026ldquo;AI 배경이 없는데 할 수 있을까\u0026quot;였다. 끝나고 나니 필요한 건 조금 달랐다는 걸 알게 됐다. 중요했던 건 ML 알고리즘이나 인프라 전문성이 아니라, 도메인을 얼마나 정확히 이해하고 있는가, 그리고 그에 맞춰 어떤 feature를 어떻게 조합할지 판단하는 능력이었다. 데이터를 읽고 패턴을 찾는 분석 능력도 그만큼 필요했다.\n참고 Logistic Regression 다시 보기 모델 학습 프레임워크 고르기: sklearn vs ONNX ML Lifecycle의 틈새를 메우는 MLflow ","permalink":"https://wid-blog.pages.dev/posts/career/dable/dsp-fallback-ctr-ml-lifecycle/","summary":"AI 배경이 없는 백엔드 엔지니어로서 DSP Fallback CTR을 위한 첫 ML Lifecycle 3단 구조를 만들며 내린 기술 결정들과, 운영 끝에 배운 것들.","title":"LR 기반 ML Lifecycle 도전기"},{"content":"MLflow는 ML Lifecycle의 experiment와 model 사이 경계를 메우는 도구다. experiment 쪽에서는 \u0026ldquo;어떤 파라미터로 무엇을 학습했는가\u0026quot;를, model 쪽에서는 \u0026ldquo;어느 버전이 지금 production을 가리키는가\u0026quot;를 붙잡아준다. 그 경계는 대형 ML 팀에만 있는 것이 아니다. 가벼운 Logistic Regression 모델 하나를 운영할 때도 똑같이 나타난다.\n이 글은 MLflow 자체를 다룬다. 무엇이고, ML Lifecycle의 어디에 위치하며, 경량 팀이 그 중 어떤 조각을 고를 수 있는가.\nML Lifecycle ML 프로젝트는 대체로 네 단계로 움직인다.\nExperiment — 데이터를 보고, 파라미터를 바꿔가며 모델을 학습해본다. 메트릭을 기록하고 돌아가서 다시 실행한다. Model — 쓸 만한 결과가 나오면 그 모델을 \u0026ldquo;이것이 지금 우리의 모델\u0026quot;이라고 선언한다. 버전과 lineage가 붙는다. Deployment — 그 모델을 서빙 환경에 배치한다. 롤아웃, 롤백, 트래픽 전환 같은 문제가 여기서 발생한다. Monitoring — 서빙 중인 모델의 드리프트와 성능 저하를 감시한다. 각 단계는 고유한 문제를 가진다. experiment는 \u0026ldquo;무엇을 해봤는지 기억하는 것\u0026quot;이 어렵고, model은 \u0026ldquo;지금 어느 것이 진짜인지\u0026rdquo; 합의하는 것이 어렵다. deployment는 \u0026ldquo;바꿔 끼우는 것\u0026quot;이 어렵고, monitoring은 \u0026ldquo;언제 다시 학습시켜야 하는지\u0026rdquo; 판단하는 것이 어렵다.\nMLflow는 이 중 앞의 두 칸을 주로 메운다. deployment와 monitoring 영역에도 걸쳐 있지만, 중심축은 experiment와 model이다.\nmodel_v3_final.pkl 문제 파일 시스템만으로 모델을 관리할 때 어디서 깨지는가를 먼저 확인해야 한다. 그래야 왜 경계에 도구가 필요한지 드러난다.\n처음에는 간단하다. model.pkl을 S3에 올리고, 인퍼런스 서버가 그걸 읽는다. 학습이 끝날 때마다 덮어쓰면 된다.\n그러다 한 번 롤백이 필요해진다. 어제 버전으로 돌아가야 하는데 파일은 이미 덮어써졌다. 그래서 model_v2.pkl, model_v3.pkl로 나누기 시작한다. 얼마 안 가 model_v3_final.pkl이 등장한다. 그다음은 model_v3_final_really.pkl이다.\n이 이름들이 해결하지 못하는 것이 세 가지 있다.\nLineage — model_v3_final.pkl이 어떤 코드로, 어떤 데이터로, 어떤 파라미터로 학습됐는지 추적할 방법이 없다. 재현하려 해도 같은 결과가 나오지 않는다. Alias — \u0026ldquo;지금 production이 가리키는 모델\u0026quot;을 코드 외부의 문자열 규칙으로 관리하게 된다. 인퍼런스 서버가 latest.pkl을 읽도록 할지, 환경 변수로 버전을 주입할지, 매번 결정해야 한다. 재현성 — 몇 달 뒤에 같은 실험을 실행하고 싶은데, 그때의 파라미터와 코드를 모은 기록이 어디에도 없다. 이 세 가지를 풀려면 결국 \u0026ldquo;파일 이름\u0026rdquo; 레이어 위에 메타데이터 레이어 하나가 필요해진다. 그게 MLflow가 메우는 자리다.\nMLflow의 네 조각 MLflow는 서로 독립적인 네 개의 컴포넌트로 구성된다. 전부 하나의 패키지 안에 있지만, 쓰는 쪽에서 골라 쓸 수 있다.\nTracking 학습 한 번을 run이라는 단위로 기록한다. 파라미터, 메트릭, 그리고 학습 결과물(모델 파일, 플롯, 로그)을 run에 묶어둔다. 여러 run은 experiment라는 이름으로 묶인다.\nimport mlflow with mlflow.start_run(): mlflow.log_param(\u0026#34;C\u0026#34;, 0.1) mlflow.log_metric(\u0026#34;val_auc\u0026#34;, 0.782) mlflow.sklearn.log_model(model, \u0026#34;model\u0026#34;) 이 한 덩어리가 lineage의 씨앗이다. 몇 달 뒤에 \u0026ldquo;그때 val_auc가 0.78이었는데 파라미터가 뭐였지?\u0026ldquo;를 물어볼 수 있는 기록이 된다.\nModel Registry Tracking이 \u0026ldquo;어떻게 학습했는가\u0026quot;를 기록한다면, Registry는 \u0026ldquo;어떤 결과물을 우리 것으로 선언할 것인가\u0026quot;를 기록한다. 학습 결과물 중 하나를 registered model로 승격시키면 버전이 붙는다. v1, v2, v3가 자동으로 쌓인다.\n그리고 그 버전들 위에 alias를 붙일 수 있다. champion이라는 alias는 특정 버전을 가리키는 mutable reference다. 새 버전이 검증을 통과하면 champion alias를 옮긴다. 코드를 바꾸지 않고, 이름 규칙을 바꾸지 않고, alias 하나만 이동시키는 것으로 \u0026ldquo;production이 가리키는 모델\u0026quot;이 교체된다.\nmlflow.register_model(\u0026#34;runs:/\u0026lt;run-id\u0026gt;/model\u0026#34;, name=\u0026#34;ctr-model\u0026#34;) client.set_registered_model_alias(\u0026#34;ctr-model\u0026#34;, \u0026#34;champion\u0026#34;, version=7) Registry는 앞서 말한 model_v3_final.pkl 문제를 모두 치운다. lineage는 run과 자동 연결되고, alias는 이름 규칙을 대체하고, 재현은 run id로 가능해진다.\n중요한 제약이 하나 있다. Registry를 쓰려면 DB backend가 필수다. 파일 스토리지(./mlruns)만으로는 registry API가 동작하지 않는다. 가볍게 시작하고 싶어도 PostgreSQL이나 MySQL, 최소한 SQLite 하나는 띄워야 한다. MLflow 3.7.0부터 default backend가 SQLite로 바뀌어서 처음 진입 장벽이 조금 낮아졌다.\nModels \u0026ldquo;모델 파일\u0026quot;이 무엇인지 표준화하는 조각이다. sklearn, pytorch, xgboost 같은 프레임워크마다 flavor가 있고, 같은 모델을 여러 flavor로 저장할 수 있다. 저장된 모델은 로드할 때 원래 프레임워크 코드 없이도 불러올 수 있다.\nModels는 experiment와 deployment 사이를 이어주는 포터빌리티 계층이다. Tracking/Registry가 \u0026ldquo;어떤 모델이냐\u0026quot;를 다룬다면, Models는 \u0026ldquo;그 모델을 어떻게 직렬화하느냐\u0026quot;를 다룬다.\nProjects MLproject 파일과 conda/docker 설정을 묶어서 \u0026ldquo;누가 돌려도 같은 환경\u0026quot;을 만든다. mlflow run .으로 실행하면 환경이 세팅되고 학습이 실행된다.\n네 조각 중 가장 덜 쓰이는 편이다. 이미 내부에 배치 실행 표준이 있는 팀은 Projects를 덮어쓰지 않고 자기 표준을 유지한다.\nLifecycle 배치 flowchart LR E[Experiment] --\u003e|\"Tracking(run, param, metric)\"| M[Model] M --\u003e|\"Registry(version, alias)\"| D[Deployment] M -.-\u003e|\"Models(flavor)\"| D P[Projects] -.-\u003e|\"실행 환경\"| E D --\u003e Mo[Monitoring] Tracking: experiment 단계 내부 Registry: experiment와 deployment 사이의 model 칸 Models: model에서 deployment로 넘어가는 포터빌리티 축 Projects: experiment 칸의 재현성 계층 (선택) monitoring은 MLflow가 직접 담당하지 않는다. 별도 도구가 필요하다. 이 그림이 MLflow의 범위를 가장 간결하게 보여준다. 네 조각이 각자의 자리에 있고, 어느 칸을 채울지는 프로젝트가 정한다.\nTracking 과 Registry 선택 경량 LR 모델을 production에 운영하는 시나리오를 가정해보자. 네 조각 중 자주 마주치는 조합은 둘이다. Tracking과 Registry.\nTracking이 필요한 이유. 학습 배치가 매 주기 LR을 다시 실행하면, 그때마다 파라미터와 validation 메트릭이 달라진다. 어느 run이 어떤 숫자를 냈는지 나중에 추적해야 한다. 파일 이름으로 관리할 수 있는 수준의 기록이 아니다. Tracking이 메워주는 자리가 바로 이 지점이다.\nRegistry가 필요한 이유. 학습 배치가 만든 모델 중 검증 단계를 통과한 것만 \u0026ldquo;champion\u0026quot;으로 승격해야 한다. 인퍼런스 서버는 그 champion을 로드한다. 이걸 파일 규칙으로 하면 서버가 latest.pkl을 polling하게 되고, 검증이 안 끝난 모델이 먼저 올라가는 race가 생긴다. alias를 쓰면 그 race가 사라진다. 배포의 방아쇠를 당기는 주체와 배포되는 객체가 깔끔히 분리된다.\nalias가 움직이는 것과 실제 인퍼런스 서버 교체는 별개의 사건이다. champion alias가 옮겨진 뒤 배포 도구(예: Argo Rollouts)가 POD 교체를 트리거한다. Rollouts가 새 POD를 띄우면, 그 POD는 기동 시 champion alias가 가리키는 모델을 로드해서 서비스에 투입된다. MLflow는 \u0026ldquo;어느 것이 champion인가\u0026quot;까지만 말하고, \u0026ldquo;어떻게 서비스에 배치할 것인가\u0026quot;는 배포 도구의 몫이다.\n이 분리가 핵심이다. MLflow가 모든 것을 할 필요는 없다. 경계만 메우면 된다.\n사용하지 않는 컴포넌트 Models 포맷은 Tracking에 모델을 로깅할 때 자동으로 따라온다. 명시적으로 고르는 조각은 아니지만 혜택은 받는다. Registry에서 runs:/\u0026lt;id\u0026gt;/model URI로 꺼낼 수 있게 되는 것이 이 포맷 덕분이다.\nProjects는 잘 쓰이지 않는다. 팀이 이미 안정적인 배치 실행 표준을 가지고 있다면, 그 위에 MLproject 레이어를 추가하는 것은 중복이다. 한 배치가 한 프레임워크 안에서 실행되면 Projects의 재현성 이득은 크지 않다.\nServing도 선택적이다. MLflow는 자체 서빙 엔드포인트(mlflow models serve)를 제공하지만, LR 같은 경량 모델의 인퍼런스는 기존 서버에서 sklearn으로 직접 처리하는 쪽이 더 가볍고, 기존 인프라에 통합하기도 쉽다. 서빙 레이어를 MLflow에 위임할 이유가 없는 경우가 많다.\n네 조각 중 두 개만 쓴다고 해서 MLflow를 \u0026ldquo;반만 쓴\u0026rdquo; 것은 아니다. 메워야 할 경계만 메우고 나머지는 다른 도구에 맡기는 것이 이 도구의 정석적인 사용 방식에 가깝다.\n맺음 경계에 부딪힌다고 했다. 그 경계는 파일 이름이 설명하지 못하는 meta 정보(언제, 어떻게, 무엇으로, 지금 어느 것이 진짜인가)가 쌓이기 시작하는 지점이다. MLflow는 그 지점에 놓이는 가벼운 메타데이터 레이어다. 얼마나 가볍게 쓸지는 프로젝트가 정한다.\n대형 ML 팀에만 있는 도구가 아니다. LR 하나를 운영하더라도 같은 경계는 찾아온다. 그때 필요한 칸만 골라 채우면 된다.\n","permalink":"https://wid-blog.pages.dev/posts/tech/ml/mlflow/","summary":"MLflow의 네 조각이 ML Lifecycle의 어느 칸을 메우는지, 그리고 경량 팀이 그 중 어떤 조각을 고를 수 있는지.","title":"MLflow 와 ML Lifecycle"},{"content":"sklearn과 ONNX는 같은 질문의 답이 아니다. \u0026ldquo;LR 하나 학습하는데 뭘 쓰지?\u0026ldquo;로 시작해서 이 둘을 나란히 놓는 순간, 비교 자체가 착시가 된다. 한 쪽은 모델을 학습시키는 프레임워크고, 다른 쪽은 학습된 모델을 담아 운반하는 포맷이다. 같은 레이어에 속하지 않는다.\nsklearn과 ONNX를 고르는 결정 자체보다, 왜 \u0026ldquo;sklearn이냐 ONNX냐\u0026quot;가 애초에 성립하는 질문이 아닌지가 먼저다.\n프레임워크 비교의 전제 \u0026ldquo;sklearn vs ONNX\u0026quot;를 검색하면 두 도구가 같은 역할을 놓고 경쟁하는 것처럼 묶여 나온다. 장단점 표, 벤치마크, 사용 예시가 나란히 붙는다. 이 배치 자체가 착시를 만든다.\nsklearn은 데이터를 받아 모델을 학습시키는 라이브러리다. LogisticRegression, RandomForest, GradientBoosting — 학습 알고리즘과 그 구현이 들어 있다. 학습이 끝나면 결과 모델 객체를 .pkl 파일로 저장하고, Python 프로세스에서 다시 읽어 predict을 실행한다. 학습부터 서빙까지 Python 생태계 안에서 완결된다.\nONNX에는 학습 알고리즘이 없다. ONNX가 제공하는 것은 \u0026ldquo;이미 학습된 모델\u0026quot;을 프레임워크 중립적으로 표현하는 포맷이다. PyTorch에서 학습한 트랜스포머도, sklearn에서 학습한 LR도, 모두 같은 ONNX 그래프로 변환할 수 있다. 그다음에 어떤 런타임에서든 그 그래프를 실행하면 된다.\n정리하면 — 한 쪽은 학습자고 다른 쪽은 운송 수단이다. \u0026ldquo;학습자와 운송 수단 중 뭘 쓸까\u0026quot;라는 질문은 성립하지 않는다. 둘이 같이 움직이거나, 운송 수단이 필요 없거나 둘 중 하나다.\nsklearn sklearn은 두 가지 일을 한 번에 한다. 모델을 학습시키는 것, 그리고 학습된 모델을 Python 객체로 저장해 다시 읽는 것.\nfrom sklearn.linear_model import LogisticRegression import joblib model = LogisticRegression() model.fit(X_train, y_train) joblib.dump(model, \u0026#34;model.pkl\u0026#34;) 이 .pkl 파일은 Python의 네이티브 직렬화 포맷을 따른다. Python 외 언어에서는 읽지 못한다. 같은 sklearn 버전, 같은 NumPy 버전이 설치된 환경이어야 안전하게 재로딩된다. 대신 학습 → 저장 → 서빙이 하나의 파이프라인에서 끊김 없이 연결된다.\n대부분의 ML 코드는 Python으로 학습하고 Python 프로세스로 서빙한다. 이 경로에 다른 레이어를 추가할 이유가 없다면, sklearn이 직접 내놓는 저장 포맷이 가장 짧은 길이다.\nONNX ONNX는 프레임워크-중립 중간 표현(Intermediate Representation)이다. 모델의 연산 그래프를 표준화된 opset으로 기록하고, ONNX Runtime 같은 별도 런타임이 그 그래프를 읽어 실행한다.\n이 한 단계가 추가되면 몇 가지 제약이 해제된다.\n언어 경계 — PyTorch/sklearn에서 학습한 모델을 C++, C#, Java, Rust 프로세스에서 추론할 수 있다. Python 없이. 하드웨어 경계 — ONNX Runtime은 그래프 최적화와 하드웨어별 execution provider를 제공한다. 같은 모델이 CPU, CUDA GPU, TensorRT, CoreML에서 실행된다. 프레임워크 경계 — 팀 안에 PyTorch 모델과 TensorFlow 모델이 섞여 있는데 서빙 스택은 하나로 통일하고 싶을 때, ONNX가 공통분모가 된다. 이 세 경계가 실제로 존재하는 프로젝트라면 ONNX 레이어는 도입 비용을 정당화한다. 경계가 없으면, 이 레이어는 파이프라인에 단계 하나를 추가하는 일 이상이 아니다.\n성능 이득은 조건부다 \u0026ldquo;ONNX Runtime이 더 빠르다\u0026quot;는 이야기가 자주 들린다. 절반만 맞다.\nONNX Runtime은 그래프 최적화(operator fusion, constant folding)와 하드웨어 가속기(CUDA, TensorRT, OpenVINO)를 붙일 수 있다. 그래서 같은 모델을 네이티브 프레임워크보다 빠르게 실행할 수 있는 경우가 있다. 핵심은 경우가 있다는 것이다.\n그 이득이 실제로 생기려면 보통 다음 중 하나 이상이 전제되어야 한다.\nGPU나 가속기 같은 전용 하드웨어 Python GIL을 벗어나는 non-Python 런타임 그래프가 충분히 커서 최적화 효과가 유의미한 모델 Logistic Regression은 이 중 어느 조건에도 해당하지 않는다. 가중치 벡터와 입력 벡터의 내적 한 번이 전부다. 여기에 graph fusion을 적용해도 줄일 연산이 거의 없다. CPU에서 벡터를 한 번 곱하는 작업에 ONNX Runtime과 sklearn 사이의 유의미한 지연 차이는 기대하기 어렵다.\n그래서 \u0026ldquo;ONNX가 빠르다\u0026quot;는 문장은, 어떤 모델인지 어디서 실행하는지를 덧붙이지 않으면 참이 되지 않는다.\n언제 ONNX 레이어가 필요한가 추상적인 판단 기준 몇 개보다는, ONNX를 도입했을 때 이득이 명확해지는 조건들을 나열하는 쪽이 낫다.\n학습 언어와 서빙 언어가 다르다. Python으로 학습하고 C++/Java/Go 서버에서 추론해야 한다. ONNX가 그 사이를 연결한다. GPU나 edge 추론이 필요하다. 모델이 크거나, 지연 요구가 엄격하거나, edge device에 올려야 한다. ONNX Runtime의 execution provider가 이를 지원한다. 여러 프레임워크의 모델을 한 서빙 스택으로 통일하고 싶다. PyTorch, sklearn, TensorFlow가 섞인 모델들을 같은 인퍼런스 서버에서 실행해야 한다. ONNX가 공통 포맷이 된다. 학습 코드와 서빙 인프라의 수명이 분리된다. 학습 코드는 자주 리팩토링하고 싶지만 서빙 쪽 바이너리는 안정적으로 고정되어 있어야 한다. ONNX가 그 사이의 고정점이 된다. 이 조건 중 어느 것도 해당하지 않으면, ONNX 레이어가 주는 것은 \u0026ldquo;변환 단계 하나 + opset 버전 호환성 걱정 + float/double precision 디버깅\u0026quot;이다. 비용만 들고 얻는 것이 없는 쪽이다.\n경량 LR 시나리오 경량 LR 모델을 Python 학습 + Python 서빙 경로에서 운영하는 시나리오를 가정해보자. GPU 추론은 필요 없고, 모델은 가중치 벡터 하나 크기다. 다른 프레임워크의 모델을 함께 배치할 계획도 없다. 위에서 나열한 네 조건 중 어느 것도 걸리지 않는다.\n이 경우 실제 결정은 \u0026ldquo;ONNX를 쓸 것인가\u0026quot;가 아니라, 애초에 \u0026ldquo;ONNX 레이어가 이 그림에 들어갈 자리가 있는가\u0026quot;로 내려간다. 자리가 없다. sklearn이 직접 내놓는 .pkl 저장이 학습에서 서빙까지 가장 짧은 길이다.\n정리 처음 질문으로 돌아가자. \u0026ldquo;sklearn과 ONNX 중 뭘 쓸까?\u0026ldquo;는 답할 수 있는 형태의 질문이 아니다. 두 도구가 같은 레이어에 속하지 않기 때문이다.\n이 질문은 둘로 쪼개져야 한다. 하나는 \u0026ldquo;어떤 라이브러리로 학습할 것인가\u0026rdquo; — sklearn, PyTorch, XGBoost 같은 학습 프레임워크들 사이의 선택이다. 다른 하나는 \u0026ldquo;학습된 모델을 어떤 포맷으로 배포할 것인가\u0026rdquo; — 각 프레임워크의 네이티브 저장 포맷일 수도, ONNX일 수도 있다.\n두 질문으로 분리하면, \u0026ldquo;ONNX 레이어가 필요한가\u0026quot;는 학습 프레임워크 선택과 독립된 문제가 된다. 그리고 가벼운 모델에서 이 질문은 대체로 \u0026ldquo;아니다\u0026quot;로 빠르게 닫힌다. 결론이 난 문제에 레이어를 추가할 이유는 없다.\n같은 질문의 답이 아닌 두 도구를 같은 질문에 억지로 넣으면, 답은 매번 어색해진다. 질문을 먼저 다시 써야 답도 명확해진다.\n","permalink":"https://wid-blog.pages.dev/posts/tech/ml/model-training-frameworks/","summary":"sklearn과 ONNX는 같은 레이어의 경쟁자가 아니다. 두 도구의 자리를 분리해서 보면 \u0026lsquo;ONNX 레이어가 필요한가\u0026rsquo;라는 질문이 자연스럽게 남는다.","title":"모델 학습 프레임워크 고르기: sklearn vs ONNX"},{"content":"CTR 예측의 baseline을 정할 때 후보는 많다. Gradient Boosting, Neural Network, 그리고 Logistic Regression. 이 중 LR은 여전히 자주 baseline 자리에 선택된다. 오래된 모델이 그 자리에 있는 데에는 이유가 있다.\n이 글은 그 이유를 원리부터 본다. LR이 무엇이고, 왜 이 자리에 자주 선택되는지.\n세 가지 특성 Logistic Regression이 오랫동안 CTR 예측의 baseline 자리를 지켜온 이유는 세 가지로 요약된다.\n경량. 모델이 벡터 내적 한 번이다. 학습도, 인퍼런스도, 피처 수에 선형.\n해석 가능. 계수 하나하나가 \u0026ldquo;이 피처가 결과에 얼마나 기여하는가\u0026quot;를 직접 말해준다.\n확률 출력. 0과 1 사이의 값을 출력한다. 광고에서는 입찰가를 곱할 때 그대로 쓰인다.\n이하 본문은 이 셋이 왜 \u0026ldquo;구조적으로\u0026rdquo; LR에 붙어 있는지 설명한다.\n선형에서 시그모이드로 Logistic Regression을 가장 빠르게 이해하는 방법은 선형 회귀에서 출발하는 것이다.\n선형 회귀는 입력의 가중합을 출력한다.\n$$ z = w \\cdot x + b $$문제는 $z$가 실수 전체를 범위로 갖는다는 점이다. CTR 같은 확률을 내놓으려면 출력이 0과 1 사이여야 한다. 선형 회귀는 그걸 보장하지 않는다.\n시그모이드 함수가 이 문제를 해결한다.\n$$ \\sigma(z) = \\frac{1}{1 + e^{-z}} $$시그모이드는 실수 전체를 $(0, 1)$ 구간으로 부드럽게 압축한다. 입력이 아무리 커져도 1에 수렴하고, 아무리 작아져도 0에 수렴한다. 선형 회귀의 출력을 시그모이드에 통과시키면 확률이 된다.\n이 단순한 합성이 Logistic Regression의 전부다. 선형 모델 + 확률 출력.\n주목할 점 하나. 확률 출력은 비선형이지만, decision boundary, 즉 확률 0.5를 기준으로 양쪽을 나누는 경계는 여전히 선형이다. $w \\cdot x + b = 0$ 이라는 초평면이 그대로 경계가 된다. LR은 \u0026ldquo;선형 분류기에 확률을 결합한 모델\u0026quot;이다.\nlog-loss 모델 구조가 정해졌다면, 학습은 \u0026ldquo;좋은 $w$와 $b$를 찾는 일\u0026quot;이다. 기준이 필요하다.\n선형 회귀는 MSE를 쓴다. 그런데 LR은 쓰지 않는다. 왜 그런가.\nLR의 출력은 확률이다. 확률 모델의 손실에는 더 적합한 선택이 있다. log-loss (또는 cross-entropy).\n$$ L = -\\frac{1}{N} \\sum_{i=1}^{N} \\left[ y_i \\log \\hat{y}_i + (1 - y_i) \\log (1 - \\hat{y}_i) \\right] $$정답이 1일 때는 $\\log \\hat{y}$가 커질수록 손실이 줄고, 정답이 0일 때는 $\\log(1 - \\hat{y})$가 커질수록 손실이 준다. 확률 예측이 정답과 가까워질수록 손실은 0에 수렴한다.\nlog-loss는 LR에 대해 convex하다. 지역 최적점에 빠지지 않는다. 전역 최적으로 수렴할 수 있다는 뜻이다. 이 성질이 LR을 대규모 데이터에서 빠르게 학습시킬 수 있는 수학적 근거다.\n세 가지 특성의 구조적 이유 앞서 정리한 세 가지 특성, 경량, 해석 가능, 확률 출력은 위 구조에서 그대로 따라 나온다.\n경량 학습된 LR 모델은 결국 가중치 벡터 $w$와 편향 $b$ 한 쌍이다. 인퍼런스는 내적 한 번과 시그모이드 한 번. 피처가 백만 개든 천만 개든, 연산량은 피처 수에 선형이다. 트리 앙상블이나 신경망의 수많은 곱셈과 비선형 연산과는 비교할 수 없이 가볍다.\n해석 가능 계수 $w_i$는 \u0026ldquo;피처 $i$가 1만큼 증가할 때 log-odds가 $w_i$만큼 변한다\u0026quot;는 뜻이다. 부호는 방향, 크기는 영향력을 말해준다. 광고 도메인에서 \u0026ldquo;어떤 피처가 클릭에 긍정적으로 작용하는가\u0026quot;를 알고 싶을 때, LR은 계수표 하나로 대답한다. 현업의 설명 책임에 적합하다.\n확률 출력 많은 분류기는 ranking용 score만 출력한다. LR은 calibrated probability를 출력한다. 광고의 기대값 계산은 이 숫자를 그대로 곱할 수 있어야 한다. 예측 CTR × 입찰가 = 기대 수익. probability가 아닌 score는 입찰 공식에 바로 들어가지 못한다.\nCTR 예측 적용 CTR 예측이라는 문제에는 세 가지 특성이 있다.\n희소. 피처 대부분은 one-hot 인코딩된 카테고리다. 수백만 차원 중 몇 개만 1이고 나머지는 0이다.\n고차원. 광고, 사용자, 컨텍스트의 조합은 수백만에서 수억 단위로 퍼진다.\n대규모. 학습 데이터는 일 단위로 대량 축적된다.\nLR은 이 세 특성과 정확히 맞물린다. 희소 벡터의 내적은 non-zero 항목만 계산하면 되므로 피처 차원이 커도 연산은 실제 값이 있는 수에 비례한다. 학습은 SGD 계열로 분산이 쉽다. 인퍼런스는 실시간 입찰의 타이트한 지연 예산 안에 들어간다.\nCTR 모델을 처음 올리는 상황에서 이 특성들이 결정적으로 작용한다. baseline을 빠르게 세우고, 학습 파이프라인, 서빙, 모니터링까지 전체 lifecycle을 먼저 검증하는 것이 우선이다. 복잡한 모델로는 그 검증 자체가 지연된다.\n한계와 다음 단계 LR이 이 자리에 있는 이유를 봤으니, 떠나는 이유도 함께 봐야 한다.\n가장 큰 한계는 비선형 상호작용의 부재다. 피처들끼리의 곱, 조건부 효과, 복잡한 결합을 LR은 스스로 발견하지 못한다. 사람이 feature engineering으로 미리 정의해야 한다. 피처 조합이 많아질수록 엔지니어링 비용은 커지고, 운영은 피처 설계 리뷰에 묶인다.\n그래서 언제 넘어가는가. 데이터와 운영 여력이 \u0026ldquo;피처 엔지니어링으로 감당할 수 없는 지점\u0026quot;에 이를 때. Gradient Boosting Decision Tree는 상호작용을 스스로 학습한다. 신경망은 더 나아가 embedding으로 고차원 카테고리를 연속 벡터로 변환한다. 두 방향 모두 LR의 한계를 정확히 겨냥한다.\n다만 시작점은 여전히 LR이 합리적이다. baseline 없이 복잡한 모델부터 올리면, 무엇이 모델의 기여이고 무엇이 파이프라인의 기여인지 구분할 수 없다. LR이 준 숫자가 이후 모든 비교의 기준선이 된다.\n마무리 오래된 모델을 고른 데에는 이유가 있었다.\n그 이유는 구조에 있다. 선형 모델과 시그모이드의 합성, log-loss의 convex성, 희소·고차원에서의 가벼움. 세 가지가 합쳐져 LR은 CTR 예측의 baseline으로 오래 유지되고 있다.\n다음 모델로 넘어갈 때가 오더라도, LR이 준 숫자는 baseline으로 남는다.\n","permalink":"https://wid-blog.pages.dev/posts/tech/ml/logistic-regression/","summary":"CTR 예측의 baseline으로서 Logistic Regression의 구조와 특성을 정리한다. 오래된 모델이 여전히 그 자리에 있는 이유.","title":"Logistic Regression 다시 보기"},{"content":"Go의 동시성 모델을 개념으로는 알고 있었다. Goroutine은 경량 스레드이고, channel로 통신하고, sync 패키지로 동기화한다. 하지만 패턴별 차이를 코드와 수치로 직접 비교해본 적은 없었다.\n직접 구현하고 벤치마크를 돌려보기로 했다. mutex, channel, lock-free 세 가지 접근을 하나의 프로젝트에서 다뤘다.\nMutex 첫 번째로 구현한 것은 sync.RWMutex 기반의 동시성 안전한 맵이었다. 쓰기에는 Lock(), 읽기에는 RLock()을 사용해서 여러 goroutine이 동시에 접근할 수 있게 했다.\n구현 후 Go 표준 라이브러리의 sync.Map과 벤치마크를 비교했다. 결과는 예상과 달랐다. sync.Map이 쓰기 작업에서 약 11% 빨랐다. sync.Map은 읽기 최적화를 위해 내부적으로 두 개의 맵을 사용하고, 읽기-쓰기 비율에 따라 자동으로 승격하는 구조다. 단순히 RWMutex를 감싸는 것보다 표준 라이브러리의 최적화가 효과적이었다.\nChannel 채널 패턴에서는 데이터 흐름 제어를 구현했다. FanOut은 하나의 입력 채널에서 여러 출력 채널로 데이터를 분배한다. select문으로 먼저 받을 수 있는 출력 채널에 전달하는 방식이다.\nTurnOut은 여러 입력에서 여러 출력으로 라우팅하면서 quit 채널로 종료 신호를 처리한다. select문에 quit 채널을 포함시키면 데이터 처리와 종료 신호를 하나의 루프에서 자연스럽게 다룰 수 있었다. 채널을 닫고 남은 데이터를 소진하는 정리 과정도 구현했다.\n제너릭 타입([T any])을 활용해서 타입에 무관하게 재사용할 수 있게 만들었다.\nLock-free 가장 흥미로웠던 부분이다. 두 가지 lock-free 패턴을 구현했다.\nSpinningCAS는 atomic.CompareAndSwapInt32로 락을 구현한다. 다른 goroutine이 락을 점유하고 있으면 대기 큐에 들어가지 않고, CAS 연산을 반복하며 스핀한다. 여기서 runtime.Gosched()가 중요했다. 스핀 루프에서 CPU를 양보하지 않으면 다른 goroutine이 실행되지 못해 교착 상태에 가까운 상황이 발생했다. 한 줄을 추가하는 것만으로 동작이 달라지는 경험이었다.\nSpinningCAS와 표준 sync.Mutex를 벤치마크로 비교했다. 결과는 Mutex가 약 17% 빨랐다. Go의 Mutex는 단순한 락이 아니라, 스핀과 대기 큐를 상황에 따라 전환하는 하이브리드 구조다. 직접 구현한 순수 스핀 락이 범용 Mutex를 이기기 어려운 이유를 수치로 확인했다.\nTicketStorage는 순서 보장이 필요한 경우를 위한 패턴이다. atomic.AddUint64로 티켓 번호를 발급하고, 자신의 번호가 올 때까지 CAS로 스핀한다. 공정성(FIFO)을 보장하지만, 경합이 높으면 대기 시간이 길어지는 트레이드오프가 있다.\n회고 동시성 패턴을 개념으로 아는 것과 직접 벤치마크를 돌려보며 체감하는 것은 다른 경험이었다.\n가장 의외였던 것은 두 벤치마크 모두 표준 라이브러리가 더 빨랐다는 점이다. sync.Map은 내부 최적화가, sync.Mutex는 하이브리드 전략이 단순 구현을 이겼다. \u0026ldquo;직접 만들면 더 빠를 것이다\u0026quot;라는 가정이 틀릴 수 있다는 걸 수치로 확인했다.\nruntime.Gosched() 한 줄이 동작을 바꿔놓은 경험도 기억에 남는다. 동시성 코드에서는 이론적으로 맞는 구현이 실행 환경에서는 다르게 동작할 수 있다.\n동시성 패턴을 개념으로 아는 것과 직접 구현하고 수치를 마주하는 것. 그 차이를 확인한 프로젝트였다.\n참고 Go Concurrency 모델 ","permalink":"https://wid-blog.pages.dev/posts/career/personal/concurrency-go-retrospective/","summary":"Go의 동시성 패턴 세 가지(mutex, channel, lock-free)를 직접 구현하고 벤치마크하며 체화한 과정의 기록.","title":"concurrency-go 회고"},{"content":"Go의 동시성 모델은 CSP(Communicating Sequential Processes)를 기반으로 한다. 핵심 철학은 하나다.\n\u0026ldquo;Do not communicate by sharing memory; instead, share memory by communicating.\u0026rdquo;\n공유 메모리에 락을 거는 대신, 채널을 통해 데이터를 전달한다. Goroutine이 실행 단위를, Channel이 통신을, sync/atomic 패키지가 보조 동기화를 담당한다.\nGoroutine Goroutine은 Go의 경량 실행 단위다. OS 스레드가 아니다. Go 런타임이 여러 goroutine을 소수의 OS 스레드에 멀티플렉싱한다.\ngo func() { // 이 함수는 새 goroutine에서 실행된다 }() go 키워드 하나로 생성된다. 초기 스택은 수 KB에 불과하고, 필요에 따라 런타임이 자동으로 늘리고 줄인다. OS 스레드라면 수천 개를 만들기 어렵지만, goroutine은 수십만 개를 같은 주소 공간에서 생성할 수 있다.\nGMP 스케줄러 Go 런타임은 M:N 스케줄링 모델을 사용한다. 이를 GMP 모델이라 부른다.\nflowchart TB subgraph Runtime[\"Go Runtime\"] subgraph P1[\"P (Processor)\"] LRQ1[\"로컬 큐: G1, G2, G3\"] end subgraph P2[\"P (Processor)\"] LRQ2[\"로컬 큐: G4, G5\"] end GRQ[\"글로벌 큐: G6, G7...\"] end subgraph OS[\"OS\"] M1[\"M (OS Thread)\"] M2[\"M (OS Thread)\"] M3[\"M (OS Thread)\"] end P1 --\u003e M1 P2 --\u003e M2 GRQ -.-\u003e|\"P의 로컬 큐가 비면 가져감\"| P1 G(Goroutine). 실행할 함수와 스택을 가진 경량 실행 단위다.\nM(Machine). OS 스레드다. 실제 CPU에서 명령을 실행한다.\nP(Processor). 논리적 프로세서다. goroutine을 실행하기 위한 컨텍스트를 제공한다. GOMAXPROCS로 P의 수를 설정하며, 기본값은 CPU 코어 수다.\nP는 로컬 큐를 가지고 있다. goroutine이 생성되면 현재 P의 로컬 큐에 들어간다. M은 P에 연결되어 로컬 큐의 goroutine을 하나씩 실행한다. 하나의 goroutine이 시스템 콜로 블로킹되면, 런타임은 같은 P의 다른 goroutine을 다른 M으로 옮겨서 실행을 계속한다.\n함수 호출당 평균 세 개의 명령어 정도의 오버헤드만 발생한다.\nChannel Channel은 goroutine 간 데이터를 전달하는 타입이 지정된 통신 수단이다.\nUnbuffered Channel ch := make(chan int) 송신자와 수신자가 동시에 준비되어야 전달이 완료된다. 송신자는 수신자가 값을 가져갈 때까지, 수신자는 송신자가 값을 보낼 때까지 블로킹된다. 통신과 동기화가 동시에 이루어진다.\nsequenceDiagram participant G1 as Goroutine 1 participant Ch as Channel (unbuffered) participant G2 as Goroutine 2 G1-\u003e\u003eCh: 송신 (블로킹) Note over G1,Ch: G2가 수신할 때까지 대기 G2-\u003e\u003eCh: 수신 Ch--\u003e\u003eG1: 송신 완료 Ch--\u003e\u003eG2: 값 전달 Buffered Channel ch := make(chan int, 10) // 버퍼 크기 10 버퍼에 여유가 있으면 송신이 즉시 완료된다. 버퍼가 가득 차면 송신자가 블로킹된다. 동시 실행 수를 제한하는 세마포어로 활용할 수 있다.\n방향성 채널에 방향을 지정하면 함수의 의도가 명확해진다.\nfunc producer(out chan\u0026lt;- int) { // 송신 전용 out \u0026lt;- 42 } func consumer(in \u0026lt;-chan int) { // 수신 전용 val := \u0026lt;-in } select select문은 여러 채널 연산 중 준비된 것을 실행한다. 여러 채널을 동시에 대기하거나, 타임아웃을 처리하거나, 비블로킹 연산을 구현할 때 사용한다.\nselect { case msg := \u0026lt;-ch1: handle(msg) case ch2 \u0026lt;- response: // 송신 완료 case \u0026lt;-quit: return default: // 어떤 채널도 준비되지 않았을 때 } default를 포함하면 어떤 채널도 준비되지 않았을 때 블로킹 없이 넘어간다.\n주요 패턴 flowchart LR subgraph FanOut[\"Fan-Out\"] IN1[\"입력\"] --\u003e W1[\"Worker 1\"] IN1 --\u003e W2[\"Worker 2\"] IN1 --\u003e W3[\"Worker 3\"] end subgraph FanIn[\"Fan-In\"] R1[\"결과 1\"] --\u003e OUT1[\"출력\"] R2[\"결과 2\"] --\u003e OUT1 R3[\"결과 3\"] --\u003e OUT1 end Fan-Out. 하나의 채널에서 여러 goroutine이 읽어 작업을 분배한다.\nFan-In. 여러 채널의 결과를 하나의 채널로 합친다.\nPipeline. 각 단계가 채널로 연결된 처리 파이프라인이다. 입력 채널에서 읽고, 처리하고, 출력 채널로 보낸다.\nsync 패키지 채널이 항상 최선은 아니다. 공유 상태를 보호하는 단순한 경우에는 sync 패키지가 적합하다.\nMutex. 하나의 goroutine만 임계 영역에 접근하도록 보장한다. Lock()과 Unlock()으로 제어한다.\nRWMutex. 읽기는 여러 goroutine이 동시에, 쓰기는 독점적으로 접근한다. 읽기가 쓰기보다 빈번한 경우에 효과적이다.\nWaitGroup. 여러 goroutine의 완료를 대기한다. Add()로 카운터를 증가시키고, Done()으로 감소시키고, Wait()로 0이 될 때까지 대기한다.\nOnce. 함수를 정확히 한 번만 실행한다. 초기화에 사용된다.\natomic 패키지 sync/atomic 패키지는 정수나 포인터에 대한 원자적 연산을 제공한다. 락 없이 단일 변수를 안전하게 읽고 쓸 수 있다.\nCompareAndSwap(CAS)은 lock-free 알고리즘의 기초가 되는 연산이다. 현재 값이 기대한 값과 같으면 새 값으로 교체하고 true를 반환한다. 다르면 false를 반환하고 아무 것도 하지 않는다.\nvar counter int64 // 여러 goroutine에서 안전하게 증가 atomic.AddInt64(\u0026amp;counter, 1) // CAS: 기대값이 맞을 때만 교체 atomic.CompareAndSwapInt64(\u0026amp;counter, oldVal, newVal) sync 패키지보다 낮은 수준의 도구다. 단순 카운터나 플래그에는 적합하지만, 복잡한 동기화에는 Mutex나 Channel이 낫다.\n선택 기준 상황 도구 goroutine 간 데이터 전달 Channel 작업 분배, 결과 수집 Channel (fan-out/fan-in) 공유 상태 보호 (읽기/쓰기) sync.RWMutex 동시 실행 수 제한 Buffered Channel 여러 goroutine 완료 대기 sync.WaitGroup 단순 카운터/플래그 sync/atomic Go 공식 위키에서는 다음과 같이 정리한다. 채널은 소유권 전달, 작업 분배, 비동기 결과 전달에 적합하다. Mutex는 캐시, 상태 보호처럼 공유 자원의 접근 제어에 적합하다. 둘 다 유효한 도구이며, 상황에 따라 선택한다.\n","permalink":"https://wid-blog.pages.dev/posts/tech/language/go-concurrency-model/","summary":"Go의 동시성 모델은 CSP를 기반으로 Goroutine과 Channel을 핵심 도구로 제공한다. 각 도구의 동작 원리와 선택 기준을 정리한다.","title":"Go Concurrency 모델"},{"content":"실무에서 Kafka를 쓰고 있었다. 메시지를 produce하고, consume하고, 모니터링 대시보드를 확인하는 정도였다. 클러스터를 직접 구성하거나, topic 설계부터 consumer group 전략까지 처음부터 결정해본 적은 없었다.\nHexagonal Architecture도 비슷했다. 개념은 알고 있었고, 기존 코드에서 port/adapter 패턴을 따르고 있었지만, 빈 프로젝트에서 레이어를 나눠본 경험은 없었다.\n직접 다뤄보고 싶었다. 그래서 채팅 시스템을 만들기로 했다.\n왜 채팅인가 채팅은 Kafka의 pub/sub 모델과 자연스럽게 맞물리는 도메인이다. 메시지를 발행하고 구독자에게 전달하는 흐름이 채팅의 핵심 동작과 일치한다.\nWebSocket 기반 실시간 통신, 이벤트 기반 아키텍처, 멀티 인스턴스 간 메시지 동기화. 이 세 가지를 하나의 프로젝트에서 다룰 수 있다고 판단했다.\n기술 선택 Go + Java 채팅 서비스는 Go로 만들었다. 경량 goroutine 기반의 동시성 처리가 WebSocket 서버에 적합하다고 봤다. 사용자 인증 서비스는 Java(Spring WebFlux)로 만들었다. OAuth2 + JWT 인증은 Spring Security 생태계가 잘 갖추어져 있었고, 이미 익숙한 프레임워크였다.\nAPI Gateway는 Kotlin으로 Spring Cloud Gateway를 사용했다. user-service와 같은 reactive 스택 위에서 동작하는 점, Java 생태계와의 연속성이 선택 이유였다.\nMongoDB 채팅 메시지는 document 구조로 저장하는 것이 자연스러웠다. 방과 메시지가 비정형 데이터에 가까웠고, 스키마 변경이 잦을 것이라 예상했다.\n처음에는 Redis를 사용했다. 빠르게 프로토타이핑하기에는 좋았지만, 메시지 영속성이 필요해지면서 MongoDB로 전환했다.\nKafka KRaft Kafka는 KRaft 모드로 구성했다. ZooKeeper 의존성 없이 Kafka 자체적으로 메타데이터를 관리하는 방식이다. 별도 ZooKeeper 클러스터를 운영하지 않아도 되어 인프라 구성이 단순해졌다.\n3-node 클러스터를 Docker Compose로 구성했고, 각 노드가 controller와 broker 역할을 겸임하도록 설정했다.\n아키텍처 진화 프로젝트는 한 번에 설계한 것이 아니다. PR 단위로 점진적으로 바뀌어갔다.\n시작 처음에는 user-service(Java)와 chat-service(Go) 두 개로 시작했다. chat-service가 WebSocket 핸들링, 방 관리, 메시지 저장, 브로드캐스팅을 전부 담당했다. 저장소는 Redis였다.\nRedis → MongoDB 메시지를 영속적으로 저장해야 했다. Redis는 인메모리 특성상 적합하지 않다고 판단했고, MongoDB로 교체했다. 이 과정에서 repository 계층만 교체하면 되는 구조의 이점을 체감했다. Hexagonal Architecture를 적용해둔 덕분이었다.\nHexagonal Architecture 정리 user-service를 먼저 정리했다. 기존에 대략적으로 나눠져 있던 패키지를 domain/entity, port/driving, port/driven, adapter/driving, adapter/driven 구조로 재배치했다. 이후 chat-service에도 같은 구조를 적용했다.\nKafka 도입 Kafka producer를 먼저 구현하고, 이어서 consumer를 추가했다. 이때 동시성 문제를 마주했다.\n채팅 방에 사용자가 join/leave하는 동안 메시지가 동시에 브로드캐스트되면 race condition이 발생했다. RoomManager에 2단계 lock 전략을 도입해서 해결했다. 방 목록 접근에는 RoomManager 레벨의 RWMutex를, 방 내부 참가자 접근에는 LiveRoom별 RWMutex를 사용해 병목을 줄였다.\n서비스 분리 chat-service가 커지면서 messenger-service와 message-service를 분리했다. messenger-service는 Kafka producer/consumer와 WebSocket 핸들링을, message-service는 메시지 저장과 조회를 담당한다.\nFat Domain 초기에는 도메인 엔티티가 데이터만 들고 있었다. 도메인 로직을 엔티티로 옮기고, application 계층에 usecase 패턴을 도입했다. 각 usecase는 단일 Handle 메서드를 가지며, 하나의 비즈니스 동작만 책임진다.\nKafka를 채팅 브로커로 메시지 흐름은 다음과 같다.\nsequenceDiagram participant C as WebSocket Client participant S as SendUseCase participant DB as MongoDB participant K as Kafka participant B as MessageBroker participant R as RoomManager C-\u003e\u003eS: 메시지 전송 S-\u003e\u003eDB: 메시지 저장 S-\u003e\u003eK: Kafka publish K-\u003e\u003eB: Consumer 수신 B-\u003e\u003eS: OnReceive 콜백 S-\u003e\u003eR: Broadcast R-\u003e\u003eC: WebSocket 전달 SendUseCase가 MessageSubscriber 인터페이스를 직접 구현하고, MessageBroker에 자기 자신을 등록한다. Observer 패턴이다. Consumer가 메시지를 수신하면 등록된 모든 subscriber의 OnReceive를 호출하고, subscriber는 RoomManager를 통해 해당 방의 모든 WebSocket 클라이언트에게 메시지를 전달한다.\n이 구조의 이점은 수평 확장이다. 채팅 서비스 인스턴스가 여러 개 떠 있을 때, 한 인스턴스에서 발생한 메시지가 Kafka를 통해 다른 인스턴스에도 전달된다. 같은 방에 접속한 사용자가 서로 다른 인스턴스에 연결되어 있어도 메시지를 주고받을 수 있다.\n회고 Kafka를 직접 다뤄보고 싶어서 시작한 프로젝트였다.\nHexagonal Architecture가 Go에서 자연스럽게 동작한다는 것을 확인했다. Go의 암묵적 인터페이스 덕분에 port를 정의하고 adapter를 구현하는 과정이 간결했다. DI 프레임워크 없이 main 함수에서 직접 의존성을 조립하는 방식도 오히려 명시적이고 추적하기 쉬웠다.\n동시성 제어에서 가장 많이 배웠다. 처음에는 하나의 RWMutex로 전체 방 목록을 보호했는데, 병목이 생겼다. 방 목록과 방 내부 참가자를 분리해서 각각 lock을 거는 2단계 전략으로 바꾸니, 벤치마크에서 확연한 차이가 나타났다. 이론으로 이해하는 것과 직접 벤치마크를 돌려보며 체감하는 것은 다른 경험이었다.\n아쉬운 점도 있다. 테스트 코드가 부족했다. Hexagonal Architecture의 핵심 이점 중 하나가 port를 mock으로 교체해서 테스트하기 쉽다는 것인데, 테스트를 충분히 작성하지 못했다.\ngRPC도 설정만 해두고 서비스 간 통신에는 적용하지 못했다. 현재 서비스 간 통신은 전부 REST다. gRPC 적용은 다음 단계로 남겨두었다.\nKafka를 직접 다뤄보고 싶어서 시작했고, 그 이상을 얻었다. 아키텍처 설계, 동시성 제어, 서비스 분리. 하나의 시스템 안에서 함께 마주하는 것은 각각을 따로 공부하는 것과 다른 경험이었다.\n참고 chat-services GitHub Repository Go에서 Hexagonal Architecture 구현 Kafka 기초와 KRaft 모드 ","permalink":"https://wid-blog.pages.dev/posts/career/personal/chat-services-retrospective/","summary":"실무에서 깊이 다루기 어려웠던 Kafka와 Hexagonal Architecture를 채팅 시스템 개인 프로젝트로 직접 설계하고 구현한 과정의 기록.","title":"chat-services 회고"},{"content":"Kafka는 분산 이벤트 스트리밍 플랫폼이다. 대량의 이벤트를 실시간으로 발행하고 구독하는 구조를 제공한다. 실시간 데이터 파이프라인, 이벤트 기반 아키텍처, 로그 수집 등 다양한 영역에서 사용된다.\n이 글은 Kafka의 핵심 개념을 정리하고, 최근 ZooKeeper 의존성을 제거한 KRaft 모드의 등장 배경을 설명한다.\n토픽과 파티션 토픽 Kafka에서 메시지는 **토픽(Topic)**에 발행된다. 토픽은 메시지의 논리적 카테고리다. order-events, user-signups처럼 이벤트 유형별로 토픽을 생성한다.\n토픽은 메시지를 보관하는 로그다. 한 번 기록된 메시지는 변경되지 않는다(append-only). 보존 기간(retention)이 지나면 삭제된다.\n파티션 하나의 토픽은 여러 **파티션(Partition)**으로 나뉜다. 파티션은 Kafka의 병렬성과 순서 보장을 동시에 제공하는 핵심 단위다.\nflowchart LR subgraph Topic[\"Topic: order-events\"] P0[\"Partition 0msg0, msg3, msg6...\"] P1[\"Partition 1msg1, msg4, msg7...\"] P2[\"Partition 2msg2, msg5, msg8...\"] end 파티션 내에서 메시지는 순서가 보장된다. 파티션 간에는 순서가 보장되지 않는다. 같은 키를 가진 메시지는 같은 파티션에 할당되므로, 특정 엔티티(예: 특정 주문)에 대한 이벤트 순서를 보장할 수 있다.\n파티션 수를 늘리면 처리량이 증가한다. 여러 컨슈머가 각 파티션을 병렬로 처리할 수 있기 때문이다.\n오프셋 각 파티션 내에서 메시지는 고유한 오프셋(Offset) 번호를 가진다. 0부터 시작해서 순차적으로 증가한다. 오프셋은 컨슈머가 \u0026ldquo;어디까지 읽었는가\u0026quot;를 추적하는 기준이 된다.\n프로듀서 **프로듀서(Producer)**는 토픽에 메시지를 발행한다.\n프로듀서가 메시지를 보낼 때, 어느 파티션에 할당할지 결정해야 한다. 세 가지 방식이 있다.\n키 기반 파티셔닝. 메시지에 키가 있으면 키의 해시값으로 파티션을 결정한다. 같은 키는 항상 같은 파티션에 할당된다. 특정 사용자나 주문에 대한 이벤트 순서를 보장할 때 사용한다.\n라운드 로빈. 키가 없으면 파티션에 순서대로 분배한다. 순서 보장이 필요 없고 부하를 고르게 분산할 때 적합하다.\n커스텀 파티셔너. 직접 파티셔닝 로직을 구현할 수도 있다. 특정 비즈니스 규칙에 따라 파티션을 선택해야 할 때 사용한다.\nAcks 프로듀서는 메시지가 브로커에 기록되었는지 확인하는 수준을 설정할 수 있다.\nacks=0: 확인 없이 전송. 가장 빠르지만 유실 가능성이 있다. acks=1: 리더 브로커가 기록하면 확인. 리더 장애 시 유실 가능성이 있다. acks=all: 모든 ISR(In-Sync Replica)이 기록하면 확인. 가장 안전하지만 지연이 증가한다. 컨슈머 **컨슈머(Consumer)**는 토픽에서 메시지를 읽는다. 프로듀서가 메시지를 \u0026ldquo;push\u0026quot;하는 것과 달리, 컨슈머는 직접 \u0026ldquo;pull\u0026quot;한다. 컨슈머가 자신의 처리 속도에 맞춰 메시지를 가져갈 수 있다.\n컨슈머는 읽은 메시지의 오프셋을 **커밋(Commit)**한다. 커밋된 오프셋은 Kafka 내부 토픽(__consumer_offsets)에 저장된다. 컨슈머가 재시작되면 마지막 커밋된 오프셋부터 다시 읽는다.\n컨슈머 그룹 여러 컨슈머를 하나의 **컨슈머 그룹(Consumer Group)**으로 묶을 수 있다. 같은 그룹 내에서 각 파티션은 하나의 컨슈머에만 할당된다.\nflowchart LR subgraph Topic[\"Topic (3 Partitions)\"] P0[\"P0\"] P1[\"P1\"] P2[\"P2\"] end subgraph Group[\"Consumer Group A\"] C1[\"Consumer 1\"] C2[\"Consumer 2\"] C3[\"Consumer 3\"] end P0 --\u003e C1 P1 --\u003e C2 P2 --\u003e C3 컨슈머 수가 파티션 수보다 많으면, 초과 컨슈머는 유휴 상태가 된다. 처리량을 늘리려면 파티션 수를 먼저 늘려야 한다.\n그룹 내 컨슈머가 추가되거나 제거되면 **리밸런싱(Rebalancing)**이 발생한다. 파티션 할당을 재조정하는 과정이다. 리밸런싱 중에는 해당 그룹의 메시지 처리가 일시 중단된다.\n서로 다른 컨슈머 그룹 서로 다른 컨슈머 그룹은 같은 토픽을 독립적으로 읽는다. 각 그룹이 자체 오프셋을 관리한다.\nflowchart LR subgraph Topic[\"Topic (3 Partitions)\"] P0[\"P0\"] P1[\"P1\"] P2[\"P2\"] end subgraph GA[\"Group A (주문 처리)\"] A1[\"Consumer A1\"] A2[\"Consumer A2\"] end subgraph GB[\"Group B (분석)\"] B1[\"Consumer B1\"] end P0 --\u003e A1 P1 --\u003e A2 P2 --\u003e A1 P0 --\u003e B1 P1 --\u003e B1 P2 --\u003e B1 하나의 토픽에 여러 컨슈머 그룹이 구독하는 구조는 pub/sub 패턴이다. 주문 처리 시스템과 분석 시스템이 같은 이벤트를 독립적으로 소비하는 경우가 대표적이다.\n브로커와 클러스터 브로커 **브로커(Broker)**는 Kafka 서버 인스턴스다. 메시지를 수신하고, 디스크에 저장하고, 컨슈머에게 전달한다. 여러 브로커가 모여 **클러스터(Cluster)**를 구성한다.\n각 파티션은 하나의 브로커에 **리더(Leader)**로 할당된다. 프로듀서와 컨슈머는 리더 브로커와 통신한다.\n복제 파티션은 여러 브로커에 **복제(Replication)**된다. 리더가 장애를 일으키면 팔로워 중 하나가 새 리더로 승격된다.\nflowchart TB subgraph Cluster[\"Kafka Cluster\"] subgraph B1[\"Broker 1\"] P0L[\"P0 (Leader)\"] P1F[\"P1 (Follower)\"] end subgraph B2[\"Broker 2\"] P0F[\"P0 (Follower)\"] P1L[\"P1 (Leader)\"] end subgraph B3[\"Broker 3\"] P0F2[\"P0 (Follower)\"] P1F2[\"P1 (Follower)\"] end end P0L -.-\u003e|복제| P0F P0L -.-\u003e|복제| P0F2 P1L -.-\u003e|복제| P1F P1L -.-\u003e|복제| P1F2 **ISR(In-Sync Replicas)**은 리더와 동기화된 복제본 집합이다. 팔로워가 리더를 따라잡지 못하면 ISR에서 제외된다. acks=all로 설정하면 ISR의 모든 복제본에 기록이 완료되어야 프로듀서에게 확인을 보낸다.\nmin.insync.replicas 설정으로 최소 ISR 수를 지정할 수 있다. replication factor가 3이고 min ISR이 2이면, 브로커 1대가 장애를 일으켜도 쓰기가 가능하다. 2대가 장애를 일으키면 쓰기가 거부되어 데이터 정합성을 보호한다.\nZooKeeper와 그 한계 Kafka 3.3 이전까지, Kafka 클러스터의 메타데이터 관리는 ZooKeeper가 담당했다. 브로커 목록, 토픽/파티션 설정, 컨트롤러 선출, ACL 정보 등을 ZooKeeper에 저장하고 조회했다.\nZooKeeper 기반 아키텍처에는 몇 가지 문제가 있었다.\n별도 시스템 운영 부담. Kafka 클러스터와 별개로 ZooKeeper 클러스터(보통 3~5 노드)를 운영해야 한다. 모니터링, 업그레이드, 장애 대응 대상이 두 배가 된다.\n메타데이터 전파 병목. 브로커가 ZooKeeper에서 메타데이터를 가져오는 구조이므로, 파티션 수가 늘어나면 메타데이터 동기화에 시간이 걸린다. 대규모 클러스터에서 컨트롤러 장애 복구가 느려지는 원인이 된다.\n이중 합의 문제. ZooKeeper는 자체 합의 알고리즘(ZAB)으로 동작하고, Kafka는 별도로 ISR 기반 복제를 운영한다. 두 시스템의 상태가 일시적으로 불일치할 수 있다.\nKRaft 모드 **KRaft(Kafka Raft)**는 ZooKeeper를 제거하고, Kafka 자체적으로 메타데이터를 관리하는 모드다. Kafka 3.3에서 프로덕션 사용이 가능해졌고, 4.0부터 ZooKeeper 모드가 제거되었다.\nKRaft에서는 일부 브로커가 Controller 역할을 겸임한다. Controller 노드들이 Raft 합의 알고리즘으로 메타데이터 로그에 대해 합의한다. 메타데이터가 Kafka 내부 토픽에 저장되므로, 별도 시스템이 필요 없다.\nZooKeeper 모드와 비교한 주요 변화:\nZooKeeper 클러스터 제거. 운영 대상이 Kafka 하나로 줄어든다. 메타데이터가 이벤트 로그로 관리된다. 브로커가 메타데이터 로그를 구독하여 자체 상태를 유지한다. ZooKeeper에서 풀링하는 방식보다 전파가 빠르다. 컨트롤러 장애 복구가 빨라진다. Raft 프로토콜에 의해 새 리더가 선출되고, 메타데이터 로그를 이어받는다. 정리 Kafka의 핵심은 토픽, 파티션, 컨슈머 그룹이다. 파티션이 병렬성과 순서 보장을 제공하고, 컨슈머 그룹이 수평 확장을 가능하게 한다. 브로커 복제가 장애 내성을 보장한다.\nKRaft 모드는 이 구조에서 ZooKeeper라는 외부 의존성을 제거했다. Kafka만으로 메타데이터 합의와 관리가 완결되는 아키텍처로 전환한 것이다.\n","permalink":"https://wid-blog.pages.dev/posts/tech/infra/kafka-fundamentals-kraft/","summary":"Kafka의 핵심 개념(토픽, 파티션, 컨슈머 그룹, 복제)을 정리하고, ZooKeeper 의존성을 제거한 KRaft 모드의 등장 배경을 설명한다.","title":"Kafka 기초와 KRaft 모드"},{"content":"Hexagonal Architecture(Ports \u0026amp; Adapters)의 핵심은 의존성 방향 제어다. 비즈니스 로직이 프레임워크나 DB에 종속되지 않도록, 모든 외부 의존성을 인터페이스(Port) 뒤로 격리한다.\nGo에서는 암묵적 인터페이스와 패키지 구조 덕분에 이 패턴이 자연스럽게 구현된다.\nHexagonal Architecture Alistair Cockburn이 제안한 이 패턴은 애플리케이션을 세 영역으로 나눈다.\nDomain. 비즈니스 규칙을 담은 핵심 계층이다. 외부 기술에 의존하지 않는다.\nPort. 애플리케이션과 외부 세계 사이의 인터페이스다. 두 종류가 있다.\nDriving port(inbound): 외부에서 애플리케이션으로 들어오는 진입점. 애플리케이션이 제공하는 기능을 정의한다. Driven port(outbound): 애플리케이션이 외부 시스템에 요청하는 인터페이스. 애플리케이션이 필요로 하는 것을 정의한다. Adapter. Port의 구현체다. Driving adapter는 HTTP handler, gRPC handler처럼 외부 요청을 받아 port를 호출한다. Driven adapter는 DB repository, 메시지 브로커처럼 port 인터페이스를 구현해서 외부 시스템과 통신한다.\n의존성 방향은 항상 안쪽을 향한다. Adapter → Port → Domain. Domain은 Port의 존재를 모르고, Port는 Adapter의 존재를 모른다.\nflowchart LR subgraph Adapter[\"Adapter\"] DA[\"Driving AdapterREST, gRPC\"] DRA[\"Driven AdapterDB, Kafka\"] end subgraph Port[\"Port\"] DP[\"Driving Port\"] DRP[\"Driven Port\"] end subgraph Core[\"Domain + Application\"] D[\"Entity\"] A[\"UseCase / Service\"] end DA --\u003e|호출| DP DP -.-\u003e|정의| A A --\u003e|사용| DRP DRP -.-\u003e|구현| DRA A --\u003e|포함| D Go 디렉토리 구조 Go에서 Hexagonal Architecture를 적용할 때 사용할 수 있는 디렉토리 구조다.\ninternal/ ├── domain/ │ ├── entity/ # 비즈니스 엔티티 │ └── service/ # 도메인 서비스 ├── port/ │ ├── driving/ # inbound 인터페이스 │ └── driven/ # outbound 인터페이스 ├── application/ │ ├── usecase/ # 비즈니스 동작 단위 │ ├── dto/ # 계층 간 데이터 전달 객체 │ └── mapper/ # entity ↔ dto 변환 └── adapter/ ├── driving/ # REST handler, gRPC handler └── driven/ # DB repository, 메시지 브로커 internal/ 패키지를 사용하면 외부 모듈에서 직접 접근할 수 없다. 애플리케이션의 내부 구현이 자연스럽게 캡슐화된다.\nPort Port는 Go 인터페이스로 정의한다.\nDriving Port 외부에서 애플리케이션으로 들어오는 진입점이다. UseCase 단위로 정의하면 각 인터페이스가 단일 책임을 가진다.\n// port/driving/messenger.go type JoinRoomUseCase interface { Handle(ctx context.Context, req dto.JoinRequest) error } type SendMessageUseCase interface { Handle(ctx context.Context, req dto.SendRequest) error } Driven Port 애플리케이션이 외부 시스템에 요청하는 인터페이스다.\n// port/driven/message.go type MessageRepository interface { Create(ctx context.Context, message entity.Message) error FindByRoom(ctx context.Context, roomID string, cursor string, limit int) ([]entity.Message, error) } type MessageBroker interface { Publish(ctx context.Context, message entity.Message) error Subscribe(subscriber MessageSubscriber) } 암묵적 인터페이스 Go의 인터페이스는 암묵적으로 구현된다. Adapter가 Port 인터페이스의 메서드를 가지고 있으면 별도 선언 없이 해당 인터페이스를 만족한다. Java의 implements 키워드가 필요 없다.\n이 특성은 Hexagonal Architecture에 적합하다. Driven adapter가 driven port를 구현할 때, adapter 코드에 port 패키지를 import하지 않아도 된다. 의존성이 코드 수준에서도 분리된다.\n단, 컴파일 타임에 인터페이스 구현을 보장하려면 다음과 같은 관례를 사용한다.\nvar _ driven.MessageRepository = (*MongoMessageRepository)(nil) 이 한 줄이 MongoMessageRepository가 driven.MessageRepository를 만족하는지 컴파일 타임에 검증한다.\nAdapter Driving Adapter HTTP handler가 대표적인 driving adapter다. 외부 요청을 받아서 driving port(usecase)를 호출한다.\n// adapter/driving/rest/handler.go type Handler struct { sendUseCase driving.SendMessageUseCase } func NewHandler(uc driving.SendMessageUseCase) *Handler { return \u0026amp;Handler{sendUseCase: uc} } func (h *Handler) Send(c *gin.Context) { var req dto.SendRequest if err := c.ShouldBindJSON(\u0026amp;req); err != nil { c.JSON(http.StatusBadRequest, gin.H{\u0026#34;error\u0026#34;: err.Error()}) return } if err := h.sendUseCase.Handle(c.Request.Context(), req); err != nil { c.JSON(http.StatusInternalServerError, gin.H{\u0026#34;error\u0026#34;: err.Error()}) return } c.Status(http.StatusOK) } Handler는 driving port 인터페이스에만 의존한다. 그 뒤에 어떤 구현체가 있는지 모른다.\nDriven Adapter DB repository가 대표적인 driven adapter다. Driven port 인터페이스를 구현한다.\n// adapter/driven/persistence/repository.go type MongoMessageRepository struct { collection *mongo.Collection } func NewMongoMessageRepository(db *mongo.Database) *MongoMessageRepository { return \u0026amp;MongoMessageRepository{ collection: db.Collection(\u0026#34;messages\u0026#34;), } } func (r *MongoMessageRepository) Create(ctx context.Context, message entity.Message) error { doc := orm.FromMessage(message) _, err := r.collection.InsertOne(ctx, doc) if err != nil { return fmt.Errorf(\u0026#34;insert message: %w\u0026#34;, err) } return nil } ORM 모델과 domain entity는 별도 구조체로 분리한다. orm.FromMessage()와 ToDomain() 메서드로 변환한다. Domain entity가 DB 구조에 종속되지 않기 위함이다.\nDomain과 Application Entity Domain entity는 비즈니스 규칙을 포함한다. 필드를 unexported(소문자)로 선언하고 getter 메서드를 제공한다.\n// domain/entity/message.go type Message struct { id string roomID string userID string body string sentAt time.Time } func NewMessage(roomID, userID, body string) Message { return Message{ id: uuid.New().String(), roomID: roomID, userID: userID, body: body, sentAt: time.Now(), } } func (m Message) ID() string { return m.id } func (m Message) RoomID() string { return m.roomID } func (m Message) Body() string { return m.body } 필드가 unexported이므로 외부에서 직접 수정할 수 없다. 생성은 NewMessage 생성자를 통해서만 가능하다. 도메인 불변 조건(invariant)을 보호한다.\nUseCase UseCase는 하나의 비즈니스 동작을 담당한다. Driving port를 구현하며, driven port에 의존한다.\n// application/usecase/send.go type SendUseCase struct { repo driven.MessageRepository broker driven.MessageBroker } func NewSendUseCase(repo driven.MessageRepository, broker driven.MessageBroker) *SendUseCase { return \u0026amp;SendUseCase{repo: repo, broker: broker} } func (uc *SendUseCase) Handle(ctx context.Context, req dto.SendRequest) error { message := entity.NewMessage(req.RoomID, req.UserID, req.Body) if err := uc.repo.Create(ctx, message); err != nil { return fmt.Errorf(\u0026#34;save message: %w\u0026#34;, err) } if err := uc.broker.Publish(ctx, message); err != nil { return fmt.Errorf(\u0026#34;publish message: %w\u0026#34;, err) } return nil } UseCase는 driven port 인터페이스에만 의존한다. MongoDB든 PostgreSQL이든 MessageRepository 인터페이스를 구현하면 교체할 수 있다.\n의존성 주입 Go에서는 DI 프레임워크 없이 main 함수에서 직접 의존성을 조립하는 것이 일반적이다.\nfunc main() { // driven adapter db := mongodb.Connect(os.Getenv(\u0026#34;MONGO_URI\u0026#34;)) messageRepo := repository.NewMongoMessageRepository(db) broker := messaging.NewKafkaBroker(kafkaConfig) // usecase (driven port 주입) sendUseCase := usecase.NewSendUseCase(messageRepo, broker) // driving adapter (driving port 주입) handler := rest.NewHandler(sendUseCase) // 서버 시작 server := rest.NewServer(handler) server.Run(\u0026#34;:8080\u0026#34;) } 의존성 그래프가 한 곳에서 명시적으로 드러난다. 어떤 구현체가 어떤 인터페이스에 주입되는지 코드를 따라가면 바로 확인할 수 있다.\nJava/Spring에서는 @Component와 @Autowired로 프레임워크가 의존성을 자동 주입한다. Go에서는 이 과정이 수동이지만 의존성 흐름이 명시적이고 추적이 쉽다.\n정리 Hexagonal Architecture의 구현은 언어마다 관용적 방식이 다르다. Go에서는 암묵적 인터페이스, internal 패키지, 수동 DI가 이 패턴과 잘 맞는다. Port를 인터페이스로 정의하고, Adapter가 이를 구현하고, main에서 조립한다. 프레임워크 없이도 의존성 방향이 코드 구조에 그대로 드러난다.\n","permalink":"https://wid-blog.pages.dev/posts/tech/architecture/go-hexagonal-architecture/","summary":"Hexagonal Architecture의 핵심 개념과 Go에서의 관용적 구현. 암묵적 인터페이스와 패키지 구조를 활용한 의존성 방향 제어.","title":"Go에서 Hexagonal Architecture 구현"},{"content":"백엔드 엔지니어. 기술과 일하며 느낀 것들을 글로 씁니다.\n","permalink":"https://wid-blog.pages.dev/about/","summary":"\u003cp\u003e백엔드 엔지니어. 기술과 일하며 느낀 것들을 글로 씁니다.\u003c/p\u003e","title":"소개"}]