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


2편에서 “모듈은 깊어야 한다” 는 목표와 “정보를 숨겨라” 는 기본 기법을 봤습니다. 이번 편의 6~8장은 그 목표에 이르는 구체적인 규칙 세 가지 입니다.

  • 6장: 모듈을 어느 정도 범용적으로 만들어라
  • 7장: 인접한 계층은 다른 추상화를 가져야 한다
  • 8장: 복잡성을 아래로 끌어내려라

세 장은 서로 붙어 있습니다. 범용 인터페이스는 더 적은 메서드로 더 많은 일을 하니 깊어지고(6장), 계층이 같은 추상화를 반복하면 인터페이스만 늘고 기능은 늘지 않으니 얕아지며(7장), 어차피 누군가 감당해야 할 복잡성이라면 모듈 안에서 감당하는 쪽이 사용자가 많을수록 이득입니다(8장).


1. 범용 모듈이 더 깊다 (6장)#

새 모듈을 만들 때 늘 부딪히는 질문이 있습니다. 지금 필요한 것만 딱 맞게 만들 것인가, 나중을 위해 범용으로 만들 것인가. 책의 답은 둘 다 아닙니다.

“In my experience, the sweet spot is to implement new modules in a somewhat general-purpose fashion. The phrase ‘somewhat general-purpose’ means that the module’s functionality should reflect your current needs, but its interface should not.” (6.1절)

구현은 지금의 필요를, 인터페이스는 그보다 조금 더 넓은 범위를 반영하라는 것입니다. 그리고 책은 뜻밖의 주장을 덧붙입니다. 범용 인터페이스의 가장 큰 이점은 재사용이 아니라 단순함 이라는 것입니다. 다른 곳에 다시 쓰이지 않더라도 범용 쪽이 더 낫다는 뜻입니다.

1.1 키마다 메서드 하나 — 특수 목적 인터페이스#

원칙. 인터페이스는 상위 계층의 기능이 아니라 이 모듈의 기본 연산으로 정의합니다.

책의 6.2절 예제는 설계 수업의 텍스트 편집기입니다. 많은 팀이 텍스트 클래스에 편집기의 기능을 그대로 반영했습니다. 백스페이스 키를 위한 backspace(cursor), 삭제 키를 위한 delete(cursor), 선택 삭제를 위한 deleteSelection(selection).

Before. Go 로 옮기면 이렇습니다. 본문은 줄 단위로 저장합니다.

// Text 는 줄 단위로 저장되는 본문입니다.
type Text struct{ lines []string }

// Cursor 는 편집기 커서 위치입니다.
type Cursor struct{ Line, Col int }

// Selection 은 편집기의 선택 영역입니다.
type Selection struct{ Start, End Cursor }

// Backspace 는 커서 왼쪽 글자 하나를 지웁니다. 줄 머리에서는 윗줄과 합칩니다.
func (t *Text) Backspace(c Cursor) {
	if c.Col > 0 {
		l := t.lines[c.Line]
		t.lines[c.Line] = l[:c.Col-1] + l[c.Col:]
		return
	}
	if c.Line > 0 {
		t.lines[c.Line-1] += t.lines[c.Line]
		t.lines = append(t.lines[:c.Line], t.lines[c.Line+1:]...)
	}
}

// Delete 는 커서 오른쪽 글자 하나를 지웁니다. 줄 끝에서는 아랫줄과 합칩니다.
func (t *Text) Delete(c Cursor) { /* Backspace 와 대칭. 12줄 */ }

// DeleteSelection 은 선택 영역을 지웁니다. (여러 줄에 걸친 경우는 아직 미지원)
func (t *Text) DeleteSelection(s Selection) {
	if s.Start.Line != s.End.Line {
		return
	}
	l := t.lines[s.Start.Line]
	t.lines[s.Start.Line] = l[:s.Start.Col] + l[s.End.Col:]
}

무엇이 문제인가. 책이 지적하는 것은 두 가지입니다.

첫째, 메서드가 많은데 각각이 얕습니다. 세 메서드가 모두 “글자를 지운다” 는 같은 일을 하는데, 줄 합치기 로직이 Backspace 와 Delete 에 한 번씩 중복되어 있고, DeleteSelection 은 여러 줄을 지원하지 않습니다. 세 개를 다 제대로 만들려면 같은 로직을 세 번 써야 합니다.

