← Study Hub

설계 정전 3권 정독 — Parnas · Ousterhout · Tar Pit

1972년 논문 6쪽, 2018년 책 한 권, 2006년 논문 66쪽. 셋 다 같은 적을 다른 무기로 상대한다. 원문을 직접 대조해 핵심만 관통한다.
진행률
0 / 0 완료
설계 책을 열 권 쌓아 두면 압도된다. 그런데 이 세 편을 나란히 놓고 읽으면 이상한 일이 벌어진다. 세 편이 같은 적을 서로 다른 각도에서 때리고 있다. Parnas는 무엇을 기준으로 쪼갤 것인가를, Ousterhout는 쪼갠 것이 좋은지 어떻게 아는가를, Tar Pit은 애초에 진짜 적이 무엇인가를 답한다. 이 강의는 세 원문을 직접 펼쳐 놓고 그 답들을 한 줄로 잇는다.
📝 이 강의의 원칙

세 원전 모두 원문을 직접 대조해 인용했다. 요약본에서 흔히 잘못 옮겨지는 대목 — Parnas의 성능 반박, Tar Pit의 "본질적 상태" 정의, Ousterhout의 10~20% 숫자가 정확히 무엇에 붙는지 — 은 원문 표현을 그대로 실었다. 인용부호 안의 문장은 전부 원문이다.

✅ 이 강의는 진입문이다 — 권별 정독본이 따로 있다

여기서는 세 권을 한 장으로 잇는 데 집중한다. 각 권을 원전 구조대로 끝까지 파고드는 정독본이 따로 있으니, 이 강의를 먼저 읽고 관심 가는 쪽으로 내려가면 된다.

1. 세 권을 왜 같이 읽는가

세 편은 46년에 걸쳐 나왔지만 문제의식은 하나다. 소프트웨어가 커지면 왜 손댈 수 없게 되는가. 각자 그 질문의 다른 부분을 맡는다.

공통의 적 — 복잡도: 시스템을 이해하고 바꾸기 어렵게 만드는 모든 것 1972 Parnas — 무엇을 기준으로 쪼갤 것인가 답: 처리 순서가 아니라 "바뀔 결정"을 감추도록 쪼갠다 (정보 은닉) 1986 Brooks — 복잡도를 본질과 우발로 나눈다 (배경) "소프트웨어의 복잡도는 본질적 속성이다" → Tar Pit이 정면으로 반박한다 2006 Out of the Tar Pit — 우발적 복잡도의 최대 원천은 무엇인가 답: 상태(state)와 제어(control). 없애고, 못 없애면 격리한다 2018 Ousterhout — 그래서 매일 무엇을 하는가 답: 깊은 모듈을 만들고, 개발 시간의 10~20%를 설계에 투자한다
같은 적, 네 개의 답. Brooks는 이 강의의 주인공은 아니지만 Tar Pit이 반박하는 대상이라 자리를 잡아 뒀다.
🔑 한 줄로 이으면

Parnas가 "바뀔 결정을 감추는 단위로 나눠라"라고 기준을 주고, Ousterhout가 "그렇게 나눈 결과가 깊은지 얕은지 재라"라고 척도를 주고, Tar Pit이 "감춰야 할 것 중 가장 위험한 건 상태다"라고 표적을 특정한다.

2. Parnas ① — 같은 프로그램, 두 개의 분해 🔥 핵심 20%

논문 제목이 곧 질문이다. On the Criteria To Be Used in Decomposing Systems into Modules시스템을 모듈로 나눌 때 쓸 기준에 관하여. 당시 사람들은 "모듈로 나눠라"까지는 말했지만 무엇을 기준으로는 말하지 않았다. Parnas의 첫 문단이 정확히 그 지적이다.

📝 원문

"Usually nothing is said about the criteria to be used in dividing the system into modules."
— 보통 어떤 기준으로 시스템을 모듈로 나눌지에 대해서는 아무 말이 없다.

실험 대상: KWIC 색인 시스템

Parnas는 작은 프로그램 하나를 고른다. KWIC 색인이다. 하는 일은 이렇다.

그러면 어떤 단어로든 제목을 찾을 수 있는 색인이 된다. 좋은 프로그래머라면 1~2주면 짜는 크기라고 Parnas 본인이 못 박는다. 일부러 작은 문제를 고른 것이다 — 큰 시스템은 지면에 담을 수 없으니까.

분해 1 — 순서도대로 자른다

대부분의 사람이 하는 방식이다. 처리 단계 하나가 모듈 하나가 된다. Parnas 본인도 "소규모 실험 결과 대다수 프로그래머가 제안하는 분해가 대략 이것"이라고 적었다.

Modularization 1 — 모듈 5개: Input · Circular Shift · Alphabetizing · Output · Master Control
// ⚠️ 핵심: 이 두 개를 "모든 모듈이 직접" 읽고 쓴다.
var core: [UInt32] = []      // 문자를 한 워드에 4개씩 packing해서 저장
var lineIndex: [Int] = []    // 각 줄의 시작 주소

enum Input {                             // 모듈 1
    static func read(_ path: String) {
        // 파일을 읽어 core에 4개씩 packing하고 lineIndex를 채운다
    }
}

enum CircularShift {                     // 모듈 2
    static func setup() {
        for line in lineIndex.indices {
            let start = lineIndex[line]
            let word  = core[start / 4]  // ← "4개 packing" 규칙을 여기서도 안다
            let byte  = (word >> 24) & 0xFF
            _ = byte
        }
    }
}

enum Alphabetizing {                     // 모듈 3
    static func run() {
        // 정렬하려면 문자를 비교해야 한다 → core 포맷을 또 안다
    }
}

enum Output {                            // 모듈 4
    static func print() {
        // 출력하려면 문자를 꺼내야 한다 → core 포맷을 또 안다
    }
}

보기에 아주 깔끔하다. 인터페이스도 명확하다. Parnas도 "이것은 모듈러 프로그래밍 지지자들이 말하는 의미의 모듈화가 맞다"고 인정한다. 그런데 문제가 하나 있다. "문자를 한 워드에 4개씩 담는다"는 결정을 네 모듈이 전부 알고 있다.

분해 2 — 감출 것을 기준으로 자른다

Modularization 2 — 모듈 6개: Line Storage · Input · Circular Shifter · Alphabetizer · Output · Master Control
// 모듈 1: Line Storage — 저장 방식을 혼자만 안다 (이 모듈의 "비밀")
enum LineStorage {
    private static var core: [UInt32] = []      // 바깥에서 볼 수 없다
    private static var lineIndex: [Int] = []

    static func char(_ r: Int, _ w: Int, _ c: Int) -> UInt8 { /* r번째 줄, w번째 단어, c번째 문자 */ 0 }
    static func setChar(_ r: Int, _ w: Int, _ c: Int, _ d: UInt8) { }
    static func words(_ r: Int) -> Int { 0 }     // r번째 줄의 단어 수
    static func lines() -> Int { 0 }
    static func delLine(_ r: Int) { }
    static func delWord(_ r: Int, _ w: Int) { }
}

// 모듈 3: Circular Shifter — 시프트를 미리 만드는지 그때그때 계산하는지를 감춘다
enum CircularShifter {
    static func setup() { }                                     // 다른 함수보다 먼저 호출
    static func char(_ l: Int, _ w: Int, _ c: Int) -> UInt8 { 0 }  // l번째 시프트의 문자
}

