Claude Code를 오래 쓰다 보면 “Compacting conversation…”이 뜨는 순간이 와요. 그 뒤부터 “아까 말한 그 함수 수정해줘” 하면 전혀 다른 파일을 건드리거나, TODO 목록을 통째로 잊어버리는 일이 생기죠.
이 글에서는 왜 이런 일이 발생하는지 원인을 분석하고, 압축 전에 상태를 파일로 저장하고 압축 후에 복원하는 접근법을 살펴봐요.
이 접근법을 실제로 잘 구현한 두 오픈소스 프로젝트의 실제 구현 코드를 함께 분석할 거예요:
- Claude Code용 플러그인 OMC(oh-my-claudecode)
- OpenCode용 플러그인 OMO(oh-my-openagent)
읽고 나면 다음을 이해할 수 있어요:
- Compacting 이후 답변 품질이 떨어지는 구조적 원인
/compact과 자동 압축의 결정적 차이- Claude Code 훅(Hooks)으로 상태를 보존하고 복원하는 구체적인 방법
- 요약 프롬프트를 직접 제어해서 압축 품질 자체를 높이는 근본적 해법
이 글은 Claude Code의 훅 시스템을 중심으로 설명해요. OpenCode, Codex 등 다른 AI 코딩 도구도 컨텍스트 압축 문제는 동일하지만, 훅 API와 제어 방식이 다를 수 있어요.
왜 Compacting 이후 답변이 이상해지는가
컨텍스트 윈도우의 한계
AI 코딩 에이전트는 컨텍스트 윈도우(Context Window)라는 정해진 크기의 메모리 안에서 동작해요. Claude Code에서 사용하는 Claude Opus 4.6과 Sonnet 4.6은 1M 토큰의 컨텍스트 윈도우를 지원하지만, 코딩 작업 한 세션이면 이마저도 쉽게 넘길 수 있어요.
컨텍스트 윈도우가 꽉 차면 Claude Code는 자동으로 대화를 요약 압축(Compaction) 해요. 원본 대화를 삭제하고 요약본만 남기는 거예요. 문제는 이 과정에서 다음 정보가 뭉개지는 거예요:
- 도구 호출 결과 — 파일 읽기, grep 결과, 빌드 로그 등
- 작업 중인 파일 목록과 수정 내역
- TODO 진행 상황
- 사용자가 명시한 제약조건의 원문
/compact과 자동 압축의 결정적 차이
Claude Code Hooks 공식 문서에서 PreCompact 훅의 입력 스키마를 보면, /compact과 자동 압축 사이에 중요한 차이가 있어요:
{
"trigger": "manual",
"custom_instructions": "TODO 목록과 파일 경로를 보존해줘"
}{
"trigger": "auto",
"custom_instructions": ""
}/compact은 custom_instructions 필드로 요약 과정에 보존 지시를 줄 수 있어요. 반면 자동 압축은 이 필드가 항상 비어있어요.
자동 압축이 터지면 “뭘 보존하라”는 지시 없이 요약이 진행돼요. 그래서 자동 압축(100%)이 터지기 전에 /compact을 먼저 실행하는 게 핵심이에요.
OMC의 압축 해결 플로우
해결 접근법은 세 단계예요: 자동 압축 전에 /compact을 유도하고 → 압축 직전에 상태를 파일로 저장하고 → 압축 후에 파일에서 복원하는 것이에요. OMC가 이 세 단계를 Claude Code 훅으로 어떻게 구현하는지, 전체 흐름을 먼저 볼게요:
[75%] Stop 훅 실행 ─────────────────── 1단계: 자동 압축 전에 /compact 유도
│ Claude가 멈추려 하면 exit 2로 차단
│ → "먼저 /compact을 실행하세요"
│
[85%] PostToolUse 훅 실행
│ 도구 실행 후 경고 메시지 주입
│ → "/compact을 실행하세요"
│
[/compact 실행 또는 자동 압축 트리거]
│
├─ PreCompact 훅 실행 ────────────── 2단계: 상태를 파일로 저장
│ ├─ .omc/state/checkpoints/checkpoint-{timestamp}.json 생성
│ └─ .omc/project-memory.json 읽어서 경고 표시
│
├─ [Claude Code가 대화를 요약 압축]
│
└─ SessionStart(compact) 훅 실행 ─── 3단계: 파일에서 상태 복원
├─ .omc/state/sessions/{id}/*-state.json → 모드 상태 복원
├─ .claude/todos.json → TODO 목록 복원
├─ .omc/project-memory.json → 프로젝트 메모리 복원
└─ .omc/notepad.md → 우선 컨텍스트 복원
↓
additionalContext로 새 컨텍스트에 원문 주입각 단계를 코드 레벨에서 살펴볼게요.
1단계: 자동 압축을 피하는 방법
OMC의 코드를 기준으로, /compact을 유도하는 두 가지 메커니즘을 살펴볼게요.
75%에서 Stop을 차단해요
Claude가 작업을 끝내고 멈추려 할 때, 컨텍스트 사용률이 75% 이상이면 Stop을 차단하고 /compact을 먼저 실행하라고 강제해요:
const THRESHOLD = parseInt(
process.env.OMC_CONTEXT_GUARD_THRESHOLD || '75', 10
);
const CRITICAL_THRESHOLD = 95;
const MAX_BLOCKS = 2;THRESHOLD = 75: 컨텍스트 75% 이상이면 Stop 차단CRITICAL_THRESHOLD = 95: 95% 이상은 차단하지 않아요. 차단하면 압축 자체가 실행될 수 없어서 데드락이 걸리거든요.MAX_BLOCKS = 2: 세션당 최대 2회만 차단해요. 무한 차단을 방지하는 안전장치예요.
컨텍스트 사용률은 JSONL 트랜스크립트 파일의 마지막 4096바이트에서 input_tokens / context_window로 추정해요:
function estimateContextPercent(transcriptPath) {
if (!transcriptPath) return 0;
let fd = -1;
try {
const stat = statSync(transcriptPath);
if (stat.size === 0) return 0;
fd = openSync(transcriptPath, 'r');
const readSize = Math.min(4096, stat.size);
const buf = Buffer.alloc(readSize);
readSync(fd, buf, 0, readSize, stat.size - readSize);
closeSync(fd);
fd = -1;
const tail = buf.toString('utf-8');
const windowMatch = tail.match(/"context_window"\s{0,5}:\s{0,5}(\d+)/g);
const inputMatch = tail.match(/"input_tokens"\s{0,5}:\s{0,5}(\d+)/g);
if (!windowMatch || !inputMatch) return 0;
const lastWindow = parseInt(windowMatch[windowMatch.length - 1].match(/(\d+)/)[1], 10);
const lastInput = parseInt(inputMatch[inputMatch.length - 1].match(/(\d+)/)[1], 10);
if (lastWindow === 0) return 0;
return Math.round((lastInput / lastWindow) * 100);
} catch {
return 0;
} finally {
if (fd !== -1) try { closeSync(fd); } catch { /* ignore */ }
}
}85%에서 경고 메시지를 주입해요
도구 실행 후마다 누적 토큰을 추정하고, 85%에 도달하면 Claude에게 “지금 /compact을 실행하세요”라는 경고를 주입해요:
export const DEFAULT_THRESHOLD = 0.85;
export const CRITICAL_THRESHOLD = 0.95;
export const MIN_TOKENS_FOR_COMPACTION = 50_000;
export const COMPACTION_COOLDOWN_MS = 60_000; // 경고 간 1분 쿨다운
export const MAX_WARNINGS = 3; // 세션당 최대 3회
export const CHARS_PER_TOKEN = 4; // 문자→토큰 변환 계수토큰 추정 방식은 누적 출력 문자 수 ÷ 4예요. 정확하진 않지만, 비용 없이 실시간으로 추정하기엔 충분해요.
여러 서브에이전트가 동시에 완료될 때 경고가 중복 발생하는 것을 500ms 디바운스로 방지해요. (소스 코드)
2단계, 3단계: 상태 저장과 복원
PreCompact 훅에서 체크포인트를 파일로 저장해요
/compact이 실행되거나 자동 압축이 트리거되면 PreCompact 훅이 실행돼요. 이 시점에 .omc/state/checkpoints/checkpoint-{타임스탬프}.json 파일을 생성하고 현재 상태를 저장해요:
export async function createCompactCheckpoint(
directory: string,
trigger: "manual" | "auto",
): Promise<CompactCheckpoint> {
const activeModes = await saveModeSummary(directory);
const todoSummary = readTodoSummary(directory);
const jobsSummary = await getActiveJobsSummary(directory);
return {
created_at: new Date().toISOString(),
trigger,
active_modes: activeModes as CompactCheckpoint["active_modes"],
todo_summary: todoSummary,
wisdom_exported: false,
background_jobs: {
active: jobsSummary.activeJobs,
recent: jobsSummary.recentJobs,
stats: jobsSummary.stats,
},
};
}실제로 생성되는 체크포인트 파일의 예시예요:
{
"created_at": "2026-03-26T14:30:00.000Z",
"trigger": "manual",
"active_modes": {
"autopilot": { "phase": "execution", "originalIdea": "auth 모듈 리팩토링" },
"ralph": { "iteration": 3, "prompt": "테스트 커버리지 100% 달성" }
},
"todo_summary": {
"pending": 3,
"in_progress": 1,
"completed": 7
},
"wisdom_exported": false,
"background_jobs": {
"active": [
{ "jobId": "j-1", "provider": "codex", "model": "gpt-5.3", "agentRole": "benchmark", "spawnedAt": "..." }
],
"recent": [],
"stats": { "total": 5, "active": 1, "completed": 4, "failed": 0 }
}
}이 파일 덕분에 압축 후에도 “autopilot이 execution 단계에 있고, TODO가 3개 남아있고, 백그라운드에서 벤치마크가 돌고 있다”는 맥락이 보존돼요.
별도로 프로젝트 메모리(기술 스택, 사용자 지시사항)도 보존해요:
PreCompact 훅의 systemMessage 출력은 사용자에게 보여지는 경고 메시지예요. 요약 프롬프트에 주입되는 게 아니에요. Claude Code Hooks 공식 문서에도 “PreCompact — No decision control. Used for side effects like logging or cleanup”이라고 명시되어 있어요. 실제 상태 보존은 디스크 파일 저장 → SessionStart 복원 경로로 이루어져요.
SessionStart 훅에서 상태를 복원해요
압축이 완료되면 새 컨텍스트 윈도우가 열리면서 SessionStart 훅이 실행돼요. matcher: "compact"로 설정하면 압축 직후에만 실행돼요.
이 훅에서 디스크에 저장해둔 상태를 읽어 additionalContext로 주입해요:
// ultrawork 모드 상태 복원
const uwState = readJSON(ultraworkStatePath);
if (uwState?.prompt) {
parts.push(`[Ultrawork] prompt: ${uwState.prompt}`);
}
// TODO 목록 복원
const todos = readJSON(todosPath);
const pending = todos?.filter(t => t.status !== 'completed');
if (pending?.length) {
parts.push(`[Pending TODOs] ${formatTodos(pending)}`);
}
// 프로젝트 메모리 복원
const memory = readJSON(projectMemoryPath);
if (memory?.directives?.length) {
parts.push(`[Project Memory] ${formatMemory(memory)}`);
}복원되는 상태:
| 대상 | 파일 위치 | 내용 |
|---|---|---|
| 활성 모드 | .omc/state/sessions/{sessionId}/*-state.json | autopilot 단계, ralph 반복 횟수 |
| 미완료 TODO | .claude/todos.json | pending/in_progress 항목 |
| 프로젝트 메모리 | .omc/project-memory.json | 기술 스택, 사용자 지시사항 |
| 노트패드 | .omc/notepad.md ## Priority Context | 사용자가 기록한 핵심 정보 |
SessionStart의 additionalContext는 새 컨텍스트 윈도우가 열린 이후에 주입돼요. 요약 모델을 거치지 않으니 의역이나 누락 없이 원문 그대로 전달돼요. 이것이 PreCompact의 systemMessage보다 신뢰할 수 있는 이유예요.
더 근본적인 해법: 요약 프롬프트를 직접 제어하기
지금까지 살펴본 OMC의 접근법은 압축 전후를 관리하는 방식이에요. 압축 전에 상태를 파일로 저장하고, 압축 후에 복원하는 거죠. 요약 자체는 Claude Code 내부에서 알아서 처리하니, 요약 품질에는 개입할 수 없어요.
그런데 만약 요약 모델에게 “이렇게 정리해”라고 직접 지시할 수 있다면 어떨까요? OpenCode용 플러그인인 OMO(oh-my-openagent)가 바로 이 방식을 사용해요.
[70%] context-window-monitor 실행
│ 도구 출력에 "품질 떨어뜨리지 마" 주입
│
[78%] preemptive-compaction 실행
│ session.summarize() API로 압축 자동 트리거
│
├─ experimental.session.compacting 훅 실행
│ ├─ TODO 리스트 스냅샷 저장 (compactionTodoPreserver)
│ ├─ 에이전트 설정 체크포인트 (compactionContextInjector)
│ └─ output.context.push()로 8섹션 요약 프롬프트 주입 ← 핵심 차이
│
├─ [요약 모델이 8섹션 구조로 요약 생성]
│
└─ 압축 후 복원
├─ TODO 스냅샷과 비교 → 사라진 항목만 복원
├─ 에이전트 설정 무음 복원 (session.promptAsync)
└─ 5개 응답 모니터링 → 품질 저하 감지 시 재압축OMC와 비교하면, OMO는 요약 과정 자체에 개입하는 게 가장 큰 차이예요. 하나씩 살펴볼게요.
요약 프롬프트에 직접 텍스트를 주입해요
OMO는 OpenCode 위에서 동작해요. OpenCode는 experimental.session.compacting이라는 플러그인 훅을 제공하는데, 이 훅에서 output.context.push()로 요약 모델의 프롬프트에 직접 텍스트를 주입할 수 있어요:
"experimental.session.compacting": async (
_input: { sessionID: string },
output: { context: string[] },
): Promise<void> => {
await hooks.compactionContextInjector?.capture(_input.sessionID)
await hooks.compactionTodoPreserver?.capture(_input.sessionID)
await hooks.claudeCodeHooks?.["experimental.session.compacting"]?.(
_input,
output,
)
if (hooks.compactionContextInjector) {
output.context.push(
hooks.compactionContextInjector.inject(_input.sessionID)
)
}
},output.context.push()가 핵심이에요. 이 한 줄이 요약 모델에게 “이 텍스트를 참고해서 요약해”라고 지시하는 거예요.
8개 섹션 구조로 요약을 강제해요
주입되는 텍스트는 요약 모델에게 8개 섹션 구조로 정리하라고 강제하는 프롬프트예요:
| # | 섹션 | 핵심 규칙 |
|---|---|---|
| 1 | 사용자 요청 원문 | 원문 그대로 보존. 의역 금지 |
| 2 | 최종 목표 | 전체 작업의 종착점 |
| 3 | 완료된 작업 | 수정된 파일명, 구현된 기능 |
| 4 | 남은 작업 | 아직 안 한 것 |
| 5 | 활성 작업 컨텍스트 | 편집 중인 파일, 런타임 상태 |
| 6 | 명시적 제약조건 | 사용자가 말한 것만 원문 인용 |
| 7 | 에이전트 검증 상태 | 검증 진행 상황, 이전 거절 이력 |
| 8 | 위임된 에이전트 세션 | 세션 ID → 새로 생성하지 않고 재개 |
6번 섹션이 특히 중요해요. 압축 과정에서 AI가 존재하지 않는 제약조건을 만들어내면(hallucination), 이후 작업이 불필요하게 제한돼요. “사용자가 명시적으로 말한 것만, 원문으로 인용하라”는 규칙이 이를 방지해요.
78%에서 자동으로 압축을 트리거해요
OMC는 Claude에게 /compact을 실행하라고 부탁하는 것이 최선이었어요. OMO는 session.summarize() API를 직접 호출해서 코드에서 압축을 즉시 실행할 수 있어요:
const PREEMPTIVE_COMPACTION_THRESHOLD = 0.78;
// ...
const usageRatio = totalInputTokens / actualLimit;
if (usageRatio < PREEMPTIVE_COMPACTION_THRESHOLD) return;
await ctx.client.session.summarize(/* ... */);압축 후 품질 저하를 자동으로 감지해요
OMO는 압축 후 5개 응답을 모니터링해요. 3개 이상 텍스트가 없는 응답(모델이 무한 루프에 빠진 징후)이 감지되면 자동으로 재압축을 트리거해요:
const POST_COMPACTION_MONITOR_COUNT = 5;
const POST_COMPACTION_NO_TEXT_THRESHOLD = 3;60초 쿨다운으로 무한 재압축 루프를 방지해요.
70%에서 모델의 “자발적 품질 저하”를 차단해요
모델은 컨텍스트가 차오르면 스스로 답변을 축약하려는 경향이 있어요. OMO는 70% 시점에 도구 출력에 “아직 컨텍스트 여유가 있으니 품질을 떨어뜨리지 마세요”라는 지시를 주입해서 이를 차단해요:
const CONTEXT_WARNING_THRESHOLD = 0.70;왜 Claude Code에서는 OMO 방식이 불가능한가
OMO의 핵심 기능들이 Claude Code에서 불가능한 이유는 런타임이 노출하는 제어권이 다르기 때문이에요.
| 능력 | OMO (OpenCode) | OMC (Claude Code) |
|---|---|---|
| 요약 프롬프트 수정 | output.context.push() | 불가능 |
| 프로그래밍적 압축 트리거 | session.summarize() | 불가능 (간접 유도만 가능) |
| 요약 모델 변경 | resolveCompactionModel() | 불가능 |
| 품질 저하 감지 → 재압축 | 가능 (API 의존) | 불가능 |
Claude Code Hooks 공식 문서에 PreCompact와 PostCompact 모두 “No decision control. Used for side effects like logging or cleanup”이라고 명시되어 있어요. 압축 과정에 개입하거나 차단할 수 없는 거예요.
비유하면 이래요:
- OMO는 요리사(요약 모델)에게 레시피를 직접 건네줄 수 있는 주방장이에요. “이 8가지 섹션으로 정리해”라고 지시할 수 있어요.
- OMC는 요리사가 요리하기 직전에 재료를 정리해두고, 요리가 끝난 직후에 빠진 재료를 보충하는 보조 셰프예요. 요리 과정 자체에는 개입할 수 없지만, 준비와 마무리로 결과물의 품질을 끌어올려요.
전체 방어 타임라인
OMC와 OMO가 각각 어느 시점에 개입하는지 비교해 볼게요:
| 컨텍스트 사용률 | OMC (Claude Code) | OMO (OpenCode) |
|---|---|---|
| 70% | — | 도구 출력에 “품질 떨어뜨리지 마” 주입 |
| 75% | Stop 차단 → /compact 강제 유도 | — |
| 78% | — | session.summarize() 자동 호출 |
| 80% | HUD 상태바에 시각 경고 표시 | — |
| 85% | PostToolUse 경고 메시지 주입 | — |
| 95% | 긴급 압축 또는 세션 핸드오프 | 긴급 압축 또는 세션 핸드오프 |
| 100% | 자동 압축 (custom_instructions 없음) | 자동 압축 (custom_instructions 없음) |
OMO는 78%에서 프로그래밍적으로 압축을 트리거할 수 있어서 100%를 훨씬 앞서 대응해요. OMC는 API로 압축을 트리거할 수 없으니, Stop 차단 + 경고 주입으로 Claude가 /compact을 실행하도록 유도하는 전략을 써요.
번외: 직접 구현하기 어렵다면
이 글에서 설명한 접근법을 직접 구현할 수도 있지만, 이미 잘 만들어진 도구를 활용할 수도 있어요.
Claude Code를 쓴다면: OMC
Claude Code 플러그인 마켓플레이스를 통해 설치하거나, npm 패키지를 직접 사용할 수 있어요. 설치하면 Stop 차단, PostToolUse 경고, PreCompact 체크포인트, SessionStart 복원 훅이 모두 설정돼요.
OpenCode를 쓴다면: OMO
OpenCode를 사용하고 있다면 OMO를 설치해서 요약 프롬프트 제어, 선제적 압축 트리거, 품질 저하 감지까지 활용할 수 있어요.
정리
AI 코딩 에이전트의 진짜 병목은 모델 성능이 아니라 컨텍스트 관리예요. 자동 압축이 터지기 전에 /compact을 실행하고, 압축 전에 상태를 파일로 저장하고, 압축 후에 복원하는 것만으로도 체감 품질이 크게 달라져요. OMO처럼 요약 프롬프트 자체를 제어할 수 있다면 더 근본적인 해결이 가능하지만, Claude Code에서는 훅 시스템의 제약 안에서 파일 기반 체크포인트 + SessionStart 복원이 가장 신뢰할 수 있는 전략이에요.