본문으로 건너뛰기

Chrome 146 세로탭 탐험

· 약 8분

Arc를 처음 만난 날

Arc를 처음 켰을 때의 인상은 지금도 또렷해요. 새 탭 페이지는 없고 URL바는 화면 한복판이 아니라 옆에 붙어 있었으며, Cmd+T를 누르면 빈 새 탭 대신 "다음에 뭐 할 거야?"라고 묻는 듯한 Command Bar가 떴어요. 5분도 안 돼 "아, 이게 2020년대 브라우저구나" 싶었고, 그날부터 메인 브라우저가 됐어요.

좋아한 부분을 꼽자면 끝이 없었습니다.

SidebarCmd+S 한 번이면 접히고 콘텐츠가 화면 전체를 덮습니다. 단순한 토글이 아니라 "작업 모드 자체가 전환되는 감각"이었습니다. 글을 읽을 때는 접고 작업할 때는 펴면서, 키 한 번으로 흐름을 바꿉니다.

Spaces는 회사, 개인, 사이드 프로젝트를 색깔과 아이콘으로 분리합니다. 컨텍스트 스위칭이 "탭 닫고 새로 열기"보다 한 단계 위로 올라갔고, Space마다 북마크, 핀, 테마가 따로 존재합니다. Command Bar에는 Cmd+T 한 키로 북마크 점프, URL 입력, 탭 검색, 새 탭 생성이 모두 모입니다. 키보드만으로 거의 모든 이동이 가능해서 어느 순간부터 마우스로 탭을 클릭하지 않게 됐습니다.

Pinned Tabs / Today Tabs에서는 자주 가는 사이트를 사이드바 위쪽에 고정하고, 일회성 탭은 일정 시간이 지나면 자동으로 사라지게 합니다. "탭 정리"라는 행위 자체가 사라졌습니다. 한 번 익숙해지면 다른 브라우저의 탭 50개 무덤이 답답하게 느껴집니다.

Boost는 사이트 CSS와 JS를 직접 오버라이드합니다. 광고 영역을 가리고 폰트를 바꾸거나, 거슬리는 섹션을 통째로 숨기기도 합니다. Twitter의 "For You" 탭을 display: none으로 지우고 썼습니다. Little Arc는 외부 앱에서 클릭한 링크를 작은 팝업 윈도우로 엽니다. 메인 브라우저의 흐름을 깨지 않고 그 자리에서 닫히므로, Slack에서 링크 하나를 확인하려고 메인 창에 탭을 쌓는 일이 사라집니다.

쓸수록 "브라우저는 그냥 탭 컨테이너 아니냐"는 통념이 깨졌습니다. Arc는 브라우저를 단순한 도구가 아니라 작업 공간 그 자체로 다시 정의한 첫 제품이었습니다. 그렇게 한참을 메인으로 썼습니다.

그러다 어쩔 수 없이 Chrome으로

문제는 한국에서 매일 쓰기에 가끔 까다로웠다는 점입니다. 디자인이 살짝 깨지는 페이지와 결제창이 제대로 안 뜨는 사이트가 종종 있었습니다. 평소에는 "뭐 그러려니" 하고 넘겼는데, 작년 가을 춘천마라톤 등록일에 결제가 안 돼서 선착순 마감을 놓쳤습니다. 그날 이후 호환성이 우선이라는 판단이 섰고, 메인을 Chrome으로 옮겼습니다.

Chrome으로 넘어와서 가장 그리웠던 건 결국 세로탭이었습니다. Sidebar 토글의 흐름, 컨텍스트가 바뀌는 감각, 좌측에 정렬된 깔끔한 탭 목록 — 여기에 익숙해지면 가로 탭으로 돌아가기 어렵습니다. 그렇게 어색한 1년이 흘렀습니다.

그러다 2026년 4월, Chrome 146 stable에 세로탭(vertical tabs)이 정식으로 추가됐다는 뉴스를 봤습니다. Arc의 흐름을 Chrome에서 다시 만날 수 있을까 싶었습니다. 이왕 둘러보는 김에 시중의 다른 브라우저도 한꺼번에 설치해 비교했습니다. 이 글은 그 탐험기입니다.

Chrome의 세로탭

Chrome 146은 우클릭 메뉴에 "Show Tabs Vertically" 항목을 추가했습니다. 한 번 켜면 다음에도 그대로 유지됩니다. 활성화 자체는 간단합니다.

문제는 이게 전부라는 점입니다.

  • 토글 단축키가 없어 세로탭을 잠깐 접고 가로로 돌리는 흐름이 없습니다
  • 사이드바 너비 조절이 거칩니다
  • 그룹 탭과 세로탭 UI가 어딘가 어색하게 섞입니다
  • Arc의 "공간이 바뀌는 감각" 없이 탭 영역만 옆으로 옮겨갑니다

비교 대상은 분명합니다. Edge는 Cmd+Shift+, 한 번으로 세로/가로 탭을 토글합니다(Windows에서는 Ctrl+Shift+,). 2021년부터 그랬습니다. Chrome이 5년 늦게 따라잡으면서도 단축키를 넣지 않은 건 의도적이라고밖에 보기 어렵습니다.

추측건대 Chrome은 "세로탭 사용자 = 소수 파워유저"라는 데이터를 갖고 있을 것입니다. 우클릭 메뉴에만 둔 건 일반 사용자에게 노출하지 않겠다는 결정으로 보입니다. 합리적이긴 하지만, Arc의 사용성을 기대하고 켠 사람에게는 첫인상부터 부족합니다.

이게 끝이라면 다른 브라우저를 다시 봐야겠다고 생각했습니다.

다시 둘러본 브라우저들

세로탭이 정식 기능으로 있고, 토글 단축키가 있는지를 기준으로 정리해봤습니다.

브라우저세로탭토글 단축키한 줄 평
Edge정식Cmd+Shift+, (Mac) / Ctrl+Shift+, (Win)가장 현실적인 1순위
Vivaldi정식무한 커스텀파워유저 끝판왕, 학습곡선 가파름
Zen강제매핑 가능Firefox 포크. Arc UX 정신적 후계
Arc기본Cmd+S신규 기능 없음, 사실상 EOL
Dia기본Arc 후속 유사macOS Apple Silicon 전용
Chrome지원 (146~)없음단축키 부재, UX 미성숙
Firefox정식F1 사이드바의외로 견고한 후보
Safari사이드바만완전한 세로탭은 아님

각각 며칠씩 써보고 느낀 점을 짧게 정리합니다.

Edge

단축키의 유무가 이렇게 큰 차이를 만듭니다.

Microsoft 브라우저라 윈도우 전용이라고 생각하기 쉬운데, macOS 버전도 정식으로 있습니다. Apple Silicon 네이티브 빌드까지 지원해서 M1 이상에선 매우 가볍게 돌아갑니다.

Chromium 기반이라 Chrome 확장을 그대로 쓸 수 있고 동기화도 매끄럽습니다. Cmd+Shift+,로 세로/가로를 토글하며(Windows는 Ctrl+Shift+,), 이 차이가 꽤 큽니다. 회의 중 화면을 공유할 때는 가로로, 평소 작업할 때는 세로로 바꿔도 흐름이 끊기지 않습니다.

거슬리는 건 Microsoft 자체 PR입니다. 첫 실행할 때마다 Bing/Copilot/Reading list 같은 걸 꽂으려 듭니다. 한 번 정리해두면 그 뒤론 조용해집니다. 세로탭을 진지하게 쓰고 싶다면 Mac/Win 가리지 않고 가장 안전한 선택입니다.

Vivaldi

무엇이든 되지만 그만큼 직접 설정해야 합니다.

세로탭은 기본이며 탭 타일링(여러 탭을 한 화면에 분할), 세션 저장, 마우스 제스처, 이메일, 캘린더, RSS 내장까지 갖췄습니다. 모든 명령에 단축키를 매핑할 수도 있습니다. 할 수 있는 게 너무 많아서 첫 실행 후 30분 동안 설정만 하게 됩니다.

파워유저용 도구가 맞고, 거기서 본인이 원하는 워크플로우가 명확하다면 최고의 선택입니다. 다만 "그냥 좋은 디폴트가 있는 브라우저"를 원하는 사람과는 안 맞습니다.

Zen

Arc UX가 그리운 사람들에게는 새 집 같은 브라우저입니다.

Firefox 148 기반 포크입니다. 세로탭이 기본이고 가로탭은 아예 만들지 않습니다. Arc의 Spaces에 해당하는 워크스페이스, 화면 분할 타일링(최대 4개), 탭 폴더, Glance 미리보기, Zen Mods를 통한 사이트 커스텀까지 갖췄습니다. Arc UX의 향수를 가장 진지하게 계승하는 프로젝트입니다.

오픈소스이고 무료이며 텔레메트리를 최소화합니다. 2026년 4월 기준 1.19.x 베타지만 일상에서 쓰기에 큰 문제는 없습니다. 단점은 Firefox 기반이라 Chrome 확장을 쓰지 못하고, 한국 사이트 호환성도 결국 Chrome보다 떨어진다는 점입니다 — Arc에서 겪었던 패턴이 어느 정도 반복됩니다.

그래도 "무료 Arc 대안"으로는 단연 가장 흥미로운 후보입니다.

Brave

빠르고 가볍지만 세로탭은 평범합니다.

광고/트래커 차단을 엔진 레벨에서 처리해서 체감 속도가 빠릅니다. 메모리 사용량이 Chrome 대비 60% 이상 적다는 벤치마크가 여러 곳에서 나옵니다. 세로탭도 정식 지원이지만 사용성 자체는 평범합니다. 프라이버시/속도가 1순위이고 세로탭은 보너스 정도라면 좋은 선택입니다.

Firefox

의외로 가장 견고했습니다.

Firefox 138 즈음부터 사이드바와 함께 세로탭이 정식 기능으로 들어왔습니다. 안정성과 표준 준수라는 측면에서 가장 견고합니다. 진짜로 독립된 엔진(Gecko)을 쓰는 유일한 메이저 브라우저라는 가치도 다시 보입니다.

세로탭 UX 자체는 Edge보다 살짝 투박하지만, 추적 방지, 확장 생태계, 메모리 효율의 균형은 여전히 좋습니다. 며칠 써본 후보 가운데 가장 의외였습니다.

Safari

사이드바는 있지만 본격적인 세로탭은 아닙니다.

탭 그룹과 사이드바는 좋지만, 사이드바를 켜도 진정한 세로탭이라기보다는 "탭 그룹 네비게이션"에 가깝습니다. macOS와의 통합(Handoff, iCloud 키체인, 배터리 효율)은 여전히 압도적이지만, 세로탭 자체를 우선순위로 본다면 Safari는 답이 아닙니다.

AI 브라우저

같은 시기에 등장했지만 다른 질문에 답하는 브라우저입니다.

세로탭 이슈와 별개로, 2025년 말부터 2026년 사이에 AI 네이티브 브라우저 라는 새 카테고리가 본격적으로 등장했습니다. 같이 둘러본 김에 짧게 정리합니다.

브라우저만든 곳플랫폼핵심
DiaThe Browser Company (Arc 팀)macOS Apple Silicon 전용URL바가 AI 어시스턴트, 무료 + Pro $20/월
CometPerplexityWin/Mac/iOS/Android컨텍스트 인식 어시스턴트, MAU 10M (2025 Q3)
ChatGPT AtlasOpenAImacOS Apple Silicon 전용Agent Mode로 멀티탭 자동화, $20~$200/월

흥미롭지만 이들은 "세로탭의 답"과는 다른 질문을 다룹니다. 검색, 리서치, 반복 작업의 자동화를 풀어주는 도구이며, Arc의 "작업 공간으로서의 브라우저" 감각을 채워주는 도구는 아닙니다. Dia가 Arc 팀의 후속이라 가장 가까울 법하지만, 이쪽은 의도적으로 더 보수적이고 채팅 인터페이스 중심입니다.

윈도우 환경에서 AI 브라우저를 진지하게 쓰고 싶다면 현재로서는 Comet이 사실상 유일한 선택지입니다. macOS라면 Dia를 무료로 시작해보는 게 부담 없습니다.

시장 지형, 한 문단

Statcounter 기준 2026년 글로벌 점유율은 Chrome 71%, Safari 14.7%, Edge 4.6%, Firefox 2.2% 정도입니다. 한국 데스크탑은 Chrome이 72%로 더 압도적입니다. 성능 벤치마크(JetStream 3, Speedometer 3.1)에서는 Chrome이 처음으로 Safari와 공동 1위에 올라왔고, 에너지 효율은 Edge가 1위입니다. 점유율이든 속도든 적어도 데이터상으로는 Chrome을 쓰지 않을 이유가 점점 줄어들고 있습니다. 그럴수록 사용성의 아쉬움이 도드라집니다.

결론

아직 Arc의 자리는 비어 있습니다.

며칠씩 돌려가며 써보고 정리한 결론은 단순합니다. 현실적인 1순위는 Edge입니다. 단축키와 호환성을 갖췄고 Chrome 확장을 그대로 쓸 수 있어 Windows 환경이라면 거의 무난합니다. 호기심으로 고른 1순위는 Zen입니다. Arc UX의 정신적 후계로서 베타지만 매일 발전 중이고, 무료 오픈소스라는 가치가 큽니다. Chrome의 세로탭은 시작점일 뿐 아직 완성품은 아닙니다. 구글이 단축키를 넣고 사이드바 UX를 다듬어줄 때까지는 차선책에 머뭅니다.

그리고 인정할 건 인정해야 합니다. 한국에서 결제와 본인인증, 회사 사내 시스템 같은 결정적인 순간엔 결국 Chrome이 가장 안전합니다. 춘마 같은 사건은 한 번이면 충분합니다. 그래서 일상 작업은 Edge나 Zen으로 옮겨도, Chrome 한 자리는 여전히 비워두게 됩니다.

Arc가 만들어준 그 감각 — "브라우저가 단순한 도구가 아니라 작업 공간 그 자체"라는 감각을 다시 만날 날이 언젠가는 올 거라고 믿습니다. 다만 그게 Chrome 146의 세로탭은 아니었습니다.

그러니 올해 춘천은 Chrome으로 등록할 거예요. 하지만 일상의 브라우저는 아직 찾는 중이에요.

참고

Claude Code Switcher

· 약 15분

왜 "스위처"가 필요해졌나

Claude Code가 2024년 말 등장한 직후만 해도 사용량 캡은 거의 의식되지 않았어요. 그게 바뀐 건 2025-08-28이에요. Anthropic이 Pro / Max 플랜에 5시간 롤링 윈도우 + 7일 weekly cap 이중 제한을 도입한 날이에요. Max 5x ($100/mo)와 Max 20x ($200/mo)도 weekly 캡에서 자유롭지 않습니다.

cap에 걸리면 두 가지 길이 있습니다.

  1. 그 주가 끝날 때까지 기다립니다
  2. 다른 프로바이더로 옮깁니다. Z.AI의 GLM Coding Plan, Moonshot의 Kimi, OpenRouter의 300개 모델 중 하나, 또는 로컬 Ollama를 선택합니다

옮기는 일 자체는 어렵지 않습니다. Claude Code가 환경변수 두 개(ANTHROPIC_BASE_URLANTHROPIC_AUTH_TOKEN)만 보고 동작하기 때문입니다. 예를 들어 GLM으로 옮기는 방법은 Z.AI 공식 가이드 기준으로 두 줄이면 됩니다.

export ANTHROPIC_BASE_URL="https://api.z.ai/api/anthropic"
export ANTHROPIC_AUTH_TOKEN="..."

다른 프로바이더(Kimi, DeepSeek 등)도 같은 패턴이지만 정확한 Anthropic-호환 엔드포인트 URL은 각 프로바이더 공식 문서에서 직접 확인해야 합니다. 같은 회사라도 OpenAI-호환 엔드포인트(/v1)와 Anthropic-호환 엔드포인트가 다르고, 후자가 잘못 표기된 글이 인터넷에 흔합니다.

문제는 하루에 셋넷씩 갈아타게 되는 순간입니다. 회사 계정, 개인 계정, GLM, 로컬 모델, 임시 OpenRouter를 오가며 매번 셸 변수를 바꾸다 보면, 헷갈려서 회사 계정으로 사이드 프로젝트를 돌리기도 하고 캡이 어디서 깎이는지도 까먹습니다. 이 지점에서 등장한 도구가 **CCS (Claude Code Switcher)**입니다.

CCS 한 줄 요약

@kaitranntt/ccs는 Kai Tran(카이 트란)이 만든 Claude Code용 멀티 프로바이더/멀티 계정 프로필 매니저입니다. MIT 라이선스의 npm 패키지이며, GitHub ★2.2k, 2026-04-29 기준 v7.75.0입니다. 721개 릴리즈와 3,687개 커밋이 쌓일 만큼 개발이 매우 활발합니다.

홈페이지: https://ccs.kaitran.ca/ 저장소: https://github.com/kaitranntt/ccs

자기 소개는 한 문장입니다.

The multi-provider profile and runtime manager for Claude Code and compatible CLIs. — config 파일 갈아엎지 말고, 활성 세션 깨지 않으면서, 몇 초 안에 프로바이더를 옮겨라.

무엇을 지원하나

설치는 한 줄이면 됩니다.

npm install -g @kaitranntt/ccs
# 또는
bun add -g @kaitranntt/ccs

ccs config 한 번 돌려 초기 설정 후, 프로필 단위로 사용합니다.

명령라우팅
ccs기본값: Claude Sonnet 4.6
ccs glmZ.AI GLM-5.1 (API 또는 Coding Plan)
ccs kimiMoonshot Kimi (1M context)
ccs geminiGemini 3 Pro/Flash (OAuth)
ccs codexGPT-5.4 / Codex (OAuth)
ccs ollama로컬 Ollama 모델
ccs work / ccs personal같은 Claude 라도 계정 격리
ccs --target droid glmruntime 자체를 Factory Droid로 바꾸고 모델은 GLM

