7장, 캡슐화

2021.10.18

  • 모듈을 분리하는 가장 중요한 기준은 시스템에서 각 모듈이 자신을 제외한 다른 부분에 드러내지 않아야 할 비밀을 얼마나 잘 숨기느냐 있다.

  • 클래스는 본래 정보를 숨기는 용도로 설계되었다.

    • 클래스는 내부 정보 뿐 아니라, 클래스 사이의 연결 관계를 숨기는 데도 유용하다.
    • 너무 많이 숨기다 보면 인터페이스가 비대해질 수 있다.
  • 가장 큰 캡슐화 단위는 클래스와 모듈이지만 함수도 구현을 캡슐화한다.

    • 알고리즘 전체를 함수 하나에 담은 뒤, 알고리즘 교체를 적절하게 사용.

1. Encapsulate Record, 레코드 캡슐화하기

  • 데이터 레코드는 정의하고 사용하기 간단하지만 계산해서 얻을 수 있는 값과 그렇지 않은 값을 명확히 구분해야 하는 단점이 있다.
  • 저자는 가변 데이터일때 객체를 선호. 값이 불변이면 미리 구한 다음 레코드에 저장.

절차

  1. 레코드를 담은 변수를 캡슐화한다.
  2. 레코드를 감싼 단순한 클래스로 해당 변수의 내용을 교체한다. 이 클래스에 원본 레코드를 반환하는 접근자도 정의하고, 변수를 캡슐화하는 함수들이 이 접근자를 사용하도록 수정한다.
  3. 테스트.
  4. 원본 레코드 대신 새로 정의한 클래스 타입의 객체를 반환하는 함수들을 새로 만든다.
  5. 레코드를 반환하는 예전 함수를 사용하는 코드를 4에서 만든 새 함수를 사용하도록 바꾼다. 필드에 접근할 때는 객체의 접근자를 사용한다. 적절한 접근자가 없다면 추가한다. 한 부분을 바꿀때마다 테스트.
  6. 클래스에서 원본 데이터를 반환하는 접근자와 원본 레코드를 반환하는 함수를 제거한다.
  7. 테스트.
  8. 레코드의 필드도 데이터 구조인 중첩 구조라면 레코드 캡슐화하기와 컬렉션 캡슐화하기를 재귀적으로 적용한다.

예시

before

const organization = { name: '애크미 구스베리', country: 'GB' };

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;
  }
}

2. Encapsuplate Collection, 컬렉션 캡슐화하기

  • 컬렉션을 감싼 클래스에 흔히 add(), remove()라는 이름의 변경자 메서드를 만든다.

    • 컬렉션을 소유한 클래스를 통해서만 원소를 변경하도록 하면, 추후에 컬렉션 변경 방식도 원하는 대로 수정할 수 있다.
    • 데이터 구조가 언제 어떻게 수정되는지 파악하기 쉬워서, 필요한 시점에 데이터 구조를 변경하기 쉽다는 장점이 있다.
  • 메서드 체이닝 등을 위해 직접 구현한 메서드들 대신 표준화된 인터페이스를 사용하자.

절차

  1. 아직 컬렉션을 캡슐화하지 않았다면 변수 캡슐화하기부터 한다.
  2. 컬렉션에 원소를 추가/제거하는 함수를 추가한다.
  3. 정적 검사를 수행한다.
  4. 컬렉션을 참조하는 부분을 모두 찾는다. 컬렉션의 변경자를 호출하는 코드가 모두 앞에서 추가한 추가/제거 함수를 호출하도록 수정한다. ( 하나씩 수정할 때마다 테스트 )
  5. 컬렉션 게터를 수정해서 원본 내용을 수정할 수 없는 읽기전용 프락시나 복제본을 반환하게 한다.
  6. 테스트.

예시

before

class Person {
  get courses() {
    return this._courses;
  }
  set courses(aList) {
    this._courses = aList;
  }
}

after

class Person {
  get courses() {
    return this._courses.slice();
  }
  addCourse(aCourse) {}
  removeCourse(aCourse) {}
}
;

3. Replace Primitive with Object 기본형을, 객체로 바꾸기

  • 단순히 출력 이상의 기능이 필요해지는 순간 데이터를 표현하는 전용 객체/클래스를 정의한다.
  • 객체/클래스로 변경하면 추가 기능 등을 처리할 시에 메서드 추가를 쉽게할 수 있어 유연하다.

