Go 로 읽는 소프트웨어 설계의 철학 Part 6: 주석과 이름
이 글은 Claude Fable 5.1 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.
책의 후반부는 코드 자체의 명확성 으로 옮겨 갑니다. 12~15장은 주석과 이름에 대한 네 개의 장입니다. 이 장들은 종종 “스타일” 이야기로 치부되지만, 저자는 다르게 봅니다. 주석은 추상화를 정의하는 유일한 수단이고, 이름은 가장 짧은 문서이며, 주석을 먼저 쓰는 것은 설계 도구라는 것입니다.
Go 는 이 주제에 대해 자기 의견이 뚜렷한 언어입니다. go doc 이 주석의 형식을 정하고, 스타일 가이드가 짧은 이름을 권합니다. 그래서 이번 편에는 책과 Go 가 갈라서는 지점 이 하나 있습니다. 14.5절입니다. 어느 쪽 편을 들지 않고 양쪽 논거를 그대로 싣겠습니다.
1. 왜 주석을 쓰는가 (12장)#
책은 주석을 쓰지 않는 네 가지 변명을 나열하고 하나씩 반박합니다. 첫 번째가 가장 유명합니다.
“Some people believe that if code is written well, it is so obvious that no comments are needed. This is a delicious myth, like a rumor that ice cream is good for your health: we’d really like to believe it! Unfortunately, it’s simply not true.” (12.1절)
반박의 핵심은 추상화 입니다. 4장에서 추상화는 “본질적인 정보는 남기고 무시해도 되는 세부는 빼는 단순화된 관점” 이었습니다. 코드는 그 관점을 제공할 수 없습니다. 코드에는 세부가 전부 들어 있으니, 코드를 읽어야 메서드를 쓸 수 있다면 추상화가 없는 것입니다. 주석 없이 남는 유일한 추상화는 선언부(이름, 인자, 반환 타입)인데, 그것만으로는 부족합니다. 5편의 Snippet(body, start, end) 이 좋은 예입니다. 시그니처만 보고는 end 가 포함인지 아닌지, start > end 면 어떻게 되는지 알 수 없습니다.
나머지 변명에 대한 답은 짧습니다. “시간이 없다” 에는 주석 타이핑이 개발 시간의 10% 를 넘지 않는다는 계산이, “낡아서 오해를 부른다” 에는 중복을 피하고 코드 옆에 두면 유지가 어렵지 않다는 답이, “지금까지 본 주석은 다 쓸모없었다” 에는 그 변명이 가장 일리가 있으며 그래서 다음 장에서 잘 쓰는 법을 알려 주겠다는 답이 옵니다.
2. 코드에서 명백하지 않은 것을 쓴다 (13장)#
13장의 지도 원리는 제목 그대로입니다. 코드에서 명백하지 않은 것 을 씁니다. 그리고 좋은 주석은 코드와 다른 수준 에서 말합니다. 코드보다 더 자세하거나(정밀성), 코드보다 더 추상적이거나(직관). 코드와 같은 수준의 주석은 코드를 반복할 뿐입니다.
2.1 코드를 반복하는 주석#
원칙. 주석은 코드 옆만 보고는 쓸 수 없는 것을 담습니다.
Before. 책의 13.2절 예제들을 Go 로 옮긴 것입니다.
// textHorizontalPadding 은 텍스트 각 줄의 수평 패딩입니다.
const textHorizontalPadding = 4
// Layout 은 레이아웃입니다.
type Layout struct {
offset int // 현재 오프셋
lineWidths map[int]int // 문서 안의 모든 줄 너비와 등장 횟수
}
// GetNormalizedTitle 은 note 에서 정규화된 제목을 얻습니다.
func GetNormalizedTitle(title string) string {
// 앞뒤 공백을 지운다
title = strings.TrimSpace(title)
// 소문자로 바꾼다
title = strings.ToLower(title)
// 연속 공백을 하나로 합친다
return strings.Join(strings.Fields(title), " ")
}
무엇이 문제인가. 책이 제시하는 판정 질문이 있습니다. “이 코드를 본 적 없는 사람이 옆의 코드만 보고 이 주석을 쓸 수 있는가.” 위 주석은 전부 그렇습니다. textHorizontalPadding 의 주석은 이름을 띄어쓰기한 것이고, GetNormalizedTitle 의 주석은 이름의 단어를 순서만 바꾼 것입니다. 함수 안의 세 줄 주석은 각 줄의 코드를 한국어로 번역한 것입니다.
그러면서 정작 필요한 정보는 없습니다. 패딩의 단위는 무엇인가. 한쪽인가 양쪽인가. “정규화” 가 무엇인가. offset 은 무엇의 오프셋이고 “현재” 는 언제인가. lineWidths 의 키가 너비이고 값이 횟수인지, 너비의 단위가 픽셀인지 글자 수인지.
Go 에서는 이 위험 신호를 하나 더 조심해야 합니다. Go 의 문서 주석 관례는 선언된 이름으로 문장을 시작 합니다. go.dev/doc/comment 는 “doc comments for types start with complete sentences naming the declared symbol” 이라고 씁니다. 그래서 이름의 단어가 주석 첫머리에 나오는 것 자체는 피할 수 없습니다. 판정은 그 뒤 에 대해 내려야 합니다. 이름 다음에 오는 말이 이름을 풀어 쓴 것뿐이면 반복이고, 이름이 말하지 않는 것을 더하면 주석입니다.
After. 같은 선언에 다른 수준의 정보를 붙입니다.
// textHorizontalPadding 은 텍스트 각 줄의 왼쪽과 오른쪽에 비워 두는 여백입니다. 단위는 픽셀입니다.
const textHorizontalPadding = 4
// Layout 은 화면에 그릴 문서 한 개의 줄 배치입니다. 한 인스턴스가 문서 하나에 대응합니다.
type Layout struct {
// 아직 화면에 내보내지 않은 첫 줄의 인덱스입니다. 0 이상 줄 수 이하입니다.
nextLine int
// 줄 길이 통계입니다. 키는 개행을 포함한 글자 수, 값은 정확히 그 길이인 줄의 개수입니다.
// 어떤 길이의 줄이 하나도 없으면 그 키는 없습니다.
numLinesWithLength map[int]int
}
// NormalizeTitle 은 검색과 중복 판정에 쓰는 비교용 제목을 만듭니다.
// 대소문자와 공백의 차이만 있는 두 제목은 같은 결과가 됩니다. 원래 제목의 표시용으로는 쓰지 않습니다.
func NormalizeTitle(title string) string {
return strings.Join(strings.Fields(strings.ToLower(title)), " ")
}
무엇이 달라졌나. 주석마다 코드에 없던 정보가 들어갔습니다. 픽셀, 양쪽, “내보내지 않은 첫 줄”, 개행 포함, 없는 키의 의미, “비교용이지 표시용이 아님”. 이름도 같이 바뀌었습니다. offset 은 nextLine 으로, lineWidths 는 numLinesWithLength 로. 책은 후자를 두고 “너비” 대신 “길이” 를 쓴 이유까지 설명합니다. 길이라는 말이 픽셀보다 글자 수를 떠올리게 하기 때문입니다.
함수 안의 줄별 주석은 사라졌습니다. 세 줄이 한 줄이 되어 주석을 달 자리도 없어졌지만, 있었더라도 필요 없습니다. 책의 13.6절은 구현 주석의 목표가 코드가 무엇을 하는지이지 어떻게 하는지가 아니라고 말합니다. 짧은 함수는 인터페이스 주석이 이미 “무엇” 을 말하니 구현 주석이 필요 없고, 긴 함수는 주요 블록 앞에 그 블록의 “무엇” 을 한 줄씩 적으면 됩니다. 4편의 Import 함수에 넣은 // 첫 줄: 레코드 개수 가 그것입니다.
2.2 변수 주석은 동사가 아니라 명사로#
13.3절에 작은 규칙이 하나 더 있습니다. 변수를 문서화할 때는 무엇을 나타내는지 를 쓰지, 어떻게 조작되는지 를 쓰지 않습니다. 책의 예는 Raft 구현의 불리언 변수입니다. 원래 주석은 “Receiver 스레드와 PeriodicTasks 스레드가 소통하기 위한 지시 변수. 유효한 하트비트를 받으면 TRUE 로, 선거 타임아웃이 리셋되면 FALSE 로 토글” 이라고 코드 구조를 그대로 따라갔습니다. 고친 주석은 “마지막으로 선거 타이머가 리셋된 뒤 하트비트를 받았으면 true” 입니다. 이 한 문장에서 언제 true 가 되고 언제 false 가 되는지가 저절로 따라 나옵니다.
Go 로 옮기면 이렇습니다.
// Before: 코드 구조를 따라간 주석
// receiver 고루틴과 ticker 고루틴이 하트비트 수신 여부를 주고받는 플래그.
// 유효한 하트비트를 받으면 true 로, 선거 타이머를 리셋하면 false 로 바꾼다.
receivedValidHeartbeat bool
// After: 무엇을 나타내는지
// 마지막으로 선거 타이머를 리셋한 뒤 하트비트를 받았으면 true 입니다.
// receiver 고루틴이 쓰고 ticker 고루틴이 읽습니다.
receivedValidHeartbeat bool
2.3 직관을 주는 주석#
원칙. 코드보다 높은 수준에서, 이 코드가 무엇을 하려는지를 씁니다.
책의 13.4절 예제는 RAMCloud 의 RPC 코드입니다. 원래 주석은 “assignPos 가 가리키는 PKHash 와 같은 세션을 쓰는 LOADING 상태의 readRpc 가 있고, 그 readRPC 의 마지막 PKHash 가 현재 배정 중인 PKHash 보다 작으면…” 하고 코드의 조건문을 그대로 풀어 씁니다. 고친 주석은 한 줄입니다. “아직 보내지 않은, 원하는 서버로 가는 기존 RPC 에 현재 키 해시를 얹으려 시도한다.”
노트 서비스에서 같은 모양을 만들면 이렇습니다.
// Assign 은 id 를 server 로 가는 배치에 넣습니다.
func Assign(batches []*Batch, server, id string) []*Batch {
// 아직 보내지 않았고 자리가 남은, 같은 서버행 배치가 있으면 거기에 얹는다.
for _, b := range batches {
if b.Server == server && !b.Sent && len(b.IDs) < maxIDsPerBatch {
b.IDs = append(b.IDs, id)
return batches
}
}
return append(batches, &Batch{Server: server, IDs: []string{id}})
}
루프 앞의 한 줄이 조건문 세 개를 설명합니다. 책의 말대로, 이 한 줄이 있으면 독자는 !b.Sent 가 왜 있는지(보낸 배치에는 더 넣을 수 없다), len(b.IDs) < maxIDsPerBatch 가 왜 있는지(배치 크기에 상한이 있다)를 스스로 설명할 수 있습니다. 그리고 코드를 판정 할 수 있습니다. “기존 배치에 얹으려면 이 세 조건이면 충분한가.” 조건문을 번역한 주석으로는 그 질문을 던질 수 없습니다.
책은 높은 수준의 주석이 낮은 수준의 주석보다 쓰기 어렵다 고 인정합니다. 코드를 다른 방식으로 생각해야 하기 때문입니다. “이 코드가 하려는 일은 무엇인가. 이 코드의 모든 것을 설명하는 가장 단순한 말은 무엇인가.” 그것이 추상화이고, 이 책이 처음부터 요구해 온 것입니다.
2.4 인터페이스 주석과 구현 주석#
13.5절은 이 장에서 가장 깁니다. 핵심은 인터페이스 주석과 구현 주석을 분리 하라는 것입니다. 인터페이스 주석은 쓰는 사람이 알아야 할 것이고, 구현 주석은 안에서 어떻게 돌아가는지입니다. 그리고 책은 이렇게 덧붙입니다. 두 종류가 달라야 합니다. 인터페이스 주석이 구현을 설명해야만 한다면 그 모듈은 얕은 것입니다.
책의 예제는 RAMCloud 의 IndexLookup 클래스입니다. 원래 클래스 주석의 첫 문단은 “단일 LookupIndexKeys RPC 와 여러 IndexedRead RPC 를 관리한다”, “동시 indexedRead RPC 의 수 등 설정 파라미터가 아래에 있다” 처럼 구현 이야기였습니다. 사용자는 RPC 의 이름을 알 필요가 없고, 그 파라미터들은 전부 private 변수였습니다. 고친 주석은 “클라이언트 애플리케이션이 인덱스로 범위 질의를 할 때 쓴다. 인스턴스 하나가 질의 하나다” 로 시작합니다.
책은 여기서 연습 문제를 냅니다. IndexLookup 의 인터페이스 주석에 다음 정보가 필요한가.
| 정보 | 필요한가 | 이유 |
|---|---|---|
| 서버로 보내는 메시지 형식 | 아니오 | 구현 세부 |
| 범위 판정에 쓰는 비교 함수(정수인가 문자열인가) | 예 | 사용자가 결과를 예측하려면 알아야 함 |
| 서버가 인덱스를 저장하는 자료구조 | 아니오 | IndexLookup 의 구현조차 몰라야 함 |
| 여러 서버에 동시에 요청하는가 | 아마도 | 성능에 관심 있는 사용자가 있을 수 있음 |
| 서버 크래시 처리 방식 | 아니오 | 자동 복구되어 사용자에게 보이지 않음 |
기준은 하나입니다. 쓰는 사람이 이것을 모르면 잘못 쓰게 되는가. Go 의 go doc 출력은 정확히 인터페이스 주석만 보여 주므로, 이 기준을 적용하기 좋습니다. go doc 으로 봤을 때 그 패키지를 쓰기에 충분한지, 그리고 거기에 구현 이야기가 섞여 있지 않은지를 보면 됩니다.
3. 이름 (14장)#
3.1 block 이라는 이름의 버그#
원칙. 이름은 정밀해야 하고, 같은 이름은 같은 것에만 씁니다.
14장은 저자가 고친 가장 어려운 버그 이야기로 시작합니다. 1980년대 말 Sprite 분산 운영체제에서 파일의 데이터 블록이 가끔 0 으로 채워지는 문제가 있었습니다. 재현이 드물어 대학원생들이 포기했고, 저자가 직접 나서서 6개월 만에 찾았습니다. 원인은 이름 하나였습니다. 파일 시스템 코드가 block 이라는 변수명을 디스크의 물리 블록 번호 와 파일 안의 논리 블록 번호 양쪽에 썼고, 한 곳에서 논리 블록 번호가 물리 블록 번호 자리에 들어갔습니다. 엉뚱한 디스크 블록이 0 으로 덮였습니다.
여러 사람이 그 코드를 읽었지만 아무도 못 봤습니다. block 이 물리 블록 자리에 쓰인 것을 보면 반사적으로 물리 블록이라고 가정했기 때문입니다. fileBlock 과 diskBlock 이었다면 일어나지 않았을 버그입니다.
Before. 그 버그를 Go 로 재현합니다.
type FS struct {
disk []byte
blkmap map[int]int // 파일 블록 → 디스크 블록
}
// zeroBlock 은 디스크 블록 하나를 0 으로 채웁니다.
func (fs *FS) zeroBlock(block int) {
clear(fs.disk[block*blockSize : (block+1)*blockSize])
}
// Truncate 는 파일의 block 번째 이후 블록을 버립니다.
func (fs *FS) Truncate(block int) {
for fb, db := range fs.blkmap {
if fb >= block {
fs.zeroBlock(fb) // 버그: 디스크 블록(db)을 넘겨야 하는데 파일 블록을 넘겼다
_ = db
delete(fs.blkmap, fb)
}
}
}
테스트에서 파일 블록 0 을 디스크 블록 3 에 배정하고 Truncate(0) 을 부르면, 디스크 블록 3 은 멀쩡하고 디스크 블록 0 이 지워집니다. 컴파일러는 아무 말도 하지 않습니다. 둘 다 int 이기 때문입니다.
After. 책의 처방은 이름을 바꾸는 것입니다. Go 에서는 한 걸음 더 갈 수 있습니다. 이름을 타입 으로 만듭니다.
// FileBlock 은 파일 안에서의 논리 블록 번호입니다. 0 이 파일의 첫 블록입니다.
type FileBlock int
// DiskBlock 은 디스크 위의 물리 블록 번호입니다. 0 이 디스크의 첫 블록입니다.
type DiskBlock int
type FS struct {
disk []byte
blkmap map[FileBlock]DiskBlock
}
// zeroBlock 은 디스크 블록 하나를 0 으로 채웁니다.
func (fs *FS) zeroBlock(diskBlock DiskBlock) {
off := int(diskBlock) * blockSize
clear(fs.disk[off : off+blockSize])
}
// Truncate 는 파일의 from 번째 이후 블록을 버립니다.
func (fs *FS) Truncate(from FileBlock) {
for fileBlock, diskBlock := range fs.blkmap {
if fileBlock >= from {
fs.zeroBlock(diskBlock)
delete(fs.blkmap, fileBlock)
}
}
}
무엇이 달라졌나. 이름이 정밀해진 것은 책의 처방 그대로입니다. 그 위에, 같은 실수를 다시 하면 컴파일이 되지 않습니다. zeroBlock(fileBlock) 이라고 쓰면 실제 출력이 이렇습니다.
cannot use from (variable of int type FileBlock) as DiskBlock value in argument to zeroBlock
6개월 걸린 버그가 컴파일 에러 한 줄이 됩니다. type FileBlock int 은 비용이 없습니다. 런타임에는 그냥 int 이고, 산술 연산도 그대로 됩니다. 두 개념이 같은 기저 타입을 가지면서 서로 섞이면 안 될 때, Go 에서는 이름만 바꾸지 말고 타입을 나누는 것이 정석입니다. time.Duration 이 int64 를 감싼 이유가 같습니다.
책은 14.3절에서 정밀하지 않은 이름의 예를 더 듭니다. 편집기에서 글자 위치를 x, y 로 부른 것(화면 픽셀 좌표와 구분이 안 됨), blinkStatus 라는 불리언(true 가 무슨 뜻인지 모름. cursorVisible 이 나음), 반환값이 없는 메서드 안의 result 라는 변수. 그리고 반대 방향의 실수도 있습니다. 텍스트를 지우는 메서드의 인자를 selection 이라고 부르면 너무 구체적 입니다. 선택되지 않은 범위도 지울 수 있으니 range 여야 합니다. 3편에서 Cursor 를 Position 으로 바꾼 것이 같은 이유였습니다.
그리고 위험 신호가 하나 더 있습니다. 이름 짓기가 어렵다면 그 변수나 메서드의 설계에 문제가 있을 수 있습니다. 하나의 변수로 여러 가지를 나타내려 하고 있는지 의심해 보라는 것입니다.
3.2 다른 의견: Go 스타일 가이드 (14.5절)#
여기가 책과 Go 가 갈라서는 지점입니다. 저자는 이 절을 따로 두어 반대 의견을 소개합니다.
Andrew Gerrand 의 2014년 발표 “What’s in a name?” 은 “long names obscure what the code does” 라고 말하고, 한 글자 변수를 쓴 RuneCount 가 긴 이름을 쓴 버전보다 읽기 쉽다고 주장합니다. 책은 두 버전을 나란히 싣습니다. 아래는 책에 실린 형태입니다.
// 짧은 이름 (Gerrand 가 권하는 쪽)
func RuneCount(b []byte) int {
i, n := 0, 0
for i < len(b) {
if b[i] < RuneSelf {
i++
} else {
_, size := DecodeRune(b[i:])
i += size
}
n++
}
return n
}
// 긴 이름
func RuneCount(buffer []byte) int {
index, count := 0, 0
for index < len(buffer) {
if buffer[index] < RuneSelf {
index++
} else {
_, size := DecodeRune(buffer[index:])
index += size
}
count++
}
return count
}
저자의 반응은 이렇습니다. 두 번째가 더 읽기 어렵다고 느끼지 않는다고 합니다. count 가 n 보다 변수의 역할을 조금 더 잘 알려 주고, 첫 번째 버전에서는 n 이 무엇인지 알아내려고 코드를 훑게 되었다고 합니다. 다만 n 이 시스템 전체에서 일관되게 개수에만 쓰인다면 짧은 이름도 다른 개발자에게 분명할 것이라고 덧붙입니다.
저자가 더 우려하는 것은 Go 문화가 같은 짧은 이름을 여러 뜻으로 쓰는 것입니다. ch 가 문자이기도 하고 채널이기도 하고, d 가 데이터이기도 하고 차이이기도 하고 거리이기도 한 것. 저자에게 그것은 block 과 같은 종류의 모호함입니다.
그리고 결론은 양쪽에 공정합니다.
“Overall, I would argue that readability must be determined by readers, not writers. If you write code with short variable names and the people who read it find it easy to understand, then that’s fine. If you start getting complaints that your code is cryptic, then you should consider using longer names.” (14.5절)
저자가 동의하는 Gerrand 의 문장도 있습니다. “선언과 사용 사이의 거리가 멀수록 이름은 길어야 한다.” 책이 앞서 말한 “짧은 루프의 i, j 는 괜찮다” 는 규칙이 그것입니다.
이 시리즈가 덧붙일 것은 하나입니다. Go 공식 문서와 스타일 가이드의 현재 문구는 저자가 인용한 2014년 발표보다 책 쪽에 가까워져 있습니다. Google 의 Go 스타일 가이드는 “이름의 길이는 스코프의 크기에 비례하고 그 스코프 안에서 쓰이는 횟수에 반비례해야 한다” 고 쓰고, 한 글자 변수는 “전체 단어가 명백하고 반복이 되는 자리로 제한” 하라고 하며, 시작점으로 count 나 options 같은 한 단어 이름 을 권합니다. 저자가 n 보다 낫다고 한 바로 그 count 입니다. 짧은 이름을 쓰되 스코프에 비례하라는 원칙은 양쪽이 같고, 차이는 “짧다” 의 기본값이 어디냐 정도입니다.
14.4절의 일관성 규칙은 Go 에서 특히 실용적입니다. 어떤 용도에 이름 하나를 정하면 항상 그 이름을 쓰고, 그 이름을 다른 용도에 쓰지 않고, 그 용도가 충분히 좁아서 같은 이름의 변수가 모두 같은 동작을 하게 합니다. Go 코드의 r io.Reader, w io.Writer, ctx context.Context, err error 는 이 규칙의 성공 사례입니다. 짧지만 모호하지 않은 것은 일관성 덕분입니다. 저자의 우려가 맞는 지점은 그 일관성이 깨질 때입니다.
4. 주석을 먼저 쓴다 (15장)#
원칙. 인터페이스 주석을 코드보다 먼저 씁니다. 주석이 길어지면 설계를 의심합니다.
15장은 이 책에서 가장 실천적인 제안입니다. 대부분의 개발자는 주석을 코딩과 테스트가 끝난 뒤로 미룹니다. “코드가 아직 바뀌니까.” 저자는 그 결과를 두 가지로 봅니다. 미루면 결국 안 쓰게 되고, 써도 나쁜 주석이 됩니다. 이미 머릿속에서 그 코드는 끝난 것이라 설계 과정의 기억이 흐려졌고, 코드를 보면서 쓰니 코드를 반복하는 주석이 됩니다.
저자의 방법은 이렇습니다. 새 타입을 만들 때 타입의 인터페이스 주석을 먼저 쓰고, 가장 중요한 공개 메서드들의 주석과 시그니처를 쓰되 본문은 비워 두고, 구조가 맞다고 느껴질 때까지 주석을 고치고, 그다음에 필드를 선언하고, 마지막에 본문을 채웁니다. 코드가 끝나면 주석도 끝나 있습니다.
가장 중요한 이점은 더 나은 주석이 아니라 더 나은 설계 입니다.
“Comments serve as a canary in the coal mine of complexity. If a method or variable requires a long comment, it is a red flag that you don’t have a good abstraction.” (15.3절)
Before. 노트 검색 함수를 설계하면서, 본문을 비워 두고 주석부터 썼습니다.
// Search 는 노트를 검색합니다. q 가 비어 있으면 모든 노트가 대상입니다. tag 가 비어 있지 않으면
// 그 태그를 가진 노트만 대상이고, tag 가 "-" 로 시작하면 그 태그를 가지지 않은 노트만 대상입니다.
// limit 이 0 이면 제한이 없고, 음수면 뒤에서부터 -limit 개를 돌려줍니다. sortBy 는 "date",
// "title", "" 중 하나이며 "" 는 관련도순입니다. 단 q 가 비어 있으면 관련도가 정의되지 않으므로
// sortBy 가 "" 일 때 "date" 로 동작합니다. desc 는 sortBy 가 "" 이 아닐 때만 의미가 있습니다.
// includeArchived 가 false 면 보관된 노트는 제외되지만, tag 로 "archived" 를 지정하면 포함됩니다.
func Search(q, tag string, limit int, sortBy string, desc, includeArchived bool) []string {
panic("TODO")
}
무엇이 문제인가. 코드를 한 줄도 쓰기 전에 문제가 보입니다. 주석이 여섯 줄이고, 그중 절반이 인자들 사이의 상호작용 입니다. sortBy 가 "" 인데 q 도 비어 있으면, desc 는 sortBy 가 있을 때만, tag 가 "archived" 면 includeArchived 를 무시하고. 이것이 카나리아입니다. 인자 하나하나는 그럴듯한데, 조합이 설명되지 않습니다. 이 주석을 완전하고 단순하게 쓸 방법이 없다면, 문제는 주석이 아니라 인터페이스입니다.
Go 의 panic("TODO") 는 이 연습을 컴파일되는 상태로 할 수 있게 해 줍니다. 시그니처와 주석만 있는 파일이 go vet 을 통과하니, 설계를 코드 안에서 고칠 수 있습니다.
After. 주석을 짧게 만들 수 있을 때까지 인터페이스를 고칩니다.
// Query 는 검색 조건입니다. zero value 는 "모든 노트를 최신순으로" 입니다.
type Query struct {
Text string // 비어 있으면 본문 조건 없음
Tag string // 비어 있으면 태그 조건 없음
}
// Search 는 q 에 맞는 노트 ID 를 최신순으로 돌려줍니다. 보관된 노트는 포함하지 않습니다.
func Search(q Query) []string {
var out []string
for _, n := range all {
if n.archived || (q.Text != "" && !contains(n.body, q.Text)) || (q.Tag != "" && !n.has(q.Tag)) {
continue
}
out = append(out, n.id)
}
sort.Slice(out, func(i, j int) bool { return byID[out[i]].date > byID[out[j]].date })
return out
}
// SearchArchived 는 보관된 노트만을 대상으로 Search 와 같은 일을 합니다.
func SearchArchived(q Query) []string { /* 생략 */ }
무엇이 달라졌나. 주석이 한 줄이 되었고, 그 한 줄이 완전합니다. 그러기 위해 무엇을 버렸는지가 설계 결정입니다. 정렬 옵션을 없앴습니다(항상 최신순. 다른 정렬은 호출자가 결과를 다시 정렬하면 됨). limit 을 없앴습니다(호출자가 슬라이스를 자르면 됨). 음수 limit 과 "-tag" 같은 부호 트릭을 없앴습니다. includeArchived 와 "archived" 태그의 상호작용은 함수를 둘로 나눠 없앴습니다. 인자 여섯 개가 struct 하나로 줄었고, struct 의 zero value 가 가장 흔한 질의입니다.
인터페이스가 이렇게 정해진 뒤에야 본문을 채웠습니다. 테스트는 zero value 질의가 보관 노트를 빼고 최신순으로 돌려주는 것과, 본문·태그 조건이 모두 걸리는 것을 확인합니다. 본문을 채우는 동안 주석을 고칠 일은 없었습니다. 주석이 먼저 설계를 끝냈기 때문입니다.
책은 이 방식이 비용이 크지 않다고 계산합니다. 코드와 주석을 타이핑하는 시간은 전체 개발 시간의 10% 를 넘지 않고, 주석이 그 절반이라 해도 5% 이며, 미뤄서 아끼는 것은 그 일부입니다. 반면 추상화가 코딩 전에 안정되면 코드를 고칠 일이 줄어듭니다. 저자는 주석을 먼저 쓰는 쪽이 전체적으로 더 빠를 수도 있다 고 말합니다.
5. 이번 편의 위험 신호#
| 위험 신호 | 책의 절 | 이번 편의 예 |
|---|---|---|
| 코드를 반복하는 주석 (Comment Repeats Code) | 13.2 | GetNormalizedTitle 은 정규화된 제목을 얻습니다 |
| 구현이 인터페이스 문서를 오염 (Implementation Documentation Contaminates Interface) | 13.5 | RPC 이름과 private 설정을 나열한 클래스 주석 |
| 모호한 이름 (Vague Name) | 14.3 | block, x/y, blinkStatus, result |
| 이름 짓기가 어려움 (Hard to Pick Name) | 14.3 | 하나의 변수로 여러 가지를 나타내려 할 때 |
| 설명하기 어려움 (Hard to Describe) | 15.3 | 여섯 줄 주석이 필요한 Search |
다음 편은 16~18장입니다. 기존 코드를 고칠 때 전략적으로 남는 법, 일관성이 왜 그 자체로 가치인지, 그리고 “코드는 명백해야 한다” 는 원칙이 범용 컨테이너와 이벤트 기반 코드에 무엇을 요구하는지를 봅니다.
References#
1차 자료
- Ousterhout, J. K. A Philosophy of Software Design, 1st ed. Yaknyam Press, 2018. 본문 인용(12.1, 13.2~13.6, 14.1~14.5, 15.1~15.5절)은 1판 원문을 직접 옮긴 것입니다.
RuneCount두 버전은 14.5절에 실린 형태를 그대로 옮겼습니다. - Gerrand, A. “What’s in a name?” Go talk, 2014-10. https://go.dev/talks/2014/names.slide — 책이 인용하는 발표입니다. 2026-09-16 에 확인한 현재 슬라이드의 코드 예제는 책에 실린 형태와 변수명이 일부 다릅니다(짧은 이름 버전이
n대신count를 씁니다). 본문의 코드는 책의 인용을 따랐습니다. - Google Go Style Guide, “Variable names” — https://google.github.io/styleguide/go/decisions#variable-names — 이름 길이와 스코프의 비례 규칙, 한 글자 이름의 제한,
count를 시작점으로 권하는 문구의 출처입니다. - Go Doc Comments — https://go.dev/doc/comment — 문서 주석이 선언된 이름으로 시작한다는 관례의 출처입니다.
본문의 코드
- 모든 Go 코드는
go version go1.26.0 darwin/arm64에서go vet과 테스트를 통과했습니다.block버그의 Before 는 엉뚱한 디스크 블록이 지워지는 것을 테스트로 재현했고, After 의 컴파일 에러 문구는 실제go build출력입니다. - Before 의
Search는 본문이panic("TODO")인 채로 컴파일만 확인했습니다. 15장의 방법을 그대로 따른 것입니다.
시리즈