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


이 시리즈에서 Go 가 가장 할 말이 많은 장입니다. 책의 10장 “Define Errors Out Of Existence” 는 예외(exception)에 대한 장인데, Go 에는 예외가 없습니다. 그러면 이 장은 Go 와 무관할까요. 반대입니다. 책은 첫 절에서 “예외” 를 넓게 정의합니다.

“I use the term exception to refer to any uncommon condition that alters the normal flow of control in a program. … exceptions can occur even without using a formal exception reporting mechanism, such as when a method returns a special value indicating that it didn’t complete its normal behavior.” (10.1절)

반환값으로 돌려주는 에러도 예외입니다. 그러니 Go 의 error 반환값은 이 장의 정의에 정확히 들어맞습니다. 그리고 Go 는 에러를 시그니처에 드러내는 언어이므로, 이 장의 처방이 적용되면 그 결과가 시그니처에서 바로 보입니다. (T, error) 가 T 가 되는 것입니다.


1. 왜 에러 처리가 복잡성의 주범인가#

책의 진단은 세 갈래입니다.

첫째, 에러 처리 코드는 정상 경로 코드보다 본질적으로 쓰기 어렵습니다. 에러가 났을 때 할 수 있는 일은 둘뿐입니다. 어떻게든 계속 진행하거나(재전송, 복구), 중단하고 위로 보고하거나. 둘 다 어렵습니다. 중단할 때는 이미 반쯤 바뀐 상태를 되돌려야 하고, 계속 진행하려는 시도는 또 다른 에러 를 낳습니다. 유실된 줄 알고 재전송한 패킷이 사실은 지연된 것이었다면 상대는 중복 패킷을 처리해야 합니다.

둘째, 에러 처리 코드는 거의 실행되지 않습니다. 그래서 테스트되지 않고, 버그가 오래 살아남습니다. 책이 인용하는 2014년 OSDI 논문(Yuan 외)에 따르면 분산 데이터 시스템의 치명적 장애 중 90% 이상이 잘못된 에러 처리에서 비롯되었습니다. 저자가 좋아하는 말이 “실행되지 않은 코드는 동작하지 않는다” 입니다.

셋째, 프로그래머가 불필요한 에러를 너무 많이 정의합니다. “에러는 많이 잡을수록 좋다” 는 교육의 결과로, 조금이라도 수상하면 에러를 내는 과잉 방어 스타일이 생깁니다. 그리고 에러는 인터페이스의 일부입니다. 에러가 많은 모듈은 인터페이스가 복잡하고, 따라서 얕습니다.

책의 결론은 이렇습니다. 에러를 던지는 것은 쉽고 처리하는 것은 어려우니, 복잡성은 처리하는 쪽에서 생깁니다. 그러므로 에러를 처리해야 하는 자리의 수 를 줄이는 것이 목표입니다. 네 가지 기법이 있습니다.

flowchart TB
    G["에러를 처리해야 하는 자리를 줄인다"]
    A["1. 존재를 없앤다<br/>에러가 아니도록 API 를 재정의"]
    B["2. 마스킹<br/>낮은 계층에서 처리해<br/>위에서는 모르게"]
    C["3. 집계<br/>여러 에러를 한 곳의<br/>핸들러 하나로"]
    D["4. 크래시<br/>처리할 가치가 없으면<br/>그냥 죽는다"]
    G --> A
    G --> B
    G --> C
    G --> D
    style G fill:#87CEEB,color:#000000
    style A fill:#90EE90,color:#000000
    style B fill:#FFD700,color:#000000
    style C fill:#FFD700,color:#000000
    style D fill:#D3D3D3,color:#000000

2. 에러의 존재를 없앤다 (10.3~10.5)#

2.1 Tcl 의 unset — 저자 자신의 실수#

원칙. 에러가 생기는 조건이 더 이상 에러가 아니도록 연산의 의미를 재정의합니다.

