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


1편에서 복잡성이 무엇이고 어디서 오는지를 봤습니다. 이번 편부터는 그것을 줄이는 기법 입니다. 책의 4장 “Modules Should Be Deep” 과 5장 “Information Hiding (and Leakage)” 을 다룹니다. 이 두 장은 이 책에서 가장 많이 인용되는 부분이고, 나머지 장의 절반은 이 두 장을 어떻게 실천하느냐에 대한 각론입니다.

이번 편부터 예제 일부가 같은 도메인을 씁니다. 파일 저장소를 가진 작은 노트 서비스 입니다. 노트 하나는 제목·본문·태그를 가지고, 디렉터리에 JSON 파일 하나로 저장됩니다.


1. 모듈의 깊이 (4장)#

책은 모듈을 인터페이스 와 구현 으로 나눕니다. 인터페이스는 모듈을 쓰기 위해 알아야 하는 모든 것이고, 구현은 그 약속을 지키는 코드입니다. 그리고 이렇게 씁니다.

“The best modules are those that provide powerful functionality yet have simple interfaces. I use the term deep to describe such modules.” (4.4절)

책의 그림 4.1 은 모듈을 직사각형으로 그립니다. 면적이 기능의 양이고, 윗변의 길이가 인터페이스의 복잡도입니다. 같은 그림을 mermaid 로 옮기면 이렇습니다.

flowchart TB
    subgraph DEEP["깊은 모듈"]
        DI["인터페이스: Load(dir, id)"]
        DM["구현<br/>파일 열기 · 버퍼링 · JSON 디코딩<br/>에러 정리 · 파일 닫기<br/><br/>(호출자에게 보이지 않음)"]
        DI --> DM
    end
    subgraph SHALLOW["얕은 모듈"]
        SI["인터페이스: FileOpener · Open · BufferedReader · Reader · JSONNoteDecoder · Decode"]
        SM["구현<br/>(인터페이스와 거의 같은 양)"]
        SI --> SM
    end
    style DI fill:#90EE90,color:#000000
    style DM fill:#87CEEB,color:#000000
    style SI fill:#FF9999,color:#000000
    style SM fill:#D3D3D3,color:#000000

깊이는 비용 대비 편익입니다. 편익은 기능이고, 비용은 인터페이스입니다. 인터페이스는 모듈이 시스템의 나머지에 강요하는 복잡성 입니다. 그러니 “인터페이스는 좋은 것이니 많을수록 좋다” 는 생각이 틀렸다는 것이 이 장의 요지입니다.

책이 드는 깊은 모듈의 예는 Unix 파일 I/O 입니다. open, read, write, lseek, close 다섯 개의 시스템 콜 뒤에 수십만 줄의 파일 시스템 구현이 있습니다. 디스크 상의 표현, 디렉터리 탐색, 권한, 캐시, 스케줄링이 전부 그 안에 있는데, 호출자는 아무것도 몰라도 됩니다. 그리고 수십 년간 구현은 계속 바뀌었지만 다섯 개의 호출은 바뀌지 않았습니다.

얕은 모듈의 예는 Java 의 파일 읽기입니다. 직렬화된 객체 하나를 읽으려면 FileInputStream, BufferedInputStream, ObjectInputStream 세 객체를 순서대로 만들어야 합니다. 앞의 두 객체는 만든 뒤 다시 쓰이지 않습니다. 버퍼링을 빼먹으면 조용히 느려집니다. 책은 이것을 classitis 라고 부릅니다. “클래스는 좋으니 많을수록 좋다” 는 믿음에서 오는 병입니다.

1.1 얕은 모듈 — 객체 세 개를 조립해야 노트 하나를 읽는다#

원칙. 인터페이스의 복잡도 대비 기능의 비율을 높입니다.

Before. 노트 서비스의 저장소 패키지가 Java 의 스트림 조립을 그대로 흉내 냈다고 해 봅시다.

// FileOpener 는 노트 디렉터리에서 파일을 엽니다.
type FileOpener struct{ Dir string }

// Open 은 id 에 해당하는 파일을 엽니다. 닫는 것은 호출자 책임입니다.
func (o FileOpener) Open(id string) (*os.File, error) {
	return os.Open(filepath.Join(o.Dir, id+".json"))
}

// BufferedReader 는 io.Reader 에 버퍼를 씌웁니다. 잊으면 느립니다.
type BufferedReader struct{ R io.Reader }

