← Study Hub

Out of the Tar Pit 정독

복잡도를 줄이라는 말을 상태와 제어를 줄이라는 말로 번역한 논문. Brooks에게 We disagree로 답하고, FRP라는 대안 아키텍처까지 간다. 10개 장 전체를 원문 대조했다.
진행률
0 / 0 완료
"복잡도를 줄여라"는 누구나 한다. 이 논문이 한 일은 그 말을 실행 가능한 문장으로 번역한 것이다 — 상태(state)와 제어(control)를 줄여라. 그리고 Brooks의 No Silver Bullet이 "복잡도는 본질적이라 어쩔 수 없다"고 한 데 대해 "We disagree" 세 글자로 정면 반박한다. 66쪽 중 앞 절반이 본체다.
📝 이 강의의 위치

세 원전을 한 장으로 잇는 설계 정전 3권 정독의 심화편이다. 종합본이 이 논문을 3개 절로 압축했다면, 여기서는 10개 장 전체를 따라간다. 인용부호 안의 문장은 전부 원문(2006년 2월 6일판)이다.

🚨 이름부터 짚고 간다

이 논문의 FRPFunctional Relational Programming이다. RxSwift·Combine 계열의 Functional Reactive Programming과 완전히 다르다. 검색할 때 반드시 "Out of the Tar Pit"을 함께 넣어야 한다.

1. 문제 제기 — 무엇과 싸우는가

논문 제목의 "tar pit(타르 늪)"은 Brooks의 The Mythical Man-Month 첫 장에서 온 이미지다. 거대한 짐승들이 허우적대다 빠져나오지 못하는 늪 — 대형 소프트웨어 프로젝트의 은유다.

Moseley와 Marks의 진단은 단호하다. "Complexity is the single major difficulty in the successful development of large-scale software systems." — 복잡도는 대규모 소프트웨어 시스템 개발을 성공시키는 데 있어 단일 최대 난점이다. 그리고 복잡도가 위험한 이유는 딱 하나다 — 우리가 시스템을 이해하려는 시도를 방해하기 때문이다.

2. 우리가 시스템을 이해하는 두 가지 방법 🔥 핵심 20%

3장이 이 논문의 인식론적 토대다. 우리가 코드가 맞는지 확인하는 방법은 둘뿐이다.

테스트 — 바깥에서 보기
시스템을 블랙박스로 놓고 이해하려는 시도. 특정 상황에서 어떻게 행동하는지 관찰해 결론을 내린다.
비공식적 추론 — 안에서 따져보기
시스템을 안에서 들여다보며 이해하려는 시도. 추가 정보를 써서 더 정확한 이해를 얻으려는 것.
🔑 둘 중 어느 쪽이 중요한가

"Of the two informal reasoning is the most important by far."

이유가 두 개다. 첫째, 테스트에는 본질적 한계가 있다. 둘째, 비공식적 추론은 개발 과정에 내재해 있어서 언제나 사용된다 — 코드를 읽는 순간 이미 하고 있다.

결정적인 논거

세 번째 이유가 가장 강하다. 두 방법이 서로 다른 일을 한다는 것이다.

추론이 나아지면 informal reasoning 버그가 애초에 덜 만들어진다 less errors being created 테스트가 나아지면 testing 이미 만들어진 버그가 더 발견될 뿐이다 more errors being detected Dijkstra: "정말 믿을 만한 소프트웨어를 원한다면, 애초에 대부분의 버그를 피할 방법을 찾아야 한다"
같은 노력을 어디에 넣느냐의 문제다. 추론은 상류에서, 테스트는 하류에서 일한다.

테스트의 두 가지 근본 한계

⚠️ 한계 ① — 입력

"a test (of any kind) that uses one particular set of inputs tells you nothing at all about the behaviour of the system or component when it is given a different set of inputs."

그래서 테스트에 대한 걱정은 언제나 "올바른 테스트를 했는가?"이고, 확실한 답은 오직 부정형으로만 온다 — 시스템이 깨질 때.

Dijkstra의 유명한 문장도 인용한다 — "testing is hopelessly inadequate ... it can be used very effectively to show the presence of bugs but never to show their absence." 그리고 논문은 한 줄 덧붙인다. "We agree with Dijkstra. Rely on testing at your peril."

🚨 그래서 나오는 결론 — 이 논문의 축

"It is precisely because of the limitations of all these approaches that simplicity is vital. When considered next to testing and reasoning, simplicity is more important than either. Given a stark choice between investment in testing and investment in simplicity, the latter may often be the better choice because it will facilitate all future attempts to understand the system — attempts of any kind."

