먹고 기도하고 코딩하라

클로저(Closure)와 IIFE 본문

Javascript

클로저(Closure)와 IIFE

사과먹는사람 2021. 8. 13. 22:05
728x90
728x90

 

클로저

자바스크립트 함수와 스코프를 공부하다 보면 클로저(Closure) 개념과 맞닥뜨리게 된다.

클로저란 한마디로 특정 스코프(주로 자신의 외부)의 변수를 기억하고 접근할 수 있는 함수 혹은 내부 함수와 외부 스코프의 변수들을 잇는 연결고리이다. 자신(내부 함수)을 포함하는 외부 스코프 바깥에서 내부 함수를 호출하더라도 외부 스코프 변수를 여전히 접근할 수 있는 함수라고 생각하면 된다(함수가 특정 스코프에 접근할 수 있도록 의도적으로 해당 스코프에서 정의하는 경우가 많음). 기본적으로, 자바스크립트의 모든 함수는 적어도 1가지 컨텍스트(전역 컨텍스트)에 클로저이다. 

클로저 예시를 들기 위해 외부 함수의 변수나 함수에 접근할 수 있는 내부 함수를 반환하는 예시를 많이 들지만, 사실 클로저라고 무조건 함수를 반환하는 것은 아니다. 다만 그런 예시가 압도적으로 많을 뿐이다.

(클로저는 반드시 함수를 반환해야 하는 줄 알고 검색해 봤는데 나 같은 어떤 사람이 스택오버플로우에 질문을 남겼다. 재미있는 답변을 하나 가져오자면, "클로저는 스크루지 영감과 같다. 스크루지 영감은 자린고비이며 절대 돈을 그냥 흘려 보내지 않는다. 클로저도 이와 비슷하다. 클로저는 생을 마감하기 전까지 자기가 가진 변수들을 놔주지 않는다.")

 

스코프(scope)는 변수, 상수, 매개변수 등이 언제 어디서 정의되는지 결정하며 어떤 것이 유효한 범위 정도로 이해하면 쉽다. 특히 함수 스코프를 이야기할 때 스코프는 함수가 실행될 때에 따라 결정되는 게 아니라 선언되는 위치에 따라 정해지는데 이를 정적(어휘적, lexical) 스코프(lexical scoping)라고 한다. 다시 설명하자면 정적 스코프는 어떤 변수가 함수 스코프 안에 있는지는 함수를 '정의할 때' 알 수 있다는 뜻이다. 블록 스코프도 있지만 가장 root가 되는 자리에는 전역 스코프가 자리한다. 

 

 

클로저 예시를 살펴보자. 클로저 예시에는 중첩 함수나 블록 스코프 안의 함수가 쓰인다.

// ex1
let globalFunc;
{
  let blockVar = 'a';
  globalFunc = () => console.log(blockVar);
}
globalFunc();	//'a'
const makeCounter = () => {
  let count = 0;
  return () => count++;
}

let counter = makeCounter();
console.log(counter());	//0
console.log(counter());	//1

첫 번째 예시에서는 블록 스코프 안의 globalFunc, 두 번째 예시에서는 makeCounter 함수 표현식 내의 익명함수에 주목하길 바란다.

이 두 함수의 공통점은 외부에서 접근이 불가능한 변수(blockVar, count. 내부 함수에 의해 접근되는 외부 스코프의 변수들을 자유 변수라고 부른다)를 외부 스코프에서 태연하게 접근이 가능하다는 점이며, makeCounter의 경우에는 놀랍게도 count 값을 기억하고 있다.

첫 번째 예시의 경우, globalFunc를 어디서 호출하든 클로저 안의 blockVar 식별자에 접근이 가능하다(그것은 언제나 'a'일 것이다). 원칙적으로는 blockVar는 블록 스코프에 선언이 되었으니 블록이 끝나면서 접근할 수 없는 변수가 되어야 맞는 건데, globalFunc 덕분에 스코프를 벗어나서도 접근할 수 있는 것이다.

이는 내부함수인 makeCounter과 globalFunc가 자신이 선언되었을 때의 환경(lexical environment)을 기억하고 있기 때문에 가능하다. 자바스크립트 함수는 함수의 숨은 프로퍼티인 [[Environment]]를 이용해, 자신의 출생지를 기억하고 있다. 클로저 역시 [[Environment]]를 이용해 자신이 선언되었던 그 블록 스코프를 기억하고 그 스코프의 변수에 접근할 수 있는 것이다.

참고로, makeCounter는 고차 함수(high-order function)라고도 불리는데, 이는 함수를 반환하는 함수를 의미한다.

