# A Philosophy of Software Design ::: INFO John Ousterhout, 『A Philosophy of Software Design』, Yaknyam Press, 2018 ::: 저자는 소프트웨어 설계가 프로그래밍에서 가장 근본적인 문제임에도 불구하고 체계적으로 가르쳐지지 않고 있다고 지적한다. 저자가 정의한 컴퓨터 과학의 근본 문제는 '문제 분해'다. 소프트웨어는 물리적 시스템과 달리 매우 유연하기 때문에 초기에 모든 설계를 확정하는 폭포수 모델보다는 점진적 접근이 더 적합하다. 점진적 개발은 설계가 결코 끝나지 않음을 의미한다. 이 책의 두 가지 목표는 복잡성의 본질을 이해하고, 복잡성을 최소화하는 기법을 제시하는 것이다. ::: NOTE 작은 모듈이 아니라 깊은 모듈을 지향하라는 조언이 핵심. 자바 생태계 특유의 번잡스러운 인터페이스 설계를 지적해서 속이 다 시원했다. 12장부터는 이렇게 많은 페이지를 할애했어야 했나싶기도. 클린코드와 배치되는 주장들은 흥미롭고 공감됐다. ::: ## Introduction 소프트웨어 개발에서 가장 큰 제약은 우리가 만드는 시스템을 이해하는 능력이다. 프로그램이 진화하고 기능이 추가될수록 복잡성이 쌓이고, 개발 속도가 느려지며 버그가 늘어난다. 복잡성에 맞서는 두 가지 방법이 있다. - 복잡성 제거: 코드를 더 단순하고 명확하게 만든다. - 복잡성 캡슐화: 모듈러 설계를 통해 개발자가 한 번에 모든 복잡성에 노출되지 않도록 만든다. ## The Nature of Complexity > Complexity is anything related to the structure of a software system that makes it hard to understand and modify the system. 복잡성은 절대적 크기가 아닌 개발자가 특정 시점에 특정 목표를 달성하려 할 때 경험하는 것이다. 크고 정교한 시스템이라도 작업하기 쉽다면 복잡하지 않다. 따라서 복잡성을 격리하는 것은 제거하는 것만큼 효과적이다. 복잡성에는 세 가지 증상이 있다. - 변경 증폭: 단순한 변경이 여러 곳의 코드 수정을 요구하는 경우. e.g., 웹 사이트 배경색이 각 페이지에 직접 지정되어 있으면, 색상 변경 시 모든 페이지를 수정해야 한다. - 인지 부하: 개발자가 작업을 완료하기 위해 알아야 할 정보의 양이 많은 경우. e.g., C 언어에서 메모리를 할당하고 포인터를 반환하면서 호출자가 메모리를 해제해야 하는 경우. 코드 줄 수가 적다고 단순한 것이 아니다. - 미지의 미지(Unkonwn unkonwns): 과업을 달성하기 위해 무엇을 알아야 하는지조차 모르는 상태. 세 가지 증상 중 가장 최악이다. 복잡성에는 두 가지 원인이 있다. - 의존성: 코드를 독립적으로 이해하거나 수정할 수 잆는 상태. 의존성을 완전히 제거할 수는 없지만, 수를 줄이고 남은 것을 단순화해서 명확하게 만들 수 있다. - 불명확성: 중요한 정보가 명확하지 않은 상태. 변수명이 너무 일반적이거나, 문서가 단위를 명시하지 않거나, 비일관적인 네이밍이 원인이 된다. 복잡성은 점진적이다. 한 번의 변경에서 추가되는 약간의 복잡성은 대수롭지 않아 보이지만, 수백, 수천 개가 쌓이면 모든 변경이 영향을 받게 된다. ## Working Code Isn't Enough 대부분의 프로그래머는 일단 동작하게 만들자는 전술적 마인드셋으로 개발한다. 단기적으로는 합리적으로 보이지만, 복잡성의 축적을 초래한다. 전술적 토네이도는 누구보다 빠르게 코드를 쏟아내지만 완전히 전술적으로 작업하는 개발자다. 경영진은 영우으로 대하지만, 뒤에 남는 파괴의 흔적을 치워야 하는 것은 다른 개발자들이다. 반면 전략적 프로그래밍은 투자 마인드셋을 요구한다. 단기적으로는 10~20% 느려질 수 잇지만, 장기적으로는 더 빠르다. 투자는 두 가지 형태를 띈다: (1) 깔끔한 설계를 위해 여러 대안을 고려하고 좋은 문서를 작성하는 능동적 투자, (2) 설계 문제를 발견했을 때 패치가 아닌 근본적인 문제를 수정하는 반응적 투자. 페이스북의 "Move fast and break things" 문화는 전술적 접근의 대표적 사례다. 결국 코드가 불안정해져 "Move fast with solid infrastructure"로 모토를 변경해야 했다. 반면 구글과 VMware는 전략적 접근으로 성공했으며, 높은 코드 품질을 우수한 엔지니어를 채용하는 수단으로 활용했다. ## Modules Should Be Deep > The best modules are those whose interfaces are much simpler than their implementations. 모듈(클래스, 서브시스템, 서비스 등)은 인터페이스와 구현으로 구성된다. 인터페이스는 모듈이 무엇을 하는지를, 구현은 어떻게 하는지를 나타낸다. 인터페이스에는 형식적 부분(시그니처, 타입 등)과 비형식적 부분(고수준 동작, 사용 제약 등)이 있다.대부분의 인터페이스에서 비형식적 부분이 더 크고 복잡하다. 추상화는 엔티티에서 중요하지 않은 세부 사항을 생략한 단순화된 뷰다. 추상화가 잘못되는 두 가지 경우가 있다: (1) 불필요한 세부사항이 포함되면 인지 부하가 증가한다. (2) 중요한 세부사항이 누락되면 단순해 보이지만 실제로는 그렇지 않은 상태가 된다. 모듈을 직사각형으로 상상해보자. 넓이는 기능, 윗변의 길이는 인터페이스의 복잡성이다. 최고의 모듈은 깊다. 단순한 인터페이스 뒤에 많은 기능이 숨어있다. 좋은 모듈의 예시는 [[unix]] I/O다. 5개의 시스템 콜(`open`, `read`, `write`, `lseek`, `close`)로 파일 I/O의 모든 것을 다룬다. 내부적으로는 디스크 블록 할당, 디렉토리 관리, 권한 제어, 캐싱 등 복잡한 구현이 숨겨져 있다. > Module depth is a way of thinking about cost versus benefit. The benefit provided by a module is its functionality. The cost of a module (in terms of system complexity) is its interface." 얕은 모듈의 예시는 자바의 I/O다. 파일에서 직렬화된 객체를 읽으려면 `FileInputStream`, `BufferedInputStream`, `ObjectInputStream` 세 개의 객체를 생성해야 한다. 버퍼링이 기본이 아닌 별도 객체라는 점이 특히 문제다. [[unix]] 설계자들은 순차적 I/O를 기본으로 만들어 일반적인 I/O 동작을 단순하게 처리했다. ## Information Hiding 깊은 모듈을 만드는 가장 중요한 기법은 정보 은닉이다. 정보를 은닉하는 두 가지 방법으로 복잡성을 줄일 수 있다: (1) 인터페이스를 단순화해 인지 부하를 줄인다. (2) 변경이 한 모듈에만 영향을 주도록 만들어 시스템의 진화를 쉽게 한다. 설계 결정이 여러 모듈에 반영되면 정보 누수가 일어난다. 특히 위험한 것은 백도어 누수다. 인터페이스에 나타나지는 않지만 여러 모듈이 같은 정보를 공유하는 경우, 인터페이스를 통한 누수보다 더 교활해진다. 특히 프로퍼티를 private으로 선언하는 것이 정보 은닉은 아니다. getter, setter를 통해 정보가 노출되면 public과 다를 바가 없다. 파일을 읽고, 수정하고, 쓰는 프로그램에서 읽기/수정/쓰기를 세 개의 클래스로 나누면 파일 포맷에 대한 정보가 누수된다. 모듈 구조는 작업 순서가 아니라 각 작업에 필요한 정보를 기준으로 결정해야 한다. 학생들이 HTTP 서버를 구현할 때 가장 흔히 범하는 실수는 너무 많은 얕은 클래스를 만들어 정보 누수를 일으키는 것이었다. 예를 들어, 요청 읽기와 파싱을 별도 클래스로 나누면 두 클래스 모두 HTTP 요청 구조를 알아야 한다. 하나의 클래스로 합치는 것이 더 나은 은닉이다. ## General-Purpose Modules are Deeper > The phrase 'somewhat general-purpose' means that the module's functionality should reflect your current needs, but its interface should not. 인터페이스는 현재의 필요에 묶이지 않으면서도, 현재의 필요를 쉽게 충족할 수 있을만큼 범용적이어야 한다. 학생들이 텍스트 에디터를 만들 때 특수 목적 API를 만들곤한다. ```java void backspace(Cursor cursor); void delete(Cursor cursor); void deleteSelection(Selection selection); ``` 더 나은 접근은 범용 API를 만드는 것이다. ```java void insert(Position position, String newText); void delete(Position start, Position end); ``` 범용 API는 메서드 수가 적고, UI와 텍스트 클래스 사이의 정보 누수를 줄이며, 다른 용도로도 재사용이 가능하다. 특수 목적 API의 `backspace` 메서드는 거짓 추상화다. 문자 삭제에 대한 정보를 은닉하는 것처럼 보이지만, 사용자 인터페이스 모듈은 실제로 이 정보를 반드시 알아야 한다. 인터페이스를 만들 때 자문해보자. - 현재의 필요를 충족하는 가장 단순한 인터페이스는 무엇인가? - 이 메서드가 몇 가지 상황에서 사용될 수 있는가? - 현재의 필요에 이 API가 사용하기 쉬운가? ## Different Layer, Different Abstraction 잘 설계된 시스템에서 각 레이어는 다른 추상화를 제공한다. 같은 추상화가 반복되면 그 레이어는 존재 가치를 정당화하기 어렵다. 패스-스루(Pass-Through) 메서드는 아무 기능도 추가하지 않으면서 인터페이스 복잡성만 증가시키는 메서드다. 이는 클래스간 책임 분배가 잘못되었다는 신호다. 시그니처가 거의 같은 메서드에 인자를 그대로 전달하는 메서드는 적신호다. 데코레이터 패턴은 종종 얕은 레이어의 동기가 된다. 데코레이터를 만들기 전에 기존 클래스에 직접 추가할 수 있는지, 기존 클래스에 병합할 수 있는지, 독립적인 클래스로 만들 수 있는지 고려해야 한다. 패스-스루 변수는 여러 메서드를 거쳐 전달되지만 중간 메서드에서는 사용되지 않는 변수다. 해결책으로는 컨텍스트 객체가 인다. 컨텍스트 객체는 시스템 전체 정보의 처리를 통합하고, 패스-스루 필요성을 줄인다. 다만 컨텍스트는 전역 변수의 단점을 갖고 있으므로 재약이 필요하다. ## Pull Complexity Downwards 모듈의 구현을 단순하게 만든 것보다, 인터페이스를 단순하게 만드는 것이 중요하다. 모듈 개발자는 소수지만 사용자는 다수이기 때문이다. 모듈 개발 중 피할 수 없는 복잡성을 발견했을 때, 사용자에게 넘기기보다 모듈 내부에서 처리하는 것이 대부분 옳은 결정이다. 설정 파라미터(configuration parameters)를 복잡성을 위로 올리는 대표적인 사례다. 사용자가 적절한 값을 결정하기 어렵거나, 시스템이 스스로 합리적인 값을 계산할 수 있는 경우가 많다. 복잡성을 아래로 끌어내리는 것이 합리적인 경우는 다음과 같다. - 끌어내리는 복잡성이 클래스의 기존 기능과 밀접할 때. - 다른 곳에서 많은 단순화가 이루어질 때. - 클래스 인터페이스가 단순해질 때. ## Better Together Or Better Apart? 코드를 합칠지 분리할지 결정할 때, 목표는 전체 시스템의 복잡성 감소다. 코드의 세분화는 추가 복잡성을 만들 수 있다. 컴포넌트 수 자체가 복잡성을 더하고, 관리할 코드가 늘어난다. 또한 분리로 인해 관련 코드를 한 눈에 보기 어려워진다. 합치는 것이 좋은 경우는 다음과 같다. - 정보를 공유할 때. 같은 정보를 사용하는 코드는 한 곳에 둔다. - 인터페이스가 단순해질 때. 여러 단계를 하나의 메서드로 만든다. - 중복을 제거할 때. 코드 중복은 반복(repetition)에 대한 적신호다. 특수 목적 코드가 범용 매커니즘과 섞이면 관련이 없는 복잡성이 추가되어 범용 매커니즘을 이해하기 어려워진다. 코드가 수백 줄이라도 단순한 시그니처와 읽기 쉬운 구조를 가지면 괜찮다. 코드 분할이 합리적인 경우는 (1) 독립적으로 이해 가능한 하위 작업을 분리하는 경우, (2) 원래 메서드가 밀접하지 않은 여러 일을 하는 경우다. 한 메서드의 구현을 이해하려면 다른 메서드의 구현을 알아야 하는 경우 적신호다. ## Define Errors Out Of Existence > Exception handling is one of the worst sources of complexity in software systems. 예외 처리 코드는 정상 경로 코드보다 본질적으로 작성하기 어렵고, 테스트하기도 어렵다. 연구에 따르면 분산 데이터 집약 시스템의 치명적 실패 중 90% 이상이 잘못된 예외 처리 때문이었다. 프로그래머들이 과도하게 방어적으로 코딩해서 불필요한 예외를 던지는 경향이 있다. 예외를 던지는 것은 쉽지만, 예외를 처리하는 것은 어렵다. 예외를 줄이는 기법은 예외가 발생하지 않도록 시맨틱을 재정의하는 것이다. 윈도우즈에서 열린 파일을 삭제하면 오류가 일어난다. [[unix]]는 파일 이름만 제거하고 열려 있는 핸들러는 계속 사용할 수 있게 만들어 오류 자체를 제거했다. 그 외에 저수준 레이어에서 예외를 감지하고 처리하거나, 여러 예외를 하나의 핸들러로 처리하는 것도 가능하다. OOM과 같이 실제로 복구 불가능한 에러는 크래시를 일으키는 것이 최선이다. ## Design it Twice > 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. 모든 주요 설계 결정에서 최소 두 가지 이상의 대안을 고려해야 한다. 가능하면 근본적으로 다른 접근법을 시도하는 것이 좋다. 각 대안의 장단점 비교 기준은 (1) 인터페이스의 단순성, (2) 범용성, (3) 효율적인 구현 가능성이다. 이 원칙은 인터페이스 설계, 구현 방식, 시스템 분해 등 모든 수준에 적용된다. 똑독한 사람들은 첫 아이디어만으로 충분하다고 생각하는 나쁜 습관을 가지기 쉬운데, 소프트웨어 설계는 누구도 처음에 올바르게 설계하지 못할만큼 어려운 문제다. ## Why Write Comments? 문서화는 추상화에서 중요한 역할을 한다. 주석없이는 복잡성을 숨길 수 없다. 클린 코드의 영향으로 프로그래머 사이에 주석에 대한 잘못된 믿음이 퍼져있다. 저자는 이를 반박한다. 1. 좋은 코드는 자기 문서화된다? 코드 자체는 비형식적인 인터페이스를 표현할 수 없다. 2. 주석을 작성할 시간이 없다? 개발 시간의 10% 이하만 타이핑에 사용되므로, 주석 작성은 전체 개발 시간의 10% 이상을 더하지 않는다. 좋은 문서가 향상하는 유지보수성의 효과가 이를 즉시 상쇄한다. 3. 주석이 오래되어 오해를 준다? 큰 코드 변경에는 큰 문서 변경이 수반된다. 코드 리뷰를 통해 오래된 주석을 잡아낼 수도 있다. 4. 내가 본 주석은 다 쓸모 없었다? 좋은 주석 작성법을 배우면 해결된다. > The overall idea behind comments is to capture information that was in the mind of the designer but couldn't be represented in the code." ## Comments Should Describe Things that Aren't Obvious from the Code 주석의 모든 정보가 바로 옆에 있는 코드에서 즉시 파악 가능한 경우에는 주석을 작성해서는 안 된다. 저수준 주석은 디테일을 더한다. 변수 주석은 코드에서 명확하지 않은 정보를 포함해야 한다. 단위나 경계 조건, null 여부, 자원 관리 책임 등을 설명할 수 있다. 고수준 주석은 직관을 강화한다. 구현 주석은 코드가 무엇을 하는지와 왜 하는지 설명하되, 어떻게 하는지는 설명하지 않는다. 인터페이스 주석은 추상화를 정의하고, 구현 주석은 추상화의 실현 방법을 설명한다. 인터페이스 주석이 구현 세부사항을 설명해야 한다면 그 모듈이 너무 얕다는 적신호다. ## Choosing Names 이름만으로 해당 엔티티가 무엇인지 명확한 그림이 떠올라야 한다. 정확하고 일관성있는 이름을 지어야 한다. 버그를 유발하는 나쁜 이름도 있다. 파일 시스템에서 `block`이라는 이름이 파일 블록과 디스크 블록 두 가지 의미로 사용되어 실제 버그로 이어진 사례가 있다. Go 언어의 스타일 가이드는 매우 짧은 이름을 선호한다. 저자는 가독성은 독자가 결정하는 것이지 저자가 결정하는 것이 아니라고 반박한다. 단, 선언과 사용 사이의 거리가 멀수록 이름이 길어야 한다는 점에는 동의한다. ## Write The Comments First 주석을 작성하기 가장 좋은 시점은 코드를 작성하기 전이다. 주석은 설계 도구다. 메서드나 변수에 긴 주석이 필요하다면 좋은 추상화가 아니라는 적신호다. 인터페이스 주석과 구현을 비교하면 메서드의 깊이를 판단할 수 있다. 완전한 주석을 작성하기 어렵다면 설명하려는 대상의 설계에 문제가 있을 수 있다는 적신호다. ## Modifying Existing Code 기존 코드를 수정할 때도 전략적 접근이 필요하다. "현재 필요한 최소한의 변경"이라는 마인드셋은 전술적 프로그래밍이며, 복잡성을 점진적으로 축적한다. ## Consistency 일관성은 인지 부하를 줄이고, "미지의 미지"를 줄인다. 같은 목적에는 같은 이름을 짓는 것, 중괄호의 위치나 들여쓰기, 동일한 인터페이스의 다양한 구현, 반복적인 디자인 패턴이 일관성의 예시다. 일관성을 보장하려면 문서화, 자동화, 코드 리뷰를 통한 가치 공유, 기존 코드 존중이 필요하다. ## Code Should be Obvious 명확성은 독자가 판단한다. 코드 리뷰에서 다른 사람이 명확하지 않다고 하면, 당신에게 아무리 명확해도 그 코드는 명확하지 않은 것이다. 코드를 명확하게 만드려면 좋은 이름과 일관성, 주석이 있어야 한다. 코드를 명확하지 않게 만드는 것의 예시는 다음과 같다. - 이벤트 구동 프로그래밍: 제어 흐름을 따라가기 어렵다. - 제네릭 컨테이너: `Pair` 보다 의미있는 이름의 전용 타입이 더 명확하다. - 선언과 할당의 타입 불일치: `List`로 선언하고 `ArrayList`로 할당하면 혼란스럽다. - 독자의 기대를 위반하는 코드: 예상과 다르게 동작하면 반드시 문서화가 필요하다. ## Software Trends **객체지향 프로그래밍과 상속** 인터페이스 상속은 같은 인터페이스의 여러 구현을 통해 추상화의 깊이를 더하므로 긍정적이다. 구현 상속은 코드의 중복을 줄이지만, 부모-자식간 의존성과 정보 누수를 만든다. 가능하면 합성(composition)이 낫다. **애자일 개발** 점진적 개발은 좋지만, 전술적 프로그래밍으로 이어질 위험이 있다. > Developing incrementally is generally a good idea, but the increments of development should be abstractions, not features. **단위 테스트** 테스트는 리팩토링을 가능하게 만든다. 테스트가 없으면 구조적 변경이 위험해 북잡성이 축적된다. **테스트 주도 개발** 저자는 단위 테스트를 강력히 지지하지만, TDD에는 회의적이다. > 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. **디자인 패턴** 일반적으로 유용하지만, 과도한 적용은 위험하다. 모든 문제가 기존 패턴으로 깔끔하게 해결되지는 않는다. **Getter/Setter** 인스턴스 변수 자체를 노출하지 않는 것이 더 좋은 정보 은닉이다. > Getters and setters are shallow methods (typically only a single line), so they add clutter to the class's interface without providing much functionality. ## Designing for Performance 모든 구문을 최적화하면 개발이 느려지고 불필요한 복잡성이 생기지만, 성능을 완전히 무시하면 5-10배 느린 시스템이 된다. 자연스럽게 효율적인 설계를 선택해야 한다. 네트워크 레이턴시나 디스크 IO, 동적 메모리 할당 등 비용이 큰 연산은 인지하고 있어야 한다. 프로파일링 없이 직관으로 최적화하면 실제 효과는 없이 복잡성만 늘어난다. 먼저 측정하고, 성능에 가장 큰 영향을 주는 소수의 임계 경로(critical path)를 파악한 후 최적화해야 한다. 임계 경로에서 특수 케이스 검사는 최소화하고, 이상적으로는 하나의 테스트로 모든 특수 케이스를 감지할 수 있어야 한다. ## Conclusion > The reward for being a good designer is that you get to spend a larger fraction of your time in the design phase, which is fun. Poor designers spend most of their time chasing bugs in complicated and brittle code. 좋은 설계는 초기에 추가 작업을 요구하지만, 투자는 빠르게 회수할 수 있다. 설계 능력이 성장하면 좋은 설계를 더 빠르게 만들 수 있게 되고, 설계 과정에서 더 많은 시간을 보내게 되는데, 이것이 프로그래밍에서 가장 즐거운 부분이다. ## 관련문서 - [[clean-code-myth]] - [[unix-philosophy]]