logo
PostsInvestingHomebarArticlesAbout

Javascript 이해하기 / 4. Function

2019.01.21 / 16min

Intro

함수가 컨텍스트의 변수 객체(VO)에 어떠한 영향을 미치며, 각 함수의 스코프 체인에는 무엇이 들어가는지도 알아보자.


TOC

종류

ECMAScript 에는 3가지 종류의 함수가 있고, 각각의 고유한 특징을 갖는다.

const foo = function () {...}; // 함수 표현식
function foo() {...} // 함수 선언식
(function () {...})(); // IIFE

위와 같이 함수의 경우는 3가지가 있다.

  • 함수 표현식
  • 함수 선언식
  • 즉시 실행 함수

함수 선언식

함수 선언식(줄여서 FD)은 다음과 같은 특징을 갖는다.

  • 반드시 이름을 가진다.
  • 소스 코드 위치에 자리한다. 프로그램 레벨이나 다른 함수의 Body안에 위치한다.
  • 컨텍스트 진입 시점에 생성한다.
  • 변수 객체(VO)에 영향을 준다.
function exampleFunc() {...}

가장 중요한 특징은 변수 객체에 영향을 미친다는 것이다.
이 함수는 컨텍스트의 변수 객체(VO)에 들어간다.

foo(); // 작동함

function foo() {
	alert('foo');
} 

위의 소스는 그렇다면 GlobalVO 에 들어가 있을 것이다.(흔히 호이스팅이라 불리는 것)

소스 코드 내에 함수를 정의하는 위치 또한 중요하다.

// 함수를 다음 2가지 방법으로 선언할 수 있다.
// 1) 전역 컨텍스트에 직접.
function globalFD() {
	// 2) 또는 다른 함수의 Body 내에서 선언.
	function innerFD() {}
}

함수를 선언할 수 있는 위치는 결국 두 군데다.


함수 표현식

함수 표현식(줄여서 FE)은 다음과 같은 함수다.

  • 표현식 위치에만 정의할 수 있다.
  • 선택적으로 이름을 가질 수 있다.(없을 수도 있다)
  • 함수 표현은 변수 객체에 영향을 주지 않는다.
  • 코드 실행 시점에 생성한다.
let foo = function () {...};

위의 경우는 익명함수 표현식을 foo 변수에 할당한다.
할당이 끝나면 foo를 호출할 수 있다. 선택적으로 이름을 줄 수 있다.

let foo = function _foo() {...};

여기에서 주목해야 할 것은 함수 내부에서 _foo 라는 이름을 사용할 수 있을 뿐만 아니라(외부는 사용불가), FE의 바깥에서도 식별자 foo에 접근할 수 있다는 사실이다.

FE를 식별자에 할당하면 FD와 구분하기 어려워진다. 하지만 FE가 항상 표현식에 위치한다는 사실을 알고 있다면, 둘을 쉽게 구분할 수 있다.

다음 예제에는 다양한 ECMAScript 표현식이 나와있는데, 모든 함수는 함수 표현식이다.

// 괄호(그룹화 연산자) 안에서는 표현식이 된다.
(function foo() {});

// 배열 리터럴 안에 있을 경우에도 표현식이다.
[function bar() {}];

// 콤마 또한 표현식으로 처리한다.
1, function baz() {};

위의 경우의 표현식들은, 표현식 위치에서 함수를 사용하고 변수 객체를 오염시키지 않으려면 필요하다.

function foo(callback) {
	callback();
}

foo(function bar() {alert('foo.bar');});
foo(function baz() {alert('foo.baz');});

FE를 변수에 할당하면, 함수는 메모리에 계속 존재한다. 따라서 나중에 변수명으로 접근할 수 있다(알고 있듯이 변수가 변수 객체(VO)에 영향을 주기 때문). 다시 말하지만 Global VO에 존재한다는 말이다.

let foo = function () {
	alert('foo');
};

foo();

즉시 실행 함수

보조적인 역할을 하는 도우미 데이터를 외부 컨텍스트에 감추기 위해서 유효범위를 캡슐화하는 예제가 있다(FE를 생성 직후 호출).

let foo = {};

(function initialize() {
	let x = 10;
	foo.bar = function () {   
		alert(x);
	};
})();

foo.bar(); // 10;

alert(x); // "x" is not defined

함수 foo.bar ( foo[[Scope]] 프로퍼티에 있는)는 initialize 함수의 내부에 있는 변수 x 에 접근할 수 있다. 그리고 x는 외부에서 직접 접근할 수 없다.

많은 라이브러리가 private 데이터를 만들어서 보조 개체를 감추는데 이 전략을 이용한다.

초기화하는 FE의 이름을 종종 생략하기도 한다.

(function () {
	// 초기화 스코프
})();

런타임에 조건에 따라 FE를 생성함으로써 VO를 오염시키지 않는 예제도 있다.

let foo = 10;
let bar = (
	foo % 2 == 0 ? 
		function () { alert(0); }
		: 
		function () { alert(1); }
);

bar(); // 0

감싸는 괄호에 대한 질문

