22. 1. 15 자바스크립트) 함수 심화학습

divedeepp·2023년 1월 15일
0

JavaScript

목록 보기
7/11

재귀(recursion)

문제 해결을 하다 보면 함수에서 다른 함수를 호출해야할 때가 있다. 이 때 함수가 함수 자기 자신을 호출할 수도 있는데, 이를 재귀라고 한다.

재귀는 큰 목표 작업 하나를 동일하면서 간단한 작업 여러 개로 나눌 수 있을 때 유용한 프로그래밍 패턴이다.

목표 작업을 간단한 동작 하나와 목표 작업을 변형한 작업으로 단순화시킬 수 있을 때도 재귀를 사용할 수 있다.

재귀의 예시를 살펴보자.

function pow(x, n) {
  if (n == 1) {
    return x;
  } else {
    return x * pow(x, n-1);
  }
}

pow(2, 3);	// 8

n == 1일 때 : 모든 절차가 간단해진다. 명확한 결과값을 즉시 도출하므로, 이를 재귀의 베이스(base)라 한다.

n != 1 일 때 : pow(x, n)x * pow(x, n - 1)로 표현할 수 있다. 이를 재귀 단계(recursive step)이라 부른다. 목표 작업 pow(x, n)을 간단한 동작(x를 곱하기)과 목표 작업을 변형한 작업(pow(x, n - 1))으로 분할 하였다.

이렇게 재귀를 이용하면 함수 호출의 결과가 명확해질 때까지 함수 호출을 더 간단한 함수 호출로 계속 줄일 수 있다.

가장 처음하는 호출을 포함한 중첩 호출의 최대 개수는 재귀 깊이(recursion depth)라고 한다. 위 예시에서 pow(x, n)의 재귀 깊이는 n이다.

자바스크립트 엔진은 최대 재귀 깊이를 제한한다. 만개 정도까지는 확실히 허용하고, 엔진에 따라 이보다 더 많은 깊이를 허용하는 경우도 있다. 하지만 대다수의 엔진이 십만까지는 다루지 못한다.

이런 제한을 완화하려고 엔진 내부에서 자동으로 tail calls optimization이라는 최적화를 수행하긴 하지만, 모든 곳에 적용되는 것은 아니고 간단한 경우에만 적용된다.

재귀 깊이 제한때문에 재귀를 실제 적용하는데에는 제약이 있긴 하지만, 재귀로 간결하고 유지보수가 쉬운 코드를 만들 수 있기 때문에 여전히 광범위하게 사용되고 있다.

실제 재귀 호출시 동작 방식 - 실행 컨텍스트와 스택

실제 재귀 호출이 어떻게 동작하는지 알아보자. 이를 위해서는 함수의 내부 동작에 대해 살펴보아야 한다.

실행 중인 함수의 실행 절차에 대한 정보는 해당 함수의 실행 컨텍스트(execution context)에 저장된다.

실행 컨텍스트는 함수 실행에 대한 세부 정보를 담고 있는 내부 데이터 구조이다. 제어 흐름의 현재 위치, 변수의 현재 값, this의 값 등 상세 내부 정보가 실행 컨텍스트에 저장된다.

함수 호출 1 회당 하나의 실행 컨텍스트가 생성된다.

함수 내부에 중첩 호출이 있을 때는 아래와 같은 절차가 수행된다.

  • 현재 함수의 실행이 일시 중지된다.
  • 중지된 함수와 연관된 실행 컨텍스트는 실행 컨텍스트 스택에 저장된다.
  • 중첩 호출이 실행된다.
  • 중첩 호출 실행이 끝난 이후 실행 컨텍스트 스택에서 일시 중단한 함수의 실행 컨텍스트를 꺼내오고, 중단한 함수의 실행을 다시 이어간다.

재귀 깊이는 스택에 들어가는 실행 컨텍스트 수의 최댓값과 같다. 실행 컨텍스트는 메모리를 차지하므로 재귀를 사용할 때에는 메모리 요구사항에 유의해야 한다.

재귀적 순회(recursive traversal)

한 회사 내에 모든 임직원의 임금의 합을 구해보자.

두 가지 경우로 나누어 생각할 수 있다.

  1. 베이스 : 임직원 배열을 가진 단순한 부서. 간단한 반복문으로 급여 합계를 구할 수 있다.
  2. 재귀 단계 : N개의 하위 부서가 있는 객체. 각 하위 부서에 속한 임직원의 급여 합계를 얻기 위해서 N번의 재귀 호출을 하고, 최종적으로 모든 하위부서 임직원의 급여를 더한다.
let company = { 
  sales: [{name: 'John', salary: 1000}, {name: 'Alice', salary: 1600 }],
  development: {
    sites: [{name: 'Peter', salary: 2000}, {name: 'Alex', salary: 1800 }],
    internals: [{name: 'Jack', salary: 1300}]
  }
};

// 급여 합계를 구해주는 함수
function sumSalaries(department) {
  if (Array.isArray(department)) { // 첫 번째 경우
    return department.reduce((prev, current) => prev + current.salary, 0); // 배열의 요소를 합함
  } else { // 두 번째 경우
    let sum = 0;
    for (let subdep of Object.values(department)) {
      sum += sumSalaries(subdep); // 재귀 호출로 각 하위 부서 임직원의 급여 총합을 구함
    }
    return sum;
  }
}

alert(sumSalaries(company)); // 7700

재귀적 자료 구조

재귀적 자료 구조는 자기 자신의 일부를 복제하는 형태의 자료 구조이다.

위에서 살펴본 회사 구조가 재귀적 자료 구조의 한 예이다. 회사는 부서를 가지는 객체로 이루어져 있고, 각 부서도 하위 부서를 가지는 객체 구조이다.

연결 리스트(linked list)

연결리스트도 재귀적 자료 구조 중 하나이다.

객체를 정렬하여 어딘가에 저장하고 싶다면 가장 먼저 떠오르는 구조가 배열일 것이다. 하지만 배열은 요소 삭제와 삽입에 들어가는 비용이 많이 든다. 앞 쪽 요소에 무언가를 할 때 shift, unshift를 사용하면 모든 요소의 번호를 다시 매겨야 하므로 배열은 꽤 느리다.

빠르게 삽입 혹은 삭제를 해야 할 때는 배열 대신 연결 리스트를 사용할 수 있다.

연결 리스트는 객체 안에 아래 프로퍼티들을 조합해서 정의할 수 있다.

  • value : 연결 리스트 노드가 가지는 값 프로퍼티
  • next : 다음 연결 리스트 노드를 참조하는 프로퍼티. 다음 요소가 없으면 null이 된다.
let linkedList = {
  value: 1,
  next: {
    value: 2,
    next: {
      value: 3,
      next: {
        value: 4,
        next: null
      }
    }
  }
};

연결 리스트의 next 프로퍼티를 이용해서 이어지는 객체 어디에든 도달할 수 있다.

linkedList.next;	// { value: 2, next: ... }
linkedList.next.next;	// { value 3, next: ... }

전체 리스트를 여러 부분으로 쉽게 나눌 수도 있고, 다시 합치는 것도 가능하다.

// 나누기
let secondLinkedList = list.next.next;
list.next.next = null;

// 합치기
list.next.next = secondLinkedList;

쉽게 요소를 추가하거나 삭제할 수도 있다.

let list = { value: 1 };
list.next = { value: 2 };
list.next.next = { value: 3 };
list.next.next.next = { value: 4 };

// 연결 리스트 맨 앞에 노드 추가
list = { value: "new item", next: list };