책의 첫 예제는 저자 본인의 것입니다. Tcl 의 unset 명령은 변수를 지우는데, 없는 변수를 지우려 하면 에러를 냅니다. 저자는 당시 그것이 버그일 테니 알려 주는 것이 맞다고 생각했습니다. 그런데 unset 의 가장 흔한 용도는 어떤 작업이 남겼을지 모르는 임시 상태를 치우는 것이었습니다. 작업이 중간에 실패했으면 어느 변수가 만들어졌는지 알 수 없으니, 만들어졌을 수 있는 변수를 전부 지우는 것이 가장 단순합니다. 그런데 unset 이 에러를 내니 개발자들은 unset 호출마다 catch 를 씌워 에러를 무시했습니다.

“In retrospect, the definition of the unset command is one of the biggest mistakes I made in the design of Tcl.” (10.2절)

Before. 노트 저장소의 Delete 가 같은 실수를 합니다.

var ErrNotFound = errors.New("note not found")

// Delete 는 노트를 지웁니다. 없는 노트를 지우려 하면 ErrNotFound 입니다.
func (s *Store) Delete(id string) error {
	if _, ok := s.m[id]; !ok {
		return ErrNotFound
	}
	delete(s.m, id)
	return nil
}

// Cleanup 은 작업 중 만들어졌을 수 있는 임시 노트를 전부 치웁니다.
// 어느 것이 실제로 만들어졌는지는 모르므로 후보를 모두 지웁니다.
func (s *Store) Cleanup(candidates []string) error {
	for _, id := range candidates {
		if err := s.Delete(id); err != nil && !errors.Is(err, ErrNotFound) {
			return err
		}
	}
	return nil
}

무엇이 문제인가. Cleanup 의 errors.Is(err, ErrNotFound) 가 Tcl 의 catch 입니다. Delete 가 정의한 에러를 호출자가 받자마자 무시합니다. 에러를 정의한 쪽도, 무시하는 쪽도 코드를 썼는데 얻은 것이 없습니다. 게다가 Cleanup 의 시그니처에 error 가 남았습니다. 실제로는 ErrNotFound 말고 다른 에러가 나올 길이 없는데도, 호출자는 그것을 모르니 Cleanup 의 에러도 처리해야 합니다. 에러 하나가 두 단계 위까지 번졌습니다.

After. Delete 의 정의를 바꿉니다. “노트를 지운다” 에서 “노트가 없는 상태로 만든다” 로.

// Delete 는 id 의 노트가 존재하지 않는 상태로 만듭니다. 이미 없으면 할 일이 없습니다.
func (s *Store) Delete(id string) { delete(s.m, id) }

// Cleanup 은 작업 중 만들어졌을 수 있는 임시 노트를 전부 치웁니다.
func (s *Store) Cleanup(candidates []string) {
	for _, id := range candidates {
		s.Delete(id)
	}
}

무엇이 달라졌나. 두 함수 모두 시그니처에서 error 가 사라졌습니다. 없는 노트를 지우는 것은 이제 에러가 아니라 “이미 목표 상태” 입니다. Cleanup 은 세 줄이고, 호출자는 아무것도 처리할 것이 없습니다. 책의 표현으로, 첫 번째 정의에서는 없는 변수를 만나면 unset 이 제 일을 할 수 없으니 에러가 자연스럽지만, 두 번째 정의에서는 없는 변수를 만나는 것이 완벽하게 자연스럽고 할 일이 이미 끝나 있으니 그냥 돌아오면 됩니다.

Go 표준 라이브러리 안에서 두 정의를 나란히 볼 수 있습니다. 내장 delete(m, k) 는 없는 키에 대해 아무 일도 하지 않습니다. os.RemoveAll 의 문서에는 “If the path does not exist, RemoveAll returns nil (no error)” 라고 적혀 있습니다. 반면 os.Remove 는 없는 파일에 대해 에러를 돌려줍니다. 그래서 os.Remove 를 정리 코드에 쓰면 errors.Is(err, fs.ErrNotExist) 가 따라붙고, os.RemoveAll 을 쓰면 붙지 않습니다. 같은 라이브러리 안에 Tcl 의 unset 과 그 교정본이 함께 있는 셈입니다.

