<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>주맨의 개발노트</title>
    <link>https://devgeek.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Sat, 11 Apr 2026 07:38:32 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>JooMan</managingEditor>
    <image>
      <title>주맨의 개발노트</title>
      <url>https://tistory1.daumcdn.net/tistory/4925296/attach/8c9a975c6d6c46a2a578039ca86bb2bf</url>
      <link>https://devgeek.tistory.com</link>
    </image>
    <item>
      <title>Claude Code Hook으로 작업 완료 알림 받기</title>
      <link>https://devgeek.tistory.com/166</link>
      <description>&lt;style&gt;
  /* 코드 블록 래퍼 */
  .code-wrap {
    margin: 28px 0;
    border-radius: 10px;
    border: 1px solid var(--border);
    max-width: 100%;
    width: 100%;
    box-sizing: border-box;
    overflow: hidden;
  }
  .code-header {
    background: var(--surface2);
    padding: 10px 18px;
    display: flex;
    justify-content: space-between;
    align-items: center;
    border-bottom: 1px solid var(--border);
  }
  .code-body {
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
  }
  .code-filename {
    font-family: 'JetBrains Mono', monospace;
    font-size: 12px;
    color: var(--accent2);
  }
  .code-lang {
    font-size: 11px;
    color: var(--text-dim);
    text-transform: uppercase;
    letter-spacing: .08em;
  }
  .code-wrap pre { margin: 0; border-radius: 0; border: none; min-width: max-content; }

  /* 문법 색상 */
  .c-comment { color: #6e7681; }
  .c-key     { color: #a78bfa; }
  .c-str     { color: #7ee787; }
  .c-head    { color: #79c0ff; }
  .c-section { color: #ffa657; }

  /* 비교 카드 */
  .compare {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 16px;
    margin: 28px 0;
  }
  .compare-card {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 10px;
    padding: 20px;
    min-width: 0;
  }
  .compare-card.bad  { border-color: rgba(248,113,97,.3); }
  .compare-card.good { border-color: rgba(52,211,153,.3); }
  .compare-label {
    font-size: 11px;
    letter-spacing: .1em;
    text-transform: uppercase;
    margin-bottom: 12px;
    font-weight: 600;
    display: block;
  }
  .compare-card.bad  .compare-label { color: #f87171; }
  .compare-card.good .compare-label { color: #34d399; }
  .compare-card p { font-size: 14.5px; color: var(--text-muted); margin: 0; }

  /* 레벨/이벤트 카드 */
  .level-list {
    margin: 24px 0;
    display: flex;
    flex-direction: column;
    gap: 12px;
  }
  .level-item {
    display: flex;
    gap: 20px;
    padding: 20px;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 10px;
    align-items: flex-start;
  }
  .level-badge {
    flex-shrink: 0;
    width: 56px;
    height: 56px;
    border-radius: 10px;
    background: var(--hl);
    border: 1px solid var(--accent);
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    font-size: 10px;
    color: var(--text-dim);
    text-transform: uppercase;
    letter-spacing: .05em;
  }
  .level-badge span {
    font-size: 18px;
    font-weight: 700;
    color: var(--accent2);
    line-height: 1;
    margin-bottom: 2px;
  }
  .level-body { min-width: 0; }
  .level-body h4 { font-size: 15px; font-weight: 600; color: #fff; margin: 0 0 6px; }
  .level-body p  { font-size: 14px; color: var(--text-muted); margin: 0; }

  /* 동작 흐름 다이어그램 */
  .flow {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 12px;
    padding: 32px 24px;
    margin: 28px 0;
    text-align: center;
  }
  .flow-steps {
    display: flex;
    flex-direction: column;
    gap: 8px;
    align-items: center;
  }
  .flow-step {
    background: var(--code-bg);
    border: 1px solid var(--border);
    border-radius: 8px;
    padding: 12px 28px;
    font-size: 14px;
    color: var(--text);
    width: 100%;
    max-width: 480px;
    text-align: left;
    box-sizing: border-box;
  }
  .flow-step .step-num {
    font-size: 11px;
    color: var(--accent);
    text-transform: uppercase;
    letter-spacing: .1em;
    display: block;
    margin-bottom: 3px;
  }
  .flow-arrow { color: var(--text-dim); font-size: 18px; line-height: 1; }
  .flow-step.hl { border-color: var(--accent); background: var(--hl); }

  /* 테이블 */
  .table-scroll {
    width: 100%;
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
    margin: 20px 0;
  }
  .table-scroll table { margin: 0; min-width: 480px; }

  /* 알림 미리보기 박스 */
  .notif-preview {
    background: #1c1c1e;
    border: 1px solid rgba(255,255,255,.12);
    border-radius: 14px;
    padding: 16px 20px;
    margin: 20px 0;
    display: flex;
    gap: 14px;
    align-items: flex-start;
    max-width: 380px;
  }
  .notif-icon {
    width: 36px;
    height: 36px;
    border-radius: 8px;
    background: #2c2c2e;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 18px;
    flex-shrink: 0;
  }
  .notif-body { flex: 1; min-width: 0; }
  .notif-app {
    font-size: 11px;
    color: rgba(255,255,255,.4);
    margin-bottom: 3px;
    display: flex;
    justify-content: space-between;
  }
  .notif-title {
    font-size: 13px;
    font-weight: 600;
    color: rgba(255,255,255,.9);
    margin-bottom: 2px;
  }
  .notif-message {
    font-size: 13px;
    color: rgba(255,255,255,.55);
  }
  .notif-sound {
    font-size: 12px;
    color: var(--text-dim);
    margin-top: 8px;
  }

  /* 핵심 정리 박스 */
  .summary {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 12px;
    padding: 28px 32px;
    margin-top: 48px;
  }
  .summary-title {
    font-size: 13px;
    font-weight: 700;
    letter-spacing: .15em;
    text-transform: uppercase;
    color: var(--accent);
    margin-bottom: 16px;
    display: block;
  }
  .summary ul { padding-left: 20px; margin: 0; }
  .summary li { color: var(--text-muted); font-size: 15px; margin-bottom: 10px; }
  .summary li strong { color: var(--accent2); }

  /* 반응형 */
  @media (max-width: 640px) {
    .compare { grid-template-columns: 1fr; }
    .level-item { flex-direction: column; }
    .flow-step { padding: 12px 16px; max-width: 100%; }
    .summary { padding: 20px; }
    .notif-preview { max-width: 100%; }
  }
&lt;/style&gt;

&lt;h2&gt;들어가며&lt;/h2&gt;

&lt;p&gt;Claude Code로 작업을 맡겨두고 다른 일을 하다 보면 &quot;언제 끝났지?&quot; 하고 화면을 계속 확인하게 됩니다. 특히 여러 프로젝트를 동시에 돌릴 때는 어느 인스턴스가 완료됐는지도 알기 어렵습니다. Android Studio 플러그인, VS Code 터미널, 일반 터미널까지 열어두면 상황은 더 복잡해집니다.&lt;/p&gt;

&lt;p&gt;저도 처음에는 그냥 터미널 탭을 왔다 갔다 하면서 확인했습니다. 그런데 이게 생각보다 흐름을 많이 끊었습니다. 아직 안 끝난 작업을 확인하러 들어갔다가 다시 원래 하던 일로 돌아오는 일이 반복됐고, 어떤 때는 Claude Code가 이미 제 응답을 기다리고 있는데도 한참 뒤에 알아차리기도 했습니다.&lt;/p&gt;

&lt;p&gt;그래서 Claude Code의 Hook 시스템으로 macOS 네이티브 알림을 붙였습니다. 제가 실제로 쓰고 있는 설정에는 &lt;strong&gt;작업 완료와 사용자 응답 요청을 서로 다른 소리로 구분하는 Hook&lt;/strong&gt;이 들어가 있고, 알림 메시지에는 프로젝트명과 시각도 함께 표시되도록 되어 있습니다. 이 글은 그 실제 설정과, 그 설정을 쓰면서 느낀 사용 흐름의 변화를 함께 정리한 기록입니다.&lt;/p&gt;

&lt;h2&gt;Hook을 붙이게 된 이유&lt;/h2&gt;

&lt;p&gt;Claude Code를 백그라운드 작업자처럼 쓰기 시작하면서 가장 먼저 부딪힌 건 완료 시점을 놓치는 문제였습니다. 작업이 30초 걸릴지 5분 걸릴지 매번 다르다 보니, 결국 사람 쪽에서 계속 상태를 확인하게 됩니다. 자동화 도구를 써 놓고 다시 수동 모니터링으로 돌아가는 셈이었습니다.&lt;/p&gt;

&lt;p&gt;제가 원한 건 거창한 대시보드가 아니었습니다. 그냥 &lt;strong&gt;&quot;끝났다&quot;와 &quot;지금 제가 답해야 한다&quot;를 확실히 구분해서 알려주는 신호&lt;/strong&gt;면 충분했습니다. Hook은 딱 그 지점에 잘 맞았습니다. Claude Code가 직접 뭔가 복잡한 UI를 제공하는 게 아니라, 특정 이벤트가 발생했을 때 제가 원하는 쉘 명령어를 붙일 수 있기 때문입니다.&lt;/p&gt;

&lt;h2&gt;Hook이 동작하는 방식&lt;/h2&gt;

&lt;p&gt;Hook은 Claude Code의 특정 이벤트가 발생할 때 쉘 명령어를 자동으로 실행하는 메커니즘입니다. Claude가 직접 알림을 띄우는 게 아니라, 이벤트가 발생하는 순간 지정해 둔 명령어가 실행되는 구조입니다.&lt;/p&gt;

&lt;div class=&quot;flow&quot;&gt;
  &lt;div class=&quot;flow-steps&quot;&gt;
    &lt;div class=&quot;flow-step&quot;&gt;
      &lt;span class=&quot;step-num&quot;&gt;1 — 이벤트 발생&lt;/span&gt;
      Claude Code가 응답을 마치거나 사용자 입력을 기다림
    &lt;/div&gt;
    &lt;div class=&quot;flow-arrow&quot;&gt;↓&lt;/div&gt;
    &lt;div class=&quot;flow-step hl&quot;&gt;
      &lt;span class=&quot;step-num&quot;&gt;2 — Hook 트리거&lt;/span&gt;
      settings.json에 등록된 명령어 실행
    &lt;/div&gt;
    &lt;div class=&quot;flow-arrow&quot;&gt;↓&lt;/div&gt;
    &lt;div class=&quot;flow-step&quot;&gt;
      &lt;span class=&quot;step-num&quot;&gt;3 — 알림 표시&lt;/span&gt;
      macOS 네이티브 알림 팝업 + 소리 재생
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;이 구조가 좋았던 이유는 설정을 한 군데만 바꾸면 된다는 점입니다. Hook은 &lt;code&gt;~/.claude/settings.json&lt;/code&gt;에 등록하므로 Android Studio 플러그인, VS Code 터미널, 일반 터미널 등 어디서 Claude Code를 띄우든 동일하게 동작합니다. 한 환경에서만 따로 맞춰야 하는 종류의 설정이 아니라는 점이 꽤 편했습니다.&lt;/p&gt;

&lt;h2&gt;실제로는 두 이벤트만 있어도 충분했다&lt;/h2&gt;

&lt;p&gt;Claude Code는 다양한 이벤트에 Hook을 걸 수 있지만, 제 현재 설정 파일에 들어 있는 알림 Hook은 두 가지뿐입니다. 작업이 끝났다는 신호와, 지금 제가 확인해야 한다는 신호입니다. 제 경험상 이 둘만 분리해도 사용 흐름은 꽤 달라졌습니다.&lt;/p&gt;

&lt;div class=&quot;level-list&quot;&gt;
  &lt;div class=&quot;level-item&quot;&gt;
    &lt;div class=&quot;level-badge&quot;&gt;&lt;span&gt;Stop&lt;/span&gt;&lt;/div&gt;
    &lt;div class=&quot;level-body&quot;&gt;
      &lt;h4&gt;작업 완료 — Claude가 응답을 마쳤을 때&lt;/h4&gt;
      &lt;p&gt;Claude가 응답 생성을 완전히 끝내고 사용자 입력을 기다리는 상태가 될 때 트리거됩니다. 코드 작성, 파일 수정, 분석 등 모든 작업이 끝난 시점입니다. &quot;이제 확인하러 가도 됩니다&quot;라는 신호로 쓰기 좋습니다.&lt;/p&gt;
    &lt;/div&gt;
  &lt;/div&gt;
  &lt;div class=&quot;level-item&quot;&gt;
    &lt;div class=&quot;level-badge&quot;&gt;&lt;span&gt;Noti&lt;/span&gt;&lt;/div&gt;
    &lt;div class=&quot;level-body&quot;&gt;
      &lt;h4&gt;응답 요청 — Claude가 사용자 입력을 기다릴 때&lt;/h4&gt;
      &lt;p&gt;Claude가 작업 중 사용자 확인이 필요한 상황에 트리거됩니다. Plan 모드에서 승인 대기, 파일 수정·명령 실행 권한 요청, 선택지 제시 등이 해당됩니다. &quot;지금 당장 확인해야 합니다&quot;라는 신호로 쓰기 좋습니다.&lt;/p&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;참고로 Hook이 지원하는 전체 이벤트는 &lt;code&gt;Stop&lt;/code&gt;, &lt;code&gt;Notification&lt;/code&gt; 외에도 &lt;code&gt;PreToolUse&lt;/code&gt;, &lt;code&gt;PostToolUse&lt;/code&gt;, &lt;code&gt;SessionStart&lt;/code&gt;, &lt;code&gt;SessionEnd&lt;/code&gt;, &lt;code&gt;UserPromptSubmit&lt;/code&gt;, &lt;code&gt;PreCompact&lt;/code&gt;, &lt;code&gt;SubagentStop&lt;/code&gt;가 있습니다. 저도 처음에는 이것저것 다 붙여볼까 생각했지만, 실제로 계속 유지하게 되는 건 단순한 설정이었습니다. 알림처럼 즉시 체감되는 자동화부터 붙이는 편이 훨씬 낫다고 느꼈습니다.&lt;/p&gt;

&lt;h2&gt;macOS 알림 명령어&lt;/h2&gt;

&lt;p&gt;macOS에서 네이티브 알림을 띄우는 가장 간단한 방법은 &lt;code&gt;osascript&lt;/code&gt;입니다. 추가 설치가 필요 없다는 점이 좋았습니다. 이 작업을 위해 별도 앱이나 메뉴바 유틸리티를 깔고 싶지는 않았기 때문입니다. 기본 제공 기능만으로 충분했습니다.&lt;/p&gt;

&lt;div class=&quot;code-wrap&quot;&gt;
  &lt;div class=&quot;code-header&quot;&gt;
    &lt;span class=&quot;code-filename&quot;&gt;기본 형태&lt;/span&gt;
    &lt;span class=&quot;code-lang&quot;&gt;Shell&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;code-body&quot; style=&quot;overflow-x:auto;-webkit-overflow-scrolling:touch;&quot;&gt;&lt;pre style=&quot;min-width:max-content;overflow-x:visible;&quot;&gt;osascript -e &lt;span class=&quot;c-str&quot;&gt;&quot;display notification \&quot;메시지\&quot; with title \&quot;제목\&quot; subtitle \&quot;부제목\&quot; sound name \&quot;Glass\&quot;&quot;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;알림에 표시할 수 있는 필드는 네 가지입니다.&lt;/p&gt;

&lt;div class=&quot;table-scroll&quot;&gt;
  &lt;table&gt;
    &lt;thead&gt;
      &lt;tr&gt;
        &lt;th&gt;필드&lt;/th&gt;
        &lt;th&gt;설명&lt;/th&gt;
        &lt;th&gt;예시&lt;/th&gt;
      &lt;/tr&gt;
    &lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;
        &lt;td&gt;&lt;code&gt;notification&lt;/code&gt;&lt;/td&gt;
        &lt;td&gt;알림 본문. 가장 눈에 잘 띄는 텍스트.&lt;/td&gt;
        &lt;td&gt;&lt;code&gt;my-android-app 완료&lt;/code&gt;&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
        &lt;td&gt;&lt;code&gt;title&lt;/code&gt;&lt;/td&gt;
        &lt;td&gt;앱 이름 위치에 표시되는 굵은 텍스트.&lt;/td&gt;
        &lt;td&gt;&lt;code&gt;Claude Code&lt;/code&gt;&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
        &lt;td&gt;&lt;code&gt;subtitle&lt;/code&gt;&lt;/td&gt;
        &lt;td&gt;title 아래 작게 표시되는 보조 텍스트.&lt;/td&gt;
        &lt;td&gt;&lt;code&gt;14:32&lt;/code&gt;&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
        &lt;td&gt;&lt;code&gt;sound name&lt;/code&gt;&lt;/td&gt;
        &lt;td&gt;알림 소리. &lt;code&gt;/System/Library/Sounds/&lt;/code&gt;의 파일명.&lt;/td&gt;
        &lt;td&gt;&lt;code&gt;Glass&lt;/code&gt;, &lt;code&gt;Ping&lt;/code&gt;&lt;/td&gt;
      &lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;
&lt;/div&gt;

&lt;h3&gt;프로젝트명과 시각 자동 삽입&lt;/h3&gt;

&lt;p&gt;제 현재 설정에서 눈에 띄는 포인트는 프로젝트명을 자동으로 넣는 부분입니다. 실제 명령어에 &lt;code&gt;$(basename $PWD)&lt;/code&gt;와 &lt;code&gt;$(date +%H:%M)&lt;/code&gt;이 들어가 있어서, 알림에 현재 작업 디렉토리명과 시각이 함께 표시됩니다. 여러 Claude Code 인스턴스를 같이 띄울 때는 알림이 왔다는 사실보다 &lt;strong&gt;어느 프로젝트에서 온 알림인지&lt;/strong&gt;가 더 중요하다고 느꼈는데, 저는 이 방식이 그 문제를 가장 단순하게 풀어준다고 봤습니다.&lt;/p&gt;

&lt;div class=&quot;code-wrap&quot;&gt;
  &lt;div class=&quot;code-header&quot;&gt;
    &lt;span class=&quot;code-filename&quot;&gt;프로젝트명 + 시각 포함&lt;/span&gt;
    &lt;span class=&quot;code-lang&quot;&gt;Shell&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;code-body&quot; style=&quot;overflow-x:auto;-webkit-overflow-scrolling:touch;&quot;&gt;&lt;pre style=&quot;min-width:max-content;overflow-x:visible;&quot;&gt;osascript -e &lt;span class=&quot;c-str&quot;&gt;&quot;display notification \&quot;$(basename $PWD) 완료\&quot; with title \&quot;Claude Code\&quot; subtitle \&quot;$(date +%H:%M)\&quot; sound name \&quot;Glass\&quot;&quot;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;&lt;code&gt;$PWD&lt;/code&gt;는 Hook이 실행되는 시점의 작업 디렉토리를 반환합니다. Claude Code가 실행된 폴더가 곧 &lt;code&gt;$PWD&lt;/code&gt;가 되므로, 같은 명령어를 어떤 프로젝트에서든 그대로 쓸 수 있습니다. 따로 프로젝트명을 하드코딩할 필요가 없어서 실제 운용할 때 훨씬 덜 번거롭습니다.&lt;/p&gt;

&lt;div class=&quot;compare&quot;&gt;
  &lt;div class=&quot;compare-card bad&quot;&gt;
    &lt;span class=&quot;compare-label&quot;&gt;단순 알림&lt;/span&gt;
    &lt;p&gt;알림이 울려도 화면을 봐야 어느 프로젝트인지 알 수 있습니다. 인스턴스가 많을수록 혼란스러워집니다.&lt;/p&gt;
  &lt;/div&gt;
  &lt;div class=&quot;compare-card good&quot;&gt;
    &lt;span class=&quot;compare-label&quot;&gt;프로젝트명 포함 알림&lt;/span&gt;
    &lt;p&gt;알림 배너에 &lt;code&gt;my-android-app 완료&lt;/code&gt;처럼 프로젝트명이 표시되어 화면을 보지 않아도 파악할 수 있습니다.&lt;/p&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;h2&gt;두 가지 Hook 설정&lt;/h2&gt;

&lt;p&gt;제 실제 설정에서는 &lt;code&gt;Stop&lt;/code&gt;에 &lt;code&gt;Glass&lt;/code&gt;, &lt;code&gt;Notification&lt;/code&gt;에 &lt;code&gt;Ping&lt;/code&gt;을 연결해 두었습니다. 그 이유는 제 사용 경험 때문입니다. 화면을 보고 있지 않을 때는 텍스트보다 소리가 먼저 들어오는데, 저는 &lt;strong&gt;Glass 소리(맑고 길게)&lt;/strong&gt;를 작업 완료 신호로, &lt;strong&gt;Ping 소리(짧고 날카롭게)&lt;/strong&gt;를 응답 요청 신호로 두는 쪽이 가장 직관적이었습니다.&lt;/p&gt;

&lt;h3&gt;Stop — 작업 완료 알림&lt;/h3&gt;

&lt;div class=&quot;notif-preview&quot;&gt;
  &lt;div class=&quot;notif-icon&quot;&gt; ️&lt;/div&gt;
  &lt;div class=&quot;notif-body&quot;&gt;
    &lt;div class=&quot;notif-app&quot;&gt;&lt;span&gt;Terminal&lt;/span&gt;&lt;span&gt;14:32&lt;/span&gt;&lt;/div&gt;
    &lt;div class=&quot;notif-title&quot;&gt;Claude Code&lt;/div&gt;
    &lt;div class=&quot;notif-message&quot;&gt;my-android-app 완료&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;
&lt;p class=&quot;notif-sound&quot;&gt;  Glass 사운드 재생&lt;/p&gt;

&lt;h3&gt;Notification — 응답 요청 알림&lt;/h3&gt;

&lt;div class=&quot;notif-preview&quot;&gt;
  &lt;div class=&quot;notif-icon&quot;&gt; ️&lt;/div&gt;
  &lt;div class=&quot;notif-body&quot;&gt;
    &lt;div class=&quot;notif-app&quot;&gt;&lt;span&gt;Terminal&lt;/span&gt;&lt;span&gt;14:33&lt;/span&gt;&lt;/div&gt;
    &lt;div class=&quot;notif-title&quot;&gt;Claude Code ⚠️&lt;/div&gt;
    &lt;div class=&quot;notif-message&quot;&gt;my-android-app — 응답이 필요합니다&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;
&lt;p class=&quot;notif-sound&quot;&gt;  Ping 사운드 재생&lt;/p&gt;

&lt;h2&gt;제가 현재 쓰는 settings.json 설정&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;~/.claude/settings.json&lt;/code&gt;에 &lt;code&gt;hooks&lt;/code&gt; 키를 추가하면 됩니다. 기존 설정(플러그인, statusLine 등)은 그대로 두고 새 키만 넣으면 됩니다. 아래는 제가 지금 그대로 쓰고 있는 형태입니다.&lt;/p&gt;

&lt;div class=&quot;code-wrap&quot;&gt;
  &lt;div class=&quot;code-header&quot;&gt;
    &lt;span class=&quot;code-filename&quot;&gt;~/.claude/settings.json&lt;/span&gt;
    &lt;span class=&quot;code-lang&quot;&gt;JSON&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;code-body&quot; style=&quot;overflow-x:auto;-webkit-overflow-scrolling:touch;&quot;&gt;&lt;pre style=&quot;min-width:max-content;overflow-x:visible;&quot;&gt;{
  &lt;span class=&quot;c-key&quot;&gt;&quot;hooks&quot;&lt;/span&gt;: {
    &lt;span class=&quot;c-key&quot;&gt;&quot;Stop&quot;&lt;/span&gt;: [
      {
        &lt;span class=&quot;c-key&quot;&gt;&quot;hooks&quot;&lt;/span&gt;: [
          {
            &lt;span class=&quot;c-key&quot;&gt;&quot;type&quot;&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;&quot;command&quot;&lt;/span&gt;,
            &lt;span class=&quot;c-key&quot;&gt;&quot;command&quot;&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;&quot;osascript -e \&quot;display notification \\\&quot;$(basename $PWD) 완료\\\&quot; with title \\\&quot;Claude Code\\\&quot; subtitle \\\&quot;$(date +%H:%M)\\\&quot; sound name \\\&quot;Glass\\\&quot;\&quot;&quot;&lt;/span&gt;
          }
        ]
      }
    ],
    &lt;span class=&quot;c-key&quot;&gt;&quot;Notification&quot;&lt;/span&gt;: [
      {
        &lt;span class=&quot;c-key&quot;&gt;&quot;hooks&quot;&lt;/span&gt;: [
          {
            &lt;span class=&quot;c-key&quot;&gt;&quot;type&quot;&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;&quot;command&quot;&lt;/span&gt;,
            &lt;span class=&quot;c-key&quot;&gt;&quot;command&quot;&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;&quot;osascript -e \&quot;display notification \\\&quot;$(basename $PWD) — 응답이 필요합니다\\\&quot; with title \\\&quot;Claude Code ⚠️\\\&quot; subtitle \\\&quot;$(date +%H:%M)\\\&quot; sound name \\\&quot;Ping\\\&quot;\&quot;&quot;&lt;/span&gt;
          }
        ]
      }
    ]
  }
}&lt;/pre&gt;&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;설정 파일을 저장한 후 Claude Code를 재시작하면 적용됩니다. Claude Code는 시작 시 &lt;code&gt;settings.json&lt;/code&gt;을 읽기 때문에 실행 중 수정해도 재시작이 필요합니다. 처음에는 저장만 하면 바로 반영될 줄 알았는데, 이 부분은 한 번 놓치기 쉬웠습니다.&lt;/p&gt;

&lt;p&gt;다만 실제 설정을 그대로 적으면 지금처럼 &lt;code&gt;&quot;command&quot;&lt;/code&gt; 한 줄이 길어질 수밖에 없습니다. JSON 문자열 자체는 예쁘게 여러 줄로 나누기 어렵기 때문입니다. 가독성을 더 챙기고 싶다면 알림 명령어를 별도 쉘 스크립트로 빼는 쪽이 낫습니다.&lt;/p&gt;

&lt;h3&gt;가독성을 우선하면 스크립트로 분리하는 편이 낫다&lt;/h3&gt;

&lt;p&gt;예를 들어 &lt;code&gt;settings.json&lt;/code&gt;에서는 스크립트만 호출하고, 실제 &lt;code&gt;osascript&lt;/code&gt; 명령은 별도 파일로 분리할 수 있습니다. 이렇게 하면 설정 파일은 짧아지고, 알림 문구를 나중에 수정할 때도 훨씬 편합니다.&lt;/p&gt;

&lt;div class=&quot;code-wrap&quot;&gt;
  &lt;div class=&quot;code-header&quot;&gt;
    &lt;span class=&quot;code-filename&quot;&gt;~/.claude/settings.json&lt;/span&gt;
    &lt;span class=&quot;code-lang&quot;&gt;JSON&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;code-body&quot; style=&quot;overflow-x:auto;-webkit-overflow-scrolling:touch;&quot;&gt;&lt;pre style=&quot;min-width:max-content;overflow-x:visible;&quot;&gt;{
  &lt;span class=&quot;c-key&quot;&gt;&quot;hooks&quot;&lt;/span&gt;: {
    &lt;span class=&quot;c-key&quot;&gt;&quot;Stop&quot;&lt;/span&gt;: [
      {
        &lt;span class=&quot;c-key&quot;&gt;&quot;hooks&quot;&lt;/span&gt;: [
          {
            &lt;span class=&quot;c-key&quot;&gt;&quot;type&quot;&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;&quot;command&quot;&lt;/span&gt;,
            &lt;span class=&quot;c-key&quot;&gt;&quot;command&quot;&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;&quot;~/.claude/hooks/notify-stop.sh&quot;&lt;/span&gt;
          }
        ]
      }
    ],
    &lt;span class=&quot;c-key&quot;&gt;&quot;Notification&quot;&lt;/span&gt;: [
      {
        &lt;span class=&quot;c-key&quot;&gt;&quot;hooks&quot;&lt;/span&gt;: [
          {
            &lt;span class=&quot;c-key&quot;&gt;&quot;type&quot;&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;&quot;command&quot;&lt;/span&gt;,
            &lt;span class=&quot;c-key&quot;&gt;&quot;command&quot;&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;&quot;~/.claude/hooks/notify-input.sh&quot;&lt;/span&gt;
          }
        ]
      }
    ]
  }
}&lt;/pre&gt;&lt;/div&gt;
&lt;/div&gt;

&lt;div class=&quot;code-wrap&quot;&gt;
  &lt;div class=&quot;code-header&quot;&gt;
    &lt;span class=&quot;code-filename&quot;&gt;~/.claude/hooks/notify-stop.sh&lt;/span&gt;
    &lt;span class=&quot;code-lang&quot;&gt;Shell&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;code-body&quot; style=&quot;overflow-x:auto;-webkit-overflow-scrolling:touch;&quot;&gt;&lt;pre style=&quot;min-width:max-content;overflow-x:visible;&quot;&gt;osascript -e &lt;span class=&quot;c-str&quot;&gt;&quot;display notification \&quot;$(basename $PWD) 완료\&quot; with title \&quot;Claude Code\&quot; subtitle \&quot;$(date +%H:%M)\&quot; sound name \&quot;Glass\&quot;&quot;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/div&gt;

&lt;div class=&quot;code-wrap&quot;&gt;
  &lt;div class=&quot;code-header&quot;&gt;
    &lt;span class=&quot;code-filename&quot;&gt;~/.claude/hooks/notify-input.sh&lt;/span&gt;
    &lt;span class=&quot;code-lang&quot;&gt;Shell&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;code-body&quot; style=&quot;overflow-x:auto;-webkit-overflow-scrolling:touch;&quot;&gt;&lt;pre style=&quot;min-width:max-content;overflow-x:visible;&quot;&gt;osascript -e &lt;span class=&quot;c-str&quot;&gt;&quot;display notification \&quot;$(basename $PWD) — 응답이 필요합니다\&quot; with title \&quot;Claude Code ⚠️\&quot; subtitle \&quot;$(date +%H:%M)\&quot; sound name \&quot;Ping\&quot;&quot;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/div&gt;

&lt;h2&gt;첫 실행 시 알림 권한 허용&lt;/h2&gt;

&lt;p&gt;처음 Hook이 실행될 때 macOS가 Terminal(또는 iTerm2)의 알림 권한을 요청합니다. &lt;strong&gt;허용&lt;/strong&gt;을 눌러야 이후 알림이 표시됩니다. 저는 처음에 &quot;명령어는 맞는데 왜 알림이 안 뜨지?&quot; 하고 잠깐 헷갈렸는데, 원인은 대부분 여기였습니다. 권한을 실수로 거부했거나 나중에 변경하고 싶다면 &lt;code&gt;시스템 설정 → 알림 → Terminal&lt;/code&gt;에서 조정할 수 있습니다.&lt;/p&gt;

&lt;div class=&quot;level-list&quot;&gt;
  &lt;div class=&quot;level-item&quot;&gt;
    &lt;div class=&quot;level-badge&quot;&gt;&lt;span&gt;배너&lt;/span&gt;&lt;/div&gt;
    &lt;div class=&quot;level-body&quot;&gt;
      &lt;h4&gt;배너 방식 권장&lt;/h4&gt;
      &lt;p&gt;화면 오른쪽 상단에 잠깐 떴다가 자동으로 사라집니다. 알림 센터에도 기록이 남아 나중에 확인할 수 있습니다. 작업에 방해가 적으면서도 눈에 잘 띕니다.&lt;/p&gt;
    &lt;/div&gt;
  &lt;/div&gt;
  &lt;div class=&quot;level-item&quot;&gt;
    &lt;div class=&quot;level-badge&quot;&gt;&lt;span&gt;알림&lt;br&gt;센터&lt;/span&gt;&lt;/div&gt;
    &lt;div class=&quot;level-body&quot;&gt;
      &lt;h4&gt;알림 기록 확인&lt;/h4&gt;
      &lt;p&gt;화면 오른쪽 상단 시계를 클릭하면 지나간 알림을 모두 볼 수 있습니다. 여러 인스턴스가 순서대로 완료됐을 때 타임라인을 확인하는 용도로 유용합니다.&lt;/p&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;h2&gt;사운드는 이렇게 골랐다&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/System/Library/Sounds/&lt;/code&gt;에 있는 파일이라면 어떤 소리도 쓸 수 있습니다. &lt;code&gt;sound name&lt;/code&gt;에는 확장자 없이 파일명만 적으면 됩니다. 실제 제 설정 파일에는 &lt;code&gt;Glass&lt;/code&gt;와 &lt;code&gt;Ping&lt;/code&gt;이 들어 있습니다. 아래 표의 다른 항목은 선택 가능한 예시들이고, 왜 저는 이 조합을 쓰고 있는지도 함께 정리해봤습니다.&lt;/p&gt;

&lt;div class=&quot;table-scroll&quot;&gt;
  &lt;table&gt;
    &lt;thead&gt;
      &lt;tr&gt;
        &lt;th&gt;사운드&lt;/th&gt;
        &lt;th&gt;특징&lt;/th&gt;
        &lt;th&gt;추천 용도&lt;/th&gt;
      &lt;/tr&gt;
    &lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;
        &lt;td&gt;&lt;code&gt;Glass&lt;/code&gt;&lt;/td&gt;
        &lt;td&gt;맑고 길게 울리는 종소리&lt;/td&gt;
        &lt;td&gt;작업 완료 (Stop)&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
        &lt;td&gt;&lt;code&gt;Ping&lt;/code&gt;&lt;/td&gt;
        &lt;td&gt;짧고 날카로운 금속음&lt;/td&gt;
        &lt;td&gt;응답 요청 (Notification)&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
        &lt;td&gt;&lt;code&gt;Purr&lt;/code&gt;&lt;/td&gt;
        &lt;td&gt;부드럽고 낮은 진동음&lt;/td&gt;
        &lt;td&gt;조용한 환경에서 작업 완료&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
        &lt;td&gt;&lt;code&gt;Funk&lt;/code&gt;&lt;/td&gt;
        &lt;td&gt;묵직하고 낮은 타격음&lt;/td&gt;
        &lt;td&gt;오류나 주의가 필요한 경우&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
        &lt;td&gt;&lt;code&gt;Tink&lt;/code&gt;&lt;/td&gt;
        &lt;td&gt;아주 짧고 가벼운 틱 소리&lt;/td&gt;
        &lt;td&gt;빈번한 이벤트에서 덜 거슬리게&lt;/td&gt;
      &lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;
&lt;/div&gt;

&lt;h2&gt;정리하며&lt;/h2&gt;

&lt;p&gt;이 설정을 붙이고 나서 가장 크게 달라진 건 Claude Code를 덜 감시하게 됐다는 점입니다. 예전에는 일이 끝났는지 확인하려고 제가 먼저 Claude Code를 들여다봤다면, 지금은 정말 필요한 순간에만 알림이 저를 부릅니다. 작은 차이 같지만 집중이 꽤 덜 끊깁니다.&lt;/p&gt;

&lt;p&gt;정리하면, 제 실제 설정 파일에는 &lt;code&gt;Stop&lt;/code&gt;과 &lt;code&gt;Notification&lt;/code&gt; 두 이벤트만 들어 있고, 각각 &lt;code&gt;Glass&lt;/code&gt;와 &lt;code&gt;Ping&lt;/code&gt; 소리가 연결되어 있습니다. 또 &lt;code&gt;basename $PWD&lt;/code&gt;를 써서 프로젝트명이 자동으로 들어가게 해 두었습니다. 여기서부터는 사실 설명이 아니라 제 경험인데, 저는 이 정도만으로도 Claude Code를 한결 덜 감시하게 됐습니다. 특히 여러 작업을 같이 돌릴 때 체감이 더 컸습니다.&lt;/p&gt;

&lt;div class=&quot;summary&quot;&gt;
  &lt;span class=&quot;summary-title&quot;&gt;핵심 정리&lt;/span&gt;
  &lt;ul&gt;
    &lt;li&gt;&lt;strong&gt;제가 Hook을 붙인 이유는 Claude Code를 덜 감시하고 싶어서였습니다.&lt;/strong&gt; 완료 여부를 확인하러 직접 들어가는 흐름이 줄어들면 작업 집중이 덜 끊깁니다.&lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;&lt;code&gt;Stop&lt;/code&gt;은 작업 완료, &lt;code&gt;Notification&lt;/code&gt;은 응답 요청입니다.&lt;/strong&gt; 두 이벤트만 나눠도 실제 사용성은 꽤 좋아집니다.&lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;&lt;code&gt;basename $PWD&lt;/code&gt;로 어느 프로젝트인지 자동 표시합니다.&lt;/strong&gt; 이 부분은 실제 설정 명령어에도 들어 있고, 여러 인스턴스를 같이 띄울 때 특히 유용했습니다.&lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;소리 구분은 생각보다 효과가 큽니다.&lt;/strong&gt; 실제 설정은 Glass와 Ping을 쓰고 있고, 저는 이 조합이 가장 직관적이었습니다.&lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;설정은 단순해야 오래 갑니다.&lt;/strong&gt; 이건 제 결론입니다. 여러 이벤트를 다 붙이기보다, 먼저 체감이 큰 알림 자동화부터 두는 편이 유지하기 쉬웠습니다.&lt;/li&gt;
  &lt;/ul&gt;
&lt;/div&gt;</description>
      <category>AI</category>
      <author>JooMan</author>
      <guid isPermaLink="true">https://devgeek.tistory.com/166</guid>
      <comments>https://devgeek.tistory.com/166#entry166comment</comments>
      <pubDate>Tue, 31 Mar 2026 20:08:14 +0900</pubDate>
    </item>
    <item>
      <title>Claude Code Skill 사용 경험 - 왜 user-invocable: false로 설계했나</title>
      <link>https://devgeek.tistory.com/165</link>
      <description>&lt;style&gt;
  /* 코드 블록 래퍼 (파일명 헤더 포함) */
  .code-wrap {
    margin: 28px 0;
    border-radius: 10px;
    overflow: hidden;
    border: 1px solid var(--border);
    max-width: 100%;
  }
  .code-header {
    background: var(--surface2);
    padding: 10px 18px;
    display: flex;
    justify-content: space-between;
    align-items: center;
    border-bottom: 1px solid var(--border);
  }
  .code-filename {
    font-family: 'JetBrains Mono', monospace;
    font-size: 12px;
    color: var(--accent2);
  }
  .code-lang {
    font-size: 11px;
    color: var(--text-dim);
    text-transform: uppercase;
    letter-spacing: .08em;
  }
  .code-wrap pre { margin: 0; border-radius: 0; border: none; }

  /* 문법 색상 */
  .c-comment { color: #6e7681; }
  .c-key     { color: #a78bfa; }
  .c-str     { color: #7ee787; }
  .c-head    { color: #79c0ff; }
  .c-section { color: #ffa657; }

  /* 비교 카드 */
  .compare {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 16px;
    margin: 28px 0;
  }
  .compare-card {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 10px;
    padding: 20px;
    min-width: 0;
  }
  .compare-card.bad  { border-color: rgba(248,113,97,.3); }
  .compare-card.good { border-color: rgba(52,211,153,.3); }
  .compare-label {
    font-size: 11px;
    letter-spacing: .1em;
    text-transform: uppercase;
    margin-bottom: 12px;
    font-weight: 600;
    display: block;
  }
  .compare-card.bad  .compare-label { color: #f87171; }
  .compare-card.good .compare-label { color: #34d399; }
  .compare-card p { font-size: 14.5px; color: var(--text-muted); margin: 0; }

  /* 레벨 카드 */
  .level-list {
    margin: 24px 0;
    display: flex;
    flex-direction: column;
    gap: 12px;
  }
  .level-item {
    display: flex;
    gap: 20px;
    padding: 20px;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 10px;
    align-items: flex-start;
  }
  .level-badge {
    flex-shrink: 0;
    width: 56px;
    height: 56px;
    border-radius: 10px;
    background: var(--hl);
    border: 1px solid var(--accent);
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    font-size: 10px;
    color: var(--text-dim);
    text-transform: uppercase;
    letter-spacing: .05em;
  }
  .level-badge span {
    font-size: 18px;
    font-weight: 700;
    color: var(--accent2);
    line-height: 1;
    margin-bottom: 2px;
  }
  .level-body { min-width: 0; }
  .level-body h4 { font-size: 15px; font-weight: 600; color: #fff; margin: 0 0 6px; }
  .level-body p  { font-size: 14px; color: var(--text-muted); margin: 0; }

  /* 동작 흐름 다이어그램 */
  .flow {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 12px;
    padding: 32px 24px;
    margin: 28px 0;
    text-align: center;
  }
  .flow-steps {
    display: flex;
    flex-direction: column;
    gap: 8px;
    align-items: center;
  }
  .flow-step {
    background: var(--code-bg);
    border: 1px solid var(--border);
    border-radius: 8px;
    padding: 12px 28px;
    font-size: 14px;
    color: var(--text);
    width: 100%;
    max-width: 480px;
    text-align: left;
    box-sizing: border-box;
  }
  .flow-step .step-num {
    font-size: 11px;
    color: var(--accent);
    text-transform: uppercase;
    letter-spacing: .1em;
    display: block;
    margin-bottom: 3px;
  }
  .flow-arrow { color: var(--text-dim); font-size: 18px; line-height: 1; }
  .flow-step.hl { border-color: var(--accent); background: var(--hl); }

  /* 테이블 — 좁은 화면에서 가로 스크롤 */
  .table-scroll {
    width: 100%;
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
    margin: 20px 0;
  }
  .table-scroll table { margin: 0; min-width: 480px; }

  /* 핵심 정리 박스 */
  .summary {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 12px;
    padding: 28px 32px;
    margin-top: 48px;
  }
  .summary-title {
    font-size: 13px;
    font-weight: 700;
    letter-spacing: .15em;
    text-transform: uppercase;
    color: var(--accent);
    margin-bottom: 16px;
    display: block;
  }
  .summary ul { padding-left: 20px; margin: 0; }
  .summary li { color: var(--text-muted); font-size: 15px; margin-bottom: 10px; }
  .summary li strong { color: var(--accent2); }

  /* 반응형 */
  @media (max-width: 640px) {
    .compare { grid-template-columns: 1fr; }
    .level-item { flex-direction: column; }
    .flow-step { padding: 12px 16px; max-width: 100%; }
    .summary { padding: 20px; }
  }
&lt;/style&gt;

&lt;h2&gt;들어가며&lt;/h2&gt;
&lt;p&gt;Claude Skill을 처음 만들 때는 보통 &quot;이걸 어떻게 호출하지?&quot;부터 생각하게 됩니다. 저도 처음에는 비슷했습니다. 그런데 디자인 시스템 관련 Skill을 정리하면서 방향이 조금 달라졌습니다. 제가 원한 것은 호출 가능한 기능 세트가 아니라, &lt;strong&gt;Claude가 언제 물어봐도 같은 기준으로 답하게 만드는 내부 판단 레이어&lt;/strong&gt;였습니다.&lt;/p&gt;
&lt;p&gt;그래서 `design-system-color`, `design-system-spacing`, `design-system-shape`, `design-system-typography` 같은 Skill을 만들면서 일부러 &lt;code&gt;user-invocable: false&lt;/code&gt;를 넣었습니다. 이 글은 왜 그렇게 했는지, 그리고 그 선택이 디자인 시스템을 수정하거나 확장할 때 어떤 차이를 만들었는지에 대한 기록입니다.&lt;/p&gt;

&lt;h2&gt;왜 직접 호출 가능한 Skill로 만들지 않았나&lt;/h2&gt;
&lt;p&gt;겉으로 보기에는 Skill을 직접 호출할 수 있게 열어두는 편이 더 친절해 보입니다. 하지만 제 목적에는 오히려 반대였습니다. 사용자가 스킬 이름을 기억하고 명령어처럼 불러야 하는 구조보다, &lt;strong&gt;그냥 자연스럽게 질문하면 Claude가 내부 기준을 참고해 답하는 구조&lt;/strong&gt;가 더 맞았습니다.&lt;/p&gt;
&lt;p&gt;특히 디자인 시스템은 단순히 질문에 답하는 주제가 아니라, 시간이 지나면서 계속 변경되고 보완되는 규칙 집합에 가깝습니다. &quot;이 spacing token을 바꿔도 될까?&quot;, &quot;새로운 semantic color를 추가할 때 기준이 뭐지?&quot;, &quot;radius 규칙을 컴포넌트 전반에 어떻게 반영하지?&quot; 같은 상황에서는 사용자가 스킬 이름을 떠올리는 것보다, Claude가 내부적으로 기준을 안정적으로 참조하는 편이 훨씬 중요했습니다.&lt;/p&gt;

&lt;div class=&quot;compare&quot;&gt;
  &lt;div class=&quot;compare-card bad&quot;&gt;
    &lt;span class=&quot;compare-label&quot;&gt;직접 호출 중심&lt;/span&gt;
    &lt;p&gt;사용자가 스킬 이름과 사용법을 알아야 합니다. 결국 기능을 노출하는 쪽에 초점이 맞고, 질의 경험은 오히려 불필요하게 복잡해질 수 있습니다.&lt;/p&gt;
  &lt;/div&gt;
  &lt;div class=&quot;compare-card good&quot;&gt;
    &lt;span class=&quot;compare-label&quot;&gt;내부 기준 중심&lt;/span&gt;
    &lt;p&gt;사용자는 평소처럼 자연어로 질문하고, Claude만 내부적으로 Skill을 참고합니다. 인터페이스는 단순해지고, 답변 기준은 더 안정적으로 유지됩니다.&lt;/p&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;h2&gt;&lt;code&gt;user-invocable: false&lt;/code&gt;에 담았던 의도&lt;/h2&gt;
&lt;p&gt;이 설정은 단순히 &quot;숨겨두기&quot;가 아니었습니다. 제 경우에는 &lt;strong&gt;이 Skill의 역할을 기능이 아니라 기준으로 한정하려는 선언&lt;/strong&gt;에 가까웠습니다. 사용자가 직접 호출하는 순간, 이 Skill은 운영 문서보다 명령형 도구처럼 인식되기 쉽습니다.&lt;/p&gt;
&lt;p&gt;그렇게 되면 문제가 두 가지 생깁니다. 하나는 사용법 자체를 또 학습해야 한다는 점이고, 다른 하나는 &quot;언제 이 스킬을 써야 하지?&quot;라는 메타 질문이 새로 생긴다는 점입니다. 저는 디자인 시스템을 다룰 때 필요한 것이 호출 절차가 아니라, 변경이나 확장 상황에서도 같은 기준을 유지하는 일이라고 봤기 때문에 그 부담을 사용자 쪽에 넘기고 싶지 않았습니다.&lt;/p&gt;

&lt;div class=&quot;code-wrap&quot;&gt;
  &lt;div class=&quot;code-header&quot;&gt;
    &lt;span class=&quot;code-filename&quot;&gt;.claude/skills/design-system-color/SKILL.md&lt;/span&gt;
    &lt;span class=&quot;code-lang&quot;&gt;YAML&lt;/span&gt;
  &lt;/div&gt;
  &lt;pre&gt;&lt;span class=&quot;c-comment&quot;&gt;---&lt;/span&gt;
&lt;span class=&quot;c-section&quot;&gt;name&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;design-system-color&lt;/span&gt;
&lt;span class=&quot;c-section&quot;&gt;description&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;Nevera 컬러 디자인 시스템 가이드&lt;/span&gt;
&lt;span class=&quot;c-section&quot;&gt;user-invocable&lt;/span&gt;: &lt;span class=&quot;c-key&quot;&gt;false&lt;/span&gt;
&lt;span class=&quot;c-comment&quot;&gt;---&lt;/span&gt;

당신은 Nevera의 컬러 디자인 시스템 가이드다.
역할은 프로젝트의 시맨틱 토큰 구조를 기준으로
컬러 사용을 일관되게 유지하는 것이다.&lt;/pre&gt;
&lt;/div&gt;

&lt;h2&gt;내가 원한 사용 방식은 이런 흐름이었다&lt;/h2&gt;
&lt;p&gt;핵심은 사용자가 Skill의 존재를 몰라도 되는 흐름이었습니다. 개발자는 평범하게 질문하고, Claude는 필요한 순간에만 내부 규칙을 가져와 판단합니다. 이 방식은 디자인 시스템처럼 규칙이 계속 수정되고 확장되는 주제에서 특히 자연스러웠습니다.&lt;/p&gt;

&lt;div class=&quot;flow&quot;&gt;
  &lt;div class=&quot;flow-steps&quot;&gt;
    &lt;div class=&quot;flow-step&quot;&gt;
      &lt;span class=&quot;step-num&quot;&gt;Step 1 · 개발자 질문&lt;/span&gt;
      &quot;이 카드 내부 패딩은 어떤 spacing token이 맞아?&quot; 또는 &quot;새 semantic color를 추가할 때 기준이 뭐야?&quot;처럼 자연어로 그냥 물어봅니다.
    &lt;/div&gt;
    &lt;div class=&quot;flow-arrow&quot;&gt;↓&lt;/div&gt;
    &lt;div class=&quot;flow-step&quot;&gt;
      &lt;span class=&quot;step-num&quot;&gt;Step 2 · Claude 판단&lt;/span&gt;
      질문이 spacing 검토인지, 컬러 규칙 추가인지 같은 맥락을 파악하고 대응되는 Skill을 내부적으로 선택합니다.
    &lt;/div&gt;
    &lt;div class=&quot;flow-arrow&quot;&gt;↓&lt;/div&gt;
    &lt;div class=&quot;flow-step hl&quot;&gt;
      &lt;span class=&quot;step-num&quot;&gt;Step 3 · 기준 참조&lt;/span&gt;
      `NeveraTheme.spacing.*`, 하드코딩 금지, `XxxDimension` 분리 규칙 같은 내용을 기준으로 답변을 구성합니다.
    &lt;/div&gt;
    &lt;div class=&quot;flow-arrow&quot;&gt;↓&lt;/div&gt;
    &lt;div class=&quot;flow-step&quot;&gt;
      &lt;span class=&quot;step-num&quot;&gt;Step 4 · 일관된 응답&lt;/span&gt;
      사용자는 스킬을 호출하지 않았지만, 결과는 항상 같은 규칙에 기반한 답변을 받습니다.
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;h2&gt;실제로는 유지보수 측면에서도 이 방식이 더 좋았다&lt;/h2&gt;
&lt;p&gt;이 구조의 장점은 사용 경험만 단순해지는 데 있지 않았습니다. 유지보수 포인트도 분명해졌습니다. 디자인 시스템 규칙이 바뀌거나 새로운 기준을 추가해야 할 때, 프롬프트 예시를 여러 군데 고치는 대신 Skill 파일만 수정하면 됩니다. 질문 방식은 그대로 두고 내부 기준만 최신화할 수 있다는 점이 컸습니다.&lt;/p&gt;

&lt;h3&gt;질문 인터페이스와 규칙 저장소를 분리할 수 있다&lt;/h3&gt;
&lt;p&gt;개발자는 계속 자연어로 묻습니다. 반면 운영자는 Skill 문서에서 판단 기준만 관리하면 됩니다. 이 분리가 되면 &quot;어떻게 물어봐야 좋은 답이 나오는지&quot;를 교육하는 비용도 줄어들고, 규칙 개정이 생겨도 수정 지점이 명확해집니다.&lt;/p&gt;

&lt;h3&gt;규칙 변경이 생겨도 사용 방식은 바뀌지 않는다&lt;/h3&gt;
&lt;p&gt;예를 들어 spacing 토큰이나 typography 선택 기준이 조금 바뀌더라도, 사용자는 여전히 같은 방식으로 질문하면 됩니다. 바뀌는 것은 Skill 내부 지식뿐이고, 외부 인터페이스는 고정됩니다. 디자인 시스템처럼 계속 다듬어야 하는 주제에서는 이 고정된 인터페이스가 특히 중요하다고 느꼈습니다.&lt;/p&gt;

&lt;div class=&quot;level-list&quot;&gt;
  &lt;div class=&quot;level-item&quot;&gt;
    &lt;div class=&quot;level-badge&quot;&gt;&lt;span&gt;UX&lt;/span&gt;질의&lt;/div&gt;
    &lt;div class=&quot;level-body&quot;&gt;
      &lt;h4&gt;사용자는 자연어만 기억하면 된다&lt;/h4&gt;
      &lt;p&gt;스킬 이름, 호출 문법, 인수 형식을 외울 필요가 없습니다. 그냥 디자인 시스템을 수정하거나 검토할 때 필요한 질문을 던지면 됩니다.&lt;/p&gt;
    &lt;/div&gt;
  &lt;/div&gt;
  &lt;div class=&quot;level-item&quot;&gt;
    &lt;div class=&quot;level-badge&quot;&gt;&lt;span&gt;OPS&lt;/span&gt;기준&lt;/div&gt;
    &lt;div class=&quot;level-body&quot;&gt;
      &lt;h4&gt;운영자는 Skill 문서만 업데이트하면 된다&lt;/h4&gt;
      &lt;p&gt;규칙이 바뀌거나 새 기준이 추가될 때 수정 대상이 분산되지 않습니다. 지식이 한곳에 모여 있으니 관리가 단순해집니다.&lt;/p&gt;
    &lt;/div&gt;
  &lt;/div&gt;
  &lt;div class=&quot;level-item&quot;&gt;
    &lt;div class=&quot;level-badge&quot;&gt;&lt;span&gt;AI&lt;/span&gt;응답&lt;/div&gt;
    &lt;div class=&quot;level-body&quot;&gt;
      &lt;h4&gt;Claude는 더 일관된 기준으로 답한다&lt;/h4&gt;
      &lt;p&gt;그때그때 떠올린 설명이 아니라, 명시된 판단 절차와 금지 규칙을 따라가게 됩니다.&lt;/p&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;h2&gt;결국 이건 숨겨진 기능이 아니라 설계된 제약이었다&lt;/h2&gt;
&lt;p&gt;AI를 쓸 때는 기능을 더 열어두는 것이 무조건 좋아 보일 때가 많습니다. 하지만 실제로는 &lt;strong&gt;무엇을 노출하고 무엇을 내부 규칙으로 남길지 구분하는 일&lt;/strong&gt;이 더 중요할 수 있습니다. 제 경우 디자인 시스템 Skill은 누군가가 직접 호출하는 도구보다, Claude의 답변을 흔들리지 않게 만드는 기준 문서에 가까웠습니다.&lt;/p&gt;
&lt;p&gt;그래서 `user-invocable: false`는 제약이라기보다, 제가 원하는 사용 경험을 만들기 위한 설계 선택이었습니다. 사용자는 더 편하게 질문하고, Claude는 디자인 시스템을 변경하거나 확장하는 상황에서도 더 일관되게 답하게 만드는 방향이었기 때문입니다.&lt;/p&gt;

&lt;div class=&quot;summary&quot;&gt;
  &lt;span class=&quot;summary-title&quot;&gt;Key Points&lt;/span&gt;
  &lt;ul&gt;
    &lt;li&gt;&lt;strong&gt;직접 호출을 막은 이유&lt;/strong&gt;는 기능을 숨기기 위해서가 아니라, 자연어 질의를 기본 인터페이스로 유지하기 위해서였습니다.&lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;디자인 시스템 Skill의 역할&lt;/strong&gt;은 자주 묻는 질문 대응보다, 변경과 확장 상황에서도 내부 판단 기준을 유지하는 데 더 가까웠습니다.&lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;user-invocable: false&lt;/strong&gt;는 사용성 저하가 아니라 역할 분리를 위한 설정이었습니다.&lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;유지보수 관점&lt;/strong&gt;에서는 질문 방식은 그대로 두고, Skill 문서만 업데이트하면 된다는 점이 특히 유용했습니다.&lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;결론적으로&lt;/strong&gt; 이 설계는 Claude를 더 많이 하게 만든 것이 아니라, 더 일관되게 답하게 만든 선택이었습니다.&lt;/li&gt;
  &lt;/ul&gt;
&lt;/div&gt;</description>
      <category>AI</category>
      <author>JooMan</author>
      <guid isPermaLink="true">https://devgeek.tistory.com/165</guid>
      <comments>https://devgeek.tistory.com/165#entry165comment</comments>
      <pubDate>Mon, 30 Mar 2026 14:19:19 +0900</pubDate>
    </item>
    <item>
      <title>Claude Code Skill로 커밋 메시지 추천 자동화</title>
      <link>https://devgeek.tistory.com/164</link>
      <description>&lt;style&gt;
  /* 코드 블록 래퍼 (파일명 헤더 포함) */
  .code-wrap {
    margin: 28px 0;
    border-radius: 10px;
    overflow: hidden;
    border: 1px solid var(--border);
    max-width: 100%;
  }
  .code-header {
    background: var(--surface2);
    padding: 10px 18px;
    display: flex;
    justify-content: space-between;
    align-items: center;
    border-bottom: 1px solid var(--border);
  }
  .code-filename {
    font-family: 'JetBrains Mono', monospace;
    font-size: 12px;
    color: var(--accent2);
  }
  .code-lang {
    font-size: 11px;
    color: var(--text-dim);
    text-transform: uppercase;
    letter-spacing: .08em;
  }
  .code-wrap pre { margin: 0; border-radius: 0; border: none; }

  /* 문법 색상 */
  .c-comment { color: #6e7681; }
  .c-key     { color: #a78bfa; }
  .c-str     { color: #7ee787; }
  .c-head    { color: #79c0ff; }
  .c-section { color: #ffa657; }

  /* 비교 카드 */
  .compare {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 16px;
    margin: 28px 0;
  }
  .compare-card {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 10px;
    padding: 20px;
    min-width: 0;
  }
  .compare-card.bad  { border-color: rgba(248,113,97,.3); }
  .compare-card.good { border-color: rgba(52,211,153,.3); }
  .compare-label {
    font-size: 11px;
    letter-spacing: .1em;
    text-transform: uppercase;
    margin-bottom: 12px;
    font-weight: 600;
    display: block;
  }
  .compare-card.bad  .compare-label { color: #f87171; }
  .compare-card.good .compare-label { color: #34d399; }
  .compare-card p { font-size: 14.5px; color: var(--text-muted); margin: 0; }

  /* 레벨 카드 */
  .level-list {
    margin: 24px 0;
    display: flex;
    flex-direction: column;
    gap: 12px;
  }
  .level-item {
    display: flex;
    gap: 20px;
    padding: 20px;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 10px;
    align-items: flex-start;
  }
  .level-badge {
    flex-shrink: 0;
    width: 56px;
    height: 56px;
    border-radius: 10px;
    background: var(--hl);
    border: 1px solid var(--accent);
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    font-size: 10px;
    color: var(--text-dim);
    text-transform: uppercase;
    letter-spacing: .05em;
  }
  .level-badge span {
    font-size: 18px;
    font-weight: 700;
    color: var(--accent2);
    line-height: 1;
    margin-bottom: 2px;
  }
  .level-body { min-width: 0; }
  .level-body h4 { font-size: 15px; font-weight: 600; color: #fff; margin: 0 0 6px; }
  .level-body p  { font-size: 14px; color: var(--text-muted); margin: 0; }

  /* 동작 흐름 다이어그램 */
  .flow {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 12px;
    padding: 32px 24px;
    margin: 28px 0;
    text-align: center;
  }
  .flow-steps {
    display: flex;
    flex-direction: column;
    gap: 8px;
    align-items: center;
  }
  .flow-step {
    background: var(--code-bg);
    border: 1px solid var(--border);
    border-radius: 8px;
    padding: 12px 28px;
    font-size: 14px;
    color: var(--text);
    width: 100%;
    max-width: 480px;
    text-align: left;
    box-sizing: border-box;
  }
  .flow-step .step-num {
    font-size: 11px;
    color: var(--accent);
    text-transform: uppercase;
    letter-spacing: .1em;
    display: block;
    margin-bottom: 3px;
  }
  .flow-arrow { color: var(--text-dim); font-size: 18px; line-height: 1; }
  .flow-step.hl { border-color: var(--accent); background: var(--hl); }

  /* 테이블 — 좁은 화면에서 가로 스크롤 */
  .table-scroll {
    width: 100%;
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
    margin: 20px 0;
  }
  .table-scroll table { margin: 0; min-width: 480px; }

  /* 핵심 정리 박스 */
  .summary {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 12px;
    padding: 28px 32px;
    margin-top: 48px;
  }
  .summary-title {
    font-size: 13px;
    font-weight: 700;
    letter-spacing: .15em;
    text-transform: uppercase;
    color: var(--accent);
    margin-bottom: 16px;
    display: block;
  }
  .summary ul { padding-left: 20px; margin: 0; }
  .summary li { color: var(--text-muted); font-size: 15px; margin-bottom: 10px; }
  .summary li strong { color: var(--accent2); }

  /* 반응형 */
  @media (max-width: 640px) {
    .compare { grid-template-columns: 1fr; }
    .level-item { flex-direction: column; }
    .flow-step { padding: 12px 16px; max-width: 100%; }
    .summary { padding: 20px; }
  }
&lt;/style&gt;

&lt;h2&gt;들어가며&lt;/h2&gt;
&lt;p&gt;Claude Code를 쓰다 보면 생각보다 자주 반복되는 작업이 있습니다. 그중 하나가 바로 &lt;strong&gt;커밋 메시지 정리&lt;/strong&gt;였습니다. 변경 내용을 보고 Conventional Commits 형식으로 적당한 메시지를 떠올리는 일은 어렵지는 않지만, 맥락을 한 번 더 읽고 정리해야 해서 은근히 흐름을 끊었습니다.&lt;/p&gt;
&lt;p&gt;그래서 저는 이 작업을 아예 Claude Code Skill로 분리해 봤습니다. 이번 글은 제가 만든 &lt;code&gt;recommend-commit-message&lt;/code&gt; 스킬의 구조를 기준으로, &lt;strong&gt;어떤 문제를 해결하려고 했는지&lt;/strong&gt;, &lt;strong&gt;어떻게 규칙을 설계했는지&lt;/strong&gt;, &lt;strong&gt;실제로 어떤 식으로 활용했는지&lt;/strong&gt;를 공유하는 기록입니다.&lt;/p&gt;
&lt;p&gt;참고로 이 스킬은 GitHub에도 공개해뒀습니다. 글을 읽다가 직접 구조를 보고 싶다면 &lt;a href=&quot;https://github.com/JuhyeokLee97/awesome-android-claude-skills/tree/main/skills/recommend-commit-message&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;recommend-commit-message Skill 저장소&lt;/a&gt;에서 바로 확인할 수 있고, 자신의 프로젝트에 맞게 가져가서 수정해 써도 됩니다.&lt;/p&gt;

&lt;h2&gt;왜 굳이 Skill로 분리했나&lt;/h2&gt;
&lt;p&gt;일반 프롬프트로도 “커밋 메시지 추천해줘”는 충분히 할 수 있습니다. 다만 매번 원하는 출력 형식, 언어, 분석 순서, 경고 조건을 다시 설명해야 했고, 결과도 호출할 때마다 조금씩 흔들렸습니다.&lt;/p&gt;
&lt;p&gt;반면 Skill로 만들면 반복 규칙을 문서에 고정할 수 있습니다. 한 번 잘 정의해 두면, 그다음부터는 사람이 해야 할 일이 “지금 이 상황에 맞게 호출하기” 정도로 줄어듭니다.&lt;/p&gt;

&lt;div class=&quot;compare&quot;&gt;
  &lt;div class=&quot;compare-card bad&quot;&gt;
    &lt;span class=&quot;compare-label&quot;&gt;프롬프트로만 처리&lt;/span&gt;
    &lt;p&gt;매번 조건을 다시 설명해야 하고, 출력 포맷이나 분석 관점이 호출마다 달라질 수 있습니다. 결국 자주 쓰는 작업일수록 손이 더 갑니다.&lt;/p&gt;
  &lt;/div&gt;
  &lt;div class=&quot;compare-card good&quot;&gt;
    &lt;span class=&quot;compare-label&quot;&gt;Skill로 분리&lt;/span&gt;
    &lt;p&gt;분석 순서, 언어 규칙, 경고 조건, 출력 템플릿을 문서로 고정할 수 있습니다. 호출은 짧아지고 결과는 더 일관됩니다.&lt;/p&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;h2&gt;제가 만든 스킬의 핵심 구조&lt;/h2&gt;
&lt;p&gt;이번에 만든 스킬은 staged git 변경사항을 읽고, Conventional Commits 형식의 후보를 2~3개 추천하는 역할에 집중합니다. 중요한 점은 &lt;strong&gt;커밋을 직접 실행하지 않는다&lt;/strong&gt;는 점입니다. 판단은 도와주되 최종 선택은 사람이 하도록 경계를 분명히 뒀습니다.&lt;/p&gt;
&lt;p&gt;문서를 보면 단순히 “메시지 추천”만 적은 것이 아니라, 분석 대상과 순서가 꽤 세밀하게 정의되어 있습니다. 저는 이 부분이 Skill의 핵심이라고 생각합니다. 결국 성능 차이는 모델 자체보다도 &lt;strong&gt;어떤 문맥을 어떻게 읽히게 설계했는가&lt;/strong&gt;에서 많이 갈립니다.&lt;/p&gt;

&lt;h3&gt;1. 출력 언어를 인자로 제어&lt;/h3&gt;
&lt;p&gt;이 스킬은 &lt;code&gt;$ARGUMENTS&lt;/code&gt;로 출력 언어를 바꿀 수 있게 만들었습니다. 기본값은 한국어지만, 영어, 일본어, 중국어, 스페인어, 포르투갈어, 프랑스어, 독일어까지 지정할 수 있게 해뒀습니다.&lt;/p&gt;
&lt;p&gt;여기서 재미있는 포인트는 &lt;code&gt;type&lt;/code&gt;, &lt;code&gt;scope&lt;/code&gt;, Conventional Commits 키워드는 항상 영어로 유지하고, 제목과 본문만 선택한 언어로 쓰도록 분리한 점입니다. 이렇게 해두면 팀 규칙과 읽기 편의성을 동시에 챙길 수 있습니다.&lt;/p&gt;

&lt;div class=&quot;code-wrap&quot;&gt;
  &lt;div class=&quot;code-header&quot;&gt;
    &lt;span class=&quot;code-filename&quot;&gt;Skill-Recomend-Commit-Message/SKILL.md&lt;/span&gt;
    &lt;span class=&quot;code-lang&quot;&gt;Markdown&lt;/span&gt;
  &lt;/div&gt;
  &lt;pre&gt;&lt;span class=&quot;c-head&quot;&gt;The output language is controlled by&lt;/span&gt; &lt;span class=&quot;c-key&quot;&gt;$ARGUMENTS&lt;/span&gt;:
- No argument or &lt;span class=&quot;c-str&quot;&gt;KOR&lt;/span&gt; → Korean
- &lt;span class=&quot;c-str&quot;&gt;ENG&lt;/span&gt; → English
- &lt;span class=&quot;c-str&quot;&gt;JPN&lt;/span&gt; → Japanese
...

&lt;span class=&quot;c-head&quot;&gt;Rule&lt;/span&gt;: &lt;span class=&quot;c-key&quot;&gt;type&lt;/span&gt;, &lt;span class=&quot;c-key&quot;&gt;scope&lt;/span&gt;, and Conventional Commits keywords
are always in English regardless of language setting.&lt;/pre&gt;
&lt;/div&gt;

&lt;h3&gt;2. staged/unstaged 상태를 분리해서 다룸&lt;/h3&gt;
&lt;p&gt;문서의 첫 단계는 &lt;code&gt;git diff --staged&lt;/code&gt; 확인입니다. staged 변경사항이 없으면 바로 중단하고, 먼저 &lt;code&gt;git add&lt;/code&gt;를 하라고 안내합니다. 반대로 unstaged 변경사항은 &lt;code&gt;git diff&lt;/code&gt;로 따로 확인하되, 작업을 멈추지는 않고 마지막에 경고만 붙입니다.&lt;/p&gt;
&lt;p&gt;이 구분이 꽤 실용적이었습니다. 실제 커밋 후보를 추천해야 하는 대상은 staged 변경사항이기 때문에 기준은 분명하게 잡고, 동시에 작업 디렉터리에 남아 있는 변경사항도 놓치지 않게 만든 구조입니다.&lt;/p&gt;

&lt;h3&gt;3. 변경 파일만 보는 게 아니라 프로젝트 맥락까지 읽음&lt;/h3&gt;
&lt;p&gt;이 스킬에서 제가 특히 신경 쓴 부분은 &lt;strong&gt;변경 diff만 읽고 메시지를 짓지 않게 한 것&lt;/strong&gt;입니다. 프로젝트 스택과 최근 커밋 히스토리도 같이 보도록 넣었습니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;package.json&lt;/code&gt;, &lt;code&gt;build.gradle&lt;/code&gt;, &lt;code&gt;Cargo.toml&lt;/code&gt;, &lt;code&gt;go.mod&lt;/code&gt;, &lt;code&gt;pyproject.toml&lt;/code&gt; 같은 파일을 확인해서 기술 스택 힌트를 얻고, &lt;code&gt;git log&lt;/code&gt;로 최근 메시지 패턴도 읽습니다. 즉, 지금 바뀐 코드만 보고 추천하는 게 아니라 &lt;strong&gt;이 저장소가 원래 어떤 식으로 커밋해왔는지&lt;/strong&gt;까지 반영하려는 설계입니다.&lt;/p&gt;

&lt;div class=&quot;flow&quot;&gt;
  &lt;div class=&quot;flow-steps&quot;&gt;
    &lt;div class=&quot;flow-step&quot;&gt;
      &lt;span class=&quot;step-num&quot;&gt;Step 1 · staged 확인&lt;/span&gt;
      &lt;code&gt;git diff --staged&lt;/code&gt; 결과가 없으면 추천을 중단하고 먼저 stage 하라고 안내합니다.
    &lt;/div&gt;
    &lt;div class=&quot;flow-arrow&quot;&gt;↓&lt;/div&gt;
    &lt;div class=&quot;flow-step&quot;&gt;
      &lt;span class=&quot;step-num&quot;&gt;Step 2 · unstaged 확인&lt;/span&gt;
      아직 stage 하지 않은 변경이 있으면 마지막에 경고를 붙여 사용자 판단을 돕습니다.
    &lt;/div&gt;
    &lt;div class=&quot;flow-arrow&quot;&gt;↓&lt;/div&gt;
    &lt;div class=&quot;flow-step hl&quot;&gt;
      &lt;span class=&quot;step-num&quot;&gt;Step 3 · 프로젝트 맥락 분석&lt;/span&gt;
      빌드 파일, 모듈 구조, 최근 커밋 히스토리를 읽어 scope와 표현 방식을 맞춥니다.
    &lt;/div&gt;
    &lt;div class=&quot;flow-arrow&quot;&gt;↓&lt;/div&gt;
    &lt;div class=&quot;flow-step&quot;&gt;
      &lt;span class=&quot;step-num&quot;&gt;Step 4 · 후보 생성&lt;/span&gt;
      단일 목적이면 2~3개 후보를, 여러 목적이면 커밋 분리를 제안하며 각각의 후보를 생성합니다.
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;h2&gt;문서 설계에서 좋았던 부분&lt;/h2&gt;
&lt;p&gt;이 스킬은 단순히 예시 몇 줄을 적은 문서가 아니라, 모델이 흔들릴 만한 지점을 미리 규칙으로 고정해 둔 문서에 가깝습니다. 특히 좋았던 건 &lt;strong&gt;scope 추천 로직&lt;/strong&gt;, &lt;strong&gt;type 선택 가이드&lt;/strong&gt;, &lt;strong&gt;출력 템플릿&lt;/strong&gt;을 각각 별도로 정의한 점입니다.&lt;/p&gt;
&lt;p&gt;이렇게 분리해 두면 모델이 어느 한 부분에서 애매해져도 다른 규칙이 보정 역할을 해줍니다. 사람이 읽어도 “왜 이런 결과가 나왔는지”를 역추적할 수 있어서, 나중에 스킬을 다듬기도 훨씬 편했습니다.&lt;/p&gt;

&lt;h3&gt;scope 추천 로직을 단계적으로 정의&lt;/h3&gt;
&lt;p&gt;문서에는 scope를 정할 때 우선순위가 있습니다. 먼저 Git 히스토리에서 기존 패턴을 확인하고, Android 멀티 모듈이면 모듈 폴더명을 쓰고, 그게 아니면 레이어나 최상위 디렉터리 이름으로 fallback 하도록 했습니다.&lt;/p&gt;
&lt;p&gt;이 방식의 장점은 저장소마다 다른 네이밍 문화에 적응할 수 있다는 점입니다. 무조건 파일명만 보고 scope를 짓는 것보다 훨씬 현실적인 결과가 나왔습니다.&lt;/p&gt;

&lt;div class=&quot;level-list&quot;&gt;
  &lt;div class=&quot;level-item&quot;&gt;
    &lt;div class=&quot;level-badge&quot;&gt;&lt;span&gt;1&lt;/span&gt;우선&lt;/div&gt;
    &lt;div class=&quot;level-body&quot;&gt;
      &lt;h4&gt;Git 히스토리 우선&lt;/h4&gt;
      &lt;p&gt;이미 팀이나 개인이 쓰고 있는 scope 패턴이 있다면 그 규칙을 먼저 따르게 했습니다. 새 규칙을 발명하기보다 기존 흐름에 붙는 쪽이 안정적입니다.&lt;/p&gt;
    &lt;/div&gt;
  &lt;/div&gt;
  &lt;div class=&quot;level-item&quot;&gt;
    &lt;div class=&quot;level-badge&quot;&gt;&lt;span&gt;2&lt;/span&gt;구조&lt;/div&gt;
    &lt;div class=&quot;level-body&quot;&gt;
      &lt;h4&gt;모듈/레이어 기반 추론&lt;/h4&gt;
      &lt;p&gt;Android 멀티 모듈이나 Clean Architecture 구조라면 폴더와 파일명에서 feature, layer, service 이름을 읽어 scope 후보로 사용합니다.&lt;/p&gt;
    &lt;/div&gt;
  &lt;/div&gt;
  &lt;div class=&quot;level-item&quot;&gt;
    &lt;div class=&quot;level-badge&quot;&gt;&lt;span&gt;3&lt;/span&gt;예외&lt;/div&gt;
    &lt;div class=&quot;level-body&quot;&gt;
      &lt;h4&gt;애매하면 생략 가능&lt;/h4&gt;
      &lt;p&gt;scope가 불명확한 프로젝트 전체 변경이라면 억지로 넣지 않도록 했습니다. 없애는 것도 규칙이라는 점이 중요합니다.&lt;/p&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;h3&gt;type 선택도 파일 경로 패턴으로 힌트를 줌&lt;/h3&gt;
&lt;p&gt;또 하나 유용했던 부분은 path-based type hint입니다. 테스트 파일이면 &lt;code&gt;test&lt;/code&gt;, 문서 파일이면 &lt;code&gt;docs&lt;/code&gt;, 워크플로 파일이면 &lt;code&gt;ci&lt;/code&gt;처럼 경로 패턴에 따라 기본 힌트를 제공했습니다.&lt;/p&gt;
&lt;p&gt;물론 최종 판단은 diff 내용을 봐야 하지만, 이런 힌트가 있으면 모델이 너무 넓은 선택지에서 헤매지 않습니다. 특히 Android 프로젝트처럼 파일 종류가 다양한 경우 체감이 꽤 있었습니다.&lt;/p&gt;

&lt;div class=&quot;table-scroll&quot;&gt;
  &lt;table&gt;
    &lt;thead&gt;
      &lt;tr&gt;
        &lt;th&gt;판단 기준&lt;/th&gt;
        &lt;th&gt;문서에서 준 힌트&lt;/th&gt;
        &lt;th&gt;의도&lt;/th&gt;
      &lt;/tr&gt;
    &lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;
        &lt;td&gt;&lt;strong&gt;테스트 파일&lt;/strong&gt;&lt;/td&gt;
        &lt;td&gt;&lt;code&gt;*test*&lt;/code&gt;, &lt;code&gt;*Spec*&lt;/code&gt;, &lt;code&gt;*Test.kt&lt;/code&gt; → &lt;code&gt;test&lt;/code&gt;&lt;/td&gt;
        &lt;td&gt;변경 목적을 빠르게 좁히기 위해&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
        &lt;td&gt;&lt;strong&gt;문서 파일&lt;/strong&gt;&lt;/td&gt;
        &lt;td&gt;&lt;code&gt;*.md&lt;/code&gt;, &lt;code&gt;docs/**&lt;/code&gt; → &lt;code&gt;docs&lt;/code&gt;&lt;/td&gt;
        &lt;td&gt;코드 수정과 문서 수정을 분리하기 위해&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
        &lt;td&gt;&lt;strong&gt;빌드 파일&lt;/strong&gt;&lt;/td&gt;
        &lt;td&gt;&lt;code&gt;package.json&lt;/code&gt;, &lt;code&gt;*.gradle*&lt;/code&gt; → &lt;code&gt;build&lt;/code&gt;, &lt;code&gt;chore&lt;/code&gt;&lt;/td&gt;
        &lt;td&gt;의존성/설정 변경을 명확히 드러내기 위해&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
        &lt;td&gt;&lt;strong&gt;UI 관련 파일&lt;/strong&gt;&lt;/td&gt;
        &lt;td&gt;&lt;code&gt;screens/**&lt;/code&gt;, &lt;code&gt;components/**&lt;/code&gt; → &lt;code&gt;feat&lt;/code&gt;, &lt;code&gt;design&lt;/code&gt;&lt;/td&gt;
        &lt;td&gt;기능 추가와 시각 수정의 경계를 잡기 위해&lt;/td&gt;
      &lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;
&lt;/div&gt;

&lt;h2&gt;실제로 써보니 좋았던 점&lt;/h2&gt;
&lt;p&gt;가장 먼저 느낀 장점은 &lt;strong&gt;출력 품질보다 일관성&lt;/strong&gt;이었습니다. 커밋 메시지는 매번 기막히게 창의적일 필요가 없고, 오히려 저장소 전체에서 톤이 맞는 편이 더 중요합니다. 이 스킬은 바로 그 부분을 잘 해결해줬습니다.&lt;/p&gt;
&lt;p&gt;또 하나는 커밋을 나눠야 할 때 도움이 됐다는 점입니다. 문서에 “독립된 목적이 2개 이상이면 split commit을 추천하라”는 규칙을 넣어두니, 단순한 메시지 추천기를 넘어 &lt;strong&gt;작업 단위 점검 도구&lt;/strong&gt;처럼 쓸 수 있었습니다.&lt;/p&gt;

&lt;blockquote&gt;Skill를 잘 만들어두면 모델이 더 똑똑해진다기보다, 내가 원하는 작업 기준이 더 분명해집니다. 실제로 체감되는 차이는 대부분 여기서 나옵니다.&lt;/blockquote&gt;

&lt;h2&gt;이런 식으로 커스텀하는 게 의미 있었던 이유&lt;/h2&gt;
&lt;p&gt;제가 이번 경험에서 가장 크게 느낀 건 Claude Code Skill의 가치는 “거대한 자동화”보다 “자주 반복되는 작고 애매한 판단을 정리하는 것”에 있다는 점이었습니다. 커밋 메시지 추천은 딱 그런 종류의 작업이었습니다.&lt;/p&gt;
&lt;p&gt;개발하면서 반복되지만 매번 설명하기 귀찮은 규칙, 프로젝트마다 조금씩 다른 판단 기준, 팀 혹은 개인의 작업 스타일 같은 것들은 일반 프롬프트보다 Skill 문서에 더 잘 어울립니다. 한번 구조를 잡아두면 이후에는 훨씬 가볍게 재사용할 수 있습니다.&lt;/p&gt;
&lt;p&gt;만약 비슷한 고민이 있었다면 글만 읽고 끝내기보다 실제 스킬 문서를 열어보는 편이 더 도움이 됩니다. 제가 사용한 원본은 &lt;a href=&quot;https://github.com/JuhyeokLee97/awesome-android-claude-skills/tree/main/skills/recommend-commit-message&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;GitHub의 recommend-commit-message 디렉토리&lt;/a&gt;에 올려뒀고, 그대로 참고해도 되고 팀 규칙에 맞게 scope나 type 힌트만 조금 바꿔서 써도 충분합니다.&lt;/p&gt;

&lt;h3&gt;제가 다음에도 Skill로 빼고 싶은 작업&lt;/h3&gt;
&lt;p&gt;이번에 해보니 비슷한 결의 작업은 계속 Skill로 분리할 수 있겠다는 확신이 생겼습니다. 예를 들면 PR 설명 생성, 리뷰 체크리스트 정리, 릴리즈 노트 초안 생성 같은 것들이 비슷한 패턴입니다.&lt;/p&gt;
&lt;p&gt;공통점은 하나입니다. 정답이 하나로 고정되진 않지만, &lt;strong&gt;좋은 결과를 만드는 판단 기준은 분명히 존재하는 작업&lt;/strong&gt;이라는 점입니다.&lt;/p&gt;

&lt;h2&gt;정리하며&lt;/h2&gt;
&lt;p&gt;이번 &lt;code&gt;recommend-commit-message&lt;/code&gt; 스킬은 복잡한 자동화라기보다, 제가 실제로 반복하던 판단 과정을 문서로 외부화한 결과물에 가깝습니다. staged 변경 확인, unstaged 경고, 프로젝트 스택 분석, 히스토리 기반 scope 추천, split commit 제안까지 흐름을 정의해두니 호출이 훨씬 가벼워졌습니다.&lt;/p&gt;
&lt;p&gt;Claude Code Skill를 커스텀하게 써본 경험을 한 줄로 정리하면 이렇습니다. &lt;strong&gt;모델에게 일을 잘 시키는 방법은 더 길게 말하는 게 아니라, 반복되는 판단 기준을 구조로 고정하는 것&lt;/strong&gt;이었습니다.&lt;/p&gt;

&lt;div class=&quot;summary&quot;&gt;
  &lt;span class=&quot;summary-title&quot;&gt;Summary&lt;/span&gt;
  &lt;ul&gt;
    &lt;li&gt;&lt;strong&gt;이번 스킬의 목적&lt;/strong&gt;은 staged 변경사항을 읽고 Conventional Commits 형식의 커밋 메시지를 안정적으로 추천하는 것이었습니다.&lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;핵심 설계 포인트&lt;/strong&gt;는 출력 언어 제어, staged/unstaged 분리, 프로젝트 스택과 Git 히스토리 분석이었습니다.&lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;좋았던 점&lt;/strong&gt;은 결과가 더 화려해진 것보다, 커밋 메시지 품질과 톤이 일관되게 유지됐다는 점입니다.&lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;Skill의 진짜 장점&lt;/strong&gt;은 자주 반복되지만 매번 설명하기 귀찮은 판단 기준을 문서로 고정할 수 있다는 데 있습니다.&lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;결론&lt;/strong&gt;은 Claude Code Skill를 커스텀할수록 모델 활용 경험이 좋아진다기보다, 내가 원하는 작업 방식이 더 선명해진다는 것입니다.&lt;/li&gt;
  &lt;/ul&gt;
&lt;/div&gt;</description>
      <category>AI</category>
      <author>JooMan</author>
      <guid isPermaLink="true">https://devgeek.tistory.com/164</guid>
      <comments>https://devgeek.tistory.com/164#entry164comment</comments>
      <pubDate>Fri, 27 Mar 2026 09:48:04 +0900</pubDate>
    </item>
    <item>
      <title>Claude Code Skills 설정 가이드 &amp;mdash; SKILL.md 구조부터 Frontmatter까지</title>
      <link>https://devgeek.tistory.com/163</link>
      <description>&lt;style&gt;
  /* 코드 블록 래퍼 (파일명 헤더 포함) */
  .code-wrap {
    margin: 28px 0;
    border-radius: 10px;
    border: 1px solid var(--border);
    max-width: 100%;
    width: 100%;
    box-sizing: border-box;
    overflow: hidden;
  }
  .code-header {
    background: var(--surface2);
    padding: 10px 18px;
    display: flex;
    justify-content: space-between;
    align-items: center;
    border-bottom: 1px solid var(--border);
  }
  .code-body {
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
  }
  .code-filename {
    font-family: 'JetBrains Mono', monospace;
    font-size: 12px;
    color: var(--accent2);
  }
  .code-lang {
    font-size: 11px;
    color: var(--text-dim);
    text-transform: uppercase;
    letter-spacing: .08em;
  }
  .code-wrap pre { margin: 0; border-radius: 0; border: none; min-width: max-content; }

  /* 문법 색상 */
  .c-comment { color: #6e7681; }
  .c-key     { color: #a78bfa; }
  .c-str     { color: #7ee787; }
  .c-head    { color: #79c0ff; }
  .c-section { color: #ffa657; }

  /* 비교 카드 */
  .compare {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 16px;
    margin: 28px 0;
  }
  .compare-card {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 10px;
    padding: 20px;
    min-width: 0;
  }
  .compare-card.bad  { border-color: rgba(248,113,97,.3); }
  .compare-card.good { border-color: rgba(52,211,153,.3); }
  .compare-label {
    font-size: 11px;
    letter-spacing: .1em;
    text-transform: uppercase;
    margin-bottom: 12px;
    font-weight: 600;
    display: block;
  }
  .compare-card.bad  .compare-label { color: #f87171; }
  .compare-card.good .compare-label { color: #34d399; }
  .compare-card p { font-size: 14.5px; color: var(--text-muted); margin: 0; }

  /* 레벨 카드 */
  .level-list {
    margin: 24px 0;
    display: flex;
    flex-direction: column;
    gap: 12px;
  }
  .level-item {
    display: flex;
    gap: 20px;
    padding: 20px;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 10px;
    align-items: flex-start;
  }
  .level-badge {
    flex-shrink: 0;
    width: 56px;
    height: 56px;
    border-radius: 10px;
    background: var(--hl);
    border: 1px solid var(--accent);
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    font-size: 10px;
    color: var(--text-dim);
    text-transform: uppercase;
    letter-spacing: .05em;
  }
  .level-badge span {
    font-size: 18px;
    font-weight: 700;
    color: var(--accent2);
    line-height: 1;
    margin-bottom: 2px;
  }
  .level-body { min-width: 0; }
  .level-body h4 { font-size: 15px; font-weight: 600; color: #fff; margin: 0 0 6px; }
  .level-body p  { font-size: 14px; color: var(--text-muted); margin: 0; }

  /* 동작 흐름 다이어그램 */
  .flow {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 12px;
    padding: 32px 24px;
    margin: 28px 0;
    text-align: center;
  }
  .flow-steps {
    display: flex;
    flex-direction: column;
    gap: 8px;
    align-items: center;
  }
  .flow-step {
    background: var(--code-bg);
    border: 1px solid var(--border);
    border-radius: 8px;
    padding: 12px 28px;
    font-size: 14px;
    color: var(--text);
    width: 100%;
    max-width: 480px;
    text-align: left;
    box-sizing: border-box;
  }
  .flow-step .step-num {
    font-size: 11px;
    color: var(--accent);
    text-transform: uppercase;
    letter-spacing: .1em;
    display: block;
    margin-bottom: 3px;
  }
  .flow-arrow { color: var(--text-dim); font-size: 18px; line-height: 1; }
  .flow-step.hl { border-color: var(--accent); background: var(--hl); }

  /* 테이블 — 좁은 화면에서 가로 스크롤 */
  .table-scroll {
    width: 100%;
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
    margin: 20px 0;
  }
  .table-scroll table { margin: 0; min-width: 480px; }

  /* 핵심 정리 박스 */
  .summary {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 12px;
    padding: 28px 32px;
    margin-top: 48px;
  }
  .summary-title {
    font-size: 13px;
    font-weight: 700;
    letter-spacing: .15em;
    text-transform: uppercase;
    color: var(--accent);
    margin-bottom: 16px;
    display: block;
  }
  .summary ul { padding-left: 20px; margin: 0; }
  .summary li { color: var(--text-muted); font-size: 15px; margin-bottom: 10px; }
  .summary li strong { color: var(--accent2); }

  /* 반응형 */
  @media (max-width: 640px) {
    .compare { grid-template-columns: 1fr; }
    .level-item { flex-direction: column; }
    .flow-step { padding: 12px 16px; max-width: 100%; }
    .summary { padding: 20px; }
  }
&lt;/style&gt;

&lt;h2&gt;들어가며&lt;/h2&gt;

&lt;p&gt;Skills를 만들어보려고 SKILL.md를 처음 열면 바로 질문이 생긴다. frontmatter에는 어떤 필드를 쓸 수 있는가? description은 어떻게 쓰는 게 좋은가? Claude가 Skill을 자동으로 실행하지 않도록 막을 수 있는가? 파일을 어디에 두어야 팀 전체에 공유되는가?&lt;/p&gt;

&lt;p&gt;이 글은 그 질문들에 답한다. SKILL.md의 파일 구조, frontmatter 각 필드의 의미와 쓰임새, Skill을 어디에 저장하느냐에 따라 달라지는 적용 범위, 인수를 넘기고 시스템 변수를 활용하는 방법까지 — Skills를 설정하고 제어하는 데 필요한 내용을 정리했다.&lt;/p&gt;

&lt;h2&gt;Skill이 동작하는 방식&lt;/h2&gt;

&lt;p&gt;Skill의 핵심은 &lt;strong&gt;두 단계 로딩&lt;/strong&gt;이다. 항상 모든 내용을 메모리에 올리는 게 아니라, 필요할 때만 가져온다. Claude의 컨텍스트 윈도우는 유한하기 때문에 이 구조가 중요하다.&lt;/p&gt;

&lt;div class=&quot;level-list&quot;&gt;
  &lt;div class=&quot;level-item&quot;&gt;
    &lt;div class=&quot;level-badge&quot;&gt;&lt;span&gt;L1&lt;/span&gt;메타&lt;/div&gt;
    &lt;div class=&quot;level-body&quot;&gt;
      &lt;h4&gt;항상 컨텍스트에 존재 — &lt;code&gt;description&lt;/code&gt; 필드&lt;/h4&gt;
      &lt;p&gt;세션이 시작되면 등록된 모든 Skill의 이름과 description이 컨텍스트에 로드된다. Claude는 이걸 보고 &quot;언제 어떤 Skill을 써야 하는지&quot; 파악한다. description이 짧을수록 컨텍스트 절약에 유리하다.&lt;/p&gt;
    &lt;/div&gt;
  &lt;/div&gt;
  &lt;div class=&quot;level-item&quot;&gt;
    &lt;div class=&quot;level-badge&quot;&gt;&lt;span&gt;L2&lt;/span&gt;본문&lt;/div&gt;
    &lt;div class=&quot;level-body&quot;&gt;
      &lt;h4&gt;호출 시 로드 — &lt;code&gt;SKILL.md&lt;/code&gt; 본문&lt;/h4&gt;
      &lt;p&gt;Claude가 Skill을 사용하겠다고 판단하거나, 사용자가 &lt;code&gt;/skill-name&lt;/code&gt;으로 직접 호출하면 그때 SKILL.md의 전체 내용이 로드된다. 평소에는 컨텍스트를 차지하지 않는다.&lt;/p&gt;
    &lt;/div&gt;
  &lt;/div&gt;
  &lt;div class=&quot;level-item&quot;&gt;
    &lt;div class=&quot;level-badge&quot;&gt;&lt;span&gt;L3&lt;/span&gt;리소스&lt;/div&gt;
    &lt;div class=&quot;level-body&quot;&gt;
      &lt;h4&gt;선택적 로드 — 보조 파일들&lt;/h4&gt;
      &lt;p&gt;Skill 디렉토리 안에 추가 파일을 둘 수 있다. SKILL.md에서 참조하면 Claude가 필요할 때만 읽는다. 방대한 API 레퍼런스나 예시 컬렉션을 분리할 때 유용하다.&lt;/p&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;이 구조 덕분에 Skill이 많아도 컨텍스트 부담이 크지 않다. description만 항상 올라오고, 나머지는 실제로 필요한 순간에만 로드된다. Skill이 많아 description 목록이 컨텍스트 한도를 초과하면 일부가 제외될 수 있는데, &lt;code&gt;/context&lt;/code&gt;로 확인하거나 &lt;code&gt;SLASH_COMMAND_TOOL_CHAR_BUDGET&lt;/code&gt; 환경변수로 한도를 늘릴 수 있다.&lt;/p&gt;

&lt;h2&gt;SKILL.md 파일 구조와 구성 요소&lt;/h2&gt;

&lt;p&gt;Skill은 &lt;code&gt;SKILL.md&lt;/code&gt; 파일 하나로 시작한다. 이 파일이 있는 디렉토리가 곧 하나의 Skill이다. 구조는 단순하다.&lt;/p&gt;

&lt;div class=&quot;code-wrap&quot;&gt;
  &lt;div class=&quot;code-header&quot;&gt;
    &lt;span class=&quot;code-filename&quot;&gt;~/.claude/skills/my-skill/&lt;/span&gt;
    &lt;span class=&quot;code-lang&quot;&gt;디렉토리 구조&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;code-body&quot; style=&quot;overflow-x:auto;-webkit-overflow-scrolling:touch;&quot;&gt;&lt;pre style=&quot;min-width:max-content;overflow-x:visible;&quot;&gt;my-skill/
├── SKILL.md           &lt;span class=&quot;c-comment&quot;&gt;# 메인 지침 (필수)&lt;/span&gt;
├── reference.md       &lt;span class=&quot;c-comment&quot;&gt;# 상세 레퍼런스 (선택)&lt;/span&gt;
├── examples/
│   └── sample.md      &lt;span class=&quot;c-comment&quot;&gt;# 예시 출력 (선택)&lt;/span&gt;
└── scripts/
    └── helper.py      &lt;span class=&quot;c-comment&quot;&gt;# 실행 스크립트 (선택)&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;&lt;code&gt;SKILL.md&lt;/code&gt;는 YAML frontmatter와 마크다운 본문, 두 부분으로 구성된다. frontmatter가 메타데이터를 정의하고, 본문이 실제 지침이 된다.&lt;/p&gt;

&lt;div class=&quot;code-wrap&quot;&gt;
  &lt;div class=&quot;code-header&quot;&gt;
    &lt;span class=&quot;code-filename&quot;&gt;~/.claude/skills/explain-code/SKILL.md&lt;/span&gt;
    &lt;span class=&quot;code-lang&quot;&gt;YAML&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;code-body&quot; style=&quot;overflow-x:auto;-webkit-overflow-scrolling:touch;&quot;&gt;&lt;pre style=&quot;min-width:max-content;overflow-x:visible;&quot;&gt;&lt;span class=&quot;c-comment&quot;&gt;---&lt;/span&gt;
&lt;span class=&quot;c-section&quot;&gt;name&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;explain-code&lt;/span&gt;
&lt;span class=&quot;c-section&quot;&gt;description&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;|&lt;/span&gt;
  &lt;span class=&quot;c-str&quot;&gt;Explains code with visual diagrams and analogies.&lt;/span&gt;
  &lt;span class=&quot;c-str&quot;&gt;Use when explaining how code works, teaching about&lt;/span&gt;
  &lt;span class=&quot;c-str&quot;&gt;a codebase, or when the user asks &quot;how does this work?&quot;&lt;/span&gt;
&lt;span class=&quot;c-comment&quot;&gt;---&lt;/span&gt;

When explaining code, always include:

&lt;span class=&quot;c-key&quot;&gt;1.&lt;/span&gt; **Start with an analogy**: Compare to something familiar
&lt;span class=&quot;c-key&quot;&gt;2.&lt;/span&gt; **Draw a diagram**: Show flow and structure in ASCII art
&lt;span class=&quot;c-key&quot;&gt;3.&lt;/span&gt; **Walk through the code**: Explain step-by-step
&lt;span class=&quot;c-key&quot;&gt;4.&lt;/span&gt; **Highlight a gotcha**: What's a common mistake?&lt;/pre&gt;&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;이렇게 만들어두면 &quot;이 코드가 어떻게 동작해?&quot;라고 물었을 때 Claude가 description을 보고 자동으로 이 Skill을 적용한다. 또는 &lt;code&gt;/explain-code src/auth/login.ts&lt;/code&gt;처럼 직접 호출해도 된다. SKILL.md는 500줄 이하로 유지하고, 상세 레퍼런스는 별도 파일로 분리하는 게 좋다.&lt;/p&gt;

&lt;h2&gt;Frontmatter — Skill 동작을 제어하는 설정&lt;/h2&gt;

&lt;p&gt;SKILL.md의 frontmatter에서 Skill의 동작 방식을 세밀하게 제어할 수 있다. 모든 필드가 선택적이지만, &lt;code&gt;description&lt;/code&gt;은 꼭 작성하는 게 좋다. Claude가 이 필드를 보고 언제 Skill을 쓸지 판단하기 때문이다.&lt;/p&gt;

&lt;p&gt;frontmatter는 반드시 &lt;code&gt;---&lt;/code&gt;로 시작하고 &lt;code&gt;---&lt;/code&gt;로 닫아야 한다. 이 두 줄 사이에 YAML 형식으로 필드를 작성하고, 그 아래에 마크다운 지침 본문이 온다.&lt;/p&gt;

&lt;div class=&quot;code-wrap&quot;&gt;
  &lt;div class=&quot;code-header&quot;&gt;
    &lt;span class=&quot;code-filename&quot;&gt;SKILL.md 기본 구조&lt;/span&gt;
    &lt;span class=&quot;code-lang&quot;&gt;YAML&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;code-body&quot; style=&quot;overflow-x:auto;-webkit-overflow-scrolling:touch;&quot;&gt;&lt;pre style=&quot;min-width:max-content;overflow-x:visible;&quot;&gt;&lt;span class=&quot;c-comment&quot;&gt;---&lt;/span&gt;
&lt;span class=&quot;c-section&quot;&gt;name&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;...&lt;/span&gt;
&lt;span class=&quot;c-section&quot;&gt;description&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;...&lt;/span&gt;
&lt;span class=&quot;c-comment&quot;&gt;---&lt;/span&gt;

지침 본문...&lt;/pre&gt;&lt;/div&gt;
&lt;/div&gt;

&lt;div class=&quot;table-scroll&quot;&gt;
  &lt;table&gt;
    &lt;thead&gt;
      &lt;tr&gt;
        &lt;th&gt;필드&lt;/th&gt;
        &lt;th&gt;설명&lt;/th&gt;
        &lt;th&gt;기본값&lt;/th&gt;
      &lt;/tr&gt;
    &lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;
        &lt;td&gt;&lt;code&gt;name&lt;/code&gt;&lt;/td&gt;
        &lt;td&gt;Skill 이름. &lt;code&gt;/name&lt;/code&gt;으로 직접 호출할 때 사용. 생략 시 디렉토리명으로 대체.&lt;/td&gt;
        &lt;td&gt;디렉토리명&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
        &lt;td&gt;&lt;code&gt;description&lt;/code&gt;&lt;/td&gt;
        &lt;td&gt;Skill이 하는 일과 언제 써야 하는지. Claude가 자동 호출 판단에 사용. &lt;strong&gt;필수에 가까운 권장 필드.&lt;/strong&gt;&lt;/td&gt;
        &lt;td&gt;본문 첫 단락&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
        &lt;td&gt;&lt;code&gt;disable-model-invocation&lt;/code&gt;&lt;/td&gt;
        &lt;td&gt;&lt;code&gt;true&lt;/code&gt;로 설정하면 Claude가 자동으로 이 Skill을 로드하지 않음. 사용자가 직접 호출해야 함.&lt;/td&gt;
        &lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
        &lt;td&gt;&lt;code&gt;user-invocable&lt;/code&gt;&lt;/td&gt;
        &lt;td&gt;&lt;code&gt;false&lt;/code&gt;로 설정하면 &lt;code&gt;/&lt;/code&gt; 메뉴에서 숨겨짐. Claude만 호출 가능한 배경 지식용.&lt;/td&gt;
        &lt;td&gt;&lt;code&gt;true&lt;/code&gt;&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
        &lt;td&gt;&lt;code&gt;allowed-tools&lt;/code&gt;&lt;/td&gt;
        &lt;td&gt;이 Skill이 활성화될 때 Claude가 허가 없이 사용할 수 있는 도구 목록.&lt;/td&gt;
        &lt;td&gt;—&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
        &lt;td&gt;&lt;code&gt;context&lt;/code&gt;&lt;/td&gt;
        &lt;td&gt;&lt;code&gt;fork&lt;/code&gt;로 설정하면 별도 서브에이전트 컨텍스트에서 실행.&lt;/td&gt;
        &lt;td&gt;—&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
        &lt;td&gt;&lt;code&gt;agent&lt;/code&gt;&lt;/td&gt;
        &lt;td&gt;&lt;code&gt;context: fork&lt;/code&gt; 설정 시 사용할 서브에이전트 타입. &lt;code&gt;Explore&lt;/code&gt;, &lt;code&gt;Plan&lt;/code&gt;, &lt;code&gt;general-purpose&lt;/code&gt; 또는 커스텀 에이전트명.&lt;/td&gt;
        &lt;td&gt;&lt;code&gt;general-purpose&lt;/code&gt;&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
        &lt;td&gt;&lt;code&gt;model&lt;/code&gt;&lt;/td&gt;
        &lt;td&gt;이 Skill이 활성화될 때 사용할 모델 지정.&lt;/td&gt;
        &lt;td&gt;—&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
        &lt;td&gt;&lt;code&gt;argument-hint&lt;/code&gt;&lt;/td&gt;
        &lt;td&gt;자동완성 시 표시되는 인수 힌트. 예: &lt;code&gt;[issue-number]&lt;/code&gt;&lt;/td&gt;
        &lt;td&gt;—&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
        &lt;td&gt;&lt;code&gt;hooks&lt;/code&gt;&lt;/td&gt;
        &lt;td&gt;이 Skill의 라이프사이클에 스코프된 hooks 설정.&lt;/td&gt;
        &lt;td&gt;—&lt;/td&gt;
      &lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;
&lt;/div&gt;

&lt;h3&gt;description — 자동 호출의 판단 기준&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;description&lt;/code&gt;은 Claude가 &quot;지금 이 Skill을 써야 하나?&quot;를 결정할 때 참고하는 유일한 필드다. 생략하면 본문 첫 단락이 대신 사용되지만, 명시적으로 작성하는 게 좋다. &lt;strong&gt;사용자가 어떤 상황에서 이 Skill을 쓸 것인지&lt;/strong&gt;를 직접 적어두면 자동 호출 정확도가 높아진다.&lt;/p&gt;

&lt;div class=&quot;code-wrap&quot;&gt;
  &lt;div class=&quot;code-header&quot;&gt;
    &lt;span class=&quot;code-filename&quot;&gt;review-pr/SKILL.md&lt;/span&gt;
    &lt;span class=&quot;code-lang&quot;&gt;YAML&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;code-body&quot; style=&quot;overflow-x:auto;-webkit-overflow-scrolling:touch;&quot;&gt;&lt;pre style=&quot;min-width:max-content;overflow-x:visible;&quot;&gt;&lt;span class=&quot;c-comment&quot;&gt;---&lt;/span&gt;
&lt;span class=&quot;c-section&quot;&gt;name&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;review-pr&lt;/span&gt;
&lt;span class=&quot;c-section&quot;&gt;description&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;|&lt;/span&gt;
  &lt;span class=&quot;c-str&quot;&gt;Review the current pull request for code quality,&lt;/span&gt;
  &lt;span class=&quot;c-str&quot;&gt;style, and potential bugs. Use when asked to review&lt;/span&gt;
  &lt;span class=&quot;c-str&quot;&gt;a PR, check code changes, or inspect a diff.&lt;/span&gt;
&lt;span class=&quot;c-comment&quot;&gt;---&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/div&gt;

&lt;h3&gt;disable-model-invocation / user-invocable — 호출 주체 제어&lt;/h3&gt;

&lt;p&gt;Skill에서 가장 중요한 설계 결정 중 하나가 &quot;이 Skill을 Claude가 알아서 쓰게 할 것인가, 아니면 내가 직접 부를 때만 쓸 것인가&quot;다. &lt;code&gt;disable-model-invocation&lt;/code&gt;과 &lt;code&gt;user-invocable&lt;/code&gt;, 두 필드로 이를 제어한다.&lt;/p&gt;

&lt;div class=&quot;compare&quot;&gt;
  &lt;div class=&quot;compare-card bad&quot;&gt;
    &lt;span class=&quot;compare-label&quot;&gt;문제 상황&lt;/span&gt;
    &lt;p&gt;배포 Skill을 만들었는데, 코드가 완성된 것처럼 보이자 Claude가 알아서 &lt;code&gt;/deploy&lt;/code&gt;를 실행해버린다. 사이드 이펙트가 있는 작업이 의도치 않게 트리거된다.&lt;/p&gt;
  &lt;/div&gt;
  &lt;div class=&quot;compare-card good&quot;&gt;
    &lt;span class=&quot;compare-label&quot;&gt;해결책&lt;/span&gt;
    &lt;p&gt;&lt;code&gt;disable-model-invocation: true&lt;/code&gt;를 설정하면 Claude는 절대 이 Skill을 스스로 호출하지 않는다. 사용자가 &lt;code&gt;/deploy&lt;/code&gt;를 입력할 때만 실행된다.&lt;/p&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;반대 상황도 있다. 예를 들어 레거시 시스템의 배경 지식을 담은 Skill이라면, Claude는 관련 작업을 할 때 자동으로 참고해야 하지만 사용자가 &lt;code&gt;/legacy-system-context&lt;/code&gt;를 직접 입력하는 건 의미가 없다. 이때는 &lt;code&gt;user-invocable: false&lt;/code&gt;를 쓴다.&lt;/p&gt;

&lt;div class=&quot;table-scroll&quot;&gt;
  &lt;table&gt;
    &lt;thead&gt;
      &lt;tr&gt;
        &lt;th&gt;설정&lt;/th&gt;
        &lt;th&gt;사용자 호출&lt;/th&gt;
        &lt;th&gt;Claude 자동 호출&lt;/th&gt;
        &lt;th&gt;description 컨텍스트 상시 로드&lt;/th&gt;
      &lt;/tr&gt;
    &lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;
        &lt;td&gt;(기본값)&lt;/td&gt;
        &lt;td&gt;가능&lt;/td&gt;
        &lt;td&gt;가능&lt;/td&gt;
        &lt;td&gt;O&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
        &lt;td&gt;&lt;code&gt;disable-model-invocation: true&lt;/code&gt;&lt;/td&gt;
        &lt;td&gt;가능&lt;/td&gt;
        &lt;td&gt;불가&lt;/td&gt;
        &lt;td&gt;X&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
        &lt;td&gt;&lt;code&gt;user-invocable: false&lt;/code&gt;&lt;/td&gt;
        &lt;td&gt;불가&lt;/td&gt;
        &lt;td&gt;가능&lt;/td&gt;
        &lt;td&gt;O&lt;/td&gt;
      &lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;
&lt;/div&gt;

&lt;h3&gt;allowed-tools — 허가 없이 사용할 도구 지정&lt;/h3&gt;

&lt;p&gt;기본적으로 Claude는 도구를 사용할 때마다 승인을 요청한다. &lt;code&gt;allowed-tools&lt;/code&gt;에 도구를 나열하면 이 Skill이 활성화된 동안 해당 도구는 승인 없이 바로 실행된다. Bash 명령은 &lt;code&gt;Bash(패턴)&lt;/code&gt; 형식으로 허용 범위를 좁힐 수 있다.&lt;/p&gt;

&lt;div class=&quot;code-wrap&quot;&gt;
  &lt;div class=&quot;code-header&quot;&gt;
    &lt;span class=&quot;code-filename&quot;&gt;build/SKILL.md&lt;/span&gt;
    &lt;span class=&quot;code-lang&quot;&gt;YAML&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;code-body&quot; style=&quot;overflow-x:auto;-webkit-overflow-scrolling:touch;&quot;&gt;&lt;pre style=&quot;min-width:max-content;overflow-x:visible;&quot;&gt;&lt;span class=&quot;c-comment&quot;&gt;---&lt;/span&gt;
&lt;span class=&quot;c-section&quot;&gt;name&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;build&lt;/span&gt;
&lt;span class=&quot;c-section&quot;&gt;allowed-tools&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;Bash(npm *), Bash(git *), Read, Write&lt;/span&gt;
&lt;span class=&quot;c-comment&quot;&gt;---&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/div&gt;

&lt;h3&gt;context와 agent — 서브에이전트에서 실행하기&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;context: fork&lt;/code&gt;를 설정하면 Skill이 현재 대화와 분리된 서브에이전트 컨텍스트에서 실행된다. 긴 탐색 작업이나 현재 대화 맥락을 오염시키고 싶지 않을 때 유용하다. &lt;code&gt;agent&lt;/code&gt;로 에이전트 타입을 지정할 수 있으며, 생략하면 &lt;code&gt;general-purpose&lt;/code&gt;가 사용된다. 이 두 필드는 함께 써야 의미가 있다.&lt;/p&gt;

&lt;div class=&quot;code-wrap&quot;&gt;
  &lt;div class=&quot;code-header&quot;&gt;
    &lt;span class=&quot;code-filename&quot;&gt;explore-codebase/SKILL.md&lt;/span&gt;
    &lt;span class=&quot;code-lang&quot;&gt;YAML&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;code-body&quot; style=&quot;overflow-x:auto;-webkit-overflow-scrolling:touch;&quot;&gt;&lt;pre style=&quot;min-width:max-content;overflow-x:visible;&quot;&gt;&lt;span class=&quot;c-comment&quot;&gt;---&lt;/span&gt;
&lt;span class=&quot;c-section&quot;&gt;name&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;explore-codebase&lt;/span&gt;
&lt;span class=&quot;c-section&quot;&gt;context&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;fork&lt;/span&gt;
&lt;span class=&quot;c-section&quot;&gt;agent&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;Explore&lt;/span&gt;
&lt;span class=&quot;c-comment&quot;&gt;---&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/div&gt;

&lt;h3&gt;model — Skill별 모델 지정&lt;/h3&gt;

&lt;p&gt;특정 작업에 맞는 모델을 고정하고 싶을 때 사용한다. 단순 반복 작업엔 빠른 모델을, 복잡한 분석엔 상위 모델을 배정해 비용과 속도를 조율할 수 있다.&lt;/p&gt;

&lt;div class=&quot;code-wrap&quot;&gt;
  &lt;div class=&quot;code-header&quot;&gt;
    &lt;span class=&quot;code-filename&quot;&gt;complex-review/SKILL.md&lt;/span&gt;
    &lt;span class=&quot;code-lang&quot;&gt;YAML&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;code-body&quot; style=&quot;overflow-x:auto;-webkit-overflow-scrolling:touch;&quot;&gt;&lt;pre style=&quot;min-width:max-content;overflow-x:visible;&quot;&gt;&lt;span class=&quot;c-comment&quot;&gt;---&lt;/span&gt;
&lt;span class=&quot;c-section&quot;&gt;name&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;complex-review&lt;/span&gt;
&lt;span class=&quot;c-section&quot;&gt;model&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;claude-opus-4-6&lt;/span&gt;
&lt;span class=&quot;c-comment&quot;&gt;---&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;지금까지 살펴본 필드들을 실제 배포 Skill에 조합하면 다음과 같다. 각 줄의 역할을 주석으로 표시했다.&lt;/p&gt;

&lt;div class=&quot;code-wrap&quot;&gt;
  &lt;div class=&quot;code-header&quot;&gt;
    &lt;span class=&quot;code-filename&quot;&gt;deploy/SKILL.md&lt;/span&gt;
    &lt;span class=&quot;code-lang&quot;&gt;YAML&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;code-body&quot; style=&quot;overflow-x:auto;-webkit-overflow-scrolling:touch;&quot;&gt;&lt;pre style=&quot;min-width:max-content;overflow-x:visible;&quot;&gt;&lt;span class=&quot;c-comment&quot;&gt;---&lt;/span&gt;
&lt;span class=&quot;c-section&quot;&gt;name&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;deploy&lt;/span&gt;                                       &lt;span class=&quot;c-comment&quot;&gt;# /deploy 로 직접 호출&lt;/span&gt;
&lt;span class=&quot;c-section&quot;&gt;description&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;Deploy the application to production.&lt;/span&gt;    &lt;span class=&quot;c-comment&quot;&gt;# 자동 호출 판단 기준&lt;/span&gt;
             &lt;span class=&quot;c-str&quot;&gt;Use when deploying to staging or production.&lt;/span&gt;
&lt;span class=&quot;c-section&quot;&gt;disable-model-invocation&lt;/span&gt;: &lt;span class=&quot;c-key&quot;&gt;true&lt;/span&gt;                     &lt;span class=&quot;c-comment&quot;&gt;# Claude 자동 실행 차단&lt;/span&gt;
&lt;span class=&quot;c-section&quot;&gt;allowed-tools&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;Bash(npm *), Bash(git *)&lt;/span&gt;             &lt;span class=&quot;c-comment&quot;&gt;# 허가 없이 쓸 수 있는 도구&lt;/span&gt;
&lt;span class=&quot;c-section&quot;&gt;context&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;fork&lt;/span&gt;                                        &lt;span class=&quot;c-comment&quot;&gt;# 서브에이전트 컨텍스트에서 실행&lt;/span&gt;
&lt;span class=&quot;c-section&quot;&gt;agent&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;Explore&lt;/span&gt;                                       &lt;span class=&quot;c-comment&quot;&gt;# 사용할 에이전트 타입&lt;/span&gt;
&lt;span class=&quot;c-comment&quot;&gt;---&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/div&gt;

&lt;h2&gt;어디에 저장하는가 — Skill 저장 위치&lt;/h2&gt;

&lt;p&gt;Skill을 어느 경로에 두느냐에 따라 적용 범위가 달라진다. 개인 작업 스타일 가이드는 &lt;code&gt;~/.claude/&lt;/code&gt;에, 팀 공유 컨벤션은 프로젝트 루트의 &lt;code&gt;.claude/&lt;/code&gt;에 두면 된다.&lt;/p&gt;

&lt;div class=&quot;table-scroll&quot;&gt;
  &lt;table&gt;
    &lt;thead&gt;
      &lt;tr&gt;
        &lt;th&gt;위치&lt;/th&gt;
        &lt;th&gt;경로&lt;/th&gt;
        &lt;th&gt;적용 범위&lt;/th&gt;
      &lt;/tr&gt;
    &lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;
        &lt;td&gt;&lt;strong&gt;Enterprise&lt;/strong&gt;&lt;/td&gt;
        &lt;td&gt;관리형 설정 참조&lt;/td&gt;
        &lt;td&gt;조직 전체 사용자&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
        &lt;td&gt;&lt;strong&gt;Personal&lt;/strong&gt;&lt;/td&gt;
        &lt;td&gt;&lt;code&gt;~/.claude/skills/&amp;lt;skill-name&amp;gt;/SKILL.md&lt;/code&gt;&lt;/td&gt;
        &lt;td&gt;내 모든 프로젝트&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
        &lt;td&gt;&lt;strong&gt;Project&lt;/strong&gt;&lt;/td&gt;
        &lt;td&gt;&lt;code&gt;.claude/skills/&amp;lt;skill-name&amp;gt;/SKILL.md&lt;/code&gt;&lt;/td&gt;
        &lt;td&gt;현재 프로젝트만&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
        &lt;td&gt;&lt;strong&gt;Plugin&lt;/strong&gt;&lt;/td&gt;
        &lt;td&gt;&lt;code&gt;&amp;lt;plugin&amp;gt;/skills/&amp;lt;skill-name&amp;gt;/SKILL.md&lt;/code&gt;&lt;/td&gt;
        &lt;td&gt;플러그인이 활성화된 곳&lt;/td&gt;
      &lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;
&lt;/div&gt;

&lt;p&gt;같은 이름의 Skill이 여러 위치에 있으면 우선순위가 적용된다: Enterprise &gt; Personal &gt; Project. 프로젝트 Skill을 버전 관리에 포함시키면 팀 전체가 동일한 지침을 공유할 수 있다.&lt;/p&gt;

&lt;p&gt;모노레포 환경에서는 &lt;strong&gt;중첩 디렉토리 자동 탐색&lt;/strong&gt;도 지원된다. &lt;code&gt;packages/frontend/&lt;/code&gt; 안의 파일을 편집 중이라면, Claude Code는 &lt;code&gt;packages/frontend/.claude/skills/&lt;/code&gt;도 자동으로 찾아본다. 패키지별로 독립된 Skill을 관리할 수 있다는 뜻이다. 또한 &lt;code&gt;--add-dir&lt;/code&gt;로 추가한 디렉토리 내 &lt;code&gt;.claude/skills/&lt;/code&gt;도 자동 로드되며, 세션 중 수정하면 재시작 없이 즉시 반영된다.&lt;/p&gt;

&lt;h2&gt;$ARGUMENTS와 시스템 변수 — Skill에 값 전달하기&lt;/h2&gt;

&lt;p&gt;Skill을 호출할 때 인수를 넘길 수 있다. &lt;code&gt;$ARGUMENTS&lt;/code&gt; placeholder가 실제 입력값으로 대체된다. 여러 인수는 &lt;code&gt;$ARGUMENTS[0]&lt;/code&gt;, &lt;code&gt;$ARGUMENTS[1]&lt;/code&gt; (또는 축약형 &lt;code&gt;$0&lt;/code&gt;, &lt;code&gt;$1&lt;/code&gt;)으로 위치별 접근이 가능하다.&lt;/p&gt;

&lt;h3&gt;인수 1개 — argument-hint로 힌트 제공하기&lt;/h3&gt;

&lt;p&gt;인수가 하나일 때는 &lt;code&gt;$ARGUMENTS&lt;/code&gt;를 그대로 쓰면 된다. &lt;code&gt;argument-hint&lt;/code&gt; 필드를 추가하면 &lt;code&gt;/&lt;/code&gt; 메뉴 자동완성에서 어떤 값을 입력해야 하는지 힌트로 표시된다.&lt;/p&gt;

&lt;div class=&quot;code-wrap&quot;&gt;
  &lt;div class=&quot;code-header&quot;&gt;
    &lt;span class=&quot;code-filename&quot;&gt;fix-issue/SKILL.md&lt;/span&gt;
    &lt;span class=&quot;code-lang&quot;&gt;YAML&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;code-body&quot; style=&quot;overflow-x:auto;-webkit-overflow-scrolling:touch;&quot;&gt;&lt;pre style=&quot;min-width:max-content;overflow-x:visible;&quot;&gt;&lt;span class=&quot;c-comment&quot;&gt;---&lt;/span&gt;
&lt;span class=&quot;c-section&quot;&gt;name&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;fix-issue&lt;/span&gt;
&lt;span class=&quot;c-section&quot;&gt;description&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;Fix a GitHub issue&lt;/span&gt;
&lt;span class=&quot;c-section&quot;&gt;argument-hint&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;&quot;[issue-number]&quot;&lt;/span&gt;
&lt;span class=&quot;c-section&quot;&gt;disable-model-invocation&lt;/span&gt;: &lt;span class=&quot;c-key&quot;&gt;true&lt;/span&gt;
&lt;span class=&quot;c-comment&quot;&gt;---&lt;/span&gt;

Fix GitHub issue &lt;span class=&quot;c-section&quot;&gt;$ARGUMENTS&lt;/span&gt; following our coding standards.

&lt;span class=&quot;c-key&quot;&gt;1.&lt;/span&gt; Read the issue description
&lt;span class=&quot;c-key&quot;&gt;2.&lt;/span&gt; Implement the fix
&lt;span class=&quot;c-key&quot;&gt;3.&lt;/span&gt; Write tests
&lt;span class=&quot;c-key&quot;&gt;4.&lt;/span&gt; Create a commit&lt;/pre&gt;&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;사용자는 다음과 같이 호출한다.&lt;/p&gt;

&lt;div class=&quot;code-wrap&quot;&gt;
  &lt;div class=&quot;code-header&quot;&gt;
    &lt;span class=&quot;code-filename&quot;&gt;터미널&lt;/span&gt;
    &lt;span class=&quot;code-lang&quot;&gt;Shell&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;code-body&quot; style=&quot;overflow-x:auto;-webkit-overflow-scrolling:touch;&quot;&gt;&lt;pre style=&quot;min-width:max-content;overflow-x:visible;&quot;&gt;/fix-issue 42
→ $ARGUMENTS = &quot;42&quot;&lt;/pre&gt;&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;&lt;code&gt;argument-hint&lt;/code&gt;는 자동완성 UI에 표시되는 힌트일 뿐이며, 실제 값 전달 방식에는 영향을 주지 않는다.&lt;/p&gt;

&lt;h3&gt;인수 2개 이상 — 위치 인수로 분리하기&lt;/h3&gt;

&lt;p&gt;공백으로 구분된 여러 인수를 넘기면 &lt;code&gt;$ARGUMENTS[0]&lt;/code&gt;, &lt;code&gt;$ARGUMENTS[1]&lt;/code&gt;로 각각 접근할 수 있다. &lt;code&gt;argument-hint&lt;/code&gt;에 위치마다 레이블을 적어두면 사용자가 순서를 기억하기 쉽다.&lt;/p&gt;

&lt;div class=&quot;code-wrap&quot;&gt;
  &lt;div class=&quot;code-header&quot;&gt;
    &lt;span class=&quot;code-filename&quot;&gt;deploy/SKILL.md&lt;/span&gt;
    &lt;span class=&quot;code-lang&quot;&gt;YAML&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;code-body&quot; style=&quot;overflow-x:auto;-webkit-overflow-scrolling:touch;&quot;&gt;&lt;pre style=&quot;min-width:max-content;overflow-x:visible;&quot;&gt;&lt;span class=&quot;c-comment&quot;&gt;---&lt;/span&gt;
&lt;span class=&quot;c-section&quot;&gt;name&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;deploy&lt;/span&gt;
&lt;span class=&quot;c-section&quot;&gt;description&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;Deploy to the specified environment&lt;/span&gt;
&lt;span class=&quot;c-section&quot;&gt;argument-hint&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;&quot;[env] [version]&quot;&lt;/span&gt;
&lt;span class=&quot;c-section&quot;&gt;disable-model-invocation&lt;/span&gt;: &lt;span class=&quot;c-key&quot;&gt;true&lt;/span&gt;
&lt;span class=&quot;c-comment&quot;&gt;---&lt;/span&gt;

Deploy version &lt;span class=&quot;c-section&quot;&gt;$ARGUMENTS[1]&lt;/span&gt; to the &lt;span class=&quot;c-section&quot;&gt;$ARGUMENTS[0]&lt;/span&gt; environment.

&lt;span class=&quot;c-key&quot;&gt;1.&lt;/span&gt; Verify &lt;span class=&quot;c-section&quot;&gt;$ARGUMENTS[0]&lt;/span&gt; is a valid target (staging | production)
&lt;span class=&quot;c-key&quot;&gt;2.&lt;/span&gt; Check that tag &lt;span class=&quot;c-section&quot;&gt;$ARGUMENTS[1]&lt;/span&gt; exists in the registry
&lt;span class=&quot;c-key&quot;&gt;3.&lt;/span&gt; Run the deployment pipeline
&lt;span class=&quot;c-key&quot;&gt;4.&lt;/span&gt; Confirm health checks pass&lt;/pre&gt;&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;사용자는 다음과 같이 호출한다.&lt;/p&gt;

&lt;div class=&quot;code-wrap&quot;&gt;
  &lt;div class=&quot;code-header&quot;&gt;
    &lt;span class=&quot;code-filename&quot;&gt;터미널&lt;/span&gt;
    &lt;span class=&quot;code-lang&quot;&gt;Shell&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;code-body&quot; style=&quot;overflow-x:auto;-webkit-overflow-scrolling:touch;&quot;&gt;&lt;pre style=&quot;min-width:max-content;overflow-x:visible;&quot;&gt;/deploy staging v1.2.0
→ $ARGUMENTS[0] = &quot;staging&quot;
→ $ARGUMENTS[1] = &quot;v1.2.0&quot;
→ $ARGUMENTS    = &quot;staging v1.2.0&quot;  (전체 문자열)&lt;/pre&gt;&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;&lt;code&gt;$ARGUMENTS&lt;/code&gt; 외에도 시스템에서 자동으로 제공하는 변수가 있다. &lt;code&gt;${CLAUDE_SESSION_ID}&lt;/code&gt;는 현재 세션 ID로, 세션별 로그 파일 생성이나 작업 추적에 활용할 수 있다. &lt;code&gt;${CLAUDE_SKILL_DIR}&lt;/code&gt;는 해당 Skill의 디렉토리 경로로, 번들된 스크립트를 현재 작업 디렉토리와 무관하게 참조할 때 유용하다.&lt;/p&gt;

&lt;div class=&quot;code-wrap&quot;&gt;
  &lt;div class=&quot;code-header&quot;&gt;
    &lt;span class=&quot;code-filename&quot;&gt;session-logger/SKILL.md&lt;/span&gt;
    &lt;span class=&quot;code-lang&quot;&gt;YAML&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;code-body&quot; style=&quot;overflow-x:auto;-webkit-overflow-scrolling:touch;&quot;&gt;&lt;pre style=&quot;min-width:max-content;overflow-x:visible;&quot;&gt;&lt;span class=&quot;c-comment&quot;&gt;---&lt;/span&gt;
&lt;span class=&quot;c-section&quot;&gt;name&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;session-logger&lt;/span&gt;
&lt;span class=&quot;c-section&quot;&gt;description&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;Log activity for this session&lt;/span&gt;
&lt;span class=&quot;c-section&quot;&gt;allowed-tools&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;Bash(python *)&lt;/span&gt;
&lt;span class=&quot;c-comment&quot;&gt;---&lt;/span&gt;

Run the following script to log this session's activity:

&lt;span class=&quot;c-str&quot;&gt;python ${CLAUDE_SKILL_DIR}/scripts/log.py&lt;/span&gt;

Log destination: logs/&lt;span class=&quot;c-section&quot;&gt;${CLAUDE_SESSION_ID}&lt;/span&gt;.log

Activity to log: &lt;span class=&quot;c-section&quot;&gt;$ARGUMENTS&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;/div&gt;

&lt;h2&gt;트러블슈팅&lt;/h2&gt;

&lt;p&gt;Skill이 예상대로 동작하지 않을 때 확인할 체크리스트다.&lt;/p&gt;

&lt;div class=&quot;level-list&quot;&gt;
  &lt;div class=&quot;level-item&quot;&gt;
    &lt;div class=&quot;level-badge&quot;&gt;&lt;span&gt;Q1&lt;/span&gt;미트리거&lt;/div&gt;
    &lt;div class=&quot;level-body&quot;&gt;
      &lt;h4&gt;Skill이 자동으로 트리거되지 않는다&lt;/h4&gt;
      &lt;p&gt;description에 사용자가 실제로 쓸 법한 키워드가 포함되어 있는지 확인한다. &quot;Skill 목록 보여줘&quot;로 등록 여부를 먼저 체크하고, &lt;code&gt;/skill-name&lt;/code&gt;으로 직접 호출해 동작 자체는 정상인지 검증한다. description 문구를 실제 사용 패턴에 맞게 다듬는 게 효과적이다.&lt;/p&gt;
    &lt;/div&gt;
  &lt;/div&gt;
  &lt;div class=&quot;level-item&quot;&gt;
    &lt;div class=&quot;level-badge&quot;&gt;&lt;span&gt;Q2&lt;/span&gt;과트리거&lt;/div&gt;
    &lt;div class=&quot;level-body&quot;&gt;
      &lt;h4&gt;원치 않는 상황에 Skill이 자꾸 켜진다&lt;/h4&gt;
      &lt;p&gt;description을 더 구체적으로 좁힌다. 그래도 안 되면 &lt;code&gt;disable-model-invocation: true&lt;/code&gt;로 수동 호출 전용으로 전환한다.&lt;/p&gt;
    &lt;/div&gt;
  &lt;/div&gt;
  &lt;div class=&quot;level-item&quot;&gt;
    &lt;div class=&quot;level-badge&quot;&gt;&lt;span&gt;Q3&lt;/span&gt;컨텍스트&lt;/div&gt;
    &lt;div class=&quot;level-body&quot;&gt;
      &lt;h4&gt;일부 Skill이 목록에 보이지 않는다&lt;/h4&gt;
      &lt;p&gt;Skill이 많으면 description 목록이 컨텍스트 예산(기본: 컨텍스트 윈도우의 2%, 최대 16,000자)을 초과해 일부가 제외된다. &lt;code&gt;/context&lt;/code&gt;로 경고 메시지를 확인하고, 필요하면 &lt;code&gt;SLASH_COMMAND_TOOL_CHAR_BUDGET&lt;/code&gt; 환경변수로 한도를 늘린다.&lt;/p&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;h2&gt;정리하며&lt;/h2&gt;

&lt;p&gt;Claude Code Skills는 &quot;반복 설명&quot;의 피로를 해결하는 구조적인 답이다. description만 항상 컨텍스트에 올리고, 실제 내용은 필요할 때만 로드하는 Progressive Disclosure 구조가 컨텍스트 효율을 지킨다. 개인 작업 스타일이든 팀 컨벤션이든, Skill 하나로 Claude를 일관되게 동작시킬 수 있다.&lt;/p&gt;

&lt;p&gt;번들 스킬(&lt;code&gt;/batch&lt;/code&gt;, &lt;code&gt;/simplify&lt;/code&gt; 등)은 당장 쓸 수 있는 강력한 워크플로우를 제공하고, 커스텀 Skill로 팀 특화 지침을 추가하면 된다. 복잡한 워크플로우는 &lt;code&gt;$ARGUMENTS&lt;/code&gt;와 동적 컨텍스트 주입, &lt;code&gt;context: fork&lt;/code&gt;로 더 강력하게 만들 수 있다. 한 번 잘 만들어두면 반복 작업이 줄고, Claude의 응답 품질도 일정해진다.&lt;/p&gt;

&lt;div class=&quot;summary&quot;&gt;
  &lt;span class=&quot;summary-title&quot;&gt;핵심 정리&lt;/span&gt;
  &lt;ul&gt;
    &lt;li&gt;&lt;strong&gt;두 단계 로딩이 컨텍스트를 아낀다.&lt;/strong&gt; description은 항상 로드되고, 본문은 호출 시에만, 보조 파일은 선택적으로 로드된다. Skill이 많아도 컨텍스트 낭비가 없다.&lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;description이 자동 호출의 열쇠다.&lt;/strong&gt; Claude는 description을 보고 Skill을 써야 할지 판단한다. 어떤 상황에서 이 Skill을 써야 하는지 명시적으로 적어두면 자동 호출 정확도가 높아진다.&lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;&lt;code&gt;disable-model-invocation: true&lt;/code&gt;는 사이드이펙트 있는 Skill에 필수.&lt;/strong&gt; 배포·메시지 전송처럼 타이밍을 직접 제어해야 하는 작업에는 Claude가 알아서 호출하지 못하도록 막아야 한다.&lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;저장 위치가 적용 범위를 결정한다.&lt;/strong&gt; &lt;code&gt;~/.claude/&lt;/code&gt;는 개인 전체, &lt;code&gt;.claude/&lt;/code&gt;는 프로젝트 한정. 프로젝트 Skill을 버전 관리에 포함하면 팀 전체가 동일한 지침을 공유할 수 있다.&lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;&lt;code&gt;$ARGUMENTS&lt;/code&gt;로 Skill을 동적으로 만든다.&lt;/strong&gt; &lt;code&gt;$ARGUMENTS[0]&lt;/code&gt;, &lt;code&gt;$ARGUMENTS[1]&lt;/code&gt;로 위치별 인수를 받고, &lt;code&gt;${CLAUDE_SKILL_DIR}&lt;/code&gt;·&lt;code&gt;${CLAUDE_SESSION_ID}&lt;/code&gt; 같은 시스템 변수로 경로와 세션 정보를 처리할 수 있다.&lt;/li&gt;
  &lt;/ul&gt;
&lt;/div&gt;</description>
      <category>AI</category>
      <author>JooMan</author>
      <guid isPermaLink="true">https://devgeek.tistory.com/163</guid>
      <comments>https://devgeek.tistory.com/163#entry163comment</comments>
      <pubDate>Fri, 20 Mar 2026 06:43:19 +0900</pubDate>
    </item>
    <item>
      <title>Claude Agent Skills - Skill 이해와 사용법</title>
      <link>https://devgeek.tistory.com/162</link>
      <description>&lt;style&gt;
  /* 코드 블록 래퍼 (파일명 헤더 포함) */
  .code-wrap {
    margin: 28px 0;
    border-radius: 10px;
    overflow: hidden;
    border: 1px solid var(--border);
    max-width: 100%;
  }
  .code-header {
    background: var(--surface2);
    padding: 10px 18px;
    display: flex;
    justify-content: space-between;
    align-items: center;
    border-bottom: 1px solid var(--border);
  }
  .code-filename {
    font-family: 'JetBrains Mono', monospace;
    font-size: 12px;
    color: var(--accent2);
  }
  .code-lang {
    font-size: 11px;
    color: var(--text-dim);
    text-transform: uppercase;
    letter-spacing: .08em;
  }
  .code-wrap pre { margin: 0; border-radius: 0; border: none; }

  /* 문법 색상 */
  .c-comment { color: #6e7681; }
  .c-key     { color: #a78bfa; }
  .c-str     { color: #7ee787; }
  .c-head    { color: #79c0ff; }
  .c-section { color: #ffa657; }

  /* 비교 카드 */
  .compare {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 16px;
    margin: 28px 0;
  }
  .compare-card {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 10px;
    padding: 20px;
    min-width: 0;
  }
  .compare-card.bad  { border-color: rgba(248,113,97,.3); }
  .compare-card.good { border-color: rgba(52,211,153,.3); }
  .compare-label {
    font-size: 11px;
    letter-spacing: .1em;
    text-transform: uppercase;
    margin-bottom: 12px;
    font-weight: 600;
    display: block;
  }
  .compare-card.bad  .compare-label { color: #f87171; }
  .compare-card.good .compare-label { color: #34d399; }
  .compare-card p { font-size: 14.5px; color: var(--text-muted); margin: 0; }

  /* Progressive Disclosure 레벨 카드 */
  .level-list {
    margin: 24px 0;
    display: flex;
    flex-direction: column;
    gap: 12px;
  }
  .level-item {
    display: flex;
    gap: 20px;
    padding: 20px;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 10px;
    align-items: flex-start;
  }
  .level-badge {
    flex-shrink: 0;
    width: 56px;
    height: 56px;
    border-radius: 10px;
    background: var(--hl);
    border: 1px solid var(--accent);
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    font-size: 10px;
    color: var(--text-dim);
    text-transform: uppercase;
    letter-spacing: .05em;
  }
  .level-badge span {
    font-size: 18px;
    font-weight: 700;
    color: var(--accent2);
    line-height: 1;
    margin-bottom: 2px;
  }
  .level-body { min-width: 0; }
  .level-body h4 { font-size: 15px; font-weight: 600; color: #fff; margin: 0 0 6px; }
  .level-body p  { font-size: 14px; color: var(--text-muted); margin: 0; }

  /* 동작 흐름 다이어그램 */
  .flow {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 12px;
    padding: 32px 24px;
    margin: 28px 0;
    text-align: center;
  }
  .flow-steps {
    display: flex;
    flex-direction: column;
    gap: 8px;
    align-items: center;
  }
  .flow-step {
    background: var(--code-bg);
    border: 1px solid var(--border);
    border-radius: 8px;
    padding: 12px 28px;
    font-size: 14px;
    color: var(--text);
    width: 100%;
    max-width: 480px;
    text-align: left;
    box-sizing: border-box;
  }
  .flow-step .step-num {
    font-size: 11px;
    color: var(--accent);
    text-transform: uppercase;
    letter-spacing: .1em;
    display: block;
    margin-bottom: 3px;
  }
  .flow-arrow { color: var(--text-dim); font-size: 18px; line-height: 1; }
  .flow-step.hl { border-color: var(--accent); background: var(--hl); }

  /* 테이블 — 좁은 화면에서 가로 스크롤 */
  .table-scroll {
    width: 100%;
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
    margin: 20px 0;
  }
  .table-scroll table { margin: 0; min-width: 480px; }

  /* 핵심 정리 박스 */
  .summary {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 12px;
    padding: 28px 32px;
    margin-top: 48px;
  }
  .summary-title {
    font-size: 13px;
    font-weight: 700;
    letter-spacing: .15em;
    text-transform: uppercase;
    color: var(--accent);
    margin-bottom: 16px;
    display: block;
  }
  .summary ul { padding-left: 20px; margin: 0; }
  .summary li { color: var(--text-muted); font-size: 15px; margin-bottom: 10px; }
  .summary li strong { color: var(--accent2); }

  /* 반응형 */
  @media (max-width: 640px) {
    .compare { grid-template-columns: 1fr; }
    .level-item { flex-direction: column; }
    .flow-step { padding: 12px 16px; max-width: 100%; }
    .summary { padding: 20px; }
  }
&lt;/style&gt;

&lt;h2&gt;들어가며&lt;/h2&gt;
&lt;p&gt;
  Claude API를 처음 사용할 때 대부분의 개발자는 시스템 프롬프트에 배경지식을 쭉 적어 넣습니다.
  &quot;너는 PDF 전처리 전문가야. 이 라이브러리를 쓰고, 이런 순서로 처리해야 해…&quot; 같은 식으로요.
  이 방법은 처음엔 잘 동작하지만, 결국 한계가 옵니다.
&lt;/p&gt;
&lt;p&gt;
  지식이 많아질수록 프롬프트는 길어지고, 길어진 프롬프트는 컨텍스트 윈도우를 잠식합니다.
  게다가 여러 프로젝트에서 같은 내용을 복붙하다 보면 관리도 엉망이 되죠.
&lt;/p&gt;
&lt;p&gt;
  &lt;strong&gt;Agent Skills&lt;/strong&gt;는 이 문제를 해결하기 위해 Anthropic이 설계한 구조입니다.
  한 번 만들어두면 Claude가 필요할 때 알아서 꺼내 씁니다.
&lt;/p&gt;

&lt;h2&gt;Agent Skills가 뭔가요?&lt;/h2&gt;
&lt;p&gt;
  Agent Skills는 Claude에게 도메인 특화 지식을 제공하는 &lt;strong&gt;파일 시스템 기반 모듈&lt;/strong&gt;입니다.
  쉽게 말하면, &quot;이 작업을 할 때는 이 가이드북을 참고해&quot;라고 Claude에게 알려주는 구조예요.
&lt;/p&gt;

&lt;blockquote&gt;
  &lt;strong&gt;핵심 아이디어:&lt;/strong&gt; 프롬프트는 그때그때 주어지는 &lt;em&gt;일회성 지시&lt;/em&gt;입니다.
  반면 Skills는 &lt;em&gt;상시 대기 중인 전문 지식&lt;/em&gt;이에요.
  Claude가 관련 요청을 받으면 그때 꺼내 씁니다.
&lt;/blockquote&gt;

&lt;p&gt;
  공식 문서에서는 Skills를 이렇게 설명합니다.
  &quot;새로 입사한 팀원에게 작성해주는 온보딩 가이드처럼&quot; 구성한다고요.
  역할, 절차, 예시, 도구 사용법이 담긴 폴더를 하나 만들어두면
  Claude가 그걸 읽고 전문가처럼 동작합니다.
&lt;/p&gt;

&lt;h2&gt;기존 방식과 무엇이 다른가요?&lt;/h2&gt;
&lt;p&gt;가장 큰 차이는 &lt;strong&gt;지식이 로드되는 시점&lt;/strong&gt;입니다.&lt;/p&gt;

&lt;div class=&quot;compare&quot;&gt;
  &lt;div class=&quot;compare-card bad&quot;&gt;
    &lt;span class=&quot;compare-label&quot;&gt;기존 방식 — 시스템 프롬프트&lt;/span&gt;
    &lt;p&gt;모든 지식을 대화 시작부터 컨텍스트에 올립니다. 쓰지 않을 정보도 항상 자리를 차지하죠.&lt;/p&gt;
  &lt;/div&gt;
  &lt;div class=&quot;compare-card good&quot;&gt;
    &lt;span class=&quot;compare-label&quot;&gt;Agent Skills&lt;/span&gt;
    &lt;p&gt;필요한 순간에만 해당 Skill을 읽어옵니다. 안 쓰는 지식은 컨텍스트를 소비하지 않아요.&lt;/p&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;
  이 구조를 공식 문서에서는 &lt;strong&gt;Progressive Disclosure(점진적 공개)&lt;/strong&gt;라고 부릅니다.
  3단계로 나눠서 설명할 수 있어요.
&lt;/p&gt;

&lt;h2&gt;Progressive Disclosure — 3단계 구조&lt;/h2&gt;

&lt;div class=&quot;level-list&quot;&gt;
  &lt;div class=&quot;level-item&quot;&gt;
    &lt;div class=&quot;level-badge&quot;&gt;&lt;span&gt;L1&lt;/span&gt;메타&lt;/div&gt;
    &lt;div class=&quot;level-body&quot;&gt;
      &lt;h4&gt;항상 로드됨 — Skill 이름 + 설명 (~100 토큰)&lt;/h4&gt;
      &lt;p&gt;Claude는 어떤 Skills가 있는지만 미리 알고 있습니다. 내용은 아직 로드하지 않아요.&lt;/p&gt;
    &lt;/div&gt;
  &lt;/div&gt;
  &lt;div class=&quot;level-item&quot;&gt;
    &lt;div class=&quot;level-badge&quot;&gt;&lt;span&gt;L2&lt;/span&gt;지시&lt;/div&gt;
    &lt;div class=&quot;level-body&quot;&gt;
      &lt;h4&gt;요청이 매칭될 때 로드됨 — SKILL.md 본문 (5,000 토큰 이하)&lt;/h4&gt;
      &lt;p&gt;관련 요청이 들어오면 SKILL.md 파일을 읽어 실제 지식을 컨텍스트에 올립니다.&lt;/p&gt;
    &lt;/div&gt;
  &lt;/div&gt;
  &lt;div class=&quot;level-item&quot;&gt;
    &lt;div class=&quot;level-badge&quot;&gt;&lt;span&gt;L3&lt;/span&gt;리소스&lt;/div&gt;
    &lt;div class=&quot;level-body&quot;&gt;
      &lt;h4&gt;필요할 때만 로드됨 — 추가 문서 + 스크립트 (사실상 무제한)&lt;/h4&gt;
      &lt;p&gt;추가 마크다운 파일이나 스크립트는 Claude가 직접 필요하다고 판단할 때만 읽습니다.&lt;/p&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;h2&gt;실제로 어떻게 동작하나요?&lt;/h2&gt;
&lt;p&gt;
  Claude가 &quot;이 PDF에서 텍스트 추출해줘&quot;라는 요청을 받았을 때
  내부적으로 어떤 일이 일어나는지 따라가봅시다.
&lt;/p&gt;

&lt;div class=&quot;flow&quot;&gt;
  &lt;div class=&quot;flow-steps&quot;&gt;
    &lt;div class=&quot;flow-step&quot;&gt;
      &lt;span class=&quot;step-num&quot;&gt;Step 1 · 시작 시&lt;/span&gt;
      시스템 프롬프트에 Skills 목록 로드 (이름 + 설명만, L1)
    &lt;/div&gt;
    &lt;div class=&quot;flow-arrow&quot;&gt;↓&lt;/div&gt;
    &lt;div class=&quot;flow-step&quot;&gt;
      &lt;span class=&quot;step-num&quot;&gt;Step 2 · 요청 수신&lt;/span&gt;
      &quot;이 PDF에서 텍스트 추출해줘&quot;
    &lt;/div&gt;
    &lt;div class=&quot;flow-arrow&quot;&gt;↓&lt;/div&gt;
    &lt;div class=&quot;flow-step hl&quot;&gt;
      &lt;span class=&quot;step-num&quot;&gt;Step 3 · Skill 트리거&lt;/span&gt;
      PDF 관련 Skill 감지 → bash로 SKILL.md 읽기 (L2)
    &lt;/div&gt;
    &lt;div class=&quot;flow-arrow&quot;&gt;↓&lt;/div&gt;
    &lt;div class=&quot;flow-step&quot;&gt;
      &lt;span class=&quot;step-num&quot;&gt;Step 4 · 선택적 로드&lt;/span&gt;
      폼 작성은 불필요 → FORMS.md는 읽지 않음 (L3 스킵)
    &lt;/div&gt;
    &lt;div class=&quot;flow-arrow&quot;&gt;↓&lt;/div&gt;
    &lt;div class=&quot;flow-step&quot;&gt;
      &lt;span class=&quot;step-num&quot;&gt;Step 5 · 실행&lt;/span&gt;
      SKILL.md 지시에 따라 작업 완료
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;
  중요한 점은, Claude가 가상 머신의 &lt;strong&gt;파일 시스템&lt;/strong&gt;에 접근해서
  bash 명령으로 파일을 직접 읽는다는 겁니다.
  Skills는 단순한 텍스트 설정이 아니라, 실행 가능한 스크립트까지 포함할 수 있는 실제 디렉토리입니다.
&lt;/p&gt;

&lt;h2&gt;SKILL.md 파일은 어떻게 생겼나요?&lt;/h2&gt;
&lt;p&gt;모든 Skill은 &lt;code&gt;SKILL.md&lt;/code&gt; 파일 하나에서 시작합니다. 구조는 생각보다 단순합니다.&lt;/p&gt;

&lt;div class=&quot;code-wrap&quot;&gt;
  &lt;div class=&quot;code-header&quot;&gt;
    &lt;span class=&quot;code-filename&quot;&gt;SKILL.md&lt;/span&gt;
    &lt;span class=&quot;code-lang&quot;&gt;Markdown + YAML Frontmatter&lt;/span&gt;
  &lt;/div&gt;
  &lt;pre&gt;&lt;span class=&quot;c-comment&quot;&gt;---&lt;/span&gt;
&lt;span class=&quot;c-key&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;c-comment&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;c-str&quot;&gt;pdf-processing&lt;/span&gt;
&lt;span class=&quot;c-key&quot;&gt;description&lt;/span&gt;&lt;span class=&quot;c-comment&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;c-str&quot;&gt;Extract text and tables from PDF files,&lt;/span&gt;
  &lt;span class=&quot;c-str&quot;&gt;fill forms, merge documents.&lt;/span&gt;
  &lt;span class=&quot;c-str&quot;&gt;Use when working with PDF files or when the user&lt;/span&gt;
  &lt;span class=&quot;c-str&quot;&gt;mentions PDFs, forms, or document extraction.&lt;/span&gt;
&lt;span class=&quot;c-comment&quot;&gt;---&lt;/span&gt;

&lt;span class=&quot;c-head&quot;&gt;# PDF Processing Skill&lt;/span&gt;

&lt;span class=&quot;c-section&quot;&gt;## 빠른 시작&lt;/span&gt;

텍스트 추출에는 pdfplumber를 사용합니다:

&lt;span class=&quot;c-str&quot;&gt;```python
import pdfplumber

with pdfplumber.open(&quot;document.pdf&quot;) as pdf:
    text = pdf.pages[0].extract_text()
```&lt;/span&gt;

&lt;span class=&quot;c-section&quot;&gt;## 폼 작성이 필요하다면&lt;/span&gt;

자세한 내용은 [FORMS.md](FORMS.md)를 참고하세요.
(복잡한 폼 작성 시에만 이 파일을 읽습니다)&lt;/pre&gt;
&lt;/div&gt;

&lt;p&gt;
  YAML frontmatter의 &lt;code&gt;description&lt;/code&gt; 필드가 핵심입니다.
  Claude는 이 설명을 보고 &quot;이 Skill을 지금 써야 하는가?&quot;를 판단합니다.
  &lt;strong&gt;언제 써야 하는지를 명확히 적는 것이 가장 중요합니다.&lt;/strong&gt;
&lt;/p&gt;

&lt;h3&gt;name 필드 규칙&lt;/h3&gt;

&lt;div class=&quot;code-wrap&quot;&gt;
  &lt;div class=&quot;code-header&quot;&gt;
    &lt;span class=&quot;code-filename&quot;&gt;name 필드 규칙&lt;/span&gt;
    &lt;span class=&quot;code-lang&quot;&gt;YAML&lt;/span&gt;
  &lt;/div&gt;
  &lt;pre&gt;&lt;span class=&quot;c-comment&quot;&gt;# ✅ 올바른 예시&lt;/span&gt;
&lt;span class=&quot;c-key&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;c-comment&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;c-str&quot;&gt;pdf-processing&lt;/span&gt;        &lt;span class=&quot;c-comment&quot;&gt;# 소문자, 숫자, 하이픈만 허용&lt;/span&gt;
&lt;span class=&quot;c-key&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;c-comment&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;c-str&quot;&gt;data-export-tool&lt;/span&gt;      &lt;span class=&quot;c-comment&quot;&gt;# 최대 64자&lt;/span&gt;

&lt;span class=&quot;c-comment&quot;&gt;# ❌ 잘못된 예시&lt;/span&gt;
&lt;span class=&quot;c-key&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;c-comment&quot;&gt;:&lt;/span&gt; PDF_Processing         &lt;span class=&quot;c-comment&quot;&gt;# 대문자·언더스코어 불가&lt;/span&gt;
&lt;span class=&quot;c-key&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;c-comment&quot;&gt;:&lt;/span&gt; claude-pdf-tool        &lt;span class=&quot;c-comment&quot;&gt;# 'claude' 예약어&lt;/span&gt;
&lt;span class=&quot;c-key&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;c-comment&quot;&gt;:&lt;/span&gt; anthropic-helper       &lt;span class=&quot;c-comment&quot;&gt;# 'anthropic' 예약어&lt;/span&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;h2&gt;Skill 디렉토리 구조&lt;/h2&gt;
&lt;p&gt;
  단순한 작업이라면 &lt;code&gt;SKILL.md&lt;/code&gt; 하나로 충분합니다.
  하지만 복잡한 도메인이라면 파일을 나눠서 관리할 수 있어요.
&lt;/p&gt;

&lt;div class=&quot;code-wrap&quot;&gt;
  &lt;div class=&quot;code-header&quot;&gt;
    &lt;span class=&quot;code-filename&quot;&gt;Skill 폴더 구조 예시&lt;/span&gt;
    &lt;span class=&quot;code-lang&quot;&gt;Directory&lt;/span&gt;
  &lt;/div&gt;
  &lt;pre&gt;pdf-skill/
├── &lt;span class=&quot;c-head&quot;&gt;SKILL.md&lt;/span&gt;          &lt;span class=&quot;c-comment&quot;&gt;# 핵심 지시 (항상 먼저 읽힘)&lt;/span&gt;
├── &lt;span class=&quot;c-section&quot;&gt;FORMS.md&lt;/span&gt;          &lt;span class=&quot;c-comment&quot;&gt;# 폼 작성 상세 가이드 (필요할 때만)&lt;/span&gt;
├── &lt;span class=&quot;c-section&quot;&gt;REFERENCE.md&lt;/span&gt;      &lt;span class=&quot;c-comment&quot;&gt;# API 레퍼런스 (필요할 때만)&lt;/span&gt;
└── scripts/
    └── &lt;span class=&quot;c-str&quot;&gt;fill_form.py&lt;/span&gt;  &lt;span class=&quot;c-comment&quot;&gt;# 실행 스크립트 (결과만 컨텍스트에 올라옴)&lt;/span&gt;&lt;/pre&gt;
&lt;/div&gt;

&lt;blockquote&gt;
  &lt;strong&gt;스크립트의 장점:&lt;/strong&gt; Claude가 &lt;code&gt;fill_form.py&lt;/code&gt;를 실행하면,
  스크립트 &lt;em&gt;코드 자체&lt;/em&gt;는 컨텍스트에 올라오지 않습니다. 실행 &lt;em&gt;결과&lt;/em&gt;만 올라와요.
  반복적이고 정형화된 로직을 스크립트로 빼면 컨텍스트를 크게 절약할 수 있습니다.
&lt;/blockquote&gt;

&lt;h2&gt;어디서 사용할 수 있나요?&lt;/h2&gt;
&lt;p&gt;Agent Skills는 Anthropic의 여러 제품에서 사용할 수 있습니다. 각 환경마다 특징이 조금씩 다릅니다.&lt;/p&gt;

&lt;div class=&quot;table-scroll&quot;&gt;
  &lt;table&gt;
    &lt;thead&gt;
      &lt;tr&gt;
        &lt;th&gt;환경&lt;/th&gt;
        &lt;th&gt;사용 방법&lt;/th&gt;
        &lt;th&gt;특이사항&lt;/th&gt;
      &lt;/tr&gt;
    &lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;
        &lt;td&gt;&lt;strong&gt;Claude API&lt;/strong&gt;&lt;/td&gt;
        &lt;td&gt;&lt;code&gt;container&lt;/code&gt; 파라미터에 &lt;code&gt;skill_id&lt;/code&gt; 지정&lt;/td&gt;
        &lt;td&gt;베타 헤더 3개 필요. 기본 제공 Skills(PDF·Excel·Word 등) 즉시 사용 가능. 커스텀 Skill은 &lt;code&gt;/v1/skills&lt;/code&gt;로 업로드.&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
        &lt;td&gt;&lt;strong&gt;Claude.ai&lt;/strong&gt;&lt;/td&gt;
        &lt;td&gt;설정 → Features에서 업로드&lt;/td&gt;
        &lt;td&gt;Pro·Max·Team·Enterprise 플랜 한정. 현재 개인 단위 공유만 지원.&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
        &lt;td&gt;&lt;strong&gt;Claude Code / Agent SDK&lt;/strong&gt;&lt;/td&gt;
        &lt;td&gt;&lt;code&gt;.claude/skills/&lt;/code&gt; 폴더에 배치&lt;/td&gt;
        &lt;td&gt;파일 시스템 기반 자동 인식. 네트워크 접근 자유로움.&lt;/td&gt;
      &lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;
&lt;/div&gt;

&lt;h3&gt;API 요청 예시&lt;/h3&gt;

&lt;div class=&quot;code-wrap&quot;&gt;
  &lt;div class=&quot;code-header&quot;&gt;
    &lt;span class=&quot;code-filename&quot;&gt;api_example.py&lt;/span&gt;
    &lt;span class=&quot;code-lang&quot;&gt;Python&lt;/span&gt;
  &lt;/div&gt;
  &lt;pre&gt;&lt;span class=&quot;c-key&quot;&gt;import&lt;/span&gt; anthropic

client = anthropic.Anthropic()

response = client.beta.messages.create(
    model=&lt;span class=&quot;c-str&quot;&gt;&quot;claude-opus-4-6&quot;&lt;/span&gt;,
    max_tokens=&lt;span class=&quot;c-str&quot;&gt;4096&lt;/span&gt;,
    messages=[{&lt;span class=&quot;c-str&quot;&gt;&quot;role&quot;&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;&quot;user&quot;&lt;/span&gt;, &lt;span class=&quot;c-str&quot;&gt;&quot;content&quot;&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;&quot;이 PDF에서 텍스트를 추출해줘&quot;&lt;/span&gt;}],
    container={&lt;span class=&quot;c-str&quot;&gt;&quot;type&quot;&lt;/span&gt;: &lt;span class=&quot;c-str&quot;&gt;&quot;auto&quot;&lt;/span&gt;, &lt;span class=&quot;c-str&quot;&gt;&quot;skill_ids&quot;&lt;/span&gt;: [&lt;span class=&quot;c-str&quot;&gt;&quot;pdf-processing&quot;&lt;/span&gt;]},
    betas=[
        &lt;span class=&quot;c-str&quot;&gt;&quot;code-execution-2025-08-25&quot;&lt;/span&gt;,
        &lt;span class=&quot;c-str&quot;&gt;&quot;skills-2025-10-02&quot;&lt;/span&gt;,
        &lt;span class=&quot;c-str&quot;&gt;&quot;files-api-2025-04-14&quot;&lt;/span&gt;,
    ],
)&lt;/pre&gt;
&lt;/div&gt;

&lt;h2&gt;알아두면 좋은 제약 사항&lt;/h2&gt;

&lt;blockquote&gt;
  &lt;strong&gt;Cross-surface 동기화 없음:&lt;/strong&gt;
  Claude.ai에 업로드한 Skill은 API에서 자동으로 사용할 수 없습니다. 환경마다 별도 관리가 필요해요.
  &lt;br&gt;&lt;br&gt;
  &lt;strong&gt;API 환경에서 네트워크 접근 제한:&lt;/strong&gt;
  외부 API 호출이나 인터넷 접근이 막혀 있습니다. 미리 설치된 패키지만 사용할 수 있어요.
  &lt;br&gt;&lt;br&gt;
  &lt;strong&gt;Claude.ai 커스텀 Skill은 개인 단위:&lt;/strong&gt;
  팀 전체에 동일한 Skill을 배포하려면 각자 업로드해야 합니다.
&lt;/blockquote&gt;

&lt;h2&gt;정리하며&lt;/h2&gt;
&lt;p&gt;
  Agent Skills는 단순한 편의 기능이 아닙니다.
  프롬프트를 코드처럼 관리하고, AI의 전문 지식을 모듈화해서 재사용할 수 있는 구조입니다.
&lt;/p&gt;
&lt;p&gt;
  팀 단위로 Claude를 활용하거나, 특정 도메인에서 일관된 동작이 필요하다면
  시스템 프롬프트 대신 Skills 도입을 진지하게 고려해볼 만 합니다.
&lt;/p&gt;

&lt;div class=&quot;summary&quot;&gt;
  &lt;span class=&quot;summary-title&quot;&gt;핵심 정리&lt;/span&gt;
  &lt;ul&gt;
    &lt;li&gt;&lt;strong&gt;Agent Skills = 재사용 가능한 지식 모듈.&lt;/strong&gt; 한 번 만들면 여러 대화에서 자동으로 활용됩니다.&lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;Progressive Disclosure로 컨텍스트 절약.&lt;/strong&gt; 필요한 순간에만 해당 정보를 로드합니다.&lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;SKILL.md의 &lt;code&gt;description&lt;/code&gt; 필드가 핵심.&lt;/strong&gt; Claude가 언제 이 Skill을 써야 하는지 판단하는 기준입니다.&lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;스크립트까지 번들링 가능.&lt;/strong&gt; 실행 결과만 컨텍스트에 올라와 토큰 효율이 높습니다.&lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;환경마다 별도 관리 필요.&lt;/strong&gt; API, Claude.ai, Claude Code 간 자동 동기화는 없습니다.&lt;/li&gt;
  &lt;/ul&gt;
&lt;/div&gt;</description>
      <category>AI</category>
      <category>claude</category>
      <category>Claude Skill</category>
      <author>JooMan</author>
      <guid isPermaLink="true">https://devgeek.tistory.com/162</guid>
      <comments>https://devgeek.tistory.com/162#entry162comment</comments>
      <pubDate>Thu, 19 Mar 2026 06:44:09 +0900</pubDate>
    </item>
    <item>
      <title>[Jetpack Compose] 테마 간단하게 설정하기</title>
      <link>https://devgeek.tistory.com/161</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Jetpack Compose로 UI를 구성하다 보면 &lt;code&gt;MaterialTheme.colorScheme.primary&lt;/code&gt; 같은 코드를 자연스럽게 쓰게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 막상 이렇게 생각해보면 의문이 든다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이 테마는 어디서 정의되는 걸까?&lt;/li&gt;
&lt;li&gt;색상들은 어떤 기준으로 만들어졌을까?&lt;/li&gt;
&lt;li&gt;디자인 시스템이라는 건 정확하게 무엇을 의미할까?&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 &lt;b&gt;Jetpack Compose에서 Material Design 3 테마 구조를 학습하면서 정리한 내용&lt;/b&gt;이다.&lt;br /&gt;특히 Material Theme Builder를 활용해 &lt;b&gt;커스텀 테마를 생성하고 Compose에 적용하는 흐름&lt;/b&gt;을 중심으로 정리해보려고 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;학습 프로젝트 소개: Compose Theming Study&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에서 정리한 내용은 아래 학습용 프로젝트를 기반으로 한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;screenshot.png&quot; data-origin-width=&quot;1120&quot; data-origin-height=&quot;743&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/NZ1Ko/dJMcajgzfV8/BZ5X7FILUqeNDcVMGc4WmK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/NZ1Ko/dJMcajgzfV8/BZ5X7FILUqeNDcVMGc4WmK/img.png&quot; data-alt=&quot;학습용 프로젝틍 앱 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/NZ1Ko/dJMcajgzfV8/BZ5X7FILUqeNDcVMGc4WmK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FNZ1Ko%2FdJMcajgzfV8%2FBZ5X7FILUqeNDcVMGc4WmK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1120&quot; height=&quot;743&quot; data-filename=&quot;screenshot.png&quot; data-origin-width=&quot;1120&quot; data-origin-height=&quot;743&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;학습용 프로젝틍 앱 화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;figure id=&quot;og_1767695223566&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - JuhyeokLee97/ComposeThemingStudy&quot; data-og-description=&quot;Contribute to JuhyeokLee97/ComposeThemingStudy development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/JuhyeokLee97/ComposeThemingStudy&quot; data-og-url=&quot;https://github.com/JuhyeokLee97/ComposeThemingStudy&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bhWHYs/hyZRo6sJu1/5dWo8kd94sdRLemFkkay40/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/bR2Mo0/hyZPM2Ftcf/kGM8IQBPulrVxszf7sH6n1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/JuhyeokLee97/ComposeThemingStudy&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/JuhyeokLee97/ComposeThemingStudy&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bhWHYs/hyZRo6sJu1/5dWo8kd94sdRLemFkkay40/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/bR2Mo0/hyZPM2Ftcf/kGM8IQBPulrVxszf7sH6n1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - JuhyeokLee97/ComposeThemingStudy&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to JuhyeokLee97/ComposeThemingStudy development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 프로젝트의 목적은 명확하다.&lt;br /&gt;&lt;b&gt;Jetpack Compose에서 디자인 시스템과 테마 구조를 이해하는 것&lt;/b&gt;이다&lt;br /&gt;기능 구현보다는, Material Design 3 테마가 어떻게 구성되고 Compose에서 어떻게 사용되는지를 중심으로 살펴보는 데 초점을 맞췄다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글을 통해 Jetpack Compose에서 Theme이 어떤 구조로 구성되고, 어떤 흐름으로 프로젝트에 적용되는지 전체적인 그림을 잡을 수 있기를 바란다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Jetpack Compose에서 Theme은 무엇을 담당할까?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Compose에서 MaterialTheme는 단순한 색상 묶음이 아니다. 앱 전체 UI의 &lt;b&gt;기준점&lt;/b&gt;에 해당한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대표적으로 다음 요소들을 포함한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;ColorScheme&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;primary / secondary / tertiary&lt;/li&gt;
&lt;li&gt;background / surface / error&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Typography&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;body, title, label 계열의 텍스트 스타일&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Shape&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;버튼, 카드 등 기본 컴포넌트 형태&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Theme의 중요한 점은, UI 코드에서 &lt;b&gt;직접 색상이나 스타일을 결정하지 않도록 유도&lt;/b&gt;한다는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다시 말해서 Compose에서 Theme는 &quot;&lt;b&gt;디자인 값을 직접 쓰지 않게 만드는 장치&lt;/b&gt;&quot;에 가깝다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;테마를 직접 정의하려다 느낀 어려움&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Material Design 3 문서를 기준으로 테마를 직접 구성해보려고 하면 생각보다 고려해야 할 요소가 많다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Light / Dark 테마 각각의 색상&lt;/li&gt;
&lt;li&gt;대비 문제&lt;/li&gt;
&lt;li&gt;각 Color token의 역할&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;학습 단계에서는 &quot;&lt;b&gt;이게 맞는 방향인가?&lt;/b&gt;&quot;라는 의문이 계속 생기곤 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 과정에서 사용해본 도구가 &lt;b&gt;Material Theme Builder&lt;/b&gt;였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;a href=&quot;https://material-foundation.github.io/material-theme-builder/&quot;&gt;Material Theme Builder&lt;/a&gt;로 테마 생성하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Material Theme Builder는 Material Design 3 기준에 맞는 테마를 자동으로 생성해주는 도구다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심 특징은 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Primary 색상 기준으로 ColorScheme 생성&lt;/li&gt;
&lt;li&gt;Light / Dark 테마 자동 구성&lt;/li&gt;
&lt;li&gt;Material 3 가이드라인 준수&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;학습 목적에서 가장 좋았던 점은 &lt;b&gt;완성된 결과물을 먼저 보고 구조를 이해할 수 있다는 점&lt;/b&gt;이다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://developer.android.com/static/codelabs/jetpack-compose-theming/img/c7eb969bd528cb3b.png?hl=ko&quot; /&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Compose 프로젝트로 테마 코드 가져오기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Material Theme Builder에서는 Export 옵션으로 Jetpack Compose(Theme)를 선택할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Export된 코드에는 보통 다음과 같은 파일이 포함된다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Color.kt&lt;/li&gt;
&lt;li&gt;Theme.kt&lt;/li&gt;
&lt;li&gt;(Typography / Shape 관련 설정)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;directory_capture.png&quot; data-origin-width=&quot;1500&quot; data-origin-height=&quot;270&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cdW1oK/dJMb9958Byk/KkpTjnc5kBOcL93CiC1CB0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cdW1oK/dJMb9958Byk/KkpTjnc5kBOcL93CiC1CB0/img.png&quot; data-alt=&quot;Material Theme Builder를 통해 Export 된 파일들&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cdW1oK/dJMb9958Byk/KkpTjnc5kBOcL93CiC1CB0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcdW1oK%2FdJMb9958Byk%2FKkpTjnc5kBOcL93CiC1CB0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1500&quot; height=&quot;270&quot; data-filename=&quot;directory_capture.png&quot; data-origin-width=&quot;1500&quot; data-origin-height=&quot;270&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Material Theme Builder를 통해 Export 된 파일들&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;학습 프로젝트에서는 이 구조를 그대로 가져와 Compose Theme이 어떻게 정의되고 연결되는지를 중심으로 살펴봤다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 과정을 통해 자연스럽게 다음을 이해할 수 있었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;ColorScheme이 어떻게 분리되는지&lt;/li&gt;
&lt;li&gt;Light / Dark 테마가 어떻게 선택되는지&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;프로젝트에서 Theme 설정 구조&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;ui/theme/Color.kt&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Color.kt&lt;/code&gt; 파일은 테마에서 사용할 색상을 정의하고 관리하는 파일이다.&lt;br /&gt;즉, 색상 값 자체보다 &quot;이 색이 어떤 역할을 하는지&quot;에 집중하도록 설계되어 있다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;import androidx.compose.ui.graphics.Color

val primaryLight = Color(0xFF4C662B)
val onPrimaryLight = Color(0xFFFFFFFF)
val primaryContainerLight = Color(0xFFCDEDA3)
val onPrimaryContainerLight = Color(0xFF354E16)
...
val primaryDark = Color(0xFFB1D18A)
val onPrimaryDark = Color(0xFF1F3701)
val primaryContainerDark = Color(0xFF354E16)
val onPrimaryContainerDark = Color(0xFFCDEDA3)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이 구조 덕분에 테마 변경이 필요할 때도 UI 코드에 손대지 않고 색상 정의만 수정할 수 있다.&lt;/b&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;ui/theme/Type.kt&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Type.kt&lt;/code&gt; 파일은 테마에서 사용할 TextStyle에 해당하는 typography를 정의하고 관리하는 파일이다.&lt;/p&gt;
&lt;pre class=&quot;nix&quot;&gt;&lt;code&gt;val provider = GoogleFont.Provider(
    providerAuthority = &quot;com.google.android.gms.fonts&quot;,
    providerPackage = &quot;com.google.android.gms&quot;,
    certificates = R.array.com_google_android_gms_fonts_certs
)

val bodyFontFamily = FontFamily(
    Font(
        googleFont = GoogleFont(&quot;Roboto&quot;),
        fontProvider = provider,
    )
)

val displayFontFamily = FontFamily(
    Font(
        googleFont = GoogleFont(&quot;Roboto&quot;),
        fontProvider = provider,
    )
)

val baseline = Typography(
    headlineSmall = TextStyle(
        fontWeight = FontWeight.SemiBold,
        fontSize = 24.sp,
        lineHeight = 32.sp,
        letterSpacing = 0.sp
    ),
    titleLarge = TextStyle(
        fontWeight = FontWeight.Normal,
        fontSize = 18.sp,
        lineHeight = 32.sp,
        letterSpacing = 0.sp
    ),
    ...
)

val typography = Typography(
    displayLarge = baseline.displayLarge.copy(fontFamily = displayFontFamily),
    displayMedium = baseline.displayMedium.copy(fontFamily = displayFontFamily),
    displaySmall = baseline.displaySmall.copy(fontFamily = displayFontFamily),
    ...
)&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;ui/theme/Theme.kt&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Theme.kt&lt;/code&gt; 파일은 앱에서 사용할 커스텀한 &lt;code&gt;MaterialTheme&lt;/code&gt;을 설정하는 파일이다.&lt;br /&gt;&lt;code&gt;Color.kt&lt;/code&gt;에 정의된 값들을 참조해 lightColorScheme, darkColorScheme을 정의하고, &lt;code&gt;Type.kt&lt;/code&gt;에 정의된 typography를 함께 설정해 커스텀한 &lt;code&gt;ComposeThemingStudyTheme&lt;/code&gt; 내부의 &lt;code&gt;MaterialTheme&lt;/code&gt;에 적용한다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;private val LightColorScheme = lightColorScheme(
    primary = primaryLight,
    onPrimary = onPrimaryLight,
    ...
)
private val DarkColorScheme = darkColorScheme(
    primary = primaryDark,
    onPrimary = onPrimaryDark,
    ...
)

@Composable
fun ComposeThemingStudyTheme(
    useDarkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -&amp;gt; Unit
) {
    val colorScheme = when {
        useDarkTheme -&amp;gt; DarkColorScheme
        else -&amp;gt; LightColorScheme
    }
    ...
    MaterialTheme(
        colorScheme = colorScheme,
        typography = typography,
        content = content
    )
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래처럼 Theme 레벨에서 colorScheme을 분기함으로써, 개별 Composable에서는 다크 모드 여부를 전혀 신경 쓰지 않아도 된다.&lt;/p&gt;
&lt;pre class=&quot;ocaml&quot;&gt;&lt;code&gt;val colorScheme = when {
    useDarkTheme -&amp;gt; DarkColorScheme
    else -&amp;gt; LightColorScheme
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Theme 사용하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;학습 프로젝트에서는 위와 같은 형태로 Theme을 정의하고, &lt;code&gt;setContent{}&lt;/code&gt; 내부에서 Composable 함수 전체를 감싼다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;setContent {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    ComposeThemingStudyTheme {
        Surface(tonalElevation = 5.dp) {
            ReplyApp(
                replyHomeUiState = uiState,
                closeDetailScreen = {
                    viewModel.closeDetailScreen()
                },
                navigateToDetail = { emailId -&amp;gt;
                    viewModel.setSelectedEmail(emailId)
                }
            )
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조를 기준으로 &lt;code&gt;setContent{}&lt;/code&gt; 내부의 모든 Composable은 &lt;code&gt;MaterialTheme.colorScheme&lt;/code&gt;과 &lt;code&gt;MaterialTheme.typography&lt;/code&gt;를 통해서 &lt;code&gt;ComposeThemingStudyTheme&lt;/code&gt;에 정의된 colorScheme과 typography에 접근하도록 구성했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조 덕분에 Composable에서는 &quot;어떤 색을 쓸지&quot;가 아니라 &quot;이 UI가 어떤 역할인지&quot;만 표현하면 되도록 구성할 수 있었다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Theme 적용 전 vs 후 화면&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;theme 적용 비교.png&quot; data-origin-width=&quot;1702&quot; data-origin-height=&quot;1030&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bUE6wn/dJMcafL29V8/MwRda1bJ2ziv2dwuDfMbdk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bUE6wn/dJMcafL29V8/MwRda1bJ2ziv2dwuDfMbdk/img.png&quot; data-alt=&quot;Theme 적용 전 vs 적용 후 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bUE6wn/dJMcafL29V8/MwRda1bJ2ziv2dwuDfMbdk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbUE6wn%2FdJMcafL29V8%2FMwRda1bJ2ziv2dwuDfMbdk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1702&quot; height=&quot;1030&quot; data-filename=&quot;theme 적용 비교.png&quot; data-origin-width=&quot;1702&quot; data-origin-height=&quot;1030&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Theme 적용 전 vs 적용 후 화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jetpack Compose에서 Theme을 이해하는 것은 단순히 색상을 바꾸는 법을 배우는 것이 아니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Theme은 UI를 예쁘게 만드는 도구가 아니라 &lt;b&gt;일관성을 강제하는 구조&lt;/b&gt;다&lt;/li&gt;
&lt;li&gt;Material Design 3의 Color token들은 &quot;&lt;b&gt;역할 기반&lt;/b&gt;&quot;으로 이해해야 한다&lt;/li&gt;
&lt;li&gt;&quot;디자인 시스템이 무엇인지&quot;, &quot;UI를 구조적으로 관리한다는 것이 어떤 의미인지&quot;를 함께 이해하는 과정에 가깝다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Material Theme Builder는 그 과정을 시작하기에 좋은 도구였고, 학습 단계에서 &quot;&lt;b&gt;올바른 기준을 가진 결과물&lt;/b&gt;&quot;을 빠르게 확인할 수 있게 도와줬다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Compose Theme 구조가 막연하게 느껴졌다면, 한 번쯤은 테마만을 목적으로 한 작은 학습 프로젝트를 만들어보는 것도 좋은 방법이라고 생각한다.&lt;/p&gt;</description>
      <category>안드로이드/Jetpack Compose</category>
      <author>JooMan</author>
      <guid isPermaLink="true">https://devgeek.tistory.com/161</guid>
      <comments>https://devgeek.tistory.com/161#entry161comment</comments>
      <pubDate>Wed, 7 Jan 2026 07:30:23 +0900</pubDate>
    </item>
    <item>
      <title>OkHttp는 왜 사용했을까?</title>
      <link>https://devgeek.tistory.com/160</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Android 공식 배발 가이드에서도 네트워크 통신 라이브러리로 Retrofit 사용을 권장하고 있다.&lt;br /&gt;나 또한 Retrofit을 당연하게 사용해왔지만 &lt;b&gt;왜 Retrofit 사용이 표준이 되었는지&lt;/b&gt; 궁금해졌고 글로 설명된 Retrofit 장점을 직접 느껴보기 위해서 Square에서 제공하는 &lt;b&gt;OkHttp&lt;/b&gt;가 어떻게 사용되는지 학습하게 됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에서는 OkHttp를 직접 사용해 네트워크 통신을 구현해보며, &lt;b&gt;OkHttp가 어떤 방식으로 동작하는지&lt;/b&gt;와 Java의 &lt;b&gt;HttpURLConnection 대비 어떤 점이 개선되었는지&lt;/b&gt;를 정리해본다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;OkHttp를 이용한 네트워크 GET 요청 처리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OkHttp에서는 HttpURLConnection과 다르게 사용자가 &lt;b&gt;동기적&lt;/b&gt;으로 통신할 수 있고 &lt;b&gt;비동기적&lt;/b&gt;으로 통신할 수 있는 선택지를 제공한다.&lt;br /&gt;먼저 &lt;b&gt;동기적&lt;/b&gt;으로 유저 정보를 가져오는 GET 방식을 학습했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;유저 정보를 읽어오는 GET 요청 구현 소스 코드 1 - 동기 방식(&lt;code&gt;execute&lt;/code&gt;)&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;val client = OkHttpClient()

fun fetchUserWithOkHttpClient(userId: Int): User? {
    val request = Request.Builder()
        .url(&quot;https://jsonplaceholder.typicode.com/users/$userId&quot;)
        .build()
    return try {
        val response = client.newCall(request).execute()
        if (response.isSuccessful) {
            val jsonBody = response.body()?.string()
            Gson().fromJson(jsonBody, User::class.java)
        } else {
            null
        }
    } catch(e: Exception) {
        null
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;네트워크 통신 이해 및 소스 코드 분석&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. 네트워크 통신을 수행할 &lt;code&gt;OkHttpClient()&lt;/code&gt;를 생성한다.&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;val client = OkHttpClient()&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. 네트워크 통신에 대한 정보를 갖는 Request 객체를 만든다.&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Request 객체는 Builder 패턴으로 만든다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Builder#url()&lt;/code&gt; 메서드를 이용해서 서버의 사용자 조회 API URL를 설정한다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;.url(&quot;https://jsonplaceholder.typicode.com/users/$userId&quot;)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;build()&lt;/code&gt;를 통해서 통신 관련된 설정값을 포함한 Request를 만든다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네트워크 통신 method를 정의하지 않은 이유는 Builder를 통해 생성 시, 기본값이 GET이기 때문에 설정하지 않았다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. OkHttpClient를 이용해서 네트워크 통신을 동기적으로 처리하도록 요청한다.&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;client.newCall(request).execute()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;execute()&lt;/code&gt;: 동기적으로 처리하는 함수이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;4. 네트워크 통신이 성공한 경우에 대해 처리한다.&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;response.isSuccessful&lt;/code&gt;: 네트워크 통신 결과값(OkHttp에서 제공하는 Response)을 통해서 성공 여부를 확인한다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;response.body()?.string()&lt;/code&gt;: 통신 결과값 중, Body 영역에 해당하는 값을 읽어온다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Gson().fromJson(jsonBody, User::class.java)&lt;/code&gt;: Json 형식의 문자열로 반환된 Body 값을 미리 정의해둔 User 데이터 클래스 형태로 변환한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;유저 정보를 읽어오는 GET 요청 구현 소스 코드 2 - 비동기 방식(&lt;code&gt;enqueue&lt;/code&gt;)&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;val client = OkHttpClient()

private fun fetchUserWithOkHttpClient(
    userId: Int,
    onSuccess: (User) -&amp;gt; Unit,
    onError: (String) -&amp;gt; Unit
) {
    val request = Request.Builder()
        .url(&quot;https://jsonplaceholder.typicode.com/users/$userId&quot;)
        .build()

    client.newCall(request).enqueue(object : Callback {
        override fun onResponse(response: Response) {
            if (response.isSuccessful) {
                val jsonBody = response.body().string()
                val user = Gson().fromJson(jsonBody, User::class.java)
                onSuccess(user)
            }
        }

        override fun onFailure(request: Request, e: IOException) {
            onError(e.toString())
        }
    })
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;네트워크 통신 이해 및 소스 코드 분석&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. 네트워크 통신을 수행할 &lt;code&gt;OkHttpClient()&lt;/code&gt;를 생성한다.&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;val client = OkHttpClient()&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. 네트워크 통신에 대한 정보를 갖는 Request 객체를 만든다.&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Request 객체는 Builder 패턴으로 만든다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Builder#url()&lt;/code&gt; 메서드를 이용해서 서버의 사용자 조회 API URL를 설정한다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;.url(&quot;https://jsonplaceholder.typicode.com/users/$userId&quot;)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;build()&lt;/code&gt;를 통해서 통신 관련된 설정값을 포함한 Request를 만든다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네트워크 통신 method를 정의하지 않은 이유는 Builder를 통해 생성 시, 기본값이 GET이기 때문에 설정하지 않았다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. OkHttpClient 이용해서 네트워크 통신을 비동기적으로 처리하도록 요청한다.&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;client.newCall(request).enqueue(object: Callback{ ... })&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;enqueue()&lt;/code&gt;: 비동기적으로 처리하는 함수이다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;object: Callback{ ... }&lt;/code&gt;: 비동기적으로 처리하기 위해서 네트워크 통신 성공과 실패를 처리할 콜백을 선언해준다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;onResponse(response: Response)&lt;/code&gt;: 네트워크 통신이 성공한 경우, 해달 콜백이 호출된다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;onFailure(request: Request, e: IOException)&lt;/code&gt;: 네트워크 통신이 실패한 경우, 해당 콜백이 호출된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;i&gt;Callback#onResponse&lt;/i&gt;&lt;/b&gt; 콜백이 호출된다는 것은 &lt;b&gt;클라이언트에서 원하는 성공의 의미와는 다르다.&lt;/b&gt;&lt;br /&gt;네트워크 통신이 실패하지 않고 성공했다는 의미로 서버에서 에러를 정상적으로 내려주는 경우도 네트워크 통신 성공으로 취급된다.&lt;br /&gt;&lt;b&gt;그러기 때문에 내부에서 response 값을 이용해서 클라이언트 기준에서 성공/실패 여부를 관리해서 처리해야한다.&lt;br /&gt;&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;HttpURLConnection보다 OkHttp가 나은 점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전에 HttpURLConnection을 직접 사용해 네트워크 통신을 구현해보며 Low-level API가 어떤 식으로 동작하는지 확인했다.&lt;br /&gt;이를 통해서 HTTP 통신의 기본 구조를 이해할 수 있었지만, 동시에 왜 이 방식이 실무에서 잘 사용되지 않는지도 자연스럽게 체감하게 됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 GET 요청을 &lt;b&gt;OkHttp&lt;/b&gt;로 구현해보며 HttpURLConnection과 비교했을 때, &lt;b&gt;개발자 입장에서 어떤 점이 개선되었는지&lt;/b&gt;를 정리해보면 다음과 같다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. 요청과 연결 개념의 분리&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HttpURLConnection에서는 &lt;b&gt;연결 객체 자체가 요청의 역할&lt;/b&gt;을 한다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;val connection = url.openConnection() as HttpURLConnection
connection.requestMethod = &quot;GET&quot;
connection.setRequestProperty(...)&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;요청 정보가 mutable한 connection 객체에 흩어져서 설정된다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&quot;이것이 하나의 요청이다&quot;&lt;/b&gt;라는 개념적 경계가 불명확하다.&lt;/li&gt;
&lt;li&gt;설정 순서에 따라 실수가 발생하기 쉽다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 OkHttp는 &lt;b&gt;Request라는 명확한 요청 모델&lt;/b&gt;을 제공한다.&lt;/p&gt;
&lt;pre class=&quot;gcode&quot;&gt;&lt;code&gt;val request = Request.Builder()
    .url(&quot;https://example.com&quot;)
    .get()
    .build()&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;URL, Method, Header, Body가 하나의 불변 객체(Request)로 묶인다.&lt;/li&gt;
&lt;li&gt;요청 자체가 값처럼 취급된다.&lt;/li&gt;
&lt;li&gt;요청 정의와 실행(Call)이 명확히 분리된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Request 모델 덕분에 요청을 '연결 설정 과정'이 아니라 '명세'로 다룰 수 있게 됐다.&lt;/b&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. 동기/비동기 처리 모델의 명확한 분리&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HttpURLConnection은 &lt;b&gt;비동기 처리를 직접 제공하지 않는다.&lt;/b&gt; 개발자가 직접 Thread/Executor를 만들어야 하고 동기/비동기 처리 경계가 코등상에 명확하지 않다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 &lt;b&gt;OkHttp는 명확한 선택지를 제공한다.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 동기
client.newCall(request).execute()

// 비동기
client.newCall(request).enqueue(callback)&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;execute()&lt;/code&gt;: 호출한 스레드를 block&lt;/li&gt;
&lt;li&gt;&lt;code&gt;enqueue()&lt;/code&gt;: OkHttp 내부 스레드에서 비동기 실행 후 콜백 전달&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. Response 구조의 명확화&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HttpUrlConnection에서는 응답을 다루기 위해서 여러 API를 조합해야 한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;responseCode&lt;/li&gt;
&lt;li&gt;getHeaderField()&lt;/li&gt;
&lt;li&gt;inputStream/errorStream&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OkHttp는 HTTP 응답을 하나의 &lt;b&gt;Response 객체로 구조화된다.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;response.code
response.headers
response.body&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Status/Header/Body의 역할이 명확하다.&lt;/li&gt;
&lt;li&gt;onResponse가 호출되더라도 HTTP 에러일 수 있음을 명시적으로 인지할 수 있다.&lt;/li&gt;
&lt;li&gt;응답 처리 흐름이 코드로 자연스럽게 드러난다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;HTTP 프로토콜 구조가 API 설계에 그대로 반영되어 있는 것 같다.&lt;/b&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;4. 확장성과 공통 관심사 분리&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HttpURLConnection은 공통 로직을 넣기 어렵다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;로깅, 인증 헤더 추가, 재시도, 캐싱 등 모든 요청마다 중복 코드가 생기기 쉽다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OkHttp는 &lt;b&gt;Interceptor&lt;/b&gt;를 통해 이를 구조적으로 해결한다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;// 인증 헤더 추가 관련 Interceptor 구현
class AuthInterceptor(
    private val tokenProvider: TokenProvider
) : Interceptor {

    override fun intercept(chain: Interceptor.Chain): Response {
        val original = chain.request()

        val newRequest = original.newBuilder()
            .addHeader(&quot;Authorization&quot;, &quot;Bearer ${tokenProvider.token}&quot;)
            .build()

        return chain.proceed(newRequest)
    }
}

// OkHttpClient에 추가
val client = OkHttpClient.Builder()
    .addInterceptor(AuthInterceptor(tokenProvider))
    .build()&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;네트워크 요청 흐름에 공통 로직을 레이어로 삽입 가능하다.&lt;/li&gt;
&lt;li&gt;요청/응답을 가로채되, 구조를 깨지 않는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;OkHttp는 네트워크 계층을 확장 가능한 파이프라인으로 다룰 수 있는 큰 장점이 있는 것 같다.&lt;/b&gt;&lt;/p&gt;</description>
      <category>안드로이드/Why</category>
      <author>JooMan</author>
      <guid isPermaLink="true">https://devgeek.tistory.com/160</guid>
      <comments>https://devgeek.tistory.com/160#entry160comment</comments>
      <pubDate>Tue, 6 Jan 2026 07:00:18 +0900</pubDate>
    </item>
    <item>
      <title>Retrofit 이전에는 Android에서 네트워크 통신을 어떻게 했을까?</title>
      <link>https://devgeek.tistory.com/159</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Android 개발에서 Retrofit은 사실상 표준처럼 사용되지만, &lt;b&gt;&quot;왜 Retrofit이 필요한가?&quot;에 대해서는 깊이 고민해보지 않았다.&lt;/b&gt;&lt;br /&gt;High-level의 &lt;b&gt;Retrofit&lt;/b&gt;을 사용하는 장점을 글로써 이해하는 것이 아니라 직접 경험하기 위해서는 Low-level에서의 네트워크 통신이 어떻게 동작하는지 알 필요가 있다고 느껴졌다. 그래서 Java에서 제공하는 &lt;b&gt;HttpURLConnection&lt;/b&gt;을 이용한 네트워크 통신 방법을 학습하기로 했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;HttpURLConnection GET 사용법&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 Low-level의 HttpURLConnection에서 기본적인 REST API - GET 요청을 하는 소스 코드는 다음과 같다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;소스 코드&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;fun fetchUserWithHttpUrlConnection(userId: Int): User? {
    var connection: HttpURLConnection? = null
    try {
        val url = URL(&quot;https://jsonplaceholder.typicode.com/users/$userId&quot;)
        connection = url.openConnection() as HttpURLConnection
        connection.requestMethod = &quot;GET&quot;
        connection.connectTimeout = 5000
        connection.readTimeout = 5000

        val responseCode = connection.responseCode
        if (responseCode == HttpURLConnection.HTTP_OK) {
            val inputStream = connection.getInputStream()
            val reader = BufferedReader(InputStreamReader(inputStream))
            val response = reader.readText()
            reader.close()

            val gson = Gson()
            return gson.fromJson(response, User::class.java)
        }
        return null
    } catch (e: Exception) {
        e.printStackTrace()
        return null
    } finally {
        connection?.disconnect()
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;HttpURLConnection을 통한 네트워크 통신 이해 및 소스 코드 분석&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. 네트워크 통신 요청을 위해서는 HttpURLConnection 객체 생성이 필요하고, 요청에 대한 정보를 담아야한다.&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;HttpURLConnection은 &lt;code&gt;URL.openConnection()&lt;/code&gt;를 통해서 객체를 생성한다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;openConnection()&lt;/code&gt;은 &lt;b&gt;연결 객체를 준비&lt;/b&gt;하는 단계이며, &lt;b&gt;실제 네트워크 연결은 lazy 하게 이후 시점에서 시작된다.&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;HttpURLConnection의 속성값(&lt;code&gt;method&lt;/code&gt;, &lt;code&gt;connectionTimeout&lt;/code&gt;, &lt;code&gt;readTimeout&lt;/code&gt;)들을 설정한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. 네트워크 연결/요청 전송은 &lt;code&gt;responseCode&lt;/code&gt; 또는 &lt;code&gt;inputStream/outputStream&lt;/code&gt; 접근 시점에 트리거된다.&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;val responseCode = connection.responseCode&lt;/code&gt;를 통해서 트리거된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. 네트워크 통신 요청 결과값을 읽을 수 있도록, 즉 처리할 수 있도록 셋팅한다.&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;val inputStream = connection.getInputStream()&lt;/code&gt;: 서버로부터 수신된 데이터를 OS의 소켓 버퍼를 통해 읽어올 수 있는 스트림이다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;val reader = BufferedReader(InputStreamReader(inputStream))&lt;/code&gt;: 바잍 단위로 전달되는 데이터를 문자로 처리하기 위해 &lt;code&gt;InputStreamReader&lt;/code&gt;를 사용한다. 그리고 효율적으로 데이터를 읽어오기 위해서 &lt;code&gt;BufferedReader&lt;/code&gt;로 변환한다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;val response = reader.readText()&lt;/code&gt;: 내부적으로 &lt;code&gt;read()&lt;/code&gt;를 반복 호출해서 스트림의 끝까지 읽어 하나의 문자열을 만든다. &lt;b&gt;이 과정에서 데이터 전달이 완료되지 않으면 &lt;code&gt;read()&lt;/code&gt;에서 Block 될 수 있다.&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 응답 값은 한 번에 완성되어 전달되는 것이 아니라 네트워크 특성상 조각 단위로 전달된다.&lt;br /&gt;InputStream/BufferedReader는 데이터를 미리 다 받아두는 것이 아니라, 스트림에서 read()가 호출될 때마다 전달된 만큼 읽고(&lt;b&gt;없으면 block&lt;/b&gt;) 문자로 디코딩, 버퍼링하도록 준비하는 Wrapper다.&lt;br /&gt;&lt;b&gt;따라서 서버/네트워크가 늦으면 reader.readText() 내부의 read 과정에서 스레드가 block 될 수 있고, 이를 메인 스레드에서 수행하면 UI 정지(ANR) 위험이 생길 수 있다.&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;4. 네트워크 스트림 및 소켓과 같은 OS 리소스 해제를 위해 BufferedReader를 종료한다.&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;reader.close()&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;5. 전달받은 Json 타입의 문자열 데이터를 User 클래스 타입으로 변환한다.&lt;/h4&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;생각 정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Square에서 OkHttp와 Retrofit을 제공하기 전에는 Java에서 제공하는 HttpURLConnection을 사용한 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HttpURLConnection을 사용해서 네트워크 통신을 할 수는 있지만, 개발하고 유지보수하는 입장에서 네트워크 통신을 위한 객체 connection을 생성하고 Builder 패턴이나 DSL 패턴 없이 요청에 대한 정보를 설정하는데 있어서 &lt;b&gt;자유도가 높은 대신, 관리 포인트가 많아 유지보수 비용이 컸을 것으로 보인다.&lt;/b&gt;&lt;br /&gt;OkHttp를 이용하면 &lt;code&gt;Request&lt;/code&gt;를 이용해서 Builder 패턴으로 &lt;b&gt;네트워크 통신을 위한 규칙을 강제해서 관리하기 쉽고&lt;/b&gt;,&lt;br /&gt;Retrofit을 이용하면 &lt;b&gt;주석 기반으로 관리하기 용이&lt;/b&gt;한데 말이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;몇 개 안되는 네트워크 통신에서는 사용할 수 있겠지만 실제 프로덕트에서는 &lt;b&gt;수 많은 통신이 이루어지고 각 통신에서 변경사항이 발생한다면 유지보수하기가 상당히 어려웠을 것으로 예상된다.&lt;/b&gt;&lt;/p&gt;</description>
      <category>안드로이드/Why</category>
      <author>JooMan</author>
      <guid isPermaLink="true">https://devgeek.tistory.com/159</guid>
      <comments>https://devgeek.tistory.com/159#entry159comment</comments>
      <pubDate>Mon, 5 Jan 2026 17:54:10 +0900</pubDate>
    </item>
    <item>
      <title>Foreground Service Short Service로 게시글 업로드 구현하기</title>
      <link>https://devgeek.tistory.com/158</link>
      <description>&lt;p data-ke-size=&quot;size18&quot;&gt;안드로이드에서 &lt;b&gt;백그라운드 작업을 안정적으로 처리하면서도,&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;사용자에게 현재 작업이 진행 중임을 명확하게 알려야 하는 경우&lt;/b&gt; 어떤 선택이 적절할까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순한 네트워크 요청이라면 &lt;code&gt;WorkManager&lt;/code&gt;가 좋은 선택이 될 수 있다.&lt;br /&gt;하지만 다음 조건을 모두 만족하는 작업이라면 이야기가 달라진다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;사용자가 명확히 트리거한 작업이고&lt;/li&gt;
&lt;li&gt;즉시 실행되어야 하며&lt;/li&gt;
&lt;li&gt;앱이 백그라운드로 전환되더라도 중단되면 안 되고&lt;/li&gt;
&lt;li&gt;수행 시간이 길지 않은 작업&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에서는 이러한 조건을 만족하는 &lt;b&gt;게시글 업로드(이미지 + 텍스트)&lt;/b&gt; 시나리를 예시로, &lt;code&gt;Foreground Service&lt;/code&gt; 중에서 &lt;b&gt;Short Service&lt;/b&gt;를 어떻게 사용하면 좋을지 정리해본다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고). Android 14(API 34)에서 강화된 정책까지 함께 다룬다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;샘플 전체 코드가 궁금한 경우, 제일 하단에 위치한 예제 코드부터 확인하면 된다.&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Foreground Service란?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Foreground Service&lt;/code&gt;는 &lt;b&gt;사용자가 인지해야 하는 작업&lt;/b&gt;을 수행하기 위해 사용하는 Service다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심 특징은 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;반드시 &lt;b&gt;Notification&lt;/b&gt;을 통해 사용자에게 작업 수행 중임을 알려야 한다&lt;/li&gt;
&lt;li&gt;백그라운드 상태에서도 비교적 높은 실행 우선순위를 가진다&lt;/li&gt;
&lt;li&gt;시스템 리소스를 지속적으로 사용하므로 남용하면 안 된다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, Foreground Service는 단훈시 &quot;백그라운드에서 오래 돌리기 위한 도구&quot;가 아니라,&lt;br /&gt;&lt;b&gt;사용자가 지금 이 작업이 실행 중이라는 사실을 알아야 하는 경우에만 사용하는 것이 전제다.&lt;/b&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;언제 Foreground Service를 사용해야 할까?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 질문에 모두 Yes라면 Foreground Service를 고려해볼 수 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이 작업은 &lt;b&gt;사용자 액션으로 직접 시작되었는가?&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;작업이 진행 중임을 &lt;b&gt;사용자가 인지해야 하는가?&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;앱이 백그라운드로 가도 &lt;b&gt;작업이 중단되면 안 되는가?&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게시글 업로드는 보통 이 조건을 만족한다.&lt;br /&gt;사용자가 업로드 버튼을 눌렀고, 업로드 중이라는 사실을 알고 싶어 하며,&lt;br /&gt;업로드 도중 앱을 나가더라도 작업이 계속되기를 기대한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 맥락에서 Foreground Service는 자연스러운 선택이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Foreground Service, short Service란?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Short Service&lt;/code&gt;는 &lt;b&gt;짧은 시간 안에 반드시 종료되는 작업&lt;/b&gt;을 위한 Foreground Service 타입이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공식 문서 기준 핵심 제약은 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;약 &lt;b&gt;3분 이내&lt;/b&gt;에 작업이 종료되어야 한다&lt;/li&gt;
&lt;li&gt;&lt;b&gt;STICKY 재시작을 지원하지 않는다&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;Android 14(API 34)부터는 &lt;b&gt;타임아웃이 강제&lt;/b&gt;되며, 무시 시 &lt;b&gt;ANR이 발생한다&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, Short Service는 &lt;b&gt;&quot;지금 당장 끝내야 하는 작업을 빠르게 처리하고 즉시 정리하는 Service&lt;/b&gt;를 의미한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Foreground Service 사용 방법&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. Foreground Service 선언 및 권한&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Foreground Service 권한 및 Service 선언&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Short Service를 사용하기 위해서는 &lt;code&gt;FOREGROUND_SERVICE&lt;/code&gt; 권한과 사용할 Service를 Manifest에 선언해 한다.&lt;/p&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;&amp;lt;uses-permission android:name=&quot;android.permission.FOREGROUND_SERVICE&quot; /&amp;gt;

&amp;lt;application&amp;gt;
    &amp;lt;service
        android:name=&quot;.service.PostService&quot;
        android:foregroundServiceType=&quot;shortService&quot; /&amp;gt;
&amp;lt;/application&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Notification 권한에 대한 이해&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://developer.android.com/develop/ui/views/notifications/notification-permission?utm_source=chatgpt.com#:~:text=Note%3A%20Apps%20don%27t%20need%20to%20request%20the%20POST_NOTIFICATIONS%20permission%20in%20order%20to%20launch%20a%20foreground%20service.%20However%2C%20apps%20must%20include%20a%20notification%20when%20they%20start%20a%20foreground%20service%2C%20just%20as%20they%20do%20on%20previous%20versions%20of%20Android.&quot;&gt;공식 문서&lt;/a&gt;에서는 Foreground Service 실행 자체에는 &lt;code&gt;POST_NOTIFICATION&lt;/code&gt; 권한이 필수는 아니라고 명시한다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Apps don't need to request the POST_NOTIFICATIONS permission in order to launch a foreground service.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 Foreground Service의 목적은 &lt;b&gt;사용자에게 작업 수행 사실을 알리는 것&lt;/b&gt;이므로, 실제 사용자 경험 관점에서는 Notification을 정상적으로 노출하기 위해 &lt;code&gt;POST_NOTIFICATIONS&lt;/code&gt; 권한을 함께 사용하는 것이 바람직하다.&lt;/p&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;manifest xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;&amp;gt;

    &amp;lt;uses-permission android:name=&quot;android.permission.POST_NOTIFICATIONS&quot;/&amp;gt;
    &amp;lt;uses-permission android:name=&quot;android.permission.FOREGROUND_SERVICE&quot; /&amp;gt;
    &amp;lt;application&amp;gt;
        &amp;lt;service
            android:name=&quot;.service.PostService&quot;
            android:foregroundServiceType=&quot;shortService&quot; /&amp;gt;
    &amp;lt;/application&amp;gt;
&amp;lt;/manifest&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. Foreground Service 실행&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Foreground Service는 &lt;code&gt;onStartCommand()&lt;/code&gt; 내부에서 &lt;code&gt;startForeground()&lt;/code&gt;를 호출함으로써 시작된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Foreground Service의 Short Service를 사용할 때 반드시 기억해야 할 두 가지 포인트가 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. &lt;code&gt;START_NOT_STICKY&lt;/code&gt; 사용&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Short Service는 재시작을 전제로 하지 않는다.&lt;/li&gt;
&lt;li&gt;따라서 &lt;code&gt;onStartCommand()&lt;/code&gt;의 반환값으로는 &lt;code&gt;START_NOT_STICKY&lt;/code&gt;를 사용하는 것이 적절하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. &lt;code&gt;startForegound()&lt;/code&gt; 호출 시 OS 버전 분기&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;ForegroundServiceType&lt;/code&gt; 개념은 Android 10(API 29)에서 도입되었다.&lt;/li&gt;
&lt;li&gt;따라서 Short Service를 의도대로 동작시키기 위해서는 OS 버전 분기가 필요하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;if (Build.VERSION.SDK_INT &amp;gt;= Build.VERSION_CODES.Q) {
    startForeground(id, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE)
} else {
    startForeground(id, notification)
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. Foreground Service 종료 (중요)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Android 14부터 Foreground Service의 Short Service에는 &lt;b&gt;3분 타임아웃이 강제&lt;/b&gt;된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;타임아웃이 발생하면 &lt;code&gt;Service.onTimeout()&lt;/code&gt; 콜백이 호출되며, 이를 처리하지 않으면 &lt;b&gt;*ANR이 발생한다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 반드시 다음 처리가 필요하다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;stopForeground()&lt;/code&gt;: Foreground 상태 해제&lt;/li&gt;
&lt;li&gt;&lt;code&gt;stopSelf()&lt;/code&gt;: Service 종료&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;override fun onTimeout(startId: Int) {
    stopForeground(STOP_FOREGROUND_REMOVE)
    stopSelf(startId)
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;게시글 업로드를 Foreground Service Short Service로 구현하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게시글 업로드 예제에서는 다음과 같은 설계 기준을 사용했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;업로드 성공 여부는 &lt;b&gt;서버 응답 기준&lt;/b&gt;으로 판단한다&lt;/li&gt;
&lt;li&gt;Short Service 타임아웃은 &lt;b&gt;실패로 간주하지 않는다&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;비즈니스 실패에 대해서만 실패 Notification을 노출한다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이는 Short Service의 타임아웃이 네트워크 통신 실패가 아니라 &lt;b&gt;Service 수명 정책 이벤트&lt;/b&gt;라는 점을 고려한 선택이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래는 전체 예제 코드다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@AndroidEntryPoint
class PostService : LifecycleService() {

    @Inject
    lateinit var postBoardUseCase: PostBoardUseCase

    @Inject
    lateinit var notificationHelper: NotificationHelper

    @Inject
    lateinit var postEventBus: PostEventBus

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        super.onStartCommand(intent, flags, startId)
        val boardParcel = intent?.getParcelableExtra(EXTRA_BOARD, BoardParcel::class.java)
        if (boardParcel == null) {
            stopSelf(startId)
            return START_NOT_STICKY
        }
        startForegroundService()
        lifecycleScope.launch(Dispatchers.IO) {
            postBoard(startId, boardParcel)
        }
        return START_NOT_STICKY
    }

    private fun startForegroundService() {
        val notification = notificationHelper.createPostBoardUploadNotification()

        if (Build.VERSION.SDK_INT &amp;gt;= Build.VERSION_CODES.Q) {
            startForeground(
                NotificationHelper.POST_UPLOAD_NOTIFICATION_ID,
                notification,
                ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE
            )
        } else {
            startForeground(
                NotificationHelper.POST_UPLOAD_NOTIFICATION_ID,
                notification
            )
        }
    }

    private suspend fun postBoard(startId: Int, boardParcel: BoardParcel) {
        try {
            postBoardUseCase(
                title = boardParcel.title,
                content = boardParcel.content,
                images = boardParcel.images
            ).fold(
                onSuccess = { boardId -&amp;gt;
                    postEventBus.emit(PostEvent.Success(boardId))
                },
                onFailure = { exception -&amp;gt;
                    val errorMessage = when (exception) {
                        is PostException -&amp;gt; exception.userMessage
                        else -&amp;gt; exception.message ?: &quot;업로드에 실패했습니다.&quot;
                    }
                    postEventBus.emit(PostEvent.Failure(errorMessage))
                    throw exception
                }
            )
        } catch (e: Exception) {
            showFailedNotification()
        } finally {
            stopForeground(STOP_FOREGROUND_REMOVE)
            stopSelf(startId)
        }
    }

    private fun showFailedNotification() = with(notificationHelper) {
        val notification = createPostBoardUploadFailedNotification()
        notify(NotificationHelper.POST_UPLOAD_NOTIFICATION_ID, notification)
    }

    override fun onTimeout(startId: Int) {
        super.onTimeout(startId)
        stopForeground(STOP_FOREGROUND_REMOVE)
        stopSelf(startId)
    }

    companion object {
        const val EXTRA_BOARD = &quot;extra_board&quot;
    }
}&lt;/code&gt;&lt;/pre&gt;</description>
      <category>안드로이드</category>
      <author>JooMan</author>
      <guid isPermaLink="true">https://devgeek.tistory.com/158</guid>
      <comments>https://devgeek.tistory.com/158#entry158comment</comments>
      <pubDate>Wed, 24 Dec 2025 23:25:53 +0900</pubDate>
    </item>
    <item>
      <title>[Jetpack Compose] Compose에서 Navigation 사용</title>
      <link>https://devgeek.tistory.com/157</link>
      <description>&lt;h1&gt;Compose에서의 Navigation&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://developer.android.com/guide/navigation&quot;&gt;Navigation Component&lt;/a&gt;는 Jetpack Compose 앱에서도 사용할 수 있다.&lt;br /&gt;이를 통해 Composable 간 화면 이동을 구현하면서 Navigation Component가 제공하는 인프라와 기능을 그대로 활용할 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;프로젝트 설정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Compose에서 Navigation을 사용하려면, app 모듈의 &lt;code&gt;build.gradle&lt;/code&gt; 파일에 아래 Navigation 관련 의존성을 추가해야 한다.&lt;/p&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;dependencies {
    val nav_version = &quot;2.9.6&quot;

    implementation(&quot;androidx.navigation:navigation-compose:$nav_version&quot;)
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;시작하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Navigation을 사용할 때, &lt;code&gt;navigation host&lt;/code&gt;, &lt;code&gt;graph&lt;/code&gt; 그리고 &lt;code&gt;controller&lt;/code&gt;를 구현해야 한다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고. Navigation에 대한 자세한 내용은 &lt;a href=&quot;https://devgeek.tistory.com/156&quot;&gt;[Jetpack Compose] Navigation 개요&lt;/a&gt; 글에서 확인할 수 있다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;NavController 만들기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Navigation Controller&lt;/b&gt;는 내비게이션의 핵심 개념 중 하나다. &lt;code&gt;NavController&lt;/code&gt;는 내비게이션 그래프(&lt;code&gt;NavGraph&lt;/code&gt;)를 사용해 그래프에 정의된 destination 간 이동을 담당한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Navigation Component를 사용할 때는 &lt;code&gt;NavController&lt;/code&gt; 클래스를 통해 Navigation Controller를 생성한다. 그리고 &lt;code&gt;NavController&lt;/code&gt;는 사용자가 방문한 destination을 추적하고, destination 간 이동을 관리하는 핵심 API다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Compose에서의 NavController&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jetpack Compose에서 &lt;code&gt;NavController&lt;/code&gt;를 만들기 위해서는 &lt;code&gt;rememberNavController()&lt;/code&gt; 함수를 사용하면 된다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;val navController = rememberNavController()&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;code&gt;NavController&lt;/code&gt;는 Composable 계층 구조에서 가능한 상위 계층에서 생성해야 한다.그렇게 해야 &lt;code&gt;NavController&lt;/code&gt;에 접근해야 하는 모든 Composable이 이를 참조할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 &lt;code&gt;NavController&lt;/code&gt;를 상위에 두면, 화면 내부뿐 아니라 화면 외부 Composable들을 업데이트할 때도 &lt;code&gt;NavController&lt;/code&gt;를 SSOT(Single Source of Truth)에 따라 사용할 수 있게 된다. 이는 상태 호이스팅(state hoisting)의 원칙을 따르는 방식이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;NavHost 만들기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;NavHost&lt;/code&gt;는 현재 내비게이션 대상(destination)을 담는 UI 요소다. 사용자가 앱 안에서 화면을 이동할 때, 앱은 이 &lt;code&gt;NavHost&lt;/code&gt; 안에서 다른 destination을 교체하며 화면을 전환한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. NavHost 생성과 동시에 Graph 구성하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Route는 특정 destination으로 이동하기 위해 필요한 정보를 포함하며, destination을 식별하는 역할을 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;@Serializable&lt;/code&gt; 어노테이션을 사용하면, 해당 Route 타입에 필요한 직렬화/역직렬화 로직이 자동으로 생성된다. 이 어노테이션은 Kotlin Serialization 플러그인이 제공하므로, 먼저 플러그인을 설정해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;Route를 정의한 뒤에는 &lt;code&gt;NavHost&lt;/code&gt; Composable을 사용해 Navigation Graph를 구성한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;아래 예시 코드를 보자.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;@Serializable
object Profile

@Serializable
object FriendsList

val navController = rememberNavController()

NavHost(navController = navController, startDestination = Profile) {
    composable&amp;lt;Profile&amp;gt; { ProfileScreen() }
    composable&amp;lt;FriendsList&amp;gt; { FriendsListScreen() }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;code&gt;Profile&lt;/code&gt;과 &lt;code&gt;FriendsList&lt;/code&gt;는 &lt;b&gt;각각 하나의 Route를 표현하는 직렬화 가능한 객체&lt;/b&gt;다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;NavHost&lt;/code&gt;는 &lt;code&gt;NavController&lt;/code&gt;와 첫 시작인 destination에 해당하는 &lt;b&gt;Route&lt;/b&gt; 타입을 받는다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;NavHost&lt;/code&gt; 람다는 내부적으로 &lt;code&gt;NavController.createGraph()&lt;/code&gt;를 호출하여 &lt;code&gt;NavGraph&lt;/code&gt;를 생성한다.&lt;/li&gt;
&lt;li&gt;각 &lt;code&gt;composable&amp;lt;T&amp;gt;()&lt;/code&gt; 호출은 해당 Route 타입을 그래프에 destination으로 추가한다.&lt;/li&gt;
&lt;li&gt;각 &lt;code&gt;composable&amp;lt;&amp;gt;&lt;/code&gt; 블록 내부에서 선언된 Composable이 &lt;code&gt;NavHost&lt;/code&gt;가 해당 destination에 렌더링할 UI를 정의한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. NavController.createGraph()로 그래프 생성 후 NavHost에 전달하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;NavHost&lt;/code&gt; 내부에서 바로 그래프를 구성하는 방식이 Compose에서 일반적인 패턴이지만, 경우에 따라 Navigation Graph를 외부에서 먼저 생성한 뒤 &lt;code&gt;NavHost&lt;/code&gt;에 전달해야 하는 상황도 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 경우 &lt;code&gt;NavController&lt;/code&gt;의 &lt;code&gt;createGraph()&lt;/code&gt; 함수를 사용해 &lt;code&gt;NavGraph&lt;/code&gt;를 직접 구성할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 코드는 앞선 예제와 동일한 그래프를 &lt;code&gt;NavHost&lt;/code&gt; 외부에서 생성한 뒤, &lt;code&gt;NavHost&lt;/code&gt;에 전달하는 방식이다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;val navGraph by remember(navController) {
  navController.createGraph(startDestination = Profile) {
    composable&amp;lt;Profile&amp;gt; { ProfileScreen() }
    composable&amp;lt;FriendsList&amp;gt; { FriendsListScreen() }
  }
}

NavHost(navController = navController, graph = navGraph)&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Composable로 이동하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Composable로 이동하려면 &lt;code&gt;NavController.navigate&amp;lt;T&amp;gt;()&lt;/code&gt;를 사용해야 한다. 이 overload 버전의 &lt;code&gt;navigate()&lt;/code&gt;는 하나의 &lt;code&gt;route&lt;/code&gt; 인자(타입)만 받으며, 이 타입 자체가 destination을 식별하는 키 역할을 한다.&lt;/p&gt;
&lt;pre class=&quot;scala&quot;&gt;&lt;code&gt;@Serializable
object FriendsList

navController.navigate(route = FriendsList)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Navigation Graph에서 Composable로 이동하려면, 먼저 &lt;b&gt;각 destination을 하나의 타입으로 대응되도록 &lt;code&gt;NavGraph&lt;/code&gt;를 정의&lt;/b&gt;해야 한다. Composable destination은 &lt;code&gt;composable&amp;lt;T&amp;gt;()&lt;/code&gt; 함수를 사용해 등록한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Composable에서 이벤트를 외부로 전달하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Composable 함수가 다른 화면으로 이동해야 한다고 해서 해당 Composable을 &lt;code&gt;NavController&lt;/code&gt;를 직접 넘겨 &lt;code&gt;navigate()&lt;/code&gt;를 호출하게 하면 안 된다. UDF(Unidirectional Data Flow) 원칙에 따르면, Composable은 내비게이션 로직을 직접 다루지 않고 &lt;b&gt;이벤트만 외부로 전달&lt;/b&gt;해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구체적인 예시는 아래 섹션에서 설명한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;예시&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Serializable
object Profile
@Serializable
object FriendsList

@Composable
fun MyAppNavHost(
    modifier: Modifier = Modifier,
    navController: NavHostController = rememberNavController(),
) {
    NavHost(
        modifier = modifier,
        navController = navController,
        startDestination = Profile
    ) {
        composable&amp;lt;Profile&amp;gt; {
            ProfileScreen(
                onNavigateToFriends = { navController.navigate(route = FriendsList) },
                /* ... */
            )
        }
        composable&amp;lt;FriendsList&amp;gt; { FriendsListScreen(/* ... */) }
    }
}

@Composable
fun ProfileScreen(
    onNavigateToFriends: () -&amp;gt; Unit,
    /* ... */
) {
    /* ... */
    Button(onClick = onNavigateToFriends) {
        Text(text = &quot;See friends list&quot;)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Navigation Graph의 각 destination은 route를 통해 생성된다. 이 route는 destination에 필요한 정보를 나타내거나 직렬화 가능한 객체 또는 클래스다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;MyAppNavHost&lt;/code&gt; Composable은 &lt;code&gt;NavController&lt;/code&gt; 객체를 갖고 있다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;navigate()&lt;/code&gt; 호출은 &lt;code&gt;MyAppNavHost&lt;/code&gt; 내부에서 이루어져야 하며, &lt;code&gt;ProfileScreen&lt;/code&gt;처럼 UI를 선언하는 Composable에서 직접 호출하면 안 된다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ProfileScreen&lt;/code&gt;에는 사용자를 &lt;code&gt;FriendsList&lt;/code&gt;로 이동시키는 버튼이 있지만, 이 버튼은 직접 &lt;code&gt;navigate()&lt;/code&gt;를 호출하지 않는다.&lt;/li&gt;
&lt;li&gt;대신 버튼은 &lt;code&gt;onNavigateToFriends&lt;/code&gt; 파라미터로 전달된 람다 함수를 호출한다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;MyAppNavHost&lt;/code&gt;는 &lt;code&gt;ProfileScreen&lt;/code&gt;을 &lt;code&gt;NavGraph&lt;/code&gt;에 등록할 때 &lt;code&gt;onNavigateToFriends&lt;/code&gt;에 &lt;code&gt;navController.navigate(route = FriendsList)&lt;/code&gt;를 호출하는 람다를 전달한다.&lt;/li&gt;
&lt;li&gt;이를 통해 사용자가 &lt;code&gt;ProfileScreen&lt;/code&gt;의 버튼을 눌렀을 때 올바르게 &lt;code&gt;FriendsListScreen&lt;/code&gt;으로 이동하게 된다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;인자와 함께 이동하기&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;인자를 갖는 Route 만들기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Destination에 특정 데이터를 전달할 필요가 있는 경우, route를 파라미터를 갖는 클래스로 정의해야 한다. 아래 예시처럼 &lt;code&gt;Profile&lt;/code&gt; route는 &lt;code&gt;name&lt;/code&gt; 파라미터를 갖는 data class이다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;@Serializable
data class Profile(val name: String)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Destination에 인자를 전달할 때는 route 클래스를 인스턴스화하면서 생성자에 값을 전달하면 된다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Note: 인자를 전달할 때는 data class를 사용해야 한다. 인자가 없는 경우에는 object 또는 data object를 사용해야 한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 Nullable 한 인자를 전달해야 하는 경우에는 default 값을 설정해야 한다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;@Serializable
data class Profile(val nickname: String? = null)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Destination에서 type-safe한 인자 이용하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Route 객체는 &lt;code&gt;NavBackStackEntry.toRoute()&lt;/code&gt; 또는 &lt;code&gt;SavedStateHandle.toRoute()&lt;/code&gt;를 통해서도 얻을 수 있다. &lt;code&gt;composable&amp;lt;T&amp;gt;()&lt;/code&gt;를 사용해 destination을 정의하면 해당 블록에서 &lt;code&gt;NavBackStackEntry&lt;/code&gt;를 파라미터로 받을 수 있다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;@Serializable
data class Profile(val name: String)

val navController = rememberNavController()

NavHost(navController = navController, startDestination = Profile(name=&quot;John Smith&quot;)) {
    composable&amp;lt;Profile&amp;gt; { backStackEntry -&amp;gt;
        val profile: Profile = backStackEntry.toRoute()
        ProfileScreen(name = profile.name)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Navigation Graph의 &lt;code&gt;startDestination&lt;/code&gt;이 &lt;code&gt;&quot;John Smith&quot;&lt;/code&gt; 값을 갖는 &lt;code&gt;Profile&lt;/code&gt; route로 설정되어 있다.&lt;/li&gt;
&lt;li&gt;Destination은 &lt;code&gt;composable&amp;lt;Profile&amp;gt;{}&lt;/code&gt;의 블록 자체다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ProfileScreen&lt;/code&gt; Composable은 &lt;code&gt;name&lt;/code&gt;의 값으로 &lt;code&gt;profile.name&lt;/code&gt;을 사용한다.&lt;/li&gt;
&lt;li&gt;그러므로 &lt;code&gt;&quot;John Smith&quot;&lt;/code&gt; 값이 &lt;code&gt;ProfileScreen&lt;/code&gt;으로 전달된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;NavController와 NavHost를 함께 사용한 예시 코드&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Serializable
data class Profile(val name: String)

@Serializable
object FriendsList

// Define the ProfileScreen composable
@Composable
fun ProfileScreen(
    profile: Profile,
    onNavigateToFriendsList: () -&amp;gt; Unit,
) {
    Text(&quot;Profile for ${profile.name}&quot;)
    Button(onClick = { onNavigateToFriendsList() }) {
        Text(&quot;Go To Friends List&quot;)
    }
}

// Define the FriendsListScreen composable
@Composable
fun FriendsListScreen(onNavigateToProfile: () -&amp;gt; Unit) {
    Text(&quot;Friends List&quot;)
    Button(onClick = { onNavigateToProfile() }) {
        Text(&quot;Go to Profile&quot;)
    }
}

// Define the MyApp composable, including the `NavController` and `NavHost`.
@Composable
fun MyApp() {
    val navController = rememberNavController()
    NavHost(navController, startDestination = Profile(name = &quot;John Smith&quot;)) {
        composable&amp;lt;Profile&amp;gt; { backStackEntry -&amp;gt;
            val profile: Profile = backStackEntry.toRoute()
            ProfileScreen(
                profile = profile,
                onNavigateToFriendsList = {
                    navController.navigate(route = FriendsList)
                }
            )
        }
        composable&amp;lt;FriendsList&amp;gt; {
            FriendsListScreen(
                onNavigateToProfile = {
                    navController.navigate(
                        route = Profile(name = &quot;Aisha Devi&quot;)
                    )
                }
            )
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;위 코드에서는 Composable은 &lt;code&gt;NavController&lt;/code&gt;를 직접 전달받는 대신, &lt;code&gt;NavHost&lt;/code&gt;가 처리할 수 있도록 이벤트(콜백)를 외부로 노출한다. 즉, Composable은 &lt;code&gt;() -&amp;gt; Unit&lt;/code&gt; 형태의 파라미터를 갖고 있고, 이 파라미터에는 &lt;code&gt;NavHost&lt;/code&gt;로부터 &lt;code&gt;NavController.navigate()&lt;/code&gt;를 호출하는 람다가 전달된다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;복잡한 데이터 사용하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Navigation에서 복잡한 객체(ex. UserInfo, Product 등 전체 모델)를 직접 전달하는 것은 지양해야 한다. 대신 화면 이동 시에는 필수적인 최소 정보, 예를 들면 고유 식별자(ID)와 같은 값만 전달하는 것이 좋다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;// Pass only the user ID when navigating to a new destination as argument
navController.navigate(Profile(id = &quot;user1234&quot;))&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;복잡한 객체는 별도의 Data Layer에서 관리하며, 이를 통해 SSOT(Single Source of Truth) 원칙을 따를 수 있다. Destination으로 이동한 뒤에는 전달된 ID를 이용해 Data Layer에서 필요한 데이터를 조회하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추가로 ViewModel에서 Route에 담긴 인자 값을 참조하기 위해서는 &lt;code&gt;SavedStateHandle&lt;/code&gt;을 사용하면 된다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;class UserViewModel(
    savedStateHandle: SavedStateHandle,
    private val userInfoRepository: UserInfoRepository
) : ViewModel() {

    private val profile = savedStateHandle.toRoute&amp;lt;Profile&amp;gt;()

    // Fetch the relevant user information from the data layer,
    // ie. userInfoRepository, based on the passed userId argument
    private val userInfo: Flow&amp;lt;UserInfo&amp;gt; = userInfoRepository.getUserInfo(profile.id)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 접근 방식은 Configuration Changes가 발생했을 때 데이터가 유실되는 것을 막아주고, 해당 객체가 업데이트 되거나 변경(mutation)되는 과정에서 발생할 수 있는 불일치 문제도 방지해준다.&lt;/p&gt;</description>
      <category>안드로이드/Jetpack Compose</category>
      <author>JooMan</author>
      <guid isPermaLink="true">https://devgeek.tistory.com/157</guid>
      <comments>https://devgeek.tistory.com/157#entry157comment</comments>
      <pubDate>Wed, 26 Nov 2025 16:40:32 +0900</pubDate>
    </item>
  </channel>
</rss>