이 글은 Claude Fable 5.1 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.


마지막 편입니다. 19장 “Software Trends” 에서 저자는 지난 수십 년의 유행을 복잡성이라는 하나의 잣대로 평가합니다. 20장은 성능이 좋은 설계와 충돌하지 않는다는 주장이고, 2판에서 추가된 21장은 책 전체를 “무엇이 중요한가” 라는 질문 하나로 되감습니다.


1. 소프트웨어 유행 (19장)#

19장의 태도는 마지막 절에 요약되어 있습니다.

“Whenever you encounter a proposal for a new software development paradigm, challenge it from the standpoint of complexity: does the proposal really help to minimize complexity in large software systems?” (19.7절)

1.1 구현 상속 — Go 에는 없지만 흉내 낼 수는 있다#

원칙. 인터페이스 상속은 추상화를 깊게 하지만, 구현 상속은 부모와 자식 사이에 의존성을 만듭니다.

책은 상속을 둘로 나눕니다. 인터페이스 상속 은 부모가 시그니처만 정하고 자식들이 각자 구현하는 것입니다. 같은 인터페이스에 구현이 많을수록 그 인터페이스는 깊어지고, 한 구현에서 배운 것을 다른 구현에 그대로 쓸 수 있습니다. 구현 상속 은 부모가 기본 구현까지 주고 자식이 골라서 덮어쓰는 것입니다. 코드 중복은 줄지만, 부모의 필드를 부모와 자식이 같이 만지면서 정보가 누출되고, 부모를 고치려면 모든 자식을 봐야 하며, 자식이 메서드를 덮어쓰려면 부모의 구현을 봐야 합니다. 최악의 경우 계층 전체를 알아야 어느 하나를 고칠 수 있습니다.

Go 에는 상속이 없습니다. 인터페이스 상속에 해당하는 것은 그냥 인터페이스이고, Go 의 인터페이스는 암묵적으로 만족되니 “상속” 이라는 말조차 필요 없습니다. 구현 상속에 해당하는 것은 없는데, struct embedding 으로 흉내 내는 코드가 흔합니다. 그리고 그 흉내는 책이 경고하는 문제를 그대로 재현하면서 한 가지를 더 얹습니다.

Before.

// BaseStore 는 저장 공통 기능을 제공하는 "부모" 입니다.
type BaseStore struct {
	saved []string // 자식도 이 필드를 직접 만진다
}

// Save 는 항목 하나를 저장합니다. 자식이 덮어쓰기를 기대합니다.
func (b *BaseStore) Save(item string) { b.saved = append(b.saved, item) }

// SaveAll 은 여러 항목을 저장합니다. 내부에서 Save 를 부릅니다.
func (b *BaseStore) SaveAll(items []string) {
	for _, it := range items {
		b.Save(it)
	}
}

// AuditedStore 는 BaseStore 를 "상속" 해서 Save 를 덮어씁니다.
type AuditedStore struct {
	BaseStore
	log []string
}

// Save 는 저장하면서 감사 로그를 남깁니다.
func (a *AuditedStore) Save(item string) {
	a.log = append(a.log, "save "+item)
	a.saved = append(a.saved, item) // 부모의 필드를 직접 조작
}

무엇이 문제인가. 책이 말한 문제가 둘 다 있습니다. AuditedStore.Save 가 a.saved 를 직접 만지니 BaseStore 의 저장 방식이 바뀌면 자식도 깨집니다(정보 누출). AuditedStore.Save 를 쓰려면 BaseStore.Save 가 무엇을 하는지 알아야 합니다(부모 구현에 대한 의존).

