모던 자바스크립트 튜토리얼 part 1.6 함수 심화학습

목차

1. 재귀와 스택

재귀 함수는 자기 반복적인 구조를 가진 함수를 작성할 때 아주 좋다. 예를 들어서 팩토리얼 함수를 작성할 때 재귀 함수를 사용하면 아래와 같이 작성할 수 있다.

function factorial(n) {
  return n ? n * factorial(n - 1) : 1;
}

그럼 이런 재귀 함수는 실제 js에서 어떻게 작동하는 걸까? 실행 컨텍스트가 이용된다. 이에 관한 글은 여기에 정리하였다.

2. 나머지 매개변수와 spread

나머지 매개변수를 통해 인수의 개수를 무제한으로 전달할 수 있다. 그리고 나머지 매개변수에는 그 앞의 매개변수들을 제외한 나머지 인수들이 배열 형태로 모인다.

유사 배열 객체인 arguments를 사용해도 인덱스를 사용해 인수에 접근 가능하다. 그러나 이는 배열이 아니라 유사 배열 객체고 이터러블이라 반복은 가능하지만 배열 메서드를 쓸 수 없다. 또한 인수 전체를 담아 버리므로 특정 인수들만 나머지에 담는 것이 불가능하다. 그리고 화살표 함수는 arguments를 지원하지 않는다.

2.1. 스프레드에 대해

스프레드 문법은 이터러블에 사용될 수 있다. 스프레드는 for..of와 같은 방식으로 내부의 이터레이터를 호출하여 요소를 수집한다. 단 특정 객체를 배열로 바꿀 땐 Array.from이 더 많이 쓰인다. 스프레드 문법은 내부의 이터레이터를 호출하므로 이터러블에만 사용할 수 있는데, Array.from은 이터러블뿐 아니라 유사 배열 객체에도 쓰일 수 있기 때문이다.

그런데 스프레드 문법을 직접 써 보면 객체에도 쓸 수 있는 것을 발견할 수 있다.

let a={a:1, b:2, c:3};
let aCopy={...a};
console.log(a===aCopy);

이상하다..단순 객체는 이터러블이 아닌데..왜 이럴까? 이는 스프레드 프로퍼티 프로포절에 객체 리터럴에 대해서도 스프레드 문법을 사용할 수 있도록 제안되었고 이게 받아들여졌기 때문이다. 이 내용은 다른 글에 정리하였다.

3. 변수의 유효범위와 클로저

JS는 함수 지향 언어이고 함수 또한 객체로 취급한다. 따라서 함수의 동적 생성이나 함수를 인수로 넘기기 등이 가능하다. 이때 함수와 외부의 상호작용에 대해 좀더 알아본다.

참고로 여기서는 let, const에 대해서만 다룬다. var에 대해서는 추후에 다룰 것이라고 한다.

3.1 코드 블록

코드 블록은 중괄호로 묶인 부분을 말한다. 이런 블록은 특정 작업을 수행하는 코드를 한데 묶어두는 용도로 사용되며 변수 스코프로도 사용된다.

이때 let의 경우는 블록 레벨 스코프를 사용하므로 동일한 스코프에 let으로 변수를 여러 번 선언하면 에러가 발생한다.

let msg = "hello";
console.log(msg);
// Identifier 'msg' has already been declared
let msg = "hello";

3.2. 렉시컬 환경

JS에서 함수는 객체이므로 당연히 함수에서 반환될 수도 있다. 이런 걸 이용할 수도 있다. 예를 들어서 다음과 같이 활용 가능하다.

function addNumber(n) {
  return function (x) {
    return x + n;
  };
}
 
let add3 = addNumber(3);
console.log(add3(5)); // 8

그런데 만약 addNumber를 통해 함수를 여러 개 만들었을 때 이 함수들은 서로 다른 객체일까? 만약 addNumber 내에 어떤 변수가 있었다면 이 변수는 addNumber가 리턴하는 함수들 내에서 어떤 값을 가질 것인가? 이런 질문에 대한 답은 렉시컬 환경이 준다.

JS에서는 실행 중인 함수, 코드 블록, 그리고 전역 스크립트는 렉시컬 환경이라 불리는 숨겨진 연관 객체를 갖는다. 즉 각 스코프는 자신이 가지고 있는 정보들을 담은 객체를 가지고 있는 것이다. 이 렉시컬 환경 객체는 환경 레코드와 외부 렉시컬 환경 참조로 구성된다.

