코딩 복습용 블로그
안녕하세여! 코딩 복습용 블로그입니다.
전체 글 (421)
[LLM-응용-2] MCP 학습 정리: quote-cli로 배운 것
728x90

MCP 학습 정리: quote-cli로 배운 것

2026-11 · quote-cli 프로젝트에서 직접 만든 MCP 서버를 Claude Code에 연결하고, 장애를 고치고, 훅으로 감사 로그를 남기기까지의 기록


1. MCP란

MCP(Model Context Protocol) 는 AI 클라이언트(Claude Code 등)가 외부 프로그램(MCP 서버)의 기능을 표준 방식으로 쓰게 해 주는 프로토콜이다.

Claude Code (클라이언트)  ⇄  stdio(JSON-RPC)  ⇄  main.py (MCP 서버)  ⇄  quotes.json

서버가 클라이언트에 내놓는 것은 세 가지다.

종류 역할 누가 부르나 이 프로젝트의 예
Tool 동작을 실행 (읽기/쓰기) 모델이 스스로 판단해 호출 get_random_quote, add_quote
Resource 읽기 전용 데이터를 URI로 제공 사용자가 @서버:URI로 첨부 quote://all
Prompt 재사용할 프롬프트 템플릿 사용자가 슬래시 명령으로 호출 quote_post(topic)

2. 서버 만들기 (main.py)

Python SDK의 FastMCP를 쓰면 데코레이터만으로 서버를 만든다.

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("quote")                 # ① 서버 객체

@mcp.tool()                             # ② 도구: docstring이 곧 도구 설명
def get_random_quote() -> str: ...

@mcp.tool()                             # ③ 쓰기 도구
def add_quote(text: str, author: str) -> str: ...

@mcp.resource("quote://all")            # ④ 리소스
def all_quotes() -> str: ...

@mcp.prompt()                           # ⑤ 프롬프트
def quote_post(topic: str) -> str: ...

if __name__ == "__main__":
    mcp.run()                           # ⑥ 기본 전송은 stdio
  • 함수의 타입 힌트 → 입력 스키마, docstring → 도구 설명으로 자동 변환된다.
    예: add_quote → {"text": string, "author": string}, 설명 "새 명언을 명언 목록에 추가합니다."
  • 모델에게 노출될 때 도구 이름은 mcp__<서버명>__<도구명> 형태가 된다 → mcp__quote__add_quote.

3. Claude Code에 연결하기

.mcp.json (프로젝트 루트, 팀과 공유 가능)

{
  "mcpServers": {
    "quote": {
      "type": "stdio",
      "command": "uv",
      "args": ["run", "main.py"],
      "env": {}
    }
  }
}
  • stdio 방식: Claude Code가 이 명령으로 서버 프로세스를 직접 띄우고 표준입출력으로 통신한다.

.claude/settings.local.json (개인 설정)

{
  "enabledMcpjsonServers": ["quote"],
  "enableAllProjectMcpServers": true
}
  • 프로젝트 .mcp.json의 서버는 보안상 승인을 받아야 켜진다. 이 설정이 그 승인이다.
  • 연결 상태 확인과 재연결은 세션 안에서 /mcp로 한다.

사용법 정리

하고 싶은 일 방법
도구 사용 자연어로 요청 ("명언 하나 추가해") → 모델이 add_quote 호출
리소스 첨부 @quote:quote://all
프롬프트 실행 /mcp__quote__quote_post <주제>

4. 겪은 장애와 해결 (핵심 교훈)

장애 ① 서버가 연결되지 않음

증상 처음 짐작한 원인 진짜 원인
quote 서버 접속 실패 데이터 파일 이름 불일치 (question.json ↔ 코드는 quotes.json) mcp 패키지 2.x에는 FastMCP가 없음 → import 단계에서 서버가 즉시 종료

해결

  1. 데이터 파일 이름을 quotes.json으로 변경
  2. pyproject.toml의 의존성을 mcp>=2.3.0 → mcp[cli]>=1.2,<2 로 고정 후 재설치
  3. /mcp로 재연결

교훈

  • 서버 연결 실패는 클라이언트에서는 "끊김"으로만 보인다. uv run main.py를 직접 실행해 보는 것이 가장 빠른 진단이다.
  • 증상 하나에 원인이 여러 개일 수 있다. 첫 번째 원인만 고치고 멈추지 말 것.
  • SDK 메이저 버전이 바뀌면 API가 사라질 수 있으니 버전 상한을 고정한다.

장애 ② 서버가 꺼진 동안의 우회와 그 대가

서버가 죽어 있는 동안 명언 조회·추가를 파일 직접 읽기/쓰기로 우회했다. 결과는 같아 보였지만:

  • MCP 도구를 거치지 않았으므로 add_quote에 걸린 훅이 실행되지 않음 → 감사 로그 누락
  • 교훈: 훅/감사는 "도구 호출"에 걸린다. 같은 일을 다른 경로로 하면 통제가 우회된다.

장애 ③ 감사 로그가 생기지 않음

증상 원인
add_quote를 호출해도 .claude/mcp-audit.log가 생기지 않음 훅 스크립트가 쓰는 jq가 시스템에 없음 (exit 127)

해결

  1. winget install --id jqlang.jq -e (새 셸부터 PATH 적용)
  2. 지금 세션에서도 바로 쓰이도록 jq.exe를 ~/bin(Git Bash PATH에 포함)에 복사
  3. add_quote 실제 호출 → 로그 한 줄 기록 확인

자세한 기록은 jq 설치로 훅 복구 참고.


5. 훅(Hooks)으로 MCP 도구 통제하기

settings.local.json의 PreToolUse 훅은 도구 실행 직전에 스크립트를 돌린다. matcher에 MCP 도구 이름을 그대로 쓸 수 있다.

"hooks": {
  "PreToolUse": [
    { "matcher": "Bash",                  "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-dangerous.sh" }] },
    { "matcher": "mcp__quote__add_quote", "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/audit-mcp.sh" }] }
  ]
}

훅이 받는 입력 (stdin JSON)

{ "tool_name": "mcp__quote__add_quote", "tool_input": { "text": "...", "author": "..." } }

두 가지 활용 패턴

훅 목적 방식
audit-mcp.sh 기록: 쓰기 도구 호출 감사 시각·도구명·입력을 탭 구분으로 로그에 추가, exit 0 (통과)
block-dangerous.sh 차단: rm -rf 방지 패턴이 맞으면 permissionDecision: "deny" JSON을 출력

실제 남은 로그:

2026-11-08 18:55:52    mcp__quote__add_quote    {"text":"배움에는 왕도가 없다.","author":"유클리드"}

⚠️ 가장 중요한 교훈: 훅은 조용히 실패한다

  • jq가 없어서 두 훅 모두 오류로 끝났지만, PreToolUse 훅이 실패하면 차단하지 않고 그냥 통과된다.
  • 그래서 rm -rf 차단 훅도 보호하는 것처럼 보였지만 실제로는 아무것도 막지 못했다.
  • 대책: 훅을 만든 뒤에는 반드시 직접 입력을 흘려 넣어 테스트한다.
echo '{"tool_name":"t","tool_input":{"command":"rm -rf /x"}}' | bash .claude/hooks/block-dangerous.sh
# → deny JSON이 나와야 정상

6. 체크리스트

  • 서버를 uv run main.py로 단독 실행해 import 오류가 없는지 확인
  • SDK 버전 상한 고정 (mcp[cli]>=1.2,<2)
  • .mcp.json + enabledMcpjsonServers 승인
  • /mcp에서 연결 상태 확인
  • 훅 스크립트의 외부 의존성(jq 등) 설치 확인
  • 훅을 가짜 입력으로 직접 테스트
  • 서버가 죽었을 때 파일 직접 수정으로 우회하지 않기 (감사·통제 우회됨)
728x90
  Comments,     Trackbacks
[LLM-응용-1] Claude Code 스킬로 "이슈 → 커밋 → PR → 리뷰 → 리뷰 반영" 자동화해 보기
728x90

Claude Code 스킬로 "이슈 → 커밋 → PR → 리뷰 → 리뷰 반영" 자동화해 보기

버그 하나를 고치는 데 필요한 GitHub 작업을 Claude Code 프로젝트 스킬 여섯 개로 나눠 만들고, 실제 저장소에서 처음부터 끝까지 돌려 봤습니다. 만드는 법, 돌리면서 막힌 곳, 그리고 배운 점을 정리합니다.


들어가며: 왜 스킬인가

Claude Code에 "이슈 만들어 줘", "커밋해 줘", "PR 올려 줘"라고 말하면 대체로 잘 해 줍니다. 문제는 매번 결과가 조금씩 다르다는 점입니다.

  • 이번에는 제목에 [Bug]를 붙였는데 다음에는 빠집니다.
  • PR 본문에 관련 이슈 연결을 잊습니다.
  • 확인도 없이 push해 버립니다.

매번 프롬프트에 규칙을 길게 적을 수도 있지만, 그러면 사람이 같은 말을 반복해야 합니다. 스킬은 이 반복을 파일로 고정하는 방법입니다. 한 번 적어 두면 Claude가 상황에 맞을 때 스스로 꺼내 쓰고, /스킬이름으로 직접 부를 수도 있습니다.

이번 실습의 목표는 간단합니다.

작은 API 서버의 성능 버그 하나를 두고, 이슈 등록부터 리뷰 반영까지 전 과정을 스킬로 처리한다.

실습 대상: task-keeper

task-keeper는 할 일(Task)과 태그(Tag)를 다대다로 관리하는 작은 FastAPI 앱입니다. 여기에 일부러 심어 둔 버그가 하나 있습니다.

# app/services/task_service.py
def list_tasks(db: Session) -> list[Task]:
    tasks = db.query(Task).all()
    return tasks
# app/models.py
tags = relationship("Tag", secondary=task_tags, lazy="select")

GET /tasks는 할 일 목록을 한 번에 가져오지만, 응답을 JSON으로 만들 때 할 일마다 task.tags에 접근합니다. 관계가 lazy 로딩이라서 접근할 때마다 태그 조회 쿼리가 하나씩 더 나갑니다. 할 일이 N개면 쿼리는 1 + N번입니다. 전형적인 N+1 문제입니다.


