OOP: 상속과 믹스인

hwisaac·2023년 2월 25일
0

oop

목록 보기
3/5

객체의 상속(Inheritance)

객체지향 프로그래밍에서 상속(Inheritance)은 클래스(Class) 간에 관계를 형성하는 기능입니다.

상속을 사용하면 기존 클래스를 기반으로 새로운 클래스를 작성할 수 있습니다.

이를 통해 중복 코드를 줄이고, 코드의 재사용성을 높일 수 있습니다.

상속을 사용하면 상위 클래스(Superclass)의 속성과 메서드를 하위 클래스(Subclass)가 물려받아 사용할 수 있습니다.

상위 클래스에서 정의된 속성과 메서드는 하위 클래스에서 다시 정의하지 않아도 사용할 수 있으며, 상위 클래스에서 정의된 속성과 메서드를 재사용하여 하위 클래스를 작성할 수 있습니다.

상속을 사용하는 가장 일반적인 예시는 extends 키워드를 사용하여 하위 클래스를 정의하는 것입니다.

예를 들어, 다음은 TypeScript로 작성된 Animal 클래스와 Dog 클래스입니다.

class Animal {
  private name: string;

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

  public speak(): void {
    console.log(`${this.name} makes a noise.`);
  }
}

class Dog extends Animal {
  public speak(): void {
    console.log(`${this.name} barks.`);
  }
}

const dog1 = new Dog("Rufus");
dog1.speak(); // Rufus barks.

위 예시에서 Animal 클래스는 name 속성을 가지며, speak 메서드를 가지고 있습니다.

Dog 클래스는 Animal 클래스를 상속하며, speak 메서드를 다시 정의합니다.

dog1Dog 클래스의 인스턴스로, speak 메서드를 사용하여 객체의 동작을 수행합니다.

상속을 사용하면 상위 클래스에서 정의된 속성과 메서드를 하위 클래스에서 재사용하여 코드를 간결하게 작성할 수 있습니다.

하지만 상속은 상위 클래스와 하위 클래스 간의 결합도(Coupling)를 높이는 결과를 가져올 수 있으므로, 상속을 남발하지 않고 적절한 상황에서 사용하는 것이 좋습니다.

상속할 때 주의해야 하는 '결합도'

상속은 부모 클래스와 자식 클래스 간의 결합도(Coupling)를 높일 수 있습니다.

결합도는 두 모듈 간의 상호 의존도를 나타내는 개념으로, 결합도가 높을수록 모듈 간의 의존성이 강해지며, 하나의 모듈을 변경할 때 다른 모듈에도 영향을 미칠 가능성이 높아집니다.

예를 들어, 다음은 TypeScript로 작성된 Person 클래스와 Employee 클래스입니다.

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

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

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

  public setName(name: string): void {
    this.name = name;
  }

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

  public setAge(age: number): void {
    this.age = age;
  }
}

class Employee extends Person {
  private employeeId: number;

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

  public getEmployeeId(): number {
    return this.employeeId;
  }

  public setEmployeeId(employeeId: number): void {
    this.employeeId = employeeId;
  }
}

위 예시에서 Person 클래스는 nameage 속성을 가지며, getName, setName, getAge, setAge 메서드를 가지고 있습니다.

Employee 클래스는 Person 클래스를 상속하며, employeeId 속성과 getEmployeeId, setEmployeeId 메서드를 가지고 있습니다.

이 예시에서 Employee 클래스는 Person 클래스를 상속받았기 때문에, Employee 클래스는 Person 클래스의 속성과 메서드를 사용할 수 있습니다.

하지만 Employee 클래스는 Person 클래스에 강하게 결합되어 있습니다.

만약 Person 클래스를 수정하면, Employee 클래스도 영향을 받을 수 있으며, 이를 방지하기 위해서는 Person 클래스와 Employee 클래스를 느슨하게 결합해야 합니다.

상속을 사용할 때는 결합도가 높아지지 않도록 주의해야 하며, 상속보다는 인터페이스(Interface)나 믹스인(Mixin) 등의 기술을 사용하여 느슨한 결합을 유지하는 것이 좋습니다.

인터페이스를 이용하여 느슨하게 결합하기

인터페이스는 클래스가 구현해야 하는 메서드의 이름과 타입을 정의합니다.

이를 통해 여러 클래스에서 같은 인터페이스를 구현하도록 하여 코드의 재사용성을 높일 수 있습니다.

다음은 TypeScript로 작성된 Person 클래스와 Employee 클래스를 인터페이스를 사용하여 수정한 예시입니다.