// 모듈 4: Alphabetizer — 한 번에 다 정렬하는지 필요할 때만 하는지를 감춘다
enum Alphabetizer {
    static func alph() { }                       // 다른 함수보다 먼저 호출
    static func ith(_ i: Int) -> Int { 0 }       // i번째로 오는 시프트의 인덱스
}

함수 이름(CHAR, SETCHAR, WORDS, CSCHAR, CSSETUP, ALPH, ITH)은 논문에 나온 그대로다. 이제 바깥에서는 배열을 볼 수 없다. char(r, w, c)로만 물어본다.

분해 1 — 처리 순서대로 분해 2 — 감출 것 기준으로 Input Circular Shift Alphabetizing Output 공유 배열 core[] + lineIndex[] "4개 packing" 결정을 넷이 전부 안다 저장 방식이 바뀌면 네 모듈을 전부 고쳐야 한다 Input Circular Shifter Alphabetizer Output Line Storage — char() / setChar() / words() 함수 이름과 인자만 노출한다 비밀: core[] 저장 포맷 이 상자 밖으로 새어 나가지 않는다 저장 방식이 바뀌면 이 모듈 하나만 고친다 두 분해는 실행 파일이 같을 수도 있다. 다른 건 "일을 나눈 방식과 인터페이스"뿐이다.
화살표가 모이는 지점이 다르다. 왼쪽은 자료구조에, 오른쪽은 함수 인터페이스에 모인다. 이 차이 하나가 다음 절의 결과를 만든다.
🧠 자주 놓치는 대목 원문

Parnas는 두 분해가 런타임에는 같을 수도 있다고 명시한다. "A system built according to decomposition 1 could conceivably be identical after assembly to one built according to decomposition 2." 즉 이건 성능이나 자료구조 얘기가 아니라 일감을 나누는 방식과 인터페이스 얘기다. 같은 물건을 두 가지로 자르는 것뿐이다.

3. Parnas ② — 변경 5개를 대입하면 한쪽이 무너진다 🔥 핵심 20%

"어느 쪽이 좋은가"를 취향으로 다투지 않는 게 이 논문의 힘이다. Parnas는 실제로 바뀔 법한 결정 5개를 목록으로 만들고, 두 설계에 하나씩 대입해 본다. 논문 원문의 목록 그대로다.

바뀔 수 있는 결정분해 1 (순서대로)분해 2 (감출 것 기준)
1. 입력 포맷한 모듈한 모듈
2. 모든 줄을 메모리에 둘 것인가
— 큰 작업이면 디스크로 내려야 한다
모든 모듈이 바뀐다Line Storage 하나
3. 문자를 한 워드에 4개씩 packing
— 데이터가 작으면 안 하는 게 빠르다
모든 모듈이 바뀐다Line Storage 하나
4. 시프트를 미리 만들 것인가
— 아니면 요청 시 계산할 것인가
Alphabetizer와 Output도 안다Circular Shifter 하나
5. 정렬을 한 번에 다 할 것인가
— 아니면 필요한 만큼씩 나눠 할 것인가
Output이 "정렬 완료"를 전제한다Alphabetizer 하나

1번은 양쪽 다 한 모듈이다. 그런데 2번부터 갈린다. 논문의 표현은 담백하지만 세다. "For the first decomposition the second change would result in changes in every module!" — 느낌표까지 원문이다.

왜 그렇게 되는지 코드로 확인하기

3번 변경(packing을 그만둔다)을 실제로 넣어 보자.

❌ 분해 1 — 같은 수정을 네 군데에서 반복한다
// Input 안
core[i / 4] |= UInt32(byte) << (24 - 8 * (i % 4))   // → core[i] = UInt32(byte)

// CircularShift 안
let byte = (core[start / 4] >> 24) & 0xFF           // → let byte = core[start]

// Alphabetizing 안
let byte = (core[pos / 4] >> (24 - 8 * (pos % 4)))  // → let byte = core[pos]

// Output 안
let byte = (core[pos / 4] >> (24 - 8 * (pos % 4)))  // → let byte = core[pos]
//  ↑ 네 번 고친다. 한 군데를 빠뜨리면 조용히 깨진다.
✅ 분해 2 — 함수 안쪽만 바뀌고 호출부는 한 줄도 안 바뀐다
enum LineStorage {
    // 바뀌는 건 이 두 함수의 "안"뿐이다
    static func char(_ r: Int, _ w: Int, _ c: Int) -> UInt8 {
        let pos = offset(r, w, c)
     // return UInt8((core[pos / 4] >> (24 - 8 * (pos % 4))) & 0xFF)   // 이전
        return UInt8(core[pos])                                          // 이후
    }
}

// Circular Shifter / Alphabetizer / Output — 수정 0줄
let ch = LineStorage.char(r, w, c)
🚨 여기가 진짜 교훈이다

분해 1이 무너지는 이유는 "코드를 잘못 짜서"가 아니다. 자료구조를 공유했기 때문이다. 자료구조를 공유하면 그 구조에 대한 결정을 아는 모든 모듈이 함께 바뀐다. 인터페이스가 아무리 깔끔해도 소용없다 — 배열 하나를 같이 만지는 순간 그들은 한 덩어리다.

Parnas가 비교에 쓴 세 가지 잣대

변경 용이성 (Changeability)
위의 표. 한 결정이 바뀔 때 몇 개 모듈이 흔들리는가.
독립 개발 (Independent Development)
분해 1의 인터페이스는 "복잡한 포맷과 테이블 구조"라 여러 팀이 함께 설계해야 한다. 분해 2의 인터페이스는 함수 이름과 인자 타입뿐이라 각자 바로 시작한다.
이해 가능성 (Comprehensibility)
분해 1은 Output을 이해하려면 Alphabetizer·Circular Shifter·Input을 다 알아야 한다. 시스템 전체로만 이해된다.
모듈 = 코드 덩어리가 아니다
원문: "'module' is considered to be a responsibility assignment rather than a subprogram." 모듈은 책임의 배정이다.

4. Parnas ③ — 기준, 오해, 그리고 저자 자신의 오답노트

결론 문장

논문의 결론은 세 문장이다. 이 분야에서 가장 많이 인용되는 대목이다.

🔑 원문 결론

"We have tried to demonstrate by these examples that it is almost always incorrect to begin the decomposition of a system into modules on the basis of a flowchart. We propose instead that one begins with a list of difficult design decisions or design decisions which are likely to change. Each module is then designed to hide such a decision from the others."

— 순서도를 기준으로 분해를 시작하는 것은 거의 언제나 틀렸다. 대신 어렵거나 바뀔 가능성이 높은 설계 결정의 목록에서 시작하라. 각 모듈은 그런 결정 하나를 다른 모듈로부터 감추도록 설계된다.

그리고 곧바로 이유가 붙는다. "Since, in most cases, design decisions transcend time of execution, modules will not correspond to steps in the processing." — 설계 결정은 대개 실행 시점을 초월하기 때문에, 모듈은 처리 단계와 일치하지 않게 된다.

Parnas는 순서도를 무조건 나쁘다고 하지 않는다. "5,000~10,000 instructions 규모까지는 유용한 추상이었지만, 그 너머로는 충분하지 않아 보인다"고 선을 긋는다.

논문이 직접 제시하는 "감춰야 할 것" 5가지