2.2 Windows 의 파일 삭제#

책의 두 번째 예제는 코드가 아니라 운영체제입니다. Windows 는 열려 있는 파일을 지울 수 없게 합니다. 그래서 사용자는 어느 프로세스가 파일을 잡고 있는지 찾아서 죽여야 하고, 포기하고 재부팅하기도 합니다. Unix 는 열려 있는 파일을 지우면 디렉터리에서 이름만 지우고 삭제 표시를 해 둔 뒤 성공을 돌려줍니다. 이미 열어 둔 프로세스는 계속 읽고 쓸 수 있고, 모두 닫으면 그때 데이터가 해제됩니다.

책은 이 설계가 에러를 두 종류 없앴다고 짚습니다. 삭제하는 쪽은 “사용 중” 에러를 받지 않고, 열어 둔 쪽은 “삭제된 파일” 에러를 받지 않습니다. 후자가 중요합니다. 즉시 지우고 열린 핸들을 전부 무효화하는 설계도 가능했지만, 그러면 파일을 쓰던 프로세스들에 새 에러가 생깁니다. 삭제를 미루는 것으로 그 에러까지 없앴습니다.

2.3 범위 밖 인덱스 — Java 의 substring#

원칙. 입력의 모든 값에 대해 결과가 정의되게 만듭니다.

책의 세 번째 예제는 Java 의 substring 입니다. 인덱스가 범위를 벗어나면 IndexOutOfBoundsException 을 던집니다. 저자는 인덱스가 범위를 벗어날 수 있는 상황에서 “겹치는 부분만” 잘라내고 싶은 경우가 자주 있는데, 그때마다 인덱스를 0 과 길이 사이로 보정하는 5~10줄이 필요하다고 말합니다. Python 의 슬라이스는 범위 밖이면 빈 결과를 돌려주고, 그쪽이 낫다는 것입니다.

Go 의 슬라이싱은 Java 쪽입니다. s[3:10] 은 len(s) 가 5 면 패닉입니다.

Before. 검색 결과 미리보기를 만드는 코드입니다.

// Snippet 은 body[start:end] 를 돌려줍니다. 범위가 본문 밖이면 패닉입니다.
func Snippet(body string, start, end int) string { return body[start:end] }

// Preview 는 검색 결과 미리보기로 매치 앞뒤 20자를 잘라 냅니다.
// 호출자가 경계를 직접 보정해야 합니다.
func Preview(body string, matchAt int) string {
	start := matchAt - 20
	if start < 0 {
		start = 0
	}
	end := matchAt + 20
	if end > len(body) {
		end = len(body)
	}
	if start > end {
		start = end
	}
	return Snippet(body, start, end)
}

무엇이 문제인가. Preview 열네 줄 중 열 줄이 경계 보정입니다. 그리고 이 보정은 Snippet 을 부르는 모든 곳 에서 반복됩니다. 하나라도 빼먹으면 패닉이고, 테스트로 확인하면 Snippet("hello", 3, 10) 은 실제로 패닉합니다. 책의 표현대로 한 줄짜리 호출이 5~10줄이 되었습니다.

After. Snippet 이 어떤 인덱스에 대해서도 정의되게 합니다.

// Snippet 은 body 에서 인덱스가 start 이상 end 미만인 글자들을 돌려줍니다.
// 범위가 본문 밖으로 나가거나 start > end 여도 정의됩니다. 겹치는 부분이 없으면 빈 문자열입니다.
func Snippet(body string, start, end int) string {
	start = max(start, 0)
	end = min(end, len(body))
	if start >= end {
		return ""
	}
	return body[start:end]
}

// Preview 는 검색 결과 미리보기로 매치 앞뒤 20자를 잘라 냅니다.
func Preview(body string, matchAt int) string {
	return Snippet(body, matchAt-20, matchAt+20)
}