둘째, 정보가 누출됩니다. Cursor 와 Selection 은 편집기 화면 의 개념입니다. 그것이 텍스트 저장 타입에 들어와 있습니다. 편집기에 “단어 단위 삭제” 기능을 추가하면 텍스트 타입에 DeleteWord 를 추가해야 합니다. 화면 쪽 작업이 저장 쪽 작업이 됩니다. 두 모듈을 따로 개발할 수 없게 됩니다.

책은 여기에 날카로운 관찰을 하나 더 얹습니다. backspace 메서드는 거짓 추상화(false abstraction) 라는 것입니다. 어떤 글자가 지워지는지를 숨기는 척하지만, 편집기 쪽 개발자는 그것을 정확히 알아야 합니다. 그래서 결국 텍스트 클래스의 코드를 읽으러 갑니다. 숨겨서는 안 되는 정보를 숨긴 것이고, 결과는 모호성입니다.

1.2 두 메서드로 — 범용 인터페이스#

After. 책의 6.3절이 제시하는 인터페이스를 그대로 옮깁니다. 내부 표현은 여전히 줄 단위입니다.

// Text 는 줄 단위로 저장되지만, 인터페이스는 글자 위치로만 말합니다.
type Text struct{ lines []string }

// Position 은 본문 안의 글자 위치입니다.
type Position struct{ Line, Col int }

// Insert 는 pos 에 s 를 끼워 넣습니다. s 에 개행이 있으면 줄이 늘어납니다.
func (t *Text) Insert(pos Position, s string) {
	l := t.lines[pos.Line]
	parts := strings.Split(l[:pos.Col]+s+l[pos.Col:], "\n")
	t.lines = append(t.lines[:pos.Line], append(parts, t.lines[pos.Line+1:]...)...)
}

// Delete 는 start 이상 end 미만의 글자를 지웁니다. 여러 줄에 걸쳐도 됩니다.
func (t *Text) Delete(start, end Position) {
	joined := t.lines[start.Line][:start.Col] + t.lines[end.Line][end.Col:]
	t.lines = append(t.lines[:start.Line], append([]string{joined}, t.lines[end.Line+1:]...)...)
}

// Move 는 pos 에서 n 글자 떨어진 위치를 돌려줍니다. 줄 경계를 넘어갑니다.
// 개행도 한 글자로 셉니다. 본문 밖으로는 나가지 않습니다.
func (t *Text) Move(pos Position, n int) Position { /* 줄 경계 처리. 20줄 */ }

편집기 쪽 코드는 이렇게 됩니다. 텍스트 타입은 백스페이스가 무엇인지 모릅니다.

func Backspace(t *Text, cur Position) { t.Delete(t.Move(cur, -1), cur) }

func DeleteKey(t *Text, cur Position) { t.Delete(cur, t.Move(cur, 1)) }

func DeleteSelection(t *Text, start, end Position) { t.Delete(start, end) }

무엇이 달라졌나. 지우는 메서드가 셋에서 하나로 줄었습니다. 줄 합치기 로직은 Delete 안에 한 번만 있습니다. 그런데 기능은 늘었습니다. 여러 줄에 걸친 선택 삭제가 공짜로 생겼습니다. Delete 가 원래 임의의 두 위치 사이를 지우니, 그 두 위치가 다른 줄에 있어도 됩니다. 테스트로 "ab\ncd\nef" 에서 (0,1) 부터 (2,1) 까지 지우면 "af" 가 나옵니다. Before 에서는 “아직 미지원” 이던 기능입니다.

편집기 쪽 코드는 한 줄씩 길어졌습니다. 그러나 책의 말대로 더 명백해졌습니다. 백스페이스가 “커서 한 칸 앞부터 커서까지 지운다” 는 것이 그 한 줄에 그대로 보입니다. 이 정보는 편집기 개발자가 알아야 하는 것이고, 이제는 텍스트 타입의 코드를 열지 않아도 알 수 있습니다.

그리고 새 용도가 생겼습니다. 파일 안의 문자열을 치환하는 도구를 만든다면 Backspace 는 쓸모가 없지만 Delete 와 Insert 는 그대로 쓰입니다. 테스트에 그 경우도 넣어 두었습니다. 다만 책의 강조점을 다시 옮기면, 이 재사용은 덤 입니다. 재사용이 없더라도 After 가 더 단순하다는 것이 6장의 주장입니다.

