Claude Code 비용 최적화를 위한 지표 수집

Claude Code의 사용 패턴을 분석하고 개인별 개선 권고를 만들기 위해, Hook과 OpenTelemetry로 어떤 지표를 수집하고 활용할 수 있는지 정리합니다.

Claude Code를 효율적으로 사용하고 있는지 개인적으로나 팀 차원에서 자주 돌아보고 개선 시도를 한다면, 같은 작업을 더 적은 비용으로 할 수 있습니다. 이 글에서는 Claude Code의 Hook과 OpenTelemetry를 활용하여 사용 지표를 수집하고, 수집된 데이터로 개인별 개선 권고 리포트를 만드는 방법을 정리합니다.

1. 수집해야 할 지표

비용 최적화 관점에서 의미 있는 지표는 크게 네 영역으로 나뉩니다.

1.1. 컨텍스트 관리 효율성

컨텍스트가 가득 차서 자동 압축(autocompact)이 발생하면, 그만큼 한 세션에서 많은 토큰을 소비했다는 신호입니다.

지표 의미

세션 시작 유형 (source)

startup(신규), resume(재개), compact(압축 후), clear(/clear 후) 비율로 사용 습관을 파악

자동 압축 빈도

세션당 autocompact 횟수가 많으면 작업 단위를 더 작게 분리할 필요가 있음

압축 간격

압축 사이 시간이 짧을수록 컨텍스트를 빠르게 소진하고 있다는 의미

1.2. 도구 사용 패턴

Claude Code는 파일 읽기, 편집, 명령 실행 등 도구를 호출할 때마다 API 호출이 발생합니다. 도구 사용 방식에 따라 같은 작업의 토큰 소비량이 크게 달라집니다.

지표 의미

도구별 호출 빈도

어떤 도구를 주로 쓰는지 파악. Bash`로 `cat, grep`을 반복 호출하면 전용 도구(`Read, Grep)보다 토큰을 더 소비

도구 호출 실패율

실패 후 재시도는 토큰 낭비. 반복 실패 패턴은 사용자 교육이 필요한 영역

서브에이전트 생성 빈도

Agent 도구로 서브에이전트를 생성하면 추가 API 호출이 발생. 불필요한 서브에이전트 생성은 비용 증가 요인

1.3. 세션 수명 주기

지표 의미

세션 길이 (시작~종료 시간)

세션이 길수록 컨텍스트가 누적되어 턴당 입력 토큰이 증가 [1]

세션 종료 사유

정상 종료, 비정상 종료, rate limit 등의 비율

1.4. API 호출과 비용

가장 직접적인 비용 지표입니다.

지표 의미

API 호출 횟수

세션당, 프롬프트당 API 호출 수

호출당 토큰 수

입력/출력/캐시 읽기/캐시 쓰기 토큰

호출당 비용 (USD)

모델별 단가 적용된 실제 비용

모델별 사용 비율

Opus vs Sonnet vs Haiku. Opus는 Sonnet 대비 5배 비용

캐시 히트율

캐시 읽기 토큰 비율이 높을수록 비용 효율적

2. 지표별 수집 방법

수집 방법은 크게 두 가지입니다. Hook은 사용 행동 패턴을, OpenTelemetry는 정확한 비용 데이터를 수집합니다.

2.1. Hook으로 수집하는 지표

Claude Code의 Hook은 특정 이벤트 발생 시 셸 스크립트를 실행하는 기능입니다. 비용 데이터에는 직접 접근할 수 없지만, 사용 행동 패턴을 기록하는 데 적합합니다.

Hook 이벤트 수집 가능한 지표 비고

SessionStart

세션 시작 유형(source), 사용 모델(model)

source 값: startup, resume, compact, clear

SessionEnd

세션 종료 시각, 종료 사유

세션 시작 시각과 조합하면 세션 길이 산출

PreCompact

압축 발생 시각, 자동/수동 여부

`matcher: auto`로 자동 압축만 필터링 가능

PreToolUse

도구 이름, 호출 시각

고빈도 이벤트이므로 성능 영향 고려 필요

PostToolUseFailure

실패한 도구, 에러 내용

반복 실패 패턴 분석용

SubagentStart

서브에이전트 생성 시각

불필요한 서브에이전트 생성 패턴 분석

2.1.1. 수집 스크립트 예시

하나의 스크립트에서 이벤트 유형별로 분기하여 JSONL 형식으로 기록합니다.

~/.claude/scripts/log-usage.sh
#!/bin/bash
INPUT=$(cat)
EVENT=$(echo "$INPUT" | jq -r '.hook_event_name')
SESSION=$(echo "$INPUT" | jq -r '.session_id')
TIMESTAMP=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
USER=$(whoami)

LOG_DIR="$HOME/.claude/usage-logs"
mkdir -p "$LOG_DIR"

case "$EVENT" in
  SessionStart)
    SOURCE=$(echo "$INPUT" | jq -r '.source')
    MODEL=$(echo "$INPUT" | jq -r '.model')
    echo "{\"ts\":\"$TIMESTAMP\",\"user\":\"$USER\",\"event\":\"session_start\",\"session\":\"$SESSION\",\"source\":\"$SOURCE\",\"model\":\"$MODEL\"}" \
      >> "$LOG_DIR/events.jsonl"
    ;;
  SessionEnd)
    echo "{\"ts\":\"$TIMESTAMP\",\"user\":\"$USER\",\"event\":\"session_end\",\"session\":\"$SESSION\"}" \
      >> "$LOG_DIR/events.jsonl"
    ;;
  PreCompact)
    echo "{\"ts\":\"$TIMESTAMP\",\"user\":\"$USER\",\"event\":\"compact\",\"session\":\"$SESSION\"}" \
      >> "$LOG_DIR/events.jsonl"
    ;;
  PreToolUse)
    TOOL=$(echo "$INPUT" | jq -r '.tool_name')
    echo "{\"ts\":\"$TIMESTAMP\",\"user\":\"$USER\",\"event\":\"tool_use\",\"session\":\"$SESSION\",\"tool\":\"$TOOL\"}" \
      >> "$LOG_DIR/events.jsonl"
    ;;
  PostToolUseFailure)
    TOOL=$(echo "$INPUT" | jq -r '.tool_name')
    echo "{\"ts\":\"$TIMESTAMP\",\"user\":\"$USER\",\"event\":\"tool_fail\",\"session\":\"$SESSION\",\"tool\":\"$TOOL\"}" \
      >> "$LOG_DIR/events.jsonl"
    ;;
  SubagentStart)
    echo "{\"ts\":\"$TIMESTAMP\",\"user\":\"$USER\",\"event\":\"subagent\",\"session\":\"$SESSION\"}" \
      >> "$LOG_DIR/events.jsonl"
    ;;
