모듈을 분리하는 가장 중요한 기준은 시스템에서 각 모듈이 자신을 제외한 다른 부분에 드러내지 않아야 할 비밀을 얼마나 잘 숨기느냐 있다.
클래스는 본래 정보를 숨기는 용도로 설계되었다.
- 클래스는 내부 정보 뿐 아니라, 클래스 사이의 연결 관계를 숨기는 데도 유용하다.
- 너무 많이 숨기다 보면 인터페이스가 비대해질 수 있다.
가장 큰 캡슐화 단위는 클래스와 모듈이지만 함수도 구현을 캡슐화한다.
- 알고리즘 전체를 함수 하나에 담은 뒤, 알고리즘 교체를 적절하게 사용.
1. Encapsulate Record, 레코드 캡슐화하기
- 데이터 레코드는 정의하고 사용하기 간단하지만 계산해서 얻을 수 있는 값과 그렇지 않은 값을 명확히 구분해야 하는 단점이 있다.
- 저자는 가변 데이터일때 객체를 선호. 값이 불변이면 미리 구한 다음 레코드에 저장.
절차
- 레코드를 담은 변수를 캡슐화한다.
- 레코드를 감싼 단순한 클래스로 해당 변수의 내용을 교체한다. 이 클래스에 원본 레코드를 반환하는 접근자도 정의하고, 변수를 캡슐화하는 함수들이 이 접근자를 사용하도록 수정한다.
- 테스트.
- 원본 레코드 대신 새로 정의한 클래스 타입의 객체를 반환하는 함수들을 새로 만든다.
- 레코드를 반환하는 예전 함수를 사용하는 코드를 4에서 만든 새 함수를 사용하도록 바꾼다. 필드에 접근할 때는 객체의 접근자를 사용한다. 적절한 접근자가 없다면 추가한다. 한 부분을 바꿀때마다 테스트.
- 클래스에서 원본 데이터를 반환하는 접근자와 원본 레코드를 반환하는 함수를 제거한다.
- 테스트.
- 레코드의 필드도 데이터 구조인 중첩 구조라면 레코드 캡슐화하기와 컬렉션 캡슐화하기를 재귀적으로 적용한다.
예시
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, 컬렉션 캡슐화하기
절차
- 아직 컬렉션을 캡슐화하지 않았다면 변수 캡슐화하기부터 한다.
- 컬렉션에 원소를 추가/제거하는 함수를 추가한다.
- 정적 검사를 수행한다.
- 컬렉션을 참조하는 부분을 모두 찾는다. 컬렉션의 변경자를 호출하는 코드가 모두 앞에서 추가한 추가/제거 함수를 호출하도록 수정한다. ( 하나씩 수정할 때마다 테스트 )
- 컬렉션 게터를 수정해서 원본 내용을 수정할 수 없는 읽기전용 프락시나 복제본을 반환하게 한다.
- 테스트.
예시
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 기본형을, 객체로 바꾸기
- 단순히 출력 이상의 기능이 필요해지는 순간 데이터를 표현하는 전용 객체/클래스를 정의한다.
- 객체/클래스로 변경하면 추가 기능 등을 처리할 시에 메서드 추가를 쉽게할 수 있어 유연하다.
절차
- 아직 변수를 캡슐화하지 않았다면 캡슐화한다.
- 단순한 값 클래스를 만든다.
- 생성자는 기존 값을 인수로 받아서 저장하고, 이 값을 반환하는 게터를 추가한다.
- 정적 검사를 수행한다.
- 값 클래스의 인스턴스를 새로 만들어서 필드에 저장하도록 세터를 수정한다. 이미 있다면 필드의 타입을 적절히 변경한다.
- 새로 만든 클래스의 게터를 호출한 결과를 반환하도록 게터를 수정한다.
- 테스트.
- 함수 이름을 바꾸면 원본 접근자의 동작을 더 잘 드러낼 수 있는지 검토한다.
예시
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 임시 변수를 질의, 함수로 바꾸기
- 함수 안에서 코드의 결과 값을 뒤에서 다시 참조할 목적으로 임시 변수를 사용하는데, 이를 함수로 만들어 사용하는 편이 나을 때가 많다.
- 비슷한 계산을 수행하는 다른 함수에서도 사용할 수 있어 중복이 줄어든다.
- 값이 한 번만 할당되어 사용되어지는 임시 변수만 질의 함수로 바꾸자.
절차
- 변수가 사용되기 전에 값이 확실히 결정되는지, 변수를 사용할 때마다 계산 로직이 매번 다른 결과를 내지는 않는지 확인한다.
- 읽기전용으로 만들 수 있는 변수는 읽기전용으로 만든다.
- 테스트.
- 변수 대입문을 함수로 추출한다.
- 테스트.R
- 변수 인라인하기로 임시 변수를 제거한다.
예시
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;
- 메서드와 데이터가 너무 많은 클래스는 이해하기가 쉽지 않으니 잘 살펴보고 적절히 분리하는 것이 좋다.
- 일부 데이터와 메서드를 따로 묶을 수 있다면 분리하기에 좋다.
- 함께 변경되는 일이 많거나 서로 의존하는 데이터들도 분리한다.
- 데이터나 메서드 일부를 제거해도 다른 필드나 메서드들에서 논리적으로 문제가 없다면 분리할 수 있다.
절차
- 클래스의 역할을 분리할 방법을 정한다.
- 분리될 역할을 담당할 클래스를 새로 만든다.
- 원래 클래스의 생성자에서 새로운 클래스의 인스턴스를 생성하여 필드에 저장해둔다.
- 분리될 역할에 필요한 필드들을 새 클래스로 옮긴다. 하나씩 옮길때마다 테스트.
- 메서드들도 새 클래스로 옮긴다. 이때 저수준 메서드, 즉 다른 메서드를 호출하기보다는 호출을 당하는 일이 많은 메서드부터 옮긴다. 하나씩 옮길 때마다 테스트.
- 양족 클래스의 인터페이스를 살펴보면서 불필요한 메서드를 제거하고, 이름도 새로운 환경에 맞게 바꾼다.
- 새 클래스를 외부로 노출할지 정한다. 노출하려거든 새 클래스에 참조를 값으로 바꾸기를 적용할 지 고민해본다.
예시
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, 클래스 인라인하기
- 더 이상 제 역할을 못해서 그대로 두면 안되는 클래스는 인라인해버린다.
- 역할을 옮기는 리팩터링을 하고, 특정 클래스에 남은 역할이 거의 없을 때 인라인 대상으로 정한다.
절차
- 소스 클래스의 각 public 메서드에 대응하는 메서드들을 타깃 클래스에 생성한다. 이 메서드들은 단순히 작업을 소스 클래스로 위임해야 한다.
- 소스 클래스의 메서드를 사용하는 코드를 모두 타깃 클래스의 위임 메서드를 사용하도록 바꾼다. 하나씩 바꿀때 마다 테스트.
- 소스 클래스의 메서드와 필드를 모두 타깃 클래스로 옮긴다. 하나씩 옮길 때마다 테스트.
- 소스 클래스를 삭제하고 조의를 표한다.
예시
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, 위임 숨기기
- 모듈화 설계를 제대로 하는 핵심은 캡슐화이다.
- 위임 메서드를 만들어서 사용한다면, 위임 객체가 수정되더라도 클라이언트(사용하는)쪽에서는 아무런 영향을 받지 않는다.
절차
- 위임 객체의 각 메서드에 해당하는 위임 메서드를 서버에 생성한다.
- 클라이언트가 위임 객체 대신 서버를 호출하도록 수정한다. 하나씩 바꿀 때마다 테스트.
- 모두 수정했다면, 서버로부터 위임 객체를 얻는 접근자를 제거한다.
- 테스트.
예시
before
manager = aPerson.department.manager;
after
class Person {
get manager() {
return this.department.manager;
}
}
manager = aPerson.manager;
8. Remove Middle Man , 중개자 제거하기
- 클라이언트가 위임 객체의 다른 기능을 사용하고 싶을 때마다 위임 메서드를 추가해야 하는데, 이렇게 기능을 추가하다 보면 단순히 전달만 하는 위임 메서드들이 점점 성가셔진다.
- 서버 클래스는 그저 중개자 역할로 전락하여 차라리 클라이언트가 위임 객체를 직접 호출하는게 나을 수 있다.
- 디미터의 법칙을 너무 신봉할 때, 위와 같은 현상이 자주 나온다.
- 중개자를 사용할지 말지에 대한 적절한 판단이 쉽지는 않지만, 이 기준은 시기와 상황에 따라 다르다.
절차
- 위임 객체를 얻는 게터를 만든다.
- 위임 메서드를 호출하는 클라이언트가 모두 이 게터를 거치도록 수정한다. 하나씩 바꿀 때마다 테스트.
- 모두 수정했다면 위임 메서드를 삭제한다.
예시
before
class Person {
get manager() {
return this.department.manager;
}
}
manager = aPerson.manager;
after
manager = aPerson.department.manager;
9. Substitute Algorithm, 알고리즘 교체하기
- 기존의 복잡한 코드가 있다면 간명한 방식으로 변경할 방법이 있다면 간명한 방식으로 바꾼다.
- 내 코드와 똑같은 기능을 제공하는 라이브러리를 찾았을 때.
- 알고리즘을 살짝 다르게 동작하도록 바꾸고 싶을 때.
- 이 작업에 착수하려면 메서드를 가능한 잘게 나눴는지 확인하는게 좋다.
절차
- 교체할 코드를 함수 하나에 모은다.
- 이 함수만을 이용해 동작을 검증하는 테스트를 마련한다.
- 대체할 알고리즘을 준비한다.
- 정적 검사를 수행한다.
- 기존 알고리즘과 새 알고리즘의 결과를 비교하는 테스트를 수행한다. 두 결과가 같다면 리팩터링이 끝난다. 그렇지 않다면 기존 알고리즘을 참고해서 새 알고리즘을 테스트하고 디버깅한다.
예시
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)) || '';
}