📝 오해 주의

"테스트가 쓸모없다"가 아니다. 논문은 "모든 접근법에 한계가 있으므로 테스트와 추론을 함께 쓰는 것이 현명할 때가 많다"고 명시한다. 주장은 우선순위에 관한 것이다 — 단순함이 둘 다를 돕는다.

3. 상태 — 가장 큰 범인 🔥 핵심 20%

4.1절은 아주 일상적인 장면으로 시작한다. 논문에서 가장 유명한 도입부다.

🔑 원문

소프트웨어 고객센터에 전화해서 "다시 해 보세요", "문서를 다시 여세요", "프로그램을 재시작하세요", "컴퓨터를 재부팅하세요", "재설치하세요", 심지어 "OS를 재설치하고 프로그램을 다시 까세요"를 들어 본 사람은, 이미 상태가 신뢰성 있고 이해 가능한 소프트웨어를 쓰는 데 어떤 문제를 일으키는지 직접 경험한 것이다.

그리고 인과를 역순으로 짚는다 — 이 말들이 익숙한 이유는 실제로 자주 문제를 해결하기 때문이고, 자주 해결되는 이유는 많은 시스템이 상태 처리에 오류를 갖고 있기 때문이며, 그런 오류가 많은 이유는 상태의 존재가 프로그램을 이해하기 어렵게 만들기 때문이다.

"껐다 켜보세요"가 통하는 이유를 이렇게 설명한 글은 드물다. 그건 상태를 날리는 행위다.

🧠 여기서 "상태"란 무엇인가 각주

논문은 각주로 범위를 좁힌다. "By 'state' we mean mutable state specifically" — 가변 상태만을 말하며, 논리형 언어의 단일 대입 변수 같은 불변 값은 제외한다. 즉 "변수를 쓰지 마라"가 아니라 "바뀌는 것을 줄여라"다.

상태가 테스트를 무너뜨리는 방식

⚠️ 원문 — 입력 문제와 똑같은 구조

"a test (of any kind) on a system or component that is in one particular state tells you nothing at all about the behaviour of that system or component when it happens to be in another state."

그러면 실무는 어떻게 하고 있는가? 논문의 묘사가 정직하다.

  1. 시스템을 "깨끗한" 또는 "초기" 상태(대개는 숨겨져 있다)로 띄운다.
  2. 테스트 입력으로 원하는 테스트를 수행한다.
  3. 그리고 "내부 숨은 상태와 무관하게 같은 입력이면 늘 같게 동작할 것"이라고 가정한다.
🚨 논문의 평가

"In essence, this approach is simply sweeping the problem of state under the carpet."

그리고 왜 그러는지도 인정한다 — 첫째로 대개 그렇게 해도 넘어가지기 때문이고, 더 결정적으로는 복잡한 내부 숨은 상태를 가진 시스템을 테스트할 때 다른 대안이 없기 때문이다. 문제는 "항상 넘어가지지는 않는다"는 것.

상태가 추론을 무너뜨리는 두 가지 방식

① 지수적 폭발. 문장이 짧다. "for every single bit of state that we add we double the total number of possible states." 상태를 1비트 추가할 때마다 가능한 전체 상태 수는 두 배가 된다.

불리언 몇 개면 이미 손을 벗어난다
final class UploadView {
    var isLoading    = false   //  2
    var hasError     = false   //  4
    var isEditing    = false   //  8
    var isSelected   = false   // 16
    var isOffline    = false   // 32
    var didAuth      = false   // 64
    // ... 여기까지 64가지. 30개면 10억 가지가 넘는다.
    // 테스트에서 확인하는 건 그중 몇 개일까?
}
✅ 상태 수를 줄이는 가장 쉬운 방법

불리언 여러 개를 하나의 열거형으로 바꾸면 불가능한 조합이 타입 수준에서 사라진다.

64가지 → 4가지. 게다가 "로딩 중이면서 에러"가 표현 불가능해진다
enum UploadState {
    case idle
    case loading
    case failed(Error)
    case done(URL)
}
final class UploadView {
    var state: UploadState = .idle
}

② 오염(contamination). 이게 더 무섭다. 논문의 논지는 이렇다 — 상태 없는 프로시저는 이해하기 쉬울 거라고 기대하지만, 대체로 그렇지 않다.

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

"it is our belief that the single biggest remaining cause of complexity in most contemporary large systems is state, and the more we can do to limit and manage state, the better."

4. 제어와 코드량 🔥 핵심 20%

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

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

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