// Reader 는 버퍼가 씌워진 Reader 를 돌려줍니다.
func (b BufferedReader) Reader() io.Reader { return bufio.NewReader(b.R) }

// JSONNoteDecoder 는 JSON 을 Note 로 바꿉니다.
type JSONNoteDecoder struct{ R io.Reader }

// Decode 는 JSON 하나를 읽어 Note 로 돌려줍니다.
func (d JSONNoteDecoder) Decode() (Note, error) {
	var n Note
	err := json.NewDecoder(d.R).Decode(&n)
	return n, err
}

호출하는 쪽은 이렇게 됩니다.

f, err := notes.FileOpener{Dir: dir}.Open("42")
if err != nil {
	return err
}
defer f.Close()
r := notes.BufferedReader{R: f}.Reader()
n, err := notes.JSONNoteDecoder{R: r}.Decode()

무엇이 문제인가. 노트 하나를 읽기 위해 호출자가 알아야 하는 것을 세어 봅니다. 타입 세 개, 메서드 세 개, 조립 순서, Close 책임, 버퍼링을 빼먹으면 느려진다는 사실. 그런데 이 모듈이 숨기는 것은 무엇인지 보면, 거의 없습니다. 파일 경로 규칙(id + ".json") 정도입니다. 인터페이스를 읽는 것이 구현을 읽는 것보다 쉽지 않습니다.

Go 에서 이 크기를 재는 방법이 있습니다. go doc -all 출력에서 exported 선언만 세면 됩니다.

