Chapter 07. 캡슐화
✏️ 레코드 캡슐화하기
배경
- 대부분의 프로그래밍 언어는 데이터 레코드를 표현하는 구조를 제공한다.
- 이 구조에는 장, 단점이 있는데,
- 데이터를 직관적인 방식으로 묶을 수 있어서 의미 있는 단위로 전달할 수 있게 해준다는 장점이 있다.
- 계산해서 얻을 수 있는 값과 그렇지 않은 값을 명확히 저장해야 하는 단점이 있다.
- e.g.) { start: 1, end: 5, length: 5 } → 값의 범위에 해당하는 length는 연산 값일 수 있다.
- 이에 가변 데이터를 저장하는 용도로는 레코드보다 객체가 나을 수 있다.
- 객체를 사용하면 어떻게 저장했는지를 숨긴 채 각각의 값을 메서드로 제공할 수 있다.
- 레코드 구조는 두 가지로 구분할 수 있다.
- 필드 이름을 노출하는 형태
- (필드를 외부로부터 숨겨서) 내가 원하는 이름을 쓸 수 있는 형태
- 주로 라이브러리에서 해시, 맵, 해시맵, 딕셔너리 등의 이름으로 제공
- 이 중 해시맵은 다양한 프로그래밍 작업에 유용하지만 필드를 명확히 알려주지 않는다는 단점이 있다.
- 이러한 불투명한 레코드는 명시적인 레코드로 리팩터링해도 되지만, 클래스를 사용하거나 JSON과 같은 포맷으로 직렬화하는 방식이 나을 수 있다.
절차
- 레코드를 담은 변수를 캡슐화한다.
- 레코드를 감싼 단순한 클래스로 해당 변수의 내용을 교체한다.
- 원본 레코드 대신 새로 정의한 클래스 타입의 객체를 반환하는 함수들을 새로 만든다.
- 레코드를 반환하는 이전함수를 사용하는 코드를 새 함수로 변경한다. 필드에 접근할 때는 객체의 접근자를 사용한다.
- 클래스에서 원본 데이터를 반환하는 접근자와 원본 레코드를 반환하는 함수들을 제거한다.
- 레코드의 필드도 중첩 구조라면 위 과정을 재귀적으로 적용한다.
예시
Before
const organization = { name: "셜록", country: "GB" };
const newName = "처칠";
result+= `<h1>${organization.name}</h1>` // 읽기 예
organization.name = newName; // 쓰기 예
After
- 레코드를 클래스로 변경
- 레코드를 읽는 코드는 게터로 변경
- 레코드를 갱신하는 코드는 세터로 변경
- 새 클래스의 인스턴스를 반환하는 함수 생성
class Organization {
constructor(data) {
this._data = data;
this._country = country;
}
get name() { return this._data.name; }
get country() { return this._country; }
set name(aString) { this._data.name = aString; }
set country(aCountryCode) { this._country = aCountryCode; }
}
const organization = new Organization({ name: "셜록", country: "GB" });
function getOrganization() {return organization;}
✏️ 컬렉션 캡슐화하기
배경
- 컬렉션 변수로의 접근을 캡슐화하면서 게터가 컬렉션 자체를 반환하도록 한다면, 그 컬렉션을 감싼 클래스가 눈치채지 못하는 상태에서 컬렉션의 원소들이 바뀌어버릴 수 있다.
- 내부 컬렉션을 직접 수정하지 못하게 막는 방법
- 절대 컬렉션 값을 반환하지 않게 하기
- 컬렉션에 접근하려면 컬렉션이 소속된 클래스의 적절한 메서드를 반드시 거치게 하는 것이다.
- aCustomer.order.size() => aCustomer.numberOfOrder()
- 저자는 이 방식에 동의하지 않는다고 한다.
- 컬렉션 읽기전용으로 제공하기
- 컬렉션 게터를 제공하되 내부 컬렉션의 복제본 반환하기
- 복제본을 수정해도 캡슐화된 원본 컬렉션에는 아무런 영향을 주지 않는다.
- 절대 컬렉션 값을 반환하지 않게 하기
절차
- 아직 컬렉션을 캡슐화하지 않았다면 변수 캡슐화하기부터 한다.
- 컬렉션에 원소를 추가/제거하는 함수를 추가한다.
- 컬렉션을 참조하는 부분을 모두 찾는다. 그리고 앞에서 추가한 추가/제거 함수를 호출하도록 수정한다.
- 컬렉션 게터를 수정해 원본 내용을 수정할 수 없는 읽기전용 프록시나 복제본을 반환하게 한다.
예시
Before
// 코드 내용 : 수업 목록을 필드로 지니고 있는 Person 클래스, 코스 정보를 지니고 있는 Course 클래스
class Person {
constructor(name) {
this._name = name;
this._courses = [];
}
get name() { return this._name; }
get courses() { return this._courses; }
set courses(courses) { this._courses = courses; }
}
class Course {
constructor(name, isAdvanced) {
this._name = name;
this._isAdvanced = isAdvanced;
}
get name() { return this._name; }
get isAdvanced() { return this._isAdvanced; }
}
// 클라이언트
advancedCoursesLength = aPerson.courses.filter((course) => course.isAdvanced).length;
// 🔥 수업 컬렉션을 통째로 설정
const basicCourseNames = readBasicCourseNames(filename);
aPerson.courses = basicCourseNames.map((name) => new Course(name, false));
// 🔥 수업 목록을 직접 수정
for(const name of readBasicCourseNames(filename)) {
aPerson.courses.push(new Course(name, false));
}
After
- 수업을 하나씩 추가하고 제거하는 메서드를 Person 클래스에 추가
- 직접 접근하는 대신 추가/제거 메서드를 활용해 데이터를 제어하도록 수정
class Person {
constructor(name) {
this._name = name;
this._courses = [];
}
get name() { return this._name; }
// 📍 복제본 제공
get courses() { return this._courses.slice(); }
set courses(list) { this._courses = list.slice(); }
addCourse(course) { this._courses.push(course); }
removeCourse(course, runIfAbsent){
const index = this._courses.indexOf(course);
if (index === -1) {
runIfAbsent();
return;
}
this._courses.splice(index, 1);
}
}
// 클라이언트
const refactoringCourse = new Course('리팩토링', true);
for(const name of readBasicCourseNames(filename)) {
aPerson.addCourse(new Course(name, false));
}
aPerson.addCourse(course);
aPerson.removeCourse(course, () => console.log('제거 완료!'));
Deep dive
- 포인트는 데이터에 직접 접근하지 못하게 하는 것 + 원본은 유지하고 복제본으로 제어하는 것!
✏️ 기본형을 객체로 바꾸기
배경
- 처음에 간단한 데이터 항목으로 표현하던 코드들에 여러 동작들이 추가되기 시작하면 금새 중복 코드가 늘어나기 마련이다.
- 단순한 출력 이상의 기능이 필요해지는 순간 그 데이터를 표현하는 전용 클래스를 정의하는 것이 좋다.
- 프로그램이 커질수록 클래스는 그 빛을 발하게 된다.
절차
- 아직 변수를 캡슐화하지 않았다면 캡슐화한다.
- 단순한 값 클래스를 만든다.
- 값 클래스의 인스턴스를 새로 만들어 필드에 저장하도록 세터를 수정한다.
- 새로 만든 클래스의 게터를 호출한 결과를 반환하도록 게터를 수정한다.
- 함수 이름을 바꾸면 원본 접근자의 동작을 더 잘 드러낼 수 있는지 검토한다.
예시
Before
// 코드 내용: 우선순위 클래스와 우선순위를 필터링해 최우선순위 개수를 반환하는 클라이언트 함수
class Order {
constructor(data) {
this.priority = data.priority;
}
...
}
// 클라이언트
const highPriorityCount = orders.filter((order) =>
'high' === order.priority || 'rush' === order.priority
).length;
After
- Order는 우선순위 클래스에서 값을 가져오는 값 클래스로 변경
- Priority에는 값 검증, 비교 및 연산 로직을 담도록 작성
// 확장 버전
class Order {
...
get priority() { return this._priority; }
get priorityString() { return this._priority.toString(); }
set priority(aString) { this._priority = new Priority(aString); }
}
class Priority {
constructor(value) {
// 📍 검증 로직
if (value instanseof Priority) return value;
if (Priority.legalValues().includes(value)) {
this._value = value;
} else {
throw new Error(`${value}는 유효하지 않은 우선순위입니다.`)
}
}
get _index() { return Priority.legalValues().findIndex(s => s === this._value); }
// 📍 비교 로직
static legalValues() { return ['low', 'normal', 'high', 'rush']; }
toString() { return this._value; }
equals(other) { return this._index === other._index; }
higherThan(other) { return this._index > other._index; }
lowerThan(other) { return this._index < other._index; }
}
// 📍 활용 예시
const highPriorityCount = orders.filter((order) =>
order.priority.higherThan(new Priority('normal'))).length;
Deep dive
- 위와 같이 확장성을 고려해 여러 비교, 검증 로직을 추가하는 것도 좋지만 간단할 경우 아래와 같이 작성해도 좋지 않을까?
class Order {
constructor(data) {
this.priority = data.priority;
}
isHighPriority(){
return 'high' === this.priority || 'rush' === this.priority
}
}
const highPriorityCount = orders.filter((order) => order.isHighPriority()).length;
✏️ 임시 변수를 질의 함수로 바꾸기
배경
- 함수 안에서 어떤 코드의 결괏값을 뒤에서 다시 참조할 목적으로 임시 변수를 쓰기도 한다.
- 이를 통해 코드의 반복을 줄이고 값의 의미를 설명할 수 있어 유용하다.
- 그런데 한 걸음 더 나아가 아예 함수로 만드는 게 나을 수도 있다.
- 긴 함수의 한 부분을 별도 함수로 추출하고자 할 때 먼저 변수들을 각각의 함수로 만들면 일이 수월해진다.
- 의존 관계나 부수효과 파악 용이
- 코드 중복 제거
- 임시 변수를 질의 함수로 바꾼다고 다 좋아지는 건 아니다.
- 특히 ‘옛날 주소’처럼 스냅샷 용도로 쓰이는 변수에는 이 리팩토링을 적용하면 안 된다.
절차
- 변수가 사용되기 전에 값이 확실히 결정되는지 확인한다.
- 읽기 전용으로 만들 수 있는 변수는 읽기 전용으로 만든다.
- 변수 대입문을 함수로 추출한다.
- 변수 인라인하기로 임시 변수를 제거한다.
예시
Before
// 코드 내용: 할인율에 따라 가격을 연산하는 Order 클래스
class Order {
constructor(quantity, item) {
this._quantity = quantity;
this._item = item;
}
get price() {
const basePrice = this._quantity * this._item.price;
const discountFactor = 0.98;
if (basePrice > 1000) discountFactor -= 0.03;
return basePrice * discountFactor;
}
}
After
- 임시 변수인 basePrice와 discountFactor을 메서드로 변환
- 게터 함수로 변환
- 기존 변수를 제거하고 추출한 함수 대입
class Order {
constructor(quantity, item) {
this._quantity = quantity;
this._item = item;
}
get basePrice() {
return this._quantity * this._item.price;
}
get discountFactor(){
return this.basePrice > 1000 ? 0.95 : 0.98
}
get price() {
return this.basePrice * this.discountFactor;
}
}
✏️ 클래스 추출하기
배경
- 메서드와 데이터가 너무 많은 클래스는 이해하기 쉽지 않으니 잘 살펴보고 적절히 분리하는 것이 좋다.
- 특히 일부 데이터와 메서드를 따로 묶을 수 있다면 어서 분리하라는 신호다.
- 함께 변경되는 일이 많거나 서로 의존하는 데이터들도 분리한다.
- 제거해도 다른 필드나 메서드들이 논리적으로 문제가 없다면 분리할 수 있다는 뜻이다.
절차
- 클래스의 역할을 분리할 방법을 정하고 분리될 역할을 담당할 클래스를 새로 만든다.
- 원래 클래스의 생성자에서 새로운 클래스의 인스턴스를 생성해 필드에 저장해둔다.
- 분리될 역할에 필요한 필드들을 새 클래스로 옮긴다.
- 메서드들도 새 클래스로 옮긴다.
- 양쪽 클래스의 인터페이스를 살펴보며 불필요한 메서드를 제거하고 이름을 맞게 변경한다.
예시
Before
// 클래스 내용: 이름, 전화번호, 직장 주소 코드, 직장 전화번호가 담긴 사람 클래스
// 목표 : 전화번호 관련 동작을 별도 클래스로 추출하기
class Person {
...
get name() { return this._name; }
set name(arg) { this._name = arg; }
get telephoneNumber() { return `(${this.officeAreaCode}) ${this.officeNumber}`; }
get officeAreaCode() { return this._officeAreaCode; }
set officeAreaCode(arg) { this._officeAreaCode = arg; }
get officeNumber() { return this._officeNumber; }
set officeNumber(arg) { this._officeNumber = arg; }
}
After
- 전화번호 관련 메서드를 TelephoneNumber 클래스로 이동
- 이동한 메서드는 순수한 번호를 뜻하므로 office를 제거한 네이밍으로 변경 (함수 선언 바꾸기)
- 전화번호를 읽기 좋은 포맷으로 출력하는 메서드 추가
class Person {
constructor {
this._telephoneNumber = new TelephoneNumber();
}
get officeAreaCode() {return this._telephoneNumber.areaCode;}
set officeAreaCode(arg) {return this._telephoneNumber.areaCode = arg;}
get officeNumber() {return this._telephoneNumber.number;}
set officeNumber(arg) {return this._telephoneNumber.number = arg;}
get telephoneNumber() {return this._telephoneNumber.toString;}
}
class TelephoneNumber {
...
get areaCode() {return this._areaCode;}
set areaCode(arg) {this._areaCode = arg;}
get number() {return this._number;}
set number(arg) {this._number = arg;}
get toString() {return `${this._areaCode} ${this._number}`}
}
✏️ 클래스 인라인하기
배경
- 클래스 인라인하기는 클래스 추출하기의 반대 리팩터링 기법이다.
- 역할을 옮긴 후 특정 클래스에 남은 역할이 거의 없을 때
- 가장 많이 사용하는 클래스로 흡수시킨다.
- 또는 두 클래스의 기능을 지금과 다르게 배분하고 싶을 때도 인라인하면 좋다.
절차
- 소스 클래스의 각 public 메서드에 대응하는 메서드들을 타깃 클래스에 생성한다.
- 소스 클래스의 메서드를 사용하는 코드를 모두 타깃 클래스의 위임 메서드를 사용하도록 바꾼다.
- 소스 클래스의 메서드와 필드를 모두 타깃 클래스로 옮긴다.
- 소스 클래스를 제거한다.
- 조의를 표한다 🙏🏻
예시
Before
// 코드 내용: 배송 추적 정보를 표현하는 TrackingInformation 클래스와
// 이를 일부처럼 사용하는 Shipment 클래스
// 목표: Shipment 클래스로 인라인하기
class TrackingInformation {
...
get shippingCompany() { return this._shippingCompany; } // 배송 회사
set shippingCompany(arg) { this._shippingCompany = arg; }
get trackingNumber() { return this._trackingNumber; } // 추적 번호
set trackingNumber(arg) { this._trackingNumber = arg; }
get display() {
return `${this.shippingCompany}: ${this.trackingNumber}`;
}
}
class Shipment {
...
get trackingInfo() { return this._trackingInformation.display; }
get trackingInformation() { return this._trackingInformation; }
set trackingInformation(trackingInformation) {
this._trackingInformation = trackingInformation;
}
}
After
- 외부에서 직접 호출하는 TrackingInformation 메서드들을 Shipment로 옮긴다.
- 이 과정을 반복한 후 TrackingInformation 클래스를 제거한다.
class Shipment {
...
get trackingInfo() { return `${this.shippingCompany}: ${this.trackingNumber}`;}
get shippingCompany() { return this._shippingCompany; }
set shippingCompany(arg) { this._shippingCompany = arg; }
get trackingNumber() { return this._trackingNumber; }
set trackingNumber(arg) { this._trackingNumber = arg; }
}
✏️ 위임 숨기기
배경
- 모듈화 설계를 제대로 하는 핵심은 캡슐화다.
- 캡슐화는 모듈들이 시스템의 다른 부분에 대해 알아야 할 내용을 줄여준다.
- 캡슐화는 필드를 숨기는 것 이상의 역할을 한다.
- 서버 객체의 필드를 가리키는 객체(위임 객체)의 메서드를 호출하려면 클라이언트는 이 위임 객체를 알아야 한다.
- 이 때 의존성이 생기게 되는데, 이러한 의존성을 없애려면 서버 자체에 위임 메서드를 만들어 위임 객체의 존재를 숨기면 된다.
- 그러면 위임 객체가 수정되더라도 서버 코드만 고치면 되며, 클라이언트는 아무런 영향을 받지 않는다.
- 일종의 수정 범위(depth) 줄이기?
절차
- 위임 객체의 각 메서드에 해당하는 위임 메서드를 서버에 생성한다.
- 클라이언트가 위임 객체 대신 서버를 호출하도록 수정한다.
- 모두 수정했다면 서버로부터 위임 객체를 얻는 접근자를 제거한다.
예시
Before
// 목표 : 클라이언트에서 어떤 사람이 속한 부서의 관리자 얻기
class Person {
constructor(name) {
this._name = name;
}
get name() { return this._name; }
get department() { return this._department; }
set department(arg) { this._department = arg; }
}
class Department {
...
get chargeCode() { return this._chargeCode; }
get manager() { return this._manager; }
set chargeCode(arg) { this._chargeCode = arg; }
set manager(arg) { this._manager = arg; }
}
// 클라이언트
manager = aPerson.department.manager;
// 동작 방식
get departmet() => class Department => get manager()
After
- Person 클래스에 위임 메서드인 manager()를 생성해 값 제공
class Person {
...
get manager() { return this._department.manager; }
}
// 클라이언트
manager = aPerson.manager;
✏️ 중개자 제거하기
배경
- 단순히 전달만 하는 위임 메서드를 추가해 사용하게 되면 서버 클래스는 중개자(middle man) 역할로 전락할 수 있다.
- 이 때는 차라리 클라이언트가 위임 객체를 직접 호출하는 게 나을 수 있다.
데메테르 법칙 (Law of Demeter), LOD
- 또 다른 이름으로는 최소 지식 원칙(Principle of least knowledge)라고 한다.
- 내부 정보를 가능한 한 숨기고 밀접한 모듈과만 상호작용하여 결합도를 낮추자는 원칙.
- 모듈은 자신이 조작하는 객체의 속사정을 몰라야 한다는 것을 의미
- 남용 시 이 과정에서 위임 혹은 래퍼(wrapper) 메서드가 너무 늘어나는 등의 부작용이 있을 수 있다.
- A에서 C의 정보를 얻어 오기 위한 방법은 C에서 B로, B에서 A로 값을 캡슐화하여 값을 가져오도록 한다.
절차
- 위임 객체를 얻는 게터를 만든다.
- 위임 메서드를 호출하는 클라이언트가 모두 이 게터를 거치도록 수정한다.
- 모두 수정했다면 위임 메서드를 제거한다.
예시
Before
// 코드 내용 : 자신이 속한 부서 객체를 통해 관리자를 찾는 사람 클래스를 통해 관리자 호출
manager = aPerson.manager;
class Person {
...
get manager() { return this._department.manager; }
}
class Department {
...
get manager() { return this._manager; }
}
After
// Person 클래스에서 직접 department 위임 객체를 얻어 manager 호출 (중개자 제거)
class Person {
...
get department() { return this._department; }
}
manager = aPerson.department.manager;
✏️ 알고리즘 교체하기
배경
- 어떤 목적을 달성하는 방법은 여러 가지가 있게 마련이다.
- 알고리즘도 마찬가지다.
- 리팩터링하면 복잡한 대상을 단순한 단위로 나눌 수 있지만,
- 때로는 알고리즘 전체를 걷어내고 훨씬 간결한 알고리즘으로 바꿔야 할 때가 있다.
- 이 작업에 착수하려면 반드시 메서드를 가능한 한 잘게 나눴는지 확인해야 한다.
- 거대한 알고리즘을 교체하기란 상당히 어려우니 먼저 간소화하는 작업부터 해야 교체가 쉬워진다.
절차
- 교체할 코드를 함수 하나에 모은다.
- 이 함수만을 이용해 동작이 정상 작동하는지 테스트한 후 대체할 알고리즘을 준비한다.
- 기존 알고리즘과 새 알고리즘의 결과를 비교한 후 같다면 교체한다.
예시
Before
// 함수 내용: people을 순회하면서 해당 이름에 해당하는 string 반환
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
// 추출할 값을 배열로 생성 후 컬렉션 파이프라인(find, includes)으로 대체
function foundPerson(people) {
const candidates = ['Don','John','Kent'];
return people.find((p) => candidates.includes(p) || '');
}
반응형
'Programming > 13. Book' 카테고리의 다른 글
모던 리액트 Deep Dive - 12장 (0) | 2024.03.03 |
---|---|
리팩터링 - 12장 (0) | 2024.03.02 |
리팩터링 - 11장 (0) | 2024.03.02 |
리팩터링 - 10장 (0) | 2024.03.02 |
리팩터링 - 8, 9장 (0) | 2024.03.02 |
댓글