절차

  1. 아직 변수를 캡슐화하지 않았다면 캡슐화한다.
  2. 단순한 값 클래스를 만든다.
  3. 생성자는 기존 값을 인수로 받아서 저장하고, 이 값을 반환하는 게터를 추가한다.
  4. 정적 검사를 수행한다.
  5. 값 클래스의 인스턴스를 새로 만들어서 필드에 저장하도록 세터를 수정한다. 이미 있다면 필드의 타입을 적절히 변경한다.
  6. 새로 만든 클래스의 게터를 호출한 결과를 반환하도록 게터를 수정한다.
  7. 테스트.
  8. 함수 이름을 바꾸면 원본 접근자의 동작을 더 잘 드러낼 수 있는지 검토한다.

예시

before

orders.filter((o) => 'high' === o.priority || 'rush' === o.priority);

after

orders.filter((o) => o.priority.higherThan(new Priority('normal')));

4. Replace Temp with Query 임시 변수를 질의, 함수로 바꾸기

  • 함수 안에서 코드의 결과 값을 뒤에서 다시 참조할 목적으로 임시 변수를 사용하는데, 이를 함수로 만들어 사용하는 편이 나을 때가 많다.
    • 비슷한 계산을 수행하는 다른 함수에서도 사용할 수 있어 중복이 줄어든다.
    • 값이 한 번만 할당되어 사용되어지는 임시 변수만 질의 함수로 바꾸자.

절차

  1. 변수가 사용되기 전에 값이 확실히 결정되는지, 변수를 사용할 때마다 계산 로직이 매번 다른 결과를 내지는 않는지 확인한다.
  2. 읽기전용으로 만들 수 있는 변수는 읽기전용으로 만든다.
  3. 테스트.
  4. 변수 대입문을 함수로 추출한다.
  5. 테스트.R
  6. 변수 인라인하기로 임시 변수를 제거한다.

예시

before

const basePrice = this._quantity * this._itemPrice;
if (basePrice > 1000) return basePrice * 0.95;
else return basePrice * 0.98;

after

class Order {
  get basePrice() {
    this._quantity * this._itemPrice;
  }
  // ...
}
if (basePrice > 1000) return this.basePrice * 0.95;
else return this.basePrice * 0.98;

5. Extract Class, 클래스 추출하기

  • 메서드와 데이터가 너무 많은 클래스는 이해하기가 쉽지 않으니 잘 살펴보고 적절히 분리하는 것이 좋다.
    • 일부 데이터와 메서드를 따로 묶을 수 있다면 분리하기에 좋다.
    • 함께 변경되는 일이 많거나 서로 의존하는 데이터들도 분리한다.
    • 데이터나 메서드 일부를 제거해도 다른 필드나 메서드들에서 논리적으로 문제가 없다면 분리할 수 있다.

절차

  1. 클래스의 역할을 분리할 방법을 정한다.
  2. 분리될 역할을 담당할 클래스를 새로 만든다.
  3. 원래 클래스의 생성자에서 새로운 클래스의 인스턴스를 생성하여 필드에 저장해둔다.
  4. 분리될 역할에 필요한 필드들을 새 클래스로 옮긴다. 하나씩 옮길때마다 테스트.
  5. 메서드들도 새 클래스로 옮긴다. 이때 저수준 메서드, 즉 다른 메서드를 호출하기보다는 호출을 당하는 일이 많은 메서드부터 옮긴다. 하나씩 옮길 때마다 테스트.
  6. 양족 클래스의 인터페이스를 살펴보면서 불필요한 메서드를 제거하고, 이름도 새로운 환경에 맞게 바꾼다.
  7. 새 클래스를 외부로 노출할지 정한다. 노출하려거든 새 클래스에 참조를 값으로 바꾸기를 적용할 지 고민해본다.

예시

before

class Person {
  get officeAreaCode() {
    return this._officeAreaCode;
  }
  get officeNumber() {
    return this._officeNumber;
  }
}

after

class Person {
  get officeAreaCode() {
    return this._telephoneNumber.areaCode;
  }
  get officeNumber() {
    return this._telephoneNumber.number;
  }
}
class TelephoneNumber {
  get areaCode() {
    return this._areaCode;
  }
  get number() {
    return this._number;
  }
}