// 중간 요소 제거. 이전 요소의 next를 변경 해준다.
list.next = list.next.next;

이처럼 연결 리스트는 배열과 달리 대량으로 요소 번호를 재할당하지 않고도 요소를 쉽게 재배열할 수 있다는 장점이 있다.

다만, 연결 리스트의 가장 큰 단점은 인덱스만 사용해서 요소에 쉽게 접근할 수 없다는 점이다. N번 째 값을 얻기 위해서 배열처럼 arr[N]로 원하는 요소에 접근할 수 없고, 첫 번째 항목부터 시작해서 N번 next로 이동해야 한다.


나머지 매개변수와 스프레드 문법에 사용되는 ...

...은 나머지 매개변수와 스프레드 문법에 사용될 수 있다.

  • ...이 함수 매개변수의 끝에 있으면 인수 목록의 나머지를 배열로 모아주는 나머지 매개변수이다.
  • ...이 함수 호출 시 사용되거나 기타 경우에는 배열을 목록으로 확장해주는 스프레드 문법이다.

나머지 매개변수 ...

상당수의 자바스크립트 내장 함수는 인수의 개수에 제약을 두지 않습니다. 또, 함수 정의 방법과 상관없이 함수에 넘겨주는 인수의 개수에는 제약이 없다.

함수에서 임의의 개수의 인수를 받는 방법과 매개변수에 배열을 전달하는 방법에 대해 알아보자.

function sum(a, b) {
  return a + b;
}

alert( sum(1, 2, 3, 4, 5) );

함수를 정의할 때는 인수를 두 개만 받고, 실제 함수를 호출할 때는 이보다 많은 여분의 인수를 전달했지만, 에러가 발생하지 않았다. 반환값은 처음 두 개의 인수만을 사용해 계산된다.

값들을 담은 배열 이름 앞에 마침표 세 개 ...를 붙여서 함수 선언부에 매개변수로 포함시킬 수 있다. 아래 ...args는 매개변수들을 한데 모은 배열이다.

function sumAll(...args) {
  let sum = 0;
  
  for (let arg of args) sum += arg;
  
  return sum;
}

sumAll(1)

앞 부분의 매개변수는 변수로, 남아있는 매개변수들은 배열에 모을 수도 있다.

function showName(firstName, lastName, ...titles) {
  alert( firstName + ' ' + lastName );
  alert( titles[0] );
  alert( titles[1] );
  alert( titles.length );
}

showName("Bora", "Lee", "Software Engineer", "Researcher");

나머지 매개변수 ...는 남아있는 인수를 모으는 역할을 하므로 항상 마지막에 있어야 한다.

유사 배열 객체 arguments

유사 배열 객체 arguments에 인덱스를 사용해서 인수에 접근할 수 있다.

function showName() {
  alert( arguments.length );
  alert( arguments[0] );
  alert( arguments[1] );
}

// 2, Bora, Lee가 출력됨
showName("Bora", "Lee");

// 1, Bora, undefined가 출력됨(두 번째 인수는 없음)
showName("Bora");

arguments는 이터러블이지만 배열은 아니다. 따라서, 배열 메서드를 사용할 수 없는 단점이 있다. 또, 인수 전체를 담기 때문에 나머지 매개변수처럼 인수의 일부만 사용할 수 없다.

스프레드 문법(spread syntax) ...

지금까지는 매개변수 목록을 배열로 가져오는 방법에 대해 살펴보았다.

그런데 개발을 하다 보면 배열을 통째로 매개변수에 넘겨주는 것처럼 반대되는 기능이 필요할 때가 생긴다.

스프레드 문법은 이럴 때 사용하기 위해서 만들어졌다. ...를 사용하지만, 나머지 매개변수와는 반대되는 역할을 한다.

let arr = [3, 5, 1];

alert( Math.max(...arr) );	// 5

let arr1 = [1, -2, 3, 4];
let arr2 = [8, 3, -8, 1];

alert( Math.max(...arr1, ...arr2) );	// 8
alert( Math.max(1, ...arr1, 2, ...arr2, 25) );	// 25

스프레드 문법은 배열을 합칠 때도 활용할 수 있다.

let arr1 = [3, 5, 1];
let arr2 = [8, 9, 15];

let merged = [0, ...arr1, 2, ...arr2];

alert(merged);	// [0, 3, 5, 1, 2, 8, 9, 15]

배열이 아니더라도 이터러블이면 스프레드 문법을 사용할 수 있다. 스프레드 문법을 사용해서 문자열을 문자 배열로 변환시켜 보자.

let str = "Hello";

alert( [...str] );	// ["H", "e", "l", "l", "o"]

스프레드 문법은 for of와 같은 방식으로 내부에서 iterator를 사용해서 요소를 수집한다.

참고로, Array.from()은 이터러블을 배열로 바꿔줄 수 있다.

let str = "Hello";

alert( Array.from(str) );	// ["H", "e", "l", "l", "o"]

스프레드 문법과 동일한 결과가 출력되는 것을 확인할 수 있다. 그런데 Array.from(obj)[...obj]는 다음과 같은 미묘한 차이가 있다.

  • Array.from()은 유사 배열 객체와 이터러블 둘 다 사용할 수 있다.
  • 스프레드 문법은 이터러블에만 사용할 수 있다.

이런 이유 때문에 무언가를 배열로 바꿀 때에는 스프레드 문법보다 Array.from()이 보편적으로 사용된다.

배열과 객체의 복사본 만들기

Object.assign() 말고도 스프레드 문법을 사용해서 배열과 객체를 복사할 수 있다.

// 배열 복사
let arr = [1, 2, 3];
let arrCopy = [...arr];

JSON.stringify(arr) === JSON.stringify(arrCopy);	// true

arr === arrCopy;	// false (참조가 다름)

// 객체 복사
let obj = { a: 1, b: 2, c: 3 };
let objCopy = { ...obj }; 

JSON.stringify(obj) === JSON.stringify(objCopy); // true

alert(obj === objCopy); // false (참조가 다름)

변수의 유효범위(scope)와 클로저

자바스크립트는 함수형 언어이다. 함수를 동적으로 생성할 수 있고, 생성한 함수를 다른 함수에 인수로 넘길 수 있으며, 생성된 곳이 아닌 곳에서 함수를 호출할 수도 있다.

함수 내부에서 함수 외부에 있는 변수를 접근할 수 있다. 그런데, 함수가 생성된 이후에 외부 변수가 변경되면 어떤 일이 발생할까? 함수는 새로운 값을 가져올까 아니면 생성 시점 이전의 값을 가져올까?

또, 매개변수를 통해 함수를 넘기고 이 함수를 멀리 떨어진 코드에서 호출할 때는 어떤 일이 발생할까? 함수는 호출되는 곳을 기준으로 외부 변수에 접근할까?

코드 블록 {...}

코드 블록 안에서 선언한 변수는 블록 안에서만 사용할 수 있다.

{
  let message = "Hello";
  
  alert(message);	// Hello
}

alert(message);	// ReferenceError: message is not defined

이런 특징은 특정 작업을 수행하는 코드를 한데 묶어두는 용도로 활용할 수 있다.

{
  // 메시지 출력
  let message = "안녕하세요.";
  alert(message);
}

{
  // 또 다른 메시지 출력
  let message = "안녕히 가세요.";
  alert(message);
}

if, for, while 등에서도 마찬가지로 코드 블록 안에서 선언한 변수는 오직 블록 안에서만 접근 가능하다.

if (true) {
  let phrase = "Hello"
  
  alert(phrase);	// Hello
}

alert(phrase);	// ReferenceError: phrase is not defined

