← Study Hub

Parnas 1972 정독 — 모듈을 나누는 기준

6쪽짜리 논문 하나가 이후 모든 모듈 이론의 조상이 됐다. KWIC 실험을 처음부터 끝까지 따라가며 정보 은닉이 실제로 무엇을 하는지 본다. 거의 인용되지 않는 절까지 포함해 원문 전체를 대조했다.
진행률
0 / 0 완료
6쪽짜리 논문 하나가 이후 나온 거의 모든 모듈 이론의 조상이 됐다. 그런데 이 논문이 실제로 하는 일은 추상적인 원칙을 선포하는 것이 아니다. 아주 작은 프로그램 하나를 두 가지로 설계해 놓고, 현실에서 일어날 법한 변경 5개를 대입해 어느 쪽이 무너지는지 실험한다. 이 강의는 그 실험을 처음부터 끝까지 따라간다. 인용부호 안의 문장은 전부 원문이다.
📝 이 강의의 위치

세 원전을 한 장으로 잇는 설계 정전 3권 정독의 심화편이다. 종합본이 이 논문을 3개 절로 압축했다면, 여기서는 거의 인용되지 않는 절까지 포함해 논문 전체를 따라간다.

1. 1972년, 무엇이 문제였나

당시에도 "모듈러 프로그래밍"은 이미 상식이었다. Parnas는 1970년 교과서(Gauthier & Pont)를 길게 인용하면서 시작한다. 그 인용문의 요지는 이렇다 — 잘 정의된 작업 분할이 시스템 모듈성을 보장한다. 각 작업이 별개의 모듈이 되고, 인터페이스가 명확하니 혼동이 없고, 테스트도 따로 하고, 유지보수도 모듈 단위로 한다.

다 맞는 말이다. 그런데 Parnas는 바로 다음 문단에서 급소를 찌른다.

🔑 논문의 출발점

"Usually nothing is said about the criteria to be used in dividing the system into modules."

— 보통 시스템을 모듈로 어떤 기준으로 나눌지에 대해서는 아무 말이 없다.

모듈러 프로그래밍이 약속한 세 가지

먼저 "무엇을 위해 나누는가"를 못 박는다. 이게 뒤에서 채점표가 된다.

① 관리적 이득 (managerial)
각 모듈을 별개 그룹이 맡되 서로 소통할 필요가 거의 없어서 개발 기간이 짧아진다.
② 제품 유연성 (product flexibility)
한 모듈을 과감하게 바꿔도 다른 모듈을 건드릴 필요가 없어야 한다.
③ 이해 가능성 (comprehensibility)
시스템을 한 번에 한 모듈씩 공부할 수 있어야 한다.
⚠️ 여기서 이미 함정이 보인다

세 가지 모두 "모듈로 나눴다"는 사실만으로는 얻어지지 않는다. 나눠 놓고도 서로 소통해야 하고, 하나를 바꾸면 다 바뀌고, 하나만 봐서는 이해가 안 될 수 있다. 논문의 나머지 전부가 이 격차에 관한 것이다.

모듈의 정의부터 다시

본문에 들어가기 전 Parnas는 용어를 재정의한다. 이 한 문장이 논문 전체의 열쇠다.

🔑 원문

"In this context 'module' is considered to be a responsibility assignment rather than a subprogram."

— 여기서 '모듈'은 서브프로그램이 아니라 책임의 배정으로 본다.

모듈은 코드 덩어리가 아니다. "이 결정은 네가 책임진다"는 배정이다. 이 정의를 받아들이면 9장의 성능 논의가 자연스럽게 따라온다 — 책임의 경계와 실행 파일의 경계가 같을 이유가 없기 때문이다.

2. 실험 설계 — 왜 하필 KWIC인가

실험 대상은 KWIC 색인 시스템이다. KWIC은 Key Word In Context, 즉 문맥 속의 키워드라는 뜻이다. 하는 일은 세 줄로 끝난다.

입력 한 줄 the quick brown fox 맨 앞 단어를 떼어 뒤에 붙이기 ×4 the quick brown fox quick brown fox the brown fox the quick fox the quick brown 정렬 여러 줄의 모든 시프트를 알파벳순으로 brown fox the quick fox the quick brown lazy dog jumps over the quick brown fox the the quick brown fox 결과: 어떤 단어로 찾아도 그 단어가 맨 앞에 온 줄이 있다 색인 카드 시대의 전문 검색(full-text search)이다
Ctrl+F가 없던 시절, 종이 색인에서 아무 단어로나 문헌을 찾게 해 주던 방식이다. 지금 보면 역색인(inverted index)의 조상 격.