6. Inline Class, 클래스 인라인하기

  • 더 이상 제 역할을 못해서 그대로 두면 안되는 클래스는 인라인해버린다.
    • 역할을 옮기는 리팩터링을 하고, 특정 클래스에 남은 역할이 거의 없을 때 인라인 대상으로 정한다.

절차

  1. 소스 클래스의 각 public 메서드에 대응하는 메서드들을 타깃 클래스에 생성한다. 이 메서드들은 단순히 작업을 소스 클래스로 위임해야 한다.
  2. 소스 클래스의 메서드를 사용하는 코드를 모두 타깃 클래스의 위임 메서드를 사용하도록 바꾼다. 하나씩 바꿀때 마다 테스트.
  3. 소스 클래스의 메서드와 필드를 모두 타깃 클래스로 옮긴다. 하나씩 옮길 때마다 테스트.
  4. 소스 클래스를 삭제하고 조의를 표한다.

예시

before

class Person {
  get officeAreaCode() {
    return this._telephoneNumber.areaCode;
  }
  get officeNumber() {
    return this._telephoneNumber.number;
  }
}
class TelephoneNumber {
  get areaCode() {
    return this._areaCode;
  }
  get number() {
    return this._number;
  }
}

after

class Person {
  get officeAreaCode() {
    return this._officeAreaCode;
  }
  get officeNumber() {
    return this._officeNumber;
  }
}

7. Hide Delegate, 위임 숨기기

  • 모듈화 설계를 제대로 하는 핵심은 캡슐화이다.
  • 위임 메서드를 만들어서 사용한다면, 위임 객체가 수정되더라도 클라이언트(사용하는)쪽에서는 아무런 영향을 받지 않는다.

절차

  1. 위임 객체의 각 메서드에 해당하는 위임 메서드를 서버에 생성한다.
  2. 클라이언트가 위임 객체 대신 서버를 호출하도록 수정한다. 하나씩 바꿀 때마다 테스트.
  3. 모두 수정했다면, 서버로부터 위임 객체를 얻는 접근자를 제거한다.
  4. 테스트.

예시

before

manager = aPerson.department.manager;

after

class Person {
  get manager() {
    return this.department.manager;
  }
}
manager = aPerson.manager;

8. Remove Middle Man , 중개자 제거하기

  • 클라이언트가 위임 객체의 다른 기능을 사용하고 싶을 때마다 위임 메서드를 추가해야 하는데, 이렇게 기능을 추가하다 보면 단순히 전달만 하는 위임 메서드들이 점점 성가셔진다.
  • 서버 클래스는 그저 중개자 역할로 전락하여 차라리 클라이언트가 위임 객체를 직접 호출하는게 나을 수 있다.
  • 디미터의 법칙을 너무 신봉할 때, 위와 같은 현상이 자주 나온다.
  • 중개자를 사용할지 말지에 대한 적절한 판단이 쉽지는 않지만, 이 기준은 시기와 상황에 따라 다르다.

절차

  1. 위임 객체를 얻는 게터를 만든다.
  2. 위임 메서드를 호출하는 클라이언트가 모두 이 게터를 거치도록 수정한다. 하나씩 바꿀 때마다 테스트.
  3. 모두 수정했다면 위임 메서드를 삭제한다.

예시

before

class Person {
  get manager() {
    return this.department.manager;
  }
}
manager = aPerson.manager;

after

manager = aPerson.department.manager;

9. Substitute Algorithm, 알고리즘 교체하기

  • 기존의 복잡한 코드가 있다면 간명한 방식으로 변경할 방법이 있다면 간명한 방식으로 바꾼다.
    • 내 코드와 똑같은 기능을 제공하는 라이브러리를 찾았을 때.
    • 알고리즘을 살짝 다르게 동작하도록 바꾸고 싶을 때.
  • 이 작업에 착수하려면 메서드를 가능한 잘게 나눴는지 확인하는게 좋다.

절차

  1. 교체할 코드를 함수 하나에 모은다.
  2. 이 함수만을 이용해 동작을 검증하는 테스트를 마련한다.
  3. 대체할 알고리즘을 준비한다.
  4. 정적 검사를 수행한다.
  5. 기존 알고리즘과 새 알고리즘의 결과를 비교하는 테스트를 수행한다. 두 결과가 같다면 리팩터링이 끝난다. 그렇지 않다면 기존 알고리즘을 참고해서 새 알고리즘을 테스트하고 디버깅한다.

예시

before

// 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)) || '';
}