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


앞의 여섯 편이 새 코드를 어떻게 쓰는가 였다면, 이번 편의 16장은 있는 코드를 어떻게 고치는가 입니다. 시스템의 설계는 처음 구상보다 이후의 수정들로 결정됩니다. 그래서 수정할 때 복잡성이 스며들지 않게 하는 것이 새로 쓸 때만큼 중요합니다.

17장 “Consistency” 와 18장 “Code Should be Obvious” 는 그 수정이 만들어 내는 코드의 표면 에 대한 장입니다. 비슷한 것은 비슷하게, 그리고 처음 읽는 사람이 한 번에 맞게 추측할 수 있게.


1. 기존 코드를 고칠 때 (16장)#

1.1 가장 작은 변경의 유혹#

원칙. 수정이 끝났을 때, 처음부터 그 변경을 염두에 두고 설계했더라면 가졌을 구조가 되어 있어야 합니다.

책은 개발자가 기존 코드에 들어갈 때의 전형적인 사고방식을 이렇게 적습니다. “내가 필요한 것을 하는 가장 작은 변경 은 무엇인가.” 익숙하지 않은 코드라 큰 변경이 무섭다는 이유도 붙습니다. 그러나 그것이 전술적 프로그래밍입니다. 최소 변경 하나하나가 특수 경우와 의존성을 조금씩 남기고, 설계는 수정할 때마다 조금씩 나빠집니다.

“Whenever you modify any code, try to find a way to improve the system design at least a little bit in the process. If you’re not making the design better, you are probably making it worse.” (16.1절)

Before. 노트 저장소에 버그 리포트가 들어왔습니다. ID 에 ../secret 같은 값을 넣으면 저장 디렉터리 밖에 파일이 쓰인다는 것입니다. 가장 작은 변경으로 고쳤습니다.

type Store struct{ Dir string }

func (s *Store) path(id string) string { return filepath.Join(s.Dir, id+".json") }

// Save 는 노트를 저장합니다.
func (s *Store) Save(id string, body []byte) error {
	if strings.Contains(id, "/") { // 버그 #123 땜질: "../x" 같은 id 가 디렉터리를 벗어난다
		return errors.New("invalid id")
	}
	return os.WriteFile(s.path(id), body, 0o644)
}

// Load 는 노트를 읽습니다.
func (s *Store) Load(id string) ([]byte, error) { return os.ReadFile(s.path(id)) }

// Delete 는 노트를 지웁니다.
func (s *Store) Delete(id string) error { return os.Remove(s.path(id)) }

무엇이 문제인가. 버그 리포트에 적힌 증상은 사라졌습니다. Save("../secret", ...) 은 거부됩니다. 그런데 테스트에서 Load("../secret") 을 부르면 저장 디렉터리 밖의 파일 내용이 그대로 읽힙니다. Delete 도 마찬가지입니다. 리포트가 Save 를 언급했으니 Save 만 고쳤고, 같은 원인이 세 함수에 있다는 것은 보지 않았습니다. 1편의 용어로 이것은 땜질이고, 5편의 용어로는 세 곳에서 처리해야 할 에러를 한 곳에서만 처리한 것입니다.

그리고 strings.Contains(id, "/") 라는 검사 자체도 불완전합니다. Windows 의 \, 빈 문자열, 공백, 대문자와 소문자가 섞인 이름은 통과합니다. 최소 변경은 원인을 묻지 않고 증상만 막기 때문에 이렇게 됩니다.

After. “이 변경을 처음부터 알았다면 어떻게 설계했을까” 를 묻습니다. 답은 ID 가 파일 이름이 된다는 사실 을 한 곳에서 책임지는 것입니다.

// ID 는 노트 식별자입니다. ParseID 를 거친 값만 만들 수 있으므로 항상 파일 이름으로 안전합니다.
type ID struct{ s string }

var idPattern = regexp.MustCompile(`^[a-z0-9-]{1,64}$`)