1. 스킬의 기본 구조

스킬은 프로젝트의 .claude/skills/<스킬이름>/SKILL.md 파일 하나로 시작합니다.

.claude/skills/
└── create-issue/
    ├── SKILL.md                     # 무엇을, 어떤 순서로 할지
    └── resources/
        └── issue_templates.md       # 결과물의 모양 (템플릿)

SKILL.md의 맨 위에는 frontmatter가 들어갑니다.

---
name: create-issue
description: 깃헙 이슈를 생성합니다. 사용자가 버그 리포트, 기능 요청, 개선 사항 등을
  이슈로 등록하고 싶을 때 사용합니다. "이슈 만들어줘", "버그 리포트 작성해줘",
  "기능 요청 이슈 올려줘" 등의 요청에 반응합니다.
---

여기서 가장 중요한 것은 description 입니다. Claude는 이 설명을 보고 지금 이 스킬을 쓸지 판단합니다. 그래서 "무엇을 하는지"와 함께 "어떤 말을 들으면 반응하는지" 를 예시로 적어 두는 게 좋습니다.

본문에는 보통 아래 세 가지를 적습니다.

섹션 내용
Goal 이 스킬이 끝나면 무엇이 만들어지는가
Instructions 어떤 명령을 어떤 순서로 실행하는가
Constraints 하면 안 되는 것, 반드시 사람에게 물어야 하는 지점

이번에 만든 스킬 여섯 개

스킬 하는 일 템플릿
create-issue 요청을 분석해 [Bug]/[Feature]/[Improvement] 이슈 생성 issue_templates.md
git-commit <type>(<scope>): <subject> 형식의 한국어 커밋 —
create-pr 브랜치의 모든 커밋을 분석해 PR 생성, 이슈 연결 pr_template.md
review-pr 체크리스트로 PR 리뷰 (품질·보안·성능·테스트) review_checklist.md
apply-pr-review 리뷰 코멘트를 분류해 반영하고 코멘트마다 답글 reply_templates.md
commit-and-pr git-commit과 create-pr을 차례로 호출 두 스킬 것을 사용

create-issue와 git-commit은 미리 준비된 예제이고, 나머지 네 개는 이번에 직접 만들었습니다.


2. 스킬을 만들 때 정한 원칙 네 가지

만들다 보니 스킬마다 반복해서 고민하게 되는 지점이 있었습니다. 다음 네 가지로 정리했습니다.

① 절차와 형식을 분리한다

SKILL.md에는 절차만 적고, 결과물의 모양은 resources/의 템플릿으로 뺐습니다.

4. **템플릿 참조**: `resources/pr_template.md`에서 제목 형식과 본문 템플릿을 읽습니다.

PR 본문에 섹션을 하나 추가하고 싶으면 템플릿 파일만 고치면 됩니다. 절차는 건드리지 않아도 됩니다.

② 스킬끼리는 규칙을 복사하지 말고 호출한다

commit-and-pr을 만들 때, 커밋 규칙과 PR 규칙을 다시 적고 싶은 유혹이 있었습니다. 하지만 그러면 규칙이 두 곳에 생기고, 한쪽만 고치는 순간 어긋납니다. 그래서 이 스킬은 두 스킬을 부르기만 하도록 만들었습니다.

4. **커밋**: `git-commit` 스킬을 호출하여 그 절차대로 커밋합니다.
5. **PR 생성**: 이어서 `create-pr` 스킬을 호출하여 그 절차대로 push하고 PR을 만듭니다.

③ 되돌리기 어려운 일 앞에서 멈춘다

이슈 생성, PR 생성, GitHub에 리뷰 게시, push는 지워도 알림과 기록이 남습니다. 이런 단계 앞에는 반드시 사람의 승인을 받도록 적었습니다.

## Constraints
- 사용자 승인 없이 PR을 생성하지 않습니다.
- 기본 브랜치(`main`)에 직접 push하지 않습니다.
- `git push --force`는 사용자가 명시적으로 요청한 경우에만 사용합니다.

반대로 로컬 파일을 읽거나 분석하는 단계에는 승인을 두지 않았습니다. 모든 단계에서 물어보면 흐름이 끊기기 때문입니다.

④ 확인하지 못한 것을 적을 자리를 만든다

에이전트가 가장 위험할 때는 확인하지 않은 것을 확인한 것처럼 말할 때입니다. 그래서 템플릿 곳곳에 "모르면 모른다고 쓰는 자리"를 만들었습니다.

  • 이슈 템플릿: 모르는 정보는 TODO: 추가 정보 필요
  • PR 템플릿: 테스트 항목은 체크박스. 실행하지 않았으면 체크하지 않음
  • 리뷰: 추측과 확인한 사실을 구분하고, 확인 못 한 것은 "확인 필요"
  • 리뷰 반영: "실행하지 않은 테스트를 통과했다고 쓰지 않는다"

이 원칙은 실습 중에 실제로 크게 도움이 되었습니다. 뒤에서 다시 나옵니다.


3. 실제로 돌려 보기

Step 1. /create-issue: 한 줄짜리 요청을 이슈로

/create-issue 할 일 목록 조회가 느림. 할 일이 많을 때 GET /tasks 응답이 오래 걸림

흥미로웠던 점은, Claude가 이 문장만으로 이슈를 쓰지 않았다는 것입니다. 먼저 GET /tasks 코드를 따라가 읽고, list_tasks()와 lazy="select"에서 N+1 원인을 찾은 뒤 이슈를 썼습니다.

생성된 이슈는 다음과 같습니다.

  • 제목: [Bug] 할 일이 많을 때 GET /tasks 응답이 느림 (태그 N+1 쿼리)
  • 본문: 원인, 재현 방법(SQLALCHEMY_ECHO=1로 쿼리 로그 보기), 기대 동작과 실제 동작, 수정 방향
  • 측정값처럼 모르는 정보는 TODO: 추가 정보 필요로 남김

막힌 곳

문제 해결
needs-triage 라벨이 저장소에 없음 스킬에 적힌 대로 gh label create로 먼저 생성
the repository has disabled issues 저장소 설정을 바꾸는 일이라 Claude가 멈추고 물어봄 → 승인 후 gh repo edit --enable-issues

두 번째 경우는 스킬에 적혀 있지 않은 상황이었습니다. 그런데도 "되돌리기 어려운 일은 묻는다"는 원칙대로 멈춘 것이 좋았습니다.

Step 2. /git-commit: 성격별로 커밋 나누기

스킬 파일과 문서를 커밋할 때, Claude는 이를 chore(skills)와 docs(wiki) 두 커밋으로 나눴습니다.

여기서 Author identity unknown 오류가 났습니다. 새 PC라 git 사용자 정보가 설정되어 있지 않았던 것입니다. 전역 설정을 함부로 바꾸지 않고, 해당 커밋에만 작성자를 지정했습니다.

git -c user.name=<이름> -c user.email=<이메일> commit -m "..."

Step 3. /create-pr: 요청과 실제 상태가 다를 때

/create-pr 방금 커밋한 n+1 수정을 pr로 올려라

이번 실습에서 가장 인상적인 장면이었습니다. create-pr 스킬에는 이런 단계가 있습니다.

2. **변경사항 분석**: base 브랜치 대비 이 브랜치에 들어간 내용을 모두 읽습니다.
   - 최신 커밋 하나만 보지 말고, 브랜치의 **모든 커밋**을 기준으로 작성합니다.

이 단계를 실행해 보니, 브랜치에 N+1 수정 커밋이 아예 없었습니다. 앞에서 커밋한 것은 스킬 파일과 문서뿐이었습니다. 요청을 그대로 따랐다면 버그 수정이 없는 "fix" PR이 올라갔을 것입니다.

Claude는 이 사실을 먼저 알리고, 수정을 만든 뒤 PR을 올렸습니다.

from sqlalchemy.orm import Session, selectinload

def list_tasks(db: Session) -> list[Task]:
    # 태그를 IN 쿼리 한 번으로 미리 불러와 N+1을 막습니다.
    return db.query(Task).options(selectinload(Task.tags)).all()

selectinload는 할 일 목록을 가져온 뒤, 그 할 일들의 태그를 WHERE task_id IN (...) 쿼리 한 번으로 몰아서 가져옵니다. 할 일이 몇 개든 쿼리는 2번으로 고정됩니다.

회귀 테스트도 하나 추가했습니다. SQLAlchemy 이벤트로 실제 실행된 쿼리 수를 세는 방식입니다.

def test_list_tasks_query_count_is_constant(client, db_session):
    for i in range(5):
        client.post("/tasks", json={"title": f"할 일 {i}", "tags": [f"태그{i}"]})

    queries = []
    engine = db_session.get_bind()
    listener = lambda *args: queries.append(args[2])
    event.listen(engine, "before_cursor_execute", listener)
    try:
        res = client.get("/tasks")
    finally:
        event.remove(engine, "before_cursor_execute", listener)

    assert len(queries) == 2  # 할 일 1회 + 태그 1회

그런데 테스트가 안 돌았습니다

uv run pytest를 실행하자마자 pydantic 내부에서 AssertionError가 났습니다. 원인은 로컬 Python이 3.14.0rc2, 즉 정식 출시 전 버전이었기 때문입니다. pydantic이 쓰는 typing 내부 API가 rc 버전과 맞지 않았습니다. 수정 전 코드에서도 똑같이 실패했으니 이번 변경과는 관계가 없었습니다.

정식판 Python을 받으려 했지만 실습 환경에서 다운로드가 막혀 있었습니다. 그래서 pydantic을 거치지 않고 SQLAlchemy만으로 쿼리 수를 직접 세 봤습니다.

할 일 50개일 때 쿼리 수
수정 전 51회
수정 후 2회

그리고 pytest를 돌리지 못했다는 사실은 PR 본문의 테스트 항목에 체크하지 않은 채로 남겼습니다. 원칙 ④가 쓰인 순간입니다.