esac

2.1.2. settings.json 설정

~/.claude/settings.json
{
  "hooks": {
    "SessionStart": [
      { "hooks": [{ "type": "command", "command": "~/.claude/scripts/log-usage.sh" }] }
    ],
    "SessionEnd": [
      { "hooks": [{ "type": "command", "command": "~/.claude/scripts/log-usage.sh" }] }
    ],
    "PreCompact": [
      { "matcher": "auto",
        "hooks": [{ "type": "command", "command": "~/.claude/scripts/log-usage.sh" }] }
    ],
    "PreToolUse": [
      { "hooks": [{ "type": "command", "command": "~/.claude/scripts/log-usage.sh" }] }
    ],
    "PostToolUseFailure": [
      { "hooks": [{ "type": "command", "command": "~/.claude/scripts/log-usage.sh" }] }
    ],
    "SubagentStart": [
      { "hooks": [{ "type": "command", "command": "~/.claude/scripts/log-usage.sh" }] }
    ]
  }
}

2.2. OpenTelemetry로 수집하는 지표

Hook에서는 API 호출 횟수, 토큰 수, 비용 같은 핵심 비용 데이터에 접근할 수 없습니다. 이 데이터는 Claude Code의 OpenTelemetry 연동을 통해 수집할 수 있습니다.

Claude Code는 claude_code.api_request 이벤트를 OTLP로 내보냅니다. 이 이벤트에는 다음 필드가 포함됩니다.

필드 설명

model

사용된 모델 (예: claude-sonnet-4-6)

cost_usd

해당 API 호출의 비용 (USD)

input_tokens

입력 토큰 수

output_tokens

출력 토큰 수

cache_read_tokens

캐시에서 읽은 토큰 수

cache_creation_tokens

캐시에 새로 쓴 토큰 수

duration_ms

API 응답 시간

event.sequence

세션 내 API 호출 순번 (순서 보장용)

prompt.id

사용자 프롬프트 단위 UUID (한 프롬프트에서 여러 API 호출이 발생할 수 있음)

2.2.1. 활성화 방법

환경 변수를 설정하면 Claude Code가 OTLP 프로토콜로 텔레메트리를 전송합니다.

export CLAUDE_CODE_ENABLE_TELEMETRY=1
export OTEL_LOGS_EXPORTER=otlp
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317

개발자 PC의 셸 프로파일(~/.bashrc, ~/.zshrc 등)에 추가하거나, 전사 dotfiles 관리 도구로 배포할 수 있습니다.

2.3. 수집 방법 요약

지표 Hook OpenTelemetry

세션 시작/종료, 압축 빈도

O

도구 사용 패턴, 실패율

O

API 호출 횟수

O

토큰 수 (입력/출력/캐시)

O

호출당 비용 (USD)

O

모델별 사용 비율

O

O

3. 개인 분석: 로컬 로그로 셀프 리포트 만들기

전사 수집 인프라가 없더라도, 앞서 설명한 Hook 스크립트로 로컬에 쌓인 ~/.claude/usage-logs/events.jsonl 파일만으로 개인 사용 패턴을 분석할 수 있습니다.

3.1. 분석 스크립트 예시

`jq`와 셸 명령만으로 주요 지표를 산출하는 스크립트입니다.

~/.claude/scripts/self-report.sh
#!/bin/bash
LOG="$HOME/.claude/usage-logs/events.jsonl"

if [ ! -f "$LOG" ]; then
  echo "로그 파일이 없습니다: $LOG"
  exit 1
fi

# 분석 기간 (기본: 최근 7일)
SINCE=$(date -u -d '7 days ago' '+%Y-%m-%dT00:00:00Z' 2>/dev/null \
     || date -u -v-7d '+%Y-%m-%dT00:00:00Z')  # macOS 호환
DATA=$(jq -c --arg since "$SINCE" 'select(.ts >= $since)' "$LOG")

echo "=== 셀프 사용 리포트 (최근 7일) ==="
echo ""

# 세션 수
SESSIONS=$(echo "$DATA" | jq -r 'select(.event=="session_start") | .session' | sort -u | wc -l)
echo "[세션 요약]"
echo "  세션 수: $SESSIONS"

# 세션 시작 유형 비율
echo "  시작 유형:"
echo "$DATA" | jq -r 'select(.event=="session_start") | .source' \
  | sort | uniq -c | sort -rn | while read count source; do
    echo "    $source: $count"
  done

# autocompact 횟수
COMPACTS=$(echo "$DATA" | jq -r 'select(.event=="compact")' | wc -l)
echo "  자동 압축 횟수: $COMPACTS (세션당 평균: $(echo "scale=1; $COMPACTS / $SESSIONS" | bc 2>/dev/null || echo "N/A"))"
echo ""

# 도구 사용 패턴
echo "[도구 사용]"
TOTAL_TOOLS=$(echo "$DATA" | jq -r 'select(.event=="tool_use")' | wc -l)
echo "  총 도구 호출: $TOTAL_TOOLS"
echo "  상위 도구:"
echo "$DATA" | jq -r 'select(.event=="tool_use") | .tool' \
  | sort | uniq -c | sort -rn | head -10 | while read count tool; do
    pct=$(echo "scale=0; $count * 100 / $TOTAL_TOOLS" | bc 2>/dev/null || echo "?")
    echo "    $tool: $count (${pct}%)"
  done

# Bash로 cat/grep/find 호출 비율 (도구 이름만으로는 Bash 내부 명령을 알 수 없으므로 Bash 비율만 표시)
BASH_COUNT=$(echo "$DATA" | jq -r 'select(.event=="tool_use" and .tool=="Bash")' | wc -l)
if [ "$TOTAL_TOOLS" -gt 0 ]; then
  BASH_PCT=$(echo "scale=0; $BASH_COUNT * 100 / $TOTAL_TOOLS" | bc 2>/dev/null || echo "?")
  echo "  Bash 도구 비율: ${BASH_PCT}% (30% 이상이면 전용 도구 활용 권장)"
fi

# 실패율
FAILS=$(echo "$DATA" | jq -r 'select(.event=="tool_fail")' | wc -l)
if [ "$TOTAL_TOOLS" -gt 0 ]; then
  FAIL_PCT=$(echo "scale=1; $FAILS * 100 / $TOTAL_TOOLS" | bc 2>/dev/null || echo "?")
  echo "  도구 호출 실패: $FAILS (실패율: ${FAIL_PCT}%)"