// ParseID 는 외부에서 온 문자열을 ID 로 바꿉니다. 소문자·숫자·하이픈 1자에서 64자까지만 허용합니다.
// 허용 범위를 좁게 잡은 이유는 ID 가 그대로 파일 이름이 되기 때문입니다. 경로 구분자나
// ".." 이 들어오면 저장 디렉터리 밖의 파일을 읽고 쓰게 됩니다 (버그 #123).
func ParseID(s string) (ID, error) {
	if !idPattern.MatchString(s) {
		return ID{}, errors.New("invalid id")
	}
	return ID{s}, nil
}

func (s *Store) path(id ID) string { return filepath.Join(s.Dir, id.s+".json") }

func (s *Store) Save(id ID, body []byte) error { return os.WriteFile(s.path(id), body, 0o644) }
func (s *Store) Load(id ID) ([]byte, error)    { return os.ReadFile(s.path(id)) }
func (s *Store) Delete(id ID) error            { return os.Remove(s.path(id)) }

무엇이 달라졌나. 세 함수가 모두 ID 를 받고, ID 는 ParseID 를 통해서만 만들어집니다. 필드가 unexported 라 패키지 밖에서 ID{"../x"} 라고 쓸 수 없습니다. 검사는 한 곳에 있고, 새 함수를 추가해도 자동으로 보호됩니다. 검사 자체도 “금지 목록” 에서 “허용 목록” 으로 바뀌어 \ 나 대문자 같은 빠진 경우가 없습니다. 테스트는 ../secret, a/b, 빈 문자열, 대문자, 공백이 모두 거부되고 note-42 는 통과하는 것을 확인합니다.

이 수정은 땜질보다 오래 걸렸습니다. 시그니처가 바뀌어서 호출하는 곳도 고쳐야 합니다. 책은 그 비용을 인정합니다. 석 달짜리 리팩터링과 두 시간짜리 땜질 사이에서 마감이 땜질을 강요할 때가 있고, 다른 팀에 비호환 변경을 강요하는 리팩터링은 현실적이지 않을 수 있다고 씁니다. 그래도 물어야 할 질문이 있습니다. “지금의 제약 안에서 내가 할 수 있는 가장 깨끗한 설계는 무엇인가.” 석 달짜리에 가까우면서 며칠이면 되는 대안이 있을 수 있고, 지금 못 하면 마감 뒤에 돌아올 시간을 확보하라는 것입니다.

1.2 주석은 커밋 로그가 아니라 코드에#

16.3절은 짧지만 실용적입니다. 변경의 배경을 커밋 메시지에만 적는 실수입니다. 나중에 그 정보가 필요한 개발자는 저장소 로그를 뒤질 생각을 하지 않고, 하더라도 찾기 어렵습니다. 그래서 미묘한 문제 때문에 넣은 코드의 이유가 코드에 없으면, 누군가 그 코드를 걷어내고 버그를 되살립니다.

위 After 의 ParseID 주석이 그 예입니다. “허용 범위를 좁게 잡은 이유” 와 버그 번호가 코드 옆에 있습니다. 커밋 메시지에도 같은 내용을 적는 것은 괜찮지만, 코드에 있는 것이 우선입니다. 문서는 개발자가 볼 가능성이 가장 높은 곳 에 둡니다. 커밋 로그는 거의 그런 곳이 아닙니다.

같은 장의 나머지 조언도 이 원칙의 변주입니다. 주석은 설명하는 코드 가까이에 두고, 구현 주석은 메서드 맨 위에 몰아 두지 말고 각 단계 바로 위에 두며, 중복하지 말고 한 곳에 두고 다른 곳에서는 참조하고, 커밋 전에 diff 를 훑으며 주석이 코드 변경을 따라갔는지 확인합니다. 그리고 높은 수준의 주석일수록 유지가 쉽습니다. 세부를 담지 않으니 세부가 바뀌어도 틀리지 않기 때문입니다.


2. 일관성 (17장)#

원칙. 비슷한 것은 비슷하게, 다른 것은 다르게 합니다.

“Consistency creates cognitive leverage: once you have learned how something is done in one place, you can use that knowledge to immediately understand other places that use the same approach.” (17장 도입부)

일관성은 실수도 줄입니다. 일관되지 않은 시스템에서는 서로 다른 두 상황이 같아 보일 수 있고, 익숙한 패턴을 보고 잘못된 가정을 하게 됩니다. 일관된 시스템에서는 익숙해 보이는 것에 대한 가정이 안전 합니다.