지원 프로바이더는 점점 늘어 현재 다음을 포함합니다.

  • Claude (Sonnet 4.6, Opus 4.6): 공식
  • GLM-5.1, GLM-5-Turbo, GLM-4.7: Z.AI
  • Kimi K2.5: Moonshot
  • Gemini 3 Pro/Flash: Google (OAuth)
  • GPT-5.4 / Codex: OpenAI (OAuth)
  • Antigravity Pro/Turbo: ccs agy (OAuth)
  • OpenRouter: 300+ 모델 한 번에
  • Ollama, llama.cpp, Novita, Alibaba Coding Plan 등 로컬/OpenAI-호환 모두

어떻게 동작하나

CCS의 핵심은 두 가지 컴포넌트입니다.

첫 번째는 로컬 Anthropic-호환 프록시입니다. GLM/Kimi처럼 이미 Anthropic-호환 엔드포인트를 제공하는 프로바이더는 단순 패스스루로 지나가고, 그렇지 않은 OpenAI-호환 프로바이더(OpenRouter, DeepSeek 등)는 로컬 프록시가 요청과 응답을 변환해 Claude Code가 자기 형식대로 받게 만듭니다.

두 번째는 CLIProxyAPI라는 OAuth 프록시 백엔드입니다. Gemini, Codex, Antigravity처럼 공식 API 키가 없는 OAuth 기반 프로바이더는 이 별도 컴포넌트가 처리하고, 라운드로빈이나 fill-first 같은 토큰 관리 정책도 여기서 돕습니다. 커뮤니티 fork(CLIProxyAPIPlus)도 옵트인으로 쓸 수 있습니다.

활성화는 eval "$(ccs proxy activate)" 같이 셸에 평가시켜 ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN을 그 세션 한정으로 주입하는 방식입니다. 다음 셸이나 다음 디렉토리에서는 다시 깨끗해집니다.

# CCS가 한 번에 처리하는 것
$ ccs glm "이 PR 리뷰해줘"
# 내부적으로:
# 1. ~/.ccs/profiles/glm.settings.json 읽기
# 2. ANTHROPIC_BASE_URL=https://api.z.ai/api/anthropic 주입
# 3. ANTHROPIC_AUTH_TOKEN=$GLM_KEY 주입
# 4. claude code 호출

겉으로는 한 줄이지만 안쪽에서 하는 일은 수동 env 셋과 다르지 않고, 그걸 프로필이라는 단위로 캡슐화했을 뿐입니다.

장점은 어디서 나오나

가장 눈에 띄는 건 명령 하나가 프로파일 하나에 대응한다는 점입니다. 회사 Claude, 개인 Claude, GLM, Kimi, 로컬까지 다섯 개를 셸 변수 갈아끼우지 않고 명령 하나로 분리합니다. 여기서 한 발 더 들어가면 멀티 계정 격리가 있습니다. ccs workccs personal이 같은 머신에서 동시에 다른 Claude 세션을 돌릴 수 있고 weekly cap도 따로 깎이므로, 회사 계정으로 사이드 프로젝트를 돌리다 cap을 축내는 사고가 구조적으로 사라집니다.

비용 이야기도 빼놓기 어렵습니다. 공식 페이지는 "Save $500–1000/month with strategic delegation"이라 적는데, 검증은 본인 워크로드에 따라 다릅니다. 다만 무거운 보일러플레이트는 GLM($10–30/mo)으로 돌리고 설계만 Claude Max로 가는 패턴은 실제로 cap 부담을 크게 줄여줍니다.

나머지 장점은 결이 비슷합니다. Z.AI처럼 API 키를 발급해 쓰는 곳, Gemini처럼 OAuth로 가는 곳, 로컬 Ollama까지 같은 명령 표면 위로 올라오고, proxy.routing 설정으로 "긴 컨텍스트는 Kimi, 추론은 Claude, 백그라운드는 GLM" 식의 자동 라우팅도 짤 수 있습니다. config 파일을 손으로 만지지 않고 비주얼 대시보드로 프로필을 추가/수정할 수 있어, 한국에서 GLM Coding Plan처럼 결제/키 발급이 번거로운 프로바이더를 쓸 때 편합니다. 721 릴리즈에 거의 매일 업데이트될 만큼 개발이 활발해 새 프로바이더가 나오면 빠르게 흡수되는 것도 덤입니다.

단점과 주의사항

가장 실질적인 부담은 추상화가 두껍다는 점입니다. 프록시가 두 단계(로컬 + CLIProxyAPI)로 끼어들고 프로바이더마다 라우팅 룰이 따로 사는 탓에, "내 요청이 어디로 갔지?"가 헷갈리는 순간이 생깁니다. 로그(~/.ccs/logs/)를 켜둘 가치가 여기 있습니다. 핵심 기능 일부가 CLIProxyAPI라는 별도 프로젝트에 묶여 있다는 점도 같은 맥락인데, 그쪽 이슈가 CCS 동작에 그대로 옮겨붙을 수 있습니다. 버전 업이 매우 빠른 것(v7.75.0, 721 릴리즈)도 새 프로바이더 흡수에는 좋지만 "내 환경에서 안정적인 v7.x"를 고수하려는 보수적 운영에는 부담이라, lockfile로 고정하는 편이 낫습니다.

쓸모의 경계도 분명합니다. 다른 프로바이더를 안 쓰고 회사/개인 두 계정만 격리하는 경우라면 direnv.envrc 같은 더 가벼운 도구로 충분하고, CCS는 옮겨 다닐 곳이 셋 이상일 때부터 의미가 생깁니다. 그리고 Codex, Antigravity 같은 비공개 OAuth 프로바이더를 우회로 쓰는 것은 각 서비스 이용약관 위반 소지가 있으니, 회사 환경에서는 한 번 확인하는 편이 안전합니다.

한국에서 쓸 때 걸리는 현실적 지점도 둘 있습니다. Z.AI Coding Plan은 분기 결제(분기당 $30–80), Moonshot Kimi는 위안화 결제라 CCS가 결제까지 해결해주지는 않습니다. 또 이 도구가 weekly cap 회피 수단으로 광고되지만, Anthropic 입장에서 "한 사람이 멀티 계정으로 cap을 돌파"하는 패턴이 쌓이면 정책이 다시 조여질 수 있습니다. 도구의 책임은 아니어도 시장 동학은 의식해 둘 만합니다.

한국 커뮤니티 반응

CCS 자체에 대한 긱뉴스(news.hada.io) 단일 글은 (작성 시점 기준) 아직 없지만, CCS 같은 도구가 왜 등장했는지를 보여주는 신호는 긱뉴스에 풍부합니다.

스레드(Threads) 쪽에서는 한국어 사용자들이 비슷한 도구를 다양하게 시도 중이라는 점이 눈에 띕니다. vibe proxy로 Claude Code에 AntiGravity와 GLM을 라이드 매핑하거나, oh-my-opencode(★9K)가 Kimi K2.5와 GLM 5를 메인 오케스트레이터로 튜닝하거나, free-claude-code가 NVIDIA NIM 무료 API로 Claude Code를 무료 운영하는 식입니다. 한국 개발자들이 프록시와 멀티 프로바이더라는 패턴 자체에 적극적으로 베팅하고 있다는 의미이고, CCS는 그 흐름에서 가장 잘 정리된 도구 중 하나입니다.

대안 비교

CCS만 있는 게 아닙니다. 같은 영역에 여러 도구가 경쟁 중입니다.

도구강점약점
CCS (kaitranntt)★2.2kOAuth 프록시 + GUI 대시보드 + 활발한 업데이트추상화 두꺼움, 의존성 복잡
claude-code-router (musistudio)★33.2k시나리오 라우팅(background/think/longContext), 토큰 임계값 자동 전환, JS 플러그인OAuth 미지원
cc-switch (farion1231)(데스크톱)데스크톱 GUI, Claude Code/Codex/OpenCode/Gemini 통합CLI 친화도 낮음
claude-code-switch (foreveryh)(소규모)미니멀, 한 명령으로 Anthropic 모델만 전환Anthropic 외 미지원
OpenCode (opencode.ai)(별도 트리)75+ 프로바이더, 완전 오픈, Claude Code 호환 인터페이스자체 CLI 라 Claude Code 그대로는 아님
DIY (.zshrc 두 줄)의존성 없음, 가장 투명함프로필 3개 넘어가면 관리 불가

API 키 기반 프로바이더가 2개 이내이고 라우팅이 필요하다면 claude-code-router가 어울립니다. 별 수도 가장 많고 시나리오 라우팅이 가장 강합니다. OAuth 프로바이더를 포함한 멀티 계정이라면 이 영역에서 거의 유일한 CCS가 맞습니다. 데스크톱 친화성과 멀티 툴 통합 GUI를 원한다면 cc-switch, Anthropic만 쓰면서 단순함을 우선한다면 DIY 또는 claude-code-switch를 고르면 됩니다.

시나리오: 회사 Team + Enterprise 두 계정만 쓴다면

CCS가 풀려는 문제가 프로바이더 다양화와 OAuth 우회라면, Anthropic만 쓰면서 회사 계정 두 개(Team + Enterprise)만 격리하는 시나리오에는 정확히 그 두 가치가 빠져 있습니다. 이때는 도구 선택이 달라집니다.

이 시나리오의 본질은 두 ANTHROPIC 계정 사이를 빠르고 정확하게 전환하는 한 가지로 줄어듭니다. 다른 프로바이더도, 시나리오 라우팅도, 로컬 모델도 무관합니다.

이 경우라면 direnv가 첫 손에 꼽힙니다. .envrcexport ANTHROPIC_API_KEY=... 한 줄을 두 회사 디렉토리에 각각 두면 cd만으로 키가 자동 로드됩니다. 이 시나리오에서 가장 큰 리스크는 한 회사 코드에 다른 회사 키가 실수로 흘러가는 사고인데, 디렉토리 경계로 그걸 막아주는 게 direnv의 장점입니다.

더 단순하게 가려면 ~/.zshrc에 alias 두 줄이면 됩니다.

alias cct='ANTHROPIC_AUTH_TOKEN=$TEAM_TOKEN claude'
alias cce='ANTHROPIC_AUTH_TOKEN=$ENT_TOKEN claude'

디렉토리에 묶이지 않아 빠른 대신, 지금 어느 회사 컨텍스트인지는 사람이 기억해야 합니다. Anthropic 전용 미니멀 스위처인 claude-code-switch도 CCS보다 가볍고 이 시나리오에 정확히 맞습니다. CCS를 굳이 쓴다면 멀티 계정 모드만(ccs auth create team / ccs auth create ent) 떼어 쓰는 것도 동작은 하지만, 프로바이더 다양화, 프록시, OAuth 같은 95% 기능을 놀리면서 의존성과 복잡도는 그대로 짊어지게 됩니다. 나중에 GLM 같은 외부 프로바이더로 확장할 가능성이 있을 때만 미리 깔아두는 의미가 있습니다.

Enterprise가 SSO를 강제하면 단순 API 키 토글로는 안 되고 OAuth 로그인 사이클이 필요합니다. 이때는 direnv만으로 부족해 CCS의 OAuth 프로필 분리나 Anthropic Console의 organization 스위처를 써야 하는데, SSO 강제에 잦은 전환까지 겹친 경우가 사실상 CCS를 정당화하는 거의 유일한 회사 시나리오입니다. 정리하면 API 키 기반 두 계정에는 direnv로 충분하고, SSO 강제에 잦은 전환이 겹칠 때만 CCS가 값을 하며, 그 밖에는 대체로 과한 도구입니다.

설정/대화는 공유하면서 계정만 분리하고 싶다면

같은 시나리오의 변종입니다. 두 회사 계정을 전환은 하되 슬래시 커맨드, MCP, hooks, CLAUDE.md/대화 히스토리는 한 군데에서 공유하고 싶은 경우인데, 이때는 정답이 다릅니다.

먼저 Claude Code가 무엇을 어디 두는지 알면 답이 나옵니다.

