프로그래밍 원칙을 조작 가능하게 — 증상·절차·경계로 다시 쓰기

언제 — 원칙집이 “무엇을 믿어라”는 주는데 “언제 어떻게 하라”를 안 줄 때. 원칙마다 증상 → 절차 → 경계(언제 적용하지 말 것) 를 붙인다. 경계가 핵심이다 — 슬로건은 자기가 틀리는 지점을 말하지 않는다.

가치선언(정직하라, 좋은 일을 하라)은 이견 낼 여지가 없어 레시피가 되지 않으므로 뺀다. 남는 건 실제로 의사결정에 개입하는 일곱이다.

증상 레시피
상태를 맞추려고만 존재하는 useEffect 가 있다 1. 파생
중복 2곳을 보고 즉시 헬퍼로 뽑고 싶다 2. AHA
“이렇게 하면 그 문제 안 생겨요” 3. 이전 vs 제거
undefined 가 함수 셋을 지나 렌더에서 크래시 4. 경계
선택 근거가 “우리가 이미 잘 아니까” 뿐 5. 익숙함
도구 선택에서 분석 마비 6. 되돌릴 수 있는 문
한 파일을 고치면 무관해 보이는 곳이 깨진다 7. 모듈 경계

1. 상태를 동기화하지 말고 파생하라

가장 견고한 레시피. 단 단일 런타임 안에서만 무조건 참이다.

증상 — 한 값을 바꿀 때 다른 상태도 “같이” 갱신해야 한다. 오직 다른 상태를 맞추기 위해서만 존재하는 useEffect 가 있다. 미러 변수가 보인다(isEven, winner, filteredList, sortedRows).

절차

  1. 각 상태에 묻는다 — “이 값은 다른 상태만으로 계산 가능한가?” 가능하면 상태가 아니라 파생값이다.
  2. 단일 소스만 남기고 나머지는 렌더 중 순수 함수로 계산한다.
  3. 동기화하던 useEffect 를 삭제한다.
  4. 파생 계산이 프로파일에서 비싸면 useMemo 로 감싼다 — 삭제가 아니라 캐시. 둘을 혼동하지 말 것.

경계 — 소스가 분산되면(멀티 클라이언트, 서버·클라 이중 소스) 파생으로 못 푼다. 그건 진짜 sync 문제이고 CRDT·이벤트 소싱 영역이다. 파생 비용이 진짜 크고 입력이 자주 안 변하면 물질화가 정당하지만, 그 순간 다시 동기화 계약을 지는 것임을 자각한다.


2. 추상은 비명이 들릴 때까지 미뤄라 (AHA)

AHA 는 DRY 와 WET 의 절충이 아니다. 원칙은 대칭적(과잉·과소 추상 둘 다 피함)이고 타이브레이커만 비대칭이다 — 확신이 없으면 중복 쪽. 근거는 가역성: 잘못된 추상은 중복보다 되돌리기가 비싸다.

증상 — 중복 2곳을 보고 즉시 뽑고 싶다. 이미 뽑은 추상이 options 플래그와 조건 분기로 부풀고 있다. 추상 하나를 고치는데 무관한 호출부가 깨진다.

절차

  1. 중복을 발견하면 놔둔다. 세 번째 사용처가 나타날 때까지.
  2. 세 번째에서 묻는다 — 세 곳이 같은 이유로 함께 변하는가, 지금 우연히 비슷한가? 같은 이유로 변할 때만 추상한다.
  3. 가역성 테스트: “틀린 추상이면 되돌리는 비용 vs 지금 중복 유지 비용?” 되돌리기가 더 비싸면 아직 이르다.
  4. 이미 부푼 추상은 역방향으로 — 인라인으로 되돌려 중복을 복원한 뒤 진짜 공통점만 다시 뽑는다.

경계 — “세 번 규칙”은 휴리스틱이지 법칙이 아니다. 계약(공개 API·타입 경계·프로토콜)은 중복 비용이 커서 더 일찍 추상해야 한다. “코드가 비명을 지른다”는 판정이 안 서면 차라리 셀 수 있는 규칙을 써라 — 나쁜 규칙보다 낫고 규칙 없음보다도 낫다.


3. 문제를 이전시켰는지 제거했는지 검증하라

대부분의 “제거”는 큰 문제를 작은 문제와 맞바꾼 것이지 소멸이 아니다. 그래서 핵심은 “제거하라”가 아니라 제거인지 이전인지 판별하는 것이다.

증상 — “이렇게 하면 그 문제 안 생겨요”라는 리팩터/설계 주장.