환경 레코드는 스코프가 가지는 모든 지역 변수와 this값 등의 기타 정보를 저장한다. 변수는 이 환경 레코드의 프로퍼티이고 변수를 조작하는 것은 환경 레코드의 프로퍼티를 조작하는 것이다. 그리고 외부 렉시컬 환경 참조는 말 그대로 외부 렉시컬 환경 객체에 대한 참조를 저장한다.

3.2.1. 변수와 렉시컬 환경

let과 const로 저장된 변수는 스크립트가 시작될 때 엔진에 인식되고 렉시컬 환경에 올라간다. 그러나 특수한 상태인 uninitialized 상태가 되어서 let, const로 선언되기 전에는 변수에 접근할 수 없다.

예를 들어 다음과 같은 코드를 보자. 주석으로 변수의 저장과 외부 렉시컬 환경 참조를 설명하였다.

/* 
전역 렉시컬 환경
외부 렉시컬 환경은 없으므로 외부 렉시컬 환경 참조는 null을 가리키고 있다
지역 변수 a와 그 값 1을 프로퍼티 a:1로 저장
*/
let a=1;
if(a===1){
  /*
  외부 렉시컬 환경 참조는 전역 렉시컬 환경을 가리킨다
  지역 변수 b와 그 값 2를 저장
  */
  let b=2;
  console.log(b);
}

코드가 실행되고 실행 흐름이 한 줄씩 나아가면서 렉시컬 환경은 변화한다. 예를 들어서 변수 a를 선언하면 렉시컬 환경에서 a는 undefined로 저장될 것이다. 그리고 실행 흐름 상에서 a에 1을 저장하면 렉시컬 환경에서 a는 1로 변화할 것이다.

다음 코드를 보고 실행 흐름에 따른 렉시컬 환경 변화를 보자.

/*
스크립트 시작. 스크립트에서 선언한 변수 전체가 렉시컬 환경에 올라가고 값은 특수한 상태인 uninitialized 상태가 된다. 엔진이 변수들을 인지하긴 하지만 let으로 선언되기 전까지는 해당 변수에 접근할 수 없다
*/
let a, b;
/*
a,b가 선언되었고 아직 값 할당이 안 되었으므로 프로퍼티의 값은 undefined. 
*/
a=1;
/* a에 값이 할당되었다. 전역 렉시컬 환경에 이 값이 저장된다. */

하지만 var로 선언된 변수의 경우 스크립트의 시작 시점에 초기화되어 렉시컬 환경에 올라가고 undefined로 초기화된다. 따라서 실제로 코드를 작성할 때 var로 선언된 변수들은 마치 모두 스크립트의 최상단에 선언된 것과 같이 쓰일 수 있다.

/* 코드에선 a의 선언 전이지만 a의 선언이 
이미 스크립트 시작 시점에 처리되어 a를 사용할 수 있다 */
a = 2;
console.log(a); // 2
 
var a = 1;

3.2.2. 함수와 렉시컬 환경

함수 또한 하나의 값처럼 취급되므로 렉시컬 환경에 저장되어 있다. 변수와 비슷하다.

그러나 함수 선언문으로 선언한 함수의 경우 var가 그랬던 것과 같이 렉시컬 환경의 생성과 함께 초기화된다. 따라서 아직 함수 선언문에 도달하지 못했더라도 렉시컬 환경이 만들어지는 즉시 사용할 수 있다.

// foo는 함수 선언문으로 선언되었으므로 바로 사용 가능
foo();
 
function foo() {
    console.log("hi");
}

이때 함수 선언문으로 선언된 함수만 이렇게 렉시컬 환경 생성과 동시에 초기화된다. 함수 표현식으로 선언한 함수는 선언 이전에 사용할 수 없다.

또한 함수를 호출해 실행하면 새로운 렉시컬 환경이 만들어진다. 여기에는 함수의 지역변수와 외부 렉시컬 환경에 대한 참조뿐 아니라 함수 호출 시 넘겨받은 매개변수도 저장된다.