2.1 “없음” 을 네 가지로 말하는 저장소#

Before. 한 패키지 안에서 “찾는 것이 없다” 를 네 가지 방식으로 표현합니다.

// Get 은 없으면 ok=false 입니다.
func (r *Repo) Get(id string) (Note, bool) { n, ok := r.m[id]; return n, ok }

// Find 는 없으면 ErrNotFound 입니다.
func (r *Repo) Find(id string) (Note, error) {
	n, ok := r.m[id]
	if !ok {
		return Note{}, ErrNotFound
	}
	return n, nil
}

// Lookup 은 없으면 nil 입니다.
func (r *Repo) Lookup(id string) *Note {
	if n, ok := r.m[id]; ok {
		return &n
	}
	return nil
}

// ByTitle 은 없으면 zero value 입니다. 호출자는 Title == "" 로 판단해야 합니다.
func (r *Repo) ByTitle(title string) Note {
	for _, n := range r.m {
		if n.Title == title {
			return n
		}
	}
	return Note{}
}

무엇이 문제인가. 각 함수는 그 자체로 틀리지 않았습니다. 문제는 네 개가 한 패키지에 같이 있다 는 것입니다. 호출자는 함수마다 “없음” 이 어떻게 오는지를 따로 배워야 하고, 하나에서 배운 것을 다른 데 적용하면 틀립니다. Get 을 쓰던 사람이 Lookup 의 반환값을 ok 처럼 다루면 nil 포인터 역참조입니다. 그리고 마지막 방식은 그냥 버그입니다. 테스트에서 제목이 빈 노트를 넣으면 ByTitle("") 이 그 노트를 돌려주는데, 호출자는 그것을 “없음” 으로 읽습니다. zero value 가 유효한 값과 겹치는 순간 “없음” 의 표현이 무너집니다.

After. 한 가지로 정하고, 그 규칙을 패키지 문서에 적습니다.

// 이 패키지의 조회 함수는 모두 (값, 있는가) 를 돌려줍니다. 없는 것은 에러가 아닙니다.

func (r *Repo) Get(id string) (Note, bool) { n, ok := r.m[id]; return n, ok }

func (r *Repo) ByTitle(title string) (Note, bool) {
	for _, n := range r.m {
		if n.Title == title {
			return n, true
		}
	}
	return Note{}, false
}

무엇이 달라졌나. 어느 방식을 골랐느냐보다 하나만 골랐다 는 것이 요점입니다. (Note, bool) 을 고른 이유는 5편의 논리입니다. 없는 것을 찾는 것은 정상적인 일이지 에러가 아니고, Go 의 map 조회 관용구와 같은 모양이라 배울 것이 없습니다. 다른 패키지가 (Note, error) 와 ErrNotFound 를 쓴다면 거기서는 그것이 맞습니다. 책의 표현으로 “로마에서는 로마법을” 따릅니다.

2.2 Go 가 17장을 언어 차원에서 구현한 방식#

17.2절은 일관성을 유지하는 법입니다. 문서화하고, 도구로 강제하고, 코드 리뷰에서 가르치고, 새 파일에 들어가면 주변을 먼저 보고 따라 하고, 기존 관례를 “개선” 하려는 충동을 참는 것입니다. 책의 사례는 줄 끝 문자입니다. Unix 와 Windows 개발자가 섞여 있어 파일 전체가 수정된 것처럼 보이는 diff 가 계속 생겼고, 문서로는 해결되지 않다가 커밋 전에 캐리지 리턴을 검사하는 스크립트 를 두자 즉시 해결되었습니다.

Go 는 이 절의 처방을 언어 배포판에 넣었습니다. gofmt 가 들여쓰기, 중괄호 위치, 공백을 결정하고, 설정 옵션이 없습니다. 논쟁할 것이 없으니 논쟁이 없습니다. 책이 “저수준 문법 관례에는 자동 검사기가 특히 잘 맞는다” 고 쓴 바로 그 자리입니다. go vet 과 staticcheck 같은 도구가 그 위의 층을 맡습니다. 그리고 표준 라이브러리가 이름 관례(r io.Reader, ctx, err)와 에러 관례(errors.Is, %w)의 참고 답안 역할을 합니다. 6편에서 본 대로 Go 의 짧은 이름이 모호하지 않은 것은 이 일관성 덕분입니다.