무엇이 달라졌나. Preview 가 한 줄이 되었습니다. 보정은 Snippet 안에 한 번만 있습니다. 문서 주석이 책이 제안한 API 그대로입니다. “인덱스가 start 이상 end 미만인 글자들을 돌려준다.” 이 정의는 음수 인덱스에도, start > end 에도, 빈 문자열에도 성립합니다. 테스트에 그 다섯 경우를 넣었습니다. 기능은 늘고 인터페이스는 단순해졌으니 더 깊은 함수입니다.

책은 여기서 예상되는 반론에 답합니다. “에러를 내야 버그를 잡지 않느냐.” 저자의 답은, 에러가 많은 방식도 버그를 몇 개 잡기는 하지만 복잡성을 늘려서 다른 버그를 만든다는 것입니다. 개발자는 에러를 피하거나 무시하는 코드를 추가로 써야 하고, 그 코드에 버그가 생기거나, 아예 잊어서 런타임에 에러가 터집니다. “버그를 줄이는 가장 좋은 방법은 소프트웨어를 단순하게 만드는 것” 입니다.

다만 이 예제를 Go 의 슬라이싱 자체에 대한 비판으로 읽을 필요는 없습니다. 언어 수준의 슬라이싱은 배열 접근에 가까운 저수준 연산이고, 범위 밖 접근이 패닉인 것은 메모리 안전을 위한 선택입니다. 책의 논지는 그 위에 놓이는 라이브러리 함수 의 계약에 대한 것입니다. Snippet 처럼 “겹치는 부분” 이라는 의미가 자연스러운 함수라면 그 의미로 정의하는 편이 낫습니다.


3. 마스킹 (10.6)#

원칙. 낮은 계층에서 처리할 수 있는 에러는 거기서 처리해서 위에서는 모르게 합니다.

책의 예는 TCP 입니다. 패킷은 손실되지만 TCP 가 재전송하므로 애플리케이션은 손실을 모릅니다. 더 논쟁적인 예는 NFS 입니다. 서버가 응답하지 않으면 클라이언트는 에러를 내는 대신 될 때까지 재시도하고, 애플리케이션은 멈춥니다. 많은 사람이 에러를 내야 한다고 주장했지만, 저자는 그쪽이 더 나쁘다고 봅니다. 파일 접근을 잃은 애플리케이션이 할 수 있는 일은 재시도뿐인데 그것은 어차피 멈추는 것이고, 재시도를 모든 애플리케이션의 모든 파일 호출에 두느니 NFS 계층 한 곳에 두는 편이 낫다는 것입니다. 컴파일러가 NFS 서버 장애를 걱정해서는 안 됩니다.

Go 에서 마스킹의 교과서적 예는 io.ReadFull 입니다. io.Reader 의 계약은 요청한 것보다 적게 읽어도 된다는 것입니다. 그래서 정확히 N 바이트가 필요한 호출자는 짧은 읽기를 처리해야 합니다.

Before. 16바이트 헤더를 읽는 코드가 짧은 읽기를 직접 처리합니다.

func ReadHeaderBefore(r io.Reader) ([]byte, error) {
	buf := make([]byte, 16)
	got := 0
	for got < len(buf) {
		n, err := r.Read(buf[got:])
		got += n
		if err != nil {
			if err == io.EOF && got == len(buf) {
				break
			}
			if err == io.EOF {
				return nil, io.ErrUnexpectedEOF
			}
			return nil, err
		}
	}
	return buf, nil
}

After. 짧은 읽기는 io.ReadFull 안에 있습니다.

func ReadHeaderAfter(r io.Reader) ([]byte, error) {
	buf := make([]byte, 16)
	if _, err := io.ReadFull(r, buf); err != nil {
		return nil, err
	}
	return buf, nil
}

무엇이 달라졌나. 짧은 읽기라는 조건이 호출자에게서 사라졌습니다. 테스트는 표준 라이브러리의 iotest.OneByteReader 로 한 번에 1바이트씩만 주는 극단적인 Reader 를 만들어 두 함수 모두 16바이트를 온전히 읽는지, 그리고 데이터가 모자라면 둘 다 io.ErrUnexpectedEOF 를 돌려주는지 확인했습니다. 결과는 같고, 코드는 열여섯 줄에서 여섯 줄이 되었습니다. 그리고 그 여섯 줄에는 EOF 와 부분 읽기의 미묘한 조합(마지막 바이트와 함께 EOF 가 오는 경우)을 다루는 코드가 없습니다. io.ReadFull 이 한 번 제대로 다뤘으니 모든 호출자가 다시 다룰 필요가 없습니다.