그런데 함수 내에서 어떤 변수에 접근한다고 하자. 그러면 먼저 내부 렉시컬 환경에서 그 변수를 검색한다. 외부 변수와 같은 이름의 내부 변수가 있으면 그 이름의 변수에 접근 시 내부 변수에 접근하게 되는 이유가 이것이다.

만약 내부 렉시컬 환경에서 원하는 변수를 찾지 못할 시 검색 범위를 외부 렉시컬 환경으로 확장해 가며 검색한다. 이 검색은 검색 범위가 전역 렉시컬 환경이 될 때까지 반복된다.

3.2.3. 함수 생성과 렉시컬 환경

함수를 생성하는 함수를 보자. 좀더 간소화하였다.

function foo() {
    let t = 0;
 
    return function () {
        return t++;
    }
}

foo()를 호출하면 호출시마다 새로운 렉시컬 환경이 만들어진다. 함수 호출시마다 실행 컨텍스트가 콜스택에 쌓이고 거기에는 렉시컬 환경이 포함되어 있는 것을 생각해 볼 때 당연한 이야기이다.

그럼 foo가 리턴하는 중첩 함수의 렉시컬 환경은 어떻게 될까? 모든 함수는 함수가 생성된 곳의 렉시컬 환경 참조를 숨김 프로퍼티인 [[Environment]]로 갖는다. 이는 함수가 생성될 때 딱 한 번 값이 변경되고 영원히 변하지 않는다.

function foo() {
    let t = 0;
 
    return function () {
        return t++;
    }
}
 
let t1 = foo();
let t2 = foo();
// 0 1 2 0 1 출력. t1이 가진 Environment와 t2가 가진 Environment가 독립임을 확인 가능하다
console.log(t1());
console.log(t1());
console.log(t1());
console.log(t2());
console.log(t2());

즉 위 코드의 t1, t2의 Environment에는 foo()가 함수를 생성하고 리턴할 당시의 외부 렉시컬 환경, foo의 지역변수 t가 값 0을 가지는 환경에 대한 참조가 저장되어 있다. 자신이 생긴 곳의 렉시컬 환경 참조를 갖는 것이다.

그럼 t1, t2를 실행하면 어떻게 될까? 먼저 자기 자신의 렉시컬 환경에서 t를 찾는다. 그러나 자신의 렉시컬 환경엔 t가 없다. 따라서 외부 렉시컬 환경인 foo에서 찾게 된다. 이때 이렇게 외부 변수에 접근할 땐 [[Environment]]를 사용한다. 그게 참조하고 있는 렉시컬 환경에 있는 t를 찾아서 0을 리턴하고 t를 1로 바꾸는 것이다.

이렇게 외부 변수를 기억하고 접근할 수 있는 함수를 클로저라고 하는데, js는 모든 함수에 Environment 숨김 프로퍼티가 있으므로 모든 함수가 클로저다.

이를 보여주는 또 하나의 예제는 다음과 같다.

function print() {
  let a = "김성현";
  return function () {
    console.log(a); // 김성현
  };
}
 
let a = "김성현2";
 
let p = print();
p();

p의 생성시에 기억된, p 생성 당시의 렉시컬 환경에서 a를 먼저 찾기 때문에 이런 일이 발생하는 것이다.

3.3. 메모리 GC와 클로저

함수 호출이 끝나면 해당 함수 호출로 생성된 렉시컬 환경은 메모리에서 제거된다. 더 이상 그 환경에 도달할 수 없기 때문이다.

하지만 호출된 함수 내에서 함수를 만들어서 리턴한다면, 리턴된 함수의 [[Environment]]가 호출된 함수의 렉시컬 환경을 참조하고 있을 것이다. 따라서 함수 호출이 종료되어도 렉시컬 환경이 메모리에서 제거되지 않는다.

이렇게 렉시컬 환경이 메모리에서 제거되지 않음으로 인해 메모리 누수가 발생할 수 있다.

예를 들어서 다음 코드를 보자.

function addNumber(n) {
  return function (x) {
    return x + n;
  };
}
// addOne, addTwo, addThree는
// 모두 그 생성된 렉시컬 환경을 독립적으로 기억한다
let addOne = addNumber(1);
let addTwo = addNumber(2);
let addThree = addNumber(3);
 
