본문 바로가기
Develop/Others

[typescript] Composition vs Inheritance

by 3-stack 2022. 2. 6.

Typescript

Enum, Tuple, Composition과 Inheritance을 학습합니다.

 

축구경기 데이터가 담겨있는 csv 파일에서 데이터를 추출해서

맨유의 승리경기 횟수를 출력하는 프로그램을 만들어봅니다.

간단하게 만든 후 리팩토링 하는 과정을 통해 Enum, Tuple, Generic, Composition과 Inheritance 를 알아봅니다.

 

- csv 파일은 아래와 같은 형식으로 저장되어 있다.

10/08/2018,Man United,Leicester,2,1,H,A Marriner
11/08/2018,Bournemouth,Cardiff,2,0,H,K Friend
11/08/2018,Fulham,Crystal Palace,0,2,A,M Dean
-----
[0] 12/08/2018 - 경기일자
[1] Arsenal - 홈팀
[2] Man City - 어웨이팀
[3] 0 - 홈득점
[4] 2 - 어웨이득점
[5] A - 매치결과
[6] M Oliver - MVP

 

# csv 파일을 읽어서 맨유의 승리경기 횟수를 출력해봅니다.

index.ts

import fs from "fs";

const data: string[][] = fs
  .readFileSync("football.csv", {
    encoding: "utf-8",
  })
  .split("\n")
  .map((row) => row.split(","));

const ManU = "Man United";
let manUnitedWins = 0;
data.forEach((match) => {
  const home = match[1];
  const away = match[2];
  const matchResult = match[5];
  if (
    (home === ManU && matchResult === "H") ||
    (away === ManU && matchResult === "A")
  ) {
    manUnitedWins++;
  }
});

console.log({ manUnitedWins });

 

# 리팩토링 항목체크

위 코드를 통해 원하는 결과를 잘 수행할 수 있지만 아래와 같은 개선항목들이 보입니다.

  1. 문자열을 하드코딩 하고, magic string comparison 하고 있습니다. 이는 타이핑 실수로 인한 오류와 유지보수에 취약합니다. "H", "A"가 무엇일까요? Home 팀의 승리이니자, Away 팀의 승리인지를 나타내는 데이터 값입니다. 프로그램을 오랜만에 보거나 처음보거나 다른 개발자와 협업을 위해 설명이 필요해보입니다. 또한 속성을 나타내는 문자열이 변경될 경우 수정이 어렵습니다. 
  2. csv 파일에서 데이터를 출력하는 로직을 재사용성이 떨어집니다. 추출한 데이터를 다른 파일에서 후처리하고 싶은 경우 매번 동일한 로직이 반복될 수 있습니다.
  3. 추출한 데이터의 타입이 모두 string 으로 부적절합니다. Date, number와 같은 적합한 타입 정의가 필요해보입니다. 데이터는 [경기일자, 홈팀명, 어웨이팀명, 홈득점, 어웨이팀득점, 매치결과, MVP] 입니다. 적합한 타입을 갖도록 개선이 필요합니다.
  4. 다양한 포맷의 csv 파일을 지원하지 못 합니다. 현재 데이터는 두번째 값이 홈팀 이름을 표시하지 않고 날씨를 나타내는 값으로 변경되거나 강의정보 csv 파일과 같이 다른 포맷 사용을 지원하지 못 합니다.

 

1. 하드코딩 문자열 비교 제거 

Enum을 사용해서 수정해봅니다. Enum은 아래와 같은 특징을 갖습니다.

  • 해당 값들이 모두 밀접하게 관련된 값이라는 것을 다른 개발자들과 공유하기 위해 사용합니다.
  • 밀접하게 관련된 값들을 하나의 Enum으로 관리해주세요.
  • 객체와 거의 동일한 구문 규칙을 따릅니다.
  • TS에서 JS로 변환시 동일한 키와 값으로 객체를 생성합니다.

matchResult.ts

export enum MatchResult {
  HomeWin = "H",
  AwayWin = "A",
  Draw = "D",
}

index.ts

import { MatchResult } from "./MatchResult";

...

data.forEach((match) => {
  ...
  if (
    (home === ManU && matchResult === MatchResult.HomeWin) ||
    (away === ManU && matchResult === MatchResult.AwayWin)
  ) {
    manUnitedWins++;
  }
});

 

2. csv 파일을 데이터화 하는 로직 재사용성 증대 - 클래스화

CsvFileReader라는 클래스를 생성하고 csv 파일을 읽는 로직을 구현합니다.

CsvFileReader.ts

import fs from "fs";

export class CsvFileReader {
  data: string[][] = [];

  constructor(public filename: string) {}

  read(): void {
    this.data = fs
      .readFileSync("football.csv", {
        encoding: "utf-8",
      })
      .split("\n")
      .map((row: string): string[] => row.split(","));
  }
}

index.ts

...
import { CsvFileReader } from "./CsvFileReader";

const reader = new CsvFileReader("football.csv");
reader.read();

...