fi

# 서브에이전트
SUBAGENTS=$(echo "$DATA" | jq -r 'select(.event=="subagent")' | wc -l)
echo "  서브에이전트 생성: $SUBAGENTS (세션당 평균: $(echo "scale=1; $SUBAGENTS / $SESSIONS" | bc 2>/dev/null || echo "N/A"))"
echo ""

# 간단한 권고
echo "[자동 권고]"
COMPACT_AVG=$(echo "scale=1; $COMPACTS / $SESSIONS" | bc 2>/dev/null || echo "0")
if [ "$(echo "$COMPACT_AVG >= 3" | bc 2>/dev/null)" = "1" ]; then
  echo "  ⚠ 세션당 autocompact가 ${COMPACT_AVG}회입니다. 작업을 더 작은 단위로 분리하세요."
fi
if [ "$(echo "$BASH_PCT >= 30" | bc 2>/dev/null)" = "1" ]; then
  echo "  ⚠ Bash 도구 비율이 ${BASH_PCT}%입니다. Read, Grep, Glob 전용 도구를 활용하세요."
fi
if [ "$(echo "$FAIL_PCT > 15" | bc 2>/dev/null)" = "1" ]; then
  echo "  ⚠ 도구 호출 실패율이 ${FAIL_PCT}%입니다. 반복 실패하는 도구의 사용법을 점검하세요."
fi
SUBAGENT_AVG=$(echo "scale=1; $SUBAGENTS / $SESSIONS" | bc 2>/dev/null || echo "0")
if [ "$(echo "$SUBAGENT_AVG > 5" | bc 2>/dev/null)" = "1" ]; then
  echo "  ⚠ 세션당 서브에이전트가 평균 ${SUBAGENT_AVG}회입니다. 간단한 검색은 직접 도구를 호출하세요."
fi
echo "  (비용 관련 권고는 OpenTelemetry 데이터가 필요합니다)"

스크립트 실행 결과 예시:

=== 셀프 사용 리포트 (최근 7일) ===

[세션 요약]
  세션 수: 15
  시작 유형:
    startup: 10
    resume: 3
    compact: 2
  자동 압축 횟수: 5 (세션당 평균: 0.3)

[도구 사용]
  총 도구 호출: 312
  상위 도구:
    Read: 89 (28%)
    Edit: 67 (21%)
    Bash: 58 (18%)
    Grep: 45 (14%)
    Glob: 32 (10%)
    Write: 12 (3%)
    Agent: 9 (2%)
  Bash 도구 비율: 18% (30% 이상이면 전용 도구 활용 권장)
  도구 호출 실패: 8 (실패율: 2.5%)
  서브에이전트 생성: 9 (세션당 평균: 0.6)

[자동 권고]
  (비용 관련 권고는 OpenTelemetry 데이터가 필요합니다)

이 스크립트는 별도의 인프라 없이도 개인이 즉시 실행하여 자신의 사용 습관을 점검할 수 있습니다. 팀 평균과의 비교가 필요 없는 개인 수준의 개선에는 이것만으로 충분합니다.

4. 수집 데이터 저장소

다수 사용자의 데이터를 분석하려면 중앙 저장소가 필요합니다. 조직 규모와 기존 인프라에 따라 선택지가 달라집니다.

4.1. 저장소 구성

개발자 PC                         중앙 수집                    분석/리포트
┌──────────────┐                ┌─────────────────┐         ┌──────────────┐
│ Claude Code  │                │                 │         │              │
│  ├ Hook      │── JSONL 전송 ─▶│  저장소          │────────▶│  분석 파이프  │
│  └ OTel      │── OTLP ──────▶│                 │         │  라인        │
└──────────────┘                └─────────────────┘         └──────────────┘

4.2. 저장소 선택지

방식 장점 단점 적합한 규모

파일 기반 (JSONL + S3)

구축 비용 최소, 별도 인프라 불필요

실시간 쿼리 어려움, 분석 시 별도 로딩 필요

소규모 (10명 이하)

OpenTelemetry Collector + Clickhouse

OTLP 네이티브 수집, 대량 시계열 데이터에 강점

Clickhouse 운영 부담

중규모 (10~100명)

Grafana + Loki/Tempo

기존 모니터링 인프라 활용 가능, 대시보드 내장

학습 곡선, 커스텀 리포트 생성이 번거로움

모니터링 인프라가 이미 있는 조직

BigQuery / Athena

SQL로 자유로운 분석, 대규모 데이터 처리

클라우드 비용, 초기 파이프라인 구축

대규모 (100명 이상)

4.3. Hook 데이터 중앙 수집

Hook은 기본적으로 개발자 PC의 로컬 파일에 기록합니다. 중앙으로 모으려면 추가 처리가 필요합니다. 세 가지 방법이 있습니다.

  • cron + rsync/scp: 주기적으로 로컬 JSONL을 중앙 서버나 S3로 전송

  • Hook 스크립트에서 직접 전송: `curl`로 수집 API에 POST (단, Hook 실행 시간이 세션 성능에 영향을 줄 수 있으므로 백그라운드 전송 권장)

  • Fluentd/Fluent Bit: 로컬 JSONL 파일을 tail하여 중앙 저장소로 전송

OpenTelemetry 데이터는 OTLP 엔드포인트를 중앙 Collector로 지정하면 별도 처리 없이 수집됩니다.

5. 권고 가이드 생성 로직

수집된 데이터를 분석하여 개인별 개선 권고를 생성하는 로직입니다.

5.1. 컨텍스트 관리 권고

조건 판정 권고

세션당 autocompact >= 3회

컨텍스트 과다 사용

"작업을 더 작은 단위로 분리하세요. 한 세션에서 하나의 목적만 달성하는 것이 비용 효율적입니다."

compact 간격 < 10분

빠른 컨텍스트 소진

"프롬프트를 더 간결하게 작성하거나, 불필요한 파일 읽기를 줄여보세요."

source=resume 비율 < 10%

세션 재개를 활용하지 않음

"이전 세션을 재개(claude --resume)하면 컨텍스트를 처음부터 다시 구성하지 않아도 됩니다."

5.2. 도구 사용 권고

조건 판정 권고

Bash로 cat/grep/find 호출 비율 > 30%

전용 도구 미활용

"Bash 대신 Read, Grep, Glob 등 전용 도구를 사용하면 토큰을 절약할 수 있습니다."

도구 호출 실패율 > 15%

비효율적 도구 사용

"도구 호출 실패가 잦습니다. 에러 메시지를 확인하고 사용법을 점검해보세요."

세션당 서브에이전트 > 5회

