루비로 배우는 객체지향 디자인 - 8장

Created
Sep 30, 2020 07:41 AM
Tags

조합을 이용해 객체 통합하기

  • 7장은 루비의 '모듈' 을 이용한 방식이라 패스
  • 교향곡으로 비유
    • 작곡가는 음표를 모아 음악을 작곡(compose, 조합과 동음어)하지만 음악은 음표들의 총합 이상이다.
    • 교향곡은 음표들을 포함하고 있지만 그 자체가 음표들인 것은 아니다. 여기에는 그 이상의 것이 있다.
  • 객체지향 조합을 이용하면 간단하고 독립적인 객체를 보다 크고 복합적인 것으로 통합할 수 있다.
    • 조합에서 좀 더 큰 객체는 자신의 부분들을 가지고 있다. 즉 가지고 있는(has-a) 관계를 맺는다.
    • 각 조합의 부품들을 가지고 있을 뿐 아니라 인터페이스를 통해 각 부품들과 소통한다.
    • 부분이란 곧 역할이며, 부분의 합(예를 들어 자전거)은 주어진 역할을 수행하는 어떤 부품(객체)와도 즐겁게 협업할 수 있다.

자전거 부품 조합하기

  • 6장에서 상속을 활용한 자전거 클래스 구현 예시를 조합의 형태로 교체하기
    • 기존 코드를 무시하고 자전거가 어떻게 조합되어야 하는가를 고민해보자
    • Bicycle 클래스가 spares 메세지에 반응하여 예비 부품 목록을 반환한다. 자전거의 모든 부품을 들고 있는 객체를 만든다면 spares 메세지를 이 객체에 전달할 수 있을 것이다.
    • 그래서 부품의 모음을 담당할 Parts 라는 클래스가 필요하다.
notion imagenotion image
  • 새로운 디자인을 적용하기 위해 이전 코드 대부분을 지우고 parts 변수를 추가한 뒤, sparesparts 에 위임한다.
class Bicycle { constructor({ size, parts } = {}) { this.size = size; this.parts = parts; } get spares() { return this.parts.spares; } }
  • Parts 로 상속 관계를 만들어보자
    • Bicycle 에서 제거되었던 부품의 행동들을 구현해야한다. 제일 먼저 Parts 의 상속관계로 만들어본다.
class Parts { constructor(args = {}) { this.chain = args.chain || this.defaultChain; this.tireSize = args.tireSize || this.defaultTireSize; this.postInitialize(args); } postInitialize(args) {} get spares() { return { tireSize: this.tireSize, chain: this.chain, ...this.localSpares, }; } get defaultTireSize() { throw new Error("Not Implemented"); } get defaultChain() { return "10-speed"; } get localSpares() { return {}; } } class RoadBikeParts extends Parts { postInitialize(args) { this.tapeColor = args.tapeColor; } get localSpares() { return { tapeColor: this.tapeColor, }; } get defaultTireSize() { return "23"; } } class MountainBikeParts extends Parts { postInitialize(args) { this.frontShock = args.frontShock; this.rearShock = args.rearShock; } get localSpares() { return { rearShock: this.rearShock, }; } get defaultTireSize() { return "2.1"; } }
  • 6장과 비교하여 Bicycle 의 상속 관계와 거의 똑같지만 약간의 차이가 있는 정도이다.
  • 자전거는 RoadBikeParts 를 가지고 있든 MountainBikeParts 를 가지고 있든 자신의 size, spares 를 알고 답할 수 있다.
const roadBike = new Bicycle({ size: "L", parts: new RoadBikeParts({ tapeColor: "red" }), }); console.log(roadBike.size); // => 'L' console.log(roadBike.spares); // => { tireSize: '23', chain: '10-speed', tapeColor: 'red' } const mountainBike = new Bicycle({ size: "L", parts: new MountainBikeParts({ rearShock: "Fox" }), }); console.log(mountainBike.size); // => 'L' console.log(mountainBike.spares); // => { tireSize: '2.1', chain: '10-speed', rearShock: 'Fox' }
  • 6장의 구현체 동작과 엄청난 차이가 있진 않지만 결정적인 차이를 발견할 수 있다. Bicycle 클래스에 포함된 자전거 관련 코드는 사실 별로 없었다는 점이다.
  • 위의 코드 대부분은 개별 부품을 다루고 있다. 그리고 Parts 상속 관계를 리팩터링하면 어떤 식으로 될까?

Parts 객체 조합하기

  • 각각의 부품을 담당하는 클래스를 만들 예정인데, 각각의 부분에 Part 라는 이름이 붙으면서 용어의 혼란이 올 수 있다. (영어권이라 복수 및 단수 때문으로 보임)
notion imagenotion image
  • 위의 디자인을 적용하기 위해 새 Part 클래스를 만들어본다. PartsPart 의 조합이 된다.
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에서는 Partslength 메서드 구현을 해주는게 가장 쉽게 문제를 해결할 수 있는 방법으로 보인다.

Parts 생산하기

  • 우리의 애플리케이션 어딘가에서 누군가는 Part 들을 만드는 법을 알고 있어야 한다. 또한 roadBike 를 만들기 위해서는 3개의 부품이 필요하다는 사실도 일고 있어야 한다.
  • 이런 지식이 여러 곳에 흩뿌려지는 것은 좋지 않은데다 불필요한 일이다. 자전거의 종류를 설명하고 이 설명에 따라 필요한 부품들을 자동으로 생산할 수 있다면 문제가 훨씬 간단해질 것이다.