1.3 얼마나 범용적이어야 하는가#

“어느 정도” 를 정하는 기준으로 책은 세 질문을 줍니다.

질문뜻
현재의 모든 필요를 덮는 가장 단순한 인터페이스 는 무엇인가기능을 줄이지 않으면서 메서드 수를 줄일 수 있으면 그것이 더 범용적인 것입니다. 단, 메서드를 줄이려고 인자를 잔뜩 늘리면 단순해진 것이 아닙니다
이 메서드는 몇 가지 상황 에서 쓰이는가한 가지 용도만 있는 메서드(Backspace)는 너무 특수하다는 신호입니다
이 API 는 지금의 필요 에 쓰기 쉬운가너무 범용으로 갔는지 잡아 주는 질문입니다. 글자 하나씩만 넣고 지우는 API 는 단순하고 범용적이지만, 편집기를 만들려면 루프투성이가 됩니다

세 번째 질문이 있어서 이 규칙은 “무조건 범용” 이 아닙니다. Insert 가 문자열을 받고 Delete 가 범위를 받는 것이 그 균형점입니다.


2. 다른 계층, 다른 추상화 (7장)#

시스템은 계층으로 쌓입니다. 파일 시스템의 맨 위는 “가변 길이 바이트 배열” 이고, 그 아래는 “고정 크기 블록의 캐시” 이고, 맨 아래는 “장치 드라이버” 입니다. 한 연산이 계층을 따라 내려갈 때마다 추상화가 바뀝니다. 그런데 인접한 두 계층이 같은 추상화 를 가지고 있다면, 그것은 계층 나누기가 잘못됐다는 신호입니다.

2.1 pass-through 메서드 — 있으나 마나 한 계층#

원칙. 기능의 인터페이스는 그 기능을 구현하는 곳에 둡니다.

책의 7.1절 예제는 학생의 TextDocument 클래스입니다. 15개 public 메서드 중 13개가 textArea 의 같은 이름 메서드를 그대로 부르는 것이었습니다.

Before. Go 서버에서 흔히 보는 모양으로 옮기면 “서비스 계층” 입니다.

// Store 는 저장소입니다.
type Store struct{ m map[string]Note }

func (s *Store) Get(id string) (Note, bool) { n, ok := s.m[id]; return n, ok }
func (s *Store) Put(n Note)                 { s.m[n.ID] = n }
func (s *Store) Delete(id string)           { delete(s.m, id) }
func (s *Store) All() []Note                { /* map 을 슬라이스로 */ }

// Service 는 "비즈니스 계층" 입니다. 다섯 메서드 중 넷이 Store 를 그대로 부릅니다.
type Service struct{ store *Store }

func (v *Service) Get(id string) (Note, bool) { return v.store.Get(id) }
func (v *Service) Put(n Note)                 { v.store.Put(n) }
func (v *Service) Delete(id string)           { v.store.Delete(id) }
func (v *Service) All() []Note                { return v.store.All() }

// Create 만 실질적인 일을 합니다.
func (v *Service) Create(title, body string) (Note, error) {
	if strings.TrimSpace(title) == "" {
		return Note{}, errors.New("title required")
	}
	n := Note{ID: newID(), Title: title, Body: body}
	v.store.Put(n)
	return n, nil
}

무엇이 문제인가. 책의 진단 그대로입니다. pass-through 메서드는 인터페이스를 늘리지만 기능을 늘리지 않습니다. Store.Get 의 시그니처가 바뀌면 Service.Get 도 따라 바뀌어야 하니 의존성도 생깁니다. 그리고 책이 묻는 질문이 있습니다. “이 두 클래스는 각각 정확히 어떤 기능과 추상화를 책임지는가.” 위 코드에서 Service 의 책임은 “제목이 비어 있으면 안 된다” 는 규칙 하나뿐인데, 그 규칙조차 지켜지지 않습니다. Service.Put 이 그대로 열려 있어서 빈 제목의 노트를 넣을 수 있습니다. 테스트로 확인했습니다. 규칙을 지키라고 만든 계층이 규칙을 우회하는 통로를 제공합니다.

