본문 바로가기

What I Learnd/TIL

TIL - TypeScript 객체 지향 프로그래밍

객체 지향 프로그래밍(Object-Orented Programming, OOP)이란, 
소프트웨어 개발 패러다임 중 하나로, 프로그램을 작성하는 방법론 중 하나이다.

말그대로 "객체(Object)"라는 개념을 중심으로 프로그램을 설계하고 구현하는데, 객체란 데이터와 해당 데이터를 처리하는 메서드(함수)들의 묶음으로, 클래스라는 템플렛을 이용하여 생성된다.

각 객체는 독립적으로 존재하며, 객체들 간에 상호작용을 통해 프로그램이 동작한다.

 

객체지향프로그래밍의 주요 특징


1. 캡슐화(Encapsulation)

데이터와 그 데이터를 처리하는 메서드를 하나의 단위로 묶어 은닉한다. 이렇게 하면 객체의 내부 구현을 외부로 부터 숨기고, 외부에서는 객체가 제공하는인터페이스만을 이용하여 객체와 상호작용 할수 있다.

  • 데이터를 private으로 선언하고, getter와 setter 메서드를 통해 데이터에 접근하고 수정한다.
    • Getter 메서드: Getter 메서드는 클래스 외부에서 private 변수의 값을 읽어오기 위해 사용. 이 메서드는 해당 변수의 값을 반환하는 역할을 수행하며, 일반적으로 메서드 이름 앞에 get을 붙여서 표기
    • Setter 메서드: Setter 메서드는 클래스 외부에서 private 변수의 값을 변경하고 설정하기 위해 사용. 이 메서드는 매개변수로 전달된 값을 private 변수에 할당하는 역할을 수행허며, 일반적으로 메서드 이름 앞에 set을 붙여서 표기
  • 데이터에 대한 유효성 검사와 보안을 적절히 구현하여 외부로부터 데이터를 보호한다.
  • 클래스 외부에서는 내부 구현을 알 필요 없이 인터페이스를 통해 객체와 상호작용한다.
class Person {
  private name: string;
  private age: number;

  constructor(name: string, age: number) {
    this.name = name;
    this.age = age;
  }

  getName(): string {
    return this.name;
  }

  getAge(): number {
    return this.age;
  }

  setAge(age: number): void {
    if (age >= 0) {
      this.age = age;
    }
  }
}

 

2. 상속(Inheritance)

클래스들 간에 부모-자식 관계를 형성하여 자식 클래스가 부모 클래스의 특성과 기능을 물려받을 수 있게 한다. 이를 통해 코드 재사용성이 높아지고, 계층적인 구조를 표현하여 프로그램의 구성과 관리를 용이하게 한다.

class Animal {
  speak(): void {
    console.log("Animal speaks.");
  }
}

class Dog extends Animal {
  speak(): void {
    console.log("Dog barks.");
  }
}

class Cat extends Animal {
  speak(): void {
    console.log("Cat meows.");
  }
}

 

3. 다형성(Polymorphism)

동일한 인터페이스를 갖는 다양한 객체들이 이 인터페이스를 구현함으로써 동일한 메서드 호출에도 다양한 방식으로 동작할 수 있게 한다. 코드의 유연성과 확장성을 높여준다.

  • 하나의 인터페이스를 여러 클래스가 공유하며, 각 클래스는 해당 인터페이스를 구현한다.
  • 인터페이스를 통해 다양한 객체를 동일한 방법으로 다룰 수 있다. 유연하고 일관된 코드 작성에 용이.
interface Shape {
  area(): number;
}

class Rectangle implements Shape {
  private width: number;
  private height: number;

  constructor(width: number, height: number) {
    this.width = width;
    this.height = height;
  }

  area(): number {
    return this.width * this.height;
  }
}

class Circle implements Shape {
  private radius: number;

  constructor(radius: number) {
    this.radius = radius;
  }

  area(): number {
    return 3.14 * this.radius * this.radius;
  }
}

 


클래스의 구성요소

클래스는 속성(attribute)과 메서드(method)를 정의한다.

객체란, 클래스를 기반으로 생성되며 클래스의 인스턴스(instance)라고도 한다.

TypeScript에서 클래스를 정의하려면?
→ class 키워드 사용! class의 attribute와 method를 정의하고, 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라는 이름으로 정의
  • 생성자는 인스턴스를 생성할 때 자동 호출됨
  • 클래스 내에 오직 하나만 존재할 수 있다
  • 보통, 생성자로 객체 속성을 초기화 하는 것 뿐 아니라 객체가 생성이 될 때 곡 되어야 하는 초기화 로직을 집어 넣기도 한다!