책의 마지막 경고는 17.3절입니다. 일관성은 다른 것은 다르게 하는 것도 포함합니다. 정말 다른 것에 같은 이름을 쓰거나, 맞지 않는 문제에 익숙한 패턴을 억지로 씌우면 복잡성과 혼란이 생깁니다. 일관성이 가치 있는 것은 “x 처럼 보이면 정말 x 다” 는 믿음이 성립할 때뿐입니다. 2.1 의 ByTitle 이 그 반례였습니다. Note 를 돌려주니 Get 과 비슷해 보이는데, “없음” 의 의미가 달랐습니다.


3. 코드는 명백해야 한다 (18장)#

원칙. 처음 읽는 사람이 깊이 생각하지 않고 한 추측이 맞아야 합니다.

18장은 1편에서 본 복잡성의 두 원인 중 모호성 에 대한 장입니다. 명백한 코드는 빠르게 읽히고, 첫 추측이 맞고, 주석이 덜 필요합니다. 그리고 판정자는 다시 독자입니다.

“If someone reading your code says it’s not obvious, then it’s not obvious, no matter how clear it may seem to you.” (18장 도입부)

명백하게 만드는 기법 둘은 이미 다뤘습니다. 좋은 이름(14장)과 일관성(17장). 책이 더하는 것은 공백 과 주석 입니다. 파라미터 문서에 빈 줄을 넣어 어디서 한 파라미터가 끝나고 다음이 시작하는지 보이게 하고, 메서드 안의 주요 블록 사이에 빈 줄을 두고 그 첫 줄에 블록을 설명하는 주석을 두는 것입니다. 4편의 Import 가 그렇게 쓰였습니다. Go 에서는 문장 안의 공백은 gofmt 가 정하고, 블록 사이의 빈 줄은 작성자가 정합니다.

18.2절은 반대로 코드를 덜 명백하게 만드는 것들의 목록입니다. 세 개를 Go 로 옮깁니다.

3.1 범용 컨테이너#

책의 예는 Java 의 Pair 입니다. 메서드에서 값 두 개를 돌려주려고 new Pair<Integer, Boolean>(currentTerm, false) 를 쓰면, 호출자는 result.getKey() 와 result.getValue() 로 꺼내야 하고, 그 이름은 값의 의미를 전혀 말해 주지 않습니다.

Go 는 오래전부터 다중 반환값이 있어서 이 문제가 드물었습니다. 그런데 제네릭이 들어온 뒤로 Pair[A, B] 를 만드는 코드가 다시 보이기 시작했습니다.

Before.

// Pair 는 아무 두 값을 묶습니다.
type Pair[A, B any] struct {
	First  A
	Second B
}

// RequestVote 는 후보의 투표 요청을 처리합니다.
func RequestVote(currentTerm, candidateTerm int) Pair[int, bool] {
	if candidateTerm < currentTerm {
		return Pair[int, bool]{currentTerm, false}
	}
	return Pair[int, bool]{candidateTerm, true}
}

호출하는 쪽은 r.First 와 r.Second 를 씁니다. 테스트를 쓰면서 “First 가 임기였나 승인이었나” 를 한 번 더 확인해야 했습니다. 그것이 이 절이 말하는 모호성입니다.

After. 값이 함수 밖으로 나가서 돌아다닌다면 이름 있는 struct 로, 그 자리에서 소비된다면 다중 반환값으로 씁니다.

// VoteReply 는 투표 요청에 대한 응답입니다.
type VoteReply struct {
	Term    int  // 응답자의 현재 임기. 후보는 이 값이 자기 임기보다 크면 follower 로 내려간다
	Granted bool // 이 응답자가 후보에게 표를 주었는가
}

// RequestVote 는 후보의 투표 요청을 처리합니다.
func RequestVote(currentTerm, candidateTerm int) VoteReply {
	if candidateTerm < currentTerm {
		return VoteReply{Term: currentTerm, Granted: false}
	}
	return VoteReply{Term: candidateTerm, Granted: true}
}

