본문으로 건너뛰기

five lines of code

Christian ClausenISBN 9791158393915

목차108개 항목

Chapter 1. Refactoring Refactoring: 리팩터링 다시 시작하기

고품질 코드는 유지보수 비용을 줄이고, 버그를 줄이며, 개발자를 행복하게 만든다. 리팩터링은 그 고품질에 도달하는 가장 일반적인 방법이다. 그러나 기존 방식—코드 냄새(code smells)와 단위 테스트—은 진입 장벽이 불필요하게 높다.


1.1 What is Refactoring?: 리팩터링이란 무엇인가

정의 (리팩터링): 코드가 하는 일을 바꾸지 않으면서, 코드를 더 읽기 쉽고 유지보수하기 좋게 변경하는 행위이다.

정의 (좋은 코드): 사람이 읽을 수 있고(human-readable), 유지보수가 쉬우며(easy to maintain), 의도한 동작을 올바르게 수행하는 코드이다.

리팩터링의 세 가지 경제적 근거

  • 생산성: 코드베이스를 더 읽기 쉽게 만들면 새 기능 구현에 쓸 시간을 확보한다. 프로그래머의 시간은 비싸다.
  • 안정성: 코드를 더 유지보수하기 쉽게 만들면 버그가 줄고, 발생한 버그도 더 쉽게 고칠 수 있다.
  • 즐거움: 코드를 읽을 때 우리는 머릿속에 모델을 구축한다. 한 번에 유지해야 할 정보가 많을수록 더 피로해진다. 좋은 코드베이스는 그 부담을 줄인다.

1.1.1 리팩터링 예시: 지역 변수 추출

// Before: 동일한 연산(pow)을 두 번 수행하여 비효율적
return pow(base, exp / 2) * pow(base, exp / 2);

// After: 결과를 지역 변수에 캐싱하여 중복 연산 제거 및 가독성 향상
const result = pow(base, exp / 2);
return result * result;
  • 코드의 동작(반환값)은 동일하게 유지된다.
  • 성능 개선(중복 계산 제거)과 가독성 향상이 동시에 달성된다.

1.1.2 리팩터링의 범위

이 책에서 다루는 리팩터링은 다음 두 가지에 초점을 맞춘다.

  • Human-readable: 코드를 읽는 사람이 빠르게 이해할 수 있는가
  • Easy to maintain: 다음 사람이 쉽게 수정할 수 있는가

또한, 이 책의 리팩터링 기법은 객체지향 프로그래밍(OOP) 언어를 전제로 한다.


1.2 Skills: What to Refactor?: 무엇을 리팩터링할 것인가

리팩터링에서 첫 번째 진입 장벽은 “무엇을 고쳐야 하는지”를 아는 것이다.

1.2.1 기존 접근: 코드 냄새(Code Smells)

  • “함수는 한 가지 일만 해야 한다”는 유명한 코드 냄새 지침이 있다.
  • 강력하지만 경계가 모호하고 판단과 경험에 의존한다.
  • 주니어 개발자에게는 “육감(sixth sense)“처럼 느껴져 학습하기 어렵다.
// 이 코드가 "냄새"가 나는가?
// 나누기, 지수, 곱셈 — 세 가지 일? 아니면 하나의 수를 반환하므로 한 가지 일?
const result = pow(base, exp / 2);
return result * result;
// -> 코드 냄새 기준으로는 판단이 모호하다.

1.2.2 이 책의 접근: 규칙(Rules)

  • 코드 냄새 대신 쉽게 인식하고 적용할 수 있는 규칙을 제시한다.
  • 규칙은 명확하고 간결하며 기억하기 쉽다.
  • 규칙과 코드 냄새의 커버리지는 완전히 일치하지 않는다.
    • 규칙이 과도하게 엄격하여 불필요한 수정을 요구할 수 있다.
    • 규칙을 따르고도 냄새나는 코드가 나올 수 있다.
  • 규칙은 **훈련 바퀴(training wheels)**이다. 완벽하지 않지만, 시작점을 제공한다.
flowchart LR
    A[코드 냄새\nCode Smells] --- C((겹치는\n영역))
    B[이 책의 규칙\nRules] --- C
    style A fill:#f9f,stroke:#333
    style B fill:#bbf,stroke:#333
    style C fill:#dfd,stroke:#333

1.2.3 규칙 학습의 3단계 추상화

규칙을 깊이 이해하기 위해 세 단계를 순서대로 밟는다.

단계 내용 예시
1단계 규칙 이름(절대적 표현)을 외우고 따른다 “Five Lines”
2단계 예외를 포함한 상세 설명을 익힌다 특정 상황에서의 적용 제외 조건
3단계 규칙 이면의 코드 냄새 의도를 체화한다 진정한 코딩 구루(guru)의 감각

1.3 Culture: When to Refactor?: 언제 리팩터링할 것인가

리팩터링은 샤워하는 것과 같다. — Kent Beck

리팩터링은 자주, 규칙적으로 할수록 비용이 적게 든다.

1.3.1 6단계 개발 워크플로우

기존의 레드-그린-리팩터(red-green-refactor) 루프는 테스트 주도 개발(TDD)에 묶여 있다. 이 책은 테스트와 분리된 다음의 범용 워크플로우를 권장한다.

flowchart TD
    A([1. 탐색 Explore]) --> B([2. 명세 Specify])
    B --> C([3. 구현 Implement])
    C --> D([4. 테스트 Test])
    D --> E([5. 리팩터 Refactor])
    E --> F([6. 배포 Deliver])
    style E fill:#ffffcc,stroke:#999
  • 탐색: 요구사항이 불명확한 경우 빠르게 실험하여 방향을 확인한다.
  • 명세: 무엇을 만들어야 하는지 명확히 한다. 가능하면 자동화 테스트 형태로 만든다.
  • 구현: 코드를 작성한다.
  • 테스트: 코드가 명세를 통과하는지 검증한다.
  • 리팩터: 코드를 다음 사람(미래의 자신 포함)이 쉽게 다룰 수 있도록 정리한다.
  • 배포: 풀 리퀘스트(PR) 또는 브랜치 푸시로 사용자에게 코드를 전달한다.

1.3.2 레거시 시스템에서의 리팩터링

먼저 변경을 쉽게 만들고, 그 다음 쉬운 변경을 수행하라. — Kent Beck

  • 전체 코드베이스를 한 번에 리팩터링하려 하지 않는다.
  • 새 코드를 추가하기 바로 직전에, 그 부분만 리팩터링하여 추가를 쉽게 만든다.
  • 마치 요리 재료를 모두 손질한 뒤 요리를 시작하는 것과 같다.

1.3.3 리팩터링을 하지 말아야 할 때

다음 세 가지 유형의 코드베이스는 리팩터링이 가치 없을 가능성이 높다.

  • 일회성 코드(Spike): 한 번 실행하고 바로 삭제할 코드
  • 퇴역 예정 코드: 유지보수 모드에서 곧 폐기될 코드
  • 극한 성능 요구 코드: 임베디드 시스템, 고성능 물리 엔진 등

1.4 Tools: How to Refactor Safely?: 어떻게 안전하게 리팩터링할 것인가

자동화 테스트는 자동차의 브레이크와 같다. 브레이크는 느리게 가려고 있는 것이 아니라, 빠르게 달려도 안전하다고 느끼기 위해 있다.

1.4.1 테스트 없이 안전을 확보하는 방법

이 책은 자동화 테스트 없이도 다음 세 가지 도구로 안전을 확보한다.

  • 구조화된 리팩터링 패턴: 레시피처럼 상세하고 단계별로 구성된 절차
  • 버전 관리(Git): 언제든 정상 동작하던 상태로 되돌릴 수 있는 체크포인트
  • 컴파일러와 타입 시스템: 흔한 실수의 상당 부분을 컴파일 오류로 조기 감지

1.4.2 실전 안전 수칙

  • 컴파일러가 오류 없이 통과하거나, 직접 앱을 열어 동작을 확인할 때마다 커밋을 한다.
  • 코드가 망가졌고 즉시 고치기 어렵다면, 마지막 정상 커밋으로 즉시 되돌린다.
  • 실제 운영 시스템에서 자동화 테스트 없이 리팩터링할 경우, IDE 자동 리팩터링, 수동 테스트, 아주 작은 단계로 진행 등을 통해 신뢰를 보완한다. 단, 이 경우 자동화 테스트를 적용하는 것이 비용 효율적일 수 있다.

1.5 Tools You Need to Get Started: 시작에 필요한 도구

1.5.1 프로그래밍 언어: TypeScript

  • 이 책의 모든 예제는 TypeScript로 작성되어 있다.
  • Java, C#, C++, JavaScript와 유사하여 해당 언어 사용자라면 쉽게 읽을 수 있다.
  • 클래스가 전혀 없는 비객체지향 코드에서 완전한 객체지향 코드로 점진적으로 발전시킬 수 있다.
  • 규칙과 리팩터링 패턴 자체는 언어 무관(language-agnostic)하며, 모든 객체지향 언어에 적용된다.

TypeScript 참고 사항

// Before: == 은 타입 강제 변환으로 예상치 못한 결과를 낳는다
console.log(0 == "");  // true (예상 밖의 결과)

// After: === 는 타입까지 비교하여 의도한 동작을 보장한다
console.log(0 === ""); // false (올바른 결과)

1.5.2 에디터: Visual Studio Code

  • TypeScript와 뛰어난 호환성을 제공한다.
  • 터미널에서 tsc -w를 백그라운드로 실행하여 파일 변경 시 자동 컴파일이 가능하다.
  • Visual Studio(IDE)와는 완전히 다른 별개의 도구이다.

1.5.3 버전 관리: Git

  • 필수는 아니지만 강력히 권장된다.
  • 길을 잃었을 때 안전하게 되돌아갈 수 있는 체크포인트 역할을 한다.
  • 특정 섹션의 참조 코드 상태로 즉시 복원 가능하다.
# 특정 섹션의 참조 솔루션 상태로 초기화 (주의: 현재 변경 사항은 모두 유실된다)
git reset --hard section-2.1

1.6 Overarching Example — A 2D Puzzle Game: 핵심 예제 소개

이 책의 Part 1 전체에 걸쳐 단 하나의 코드베이스를 중심으로 학습한다.

1.6.1 게임 개요

  • 장르: 2D 블록-밀기 퍼즐 게임 (Boulder Dash 류)
  • 조작: 화살표 키로 플레이어 이동
  • 목표: 상자(box)를 우하단 코너로 이동시킨다

1.6.2 게임 요소

요소 설명
플레이어(Player) 화살표 키로 조작하는 주체
상자(Box) 목표 지점으로 밀어야 하는 갈색 사각형
돌(Stone) 밀 수 있는 파란 사각형; 지지물이 없으면 낙하
열쇠/자물쇠(Key/Lock) 노란 사각형; 경로를 막는 자물쇠와 이를 여는 열쇠
플럭스(Flux) 초록빛 사각형; 플레이어가 밟으면 제거됨
벽(Wall) 회색 사각형; 이동 불가
공기(Air) 흰 사각형; 빈 공간

1.6.3 코드베이스의 특성

  • DRY(Don’t Repeat Yourself)와 KISS(Keep It Simple, Stupid) 원칙을 이미 준수하고 있다.
  • 그럼에도 불구하고 여전히 리팩터링이 필요한 현실적인(industry-realistic) 코드 스타일로 작성되어 있다.
  • 수동 테스트가 직관적이다. 게임이 올바르게 동작하는지 눈으로 즉시 확인할 수 있다.
# 게임 세팅 방법
git clone https://github.com/thedrlambda/five-lines
# index.html을 브라우저에서 열고, tsc -w로 백그라운드 컴파일 실행

1.6.4 두 번째 연습 코드베이스

  • 별도의 2D 액션 게임 프로젝트(bomb-guy)가 솔루션 없이 제공된다.
  • 재독 시 심화 연습, 또는 강의 과제용으로 활용 가능하다.
  • 동일한 스타일과 구조를 가지며, 동일한 단계로 리팩터링할 수 있다.

1.7 A Note on Real-World Software: 실제 코드에 적용할 때의 주의사항

  • 이 책의 목적은 특정 규칙을 모든 상황에 맹목적으로 적용하는 것이 아니라, 리팩터링 역량 자체를 습득하는 것이다.
  • 규칙의 목적은 이해를 쌓는 것이다. 자동 리팩터링 도구로 만들 수 없는 이유도 여기에 있다.
  • 핵심 원칙: 규칙을 더 잘 알게 될 때까지는, 규칙을 따른다.
  • 실제 운영 시스템에서는 자동화 테스트 없이 리팩터링하지 않는 것을 권장한다. 이 책이 테스트 없이 진행하는 것은 학습 환경이기 때문이다.

챕터 1 요약

핵심 개념 (규칙/패턴) 목적 및 내용
리팩터링 정의 동작을 바꾸지 않으면서 코드를 더 읽기 쉽고 유지보수하기 좋게 변경하는 것
좋은 코드 정의 인간이 읽기 쉽고, 유지보수가 쉬우며, 의도한 동작을 올바르게 수행하는 코드
Skills — 규칙(Rules) 코드 냄새의 대안. 명확하고 구체적이며 즉시 적용 가능한 지침. 훈련 바퀴 역할
Culture — 6단계 워크플로우 탐색 → 명세 → 구현 → 테스트 → 리팩터 → 배포. TDD 없이 리팩터링을 통합
Culture — 레거시 전략 “먼저 변경을 쉽게, 그 다음 쉬운 변경을” — 새 코드 추가 직전에 해당 부분만 리팩터링
Tools — 안전 확보 3요소 구조화된 리팩터링 패턴 + 버전 관리(Git) + 컴파일러/타입 시스템
예제 코드베이스 2D 퍼즐 게임(five-lines). Part 1 전체에서 단일 코드베이스로 지속 학습
규칙 학습 3단계 ① 이름 암기 → ② 예외 포함 설명 이해 → ③ 코드 냄새 의도 체화

Chapter 2. Looking Under the Hood of Refactoring: 리팩터링의 내부 원리

리팩터링이란 코드가 하는 일을 바꾸지 않으면서 코드를 더 좋게 만드는 것이다.


2.1 가독성(Readability)과 유지보수성(Maintainability) 향상

2.1.1 코드를 더 좋게 만든다는 것

가독성(Readability): 코드가 자신의 의도를 얼마나 잘 전달하는가를 나타내는 속성이다.

가독성을 높이는 수단들은 다음과 같다.

  • 코딩 컨벤션의 수립과 준수
  • 의미 있는 변수·메서드·클래스·파일 명명
  • 적절한 주석 (코드를 그대로 반복하지 않는)
  • 공백(whitespace)을 통한 구조 시각화

아래는 가독성을 모두 위반한 코드와 개선된 코드의 대비이다.

// Before: 가독성을 모두 위반한 코드 — 잘못된 타입 이름, 이중 부정, 오도하는 들여쓰기
function checkValue(str: boolean) {
  // Check value
  if (str !== false)
    // return
    return true;
  else; // otherwise
    return str;
}
// After: 의도를 명확히 전달하는 코드
function isTrue(bool: boolean) {
  return bool;
}
  • str이라는 이름은 boolean 타입에 맞지 않는다 → bool로 변경한다.
  • str !== false는 이중 부정이다 → bool로 단순화한다.
  • 코드를 그대로 반복하는 주석은 노이즈다 → 제거한다.
  • else;의 세미콜론은 else 블록을 즉시 종료시키는 버그다 → 제거한다.

2.1.2 유지보수성과 불변 조건(Invariant)

유지보수성(Maintainability): 코드를 변경할 때 얼마나 많은 사전 조사가 필요한가를 나타내는 속성이다.

  • 조사해야 할 코드가 많을수록 시간이 오래 걸리고 실수 가능성이 높아진다.
  • 유지보수성이 낮은 코드는 무관해 보이는 곳을 건드렸을 때 시스템이 깨지는 취약성(fragility)을 유발한다.
  • 취약성의 근본 원인은 대부분 **전역 상태(global state)**이다.

전역 상태를 파악하는 실용적 기법: 중괄호 { ... }를 기준으로 삼는다. 중괄호 바깥에 있는 모든 것은 중괄호 안쪽 코드에게 전역 상태이다.

불변 조건(Invariant): 코드 내에서 명시적으로 검사하지는 않지만 항상 참이라고 가정하는 속성이다. (“이 숫자는 절대 음수가 되지 않는다”, “이 파일은 반드시 존재한다” 등)

비지역 불변 조건(Nonlocal Invariant)이 코드를 오염시키는 예시
// Before: daysUntilExpiry > 0 이라는 불변 조건이 코드 내에 암묵적으로만 존재
class GroceryItem {
  daysUntilExpiry: number;
  value: number;

  getUrgency(): number {
    // 불변 조건: daysUntilExpiry는 항상 양수라고 가정 (명시되지 않음)
    return this.value / this.daysUntilExpiry; // 전구 추가 시 0으로 나누기 오류 발생
  }
}

class LightBulb extends GroceryItem {
  constructor() {
    super();
    this.daysUntilExpiry = 0; // 불변 조건 위반 — 담당자가 invariant를 몰랐음
  }
}
  • 2년 후 전구를 추가한 개발자는 urgency = value / daysUntilExpiry 계산식의 존재를 몰랐다.
  • daysUntilExpiry = 0으로 설정하는 순간 불변 조건이 깨지고 Division by zero 오류가 발생한다.
  • 이처럼 불변 조건이 멀리 떨어져 있을수록 시스템은 취약해진다.

불변 조건의 지역화(Localizing Invariants): 리팩터링은 명시적 검사를 추가해 불변 조건을 제거하는 것이 아니라(그것은 동작을 바꾼다), 함께 변하는 것들을 가까이 모아 불변 조건을 눈에 잘 띄게 만든다.


2.1.3 동작을 바꾸지 않는다는 것의 의미

리팩터링은 블랙 박스 외부에서 관찰할 수 있는 동작을 변경하지 않는다. 단, 성능은 예외이다.

  • 대부분의 시스템에서 성능보다 가독성·유지보수성이 더 가치 있다.
  • 성능이 중요하다면, 리팩터링과 별도의 단계에서 프로파일링 도구를 통해 다루어야 한다.
  • 리팩터링 범위는 좁을수록 머지 충돌(merge conflict) 위험이 줄어든다.

리팩터링의 세 가지 기둥:

  1. 의도를 전달하여 가독성 향상
  2. 불변 조건을 지역화하여 유지보수성 향상
  3. 위 1, 2를 범위 밖의 코드에 영향 없이 수행

2.2 속도, 유연성, 안정성 확보

대부분의 아키텍처 수준의 리팩터링 패턴과 규칙은 객체 합성(composition)을 돕기 위해 존재한다.

2.2.1 상속보다 합성(Composition over Inheritance)

Gang of Four(GoF)는 1994년 Design Patterns에서 이미 비지역 불변 조건을 우발적으로 도입하는 주범으로 **상속(inheritance)**을 지목하고, “객체 합성을 상속보다 선호하라”는 원칙을 제시했다.

// Before: 상속(Inheritance) — 새 메서드 추가 시 조용히 잘못된 동작을 상속받는다
interface Bird {
  hasBeak(): boolean;
  canFly(): boolean;
}
class CommonBird implements Bird {
  hasBeak() { return true; }
  canFly() { return true; }
}
class Penguin extends CommonBird {
  canFly() { return false; }
  // canSwim()이 CommonBird에 추가되면, Penguin은 아무 경고 없이 canSwim = false를 상속받는다
}
// After: 합성(Composition) — 새 메서드 추가 시 컴파일러가 누락을 강제로 알려준다
interface Bird {
  hasBeak(): boolean;
  canFly(): boolean;
  canSwim(): boolean;
}
class CommonBird implements Bird {
  hasBeak() { return true; }
  canFly() { return true; }
  canSwim() { return false; }
}
class Penguin implements Bird {
  private bird = new CommonBird(); // 합성
  hasBeak() { return this.bird.hasBeak(); } // 직접 위임(forwarding)
  canFly() { return false; }
  canSwim() { return true; } // 컴파일러가 구현 누락을 오류로 강제함
}
비교 항목 상속 합성
새 메서드 추가 시 조용히 기본값 상속 (버그 위험) 컴파일 오류로 구현 강제 (안전)
재사용 방식 is-a 관계 has-a 관계 (위임)
불변 조건 범위 비지역적 (취약) 지역적 (견고)
코드 결합도 높음 낮음

핵심 통찰: 인간의 기억은 신뢰할 수 없는 의존성이다. 상속은 “재정의해야 할 것을 기억하라”는 암묵적 계약을 만들지만, 합성은 그 계약을 컴파일러가 강제한다.

합성 기반 시스템은 레고 블록처럼 작동한다. 모든 것이 서로 맞도록 설계되어 있어 부품을 교체하거나 기존 컴포넌트를 조합하여 새로운 것을 만드는 속도가 매우 빠르다.


2.2.2 수정이 아닌 추가로 변경하기 (Change by Addition)

합성의 가장 큰 장점은 기존 코드를 건드리지 않고 기능을 추가하거나 변경할 수 있다는 것이다. 이 속성은 **개방-폐쇄 원칙(Open-Closed Principle, OCP)**이라고도 불린다.

컴포넌트는 확장(추가)에는 열려 있고, 수정에는 닫혀 있어야 한다.

추가로 변경하기가 가져오는 이점:

  • 개발 속도: 기존 코드를 건드리지 않으면 사전 조사 시간이 줄어든다. 단, 사용되지 않는 코드는 즉시 삭제해야 코드베이스 팽창을 막는다.
  • 안정성: 기존 코드를 보존한 채 새 코드를 추가하면 언제든 이전 동작으로 폴백(fallback)할 수 있다. 기존 기능에 새로운 오류를 도입하지 않는다.

2.3 리팩터링과 일상적 업무

“항상 네가 찾은 것보다 더 나은 상태로 두고 떠나라.” — 보이 스카우트 규칙(Boy Scout Rule)

리팩터링되지 않은 코드를 배포하는 것은 다음 개발자로부터 시간을 빌리는 것이다. 나쁜 소프트웨어 아키텍처에는 이자(interest rate)가 붙는다 — 이것이 **기술 부채(Technical Debt)**이다.

일상적 리팩터링의 두 가지 방식:

  1. 레거시 시스템: 변경을 가하기 전에 먼저 리팩터링하고, 이후 일반 작업 흐름을 따른다.
  2. 신규 코드: 변경 사항을 만든 후, 배포 전에 리팩터링한다.

2.3.1 학습 방법으로서의 리팩터링

  • 리팩터링은 코드를 완전히 이해하지 않아도 개선할 수 있는 방법이다.
  • 이해하기까지 수 시간이 걸릴 코드도 리팩터링을 통해 작은 조각씩 소화하며 결국 쉽게 이해되는 상태로 만든다.
  • 리팩터링을 통해 더 좋은 코드의 이점을 직접 경험하면, 코드를 쓰고 생각하는 방식 자체가 바뀐다.
  • 안정성 확보 → 배포 빈도 증가 → 더 큰 안정성의 선순환이 가능해진다.
  • 유연성 확보 → 설정 관리 시스템, 피처 토글(feature-toggling) 시스템 구축이 가능해진다.

2.4 소프트웨어 맥락에서의 “도메인(Domain)”

도메인(Domain): 소프트웨어가 모델링하는 실세계의 특정 측면이다.

  • 도메인에는 사용자와 전문가, 고유한 언어와 문화가 존재한다.
  • 이 책 1부에서는 도메인이 2D 퍼즐 게임이며, 사용자는 플레이어, 전문가는 게임·레벨 디자이너이다.
  • 프로그래밍 언어는 모호함을 허용하지 않으므로, 개발자는 도메인 전문가도 모르는 새로운 엣지 케이스를 탐구해야 한다.
  • 따라서 프로그래밍은 근본적으로 학습과 소통의 행위이다.

챕터 2 요약

핵심 개념 (규칙/패턴) 목적 및 내용
가독성(Readability) 코드가 의도를 전달하는 능력. 명명, 주석, 공백 등을 통해 높인다.
유지보수성(Maintainability) 변경 시 필요한 사전 조사의 양. 적을수록 좋다.
불변 조건의 지역화 (Localizing Invariants) 암묵적 가정(불변 조건)을 가까이 모아 취약성을 낮춘다.
전역 상태(Global State) 취약성의 근본 원인. { } 바깥의 모든 것이 전역 상태이다.
상속보다 합성 (Composition over Inheritance) GoF 원칙. 비지역 불변 조건 도입을 방지하고 유연성을 높인다.
추가로 변경하기 (Change by Addition) 기존 코드를 건드리지 않고 기능을 확장. 개발 속도·안정성 향상.
개방-폐쇄 원칙 (Open-Closed Principle) 확장에는 열려 있고, 수정에는 닫혀 있어야 한다.
기술 부채 (Technical Debt) 미뤄진 리팩터링이 쌓인 이자 붙는 부채. 보이 스카우트 규칙으로 예방한다.
도메인 (Domain) 소프트웨어가 모델링하는 실세계 영역. 언어·문화·전문가를 포함한다.
flowchart TD
    A[리팩터링<br/>동작 변경 없이 코드 개선] --> B[가독성 향상<br/>의도 전달]
    A --> C[유지보수성 향상<br/>불변 조건 지역화]
    C --> D[전역 상태 최소화]
    D --> E[상속보다 합성 선호]
    E --> F[추가로 변경하기<br/>Open-Closed Principle]
    F --> G[개발 속도 향상]
    F --> H[시스템 안정성 향상]
    F --> I[유연성 향상]
    A --> J[일상적 리팩터링<br/>Boy Scout Rule]
    J --> K[기술 부채 방지]

Chapter 3. Shatter Long Functions: 긴 함수 분해하기

“Don’t panic.” — Douglas Adams, The Hitchhiker’s Guide to the Galaxy

코드는 DRY와 KISS 원칙을 따르더라도 쉽게 복잡해진다. 주된 원인은 세 가지다.

  • 하나의 메서드가 여러 가지 다른 일을 수행한다.
  • 배열 접근, 산술 연산 등 저수준(low-level) 원시 연산을 직접 사용한다.
  • 주석이나 명확한 변수/메서드 명명이 부족하다.

이 챕터는 drawupdate라는 두 함수를 예시로, 긴 함수를 분해하는 구체적이고 안전한 방법을 다룬다.


3.1 첫 번째 규칙 정립: 왜 다섯 줄인가?

3.1.1 규칙: Five Lines (다섯 줄)

[Five Lines 규칙]: 하나의 메서드(함수)는 {} 를 제외하고 5줄을 초과해서는 안 된다.

줄(line) 계산 기준:

  • if, for, while, 세미콜론으로 끝나는 모든 문(assignments, 메서드 호출, return 등)은 각각 1줄로 계산한다.
  • 공백 줄과 중괄호({, })는 계산에서 제외한다.
  • else가 붙은 ifif 1줄로 계산한다.

예시: 5줄이 무엇인지 구체적으로 보여주는 코드

// 2D 배열을 순회하는 정확히 5줄짜리 함수들
function containsEven(arr: number[][]): boolean {
  for (let x = 0; x < arr.length; x++) {
    for (let y = 0; y < arr[x].length; y++) {
      if (arr[x][y] % 2 === 0) {
        return true;
      }
    }
  }
  return false;
}

function minimum(arr: number[][]): number {
  let result = Number.POSITIVE_INFINITY;
  for (let x = 0; x < arr.length; x++) {
    for (let y = 0; y < arr[x].length; y++) {
      result = Math.min(arr[x][y], result);
    }
  }
  return result;
}

이 규칙이 의미하는 것:

  • 5줄 제한의 정확한 숫자보다 “제한을 두는 것 자체” 가 중요하다.
  • 5줄은 기본 자료구조(2D 배열 등)를 한 번 순회하는 데 필요한 최소 단위에서 유도된 숫자다.
  • 각 메서드의 이름이 코드의 의도를 전달하는 기회가 된다 — 사실상 5줄마다 주석을 다는 것과 같은 효과다.
  • 20줄짜리 메서드가 있다면, 10줄씩 두 개의 헬퍼 메서드로 분리하고 원본은 2줄이 된다. 이 과정을 반복하면 모든 메서드를 5줄 이하로 만들 수 있다.

코드 스멜(Smell) 연결:

  • 긴 메서드는 그 자체로 스멜이다. 모든 로직을 한 번에 머릿속에 담아야 하기 때문이다.
  • “메서드는 한 가지 일만 해야 한다 (Methods should do one thing)” — Robert C. Martin, Clean Code

3.2 함수를 분해하는 리팩터링 패턴 도입

긴 함수를 만났을 때, 모든 줄의 세부 내용을 파악하려 하면 오히려 비생산적이다. 대신 코드의 “모양(shape)” 을 먼저 파악한다.

접근 방법:

  1. 논리적으로 연관된 줄들의 그룹을 식별한다.
  2. 그룹 사이에 빈 줄을 추가하고, 임시 주석을 달아 그룹의 의미를 기록한다.
  3. 각 그룹을 새로운 메서드로 추출한다 (주석이 메서드 이름이 된다).

draw 함수 초기 상태와 분해 과정:

// Before: 하나의 함수가 맵 렌더링과 플레이어 렌더링을 모두 담당
function draw() {
  let canvas = document.getElementById("GameCanvas") as HTMLCanvasElement;
  let g = canvas.getContext("2d");
  g.clearRect(0, 0, canvas.width, canvas.height);

  // Draw map
  for (let y = 0; y < map.length; y++) {
    for (let x = 0; x < map[y].length; x++) {
      if (map[y][x] === Tile.FLUX) g.fillStyle = "#ccffcc";
      else if (map[y][x] === Tile.UNBREAKABLE) g.fillStyle = "#999999";
      // ... 이하 생략
      if (map[y][x] !== Tile.AIR && map[y][x] !== Tile.PLAYER)
        g.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
    }
  }

  // Draw player
  g.fillStyle = "#ff0000";
  g.fillRect(playerx * TILE_SIZE, playery * TILE_SIZE, TILE_SIZE, TILE_SIZE);
}
// After: 메서드 추출(Extract Method) 적용 — 주석이 메서드 이름으로 대체됨
function draw() {
  let canvas = document.getElementById("GameCanvas") as HTMLCanvasElement;
  let g = canvas.getContext("2d");
  g.clearRect(0, 0, canvas.width, canvas.height);
  drawMap(g);
  drawPlayer(g);
}

function drawMap(g: CanvasRenderingContext2D) {
  for (let y = 0; y < map.length; y++) {
    for (let x = 0; x < map[y].length; x++) {
      if (map[y][x] === Tile.FLUX) g.fillStyle = "#ccffcc";
      else if (map[y][x] === Tile.UNBREAKABLE) g.fillStyle = "#999999";
      // ... 이하 생략
      if (map[y][x] !== Tile.AIR && map[y][x] !== Tile.PLAYER)
        g.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
    }
  }
}

function drawPlayer(g: CanvasRenderingContext2D) {
  g.fillStyle = "#ff0000";
  g.fillRect(playerx * TILE_SIZE, playery * TILE_SIZE, TILE_SIZE, TILE_SIZE);
}

핵심 인사이트:

  • 주석과 메서드 이름은 동일한 정보를 전달한다. 추출이 완료되면 주석을 삭제한다.
  • gdraw에서 선언되었으므로, 추출된 함수의 매개변수로 전달해야 한다. 컴파일러가 이를 자동으로 알려준다.

3.2.1 리팩터링 패턴: 메서드 추출 (Extract Method)

  • 설명: 하나의 메서드 내 일부 줄들을 새로운 독립 메서드로 분리한다. 많은 현대 IDE(VS Code 등)가 이 패턴을 내장 기능으로 지원한다. 안전성이 매우 높다.
  • 적용 절차:
단계 행동
1 추출할 줄들 주변에 빈 줄(및 임시 주석)을 추가하여 그룹을 표시한다
2 원하는 이름으로 비어있는 새 메서드를 생성한다
3 그룹의 최상단에 새 메서드 호출을 삽입한다
4 그룹의 줄들을 잘라내어 새 메서드 본문에 붙여넣는다
5 컴파일하여 스코프 오류를 확인한다
6 오류가 난 변수들을 매개변수로 추가한다
7 추출된 함수 내에서 할당(assign)이 발생하는 매개변수 p가 있다면: (a) 새 메서드 마지막에 return p;를 추가하고, (b) 호출부에서 p = newMethod(...) 형태로 반환값을 받는다
8 다시 컴파일하여 오류를 확인한다
9 호출부에서 인수(arguments)를 전달하여 오류를 해결한다
10 불필요한 빈 줄과 임시 주석을 제거한다
  • 효과: 코드 중복 제거, 함수 책임 분리, 주석 제거(이름으로 대체), 안전한 변환(컴파일러가 오류를 검증)

Pro Tip: return이 일부 분기에만 존재하면 메서드 추출이 어려워진다. 이를 예방하기 위해 메서드의 아래쪽부터 위쪽 방향으로 작업하는 것을 권장한다. 이렇게 하면 return이 자연스럽게 위로 올라가 모든 분기에서 반환하게 된다.

Extract Method 단계별 예시 (2D 배열 최솟값):

// Before: 추출 전 minimum 함수
function minimum(arr: number[][]): number {
  let result = Number.POSITIVE_INFINITY;
  for (let x = 0; x < arr.length; x++)
    for (let y = 0; y < arr[x].length; y++)

      if (result > arr[x][y])   // 이 두 줄을 추출 대상으로 표시
        result = arr[x][y];

  return result;
}
// After: Extract Method 완료 — result에 할당하므로 return/assignment 처리 포함
function minimum(arr: number[][]): number {
  let result = Number.POSITIVE_INFINITY;
  for (let x = 0; x < arr.length; x++)
    for (let y = 0; y < arr[x].length; y++)
      result = min(result, arr, x, y);
  return result;
}

function min(result: number, arr: number[][], x: number, y: number): number {
  if (result > arr[x][y])
    result = arr[x][y];
  return result;
}

안전성에 관한 핵심 철학:

완벽한 결과물을 만드는 것보다, 아무것도 깨뜨리지 않았다는 확신 이 더 가치 있다. 특히 코드가 무슨 일을 하는지 아직 파악하기 전에는 더욱 그렇다. 영리하게 처리하려다 오히려 문제가 생기는 경우가 많다. 컴파일러는 절대 잊지 않는다.


3.3 추상화 수준 균형을 위한 함수 분해

3.3.1 규칙: Either Call or Pass (호출하거나 전달하거나)

[Either Call or Pass 규칙]: 하나의 함수는 어떤 객체의 메서드를 호출(call) 하거나, 그 객체를 인수(argument)로 전달(pass) 하거나, 둘 중 하나만 해야 한다.

규칙 위반 예시와 수정:

// Before: sum(arr)이라는 고수준 추상화와 arr.length라는 저수준 접근이 혼재
function average(arr: number[]): number {
  return sum(arr) / arr.length;
}
// After: size(arr)로 추상화 수준을 통일
function average(arr: number[]): number {
  return sum(arr) / size(arr);
}

