요약이 지운 것을 붙잡다 — 에이전트에게 메모리를 주기

2026-04-06

3편에서 불어나는 대화를 요약해 접었습니다. 그런데 그 편 끝에 정직하게 남긴 문제가 있었어요 — 요약은 손실 압축이라, 오래된 대화를 한 문단으로 줄이면 디테일이 날아갑니다. 흐름(무슨 얘기를 나눴나)이야 줄여도 되지만, 정말 잃으면 안 되는 사실(작업할 파일 경로, 확정된 결정, 사용자가 알려 준 이름)까지 요약에 맡기면 위험하죠. 요약이 그걸 빠뜨리는 순간 에이전트는 "그걸 모르는" 상태가 되니까요.

규칙 — 잃으면 안 되는 사실은 요약에 맡기지 마라. 에이전트가 대화 밖에 적고 읽을 메모리를 줘라.

이번 편에 그걸 붙잡습니다. 요약이 지우더라도 남는 자리 — 구조화된 메모리를 만들고, 에이전트가 스스로 쓰고 읽게 합니다. Claude Code가 CLAUDE.md나 메모리에 사실을 적어 두는 것과 같은 발상이에요.

💻 코드는 github.com/kahnco/agent-from-scratch에 이어집니다. 병렬로 불려도 안전한지(-race)까지 검증합니다.

대화 밖에 사는 저장소

핵심은 메모리를 대화(messages) 밖에 두는 것입니다. 대화는 3편에서 접히지만, 이 저장소는 접히지 않아요. 그냥 key-value 맵입니다.

// 대화가 요약돼 접혀도(3편) 여기 적은 건 남는다 — 손실되면 안 되는 사실·결정의 자리.
type Memory struct {
	mu    sync.Mutex        // 왜 필요한지는 아래에서
	store map[string]string
}

func (m *Memory) Set(key, value string) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.store[key] = value
}

그리고 이걸 도구로 에이전트에게 노출합니다. 2편에서 "부작용 있는 도구는 조심해야 한다"고 했는데, memory_write가 바로 그 첫 실전 예예요 — 읽기만 하던 calculator·word_count와 달리, 이 도구는 상태를 바꿉니다.

func MemoryWriteTool(mem *Memory) LocalTool {
	return LocalTool{
		Description: "나중에도 필요한 사실·결정·맥락을 메모리에 적어 둔다. 대화가 요약돼 접혀도 남는다.",
		Schema:      json.RawMessage(`{ ...key, value... }`),
		Run: func(input json.RawMessage) (string, error) {
			var in struct{ Key, Value string }
			// ... 파싱 ...
			mem.Set(in.Key, in.Value)
			return "저장함: " + in.Key, nil
		},
	}
}

memory_read는 그 짝인 읽기 전용 도구고요. 이제 모델은 "이건 나중에 필요하겠다" 싶은 걸 memory_write로 적고, 필요할 때 memory_read로 꺼냅니다. 무엇을 적을지는 모델이 정합니다 — 도구 설명으로 "나중에 필요한 사실을 적어 두라"고 안내할 뿐이죠.

규칙 회수 — 메모리는 대화 밖 key-value 저장소다. 쓰기·읽기를 도구로 노출하면, 무엇을 기억할지는 모델이 스스로 정한다. 쓰기는 부작용 도구다.

적어 뒀다는 걸, 접힌 뒤에도 알게 하기

여기 미묘한 문제가 하나 있습니다. 메모리에 적어는 뒀는데, 대화가 접히면(3편) 모델은 자기가 뭘 적어 뒀는지조차 잊어버립니다. "적었다"는 사실 자체가 요약돼 사라질 수 있으니까요. 그러면 메모리가 있어도 안 꺼내 쓰죠.

그래서 적어 둔 키 목록을 매 스텝 시스템 프롬프트에 주입합니다. 대화가 아무리 접혀도, 시스템 프롬프트는 매번 새로 만들어 붙이니 항상 보여요.

// 매 스텝 다시 만들어, 방금 적은 키도 다음 호출부터 모델 눈에 보이게 한다.
func (a *Agent) systemPrompt() string {
	if a.Memory == nil {
		return a.System
	}
	keys := a.Memory.Keys()
	if len(keys) == 0 {
		return a.System
	}
	return a.System + "\n\n[메모리에 적어 둔 항목] " + strings.Join(keys, ", ") +
		"\n(값이 필요하면 memory_read 로 읽어라. 나중에 필요할 사실은 memory_write 로 적어 둬라.)"
}

키의 목록만 넣고 값은 안 넣는 게 포인트입니다. 값까지 다 넣으면 그게 또 컨텍스트를 먹어 3편의 문제로 되돌아가거든요. "이런 항목들이 있다"만 보여 주고, 실제 값이 필요하면 memory_read로 꺼내게 하는 거죠. 이렇게 하면 대화는 접혀 가벼워도, 모델은 "user_name, api_key_location을 적어 뒀지"를 늘 알고 필요할 때 꺼냅니다. "적어 둔 키가 이후 요청의 시스템 프롬프트에 실제로 실려 나가는지"를 테스트로 못 박았어요.

규칙 회수 — 적어 둔 키 목록을 매 스텝 시스템 프롬프트에 주입해, 대화가 접혀도 모델이 메모리의 존재를 안다. 값이 아니라 목록만 — 값은 필요할 때 읽게 한다.

함정 — 병렬로 적으면 map이 터진다

이번 편에서 제일 재미있는 함정은 2편과의 교차점에 있습니다. 2편에서 우리는 한 턴의 여러 도구 호출을 goroutine으로 병렬 실행하게 만들었죠. 그런데 모델이 한 턴에 memory_write두 개 부르면 어떻게 될까요?