클래스 접근 제한자

속성과 메서드에 접근 제한자를 사용해 접근을 제한할 수 있다.

TS 접근 제한자 비교
public private protected
클래스 외부에서도 접근 가능 클래스 내부에서만 접근 가능 내부와 해당 클래스를 상속받은 자식 클래스에서만 접근 가능
접근 제한자 선언이 안되어있다면 기본적으로 접근 제한자는 publie 보통은 속성의 대부분 private으로 접근 제한자 설정  
민감하지 않은 객체정보를 열람할 때나 누구나 해당 클래스의 특정기능을 사용해야할 때 주로 사용! 클래스 속성을 보거나 편집하고 싶다면 별도의 getter/setter 메서드 준비가 관례  
class Person {
  private name: string;
  private age: number;

  constructor(name: string, age: number) {
    this.name = name;
    this.age = age;
  }

  public sayHello() {
    console.log(`안녕하세요! 제 이름은 ${this.name}이고, 나이는 ${this.age}살입니다.`);
  }
}

상속(inheritance)

  • 클래스 간의 관계를 정의하는 주요개념
  • 기존 클래스의 속성과 메서드를 물려받아 새로운 클래스를 정의
  • 같은 코드를 반복적으로 작성할 필요가 없어짐
  • extends 키워드 사용
class Animal { // Animal 부모 클래스
  name: string;

  constructor(name: string) {
    this.name = name;
  }

  makeSound() {
    console.log('동물 소리~');
  }
}

class Dog extends Animal { // Dog 자식 클래스
  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(); // 동물 소리~

→ Dog 클래스는 부모의 함수의 동작을 새롭게 정의하고 있다. 이것을 overwritting이라고 한다.

 

서브타입, 슈퍼타입

  • 위의 예제에서 Animal은 Dog, Cat의 슈퍼타입
  • Dog, Cat은 Animal의 서브타입
  • upcasting & downcasting: 슈퍼타입, 서브타입으로 변환할 수 있는 타입 변환 기술!
upcasting downcasting
  • 서브타입에서 슈퍼타입으로 변환
  • 이 경우 타입 변환은 암시적으로 이루어져 별도의 타입 변환 구문이 필요없음
  • upcasting이 필요한 이유는 서브타입 객체를 슈퍼타입 객체로 다루면 유연하게 활용할 수 있기 때문
  • 슈퍼타입에서 서브타입으로 변환
  • 이 경우 as 키워드로 명시적인 타입 변환 필요
  • upcasting보다는 상대적으로 사용할 일이 적지만, 해당 서브클래스의 고유한 메서드와 속성에 다시 접근해야할 때 사용된다. 
class Animal {
  constructor(public name: string) {}

  makeSound(): void {
    console.log("Animal makes a sound");
  }
}

class Dog extends Animal {
  makeSound(): void {
    console.log("Dog barks");
  }

  fetch(): void {
    console.log("Dog fetches the ball");
  }
}

class Cat extends Animal {
  makeSound(): void {
    console.log("Cat meows");
  }

  climbTree(): void {
    console.log("Cat climbs a tree");
  }
}

let dog: Dog = new Dog("Buddy");
let cat: Cat = new Cat("Whiskers");

// 업캐스팅: Dog와 Cat 인스턴스를 Animal 타입으로 캐스팅
let animalDog: Animal = dog;
let animalCat: Animal = cat;

animalDog.makeSound(); // 출력: "Dog barks"
animalCat.makeSound(); // 출력: "Cat meows"

// 업캐스팅 후에는 서브클래스의 특정 메서드나 속성에 접근할 수 없다.
// 예를 들어, 아래 코드는 에러 발생
// animalDog.fetch(); // 에러: Property 'fetch' does not exist on type 'Animal'.
// animalCat.climbTree(); // 에러: Property 'climbTree' does not exist on type 'Animal'.

 

혹시 위의 코드를 다시 다운캐스팅 하려면?

class Animal {
  constructor(public name: string) {}

  makeSound(): void {
    console.log("Animal makes a sound");
  }
}

class Dog extends Animal {
  makeSound(): void {
    console.log("Dog barks");
  }

  fetch(): void {
    console.log("Dog fetches the ball");
  }
}

class Cat extends Animal {
  makeSound(): void {
    console.log("Cat meows");
  }

