Skip to main content

Command Palette

Search for a command to run...

Typescript 객체지향프로그래밍

객체지향 프로그래밍 캡슐화, 추상화, 상속, 다형성 ...

Published
11 min readView as Markdown
Typescript 객체지향프로그래밍

Introduction

최근에 [헤드퍼스트 디자인패턴]이라는 책을 읽고 있는데, 객체지향프로그래밍에 대한 개념이 부족한 것을 느꼈습니다... 그래서 여러 디자인패턴들에 대해 공부하기전 캡슐화, 추상화, 상속, 다형성 등 객체지향프로그래밍에 대해 먼저 공부하려고 합니다!

Body

객체 지향 프로그래밍(Object-Oriented Programming) 이란?

객체 지향 프로그래밍이란 "객체" 개념을 중심으로 하는 프로그래밍 패러다임입니다. 객체는 데이터를(속성) 및 해당 데이터에 작용하는 동작(메서드)을 캡슐화한 독립적인 단위입니다.
OOP의 주요 원칙은 다음과 같습니다.

  • 캡슐화(Encapsulation)

    • 캡슐화는 데이터(속성)와 해당 데이터에 작용하는 메서드를 단일 단위(객체)로 묶는 것을 의미합니다. 이는 객체의 내부 구현 세부 사항을 숨기고, 객체를 블랙박스처럼 다루며, 다른 코드 부분이 객체와 상호 작용할 수 있는 공개 인터페이스만을 노출합니다.
  • 추상화(Abstraction)

    • 추상화는 객체의 본질적인 측면에 집중하면서 불필요한 복잡성을 숨기는 것을 의미합니다. 이는 인터페이스(객체가 사용되는 방법)와 구현(객체가 내부적으로 정의되는 방법) 사이의 명확한 분리를 만드는 데 도움을 줍니다.
  • 상속(Inheritance)

    • 상속은 클래스(서브클래스)가 다른 클래스(슈퍼클래스)의 속성과 메서드를 상속받을 수 있게 하는 메커니즘입니다. 이는 코드의 재사용성을 높이고, 클래스 간의 계층적 관계를 촉진합니다.
  • 다형성(Polymorphism)

    • 다형성은 서로 다른 클래스의 객체를 공통 슈퍼클래스의 객체로 다룰 수 있게 해줍니다. 이는 단일 인터페이스가 다양한 유형의 객체를 표현할 수 있도록 하여 코드베이스의 유연성과 확장성을 제공합니다.

TypeScript에서는 객체 지향 프로그래밍 개념을 구현하기 위해 클래스와 인터페이스를 만들 수 있습니다.

class Animal {
  private name: string;

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

  makeSound() {
    console.log(`${this.name} makes a sound.`);
  }
}

class Dog extends Animal {
  constructor(name: string) {
    super(name);
  }

  makeSound() {
    console.log(`${this.name} barks.`);
  }
}

const dog = new Dog("Buddy");
dog.makeSound(); // Output: "Buddy barks."

이 예제에서는 Animal 클래스를 정의하고, 그 안에 makeSound 메서드를 포함시킵니다. 그 다음, Animal 클래스를 상속받는 Dog 클래스를 만듭니다. Dog 클래스는 makeSound 메서드를 오버라이드하여 특화된 구현을 제공합니다.

TypeScript에서 객체 지향 프로그래밍 원칙을 사용하면 더 체계적이고, 유지보수하기 쉬우며, 확장 가능한 애플리케이션을 만들 수 있습니다.

클래스와 객체란?

클래스는 객체를 생성하기 위한 설계도 입니다. 클래스는 객체가 생성될 때 기반으로 사용할 구조와 동작을 정의합니다. TypeScript에서 클래스는 속성(필드 또는 속성이라고도 함)과 메서드(클래스와 관련된 함수)를 포함합니다. 클래스는 특정 개념이나 엔티티와 관련된 데이터와 작업을 캡슐화합니다.

다음은 TypeScript에서 클래스를 생성하는 기본 문법입니다

class ClassName {
  // Properties (attributes)
  propertyName1: type;
  propertyName2: type;

  // Constructor
  constructor(parameter1: type, parameter2: type) {
    this.propertyName1 = parameter1;
    this.propertyName2 = parameter2;
  }