서브에이전트 남용 가능성

"서브에이전트 생성을 줄이고, 간단한 검색은 직접 도구를 호출하는 것이 비용 효율적입니다."

5.3. 모델 선택 권고

조건 판정 권고

Opus 사용 비율 > 50%

고비용 모델 과다 사용

"단순 편집이나 검색 작업에는 Sonnet을 사용하세요. Opus는 Sonnet 대비 5배 비용입니다."

서브에이전트에서 Opus 사용

서브에이전트 비용 과다

"서브에이전트는 Sonnet이나 Haiku로 충분한 경우가 많습니다."

5.4. 비용 효율 권고

이 항목은 OpenTelemetry 데이터가 있어야 산출 가능합니다.

조건 판정 권고

캐시 히트율 < 50%

캐시 활용 부족

"세션 중간에 모델을 전환하거나 CLAUDE.md를 수정하면 캐시가 무효화됩니다. 한 세션에서는 하나의 모델을 유지하세요." [2]

프롬프트당 API 호출 > 20회

한 프롬프트에 과도한 작업 요청

"하나의 프롬프트에 여러 작업을 한꺼번에 요청하면 API 호출이 급증합니다. 작업을 나누어 요청하세요."

일일 비용이 팀 평균의 2배 이상

비용 이상치

개별 상담 대상. 사용 패턴 상세 분석 필요

5.5. 리포트 예시

위의 로직을 조합하면 다음과 같은 개인별 주간 리포트를 생성할 수 있습니다.

=== 주간 사용 리포트 (2026-03-24 ~ 2026-03-30) ===
사용자: hong

[요약]
  세션 수: 23
  총 API 호출: 847
  총 비용: $42.30
  주요 모델: Sonnet 68%, Opus 32%

[개선 권고]
  1. 컨텍스트 관리
     - 세션당 평균 autocompact: 3.2회 (팀 평균: 1.1회)
     → 작업을 더 작은 단위로 분리하세요.

  2. 도구 사용
     - Bash로 grep/cat 호출: 전체 도구 사용의 41%
     → Read, Grep 전용 도구를 직접 사용하면 토큰을 절약할 수 있습니다.

  3. 모델 선택
     - Opus 사용 비율: 32% (팀 평균: 15%)
     → 단순 작업에서는 Sonnet으로 전환을 권장합니다.
     → 예상 절감: 주당 ~$8.50

[잘하고 있는 점]
  - 캐시 히트율 87% (팀 평균: 72%)
  - 도구 호출 실패율 3% (팀 평균: 8%)

6. 주의할 점

  • PreToolUse는 고빈도 이벤트입니다. Hook 스크립트의 실행 시간이 길면 세션 응답 속도에 영향을 줄 수 있습니다. 스크립트를 가볍게 유지하거나, 샘플링을 적용하거나, 백그라운드로 기록하는 방식을 고려해야 합니다.

  • 전사 배포가 필요합니다. Hook 설정과 환경 변수를 각 개발자 PC에 배포해야 합니다. Enterprise managed config, dotfiles 저장소, 또는 온보딩 스크립트를 활용할 수 있습니다.

  • Hook으로는 정확한 토큰 수와 비용을 알 수 없습니다. 비용 분석이 목적이라면 OpenTelemetry는 필수입니다. Hook만으로는 사용 행동 패턴 분석에 한정됩니다.

  • 개인정보 고려가 필요합니다. 프롬프트 내용이나 코드를 수집하는 것이 아니라 메타데이터(이벤트 유형, 도구 이름, 타임스탬프)만 수집하지만, 수집 목적과 범위를 사전에 공유하고 동의를 구하는 것이 바람직합니다.


1. 턴당 입력 토큰의 이차 증가에 대해서는 Claude Code 토큰 비용과 프롬프트 캐싱을 참고
2. Prompt Caching의 상세 원리는 Claude Code 토큰 비용과 프롬프트 캐싱을 참고

Claude Code Hook으로 quota 전환 자동화하기

Claude Code의 Hook을 활용하여 여러 quota 간 전환을 자동화하는 방법을 정리합니다.

배경

Claude Code를 여러 quota로 사용하는 경우가 있습니다. 예를 들면 다음과 같습니다.

  • Max plan을 2개를 구독해서 번갈아 가면서 쓰는 경우

  • 같은 이메일 계정에 여러 organization이 연결되어 각각 별도의 quota가 있는 경우

  • Team plan과 Enterprise plan을 동시에 구독하는 경우

이런 상황에서 한쪽 quota가 소진되면 다른 쪽으로 전환해서 쓸 수 있습니다. 문제는 quota A가 소진되어 quota B로 전환한 뒤, quota A가 초기화되는 시점에 다시 돌아오는 걸 잊기 쉽다는 것입니다. Claude Code의 Hook 기능을 활용하면 이 전환을 자동화할 수 있습니다.

이 글은 Claude Code에게 프롬프트를 내려서 hook 스크립트를 생성하고, 생성된 코드를 분석하는 방식으로 구성됩니다.

전체 구성

필요 환경

  • Claude Code v2.1.78(2026년 3월 18일 릴리즈) 이상 (StopFailure hook 이벤트 지원)

  • jq 설치 필요

원리: Quota 소진 시 Claude Code의 동작

사용자에게 보여주는 메시지

Claude Code에서는 API quota를 소진 시 초기화 예정 시간을 포함한 메시지를 안내합니다.

5시간 한도 소진 시
You've hit your limit · resets 2pm (Asia/Seoul)

주간 한도 소진 시에는 날짜도 포함됩니다:

주간 한도 소진 시
You've hit your limit · resets Feb 20, 5pm (Asia/Seoul)

메시지 형식은 Claude Code 버전에 따라 달라져 왔습니다. 과거에는 Claude usage limit reached. Your limit will reset at 2pm (America/New_York) 형식이었고, 중간5-hour limit reached · resets 12pm 을 거쳐, 현재You’ve hit your limit · resets …​ 형식이 되었습니다.

StopFailure hook 이벤트

Quota 소진 시 발생하는 hook 이벤트는 StopFailure 입니다 (2026년 3월 18일 릴리즈된 v2.1.78에서 도입). Notification 이나 Stop 은 quota 소진 시 발생하지 않습니다.

StopFailure 이벤트의 JSON 입력 (실제 기록된 데이터)
{
  "session_id": "5cbd3730-...",
  "transcript_path": "/home/user/.claude/projects/.../abc123.jsonl",
  "cwd": "/home/user/project",
  "hook_event_name": "StopFailure",
  "error": "rate_limit",
  "last_assistant_message": "You've hit your limit · resets 11pm (Asia/Seoul)"
}

