AI 에이전트가 어제 한 일을 잊지 않게 만드는 법: Durable Memory와 Run Ledger 설계

이경규·2026년 7월 13일

AI 에이전트가 어제 한 일을 잊지 않게 만드는 법: Durable Memory와 Run Ledger 설계

AI 코딩 에이전트를 며칠 이상 써보면 이상한 순간이 온다.

어제 분명히 같은 문제를 고쳤다.
같은 파일을 읽었다.
같은 테스트가 실패했다.
왜 그 접근을 버렸는지도 정리했다.

그런데 새 세션을 열면 다시 처음부터 시작한다.

이 파일 구조를 먼저 확인하겠습니다.
관련 코드를 탐색하겠습니다.
가능한 원인을 분석하겠습니다.

개발자 입장에서는 답답하다.

이미 확인한 내용인데 또 읽는다.
이미 실패한 접근인데 다시 시도한다.
어제 남긴 판단과 오늘의 판단이 충돌한다.

그래서 많은 팀이 대화 기록을 길게 보존하려고 한다.

하지만 대화 기록을 전부 들고 가는 방식은 오래 못 간다.

세션 로그
도구 호출
전체 파일 내용
빌드 로그
실패 로그
리뷰 코멘트
수정 설명

이 모든 것을 다음 세션에 넘기면 컨텍스트는 금방 더러워진다.

AI 에이전트에게 필요한 것은 긴 기억이 아니다.

다시 시작할 때 필요한 기억만 남기는 구조다.

이 글에서는 그 구조를 Durable Memory와 Run Ledger로 나눠 설명한다.

Durable Memory
→ 다음 작업에서도 재사용할 지식

Run Ledger
→ 현재 작업을 이어가기 위한 실행 기록

둘을 구분하면 AI 에이전트가 같은 실수를 반복하는 문제를 크게 줄일 수 있다.


1. 대화 기록은 메모리가 아니다

먼저 가장 많이 하는 오해부터 정리하자.

대화가 길면 AI가 많이 기억한다.

어느 정도는 맞다.

하지만 실무에서는 대화 기록과 작업 메모리를 구분해야 한다.

대화 기록에는 필요 없는 내용이 너무 많다.

초기 추측
폐기된 아이디어
중복 설명
전체 빌드 로그
오래된 테스트 결과
이미 해결된 오류

예를 들어 한 버그를 고치는 동안 다음 일이 있었다고 하자.

1차 접근
→ Repository에 상태 추가
→ 아키텍처 위반으로 폐기

2차 접근
→ ViewModel guard 추가
→ 테스트 실패

3차 접근
→ 실패 상태 복구 추가
→ 테스트 통과

다음 세션에 필요한 것은 전체 대화가 아니다.

현재 채택된 접근
왜 1차 접근을 버렸는지
현재 변경 파일
마지막 테스트 결과
남은 위험

이 다섯 가지만 있으면 된다.

대화는 과정이고, 메모리는 결과다.


2. Durable Memory와 Run Ledger를 분리한다

둘은 목적이 다르다.

Durable Memory

여러 작업에서 재사용할 수 있는 장기 지식이다.

이 프로젝트의 아키텍처 규칙
이 팀이 금지하는 패턴
특정 모듈의 주의사항
반복적으로 발생한 장애 원인
검증된 해결 방식

예를 들면 이런 내용이다.

# memory/ios-concurrency.md

- UI 상태는 MainActor에서 변경한다.
- ViewModel이 Repository를 직접 생성하지 않는다.
- 비동기 제출 함수는 중복 진입을 guard로 막는다.
- 실패 후 재시도 가능 상태를 명시적으로 복구한다.
- 테스트를 실행하지 않았다면 통과했다고 보고하지 않는다.

이 정보는 다음 버그에서도 쓸 수 있다.


Run Ledger

현재 작업 하나를 이어가기 위한 실행 기록이다.

지금 어느 단계인지
누가 무엇을 했는지
어떤 파일이 바뀌었는지
무엇이 실패했는지
다음 담당자가 무엇을 해야 하는지

