본문 바로가기

JavaScript

[ JavaScript ] 함수와 클로저(Closure) ... 커링을 곁들인

반응형

함수

자바스크립트에서 함수는 일급 객체로 취급한다. 일급 객체란 생성, 대입, 인자 또는 반환값으로서의 전달 등 프로그래밍 언어의 기본적인 조작을 제한없이 사용할 수 있는 대상을 의미한다. 쉽게 말해서 모든 기능을 몰빵한 것으로 생각하면 된다. 일급 객체는 자바스크립트만의 특성은 아니고 다른 언어에서도 나타나는 특성이다. 아래의 조건을 만족하면 일급 객체로 간주한다. 

1. 무명의 리터럴로 표현이 가능하다. (익명함수)
2. 변수나 자료 구조(객체, 배열 등)에 저장할 수 있다.
3. 함수의 매개변수에 전달할 수 있다.
4. 반환값으로 사용할 수 있다.

 

// 1. 무명의 리터럴로 표현이 가능하다.
// 2. 변수나 자료 구조에 저장할 수 있다.
var increase = function (num) {
  return ++num;
};

var decrease = function (num) {
  return --num;
};

var predicates = { increase, decrease };

// 3. 함수의 매개변수에 전달할 수 있다.
// 4. 반환값으로 사용할 수 있다.
function makeCounter(predicate) {
  var num = 0;

  return function () {
    num = predicate(num);
    return num;
  };
}

var increaser = makeCounter(predicates.increase);
console.log(increaser()); // 1
console.log(increaser()); // 2

var decreaser = makeCounter(predicates.decrease);
console.log(decreaser()); // -1
console.log(decreaser()); // -2

 

함수도 객체다. 그런데 객체와 다른점은 '호출 가능'하다는 점이다. 

 

 

자바스크립트에서 함수는 선언과 동시에 실행시킬 수 있다. 이러한 함수를 '즉시 실행 함수'라고 한다. 이걸 도대체 어디다 쓸까 싶겠지만 곧 배우게 될 클로저를 응용할때 쓰인다. 이는 클로저 파트에서 자세하게 다루도록 하고.. 여기서는 즉시 실행 함수의 문법만 살펴보자. 

 

// 기명 즉시 실행 함수(named immediately-invoked function expression)
(function myFunction() {
  var a = 3;
  var b = 5;
  return a * b;
}());

// 익명 즉시 실행 함수(immediately-invoked function expression)
(function () {
  var a = 3;
  var b = 5;
  return a * b;
}());

(function () {
  // ...
})();

 

자바스크립트 엔진에 의해 함수 몸체를 닫는 중괄호 뒤에 ;가 자동 추가된다. 따라서 즉시 실행 함수는 소괄호로 감싸줘야 한다. 즉시 실행 함수를 소괄호로 감싸주지 않으면 아래와 같은 참사가 일어난다. 

 

fnuc{}(); → func{};();

 

클로저

클로저는 실행 컨텍스트의 연장선이다. 만약에 실행 컨텍스트를 잘 이해했다면 클로저는 어렵지 않게 이해할 수 있다. 실행 컨텍스트가 뭔지 모르면 이 글을 보고 오도록 하자. 

 

먼저 클로저의 정의부터 보자. 두 가지 정의를 가져와봤는데 한글보다는 영어가 좀 더 이해하기 쉽게 써놔서 영문 그대로 가져왔다. 먼저 첫 번째 정의다. 

 

"The closure is a function that accesses its lexical scope even executed outside of its lexical scope."

 

한 단어도 거를게 없다. 단어 하나하나를 곱씹어봐야 한다. 이 문장의 핵심은 두 가지다.

 

  1. 클로저는 함수다 (엄밀하게 말하면 함수는 아님. 뒤에 나옴)
  2. 클로저는 렉시컬 스코프 외부에서 실행되어도 자신의 렉시컬 스코프에 접근할 수 있다. 

두 번째 문장이 굉장히 난해하다. 렉시컬 스코프가 뭔소리...? 렉시컬 스코프는 쉽게 말해서 '선언 시점의 상위 스코프'를 말한다. 자바스크립트에서 함수는 함수 선언 시점에 상위 스코프가 결정된다는 특징이 있다. 함수를 어디서 호출했는지는 함수 스코프에 아무런 영향을 주지 않는다. 아래 예시를 보자.

 

let x = 1;