프로그래머는 값들 사이의 관계만 말하고 싶었다. 그런데 임의의 제어 흐름을 골라야 했다. 논문의 표현으로는 "문제를 과잉 명세(over-specify)하도록 강요당한 것"이다.

🚨 두 가지 불필요한 일이 일어난다

"first an artificial ordering is being imposed, and then further work is done to remove it."

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

📝 문제는 코드가 아니라 의미론이다

논문이 명확히 한다 — "the problem is not in the text of the program above", 어차피 코드는 어떤 순서로든 적혀야 한다. 문제는 "텍스트 순서가 곧 실행 순서"라고 정의한 언어의 의미론에 있다. 같은 텍스트를 그런 순서를 정의하지 않는 언어의 프로그램으로 볼 수도 있다.

동시성 — "이보다 더 나빠질 수는 없다"

🚨 원문

"Running a test in the presence of concurrency with a known initial state and set of inputs tells you nothing at all about what will happen the next time you run that very same test with the very same inputs and the very same starting state... and things can't really get any worse than that."

상태는 "다른 상태에서는 모른다"였고, 동시성은 "같은 상태, 같은 입력에서도 모른다"이다. 테스트가 줄 수 있는 정보가 0이 되는 지점이다.

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

코드가 많으면 복잡하다. 다만 논문은 이것을 대체로 이차적 효과로 본다 — "much code is simply concerned with managing state or specifying control." 그래도 따로 다루는 이유가 두 가지다.

측정하기 가장 쉽다
복잡도 중 유일하게 숫자로 바로 나오는 형태다.
다른 원인과 나쁘게 상호작용한다
코드가 늘면 상태와 제어도 함께 늘어난다.
🧠 Dijkstra 인용과 논문의 희망 원문

Brooks는 복잡도가 크기에 대해 비선형적으로 증가한다고 했다. 논문은 "현재 대부분의 시스템에서는 그 말이 맞다"고 동의하되(단, "essential"이라는 단어에는 반대한다), Dijkstra를 인용해 조건을 붙인다 — "추상화 능력을 적절히 적용하면, 프로그램을 이해하는 데 드는 지적 노력이 길이에 비례하는 것 이상으로 커질 필요는 없다."

그리고 자기 입장을 밝힌다 — 상태와 제어를 효과적으로 관리하면, 복잡도가 코드량에 대해 비선형으로 는다는 것도 훨씬 불분명해진다.

5. 나머지 원인들 — 3가지 원리로 수렴한다

4.4절이 짧지만 밀도가 높다. 중복 코드, 죽은 코드, 불필요한 추상화, 놓친 추상화, 나쁜 모듈성, 부실한 문서 — 이 모든 것이 세 가지 원리로 환원된다.

원리내용
복잡도가 복잡도를 낳는다
Complexity breeds complexity
시스템을 명확히 이해할 수 없기 때문에 생기는 이차적 복잡도. 중복이 대표 사례다 — (상태·제어·코드량 때문에) 그 기능이 이미 있는지 알 수 없거나, 있는 것이 정확히 원하는 걸 하는지 판단하기에 너무 복잡하면 중복해서 만들려는 강한 경향이 생긴다. 시간 압박이 있으면 특히 그렇다.
단순함은 어렵다
Simplicity is Hard
단순함을 얻는 데는 상당한 노력이 든다. 첫 번째 해법은 대개 가장 단순한 해법이 아니다 — 기존 복잡도가 있거나 시간 압박이 있으면 더욱. 그리고 결론이 강하다 — "단순함은 인식되고, 추구되고, 소중히 여겨질 때만 얻어진다."
권력은 부패한다
Power corrupts
언어가 강제로 보장해 주지 않으면(= 언어의 힘을 제한하지 않으면) 실수와 남용은 반드시 일어난다. GC가 좋은 이유는 수동 메모리 관리라는 "권력"을 빼앗기 때문이다. 상태에도 정확히 같은 원리가 적용된다 — 그래서 상태를 "허용만" 하는 언어(ML, Scheme 등)도 경계해야 한다. 아무리 사용을 권장하지 않더라도.
✅ "권력은 부패한다"를 오늘 코드로
  • var보다 let이 좋은 이유는 취향이 아니라 보장이다. 강제되지 않는 규칙은 언젠가 깨진다.
  • structclass보다 안전한 이유도 같다 — 공유 가변 참조라는 권력이 없다.
  • "우리 팀은 전역 변수 안 씁니다"는 컨벤션이고, 컨벤션은 언어의 보장이 아니다.

6. 패러다임 채점 — OO · FP · 논리형