예를 들면 이런 구조다.

{
  "run_id": "run-20260713-001",
  "task": "checkout-duplicate-submit",
  "phase": "review",
  "current_agent": "reviewer",
  "attempt": 2,
  "last_successful_step": "implementation",
  "next_step": "review_diff",
  "changed_files": [
    "CheckoutViewModel.swift",
    "CheckoutViewModelTests.swift"
  ],
  "last_test": {
    "status": "passed",
    "command": "xcodebuild test ..."
  },
  "remaining_risks": [
    "서버 idempotency는 별도 작업"
  ]
}

Run Ledger는 프로젝트 전체 지식이 아니다.

현재 작업의 인수인계 문서다.


3. 가장 먼저 파일 구조를 만든다

실무에서는 다음 정도로 시작할 수 있다.

.ai/
├── memory/
│   ├── architecture.md
│   ├── coding-lessons.md
│   ├── failure-patterns.md
│   └── module-notes/
│       ├── payment.md
│       └── auth.md
│
├── runs/
│   └── checkout-duplicate-submit/
│       ├── ledger.json
│       ├── handoff.md
│       ├── latest-failure.json
│       ├── decisions.md
│       └── artifacts/
│           ├── plan.json
│           ├── diff.patch
│           ├── review.json
│           └── test-result.json
│
└── policies/
    ├── memory-policy.yaml
    └── compaction-policy.yaml

폴더 역할은 단순하다.

memory/
→ 여러 작업에서 다시 쓸 지식

runs/
→ 현재 작업의 실행 기록

artifacts/
→ 계획, diff, 리뷰, 테스트 증거

policies/
→ 무엇을 기억하고 무엇을 버릴지 규칙

4. memory-policy.yaml로 기억 기준을 정한다

AI에게 “중요한 건 기억해”라고 하면 기준이 흔들린다.

정책으로 정한다.

version: 1

durable_memory:
  allow:
    - architecture_rule
    - security_rule
    - verified_failure_pattern
    - verified_fix_pattern
    - module_constraint
    - team_workflow_rule

  deny:
    - raw_chat_history
    - unverified_guess
    - full_build_log
    - temporary_file_path
    - personal_opinion
    - duplicated_note

  require_evidence:
    - verified_failure_pattern
    - verified_fix_pattern

  evidence_sources:
    - passing_test
    - accepted_review
    - merged_diff
    - official_project_document

retention:
  architecture_rule: permanent
  module_constraint: permanent
  verified_fix_pattern: 180d
  temporary_workaround: 14d

핵심은 require_evidence다.

AI가 한 번 추측한 내용을 장기 기억으로 저장하면 안 된다.

추측
→ 세션 메모

테스트와 리뷰로 검증
→ Durable Memory 후보

5. 검증되지 않은 기억은 독이 된다

AI 메모리에서 가장 위험한 것은 틀린 기억이다.

예를 들어 AI가 이렇게 적었다고 하자.

PaymentRepository는 UI 상태를 관리한다.

실제로는 잘못된 아키텍처일 수 있다.

이 내용이 Durable Memory에 들어가면 다음 작업에서도 반복된다.

그래서 메모리는 상태를 가져야 한다.

id: memory-20260713-001
type: verified_fix_pattern
status: candidate
summary: "중복 제출은 ViewModel의 isSubmitting guard로 차단한다."
evidence:
  tests:
    - CheckoutViewModelTests.testDuplicateSubmit
  review:
    - review.json#issue-3
created_at: 2026-07-13T10:00:00+09:00
approved_by: null

검증이 끝나면 상태를 바꾼다.

status: approved
approved_by: human
approved_at: 2026-07-13T10:35:00+09:00

Durable Memory는 자동 저장소가 아니라 승인된 지식 저장소에 가깝다.


6. Run Ledger에는 사건만 남긴다

Run Ledger는 일기처럼 쓰면 안 된다.

사건 중심으로 기록한다.