reader.data.forEach((match) => {
  ...
}

 

3. 적합한 데이터 타입 정의

Tuple : 리스트와 동일하게 여러 객체를 모아서 담는다. 숫자, 문자, 객체, 배열, 튜플 안의 튜플 전부 가능하다. 하지만 튜플 내의 값은 수정이 불가하다. 추가도, 삭제도 안 된다.

MatchData.ts

import { MatchResult } from "./MatchResult";

export type MatchData = [
  Date,
  string,
  string,
  number,
  number,
  MatchResult,
  string
];

utils.ts

/**
 * @param dateString '28/10/2018'
 * @return Date
 */
export const dateStringToDate = (dateString: string): Date => {
  const dateParts = dateString.split("/").map((datePart) => parseInt(datePart));
  return new Date(dateParts[2], dateParts[1] - 1, dateParts[0]);
};

CsvFileReader.ts

import fs from "fs";
import { MatchResult } from "./MatchResult";
import { MatchData } from "./MatchData";
import { dateStringToDate } from "./utils";

export class CsvFileReader {
  data: MatchData[] = [];

  constructor(public filename: string) {}

  read(): void {
    this.data = fs
      .readFileSync("football.csv", {
        encoding: "utf-8",
      })
      .split("\n")
      .map((row: string) => row.split(","))
      .map(this.load);
  }
  
  load(row: string[]): MatchData {
    return [
      dateStringToDate(row[0]),
      row[1],
      row[2],
      parseInt(row[3], 10),
      parseInt(row[4], 10),
      row[5] as MatchResult,
      row[6],
    ];
  }
}

 

5. CsvFileReader의 확장성 증대 - Inheritance / Composition

다양한 포맷의 csv 파일을 지원할 수 있게 개선이 필요합니다.

CsvFileReader는 오직 csv 파일에서 데이터를 읽어오는 것에 집중하고, MatchReader라는 클래스를 추가해서 경기 결과와 관련된 기능는 해당 클래스에서 처리할 수 있게 분리합니다.

다른 포맷의 csv 파일을 처리하도록 확장하고 싶은 경우, 기존 코드 수정없이 클래스만 추가해주면 됩니다.

분리된 클래스를 같이 사용할 때 크게 상속(Inheritance)과 구성(Composition) 두 가지 방법이 있습니다.

Inheritance : "B is A" 관계를 갖게 됩니다. B가 A의 타입일 때 사용합니다.

Composition: "B has A" 관계를 갖게 됩니다. B가 A의 기능을 사용할 때 사용합니다.

 

5-1. Inheritance 

  • 부모클래스(CsvFileRedaer)를 추상클래스(Abstract Class)화 해서 자식클래스(MatchReader)에서 load 메소드를 구현할 수 있게 처리할 계획입니다.
  • CsvFileReader의 data 타입은 자식클래스에 따라 데이터 타입이 정해질 수 있도록 Generic을 사용합니다.
  • 상속은 "is a" 관계입니다. 경기 데이터를 읽는 MatchReader는 CsvFileReader 일까요?
  • MatchReader가 API를 통해 데이터를 읽는 경우가 있다면 두 클래스의 관계는 "is a"가 아니게 됩니다.
  • 이 경우, MatchCsvFileReader, MatchApiReader 두 클래스가 만들어지게 되며 MatchReader의 공통 속성들의 중복처리가 별도로 필요해집니다.

CsvFileReader.ts

import fs from "fs";

export abstract class CsvFileReader<T> {
  data: T[] = [];

  constructor(public filename: string) {}

  abstract load(row: string[]): T;

  read(): void {
    this.data = fs
      .readFileSync("football.csv", {
        encoding: "utf-8",
      })
      .split("\n")
      .map((row: string) => row.split(","))
      .map(this.load);
  }
}

matchReader.ts

import { CsvFileReader } from "./CsvFileReader";
import { MatchResult } from "./MatchResult";
import { MatchData } from "./MatchData";
import { dateStringToDate } from "./utils";

export class MatchReader extends CsvFileReader<MatchData> {
  load(row: string[]): MatchData {
    return [
      dateStringToDate(row[0]),
      row[1],
      row[2],
      parseInt(row[3], 10),
      parseInt(row[4], 10),
      row[5] as MatchResult,
      row[6],
    ];
  }
}

index.ts

import { MatchReader } from "./MatchReader";
import { MatchResult } from "./MatchResult";

const reader = new MatchReader("football.csv");
reader.read();

const ManU = "Man United";
let manUnitedWins = 0;

reader.data.forEach((match) => {
  const home = match[1];
  const away = match[2];
  const matchResult = match[5];

  if (
    (home === ManU && matchResult === MatchResult.HomeWin) ||
    (away === ManU && matchResult === MatchResult.AwayWin)
  ) {
    manUnitedWins++;
  }
});

console.log({ manUnitedWins });

 

5-2. Composition

Composition 한다는 것은 MatchReader가 CsvFileReader의 read 기능을 가져다 쓸 수 있게 한다는 의미.

CsvFileReader는 Match와 관련된 기능을 제거하고, 자신의 역할인 read 메서드만 남긴다. data 타입도 string[][]

CsvFileReader.ts

import fs from "fs";

export class CsvFileReader {
  data: string[][] = [];

  constructor(public filename: string) {}

  read(): void {
    this.data = fs
      .readFileSync(this.filename, {
        encoding: "utf-8",
      })
      .split("\n")
      .map((row: string) => row.split(","));
  }
}

MatchReader.ts

import { MatchResult } from "./MatchResult";
import { MatchData } from "./MatchData";
import { dateStringToDate } from "./utils";

interface DataReader {
  data: string[][];
  read(): void;
}

export class MatchReader {
  matches: MatchData[] = [];

  constructor(public reader: DataReader) {}
 
  load(): void {
    this.reader.read();
    this.matches = this.reader.data.map((row: string[]): MatchData => {
      return [
        dateStringToDate(row[0]),
        row[1],
        row[2],
        parseInt(row[3], 10),
        parseInt(row[4], 10),
        row[5] as MatchResult,
        row[6],
      ];
    });
  }
}

 

 

 

https://realpython.com/inheritance-composition-python/

댓글