추상적인 원칙만 주고 끝내지 않는다. 구체적인 권고가 붙는다.

  1. 자료구조와 그 접근·수정 절차는 한 모듈이다. 여러 모듈이 나눠 갖지 않는다.
  2. 호출 규약(calling sequence)과 그 루틴은 한 모듈이다. 어셈블리 레벨에서 특히 중요하다.
  3. OS의 큐에 쓰이는 컨트롤 블록 포맷은 전용 모듈 안에 숨긴다. 이걸 모듈 간 인터페이스로 쓰는 게 관행인데, 포맷은 자주 바뀌므로 대단히 비싸게 치른다.
  4. 문자 코드, 알파벳 정렬 순서 같은 데이터도 모듈 안에 숨긴다.
  5. 어떤 항목을 어떤 순서로 처리할지도 가능한 한 한 모듈 안에 숨긴다.

3번은 오늘날 그대로 살아 있다. DTO / JSON 스키마 / DB 컬럼 구조를 모듈 경계로 삼는 설계가 정확히 이 함정이다.

오해 1 — "모듈로 나누면 느려진다"

이 반박은 1972년에 이미 나왔고 답도 나왔다. 다만 흔히 잘못 인용된다. Parnas는 "컴파일러가 인라인해 주니 괜찮다"고 말하지 않았다.

📝 실제로 한 말

"To save the procedure call overhead, yet gain the advantages that we have seen above, we must implement these modules in an unusual way. In many cases the routines will be best inserted into the code by an assembler..." 그리고 결론부에서: "To achieve an efficient implementation we must abandon the assumption that a module is one or more subroutines, and instead allow subroutines and programs to be assembled collections of code from various modules."

번역하면 이렇다. 모듈은 "설계상의 경계"이고, 서브루틴은 "실행 파일의 단위"다. 이 둘이 1:1일 이유가 없다. 도구가 그 매핑을 해 주면 된다. 오늘날 인라이닝, LTO, 단일화 컴파일이 하는 일이 정확히 이것이다. Parnas가 요구한 건 컴파일러 최적화가 아니라 "모듈 = 서브루틴"이라는 가정 자체를 버리는 것이었다.

오해 2 — "계층 구조가 좋은 분해다"

논문에는 별도 절이 있다. 결론은 단호하다.

🔑 원문

"hierarchical structure and 'clean' decomposition are two desirable but independent properties of a system structure." — 계층 구조와 깔끔한 분해는 둘 다 바람직하지만 서로 독립적인 성질이다.

계층은 "uses / depends upon" 관계가 부분 순서(partial ordering)를 이룰 때 생긴다. 그것대로 두 가지 이득이 있다고 Parnas는 적는다.

하지만 분해 1처럼 인터페이스에 중요한 결정을 노출한 채로도 계층 구조는 얼마든지 만들 수 있다. 그래서 별개다. 레이어를 잘 쌓았다고 정보 은닉이 된 게 아니다.

그리고 — Parnas 자신의 오답노트

논문에는 "Improvement in Circular Shift Module"이라는 짧은 절이 있다. 요약본에서 거의 잘리는 부분인데, 정보 은닉의 가장 날카로운 대목이다. Parnas가 자기가 만든 분해 2를 스스로 깐다.

분해 2의 Circular Shifter는 저장 방식은 잘 감췄다. 그런데 시프트가 나오는 순서를 명세에 못 박아 뒀다 — "i가 j보다 작으면 i번 줄의 시프트가 먼저 나온다, 각 줄의 첫 시프트는 원본이다" 하는 식으로.

Parnas의 자기비판 — 필요 이상으로 말한 인터페이스
// ❌ 원래 명세 — 순서까지 약속해 버렸다
//   (1) i < j 이면 i번 줄의 시프트들이 j번 줄보다 앞에 온다
//   (2) 각 줄의 첫 시프트는 원본, 두 번째는 한 단어 회전...
enum CircularShifter {
    static func setup()
    static func char(_ l: Int, _ w: Int, _ c: Int) -> UInt8
}

// ✅ 필요한 만큼만 말했다면
//   (1) 정의상 존재해야 할 시프트가 표에 전부 있다
//   (2) 어떤 것도 두 번 들어가지 않는다
//   (3) 시프트로부터 원본 줄을 알아내는 함수가 있다
enum CircularShifter2 {
    static func setup()
    static func char(_ l: Int, _ w: Int, _ c: Int) -> UInt8
    static func originalLine(_ l: Int) -> Int      // ← 순서 약속 대신 이것
}

순서를 약속해 버리면 어떤 손해가 나는가? "시프트를 애초에 알파벳순으로 생성해서 정렬 단계를 통째로 없애는" 구현을 만들 수 없다. (그러면 alph()는 빈 함수가 되고 ith(i)i를 그대로 돌려주면 된다.) 그 가능성을 자기 손으로 막아 버린 것이다. Parnas의 평가는 가차 없다.

🚨 원문

"By prescribing the order for the shifts we have given more information than necessary and so unnecessarily restricted the class of systems that we can build without changing the definitions. ... Our failure to do this ... must clearly be classified as a design error."

— 필요 이상으로 말했고, 그래서 정의를 바꾸지 않고 만들 수 있는 시스템의 범위를 불필요하게 좁혔다. 이것은 명백히 설계 오류로 분류되어야 한다.

✅ 실무 번역
  • 인터페이스에 "정렬되어 반환됩니다", "호출 순서대로 처리됩니다" 같은 약속을 무심코 넣지 않았는지 본다.
  • 테스트가 순서에 의존하고 있다면, 그 순서는 이미 공개된 계약이다. 되돌리기 어렵다.
  • "지금 구현이 어차피 그러니까"는 명세에 쓸 이유가 아니다. 구현이 그런 것과 약속한 것은 다르다.

5. Ousterhout ① — 복잡도의 정체 🔥 핵심 20%

A Philosophy of Software Design은 Parnas의 현대적 계승이다. 전제가 한 줄로 요약된다. "소프트웨어 설계가 존재하는 이유는 복잡도와 싸우기 위해서다." 패턴을 따르기 위해서도, 추상화를 최대화하기 위해서도, 줄 수를 줄이기 위해서도 아니다.

정의 — 남이 판단해 주는 정의

🔑 복잡도의 정의

복잡도(complexity) = 시스템의 구조와 관련된 것 중, 이해하거나 수정하기 어렵게 만드는 모든 것.

이 정의의 무서운 점은 기준이 남에게 있다는 것이다. 내가 짤 때 명확했는지는 상관없다. 다른 사람이 (또는 6개월 뒤의 내가) 어렵다고 느끼면 복잡한 것이다. 코드가 길어서가 아니라 바꾸는 비용이 기준이다.

3가지 증상 — 복잡도가 남기는 발자국

증상무엇인가현장에서 이렇게 들린다
변경 증폭
change amplification
사소한 변경 하나가 여러 곳의 수정을 부른다"버튼 색 하나 바꾸는데 파일 8개를 건드렸어요"
인지 부하
cognitive load
이 일을 하려면 알아야 하는 정보의 양"이 함수 쓰려면 초기화 순서랑 스레드 규칙을 다 알아야 해요"
미지의 미지
unknown unknowns
어디를 고쳐야 하는지조차 모른다"고친 줄 알았는데 이런 경로가 있는 줄 몰랐어요"

Ousterhout는 셋 중 미지의 미지가 가장 나쁘다고 못 박는다. 이유가 명확하다. 앞의 둘은 힘들어도 끝나기는 한다. 시간을 더 쓰면 된다. 그런데 세 번째는 아무리 성실해도 실패한다 — 존재를 모르는 것을 고칠 수는 없기 때문이다.

2가지 원인