책은 마스킹이 복잡성을 아래로 끌어내리는 것 의 한 형태라고 정리합니다. 3편의 8장과 같은 원칙입니다. 그리고 마스킹은 낮은 계층의, 많은 곳에서 쓰이는 함수에서 가장 효과적입니다. 위로 올라갈수록 처리해야 할 자리가 늘기 때문입니다.


4. 집계 (10.7)#

원칙. 여러 곳에서 생기는 비슷한 에러를 한 곳의 핸들러 하나로 처리합니다.

책의 예는 웹 서버입니다. 각 URL 핸들러가 getParameter 를 부르고, 파라미터가 없으면 예외가 납니다. 학생들 다수는 getParameter 호출마다 핸들러를 씌웠고, 그 핸들러들은 전부 같은 일(에러 응답 생성)을 했습니다. 더 나은 방법은 예외를 최상위 디스패처까지 올려 보내서 거기서 한 번에 처리하는 것입니다. 파라미터 누락뿐 아니라 잘못된 형식, 권한 없음도 모두 “에러 응답” 으로 끝나는 것은 같으니 같은 핸들러가 처리할 수 있습니다. 메시지만 다르고, 그 메시지는 에러를 만드는 쪽이 채웁니다.

Before. Go 의 net/http 핸들러에서 흔히 보는 모양입니다.

func GetNote(w http.ResponseWriter, r *http.Request) {
	raw := r.URL.Query().Get("id")
	if raw == "" {
		http.Error(w, "parameter 'id' not present", http.StatusBadRequest)
		return
	}
	id, err := strconv.Atoi(raw)
	if err != nil {
		http.Error(w, fmt.Sprintf("bad value %q for 'id'", raw), http.StatusBadRequest)
		return
	}
	n, ok := notes[id]
	if !ok {
		http.Error(w, fmt.Sprintf("note %d not found", id), http.StatusNotFound)
		return
	}
	fmt.Fprint(w, n.Title)
}

func DeleteNote(w http.ResponseWriter, r *http.Request) {
	raw := r.URL.Query().Get("id")
	if raw == "" {
		http.Error(w, "parameter 'id' not present", http.StatusBadRequest)
		return
	}
	id, err := strconv.Atoi(raw)
	if err != nil {
		http.Error(w, fmt.Sprintf("bad value %q for 'id'", raw), http.StatusBadRequest)
		return
	}
	// ... 권한 검사, 삭제
}

무엇이 문제인가. 파라미터 하나를 꺼내는 데 여덟 줄이고, 그 여덟 줄이 핸들러마다 복사됩니다. http.Error 를 부르고 return 하는 것을 잊으면 에러 응답을 쓴 뒤에 정상 응답까지 이어 쓰는 버그가 됩니다. 응답 형식을 JSON 으로 바꾸기로 하면 모든 http.Error 를 찾아 고쳐야 합니다. 1편의 변경 증폭입니다.

After. 핸들러는 에러를 돌려주기만 하고, 응답으로 바꾸는 것은 한 곳에서 합니다.

// httpError 는 "이 요청을 이 상태 코드와 문구로 끝내라" 는 뜻입니다.
type httpError struct {
	status int
	msg    string
}

func (e *httpError) Error() string { return e.msg }

// intParam 은 파라미터를 꺼내고 정수로 바꿉니다. 실패하면 사용자에게 보여 줄 문구를 담은 에러입니다.
func intParam(r *http.Request, name string) (int, error) {
	raw := r.URL.Query().Get(name)
	if raw == "" {
		return 0, &httpError{http.StatusBadRequest, fmt.Sprintf("parameter %q not present", name)}
	}
	n, err := strconv.Atoi(raw)
	if err != nil {
		return 0, &httpError{http.StatusBadRequest, fmt.Sprintf("bad value %q for %q", raw, name)}
	}
	return n, nil
}