interface IPerson {
  getName(): string;
  setName(name: string): void;
  getAge(): number;
  setAge(age: number): void;
}

class Person implements IPerson {
  private name: string;
  private age: number;

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

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

  public setName(name: string): void {
    this.name = name;
  }

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

  public setAge(age: number): void {
    this.age = age;
  }
}

class Employee implements IPerson {
  private employeeId: number;
  private person: Person;

  constructor(name: string, age: number, employeeId: number) {
    this.person = new Person(name, age);
    this.employeeId = employeeId;
  }

  public getName(): string {
    return this.person.getName();
  }

  public setName(name: string): void {
    this.person.setName(name);
  }

  public getAge(): number {
    return this.person.getAge();
  }

  public setAge(age: number): void {
    this.person.setAge(age);
  }

  public getEmployeeId(): number {
    return this.employeeId;
  }

  public setEmployeeId(employeeId: number): void {
    this.employeeId = employeeId;
  }
}

위 예시에서 IPerson 인터페이스는 Person 클래스와 Employee 클래스에서 구현해야 하는 메서드를 정의합니다.

Person 클래스는 IPerson 인터페이스를 구현하며, Employee 클래스는 IPerson 인터페이스와 Person 클래스를 사용하여 구현됩니다.

이를 통해 Employee 클래스는 Person 클래스와 느슨하게 결합되며, Person 클래스의 변경이 Employee 클래스에 영향을 미치지 않도록 할 수 있습니다.

믹스인(Mixin)

믹스인(Mixin)은 객체 지향 프로그래밍에서 다중 상속을 구현하는 방법 중 하나입니다.

다중 상속이란, 하나의 클래스가 여러 개의 클래스로부터 상속받는 것을 말합니다.

다중 상속은 다양한 문제점을 가지고 있기 때문에 일부 언어에서는 지원하지 않거나 제한적으로 지원합니다.

믹스인은 이러한 문제점을 해결하기 위한 방법 중 하나로, 다중 상속의 일부 기능만을 제공하면서도 코드의 재사용성을 높이는 방법입니다.

믹스인은 클래스가 아니라 재사용 가능한 메소드와 프로퍼티들의 집합이며, 다른 클래스에서 이를 재사용할 수 있습니다.

믹스인을 사용하면 코드 중복을 줄이고, 클래스 간의 의존성을 낮출 수 있습니다.

믹스인은 보통 인터페이스를 사용하여 정의하며, 인터페이스를 상속받는 클래스에서 믹스인의 메소드와 프로퍼티를 구현함으로써 사용합니다.

javascript 와 typescript 에서 믹스인 구현하기

JavaScriptTypeScript 언어에서는 믹스인을 지원하지 않습니다.

하지만, 이를 구현하기 위한 다양한 방법이 있습니다.

JavaScript에서는 객체 병합(Object merging)을 이용하여 믹스인을 구현할 수 있습니다.

예를 들어, 다음과 같은 두 개의 객체가 있다고 가정해보겠습니다.

const user = {
  name: 'Alice',
  age: 30
};

const logger = {
  log(message) {
    console.log(`[${new Date().toISOString()}] ${message}`);
  }
};

이제, 이 두 개의 객체를 병합하여 새로운 객체를 생성하는 함수를 작성해보겠습니다.

function mixin(target, ...sources) {
  Object.assign(target, ...sources);
}

위 함수는 첫 번째 인수로 타겟 객체를 받고, 나머지 인수로 병합할 소스 객체들을 받습니다. 이후, Object.assign 메소드를 사용하여 타겟 객체에 소스 객체들을 병합합니다.

이제, mixin 함수를 사용하여 믹스인을 구현할 수 있습니다.

const myClass = {};

mixin(myClass, user, logger);

console.log(myClass); // { name: 'Alice', age: 30, log: [Function: log] }

위 코드에서는 mixin 함수를 사용하여 userlogger 객체를 myClass 객체에 병합하였습니다. 이를 통해 myClass 객체는 name, age, log 메소드를 모두 갖게 됩니다.

TypeScript에서는 mixin을 구현하기 위해 클래스를 사용할 수 있습니다.

TypeScript에서는 인터페이스와 클래스를 함께 사용하여 mixin을 구현할 수 있습니다.

예를 들어, 다음과 같은 인터페이스와 클래스가 있다고 가정해보겠습니다.

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

class Logger {
  log(message: string) {
    console.log(`[${new Date().toISOString()}] ${message}`);
  }
}

이제, 이 두 개의 클래스와 인터페이스를 함께 사용하여 믹스인을 구현하는 예시를 보겠습니다.