그리고 Go 만의 함정이 있습니다. a.SaveAll(...) 을 부르면 BaseStore.SaveAll 이 실행되고, 그 안의 b.Save(it) 은 BaseStore.Save 를 부릅니다. AuditedStore.Save 가 아닙니다. Go 의 embedding 은 메서드를 승격 시킬 뿐 가상 디스패치가 없습니다. 테스트로 확인하면 Save("x") 뒤에 SaveAll([]string{"y", "z"}) 를 불렀을 때 저장은 셋인데 감사 로그는 하나입니다. Java 나 C++ 에서 상속을 배운 사람이 가장 자주 놀라는 지점이고, 감사 로그가 조용히 빠지는 종류의 버그라 발견도 늦습니다.

After. 책의 처방은 “합성으로 같은 이득을 얻을 수 없는지 먼저 보라” 입니다. Go 에서는 그것이 기본 문법입니다.

// Store 는 항목을 저장합니다. 여러 구현이 있을 수 있습니다.
type Store interface {
	Save(item string)
}

// MemStore 는 메모리에 저장합니다.
type MemStore struct{ saved []string }

func (m *MemStore) Save(item string) { m.saved = append(m.saved, item) }

// SaveAll 은 어떤 Store 에든 여러 항목을 저장합니다. 상속이 아니라 인터페이스로 재사용합니다.
func SaveAll(s Store, items []string) {
	for _, it := range items {
		s.Save(it)
	}
}

// Audited 는 다른 Store 를 감싸서 감사 로그를 남깁니다. 내부 필드를 만지지 않습니다.
type Audited struct {
	Inner Store
	Log   []string
}

func (a *Audited) Save(item string) {
	a.Log = append(a.Log, "save "+item)
	a.Inner.Save(item)
}

무엇이 달라졌나. SaveAll 은 Store 인터페이스를 받으므로 Audited 를 넘기면 Audited.Save 가 불립니다. 테스트에서 저장 셋, 로그 셋입니다. Audited 는 MemStore 의 필드를 모르고, MemStore 는 Audited 의 존재를 모릅니다. 부모를 고치려고 자식을 볼 일도, 자식을 고치려고 부모를 볼 일도 없습니다.

Embedding 이 나쁘다는 말이 아닙니다. sync.Mutex 를 embedding 해서 Lock/Unlock 을 승격시키거나, 인터페이스를 embedding 해서 일부 메서드만 다시 정의하는 것은 관용구입니다. 문제는 embedding 을 “부모의 메서드가 내 덮어쓴 메서드를 부를 것” 이라는 기대 로 쓸 때입니다. 그 기대는 성립하지 않고, 그렇게 쓰고 싶어지는 순간이 책이 말하는 구현 상속의 유혹입니다. 3편의 데코레이터 논의와 같은 결론입니다. 감싸되, 감싸는 대상의 인터페이스를 통해서만 말합니다.

1.2 애자일과 TDD#

19.2절과 19.4절은 이 책에서 가장 자주 인용되고 가장 자주 반박되는 부분입니다. 저자의 주장을 그대로 옮기고, 그다음에 반론을 붙이겠습니다.

애자일에 대해 저자는 점진적·반복적 개발이라는 핵심에는 동의합니다. 시스템을 처음부터 다 설계할 수 없으니 조금씩 만들면서 추상화를 다듬는 것은 이 책의 방법이기도 합니다. 저자가 우려하는 것은 애자일이 개발자를 기능 에 집중시키고 설계 결정을 미루게 해서 전술적 프로그래밍으로 흐른다는 것입니다. 처방은 이렇습니다.

“Developing incrementally is generally a good idea, but the increments of development should be abstractions, not features.” (19.2절)

TDD 에 대해서는 더 직접적입니다.

“Although I am a strong advocate of unit testing, I am not a fan of test-driven development. The problem with test-driven development is that it focuses attention on getting specific features working, rather than finding the best design. This is tactical programming pure and simple, with all of its disadvantages.” (19.4절)

