모듈을 분리하는 가장 중요한 기준은 시스템에서 각 모듈이 자신을 제외한 부분에 드러내지 않아야 할 비밀을 얼마나 잘 숨기느냐에 있다.
이 장에서는 다양한 캡슐화 기법을 소개한다. 이 기법들을 활용하여, 레코드, 임시변수, 클래스 사이 관계 등을 캡슐화할 수 있다.
레코드 캡슐화하기
배경
대부분의 프로그래밍 언어는 구조체(struct), 레코드(record), 딕셔너리(dictionary) 등으로 데이터를 표현한다. 하지만 이 방식은 "계산된 값"과 "저장된 값"을 구분 없이 다루기 때문에, 코드의 의도를 파악하거나 데이터를 수정하기가 어렵다.
이런 이유로 가변 데이터를 표현할 때는 레코드보다 객체를 선호한다. 객체는 내부 구조를 감추고, 메서드를 통해 값을 제공함으로써 캡슐화의 이점을 누릴 수 있다. 이렇게 하면 외부에서는 데이터가 저장된 값인지 계산된 값인지 알 필요 없이 일관된 방식으로 접근할 수 있다.
절차
- 레코드를 캡슐화할 변수로 교체한다.
- 단순한 클래스를 만들어 해당 변수의 데이터를 감싼다.
- 이 클래스에 원본 레코드를 반환하는 접근자를 만든다.
- 기존의 코드에서 이 접근자를 사용하도록 수정한다.
- 테스트한다.
- 레코드 대신 캡슐화한 객체를 반환하는 새 함수를 만든다.
- 기존 코드에서 레코드 반환 함수를 새 함수로 교체한다.
- 필드 접근은 객체의 메서드나 접근자를 통해 이루어지도록 변경한다.
- 필요한 접근자가 없으면 추가한다.
- 변경 후에는 반드시 테스트를 수행한다.
- 클래스에서 원본 레코드를 반환하는 접근자와 관련 함수를 제거한다.
- 테스트한다.
- 중첩된 레코드가 있다면, 이 절차를 재귀적으로 적용한다.
예시
Before
const organization = {
name: 'Acme Gooseberries',
country: 'GB'
};
function getOrganization() {
return organization;
}
console.log(getOrganization().name);
After
class Organization {
constructor(data) {
this._data = data;
}
get name() {
return this._data.name;
}
get country() {
return this._data.country;
}
}
const organization = new Organization({
name: 'Acme Gooseberries',
country: 'GB'
});
function getOrganization() {
return organization;
}
console.log(getOrganization().name);
컬렉션 캡슐화하기
배경
컬렉션 데이터를 외부에서 직접 수정할 수 있게 하면, 클래스 내부의 일관성을 깨뜨릴 위험이 크다. 예를 들어, 게터를 통해 컬렉션 자체를 반환하면, 외부에서 마음대로 원소를 추가하거나 제거할 수 있다. 클래스는 이런 변경을 전혀 감지하지 못한다.
이런 문제를 방지하기 위해 컬렉션 자체를 외부에 노출하지 않고, add(), remove()와 같은 명확한 변경자 메서드를 통해서만 컬렉션을 수정하게 만든다. 또한 컬렉션을 반환할 때는 원본을 직접 반환하지 않고 복제본이나 읽기 전용 프록시를 반환하여 외부의 실수로 인한 변경을 방지할 수 있다.
요즘 언어들은 컬렉션 파이프라인 같은 패턴을 통해 컬렉션을 안전하게 조작하는 기능을 잘 지원하기 때문에, 변경은 캡슐화하고 읽기는 유연하게 제공하는 것이 좋다.
절차
- 컬렉션이 캡슐화되어 있지 않다면, 변수 캡슐화를 먼저 수행한다.
- 컬렉션에 원소를 추가/제거하는 메서드 (add(), remove())를 만든다.
- 정적 분석 도구로 변경 영향을 파악한다.
- 컬렉션을 직접 수정하는 코드들을 모두 찾아, 위에서 만든 메서드로 대체하고 테스트한다.
- 컬렉션 게터에서 원본 컬렉션 대신 복제본이나 읽기 전용 프록시를 반환하도록 수정한다.
- 전체적으로 테스트를 수행한다.
예시
Before
class Person {
constructor(name) {
this._name = name;
this._courses = [];
}
get courses() {
return this._courses;
}
}
const person = new Person('철수');
person.courses.push({ name: '리팩터링', grade: 'A' }); // 외부에서 직접 수정
After
class Person {
constructor(name) {
this._name = name;
this._courses = [];
}
get courses() {
// 복제본을 반환하여 외부 수정 차단
return [...this._courses];
}
addCourse(course) {
this._courses.push(course);
}
removeCourse(courseName) {
this._courses = this._courses.filter(c => c.name !== courseName);
}
}
const person = new Person('철수');
person.addCourse({ name: '리팩터링', grade: 'A' }); // 메서드를 통해 안전하게 수정
기본형을 객체로 바꾸기
배경
처음에는 단순한 숫자나 문자열로 충분했던 값도, 시간이 지나면서 로직이 붙고 역할이 커지게 된다. 이럴 땐 기본형 대신 객체를 만들어 해당 데이터를 전용 클래스로 다루는 것이 좋다.
객체로 바꾸면:
- 관련된 동작을 메서드로 묶을 수 있고,
- 코드의 의미를 명확하게 표현할 수 있으며,
- 추후 확장이나 변경도 유연하게 대응할 수 있다.
이 리팩터링은 도메인 개념을 코드에 반영하는 첫걸음이기도 하다.
절차
- 해당 필드가 아직 캡슐화되지 않았다면, 변수 캡슐화를 먼저 수행한다.
- 기본형 값을 감쌀 값 객체(value class)를 만든다.
- 생성자는 기존 값을 인수로 받아 저장하고,
- 값을 반환하는 getter를 추가한다.
- 정적 검사를 수행한다.
- 기존 필드에 값을 직접 저장하지 않고, 새 클래스의 인스턴스를 저장하도록 세터를 수정한다.
- 필요하다면 타입도 변경한다.
- 게터에서는 새 클래스의 값을 반환하도록 수정한다.
- 테스트한다.
- 필요하면 게터/세터의 이름을 더 도메인 친화적으로 바꾼다.
예시
Before
class Order {
constructor(data) {
this._priority = data.priority; // 문자열 타입
}
get priority() {
return this._priority;
}
set priority(value) {
this._priority = value;
}
}
const order = new Order({ priority: 'high' });
console.log(order.priority); // high
After
class Priority {
constructor(value) {
this._value = value;
}
get value() {
return this._value;
}
// 필요한 경우, 비교 메서드나 정렬 로직 등을 여기에 추가 가능
isHigherThan(other) {
const levels = ['low', 'normal', 'high'];
return levels.indexOf(this._value) > levels.indexOf(other.value);
}
}
class Order {
constructor(data) {
this._priority = new Priority(data.priority);
}
get priority() {
return this._priority.value;
}
set priority(value) {
this._priority = new Priority(value);
}
}
const order = new Order({ priority: 'high' });
console.log(order.priority); // high
const otherOrder = new Order({ priority: 'normal' });
console.log(order._priority.isHigherThan(otherOrder._priority)); // true
임시 변수를 질의 함수로 바꾸기
배경
긴 함수를 분해하려고 할 때, 임시 변수들이 걸림돌이 되는 경우가 많다. 변수는 함수 간에 전달하기 번거롭고, 복잡한 컨텍스트를 만든다.
이럴 땐 임시 변수를 질의 함수(계산 함수)로 바꾸면 훨씬 유연해진다.
- 중복되는 계산을 줄일 수 있고,
- 추출한 함수를 다른 함수에서도 재사용할 수 있으며,
- 코드 가독성과 유지보수성이 크게 향상된다.
특히 클래스 안에서 적용하면, 질의 함수는 필드를 참조할 수 있어 별도 인자를 넘기지 않고도 깔끔하게 동작한다.
절차
- 변수의 값이 사용 전에 명확히 결정되는지 확인한다.
- 값이 매번 동일한 결과를 반환해야 한다.
- 변수가 변경되지 않도록 읽기 전용으로 만든다.
- 기존 동작이 유지되는지 테스트한다.
- 변수 대입 부분을 별도의 질의 함수로 추출한다.
- 예: getBasePrice()
- 변수 인라인하기 기법을 이용해, 해당 변수를 제거한다.
예시
Before
class Order {
constructor(quantity, itemPrice) {
this._quantity = quantity;
this._itemPrice = itemPrice;
}
get price() {
const basePrice = this._quantity * this._itemPrice;
if (basePrice > 1000) {
return basePrice * 0.95;
}
return basePrice * 0.98;
}
}
After
class Order {
constructor(quantity, itemPrice) {
this._quantity = quantity;
this._itemPrice = itemPrice;
}
get basePrice() {
return this._quantity * this._itemPrice;
}
get price() {
if (this.basePrice > 1000) {
return this.basePrice * 0.95;
}
return this.basePrice * 0.98;
}
}
클래스 추출하기
배경
처음엔 단순하던 클래스도 기능이 추가되면서 점점 커진다. 이런 비대해진 클래스는 이해하기 어렵고, 변경도 위험하다. 특히 하나의 클래스가 너무 많은 책임을 갖고 있다면, 책임을 역할별로 나눠서 별도의 클래스로 분리하는 것이 좋다.
"이 데이터와 메서드들은 서로 강하게 연결돼 있는데, 다른 것들과는 관련이 없는 것 같아"
— 이런 느낌이 든다면 클래스를 추출하라는 신호다.
이 리팩터링을 적용하면:
- 코드의 응집도를 높일 수 있고,
- 변경이 필요한 영역만 안전하게 수정할 수 있으며,
- 각 클래스의 책임이 명확해진다.
절차
- 클래스 안에서 서로 관련 있는 데이터와 메서드를 묶는다.
- 새 클래스를 만들어, 그 책임을 맡기기로 한다.
- 원래 클래스의 생성자에서 새 클래스를 생성하고 필드로 저장한다.
- 관련 필드를 새 클래스로 옮기고, 옮길 때마다 테스트한다.
- 해당 필드를 사용하는 메서드들도 차례로 옮긴다.
- 의존성이 적은 저수준 메서드부터 옮기는 것이 좋다.
- 두 클래스의 인터페이스를 정리한다.
- 불필요한 메서드는 제거하고, 이름도 새 맥락에 맞게 수정한다.
- 새 클래스를 외부에 공개할지 결정한다.
- 필요하다면, 참조를 값(value object)처럼 다루는 것도 고려한다.
예시
Before
class Person {
constructor(name, officeAreaCode, officeNumber) {
this._name = name;
this._officeAreaCode = officeAreaCode;
this._officeNumber = officeNumber;
}
get name() {
return this._name;
}
get telephoneNumber() {
return `(${this._officeAreaCode}) ${this._officeNumber}`;
}
}
After
class TelephoneNumber {
constructor(areaCode, number) {
this._areaCode = areaCode;
this._number = number;
}
get areaCode() {
return this._areaCode;
}
get number() {
return this._number;
}
get formatted() {
return `(${this._areaCode}) ${this._number}`;
}
}
class Person {
constructor(name, officeAreaCode, officeNumber) {
this._name = name;
this._telephoneNumber = new TelephoneNumber(officeAreaCode, officeNumber);
}
get name() {
return this._name;
}
get telephoneNumber() {
return this._telephoneNumber.formatted;
}
}
클래스 인라인하기
배경
어떤 클래스를 분리했지만 시간이 지나면서 그 클래스에 남은 책임이 거의 없어졌다면, 그 클래스를 그대로 유지할 이유가 없다.
예전에는 역할이 있었지만 지금은 그저 껍데기만 남은 클래스는 오히려 코드만 복잡하게 만들 뿐이다.
이럴 땐 해당 클래스를 다른 클래스로 흡수(인라인)하여 간결한 구조로 바꿔야 한다.
이 리팩터링을 통해 다음을 기대할 수 있다:
- 구조가 단순해진다
- 추적해야 할 클래스 수가 줄어든다
- 중복된 추상화 레이어를 제거할 수 있다
절차
- 소스 클래스의 public 메서드마다, 타깃 클래스에 위임 메서드를 만든다.
- 소스 클래스의 메서드를 사용하는 모든 코드를 타깃 클래스의 위임 메서드를 사용하도록 수정한다.
- 하나씩 바꾼 뒤 테스트한다.
- 모든 메서드와 필드를 타깃 클래스로 옮긴다.
- 각 단계마다 테스트를 수행한다.
- 소스 클래스를 삭제한다.
예시
Before
class TelephoneNumber {
constructor(areaCode, number) {
this._areaCode = areaCode;
this._number = number;
}
get areaCode() {
return this._areaCode;
}
get number() {
return this._number;
}
get formatted() {
return `(${this._areaCode}) ${this._number}`;
}
}
class Person {
constructor(name, telephoneNumber) {
this._name = name;
this._telephoneNumber = telephoneNumber;
}
get name() {
return this._name;
}
get telephoneNumber() {
return this._telephoneNumber.formatted;
}
}
After
class Person {
constructor(name, areaCode, number) {
this._name = name;
this._areaCode = areaCode;
this._number = number;
}
get name() {
return this._name;
}
get telephoneNumber() {
return `(${this._areaCode}) ${this._number}`;
}
}
위임 숨기기
배경
서버 객체가 다른 객체에 작업을 위임(delegate) 하는 경우, 클라이언트는 위임 객체를 직접 다뤄야 할 때가 있다.
이때, 위임 객체의 인터페이스가 변경되면 이 인터페이스를 사용하는 모든 클라이언트 코드를 수정해야 하는 문제가 발생한다.
이 의존성을 없애려면, 서버 객체에서 위임 객체의 메서드를 직접 호출하여, 위임 객체를 클라이언트에서 숨기는 것이 좋다.
위임 객체의 존재를 숨기면:
- 클라이언트는 더 이상 위임 객체에 의존하지 않게 되어, 인터페이스 변경에 따른 코드 수정이 줄어든다.
- 객체의 구현 세부 사항을 은폐하여 응집도가 높아진다.
절차
- 위임 객체의 각 메서드를 서버 객체에서 위임 메서드로 생성한다.
- 클라이언트 코드에서 위임 객체를 호출하는 부분을 서버 객체의 위임 메서드 호출로 수정한다.
- 변경 후에는 매번 테스트를 수행한다.
- 모든 수정이 완료되면, 서버 객체에서 위임 객체를 반환하는 접근자를 제거한다.
- 최종 테스트를 수행한다.
예시
Before
class Customer {
constructor(name) {
this._name = name;
this._billingPlan = new BillingPlan();
}
get name() {
return this._name;
}
get billingPlan() {
return this._billingPlan;
}
set billingPlan(aPlan) {
this._billingPlan = aPlan;
}
get plan() {
return this._billingPlan;
}
}
class BillingPlan {
get planDetails() {
return 'Basic Plan';
}
}
After
class Customer {
constructor(name) {
this._name = name;
this._billingPlan = new BillingPlan();
}
get name() {
return this._name;
}
get planDetails() {
return this._billingPlan.planDetails;
}
set billingPlan(aPlan) {
this._billingPlan = aPlan;
}
}
class BillingPlan {
get planDetails() {
return 'Basic Plan';
}
}
중개자 제거하기
배경
위임 숨기기를 자주 사용하다 보면, 서버 객체에 위임 메서드를 점점 추가하게 된다.
클라이언트가 위임 객체의 기능을 사용하고 싶을 때마다 서버 객체에 위임 메서드를 추가하다 보면, 단순히 전달만 하는 메서드들이 쌓여서 코드가 점점 복잡해진다. 이런 상태가 되면, 중개자를 계속 추가하는 것보다 클라이언트가 직접 위임 객체를 호출하는 것이 더 나을 때가 많다.
이 문제는 디미터의 법칙을 지나치게 신봉할 때 자주 발생한다.
디미터의 법칙 (The Law of Demeter) 이란?
객체 지향 프로그램을 위한 설계 지침 중 하나. 한 객체가 어떠한 자료를 갖고 있는지 외부에서 알지 못해야한다는 것을 강조한다. (Loosing Coupling)
따라서, 불필요한 중개자를 제거하고 클라이언트가 직접 위임 객체와 소통하도록 해주는 것이 좋다.
절차
- 위임 객체를 얻는 게터(getter)를 만든다.
- 위임 메서드를 호출하는 모든 클라이언트 코드가 이 게터를 사용하도록 수정한다.
- 수정할 때마다 테스트한다.
- 모든 수정이 완료되면, 불필요한 위임 메서드를 삭제한다.
예시
Before
class Customer {
constructor(name) {
this._name = name;
this._billingPlan = new BillingPlan();
}
get name() {
return this._name;
}
get billingPlan() {
return this._billingPlan;
}
set billingPlan(aPlan) {
this._billingPlan = aPlan;
}
get planDetails() {
return this._billingPlan.planDetails;
}
}
class BillingPlan {
get planDetails() {
return 'Basic Plan';
}
}
After
class Customer {
constructor(name) {
this._name = name;
this._billingPlan = new BillingPlan();
}
get name() {
return this._name;
}
get billingPlan() {
return this._billingPlan;
}
set billingPlan(aPlan) {
this._billingPlan = aPlan;
}
}
class BillingPlan {
get planDetails() {
return 'Basic Plan';
}
}
알고리즘 교체하기
배경
기존의 알고리즘이 너무 복잡하거나 비효율적이라면, 더 간단하고 효율적인 방법으로 교체하는 것이 필요하다. 때로는 알고리즘 전체를 완전히 다른 알고리즘으로 바꾸어야 할 때도 있다. 또한, 내 코드와 똑같은 기능을 제공하는 라이브러리를 찾았다면, 직접 구현한 알고리즘을 라이브러리로 교체하는 것도 좋은 선택이다.
이 리팩터링 기법은 코드의 복잡도를 줄이고, 성능을 향상시키는 데 중점을 둔다.
절차
- 교체할 코드를 하나의 함수로 모은다.
- 알고리즘을 별도의 함수로 분리하여, 나중에 교체할 때 더 쉽게 변경할 수 있도록 한다.
- 해당 함수의 동작을 검증하는 테스트를 작성한다.
- 기존 알고리즘의 동작을 정확히 검증하는 테스트가 있어야, 교체 후에도 동일한 결과를 얻을 수 있다.
- 새로운 알고리즘을 준비한다.
- 더 간결하거나 효율적인 알고리즘을 선택하여 준비한다.
- 정적 검사를 수행한다.
- 코드 스타일, 성능 최적화 등을 확인하여 새로운 알고리즘이 기존 코드에서 문제 없이 동작하도록 한다.
- 기존 알고리즘과 새 알고리즘의 결과를 비교하는 테스트를 수행한다.
- 테스트를 통해 결과가 동일한지 확인한다. 결과가 다르다면, 기존 알고리즘을 참고하여 새 알고리즘을 디버깅하고 테스트한다.
예시
Before
function sortArray(arr) {
// 복잡한 정렬 알고리즘
for (let i = 0; i < arr.length - 1; i++) {
for (let j = i + 1; j < arr.length; j++) {
if (arr[i] > arr[j]) {
let temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
}
return arr;
}
After
function sortArray(arr) {
// 더 간단한 내장 정렬 알고리즘 사용
return arr.sort((a, b) => a - b);
}
'Programming > Refactoring' 카테고리의 다른 글
기본적인 리팩터링 (3) (0) | 2025.04.16 |
---|---|
기본적인 리팩터링 (2) (0) | 2025.04.10 |
기본적인 리팩터링 (1) (0) | 2025.04.10 |
테스트 구축하기 (0) | 2025.03.20 |
코드에서 나는 악취 (1) | 2025.03.13 |