Vanilla Javascript 파보기

본 블로그에서는 Vanilla Javascript의 this와 OOP를 깊이 파헤쳐, 문제 해결과 Javascript의 본질적인 이해를 돕고자 합니다.

Javascript 의 this


Javascript 에서 this 라는 주제는 항상 알듯 하면서도 완전히 정복할 수 없는 분야였다. NestJS 기반으로 실무에서 개발을 하다가, 간단하지만 이해할 수 없는 현상이 생겨서, 깊게 파보게 되었다. 문제를 해결하는 것 자체는 "그냥 항상 arrow function 을 사용하면 돼" 를 좌우명으로 삼고 개발하면 된다. 실제로 취미로 리액트를 공부했을 때에는, 이 이슈를 대수롭게 여기지 않고 arrow function 을 사용했었다. 하지만 이번에도 이렇게 넘기면, 영원히 찝찝함으로 남을 것 같아서 깊이 파보게 되었다.

문제가 되는 상황을 간단히 표현해 보면, 다음과 같은 상황으로 정리할 수 있다.

class A {

  constructor(private readonly b: B;) {
    this.b = new B();
  }

  log() {
    /*
     * 1. 
     * [Error] - Cannot read properties of undefined (reading 'name')
     */
    ["heejae", "dana"].map(this.b.log);

    /*
     * 2.
     * I am B  heejae
     * I am B  dana
     */
    ["heejae", "dana"].map(this.b.log, this.b);

    /*
     * 3.
     * I am B  heejae
     * I am B  dana
     */
    ["heejae", "dana"].map((msg) => this.b.log(msg));
  }
}
class B {
  private readonly name = "I am B";

  log(msg: string) {
    console.log(`${this.name}  ${msg}`);
  }
}

const a = new A();
a.log();


1.["heejae", "dana"].map(this.b.log);

처음에 이 문제를 마주쳤을 때, 이 코드가 왜 문제가 되는지 이해하지 못했었다. map 함수 내부에 arrow function 을 쓰는 것이 너무 당연하다고 느껴서, 문제의 원인이 무엇인지 파악하는 데에 더 오래 걸렸던 것 같다. 문제에 대한 해답은 MDN 의 문서를 통해서 알 수 있었다.

arr.map(callback(currentValue[, index[, array]])[, thisArg])

If a thisArg parameter is provided, it will be used as callback's this value. Otherwise, the value undefined will be used as its this value. The this value ultimately observable by callbackFn is determined according to the usual rules for determining the this seen by a function.

map 함수는 기본적으로 두 번째 인자를 받을 수 있는데, 두 번째 인자로 map 의 callback function 의 실행 Context 를 설정할 수 있다. 만약 두 번째 인자를 넘겨주지 않는 경우, undefined 가 함수의 실행 Context 로 설정된다.


2.["heejae", "dana"].map(this.b.log, this.b);

이 코드가 정상적으로 동작하는 이유는, 1에서 설명한 이유와 같다. map 함수의 두 번째 인자로 B 의 인스턴스를 넘겨주었기 때문에, 실행 컨택스트가 b 로 설정되어서 정상적으로 동작한다.


3.["heejae", "dana"].map((msg) => this.b.log(msg));

이 코드는 가장 친숙한 코드이다. arrow function 을 이용하면, this 연산자에서 자유로워질 수 있다. arrow function 은 자체적인 실행 Context 를 지닐 수 없다. arrow function 의 this 는 Lexical this이다. map 이 실행되면서 간접실행이 일어날때의 this 문맥을 그대로 계승한다. 결과적으로, arrow function 의 this 는 a.log() 의 실행 컨택스트이며, a이다. 결과적으로 a.b.log() 가 실행되는 것이기 때문에, 실행 Context 에서 문제가 발생하지 않는다.



Javascript 의 OOP


Javascript 개발자들은 자유롭게 class 문법을 사용한다. 이전 버전의 React와, Angular/Nest 와 같은 프레임워크들은 아예 class 기반의 문법을 강요하는 경우도 종종 있다. Ruby, Java 언어에 익숙했었던 나 또한, Javascript 에 class 문법이 존재하는 것에 대해 당연하게 생각했었다. 하지만 Javascript 의 class 문법은 ES6 (2015) 에 공식적으로 추가된 feature 이다. Javascript 의 Guru 인 Douglas Crockford 는 ES6 의 class 문법에 대해 "The worst part in ES6" 라고 평가하기도 했다. 그렇다면 Javascript 에서는 왜 객체지향 프로그래밍을 쉽게 해줄 class 를 늦게 지원하고, Douglas Crockford 는 왜 이에 대해서 부정적인 입장을 취했을까?

가장 본질적인 원인은 Javascript 가 대표적인 OOP 언어들과 구조적으로 아예 다른 방식으로 세상을 이해하기 때문이다. Java 와 같은 대표적인 객체지향 언어에서는, 흔히 class 를 붕어빵 틀에, instance 를 붕어빵에 비유한다. Javascript 의 class 문법도 사용법만 따지고 보면 이와 비슷할 수 있지만, 깊게 파고들면 이러한 비유 자체가 잘못된 것을 알 수 있다. 다음의 예시를 살펴보자.

function Person(name: string) {
  this.name = name;
}
const person = new Person("Heejae");