위치무엇계정 종속?
~/.claude/settings.json사용자 설정 (theme, env, hooks 트리거 등)무관
~/.claude/commands/, agents/, skills/커스텀 슬래시 커맨드, 에이전트, 스킬무관
~/.claude/CLAUDE.md글로벌 메모리무관
~/.claude/projects/<path-hash>/*.jsonl대화 히스토리 (프로젝트 경로 기준)무관
OS 키체인 / ANTHROPIC_AUTH_TOKEN인증 토큰종속

핵심은 계정 종속이 토큰 한 가지뿐이라는 점입니다. 토큰만 갈아끼우면 나머지는 자동으로 공유되므로, 답은 direnv로 토큰만 스위칭하는 것입니다. .envrcexport ANTHROPIC_AUTH_TOKEN=... 한 줄만 두고 ~/.claude/는 건드리지 않으면, 슬래시 커맨드, hooks, MCP, 대화가 전부 자동으로 공유됩니다. 반대로 ccs auth create work 같은 CCS의 isolated profile 모드는 정확히 이 공유를 분리하는 기능이라 피해야 합니다. CCS 자체는 써도 되지만 isolation 옵션은 끄고 토큰만 바꾸는 모드로만 씁니다. 여러 머신 사이에서도 공유하고 싶으면 ~/.claude/를 git으로 관리하는 dotfiles가 답인데, settings.json에 토큰이 평문으로 들어가지 않도록 .gitignoresettings.local.json을 등록해 두는 정도만 주의하면 됩니다.

다만 Enterprise 데이터 격리는 짚어둘 필요가 있습니다. Enterprise 플랜 약관에 대화 데이터를 다른 organization 환경과 섞지 못하게 하는 조항이 있을 수 있어서, PII, 고객 데이터, 비공개 코드를 다루는 프로젝트라면 ~/.claude/projects/를 공유하는 것이 회사 보안 정책 위반이 될 수 있습니다. 사내 보안팀에 한 번 확인하는 게 안전하고, 우려가 있으면 프로젝트 디렉토리 자체를 회사별로 분리해 자연히 다른 <path-hash>로 떨어뜨리는 패턴(예: ~/work/team/, ~/work/ent/ 두 루트)이 가장 깔끔합니다.

SSO 강제 환경, 한국 회사 현실에 더 가까운 케이스

위 두 서브섹션은 API 키를 사용자가 직접 발급받을 수 있다는 전제 위에 있습니다. 그런데 국내 대기업, 금융권, 보안 민감 조직 대부분은 그 전제 자체가 깨져 있습니다. Team/Enterprise 플랜에서 SSO를 강제하면 사용자 API 키 발급이 비활성화되고, 인증은 OAuth + IdP MFA 사이클로만 가능합니다. 이 환경에서는 도구 선택이 다시 한 번 달라집니다.

먼저 구조를 짚으면 다음과 같습니다.

  1. claude login → 브라우저 → 회사 IdP 로그인 (SSO + MFA) → Anthropic Console → 토큰 발급
  2. 토큰은 OS 키체인 또는 ~/.claude/.credentials.json에 저장
  3. 토큰은 보통 시간 제한이 있고 (몇 시간~며칠), 만료되면 다시 SSO 사이클
  4. 한 머신의 한 OS 유저는 동시에 한 organization의 세션만 보유 가능

즉 SSO 강제 환경에서 두 회사 organization 사이를 전환하려면 결국 logout, 다른 org로 login, 다시 작업 사이클을 돌아야 합니다. IdP가 매번 MFA를 요구하니 도구가 우회할 수 있는 영역이 아닙니다.

실제로 동작하는 패턴은 현실적 운영 순으로 정리할 수 있습니다. 가장 흔한 사례이자 가장 깔끔한 결론은 organization 하나만 쓰는 것입니다. 회사가 Enterprise 하나를 표준화했고 Team이 레거시/POC였다면 둘 다 유지할 이유가 없으니 회사 IT에 정리를 요청하는 편이 낫습니다. 두 organization이 같은 Anthropic Workspace 안의 서로 다른 workspace라면, admin이 workspace를 분리해 두는 것만으로 organization 전환 없이 claude /switch-workspace 식의 in-session 전환이 가능합니다. 이게 되는지부터 회사 IT에 확인하면 가장 매끄럽습니다.

두 organization이 진짜 별개라면 방법이 무거워집니다. 같은 노트북에 OS 계정 두 개를 만들어 각각 SSO 로그인 상태를 독립으로 두면 키체인과 ~/.claude/가 자연히 분리되지만, 설정/대화 공유가 깨져서 dotfiles를 두 OS 유저 사이에 공유하는 추가 작업이 필요합니다. CCS의 OAuth 프로필 분리(ccs auth create work-team / ccs auth create work-ent)는 이론상 이 시나리오에 정확히 맞지만, 회사 IdP가 OAuth 흐름에 거는 추가 검증(IP 화이트리스트, 디바이스 트러스트, MFA 빈도)에 따라 CCS의 토큰 캐시 방식이 IdP 정책과 충돌할 수 있어 시도 전에는 알기 어렵습니다. 사내 보안팀과 사전 협의를 권합니다. 가장 보수적인 선택은 두 회사용 Dev Container나 VM을 따로 띄워 각각 한 organization으로 로그인하는 것인데, 무겁고 호스트 키체인을 못 쓴다는 대가가 따릅니다.

반대로 SSO 환경에서 동작하지 않는 패턴은 다음과 같습니다.

  • direnv + ANTHROPIC_AUTH_TOKEN 토글: 사용자 API 키 자체가 없으니 토글할 대상이 없습니다.
  • 셸 alias로 토큰 swap: 같은 이유로 동작하지 않습니다.
  • "session 토큰을 추출해서 재사용": 만료가 짧고 IdP가 디바이스 핑거프린팅으로 거부할 가능성이 있습니다. ToS의 회색지대이기도 합니다.
  • 두 organization의 ~/.claude/.credentials.json을 수동으로 백업/복구: 동작은 할 수 있지만 MFA 빈도 정책을 우회하는 모양새가 되어 정책 위반 우려가 있습니다.

한국 회사 현실에 맞는 결론은, SSO 강제면 organization 전환 자체가 비용이 크다는 사실을 인정하고 두 organization을 동시에 자주 오가지 않는 워크플로우를 설계하는 쪽입니다. 오전엔 Team org에서 한 덩어리, 오후엔 Enterprise org에서 다른 덩어리를 처리하는 식의 시간 분할이, 전환을 도구로 매끄럽게 만드는 것보다 운영 부담이 훨씬 적습니다. 도구는 그 다음 문제입니다.

실전 팁

블로그, GitHub 이슈, 도구 README의 패턴을 종합해 한국 환경에서 바로 쓸 만한 것만 모았습니다.

가장 큰 가치는 라우팅 자동화보다 자기 워크플로우에 맞는 모델 분리에서 나옵니다. 설계/아키텍처는 ccs(Claude Sonnet), 보일러플레이트/테스트 정리는 ccs glm, 1M 컨텍스트가 필요한 대규모 리뷰는 ccs kimi, 민감 코드는 로컬 ccs ollama 정도가 무난한 시작점입니다. Claude Sonnet 4.6도 1M 컨텍스트를 지원하지만 토큰 값이 비싸서, 큰 monorepo 전체 리뷰는 ccs kimi 한 번이 훨씬 싸게 먹힙니다. 새 prompt가 안정적인지 확인하는 단계라면 첫 5~10번은 무료인 ccs ollama로 돌려보다 만족스러우면 Claude로 옮기면 됩니다.

회사/개인 계정은 무조건 격리하는 편이 좋습니다(ccs auth create work / ccs auth create personal). weekly cap이 섞이지 않아 둘 다 마음 편히 쓸 수 있습니다. 여기에 더해 Claude Max의 weekly cap이 60% 이상 깎였으면 남은 작업의 70% 정도는 GLM으로 돌리는 룰을 정해두면, cap이 갑자기 끊기는 사고가 사라집니다.

라우팅은 단순하게 시작하는 편이 낫습니다. proxy.routing 시나리오 라우팅을 처음부터 복잡하게 짜면 요청이 어디로 갔는지 못 따라가니, 모델을 직접 지정해 일주일 운용하고 패턴이 보이면 그때 룰을 추가합니다. 기본 CLIProxyAPI가 새 프로바이더를 못 잡으면 옵트인 fork(CLIProxyAPIPlus) 활성화부터 시도해 볼 만합니다.

매번 eval 치기 싫으면 ~/.zshrc에 한 줄을 넣어 영구화합니다.

[[ -s "$HOME/.ccs/init.zsh" ]] && source "$HOME/.ccs/init.zsh"

멀티 계정 사고를 디버깅할 때는 CCS_LOG=debug ccs glm "..." 식으로 한 번 돌려보면 어느 base URL로 갔는지, 어느 토큰을 썼는지 정확히 보이니 로그를 켜두는 게 필수입니다.

한 줄로

CCS는 Claude Code가 환경변수 두 개로 정의되는 도구라는 사실을 그대로 받아들인 도구입니다. 그 두 변수를 프로파일이라는 단위로 정리하고, OAuth 우회와 다중 계정까지 한 명령 표면 위로 올려놓았습니다. weekly cap 시대에 한 프로바이더에만 의지하는 게 부담스러워진 사용자에게 기본기에 가까운 도구가 됐고, 같은 영역에 claude-code-router 같은 더 큰 별 수의 경쟁자도 있습니다. 자기 워크플로우가 옮겨 다닐 곳이 셋 이상인지부터 보고, 그다음에 도구를 고르는 순서가 맞아요.


→ 다음 편: Claude Code Switcher (CCS) 2편: 디렉토리, 설정, 라우팅의 실제 — 사용자 머신의 ~/.ccs/를 직접 들여다보며 instance / shared 격리 모델, config.yaml의 9개 영역, 로컬 프록시의 5단계 변환을 풀어 봅니다.

참고

CCS 설정과 라우팅

· 약 14분

1편의 잔상

Claude Code Switcher (CCS) — 프로바이더 전환, 멀티 계정, 로컬 모델까지 한 명령으로표면의 글이었어요. CCS가 등장했는지, 무엇을 풀고 있는지, 누구에게 언제 맞고 안 맞는지를 다뤘어요. 그 글의 한 단락을 그대로 옮기면 이렇습니다.

CCS는 Claude Code가 환경변수 두 개로 정의되는 도구라는 사실을 그대로 받아들인 도구다. 그 두 변수를 프로파일 이라는 단위로 정리하고, OAuth 우회와 다중 계정까지 한 명령 표면 위로 올려놓았다.

2편은 그 표면 아래를 다룹니다. ~/.ccs/ 한 디렉토리에 무엇이 어떻게 사는지, config.yaml의 어느 키가 무엇을 결정하는지, instance와 shared가 어떻게 갈리는지, 로컬 프록시는 정확히 어떤 변환을 거치는지 살펴봅니다. 1편에서 표 한 줄로만 끝낸 부분을 시스템의 그림으로 풀어 봅니다.

이 글의 모든 디렉토리/키 이름은 사용자 머신의 실제 CCS 설치를 직접 들여다본 결과입니다. 토큰/세션 같은 민감한 값은 보지 않고 구조만 인용합니다.

~/.ccs/ 한 장 지도

CCS가 머신에 자리 잡으면 홈 디렉토리에 ~/.ccs/ 한 폴더가 생깁니다. 그 안의 트리가 사실상 CCS의 운영 모델 전부인데, 진짜 그림은 심볼릭 링크 체인입니다.

~/.claude/ ← 사용자의 기존 Claude Code 글로벌 (canonical)
├── settings.json
├── commands/, skills/, agents/, plugins/
└── projects/

~/.ccs/ ← CCS 영역
├── config.yaml ← 메인 설정 (YAML, 11K 정도)
├── .session-secret ← 세션 비밀 (64B, 절대 공유 금지)
├── .claude/ ← CCS *번들* (ccs.md, ccs-delegation 스킬 등 자체 주입)
├── cliproxy/bin/ ← CLIProxyAPI 바이너리 (자동 다운로드)
├── cache/, completions/, logs/

├── shared/ ← 사용자 정의 공유 영역
│ ├── settings.json → ~/.claude/settings.json (symlink)
│ ├── commands → ~/.claude/commands (symlink)
│ ├── skills → ~/.claude/skills (symlink)
│ ├── agents → ~/.claude/agents (symlink)
│ ├── plugins → ~/.claude/plugins (symlink)
│ └── context-groups/default/projects/ (실 디렉토리)

└── instances/ ← per-account 격리 영역
├── team/ ← 회사 Team account
│ ├── settings.json → ~/.ccs/shared/settings.json (symlink)
│ ├── commands → ~/.ccs/shared/commands (symlink)
│ ├── skills → ~/.ccs/shared/skills (symlink)
│ ├── agents → ~/.ccs/shared/agents (symlink)
│ ├── projects → ~/.ccs/shared/context-groups/default/projects/ (symlink)
│ ├── plugins/, .anthropic/, sessions/, session-env/ ← 실 디렉토리
│ ├── todos/, file-history/, shell-snapshots/, logs/ ← 실
│ ├── backups/, cache/, debug/, image-cache/, paste-cache/, plans/ ← 실
│ └── .claude.json, history.jsonl, .session-stats.json,
│ mcp-needs-auth-cache.json, policy-limits.json, remote-settings.json
└── enterprise/ ← 회사 Enterprise account (구조 동일)

여기서 눈여겨볼 것은 두 가지입니다. 하나는 2단계 symlink 체인입니다. instances/team/settings.json을 따라가면 ~/.ccs/shared/settings.json으로, 다시 ~/.claude/settings.json으로 이어집니다. 사용자의 기존 Claude Code 글로벌 설정이 모든 인스턴스에 자동 반영된다는 뜻이며, 한 번 만든 슬래시 커맨드, 스킬, MCP가 team과 enterprise 양쪽에서 살아 있는 이유도 여기에 있습니다.

다른 하나는 projects/만 체인이 한 단계라는 점입니다. 대화 히스토리는 instances/team/projects에서 ~/.ccs/shared/context-groups/default/projects/(실 디렉토리)까지만 가고 ~/.claude/projects/까지는 이어지지 않습니다. CCS가 자기 context-group 단위로 대화를 모아 사용자의 평소 Claude Code 대화와 자연히 분리해 두는 것으로, 회사 대화가 개인용 Claude Code에 섞이지 않도록 의도한 끊음입니다.

이 2단계 체인이 이중 구조라고 표현했던 것의 실제 메커니즘이며, 1편의 "공유 vs 분리"가 단일 축이 아니라 어느 단위까지 체인을 잇느냐의 문제였다는 뜻입니다. 5~6절에서 이 단위들을 풀어 봅니다.

config.yaml의 9개 영역

설정의 거의 전부는 ~/.ccs/config.yaml 한 파일에 모입니다. JSON이 아니라 YAML인 점이 의외인데, 사람이 손으로 읽고 고치기 좋은 쪽을 택한 것으로 보입니다.

키 구조만 추리면 9개 영역으로 나뉩니다.

version: # 메타
default: # 기본 프로파일

accounts: # *instance 단위* 메타
team:
context_mode: # isolated | shared
context_group: # shared 일 때 그룹 이름
continuity_mode: # 추가 공유 정책
enterprise:
...

profiles: # 프로바이더 프로파일 (glm, kimi, ollama, ...)

cliproxy: # OAuth 프록시 백엔드 설정
backend:
oauth_accounts:
providers:
routing:
strategy: # round-robin / fill-first
session_affinity:
session_affinity_ttl:

proxy: # 로컬 OpenAI-호환 프록시
profile_ports: # 프로파일별 포트 매핑
routing:
longContextThreshold:

cliproxy_server: # 원격/로컬 CLIProxy 서버
remote: { enabled, host, protocol, auth_token }
fallback: { enabled, auto_start }
local: { port, auto_start }

logging: # 로그 회전·보관
preferences: # theme, telemetry, auto_update

websearch: # ★ 8개 fallback 프로바이더
providers:
exa, tavily, brave, searxng, duckduckgo, gemini, opencode, grok

# --- 통합 (Claude Code 외부 도구들) ---
copilot: # GitHub Copilot 라우팅
cursor: # Cursor 통합 (ghost_mode 포함)
channels: # Telegram/Discord/iMessage
thinking: # opus/sonnet/haiku 별 thinking 기본값
global_env: # DISABLE_TELEMETRY 등 전역 env 자동 주입

이 9개를 한 번 훑으면 CCS가 단순 스위처가 아니라 Claude Code 주변의 운영 표면 전체를 흡수해 가고 있다는 인상이 분명해집니다. 12절에서 이 중 안 알려진 부분을 따로 추립니다.

Instance 시스템, symlink와 실 상태의 갈림

instances/<name>/ 디렉토리는 언뜻 보면 Claude Code의 모든 상태를 담은 통 같지만, ls -la로 열어 보면 절반은 shared/로의 symlink이고 나머지 절반만 인스턴스별 실 상태입니다. 둘을 갈라 두면 멘탈 모델이 명확해집니다.

먼저 모든 인스턴스가 공유하는 symlink 항목입니다.

항목가는 곳
settings.json~/.ccs/shared/settings.json~/.claude/settings.json
commands/~/.ccs/shared/commands~/.claude/commands/
skills/~/.ccs/shared/skills~/.claude/skills/
agents/~/.ccs/shared/agents~/.claude/agents/
projects/~/.ccs/shared/context-groups/default/projects/ (한 단계만)

settings.json의 최상위 키 9개는 체인을 따라가 본 결과 다음과 같습니다. 사용자의 기존 ~/.claude/settings.json 스키마 그대로입니다.

alwaysThinkingEnabled
enabledPlugins
env
extraKnownMarketplaces
hooks
permissions
skipAutoPermissionPrompt
skipDangerousModePermissionPrompt
statusLine

다음은 인스턴스별로 격리된 실 상태입니다.

분류항목
인증 (절대 안 섞임).anthropic/ (OAuth), .claude.json (사용자 메타)
세션/실행sessions/, session-env/, shell-snapshots/, history.jsonl
작업 상태todos/, file-history/, plans/ (team만), backups/
로깅/캐시logs/, cache/, debug/, image-cache/, paste-cache/
플러그인plugins/ — 인스턴스별 (claude-hud 같은 설치)
메타/정책policy-limits.json, remote-settings.json, mcp-needs-auth-cache.json, .session-stats.json
외부 통합.omc/ (oh-my-claudecode 상태)

한 가지 눈에 띄는 사실은 이 글의 plan 파일이 instances/team/plans/ccs-hashed-llama.md에 있다는 점입니다(이 항목은 real이고 symlink가 아닙니다). 이 글을 쓰는 동안 사용자의 Claude Code 세션이 team 인스턴스에서 돌고 있었다는 뜻입니다. CCS는 보이지 않게 CLAUDE_CONFIG_DIR 같은 환경변수로 인스턴스 컨텍스트를 끼워 넣습니다.

enterprise 인스턴스는 team보다 가볍게 비어 있는 상태였는데, 활성 사용 중인지 아닌지가 디렉토리 충실도에 그대로 드러납니다. CCS는 인스턴스를 만들 때 symlink와 빈 디렉토리만 만들어 두고, 실제로 사용해야 실 데이터가 채워지는 lazy 모델입니다.

shared/와 context-groups로 1편의 빈칸 메우기

1편의 "설정/대화는 공유하면서 계정만 분리하고 싶다면" 단락의 진짜 답이 여기 있습니다. CCS는 이 시나리오를 shared/라는 별도 영역과 context_mode/context_group 설정으로 정식 지원합니다.

config.yamlaccounts: 섹션:

accounts:
team:
context_mode: isolated # 또는 shared
context_group: default # shared 일 때만 의미
continuity_mode: deeper # 또는 default
enterprise:
context_mode: shared
context_group: default
continuity_mode: deeper

세 키의 역할:

context_mode: isolated | shared는 인스턴스가 자기 디렉토리에만 사는지, 같은 그룹의 다른 인스턴스와 공유하는지를 정합니다. context_groupshared일 때 쓸 그룹 이름이며, ~/.ccs/shared/context-groups/<group>/가 그 그룹의 공유 데이터 자리입니다. continuity_mode: deeper는 공유의 깊이를 정하며, deeper는 더 많은 디렉토리를 공유 대상에 포함합니다.

이 셋을 조합해 보면 1편에서 "공유 vs 분리"라고 단순화한 구도가 사실 세 축의 조합임이 드러납니다.

무엇이 공유 가능하고 무엇이 항상 격리되는가

CCS의 공식 docs는 이 부분을 명시해 두지 않습니다.

CCS only shares workspace context paths (project/session context files). It does not merge or copy authentication credentials between accounts.

docs/session-sharing-technical-analysis.md

요약 표:

분류대상동작
shared (조건부)session-env/shared + deeper 일 때 그룹 안에서 공유
shared (조건부)file-history/동일
shared (조건부)shell-snapshots/동일
shared (조건부)todos/동일
항상 격리.anthropic/ (OAuth)인증은 어떤 모드에서도 인스턴스별
항상 격리인증 토큰 / 자격증명동일
수동 공유commands/, skills/, agents/, plugins/shared/<dir>/에 두면 모든 인스턴스가 사용

인증은 어떤 경우에도 섞이지 않습니다. 한 인스턴스의 OAuth 토큰이 실수로 다른 인스턴스의 요청에 흘러갈 일이 구조적으로 없으므로, 회사 환경에서 가장 큰 사고 가능성을 시스템 자체가 막아 둡니다.

ccs -r (resume)는 현재 활성 lane만 이어가고, ccs <account> -r은 그 인스턴스의 lane만 이어갑니다. 두 인스턴스 모두 다른 continuity 인벤토리를 가질 수 있다는 점을 의식하고 운영해야 합니다.

4가지 진입점: Target Adapter System

ccs가 Claude Code만 호출하는 게 아닙니다. CCS는 runtime 자체를 바꾸는 4가지 바이너리를 노출합니다.

바이너리runtime
ccsClaude Code (기본)
ccsd / ccs-droidFactory Droid
ccsx / ccs-codexCodex CLI (네이티브)
ccsxpCodex CLI + CLIProxy 프로바이더 오버라이드

내부적으로는 각 바이너리가 CCS_INTERNAL_ENTRY_TARGET 환경변수를 세팅한 후 target resolver에 위임합니다. resolver의 우선순위:

  1. CLI 플래그 (--target)
  2. 진입 바이너리 자체
  3. argv[0] 이름 검출
  4. 프로파일별 config
  5. 기본값

이 추상화 덕분에 "Claude Code로 GLM 돌리기"와 "Droid로 GLM 돌리기"가 같은 명령 표면 위에서 가능합니다(ccs glm vs ccs --target droid glm). Codex의 경우 자기 ~/.codex/ 상태를 별도 보존하려고 환경변수만 임시로 덮어쓰는 식으로 신경을 더 씁니다.

로컬 프록시 (127.0.0.1:포트)의 5단계 흐름

ccs glm 같은 OpenAI-호환 프로바이더 명령이 들어오면 다음 5단계를 거칩니다(docs/openai-compatible-providers.md 정리).

  1. 127.0.0.1에서 그 프로파일의 로컬 포트 바인딩 (포트는 proxy.profile_ports에서 할당)
  2. Claude Code가 보내는 Anthropic 형식 /v1/messages 요청 수신
  3. OpenAI chat-completions 형식으로 변환
  4. 업스트림 프로바이더로 포워드
  5. 스트리밍 응답을 다시 Anthropic SSE로 역변환해 Claude Code에 돌려줌

정리하면 Claude Code는 자기가 Anthropic과 이야기하는 줄 알고, 변환은 프록시 한 점에서만 일어나므로 디버깅 지점도 한 곳으로 모입니다.

Anthropic-호환 엔드포인트는 프록시를 우회합니다. https://api.anthropic.com이나 Z.AI의 /api/anthropic으로 가는 요청은 변환이 필요 없어 그냥 직통하고, 이 분기 덕분에 GLM 사용은 추상화 비용이 거의 0입니다.

CLIProxyAPI 서버 자체는 기본적으로 port 8317에서 동작하는데, cliproxy_server.local.port로 바꿀 수 있고 remote.host를 켜면 다른 머신의 CLIProxy를 쓸 수도 있습니다.

시나리오 라우팅 4종

CCS가 수동으로 프로바이더를 명시하지 않아도 자동으로 라우팅하는 4가지 시나리오가 있습니다.

시나리오트리거
background요청이 Haiku를 포함 (가벼운 백그라운드 작업)
thinkAnthropic extended thinking 활성화
longContext토큰 추정치가 임계값 초과 (proxy.routing.longContextThreshold)
webSearchweb_search 툴 호출

config.yamlproxy.routing.longContextThreshold 키가 실제로 있어 임계값을 손으로 조절할 수 있습니다. 자동화의 깊이는 워크로드에 따라 잘 맞기도 하고 헷갈리기도 하므로, 처음부터 라우팅 룰을 복잡하게 짜기보다 한 주 동안 단순하게 운영한 뒤 패턴을 보고 추가하는 편이 낫습니다(1편 팁 7절 참조).

CLIProxy 서브시스템, OAuth 프로바이더의 자리

API 키가 없는 OAuth 기반 프로바이더(Gemini, GitHub Copilot, AWS Kiro 등)는 별도 서브시스템이 책임집니다. ~/.ccs/cliproxy/bin/에 자동 다운로드되는 CLIProxyAPI 바이너리가 그 핵심입니다.

이 서브시스템의 Account manager는 OAuth 토큰의 lifecycle(발급, 갱신, 만료)을 관리하고, Quota manager와 Quota fetcher는 각각 프로바이더별 quota 추적과 자동 failover, 사용량 실시간 동기화를 맡습니다. Auth handler는 Anthropic, Gemini, Copilot, Kiro 등 각 프로바이더의 OAuth 흐름을 처리합니다. Model catalogs에는 프로바이더별 모델 목록과 compatibility 가드가 있으며, codex-plan-compatibility.ts 같은 파일이 plan별로 맞지 않는 모델 조합을 막아 줍니다. Hybrid quota strategy는 round-robin 또는 fill-first(cliproxy.routing.strategy) 방식으로 같은 프로바이더의 여러 OAuth 계정을 어떻게 분산할지 정합니다.

config.yamlcliproxy.oauth_accounts가 등록된 OAuth 계정 목록을 담는데, 이 부분은 토큰을 포함하므로 git에 올리면 안 되는 영역입니다. 다음 11절에서 정리합니다.

파일 관리 실전: symlink가 dotfiles의 답입니다

2절의 symlink 체인을 보고 나면 자연스레 이런 질문이 나옵니다. 내 CCS 환경을 다른 머신에 어떻게 옮기는지, ~/.ccs/를 통째로 git에 넣어도 되는지 궁금해집니다. ~/.ccs/를 versioning하지 않고 ~/.claude/를 versioning해 CCS가 그쪽을 끌어들이게 하면 됩니다.

멘탈 모델: ~/.claude/가 source of truth

체인의 시작점은 ~/.claude/입니다. CCS의 shared/와 instance들은 그쪽으로 가는 포인터일 뿐입니다. 따라서 dotfiles의 운용 원칙은 하나로 압축됩니다. ~/.claude/를 git으로 관리해 두면, 새 머신에 CCS를 설치하더라도 shared/만 같은 symlink로 다시 걸어 모든 인스턴스가 같은 환경을 보게 됩니다.

git에 올리기 좋은 자산 (~/.claude/ 쪽)

~/.claude/commands/, ~/.claude/skills/, ~/.claude/agents/에는 각각 슬래시 커맨드, 스킬, 에이전트 정의가 들어가므로 git에 올리기 좋습니다. 글로벌 메모리인 ~/.claude/CLAUDE.md도 관리 대상입니다. ~/.claude/settings.json은 토큰을 직접 적어 두지 않은 경우에만 포함해야 하며, env 필드에 비밀이 있다면 settings.local.json 패턴으로 분리합니다. 실 디렉토리인 ~/.ccs/shared/context-groups/default/에서는 projects/를 빼고 CCS의 그룹 메타만 versioning하는 식으로 관리합니다.

~/.ccs/.claude/ (CCS 번들)은 npm 패키지 일부라 install 시 자동으로 따라옵니다. 직접 versioning 할 필요 없습니다.

절대 git 금지 (자격증명, 세션, 트랜스크립트)

~/.ccs/config.yaml에는 OAuth 토큰/refresh_token이 있으므로 머신별로 보관하고, 세션 비밀인 ~/.ccs/.session-secret도 제외합니다. ~/.ccs/instances/*/.anthropic/의 Anthropic OAuth와 계정 식별자를 포함한 ~/.ccs/instances/*/.claude.json도 올리면 안 됩니다.

~/.ccs/instances/*/sessions/, session-env/에는 세션과 세션별 env가 있어 토큰 환경변수가 들어갈 가능성이 있습니다. ~/.ccs/instances/*/history.jsonl, file-history/에는 명령과 파일 history가 남아 회사 코드의 흔적이 섞일 수 있습니다. 대화 히스토리 본체~/.ccs/shared/context-groups/*/projects/도 PII나 회사 코드를 포함할 가능성이 있으므로 제외합니다. 바이너리와 휘발성 데이터가 담긴 ~/.ccs/cliproxy/, cache/, logs/, 평소 Claude Code 대화 히스토리인 ~/.claude/projects/도 git에 올리지 않습니다.