function foo() {
  let x = 10;
  bar(); // x = 10일 것 같지만 함수 선언 시점에서 x = 1 이므로 x = 1
}

function bar() { // bar 선언 시점
  console.log(x);
}

foo(); 
bar(); 

/* 출력결과
1
1
*/

 

foo() 에 주목해야 한다. 뭔가 느낌상 foo 안에서 bar가 호출되면 bar의 상위 스코프는 foo 함수 내부일 것 같지만 그렇지 않다. bar는 함수이므로 선언 시점에 상위 스코프가 결정된다. bar 선언 시점의 상위 스코프는 전역이므로 foo 내부에서 bar가 호출되어도 1이 출력된다. 

 

 

이제 아주 간단한 클로저 예시를 살펴보자. 

 

 

위 코드에서 클로저는 bar 함수다. 아까 살펴본 정의를 만족하는지 검사해보자. 

 

첫째, 클로저는 함수라고 했는데 bar는 함수다. 따라서 첫 번째 조건은 만족한다.

둘째, bar는 렉시컬 스코프 외부에서 실행되어도 자신의 렉시컬 스코프에 접근할 수 있는가?

 

bar의 렉시컬 스코프는 foo 함수 내부다. foo 함수는 bar 함수를 반환하고 있다. 그리고 그 결과를 전역 변수인 barFunc에 저장하고 있다. 따라서 변수 barFunc과 bar 함수는 바인딩 되었다고 할 수 있으므로 barFunc 또한 함수로서의 역할을 할 수 있게 됐다. 마지막 줄에서 barFunc를 호출하면 바인딩된 bar 함수가 호출되며, bar 함수 내부의 y는 bar의 렉시컬 스코프의 변수를 참조한다. 결론적으로 전역에서 barFunc에 바인딩된 bar를 호출함으로써 bar는 자신의 렉시컬 스코프에 접근하고 있다. 따라서 두 번째 조건도 만족한다. 

 

 

 

이제 두 번째 정의를 살펴보자. MDN 문서에서는 클로저를 아래와 같이 정의하고 있다.

 

 A  closure is the combination of a function bundled together (enclosed) with references to its surrounding state (the lexical environment)

 

수식으로 표현하면 아래와 같다. 

 

클로저 = 함수 + 함수가 참조하는 렉시컬 환경

 

첫 번째 정의는 클로저를 '함수'로 정의했다면, 두 번째 정의는 함수와 주변 환경 자체를 클로저로 정의하고 있다. 위 코드에서 클로저는 bar와 bar의 Lexical Scope가 된다. bar 함수 내부에서 자신의 렉시컬 스코프 변수 y를 참조하고 있으므로 y는 bar에 묶여있다. 그리고 y에 접근하기 위해서는 무조건 bar 함수를 거쳐야 한다. 클래스의 private 변수와 굉장히 유사하다. 이렇게 y는 bar 안에 닫혀있다는 의미에서 이름을 'Closure'라고 지은 것 같다. 이는 그냥 내 추측이다. 

 

 

클로저는 단순히 정의로 접근하기에는 쉽게 받아들여지지 않는 개념이다. 클로저의 동작 과정을 이해하기 위해서는 위 에서 다룬 코드를 실행 컨텍스트 관점에서 봐야 한다. 아래 그림을 통해 클로저의 동작 과정을 이해해보자. 만약에 실행 컨텍스트 가 뭔지 모르겠으면 이 글을 읽고 오도록 하자. 

 

 

1) 전역 실행 컨텍스트

 

  • 평가 과정 : 자바스크립트 엔진은 전역 코드 평가 과정에서 선언문들만 먼저 실행시키고, 스코프에 등록해놓는다. 이 단계에서 x 값은 엄밀히 말해서 '<uninitialized>'이고 foo 에는 함수 내용이 저장된다. 변수 호이스팅과 함수 호이스팅의 차이 때문이다. 이는 모든 평가 과정에 동일하게 적용된다.  
  • 실행 과정 : 전역 실행 컨텍스트가 생성되고 코드를 순차적으로 실행한다. x에는 값을 할당하고, foo 함수는 자신의 내부 슬롯에 전역 렉시컬 환경을 상위 스코프로서 저장한다. 
  • foo 함수가 호출된다. 

 