"좋은 프로그래머면 1~2주" — 그런데도 크게 다루는 이유

Parnas는 이 시스템의 크기를 솔직하게 인정한다. "극단적인 상황(거대한 데이터베이스, 지원 소프트웨어 없음)이 아니라면 좋은 프로그래머가 1~2주면 만들 수 있다." 그래서 "모듈러 프로그래밍을 하게 만드는 어려움들이 이 시스템에서는 하나도 중요하지 않다"고까지 쓴다.

🧠 방법론적으로 정직한 선택 원문

"큰 시스템을 지면에서 철저히 다루는 것은 불가능하므로, 이 문제를 마치 큰 프로젝트인 것처럼 다루는 연습을 거쳐야 한다." 즉 실험 조건을 축소한 것이지 문제를 쉽게 고른 게 아니다. 그리고 두 번째 분해는 실제로 학부 수업 프로젝트에서 성공적으로 사용된 것이라고 밝힌다.

3. 분해 1 — 순서도대로 자른다 🔥 핵심 20%

Parnas가 먼저 제시하는 건 대다수가 실제로 하는 방식이다. 그는 이렇게 못 박는다 — "소규모 실험 결과, 대다수 프로그래머가 이 작업에 대해 제안할 분해가 대략 이것이다."

모듈 5개 (원문 그대로)

모듈논문이 규정한 책임
1. Input입력 매체에서 줄을 읽어 메모리에 저장한다. 문자는 한 워드에 4개씩 packing하고, 안 쓰는 문자 하나로 단어 끝을 표시한다. 각 줄의 시작 주소를 담은 인덱스를 유지한다.
2. Circular ShiftInput이 끝난 뒤 호출된다. 각 순환 시프트의 첫 문자 주소원본 줄 번호를 담은 인덱스를 만든다. 출력은 메모리에 (원본 줄 번호, 시작 주소) 쌍으로 남긴다.
3. Alphabetizing모듈 1과 2가 만든 배열을 입력으로 받아, 같은 포맷의 배열을 알파벳순으로 다시 만든다.
4. Output모듈 3과 1의 배열로 보기 좋게 서식을 갖춘 목록을 출력한다.
5. Master Control나머지 넷의 순서를 제어한다. 에러 메시지와 공간 할당도 다룰 수 있다.
분해 1을 Swift로 — 네 모듈이 같은 배열을 직접 만진다
// ⚠️ 이 두 개가 "시스템 레벨 결정"이고, 네 모듈이 전부 안다.
var core: [UInt32] = []      // 문자를 한 워드에 4개씩 packing
var lineIndex: [Int] = []    // 각 줄의 시작 주소
var shiftIndex: [(line: Int, start: Int)] = []

enum Input {                                   // 모듈 1
    static func read(_ path: String) {
        for (i, ch) in bytes(of: path).enumerated() {
            let w = i / 4, slot = i % 4        // ← packing 규칙 ①
            core[w] |= UInt32(ch) << (24 - 8 * slot)
        }
    }
}

enum CircularShift {                           // 모듈 2
    static func setup() {
        for line in lineIndex.indices {
            var p = lineIndex[line]
            while p < end(of: line) {
                let byte = (core[p / 4] >> (24 - 8 * (p % 4))) & 0xFF   // ← packing 규칙 ②
                if byte == wordEndMarker { shiftIndex.append((line, p + 1)) }
                p += 1
            }
        }
    }
}

enum Alphabetizing {                           // 모듈 3
    static func run() {
        shiftIndex.sort { a, b in
            compareChars(at: a.start, and: b.start)   // ← packing 규칙 ③ (비교하려면 문자를 꺼내야 한다)
        }
    }
}

enum Output {                                  // 모듈 4
    static func print() {
        for s in shiftIndex {
            let byte = (core[s.start / 4] >> (24 - 8 * (s.start % 4))) & 0xFF  // ← packing 규칙 ④
            _ = byte
        }
    }
}
⚠️ 이게 나쁜 코드인가?

아니다. Parnas 본인이 "이것은 모듈러 프로그래밍 지지자들이 말하는 의미의 모듈화가 맞다"고 인정한다. 인터페이스도 명확하고, 각 모듈은 "충분히 이해되고 잘 프로그래밍될 만큼 작고 단순하다." 문제는 다른 곳에 있다 — "packing 규칙"이라고 표시한 주석이 네 개라는 점이다.

