Home
프로필

클로저와 스코프, 그리고 모듈

image

💡 들어가기 전

자바스크립트는 스크립트어지만 실행 전 파싱과 컴파일을 가지는 언어이기도 하다. 컴파일 단계에서는 개발자가 작성한 변수와 함수, 블록의 위치가 스코프 규칙에 따라 분석되고 결정된다. 이렇게 분석된 스코프는 위계, 순서가 확실하기 때문에 렉시컬(사전적) 스코프라고 부른다. 이는 런타임 조건에 영향을 받지 않는다.

스코프 중첩은 클로저를 이루는 근간이다. 알고는 있었지만 허투로 알았던 것을 이번 계기에 제대로 다져보도록 한다.

💡 JS는 컴파일 언어다

컴파일에는 세 단계가 있다.


  1. 토크나이징/렉싱
  • 문자열 소스코드를 토큰이라 불리는 의미 조각들로 쪼개는 작업
  • 예를 들어 var a = 1; 이라는 코드가 있다면 var / a / = / 1 / ; 로 구획하여 저장한다
  1. 파싱
  • 저장된 형태소(토큰)를 기반으로 AST 생성
  • 최상위 노드(변수 선언), 변수명에 해당되는 식별자 노드, 값으로 들어가는 할당식 노드로 전개된다
  1. 코드생성
  • 만들어진 AST를 컴퓨터가 실행 가능한 코드로 변환
  • 즉 트리를 읽으면서 실제 변수를 생성, 메모리를 확보하고 할당값을 저장하는 작업이 수행된다

JS가 선 컴파일, 후 실행된다는 사실을 입증할 수 있는 증거로는 구문 오류, 초기 오류, 호이스팅이 있다

💡 구문 오류

구문오류.js

var greeting = 'hello';
console.log(greeting)
greeting = .'hi'; // SyntaxError: unexpected token.

코드 실행 순서에 의해 hello가 콘솔에 찍힐 거 같지만, 세번째 줄의 오류로 인해 실행 단계로 들어서지 않는다. 분명 콘솔로그 코드까진 문제가 없는데 말이다. 이는 프로그램이 파싱부터 되고 실행되기 때문이다. 파싱 단계에서의 오류.

💡 초기 오류

초기오류.js

console.log('안녕');
say('어떻게 지내', '잘지내지'); // Uncaught SyntaxError
function say(greet, greet) {
"use strict";
console.log(greet);
}

첫번째 콘솔로그가 잘 찍힐 것 같지만, say 함수 실행에서 먼저 오류가 걸린다. 원인은 같은 이름의 파라미터를 써서 그러하다. ECMA 명세서에 따르면 엄격 모드에서 실행되는 모듈 또는 함수는 가이드를 지키지 않을 경우 초기 오류를 발생시킨다. 파싱 단계에서 파라미터가 중복되었는지, 엄격 모드가 실행되는지 미리 알지 못했다면 에러를 띄우지 않고 안녕을 실행시켰을 것이다.

💡 호이스팅

호이스팅.js

function saySomething() {
var greeting = "안녕하세요.";
{
greeting = "안녕";
let greeting = "헬로";
console.log(greeting);
}
}
saySometing(); // ReferenceError: Cannot access before initialization

이 콘솔로그 greeting은 블록스코프의 greeting을 우선 참조한다. 순서대로 보면 함수스코프의 greeting을 재할당할 것 같은데 그렇지 않다. 이유는 let greeting이 호이스팅 되어 블록 스코프의 상단으로 끌어올려지기 때문이다. 이후 선언과 할당이 같이 일어난 지점, 즉 초기화가 되는 시점까지 이 변수는 잠금에 걸린다. 하지만 잠금에 걸린 변수에 할당을 하려 시도하여 여기선 TDZ 오류가 난다. 만약 호이스팅이 컴파일 타임에 일어나지 않았다면 TDZ 에러를 발생시키지 않았을 것이다.

💡 런타임 스코프

일반적으로 스코프는 컴파일 타임에 결정되고 런타임 환경과는 무관하다. 하지만 런타임에서도 스코프를 수정하는 방법이 있기는 하다. 비엄격모드에서 eval()이나 with를 사용하는 것이다.

eval은 문자열 형태의 코드를 받는데, 런타임에서 이 코드를 실행시킨다.