중첩 함수

함수 내부에서 선언한 함수를 중첩 함수라고 부른다. 중첩 함수는 아래와 같이 코드를 정돈하는데에 사용할 수 있다.

function sayHiBye(firstName, lastName) {
  function getFullName() {
    return firstName + " " + lastName;
  }
  
  alert( "Hello, " + getFullName() );
  alert( "Bye, " + getFullName() );
}

중첩 함수는 새로운 객체의 프로퍼티 형태나 중첩 함수 그 자체로 반환될 수 있다. 이렇게 반환된 중첩 함수는 어디서든 호출해 사용할 수 있다.

function makeCounter() {
  let count = 0;
  
  return function() {
    return count++;
  };
}

let counter = makeCounter();

alert( counter() );	// 0
alert( counter() );	// 1

좀 더 복잡한 내용을 살펴보자. 위 예시에서 counter를 여러 개 만들었을 때, 이 함수들은 서로 독립적일까? 함수와 중첩 함수 내 count 변수에는 어떤 값이 할당될까?

렉시컬 환경(lexical environment)

명확한 이해를 돕기 위해 설명을 몇 개의 단계로 나눠서 진행해보자.

단계 1. 변수

자바스크립트에서 실행 중인 함수, 코드 블록, 스크립트 전체는 렉시컬 환경이라 불리는 내부 숨김 연관 객체(internal hidden associcated object)를 갖는다.

렉시컬 환경 객체는 두 부분으로 구성된다.

  1. 환경 레코드(environment record) : 모든 지역 변수를 프로퍼티로 저장하고 있는 객체이다. this 값과 같은 기타 정보도 여기에 저장된다.
  2. 외부 렉시컬 환경(outer lexical environment)에 대한 참조 : 외부 코드와 연관된다.

변수는 특수 내부 객체인 환경 레코드의 프로퍼티일 뿐이다. 다시 말해, 변수를 가져오거나 변경하는 것은 환경 레코드의 프로퍼티를 가져오거나 변경함을 의미한다.

아래 두 줄짜리 코드에는 렉시컬 환경이 하나만 존재한다.

이렇게 스크립트 전체와 관련된 렉시컬 환경은 전역 렉시컬 환경(global lexical environment)이라 한다.

위 그림에서 네모 상자는 변수가 저장되는 환경 레코드이고, phrase환경 레코드의 프로퍼티(변수)를 나타낸다. 화살표외부 렉시컬 환경에 대한 참조를 나타낸다. 전역 렉시컬 환경이 외부 참조를 갖지 않기 때문에 화살표가 null을 가리킨다.

코드가 실행되고 실행 흐름이 이어져 나가면서 렉시컬 환경은 변화한다.

우측의 네모 상자들은 코드가 한 줄씩 실행될 때마다 전역 렉시컬 환경이 어떻게 변화하는지 보여준다.

  1. 스크립트가 시작되면 스크립트 내에서 선언한 변수 전체가 렉시컬 환경에 올라간다.
  • 이 때 변수의 상태는 특수 내부 상태인 uninitialized가 된다.
  • 자바스크립트 엔진은 uninitialized 상태의 변수를 인지하긴 하지만, let을 만나기 전까지는 이 변수를 참조할 수 없다.
  1. let phrase가 나타났다. 아직 값을 할당하기 전이기 때문에 프로퍼티(변수) 값은 undefined이다. phrase는 이 시점 이후부터 사용할 수 있다.
  2. 환경 레코드의 프로퍼티 phrase에 값이 할당되었다.
  3. 환경 레코드의 프로퍼티 phrase의 값이 변경되었다.

렉시컬 환경은 명세서에서 자바스크립트가 어떻게 동작하는지 설명하는 데 쓰이는 이론상의 객체이다. 따라서 코드를 사용해서 직접 렉시컬 환경을 얻거나 조작하는 것은 불가능하다. 자바스크립트 엔진들은 명세서에 언급된 사항을 준수하면서, 사용하지 않는 변수를 버려 메모리를 절약하거나 등의 엔진 고유의 방법을 사용해서 렉시컬 환경을 최적화한다.

단계 2. 함수 선언문

함수는 변수와 마찬가지로 값이다.

다만 함수 선언문으로 선언한 함수는 일반 변수와는 달리 바로 초기화한다는 점이 다르다.

함수 선언문으로 선언한 함수는 렉시컬 환경이 만들어지는 즉시 사용할 수 있다. 선언되기 전에도 함수를 사용할 수 있는 것은 바로 이 이유 때문이다.

아래 그림은 스크립트에 함수를 추가했을 때, 전역 렉시컬 환경 초기 상태가 어떻게 변하는지 보여준다.

이런 동작 방식은 함수 선언문으로 정의한 함수에만 적용된다. 함수를 변수에 할당한 함수 표현식은 해당하지 않는다.

단계 3. 내부와 외부 렉시컬 환경

함수를 호출해서 실행하면 새로운 렉시컬 환경이 자동으로 만들어진다. 이 렉시컬 환경에는 함수 호출 시에 넘겨받은 매개변수와 함수의 지역 변수가 저장된다.

아래 그림에서 say("John")을 호출하면 아래와 같은 내부 변화가 일어난다.

함수가 호출 중인 동안에는 호출 중인 함수를 위한 내부 렉시컬 환경과 내부 렉시컬 환경이 가리키는 외부 렉시컬 환경을 가지게 된다.

  • 예시의 내부 렉시컬 환경은 현재 실행 중인 함수인 say에 상응한다. 내부 렉시컬 환경에는 함수의 매개변수인 name으로부터 유래한 프로퍼티 하나만 있다. say("John")을 호출했기 때문에, name의 값은 "John"이 된다.
  • 예시의 외부 렉시컬 환경은 전역 렉시컬 환경이다. 전역 렉시컬 환경은 변수 phrase와 함수 say를 프로퍼티로 갖는다.

그리고 내부 렉시컬 환경은 외부 렉시컬 환경에 대한 참조를 갖는다.

코드에서 변수에 접근할 때는, 먼저 내부 렉시컬 환경을 검색 범위로 잡는다. 내부 렉시컬 환경에서 원하는 변수를 찾지 못하면 검색 범위를 내부 렉시컬 환경이 참조하는 외부 렉시컬 환경으로 확장한다. 이 과정은 검색 범위가 전역 렉시컬 환경으로 확장될 때까지 반복된다.

전역 렉시컬 환경에 도달할 때까지 변수를 찾지 못하면 strict mode에서는 에러가 발생한다.

아래 그림에서 변수 검색이 어떻게 진행되는지 확인해보자.

  • 함수 say 내부의 alert에서 변수 name에 접근할 때는 먼저 내부 렉시컬 환경을 살펴본다. 내부 렉시컬 환경에서 변수 name을 찾았다.
  • alert에서 변수 phrase에 접근하려는데, phrase에 상응하는 프로퍼티가 내부 렉시컬 환경에 없다. 따라서 검색 범위는 외부 렉시컬 환경으로 확장된다. 외부 렉시컬 환경에서 phrase를 찾았다.

단계 4. 함수를 반환하는 함수

function makeCounter() {
  let count = 0;

  return function() {
    return count++;
  };
}

let counter = makeCounter();

makeCounter()를 호출하면 호출할 때마다 새로운 렉시컬 환경 객체가 만들어지고, 여기에 makeCounter를 실행하는데 필요한 변수들이 저장된다.

makeCounter()가 실행되는 도중에 본문(return count++)이 한 줄 짜리인 중첩 함수가 만들어진다. 현재는 중첩 함수가 생성되기만 하고 실행은 되지 않은 상태이다.