## 테스트
- [x] SQLAlchemy로 쿼리 수 측정: 할 일 50개 기준 51회 → 2회
- [ ] `uv run pytest` — 로컬 Python 3.14.0rc2에서 pydantic 임포트 실패로 미실행

PR 본문에 Closes #1을 넣어 이슈와 연결했습니다. 이렇게 하면 PR이 머지될 때 이슈가 자동으로 닫힙니다.

Step 4. /review-pr: 체크리스트로 리뷰하기

review-pr 스킬은 gh pr view --json으로 메타데이터를, gh pr diff로 변경 내용을 읽은 뒤 체크리스트를 하나씩 적용합니다. 체크리스트 일부를 소개하면 다음과 같습니다.

## 3. 성능
- [ ] N+1 쿼리가 없는가 (관계 접근 시 `selectinload`, `joinedload` 사용)
- [ ] 반복문 안에서 DB 쿼리나 네트워크 호출을 하지 않는가
- [ ] 목록 조회에 페이지네이션이나 개수 제한이 필요하지 않은가

## 4. 테스트
- [ ] 버그 수정이라면, 수정 전 코드에서 실패하는 테스트인가

지적 사항은 심각도로 나눕니다.

  • 🔴 Blocker: 머지 전에 반드시 수정 (버그, 보안 취약점)
  • 🟡 Major: 강하게 권장 (성능, 테스트 누락)
  • 🟢 Minor: 선택 (가독성, 스타일)

방금 만든 PR을 리뷰해 보니, Blocker는 없었지만 Major가 세 개 나왔습니다.

심각도 지적
🟡 새 테스트를 한 번도 실행하지 못했다
🟡 테스트가 같은 DB 세션을 공유해서, 수정 전 코드로 돌려도 1+N이 아니라 1+(N-1)로 셀 수 있다 → 측정 직전에 db_session.expire_all() 필요
🟡 PR에 버그 수정과 관계없는 커밋(스킬, 문서)이 섞여 있다

두 번째 지적이 특히 좋았습니다. 테스트가 통과하는지만 볼 게 아니라, "이 테스트가 정말 버그를 잡아내는가" 를 따진 것입니다. 앞선 POST 요청에서 마지막 할 일의 태그가 세션에 이미 올라와 있으므로, 측정값이 버그 모양과 정확히 맞지 않는다는 분석입니다.

참고로 자기 PR에는 GitHub에서 Approve나 Request changes를 할 수 없습니다. 그래서 스킬에 --comment로 남기라고 적어 두었습니다.

Step 5. /apply-pr-review: 리뷰를 무조건 따르지 않기

리뷰를 반영하는 스킬에서 가장 신경 쓴 부분은 이것입니다.

리뷰어 의견을 무조건 따르지 않습니다. 지적이 맞는지 코드를 직접 읽고 확인합니다.

지적마다 네 가지 중 하나로 분류합니다.

분류 의미 답글
✅ 반영 타당하고 이 PR 범위 안 "반영했습니다. (커밋 해시)"
💬 질문 답변 코드 변경 없이 설명 필요 답변
🙅 반영 안 함 근거가 틀렸거나 범위 밖 이유와 대안
⏸️ 보류 사람이 결정해야 함 결정 후 다시 답변

GitHub의 리뷰 코멘트는 종류가 세 가지라서 가져오는 방법도 다릅니다. 이 부분은 미리 알아 두면 좋습니다.

종류 가져오기 답하기
코드 줄에 단 인라인 코멘트 GraphQL reviewThreads (해결 여부 포함) gh api .../pulls/<n>/comments/<id>/replies
리뷰 본문 gh pr view --json reviews 답글 기능 없음 → gh pr comment로 종합
일반 PR 코멘트 gh pr view --json comments 답글 기능 없음 → gh pr comment로 종합

실제로 돌려 보니 PR에 코멘트가 하나도 없었습니다. 앞 단계의 리뷰가 로컬 파일로만 있었기 때문입니다. 스킬은 그대로 끝내지 않고 "리뷰를 먼저 게시할까요?"라고 물었고, 게시한 뒤 지적 7건을 분류했습니다.

  • ✅ 반영 4건: expire_all() 추가, import 위치 정리, README와 주석 갱신
  • 💬 답변 1건: 테스트 미실행. 이 환경에서는 해결할 수 없음
  • 🙅 반영 안 함 2건: PR 분리(force push 필요), 페이지네이션(이번 PR 범위 밖)

PR 분리를 "반영 안 함"으로 둔 이유가 좋았습니다. 리뷰 지적은 맞지만, 고치려면 브랜치 이력을 다시 쓰고 force push를 해야 합니다. 스킬의 제약("force push는 명시적 요청이 있을 때만")과 부딪히므로, 사람에게 판단을 넘긴 것입니다.


4. 번외: Claude Code가 아예 안 켜졌던 이야기

사실 이 실습은 Claude Code가 실행되지 않는 문제로 시작했습니다. Windows에서 npm으로 Claude Code를 쓴다면 겪을 수 있는 일이라 함께 적어 둡니다.

$ claude
/c/Users/admin/.npm-global/claude: line 12: .../claude-code/bin/claude.exe: No such file or directory

npm uninstall -g, npm install -g로 다시 설치해도 그대로였습니다.

원인: claude가 두 군데 설치되어 있었다

위치 버전 상태
~/.npm-global (예전 npm 설치 위치) 2.1.291 망가짐. 셸이 PATH에서 이걸 먼저 찾음
C:\Program Files\nodejs (nvm, 현재 npm 설치 위치) 2.1.319 정상

망가지는 과정은 이랬습니다.

  1. ~/.npm-global의 claude가 자동 업데이트를 시작합니다.
  2. Windows는 실행 중인 exe를 덮어쓸 수 없어서, 업데이터가 기존 파일 이름을 claude.exe.old.*로 바꿔 둡니다.
  3. 새 버전은 npm install -g로 설치되는데, 이 명령은 현재 npm 설정의 위치(nvm 쪽) 에 설치합니다.
  4. 결국 ~/.npm-global에는 .old 파일만 남고, 셸은 계속 거기서 claude를 찾다가 실패합니다.

사용자가 직접 한 재설치도 모두 nvm 쪽에만 설치되었기 때문에, 망가진 쪽은 한 번도 고쳐지지 않았던 것입니다.

헛짚었던 것들

  • 처음에는 npm의 ignore-scripts 설정 때문에 설치 스크립트가 안 돈 줄 알았습니다. 확인해 보니 처음부터 false였습니다.
  • exe를 수동으로 다시 만드는 임시 복구를 했지만, 16분 뒤 자동 업데이트가 돌면서 똑같이 다시 망가졌습니다.

로그를 끝까지 읽고 나서야 "설치 위치가 두 군데"라는 근본 원인이 보였습니다.

해결

오래된 설치본을 지우는 대신 백업 폴더로 옮겼습니다. 혹시 문제가 생기면 되돌릴 수 있게 하기 위해서입니다.

mkdir -p ~/.npm-global/_claude-backup
mv ~/.npm-global/node_modules/@anthropic-ai/claude-code \
   ~/.npm-global/claude ~/.npm-global/claude.cmd ~/.npm-global/claude.ps1 \
   ~/.npm-global/_claude-backup/
hash -r
$ which -a claude
/c/Program Files/nodejs/claude
$ claude --version
2.1.319 (Claude Code)

💡 hash -r은 Git Bash(bash)용 명령입니다. bash는 한 번 찾은 실행 파일의 경로를 기억해 두기 때문에, 파일을 옮긴 뒤에는 이 명령으로 기억을 지워야 합니다. PowerShell에는 hash 명령이 없고 경로를 기억해 두지도 않으므로 필요 없습니다. 창을 새로 열어도 됩니다.

비슷한 증상을 겪는다면 which -a claude(bash) 또는 where.exe claude(PowerShell)로 claude가 몇 군데 있는지부터 확인해 보세요.


5. 배운 점

스킬은 "요청"보다 "실제 상태"를 먼저 확인하게 만들어야 한다

이번 실습에서 요청에 담긴 전제가 틀린 경우가 세 번 있었습니다.

  • "방금 커밋한 수정" → 브랜치에 수정 커밋이 없었음
  • "리뷰 반영해 줘" → PR에 리뷰 코멘트가 없었음
  • "재설치했는데 안 됨" → 재설치가 엉뚱한 위치에 되고 있었음

세 경우 모두 상태를 먼저 확인하는 단계 덕분에 잡아냈습니다. 스킬의 첫 단계는 거의 항상 "현재 상태 확인"이어야 한다고 느꼈습니다.

스킬이 모든 예외를 다룰 수는 없다. 대신 "멈추고 알린다"를 기본으로

Issues 기능 꺼짐, git 사용자 정보 없음, rc 버전 Python은 모두 스킬에 적혀 있지 않은 상황이었습니다. 이런 일을 하나하나 스킬에 적을 수는 없습니다. 대신 "되돌리기 어려운 일 앞에서는 멈춘다" 는 원칙을 Constraints에 넣어 두니, 처음 보는 상황에서도 안전하게 행동했습니다.

검증하지 못한 것을 기록할 자리가 신뢰를 만든다

pytest를 돌리지 못한 사실이 PR 본문, 리뷰, 리뷰 반영 계획에 일관되게 남았습니다. 템플릿에 체크박스와 "확인 필요" 자리를 만들어 둔 덕분입니다. 이게 없었다면 테스트가 통과한 것처럼 보였을 것입니다.

에이전트에게 다른 저장소에 올리라고 할 때는 경로를 명확히

실습 결과를 별도 저장소에 올리는 과정에서, "올려 줘"라고만 했더니 Claude가 폴더 위치를 추측해서 두 번이나 엉뚱한 곳에 올렸습니다. 사람에게는 당연한 "기존 폴더들과 같은 위치"가 에이전트에게는 당연하지 않았습니다. 대상 경로는 처음부터 명시하거나, 올리기 전에 확인하게 하는 게 좋습니다.

스킬 조합은 복사가 아니라 호출로

