서론

자바스크립트 prototype 그리고 class

자바스크립트가 엄연한 객체지향프로그래밍(OOP) 언어라는 것은 알고 계실 겁니다.

다만 OOP하면 떠오르는 대표적인 언어인 Java와 같은 언어에서 다형성(Polymorphism)을 지원하기 위해 사용하는 class 기반 상속과 다른 모양 때문에, 자바스크립트에서 사용하는 prototype이라는 개념이 낯설게 느껴질 수 있습니다.

그런데 ES6(ES2015)부터 자바스크립트에도 class가 생겼습니다.

사실 내부적으로는 기존 prototype을 사용하는 것이지만, Java와 비슷한 다른 언어에서 건너온 사람들이 좀 더 쉽게 사용하라고 배려한 것 같습니다. (사실 기존 자바스크립트 개발자도 덕을 봤습니다.)

간단하게 예를 살펴보겠습니다.

간단한 예: class vs. prototype

같은 내용을 각각 class와 전통적인 prototype 방법으로 작성해보겠습니다.

타입스크립트 코드입니다.

  • class로 표현

      abstract class Animal {
        constructor(public readonly type: string) {}
    
        abstract public roar(): void
      }
    
      class Tiger extends Animal {
        constructor() {
          super('호랑이')
        }
    
        @boundMethod
        public roar() {
          console.log('어흥')
        }
      }
    
      ...
    
      const tiger = new Tiger()
    
  • prototype 방법으로 표현

      function Animal(type) {
        this.type = type
      }
    
      Animal.prototype.roar = function () {
        throw new Error('not implemented')
      }
    
      function Tiger() {
        Animal.call(this)
      }
    
      Tiger.prototype.roar = function () {
        console.log('어흥')
      }
    
      Tiger.prototype = Object.create(Animal.prototype)
      Tiger.prototype.constructor = Animal
        
      ...
        
      const tiger = new Tiger()
    

prototype 방법은 뭔가 낯설고 적을 게 많아서 헷갈립니다.

복잡한 문법은 개발자에게 사용 저항을 일으키죠. (심지어 저는 세미콜론 하나 더 적는 것도 매우 싫어한답니다.)

그동안 사용하기가 까다로웠지만 class 문법이 추가된 ES6 이후에는 활용될 가능성이 더 높은 것 같습니다.

클래스를 잘 활용해봅시다. (쓰라고 만들었잖아요.)

자바스크립트의 장점: 임의로 정의하고 자유로운 조작이 가능한 객체

Arbitrary Object

자바스크립트에서는 Object Literal, 즉 {}로 객체를 마음대로 생성하고 변형할 수 있고, 특히 JSON 1:1과 호환이 되는 장점이 있습니다. (JSON이 말 그대로 JavaScript Object Notation이니까요. 닭이 먼저냐 계랸이 먼저냐)

즉 Object => JSON(직렬화), JSON => Object(역직렬화)가 아주 쉽습니다.

물론 여기에는 양면성이 있습니다. 임의로 만들고 조작하는 것이 좋을 때도 있지만, 엄격하게 정의된 틀(스키마, 타입 정의 등)이 필요할 때도 있으니까요.

예를 들어 우리 무스마에서는 클라이언트-서버 간의 프로토콜 패킷을 클래스로 정의하고 있습니다. 클래스 선언은 별도의 서브 프로젝트로 분리하고 클라이언트와 서버에서 공유해서 사용합니다.

당연히 클라이언트와 서버에서 보낼 때와 받을 때, 서로 약속된 것(프로토콜)을 주고 받을 수 있도록 어떤 틀이 있어야겠지요?

클래스(프로토타입 상속과 혼용)의 장점

클래스를 통해 다형성(Polymorphism), 캡슐화, 코드의 재사용을 구현할 수 있습니다.

  • 다형성
    • 상하 관계, 추상화 구현
    • 예: Lion, Tiger => Animal
  • 캡슐화
    • 필요한 매서드를 내포하고 유도된 속성 등을 포함할 수 있음
    • 내부 구현은 은폐
  • 코드의 재사용
    • 같은 클래스(프로토타입)끼리는 인스턴스 매서드, 프로퍼티를 공유

잘 사용하려면 어떻게 해야할까요?

본론

JSON 직렬화 Before & After

다음과 같은 코드가 있다고 가정합시다.

타입스크립트 코드입니다.

export class Animal {
  constructor(public readonly type: string) {}
  
  @boundMethod
  public describe() {
    console.log(`${type}입니다.`)
  }
}

export class Tiger extends Animal {
  constructor(public readonly name: string) {
    super('호랑이')
  }

  @boundMethod
  public introduce() {
    console.log(`내 이름은 ${this.name}, ${this.type}(이)죠.`)
  }

  public get key(): string {
    return `${this.name}:${this.type}`
  }
}

...

const tiger = new Tiger('무등산')

위의 tiger를 JSON으로 변환하면 아래와 같습니다.

{
  "name": "무등산",
  "type": "호랑이"
}

그리고 이걸 다시 Object로 변환하면 아래와 같습니다.