모든 함수는 함수가 생성된 곳의 렉시컬 환경을 기억한다. 함수는 [[Environment]]라 불리는 숨김 프로퍼티를 갖는데, 여기에 함수가 만들어진 곳의 렉시컬 환경에 대한 참조가 저장된다.

따라서 counter.[[Environment]]에는 {count: 0}이 있는 렉시컬 환경에 대한 참조가 저장된다. 호출 장소와 상관없이 함수가 자신이 태어난 곳을 기억할 수 있는 것은 바로 [[Environment]] 프로퍼티 덕분이다.

counter()를 호출하면 각 호출마다 새로운 렉시컬 환경이 생성된다. 그리고 이 렉시컬 환경은 counter.[[Environment]]에 저장된 렉시컬 환경을 외부 렉시컬 환경으로서 참조한다.

실행 흐름이 중첩 함수의 본문으로 넘어오면 count 변수가 필요한데, 먼저 자체 렉시컬 환경에서 변수를 찾는다. 익명 중첩 함수에는 지역변수가 없기 때문에 이 렉시컬 환경은 비어있는 상황이다(<empty>). 이제 counter()의 렉시컬 환경이 참조하는 외부 렉시컬 환경에서 count를 찾아보자. count를 찾았다.

이제 count++가 실행되면서 count 값이 1 증가해야하는데, 변수값 갱신은 변수가 저장된 렉시컬 환경에서 이루어진다.

따라서 실행이 종료된 후의 상태는 다음과 같다.

counter()를 여러 번 호출하면 count 변수가 증가하는 이유가 바로 여기에 있다.

클로저(closure)

외부 변수를 기억하고 이 외부 변수에 접근할 수 있는 함수를 의미한다.

자바스크립트에서는 모든 함수가 자연스럽게 클로저가 된다.

자바스크립트의 함수는 숨김 프로퍼티인 [[Environment]]를 이용해서 자신이 어디서 만들어졌는지를 기억한다. 함수 본문에서는 [[Environment]] 프로퍼티를 사용해서 외부 변수에 접근한다.

아래는 클로저를 활용한 sum 함수 구현 예시이다.

function sum(a) {
  return function(b) {
    return a + b;
  };
}

sum(1)(2);	// 3
sum(5)(-1);	// 4

프론트엔드 개발자 채용 인터뷰에서 "클로저가 무엇입니까?"라는 질문을 받으면, 클로저의 정의를 말하고 자바스크립트에서 왜 모든 함수가 클로저인지에 관해 설명하면 된다. 이 때, [[Environment]] 프로퍼티와 렉시컬 환경이 어떤 방식으로 동작하는지에 대한 설명을 덧붙이면 좋다.

가비지 컬렉션

함수 호출이 끝나면 함수에 대응하는 렉시컬 환경이 메모리에서 제거된다. 함수와 관련된 변수들은 이 때 모두 사라진다.

함수 호출이 끝나면 관련 변수를 참조할 수 없는 이유가 바로 여기에 있다. 자바스크립트에서 모든 객체는 도달 가능한 상태일 때만 메모리에 유지된다.

그런데 호출이 끝난 후에도 여전히 도달 가능한 중첩 함수가 있을 수 있다. 이때는 이 중첩함수의 [[Environment]] 프로퍼티에 외부 함수 렉시컬 환경에 대한 정보가 저장된다. 다시 말해, 도달 가능한 상태가 되는 것이다.

함수 호출은 끝났지만 렉시컬 환경이 메모리에 유지되는 이유는 바로 이 때문이다.

function f() {
  let value = 123;
  
  return function() {
    alert(value);
  }
}

let g = f();	// g.[[Environment]]에
				// f() 호출 시 만들어지는 렉시컬 환경 정보가 저장된다.

그런데 이렇게 중첩 함수를 사용할 때는 주의할 점이 있다. f()를 여러 번 호출하고 그 결과를 어딘가에 저장하는 경우, 호출 시에 만들어지는 각 렉시컬 환경 모두가 메모리에 유지된다는 점이다. 각 렉시컬 환경은 메모리에서 삭제되지 않는다.

function f() {
  let value = Math.random();

  return function() { alert(value); };
}

// 배열 안의 세 함수는 각각 f()를 호출할 때 생성된
// 렉시컬 환경과 연관 관계를 맺는다.
let arr = [f(), f(), f()];

렉시컬 환경 객체는 다른 객체와 마찬가지로 도달할 수 없을 때, 메모리에서 삭제된다. 해당 렉시컬 환경 객체를 참조하는 중첩 함수가 하나라도 있으면 사라지지 않는다.

아래 예시 같이 중첩 함수가 메모리에서 삭제되고 난 후에야, 이를 감싸는 렉시컬 환경도 메모리에서 제거된다.

function f() {
  let value = 123;

  return function() {
    alert(value);
  }
}

let g = f(); // g가 살아있는 동안엔 연관 렉시컬 환경도 메모리에 살아있다.

g = null; // 도달할 수 없는 상태가 되었으므로 메모리에서 삭제된다.

오래된 var

var로 선언한 변수는 let으로 선언한 변수와 유사하다. 하지만 초기 자바스크립트 구현 방식 때문에 varletconst로 선언한 변수와는 다른 방식으로 동작한다.

var는 블록 스코프가 없다

var로 선언한 변수의 스코프는 함수 스코프이거나 전역 스코프이다. 블록 기준으로 스코프가 생기지 않기 때문에 블록 밖에서 접근이 가능하다.

if (true) {
  var test = true;
}

alert(test);	// true. if 문이 끝나도 변수에 접근할 수 있음

위 예시를 살펴보면 var는 코드 블록을 무시하기 때문에 test는 전역 변수가 된다. 다시 말해, 전역 스코프에서 이 변수에 접근할 수 있다.

또 다른 예시를 살펴보자. var는 블록이나 루프 수즌의 스코프를 형성하지 않기 때문에 여전히 반복문 밖인 전역 스코프에서도 변수에 접근할 수 있다.

for (var i = 0; i < 10; i++) {
  // ...
}

alert(i);	// 10

코드 블록이 함수 안에 있다면, var는 함수 레벨 변수가 된다.

function sayHi() {
  if (true) {
      var phrase = "Hello";
  }
  
  alert(phrase);	// Hello
}

sayHi();
alrt(phrase);	// Error

var는 변수의 중복 선언을 허용한다

한 스코프에서 같은 변수를 let으로 중복 선언하면 에러가 발생한다.

반면에 var는 같은 변수를 여러 번 중복 선언할 수 있다.

let user;
let user;	// Error

var user = "Pete";
var user = "John";

alert(user);	// John

선언하기 전에 사용할 수 있는 var

var 선언은 전역에서 선언한 변수라면 스크립트가 시작될 때 처리되고, 함수 내에서 선언한 변수라면 함수가 시작될 때 처리된다.

함수 본문 내에서 var로 선언한 변수는 선언 위치와 상관없이 함수 본문이 시작되는 지점에서 정의된다. 따라서 아래 두 예제는 동일하게 동작한다.

function sayHi() {
  phrase = "Hello";
  
  alert(phrase);
  
  var phrase;
}

sayHi();	// Hello
function sayHi() {
  var phrase;
  
  phrase = "Hello";
  
  alert(phrase);
}

sayHi();	// Hello

또, 코드 블록은 무시되기 때문에 아래 코드 역시 동일하게 동작한다.

function sayHi() {
  phrase = "Hello";
  
  if (false) {
    var phrase;
  }
  
  alert(phrase);
}

sayHi();	// Hello