// 핸들러는 에러를 돌려주기만 합니다. 응답으로 바꾸는 것은 한 곳에서 합니다.
type handler func(w http.ResponseWriter, r *http.Request) error

func (h handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	err := h(w, r)
	if err == nil {
		return
	}
	var he *httpError
	if errors.As(err, &he) {
		http.Error(w, he.msg, he.status)
		return
	}
	http.Error(w, "internal error", http.StatusInternalServerError)
}
func GetNote(w http.ResponseWriter, r *http.Request) error {
	id, err := intParam(r, "id")
	if err != nil {
		return err
	}
	n, ok := notes[id]
	if !ok {
		return &httpError{http.StatusNotFound, fmt.Sprintf("note %d not found", id)}
	}
	fmt.Fprint(w, n.Title)
	return nil
}

무엇이 달라졌나. 책이 설명하는 정보 은닉이 그대로 나타납니다. ServeHTTP 는 에러 응답을 어떻게 만드는지 알지만 어떤 에러가 있는지는 모릅니다. intParam 은 파라미터를 어떻게 꺼내는지 와 그 실패를 사람에게 어떻게 설명하는지 를 알지만 HTTP 응답 형식은 모릅니다. 이 둘은 서로 밀접하니 한 곳에 있는 것이 맞고, 응답 형식과는 무관하니 떨어져 있는 것이 맞습니다. 응답을 JSON 으로 바꾸려면 ServeHTTP 한 곳만 고치면 됩니다. 새 종류의 에러(권한 없음)는 httpError 를 하나 더 만들면 아무것도 바꾸지 않고 기존 체계에 들어갑니다. DeleteNote 의 403 이 그렇게 들어갔고, 테스트로 확인했습니다.

handler 타입은 Go 생태계에서 오래된 관용구입니다. http.HandlerFunc 가 error 를 돌려주지 않는 것을 아쉬워한 사람들이 만들어 온 모양이고, 여러 웹 프레임워크가 같은 형태를 기본으로 제공합니다. 책의 용어로 이것은 요청을 중단하는 예외 를 정의한 것입니다. 요청 처리 루프의 꼭대기에서 한 번 잡고, 정리하고, 다음 요청으로 넘어갑니다. 시스템 전체를 죽여야 하는 에러와는 구분됩니다.

책은 집계의 더 극단적인 예로 RAMCloud 를 듭니다. 객체 하나가 손상된 것을 발견하면 그 객체만 복구하지 않고 서버를 통째로 죽입니다. 서버 크래시 복구는 어차피 만들어야 하니, 작은 에러를 큰 에러로 “승격” 시켜서 같은 메커니즘으로 처리하는 것입니다. 복구 코드가 줄고, 크래시 복구가 더 자주 실행되니 그 버그도 더 빨리 발견됩니다. 단, 이것은 객체 손상이 드물기 때문에 가능한 선택입니다. 패킷 하나 잃을 때마다 서버를 죽일 수는 없습니다.


5. 크래시 (10.8)#

원칙. 처리할 가치가 없는 에러는 진단 정보를 남기고 죽습니다.

책의 예는 C 의 malloc 입니다. 메모리가 부족하면 NULL 을 돌려주는데, 모든 호출자가 그것을 검사하기를 기대하는 설계입니다. 검사를 잊으면 널 포인터 역참조로 죽는데, 그 크래시는 진짜 원인을 가립니다. 그리고 메모리가 부족할 때 애플리케이션이 할 수 있는 일은 사실 없습니다. 그래서 저자는 malloc 을 감싸서 실패 시 메시지를 내고 죽는 ckalloc 을 만들어 그것만 쓰게 했습니다.

Go 는 이 결정을 언어 차원에서 내렸습니다. 메모리 할당 실패는 error 로 돌아오지 않고 런타임이 fatal error: runtime: out of memory 로 종료합니다. 호출자가 검사할 것이 없습니다.

라이브러리 수준에서 같은 원칙을 담은 관용구가 Must 입니다.