정의 문서에 무엇이 들어가야 하는가

Parnas가 슬쩍 던지는 한 문단이 중요하다. 이 분해로 실제 작업을 시작하려면 무엇이 더 필요한가?

🚨 원문

"The defining documents would include a number of pictures showing core formats, pointer conventions, calling conventions, etc. All of the interfaces between the four modules must be specified before work could begin."

— 정의 문서에는 메모리 포맷·포인터 규약·호출 규약을 보여 주는 그림이 여러 장 들어가야 한다. 네 모듈 사이의 모든 인터페이스가 작업 시작 전에 명세되어야 한다.

이게 5장 "독립 개발" 항목의 복선이다. 여러 팀이 시작하기 전에 자료구조를 합의해야 한다면, 그건 이미 독립적이지 않다.

4. 분해 2 — 감출 것을 기준으로 자른다 🔥 핵심 20%

두 번째 분해는 모듈이 6개다. 하나 늘어난 것이 Line Storage인데, 이 모듈이 전부라고 해도 과언이 아니다.

모듈 1: Line Storage — 함수 이름은 논문에 나온 그대로
enum LineStorage {
    // 🔒 비밀: 줄을 어떻게 담아 두는지. 바깥에서 볼 수 없다.
    private static var core: [UInt32] = []
    private static var lineIndex: [Int] = []

    /// r번째 줄, w번째 단어, c번째 문자
    static func char(_ r: Int, _ w: Int, _ c: Int) -> UInt8 { ... }        // CHAR(r,w,c)
    static func setChar(_ r: Int, _ w: Int, _ c: Int, _ d: UInt8) { ... }  // SETCHAR(r,w,c,d)
    static func words(_ r: Int) -> Int { ... }                             // WORDS(r)

    // 논문이 명시한 추가 함수들
    static func lines() -> Int { ... }              // 현재 저장된 줄 수
    static func chars(_ r: Int, _ w: Int) -> Int { ... }  // 단어의 문자 수
    static func delLine(_ r: Int) { ... }           // DELINE
    static func delWord(_ r: Int, _ w: Int) { ... } // DELWRD
}
📝 호출 규칙 위반은 어떻게 다루나

"There are certain restrictions in the way that these routines may be called; if these restrictions are violated the routines 'trap' to an error-handling subroutine which is to be provided by the users of the routine." — 규칙을 어기면 사용자 쪽이 제공한 에러 처리 서브루틴으로 트랩한다. 에러 정책 자체를 모듈 밖으로 뺀 설계다.

모듈 3·4 — 각자 자기 비밀을 하나씩 감춘다
// 모듈 3: Circular Shifter
// 🔒 비밀: 시프트를 미리 다 만들어 두는가, 요청받을 때 계산하는가
enum CircularShifter {
    static func setup()                                            // CSSETUP
    static func char(_ l: Int, _ w: Int, _ c: Int) -> UInt8        // CSCHAR(l,w,c)
    // "모든 줄이 아니라 모든 순환 시프트를 담은 줄 보관소"라는 인상을 만든다
}

// 모듈 4: Alphabetizer
// 🔒 비밀: 한 번에 다 정렬하는가, 필요할 때마다 조금씩 하는가
enum Alphabetizer {
    static func alph()                       // ALPH — 다른 함수보다 먼저 호출되어야 한다
    static func ith(_ i: Int) -> Int         // ITH(i) — 알파벳순 i번째 시프트의 인덱스
}

// 모듈 2: Input · 모듈 5: Output · 모듈 6: Master Control
분해 1 — 처리 순서대로 분해 2 — 감출 것 기준으로 Input Circular Shift Alphabetizing Output 공유 배열 core[] · lineIndex[] "4개 packing" 결정을 넷이 전부 안다 저장 방식이 바뀌면 네 모듈을 전부 고쳐야 한다 작업 시작 전 인터페이스 합의도 필요하다 Input Circular Shifter Alphabetizer Output Line Storage — char() / setChar() / words() 함수 이름과 인자 타입만 노출한다 🔒 비밀: core[] 저장 포맷 이 상자 밖으로 새지 않는다 바뀌면 이 모듈 하나만 고친다 두 시스템은 어셈블 후 동일할 수도 있다 다른 것은 일감을 나눈 방식과 인터페이스뿐이다
화살표가 모이는 지점이 자료구조함수 인터페이스냐. 이 차이 하나가 다음 장의 결과를 전부 만든다.
🧠 숨은 모듈 하나 원문