이렇게 변수가 끌어올려 지는 현상을 호이스팅이라 한다. 주의해야할 점은 변수 선언은 호이스팅 되지만 할당은 호이스팅 되지 않는다.

function sayHi() {
  alert(phrase);
  
  var phrase = "Hello";
}

sayHi();

위 예시는 실제로 다음과 같이 동작한다. 변수 선언은 함수 실행이 시작될 때 호이스팅되지만, 할당은 호이스팅 되지 않기 때문에 예상한 결과와 다르게 동작한다.

function sayHi() {
  var phrase;
  
  alert(phrase);
  
  phrase = "Hello";
}

sayHi();	// undefined

이처럼 모든 var 선언은 함수 시작 시 처리되기 때문에, var로 선언한 변수는 어디서든 참조할 수 있다. 하지만 변수에 무언가를 할당하기 전까지는 값이 undefined이다.

var도 블록 레벨 스코프를 가질 수 있다

과거에는 변수 선언에 var만 사용할 수 있었다. 하지만 var는 블록 레벨 스코프를 가질 수 없었기 때문에 개발자들은 var도 블록 레벨 스코프를 가질 수 있는 방안을 고려하게 된다. 이 때 만들어진 것이 즉시 실행 함수 표현식(IIFE)이다.

IIFE는 다음과 같이 사용한다. 함수 선언식을 소괄호로 감싸고, 바로 호출하여 실행한다. 함수를 괄호로 감싸면 자바스크립트가 함수를 함수 선언문이 아닌 표현식으로 인식하도록 속일 수 있다. 함수 표현식은 이름이 없어도 괜찮고, 즉시 호출도 가능하다.

(function() {
  var message = "Hello";
  
  alert(message);
})();	// Hello

위 예시에서 var로 선언된 변수는 블록 레벨 스코프를 가지게 되므로 블록 외부에서 접근할 수 없다.


전역 객체

전역 객체를 사용하면 어디서나 사용 가능한 변수나 함수를 만들 수 있다. 전역 객체는 언어 자체나 호스트 환경에 기본 내장되어 있는 경우가 많다. 예를 들어 브라우저 환경에서는 전역 객체를 window, Node 환경에서는 global이라고 부른다.

전역 객체에는 Array와 같은 내장 객체나 window.innerHeight(뷰포트의 높이를 반환) 같은 브라우저 환경 전용 변수 등이 저장되어 있다.

전역 객체의 모든 프로퍼티는 아래와 같이 직접 접근할 수 있다.

alert("Hello");

window.alert("Hello");	// 위와 동일하게 동작한다

브라우저에서 let이나 const가 아닌 var로 선언한 전역 함수나 전역 변수는 전역 객체의 프로퍼티가 된다.

var gVar = 5;

alert(window.gVar);	// 5

let gLet = 5;

alert(window.gLet);	// undefined

하위 호환성 때문에 위와 같은 방식으로 전역 객체를 사용해도 동작은 하지만, 모듈을 사용하는 모던 자바스크립트는 이러한 방식을 지원하지 않기 때문에 사용을 지양해야한다.

중요한 변수라서 모든 곳에서 사용할 수 있게 하려면, 아래와 같이 전역 객체에 직접 프로퍼티를 추가시킨다.

// 모든 스크립트에서 접근할 수 있게 프로퍼티 선언
window.currentUser = {
  name: "Yong"
};

alert(currentUser.name);	// Yong
alert(window.currentUser.name);	// Yong

전역 변수는 되도록 사용하지 않는 것이 좋다. 함수를 만들때에도 외부 변수나 전역 변수를 사용하는 것보다 입력으로 변수를 받고 이를 이용해서 출력을 하는 방식으로 만들어야 테스트도 쉽고, 에러도 덜 발생한다. 따라서 프로젝트 전체에서 꼭 필요한 변수만 전역 객체에 저장하고, 전역 변수는 가능한 한 최소한으로 사용한다.


객체로서의 함수와 기명 함수 표현식

자바스크립트에서 함수는 값으로 취급되며 자료형은 객체이다. 다시 말해 함수를 호출이 가능한 active한 "객체"라고 이해하면 된다. 함수를 호출 할 수 있을 뿐만 아니라 객체처럼 함수에 프로퍼티를 추가하거나 제거, 참조를 통해 전달할 수도 있다.

name 프로퍼티

함수 객체의 name 프로퍼티를 사용하면 함수 이름을 가져올 수 있다.

function sayHi() {
  alert("Hi");
}

alert(sayHi.name);	// sayHi

// 익명 함수에도 자동으로 이름이 할당된다
let sayHi = function() {
  alert("Hi");
};

alert(sayHi.name);	// sayHi

length 프로퍼티

함수 객체의 length 프로퍼티는 매개변수의 개수를 반환한다. 단, 나머지 매개변수는 개수에 포함되지 않는다.

function f1(a) {}
function f2(a, b) {}
function many(a, b, ...more) {}

alert(f1.length);	// 1
alert(f2.length);	// 2
alert(many.length);	// 2

length 프로퍼티를 활용하면 객체지향의 중요한 개념 중 하나인 다형성을 구현할 수 있다. 아래 예시를 살펴보자. 파라미터의 종류에 따라(아래 예시에서는 length 프로퍼티의 값) 함수를 처리하는 방식이 다양한 것을 바로 다형성이라 한다.

function ask(question, ...handlers) {
  let isYes = confirm(question);
  
  for (let handler of handlers) {
    if (handler.length == 0) {
      if (isYes) handler();
    } else {
      handler(isYes);
    }
  }
}

ask("질문 있으신가요?", () => alert('OK를 선택하셨습니다.'), result => alert(result));

함수에 프로퍼티 추가하기

함수에는 자체적으로 만든 프로퍼티를 추가할 수 있다.

function sayHi() {
  alert("Hi");
  
  sayHi.counter++;
}

sayHi.counter = 0; // 초기화

sayHi();	// Hi
sayHi();	// Hi

alert(sayHi.counter);	// 2

기명 함수 표현식

기명 함수 표현식(Named Function Expression, NFE)은 이름이 있는 함수 표현식을 나타낸다.

// 일반적인 함수 표현식
let sayHi = function(who) {
  alert(`Hello, ${who}`);
};

// 기명 함수 표현식
let sayHi = function func(who) {
  alert(`Hello, ${who}`);
}

기명 함수 표현식을 사용하면 두 가지 변화가 생긴다.

  • 이름을 사용해서 함수 표현식 내부에서 자기 자신을 참조할 수 있다.
  • 기명 함수 표현식 외부에서는 그 이름을 사용할 수 없다.
let sayHi = function func(who) {
  if (who) {
    alert(`Hello, ${who}`);
  } else {
    func("Guest");
  }
};

sayHi();	// Hello, Guest
func();		// Error

위 예시에서 왜 중첩 호출을 할 때 sayHi 대신 func를 사용할까? 그 이유는 다음과 같다. 대부분의 개발자는 아래와 같이 코드를 작성하곤 한다.

let sayHi = function(who) {
  if (who) {
    alert(`Hello, ${who}`);
  } else {
    sayHi("Guest");
  }
};

하지만 이렇게 코드를 작성하면 외부 코드에 의해 sayHi가 변경될 수 있다는 문제가 생긴다.

let sayHi = function(who) {
  if (who) {
    alert(`Hello, ${who}`);
  } else {
    sayHi("Guest"); // TypeError: sayHi is not a function
  }
};

let welcome = sayHi;
sayHi = null;

welcome(); // Error

이러한 문제를 해결할 때 사용하는 것이 바로 기명 함수 표현식이다.