// runToolsParallel — 2편. 각 tool_use 를 goroutine 으로 동시에 실행한다.
go func(i int, call ContentBlock) {
	results[i] = a.runTool(call) // 여기서 mem.Set 이 동시에 불린다
}(i, call)

두 goroutine이 동시에 mem.Set을 호출합니다 — 즉 같은 map에 동시에 씁니다. Go에서 map 동시 쓰기는 정의되지 않은 동작이라, 운 나쁘면 fatal error: concurrent map writes로 프로그램이 통째로 죽어요. 이게 병렬 실행(2편)과 상태 있는 도구(4편)를 함께 쓸 때 생기는, 놓치기 쉬운 버그입니다.

해법은 위에서 이미 봤습니다 — Memorysync.Mutex예요. Set/Get/Keys가 모두 락을 잡으니, 두 goroutine이 동시에 와도 한 번에 하나씩 안전하게 처리됩니다. 말로 "안전하다"가 아니라, 한 턴에 두 개의 memory_write를 병렬로 실행하는 테스트를 go test -race로 돌려 경합 감지기가 침묵하는지로 증명했어요. mutex를 지우면 이 테스트가 바로 빨간불이 됩니다.

규칙 회수 — 병렬 실행(2편) + 상태 있는 도구(4편)는 데이터 레이스의 씨앗이다. 공유 상태는 mutex로 감싸고, -race로 증명하라.

정직하게 — 메모리는 요약을 대체하지 않는다

메모리를 붙였다고 3편의 요약이 필요 없어지는 게 아닙니다. 둘은 역할이 다릅니다.

  • 요약은 흐름, 메모리는 사실. 요약(3편)은 "무슨 대화가 오갔나"라는 흐름을 손실 압축해 컨텍스트를 가볍게 합니다. 메모리(4편)는 "절대 잃으면 안 되는 사실"을 무손실로 붙잡습니다. 요약만 있으면 디테일이 새고, 메모리만 있으면 대화가 여전히 불어나요. 둘을 같이 써야 긴 작업을 가볍고도 정확하게 버팁니다.
  • 메모리도 무한하지 않다. 키 목록을 시스템 프롬프트에 주입하니, 메모리를 수천 개 적으면 그 목록 자체가 컨텍스트를 먹습니다. 정말 큰 지식은 인메모리 맵이 아니라 바깥 저장소(파일·DB·벡터 검색)에 두고, 도구로 "검색"하게 하는 게 다음 수순이에요. 지금 건 개념을 드러내는 최소 골격입니다.
  • 무엇을 적을지는 모델이 정한다 — 그래서 불완전하다. 모델이 "이건 중요하다"고 판단해야 적으니, 놓칠 수도 있고 쓸데없는 걸 적을 수도 있습니다. 도구 설명과 시스템 프롬프트로 "무엇을 언제 적어야 하는지"를 잘 안내하는 게 품질을 좌우해요. 정말 결정적인 사실(예: 사용자 ID)은 모델의 판단에 맡기지 말고 우리 코드가 직접 메모리에 넣어 두는 게 안전합니다.
  • 영속성은 여기서 끝나지 않는다. 지금 Memory는 프로세스가 죽으면 사라지는 인메모리 맵입니다. 진짜 "기억"이 되려면 파일이나 DB로 세션을 넘어 살아남아야 하죠 — 인터페이스는 그대로 두고 구현만 갈아 끼우면 됩니다(Flutter 실전 4편에서 도메인을 안 고치고 저장소만 sqflite로 바꿨던 것과 같은 결이에요).

이 넷을 알고 쓰면, 메모리는 "긴 작업에서 사실을 흘리지 않는" 든든한 짝이 됩니다. 이번 편으로 에이전트가 오래 돌아도, 접혀도, 중요한 걸 붙잡는 수준이 됐어요.

정리 — 요약이 지운 것을 메모리가 붙잡는다

  • 문제: 요약(3편)은 손실 압축이라 잃으면 안 되는 사실까지 지울 수 있다.
  • 메모리: 대화 밖 key-value 저장소를 만들고, memory_write(부작용 도구)·memory_read로 노출해 모델이 스스로 적고 읽게 한다.
  • 주입: 적어 둔 키 목록을 매 스텝 시스템 프롬프트에 넣어, 대화가 접혀도 메모리의 존재를 알게 한다(값이 아니라 목록만).
  • 함정: 병렬 실행(2편) + 상태 도구(4편) = 레이스. mutex로 감싸고 -race로 증명.
  • 정직하게: 요약(흐름)과 메모리(사실)는 역할이 다르고 함께 쓴다. 메모리도 예산이 필요하고, 무엇을 적을지는 모델이 정해 불완전하며, 진짜 영속엔 바깥 저장소가 필요하다.

이번 편까지로 에이전트의 뼈대가 얼추 섰습니다 — 루프(1편), 도구와 예산(2편), 컨텍스트(3편), 메모리(4편). 다음 편에서는 지금까지 우리가 직접 짠 이 골격을, Anthropic이 공식으로 내놓은 도구(SDK의 Tool Runner, Managed Agents)와 나란히 놓고 견줍니다 — 무엇을 직접 짜고 무엇을 맡길지의 이야기입니다.

핵심 한 줄 — 요약은 흐름을 접고, 메모리는 사실을 붙잡는다. 둘은 대체가 아니라 짝이다. 그리고 병렬로 쓰이는 공유 상태는 반드시 mutex로 감싸라 — -race가 증명해 준다.