
- #typescript
타입스크립트 - OOP와 타입스크립트
Prolip
2024-12-31
OOP가 무엇인지 정리하기, 타입스크립트를 활용한 객체 지향 프로그래밍 알아보기..
OOP(객체지향 프로그래밍)란?
객체지향 프로그래밍(Object-Oriented Programming) 은 프로그램을 객체(Object) 라는 기본 단위로 나누어 설계하는 프로그래밍 패러다임이다.
핵심이 되는 키워드는 다음과 같다
- 객체 (Object): 데이터와 메서드(동작이라고 생각하면 이해하기 쉽다.)가 함께 모인 독립적인 단위.
- 클래스 (Class): 위의 객체를 생성하기 위한 템플릿, 설계도, 혹은 청사진이라고 생각하자.
- 인스턴스 (Instance): 위의 클래스를 통해 만들어낸 실제 구현체.
- 메서드 (Method): 객체가 할 수 있는 동작을 정의하는 함수.
- 속성 (Property): 객체가 가지고 있는 데이터.
OOP의 목표는 기본적으로 코드의 재사용성, 확장성, 유지보수성을 향상시키는 것이다.
물론 작은 단위의 프로그램에선 필요 없을 수 있지만, 복잡한 프로그램일수록 코드의 가독성, 기능 추가 및 버그 수정 측면에서 빛을 발한다.
OOP의 4대 원칙으로 캡슐화(Encapsulation), 추상화(Abstraction), 상속(Inheritance), 다형성(Polymorphism) 이 있다.
먼저 우리가 일상 생활에서 자주 볼 수 있는 커피 머신을 절차지향적으로 구현한 뒤, 한계점을 파악하고 위의 4대 원칙에 대해 자세히 알아보며 코드를 개선해보겠다.
절차지향적으로 커피 머신 구현해보기
let coffeeBeans = 100; const BEANS_GRAM_PER_SHOT = 12; function coffeeMachine(shots) { const neededBeans = shots * BEANS_GRAM_PER_SHOT; if (coffeeBeans < neededBeans) { throw new Error("Not enough coffee beans!"); } coffeeBeans -= neededBeans; return { shots, hasMilk: false, }; } function refill(beans) { if (beans <= 0) { throw new Error("value for beans should be greater than 0"); } coffeeBeans += beans; } const coffee = coffeeMachine(2);
절차지향적으로 구현한 간단한 커피 머신이다. 차근차근 살표보겠다.
1. coffeeBenas: 현재 가지고 있는 커피콩을 의미한다.
2. BEANS_GRAM_PER_SHOT: 샷 하나에 필요한 커피콩의 개수이다.
3. coffeeMachine
- 해당 함수는 매개변수로 전달된 shots와 BEANS_GRAM_PER_SHOT를 곱해 neededBenas에 할당한다.
- 또, 현재 가진 커피 콩이 필요한 커피콩의 개수보다 적다면 에러를 던진다. 만약 커피콩이 넉넉하게 있다면 현재 커피콩을 필요한 만큼 빼고, 커피를 반환한다.
4. refill: 추가하고 싶은 콩의 개수를 전달해 전역으로 선언된 커피콩에 추가한다.
이렇게 커피 머신은 위 명령어들의 순차적인 실행 흐름으로 이루어져있으며, 함수 중심으로 동작한다. coffeeMachine 함수와 refill 함수가 주요 로직을 담당하며 이 두 함수가 특정 동작을 수행한다는 의미이다.
이렇게 작은 규모의 커피 머신은 문제 없이 잘 동작할 것이며, 딱 보기에 가독성도 나쁘진 않아보인다.
하지만 여러 문제점이 존재하는데 하나씩 짚어보자.
1. coffeeBeans가 전역 변수로 선언되어있다.
전역 변수로 선언된 커피콩을 외부에서 쉽게 변경할 수 있어 예상치 못한 에러를 발생시킬 가능성이 크다.
2. 재사용성, 확장성이 부족하다.
만약 새로운 기능이 추가된다면 이 함수의 구조가 크게 변경될 것이다. 가령 커피를 내리는 과정에 만약 우유를 추가하고 싶다면? 혹은 설탕을 추가하고 싶다면?
이런 경우 모든 코드를 수정해야만한다.
OOP와 Typescript?
Typescript는 정적 타입 시스템과 클래스 문법을 지원해 OOP의 개념을 명확하고 직관적으로 구현할 수 있다.
타입 검사와 인터페이스를 활용해 코드의 안정성도 높일 수 있고, 유지보수도 쉽게 만들 수 있다.
Typescript에서 지원하는 interface, private, implements를 사용하면 아래에서 살펴보겠지만, 정적 타입 검사를 통해 개발 중 버그를 발견하기 쉬울 뿐더러, 명확한 구조를 제공해 재사용성을 극대화할 수 있다.
OOP의 4대 원칙을 Typescript를 통해 적용해보기
1. 캡슐화 (Encapsulation)
캡슐화란 객체의 속성을 외부로부터 숨기고 이 속성에 직접 접근하는 것이 아닌, 이 속성을 변경하거나 접근하는 메서드를 제공하는 것을 의미한다.
내부 구현을 숨기고 불필요한 접근을 제한하면 당연히 보안을 강화할 수 있다.
타입스크립트에선 private, protected 등의 키워드로 속성 및 메서드의 접근 범위를 제한할 수 있다. getter와 setter 메서드로 내부 속성을 접근하거나 수정할 수도 있다.
이제 위에서 문제가 됐던 coffeeBeans를 캡슐화를 통해 안전하게 숨겨보자.
interface ICoffee { shots: number; hasMilk?: boolean; } class CoffeeMachine { static BEANS_GRAM_PER_SHOT = 12; constructor(private coffeeBeans: number) {} refill(beans: number) { if (beans <= 0) { throw new Error("value for beans should be greater than 0"); } this.coffeeBeans += beans; } makeCoffee(shots: number): ICoffee { const neededBeans = shots * CoffeeMachine.BEANS_GRAM_PER_SHOT; if (this.coffeeBeans < neededBeans) { throw new Error("Not enough coffee beans!"); } this.coffeeBeans -= neededBeans; return { shots, hasMilk: false, }; } } const coffeeMachine = new CoffeeMachine(100); coffeeMachine.coffeeMeans--; // Error coffeeMachine.refill(100); // OK const coffee = coffeeMachine.makeCoffee(2);
CoffeeMachine 클래스는 커피 머신을 제조하는 기계를 찍어내는 틀이라고 생각하면 된다. 이 클래스를 사용해 찍어낸 coffeeMachine은 Object, 인스턴스 등으로 부를 수 있다.
이제 이 코드도 차근차근 살펴보자.
1. constructor(private coffeeBeans: number)
- 생성자 함수로 전달 받은 coffeeBeans 앞에 private 키워드를 붙여 멤버 변수로 바로 등록하고 있다. 이는 객체가 가지는 데이터로 위에서 살펴본 속성 (Property) 에 해당한다고 볼 수 있다.
- 이 coffeeBeans는 은닉되어 외부에서 접근할 수 없게 된다. 하지만 아래와 같이 접근하는 것은 가능하다.
coffeeMachine["coffeeBeans"];
- private 키워드를 사용한 멤버 변수는 외부 인스턴스를 통해 접근하는 것은 불가하나 위와 같은 접근은 가능하다. 정말로 안전하게 막고 싶다면 # 키워드를 사용해 완전히 가릴 수 있다.
- 이렇게 private 키워드를 사용한 커피콩은 이제 refill 함수를 통해서만 추가할 수 있게 된다.
2. static BEANS_GRAM_PER_SHOT = 12;
- static 키워드를 붙이면 인스턴스 레벨이 아닌 클래스 레벨에서 사용할 수 있다. 커피를 내릴 때 샷에 필요한 커피콩의 개수는 객체가 가지고 있을 데이터라고 볼 수 있을까?
- 만약 멤버 변수로 가지고 있다면, 우리가 이 청사진을 통해 찍어낸 인스턴스에 모두 이 값을 포함시킬 것이다.
- 그럴 필요가 없으니 클래스 레벨에서 사용하도록 static 키워드를 붙인다. 이렇게 클래스 레벨로 끌어올린 데이터는 this로 접근하는 것이 아닌 해당 클래스명을 통해 접근할 수 있다.
3. refill, makeCoffee
- 이제 이 메서드들이 객체가 할 수 있는 동작에 해당하게 된다. 클래스를 통해 makeCoffee 함수를 호출하거나 혹은 refill 함수를 호출해 객체가 동작하게 된다.
2. 추상화 (Abstraction)
추상화란 여러 클래스에 걸쳐 공통적으로 사용되는 함수들의 규격을 정의하는 방법이다.
또, 추상화란 외부에서 이 클래스를 사용할 때 어떻게 사용할지, 사용자가 사용하기 편하도록 내부에서 동작할 기능과 위부에서 노출할 기능을 분리해내는 프로세스 자체라고도 볼 수 있다.
자 위에서 구현한 커피 머신에 사실 여러 기능이 있다고 가정해보자.
class CoffeeMachine { static BEANS_GRAM_PER_SHOT = 12; constructor(private coffeeBeans: number) {} refill(beans: number) { if (beans <= 0) { throw new Error("value for beans should be greater than 0"); } this.coffeeBeans += beans; } grindBeans(shots: number) { if (this.coffeeBeans < shots * CoffeeMachine.BEANS_GRAM_PER_SHOT) { throw new Error("Not enough coffee beans!"); } console.log(`grinding beans for ${shots} shots`); this.coffeeBeans -= shots * CoffeeMachine.BEANS_GRAM_PER_SHOT; } heating(): void { console.log("heating up.."); } extract(shots: number): ICoffee { console.log(`pulling ${shots} shots..`); return { shots, hasMilk: false, }; } makeCoffee(shots: number): ICoffee { this.grindBeans(shots); this.heating(); return this.extract(shots); } }
이렇게 커피를 내리기 위해서 커피콩을 갈고, 기계를 뜨겁게 만들고, 추출하는 과정에 존재한다고 가정해보자.
그럼 밖에서 이 함수를 사용할 때, refill, grindBeans, heating, extract, makeCoffee가 모두 노출되어 사용자는 도대체 무슨 함수를 호출할지 몰라 혼란스러울 것이다.
그럼 어떻게 해결할까?
위에서 살펴본 private 키워드를 사용할 수도 있다.
private grindBenas(shots: number) => private heating(): void => private extract(shots: number): ICoffee =>
이렇게 캡슐화를 통해 외부로 노출되는 함수를 가려 추상화를 성취할 수도 있다. 하지만 이 방법은 단순히 외부 노출 관점에서만 바라본 추상화 방법이라고 볼 수 있다.
그럼 더 우아한 방법은 무엇이 있을까?
interface ICoffee { shots: number; hasMilk?: boolean; } interface ICoffeeMachine { makeCoffee(shots: number): ICoffee; } class CoffeeMachine implements ICoffeeMachine { static BEANS_GRAM_PER_SHOT = 12; constructor(private coffeeBeans: number) {} refill(beans: number) { // 커피콩을 채운다. } grindBeans(shots: number) { // 커피콩을 간다. } heating(): void { // 기계를 데운다. } extract(shots: number): ICoffee { // 커피를 추출함. } makeCoffee(shots: number): ICoffee { this.grindBeans(shots); this.heating(); return this.extract(shots); } } const coffeeMachine: ICoffeeMachine = new CoffeeMachine(100); const coffee = coffeeMachine.makeCoffee(2);
바로 interface를 사용할 수 있다.
여기서 interface는 마치 계약서와 같이 행동한다. 위에서 정의한 ICoffeeMachine 인터페이스는 makeCoffee 함수를 구현하도록 명시한 일종의 계약서이다.
implements를 통해 ICoffeeMachine을 구현하도록 명시한 CoffeeMachine 클래스는 반드시 makeCoffee 함수를 구현해야만 한다. 구현하지 않는다면 타입스크립트는 이를 감지하고 개발자에게 에러를 던져준다.
이제 여기서 coffeeMachine 인스턴스에 타입을 지정해주면 해당 인스턴스에선 makeCoffee 함수를 제외한 어떤 함수에도 접근할 수 없게 된다.
interface는 아래와 같이 응용이 가능하다.
interface ICoffee { shots: number; hasMilk?: boolean; } interface ICoffeeMachine { makeCoffee(shots: number): ICoffee; } interface ICommercialCoffeeMachine { makeCoffee(shots: number): ICoffee; refill(beans: number): void; clean(): void; } class CoffeeMachine implements ICoffeeMachine { static BEANS_GRAM_PER_SHOT = 12; constructor(private coffeeBeans: number) {} refill(beans: number) { // 커피콩을 채운다. } grindBeans(shots: number) { // 커피콩을 간다. } heating(): void { // 기계를 데운다. } extract(shots: number): ICoffee { // 커피를 추출함. } makeCoffee(shots: number): ICoffee { this.grindBeans(shots); this.heating(); return this.extract(shots); } clean() { console.log("cleaning.."); } } class AmateurBarista { constructor(private machine: ICoffeeMachine) {} order(shots: number): ICoffee { const coffee = this.machine.makeCoffee(shots); return coffee; } } class ProBarista { constructor(private machine: ICommercialCoffeeMachine) {} order(shots: number): ICoffee { const coffee = this.machine.makeCoffee(shots); this.machine.refill(24); this.machine.clean(); return coffee; } } const coffeeMachine = new CoffeeMachine(100); const amateur = new AmateurBarista(coffeeMachine); const pro = new ProBarista(coffeeMachine);
ICommercialCoffeeMachine 인터페이스는 음.. 조금 더 고급 커피 머신이라고 생각할 수 있다. 커피 콩을 채우고, 커피 머신을 청소하는 로직도 규약되어 있다.
이제 AmateurBarista, ProBarista 클래스를 보면 참 재밌는게 생성자 함수에서 전달 받는 machine에 이 계약서를 명시하고 있다.
아마추어 바리스타는 전달 받은 샷을 내릴 줄만 아는 뜨내기다. 반면, 프로 바리스타는 커피를 내리고 커피콩도 채우고 청소도 한다.
아래에선 하나의 커피 머신만을 사용해 두 인스턴스를 만들어낼 수 있게 된다.
너무 재밌다..
3. 상속 (Inheritance)
상속은 기존 클래스의 기능을 확장해 새로운 클래스를 만드는 것으로 코드의 재사용성을 높이고, 중복된 코드를 제거할 수 있다.
한참 위에서 우유를 넣은 커피와 설탕을 넣은 커피를 만들고 싶은 경우에 대해 얘기했었다.
이 때 상속을 사용하면 간편하게 구현이 가능하다.
class CaffeLatteMachine extends CoffeeMachine { steamMilk() { console.log("Steaming some milk..."); return true; } makeCoffee(shots: number): ICoffee { const coffee = super.makeCoffee(2); const milk = this.steamMilk(); return { ...coffee, hasMilk: milk, }; } }
steamMilk는 저렇게 생겼지만 사실 내부적으로는 뭐.. 정말 복잡한 로직이 있다고 가정하고 보도록 하자.
부모 클래스의 makeCoffee 함수를 오버라이딩하기 위해 super를 통해 부모의 makeCoffee 함수를 호출한다. 만약 super 함수를 호출하지 않는다면 부모 클래스의 커피콩을 갈고, 기계를 데우고, 커피를 추출하는 과정을 직접 모두 구현해야 할 것이다.
이렇게 추출된 coffee를 내부의 steamMilk를 통해 우유를 제조해 우유가 담긴 커피를 반환한다.
혹은 내부적으로 특별한 멤버를 더 가지고 싶다면
class CaffeLatteMachine extends CoffeeMachine { constructor(beans: number, private SerialNumber: number) { super(beans); } }
위와 같이 생성자 함수에서 전달 받고 꼭 super를 호출해 부모의 생성자 함수도 호출해야만 한다.
4. 다형성 (Polymorphism)
다형성이란 동일한 메서드 호출이 객체의 실제 타입에 따라 다르게 동작하는 것을 의미한다. 이렇게 말하면 이해하기 어려우니 또 커피 머신으로 이해해보자.
interface ICoffee { shots: number; hasMilk?: boolean; hasSugar?: boolean; } interface ICoffeeMachine { makeCoffee(beans: number): ICoffee; } class CoffeeMachine implements ICoffeeMachine { ... } class CaffeLatteMachine extends CoffeeMachine { steamMilk() { console.log("Steaming some milk..."); return true; } makeCoffee(shots: number): ICoffee { const coffee = super.makeCoffee(shots); const milk = this.steamMilk(); return { ...coffee, hasMilk: milk, }; } } class SweetCoffeeMachine extends CoffeeMachine { addSugar() { console.log("Getting some Sugar..."); return true; } makeCoffee(shots: number): ICoffee { const coffee = super.makeCoffee(shots); const sugar = this.addSugar(); return { ...coffee, hasSugar: sugar, }; } } const machines: ICoffeeMachine[] = [ new CoffeeMachine(24), new CaffeLatteMachine(44), new SweetCoffeeMachine(54), ]; machines.forEach((machine) => { console.log(machine.makeCoffee(2)); });
1. ICoffeeMachine
먼저 ICoffeeMachine 인터페이스는 makeCoffee라는 동일한 이름의 메서드를 규약하고 있다.
그리고 이 인터페이스를 따르는 모든 클래스(CoffeeMachine, CaffeLatteMachine, SweetCoffeeMachine)는 모두 makeCoffee 메서드를 반드시 구현해야만 한다.
2. 상속과 오버라이딩
CaffeLatteMachine과 SweetCoffeeMachine은 부모 클래스인 CoffeeMachine의 메서드를 오버라이드해 다른 동작을 구현하고 있다.
부모의 기본 기능을 super.makeCoffee()로 호출하고, 각각 특화된 동작(hasMilk: true, hasSugar: true)을 추가하고 있다.
3. ICoffeeMachine 타입의 배열
machines 배열의 타입은 ICoffeeMachine[] 으로 정의되어있다. 즉, ICoffeeMachine 인터페이스의 관점에서 볼 때 모든 객체는 makeCoffee 메서드를 가지고 있다고 판단할 수 있다.
machines.forEach를 통해 makeCoffee를 호출하는데, 이 코드는 내부 구현을 몰라도 인터페이스의 메서드만으로 작업하게 된다.
그럼 이제 각각의 CoffeeMachine, CaffeLatteMachine, SweetCoffeeMachine은 자신의 방식대로 메서드를 실행하게 된다.
이렇게 이 코드가 다형성을 적용하고 있다는 것을 이해할 수 있다.
조합 (Composition)
컴포지션이 무엇일까?
- 작은 개별 객체(컴포넌트도 맞죠?)를 조합해 더 큰 객체를 만드는 설계 기법이다.
- 내가 이해한 컴포지션은 마치 리액트에서 로그인 폼을 만들 때 Input, Button 컴포넌트를 조합해 Form을 구성하듯, 여러 개의 재사용 가능한 클래스를 쪼개고 이를 조합해 기능을 구성하는 방식이다.
그럼 왜 컴포지션을 사용할까? 자 위의 다형성 부분에서 구현한 코드를 다시 살펴보자.
코드에선 설탕이 들어간 커피와 우유가 들어간 커피를 제조하는 기계가 있다. 어라? 그럼 설탕이랑 우유도 들어간 커피를 제조하는 기계도 만들고 싶고, 카라멜이 들어간 커피도 만들고 싶다.
그럼 어떻게 상속할까? 매 번 새로운 하위 클래스를 만들게 되면 구조를 생각하다 머리가 폭발해버릴 것이다.
또, 만약 CoffeeMachine 클래스의 메서드가 변경되면, 강결합으로 인해 모든 하위 클래스의 동작에 영향을 미치고, 예상치 못한 에러를 마주칠 수도 있다.
혹은 우유 제조 과정의 로직이 변경되면 이것도 모두 바꿔줘야만 한다.
조합 사용해보기
우선 조합하기 위해 작은 개별 객체로 나누어 보자. 어떤 기준으로 나누면 좋을까?
커피를 만든다. 라는 동작을 수행하는 CoffeeMachine과 조합하기 위해 우유를 제조한다. 와 설탕을 제조한다. 를 기준으로 삼아 나누어보자.
class CheapMilkSteamer { private steamMilk() { // 우유를 제조함. } addMilk(coffee: ICoffee): ICoffee { const milk = this.steamMilk(); return { ...coffee, hasMilk: milk, }; } } class SugarMixer { private getSugar() { // 설탕을 제조함. } addSugar(coffee: ICoffee): ICoffee { const sugar = this.getSugar(); return { ...coffee, hasSugar: sugar, }; } }
그럼 이렇게 조합이 가능한 작은 개별 객체로 분리해낼 수 있다.
그럼 이 객체들을 어떻게 조합해서 사용할 수 있을까? 위에서 구현했던 CaffeLatteMachine과 SweetCoffeeMachine을 조합을 통해 구현해보자.
class CaffeLatteMachine extends CoffeeMachine { constructor(beans: number, private milkFrother: CheapMilkSteamer) { super(beans); } makeCoffee(shots: number): ICoffee { const coffee = super.makeCoffee(shots); return this.milkFrother.addMilk(coffee); } } class SweetCoffeeMachine extends CoffeeMachine { constructor(beans: number, private sugarMixer: SugarMixer) { super(beans); } makeCoffee(shots: number): ICoffee { const coffee = super.makeCoffee(shots); return this.sugarMixer.addSugar(coffee); } }
이렇게 독립적인 객체들을 커피 머신이 의존성으로 주입 받아 (DI - 디펜던시 인젝션이라고 한다.) 내부적으로 어떻게 동작하는지 하~나도 상관하지 않고 단순히 super를 호출해 생성된 커피를 전달해 커피에 설탕을 넣거나, 우유를 넣어 리턴하게 된다.
분리해내긴 했는데.. 뭐가 좋은걸까?
바로 조합의 유연성이 증가한다. 위에서 말했듯, 우유와 설탕 모두 들어간 커피 머신을 만들고 싶었다. 어라? 그럼 위에서 구현한대로 의존성을 주입 받아 단순히 커피에 넣어주기만 하면 되는 거 아닌가?
class SweetCaffeLatteMachine extends CoffeeMachine { constructor( beans: number, private milkFrother: CheapMilkSteamer, private sugarMixer: SugarMixer ) { super(beans); } makeCoffee(shots: number): ICoffee { const coffee = super.makeCoffee(shots); const addedSugar = this.sugarMixer.addSugar(coffee); return this.milkFrother.addMilk(addedSugar); } }
또, 내가 싸구려 우유 스팀기를 좋은 우유 스팀기로 변경하고 싶다면 단순히 생성자에서 교체만 해주면 된다. 그럼 CoffeeMachine이나 SugarMixer 클래스엔 전혀 영향을 주지 않고 모두 독립적으로 교체할 수 있다.
조금 더 우아한 조합 사용법
위의 코드는 사실 개선할 사항이 많다. 바로 각 커피 머신이 우유 제조기, 설탕 제조기와 강결합 되어있다는 점이다.
카페라떼 머신을 예로 들어보자면, 위 코드에선 의존성 주입 과정에 실제 구현체인 CheapMilkSteamer를 주입 받아 사용한다. 그럼 만약 내가 고급 우유 제조기를 사용하고 싶다면 클래스를 수정하는 번거로운 과정이 생긴다.
위에서 우리는 interface를 통해 어떤 클래스가 가지고 있을 함수를 규약할 수 있었다. 그럼 우유 제조기를 이렇게 바꿔보면 어떨까?
interface IMilkFrother { addMilk(coffee: ICoffee): ICoffee; } class CheapMilkSteamer implements IMilkFrother { private steamMilk() { // 싸구려 우유를 제조 } addMilk(coffee: ICoffee): ICoffee { const milk = this.steamMilk(); return { ...coffee, hasMilk: milk, }; } } class FancyMilkSteamer implements IMilkFrother { private steamMilk() { // 아주 Fancy한 우유를 제조 } addMilk(coffee: ICoffee): ICoffee { const milk = this.steamMilk(); return { ...coffee, hasMilk: milk, }; } } class CaffeLatteMachine extends CoffeeMachine { constructor(beans: number, private milkFrother: IMilkFrother) { super(beans); } makeCoffee(shots: number): ICoffee { const coffee = super.makeCoffee(shots); return this.milkFrother.addMilk(coffee); } }
이렇게 의존성 주입 과정에 전달 받는 우유 제조기의 타입을 IMilkFrother로 명시했다. 이는 인터페이스에 규약된 addMilk를 따르는 모든 구현체를 받을 수 있도록 변경한 것이다.
이렇게 구현함으로써 구현자 입장에선 어떤 milkFrother를 전달해도 그저 내부에 정의된 함수만 호출하면 알아서 우유를 넣을 수 있다.
const cheapCaffeLatteMachine = new CaffeLatteMachine( 24, new CheapMilkSteamer() ); const fancyCaffeLatteMachine = new CaffeLatteMachine( 24, new FancyMilkSteamer() );
짜잔! 이렇게 말이다.
그리고 이렇게 변경하면 CaffeLatteMachine 클래스는 필요 없어짐을 알 수 있다. 단순히 우리가 우유를 넣고 싶지 않다면 noMilk를 전달해도 된다.
class CoffeeMachine implements ICoffeeMachine { constructor( private coffeeBeans: number, private milkFrother: IMilkFrother, private sugarMixer: ISugarMixer ) {} makeCoffee(shots: number): ICoffee { this.grinding(shots); this.heating(); const coffee = this.extract(shots); const addedSugar = this.sugarMixer.addSugar(coffee); return this.milkFrother.addMilk(addedSugar); } }
기존 부모 클래스의 생성자 함수에서 이렇게 의존성 주입으로 받은 우유 제조기와 설탕 제조기를 사용해 커피를 만들 때 첨가할 수 있도록 변경한다.
class NoMilk implements IMilkFrother { addMilk(coffee: ICoffee): ICoffee { return coffee; } } class NoSugar implements ISugarMixer { addSugar(coffee: ICoffee): ICoffee { return coffee; } } const simpleCoffeeMachine = new CoffeeMachine(24, new NoMilk(), new NoSugar()); const fancyCaffeLatteMachine = new CoffeeMachine( 24, new FancyMilkSteamer(), new NoSugar() );
이렇게 조합을 이용하면 내가 만들고 싶은 커피 머신을 쉽게 만들어낼 수 있다.
여기서 DIP (의존성 역전 원칙) 을 따르고 있음을 알 수 있다. 이는 상위 모듈은 하위 모듈에 의존하지 않고, 오직 인터페이스에만 의존해야 한다는 원칙으로 현재 구체 클래스가 아닌 인터페이스에만 의존하기에 의존성 역전 원칙을 따른다.
또, 새로운 기능을 추가할 때 기존 코드를 수정하지 않고도 확장할 수 있어야 한다는 원칙으로 OCP(개방 폐쇄 원칙) 도 따르고 있다.
마지막으로 각각의 클래스는 하나의 책임만 가져야 한다는 SRP (단일 책임 원칙) 도 따르고 있다. 우유를 추가하거나 설탕을 추가하는 일은 IMilkFrother와 ISugarMixer를 구현하는 구현체에게 위임하기 때문이다.
마치며
이렇게 OOP를 일상 생활에서 쉽게 볼 수 있는 커피 머신을 활용해 알아보았다. 마지막에 상속 보다는 조합을 활용해라! 라는 느낌으로 조합에 대해 알아보았는데,
이게 무조건 조합이 옳다? 는 아니고, 상속이 필요한 상황에선 상속을 사용하고, 너무 수직적인 상속 관계가 생겨 유지보수에 어려움이 생길 거 같을 때 조합을 사용하면 더 유연한 개발이 가능할 것으로 생각한다.

타입스크립트 - 타입 확장, 좁히기