테스트를 하나씩 통과시키다 보면 설계할 시점이 없고, 다음 테스트를 통과시키려고 땜질하고 싶어진다는 것입니다. 추상화가 필요하다는 것을 알게 되면 조각조각 만들지 말고 한 번에 설계하라는 것이 저자의 대안입니다. 단, 저자가 테스트를 먼저 쓰는 것이 맞다고 인정하는 경우가 하나 있습니다. 버그 수정 입니다. 고치기 전에 그 버그로 실패하는 테스트를 쓰고, 고친 뒤 통과하는 것을 확인해야 정말 고쳤는지 알 수 있습니다. 1편의 Count("") 가 그렇게 잡혔습니다.

이 시리즈가 덧붙일 반론은 두 가지입니다.

첫째, 저자가 비판하는 TDD 는 “테스트 하나 → 코드 조금” 만 있고 리팩터링이 없는 TDD 입니다. Kent Beck 이 정의한 순환은 red → green → refactor 이고, 세 번째 단계가 저자가 “설계할 시점이 없다” 고 말한 바로 그 시점입니다. 리팩터링 단계를 진지하게 밟는 TDD 는 저자의 비판에서 상당 부분 비켜갑니다. 저자의 비판이 정확히 맞는 것은 그 단계를 건너뛰는 TDD 이고, 그런 TDD 가 드물지 않다는 것도 사실입니다.

둘째, 이 책의 15장과 TDD 는 생각보다 가깝습니다. 15장은 인터페이스 주석을 코드보다 먼저 쓰라고 합니다. 주석이 길어지면 설계가 나쁜 것이라는 카나리아입니다. 테스트를 먼저 쓰는 것도 같은 종류의 카나리아입니다. 테스트가 쓰기 어렵고, 준비 코드가 길고, 여러 객체를 조립해야 한다면 인터페이스가 나쁜 것입니다. 6편의 Search(q, tag, limit, sortBy, desc, includeArchived) 는 주석으로도 테스트로도 설명하기 어려웠을 것입니다. 저자가 주석에서 찾은 신호를 TDD 실천자는 테스트에서 찾습니다. 차이는 어느 쪽이 먼저냐이고, 둘 다 하는 것을 막는 규칙은 없습니다.

Go 에서 이 논쟁은 조금 다르게 보입니다. 표 기반 테스트(table-driven test)는 “테스트 하나 → 코드 조금” 의 리듬과 잘 맞지 않고, 함수의 계약 을 입력·출력 표로 먼저 적는 쪽에 가깝습니다. 5편의 Snippet 테스트가 그 모양이었습니다. 다섯 개의 경계 조건이 표 하나에 있고, 그 표가 곧 “인덱스가 start 이상 end 미만인 글자들” 이라는 인터페이스 주석의 검증입니다. 그 표를 코드보다 먼저 쓰는 것과 주석을 먼저 쓰는 것은 같은 일입니다.

1.3 디자인 패턴과 getter/setter#

19.5절의 요지는 짧습니다. 디자인 패턴은 대체로 좋지만, 가장 큰 위험은 과잉 적용 입니다. 맞지 않는 문제에 패턴을 억지로 끼우면 직접 설계한 것보다 복잡해집니다. “패턴은 좋다” 가 “패턴이 많을수록 좋다” 를 뜻하지 않습니다.

19.6절의 getter/setter 는 Java 문화에 대한 것이지만 Go 에도 옮겨 옵니다.

// Before: 필드마다 한 줄짜리 메서드 두 개
func (n *Note) GetTitle() string  { return n.title }
func (n *Note) SetTitle(t string) { n.title = t }

// After: 그냥 데이터면 필드로, 규칙이 있는 변경만 메서드로
type Note struct {
	Title string
	Body  string
}

// Rename 은 제목을 바꿉니다. 빈 제목은 거부합니다.
func (n *Note) Rename(title string) error { /* 검증 후 대입 */ }

