[JavaScript] 생성자 함수(constructor function)을 통해 알아보는 closure, prototype, this

·

5 min read

function Person(name, age, id) {
    var privateId = id;

    this.name = name;
    this.age = age;
    this.getPrivateId = function() {
        return privateId;
    }
}

var gwon = new Person("gwon", 100, 1234);
console.log(gwon.name); // "gwon"
console.log(gwon.getPrivateId()); // 1234

와 같은 코드가 있다. 이는 class 문법이 없던 시절의 JS(ES6 이전, 즉 ES5 및 지금의 Common JS)에서 그와 동일(유사)하게 동작하는 Constructor Function 이다. 여기서 깨달은 것이 몇 가지 있어 정리하고자 한다.

new keyword

constructor function 앞에 new 키워드를 붙여야만 의도한대로 instance 생성이 가능하다. new 키워드 없이 constructor function을 호출하면 어떻게 되는지를 좀 더 단순한 예시를 통해 살펴보자.

function Person(name) {
    this.name = name;
}
var gwon = Person("gwon");
console.log(gwon); // undefined
console.log(name); // "gwon"

gwonundefined 인 것은 쉽게 이해가 가능하다. Person() 의 return 값이 존재하지 않기 때문이다. 그런데 name 이라는 global 변수에 어째서 "gwon" 이 assign된 것인가...?!

그렇다. Person() 호출 시 해당 실행 컨텍스트 상에서의 this 는 전역 객체 (Node.js 기준 global)를 가리키게 되는 것이다. 위 코드의 마지막 줄에 console.log(global) 를 추가해 확인해보면, 해당 런타임에 관한 여러 정보와 더불어 name: 'gwon' 이 추가되었음을 확인할 수 있다.

자, 그렇다면 new 키워드가 무엇을 하는지 조금 감이 오는가?

  1. 새로운 빈 Object를 생성하고

  2. 이 Object가 해당 함수 호출 컨텍스트 상에서의 this 가 되고

  3. 최종적으로 이 Object를 return하게 한다.

그리하여 Person() 내에서 this.name = name 을 통해 {name: "gwon"} 이라는 object가 return 될 수 있는 것이다.

"use strict" directive를 맨 앞에 붙여주면, Person()new 키워드 없이 호출하는 과정에서 에러가 뜬다. strict mode 에서는 함수 호출 시 thisglobal 이 아닌 undefined 로 지정하기 때문이다.

private field와 closure

처음에 들었던 예시를 다시 보자.

function Person(name, age, id) {
    var privateId = id;

    this.name = name;
    this.age = age;
    this.getPrivateId = function() {
        return privateId;
    }
}
var gwon = new Person("gwon", 100, 1234);
console.log(gwon.name); // "gwon"
console.log(gwon.getPrivateId()); // 1234

생성자 함수의 지역 변수인 privateId 를 살펴보자. 이름에서 알 수 있듯 이는 private field이다.

만약 다른 field들과 마찬가지로 (this.id = id 와 같이) this 의 property로 지정해주었다면 이는 전혀 private 하지 못하게 된다. 자유롭게 접근 및 수정이 가능해지기 때문이다.

이럴 땐,

  1. 위의 코드와 같이 var privateId = id 를 통해 지역 변수로 선언하면 자유롭게 접근이 불가능해진다.

  2. 이후 필요에 따라 getter, setter 등을 this 에 추가해주면 되는 것이다.

하지만 "어떻게 호출이 끝난 함수의 지역 변수가 접근이 가능한가?" 하는 의문이 들 수 있다. 여기서 등장하는 개념이 바로 closure 라는 것이다.

closure의 정의를 ChatGPT(GPT-3.5) 한테 물어보면 다음과 같이 대답해준다:

In JavaScript, a closure is a combination of a function bundled together with references to its surrounding state (the lexical environment) where it was declared. This combination allows the function to retain access to variables, parameters, and functions that were present at its creation even if they are no longer in scope.

그렇다. 위의 예시에서 Person() 의 호출이 끝난 이후에도 getPrivateId() 가 호출될 때 참조하는 변수인 privateId 와 같은 것들을 closure라는 단위로 통째로 묶어 저장한다는 것이다.

이 때문에 gwon.privateId 와 같이 직접적으로 접근은 불가능하면서도 getter 함수인 getPrivateId() 를 통해 접근은 가능한 것이다!

prototype

처음에 들었던 예시 코드에 introduce() 라는 method 하나를 추가해보자.

function Person(name, age, id) {
    var privateId = id;

    this.name = name;
    this.age = age;
    this.getPrivateId = function() {
        return privateId;
    }
    this.introduce = function() {
        console.log("hello, I'm " + this.name);
    }
}

