Logo

[자바스크립트] 클로저(closure) 바로알기

자바스크립트로 코딩을 하다보면 한 번쯤 듣게 되는 용어가 클로저(closure)입니다. 기술 면접 같은데서 자주 물어보는 질문이기도 한데요. 이번 포스팅에서는 이 알쏭달쏭한 클로저에 대해서 한 번 얘기해보려고 합니다.

클로저란?

클로저에 대해서 얘기를 하려면 일단 클로저가 도대체 어떤 개념인지에 알아야겠죠? 클로저는 일반적으로 어떤 함수가 자신의 내부가 아닌 외부에서 선언된 변수에 접근하는 것을 뜻합니다.

다음과 같이 미국 달러를 대한민국 원으로 환전해주는 간단한 함수를 예를 들어 설명해보겠습니다. 이 함수는 미국 달러(usd)를 인자로 받아서 함수 내부에 선언된 환율(rate)을 이용하여 대한민국 원(krw)으로 환전한 결과를 반환합니다.

function convertUsdToKrw(dollar) {
  const rate = 1113.5;
  return dollar * rate;
}

인자로 5달러를 넘겨서 이 함수를 호출하면 예상대로 5567,5원이 반환됩니다.

> convertUsdToKrw(5)
5567.5

이번에는 환율(rate)을 함수 외부에 선언하면 어떨까요?

const rate = 1113.5;

function convertUsdToKrw(dollar) {
  return dollar * rate;
}

이 함수를 동일한 인자를 넘겨서 호출해보면 완전히 동일한 결과값이 반환되는 것을 알 수 있습니다.

> convertUsdToKrw(5)
5567.5

이렇게 자바스크립트에서 함수는 매개 변수와 로컬 변수 뿐만 아니라 외부에서 선언된 변수도 자유롭게 접근을 할 수 있습니다. 그리고 이렇게 함수가 자신의 밖에서 선언된 변수에 접근하는 것을 클로저라고 합니다.

클로저의 예

대부분의 자바스크립트 개발자들은 알게 모르게 이미 작성하는 코드의 많은 부분에서 클로저를 사용하고 있을 것입니다. 특히, 어떤 함수 내에서 또 다른 함수를 선언할 때, 알게 모르게 클로저를 자주 사용하게 됩니다.

예를 들어, 여러 개의 미국 달러를 대한민국 원으로 환전해주는 함수를 작성해보겠습니다.

function batchConvertUsdToKrw(dollars) {
  const rate = 1113.5;
  const convertUsdToKrw = (dollar) => dollar * rate;
  return dollars.map(convertUsdToKrw);
}
> batchConvertUsdToKrw([1, 2, 10, 20, 50, 100])
[ 1113.5, 2227, 11135, 22270, 55675, 111350 ]

자바스크립트 배열의 map() 메서드의 인자로 convertUsdToKrw() 함수가 넘어가고 있습니다. 여기서 batchConvertUsdToKrw() 함수의 내부에서 선언된 rate 변수는 convertUsdToKrw() 함수의 입장에서 보면 외부에서 선언이 되어있습니다. 즉, convertUsdToKrw() 함수는 자신의 내부가 아닌 외부에서 선언된 rate 변수에 접근하고 있으므로 정확히 위에서 정의한 클로저라는 것을 알 수 있습니다.

클로저의 특징

좀 억지스러운 예로 다음과 같이 회원 가입을 위한 signUp() 함수를 작성한다고 가정해보겠습니다. 이 함수는 내부적으로 사용자 생성과 알람 전송을 위해서 각각 createUser()sendNotifications() 함수가 정의되어 있고 호출되고 있습니다.

function signUp(username, password, email, phone) {
  const createUser = () => {
    console.log(`${username}${password}를 검증 중...`);
    console.log(`사용자 생성 중...`);
    // DB에 사용자 레코드 저장하는 코드
  };

  const sendNotifications = () => {
    console.log(`${email}로 이메일 전송 중...`);
    console.log(`${phone}로 문자 전송 중...`);
    // 실제로 알람을 전송하는 코드
  };

  createUser();
  sendNotifications();
  console.log("메인 페이지로 이동...");
}
> signUp("user", "1234", "test@test.com", "123-456-7890", "대한민국")
user과 1234를 검증 중...
사용자 생성 중...
test@test.com로 이메일 전송 중...
123-456-7890로 문자 전송 중...
메인 페이지로 이동...

signUp() 함수는 4개의 매개 변수를 받고 있는데, createUser() 함수와 sendNotifications() 함수 입장에서 보면 모두 외부에서 선언된 변수들입니다. createUser() 함수는 외부에서 선언된 username, password 변수에 접근하고 있고, sendNotifications() 함수는 외부에서 선언된 emailphone에 접근하고 있습니다. 다시 말해, signUp() 함수 내부에는 2개의 클로저가 있는 것입니다.

여기서 재미있는 클로저의 특징을 몇가지 찾아볼 수 있는데요. 자세히 살펴보시면 createUser() 함수와 sendNotifications() 함수에 어떤 매개 변수도 필요가 없고, signUp() 함수 내에서 호출할 때 어떤 인자도 넘길 필요가 없습니다. 만약에 이 두 개의 함수를 signUp() 함수 외부로 빼낸다면 어떻게 될까요?

const createUser = (username, password) => {
  console.log(`${username}${password}를 검증 중...`);
  console.log(`사용자 생성 중...`);
};