let sayHi = function func(who) {
  if (who) {
    alert(`Hello, ${who}`);
  } else {
    func("Guest");
  }
};

let welcome = sayHi;
sayHi = null;

welcome(); // Hello, Guest 

new Function 문법

함수 표현식과 함수 선언문 이외에도 함수를 만들 수 있는 방법이 있다.

문법

다음과 같이 new Function 문법을 사용하면 함수를 만들 수 있다. 매개변수와 함수 본문은 문자열 형태로 전달한다.

let func = new Function([arg1, ...], functionBody);
let sum = new Function('a', 'b', 'return a + b');

alert( sum(1, 2) );	// 3

let sayHi = new Function('alert("Hello")');

sayHi();	// Hello

기존에 사용하던 함수 선언문, 함수 표현식 방법과의 가장 큰 차이점은 new Function 문법은 런타임에 받은 문자열을 사용해서 함수를 만들 수 있다는 점이다. 예로 서버에서 코드를 받거나 템플릿을 사용해서 함수를 동적으로 컴파일해야 하는 경우와 같은 때에 사용할 수 있다.

클로저

함수는 [[Environment]]에 저장된 정보를 이용해서 함수가 만들어진 곳을 기억한다. 즉, [[Environment]]는 함수가 만들어진 렉시컬 환경을 참조한다.

그런데 new Function을 이용해서 함수를 만들면 함수의 [[Environment]] 프로퍼티가 현재 렉시컬 환경이 아닌 전역 렉시컬 환경을 참조하게 된다. 따라서, 외부 변수에 접근할 수 없고, 오직 전역 변수에만 접근할 수 있다.

// new Function 문법
function getFunc() {
  let value = "test";

  let func = new Function('alert(value)');

  return func;
}

getFunc()(); // ReferenceError: value is not defined

// 함수 표현식
function getFunc() {
  let value = "test";

  let func = function() { alert(value); };

  return func;
}

getFunc()(); // getFunc의 렉시컬 환경에 있는 값 "test"가 출력됨

setTimeout과 setInterval을 이용한 호출 스케줄링

일정 시간이 지난 후에 원하는 함수를 예약 실행할 수 있게 하는 것을 호출 스케줄링이라고 한다.

호출 스케줄링을 구현하는 방법은 두 가지가 있다.

  • setTimeout을 이용해서 일정 시간이 지난 후에 함수를 실행하는 방법
  • setInterval을 이용해서 일정 시간 간격을 두고 함수를 실행하는 방법

setTimeout

let timer = setTimeout(func|code, [delay], [arg1], ...);

func|code는 일정 시간이 지난 후 실행하고자 하는 코드로, 함수 또는 문자열 형태이다. 대개는 이 자리에 함수가 들어간다. 하위 호환성을 위해서 문자열도 받을 수 있다.

delay는 실행 전 대기 시간으로, 단위는 밀리초이며 기본값은 0이다.

arg1, ...는 함수에 전달할 인수들이다.

아래 예시를 살펴보자.

// 함수에 인수가 없을 때
function sayHi() {
  alert('Hello');
}

setTimeout(sayHi, 1000);	// 1초 후에 Hello

// 함수에 인수를 전달할 때
function sayHi(who, phrase) {
  alert( who + ", " + phrase );
}

setTimeout(sayHi, 1000, "Yong", "Hello");	// 1초 후에 Yong, Hello

// 익명 함수 전달
setTimeout(() => alert('Hello'), 1000);	// 1초 후에 Hello

setTimeout을 호출하면 타이머 식별자가 반환된다. 스케줄링을 취소하고 싶을 때는 해당 식별자를 사용하면 된다.

let timerId = setTimeout(...);
clearTimeout(timerId);

setInterval

setIntervalsetTimeout과 동일한 문법을 사용한다. 단, setTimeout은 전달받은 함수를 단 한 번만 실행하는 것과 달리 setInterval은 함수를 주기적으로 실행한다.

마찬가지로, clearTimeout을 통해 함수 호출을 중단할 수 있다.


call/apply와 데코레이터, 포워딩

자바스크립트에서 함수는 이곳저곳 전달될 수 있고, 객체로도 사용될 수 있다. 함수 간에 호출을 어떻게 포워딩하는지, 함수를 어떻게 데코레이팅하는지 알아보자.

데코레이터는 함수를 감싸는 래퍼로 함수의 행동을 변화시킨다. 함수의 주요 작업은 여전히 함수에서 처리한다.

함수의 코드 변경 없이 캐싱 기능 추가하기

함수 안에 캐싱 관련 코드를 추가하는 대신, 래퍼 함수를 만들어서 캐싱 기능을 추가해보자.

function slow(x) {
  // CPU 집약적인 작업이 여기에 올 수 있습니다.
  alert(`slow(${x})을/를 호출함`);
  return x;
}

function cachingDecorator(func) {
  let cache = new Map();

  return function(x) {
    if (cache.has(x)) {    // cache에 해당 키가 있으면
      return cache.get(x); // 대응하는 값을 cache에서 읽어옵니다.
    }

    let result = func(x);  // 그렇지 않은 경우엔 func를 호출하고,

    cache.set(x, result);  // 그 결과를 캐싱(저장)합니다.
    return result;
  };
}

slow = cachingDecorator(slow);

alert( slow(1) ); // slow(1)이 저장되었습니다.
alert( "다시 호출: " + slow(1) ); // 동일한 결과

alert( slow(2) ); // slow(2)가 저장되었습니다.
alert( "다시 호출: " + slow(2) ); // 윗줄과 동일한 결과

위 예시에서 cachingDecorator 같이 인수로 받은 함수의 행동을 변경시켜주는 함수를 데코레이터(decorator)라고 한다.

함수에 cachingDecorator를 적용하기만 하면 캐싱이 가능한 함수를 구현할 수 있기 때문에 데코레이터 함수는 유용하다. 또, 캐싱 관련 코드를 함수 로직과 분리할 수 있기 때문에 함수의 코드가 간결해진다는 장점이 있다.

아래 그림에서 볼 수 있듯이 cachingDecorator(func)를 호출하면 래펴 함수가 반환된다. 래퍼 함수 function(x)func(x)의 호출 결과를 캐싱 로직으로 감싼다.

바깥 코드에서 봤을 때, 함수 slow는 래퍼로 감싼 이전이나 이후나 동일한 일을 수행한다. 단지, 캐싱 기능이 추가된 것 뿐이다.

함수의 본문을 수정하는 것 보다 독립된 래퍼 함수를 사용할 때 생기는 이점을 정리하면 다음과 같다.

  • 데코레이터를 재사용할 수 있다.
  • 추가하고자하는로직이 분리되어 함수의 복잡성이 증가하지 않는다.
  • 필요하다면 여러 개의 데코레이터를 조합해서 사용할 수도 있다.

call 메서드를 사용해서 컨텍스트 지정하기

위처럼 구현한 데코레이터는 객체의 메서드에는 사용하기 적합하지 않다.

// worker.slow에 캐싱 기능을 추가해봅시다.
let worker = {
  someMethod() {
    return 1;
  },

  slow(x) {
    // CPU 집약적인 작업이라 가정
    alert(`slow(${x})을/를 호출함`);
    return x * this.someMethod(); // (*)
  }
};

// 이전과 동일한 코드
function cachingDecorator(func) {
  let cache = new Map();
  return function(x) {
    if (cache.has(x)) {
      return cache.get(x);
    }
    let result = func(x); // (**)
    cache.set(x, result);
    return result;
  };
}