새 머신 부트스트랩 4단계

dotfiles가 ~/.claude/만 들고 있다고 할 때 새 머신에서 같은 환경을 만들려면:

  1. dotfiles를 clone해 ~/.claude/를 채웁니다.
  2. npm install -g @kaitranntt/ccs로 CCS를 설치합니다.
  3. ccs config로 초기 ~/.ccs/ 골격을 생성합니다.
  4. symlink를 재구성합니다. ~/.ccs/shared/{commands,skills,agents,plugins,settings.json}~/.claude/의 동명 항목으로 link하며, 한 줄 스크립트로 자동화하기를 권장합니다.
ln -sf ~/.claude/commands ~/.ccs/shared/commands
ln -sf ~/.claude/skills ~/.ccs/shared/skills
ln -sf ~/.claude/agents ~/.ccs/shared/agents
ln -sf ~/.claude/plugins ~/.ccs/shared/plugins
ln -sf ~/.claude/settings.json ~/.ccs/shared/settings.json

이 다섯 줄로 새 머신에서도 1편의 instance들이 평소 환경을 그대로 봅니다. OAuth 인증만 각 인스턴스에서 새로 하면 끝입니다.

격리의 운영 이점

한 인스턴스가 망가져도(plugins/ 깨짐, policy-limits.json 누락 등) 다른 인스턴스는 그대로이므로 단일 장애점이 줄어드는 부수 효과가 있습니다. 인스턴스 단위 백업에는 tar czf team-backup.tgz -C ~/.ccs/instances team을 씁니다. symlink는 그대로 보존하며, 옵션 -h를 추가하면 링크가 가리키는 파일까지 따라가 archive합니다. 복원할 때 같은 dotfiles 환경을 가정하므로 보통은 symlink를 그대로 두는 편이 의미 있습니다.

config.yaml의 비공식 영역들

CCS의 README가 강조하지 않지만 config.yaml 키만 봐도 드러나는, 단순 스위처라기엔 풍부한 통합이 여럿입니다.

가장 눈에 띄는 것은 WebSearch fallback입니다. websearch.providers 아래에 exa, tavily, brave, searxng, duckduckgo, gemini, opencode, grok 여덟 곳이 등록돼 있어, Claude Code의 WebSearch가 Anthropic 밖 8개 프로바이더로 넘어갈 수 있습니다. docs/websearch.md가 별도 문서로 있을 만큼 일급 통합입니다. 외부 에디터/툴 통합도 넓어서, copilot.account_type, rate_limit, model로 GitHub Copilot을 프로바이더 한 슬롯처럼 다루고 cursor.ghost_mode/cursor.port로 Cursor의 ghost-mode까지 건드립니다. channels.selected, channels.unattended를 통해 Telegram, Discord, iMessage로 Claude에게 일을 시키는 통합도 있습니다(1편의 free-claude-code 텔레그램 봇 사례와 같은 결입니다).

세밀한 정책 키도 있습니다. thinking.tier_defaults는 opus, sonnet, haiku별로 thinking 기본 모드를 다르게 잡게 해주고(Opus는 항상 deep thinking, Haiku는 끄는 식), global_env.envDISABLE_BUG_COMMAND, DISABLE_ERROR_REPORTING, DISABLE_TELEMETRY는 보안, 프라이버시에 민감한 회사 환경에서 텔레메트리를 자동으로 꺼줍니다. cliproxy.routing.session_affinitysession_affinity_ttl은 같은 세션을 같은 OAuth 계정으로 sticky하게 묶는 정책입니다. 이런 영역들은 CCS가 단순 스위처에서 운영 플랫폼으로 진화 중이라는 신호입니다.

한 줄로

1편이 CCS의 표면이었다면 2편은 안쪽입니다. 안쪽을 보고 나면 "프로파일로 옮겨 다닌다"는 1편의 단순한 설명이 사실은 instance 격리, shared 공유, OAuth 분리, OpenAI와 Anthropic 사이의 변환, 시나리오 라우팅, 여러 통합 지점이라는 축들로 짜여 있다는 게 보입니다. 추상화가 두껍다는 1편의 단점 평가는 그래서 정확하지만, 그 두께가 풀고 있는 문제도 같이 두껍다는 사실이 디렉토리 한 통에 적나라하게 드러납니다.

다음 편이 있다면 두 갈래 중 하나일 가능성이 커요. proxy.routing 시나리오를 실제 워크로드로 맞춰 보는 실측 운영 글이거나, CCS와 dotfiles, Anthropic Workspaces를 엮는 멀티-머신 계정 운영 글일 거예요. 어느 쪽이 먼저 나올지는 다음 글에서 정할게요.

참고

systemd 유닛 11종

· 약 10분

"유닛"이라는 단어가 묶고 있는 것

SysVinit → Upstart → systemd: 리눅스 init 세대 연표의 마지막 단락은 이렇게 끝났어요.

systemd는 init만이 아니라 logind, journald, networkd, resolved, timedated 등 시스템 영역의 여러 컴포넌트를 흡수해 갔다.

이 글에서는 그 한 줄을 펼쳐 봅니다. 시스템 영역에서 systemd가 흡수해 간 것 중 가장 가시적인 흔적은 흩어진 옛 도구를 **유닛(unit)**이라는 단일 언어로 묶은 모습입니다. cron, fstab, inetd, autofs, inotify, runlevel, cgroup은 저마다 다른 시대에 다른 사람이 다른 이유로 만든 도구지만, *.service, *.timer, *.mount처럼 같은 모양의 파일 안으로 모여들었습니다.

서버에 들어가서 systemctl --type=help 한 번 실행해 보면 출력이 짧습니다. 11줄.

service
socket
target
device
mount
automount
timer
swap
path
slice
scope

이 11종이 무엇을 흡수했는지 먼저 표로 보여준 다음, 한 종류씩 풀어봅니다.