이 규칙이 의미하는 것:

  • 한 함수 내에서 고수준 메서드 호출과 저수준 원시 연산을 동시에 사용하면, 읽는 사람이 두 수준을 오가며 이해해야 한다.
  • 어떤 세부 사항을 추상화로 추출했다면, 같은 함수 내의 다른 세부 사항도 함께 추출해야 한다. 함수 내부의 추상화 수준은 항상 일정해야 한다.
  • .(점 표기법)이 있으면 메서드 호출, 인수로 넘겨지면 전달이다. 두 가지가 동시에 나타나면 위반이다.

draw 함수에서의 위반 탐지 및 수정:

// Before: g가 인수로 전달되기도 하고(drawMap, drawPlayer), 직접 메서드 호출도 됨(g.clearRect)
function draw() {
  let canvas = document.getElementById("GameCanvas") as HTMLCanvasElement;
  let g = canvas.getContext("2d");
  g.clearRect(0, 0, canvas.width, canvas.height); // g에 메서드 호출
  drawMap(g);    // g를 인수로 전달
  drawPlayer(g); // g를 인수로 전달
}
// After: 앞 세 줄을 createGraphics()로 추출하여 추상화 수준 통일
function createGraphics(): CanvasRenderingContext2D {
  let canvas = document.getElementById("GameCanvas") as HTMLCanvasElement;
  let g = canvas.getContext("2d");
  g.clearRect(0, 0, canvas.width, canvas.height);
  return g;
}

function draw() {
  let g = createGraphics();
  drawMap(g);
  drawPlayer(g);
}

주의: g.clearRect만 따로 추출하면, canvas를 인수로 전달하면서도 canvas.getContext를 호출하게 되어 규칙을 또 위반한다. 그래서 앞 세 줄 전체를 함께 추출해야 한다.

3.3.2 규칙 적용 시 탐지 방법

  • 세부 내용을 읽지 않아도 탐지 가능하다.
  • 같은 변수.메서드() 호출과 함수(변수) 인수 전달이 동시에 존재하면 위반이다.
  • 위반 발견 시 Extract Method로 수정한다.

3.4 좋은 함수 이름의 속성

속성 설명
정직성 (Honest) 함수의 실제 의도를 그대로 서술한다. 내부 구현이 아닌 목적을 표현한다
완전성 (Complete) 함수가 하는 모든 일을 이름에 담는다. 숨겨진 사이드 이펙트가 없어야 한다
도메인 친화성 (Domain-understandable) 해당 도메인의 용어를 사용한다. 팀과의 소통 효율을 높이고 코드에 대한 대화를 쉽게 만든다

이름 짓기 실전 기법:

  • 주석이 없을 때: 추출할 코드에서 가장 많이 등장하는 단어를 찾는다. update로 시작한 함수에서 input이 자주 보이면 → updateInputs.
  • update는 너무 포괄적이다. 맥락상 더 어울리는 handle을 쓰는 것이 낫다 → handleInputs.
  • 이름은 나중에 언제든지 재검토한다. 함수가 더 작아지면 더 좋은 이름이 보인다.
  • Extract Method는 매개변수(parameter) 이름을 바꿀 기회도 제공한다. 루프 내의 current는 새 함수에서 input으로 더 명확하게 표현할 수 있다.

3.5 너무 많은 일을 하는 함수 분해하기

update 함수에서 Extract Method를 적용하는 전체 흐름:

// Before: update 함수가 입력 처리와 맵 업데이트를 모두 담당
function update() {
  while (inputs.length > 0) {
    let current = inputs.pop();
    if (current === Input.LEFT) moveHorizontal(-1);
    else if (current === Input.RIGHT) moveHorizontal(1);
    else if (current === Input.UP) moveVertical(-1);
    else if (current === Input.DOWN) moveVertical(1);
  }
  // 빈 줄 — 두 그룹의 경계
  for (let y = map.length - 1; y >= 0; y--) {
    for (let x = 0; x < map[y].length; x++) {
      // ... 맵 업데이트 로직
    }
  }
}
// After: 두 그룹을 각각 handleInputs()와 updateMap()으로 추출
function update() {
  handleInputs();
  updateMap();
}

function handleInputs() {
  while (inputs.length > 0) {
    let current = inputs.pop();
    if (current === Input.LEFT) moveHorizontal(-1);
    else if (current === Input.RIGHT) moveHorizontal(1);
    else if (current === Input.UP) moveVertical(-1);
    else if (current === Input.DOWN) moveVertical(1);
  }
}

function updateMap() {
  for (let y = map.length - 1; y >= 0; y--) {
    for (let x = 0; x < map[y].length; x++) {
      // ... 맵 업데이트 로직
    }
  }
}

3.5.1 규칙: if Only at the Start (if는 함수의 시작에만)

[if Only at the Start 규칙]: if가 있다면, 그것은 함수의 첫 번째 문장(statement) 이어야 한다.

설명:

  • 무언가를 확인(check) 하는 것은 그 자체로 하나의 책임이다.
  • 따라서 if가 있는 함수는 그 if (및 연결된 else if, else)만 해야 한다.
  • if의 본문(body)과 else는 코드 구조의 일부이므로 별도로 추출하지 않는다. 구조를 바꾸면 동작이 바뀔 수 있으므로 리팩터링 중에는 구조를 유지한다.
  • else if 체인은 원자적(atomic) 단위다. 쪼갤 수 없다.

예시: 소수 출력 함수

// Before: 루프와 if 확인이 하나의 함수에 혼재 (두 가지 책임)
function reportPrimes(n: number) {
  for (let i = 2; i < n; i++)
    if (isPrime(i))
      console.log(`${i} is prime`);
}
// After: 루프(순회 책임)와 조건 확인(판별 책임)을 분리
function reportPrimes(n: number) {
  for (let i = 2; i < n; i++)
    reportIfPrime(i);
}

function reportIfPrime(n: number) {
  if (isPrime(n))
    console.log(`${n} is prime`);
}

3.5.2 규칙 적용: updateMap과 handleInputs

if가 함수 중간에 있는 updateMap 수정:

// Before: for 루프 내부에 if 블록이 중첩 — if가 함수 처음이 아님
function updateMap() {
  for (let y = map.length - 1; y >= 0; y--) {
    for (let x = 0; x < map[y].length; x++) {
      if ((map[y][x] === Tile.STONE || map[y][x] === Tile.FALLING_STONE)
          && map[y + 1][x] === Tile.AIR) {
        map[y + 1][x] = Tile.FALLING_STONE;
        map[y][x] = Tile.AIR;
      } else if ((map[y][x] === Tile.BOX || map[y][x] === Tile.FALLING_BOX)
          && map[y + 1][x] === Tile.AIR) {
        map[y + 1][x] = Tile.FALLING_BOX;
        map[y][x] = Tile.AIR;
      } else if (map[y][x] === Tile.FALLING_STONE) {
        map[y][x] = Tile.STONE;
      } else if (map[y][x] === Tile.FALLING_BOX) {
        map[y][x] = Tile.BOX;
      }
    }
  }
}
// After: if 블록 전체를 updateTile(x, y)로 추출 — updateMap은 5줄 이내로 완료
function updateMap() {
  for (let y = map.length - 1; y >= 0; y--) {
    for (let x = 0; x < map[y].length; x++) {
      updateTile(x, y);
    }
  }
}

function updateTile(x: number, y: number) {
  if ((map[y][x] === Tile.STONE || map[y][x] === Tile.FALLING_STONE)
      && map[y + 1][x] === Tile.AIR) {
    map[y + 1][x] = Tile.FALLING_STONE;
    map[y][x] = Tile.AIR;
  } else if ((map[y][x] === Tile.BOX || map[y][x] === Tile.FALLING_BOX)
      && map[y + 1][x] === Tile.AIR) {
    map[y + 1][x] = Tile.FALLING_BOX;
    map[y][x] = Tile.AIR;
  } else if (map[y][x] === Tile.FALLING_STONE) {
    map[y][x] = Tile.STONE;
  } else if (map[y][x] === Tile.FALLING_BOX) {
    map[y][x] = Tile.BOX;
  }
}

handleInputs에서의 Extract Method 및 이름 개선:

// Before: while 루프 내에 if 체인 혼재
function handleInputs() {
  while (inputs.length > 0) {
    let current = inputs.pop();
    if (current === Input.RIGHT) moveHorizontal(1);
    else if (current === Input.LEFT) moveHorizontal(-1);
    else if (current === Input.DOWN) moveVertical(1);
    else if (current === Input.UP) moveVertical(-1);
  }
}
// After: if 체인을 handleInput으로 추출 — 매개변수 이름도 current → input으로 개선됨
function handleInputs() {
  while (inputs.length > 0) {
    let current = inputs.pop();
    handleInput(current);
  }
}

function handleInput(input: Input) {
  if (input === Input.RIGHT) moveHorizontal(1);
  else if (input === Input.LEFT) moveHorizontal(-1);
  else if (input === Input.DOWN) moveVertical(1);
  else if (input === Input.UP) moveVertical(-1);
}

주목할 인사이트: handleInput은 이미 간결하여 현재 상태로는 Five Lines 규칙을 완전히 충족하기 어렵다 — else if 체인 전체가 하나의 원자 단위이기 때문이다. 이 문제는 4장에서 다루는 우아한 해법(타입 코드 처리)으로 해결한다.


전체 리팩터링 흐름 다이어그램

flowchart TD
    A[긴 함수 발견] --> B{Five Lines 위반?}
    B -- 예 --> C[코드 '모양' 파악\n세부 내용 읽지 않음]
    C --> D[논리적 그룹 식별\n빈 줄 + 임시 주석 추가]
    D --> E[Extract Method 적용\n주석 → 함수 이름]
    E --> F{Either Call or Pass 위반?}
    F -- 예 --> G[같은 수준의 세부 사항을\n함께 추출]
    G --> E
    F -- 아니오 --> H{if Only at the Start 위반?}
    H -- 예 --> I[if 블록 전체를\n별도 함수로 추출]
    I --> E
    H -- 아니오 --> J[규칙 준수 완료]
    B -- 아니오 --> J

챕터 3 요약

핵심 개념 (규칙/패턴) 목적 및 내용
규칙: Five Lines 메서드는 5줄 이하여야 한다. 함수가 한 가지 이상의 일을 하는지 식별하는 기준이 된다
패턴: Extract Method 함수 일부를 새로운 독립 함수로 안전하게 분리한다. 컴파일러를 활용하여 오류 없는 변환을 보장한다
규칙: Either Call or Pass 함수 내에서 같은 객체를 호출하거나 전달하거나 하나만 해야 한다. 추상화 수준을 일정하게 유지하는 데 목적이 있다
좋은 함수 이름 3원칙 정직하고(honest), 완전하고(complete), 도메인 언어를 사용(domain-understandable)해야 한다
규칙: if Only at the Start if는 함수의 첫 번째 문장이어야 한다. 조건 확인은 그 자체로 하나의 책임이므로 독립 함수로 분리한다
Extract Method의 부수 효과 임시 주석 제거, 매개변수 이름 개선, 추상화 수준 균일화를 동시에 달성한다

Chapter 4. Make Type Codes Work: 타입 코드를 클래스로 대체하기

if-else는 코드에 박혀있는 하드코딩된 결정이다. 하드코딩된 상수를 싫어하듯, 하드코딩된 결정도 싫어해야 한다.


4.1 단순 if 문 리팩터링

4.1.1 규칙: Never use if with else (else가 있는 if 사용 금지)

규칙 (Never use if with else): 우리가 제어하지 않는 데이터 타입을 검사하는 경우가 아니라면, ifelse를 함께 사용하지 않는다.

냄새(Smell): 조기 바인딩(Early Binding)

  • if-else는 컴파일 시점에 결정이 고정(lock-in)된다. 이를 조기 바인딩이라 한다.
  • 반대로 지연 바인딩(Late Binding) 은 코드가 실행되는 마지막 순간에 동작이 결정된다.
  • 조기 바인딩은 수정에 의한 변경(change by modification)만 허용하지만, 지연 바인딩은 추가에 의한 변경(change by addition)을 가능하게 한다.

예외 조건

  • 우리가 제어할 수 없는 타입(예: string, KeyboardEvent)을 검사할 때는 else if 체인이 불가피하다.
  • 이런 경우는 반드시 프로그램의 경계(edge), 즉 I/O 처리 지점에만 위치해야 한다.
  • 외부 데이터 타입은 즉시 우리가 제어하는 타입으로 변환(mapping)한다.

독립적인 if(guard clause)와 if-else(decision)의 구분

  • 독립 if: 검사(check). 메서드 시작 부분의 유효성 검증에 허용된다.
  • if-else: 결정(decision). 이것이 이 규칙이 금지하는 대상이다.
// Before: else를 사용한 결정 구조 — Early Binding 냄새
function average(ar: number[]) {
  if (size(ar) === 0) throw "Empty array not allowed";
  else return sum(ar) / size(ar);
}

// After: 독립 if(guard)로 분리하여 else 제거
function assertNotEmpty(ar: number[]) {
  if (size(ar) === 0) throw "Empty array not allowed";
}

function average(ar: number[]) {
  assertNotEmpty(ar);
  return sum(ar) / size(ar);
}

의도(Intent)

  • if-else는 저수준 흐름 제어 연산자다. 객체 지향 프로그래밍에는 더 강력한 흐름 제어 수단, 즉 객체(objects) 가 있다.
  • 인터페이스와 여러 구현 클래스를 사용하면, 어떤 클래스를 인스턴스화하느냐에 따라 실행할 코드가 결정된다.
  • 이 규칙은 개발자가 자연스럽게 더 강력하고 유연한 도구인 객체를 탐색하도록 유도한다.

4.1.2 규칙 적용: enum을 인터페이스로 교체하는 절차

다음은 if-else 체인의 초기 상태이다.

// Before: enum 기반 else if 체인 — 새 값 추가 시 전체 체인 수정 필요
function handleInput(input: Input) {
  if (input === Input.LEFT) moveHorizontal(-1);
  else if (input === Input.RIGHT) moveHorizontal(1);
  else if (input === Input.UP) moveVertical(-1);
  else if (input === Input.DOWN) moveVertical(1);
}

절차 요약

  1. 임시 이름(Input2)의 새 인터페이스를 도입하고, enum 각 값에 대한 is 메서드를 선언한다.
  2. enum 각 값에 대응하는 클래스를 생성한다. 자신에 해당하는 is 메서드만 true를 반환하고 나머지는 false를 반환한다.
  3. 기존 enum을 RawInput으로 이름을 변경하여 컴파일러 오류를 통해 사용처를 모두 찾아낸다.
  4. 타입을 Input2로 변경하고, 동등성 검사(===)를 새 is 메서드 호출로 대체한다.
  5. 나머지 enum 값 참조(Input.RIGHT 등)를 new Right() 형태로 교체한다.
  6. 오류가 사라지면 Input2Input으로 최종 이름 변경한다.

4.1.3 리팩터링 패턴: Replace type code with classes (타입 코드를 클래스로 교체)

항목 내용
설명 enum을 인터페이스로, enum 각 값을 클래스로 변환한다. 값별 기능을 해당 클래스에 국소화할 수 있게 된다.
적용 절차 위 4.1.2 절차와 동일한 6단계를 따른다.
효과 새 값 추가 시 해당 클래스 파일 하나만 추가하면 되고, 기존 코드 수정이 불필요해진다(추가에 의한 변경).

주의사항

  • int 같은 숫자형 타입 코드는 추적이 어렵다. 반드시 먼저 enum으로 변환한 후에 이 패턴을 적용한다.
  • 각 클래스에 생기는 is 메서드들은 임시적이다. 이후 패턴(Push code into classes)을 통해 대부분 제거된다.

TrafficLight 예시

// Before: enum 기반 타입 코드
enum TrafficLight { RED, YELLOW, GREEN }

function updateCarForLight(current: TrafficLight) {
  if (current === TrafficLight.RED) car.stop();
  else car.drive();
}

// After: 인터페이스 + 클래스로 교체
interface TrafficLight {
  isRed(): boolean;
  isYellow(): boolean;
  isGreen(): boolean;
  updateCar(): void;
}

class Red implements TrafficLight {
  isRed() { return true; }
  isYellow() { return false; }
  isGreen() { return false; }
  updateCar() { car.stop(); }
}

class Yellow implements TrafficLight {
  isRed() { return false; }
  isYellow() { return true; }
  isGreen() { return false; }
  updateCar() { car.drive(); }
}

class Green implements TrafficLight {
  isRed() { return false; }
  isYellow() { return false; }
  isGreen() { return true; }
  updateCar() { car.drive(); }
}

function updateCarForLight(current: TrafficLight) {
  current.updateCar(); // if 완전 제거
}

4.1.4 코드를 클래스로 밀어 넣기 (Push code into classes 적용)

is 메서드를 상수값으로 인라인(inline)하면 if (true) / if (false) 구조가 나타나고, 이를 정리하면 각 클래스에 맞는 단일 동작만 남는다.

// Before: if-else가 남아 있는 중간 단계
class Right implements Input {
  handleInput() {
    if (false) moveHorizontal(-1);
    else if (true) moveHorizontal(1); // isRight() 인라인 결과
    else if (false) moveVertical(-1);
    else if (false) moveVertical(1);
  }
}

// After: false 분기 제거 후 단 하나의 동작만 남음
class Right implements Input {
  handle() { moveHorizontal(1); }
}

전체 결과

// After: 모든 if 제거 완료
function handleInput(input: Input) {
  input.handle();
}

interface Input {
  handle(): void;
}

class Left implements Input {
  handle() { moveHorizontal(-1); }
}
class Right implements Input {
  handle() { moveHorizontal(1); }
}
class Up implements Input {
  handle() { moveVertical(-1); }
}
class Down implements Input {
  handle() { moveVertical(1); }
}

4.1.5 리팩터링 패턴: Push code into classes (코드를 클래스로 이동)

항목 내용
설명 함수의 로직을 관련 클래스 안으로 이동시킨다. 기능이 데이터에 국소화되어 불변 조건(invariant) 관리가 쉬워진다.
적용 절차 아래 4단계를 따른다.
효과 if 문이 제거되고, 기능이 해당 값(클래스)에 응집된다.

적용 절차

  1. 원본 함수를 모든 클래스에 복사하여 붙여넣는다. function 키워드를 제거하고, 컨텍스트 파라미터를 this로 교체한다. 이름이 달라 컴파일 오류가 발생한다.
  2. 타깃 인터페이스에 새 메서드 시그니처를 추가하되, 이름을 원본과 약간 다르게 짓는다.
  3. 각 클래스의 새 메서드에서 상수를 반환하는 is 메서드를 인라인하고, if (true) / if (false) { ... } 분기를 제거하여 계산을 완료한다. 이름을 최종 이름으로 변경한다.
  4. 원본 함수의 본문을 새 메서드 호출 한 줄로 교체한다.

4.1.6 불필요한 메서드 인라인하기

리팩터링 후 handleInput처럼 단 한 줄짜리 위임 메서드가 남으면, 그 메서드는 가독성에 기여하지 못하므로 제거한다.

// Before: 단순 위임만 하는 불필요 메서드 존재
function handleInputs() {
  while (inputs.length > 0) {
    let current = inputs.pop();
    handleInput(current);
  }
}
function handleInput(input: Input) {
  input.handle();
}

// After: handleInput 인라인 후 제거
function handleInputs() {
  while (inputs.length > 0) {
    let input = inputs.pop();
    input.handle();
  }
}

4.1.7 리팩터링 패턴: Inline method (메서드 인라인)

항목 내용
설명 더 이상 가독성에 기여하지 않는 메서드를 모든 호출 지점에 인라인하고 삭제한다. Extract method의 정확한 역연산이다.
적용 절차 아래 4단계를 따른다.
효과 불필요한 간접 참조(indirection)를 제거하여 코드베이스가 간결해진다.

적용 절차

  1. 메서드 이름을 임시 이름으로 변경하여 컴파일러 오류로 모든 호출 지점을 찾는다.
  2. 메서드 본문을 복사하고 파라미터를 확인한다.
  3. 오류가 발생하는 모든 호출 지점에서 호출을 본문으로 교체하고, 인수를 파라미터에 매핑한다.
  4. 오류 없이 컴파일되면 원본 메서드를 삭제한다.

인라인하지 말아야 할 경우

  • 추상화 수준이 다른 저수준 연산을 담당하는 메서드는 인라인하지 않는다.
  • 예: 비트 연산으로 구현된 절댓값 함수는 메서드로 두어야 가독성이 높아진다.
// 인라인하면 안 되는 예 — 저수준 구현이 드러나 가독성이 떨어짐
const NUMBER_BITS = 32;
function absolute(x: number) {
  return (x ^ (x >> (NUMBER_BITS - 1))) - (x >> (NUMBER_BITS - 1));
}

4.2 복잡한 if 문 리팩터링

4.2.1 일반성 제거 (Removing Generality)

일반화된 remove 함수의 문제

  • remove(tile: Tile) 함수는 임의의 타일을 제거할 수 있도록 설계되었지만, 실제 호출 코드를 분석하면 Lock1Lock2 두 가지만 사용된다.
  • 불필요한 일반성(generality)은 책임을 흐리고 변경을 어렵게 만든다.
  • 해결책: Specialize method 패턴을 적용해 removeLock1(), removeLock2() 특화 메서드를 도입한다.
// Before: 과도하게 일반화된 함수
function remove(tile: Tile) {
  for (let y = 0; y < map.length; y++)
    for (let x = 0; x < map[y].length; x++)
      if (map[y][x] === tile) map[y][x] = new Air();
}

// After: 실제 사용 패턴에 맞게 특화된 함수들
function removeLock1() {
  for (let y = 0; y < map.length; y++)
    for (let x = 0; x < map[y].length; x++)
      if (map[y][x].isLock1()) map[y][x] = new Air();
}

function removeLock2() {
  for (let y = 0; y < map.length; y++)
    for (let x = 0; x < map[y].length; x++)
      if (map[y][x].isLock2()) map[y][x] = new Air();
}
// 원본 remove는 더 이상 호출되지 않으므로 삭제

4.2.2 리팩터링 패턴: Specialize method (메서드 특화)

항목 내용
설명 과도하게 일반화된 메서드를 실제 사용 패턴에 맞는 특화 버전으로 교체한다. 일반화 본능에 역행하는 패턴이다.
적용 절차 아래 4단계를 따른다.
효과 특화 메서드는 더 좁은 범위에서 호출되어 빠르게 불필요해지고, 결국 삭제된다. 책임이 명확해진다.

적용 절차

  1. 특화할 메서드를 복제한다.
  2. 복사본의 이름을 새 영구 이름으로 변경하고, 특화 기반이 되는 파라미터를 제거하거나 상수로 교체한다.
  3. 오류가 없도록 메서드를 수정하고 단순화한다.
  4. 기존 호출 지점을 새 특화 메서드로 전환한다.

체스 예시

// Before: 일반화된 이동 가능 여부 함수
function canMove(start: Tile, end: Tile, dx: number, dy: number) {
  return (
    dx * Math.abs(start.x - end.x) === dy * Math.abs(start.y - end.y) ||
    dy * Math.abs(start.x - end.x) === dx * Math.abs(start.y - end.y)
  );
}
// 호출: canMove(start, end, 1, 0) // 룩(Rook)

// After: 룩에 특화된 메서드 — 주석 없이도 의도가 명확
function rookCanMove(start: Tile, end: Tile) {
  return Math.abs(start.x - end.x) === 0 || Math.abs(start.y - end.y) === 0;
}
// 호출: rookCanMove(start, end)

4.2.3 유일하게 허용되는 switch

외부 데이터(파일, 데이터베이스 등)에서 읽어온 enum 인덱스를 내부 클래스 객체로 변환할 때, switch가 필요하다. 이것이 프로그램 경계(edge)에서 허용되는 유일한 switch다.

// After: 원시 타일 데이터를 객체로 변환하는 경계 함수
function assertExhausted(x: never): never {
  throw new Error("Unexpected object: " + x);
}

function transformTile(tile: RawTile): Tile {
  switch (tile) {
    case RawTile.AIR: return new Air();
    case RawTile.FLUX: return new Flux();
    case RawTile.STONE: return new Stone();
    case RawTile.FALLING_STONE: return new FallingStone();
    case RawTile.BOX: return new Box();
    case RawTile.FALLING_BOX: return new FallingBox();
    case RawTile.KEY1: return new Key1();
    case RawTile.LOCK1: return new Lock1();
    case RawTile.KEY2: return new Key2();
    case RawTile.LOCK2: return new Lock2();
    case RawTile.PLAYER: return new Player();
    case RawTile.UNBREAKABLE: return new Unbreakable();
    default: assertExhausted(tile); // 컴파일러가 모든 케이스 처리 여부를 보장
  }
}

assertExhausted는 TypeScript 특유의 기법으로, default 분기에서 never 타입을 활용해 컴파일러가 모든 enum 값의 처리 여부를 검증하게 만든다.


4.2.4 규칙: Never use switch (switch 사용 금지)

규칙 (Never use switch): default가 없고 모든 case에서 return하는 경우가 아니라면 switch를 사용하지 않는다.

switch의 두 가지 위험 요소

  • default의 위험: 새 값이 추가될 때 컴파일러가 처리 누락을 알려주지 않는다. 의도된 기본 처리와 실수로 인한 누락을 구별할 수 없다.
  • fall-through의 위험: break를 누락하면 의도치 않게 다음 case가 실행된다. 이 버그는 발견하기 어렵다.

해결책

  • default를 두지 않는다.
  • 모든 case에서 return한다. 이렇게 하면 fall-through가 불가능해지고 break가 필요 없어진다.

냄새(Smell): Martin Fowler의 책 Refactoring 에서 switch는 그 자체로 코드 냄새(smell)다. switch는 “여기서 값 X를 어떻게 처리할까”라는 컨텍스트 중심 사고를 유도한다. 반면 클래스로 코드를 밀어 넣는 것은 “이 객체(데이터)가 상황 X를 어떻게 처리하는가”라는 데이터 중심 사고를 유도한다.


4.2.5 복합 조건의 ||를 메서드로 추출

moveHorizontal처럼 ||로 연결된 복합 조건은 도메인 지식을 표현한다. 이를 보존하면서 클래스로 밀어 넣으려면 먼저 의미 있는 이름의 메서드로 추출한다.

// Before: 도메인 의미가 없는 원시 조건식
function moveHorizontal(dx: number) {
  if (map[playery][playerx + dx].isFlux() || map[playery][playerx + dx].isAir()) {
    moveToTile(playerx + dx, playery);
  } else if (
    (map[playery][playerx + dx].isStone() || map[playery][playerx + dx].isBox()) &&
    map[playery][playerx + dx + dx].isAir() &&
    !map[playery + 1][playerx + dx].isAir()
  ) {
    map[playery][playerx + dx + dx] = map[playery][playerx + dx];
    moveToTile(playerx + dx, playery);
  }
  // ...
}

// After: ||를 도메인 메서드로 추출한 후 Push code into classes 적용
function moveHorizontal(dx: number) {
  map[playery][playerx + dx].moveHorizontal(dx);
}

interface Tile {
  isEdible(): boolean;   // isFlux() || isAir()
  isPushable(): boolean; // isStone() || isBox()
  moveHorizontal(dx: number): void;
}

class Air implements Tile {
  isEdible() { return true; }
  isPushable() { return false; }
  moveHorizontal(dx: number) { moveToTile(playerx + dx, playery); }
}

class Box implements Tile {
  isEdible() { return false; }
  isPushable() { return true; }
  moveHorizontal(dx: number) {
    if (map[playery][playerx + dx + dx].isAir() && !map[playery + 1][playerx + dx].isAir()) {
      map[playery][playerx + dx + dx] = this;
      moveToTile(playerx + dx, playery);
    }
  }
}

class Key1 implements Tile {
  isEdible() { return false; }
  isPushable() { return false; }
  moveHorizontal(dx: number) {
    removeLock1();
    moveToTile(playerx + dx, playery);
  }
}

핵심 인사이트: Push code into classes는 등가 비교 체인(is 메서드 체인)뿐 아니라, 동일한 인스턴스에 대해 메서드가 반복 호출되는 모든 경우(점(.)의 왼쪽이 동일한 경우)에도 적용 가능하다.


4.3 코드 중복 처리

4.3.1 규칙: Only inherit from interfaces (인터페이스만 상속한다)

규칙 (Only inherit from interfaces): 클래스나 추상 클래스(abstract class)가 아닌, 인터페이스(interface)만 상속(구현)한다.

추상 클래스를 피해야 하는 이유

  • 추상 클래스의 공유 코드는 결합도(Coupling) 를 만든다.
  • 일부 서브클래스에만 필요한 메서드가 추상 클래스에 있으면, 다른 서브클래스는 빈 메서드로 오버라이드해야 한다.
  • 기본 구현(default implementation)이 있으면 새 서브클래스를 추가할 때 컴파일러가 해당 메서드 처리를 강제하지 않는다.

인터페이스를 강제하는 이유

  • 새 클래스를 추가할 때 인터페이스의 모든 메서드를 명시적으로 구현하도록 강제된다.
  • 6개월 후 코드를 잊어도 새 타일 추가 시 어떤 메서드를 구현해야 하는지 컴파일러가 알려준다.

냄새: GoF(Gang of Four)의 디자인 패턴 책에 있는 원칙 “상속보다 객체 합성을 선호하라(Favor object composition over inheritance)“에서 도출된 규칙이다.

코드 공유가 필요한 경우: 상속 대신 다른 공유 클래스를 참조하는 방식으로 해결한다. 5장의 전략 패턴(Introduce strategy pattern)이 이를 다룬다.


4.3.2 코드 중복에 대한 올바른 이해

코드 중복이 나쁜 이유는 유지보수 시 변경이 전파되기 때문이다. 즉, 수렴해야 할 코드가 중복되어 있을 때 나쁘다.

  • 각 타일의 그래픽(draw 메서드)은 타일마다 달라야 하므로 발산(diverge)이 올바른 상태다.
  • 이 경우 중복처럼 보이는 코드는 실제로 독립적인 기능이므로, 공유 코드로 만들면 오히려 변경 유연성을 잃는다.
flowchart TD
    A[코드 중복 발견] --> B{미래에 함께 변경되어야 하는가?}
    B -- 예 / 수렴해야 함 --> C[공유 로직으로 통합\nStrategy Pattern 등]
    B -- 아니오 / 독립 변경 가능 --> D[중복 허용\n각 클래스에 개별 구현 유지]

4.4 복잡한 if 문 쌍 리팩터링

drawMapdrawTile 추출 후 Push code into classes 적용 전체 흐름:

flowchart TD
    A["drawMap\n(else if 체인)"] -->|"Extract method (P3.2.1)"| B["colorOfTile\n(is메서드 체인)"]
    B -->|"Replace type code with classes (P4.1.3)"| C["Tile 인터페이스 + 클래스"]
    C -->|"Push code into classes (P4.1.5)"| D["Tile.color(g)"]
    D -->|"Inline method (P4.1.7)"| E["drawMap에서 직접 호출"]
    E -->|"Extract method (P3.2.1)"| F["drawTile(g, x, y)"]
    F -->|"Push code into classes (P4.1.5)"| G["Tile.draw(g, x, y)"]
    G -->|"Inline method (P4.1.7)"| H["drawMap: map[y][x].draw(g, x, y)"]
// After: drawMap 최종 상태 — 규칙 모두 준수
function drawMap(g: CanvasRenderingContext2D) {
  for (let y = 0; y < map.length; y++)
    for (let x = 0; x < map[y].length; x++)
      map[y][x].draw(g, x, y);
}

interface Tile {
  color(g: CanvasRenderingContext2D): void;
  draw(g: CanvasRenderingContext2D, x: number, y: number): void;
}

class Air implements Tile {
  color(_g: CanvasRenderingContext2D) { /* 색상 없음 */ }
  draw(_g: CanvasRenderingContext2D, _x: number, _y: number) { /* 그리지 않음 */ }
}

class Flux implements Tile {
  color(g: CanvasRenderingContext2D) { g.fillStyle = "#ccffcc"; }
  draw(g: CanvasRenderingContext2D, x: number, y: number) {
    g.fillStyle = "#ccffcc";
    g.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
  }
}

4.5 불필요한 코드 제거

4.5.1 리팩터링 패턴: Try delete then compile (삭제 후 컴파일)

항목 내용
설명 인터페이스에서 메서드를 삭제해 보고, 컴파일 오류 여부로 해당 메서드의 사용 여부를 판단한다.
적용 절차 아래 3단계를 따른다.
효과 불필요한 코드를 제거하여 코드베이스를 가볍게 유지한다. 컴파일과 분석 속도, 테스트 용이성이 향상된다.

적용 절차

  1. 오류 없이 컴파일되는 상태에서 시작한다.
  2. 인터페이스에서 메서드 하나를 삭제한다.
  3. 다시 컴파일한다.
    • 오류가 발생하면 실행 취소(undo)하고 다음 메서드로 넘어간다.
    • 오류가 없으면 각 구현 클래스에서도 같은 메서드를 삭제 시도한다.

왜 IDE 자동 분석을 믿을 수 없는가?

  • IDE는 인터페이스에 선언된 메서드를 “외부에서 사용될 수 있다”고 가정하므로 unused 표시를 하지 않는다.
  • 우리가 직접 도입한 인터페이스는 전체 스코프를 알기 때문에 이 패턴을 안전하게 적용할 수 있다.
  • 새 기능을 구현하는 중에는 적용하지 않는다. 아직 사용되지 않는 메서드를 미리 삭제할 수 있기 때문이다.
// Before: 사용 여부가 불분명한 인터페이스 메서드 존재
interface A {
  m1(): void;
  m2(): void;
}

class B implements A {
  m1() { console.log("m1"); }
  m2() { this.m3(); }
  m3() { console.log("m3"); }
}

const a = new B();
a.m1();
// m2, m3은 실제로 사용되지 않음 — Try delete then compile으로 발견 가능

챕터 4 요약

핵심 개념 (규칙/패턴) 목적 및 내용
규칙 Never use if with else else는 조기 바인딩을 유발한다. 우리가 제어하는 타입이면 클래스로 결정을 지연시킨다.
규칙 Never use switch default와 fall-through로 인한 버그를 방지한다. 경계 변환에서만, default 없이 모든 casereturn할 때만 허용한다.
규칙 Only inherit from interfaces 추상 클래스의 공유 코드는 결합도를 높인다. 인터페이스만 상속하여 새 구현 추가 시 컴파일러의 완전성 검증을 받는다.
패턴 Replace type code with classes enum을 인터페이스+클래스로 변환한다. 값 추가가 기존 코드 수정 없이 가능해진다.
패턴 Push code into classes if-else 체인이나 반복 메서드 호출을 해당 클래스 내부로 이동시킨다. if가 제거되고 기능이 데이터에 응집된다.
패턴 Specialize method 과도한 일반성을 제거한다. 실제 호출 패턴에 맞는 특화 메서드를 도입하고 원본을 삭제한다.
패턴 Inline method 가독성에 기여하지 않는 단순 위임 메서드를 제거한다. Extract method의 역연산이다.
패턴 Try delete then compile 인터페이스에서 사용되지 않는 메서드를 발견하고 제거한다. IDE 분석의 한계를 수동으로 보완한다.

