본문 바로가기
프로그래밍

[프로그래밍] 추상화와 다형성

by 개발 까마귀 2024. 2. 4.
반응형

추상화와 다형성

안녕하세요. 개발 까마귀입니다.

이번에는 추상화와 다형성에 대해 알려드리고자 합니다.

추상화, 다형성, 캡슐화, 상속은 객체지향 개념에서 빠질수없는 개념들인데요.

하지만 해당 개념을 왜 쓰는지 어떻게 쓰는지에 대해서 많이들 모르시고 헷갈리기도 합니다.

추상화

"추상스럽다", "설명이 너무 추상적이다" 등 많이들 사용하시죠? 미술시간에도 "추상화"관련해서 많이 배우셨을테고요.

예를들어 웹툰을 그릴 때 스토리 작가가 밑그림을 그리고 다음 그림 작가가 밑그림에 디테일을 더 해서 그림을 그립니다.

이 때 밑그림은 추상화 단계 이죠

이처럼 객체지향에서도 추상화는 크게 다르지 않습니다만, 객체지향에서의 추상화의 제일 큰 목적은 "중복 제거" 입니다.

핵심적인 요소들을 뽑아내어 코드의 중복을 줄이는거죠

새는 다 날아

abstract class Bird {
  abstract name: string;

  fly() {
    console.log("fly");
  }
}

class Pigeon extends Bird {
  name = "비둘기";

  constructor() {
    super();
  }
}

class Eagle extends Bird {
  name = "독수리";

  constructor() {
    super();
  }
}

위와 같이 `Bird` 라는 추상 클래스를 만들었고 이를 "상속" 하는 `Pigeon`, `Eagle` 이라는 클래스를 만들었습니다.

만약 추상화를 하지 않았다면 어떤 코드일까요?

class Pigeon {
  name = "비둘기";
  
  fly() {
   console.log("fly")
  }
}

class Eagle {
  name = "독수리";
  
  fly() {
   console.log("fly")
  }
}

위와같이 핵심적인 요소인 `fly` 메서드가 계속 중복되서 발생하게됩니다.

이처럼 추상화를 하지 않을 경우에는 중복되는 코드가 발생하게되고 "묶음" 효과가 없어집니다.

추상화 코드에서 보이면 `Bird` 라는 추상 클래스로 "Pigeon", "Eagle" Class를 명시적으로 묶었지만

현재는 논리적으로만 묶여져있는 상태입니다.

이처럼 추상화를 하면 코드 중복 제거 및 묶음 효과가 있게됩니다.

그리고 꼭 `abstract` 수식어를 사용할 필요는 없습니다.

`abstract` 수식어는 추상화를 더 하기 쉽게 해주는 도구라고 생각하시면 됩니다.

다형성

다형성이란 "다양한 형태의 성질" 이라고 생각하시면 될겁니다.
예를들어 "아빠"라는 사람을 보면

아빠는 "회사원", "어느 가정의 부모", "산악회 회장" 등 다양한 형태를 가지고있습니다.

즉 다형성이란 "하나의 객체가 여러가지의 형태를 가지는 것"을 말하며

이를 반대로 하나의 객체가 하나의 형태를 가지것은 단형성이라고 합니다.

우리는 이미 추상화 단계에서 다형성을 하기 위한 충분한 설계를 했습니다.

저희는 새 이름을 출력하고 나는 행동을 하는 기능을 만들려고 합니다.

그렇다면 아래와같이 코드를 짜면 되겠죠?

const pigeon = new Pigeon();
pigeon.name;
pigeon.fly();

const eagle = new Eagle();
eagle.name;
eagle.fly();

네 굉장히 비효율적입니다.

만약에 부엉이가 추가된다면? 3줄 정도되는 코드가 또 추가됩니다.

또는 fly 메서드명이 바뀌었다거나 등 하면? 네 정말 끔찍합니다.

그렇다면 다형성의 힘을 빌려 아래와같이 리팩토링이 가능합니다.

// method - 1
Class BirdFlyHelper {
 helper(bird: Bird) {
  console.log(bird.name);
  bird.fly();
 }
}
new BirdFlyHelper(new Pigeon()).helper();
new BirdFlyHelper(new Eagle()).helper();


// method - 2
[new Pigeon(), new Eagle()].forEach((bird: Bird) => {
 console.log(bird.name);
 bird.fly();
});

네 위와같이 코드 중복을 줄일수있습니다.

위와같이 코드를 짤수있는 이유는 `Bird` 라는 상위 클래스로 인해서 타입을 "묶음" 했기 때문입니다.

`Bird` 클래스에서는 `fly` 메서드와 `name` 이라는 프로퍼티가 있다는것을 시스템이 알기 때문이고

그 상위 클래스를 `Pigeon`, `Eagle` 클래스가 상속되었기에 무조건 메서드와 프로퍼티가 존재한다는것을 아는것이죠

오버라이딩 & 오버로딩

다양한 형태의 성질은 성질마다 해동이 다를수있습니다.

오버라이딩

이제는 "Chicken" Class를 추가하고 울음소리를 낼수있는 "cry" 메서드를 추가해서 추상화하도록 하겠습니다.

abstract class Bird {
  abstract name: string;

  fly() {
    console.log("fly");
  }
  
  cry() {
   console.log("삐요스");
  }
}

class Pigeon extends Bird {
  name = "비둘기";

  constructor() {
    super();
  }
}

class Eagle extends Bird {
  name = "독수리";

  constructor() {
    super();
  }
}

class Chicken extends Bird {
 name = "닭";
 