// 두 값이 함수 밖으로 나가지 않을 때는 다중 반환값으로 충분합니다.
func requestVote(currentTerm, candidateTerm int) (term int, granted bool) {
	if candidateTerm < currentTerm {
		return currentTerm, false
	}
	return candidateTerm, true
}

무엇이 달라졌나. r.Term, r.Granted 는 설명이 필요 없습니다. 그리고 Pair 로는 불가능했던 것이 생겼습니다. 필드마다 주석을 달 자리 입니다. Term 옆의 “이 값이 자기 임기보다 크면 follower 로 내려간다” 는 Raft 의 규칙이고, Pair.First 에는 적을 수 없는 정보입니다.

책은 이 예에서 일반 원칙 하나를 끌어냅니다.

“software should be designed for ease of reading, not ease of writing.” (18.2절)

Pair 는 쓰는 사람에게 편합니다. 타입 하나를 안 만들어도 되니까요. 그 몇 분을 아끼려고 뒤에 오는 모든 독자에게 혼란을 줍니다. VoteReply 를 정의하는 데 드는 시간은 그 독자들이 아끼는 시간에 비하면 없는 것과 같습니다.

3.2 이벤트 기반 프로그래밍#

책이 첫 번째로 드는 것은 이벤트 기반 코드입니다. 핸들러는 직접 호출되지 않고 이벤트 모듈이 함수 포인터나 인터페이스로 간접 호출합니다. 호출하는 자리를 찾아도 어느 함수가 불릴지는 런타임에 무엇이 등록됐느냐에 달려 있어 알 수 없습니다. 그래서 흐름을 따라가기 어렵고, 동작한다고 확신하기 어렵습니다.

Before. 노트 서비스에 이벤트 버스를 두었습니다.

type Service struct {
	bus   *Bus
	notes map[string]Note
}

// Save 는 노트를 저장합니다. 그다음에 무슨 일이 일어나는지는 여기서 알 수 없습니다.
func (s *Service) Save(n Note) {
	s.notes[n.ID] = n
	s.bus.Emit("note.saved", n)
}

그리고 어딘가 먼 초기화 코드에서:

bus.On("note.saved", func(p any) {
	n := p.(Note)
	for _, w := range strings.Fields(n.Body) {
		index[w] = append(index[w], n.ID)
	}
})

무엇이 문제인가. Save 를 읽는 사람은 저장 뒤에 색인이 갱신된다는 것을 알 수 없습니다. "note.saved" 라는 문자열을 전체 검색해야 하고, 검색해도 그것이 런타임에 실제로 등록되는지, 몇 개가 등록되는지, 어떤 순서로 불리는지는 모릅니다. p.(Note) 타입 단언은 컴파일러가 확인해 주지 않으니 payload 의 타입이 바뀌면 런타임 패닉입니다. 이벤트 이름이 문자열이라 오타도 컴파일러가 잡지 못합니다. 1편의 세 증상이 다 있습니다. 색인 형식을 바꾸면 어디를 고쳐야 하는지 모르고(모름), Save 의 결과를 이해하려면 등록 코드를 찾아 읽어야 하고(인지 부하), 새 후처리를 추가할 때마다 같은 등록 의식을 반복합니다(변경 증폭).

After. 호출을 직선으로 폅니다.

// Index 는 단어 → 노트 ID 의 역색인입니다.
type Index struct{ words map[string][]string }

// Add 는 노트의 단어들을 색인에 넣습니다.
func (ix *Index) Add(n Note) {
	for _, w := range strings.Fields(n.Body) {
		ix.words[w] = append(ix.words[w], n.ID)
	}
}

type Service struct {
	index *Index
	notes map[string]Note
}

// Save 는 노트를 저장하고 검색 색인을 갱신합니다.
func (s *Service) Save(n Note) {
	s.notes[n.ID] = n
	s.index.Add(n)
}

무엇이 달라졌나. Save 를 읽으면 색인이 갱신된다는 것이 보입니다. s.index.Add 를 따라가면 무엇이 일어나는지도 보입니다. 타입 단언도, 문자열 이벤트 이름도 없습니다. 색인이 필요 없는 테스트는 빈 Index 를 주면 됩니다.