alert( worker.slow(1) ); // 기존 메서드는 잘 동작합니다.

worker.slow = cachingDecorator(worker.slow); // 캐싱 데코레이터 적용

alert( worker.slow(2) ); // 에러 발생!, Error: Cannot read property 'someMethod' of undefined

원인은 **로 표시한 줄에서 래퍼가 기존 함수 func(x)를 호출하면 thisundefined가 되기 때문이다. 즉, this의 컨텍스트가 사라졌기 때문에 에러가 발생한다.

이를 해결하기 위해서는 this를 명시적으로 고정해서 함수를 호출할 수 있게 해주는 내장 함수 메서드를 사용해야 한다.

func.call(context, arg1, ...)

위 메서드를 호출하면 메서드의 첫 번째 인수가 this, 이어지는 인수가 func의 인수가 된 후, func가 호출된다.

// 컨텍스트만 고정
function sayHi() {
  alert(this.name);
}

let user = { name: "John" };
let admin = { name: "Admin" };

// call을 사용해 원하는 객체가 'this'가 되도록 합니다.
sayHi.call( user ); // this = John
sayHi.call( admin ); // this = Admin

// 컨텍스트 및 함수의 인수 전달
function say(phrase) {
  alert(this.name + ': ' + phrase);
}

let user = { name: "John" };

// this엔 user가 고정되고, "Hello"는 메서드의 첫 번째 인수가 됩니다.
say.call( user, "Hello" ); // John: Hello

다시 처음으로 돌아가서 데코레이터를 객체의 메서드에 적용할 때 call을 사용하면 컨텍스트를 원본 함수로 전달하면서 에러가 발생하지 않는다.

let worker = {
  someMethod() {
    return 1;
  },

  slow(x) {
    alert(`slow(${x})을/를 호출함`);
    return x * this.someMethod(); // (*)
  }
};

function cachingDecorator(func) {
  let cache = new Map();
  return function(x) {
    if (cache.has(x)) {
      return cache.get(x);
    }
    let result = func.call(this, x); // 이젠 'this'가 제대로 전달됩니다.
    cache.set(x, result);
    return result;
  };
}

worker.slow = cachingDecorator(worker.slow); // 캐싱 데코레이터 적용

alert( worker.slow(2) ); // 제대로 동작합니다.
alert( worker.slow(2) ); // 제대로 동작합니다. 다만, 원본 함수가 호출되지 않고 캐시 된 값이 출력됩니다.

여러 인수 전달하기

여러 인수를 전달할 방법은 다양하다.

  • 복수 키를 지원하는 맵과 유사한 자료 구조 구현하기
  • 중첩 맵 사용하기
  • 해싱 함수로 두 값을 하나로 합치기

세 번째 방법을 사용해보자

let worker = {
  slow(min, max) {
    alert(`slow(${min},${max})을/를 호출함`);
    return min + max;
  }
};

function cachingDecorator(func, hash) {
  let cache = new Map();
  return function() {
    let key = hash(arguments); // (*)
    if (cache.has(key)) {
      return cache.get(key);
    }

    let result = func.call(this, ...arguments); // (**)

    cache.set(key, result);
    return result;
  };
}

function hash(args) {
  return args[0] + ',' + args[1];
}

worker.slow = cachingDecorator(worker.slow, hash);

alert( worker.slow(3, 5) ); // 제대로 동작합니다.
alert( "다시 호출: " + worker.slow(3, 5) ); // 동일한 결과 출력(캐시된 결과)

func.apply

위에서 사용한 func.call(this, ...arguments) 대신, func.apply를 사용해도 된다.

apply 메서드는 functhis를 첫 번째 인수 context로 고정하고, 유사 배열 객체인 args를 함수의 인수로 사용한다.

func.apply(context, args)

call 메서드와 apply 메서드의 차이는 다음과 같다.

  • call 메서드는 복수 인수를 따로 따로 받는다.
  • apply 메서드는 유사 배열 형태의 인수만 받는다.

이 차이만 빼면 두 메서드는 완전히 동일하게 동작한다. 인수가 이터러블 형태라면 call을, 유사 배열 형태라면 apply를 사용하면 된다.

이렇게 컨텍스트와 함께 인수 전체를 다른 함수에 전달하는 것을 콜 포워딩(call fowarding)이라 한다.

메서드 빌리기

위에서 구현한 해싱 함수를 개선해보자. args의 요소 개수에 상관없이 요소들을 합칠 수 있게 하자. 가장 자연스러운 해결책은 배열 메서드 join을 사용하는 것이다.

function hash(args) {
  return args.join();
}

하지만 이 방법은 동작하지 않는다. 인수로 넘어오는 args는 진짜 배열이 아니고 이터러블 객체나 유사 배열 객체이기 때문이다. 배열이 아니기 때문에 join을 호출하면 에러가 발생한다.

아래와 같이 메서드 빌리기(method borrowing) 기법을 통해서 배열 메서드 join을 사용할 수 있다.

function hash() {
  alert( [].join.call(arguments) ); // 1,2
}

hash(1, 2);

일반 배열에서 join 메서드를 빌려오고([].join), [].join.call를 사용해서 arguments를 컨텍스트로 고정한 후 join 메서드를 호출한다.

데코레이터와 함수 프로퍼티

함수 또는 메서드를 데코레이터로 감싸는 것은 대체적으로 안전하다. 그런데 원본 함수에 calledCount 등의 프로퍼티가 있으면 데코레이터를 적용한 함수에서는 프로퍼티를 사용할 수 없으므로 안전하지 않다.

함수에 프로퍼티가 있는 경우에는 데코레이터 사용에 주의해야 한다.

함수 프로퍼티에 접근할 수 있게 해주는 데코레이터를 만들 때에는 Proxy라는 특별한 객체를 사용해서 함수를 감싸야 한다.


함수 바인딩

객체 메서드를 콜백으로 전달할 때 this 정보가 사라지는 문제가 생긴다. 이를 해결하는 방법을 알아보자.

사라진 this

앞 서 다양한 예제를 통해 this 정보가 사라지는 문제를 경험했다. 객체 메서드가 객체 내부가 아닌 다른 곳에 전달되어 호출되면 this가 사라진다.

let user = {
  firstName: "John",
  sayHi() {
    alert(`Hello, ${this.firstName}!`);
  }
};

setTimeout(user.sayHi, 1000); // Hello, undefined!

이는 setTimeout에 객체에서 분리된 함수인 user.sayHi가 전달되기 때문이다. 위 예시의 마지막 줄은 아래와 같다.

let f = user.sayHi;
setTimeout(f, 1000); // user 컨텍스트를 잃어버림

브라우저 환경에서 setTimeout 메서드는 인수로 전달받은 함수를 호출할 때 thiswindow를 할당한다. 따라서 위 예시에서 this.firstNamewindow.firstName가 되는데, window 객체에는 firstName 프로퍼티가 없으므로 undefined가 출력된다.

메서드를 전달할 때, 컨텍스트를 유지하려면 어떻게 해야 할까?

방법 1. 래퍼

가장 간단한 해결책은 래퍼 함수를 사용하는 것이다.

// 함수 선언 방식 래퍼
let user = {
  firstName: "John",
  sayHi() {
    alert(`Hello, ${this.firstName}!`);
  }
};

setTimeout(function() {
  user.sayHi(); // Hello, John!
}, 1000);

// 화살표 함수 방식 래퍼
setTimeout(() => user.sayHi(), 1000); // Hello, John!