초기화 시간 전용 필드는 없지만 (#36056, #30784에서 기능 요청 중), last_assistant_message 필드에 초기화 시간이 포함된 메시지가 담겨 옵니다. `on-rate-limit.sh`에서 이 필드를 grep하여 초기화 시간을 추출합니다.

실행 흐름

아래는 quota 전환 자동화의 전체 흐름입니다. 스크립트 3개와 hook 2개로 구성됩니다.

[Quota A 소진]
    │
    ▼
StopFailure hook (rate_limit) ──→ on-rate-limit.sh
    │                                  │
    │                          last_assistant_message에서 초기화 시간 파싱
    │                                  │
    │                                  ▼
    │                          quota-switch-reset-time 파일에 기록
    │
    ▼
[사용자가 /logout 후 Quota B로 로그인]
    │
    ▼
[Quota B로 작업 계속]
    │
    ▼
[초기화 시간 경과 후 새 세션 or /clear]
    │
    ▼
SessionStart hook ──→ check-quota-switch.sh
                          │
                  시간 비교: 현재 >= 초기화 시간?
                          │
                    Yes ──┤
                          │
                  credentials 삭제 + 세션 블로킹
                          │
                          ▼
                  [Claude Code 재시작 → Quota A로 로그인]
스크립트 역할 호출 시점

on-rate-limit.sh

Hook 입력의 last_assistant_message 에서 초기화 시간을 파싱하여 기록

StopFailure hook (rate_limit)

check-quota-switch.sh

초기화 시간이 지났으면 credentials를 삭제하여 재로그인 유도

SessionStart hook (startup, clear)

record-quota-reset.sh

초기화 시간을 수동으로 기록 (자동 파싱 실패 시 fallback)

사용자가 직접 실행

구현과 테스트 프롬프트

구현 프롬프트

아래와 같은 프롬프트로 Claude Code에게 구현을 요청합니다.

Claude Code를 quota A, quota B 두 개로 쓰고 있어. Quota A가 소진되면 quota B로 전환해서 쓰다가, quota A의 초기화 시간이 지난 후 새 세션이나 /clear 시에 자동으로 quota B를 logout시켜서 quota A로 복귀하고 싶어. 아래 요구사항대로 구현해줘.

  1. quota 소진 감지: StopFailure hook에 rate_limit matcher를 걸어서 소진 시점을 감지해줘. 스크립트 이름은 on-rate-limit.sh로 해줘.

  2. 초기화 시간 자동 기록: on-rate-limit.sh에서 hook 입력의 last_assistant_message 필드에서 "resets TIME (TIMEZONE)" 패턴을 파싱하여 초기화 시간을 ~/.claude/quota-switch-reset-time에 epoch으로 저장해줘.

  3. 초기화 시간 수동 기록(fallback): 자동 파싱이 실패할 경우를 대비해 record-quota-reset.sh도 만들어줘. "4h", "30m" 같은 상대 시간과 절대 시간을 지원해야해.

  4. 자동 복귀: SessionStart hook에서 check-quota-switch.sh를 실행해서, 현재 시간이 초기화 시간을 지났으면 credentials를 삭제하여 재로그인을 유도해줘. startup과 clear 두 matcher 모두 등록해줘.

Claude Code는 이 요청을 받아 세 개의 스크립트 파일을 생성하고, `~/.claude/settings.json`에 hook 설정까지 직접 등록했습니다. 사용자가 `settings.json`을 수동으로 편집할 필요 없이, 프롬프트 하나로 스크립트 작성과 hook 등록이 모두 완료됩니다.

테스트 프롬프트

실제 quota를 소진하지 않고도 hook과 스크립트가 정상 동작하는지 검증할 수 있습니다. 테스트 역시 Claude Code에게 요청하면 됩니다.

~/.claude/settings.json에 등록된 on-rate-limit.sh와 check-quota-switch.sh를 실제 quota 소진 없이 테스트하고 싶어. on-rate-limit.sh는 hook JSON 입력을 직접 구성해서 stdin으로 넘기는 방식으로 테스트해줘. "resets 2pm (Asia/Seoul)" 같은 5시간 한도 메시지와 "resets Feb 20, 5pm (Asia/Seoul)" 같은 주간 한도 메시지 두 가지를 모두 테스트해줘. 각 테스트 후 hook-events.log와 quota-switch-reset-time 파일을 확인해줘. check-quota-switch.sh는 과거 시간의 epoch을 기록한 뒤 실행해서 credentials 삭제 동작을 검증해줘. 단, 실제 credentials가 삭제되므로 테스트 전에 백업하고 테스트 후 복원해줘.

Claude Code는 이 요청에 따라 hook JSON을 직접 구성하여 `on-rate-limit.sh`에 전달하고, 결과를 확인하는 과정을 수행합니다. 예를 들어 5시간 한도 메시지 테스트는 다음과 같이 실행됩니다:

echo '{
  "hook_event_name": "StopFailure",
  "error": "rate_limit",
  "last_assistant_message": "You'\''ve hit your limit · resets 2pm (Asia/Seoul)"
}' | ~/.claude/scripts/on-rate-limit.sh

테스트가 성공하면 ~/.claude/hook-events.log 에 "Auto-recorded quota reset time"이 기록되고, ~/.claude/quota-switch-reset-time 파일에 epoch 값이 저장됩니다.

사용 방법

1단계: quota A 소진

Quota A가 소진되면 StopFailure hook이 자동으로 on-rate-limit.sh 를 실행합니다. Hook 입력의 last_assistant_message 에서 초기화 시간을 파싱하여 `~/.claude/quota-switch-reset-time`에 기록합니다. `~/.claude/hook-events.log`에서 "Auto-recorded quota reset time"이 기록되었는지 확인할 수 있습니다.

자동 파싱에 실패한 경우, 로그에 실패 사유가 기록됩니다. 이때는 수동으로 쿼터가 초기화될 시간을 기록합니다:

record-quota-reset.sh 수동 실행 예시
# 절대 시간
~/.claude/scripts/record-quota-reset.sh "2026-03-29 09:00"

# 상대 시간
~/.claude/scripts/record-quota-reset.sh 4h
~/.claude/scripts/record-quota-reset.sh 30m

2단계: quota B로 전환

Claude Code에서 /logout 을 실행한 후 quota B 계정으로 재로그인합니다.

3단계: 자동 복귀

기록된 초기화 시간이 지난 후에 Claude Code를 새로 시작하거나 /clear 를 실행하면 SessionStart hook이 동작합니다. Quota B의 credentials가 자동으로 제거되고, Claude Code를 재시작하면 quota A로 로그인할 수 있습니다.