{
  name: "무등산",
  type: "호랑이"
}

어떤 문제가 있는지 보이십니까?

한 번 JSON이 되었다가(JSON.stringify), 다시 Object가 되면(JSON.parse) 더는 describe(), introduce() 메서드라든지, key 속성에 접근할 수 없습니다. 즉 객체가 Tiger 클래스의 인스턴스라는 정보가 유실됩니다.

JSON으로 직렬화하면 프로토타입 정보는 유실됩니다.

Object.setPrototypeOf()

아마도 여러분은 특정 클래스의 인스턴스 객체가 JSON을 거쳐서 다시 객체로 역직렬화하더라도, 원래의 프로토타입 정보가 유지되기를 바랄 것입니다.

다행히도 ES6에는 이를 위한 방법이 있습니다.

바로 Object.setPrototypeOf() 입니다.

사용법은 이렇습니다.

const tiger = new Tiger('무등산')
const serialized = JSON.stringify(tiger)
const deserialized = JSON.parse(serialized)

// tiger와 같은 상태로 복원
const recovered = Object.setPrototypeOf(deserialized, Tiger.prototype)

이제 위의 recoveredTiger 클래스의 인스턴스가 됩니다.

> recovered.key
'무등산:호랑이'

> recovered.describe()
호랑이입니다.

> recovered.introduce()
내 이름은 무등산, 호랑이(이)죠.

네 이런 식으로 사용하시면 됩니다.

심화: 다층 중첩된 객체일 때

이런 경우는 어떻게 해야할까요?

class Tiger {
  public children: Tiger[]
}

만약 JSON에서 역직렬화를 한 뒤, Tiger 프로토타입을 매긴다고 해도 하위 속성까지 모두 prototype이 복원되는 것이 아닙니다.

따라서 위의 children의 요소 각각을 Tiger 인스턴스로 활용하려면 마찬가지로 각각 Object.setPrototypeOf()를 해야 합니다.

예를 들면, Tiger.promote() 같은 static 함수를 만드는 것입니다.

class Tiger {
  public static promote(o: Object): Tiger {
    const tiger = Object.setPrototypeOf(o, Tiger.prototype)
    tiger.children = tiger.children.map(Tiger.promote)
    return tiger
  }

  ...
}

이게 조금 많이 귀찮습니다.

그래서 라이브러리의 도움을 조금 받겠습니다.

typescript-json-serializer

typescript-json-serializer는 타입스크립트에서 JSON (역)직렬화 전후에 프로토타입이 복원되는 JSON 직렬화 라이브러리입니다.

사용법은 이렇습니다.

@Serializable()
export class Student {
  @JsonProperty()
  public readonly studentId: number

  @JsonProperty()
  public readonly name: string

  @JsonProperty()
  public readonly grade: number

  constructor(
    readonly studentId: number,
    readonly name: string,
    readonly grade: number
  ) {
    this.studentId = studentId
    this.name = name
    this.grade = grade
  }
}

...

const original: Student = new Student(12345, '코난', 2)
const serialized: Object = serialize(original) // JSON이 아닌 JS Object로 변환됩니다.
const deserialized: Student = deserialize(serialized, Student)

저는 이 라이브러리를 잘 쓰고 있습니다.

더 바라는 것이 있다면 좀 더 간단하게, 아래와 같이 쓰고 싶었는데요.

@Serializable()
export class Student {
  constructor(
    @JsonProperty()
    public readonly studentId: number,
    
    @JsonProperty()
    public readonly name: string,
    
    @JsonProperty()
    public readonly grade: number
  ) {}
}

그래서 라이브러리 메인테이너에게 좀 고쳐달라고 요청했습니다.

Support constructor parameter decorators #28

좀 걸릴 줄 알았는데, 이런! 이틀만에 고쳤네요. (그런데 아직 버그가 있어서 좀 더 기다려야 합니다.)

조만간에 더 간단하고 편리하게 사용할 수 있을 것 같습니다.

결론

클래스(와 프로토타입)는 유용하다. 그러나 잘 사용하려면 라이브러리의 도움을 받아야 한다.

자바스크립트 클래스의 유용함을 설명하는 책에서는 어떤 클래스의 인스턴스가 항상 그대로 존재한다는 가정 하에 설명을 하는 경우가 많습니다. 한마디로 말해서 JSON으로 직렬화 되고 그것을 다시 객체로 복원하는 시나리오가 배제되어 있습니다. 하지만 JSON 직렬화는 자바스크립트 애플리케이션에서 매우 빈번한 사례입니다.

역직렬화 후에는 Object.setPropertyOf()로 프로토타입을 다시 지정해줘야 해당 인스턴스를 특정 클래스(프로토타입)의 인스턴스로 사용할 수 있다는 것을 알았습니다.

그리고 복잡한 구조의 클래스를 쉽게 JSON 역직렬화 할 수 있도록 해주는 typescript-json-serializer 라이브러리를 소개했습니다.

앞으로 여러 사례에서 자바스크립트 class를 적절히 사용하기 위해 적절한 도움이 되기를 기대합니다.

감사합니다.

References