March 24, 2022 • ☕️☕️ 10 min read
캡슐화
모듈을 분리할 때는 각 모듈이 자신을 제외한 다른 부분에 드러내지 않아야 할 비밀을 잘 숨겨야 한다. 이때 레코드 캡슐화, 컬렉션 캡슐화, 기본형을 객체로 바꿔 캡슐화하는 방법 등이 많이 쓰인다.
클래스를 이용하면 내부 정보를 숨길 수 있을 뿐 아니라 위임 숨기기를 통해 클래스 사이의 연결 관계를 숨길 수도 있다. 알고리즘을 함수로 추출하여 구현을 캡슐화하는 방법도 있다.
가변 데이터를 저장할 때는 레코드보다 객체를 선호한다. 객체를 사용하면 어떻게 저장했는지를 숨긴 채 각각의 값을 서로 다른 메서드로 제공할 수 있다.
레코드 구조는 필드 이름을 노출하는 형태와 필드를 외부로부터 숨겨서 원하는 이름을 쓸 수 있는 형태 두 가지로 구분할 수 있다. 후자는 주로 라이브러리에서 해시(hash), 맵(map), 해시맵(hashmap), 딕셔너리(dictionary), 연관 배열(associative array) 등의 이름으로 제공한다.
// before
const organization = { name: "에크미 구스베리", country: "GB" };
result += `<h1>${organization.name}</h1>`; // 읽기 예
organization.name = newName; // 쓰기 예
레코드를 캡슐화하는 목적은 변수 자체는 물론 그 내용을 조작하는 방식도 통제하기 위함이므로, 레코드를 클래스로 바꾼다.
class Organization {
constructor(data) {
this._name = data.name;
this._country = data.country;
}
set name(aString) {
this._data.name = aString;
}
get name() {
return this._data.name;
}
}
const organization = new Organization({
name: "에크미 구스베리",
country: "GB",
});
function getOrganization() {
return organization;
}
getOrganization().name = newName;
result += `<h1>${getOrganization().name}</h1>`;
컬렉션 변수로의 접근을 캡슐화하면서 게터가 컬렉션 자체를 반환하도록 한다면, 그 컬렉션을 감싼 클래스가 눈치채지 못하는 상태에서 컬렉션의 원소들이 바뀌어버릴 수 있다. 컬렉션 게터가 원본 컬렉션을 반환하지 않게 만들어서 클라이언트가 실수로 컬렉션을 바꿀 가능성을 차단하는 것이 좋다.
내부 컬렉션을 직접 수정하지 못하게 막는 방법 중 하나로, 절대로 컬렉션 값을 반환하지 않게 할 수 있다. 컬렉션에 접근하려면 컬렉션이 소속된 클래스의 적절한 메서드를 반드시 거치게 하는 것이다.
또 다른 방법은 컬렉션을 읽기전용으로 제공할 수 있다. 프락시가 내부 컬렉션을 읽는 연산은 그대로 전달하고, 쓰기는 모두 막는 것이다.
가장 흔히 사용하는 방식은 아마도 컬렉션 게터를 제공하되 내부 컬렉션의 복제본을 반환하는 것이다. 복제본을 수정해도 캡슐화된 원본 컬렉션에는 아무런 영향을 주지 않는다.
여기서 중요한 점은 코드베이스에 일관성을 주는 것이다. 컬렉션 접근 함수의 동작 방식을 통일해야 한다.
// before
class Person {
constructor(name) {
this._name = name;
this._courses = [];
}
get name() {
return this._name;
}
get courses() {
return this._courses;
}
set courses(aList) {
this._courses = aList;
}
}
class Course {
constructor(name, isAdvanced) {
this._name = name;
this._isAdvanced = isAdvanced;
}
get name() {
return this._name;
}
get isAdvanced() {
return this._isAdvanced;
}
}
모든 필드가 접근자 메서드로 보호받고 있으나, 세터를 이용해 수업 컬렉션을 통째로 설정한 클라이언트는 누구든 이 컬렉션을 마음대로 수정할 수 있다. 캡슐화가 깨지는 것이다.
// after
class Person {
// setter 제거
get courses() {
return this._courses.slice();
}
addCourse(aCourse) {
this._courses.push(aCourse);
}
removeCourse(
aCourse,
fnIfAbsent = () => {
throw new RangeError();
}
) {
const index = this._courses.indexOf(aCourse);
if (index === -1) fnIfAbsent();
else this._courses.splice(index, 1);
}
}
단순한 출력 이상의 기능이 필요해지는 순간 그 데이터를 표현하는 전용 클래스를 정의한다.
// before
class Order {
constructor(data) {
this.priority = data.priority;
// ...
}
}
// 클라이언트
const highPriorityCount = orders.filter(
(o) => "high" === o.priority || "rush" === o.priority
).length;
// after
class Order {
get priority() {
return this._priority;
}
set priority(aString) {
this._priority = new Priority(aString);
}
// ...
}
class Priority {
constructor(value) {
this._value = value;
}
toString() {
return this._value;
}
}
const highPriorityCount = orders.filter(
(o) => "high" === o.priority.toString() || "rush" === o.priority.toString()
).length;
이렇게 하면 Priority
클래스를 새로운 동작을 담는 장소로 활용할 수 있게 된다
임시 변수를 사용하면 코드의 반복을 줄이고 값의 의미를 설명할 수도 있어 유용하다. 여기서 한 걸음 더 나아가 아예 함수로 만들어 사용하는 편이 나을 때가 많다.
변수 대신 함수로 만들어두면 비슷한 계산을 수행하는 다른 함수에서도 사용할 수 있어 코드 중복이 줄어든다. 특히 추출할 메서드들에 공유 컨텍스트를 제공하는 클래스 안에서 적용할 때 효과가 가장 크다.
// before
class Order {
constructor(quantity, item) {
this._quantity = quantity;
this._item = item;
}
get price() {
var basePrice = this._quantity * this._item.price;
var discountFactor = 0.98;
if (basePrice > 1000) discountFactor -= 0.03;
return basePrice * discountFactor;
}
// after
class Order {
constructor(quantity, item) {
this._quantity = quantity;
this._item = item;
}
get price() {
return this.basePrice * this.discountFactor;
}
get basePrice() {
return this._quantity * this._item.price;
}
get discountFactor() {
var discountFactor = 0.98;
if (basePrice > 1000) discountFactor -= 0.03;
return 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
class Person {
constructor() {
this._telephoneNumber = new TelephoneNumber();
}
get name() {
return this._name;
}
set name(arg) {
this._name = arg;
}
get telephoneNumber() {
return this._telephoneNumber.toString();
}
get areaCode() {
return this._telephoneNumber.areaCode;
}
set areaCode(arg) {
this._telephoneNumber.areaCode = arg;
}
get officeNumber() {
return this._telephoneNumber.number;
}
set officeNumber(arg) {
this._telephoneNumber.number = arg;
}
}
class TelephoneNumber {
get areaCode() {
return this._areaCode;
}
set areaCode(arg) {
this._areaCode = arg;
}
get number() {
return this._number;
}
set number(arg) {
this._number = arg;
}
toString() {
return `(${this.areaCode}) ${this.number}`;
}
}
클래스 인라인하기는 클래스 추출하기를 거꾸로 돌리는 리팩터링이다.
더 이상 제 역할을 못 해서 그대로 두면 안 되는 클래스는 인라인한다. 두 클래스의 기능을 지금과 다르게 배분하고 싶을 때도 클래스를 인라인한다.
// before
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(aTrackingInformation) {
this._trackingInformation = aTrackingInformation;
}
}
TrackingInformation
이 현재는 제 역할을 못 하고 있으니 Shipment
클래스로 인라인한다.
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;
}
}
TrackingInformation
클래스는 삭제한다.
모듈화 설계를 제대로 하는 핵심은 캡슐화다. 캡슐화는 모듈들이 시스템의 다른 부분에 대해 알아야 할 내용을 줄여준다.
// before
class Person {
constructor(name) {
this._name = name;
}
get name() { return this._name; }
get department() { return this._department; }
set department() { this._department = arg; }
}
class Department {
get chargeCode() { return this._chargeCode; }
set chargeCode() { this._chargeCode = arg; }
get manager() { return this._manager; }
set manager() { this._manager = arg; }
}
// 클라이언트
const manager = aPerson.department.manager;
클라이언트가 Department
클래스를 몰라도 되도록, Person
클래스에 간단한 위임 메서드를 만들어 의존성을 줄일 수 있다.
// after
class Person {
// ...
get manager() {
return this._department.manager;
}
}
// 클라이언트
const manager = aPerson.manager;
위임 메서드를 매번 추가하다 보면 서버 클래스는 그저 중개자 역할로 전락하여, 차라리 클라이언트가 위임 객체를 직접 호출하는 게 나을 수 있다.
// before
const manager = aPerson.manager;
class Person {
// ...
get manager() {
return this._department.manager;
}
}
class Department {
// ...
get manager() {
return this._manager;
}
}
// after
class Person {
// ...
get department() {
return this._department;
}
// 삭제
// get manager() { return this._department.manager; }
}
// 클라이언트
const manager = aPerson.department.manager;
메서드를 잘게 나누어 알고리즘을 간소화하자.