$ go doc -all ./load_before | grep -E '^(func|type)'
type BufferedReader struct{ R io.Reader }
func (b BufferedReader) Reader() io.Reader
type FileOpener struct{ Dir string }
func (o FileOpener) Open(id string) (*os.File, error)
type JSONNoteDecoder struct{ R io.Reader }
func (d JSONNoteDecoder) Decode() (Note, error)
type Note struct {

After. 함수 하나로 합칩니다.

// Load 는 dir 에 저장된 id 노트를 읽어 돌려줍니다.
func Load(dir, id string) (Note, error) {
	data, err := os.ReadFile(filepath.Join(dir, id+".json"))
	if err != nil {
		return Note{}, err
	}
	var n Note
	if err := json.Unmarshal(data, &n); err != nil {
		return Note{}, err
	}
	return n, nil
}
$ go doc -all ./load_after | grep -E '^(func|type)'
type Note struct {
func Load(dir, id string) (Note, error)

무엇이 달라졌나. 인터페이스가 선언 7개에서 2개로 줄었고, 그중 Note 는 양쪽 모두에 있으니 실질적으로 6개가 1개가 되었습니다. 기능은 같습니다. 버퍼링은 os.ReadFile 안에 있고, 파일을 닫는 것도 그 안에 있습니다. 호출자가 실수할 자리가 없어졌습니다.

Go 표준 라이브러리 자체가 이 방향으로 움직여 왔습니다. os.Open 과 bufio.NewReader 와 io.ReadAll 을 조립하는 대신 os.ReadFile 하나를 부르고, http.NewRequest 와 client.Do 대신 http.Get 을 부릅니다. 저수준 조각들은 남아 있되, 흔한 경우를 위한 깊은 함수 가 그 위에 있습니다. 책이 Unix 의 lseek 를 두고 한 말과 같습니다. 순차 접근이 흔하니 그것을 기본으로 하고, 임의 접근은 따로 두되 순차 접근만 하는 사람은 그 존재를 몰라도 되게 했습니다.

1.2 얕은 메서드 — 있는 것보다 없는 것이 낫다#

책에는 극단적인 예가 하나 실려 있습니다. 설계 수업의 학생 프로젝트에서 나온 것입니다.

private void addNullValueForAttribute(String attribute) {
    data.put(attribute, null);
}

Go 로 옮기면 이런 모양입니다.

func (s *Store) clearTags(n *Note) { n.Tags = nil }

책의 평가는 냉정합니다. 이 메서드는 아무것도 추상화하지 않습니다. 기능 전체가 인터페이스에 그대로 보입니다. 제대로 문서화하면 문서가 코드보다 깁니다. 호출하는 데 드는 타자 수가 직접 쓰는 것보다 많습니다. 새 이름 하나를 배워야 한다는 비용만 추가하고 얻는 것이 없습니다.

이런 메서드는 “함수는 작아야 한다” 는 규칙을 기계적으로 따를 때 생깁니다. 책은 4.6절에서 “N 줄이 넘는 메서드는 나눠라” 는 조언을 직접 겨냥합니다. 그 결과가 얕은 메서드의 확산이라는 것입니다. 이 논쟁은 9장에서 더 길게 이어지고, 이 시리즈에서는 4편에서 다룹니다.


2. 정보 은닉과 누출 (5장)#

깊은 모듈을 만드는 가장 중요한 기법이 정보 은닉 입니다. 책은 이 개념의 출처를 David Parnas 의 1972년 논문으로 밝힙니다. 모듈은 몇 가지 설계 결정 을 캡슐화하고, 그 지식은 구현에는 있지만 인터페이스에는 나타나지 않는다는 것입니다.

반대가 정보 누출(information leakage) 입니다.

“Information leakage occurs when a design decision is reflected in multiple modules.” (5.2절)

여기서 주의할 점이 하나 있습니다. 책은 “private 로 선언하는 것” 과 정보 은닉이 같지 않다고 명시합니다. 필드를 private 로 감춰도 getter 와 setter 로 그대로 내보내면 public 인 것과 다르지 않습니다. Go 로 말하면 소문자로 시작하는 것만으로는 은닉이 아닙니다. 그 정보에 대한 의존이 바깥에 생기지 않아야 은닉입니다.

2.1 인터페이스를 통한 누출 — 내부 map 을 그대로 돌려준다#

원칙. 내부 표현을 인터페이스에 드러내지 않습니다.

책의 5.6절 예제입니다. HTTP 요청의 파라미터를 다루는 학생 코드 대부분이 이런 메서드를 가지고 있었다고 합니다.

public Map<String, String> getParams() {
    return this.params;
}

Before. Go 로 옮기면 그대로입니다.

// Request 는 파싱된 HTTP 요청입니다.
type Request struct {
	params map[string]string // URL 과 본문의 파라미터를 합쳐 둔다
}

// Params 는 파라미터 전체를 돌려줍니다. 돌려받은 맵을 고치면 안 됩니다.
func (r *Request) Params() map[string]string { return r.params }

무엇이 문제인가. 책이 지적하는 문제는 세 가지이고, Go 에서도 전부 그대로입니다.

첫째, 내부 표현이 인터페이스가 되었습니다. 파라미터를 map[string]string 으로 저장한다는 것은 구현 결정입니다. 나중에 같은 이름의 파라미터 여러 개를 지원하려고 map[string][]string 으로 바꾸면 모든 호출자가 깨집니다.

둘째, 호출자가 일을 더 해야 합니다. 맵을 받고, 키로 조회하고, 정수가 필요하면 변환까지 직접 합니다.

셋째, 주석의 “고치면 안 됩니다” 는 강제력이 없습니다. Go 의 map 은 참조 의미라서, 돌려받은 맵을 고치면 Request 의 내부가 바뀝니다. 테스트로 확인했습니다. 호출자가 req.Params()["photo_id"] = "0" 을 하면 그다음 req.Params()["photo_id"] 는 "0" 입니다. 컴파일러는 아무 말도 하지 않습니다.

After. 책이 제시하는 인터페이스를 그대로 옮깁니다.

// Param 은 이름에 해당하는 파라미터 값을 돌려줍니다. 없으면 빈 문자열입니다.
func (r *Request) Param(name string) string { return r.params[name] }

// IntParam 은 파라미터를 정수로 바꿔 돌려줍니다. 없거나 정수가 아니면 에러입니다.
func (r *Request) IntParam(name string) (int, error) {
	v, ok := r.params[name]
	if !ok {
		return 0, fmt.Errorf("parameter %q missing", name)
	}
	n, err := strconv.Atoi(v)
	if err != nil {
		return 0, fmt.Errorf("parameter %q: %w", name, err)
	}
	return n, nil
}

무엇이 달라졌나. map 이라는 단어가 인터페이스에서 사라졌습니다. 저장 방식을 바꿔도 호출자는 모릅니다. 문자열에서 정수로의 변환도 모듈 안으로 들어갔습니다. 그리고 내부를 고칠 방법이 없어졌습니다. 주석으로 부탁하던 것을 타입이 보장합니다.

Go 표준 라이브러리와 비교해 볼 만합니다. net/http 의 Request.Form 은 url.Values 이고, 그것은 map[string][]string 의 이름일 뿐입니다. 즉 Go 는 이 지점에서 책과 다른 선택을 했습니다. 다만 url.Values 에 Get, Set, Add 메서드를 붙여서 흔한 경우에는 맵을 직접 만지지 않아도 되게 했습니다. 표현을 감춘 것은 아니지만, 흔한 경로에서는 보이지 않게 한 것입니다. 책이 말하는 “부분적 정보 은닉” 에 가깝습니다.

2.2 시간적 분해 — 읽고 나서 파싱한다#

원칙. 모듈을 나눌 때 실행 순서가 아니라 필요한 지식을 기준으로 삼습니다.

책이 정보 누출의 흔한 원인으로 꼽는 것이 시간적 분해(temporal decomposition) 입니다. “먼저 읽고, 그다음 파싱하고, 마지막에 쓴다” 는 순서가 그대로 모듈 구조가 되는 것입니다. 5.5절의 예는 학생들의 HTTP 서버입니다. 한 팀이 요청을 문자열로 읽는 클래스와 그 문자열을 파싱하는 클래스를 따로 만들었습니다. 그런데 HTTP 요청은 파싱하지 않고는 읽을 수 없습니다. 본문 길이가 Content-Length 헤더에 있으니, 어디까지 읽어야 하는지 알려면 헤더를 파싱해야 합니다. 결국 두 클래스 모두 HTTP 요청 구조를 알아야 했고, 파싱 코드가 양쪽에 중복되었습니다.

노트 서비스에서 같은 모양을 만들어 봅니다. 노트 여러 개를 텍스트 파일 하나로 내보내는 덤프 형식 입니다. 레코드는 --- 한 줄로 구분하고, 본문 줄이 --- 로 시작하면 \--- 로 이스케이프합니다.

Before. 읽는 패키지와 파싱하는 패키지를 따로 둡니다.

// Package dumpreader 는 덤프 파일을 읽어 레코드 문자열 목록으로 나눕니다.
package dumpreader

// 레코드는 "---" 한 줄로 구분한다. 본문 줄이 "---" 로 시작하면 "\---" 로 이스케이프되어 있다.
// 이 규칙은 dumpparser 도 알고 있어야 한다.
func ReadRecords(r io.Reader) ([]string, error) {
	var recs []string
	var cur strings.Builder
	sc := bufio.NewScanner(r)
	for sc.Scan() {
		line := sc.Text()
		if line == "---" {
			recs = append(recs, cur.String())
			cur.Reset()
			continue
		}
		cur.WriteString(line)
		cur.WriteByte('\n')
	}
	if cur.Len() > 0 {
		recs = append(recs, cur.String())
	}
	return recs, sc.Err()
}
// Package dumpparser 는 레코드 문자열을 Note 로 바꿉니다.
package dumpparser

// 첫 줄이 제목, 나머지가 본문. 본문의 "\---" 는 "---" 로 되돌린다.
// 이 규칙은 dumpreader 도 알고 있어야 한다.
func ParseRecord(rec string) Note {
	title, body, _ := strings.Cut(rec, "\n")
	body = strings.ReplaceAll(body, "\\---", "---")
	return Note{Title: title, Body: strings.TrimSuffix(body, "\n")}
}

무엇이 문제인가. 두 패키지의 주석이 서로를 가리키고 있습니다. 구분자가 무엇인지, 이스케이프 규칙이 무엇인지를 양쪽이 다 알아야 합니다. dumpreader 는 \--- 가 구분자가 아니라는 것을 알아야 레코드를 제대로 자르고, dumpparser 는 \--- 를 --- 로 되돌려야 합니다. 이것이 정보 누출입니다. 설계 결정 하나(덤프 형식)가 두 모듈에 반영되어 있습니다. 이스케이프 문자를 바꾸면 두 패키지를 같이 고쳐야 하고, 한쪽만 고치면 데이터가 깨집니다.

그리고 책이 짚은 대로, 호출자도 손해를 봅니다. 두 패키지의 함수를 정해진 순서로 불러야 노트 목록을 얻습니다. 게다가 이 형식으로 쓰는 코드는 아직 없습니다. 쓰는 코드를 추가하면 같은 지식이 세 번째 자리에 생깁니다.

After. 형식을 아는 곳을 하나로 합치고, 읽기와 쓰기를 같은 패키지에 둡니다.

// Package dump 는 노트 덤프 파일 형식을 읽고 씁니다. 형식을 아는 곳은 이 패키지뿐입니다.
package dump

const sep = "---"

// Load 는 덤프를 읽어 노트 목록으로 돌려줍니다.
func Load(r io.Reader) ([]Note, error) {
	var notes []Note
	var cur *Note
	var body []string
	flush := func() {
		if cur != nil {
			cur.Body = strings.Join(body, "\n")
			notes = append(notes, *cur)
		}
		cur, body = nil, nil
	}
	sc := bufio.NewScanner(r)
	for sc.Scan() {
		line := sc.Text()
		switch {
		case line == sep:
			flush()
		case cur == nil:
			cur = &Note{Title: line}
		default:
			body = append(body, strings.TrimPrefix(line, `\`))
		}
	}
	flush()
	return notes, sc.Err()
}
// Save 는 노트 목록을 덤프 형식으로 씁니다. Load 로 그대로 되읽을 수 있습니다.
func Save(w io.Writer, notes []Note) error {
	for i, n := range notes {
		if i > 0 {
			fmt.Fprintln(w, sep)
		}
		fmt.Fprintln(w, n.Title)
		for _, line := range strings.Split(n.Body, "\n") {
			if strings.HasPrefix(line, sep) || strings.HasPrefix(line, `\`) {
				line = `\` + line
			}
			fmt.Fprintln(w, line)
		}
	}
	return nil
}

무엇이 달라졌나. 구분자와 이스케이프 규칙이 한 패키지 안에만 있습니다. sep 은 unexported 상수이고, 바깥에서는 그 값을 알 수 없습니다. 읽기와 쓰기가 같은 파일에 있으니 형식을 바꿀 때 한 곳만 보면 됩니다. 테스트는 Save 로 쓴 것을 Load 로 되읽어 원본과 같은지 비교하는 왕복 테스트입니다. 형식에 대한 지식이 한 곳에 있으면 테스트도 이렇게 한 방향으로 쓸 수 있습니다.

Load 가 Before 의 두 함수를 합친 것보다 짧아졌다는 점도 눈여겨볼 만합니다. 읽기와 파싱을 나누느라 중간 표현(레코드 문자열 목록)을 만들고 다시 뜯는 코드가 있었는데, 합치니 그것이 사라졌습니다. 책의 표현으로는 “정보 은닉은 클래스를 조금 더 크게 만드는 것으로 개선되는 경우가 많다” 입니다. 여기서는 조금 더 큰 패키지가 더 짧은 코드가 되었습니다.

Go 의 패키지 경계는 정보 은닉의 자연스러운 단위입니다. 같은 패키지 안에서는 모든 것이 보이고, 패키지 밖에서는 대문자로 시작하는 것만 보입니다. 그래서 “이 지식은 몇 개의 패키지가 알고 있는가” 를 묻는 것이 Go 에서 정보 누출을 찾는 가장 빠른 방법입니다. 답이 둘 이상이면 의심해야 합니다.

2.3 기본값 — 호출자에게 이미 아는 것을 묻지 않는다#

원칙. 흔한 경우를 가장 단순하게 만들고, 드문 경우는 따로 둡니다.

책의 5.7절은 HTTP 응답의 기본값 이야기입니다. 한 팀은 응답 객체를 만들 때 호출자가 HTTP 버전을 명시하게 했습니다. 그런데 응답 버전은 요청 버전과 같아야 하고, 요청은 이미 인자로 넘어옵니다. 모듈이 이미 아는 것을 호출자에게 다시 묻는 셈입니다. Date 헤더도 마찬가지입니다.

Before. 노트 저장소의 Save 가 버전 번호와 수정 시각을 호출자에게 요구합니다.

// Save 는 노트를 저장합니다. version 은 현재 버전 + 1 이어야 하고,
// updatedAt 은 UTC 여야 합니다.
func (s *Store) Save(n Note, version int, updatedAt time.Time) {
	n.Version = version
	n.UpdatedAt = updatedAt
	s.notes[n.ID] = n
}

무엇이 문제인가. 주석에 적힌 두 규칙은 모두 저장소가 스스로 지킬 수 있는 것입니다. 현재 버전은 저장소가 들고 있고, 지금 시각은 누구나 압니다. 그런데 호출자에게 맡겼으니 호출자가 틀릴 수 있습니다. 테스트에서 두 번째 저장에 버전 1 을 다시 넘기고 로컬 시간대의 시각을 넘겼는데, 저장소는 그대로 받아들였습니다. 이것이 책이 overexposure 라고 부르는 위험 신호입니다. 흔한 기능을 쓰는 사람이 드물게만 필요한 것까지 알아야 합니다.

After. 저장소가 알아서 합니다.

type Store struct {
	notes map[string]Note
	now   func() time.Time // 테스트에서 바꿔 끼운다
}

// Save 는 노트를 저장합니다. 버전은 하나 올리고, 수정 시각은 지금(UTC)으로 찍습니다.
func (s *Store) Save(n Note) {
	n.Version = s.notes[n.ID].Version + 1
	n.UpdatedAt = s.now().UTC()
	s.notes[n.ID] = n
}

무엇이 달라졌나. 인자 두 개가 사라졌습니다. 버전이 틀릴 방법도, 시간대가 틀릴 방법도 없습니다. 시각을 주입하는 now 필드는 테스트를 위한 것이고 unexported 라서 인터페이스에 나타나지 않습니다. 드물게 특정 시각으로 저장해야 하는 경우가 생기면 그때 SaveAt 같은 별도 메서드를 두면 됩니다. 흔한 경로를 쓰는 사람은 그 존재를 몰라도 됩니다.

책은 이 절을 이렇게 맺습니다. “The best features are the ones you get without even knowing they exist.” Java 의 버퍼링이 나쁜 예이고, 기본값이 좋은 예입니다.


3. 지나치게 하지 않기#

책의 5.9절은 “Taking it too far” 입니다. 정보 은닉은 그 정보가 모듈 밖에서 필요 없을 때만 의미가 있습니다. 성능에 영향을 주는 설정을 사용처마다 다르게 잡아야 한다면, 그것은 숨길 것이 아니라 인터페이스에 내놓아야 합니다. 목표는 바깥에서 필요한 정보의 양을 최소화 하는 것이지 0 으로 만드는 것이 아닙니다.

2.3 의 Save 도 마찬가지입니다. 만약 이 저장소가 다른 시스템에서 가져온 노트를 원래 수정 시각 그대로 보존해야 한다면, 시각은 바깥에서 필요한 정보이고 인터페이스에 있어야 합니다. 그 판단은 “이 정보가 어디서 필요한가” 를 보고 내리는 것이지, “인자는 적을수록 좋다” 는 규칙으로 내리는 것이 아닙니다.


4. 이번 편의 위험 신호#

위험 신호책의 절이번 편의 예
얕은 모듈 (Shallow Module)4.5객체 세 개를 조립해야 노트 하나를 읽는 저장소
정보 누출 (Information Leakage)5.2내부 map 을 돌려주는 Params()
시간적 분해 (Temporal Decomposition)5.3dumpreader 와 dumpparser 로 나뉜 덤프 형식
과다 노출 (Overexposure)5.7버전과 시각을 호출자에게 요구하는 Save

다음 편은 6~8장입니다. 모듈을 “어느 정도 범용적으로” 만드는 법, 계층마다 다른 추상화를 두는 법, 그리고 복잡성을 아래로 끌어내리는 법입니다. 이번 편에서 만든 노트 저장소의 Save 가 설정 파라미터를 만나면 어떻게 되는지도 거기서 봅니다.


References#

1차 자료

  • Ousterhout, J. K. A Philosophy of Software Design, 1st ed. Yaknyam Press, 2018. 본문 인용(4.4~4.7, 5.1~5.9절)은 1판 원문을 직접 옮긴 것입니다. 장·절 번호는 이 범위에서 2판과 같습니다.
  • Parnas, D. L. “On the Criteria to be Used in Decomposing Systems into Modules.” Communications of the ACM, December 1972. 책이 정보 은닉의 출처로 밝히는 논문입니다.
  • Go 표준 라이브러리 문서 — os.ReadFile, net/url.Values — 본문에서 비교한 API 입니다.

본문의 코드

  • 모든 Go 코드는 go version go1.26.0 darwin/arm64 에서 go vet 과 테스트를 통과했습니다. go doc -all 출력은 실제 실행 결과입니다. Before 코드의 결함(돌려받은 map 을 고치면 내부가 바뀜, 잘못된 버전이 그대로 저장됨)은 각각 테스트로 재현했습니다.

시리즈