타입스크립트 정리: 전통적인 OOP 패턴 피하기


원문: https://fettblog.eu/tidy-typescript-avoid-traditional-oop/

이 글은 타입스크립트 코드를 깔끔하게 작성하는 방법에 대해 다루는 세 번째 글이다. 이 시리즈에서 당신의 의견과 다른 내용이 있을 수 있지만 필자의 개인 의견이니 이해바란다.

이 글에서는 "객체 지향 프로그래밍의 패턴(Patterns of Object-Oriented Programming)"에 대해 살펴볼 것이다. 기존의 OOP는 대부분 클래스 기반 OOP를 의미하며, 많은 개발자들이 OOP를 사용할 때 이 점을 떠올릴 것이다. Java 또는 C#에 익숙한 경우 타입스크립트에서 이러한 패턴을 많이 사용할 것이다. 하지만 이는 좋지 않은 결과가 될 수 있다.

정적 클래스 피하기

Java에 익숙한 개발자들에게서 많이 볼 수 있는 한 가지 특징은 모든 로직을 클래스 안에 넣는 것이다. Java에서는 클래스를 통해서만 코드를 구성할 수 있으므로 다른 대안이 없다. 하지만 자바스크립트(타입스크립트)는 클래스 외에도 원하는 작업을 수행할 수 있는 많은 대안이 있다. 그 중 하나는 Java에서 많이 사용하는 정적 클래스 또는 정적 메소드가 있는 클래스이다.

// Environment.ts

export default class Environment {
  private static variableList: string[] = []
  static variables(): string[] { /* ... */ }
  static setVariable(key: string, value: any): void  { /* ... */ }
  static getValue(key: string): unknown  { /* ... */ }
}

// Usage in another file
import * as Environment from "./Environment";

console.log(Environment.variables());

이 타입스크립트 코드는 잘 실행되지만, 단순한 기능들을 너무 장황하게 구현하였다. 좀 더 간단하게 변경해보자.

// Environment.ts
const variableList: string = []

export function variables(): string[] { /* ... */ }
export function setVariable(key: string, value: any): void  { /* ... */ }
export function getValue(key: string): unknown  { /* ... */ }

// Usage in another file
import * as Environment from "./Environment";

console.log(Environment.variables());

사용자는 동일한 인터페이스로 Environment.ts모듈의 코드를 사용할 수 있다. 모듈 변수에 접근하기 위해 클래스를 선언하고 정적 프로퍼티에 접근하는 방법은 단순하게 모듈 스코프에 함수들을 선언하는 것으로 대체할 수 있다. 이 벙법은 타입스크립트 접근 제어자를 사용하지 않아도 모듈에서 내보낼 대상을 결정할 수 있다. 또한 아무런 작업도 수행하지 않는 Environment 인스턴스를 생성하지 않아도 된다.

그리고 더 간단하게 구현할 수 있다. 먼저 variables()의 클래스 버전을 보자.

export default class Environment {
  private static variableList: string[] = []
  static variables(): string[] { 
    return this.variableList;
   }
}

이번에는 단순한 모듈로 변경한 버전을 보자. 클래스 버전에 비해 훨씬 간결하게 구현할 수 있다.

const variableList: string = []

export function variables(): string[] {
  return variableList;
}

모듈로 변경한 코드에서는 this가 필요없기 때문에 고려할 사항이 줄어든다. 또한 번들러가 트리 쉐이킹(tree-shaking) 작업을 더 쉽게 수행할 수 있다.

// variables 함수와 variablesList만 번들 파일에 포함된다.
import { variables } from "./Environment";

console.log(variables());

따라서 자바스크립트(타입스크립트)에서는 정적 프로퍼티와 메서드가 있는 클래스보다 모듈을 사용하는 것이 효율적이다. 이러한 클래스는 별다른 장점없이 보일러 플레이트 코드만 많아질 뿐이다.

namespace 피하기

정적 클래스와 마찬가지로 불필요하게 namespace를 사용하는 경우가 있다. namespace는 ECMAScript의 모듈이 표준화되기 훨씬 전에 타입스크립트에서 도입한 기능이다. namespace를 사용하면 여러 파일에 분할된 것을 reference 마커를 사용하여 참조할 수 있다.

// users/models.ts
namespace Users {
  export interface Person {
    name: string;
    age: number;
  }
}

// users/controller.ts

/// <reference path="./models.ts" />
namespace Users {
  export function updateUser(p: Person) {
    // 나머지 코드
  }
}

그 당시, 타입스크립트는 번들링 기능도 가지고 있었다. 하지만 앞서 말했듯이, 이것은 ECMAScript가 모듈을 도입하기 전의 일이다. 이제 모듈을 통해 자바스크립트 생태계와 호환되는 코드를 구성할 수 있다.