의존성 (dependency)
이 코드가 혼자 이해되고 혼자 수정될 수 없을 때 생긴다. 의존성은 없앨 수 없다 — 줄이고 명확하게 만드는 게 목표다.
불명확성 (obscurity)
중요한 정보가 드러나 있지 않을 때 생긴다.

원인과 증상의 연결이 책에 명시되어 있다. 의존성은 변경 증폭과 높은 인지 부하를 낳고, 불명확성은 미지의 미지를 만들며 인지 부하에도 기여한다. 그래서 처방도 두 개다 — 의존성과 불명확성을 최소화하는 설계 기법을 찾으면 복잡도는 줄어든다.

⚠️ 복잡도는 한 번에 생기지 않는다

"복잡도는 점진적으로 쌓인다." 어떤 한 번의 결정이 시스템을 망가뜨리는 게 아니다. "이 정도는 괜찮잖아"가 수백 번 쌓인다. 그래서 되돌리기도 어렵다 — 되돌리려면 수백 번의 작은 정리가 필요하기 때문이다. 처방은 무관용(zero tolerance)이다.

6. Ousterhout ② — 깊은 모듈 🔥 핵심 20%

Parnas가 "감춰라"라고 했다면, Ousterhout는 "잘 감췄는지 어떻게 재는가"에 답한다. 답은 시각적이다. 모듈을 직사각형으로 그려라.

얕은 모듈 — 있으나 마나 깊은 모듈 — 좋은 모듈 인터페이스 (알아야 할 것) 구현 (해 주는 일) 배우는 비용 ≈ 직접 짜는 비용 → 감춰 주는 게 없다. 오히려 외울 게 하나 늘었다 인터페이스 구현 (아주 두껍다) 감춰 주는 양 → 조금 배우고 아주 많이 얻는다 모듈의 값어치 = 구현의 두께 − 인터페이스의 폭. 인터페이스가 넓어지면 값어치가 깎인다.
같은 "모듈 1개"라도 가치가 다르다. 인터페이스는 모든 사용자가 지불하는 비용이고, 구현은 모든 사용자가 받는 혜택이다.

실물로 보기

❌ 얕은 모듈 — 감춰 주는 게 거의 없다
final class UserFetcher {
    private let session: URLSession
    private let decoder: JSONDecoder
    private let baseURL: URL
    private let retryPolicy: RetryPolicy
    private let cachePolicy: URLRequest.CachePolicy

    // 쓰려면 이 5개를 다 이해하고 골라야 한다
    init(session: URLSession, decoder: JSONDecoder, baseURL: URL,
         retryPolicy: RetryPolicy, cachePolicy: URLRequest.CachePolicy) { ... }

    // 그런데 하는 일은 URLSession 호출 한 번
    func fetch(_ id: String) async throws -> Data { ... }
}

// 호출부: 결국 내가 다 알아야 한다
let fetcher = UserFetcher(session: .shared, decoder: JSONDecoder(),
                          baseURL: URL(string: "https://api.example.com")!,
                          retryPolicy: .exponential(max: 3),
                          cachePolicy: .reloadIgnoringLocalCacheData)
let data = try await fetcher.fetch("42")
let user = try JSONDecoder().decode(User.self, from: data)   // 디코딩도 내 몫
✅ 깊은 모듈 — 한 줄만 배우고 나머지를 다 넘긴다
enum UserAPI {
    /// 사용자를 가져온다. 재시도·캐시·디코딩·에러 변환은 안에서 처리한다.
    static func user(id: String) async throws -> User { ... }
}

// 호출부
let user = try await UserAPI.user(id: "42")

두 번째가 무조건 옳다는 뜻은 아니다. 요점은 인터페이스의 폭이 곧 모든 호출자에게 청구되는 비용이라는 것이다. 설정 값 5개를 노출하는 순간, 그 5개를 고르는 책임도 호출자에게 넘어간다.

⚠️ classitis — "클래스는 작아야 한다"의 부작용

Ousterhout가 만든 말이다. "클래스와 함수는 작아야 한다"를 믿고 잘게 쪼개면, 얕은 클래스가 잔뜩 생긴다. 인터페이스는 하나 늘 때마다 비용이 붙으므로 총 복잡도는 오히려 올라간다. 대표 사례로 Java의 IO 프레임워크를 든다 — 파일 하나 읽으려고 FileInputStreamBufferedInputStream으로 감싸고 다시 ObjectInputStream으로 감싸야 한다. 버퍼링은 거의 항상 원하는 것인데 왜 매번 사용자가 조립해야 하는가?

Parnas가 그대로 이어지는 지점 — 시간적 분해

레드 플래그 중 하나가 시간적 분해(Temporal Decomposition)다. 정의는 이렇다. "실행 순서가 코드 구조에 반영되어, 다른 시점에 일어나는 작업이 다른 메서드나 클래스에 들어가 있다." Parnas의 "순서도로 나누지 마라"와 같은 말이다.

❌ 시간적 분해 — 포맷 지식이 두 클래스로 새어 나간다
final class DocumentReader {   // 앱 시작할 때 동작
    func read(_ url: URL) -> Document {
        // 헤더 16바이트, 버전은 4~8바이트, 본문은 리틀엔디안...
    }
}

final class DocumentWriter {   // 앱 종료할 때 동작
    func write(_ doc: Document, to url: URL) {
        // 헤더 16바이트, 버전은 4~8바이트, 본문은 리틀엔디안...
        // ↑ 같은 지식이 여기 또 있다. 포맷이 바뀌면 두 군데를 고쳐야 한다
    }
}
✅ 지식 기준으로 묶기 — 포맷을 아는 곳은 한 군데
enum DocumentFormat {          // "이 포맷을 안다"는 책임 하나
    static func decode(_ data: Data) throws -> Document { ... }
    static func encode(_ doc: Document) -> Data { ... }
    // 헤더 오프셋·엔디안 규칙은 이 안에만 있다
}

책이 주는 판단 기준은 한 문장으로 외울 수 있다. "모듈을 설계할 때는 각 작업을 수행하는 데 필요한 지식에 집중하라. 작업이 일어나는 순서가 아니라." 실행에는 당연히 순서가 있다 — 다만 그 순서가 모듈 구조에 반영될 이유는 없다.

✅ 나머지 핵심 원칙 3개
  • 복잡도는 아래로 내려라. 모듈 만드는 사람 한 명이 고생하는 게, 쓰는 사람 여러 명이 고생하는 것보다 싸다.
  • 다른 계층은 다른 추상화를 가져야 한다. 아래 메서드를 그대로 전달만 하는 pass-through 메서드는 그 계층이 값을 못 하고 있다는 신호다.
  • 범용 모듈이 더 깊다. 특정 호출자에 맞춘 특수 API보다, "약간 범용적인" API가 인터페이스는 단순하고 쓸모는 넓다.

설정 파라미터 — 복잡도를 위로 미는 가장 흔한 방법

"복잡도를 아래로 내려라"의 반대 사례로 책이 지목하는 것이 설정 파라미터다. 옹호 논리는 그럴듯하다 — 사용자가 자기 환경을 더 잘 아니 튜닝하게 해 주자는 것이고, 실제로 맞는 경우도 있다. 하지만 책의 지적은 이렇다.

⚠️ 원문

"설정 파라미터는 중요한 문제를 다루지 않고 남에게 떠넘기는 쉬운 핑곗거리를 제공하기도 한다. 많은 경우 사용자나 관리자가 올바른 값을 정하기는 어렵거나 불가능하다. 또 어떤 경우에는 구현에 조금만 더 공을 들이면 시스템이 스스로 자동으로 정할 수 있었을 값이다."

