시작하며
- 각 모듈이 드러내지 않아야 할 비밀을 얼마나 잘 숨기느냐 ⇒ 모듈을 분리하는 가장 중요한 기준
- 클래스는 본래 정보를 숨기는 용도로 설계되었다.
- 클래스는 내부 정보뿐 아니라 클래스 사이의 연결 관계를 숨기는 데도 유용하다.
- 함수 또한 구현을 캡슐화한다.
7.1 레코드 캡슐화하기 Encapsulate Record
// **AS-IS: 레코드(객체 리터럴)**
const organization = {name: "루미", country: "KR"};
// **TO-BE: 클래스(책에서 말하는 '객체' 예시)**
// 레코드를 캡슐화하는 목적은 변수 자체는 물론, 내용을 조작하는 방식도 통제하기 위함
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;
}
}
- 객체는 어떻게 저장했는지 숨긴 채 값들을 각각의 메서드로 제공할 수 있다.
- 가변 데이터는 객체로 저장하고, 값이 불변이면 레코드에 저장한다.
- 레코드는 연관된 여러 데이터를 직관적으로 묶을 수 있다.
- ex. 필드이름 노출하는 경우:
{name: ‘루미’, country: ‘KR’}
- ex. 필드이름 숨긴 경우:
new Map(…)
⇒ 길이 등을 직접 확인해야 한다.
- ex. 필드이름 노출하는 경우:
// **AS-IS: 중첩된 레코드**
// 중첩 정도가 심할수록 체이닝이 심해진다.
// data[id].usages[year][month]...
'1994': {
name: '루미',
usages: {
'2016': {
'1': 50,
'2': 55
}
}
},
...
// **TO-BE:**
// 아래에서 rawData 메서드를 통해 내부 데이터를 복제해 사용하므로,
// 사용하는 개발자(클라이언트)가 데이터를 직접 수정할 수는 없게 된다.
class CustomerData {
get rawData() {
return _.cloneDeep(this._data);
}
constructor(data) {
this._data = data;
}
}
function getCustomerData() {
return customerData;
}
function getRawDataOfCustomers() {
return customerData.rawData;
}
function setRawDataOfCustomers(arg) {
customerData = new CustomerData(arg);
}
function compareUsage(id, year, month) {
const later = getCustomerData().rawData[id].usages[year][month];
const earlier = getCustomerData().rawData[id].usages[year - 1][month];
return {
laterAmount: later,
change: later - earlier
};
}
-
위 TO-BE는 데이터 구조가 클수록 복제 비용이 커져 성능이 느려질 수도 있고, 클라이언트가 원본을 수정한다고 착각할 수 있다.
-
해결방법: 읽기전용 프락시를 제공하기
const target = { message1: "hello", message2: "everyone", }; const handler1 = {}; const proxy1 = new Proxy(target, handler1);
-
새로운 방법: 레코드 캡슐화를 재귀적으로 하기
- http://martinfowler.com/articles/refactoring-document-load.html
- 이 방법도 데이터 구조가 거대하면 일이 커진다.
- 그 데이터 구조를 사용할 일이 많지 않으면 효과도 별로 없다.
-
7.2 컬렉션 캡슐화하기 Encapsulate Collection
// AS-IS:
// getter가 컬렉션 자체를 반환하기 때문에
// 클래스가 눈치채지 못한 상태에서 컬렉션의 원소들이 바뀔 수 있다.
class Person {
get courses() { return this._courses; }
set courses(list) { this._courses = list; }
}
// TO-BE:
// getter가 컬렉션의 복제본을 만들어 반환한다.
// 컬렉션을 소유한 클래스를 통해서만 원소를 변경하게 만들 수 있다.
class Person {
get courses() { return this._courses.slice(); }
addCourse(course) { ... }
removeCourse(course) { ... }
}
- 내부 컬렉션의 복제본을 반환하는 방식은 널리 알려져 있다.
- 컬렉션이 상당히 크면 성능 문제가 발생할 수 있지만, 그런 경우는 거의 없다.
- 저자의 경험에 따르면 컬렉션은 어느정도 강박증을 갖고 불필요한 복제본을 만드는 편이, 예상치 못한 오류들을 디버깅하는 것보다 낫다고 한다.
다른 언어와 달리 자바스크립트는 배열을 정렬할 때 원본을 수정하기 때문!
7.3 기본형을 객체로 바꾸기 Replace Primitive with Query
// AS-IS:
orders.filter((o) => "high" === o.priority || "rush" === o.priority).length;
// TO-BE:
// Order를 통해서 Priority 객체를 제공받도록 해서 Priority를 직접 건드리지 않는다.
// 우선순위 값들 또한 Priority의 메서드로만 조작해 반환하도록 한다.
orders.filter((o) => o.priority.higherThan(new Priority("normal"))).length;
class Order {
get priority() {
return this._priority;
}
get priorityString() {
return this._priority.toString();
}
set priority(string) {
this._priority = new Priority(string);
}
}
class Priority {
constructor(value) {
if (value instanceof Priority) return value;
this._value = value;
}
toString() {
return this._value;
}
get _index() {
return Priority.legalValues().findIndex((s) => s === this._value);
}
static legalValues() {
return ["low", "normal", "high", "rush"];
}
equals(other) {
return this._index === other._index;
}
higherThan(other) {
return this._index > other._index;
}
lowerThan(other) {
return this._index < other._index;
}
}
- 기본형으로 간단히 표현했던 정보들이 개발 도중 간단하지 않게 변할 수 있다.
ex. 전화번호를 문자열로 표현했는데 추후 포매팅이나 지역 코드 추출 같은 특별한 동작이 필요해짐 - 이런 로직들로 금세 중복 코드가 늘어나서 사용할 때마다 드는 노력도 늘어나게 된다.
- 저자는 단순 출력 이상의 기능이 필요해지는 순간 전용 클래스를 정의하는 편이라고 한다.
7.4 임시 변수를 질의 함수로 바꾸기 Replace Temp with Query
// (Order class 내부)
// AS-IS:
// 여기서 임시 변수는 basePrice와 discountFactor이다.
get price() {
var basePrice = this._quantity * this._item.price;
var discountFactor = 0.98;
if (basePrice > 1000) discountFactor -= 0.03;
return basePrice * discountFactor;
}
// TO-BE:
// 이제 다른 곳에서도 basePrice, discountFactor를 사용할 수 있다.
get basePrice() {
return this._quantity * this._item.price;
}
get discountFactor() {
var discountFactor = 0.98;
if (basePrice > 1000) discountFactor -= 0.03;
return discountFactor;
}
get price() {
return this.basePrice * this.discountFactor;
}
- 함수 안에서 어떤 코드의 결과를 다시 참조할 목적으로 임시 변수를 쓰기도 한다.
- 그러나 한 걸음 나아가 아예 함수로 만들어 사용하는 편이 나을 때가 많다.
- 변수 대신 함수로 만들면 비슷한 계산을 수행하는 다른 함수에서도 사용할 수 있어 코드 중복이 줄어든다.
- 특히 클래스 안에서 적용할 때 효과가 큰데, 클래스는 추출할 메서드들에 공유 컨텍스트를 제공하기 때문이다.
- 변수에 값을 한 번 대입한 후, 더 복잡한 코드에서 여러 번 다시 대입하고, 변수가 다음번에 사용될 때 수행해도 똑같은 결과를 내야 할 때만 유효한 리팩터링 기법이다.
7.5 클래스 추출하기 Extract Class
// AS-IS:
class Person {
get officeAreaCode() {
return this._officeAreaCode;
}
get officeNumber() {
return this._officeNumber;
}
}
// TO-BE:
// 이제 전화번호를 회사 뿐만 아니라 다른 곳에서도 사용할 수 있다.
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;
}
}
- 메서드와 데이터가 너무 많은 클래스는 이해하기 쉽지않으니 적절히 분리하는 것이 좋다.
- 언제 클래스를 추출해야 할까요?
⇒ 특정 데이터/메서드 일부를 제거했을 때 다른 곳에서 논리적으로 문제가 없다면 분리할 수 있다는 뜻. - 일부 데이터와 메서드를 따로 묶을 수 있을 때 - 함께 변경되는 일이 많거나 서로 의존하는 데이터들일 때
7.6 클래스 인라인하기 Inline Class
// AS-IS:
class Person {
get officeAreaCode() {
return this._telephoneNumber.areaCode;
}
get officeNumber() {
return this._telephoneNumber.number;
}
}
// TO-BE:
// telephoneNumber를 회사번호로만 사용하고 있다면 아래처럼 합치는 편이 낫다.
class Person {
get officeAreaCode() {
return this._officeAreaCode;
}
get officeNumber() {
return this._officeNumber;
}
}
- 클래스 인라인하기는 클래스 추출하기를 거꾸로 돌리는 리팩터링 기법이다.
- 언제 클래스를 인라인해야 할까요?
- 특정 클래스가 더 이상 제 역할을 하지 못할 때 많이 사용하는 클래스로 흡수시킨다.
- 두 클래스 기능을 지금과 다르게 배분하고 싶을 때
(한 컨텍스트의 요소들을 다른 쪽으로 하나씩 옮겨 인라인한 후 추출하여 다시 분리)
7.7 위임 숨기기 Hide Delegate
// AS-IS:
manager = aPerson.department.manager;
// TO-BE:
manager = aPerson.manager;
class Person {
get manager() {
return this.department.manager;
}
}
- 서버 객체의 필드가 가리키는 객체(위임 객체)의 메서드를 호출하려면 클라이언트는 이 객체를 알아야 한다.
- 위임 객체의 인터페이스가 바뀌면 이 인터페이스를 사용하는 모든 클라이언트가 코드를 수정해야 한다.
- 이 의존성을 없애려면 서버 자체에 위임 메서드를 만들어서 위임 객체의 존재를 숨기면 된다.
- 그러면 위임 객체가 수정되어도 서버 코드만 고치면 된다.
7.8 중개자 제거하기 Remove Middle Man
// AS-IS:
manager = aPerson.manager;
class Person {
get manager() {
return this.department.manager;
}
}
// TO-BE:
manager = aPerson.department.manager;
- 위임 객체를 캡슐화하면 7.7절과 같은 이점이 있으나, 적절하지 못할 때도 있다.
- 클라이언트가 위임 객체의 다른 기능을 사용할 때마다 서버에 위임 메서드를 추가해야 하기 때문이다.
- 그러면 서버 클래스는 단순히 전달만 하는 메서드가 많아져서 그저 중개자 역할로 전락한다.
- 이때는 클라이언트가 위임 객체를 직접 호출하는 편이 낫다.
- 이처럼 언제든지 필요하면 균형점을 옮겨 위임을 숨기거나 중개자를 제거하자.
7.9 알고리즘 교체하기 Substitue Algorithm
// AS-IS:
function foundPerson(people) {
for (let i = 0; i < people.length; i++) {
if (people[i] === "dawn") {
return "dawn";
}
if (people[i] === "daisy") {
return "daisy";
}
if (people[i] === "lena") {
return "lena";
}
}
return "";
}
// TO-BE:
function foundPerson(people) {
const candidates = ["dawn", "daisy", "lena"];
return people.find((p) => candidates.includes(p)) || "";
}
- 어떤 목적을 달성하는 방법은 여러 가지가 있다.
- 하지만 때로는 알고리즘 전체를 걷어내고 훨씬 간결한 알고리즘으로 바꿔야 할 때가 있다.
[절차]
-
교체할 코드를 함수 하나에 모은다.
-
이 함수만을 이용해 동작을 검증하는 테스트를 마련한다.
-
대체할 알고리즘을 준비한다.
-
정적 검사를 수행한다.
(컴파일 단계에서 타입 에러는 없는지 등을 확인하라는 뜻인듯) -
기존과 새 알고리즘의 결과를 비교하는 테스트를 수행한다. 두 결과가 같다면 리팩터링이 끝난다. 그렇지 않다면 기존 알고리즘을 참고해서 새 알고리즘을 테스트하고 디버깅한다.