eval

function DontDoThis(){
eval("const BAD = 'this!';");
console.log(BAD);
}
DontDoThis(); // this!

이 코드에선 eval 안에 선언한 변수 BAD가 그대로 함수 스코프에 선언된 변수처럼 기능한다. 하지만 런타임에서 스코프를 작위적으로 조작하는 것은 결코 권장되는 일이 아니다. 일단 성능 때문인데, 컴파일 최적화를 마친 스코프를 다시 수정하는 것이기 때문에 CPU 자원을 불필요하게 쓰게 된다.

with 역시 비슷하다. with는 리터럴 객체를 스코프로 만드는 임의적인 장치다.

with

const obj = {age: 20}
with (obj) {
console.log(age); // 20;
}

스코프 내에서 객체의 key가 변수의 식별자처럼, value가 할당값처럼 기능하는 것을 볼 수 있다. 허나 이 역시 성능과 가독성 모두에서 좋지 못하니 사용하지 않는다. 동료 개발자들이 코드에서 동적 스코프가 있는지 없는지 매번 신경써야 한다면 엄청난 혼란과 스트레스일 것이다. 다행히 두 방법 모두 엄격 모드에서는 사용이 불가능하니, 엄격 모드가 디폴트인 요즘 환경에서는 마주할 일이 많지는 않을 것이다.

💡 렉시컬 스코프 정리

위에서 본 예외, 즉 런타임에 조정되는 스코프를 제외하고 컴파일 타임에 결정되는 스코프를 렉시컬 스코프라고 한다. 이 렉시컬이란 단어는 컴파일의 첫 단계인 렉싱과 관련이 있다. 렉시컬 스코프의 핵심은 함수나 블록, 변수 선언의 스코프는 전적으로 코드 배치에 따라 제한된다는 점이다. 코드 배치에 따라 스코프의 위계도 결정된다. 만약 현재 스코프에서 변수 참조를 찾지 못할 경우, 그 상위 스코프로 계속해서 이동하며 해당 변수를 찾으며, 이를 스코프 체인이라 부른다.

프로그램이 실행되는 동안 엔진은 변수가 속한 스코프 정보를 이미 알고 있다. 때문에 변수가 어떤 스코프에서 왔는지 파악하기 위해 여러 스코프를 탐색할 필요가 없다. 런타임에 탐색할 필요가 없다는 점은 최적화 관점에서 렉시컬 스코프가 가져다주는 중요한 혜택이다. 변수 탐색에 시간을 쓰지 않아도 되기 때문에 좀 더 효율적으로 작동할 수 있다.

컴파일은 프로그램 실행에 이점을 줄 이러한 지도, 즉 렉시컬 스코프를 그리기만 할 뿐 메모리 예약 관점에서는 아무것도 하지 않는다. 하지만 그려진 정적인 정보를 바탕으로 런타임시 메모리 할당이 빠르게 처리될 수 있다.

💡 스코프 노출 제한

소프트웨어 보안 분야에는 최소 권한의 원칙(POLP, principle of least privilege)이라는 게 존재한다. 쉽게 말해 프로그램 각각의 구성이 최소한의 권한, 최소한의 접근, 최소한의 노출을 가져야 한다는 원칙이다. 각각의 구성이 독립적일수록 그만큼 한 구성의 버그가 전체 시스템에 끼치는 악영향을 최소화할 수 있기 때문이다. 이 방식을 조금 낮은 수준에서 스코프가 상호작용하는 방식에 적용할 수 있을 것이다. 이때 노출을 최소화하고 싶은 항목은 바로 변수, 즉 스코프마다 등록된 변수의 노출이다. 전역객체를 오염시키지 말아야 한다는 정언 또한 이런 원칙에 기반한다고 볼 수 있다.

어떤 방법들이 있을까?

💡 함수 스코프에 숨기기

스코프의 범위는 함수 아니면 블록이다. 첫번째 방식은 함수를 이용하는 것이다. var 변수와 함수 선언을 관리하고자 할 때는 함수 스코프를 이용하면 된다.

그런데 함수의 작동이 효율을 위해 외부의 캐시를 필요로 하는 경우 cache는 함수 바깥에 정의돼야 할 것이다. 그런데 이 cache가 위치하는 곳이 전역 스코프라면? 전역 스코프를 오염시키지 말아야 한다는 선배들의 원칙에 위배된다. 이럴 때 해법은 간단하다. 그 cache까지 포함하는 하나의 함수를 덧씌우는 것이다.