{
  "events": [
    {
      "seq": 1,
      "type": "task_created",
      "at": "2026-07-13T09:00:00+09:00",
      "actor": "human",
      "artifact": "task.md"
    },
    {
      "seq": 2,
      "type": "plan_approved",
      "at": "2026-07-13T09:08:00+09:00",
      "actor": "human",
      "artifact": "plan.json"
    },
    {
      "seq": 3,
      "type": "implementation_completed",
      "at": "2026-07-13T09:22:00+09:00",
      "actor": "codex",
      "artifact": "implementation.json"
    },
    {
      "seq": 4,
      "type": "test_failed",
      "at": "2026-07-13T09:28:00+09:00",
      "actor": "test-runner",
      "artifact": "latest-failure.json"
    }
  ]
}

이 방식은 Event Sourcing과 비슷하다.

현재 상태만 저장하는 대신 어떤 일이 있었는지 순서대로 남긴다.

장점이 있다.

누가 상태를 바꿨는지 알 수 있다.
중간 단계로 되돌아갈 수 있다.
실패 시 마지막 성공 지점부터 재개할 수 있다.
다음 에이전트가 전체 대화를 읽지 않아도 된다.

7. Python으로 Run Ledger 만들기

import json
from dataclasses import asdict, dataclass
from datetime import datetime
from pathlib import Path
from typing import Any


@dataclass(frozen=True)
class LedgerEvent:
    seq: int
    type: str
    at: str
    actor: str
    payload: dict[str, Any]


class RunLedger:
    def __init__(self, path: Path) -> None:
        self.path = path

    def load(self) -> dict[str, Any]:
        if not self.path.exists():
            return {
                "version": 1,
                "events": [],
            }

        return json.loads(
            self.path.read_text(encoding="utf-8")
        )

    def append(
        self,
        *,
        event_type: str,
        actor: str,
        payload: dict[str, Any],
    ) -> LedgerEvent:
        ledger = self.load()
        events = ledger["events"]

        event = LedgerEvent(
            seq=len(events) + 1,
            type=event_type,
            at=datetime.now().astimezone().isoformat(),
            actor=actor,
            payload=payload,
        )

        events.append(asdict(event))

        self.path.write_text(
            json.dumps(
                ledger,
                ensure_ascii=False,
                indent=2,
            ),
            encoding="utf-8",
        )

        return event

사용은 단순하다.

ledger.append(
    event_type="implementation_completed",
    actor="codex",
    payload={
        "artifact": "implementation.json",
        "changed_files": [
            "CheckoutViewModel.swift",
            "CheckoutViewModelTests.swift",
        ],
    },
)

8. 현재 상태는 Ledger에서 재구성한다

사건 기록만 있으면 현재 상태를 다시 만들 수 있다.

from dataclasses import dataclass


@dataclass
class RunSnapshot:
    phase: str = "created"
    attempt: int = 0
    last_artifact: str | None = None
    last_failure: str | None = None
    waiting_human: bool = False


def reduce_ledger(
    ledger: dict,
) -> RunSnapshot:
    snapshot = RunSnapshot()

    for event in ledger["events"]:
        event_type = event["type"]
        payload = event["payload"]

        if event_type == "plan_approved":
            snapshot.phase = "implementing"
            snapshot.last_artifact = payload.get("artifact")

        elif event_type == "implementation_completed":
            snapshot.phase = "reviewing"
            snapshot.attempt += 1
            snapshot.last_artifact = payload.get("artifact")

        elif event_type == "review_passed":
            snapshot.phase = "testing"
            snapshot.last_artifact = payload.get("artifact")

        elif event_type == "test_failed":
            snapshot.phase = "failed_test"
            snapshot.last_failure = payload.get("artifact")

        elif event_type == "waiting_human":
            snapshot.phase = "waiting_human"
            snapshot.waiting_human = True

        elif event_type == "completed":
            snapshot.phase = "completed"

    return snapshot

프로세스가 종료돼도 ledger를 읽으면 어디서 재개할지 알 수 있다.


