세 원전을 한 장으로 잇는 설계 정전 3권 정독의 심화편이다. 종합본이 이 책을 4개 절로 압축했다면, 여기서는 21장 전체를 따라간다. 인용부호 안의 문장은 전부 원서 원문이다.
1. 이 책의 단 하나의 주장
Ousterhout는 Stanford에서 CS 190이라는 수업을 만들었다(강의 제목은 Software Design Studio). 학생들이 코드를 쓰고, 그가 리뷰하고, 학생들이 다시 쓰는 과정을 반복하는 수업이다. 이 책은 그 수업에서 같은 실수가 반복되는 것을 보고 정리한 목록이다. 그래서 추상적인 원칙보다 "이런 코드를 봤고, 왜 나쁜지 설명하면 이렇다"는 형식이 많다.
2. 복잡도의 정체 🔥 핵심 20%
정의 — 남이 판단해 주는 정의
"Complexity is anything related to the structure of a software system that makes it hard to understand and modify the system."
— 복잡도란 소프트웨어 시스템의 구조와 관련된 것 중 시스템을 이해하거나 수정하기 어렵게 만드는 모든 것이다.
이 정의의 무서운 점은 판정 권한이 나에게 없다는 것이다. 내가 짤 때 명확했는지는 상관없다. 다른 사람이(또는 6개월 뒤의 내가) 어렵다고 느끼면 복잡한 것이다. 코드 길이가 아니라 바꾸는 비용이 기준이다.
3가지 증상
| 증상 | 정의 | 현장에서 이렇게 들린다 |
|---|---|---|
| 변경 증폭 change amplification | 사소한 변경 하나가 여러 곳의 수정을 부른다 | "배경색 하나 바꾸는데 파일 8개를 건드렸어요" |
| 인지 부하 cognitive load | 작업을 끝내기 위해 알아야 하는 정보의 양 | "이 함수 쓰려면 초기화 순서랑 스레드 규칙을 다 알아야 해요" |
| 미지의 미지 unknown unknowns | 어디를 고쳐야 하는지, 문제가 있는지조차 알 수 없다 | "고친 줄 알았는데 이런 경로가 있는 줄 몰랐어요" |
"Of the three manifestations of complexity, unknown unknowns are the worst. An unknown unknown means that there is something you need to know, but there is no way for you to find out what it is, or even whether there is an issue. You won't find out about it until bugs appear after you make a change."
앞의 둘은 힘들어도 끝나기는 한다. 시간을 더 쓰면 된다. 세 번째는 아무리 성실해도 실패한다 — 존재를 모르는 것을 고칠 수는 없기 때문이다.
2가지 원인, 그리고 원인과 증상의 대응
책은 원인과 증상을 정확히 연결한다. 이게 처방을 고르는 기준이 된다.
복잡도는 점진적으로 쌓인다
"Complexity isn't caused by a single catastrophic error; it accumulates in lots of small chunks. A single dependency or obscurity, by itself, is unlikely to affect significantly the maintainability of a software system."
그래서 되돌리기도 어렵다 — 한 번의 큰 리팩터링으로는 안 되고, 수백 번의 작은 정리가 필요하다. 처방은 무관용(zero tolerance)이다.
3. 동작하는 코드로는 부족하다 🔥 핵심 20%
3장의 제목이 그대로 주장이다 — Working Code Isn't Enough.
전술적 토네이도
"The tactical tornado is a prolific programmer who pumps out code far faster than others but works in a totally tactical fashion. ... In some organizations, management treats tactical tornadoes as heroes. However, tactical tornadoes leave behind a wake of destruction. ... other engineers must clean up the messes left behind by the tactical tornado, which makes it appear that those engineers (who are the real heroes) are making slower progress."
중요한 건 그가 실제로 빠르다는 것이다. 책은 그 점을 부정하지 않는다. 속도 차이의 정체는 실력이 아니라 청구서를 누가 받느냐다. 그리고 청구서는 뒤에 오는 사람에게 간다.
얼마나 투자할 것인가 — 10~20%
"I suggest spending about 10–20% of your total development time on investments. This amount is small enough that it won't impact your schedules significantly, but large enough to produce significant benefits over time."
숫자가 붙는 대상이 정확히 무엇인지 헷갈리기 쉬운데, 책의 서술은 이렇다.
4. 모듈은 깊어야 한다 🔥 핵심 20%
Parnas가 "감춰라"라고 했다면, Ousterhout는 "잘 감췄는지 어떻게 재는가"에 답한다. 답은 시각적이다. 모듈을 직사각형으로 그려라.
인터페이스에는 두 종류가 있다
얕은 모듈의 상당수는 비형식적 인터페이스가 크기 때문에 얕다. 시그니처는 짧은데 "이걸 부르기 전에 저걸 초기화하고, 메인 스레드에서만 부르고, 결과는 캐시하지 말 것" 같은 규칙이 붙는다.
final class ImageLoader {
func load(_ url: URL) -> UIImage?
// 문서에만 있는 규칙들 = 전부 인터페이스다
// · configure(cacheSize:)를 먼저 불러야 한다
// · 메인 스레드에서만 호출할 것
// · nil은 "실패"일 수도 "아직 로딩 중"일 수도 있다
// · 반환된 UIImage를 강한 참조로 들고 있으면 캐시가 안 비워진다
}enum ImageLoader {
/// 이미지를 가져온다. 캐시·스레드·취소는 안에서 처리한다.
/// 실패는 throw로 구분되고, 반환값 보관 여부는 캐시에 영향을 주지 않는다.
static func image(at url: URL) async throws -> UIImage
}classitis — "클래스는 작아야 한다"의 부작용
"The extreme of the 'classes should be small' approach is a syndrome I call classitis, which stems from the mistaken view that 'classes are good, so more classes are better.' ... Small classes don't contribute much functionality, so there have to be a lot of them, each with its own interface. These interfaces accumulate to create tremendous complexity at the system level."
대표 사례로 Java의 IO 라이브러리를 든다. 파일에서 직렬화된 객체를 읽으려면 객체를 세 개 만들어야 한다.
FileInputStream fileStream =
new FileInputStream(fileName);
BufferedInputStream bufferedStream =
new BufferedInputStream(fileStream);
ObjectInputStream objectStream =
new ObjectInputStream(bufferedStream);Ousterhout의 지적은 두 가지다.
- 앞의 두 객체(
fileStream,bufferedStream)는 파일을 연 뒤로는 다시 쓰이지 않는다. 이후 모든 작업은objectStream으로만 한다. - 더 나쁜 건 버퍼링을 명시적으로 요청해야 한다는 점이다. "개발자가 이 객체 만드는 걸 잊으면 버퍼링이 없어서 I/O가 느려진다."
"모두가 버퍼링을 원하는 건 아니니 기본 메커니즘에 넣지 말자"는 반론에 대한 답이 정확하다. "선택지를 제공하는 것은 좋다. 하지만 인터페이스는 흔한 경우를 가능한 한 단순하게 만들도록 설계되어야 한다. 파일 I/O를 쓰는 거의 모든 사용자가 버퍼링을 원할 것이므로, 기본으로 제공되어야 한다." 버퍼링을 끄고 싶은 소수를 위해서는 별도의 생성자나 메서드를 두면 된다.
5. 정보 은닉과 유출 — Parnas를 잇는 장 🔥 핵심 20%
5장은 Parnas 1972의 직계 후손이다. 다만 용어가 하나 추가된다 — 정보 유출(information leakage).
"Information leakage occurs when the same knowledge is used in multiple places, such as two different classes that both understand the format of a particular type of file."
여기서 중요한 건 "지식"이지 "코드"가 아니다. 코드 중복이 없어도 지식이 두 곳에 있으면 유출이다.
시간적 분해 — 가장 흔한 유출 경로
"In temporal decomposition, execution order is reflected in the code structure: operations that happen at different times are in different methods or classes. If the same knowledge is used at different points in execution, it gets encoded in multiple places, resulting in information leakage."
final class DocumentReader { // 앱 시작할 때 동작
func read(_ url: URL) throws -> Document {
// 헤더 16바이트 · 버전은 4~8바이트 · 본문은 리틀엔디안
}
}
final class DocumentWriter { // 앱 종료할 때 동작
func write(_ doc: Document, to url: URL) throws {
// 헤더 16바이트 · 버전은 4~8바이트 · 본문은 리틀엔디안
// ↑ 같은 지식이 여기 또 있다. 포맷이 바뀌면 두 군데를 고쳐야 한다.
}
}enum DocumentFormat { // "이 포맷을 안다"는 책임 하나
static func decode(_ data: Data) throws -> Document
static func encode(_ doc: Document) -> Data
// 헤더 오프셋 · 엔디안 규칙은 이 안에만 있다
}"When designing modules, focus on the knowledge that's needed to perform each task, not the order in which tasks occur."
— 실행에는 당연히 순서가 있다. 다만 그 순서가 모듈 구조에 반영될 이유는 없다.
클래스 안에서도 정보 은닉을 한다
5.8절이 자주 놓치는 대목이다. 정보 은닉은 클래스 사이에서만 하는 게 아니다. 클래스 안에서도, 어떤 메서드가 아는 정보를 다른 메서드가 몰라도 되게 만들면 클래스 내부의 변경이 쉬워진다.
책은 균형도 명시한다. 감추는 게 언제나 옳은 건 아니다. 사용자가 반드시 알아야 하는 정보를 감추면 그건 은닉이 아니라 불명확성이다. 예: 성능에 결정적인 파라미터를 완전히 숨겨서 튜닝할 방법을 없애는 것.
6. 범용이 더 깊다 · 계층마다 다른 추상화
"약간 범용적"이 정답이다 (6장)
6장의 제목은 General-Purpose Modules are Deeper이지만, 6.1절의 제목은 "Make classes somewhat general-purpose"다. somewhat이 핵심이다.
- "지금 내 요구를 전부 커버하는 가장 단순한 인터페이스는 무엇인가?" 전체 능력을 줄이지 않고 메서드 수를 줄였다면, 더 범용적으로 만든 것이다. 단 인자를 잔뜩 추가해서 메서드 수를 줄인 거라면 단순해진 게 아니다.
- "이 메서드는 몇 가지 상황에서 쓰일 것인가?" 딱 한 가지 용도로 설계된 메서드는 지나치게 특수하다는 레드 플래그다.
- "이 API가 지금 내 요구에 쓰기 쉬운가?" 이 클래스를 쓰려고 추가 코드를 많이 써야 한다면, 그건 범용화를 너무 멀리 간 것이다.
// ❌ 특수 목적 — 삭제 메서드가 세 개
protocol TextSpecial {
func backspace() // 커서 앞 한 글자
func delete() // 커서 뒤 한 글자
func deleteSelection() // 선택 영역
}
// ✅ 약간 범용 — 하나로 세 용도를 다 덮는다
protocol TextGeneral {
func delete(from: Position, to: Position)
}
// ⚠️ 너무 범용 — 단순하고 범용적이지만 쓰기 나쁘다
protocol TextTooGeneral {
func insert(_ ch: Character, at: Position) // 한 글자씩만
func delete(at: Position) // 상위 코드가 루프를 돌아야 하고 느리다
}계층마다 다른 추상화 (7장)
인접한 두 계층이 비슷한 추상화를 가지면 문제가 생긴다. 가장 흔한 증상이 pass-through 메서드다.
public class TextDocument ... {
private TextArea textArea;
public Character getLastTypedCharacter() {
return textArea.getLastTypedCharacter();
}
public int getCursorOffset() {
return textArea.getCursorOffset();
}
public void insertString(String textToInsert, int offset) {
textArea.insertString(textToInsert, offset);
}
}모든 인터페이스 중복이 나쁜 건 아니다. 여러 구현이 같은 인터페이스를 제공하는 것(디스크 I/O와 소켓 I/O가 같은 인터페이스를 갖는 것)은 좋다. 이건 인터페이스를 더 깊게 만든다. 나쁜 건 같은 계층 사슬 안에서 위아래가 같은 API를 갖는 것이다. 데코레이터 패턴은 이 함정에 빠지기 쉽다고 책은 경고한다.
7. 복잡도를 아래로 내려라
모듈을 만드는 사람은 한 명이고, 쓰는 사람은 여러 명이다. 따라서 만드는 쪽이 고생하는 게 훨씬 싸다. "이 복잡도를 내가 처리할까, 사용자에게 넘길까?"에서 답은 대체로 내가다.
설정 파라미터 — 복잡도를 위로 미는 가장 흔한 방법
옹호 논리는 그럴듯하다. 사용자가 자기 도메인을 더 잘 아니 튜닝하게 해 주자는 것이고, 실제로 맞을 때도 있다. 그런데 책의 지적은 이렇다.
"However, configuration parameters also provide an easy excuse to avoid dealing with important issues and pass them on to someone else. In many cases it's difficult or impossible for users or administrators to determine the right values. In other cases, the right values could have been determined automatically with a little extra work in the system implementation."
// ❌ 복잡도를 위로 민다 — "몇 초가 맞는지는 네가 정해"
struct Transport {
let retryInterval: TimeInterval // 사용자가 이걸 어떻게 알지?
}
// ✅ 복잡도를 아래로 내린다 — 성공한 요청의 응답 시간을 재서 스스로 정한다
struct Transport {
private var rtt = MovingAverage()
private var retryInterval: TimeInterval { rtt.value * 2 }
// 덤: 운영 조건이 바뀌면 자동으로 따라간다.
// 고정 파라미터는 금방 낡는다("can easily become out of date").
}"Before exporting a configuration parameter, ask yourself: 'will users (or higher-level modules) be able to determine a better value than we can determine here?'"
그리고 마무리가 정확하다 — "Ideally, each module should solve a problem completely; configuration parameters result in an incomplete solution, which adds to system complexity." 이상적으로는 각 모듈이 문제를 완전히 해결해야 한다. 설정 파라미터는 불완전한 해법이고, 그만큼 시스템 복잡도를 늘린다.
아래로 내리는 것도 무한정은 아니다. 판단 기준이 있다. 내리려는 복잡도가 그 모듈의 기존 기능과 밀접하게 관련되어 있고, 내림으로써 다른 많은 곳이 단순해지며, 모듈의 인터페이스가 오히려 단순해진다면 내려라. 관련 없는 복잡도를 억지로 밀어 넣으면 그 모듈이 얕아진다.
8. 합칠 것인가 나눌 것인가
9장은 "이 두 코드를 한 모듈에 둘까, 나눌까"의 판단 기준을 준다. 나누는 것도 비용이 있다는 게 출발점이다 — 추가 인터페이스, 코드 사이 이동, 중복 가능성.
- 정보를 공유한다. 책의 예 — HTTP 서버에서 요청을 읽는 메서드와 파싱하는 메서드가 둘 다 HTTP 요청 포맷을 알아야 했다.
- 합치면 인터페이스가 단순해진다. 두 조각을 합쳤을 때 바깥에 노출되는 게 줄어든다면 합쳐라.
- 중복이 사라진다. 같은 코드가 반복된다면 추상화를 못 찾은 것이다(레드 플래그 "Repetition").
- 개념적으로 겹친다. 둘을 아우르는 더 상위의 간단한 범주가 있는가? 부분 문자열 검색과 대소문자 변환은 둘 다 "문자열 조작"이다.
"한쪽을 이해하려면 다른 쪽을 봐야 한다." 이건 레드 플래그 "결합된 메서드(Conjoined Methods)"이기도 하다. 나눠 놨는데 항상 같이 읽어야 한다면, 나눈 게 아니라 흩뿌린 것이다.
메서드 길이에 대한 입장 — Clean Code와 갈라지는 지점
Ousterhout의 기준은 길이가 아니라 인터페이스다. 긴 메서드라도 인터페이스가 단순하고 한 번에 읽히면 괜찮다. 쪼개야 할 때는 길어서가 아니라 안에 독립적으로 이해되는 하위 작업이 있을 때다.
쪼개서 나쁜 경우도 명시한다 — 쪼갠 결과가 결합된 메서드가 되거나, 인자를 잔뜩 주고받게 되면 쪼개지 않은 것만 못하다. 이 대목이 Robert Martin의 Clean Code와 정면으로 갈리는 지점이고, 두 사람은 공개 토론으로 이 차이를 정리해 두었다.
9. 에러를 존재하지 않게 정의하라 🔥 핵심 20%
이 책에서 가장 반직관적이면서 실전 효과가 큰 장이다. 출발점은 관찰 하나다. 예외 처리 코드가 정상 동작 코드보다 많고, 버그도 거기서 더 많이 난다.
보통은 "예외를 잘 처리하자"로 대응한다. Ousterhout의 제안은 다르다. 예외 상황 자체가 성립하지 않도록 API를 다시 정의하라.
사례 1 — Java의 substring
extension String {
func substring(from: Int, to: Int) throws -> String {
guard from >= 0, to <= count, from <= to else { throw StringError.outOfRange }
...
}
}
// 호출부 — 매번 이런다
let preview: String
if text.count > 100 {
preview = try text.substring(from: 0, to: 100)
} else {
preview = text
}extension String {
/// 인덱스가 beginIndex 이상 endIndex 미만인 문자들을 (있다면) 돌려준다.
/// 음수든, begin > end 든 동작이 정의되어 있다. 실패하지 않는다.
func substring(from: Int, to: Int) -> String {
let lo = max(0, min(from, count))
let hi = max(lo, min(to, count))
...
}
}
let preview = text.substring(from: 0, to: 100) // 짧아도 그냥 된다Ousterhout의 불평은 아주 구체적이다 — "a one-line method call now becomes 5–10 lines of code", 한 줄이면 될 호출이 5~10줄이 된다. 그리고 "Python은 범위를 벗어난 리스트 슬라이스에 대해 빈 결과를 돌려준다"며 반대 사례를 든다.
책이 이 반론을 직접 다룬다. 에러를 던지는 방식이 버그를 잡아 주는 면은 있다. 하지만 동시에 복잡도를 올리고, 그 복잡도가 다른 버그를 만든다. 개발자가 에러를 피하거나 무시하는 코드를 추가로 써야 하고(버그 확률 증가), 안 쓰면 런타임에 예상 못 한 예외가 터진다. 결론은 한 문장이다 — "Overall, the best way to reduce bugs is to make software simpler."
사례 2 — 유닉스의 파일 삭제
- 정의로 없애기 — 위의 사례들. 가장 강력하다. Tcl의
unset도 예로 든다 — "없는 변수를 지워라"는 요청은 할 일이 이미 끝난 상태이므로 그냥 리턴하면 된다. - 마스킹(masking) — 낮은 계층에서 처리하고 위로 올리지 않는다. TCP의 패킷 재전송이 예다.
- 모으기(aggregation) — 여러 예외를 한 곳에서 한꺼번에 처리한다. 웹 서버가 요청 단위 최상위에서 잡아 에러 페이지를 돌려주는 식.
- 크래시 — 복구가 정말 불가능한 종류(메모리 부족 등)는 죽인다. 복구 코드를 쓰고 테스트도 못 하느니 낫다.
"예외를 무시하라"가 아니다. 책도 한계를 명시한다 — 정말로 알려야 하는 에러까지 삼키면 그건 정보를 감춘 게 아니라 버그를 감춘 것이다. 기준은 "이 상황을 정상 동작의 일부로 정의하는 것이 자연스러운가"다. 잘라 주는 substring은 에러를 삼킨 게 아니라 모든 입력에 대해 정의된 함수가 된 것이다.
10. 두 번 설계하라
"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. You'll end up with a much better result if you consider multiple options for each major design decision: design it twice."
중요한 건 "다르게" 두 번이다
"Try to pick approaches that are radically different from each other; you'll learn more that way. Even if you are certain that there is only one reasonable approach, consider a second design anyway, no matter how bad you think it will be. It will be instructive to think about the weaknesses of that design and contrast them with the features of other designs."
책의 예시는 텍스트 편집기의 텍스트 클래스 인터페이스다. 세 가지를 놓고 비교한다.
| 대안 | 상위 코드가 해야 하는 추가 작업 | 평가 |
|---|---|---|
| 줄 단위 line-oriented | 잘라내기·붙여넣기처럼 줄 일부나 여러 줄에 걸친 작업에서 줄을 쪼개고 합쳐야 한다 | 상위가 일한다 |
| 문자 단위 character-oriented | 한 글자 넘게 바꾸려면 루프를 돌아야 한다 | 상위가 일하고, 느리다 — 문자마다 모듈 호출 |
| 문자열 범위 단위 string-oriented | 줄 경계를 넘나드는 임의 범위를 그대로 다룬다 | 상위 코드의 실제 동작에 가장 가깝다 |
- 상위 소프트웨어가 쓰기 쉬운가? — "인터페이스에서 가장 중요한 고려사항"이라고 명시한다.
- 한쪽이 더 단순한 인터페이스를 갖는가?
- 한쪽이 더 범용적인가?
- 한쪽이 더 효율적인 구현을 가능하게 하는가?
그리고 결론이 유연하다 — 최선은 대안 중 하나일 수도 있고, 여러 대안의 장점을 합친 새 설계일 수도 있다. 셋 다 별로면 그 문제점들을 재료로 삼아 추가 안을 만든다.
Ousterhout의 관찰은 뼈아프다. 똑똑한 학생일수록 첫 번째 아이디어로 지금까지 버텨 왔기 때문에 두 번 설계하는 습관이 없다. 그런데 문제가 충분히 커지면 첫 아이디어로는 안 되는 지점이 반드시 온다. 그때 배운 적 없는 기술이 필요해진다.
11. 주석과 이름 — 설계 도구로서의 글
"좋은 코드는 자기 문서화한다"에 대한 답 (12장)
12장은 주석을 안 쓰는 네 가지 변명을 하나씩 반박한다. 핵심 반박은 이것이다 — 추상화의 상당 부분은 코드로 표현할 수 없다. 시그니처는 타입만 말하고, "이 함수를 언제 불러야 하는지", "무엇을 보장하는지", "왜 이렇게 했는지"는 코드에 없다.
// count를 1 증가시킨다
count += 1
/// 사용자를 가져온다
func fetchUser(id: String) async throws -> User/// 사용자를 가져온다.
///
/// 네트워크 실패 시 최대 3회까지 지수 백오프로 재시도한다 — 호출자는 재시도를 신경 쓰지 않아도 된다.
/// 5분 이내에 같은 id로 호출하면 캐시된 값을 돌려준다. 강제로 새로 받으려면 `refreshUser(id:)`.
/// 취소되면 `CancellationError`를 던진다. 그 외 에러는 전부 `APIError`로 변환된다.
func fetchUser(id: String) async throws -> User- 더 낮은 수준의 주석은 정밀도를 더한다(lower-level comments add precision) — 단위는 무엇인지, 경계값은 포함인지, null이 무엇을 뜻하는지, 누가 해제 책임을 지는지.
- 더 높은 수준의 주석은 직관을 더한다(higher-level comments enhance intuition) — 이 코드가 왜 존재하는지, 전체에서 어떤 역할인지.
- 같은 수준에서 코드를 다시 말하는 주석만 쓸모없다.
- 구현 주석은 "what and why, not how" — 어떻게는 코드가 이미 말한다.
주석을 먼저 쓴다 (15장)
미루는 이유부터 짚는다. 다들 "코드가 아직 바뀌니까 안정되면 쓰겠다"고 말하지만, 진짜 이유는 주석을 잡일로 보기 때문이라는 것. 결과는 하나다 — "미루기 시작하면 조금 더 미루기 쉬워지고, 대개 끝내 안 쓰게 된다."
구현 전에 인터페이스 주석부터 쓴다. 이유는 테스트를 먼저 쓰는 것과 같다 — 짧고 명료하게 안 써지면, 그건 설계가 나쁘다는 신호다. 주석이 길고 복잡해진다면 인터페이스가 복잡한 것이고, 인터페이스가 복잡하면 그 모듈은 얕다.
이름 하나가 6개월을 잡아먹은 이야기 (14장)
14.1절은 Ousterhout 본인의 실화다. 1980년대 말~90년대 초 그가 대학원생들과 만든 분산 운영체제 Sprite에서 파일 데이터 블록이 가끔 0으로 변하는 버그가 있었다.
파일 시스템 코드가 block이라는 변수 이름을 두 가지 뜻으로 썼다. 어떤 곳에서는 디스크의 물리 블록 번호, 다른 곳에서는 파일 안의 논리 블록 번호. 그리고 한 지점에서 논리 블록 번호가 담긴 변수가 물리 블록 번호가 필요한 자리에 잘못 쓰였다. 결과: 무관한 디스크 블록이 0으로 덮어써졌다.
가장 무서운 부분은 원인이 아니라 왜 못 찾았는가이다.
"While tracking down the bug, several people, including myself, read over the faulty code, but we never noticed the problem. When we saw the variable block used as a physical block number, we reflexively assumed that it really held a physical block number."
— 여러 사람이 그 코드를 읽었는데 아무도 못 봤다. block이 물리 블록 번호로 쓰이는 걸 보면 반사적으로 진짜 물리 블록 번호가 들어 있다고 가정했다. 찾는 데 6개월이 걸렸다.
// ❌ 6개월
var block: Int = logicalBlock(of: file, at: offset)
writeToDisk(block) // 아무도 이상하다고 못 느낀다
// ✅ 컴파일러 없이도 눈에 보인다
var fileBlock: Int = logicalBlock(of: file, at: offset)
writeToDisk(fileBlock) // "fileBlock을 디스크에 쓴다고?" — 읽는 순간 걸린다
// 이름을 fileBlock / diskBlock 으로 나눴다면
// "프로그래머는 fileBlock을 그 자리에 쓸 수 없다는 걸 알았을 것이다"책의 지적이 더 날카롭다. block은 끔찍한 이름이 아니다. 물리 블록에도, 논리 블록에도 "제법 잘 맞는" 이름이다. 그래서 아무도 문제를 못 느꼈다. Ousterhout의 결론 — "'그럭저럭 가까운' 이름에 만족하지 말라. 조금 더 시간을 들여 정밀하고, 모호하지 않고, 직관적인 훌륭한 이름을 골라라."
12. 유행에 대한 입장
19장에서 Ousterhout는 당대의 유행들을 복잡도라는 잣대 하나로 채점한다. 호불호가 분명해서 논쟁이 많은 장이다.
| 대상 | 평가 | 이유 |
|---|---|---|
| 인터페이스 상속 | 좋음 | 같은 인터페이스를 여러 목적에 재사용한다. "구현이 많아질수록 그 인터페이스는 깊어진다." 서로 다른 구현들의 본질적 특징만 잡고 차이는 피하는 것 — 이게 추상화의 핵심. |
| 구현 상속 | 주의 | 먼저 이점을 인정한다 — 같은 구현을 여러 서브클래스에 복제하지 않아도 되므로 변경 증폭을 줄인다. 문제는 부모와 자식 사이에 의존성이 생긴다는 것 — 부모의 인스턴스 변수를 양쪽이 만지면 계층 안에서 정보 유출이 일어난다. 원문의 처방: "쓰기 전에 합성(composition) 기반 접근이 같은 이점을 줄 수 있는지 먼저 고려하라." 대안이 없으면 최소한 부모가 관리하는 상태와 자식이 관리하는 상태를 분리하라. |
| 애자일 | 주의 | 증분 개발 자체는 좋다. 위험은 증분의 단위가 "기능"이 되는 것이다. 단위는 추상화여야 한다. 기능 단위 증분은 전술적 프로그래밍으로 흐른다. |
| 단위 테스트 | 필수 | "나는 단위 테스트의 강력한 지지자다." 리팩터링의 전제 조건이다. 테스트가 없으면 설계를 고칠 수 없다. |
| TDD | 반대 | 아래 참조 |
| 디자인 패턴 | 주의 | 남용을 경계한다. 패턴을 쓰는 것이 목표가 되면 더 단순한 해법이 있는데도 패턴을 밀어 넣게 된다. |
| getter / setter | 부정적 | 얕은 메서드를 대량 생산한다. 필드를 그냥 노출하는 것보다 나은 게 없으면서 코드만 늘린다. |
TDD 비판 — 정확히 무엇을 반대하는가
"Although I am a strong advocate of unit testing, I am not a fan of test-driven development. The problem with test-driven development is that it focuses attention on getting specific features working, rather than finding the best design. This is tactical programming pure and simple, with all of its disadvantages. Test-driven development is too incremental: at any point in time, it's tempting to just hack in the next feature to make the next test pass. There's no obvious time to do design, so it's easy to end up with a mess."
대안까지 명시한다 — "개발의 단위는 기능이 아니라 추상화여야 한다. 추상화의 필요를 발견했으면 시간을 두고 조각조각 만들지 말고 한 번에 설계하라"(적어도 핵심 기능의 합리적으로 완전한 집합을 제공할 만큼은).
"One place where it makes sense to write the tests first is when fixing bugs." 버그를 고치기 전에 그 버그 때문에 실패하는 테스트를 먼저 쓴다. 그래야 정말로 그 버그를 고쳤는지 확인할 수 있다. 고친 뒤에 테스트를 쓰면 그 테스트가 실제로 버그를 건드리지 않을 수도 있다.
13. 성능을 위한 설계
20장의 입장은 단순하다. 단순한 코드가 대체로 빠른 코드다. 그리고 성능을 만질 때의 규율을 준다.
"It's tempting to rush off and start making performance tweaks, based on your intuitions about what is slow. Don't do this! Programmers' intuitions about performance are unreliable. This is true even for experienced developers. If you start making changes based on intuition, you'll waste time on things that don't actually improve performance, and you'll probably make the system more complicated in the process."
- 어디를 고칠지 알려 준다. 최상위 지표만 재면 "느리다"는 것만 알고 왜인지는 모른다. 구체적인 소수의 지점을 찾을 때까지 더 깊이 재야 한다.
- 기준선(baseline)을 만든다. 고친 뒤 다시 재서 실제로 좋아졌는지 확인한다. 측정 가능한 차이가 없으면 되돌려라 — 시스템을 더 단순하게 만든 게 아니라면.
그리고 설계 차원의 처방은 "임계 경로(critical path)를 중심으로 설계하라"이다. 가장 흔한 동작 하나를 골라, 그 경로가 거쳐야 하는 최소한의 코드가 무엇인지 먼저 그린 다음, 그 그림에 맞게 구조를 짠다. 특수 케이스를 여기저기 넣어 붙이는 방식보다 더 단순하고 더 빠른 결과가 나온다.
14. 레드 플래그 14개 — 체크리스트
원서 부록의 목록이다. 코드 리뷰에 그대로 써도 된다. 순서와 정의는 원문 그대로다.
| 레드 플래그 | 정의 |
|---|---|
| 얕은 모듈 Shallow Module | 제공하는 기능에 비해 인터페이스가 복잡하다 |
| 정보 유출 Information Leakage | 같은 지식이 여러 곳에서 쓰인다 — 두 클래스가 같은 파일 포맷을 아는 것처럼 |
| 시간적 분해 Temporal Decomposition | 실행 순서가 코드 구조에 반영되어, 다른 시점의 작업이 다른 메서드·클래스에 있다 |
| 과다 노출 Overexposure | 흔히 쓰는 기능의 API가 거의 안 쓰는 기능까지 배우게 만든다 |
| pass-through 메서드 | 인자를 그대로 다른 메서드에 넘기기만 한다 — 대개 API도 같다 |
| 반복 Repetition | 같거나 거의 같은 코드가 계속 나온다 → 올바른 추상화를 못 찾은 것 |
| 특수-범용 혼재 Special-General Mixture | 범용 메커니즘 안에 특정 용도 전용 코드가 섞여 있다 |
| 결합된 메서드 Conjoined Methods | 한 메서드의 구현을 이해하려면 다른 메서드의 구현까지 봐야 한다 |
| 코드를 반복하는 주석 | 주석의 정보가 바로 옆 코드에서 이미 명백하다 |
| 구현 문서가 인터페이스를 오염 | 인터페이스 문서가 쓰는 데 필요 없는 구현 세부를 설명한다 |
| 모호한 이름 Vague Name | 여러 가지를 가리킬 만큼 넓어서 정보를 주지 않는다 |
| 이름 짓기 어려움 Hard to Pick Name | 명확한 이미지를 주는 간단한 이름이 안 떠오른다 → 대상의 설계가 깨끗하지 않다는 힌트 |
| 설명하기 어려움 Hard to Describe | 간단하고 완전한 주석을 쓰기 어렵다 → 설계에 문제가 있다는 지표 |
| 비명백한 코드 Nonobvious Code | 빠르게 읽어서는 의미와 동작을 알 수 없다 |
이름이 안 지어지거나 한 문장으로 설명이 안 되면, 그건 글솜씨 문제가 아니라 설계 문제다. 이 두 검사는 공짜로 쓸 수 있고 거의 틀리지 않는다 — 도구도 필요 없고, 코드를 다 읽지 않아도 되고, 리뷰 중에 몇 초면 끝난다.
오늘 바로 시작하는 법
- 다음 PR에서 인터페이스 주석을 코드보다 먼저 써 본다. 안 써지면 설계를 다시 본다.
- 내가 만든 클래스 하나를 골라 직사각형으로 그려 본다. 얕으면 왜 존재하는지 자문한다.
- 가장 최근에 쓴
throws하나를 골라 정의로 없앨 수 있는지 검토한다. - 설정 파라미터 하나를 골라 "사용자가 우리보다 나은 값을 정할 수 있는가?"를 묻는다.
- 다음 설계 결정에서 근본적으로 다른 두 번째 안을 억지로라도 만들어 본다.