그런데 뒤에서 보겠지만 이게 바로 클로저의 모습이다. 변수의 노출을 막기 위해 함수 스코프를 중첩하고 캡슐화시킨 것, 이게 바로 클로저의 원초적 형태다.

💡 블록 스코프에 숨기기

반면 let/const를 사용할 땐 블록 스코프를 이용할 수 있다. 하지만 모든 중괄호 쌍이 블록 스코프를 생성하는 건 아니고, 유효한 변수 선언이 있을 때만 스코프를 형성한다. 가령

  • 객체 리터럴은 키/값 목록으로 구성되지만 이러한 오브젝트가 스코프는 아니다
  • 클래스 역시 중괄호를 사용해 정의가 이루어지지만 이는 블록이나 스코프가 아니다
  • switch 구문에 사용된 중괄호로는 블록이나 스코프를 정의할 수 없다
  • 함수 역시 바디를 중괄호로 감싸지만 이는 블록이 아니라 함수 스코프를 형성한다

변수의 범위를 좁힐 때 블록을 사용하는 것은 대부분 프로그래밍 언어의 일반적인 패턴이라고 한다. JS 역시 기능적으로 그럴 수 있고, 하지 말아야 할 리스크도 딱히 없다. 코드가 짧을 경우 어디에 변수를 선언하는지가 그리 중요하지 않을 수 있지만 길어질수록 제한할 필요가 생긴다. 가시적으로도, TDZ를 피하는 데도 이점이 있을 것이다. 식별자가 노출될 수 있는 범위를 최소한으로 줄이려면 명시적 블록 스코프를 습관화하는 것이 좋을 듯싶다.




💡 클로저

클로저는 함수에서만 일어나는 함수의 동작이다. 객체는 클로저를 가질 수 없고 클래스도 클로저를 가질 수 없다. 클로저는 중첩된 함수의 스코프를 이용하는 기술이기 때문이다. 중첩된 함수 스코프에서 내부 함수가 외부 스코프에 있는 변수를 참조하는 것이 클로저다.

사실 클로저의 외부 스코프는 일반적으로 함수에서 유래하지만 반드시 함수 스코프일 필요는 없다. 내부 함수를 감싸는 블록 스코프로도 클로저를 만들 수 있다. 또한 클래스도 클로저를 가질 수 있는데, #을 달아 변수를 비공개로 만들어 접근을 차단하면 된다.1

내부 함수가 외부 함수 스코프에 있는 변수를 사용하고 있을 경우, 외부 함수의 호출이 실행되고 종료된 후에도 해당 스코프는 살아 있다. 만약 클로저가 없었다면 해당 스코프는 파괴되고 참조되는 변수는 가비지 컬렉션의 대상이 되어 메모리에서 제거되었을 것이다.

외부 함수에 전달되는 파라미터도 외부 스코프의 변수이므로 클로저로 작동한다. 이 말은 클로저가 정적이기보다 함수 인스턴스에 따라 다르게 생성된다는 것을 의미한다. 물론 클로저는 렉시컬 스코프에 기반을 두고 컴파일시 처리되지만, 실제로 클로저의 동작은 실행 시점에 함수 인스턴스에 따라 달라지는 특성이라 할 수 있겠다.

💡 라이브 링크

클로저에 대한 오해 중 하나는 클로저가 외부 함수에 정의된 변수의 순간 상태를 기록한 스냅숏이라고 착각한다는 것이다. 하지만 클로저는 실시간으로 변수 자체에 접근하는 라이브 링크다. 클로저가 저장하는 것은 값이 아니라 변수 자체인 것이다. 때문에 값을 읽는 것뿐 아니라 수정할 수도 있다. 클로저가 강력한 이유다.

방법은 간단하다. 반환되는 내부 함수가 외부 스코프 변수의 setter로 작동하면 된다. 그리고 이를 확장하면 클래식 모듈이 된다. 클래식 모듈은 public API 객체로 일력의 함수 목록을 반환하고, 각각의 함수는 비공개 변수를 참조한다는 점에서 클로저다.

💡 클로저가 아닌 경우