생성된 코드 분석

아래 코드는 Linux 환경에서 생성된 것입니다. macOS에서의 차이점은 macOS에서의 차이점를 참고하세요.

Quota 소진 감지 및 초기화 시간 자동 기록

~/.claude/scripts/on-rate-limit.shStopFailure hook에서 실행되는 스크립트입니다. Quota 소진 시 hook 입력의 last_assistant_message 필드에서 초기화 시간을 파싱하여 기록합니다.

on-rate-limit.sh
#!/bin/bash
# StopFailure hook (matcher: rate_limit)
# 1. Log the event for diagnostics
# 2. Extract reset time from last_assistant_message (hook JSON input)

LOG_FILE="$HOME/.claude/hook-events.log"
INPUT=$(cat)

# Step 1: Log the event
echo "=== $(date '+%Y-%m-%d %H:%M:%S') ===" >> "$LOG_FILE"
echo "$INPUT" | jq . >> "$LOG_FILE" 2>/dev/null || echo "$INPUT" >> "$LOG_FILE"
echo "" >> "$LOG_FILE"

# Step 2: Extract reset time from last_assistant_message
LAST_MSG=$(echo "$INPUT" | jq -r '.last_assistant_message // empty')
if [ -z "$LAST_MSG" ]; then
    echo "No last_assistant_message in hook input" >> "$LOG_FILE"
    exit 0
fi

RESET_LINE=$(echo "$LAST_MSG" | grep -oP \
    'resets?\s+(at\s+)?(\w+\s+\d+,?\s+)?\d{1,2}(:\d{2})?\s*[ap]m(\s*\([^)]+\))?')

if [ -z "$RESET_LINE" ]; then
    echo "No reset time found in message: $LAST_MSG" >> "$LOG_FILE"
    exit 0
fi

echo "Found reset info: $RESET_LINE" >> "$LOG_FILE"

# Step 3: Parse the reset time
TIME_PART=$(echo "$RESET_LINE" | grep -oP '\d{1,2}(:\d{2})?\s*[ap]m')
DATE_PART=$(echo "$RESET_LINE" | grep -oP '(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2}' | head -1)
TZ_PART=$(echo "$RESET_LINE" | grep -oP '\(([^)]+)\)' | tr -d '()')

# Convert 12-hour to 24-hour
HOUR=$(echo "$TIME_PART" | grep -oP '^\d{1,2}')
MINUTE=$(echo "$TIME_PART" | grep -oP ':\K\d{2}' || echo "00")
AMPM=$(echo "$TIME_PART" | grep -oP '[ap]m')

if [ "$AMPM" = "pm" ] && [ "$HOUR" -ne 12 ]; then
    HOUR=$((HOUR + 12))
elif [ "$AMPM" = "am" ] && [ "$HOUR" -eq 12 ]; then
    HOUR=0
fi
TIME_24=$(printf "%02d:%s" "$HOUR" "$MINUTE")

# Build datetime string
TODAY=$(date "+%Y-%m-%d")
if [ -n "$DATE_PART" ]; then
    YEAR=$(date "+%Y")
    DATETIME="$DATE_PART $YEAR $TIME_24"
else
    DATETIME="$TODAY $TIME_24"
fi

# Convert to epoch
if [ -n "$TZ_PART" ]; then
    RESET_EPOCH=$(TZ="$TZ_PART" date -d "$DATETIME" +%s 2>/dev/null)
else
    RESET_EPOCH=$(date -d "$DATETIME" +%s 2>/dev/null)
fi

if [ -z "$RESET_EPOCH" ]; then
    echo "Failed to parse reset time: $DATETIME (TZ=$TZ_PART)" >> "$LOG_FILE"
    exit 0
fi

# If parsed time is in the past, assume next day (5-hour limit case)
NOW_EPOCH=$(date +%s)
if [ "$RESET_EPOCH" -le "$NOW_EPOCH" ] && [ -z "$DATE_PART" ]; then
    RESET_EPOCH=$((RESET_EPOCH + 86400))
fi

# Step 4: Record the reset time
RESET_FILE="$HOME/.claude/quota-switch-reset-time"
echo "$RESET_EPOCH" > "$RESET_FILE"
RESET_DISPLAY=$(date -d "@$RESET_EPOCH" "+%Y-%m-%d %H:%M:%S")
echo "Auto-recorded quota reset time: $RESET_DISPLAY" >> "$LOG_FILE"

exit 0

last_assistant_message 필드에서 resets TIME (TIMEZONE) 패턴을 grep하여 초기화 시간을 추출하는 원리는 StopFailure hook 이벤트를 참고하세요.

초기화 시간 수동 기록 (fallback)

~/.claude/scripts/record-quota-reset.sh 는 자동 파싱이 실패했을 때 초기화 시간을 수동으로 기록하는 스크립트입니다.

record-quota-reset.sh
#!/bin/bash
# Record when quota A resets so the hook can switch back from quota B
# Usage: record-quota-reset.sh "2026-03-29 09:00"
#        record-quota-reset.sh 4h    (4 hours from now)
#        record-quota-reset.sh 30m   (30 minutes from now)

RESET_FILE="$HOME/.claude/quota-switch-reset-time"

if [ -z "$1" ]; then
    echo "Usage: $0 <datetime or duration>"
    echo "  Examples:"
    echo "    $0 '2026-03-29 09:00'   # absolute time"
    echo "    $0 4h                    # 4 hours from now"
    echo "    $0 30m                   # 30 minutes from now"
    exit 1
fi

INPUT="$1"

# Parse duration format (e.g., 4h, 30m)
if [[ "$INPUT" =~ ^([0-9]+)h$ ]]; then
    HOURS="${BASH_REMATCH[1]}"
    RESET_EPOCH=$(date -d "+${HOURS} hours" +%s)
    RESET_DISPLAY=$(date -d "+${HOURS} hours" "+%Y-%m-%d %H:%M:%S")
elif [[ "$INPUT" =~ ^([0-9]+)m$ ]]; then
    MINS="${BASH_REMATCH[1]}"
    RESET_EPOCH=$(date -d "+${MINS} minutes" +%s)
    RESET_DISPLAY=$(date -d "+${MINS} minutes" "+%Y-%m-%d %H:%M:%S")
else
    # Absolute datetime
    RESET_EPOCH=$(date -d "$INPUT" +%s 2>/dev/null)
    if [ $? -ne 0 ]; then
        echo "Error: Cannot parse datetime '$INPUT'"
        exit 1
    fi
    RESET_DISPLAY=$(date -d "$INPUT" "+%Y-%m-%d %H:%M:%S")