commit-and-pr을 두 스킬의 호출로만 만드니, 커밋 규칙을 바꿀 때 git-commit 하나만 고치면 됩니다. 코드에서 함수를 재사용하는 것과 같은 원리입니다.


마치며

단계 결과
이슈 task-keeper#1
수정 selectinload 한 줄, 쿼리 51회 → 2회
PR task-keeper#2, 이슈 자동 연결
리뷰 Blocker 0 / Major 3 / Minor 4
리뷰 반영 7건 분류 (반영 4, 답변 1, 반영 안 함 2)

스킬을 만들기 전에는 "Claude에게 일을 시킨다"고 생각했다면, 만들고 나서는 "Claude가 일하는 방식을 설계한다" 는 느낌에 가까웠습니다. 무엇을 할지보다 어디서 멈출지, 무엇을 기록할지 를 정하는 데 더 많은 시간을 썼고, 실제로 그 부분이 가장 큰 차이를 만들었습니다.

아직 남은 것

  • 정식판 Python에서 pytest 실행해 보기
  • 리뷰 반영을 끝까지 실행하고, 인라인 코멘트 답글·스레드 해결 경로 검증하기
  • commit-and-pr 실제로 돌려 보기

전체 스킬 파일은 Harness_Engineering/skills-example/task-keeper에서 볼 수 있습니다.

728x90
  Comments,     Trackbacks
[TIL]폐쇄망 K8S 이미지 패치 정리-③ 이미지 교체·재배포 검증 및 Rollback
728x90

개요

앞선 글에서 아래 과정을 정리했습니다.

글 내용
정리-1 1. 보안 원격접속(SSH) 및 작업 노드 사전 점검 · 2. SFTP 기반 이미지 아티팩트 반입
정리-2 3. Deployment 매니페스트 백업 · 4. 이미지 적재·재태깅

이 글은 마지막 두 과정을 정리합니다.

  1. 이미지 교체 및 Rolling 재배포 검증
  2. 매니페스트 기반 Rollback

아래 이름은 모두 예시입니다. 실제 값은 승인된 작업 계획서에서 확인하세요.

항목 예시 값
Namespace cmp
Deployment sample-api, sample-web
새 이미지 (정리-2에서 push) registry.example.local:50000/myproject/<이미지>:<새 tag>
백업 파일 (정리-2에서 생성) sample-api-bak.yaml, sample-web-bak.yaml

5. 이미지 교체 및 Rolling 재배포 검증

개념

Deployment의 spec.template.spec.containers[].image 값을 바꾸면 그 자체로 Rolling Update가 시작됩니다. K8S가 새 이미지로 Pod를 하나씩 띄우고, 새 Pod가 Ready가 되면 기존 Pod를 하나씩 내립니다. 그래서 서비스를 멈추지 않고 이미지를 바꿀 수 있습니다.

kubectl rollout restart는 이미지 변경 없이 Pod만 새로 띄울 때 쓰는 명령입니다. 이미지를 바꾼 뒤에는 따로 실행하지 않아도 됩니다. 실행하면 재배포가 한 번 더 일어날 뿐 해는 없지만, Rollback 리비전이 하나 늘어 6번에서 헷갈릴 수 있습니다.

실제 작업 흐름

순서 행동 확인할 결과
1 Deployment의 image를 새 tag로 변경 edited 또는 image updated
2 롤아웃 완료 대기 successfully rolled out
3 Pod 상태·이미지 확인 새 Pod Running, READY 가득 참, image가 새 tag
4 애플리케이션 로그·서비스 확인 오류 로그 없음, 기능 정상
5 이상 시 6번 진행 -

목적: 대상 Deployment를 새 이미지로 교체하고 정상 기동을 확인합니다.
완료 기준: 롤아웃이 완료되고, 새 Pod가 모두 Ready이며 재시작이 늘지 않고, 서비스 확인까지 끝나면 패치 완료입니다.

5.1 이미지 교체

방법 A — kubectl edit

kubectl edit -n cmp deployment.apps/sample-api
kubectl edit -n cmp deployment.apps/sample-web

편집기에서 image: 줄의 태그만 <새 tag>로 바꾸고 저장·종료합니다. 다른 줄은 건드리지 않습니다.

방법 B — kubectl set image (오타 위험이 적음)

# 컨테이너 이름은 백업 파일의 containers[].name 값
kubectl set image -n cmp deployment/sample-api \
  <컨테이너명>=registry.example.local:50000/myproject/sample-api:<새 tag>
kubectl set image -n cmp deployment/sample-web \
  <컨테이너명>=registry.example.local:50000/myproject/sample-web:<새 tag>

(선택) 이미지 변경 없이 재기동만 필요할 때:

kubectl rollout restart -n cmp deployment.apps/sample-api
kubectl rollout restart -n cmp deployment.apps/sample-web

5.2 롤아웃 검증

kubectl rollout status -n cmp deployment/sample-api --timeout=300s
kubectl rollout status -n cmp deployment/sample-web --timeout=300s
kubectl get pod -n cmp -o wide -w          # Ctrl+C로 종료
kubectl get deploy -n cmp sample-api sample-web \
  -o custom-columns=NAME:.metadata.name,IMAGE:.spec.template.spec.containers[*].image
kubectl logs -n cmp deploy/sample-api --tail=100
kubectl logs -n cmp deploy/sample-web --tail=100

watch -n 0.1 kubectl get pod ...처럼 0.1초 간격으로 조회하면 API 서버에 부담이 됩니다. -w(변경 시에만 출력)나 watch -n 2를 씁니다.

5.3 상태별 판단

Pod 상태 의미 조치
ContainerCreating → Running 정상 진행 대기
ImagePullBackOff / ErrImagePull 레지스트리 주소·태그 오류 또는 push 누락 kubectl describe pod의 Events 확인 → 정리-2의 4번 재확인
CrashLoopBackOff 새 이미지가 기동 직후 종료 kubectl logs --previous로 원인 확인 → 6번
Running인데 READY 0/1 readiness probe 실패 로그·설정 확인, 지속되면 6번
롤아웃이 --timeout 안에 안 끝남 새 Pod가 Ready가 되지 못함 위 상태 중 무엇인지 확인 → 6번

Rolling Update 중에는 기존 Pod가 남아 있으므로, 새 Pod가 실패해도 서비스는 기존 Pod로 계속 응답합니다. 서두르지 말고 원인을 확인한 뒤 되돌립니다.


6. 매니페스트 기반 Rollback

개념

새 이미지에 문제가 있으면 기존 이미지로 되돌립니다. 방법은 두 가지입니다.

방법 장점 주의
A. 백업 YAML 적용 정리-2의 3번에서 저장한 정의 그대로 복구 백업의 resourceVersion 때문에 충돌로 거부될 수 있음 → 6.1
B. kubectl rollout undo 명령 한 줄로 직전 ReplicaSet 복구 5번에서 edit와 restart를 둘 다 했다면 "직전"이 restart 시점일 수 있음 → rollout history로 리비전 지정

실제 작업 흐름

순서 행동 확인할 결과
1 Rollback 결정 (담당자 승인) -
2 백업 YAML 적용 또는 rollout undo configured / rolled back
3 롤아웃 완료 대기 successfully rolled out
4 Pod 상태·이미지 확인 image가 <기존 tag>, Pod 정상

목적: 패치 실패 시 정리-2에서 기록한 정상 상태로 되돌립니다.
완료 기준: Deployment 이미지가 기존 태그로 돌아오고, Pod 상태가 패치 전 기록과 같아지면 복구 완료입니다.

6.1 방법 A — 백업 YAML 적용

kubectl apply -n cmp -f sample-api-bak.yaml
kubectl apply -n cmp -f sample-web-bak.yaml

the object has been modified; please apply your changes to the latest version 오류가 나면 백업 파일의 resourceVersion 줄을 지우고 다시 적용합니다.

sed -i '/^  resourceVersion:/d' sample-api-bak.yaml sample-web-bak.yaml
kubectl apply -n cmp -f sample-api-bak.yaml
kubectl apply -n cmp -f sample-web-bak.yaml

6.2 방법 B — rollout undo

kubectl rollout history -n cmp deployment/sample-api
kubectl rollout undo -n cmp deployment/sample-api                       # 직전으로
kubectl rollout undo -n cmp deployment/sample-api --to-revision=<번호>   # 지정 리비전으로
# sample-web도 동일

rollout history --revision=<번호>로 각 리비전의 이미지를 확인한 뒤 고르면 안전합니다.

6.3 복구 확인

kubectl rollout status -n cmp deployment/sample-api --timeout=300s
kubectl rollout status -n cmp deployment/sample-web --timeout=300s
kubectl get pod -n cmp -o wide
kubectl get deploy -n cmp sample-api sample-web \
  -o custom-columns=NAME:.metadata.name,IMAGE:.spec.template.spec.containers[*].image

image가 정리-2에서 기록한 <기존 tag>이고, Pod 수·상태가 패치 전과 같으면 복구 완료입니다.


전체 작업 체크리스트 (정리-1 ~ 3)

# 과정 확인
1 보안 원격접속(SSH) 및 작업 노드 사전 점검 SSH 접속, 점검 10항목 정상
2 SFTP 기반 이미지 아티팩트 반입 업로드, 서버 해시 = 원본 해시
3 Deployment 매니페스트 백업 상태 기록, 백업 YAML의 image = 기존 tag
4 이미지 적재·재태깅 load → tag → push → pull 오류 없음
5 이미지 교체 및 Rolling 재배포 검증 rollout 완료, Pod Ready, 로그·서비스 정상
6 (실패 시) 매니페스트 기반 Rollback image = 기존 tag, Pod 상태 원복

참고

728x90
  Comments,     Trackbacks
[TIL]폐쇄망 K8S 이미지 패치 정리-② 매니페스트 백업 및 이미지 적재·재태깅
728x90

개요

정리-1에서 아래 두 과정을 정리했습니다.

  1. 보안 원격접속(SSH) 및 작업 노드 사전 점검
  2. SFTP 기반 이미지 아티팩트 반입