class MyClass implements User {
  name = 'Alice';
  age = 30;
}

interface MyClass extends Logger {}

const myClass = new MyClass();

myClass.log('Hello, world!'); // [2022-02-28T08:55:21.479Z] Hello, world!

위 코드에서는 MyClass 클래스가 User 인터페이스를 구현하며, MyClass 클래스에 Logger 클래스를 믹스인합니다. 이를 통해 myClass 객체는 name, age, log 메소드를 모두 갖게 됩니다.

하지만, 이러한 방법은 TypeScript의 클래스 데코레이터를 이용하면 더욱 효율적으로 구현할 수 있습니다. 클래스 데코레이터를 사용하면 코드의 가독성과 유지보수성이 향상될 수 있습니다.

typescript 에서 클래스 데코레이터를 이용해 믹스인을 구현하는 방법

TypeScript에서 클래스 데코레이터를 이용해 믹스인을 구현하는 방법은 다음과 같습니다.

  1. 믹스인으로 사용할 클래스를 정의합니다. 믹스인으로 사용할 클래스는 일반적으로 추상 클래스 또는 인터페이스로 정의합니다.
abstract class Animal {
  abstract makeSound(): void;
}
  1. 믹스인 데코레이터 함수를 작성합니다. 믹스인 데코레이터 함수는 데코레이터로 믹스인을 적용할 대상 클래스와 믹스인으로 사용할 클래스를 인수로 받습니다.
function withSleep<T extends { new(...args: any[]): {} }>(Base: T) {
  return class extends Base {
    sleep() {
      console.log('zzz...');
    }
  }
}

위 코드에서는 withSleep 데코레이터 함수를 정의하였습니다. 이 함수는 제네릭 타입 T를 인수로 받습니다. T는 클래스 타입이어야 하며, 생성자 함수의 시그니처를 나타내는 { new(...args: any[]): {} } 타입입니다. 이 함수는 Base 클래스를 상속받은 새로운 클래스를 반환합니다. 반환된 클래스에는 sleep 메소드가 추가됩니다.

데코레이터를 적용할 클래스를 정의합니다.

@withSleep
class Dog extends Animal {
  makeSound() {
    console.log('woof');
  }
}

위 코드에서는 Dog 클래스에 withSleep 데코레이터를 적용하였습니다. 이를 통해 Dog 클래스는 Animal 클래스와 sleep 메소드를 모두 상속받게 됩니다.

데코레이터를 적용한 클래스를 사용합니다.

const dog = new Dog();
dog.makeSound(); // 'woof'
dog.sleep(); // 'zzz...'

위 코드에서는 새로운 Dog 객체를 생성하고, makeSoundsleep 메소드를 호출하였습니다.

이와 같이, TypeScript에서 클래스 데코레이터를 이용하여 믹스인을 구현할 수 있습니다. 이를 통해 코드의 가독성과 유지보수성을 향상시킬 수 있습니다.

믹스인 사용시 주의해야 할 점

믹스인은 상속보다는 구성을 사용해야 한다.

믹스인은 클래스 간의 상속 대신 구성(composition)을 사용하여 구현해야 합니다. 이는 클래스 간의 의존성을 낮추고, 코드의 재사용성을 높이는 데 도움이 됩니다.

예를 들어, 쇼핑몰 웹 애플리케이션을 만든다고 가정해보겠습니다. 이 애플리케이션에서는 상품을 나타내는 Product 클래스와 그 하위 클래스인 Book 클래스와 Electronic 클래스가 필요합니다. 이때, BookElectronic 클래스는 Product 클래스에서 상속받을 수 있습니다.

class Product {
  name: string;
  price: number;

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

class Book extends Product {
  author: string;

  constructor(name: string, price: number, author: string) {
    super(name, price);
    this.author = author;
  }
}

class Electronic extends Product {
  manufacturer: string;

  constructor(name: string, price: number, manufacturer: string) {
    super(name, price);
    this.manufacturer = manufacturer;
  }
}

위 코드에서 BookElectronic 클래스는 Product 클래스를 상속받아 생성되었습니다. 이를 상속을 이용한 코드 재사용성이라고 할 수 있습니다.

하지만, 이러한 상속 구조는 각 클래스 간의 의존성을 높이고, 유지보수가 어려워질 수 있습니다.

예를 들어, Product 클래스에서 변경이 일어날 경우, 그 변경이 하위 클래스인 BookElectronic 클래스에도 영향을 미치게 됩니다.

이를 해결하기 위해, 구성(composition)을 사용하여 코드를 재구성할 수 있습니다.

즉, Product 클래스가 BookElectronic 클래스를 내부적으로 포함하도록 변경하는 것입니다.

class Product {
  name: string;
  price: number;

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

class Book {
  product: Product;
  author: string;