논문은 지나가듯 이렇게 적는다. "어떤 버전에서는 분해에 모듈이 하나 더 있었다. 심볼 테이블 모듈이 Line Storage 안에서 사용됐다. 이 사실은 시스템의 나머지에게 완전히 보이지 않았다." — 모듈 안에 모듈이 생겨도 바깥은 아무것도 몰랐다. 이것이 정보 은닉이 작동한다는 가장 좋은 증거다.

5. 세 잣대로 채점하기 🔥 핵심 20%

이제 1장에서 세운 세 가지 기대를 그대로 채점표로 쓴다.

잣대 ① 변경 용이성 — 논문의 실험

Parnas는 "의문스럽고 여러 상황에서 바뀔 가능성이 높은 설계 결정" 5개를 나열하고, 두 설계에 하나씩 대입한다.

바뀔 수 있는 결정분해 1분해 2
1. 입력 포맷한 모듈한 모듈
2. 모든 줄을 메모리에 둘 것인가
큰 작업이면 전부 메모리에 두는 게 부적절하거나 불가능할 수 있다
모든 모듈이 바뀐다Line Storage 하나
3. 문자를 한 워드에 4개씩 packing
데이터가 작으면 안 하는 게 빠르다. 다른 포맷으로 pack할 수도 있다
모든 모듈이 바뀐다Line Storage 하나
4. 시프트를 인덱스로 둘 것인가 실제로 저장할 것인가
setup에서 아무것도 안 만들고 전부 요청 시 계산해도 된다
Alphabetizer와 Output도 안다Circular Shifter 하나
5. 정렬을 한 번에 다 할 것인가
필요할 때 찾거나, Hoare의 FIND처럼 부분 정렬할 수도 있다
Output이 "정렬 완료"를 전제한다Alphabetizer 하나
🚨 원문의 느낌표

"For the first decomposition the second change would result in changes in every module! The same is true of the third change."

그리고 이유를 한 문장으로 정리한다 — "In the first decomposition the format of the line storage in core must be used by all of the programs." 분해 1에서는 메모리 저장 포맷을 모든 프로그램이 써야만 한다.

✅ 5번 변경이 특히 흥미로운 이유
  • 분해 1: Output 모듈은 "자기가 시작될 때 인덱스가 이미 완성돼 있다"고 전제한다. 정렬을 나눠 하기로 바꾸면 이 전제가 깨진다.
  • 분해 2: Alphabetizer는 "사용자가 정렬이 실제로 언제 일어났는지 감지할 수 없도록" 설계됐다. 그래서 다른 모듈은 하나도 바꿀 필요가 없다.
  • 즉 감춘 것은 데이터만이 아니다. "언제 일어나는가"라는 시간 정보도 감췄다.

잣대 ② 독립 개발

분해 1분해 2
인터페이스의 정체복잡한 포맷과 테이블 구조함수 이름 + 인자의 개수와 타입
성격"가볍게 결정할 수 없는" 설계 결정. 각 모듈의 효율에 직결되므로 신중히 설계해야 한다"비교적 단순한 결정"
결과포맷 개발이 모듈 개발의 큰 부분이 되고, 여러 개발 그룹의 공동 작업이어야 한다모듈의 독립 개발이 훨씬 일찍 시작된다
📝 오늘로 옮기면

두 팀이 공유 DB 스키마공유 DTO를 먼저 합의해야만 각자 시작할 수 있다면, 그건 분해 1이다. 서비스를 나눴다고 독립적인 게 아니다 — 합의해야 시작할 수 있는 것이 무엇인지가 기준이다.

잣대 ③ 이해 가능성

🚨 원문

"To understand the output module in the first modularization, it will be necessary to understand something of the alphabetizer, the circular shifter, and the input module. ... The system will only be comprehensible as a whole."

— 분해 1에서 Output을 이해하려면 Alphabetizer·Circular Shifter·Input을 어느 정도 알아야 한다. Output이 쓰는 테이블에는 다른 모듈이 동작하는 방식 때문에만 말이 되는 부분이 있고, 다른 모듈의 알고리즘 때문에 생긴 구조상의 제약도 있다. 결국 시스템은 전체로서만 이해된다.