책의 논리는 이렇습니다. getter/setter 는 얕은 메서드입니다. 한 줄짜리인데 인터페이스를 둘씩 늘립니다. 그리고 그것이 필요하다는 것 자체가 내부 변수를 노출하고 있다는 뜻이니, 노출하지 않는 편이 낫습니다. 정말 그냥 데이터라면 Go 에서는 exported 필드가 관용구이고(http.Request.Method, time.Time 은 반대로 전부 숨김), 변경에 규칙이 있다면 Rename 처럼 규칙의 이름 을 가진 메서드가 됩니다. SetTitle 은 그 중간의 어정쩡한 자리입니다.


2. 성능을 위한 설계 (20장)#

“The most important idea is still simplicity: not only does simplicity improve a system’s design, but it usually makes systems faster.” (20장 도입부)

20장의 구조는 셋입니다. 무엇이 비싼지 알고 설계하라(20.1), 고치기 전에 재라(20.2), 그래도 느리면 핵심 경로 를 중심으로 다시 설계하라(20.3). 그리고 RAMCloud 의 Buffer 클래스를 그렇게 고쳐 2배 빨라진 예제가 20.4 입니다.

2.1 핵심 경로 — Buffer 예제를 Go 로#

원칙. 가장 흔한 경우에 실행되어야 하는 최소한의 코드를 먼저 그리고, 그 주위로 설계합니다.

책의 예제는 이렇습니다. RAMCloud 의 Buffer 는 불연속 메모리 청크로 이루어진 바이트 배열입니다. 가장 흔한 연산은 작은 내부 청크에 공간을 확보하는 것이고, 원래 코드는 그 경로가 세 계층(alloc → allocateAppend → Allocation::allocateAppend)에 걸쳐 있었으며 특수 경우 검사가 여섯 번이었습니다. 고친 코드는 메서드 하나에서 extraAppendBytes 라는 변수 하나를 검사해 흔한 경우를 처리하고, 나머지는 경로 밖으로 뺐습니다. 결과는 1바이트 추가가 8.8ns 에서 4.75ns 로, 그리고 클래스가 20% 작아졌습니다.

Before. 세 계층과 여섯 검사를 Go 로 옮겼습니다.

// Alloc 은 내부 청크에 n 바이트를 확보해 그 슬라이스를 돌려줍니다.
func (b *Buffer) Alloc(n int) []byte {
	data := b.allocateAppend(n)
	if data == nil { // 검사 1
		b.allocations = append(b.allocations, make([]byte, 0, max(n, allocSize)))
		data = b.allocateAppend(n)
	}
	// 마지막 청크와 인접하면 합친다
	if len(b.chunks) > 0 { // 검사 2
		last := &b.chunks[len(b.chunks)-1]
		if last.internal && adjacent(last.data, data) { // 검사 3, 4
			last.data = last.data[:len(last.data)+n]
			b.totalLength += n
			return data
		}
	}
	b.chunks = append(b.chunks, chunk{data: data, internal: true})
	b.totalLength += n
	return data
}

func (b *Buffer) allocateAppend(n int) []byte {
	if len(b.allocations) == 0 { // 검사 5
		return nil
	}
	return allocation{b.allocations[len(b.allocations)-1]}.allocateAppend(b, n)
}

func (a allocation) allocateAppend(b *Buffer, n int) []byte {
	if cap(a.buf)-len(a.buf) < n { // 검사 6
		return nil
	}
	/* 마지막 allocation 을 n 만큼 늘리고 그 조각을 돌려준다 */
}

After. 검사 하나로 흔한 경우를 가려내고, 나머지는 allocSlow 로 보냅니다.

// Buffer 는 불연속 청크로 이루어진 바이트 배열입니다. 가장 흔한 연산(작은 내부 청크 추가)을
// 중심으로 설계했습니다. extraAppendBytes 는 마지막 청크 바로 뒤에 남은 미사용 공간입니다.
// 마지막 청크가 내부 청크가 아니거나 청크가 없으면 0 입니다.
type Buffer struct {
	chunks           []chunk
	allocations      [][]byte
	extraAppendBytes int
	totalLength      int
}