책의 예시 — 재전송 간격을 파라미터로 뺄 것인가, 계산할 것인가
// ❌ 복잡도를 위로 민다 — "몇 초가 맞는지는 네가 정해"
struct Transport {
    let retryInterval: TimeInterval      // 사용자가 이 값을 어떻게 알지?
}

// ✅ 복잡도를 아래로 내린다 — 성공한 요청의 응답 시간을 재서 스스로 정한다
struct Transport {
    private var rtt = MovingAverage()
    private var retryInterval: TimeInterval { rtt.value * 2 }
    // 덤: 네트워크 상황이 바뀌면 자동으로 따라간다. 고정 파라미터는 금방 낡는다.
}
🔑 파라미터를 내보내기 전에 던질 질문

"사용자(또는 상위 모듈)가 우리보다 더 나은 값을 정할 수 있는가?" 아니라면 우리가 정해야 한다. 꼭 내보내야 한다면 합리적인 기본값을 자동으로 계산해서, 사용자는 예외적인 상황에서만 값을 주게 만든다. 책의 마무리가 정확하다 — "이상적으로는 각 모듈이 문제를 완전히 해결해야 한다. 설정 파라미터는 불완전한 해법이고, 그만큼 시스템 복잡도를 늘린다."

7. Ousterhout ③ — 에러를 존재하지 않게 정의하라

이 책에서 가장 실용적이면서 가장 반직관적인 장이다. 출발점은 관찰 하나다. 예외 처리 코드가 정상 동작 코드보다 많고, 버그도 거기서 더 많이 난다.

보통은 "예외를 잘 처리하자"고 대응한다. Ousterhout의 제안은 다르다. 예외 상황 자체가 성립하지 않도록 API를 다시 정의하라.