9. handoff.md는 다음 에이전트용 교대 문서다

Run Ledger는 기계가 읽기 좋다.

다음 에이전트에게는 더 짧은 인수인계 문서가 필요하다.

# Handoff

## Current Goal

결제 버튼 중복 탭으로 주문 생성 요청이 여러 번 실행되는 문제를 막는다.

## Current Phase

Fixing

## Last Completed Step

Claude Reviewer가 첫 번째 구현을 검토했다.

## Current Findings

- 중복 진입 guard는 정상이다.
- 실패 후 isSubmitting이 false로 복구되지 않는다.
- PaymentAPI 수정은 현재 범위 밖이다.

## Files Allowed to Edit

- CheckoutViewModel.swift
- CheckoutViewModelTests.swift

## Latest Failure

CheckoutViewModelTests.testSubmitOrder_whenFailed_allowsRetry

## Next Action

failure-context.json의 실패 한 건만 수정한다.

## Do Not Repeat

- PaymentRepository에 UI 상태를 추가하지 않는다.
- 전체 결제 구조를 리팩토링하지 않는다.

여기서 Do Not Repeat가 중요하다.

이미 실패한 접근을 다시 시도하지 않게 만든다.


10. handoff.md는 자동 생성할 수 있다

from pathlib import Path


def build_handoff(
    *,
    goal: str,
    phase: str,
    findings: list[str],
    allowed_files: list[str],
    latest_failure: str | None,
    next_action: str,
    rejected_approaches: list[str],
    output: Path,
) -> None:
    lines = [
        "# Handoff",
        "",
        "## Current Goal",
        "",
        goal,
        "",
        "## Current Phase",
        "",
        phase,
        "",
        "## Current Findings",
        "",
    ]

    lines.extend(f"- {item}" for item in findings)

    lines.extend([
        "",
        "## Files Allowed to Edit",
        "",
    ])

    lines.extend(f"- {path}" for path in allowed_files)

    lines.extend([
        "",
        "## Latest Failure",
        "",
        latest_failure or "None",
        "",
        "## Next Action",
        "",
        next_action,
        "",
        "## Do Not Repeat",
        "",
    ])

    lines.extend(
        f"- {item}" for item in rejected_approaches
    )

    output.write_text(
        "\n".join(lines),
        encoding="utf-8",
    )

새 세션은 전체 대화 대신 handoff.md와 최신 artifact만 읽는다.


11. 메모리는 쓰기보다 읽기 정책이 더 중요하다

메모리 파일이 많아지면 또 다른 문제가 생긴다.

AI가 모든 메모리를 읽기 시작한다.

architecture.md
coding-lessons.md
failure-patterns.md
payment.md
auth.md
analytics.md
network.md

작은 작업에 전부 넣으면 다시 Context Budget이 깨진다.

그래서 Memory Router가 필요하다.

routes:
  payment:
    include:
      - memory/architecture.md
      - memory/coding-lessons.md
      - memory/module-notes/payment.md

  auth:
    include:
      - memory/architecture.md
      - memory/security-rules.md
      - memory/module-notes/auth.md

  ui:
    include:
      - memory/architecture.md
      - memory/ui-patterns.md

현재 작업과 관련된 메모리만 읽는다.


12. Memory Router 구현 예시

from pathlib import Path

import yaml


class MemoryRouter:
    def __init__(
        self,
        *,
        root: Path,
        policy_file: Path,
    ) -> None:
        self.root = root
        self.policy = yaml.safe_load(
            policy_file.read_text(encoding="utf-8")
        )

    def files_for(
        self,
        domain: str,
    ) -> tuple[Path, ...]:
        route = self.policy["routes"].get(domain)

        if route is None:
            return tuple()

        return tuple(
            self.root / relative
            for relative in route.get("include", [])
        )

13. 메모리 Compaction은 요약이 아니라 정리다

에이전트 작업이 길어지면 로그와 산출물이 계속 쌓인다.

attempt-1.log
attempt-2.log
attempt-3.log
review-1.json
review-2.json
diff-1.patch
diff-2.patch