Chapter 5. Fuse Similar Code Together: 유사한 코드 통합하기

코드의 구조는 이미 코드 안에 있다. 우리의 임무는 그것을 발견하고 표면으로 드러내는 것이다.

이 챕터는 updateTile 함수를 출발점으로 삼아, 유사한 클래스/조건/메서드를 하나로 통합하는 일련의 리팩터링 패턴을 다룬다. 중심 주제는 “중복이 숨겨둔 구조를 꺼내 명시적으로 만드는 것”이다.


5.1 유사한 클래스 통합 (Unify Similar Classes)

5.1.1 문제 상황: 상수 메서드(Constant Method)를 가진 쌍둥이 클래스

StoneFallingStoneisFallingStone()의 반환값과 moveHorizontal()의 구현만 다를 뿐, 나머지 모든 메서드가 동일하다. 이처럼 상수를 반환하는 메서드만 다른 클래스들은 하나로 병합할 수 있다.

  • 상수 메서드(Constant Method): 항상 고정된 리터럴을 반환하는 메서드
  • 기저(Basis): 클래스들을 구별하는 상수 메서드의 집합. X개의 클래스를 통합하려면 최대 (X-1)개의 기저 메서드가 필요하다.

5.1.2 리팩터링 패턴: Unify Similar Classes (P5.1.1)

  • 설명: 동일한 상수 메서드 집합(기저)에서 서로 다른 값을 반환하는 클래스들을 하나의 클래스로 병합한다.
  • 적용 절차:
  1. Phase 1 — 기저 외 메서드 동일화: 각 메서드 본문을 if (true) { } 로 감싼다. true를 각 클래스의 기저 메서드 조건식으로 교체한다. 서로의 메서드 본문을 else if 로 복사·붙여넣기한다.
  2. Phase 2 — 클래스 병합: 기저 메서드의 반환값을 저장할 필드를 도입한다. 각 기저 메서드가 상수 대신 필드를 반환하도록 수정한다. 필드를 생성자 파라미터로 승격한다. 컴파일 오류를 따라 기존 호출부에 기본값 인수를 삽입한다. 기저 클래스 하나만 남기고 나머지를 삭제하며 모든 참조를 교체한다.
  • 효과: 클래스 수 감소, 숨겨진 구조(타입 코드) 표면화
// Before: Stone과 FallingStone이 두 개의 분리된 클래스로 존재
class Stone implements Tile {
  isFallingStone() { return false; }
  moveHorizontal(dx: number) {
    if (map[playery][playerx+dx+dx].isAir()
      && !map[playery+1][playerx+dx].isAir()) {
      map[playery][playerx+dx+dx] = this;
      moveToTile(playerx+dx, playery);
    }
  }
}
class FallingStone implements Tile {
  isFallingStone() { return true; }
  moveHorizontal(dx: number) { }
}
// After: 하나의 클래스로 통합, FallingState 인터페이스로 타입 코드 제거
interface FallingState {
  isFalling(): boolean;
  moveHorizontal(tile: Tile, dx: number): void;
}
class Falling implements FallingState {
  isFalling() { return true; }
  moveHorizontal(tile: Tile, dx: number) { }
}
class Resting implements FallingState {
  isFalling() { return false; }
  moveHorizontal(tile: Tile, dx: number) {
    if (map[playery][playerx+dx+dx].isAir()
      && !map[playery+1][playerx+dx].isAir()) {
      map[playery][playerx+dx+dx] = tile;
      moveToTile(playerx+dx, playery);
    }
  }
}
class Stone implements Tile {
  constructor(private falling: FallingState) { }
  isFallingStone() { return this.falling.isFalling(); }
  moveHorizontal(dx: number) {
    this.falling.moveHorizontal(this, dx);
  }
}

핵심 통찰: 클래스를 병합하면 불리언 플래그나 열거형(enum) 형태의 숨겨진 타입 코드가 드러난다. 이를 다시 “타입 코드를 클래스로 교체(Replace Type Code with Classes, P4.1.3)” 패턴으로 처리한다.

TypeScript 생성자 단축 문법:

// Before: 명시적 필드 선언과 할당
class Stone implements Tile {
  private falling: FallingState;
  constructor(falling: FallingState) {
    this.falling = falling;
  }
}

// After: 파라미터 앞에 접근 제어자를 붙여 자동 필드 생성
class Stone implements Tile {
  constructor(private falling: FallingState) { }
}

5.1.3 TrafficLight 예시 (세 클래스 통합)

Red, Yellow, Green이라는 세 개의 신호등 클래스를 하나로 통합하는 과정이 8단계 Before/After로 상세 제시된다. 기저 메서드는 color()이며, 비기저 메서드인 check()를 동일화한 뒤 필드로 승격하여 Red(col: string) 하나로 합친다.


5.2 단순 조건 통합 (Combine Ifs)

5.2.1 문제 상황

isFallingStone()isFallingBox() 를 각각 검사하는 두 if 블록의 본문이 완전히 동일해졌다. 동일한 본문을 가진 연속된 if 문은 || 로 조건을 합쳐 하나로 줄일 수 있다.