이 글은 이어지는 두 과정을 정리합니다.

  1. Deployment 매니페스트 백업
  2. 이미지 적재·재태깅

아래 이름은 모두 예시입니다. 실제 값은 승인된 작업 계획서에서 확인하세요.

항목 예시 값
Namespace cmp
Deployment sample-api, sample-web
반입한 tar sample-api.tar, sample-web.tar
tar 안의 이미지 이름 registry.example.local:55000/myproject/<이미지>:<기존 tag>
클러스터가 pull하는 레지스트리 registry.example.local:50000/myproject/<이미지>:<새 tag>

3. Deployment 매니페스트 백업

개념

Deployment는 "이 이미지로 Pod를 N개 유지하라"는 선언입니다. 이미지를 바꾸면 K8S가 새 Pod를 하나씩 띄우고 기존 Pod를 하나씩 내리는 Rolling Update를 수행합니다.

이미지를 바꾸기 전에 두 가지를 남깁니다.

  • 현재 상태 기록: Pod 수, 상태, 재시작 횟수, 배치 노드. 패치 전부터 비정상이었다면 패치 탓과 구분할 수 없으므로 먼저 확인합니다.
  • 매니페스트 백업: 현재 Deployment 정의를 YAML 파일로 저장합니다. 새 이미지에 문제가 생겼을 때 되돌릴 원본입니다.

실제 작업 흐름

순서 행동 확인할 결과
1 Namespace 전체 Pod 상태 확인 대상 Pod 모두 Running, READY 가득 참
2 대상 Deployment·ReplicaSet·Pod 필터링 대상 리소스 확인
3 현재 이미지 태그 기록 기존 tag 확보 (4번에서 사용)
4 Deployment YAML 백업 *-bak.yaml 파일 생성
5 백업 파일 확인 image:에 기존 태그 표시

목적: 패치 전 정상 상태를 기록하고 원복할 수 있는 매니페스트를 확보합니다.
완료 기준: 대상 Pod가 정상이고, 백업 파일 두 개의 image:가 기존 태그와 같으면 4번으로 넘어갑니다.

3.1 현재 상태 확인

kubectl get pod -n cmp -o wide
kubectl get all -n cmp | grep -iE 'sample-api|sample-web'
  • STATUS가 Running, READY가 1/1처럼 가득 차 있는지 봅니다.
  • 이미 CrashLoopBackOff이거나 RESTARTS가 늘고 있다면 진행하지 말고 담당자에게 확인합니다.

3.2 현재 이미지 태그 기록

kubectl get deploy -n cmp sample-api sample-web \
  -o custom-columns=NAME:.metadata.name,IMAGE:.spec.template.spec.containers[*].image

출력된 <기존 tag>와 레지스트리 주소를 작업 기록에 적어 둡니다. 4.2 재태깅에서 대상 주소를 맞출 때 씁니다.

3.3 매니페스트 백업

kubectl get -n cmp deployment.apps/sample-api -o yaml > sample-api-bak.yaml
kubectl get -n cmp deployment.apps/sample-web -o yaml > sample-web-bak.yaml

3.4 백업 확인

ls -l *-bak.yaml
grep 'image:' *-bak.yaml

파일이 비어 있지 않고 image:에 3.2에서 기록한 태그가 보이면 백업 완료입니다.

3.5 백업 파일을 쓸 때 주의

나중에 kubectl apply -n cmp -f sample-api-bak.yaml로 되돌릴 때 the object has been modified 오류가 날 수 있습니다. kubectl get -o yaml로 받은 파일에는 저장 당시의 resourceVersion이 들어 있어, 그사이 Deployment가 바뀌면 충돌로 거부되기 때문입니다. 이때는 그 줄을 지우고 다시 적용합니다.

sed -i '/^  resourceVersion:/d' sample-api-bak.yaml sample-web-bak.yaml

4. 이미지 적재·재태깅

개념

폐쇄망 클러스터의 노드는 사내 레지스트리에서 이미지를 pull합니다. 작업 노드에 tar만 올려 두면 다른 노드는 그 이미지를 볼 수 없으므로, 적재 후 레지스트리에 올려야 합니다.

명령 하는 일
nerdctl load tar를 풀어 작업 노드의 로컬 이미지로 등록
nerdctl tag 같은 이미지에 클러스터가 pull할 주소·새 태그로 이름을 하나 더 붙임 (복사 아님)
nerdctl push 그 이름으로 레지스트리에 업로드
nerdctl pull 레지스트리에서 다시 받아 업로드가 정상인지 확인

왜 재태깅하는가: tar 안에는 빌드할 때 붙인 이름(예: :55000/...)이 그대로 들어 있습니다. 클러스터는 다른 주소(예: :50000/...)에서 이미지를 받으므로, 그 주소로 이름을 다시 붙여 올려야 합니다.

왜 새 태그인가: 기존 태그를 덮어쓰면 노드에 남은 캐시 때문에 일부 Pod가 옛 이미지로 뜰 수 있고, 되돌릴 때 어느 것이 옛 이미지인지 구분할 수 없습니다.

실제 작업 흐름

순서 행동 확인할 결과
1 tar 적재 Loaded image: ... 출력
2 적재된 이미지 이름·태그 확인 :55000/...:<기존 tag> 표시
3 레지스트리 주소·새 태그로 tag :50000/...:<새 tag> 추가 표시
4 대상 주소 대조 3.2에서 기록한 Deployment 주소와 일치
5 레지스트리에 push 오류 없이 종료
6 레지스트리에서 pull 오류 없이 종료

목적: 반입한 이미지를 클러스터 전 노드가 받을 수 있는 레지스트리에 새 태그로 올립니다.
완료 기준: push·pull이 오류 없이 끝나면 이미지 교체(재배포)를 진행할 준비가 된 것입니다.

4.1 이미지 적재

nerdctl load -i sample-api.tar
nerdctl load -i sample-web.tar
nerdctl images | grep -iE 'sample-api|sample-web'

Loaded image: 줄에 나온 이름이 다음 tag 명령의 원본 이름입니다. 작업 계획서와 다르면 출력된 이름을 씁니다.

4.2 재태깅

nerdctl tag registry.example.local:55000/myproject/sample-api:<기존 tag> \
            registry.example.local:50000/myproject/sample-api:<새 tag>
nerdctl tag registry.example.local:55000/myproject/sample-web:<기존 tag> \
            registry.example.local:50000/myproject/sample-web:<새 tag>
nerdctl images | grep -iE 'sample-api|sample-web'

⚠️ 대상 주소는 3.2에서 기록한 Deployment의 현재 image: 레지스트리 주소와 같아야 합니다. 다르면 이후 재배포 때 Pod가 ImagePullBackOff에 빠집니다.

4.3 레지스트리 업로드 및 확인

nerdctl push registry.example.local:50000/myproject/sample-api:<새 tag>
nerdctl push registry.example.local:50000/myproject/sample-web:<새 tag>

nerdctl pull registry.example.local:50000/myproject/sample-api:<새 tag>
nerdctl pull registry.example.local:50000/myproject/sample-web:<새 tag>

4.4 오류가 날 때

증상 확인할 것
server gave HTTP response to HTTPS client HTTP 레지스트리입니다. 운영 절차에 따라 --insecure-registry 사용 여부를 확인합니다.
x509: certificate signed by unknown authority 사내 CA 인증서가 노드에 등록되어 있는지 확인합니다. 임의로 검증을 끄지 않습니다.
unauthorized / denied nerdctl login 상태와 push 권한을 확인합니다.
no space left on device df -h로 확인합니다. 정리-1의 "tar 크기 2배" 기준을 다시 봅니다.

다음 글

적재·재태깅까지 끝나면 Deployment의 이미지를 새 태그로 바꾸고(kubectl edit / kubectl set image), kubectl rollout status로 재배포를 검증합니다. 실패하면 3번에서 만든 백업 YAML로 되돌립니다. 이 과정은 다음 글에서 정리합니다.

참고

728x90
  Comments,     Trackbacks
인프라 개념 정리: 보안과 자동화 정리
728x90

[TIL] 보안과 자동화 정리

인프라 보안과 IaC를 정리하며

개발하다 보면 서버가 느려지거나, 외부에서 비정상적인 요청이 들어오거나, 설정을 바꾼 뒤 서비스가 달라지는 상황을 마주한다. 이때 인프라 보안과 운영 자동화의 기본 개념을 알고 있으면 문제의 원인을 더 차분하게 좁힐 수 있다.

이번 글에서는 인프라에서 자주 만나는 보안 사고와 취약점 대응 방법을 먼저 정리하고, 이어서 인프라를 코드로 관리하는 IaC의 계층과 운영 원칙을 살펴본다.

1. 보안 사고를 보는 기본 관점

보안 사고는 한 가지 기술 결함만으로 발생하지 않는다. 취약한 소프트웨어, 잘못된 설정, 탈취된 계정, 감시 공백이 겹치면서 사고 가능성과 피해가 커진다.

위협, 취약점, 위험

  • 위협: 피해를 일으킬 수 있는 행위자나 사건
  • 취약점: 위협이 악용할 수 있는 약점
  • 위험: 취약점이 악용될 가능성과 예상 피해를 함께 고려한 판단

예를 들어 외부에 공개된 오래된 서비스는 취약점이 될 수 있다. 이를 노리는 공격은 위협이고, 서비스 중단이나 정보 유출이 일어날 가능성은 위험으로 본다.

사고가 반복되는 원인

  • 패치 누락, 공개 범위 오설정, 과도한 권한처럼 사람과 절차에서 생기는 실수
  • 이미 공개된 취약점을 방치하는 문제
  • 단일 장애 지점이나 과도한 신뢰 관계처럼 설계가 피해를 키우는 경우
  • 담당자의 기억과 수동 확인에만 의존해 운영의 일관성이 무너지는 경우

제로데이 취약점은 수정 패치가 제공되기 전에 알려졌거나 악용되는 취약점을 말한다. 패치 적용을 놓친 알려진 취약점과는 구분해야 한다. 제로데이 대응은 패치만 기다리는 데 그치지 않고, 노출 범위를 줄이고 접근을 제한하며 탐지를 강화하는 방식으로 위험을 낮춘다.