클로저처럼 보이지만 클로저가 아닌 경우가 있다.

  • 동일한 스코프에서 변수와 함수 호출이 이뤄진 경우
  • 참조하는 변수가 전역 객체에 놓여 있는 경우
  • 외부 스코프의 변수를 참조하지 않는 경우
  • 내부 함수를 호출하지 않는 경우

즉 클로저는 내부 함수가 실제로 호출되고, 외부 스코프의 변수를 사용하고 있을 때만 관찰된다. 혹은 이렇게 말해보자. 클로저는 함수가 외부 스코프의 변수를 사용하면서, 그 변수에 접근 가능하지 않은 다른 스코프에서 실행될 때 관찰된다. 여기서 포인트는 다음이다.

  • 반환되는 함수가 반드시 있어야 한다
  • 이 함수는 외부 스코프의 변수를(함수 스코프든 블록 스코프든) 적어도 하나 이상 참조해야 한다
  • 참조하려는 변수에 접근 불가능한, 다른 바깥 분기(스코프)에서 함수를 호출해야 한다

💡 클로저의 생명주기

클로저는 함수 인스턴스와 연결되므로 이 함수를 참조하는 함수가 있는 한, 변수에 대한 클로저는 지속된다. 이런 클로저의 특성은 효율적일 수 있지만, 또한 변수의 GC를 막아 메모리 사용을 급증시키는 요인이 될 수 있다. 그래서 더 이상 필요하지 않은 함수 참조(그에 따른 클로저)는 제때 삭제하는 게 중요하다.

현대 JS 엔진 상당수는 클로저를 최적화해서 실제로 함수 내에서 참조되는 변수만을 클로저에 남긴다. 이는 클로저 스코프의 크기를 줄이고 필요하지 않은 변수를 제공해 메모리 사용량을 줄인다. 이로 인해 최적화 결과가 마치 변수별로 이뤄진 것처럼 보이게 한다.

하지만 이는 JS 엔진의 선택사항일 뿐, 최적화가 자동으로 될 거라고 과신하면 안 된다. 객체나 배열처럼 큰 값을 가진 변수가 있고 해당 변수가 클로저 스코프에 있는 경우, 해당 값이 더 이상 필요하지 않고 메모리도 필요하지 않다면 메모리 사용량 측면에서 수동으로 값을 버리는 쪽이 안전하다. 클로저 최적화에 의존하지 말 것.

물론 변수의 값을 null로 할당한다고 해서 변수가 바로 없어지는 것은 아니다. 클로저 스코프는 우리가 통제할 수 없다. 다만 이렇게 하면 GC의 대상이 될 수 있다. 필요 이상으로 메모리를 점유하지 않도록 명시적으로 값을 해체하는 것은 번거롭지만 좋은 습관일 수 있다.


클로저의 지속으로 얻는 이점

  • 함수 인스턴스가 이전에 저장/수정된 정보를 기억해내 함수의 효율성이 올라간다
  • 함수 인스턴스 안에 변수를 캡슐화해 코드 가독성을 향상시키고, 변수의 노출을 제한하는 동시에 사용한다. 함수를 호출할 때마다 새로운 정보를 전달할 필요가 없기에 상호작용하기가 더 쉬워진다.

💡 클로저에 대한 다른 관점

교과서적인 내용을 한번 짚어보자: "함수는 일급 객체로서 다른 함수에 넘기거나 그로부터 반환될 수 있다". 여기서 출발하여, 반환된 내부 함수는 그를 둘러쌌던 외부 함수의 스코프 환경을 기억/접근한다는 게 클로저를 이해하는 일반적인 길이다. 그런데 관점을 바꿔 일급값으로서의 함수를 강조하는 대신, 함수는 다른 비원싯값과 마찬가지로 참조에 의해 저장되고 참조/복사를 통해 할당/전달된다는 점을 중심 삼아 보자. 이 관점을 적용하면 반환된 내부 함수 인스턴스를 호출하는 것은 실은 그 주소에 남아 있는 함수를 실행하는 것에 불과하다. 즉 외부 함수를 깐 순간, 전달되는 것은 함수 인스턴스 자체가 아니라 함수 인스턴스에 대한 참조라는 시각이다. 그러면 함수 인스턴스 자체는 고정된 주소(원래의 렉시컬 자리)에 남아 있어서, 실행되는 순간 자신의 외부 스코프 환경(클로저)에 접근하는 것이 당연한 것처럼 보인다. 이를 이해하는 데는 렉시컬 스코프 규칙 하나면 충분하다.

