객체지향 프로그래밍
OOP (Object Oriented Programming)
a programming paradigm based on the concept of objects - 객체를 개념으로 한 프로그래밍 방식 Object(객체): 관련된 데이터나 코드를 함께 묶는 것
객체지향 개념 정리 (↔ 절차적 프로그래밍)
Imperative and Procedural Programming
명령과 절차를 따르는 프로그래밍 하나의 어플리케이션을 만들 때 어플리케이션이 동작하기 위한 데이터와 함수들 위주로 구성하는 것 정의된 순서대로 함수를 하나씩 호출하는 방식
절차적 프로그래밍의 치명적인 단점
- 함수가 여러 가지가 얽혀있고 데이터도 다르곳에서 업데이트될 수 있으므로 하나를 수정하기 위해 전체적인 어플리케이션이 어떻게 동작하는지 이해해야 한다.
- 하나를 수정했을 때 다른 side effect가 발생할 확률이 높다.
- 한눈에 어플리케이션을 이해하기 어렵다.
- 유지보수 및 확장이 어렵다.
객체지향 프로그래밍 장점
**- 프로그램을 객체로 정의해서 객체들끼리 서로 의사소통하도록 디자인하고 코딩한다.
- 서로 관련있는 데이터와 함수를 여러가지 오브젝트로 정의해서 프로그래밍한다.
- 객체지향이라는 것은 "우리가 흔히 볼 수 있는 물건들 오브젝트 객체들"을 말한다.
- 한 곳에서 문제가 생긴다면 관련있는 오브젝트만 이해하고 수정하면 된다.
- 여러 번 반복되는 것이 있다면 관련 오브젝트 재사용이 가능하다.
- 새로운 기능 필요시 새로운 오브젝트를 만들면 되므로 확장성이 올라간다.
- 생산성 up
- higher-quality
- 속도 up
- 유지보수 용이**
Object 구성
데이터(data, fields, properties) + 함수(methods)
Object는 우리 주변에서 볼 수 있는 학생, 은행 등 다양한 객체들을 선정해서 디자인해볼 수 있다. 이런 일상에서 볼 수 있는 물체뿐 아니라 프로그래밍할 때 만날 수 있는 추상적인 컨셉인 error, exception, event 등도 object로 만들고 관리할 수 있다.
class & object
**class: 단순 틀 같은 개념 (데이터가 들어있지 않다.) 어떻게 생겼는지 묘사하는 역할
- template
- declare once
- no data in**
**object: 클래스에 데이터를 넣어 만듦
- instance of a class
- created many times
- data in**
object는 class의 instance라고 얘기한다.
ex) 붕어빵 클래스를 이용해 팥 붕어빵 인스턴스를 생성했다.
🎉 중요한 객체지향 원칙
캡슐화 (Encapsulation)
절차지향 프로그래밍에서는 데이터와 함수 등 여러가지가 섞여 있다. 이렇게 흩어져 있는 것들 중 서로 관련있는 것들을 묶어놓는 것을 캡슐화라 한다. 즉, 서로 관련 있는 데이터와 함수를 한 오브젝트 안에 담아두고 외부에서 보일 필요가 없는 데이터를 잘 숨겨서 캡슐화를 한다.
추상화 (Abstraction)
외부의 복잡함을 모두 이해하지 않고 외부에서 간단한 인터페이스를 통해 쓸 수 있는 것을 말한다. 예를 들어, 커피 머신을 이용할 때 내부의 복잡한 매커니즘을 모르고도 외부의 버튼만을 이용해 사용할 수 있다. 이처럼 추상화를 통해 외부에서는 내부가 얼마나 복잡한지 상관없이 지정된 외부에서만 보이는 인터페이스 함수를 이용해서 오브젝트를 사용할 수 있다.
상속성 (Inheritance)
상속을 이용하면 한번 잘 정의해둔 클래스를 재사용할 수 있다.
상속의 관계: 부모 클래스 ↔ 자식 클래스 (IS-A 관계 → 상속을 받은 자식 클래스는 바로 부모 클래스이다.)
다형성 (Polymorphism)
상속을 통해 만들어진 객체들이 무엇인지와 상관없이 공통된 함수를 호출할 수 있다.
?객체지향적으로 커피기계 만들기
절차지향적으로 커피기계 만들기
{
type CoffeeCup = {
shots: number;
hasMilk: boolean;
}
const BEANS_GRAMM_PER_SHOT: number = 7;
let coffeeBeans: number = 0;
function makeCoffee(shots: number): CoffeeCup {
if (coffeeBeans < shots * BEANS_GRAMM_PER_SHOT) {
throw new Error('Not enough coffee beans!!');
}
coffeeBeans -= shots * BEANS_GRAMM_PER_SHOT;
return {
shots,
hasMilk: false
};
}
coffeeBeans += 3 * BEANS_GRAMM_PER_SHOT;
const coffee = makeCoffee(2);
console.log(coffee);
}
// 출력
// { shots: 2, hasMilk: false }
1️⃣ 객체지향적으로 커피기계 만들기 (static 사용)
{
type CoffeeCup = {
shots: number;
hasMilk: boolean;
}
class CoffeeMaker {
static BEANS_GRAMM_PER_SHOT: number = 7;
coffeeBeans: number = 0;
constructor(coffeeBenas: number) {
this.coffeeBeans = coffeeBenas; // 해당 클래스 안에 있는 coffeeBeans를 전달된 coffeeBeans만큼 할당
}
makeCoffee(shots: number): CoffeeCup {
if (this.coffeeBeans < shots * CoffeeMaker.BEANS_GRAMM_PER_SHOT) {
throw new Error('Not enough coffee beans!!');
}
this.coffeeBeans -= shots * CoffeeMaker.BEANS_GRAMM_PER_SHOT;
return {
shots,
hasMilk: false
};
}
}
const maker = new CoffeeMaker(32);
console.log(maker);
const maker2 = new CoffeeMaker(34);
console.log(maker2);
}
// 출력
// CoffeeMaker { BEANS_GRAMM_PER_SHOT: 7, coffeeBeans: 32 }
만약에 constructor을 사용하고 싶지 않다?
{
type CoffeeCup = {
shots: number;
hasMilk: boolean;
}
class CoffeeMaker {
static BEANS_GRAMM_PER_SHOT: number = 7;
coffeeBeans: number = 0;
constructor(coffeeBenas: number) {
this.coffeeBeans = coffeeBenas; // 해당 클래스 안에 있는 coffeeBeans를 전달된 coffeeBeans만큼 할당
}
static makeMachine(coffeeBeans: number): CoffeeMaker {
return new CoffeeMaker(coffeeBeans);
}
makeCoffee(shots: number): CoffeeCup {
if (this.coffeeBeans < shots * CoffeeMaker.BEANS_GRAMM_PER_SHOT) {
throw new Error('Not enough coffee beans!!');
}
this.coffeeBeans -= shots * CoffeeMaker.BEANS_GRAMM_PER_SHOT;
return {
shots,
hasMilk: false
};
}
}
const maker = new CoffeeMaker(32);
console.log(maker);
const maker2 = new CoffeeMaker(34);
console.log(maker2);
const maker3 = CoffeeMaker.makeMachine(3);
}
2️⃣ 캡슐화 (encapsulation) 적용하기
캡슐화는 클래스를 만들 때 외부에서 접근할 수 있는 것은 무엇인지 내부적으로만 가져야 할 데이터는 무엇인지와 같은 것들을 결정할 수 있다.
{
type CoffeeCup = {
shots: number;
hasMilk: boolean;
}
class CoffeeMaker {
private static BEANS_GRAMM_PER_SHOT: number = 7;
private coffeeBeans: number = 0;
private constructor(coffeeBenas: number) {
this.coffeeBeans = coffeeBenas; // 해당 클래스 안에 있는 coffeeBeans를 전달된 coffeeBeans만큼 할당
}
static makeMachine(coffeeBeans: number): CoffeeMaker {
return new CoffeeMaker(coffeeBeans);
}
fillCoffeeBeans(beans: number) {
if (beans < 0) {
throw new Error('values for beans should be greater than 0');
}
this.coffeeBeans += beans;
}
makeCoffee(shots: number): CoffeeCup {
if (this.coffeeBeans < shots * CoffeeMaker.BEANS_GRAMM_PER_SHOT) {
throw new Error('Not enough coffee beans!!');
}
this.coffeeBeans -= shots * CoffeeMaker.BEANS_GRAMM_PER_SHOT;
return {
shots,
hasMilk: false
};
}
}
const maker = CoffeeMaker.makeMachine(32);
maker.fillCoffeeBeans(32);
console.log(maker);
}
3️⃣ 유용한 Getter와 Setter
setter와 getter은 일반 멤버변수처럼 사용 가능한데, 어떠한 계산시 좀 더 유용하게 사용할 수 있다.
class User {
get fullName(): string {
return `${this.firstName} ${this.lastName}`;
}
private internalAge = 4;
get age(): number {
return this.internalAge;
}
set age(num: number) {
if (num < 0) {
throw new Error('values for age should be greater than 0')
}
this.internalAge = num;
}
constructor(private firstName: string, private lastName: string) {
}
}
const user = new User('Steve', 'Jobs');
user.age = 6; // 내부적으로 internalAge 접근은 불가하지만 set age()를 통해 internalAge 값 할당 가능
console.log(user.fullName); // Ellie Jobs
console.log(user.age);
// 출력
Steve Jobs
6
private firstName: string;
private lastName: string;
.
.
.
constructor(firstName: string, lastName: string) {
this.firstName = firstName;
this.lastName = lastName;
}
4️⃣ 추상화 (Abstraction)
{
type CoffeeCup = {
shots: number;
hasMilk: boolean;
}
// 2. 인터페이스 정의를 통한 추상화
// CoffeeMaker라는 인터페이스를 이용하면 makeCoffee를 이용할 수 있다는 것 명시
interface CoffeeMaker {
makeCoffee(shots: number): CoffeeCup;
}
class CoffeeMachine implements CoffeeMaker {
private static BEANS_GRAMM_PER_SHOT: number = 7;
private coffeeBeans: number = 0;
private constructor(coffeeBenas: number) {
this.coffeeBeans = coffeeBenas; // 해당 클래스 안에 있는 coffeeBeans를 전달된 coffeeBeans만큼 할당
}
static makeMachine(coffeeBeans: number): CoffeeMachine {
return new CoffeeMachine(coffeeBeans);
}
fillCoffeeBeans(beans: number) {
if (beans < 0) {
throw new Error('values for beans should be greater than 0');
}
this.coffeeBeans += beans;
}
private grindBeans(shots: number) {
console.log(`grinding beans for ${shots}`);
if (this.coffeeBeans < shots * CoffeeMachine.BEANS_GRAMM_PER_SHOT) {
throw new Error('Not enough coffee beans!!');
}
this.coffeeBeans -= shots * CoffeeMachine.BEANS_GRAMM_PER_SHOT;
}
private preheat(): void {
console.log('heating up...🔥');
}
private extract(shots: number): CoffeeCup {
console.log(`Pulling ${shots} shots...☕️`);
return {
shots,
hasMilk: false,
}
}
makeCoffee(shots: number): CoffeeCup {
this.grindBeans(shots);
this.preheat();
return this.extract(shots);
}
}
const maker: CoffeeMachine = CoffeeMachine.makeMachine(32);
maker.fillCoffeeBeans(32);
maker.makeCoffee(2);
}
grindBeans, preheat, extract 이 세 함수들을 추가했을 때, 어떤 순서대로 써야할지 모르겠다… 이 때 추상화가 빛을 발하게 된다.
추상화는 인터페이스를 간단하게 만듦으로써 사용자가 간편하게 사용할 수 있도록 도와준다.
보통 정보은닉 (캡슐화, encapsulation)을 통해 추상화를 할 수 있다.
인터페이스는 외부에서 사용하므로 최대한
CoffeeMaker
라는 이름을 유지하고 구현하는 클래스에서 다른 이름을 가져가는 것을 지향한다. 따라서 해당 클래스는CoffeeMachine
으로 변경한다.
이 클래스는 위 인터페이스 규격을 따른다. (
implements CoffeeMaker
) 따라서 해당 클래스는 해당 인터페이스에서 규격된 모든 함수를 구현해야 한다. 해당 클래스에서makeCoffee
를 구현하지 않으면, 클래스 자체에 에러가 발생한다.
+) 전문샵에서 사용하는 CoffeeMaker Interface
{
type CoffeeCup = {
shots: number;
hasMilk: boolean;
}
// 2. 인터페이스 정의를 통한 추상화
// CoffeeMaker라는 인터페이스를 이용하면 makeCoffee를 이용할 수 있다는 것 명시
interface CoffeeMaker {
makeCoffee(shots: number): CoffeeCup;
}
interface CommercialCoffeeMaker {
makeCoffee(shots: number): CoffeeCup;
fillCoffeeBeans(beans: number): void;
clean(): void;
}
class CoffeeMachine implements CoffeeMaker, CommercialCoffeeMaker {
private static BEANS_GRAMM_PER_SHOT: number = 7;
private coffeeBeans: number = 0;
private constructor(coffeeBenas: number) {
this.coffeeBeans = coffeeBenas; // 해당 클래스 안에 있는 coffeeBeans를 전달된 coffeeBeans만큼 할당
}
static makeMachine(coffeeBeans: number): CoffeeMachine {
return new CoffeeMachine(coffeeBeans);
}
fillCoffeeBeans(beans: number) {
if (beans < 0) {
throw new Error('values for beans should be greater than 0');
}
this.coffeeBeans += beans;
}
clean() {
console.log('cleaning the machine...🧼');
}
private grindBeans(shots: number) {
console.log(`grinding beans for ${shots}`);
if (this.coffeeBeans < shots * CoffeeMachine.BEANS_GRAMM_PER_SHOT) {
throw new Error('Not enough coffee beans!!');
}
this.coffeeBeans -= shots * CoffeeMachine.BEANS_GRAMM_PER_SHOT;
}
private preheat(): void {
console.log('heating up...🔥');
}
private extract(shots: number): CoffeeCup {
console.log(`Pulling ${shots} shots...☕️`);
return {
shots,
hasMilk: false,
}
}
makeCoffee(shots: number): CoffeeCup {
this.grindBeans(shots);
this.preheat();
return this.extract(shots);
}
}
class AmateurUser {
constructor(private machine: CoffeeMaker) {}
makeCoffee() {
const coffee = this.machine.makeCoffee(2);
console.log(coffee);
}
}
class ProBarista {
constructor(private machine: CommercialCoffeeMaker) {}
makeCoffee() {
const coffee = this.machine.makeCoffee(2);
console.log(coffee);
this.machine.fillCoffeeBeans(45);
this.machine.clean();
}
}
const maker: CoffeeMachine = CoffeeMachine.makeMachine(32);
const amateur = new AmateurUser(maker);
const pro = new ProBarista(maker);
amateur.makeCoffee();
pro.makeCoffee();
}
// 출력
// Amateur
grinding beans for 2
heating up...🔥
Pulling 2 shots...☕️
{ shots: 2, hasMilk: false }
// Pro
grinding beans for 2
heating up...🔥
Pulling 2 shots...☕️
{ shots: 2, hasMilk: false }
cleaning the machine...🧼
5️⃣ 상속으로 다양한 커피 기계 만들기 (Inheritance)
중복된 코드는 가독성이 매우 떨어지므로, 이를 상속을 통해 코드의 재사용성을 높이자.
{
type CoffeeCup = {
shots: number;
hasMilk: boolean;
}
// 2. 인터페이스 정의를 통한 추상화
// CoffeeMaker라는 인터페이스를 이용하면 makeCoffee를 이용할 수 있다는 것 명시
interface CoffeeMaker {
makeCoffee(shots: number): CoffeeCup;
}
class CoffeeMachine implements CoffeeMaker {
private static BEANS_GRAMM_PER_SHOT: number = 7;
private coffeeBeans: number = 0;
constructor(coffeeBenas: number) {
this.coffeeBeans = coffeeBenas; // 해당 클래스 안에 있는 coffeeBeans를 전달된 coffeeBeans만큼 할당
}
static makeMachine(coffeeBeans: number): CoffeeMachine {
return new CoffeeMachine(coffeeBeans);
}
fillCoffeeBeans(beans: number) {
if (beans < 0) {
throw new Error('values for beans should be greater than 0');
}
this.coffeeBeans += beans;
}
clean() {
console.log('cleaning the machine...🧼');
}
private grindBeans(shots: number) {
console.log(`grinding beans for ${shots}`);
if (this.coffeeBeans < shots * CoffeeMachine.BEANS_GRAMM_PER_SHOT) {
throw new Error('Not enough coffee beans!!');
}
this.coffeeBeans -= shots * CoffeeMachine.BEANS_GRAMM_PER_SHOT;
}
private preheat(): void {
console.log('heating up...🔥');
}
private extract(shots: number): CoffeeCup {
console.log(`Pulling ${shots} shots...☕️`);
return {
shots,
hasMilk: false,
}
}
makeCoffee(shots: number): CoffeeCup {
this.grindBeans(shots);
this.preheat();
return this.extract(shots);
}
}
class CaffeLatteMachine extends CoffeeMachine {
// ⭐️
constructor(beans: number, public readonly serialNumber: string) {
super(beans);
}
private steamMilk(): void {
console.log('Steaming some milk...🥛');
}
// ✏️
makeCoffee(shots: number): CoffeeCup {
const coffee = super.makeCoffee(shots);
this.steamMilk();
return {
...coffee,
hasMilk: true
}
}
}
const machine = new CoffeeMachine(23);
const latteMachine = new CaffeLatteMachine(23, 'SEIIQ12');
const coffee = latteMachine.makeCoffee(1);
console.log(coffee);
console.log("라떼머신 번호", latteMachine.serialNumber);
}
✏️ 부모 클래스의 makeCoffee 함수의 매커니즘을 그대로 따르면서, 자식 클래스에 추가적으로 구현된 부분이 표현된다. 즉, 위 코드에서는 CoffeeMachine 클래스의 makeCoffee 함수를 CaffeLatteMachine 클래스가 상속받은 후 steamMilk()를 추가하여 활용한 것이다.
⭐️ 만약 자식클래스에서 생성자를 따로 구현하는 경우엔, 부모의 생성자도 호출해줘야 한다. 즉, 부모 클래스에서 필요한 데이터를 받아오고 받아온 데이터를 super()를 이용해 전달해줘야 한다.
다형성 (Polymorphism)
✔︎ **하나의 인터페이스나 부모의 클래스를 상속한 자식 클래스들이 인터페이스와 부모 클래스에 있는 함수들을 다른 방식으로 다양하게 구성함으로써 다양성을 만드는 것을 말한다.
✔︎ 인터페이스와 부모 클래스에 있는 동일한 함수 api를 통해 각각 구현된 자식 클래스의 내부 구현 사항을 신경 쓰지 않고 약속된 한 가지의 api를 호출해서 사용자도 간편하게 다양한 기능들을 활용하도록 도와준다.
✔︎ 다형성을 이용하면 한 가지의 클래스나 인터페이스를 통해 다른 방식으로 구현한 클래스를 만들 수 있다.**
장점
- 내부적으로 구현된 다양한 클래스들이 한가지의 인터페이스를 구현하거나 동일한 부모 클래스를 상속했을 때 동일한 함수를 어떤 클래스인지 구분하지 않고 공통된 api를 호출할 수 있다.
{
type CoffeeCup = {
shots: number;
hasMilk?: boolean;
hasSugar?: boolean;
}
interface CoffeeMaker {
makeCoffee(shots: number): CoffeeCup;
}
class CoffeeMachine implements CoffeeMaker {
private static BEANS_GRAMM_PER_SHOT: number = 7;
private coffeeBeans: number = 0;
constructor(coffeeBenas: number) {
this.coffeeBeans = coffeeBenas; // 해당 클래스 안에 있는 coffeeBeans를 전달된 coffeeBeans만큼 할당
}
static makeMachine(coffeeBeans: number): CoffeeMachine {
return new CoffeeMachine(coffeeBeans);
}
fillCoffeeBeans(beans: number) {
if (beans < 0) {
throw new Error('values for beans should be greater than 0');
}
this.coffeeBeans += beans;
}
clean() {
console.log('cleaning the machine...🧼');
}
private grindBeans(shots: number) {
console.log(`grinding beans for ${shots}`);
if (this.coffeeBeans < shots * CoffeeMachine.BEANS_GRAMM_PER_SHOT) {
throw new Error('Not enough coffee beans!!');
}
this.coffeeBeans -= shots * CoffeeMachine.BEANS_GRAMM_PER_SHOT;
}
private preheat(): void {
console.log('heating up...🔥');
}
private extract(shots: number): CoffeeCup {
console.log(`Pulling ${shots} shots...☕️`);
return {
shots,
hasMilk: false,
}
}
makeCoffee(shots: number): CoffeeCup {
this.grindBeans(shots);
this.preheat();
return this.extract(shots);
}
}
class CaffeLatteMachine extends CoffeeMachine {
constructor(beans: number, public readonly serialNumber: string) {
super(beans);
}
private steamMilk(): void {
console.log('Steaming some milk...🥛');
}
makeCoffee(shots: number): CoffeeCup {
const coffee = super.makeCoffee(shots);
this.steamMilk();
return {
...coffee,
hasMilk: true
}
}
}
class SweetCoffeeMaker extends CoffeeMachine {
makeCoffee(shots: number): CoffeeCup {
const coffee = super.makeCoffee(shots);
return {
...coffee,
hasSugar: true
}
}
}
// 부모 클래스인 CoffeeMachine이 CoffeeMaker 인터페이스의 규격에 따르기 때문에
// 두 자식 클래스는 CoffeeMaker 배열로 만들 수 있다.
const machines: CoffeeMaker[] = [
new CoffeeMachine(16),
new CaffeLatteMachine(16, '1'),
new SweetCoffeeMaker(16),
new CoffeeMachine(16),
new CaffeLatteMachine(16, '1'),
new SweetCoffeeMaker(16)
];
machines.forEach(machine => {
console.log('----------------------');
machine.makeCoffee(1);
})
}
// 출력
----------------------
grinding beans for 1
heating up...🔥
Pulling 1 shots...☕️
----------------------
grinding beans for 1
heating up...🔥
Pulling 1 shots...☕️
Steaming some milk...🥛
----------------------
grinding beans for 1
heating up...🔥
Pulling 1 shots...☕️
----------------------
grinding beans for 1
heating up...🔥
Pulling 1 shots...☕️
----------------------
grinding beans for 1
heating up...🔥
Pulling 1 shots...☕️
Steaming some milk...🥛
----------------------
grinding beans for 1
heating up...🔥
Pulling 1 shots...☕️
👿 상속의 문제점
✔︎ 어떤 부모 클래스의 행동을 수정하면 해당 사항때문에 이를 상속하는 모든 자식 클래스에 영향을 미칠 수 있다. ✔︎ 새로운 기능 도입시 어떻게 상속의 구조를 지어야 할지 복잡하다. ✔︎ 타입스크립트에서는 한 가지 이상의 부모클래스를 상속할 수 없다. → Classes can only extend a single class
⭐️ 상속만을 이용해서 깊이있게 상속을 해버리면 관계가 복잡해질 수 있으므로 불필요한 상속 대신에 Composition을 이용해보자.
// 싸구려 우유 거품기
class CheapMilkSteamer {
private steamMilk(): void {
console.log('Steaming some milk...🥛');
}
makeMilk(cup: CoffeeCup): CoffeeCup {
this.steamMilk();
return {
...cup,
hasMilk: true,
}
}
}
class CaffeLatteMachine extends CoffeeMachine {
constructor(
beans: number,
public readonly serialNumber: string,
private milkFother: CheapMilkSteamer
) {
super(beans);
}
makeCoffee(shots: number): CoffeeCup {
const coffee = super.makeCoffee(shots);
return this.milkFother.makeMilk(coffee);
}
}
// 설탕 제조기
class AutomaticSugarMixer {
private getSugar() {
console.log('Getting some sugar from jar 🍭');
return true;
}
addSugar(cup: CoffeeCup): CoffeeCup {
const sugar = this.getSugar();
return {
...cup,
hasSugar: sugar,
}
}
}
class SweetCoffeeMaker extends CoffeeMachine {
constructor(private beans: number, private sugar: AutomaticSugarMixer) {
super(beans);
};
makeCoffee(shots: number): CoffeeCup {
const coffee = super.makeCoffee(shots);
return this.sugar.addSugar(coffee);
}
}
🖇 이 방식을 이용해서 SweetCaffeLatteMachine을 만들어보자!
class SweetCaffeLatteMachine extends CoffeeMachine {
constructor(
private beans: number,
private milk: CheapMilkSteamer,
private sugar: AutomaticSugarMixer
){
super(beans);
}
makeCoffee(shots: number): CoffeeCup {
const coffee = super.makeCoffee(shots);
const sugarAdded = this.sugar.addSugar(coffee);
return this.milk.makeMilk(sugarAdded);
✔︎ 이와 같이 Composition을 이용해서 필요한 기능을 외부로부터 받아와 재사용할 수 있다. 코드의 재사용성을 높여주는 것이다.
- ✔︎ 단점
- 위 클래스들은
CheapMilkSteamer
와AutomaticSugarMixer
와 굉장히 타이트하게 커플링되어져 있다. ⭐️ 클래스 간에 연관되어 있는 것은 좋은 코드가 아니다!
✍️ 여기서 말한 단점을 개선할만한 테크닉에 대해 아래에서 바로 언급하겠다.
💪🏼 강력한 Interface
{
type CoffeeCup = {
shots: number;
hasMilk?: boolean;
hasSugar?: boolean;
}
interface CoffeeMaker {
makeCoffee(shots: number): CoffeeCup;
}
class CoffeeMachine implements CoffeeMaker {
private static BEANS_GRAMM_PER_SHOT: number = 7;
private coffeeBeans: number = 0;
constructor(
coffeeBenas: number,
private milk: MilkFrother,
private sugar: SugarProvider
)
{
this.coffeeBeans = coffeeBenas; // 해당 클래스 안에 있는 coffeeBeans를 전달된 coffeeBeans만큼 할당
}
fillCoffeeBeans(beans: number) {
if (beans < 0) {
throw new Error('values for beans should be greater than 0');
}
this.coffeeBeans += beans;
}
clean() {
console.log('cleaning the machine...🧼');
}
private grindBeans(shots: number) {
console.log(`grinding beans for ${shots}`);
if (this.coffeeBeans < shots * CoffeeMachine.BEANS_GRAMM_PER_SHOT) {
throw new Error('Not enough coffee beans!!');
}
this.coffeeBeans -= shots * CoffeeMachine.BEANS_GRAMM_PER_SHOT;
}
private preheat(): void {
console.log('heating up...🔥');
}
private extract(shots: number): CoffeeCup {
console.log(`Pulling ${shots} shots...☕️`);
return {
shots,
hasMilk: false,
}
}
makeCoffee(shots: number): CoffeeCup {
this.grindBeans(shots);
this.preheat();
const coffee = this.extract(shots);
const sugarAdded = this.sugar.addSugar(coffee);
return this.milk.makeMilk(sugarAdded);
}
}
interface MilkFrother {
makeMilk(cup: CoffeeCup): CoffeeCup;
}
interface SugarProvider {
addSugar(cup: CoffeeCup): CoffeeCup;
}
// 싸구려 우유 거품기
class CheapMilkSteamer implements MilkFrother {
private steamMilk(): void {
console.log('Steaming some milk...🥛');
}
makeMilk(cup: CoffeeCup): CoffeeCup {
this.steamMilk();
return {
...cup,
hasMilk: true,
}
}
}
class FancyMilkSteamer implements MilkFrother {
private steamMilk(): void {
console.log('Fancy Steaming some milk...🥛');
}
makeMilk(cup: CoffeeCup): CoffeeCup {
this.steamMilk();
return {
...cup,
hasMilk: true,
}
}
}
class ColdMilkSteamer implements MilkFrother {
private steamMilk(): void {
console.log('Fancy Steaming some milk...🥛');
}
makeMilk(cup: CoffeeCup): CoffeeCup {
this.steamMilk();
return {
...cup,
hasMilk: true,
}
}
}
// 우유를 만들지 않음
class NoMilk implements MilkFrother {
makeMilk(cup: CoffeeCup): CoffeeCup {
return cup;
}
}
// 설탕 제조기
class CandySugarMixer implements SugarProvider {
private getSugar() {
console.log('Getting some sugar from jar 🍭');
return true;
}
addSugar(cup: CoffeeCup): CoffeeCup {
const sugar = this.getSugar();
return {
...cup,
hasSugar: sugar,
}
}
}
class SugarMixer implements SugarProvider {
private getSugar() {
console.log('Getting some sugar from jar 🍭');
return true;
}
addSugar(cup: CoffeeCup): CoffeeCup {
const sugar = this.getSugar();
return {
...cup,
hasSugar: sugar,
}
}
}
class NoSugar implements SugarProvider {
addSugar(cup: CoffeeCup): CoffeeCup {
return cup;
}
}
// Milk
const cheapMilkMaker = new CheapMilkSteamer();
const fancyMilkMaker = new FancyMilkSteamer();
const coldMilkMaker = new ColdMilkSteamer();
const noMilk = new NoMilk();
// Sugar
const candySugar = new CandySugarMixer();
const sugar = new SugarMixer();
const noSugar = new NoSugar();
//
const sweetCandyMahcine = new CoffeeMachine(12, noMilk, candySugar);
const sweetMahcine = new CoffeeMachine(12, noMilk, sugar);
const latteMachine = new CoffeeMachine(12, cheapMilkMaker, noSugar);
const coldLatteMachine = new CoffeeMachine(12, coldMilkMaker, noSugar);
const sweetLatteMachine = new CoffeeMachine(12, cheapMilkMaker, candySugar);
}
상속이 무조건 나쁘고, composition만 이용해야하는 건 아니지만, 코드가 너무 수직적인 관계 (깊은 관계)라면 composition을 이용해서 좀 더 필요한 기능들을 조립해서 확장이 가능하고 재사용성이 높고, 유지보수가 쉬우며 더 높은 퀄리티의 코드를 만들기 위해 고민하는 과정이 중요하다.
✏️ Abstract 클래스
✔︎ abstract 클래스 자체는 object 생성이 불가능하다. (추상적인 클래스) → 공통적인 기능 구현 가능
✔︎ 구현하는 클래스마다 달라져야하는 부분이 있다면 해당 부분만 abstract 메소드로 정의한다. → 함수 이름이 무엇인지, 인자를 무엇을 받아서 무엇을 리턴하는지만 정의할 수 있다. → protected abstract extract()
// abstract 키워드를 붙이면 CoffeeMachine 자체로는 Object 생성 불가
abstract class CoffeeMachine implements CoffeeMaker {
private static BEANS_GRAMM_PER_SHOT: number = 7;
private coffeeBeans: number = 0;
constructor(coffeeBenas: number) {
this.coffeeBeans = coffeeBenas; // 해당 클래스 안에 있는 coffeeBeans를 전달된 coffeeBeans만큼 할당
}
fillCoffeeBeans(beans: number) {
if (beans < 0) {
throw new Error('values for beans should be greater than 0');
}
this.coffeeBeans += beans;
}
clean() {
console.log('cleaning the machine...🧼');
}
private grindBeans(shots: number) {
console.log(`grinding beans for ${shots}`);
if (this.coffeeBeans < shots * CoffeeMachine.BEANS_GRAMM_PER_SHOT) {
throw new Error('Not enough coffee beans!!');
}
this.coffeeBeans -= shots * CoffeeMachine.BEANS_GRAMM_PER_SHOT;
}
private preheat(): void {
console.log('heating up...🔥');
}
// abstract 메소드는 구현사항을 쓰면 안됨.
protected abstract extract(shots: number): CoffeeCup ;
makeCoffee(shots: number): CoffeeCup {
this.grindBeans(shots);
this.preheat();
return this.extract(shots);
}
}
class CaffeLatteMachine extends CoffeeMachine {
constructor(beans: number, public readonly serialNumber: string) {
super(beans);
}
private steamMilk(): void {
console.log('Steaming some milk...🥛');
}
// abstract method 사용
protected extract(shots: number): CoffeeCup {
this.steamMilk();
return {
shots,
hasMilk: true,
}
}
}