-
추상화
사람의 속성이나 행동을 생각해보자. 사람이라고 판단할 수 있는 속성이나 사람이 할 수 있는 행동은 너무 많으므로 사람의 모든 속성과 행동을 기술할 수는 없을 것이다. 또한 사람이라고 판단되는 속성이나 행동은 필요에 따라 선별해서 사용할 수 있다.
추상화란 어떤 영역에서 필요로 하는 속성이나 행동을 추출하는 작업을 의미한다.
추상화는 사물들의 공통된 특징, 즉 추상적 특징을 파악해 인식의 대상으로 삼는 행위다. 추상화가 가능한 개체들은 개체가 소유한 특성의 이름으로 하나의 집합(class)을 이룬다.
만약 추상화가 없다면 우리는 각각의 개체를 구분해야 할 것이다. 가령 자동차 종류마다 엔진 오일을 교환하는 방식이 다르다고 하자.
switch(자동차 종류) { case: 아우디: break; // 아우디 엔진 오일을 교환하는 과정을 기술 case: 벤츠: break; // 벤츠 엔진 오일을 교환하는 과정을 기술 }
이때 BMW와 같은 새로운 종류의 자동차 엔진 오일을 교환하는 기능을 추가하라는 요구사항이 있을 경우 BMW의 엔진 오일을 교환하는 case문을 더 추가해야 한다.
switch(자동차 종류) { case: 아우디: break; // 아우디 엔진 오일을 교환하는 과정을 기술 case: 벤츠: break; // 벤츠 엔진 오일을 교환하는 과정을 기술 case: BMW: break; // BMW 엔진 오일을 교환하는 과정을 기술 }
아우디, 벤츠, BMW와 같은 구체적인 자동차 대신 이들의 추상화 개념인 자동차를 이용할 경우라면 다음과 같이 작성했을 것이다.
const changeEngineOil = (c: Car): void => { c.changeEngineOil(); };
캡슐화
소프트웨어 공학에서 요구사항 변경에 대처하는 고전적인 설계 원리로는 응집도(cohesion)와 결합도(coupling)가 있다.
응집도는 클래스나 모듈 안의 요소들이 얼마나 밀접하게 관련되어 있는지를 나타내고, 결합도는 어떤 기능을 수행하는 데 다른 클래스나 모듈들에 얼마나 의존적인지를 나타낸다. 높은 응집도와 낮은 결합도를 유지할 수 있도록 설계해야 요구사항을 변경할 때 유연하게 대처할 수 있다.
캡슐화는 특히 낮은 결합도를 유지할 수 있도록 해주는 객체지향 설계 원리다. 캡슐화는 정보 은닉을 통해 높은 응집도와 낮은 결합도롤 같도록 한다. 정보 은닉이란 말 그대로 알 필요가 없는 정보는 외부에서 접근하지 못하도록 제한하는 것이다.
정보 은닉은 왜 필요한 것일까? 소프트웨어는 결합이 많을수록 문제가 많이 발생한다. 한 클래스가 변경이 발생하면 변경된 클래스의 비밀에 의존하는 다른 클래스들도 변경해야 할 가능성이 커진다는 뜻이다.
class ArrayStack { public top: number; public itemArray: number[]; public stackSize: number; public constructor(stackSize: number) { this.itemArray = Array.from({ length: stackSize }); this.top = -1; this.stackSize = stackSize; } public isEmpty(): boolean {} public isFull(): boolean {} public push(item: number): void {} public pop(): number {} public peek(): number {} } class StackClient { public static init() { const st = new ArrayStack(10); st.itemArray[++st.top] = 20; console.log(st.itemArray[st.top]); } } StackClient.init();
주의해서 볼 것은 자료구조에 모두 public 키워드를 붙여 외부에 공개되어 있다는 점이다. 즉 push 메서드나 pop 메서드를 사용하지 않고 직접 배열에 값을 저장할 수 있다. 이런 경우 ArrayStack과 StackClient 클래스는 강한 결합이 발생한다.
가령 ArrayList 클래스를 사용해 스택 구현이 변경되면 StackClient 클래스도 따라서 변경 되어야 하는데, 이는 StackClient 클래스가 은닉된 정보를 직접 사용했기 때문이다. 따라서 이 은닉 정보가 변경되면 해당 정보를 사용한 쪽도 모두 변경되어야 한다.
이 문제를 해결하려면 변경되는 곳을 파악해 이를 은닉한다. 스택 예에서는 자료구조가 변경될 가능성이 크므로 자료구조의 형태와 관련이 있는 top, itemArray, stackSize 클래스를 다음과 같이 외부에서 접근하지 못하도록 private 키워드를 붙여 은닉한다.
private top: number; private itemArray: number[]; private stackSize: number;
지금부터는 push, pop, peek 메서드의 연산으로만 스택을 사용할 수 있다. 하지만 push, pop, peek 메서드 등이 어떤 방식으로 어떤 자료구조를 사용해 작업을 실행하는지는 알 수가 없다. 즉, 스택과 이를 사용하는 코드의 결합이 낮아지는 것이다.
class StackClient { public static init() { const st = new ArrayStack(10); st.push(20); console.log(st.peek()); } }
일반화 관계
1. 일반화는 또 다른 캡슐화
일반화 관계는 객체지향 프로그래밍 관점에서는 상속 관계라 한다. 속성이나 기능의 재사용만 강조해서 사용하는 경우가 많기 때문에 객체 지향 개념에서 가장 많이 오용되고 있는 것이 일반화 관계다.
철학에서 일반화는 '여러 개체들이 가진 공통된 특성을 부각시켜 하나의 개념이나 법칙으로 성립시키는 과정'이라 한다. 예를 들어 사과, 배, 바나나, 오렌지 등이 가진 동통된 개념은 무엇이냐고 묻는다면 거의 대부분 과일이라 대답할 것이다. 즉 과일은 사과, 배, 바나나, 오렌지 등이 가진 공통 개념을 일반화한 개념이며 사과, 배, 바나나, 오렌지 등은 과일의 한 종류이므로 과일을 특수화한 개념이다.
let 가격 총합 = 0; while (장바구니에 과일이 있다) { switch (과일 종류) { case 사과: 가격 총합 += 사과 가격 case 배: 가격 총합 += 배 가격 case 바나나: 가격 총합 += 바나나 가격 case 오렌지: 가격 총합 += 오렌지 가격 } }
각각의 과일 종류를 일일이 고려해 코드를 작성하면 새로운 과일의 종류가 나타날 때마다 항상 코드를 수정해야 하므로 변경사항에 유연성 있게 대처하지 못한다. 따라서 새로운 과일의 종류가 추가되더라도 코드를 수정할 필요가 없도록 바꿔야 한다.
const computeTotalPrice = (fruits: Fruit[]) => { let total = 0; for (let curFruit of fruits) { total += curFruit.calculatePrice(); } return total; };
지금까지 살펴본 일반화 관계는 외부 세계에 자식 클래스를 캡슐화(또는 은닉)하는 개념으로 볼 수 있으며, 이때 캡슐화 개념은 한 클래스 안에 있는 속성 및 연산들의 캡슐화에 한정되지 않고 일반화 관계를 통해 클래스 자체를 캡슐화하는 것으로 확장된다. 이러한 서브 클래스 캡슐화는 외부 클라이언트가 개별적인 클래스들과 무관하게 프로그래밍을 할 수 있게 한다.
2. 일반화 관계와 위임
class MyStack extends ArrayList { public push(element: string): void { add(element); } public pop(): string { return remove(size() - 1); } }
ArrayList 클래스를 상속받아 Stack 클래스를 만들었다. 아마 프로그래머의 의도는 ArrayList 클래스에 정의된 isEmpty, size, add, remove 메서드를 자신이 구현하지 않고 그래로 사용하길 원했을 것이다. 기능의 재사용이라는 측면으로만 보면 결론은 성공적이라고 볼 수 있다. 그러나 ArrayList 클래스에 정의된 스택과 전혀 관련 없는 수많은 연산이나 속성도 같이받게 된다.
그렇다면 어떤 클래스의 일부 기능만 재사용하고 싶은 경우에는 어떻게 하는 것이 좋을까? 답은 위임을 사용하는 것이다. 위임은 자신이 직접 기능을 실행하지 않고 다른 클래스의 객체가 기능을 실행하도록 위임하는 것이다.
다음은 위임을 사용해 일반화(상속)을 대신하는 과정이다.
자식 클래스에 부모 클래스의 인스턴스를 참조하는 속성을 만든다. 이 속성 필드를 this로 초기화한다.
class MyStack extends ArrayList { + private arList: ArrayList = this; public push(element: string): void { add(element); } public pop(): string { return remove(size() - 1); } }
서브 클래스에 정의된 각 메서드에 1번에서 만든 위임 속성 필드를 참조하도록 만든다.
class MyStack extends ArrayList { private arList: ArrayList = this; public push(element: string): void { - add(element); + arList.add(element); } public pop(): string { - return remove(size() - 1); + return arList.remove(arList.size() - 1); } }
서브 클래스에서 일반화 관계 선언을 제거하고 위임 속성 필드에 슈퍼 클래스의 객체를 생성해 대입한다.
- class MyStack extends ArrayList { + class MyStack { - private arList: ArrayList = this; + private arList: ArrayList = new ArrayList(); public push(element: string): void { arList.add(element); } public pop(): string { return arList.remove(arList.size() - 1); } }
서브 클래스에서 사용된 슈퍼 클래스의 메서드에도 위임 메서드를 추가한다.
class MyStack { private arList: ArrayList = new ArrayList(); public push(element: string): void { arList.add(element); } public pop(): string { return arList.remove(arList.size() - 1); } + public isEmpty(): boolean { + return arList.isEmpty(); + } + public size(): number { + return arList.size(); + } }
다형성
객체지향에서 다형성은 '서로 다른 클래스의 객체가 같은 메세지를 받았을 때 각자의 방식으로 동작하는 능력'이다.
다형성을 사용하지 않는 경우
class Dog { public bark(): void {} } class Cat { public meow(): void {} } class Parrot { public sing(): void {} } const dog: Dog = new Dog(); const cat: Cat = new Cat(); const parrot: Parrot = new Parrot(); dog.bark(); cat.meow(); parrot.sing();
다형성을 사용하는 경우
abstract class Pet { public abstract talk(): void; } class Dog extends Pet { public talk(): void {} } class Cat extends Pet { public talk(): void {} } class Parrot extends Pet { public talk(): void {} } const groupTalk = (pets: Pet[]) => { pets.forEach((pet) => pet.talk()); }; const pets: Pet[] = [new Dog(), new Cat(), new Parrot()]; groupTalk(pets);
다형성을 사용하지 않는 경우는 클래스별로 다르게 처리해주어야 하지만 다형성을 사용하는 경우에는 구체적으로 현재 어떤 클래스 객체가 참조되는지와 무관하게 프로그래밍을 할 수 있다. 따라서 새로운 애완동물을 나타내는 클래스가 자식 클래스로 추가되더라도 코드는 영향을 받지 않는다.
피터 코드의 상속 규칙
피터 코드는 상속의 요용을 막기 위해 상속의 사용을 엄격하게 제한하는 규칙들을 만들었다. 다음 5가지 규칙이 있으며 어느 하나라도 만족하지 않는다면 상속을 사용해서는 안된다.
- 자식 클래스와 부모 클래스 사이는 '역할 수행' 관계가 아니어야 한다.
- 한 클래스의 인스턴스는 다른 서브 클래스의 객체로 변환할 필요가 절대 없어야 한다.
- 자식 클래스가 부모 클래스의 책임을 무시하거나 재정의하지 않고 확장만 수행해야 한다.
- 자식 클래스가 단지 일부 기능을 재사용할 목적으로 유틸리티 역할을 수행하는 클래스를 상속하지 않아야 한다.
- 자식 클래스가 역할, 트랜잭션, 디바이스 등을 특수화해야 한다.