사례 1 — 범위를 벗어난 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
}
✅ 범위를 잘라서 준다 — 예외가 사라지고 if도 사라진다
extension String {
    /// 범위를 벗어나면 문자열 경계로 자른다. 실패하지 않는다.
    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가 실제로 든 예다. Java의 String.substring은 인덱스가 범위를 벗어나면 IndexOutOfBoundsException을 던진다. 그의 불평은 구체적이다 — "한 줄이면 될 메서드 호출이 5~10줄의 코드가 된다." 반면 Python은 범위를 벗어난 슬라이스에 대해 빈 결과를 돌려준다. 예외를 정의로 없앤 쪽이다.

🧠 "에러를 없애면 버그가 늘지 않나?" 원문

책이 이 반론을 직접 다룬다. 에러를 던지는 방식이 버그를 잡아 주는 면은 있다. 하지만 동시에 복잡도를 올리고, 그 복잡도가 다른 버그를 만든다. 개발자가 에러를 피하거나 무시하는 코드를 추가로 써야 하고(버그 확률 증가), 안 쓰면 런타임에 예상 못 한 예외가 터진다. 결론은 한 문장이다 — "버그를 줄이는 가장 좋은 방법은 소프트웨어를 더 단순하게 만드는 것이다."

사례 2 — 유닉스가 파일 삭제를 정의한 방식

Windows 방식
누가 열고 있는 파일은 지울 수 없다. → "사용 중입니다" 에러가 생기고, 호출자마다 그 상황을 처리해야 한다.
유닉스 방식
삭제는 항상 성공한다. 다만 이름만 지우고, 마지막 사용자가 닫을 때 실제로 없앤다. → 에러가 존재하지 않는다.

TCP도 같은 발상이다. 패킷 손실은 실제로 일어나지만, TCP는 그것을 위로 보고하지 않고 안에서 재전송해서 없앤다. 응용 프로그램에게 "패킷 손실"이라는 에러는 존재하지 않는다.

✅ 예외를 줄이는 4가지 수단
  • 정의로 없애기 — 위의 사례들. 가장 강력하다.
  • 마스킹(masking) — 낮은 계층에서 처리하고 위로 올리지 않는다 (TCP 재전송).
  • 모으기(aggregation) — 여러 예외를 한 곳에서 한꺼번에 처리한다. 웹 서버가 요청 단위로 최상위에서 잡아 500을 돌려주는 식.
  • 크래시 — 복구가 정말 불가능한 종류(메모리 부족 등)는 그냥 죽인다. 복구 코드를 쓰고 테스트도 못 하느니 낫다.
⚠️ 오해 주의

"예외를 무시하라"가 아니다. 에러 조건이 발생할 수 있는 API를, 발생할 수 없는 API로 다시 설계하라는 것이다. 잘라 주는 substring은 에러를 삼킨 게 아니라 "모든 입력에 대해 정의된 함수"가 된 것이다.

8. Ousterhout ④ — 전술 vs 전략, 그리고 레드 플래그 14개

이 책의 실무적 심장

전술적 프로그래밍 (tactical)
목표는 "동작하게 만드는 것". 매번 조금씩 지저분해지고, 각 단계는 다 합리적으로 보인다. 결과는 몇 년 뒤 손댈 수 없는 코드베이스.
전략적 프로그래밍 (strategic)
코드가 동작하는 건 최소 조건. 목표는 좋은 설계라는 자산을 만드는 것. 매번 조금씩 투자한다.

처방이 구체적이다. 개발 시간의 10~20%를 설계에 더 쓴다. 저자의 설명은 이렇다 — 초기 프로젝트는 순수 전술적 접근보다 10~20% 더 걸린다. 그런데 몇 달 안에 이득이 시작되고, 얼마 지나지 않아 전술적으로 짰을 때보다 최소 10~20% 빠르게 개발하게 된다.

🚨 전술적 토네이도 (tactical tornado)

Ousterhout가 만든 말이다. 전술적 프로그래밍의 극단에 있는 사람으로, 실제로 남들보다 훨씬 빨리 코드를 뽑아낸다 — 급한 기능 하나를 붙이는 데 이 사람보다 빠른 사람은 없다. 그래서 어떤 조직에서는 영웅 대접을 받는다.

문제는 뒤에 남는 것이다. 원문의 표현은 냉정하다 — "tactical tornadoes leave behind a wake of destruction." 그 잔해를 치우는 건 나중에 그 코드로 일해야 하는 엔지니어들이고, 그러다 보니 정작 진짜 영웅인 그 사람들이 더 느려 보이게 된다. 속도 차이의 정체는 실력이 아니라 청구서를 누가 받느냐다.

주석은 문서가 아니라 설계 도구다

흔한 오해는 "주석은 나중에 남을 사람을 위한 것"이다. Ousterhout의 주장은 다르다. 구현하기 전에 인터페이스 주석부터 쓰라. 이유는 테스트를 먼저 쓰는 것과 같다 — 짧고 명료하게 안 써지면, 그건 설계가 나쁘다는 신호다.

미루는 이유도 정확히 짚는다. 다들 "코드가 아직 바뀌니까 안정되면 쓰겠다"고 말하지만, 진짜 이유는 주석을 잡일로 보기 때문이라는 것. 그리고 결과는 하나다 — 미루기 시작하면 조금 더 미루기 쉬워지고, 대개 끝내 안 쓰게 된다.

❌ 코드를 그대로 반복하는 주석 — 레드 플래그
// 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

레드 플래그 14개 — 실무 체크리스트

원서 부록의 목록이다. 코드 리뷰 때 그대로 써도 된다.

레드 플래그무엇을 뜻하는가
얕은 모듈
Shallow Module
제공하는 기능에 비해 인터페이스가 복잡하다
정보 유출
Information Leakage
같은 지식이 여러 곳에서 쓰인다 (예: 두 클래스가 같은 파일 포맷을 안다)
시간적 분해
Temporal Decomposition
실행 순서가 코드 구조에 반영되어 있다
과다 노출
Overexposure
흔히 쓰는 기능을 쓰려는데 거의 안 쓰는 기능까지 배우게 만든다
pass-through 메서드인자를 그대로 다른 메서드에 넘기기만 한다 — 대개 API도 같다
반복
Repetition
같거나 거의 같은 코드가 계속 나온다 → 추상화를 못 찾은 것
특수-범용 혼재
Special-General Mixture
범용 메커니즘 안에 특정 용도 전용 코드가 섞여 있다
결합된 메서드
Conjoined Methods
한 메서드를 이해하려면 다른 메서드 구현까지 봐야 한다
코드를 반복하는 주석주석의 내용이 바로 옆 코드에서 이미 명백하다
구현 문서가 인터페이스를 오염인터페이스 문서가 쓰는 데 필요 없는 구현 세부를 설명한다
모호한 이름
Vague Name
여러 가지를 가리킬 만큼 넓어서 정보를 주지 않는다
이름 짓기 어려움
Hard to Pick Name
명확한 이미지를 주는 간단한 이름이 안 떠오른다 → 설계가 깨끗하지 않다는 힌트
설명하기 어려움
Hard to Describe
간단하고 완전한 주석을 쓰기 어렵다 → 설계에 문제가 있다는 지표
비명백한 코드
Nonobvious Code
빠르게 읽어서는 의미와 동작을 알 수 없다
🔑 마지막 두 개가 특히 강력하다

이름이 안 지어지거나 한 문장으로 설명이 안 되면, 그건 글솜씨 문제가 아니라 설계 문제다. 이름과 설명은 설계의 리트머스 시험지다 — 공짜로 쓸 수 있고 거의 틀리지 않는다.

9. Tar Pit ① — 본질과 우발을 다시 긋다 🔥 핵심 20%

Out of the Tar Pit은 66쪽짜리 논문이지만, 8장 이후는 관계 모델 각론이라 앞 절반(1~7장, 약 35쪽)만 읽어도 값을 한다. 출발점은 Brooks의 No Silver Bullet이다.

Brooks는 복잡도를 본질(essence)우발(accident)로 나눴다. 그리고 "소프트웨어의 복잡도는 본질적 속성이지 우발적 속성이 아니다"라고 결론지었다. 그래서 은탄환은 없다는 것이다.

Moseley와 Marks는 정확히 그 문장을 인용하고 "We disagree"라고 쓴다. 그리고 정의를 다시 긋는다.

🔑 원문의 정의

Essential Complexity — 문제 자체에 내재한 복잡도. 사용자가 보는 대로의 문제(as seen by the users)에 있는 것.

Accidental Complexity — 나머지 전부. 이상적인 세계였다면 개발팀이 상대하지 않았을 복잡도 (예: 성능 문제에서 오는 것, 최적이 아닌 언어와 인프라에서 오는 것).

이 정의가 왜 칼날인가

"사용자가 보는 문제"라는 단서 하나가 대부분을 우발로 밀어낸다. 논문의 예시가 노골적이다.

⚠️ 본질이 아닌 것들 (원문 예시)

비트, 바이트, 트랜지스터, 전기, 그리고 컴퓨터 자체가 본질적이지 않다. 사용자의 문제와 아무 상관이 없기 때문이다. 그리고 결정적인 판별 규칙 하나 — "사용자가 그게 뭔지조차 모른다면(예: 스레드 풀, 루프 카운터) 그것은 본질일 수 없다."

이 잣대를 오늘의 코드에 대 보자.

우리 코드에 있는 것분류이유
"대출 한도는 연소득의 3배를 넘을 수 없다"본질사용자가 아는, 사용자의 규칙
Redis 캐시 레이어우발성능 때문. 사용자는 캐시를 요구한 적 없다
DB 인덱스, 커넥션 풀우발같은 이유
ORM, DTO ↔ 엔티티 매핑우발인프라가 만든 것
스레드, 락, 비동기 큐우발사용자는 이게 뭔지 모른다
"주문은 결제 후에만 배송으로 넘어간다"본질사용자의 업무 규칙
🧠 반론에 대한 논문의 답 원문

"그래도 현실에서는 캐시가 필요하지 않나?" 논문도 인정한다. "현실의 모든 개발은 어느 정도의 우발적 복잡도를 상대해야 한다. 이 정의는 그걸 부정하려는 게 아니라, 그것의 이차적 성격을 드러내려는 것이다." 즉 필요 없다는 게 아니라, 본질과 섞이지 않게 관리하라는 것이다. 이게 뒤에 나올 FRP의 전부다.

왜 복잡도가 유일한 적인가 — 테스트 vs 비공식적 추론

논문은 우리가 코드가 맞는지 확인하는 방법이 둘뿐이라고 정리한다.

테스트 — 바깥에서 보기
블랙박스로 관찰한다. 근본 한계: "어떤 입력 하나로 한 테스트는, 다른 입력에 대해서는 아무것도 말해 주지 않는다."
비공식적 추론 — 안에서 따져보기
머릿속으로 코드를 읽고 시뮬레이션한다. "둘 중 비공식적 추론이 압도적으로 더 중요하다."

왜 추론이 더 중요한가? 논문의 논거가 명쾌하다. 추론이 나아지면 버그가 애초에 덜 생기고, 테스트가 나아지면 버그가 더 많이 발견될 뿐이다. Dijkstra의 문장을 인용한다 — "정말 믿을 만한 소프트웨어를 원하는 사람은, 애초에 대부분의 버그를 피하는 방법을 찾아야 한다는 걸 알게 될 것이다."

🚨 그래서 나오는 결론

"모든 접근법에 한계가 있기 때문에 단순함이 결정적이다. 테스트나 추론과 나란히 놓고 보면 단순함이 둘 중 어느 것보다 중요하다. 테스트에 투자할지 단순함에 투자할지를 냉정하게 골라야 한다면, 후자가 나은 선택인 경우가 많다."

10. Tar Pit ② — 상태·제어·코드량 🔥 핵심 20%

상태 — 가장 큰 범인

논문은 이 절을 아주 일상적인 장면으로 연다.

📝 원문의 도입

소프트웨어 고객센터에 전화해서 "다시 해 보세요", "문서를 다시 여세요", "프로그램을 재시작하세요", "컴퓨터를 재부팅하세요", "재설치하세요"를 들어 본 사람은 이미 상태가 무엇을 망치는지 직접 겪은 것이다. 이 말들이 익숙한 이유는 실제로 자주 문제를 해결하기 때문이고, 자주 해결되는 이유는 많은 시스템이 상태 처리에 오류를 갖고 있기 때문이다.

"껐다 켜보세요"가 통하는 이유를 이렇게 설명한 글은 드물다. 그건 상태를 날리는 행위다. 논문은 상태가 어떻게 무너뜨리는지 두 갈래로 나눈다.

테스트에 미치는 영향
"어떤 상태에서 한 테스트는, 다른 상태에 대해 아무것도 말해 주지 않는다." 그래서 우리는 "깨끗한 초기 상태"에서 테스트하고 매번 같을 것이라 가정한다 — 문제를 카펫 밑으로 쓸어 넣는 것이다.
추론에 미치는 영향
머릿속 추론은 "이 변수가 이 상태면 이렇게 되고..."라는 경우별 시뮬레이션이다. 상태 수가 늘면 이 방식은 테스트만큼이나 빠르게 무너진다.

두 가지 구체적 메커니즘

① 지수적 폭발. 논문의 문장은 짧다. "상태를 1비트 추가할 때마다 가능한 전체 상태 수는 두 배가 된다." 불리언 30개면 10억 가지가 넘고, 우리는 그중 몇 개만 테스트한다.

② 오염(contamination). 이게 더 무섭다.

기대: 상태 없는 함수는 이해하기 쉬울 것이다 순수 함수 A 순수 함수 B 순수 함수 C 상태 있음 현실: 오염은 호출 사슬을 거꾸로 타고 올라온다 A — 오염됨 B — 오염됨 C — 오염됨 상태 있음 A 자신은 상태가 없지만, 간접적으로라도 상태를 만지면 상태의 맥락에서만 이해된다 "낙타가 천막 안으로 코를 들이밀면, 나머지도 따라 들어온다" — 논문의 인용
상태의 진짜 위험은 상태 자체가 아니라 전염성이다. 순수한 코드도 상태 있는 코드를 부르는 순간 순수함의 이득을 잃는다.

제어 — 우리가 시키지도 않은 순서

제어(control)는 일이 일어나는 순서다. 논문의 지적은 이렇다 — 우리는 대개 순서에 관심이 없는데도 언어가 순서를 말하도록 강요한다.

논문의 예시 — 세 줄 사이에 순서가 필요한가?
a := b + 3
c := d + 2
e := f * 4

프로그래머는 값들 사이의 관계만 말하고 싶었다. 그런데 임의의 순서를 하나 골라야 했다. 그리고 컴파일러는 그 순서가 사실 무시해도 된다는 것을 증명하려고 애를 쓴다. 논문의 정리가 날카롭다.

🚨 두 가지 불필요한 일

"먼저 인위적인 순서가 부과되고, 그다음 그것을 제거하는 작업이 추가로 이루어진다." 그리고 코드를 읽는 사람도 컴파일러가 한 일을 똑같이 반복해야 한다 — 일단 순서가 중요하다고 가정한 뒤, 살펴보고 아니라고 판단해야 한다. 이 판단에서 나오는 실수가 아주 미묘하고 찾기 어려운 버그가 된다.

동시성은 여기에 최악의 한 방을 더한다. "동시성이 있는 상태에서 같은 초기 상태와 같은 입력으로 테스트를 돌렸다는 사실은, 다음번에 같은 테스트를 같은 입력과 같은 초기 상태로 돌렸을 때 무슨 일이 일어날지에 대해 아무것도 말해 주지 않는다." 그리고 한 줄을 덧붙인다 — "상황이 이보다 더 나빠질 수는 없다."

코드량 — 이차적이지만 무시 못 할

코드가 많으면 복잡하다. 다만 논문은 이것을 대체로 이차적 효과로 본다 — 많은 코드가 결국 상태를 관리하거나 제어를 명시하는 코드이기 때문이다. 그래도 따로 다루는 이유는 두 가지다. 측정하기 가장 쉽고, 다른 원인들과 나쁘게 상호작용하기 때문이다.

그 밖의 원인들 — 3가지 원리로 수렴한다

✅ 중복 코드·죽은 코드·잘못된 추상화가 나오는 이유
  • 복잡도가 복잡도를 낳는다. 이미 있는 기능인지 알 수 없거나, 그게 정확히 뭘 하는지 이해하기 너무 복잡하면 중복해서 만든다. 시간 압박이 있으면 더 그렇다.
  • 단순함은 어렵다. 첫 번째 해법은 대개 가장 단순한 해법이 아니다. 단순함은 인식되고, 추구되고, 소중히 여겨질 때만 얻어진다.
  • 권력은 부패한다. 언어가 강제로 막아 주지 않으면 실수와 남용은 반드시 일어난다. GC가 좋은 이유는 수동 메모리 관리라는 "권력"을 빼앗기 때문이고, 상태에 대해서도 똑같은 원리가 적용된다.

11. Tar Pit ③ — 패러다임 채점표와 FRP

기존 패러다임을 상태·제어 기준으로 채점하면

패러다임상태제어논문의 평가
객체지향나쁨나쁨 "모든 형태의 OOP는 (객체 안에 담긴) 상태에 의존하고, 일반적으로 모든 동작이 그 상태의 영향을 받는다." 캡슐화는 좋은 발전이지만 상태를 없애는 게 아니라 숨길 뿐이다. 게다가 여러 객체에 걸친 무결성 제약을 어디에 둘지가 불분명해지고, 객체 정체성(identity)이라는 두 번째 동일성 개념까지 추가로 다뤄야 한다.
함수형좋음보통 참조 투명성 덕에 "테스트의 두 가지 결정적 약점 중 하나를 소멸시킨다". 결과에 영향을 줄 수 있는 모든 것이 인자에 항상 드러나 있다. 다만 순수하지 않은 언어(ML, Scheme)는 보장이 없다.
논리형좋음이론상 최고 순수 논리 프로그래밍은 "제어에서 오는 복잡도를 벗어날 가능성"을 제시한다. 다만 실제 Prolog는 두 가지로 읽힌다 — 논리적 공리 집합으로도, 순서가 있는 명령 나열로도. 하나로는 맞고 다른 하나로는 (비종료 등으로) 틀릴 수 있다. cut 같은 기능은 이 문제를 키운다.
⚠️ OO 비판을 오해하지 말 것

논문은 "캡슐화가 나쁘다"고 하지 않는다. 캡슐화는 덜 구조화된 명령형 스타일에 비하면 주요 강점이라고 명시한다. 문제는 캡슐화가 상태의 양을 줄이지 않는다는 것이다. 객체 100개가 각자 상태를 잘 숨겨도, 시스템 전체의 가능한 상태 수는 여전히 곱해진다.

처방 두 단어 — Avoid, Separate

🔑 논문의 권고 전체

"Our recommendations for dealing with complexity ... can be summed up as: Avoid · Separate"

우선 목표는 진짜로 반드시 필요한 게 아닌 상태와 제어를 피하는 것이다. 그래도 남는 것(진짜 본질이거나, 본질은 아니지만 실용적으로 유용한 것)은 나머지로부터 분리해 낸다.

그리고 덧붙인다 — "특별히 심오한 권고는 아니다. 하지만 지금 대부분의 소프트웨어가 이렇게 개발되고 있지 않기 때문에 말할 가치가 있다."

뿌리도 밝힌다. Prolog 공동 개발자 Kowalski의 1979년 논문 제목이 곧 그 아이디어다 — "Algorithm = Logic + Control". 무엇을 원하는지(로직)와 어떻게 찾을지(제어)를 분리하라는 것.

FRP — Functional Relational Programming

🚨 이름 주의

Functional Reactive Programming(RxSwift·Combine 계열)과 완전히 다르다. 여기서 R은 Relational(관계형)이다. 검색할 때 반드시 "Out of the Tar Pit"을 같이 넣어야 한다.

논문이 제안하는 아키텍처는 시스템을 네 덩어리로 강제 분리한다. 이름은 원문 그대로다.

Feeders 입력 → 관계 대입 ① 본질적 상태 관계(relvar)로만 저장 사용자가 직접 입력한 것 파생 가능한 건 여기 없다 ② 본질적 로직 파생 관계 + 무결성 제약 + 순수 함수 전부 선언적 · 상태 없음 Observers 변화 관찰 → 출력 ③ 우발적 상태와 제어 — "성능" 인덱스 · 캐시 · 저장 방식 · 즉시/지연 계산 · 병렬화 서로를 참조할 수 없는 선언적 "힌트"일 뿐 — 결과를 바꿀 수 없다 위쪽 두 칸이 정답을 정하고, 아래 칸은 속도만 정한다. 이 분리가 FRP의 전부다.
DB 인덱스와 같은 원리다 — 인덱스를 추가해도 쿼리 결과는 바뀌지 않고 속도만 바뀐다. FRP는 그 성질을 시스템 전체로 확장한다.
🧠 "본질적 상태"의 정의가 생각보다 훨씬 좁다 원문

흔한 요약은 "입력 데이터가 본질적 상태"라고 쓴다. 원문은 더 엄격하다.