개발을 오래한 것은 아니지만 1년동안 왜 괄호로 함수를 감싸야 선언과 동시에 호출할 수 있지 라는 생각을 했었다.

바로 표현식 구문이 가지는 제약 때문이었다.

표준에 따라서 표현식 구문은 여는 중괄호({)로 시작할 수 없다. 블럭과 구분할 수 없기 때문이다. 그리고 함수 선언과 구분하기 힘들기 때문에 함수 키워드로 시작해서도 안 된다.

다시 말해서, 즉시 실행 함수(function 키워드로 시작하는)를 만들기 위해서 아래와 같이 함수 선언식을 작성했다면,

function () {...}();// 또는 아래와 같이 이름이 있는.
function foo() {...}();

두 경우 모두 Parser가 해석 에러를 보고할 것이다.

에러의 원인은 다양하겠지만, 전역 코드에 이렇게 선언을 하면(즉, 프로그램 레벨에), function 키워드로 시작하기 때문에 파서는 코드를 함수 선언식으로 이해한다.

첫번째 경우는 함수의 이름이 없기 때문에 SyntaxError를 보고한다.

두 번째의 경우는 함수에 이름(foo)이 존재하기 때문에 Parser가 정상적인 함수 선언으로 처리한다. 하지만 내부에 표현식이 없는 그룹화 연산자를 사용하고 있음을 알리는 문법 에러가 발생한다. 이 경우에 함수 선언 뒤에 오는 것은 함수 호출을 위한 괄호가 아니라 그룹화 연산자일 뿐이다.

만약 코드를 다음과 같이 작성했다면,

// "foo"는 함수 선언이다
// 그리고 실행 컨텍스트 진입 시점에 생성한다.
alert(foo); 

// function
function foo(x) {
	alert(x);
}(1); // 이것은 호출이 아니라, 그룹화 연산자다.

foo(10);  // 10

함수 선언과 표현식 (1)을 가지고 있는 그룹화 연산자가 있기 때문에 두 구문 모두 아무런 문제가 없다.
위의 예제는 아래의 예제와 같다.

// 함수 선언
function foo(x) {
	alert(x);
}
// 표현식이 있는 그룹화 연산자
(1);
// 다른 (function) 표현식을 갖는 또 다른 그룹화 연산자
(function () {});

// 내부에 있는 표현식
("foo");

ECMA 스펙상으로 볼 때, 위의 코드는 잘못된 구문이다(표현식 구문은 function 키워드로 시작할 수 없다). 하지만 아래에 나와있는 것처럼, 문법 에러를 제공하는 ECMAScript 구현체는 하나도 없으며 모두 이를 각자 나름의 방식으로 처리한다.

지금까지 설명한 내용을 가지고, 어떻게 Parser에게 함수를 생성과 동시에 실행하고 싶다고 이야기할 수 있을까?

함수 선언식이 아닌 함수 표현식을 사용하면 된다.

표현식을 만드는 가장 간단한 방법은 위에서 이야기 했듯이 그룹화 연산자를 사용한다. 그룹화 연산자 안에 표현식을 두면, 파서는 함수 표현식(FE)인 코드를 구분할 수 있으며 이에 따라 모호함도 사라진다. 이러한 함수는 코드 실행 단계 동안에 만들어지고, 함수 실행이 끝난 후에는 사라진다(함수를 참조하고 있는 곳이 없다면).

(function foo(x) {
	alert(x);
})(1); // 이건 그룹화 연산자가 아닌 함수 호출이다.

예제의 마지막에 있는 괄호는 FD의 경우처럼 그룹화 연산자가 아니라 함수 호출 괄호다.

다음 예제에 나오는 즉시 호출 함수는 괄호로 감쌀 필요가 없다는 것에 주목하자. 이유는 함수가 표현식의 위치에 있어서 파서가 이를 코드 실행 시점에 생성하는 FE로 처리해야 한다는 것을 이미 알고 있기 떄문이다.

let foo = {
	bar: function (x) {   
		return x % 2 != 0 ? 'yes' : 'no';
	}(1)
};

alert(foo.bar); // 'yes'

얼핏보면 foo.bar는 함수가 아니라 문자열처럼 보인다. 여기에 있는 함수는 프로퍼티를 초기화할 때만 사용하는데, 조건 매개변수 값에 따라서 값을 돌려주는 함수를 만들고 바로 실행한다. 따라서, 괄호 를 묻는 질문에 완벽한 대답은 다음과 같다.

그룹화 괄호는 함수가 표현식의 위치에 있지 않을 때 필요하고, 함수를 생성 후 즉시 실행하고 싶은 경우에는 직접 함수를 FE(표현식)로 변환한다.
파서가 FE로 처리해야 한다는 것을 아는 경우, 즉 함수가 이미 표현식의 위치에 있는 경우에는 괄호가 필요없다.

괄호를 감싸는 방법 외에 함수를 FE 타입으로 변경할 수 있는 다른 방법이 있다. 예를 들어,

1, function () { 
	alert('익명함수를 호출합니다.');
}();// 또는 이렇게,

!function () { 
	alert('ECMAScript');
}();// 그리고 수동적으로 변경하는 다른 방법들...

올바른 표현식

(function () {})();
(function () {}());

구현의 확장 : Function문

다음에 나오는 예제 코드는 어떤 ECMAScript 구현체도 명세를 따르지 않았음을 보여준다.

if (true) { 
	function foo() {   
		alert(0); 
	}
} else { 
	function foo() {   
		alert(1); 
	}
}
	
foo(); // 1 또는 0? 다른 ECMAScript 엔진에서 테스트 해보자.

표준에 비춰볼 때 이 구조는 문제가 있다. 코드 블럭 안에 함수 선언식(FD)을 둘 수 없기 때문이다(지금은 ifelseFD를 가지고 있음). 위에서 이야기 했듯이, FD는 프로그램 레벨이나 다른 함수의 몸체 안에 직접 위치해야 한다.

코드 블럭은 오직 구문만 가질 수 있기 때문에 위의 예제는 잘못되었다. 블럭 내에 함수는 표현식의 위치에만 나올 수 있으며, 함수를 정의할 때는 여는 중괄호(코드 블럭과 구분할 수 없음)나 함수 키워드로 시작할 수 없다(FD와 구분할 수 없음).

하지만 표준 문서의 error processing 섹션은 ECMAScript 구현체가 프로그램 구문을 확장할 수 있도록 허용하고 있다. 그리고 블럭 안에 등장하는 함수 처리가 이러한 확장 중에 하나다. 오늘날 존재하는 모든 구현체는 이 경우에 예외를 던지지 않고 각자 고유의 방식으로 처리한다.

위 예제의 if-else 분기문은 두 함수 중 어떤 것을 정의할지 선택할 수 있다고 가정한다. 이 결정은 런타임에 이루어지기 때문에, 함수 표현식(FE)을 사용해야 한다. 하지만 대부분의 구현체는 단순하게 컨텍스트 진입 시점에 두 개의 함수 선언식(FD)을 모두 생성한다. 두 함수 모두 같은 이름을 사용하기 때문에, 마지막에 선언한 함수만 호출할 수 있다. 이런 이유로 이 예제를 실행하면 else로 코드 제어가 이동할 수 없음에도 불구하고 foo 함수는 1을 출력한다.

[2023-06-14] 현재는 크롬 기준 1로 잘 나온다.


기명함수 표현식의 특징(Named Function Expression, NFE)

이름을 갖는 FE(기명 함수 표현식, 줄여서 NFE)는 중요한 특징 하나를 가지고 있다.

  • 함수 표현식은 컨텍스트의 변수 객체(VO)에 영향을 주지 않는다.
  • FE는 이름으로 자기 자신을 재귀 호출할 수 있다.
(function foo(bar) {
	if (bar) {
		return;
	}
	foo(true); // "foo" 이름을 이용할 수 있다.
})();

// 하지만 외부에서는 "foo"를 이용할 수 없다.  
foo(); // "foo" is not defined

foo 를 어디에 보관하는 걸까? foo의 활성화 객체 안도 아니다. foo 함수 내부에서 foo라는 이름을 정의한 적이 없다. 그렇다면 foo를 생성하는 컨텍스트의 변수객체 안도 역시 아니다. FEVO에 영향을 주지 않는다는 사실을 외부에서 foo 를 호출하면서 확인했다.

그렇다면 어디일까?

코드 실행 시점에 인터프리터가 기명 함수 표현식(NFE)을 만나면. 함수 표현식을 만들기 전에 보조 특수 객체(auxiliary specilal object) 를 만들고 스코프 체인의 가장 앞에 특수 객체를 추가한다. 그런 다음 함수 표현식을 만드는데, 이 때 함수에 [[Scope]] 프로퍼티(Scope chain에서 배웠듯이)가 생긴다. 여기에는 함수를 생성하는 컨텍스트의 스코프 체인이 들어있다(즉, [[Scope]] 안에 특수 객체가 위치한다). 다음으로, 기명 함수 표현식을 특수 객체에 고유 프로퍼티로 추가한다. 이 프로퍼티의 값은 함수 표현식을 참조한다. 그리고 마지막으로 부모의 스코프 체인에서 특수 객체를 제거한다.

specialObject = {};

Scope = specialObject + Scope;

foo = new FunctionExpression;
foo.[[Scope]] = Scope;
specialObject.foo = foo; // {DontDelete}, {ReadOnly}

delete Scope[0]; // 스코프 체인의 가장 앞에 있는 specialObject를 삭제한다.

따라서, 외부에서는 이 함수의 이름을 사용할 수 없다. 함수의 [[Scope]] 안에 특수 객체가 저장되어 있기 때문에, 내부에서는 이 함수의 이름을 사용할 수 있다.


마무리

생각보다 내용이 많아졌다. 선언식, 표현식을 비교하면서 보여주는 블로그들은 많이 봤지만 정확히 왜 그렇게 되고 즉시실행은 왜 저렇게 될 수 밖에 없는 것인가에 대해서 정리를 해보았다.


###s Reference


avatar
snyungSoftware Engineer(from. 2018)
social-mailsocial-githubsocial-facebooksocial-book