2) foo 함수 호출 - foo 실행 컨텍스트

 

  • 평가 과정 : foo 함수 내의 선언문들만 먼저 실행시켜 스코프에 등록해놓는다. 
  • 실행 과정 : foo 실행 컨텍스트가 생성되고 함수 내부 코드를 순차적으로 실행한다. y에 값을 할당하고, bar는 함수는 자신의 내부 슬롯에 foo 함수의 렉시컬 환경을 상위 스코프로서 저장한다. 
  • bar 함수를 반환하면 전역 변수 barFunc와 연결된다. 즉, barFunc는 foo 렉시컬 환경의 bar 함수를 참조하게 되는 것이다. 

 

3) foo 함수 실행 종료 후 모습

 

  • foo 함수의 생명주기가 종료되면 foo 실행 컨텍스트는 스택에서 제거된다. 하지만 전역변수 barFunc가 foo 렉시컬 환경 안의 함수를 참조하고 있으므로 foo 렉시컬 환경은 그대로 유지된다. 

 

4) barFunc 호출 - bar 실행 컨텍스트

 

  • bar 함수 내부가 실행된다. console과 출력에 사용되는 변수들은 스코프 체이닝을 통해 찾을 수 있다. 함수 실행이 종료되면 bar 실행 컨텍스트가 스택에서 pop 되고, 소스코드의 끝에 도달했으므로 전역 실행 컨텍스트도 스택에서 pop되어 코드 실행이 종료된다. 

 

클로저 예시

1. 클로저 예시로 정말 많이 다루는 코드를 가져와봤다. 아래 코드 결과가 어떻게 될지 예상해보자.

for (var i = 0; i < 5; i++) {
  setTimeout(function() {
    console.log(i);
  }, 1);
}

 

처음 보면 당연히 0~4까지 순차적으로 출력될 것으로 예상한다. 하지만 직접 돌려보면 5가 다섯번 출력되는 것을 확인할 수 있다. 도대체 왜...? 이는 var 때문이다. var는 함수 레벨 스코프를 따르기 때문에 i는 전역변수다. setTimeout은 1초 뒤에 콜벡함수를 호출하는데 1초동안 for문이 실행되면서 i = 5가 된다. 1초 뒤에 콜벡함수에서 i를 출력하면 이때의 i는 전역변수이기 때문에 5가 출력되는 것이다. 

 

두 가지 해결책이 있다.

 

첫째는 그냥 let을 쓰면 된다. let은 블록 레벨 스코프를 따르기 때문에 i는 지역변수가 되기 때문이다. 

 

둘째는 클로저를 사용하는 방법이다. 클로저를 사용하면 콜벡 함수 내부에 변수를 저장할 수 있기 때문에 지역 변수를 사용한 효과를 가져올 수 있다. 방법은 아래와 같다. 

 

for (var i = 0; i < 5; i++) {
    setTimeout((function() {
        var tmp = i;
        return function() {
            console.log(tmp);
        };
    }()), 1);
}

 

 

2. 클로저로 카운터 만들기

function makeCounter() {
// 자유 변수
    let counter = 0;

    function increase() {
        counter++;
    }

    function decrease() {
        counter--;
    }

    function getCounter() {
        return counter;
    }

    return {increase, decrease, getCounter};
}

let counter = makeCounter();
counter.increase(); // counter = 1
counter.increase(); // counter = 2
console.log(counter.getCounter());
counter.decrease(); // counter = 1
console.log(counter.getCounter());

counter = makeCounter(); // 초기화: counter = 0
console.log(counter.getCounter());

 

makeCounter 안에 자유변수를 선언해주고 내부 함수들을 전부 하나의 객체로 묶어 한 번에 반환했다. 이렇게 하면 마치 클래스의 메소드처럼 함수를 불러올 수 있다. 

 

 

3. 체이닝 가능한 함수 만들기

function makeCounter() {
    // 자유 변수
    let counter = 0;
    
    function increase() {
        counter++;
        return this;
    }

    function decrease() {
        counter--;
        return this;
    }

    function getCounter() {
        return counter;
    }

    return { increase, decrease, getCounter };
}
    
let counter = makeCounter().increase().increase().decrease().getCounter();
console.log(counter); // 1

 

약간 응용 예시다. 나는 이 코드를 처음 봤을때 이해가지 않았던 것이 'this'였다. 일반함수의 this는 전역객체인데... 마치 makeCounter를 가리키는 것처럼 동작하고 있기 때문이다. 그런데 잘 보면 makeCounter는 세 함수를 '객체' 안에 담아서 반환하고 있다. 따라서 increase와 decrease 함수 내의 this는 makeCounter가 반환하는 객체를 가리킨다. 세 함수 모두 동일한 렉시컬 환경을 가지고 있기 때문에 자유 변수 또한 공유하고 있는 셈이다.

 

 