  climbTree(): void {
    console.log("Cat climbs a tree");
  }
}

let dog: Dog = new Dog("Buddy");
let cat: Cat = new Cat("Whiskers");

let animalDog: Animal = dog;
let animalCat: Animal = cat;

// 다운캐스팅: Animal 타입을 다시 Dog와 Cat 타입으로 캐스팅
let originalDog: Dog = animalDog as Dog;
let originalCat: Cat = animalCat as Cat;

originalDog.makeSound(); // 출력: "Dog barks"
originalDog.fetch(); // 출력: "Dog fetches the ball"

originalCat.makeSound(); // 출력: "Cat meows"
originalCat.climbTree(); // 출력: "Cat climbs a tree"

 

추상 클래스(Abstract class)

추상클래스란 클래스와는 다르게 인스턴스화를 할 수 없는 클래스, 객체를 직접 생성할 수 없는 클래스로서, 다른 클래스들의 공통된 속성과 메서드를 정의하는 기반 클래스이다. 추상 클래스는 일반적으로 추상 메서드(Abstract Method)를 포함하며, 이러한 추상 메서드는 선언만 되고 구현은 하위 클래스에서 이루어 진다!

추상클래스의 목적?

  • 상속을 통해 자식 클래스에서 메서드를 제각각 구현하도록 강제하기 위함
  • 최소한의 기본 메서드는 정의할 수 있지만
  • 핵심기능의 구현은 모두 자식 클래스에게 위임하는 것!
  • 이로 하여금 추상클래스의 인스턴스화를 방지하고(추상클래스에는 추상메서드가 하나이상 포함되어있기 때문에 해당 클래스의 인스턴스를 직접 생성 불가. 에러발생.) 다형성 및 상속을 활용하기 용이, 일관성 있는 프로그래밍을 할 수 있게 도와주며 코드의 가독성을 향상시킨다.

사용방법

  • abstract 키워드 사용
  • 최소 1개 이상의 추상 함수가 있다.
  • 선언만 하고 본체(implementation)를 갖지 않으며, 하위 클래스에서 구현해야 함.
abstract class Vehicle {
  constructor(public brand: string, public model: string) {}

  abstract start(): void;
  abstract stop(): void;
}

class Car extends Vehicle {
  constructor(brand: string, model: string) {
    super(brand, model);
  }

  start(): void {
    console.log(`Starting the car ${this.brand} ${this.model}`);
  }

  stop(): void {
    console.log(`Stopping the car ${this.brand} ${this.model}`);
  }
}

class Bike extends Vehicle {
  constructor(brand: string, model: string) {
    super(brand, model);
  }

  start(): void {
    console.log(`Starting the bike ${this.brand} ${this.model}`);
  }

  stop(): void {
    console.log(`Stopping the bike ${this.brand} ${this.model}`);
  }
}

// 추상 클래스는 직접 인스턴스화할 수 없으므로
// const vehicle = new Vehicle(); // 에러: Cannot create an instance of an abstract class.

const car = new Car("Toyota", "Corolla");
const bike = new Bike("Honda", "CBR");

car.start(); // 출력: "Starting the car Toyota Corolla"
car.stop();  // 출력: "Stopping the car Toyota Corolla"

bike.start(); // 출력: "Starting the bike Honda CBR"
bike.stop();  // 출력: "Stopping the bike Honda CBR"

→ 'Vehicle'은 추상 클래스로, 'start'와 'stop' 메서드를 추상 메서드로 선언한다. 이 추상 메서드는 'Car', 'Bike' 클래스에서 각각 구현되어야 한다. 'Car' 와 'Bike' 클래스들은 'Vehible'을 상속받으며, 각각의 교통수단에 맞게 'start'와 'stop' 메서드를 구현한다. 이를 통해 추상 클래스는 공통된 기능을 정의하고 서브클래스들은 이를 구체화하는 데에 어떻게 사용되는지를 알수 있다!!! 추상 클래스를 사용하면 공통된 동작을 정의함으로써 코드의 재사용성을 높일 수 있음!

 

인터페이스