5장은 기존 패러다임을 상태와 제어 두 잣대로만 채점한다.

객체지향

⚠️ 먼저, 공정한 인정

논문은 캡슐화를 부정하지 않는다. 객체 = 상태 + 그 상태를 다루는 절차라는 발상은 추상 자료형(ADT)의 계승이며, "덜 구조화된 명령형 스타일과 비교하면 OOP 접근의 주요 강점 중 하나"라고 명시한다. 접근을 메서드로 제한해 무결성 제약을 강제할 수 있기 때문이다.

문제는 그다음이다.

제약이 흩어진다
여러 접근 메서드가 같은 상태 조각을 만지면, 하나의 제약을 여러 곳에서 강제해야 한다.
다중 객체 제약이 갈 곳이 없다
캡슐화 기반 강제는 단일 객체 제약에 강하게 편향되어 있다. 여러 객체에 걸친 제약은 어디에 두어야 할지조차 불분명해진다.

정체성(identity) — OO가 상태와 함께 가져오는 두 번째 부담

이 대목이 자주 놓친다. OOP에서 객체는 속성과 무관하게 고유하게 식별되는 존재다. 논문의 용어로 내포적 정체성(intensional identity)이며, 속성이 같으면 같은 것으로 보는 외연적 정체성(extensional identity)과 대비된다.

🧠 Baker의 관찰 인용 원문

"In a sense, object identity can be considered to be a rejection of the 'relational algebra' view of the world in which two objects can only be distinguished through differing attributes."

가변이 필요 없는데 정체성이 따라붙을 때 — 두 가지 동일성을 오가야 한다
final class Money {                 // 값인데 객체다
    let amount: Decimal
    let currency: String
}
let a = Money(amount: 100, currency: "KRW")
let b = Money(amount: 100, currency: "KRW")
a === b                             // false — 정체성 비교
a == b                              // 직접 구현해야 한다 (Value Object 기법)
// 논문의 지적: 도메인별 동등성을 직접 만들면
// 그것이 동치관계의 표준 성질(예: 추이성)을 만족한다는 보장이 사라진다.

struct Money2: Equatable {          // ✅ 정체성 개념 자체가 없다
    let amount: Decimal
    let currency: String
}

논문의 결론은 이렇다 — 정체성이라는 추가 개념은 상태 사용에서 곧바로 파생되며, 패러다임의 일부라 피할 수 없다. 그리고 두 동일성 개념 사이를 정신적으로 오가야 하므로 추론 부담이 늘고, 혼동에서 심각한 오류가 나올 수 있다.

함수형

🔑 참조 투명성이 하는 일

"It is this cast iron guarantee of referential transparency that obliterates one of the two crucial weaknesses of testing."

테스트의 약점 두 개 중 "다른 상태에서는 모른다"가 통째로 사라진다. 남는 약점은 입력 문제 하나뿐이다. 그리고 결정적인 성질 — "결과에 영향을 줄 수 있는 모든 것이 언제나 실제 인자에 즉시 드러나 있다."

FP의 약점 — 논문이 솔직하게 인정하는 부분

5.2.4절은 FP 옹호론이 잘 인용하지 않는 대목이다. 상태가 특정한 종류의 모듈성을 제공한다는 반론을 논문은 "certainly true"라고 받는다.

상태 있는 방식 함수형 방식 ✅ 쓸 때 싸다 호출자를 하나도 안 고치고 상태를 추가할 수 있다 ❌ 읽을 때 비싸다 무엇이 결과를 좌우하는지 절대 알 수 없다 최악의 경우 시스템 전체 코드를 봐야 한다 ❌ 쓸 때 비싸다 영향받는 모든 곳에 인자를 추가해야 한다 ✅ 읽을 때 싸다 호출부의 인자만 보면 전부 알 수 있다 테스트와 추론이 크게 개선된다 코드는 한 번 쓰이고, 계속해서 읽히고 추론되고 테스트된다 그래서 논문은 정적 타입과 같은 종류의 거래라고 본다 — 일회성 선불 비용 vs 지속적 이득
논문이 이 트레이드오프를 명시적으로 인정한다는 점이 중요하다. 공짜가 아니라 거래다.
⚠️ 모나드에 대한 평가도 냉정하다

Haskell의 모나드는 "elegant"하고 위 문제를 실제로 피하게 해 준다고 인정한다. 그러나 "매우 쉽게 남용되어 Haskell 안에 상태 있고 부수효과 있는 하위 언어를 만들 수 있다"고 경고한다 — 타입으로 표시된다는 점만 다를 뿐, 우리가 피하려던 문제를 그대로 다시 들여올 수 있다.