"한 번에 한 모듈씩 공부할 수 있어야 한다"는 세 번째 약속이 분해 1에서는 지켜지지 않는다. 모듈로 나눴는데도.

6. 기준의 정식화 — 정보 은닉

이제 Parnas가 답을 밝힌다. 두 분해에 각각 어떤 기준이 쓰였는가?

분해 1의 기준
"처리의 각 주요 단계를 모듈로 만든다." 원문의 표현이 재미있다 — "첫 번째 분해를 얻으려면 순서도를 그리면 된다."
분해 2의 기준
정보 은닉(information hiding). 모듈은 더 이상 처리 단계에 대응하지 않는다. Line Storage는 시스템의 거의 모든 동작에서 쓰인다.
🔑 정보 은닉의 정의 — 원문 두 문장

"Every module in the second decomposition is characterized by its knowledge of a design decision which it hides from all others. Its interface or definition was chosen to reveal as little as possible about its inner workings."

— 분해 2의 모든 모듈은 자신이 아는, 그리고 남에게 감추는 설계 결정으로 규정된다. 인터페이스는 내부 동작에 대해 가능한 한 적게 드러내도록 선택되었다.

순서도에 대한 평가는 생각보다 공정하다

흔히 "Parnas가 순서도를 부정했다"고 인용되는데, 원문은 더 정확하다.

📝 원문

"The flowchart was a useful abstraction for systems with on the order of 5,000–10,000 instructions, but as we move beyond that it does not appear to be sufficient; something additional is needed."

— 순서도는 명령어 5,000~10,000개 규모의 시스템에는 유용한 추상이었다. 그 너머로 가면 충분하지 않아 보이며, 무언가가 더 필요하다.

그리고 그 관행이 어디서 왔는지도 짚는다 — "이것은 '먼저 대략적인 순서도를 그리고 거기서 상세 구현으로 옮겨 가라'고 가르치는 프로그래머 훈련 전체의 산물이다."

7. 감춰야 할 것 5가지 — 논문의 구체적 처방

추상적 원칙만 주고 끝내지 않는다. "권할 만해 보이는 분해의 구체적 예"가 다섯 개 붙는다. 1972년에 쓰였는데 지금 그대로 쓸 수 있다.

#논문의 처방2026년의 같은 함정
1자료구조와 그 내부 연결, 접근·수정 절차는 한 모듈이다. 관행처럼 여러 모듈이 나눠 갖지 않는다.여러 모듈이 같은 구조체·딕셔너리·테이블을 직접 읽고 쓰는 것
2루틴을 호출하는 명령 시퀀스와 루틴 자체는 한 모듈이다. 완벽한 범용 호출 규약은 없어서 계속 바뀌기 때문이다.호출 규약을 호출자 쪽에 흩뿌리는 것 — 헤더 조립, 인증 토큰 붙이기, 재시도 로직
3OS 큐에 쓰이는 컨트롤 블록 포맷은 전용 모듈 안에 감춘다. 그것을 모듈 간 인터페이스로 삼는 게 관행인데, 설계가 진화하면서 포맷이 자주 바뀌므로 대단히 비싸게 치른다.DTO·JSON 스키마·DB 컬럼 구조를 그대로 모듈 경계로 삼는 설계
4문자 코드, 알파벳 정렬 순서 같은 데이터도 모듈에 감춰야 유연성이 가장 크다.로케일·정렬 규칙·통화 포맷을 여러 곳에 하드코딩
5어떤 항목이 어떤 순서로 처리될지도 가능한 한 한 모듈 안에 감춘다. 장비 추가부터 자원 부족까지 순서는 극도로 가변적이기 때문이다.호출 순서를 호출자가 알아야 하는 API, 이벤트 처리 순서에 의존하는 로직
⚠️ 3번이 오늘날 가장 자주 어겨진다

"컨트롤 블록 포맷을 모듈 간 인터페이스로 삼는 것이 관행"이라는 지적은 지금의 DTO 공유와 정확히 같은 구조다. 두 서비스가 같은 JSON 스키마를 공유하면, 그 스키마는 이제 바꿀 수 없는 계약이 된다. Parnas의 표현대로 "often proves extremely costly."

8. 저자의 오답노트 — 필요 이상으로 말한 인터페이스

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

분해 2의 Circular Shifter는 저장 방식은 잘 감췄다. 그런데 시프트가 나오는 순서를 명세에 못 박아 뒀다.

