리팩터링 2판의 Chatper 12를 보고 정리한 글입니다.
이 장에서는 객체 지향 프로그래밍에서 가장 유명한 특성인 상속을 다룬다. 이 상속은 유용한 동시에 오용하기 쉬운데, 이와 관련한 리팩터링 기법들을 알아보자.
반대 리팩터링: 메서드 내리기
메서드들의 본문 코드가 똑같을 때, 중복이 발생하게 된다. 이러한 중복은 한쪽의 변경이 다른쪽에는 반영되지 않을 수 있다는 위험을 항상 수반한다. 이를 위해 메서드 올리기를 적용하여 리팩터링을 진행해보자.
리팩터링 전
두 서브클래스에서 같은 일을 수행하는 메서드 발견
class Employee extends Party {
get annualCost() {
return this.monthlyCost * 12;
}
}
class Department extends Party {
get totalAnnualCost() {
return this.monthlyCost * 12;
}
}
리팩터링 후
// 슈퍼클래스에 메서드를 붙여 넣자.
class Party {
get annualCost() {
return this.monthlyCost * 12;
}
}
monthlyCost()를 Party에선 구현해 놓지 않았는데, JS가 동적 언어여서 잘 동작한다. 이럴 때는 함정 메서드(monthlyCost 메서드 정의로 에러를 던지는 코드)를 Party 클래스에 구현해 놓고 사용하면 서브클래스가 monthlyCost()를 구현해야 한다는 사실을 알려줄 수 있다.
반대 리팩터링: 필드 내리기
서브클래스들이 독립적으로 개발되었거나 뒤늦게 하나의 계층구조로 리팩터링된 경우라면 일부 기능이 중복되어 있을 때가 있다. 이러한 경우 분석 결과 필드들이 비슷한 방식으로 쓰인다고 판단되면 슈퍼클래스로 끌어올리자.
이렇게 하면 데이터 중복 선언을 없앨 수 있고, 해당 필드를 사용하는 동작을 서브 클래스에서 슈퍼클래스로 옮길 수 있다.
리팩터링 전
class Employee {...} // 자바코드
class SalesPerson extends Employee {
private String name;
}
class Engineer extends Employee {
private String name;
}
리팩터링 후
class Employee {
protected String name;
}
class SalesPerson extends Employee {...}
class Engineer extends Employee {...}
생성자는 다루기 까다롭지만, 서브클래스들에게서 기능이 같은 것들을 발견하고, 생성자로 옮길 수 있는 경우 생성자로 옮겨보자.
리팩터링 전
class Party {}
class Employee extends Party {
constructor(name, id, monthlyCost) {
super();
this._id = id;
this._name = name;
this._monthlyCost = monthlyCost;
}
// 생략
}
class Department extends Party {
constructor(name, staff) {
super();
this._name = name;
this._staff = staff;
}
// 생략
}
여기서 name을 초기화하는 공통 코드를 발견할 수 있다. 이를 생성자로 옮겨보자.
리팩터링 후
class Party {
constructor(name) {
this.name = name;
}
}
class Employee extends Party {
constructor(name, id, monthlyCost) {
super(name);
this._id = id;
this._monthlyCost = monthlyCost;
}
// 생략
}
class Department extends Party {
constructor(name, staff) {
super(name);
this._staff = staff;
}
// 생략
}
위 예제와 달리 공통 코드가 나중에 오는 경우, 함수로 추출한뒤 슈퍼클래스로 옮겨서 사용하는 식으로 리팩터링을 진행할 수 있다.
반대 리팩터링: 메서드 올리기
특정 서브클래스 하나와만 관련된 메서드는 슈퍼클래스에서 제거하고 해당 서브클래스들에 추가하는 편이 깔끔하다.
해당 기능을 제공하는 서브클래스가 정확히 무엇인지를 호출자가 알고 있을 때만 적용할 수 있다.
리팩터링 전
class Employee {
get quota() {...}
}
class Engineer extends Employee {...}
class Salesperson extends Employee {...}
리팩터링 후
class Employee {...}
class Engineer extends Employee {...}
class Salesperson extends Employee {
get quota() {...}
}
반대 리팩터링: 필드 올리기
서브클래스 하나에서만 사용하는 필드는 해당 서브클래스로 옮긴다.
리팩터링 전
class Employee { // 자바코드
private String quota;
}
class Engineer extends Employee {...}
class Salesperson extends Employee {...}
리팩터링 후
class Employee {...}
class Engineer extends Employee {...}
class Salesperson extends Employee {
protected String quota;
}
반대 리팩터링: 서브 클래스 제거하기
소프트웨어 시스템에서는 비슷한 대상들을 특정 특성에 따라 구분해야 할 때가 자주 있는데 이를 다루는 수단으로 타입 코드 필드가 있다.
이러한 타입코드를 사용하는 곳에서 서브클래스는 아래와 같은 2가지 면에서 매력적이다.
이 리팩터링은 대상 클래스에 직접 적용하는 경우와, 타입 코드 자체에 적용하는 경우를 고민해야 한다. 예시를 참고하자.
직접 상속하는 경우
리팩터링 전
class Employee {
constructor(name, type) {
this.validateType(type);
this._name = name;
this._type = type;
}
validateType(arg) {
if (!['engineer', 'manager', 'salesman'].includes(arg)) {
throw new Error(`Employee cannot be of type ${arg}`);
}
}
toString() {
return `${this._name} (${this._type})`;
}
}
리팩터링 후
클래스에 있는 type을 제거하고, 생성자를 팩터리 함수로 바꿔서 선택 로직을 함수에서 처리하는 모습을 확인할 수 있다.
class Employee {
constructor(name) {
this._name = name;
}
toString() {
return `${this._name} (${this.type})`;
}
}
class Engineer extends Employee {
get type() {
return 'engineer';
}
}
class Salesperson extends Employee {
get type() {
return 'salesperson';
}
}
class Manager extends Employee {
get type() {
return 'manager';
}
}
function createEmployee(name, type) {
switch (type) {
case 'engineer':
return new Engineer(name);
case 'salesperson':
return new Salesperson(name);
case 'manager':
return new Manager(name);
default: throw new Error(`Employee cannot be of type ${arg}`);
}
}
서브클래스들에는 타입 코드 게터가 여전히 남아 있고 이를 제거하고 싶겠지만 이 메서드를 이용하는 코드가 어딘가에 남아 있을 수 있으므로 조건부 로직을 다형성으로 바꾸기와 메서드 내리기로 문제를 해결하자.
간접 상속하는 경우
리팩터링 전
이번에는 직원의 서브클래스로 '아르바이트'와 '정직원'이라는 클래스가 이미 있어서 Employee를 직접 상속하는 방식으로 타입 코드 문제를 대처할 수 없다. 이러한 경우 간접 상속을 이용한다.
class Employee {
constructor(name, type) {
this.validateType(type);
this._name = name;
this._type = type;
}
validateType(arg) {
if (!['engineer', 'manager', 'salesperson'].includes(arg)) {
throw new Error(`${arg}라는 직원 유형은 없습니다.`);
}
}
get type() {
return this._type;
}
set type(arg) {
this._type = arg;
}
get capitalizedType() {
return this._type.charAt(0).toUpperCase() + this._type.substr(1);
}
toString() {
return `${this._name} (${this.capitalizedType})`;
}
}
리팩터링 후
타입 코드를 객체로 바꾸고, 앞 예시와 같은 방식으로 직원 유형을 리팩터링해보자.
class EmployeeType {
constructor(aString) {
this._value = aString;
}
toString() {
return this._value;
}
get capitalizedType() {
return this.toString().charAt(0).toUpperCase() + this.toString().substr(1);
}
}
class Engineer extends EmployeeType {
toString() {
return 'engineer';
}
}
class Manager extends EmployeeType {
toString() {
return 'manager';
}
}
class Salesperson extends EmployeeType {
toString() {
return 'salesperson';
}
}
class Employee {
constructor(name, type) {
this.validateType(type);
this._name = name;
this._type = type;
}
validateType(arg) {
if (!['engineer', 'manager', 'salesperson'].includes(arg)) {
throw new Error(`${arg}라는 직원 유형은 없습니다.`);
}
}
get typeString() {
return this._type.toString();
}
get type() {
return this._type;
}
set type(arg) {
this._type = new EmployeeType(arg);
}
static createEmployeeType(aString) {
switch(aString) {
case 'engineer': return new Engineer();
case 'manager': return new Manager();
case 'salesperson': return new Salesperson();
default: throw new Error(`${aString}라는 직원 유형은 없습니다.`);
}
}
toString() {
return `${this._name} (${this.type.capitalizedType})`;
}
}
반대 리팩터링: 타입 코드를 서브클래스로 바꾸기
서브클래스는 소프트웨어 시스템이 성장함에 따라 그 가치가 바래지기도 한다. 더 이상 쓰이지 않는 서브클래스와 마주하는 프로그래머는 가치 없는 것을 이해하느라 에너지를 낭비할 것이고, 이러한 경우 서브클래스를 슈퍼클래스의 필드로 대체해 제거하는게 최선이다.
리팩터링 전
class Person {
constructor(name) {
this._name = name;
}
get name() {
return this._name;
}
get genderCoder() {
return 'X';
}
// 생략
}
class Male extends Person {
get genderCode() {
return 'M';
}
}
class Female extends Person {
get genderCode() {
return 'F';
}
}
// 클라이언트
const numberOfMales = people.filter(p => p instanceof Male).length;
다음과 같은 코드에서 서브 클래스가 하는 일이 이게 다라면 굳이 존재할 이유가 없다. 이를 리팩터링 해보자.
리팩터링 후
서브클래스 만들기를 캡슐화하는 방법은 생성자를 팩터리 함수로 바꾸기다. 먼저 이를 진행해보자.
function createPerson(aRecord) {
let p;
switch (aRecord.gender) {
case 'M':
p = new Male(aRecord.name);
break;
case 'F':
p = new Female(aRecord.name);
break;
default:
p = new Person(aRecord.name);
}
return p;
}
function loadFromInput(data) {
const result = [];
data.forEach((aRecord) => {
result.push(createPerson(aRecord));
});
return result;
}
현재 이 코드를 깔끔히 청소해보자. 변수 p를 인라인 하고 loadFromInput()의 반복문을 파이프라인으로 바꾼다. 또한 클라이언트 코드에서 instanceOf를 사용하는 타입 검사 코드를 함수로 추출한다.
function createPerson(aRecord) {
switch (aRecord.gender) {
case 'M':
return new Male(aRecord.name);
case 'F':
return new Female(aRecord.name);
default:
return new Person(aRecord.name);
}
}
function loadFromInput(data) {
data.map((aRecord) => createPerson(aRecord));
}
function isMale(aPerson) {return aPerson instanceof Male;}
class Person {
get isMale() {return this instanceOf Male;}
}
// 클라이언트
const numberOfMales = people.filter(p => p.isMale).length;
마지막으로 매개변수를 이용하여 서브클래스들의 차이를 나타낼 필드를 추가하자.
class Person {
constructor(name, genderCode) {
this._name = name;
this._genderCode = genderCode;
}
get name() {
return this._name;
}
get genderCoder() {
return 'X';
}
get isMale() {
return this._genderCode === 'M';
}
// 생략
}
function createPerson(aRecord) {
switch (aRecord.gender) {
case 'M':
return new Person(aRecord.name, 'M');
case 'F':
return new Person(aRecord.name, 'F');
default:
return new Person(aRecord.name, 'X');
}
}
비슷한 일을 수행하는 두 클래스가 보이면 상속 메커니즘을 이용해서 비슷한 부분을 공통의 슈퍼클래스로 옮겨 담을 수 있다.
리팩터링 전
class Employee {
constructor(name, id, monthlyCost) {
this.name = name;
this.id = id;
this.monthlyCost = monthlyCost;
}
get monthlyCost() {
return this._monthlyCost;
}
get name() {
return this._name;
}
get id() {
return this._id;
}
get annualCost() {
return this.monthlyCost * 12;
}
}
class Department {
constructor(name, staff) {
this.name = name;
this.staff = staff;
}
get staff() {
return this._staff.slice();
}
get name() {
return this._name;
}
get totalMonthlyCost() {
return this.staff.map((e) => e.monthlyCost).reduce((sum, cost) => sum + cost);
}
get headCount() {
return this.staff.length;
}
get totalAnnualCost() {
return this.totalMonthlyCost * 12;
}
}
연간 비용과 월간 비용부분과 관련한 부분에서 공통된 기능이 눈에 띔을 확인할 수 있다.
리팩터링 후
Party라는 빈 클래스를 만들고 두 클래스가 이를 확장하도록 만들자. 공통된 필드와 메서드를 슈퍼클래스로 옮긴다.
class Party {
constructor(name) {
this._name = name;
}
get name() {
return this._name;
}
get annualCost() {
return this.monthlyCost * 12;
}
}
class Employee extends Party {
constructor(name, id, monthlyCost) {
super(name);
this.name = name;
this.id = id;
this.monthlyCost = monthlyCost;
}
get monthlyCost() {
return this._monthlyCost;
}
get id() {
return this._id;
}
}
class Department extends Party {
constructor(name, staff) {
super(name);
this.name = name;
this.staff = staff;
}
get staff() {
return this._staff.slice();
}
get totalMonthlyCost() {
return this.staff.map((e) => e.monthlyCost).reduce((sum, cost) => sum + cost);
}
get headCount() {
return this.staff.length;
}
}
계층구조도 진화하면서 어떤 클래스와 그 부모가 너무 비슷해져서 더는 독립적으로 존재해야 할 이유가 사라지는 경우가 생기기도 한다.
→ 둘을 하나로 합쳐야 할 시점이다.
리팩터링 전
class Employee {...}
class SalesPerson extends Employee {...}
리팩터링 후
class Employee {...}
상속은 무언가가 달라져야 하는 이유가 여러 개여도 상속에서는 그중 단 하나의 이유만 선택해야 하며, 클래스들의 관계를 아주 긴밀하게 결합한다.
따라서 이 대신 위임을 사용하자. 위임은 객체 사이의 일반적인 관계이므로 상호작용에 필요한 인터페이스를 명확히 정의할 수 있다.
즉, 상속보다 결합도가 훨씬 약하다.
서브 클래스가 하나인 경우(공연 예약 클래스)
리팩터링 전
class Booking {
constructor(show, date) {
this._show = show;
this._date = date;
}
get hasTalkback() {
return this._show.hasOwnProperty('talkback') && !this.isPeakDay;
}
get basePrice() {
let result = this._show.price;
if (this.isPeakDay) result += Math.round(result * 0.15);
return result;
}
}
// 추가 비용을 다양하게 설정할 수 있느 프리미엄 예약용 서브 클래스
class PremiumBooking extends Booking {
constructor(show, date, extras) {
super(show, date);
this._extras = extras;
}
get hasTalkback() {
return this._show.hasOwnProperty('talkback');
}
get basePrice() {
return Math.round(super.basePrice + this._extras.premiumFee);
}
// 슈퍼클래스에는 없는 기능을 프리미엄 예약에서 제공하는 예
get hasDinner() {
return this._extras.hasOwnProperty('dinner') && !this.isPeakDay;
}
}
// 클라이언트 쪽 코드
// 일반예약
aBooking = new Booking(show, date);
// 프리미엄 예약
aBooking = new PremiumBooking(show, date, extras);
리팩터링 후
우선 생성자를 팩터리 함수로 바꿔서 생성자 호출 부분을 캡슐화한다.
function createBooking(show, date) {
return new Booking(show, date);
}
function createPremiumBooking(show, date, extras) {
return new PremiumBooking(show, date, extras);
}
// 클라이언트 쪽 코드
// 일반예약
aBooking = createBooking(show, date);
// 프리미엄 예약
aBooking = createPremiumBooking(show, date, extras);
이후 위임클래스를 만들고 위임을 예약 객체와 연결
// 역참조를 매개변수로 받는 위임 클래스
class PremiumBookingDelegate {
constructor(hostBooking, extras) {
this._host = hostBooking;
this._extras = extras;
}
}
function createPremiumBooking(show, date, extras) {
const result = new PremiumBooking(show, date, extras);
result._bePremium(extras);
return result;
}
class Booking {
...
_bePremium(extras) {
this._premiumDelegate = new PremiumBookingDelegate(this, extras);
}
}
이후 기능을 위임으로 옮겨준다.
class Booking {
constructor(show, date) {
this._show = show;
this._date = date;
}
_bePremium(extras) {
this._premiumDelegate = new PremiumBookingDelegate(this, extras);
}
get hasTalkback() {
return this._premiumDelegate
? this._premiumDelegate.hasTalkback
: this._show.hasOwnProperty('talkback') && !this.isPeakDay;
}
// 위임의 메서드를 기반 메서드의 확장 형태로 재호출하는 경우
get basePrice() {
let result = this._show.price;
if (this.isPeakDay) result += Math.round(result * 0.15);
return this._premiumDelegate ? this._premiumDelegate.extendBasePrice(result) : result;
}
get hasDinner() {
return this._premiumDelegate ? this._premiumDelegate.hasDinner : undefined;
}
}
// 역참조를 매개변수로 받는 위임 클래스
class PremiumBookingDelegate {
constructor(hostBooking, extras) {
this._host = hostBooking;
this._extras = extras;
}
get hasTalkback() {
return this._host._show.hasOwnProperty('talkback');
}
extendBasePrice(base) {
return Math.round(base + this._extras.premiumFee);
}
get hasDinner() {
return this._extras.hasOwnProperty('dinner') && !this._host.isPeakDay;
}
}
// 클라이언트 쪽 코드
// 일반예약
aBooking = createBooking(show, date);
// 프리미엄 예약
aBooking = createPremiumBooking(show, date, extras);
function createBooking(show, date) {
return new Booking(show, date);
}
function createPremiumBooking(show, date, extras) {
const result = new Booking(show, date, extras);
result._bePremium(extras);
return result;
}
상속은 기존 기능을 재활용하는 강력하고 손쉬운 수단이지만, 혼란과 복잡도를 키우는 방식으로 이뤄지기도 한다. 따라서 이러한 상속보다 위임을 사용하여 객체를 분리하고 문제를 피해보자.
그렇다면 '상속은 절대 하지 말아야 할까?' 라는 질문에 저자는 상속을 먼저 적용하고 나중에 문제가 생기면 슈퍼클래스를 위임으로 바꾸는 방식을 조언한다.
고대 스크롤 관리 코드 예제
리팩터링 전
class CatalogItem {
constructor(id, title, tags) {
this.id = id;
this.title = title;
this.tags = tags;
}
get id() {
return this._id;
}
get title() {
return this._title;
}
hasTag(arg) {
return this._tags.includes(arg);
}
}
// 정기 세척 이력이 필요하여 카탈로그 아이템을 확장하여
// 세척 관련 데이터를 추가
class Scroll extends CatalogItem {
constructor(id, title, tags, dateLastCleaned) {
super(id, title, tags);
this._lastCleaned = dateLastCleaned;
}
needsCleaning(targetDate) {
const threshold = this.hasTag('reversed') ? 700 : 1500;
return this.daysSinceLastCleaning(targetDate) > threshold;
}
datsSinceLastCleaning(targetDate) {
return this._lastCleaned.until(targetDate, ChronoUnit.DAYS);
}
}
석화병 치료법을 적어 놓은 스크롤은 사본이 여러 개임에도 카탈로그 아이템은 하나뿐이라는 차이가 존재하여 슈퍼-서브 관계라는 모델링이 맞지 않음
리팩터링 후
Scroll에 카탈로그 아이템을 참조하는 속성을 만들고 슈퍼클래스의 인스턴스를 새로 하나 만들어 대입하고, 대응하는 메서드를 만든 후 상속관계를 끊는다.
class CatalogItem {
constructor(id, title, tags) {
this.id = id;
this.title = title;
this.tags = tags;
}
}
class Scroll {
constructor(id, title, tags, dateLastCleaned) {
this._catalogItem = new CatalogItem(id, title, tags);
this._lastCleaned = dateLastCleaned;
}
needsCleaning(targetDate) {
const threshold = this.hasTag('reversed') ? 700 : 1500;
return this.daysSinceLastCleaning(targetDate) > threshold;
}
datsSinceLastCleaning(targetDate) {
return this._lastCleaned.until(targetDate, ChronoUnit.DAYS);
}
get id() {
return this._catalogItem.id;
}
get title() {
return this._catalogItem.title;
}
hasTag(aString) {
return this._catalogItem.hasTag(aString);
}
}