fi

echo "$RESET_EPOCH" > "$RESET_FILE"
echo "Quota A reset time recorded: $RESET_DISPLAY"
echo "Saved to: $RESET_FILE"
echo ""
echo "Now run /logout in Claude Code and log in with quota B."
echo "When the reset time arrives, the next session start or /clear will auto-logout quota B."

절대 시간("2026-03-29 09:00")과 상대 시간(4h, 30m) 두 가지 형식을 지원합니다. 내부적으로는 Unix epoch 타임스탬프로 변환하여 ~/.claude/quota-switch-reset-time 파일에 저장합니다.

자동 복귀

~/.claude/scripts/check-quota-switch.sh 는 SessionStart hook에서 실행되는 스크립트입니다.

check-quota-switch.sh
#!/bin/bash
# SessionStart hook: check if quota A has reset
# If so, remove credentials to force re-login with quota A

RESET_FILE="$HOME/.claude/quota-switch-reset-time"
CRED_FILE="$HOME/.claude/.credentials.json"

# No reset time recorded, nothing to do
if [ ! -f "$RESET_FILE" ]; then
    exit 0
fi

RESET_EPOCH=$(cat "$RESET_FILE")
NOW_EPOCH=$(date +%s)

if [ "$NOW_EPOCH" -ge "$RESET_EPOCH" ]; then
    # Time to switch back to quota A
    RESET_DISPLAY=$(date -d "@$RESET_EPOCH" "+%Y-%m-%d %H:%M:%S")

    # Backup quota B credentials and remove current credentials
    if [ -f "$CRED_FILE" ]; then
        cp "$CRED_FILE" "$HOME/.claude/.credentials.quota-b.bak"
        rm -f "$CRED_FILE"
    fi

    # Remove reset time file
    rm -f "$RESET_FILE"

    # Exit with code 2 to block session and show message
    echo "Quota A has reset (reset time: $RESET_DISPLAY)." >&2
    echo "Quota B credentials have been removed." >&2
    echo "Please restart Claude Code to log in with quota A." >&2
    exit 2
fi

# Not yet time to switch
exit 0

이 스크립트의 핵심 동작은 다음과 같습니다.

  • 기록된 초기화 시간 파일이 없으면 `exit 0`으로 아무 동작 없이 종료합니다. 평소에는 hook이 세션 시작을 방해하지 않습니다.

  • 현재 시간이 초기화 시간 이상이면 전환을 실행합니다.

  • ~/.claude/.credentials.json~/.claude/.credentials.quota-b.bak 으로 백업한 후 삭제합니다. Credentials 파일이 없으면 Claude Code가 다음 시작 시 로그인을 요청합니다.

  • `exit 2`로 세션을 블로킹하고 stderr로 안내 메시지를 출력합니다. Claude Code hook에서 exit code 2는 세션 진행을 차단하고 stderr 메시지를 사용자에게 보여주는 약속된 규칙입니다.

settings.json hook 설정

~/.claude/settings.jsonStopFailure`와 `SessionStart hook을 등록합니다.

{
  "hooks": {
    "StopFailure": [
      {
        "matcher": "rate_limit",
        "hooks": [
          {
            "type": "command",
            "command": "/home/user/.claude/scripts/on-rate-limit.sh"
          }
        ]
      }
    ],
    "SessionStart": [
      {
        "matcher": "startup",
        "hooks": [
          {
            "type": "command",
            "command": "/home/user/.claude/scripts/check-quota-switch.sh"
          }
        ]
      },
      {
        "matcher": "clear",
        "hooks": [
          {
            "type": "command",
            "command": "/home/user/.claude/scripts/check-quota-switch.sh"
          }
        ]
      }
    ]
  }
}

"matcher": "rate_limit" 으로 quota 소진 이벤트만 필터링합니다. SessionStart 이벤트는 startup (새 세션 시작)과 clear (/clear 실행) 두 경우 모두 같은 스크립트를 실행하도록 설정했습니다.

한계

  • credentials 저장소 의존: Linux에서는 ~/.claude/.credentials.json 파일을, macOS에서는 Keychain을 직접 조작하는 방식이므로, Claude Code의 내부 인증 구조가 변경되면 동작하지 않을 수 있습니다.

  • 완전 자동 로그인은 불가: Credentials 제거 후 재시작 시 로그인 화면이 나타나지만, quota A 계정을 자동으로 선택해주지는 않습니다. 사용자가 직접 quota A 계정으로 로그인해야 합니다.

  • 세션 중간 전환은 지원하지 않음: SessionStart hook은 새 세션 시작이나 /clear 시에만 동작합니다. 세션 중간에 초기화 시간이 지나더라도 현재 세션에서는 전환이 일어나지 않습니다. 매 프롬프트마다 체크하는 방식(UserPromptSubmit hook)도 가능하지만, 세션 중간에 credentials가 제거되면 진행 중인 작업과 컨텍스트가 유실되므로 권장하지 않습니다.

  • 메시지 형식 의존: on-rate-limit.sh`의 파싱은 `last_assistant_message 필드의 resets TIME (TIMEZONE) 패턴에 의존합니다. Claude Code 버전에 따라 메시지 형식이 달라져 왔으므로, 향후 형식이 변경되면 파싱이 실패할 수 있습니다. 실패 시 ~/.claude/hook-events.log 에 원인이 기록되며, `record-quota-reset.sh`로 수동 기록할 수 있습니다.

macOS에서의 차이점

Claude Code는 실행 환경의 OS를 인식합니다. macOS에서 같은 요청을 하면 date 명령어 부분이 BSD 문법으로 생성됩니다. 주요 차이는 다음과 같습니다.

용도 Linux (GNU date) macOS (BSD date)

상대 시간 (4시간 후)

date -d "+4 hours" +%s

date -v+4H +%s

절대 시간 파싱

date -d "2026-03-29 09:00" +%s

date -j -f "%Y-%m-%d %H:%M" "2026-03-29 09:00" +%s

epoch → 표시 형식

date -d "@$EPOCH" "+%Y-%m-%d %H:%M:%S"

date -r $EPOCH "+%Y-%m-%d %H:%M:%S"