문제의 명세 — 논문이 원래 규정한 것
// (1) i < j 이면 i번 줄의 시프트들이 j번 줄의 시프트들보다 앞에 온다
// (2) 각 줄에서 첫 번째 시프트는 원본 줄이고,
//     두 번째 시프트는 첫 번째를 한 단어 회전시킨 것이다 ...
enum CircularShifter {
    static func setup()
    static func char(_ l: Int, _ w: Int, _ c: Int) -> UInt8
}
Parnas가 "이렇게 했어야 한다"고 말한 명세
// (1) 정의상 존재해야 할 시프트가 표에 전부 있다
// (2) 그중 어떤 것도 두 번 들어가지 않는다
// (3) 시프트로부터 원본 줄을 알아낼 수 있는 함수가 있다
enum CircularShifter {
    static func setup()
    static func char(_ l: Int, _ w: Int, _ c: Int) -> UInt8
    static func originalLine(_ l: Int) -> Int      // ← 순서 약속 대신 이 함수
}

순서를 약속하면 무엇을 잃는가

구체적인 손해가 있다. 시프트를 애초에 알파벳순으로 만들어 버리는 구현을 이제 만들 수 없다. 그런 구현이라면 정렬 모듈이 통째로 공짜가 된다.

순서를 약속하지 않았다면 가능했을 구현 — 정렬이 사라진다
enum CircularShifter {
    static func setup() {
        // 시프트를 만들면서 곧바로 알파벳순으로 넣는다
    }
}