const roadConfig = [ ["chain", "10-speed"], ["tireSize", "23"], ["tapeColor", "red"], ]; const mountainConfig = [ ["chain", "10-speed"], ["tireSize", "2.1"], ["frontShock", "Manitou", false], ["rearShock", "Fox"], ];
  • 객체와 달리 이차원 배열은 구조에 대한 정보를 제공하지 않지만, 이 구조가 어떤 방식으로 정리되어있는지 알고 있고 이 지식을 가지고 Parts 를 생성하는 객체 속에 넣어두면 된다.
  • 그러니 PartsFactory 를 만들어보자. 팩토리라는 용어가 약간 어렵게 다가올 수도 있지만, 부자연스럽거나 쓸데없이 복잡한 것이 아니다. 이 단어는 '다른 객체를 만드는 객체' 라는 관념을 좀 더 간결하게 소통하기 위해 객체지향 디자이너들이 사용하는 개념일 뿐이다.
class PartsFactory { static build(config, partClass = Part, partsClass = Parts) { return new partsClass( config.map( (partConfig) => new partClass({ name: partConfig[0], description: partConfig[1], needSpare: partConfig[2] || true, }) ) ); } }
  • config 구조에 대한 지식을 팩토리 안에 넣어둘 경우 두 가지 결과가 나온다.
    • config 가 간결하게 표현될 수 있다. PartsFactoryconfig 내부 구조를 알고 있기 때문에 객체가 아니라 배열 형태로 작성할 수 있다.
    • 한 번 configs 를 배열로 관리하기 시작하면, 새로운 Parts 를 만들 때는 언제나 팩토리를 사용해야 한다. 다른 방식으로 만들려면 config.map 안의 매퍼 함수의 지식을 중복하게 된다.
const roadParts = PartsFactory.build(roadConfig) console.log(roadParts); /* Parts { parts: [ Part { name: 'chain', description: '10-speed', needSpare: true }, Part { name: 'tireSize', description: '23', needSpare: true }, Part { name: 'tapeColor', description: 'red', needSpare: true } ] } */
  • 이런 식으로 부품을 만들기 위한 모든 지식이 고립되었다. 예전에 애플리케이션 곳곳에 흩뿌려져 있었던 지식이 하나의 클래스와 두 개의 배열 속에 격리된 것이다.
  • Part 라는 클래스도 생각을 해 보면 단순한 Struct 수준이기 때문에 루비에서는 OpenStruct 로 대체해버릴 수 있다. JS라면 객체 리터럴로 선언하는 것만 해도 될 것 같다. 아래와 같이 바꾸어도 기존 동작에 차이는 없다.
class PartsFactory { static build(config, partsClass = Parts) { // 2번째 인자 없앰 return new partsClass( config.map((partConfig) => ({ // 클래스의 인스턴스가 아니라 객체 리터럴 리턴 name: partConfig[0], description: partConfig[1], needSpare: partConfig[2] || true, })) ); } }

조합된 Bicycle

  • BicycleParts 를 가지고(has-a) 있다. 이어서 PartsPart 의 모음을 가지고(has-a) 있다. 위에서 새로이 작성한 코드를 조합해보자.
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); } get length() { return this.parts.length; } } class PartsFactory { static build(config, partsClass = Parts) { return new partsClass(config.map(this.createPart)); } static createPart(partConfig) { return { name: partConfig[0], description: partConfig[1], needSpare: partConfig[2] || true, }; } } const roadConfig = [ ["chain", "10-speed"], ["tireSize", "23"], ["tapeColor", "red"], ]; const mountainConfig = [ ["chain", "10-speed"], ["tireSize", "2.1"], ["frontShock", "Manitou", false], ["rearShock", "Fox"], ];
  • 이 클래스들 덕분에 이제 새로운 종류의 자전거를 만드는 것이 아주 쉬워졌다. 6장에서 리컴벤트 자전거를 만들기 위해 들었던 줄 수에 비해 이제는 4줄짜리 설정이면 된다.
const recumbentConfig = [ ["chain", "9-speed"], ["tireSize", "28"], ["flag", "tall and oragne"], ]; const recumbentBike = new Bicycle({ size: "L", parts: PartsFactory.build(recumbentConfig), }); console.log(recumbentBike.spares); /* [ { name: 'chain', description: '9-speed', needSpare: true }, { name: 'tireSize', description: '28', needSpare: true }, { name: 'flag', description: 'tall and oragne', needSpare: true } ] */
  • 집합(Aggregation): 새로운 종류의 조합(Composition)
    • 조합은 종종 위임을 사용한다. 조합된 객체는 잘 정의된 인터페이스를 통해 협업할 줄 아는 여러 부분들로 구성되어 있다.
    • 조합은 가지고 있는 관계(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)로 이루어지는 경향이 있고 이런 개체들은 새롭게 조합하고 재배치하기 쉽다.
  • 간단한 객체는 이해하기 쉽고 재사용하고 테스트하기 용이하다. 하지만 이런 객체들이 모여 보다 복잡한 하나의 전체를 이루기 때문에 애플리케이션 전체의 작동 방식은 개별 부분들만큼 이해하기 쉽지 않을 수 있다.
  • 조합, 상속 등 각각을 제대로 사용하는 것은 경험과 판단력의 문제이다. 경험을 쌓는 가장 좋은 방법을 직접 실수를 통해 배우는 것이다.
  • 디자인 기술을 향상시키고자 한다면 기술을 적극적으로 사용해 보고 실수를 받아들일 줄 알아야 한다. 잘못된 디자인 결정을 버리고 가차없이 새로운 방식으로 리팩터링하면 된다.
  • 경험이 쌓여갈수록 처음부터 올바른 기술을 선택할 수 있게 될 것이다. 디자인 선택의 비용도 내려간다. 그리고 우리의 애플리케이션은 더욱 발전할 것이다.