라이브러리 계약
애플리케이션은 사용자에 의해 평가됩니다. 라이브러리는 프로그래머에 의해 평가되며, 특정 순간에 평가됩니다 — 바로 작성자가 상상하지 못한 무언가를 원할 때입니다. 좋은 라이브러리는 의도된 대로 사용될 때 편리한 라이브러리가 아닙니다. 다른 방식으로 사용될 때 폐기되지 않아도 되는 라이브러리입니다.
아래의 모든 규칙은 하나의 비대칭성에서 비롯됩니다. 라이브러리 내부에서는 무엇이든 영원히 변경할 수 있습니다. 외부에서는 호출자가 관찰할 수 있는 모든 것이 누군가가 처음으로 의존하는 순간 고정됩니다 — 그리고 충분한 호출자가 있다면, 누군가는 그것의 모든 것에 의존하게 됩니다.
무엇이 고정되며, 언제 고정되는가
하이럼의 법칙은 작동 조건이지 호기심이 아닙니다: 충분한 사용자가 주어지면, 모든 관찰 가능한 동작은 하중을 견디게 됩니다 — 예외 문구, 열거 순서, 로그 출력, 타이밍, 심지어 문서화된 정의되지 않은 동작까지도 포함됩니다. "계약에 없었다"는 그것을 기준으로 출시한 호출자를 구제하지 못합니다.
따라서 고정선은 public 키워드가 아닌 관찰 가능성에 있습니다.
그리고 세 가지 결과가 따릅니다:
- 표면은 작게 시작합니다. 한 번 공개된 심볼은 넓힐 수는 있지만 조용히 철회할 수 없는 약속입니다. "당분간 public"은 이득 없이 부담만 지는 빚입니다; 필요할 때 노출하십시오.
- 포맷은 코드보다 오래갑니다. 파일 포맷, 네트워크 프로토콜, 영구 스키마는 라이브러리를 둘러싼 모든 재작성을 견딥니다. 내부 구조체를 있는 그대로 직렬화하는 것은 고정된 계층을 편집 가능한 계층에 용접하는 것입니다.
- 인앱 모듈은 패키징 시점이 아니라 두 번째 소비자 시점에 이 선을 넘습니다. 두 호출자가 존재하는 순간, 그 API는 게시된 패키지와 동일한 고정 경제학을 따릅니다 — 단지 폭발 반경이 더 작을 뿐입니다.
의미론적 버전 관리(SemVer)는 동일한 선을 공개적으로 표기한 것이며, 하나는 없으면 작동을 거부하는 것으로 시작합니다: "의미론적 버전 관리를 사용하는 소프트웨어는 공개 API를 선언해야 합니다" (SemVer 2.0.0, 1항). 일단 선언되면, 증분은 느껴지는 것이 아니라 결정됩니다 — 해당 API에 대한 하위 호환되지 않는 변경은 MAJOR를 올려야 하고(8항), 새로운 하위 호환 기능은 MINOR를 올려야 하며(7항), 하위 호환 버그 수정은 PATCH를 올려야 합니다(6항). 하이럼의 법칙이 선언을 어렵게 만드는 이유입니다: 스펙은 선언한 것을 고정하고, 호출자는 관찰한 것을 고정합니다.
스펙은 아직 선을 넘지 않은 모듈에 대한 이름도 가지고 있습니다. "주 버전 0(0.y.z)은 초기 개발을 위한 것입니다. 무엇이든 언제든지 변경될 수 있습니다" (4항). 이것이 하나의 소비자를 가진 모듈에 대한 정직한 레이블이며, 두 번째 소비자가 생기는 날은 패키징되는 날이 아니라 1.0.0이 필요한 날입니다.
제어권을 빼앗지 말라
메커니즘과 정책을 분리하십시오. HTTP 요청을 수행하는 것은 메커니즘입니다. 지수 백오프로 세 번 재시도하는 것은 정책이며, 핵심에 정책이 내장되면 라이브러리는 그 정책을 공유하는 호출자로 범위가 좁혀집니다. 로깅, 스레딩, 재시도, 타임아웃, 캐싱, 직렬화에 대한 편리한 기본값을 제공하십시오 — 그리고 모든 것을 교체 가능하게 만드십시오.
애플리케이션의 역할은 단 하나이며, 그것은 여러분의 것이 아닙니다. 프로세스 종료, stdout에 쓰기, SIGINT 트랩, 최상위 예외 처리기 설치, 스레드 풀 크기 조정: 프로세스당 정확히 하나의 컴포넌트만 이 작업을 수행할 수 있으며, 이를 수행하는 라이브러리는 그 역할을 가진 컴포넌트와 싸우게 됩니다. 프로세스의 입력을 읽는 것도 동일한 역할에 속합니다: 환경 변수, 구성 파일 및 명령줄 인수는 애플리케이션 루트에서 읽히며, 이 루트는 각 값을 라이브러리에 매개변수나 옵션 객체로 전달합니다. 그러면 호출자는 값의 출처를 선택할 수 있으며, 테스트는 머신을 설정하는 대신 값을 전달합니다.
매직 — 자동 감지, 자동 변환, 자동 재연결, 자동 캐시 — 은 호출자가 왜 그런 일이 발생했는지 재구성할 수 있는 경우에만 허용됩니다. 사용자가 디버깅할 수 없는 편의는 작업을 절약하는 것이 아니라 제어권을 빼앗는 것입니다.
숨은 비용을 추가하지 말라
일이 일어나는 시점은 호출자의 몫입니다. 스레드를 시작하는 임포트, 연결을 여는 생성자, 환경 변수를 읽는 정적 초기화자 — 각각은 호출자가 결코 선택하지 않았고 되돌릴 수 없는 순간에 실제 작업을 이동시킵니다.
의존성은 여러분의 API의 일부입니다. 그것의 런타임, 업데이트 주기, ABI 안정성, 라이선스, 자체 초기화 부작용은 모두 모든 호출자에게 전달됩니다; 여러분은 그들을 대신하여 선택하고 있는 것입니다.
성능 특성은 계약입니다. O(1) 조회를 O(n)으로 바꾸거나 할당 및 복사를 두 배로 늘리는 것은 호환되는 서명을 가진 호환성을 깨는 변경이며, 호출자는 이를 프로덕션에서 발견합니다.
입력이 출력보다 빠르게 도착하는 곳 — 스트림, 이벤트, 로그, 큐 — 에서 무제한 내부 버퍼는 지연된 메모리 누수입니다. 역압(Backpressure)은 운영 세부 사항이 아니라 설계의 일부입니다.
추상화 자체의 기준은 제로-코스트이며, 스트로우스트룹의 두 조항 모두에서 그렇습니다. 호출자가 사용하지 않는 것에 대해 비용을 지불하지 않습니다: 편의 계층은 기본 계층에 할당, 디스패치 또는 설정 비용을 부과해서는 안 됩니다. 그리고 호출자가 사용하는 것은 손으로 작성한 것보다 합리적으로 더 잘 작성할 수 없어야 합니다: 손으로 작성한 코드에 의해 능가되는 추상화는 우회되며, 우회된 라이브러리는 버전 번호만 남은 폐기물입니다.
숨은 상태를 유지하지 말라
싱글톤, 정적 캐시, 전역 이벤트 루프, 프로세스 전역 구성: 각각은 여러분의 라이브러리가 프로세스에서 유일할 때는 작동하지만, 두 번째 라이브러리, 플러그인 호스트 또는 병렬 테스트 스위트가 공유하는 순간 충돌합니다. 상태는 호출자가 생성하고, 소유하며, 두 번 생성할 수 있는 인스턴스에 존재해야 합니다.
실패를 숨기지 말라
API 형태는 무엇이 발생할 수 있고 누가 책임을 지는지 선언합니다. 오류, 취소, 타임아웃, 부분 성공, 부재 — 타입으로 모델링하고, "이건 정상적으로는 절대 발생하지 않아"와 예외를 통해 우회하지 마십시오. 동일한 형태는 주석이 강제할 수 없는 소유권 답변을 전달합니다: 누가 스트림을 닫는지, 버퍼가 재사용될 수 있는지, 콜백이 도착하는 스레드는 무엇인지.
원인을 보존하십시오.
실패를 false나 null로 축소하는 것은 상위 계층이 결정을 내리는 데 필요한 정확한 정보를 제거합니다;
직접 로깅하고 계속 진행하는 것은 동일한 정보를 잃고 호출자의 로깅 정책을 빼앗습니다.
품질은 비정상 경로에서 판가름납니다. 정상 경로에서는 라이브러리가 서로 교체 가능합니다; 타임아웃, 재연결, 스트림 중간 연결 끊김, 부분 읽기, 손상된 데이터 및 재시도가 차이가 있는 부분이므로, 먼저 그것들의 구성을 위해 설계하십시오.
취소는 나중에 추가하기 어렵습니다 — 취소 토큰은 그것이 닿는 모든 서명을 관통합니다. I/O 및 장기 실행 작업은 첫 번째 버전부터 이를 받아들입니다.
탈출을 막지 말라
두 개의 층을 구축하십시오.
일반적인 경우를 한 줄로 처리하는 고수준 API를,
공개 프리미티브 위에 올리십시오 — ReadAsync() 위에 ReadAsync(Memory<byte>) — 그래서 최상위 층에 맞지 않는 요구 사항을 가진 호출자가 떠나는 대신 아래로 내려갈 수 있습니다.
최상위 층만 있는 라이브러리는 예상하지 못한 첫 번째 요구 사항에서 폐기됩니다.
관찰 가능성은 주입되는 것이지 소유되는 것이 아닙니다: 로거, 메트릭, 트레이스는 호출자의 스택에 연결됩니다. 여러분의 라이브러리만 읽을 수 있는 진단 형식은 인질로 잡힌 원격 측정입니다.
하나의 세금, 두 개의 통화
이 기술과 클린 아키텍처는 두 개의 다른 통화로 표현된 동일한 거부입니다. 제로-코스트 추상화는 런타임 성능에 대한 세금을 거부합니다; 의존성 규칙은 변경 가능성에 대한 세금을 거부합니다. 둘 다에서 추상화는 의존하는 쪽이 포기해야 하는 것으로 평가됩니다.
| 여기서의 의무 | 아키텍처적 명칭 |
|---|---|
| 메커니즘과 정책 분리 | 유스케이스를 프레임워크 및 드라이버와 분리 |
| 기본값 교체 가능 | 의존성 역전 — 안정적인 쪽이 구현을 선택 |
| 편의 층 아래의 프리미티브 | 외부 세부 사항 교체 가능 |
| 전역 상태 없음 | 의존성이 명시적이므로 경계를 그릴 수 있음 |
| 의존성은 API의 일부 | 의존성 규칙 |
호출자의 건강한 형태는 여러분의 라이브러리를 맨 아래에 둡니다: 그들의 정책과 도메인 아래에 있는 교체 가능한 세부 사항입니다. 반전된 형태 — 애플리케이션이 프레임워크 안에 살며, 그의 생명주기, 타입, DI, 오류 모델, 비동기 모델을 끌고 다니는 것 — 은 라이브러리가 애플리케이션 역할을 하며, 아키텍처 규모로 작성된 것입니다.
일반적인 형태: 좋은 아키텍처는 의존성의 수를 최소화하는 것이 아니라 각 의존성이 빼앗는 자유를 최소화합니다.
문장
라이브러리의 추상화는 호출자의 선택을 넓히기 위해 존재합니다. 성능, 제어권 또는 정보를 대가로 가져가는 순간, 그것은 추상화를 중단하고 결정을 내리기 시작한 것입니다.
적용하지 말아야 할 때
하나의 소비자, 하나의 저장소, 하나의 팀을 가진 모듈은 고정선을 넘지 않았습니다 — SemVer 용어로 0.y.z 상태이며, 번호를 가지고 있든 없든 — 그리고 교체 가능한 모든 정책 인터페이스를 구축하는 것은 유지 보수 비용이 드는 추측입니다. 첫날부터 비용이 들지 않는 규율을 유지하십시오 — 전역 상태 없음, 원인을 보존하는 실패, 호출자가 요청하기 전에는 작업 없음 — 그리고 두 번째 소비자가 도착할 때 인터페이스를 추가하십시오. 그 순간이 추측이 중단되는 때입니다.
출처
- Semantic Versioning 2.0.0, semver.org: 1항 (공개 API는 반드시 선언되어야 함), 6–8항 (PATCH, MINOR, MAJOR 증분), 4항 (0.y.z), 및 지원 중단에 대한 FAQ 항목 (문서화한 다음, 지원 중단이 적용된 MINOR 릴리스를 발행). 문구는 페이지를 기준으로 확인함.
- 하이럼의 법칙은 표준이 아닌 관찰이며, 그렇게 인용됨.