  // Methods
  methodName1() {
    // Method implementation
  }

  methodName2() {
    // Method implementation
  }
}

TypeScript에서 기본적인 자동차를 나타내는 간단한 클래스를 만들어 보겠습니다.

class Car {
  // Properties
  make: string;
  model: string;
  year: number;

  // Constructor
  constructor(make: string, model: string, year: number) {
    this.make = make;
    this.model = model;
    this.year = year;
  }

  // Method
  start() {
    console.log(`Starting the ${this.make} ${this.model}.`);
  }
}

이제 객체는 클래스의 인스턴스입니다. 클래스를 정의한 후에는 해당 클래스를 기반으로 객체를 생성할 수 있습니다. 객체는 클래스에 의해 설명된 엔티티의 특정 인스턴스를 나타냅니다. 각 객체는 고유한 속성 세트를 가지며, 클래스에서 정의된 메서드를 호출할 수 있습니다.

다음은 우리가 이전에 정의한 Car 클래스를 기반으로 객체를 생성하는 방법입니다:

const myCar = new Car("Toyota", "Camry", 2022);
const anotherCar = new Car("Honda", "Accord", 2023);

myCar.start(); // Output: "Starting the Toyota Camry."
anotherCar.start(); // Output: "Starting the Honda Accord."

이 예제에서 우리는 myCaranotherCar라는 두 개의 Car 클래스의 별도 인스턴스를 생성했습니다. 각각 고유한 속성(make, model, year)을 가지고 있으며, start 메서드를 호출할 수 있는 능력을 갖추고 있습니다.

TypeScript에서 클래스와 객체를 사용하면 코드를 더 효율적으로 구조화할 수 있고, 상속을 통해 코드 재사용을 촉진하며, 객체 지향 프로그래밍의 원칙을 활용할 수 있습니다.

캡슐화 (Encapsulation)

캡슐화는 객체 지향 프로그래밍(OOP)의 기본 원칙 중 하나입니다. 캡슐화는 데이터를(속성)와 해당 데이터에 작용하는 메서드(함수)를 클래스라는 단일 단위로 묶는 것을 의미합니다. 캡슐화의 주요 목표는 객체의 내부 구현 세부 사항을 숨기고, 다른 코드 부분이 객체와 상호 작용할 수 있는 명확히 정의된 공개 인터페이스만을 노출하는 것입니다. 이 개념은 종종 "데이터 숨기기"라는 문구로 요약됩니다.

캡슐화의 이점

  • 모듈화

    • 관련된 데이터와 동작을 클래스 내에 캡슐화함으로써, 독립적으로 재사용 및 유지 관리할 수 있는 자급자족형 모듈을 만들 수 있습니다.
  • 데이터 보호

    • 내부 데이터를 비공개(또는 보호)로 설정함으로써 클래스 외부에서의 무단 접근과 수정을 방지하여 데이터의 무결성과 보안을 보장합니다.
  • 코드 유연성

    • 캡슐화를 통해 공개 인터페이스가 변경되지 않는 한 클래스의 내부 구현을 변경하더라도 그 클래스를 사용하는 코드에 영향을 미치지 않게 할 수 있습니다.

이제 TypeScript에서 캡슐화의 예를 살펴보겠습니다.

class BankAccount {
  private accountNumber: string;
  private balance: number;

  constructor(accountNumber: string, initialBalance: number) {
    this.accountNumber = accountNumber;
    this.balance = initialBalance;
  }

  public getAccountNumber(): string {
    return this.accountNumber;
  }

  public getBalance(): number {
    return this.balance;
  }

  public deposit(amount: number): void {
    this.balance += amount;
    console.log(`Deposited ${amount}. New balance: ${this.balance}`);
  }

  public withdraw(amount: number): void {
    if (this.balance >= amount) {
      this.balance -= amount;
      console.log(`Withdrawn ${amount}. New balance: ${this.balance}`);
    } else {
      console.log("Insufficient balance");
    }
  }
}