// Alloc 은 내부 청크에 n 바이트를 확보해 그 슬라이스를 돌려줍니다.
func (b *Buffer) Alloc(n int) []byte {
	if n <= b.extraAppendBytes { // 흔한 경우: 검사 한 번
		last := &b.chunks[len(b.chunks)-1]
		old := len(last.data)
		last.data = last.data[:old+n]
		b.extraAppendBytes -= n
		b.totalLength += n
		return last.data[old:]
	}
	return b.allocSlow(n)
}

// allocSlow 는 드문 경우를 처리합니다. 성능보다 단순함을 우선합니다.
func (b *Buffer) allocSlow(n int) []byte { /* 새 allocation 과 청크를 만든다. 7줄 */ }

2.2 측정 먼저 — 그리고 결과를 그대로 적는다#

원칙. 고치기 전에 재고, 고친 뒤에 다시 재서, 측정 가능한 차이가 없으면 되돌립니다.

책의 20.2절은 이렇게 시작합니다. 성능에 대한 프로그래머의 직관은 믿을 수 없고, 경험 많은 개발자도 마찬가지입니다. 그래서 고치기 전에 재야 합니다. 어디가 느린지 알기 위해서, 그리고 고친 뒤에 비교할 기준선을 갖기 위해서.

“If the changes didn’t make a measurable difference in performance, then back them out (unless they made the system simpler). There’s no point in retaining complexity unless it provides a significant speedup.” (20.2절)

두 버전을 Go 의 testing.B 로 쟀습니다. 1바이트를 4,000번 추가하는 것을 한 반복으로 잡아, 청크 하나를 만드는 할당 비용이 분산되게 했습니다. 다섯 번씩 돌린 결과입니다.

버전4,000회 추가 (ns/op)추가 1회당
Before (세 계층, 검사 여섯)8,911 ~ 9,117약 2.2 ns
After (검사 하나)7,936 ~ 8,231약 2.0 ns

측정 환경은 Apple M4 Max, go1.26.0 darwin/arm64 입니다. 두 버전 모두 반복당 할당은 3회, 4,152 바이트로 같습니다.

무엇이 달라졌나. 약 10% 빨라졌습니다. 책의 2배가 아닙니다. 그 이유는 짐작할 수 있습니다. 책의 원래 코드는 C++ 에서 가상 호출과 반환값 검사가 겹친 것이었고, 이 Go 재현은 처음부터 세 함수가 같은 파일에 있어 컴파일러가 인라인할 여지가 컸습니다. 즉 Before 가 책의 Before 만큼 나쁘지 않았습니다. 그리고 추가 1회가 2ns 안팎이라 어느 쪽이든 이미 빠릅니다.

그러면 이 수정을 유지해야 할까요. 책의 기준으로 답하면 예 입니다. 20.2절의 조건은 “측정 가능한 차이가 없으면 되돌린다, 단 더 단순해졌다면 예외” 이고, After 는 측정 가능하게 빠르면서 더 단순합니다. 함수 셋이 둘로, 검사 여섯이 하나로 줄었고, adjacent 같은 포인터 비교 트릭이 사라졌습니다. 속도 때문이 아니라 단순함 때문에 남길 수정입니다. 그것이 20장의 결론이기도 합니다. “깨끗한 설계와 높은 성능은 양립한다.”

이 절을 이렇게 쓴 이유가 있습니다. 벤치마크를 돌리기 전에 이 글의 초안은 “책처럼 2배 가까이 빨라졌다” 는 문장을 기대하고 있었습니다. 재 보니 아니었습니다. 그 기대를 그대로 적었다면 20.2절을 어긴 글이 되었을 것입니다. 측정은 글에도 적용됩니다.

2.3 무엇이 비싼가#