// Before: 초기화 실패를 error 로 돌려주고, 모든 호출자가 검사한다
re, err := regexp.Compile(`^note-\d+$`)
if err != nil {
	log.Fatal(err)
}

// After: 컴파일 실패는 프로그래머의 오타이고, 고칠 방법은 코드를 고치는 것뿐이다
var noteID = regexp.MustCompile(`^note-\d+$`)

regexp.MustCompile, template.Must, netip.MustParseAddr 이 모두 같은 형태입니다. 패턴 문자열은 코드에 박혀 있으니 컴파일 실패는 배포 전에 드러나야 하는 버그이고, 런타임에 우아하게 처리할 것이 아닙니다. 패키지 변수 초기화에서 패닉하면 프로그램은 시작조차 하지 않습니다. 그것이 의도입니다.

책은 크래시가 허용되는지가 애플리케이션에 달렸다 고 못 박습니다. 복제 저장 시스템이 I/O 에러에 죽어서는 안 됩니다. 복구가 그 시스템의 존재 이유이기 때문입니다.


6. 특수 경우도 없앤다 (10.9)#

원칙. 정상 경로가 특수 경우를 저절로 처리하도록 설계합니다.

에러를 없애는 것과 같은 이유로, 다른 특수 경우도 없앨 수 있습니다. 책의 예는 편집기의 선택 영역입니다. 학생 대부분이 “선택이 있는가” 를 나타내는 상태 변수를 두었고, 그 결과 코드 곳곳에 “선택 없음” 검사가 생겼습니다.

Before. Go 에서 “없을 수 있음” 은 흔히 포인터의 nil 로 표현됩니다.

type Editor struct {
	text string
	sel  *Selection // nil 이면 선택 없음
}

func (e *Editor) DeleteSelection() {
	if e.sel == nil { // 특수 경우
		return
	}
	e.text = e.text[:e.sel.Start] + e.text[e.sel.End:]
	e.sel = nil
}

func (e *Editor) Copy() string {
	if e.sel == nil { // 또 특수 경우
		return ""
	}
	return e.text[e.sel.Start:e.sel.End]
}

After. 선택은 항상 존재하고, 비어 있을 수 있습니다.

// Selection 은 항상 존재합니다. Start == End 이면 비어 있고 화면에 보이지 않습니다.
type Selection struct{ Start, End int }

type Editor struct {
	text string
	sel  Selection
}

// DeleteSelection 은 선택 영역을 지웁니다. 비어 있으면 원문이 그대로 남습니다.
func (e *Editor) DeleteSelection() {
	e.text = e.text[:e.sel.Start] + e.text[e.sel.End:]
	e.sel = Selection{e.sel.Start, e.sel.Start}
}

// Copy 는 선택 영역을 돌려줍니다. 비어 있으면 빈 문자열입니다.
func (e *Editor) Copy() string { return e.text[e.sel.Start:e.sel.End] }

무엇이 달라졌나. nil 검사가 전부 사라졌습니다. 빈 선택을 지우면 text[:s] + text[s:] 가 원문 그대로이고, 빈 선택을 복사하면 text[s:s] 가 빈 문자열입니다. 특수 경우가 정상 경로의 자연스러운 결과 로 처리됩니다. 테스트에서 빈 선택과 실제 선택 모두 확인했습니다.

책은 이것이 7장 “다른 계층, 다른 추상화” 의 예이기도 하다고 말합니다. “선택 없음” 은 사용자가 화면을 보는 방식에서는 의미가 있지만, 그것을 구현 내부에 그대로 표현할 필요는 없습니다. 항상 존재하되 가끔 비어서 보이지 않는 선택이 더 단순한 구현을 만듭니다.

Go 에서 이 원칙은 zero value 를 유용하게 만들라 는 관용구와 통합니다. bytes.Buffer, sync.Mutex, strings.Builder 는 선언만 하면 쓸 수 있고 nil 검사가 필요 없습니다. “없음” 을 포인터의 nil 로 표현하고 싶어질 때, 대신 “비어 있음” 을 값으로 표현할 수 없는지 먼저 물어보면 특수 경우가 자주 사라집니다.