그리고 FP의 약점을 한 문장으로 정리한다 — "the main weakness of functional programming is the flip side of its main strength": 상태를 유지해야만 하는 시스템을 만들 때 문제가 생긴다는 것.

논리형

논리형 프로그래밍은 "제어에서 오는 복잡도를 벗어날 가능성"을 제시한다는 점에서 논문이 가장 높이 사는 패러다임이다. 순수 논리 프로그래밍은 문제와 해의 조건을 공리로 진술할 뿐이고, 실행은 각 해에 대한 형식 증명을 구성하는 것과 같다.

🚨 그런데 Prolog는 이상에 못 미친다

모든 순수 Prolog 프로그램은 두 가지로 읽힌다 — ① 순수한 논리 공리의 집합, ② 목표를 증명하려고 특정 순서로 적용되는 명령의 나열. 그리고 결정적인 문제 — "하나의 Prolog 프로그램이 첫 번째 방식으로는 옳고, 두 번째 방식으로는 (예컨대 비종료 때문에) 틀릴 수 있다."

그래서 공리를 쓰는 동안 운영적 해석을 신경 써야 한다. cut 같은 기능은 이 문제를 더 키운다 — "cut의 효과를 오해하는 것이 Prolog 프로그램 버그의 주요 원인으로 알려져 있다."

7. 본질과 우발을 다시 긋다 🔥 핵심 20%

6장에서 논문은 Brooks의 용어를 이어받되 더 엄격하게 다시 정의한다.

🔑 원문의 정의

Essential Complexity — "is inherent in, and the essence of, the problem (as seen by the users)."

Accidental Complexity — "is all the rest — complexity with which the development team would not have to deal in the ideal world (e.g. complexity arising from performance issues and from suboptimal language and infrastructure)."

그리고 자기 정의가 통상적 용법보다 엄격하다는 걸 명시한다 — "strictly essential to the users' problem"이지, 어떤 구현된 시스템에 본질적이거나 소프트웨어 일반에 본질적인 것이 아니다.

판별 규칙 두 개

⚠️ 규칙 ① — 사용자가 모르면 본질이 아니다

"if the user doesn't even know what something is (e.g. a thread pool or a loop counter — to pick two arbitrary examples) then it cannot possibly be essential by our definition."

⚠️ 규칙 ② — 피할 방법이 하나라도 있으면 본질이 아니다

"the 'have to' part of this observation is critical — if there is any possible way that the team could produce a system that the users will consider correct without having to be concerned with a given type of complexity, then that complexity is not essential."

이 잣대를 오늘 코드에 대 보면 결과가 꽤 불편하다.

우리 코드에 있는 것분류이유
"대출 한도는 연소득의 3배를 넘을 수 없다"본질사용자가 아는, 사용자의 규칙
"주문은 결제 후에만 배송으로 넘어간다"본질사용자의 업무 규칙
Redis 캐시 · CDN우발성능 때문. 사용자가 요구한 적 없다
DB 인덱스 · 커넥션 풀 · 샤딩우발같은 이유
ORM · DTO ↔ 엔티티 매핑우발인프라가 만든 것
스레드 · 락 · 비동기 큐우발규칙 ①에 정확히 해당 — 사용자는 이게 뭔지 모른다
비트 · 바이트 · 트랜지스터 · 전기 · 컴퓨터 자체우발논문이 직접 든 예. 사용자 문제와 무관하다
🧠 Brooks와 정면으로 갈리는 지점 원문

Brooks는 "The complexity of software is an essential property, not an accidental one"이라고 썼고, Booch 등도 동의했다. 논문의 응답은 두 단어로 시작한다 — "We disagree."

근거는 두 개다. ① 복잡도는 소프트웨어의 내재적 속성이 아니다 — 단순하면서도 여전히 소프트웨어인 것을 쓰는 게 완벽히 가능하다. ② 기존 소프트웨어에서 보이는 복잡도의 상당수는 (문제에) 본질적이지 않다.

📝 "그래도 캐시는 필요하잖아"에 대한 답

논문도 인정한다. "현실 세계에서는 모든 가능한 방법이 실용적이지는 않으므로, 어떤 실제 개발이든 어느 정도의 우발적 복잡도를 상대해야 한다. 이 정의는 그것을 부정하려는 게 아니라 — 그것의 이차적 성격을 식별하려는 것이다."

필요 없다는 게 아니라 본질과 섞이지 않게 관리하라는 것이다. 이게 뒤에 나올 FRP의 전부다.