  • 입력 데이터 중에서도 "앞으로 다시 참조할 가능성이 있는 것"만 본질적 상태다. 부수효과만 일으키고 끝나는 입력은 저장할 필요조차 없다.
  • 파생 데이터는 불변이든 가변이든 전부 우발적 상태다. 필요할 때 다시 계산하면 되기 때문이다.
  • 그래서 "성능을 위한 비정규화(denormalization)"라는 개념은 "이 맥락에서 전혀 말이 되지 않는다"고 원문이 직접 못 박는다 — 물리 저장이 논리 구조를 그대로 따라간다는 가정이 깔려 있기 때문이다.

오늘의 코드로 번역하면

FRP 전체를 그대로 도입할 일은 거의 없다. 하지만 세 가지는 그대로 쓸 수 있다.

❌ 파생 가능한 값을 상태로 들고 있다 — 동기화 버그의 공장
final class Cart {
    private(set) var items: [Item] = []
    private(set) var total: Int = 0        // ← 파생 가능한데 저장했다
    private(set) var isEmpty: Bool = true  // ← 이것도
    private(set) var discount: Int = 0     // ← 이것도

    func add(_ item: Item) {
        items.append(item)
        total += item.price                // 세 줄을 매번 맞춰 줘야 한다
        isEmpty = false
        discount = computeDiscount()
    }