이 예제에서는 간단한 은행 계좌를 나타내는 BankAccount 클래스를 가지고 있습니다. 이 클래스에는 accountNumberbalance라는 두 개의 비공개 속성이 있습니다. 이 속성들은 private으로 표시되어 있어서 클래스 외부에서 접근할 수 없습니다. 이는 코드의 다른 부분에서 이러한 속성에 직접 접근하거나 수정할 수 없도록 보장합니다.

비공개 속성과 상호 작용하기 위해, 클래스는 getAccountNumber, getBalance, deposit, withdraw라는 공개 메서드를 제공합니다. 이 메서드들은 다른 코드가 BankAccount 객체와 상호 작용할 수 있도록 하는 공개 인터페이스 역할을 합니다.

이제 이 클래스를 사용해보겠습니다.

const account = new BankAccount("123456789", 1000);

console.log("Account Number:", account.getAccountNumber()); // Output: "Account Number: 123456789"
console.log("Balance:", account.getBalance()); // Output: "Balance: 1000"

account.deposit(500); // Output: "Deposited 500. New balance: 1500"
account.withdraw(200); // Output: "Withdrawn 200. New balance: 1300"
account.withdraw(1500); // Output: "Insufficient balance"

이 사용 예제에서 우리는 공개 메서드를 통해 계좌 번호와 잔액에 접근할 수 있지만, 이를 직접 수정할 수는 없다는 것을 알 수 있습니다. 이는 캡슐화 덕분인데, 이는 BankAccount 객체의 내부 상태를 보호하고 정의된 공개 인터페이스를 통해서만 제어된 접근을 허용합니다.

데이터를 클래스 내에 캡슐화하고 상호 작용할 수 있는 잘 정의된 메서드를 제공함으로써 객체의 상태가 일관되고 안전하게 유지되도록 하며, 코드 유지 관리가 더 쉬워지고 버그나 실수로 인한 오용의 가능성을 줄일 수 있습니다.

추상화 (Abstraction)

추상화는 객체 지향 프로그래밍(OOP)의 중요한 개념으로, 객체의 필수적인 기능을 제공하면서 불필요한 세부 사항을 숨기는 데 중점을 둡니다. 이를 통해 복잡한 시스템이나 엔티티를 단순화된 방식으로 표현할 수 있어 이해하고 작업하기가 쉬워집니다. 추상화를 통해 객체의 관련된 특성을 캡처하면서 모든 구현 세부 사항을 노출하지 않을 수 있습니다.

OOP에서 추상화는 추상 클래스와 인터페이스를 사용하여 달성됩니다. 이러한 추상적 구조는 다른 클래스가 따라야 할 청사진을 제공하며, 메서드와 속성의 집합을 정의하지만 그 구현을 제공하지 않습니다. 추상 클래스를 확장하거나 인터페이스를 구현하는 구체적인 서브클래스가 이러한 추상 요소들을 구현하는 역할을 맡습니다.

추상화의 이점

  • 복잡성 단순화

    • 추상화는 객체의 고수준 설계와 동작에 집중하게 하여, 그 수준에서 중요하지 않은 복잡한 세부 사항을 숨깁니다.
  • 코드 재사용성

    • 추상 클래스나 인터페이스를 통해 공통 인터페이스를 정의함으로써 코드 재사용을 촉진합니다. 구체적인 클래스는 추상 클래스를 상속하거나 인터페이스를 구현하여 그 구조와 동작을 상속받을 수 있습니다.
  • 유연성

    • 추상화는 추상 인터페이스가 변경되지 않는 한, 구체적인 클래스의 구현 세부 사항을 변경해도 프로그램의 전체 기능에 영향을 미치지 않도록 해줍니다.

이제 Typescript에서 추상화 예를 살펴보겠습니다.

// Abstract class representing a shape
abstract class Shape {
  abstract getArea(): number;
  abstract getPerimeter(): number;
}

// Concrete subclass representing a Circle
class Circle extends Shape {
  private radius: number;

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

  getArea(): number {
    return Math.PI * this.radius * this.radius;
  }

  getPerimeter(): number {
    return 2 * Math.PI * this.radius;
  }
}

// Concrete subclass representing a Rectangle
class Rectangle extends Shape {
  private width: number;
  private height: number;

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

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

  getPerimeter(): number {
    return 2 * (this.width + this.height);
  }
}