4. 즉시 실행 함수와 클로저 

const counter = (function makeCounter() {
    // 자유 변수
    let counter = 0;

    function increase() {
        counter++;
    }

    function decrease() {
        counter--;
    }

    function getCounter() {
        return counter;
    }

    return {increase, decrease, getCounter};
})();
// 즉시 실행 함수로 한번만 호출

counter.increase();
counter.increase();
counter.decrease();
console.log(counter.getCounter());

 

만약에 클로저를 한 번만 사용하고 싶다면 즉시 실행 함수로 한 번만 호출하면 된다. 근데 이렇게 활용은 잘 안할 것 같다. 단지 예시일 뿐이니 참고만 하자.

 

 

5. 커링(currying)

 

커링은 쉽게 말해서 f(a, b, c)와 같은 함수를 f(a)(b)(c)로 변환하는 것을 말한다. 이때 인자는 무조건 순서대로 전달되어야 하며 중간 결과는 다음 인자를 받기 위해 대기한다. 그리고 마지막 인자가 전달되어야 비로소 원본 함수가 실행된다. 코드로 바로 알아보자. 숫자 세 개를 인자로 받아 합을 반환하는 함수다. 

 

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

 

이를 커링으로 변환하면 아래와 같다. 

 

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

// 아래처럼 사용 가능하다
const a = sum(3);
const b = a(2);
const c = b(1); // 마지막 인자를 받을때 비로소 덧셈 실행
console.log(c);

// 또는 한번에
console.log(sum(1)(2)(3));

 

진짜 너무 신기하다. 어떻게 이런 생각을 하지...ㅎㅎ 커링은 인자를 원할때 받을 수 있다는 점에서 아주아주 강력하다. 당장 필요한 정보만 받아서 전달하고, 또 필요한 정보가 들어오면 전달하는 식으로 마지막 인자가 넘어갈 때까지 함수의 실행을 미루는 방식을 전문용어로 지연실행(lazy execytion)이라고 한다. 나도 이론만 알았지 직접 써보는 것은 처음이라 너무 신기하다. 

 

커링은 계속 함수를 반환하는 형식이기 때문에 아래와 같이 화살표 함수로 매우 아름답게 표현할 수 있다. 

 

const sum = a => b => c => a + b + c;

// 아래처럼 사용 가능하다
const a = sum(1);
const b = a(2);
const c = b(3); // 마지막 인자를 받을때 비로소 덧셈 실행
console.log(c);

 

 

아래는 함수를 인자로 받는 일반화 버전이다. 박수 한번 치자.

 

const curry = func => a => b => c => func(a, b, c);
  
const getSum = curry((a, b, c) => a + b + c);
console.log(getSum(1)(2)(3));

 

 

마지막으로 커링과 클로저의 관계를 살펴보고 끝내자. 커링은 클로저의 응용 버전이다. 왜? 아래 코드를 보자. 

 

function sum(a){ // a함수
    return function(b){ // b함수
        return function(c) { // c함수
            return a + b + c;
        } 
    } 
}

const global_c = sum(1)(2); // c함수
console.log(global_c(3));

 

함수 이름은 편의상 a함수, b함수, c함수로 부르겠다. 

  • c 함수는 내부에 자신의 렉시컬 환경 변수인 b를 참조하고 있다.
  • b 함수는 내부에 자신의 렉시컬 환경 변수인 a를 참조하고 있다.  
  • 전역에서 sum(1)(2)(3) 을 해주면 b, c 함수는 외부 함수 밖에서 호출되는 셈이다. 

여기까지 클로저의 정의를 만족하므로 커링은 클로저의 응용 버전이라고 할 수 있다. 

 

 

 

 

<참조>

https://dmitripavlutin.com/simple-explanation-of-javascript-closures/ 

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Closures   

https://meetup.toast.com/posts/86   -  강추강추!!

https://javascript.info/currying-partials   

 

 

반응형

'JavaScript' 카테고리의 다른 글

[ JavaScript ] async/await  (0) 2022.04.29
[ JavaScript ] Promise  (0) 2022.04.27
[ JavaScript ] 실행 컨텍스트  (0) 2022.04.23
[ JavaScript ] 스코프와 호이스팅(feat. var let const)  (1) 2022.04.22
[ JavaScript ] this  (0) 2022.04.19