모듈 분리의 핵심은 “서로에 대해 몰라도 되는 부분을 얼마나 숨기느냐”다. 이 장의 기법들은 전부 노출을 줄이는 방향을 가리킨다. 파울러는 캡슐화를 “가장 중요한 리팩터링 원칙”에 가깝게 다루는데, 이유는 단순하다. 숨긴 것은 마음대로 바꿀 수 있고, 드러낸 것은 함부로 못 바꾼다. 캡슐화의 실익은 정보 은닉 그 자체가 아니라 “변경의 파급 범위를 좁히는 것”이다. 아래 기법들을 하나씩 정리한다.

이 장의 기법들을 “무엇을 감추는가” 축으로 늘어놓으면 하나의 스펙트럼으로 읽힌다.

flowchart LR
    R["레코드 캡슐화<br/>필드 직접 접근을 감춤"] --> C["컬렉션 캡슐화<br/>원본 컬렉션을 감춤"]
    C --> P["기본형을 객체로<br/>값의 표현·검증을 감춤"]
    P --> X["클래스 추출<br/>책임 경계를 감춤"]
    X --> D["위임 숨기기<br/>위임 체인을 감춤"]

왼쪽의 작은 은닉에서 오른쪽의 구조적 은닉으로 갈수록 감추는 대상이 커진다.

레코드 캡슐화하기

가변 데이터라면 순수 레코드(그냥 {} 객체)보다 클래스가 낫다. 필드를 직접 만지는 코드가 여기저기 흩어져 있으면 필드 이름 하나 바꾸는 것도 위험해지고, 파생 값을 넣거나 검증을 추가할 자리가 없다. 접근 함수를 관문으로 세우면 이후 변경이 전부 한 곳에서 끝난다.

// before — 날것의 레코드
const organization = { name: "애크미 구스베리", country: "GB" }
// 여기저기서 organization.name 을 직접 읽고 쓴다
 
// after — 클래스로 감싼 접근 함수
class Organization {
  constructor(data) {
    this._name = data.name
    this._country = data.country
  }
  get name() {
    return this._name
  }
  set name(arg) {
    this._name = arg
  }
  get country() {
    return this._country
  }
  set country(arg) {
    this._country = arg
  }
}

절차의 요점은 이렇다. 레코드를 담은 변수를 먼저 캡슐화(변수 캡슐화)하고, 레코드를 클래스로 대체한 뒤, 접근 함수를 하나씩 만들어 직접 접근을 전부 함수 뒤로 밀어 넣는다. 흔한 함정: 중첩된 레코드(리스트 안의 레코드, 레코드 안의 레코드)는 한 번에 다 감싸려다 지친다. 파울러도 중첩이 깊으면 별도 클래스로 쪼개 가며 점진적으로 하라고 한다.

컬렉션 캡슐화하기

getter가 컬렉션을 그대로 반환하면 캡슐화가 뚫린다. 받은 쪽에서 push() 한 번이면 원본이 클래스 모르게 바뀐다. 컬렉션은 “값을 읽는 통로”와 “원소를 넣고 빼는 통로”를 반드시 분리해야 한다. 추가/제거는 addCourse(), removeCourse() 같은 전용 메서드로만 열어 두고, getter는 원본을 노출하지 않게 한다.

class Person {
  get courses() {
    return this._courses.slice()
  } // 복제본 반환
  addCourse(course) {
    this._courses.push(course)
  }
  removeCourse(
    course,
    fnIfAbsent = () => {
      throw new RangeError()
    },
  ) {
    const index = this._courses.indexOf(course)
    if (index === -1) fnIfAbsent()
    else this._courses.splice(index, 1)
  }
}

두 가지 전략이 있다. (1) 복제본 반환slice()로 방어적 복사본을 준다. 가장 흔하고 안전하며, 복사 비용이 성능 문제가 되는 경우는 실제로 거의 없다. (2) 읽기 전용 프락시/뷰 반환 — 원본을 감싸되 변경 메서드를 막은 래퍼를 준다. 파울러는 대부분의 경우 복제본 반환을 권한다. 어느 쪽이든 핵심은 “getter로 받은 컬렉션을 변경해도 원본이 안 바뀐다”를 보장하는 것이다. 흔한 함정: getter가 slice() 없이 그냥 this._courses를 넘기면 위의 모든 방어가 무력화된다.