20.1절의 표는 옮겨 둘 가치가 있습니다. 책이 2018년 기준으로 적은 “상대적으로 비싼 연산” 입니다.

연산비용 (책의 수치)
데이터센터 안의 네트워크 왕복10~50 µs, 명령어 수만 개 분량
디스크 I/O5~10 ms, 명령어 수백만 개 분량
플래시 저장소10~100 µs
동적 메모리 할당할당·해제·GC 오버헤드
캐시 미스명령어 수백 개 분량

책의 조언은 이 감각을 갖고 “자연스럽게 효율적인” 설계를 고르라 는 것입니다. 해시 테이블과 정렬된 맵이 둘 다 깨끗하다면 5~10배 빠른 해시 테이블을 고르고, 구조체 배열은 포인터 배열이 아니라 값 배열로 잡는 것. 효율이 복잡성을 요구할 때만 고민이 시작되고, 그때의 기준은 “복잡성이 작고 인터페이스에 드러나지 않는가” 입니다. 위 벤치마크 결과의 4152 B/op, 3 allocs/op 가 Go 에서 이 감각을 기르는 첫 숫자입니다. -benchmem 을 항상 켜 두면 할당 횟수가 눈에 들어옵니다.


3. 무엇이 중요한지 정하라 (21장)#

21장은 2판(2021)에서 새로 들어간 장입니다. 이 시리즈는 1판 원문을 기준으로 썼기 때문에 이 장의 본문은 인용할 수 없습니다. 저자의 책 소개 페이지에 적힌 설명과 2판 목차로 확인되는 절 제목만 옮깁니다. 저자는 이 장이 “좋은 설계란 중요한 것과 중요하지 않은 것을 가르고 중요한 것에 집중하는 일” 에 대한 것이라고 소개합니다. 절 제목은 “무엇이 중요한지 어떻게 정하는가”, “중요한 것을 최소화하기”, “중요한 것을 어떻게 강조하는가”, “흔한 실수”, “더 넓게 생각하기” 입니다.

이 시리즈가 여기까지 온 뒤에 그 문장을 다시 읽으면, 앞의 장들이 전부 이 한 문장의 각론이었다는 것이 보입니다.

장“중요한 것” 이 무엇이었나
4~5장 깊은 모듈, 정보 은닉인터페이스에 남길 것 = 사용자에게 중요한 것
10장 에러없애도 되는 에러 = 중요하지 않은 것, 드러내야 하는 에러 = 중요한 것
13장 주석코드에서 명백하지 않은 것 = 주석에 적을 만큼 중요한 것
14장 이름이름에 담을 두세 단어 = 그 대상의 가장 중요한 속성
20장 성능핵심 경로 = 성능에 중요한 유일한 경로

5편의 마지막 문장이 이것이었습니다. 중요하지 않은 것은 숨기고, 많이 숨길수록 좋지만, 중요한 것은 드러내야 합니다. 무엇이 중요한지 정하는 것이 설계입니다.


4. 시리즈 전체의 위험 신호#

책의 부록에는 설계 원칙 요약과 위험 신호 요약이 있습니다. 이 시리즈에서 코드로 옮긴 위험 신호를 한 표에 모읍니다. 자기 코드에서 이것들을 찾는 것이 이 책을 읽은 뒤에 할 수 있는 가장 구체적인 일입니다.