7. 지나치게 하지 않기 (10.10)#

에러를 없애거나 마스킹하는 것은 그 정보가 모듈 밖에서 필요 없을 때만 의미가 있습니다. 책의 반례는 한 학생 팀의 네트워크 모듈입니다. 모든 네트워크 에러를 잡아서 버리고 아무 일 없다는 듯 계속했습니다. 그 결과 그 모듈을 쓰는 애플리케이션은 메시지가 유실됐는지, 상대 서버가 죽었는지 알 길이 없었고, 견고한 애플리케이션을 만드는 것이 불가능했습니다.

2.1 의 Delete 도 마찬가지입니다. 만약 이 저장소가 감사 로그를 남겨야 하고 “실제로 존재하던 것을 지웠는가” 가 기록에 필요하다면, 그 정보는 밖에서 필요한 것이고 인터페이스에 있어야 합니다. 그때는 Delete(id) (existed bool) 처럼 에러가 아닌 형태 로 내놓는 선택지도 있습니다. 호출자가 무시해도 되는 정보는 무시할 수 있는 형태로 주는 것입니다.

책의 마지막 문장이 기준입니다. 중요하지 않은 것은 숨기고, 많이 숨길수록 좋지만, 중요한 것은 드러내야 합니다. 무엇이 중요한지 정하는 것이 설계이고, 그 이야기는 마지막 편의 21장에서 다시 나옵니다.


8. 이번 편의 위험 신호와 기법#

기법책의 절이번 편의 예시그니처의 변화
존재를 없앤다10.3~10.5Delete, Snippeterror 반환이 사라짐, 패닉이 사라짐
마스킹10.6io.ReadFull짧은 읽기가 호출자에게서 사라짐
집계10.7handler 와 httpErrorhttp.Error 호출이 한 곳으로
크래시10.8regexp.MustCompileerror 반환이 사라지고 패닉으로
특수 경우 없애기10.9빈 Selectionnil 검사가 사라짐

위험 신호로는 하나가 새로 추가됩니다. 너무 많은 예외(Too Many Exceptions). Go 로 옮기면 “호출자가 받자마자 errors.Is 로 무시하는 에러” 입니다. 그런 에러를 발견하면 그 에러를 정의한 함수의 계약을 다시 보는 것이 순서입니다.

다음 편은 12~15장, 주석과 이름입니다. “좋은 코드는 스스로를 문서화한다” 는 말을 저자가 왜 “맛있는 신화” 라고 부르는지, 그리고 14.5절에서 Go 스타일 가이드와 어떻게 갈라서는지를 봅니다.


References#

1차 자료

  • Ousterhout, J. K. A Philosophy of Software Design, 1st ed. Yaknyam Press, 2018. 본문 인용(10.1~10.10절)은 1판 원문을 직접 옮긴 것입니다.
  • Yuan, D. et al. “Simple Testing Can Prevent Most Critical Failures: An Analysis of Production Failures in Distributed Data-Intensive Systems.” OSDI 2014. 책의 10.1절이 인용하는 “치명적 장애의 90% 이상이 잘못된 에러 처리” 의 출처입니다. 본문의 수치는 책의 인용을 옮긴 것이며 논문 원문을 직접 확인하지는 않았습니다.
  • Go 표준 라이브러리 문서 — os.RemoveAll (“If the path does not exist, RemoveAll returns nil”), io.ReadFull, regexp.MustCompile, testing/iotest.OneByteReader.

본문의 코드

  • 모든 Go 코드는 go version go1.26.0 darwin/arm64 에서 go vet 과 테스트를 통과했습니다. Before 코드의 결함(Snippet("hello", 3, 10) 의 패닉)은 recover 로 잡아 테스트에서 재현했습니다. HTTP 핸들러는 net/http/httptest 로 400·404·403·200 응답을 확인했습니다.
  • os.RemoveAll 의 문서 문구는 go doc os.RemoveAll 출력에서 옮겼습니다.

시리즈