책은 해법을 세 가지로 그립니다(그림 7.1). 상위 클래스를 걷어내고 하위 클래스를 직접 노출하거나(b), 기능을 두 클래스에 다시 배분하거나(c), 아예 합치거나(d).

After. 여기서는 합쳤습니다. 저장과 규칙을 한 타입이 맡습니다.

// Notes 는 노트를 만들고 찾고 지웁니다. 규칙(제목 필수, ID 발급)은 여기서만 지킵니다.
type Notes struct {
	m   map[string]Note
	seq int
}

func (v *Notes) Get(id string) (Note, bool) { n, ok := v.m[id]; return n, ok }
func (v *Notes) Delete(id string)           { delete(v.m, id) }

// Create 는 제목이 비어 있지 않은 노트를 만들어 ID 를 발급합니다.
func (v *Notes) Create(title, body string) (Note, error) {
	if strings.TrimSpace(title) == "" {
		return Note{}, errors.New("title required")
	}
	v.seq++
	n := Note{ID: fmt.Sprintf("%03d", v.seq), Title: title, Body: body}
	v.m[n.ID] = n
	return n, nil
}

// Rename 은 제목을 바꿉니다. 빈 제목은 거부합니다.
func (v *Notes) Rename(id, title string) error { /* 조회 → 검증 → 저장. 11줄 */ }

무엇이 달라졌나. Put 이 사라졌습니다. 노트를 바꾸는 방법은 Create 와 Rename 뿐이고, 둘 다 규칙을 지킵니다. 우회로가 없습니다. 타입 하나에 exported 메서드 넷이고, 넷 모두 고유한 일을 합니다.

Go 웹 서비스에서 handler → service → repository 세 계층은 거의 관습입니다. 그 관습 자체가 나쁜 것은 아닙니다. 책도 7.2절에서 같은 시그니처가 괜찮은 경우 를 인정합니다. 디스패처(URL 을 보고 핸들러를 고르는 라우터)나 같은 인터페이스의 여러 구현(디스크 드라이버) 이 그것입니다. 기준은 시그니처가 같으냐가 아니라 각 메서드가 고유한 기능을 더하느냐 입니다. 서비스 계층의 메서드가 저장소를 부르는 것 말고 하는 일이 없다면, 그 계층은 아직 존재할 이유를 얻지 못한 것입니다. 이유가 생기면(트랜잭션, 권한 검사, 이벤트 발행) 그때 만들어도 늦지 않습니다.

2.2 데코레이터에 대하여#

7.3절은 데코레이터 패턴을 다룹니다. Java 의 BufferedInputStream 이 데코레이터입니다. 기존 객체를 감싸서 같은 API 에 기능을 조금 더합니다. 책은 데코레이터가 얕아지기 쉽다 고 경고합니다. 적은 기능에 많은 보일러플레이트, 그리고 pass-through 메서드의 온상이기 때문입니다.

Go 에서 데코레이터는 io.Reader 를 감싸는 타입들과 http.Handler 미들웨어로 흔히 나타납니다. 이것들은 대체로 괜찮은 편입니다. io.Reader 는 메서드가 하나뿐이라 감싸도 pass-through 가 생기지 않고, 미들웨어는 ServeHTTP 하나를 감싸면서 로깅·인증·복구처럼 분명한 기능을 더합니다. 데코레이터가 문제가 되는 것은 감싸는 인터페이스가 넓을 때 입니다. 메서드 열 개짜리 인터페이스를 감싸서 하나만 바꾸면 아홉 개가 pass-through 가 됩니다. 그럴 때 책이 권하는 대안은 “기능을 원래 타입에 직접 넣을 수 없는가”, “독립된 타입으로 만들 수 없는가” 입니다.

2.3 pass-through 변수 — 네 단계를 관통하는 인자#

원칙. 중간 계층이 쓰지 않는 값을 중간 계층의 시그니처에 넣지 않습니다.

책의 7.5절은 다른 종류의 계층 간 중복입니다. main 에서 받은 인증서 정보가 m1, m2 를 거쳐 m3 까지 내려가는데, 정작 쓰는 곳은 m3 뿐입니다. m1 과 m2 는 그 변수의 존재를 알아야 하지만 쓸 일이 없습니다.

Before. 노트 서버의 호출 사슬입니다.

// main → Run → serve → handle → fetch. timeout 과 logger 는 fetch 와 handle 만 쓴다.

