type이나 interface 대신 class를 사용하는 이유는 무엇인가?

쑹춘·2023년 4월 6일
0

객체의 런타임 체크가 가능하기 때문이다.


이를테면

/* class의 세계 */

class Person {
  fName: string;
  lName: string;
  
  constructor({ fName, lName }: Person) {
    this.fName = fName;
    this.lName = lName;
  }
}

const me = new Person({
  fName: 'sungchun',
  lName: 'park',
});

이 있다고 한다면, 외부로부터(API로부터, 혹은 메소드 호출로부터) 주입된 변수가 Person 인스턴스인지 판별할 때 방식은 아래와 같이 짧다.

/* class의 세계 */

const isPerson = me instanceof Person;

if (isPerson) {
  // ...
}

if 구문으로 type guard를 설치하면 이제 이 다음부터는 me가 Person이 된다. (*tsc가 그렇게 알도록 하는 게 중요하다.)

만약 Person이 type이라 한다면, type, interface 같은 것들은 모두 Javascript 런타임에 소거되기 때문에 위와 같이 쓸 수 없다.

/* type의 세계 */

type Person = {
  fName: string,
  lName: string,
};

const me = {
  fName: 'sungchun',
  lName: 'park',
};

1번 안, 간단한 방법.

/* type의 세계 */

const isPerson = Boolean(
  typeof me === 'object' &&
  me && // null도 object이기 때문이다.
  'fName' in me &&
  'lName' in me &&
  typeof me.fName === 'string' &&
  typeof me.fName === 'string'
);

써놓고 보니 전혀 간단하지도 않고 아무도 이렇게 살기는 싫을 것이다. 무엇보다 isPerson을 평가하고 난 다음에도 me는 여전히 unknown. 그렇다면,

2번 안으로 가보자. type predicate를 사용하는 방식이다.

/* 아직도 type의 세계 */

const isPerson = (target: unknown): target is Person => Boolean(
  typeof person === 'object' &&
  person &&
  'fName' in me &&
  'lName' in me &&
  typeof me.fName === 'string' &&
  typeof me.fName === 'string'
);

(마음에 들지 않는다는 것을 안다.)

주로 다루는 모델들에 대한 class 선언이 되지 않은 어플리케이션 코드를 인수하여 유지보수를 할 때 위와 같은 공략이 필요하다. 함수 바디 내부는 여전히 지저분할 수 있지만, 적어도 판정식을 캡슐화할 수 있다. 적어도 테스트는 할 수 있다. 그러나,

리팩터링을 포함한 이슈 처리와, 리팩터링이 없는 이슈 처리를 여럿 지나온 지난 시간들을 회고해보건데, class 작성부터 하고 감이 가장 빠르다고 생각한다. 보통 가장 빠른 길이 안정적일 수는 없는 법인데, 이 길은 안정적이기까지 하다.

class를 선언하면 어플리케이션 개발 플로우 중 "modeling"이라는 작업 개념의 관점에서 데이터를 다루게 된다. 메소드가 있고, getter/setter가 있으며 접근 한정자를(Javascript 런타임에도!) 사용할 수 있기 때문이기도 하다. 내가 다루는 어플리케이션을 얼만큼 자르고 감추고 격리해야 할 지에 대해서도 생각할 겨를이 생긴다. 이제 프론트엔드 분야라기엔 관심 영역이 더 넓어지므로 여기까지만 쓰자.

런타임에도 유지되는 불변 프로퍼티의 선언도 가능하다. 제일 매력적이라고 생각하는 부분인데,

class Person {
  #fName: string;
  #lName: string;
  
  constructor({ fName, lName }: Person) {
    this.#fName = fName;
    this.#lName = lName;
  }

  get fName() {
    return this.#fName;
  }

  get lName() {
    return this.#lName;
  }
}

계산된 값으로서의 getter를 작성할 수도 있는 건, 사실 리터럴 객체도 완전 가능하기 때문에 class만의 이점은 아니다.

  // ...

  get name() {
    return [this.#fName, this.#lName].join(' ');
  }

  // ...

class를 선언하는 일은 아무것도 아니다. 일단 써놓고 보자, 나중에 다시 합치거나 없애도 늦지 않다. 그런 경우가 잘 없었다.

단점은 무엇일까? 있을까?

기존 코드, 그러니까 데이터를 fetch하는 영역 및 메소드 호출부 같은 데에서 이제 이 class 인스턴싱 구문을 모두 추가해주어야 한다는 것이다.

이 단점 때문에 나는 많은 이슈 처리를, 그냥 user-defined type guard 함수를 만들어서 대응했던 것 같다. 후회를 하는 지점이다.

profile
성천

0개의 댓글