이 예제에서 우리는 Shape라는 추상 클래스를 가지고 있습니다. 이 클래스는 getArea()getPerimeter()라는 두 개의 추상 메서드를 정의합니다. 이 메서드들은 어떤 도형의 필수적인 특성을 나타내지만, Shape 클래스 자체에서는 구현되지 않습니다.

그 다음으로, CircleRectangle이라는 두 개의 구체적인 서브클래스가 Shape 클래스를 확장합니다. 이 서브클래스들은 추상 메서드에 대한 구현을 제공할 책임이 있습니다.

추상화를 사용함으로써, 우리는 다양한 도형을 모아 각 도형의 구체적인 구현 세부 사항에 대해 걱정하지 않고 그들의 면적과 둘레를 계산할 수 있습니다.

function printShapeDetails(shape: Shape) {
  console.log("Area:", shape.getArea());
  console.log("Perimeter:", shape.getPerimeter());
}

const circle = new Circle(5);
const rectangle = new Rectangle(4, 6);

printShapeDetails(circle);
printShapeDetails(rectangle);

이 코드들 실행하면 아래와 같은 결과가 나옵니다.

Area: 78.53981633974483
Perimeter: 31.41592653589793
Area: 24
Perimeter: 20

추상화의 장점은 printShapeDetails 함수가 Shape 인터페이스를 구현하는 모든 도형과 함께 작동할 수 있다는 점입니다. 삼각형, 사각형 등 새로운 도형을 만들어도 Shape 클래스나 printShapeDetails 함수를 수정할 필요가 없습니다. 이는 추상화를 통해 얻은 유연성과 재사용성을 잘 보여줍니다.

상속 (Inheritance)

상속은 객체 지향 프로그래밍(OOP)의 핵심 개념으로, 한 클래스(서브클래스)가 다른 클래스(슈퍼클래스)의 속성과 메서드를 상속받을 수 있게 해줍니다. 상속은 클래스 간의 "is-a" 관계를 형성하며, 서브클래스는 슈퍼클래스의 특수화된 버전이 됩니다. 상속은 공통 속성과 동작을 슈퍼클래스에 정의하고 이를 서브클래스에서 확장하거나 수정할 수 있도록 하여 코드 재사용을 촉진합니다.

상속되는 클래스를 슈퍼클래스 또는 기본 클래스라고 하고, 슈퍼클래스를 상속받는 클래스를 서브클래스 또는 파생 클래스라고 합니다.

상속의 이점

  1. 코드 재사용성: 상속을 통해 슈퍼클래스에 정의된 기능을 재사용할 수 있으며, 애플리케이션에서 중복 코드를 줄일 수 있습니다.

  2. 모듈화: 상속은 클래스의 계층적 조직을 촉진하여 코드를 더 체계적으로 구성하고 유지 관리하기 쉽게 만듭니다.

  3. 다형성: 상속된 메서드는 서브클래스에서 오버라이드되어 특수한 동작을 제공할 수 있으며, 공통 슈퍼클래스 인터페이스를 통해 서로 다른 클래스의 객체를 다룰 때 다형성을 가능하게 합니다.

이제 예제를 통해 상속을 설명해 보겠습니다.

class Animal {
  private name: string;
  private age: number;

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

  makeSound() {
    console.log("Some generic sound");
  }

  getInfo() {
    return `Name: ${this.name}, Age: ${this.age}`;
  }
}

// Subclass: Dog (inherits from Animal)
class Dog extends Animal {
  private breed: string;

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

  makeSound() {
    console.log("Woof!");
  }

  getInfo() {
    return `${super.getInfo()}, Breed: ${this.breed}`;
  }
}

이 예제에서는 nameage라는 속성과 makeSound()getInfo() 메서드를 가진 기본 클래스 Animal이 있습니다. 그런 다음, Animal을 상속받는 Dog라는 서브클래스를 정의합니다. Dog 클래스는 고유한 속성 breed를 추가하고, makeSound()getInfo() 메서드를 오버라이드합니다.

이제 이러한 클래스를 기반으로 객체를 생성해 보겠습니다:

const genericAnimal = new Animal("Generic Animal", 5);
console.log(genericAnimal.getInfo()); // Output: "Name: Generic Animal, Age: 5"
genericAnimal.makeSound(); // Output: "Some generic sound"

const dog = new Dog("Buddy", 3, "Labrador");
console.log(dog.getInfo()); // Output: "Name: Buddy, Age: 3, Breed: Labrador"
dog.makeSound(); // Output: "Woof!"

이 예제에서, Dog 클래스는 Animal 클래스로부터 name, age 속성과 메서드를 상속받는 것을 볼 수 있습니다. makeSound() 메서드는 Dog 클래스에서 오버라이드되어 개의 특유의 소리("Woof!")를 제공합니다. 또한, getInfo() 메서드도 Dog 클래스에서 오버라이드되어 nameage와 함께 breed 속성을 포함합니다.

상속을 사용함으로써, 각 서브클래스가 슈퍼클래스의 기능을 상속하고 확장하는 클래스 계층 구조를 만들 수 있습니다. 이 접근 방식은 공통 코드를 재사용하고, 특화된 동작을 정의하며, 구조적이고 모듈화된 방식으로 복잡한 시스템을 구축할 수 있게 합니다.

다형성 (Polymorphism)

다형성은 객체 지향 프로그래밍(OOP)의 또 다른 중요한 개념으로, 서로 다른 클래스의 객체를 공통 슈퍼클래스의 객체로 다룰 수 있게 합니다. 이를 통해 단일 인터페이스(메서드 또는 속성)가 다양한 유형의 객체를 나타낼 수 있어 코드베이스에 유연성과 확장성을 제공합니다. 다형성은 종종 메서드 오버라이딩과 메서드 오버로딩을 통해 구현됩니다.

다형성에는 두 가지 유형이 있습니다:

  • 컴파일 타임 다형성 (정적 다형성)

    • 이 유형의 다형성은 컴파일 시에 해결됩니다. 이는 메서드 오버로딩이 사용될 때 발생합니다, 즉, 동일한 이름이지만 다른 매개변수 목록을 가진 여러 메서드가 존재할 때입니다. 컴파일러는 함수 호출 시 전달된 인수와 메서드의 시그니처를 기반으로 적절한 메서드를 결정합니다.
  • 런타임 다형성 (동적 다형성)

    • 이 유형의 다형성은 런타임 시에 해결됩니다. 이는 서브클래스에서 슈퍼클래스와 동일한 이름과 시그니처를 가진 메서드가 있을 때 발생합니다. 호출될 메서드는 객체의 실제 유형에 따라 런타임 시에 결정됩니다.

이제 두 가지 유형의 다형성을 예제로 설명해 보겠습니다.

// Compile-time Polymorphism (Method Overloading)
class MathOperations {
  add(a: number, b: number): number;
  add(a: string, b: string): string;
  add(a: any, b: any): any {
    return a + b;
  }
}

const math = new MathOperations();
console.log(math.add(5, 10)); // Output: 15 (number addition)
console.log(math.add("Hello, ", "World!")); // Output: "Hello, World!" (string concatenation)

// Run-time Polymorphism (Method Overriding)
class Animal {
  makeSound() {
    console.log("Some generic sound");
  }
}

class Dog extends Animal {
  makeSound() {
    console.log("Woof!");
  }
}

class Cat extends Animal {
  makeSound() {
    console.log("Meow!");
  }
}

function animalSound(animal: Animal) {
  animal.makeSound();
}

const dog = new Dog();
const cat = new Cat();

animalSound(dog); // Output: "Woof!" (Dog's sound)
animalSound(cat); // Output: "Meow!" (Cat's sound)

이 예제에서 먼저 MathOperations 클래스에서 메서드 오버로딩을 통한 컴파일 타임 다형성을 보여줍니다. 우리는 add 메서드의 두 가지 버전을 정의합니다: 하나는 숫자 덧셈을 위한 것이고, 다른 하나는 문자열 연결을 위한 것입니다. 함수 호출 시 사용된 인수 유형에 따라 적절한 메서드가 컴파일 타임에 선택됩니다.