8. 이상적 세계 — 무엇이 진짜 본질적 상태인가

7.1절은 이 논문에서 가장 자주 잘못 요약되는 부분이다. "입력 데이터가 본질적 상태"라고 흔히 쓰는데, 원문은 훨씬 엄격하다.

사용자 요구에 나오는 데이터 입력됨 파생됨 직접 제공된 데이터 (input) 본질적 상태 나중에 다시 참조할 가능성이 있는 것 저장 불필요 부수효과만 일으키고 끝나는 입력 파생 데이터 (derived) 불변 파생 → 우발적 상태 가변 파생 → 우발적 상태 파생 가능한 것은 전부 우발적 상태다 — 다시 계산하면 되니까 그리고 사용자 요구에 없는 파생 데이터(캐시 등)도 당연히 우발적 상태다 → 그래서 "성능을 위한 비정규화"는 이 맥락에서 전혀 말이 되지 않는다
이 트리를 통과해 왼쪽 위 파란 상자에 남는 것만 본질적 상태다. 우리 코드의 상태 대부분이 여기 도달하지 못한다.
🧠 가변 파생 데이터도 우발인 이유 원문

"사용자가 이 값을 수정할 수 있는데도 파생이라고?" 논문의 답이 정교하다. 파생 데이터의 가변성은 그것을 만든 함수에 역함수가 있을 때만 말이 된다(없다면 — 가변이므로 — 그건 지속적으로 파생된다고 볼 수 없고 사실상 입력이다). 역함수는 파생 데이터가 입력 데이터의 단순한 재구조화일 때 흔히 존재하고, 그럴 때는 파생 데이터의 수정을 원본 상태의 대응하는 수정으로 그대로 취급하면 된다.

9. 처방 — Avoid, Separate 🔥 핵심 20%

🔑 논문의 권고 전체가 두 단어다

"Our recommendations for dealing with complexity (as exemplified by both state and control) can be summed up as: Avoid · Separate"

Avoid — 절대적으로, 진정으로 본질적이지 않은 상태와 제어를 피한다. Separate — 그래도 남는 것(진짜 본질이거나, 본질은 아니지만 실용적으로 유용한 것)은 나머지로부터 분리해 낸다.

⚠️ 논문 스스로의 평가

"There is nothing particularly profound in these recommendations, but they are worth stating because they are emphatically not the way most software is developed today."

— 특별히 심오하지 않다. 다만 지금 대부분의 소프트웨어가 이렇게 만들어지지 않고 있기 때문에 말할 가치가 있다.

뿌리도 밝힌다. Prolog 공동 개발자 Kowalski의 1979년 논문 제목이 곧 그 아이디어다.

🔑 Kowalski, 1979

Algorithm = Logic + Control

무엇을 원하는지(로직)와 어떻게 찾을지(제어)를 분리하라는 것. 논문은 "이 분리가 우리가 권고하는 것의 핵심에 가깝다"고 쓴다.

10. 관계 모델 — 접근 경로 독립성

8장은 "그래서 본질적 상태를 무엇으로 표현할 것인가"에 답한다. 답은 관계(relation)이고, 이유는 성능이나 익숙함이 아니라 접근 경로 독립성(access path independence)이다.

🔑 왜 관계인가

"The idea of structuring data using relations is appealing because no subjective, up-front decisions need to be made about the access paths that will later be used to query and process the data."

논문의 예 — 직원과 부서

직원과 그들이 일하는 부서 정보를 표현한다고 하자. 계층 모델에서는 시작부터 주관적 선택을 강요당한다.

선택쉬워지는 질문어려워지는 질문
부서가 상위
부서가 직원을 "담고" 있다
이 부서의 직원 전부 → 경로를 따라가면 된다이 직원의 부서 → 전 부서를 탐색해야 한다
직원이 상위
직원이 부서를 "담고" 있다
이 직원의 부서이 부서의 직원 전부
관계형둘 다둘 다 — 경로가 애초에 없다

네트워크 모델은 양방향 경로를 허용해 문제를 완화했다. 그런데 논문의 지적이 결정적이다 — "미래에 필요해질 모든 접근 경로를 미리 예측하는 것은 불가능하다." 그래서 언제나 예견된 1차 검색 요구예견되지 않은 2차 검색 요구 사이에 격차가 생긴다.

그리고 OOP는 정확히 같은 선택을 강요한다

🚨 논문의 지적

같은 예를 객체지향으로 풀면 선택지는 셋이다 — ① Employee가 Department 참조를 갖는다, ② Department가 Employee 참조들의 집합을 갖는다, ③ 둘 다.