func Run(addr string, timeout time.Duration, logger *slog.Logger) error {
	return serve(addr, timeout, logger)
}

func serve(addr string, timeout time.Duration, logger *slog.Logger) error {
	return handle("/notes/1", timeout, logger)
}

func handle(path string, timeout time.Duration, logger *slog.Logger) error {
	logger.Info("handling", "path", path)
	_, err := fetch(path, timeout)
	return err
}

func fetch(path string, timeout time.Duration) (string, error) {
	_ = timeout // 여기서만 실제로 쓴다
	return "note", nil
}

무엇이 문제인가. serve 는 timeout 도 logger 도 쓰지 않는데 시그니처에 둘 다 있습니다. 여기에 메트릭 수집기 하나를 추가하면 네 함수의 시그니처를 전부 고쳐야 합니다. 책이 말하는 대로, 변수가 새로 생길 때마다 그 경로의 모든 메서드를 손봐야 합니다.

책은 해법 네 가지를 검토합니다. 위아래가 이미 공유하는 객체에 넣기, 전역 변수, 그리고 저자가 가장 자주 쓴다는 컨텍스트 객체 입니다. 전역 변수는 한 프로세스에 인스턴스를 두 개 띄울 수 없게 만들고, 그것이 테스트에서 자주 필요하다는 것이 책의 반대 이유입니다.

After. 시스템 인스턴스 하나의 전역 상태를 구조체 하나에 모으고, 메서드로 바꿉니다.

// App 은 시스템 인스턴스 하나의 전역 상태입니다. 생성자에서 한 번 받습니다.
type App struct {
	Timeout time.Duration
	Logger  *slog.Logger
}

func (a *App) Run(addr string) error { return a.serve(addr) }

func (a *App) serve(addr string) error { return a.handle("/notes/1") }

func (a *App) handle(path string) error {
	a.Logger.Info("handling", "path", path)
	_, err := a.fetch(path)
	return err
}

func (a *App) fetch(path string) (string, error) {
	_ = a.Timeout
	return "note", nil
}

무엇이 달라졌나. 중간 함수의 시그니처에서 timeout 과 logger 가 사라졌습니다. 새 값을 추가하면 App 필드 하나와 그것을 쓰는 곳만 바뀝니다. 그리고 테스트에서 App 두 개를 다른 설정으로 만들어 같은 프로세스 안에서 나란히 돌릴 수 있습니다. 전역 변수였다면 불가능했을 일입니다.

이것은 Go 에서 아주 흔한 모양입니다. *Server, *App, *Handler 같은 구조체에 의존성을 담고 메서드를 붙이는 것입니다. 책이 컨텍스트 객체라고 부르는 것이 Go 에서는 그냥 리시버 입니다.

여기서 이름 때문에 헷갈리기 쉬운 것이 하나 있습니다. Go 의 context.Context 는 책의 context 가 아닙니다. context 패키지 문서는 이렇게 못 박습니다.

“Use context Values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions.”

설정값이나 로거를 context.WithValue 로 실어 나르면 책의 pass-through 변수 문제를 해결한 것이 아니라 숨긴 것입니다. 시그니처에서는 사라지지만, 어느 함수가 어떤 값을 꺼내 쓰는지 아무 데도 적혀 있지 않게 됩니다. 1편의 용어로 “모름” 을 만드는 일입니다. context.Context 는 취소와 마감, 그리고 요청 단위로 흘러가는 값(요청 ID, 인증된 사용자)을 위한 것이고, 시스템 전역 상태는 구조체 필드에 둡니다.

책 스스로도 컨텍스트 객체가 이상적인 해법은 아니라고 덧붙입니다. 전역 변수의 단점 대부분을 물려받고, 규율이 없으면 잡동사니 가방이 되며, 스레드 안전성 문제도 생깁니다. 책의 조언은 컨텍스트 안의 값을 불변 으로 두라는 것입니다. Go 에서는 App 의 필드를 생성 후 바꾸지 않는 것으로 같은 효과를 냅니다.


3. 복잡성을 아래로 끌어내려라 (8장)#

8장은 짧지만 이 책에서 가장 자주 인용되는 문장 중 하나를 담고 있습니다.

“it is more important for a module to have a simple interface than a simple implementation.” (8장 도입부)