    func remove(at i: Int) {
        items.remove(at: i)
        total -= items[i].price            // 🐞 이미 지운 뒤에 읽는다
        // 🐞 isEmpty·discount 갱신을 잊었다
    }
}
✅ 본질적 상태는 items 하나. 나머지는 전부 파생
struct Cart {
    private(set) var items: [Item] = []    // ① 본질적 상태 — 사용자가 넣은 것

    // ② 본질적 로직 — 저장하지 않고 그때그때 만든다
    var total: Int    { items.reduce(0) { $0 + $1.price } }
    var isEmpty: Bool { items.isEmpty }
    var discount: Int { computeDiscount(items) }

    mutating func add(_ item: Item)    { items.append(item) }
    mutating func remove(at i: Int)    { items.remove(at: i) }
    // 동기화할 게 없으므로 동기화 버그도 없다
}
✅ 성능이 필요해지면 — "결과를 바꿀 수 없는 자리"에만 넣는다
struct Cart {
    private(set) var items: [Item] = []

    // 정답을 만드는 순수 함수 — 캐시를 모른다
    var total: Int { Self.computeTotal(items) }
    static func computeTotal(_ items: [Item]) -> Int { items.reduce(0) { $0 + $1.price } }
}

// ③ 우발적 상태 — 완전히 분리된 계층. 지워도 결과는 같고 속도만 느려진다.
final class TotalCache {
    private var memo: [Cart.ID: Int] = [:]

    func total(of cart: Cart) -> Int {
        if let hit = memo[cart.id] { return hit }
        let v = Cart.computeTotal(cart.items)   // ← 항상 같은 정답 함수를 부른다
        memo[cart.id] = v
        return v
    }
}
✅ FRP에서 실제로 가져올 세 가지
  • 파생 가능한 값은 저장하지 않는다. 저장하는 순간 "두 값을 맞추는 일"이 생기고, 그게 버그가 된다.
  • 상태를 시스템 가장자리로 밀고, 가운데는 순수 함수로 채운다. Redux·Elm 아키텍처·functional core / imperative shell이 전부 이 논문의 자식이다.
  • 캐시·인덱스·비동기는 "정답을 만드는 층"과 분리한다. 성능 코드가 정답을 바꿀 수 있으면 그 시스템은 추론이 불가능해진다.

12. 한 장으로 묶기 — 오늘 코드에 적용하는 규칙

Parnas 1972 무엇을 나눌 것인가 바뀔 결정을 감춰라 Ousterhout 2018 어떻게 유지할 것인가 깊게 만들고 매일 투자하라 Tar Pit 2006 진짜 적은 무엇인가 상태와 제어를 없애라 바뀔 결정을 좁은 인터페이스 뒤에 감춘다. 그중 가장 먼저 감출 것은 상태다. 46년 동안 세 번 다르게 쓰인 같은 문장
세 권이 서로를 보완하는 방식. 하나만 읽으면 반쪽이다 — 기준만 알고 척도가 없거나, 척도는 있는데 표적을 모른다.

내일 코드 리뷰에서 바로 쓸 수 있는 질문 8개

출처질문걸리면 무엇을 의심하는가
Parnas이 자료구조의 포맷을 아는 곳이 몇 군데인가?2곳 이상이면 정보 유출. 접근 함수로 감싼다
Parnas클래스 이름이 처리 단계(Reader/Writer/Parser/Sender)로 되어 있는가?시간적 분해 의심. 같은 지식이 갈라져 있을 확률이 높다
Parnas인터페이스가 필요 이상으로 약속하고 있지 않은가?순서·정렬 보장을 무심코 넣으면 구현 선택지가 사라진다
Ousterhout이 API를 쓰려고 배워야 하는 양이, 직접 짜는 양과 비슷한가?얕은 모듈. 존재 이유가 없다
Ousterhout이 에러 케이스를 API 설계로 없앨 수 있는가?없앨 수 있으면 호출부의 if가 전부 사라진다
Ousterhout이 함수/변수에 이름을 붙이기 어려운가? 한 문장으로 설명이 되는가?안 되면 글솜씨가 아니라 설계 문제
Tar Pit이 프로퍼티는 다른 값으로부터 계산할 수 있는가?가능하면 저장하지 말 것. 동기화 버그의 씨앗
Tar Pit이 캐시/인덱스를 지우면 결과가 달라지는가?달라지면 우발적 상태가 본질에 섞인 것. 분리 대상

읽는 순서 추천

시간이 30분뿐이라면
Parnas 1972만 읽는다. CACM 지면으로 6쪽이라 실제로 완독된다. 위 2·3·4장이 그 지도다.
한 권만 산다면
A Philosophy of Software Design. 가장 실용적이고, 오늘 당장 코드에 적용할 게 가장 많다.
설계 토론에서 이기고 싶다면
Out of the Tar Pit 1~7장(약 35쪽). "상태를 줄이자"를 논증할 언어를 준다.
셋 다 읽는다면
Parnas → Tar Pit → Ousterhout 순서. 기준 → 표적 → 실천의 흐름이다.
🔑 마지막으로 한 문장만 가져간다면

Parnas의 결론 문장이다. "먼저 어려운 설계 결정, 또는 바뀔 가능성이 높은 결정의 목록을 만들어라. 그리고 각 모듈이 그런 결정 하나씩을 다른 모듈로부터 감추도록 설계하라." 나머지 두 권은 이 문장의 각주라고 봐도 크게 틀리지 않는다.

출처 · 참고자료