이 외에도 macOS에서는 다음 차이가 있습니다:

  • grep -oP (PCRE)가 macOS 기본 grep에서 지원되지 않습니다. brew install grep 후 `ggrep`을 사용하거나, 정규식을 POSIX 호환으로 변경해야 합니다.

  • macOS에서 Claude Code의 credentials는 macOS Keychain에 저장됩니다. ~/.claude/.credentials.json 파일이 존재하지 않으므로, check-quota-switch.sh에서 파일을 삭제하는 방식이 동작하지 않습니다. macOS에서 프롬프트를 내리면 security delete-generic-password 명령으로 Keychain에서 credentials를 삭제하는 스크립트가 생성됩니다.

Go 코드에 품질 도구 적용하기

Go 프로젝트에서 기본적으로 갖추면 좋은 코드 품질 도구들(gofmt, goimports, go vet, golangci-lint)을 정리하고, Makefile로 통합하는 방법을 소개합니다.

Go는 언어 차원에서 코드 포맷팅 도구(gofmt)를 제공하고, 표준 도구 체인에 정적 분석(go vet)이 포함되어 있습니다. 이런 도구들을 프로젝트 초기에 설정해두면 코드 리뷰에서 스타일 논쟁을 줄이고, 흔한 실수를 빌드 전에 잡을 수 있습니다.

이 글에서는 Go로 개발하는 프로젝트에서 기본적으로 갖추면 좋은 도구들과 설정 방법을 정리합니다.

1. gofmt: 코드 포맷팅

Go 커뮤니티에서는 gofmt 이 표준 포매터입니다. 탭/스페이스, 중괄호 위치 같은 스타일 논쟁 없이 모든 Go 코드가 동일한 포맷을 따르게 됩니다.

gofmt -w .

-w 플래그는 파일을 직접 수정합니다. -l 플래그를 쓰면 포맷이 맞지 않는 파일 목록만 출력합니다.

gofmt -l .

포맷이 맞지 않는 파일이 있으면 파일 경로가 출력되고, 모두 맞으면 아무것도 출력되지 않습니다. CI에서는 이 출력을 검사해서 포맷팅되지 않은 코드가 머지되는 것을 막을 수 있습니다.

2. goimports: import 정리

goimports는 `gofmt`의 기능에 더해 import 문을 자동으로 정리합니다.

  • 사용하지 않는 import를 제거

  • 필요한 import를 자동 추가

  • import를 표준 라이브러리 / 그 외 패키지 2그룹으로 정렬 (-local 플래그를 쓰면 로컬 패키지를 별도 그룹으로 분리 가능)

go install golang.org/x/tools/cmd/goimports@latest
goimports -w .

gofmt 대신 `goimports`만 실행해도 포맷팅까지 함께 처리됩니다.

3. go vet: 정적 분석

`go vet`은 Go 표준 도구 체인에 포함된 정적 분석 도구입니다. 컴파일러가 잡지 못하는 의심스러운 코드를 검출합니다.

go vet ./...

대표적으로 잡아주는 문제들은 다음과 같습니다.

  • fmt.Printf`의 포맷 문자열과 인자 불일치 (`printf)

  • 도달할 수 없는 코드 (unreachable)

  • sync.Mutex 등 잠금을 값으로 복사하는 코드 (copylocks)

  • context.WithCancel`의 cancel 함수를 호출하지 않는 경로 (`lostcancel)

별도 설치 없이 바로 사용할 수 있으므로, 모든 Go 프로젝트에서 기본으로 실행하는 것이 좋습니다.

4. golangci-lint: 통합 린터

golangci-lint는 여러 린터를 하나의 도구로 통합해서 실행해주는 린터 러너입니다. 개별 린터를 따로 설치하고 실행할 필요 없이, 설정 파일 하나로 원하는 린터를 선택해서 쓸 수 있습니다.

## 바이너리 설치 (go install은 공식적으로 권장되지 않음)
curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b $(go env GOPATH)/bin
golangci-lint run ./...

4.1. 설정 파일

프로젝트 루트에 .golangci.yml 파일을 만들어 설정합니다. 아래는 golangci-lint v2 기준 설정 예시입니다.

.golangci.yml
version: "2"

linters:
  default: standard
  enable:
    - misspell

formatters:
  enable:
    - gofmt
    - goimports

v2에서는 `version: "2"`를 반드시 명시해야 합니다. `default: standard`로 설정하면 errcheck, govet, ineffassign, staticcheck, unused 5개 린터가 기본으로 활성화됩니다. 추가로 필요한 린터만 `enable`에 넣으면 됩니다.

v2의 또 다른 변경점은 gofmt, goimports 같은 포매터가 linters`가 아닌 `formatters 섹션으로 분리된 것입니다. 기존 v1 설정 파일이 있다면 golangci-lint migrate 명령으로 자동 변환할 수 있습니다.

4.2. 기본 활성화 린터 (default: standard)

린터 설명

errcheck

에러 반환값을 무시하는 코드 검출

govet

`go vet`과 동일한 정적 분석

ineffassign

이후에 사용되지 않는 변수 할당 검출

staticcheck

Go 코드의 버그, 성능 문제, 스타일 문제를 종합적으로 검사 (gosimple, stylecheck 포함)

unused

사용되지 않는 변수, 함수, 타입 검출

이 중 `errcheck`은 특히 중요합니다. Go에서 에러를 반환값으로 처리하는 관례상, 에러를 무시하는 코드는 런타임에 예상치 못한 동작을 일으킬 수 있습니다.

5. Makefile로 통합

위 도구들을 개별로 실행하는 것은 번거로우므로, Makefile에 통합하면 편리합니다.

Makefile
.PHONY: fmt lint test check ci

fmt:
	goimports -w .

lint:
	golangci-lint run ./...

test:
	go test ./...

check: fmt lint test

ci: lint test

check`와 `ci 두 가지 타겟을 나눈 이유가 있습니다. fmt 타겟은 파일을 직접 수정하므로 로컬에서 커밋 전에 실행하기에 편리하지만, CI 환경에서는 파일을 수정하면 안 됩니다. ci 타겟은 `golangci-lint`의 gofmt, goimports 린터가 포맷 위반을 검출만 하고 파일을 수정하지 않으므로, CI에서 안전하게 쓸 수 있습니다.

  • 로컬: make check (포맷 자동 수정 + 린트 + 테스트)

  • CI: make ci (린트 + 테스트, 파일 수정 없음)

`go vet`을 별도 타겟으로 두지 않은 이유는, golangci-lint에 govet 린터가 포함되어 있어서 `make lint`에서 이미 실행되기 때문입니다.

코드 수정 후 커밋하기 전에 `make check`를 실행하는 습관을 들이면, CI에서 실패하는 상황을 줄일 수 있습니다.

6. 참고 자료

(이 글은 개인 라이선스로 구매한 Claude Code의 도움을 받아 작성되었습니다.)