  constructor(name: string, price: number, author: string) {
    this.product = new Product(name, price);
    this.author = author;
  }
}

class Electronic {
  product: Product;
  manufacturer: string;

  constructor(name: string, price: number, manufacturer: string) {
    this.product = new Product(name, price);
    this.manufacturer = manufacturer;
  }
}

위 코드에서 Product 클래스는 그대로 유지되었고, BookElectronic 클래스는 Product 클래스를 내부적으로 포함하도록 변경되었습니다.

이를 구성을 이용한 코드 재사용성이라고 할 수 있습니다.

이제, Product 클래스에서 변경이 일어나더라도 BookElectronic 클래스에는 영향을 미치지 않습니다.

또한, BookElectronic 클래스는 각각 Product 클래스를 포함하기 때문에, 각 클래스 간의 의존성이 낮아지고, 유지보수가 용이해집니다.

또한, 각 클래스의 역할이 분리되어 있어서 코드의 가독성이 높아집니다.

상속과 구성은 각각 장단점이 있기 때문에, 어떤 방법을 선택할지는 상황에 따라 다릅니다.

일반적으로는 구성을 사용하는 것이 상속보다 더 안전하고 유연한 방법이라고 할 수 있습니다.

하지만, 구성을 사용하면 코드의 복잡성이 높아질 수 있으며, 일부 상황에서는 상속을 사용하는 것이 더 적합할 수도 있습니다.

믹스인 간에 이름 충돌이 일어날 수 있다.

믹스인은 여러 개의 클래스에서 재사용될 수 있기 때문에, 다른 믹스인에서 이미 사용한 이름과 충돌할 수 있습니다.

이러한 경우에는 이름 충돌을 방지하기 위해 네임스페이스를 사용하거나, 믹스인에서 이름을 변경해야 할 수 있습니다.

예를 들어, TypeScript에서 다음과 같이 두 개의 믹스인을 정의해보겠습니다.

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

interface Logger {
  log(message: string): void;
}

class MyClass implements User, Logger {
  name = 'Alice';
  age = 30;

  log(message: string) {
    console.log(`[${new Date().toISOString()}] ${message}`);
  }
}

위 코드에서 UserLogger 믹스인은 각각 이름과 나이, 그리고 로깅 기능을 제공합니다.

MyClass 클래스는 UserLogger 믹스인을 구현한 클래스이며, 이를 통해 이름, 나이, 로깅 기능을 모두 갖게 됩니다.

하지만, 이 코드에서는 UserLogger 믹스인에서 동일한 이름을 가진 메소드가 존재할 때, 이름 충돌이 일어날 수 있습니다.

예를 들어, Logger 믹스인에서 log 메소드를 다시 정의한다면 다음과 같은 코드가 될 수 있습니다.

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

interface Logger {
  log(message: string): void;
}

interface Logger {
  logError(error: Error): void;
}

class MyClass implements User, Logger {
  name = 'Alice';
  age = 30;

  log(message: string) {
    console.log(`[${new Date().toISOString()}] ${message}`);
  }

  logError(error: Error) {
    console.log(`[${new Date().toISOString()}] ${error.message}`);
  }
}

위 코드에서 Logger 믹스인에서 log 메소드를 다시 정의하면, MyClass 클래스에서는 어떤 log 메소드를 사용해야 할지 알 수 없게 됩니다.

이러한 경우에는 이름 충돌을 방지하기 위해 네임스페이스를 사용하거나, 메소드의 이름을 변경하는 등의 방법을 사용해야 합니다.

예를 들어, Logger 믹스인에서 log 메소드를 logMessage로 변경한다면, 다음과 같이 코드를 수정할 수 있습니다.

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

interface Logger {
  logMessage(message: string): void;
}

interface Logger {
  logError(error: Error): void;
}

class MyClass implements User, Logger {
  name = 'Alice';
  age = 30;

  logMessage(message: string) {
    console.log(`[${new Date().toISOString()}] ${message}`);
  }