 constructor() {
  super();
 }
}

이렇게 `cry` 라는 메서드를 추가했습니다. 

`cry` 메서드는 "삐요스"라는 울음 소리를 냅니다.

하지만 "삐요스" 울음소리는 독수리만 내는 소리지 "닭" 과 "비둘기"는 다른 소리는 내죠

그러므로 닭과 비둘기는 다른 형태의 소리를 내기위해 재구성 해줘야합니다.

abstract class Bird {
  abstract name: string;

  fly() {
    console.log("fly");
  }
  
  cry() {
   console.log("삐요스");
  }
}

class Pigeon extends Bird {
  name = "비둘기";

  constructor() {
    super();
  }
  
  cry() {
   console.log("구구구구구구구구구구구구구구구구구구구구구");
  }
}

class Eagle extends Bird {
  name = "독수리";

  constructor() {
    super();
  }
}

class Chicken extends Bird {
 name = "닭";
 
 constructor() {
  super();
 }
 
 cry() {
  console.log("꼬끼오");
 }
 
 fly() {
  console.log("못남");
 }
}

위와같이 비둘기와 닭은 다른 형태이기에

그 형태에 맞는 소리를 내기 위해 상위 클래스에 있는 `cry` 메서드를 자기 형태에 맞게 재구성했으며 닭은 날지 못하기에 `fly` 메서드 또한 재구성했습니다.

이를 "오버라이딩" 이라고 부릅니다.

이렇게 "오버라이딩"을 통해서 각 형태의 맞는 행동을 재구성 할수있습니다.

현재 예시에서는 추상클래스로 했지만 interface로 해도 오버라이딩이 가능합니다.

오버로딩

이제 각 새마다 울음소리를 내고 나는 행동을 하고싶어요.

하지만 닭은 못날죠? 그러면 위에서 다형성했을 때 반복문을 돌리거나 `Helper` 클래스로 이용해서 구현이 가능하겠지만

코드가 좀 복잡해지겠네요. 이를 해결 하기 위해서 "오버로딩"을 할용하도록 하죠

class BirdAction {
    void action(Eagle eagle) {
     eagle.cry();
     eagle.fly();
    }

    void action(Pigeon pigeon) {
     pigeon.cry();
     pigeon.fly();
    }

    void action(Chicken chicken) {
     chicken.cry();
    }
}

new BirdAction.action(new Eagle());
new BirdAction.action(new Pigeon());
new BirdAction.action(new Chicken());

*위 코드는 Typescript 코드가 아닙니다.*

이렇게 `action` 이라는 같은 메서드명으로 각기 다른 행동을 할수있게 해주는것을 "오버로딩" 이라고 합니다.

이렇게하면 클라이언트 입장에서는 `Chicken` 행동 클래스에 맞는 행동을 하기 위해 다른 메서드를 찾을 필요없고 오직 `action`이라는 하나의 메서드를 통해서 사용이 가능합니다.

하지만 오버로딩은 그리 좋은 방법이 아닙니다.

"확장에는 열려있고 수정에는 닫혀있어라" 하는 SOLID 원칙에 위배되니깐요.

만약에 "펭귄" Class 가 추가된다면 수정이 일어날테고 갑자기 닭이 날수있다면 또한 수정이 일어납니다.

그렇게되면 다른 "독수리", "비둘기"에 피해가 갈수있고 계속해서 새가 추가된다면 `BirdAction` Class는 계속 덩치가 커져서 나중에 손대기 무서운 모듈이 되겠지요.

위와같은 케이스 일 때는 아래와같이 바꾸면 좋습니다.

interface BirdActionable {
  action(bird: Bird): void;
}

class ChickenAction implements BirdActionable {
  action(chicken: Chicken): void {
    chicken.cry();
  }
}

class PigeonAction implements BirdActionable {
  action(pigeon: Pigeon): void {
    pigeon.cry();
  }
}

class EagleAction implements BirdActionable {
  action(eagle: Eagle): void {
    eagle.cry();
  }
}

new ChickenAction().action(new Chicken());
new PigeonAction().action(new Pigeon());
new EagleAction().action(new Eagle());

`BirdActionable` 이라는 interface를 선언하고 각 새에 행동을 구현하는 클래스는 해당 interface를 구현하도록 합니다.

이렇게되면 닭이 갑자기 날수있다고 하더라도 `ChickenAction` Class만 수정하면되고 나중에 펭귄이 추가된다고 해도 다른 새들에 영향이 안가고 기능을 추가할수있습니다.

하지만 "오버로딩"이 클라이언트 입장에서는 나쁜게 아닙니다.

똑같은 메서드명으로 들어오는 아규먼트에 다르게 내부적으로 핸들링해주면되는거니 클라이언트 입장에서는 더욱더 편리하고 간편하죠

이거에 대한 대중적인 예가 Java에 `println` 메서드가 아닌가 싶네요.

println에 오버로딩

이렇게 약간의 트레이드 오프가 따를수밖에 없습니다.

정리

추상화와 다형성을 잘 이해하시면 실무에서 코드 중복을 줄일수있고 더 안전한 팩토리패턴을 활용할수있습니다.

예를들어 Repository 같은 경우에는 CRUD가 기본적으로 들어가기에 `find`, `findOne`, `update`, 'creat`, 'delete` 로 기본적인 메서드명을 추상화하고 사용하고 거기에 다른 내부 구현 방식이 있으면 오버라이딩하는식으로 하면 되니깐요.

관련해서 더 좋은 지식을 정리해서 뵙도록 하겠습니다.

 

감사합니다.

반응형

댓글