기본형을 객체로 바꾸기

전화번호, 우선순위, 온도 같은 값이 문자열이나 숫자로 돌아다니기 시작하면 곧 포맷팅·검증·비교 로직이 여기저기 중복된다. 초반에 값을 클래스로 승격해 두면 그 로직이 모일 집이 생긴다. 파울러가 “가성비가 가장 좋은 리팩터링 중 하나”라고 꼽는 기법이다.

// before — 우선순위가 그냥 문자열
if (order.priority === "high" || order.priority === "rush") { ... }
 
// after — Priority 값 객체
class Priority {
  constructor(value) {
    if (!Priority.legalValues().includes(value))
      throw new Error(`<${value}> is invalid for Priority`);
    this._value = value;
  }
  static legalValues() { return ["low", "normal", "high", "rush"]; }
  get _index() { return Priority.legalValues().indexOf(this._value); }
  higherThan(other) { return this._index > other._index; }
  toString() { return this._value; }
}
 
if (order.priority.higherThan(new Priority("normal"))) { ... }

이제 “high가 rush보다 낮은가” 같은 비교가 문자열 대소 비교가 아니라 도메인 규칙으로 표현된다. 유효하지 않은 값은 생성 시점에 막힌다. 흔한 함정: 처음부터 완벽한 값 객체를 만들려 하지 말 것. 우선 필드를 캡슐화하고, 단순 래핑 클래스를 넣은 다음, 실제로 로직이 몰릴 때 하나씩 메서드로 끌어올리면 된다.

임시 변수를 질의 함수로 바꾸기

긴 함수를 쪼갤 때 임시 변수가 추출을 막는 일이 잦다. 추출하려는 코드 조각이 어떤 지역 변수에 의존하면 그 변수를 인자로 넘겨야 하고, 조각이 그 변수를 수정하면 아예 추출이 어려워진다. 변수를 함수(질의 함수)로 바꿔 두면 추출한 함수들끼리 값을 주고받을 통로가 생긴다.

// before
get price() {
  const basePrice = this._quantity * this._itemPrice;
  const discountFactor = basePrice > 1000 ? 0.95 : 0.98;
  return basePrice * discountFactor;
}
 
// after — basePrice, discountFactor 를 질의 함수로
get price() { return this.basePrice * this.discountFactor; }
get basePrice() { return this._quantity * this._itemPrice; }
get discountFactor() { return this.basePrice > 1000 ? 0.95 : 0.98; }

전제 조건: 변수가 한 번만 대입되어야 하고(계산이 결정적), 부작용이 없어야 한다. 흔한 함정: 매번 계산하면 느려질까 걱정하지만, 이런 계산은 대개 무시할 수 있는 비용이고, 성능이 정말 문제면 나중에 프로파일링 후 캐시를 붙이면 된다. 먼저 표현을 명확히 하는 게 우선이다.

클래스 추출하기 / 인라인하기

클래스가 두 가지 책임의 데이터와 메서드를 함께 품기 시작하면 분리한다. 신호: 일부 데이터와 일부 메서드가 늘 함께 움직이고, 나머지와는 거의 안 엮인다. 그 하위 집합을 새 클래스로 뽑는다.

// before — Person 이 전화번호까지 떠안음
class Person {
  get officeAreaCode() {
    return this._officeAreaCode
  }
  get officeNumber() {
    return this._officeNumber
  }
}
 
// after — TelephoneNumber 로 추출
class TelephoneNumber {
  get areaCode() {
    return this._areaCode
  }
  get number() {
    return this._number
  }
  toString() {
    return `(${this.areaCode}) ${this.number}`
  }
}
class Person {
  get officeAreaCode() {
    return this._telephoneNumber.areaCode
  }
}

이 추출은 한 번에 끝내지 않고 마이그레이션 절차로 밟는다. 빈 클래스를 세우고 → 필드를 옮겨 위임하게 만들고 → 관련 동작(toString)까지 옮긴다. Next로 넘기며 매 단계에서 Person이 얼마나 얇아지는지 확인해 보자.