모두 지우면 감사 기록이 사라진다.

모두 컨텍스트에 넣으면 토큰이 낭비된다.

그래서 저장과 주입을 분리한다.

디스크에는 원본 보관
컨텍스트에는 요약본만 주입

compaction-policy.yaml 예시는 다음과 같다.

version: 1

inject:
  - current_handoff
  - current_plan
  - current_diff_stat
  - latest_failure
  - approved_memory

summarize:
  - previous_attempts
  - previous_reviews
  - long_tool_outputs

archive_only:
  - full_build_logs
  - previous_diffs
  - raw_agent_transcripts

limits:
  max_injected_files: 10
  max_log_tail_characters: 6000
  max_previous_attempts: 3

14. Durable Memory 후보는 작업 종료 시 추출한다

작업이 끝났다고 모든 내용을 기억할 필요는 없다.

종료 시점에 후보만 추출한다.

{
  "memory_candidates": [
    {
      "type": "verified_fix_pattern",
      "summary": "비동기 제출 함수는 ViewModel에서 중복 진입을 막고 실패 후 상태를 복구한다.",
      "evidence": [
        "CheckoutViewModelTests.testDuplicateSubmit",
        "CheckoutViewModelTests.testRetryAfterFailure"
      ]
    },
    {
      "type": "module_constraint",
      "summary": "PaymentAPI 변경은 payment-owner 승인이 필요하다.",
      "evidence": [
        "protected-files.yaml"
      ]
    }
  ]
}

후보는 자동 승인하지 않는다.


15. Memory Promotion Gate를 둔다

Eval Gate처럼 메모리에도 승격 단계가 필요하다.

Candidate
→ Review
→ Approved
→ Durable Memory

승격 조건을 정한다.

promotion:
  require:
    - evidence
    - no_conflict_with_existing_memory
    - accepted_review
    - human_approval_for_architecture_rules

  reject_if:
    - unverified_guess
    - one_off_workaround
    - user_specific_secret
    - duplicated_memory
    - stale_rule

특히 아키텍처 규칙은 사람 승인을 받는 편이 좋다.


16. 오래된 메모리는 자동으로 의심해야 한다

모델과 프로젝트는 계속 바뀐다.

예전에 맞던 규칙이 지금은 틀릴 수 있다.

이 프로젝트는 UIKit만 사용한다.

몇 달 뒤 SwiftUI가 도입되면 오래된 기억이 된다.

따라서 메모리에 날짜와 유효 범위를 둔다.

id: memory-architecture-014
status: approved
summary: "Checkout 화면 상태는 ViewModel이 소유한다."
scope:
  - "Sources/Checkout/**"
created_at: 2026-07-13
review_after: 2026-10-13
source:
  - architecture.md

review_after가 지나면 다시 검토한다.


17. Conflict Resolver를 둔다

기존 메모리와 새 후보가 충돌할 수 있다.

기존:
Repository가 재시도 정책을 관리한다.

신규:
UseCase가 재시도 정책을 관리한다.

AI가 둘 중 하나를 임의로 선택하게 하지 않는다.

{
  "conflict": true,
  "existing_memory": "memory-021",
  "candidate_memory": "candidate-104",
  "resolution": "waiting_human",
  "reason": "아키텍처 책임 변경에 해당합니다."
}

메모리 충돌은 단순 최신값 우선으로 처리하면 위험하다.


18. AI 코딩 에이전트용 최소 구성

처음부터 복잡하게 만들 필요는 없다.

최소 버전은 이 정도면 된다.

.ai/
├── memory/
│   ├── architecture.md
│   └── lessons.md
└── runs/
    └── current/
        ├── ledger.json
        ├── handoff.md
        ├── latest-failure.json
        └── artifacts/

운영 순서는 다음과 같다.

1. 작업 시작 시 ledger 생성
2. 주요 단계마다 event 기록
3. 실패 시 latest-failure.json 저장
4. 세션 종료 전 handoff.md 생성
5. 새 세션은 handoff와 최신 artifact만 읽기
6. 작업 완료 후 memory candidate 추출
7. 사람이 승인한 것만 memory에 반영