이벤트 기반 설계가 항상 틀렸다는 말은 아닙니다. 책도 “어떤 상황에서는 유용하니 결국 쓰게 될 것” 이라고 씁니다. 핸들러가 여러 프로세스에 걸쳐 있거나, 등록하는 쪽이 정말로 런타임에 정해지거나, 발행하는 쪽이 구독자를 알아서는 안 되는 경계(플러그인, 다른 팀의 모듈)라면 간접성이 그 값을 합니다. 책의 처방은 그럴 때 핸들러의 인터페이스 주석에 언제 누가 이것을 호출하는지 를 적어 모호성을 보상하라는 것입니다. 위 예제처럼 발행자와 구독자가 같은 프로세스의 같은 패키지에 있고 구독자가 하나뿐이라면, 버스는 흐름을 감출 뿐 아무것도 분리하지 않습니다.

3.3 독자의 기대를 어기는 코드#

책의 세 번째 예는 Java 의 main 이 new RaftClient(...) 로 끝나는 코드입니다. main 이 끝나면 프로그램이 끝난다고 독자는 기대하는데, 생성자가 스레드를 띄워서 프로그램은 계속 돕니다. Go 에서는 정반대의 함정이 있습니다. main 이 반환하면 다른 고루틴이 무엇을 하고 있든 프로그램이 끝납니다. 그래서 생성자가 고루틴을 띄우는 타입은 그 사실을 문서에 적어야 하고, main 은 무엇을 기다리는지가 보여야 합니다.

// NewWatcher 는 dir 를 감시하는 Watcher 를 만들고 감시 고루틴을 즉시 시작합니다.
// 고루틴은 Close 가 호출될 때까지 돕니다. 호출자는 프로그램 종료 전에 Close 를 불러야 합니다.
func NewWatcher(dir string) *Watcher

18장의 결론은 정보의 관점입니다. 코드가 명백하지 않다는 것은 독자에게 없는 중요한 정보 가 있다는 뜻입니다. Pair 예제에서는 First 가 임기라는 정보, RaftClient 예제에서는 생성자가 스레드를 만든다는 정보. 그것을 주는 방법은 셋입니다. 필요한 정보의 양을 줄이거나(추상화, 특수 경우 제거), 독자가 이미 아는 것을 활용하거나(관례, 기대에 맞추기), 코드 안에서 정보를 주거나(이름, 주석). 이 책의 앞 열일곱 장이 그 세 가지의 각론이었습니다.


4. 이번 편의 위험 신호#

위험 신호책의 절이번 편의 예
최소 변경 (땜질)16.1Save 에만 넣은 strings.Contains(id, "/")
불일치 (Inconsistency)17“없음” 을 bool·error·nil·zero value 로 각각 표현
명백하지 않은 코드 (Nonobvious Code)18.2Pair.First, bus.Emit("note.saved", n)

다음 편이 마지막입니다. 19장에서 저자는 객체 지향의 구현 상속, 애자일, TDD, 디자인 패턴을 복잡성의 잣대로 평가합니다. Go 의 struct embedding 이 구현 상속의 함정을 어떻게 재현하는지, 그리고 저자의 TDD 비판에 무엇을 더 말할 수 있는지를 봅니다. 20장의 성능과 21장의 “무엇이 중요한가” 로 시리즈를 맺습니다.


References#

1차 자료

  • Ousterhout, J. K. A Philosophy of Software Design, 1st ed. Yaknyam Press, 2018. 본문 인용(16.1~16.6, 17.1~17.3, 18.1~18.3절)은 1판 원문을 직접 옮긴 것입니다.
  • Go 표준 도구 — gofmt, go vet — 17.2절의 “자동 검사기” 에 해당하는 도구입니다.

본문의 코드

  • 모든 Go 코드는 go version go1.26.0 darwin/arm64 에서 go vet 과 테스트를 통과했습니다. Before 의 경로 탈출은 임시 디렉터리 밖에 놓은 파일을 Load("../secret") 으로 실제로 읽어 재현했습니다. ByTitle("") 이 제목 없는 노트를 돌려주는 것도 테스트로 확인했습니다.

시리즈