const sendNotifications = (email, phone) => {
  console.log(`${email}로 이메일 전송 중...`);
  console.log(`${phone}로 문자 전송 중...`);
};

function signUp(username, password, email, phone) {
  createUser(username, password);
  sendNotifications(email, phone);
  console.log("메인 페이지로 이동...");
}

위와 같이 createUser() 함수와 sendNotifications() 함수에는 매개 변수가 필요하게 되고, signUp() 함수 내에서 호출할 때 인자를 넘겨줘야 합니다. 이와 같이 클로저를 활용하면 어떤 함수 내부에서만 사용되는 일회성 함수(정의 후 바로 호출되는)의 매개 변수를 생략할 수 있습니다.

클로저의 부작용

위에서 살펴본 클로저의 특징은 과용하거나 오용하게 되면 오히려 코드 품질 측면에서 부정적인 영향을 미칠 수 있습니다. 왜냐하면 클로저가 많아지게 되면 코드가 읽거나 고치기가 어려워지고 버그가 발생하기 쉬워지기 때문입니다.

예를 들어, 이 전에 작성한 batchConvertUsdToKrw() 함수 내부에 선언되어 있던 rate 변수를 밖으로 let 키워드를 사용하여 빼내어 보겠습니다.

let rate = 1113.5;

function batchConvertUsdToKrw(dollars) {
  const convertUsdToKrw = (dollar) => dollar * rate;
  return dollars.map(convertUsdToKrw);
}

이렇게 되면 rate 변수로 인해서 batchConvertUsdToKrw() 함수에게도 클로저가 생기고, convertUsdToKrw() 함수 입장에서는 중첩 클로저가 생깁니다. 이 함수가 짧으니까 망정이지 매우 긴 함수였다면 rate 변수의 출저가 어디인지 알아내려면 두 겹의 함수 네임 스페이스를 뒤지느라 곤혹 스러울 것입니다.

뿐만 아니라, let 키워드를 사용해서 rate 변수를 선언하였기 때문에, rate 변수에 할당된 값을 batchConvertUsdToKrw() 함수 외부에서 자유롭게 바꿀 수가 있습니다.

> batchConvertUsdToKrw([1, 5])
[ 1113.5, 5567.5 ]
> rate = 100
100
> batchConvertUsdToKrw([1, 5])
[ 100, 500 ]

이러한 문제는 심각한 버그로 이어질 수 있어서 값이 바뀔 수 있는 외부 변수에 접근할 때는 각별히 주의해야 합니다.

특히, 이러한 버그는 비동기 처리 시에 발생할 확률이 더욱 높아집니다. 아래 프로그램을 실행해보면, rate 변수에 중간에 할당된 값은 무시되고 제일 마지막에 할당한 값이 계속해서 출력되는 것을 볼 수 있는데요. setTimeout() 함수 때문에 콘솔에 출력되는 시점이 1초씩 지연되기 때문에 발생하는 현상입니다.

let rate = 1113.5;

function printRate() {
  setTimeout(() => console.log(`현재 미달러 환율은 ${rate}원 입니다.`), 1000);
}

printRate(); // 현재 미달러 환율은 500원 입니다.

rate = 100;
printRate(); // 현재 미달러 환율은 500원 입니다.

rate = 500;
printRate(); // 현재 미달러 환율은 500원 입니다.

클로저 피하기

중접 클로저를 피하는 간단한 방법은 convertUsdToKrw() 함수를 batchConvertUsdToKrw() 함수 밖으로 빼주어 최대한 클로저가 중첩되지 않도록 해주는 것입니다.

let rate = 1113.5;

const convertUsdToKrw = (dollar) => dollar * rate;

function batchConvertUsdToKrw(dollars) {
  return dollars.map(convertUsdToKrw);
}

이렇게 내부에서 정의된 함수를 외부로 빼면 이 함수에 대해서도 단위 테스트를 작성할 수 있으며, 이 함수는 batchConvertUsdToKrw() 함수를 벗어나 다른 곳에서도 호출이 가능해집니다. (이 부분은 상황에 따라서 장점이 될 수고 있고 단점이 될 수도 있습니다.)

한 발짝 더 나아가, 아예 ratebatchConvertUsdToKrw() 함수의 매개 변수로 넣어주면, rate 변수의 값이 외부에서 수정될 수 있는 문제를 근본적으로 예방할 수 있습니다.

function batchConvertUsdToKrw(dollars, rate) {
  const convertUsdToKrw = (dollar) => dollar * rate;
  return dollars.map(convertUsdToKrw);
}

이제는 batchConvertUsdToKrw() 함수를 호출할 때, 항상 명시적으로 rate 인자를 넘겨줘야 하기 때문입니다.

> batchConvertUsdToKrw([1, 5], 1113.5)
[ 1113.5, 5567.5 ]
> batchConvertUsdToKrw([1, 5], 100)
[ 100, 500 ]

마치면서

사실 클로저는 자바스크립트만 국한된 개념이 아니며 다른 많은 프로그래밍 언어에서도 존재하는 개념입니다. 그럼에도 불구하고 특히 자바스크립트 커뮤니티에서 클로저가 많은 거론되는 이유는 함수를 마치 일반 값처럼 다룰 수 있는 자바스크립트의 유연함 때문일 것입니다. 어떻게 사용하느냐에 따라서 양날의 검이 될 수 있는 클로저에 대한 개념을 잡으시는데 도움이 되었으면 좋겠습니다.