이유는 산수입니다. 모듈은 만드는 사람보다 쓰는 사람이 많습니다. 그러니 어차피 누군가 감당해야 할 복잡성이라면 만드는 사람이 감당하는 쪽이 총량이 적습니다. 개발자 입장에서는 반대로 하고 싶은 유혹이 큽니다. 잘 모르겠으면 예외를 던져서 호출자에게 넘기고, 정책을 정하기 어려우면 설정 파라미터로 만들어서 운영자에게 넘기는 것입니다.

1.2절의 텍스트 타입이 이미 이 원칙의 예입니다. 줄 단위 저장에 글자 단위 인터페이스를 얹느라 Insert 와 Delete 안에 줄 쪼개기와 합치기가 들어갔습니다. 구현은 복잡해졌지만, 그 복잡성을 편집기 쪽에서 감당했다면 화면 코드 곳곳에 흩어졌을 것입니다.

3.1 설정 파라미터 — 복잡성을 위로 밀어 올리기#

원칙. 설정 파라미터를 내놓기 전에, 모듈이 그 값을 스스로 정할 수 없는지 먼저 묻습니다.

책의 8.2절 예제는 네트워크 프로토콜의 재시도 간격입니다. 설정 파라미터로 내놓을 수도 있지만, 성공한 요청의 응답 시간을 재서 그 몇 배를 쓰면 프로토콜이 스스로 정할 수 있습니다. 그러면 사용자가 값을 고민할 필요가 없고, 환경이 바뀌면 자동으로 따라갑니다. 설정 파라미터는 낡기 쉽지만 측정값은 낡지 않습니다.

Before. 호출자가 네 개의 숫자를 정해야 하는 클라이언트입니다.

// Client 는 재시도하는 클라이언트입니다. 네 개의 값을 호출자가 정해야 합니다.
type Client struct {
	MaxRetries    int
	RetryInterval time.Duration
	BackoffFactor float64
	Timeout       time.Duration
}

func (c *Client) Call() error {
	interval := c.RetryInterval
	var err error
	for i := 0; i <= c.MaxRetries; i++ {
		if err = c.do(); err == nil {
			return nil
		}
		c.sleep(interval)
		interval = time.Duration(float64(interval) * c.BackoffFactor)
	}
	return errors.New("gave up: " + err.Error())
}

무엇이 문제인가. RetryInterval 을 얼마로 해야 할까요. 호출자는 모릅니다. 서버가 보통 40ms 에 응답하는지 400ms 에 응답하는지는 클라이언트가 요청을 보내 봐야 아는 것이고, 그것을 아는 것은 호출자가 아니라 이 모듈입니다. 책의 질문을 그대로 적용하면 이렇습니다. “사용자가 여기서 우리가 정하는 것보다 더 나은 값을 정할 수 있는가.” 답이 아니오라면 파라미터로 내놓을 이유가 없습니다.

그리고 이 네 값은 서로 얽혀 있습니다. BackoffFactor 2 에 MaxRetries 3 이면 마지막 대기가 첫 대기의 8배입니다. 테스트로 확인하면 100ms 로 시작해서 네 번째 대기가 800ms 입니다. 그 조합이 적절한지는 아무도 계산하지 않았을 것입니다. 파라미터가 늘수록 조합의 수는 곱으로 늘고, 그 조합을 검증하는 것은 사용자의 몫이 됩니다.

After. 응답 시간을 재서 스스로 정합니다.

// Client 는 재시도하는 클라이언트입니다. 재시도 간격은 성공한 요청의 응답 시간을 재서 정합니다.
type Client struct {
	rtt time.Duration // 성공 응답 시간의 지수 이동 평균
	do  func() error
}

const (
	maxAttempts = 4
	initialRTT  = 200 * time.Millisecond
)

func New(do func() error) *Client { return &Client{rtt: initialRTT, do: do} }

func (c *Client) Call() error {
	var err error
	for i := 0; i < maxAttempts; i++ {
		start := time.Now()
		if err = c.do(); err == nil {
			c.rtt = (c.rtt*7 + time.Since(start)) / 8
			return nil
		}
		time.Sleep(2 * c.rtt) // 평소 응답 시간의 두 배를 기다린다
	}
	return errors.New("gave up: " + err.Error())
}