위험 신호Go 에서 보이는 모양편
얕은 모듈go doc 의 exported 선언 수에 비해 하는 일이 없다2
정보 누출같은 형식·규칙을 두 패키지가 안다2
시간적 분해패키지 이름이 reader, parser, writer 순서를 따라간다2
과다 노출흔한 호출에 드물게 필요한 인자가 있다2
pass-through 메서드저장소를 그대로 부르는 서비스 계층3
pass-through 변수중간 함수의 시그니처에 자기가 안 쓰는 인자가 있다3
설정 파라미터 떠넘기기모듈이 잴 수 있는 값을 호출자에게 묻는다3
범용-특수 혼합범용 타입 안에 UI 개념(Cursor, Selection)이 있다3, 4
이어 붙은 메서드공유 상태 구조체 포인터 하나만 받는 함수들4
너무 많은 예외받자마자 errors.Is 로 무시하는 에러5
코드를 반복하는 주석이름을 띄어쓰기한 문서 주석6
모호한 이름두 개념이 같은 int 와 같은 이름을 쓴다6
설명하기 어려움인터페이스 주석이 인자들의 상호작용을 설명해야 한다6
불일치“없음” 을 bool, error, nil, zero value 로 각각 표현7
명백하지 않은 코드Pair.First, 문자열 이벤트 이름7
구현 상속embedding 한 타입의 메서드가 내 메서드를 부를 것이라는 기대8

책의 설계 원칙 요약(1판 기준 15개) 중 이 시리즈가 가장 자주 돌아온 것은 세 개였습니다. 모듈은 깊어야 한다(4장). 모듈은 단순한 구현보다 단순한 인터페이스를 갖는 것이 더 중요하다(8장). 그리고 소프트웨어는 쓰기 쉽게가 아니라 읽기 쉽게 설계되어야 한다(18장). Go 로 옮기면서 마찰이 있었던 곳도 있었고(10장은 오히려 잘 맞았고, 14장은 갈라섰고, 19장은 흉내 낸 함정이 있었습니다), 그 마찰 자체가 책의 주장을 더 잘 보이게 했습니다. 언어가 어떤 실수를 막아 주는지, 어떤 실수는 막아 주지 않는지가 그 자리에서 드러났기 때문입니다.

책의 마지막 문단은 설계가 재미있다 는 이야기입니다. 문제를 가장 단순한 구조로 푸는 것은 퍼즐이고, 단순하면서 강력한 해법을 찾는 것은 좋은 기분이라는 것입니다. 이 시리즈의 Before 코드들을 After 로 고치면서 느낀 것도 그것이었습니다. 코드가 줄어들고 주석이 짧아지는 순간이 있고, 그 순간이 설계가 맞아 들어갔다는 신호입니다.


References#

1차 자료

  • Ousterhout, J. K. A Philosophy of Software Design, 1st ed. Yaknyam Press, 2018. 본문 인용(19.1~19.7, 20.1~20.5절, 설계 원칙 요약)은 1판 원문을 직접 옮긴 것입니다. RAMCloud Buffer 의 원래 수치(8.8 ns → 4.75 ns, 코드 20% 감소)는 20.4절의 것입니다.
  • 저자의 책 소개 페이지 — 21장 “Decide What Matters” 가 2판에서 추가되었다는 사실과 그 장의 요지(“separating what’s important from what’s not important”)의 출처입니다. 21장의 절 제목은 2판 목차(온라인 서점의 목차 정보)로 확인했으며, 본문은 확인하지 못해 인용하지 않았습니다.
  • Beck, K. Test-Driven Development: By Example. Addison-Wesley, 2002. red → green → refactor 순환의 출처입니다. 반론 부분은 이 시리즈의 의견이며 책의 주장이 아닙니다.

본문의 코드와 측정

  • 모든 Go 코드는 go version go1.26.0 darwin/arm64 에서 go vet 과 테스트를 통과했습니다. embedding 예제의 Before 는 SaveAll 뒤 감사 로그가 하나뿐인 것을 테스트로 재현했습니다.
  • 벤치마크는 go test -bench Append4000 -benchmem -count 5 로 Apple M4 Max 에서 측정했습니다. 표의 범위는 다섯 번 실행의 최소·최대이며, 추가 1회당 값은 4,000 으로 나눈 근사치입니다. 한 반복이 새 Buffer 를 만들므로 4 KB 할당 한 번이 포함되어 있고, 그 비용은 두 버전에 같습니다.

시리즈