절차

  1. 없앤 문제가 어디로 갔는지 추적한다. 사라졌나, 다른 레이어·사람·런타임으로 옮겼나?
  2. 이전이면(런타임 복잡도를 빌드타임/서버로 밀기) 그건 트레이드오프다. 옮긴 곳에서 실제로 더 싸게 다뤄지는지 명시한다.
  3. 진짜 제거의 표식 — 상태 공간이 좁아진다. 잘못된 상태를 표현할 수 없게 된다. 문제의 한 클래스가 통째로 불가능해진다.
  4. 제거도 이전도 아니면(문제는 그대로, 코드만 이동) 아무것도 안 한 것이다. 되돌려라.

경계 — “완전 제거”라는 수사에 속지 말 것. 트레이드오프임을 인정하는 게 정직이다. 타입으로 상태 공간을 좁히는 것(제거)과 복잡도를 다른 레이어로 미는 것(이전)을 가르는 선을 매번 긋는다.


4. 경계에서 엄격하게, 안에서 빨리 실패하라

서로 다른 세 축을 먼저 분리한다. “관대한 파싱”을 fail-fast 의 짝으로 묶는 건 방향 오류다.

  • fail-fast — 에러를 언제 드러내나: 즉시, 전파 전에.
  • parse, don’t validate — 경계에서 비구조→구조로 한 번 변환하고 증명을 타입에 실어 나른다: 어떻게. 엄격하며 fail-fast 와 같은 편.
  • Postel 의 관대함무엇을 받아들이나: 앞 둘과 반대 방향. 보완이 아니다.

증상 — 잘못된 입력이 조용히 흘러가 몇 레이어 뒤 애매한 에러로 터진다. 에러 스택이 원인이 아니라 증상 지점을 가리킨다.

절차

  1. 바깥 경계(네트워크 응답·사용자 입력·파일)에서 엄격하게 받는다. 변형 입력은 일찍 거부하고, 통과한 값은 타입으로 그 사실을 들고 다니게 한다.
  2. 위반은 그 자리에서 던지거나 명시적 에러 값으로 반환한다. 하류로 넘기지 않는다.
  3. 신뢰 경계 안쪽은 fail-fast. 타입으로 표현 가능한 실패는 런타임 검사 대신 타입으로 막는다(→ 레시피 3의 “표현 불가능하게”). 엄격한 거부는 fail-fast 일 뿐 아니라 공격의 한 클래스를 제거한다.
  4. 개발 빌드에서는 시끄럽게, 프로덕션에서는 우아하게. 단 “우아하게”는 거부를 사람에게 전달하는 방식이지 무엇을 시스템에 들이나가 아니다.

경계 — 관대함은 기본값이 아니라 상호운용을 위한 명시적 트레이드오프로만 고르고, 그때 지불하는 비용을 안다(용인된 에러는 고착되어 구현을 복잡하게 만든다 — RFC 9413 Protocol Decay Hypothesis). 관대함이 옳은 실제 사례는 있다 — 브라우저의 forgiving HTML 파싱이 웹 상호운용을 살렸다. 요점은 금지가 아니라 “엄격을 기본으로, 관대함은 눈 뜨고 내리는 결정으로” 다.


5. 익숙함을 단순함으로 착각하지 마라

Rich Hickey 가 못박은 두 지점이 절차의 전부다.

  • simple = simplex, “one fold” — 한 역할·한 관심사가 얽히지 않음. 객관적. 이건 interleaving 이지 cardinality 가 아니다 — 부품 수가 적은 게 simple 이 아니라, 다른 이유로 존재하는 것들이 안 엮인 게 simple.
  • easy = adjacens, “가까이 있음” — 손에 익음. 주관적(사람마다 다르다).
  • complect ≠ compose — 독립을 유지한 채 나란히 두는 건 좋고, 엮어서 서로 가정하게 만드는 것이 복잡도의 근원이다.

증상 — 도구/패턴 선택 근거가 “이미 잘 아니까” / “러닝커브 없어서” 뿐이다.

절차

  1. 후보를 두 축으로 분리 채점한다. simple = 하나를 다른 이유로 바꿀 때 무관한 관심사까지 건드려야 하나(객관). easy = 팀 숙련도·즉시 착수 비용(주관).
  2. 두 근거를 각각 따로 적는다. 두 근거가 한 칸에 섞이면 거기가 착각이 시작된 지점이다.
  3. easy 만 높고 simple 이 낮으면 단기 속도를 얻고 얽힘 비용을 뒤로 미루는 것이다. 그 미룸이 의식적 트레이드오프인지 확인한다.
  4. 결정 문서에 두 점수를 남겨 나중에 검증 가능하게 한다.

