51일차 TIL (5주차 강의 마무리 / .ts 파일로 리팩토링)
클래스란 -
- 클래스는 객체 지향 프로그래밍(OOP)의 핵심 구성 요소 중 하나
- 클래스는객체를 만들기 위한 틀(template)
- 클래스에서는 같은 종류의 객체들이 공통으로 가지는 속성(attribute)과 메서드(method)를 정의
- 속성은 **객체의 성질**을 결정하는 것
- 예를 들어, 붕어빵은 `팥`이란 속성이 있는 `팥 붕어빵` 과 `슈크림` 이란 속성이 있는 `슈크림 붕어빵`
- 메서드**는 **객체의 성질을 변화**시키거나 **객체에서 제공하는 기능들을 사용**하는 창구
- 붕어빵 주인은 붕어빵을 팥 붕어빵에서 슈크림 붕어빵으로 전환할 수 있다
- 붕어빵을 사는 고객들은 팥 붕어빵, 슈크림 붕어빵의 가격을 알 수 있다
객체란?
- 객체는 클래스를 기반으로 생성되며 클래스의 인스턴스(instance)라고도 함
클래스 및 객체 정의 방법
- TypeScript에서 클래스를 정의하려면 `class` 키워드를 사용하면 돼
- 클래스의 속성과 메서드를 정의하고, `new` 키워드를 사용하여 객체를 생성할 수 있다
class Person {
name: string;
age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
sayHello() {
console.log(`안녕하세요! 제 이름은 ${this.name}이고, 나이는 ${this.age}살입니다.`);
}
}
const person = new Person('Spartan', 30);
person.sayHello();
- 생성자(constructor)
- 생성자는 클래스의 인스턴스를 생성하고 초기화하는데 사용되는 특별한 메서드
- 생성자는 클래스 내에서 `constructor`라는 이름으로 정의됨
- 생성자는 인스턴스를 생성할 때 자동으로 호출됨
- 생성자는 클래스 내에 오직 **하나만 존재**할 수 있다
- 보통, 생성자로 객체 속성을 초기화 하는것 뿐 아니라 객체가 생성이 될 떄 꼭 되어야 하는 초기화 로직을 집어넣기도 함
- 예를 들어, DBConnector라는 클래스가 있다면 이 클래스 타입의 객체가 생성이 될 때 생성자에서 DB 연결을 미리 해주면 편할수 있다
접근 제한자
- 클래스에서는 **속성**과 **메서드**에 **접근 제한자를 사용**해 접근을 제한할 수 있다
- `public`
- **클래스 외부에서도 접근이 가능한 접근 제한자
- 접근 제한자가 선언이 안되어있다면 **기본적으로 접근 제한자는 public**
- 보통은 클래스의 함수 중 민감하지 않은 객체 정보를 열람할 때나 누구나 해당 클래스의 특정 기능을 사용해야 할 때 많이 쓰임
- `private`
- **클래스 내부에서만 접근이 가능한 접근 제한자**
- 보통은 클래스의 **속성은 대부분 private으로 접근 제한자를 설정
- 즉, 외부에서 직접적으로 객체의 속성을 변경할 수 없게 제한한다
- 클래스의 속성을 보거나 편집하고 싶다면 별도의 getter/setter 메서드를 준비해놓는 것이 관례
- `protected`
- **클래스 내부와 해당 클래스를 상속받은 자식 클래스에서만 접근**이 가능한 접근 제한자.
상속 -
상속은 객체 지향 프로그래밍에서 클래스 간의 관계를 정의하는 중요한 개념
상속을 통해 기존 클래스의 속성과 메서드를 물려받아 새로운 클래스를 정의할 수 있어
상속이 있어서 똑같은 코드를 계속 반복적으로 작성할 필요 없게 됨
class Animal {
name: string;
constructor(name: string) {
this.name = name;
}
makeSound() {
console.log('동물 소리~');
}
}
class Dog extends Animal {
age: number;
constructor(name: string) {
super(name);
this.age = 5;
}
makeSound() {
console.log('멍멍!'); // 부모의 makeSound 동작과 달라요!
}
eat() { // Dog 클래스만의 새로운 함수 정의
console.log('강아지가 사료를 먹습니다.');
}
}
class Cat extends Animal { // Animal과 다를게 하나도 없어요!
}
const dog = new Dog('누렁이');
dog.makeSound(); // 출력: 멍멍!
const cat = new Cat('야옹이');
cat.makeSound(); // 출력: 동물 소리~
super 키워드는 자식 클래스가 부모 클래스를 참조하는데 사용하는 키워드
자식 클래스에서 생성자를 정의할 때 부모 클래스의 생성자를 호출해야 하는데 이 때 쓰임
서브타입, 슈퍼타입
- 우선, 서브타입의 정의는 다음과 같답니다.
> *두 개의 타입 A와 B가 있고 B가 A의 서브타입이면 A가 필요한 곳에는 어디든 B를 안전하게 사용할 수 있다.*
>
- 또한, 슈퍼타입의 정의는 다음과 같아요.
> *두 개의 타입 A와 B가 있고 B가 A의 슈퍼타입이면 B가 필요한 곳에는 어디든 A를 안전하게 사용할 수 있다.*
>
- any는 모든 것의 슈퍼타입이에요.
- Animal은 Dog, Cat의 슈퍼타입이고요. Dog, Cat은 Animal의 서브타입이에요!
- upcasting, downcasting
upcasting과 downcasting은 슈퍼타입, 서브타입으로 변환할 수 있는 타입 변환 기술
upcasting
let dog: Dog = new Dog('또순이');
let animal: Animal = dog; // upcasting 발동!
animal.eat(); // 에러. 슈퍼타입(Animal)으로 변환이 되어 eat 메서드를 호출할 수 없어요!
- **서브타입 → 슈퍼타입으로 변환**을 하는 것을 **upcasting**이라고 해요!
- 이 경우에는 **타입 변환은 암시적**으로 이루어져 별도의 타입 변환 구문이 필요가 없어요!
- 즉, TypeScript가 자동으로 해준다는 얘기입니다!
- 위의 코드에서도 단지 슈퍼타입 변수에 대입만 했을 뿐이에요.
downcasting
let animal: Animal;
animal = new Dog('또순이');
let realDog: Dog = animal as Dog;
realDog.eat(); // 서브타입(Dog)로 변환이 되었기 때문에 eat 메서드를 호출할 수 있죠!
as 키워드로 명시적으로 타입 변환을 해줘야 합
추상 클래스
추상 클래스는 클래스와는 다르게 인스턴스화를 할 수 없는 클래스에요!
추상 클래스의 목적은 상속을 통해 자식 클래스에서 메서드를 제각각 구현하도록 강제를 하는 용도입니다!
골자는 핵심 기능의 구현은 전부 자식 클래스에게 위임을 하는 것이에요!
- 추상 클래스 및 추상 함수는 `abstract` 키워드를 사용하여 정의합니다.
- 추상 클래스는 **1개 이상의 추상 함수**가 있는 것이 일반적입니다.
abstract class Shape {
abstract getArea(): number; // 추상 함수 정의!!!
printArea() {
console.log(`도형 넓이: ${this.getArea()}`);
}
}
class Circle extends Shape {
radius: number;
constructor(radius: number) {
super();
this.radius = radius;
}
getArea(): number { // 원의 넓이를 구하는 공식은 파이 X 반지름 X 반지름
return Math.PI * this.radius * this.radius;
}
}
class Rectangle extends Shape {
width: number;
height: number;
constructor(width: number, height: number) {
super();
this.width = width;
this.height = height;
}
getArea(): number { // 사각형의 넓이를 구하는 공식은 가로 X 세로
return this.width * this.height;
}
}
const circle = new Circle(5);
circle.printArea();
const rectangle = new Rectangle(4, 6);
rectangle.printArea();
abstract getArea(): number; 이 중요함 추상 클래스를 상속 받은 자식 클래스들은 반드시 getArea 함수를 구현해야 함
인터페이스
TypeScript에서 객체의 타입을 정의하는데 사용됨
객체가 가져야 하는 속성과 메서드를 정의
인터페이스를 구현한 객체는 인터페이스를 반드시 준수
코드의 안정성을 높이고 유지 보수성을 향상시킬 수 있다
상 클래스와 인터페이스는 어떠한 차이가
### 구현부 제공 여부
- 추상 클래스
- 클래스의 기본 구현을 제공
- 인터페이스
- 객체의 구조만을 정의하고 기본 구현을 제공하지 않는다
### ☑️ 상속 메커니즘
- 추상 클래스
- 단일 상속만 지원
- 인터페이스
- 다중 상속을 지원
- 즉, 하나의 클래스는 여러 인터페이스를 구현할 수 있다
### ☑️ 구현 메커니즘
- 추상 클래스
- 추상 클래스를 상속받은 자식 클래스는 반드시 추상 함수를 구현해야 한다
- 인터페이스
- 인터페이스를 구현하는 클래스는 인터페이스에 정의된 모든 메서드를 전부 구현해야 한다
### ☑️ 언제 쓰면 좋을까요?
- **기본 구현을 제공하고 상속을 통해 확장**하는데 초점을 맞추고 싶다면 → `추상 클래스`
- 객체가 완벽하게 **특정 구조를 준수하도록 강제**하고 싶다면 → `인터페이스`
객체 지향 설계 원칙 - S.O.L.I.D
객체 지향 설계를 할 때는 S.O.L.I.D 원칙을 따라서 설계를 하는 것이 필수에요! 이렇게 설계를 하면 프로그램이 유연하고, 확장 가능하며, 이해하기 쉬운 구조를 가지도록 도와준다
SRP 원칙
- 클래스는 **하나의 책임**만 가져야 한다는 매우 기본적인 원칙
- 특히, 5개의 설계 원칙 중 **가장 기본적이고 중요한 원칙**이에요! **절대 잊어버리시면 xx!**
- 예를 들어서, 유저 서비스라는 클래스가 있다고 가정하면
- 이 유저 서비스에서는 유저 관련된 액션만 해야되고 다른 액션을 해서는 안됨.
O(OCP. 개방 폐쇄 원칙) → 인터페이스 혹은 상속을 잘 쓰자!
- 클래스는 확장에 대해서는 열려 있어야 하고 수정에 대해서는 닫혀 있어야 한다는 원칙
- 클래스의 **기존 코드를 변경하지 않고도 기능을 확장**할 수 있어야 함
- 즉, `인터페이스`나 `상속`을 통해서 이를 해결할 수가 있다
- 부모 클래스의 기존 코드 변경을 하지 않고 기능을 확장하는데 아무런 문제가 없다
LSP 원칙
- **서브타입은 기반이 되는 슈퍼타입을 대체**할 수 있어야 한다는 원칙입니다.
- 다시 말해, 자식 클래스는 부모 클래스의 기능을 수정하지 않고도 부모 클래스와 호환되어야 합니다.
- 다시 말해서, **논리적으로 엄격하게 관계가 정립**이 되어야 한다는 얘기입니다.
class Bird {
fly(): void {
console.log("펄럭펄럭~");
}
}
class Penguin extends Bird {
// 으잉? 펭귄이 날 수 있나요? 펭귄이 펄럭펄럭~ 한다는 것은 명백한 위반이죠.
}
-----------------------------------------
abstract class Bird {
abstract move(): void;
}
class FlyingBird extends Bird {
move() {
console.log("펄럭펄럭~");
}
}
class NonFlyingBird extends Bird {
move() {
console.log("뚜벅뚜벅!");
}
}
class Penguin extends NonFlyingBird {} // 이제 위배되는 것은 아무것도 없네요!
I(ISP. 인터페이스 분리 원칙)
- 클래스는 자신이 사용하지 않는 인터페이스의 영향을 받지 않아야 합니다.
- 즉, **해당 클래스에게 무의미한 메소드의 구현을 막자**는 의미에요!
- 따라서, **인터페이스를** 너무 크게 정의하기보단 **필요한 만큼만 정의**하고 **클래스는 입맛에 맞게 필요한 인터페이스들을 구현**하도록 유도합니다!
D(DIP. 의존성 역전 원칙)
- DIP는 Java의 Spring 프레임워크나 Node.js의 Nest.js 프레임워크와 같이 **웹 서버 프레임워크 내에서 많이 나오는 원칙**이에요.
- 이 원칙은 하위 수준 모듈(구현 클래스)보다 **상위 수준 모듈(인터페이스)에 의존**을 해야한다는 의미입니다!
- 예를 들어서, 데이터베이스라는 클래스가 있다고 가정을 해보겠습니다.
- 데이터베이스의 원천은 로컬 스토리지가 될 수도 있고 클라우드 스토리지가 될 수도 있어요.
- 이 때, 데이터베이스의 원천을 로컬 스토리지 타입 혹은 클라우드 스토리지 타입으로 한정하는 것이 아닙니다.
- 그보다 상위 수준인 스토리지 타입으로 한정을 하는 것이 맞아요!
interface MyStorage {
save(data: string): void;
}
class MyLocalStorage implements MyStorage {
save(data: string): void {
console.log(`로컬에 저장: ${data}`);
}
}
class MyCloudStorage implements MyStorage {
save(data: string): void {
console.log(`클라우드에 저장: ${data}`);
}
}
class Database {
// 상위 수준 모듈인 MyStorage 타입을 의존!
// 여기서 MyLocalStorage, MyCloudStorage 같은 하위 수준 모듈에 의존하지 않는게 핵심!
constructor(private storage: MyStorage) {}
saveData(data: string): void {
this.storage.save(data);
}
}
const myLocalStorage = new MyLocalStorage();
const myCloudStorage = new MyCloudStorage();
const myLocalDatabase = new Database(myLocalStorage);
const myCloudDatabase = new Database(myCloudStorage);
myLocalDatabase.saveData("로컬 데이터");
myCloudDatabase.saveData("클라우드 데이터");