③을 고르면 "잘해야 중복 참조를 유지하는 추가 작업에 노출되고, 최악의 경우 버그에 노출된다." 그리고 마무리가 세다 — "There are disturbing similarities between the data structuring approaches of OOP and XML on the one hand and the network and hierarchical models on the other." OOP와 XML의 데이터 구조화 방식은 네트워크·계층 모델과 불안할 정도로 닮았다.

③을 고른 코드 — 논문이 경고한 그 상황
final class Employee {
    var department: Department?          // 참조 ①
}
final class Department {
    var employees: [Employee] = []       // 참조 ②
}

// 이제 이 둘을 항상 맞춰야 한다 — 한 곳만 고치면 조용히 깨진다
func move(_ e: Employee, to d: Department) {
    e.department?.employees.removeAll { $0 === e }
    e.department = d
    d.employees.append(e)                // 🐞 이 줄을 빠뜨리면?
}
관계로 두면 — 맞출 것이 없다. 경로는 질의할 때 만든다
struct Employment: Hashable {            // 본질적 상태: 관계 하나
    let employeeID: EmployeeID
    let departmentID: DepartmentID
}
var employment: Set<Employment> = []

// 두 방향 질문 모두 파생이다 — 저장하지 않는다
func members(of d: DepartmentID) -> [EmployeeID] {
    employment.filter { $0.departmentID == d }.map(\.employeeID)
}
func department(of e: EmployeeID) -> DepartmentID? {
    employment.first { $0.employeeID == e }?.departmentID
}
func move(_ e: EmployeeID, to d: DepartmentID) {
    employment = employment.filter { $0.employeeID != e }
    employment.insert(Employment(employeeID: e, departmentID: d))
}
📝 덤 — 개체와 관계를 구분하지 않는다

논문은 Chen의 ER 모델링과 비교하며 관계 모델의 또 다른 장점을 든다. 개체(entity)와 관계(relationship)를 구분하지 않는다는 것이다. 이유는 실용적이다 — "무언가가 개체인지 관계인지는 매우 주관적인 질문일 수 있기 때문에 그런 구분을 쓰는 것은 문제가 될 수 있다."

11. FRP 아키텍처

9장이 논문의 종착지다. 시스템을 네 덩어리로 강제 분리한다. 이름은 원문 그대로다.

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

각 칸의 규칙

구성 요소규칙
① 본질적 상태
Essential State
base relvar의 이름과 타입만 명세한다. "FRP에서 모든 상태는 오직 관계로만 저장된다 — 예외는 없다." 그리고 사용자가 직접 입력한 것일 때만 본질적 상태로 다루기를 강하게 권장한다.
② 본질적 로직
Essential Logic
파생 relvar 정의(관계 대수) + 무결성 제약(항상 참이어야 하는 불리언 식) + 임의의 순수 사용자 정의 함수. 상태를 수정하려는 모든 시도는 언제나 무결성 제약의 적용을 받는다.
③ 우발적 상태와 제어
Accidental State and Control
서로를 어떤 식으로도 참조할 수 없는 고립된 성능 "힌트"들의 나열. 선언적이어야 한다. 예: "이 파생 relvar는 매번 계산하지 말고 실제로 저장하라", "이 속성 부분집합은 따로 저장하라", "인덱스를 써라", "이건 즉시 계산하고 저건 지연 계산하라".
④ Other
Feeders / Observers
Feeder는 입력을 관계 대입으로 변환한다. Observer는 (파생) relvar의 변화에 반응해 출력한다 — live query이자 트리거 역할. 둘 다 최소한이어야 하며, 관계로/에서의 필요한 변환만 수행한다.
🚨 가장 강한 문장 하나

본질적 로직을 설계할 때 "absolutely NO consideration is paid to performance issues of any kind here." 그리고 이어서 — "'성능을 위한 비정규화(denormalization for performance)' 같은 개념은 이 맥락에서 전혀 말이 되지 않는다." 물리 저장이 여기서 명세하는 관계 구조를 그대로 따라간다는 가정이 깔려 있는데, 전혀 그렇지 않기 때문이다.

⚠️ Observer로 상태를 바꾸지 말 것

논문이 명시적으로 금지한다. "hybrid feeders / observers should not act as triggers which directly update the essential state" — 그렇게 하면 정의상 파생 상태, 즉 우발적 상태를 만드는 것이기 때문이다. 오늘날 "이벤트를 받아서 다른 테이블을 업데이트하는" 트리거·리스너가 정확히 이 함정이다.