위 예시가 의도한 대로 동작하는 이유는 외부 렉시컬 환경에서 user를 받아서 보통 때처럼 메서드를 호출하기 때문이다. 이러한 방식은 setTimeout이 트리거 되기 전에 user가 변경되면, 변경된 객체의 메서드를 호출하는 취약점이 있다.

let user = {
  firstName: "John",
  sayHi() {
    alert(`Hello, ${this.firstName}!`);
  }
};

setTimeout(() => user.sayHi(), 1000);

// 1초가 지나기 전에 user의 값이 바뀜
user = { sayHi() { alert("또 다른 사용자!"); } };

// setTimeout에 또 다른 사용자!

두 번째 방법을 사용하면 이런 일이 발생하지 않는다.

방법 2. bind

모든 함수는 this를 수정할 수 있는 내장 메서드 bind 를 가지고 있다.

기본 문법은 다음과 같다.

let boundFunc = func.bind(context);

bind 메서드는 함수처럼 호출 가능한 특수 객체를 반환한다. 이 객체를 호출하면 thiscontext로 고정된 함수 func가 반환된다. 따라서, boundFunc를 호출하면 this가 고정된 func를 호출하는 것과 동일한 효과를 볼 수 있다.

let user = {
  firstName: "John"
};

function func() {
  alert(this.firstName);
}

let funcUser = func.bind(user);
funcUser(); // John

바인딩된 함수의 인수도 원본 함수의 인수로 그대로 전달된다.

let user = {
  firstName: "John"
};

function func(phrase) {
  alert(phrase + ', ' + this.firstName);
}

// this를 user로 바인딩합니다.
let funcUser = func.bind(user);

funcUser("Hello"); // Hello, John (인수 "Hello"가 넘겨지고 this는 user로 고정됩니다.)

이제 객체 메서드에 bind 메서드를 적용해 보자.

let user = {
  firstName: "John",
  sayHi() {
    alert(`Hello, ${this.firstName}!`);
  }
};

let sayHi = user.sayHi.bind(user); // (*)

// 이제 객체 없이도 객체 메서드를 호출할 수 있습니다.
sayHi(); // Hello, John!

setTimeout(sayHi, 1000); // Hello, John!

// 1초 이내에 user 값이 변화해도
// sayHi는 기존 값을 사용합니다.
user = {
  sayHi() { alert("또 다른 사용자!"); }
};

마찬가지로 인수도 전달할 수 있다.

let user = {
  firstName: "John",
  say(phrase) {
    alert(`${phrase}, ${this.firstName}!`);
  }
};

let say = user.say.bind(user);

say("Hello"); // Hello, John (인수 "Hello"가 say로 전달되었습니다.)
say("Bye"); // Bye, John ("Bye"가 say로 전달되었습니다.)

부분 적용(partial application)

bind 메서드의 문법은 다음과 같다.

let bound = func.bind(context, [arg1] , ...);

bind는 컨텍스트를 this로 고정하는 것 뿐만 아니라 함수의 인수도 고정한다.

곱셉을 하는 함수에 인수를 고정해서 double 함수를 만들어보자.

function mul(a, b) {
  return a * b;
}

let double = mul.bind(null, 2);

alert( double(3) ); // = mul(2, 3) = 6
alert( double(4) ); // = mul(2, 4) = 8
alert( double(5) ); // = mul(2, 5) = 10

이러한 방식을 부분 적용이라 한다. 부분 적용을 사용하면 기존 함수의 매개변수를 고정하여 새로운 함수를 만들 수 있다.

기존 함수의 인수 몇 개를 고정한 함수, 부분 함수(부분 적용 함수)를 만드는 이유는 다음과 같다.

  • 가독성이 좋은 이름을 가진 독립 함수를 만들 수 있다. ex) double
  • 인수를 고정할 수 있어서, 매번 같은 인수를 전달할 필요가 없다. ex) mul(2, 3), mul(2, 4)
  • 이외에도 베이스 함수를 기반으로 더 특정된 기능을 가진 변형 함수를 만들 수 있다.

컨텍스트 없는 부분 적용

bind 메서드는 컨텍스트를 생략하고 인수만 고정할 수 없다.

인수는 고정하면 컨텍스트는 고정하고 싶지 않을 때는 다음과 같이 구현할 수 있다.

function partial(func, ...argsBound) {
  return function(...args) { // (*)
    return func.call(this, ...argsBound, ...args);
  }
}

// 사용법:
let user = {
  firstName: "John",
  say(time, phrase) {
    alert(`[${time}] ${this.firstName}: ${phrase}!`);
  }
};

// 시간을 고정한 부분 메서드를 추가함
user.sayNow = partial(user.say, new Date().getHours() + ':' + new Date().getMinutes());

user.sayNow("Hello");
// 출력값 예시:
// [10:00] John: Hello!

partial을 호출하면 래퍼가 반환된다. 래퍼를 호출하면 func는 다음과 같이 동작한다.

  • 동일한 this를 받는다.
  • partial을 호출할 때 받은 인수("HH:MM")는 ...argsBound에 전달된다.
  • 래퍼에 전달된 인수("Hello")는 ...args가 된다.

화살표 함수 다시 살펴보기

화살표 함수는 단순히 함수를 짧게 쓰기 위한 용도가 아니다. 몇 가지 유용한 기능을 제공한다.

화살표 함수에는 this가 없다.

화살표 함수 본문에서 this에 접근하면, 외부 컨텍스트에서 값을 가져온다.

이런 특징은 객체의 메서드 안에서 동일 객체의 프로퍼티를 대상으로 순회를 할 때 사용할 수 있다.

let group = {
  title: "1모둠",
  students: ["보라", "호진", "지민"],

  showList() {
    this.students.forEach(
      student => alert(this.title + ': ' + student)
    );
  }
};

group.showList();

예시의 forEach에서 화살표 함수를 사용했기 때문에 화살표 함수 본문에 있는 this.title은 화살표 함수 바깥에 있는 메서드인 showList가 가리키는 대상과 동일해진다.

위 예시에서 화살표 함수 대신 일반 함수를 사용한다면 에러가 발생한다.

let group = {
  title: "1모둠",
  students: ["보라", "호진", "지민"],

  showList() {
    this.students.forEach(function(student) {
      // TypeError: Cannot read property 'title' of undefined
      alert(this.title + ': ' + student)
    });
  }
};

group.showList();

forEach에 전달되는 함수의 thisundefined이기 때문에 에러가 발생한다. 그런데 화살표 함수는 this 자체가 없기 때문에 이런 에러가 발생하지 않는다.

참고로, 화살표 함수는 this가 없기 때문에 생성자 함수로 사용할 수 없다. new와 함께 호출 할 수 없다.

화살표 함수에는 유사 배열 객체 arguments가 없다

화살표 함수는 일반 함수와 다르게 모든 인수에 접근할 수 있는 유사 배열 객체 arguments를 지원하지 않는다.

이런 특징은 현재 this 값과 arguments 정보를 함께 실어 호출을 포워딩 해주는 데코레이터를 만들 때 유용하게 사용된다.

function defer(f, ms) {
  return function() {
    setTimeout(() => f.apply(this, arguments), ms)
  };
}

function sayHi(who) {
  alert('안녕, ' + who);
}

let sayHiDeferred = defer(sayHi, 2000);
sayHiDeferred("철수"); // 2초 후 "안녕, 철수"가 출력됩니다.

화살표 함수와 일반 함수의 다른 점을 요약하면 다음과 같다.

  • this를 가지지 않는다.
  • arguments를 지원하지 않는다.
  • new와 함께 호출할 수 없다.

참고 문헌

https://ko.javascript.info/advanced-functions

profile
더깊이

0개의 댓글