어느 쪽으로 이해하든 결과는 동일하다. 두 관점 모두 클로저를 이해하는 데 유용하다.

💡 모듈

앞에서 잠깐 언급했지만 모듈은 렉시컬 스코프와 클로저를 기반으로 만들어진다. 표면적으로는 아니지만 ESM 역시 엔진에 의해 함수 모듈로 래핑된다. 모듈은 간단히 말하면 관련된 데이터와 함수의 모음집이다. 데이터(상태)를 저장하며, 해당 정보에 접근하고 업데이트하는 기능을 제공한다.

모듈 패턴이 가진 목적을 달성하려면 단지 상태와 함수를 그룹화하는 것뿐만 아니라 가시성 제어를 통한 통제가 반드시 필요하다. 이때 특정 데이터를 비공개로 하기 위해 사용되는 것이 클로저다. 외부 스코프에서는 접근할 수 없도록 데이터를 클로저로 두는 것이다. 오직 공개적으로 반환하는 API를 통해서만 이 데이터에 접근할 수 있도록 하는 것, 이것이 모튤 패턴의 정신이다.

캡슐화와 최소노출의 원칙
캡슐화의 목표는 정보(데이터)와 동작(함수)을 한데 묶거나 함께 배치해 공통의 목적을 달성하는 것이다. 공통의 목적을 가진 코드 무리를 단일한 파일에 묶는 것도 캡슐화라 할 수 있다. 하지만 설령 데이터와 상태를 가진 함수를 하나로 묶는다 해도, 데이터의 가시성을 제한하지 않으면 캡슐화가 아니다. 이런 경우는 모듈이라는 호칭이 적절하지 않다.

네임스페이스
데이터 없이 관련된 함수를 그룹으로 묶는 것은 모듈에서 말하는 캡슐화가 아니다. 이러한 무상태(무데이터) 함수를 모아놓은 것은 별도로 네임스페이스라고 부른다.

💡 다양한 모듈 패턴

  • IIFE 싱글턴 모듈 IIFE를 사용하여 모듈(함수) 스코프를 조성했다면 그건 프로그램에서 해당 모듈 인스턴스 하나만 필요하다는 뜻이다. 클래스로 구현할 수도 있다.
  • 팩토리 모듈 다중의 인스턴스를 갖게 하려면 IIFE가 아닌 함수 선언식이나 표현식을 사용하여 함수를 정의하면 된다. 클래스로 구현하는 일반적인 방식이다.
  • CommonJS 모듈 클래식 모듈은 직접 함수를 작성하여 만드는 방식을 의미하지만, CommonJS나 ES모듈의 경우 파일을 기반하여 작동한다. 각 파일은 생성시 빈 백지여서 최상위 스코프(전역 스코프)처럼 보이지만 엔진에 의해 하나의 함수 모듈로 래핑된다. 그래서 각 파일마다 자체의 모듈(함수) 스코프를 가지게 된다. CJS 역시 싱글턴으로 작동한다.
  • ES 모듈 CJS와 거의 유사하다. 파일 기반이고 싱글턴으로 작동하며 모든 것은 내보내기 전까지 비공개이다. 큰 차이점이라면 CJS가 동적으로, 파일 어디에서나 혹은 함수 안에서도 불릴 수 있는 반면 ESM은 파일의 최상단에 작성될 것이 요구된다는 점이다. 또한 CJS는 동기적으로 작동하고, ESM은 동기/비동기 로드를 모두 지원한다.

엔진의 모듈 래핑

아래처럼 작성된 ESM 코드는


// example.mjs
const message = "Hello, world!";
function greet(name) {
return `Hello, ${name}!`;
}
export { message, greet };

엔진에 의해 함수 스코프로 감싸져 다음과 같이 처리된다


// Transformed version of example.mjs by the engine
(function (exports, require, module, __filename, __dirname) {
const message = "Hello, world!";
function greet(name) {
return `Hello, ${name}!`;
}
module.exports = { message, greet };
});




참고링크

Footnotes

  1. Emulate the privacy of closure