자바스크립트 함수 (1) - 함수 객체, 함수 객체 생성


자바스크립트로 유명한 개발자 Douglas Crockford의 말을 빌리면, 자바스크립트는 지구에서 가장 오해하고 있는 프로그래밍 언어다. 물론 지금 글을 작성하는 2017년에는 그 오해가 많이 풀리긴 했지만, 여전히 자바스크립트는 계속 진화하고 있고, 그 변화를 자세히 알고 있는 사람은 드물다.

자바스크립트에서 특히 잘 모르거나, 오해하고 있는 부분은 바로 함수라고 생각한다. 사실 함수는 ECMAScript가 발표되고 ECMAScript 5가 될 때까지 거의 변화가 없었다. 꾸준히 암묵적인 버그와 미묘한 동작으로 우리를 혼란스럽게 만들었다. 때론 간단하게 생각했던 동작이 오히려 더 많은 코드를 요구하기도 했다.

하지만 ECMAScript 6부터 자바스크립트의 함수는 기존 개발자들의 요구나 불만을 받아들이고 크게 발전하고 있다.

함수

함수는 자바스크립트뿐만 아니라 대부분의 프로그래밍 언어에서 핵심이다. 그리고 자바스크립트의 함수는 조금 더 강력하다.

우선 ES5 기준으로 함수를 생각해보자. 함수는 객체처럼, 함수처럼, 객체 지향의 생성자처럼 동작하며, 함수에 붙어있는 프로토타입이라는 객체를 통해 공통되는 동작들을 공유했다. 함수를 만들어내는 다양한 문법 - 함수 생성자, 함수 표현식, 함수 선언식 등이 존재하고, 함수 자체로 스코프를 정의한다. 함수의 몸체에서 this의 참조는 동적으로 결정된다. arguments의 참조 또한 this처럼 동적이고 예약어처럼 사용된다. 기존의 암묵적인 버그나 에러를 유발하던 문제를 해결하기 위해 "use strict";가 있으며, 함수는 strict code 또는 non-strict code를 구분한다. 언어적 특성과 맞물려 클로저나 고차 함수를 활용한 여러 기법이 존재한다.

결론적으로 "자바스크립트의 함수는 그냥 객체 지향도 가능한 일급 함수다."라고 1줄 요약을 할 수 있겠다.

좋게 생각하면 함수는 여러 기능과 기법을 활용할 수 있도록 잘 디자인된 객체라고 할 수 있지만, 나쁘게 생각하면 함수는 너무 복잡하고 뭔가 막 뭐가 참 많다. 그리고 기능이 많은 만큼 의도하지 않은 동작을 하기도 한다. 그렇게 자바스크립트의 함수는 자바스크립트를 배우는데 첫 번째 장벽이 됐다.

ECMAScript 6+

ECMAScript 6, 그리고 그 이후 명세에서 함수는 계속 강력해지고 있다. 기본값 매개변수(Default parameter), 클래스(class), 화살표 함수(Arrow function), 나머지 매개변수(Rest parameters), name 프로퍼티, new.target, 제너레이터 함수(Generator function), async 함수, 꼬리 호출(Tail call), Block-Level 함수 등 이제 각 특징의 이름만 외우는 것도 힘들다.

이렇게 추가되는 여러 특징으로 인해 그동안 힘겹게 이해하고 활용했던 몇몇 함수의 동작과 기법은 이제 구식이 됐다. Function.prototype.bind 대신 화살표 함수를 사용하고, function으로 선언하던 클래스를 이제 정말 class로 선언하고, ES6의 Promise.prototype.then 체이닝 대신 async/await을 사용하고, Generator객체와 Runner 함수를 활용해 취소 가능한 비동기 동작을 구현할 수 있으며, 비동기 동작/함수를 동기적으로 테스트를(예를 들어 Redux-Saga의 테스트) 할 수도 있다.

그래서 뭐..?

그동안 이해했던 함수는 ES3 혹은 ES5에 머물러 있는데, 지금 ES6+의 함수를 사용한다. 이리저리 똑똑 두들겨 보며 개발하면 동작은 하지만, 왜 돌아가는지, 왜 안 돌아가는지, 왜 빠른지, 왜 편리한지, 제대로 설명 할 수 없을지 모른다.