경계 — easy 자체는 나쁜 게 아니라 바람직한 목표다. 오류는 (a) easy 를 simple 로 착각하거나 (b) 오래 살 구조에서 simple 을 easy 와 맞바꾸는 것이다.

이 구분이 나머지의 뿌리다 — complect 는 다른 변경 이유를 엮는 것이라 레시피 7과 같은 축이고, 동기화된 미러(레시피 1)는 값과 갱신 시점을 complect 한 것이라 파생이 그걸 푼다.


6. 도구는 저부담 기본값으로, 되돌릴 수 있게

증상 — 도구 선택에서 분석 마비. 또는 반대로 니치한 도구를 “정답”이라 확신.

절차

  1. 이 선택이 one-way(고부담)인지 two-way(저부담)인지부터 판정한다: 나중에 되돌리는 비용이 큰가?
  2. two-way 면(대부분) 생태계 있는 표준 도구로 빠르게 결정하고 넘어간다. 이상적으로 원하는 정보의 ~70%면 결정한다 — 지연 비용이 교정 가능한 실수의 위험보다 크다.
  3. one-way 면(데이터 모델·인증·공개 계약·핵심 의존성) 여기에만 시간을 쓴다. 소프트웨어에서 진짜 one-way 는 드물고 대개 “되돌림 rework 가 클 뿐”이므로, 결합도를 낮춰 rework 비용을 깎아 one-way 를 two-way 로 끌어내린다(→ 레시피 7).
  4. “정답은 없다”를 아무 선택이나 정당화하는 방패로 쓰지 마라. 기준은 트레이드오프를 명시했는가다.

경계 — 오분류는 양방향으로 위험하다. two-way 를 one-way 로 다루면 분석 마비, 반대면 무모함. 숨은 one-way 를 조심한다 — 데이터 손실을 동반한 마이그레이션, 소비자가 이미 의존하는 공개 API, 코드베이스 전체로 전이된 프레임워크 선택. “나중에 바꾸면 돼”처럼 보이지만 커밋 후에야 되돌림 비용이 드러난다.


7. 모듈 경계: 변경 이유로 쪼개되 깊게 유지하라

핵심 기준은 하나다 — 변경 이유. 같은 이유·같은 시점에 변하는 것은 모으고, 다른 이유·다른 시점에 변하는 것은 나눈다. 분리(SRP)와 응집(colocation)은 이 한 기준의 양면이지 두 원칙이 아니다. 그래서 타이브레이커가 필요할 일이 없다.

증상 — 한 파일을 고치면 무관해 보이는 곳이 깨진다. import 그래프가 양방향/순환. 컴포넌트 하나가 변경 이유가 다른 여러 일을 한다. 이름·시그니처가 실제 하는 일과 다르다.

절차

  1. 변경 이유로 자른다. “왜 바뀌는가”가 둘 이상이면 그 축으로 쪼갠다. 관심사는 도메인 명사가 아니라 변경 축이다.
  2. 단, 깊게 유지한다. 더 쪼갤 때 인터페이스가 늘어나는 속도가 숨겨지는 복잡도를 앞지르면 멈춘다. SRP 를 극단으로 밀면 작은 클래스가 잔뜩 생겨 전체 복잡도가 오르고, 정보 은닉은 모듈을 오히려 약간 키울 때 개선될 때가 많다. 1과 2는 서로를 견제하는 역벡터다.
  3. 결합은 한 방향으로. 순환·양방향 의존은 즉시 신호. 인터페이스로 뒤집어 되돌림 비용을 깎는다.
  4. 조합 우선. 진짜 “is-a” 일 때만 상속.
  5. 최소 놀람. 숨은 부수효과·암묵 규약은 표면으로 끌어올린다.

경계 — 분리 vs 응집에서 갈등이 느껴지면 그건 타이브레이커가 필요한 상황이 아니라 엉뚱한 축으로 쪼갰다는 신호다. 변경 이유가 아니라 도메인 토픽으로 갈랐는지 의심한다. “깊은 모듈”도 무비판 수용 대상은 아니다 — 2번은 분해를 멈추는 체크이지 분해하지 말라는 뜻이 아니다.


절차는 근거를 외부 정전에 붙일수록 스스로 반증되거나 단단해진다 — 그게 슬로건과의 차이다. 4와 7이 그렇게 교정됐다: 4는 “관대한 파싱”이 fail-fast 와 충돌하는 별개 축임을 놓쳐 엄격을 기본으로 되돌렸고, 7은 하나의 기준(변경 이유)을 둘로 쪼개 가짜 타이브레이커를 붙였던 것과 대립하는 두 원칙(SRP ↔ 깊은 모듈)을 동의어로 합쳤던 것을 갈랐다.

#588raw