2. UDP와 서비스 거부 공격

서비스 거부(DoS) 공격은 네트워크나 시스템 자원을 소진시켜 서비스를 사용할 수 없게 만드는 공격이다. 여러 감염 기기가 동시에 공격하면 분산 서비스 거부(DDoS)라고 한다.

UDP는 연결 설정 절차가 없는 데이터그램 프로토콜이다. 이 특성은 출발지 주소를 위조한 요청과 서버 응답을 이용하는 반사 공격에 악용될 수 있다. UDP 자체가 공격을 뜻하는 것은 아니며, 서버의 역할과 설정에 따라 실제 동작은 달라진다.

반사와 증폭 공격 흐름

  1. 공격자는 요청의 출발지 주소를 피해자 주소로 위조해 여러 UDP 서버에 보낸다.
  2. 요청을 받은 서버가 응답하면 그 응답이 피해자에게 전달된다. 이를 반사 공격이라고 한다.
  3. 요청보다 응답 데이터가 더 큰 경우 피해자는 공격자가 보낸 양보다 많은 트래픽을 받는다. 이를 증폭 공격이라고 한다.

DNS, NTP, SSDP 등 UDP를 사용하는 서비스는 잘못 공개되거나 설정된 경우 반사·증폭 공격에 악용될 수 있다.

DNS 서버 역할 구분

  • 권한 DNS 서버: 특정 도메인 영역의 레코드를 권위 있게 제공한다.
  • 재귀 리졸버: 클라이언트를 대신해 다른 DNS 서버를 조회하고 결과를 캐시한다.
  • 오픈 리졸버: 인터넷의 임의 사용자에게 재귀 질의를 제공하도록 노출된 DNS 리졸버를 말한다.

권한 DNS 서버라고 해서 재귀 질의를 절대 하지 않는다고 단정할 수는 없다. 두 역할은 다르지만 실제 동작은 서버 설정을 확인해야 한다.

운영에서 적용할 방어

  • DNS와 UDP 기반 서비스는 필요한 기능과 대상에만 공개한다.
  • 재귀 DNS 질의의 접근 범위를 제한해 공개 리졸버가 되지 않도록 한다.
  • 출발지 주소 위조 트래픽을 줄이는 네트워크 필터링과 클라우드 사업자의 DDoS 완화 기능을 활용한다.
  • 평소 트래픽 기준선을 기록하고 요청량, 대역폭, 오류율의 급격한 변화를 감시한다.
  • 장애 시 차단·우회·사업자 연락 절차를 미리 정하고 점검한다.

3. 웹 공격과 표적형 공격

드라이브 바이 다운로드

드라이브 바이 다운로드는 사용자가 악성 파일을 직접 실행하지 않았더라도 취약한 브라우저나 플러그인, 잘못 구성된 웹 환경을 통해 악성 코드가 내려오거나 실행되는 공격을 가리킨다.

웹 서버와 브라우저를 최신 상태로 유지하고 불필요한 플러그인을 제거하는 것이 기본이다. 여기에 엔드포인트 보호, 웹 서버 변경 감시, 의심스러운 파일 격리를 더해 피해 가능성을 낮출 수 있다.

SQL 인젝션, 세션 공격, CSRF

SQL 인젝션은 외부 입력이 SQL 구문으로 해석되어 실행되는 취약점이다. 값과 질의 구조를 분리하는 매개변수화 질의를 사용하고, 데이터베이스 계정의 권한을 필요한 범위로 제한해야 한다.

세션 탈취와 인증 우회 위험을 낮추려면 쿠키 보호 설정, 안전한 세션 수명, 다중 요소 인증, 요청마다의 권한 확인을 적용한다.

CSRF(사이트 간 요청 위조)는 사용자가 로그인한 상태를 악용해 의도하지 않은 요청을 보내게 하는 공격이다. 상태를 변경하는 요청에서 CSRF 토큰을 확인하고, 쿠키의 SameSite 설정도 함께 검토한다.

스피어 피싱과 APT

  • 스피어 피싱: 특정 사람이나 조직에 맞춘 메시지로 링크 클릭이나 계정 정보 입력을 유도한다.
  • 소셜 엔지니어링: 사람의 신뢰나 업무 관행을 악용한다.
  • APT(Advanced Persistent Threat): 고도화되고 지속적인 위협을 뜻하며, 장기간 은밀하게 접근을 유지하는 공격 캠페인을 설명할 때 사용한다.

APT는 특정 악성코드나 도구 하나를 뜻하지 않는다. 메일·웹 필터링, 다중 요소 인증, 최소 권한, 계정과 단말 로그 수집, 백업 복구 점검을 함께 적용해야 한다. 계정 탈취가 의심되면 세션과 자격 증명을 폐기하고 영향 범위를 확인한다.

4. 클라우드 보안과 책임 범위

클라우드 보안은 제공자와 고객이 나누어 맡는다. 제공자는 데이터센터와 물리 장비, 가상화 기반처럼 클라우드 자체의 보안을 담당한다. 고객은 선택한 서비스에 따라 데이터, 계정과 권한, 네트워크 규칙, 애플리케이션과 설정을 관리한다.

가상 서버를 직접 운영할 때와 완전 관리형 서비스를 사용할 때 고객이 맡는 범위는 달라진다. 따라서 서비스별 책임 경계를 확인해야 한다.

관련 표준과 관리체계

  • ISO/IEC 27017: 클라우드 서비스의 정보보호 통제를 다루는 지침이다. 클라우드 제공자와 고객의 책임을 포함한다.
  • ISMS: 정보보호 관리체계(Information Security Management System)를 뜻한다. 위험을 식별하고 정책과 통제를 적용한 뒤 점검과 개선을 반복하는 관리 방식이다.

표준과 인증 기준은 개정될 수 있으므로 실제 계약이나 감사에 적용할 때는 해당 시점의 공식 기준을 확인한다.

클라우드 기본 점검

  • 계정과 역할에 최소 권한을 적용하고 관리자 접근에는 강한 인증을 사용한다.
  • 공개 저장소와 네트워크 규칙을 검토한다.
  • 암호화 키와 토큰 같은 비밀정보를 코드 저장소에 두지 않는다.
  • 로그와 백업의 보존 기간, 접근 권한, 복구 책임자를 정한다.
  • 서비스별 제공자 책임과 고객 책임을 문서화한다.

5. 운영 중 취약점과 이상 징후 확인

운영 보안은 자산을 파악하고, 이상을 탐지하고, 영향도를 판단한 뒤, 수정과 재확인을 반복하는 과정이다. 도구 이름만 외우기보다 어떤 질문에 답하기 위해 사용하는지 이해하는 것이 중요하다.

모니터링과 점검 도구

  • SNMP: 네트워크 장비와 서버의 상태 정보를 수집하는 관리 프로토콜이다.
  • Cacti, OpenNMS: 네트워크와 시스템 모니터링에 사용할 수 있는 도구의 예다.
  • syslog, Windows Event Log: 운영체제와 서비스에서 발생한 로그를 수집하는 데 사용한다.
  • ICMP ping: 호스트의 기본 연결성을 확인하지만 애플리케이션이 정상인지까지 보장하지는 않는다. 실제 서비스는 TCP 연결이나 HTTP 요청도 확인해야 한다.
  • Nmap: 네트워크 호스트와 포트를 확인하는 도구다. 소유하거나 명시적으로 허가받은 범위에서만 실행한다.
  • OWASP ZAP: 웹 애플리케이션의 동적 보안 시험에 활용할 수 있다. 테스트 환경과 실행 범위를 먼저 정한다.
  • Greenbone/OpenVAS 계열: 취약점 점검과 결과 관리를 지원한다. 발견 결과는 자산과 버전, 노출 경로를 대조해 확인한다.

취약점 대응 순서

  1. 자산과 외부 노출 범위를 확인하고 취약점 식별자, 영향을 받는 제품과 버전을 기록한다.
  2. CVSS 점수와 벡터를 참고해 기술적 심각도를 파악한다.
  3. 인터넷 노출 여부, 실제 악용 정황, 자산 중요도, 보완 통제와 패치 가능성을 함께 고려해 우선순위를 정한다.
  4. 패치나 설정 변경을 검증 환경에서 확인하고 변경 및 롤백 계획을 세운다.
  5. 적용 후 재검사와 로그 확인으로 해결 여부를 검증한다.
  6. 예외를 둘 경우 책임자, 만료 기한, 보완 통제를 기록한다.

CVSS는 취약점 특성에 따른 심각도 척도다. 조직의 실제 위험도나 조치 순서를 단독으로 결정하는 값은 아니다.

6. 코드로 관리하는 인프라

IaC(Infrastructure as Code)는 서버와 네트워크 같은 인프라의 원하는 상태를 코드나 설정으로 표현하고, 검토·배포·재현하는 방식이다. 변경 이력을 남기고 반복 가능한 절차를 만들면 수작업으로 생기는 누락과 환경 차이를 줄일 수 있다.

다만 잘못된 설정도 빠르게 복제될 수 있다. 코드 검토와 권한 통제를 함께 운영해야 한다.

부트스트래핑 계층

운영체제나 실행 기반을 준비하는 단계다. 데이터센터에서는 OS 설치 자동화가 해당하고, 클라우드에서는 이미지 선택이나 인스턴스 초기화가 이에 해당한다.

Kickstart와 Cobbler는 OS 설치 자동화의 사례다. 클라우드 이미지와 초기화 스크립트도 부트스트래핑의 일부를 맡는다.

구성 관리 계층

OS가 준비된 뒤 패키지, 미들웨어, 사용자, 파일, 서비스 설정을 원하는 상태로 맞춘다. Ansible, Puppet, Chef 등이 구성 관리 도구의 예다.

구성 관리 도구에서 자주 쓰는 개념은 다음과 같다.

  • 선언적: 원하는 최종 상태를 표현하고, 실행 절차 일부를 도구에 맡긴다.
  • 추상화: 개별 명령보다 서비스나 패키지 같은 관리 대상을 중심으로 기술한다.
  • 멱등성: 같은 설정을 여러 번 적용해도 결과가 의도한 상태로 유지되도록 한다.
  • 수렴성: 현재 상태와 원하는 상태의 차이를 줄여 목표 상태에 도달하도록 한다.