19. Agent Runtime에 연결하면 이렇게 된다

Task
  ↓
Load Approved Memory
  ↓
Load Current Handoff
  ↓
Run Agent
  ↓
Append Ledger Event
  ↓
Save Artifacts
  ↓
Compact Context
  ↓
Generate Next Handoff
  ↓
Continue or Pause

작업이 끝나면 다음 흐름이 추가된다.

Completed Run
  ↓
Extract Memory Candidates
  ↓
Memory Promotion Gate
  ↓
Approved Durable Memory

20. Run Ledger가 있으면 무엇이 달라지나

세션이 끊겨도 이어갈 수 있다

마지막 성공 단계와 다음 작업이 남아 있다.

다른 모델로 바꿔도 이어갈 수 있다

Claude가 하던 작업을 Codex가 인수할 수 있다.

같은 실수를 반복하지 않는다

폐기된 접근을 Do Not Repeat에 남긴다.

사람 리뷰가 쉬워진다

전체 대화가 아니라 사건과 산출물을 본다.

실패 원인을 비교할 수 있다

여러 실행의 Ledger를 분석해 반복 장애를 찾을 수 있다.


21. 메모리 시스템을 망치는 방식

모든 대화를 저장한다

저장과 컨텍스트 주입은 다르다.

AI가 쓴 메모리를 바로 신뢰한다

후보와 승인 상태를 분리해야 한다.

일회성 우회책을 장기 기억으로 올린다

Temporary workaround에는 만료일이 필요하다.

모든 메모리를 매번 읽힌다

작업 도메인별 Memory Router가 필요하다.

오래된 규칙을 영구 보존한다

검토 날짜와 scope가 필요하다.

실패 로그를 메모리로 저장한다

로그는 artifact이고, 검증된 교훈만 memory다.


22. 개발자가 왜 이걸 알아야 할까

AI 에이전트가 짧은 작업만 한다면 메모리 시스템은 필요 없다.

하지만 작업 시간이 길어질수록 문제가 달라진다.

여러 세션
여러 모델
여러 subagent
여러 번의 실패
사람 승인 대기
다음 날 재개

이때 중요한 것은 모델의 지능이 아니다.

작업을 잃지 않는 구조다.

앞으로 개발자는 다음을 설계하게 된다.

무엇을 장기 기억으로 남길지
무엇을 현재 실행 기록으로만 보관할지
어떤 기억은 사람 승인이 필요한지
세션이 끊기면 어디서 재개할지
오래된 기억을 언제 폐기할지
다른 모델이 작업을 어떻게 이어받을지

이건 단순한 프롬프트 기술이 아니다.

데이터 모델링과 이벤트 설계에 가깝다.

Memory
→ 승인된 지식

Ledger
→ 실행 이력

Artifact
→ 증거

Handoff
→ 인수인계

Checkpoint
→ 재개 지점

23. 마무리

AI 에이전트에게 필요한 것은 모든 것을 기억하는 능력이 아니다.

필요한 것만 남기고, 다음 작업자가 바로 이어갈 수 있게 만드는 능력이다.

대화 기록을 길게 유지하는 것
≠
좋은 메모리

좋은 메모리는 다음 조건을 만족한다.

검증된 내용만 남는다.
작업별 기록과 장기 지식이 분리된다.
오래된 정보는 다시 검토된다.
새 세션은 전체 대화가 아니라 handoff를 읽는다.
작업 상태는 ledger로 복구할 수 있다.

한 줄로 정리하면 이렇다.

AI 에이전트의 진짜 기억력은
얼마나 많이 저장하느냐가 아니라,

다음 실행에 필요한 상태와 교훈을
얼마나 정확하게 남기느냐에서 결정된다.

모델은 세션을 잊어도 된다.

Runtime이 기억하면 된다.

profile
iOS 앱 개발자

0개의 댓글