자, 이제 이렇게 진화한 함수가 어떻게 동작하는지 다시 하나씩 차근차근 알아보자.

함수 객체

ECMAScript에서 함수 객체란 서브루틴(Subroutine)으로 수행될 수 있는 객체를 말한다. 동작을 나타내는 실행 코드와 상태를 포함하고 있으며 객체 지향의 생성자 역할도 할 수 있있다. 그리고 기본적으로 자바스크립트의 일반적인 객체(Ordinary object)와 동일한 동작을 할 수 있다(정확히는 Ordinary object의 Internal slot과 Interal method를 모두 가지고 있다).

즉, ECMAScript 함수는 일반 객체의 확장이며, 함수로 동작하기 위한 추가적인 기능을 가지고 있다.

함수 객체는 다음과 같은 데이터들을 내부에 추가로 저장한다.

  1. 클로저로 묶이는 렉시컬 환경(Lexical Environment) - [[Environment]]
  2. 함수 코드 - [[ECMAScriptCode]]
  3. 함수 종류 - [[FunctionKind]]: "normal", "classConstructor", "generator", "async"
  4. 생성자 종류 - [[ConstructorKind]]: "base", "derived"
  5. this 참조 형태 - [[ThisMode]]
  6. strict mode 여부 - [[Strict]]
  7. super 참조 - [[HomeObject]]
  8. 기타 등등

그리고 실제로 함수를 실행시켜주는 [[Call]], [[Construct]] 내부 메서드가 있다. 단순하게 함수를 호출하면 함수 객체 내부의 [[Call]]이 호출되고, new 또는 super 연산자와 함께 호출하면 [[Construct]]가 호출된다.

[[Call]]이 구현된 객체를 callable이라 부르고, [[Construct]]가 구현된 객체를 constructor라 부르는데, 자바스크립트의 함수는 callable이면서 constructor일 수도 있고 아닐 수도 있다. 대표적으로 화살표 함수는 callable이면서 non-constructor이다.

함수 생성

자바스크립트는 함수를 생성할 때 기본적으로 6가지의 정보를 사용한다.

  1. 함수 생성 방식(종류) - Normal, Arrow, Method
  2. 함수의 매개변수 리스트
  3. 함수 몸체 (함수 코드)
  4. 스코프 (Lexical Environment)
  5. strict mode 여부
  6. 함수 객체의 프로토타입 - FunctionPrototype, Generator, AsyncFunctionPrototype 등과 같은 객체들로 사용

다음 bar 함수를 생성하는 경우를 생각해보자.

function foo() {
  // bar 함수의 스코프는 foo의 Lexical Environment

  // bar는 일반적인 함수 선언식 - 함수 생성 방식은 Normal, 프로토타입은 Function.prototype
  function bar(/* 매개변수 리스트 */) {
    "use strict"; // bar는 strict function

    console.log("foo"); // 함수의 몸체
  }
}

ECMAScript는 이런 정보를 기반으로 함수를 생성한다.

  1. 함수 생성 방식을 통해 생성자가 될 수 있는지를 판단하고,
  2. strict 여부와 실제 함수 객체의 종류(일반 함수(Function)인지, 제너레이터 함수인지, async 함수인지)를 구분하여 저장한다.
  3. 함수 객체의 프로토타입(FunctionPrototype, Generator, AsyncFunctionPrototype 등)을 저장한다.

    • 여기에서의 프로토타입은 bind, apply, call 등과 같은 메서드를 가지고 있는 함수 자체의 프로토타입이다.
  4. 그리고 마지막으로 Environment(스코프), 파라미터, 함수 몸체, this 참조 방식 등의 정보를 저장한다.

이때 함수를 구분하는 방식에서 조금 혼란이 있을 수도 있다. 함수를 생성할때 함수 생성 방식과 함수 자체의 종류 두 가지를 사용한다. 함수 자체의 종류는 앞서 언급한 [[FunctionKind]]와 같으며, 함수 생성 방식은 생성 시에만 구분하여 사용하고 따로 저장하지는 않는다.

함수 생성 방식