이를 타입스크립트로 바꿔쓰면 다음과 같다.

const makeCounter = (): (() => number) => {
  let count: number = 0;
  return (): number => count++;
}

let counter: () => number = makeCounter();
console.log(counter());	//0
console.log(counter());	//1

 

 

 

IIFE (Immediately Invoked Function Expression)

여기에 곁들여 나오는 것이 IIFE, 즉시 실행 함수 표현식이다.

ES6 전, var 키워드만 존재하던 때에 var도 블록 레벨 스코프를 가질 수 있게 하려고 고민한 끝에 나온 것인데, 함수를 선언하고 즉시 실행하는 1회용 함수이다. IIFE로 클로저를 만들 수 있다.

IIFE는 반환값이 없어도 상관없고, 단순히 클로저처럼 함수 스코프의 변수를 외부에서 접근할 수 있도록 하는 역할을 하기도 한다. 물론 클로저처럼 함수를 반환할 수도 있다.

아래 예시를 보면, 이름이 없는 익명 함수를 선언하고 그 함수를 소괄호로 감싼 다음 바로 호출하는 형태이다. 이것은 클로저와 IIFE의 혼합 예시나 다름없는데, 이 이름없는 익명 함수를 실행하면 `I've been called ${++count} times`라는 문자열을 출력하는 함수를 반환하고, 이 count는 자유 변수로 존재한다. 

const msg = (() => {
  var count = 0;
  return () => `I've been called ${++count} times`;
})();
console.log(msg());	//I've been called 1 times
console.log(msg());	//I've been called 2 times

 

IIFE를 이해하는 다른 예시를 살펴보자. (참고로 난 이 예시를 이해하느라 아주 오랜 시간이 걸렸다...)

1초마다 수가 줄어들고 5초가 지나면 'go!'를 콘솔에 찍는 함수를 구현한다고 쳐 보자.

for (var i = 5; i >= 0; i--) {
  setTimeout(() => console.log(i === 0 ? 'go!' : i), (5-i)*1000);
}
// -1이 1초마다 출력되어 총 6번 출력

위의 코드를 실행하면 생각만큼 잘 안 된다. 콘솔에 출력은 1초마다 한 번씩 찍히는 게 (5-i)*1000에는 문제가 없어 보이는데 도대체 왜일까? 그것은 setTimeout의 콜백 함수에서 쓰이는 i는 이미 루프가 다 끝난 시점의 i이기 때문이다. var 로 선언한 변수는 함수 스코프와 똑같이 동작한다. (블록 스코프나 전역 스코프와는 다르다. var로 선언한 것들은 변수를 선언하기 전에도 호이스팅으로 사용이 가능함)

물론 var i라고 쓴 것을 let i로 고쳐주면 의도한대로 작동하지만, 클로저를 만드는 식으로 해결해 보자. 이번에는 var i를 아예 for 문 바깥으로 빼 보자. (주의할 점은 i 선언을 for문 밖으로 빼면 let으로 바꿔도 클로저를 쓰지 않으면 -1만 6번 나오게 된다는 점이다)

 

var i;
for (i = 5; i >= 0; i--) {
  setTimeout((i => {
    return () => {
      console.log(i === 0 ? 'go!' : i);
    }
  })(i), (5-i)*1000);
}
// 의도대로 5 4 3 2 1 go!가 출력됨
for (i = 5; i >= 0; i--) {
  (i => {
    setTimeout(() => console.log(i === 0 ? 'go!' : i), (5-i)*1000);
  })(i);
}
//동일한 결과

첫 번째는 setTimeout 안의 콜백 함수를 IIFE로 바꿨고, 두 번째는 IIFE 안에서 setTimeout을 호출하는 것으로 바꿨다. IIFE를 쓸 때 주의할 점은, (function(i, x) { })(i, x)처럼 함수의 매개변수 부분과 호출하는 부분의 매개변수를 똑같이 맞춰줘야 한다는 점이다. 당연한 이야기지만 생김새가 다소 낯설어서 헷갈릴 수 있는 부분이다.

어쨌든, IIFE는 외부 스코프의 'i의 값'(i 자체가 아님)을 매개변수로 받아 사용하게 된다. IIFE를 실행할 때마다 스코프가 새로 만들어지고, 매개변수 i의 값도 보존되므로 -1이 6번 나오는 불상사를 막을 수 있게 된다. 

 

아.. 좀 어렵다. -_- 쓸 때는 별 생각 없이 써도 이론으로 들어가서 이해하려면 풍부한 예시와 내 스스로에게 설명하듯이 풀어보는 게 필요한 것 같다.

 

 

 

References

 

728x90
반응형
Comments