유닛흡수한 옛 도구
.service/etc/init.d/* 셸 스크립트
.socketinetd / xinetd / launchd
.timercron / anacron / at
.mountfstab
.automountautofs
.pathinotify 사용처 (디렉토리 감시 데몬)
.targetrunlevel
.slicecgroup 트리의 그룹 이름
.scope외부에서 만든 프로세스를 cgroup 으로 묶는 래퍼
.deviceudev 와의 다리
.swapswapon

.service: 옛 init.d 스크립트의 자리

가장 익숙한 유닛입니다. 1편의 hello-web.service 9줄 예제를 다시 떠올리면 충분합니다.

[Unit]
Description=hello web server
After=network.target

[Service]
ExecStart=/usr/local/bin/hello-web --port 8080
Restart=on-failure

[Install]
WantedBy=multi-user.target

중요한 부분은 Type=의 6종입니다(man systemd.service).

Type언제 쓰나
simple기본값. ExecStart 가 즉시 메인 프로세스
execsimple + 자식 exec 까지 끝나야 활성화 처리
forking옛 데몬 스타일. fork 후 부모는 종료하고 자식이 데몬으로 동작
oneshot일회성 작업 (마이그레이션, 셸 스크립트 등)
notify자식이 sd_notify(3) 로 "준비됨" 통지
dbusDBus 이름 등록 시점에 활성화 처리
idlesimple 변형. 다른 잡이 콘솔 출력을 끝낼 때까지 대기

옛 init.d 시절의 가장 큰 골칫거리였던 "데몬이 fork한 자식을 init이 추적하지 못한다"는 문제는 cgroup으로 깔끔하게 해결됐습니다(1편의 3절 참조). PID 파일 위조도 더블 fork도 cgroup 트리에서 빠져나가지 못합니다. Restart=on-failure 한 줄이 monit/supervisord의 자리를 흡수한 것도 이 추적 덕분입니다.

.service 한 종류만 따로 깊게 다룬 글을 한 편 더 쓸 만큼 옵션이 많습니다. 이 글에서는 이 정도만 살펴보겠습니다.

.socket: inetd → xinetd → launchd → systemd, 21년의 계보

.socket이 가져온 모델을 흔히 "socket activation"이라 부릅니다. 처음 들으면 systemd의 발명 같지만 사실 40년짜리 계보의 마지막 단계입니다.

연도도구한 줄
1980년대 초inetd (4.3BSD)슈퍼 서버. 소켓을 listen하다가 연결이 들어오면 데몬을 fork
1990년대 후반xinetdinetd 를 보안성 강화로 대체 (Panagiotis Tsirigotis(파나기오티스 치리고티스))
2005-04-29launchdMac OS X 10.4 Tiger 도입. Dave Zarzycki(데이브 자지키) 설계. 데몬을 미리 안 띄우고 첫 연결로 깨운다
2010systemd socket activationlaunchd에서 영감을 받아 Linux로 도입

1편에서 "macOS launchd에서 영감을 받았다"는 한 줄로 끝낸 부분의 21년짜리 사연입니다. 공교롭게도 launchd의 데뷔일은 정확히 21년 전 오늘입니다.

systemd가 자식에게 listen 소켓을 어떻게 넘기는지는 sd_listen_fds(3) man page에 정확히 적혀 있습니다.

The first file descriptor may be found at file descriptor number 3 (i.e. SD_LISTEN_FDS_START), the remaining descriptors follow at 4, 5, 6, ...

세 개의 환경변수가 따라옵니다. $LISTEN_PID는 이 fd들이 자기 것인지 PID 일치 여부를 검사하고, $LISTEN_FDS는 넘어온 소켓 개수를 나타냅니다. $LISTEN_FDNAMES에는 각 소켓의 라벨(FileDescriptorName=)이 담깁니다.

자식은 accept()만 하면 되고, listen은 systemd가 부팅 직후 미리 해 둔 상태입니다. 그 결과는 다음과 같습니다.

  • 부팅 시 데몬을 미리 띄우지 않아도 됩니다(첫 연결로 깨움)
  • 데몬을 재시작해도 listen 소켓이 살아 있어 연결이 잘리지 않습니다
  • 의존성도 자연스럽게 연결됩니다. A가 B의 소켓을 두드리면 systemd가 B를 알아서 깨웁니다

간단한 예로 sshd.socket을 보겠습니다.

[Unit]
Description=OpenSSH Server Socket

[Socket]
ListenStream=22
Accept=no

[Install]
WantedBy=sockets.target

Accept=yes면 연결마다 인스턴스화된 sshd@.service가 깨어납니다. 이것이 inetd의 원래 모델입니다.

.timer: cron / anacron / at의 후계

cron 표현식은 "분 시 일 월 요일" 5컬럼입니다. systemd의 OnCalendar=는 같은 일을 하지만 표현법이 다릅니다.

[Timer]
OnCalendar=daily
Persistent=true
RandomizedDelaySec=300

트리거 키워드는 6종입니다(man systemd.timer).

OnActiveSec=는 타이머가 활성화된 시점을 기준으로 삼고, OnBootSec=는 부팅 후 N초, OnStartupSec=는 systemd가 뜬 후 N초를 가리킵니다. 반복 잡에는 짝꿍 유닛이 마지막으로 활성화된 시점을 기준으로 하는 OnUnitActiveSec=나 마지막으로 멈춘 시점을 기준으로 하는 OnUnitInactiveSec=를 씁니다. OnCalendar=는 cron의 자리를 맡은 달력식입니다.

보조 키워드도 그대로 정확히 적어둡니다.

Persistent=true는 시스템이 꺼져 있던 시간을 보정해 부팅 직후 한 번 실행하며 anacron의 자리를 맡습니다. RandomizedDelaySec=는 N초 범위에서 무작위로 지연해 cron으로 풀기 어려운 thundering herd를 피합니다. AccuracySec=는 1분 단위로 묶어 깨워서 노트북 배터리와 디스크 사용을 줄이고, OnClockChange=OnTimezoneChange=는 시계가 점프했을 때 트리거됩니다.

타이머는 자기가 일을 하지 않습니다. .timer 는 짝꿍 .service 를 한 번씩 깨울 뿐입니다. 잡 본체는 .service 에 적습니다.

# /etc/systemd/system/certbot-renew.timer
[Timer]
OnCalendar=daily
Persistent=true
RandomizedDelaySec=1h

[Install]
WantedBy=timers.target
# /etc/systemd/system/certbot-renew.service
[Service]
Type=oneshot
ExecStart=/usr/bin/certbot renew --quiet

cron과 비교하면 journald에 자동으로 기록되고, Wants= / After=로 의존성을 표현하며, 실패할 때 OnFailure=로 알림 잡을 트리거한다는 이점이 분명합니다.

.mount / .automount: fstab의 그림자와 autofs 흡수

리눅스를 오래 다룬 사람도 잘 모르는 사실이 있습니다. fstab은 부팅 시점에 그대로 쓰이지 않습니다. systemd-fstab-generator(8)가 끼어들어 fstab의 각 줄을 .mount.swap 유닛으로 변환한 다음 systemd의 의존성 그래프에 넣습니다.

systemd-fstab-generator is a generator that translates /etc/fstab into native systemd units... instantiating mount and swap units as necessary.

이름 규칙도 정해져 있습니다(man systemd.mount의 예시).

Mount units must be named after the mount point directories they control. Example: the mount point /home/lennart must be configured in a unit file home-lennart.mount.

따라서 /var/logvar-log.mount가 됩니다. 슬래시는 하이픈으로 바꾸고 첫 슬래시는 떼어냅니다. 이 변환 로직은 systemd-escape 명령으로 직접 확인할 수 있습니다.

$ systemd-escape -p --suffix=mount /var/log
var-log.mount

.automount는 autofs의 자리를 흡수했습니다. 마운트 지점에 처음 접근할 때 비로소 mount하고, 일정 시간 idle 상태가 이어지면 unmount합니다. NFS 같은 큰 볼륨을 lazy mount할 때 유용합니다.

# /etc/systemd/system/mnt-bigdata.automount
[Unit]
Description=Lazy mount for /mnt/bigdata

[Automount]
Where=/mnt/bigdata
TimeoutIdleSec=600

[Install]
WantedBy=multi-user.target

짝꿍 .mount가 함께 있어야 합니다(mnt-bigdata.mount). .automount는 트리거를 담당하고 .mount는 실제로 마운트합니다.

.path: inotify를 유닛 언어로

"파일이 생기거나 바뀌거나 사라지면 잡을 깨운다"는 패턴은 오래전부터 있었습니다. cron 폴링, inotifywait 스크립트, 별도의 파일 감시 데몬이 그 역할을 했습니다. .path는 이를 유닛 언어로 흡수했습니다.

키워드트리거 조건
PathExists=경로가 존재하면
PathExistsGlob=글롭 패턴이 매치되면
PathChanged=파일/디렉토리가 변경되면 (close-after-write)
PathModified=매 write 마다
DirectoryNotEmpty=디렉토리에 항목이 있으면
# /etc/systemd/system/upload-watch.path
[Path]
PathChanged=/srv/upload
Unit=upload-process.service

[Install]
WantedBy=multi-user.target

cron으로 1분마다 폴링하던 잡을 즉시 반응형으로 바꿀 수 있습니다. .timer와 마찬가지로 .path도 직접 일하지 않고 짝꿍 .service를 깨우는 모델입니다.

.target: runlevel의 후계

1편의 런레벨 표를 그대로 가져와 매핑하면 다음과 같습니다.

런레벨systemd target
0poweroff.target
1rescue.target
3multi-user.target
5graphical.target
6reboot.target

init 3 자리에 systemctl isolate multi-user.target 이 들어왔습니다.

런레벨은 단순한 모드 번호였습니다. target은 의존성 그래프 위의 동기화 지점입니다. network-online.target은 "네트워크가 실제로 도달 가능해질 때까지 기다리는 자리", local-fs.target은 "로컬 파일시스템이 모두 마운트된 자리", sockets.target은 "모든 socket activation listen이 끝난 자리"입니다.

/etc/systemd/system/default.target은 심볼릭 링크입니다. 데스크톱이면 graphical.target, 서버면 보통 multi-user.target으로 연결됩니다.

$ systemctl get-default
multi-user.target

.slice / .scope: cgroup 트리에 이름을 붙이는 두 형태

1편에서는 "cgroup 추적이 systemd 승리의 결정타였다"고 적었습니다. 그 cgroup 트리를 시스템에서 직접 들여다보게 해 주는 유닛이 .slice.scope입니다.

$ systemd-cgls
Control group /:
├─user.slice
│ └─user-1000.slice
│ ├─user@1000.service
│ └─session-3.scope
│ └─sshd 와 자식 프로세스들
├─system.slice
│ ├─nginx.service
│ ├─postgresql.service
│ └─sshd.service
└─machine.slice
└─runc 컨테이너들

최상위 슬라이스는 세 개로 나뉩니다. system.slice에는 시스템 데몬이, user.slice에는 로그인 사용자(login session)가, machine.slice에는 VM과 컨테이너가 속합니다.

**.scope**는 systemd가 직접 만들지 않은 프로세스, 예를 들어 SSH 로그인 세션이나 runc가 띄운 컨테이너를 cgroup으로 묶는 래퍼입니다. systemd 입장에서는 "외부에서 도착한 프로세스 무리에 이름표를 붙여 트리에 끼워 넣는" 도구입니다.

자원 제한은 슬라이스에든 서비스에든 들어갈 수 있습니다.

[Slice]
MemoryMax=4G
CPUQuota=50%
TasksMax=200

/etc/systemd/system/heavy-jobs.slice 한 파일에 4GB / 50% / 200 task 제한을 걸어두고, 거기에 속한 .service들이 그 한도를 공유하는 방식입니다.

.device / .swap: udev와 swapon의 다리

직접 작성하는 일이 거의 없는 두 종류입니다.

.device는 udev 이벤트로 자동 생성됩니다. /dev/sda가 인식되면 dev-sda.device가 자동으로 활성화되며, 다른 유닛에서 BindsTo=dev-sda.device처럼 의존성을 거는 데 주로 씁니다.

.swap은 fstab의 swap 항목을 앞에서 본 systemd-fstab-generator가 변환해 만듭니다. 직접 작성할 일이 거의 없고, 작성하더라도 Where= 대신 What=으로 디바이스를 지정합니다.

정직하게 말하면, 11종 중 운영자가 직접 작성하는 것은 사실상 6~7종이에요. .device.swap은 자동으로 생성되고 .scope도 외부 도구가 만듭니다. 직접 손으로 쓰는 것은 .service, .socket, .timer, .mount, .automount, .path, .target, .slice 정도입니다.

"init 비대화" 비판이 가리키는 것

여기까지 9개 섹션을 거치면 자연스럽게 한 그림이 보입니다. init 한 자리에 cron, fstab, inetd, autofs, inotify, runlevel, cgroup 도구가 모두 모여 있습니다. 1편에서 비껴간 "Unix 철학과 어긋난다"는 비판이 정확히 이 그림을 가리킵니다.

비판 진영의 가장 또렷한 한마디는 Slackware 창립자 Patrick Volkerding(패트릭 볼커딩)의 2013년 인터뷰에 나옵니다.

"I don't spend all day rebooting my machine, and having looked at systemd config files it seems to me a very foreign way of controlling a system to me, and attempting to control services, sockets, devices, mounts, etc., all within one daemon flies in the face of the UNIX concept of doing one thing and doing it well."

— Patrick Volkerding(패트릭 볼커딩), 2013

"services, sockets, devices, mounts, etc., all within one daemon"은 이 글이 2절부터 9절까지 보여준 11종 투어와 정확히 같은 그림을 묘사합니다. Volkerding은 그것을 부담으로 봤습니다.

반대편에서 Lennart Poettering(레나르트 푀터링)은 2010년 "Rethinking PID 1"에서 정반대 입장을 폈습니다. 단일 데몬이 의존성 그래프와 cgroup 추적을 한 자리에서 가지고 있어야 socket activation, parallel boot, 정확한 프로세스 정리가 일관되게 동작한다는 논지였습니다.

이 글은 어느 쪽이 옳다고 말하지 않습니다. 다만 두 진영이 가리키는 그림 자체, 곧 init 한 자리에 옛 도구 7~8종이 흡수된 모습은 같다는 데까지 보여줍니다. 그 그림을 어떻게 평가할지는 운영하는 시스템의 성격과 운영자의 취향에 달렸습니다.

한 줄로

1편이 "PID 1 자리에 누가 앉느냐"의 이야기였다면, 2편은 "그 자리에 앉은 것이 자기 영역을 어디까지 정의했느냐"의 이야기입니다. 유닛이라는 한 단어가 cron부터 cgroup까지 끌어안았다는 사실을 두고, 좋은 평가와 나쁜 평가가 같은 그림 위에서 출발합니다.

다음 편에서는 둘 중 하나를 더 깊이 파고들 예정이에요. .service 한 종류를 끝까지 살피는 운영자용 다이브, 또는 1편에서 중요하게 다룬 의존성 그래프(Wants= / Requires= / After=)가 실제로 풀리는 방식이에요. 어느 쪽이 먼저 나올지는 다음 글에서 정할게요.

참고

systemd 공식 man pages는 freedesktop.org가 upstream입니다. 아래 링크는 모두 그곳을 가리킵니다.

리눅스 init 세대 연표

· 약 12분

30년 버틴 init, 5년 만에 갈아치워진 표준

리눅스에서 PID 1로 부팅 직후 가장 먼저 실행되는 프로세스가 init이에요. 이 자리는 SysVinit이 30년 가까이 지켰지만 2010년대 초반부터 갑자기 풍경이 바뀌었어요. 2011년 Fedora 15에서 등장한 systemd가 5년 만에 거의 모든 메이저 배포판의 기본 init을 차지했고, 그 사이에는 Upstart가 잠깐 등장했다 사라졌죠.

이 글은 세 init 시스템을 기능 비교 대신 연표로 다룹니다.

  • 어떤 배포판이 언제 SysVinit을 버렸는가
  • Upstart는 왜 짧게 살았는가
  • systemd 채택을 둘러싼 Debian 투표, Devuan 분기, Ubuntu의 입장 변화는 어떻게 흘러갔는가
  • 지금도 systemd를 쓰지 않는 배포판은 어디인가

서버에 SSH로 들어가서 ps -p 1 -o comm=을 한 번 실행했을 때, 출력 한 줄이 어떤 역사 위에 서 있는지 정리해 보겠습니다.

세 세대, 한눈에

세대이름등장부팅 모델설정 단위대표 배포판 (전환 시점)
1세대SysVinit1983 (System V)직렬 / 런레벨/etc/inittab + /etc/init.d/*.sh거의 모든 리눅스 (~ 2010s)
2세대Upstart2006이벤트 기반/etc/init/*.confUbuntu 6.10 ~ 14.04, RHEL 6
3세대systemd2010병렬 / 의존성 + cgroup*.service, *.socket, *.timer, *.targetFedora 15+, RHEL 7+, Debian 8+, Ubuntu 15.04+, Arch, openSUSE 12.1+

세 시스템의 본질적 차이는 한 줄로 줄일 수 있습니다. SysVinit은 셸 스크립트를 순서대로 돌리고, Upstart는 이벤트가 발생하면 잡(job)을 돌리며, systemd는 의존성 그래프를 따라 유닛(unit)을 병렬로 돌린 뒤로도 계속 지켜봅니다.

1세대: SysVinit (1983~)

뿌리는 AT&T가 1983년에 낸 Unix System V입니다. 리눅스에는 1990년대 초 Miquel van Smoorenburg(미컬 반 스모렌부르흐)가 포팅한 sysvinit 패키지로 흘러들어왔고, 그 후 약 20년간 거의 모든 리눅스 배포판의 기본 init이 됐습니다.

핵심 구조는 단순합니다.

/etc/inittab → 어느 런레벨에 어떤 스크립트를 돌릴지 선언
/etc/init.d/* → 각 서비스의 start/stop/restart 스크립트
/etc/rc{0..6}.d/ → 런레벨별 심볼릭 링크 (S20foo, K80foo …)

런레벨(runlevel)은 시스템의 모드 번호입니다. 관습적으로 7개를 씁니다.

런레벨의미
0halt (종료)
1 (S)single user mode
2multi-user, 네트워크 없음 (Debian 계열은 네트워크 포함)
3multi-user, 네트워크 있음 (서버 기본값)
4미정의/사용자 정의
5multi-user + GUI (데스크톱 기본값)
6reboot

런레벨 진입 시 /etc/rcN.d/S로 시작하는 링크를 번호 순으로 실행하고, 빠져나갈 때 K 링크를 실행합니다. 부팅이란 곧 "셸 스크립트를 정해진 순서대로 한 줄씩 돌리는" 일이었습니다.

SysVinit의 한계

SysVinit이 30년을 살아남은 건 단순함 덕이고, 5년 만에 밀려난 것도 그 단순함 탓입니다.

가장 큰 문제는 직렬 부팅이었습니다. S20foo가 끝나야 S21bar가 시작되니, CPU가 놀고 있어도 두 서비스가 서로 무관해도 순서를 바꿀 수 없었고 이게 부팅이 느린 결정적 이유였습니다. 의존성 표현이 약한 것도 여기 얽힙니다. "DB가 떠 있어야 web이 뜬다" 같은 관계를 번호(S20, S21)로만 나타냈고, LSB 헤더가 의존성 기술을 거들긴 했지만 여전히 선형 정렬이 전제였습니다.

나머지 둘은 프로세스 관리 쪽입니다. 스크립트가 &로 백그라운드에 넘긴 자식을 init이 직접 추적하지 않아 PID 파일이 진실의 원천이 되는데, 이게 어긋나면 service foo status가 거짓말을 하고 자식이 더블 fork로 도망가면 더 곤란해집니다. 서비스가 죽었을 때 되살리는 것도 init 밖의 일이라, respawn을 inittab에 직접 걸거나 monit/supervisord 같은 외부 도구를 끌어와야 했습니다.

이 한계들이 2000년대 후반 "부팅이 빨라야 하는 노트북"과 "의존성 많은 데스크톱 환경"의 시대와 충돌했습니다. 다음 두 세대는 모두 이 중 하나 이상을 풀려고 출발합니다.

2세대: Upstart (2006~)

Canonical의 Scott James Remnant(스콧 제임스 렘넌트)가 만들었습니다. 첫 출시는 **Ubuntu 6.10 "Edgy Eft" (2006-10)**입니다. 이때부터 Ubuntu의 기본 init은 SysVinit이 아니라 Upstart였습니다(호환 모드로 SysV 스크립트도 돌렸습니다).

Upstart의 출발점은 "부팅은 단순한 순서가 아니라 일련의 사건들이다"라는 발상입니다. USB가 꽂히고 네트워크가 올라오며 디스크가 마운트되는 등, 이벤트가 발생할 때마다 그에 맞는 잡이 트리거되도록 모델을 짰습니다.

설정은 /etc/init/*.conf에 한 잡씩 둡니다.

description "Hello Web"

start on runlevel [2345]
stop on runlevel [!2345]

respawn
respawn limit 10 5

exec /usr/local/bin/hello-web --port 8080

respawn 한 줄로 자동 재시작이 해결되고, start on filesystem and net-device-up 식으로 이벤트 조합도 가능했습니다. SysVinit에 비하면 진짜 진보였습니다.

채택도 빨랐습니다. **Ubuntu 9.10 "Karmic Koala" (2009-10)**에서 SysV 호환 레이어 없이 native Upstart 부팅을 기본화했고, **RHEL 6 (2010-11)**이 정식 채택했습니다. Google의 ChromeOS와 일부 Fedora 릴리스도 한때 Upstart를 썼습니다.

Upstart가 짧게 살았던 이유

그런데 RHEL 6 이후 Upstart의 채택은 거기서 멈춥니다. 2010년대 초반 새로 등장한 systemd로 흐름이 갈아탔고 Ubuntu 본진마저 결국 같은 길을 갔는데, 단순히 "더 좋은 게 나왔다"로는 설명이 부족합니다.

가장 자주 꼽히는 건 Canonical의 CLA 정책입니다. Upstart 코드에 기여하려면 Canonical이 요구하는 Contributor License Agreement에 동의해야 했는데, 받은 코드를 상용으로 재배포할 권리를 회사에 양도하는 형태라 다른 진영이 부담스러워했습니다. 같은 시기 Linux 커널을 비롯한 여러 핵심 프로젝트가 CLA 없이 기여를 받던 흐름과 대조됐습니다.

기술적 한계도 겹쳤습니다. "A 이후 B" 같은 단순 의존이 이벤트 모델로는 어색했던 반면, systemd가 들고 나온 의존성 그래프와 Wants= / Requires= / After= 선언이 더 자연스럽다는 평가가 빠르게 자리 잡았습니다. 자식 프로세스 추적도 Upstart는 SysVinit과 비슷한 한계 안에 머물러, cgroup을 fork-bomb에도 끄떡없는 추적 수단으로 쓴 systemd에 비하면 약했습니다. 여기에 Fedora와 RHEL이 systemd를 적극적으로 밀면서 큰 생태계 플레이어 하나가 Upstart 진영에서 빠져나갔고, RHEL 6의 Upstart는 단명한 뒤 RHEL 7부터 systemd로 갔습니다.

결정타는 Debian과 Ubuntu가 차례로 systemd로 넘어간 시점입니다. 그 부분은 뒤에서 따로 정리합니다.

3세대: systemd (2010~)

2010년 4월, Red Hat의 Lennart Poettering(레나르트 푀터링)과 Kay Sievers(카이 지버스)가 발표한 글 한 편("Rethinking PID 1")으로 등장합니다. 핵심 아이디어는 세 가지였습니다.

첫째는 의존성 기반 병렬 부팅입니다. 유닛(*.service, *.socket, *.target 등)이 의존성을 명시하고, 의존성이 풀린 것부터 병렬로 출발합니다. 둘째는 소켓 활성화(socket activation)로, 데몬을 미리 띄우는 대신 systemd가 먼저 listen 소켓을 열어두고 첫 연결이 들어올 때 데몬을 깨웁니다. macOS의 launchd에서 영감을 받은 모델입니다. 셋째는 cgroup 기반 프로세스 추적입니다. 서비스가 fork한 자식과 손자까지 cgroup에 묶여 정확히 추적/정리되므로 PID 파일 위조나 더블 fork로 도망갈 수 없습니다.

이 세 가지가 동시에 풀린 게 결정적이었습니다. 이전 세대가 풀지 못한 한계를 한꺼번에 정리했고, 부팅 시간 단축이라는 가시적 효과도 따라왔습니다.

다만 systemd는 init만이 아니라 logind, journald, networkd, resolved, timedated 등 시스템 영역의 여러 컴포넌트를 흡수해 갔고, "Unix 철학과 어긋난다"는 비판도 같은 시기에 나왔습니다. 이 글은 그 논쟁에 한 발 들이지 않고, 채택 흐름만 따라갑니다.

systemd 채택 연표

발표 시점부터 메이저 배포판들의 채택 시점을 연도순으로 보면 흐름이 분명해집니다.

연/월사건
2010-04systemd 첫 발표 (Lennart Poettering, Kay Sievers)
2011-05Fedora 15: 메이저 배포판 중 첫 systemd 기본 채택
2011-11openSUSE 12.1: systemd 기본
2012-05Mageia 2: systemd 기본
2012-10Arch Linux: SysVinit에서 systemd로 전환
2013CoreOS: 출범부터 systemd가 핵심 (컨테이너 호스트 OS)
2014-06RHEL 7 / CentOS 7: 엔터프라이즈 표준이 바뀐 분기점
2014-11Debian Technical Committee 투표: systemd를 Jessie의 기본 init으로 결정
2014-11-27Devuan 분기 발표: "Veteran Unix Admins" 명의
2015-04Debian 8 "Jessie" 정식 출시 (systemd 기본)
2015-04-23Ubuntu 15.04 "Vivid Vervet": Upstart에서 systemd로
2017-05-25Devuan 1.0 "Jessie": Debian 기반, systemd 없는 첫 안정판

4년 (2011~2015) 만에 Fedora, RHEL, Debian, Ubuntu, openSUSE, Arch가 모두 systemd로 정렬됩니다. 이 정도 속도로 init 같은 핵심 컴포넌트가 통일된 적은 리눅스 역사에 거의 없습니다.

Debian의 투표와 Devuan 분기

Debian은 의사결정에 시간이 오래 걸리는 프로젝트입니다. systemd 채택도 예외가 아니어서 2013년 후반부터 Technical Committee 안에서 격론이 오갔고, 결국 2014년 11월 표결로 Debian 8 "Jessie"의 기본 init은 systemd로 결정됩니다.

표결 자체는 그것대로 정리됐지만, 같은 결의에 끼어 있던 또 다른 항목, 즉 "패키지가 systemd 의존성을 강제로 걸 수 있느냐"가 분기를 불렀습니다. 결의는 "다른 init 시스템 지원이 권장되지만 의무는 아니다(recommended, but not mandatory)"로 나왔습니다. 패키지가 systemd 외에 안 돌게 만들어도 막지 않는다는 의미였습니다.

이 결과에 반발한 일부 Debian 사용자/개발자가 "Veteran Unix Admins" 이름으로 2014년 11월 27일 Devuan 분기를 발표합니다. 약 2년 반의 패키지 감사/수정 끝에 2017년 5월 25일 Devuan 1.0 "Jessie"가 나왔습니다. Debian 8을 베이스로 systemd 훅을 모두 들어내고 SysVinit(또는 OpenRC)을 기본 init으로 하는 버전입니다.

Devuan은 이후로도 Debian을 한 단계씩 따라가며 출시를 이어가고 있습니다. 규모는 작지만 "systemd 없이도 Debian 생태계를 쓰고 싶다"는 수요에 답하는 진영으로 살아남았습니다.

Ubuntu가 자존심을 접은 결정

Ubuntu에게 init은 단순한 부품이 아니었습니다. Upstart는 Canonical의 자체 프로젝트였고, 거의 10년간 Ubuntu의 기본 init이었습니다. 그런데 Debian이 systemd로 결정한 직후, Mark Shuttleworth(마크 셔틀워스)는 "Ubuntu도 upstream(Debian)과 보조를 맞추겠다"고 발표합니다.

마이그레이션은 비교적 부드러웠습니다. Ubuntu 15.04 "Vivid Vervet"(2015-04-23)에서 기본 init이 systemd로 전환됐고, Ubuntu Touch(모바일)만 예외였습니다. 이후 15.04부터 16.10까지는 부팅 시 GRUB에서 Upstart와 systemd를 고를 수 있는 듀얼 부팅 기간을 유지했는데, 회귀가 생겼을 때 도망갈 길을 일정 기간 열어둔 운영적 선택이었습니다. 16.10 이후 Upstart 옵션이 제거되면서 이때부터 Ubuntu는 완전히 systemd 단독으로 갔습니다.

Canonical 입장에서는 자기 프로젝트를 접고 경쟁 프로젝트를 받아들인 결정이었지만, 그 무렵엔 systemd가 사실상 표준이 된 상태였고 Debian과 다른 init을 유지하는 비용이 더 커졌습니다.

systemd를 안 쓰는 배포판들

2026년 현재도 systemd가 아닌 init을 기본으로 쓰는 배포판이 남아 있습니다. 컨테이너 베이스 이미지나 임베디드, 보수적 운영을 위한 선택지로 의외로 자주 등장합니다.

배포판기본 init비고
Alpine LinuxOpenRC컨테이너 베이스 이미지 점유율이 높음. musl + busybox + OpenRC 조합
Void Linuxrunit단순함과 빠른 부팅이 강점
GentooOpenRC (기본) / systemd 옵션profile 선택으로 둘 다 사용 가능
DevuanSysVinit / OpenRCDebian 8 분기 후 독자 노선
SlackwareBSD-style init15.0 (2022) 시점에도 SysV가 아닌 BSD 스타일 유지
Artix LinuxOpenRC / runit / s6 / dinitArch 기반의 systemd-free 분기

여기서 중요한 점이 하나 있습니다. Alpine은 컨테이너 이미지 시장에서 표준급 점유율을 가지고 있습니다. "제 노트북은 Ubuntu고 systemd만 만져봤다"고 해도, 컨테이너에 FROM alpine:... 한 줄을 넣는 순간 OpenRC 기반 시스템과 만납니다. 다만 컨테이너 안에서는 init이 거의 의미를 갖지 않는다는 별도의 이슈가 있는데, 그건 마지막에서 다시 봅니다.

같은 작업, 세 가지 표현

세 init 시스템의 차이를 가장 빨리 느끼는 방법은 같은 서비스를 세 가지 형식으로 옆에 두고 보는 것입니다. hello-web이라는 가상의 HTTP 서버를 부팅 시 자동 기동하고 죽으면 자동 재시작하도록 등록한다고 해보겠습니다.

SysVinit: /etc/init.d/hello-web

#!/bin/sh
### BEGIN INIT INFO
# Provides: hello-web
# Required-Start: $network $remote_fs
# Required-Stop: $network $remote_fs
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: hello web server
### END INIT INFO

DAEMON=/usr/local/bin/hello-web
PIDFILE=/var/run/hello-web.pid

case "$1" in
start)
start-stop-daemon --start --background \
--make-pidfile --pidfile $PIDFILE \
--exec $DAEMON
;;
stop)
start-stop-daemon --stop --pidfile $PIDFILE
rm -f $PIDFILE
;;
restart)
$0 stop; sleep 1; $0 start
;;
status)
[ -f $PIDFILE ] && kill -0 $(cat $PIDFILE) 2>/dev/null \
&& echo "running" || echo "stopped"
;;
*)
echo "Usage: $0 {start|stop|restart|status}"; exit 1
;;
esac

그 후 update-rc.d hello-web defaults(Debian 계열) 또는 chkconfig hello-web on(RHEL 계열)으로 런레벨 링크를 만들어야 합니다. 자동 재시작은 별도 도구가 필요합니다.

Upstart: /etc/init/hello-web.conf

description "hello web server"

start on runlevel [2345]
stop on runlevel [!2345]

respawn
respawn limit 10 5

exec /usr/local/bin/hello-web --port 8080

스크립트가 아니라 선언문입니다. start-stop-daemon, PID 파일, status 분기, 런레벨 링크가 전부 사라졌습니다. respawn 한 줄로 자동 재시작도 끝납니다.

systemd: /etc/systemd/system/hello-web.service

[Unit]
Description=hello web server
After=network.target

[Service]
ExecStart=/usr/local/bin/hello-web --port 8080
Restart=on-failure
RestartSec=2

[Install]
WantedBy=multi-user.target

등록은 systemctl enable --now hello-web 한 줄. 의존성을 After=로 명시하고, 재시작 정책도 Restart=on-failure로 명확합니다. 자식 프로세스 추적은 cgroup이 알아서 해줍니다.

같은 의도가 41줄에서 9줄, 다시 12줄로 짧아집니다. 줄어든 만큼 init이 책임지는 부분과 사용자가 짜야 하는 부분의 경계가 옮겨갔다는 뜻입니다.

컨테이너 시대의 init: PID 1 문제

여기까지가 "호스트 OS의 init" 이야기였습니다. 그런데 컨테이너 시대로 들어오면 init의 의미가 한 번 더 뒤집힙니다.

도커 컨테이너 안에서 PID 1은 실제 init 시스템 대신 사용자가 실행한 프로세스입니다. CMD ["node", "server.js"]면 node가 PID 1이 됩니다. 그런데 PID 1에는 두 가지 특별한 책임이 있습니다.

하나는 좀비 자식 프로세스 수확입니다. 자식이 죽으면 부모가 wait()으로 거둬야 좀비가 정리되는데, PID 1이 이걸 안 하면 좀비가 영구히 쌓입니다. 다른 하나는 시그널 처리입니다. 커널은 PID 1에게 기본 시그널 핸들러를 붙여주지 않아서, 명시적으로 처리하지 않으면 SIGTERM이나 SIGINT가 무시됩니다.

대부분의 애플리케이션은 이 두 가지를 신경 쓰고 만들어지지 않았습니다. 그래서 컨테이너 생태계에는 경량 init들이 등장했습니다.

tini는 Docker가 --init 플래그로 채택한 사실상 표준입니다. dumb-init은 Yelp가 만든 alternatives이고, s6-overlay는 컨테이너 안에서 멀티 프로세스를 다룰 때 씁니다.

Kubernetes는 이 문제를 한 단계 더 위에서 다룹니다. Pod 종료 시 컨테이너 PID 1에 SIGTERM을 보내고 grace period 후 SIGKILL로 가는데, 앱이 SIGTERM을 안 잡으면 매번 강제 종료가 발생합니다. (이 흐름은 SIGINT, SIGTERM, SIGHUP, SIGKILL: 쿠버네티스 시대의 유닉스 시그널에서 깊게 다뤘습니다.)

호스트의 init은 의존성과 부팅 속도를 고민하지만, 컨테이너의 PID 1은 시그널 전파와 좀비 수확이라는 더 원초적인 책임으로 돌아갑니다.

한 줄로

SysVinit의 30년은 단순함이 호환성을 만든 시대였고, systemd의 5년은 의존성과 cgroup이 표준을 만든 시대였어요. 그 사이의 Upstart는 좋은 아이디어 한 가지로 잠깐 빛났지만 큰 생태계의 흐름을 못 이긴 사례로 남았고, PID 1 자리에 무엇이 앉느냐는 결국 그 시대 운영체제가 무엇을 가장 중요하게 여기는지를 비추는 거울이에요.


→ 다음 편: systemd 유닛이라는 언어: 11종을 한 바퀴. 이 글에서 살짝만 비춘 유닛 11종이 cron, fstab, inetd, autofs, inotify 같은 옛 도구를 어떻게 흡수했는지 한 바퀴 돕니다.

참고

Claude Code 외부 에디터

· 약 5분

터미널에서 긴 프롬프트는 힘들다

Claude Code를 쓰다 보면 몇 줄 넘는 프롬프트를 치고 싶을 때가 종종 있어요. 들여쓰기가 있거나, 코드를 붙이거나, 한글 IME 상태에서 복잡한 편집이 필요한 순간이에요. 터미널의 한 줄 편집기는 이런 상황에 약해요.

이럴 때 Claude Code 프롬프트를 외부 에디터로 열어서 쓸 수 있습니다. 방법 자체는 간단한데, 운용하다 보면 사소한 함정이 하나 있어 같이 정리합니다.

$EDITORCtrl+X Ctrl+E를 쓰는 기본 방법

Claude Code는 프롬프트 입력 중 Ctrl+X 뒤이어 Ctrl+E 를 누르면 $EDITOR 환경변수에 지정된 에디터를 띄웁니다. 내부적으로는 claude-prompt-<uuid>.md 같은 임시 파일을 만들고 그걸 에디터에 넘깁니다. 편집 후 저장하고 닫으면 그 내용이 그대로 프롬프트에 입력됩니다.

가장 단순한 세팅:

# ~/.zshrc
export EDITOR='nvim'

이러면 Ctrl+X Ctrl+E로 nvim이 뜨고, :wq로 닫으면 내용이 Claude Code 프롬프트로 들어옵니다.

참고로 최근 Claude Code 버전은 Ctrl+G도 같은 동작으로 바인딩돼 있습니다. 둘 다 테스트해 보고 손에 맞는 것을 쓰면 됩니다.

전역 EDITOR는 nvim, Claude만 Cursor로

그런데 EDITOR 환경변수는 git, crontab, less 등 여러 곳에서 참조합니다. 저는 이걸 nvim으로 둔 상태가 편해서 바꾸고 싶지 않아요. Claude Code 프롬프트만 GUI 에디터(Cursor, VSCode)로 띄우고 싶을 때는 함수로 감싸서 EDITOR를 이 프로세스 범위에서만 오버라이드하면 됩니다.

~/.zshrc에 들어 있는 것은 이것입니다.

cc() {
# VSCode 쓸 때
# EDITOR="code -w" command claude "$@"

# Cursor 쓸 때
EDITOR="/Applications/Cursor.app/Contents/Resources/app/bin/cursor -w" command claude "$@"
}

중요한 점은 두 가지입니다.

  • command claude: alias나 동명 함수가 걸려 있어도 실제 claude 바이너리를 실행하도록 우회합니다.
  • -w 플래그: Cursor/VSCode는 기본적으로 연 뒤 바로 셸로 제어를 반환합니다. -w(또는 --wait)를 붙여야 에디터 창이 닫힐 때까지 대기합니다. 없으면 Claude Code가 빈 파일을 바로 읽어 갑니다.

이제 터미널에서 cc만 실행하면 Claude Code가 뜨고, 프롬프트 편집 시 Cursor로 열립니다. 다른 툴들은 EDITOR=nvim을 그대로 봅니다.

실제 동작

1. 프롬프트에서 Ctrl+X Ctrl+E

Claude Code 프롬프트에서 Ctrl+X Ctrl+E 입력

2. Cursor에 임시 파일이 열립니다

Cursor에 claude-prompt 임시 파일이 열린 모습

파일명은 claude-prompt-<uuid>.md 형식입니다. 마크다운이라 코드 블록이나 리스트도 편하게 쓸 수 있습니다.

3. 저장하고 닫으면 프롬프트로 들어옵니다

여기서 한 가지 이상한 게 생겼습니다.

![복귀 후 프롬프트에 ^[[O^[I 이상 문자가 끼어든 상태

프롬프트 앞부분의 기존 텍스트와 Cursor에서 쓴 내용 사이에 ^[[O^[[I^[[O^[[I^[[O가 끼어 있습니다. 정체가 무엇일까요?

^[[O, ^[[I의 정체

이 문자들은 Focus Reporting에서 나옵니다.

터미널에는 포커스 리포팅(Focus Reporting) 이라는 기능이 있습니다. ANSI/xterm 스펙의 DEC Private Mode 1004. 이게 켜져 있으면 터미널 창의 포커스가 들어오고 나갈 때 아래 두 이스케이프 시퀀스가 터미널에 주입됩니다.

이벤트시퀀스
포커스 들어옴 (focus-in)ESC [ I (표기상 ^[[I)
포커스 나감 (focus-out)ESC [ O (표기상 ^[[O)

이걸 왜 쓰느냐면, vim/tmux 같은 TUI 앱이 유휴 상태일 때 렌더링을 멈추거나 커서 스타일을 바꾸기 위해 필요합니다. 터미널이 보내주는 이 이벤트로 현재 창이 활성인지 아닌지를 알 수 있습니다.

문제는 이 지점입니다.

  1. Claude Code가 Cursor를 띄웁니다. 이 순간 터미널 포커스가 제 터미널에서 Cursor 윈도우로 이동하고, 터미널이 ^[[O (focus-out)을 내보냅니다.
  2. Cursor에서 저장/종료하면 포커스가 다시 터미널로 돌아옵니다. ^[[I (focus-in)이 내보내집니다.
  3. 창 전환을 두세 번 하다 보면 여러 번의 in/out이 생깁니다.
  4. 이 이스케이프 시퀀스들은 stdin 버퍼에 쌓입니다. Claude Code가 에디터 종료 후 다시 입력 읽기를 시작할 때, 앞서 쌓인 것들을 프롬프트 텍스트로 흡수합니다.

정리하면 Cursor/VSCode 같은 별도 윈도우 GUI 에디터를 띄울 때만 이 현상이 생깁니다. 터미널에서 포커스가 실제로 왔다갔다 하기 때문입니다.

해결 방법

1순위: nvim / vim / helix / micro

같은 터미널 세션 안에서 도는 TUI 에디터는 포커스가 창 단위로 움직이지 않습니다. 창은 하나, 안에서 그냥 화면이 바뀔 뿐입니다. focus-in/out 이벤트 자체가 생기지 않습니다.

export EDITOR='nvim'

제 경험상 이게 제일 안정적이에요. nvim이 충분히 좋은 마크다운 편집기이기도 하고요.

2순위: 그래도 Cursor/VSCode를 쓰고 싶다면

몇 가지 부분 해결책이 있지만 완벽한 건 없습니다.

  • 복귀 후 섞인 ^[[ 시퀀스를 backspace로 수동 제거한 뒤 Enter를 누릅니다.
  • 터미널 앱 설정에서 focus reporting을 비활성화합니다(Ghostty, iTerm2 등에 옵션이 있습니다). 단 vim/tmux 쪽 기능도 같이 잃습니다.
  • Cursor/VSCode를 쓰되 cc 함수 내에서 임시로 focus reporting을 꺼 주는 래퍼를 끼웁니다. 번거로운 방법입니다.

깔끔한 길은 첫 번째예요. 긴 프롬프트 편집에 IDE급 기능이 꼭 필요한 게 아니라면 TUI 에디터 쪽이 나아요.

요약

  • Claude Code 프롬프트는 Ctrl+X Ctrl+E (또는 Ctrl+G)로 $EDITOR를 호출합니다.
  • 전역 EDITOR를 건드리지 않고 싶다면 cc() 함수 패턴으로 Claude에 한정해 오버라이드합니다. -w 플래그를 꼭 붙여야 합니다.
  • Cursor/VSCode를 쓰면 포커스 리포팅 시퀀스가 프롬프트에 섞이는 부작용이 있습니다.
  • 제일 안정적인 조합은 TUI 에디터(nvim 등) + EDITOR=nvim입니다.

참고 링크

쿠버네티스 유닉스 시그널

· 약 7분

오래된 주제와 새로운 무대

유닉스 시그널은 운영체제 수업 첫 단원에서 가볍게 훑고 지나가는 주제예요. 그런데 Docker, Kubernetes, systemd, PM2 같은 프로세스 오케스트레이터들이 일상이 된 지금, 이 네 개의 시그널은 오히려 운영 사고의 주요 원인이 되고 있어요.

  • "Docker stop을 했는데 왜 10초 뒤에야 죽나요?"
  • "K8s rolling update 중 request가 잘립니다"
  • "Ctrl+C가 안 먹히는데 뭐가 잘못됐죠?"
  • "nohup으로 돌렸는데 SSH 끊기니까 죽었어요"

전부 시그널 이해와 직결된 질문입니다. 한 번 제대로 정리해두면 두고두고 도움이 됩니다.

네 시그널 개요

시그널번호기본 동작핸들 가능대표 발생 경로
SIGINT2프로세스 종료캐치 가능터미널 Ctrl+C
SIGTERM15프로세스 종료캐치 가능kill PID, docker stop, K8s pod 종료
SIGHUP1프로세스 종료캐치 가능터미널 세션 종료, 관용적으로 "config reload"
SIGKILL9즉시 종료불가kill -9, Docker grace period 초과, OOM killer

핵심 차이는 마지막 칼럼인 핸들 가능 여부입니다. SIGKILL만 프로세스가 가로챌 수 없습니다. 이것이 왜 중요한지는 뒤에서 자세히 살펴봅니다.

SIGINT

SIGINT는 우리가 가장 자주 만나는 시그널입니다.

터미널에서 Ctrl+C를 누르면 현재 포그라운드 프로세스 그룹에 SIGINT가 날아갑니다. "사용자 인터럽트"의 약자로 interrupt에서 왔습니다.

프로세스는 이 시그널을 받으면 다음과 같이 동작합니다.

  • 기본적으로는 즉시 종료
  • 핸들러를 등록하면 정리 후 종료 가능 (열린 파일, DB 트랜잭션 롤백 등)
  • 완전히 무시할 수도 있음 (일부 REPL이 이렇게 동작)

Ctrl+C가 안 먹는 경우들

  • 자식 프로세스가 별도 세션으로 분리돼 있을 때 (setsid)
  • 프로세스가 I/O 블록 상태(디스크, 네트워크)라 인터럽트 후에도 시스템 콜 복귀가 늦을 때
  • 핸들러가 SIGINT를 잡아놓고 일부러 안 끝낼 때
  • TUI 앱(vim, tmux 등)이 터미널 raw 모드로 Ctrl+C를 키 입력으로 받아먹을 때

Ctrl+C가 안 먹히면 보통 SIGTERM을 쏴보는 게 다음 수순입니다. kill PID (기본값이 SIGTERM).

SIGTERM

SIGTERM은 정중한 종료 요청이며, 가장 중요한 시그널이자 현대 운영 환경의 기본 종료 경로입니다.

kill PID를 인자 없이 쓰면 기본이 SIGTERM입니다. docker stop CONTAINER는 컨테이너 PID 1에 SIGTERM을 전송하고, Kubernetes의 Pod 종료가 시작될 때도 각 컨테이너에 SIGTERM을 보냅니다. systemd가 unit을 멈출 때와 PM2, foreman, supervisor 등 프로세스 매니저가 stop할 때도 기본 시그널은 SIGTERM입니다.

SIGTERM은 "그만 종료해 주세요" 라는 의사 표현입니다. 프로세스가 이 시그널을 캐치할 수 있고, 원하는 시간 동안 정리할 수 있습니다.

Graceful shutdown 패턴 (Go 예제)

프로덕션 서버가 SIGTERM을 받았을 때 해야 할 일은 대체로 다음과 같습니다.

  1. 신규 요청 수신을 중단
  2. 진행 중인 요청을 완료할 때까지 기다림
  3. DB 커넥션, 파일, 임시 자원 정리
  4. 종료

Go 1.16+에서 signal.NotifyContext를 쓰면 깔끔합니다.

package main

import (
"context"
"log"
"net/http"
"os/signal"
"syscall"
"time"
)

func main() {
ctx, stop := signal.NotifyContext(context.Background(),
syscall.SIGINT, syscall.SIGTERM)
defer stop()

srv := &http.Server{Addr: ":8080"}

go func() {
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatal(err)
}
}()

<-ctx.Done() // SIGINT/SIGTERM 대기

shutdownCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()

if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown error: %v", err)
}
log.Println("bye")
}

25초 타임아웃은 K8s 기본 grace period(30초)보다 살짝 짧게 잡는 것이 관행입니다. grace period 안에 정리를 끝내고 자진 종료하기 위해서입니다.

Node.js 예제

const server = app.listen(3000)

const shutdown = (signal) => async () => {
console.log(`${signal} 수신, 정리 중...`)
server.close(() => process.exit(0))
// 25초 지나도 종료 안 되면 강제
setTimeout(() => process.exit(1), 25_000).unref()
}

process.on('SIGTERM', shutdown('SIGTERM'))
process.on('SIGINT', shutdown('SIGINT'))

Python 예제

import signal, sys, time

def shutdown(signum, frame):
print(f"signal {signum} 수신, 정리 중...")
# cleanup code here
sys.exit(0)

signal.signal(signal.SIGTERM, shutdown)
signal.signal(signal.SIGINT, shutdown)

while True:
time.sleep(1)

SIGKILL

SIGKILL을 아껴 써야 하는 이유가 있습니다. kill -9 PID는 개발자들의 오랜 습관입니다 — 하지만 프로세스에게 정리할 기회를 주지 않습니다.

  • 커널이 프로세스를 즉시 종료
  • 프로세스는 시그널을 캐치하거나 무시하거나 핸들러를 등록할 수 없다 (불가능)
  • DB 커넥션이 절반만 닫힌 상태로 방치, 파일이 잠금 해제되지 않은 채 남을 수 있음
  • 쓰기 중이던 데이터가 flush 안 된 상태로 증발

쓸 만한 때

  • SIGTERM을 보냈는데 충분히 기다린 뒤에도 안 죽음 (핸들러 무한 루프 등)
  • Docker grace period 초과 → Docker가 자동으로 SIGKILL 전송
  • K8s grace period 초과 → kubelet이 SIGKILL
  • OOM killer가 메모리 확보 위해 희생자로 선정

즉 순서는 항상 SIGTERM → 대기 → (안 되면) SIGKILL입니다. 처음부터 kill -9로 가는 것은 게으름이거나 데이터 손상 리스크를 감수하는 행위입니다.

SIGHUP

SIGHUP은 의미가 뒤집힌 시그널입니다. 이름 그대로 "HangUp"에서 왔습니다. 1970년대 전화 연결이 끊기면 모뎀이 보내주던 신호입니다. 지금은 그 용도가 거의 사라졌지만 두 가지로 남아 있습니다.

의미 1: 터미널 세션 종료

SSH로 원격 서버에 접속해서 명령을 실행하고 접속을 끊으면, 그 자식 프로세스들에 SIGHUP이 갑니다. 기본 동작은 종료입니다.

그래서 이를 피하는 도구들이 생겼습니다. nohup은 SIGHUP 무시 플래그를 설정하고 실행하는 "no hup"이고, disown은 셸의 job 테이블에서 프로세스를 제거해 SIGHUP 전파를 끊습니다. setsid는 새 세션을 만들어 터미널과의 연결 자체를 분리합니다. tmux나 screen은 결이 조금 다른데, 세션을 원격 호스트의 상주 데몬이 잡고 있어 클라이언트 연결이 끊겨도 프로세스가 유지됩니다.

주의: nohup은 SIGHUP만 막습니다. SIGTERM, SIGKILL은 그대로 받습니다. 시스템 재부팅/종료 때도 보호하지 못합니다.

의미 2: 설정 리로드 관용

현대 데몬들은 SIGHUP을 "설정 다시 읽어라"라는 신호로 재해석해서 씁니다. 공식 표준은 아니지만 사실상 관례입니다. 대표적인 예가 nginx입니다. kill -HUP이나 nginx -s reload를 받으면 마스터가 새 설정으로 워커를 띄우고 기존 워커는 현재 연결이 끝나면 종료합니다. rsyslog/syslog-ng도 같은 방식으로 설정을 리로드하고, haproxy도 리로드용으로 씁니다(최근 버전은 seamless-reload를 따로 둡니다). postgres 역시 pg_reload_conf()kill -HUP postmaster로 설정을 다시 읽습니다.

HUP의 원래 의미와는 전혀 상관없는 용도인데, 이제는 이쪽이 더 유명합니다.

쿠버네티스의 Termination 시퀀스

K8s에서 Pod를 지우면 내부적으로 다음 순서로 돕니다.

  1. Pod의 status가 Terminating으로 변경
  2. 서비스의 endpoint에서 해당 Pod 제거 (신규 트래픽 차단)
  3. preStop hook 실행 (정의돼 있다면)
  4. 각 컨테이너의 PID 1에 SIGTERM 전송
  5. Termination grace period (기본 30초) 대기
  6. 그래도 살아 있으면 SIGKILL

여기서 3번 preStop과 4번 SIGTERM 사이의 순서가 중요합니다. 많은 이가 preStop이 먼저 실행되고 그 끝에 SIGTERM이 간다고 오해하는데, 실제로는 둘이 사실상 동시에 시작됩니다(preStop은 먼저 호출되지만 SIGTERM 전송을 막지 않는다는 것이 정확한 표현입니다). preStop은 주로 "LB에서 빠질 시간을 벌기" 같은 용도로 사용합니다.

grace period 설정

spec:
terminationGracePeriodSeconds: 60
containers:
- name: app
# ...
lifecycle:
preStop:
exec:
command: ["sleep", "10"] # LB가 endpoint 제거를 반영할 시간

앱 내부의 graceful shutdown 타임아웃은 이 값보다 짧게 잡습니다.

흔한 함정들

Dockerfile의 shell form ENTRYPOINT

# 나쁜 예 — /bin/sh가 PID 1이 되어 SIGTERM을 흡수
CMD node server.js

# 좋은 예 — node가 직접 PID 1
CMD ["node", "server.js"]

shell form(문자열)은 sh -c로 감싸져 실행되는데, sh는 기본적으로 시그널을 자식에게 전파하지 않습니다. docker stop이 SIGTERM을 PID 1(sh)에 보내지만 실제 앱은 그것을 받지 못합니다. 결국 10초를 기다렸다가 SIGKILL로 박살납니다.

긴 preStop을 graceful shutdown 대체용으로 쓰지 말 것

preStop: sleep 60 같은 것으로 "종료 시간을 버는" 것은 꼼수에 가깝습니다. 앱이 실제로 정리하는 것이 아니라 단지 종료를 늦출 뿐입니다. 제대로 된 해법은 앱에 SIGTERM 핸들러를 두는 것입니다.

Python의 KeyboardInterrupt는 SIGINT만

try/except KeyboardInterrupt는 SIGINT만 잡습니다. SIGTERM은 안 잡힙니다. 컨테이너에서 돌리는 Python이면 반드시 signal.signal(signal.SIGTERM, ...)을 등록해야 합니다.

Node.js에서 동기 블로킹 작업

이벤트 루프를 오래 점유하는 동기 작업(큰 파일 sync read, 무거운 crypto 연산 등)은 process.on('SIGTERM', ...) 핸들러조차 실행하지 못합니다. Node 프로세스가 시그널을 처리할 기회를 얻지 못하기 때문입니다. graceful shutdown이 의미 있으려면 이벤트 루프를 풀어주는 코드여야 합니다.

nohup 과신

nohup long_task.sh &로 돌려놓았다고 "이제 안전하다"라고 여기기 쉽지만, SIGHUP만 막습니다. reboot나 SIGTERM 앞에서는 무력합니다. 장기 작업은 systemd 유닛이나 tmux 세션 쪽이 더 안전합니다.

한 줄로

"SIGTERM을 잡을 줄 아는 코드"와 "SIGTERM을 쏠 줄 아는 운영자"가 만나면 SIGKILL이 필요 없는 날이 많아져요. 컨테이너 오케스트레이션 시대에 이 오래된 주제가 다시 중요해진 이유는 결국 이것이에요.

참고

tmux copy-mode 스크롤 유지

· 약 3분

짜증

tmux copy-mode에서 로그를 위로 올려 y로 복사하면, 복사 직후 라이브 뷰(맨 밑)로 돌아가 버려요. 연속으로 몇 군데 복사하려고 하면 매번 다시 스크롤해야 해요.

환경: tmux 3.x 이상이 필요합니다. 이 글에 쓰인 if-shell -F, @사용자 옵션, copy-pipe-no-clear는 모두 3.x에 추가됐습니다. tmux -V로 확인할 수 있습니다.

고친 뒤

동작
y복사만, 스크롤 위치 유지
Enter복사 후 맨 밑으로 (기존 동작)
마우스 드래그복사만, 스크롤 위치 유지
Prefix + Ctrl-y위 동작 ON/OFF 토글

기본값은 ON입니다. 가끔 "복사 후 맨 밑으로"가 필요하면 토글하거나 그냥 Enter를 쓰면 됩니다.

설정

~/.tmux.conf의 copy-mode 섹션에 다음 설정을 추가합니다.

setw -g mode-keys vi

# 복사 후 copy-mode 유지 토글 (기본 ON)
set -g @copy-stay "on"

# Prefix + Ctrl-y 로 ON/OFF
bind C-y if-shell -F '#{==:#{@copy-stay},on}' \
'set -g @copy-stay "off" ; display "copy-stay: OFF (y: 맨 밑으로)"' \
'set -g @copy-stay "on" ; display "copy-stay: ON (y: 스크롤 유지)"'

bind-key -T copy-mode-vi v send-keys -X begin-selection

# y: 스크롤 유지 / 맨 밑으로 분기
bind-key -T copy-mode-vi y if-shell -F '#{==:#{@copy-stay},on}' \
'send-keys -X copy-pipe-no-clear "pbcopy"' \
'send-keys -X copy-pipe-and-cancel "pbcopy"'

# Enter는 항상 기존 동작
bind-key -T copy-mode-vi Enter send-keys -X copy-pipe-and-cancel "pbcopy"
bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-no-clear "pbcopy"

리눅스면 pbcopy 자리를 xclip -selection clipboard로 바꾸면 됩니다.

적용

tmux source-file ~/.tmux.conf

또는 tmux 안에서 Prefix + r을 누릅니다.

어떻게 되는 건지

필수는 아니지만 알아두면 좋은 부분입니다.

  • @copy-stay는 tmux의 사용자 옵션입니다. 이름이 @로 시작하면 사용자 저장소로 씁니다.
  • if-shell -F '#{==:A,B}'는 A와 B가 같으면 첫 번째 명령, 다르면 두 번째 명령을 실행합니다. -F 덕분에 외부 셸을 안 쓰고 tmux format만으로 분기가 끝납니다.
  • copy-pipe-no-clear — 복사하고 copy-mode 유지 (연속 복사용)
  • copy-pipe-and-cancel — 복사하고 copy-mode 종료 (라이브 뷰로 복귀)
  • set -g로 지정한 옵션은 서버 전역. 세션을 여러 개 띄워도 한 번만 토글하면 전체 반영되고, tmux kill-server 전까지 값이 유지됩니다.

copy-mode 종료 키 동작 차이

같은 copy-mode를 빠져나가는데도 키마다 동작이 다릅니다.

동작클립보드
y@copy-stay 값에 따라 분기복사함
Entercopy-mode 종료, 라이브 뷰로복사함
q / Escapecopy-mode 종료, 라이브 뷰로복사 안 함

"y 누르고 안 복사된 줄 알았는데 q로 빠져나갔다" 같은 사고가 가끔 있습니다. 종료 전에 복사가 끝났는지를 한 번 확인하는 습관이 안전합니다.

@사용자 옵션if-shell 토글의 일반 패턴

이 글에서 쓴 토글 구조는 사실 이 한 케이스에 한정된 트릭이 아닙니다. @옵션에 상태를 저장하고 if-shell -F로 분기하는 패턴은 tmux 안에서 재사용 가능한 토글을 만드는 일반 기법입니다.

응용 예시 — 상태바에 시각 표시 ON/OFF 토글을 만들 수 있습니다.

set -g @show-clock "off"

bind C-t if-shell -F '#{==:#{@show-clock},on}' \
'set -g @show-clock "off" ; set -g status-right ""' \
'set -g @show-clock "on" ; set -g status-right "%H:%M"'

@*로 시작하는 옵션은 tmux가 해석하지 않고 그대로 보관만 하므로 이름은 자유입니다. 외부 스크립트 없이 tmux 설정 파일 한 곳에서 상태 + 분기 + 알림을 모두 처리할 수 있다는 점이 이 패턴의 가치입니다.

기본값을 off로 두고 싶다면

set -g @copy-stay "off"

한 줄만 바꾸면 돼요. 토글 키는 그대로 동작해요.

agnoster와 Starship

· 약 7분

어느 날 Enter가 느려졌다

터미널에서 Enter를 눌렀는데 다음 프롬프트가 뜰 때까지 300ms 넘게 비는 느낌이 왔어요. 큰 레포일 때는 그러려니 했지만 작은 디렉토리에서도 똑같이 굼떴고, "디렉토리 크기와 무관하게 느리다" — 이 사실이 첫 번째 단서였어요.

이 글은 그 지연의 정체를 찾아가서, 얕은 수정으로 안 됐고, 결국 프롬프트 테마를 갈아엎고 Starship으로 넘어온 여정의 기록입니다.

첫 시도: status를 빠르게 한다

처음에는 단순하게 접근했습니다. "Enter 뒤 지연 = git status가 느림"이라고 가정하고, 속도를 높이는 옵션부터 모두 켰습니다.

# ~/.zshrc
DISABLE_UNTRACKED_FILES_DIRTY="true"
# git 전역
git config --global core.fsmonitor true
git config --global core.untrackedCache true
git config --global feature.manyFiles true

fsmonitor는 파일 시스템 이벤트를 상주 데몬이 추적해 git status의 내부 스캔 비용을 사실상 0에 가깝게 만듭니다. 실제로 git status --porcelain 자체의 시간은 크게 짧아졌습니다.

레포.git 크기fsmonitor 적용 후 status
small-repo작음즉시
mid-repo-A30 MB0.025 s
mid-repo-B51 MB0.041 s
big-repo246 MB0.042 s

그런데 Enter 지연은 거의 그대로였습니다. git status가 10배 빨라졌는데도 체감이 달라지지 않았으니, 제가 엉뚱한 부분을 최적화했다는 뜻이었습니다.

측정을 다시 할 때 만난 세 가지 함정

문제를 다시 풀어헤치려면 먼저 제대로 측정해야 했습니다. 여기서 쉽게 빠지는 함정이 세 개 있었습니다.

함정 1. zsh -i -c exit는 per-Enter 지연이 아니다

흔히 쓰는 "쉘 벤치마크"인 time zsh -i -c exit는 전혀 다른 대상을 측정합니다. 쉘이 시작할 때 한 번만 드는 비용입니다. compinit, plugin 로딩 같은 일회성 작업을 포함합니다. 매번 Enter를 칠 때 드는 비용과는 다른 층이라, 이 값을 아무리 줄여도 프롬프트 재평가 비용은 그대로입니다.

함정 2. precmd를 직접 호출해도 안 된다

일반적인 zsh 테마는 precmd_functions에 훅을 걸어 매 프롬프트 직전에 준비 작업을 합니다. 그러나 agnoster는 다릅니다.

PROMPT='%{%f%b%k%}$(build_prompt) '

PROMPT 변수 안에 명령 치환 $(build_prompt)를 끼워 둔 구조라, 프롬프트 문자열이 expansion될 때 비로소 git 호출이 일어납니다. 그래서 precmd를 수동으로 실행해 재면 거의 0ms가 찍힙니다. 실제 Enter 경로와 다른 곳에서 측정하는 셈입니다.

함정 3. 반복마다 새 zsh를 띄우면 측정이 오염된다

# 나쁜 예
for i in {1..10}; do
time zsh -ic 'print -P "$PROMPT" >/dev/null'
done

이렇게 측정하면 매 반복이 zsh 시작 비용(~200ms)에 묻힙니다. 같은 zsh 프로세스 안에서 루프를 돌려야 순수 프롬프트 렌더 비용만 남습니다.

올바른 one-liner

결국 큰 레포로 cd 한 상태에서 다음과 같이 측정해야 했습니다.

zsh -ic '
print -P "$PROMPT" >/dev/null # 웜업
{ time (for i in {1..10}; do print -P "$PROMPT" >/dev/null; done) } 2>&1
'

print -P "$PROMPT"는 PROMPT 문자열을 강제로 expand시키기 때문에 내부의 $(build_prompt)가 실제로 실행되고, 진짜 비용이 드러납니다.

진짜 범인: agnoster의 7× git spawn

측정을 제대로 돌리자 범인이 또렷이 보였습니다. ~/.oh-my-zsh/themes/agnoster.zsh-themeprompt_git()은 매 프롬프트 렌더마다 git 서브프로세스를 일곱 번 띄웁니다.

1. git config --get oh-my-zsh.hide-status
2. git rev-parse --is-inside-work-tree
3. git rev-parse --git-dir
4. git status --porcelain (parse_git_dirty)
5. git symbolic-ref HEAD
6. git log --oneline @{upstream}.. (ahead)
7. git log --oneline ..@{upstream} (behind)

그리고 macOS의 기본 git 바이너리는 Apple wrapper입니다.

/Library/Developer/CommandLineTools/usr/libexec/git-core/git

호출 한 번당 fork → exec → wrapper → core 체인에 고정적으로 40~50ms가 듭니다. 이 오버헤드는 git이 아무리 빨라도 줄어들지 않습니다.

산수가 맞아떨어집니다.

7 × 50ms ≈ 350ms

그래서 .git이 수십 MB든 수백 MB든 바닥값은 비슷하게 나옵니다. 이게 Enter 지연의 정체였습니다.

측정 결과 (10회 평균)

상태big-repo (246MB .git)small-repo
agnoster 원본 (prompt_git 포함)~427 ms~335 ms
prompt_git를 빈 함수로 대체~14.7 ms~13.6 ms

프롬프트 지연의 95% 이상이 prompt_git의 spawn 오버헤드였습니다. git status 자체는 fsmonitor 덕분에 이미 충분히 빨랐습니다. 제가 status 내부를 최적화하느라 헛돈 까닭은 줄일 수 있는 구간만 보고 줄이려 했기 때문입니다. spawn 횟수는 그대로 7이었으니 체감이 달라지지 않은 게 당연했습니다.

세 가지 선택지

구조적 한계가 확인되자 길이 명확해졌습니다.

A. agnoster의 git 세그먼트를 끈다

git config --global oh-my-zsh.hide-status 1

혹은 zshrc 마지막에:

prompt_git() { : }

프롬프트 렌더 비용이 15ms로 떨어집니다. 대신 브랜치/dirty 표시가 사라집니다. 저는 브랜치를 프롬프트에서 보는 편이 좋아서 이 선택지는 제외했습니다.

B. powerlevel10k로 간다

gitstatusd라는 상주 데몬이 변경을 감시하며 결과를 파이프로 돌려줍니다. git 바이너리를 띄우지 않으므로 spawn은 0회입니다. instant prompt를 사용해 초기 표시도 빠릅니다.

괜찮은 옵션이지만, 설정을 직접 다듬기엔 .p10k.zsh 포맷이 다소 난잡해 보였습니다.

C. Starship으로 간다

Rust로 작성한 단일 바이너리입니다. zsh 프롬프트 훅에서 starship 바이너리를 한 번만 fork하고, 그 안에서 libgit2를 라이브러리로 링크해 호출합니다. 외부 git 프로세스는 띄우지 않습니다. 즉 spawn 1회로 끝납니다.

  • 설정은 ~/.config/starship.toml 한 파일 (TOML)
  • git diff로 리뷰/롤백이 쉬움
  • 프리셋 갤러리가 존재해 시작점 제공

풍부한 정보와 속도, 설정 가독성을 모두 고려해 Starship으로 결정했습니다.

Starship 전환 작업

설치

brew install starship
# 1.25.0

~/.zshrc 두 군데 수정

agnoster를 끄고:

- ZSH_THEME="agnoster"
+ # ZSH_THEME="agnoster" # 비활성, Starship 사용
+ ZSH_THEME=""

파일 끝에 초기화 추가:

# Starship prompt. agnoster 대신. 없으면 graceful skip.
if command -v starship >/dev/null 2>&1; then
eval "$(starship init zsh)"
fi

바이너리가 없으면 조용히 건너뛰도록 command -v 체크를 넣었습니다. 새 머신 셋업할 때 zshrc를 그대로 복사해도 안전합니다.

프리셋 적용

starship preset pastel-powerline -o ~/.config/starship.toml

pastel-powerline의 정보 밀도가 가장 괜찮아 선택했습니다. 이대로 써도 되지만 두 가지가 걸렸습니다.

  1. 한 줄 프롬프트라 긴 디렉토리/브랜치명이 나오면 명령 입력 공간이 좁아집니다.
  2. 다크 터미널에서 일부 세그먼트의 글자가 묻힙니다.

튜닝 1: 두 줄 프롬프트

format 문자열 끝(마지막 """ 직전)에 줄바꿈과 character 모듈을 추가합니다.

$line_break\
$character

파일 끝에는 [character] 섹션을 추가합니다.

[character]
success_symbol = "[❯](bold #FCA17D)"
error_symbol = "[❯](bold red)"
vimcmd_symbol = "[❮](bold green)"

이제 첫 줄에는 정보(유저/경로/git/언어/시간)를 표시하고, 두 번째 줄에는 하나만 둡니다. 명령 입력이 세그먼트에 영향받지 않습니다.

튜닝 2: 다크 터미널 가독성

기본 pastel-powerline은 각 세그먼트 배경색만 지정하고 글자색은 터미널 기본을 씁니다. 어두운 터미널(제 환경은 거의 검정 배경)에서는 짙은 배경 세그먼트 안의 글자가 잘 보이지 않았습니다.

각 세그먼트에 명시적 글자색과 bold를 지정했습니다.

세그먼트배경글자색
username#9A348E (보라)#ffffff bold
directory#DA627D (분홍)#1a1a2e bold
git_branch / git_status#FCA17D (피치)#1a1a2e bold
언어 버전 (golang 등)#86BBD8 (연파랑)#1a1a2e bold
docker_context#06969A (청록)#ffffff bold
time#33658A#4A86C5#ffffff bold

time 세그먼트의 원래 배경 #33658A는 너무 어두워서 거의 터미널 배경과 합쳐져 보였습니다. 더 밝은 파랑 #4A86C5로 바꾸고, format 문자열 안의 배경/전경 치환자 두 군데도 같이 교체했습니다.

bg:#33658A) → bg:#4A86C5)
fg:#33658A) → fg:#4A86C5)

Nerd Font 글리프가 많은 파일이라 직접 편집하면 글자가 깨지기 쉽습니다. 수정은 Python 스크립트로 바이트 안전하게 적용했습니다.

결과

속도 (20회 평균, 같은 zsh 내 print -P "$PROMPT")

상태big-repo (246MB .git)small-repo
agnoster 원본427 ms335 ms
Starship (pastel-powerline)~70 ms~60 ms

약 6배입니다. 인간 지각 한계(~100ms) 안쪽이라 체감상 즉시 반응합니다. Enter가 걸리던 감각이 완전히 사라졌습니다.

시각

실제 화면입니다(일부 모자이크).

Starship 프롬프트 실제 렌더링

표시 요소:

  • 사용자명 (흰 bold, 보라 배경)
  • 디렉토리 (3계층 축약)
  • git 브랜치 + 상태 (stashed $ / untracked ? / modified ! / renamed / ahead/behind 등)
  • 감지된 언어 버전 (위 예시엔 Go v7.3.2, Java v25.0.1)
  • 시간 (♥ HH:MM)
  • 두 번째 줄: 입력 커서

이 과정에서 배운 것

  1. 느린 걸 고치려면 먼저 제대로 재야 합니다. 제가 잰 zsh -i -c exit는 Enter 지연이 아니라 쉘 시작 시간이었습니다. 엉뚱한 걸 최적화하느라 며칠 돌아갔습니다.

  2. spawn 횟수는 내부 로직 속도와 독립입니다. git이 1ms에 끝나도 7번 띄우면 350ms가 듭니다. 개선 가능한 구간이 보여도, 그 부분이 병목인지부터 확인해야 합니다.

  3. 도구의 구조를 모르면 설정도 고치기 어렵습니다. agnoster가 PROMPT 변수 안에 명령 치환을 넣는 구조라는 걸 몰랐다면 precmd 벤치마크만 반복하며 "왜 안 느리지?"라고 했을 겁니다.

  4. 근본 전환이 결국 싸게 먹힐 때가 있어요. 보조 옵션을 쌓아도 못 넘는 한계가 있을 때는 테마 자체를 갈아치우는 편이 빨라요. agnoster → Starship은 설정 파일 두 개 수정 + brew install 한 번이었어요.

참고 자료

PG19 파티션 MERGE/SPLIT

· 약 5분

PostgreSQL 파티셔닝의 짧은 역사

PostgreSQL의 파티셔닝은 10 버전에서 선언적(declarative) 문법이 들어온 이후 꾸준히 성숙해왔어요. 13에서 UPDATE로 파티션 간 row 이동이 가능해졌고, 14에서 DETACH PARTITION CONCURRENTLY가 추가됐어요. 그리고 PostgreSQL 19에서 마침내 파티션 자체를 병합하고 분할하는 DDL이 들어와요.

ALTER TABLE ... SPLIT PARTITION ...
ALTER TABLE ... MERGE PARTITIONS ...

익숙한 문법입니다. Oracle을 써본 사람이라면 20년 넘게 봐온 구문과 거의 판박입니다. 다만 자세히 보면 문법과 제약에서 결정적인 차이들이 있습니다.

이전까지 PostgreSQL에서는 어떻게 했나

PostgreSQL 18 이하에서 파티션을 병합하려면 이런 절차를 거쳤습니다.

  1. 새로운 합쳐진 파티션 테이블을 생성
  2. 기존 파티션에서 INSERT SELECT로 데이터 이동
  3. 기존 파티션 DETACH
  4. 새 파티션 ATTACH
  5. 기존 파티션 DROP

한 번에 트랜잭션으로 묶기도 까다롭고, 데이터 이동 중 ACCESS EXCLUSIVE LOCK이 걸려 서비스 가용성에도 영향을 줍니다. 분할도 마찬가지로 귀찮았습니다. Oracle에서는 한 줄이면 끝나는 작업이었습니다.

PostgreSQL 19의 새 문법

SPLIT과 MERGE를 각각 예제로 살펴보겠습니다.

SPLIT PARTITION

-- 원본 파티션 테이블
CREATE TABLE sales (
id bigint generated always as identity,
region text not null,
amount numeric
) PARTITION BY LIST (region);

CREATE TABLE sales_all PARTITION OF sales
FOR VALUES IN ('KR', 'JP', 'CN', 'US', 'UK');

-- 하나의 파티션을 셋으로 분할
ALTER TABLE sales SPLIT PARTITION sales_all INTO (
PARTITION sales_asia FOR VALUES IN ('KR', 'JP', 'CN'),
PARTITION sales_us FOR VALUES IN ('US'),
PARTITION sales_uk FOR VALUES IN ('UK')
);

MERGE PARTITIONS

-- 세 파티션을 하나로 합치기
ALTER TABLE sales MERGE PARTITIONS
(sales_asia, sales_us, sales_uk)
INTO sales_all;

문법에서 눈여겨볼 점은 다음과 같습니다.

  • 지원 타입: RANGE, LIST (HASH는 지원하지 않음)
  • RANGE 파티션은 인접(adjacent) 해야 병합 가능
  • LIST는 인접 제약 없음
  • 소스 파티션들은 괄호 (...)로 감싼다
  • 대상 파티션에 PARTITION 키워드를 붙이지 않는다

Oracle의 문법

같은 일을 Oracle에서 하면 이렇게 됩니다. Oracle 쪽은 8i 시절부터 존재해온 문법이라 변주가 많습니다.

SPLIT PARTITION

-- RANGE 파티션 분할 (AT 값 기준)
ALTER TABLE sales SPLIT PARTITION sales_2026 AT (DATE '2026-07-01')
INTO (
PARTITION sales_2026_h1 TABLESPACE ts1,
PARTITION sales_2026_h2 TABLESPACE ts2
);

-- LIST 파티션 분할 (VALUES 기준)
ALTER TABLE sales SPLIT PARTITION sales_all
VALUES ('KR', 'JP', 'CN')
INTO (
PARTITION sales_asia,
PARTITION sales_rest
);

MERGE PARTITIONS

-- 기본 문법
ALTER TABLE sales MERGE PARTITIONS
sales_q1_2026, sales_q2_2026, sales_q3_2026, sales_q4_2026
INTO PARTITION sales_2026;

-- RANGE 전용 TO 단축 문법
ALTER TABLE sales MERGE PARTITIONS
sales_q1_2026 TO sales_q4_2026
INTO PARTITION sales_2026;

나란히 비교

문법 차이

항목PostgreSQL 19Oracle
MERGE 소스 지정MERGE PARTITIONS (p1, p2) 괄호MERGE PARTITIONS p1, p2 나열
MERGE 대상 지정INTO p_newINTO PARTITION p_new
SPLIT 경계 지정FOR VALUES IN (...) / FOR VALUES FROM ... TO ...AT (value) (RANGE) / VALUES (...) (LIST)
RANGE 범위 병합 단축없음 (일일이 나열)p1 TO p4 단축 문법
TABLESPACE 지정각 대상 파티션마다 개별동일

기능/제약 차이

항목PostgreSQL 19Oracle
지원 파티션 타입RANGE, LISTRANGE, LIST, SYSTEM
HASH 파티션지원 안 함지원 안 함
RANGE 인접 조건필요필요
LIST 인접 조건불필요불필요
락 수준ACCESS EXCLUSIVE (전 구간)EXCLUSIVE + ONLINE 옵션 (EE 12.2+)
실행 방식단일 프로세스병렬 실행 가능

실전에서 갈리는 락과 온라인 실행

문법은 거의 맞춰졌습니다. 하지만 운영에서 진짜 갈리는 것은 락과 온라인 실행 여부입니다.

PostgreSQL 19

공식 커밋 메시지와 depesz의 벤치(1천만 행 LIST 파티션 기준)에서 확인된 내용은 다음과 같습니다.

  • 작업 전체 구간에서 ACCESS EXCLUSIVE LOCK 유지
  • 단일 프로세스에서 순차 실행
  • 대규모 파티션에서는 실질적 다운타임이 발생할 수 있음

쓸모 있는 평가는 이렇습니다. "편의성은 크게 좋아졌지만, 온라인성은 Oracle을 따라잡지 못했습니다." 한 줄 SQL로 간단히 표현할 수 있게 된 것만으로도 의미가 크지만, 수백 GB 파티션을 무중단으로 합칠 수 있는 수준은 아닙니다.

Oracle

Oracle Enterprise Edition 12.2부터 ONLINE 키워드로 DML 블로킹 없이 파티션을 합치거나 쪼갤 수 있습니다.

ALTER TABLE sales MERGE PARTITIONS
sales_q1_2026, sales_q2_2026
INTO PARTITION sales_h1_2026
ONLINE;

내부적으로는 shadow 구조를 만들고 점진적으로 데이터를 이관하는 방식이라, 배타 락 보유 시간이 거의 없습니다. Enterprise Edition 라이선스가 전제라는 게 큰 단서지만, 대형 운영 환경에서 체감되는 차이는 큽니다.

어디까지 따라왔고, 어디까지 아직인가

따라잡은 것

  • SPLIT/MERGE DDL 자체의 존재: SQL 한 줄로 끝납니다
  • 지원 파티션 타입 (RANGE, LIST)
  • 인접 조건 정책 (RANGE는 인접 필요, LIST는 무관)
  • 다중 분할(한 파티션 → N개)과 다중 병합(N개 → 하나)

아직 못 따라온 것

  • 온라인(ONLINE) 실행: 대형 파티션은 실질적 다운타임이 남음
  • 병렬 실행: 단일 프로세스로만 처리
  • RANGE 범위 병합 단축 문법(p1 TO p4) 없음

PostgreSQL의 역사적 약점 중 하나가 "큰 파티션 테이블의 유지보수가 DDL 한 줄로 안 끝난다"였습니다. PostgreSQL 19는 그 거리를 눈에 띄게 좁혔습니다. 다만 운영에서 가장 아픈 "락을 오래 붙잡는다"는 문제는 남아 있어서, 대형 테이블은 여전히 DETACH CONCURRENTLY + 수동 작업 전략을 병용해야 할 수 있습니다.

Oracle에서 PostgreSQL로 넘어올 때 "제가 쓰던 그 문법, PostgreSQL에도 있어요?"라는 질문의 목록이 하나씩 채워지고 있어요. 이번은 파티션 병합/분할 차례였어요.


참고