12. 오늘의 코드로 번역하기

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 OrderBook {
    private(set) var orders: [OrderID: Order] = [:]

    // 무결성 제약 — 항상 참이어야 하는 불리언 식들
    private static func invariants(_ s: [OrderID: Order]) -> Bool {
        s.values.allSatisfy { $0.total >= 0 }
        && s.values.filter { $0.status == .shipped }.allSatisfy { $0.paidAt != nil }
    }

    // 상태를 바꾸는 유일한 통로 — 모든 변경이 제약의 적용을 받는다
    mutating func apply(_ change: (inout [OrderID: Order]) -> Void) throws {
        var next = orders
        change(&next)
        guard Self.invariants(next) else { throw OrderError.invariantViolated }
        orders = next
    }
}

③ 성능은 "결과를 바꿀 수 없는 자리"에만 넣는다

✅ 정답 함수와 캐시를 분리한다 — 캐시를 지워도 결과가 같다
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
    }
    func invalidateAll() { memo.removeAll() }   // 지워도 결과는 같다. 느려질 뿐.
}
🔑 셀프 체크 한 문장

"이 캐시를 통째로 지우면 결과가 달라지는가?" 달라진다면 우발적 상태가 본질적 상태에 섞인 것이다 — 분리 대상이다. 달라지지 않는다면 제대로 분리된 것이다.

이 논문의 자식들

functional core / imperative shell
순수한 코어를 얇은 명령형 껍질이 감싼다. FRP의 ①②를 코어로, ④를 껍질로 본 형태.
Redux · Elm 아키텍처
단일 상태 트리(본질적 상태) + 순수 리듀서·셀렉터(본질적 로직) + 미들웨어(인터페이싱).
이벤트 소싱 · CQRS
이벤트가 본질적 상태, 읽기 모델이 명시적으로 파생·우발. 읽기 모델을 언제든 재구축할 수 있다는 게 핵심.
Datalog · Datomic
관계 + 선언적 질의 + 불변 사실. 논문의 FRP에 가장 가까운 실제 시스템 중 하나.
✅ 코드 리뷰에서 바로 쓸 수 있는 질문 5개
  • 이 프로퍼티는 다른 값으로부터 계산할 수 있는가? → 가능하면 저장하지 말 것
  • 이 불리언들이 동시에 참일 수 있는 조합이 실제로 존재하는가? → 아니면 열거형으로
  • 이 캐시를 지우면 결과가 달라지는가? → 달라지면 본질에 우발이 섞인 것
  • 이 순서가 정말 필요한가, 아니면 언어가 시켜서 쓴 것인가?
  • 사용자가 이게 뭔지 아는가? → 모르면 우발적 복잡도다. 격리 대상.

이미 커져 버린 기존 시스템이라면

FRP는 새로 짓는 시스템을 전제한다. 그런데 논문은 그 분리를 직접 적용할 수 없는 경우를 위해 결론부에 별도의 조언을 남긴다. 지금 우리 대부분이 여기 해당한다.

🔑 원문 — 기존 대형 시스템을 위한 조언

"In cases (such as existing large systems) where this separation cannot be directly applied we believe the focus should be on avoiding state, avoiding explicit control where possible, and striving at all costs to get rid of code."

— 우선순위가 셋으로 정해져 있다. ① 상태를 피하고, ② 가능한 곳에서 명시적 제어를 피하고, ③ 무슨 수를 써서라도 코드를 없앤다. 아키텍처를 갈아엎으라는 말이 아니다. 오늘 PR에서 할 수 있는 일들이다.

그래서 늪에서 나가는 길은 무엇인가

제목이 던진 질문에 논문은 마지막 문단에서 직접 답한다. 그리고 그 답은 자기가 66쪽에 걸쳐 만든 FRP가 아니다.

🚨 논문의 마지막 문장

"So, what is the way out of the tar pit? What is the silver bullet? . . . it may not be FRP, but we believe there can be no doubt that it is simplicity."

— 그래서 타르 늪에서 나가는 길은 무엇인가? 은탄환은 무엇인가? … 그것이 FRP는 아닐지 모르지만, 단순함이라는 데에는 의심의 여지가 없다고 우리는 믿는다.

Brooks의 "은탄환은 없다"에 대한 최종 응답이자, 이 논문 전체의 요약이다. FRP는 수단이고, 목적은 단순함이다. 그래서 관계 모델을 안 쓰더라도, Swift로 짜더라도, 이 논문이 요구하는 것은 그대로 남는다 — 상태를 줄이고, 제어를 줄이고, 남은 것은 격리하라.

출처 · 참고자료