오케스트레이션 계층

여러 서버와 서비스의 배포와 연결, 확장을 조율한다. 애플리케이션 배포, 클러스터 구성원 변경, 로드밸런서 대상 등록, 모니터링 대상 등록 등이 여기에 포함된다.

서버를 직접 지정하는 방법은 변경 범위가 분명하지만 반복 작업이 생긴다. 서비스 디스커버리나 클러스터 관리 도구를 통한 자동 등록은 규모 확장에 유리하지만 자동화 규칙과 상태 감시가 필요하다.

Capistrano와 Fabric은 원격 작업이나 배포 자동화에 사용할 수 있다. Serf와 Consul은 클러스터 구성원 정보와 서비스 디스커버리 같은 분산 환경 기능의 사례다. 도구별 기능과 지원 상태는 바뀔 수 있으므로 도입 전 공식 문서를 확인한다.

IaC를 안전하게 운영하기

  • 코드를 버전 관리에 저장하고 동료 검토와 자동 검증을 거쳐 적용한다.
  • 비밀번호와 토큰, 개인 키를 코드나 상태 파일에 평문으로 저장하지 않는다.
  • 개발·검증·운영 환경의 차이를 명시적으로 관리한다.
  • 실제 인프라와 코드 사이의 설정 편차(drift)를 주기적으로 확인한다.
  • 변경 권한을 최소화하고 실패 시 되돌릴 방법을 준비한다.
  • 큰 변경은 일부 자원에 먼저 적용해 영향을 확인한다.

후기

이번 내용을 정리하면서 인프라 보안은 보안 도구를 설치하는 것만으로 끝나는 일이 아니라는 점을 알게 됐다. 안전한 설정과 최소 권한을 바탕으로 로그와 지표를 계속 확인하고, 취약점이 실제 서비스에 미치는 영향까지 살펴야 대응 우선순위를 제대로 정할 수 있다.

또 IaC를 사용하면 인프라 변경을 반복하고 재현하기 쉬워지지만, 잘못된 설정도 여러 환경에 빠르게 퍼질 수 있다는 점을 배웠다. 그래서 코드를 검토하고 비밀정보를 보호하며 실제 설정과 코드가 일치하는지 확인하는 과정이 자동화만큼 중요하다. 앞으로 인프라 문제를 만나면 도구부터 찾기보다 어떤 자산이 영향을 받는지, 책임 범위와 변경 이력이 무엇인지 먼저 확인해야겠다.

핵심만 다시 정리

  • 사고 원인은 기술 결함뿐 아니라 설정 실수, 계정 문제, 변경 절차, 감시 공백에서도 생긴다.
  • UDP 반사·증폭 공격은 주소 위조와 공개 서비스의 설정 문제를 악용할 수 있다.
  • 클라우드 책임 범위는 사용하는 서비스 형태에 따라 달라진다.
  • 취약점 점수와 스캐너 결과는 판단을 돕는 자료이며 실제 위험을 함께 따져야 한다.
  • IaC를 안전하게 쓰려면 변경 검토, 비밀정보 보호, 편차 확인이 필요하다.

참고 자료

728x90
  Comments,     Trackbacks
하네스 엔지니어링이란? - (end)
728x90

Claude Code 하네스로 디버깅 / RCA 팀 꾸리기 — "로컬에서는 잘 되는데요"

리뷰 팀은 있는 코드를 평가했고, 구현 팀은 없는 기능을 만들었고, 마이그레이션 팀은 같은 변환을 수천 번 반복했다. 이번엔 원인을 모르는 문제를 다룬다. 디버깅은 추측이 가장 쉽게 끼어드는 일이라, 이번 하네스의 목표는 하나다. 추측을 증거로 바꾸는 순서를 강제하는 것.
참고 저장소: https://github.com/revfactory/harness-engineering-with-cc (ex-14-10-application-guide)


1. 디버깅이 왜 하네스가 필요한 일인가

혼자 디버깅할 때를 떠올려 보면 이렇다. 버그 보고를 받고, 코드를 훑고, "아마 이거겠지" 하고 고치고, 테스트가 통과하면 끝. 재현도, 가설 검증도, 같은 실수가 다른 곳에 있는지 확인도 안 한다. 운 좋게 맞으면 다행이고, 틀리면 버그는 다른 모양으로 돌아온다.

Claude에게 "이 버그 고쳐 줘"라고 시켜도 똑같다. 오히려 더 빠르게 추측하고 더 자신 있게 고친다. 그래서 이번 하네스는 네 가지 장치로 그 지름길을 막는다.

장치 막는 것
단계별 담당 분리 재현·분석·가설·수정이 한 머릿속에서 뒤섞이는 것
결정론 게이트 재현도 안 하고 가설로 넘어가는 것
하이브리드 구조 탐색은 느려지고, 수정 루프는 끝없이 도는 것
피시본 분석 떠오르는 원인 하나에 꽂혀 나머지를 안 보는 것

2. 저장소에서 가져온 것, 직접 설계한 것

저장소의 디버깅 예제에는 응용 가이드 폴더에 프로덕션 장애용 reviewer.md 하나만 있었다. 핵심 4인 에이전트, 오케스트레이터, 샘플 버그는 없었다. 대신 reviewer.md가 검토하는 산출물 이름(reproduction.sh, hypotheses.md, fix.patch, completion-checklist.md)이 나와 있어서, 그 이름과 5단계 워크플로에 맞춰 나머지를 직접 설계했다.


3. 시나리오: 로컬에서는 통과, CI에서는 실패

실습용 버그는 타임존 버그로 만들었다.

// src/settlement.js — 정산 마감일 계산
export function businessDate(isoString) {
  const d = new Date(isoString);
  const y = d.getFullYear();
  const m = String(d.getMonth() + 1).padStart(2, '0');
  const day = String(d.getDate()).padStart(2, '0');
  return `${y}-${m}-${day}`;
}

getDate()는 코드를 실행하는 컴퓨터의 시간대 기준이다. 한국 시간 11월 1일 새벽 0시 30분은 UTC로는 10월 31일 15시 30분. 그래서 한국 PC에서는 2026-11-01, UTC로 도는 CI 서버에서는 2026-10-31이 나온다. 코드만 봐서는 잘못이 안 보이고, 개발자 PC에서는 테스트가 전부 통과한다. "로컬에서는 잘 되는데요"의 교과서 같은 사례다.

재료는 세 가지를 뒀다.

  • 버그 보고서 reports/BUG-001.md: 증상, "로컬에서는 잘 되는데요"라는 개발자 코멘트, 발생·정상 환경
  • CI 로그 reports/ci.log: 실패 메시지와 함께 Node 버전, LANG=C.UTF-8, TZ=(빈 값). 진짜 단서와 헷갈리게 하는 단서가 섞여 있다
  • 같은 패턴의 두 번째 파일 src/invoice.js: 청구서 발행일도 같은 방식으로 날짜를 뽑는다. 마지막 단계의 "유사 패턴 검색"이 이걸 찾아내는지 보기 위한 함정이다

하네스를 돌리기 전에 정답부터 손으로 확인해 뒀다.

npm test                 # 통과 (로컬 한국 시간)
TZ=UTC npm test          # 실패 (CI와 같은 조건)

4. 팀 구성: 4명

에이전트 맡은 일 model 핵심 권한
reproducer 재현 스크립트, 3회 연속 재현 게이트, 수정 후 재검증 sonnet Bash, Write / Edit 없음
log-analyzer 정상·실패 환경 비교, 타임라인 sonnet Bash, Write / Edit 없음
hypothesis-analyst 피시본 분석, 가설 5개, 반증 실험 설계 opus Write / Bash 없음
fixer 최소 수정, 회귀 테스트, fix.patch sonnet Edit, Bash

지난 실습들에서 배운 대로 역할 경계는 tools 한 줄로 정했다.

코드를 고칠 수 있는 건 fixer뿐이다. 재현 담당이 재현하다가 "이거 고치면 되겠네" 하고 손대는 순간, 재현이 증거에서 추측으로 바뀐다.

별도 verifier가 없다. 수정 후 검증은 reproducer가 맡는다. 처음에 버그를 잡아낸 그 재현 스크립트로 "이제 안 일어난다"를 보여 준다. 같은 자로 재야 전후 비교가 된다.

가설 담당은 실험을 못 한다. Bash가 없어서 반증 실험을 설계만 하고 실행은 reproducer가 한다. 가설을 세운 사람이 자기 가설을 검증하면 확증 편향이 끼어든다. 구현 팀에서 "만든 사람이 검증하면 자기 확증"이라고 정리했던 원리가 여기서는 "가설 세운 사람이 실험하면 자기 확증"으로 나타난다.

opus는 가설 담당 하나다. 여러 증거를 동시에 놓고 범주별로 원인을 따지는, 판단이 가장 무거운 자리라서다.


5. 워크플로: 5단계와 두 개의 게이트

단계 하는 일 모드 다음으로 가는 문
1. 준비 보고서 파싱, _workspace/bug-001/ 생성 메인 세션 report.md 정리
2. 관찰 재현 + 로그 분석 병렬 팀 게이트 1: 재현 3회 연속
3. 가설 피시본 → 가설 5개 → 우선순위 → 반증 실험 서브 1순위 가설이 실험으로 지지됨
4. 수정-검증 수정 → 재현 스크립트 확인, 최대 3회 서브 게이트 2: 수정 후 3회 연속 통과
5. 정리 포스트모템 + 유사 패턴 검색 메인 세션 완료 체크리스트

6. 결정론 게이트: "3회 연속"의 의미

reproducer의 정의 파일에서 가장 중요한 줄은 이것이다.

3회 중 결과가 갈리면 "비결정적"으로 보고하고 게이트를 통과시키지 않는다.