다음으로, Animal, Dog, Cat 클래스에서 메서드 오버라이딩을 통한 런타임 다형성을 보여줍니다. Animal 클래스는 일반적인 소리를 제공하는 makeSound 메서드를 가지고 있습니다. DogCat 클래스는 각각의 특수한 소리를 제공하기 위해 makeSound 메서드를 오버라이드합니다. 우리가 DogCat의 객체를 사용하여 animalSound 함수를 호출할 때, 적절한 makeSound 메서드가 객체의 실제 유형에 따라 런타임에 동적으로 결정됩니다.

다형성을 통해 객체를 특정 유형이 아닌 공통 인터페이스를 기반으로 처리하여 더 유연하고 확장 가능한 코드를 작성할 수 있습니다. 이는 대규모 애플리케이션 설계에서 중요한 역할을 하며, 다양한 클래스와 모듈 간의 상호작용을 단순화합니다.

Typescript 인터페이스

TypeScript에서 인터페이스는 객체의 구조와 형태를 정의하는 데 사용됩니다. 인터페이스는 객체가 따라야 할 계약을 정의하는 방법을 제공하며, 객체가 가져야 할 속성, 메서드 및 그들의 타입을 지정합니다. 인터페이스는 정적 타입 검사와 코드 문서화를 구현하는 데 중요한 역할을 합니다.

다음은 TypeScript 인터페이스에 대한 개요입니다.

인터페이스 선언

인터페이스는 interface 키워드를 사용하여 선언하며, 그 뒤에 인터페이스의 이름을 붙입니다.

선언 예시 입니다.

interface Person {
  name: string;
  age: number;
}

속성

인터페이스는 객체가 가져야 할 속성을 정의합니다. 각 속성은 이름과 타입으로 정의됩니다.

속성 예시 입니다.

interface Person {
  name: string;
  age: number;
}

선택적 속성

인터페이스에서 속성 이름 뒤에 ?(물음표)를 추가하여 속성을 선택적으로 만들 수 있습니다. 이러한 속성은 구현 객체에서 존재할 수도 있고 생략될 수도 있습니다.

선택적 속성 예시 입니다.

interface Person {
  name: string;
  age?: number;
}

읽기 전용 속성

readonly 수식어를 사용하여 속성을 읽기 전용으로 표시할 수 있습니다. 읽기 전용 속성은 객체가 생성될 때만 값을 할당할 수 있으며, 그 이후에는 수정할 수 없습니다.

읽기 전용 속성 예시 입니다.

interface Person {
  readonly name: string;
  readonly age: number;
}

메서드

인터페이스는 객체가 구현해야 하는 메서드도 정의할 수 있습니다. 메서드 시그니처에는 메서드 이름, 매개변수 타입, 반환 타입이 포함됩니다.

메서드 예시 입니다.

interface Calculator {
  add(a: number, b: number): number;
  subtract(a: number, b: number): number;
}

인터페이스 확장

인터페이스는 다른 인터페이스를 확장하여 그 속성과 메서드를 상속받으면서 새로운 속성과 메서드를 추가할 수 있습니다. 이를 통해 모듈화되고 재사용 가능한 인터페이스 정의를 만들 수 있습니다.

인터페이스 확장 예시 입니다.

interface Employee extends Person {
  employeeId: number;
  department: string;
}

인터페이스 구현

객체가 특정 인터페이스를 따르도록 보장하려면 implements 키워드를 사용하여 해당 객체가 특정 인터페이스를 구현한다고 명시할 수 있습니다. TypeScript는 객체가 필요한 속성과 메서드를 제공하도록 강제합니다.

인터페이스 구현 예시 입니다.

class Person implements Employee {
   name: string;
   age: number;
   employeeId: number;
   department: string;
}

TypeScript의 인터페이스는 계약을 정의하고 객체에 대한 타입 검사를 강제하는 방법을 제공합니다. 인터페이스는 코드 재사용성과 유지보수성을 촉진하며, 컴파일 타임에 오류를 잡는 데 도움을 줍니다.

Conclusion

이번에 객체지향 프로그래밍에 대해서 공부한걸 바탕으로 더 좋은 코드를 작성할 수 있다는 자신감이 생겼습니다! 아직 잘 읽히지 않고 있는 디자인패턴 책을 더 깊게 공부하면서 좋은 개발자가 되겠습니다!