Refactoring Step 원본 — Person 이 전화번호 필드까지 떠안음
1/3
class Person {  constructor(name, officeAreaCode, officeNumber) {    this._name = name;    this._officeAreaCode = officeAreaCode;    this._officeNumber = officeNumber;  }  get name() {    return this._name;  }  get officeAreaCode() {    return this._officeAreaCode;  }  set officeAreaCode(arg) {    this._officeAreaCode = arg;  }  get officeNumber() {    return this._officeNumber;  }  set officeNumber(arg) {    this._officeNumber = arg;  }  get telephoneNumber() {    return `(${this._officeAreaCode}) ${this._officeNumber}`;  }}

반대 방향이 클래스 인라인하기다. 제 역할을 못 하는 껍데기 클래스, 혹은 리팩터링 과정에서 책임이 다 빠져나가 빈껍데기만 남은 클래스는 원래 클래스로 도로 합친다. 이 둘은 쌍이다. 리팩터링은 종종 “일단 잘게 추출했다가 다시 적절히 합치는” 진자 운동으로 진행된다.

위임 숨기기 / 중개자 제거하기

manager = person.department.manager처럼 클라이언트가 위임 체인을 직접 타면, 체인 중간 구조(department)가 바뀔 때 클라이언트까지 전부 고쳐야 한다. 위임을 서버 뒤로 숨긴다.

// before
manager = aPerson.department.manager
 
// after — Person 이 위임을 숨김
class Person {
  get manager() {
    return this._department.manager
  }
}
manager = aPerson.manager

이제 클라이언트는 department의 존재를 몰라도 된다. 그런데 이걸 계속 밀어붙이면 클래스가 온통 남에게 넘기기만 하는 전달 메서드(중개자)로 뒤덮인다. 그럼 반대로 중개자를 제거해 클라이언트가 실제 대상을 직접 호출하게 한다. 어느 쪽이 옳다는 원칙은 없다. 파울러의 표현대로 “디미터 법칙”조차 절대 규칙이 아니라 균형 문제다. 얼마나 많은 클라이언트가 그 위임을 쓰는지, 중간 구조가 얼마나 자주 바뀌는지가 균형점을 정한다.

알고리즘 교체하기

같은 일을 하는 더 명확한 알고리즘을 찾았다면, 복잡한 기존 코드를 통째로 갈아 끼운다. 큰 덩어리를 한 번에 바꾸는 것이므로, 먼저 대상 함수를 최대한 잘게 나눠 놓아야 안전하다. 그리고 반드시 견고한 테스트가 있어야 한다 — 교체 전후로 동작이 같음을 테스트가 보증해야 한다.

// before
function foundPerson(people) {
  for (let i = 0; i < people.length; i++) {
    if (people[i] === "Don") return "Don"
    if (people[i] === "John") return "John"
    if (people[i] === "Kent") return "Kent"
  }
  return ""
}
 
// after
function foundPerson(people) {
  const candidates = ["Don", "John", "Kent"]
  return people.find((p) => candidates.includes(p)) || ""
}

흔한 함정: 교체 대상이 사실은 미묘하게 다른 일을 하고 있었을 때. 그래서 “테스트가 초록불”이 대전제다.

정리

  • 캡슐화의 실익은 “변경의 파급 범위 축소”다. 숨긴 만큼 나중에 마음대로 바꿀 수 있다.
  • 컬렉션 getter는 항상 의심한다. 원본을 그대로 반환하는 건 캡슐화가 아니라 구멍이다. 복제본 반환이 기본값이다.
  • 기본형을 객체로 바꾸기는 가성비 최고 기법이다. 값이 문자열로 떠돌기 시작하는 순간이 신호다.
  • 클래스 추출/인라인, 위임 숨기기/중개자 제거는 각각 쌍이다. 원칙이 아니라 트레이드오프이며, 균형점은 클라이언트 수와 변경 빈도가 정한다.
  • 알고리즘 교체는 잘게 나누고 테스트로 감싼 뒤에만 안전하다.