한 번 재현은 우연일 수 있다. 두 번 실패, 한 번 성공이면 그건 재현이 아니라 운이다. 그 상태로 가설 단계에 가면 가설을 확인할 방법이 없다. 실험 결과가 바뀌어도 그게 가설 때문인지 운 때문인지 모르니까.

그리고 재현 스크립트에는 대조군이 같이 들어간다. "UTC에서 실패"만으로는 부족하고, 같은 스크립트 안에서 "한국 시간에서는 통과"까지 보여야 한다. 그래야 시간대가 원인의 경계선이라는 게 증명된다.

reproduction.sh 3회 실행
  1회: TZ=UTC 실패 / TZ=Asia/Seoul 통과
  2회: TZ=UTC 실패 / TZ=Asia/Seoul 통과
  3회: TZ=UTC 실패 / TZ=Asia/Seoul 통과
→ 게이트 1 통과

같은 스크립트가 수정 후에는 반대 방향의 게이트가 된다. 3회 모두 양쪽 다 통과해야 수정이 인정된다. 게이트를 사람의 느낌이 아니라 반복 실행 결과로 정한다는 것, 이게 이번 실습에서 배운 첫 번째 지식이다.


7. 하이브리드 구조: 왜 관찰은 팀, 수정은 서브에이전트인가

지금까지 세 팀은 모드가 하나였다. 리뷰 팀과 마이그레이션 팀은 서브에이전트, 구현 팀은 팀 모드. 디버깅 팀은 한 워크플로 안에서 둘을 섞는다.

관찰은 탐색이다. log-analyzer가 CI 로그에서 TZ=가 비어 있는 걸 발견하는 순간, reproducer가 바로 TZ=UTC로 돌려 봐야 빠르다. 메인 세션을 거쳐 전달하면 느리고 맥락이 끊긴다. 그래서 둘을 한 팀으로 묶어 직접 대화하게 둔다.

수정-검증은 통제다. 고치고, 재고, 실패하면 다시. 이 루프의 횟수를 메인 세션이 정확히 세고 있어야 3회 상한이 지켜진다. fixer와 reproducer를 한 팀으로 묶어 직접 대화하게 두면 저희끼리 몇 번이고 주고받으며 상한이 무너진다. 그래서 메인 세션이 한 명씩 부른다.

탐색 구간은 팀으로, 통제 구간은 서브에이전트로.

모드를 "이 하네스 전체에 하나"로 고르는 게 아니라 구간마다 일의 성격을 보고 고른다. 구현 팀에서 패턴을 Phase마다 조합했던 것처럼, 이번엔 실행 모드를 단계마다 조합한 셈이다.

관찰 단계에 팀 모드가 들어가니 이번엔 환경 변수가 필요하다.

export CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1
claude

8. 피시본: 가설을 떠올리지 말고 펼친다

가설 담당에게는 원인 범주 여섯 개를 줬다.

코드 / 환경 / 의존성 / 데이터 / 테스트 / 프로세스

규칙은 범주마다 최소 하나씩 요인을 적고, 해당 없으면 "해당 없음 + 이유"를 적는 것. 이렇게 하면 처음 눈에 띈 원인에 꽂혀 나머지를 건너뛰는 일이 막힌다. 이번 버그로 펼치면 대략 이런 모양이 나와야 한다.

범주 요인
코드 실행 환경 시간대에 의존하는 날짜 getter
환경 CI의 TZ 미설정(UTC), 로컬은 한국 시간
의존성 Node 버전 차이 (로컬 vs CI v20)
데이터 자정 직후 타임스탬프 (시간대 경계)
테스트 로컬 시간대에서만 실행, 시간대 고정 없음
프로세스 CI에 시간대 매트릭스 없음

여기서 가설 5개를 고르고, 가설마다 반증 실험을 붙인다.

  • 시간대 가설이 맞다면 → TZ=Asia/Seoul로 돌리면 통과해야 한다
  • Node 버전 가설이 맞다면 → 시간대는 그대로 두고 Node 버전만 바꿔도 결과가 바뀌어야 한다

반증 실험은 reproducer가 돌린다. 결과가 나오면 가설끼리 말싸움할 필요가 없다. 틀린 가설은 실험으로 지워진다. 이게 "체계적으로 좁힌다"의 실제 모습이었다.

한 가지 더. 가설 담당에게 "단일 원인으로 환원하지 않는다, 직접 원인과 기여 요인을 구분한다"를 줬다. 이 버그의 직접 원인은 코드지만, 기여 요인은 테스트(한국 시간에서만 돎)와 프로세스(CI에 시간대 고정 없음)다. 코드만 고치면 다음 시간대 버그도 또 "로컬에서는 잘 되는" 채로 들어온다.


9. 범위를 지키는 두 줄

이번 하네스에서 의외로 중요했던 건 "하지 않는 일"을 정한 두 줄이다.

fixer: "기여 요인은 고치지 않고 포스트모템 권고로 넘긴다."
fixer가 신나서 CI 설정까지 바꾸기 시작하면 수정 범위가 커지고, 검증이 실패했을 때 무엇 때문인지 가려낼 수 없다. 코드 수정은 최소로, 구조 개선은 포스트모템으로 분리한다.

5단계: "유사 패턴은 목록화만 하고 고치지 않는다."
invoice.js에도 같은 버그가 있다. 하지만 여기서 같이 고치면 이번 수정의 검증 범위가 흐려진다. 유사 패턴 목록은 다음 사고 후보 목록이고, 고치는 건 별도 버그로 다룬다.


10. 하네스 구조

debug_rca/
├── CLAUDE.md                          원칙: 재현 없이 가설 없음, 3회 게이트, blameless
├── reports/
│   ├── BUG-001.md                     버그 보고서
│   └── ci.log                         CI 실패 로그
├── src/settlement.js, invoice.js      버그가 숨은 코드
├── tests/settlement.test.js           로컬에서만 통과하는 테스트
├── .claude/
│   ├── agents/
│   │   ├── reproducer.md
│   │   ├── log-analyzer.md
│   │   ├── hypothesis-analyst.md
│   │   └── fixer.md
│   └── skills/debug-rca/SKILL.md      5단계 오케스트레이터
└── _workspace/bug-001/                실행 시 생성
    ├── report.md
    ├── observations/repro.md, logs.md
    ├── reproduction.sh
    ├── hypotheses.md
    ├── fix.patch
    ├── verification.md
    ├── postmortem.md
    ├── similar-patterns.md
    └── completion-checklist.md

실행은 한 줄이다.

BUG-001 디버깅해 줘

11. 응용: 같은 뼈대, 바꾸는 곳만 다르다

재현 → 가설 → 수정-검증 → 정리라는 뼈대는 그대로 두고, 상황에 따라 게이트의 의미와 담당자의 초점만 바꾼다.

응용 게이트 1 (재현) 바뀌는 담당 게이트 2 (수정 확인)
프로덕션 장애 프로덕션 말고 스테이징·로컬에 재현 환경 구축 log-analyzer → 알람·배포 타임라인 중심. 저장소의 reviewer 추가 핫픽스는 사람이 결정, 하네스는 근본 수정만
플래키 테스트 3회 연속 대신 N회(예: 50회) 실패율 측정 피시본 범주 교체: 실행 순서, 타이밍, 공유 상태, 난수, 네트워크 같은 N회에서 실패 0회
성능 저하 3회 측정 모두 기준선 대비 임계값 이상 저하 + 편차 작음 log-analyzer → 프로파일러 역할 3회 측정 모두 기준선 ± 허용 오차
1인 개발자 그대로 3회 팀 모드 없이 reproducer + fixer 둘만 남김. 로그·가설은 메인 세션이 겸함 그대로 3회, 루프 3회 상한 유지

몇 가지는 짚어 둘 만하다.

프로덕션 장애의 reviewer는 저장소에 있던 유일한 파일인데, 도구가 Read 하나뿐이다. 포스트모템 검토자는 아무것도 실행하거나 고치지 못하고 읽기만 한다. 그리고 "누구 잘못인가" 대신 "시스템이 왜 그걸 허용했나"를 묻고, 권고마다 자동화된 가드(린터, 테스트, 알람 임계값)를 붙이게 되어 있다. 장애 회고가 사람 탓으로 흐르는 걸 구조로 막는다. 이 blameless 원칙은 기본 하네스의 CLAUDE.md에도 헌법으로 넣어 뒀다.

플래키 테스트는 reproducer의 "3회 중 결과가 갈리면 통과시키지 않는다"에서 갈라지는 경로다. 결정론 게이트를 통과하지 못하는 버그는 버리는 게 아니라 통계 게이트로 갈아탄다.

1인 개발자 버전에서 아무리 줄여도 reproducer와 fixer는 반드시 분리한다. 둘이 합쳐지면 "고친 사람이 고쳐졌다고 판정"하게 되어 하네스의 존재 이유가 사라진다. 비용을 줄일 때 지켜야 할 최소 단위가 이 둘이다.


12. 네 팀을 이어 보면 (최종 정리)

리뷰 팀 구현 팀 마이그레이션 팀 디버깅 팀
다루는 일 있는 코드 평가 없는 기능 만들기 같은 변환 반복 모르는 원인 찾기
주 패턴 팬아웃 Phase별 조합 감독자 게이트 + 하이브리드
넘어가는 기준 없음 Phase 산출물 검토 배치 검증 재현 3회 연속
실행 모드 서브 팀 서브 팀 + 서브 혼합
검증 기준 정의 파일 기준 설계 문서 기준선 테스트 재현 스크립트
마지막 산출물 리뷰 보고서 통합 보고서 상태 파일 포스트모템

디버깅 팀에서 새로 얻은 건 두 가지다.

  1. "넘어가도 되는가"를 사람의 느낌이 아니라 반복 실행 결과로 정하는 게이트. 그리고 그 게이트가 통과되지 않을 때 갈아탈 경로(플래키, 성능)까지 미리 설계해 두는 것.
  2. 한 워크플로 안에서도 탐색 구간은 팀으로, 통제 구간은 서브에이전트로 섞는 설계. 패턴을 조합했듯 모드도 조합한다.
728x90
  Comments,     Trackbacks