enum Alphabetizer {
    static func alph() { }                    // 할 일이 없다 — 빈 함수
    static func ith(_ i: Int) -> Int { 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 in constructing the systems with the second decomposition must clearly be classified as a design error."

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

✅ 오늘의 코드에서 같은 실수 찾기
  • 반환값이 "정렬되어 있다"고 문서에 썼는가? 그 순간 정렬 없는 구현을 못 쓰게 된다.
  • 이벤트가 "등록 순서대로 호출된다"고 약속했는가? 병렬 처리가 막힌다.
  • 테스트가 순서에 의존하면, 그 순서는 이미 공개된 계약이다. 문서에 안 썼어도 마찬가지다.
  • "지금 구현이 어차피 그러니까"는 명세에 쓸 이유가 아니다. 구현이 그런 것과 약속한 것은 다르다.

9. 효율 — "모듈 = 서브루틴"을 버려라

가장 자주 잘못 인용되는 절이다. Parnas는 먼저 문제를 솔직하게 인정한다.

⚠️ 원문 — 문제 인정

"If we are not careful the second decomposition will prove to be much less efficient than the first. If each of the functions is actually implemented as a procedure with an elaborate calling sequence there will be a great deal of such calling due to the repeated switching between modules."

— 조심하지 않으면 두 번째 분해는 첫 번째보다 훨씬 비효율적일 것이다. 각 함수가 정교한 호출 규약을 가진 프로시저로 실제 구현되면, 모듈 사이를 계속 오가느라 호출이 엄청나게 많아진다.

그가 실제로 제시한 해법

"컴파일러가 인라인해 주니 괜찮다"가 아니다. 훨씬 근본적이다.

🔑 원문 — 해법

"...we must implement these modules in an unusual way. In many cases the routines will be best inserted into the code by an assembler... will require a tool by means of which programs may be written as if the functions were subroutines, but assembled by whatever implementation is appropriate."

그리고 결론부에서 못 박는다 — "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."

사람이 보는 표현 — 설계 모듈 A — 저장 포맷을 감춘다 모듈 B — 시프트 방식을 감춘다 모듈 C — 정렬 시점을 감춘다 경계의 기준 = 바뀔 결정 도구 매핑 기계가 도는 표현 — 실행 서브루틴 1 — A·B의 코드가 섞여 있다 호출 오버헤드 없음 서브루틴 2 — B·C의 코드가 섞여 있다 경계가 최종 코드에 안 보일 수 있다 경계의 기준 = 실행 효율 "모듈은 하나 이상의 서브루틴"이라는 가정을 버려라 두 표현을 기계 안에 함께 두고, 그 사이를 매핑하는 프로그램을 둔다
오늘날 인라이닝·LTO·단일화 컴파일이 하는 일이 정확히 이 매핑이다. Parnas가 요구한 건 최적화 기법이 아니라 사고방식의 교체였다.
🧠 이미 3장에서 복선을 깔아 뒀다 원문

비교의 첫 문단에서 Parnas는 이렇게 썼다. "두 시스템은 실행 가능한 표현에서는 동일하더라도 실질적으로 다르다. 이것이 가능한 이유는 실행 표현은 실행에만 쓰이면 되기 때문이다. 다른 표현들은 변경·문서화·이해를 위해 쓰인다." 즉 하나의 프로그램이 여러 표현을 갖는다는 생각이 논문 전체에 깔려 있다.

10. 거의 인용되지 않는 두 절

① 컴파일러와 인터프리터의 공통 분해

Parnas는 다른 프로젝트 얘기를 짧게 꺼낸다. Markov 알고리즘 번역기를 만들면서 같은 분해 규칙을 적용했더니, 의도하지 않은 결과가 나왔다.

🔑 원문

"we discovered that our decomposition was valid for a pure compiler and several varieties of interpretors for the language. Although there would be deep and substantial differences in the final running representations of each type of compiler, we found that the decisions implicit in the early decomposition held for all."

그리고 대조군을 명시한다. "만약 컴파일러나 인터프리터의 고전적 구분선(구문 인식기, 코드 생성기, 런타임 루틴)을 따라 책임을 나눴다면 이렇게 되지 않았을 것이다." 대신 레지스터 표현, 탐색 알고리즘, 규칙 해석 같은 것들이 모듈이 됐고, 그 문제들은 컴파일 방식과 해석 방식 양쪽에 다 존재했다.

✅ 이 절이 말하는 것
  • 좋은 분해는 실행 전략이 바뀌어도 살아남는다. 컴파일이든 해석이든 "감춰야 할 결정"은 같았기 때문이다.
  • Parnas의 해석 — "처리가 시간적으로 어떤 순서로 일어날 것으로 기대되는지는 모듈 분해에 사용되어서는 안 된다"는 주장의 추가 근거.
  • 덤으로 "한 프로젝트에서 다른 프로젝트로 작업이 상당히 이월된다"는 증거이기도 하다. 루틴 다수를 거의 그대로 재사용할 수 있었다.
  • 오늘로 옮기면: 동기/비동기, 배치/스트리밍, 서버/클라이언트 렌더링이 바뀌어도 모듈 경계가 그대로면 잘 나눈 것이다.

② 계층 구조는 별개 문제다

먼저 분해 2에서 계층을 실제로 찾아 보인다. 심볼 테이블은 다른 모듈 없이 동작하므로 레벨 1, Line Storage는 심볼 테이블이 없으면 레벨 1·있으면 레벨 2, Input과 Circular Shifter는 Line Storage를 필요로 하고, Output과 Alphabetizer는 Circular Shifter를 필요로 한다.

그런데 결론은 "그러니 계층이 좋다"가 아니다.

깔끔한 분해 (정보 은닉) → 계층 구조 (uses 부분순서) → 계층 ✗ · 은닉 ✗ 서로 마구 부르고 자료구조도 공유한다 계층 ✗ · 은닉 ✓ 전부 같은 레벨이어도 변경은 한 모듈에 갇힌다 계층 ✓ · 은닉 ✗ 레이어는 깔끔한데 인터페이스에 결정이 노출됨 계층 ✓ · 은닉 ✓ 상위를 잘라내도 쓸모가 남고 변경도 한 모듈에 갇힌다
Parnas의 결론 — "둘 다 바람직하지만 서로 독립적인 성질"이다. 왼쪽 위 칸의 존재가 핵심이다. 레이어를 잘 쌓았다고 정보 은닉이 된 것이 아니다.
✅ 계층이 주는 두 가지 이득 (은닉과 별개로)
  • 하위가 상위를 단순하게 만든다. 상위 부분은 하위 레벨의 서비스를 쓰기 때문에 단순해진다.
  • 상위를 잘라내도 쓸모 있는 제품이 남는다. 심볼 테이블은 다른 응용에도 쓰이고, line holder는 질의응답 시스템의 토대가 될 수 있다. Parnas의 표현으로는 "나무의 윗부분을 쳐내고 옛 줄기 위에 새 나무를 시작할 수 있다."

반대로 "낮은 레벨 모듈이 높은 레벨 모듈을 쓰게 설계했다면" 계층이 성립하지 않고, 일부를 떼어내기가 훨씬 어려워지며, '레벨'이라는 말 자체가 의미를 잃는다.

🧠 관계를 모듈이 아니라 프로그램 사이에 정의하는 이유 원문

Parnas는 "uses" 관계를 모듈이 아니라 프로그램 사이에 정의하는 게 낫다고 말한다. 이유가 구체적이다 — "많은 경우 한 모듈은 다른 모듈의 일부에만 의존한다." 예로 Circular Shifter는 line holder의 출력 부분에만 의존하고 SETWORD가 제대로 도는지에는 의존하지 않는다. 오늘날 "모듈 A가 모듈 B에 의존한다"는 화살표가 실제보다 의존을 과장하는 문제와 같다.

11. 2026년의 KWIC — 오늘 코드에 대입하기

논문의 결론은 세 문장이다.

🔑 결론 전문

"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."

실전 절차 — "바뀔 결정 목록"을 실제로 만들기

이 논문은 방법론까지 준다. 순서대로 하면 된다.

1단계 — 코드를 보기 전에 "바뀔 결정" 목록부터 쓴다
// 예: 주문 시스템
// 1. 결제 PG사              — 바뀔 확률 높음 (계약·수수료)
// 2. 주문 상태 저장 포맷      — 바뀔 확률 높음 (스키마 진화)
// 3. 배송비 계산 규칙         — 바뀔 확률 매우 높음 (프로모션)
// 4. 알림 채널 (푸시/SMS/톡)  — 바뀔 확률 높음
// 5. 주문번호 채번 방식        — 바뀔 확률 중간
// 6. "주문은 결제 후 배송"     — 바뀔 확률 낮음 (도메인 규칙)
❌ 2단계 (틀린 방식) — 처리 순서대로 자른다
OrderValidator  →  PaymentProcessor  →  ShippingCalculator  →  Notifier
//   ↑ 넷 다 Order 구조체의 필드를 직접 읽고 쓴다.
//   → 주문 상태 저장 포맷(결정 2)이 바뀌면 넷 다 바뀐다.
//   → Parnas의 분해 1과 정확히 같은 구조다.
✅ 2단계 (맞는 방식) — 결정 하나당 모듈 하나
enum OrderStore {          // 🔒 결정 2: 저장 포맷
    static func status(_ id: OrderID) -> OrderStatus
    static func advance(_ id: OrderID, to: OrderStatus) throws
}
enum PaymentGateway {      // 🔒 결정 1: 어느 PG사인가
    static func charge(_ amount: Money, _ method: PayMethod) async throws -> Receipt
}
enum ShippingPolicy {      // 🔒 결정 3: 배송비 규칙
    static func fee(for order: OrderID) -> Money
}
enum Notifier {            // 🔒 결정 4: 어떤 채널로 보내는가
    static func orderPlaced(_ id: OrderID) async
}
// 결정 6(도메인 규칙)은 감출 대상이 아니다 — 안 바뀌니까 드러내는 게 낫다.
⚠️ 흔한 오독 — "전부 다 감춰라"가 아니다

기준은 "바뀔 가능성이 높은가"다. 안 바뀌는 것까지 인터페이스 뒤에 숨기면 얕은 모듈만 늘어난다(→ Ousterhout의 classitis). 도메인 규칙처럼 안정적인 것은 오히려 코드에 드러내는 편이 읽기 좋다.

코드 리뷰에서 바로 쓸 수 있는 질문 5개

질문걸리면 무엇을 의심하는가
이 자료구조의 포맷을 아는 곳이 몇 군데인가?2곳 이상이면 정보 유출. 접근 함수로 감싼다
클래스 이름이 처리 단계로 되어 있는가?
Reader / Writer / Parser / Validator / Sender
순서도 분해 의심. 같은 지식이 갈라져 있을 확률이 높다
두 팀이 무언가를 합의해야만 각자 시작할 수 있는가?그게 인터페이스에 노출된 "무거운 결정"이다
인터페이스가 필요 이상으로 약속하고 있지 않은가?순서·정렬·시점 보장은 구현 선택지를 지운다
이 모듈을 이해하려고 다른 모듈을 열어 봐야 하는가?"전체로서만 이해되는" 시스템의 신호
🔑 한 문장만 가져간다면

"먼저 어려운 설계 결정, 또는 바뀔 가능성이 높은 결정의 목록을 만들어라. 그리고 각 모듈이 그런 결정 하나씩을 다른 모듈로부터 감추도록 설계하라."

출처 · 참고자료