Go 로 읽는 소프트웨어 설계의 철학 Part 4: 합칠 것인가 나눌 것인가, 그리고 두 번 설계하기
이 글은 Claude Fable 5.1 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.
3편까지가 “모듈 하나를 어떻게 깊게 만드는가” 였다면, 이번 편의 9장은 모듈의 경계를 어디에 긋는가 입니다. 두 기능을 한 곳에 둘 것인가, 나눌 것인가. 이 질문은 함수, 메서드, 타입, 패키지, 서비스 어느 수준에서나 나옵니다.
11장 “Design it Twice” 는 짧은 장이지만, 9장의 질문에 답하는 방법 이라서 함께 묶었습니다. 경계를 어디에 그을지 모르겠으면 두 가지로 그어 보고 비교하라는 것입니다.
1. 나누면 생기는 비용 (9장)#
시스템을 작은 조각으로 나누면 조각 하나하나는 단순해집니다. 그래서 “잘게 나눌수록 좋다” 는 직관이 생깁니다. 책은 나누는 행위 자체가 없던 복잡성을 만든다 는 데서 출발합니다. 네 가지입니다.
| 비용 | 설명 |
|---|---|
| 개수 | 조각이 많을수록 전체를 파악하기 어렵고, 원하는 조각을 찾기 어렵습니다. 조각마다 인터페이스가 생깁니다 |
| 관리 코드 | 객체 하나를 쓰던 코드가 여러 객체를 조립하고 관리해야 합니다 |
| 거리 | 나뉜 조각은 다른 파일, 다른 패키지로 멀어집니다. 조각들이 서로 의존하면 읽는 사람이 왔다 갔다 해야 하고, 의존이 있다는 사실조차 모를 수 있습니다 |
| 중복 | 한 곳에 있던 코드가 조각마다 필요해질 수 있습니다 |
그래서 합치는 것이 이득인 경우는 두 조각이 밀접할 때 입니다. 책이 드는 밀접함의 징후는 정보를 공유한다, 함께 쓰인다(양방향일 때만), 개념적으로 겹친다, 한쪽을 이해하려면 다른 쪽을 봐야 한다는 것입니다. 2편의 덤프 형식 예제가 첫 번째 징후였습니다. 읽는 쪽과 파싱하는 쪽이 같은 형식 지식을 공유하니 합쳐야 했습니다.
1.1 “20줄 넘으면 나눠라” — 이어 붙은 메서드#
원칙. 메서드는 길이가 아니라 추상화의 깨끗함으로 나눕니다.
책의 9.8절은 이 책에서 가장 논쟁적인 절입니다.
“However, length by itself is rarely a good reason for splitting up a method. In general, developers tend to break up methods too much.” (9.8절)
2판에서는 이 절 근처에 Clean Code 와의 견해 차이를 다루는 절이 추가되었습니다. 로버트 마틴은 함수가 작아야 하고, 그보다 더 작아야 한다고 씁니다. 오스터하우트는 길이가 아니라 깊이 가 기준이라고 씁니다. 수백 줄짜리 메서드도 시그니처가 단순하고 읽기 쉬우면 괜찮다는 것입니다.
Before. 노트 임포트 함수를 “20줄 규칙” 에 맞춰 나눈 모습입니다.
// importState 는 단계 사이를 오가는 공유 상태입니다. 어느 함수가 무엇을 채우는지는 읽어 봐야 압니다.
type importState struct {
sc *bufio.Scanner
expected int
titles []string
bodies []string
notes []Note
}
func Import(r io.Reader) ([]Note, error) {
st := &importState{sc: bufio.NewScanner(r)}
if err := readCount(st); err != nil {
return nil, err
}
if err := readRecords(st); err != nil {
return nil, err
}
if err := validate(st); err != nil {
return nil, err
}
build(st)
return st.notes, nil
}
func readCount(st *importState) error {
if !st.sc.Scan() {
return errors.New("empty input")
}
n, err := strconv.Atoi(strings.TrimSpace(st.sc.Text()))
if err != nil {
return fmt.Errorf("bad count: %w", err)
}
st.expected = n
return nil
}
func readRecords(st *importState) error { /* titles, bodies 를 채운다. 11줄 */ }
func validate(st *importState) error { /* expected 와 titles 를 비교한다. 10줄 */ }
func build(st *importState) { /* titles, bodies 로 notes 를 만든다. 5줄 */ }
무엇이 문제인가. 함수 다섯 개가 각각 20줄 이하입니다. 그런데 어느 하나도 혼자서는 이해되지 않습니다. validate 를 읽으면 st.expected 와 st.titles 가 어디서 채워지는지 알아야 하고, 그러려면 readCount 와 readRecords 를 봐야 합니다. build 는 titles 와 bodies 의 인덱스가 짝이 맞는다는 것을 전제하는데, 그 전제는 readRecords 에 있습니다. 책은 이것을 conjoined methods 라는 위험 신호로 부릅니다. 한 메서드의 구현을 다른 메서드의 구현을 보지 않고는 이해할 수 없는 상태입니다.
importState 가 그 증거입니다. 나누기 전에는 지역 변수였던 것들이 나누느라 구조체 필드가 되었고, 그 구조체는 다섯 함수 사이를 포인터로 돌아다닙니다. 함수를 나눈 것이 아니라 함수 하나를 다섯 조각으로 찢어서 공유 상태로 다시 꿰맨 것입니다. 인터페이스가 넷 늘었는데(각 조각의 시그니처) 기능은 그대로입니다. 2편의 용어로 얕은 메서드가 넷 생긴 셈입니다.
After. 하나로 되돌립니다.
// Import 는 "개수\n제목|본문\n..." 형식을 읽어 노트 목록으로 돌려줍니다.
// 개수가 맞지 않거나 제목이 비어 있으면 에러입니다.
func Import(r io.Reader) ([]Note, error) {
sc := bufio.NewScanner(r)
// 첫 줄: 레코드 개수
if !sc.Scan() {
return nil, errors.New("empty input")
}
expected, err := strconv.Atoi(strings.TrimSpace(sc.Text()))
if err != nil {
return nil, fmt.Errorf("bad count: %w", err)
}
// 나머지 줄: "제목|본문". 읽으면서 바로 검증한다.
var notes []Note
for sc.Scan() {
title, body, ok := strings.Cut(sc.Text(), "|")
if !ok {
return nil, fmt.Errorf("bad record: %q", sc.Text())
}
if strings.TrimSpace(title) == "" {
return nil, fmt.Errorf("record %d: empty title", len(notes)+1)
}
notes = append(notes, Note{Title: title, Body: body})
}
if err := sc.Err(); err != nil {
return nil, err
}
if len(notes) != expected {
return nil, fmt.Errorf("expected %d records, got %d", expected, len(notes))
}
return notes, nil
}
무엇이 달라졌나. 함수 하나, 인터페이스 하나입니다. importState 는 사라지고 지역 변수로 돌아갔습니다. 위에서 아래로 한 번 읽으면 끝납니다. 블록 사이의 경계는 빈 줄과 짧은 주석으로 표시했습니다. 책이 말하는 “5개의 20줄 블록이 순서대로 실행되는 메서드” 는 블록 단위로 읽으면 되니 나눌 이유가 없다는 경우입니다.
Before 에서는 titles 와 bodies 를 다 모은 뒤에 검증했지만, After 에서는 읽으면서 바로 검증합니다. 중간 표현이 사라지니 자연스럽게 그렇게 됩니다. 나누느라 생긴 중간 자료구조는 합치면 없어집니다.
책이 나누기를 전부 반대하는 것은 아닙니다. 9.8절은 좋은 분할의 조건을 분명히 적습니다. 하위 작업을 떼어내는 분할 이 가장 좋고, 그 조건은 두 가지입니다. 떼어낸 쪽을 읽는 사람이 원래 함수를 몰라도 되고, 원래 함수를 읽는 사람이 떼어낸 쪽의 구현을 몰라도 되어야 합니다. 보통 떼어낸 쪽이 범용적 이라는 뜻입니다. strings.Cut 이 정확히 그런 함수입니다. Import 는 Cut 의 구현을 모르고, Cut 은 Import 를 모릅니다. 반면 readCount(st *importState) 는 Import 말고는 어디서도 쓸 수 없습니다.
Go 에서 이 판단을 돕는 신호가 하나 있습니다. 떼어낸 함수의 인자가 공유 상태 구조체 포인터 하나뿐이라면, 그 함수는 하위 작업이 아니라 원래 함수의 조각일 가능성이 높습니다. 진짜 하위 작업은 필요한 값만 받고 결과만 돌려줍니다.
1.2 범용 코드와 특수 목적 코드 — undo#
원칙. 범용 메커니즘과 그것을 특정 용도에 맞춘 코드는 분리합니다.
9장의 세 예제 중 가장 긴 것이 편집기의 undo 입니다. 책의 설명은 이렇습니다. 일부 학생 팀은 undo 전체를 텍스트 클래스 안에 구현했습니다. 텍스트 클래스가 undo 목록을 들고 있고, 본문이 바뀔 때마다 항목을 추가합니다. 그런데 요구사항에 커서·선택·뷰의 undo 도 있었습니다. 그래서 UI 코드가 텍스트 클래스의 메서드를 불러 커서 변경도 목록에 넣고, undo 때 텍스트 클래스가 UI 로 콜백 해서 커서를 되돌립니다.
Before. 그 구조를 Go 로 옮깁니다.
// Text 는 본문과 undo 목록을 함께 들고 있습니다. undo 목록에는 본문 변경뿐 아니라
// 커서·선택 변경도 들어가며, 그것을 되돌리려면 UI 로 콜백해야 합니다.
type Text struct {
s string
history []entry
cursor int // history 안의 현재 위치
// UI 가 등록하는 콜백. 커서 항목을 되돌릴 때 부른다.
OnCursorRestore func(pos int)
}
type entry struct {
kind entryKind
pos int
text string // insert/delete 에 쓰는 문자열
oldPos int // cursor 항목에 쓰는 이전 위치
newPos int
unusedAt int // 다른 항목 종류가 늘 때마다 필드가 늘어난다
}
// RecordCursorMove 는 UI 가 커서를 옮길 때마다 불러 줘야 합니다.
func (t *Text) RecordCursorMove(oldPos, newPos int) {
t.push(entry{kind: kindCursor, oldPos: oldPos, newPos: newPos})
}
func (t *Text) Undo() {
if t.cursor == 0 {
return
}
t.cursor--
e := t.history[t.cursor]
switch e.kind {
case kindInsert:
t.s = t.s[:e.pos] + t.s[e.pos+len(e.text):]
case kindDelete:
t.s = t.s[:e.pos] + e.text + t.s[e.pos:]
case kindCursor:
if t.OnCursorRestore != nil {
t.OnCursorRestore(e.oldPos)
}
}
}
무엇이 문제인가. Text 안에 세 가지가 섞여 있습니다. 본문을 다루는 범용 기능, 동작 목록을 관리하는 범용 undo 메커니즘, 그리고 커서라는 UI 의 특수 목적 항목. 마지막 것이 문제입니다. 커서는 텍스트 타입의 다른 어떤 것과도 관계가 없는데 여기 들어와 있습니다. entry 구조체는 모든 항목 종류의 필드를 합친 모양이라 새 종류가 생길 때마다 자라고, Undo 의 switch 도 자랍니다. 선택 영역 undo 를 추가하려면 Text 에 RecordSelectionChange 와 OnSelectionRestore 를 추가해야 합니다. UI 기능 하나가 텍스트 타입의 메서드 두 개가 됩니다. 3편에서 본 정보 누출 그대로입니다.
책은 이것을 special-general mixture 라는 위험 신호로 부릅니다. 범용 메커니즘 안에 특정 용도의 코드가 들어 있는 상태입니다.
After. 책이 제시하는 History 클래스를 Go 로 옮깁니다. 핵심은 범용 부분을 떼어내는 것입니다.
// Package history 는 되돌릴 수 있는 동작의 목록을 관리합니다. 동작이 무엇인지는 모릅니다.
package history
// Action 은 되돌리고 다시 할 수 있는 동작 하나입니다.
type Action interface {
Undo()
Redo()
}
type History struct {
actions []Action
fences map[int]bool // 이 인덱스 앞에 울타리가 있다
cursor int
}
// Add 는 방금 실행한 동작을 기록합니다. 그 뒤의 redo 이력은 버립니다.
func (h *History) Add(a Action) { /* actions 를 cursor 에서 자르고 덧붙인다. 8줄 */ }
// AddFence 는 동작 묶음의 경계를 표시합니다. Undo 는 울타리까지 되돌립니다.
func (h *History) AddFence() { h.fences[h.cursor] = true }
// Undo 는 직전 울타리까지의 동작을 역순으로 되돌립니다.
func (h *History) Undo() {
for h.cursor > 0 {
h.cursor--
h.actions[h.cursor].Undo()
if h.fences[h.cursor] {
return
}
}
}
// Redo 는 다음 울타리까지의 동작을 다시 합니다.
func (h *History) Redo() { /* Undo 의 대칭. 9줄 */ }
텍스트 타입은 자기 변경을 Action 으로 만들어 넘기기만 합니다. 커서는 모릅니다.
type insert struct {
t *Text
pos int
text string
}
func (a insert) Redo() { a.t.s = a.t.s[:a.pos] + a.text + a.t.s[a.pos:] }
func (a insert) Undo() { a.t.s = a.t.s[:a.pos] + a.t.s[a.pos+len(a.text):] }
func (t *Text) Insert(pos int, s string) {
a := insert{t, pos, s}
a.Redo()
t.h.Add(a)
}
UI 쪽은 커서 이동을 자기 Action 으로 만듭니다. 이 코드는 텍스트 패키지에 없습니다.
type cursorMove struct {
cursor *int
from, to int
}
func (c cursorMove) Undo() { *c.cursor = c.from }
func (c cursorMove) Redo() { *c.cursor = c.to }
그리고 “글자 입력 + 커서 이동” 을 한 번의 undo 로 묶는 정책은 UI 최상위에서 울타리로 정합니다.
h.AddFence()
tx.Insert(5, " world")
h.Add(cursorMove{&cursor, 5, 11})
cursor = 11
h.Undo() // 본문도 커서도 한 번에 돌아온다
무엇이 달라졌나. 책이 정리한 대로 undo 가 세 층으로 갈라졌습니다.
| 층 | 무엇을 아는가 | 어디에 있는가 |
|---|---|---|
동작 목록의 관리와 묶음 (History) | 동작이 무엇인지 모름. 어느 앱에나 쓸 수 있음 | history 패키지 |
각 동작의 내용 (insert, cursorMove) | 자기 동작 하나만 앎 | 텍스트 타입 안, UI 안 |
묶는 정책 (AddFence 호출) | 사용자에게 “한 번의 동작” 이 무엇인지 앎 | UI 최상위 |
각 층은 다른 층을 몰라도 구현할 수 있습니다. 선택 영역 undo 를 추가하려면 UI 에 selectionChange 타입 하나를 만들면 끝이고, History 도 Text 도 바뀌지 않습니다. entry 구조체와 switch 는 사라졌습니다. 테스트에서 텍스트 삽입과 커서 이동을 울타리로 묶고 Undo 한 번에 둘 다 돌아오는 것, Redo 로 둘 다 돌아가는 것을 확인했습니다.
Go 에서 이 구조는 특별할 것이 없습니다. 메서드 두 개짜리 인터페이스와 그것을 만족하는 작은 struct 들입니다. 오히려 Java 의 익명 클래스보다 가볍습니다. 책의 표현으로 “핵심 설계 결정은 undo 메커니즘의 범용 부분을 특수 목적 부분에서 떼어내 독립된 클래스에 둔 것이고, 그러고 나면 나머지 설계는 저절로 따라온다” 는 것이 이 예제입니다.
한 가지 주의점을 책이 덧붙입니다. 범용/특수 분리는 같은 메커니즘 안에서 의 이야기입니다. 텍스트 삽입을 되돌리는 코드(insert.Undo)는 undo 메커니즘의 특수 목적 코드이므로 History 에 있으면 안 되지만, 텍스트 타입에 있는 것은 괜찮습니다. 텍스트의 다른 기능과 밀접하기 때문입니다.
1.3 로깅 클래스 — 나누지 말았어야 할 것#
9.6절의 짧은 예제도 옮겨 둘 만합니다. 한 학생 프로젝트는 에러를 잡은 자리에서 바로 로그를 남기지 않고, 같은 파일 끝에 NetworkErrorLogger 클래스를 두고 logRpcOpenError(req, dest, e) 같은 메서드를 불렀습니다. 메서드마다 한 줄짜리 logger.log(...) 였고, 각각 한 곳에서만 호출되었으며, 문서 주석이 코드보다 길었습니다.
Go 로 옮기면 이런 모양입니다.
// Before: 한 번만 쓰이는 한 줄짜리 로깅 함수
func logRPCOpenError(req Request, dest string, err error) {
slog.Warn("cannot send message", "req", req, "dest", dest, "err", err)
}
// After: 잡은 자리에서 바로
conn, err := pool.Get(dest)
if err != nil {
slog.Warn("cannot send message", "req", req, "dest", dest, "err", err)
return nil
}
책의 진단은 이렇습니다. 호출하는 자리를 읽는 사람은 무엇이 기록되는지 확인하러 로깅 메서드로 넘어가고, 로깅 메서드를 읽는 사람은 왜 이 정보가 필요한지 알려고 호출 자리로 넘어갑니다. 서로를 봐야 이해되는 두 조각, 즉 conjoined methods 입니다. 1.1의 readCount 와 같은 병입니다. 나누어서 얻은 것은 없고 인터페이스만 늘었습니다.
2. 두 번 설계하라 (11장)#
“Designing software is hard, so it’s unlikely that your first thoughts about how to structure a module or system will produce the best design.” (11장)
방법은 단순합니다. 중요한 설계 결정마다 서로 근본적으로 다른 대안을 두 개 이상 스케치하고, 장단점을 적고, 비교합니다. 모든 기능을 다 정할 필요는 없고 핵심 메서드 몇 개면 됩니다. 비교 기준의 으뜸은 상위 코드가 쓰기 쉬운가 이고, 그다음이 인터페이스의 단순함, 범용성, 효율적인 구현 가능성입니다.
책의 예제는 다시 텍스트 클래스입니다. 줄 단위 인터페이스, 글자 단위 인터페이스, 범위 단위 인터페이스 셋을 놓고 비교합니다. 3편에서 우리는 범위 단위를 썼습니다. 이번에는 그 결정을 두 번 설계하기 로 다시 내려 봅니다. Go 의 인터페이스 타입은 이 연습에 잘 맞습니다. 구현 없이 시그니처만 적어도 컴파일이 되니, 스케치를 코드로 할 수 있습니다.
설계 A: 줄 단위.
type LineText interface {
LineCount() int
Line(i int) string
SetLine(i int, s string)
InsertLine(i int, s string)
DeleteLine(i int)
}
설계 B: 범위 단위.
type Pos struct{ Line, Col int }
type RangeText interface {
Insert(p Pos, s string)
Delete(start, end Pos)
}
인터페이스만 보면 A 가 더 “완전해” 보입니다. 메서드가 다섯이고 할 수 있는 일이 분명합니다. 그런데 책의 첫 번째 기준은 인터페이스가 아니라 상위 코드 입니다. 편집기가 가장 자주 하는 두 가지 일을 각 설계 위에 써 봅니다.
커서 위치에 글자 하나 입력:
func TypeCharA(t LineText, p Pos, ch string) {
l := t.Line(p.Line)
t.SetLine(p.Line, l[:p.Col]+ch+l[p.Col:])
}
func TypeCharB(t RangeText, p Pos, ch string) { t.Insert(p, ch) }
여러 줄에 걸친 선택 영역 삭제:
func DeleteRangeA(t LineText, start, end Pos) {
joined := t.Line(start.Line)[:start.Col] + t.Line(end.Line)[end.Col:]
for i := end.Line; i > start.Line; i-- {
t.DeleteLine(i)
}
t.SetLine(start.Line, joined)
}
func DeleteRangeB(t RangeText, start, end Pos) { t.Delete(start, end) }
두 설계 모두 최소 구현을 붙여서 같은 입력에 같은 결과가 나오는 것을 테스트로 확인했습니다. 기능은 같습니다. 차이는 상위 코드에 있습니다.
| 기준 | A: 줄 단위 | B: 범위 단위 |
|---|---|---|
| 상위 코드가 쓰기 쉬운가 | 글자 입력에 줄 쪼개기, 범위 삭제에 줄 합치기 루프가 상위 코드에 들어감. 편집기의 모든 동작마다 반복됨 | 한 줄 |
| 인터페이스의 단순함 | 메서드 5개 | 메서드 2개 |
| 범용성 | 줄 단위 도구에는 맞음 | 문자열 치환, 편집기, 줄 단위 도구 모두 가능 |
| 효율적 구현 | 줄 하나를 통째로 문자열로 주고받음 | 구현이 내부 표현을 자유롭게 고를 수 있음 |
| 정보 은닉 | “본문은 줄의 목록이다” 가 인터페이스에 드러남 | 내부 표현이 보이지 않음 |
책이 이 비교에서 짚는 것은 결론보다 과정 입니다. A 와 글자 단위 설계(책의 두 번째 대안)를 나란히 놓으면 둘 다 상위 코드에 텍스트 조작이 새어 나간다는 공통점이 보이고, 그것이 위험 신호이며, “텍스트 클래스가 있다면 텍스트 조작은 전부 거기서 해야 한다” 는 원칙이 도출되고, 그 원칙이 범위 단위 설계로 이끕니다. 대안이 하나뿐이었다면 이 추론은 시작되지 않았을 것입니다.
같은 방법을 구현 단계에도 다시 씁니다. 범위 단위 인터페이스를 정한 뒤, 내부를 줄의 슬라이스로 할지, 고정 크기 블록으로 할지, gap buffer 로 할지 또 두세 가지를 놓고 비교합니다. 이때의 기준은 인터페이스 때와 다릅니다. 단순함과 성능입니다.
책은 이 연습이 오래 걸리지 않는다고 덧붙입니다. 타입 하나 정도의 모듈이라면 한두 시간이고, 구현에 드는 며칠에 비하면 작은 투자입니다. 그리고 11장의 마지막에 저자가 관찰한 것이 하나 적혀 있습니다. 똑똑한 사람일수록 이 원칙을 받아들이기 어려워한다는 것입니다. 첫 번째 아이디어로 늘 좋은 점수를 받아 왔기 때문에, 두 번째 설계를 고려하는 것이 자신이 똑똑하지 않다는 뜻처럼 느껴진다는 것입니다. 저자의 답은 “당신이 똑똑하지 않은 것이 아니라 문제가 정말 어려운 것” 입니다.
3. 이번 편의 위험 신호#
| 위험 신호 | 책의 절 | 이번 편의 예 |
|---|---|---|
| 반복 (Repetition) | 9.4 | 책의 그림 9.1, 패킷 종류마다 반복되는 로그문 |
| 범용-특수 혼합 (Special-General Mixture) | 9.5 | 커서 undo 를 끌어안은 Text |
| 이어 붙은 메서드 (Conjoined Methods) | 9.8 | importState 를 주고받는 다섯 함수, 한 번만 쓰이는 로깅 함수 |
다음 편은 10장 “Define Errors Out Of Existence” 입니다. 이 시리즈에서 Go 가 가장 할 말이 많은 장입니다. 예외가 없는 언어에서 if err != nil 을 줄이는 방법은 에러를 잘 다루는 것이 아니라 에러가 생길 수 없게 API 를 정의하는 것이라는 이야기입니다.
References#
1차 자료
- Ousterhout, J. K. A Philosophy of Software Design, 1st ed. Yaknyam Press, 2018. 본문 인용(9장 도입부, 9.1~9.8, 11장)은 1판 원문을 직접 옮긴 것입니다.
History클래스의 인터페이스(Action,addAction,addFence,undo,redo)는 9.7절의 Java 선언을 Go 로 옮긴 것입니다. - 저자의 책 소개 페이지 — 2판에서 Clean Code 와 비교하는 절이 추가되었다는 사실의 출처입니다. 이 글은 1판 원문을 기준으로 썼으므로 그 절의 내용은 인용하지 않았습니다.
본문의 코드
- 모든 Go 코드는
go version go1.26.0 darwin/arm64에서go vet과 테스트를 통과했습니다./* ... N줄 */로 줄인 부분은 지면 관계로 생략한 것이고, 실제 코드는 전부 컴파일되어 테스트를 통과했습니다. - 설계 A 와 B 의 비교는 두 인터페이스에 각각 최소 구현을 붙여 같은 입력(
"ab\ncd\nef"에서 글자 입력 후 여러 줄 삭제)에 같은 결과("af")가 나오는 것을 확인한 뒤에 쓴 것입니다.
시리즈