var gwon = new Person("gwon", 100, 1234);
console.log(gwon.name); // "gwon"
gwon.introduce(); // "hello, I'm gwon"

예상한대로 작동은 잘 하나 아쉬운 점이 하나 있다. 그것은 바로 Person instance가 생성될 때마다 매번 introduce 라는 함수 또한 새롭게 생성된다는 것이다. 이는 모든 Person instance가 공유할 수 있는 함수로, 이를 다음과 같이 변경하여 메모리를 아낄 수 있다.

function Person(name, age, id) {
    var privateId = id;

    this.name = name;
    this.age = age;
    this.getPrivateId = function() {
        return privateId;
    }
}
Person.prototype.introduce = function() { 
    console.log("hello, I'm " + this.name);
};

var gwon = new Person("gwon", 100, 1234);
console.log(gwon.name); // "gwon"
gwon.introduce(); // "hello, I'm gwon"

이렇게 하면 Person instance를 생성할 때마다 introduce 를 새롭게 생성하지 않아 메모리도 아끼면서 기존과 같이 아주 잘 작동하는 것을 볼 수 있다.

여기서 한 가지 추가로 알 수 있는 점은, getPrivateId 는 prototype에 할당을 할 수 없다는 것이다. 이는 privateId 가 Person instance 호출 시 closure에 포함되어 있고, 이는 prototype에 할당 된 함수에서는 접근이 불가능하기 때문이다.

this, arrow function

사실 내가 위의 코드 예시들을 작성하며 모든 함수들을 arrow function으로 작성했었는데, 마지막에 전부 일반적인 function으로 변경하였다. 이유는:

  1. class syntax가 나오기 전인 ES5를 기준으로 설명을 하고 싶은데, arrow function 또한 ES6에서 추가된 것으로 이를 사용하는 것이 옳지 않다고 생각했다.

  2. (사실 이게 핵심이다) prototype의 function을 arrow function으로 선언하면, 문제가 발생한다!

문제 상황을 살펴보자:

function Person(name, age, id) {
    var privateId = id;

    this.name = name;
    this.age = age;
    this.getPrivateId = () => privateId;
}
Person.prototype.introduce = () => { 
    console.log("hello, I'm " + this.name);
};

var gwon = new Person("gwon", 100, 1234);
console.log(gwon.name); // "gwon"
gwon.introduce(); // "hello, I'm undefined" ...!! 어째서 undefined가?

arrow function과 일반 function과의 차이가 바로 this에서 비롯된다는 것을 얼핏 들었었는데, 이 코드를 통해 좀 더 정확히 확인하는 시간을 가져보았다. 다음의 코드를 살펴보자:

let obj;

{
  this.x = 0;
  obj = {
    normal: function () {
      console.log(this);
    },
    arrow: () => {
      console.log(this);
    },
  };
}

obj.normal(); // { normal: [Function: normal], arrow: [Function: arrow] }
obj.arrow(); // { x: 0 }

본 예시를 통해 차이를 알 수 있을 것이다. normal() 호출 시에는 obj 를 출력했고, arrow 호출 시에는 obj 를 할당 했을 당시의 block scope를 가리키는 것을 볼 수 있다.

마지막으로 다시 한 번 다음의 코드를 살펴보자:

function Person(name, age, id) {
    var privateId = id;

    this.name = name;
    this.age = age;
    this.getPrivateId = function() {
        return privateId;
    }
}
Person.prototype.introduce = function() { 
    console.log("hello, I'm " + this.name);
};

var gwon = new Person("gwon", 100, 1234);
console.log(gwon.name); // "gwon"
gwon.introduce(); // "hello, I'm gwon"

이 때 마지막 줄의 gwon.introduce() 호출 시 해당 함수 내에서의 thisgwon 을 가리키기 때문에 this.name 을 통해 "gwon" 이라는 값을 받아 정상적으로 동작할 수 있음을 알 수 있다.

Summary

이 글을 통해

  1. new 키워드의 역할을 알았다.

  2. private field를 생성하는 방법과 이를 통해 closure가 무엇인지 알았다.

  3. prototype을 통해 메모리를 아끼며 공통 method를 선언할 수 있게 되었다.

  4. prototype에 arrow function을 할당하며 발생한 오류를 통해 함수 호출 시 this를 좀 더 정확히 알고, arrow function과 일반 함수와의 차이를 알게 되었다.

두루뭉실하게 알고 있던 많은 개념들을 정리할 수 있었던 것 같다. 본 글을 통해 많은 사람들이 깨달음을 얻어갈 수 있기를 바란다!

p.s. 지적도 언제나 환영입니다 :)