6장의 구현체 동작과 엄청난 차이가 있진 않지만 결정적인 차이를 발견할 수 있다. Bicycle 클래스에 포함된 자전거 관련 코드는 사실 별로 없었다는 점이다.
위의 코드 대부분은 개별 부품을 다루고 있다. 그리고 Parts 상속 관계를 리팩터링하면 어떤 식으로 될까?
Parts 객체 조합하기
각각의 부품을 담당하는 클래스를 만들 예정인데, 각각의 부분에 Part 라는 이름이 붙으면서 용어의 혼란이 올 수 있다. (영어권이라 복수 및 단수 때문으로 보임)
위의 디자인을 적용하기 위해 새 Part 클래스를 만들어본다. Parts 는 Part 의 조합이 된다.
class Bicycle {
constructor({ size, parts } = {}) {
this.size = size;
this.parts = parts;
}
get spares() {
return this.parts.spares;
}
}
class Parts {
constructor(parts) {
this.parts = parts;
}
get spares() {
return this.parts.filter((part) => part.needSpare);
}
}
class Part {
constructor({ name, description, needSpare = true } = {}) {
this.name = name;
this.description = description;
this.needSpare = needSpare;
}
}
// Parts 조합 예
const chain = new Part({ name: "chain", description: "10-speed" });
const roadTire = new Part({ name: "tireSize", description: "23" });
const tape = new Part({ name: "tapeColor", description: "red" });
const roadBike = new Bicycle({
size: "L",
parts: new Parts([chain, roadTire, tape]),
});
console.log(roadBike.spares);
/*
[
Part { name: 'chain', description: '10-speed', needSpare: true },
Part { name: 'tireSize', description: '23', needSpare: true },
Part { name: 'tapeColor', description: 'red', needSpare: true }
]
*/
기존 코드와는 다르게 spares 메서드가 객체가 아니라 배열을 반환하고 있다.
이 객체들은 Part 클래스의 인스턴스라기보단 그저 Part처럼 행동하는 객체이기만 하면 된다. name, description, needSpare 에 반응할 줄만 알면 되는 것이다.
하지만 이럴 경우 직접적으로 roadBike.parts.length 같은식으로 가져오면 에러가 날 것이다. parts 인스턴스 자체는 배열이 아니기 때문이다. 별도의 메서드를 래핑하던가, Array 를 상속하여 비슷한 구현체를 만들거나 할 수 있지만 의도치 않은 동작이 일어날 수 있다.
루비에서는 forwardable 모듈을 불러와 결합하고, Enumerable 모듈도 활용할 수 있겠지만 JS에서는 Parts 에 length 메서드 구현을 해주는게 가장 쉽게 문제를 해결할 수 있는 방법으로 보인다.
Parts 생산하기
우리의 애플리케이션 어딘가에서 누군가는 Part 들을 만드는 법을 알고 있어야 한다. 또한 roadBike 를 만들기 위해서는 3개의 부품이 필요하다는 사실도 일고 있어야 한다.
이런 지식이 여러 곳에 흩뿌려지는 것은 좋지 않은데다 불필요한 일이다. 자전거의 종류를 설명하고 이 설명에 따라 필요한 부품들을 자동으로 생산할 수 있다면 문제가 훨씬 간단해질 것이다.
객체와 달리 이차원 배열은 구조에 대한 정보를 제공하지 않지만, 이 구조가 어떤 방식으로 정리되어있는지 알고 있고 이 지식을 가지고 Parts 를 생성하는 객체 속에 넣어두면 된다.
그러니 PartsFactory 를 만들어보자. 팩토리라는 용어가 약간 어렵게 다가올 수도 있지만, 부자연스럽거나 쓸데없이 복잡한 것이 아니다. 이 단어는 '다른 객체를 만드는 객체' 라는 관념을 좀 더 간결하게 소통하기 위해 객체지향 디자이너들이 사용하는 개념일 뿐이다.
조합은 종종 위임을 사용한다. 조합된 객체는 잘 정의된 인터페이스를 통해 협업할 줄 아는 여러 부분들로 구성되어 있다.
조합은 가지고 있는 관계(has-a relationship)이다. 식사는 애피타이저를 가지고 있고, 대학은 학부를 가지고 있고, 자전거는 부품을 가지고 있다. 식사, 대학, 자전거는 조합된 객체이고 애피타이저, 학부, 부품은 역할들이다. 조합된 객체는 역할의 인터페이스에 의존적이다.
우리가 조합이라는 개념을 넓은 범위로 접하는 대부분의 경우에는 일반적으로 두 객체 사이의 가지고 이는 관계(has-a relationship)를 의마한다고 생각해도 좋다.
조금 더 정교하게 정의한다면, 포함된 객체(contained object)가 포함하는 객체(container)로부터 독립적으로 존재하지 못하는 방식으로 서로 가지고 있는 관계(has-a relationship)을 맺고 있다는 뜻이 된다. 이 정의를 따른다면 식사가 애피타이저를 가지고 있을 뿐 아니라 식사를 끝낸 후에는 애피타이저 역시 사라져버린다는 것을 알 수 있다.
넓은 의미, 좁은 의미의 중간을 메꾸어 주는 개념이 집합(aggregation)이 채워준다. 집합이란 포함된 객체가 독립적으로 존재할 수 있는 조합을 뜻한다. 대학은 학부를 가질 수 있고, 학부는 교수들을 가지고 있지만, 대학이 폐교되고 학부가 폐지되어도 교수들은 여전히 남아있다.
대학과 학부의 관계는 엄밀한 의미로 사용한 조합의 관계이고 학부와 교수의 관계는 집합의 관계이다. 교수들은 스스로 존재할 수 있다.
이 두 개념을 정교하게 구분하는 것이 실제 코드 작성에 별 도움이 되지 않을 수 있으나, 이제 이 개념을 알게 되었으니 대체적으로 조합이라는 개념을 사용하다 필요할 때는 집합의 개념을 사용하면 된다.
상속과 조합 중 하나 선택하기
고전적 상속은 '코드를 배치하는 기술' 이라는 점을 기억하자. 객체들을 상속 관계 속에 배치한 대가로 메세지 전달을 공짜로 얻게 된 것이다.
조합은 이 대가와 이득을 거꾸로 뒤집은 것이다. 조합을 사용하면 객체들은 각자 독립적으로 존재하지만, 그 대신 관계를 맺고 있는 객체를 알고 있어야 하며 직접 메세지를 전달해야 한다.
조합은 객체들에게 구조적 독립성을 보장해주지만 이는 직접 메세지를 전달하는 대가를 치를 때에만 가능하다.
어떤 상황에서 어떤 기술을 사용해야 하는가? 일반적인 원칙은 다음과 같다.
조합이 해결할 수 있는 문제라면 조합을 사용한다.
상속이 더 좋은 해결책이라고 확신할 수 없다면 조합을 사용한다.
조합은 상속보다 내재적으로 훨씬 적은 의존성을 갖고 있기 때문이다.
위험 요소가 적지만 그 대가가 클 때는 상속을 선택한다.
상속의 이점
2장에서 "코드는 투명하고(transparent), 적절하고(reasonable), 사용가능하고(usable), 모범이 되어야(exemplary) 해야 한다." 라는 기준을 제시했다. 상속을 제대로 적용한 코드는 2,3,4번째 목표를 훌륭히 수행한다.
제대로 구조화된 상속의 관계는 매우 적절하다(reasonable). 코드의 작은 부분만 수정해도 행동의 변화를 크게 이끌어낼 수 있다.
코드에 상속을 적용한 결과를 열려있고-닫힌(open-closed) 상태라고 표현할 수 있다. 상속 관계는 확장에 열려있고, 동시에 수정에는 닫혀있다.
상속의 비용
상속이 어울리지 않는 문제를 해결하는데 상속을 사용할 수 있다.
이런 실수를 저지르면 나중에 새로운 행동을 추가하는 순간이 올 때 큰 어려움을 겪을 것이다.
구조 자체가 잘못되어 있기 때문에 새로운 행동이 끼어들 자리가 없다. 어쩔 수 없이 코드의 중복을 하용하거나 코드를 다시 작성하게 된다.
상속이 문제를 해결하기 위한 적절한 방법일지라도 다른 프로그래머가 먼저 작성된 코드의 의도와 다른 방식으로 사용할 수 있다.
다른 프로그래머는 우리가 만든 행동이 필요하지만 상속이 강제하는 의존성을 받아들이고 싶지 않을 수 있다.
앞서 '제대로 상속이 적용된 코드라면' 이라는 예를 붙여 적절하고(reasonable), 사용가능하고(usable), 모범이 되는(exemplary) 것이라 한정해 왔다. 상속이 어울리지 않는 문제에 상속이 적용되면 양날의 검 처럼 작동한다.
모범이 되는 것(exemplary)의 반대편에는 초보 프로그래머가 잘못된 상속 관계를 확장하려 했을 때 엄청난 혼란이 올 수 있다는 것을 의미한다. 적용할 수 없는 상속 관계를 발견하면 확장하지 말고 리팩터링 해야 하지만, 초보 프로그래머에게 이런 능력을 기대할 수는 없다.
상속은 "내가 실수하면 어떤 일이 벌어질까?" 라는 질문을 아주 중요하게 고려해야 한다.
디자인 자체가 하위클래스를 상위클래스로부터 떨어질 수 없도록 묶어놓기 때문에 상위클래스에서 변경이 이루어졌을 때 영향력을 극대화한다. 코드의 작은 부분을 수정해도 넓은 영역에 거대한 영향을 미칠 수 있는 것이다.
마지막으로 얼마나 많은 사람들이 내가 만든 코드를 사용할지도 고려해야 한다.
잘 아는 소수의 사람들만 사용하는 내부용 애플리케이션이라면 변경을 예상하기 쉬울 것이므로, 상속을 사용하는 것이 효율적인 해결책이라고 확신할 수 있다.
객체를 상속받아야만 행동을 가져올 수 있는 프레임워크를 만드는 것은 좋지 않다. 누군가의 애플리케이션은 이미 자신의 상속 관계를 가지고 있기 때문에 우리가 만든 프레임워크를 상속받는 것 자체가 불가능할 수도 있다.
조합의 이점
조합을 사용하면 명확한 책임과 명료한 인터페이스를 갖는 작은 객체를 여럿 만들게 되는 경향이 있다.
조합된 객체는 자신의 부분들을 인터페이스를 통해 관리하기 때문에 한 부분을 새로 추가하는 것이 상대적으로 쉽다. 주어진 인터페이스를 충실히 따르는 객체를 추가하기만 하면 된다.
조합에 관여하는 객체들은 본질적으로 그 크기가 작다. 구조적으로 독립되어 있고 잘 정의된 인터페이스를 가지고 있다. 이런 특징 덕에 객체들을 자연스럽게 추가하고 제거할 수 있으며, 객체들을 서로 대체할 수 있는 요소로 만들어준다.
조합이 이끌어낼 수 있는 최고의 시나리오를 상상해보면 애플리케이션이 작고, 추가하거나 제거하기 쉽고, 확장하기 용이한 객체들로 이루어지는 것이다. 이런 애플리케이션은 변화에 유연하게 대응할 수 있다.
조합의 비용
조합된 객체들은 여러 부분들과 관계를 맺고 있다. 개별 부분들은 충분히 투명(transparent)하더라도, 이 부분들이 모여 전체가 작동하는 방식은 불명확할 수 있다.
구조로부터 독립성은 자동화된 메세지 전달을 포기하여 얻은 것이다. 조합된 객체는 누구에게 어떤 메세지를 전달해야할지 명확하게 알고 있어야 한다. 메세지 전달을 위해 동일한 코드가 여러 객체 속에 분산되어 존재하지만 조합은 이 코드들을 한 곳에 모아줄 수 없다.
결론적으로, 조합을 사용하면 여러 부분으로 이루어진 객체를 훌륭하게 조립할 수 있지만 매우 비슷한 부분들을 정리해야 하는 상황에서는 별 도움을 주지 못한다.
몇 가지 인용문을 통해 상속과 조합을 어느 때에 선택하면 좋을 지 알아볼 수 있다. (출처는 책에 있으므로 생략)
"상속은 특수화이다."
"상속은 이미 존재하는 클래스에 새로운 기능을 추가할 때 가장 잘 어울린다. 기존 코드의 대부분을 계속 사용하면서 상대적으로 적은 양의 새로운 코드를 추가하는 상황에 어울린다."
"주어진 행동이 자신의 부품들의 총합 이상일 때 조합을 사용하라."
'무엇이다(is-a)' 관계에서 상속 사용하기
현실세계에서 볼 수 있는 물체 중에 고정적이고 일반-특수의 상속 관계가 뚜렷한 것은 고전적 상속으로 구조화하기 좋은 대상이다.
자전거 부품 중에 샥(shock)이 있는데, 샥이 여러 종류 주어진다 해도 다양한 샥에 대한 가장 정확하고 상세한 설명은 'it is-a shock' 정도가 될 것이다.
이런 문제는 상속으로 해결하기 좋다. 다양한 샥은 낮고 넓은 피라미드형 상속 관계로 구조화할 수 있기 때문이다.
상속 관계의 범위가 좁기 때문에 이해하기 쉽고, 의도가 잘 드러나며, 쉽게 확장할 수 있다.
'무엇처럼 행동하는(behaves-like-a)' 관계에는 오리 타입을 사용하기
어떤 문제는 여러 개의 객체가 같은 역할을 수행해야 하는 상황을 만든다.
코드 속에 숨어있는 역할이 표현되는 경우
객체가 역할을 수행하고 있지만, 그 역할이 객체의 핵심적인 책임이 아닌 경우
코드의 여러 곳에서 특정 역할을 수행하려고 하는 경우
역할을 이해하기 위한 좋은 방법 중 하나는 외부의 관점에서 생각해 보는 것이다.
역할을 수행하는 객체의 관점이 아니라, 역할을 부여하는 객체의 관점에서 생각해 보는 것이다.
일단 역할이 존재한다는 것을 인지하고 나면 오리 타입을 만들어 인터페이스를 정의하고, 주어진 역할의 모든 수행자(player)들이 따를 수 있는 인터페이스를 구현해야 한다.
'가지고 있는(has-a)' 관계에서 조합 사용하기
많은 객체들은 여러 부분으로 이루어져 있지만, 객체 자체는 부분들의 총합 이상의 것이다. 자전거는 부품들(parts)을 가지고 있지만 자전거 자체는 부품들의 묶음 이상인 것 처럼.
자전거는 부품들의 행동과는 전혀 다른, 그리고 부품들의 행동보다 더 나아간 그 고유의 행동을 가지고 있다.
무엇이다(is-a)와 가지고 있다(has-a) 사이의 구분은 상속과 조합 사이에서 어떤 디자인을 선택할지 결정하는데 핵심적인 요소이다.
객체가 많은 부분을 가지고 있을 수록 조합을 사용하여 객체를 디자인하는 것이 더 어울린다.
개별 부품이 몇 가지 변형된 형태만을 갖는 특정 부품들을 만나게 되는 경우 이런 부품들은 상속을 사용하기 좋은 대상들이다.
모든 문제에 대해 몇 가지 디자인이 제공하는 비용과 이득을 저울질해보고, 자신의 경험과 판단을 가지고 최선의 선택을 해야 한다.
요약
조합을 이용하면 작은 부분들을 가지고 복잡한 객체를 만들 수 있고, 이렇게 조합된 객체는 부분들의 총합 이상의 것이다.
조합된 객체는 간단하고 독립적인 개체들(entities)로 이루어지는 경향이 있고 이런 개체들은 새롭게 조합하고 재배치하기 쉽다.
간단한 객체는 이해하기 쉽고 재사용하고 테스트하기 용이하다. 하지만 이런 객체들이 모여 보다 복잡한 하나의 전체를 이루기 때문에 애플리케이션 전체의 작동 방식은 개별 부분들만큼 이해하기 쉽지 않을 수 있다.
조합, 상속 등 각각을 제대로 사용하는 것은 경험과 판단력의 문제이다. 경험을 쌓는 가장 좋은 방법을 직접 실수를 통해 배우는 것이다.
디자인 기술을 향상시키고자 한다면 기술을 적극적으로 사용해 보고 실수를 받아들일 줄 알아야 한다. 잘못된 디자인 결정을 버리고 가차없이 새로운 방식으로 리팩터링하면 된다.
경험이 쌓여갈수록 처음부터 올바른 기술을 선택할 수 있게 될 것이다. 디자인 선택의 비용도 내려간다. 그리고 우리의 애플리케이션은 더욱 발전할 것이다.