그럼 어떤 경우에 namespace가 필요할까?

선언 확장

node_modules내에 있는 써드 파티 라이브러리의 타입 정의를 확장하려는 경우, namespace가 유용할 수 있다. 필자도 몇몇 경우에 그렇게 많이 사용한다. 예를 들어 글로벌 JSX namespace를 확장하고 img 요소가 alt 텍스트를 포함하도록 하려면 아래처럼 할 수 있다.

declare namespace JSX {
  interface IntrinsicElements {
    "img": HTMLAttributes & {
      alt: string,
      src: string,
      loading?: 'lazy' | 'eager' | 'auto';
    }
  }
}

또는 주변 모듈(ambient modules)에서 타입 정의를 작성할 때 유용하다. 하지만 이외에는 거의 사용할 일이 없다.

불필요한 namespace

namespace는 객체처럼 코드를 감싼다.

export namespace Users {
  type User = {
    name: string;
    age: number;
  }

  export function createUser(name: string, age: number): User {
    return { name, age }
  }
}

그리고 이 내용은 아래처럼 변경된다.

export var Users;
(function (Users) {
    function createUser(name, age) {
        return {
            name, age
        };
    }
    Users.createUser = createUser;
})(Users || (Users = {}));

이는 좋지 않은 결과이며 번들러가 트리 쉐이킹을 제대로 할 수 없다. 또한 접근을 위해 불필요한 뎁스가 늘어난다.

import * as Users from "./users";

Users.Users.createUser("Stefan", "39");

가급적이면 자바스크립트가 제공하는 기본 기능을 사용하는 것이 좋다. 선언 파일 외부에서 namespace를 사용하지 않으면 코드를 명확하고 깔끔하게 정리할 수 있다.

추상 클래스 피하기

추상 클래스는 기능을 미리 정의하지만 일부 기능의 실제 구현은 추상 클래스를 상속받은 클래스에 위임하는 계층적인 구조이다.

abstract class Lifeform {
  age: number;
  constructor(age: number) {
    this.age = age;
  }

  abstract move(): string;
}

class Human extends Lifeform {
  move() {
    return "Walking, mostly..."
  }
}

Lifeform의 모든 하위 클래스가 move를 구현한다. 이것은 모든 클래스 기반 프로그래밍 언어에 존재하는 기본적인 개념이다. 문제는 자바스크립트가 전통적으로 클래스 기반이 아니라는 것이다. 예를 들어 다음과 같은 추상 클래스는 유효한 자바스크립트 클래스를 생성하지만 타입스크립트에서는 인스턴스화할 수 없다.

abstract class Lifeform {
  age: number;
  constructor(age: number) {
    this.age = age;
  }
}

const lifeform = new Lifeform(20);
//               ^ 💥 Cannot create an instance of an abstract class.(2511)

따라서 타입 선언을 문서 형태의 정보로 제공하는 경우, 이를 자바스크립트로 구현한다면 원치 않는 상황이 발생할 수 있다. 예를 들어 함수 정의가 아래와 같은 경우를 보자.

declare function moveLifeform(lifeform: Lifeform);
  • moveLifeformLifeform인스턴스를 전달하는 것으로 이해할 것이다. moveLifeform 함수 내부에서 lifeform.move()메서드를 호출한다.
  • Lifeform은 추상 클래스이지만 유효한 자바스크립트 클래스이므로 인스턴스화할 수 있다.
  • move메서드가 Lifeform에 없으므로 어플리케이션의 실행이 중단된다.

이것은 잘못된 안전 의식 때문이다. 미리 정의한 기능을 구현하여 프로토타입 체인에 포함시키는 것이 안전하고 명확하게 코드를 실행시키는 방법이다.

interface Lifeform {
  move(): string
}

class BasicLifeForm {
  age: number;
  constructor(age: number) {
    this.age = age
  }
}

class Human extends BasicLifeForm implements Lifeform {
  move() {
    return "Walking"
  }
}

Lifeform인터페이스를 통해 관련된 정보를 볼 수 있다. 하지만 추상 클래스를 사용했을 때처럼 실수로 잘못된 클래스를 인스턴스화하는 상황은 거의 발생하지 않을 것이다.

마무리

타입스크립트는 초창기에는 자바스크립트 언어 자체가 구조적으로 많이 부족했고 이에 맞춘 메커니즘을 포함하였다. 하지만 이제 자바스크립트도 다른 성숙한 언어의 수준만큼 도달했기 때문에 코드를 구성할 수 있는 여러 수단을 제공한다. 따라서 모듈, 객체 및 함수 그리고 가끔은 클래스까지, 이런 네이티브 기능을 활용하는 것은 정말 좋은 생각이다.

이재성2021.01.07
Back to list