console.log(addOne(1)); // 2
console.log(addTwo(1)); // 3
console.log(addThree(1)); // 4

위 코드에서 addNumber함수의 호출이 끝난 이후에도 addOne, addTwo, addThree가 내부 Environment 프로퍼티에서 각각의 렉시컬 환경을 기억하고 있기 때문에 addNumber가 호출되면서 만들어진 렉시컬 환경들은 메모리에서 제거되지 않는다.

물론 addOne, addTwo 등의 함수가 메모리에서 제거되면 그게 생성될 때 기억되었던 addNumber의 렉시컬 환경도 제거될 것이다.

3.3.1. V8의 메모리 최적화

그런데 V8엔진 같은 경우 변수 사용을 분석하고 외부 변수가 사용되지 않는다고 판단되면 이를 메모리에서 제거하는 최적화 프로세스가 있다.

let val = "global";
 
function f() {
  let val = "local";
  function g() {
    debugger;
    /* 이때 실행이 멈춘 상태에서 val 변수는
    V8에 의해 최적화된다. 그런데 val은 아무데서도 사용되지
    않았음로 삭제되어 val을 콘솔에서 출력하려는 시도는 실패한다 */
  }
  return g;
}
 
let foo = f();
foo();

3.4. 클로저 예시

3.4.1. 카운터

function Counter() {
  this.count = 0;
 
  this.increment = function () {
    return ++this.count;
  };
 
  this.decrement = function () {
    return --this.count;
  };
}
 
let counter = new Counter();
// 1 2 3 2
console.log(counter.increment());
console.log(counter.increment());
console.log(counter.increment());
console.log(counter.decrement());

이 예제의 increment, decrement 함수는 모두 카운터를 잘 작동시킨다. 두 함수 모두 동일한 렉시컬 환경을 가지고 같은 count변수를 공유하기 때문이다.

3.4.2. 보이는 변수

let x = 1;
 
function func() {
  console.log(x);
  let x = 2;
}
 
func();

다음 코드에서 x에 접근하려는 시도는 에러를 발생시킨다.

Uncaught ReferenceError: Cannot access 'x' before initialization

func함수가 호출될 때 새로운 렉시컬 환경이 만들어진다. 그리고 이 환경이 처음 만들어질 때 스크립트 중에 지역변수 x가 선언된 것을 엔진이 감지하고 x를 <uninitialized>로 초기화한다.

그리고 console.log(x)가 실행되면서 x에 접근하려는 시도가 이뤄지는데, 이때 x는 uninitialized 상태이기 때문에 let 이전에 접근할 수 없다. 따라서 위 코드는 에러가 발생하는 게 당연하다.

3.4.3. 반복문으로 함수 만들기

function makeFunction() {
  let funcs = [];
 
  let i = 0;
  while (i < 3) {
    funcs[i] = function () {
      console.log(i);
    };
    i++;
  }
  return funcs;
}
 
let funcs = makeFunction();
// 모두 3이 나온다
funcs[0]();
funcs[1]();
funcs[2]();

의도한 것과 달리 왜 모두 3이 나오는 걸까? funcs[i] 함수가 만들어질 때 외부 렉시컬 환경에 대한 참조가 기억된다. 그리고 이 함수가 호출될 때 i에 접근하게 된다. 그런데 이 함수 내부에는 i라는 지역 변수가 없으므로 외부 렉시컬 환경에 접근하게 된다.

그런데 이때 외부 렉시컬 환경은 makeFunction 함수의 렉시컬 환경이다. 그리고 makeFunction이 끝나는 시점에서 이 렉시컬 환경에 i는 3으로 저장되어 있다. 따라서 funcs 배열의 어떤 함수를 호출해도 3이 나오는 것이다.

이를 해결하기 위해서는 funcs[i] 함수가 만들어질 때 i의 값을 복사해서 function[i]의 외부 렉시컬 환경에 저장해두면 된다.

function makeFunction() {
  let funcs = [];
 
  let i = 0;
  while (i < 3) {
    let j = i;
    funcs[i] = function () {
      console.log(j);
    };
    i++;
  }
  return funcs;
}

그러면 이제는 0, 1, 2가 나오는 것을 확인할 수 있다. funcs[i]가 생성될 때 저장되는 렉시컬 환경에 j가 따로따로 저장되기 때문이다.