5.2.2 리팩터링 패턴: Combine Ifs (P5.2.1)

  • 설명: 연속된 if/else if 블록의 본문이 동일할 때, 두 조건을 || 로 연결하여 단일 if로 병합한다.
  • 적용 절차:
  1. 두 블록의 본문이 정말 동일한지 확인한다.
  2. if 사이의 } else if ( 를 삭제하고 || 를 삽입한다. 전체 조건을 괄호로 감싼다.
  3. 단순한 표현식이라면 불필요한 괄호를 제거한다.
  • 효과: 두 조건 사이의 관계를 || 로 명시화 → 인터페이스 메서드로 캡슐화 가능
// Before: 동일한 본문을 가진 두 if 블록
if (map[y][x].isFallingStone()) {
  map[y][x].rest();
} else if (map[y][x].isFallingBox()) {
  map[y][x].rest();
}

// After: || 로 조건 통합 후 isFalling() 메서드로 캡슐화
if (map[y][x].isFalling()) {
  map[y][x].rest();
}

핵심 통찰: 이 패턴 자체는 단순하지만, || 표현을 인터페이스 메서드로 흡수하는 후속 패턴을 가능하게 만드는 촉매 역할을 한다.


5.3 복잡한 조건 통합 (Unifying Complex Conditions)

5.3.1 조건 산술(Condition Arithmetic)

조건 표현식은 내용을 알지 못해도 수학 규칙으로 변환·단순화할 수 있다.

  • ||덧셈(+) 처럼 동작한다.
  • &&곱셈(×) 처럼 동작한다.
  • 따라서 일반 산술의 교환법칙, 결합법칙, 분배법칙이 그대로 적용된다.
  • 기억법: ||의 두 줄이 +를 이루고, & 안에 ×가 숨어 있다.
// 분배법칙 적용 예시
isStony() && isAir() || isBoxy() && isAir()
= isStony() * isAir() + isBoxy() * isAir()   // 수학 표현으로
= (isStony() + isBoxy()) * isAir()           // 분배법칙
= (isStony() || isBoxy()) && isAir()         // 코드로 역변환

이를 통해 (isStony() || isBoxy()) && isAir() 에서 || 부분을 canFall() 이라는 메서드로 클래스 안으로 밀어 넣을 수 있다.

// After: 조건 산술로 단순화 후 canFall()로 캡슐화
function updateTile(x: number, y: number) {
  if (map[y][x].canFall() && map[y+1][x].isAir()) {
    map[y][x].drop();
    map[y+1][x] = map[y][x];
    map[y][x] = new Air();
  } else if (map[y][x].isFalling()) {
    map[y][x].rest();
  }
}

5.3.2 규칙: Use Pure Conditions (R5.3.2)

[Use Pure Conditions (R5.3.2)]: 조건식(if, while, for의 조건 부분)은 항상 순수(pure)해야 한다. 순수하다는 것은 부수 효과(side effect)가 없다는 의미이다. 부수 효과란 변수 값 할당, 예외 발생, I/O 조작 등을 포함한다.

  • 이유 1: 조건에 부수 효과가 있으면 위의 조건 산술 규칙을 적용할 수 없다.
  • 이유 2: 조건의 부수 효과는 개발자가 예상하지 못하므로 발견 비용이 높다.
  • 원칙 출처: “쿼리와 커맨드를 분리하라(Separate Queries from Commands)” — Richard Mitchell & Jim McKim
// Before: 부수 효과가 있는 조건 (readLine이 포인터를 전진시킴)
let line: string | null;
while ((line = br.readLine()) !== null) {
  console.log(line);
}

// After: 부수 효과(nextLine)와 조회(readLine)를 분리
for (; br.readLine() !== null; br.nextLine()) {
  const line = br.readLine();
  console.log(line);
}

제어할 수 없는 외부 API처럼 분리가 불가능한 경우, Cacher<T> 유틸리티 클래스로 부수 효과를 분리한다.

// After: Cacher를 이용한 부수 효과 격리
class Cacher<T> {
  private data: T;
  constructor(private mutator: () => T) {
    this.data = this.mutator();
  }
  get() { return this.data; }
  next() { this.data = this.mutator(); }
}

const tmpBr = new Reader();
const br = new Cacher(() => tmpBr.readLine());
for (; br.get() !== null; br.next()) {
  const line = br.get();
  console.log(line);
}

5.4 클래스 간 코드 통합 (Unify Code Across Classes)

5.4.1 전략 패턴 도입 배경

updateTile을 클래스 안으로 밀어 넣은 후, StoneBox 두 클래스에 **완전히 동일한 낙하 로직(update 메서드)**이 생겼다. 이는 수렴해야 할 중복이므로 전략 패턴(Strategy Pattern) 을 도입해 공통 로직을 FallStrategy라는 별도 클래스로 추출한다.

flowchart TD
    Stone["Stone\n+update(x,y)\n+moveHorizontal(dx)"] -->|has a| FallStrategy["FallStrategy\n+update(tile,x,y)\n+drop(tile,x,y)"]
    Box["Box\n+update(x,y)"] -->|has a| FallStrategy
    FallStrategy -->|has a| FallingState["FallingState\n+isFalling()"]
    FallingState --> Falling["Falling\n+isFalling(): true"]
    FallingState --> Resting["Resting\n+isFalling(): false"]

5.4.2 UML 클래스 다이어그램 기초 (5.4.1)

UML 클래스 다이어그램은 코드의 구조적 특성을 시각화하기 위해 사용한다.

관계 종류 표기 의미 예시
구현 (Implementation) 점선 삼각형 화살표 X implements Y Stone → Tile
합성 (Composition) 채워진 다이아몬드 X has a Y (X 소유) Stone → FallStrategy
집합 (Aggregation) 빈 다이아몬드 X has a Y (독립 존재) 일반적 참조 관계

실용 규칙: Only Inherit from Interfaces (R4.3.2)에 의해 상속 화살표는 사용하지 않는다. 실무에서는 구현합성 두 가지 관계로 대부분을 표현할 수 있다.

5.4.3 리팩터링 패턴: Introduce Strategy Pattern (P5.4.2)

  • 설명: if 문이나 분기 로직을 별도의 클래스로 이동시켜 변경을 클래스 추가로 처리할 수 있게 만든다. 전략 패턴의 핵심 가치는 런타임에 전혀 알려지지 않은 클래스를 seamless하게 통합하는 최고 수준의 늦은 바인딩(late binding)이다.
  • 적용 절차:
  1. 격리할 코드에 메서드 추출(Extract Method)을 적용한다. 통합이 목적이라면 두 메서드가 동일해지도록 만든다.
  2. 새로운 클래스를 생성한다.
  3. 생성자에서 새 클래스를 인스턴스화한다.
  4. 추출한 메서드를 새 클래스로 이동시킨다.
  5. 필드 의존성이 있으면 필드를 새 클래스로 이전하고 접근자(accessor)를 만든다. 원래 클래스의 오류를 새 접근자로 수정한다.
  6. 새 클래스의 잔여 오류를 해결하기 위해 this를 대체하는 파라미터를 추가한다.
  7. 1단계의 추출을 인라인 메서드(Inline Method)로 되돌린다.
  • 효과: 동일한 로직을 여러 클래스에서 재사용, 변경의 진원지를 단일화
// Before: Stone과 Box에 동일한 update 로직이 중복
class Stone implements Tile {
  constructor(private falling: FallingState) { }
  update(x: number, y: number) {
    if (map[y+1][x].isAir()) {
      this.falling = new Falling();
      map[y+1][x] = this;
      map[y][x] = new Air();
    } else if (this.falling.isFalling()) {
      this.falling = new Resting();
    }
  }
}
// After: FallStrategy로 공통 로직 추출
class FallStrategy {
  constructor(private falling: FallingState) { }
  getFalling() { return this.falling; }

  update(tile: Tile, x: number, y: number) {
    this.falling = map[y+1][x].isAir() ? new Falling() : new Resting();
    this.drop(tile, x, y);
  }

  private drop(tile: Tile, x: number, y: number) {
    if (this.falling.isFalling()) {
      map[y+1][x] = tile;
      map[y][x] = new Air();
    }
  }
}

class Stone implements Tile {
  private fallStrategy: FallStrategy;
  constructor(falling: FallingState) {
    this.fallStrategy = new FallStrategy(falling);
  }
  update(x: number, y: number) {
    this.fallStrategy.update(this, x, y);
  }
  moveHorizontal(dx: number) {
    this.fallStrategy.getFalling().moveHorizontal(this, dx);
  }
}

전략 패턴 vs. 상태 패턴: 전략 클래스에 필드가 있으면 상태 패턴(State Pattern)이라 부르기도 하나, 이는 학술적 구분에 불과하다. 핵심 개념은 동일하다.

5.4.4 규칙: No Interface with Only One Implementation (R5.4.3)

[No Interface with Only One Implementation (R5.4.3)]: 구현체가 하나뿐인 인터페이스는 만들지 않는다.

  • 이유: 구현체가 하나인 인터페이스는 가독성을 높이지 않고 오히려 불필요한 일반화(unnecessary generality)인지 오버헤드를 증가시킨다. 인터페이스는 변형 가능성(variation)의 신호인데, 구현체가 하나뿐이면 거짓 신호를 보내는 셈이다.
  • 냄새 출처: John Carmack — “추상화는 실제 복잡도를 높이는 대가로 인지된 복잡도를 낮춘다.” 불필요한 추상화는 피해야 한다.
  • 예외: 익명 내부 클래스를 위한 인터페이스는 구현체가 0개일 수 있으며 이는 허용된다.

5.4.5 리팩터링 패턴: Extract Interface from Implementation (P5.4.4)

  • 설명: 인터페이스가 필요해지는 시점(변형을 도입할 때)까지 인터페이스 생성을 뒤로 미룬다. 필요한 시점이 되면 이 패턴으로 클래스에서 인터페이스를 추출한다.
  • 적용 절차:
  1. 추출할 클래스와 동일한 이름의 빈 인터페이스를 생성한다.
  2. 원래 클래스의 이름을 변경하고 새 인터페이스를 구현(implements)하도록 선언한다.
  3. 컴파일 오류를 따라 순서대로 수정한다. new 오류는 새 클래스 이름으로 교체하고, 그 외 오류는 해당 메서드를 인터페이스에 추가한다.
  • 효과: 사전에 인터페이스를 설계하지 않아도 변형이 필요한 시점에 빠르게 인터페이스 도입 가능
// Before: SumProcessor 단독 클래스
class SumProcessor {
  constructor(private accumulator: number) { }
  getAccumulator() { return this.accumulator; }
  processElement(e: number) { this.accumulator += e; }
}

// Step 1: 동일한 이름의 빈 인터페이스 생성
interface SumProcessor { }

// Step 2: 클래스 이름 변경 및 인터페이스 구현 선언
class TmpName implements SumProcessor {
  constructor(private accumulator: number) { }
  getAccumulator() { return this.accumulator; }
  processElement(e: number) { this.accumulator += e; }
}

// Step 3: 컴파일 오류 수정 후 최종 결과
interface ElementProcessor {
  processElement(e: number): void;
  getAccumulator(): number;
}
class SumProcessor implements ElementProcessor {
  constructor(private accumulator: number) { }
  getAccumulator() { return this.accumulator; }
  processElement(e: number) { this.accumulator += e; }
}
class MinimumProcessor implements ElementProcessor {
  constructor(private accumulator: number) { }
  getAccumulator() { return this.accumulator; }
  processElement(e: number) {
    if (this.accumulator > e) this.accumulator = e;
  }
}

5.5 유사한 함수 통합 (Unifying Similar Functions)

removeLock1removeLock2는 검사 조건(isLock1 vs isLock2)만 다를 뿐 구조가 동일하다. 전략 패턴을 적용해 변화하는 부분(조건 검사)을 RemoveStrategy 인터페이스로 추출한다.

// Before: 거의 동일한 두 함수
function removeLock1() {
  for (let y = 0; y < map.length; y++)
    for (let x = 0; x < map[y].length; x++)
      if (map[y][x].isLock1())
        map[y][x] = new Air();
}
function removeLock2() {
  for (let y = 0; y < map.length; y++)
    for (let x = 0; x < map[y].length; x++)
      if (map[y][x].isLock2())
        map[y][x] = new Air();
}
// After: 전략 패턴으로 통합
interface RemoveStrategy {
  check(tile: Tile): boolean;
}
class RemoveLock1 implements RemoveStrategy {
  check(tile: Tile) { return tile.isLock1(); }
}
class RemoveLock2 implements RemoveStrategy {
  check(tile: Tile) { return tile.isLock2(); }
}

function remove(shouldRemove: RemoveStrategy) {
  for (let y = 0; y < map.length; y++)
    for (let x = 0; x < map[y].length; x++)
      if (shouldRemove.check(map[y][x]))
        map[y][x] = new Air();
}

class Key1 implements Tile {
  moveHorizontal(dx: number) {
    remove(new RemoveLock1());
    moveToTile(playerx + dx, playery);
  }
}

핵심 통찰: 이 변환은 새로운 잠금 타일을 추가하고 싶을 때 remove 함수를 수정하지 않고 새 클래스만 추가하면 된다는 점에서 “변경에 의한 확장(enable change by addition)“을 실현한다.


5.6 유사한 코드 통합 (Unifying Similar Code)

Key1/Key2Lock1/Lock2 쌍도 색상 코드만 다를 뿐 거의 동일하다. Unify Similar Classes (P5.1.1) 를 적용해 각 쌍을 KeyLock으로 통합한다.

이후, KeyLock의 생성자 파라미터(color, removeStrategy, lock1)들이 서로 연결되어 있다는 구조적 사실을 발견한다. 이를 KeyConfiguration이라는 새로운 클래스로 묶어 표현한다.

// After: Key와 Lock의 연결된 파라미터를 KeyConfiguration으로 캡슐화
class KeyConfiguration {
  constructor(
    private color: string,
    private _1: boolean,
    private removeStrategy: RemoveStrategy
  ) { }
  getColor() { return this.color; }
  is1() { return this._1; }
  getRemoveStrategy() { return this.removeStrategy; }
}

const YELLOW_KEY = new KeyConfiguration("#ffcc00", true, new RemoveLock1());

class Key implements Tile {
  constructor(private keyConf: KeyConfiguration) { }
  draw(g: CanvasRenderingContext2D, x: number, y: number) {
    g.fillStyle = this.keyConf.getColor();
    g.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
  }
  moveHorizontal(dx: number) {
    remove(this.keyConf.getRemoveStrategy());
    moveToTile(playerx + dx, playery);
  }
}

class Lock implements Tile {
  constructor(private keyConf: KeyConfiguration) { }
  isLock1() { return this.keyConf.is1(); }
  isLock2() { return !this.keyConf.is1(); }
  draw(g: CanvasRenderingContext2D, x: number, y: number) {
    g.fillStyle = this.keyConf.getColor();
    g.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
  }
}

function transformTile(tile: RawTile) {
  switch (tile) {
    case RawTile.KEY1: return new Key(YELLOW_KEY);
    case RawTile.LOCK1: return new Lock(YELLOW_KEY);
  }
}

핵심 통찰: 코드 통합 과정에서 “색상과 잠금 ID가 연결되어 있다”는 사실이 코드 구조 속에 이미 암묵적으로 존재하던 것이 명시화되었다. 이처럼 리팩터링은 종종 숨겨진 도메인 개념을 발굴하는 수단이 된다. 연결이 우연적인지 의도적인지 확인하는 작업이 중요하다.


챕터 5 요약

핵심 개념 (규칙/패턴) 목적 및 내용
P5.1.1 Unify Similar Classes 상수 메서드만 다른 클래스들을 하나로 병합. 2단계(동일화 → 통합) 프로세스 적용
P5.2.1 Combine Ifs 동일 본문을 가진 연속된 if/else if| 로 합쳐 관계를 명시화
R5.3.2 Use Pure Conditions 조건식에 부수 효과를 두지 않는다. 조건 산술 적용을 위한 전제 조건
조건 산술 | = 덧셈, && = 곱셈으로 보아 수학적 분배법칙으로 조건식을 단순화
P5.4.2 Introduce Strategy Pattern 변화하는 동작을 별도 클래스로 추출. 런타임 다형성과 늦은 바인딩의 극치
R5.4.3 No Interface with Only One Implementation 구현체가 하나뿐인 인터페이스는 불필요한 일반화이므로 생성하지 않는다
P5.4.4 Extract Interface from Implementation 변형이 필요한 시점까지 인터페이스 생성을 미루고, 필요 시 클래스에서 추출
KeyConfiguration 도입 연결된 파라미터를 하나의 클래스로 묶어 암묵적 도메인 개념을 명시화

Chapter 6. Defend the Data: 데이터 보호하기

불변 조건(Invariant)을 국소화하는 것이 버그 예방의 핵심이다. 캡슐화는 불변 조건이 깨질 수 있는 범위를 클래스 내부로 제한하여, 문제를 추적하고 예방하는 비용을 획기적으로 낮춘다.

챕터 2에서 불변 조건의 국소화(Localization)가 갖는 이점을 논했다. 클래스를 도입하면 관련 기능과 데이터가 응집되어 불변 조건이 자연스럽게 좁은 범위로 모인다. 이 챕터는 한 발 더 나아가, 데이터와 기능에 대한 접근 자체를 제한하는 캡슐화(Encapsulation)에 집중한다.


6.1 Encapsulating without getters: 게터 없는 캡슐화

6.1.1 Rule: Do not use getters or setters (게터/세터를 사용하지 않는다)

[규칙 R6.1.1] Do not use getters or setters: Boolean이 아닌 필드에 대해 직접 값을 반환하거나 대입하는 게터/세터 메서드를 사용하지 않는다.

왜 게터/세터가 문제인가

  • 게터의 문제: 객체를 반환하는 순간 캡슐화가 깨진다. 반환된 객체는 외부로 재배포될 수 있고, 외부 코드가 해당 객체의 퍼블릭 메서드를 호출해 내부 상태를 예측 불가능하게 변경한다. 이로 인해 불변 조건이 전역(global)으로 번진다.
  • 세터의 문제: 내부 자료구조가 바뀌면 게터의 반환 타입도 함께 바꿔야 하고, 이를 사용하는 외부 코드도 전부 수정해야 한다. 이것이 우리가 피하고자 하는 **강한 결합도(Tight Coupling)**이다.
  • 예외: Boolean 필드는 허용한다. Boolean은 그 자체로 의미가 완결되며, 아키텍처 문제를 일으키지 않는다.

풀 기반 아키텍처 vs 푸시 기반 아키텍처

구분 풀 기반(Pull-based) 푸시 기반(Push-based)
작동 방식 중앙에서 데이터를 가져와(fetch) 처리 연산을 데이터가 있는 곳으로 밀어 넣음(push)
클래스 특성 메서드 없는 “dumb” 데이터 클래스 + 모든 것을 처리하는 거대 Manager 클래스 모든 클래스가 자체 기능을 가짐
결합도 데이터 클래스와 Manager 간 강한 결합 느슨한 결합, 메서드는 서비스처럼 외부에 제공
핵심 원칙 데이터를 “가져온다” 데이터를 인수(argument)로 “전달한다”

코드 예시: 블로그 포스트 링크 생성

// Before: 풀 기반 - 게터로 데이터를 꺼내 중앙에서 조합
class Website {
  constructor(private url: string) {}
  getUrl() { return this.url; }
}
class User {
  constructor(private username: string) {}
  getUsername() { return this.username; }
}
class BlogPost {
  constructor(private author: User, private id: string) {}
  getId() { return this.id; }
  getAuthor() { return this.author; }
}
function generatePostLink(website: Website, post: BlogPost) {
  const url = website.getUrl();
  const user = post.getAuthor();    // Stranger 객체 획득
  const name = user.getUsername();  // Stranger에게 말 걸기 (Law of Demeter 위반)
  const postId = post.getId();
  return url + name + postId;
}
// After: 푸시 기반 - 연산을 데이터 클래스 안으로 밀어 넣음
class Website {
  constructor(private url: string) {}
  generateLink(name: string, id: string) {
    return this.url + name + id;
  }
}
class User {
  constructor(private username: string) {}
  generateLink(website: Website, id: string) {
    return website.generateLink(this.username, id);
  }
}
class BlogPost {
  constructor(private author: User, private id: string) {}
  generateLink(website: Website) {
    return this.author.generateLink(website, this.id);
  }
}
function generatePostLink(website: Website, post: BlogPost) {
  return post.generateLink(website); // 단 한 줄 - 사실상 인라인 가능
}

냄새(Smell): 데메테르 법칙(Law of Demeter)

  • “낯선 사람에게 말 걸지 말라(Don’t talk to strangers)“로 요약된다.
  • 낯선 사람이란 직접 참조하지 않고 게터를 통해 얻은 객체를 말한다.
  • 게터가 이 법칙 위반의 가장 흔한 원인이다.

의도(Intent)

  • 푸시 기반 아키텍처에서 메서드는 서비스처럼 외부에 노출된다.
  • 메서드 사용자는 내부 자료구조를 알 필요가 없어진다.

6.1.2 게터 제거 적용 (Applying the rule)

KeyConfigurationgetRemoveStrategy를 제거하는 실제 과정이다.

단계별 절차

  1. 게터를 private으로 변경 → 컴파일 에러가 모든 사용 지점을 드러낸다.
  2. 에러가 난 지점에 “코드를 클래스 안으로 밀어 넣기(Push code into classes, P4.1.5)“를 적용한다.
  3. 게터는 Push code into classes 과정에서 인라인화되어 불필요해지므로 삭제한다.
// Before: 게터를 통한 간접 접근
class KeyConfiguration {
  private removeStrategy: RemoveStrategy;
  getRemoveStrategy() { return this.removeStrategy; }
}
class Key implements Tile {
  moveHorizontal(dx: number) {
    remove(this.keyConf.getRemoveStrategy()); // 게터 사용
    moveToTile(playerx + dx, playery);
  }
}
// After: 연산이 데이터 소유자(KeyConfiguration) 안으로 이동
class KeyConfiguration {
  private removeStrategy: RemoveStrategy;
  // getRemoveStrategy 삭제됨
  removeLock() {
    remove(this.removeStrategy); // 직접 사용
  }
}
class Key implements Tile {
  moveHorizontal(dx: number) {
    this.keyConf.removeLock(); // 의도가 명확한 메서드 이름
    moveToTile(playerx + dx, playery);
  }
}

setColorg.fillStyle에 값을 대입하는 것처럼 보이지만, 규칙 정의상 “필드를 직접 반환/대입하는 메서드”가 아니므로 세터가 아니다. 이름이 아니라 동작 방식으로 판단한다.


6.1.3 Refactoring Pattern: Eliminate getter or setter (게터/세터 제거)

게터와 세터는 구조가 동일하여 같은 절차로 제거할 수 있다.

설명 연산을 데이터 근처로 이동시켜 게터/세터를 제거한다. 게터 하나 대신, 사용 문맥(context)마다 이름이 다른 여러 메서드가 생기는 경우가 많다. 이것이 오히려 코드의 의도를 더 명확하게 표현한다.

  • drive()notifyGreenLight(): 메서드 이름을 “효과” 기반이 아닌 “사용 문맥” 기반으로 명명한다.

적용 절차

  1. 게터 또는 세터를 private으로 선언하여 사용 지점에서 컴파일 에러를 발생시킨다.
  2. 에러 지점에 Push code into classes (P4.1.5)를 적용한다.
  3. 게터/세터가 인라인화되어 불필요해지므로 삭제한다.

효과

  • 불변 조건이 클래스 내부로 국소화된다.
  • 메서드 이름이 데이터 컨텍스트 대신 호출 컨텍스트를 반영하여 가독성이 향상된다.
  • 결합도가 낮아져 내부 자료구조 변경이 외부에 영향을 미치지 않는다.

6.1.4 마지막 게터 제거: FallStrategy.getFalling

FallStrategygetFalling 게터도 동일한 3단계 절차로 제거한다.

// Before: getFalling 게터 사용
class Stone implements Tile {
  moveHorizontal(dx: number) {
    this.fallStrategy.getFalling().moveHorizontal(this, dx); // 체이닝 = Demeter 위반
  }
}
class FallStrategy {
  getFalling() { return this.falling; }
}
// After: 연산이 FallStrategy 안으로 이동
class Stone implements Tile {
  moveHorizontal(dx: number) {
    this.fallStrategy.moveHorizontal(this, dx); // 위임
  }
}
class FallStrategy {
  // getFalling 삭제됨
  moveHorizontal(tile: Tile, dx: number) {
    this.falling.moveHorizontal(tile, dx);
  }
}

추가 개선: FallStrategy.dropif 문과 update의 삼항 연산자도 규칙을 위반하므로, 이를 TileFallingState 안으로 밀어 넣어 제거한다.

// After: 삼항 연산자와 if를 타입 다형성으로 대체
interface Tile {
  getBlockOnTopState(): FallingState; // 추가
}
class Air implements Tile {
  getBlockOnTopState() { return new Falling(); }
}
class Stone implements Tile {
  getBlockOnTopState() { return new Resting(); }
}

interface FallingState {
  drop(tile: Tile, x: number, y: number): void; // 추가
}
class Falling implements FallingState {
  drop(tile: Tile, x: number, y: number) {
    map[y + 1][x] = tile;
    map[y][x] = new Air();
  }
}
class Resting implements FallingState {
  drop(tile: Tile, x: number, y: number) { /* 아무것도 하지 않음 */ }
}

class FallStrategy {
  update(tile: Tile, x: number, y: number) {
    this.falling = map[y + 1][x].getBlockOnTopState(); // 삼항 연산자 제거
    this.falling.drop(tile, x, y);                     // drop의 if 제거
  }
}

6.2 Encapsulating simple data: 단순 데이터 캡슐화

6.2.1 Rule: Never have common affixes (공통 접두/접미사를 사용하지 않는다)

[규칙 R6.2.1] Never have common affixes: 코드에서 메서드나 변수가 공통 접두사(prefix)나 접미사(suffix)를 갖지 않도록 한다.

설명

  • playerx, playery, drawPlayer처럼 같은 접두사/접미사를 공유하는 요소들은 해당 개념에 대한 **응집도(Cohesion)**를 나타내며, 클래스로 묶어야 한다는 신호다.
  • 클래스로 묶으면 내부 구현을 숨길 수 있어, 글로벌 스코프를 오염시키지 않고 인터페이스를 완전히 통제할 수 있다.
  • 데이터를 숨김으로써 그 불변 조건이 클래스 내부에서만 유지되며, 이것이 국소 불변 조건(Local Invariant)이 된다.

냄새(Smell): 단일 책임 원칙(Single Responsibility Principle)

  • 이 규칙은 클래스 수준의 “메서드는 한 가지 일만 한다”와 동일한 원칙이다.
  • 공통 접사는 클래스가 하나의 하위 책임을 과도하게 맡고 있다는 증거다.
  • 중요한 점은, 이 원칙은 사전 설계보다 코드에서 보이는 증상을 통해 사후에 발견할 수 있다.

은행 계좌 예시

// Bad: 공통 접두사 account를 가진 전역 함수들
function accountDeposit(to: string, amount: number) {
  const accountId = database.find(to);
  database.updateOne(accountId, { $inc: { balance: amount } });
}
function accountTransfer(amount: number, from: string, to: string) {
  accountDeposit(from, -amount);
  accountDeposit(to, amount);
}
// 문제: deposit을 직접 호출하면 돈이 공중에서 생겨날 수 있다.
// Good: Account 클래스로 캡슐화 - deposit은 private
class Account {
  private deposit(to: string, amount: number) {
    const accountId = database.find(to);
    database.updateOne(accountId, { $inc: { balance: amount } });
  }
  transfer(amount: number, from: string, to: string) {
    this.deposit(from, -amount); // 항상 차감 먼저
    this.deposit(to, amount);
  }
}
// 효과: 불변 조건("이체는 항상 출금 후 입금")이 클래스 내부로 국소화됨

6.2.2 게임 코드에서 규칙 적용: Player 클래스 생성

playerx, playery, drawPlayer는 공통 접두사 player를 공유하므로 Player 클래스로 묶는다.

적용 절차 요약

  1. Player 클래스를 생성한다.
  2. playerx, playery를 클래스 내부로 이동하고 private으로 변경한다. 접두사 player를 이름에서 제거하고, 임시 게터/세터를 생성한다.
  3. 컴파일 에러를 이용해 모든 참조 지점을 찾아 수정한다.
    • a. 인스턴스 변수명(player)을 결정한다.
    • b. 모든 직접 접근을 게터/세터로 교체한다.
    • c. 두 개 이상의 메서드에서 에러가 나면, player: Player를 첫 번째 파라미터로 추가한다.
    • d. 에러가 하나의 메서드에만 남을 때까지 c를 반복한다.
    • e. 변수가 선언되었던 위치에 let player = new Player()를 배치한다.
  4. drawPlayer를 클래스 안으로 밀어 넣고, 원본 함수를 인라인(Inline method)한다.
  5. 게터/세터를 Eliminate getter or setter로 제거한다.
// After: 게터/세터가 모두 제거된 최종 Player 클래스
class Player {
  private x = 1;
  private y = 1;

  draw(g: CanvasRenderingContext2D) {
    g.fillStyle = "#ff0000";
    g.fillRect(this.x * TILE_SIZE, this.y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
  }
  moveHorizontal(dx: number) {
    map[this.y][this.x + dx].moveHorizontal(this, dx);
  }
  move(dx: number, dy: number) {
    this.moveToTile(this.x + dx, this.y + dy);
  }
  pushHorizontal(tile: Tile, dx: number) {
    if (map[this.y][this.x + dx + dx].isAir()
      && !map[this.y + 1][this.x + dx].isAir()) {
      map[this.y][this.x + dx + dx] = tile;
      this.moveToTile(this.x + dx, this.y);
    }
  }
  private moveToTile(newx: number, newy: number) {
    map[this.y][this.x] = new Air();
    map[newy][newx] = new PlayerTile();
    this.x = newx;
    this.y = newy;
  }
}
let player = new Player();

6.2.3 Refactoring Pattern: Encapsulate data (데이터 캡슐화)

설명 변수와 메서드를 클래스 안으로 이동시켜 접근 범위를 제한하고 구조를 명시적으로 만든다. 메서드를 캡슐화하면 이름이 단순해지고 응집도가 높아진다. 가장 큰 이점은 변수 캡슐화로, 불변 조건을 검증할 때 클래스 내부 코드만 확인하면 된다.

적용 절차

  1. 클래스를 생성한다.
  2. 변수를 클래스 내부로 이동시키고 letprivate으로 변경한다. 이름을 단순화하고, 임시 게터/세터를 생성한다.
  3. 컴파일 에러가 모든 참조 지점을 드러낸다. 다음 5단계로 수정한다.
    • a. 새 클래스의 인스턴스 변수명을 결정한다.
    • b. 직접 접근을 게터/세터로 교체한다.
    • c. 두 개 이상의 메서드에서 에러가 나면, 인스턴스를 첫 번째 파라미터로 추가한다.
    • d. 에러가 하나의 메서드에만 남을 때까지 c를 반복한다.
    • e. 변수가 선언되었던 위치에 클래스를 인스턴스화한다. (루프 안에 넣는 실수 방지)

주의: 클래스 인스턴스화 위치를 루프 내부에 잘못 배치하면 컴파일은 되지만 동작이 달라진다. 캡슐화한 변수가 원래 있던 위치에 인스턴스를 생성해야 한다.

효과

  • 불변 조건 검증 범위가 클래스 내부로 축소된다.
  • 관련 메서드의 이름을 단순화할 수 있다.
  • 클래스가 많아지고 작아지며, 이는 아키텍처 관점에서 바람직하다.
  • null 참조 오류를 예방한다. (파라미터로 전달하므로 사용 전 반드시 인스턴스화)

6.3 Encapsulating complex data: 복잡한 데이터 캡슐화

게임 코드에는 map, transformMap, updateMap, drawMap이라는 공통 접미사 map을 가진 그룹이 있다. Encapsulate data 패턴을 Map 클래스에 적용한다.

과정 요약

  1. Map 클래스를 생성한다.
  2. map 변수를 Map 내부로 이동하고, getMap()/setMap() 게터/세터를 임시 생성한다.
  3. 모든 참조를 파라미터 전파(parameter propagation)로 수정한다. Tile 인터페이스의 메서드에도 map: Map이 추가된다.
  4. transformMap, updateMap, drawMap을 Push code into classes로 Map.transform(), Map.update(), Map.draw()로 이동시키고 원본을 인라인한다.
  5. getMap() 게터를 Eliminate getter or setter로 제거한다. 이 과정에서 연산들이 Map 내부로 점진적으로 이동한다.
// After: Map 클래스가 내부 배열 접근을 완전히 숨김
class Map {
  private map: Tile[][];

  constructor() { /* transform 로직이 생성자로 이동 */ }

  update() {
    for (let y = this.map.length - 1; y >= 0; y--)
      for (let x = 0; x < this.map[y].length; x++)
        this.map[y][x].update(this, x, y);
  }
  draw(g: CanvasRenderingContext2D) {
    for (let y = 0; y < this.map.length; y++)
      for (let x = 0; x < this.map[y].length; x++)
        this.map[y][x].draw(g, x, y);
  }
  isAir(x: number, y: number) { return this.map[y][x].isAir(); }
  setTile(x: number, y: number, tile: Tile) { this.map[y][x] = tile; }
  movePlayer(x: number, y: number, newx: number, newy: number) {
    this.map[y][x] = new Air();
    this.map[newy][newx] = new PlayerTile();
  }
  remove(shouldRemove: RemoveStrategy) {
    for (let y = 0; y < this.map.length; y++)
      for (let x = 0; x < this.map[y].length; x++)
        if (shouldRemove.check(this.map[y][x]))
          this.map[y][x] = new Air();
  }
  pushHorizontal(player: Player, tile: Tile, x: number, y: number, dx: number) {
    if (this.map[y][x + dx + dx].isAir() && !this.map[y + 1][x + dx].isAir()) {
      this.map[y][x + dx + dx] = tile;
      player.moveToTile(this, x + dx, y);
    }
  }
}

setTilemap 배열에 대한 거의 완전한 접근권을 외부에 주지만, 겁내지 말고 코드를 추가한다. 이후에 setTileMap 내부에서만 쓰인다고 확인되면 private으로 변경하거나 삭제한다.


6.4 Eliminating a sequence invariant: 순서 불변 조건 제거

6.4.1 Refactoring Pattern: Enforce sequence (순서 강제)

배경: Map은 다른 메서드를 호출하기 전에 반드시 map.transform()을 먼저 호출해야 하는 **순서 불변 조건(Sequence Invariant)**을 갖는다. 이를 잊으면 버그가 발생한다.

해결책: transform을 생성자(constructor)로 변환한다. 생성자는 언제나 가장 먼저 실행되므로, 불변 조건이 컴파일러에 의해 자동으로 강제된다.

// Before: 순서를 수동으로 기억해야 함
class Map {
  transform() { /* 초기화 로직 */ }
}
window.onload = () => {
  map.transform(); // 까먹으면 버그
  gameLoop(map);
};
// After: 생성자가 순서를 강제 - 불변 조건이 사라짐
class Map {
  constructor() { /* 초기화 로직 */ }
}
window.onload = () => {
  // transform() 호출 불필요 - 인스턴스 생성 시 자동 실행
  gameLoop(map);
};

설명 컴파일러에게 실행 순서를 “가르치는” 가장 우아한 리팩터링 기법 중 하나다. 객체 지향 언어의 기본 보장 — “생성자는 항상 메서드보다 먼저 실행된다” — 을 활용한다. **클래스의 인스턴스 자체가 “해당 코드가 실행되었다는 증거”**가 된다.

두 가지 변형

구분 내부(Internal) 변형 외부(External) 변형
필드 접근성 private public readonly
두 번째 연산 위치 클래스 내부 메서드 외부 함수 (특정 타입 파라미터)
캡슐화 강도 강함 (권장) 약함

적용 절차

  1. “나중에 실행되어야 하는” 메서드에 Encapsulate data (P6.2.3)를 적용한다.
  2. 생성자가 “먼저 실행되어야 하는” 메서드를 호출하도록 만든다.
  3. 두 메서드의 인수가 연관되어 있다면 해당 인수를 필드로 만들고, 메서드에서 인수를 제거한다.

은행 계좌 예시 (완성형)

// After: 생성자가 출금을 먼저 강제하는 Transfer 클래스
class Transfer {
  constructor(from: string, private amount: number) {
    this.depositHelper(from, -this.amount); // 항상 먼저 실행
  }
  private depositHelper(to: string, amount: number) {
    const accountId = database.find(to);
    database.updateOne(accountId, { $inc: { balance: amount } });
  }
  deposit(to: string) {
    this.depositHelper(to, this.amount); // 나중에 호출
  }
}
// 사용법: new Transfer("Alice", 100).deposit("Bob")
// Transfer 인스턴스가 존재한다는 것 자체가 "출금이 완료됐다는 증거"

6.5 Eliminating enums another way: 열거형 제거의 다른 방법

6.5.1 비공개 생성자를 통한 열거형 구현 (Enumeration through private constructors)

TypeScript를 포함한 일부 언어에서는 enum에 메서드를 추가할 수 없다. 이를 우회하는 방법이 비공개 생성자 패턴이다.

핵심 원리: 생성자를 private으로 만들면 클래스 외부에서 인스턴스를 생성할 수 없다. 클래스 내부에서 생성한 인스턴스를 public static readonly 상수에 저장하면, 외부에서는 마치 enum처럼 사용할 수 있다.

// Before: 일반 열거형 - 메서드를 추가할 수 없음
enum TShirtSize { SMALL, MEDIUM, LARGE }

// After: 비공개 생성자 패턴 - 클래스이므로 메서드 추가 가능
class TShirtSize {
  static readonly SMALL  = new TShirtSize(new SmallValue());
  static readonly MEDIUM = new TShirtSize(new MediumValue());
  static readonly LARGE  = new TShirtSize(new LargeValue());
  private constructor(private value: SizeValue) {}
}

switch문은 이 패턴과 함께 사용할 수 없다. 그러나 어차피 규칙에 의해 switch 사용이 금지되어 있으므로 문제가 없다.

완전한 열거형 제거를 위한 추가 단계

비공개 생성자 패턴만으로는 if (s === TShirtSize.SMALL) 형태의 분기문을 없앨 수 없다. 각 값에 대한 별도 클래스가 없기 때문이다. 완전한 제거를 위해서는 “타입 코드를 클래스로 교체(Replace type code with classes, P4.1.3)“를 함께 적용한다.

// 각 열거형 값에 대한 클래스 생성
interface SizeValue { }
class SmallValue  implements SizeValue { }
class MediumValue implements SizeValue { }
class LargeValue  implements SizeValue { }

// 각 값 클래스를 인수로 전달하여 인스턴스 구분
class TShirtSize {
  static readonly SMALL  = new TShirtSize(new SmallValue());
  static readonly MEDIUM = new TShirtSize(new MediumValue());
  static readonly LARGE  = new TShirtSize(new LargeValue());
  private constructor(private value: SizeValue) {}
  // 이제 코드를 push하면 SmallValue, MediumValue, LargeValue로 분산됨
}

6.5.2 숫자를 클래스로 재매핑 (Remapping numbers to classes)

일부 언어에서 enum은 내부적으로 정수다. 게임에서 rawMap이 정수 배열로 저장된 경우, 정수를 클래스 인스턴스로 변환하는 배열을 만들어 매핑한다.

// Before: rawMap이 RawTile 열거형을 사용
let rawMap: RawTile[][] = [ /* ... */ ];
function transformTile(tile: RawTile) { /* switch 사용 */ }
// After: 정수 인덱스를 클래스 인스턴스로 매핑
interface RawTileValue {
  transform(): Tile;
}
class AirValue   implements RawTileValue { transform() { return new Air(); } }
class StoneValue implements RawTileValue { transform() { return new Stone(new Resting()); } }
class Key1Value  implements RawTileValue { transform() { return new Key(YELLOW_KEY); } }
// ...

class RawTile {
  static readonly AIR   = new RawTile(new AirValue());
  static readonly STONE = new RawTile(new StoneValue());
  // ...
  private constructor(private value: RawTileValue) {}
  transform() { return this.value.transform(); } // push through
}

// 정수 → 클래스 매핑 배열 (열거형 순서와 동일하게)
const RAW_TILES = [
  RawTile.AIR, RawTile.FLUX, RawTile.UNBREAKABLE, RawTile.PLAYER,
  RawTile.STONE, RawTile.FALLING_STONE, RawTile.BOX, RawTile.FALLING_BOX,
  RawTile.KEY1, RawTile.LOCK1, RawTile.KEY2, RawTile.LOCK2
];

let rawMap: number[][] = [ /* ... */ ]; // 정수 배열로 변경

// Map 생성자 내부에서 변환
this.map[y][x] = RAW_TILES[rawMap[y][x]].transform(); // switch 제거
// transformTile은 단 한 줄이 되어 인라인화 후 삭제

최종 효과: switch문과 assertExhausted가 완전히 제거된다. 각 타일 값의 변환 책임이 해당 값 클래스(AirValue, StoneValue 등) 안으로 분산된다.

flowchart TD
    A[정수 rawMap] -->|RAW_TILES 배열로 인덱싱| B[RawTile 인스턴스]
    B -->|.transform 호출| C[RawTileValue 인터페이스]
    C --> D[AirValue.transform\nreturn new Air]
    C --> E[StoneValue.transform\nreturn new Stone]
    C --> F[Key1Value.transform\nreturn new Key]
    D & E & F -->|결과| G[Tile 인스턴스\n실제 게임 타일]

챕터 6 요약

핵심 개념 (규칙/패턴) 목적 및 내용
R6.1.1 Do not use getters or setters Boolean이 아닌 필드의 게터/세터를 금지. 캡슐화를 지키고 불변 조건이 전역으로 퍼지는 것을 막는다.
P6.1.3 Eliminate getter or setter 게터/세터를 private으로 변경 → 에러 지점에 Push code into classes 적용 → 불필요해진 게터/세터 삭제.
R6.2.1 Never have common affixes 공통 접두/접미사를 가진 변수/메서드는 클래스로 묶는다. 단일 책임 원칙의 코드 수준 증상.
P6.2.3 Encapsulate data 변수와 메서드를 클래스 안으로 이동해 접근을 제한. 컴파일러의 에러가 모든 참조를 드러내는 안전망 역할.
P6.4.1 Enforce sequence 생성자를 이용해 순서 불변 조건을 컴파일러가 강제하도록 만든다. 인스턴스 자체가 “선행 조건이 충족됐다는 증거”가 됨.
비공개 생성자 패턴 enum에 메서드를 붙일 수 없는 언어에서 클래스를 enum처럼 사용하는 기법. switch를 다형성으로 제거하는 최종 단계.
푸시 기반 아키텍처 데이터를 꺼내는 대신 연산을 데이터 근처로 밀어 넣음. 결합도를 낮추고 모든 클래스가 자체 기능을 갖게 됨.

파트 1 결론: 이 챕터로 파트 1의 리팩터링이 마무리된다. 아키텍처가 개선된 세 가지 핵심 이유는 다음과 같다.

  1. 새로운 Tile 타입을 추가하는 것이 훨씬 빠르고 안전해졌다.
  2. 관련 변수와 기능이 클래스 및 의미 있는 이름의 메서드로 묶여 코드 추론이 용이해졌다.
  3. 데이터의 범위를 세밀하게 제어할 수 있어, 비로컬 불변 조건을 깨뜨리는 코드를 작성하기 어려워졌다.

Chapter 7. Collaborate with the Compiler: 컴파일러와 협력하기

프로그래밍은 건설(Construction)이 아니라 커뮤니케이션(Communication)이다. 컴파일러는 우리의 텍스트가 일정 수준의 품질을 갖추도록 도와주는 편집자다.


7.1 Getting to Know the Compiler: 컴파일러 이해하기

컴파일러의 근본 목표는 소스 프로그램과 동등한 하위 수준(lower-level) 언어의 프로그램을 생성하는 것이다. 그러나 현대 컴파일러는 부가 서비스로 런타임 에러 발생 가능성도 검증한다. 컴파일러는 일관성(consistency)에 강하고, 판단(judgment)에 약하다. 의심스러우면 묻는다(“When in doubt, ask”).

7.1.1 약점: 정지 문제(Halting Problem)가 컴파일 타임 지식을 제한한다

정지 문제(Halting Problem): 프로그램을 실제로 실행해보지 않고는 프로그램이 어떻게 동작할지 알 수 없다.

  • 컴파일러는 “명백히 실패하는” 프로그램과 “명백히 안전한” 프로그램 사이의 회색지대를 처리해야 한다.
  • 보수적 분석(Conservative Analysis): 특정 실패 가능성이 없음을 증명한다. 실패를 확신할 수 없으면 컴파일 자체를 거부한다.
  • 정지 문제는 특정 컴파일러나 언어에 국한된 것이 아니라 프로그래밍 언어 자체의 본질적 속성이다.
// Before: 컴파일러가 허용하지만 런타임 에러 가능성이 있는 코드
if (new Date().getDay() === 35)
  (5 as any).foo(); // getDay()는 35를 절대 반환하지 않지만, 컴파일러는 이를 알 수 없다

7.1.2 강점: 도달 가능성(Reachability)으로 메서드 반환을 보장한다

보수적 분석 중 하나로, 메서드가 모든 경로에서 반환하는지 검사한다. assertExhaustednever 키워드를 활용하면 열거형(enum)의 모든 값을 처리했는지 **망라성 검사(Exhaustiveness Check)**를 수행할 수 있다.

// After: never를 활용한 망라성 검사
enum Color {
  RED,
  GREEN,
  BLUE,
}

function assertExhausted(x: never): never {
  throw new Error("Unexpected object: " + x);
}

function handle(t: Color): string {
  if (t === Color.RED) return "#ff0000";
  if (t === Color.GREEN) return "#00ff00";
  assertExhausted(t); // Color.BLUE를 처리하지 않으면 컴파일 에러 발생
}

7.1.3 강점: 확정 할당(Definite Assignment)으로 미초기화 변수 접근을 방지한다

  • 컴파일러는 변수가 사용 전에 반드시 할당되었는지 검증한다.
  • 지역 변수에서 if 블록 내부에서만 초기화할 경우 컴파일러가 거부한다.
  • 읽기 전용(read-only) 필드는 생성자 종료 시점에 반드시 초기화되어야 한다. 이를 활용하면 특정 값의 존재를 타입 시스템으로 보장할 수 있다.
// Before: 미초기화 변수 위험
let result;
for (let i = 0; i < arr.length; i++)
  if (arr[i].name === "John") result = arr[i];
return result; // 컴파일 에러: result가 초기화되지 않을 수 있다

// After: readonly 필드로 존재 보장
class UserList {
  constructor(public readonly johnEntry: User) {}
}

7.1.4 강점: 접근 제어(Access Control)로 데이터를 캡슐화한다

  • private 접근 제어자를 통해 데이터가 의도치 않게 외부로 노출되지 않도록 보장한다.
  • 주의: private는 객체(object) 단위가 아니라 클래스(class) 단위로 적용된다. 동일 클래스의 다른 인스턴스의 private 멤버에는 접근이 가능하다.
// After: private으로 민감한 메서드 보호
class Transfer {
  constructor(from: string, private amount: number) {
    this.depositHelper(from, -this.amount);
  }
  private depositHelper(to: string, amount: number) {
    // ...
  }
  deposit(to: string) {
    this.depositHelper(to, this.amount);
  }
}

const t = new Transfer("alice", 100);
t.depositHelper("bob", 50); // 컴파일 에러

7.1.5 강점: 타입 검사(Type Checking)로 속성을 증명한다

타입 검사기는 컴파일러의 강점 중 가장 강력하다. 타입을 올바르게 설계하면 빈 스택에서 pop하는 것 같은 불가능한 상황을 타입 시스템으로 표현할 수 있다.

  • 타입 강도는 이진적(binary) 속성이 아니라 스펙트럼이다.
  • 강도 순서 (낮음 → 높음): Borrowing (Rust)Polymorphic Inference (OCaml, F#)Type Classes (Haskell)Union/Intersection Types (TypeScript)Dependent Types (Coq, Agda)
// After: 타입으로 비어있지 않은 리스트 표현
interface NonEmptyList<T> {
  head: T;
}
class Last<T> implements NonEmptyList<T> {
  constructor(public readonly head: T) {}
}
class Cons<T> implements NonEmptyList<T> {
  constructor(
    public readonly head: T,
    public readonly tail: NonEmptyList<T>
  ) {}
}
function first<T>(xs: NonEmptyList<T>) {
  return xs.head;
}
first([]); // 타입 에러: 빈 배열을 전달 불가

7.1.6 약점: null 역참조(Null Dereference)가 애플리케이션을 충돌시킨다

  • TypeScript의 엄격한 null 검사(strictNullChecks)를 비활성화하면 Java 등 주류 언어처럼 동작하여 null을 허용한다.
  • null이 있는 변수를 null 검사 없이 사용하면 런타임 에러가 발생한다.
  • 실용 원칙: “변수의 null 검사가 보이지 않으면, 그 변수는 아마 null이다.” IDE가 중복 검사라고 경고해도 성능 문제가 없다면 제거하지 말라.

7.1.7 약점: 산술 에러(Arithmetic Error)가 오버플로우나 충돌을 유발한다

  • 컴파일러는 일반적으로 **0으로 나누기(division by zero)**나 **오버플로우(overflow)**를 검사하지 않는다.
  • 빈 배열로 average를 호출하면 0으로 나누기가 발생하지만 컴파일러는 감지하지 못한다.
  • 대책: 나누기 전 분모가 0인지 확인하거나, 오버플로우 가능성이 있는 경우 BigInt를 사용한다.

7.1.8 약점: 경계 초과 에러(Out-of-Bounds Error)가 애플리케이션을 충돌시킨다

  • 배열 인덱스로 직접 접근 시 범위를 벗어나면 런타임 에러가 발생하지만 컴파일러는 이를 감지하지 못한다.
  • 대책: 자료구조 전체를 순회하거나, 확정 할당으로 요소의 존재를 증명한다.

7.1.9 약점: 무한 루프(Infinite Loop)가 애플리케이션을 멈추게 한다

  • 컴파일러는 무한 루프를 일반적으로 감지하지 못한다.
  • 대책: whileforfor...offorEach, map, filter고수준 반복 구문으로 전환하면 위험을 줄일 수 있다.
// Before: 무한 루프 위험 (컴파일러 감지 불가)
let insideQuote = false;
let quotePosition = s.indexOf('"');
while (quotePosition >= 0) {
  insideQuote = !insideQuote;
  quotePosition = s.indexOf('"'); // 이전 위치를 전달하지 않아 무한 루프 발생
}

// After: 고수준 구문으로 안전하게 대체
const quoteCount = s.split('"').length - 1;
const insideQuote = quoteCount % 2 !== 0;

7.1.10 약점: 교착 상태(Deadlock)와 경쟁 조건(Race Condition)이 의도치 않은 동작을 유발한다

TypeScript는 멀티스레딩을 지원하지 않으므로 직접적인 문제는 없지만, 멀티스레드 환경에서 발생하는 세 가지 주요 문제를 이해해야 한다.

문제 설명 결과
경쟁 조건(Race Condition) 여러 스레드가 공유 변수를 동시에 읽고 쓴다 숫자 중복 또는 건너뜀
교착 상태(Deadlock) 두 스레드가 서로의 락(lock) 해제를 기다린다 프로그램이 멈춤
기아 상태(Starvation) 한 스레드가 영원히 실행 기회를 얻지 못한다 특정 스레드만 독점 실행
  • 최선의 대책: 가능한 한 여러 스레드가 가변(mutable) 데이터를 공유하지 않도록 설계한다.

7.2 Using the Compiler: 컴파일러 활용하기

프로그래밍은 컴퓨터, 다른 개발자, 그리고 컴파일러와의 커뮤니케이션이다. 프로그램은 개발팀이 도메인에 대해 알고 있는 모든 것이 코드로 굳어진(frozen) 결과물이다.

컴파일러를 팀원으로 포함시키고, 컴파일러와 싸우는 대신 협력하여 소프트웨어의 정확성(correctness) 책임을 공유한다.

7.2.1 컴파일러를 작동시키는 방법들

컴파일러를 할 일 목록(Todo List)으로 활용하여 안전성 확보
  • 변경 대상 메서드/열거형 이름에 _handled 같은 접미사를 추가하면, 컴파일러가 해당 식별자를 사용하는 모든 위치에서 에러를 발생시켜 작업 목록을 만들어준다.
  • 처리 완료된 위치마다 동일한 접미사를 붙여 에러를 해소한다.
// After: 이름 변경으로 컴파일러를 할 일 목록으로 활용
enum Color_handled {
  RED,
  GREEN,
  BLUE,
}

function toString(c: Color_handled) {
  // 컴파일 에러 → 처리하지 않은 모든 위치를 찾아준다
  switch (c) {
    case Color_handled.RED:
      return "Red";
    default:
      return "No color";
  }
}
순서 강제(Enforce Sequence)로 안전성 확보
  • 클래스를 통해 특정 연산이 반드시 선행되었음을 타입으로 표현하면, 불변 조건(invariant)을 컴파일러가 보장하는 속성(property)으로 격상시킬 수 있다.
// After: 타입으로 대문자화(capitalize) 수행을 강제하는 두 가지 방식

// 내부 방식 (Internal): private 필드 + 메서드
class CapitalizedString_Internal {
  private value: string;
  constructor(str: string) {
    this.value = str.charAt(0).toUpperCase() + str.slice(1);
  }
  print() {
    console.log(this.value);
  }
}

// 외부 방식 (External): public readonly 필드 + 독립 함수
class CapitalizedString_External {
  public readonly value: string;
  constructor(str: string) {
    this.value = str.charAt(0).toUpperCase() + str.slice(1);
  }
}
function print(str: CapitalizedString_External) {
  console.log(str.value);
}
캡슐화 강제로 안전성 확보
  • 불변 조건에 민감한 헬퍼 메서드는 private으로 선언하여 외부에서 잘못 호출되는 것을 컴파일러가 차단하게 한다.
미사용 코드 감지로 안전성 확보
  • 패턴 Try Delete Then Compile (P4.5.1): 메서드를 삭제 후 컴파일하여 실제로 사용 중인지 확인한다.
  • 내부에서만 사용하는 인터페이스의 메서드는 이 방법으로 안전하게 제거할 수 있다.
확정 값(Definite Values)으로 안전성 확보
  • readonly 필드는 생성자 종료 시점에 반드시 초기화되어야 하므로, 빈 리스트가 될 수 없는 자료구조를 타입으로 표현할 수 있다.
// After: readonly 필드로 비어있지 않음을 컴파일 타임에 보장
interface NonEmptyList<T> {
  head: T;
}
class Last<T> implements NonEmptyList<T> {
  constructor(public readonly head: T) {}
}
class Cons<T> implements NonEmptyList<T> {
  constructor(
    public readonly head: T,
    public readonly tail: NonEmptyList<T>
  ) {}
}

7.2.2 컴파일러와 싸우지 말라

컴파일러를 방해하는 패턴은 크게 세 가지 원인에서 비롯된다: 타입 미이해, 게으름(Laziness), 아키텍처 미이해.

타입 관련 위반

캐스트(Cast) 사용

  • 캐스트는 개발자가 컴파일러보다 더 잘 안다고 주장하는 것이다. 타입 검사기를 특정 표현식에 대해 비활성화하는 것과 같다.
  • 만성 통증 환자에게 진통제를 주는 것과 같다. 지금은 해결되지만 근본 문제는 그대로다.
// Before: 캐스트로 타입 검사기 비활성화
let num = <number>JSON.parse(variable);

// After: 전송 측과 동일한 타입 재사용, 또는 커스텀 파서로 안전하게 입력 처리
window.addEventListener("keydown", (e) => {
  if (e.key === "ArrowLeft") inputs.push(new Left());
  else if (e.key === "ArrowUp") inputs.push(new Up());
  else if (e.key === "ArrowRight") inputs.push(new Right());
  else if (e.key === "ArrowDown") inputs.push(new Down());
});

동적 타입(any) 사용

  • any는 타입 검사기를 완전히 비활성화한다. HTTP로 JSON을 주고받을 때 흔히 발생한다.
  • 존재하지 않는 필드 참조나 잘못된 타입 연산 등 수많은 잠재적 에러를 열어둔다.
// Before: any로 타입 검사 무력화
(<any>arr).findIndex((x) => x === 2);

// After: tsconfig의 lib 또는 target 설정을 올바르게 업데이트
// tsconfig.json: { "lib": ["ES6"] }
arr.findIndex((x) => x === 2);

런타임 타입(Runtime Types) 사용

  • 컴파일 타임 지식을 런타임으로 이동시키는 것은 이 책의 모든 조언에 역행한다.
  • Map<string, unknown> 같은 구조로 10개의 매개변수를 대체하면, 컴파일러가 키의 존재를 확인할 수 없어 타입 검사의 강점에서 경계 초과 에러의 약점으로 전락한다.
// Before: 런타임 타입으로 컴파일 타임 안전성 포기
function stringConstructor(conf: Map<string, string>, parts: string[]) {
  return conf.get("prefix") + parts.join(conf.get("joiner")) + conf.get("postfix");
}

// After: 정적 타입으로 컴파일 타임 안전성 확보
class Configuration {
  constructor(
    public readonly prefix: string,
    public readonly joiner: string,
    public readonly postfix: string
  ) {}
}
function stringConstructor(conf: Configuration, parts: string[]) {
  return conf.prefix + parts.join(conf.joiner) + conf.postfix;
}
게으름(Laziness) 관련 위반

기본값(Default) 사용

  • 기본 매개변수는 편리하지만, 추가된 값이 기본값을 가지면 안 되는 경우에 개발자가 이를 수정하는 것을 잊기 쉽다.
  • 기본값 없이 컴파일러가 개발자에게 매번 결정을 강요하게 하면, 이해하지 못한 부분을 드러낼 수도 있다.
// Before: 기본값으로 인한 버그
class Animal {
  constructor(name: string, isMammal = true) { /* ... */ }
}
const nemo = new Animal("Clown fish"); // nemo가 포유류가 됨

// After: 기본값 제거로 컴파일러가 결정 강제
class Animal {
  constructor(name: string, isMammal: boolean) { /* ... */ }
}
const nemo = new Animal("Clown fish", false); // 명시적 결정 강제

클래스 상속(Inheritance) 사용

  • 규칙 Only Inherit from Interfaces (R4.3.2): 클래스 상속은 기본 동작(default behavior)의 일종이며 결합도(coupling)를 높인다.
  • 슈퍼클래스에 메서드를 추가하면 모든 하위 클래스에서 해당 메서드가 유효한지 수동으로 검사해야 한다.
// Before: 클래스 상속으로 인한 잠재적 버그
class Mammal {
  laysEggs() { return false; }
}
class Platypus extends Mammal {
  // laysEggs()를 오버라이드하지 않아 잘못된 동작
}

// After: 인터페이스 상속으로 각 클래스가 명시적으로 구현
interface Animal {
  laysEggs(): boolean;
}
class Platypus implements Animal {
  laysEggs() { return true; } // 강제로 구현
}

비검사 예외(Unchecked Exceptions) 사용

  • 비검사 예외는 호출자가 예외를 처리할 의무가 없으므로, 런타임에서야 에러가 발견된다.
  • 원칙: 정말로 불가능한 상황을 나타내는 하나의 Impossible 예외만 비검사로 사용하라.
  • TypeScript는 검사 예외(Checked Exceptions)를 지원하지 않으므로 더욱 신중히 설계해야 한다.
// Before: 비검사 예외로 호출자에게 처리 의무 미전달
class EmptyArray extends Error {}
function average(arr: number[]) {
  if (arr.length === 0) throw new EmptyArray();
  return arr.reduce((a, b) => a + b, 0) / arr.length;
}
console.log(average([])); // 런타임에서야 에러 발생

// After (의사코드): 검사 예외로 호출자에게 처리 강제
class Impossible extends RuntimeException {}
class EmptyArray extends CheckedException {}
// function average(arr: number[]) throws EmptyArray { ... }
아키텍처 미이해 관련 위반

게터/세터(Getter/Setter)로 캡슐화 파괴

  • 내부 배열 같은 private 필드를 게터로 노출하면 외부 코드가 내부 구조에 의존하고 직접 변경할 수 있다.
  • private 필드를 인수로 전달하는 것도 동일한 효과를 가진다.
  • 대책: this 자체를 전달하거나, 필요한 연산을 클래스 내부의 메서드로 위임한다.
// Before: 게터로 캡슐화 파괴
class Stack<T> {
  private data: T[] = [];
  getArray() { return this.data; }
}
const s = new Stack<number>();
s.getArray()[^0] = 99; // 스택 내부 직접 변경

// After: private 필드 노출 없이 필요한 연산만 공개
class Stack<T> {
  private data: T[] = [];
  push(item: T) { this.data.push(item); }
  pop(): T | undefined { return this.data.pop(); }
  peek(): T | undefined { return this.data[this.data.length - 1]; }
}

7.3 Trusting the Compiler: 컴파일러 신뢰하기

컴파일러를 활용하기 시작하면 그 출력을 신뢰하는 단계로 나아가야 한다. “내가 컴파일러보다 더 잘 안다”는 반생산적인 감정을 버리고 컴파일러의 말에 귀를 기울인다.

7.3.1 컴파일러에게 불변 조건(Invariant)을 가르쳐라

  • 지역 불변 조건(Local Invariant): 클래스 내 모든 메서드 종료 시점에 유지되어야 하는 조건. 전역 불변 조건보다 유지하기 쉽지만 컴파일러는 여전히 이를 모른다.
  • 컴파일러가 불변 조건을 모르면 도달 가능성 분석 실패나 잘못된 경고가 발생할 수 있다.
// Before: 컴파일러가 total 불변 조건을 모르므로 에러 발생
class CountingSet {
  private data: Map<string, number> = new Map();
  private total = 0;

  add(element: string) {
    const c = this.data.get(element) ?? 0;
    this.data.set(element, c + 1);
    this.total++;
  }

  randomElement(): string {
    let index = Math.floor(Math.random() * this.total);
    for (const [key, count] of this.data) {
      index -= count;
      if (index <= 0) return key;
    }
    // 컴파일 에러: 모든 경로에서 반환하지 않음
  }
}

// After: Impossible 예외로 컴파일러 불만 해소 (단, 불변 조건 보호는 여전히 개발자 책임)
class Impossible extends Error {}
class CountingSet {
  private data: Map<string, number> = new Map();
  private total = 0;

  add(element: string) {
    const c = this.data.get(element) ?? 0;
    this.data.set(element, c + 1);
    this.total++;
  }

  randomElement(): string {
    let index = Math.floor(Math.random() * this.total);
    for (const [key, count] of this.data) {
      index -= count;
      if (index <= 0) return key;
    }
    throw new Impossible();
  }
}
불변 조건 처리 우선순위

불변 조건이 존재할 때 아래 순서로 해결책을 적용한다. 위로 갈수록 장기 유지 비용이 낮다.

flowchart TD
    A[불변 조건 발견] --> B{1. 제거 가능?}
    B -- 가능 --> B1[불변 조건 제거]
    B -- 불가능 --> C{2. 컴파일러에게 가르칠 수 있나?}
    C -- 가능 --> C1[타입/구조로 컴파일러에 인코딩]
    C -- 불가능 --> D{3. 런타임에 가르칠 수 있나?}
    D -- 가능 --> D1[자동화 테스트 작성]
    D -- 불가능 --> E{4. 팀에 알릴 수 있나?}
    E -- 가능 --> E1[문서화]
    E -- 불가능 --> F{5. 테스터에게 알릴 수 있나?}
    F -- 가능 --> F1[수동 테스트]
    F -- 불가능 --> G[기도하라]
  • 핵심: 목록의 아래로 내려갈수록 더 오랫동안 유지·관리에 시간을 투자해야 한다.
  • 문서(Documentation)는 코드와의 동기화 여부를 자동으로 알려주지 않지만 테스트(Test)는 알려준다. 따라서 테스트가 문서보다 장기적으로 저렴하다.
  • 단명할 프로토타입이라면 목록의 하위 선택지를 허용할 수 있다.

7.3.2 경고(Warnings)에 주의를 기울여라

  • 경보 피로(Alarm Fatigue): 경고가 일상적인 노이즈가 되면 중요한 경고를 놓치게 된다.
  • 깨진 창문 이론(Broken Window Theory): 코드베이스가 이미 나쁜 상태이면 더 나쁜 것을 추가하는 것에 대한 심리적 저항이 낮아진다.
  • 사소한 에러나 경고가 더 심각한 에러를 가릴 수 있다. 이것이 가장 위험한 상황이다.

원칙: 건강한 경고 수는 오직 0이다.

  • 경고가 이미 많은 코드베이스에서는 허용 상한선을 설정하고 매월 점진적으로 줄여나간다.
  • 경고가 0에 도달하면 경고를 에러로 처리하는 컴파일러 설정을 활성화하여 재발을 방지한다.

7.4 Trusting the Compiler Exclusively: 컴파일러만 신뢰하기

이 코드가 동작할까? — 모든 프로그래머

방에서 가장 똑똑한 사람이 당신이라면, 당신은 잘못된 방에 있는 것이다. — 출처 불명 (컴파일러 포함)

이 단계는 컴파일러와의 협력이 정점에 이른 상태다.

  • 코드베이스가 깨끗하고, 경고가 없으며, 컴파일러에 도메인 구조와 불변 조건이 인코딩되어 있다.
  • 코드를 읽고 스스로 판단하는 것보다 컴파일 성공 여부로 더 높은 확신을 얻을 수 있다.
  • 컴파일러는 프로그램이 기대대로 문제를 해결하는지는 알 수 없지만, 프로그램이 충돌하는지는 알 수 있다.
  • 이 경지에 도달하려면 많은 연습, 규율, 그리고 적절한 기술 선택(프로그래밍 언어)이 필요하다.

챕터 7 요약

핵심 개념 (규칙/패턴) 목적 및 내용
정지 문제(Halting Problem) 컴파일러가 모든 런타임 동작을 예측할 수 없는 근본적 이유. 보수적 분석으로 안전한 부분만 보장한다.
도달 가능성(Reachability) neverassertExhausted로 모든 열거형 케이스를 처리했는지 컴파일 타임에 검증한다.
확정 할당(Definite Assignment) readonly 필드로 생성자 완료 후 특정 값의 존재를 컴파일 타임에 보장한다.
접근 제어(Access Control) private으로 민감한 불변 조건을 보호하고 캡슐화를 강제한다.
타입 검사(Type Checking) 타입으로 도메인 속성을 인코딩하여 불가능한 상태를 컴파일 타임에 차단한다.
null 역참조 약점 strictNullChecks 활성화 및 명시적 null 검사로 방어한다.
산술/경계 초과/무한 루프 약점 컴파일러가 감지 불가. 고수준 구문과 방어적 코딩으로 보완한다.
컴파일러를 할 일 목록으로 활용 식별자 이름 변경으로 컴파일 에러를 발생시켜 수정할 위치를 모두 찾아낸다.
캐스트 및 any 금지 타입 검사기를 무력화하므로 사용을 금지한다. 커스텀 파서나 올바른 타입 설계로 대체한다.
런타임 타입(Map<string, V>) 금지 컴파일 타임 안전성을 런타임으로 이전시키므로 정적 타입 클래스로 대체한다.
기본값/클래스 상속/비검사 예외 금지 게으름에서 비롯된 컴파일러 방해 패턴. 명시적 설계로 컴파일러가 책임을 지도록 한다.
불변 조건 처리 6단계 제거 → 컴파일러 인코딩 → 자동화 테스트 → 문서화 → 수동 테스트 → 기도. 위로 갈수록 유지 비용이 낮다.
경보 피로(Alarm Fatigue) 방지 경고 수는 반드시 0을 목표로 한다. 경고를 에러로 처리하는 컴파일러 설정을 최종 목표로 삼는다.
컴파일러만 신뢰하기 코드 리뷰보다 컴파일 성공에서 더 높은 확신을 얻는 최종 상태. 도메인과 불변 조건이 타입 시스템에 완전히 인코딩되어야 가능하다.

Chapter 8. Stay Away from Comments: 주석을 멀리하라

Comment only what the code cannot say. — Kevlin Henney


8.0 챕터 개요

주석(Comment)은 개발 커뮤니티에서 가장 논쟁적인 주제 중 하나이다. 이 챕터에서 다루는 주석의 범위는 메서드 내부에 작성되는 주석으로 한정되며, Javadoc처럼 외부 도구가 사용하는 문서 주석은 포함되지 않는다.[^1]

// 외부 도구용 문서 주석 — 이 챕터의 논의 대상에서 제외된다
interface Color {
  /**
   * Method for converting a color to a hex string.
   * @returns a 6 digit hex number prefixed with hashtag
   */
  toHex(): string;
}

주석에 대한 저자의 핵심 입장은 다음과 같다.[^1]

  • 주석은 컴파일러가 검증하지 않으므로 코드와 쉽게 불일치 상태가 된다
  • 주석은 종종 “냄새 나는 코드” 위에 덮는 방향제처럼 사용된다 (Martin Fowler)
  • 이해하기 어려운 코드를 작성한 개발자가 그것을 명확하게 주석으로 설명할 수 있다는 것은 잘못된 믿음이다 (Kevlin Henney)
  • 오래된 시스템일수록 주석은 낡아지며 버그의 원인이 된다

핵심 원칙: 납품(deliver) 전 리팩터링 단계에서 주석을 제거하는 것을 기본 목표로 삼는다. 단, 코드로 표현 불가능한 내용은 예외로 허용한다.[^1]

5가지 주석 분류 (처리 난이도 순)

# 유형 처리 방침
1 낡은 주석 (Outdated) 삭제한다
2 주석 처리된 코드 (Commented-out code) 삭제한다
3 사소한 주석 (Trivial) 삭제한다
4 코드 설명 주석 메서드 이름으로 전환한다
5 불변 조건(Invariant) 문서화 주석 코드/테스트로 전환하거나, 불가능하면 유지한다

8.1 낡은 주석 삭제하기

낡은 주석(Outdated Comment): 코드가 변경됐지만 주석은 갱신되지 않아, 현재 코드와 불일치하거나 잘못된 정보를 전달하는 주석이다.[^1]

// Before: 주석("and")과 조건("or")이 불일치 — 독자를 혼란에 빠뜨린다
if (element.hasSelection() || element.isMultiSelect()) {
  // Is has a selection and allows multi selection
  // ...
}

// After: 주석 삭제. 조건식 자체가 의도를 표현한다
if (element.hasSelection() || element.isMultiSelect()) {
  // ...
}

낡은 주석의 위험 수준은 3단계로 나뉜다.[^1]

  1. 단순 낭비: 읽는 데 시간이 소요되지만 별 영향 없음
  2. 혼란 유발: 잘못된 정보에 근거하여 설계 결정을 내리게 함
  3. 버그 유발: 가장 심각하며, 잘못된 주석을 믿고 코드를 수정할 때 발생

처리 방침은 단순하다. 확인 즉시 삭제한다. 주석이 없어도 버전 관리 시스템의 이력(git log, git blame)에서 과거 맥락을 확인할 수 있다.[^1]


8.2 주석 처리된 코드 삭제하기

주석 처리된 코드(Commented-out Code): 실험 또는 교체 과정에서 삭제 대신 주석으로 비활성화된 채 남아 있는 구 코드이다.[^1]

// Before: 구 알고리즘이 주석으로 남아 있다 — 혼란과 코드 노이즈를 유발한다
const PHI = (1 + Math.sqrt(5)) / 2;
const PHI_ = (1 - Math.sqrt(5)) / 2;
const C = 1 / Math.sqrt(5);

function fib(n: number): number {
  // if (n <= 1) return n;
  // else return fib(n - 1) + fib(n - 2);
  return C * (Math.pow(PHI, n) - Math.pow(PHI_, n));
}

// After: 주석 처리된 구 코드를 삭제한다. 필요 시 Git 이력에서 복원 가능하다
const PHI = (1 + Math.sqrt(5)) / 2;
const PHI_ = (1 - Math.sqrt(5)) / 2;
const C = 1 / Math.sqrt(5);

function fib(n: number): number {
  return C * (Math.pow(PHI, n) - Math.pow(PHI_, n));
}

올바른 실험 워크플로는 다음과 같다.[^1]

  1. Git에서 새 브랜치를 생성한다
  2. 구 코드를 삭제하고 새 코드를 작성한다
  3. 실패 시: 브랜치를 삭제하고 main으로 체크아웃한다
  4. 성공 시: main에 병합하고 브랜치를 삭제한다

코드는 이미 버전 관리 시스템 안에 있다. 주석 처리로 “혹시 모를 상황”을 대비할 필요는 없다.


8.3 사소한 주석 삭제하기

사소한 주석(Trivial Comment): 코드를 읽는 것과 주석을 읽는 것이 동일한 정보를 전달할 때, 즉 주석이 아무런 부가적 인사이트를 제공하지 못할 때 그 주석은 사소하다.[^1]

// Before: 주석이 코드를 그대로 반복한다 — 정보량 제로
/// Log error
Logger.error(errorMessage, e);

// After: 주석 삭제. 메서드명 자체가 충분히 설명한다
Logger.error(errorMessage, e);

추가적으로 “코드를 스캔할 때 눈에 잘 안 들어오는 주석” 역시 사소한 주석으로 분류한다. 아무도 읽지 않는 주석은 공간만 차지하므로 무비용으로 제거할 수 있다.[^1]


8.4 주석을 메서드 이름으로 전환하기

코드 설명 주석: 코드 블록의 기능을 한 줄로 요약하는 주석이다. 이는 해당 블록을 별도 메서드로 추출하고, 그 주석 문구를 메서드 이름으로 사용하면 주석이 사라지면서 가독성이 향상된다.[^1]

// Before: 주석이 코드 블록의 역할을 설명한다
/// Build request url
if (queryString) {
  fullUrl += "?" + queryString;
}

// After: 블록을 메서드로 추출하고, 주석 문구를 메서드 이름으로 사용한다
fullUrl = buildRequestUrl(fullUrl, queryString);

function buildRequestUrl(fullUrl: string, queryString: string): string {
  if (queryString) {
    fullUrl += "?" + queryString;
  }
  return fullUrl;
}
  • 추출 후 기존 주석(/// Build request url)은 메서드명으로 대체되어 사소한 주석이 되므로 곧바로 삭제한다[^1]
  • 메서드 이름이 길어지는 것을 두려워할 필요 없다. 자주 호출되는 메서드는 자연히 짧은 이름을 갖게 된다 — 언어에서 가장 자주 사용하는 단어가 가장 짧은 것과 같은 원리이다[^1]

8.4.1 계획 주석의 활용

개발 전 작업 분해 수단으로 주석을 적극 활용하는 것은 권장된다.[^1]

// 개발 전 로드맵으로서 주석을 사용한다 — 이 단계에서는 매우 유용하다
function processPayment(): void {
  /// Fetch data
  /// Check something
  ///   Transform
  /// Else
  ///   Submit
}

코드 구현 후 각 계획 주석의 가치를 비판적으로 평가한다.[^1]

  • Else처럼 구현 후 의미가 사라진 것 → 사소한 주석이므로 삭제한다
  • 기능 블록을 설명하는 것 → 메서드로 추출하여 주석을 이름으로 전환한다

8.5 불변 조건 문서화 주석 유지하기

불변 조건(Invariant) 문서화 주석: 코드의 비국소적(non-local) 불변 조건을 설명하는 주석이다. 다음 질문으로 판별한다.[^1]

“이 주석이 누군가의 버그 도입을 방지해 줄 수 있는가?”

// 유지해야 할 주석 — 이 한 줄 없이는 logout() 호출 이유를 추론하기 매우 어렵다
/// Log off used to force re-authentication on next request
session.logout();

이 유형의 주석에 대한 처리 우선순위는 다음과 같다.[^1]

  1. 컴파일러로 강제 가능한가? → 타입 시스템 또는 언어 구조로 불변 조건을 표현한다 (7장 참조)
  2. 자동화 테스트로 검증 가능한가? → 해당 불변 조건을 검증하는 테스트를 작성한다
  3. 위 두 방법이 모두 불가능한가? → 주석을 유지한다. 이때 주석은 정당한 가치를 갖는다

8.5.1 프로세스 불변 조건 (TODO / FIXME / HACK)

TODO, FIXME, HACK은 코드 자체가 아닌 개발 프로세스의 불변 조건이다.[^1]

  • 이를 코드 내에 작성하는 것 vs. 티켓 시스템에 기록하는 것은 트레이드오프가 있다
  • 코드 내에 둘 경우, 시각적으로 개수를 추적하고 그 수가 줄어들어야 한다
  • 이 주석들은 언젠가 실제로 해결하여 제거하는 것을 목표로 삼는다

나무를 심기에 가장 좋은 시간은 20년 전이었다. 두 번째로 좋은 시간은 지금이다. — 중국 속담


챕터 8 요약

flowchart TD
    A[주석 발견] --> B{유형 판별}
    B --> C[낡은 주석]
    B --> D[주석 처리된 코드]
    B --> E[사소한 주석]
    B --> F[코드 설명 주석]
    B --> G[불변 조건 문서화 주석]

    C --> H[삭제]
    D --> H
    E --> H

    F --> I[메서드로 추출하고\n주석 문구를 메서드명으로 사용]
    I --> J[기존 주석이 사소해짐]
    J --> H

    G --> K{코드/테스트로\n표현 가능?}
    K --> |Yes| L[코드 또는 테스트로 전환 후 삭제]
    K --> |No| M[주석 유지]
핵심 개념 (규칙/패턴) 목적 및 내용
낡은 주석 삭제 코드와 불일치하는 주석은 버그를 유발한다. 즉시 삭제한다.
주석 처리된 코드 삭제 구 코드는 Git 이력에 존재한다. 주석으로 보존할 이유가 없다.
사소한 주석 삭제 코드를 그대로 반복하는 주석은 노이즈이다. 삭제한다.
주석 → 메서드 이름 전환 코드 블록 설명 주석은 메서드 추출로 대체하면 주석이 불필요해진다.
불변 조건 주석 유지 코드나 테스트로 표현 불가능한 비국소적 불변 조건만 주석으로 허용한다.
프로세스 불변 조건 (TODO/FIXME/HACK) 코드 내 잔류 가능하나, 수가 감소하는 방향으로 관리해야 한다.
핵심 원칙 납품 전 리팩터링 단계에서 주석을 제거하는 것을 기본 목표로 삼는다.

Chapter 9. Love Deleting Code: 코드 삭제를 사랑하라

코드는 자산이 아니다. 코드는 부채(liability)다. 기능을 얻기 위해 감수해야 하는 필요악이며, 그것을 유지하는 한 비용은 계속 발생한다.


9.1 코드 삭제, 프로그래밍의 다음 프론티어

프로그래밍 역사의 각 도약은 복잡성을 더 잘 다루기 위한 추상화 수단을 제공했다.

연도 도약 의의
1944 최초 컴퓨터 추상화 없이 순수 계산 수행
1952 Grace Hopper의 링커 발명 순수 계산 대신 심볼로 작업 가능
1957 Fortran (컴파일러) 루프 등 고수준 제어 연산자 도입
1972 C, C++, Java 등 포인터/참조를 통한 데이터 추상화
1994 Gang of Four 디자인 패턴 재사용 가능한 고수준 설계 빌딩블록
1999 Martin Fowler 리팩터링 패턴 기존 코드 설계 개선 방법론 체계화
2011 마이크로서비스 (Sam Newman) 느슨한 결합(loose coupling) 기반 현대적 아키텍처
  • 현재 우리는 거대하고 복잡한 시스템을 구축하는 데 능숙하지만, 코드 삭제에는 아직 능숙하지 않다.
  • 어떤 코드가 실행되는지, 얼마나 자주 실행되는지 파악하는 일 자체에 상당한 투자가 필요하다.
  • 저자는 코드 삭제가 다음에 해결해야 할 큰 문제라고 주장한다.

핵심 원칙: 코드에 시간과 노력을 투자했다는 사실 자체가 그 코드에 가치를 부여하지 않는다. 이를 매몰 비용의 오류(sunk-cost fallacy)라 한다. 가치는 투자 자체가 아닌 투자의 결과에서 온다.

Christopher Hsee(1998)의 연구: 24피스 디너 세트에 깨진 조각 몇 개를 추가했더니 오히려 전체 가치가 하락했다. Less is better.


9.2 우발적 복잡성(Incidental Complexity) 제거를 위한 코드 삭제

시스템은 기능 추가, 실험, 엣지 케이스 처리를 거치면서 자연스럽게 성장한다. 복잡성은 두 가지로 나뉜다.

  • 도메인 복잡성(Domain Complexity): 도메인 자체가 본질적으로 복잡한 것 (예: 세법 계산 시스템). 제거 불가능하다.
  • 우발적 복잡성(Incidental Complexity): 도메인이 요구하지 않는데 부수적으로 추가된 복잡성. 흔히 기술 부채(Technical Debt)의 동의어로 쓰이지만, 더 세분화된 구분이 필요하다.

우발적 복잡성은 네 가지 유형으로 나뉘며, 각각 원인과 해결책이 다르다.


9.2.1 기술적 무지(Technical Ignorance): 경험 부족에서 발생

  • 원인: 불필요한 결합도(coupling)를 추가하지 않고 문제를 해결하는 방법을 모르는 상태. “모른다는 사실조차 모르는” 상태에서 발생한다.
  • 해결책: 지속적인 기술 탁월성(technical excellence) 추구. 책, 블로그, 컨퍼런스, 튜토리얼, 공동 프로그래밍(communal programming), 의도적 연습이 필요하다.

Continuous attention to technical excellence and good design enhances agility. — Manifesto for Agile Software Development

공동 프로그래밍(Communal Programming):

  • Llewellyn Falco의 핵심 원칙: 모든 아이디어는 코드가 되기 전에 다른 사람의 뇌를 거쳐야 한다.
  • 페어 프로그래밍(두 명), 앙상블 프로그래밍(모브 프로그래밍, 다수)으로 구현된다.
  • 실시간 코드 리뷰 → 비동기 코드 리뷰 불필요 → 린(lean)한 전달 프로세스 달성.

9.2.2 기술적 낭비(Technical Waste): 시간 압박에서 발생

  • 원인: 시간 압박으로 인해 더 나은 방법을 알면서도 의도적으로 나쁜 결정을 내리는 것. 테스트 생략, 리팩터링 건너뜀, 프로세스 우회 등이 포함된다.
  • 핵심 판단: 이는 외부 압박에 의한 것이지만 의도적 결정이므로 사보타주(sabotage)에 해당한다.
  • 해결책: 개발자에게 베스트 프랙티스를 생략할 상황은 절대 없다는 것을 가르친다. 관리자, 고객에게는 “에어백 미검사 차량을 3주 빨리 받겠냐”는 비유로 설득한다.

9.2.3 기술 부채(Technical Debt): 상황에서 발생

  • 원인: 어떤 이득을 위해 일시적으로 최선이 아닌 해결책을 선택하는 것.
  • 핵심 구분: 키워드는 **임시(temporary)**다. 임시가 아니라면 그것은 부채가 아니라 낭비다.
  • 올바른 사용: 핫픽스를 임시로 push하고 이후 제대로 재구현하는 것. 기술 부채는 **만료일(expiry date)**이 있을 때만 전략적으로 유효하다.

9.2.4 기술적 저항(Technical Drag): 성장에서 발생

  • 원인: 코드베이스가 성장하면서 발생하는 개발 속도 저하. 자동화 테스트, 문서, 심지어 코드 자체도 기술적 저항이다.
  • 자동화 테스트: 코드 변경 시 테스트도 함께 변경해야 하므로 의도적으로 변경을 어렵게 만든다. 안정성이 중요한 시스템에서는 이것이 좋은 일이다.
  • 문서: 변경 사항 발생 시 업데이트가 필요하므로 속도를 늦춘다.
  • 해결 원칙: 사용량이 적은 문서, 기능, 코드를 유지하는 것은 경제적으로 손해다.

“It doesn’t hurt anything to keep it in there”라는 주장은 거짓이다. Use it or lose it. — 속담

해결 원칙: 가치를 지불하지 못하는 것은 모두 삭제한다. 미사용 기능, 코드, 문서, 위키 페이지, 테스트, 설정 플래그, 인터페이스, 버전 관리 브랜치 등 모두 해당된다.


9.3 친밀도 기반 코드 분류

Dan North의 GOTO 2016 발표 “Software, faster”에서 제시한 친밀도(intimacy) 기반 코드 분류이다.

친밀도 수준 설명
친밀함 (Intimate) 최근에 직접 개발한 코드. 삭제 비용과 위험이 낮다.
친숙함 (Familiar) 자주 사용하는 라이브러리와 유틸리티. 자주 사용하는 한 유지된다.
낯섦 (Unknown) 사이에 있는 모든 것. 재학습 비용이 높아 유지 비용이 비싸다.
  • 6주 법칙: 신선한 코드의 친밀도는 약 6주 후부터 급격히 저하된다. 코드 작성자라도 6주가 지나면 이해에 있어 의미 있는 우위가 사라진다.
  • 함의: 친밀한 코드를 삭제하는 것이 더 싸고 안전하다. 낯선 코드를 삭제하려면 먼저 이해하는 데 비용이 든다.

9.4 레거시 시스템에서 코드 삭제

레거시 코드(Legacy Code): 수정하기 두려운 코드. 이 상황은 흔히 서커스 팩터(circus factor)의 결과다.

서커스 팩터(Circus Factor): 특정 인원이 팀을 떠났을 때 개발이 멈추는 위험을 나타내는 지표 (버스 팩터, 복권 팩터라고도 불림). “존만 이 시스템을 배포하는 방법을 안다”면 서커스 팩터는 1이다. 서커스 팩터를 높게 유지해야 한다.


9.4.1 교살자 무화과 패턴(Strangler Fig Pattern): 인사이트 획득

Martin Fowler의 교살자 무화과 패턴(strangler fig pattern)을 사용하여 레거시 코드의 결합도를 파악하고 모니터링을 추가한다. 패턴 이름은 기존 나무에 씨를 뿌리고 자라면서 숙주를 감싸 결국 질식시키는 교살자 무화과 나무에서 유래한다.

절차:

  1. 레거시 클래스들을 새 네임스페이스(namespace)로 격리한다.
  2. 새 네임스페이스 내에 게이트 클래스(Gate class)를 생성하여 모든 외부 접근을 이 게이트를 통하도록 한다.
  3. 네임스페이스 내 public 접근자를 package-private으로 낮추고, 게이트 클래스에만 public 함수를 둔다.
  4. 게이트에 모니터링(로깅)을 추가하여 각 호출 횟수와 성공 여부를 기록한다.
// Before: 레거시 코드가 외부에 직접 노출되어 결합도 파악 불가

class LegacyA {
  static a() { /* ... */ }
}
class LegacyB {
  b() { /* ... */ }
}

LegacyA.a();
const b = new LegacyB();
b.b();
// After: 네임스페이스로 격리 + Gate 클래스로 접근 집중화 + 모니터링 추가

namespace Legacy {
  class LegacyA {
    static a() { /* ... */ }
  }
  class LegacyB {
    b() { /* ... */ }
  }

  export class Gate {
    a() {
      try {
        const result = LegacyA.a();
        Logger.log("a success");
        return result;
      } catch (e) {
        Logger.log("a fail");
        throw e;
      }
    }
    bClass() {
      try {
        const result = new LegacyB();
        Logger.log("bClass success");
        return result;
      } catch (e) {
        Logger.log("bClass fail");
        throw e;
      }
    }
  }
}

const gate = new Legacy.Gate();
gate.a();
const b = gate.bClass();
b.b();

9.4.2 교살자 무화과 패턴: 코드 개선

모니터링 데이터를 충분히 수집한 후(팀이 정한 기준, 예: 월 1회 미만 = 삭제 대상) 다음 기준으로 처리한다.

flowchart TD
    A[레거시 코드 분석] --> B{사용 빈도 확인}
    B -->|가장 많이 호출| C[마이그레이션 우선 대상]
    B -->|거의 호출되지 않거나 항상 실패| D{전략적/중요 기능인가?}
    D -->|Yes| E[UI 개선 또는 사용자 교육으로 호출 증가 후 리팩터링 또는 재구현]
    D -->|No| F[게이트에서 메서드 삭제]
    F --> G[연쇄적으로 불필요해진 코드 탐색 및 삭제]
    C --> H{처리 방식 선택}
    H -->|결합도/취약성 제거| I[레거시 코드 리팩터링 후 최근 코드로 이동]
    H -->|처음부터 재구현| J[새 버전 완성 후 게이트에서 전환]

9.5 동결된 프로젝트(Frozen Project)에서 코드 삭제

동결된 프로젝트란 구현은 완료되었으나 사용자 교육, 접근 권한 문제 등의 장벽으로 인해 실제로 사용되지 않는 상태의 프로젝트다. 코드, DB 테이블, 인테그레이션, 외부 서비스 등 코드 외부에도 영향을 줄 수 있다.

문제점:

  • 메인 브랜치에 사용되지 않는 코드가 존재한다.
  • 모든 변경 시 이 코드와의 상호작용을 고려해야 한다.
  • 레거시 코드로 전락할 위험이 있다.
  • 장벽이 해소될 때 기능이 여전히 유효하다는 보장이 없다.

9.5.1 기본 동작을 삭제로 만들기

의도적 행동을 취하지 않으면 코드가 삭제되도록 기본값을 설정한다.

유형 처리 방식
코드베이스 내 변경만 있는 경우 메인 브랜치에서 revert하여 별도 브랜치로 이동. 태그 지정 후 6주 후 삭제 예약
외부(DB, 서비스 등) 변경이 있는 경우 프로젝트 관리 도구에 삭제 대상 모든 컴포넌트 목록을 기록한 티켓 생성, 6주 후로 예약

원칙: 두 경우 모두, 의도적 행동을 취하지 않으면 코드가 사라진다. 따라서 기술적 저항(technical drag)은 우발적으로 추가될 수 없고, 오직 의도적으로만 추가된다.


9.5.2 스파이크 앤 스태빌라이즈 패턴(Spike and Stabilize Pattern)으로 낭비 최소화

Dan North의 패턴으로, 6주 법칙의 원래 출처이다.

적용 절차:

  1. 프로젝트를 스파이크(spike)로 취급한다. 메인 애플리케이션과 최대한 분리하여 구현한다.
  2. 자동화 테스트와 리팩터링을 적용하지 않는다 (의도적으로 품질을 낮게 유지).
  3. 모니터링은 반드시 포함하여 사용 여부를 추적한다.
  4. 6주 후 사용 데이터를 확인한다.
    • 사용됨 → 제대로 재구현(테스트, 리팩터링 포함).
    • 사용되지 않음 → 삭제. 이미 메인 시스템과의 통합이 최소화되어 있어 삭제가 쉽다.

효과: 실제로 사용될지 모르는 코드에 리팩터링/테스트 비용을 투자하지 않는다. 삭제 비용도 최소화된다.


9.6 버전 관리 브랜치 삭제

Git 브랜치는 바이트 단위로 거의 무료지만, 정신적 오버헤드(mental overhead) 측면에서는 비싸다.

브랜치 생성 목적별 처리:

  • 핫픽스 브랜치 → 메인 머지 후 즉시 삭제
  • 릴리즈 태그 목적 → 브랜치 대신 Git 내장 태그(tag) 기능 사용
  • 작업 격리 목적 → 메인 머지 후 즉시 삭제

장기 브랜치의 문제점:

  • 비싸고 고통스러운 머지 충돌(merge conflicts) 발생
  • 동결된 프로젝트, 스파이크, 프로토타입의 무덤이 된다
  • 인간 코드 리뷰나 통합 팀을 포함한 게이트는 지속적 통합(continuous integration)을 막고 병목이 된다

9.6.1 브랜치 제한(Branch Limit)으로 낭비 최소화

칸반(Kanban)의 진행 중 작업 제한(WIP Limit) 개념을 브랜치 관리에 적용한다.

브랜치 제한 설정 가이드라인:

  • 제한값 ≥ 워크스테이션 수 (앙상블 프로그래밍이면 앙상블 수, 페어면 페어 수, 개인이면 개인 수)
  • 높게 설정할수록 버퍼가 생기지만 딜레이가 발생한다. 딜레이는 최소화가 목표다.
  • 제한을 깨거나 팀 규모 변화가 아닌 이유로 변경해서는 안 된다.

WIP 제한의 효과: 브랜치가 제한에 도달하면 상류(upstream)에서 새 작업 시작이 불가능해져 팀이 병목을 해결하도록 유도한다.


9.7 코드 문서 삭제

문서는 다음 세 조건이 모두 충족될 때만 가치를 발휘한다.

조건 설명
관련성(Relevant) 올바른 질문에 답해야 한다
정확성(Accurate) 답이 정확해야 한다
발견 가능성(Discoverable) 찾을 수 있어야 한다

조건 미충족 시 비용:

  • 발견 불가 → 연구 시간 + 작성 시간 낭비
  • 관련성 없음 → 위 낭비 + 검색 시 불필요한 내용 건너뜀 + 결국 재조사 필요
  • 부정확 → 최선의 경우 혼란, 최악의 경우 오류 발생 (가장 위험)

9.7.1 지식 문서화 결정 알고리즘

flowchart TD
    A[문서화 여부 결정] --> B{주제가 자주 변경되는가?}
    B -->|Yes| C[문서화 불필요: 최신 상태 유지 불가]
    B -->|No| D{드물게 사용하는가?}
    D -->|Yes| E[문서화한다]
    D -->|No| F{자동화 가능한가?}
    F -->|Yes| G[자동화한다]
    F -->|No| H[암기한다]

추가 전략:

  • 신규 팀원이 문서를 따라가며 부정확한 부분을 수정하게 하면, 사용 빈도를 높이면서 문서를 최신 상태로 유지할 수 있다.
  • 자동화 테스트 케이스를 문서로 활용하는 방식도 정확성 유지에 효과적이다.

9.8 테스트 코드 삭제

자동화 테스트(이하 테스트)는 다양한 유형이 있으며, Kent Beck의 “Test Desiderata”는 12가지 테스트 속성을 정의한다. 여기서는 개발을 저해하는 테스트에 집중한다.


9.8.1 낙관적 테스트(Optimistic Tests) 삭제

  • 정의: 항상 통과하는 테스트. 논리적 동어반복(tautology)에 해당한다.
  • 예시: hash(a) === hash(b) when a === b — 당연히 참인 것을 검증한다.
  • 원칙: 통과 불가능한 테스트는 가치가 없다. 테스트는 실패 가능해야 신뢰를 준다.

“Never trust a test you have not seen fail.” — 테스트 우선(test-first) 커뮤니티


9.8.2 비관적 테스트(Pessimistic Tests) 삭제

  • 정의: 항상 실패하는 테스트.
  • 문제: 경보 피로(alarm fatigue)를 유발하여 실제 오류가 발생해도 무시하게 만든다.
  • 원칙: 실패 테스트에 대한 허용 한계는 0이어야 한다.

9.8.3 불안정한 테스트(Flaky Tests) 수정 또는 삭제

  • 정의: 예측 불가능하게 통과/실패를 반복하는 테스트.
  • 문제: 낙관적/비관적 테스트와 동일한 문제를 가진다. 테스트 결과가 행동을 유발하지 못한다.
  • 원칙: 테스트가 빨간색일 때만 행동을 유발해야 한다. 이 원칙이 적용되지 않는 테스트는 코드베이스에 자리가 없다.

9.8.4 복잡한 테스트 제거를 위한 코드 리팩터링

  • 문제: 복잡한 셋업이나 과도한 중복을 가진 테스트를 리팩터링하는 것은 잘못된 장소에서 올바른 일을 하는 것이다.
  • 핵심 신호: 테스트가 테스트 대상 코드보다 복잡하다면, 이는 테스트 대상 코드의 아키텍처가 잘못되었다는 신호다.
  • 원칙: 리팩터링 노력은 테스트가 아닌 코드에 집중해야 한다.

9.8.5 느린 테스트의 전문화(Specialization)

  • 문제: 엔드-투-엔드(E2E) 테스트가 느려서 다른 테스트 실행 빈도를 떨어뜨린다면 개발을 저해한다.
  • 해결책:
    • 느린 테스트와 빠른 테스트를 분리하고, 빠른 테스트를 최대한 자주 실행한다.
    • 느린 테스트가 실제로 실패를 잡아내는지 확인한다. 아무것도 잡아내지 못한다면 낙관적 테스트이므로 삭제한다.
    • 실제로 문제가 생기는 깊은 곳을 직접 테스트하는 더 빠르고 구체적인 테스트로 교체한다.

9.9 설정 코드(Configuration Code) 삭제

**설정 가능성(Configurability)**의 딜레마:

  • 장점: 코드베이스를 크게 늘리지 않고 사용자 수를 늘릴 수 있다. 기능 플래그(feature flag)는 배포(deploy)와 릴리즈(release)를 분리할 수 있게 한다.
  • 단점: 설정 추가마다 코드 복잡성이 증가한다. 최악의 경우 플래그 조합마다 테스트해야 하므로 테스트 공간이 지수적으로 증가한다.

9.9.1 시간 기반 설정 범위 지정

설정을 기대 수명(expected lifetime)에 따라 세 범주로 분류하여 복잡성 증가를 관리한다.

범주 정의 처리 원칙
실험적(Experimental) 기능 플래그, A/B 테스트 등 단기 실험용 6주 이내 제거. 시작 시점부터 제거 일정을 예약한다
전환적(Transitional) 레거시 시스템에서 신규 시스템으로 이전하는 대규모 변경 중 사용 코드 내 중앙 집중화, 교살자 무화과 패턴으로 게이트 구현, 전환 완료 시 일괄 삭제
영구적(Permanent) 사용자 수를 유의미하게 늘리거나 유지 비용이 매우 낮은 것 다크 모드/라이트 모드처럼 최외곽 코드(스타일링)에만 영향을 주거나, 고객 수를 실질적으로 두 배로 늘리는 경우에만 허용

실험적 설정의 위험: A/B 테스트 설정이 실험 단계를 벗어나 영구화되어 사용자 기반을 둘로 나누는 경우가 많다. 이는 복잡성만 높이고 사용성은 높이지 않는다. 시작부터 제거 리마인더를 설정하여 방지한다.

전환적 설정의 전략: 전환 완료 여부는 게이트를 삭제했을 때 컴파일 에러가 발생하지 않는 시점으로 판단한다. 코드 조각씩 삭제하지 않고 전체를 한 번에 삭제할 수 있어 효율적이다.


9.10 외부 라이브러리 의존도 줄이기

외부 라이브러리는 양날의 검이다.

장점:

  • 수천 줄의 코드를 저렴하게 확보한다
  • 보안처럼 전문 지식이 필요한 영역에서 자체 구현보다 높은 품질을 제공한다
  • React, Swing 같은 프론트엔드 프레임워크처럼 팀에 없는 전문 기술이 필요한 경우에 필수적이다

단점:

  • 업데이트 시 코드 적응 비용 발생 (시간 소모, 오류 가능)
  • 팀의 인지 부하(cognitive load) 증가
  • 업데이트 시점 및 소요 시간 예측 불가
  • 기능 제거 또는 버그 도입 시 임시 우회책(workaround) 필요
  • 라이브러리 자체가 공격 벡터(attack vector)가 될 수 있음

David Gilbertson의 사고 실험: 콘솔 로그 색상 라이브러리에 악성 코드를 숨겨 수십만 다운로드를 기록한 가상 시나리오. 의존성의 의존성까지 모두 감사(audit)해야 한다.


9.10.1 외부 라이브러리 의존도 제한

의존도 관리 전략:

  • 신뢰할 수 있는 고품질 벤더의 라이브러리를 선택한다. 잦은 브레이킹 체인지를 피하는 벤더를 우선한다.
  • 자주 업데이트한다.

If something hurts, do it more. — DevOps 격언

자주 할수록 효율화하려는 동기가 생기고 작업량이 작아져 비용과 위험이 분산된다.

  • 의존성을 가시화하고 각 라이브러리를 ‘강화(enhancing)’ 또는 ’핵심(critical)’으로 분류한다.
분류 라이브러리가 문제를 일으켰을 때 처리 방법
강화(Enhancing) 즉시 제거 후 애플리케이션 동작, 이후 대체품 탐색
핵심(Critical) 신중하게 관리. 강화에서 핵심으로 승격은 매우 신중하게
  • 사용하지 않는 라이브러리는 즉시 제거한다.
  • jQuery 전체를 설치하고 Ajax 호출 하나만 쓰는 경우처럼, 소수 기능만 사용한다면 더 단순한 라이브러리 또는 자체 구현을 고려한다.

9.11 동작하는 기능의 코드 삭제

코드는 부채이고, 기능(feature)이 많다고 사용량이 늘어나는 것은 아니다.

비용-편익 분석:

  • 기능의 편익은 복잡하다: 복잡성 수용 기간, 예측 가능성 가치, 기능 테스트 방법, 사용자 온보딩 등 다양한 요소가 얽혀 있다.
  • 비용-편익 관계에서 가치를 높이는 두 가지 방법: ① 편익 증가, ② 비용 감소.
  • 편익 계산이 복잡하므로, 리팩터링 또는 코드 삭제로 비용을 줄이는 것이 더 쉬운 경우가 많다.

핵심 원칙: 사용량 증가보다 유지 비용이 더 높은 동작하는 기능도 삭제 대상이다. 잠재력만 있고 실제로 사용되지 않는 것은 오직 비용이다. 코드를 삭제하면 즉각적으로 코드베이스의 가치가 높아진다.


챕터 9 요약

핵심 개념 (규칙/패턴) 목적 및 내용
코드는 부채다 코드에 가치를 부여하는 것은 매몰 비용의 오류다. 가치는 기능, 비용은 코드다.
우발적 복잡성 4분류 기술적 무지(경험 부족), 기술적 낭비(시간 압박), 기술 부채(임시 결정), 기술적 저항(성장 부작용)
6주 법칙 코드 친밀도는 약 6주 후 급격히 저하된다. 동결 프로젝트, 설정 코드의 만료 기준으로 활용한다.
교살자 무화과 패턴 레거시 코드를 네임스페이스와 게이트로 격리 → 모니터링 추가 → 사용량 기반으로 마이그레이션/삭제 결정
스파이크 앤 스태빌라이즈 패턴 동결 위험이 있는 기능을 테스트/리팩터링 없이 빠르게 구현 + 모니터링 → 6주 후 사용 여부에 따라 재구현 또는 삭제
브랜치 제한(WIP Limit 적용) 칸반의 WIP 제한을 브랜치에 적용하여 머지 충돌과 정신적 오버헤드를 줄인다. 제한은 워크스테이션 수 이상으로 설정한다.
문서화 결정 알고리즘 자주 변경되면 문서화 불가 → 드물게 사용하면 문서화 → 자동화 가능하면 자동화 → 아니면 암기
나쁜 테스트 삭제 낙관적(항상 통과), 비관적(항상 실패), 불안정한(flaky) 테스트는 신뢰를 훼손하고 삭제해야 한다.
설정 코드 시간 범주화 실험적(6주 만료), 전환적(완료 시 일괄 삭제), 영구적(사용 증가 또는 유지 비용 최소)으로 분류하여 복잡성을 관리한다.
라이브러리 의존도 최소화 의존성을 가시화하고 강화/핵심으로 분류. 자주 업데이트하고, 소수 기능만 사용하는 라이브러리는 자체 구현을 고려한다.
동작하는 기능도 삭제 대상 유지 비용이 사용량이 주는 가치보다 높은 기능은 삭제한다. 코드 삭제는 즉각적으로 코드베이스 가치를 높인다.

Chapter 10. Never Be Afraid to Add Code: 코드 추가를 두려워하지 마라

“코드를 추가하는 것은 수정하는 것보다 안전하다. 두려움은 생산성을 죽인다.”[^1]


10.1 불확실성 수용: 위험 속으로 뛰어들어라

소프트웨어 개발은 도메인을 학습하고 그 지식을 코드로 변환하는 과정이다. 지식을 쌓는 가장 효과적인 방법은 실험이며, 이는 용기를 요구한다.[^1]

  • 심리적 안전감(Psychological Safety): Google의 연구에 따르면 팀 생산성의 가장 큰 예측 인자는 팀원들이 리스크를 감수할 수 있다고 느끼는 심리적 안전감이다.[^1]
  • “위험 속으로(Enter the Danger)”: 즉흥 연극에서 유래한 개념으로, 불편한 상황을 피하려는 본능에 저항하고 정면으로 맞서야 한다는 원칙이다. Patrick Lencioni는 이를 효과적인 컨설팅의 가장 중요한 교훈 중 하나로 꼽는다.[^1]
  • 첫날 배포 전략: 새로운 팀에 합류했을 때 첫날부터 프로덕션에 배포하는 전략은 두려움과 불안을 즉시 제거하고, 팀에서 가치를 전달하는 방법을 빠르게 익히게 한다.[^1]

“두려움은 심리적 고통의 한 형태다. 무언가가 두렵다면, 더 자주 하라. 더 이상 두렵지 않을 때까지.”[^1]


10.2 스파이크(Spike)로 잘못 만드는 것에 대한 두려움 극복

스파이크(Spike): 프로덕션 코드를 작성하기 전에 가설을 검증하기 위해 버려질 것을 전제로 작성하는 탐색용 코드다.[^1]

개발 워크플로

flowchart TD
    A[탐색: Spike 작성] --> B{지식 획득}
    B --> C[첫 번째 실제 버전 작성]
    C --> D[리팩터링 및 개선]
    D --> E[프로덕션 배포]
    B -->|가설 폐기| F[코드 삭제]
  • 스파이크의 핵심 전제: 스파이크에서 생산된 코드는 main에 병합되지 않는다. 코드가 결함이 있어도 괜찮기 때문에 두려움이 사라진다.[^1]
  • 프로덕트는 코드가 아닌 지식이다: 스파이크 코드를 프로덕션에 그대로 사용하면 지식이 아닌 코드가 산출물이 된다는 신호를 보내게 되며, 결국 스파이크 중에도 두려움이 재발한다.[^1]
  • 스파이크 결과물 형식화: 슬라이드 1장(핵심 3가지 + 스크린샷/목업)으로 정리하면 이해관계자에게 시간이 낭비되지 않았음을 증명하고, 팀 지식 공유 세션에 재사용할 수 있다.[^1]

10.3 고정 비율로 낭비·리스크에 대한 두려움 극복

지원 도구(테스트 환경, 브랜치 전략, 기능 토글 시스템, CI/CD 파이프라인)가 실제 프로덕션 코드보다 훨씬 정교할 때, 이는 낭비·리스크에 대한 두려움이 전달(delivery)에 대한 욕구보다 크다는 신호다.[^1]

실화: 한 팀이 아무 코드도 없이 정교한 빌드·배포 파이프라인만 구축했다. “우리는 아직 코드가 없어요. 파이프라인을 만드느라 바빴습니다.”[^1]

권장 비율: 80:20 규칙

구분 비율 내용
프로덕션 코드 80% 실제 비즈니스 가치를 전달하는 기능 코드
지원 도구 유지·개발 20% 테스트, CI/CD, 리팩터링, 자동화 등
  • 20% 할당의 효과: 중요한 유지보수 작업이 기능 작업에 묻히지 않도록 보장하고, 반대로 지원 도구의 복잡도가 프로덕션 코드를 초과하지 않도록 제한한다.[^1]
  • 가장 성공적인 구현 방식: 매주 금요일을 비(非)티켓 업무(이해관계자 요청이 아닌 모든 것)로 지정한다. 실험, 대규모 리팩터링, 낭비 감소를 위한 자동화를 수행한다. 하루 단위는 컨텍스트 전환 오버헤드 없이 의미 있는 작업을 완료하기에 충분하다.[^1]
  • 스프린트 단위 리팩터링의 위험: 매 5번째 스프린트를 리팩터링에 할당하는 방식은 코드가 점점 더 결합되고 얽혀 작업 속도가 느려지는 4번의 스프린트를 거치게 된다는 단점이 있다.[^1]

10.4 점진적 개선으로 불완전함에 대한 두려움 극복

사기꾼 증후군(Imposter Syndrome): 자신이 직무에 부적합하다고 느끼고 누군가가 자신을 사기꾼으로 폭로할 것을 두려워하는 심리 현상으로, 코드를 완벽하게 만들려는 강박으로 이어져 미루기(procrastination)를 유발한다.[^1]

  • 완벽한 코드는 존재하지 않는다: 성능, 구조, 추상화 수준, 사용 편의성, 유지보수 용이성, 안정성, 보안 등 모든 지표를 동시에 최적화하는 것은 불가능하다.[^1]
  • 최우선 최적화 지표: 개발자 생애(Developer Life): 작업을 받은 시점부터 작동하는 결과물을 만들기까지의 시간을 최소화한다. 이는 피드백 루프를 단축시켜 품질을 높이고, 연습 횟수를 극대화하며, 개발자가 원하는 일에 더 많은 시간을 쓸 수 있게 한다.[^1]
  • 스파이크 앤 스태빌라이즈(Spike and Stabilize) 패턴 (Dan North): 작업을 스파이크처럼 처리하여 지표에 무관하게 코드를 작성하고, 모니터링을 추가한다. 6주 후 사용 여부를 확인하여 미사용 시 삭제, 사용 중이면 피드백을 바탕으로 재작성한다. 실제로 사용되는 코드에만 시간을 투자하므로 개발자 생애를 최적화한다.[^1]

10.5 코드 복사·붙여넣기가 변경 속도에 미치는 영향

코드 중복(duplication)과 공유(sharing)는 서로 다른 트레이드오프를 가진다.[^1]

공유 vs. 중복 비교

특성 코드 공유 (Sharing) 코드 중복 (Duplication)
전역 동작 변경 속도 빠름 (한 곳만 수정) 느림 (모든 위치 수정 필요)
지역 동작 변경 속도 느림 (다른 호출부 영향 고려) 빠름 (해당 위치만 수정)
시스템 취약성(Fragility) 높음 낮음
실험 용이성 낮음 높음
  • 취약성(Fragility)의 원인: 공유 코드의 각 호출 지점은 서로 다른 지역 불변 조건(local invariant)을 가진다. 공유 코드를 변경할 때마다 이 불변 조건들을 깨뜨릴 위험이 있다.[^1]
  • 스파이크에서의 원칙: 스파이크 중에는 최대한 많은 중복을 권장한다. 코드가 안정화된 후, 다음 질문으로 통합 여부를 결정한다.[^1]

통합 여부 결정 질문: “이 코드가 원본과 결합되어야 하는가? 이것이 변경될 때 원본도 변경되어야 하는가? 우리 팀이 통합된 코드를 소유할 것인가?” 하나라도 ’아니오’라면 분리 상태를 유지한다.[^1]


10.6 확장성을 통한 추가에 의한 수정

코드가 변경에 취약한 곳을 파악하면 확장 가능하게 만들 수 있다. 변형(variation)을 별도의 클래스로 분리하면, 새로운 변형을 추가하는 것이 새 클래스를 하나 추가하는 것만큼 단순해진다.[^1]

  • 우발적 복잡성(Accidental Complexity): 도메인을 표현하는 데 필요하지 않은 복잡성. 모든 것을 확장 가능하게 만들면 우발적 복잡성이 증가한다.[^1]
  • 본질적 복잡성(Essential Complexity): 코드가 표현하는 실제 도메인에서 비롯된 불가피한 복잡성.[^1]
  • 변형 지점(Variation Point)은 필요할 때만 도입한다: 불필요한 확장성은 코드 흐름을 이해하기 어렵게 만든다. 우발적 복잡성 억제를 위해 변형 지점 도입을 최대한 미룬다.[^1]

3단계 확장 프로세스

  1. 코드를 중복(duplicate)한다.
  2. 중복된 코드를 작업하고 조정한다.
  3. 의미가 있다면 원본과 통합(unify)한다.[^1]

Expand-Contract 패턴

데이터베이스 등의 브레이킹 체인지(breaking change)를 안전하게 도입하는 리팩터링 패턴이다.[^1]

단계 이름 설명
1 확장(Expand) 새로운 기능을 추가한다. 안전하지만 동일한 동작이 두 곳에 존재한다.
2 마이그레이션(Migrate) 호출부를 새 기능으로 점진적으로 이동한다. 가장 긴 단계다.
3 수축(Contract) 모든 호출부 이동 후 원본을 삭제한다.
  • 확장성을 부여하는 두 가지 핵심 패턴: 타입 코드를 클래스로 교체(Replace Type Code with Classes, P4.1.3)전략 패턴 도입(Introduce Strategy Pattern, P5.4.2). 둘 다 정적 구조를 동적 구조로 전환한다.[^1]

10.7 추가에 의한 수정을 통한 하위 호환성 유지

“코드에서 가장 안전한 행동은 아무것도 변경하지 않는 것이다.”[^1]

공개 인터페이스나 API를 외부에 노출할 때, 호출자를 의도치 않은 사이드 이펙트로부터 보호할 책임이 있다.[^1]

  • 하위 호환성(Backward Compatibility)의 원칙: 변경이 필요할 때마다 공개 인터페이스에 새로운 메서드를, API에 새로운 엔드포인트를, 이벤트 기반 시스템에 새로운 이벤트를 추가한다. 기존 구현은 그대로 유지한다.[^1]
  • 버전 명명 방식: 버전 정보를 진입점(entry point) 이름에 직접 포함하는 가장 단순한 방법을 권장한다. 가장 외부 레이어(사용자와의 인터페이스)만 버전 관리한다. 내부 메서드는 테스트로 검증 가능하므로 버전 관리가 불필요하다.[^1]
// Before: 버전 혼재로 호출자가 혼란스러운 PHP 방식의 나쁜 예시
// mysql_escape_string
// mysql_real_escape_string
// mysqli_real_escape_string

// After: 버전이 이름에 명확히 드러나는 일관된 방식
function sanitizeInputV1(input: string): string { /* ... */ }
function sanitizeInputV2(input: string): string { /* ... */ }
  • 구버전 폐기 절차: 구버전에 모니터링을 추가하고, 새 버전 사용을 유도하며, 모니터링에서 사용량이 0이 되면 안전하게 삭제한다.[^1]

10.8 기능 토글(Feature Toggle)을 통한 추가에 의한 수정

기능 토글(Feature Toggle): 코드를 배포(deploy)하되 릴리스(release)는 나중에 하기 위해, 코드를 조건부로 실행되도록 감싸는 패턴이다. 코드를 통합 브랜치에 자주, 소량으로 병합하는 것이 가능해진다.[^1]

기능 토글 구현 절차 (4단계)

Step 1: FeatureToggle 클래스 생성

// After: FeatureToggle 클래스 신규 생성
class FeatureToggle {}

Step 2: 기능 플래그(Feature Flag) 메서드 추가 (초기값 false)

// After: 기능 플래그 추가 — 기본값은 false로 아무도 영향받지 않음
class FeatureToggle {
  static featureA(): boolean { return false; }
}

Step 3: 변경 지점을 if/else로 감싸기

// Before: 기존 코드만 실행
class Context {
  foo() {
    code();
  }
}

// After: 기존 코드를 else로 이동, if 블록은 비워둠
class Context {
  foo() {
    if (FeatureToggle.featureA()) {
      // 새 코드 자리
    } else {
      code(); // 기존 코드, 변경 없음
    }
  }
}

Step 4: else 코드를 if로 복사 후 수정, 플래그를 환경변수 기반으로 전환

// After: 새 코드는 if 블록 내에서만 실행되고, 플래그는 환경변수로 제어
class FeatureToggle {
  static featureA(): boolean {
    return Env.isSet("featureA"); // 환경변수 미설정 시 false
  }
}

class Context {
  foo() {
    if (FeatureToggle.featureA()) {
      newCode(); // 수정된 새 코드
    } else {
      code();    // 기존 코드
    }
  }
}

기능 토글의 주의사항

  • 의도치 않은 노출 위험: 절차를 잘못 따르면 의도하지 않은 기능이 프로덕션에 노출될 수 있다. 단순한 구현 방식일수록 실수 가능성이 낮다.[^1]
  • 토글은 기술 부채다: if 분기는 실제 복잡도를 증가시킨다. 의존적인 기능이 생기면 양쪽 분기에 모두 if가 필요해져 폭발적으로 복잡해진다.[^1]
  • 삭제 일정 강제화: 토글을 생성하는 작업이 종료될 때 최대 6주 후 토글 제거 작업을 별도로 스케줄링한다. 6주 후 기능이 켜져 있으면 else 제거, 꺼져 있으면 if 제거(사장된 코드로 처리).[^1]

경고 사례 — Knight Capital (2012): 기능 토글을 잘못 관리하여 7년 된 미사용 설정 플래그가 재활성화되었고, 두 버전의 호환되지 않는 코드가 동시에 실행되어 45분 만에 4억 달러 이상 손실이 발생했다.[^1]

토글 성숙도 로드맵

토글에 익숙해진 후 단계적으로 확장한다.[^1]

  1. 환경변수 기반 단순 토글 (위 4단계)
  2. 데이터베이스 저장 + 관리 UI (비즈니스 팀이 직접 토글 제어)
  3. 단계적 롤아웃 (초기 10% 사용자에게만 노출, 점진적 확대)
  4. A/B 테스트: 토글을 비즈니스 지표(구매 전환율 등)와 연결하여 성과가 좋은 버전을 자동 확대 적용

10.9 추상화를 통한 브랜치(Branch by Abstraction)

기능 토글이 여러 위치에서 사용될 때, 다수의 if/elseNever use if with else (R4.1.1) 규칙을 위반한다. 이를 해결하는 것이 **추상화를 통한 브랜치(Branch by Abstraction)**다.[^1]

  • 적용 시점: 기능 플래그가 코드의 여러 위치에서 사용되어 불변 조건(invariant)이 여러 if에 분산될 때 적용한다.[^1]
  • 핵심 아이디어: 불리언 플래그 대신 인터페이스 구현체(클래스)를 반환하도록 FeatureToggle을 변경한다. Version1Version2 두 클래스가 분기(branching)가 되고, 클래스가 추상화(abstraction)가 된다.[^1]

Before: 기능 토글 방식 (다수의 if 분산)

// Before: 여러 위치에 if/else가 분산되어 불변 조건 관리가 어려움
class FeatureToggle {
  static featureA(): boolean {
    return Env.isSet("featureA");
  }
}

class ContextA {
  foo() {
    if (FeatureToggle.featureA()) { aCodeV2(); }
    else { aCodeV1(); }
  }
}

class ContextB {
  bar() {
    if (FeatureToggle.featureA()) { bCodeV2(); }
    else { bCodeV1(); }
  }
}

After: 추상화를 통한 브랜치 (인터페이스로 if 제거)

// After: 불변 조건이 Version1/Version2 클래스에 집중되어 관리가 용이함
interface FeatureA {
  aCode(): void;
  bCode(): void;
}

class Version1 implements FeatureA {
  aCode() { aCodeV1(); }
  bCode() { bCodeV1(); }
}

class Version2 implements FeatureA {
  aCode() { aCodeV2(); }
  bCode() { bCodeV2(); }
}

class FeatureToggle {
  static featureA(): FeatureA {
    return Env.isSet("featureA") ? new Version2() : new Version1();
  }
}

class ContextA {
  foo() { FeatureToggle.featureA().aCode(); } // if 없음
}

class ContextB {
  bar() { FeatureToggle.featureA().bCode(); } // if 없음
}

추상화 브랜치 제거 절차 (4단계)

  1. Version1 클래스 삭제
  2. No interface with only one implementation (R5.4.3) 규칙에 따라 FeatureA 인터페이스 삭제
  3. 남은 클래스의 메서드를 호출 지점에 인라인(inline)
  4. Version2 클래스 삭제 → FeatureToggle만 남고 토글 흔적 없이 정리 완료[^1]

챕터 10 요약

핵심 개념 (규칙/패턴) 목적 및 내용
Enter the Danger (위험 속으로) 불확실한 영역을 회피하지 말고 정면으로 맞서라. 두려운 것은 더 자주 하면 익숙해진다.
스파이크(Spike) 워크플로 버려질 것을 전제로 탐색 코드를 작성해 두려움을 제거하고 지식을 획득한다. 산출물은 코드가 아닌 지식이다.
80:20 지원 도구 비율 지원 도구 개발에 최대 20%만 할당해 우발적 복잡성과 프로크라스티네이션을 방지한다.
개발자 생애(Developer Life) 최적화 작업 시작부터 작동까지의 시간을 최소화해 피드백 루프를 단축하고 실질적 품질을 높인다.
코드 중복 vs. 공유 트레이드오프 중복은 지역 변경 속도와 실험 용이성을 높이고, 공유는 전역 변경 속도를 높이되 취약성을 증가시킨다.
우발적 복잡성 vs. 본질적 복잡성 도메인 표현에 불필요한 우발적 복잡성을 억제하고, 변형 지점(variation point)은 필요할 때만 도입한다.
Expand-Contract 패턴 확장 → 마이그레이션 → 수축의 3단계로 브레이킹 체인지를 안전하게 도입한다.
하위 호환성(Backward Compatibility) 변경 시 기존 구현 유지, 새 기능은 새 진입점으로 추가한다. 버전은 이름에 직접 포함한다.
기능 토글(Feature Toggle) 배포와 릴리스를 분리해 잦은 통합을 가능하게 한다. 토글은 최대 6주 내 삭제를 스케줄링해야 한다.
추상화를 통한 브랜치(Branch by Abstraction) 다수의 if/else 대신 인터페이스와 구현체로 기능 분기를 표현해 불변 조건을 한 곳에 집중시킨다.

Chapter 11. Follow the Structure in the Code: 코드 속 구조를 따르라

소프트웨어는 현실 세계의 단면을 모델링한 것이다. 현실 세계의 연결 관계는 코드 안에 반드시 반영되어야 하며, 코드는 현실로부터 비롯된 구조를 성문화(codified)한 결과물이다.


11.1 구조의 범위와 기원에 따른 분류

소프트웨어 개발에서 다루는 구조(Structure)는 단순히 코드 패턴에 그치지 않는다. 팀 간/팀 내(intra/inter-team) 축과 코드/사람(in code/in people) 축의 2차원으로 분류된다.

구분 팀 간 (Inter-team) 팀 내 (Intra-team)
코드 내 External API 데이터와 함수, 대부분의 리팩터링
사람 내 조직도(org chart), 프로세스 행동 방식, 도메인 전문가 지식
  • 매크로 아키텍처 (Macro-architecture): 팀 간 구조를 다룬다. 제품의 외부 API 형태, 팀이 소유한 데이터, 소프트웨어 플랫폼을 정의한다.
  • 마이크로 아키텍처 (Micro-architecture): 팀 내 구조를 다룬다. 이 책의 리팩터링 패턴들이 모두 이 범주에 속한다.
  • 콘웨이의 법칙 (Conway’s Law): 조직 구조는 외부 API 형태를 제약하는 경향이 있다. 마찬가지로 도메인 전문가의 행동 방식은 코드 구조 안으로 스며든다.

사용자 행동과 리팩터링 범위

  • 사용자를 코드의 일부로 볼 수 있다.
  • 사용자와 상호작용할 수 없다면 그들은 외부 요소이므로 리팩터링의 제약 조건이 된다.
  • 사용자 재교육이 가능하다면 리팩터링 범위 안에 포함된다. 단, 대규모 조직에서 사람의 행동을 바꾸는 것은 코드를 바꾸는 것보다 훨씬 느리고 어렵다.
  • 따라서 먼저 현재 사용자 행동을 비효율까지 포함해 그대로 모델링하고, 이후 점진적으로 더 효율적인 기능을 제공하며 행동을 리팩터링하는 것이 실용적이다.

11.2 코드가 행동을 반영하는 세 가지 방법

행동(behavior)이 어디서 비롯되든, 코드 안에 행동을 내장하는 방법은 세 가지다.

  1. 제어 흐름(Control Flow) 안에 행동을 표현한다
  2. 데이터 구조(Data Structure) 안에 행동을 표현한다
  3. 데이터(Data) 자체 안에 행동을 표현한다

리팩터링은 행동을 변경하지 않는다. 따라서 리팩터링은 중복을 관리하거나, 구조를 한 방식에서 다른 방식으로 이동시키는 작업이다.

세 방법의 차이를 보여주기 위해 FizzBuzz를 예시로 활용한다.


11.2.1 제어 흐름으로 행동 표현하기

제어 흐름은 if, while, for 같은 제어 연산자, 메서드 호출, 또는 단순한 코드 라인들을 통해 표현된다.

동일한 루프를 세 가지 제어 흐름 방식으로 표현하면 다음과 같다.

// 방식 1: 제어 연산자 (Control Operator)
let i = 0;
while (i < 5) {
  foo(i);
  i++;
}

// 방식 2: 메서드 호출 (Method Call, 재귀)
function loop(i: number) {
  if (i < 5) {
    foo(i);
    loop(i + 1);
  }
}

// 방식 3: 라인 나열 (Lines)
foo(0);
foo(1);
foo(2);
foo(3);
foo(4);
  • 코드 중복을 논할 때는 대부분 이 세 하위 유형 간의 이동을 의미하며, 특히 가장 오른쪽 유형인 라인 나열 방식에서 벗어나는 방향이 일반적이다.
  • 메서드 호출과 라인은 비지역적(non-local) 구조를 표현할 수 있지만, 루프는 지역적(local)으로만 작동한다.
  • 제어 연산자와 메서드 호출만이 무한 루프를 생성할 수 있다 (for(;;), 재귀 함수 등).

제어 흐름 방식의 특성:

  • 구문을 이동하는 것만으로 흐름을 바꿀 수 있어 큰 변경을 쉽게 만든다.
  • 반면 안정성과 작은 변경을 선호하는 상황에서는 제어 흐름 밖으로 리팩터링하는 것이 좋다.
  • 큰 조정이 필요한 경우: 행동을 제어 흐름으로 옮기고, 변경을 적용한 뒤, 다시 원래 방식으로 되돌리는 전략을 취할 수 있다.

FizzBuzz - 제어 흐름 방식:

// After: 전통적인 제어 흐름 방식의 FizzBuzz 구현
function fizzBuzz(n: number) {
  for (let i = 0; i < n; i++) {
    if (i % 3 === 0 && i % 5 === 0) {
      console.log("FizzBuzz");
    } else if (i % 5 === 0) {
      console.log("Buzz");
    } else if (i % 3 === 0) {
      console.log("Fizz");
    } else {
      console.log(i);
    }
  }
}

11.2.2 데이터 구조로 행동 표현하기

데이터 구조는 시간 속에 얼어붙은 알고리즘이다.

  • 대표적인 예: 이진 탐색(Binary Search) 알고리즘과 이진 탐색 트리(BST, Binary Search Tree) 데이터 구조의 관계.
    • BST의 불변 조건(invariant): 왼쪽 자식은 부모보다 작고, 오른쪽 자식은 부모보다 크다.
    • 이진 탐색의 행동이 BST의 구조 안에 내장되어 있다.

재귀적 데이터 구조를 이용한 무한 루프 표현:

// After: 데이터 구조로 무한 루프 표현 (함수가 자기 자신을 직접 호출하지 않음)
class Rec {
  constructor(public readonly f: (_: Rec) => void) {}
}

function loop() {
  const helper = (r: Rec) => r.f(r);
  helper(new Rec(helper));
}

FizzBuzz - 데이터 구조 방식 (3의 배수/5의 배수를 클래스로 인코딩):

// After: 행동을 데이터 구조(클래스/인터페이스)에 내장한 FizzBuzz

interface FizzAction {
  num(n: number): void;
  buzz(): void;
}
class SayFizz implements FizzAction {
  num(n: number) { console.log("Fizz"); }
  buzz() { console.log("FizzBuzz"); }
}
class FizzNumber implements FizzAction {
  num(n: number) { console.log(n); }
  buzz() { console.log("Buzz"); }
}

interface BuzzAction {
  num(n: number, act: FizzAction): void;
}
class SayBuzz implements BuzzAction {
  num(n: number, act: FizzAction) { act.buzz(); }
}
class BuzzNumber implements BuzzAction {
  num(n: number, act: FizzAction) { act.num(n); }
}

// % 3 주기를 상태 머신(State Machine)처럼 클래스로 인코딩
interface FizzNum { next(): FizzNum; action(): FizzAction; }
class FizzNum1 implements FizzNum { next() { return new FizzNum2(); } action() { return new FizzNumber(); } }
class FizzNum2 implements FizzNum { next() { return new Fizz(); } action() { return new FizzNumber(); } }
class Fizz implements FizzNum { next() { return new FizzNum1(); } action() { return new SayFizz(); } }

// % 5 주기를 클래스로 인코딩
interface BuzzNum { next(): BuzzNum; action(): BuzzAction; }
class BuzzNum1 implements BuzzNum { next() { return new BuzzNum2(); } action() { return new BuzzNumber(); } }
class BuzzNum2 implements BuzzNum { next() { return new BuzzNum3(); } action() { return new BuzzNumber(); } }
class BuzzNum3 implements BuzzNum { next() { return new BuzzNum4(); } action() { return new BuzzNumber(); } }
class BuzzNum4 implements BuzzNum { next() { return new Buzz(); } action() { return new BuzzNumber(); } }
class Buzz implements BuzzNum { next() { return new BuzzNum1(); } action() { return new SayBuzz(); } }

function fizzBuzz(n: number) {
  let f: FizzNum = new Fizz();
  let b: BuzzNum = new Buzz();
  for (let i = 0; i < n; i++) {
    b.action().num(i, f.action());
    f = f.next();
    b = b.next();
  }
}

데이터 구조 방식의 특성:

  • 기존 변동 지점(variation point)에 맞는 작은 변경은 더 쉽고 안전하다.
  • 타입 안전성, 지역성(locality), 성능(캐싱/재사용 가능) 측면에서 유리하다.
  • 구조와 맞지 않는 큰 변경은 제어 흐름 방식보다 어렵다.
  • 관련 리팩터링 패턴: Replace type code with classes (P4.1.3), Introduce strategy pattern (P5.4.2).

11.2.3 데이터 자체로 행동 표현하기

행동을 데이터 자체에 직접 인코딩하는 방식이다. 컴파일러/도구의 지원을 전혀 받을 수 없어 가장 다루기 어렵다.

// After: 데이터(배열)를 이용한 무한 루프 - 직접 호출이 아닌 힙을 통한 간접 호출
function loop() {
  const a: Array<() => void> = [() => {}];
  a[^0] = () => a[^0]();
  a[^0]();
}

FizzBuzz - 데이터 방식 (배열에 인스턴스를 직접 저장):

// After: 행동을 배열(데이터)에 직접 담은 FizzBuzz

interface FizzAction { num(n: number): void; buzz(): void; }
class SayFizz implements FizzAction {
  num(n: number) { console.log("Fizz"); }
  buzz() { console.log("FizzBuzz"); }
}
class FizzNumber implements FizzAction {
  num(n: number) { console.log(n); }
  buzz() { console.log("Buzz"); }
}
interface BuzzAction { num(n: number, act: FizzAction): void; }
class SayBuzz implements BuzzAction { num(n: number, act: FizzAction) { act.buzz(); } }
class BuzzNumber implements BuzzAction { num(n: number, act: FizzAction) { act.num(n); } }

// 숫자 3을 배열 길이(주기)로 인코딩
const FIZZ: FizzAction[] = [new SayFizz(), new FizzNumber(), new FizzNumber()];
// 숫자 5를 배열 길이(주기)로 인코딩
const BUZZ: BuzzAction[] = [new SayBuzz(), new BuzzNumber(), new BuzzNumber(), new BuzzNumber(), new BuzzNumber()];

function fizzBuzz(n: number) {
  for (let i = 0; i < n; i++) {
    BUZZ[i % BUZZ.length].num(i, FIZZ[i % FIZZ.length]);
  }
}

데이터 방식의 특성:

  • 산업 현장에서는 주로 중복된 데이터(duplicated data) 형태로 나타난다.
  • 데이터가 가변(mutable)이면 일관성(consistency) 문제를 유발한다.
  • 성능상 이점이 있을 수 있으나 오류와 낭비의 원인이 되기도 한다.
  • 컴파일러 지원이 없어 안전하게 유지보수하기 어렵다. 다른 두 방식 중 하나로 적극 전환하는 것을 권장한다.

세 가지 방식 비교

방식 큰 변경 용이성 작은 변경 안전성 컴파일러 지원 성능 이점 권장 사용 시점
제어 흐름 높음 낮음 있음 없음 불확실한 초기 구현, 큰 구조 변경 시
데이터 구조 낮음 높음 있음 (타입 안전성) 있음 (캐싱) 구조가 안정화된 성숙한 코드
데이터 중간 낮음 없음 있음 (조건부) 최후 수단, 적극 회피 권장

11.3 구조를 드러내기 위해 코드를 추가하라

리팩터링은 특정 변경 방향(change vector)을 지지한다. 일부 변경을 쉽게 만드는 대신 다른 변경을 어렵게 만들기도 한다.

  • 리팩터링은 현재 구조를 고착화(solidify)하고, 유사한 변경을 더 쉽게 받아들이도록 변동 지점(variation point)을 예상되는 위치에 배치한다.
  • 안정적인 서브시스템: 리팩터링이 개발 속도와 품질을 높이는 데 매우 효과적이다.
  • 불확실성이 높은 서브시스템: 안정성보다 실험이 더 중요하다. 리팩터링을 자제하고 올바름(correctness)에 먼저 집중한다.

불확실한 상황에서의 원칙:

  • 비지역적 불변 조건(non-local invariant)은 항상 피해야 한다 (팀 생산성을 희생시키는 취약성은 금지).
  • 리팩터링을 미룰 경우, 미리팩터링된 코드를 캡슐화하여 나머지 코드에 의도치 않은 영향을 주지 않도록 한다.
  • 변동 지점을 무분별하게 추가하지 않는다. 변동 지점은 복잡성을 더하고 다른 구조를 숨길 수 있다.

성숙도에 따른 코드 전략:

  • 새로운 기능/서브시스템 구현 초기: enum과 루프를 사용한다(빠른 변경 가능, 테스트로 안전망 확보).
  • 코드가 성숙하고 구조가 안정되면: 리팩터링으로 구조를 코드에 맞게 조각한다.
  • 코드의 견고함(solidity)은 해당 코드의 방향에 대한 확신의 정도를 나타내야 한다.

11.4 예측하는 대신 관찰하고, 경험적 기법을 활용하라

변경 방향을 예측하려는 시도는 코드베이스를 도움이 아닌 해가 되는 방향으로 이끈다.

“체스를 어떻게 구현하겠냐”는 질문에 한 개발자는 인터페이스와 클래스를 쓰겠다고 답했다. “하드코딩은 어떤가?” 물으니 “유지보수가 힘들다”고 웃었다. “체스 규칙은 500년 동안 바뀐 적이 없는데?“라는 답변에 그는 눈이 커졌다.

이 일화는 강력한 도구를 갖고 있다고 해서 항상 써야 하는 것은 아님을 보여준다. 코드가 어떻게 변화하는지 관찰하는 것이 우선이다.

코드 변화 패턴에 따른 전략:

코드 변화 양상 권장 전략
변하지 않는다 아무것도 하지 않는다
예측 불가능하게 변한다 취약성을 피하는 수준의 최소 리팩터링만 한다
과거 패턴에 따라 변한다 과거에 발생한 변경 유형을 수용하도록 리팩터링한다
  • Toyota Kata, Evidence-Based Management, Popcorn Flow 등 소프트웨어 업계의 경험적(empirical) 개선 기법들이 이 원칙과 맥을 같이 한다.
  • “이 일반화가 실제로 사용될지 확실하지 않다면”, 그것은 불필요한 코드이자 우발적 복잡성(accidental complexity)이다.

11.5 코드를 이해하지 않고도 안전성을 확보하라

리팩터링은 기저 도메인이나 구조를 이해하지 않고도 수행할 수 있다. 구조는 코드 안에 이미 있으므로, 코드 안의 구조를 따르고 올바른 리팩터링 패턴을 실수 없이 적용하면 충분하다. 안전성을 확보하는 방법은 다섯 가지다.


11.5.1 테스트를 통한 안전성 확보

  • 가장 일반적인 방법이다. 정확성 검증뿐만 아니라 사용자의 관점을 직접 경험하기 위해서도 테스트를 수행해야 한다.
  • **자동화된 기능 테스트(functional test)**가 핵심이다. 수동 테스트는 빠르게 관리 불가능해진다.
  • 위험: 테스트가 실수가 발생하는 위치를 커버하지 못하거나, 기대한 것을 테스트하지 않을 수 있다.

11.5.2 숙련도를 통한 안전성 확보

  • 리팩터링을 실수 위험이 무시할 수준이 될 때까지 작은 단계로 분해한다.
  • 안전한 환경에서 충분히 연습하여 리팩터링이 기계적(mechanical)으로 이루어지도록 한다.
  • 위험: 단계를 빠뜨리는 인간 실수의 위험은 여전히 남는다.

11.5.3 도구 지원을 통한 안전성 확보

  • 현대 IDE의 도구 지원 리팩터링(예: 메서드 추출 자동화)을 활용하여 인간적 실수를 줄인다.
  • 위험: 도구 자체의 버그가 존재할 수 있다. 단, 널리 사용되는 도구일수록 버그가 빠르게 수정되어 위험이 감소한다.

11.5.4 공식 검증을 통한 안전성 확보

  • 항공기, 화성 탐사선 등 실패 비용이 극도로 높은 소프트웨어에 적용한다.
  • 증명 보조 도구(proof assistant)를 사용하여 코드가 버그 없음을 기계적으로 검증한다.
  • 위험: 증명 보조 도구 자체에 버그가 있을 수 있다.

11.5.5 결함 허용(Fault Tolerance)을 통한 안전성 확보

  • 오류가 발생해도 자동으로 자기 수정하도록 코드를 설계한다.
  • 예시: 피처 토글(feature toggling) + 실패 시 자동 롤백(automatic rollback). 리팩터링 중 실수가 있어도 시스템이 자동으로 구 코드로 되돌아간다.
  • 위험: 피처 토글 시스템이 올바른 응답과 오류를 구분하지 못하는 경우. 예를 들어 예외 대신 -1을 반환하면 시스템은 이를 유효한 정수로 인식할 수 있다.

11.6 미활용 구조 발굴하기

코드베이스 안에는 아직 활용되지 않은(unexploited) 구조가 숨어 있다. 이 구조들은 대부분 위험 회피(risk aversion)의 결과물이다. 가장 일반적으로 다음 네 곳에서 발견된다.


11.6.1 공백(Whitespace)을 통한 추출과 캡슐화 활용

개발자들은 머릿속 그룹핑을 빈 줄(blank line)로 표현한다. 빈 줄과 주석은 그룹 이름의 초안이다.

패턴 1: 함수 내 공백 - 메서드 추출(Extract Method)

// Before: 공백으로 두 구역이 분리된 함수
function subMin(arr: number[]) {
  let min = Number.POSITIVE_INFINITY;
  for (let x = 0; x < arr.length; x++) {
    min = Math.min(min, arr[x]);
  }

  for (let x = 0; x < arr.length; x++) {
    arr[x] -= min;
  }
}
// After: 공백이 드러낸 구조를 메서드 추출로 고착화
function subMin(arr: number[]) {
  const min = findMin(arr);
  subtractFromEach(min, arr);
}

function findMin(arr: number[]): number {
  let min = Number.POSITIVE_INFINITY;
  for (let x = 0; x < arr.length; x++) {
    min = Math.min(min, arr[x]);
  }
  return min;
}

function subtractFromEach(min: number, arr: number[]): void {
  for (let x = 0; x < arr.length; x++) {
    arr[x] -= min;
  }
}

패턴 2: 필드 간 공백 - 데이터 캡슐화(Encapsulate Data)

// Before: 공백이 x, y가 color보다 더 밀접함을 암시
class Particle {
  private x: number;
  private y: number;

  private color: number;
}
// After: 공백이 드러낸 관계를 캡슐화로 고착화
class Vector2D {
  constructor(private x: number, private y: number) {}
}

class Particle {
  private position: Vector2D;
  private color: number;
}

11.6.2 중복(Duplication)을 통한 통합 활용

중복은 구조의 신뢰할 수 있는 신호다. 구문 중복 → 메서드로, 메서드 중복 → 클래스로 통합한다.

단계 1: 중복 구문을 메서드 추출(Extract Method)로 통합

// Before: result += 구문이 두 클래스에 중복
class XMLFormatter {
  format(vals: string[]): string {
    let result = "";
    for (let i = 0; i < vals.length; i++) {
      result += `<Value>${vals[i]}</Value>`;
    }
    return result;
  }
}

class JSONFormatter {
  format(vals: string[]): string {
    let result = "";
    for (let i = 0; i < vals.length; i++) {
      if (i > 0) result += ",";
      result += `{ value: "${vals[i]}" }`;
    }
    return result;
  }
}
// After: 중복 구문을 formatSingle 메서드로 추출
class XMLFormatter {
  format(vals: string[]): string {
    let result = "";
    for (let i = 0; i < vals.length; i++) {
      result += this.formatSingle(vals[i]);
    }
    return result;
  }
  formatSingle(val: string): string {
    return `<Value>${val}</Value>`;
  }
}

class JSONFormatter {
  format(vals: string[]): string {
    let result = "";
    for (let i = 0; i < vals.length; i++) {
      if (i > 0) result += ",";
      result += this.formatSingle(vals[i]);
    }
    return result;
  }
  formatSingle(val: string): string {
    return `{ value: "${val}" }`;
  }
}

단계 2: 메서드를 데이터 캡슐화(Encapsulate Data)로 클래스에 집중

// After: 추출된 메서드를 별도 클래스로 캡슐화
class XMLFormatSingle {
  format(val: string): string {
    return `<Value>${val}</Value>`;
  }
}

class JSONFormatSingle {
  format(val: string): string {
    return `{ value: "${val}" }`;
  }
}

단계 3: 유사 클래스를 Unify similar classes (P5.1.1)로 통합

// After: 유사한 두 클래스를 파라미터화하여 하나로 통합
class FormatSingle {
  constructor(private before: string, private after: string) {}
  format(val: string): string {
    return `${this.before}${val}${this.after}`;
  }
}

class XMLFormatter {
  formatSingle(val: string): string {
    return new FormatSingle("<Value>", "</Value>").format(val);
  }
}

class JSONFormatter {
  formatSingle(val: string): string {
    return new FormatSingle("{ value: '", "' }").format(val);
  }
}

단계 4: 흐름이 같고 구문만 다른 경우 - 전략 패턴 도입(Introduce Strategy Pattern)

// After: 전략 패턴으로 두 포매터의 흐름을 완전히 통합
interface Separator {
  put(i: number, str: string): string;
}

class Comma implements Separator {
  put(i: number, result: string): string {
    if (i > 0) result += ",";
    return result;
  }
}

class None implements Separator {
  put(i: number, result: string): string {
    return result;
  }
}

class Formatter {
  constructor(private single: FormatSingle, private sep: Separator) {}
  format(vals: string[]): string {
    let result = "";
    for (let i = 0; i < vals.length; i++) {
      result = this.sep.put(i, result);
      result += this.single.format(vals[i]);
    }
    return result;
  }
}

class XMLFormatter {
  format(vals: string[]): string {
    return new Formatter(new FormatSingle("<Value>", "</Value>"), new None()).format(vals);
  }
}

class JSONFormatter {
  format(vals: string[]): string {
    return new Formatter(new FormatSingle("{ value: '", "' }"), new Comma()).format(vals);
  }
}

전략 패턴(Strategy Pattern)이 강력한 이유: 구문은 달라도 흐름이 동일한 코드들을 동일하게 만들어, 숨겨진 구조를 드러낸다. 이후 두 포매터는 상수 값만 다르므로 완전히 통합 가능하다.


11.6.3 공통 접두/접미사(Common Affixes)를 통한 캡슐화 활용

규칙 (Never Have Common Affixes, R6.2.1): 필드, 메서드, 클래스에 공통된 접두사나 접미사가 있으면 이는 그룹핑과 이름 제안을 동시에 나타낸다. 해결책은 항상 데이터 캡슐화(Encapsulate Data)다.

  • 공백, 중복, 이름 공통 접사를 통해 발견한 그룹핑 신호의 해결책은 동일하다: 캡슐화.
  • 언어별 캡슐화 메커니즘이 다르다: Java는 내부 클래스/패키지, C#은 네임스페이스, TypeScript는 네임스페이스 또는 모듈.
// Before: 공통 접미사 Protocol이 규칙을 위반
interface Protocol { /* ... */ }
class StringProtocol implements Protocol { /* ... */ }
class JSONProtocol implements Protocol { /* ... */ }
class ProtobufProtocol implements Protocol { /* ... */ }

let p = new StringProtocol();
// After: 네임스페이스로 캡슐화하여 공통 접미사 제거 (String이 내장 타입과 충돌하지 않음)
namespace protocol {
  export interface Protocol { /* ... */ }
  export class String implements Protocol { /* ... */ }
  export class JSON implements Protocol { /* ... */ }
  export class Protobuf implements Protocol { /* ... */ }
}

let p = new protocol.String();
  • TypeScript에서 클래스/함수 등은 기본적으로 외부 비공개이므로 export로 접근을 확장한다.

11.6.4 런타임 타입 검사를 동적 디스패치로 전환

typeof, instanceof, 리플렉션, 타입 캐스팅으로 런타임 타입을 직접 검사하는 것은 미활용 구조의 가장 흔한 신호다.

  • 객체지향 프로그래밍은 동적 디스패치(dynamic dispatch) 를 통해 런타임 타입 검사보다 더 강력한 메커니즘을 내장하고 있다.
  • 이는 Never use if with else (R4.1.1) 규칙의 특수 케이스다.

A, B의 소스 코드를 제어할 수 있는 경우:

// Before: instanceof로 런타임 타입 직접 검사
function foo(obj: any) {
  if (obj instanceof A) {
    obj.methodA();
  } else if (obj instanceof B) {
    obj.methodB();
  }
}

class A { methodA() { /* ... */ } }
class B { methodB() { /* ... */ } }
// After: 인터페이스 도입 + 코드 클래스로 밀어 넣기(Push code into classes, P4.1.5)로 if 제거
interface Foo {
  foo(): void;
}

function foo(obj: Foo) {
  obj.foo(); // if가 사라짐
}

class A implements Foo {
  foo() { this.methodA(); }
  methodA() { /* ... */ }
}

class B implements Foo {
  foo() { this.methodB(); }
  methodB() { /* ... */ }
}
  • A, B의 소스를 제어할 수 없는 경우: 타입 검사 코드를 코드베이스의 가장자리(edge)로 밀어내어 핵심 코드베이스를 깨끗하게 유지한다.

flowchart TD
    A[미활용 구조 신호 발견] --> B{신호 유형}
    B --> C[공백 / Whitespace]
    B --> D[중복 / Duplication]
    B --> E[공통 접두·접미사 / Common Affixes]
    B --> F[런타임 타입 검사 / instanceof 등]

    C --> C1[함수 내 공백]
    C --> C2[필드 간 공백]
    C1 --> G[메서드 추출\nExtract Method]
    C2 --> H[데이터 캡슐화\nEncapsulate Data]

    D --> D1[구문 중복]
    D --> D2[메서드 중복]
    D --> D3[흐름만 동일]
    D1 --> G
    D2 --> H
    D3 --> I[전략 패턴 도입\nIntroduce Strategy Pattern]
    I --> J[유사 클래스 통합\nUnify Similar Classes]

    E --> H

    F --> K{소스 제어 가능?}
    K -->|Yes| L[인터페이스 도입 +\n코드 클래스로 밀어 넣기\nPush Code into Classes]
    K -->|No| M[타입 검사를\n코드베이스 가장자리로 격리]

챕터 11 요약

핵심 개념 (규칙/패턴) 목적 및 내용
구조 공간 4분면 코드/사람 × 팀 내/팀 간으로 구조를 분류한다. 리팩터링은 팀 내 코드 영역(마이크로 아키텍처)에 해당한다.
콘웨이의 법칙 (Conway’s Law) 조직 구조는 외부 API 형태를 제약한다. 도메인 전문가의 행동 방식은 코드 구조 안으로 스며든다.
행동 표현 방식 1: 제어 흐름 큰 변경을 쉽게 만든다. 불확실한 초기 구현에 적합하다.
행동 표현 방식 2: 데이터 구조 타입 안전성, 지역성, 성능을 확보한다. 구조가 안정화된 성숙한 코드에 적합하다.
행동 표현 방식 3: 데이터 컴파일러 지원이 없어 위험하다. 다른 방식으로의 전환을 적극 권장한다.
구조 고착화 원칙 코드의 견고함은 해당 방향에 대한 확신의 정도를 반영해야 한다. 불확실한 코드는 캡슐화하고 변동 지점을 무분별하게 추가하지 않는다.
경험적 리팩터링 원칙 예측하지 말고 관찰한다. 변하지 않으면 방치하고, 변화 패턴이 있으면 그 방향에 맞게 리팩터링한다.
안전성 확보 5가지 방법 테스트 자동화, 숙련도 향상, 도구 지원, 공식 검증, 결함 허용(피처 토글) 중 여러 방법을 병용한다.
공백 활용 (Whitespace) 함수 내 공백 → 메서드 추출(Extract Method). 필드 간 공백 → 데이터 캡슐화(Encapsulate Data).
중복 활용 (Duplication) 구문 중복 → 메서드 추출. 메서드 중복 → 캡슐화. 흐름만 동일 → 전략 패턴 도입 → 유사 클래스 통합.
공통 접사 활용 (Common Affixes) Never have common affixes (R6.2.1) 위반 시 네임스페이스/모듈로 캡슐화한다.
런타임 타입 검사 제거 instanceof 검사를 발견하면 인터페이스 + Push code into classes (P4.1.5)로 동적 디스패치로 대체한다. 소스 미제어 시 코드베이스 가장자리로 격리한다.

Chapter 12. Avoid Optimizations and Generality: 최적화와 범용화 피하기


12.1 단순함을 향한 노력 (Striving for Simplicity)

단순함은 이 챕터, 나아가 책 전체를 관통하는 핵심 주제이다. 인간의 인지 용량(cognitive capacity)은 제한적이며, 코드를 이해할 때 두 가지 요인이 그 용량을 빠르게 잠식한다.

  • 결합된 컴포넌트 (Coupled components): 두 컴포넌트를 동시에 머릿속에 담아야 하므로 인지 비용 증가
  • 불변 조건 (Invariants): 코드 동작을 추론하기 위해 항상 추적해야 하는 조건들

12.1.1 범용화(Generality)와 불변 조건의 연결 고리

행위 인지 비용 유발 원인
범용화 (Generality 추가) 더 많은 것과 결합될 수 있어 결합도 증가
최적화 (Optimization) 불변 조건을 도입하여 항상 추적 필요

범용화의 문제 예시: 아래 함수는 Tile의 모든 가능한 값으로 호출되는지, 일부 값만으로 호출되는지 알 수 없다. 이를 모르면 함수 단순화가 불가능하다.

// Before: 불필요하게 범용적인 함수 — 호출 범위를 알 수 없어 단순화 불가
function remove(tile: Tile) {
  for (let y = 0; y < map.length; y++) {
    for (let x = 0; x < map[y].length; x++) {
      if (map[y][x] === tile) {
        map[y][x] = new Air();
      }
    }
  }
}

최적화의 불변 조건 예시: CountingSettotal 필드를 추가해 계산을 캐싱하면, 항상 add/removetotal을 갱신해야 한다는 불변 조건이 생긴다. 미최적화 버전에서는 새 메서드를 추가해도 기존 메서드를 절대 깨뜨릴 수 없다.

// Before: 최적화 없음 — size() 호출 시마다 전체 순회
class CountingSet {
  private data: { [key: string]: number } = {};

  randomElement(): string {
    let index = randomInt(this.size());
    for (let key in this.data) {
      index -= this.data[key];
      if (index <= 0) return key;
    }
    throw new Error("Impossible");
  }

  add(element: string) {
    const c = this.data[element] ?? 0;
    this.data[element] = c + 1;
  }

  size() {
    let total = 0;
    for (let key in this.data) {
      total += this.data[key];
    }
    return total;
  }
}
// After: total 필드로 캐싱 최적화 — 불변 조건 발생 (항상 total 동기화 필요)
class CountingSet {
  private data: { [key: string]: number } = {};
  private total = 0; // 불변 조건: add/remove 시 반드시 갱신

  randomElement(): string {
    let index = randomInt(this.size());
    for (let key in this.data) {
      index -= this.data[key];
      if (index <= 0) return key;
    }
    throw new Error("Impossible");
  }

  add(element: string) {
    const c = this.data[element] ?? 0;
    this.data[element] = c + 1;
    this.total++; // 불변 조건 유지
  }

  size() {
    return this.total;
  }
}

단순함을 추구한다는 것이 결코 최적화나 범용화를 완전히 금지하는 것은 아니다. 다만, 이를 도입할 명확한 증거가 있어야 하며, 부작용을 최소화하는 대비책을 함께 갖춰야 한다.


12.2 범용화의 시기와 방법 (When and How to Generalize)

범용화를 추가하기 전에 반드시 그 동기를 먼저 이해해야 한다. 이 책에서 권장하는 복제(Duplicate) → 변환(Transform) → 통합(Unify) 3단계 절차를 따르면, 통합 단계에서 현재 기능에 딱 필요한 만큼의 범용성이 자동으로 결정된다.

12.2.1 최소 구현으로 범용화 방지

Maximize the amount of work not done. — Kent Beck

  • 요구사항에 없는 기능을 미리 구현하지 않는다.
  • 범용화를 수용하기 위한 설계가 오히려 더 큰 부담이 될 수 있다. (스위스 아미 나이프 vs. 파링 나이프 비유)
  • 요구사항은 소프트웨어가 발전함에 따라 변한다. 불필요한 범용성에 투자한 노력은 쉽게 무용지물이 된다.

결론: 상상 속의 문제가 아닌, 지금 눈앞의 문제만 해결한다.

12.2.2 안정성이 유사한 코드만 통합하라

  • 새로운 코드를 오래된 코드와 즉시 통합하는 것을 피한다.
  • 각 대상이 유사한 수준의 안정성(stability) 에 도달할 때까지 기다린 후 통합한다.
  • 두 번째로 등장하는 코드는 첫 번째보다 훨씬 빠르게 안정화되고, 세 번째는 더 빠르게 안정화된다.
  • 불안정한 코드와 일찍 통합하면, 나중에 제거하기 어려운 범용성이 지원 코드에 녹아들어 인지 비용을 증가시킨다.

12.2.3 불필요한 범용성 제거

불필요한 범용성을 발견하고 제거하는 방법:

  1. 메서드 특수화 (Specialize Method): 메서드 파라미터가 항상 동일한 값으로 호출되는지 확인 후 특수화
  2. 삭제 후 컴파일 시도 (Try Delete Then Compile): 코드 삭제 후 컴파일 에러가 없으면 불필요한 코드
  3. 런타임 인수 모니터링: 함수에 전달되는 인수를 로그로 기록하고, 최근 n번의 호출을 검사하여 항상 같은 값으로 호출된다면 메서드 특수화 적용

Try Delete Then Compile만으로는 제거 가능한 모든 범용성을 찾기 어렵다. 런타임 인수 모니터링이 더 효과적인 방법이다.


12.3 최적화의 시기와 방법 (When and How to Optimize)

핵심 원칙: 코드는 증명되기 전까지는 효율적이다. (법률의 무죄 추정 원칙과 유사)

최적화 필요성은 자동화된 성능 테스트의 실패 시에만 동기 부여가 된다.

12.3.1 성능 테스트 유형

테스트 유형 측정 목표 특징
벤치마크 테스트 (Benchmark Test) “이 메서드는 14ms 내에 종료되어야 한다” 환경에 강하게 결합됨. 프로덕션 환경에서만 신뢰 가능
부하 테스트 (Load Test) “이 서비스는 초당 1000 요청을 처리해야 한다” 외부 요인에 비교적 강건. 프로덕션 수준 하드웨어 필요
성능 승인 테스트 (Performance Approval Test) “마지막 실행 대비 10% 이상 느려지면 안 된다” 외부 요인에 완전 독립적. 메인 루프 성능 저하나 자료구조 교체 탐지 가능

12.3.2 최적화 전 리팩터링 선행

최적화 전 코드를 충분히 리팩터링해야 하는 이유: 리팩터링은 불변 조건을 국소화(localize)하여 명확하게 만들고, 최적화는 불변 조건에 의존하므로 잘 정리된 코드를 최적화하기가 훨씬 용이하다.

// Before: arr.length에 직접 접근 — 나중에 클래스 캡슐화 시 인터페이스 노출
function average(arr: number[]) {
  return sum(arr) / arr.length;
}
// After: size()로 추출 — 최소한의 공개 인터페이스로 캡슐화 및 최적화 준비 완료
function average(arr: number[]) {
  return sum(arr) / size(arr);
}

클래스로 캡슐화하면 최적화(내부 캐싱 추가, 자료구조 교체)가 도메인 코드 변경 없이 가능하다:

// Before: 캡슐화 없음 — sum()이 매번 전체 순회
class NumberSequence {
  constructor(private arr: number[]) {}

  sum() {
    let result = 0;
    for (let i = 0; i < this.arr.length; i++) {
      result += this.arr[i];
    }
    return result;
  }

  size() {
    return this.arr.length;
  }

  average() {
    return this.sum() / this.size();
  }
}
// After: total 필드로 sum 캐싱 — O(n) → O(1) 최적화, 외부 인터페이스 불변
class NumberSequence {
  private total = 0;

  constructor(private arr: number[]) {
    for (let i = 0; i < this.arr.length; i++) {
      this.total += this.arr[i];
    }
  }

  sum() {
    return this.total;
  }

  size() {
    return this.arr.length;
  }

  average() {
    return this.sum() / this.size();
  }
}

컴파일러에게 맡겨라: 관용적(idiomatic) 코드는 컴파일러가 자동으로 최적화한다. 저수준 트릭을 사용하면 컴파일러가 의도를 인식하지 못해 오히려 느려질 수 있다.

// Before: "실력 과시" 코드 — 컴파일러 최적화 방해, 가독성 저하
function isEven(n: number) { return (n & 1) === 0; }
function half(n: number) { return n >> 1; }
// After: 관용적 코드 — 컴파일러가 자동 최적화, 가독성 우수
function isEven(n: number) { return n % 2 === 0; }
function half(n: number) { return n / 2; }

12.3.3 제약 이론으로 최적화 방향 결정

지역적 비효율성 개선에 집착하는 것은 글로벌 효율성에 거의 영향을 미치지 않는다. — Eliyahu Goldratt, The Goal

제약 이론 (Theory of Constraints)의 핵심 개념:

  • 병목 (Bottleneck): 입력 버퍼는 가득 차고 출력 버퍼는 비어있는 워크스테이션
  • 업스트림 최적화: 병목 입구에 대기열만 쌓일 뿐, 전체 처리량 향상 없음
  • 다운스트림 최적화: 병목으로부터 충분한 입력을 받지 못하므로 전체 성능 향상 없음
  • 결론: 오직 병목 워크스테이션을 최적화해야만 전체 시스템 성능이 향상된다
flowchart LR
  Input([Input]) --> B1[Worker A\n1초/task]
  B1 --> Queue1{Buffer}
  Queue1 --> B2["Worker B\n2초/task\n(병목)"]
  B2 --> Queue2{Buffer}
  Queue2 --> Output([Output])

  style B2 fill:#ff6b6b,color:#fff

리소스 풀링 (Resource Pooling): 처리 자원을 공용 풀에 두어 병목에 동적으로 자원 배분

  • 외부 수준: 로드 밸런서
  • 내부 수준: 스레드 풀 (Thread Pool)
// Before: 단순 스레딩 — Worker A 1개, Worker B 1개 고정 (100건 처리 약 201초)
interface Runnable { run(): void; }

class StageA implements Runnable {
  constructor(private input: Queue<string>, private output: Queue<string>) {}
  run() {
    while (true) {
      const result = this.input.dequeue();
      sleep(1000); // 1초 소요
      this.output.enqueue(result);
    }
  }
}

class StageB implements Runnable {
  constructor(private input: Queue<string>, private output: Queue<string>) {}
  run() {
    while (true) {
      const result = this.input.dequeue();
      sleep(2000); // 2초 소요 (병목)
      this.output.enqueue(result);
    }
  }
}
// After: 리소스 풀링 — Task 추상화로 스레드 수 자유롭게 조정 (100건 처리 약 150초)
interface Task { execute(): void; }

class StageA implements Task {
  constructor(private input: Queue<string>, private output: Queue<string>) {}
  execute() {
    const result = this.input.dequeue();
    sleep(1000);
    this.output.enqueue(result);
  }
}

class StageB implements Task {
  constructor(private input: Queue<string>, private output: Queue<string>) {}
  execute() {
    const result = this.input.dequeue();
    sleep(2000);
    this.output.enqueue(result);
  }
}

class Worker implements Runnable {
  constructor(private tasks: Queue<Task>) {}
  run() {
    while (true) {
      const task = this.tasks.dequeue();
      task.execute();
    }
  }
}
// 스레드 풀: Worker 인스턴스를 원하는 수만큼 생성 가능
// priority queue로 교체만 해도 처리 순서 제어 가능
  • 리소스 풀링의 장점: 스레드 안무(choreography)를 고민할 필요 없이 시스템이 자동 처리
  • 비용: 리소스 풀링 코드 자체의 인지 비용 증가 (단, 도메인 코드의 인지 비용은 증가하지 않음)

12.3.4 메트릭으로 최적화 방향 결정 (Guiding Optimization with Metrics)

핫스팟 (Hot Spot): 스레드가 가장 많은 시간을 소비하는 메서드

핫스팟 발생 2대 요인:

  1. 메서드 자체의 실행 시간이 긴 경우
  2. 루프 안에 위치한 경우

핫스팟 발견 방법:

  • 프로파일링 (Profiling): 각 메서드에 누적 소요 시간을 추적하는 도구 활용
  • 수동 타이밍: 최상위 레벨부터 80:20 법칙을 적용하여 시간의 80%를 차지하는 20%의 코드로 반복적으로 파고듦

최적화는 일상적인 개발 작업의 일부가 되어서는 안 된다. 팀 생산성이라는 더 가치 있는 자원과 교환하는 대가이기 때문이다.

점근적 분석(Big-O)의 함정: Big-O가 좋은 알고리즘이 실제로는 더 느릴 수 있다. 예: 소규모 데이터에서는 O(n²) 삽입 정렬이 O(n log n) 퀵 정렬보다 빠른 경우가 많다 (캐시 미스 등 추상화된 요인 때문). 반드시 측정으로 확인해야 한다.


12.3.5 좋은 알고리즘과 자료구조 선택

  • 가장 안전한 최적화: 동일한 인터페이스를 유지하면서 자료구조만 교체
  • 도메인 코드를 변경하지 않아도 되므로 위험 부담 최소
  • 성능 테스트가 즉시 성능 저하를 감지해주므로 자료구조 교체가 용이

지역적 자료구조 전환 전략: 핫스팟 내부에서 필요한 연산이 다를 경우, 핫스팟 진입 전에 자료구조를 변환하여 사용한다.

상황 최솟값 추출 비용
핫스팟 내 직접 반복 추출 O(n) — 선형
핫스팟 진입 전 최소 힙 변환 O(log n) — 로그
핫스팟 진입 전 정렬 O(1) — 상수

연결 리스트 정렬 예시: 연결 리스트를 직접 조작하는 것보다 배열로 변환 후 정렬하는 것이 캐시 효율성 측면에서 훨씬 효과적이다.

// After: 배열 변환을 통한 연결 리스트 정렬 — 캐시 효율 극대화
interface Node<T> { element: T; next: Node<T> | null; }

class LinkedList<T> {
  private root: Node<T> | null = null;

  sort() {
    const arr = this.toArray();
    arr.sort();
    const sorted = new LinkedList<T>(arr);
    this.root = sorted.root; // class-private이므로 접근 가능
  }

  private toArray(): T[] { /* ... */ return []; }
  constructor(arr?: T[]) { /* ... */ }
}

12.3.6 캐싱 활용 (Using Caching)

캐싱의 핵심 아이디어: 같은 계산을 반복하는 대신, 한 번 계산한 결과를 저장하고 재사용한다.

불변 조건에 따른 캐싱 3단계 안전도:

flowchart TD
  A[캐싱 종류] --> B["완전 멱등(Idempotent) 함수\n같은 인수 → 항상 같은 결과"]
  A --> C["일시적 멱등 함수\n결과가 일정 기간 동안만 동일"]
  A --> D["내부 캐싱\n클래스 내 필드로 캐싱"]

  B --> B1["외부 캐싱 가능\n가장 안전"]
  C --> C1["만료 시간(expiry) 포함 외부 캐싱\n중간 위험도"]
  D --> D1["클래스 전체 수명 동안 유지 필요\n가장 위험"]

  style B1 fill:#51cf66,color:#fff
  style C1 fill:#ffd43b
  style D1 fill:#ff6b6b,color:#fff

완전 멱등 함수 캐싱 (가장 안전):

// After: 멱등 함수용 외부 캐시 — 동일 인수에 항상 동일 결과 보장 시 사용
interface Cacheable { hashCode(): string; }

class Cacher<G extends Cacheable, T> {
  private data: { [key: string]: T } = {};
  constructor(private func: (arg: G) => T) {}

  call(arg: G): T {
    const key = arg.hashCode();
    if (this.data[key] === undefined) {
      this.data[key] = this.func(arg);
    }
    return this.data[key];
  }
}

일시적 멱등 함수 캐싱 (만료 시간 포함):

// After: 일시적 멱등 함수용 캐시 — 가격처럼 자주 변하지 않는 데이터에 적합
interface Cacheable { hashCode(): string; }

class Cacher<G extends Cacheable, T> {
  private data: { [key: string]: { result: T; expiry: number } } = {};
  constructor(private func: (arg: G) => T, private duration: number) {}

  call(arg: G): T {
    const key = arg.hashCode();
    if (this.data[key] === undefined || this.data[key].expiry < Date.now()) {
      this.data[key] = {
        result: this.func(arg),
        expiry: Date.now() + this.duration,
      };
    }
    return this.data[key].result;
  }
}
  • 멱등성 없는 내부 캐싱 (total 필드처럼 클래스 필드로 관리): 클래스 전체 수명에 걸쳐 불변 조건을 유지해야 하므로 가장 위험하다.

12.3.7 최적화 코드 격리 (Isolating Optimized Code)

알고리즘, 동시성, 캐싱으로도 성능 요건을 충족하지 못할 경우 성능 튜닝(Performance Tuning), 즉 마이크로 최적화로 넘어간다.

성능 튜닝의 특성:

  • 런타임과 동작 간의 미묘한 불변 조건을 활용
  • 대표적 예: 매직 비트 패턴 (Magic Bit Patterns) — 16진수 매직 넘버로 작성되어 이해하는 데 높은 인지 비용 요구
  • 튜닝된 코드는 사실상 잠긴 코드(Locked Code): 이해 없이는 수정 불가

격리 전략 2단계:

  1. 메서드/클래스로 추출하여 잠긴 영역 최소화
    • 데이터가 관련된 경우: 클래스로 격리
    • 순수 로직인 경우: 별도 메서드로 추출
    • 네이밍, 문서화, 품질 검증에 평소보다 더 많은 노력 투자 (이해도가 가장 높을 때가 추출 직후이기 때문)
  2. 전용 패키지/네임스페이스로 그룹화하여 미래 개발자에게 경고
    • 권장 패키지명: magic (충분히 발전된 기술은 마법과 구별되지 않는다는 Arthur C. Clarke의 격언에서 차용)
    • 패키지명이 import 구문에 드러나므로, 필요할 때만 자연스럽게 경고 역할
    • magic 패키지는 쓰레기 더미가 아닌, 소수의 개발자가 탁월하게 이해하는 코드의 성역으로 유지

챕터 12 요약

핵심 개념 (규칙/패턴) 목적 및 내용
단순함 우선 인지 부하를 줄이는 것이 코드 품질의 핵심 목표이다
범용화 = 결합도 위험 범용적일수록 결합 가능성이 높아져 인지 비용이 증가한다
최소 구현 원칙 상상 속 문제가 아닌 현재 존재하는 문제만 해결한다
유사 안정성 코드만 통합 성숙도가 다른 코드를 조기에 통합하면 제거하기 어려운 범용성이 생긴다
성능 테스트로 최적화 동기 부여 벤치마크/부하/승인 테스트 실패 시에만 최적화를 시작한다
리팩터링 후 최적화 리팩터링이 불변 조건을 국소화하여 최적화를 용이하게 만든다
컴파일러 협력 관용적 코드를 작성하여 컴파일러 자동 최적화 혜택을 누린다
제약 이론 적용 병목을 찾아 그 지점만 최적화해야 전체 처리량이 향상된다
리소스 풀링 자원 공용화로 도메인 코드 변경 없이 시스템 성능을 향상시킨다
프로파일링 우선 Big-O 분석보다 실제 측정(프로파일링)을 통해 핫스팟을 식별한다
자료구조 교체 최적화 동일 인터페이스 유지 하에 자료구조만 교체하는 것이 가장 안전한 최적화이다
캐싱의 3단계 안전도 멱등 > 일시적 멱등 > 내부 캐싱 순으로 불변 조건 위험도가 증가한다
최적화 코드 격리 성능 튜닝 코드는 메서드/클래스로 추출하고 magic 패키지로 격리하여 경고 신호를 제공한다

Chapter 13. Make Bad Code Look Bad: 나쁜 코드는 나쁘게 보이게 하라

“좋게 만들 수 없다면, 눈에 띄게 만들어라.” — 저자


13.1 나쁜 코드로 프로세스 문제를 신호하기

코드 품질이 낮은 것을 인지하고도 “그나마 나쁘지 않게” 수습하는 행위는 실수다. 이는 실제 문제를 숨기는 것과 같다. 나쁜 코드를 그대로 두는 것이 더 나은 이유는 두 가지다.

  • 발견 용이성: 나쁜 코드는 나중에 찾기 쉽다.
  • 지속 불가능 신호: 현재의 제약 조건(시간, 복잡도)이 지속 불가능하다는 것을 팀에 알린다.

나쁜 코드를 신호로 전달하려면 심리적 안전성(Psychological Safety)이 전제되어야 한다. Google의 Project Aristotle은 심리적 안전성이 팀 생산성에 가장 중요한 요소임을 입증했다. 팀 내에서 “메신저를 쏘지 않는” 문화가 있을 때, 나쁜 코드는 유효한 커뮤니케이션 도구가 된다.

다음 두 코드는 동일한 기능을 수행하지만, 어느 쪽이 리팩터링이 더 시급한지 한눈에 드러난다.

// Before: "그나마 나쁘지 않게" 수습된 코드 — 문제가 숨겨져 있다
function animate() {
  handleChosen();
  handleDisplaying();
  handleCompleted();
  handleMoving();
}
function handleChosen() {
  if (value >= threshold && banner.state === "chosen") { /* ... */ }
}
function handleDisplaying() {
  if (value >= target && banner.state === "displaying") { /* ... */ }
}
function handleCompleted() {
  if (banner.state === "completed") { /* ... */ }
}
function handleMoving() {
  if (banner.state === "moving" && banner.target === banner.current) { /* ... */ }
}
// After: 의도적으로 나쁘게 만든 코드 — banner.state 반복이 즉시 눈에 띈다
enum State { Chosen, Displaying, Completed, Moving }

function animate() {
  // FIXME: All concern banner.state
  if (value >= threshold && banner.state === State.Chosen) {

    // ...

  }
  if (value >= target && banner.state === State.Displaying) {

    // ...

  }
  if (banner.state === State.Completed) {

    // ...

  }
  if (banner.state === State.Moving && banner.target === banner.current) {

    // ...

  }
}

handleChosen 등으로 쪼갠 코드는 메서드가 작아 보이지만, banner.state가 반복된다는 사실을 숨긴다. 이는 해당 로직이 State 클래스 내부로 이동해야 한다는 신호를 감춘다.


13.2 순수 코드(Pristine Code)와 레거시 코드 분리하기

코드를 세 등급으로 구분한다면 — quite good, good enough, badgood enough 보다는 bad가 낫다. “꽤 좋은” 수준까지 올릴 시간이 없다면, 차라리 나쁘게 만들어 눈에 띄게 한다. 이 활동은 코드베이스를 두 가지로 분리한다.

등급 설명
순수 코드 (Pristine Code) 충분히 좋은, 팀 기준을 통과한 코드
레거시 코드 (Legacy Code) 나쁘고 눈에 띄게 되어 있는 코드

파일별 순수/레거시 비율을 한눈에 파악할 수 있으면, 리팩터링 순서를 정하기 쉽다. 순수 코드에 가장 가까운 파일부터 시작하는 것이 효과적이다. 이유는 다음과 같다.

  1. 연쇄 효과 방지: 리팩터링은 주변 코드까지 영향을 미치는 연쇄 활동이다. 주변이 이미 좋으면 리팩터링 토끼굴(rabbit hole)에 빠질 위험이 줄어든다.
  2. 깨진 유리창 이론: 이미 순수한 파일은 더 오래 순수하게 유지된다.

13.2.1 깨진 유리창 이론

깨진 유리창 이론(Broken Window Theory): 창문 하나가 깨진 채 방치되면 곧 더 많은 창문이 깨진다.

코드에서도 동일하다. 나쁜 코드가 하나 있으면, 그 옆에 더 나쁜 코드를 추가하기가 훨씬 쉬워진다. 반대로, 파일 전체가 순수하면 그 상태가 오래 유지된다. 따라서 good enough 코드가 순수 파일에 섞이는 것이 가장 위험하다.


13.3 나쁜 코드 정의 방법

코드의 품질을 측정하는 완벽한 방법은 없다. 가독성은 주관적이기 때문이다. 그러나 나쁜 코드를 추정하는 몇 가지 방법이 있다.

13.3.1 이 책의 규칙: 단순하고 구체적

이 책의 규칙들은 집중력이 분산된 상태에서도 눈에 바로 들어오도록 설계되었다. 그러나 이 규칙들은 팀이 공유했을 때 비로소 보편적인 신호가 된다.

다음 코드는 두 가지 규칙을 위반한다.

// 규칙 위반 예시: 5줄 규칙(R3.1.1) + if는 첫 줄에만(R3.5.1) 위반
function minimum(arr: number[][]): number {
  let result = 99999;
  for (let x = 0; x < arr.length; x++) {
    for (let y = 0; y < arr[x].length; y++) {
      if (arr[x][y] < result)
        result = arr[x][y];
    }
  }
  return result;
}

13.3.2 코드 스멜(Code Smell): 완전하고 추상적

Martin Fowler의 Refactoring, Robert C. Martin의 Clean Code 등에서 수집된 코드 스멜들이 이 책의 규칙의 원천이다. 일반적으로 눈에 잘 띄려면 많은 연습이 필요하지만, Magic Constants(매직 상수)Duplicated Code(중복 코드) 같은 일부는 누구나 쉽게 알아본다.

// 코드 스멜 예시: 매직 넘버(99999)
function minimum(arr: number[][]): number {
  let result = 99999; // ← Magic number (코드 스멜)
  for (let x = 0; x < arr.length; x++) {
    for (let y = 0; y < arr[x].length; y++) {
      if (arr[x][y] < result)
        result = arr[x][y];
    }
  }
  return result;
}

13.3.3 순환 복잡도(Cyclomatic Complexity): 알고리즘적(객관)

순환 복잡도(Cyclomatic Complexity): 코드를 통과하는 경로의 수를 센다.

  • if, for, while 각각 +1
  • ||, && 각각 +1
  • 테스트 케이스 최소 개수의 하한선을 제공한다.
  • 인간이 한눈에 파악할 때는 주로 들여쓰기 깊이에 의존한다.
// 순환 복잡도: 4 (함수 시작 1 + for×2 + if×1)
function minimum(arr: number[][]): number {  // +1
  let result = 99999;
  for (let x = 0; x < arr.length; x++) {    // +1
    for (let y = 0; y < arr[x].length; y++) { // +1
      if (arr[x][y] < result)                  // +1
        result = arr[x][y];
    }
  }
  return result;
}                                             // = 4

13.3.4 인지 복잡도(Cognitive Complexity): 알고리즘적(주관)

인지 복잡도(Cognitive Complexity): 코드를 읽으며 인간이 머릿속에 유지해야 하는 정보의 양을 추정한다.

  • 순환 복잡도보다 중첩(nesting)을 더 가중 처벌한다.
  • 중첩이 깊을수록 더 많은 조건을 기억해야 하기 때문이다.
  • 인간에게는 결국 들여쓰기가 핵심 지표다.
// 인지 복잡도: 6 (중첩 깊이 1+2+3)
function minimum(arr: number[][]): number {
  let result = 99999;
  for (let x = 0; x < arr.length; x++) {        // +1 (깊이 1)
    for (let y = 0; y < arr[x].length; y++) {   // +2 (깊이 2)
      if (arr[x][y] < result)                    // +3 (깊이 3)
        result = arr[x][y];
    }
  }
  return result;
}                                                // = 6

13.4 코드를 안전하게 파괴하는 3가지 규칙

나쁜 코드를 더 나쁘게(anti-refactoring) 만들 때는 반드시 다음 세 가지 규칙을 따라야 한다. 이 규칙들을 지키면 어떤 변경도 최악의 경우 쉽게 되돌릴 수 있다.

규칙 1. 정확한 정보는 절대 파괴하지 않는다: 이미 존재하는 올바른 정보(좋은 이름, 정확한 주석 등)는 보존해야 한다. 부정확하거나 불필요한 정보(오래된 주석, 자명한 주석)는 제거할 수 있다.

규칙 2. 미래의 리팩터링을 더 어렵게 만들지 않는다: 다음 담당자(자기 자신 포함)를 위해 메서드 추출(Extract Method) 위치 제안, 리팩터링 방향 힌트 등 보유한 정보를 표시해야 한다. 가능하면 미래 리팩터링을 더 쉽게 만들어야 한다.

규칙 3. 결과는 눈에 띄어야 한다: 변경 결과가 코드가 눈에 확실히 보여야 한다. 순수 코드와 레거시 코드 사이에 명확한 간극이 생겨야 신호로서 기능한다.

flowchart TD
    A[코드 파괴 시도] --> B{규칙 1\n정보 보존?}
    B -- 위반 --> Z[중단]
    B -- 통과 --> C{규칙 2\n리팩터링 방해?}
    C -- 위반 --> Z
    C -- 통과 --> D{규칙 3\n눈에 띄는가?}
    D -- 아니오 --> Z
    D -- 예 --> E[안전한 안티-리팩터링 적용]

13.5 코드를 안전하게 파괴하는 10가지 기법

13.5.1 열거형(Enum) 사용하기

  • 설명: boolean 또는 타입 코드(type code) 대신 열거형(enum)을 사용한다.
  • 적용 절차: boolean 필드의 의미를 파악하여 각 값에 이름을 부여하고 enum으로 교체한다.
  • 효과: 타입 시그니처에 의미가 추가되고, 이후 Replace type code with classesPush code into classesTry delete then compile 패턴으로 이어지는 표준 리팩터링 경로가 생긴다.
// Before: boolean 타입 코드 — 의미가 불분명하다
class Package {
  private priority: boolean;
  scheduleDispatch() {
    if (this.priority) dispatchImmediately(this);
    else queue.push(this);
  }
}
// After: enum으로 교체 — 의미가 명확하고 리팩터링 경로가 보인다
enum Importance { Priority, Regular }

class Package {
  private priority: Importance;
  scheduleDispatch() {
    if (this.priority === Importance.Priority) dispatchImmediately(this);
    else queue.push(this);
  }
}

13.5.2 int 및 string을 타입 코드로 사용하기

  • 설명: 열거형을 추가할 여유가 없을 때 int 상수나 string 리터럴을 타입 코드로 사용한다.
  • 적용 절차: 분기 조건에 string 리터럴 또는 명명된 const 정수를 사용한다.
  • 효과: else if 체인 또는 switch가 생겨 타입 코드 냄새가 즉시 드러난다. 이후 enum으로 쉽게 전환할 수 있다.
// string 타입 코드 방식
function area(width: number, shape: string): number {
  if (shape === "circle") return (width / 2) * (width / 2) * Math.PI;
  else if (shape === "square") return width * width;
}
// int 상수 타입 코드 방식
const CIRCLE = 0;
const SQUARE = 1;
function area(width: number, shape: number): number {
  if (shape === CIRCLE) return (width / 2) * (width / 2) * Math.PI;
  else if (shape === SQUARE) return width * width;
}

13.5.3 매직 넘버를 코드에 삽입하기

  • 설명: 이름이 없거나 부정확한 상수를 인라인 숫자로 직접 표기한다.
  • 적용 절차: 상수가 잘못 명명되어 있거나 이름에 정보가 없으면 인라인으로 교체한다. 의미가 불분명하면 주석을 함께 남긴다.
  • 효과: 매직 넘버는 대부분의 개발자에게 즉각적인 경고 신호다. 나중에 의미 있는 상수로 다시 추출하기도 쉽다.
// Before: 상수명이 명확해 보이지만 실제 값의 의미가 코드에서 보이지 않는다
const FOUR_THIRDS = 4 / 3;
class Sphere {
  volume(): number {
    let result = FOUR_THIRDS;
    for (let i = 0; i < 3; i++) result = result * this.radius;
    return result * Math.PI;
  }
}
// After: 매직 넘버 삽입 — 즉각적인 리팩터링 신호를 보낸다
class Sphere {
  volume(): number {
    let result = 4 / 3;
    for (let i = 0; i < 3; i++) result = result * this.radius;
    return result * 3.141592653589793;
  }
}

13.5.4 코드에 주석 추가하기

  • 설명: 메서드 이름이 되어야 할 내용을 주석으로 단다.
  • 적용 절차: 긴 함수 내에서 논리적으로 구분되는 블록마다 // [동작 설명] 주석을 추가한다.
  • 효과: 주석이 드물어질수록 주석은 더 눈에 띈다. 주석은 동시에 메서드 추출(Extract Method) 위치와 메서드명 후보를 제안한다.
// Before: 구조가 보이지 않는 긴 함수
function subMin(arr: number[][]): number {
  let min = Number.POSITIVE_INFINITY;
  for (let x = 0; x < arr.length; x++)
    for (let y = 0; y < arr[x].length; y++)
      min = Math.min(min, arr[x][y]);
  for (let x = 0; x < arr.length; x++)
    for (let y = 0; y < arr[x].length; y++)
      arr[x][y] -= min;
  return min;
}
// After: 주석으로 구조를 드러내고 메서드 추출 위치를 제안한다
function subMin(arr: number[][]): number {
  // Find min
  let min = Number.POSITIVE_INFINITY;
  for (let x = 0; x < arr.length; x++)
    for (let y = 0; y < arr[x].length; y++)
      min = Math.min(min, arr[x][y]);
  // Sub from each element
  for (let x = 0; x < arr.length; x++)
    for (let y = 0; y < arr[x].length; y++)
      arr[x][y] -= min;
  return min;
}

13.5.5 코드에 공백 삽입하기

  • 설명: 논리 블록 사이에 빈 줄을 삽입하거나, 표현식에 명시적 괄호를 추가한다.
  • 적용 절차: 관련 있는 구문들 사이에 빈 줄을 넣어 그룹화한다. 구문 그룹핑뿐 아니라 필드 그룹핑에도 활용한다.
  • 효과: 빈 줄은 메서드 추출(Extract Method) 및 데이터 캡슐화(Encapsulate Data) 위치를 시각적으로 제안한다. 개발자는 패턴을 잘 인식하고 빈 줄은 책의 단락처럼 쉽게 눈에 띈다.
// Before: 연산자 우선순위가 오해를 유발한다
let cursor = cursor + 1 % arr.length;
// After: 명시적 괄호로 의도를 드러낸다 (modulo는 곱셈과 같은 우선순위)
let cursor = (cursor + 1) % arr.length;

13.5.6 공통 접두사/접미사 기반 그룹핑

  • 설명: 공통 접두사(affix)를 가진 필드나 메서드를 코드에서 나란히 배치한다.
  • 적용 절차: 필드 선언 순서를 재배열하여 공통 접두사를 가진 것들끼리 모은다.
  • 효과: 공통 접두사는 데이터 캡슐화(Encapsulate Data) 규칙의 트리거다. 눈에 바로 들어오면 리팩터링 후보가 명확해진다.
// Before: 공통 접두사가 흩어져 있어 눈에 띄지 않는다
class PopupWindow {
  private windowPosition: Point2d;
  private hasFocus: number;
  private screenWidth: number;
  private screenHeight: number;
  private windowSize: Point2d;
}
// After: window 접두사가 모여 즉시 캡슐화 후보가 된다
class PopupWindow {
  private windowPosition: Point2d;
  private windowSize: Point2d;
  private hasFocus: number;
  private screenWidth: number;
  private screenHeight: number;
}

13.5.7 이름에 컨텍스트 추가하기

  • 설명: 메서드나 필드 이름에 공통 접미사를 추가하여 캡슐화 후보임을 강조한다.
  • 적용 절차: 관련 있는 메서드/필드에 동일한 접미사(예: _ArrUtil)를 붙인다. 강조가 더 필요하면 camelCase/PascalCase 관례를 깨는 언더스코어(_)를 사용한다.
  • 효과: 공통 접두사 규칙과 동일한 리팩터링 경로로 연결된다. 이름 개선 자체도 유익한 활동이다.
// Before: 관련 함수들이지만 시각적 연관성이 없다
function avg(arr: number[]): number { return sum(arr) / size(arr); }
function size(arr: number[]): number { return arr.length; }
function sum(arr: number[]): number {
  let sum = 0;
  for (let i = 0; i < arr.length; i++) sum += arr[i];
  return sum;
}
// After: 공통 접미사로 캡슐화 후보임을 명확히 드러낸다
function avg_ArrUtil(arr: number[]): number { return sum_ArrUtil(arr) / size_ArrUtil(arr); }
function size_ArrUtil(arr: number[]): number { return arr.length; }
function sum_ArrUtil(arr: number[]): number {
  let sum = 0;
  for (let i = 0; i < arr.length; i++) sum += arr[i];
  return sum;
}

13.5.8 긴 메서드 만들기

  • 설명: 잘못 추출된 메서드들을 하나의 긴 메서드로 인라인(inline)한다.
  • 적용 절차: 부적절하게 분리된 메서드들을 호출부에서 인라인 처리하고, 원래 메서드명은 주석으로 보존한다.
  • 효과: 긴 메서드는 대부분의 개발자에게 경고 신호다. 즉각적 인식은 낮지만 기억에 오래 남는다. 인라인 후에는 올바른 구조를 더 쉽게 파악할 수 있다.
// Before: 잘못 추출된 메서드들 — banner.state 반복이 숨겨져 있다
function animate() {
  handleChosen();
  handleDisplaying();
  handleCompleted();
  handleMoving();
}
// After: 인라인 처리 — banner.state 반복이 즉시 드러난다
function animate() {
  if (value >= threshold && banner.state === State.Chosen) {
    // ...
  }
  if (value >= target && banner.state === State.Displaying) {
    // ...
  }
  if (banner.state === State.Completed) {
    // ...
  }
  if (banner.state === State.Moving && banner.target === banner.current) {
    // ...
  }
}

13.5.9 파라미터를 많이 주기

  • 설명: 데이터 객체(data object)나 Map을 긴 파라미터 목록으로 풀어헤친다.
  • 적용 절차: Map이면 키를 변수명으로, 값 타입을 파라미터 타입으로 변환한다. 데이터 객체이면 필드를 각각 독립 파라미터로 분리한다.
  • 효과: 함수 정의부와 모든 호출부에 동시에 경고 신호가 생긴다. 코드베이스 전체에 리팩터링이 필요한 곳을 가리키는 이정표가 뿌려진다.
// Before 1: Map으로 숨겨진 파라미터
function stringConstructor(conf: Map<string, string>, parts: string[]): string {
  return conf.get("prefix") + parts.join(conf.get("joiner")) + conf.get("postfix");
}

// Before 2: 데이터 객체로 숨겨진 파라미터
class StringConstructorConfig {
  constructor(
    public readonly prefix: string,
    public readonly joiner: string,
    public readonly postfix: string,
  ) {}
}
function stringConstructor(conf: StringConstructorConfig, parts: string[]): string {
  return conf.prefix + parts.join(conf.joiner) + conf.postfix;
}
// After: 긴 파라미터 목록 — 정의부와 모든 호출부에서 즉시 눈에 띈다
function stringConstructor(
  prefix: string,
  joiner: string,
  postfix: string,
  parts: string[],
): string {
  return prefix + parts.join(joiner) + postfix;
}

13.5.10 게터(Getter)와 세터(Setter) 사용하기

  • 설명: 전역 변수(global variable)나 공개 필드(public field)를 클래스로 캡슐화하고 게터/세터로 접근하게 한다.
  • 적용 절차: 관련 데이터를 클래스로 묶고, getX() / setX() 메서드를 통해 접근하도록 변경한다.
  • 효과: 이 방식은 정보를 추가하는 순수 가산적 변환이므로 정보 손실 위험이 없다. get/set 접두사는 신택틱 관례로 정의부와 호출부 모두에서 즉시 눈에 띈다. 이후 클래스가 풍부해지면서 게터/세터는 자연스럽게 사라진다.
// Before: 전역 변수 — 어디서든 수정 가능하고 추적이 어렵다
let screenWidth: number;
let screenHeight: number;
// After: 캡슐화 + 게터/세터 — 즉각적인 리팩터링 신호
class Screen {
  constructor(private width: number, private height: number) {}
  getWidth(): number { return this.width; }
  getHeight(): number { return this.height; }
}
let screen: Screen;

챕터 13 요약

핵심 개념 (규칙/패턴) 목적 및 내용
나쁜 코드로 프로세스 신호하기 “그나마 나쁘지 않게” 수습하는 것은 문제를 숨긴다. 의도적으로 나쁘게 두는 것이 팀에게 유효한 신호다.
순수/레거시 코드 이분화 good enough 코드보다 bad 코드가 낫다. 명확한 이분화로 리팩터링 우선순위를 파악한다.
깨진 유리창 이론 순수한 파일은 오래 순수하게 유지된다. 나쁜 코드 옆에 더 나쁜 코드가 생긴다.
규칙 1 — 정보 보존 정확한 정보(좋은 이름 등)는 절대 파괴하지 않는다.
규칙 2 — 리팩터링 촉진 다음 리팩터링을 더 어렵게 만들지 않는다. 가능하면 더 쉽게 만든다.
규칙 3 — 가시성 확보 결과는 눈에 확실히 띄어야 한다. 순수 코드와 명확한 간극이 생겨야 한다.
기법 1: 열거형 사용 booleanenum 교체. 표준 리팩터링 경로(타입 코드 → 클래스)의 시작점.
기법 2: string/int 타입 코드 빠른 실험 단계에서 else if 체인으로 타입 코드 냄새를 드러낸다.
기법 3: 매직 넘버 삽입 상수를 인라인 숫자로 교체. 대부분의 개발자에게 즉각적인 경고 신호다.
기법 4: 주석 추가 메서드 이름이 될 수 있는 주석을 달아 메서드 추출 위치와 이름을 제안한다.
기법 5: 공백 삽입 빈 줄로 논리 블록을 시각화한다. 메서드 추출 및 데이터 캡슐화 위치를 암시한다.
기법 6: 공통 접사 그룹핑 공통 접두사를 가진 필드/메서드를 나란히 배치. 데이터 캡슐화 후보를 강조한다.
기법 7: 이름에 컨텍스트 추가 공통 접미사 부여로 관련 요소들을 묶는다. 관례를 깨는 _로 강조 가능.
기법 8: 긴 메서드 생성 잘못 추출된 메서드를 인라인. 숨겨진 구조적 문제를 수면 위로 올린다.
기법 9: 파라미터 많이 주기 데이터 객체/Map을 긴 파라미터 목록으로 전개. 정의부 + 모든 호출부에 신호 생성.
기법 10: 게터/세터 사용 전역 변수를 클래스로 캡슐화. 순수 가산적 변환으로 정보 손실 없이 눈에 띈다.

Chapter 14. Wrapping Up: 마무리 — 원칙과 철학의 정리

리팩터링에 관한 구체적인 규칙을 모두 외울 필요는 없다. 근저에 깔린 원칙을 내면화했다면, 그것으로 충분하다.


14.1 여정 되돌아보기

14.1.1 도입부: 동기(Motivation)

  • 이 책의 리팩터링 목표는 세 가지로 정의된다.
    • **불변식(Invariant) 지역화(Localization)**를 통한 코드 취약성(Fragility) 감소
    • 결합도(Coupling) 감소를 통한 유연성(Flexibility) 증가
    • 소프트웨어 도메인(Domain) 이해 심화
  • 리팩터링이란 무엇인지, 왜 필수적인지, 언제 우선시해야 하는지에 대한 토대를 초반 두 챕터에서 확립했다.

14.1.2 Part 1: 구체화

  • 실제 코드베이스를 단계적으로 개선하는 과정에서 일련의 규칙(Rules)리팩터링 패턴(Refactoring Patterns) 을 구축했다.
  • Part 1의 흐름:
단계 내용
1 긴 함수(Long Functions) 분해
2 타입 코드(Type Code)를 클래스로 교체
3 함수를 메서드로 전환해 클래스 내부로 이동
4 if문, 함수, 클래스 통합(Unify)
5 캡슐화(Encapsulation) 강제를 위한 고급 리팩터링 패턴 적용

14.1.3 Part 2: 시야 확장

  • 구체적인 규칙을 넘어 사회기술적(Socio-Technical) 주제들로 추상화 수준을 끌어올렸다.
  • 다룬 도구: 컴파일러, 피처 토글링(Feature Toggling), 칸반(Kanban), 제약 이론(Theory of Constraints)
  • 다룬 문화: 코드 삭제·추가·파괴(Vandalize)에 대한 접근법
  • 다룬 기술: 구조 발견(Uncovering Structure), 안전한 성능 최적화

14.2 근저의 철학 탐구

14.2.1 더 작은 단계를 추구하라

  • 핵심 원칙: 작은 단계를 밟을수록 오류 발생 위험이 극적으로 줄어든다.
  • 목표는 항상 그린-투-그린(Green to Green) 이다. 즉, 동작하는 상태에서 동작하는 상태로 이동한다.

작은 단계의 실질적 이점:

  • 중간에 중요한 발견이 있어도 다음 그린 상태까지만 진행한 뒤 방향을 전환할 수 있다.
  • 긴급 수정 요청이 오면 git reset으로 마지막 그린 상태로 되돌아가 작업 손실을 최소화할 수 있다.
  • 리팩터링 도중 컨텍스트 스위칭(Context Switching)은 머릿속의 수많은 실마리를 잃게 해 오류 위험을 폭발적으로 높인다.

규칙: 컨텍스트 전환은 반드시 그린 상태에서만 수행한다.

  • 피처 토글링처럼 코드와 문화 변경을 동시에 요구하는 변환도 안정 상태 사이의 작은 단계들로 분해할 수 있다.
    • 예: if 문으로 변경 사항을 감싸는 반사(Reflex)를 먼저 체화한 뒤, 프로덕션에서 그 이점을 취한다. 단계를 건너뛰면 완성되지 않은 코드가 릴리스될 위험이 높다.

14.2.2 내재된 구조를 발견하라

모든 돌덩어리 안에는 조각상이 있다. 조각가의 임무는 그것을 발견하는 것이다. — 미켈란젤로(Michelangelo)

  • 코드 리팩터링은 점토 조각(Clay Sculpting)에 비유된다. 돌을 깎는 것과 달리 가역적(Reversible)이고 유연하다.
  • 구조 발견의 카스케이드(Cascade) 전략:
flowchart TD
  A[코드 줄 Lines] -->|메서드 위치를 안내| B[메서드 Methods]
  B -->|클래스 위치를 안내| C[클래스 Classes]
  C -->|패키지/네임스페이스 위치를 안내| D[네임스페이스 / 패키지]

  style A fill:#2d2d2d,stroke:#888,color:#fff
  style B fill:#2d2d2d,stroke:#888,color:#fff
  style C fill:#2d2d2d,stroke:#888,color:#fff
  style D fill:#2d2d2d,stroke:#888,color:#fff
  • 내부에서 시작해 점점 더 추상적인 레이어로 변경을 전파한다.
  • 메서드는 하나 많은 편이 낫다. 공통 접두/접미(Affix)를 발견하는 단서가 되어, 새로운 클래스 도출로 이어질 수 있다.

14.2.3 협업의 도구로 규칙을 활용하라

  • 규칙은 법(Law)이 아니라 도구(Tool) 이다. 규칙을 맹목적으로 적용하거나 팀원을 단속하는 수단으로 사용하는 것은 심각한 오류다.
  • 개발팀에서 안전감(Feeling Safe)은 최우선 순위 다.
  • 규칙의 올바른 용도:
    • 코드 품질에 관한 대화의 좋은 출발점
    • 경험적 지침(Rules of Thumb)으로서의 기준점
    • 리팩터링 학습의 필요성과 동기 부여

14.2.4 개인보다 팀을 우선시하라

빨리 가려면 혼자 가라. 멀리 가려면 함께 가라. — 아프리카 속담

  • 소프트웨어 개발은 팀의 노력이다. 개발자들이 병렬로 개별 작업하면 효율이 높아 보이지만, 지식 사일로(Knowledge Silo) 를 만들어 더 큰 해악을 초래한다.
  • 페어 프로그래밍(Pair Programming)앙상블 프로그래밍(Ensemble Programming) 은 지식·기술·책임을 분산시켜 신뢰와 헌신을 강화한다.
  • 팀은 전달(Delivery)의 방법론이다. 개인이 아니다.
  • 코드 품질 판단 시 스스로에게 던질 세 가지 질문:
  1. 우리 개발자들이 이 코드를 이해하는가?
  2. 그들이 만족하는가?
  3. 성능/보안 제약을 깨뜨리지 않는 더 단순한 버전이 존재하는가?

14.2.5 완전성보다 단순성을 우선시하라

  • 새로운 규칙을 만들 때 보편성(Universality) 을 추구하는 함정에 빠지기 쉽다. 이는 코드 스멜(Code Smell)처럼 모호하고 일반적인 규칙을 낳는다.
  • 인지심리학의 이중 처리 이론(Dual-Process Theory):
시스템 특성 예시
시스템 1 빠르고 자동적, 에너지 소모 적음, 부정확 걷기, 운전, 껌 씹기
시스템 2 느리고 의식적, 에너지 소모 많음, 정확 문제 해결, 프로그래밍
  • 프로그래밍은 문제 해결이므로 시스템 2 작업이다. 개발자는 이미 시스템 2로 문제를 해결하는 데 인지 용량을 소진하고 있다.
  • 결론: 행동 변화를 원한다면, 규칙은 생각 없이도 적용할 수 있을 만큼 단순해야 한다.

“단순하지만 틀린 것”과 “복잡하지만 옳은 것” 사이에서, 행동 변화를 원한다면 단순성 쪽으로 오류를 허용하라.

  • 단순한 규칙의 위험(맹목적 적용)은 인간의 상식(Common Sense) 으로 보완된다. 규칙을 법이 아닌 지침으로 제시하면 이 문제를 방지할 수 있다.

14.2.6 객체 vs 고차 함수

  • 단일 메서드를 가진 객체와 고차 함수(Higher-Order Function) 는 리팩터링 관점에서 동일하다. 결합도도 같다.
  • 객체에 필드가 있다면 그것은 클로저(Closure)와 동일하다.

Before: 전략 객체(Strategy Object) 방식

// Before: 단일 메서드 인터페이스를 구현한 클래스를 전달
function remove(shouldRemove: RemoveStrategy): void {
  for (let y = 0; y < map.length; y++)
    for (let x = 0; x < map[y].length; x++)
      if (shouldRemove.check(map[y][x]))
        map[y][x] = new Air();
}

class Key1 implements Tile {
  moveHorizontal(dx: number): void {
    remove(new RemoveLock1());
    moveToTile(playerx + dx, playery);
  }
}

interface RemoveStrategy {
  check(tile: Tile): boolean;
}

class RemoveLock1 implements RemoveStrategy {
  check(tile: Tile): boolean {
    return tile.isLock1();
  }
}

After: 고차 함수(Higher-Order Function) 방식

// After: 인터페이스와 클래스를 제거하고 함수 타입으로 직접 전달
function remove(shouldRemove: (tile: Tile) => boolean): void {
  for (let y = 0; y < map.length; y++)
    for (let x = 0; x < map[y].length; x++)
      if (shouldRemove(map[y][x]))
        map[y][x] = new Air();
}

class Key1 implements Tile {
  moveHorizontal(dx: number): void {
    remove((tile) => tile.isLock1());
    moveToTile(playerx + dx, playery);
  }
}
  • 고차 함수 방식이 더 간결하지만, 일부 개발자에게는 가독성이 떨어질 수 있다.
  • 기준: 팀이 더 읽기 쉽다고 판단하는 방식을 선택한다. 어느 쪽이든 결합도는 동일하다.

14.3 다음 여정

14.3.1 마이크로 아키텍처(Micro-Architecture) 경로

  • 대상: 팀 내부(Intra-Team) 아키텍처 — 표현식 수준부터 공개 인터페이스 직전까지의 결합도와 취약성
  • 추천 도서:
    • Clean Code (Robert C. Martin) — 더 정교하고 상세한 코드 스멜(Code Smell) 탐구
    • Refactoring (Martin Fowler) — 리팩터링 패턴 레퍼토리 확장

14.3.2 매크로 아키텍처(Macro-Architecture) 경로

  • 대상: 팀 간(Inter-Team) 아키텍처
  • 콘웨이 법칙(Conway’s Law): 매크로 아키텍처는 조직의 커뮤니케이션 구조를 그대로 반영한다. 코드를 바꾸려면 사람에게 집중해야 한다.
  • 추천 도서: Team Topologies (Matthew Skelton, IT Revolution Press, 2019)

14.3.3 소프트웨어 품질(Software Quality) 경로

소프트웨어 품질에는 팀의 성격에 따라 적합한 접근법이 다르다.

팀 유형 권장 분야 추천 도서
일반 사용자에게 소프트웨어를 전달하는 제품 팀 테스트(Testing) / 테스트 주도 개발(TDD) Test-Driven Development (Kent Beck, 2002)
라이브러리/프레임워크를 제공하는 플랫폼 팀 타입 이론(Type Theory) Types and Programming Languages (Benjamin C. Pierce, MIT Press, 2002)
최고 수준의 품질을 목표로 하는 팀 의존 타입(Dependent Types) / 증명 보조기(Proof Assistants) Type-Driven Development with Idris (Edwin Brady, Manning, 2017)
  • 타입 안전성(Type Safety)은 가르친 내용만 커버하지만 그 범위 안에서는 완전무결하다.
  • 증명 가능한 정확성(Provable Correctness)은 소프트웨어 품질의 최전선이나 마스터하기 위한 노력이 막대하다. Lean 같은 새로운 언어가 계속 등장하고 있어 미래 가능성은 높다.

챕터 14 요약

핵심 개념 (규칙/패턴) 목적 및 내용
리팩터링의 목표 불변식 지역화로 취약성 감소, 결합도 감소로 유연성 증가, 도메인 이해 심화
그린-투-그린(Green to Green) 항상 동작하는 상태에서 동작하는 상태로 이동하여 오류 위험 최소화
구조 발견 카스케이드 줄(Lines) → 메서드 → 클래스 → 네임스페이스 순서로 내부에서 외부로 구조를 발견
규칙은 도구이다 규칙은 법이 아니라 협업과 대화를 위한 지침이며, 안전감이 최우선이다
팀 우선 원칙 지식 사일로를 피하고 페어/앙상블 프로그래밍으로 지식·책임을 분산한다
단순성 우선 원칙 규칙은 시스템 2 부하를 최소화하도록 직관적으로 적용 가능해야 한다
객체 vs 고차 함수 리팩터링 관점에서 동일하며, 팀의 가독성 기준에 따라 선택한다
다음 경로 마이크로 아키텍처(Clean Code/Refactoring), 매크로 아키텍처(Team Topologies), 품질(TDD/Type Theory)