console.log(person.name); // Heejae
console.log(person.hobby); // undefined


name 은 constructor function 에서 property 로 설정해 주지만, hobby 값은 지정해 준 적이 없으므로 당연히 undefined 가 찍히게 된다. 그렇다면 다음과 같은 코드를 추가하면 어떨까 ?

function Person(name: string) {
  this.name = name;
}
const person = new Person("Heejae");
console.log(person.hobby); // undefined
Person.prototype.hobby = "lego";
console.log(person.hobby); // lego


person 객체는 이미 생성된 후인데, Person.prototype.hobby 를 변경함으로, hobby property 를 추가시켰다. 적절한 예시라고 볼 수는 없지만, 이는 마치 붕어빵 틀을 런타임에서 변경시키는 꼴 이라고 볼 수도 있다. Java에만 익숙한 개발자라면, 이러한 현상에도 크게 불편함을 느낄 수 있지만, Dynamic 하게 붕어빵 틀을 바꿀 수 있다고 생각한다면, 어느 정도 받아들일 수 있는 현상이기도 하다.

또한 다음과 같은 예는 어떻게 이해할 수 있을까 ?


function Person(name: string) {
  this.name = name;
}
const person = new Person("Heejae");
console.log(person.hobby); // undefined

person.__proto__.hobby = "netflix";
console.log(person.hobby); // netflix

const person2 = new Person("행인");
console.log(person2.hobby); // netflix


이 부분은 내가 처음으로 Javascript 를 깊게 공부하면서 가장 불편함을 느낀 부분이다. 위의 붕어빵 틀 의 사고방식에서 이해한다면, 위의 코드 어느 부분에도 붕어빵 틀 에는 손댄 부분이 없다. 위의 코드를 보면, 붕어빵 을 이리저리 손대다 보니, 붕어빵 틀 의 모양이 변화한 모습이다. 이게 무슨 말도안되는 언어인가 ?

이러한 현상을 제대로 이해하기 위해서는 Javascript 의 Prototype 에 대해 정확히 이해해야 한다. Javascript 는 본질적으로, 세상을 객체 지향적으로 해석하지 않는다. 모든 사물은 서로가 서로를 Prototype 으로 참조하고 있을 뿐이다. 이 때, 각각의 Prototype 은 가변적으로 변할 수 있으며, 하나의 독립된 instance 도 다른 instance 의 프로토타입이 될 수 있다.

위에서 생성한 Person 함수를 뜯어보면, Image 함수 안에 prototype, [[Prototype]] 이 내부 속성으로 들어있는 것을 볼 수 있다. prototype 속성은 Person 생성자를 통해 생성된 instance 가 참조하게 되는 객체이다. [[Prototype]] 은 역으로, 이 함수가 참조하는 객체 값인데, Person 함수의 부모는 Object 객체로부터 prototype 참조를 하고 있다. [[Prototype]] 값은 어플리케이션 개발자가 직접적으로 참조할 일은 거의 없지만, Chrome 의 엔진에서는 __proto__ 라는 속성으로 해당 값을 Reference 해주고 있다. NodeJS 또한 Chrome 의 V8 런타임 엔진을 사용하므로, NodeJS 에서도 객체의 [[Prototype]] 은 __proto__ 값으로 참조된다.

그렇다면 class 문법을 사용하지 않고, Vanilla Javascript 를 통해 객체지향 프로그래밍을 하려면 어떻게 해야 할까 ?


function Person(name: string) {
  this.name = name;
  this.walk = function () {
    console.log(`사람 ${name} 이(가) 걷고 있습니다.`);
  };
  this.eat = function () {
    console.log(`사람 ${name} 이(가) 먹고 있습니다.`);
  };
}

function Student(name: string, grade: number) {
  inherit.apply(this, [Person, name]);

  this.grade = grade;
  this.study = function () {
    console.log(`학생 ${this.name} 이(가) 공부중입니다.`);
  };
}

function HighSchoolStudent(name: string, grade: number, type: string) {
  inherit.apply(this, [Student, name, grade]);

  this.type = type;
  this.wearUniform = function () {
    console.log(
      `고등학교 ${this.grade} 학년 ${this.name} 이(가) 교복을 입고 있습니다.`
    );
  };
}

function inherit(Parent: FunctionConstructor, ...args: string[]) {
  // 부모 객체의 값을 복사해 줌.
  return Parent.apply(this, args);
}

const person = new Person("person");
person.walk();
person.eat();

const student = new Student("heejae", 3);
student.walk();
student.eat();
student.study();

const highSchoolStudnet = new HighSchoolStudent("dana", 3, "summer");
highSchoolStudnet.eat();
highSchoolStudnet.study();
highSchoolStudnet.wearUniform();


위에서 눈여겨 볼 점은 inherit 함수이다. 이 함수는 부모 생성자 함수를 parameter 로 받아서 부모의 값들을 복사해 주는 역할을 한다. 자식 생성자 함수에서 this 바인딩을 자식으로 설정한 후, 부모의 생성자 함수를 실행하므로, 자식 객체에 부모의 속성들이 그대로 복사되게 된다.



참고문서

목차

Javascript 의 this Javascript 의 OOP 참고문서

이것도 읽어보세요