함수 생성 방식은 ES5와 ES6를 기준으로 생각하면 쉽다. ES5까지의 함수 표현(function 키워드 사용)은 모두 Normal이고, ES6의 화살표 함수는 Arrow, 객체 리터럴에서 메서드 문법은 Method가 된다.

function foo() {} // Normal
const foo1 = () => {}; // Arrow
const person = {
  // ...
  sayName() {} // Method
};

함수 생성을 구분하는 이유는 ArrowMethod인 경우 생성자로 동작하지 못하도록 방지( - [[Construct]] 메서드를 구현하지 않고)하고, 함수의 this 참조 방식을 결정하기 위해서다( - Arrow인 경우 this 키워드는 lexical 참조).

기존의 ES5까지는 함수가 생성자 혹은 일반 함수 두 경우로 모두 호출 가능하였으나, ES6부터 이런 혼란을 줄이고자 구분하기 시작했다.

즉, ECMAScript 6 이후 함수를 어떻게 생성하는가에 따라 생성자로 쓰일 수 있는지 없는지가 결정되며, 이는 함수 생성 시 함수 할당(FunctionAllocate)이라 부르는 단계에서 결정한다.

주의할 점은 Babel과 같은 트랜스파일러를 사용하는 경우, ECMAScript 명세와 다르게 동작할 수 있다. 예를 들어 화살표 함수는 [[Construct]] 메서드가 없기 때문에 생성자로 사용할 경우 에러가 발생해야 하지만 트랜스파일링이 수행됐다면 에러가 발생하지 않을 수 있다.

다음 코드를 크롬의 개발자도구에서 실행해 보자. 에러가 발생한다.

const Foo = () => {};
var foo = new Foo(); // Uncaught TypeError: Foo is not a constructor

하지만 Babel로 변환된 코드를 보면 아래와 같다. 에러가 발생하지 않는다.

"use strict";

var Foo = function Foo() {};
var foo = new Foo();

메서드 문법도 마찬가지로 생성자로 사용할 경우 에러가 발생해야 하지만 트랜스파일링으로 변환된 코드는 에러가 발생하지 않을 수 있다.

var obj = {
  Foo() {}
};
new obj.Foo(); // Uncaught TypeError: obj.Foo is not a constructor

다음은 변환된 코드이다. 에러가 발생하지 않는다.

"use strict";

var obj = {
  Foo: function Foo() {}
};

new obj.Foo();

변환된 코드는 Normal 함수로 처리하기 때문에 에러가 발생하지 않는다. 사실 화살표 함수나 메서드 문법으로 생성자를 정의하고 사용하는 경우는 거의 없겠지만, 혹시라도 이런 코드는 작성하지 않도록 주의해야 한다.

마치며

지금까지 자바스크립트에서 함수를 표현하면 내부적으로 함수 객체를 어떻게 만드는지 대략 알아보았다.

  1. 함수는 일반 객체를 특별하게 확장한 객체이다.
  2. 함수를 생성할 때 생성자로 사용할 수 있는지 아닌지를 결정한다.
  3. 우리가 흔히 사용하는 트랜스파일러는 ECMAScript의 에러를 모두 표현하지는 못하므로 주의해야 한다.
  4. 함수가 생성될 때 여러 내부 데이터들을 저장하는데, 여기에는 함수의 스코프나, this 참조 방식 등 함수의 동작 방식을 이해하는데 중요한 여러 데이터가 있다.

사실은 ES6부터 바뀐 this 참조 방식을 알아보려고 ECMAScript 명세를 보기 시작했는데, 생각보다 바뀐 점들이 정말 많았다. 특히 함수 생성 부분부터 많은 변화가 있었기 때문에 다시 처음부터 공부한다고 생각하고 이 글을 작성하게 되었다. 이제 함수가 실제로 어떻게 동작하는지, this는 어떻게 참조하는지, 생성자 함수로 호출될 때 super는 어떻게 참조하는지 등의 기본적인 동작들을 더 자세히 알아보고 이 글을 꾸준히 이어가려 한다. 자바스크립트를 배우거나 다른 사람에게 설명할 때 조금이라도 더 도움이 되길 바라며 이번 글을 마친다.

Reference


이민규, FE Development Lab2017.06.30Back to list