  • 인터페이스는 TypeScript에서 객체의 타입을 정의하는데 사용
  • 객체가 가져야하는 속성과 메서드를 정의
  • 인터페이스를 구현한 객체는 인터페이스를 반드시 준수해야함! 규약과 같아서 어길 수 없다!
  • 코드의 안전성을 유지하면서 유지 보수성을 향상
  구현부 제공 여부 상속 메커니즘 구현 메커니즘
추상 클래스 클래스의 기본 구현 제공 단일 상속만 지원 추상 클래스를 상속받은 자식 클래스는 반드시 추상 함수 구현해야함
인터페이스 객체의 구조만을 정의하고 기본 구현 제공X 다중 상속 지원 인터페이스를 구현하는 클래스는 인터페이스에 정의된 모든 메서드를 전부 구현해야함

기본 구현을 제공하고 상속을 통해 확장하고자 한다면 추상클래스를!
객체가 완벽하게 특정 구조를 준수하도록 강제하고 싶다면 인터페이스를!


객체 지향 설계 원칙 - S.O.L.I.D

S.O.L.I.D 원칙은 소프트웨어를 더 견고하고 유지보수 가능한 코드로 만드는 중요한 원칙들로, 객체 지향 프로그램에서 유연하고 확장 가능한 소프트웨어를 설계하기 위해 사용됨. 객체 지향 설계를 할 때는 S.O.L.I.D 원칙을 따라서 설계를 하는 것이 필수

 

1. S(SRP. Single Responsibility Principle 단일 책임 원칙)

  • 클래스는 하나의 책임만 가져야한다는 아주 기본적인 원칙

잘못된 사례의 예

class UserService {
  constructor(private db: Database) {}

  getUser(id: number): User {
    // 사용자 조회 로직
    return this.db.findUser(id);
  }

  saveUser(user: User): void {
    // 사용자 저장 로직
    this.db.saveUser(user);
  }

  sendWelcomeEmail(user: User): void {
    // 갑분 이메일 전송 로직이 여기 왜?
    const emailService = new EmailService();
    emailService.sendWelcomeEmail(user);
  }
}

 

올바르게 사용하려면?

class UserService {
  constructor(private db: Database) {}

  getUser(id: number): User {
    // 사용자 조회 로직
    return this.db.findUser(id);
  }

  saveUser(user: User): void {
    // 사용자 저장 로직
    this.db.saveUser(user);
  }
}

class EmailService {
  // 이메일 관련된 기능은 이메일 서비스에서 총괄하는게 맞습니다.
  // 다른 서비스에서 이메일 관련된 기능을 쓴다는 것은 영역을 침범하는 것이에요!
  sendWelcomeEmail(user: User): void {
    // 이메일 전송 로직
    console.log(`Sending welcome email to ${user.email}`);
  }
}

 

2. O(OCP. Open/Closed Principle 개방 폐쇄 원칙)

  • 클래스는 확장에 대해서는 열려있어야 하고 수정에 대해서는 닫혀있어야 한다는 원칙
  • 클래스의 기존코드를 변경하지 않고도 기능을 확장할 수 있어야 함
  • 인터페이스상속을 통해 해결할 수 있다! ex 부모클래스의 기존 코드 변경을 하지 않고 기능을 확장할 수 있으니까

 

3. L(LSP. Liskov Substitution Principle 리스코프 치환 원칙)

  • 서브타입은 기반이 되는 슈퍼타입을 대체할 수 있어야한다는 원칙
  • 자식 클래스는 부모 클래스의 기능을 수정하지 않고도 부모 클래스와 호환되어야함
  • 논리적으로 엄격하게 관계가 정립되어야 함!

 

3. I(ISP. Interface Segregation Principle 인터페이스 분리 원칙)

  • 클래스는 자신이 사용하지 않는 인터페이스의 영향을 받지 않아야 한다
  • 해당 클래스에게 무의미한 메소드의 구현을 막자는 뜻
  • 인터페이스를 너무 크게 정의하기보다는 필요한 만큼만 정의하고 클래스를 입맛에 맞게 필요한 인터페이스를 구현하도록 유도하자는 이야기

 

5. D(DIP. Dependency Inversion Principle 의존성 역전 원칙)

  • DIP는 Java의 Spring 프레임워크나 Node.js의 Nest.js 프레임워크와 같이 웹 서버 프레임워크 내에서 많이 나오는 원칙
  • 하위 수준 모듈(구현 클래스)보다 상위 수준 모듈(Interface)에 의존해야한다는 뜻
  • 예를 들어 데이터베이스라는 클래스가 있다고 가정하면, 데이터 베이스의 원천은 로컬일수도 있고 클라우드 일 수도 있으므로, 데이터베이스의 원천을 로컬 스토리지 타입 혹은 클라우드 스토리지 타입으로 한정하는 것은 X 그보다 상위 수준인 스토리지 타입으로 한정하는 것이 옳다!!
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("클라우드 데이터");