  logError(error: Error) {
    console.log(`[${new Date().toISOString()}] ${error.message}`);
  }
}

위 코드에서 Logger 믹스인에서 log 메소드를 logMessage로 변경하면, MyClass 클래스에서는 각각의 메소드에 대해 이름 충돌이 일어나지 않게 됩니다.

믹스인 간에 상속 관계가 생길 수 있다.

믹스인을 여러 개 사용하다 보면, 믹스인 간에도 상속 관계가 생길 수 있습니다.

이러한 경우에는 상속 관계를 만들지 않도록 주의해야 합니다.

또한, 믹스인 간의 순서에 따라 동작이 달라질 수 있기 때문에, 순서를 고려하여 사용해야 합니다.

믹스인을 과도하게 사용하지 말아야 한다.

믹스인을 과도하게 사용하면, 코드가 복잡해지고 유지보수가 어려워질 수 있습니다.

믹스인은 코드의 재사용성을 높이기 위한 방법으로 사용해야 하며, 필요한 경우에만 사용해야 합니다.

믹스인이 사용되는 언어에서 지원하지 않는 경우가 있다.(JavaScript 와 TypeScript 도 지원하지 않습니다.)

몇몇 언어에서는 믹스인을 지원하지 않거나, 제한적으로 지원하는 경우가 있습니다.

이 경우에는 다른 방법으로 코드의 재사용성을 높일 수 있습니다.

상속(Inherence) vs 구성(Composition)

상속과 구성은 각각 장단점이 있기 때문에, 어떤 방법이 적합한지는 상황에 따라 다르기 때문에, 코드의 특성과 요구사항에 따라 선택해야 합니다.

그러나 일반적으로는 구성이 상속보다 더 안전하고 유연한 방법이라고 할 수 있습니다.

상속을 사용하면 부모 클래스와 하위 클래스 간에 강한 의존성이 생기기 때문에, 부모 클래스에서 변경이 일어날 경우, 하위 클래스에도 영향을 미치게 됩니다.

또한, 상속을 남발하면 클래스 간의 계층 구조가 복잡해지고 유지보수가 어려워질 수 있습니다.

반면에 구성은 부품을 조합하는 방식으로 코드를 작성하기 때문에, 클래스 간의 의존성이 낮아지며, 유지보수가 용이해집니다.

또한, 각 클래스의 역할이 분리되어 있어서 코드의 가독성이 높아지는 장점이 있습니다.

하지만, 구성을 사용하면 코드의 복잡성이 높아질 수 있으며, 클래스 간의 상호작용이 많아져서 디버깅이 어려워질 수도 있습니다.

또한, 구성을 사용하면 객체를 생성하는 비용이 높아질 수 있습니다.

따라서, 상속과 구성을 사용할 때는 코드의 특성과 요구사항에 따라 선택해야 합니다.

일반적으로는 구성을 사용하는 것이 좀 더 안전하고 유연한 방법이라고 할 수 있지만, 간혹 상속을 사용하는 것이 더 적합한 경우도 있습니다.

주로 선택하는 예

상속은 일반적으로 다음과 같은 경우에 사용됩니다.

  1. 공통된 속성과 기능을 가진 클래스를 생성할 때
  • 여러 개의 클래스가 공통된 속성과 기능을 가지고 있을 때, 이를 하나의 부모 클래스에서 정의하고 각각의 자식 클래스에서 상속받아 사용합니다.
  1. 다형성을 활용해야 할 때
  • 부모 클래스에서 정의한 메소드를 하위 클래스에서 다시 정의하여 다형성을 구현할 수 있습니다.
  1. 새로운 기능을 추가할 때
  • 부모 클래스에서 정의한 메소드를 하위 클래스에서 오버라이딩하여 새로운 기능을 추가할 수 있습니다.

반면에 구성은 다음과 같은 경우에 사용됩니다.

  1. 클래스 간의 관계가 복잡한 경우
  • 클래스 간의 관계가 복잡하거나 계층 구조가 너무 깊은 경우, 구성을 사용하여 각각의 클래스를 독립적으로 구현하는 것이 좀 더 유지보수성이 높아질 수 있습니다.
  1. 런타임에 동적으로 객체를 생성해야 할 때
  • 구성을 사용하면 런타임에 객체를 동적으로 생성하거나 조합할 수 있기 때문에, 유연성이 높아지는 장점이 있습니다.
  1. 재사용 가능한 컴포넌트를 작성해야 할 때
  • 구성을 사용하면 재사용 가능한 컴포넌트를 작성하는 것이 더욱 용이해지기 때문에, 코드의 재사용성이 높아질 수 있습니다.

따라서, 코드의 특성과 요구사항에 따라 상속과 구성을 선택해야 합니다.

일반적으로는 상속은 계층 구조가 간단하고 공통된 속성과 기능이 많은 경우에 사용하는 것이 적합하며,

구성은 클래스 간의 관계가 복잡하거나 런타임에 동적으로 객체를 생성해야 하는 경우에 사용하는 것이 적합합니다.

0개의 댓글