무엇이 달라졌나. 인터페이스에서 숫자 네 개가 사라졌습니다. 생성자는 do 하나만 받습니다. 테스트에서 응답에 40ms 걸리는 서버를 흉내 내고 50번 성공시킨 뒤 실패시키면, 재시도 대기가 80ms 근처로 수렴한 것이 확인됩니다. 서버가 느려지면 대기도 길어지고, 빨라지면 짧아집니다. 설정 파일에 적힌 숫자로는 할 수 없는 일입니다.

maxAttempts 와 initialRTT 가 상수로 남아 있다는 점은 짚어 두어야 합니다. 이 둘도 파라미터로 내놓을 수 있지만, 그 전에 같은 질문을 던집니다. 사용자가 더 나은 값을 아는가. 대부분의 경우 아니오이고, 정말 필요한 사용자가 나타나면 그때 옵션을 여는 것이 책의 권고입니다. “합리적인 기본값을 자동으로 계산하고, 예외적인 조건에서만 값을 받아라.”

Go 에서 그 “예외적인 조건” 을 위한 관용구가 functional options 입니다. New(do, WithMaxAttempts(6)) 처럼, 기본값을 쓰는 사람은 옵션의 존재를 몰라도 되고 필요한 사람만 넘깁니다. 2편에서 본 부분적 정보 은닉의 Go 식 표현입니다. 다만 순서가 중요합니다. 옵션은 기본값을 먼저 잘 정한 뒤에 여는 것이지, 정하기 귀찮아서 여는 것이 아닙니다.

3.2 지나치게 하지 않기#

8.3절은 이 원칙의 한계선입니다. 끌어내리기가 말이 되는 조건은 세 가지입니다. 끌어내리는 복잡성이 (a) 그 모듈의 기존 기능과 밀접하고, (b) 다른 곳의 단순화를 많이 낳으며, (c) 그 모듈의 인터페이스를 단순하게 만들 때입니다.

1.1절의 Backspace 가 반례입니다. 편집기의 지식을 텍스트 타입으로 끌어내린 것이니 언뜻 이 원칙을 따른 것처럼 보이지만, 상위 코드가 별로 단순해지지 않았고 편집기 지식은 텍스트 타입의 핵심 기능과 무관합니다. 결과는 끌어내리기가 아니라 정보 누출이었습니다. 원칙을 기계적으로 적용하면 서로 충돌하고, 충돌을 푸는 기준은 언제나 시스템 전체의 복잡성 입니다.


4. 이번 편의 위험 신호#

위험 신호책의 절이번 편의 예
특수 목적 인터페이스6.2Backspace, Delete, DeleteSelection 을 따로 가진 텍스트 타입
pass-through 메서드7.1저장소를 그대로 부르는 서비스 계층
pass-through 변수7.5네 함수를 관통하는 timeout 과 logger
설정 파라미터로 떠넘기기8.2RetryInterval 과 BackoffFactor 를 호출자에게 묻는 클라이언트

다음 편은 9장과 11장입니다. 코드를 합칠지 나눌지 정하는 기준, “메서드는 짧아야 한다” 는 통념에 대한 반론, 그리고 같은 모듈을 두 번 설계해서 비교하는 연습입니다.


References#

1차 자료

  • Ousterhout, J. K. A Philosophy of Software Design, 1st ed. Yaknyam Press, 2018. 본문 인용(6.1~6.5, 7.1~7.5, 8장 도입부, 8.2~8.3절)은 1판 원문을 직접 옮긴 것입니다. 2판에서 6장이 재편되었으므로 6장의 절 번호는 2판과 다를 수 있습니다.
  • Go context 패키지 문서 — https://pkg.go.dev/context — “Use context Values only for request-scoped data…” 인용의 출처입니다.

본문의 코드

  • 모든 Go 코드는 go version go1.26.0 darwin/arm64 에서 go vet 과 테스트를 통과했습니다. 본문에서 /* ... N줄 */ 로 줄인 부분은 지면 관계로 생략한 것이고, 실제 코드는 전부 컴파일되어 테스트를 통과했습니다. After 의 재시도 클라이언트는 시계와 sleep 을 주입해 테스트했으며, 본문 코드는 그 주입 필드를 뺀 형태입니다.
  • Before 코드의 결함(DeleteSelection 의 여러 줄 미지원, Service.Put 을 통한 검증 우회, 네 번째 대기가 800ms)은 각각 테스트로 재현했습니다.

시리즈