면접관 앞에서 클로저 (closure)를 왜 사용하는지와 주의사항에 대해 말해보자

조민호·어제
3

Javascript에서 가장 중요한 개념 중 하나인 클로저(closure) 에 대해 깊게 알아보자.
면접관의 꼬리질문에도 당황하지 않을 수 있도록!


면접관: 클로저에 대해 설명해 주시겠어요?
???: 내부 함수가 외부 함수의.... 이렇고 저렇고... 입니다
면접관: 그러면 클로저는 왜 사용하나요?
???: ....
면접관: 클로저를 사용할때 주의해야 할 사항에 대해 아는대로 말해주세요
???: ....


클로저 주의사항

  1. 클로저에서 ‘호출 형태’나 ‘호출 위치’는 의미가 없다.

    '선언 위치'에 따른 그 당시 렉시컬 스코프의 환경레코드를 계속 참조하는 것이다.
    스코프체인 역시 참조하는 환경레코드를 기반으로 발생한다

  2. 외부 함수의 생명 주기가 내부 함수의 생명 주기보다 짧아야 한다.

    클로저는 함수 선언 당시의 렉시컬 스코프에 있는 환경레코드를 계속 참조할 수 있는 것이다.

    그러나, 외부 함수의 생명주기가 여전히 살아있다면
    즉, 클로저에서 외부 환경 참조를 통한 값을 사용하는게 아니라 외부 함수의 환경레코드에서 곧바로 사용하게 된다.

    글만 봐서는 이해가 안 될텐데, 아래 예시들을 통해 알아보자.

    예시 1

    아래 코드를 실행해보면 0, 1, 2 를 출력하는게 아니라 3, 3, 3이 출력된다

      for (var i = 0; i < 3; i++) {
        setTimeout(function () {
          console.log(i); // 3 3 3
        }, 1000);
      }

    그 이유는 아래와 같다.

    • setTimeout콜백은 내부 함수이며, 외부 스코프는 for라는 블록스코프이다.
      즉, setTimeout콜백은 클로저이다.

    • 1초가 지난 뒤 콜백이 실행되고 i를 출력한다. 그러나 i는 var로 선언된 변수이다.
      var는 함수 스코프를 가지므로 이 상황에서는 전역변수로 동작한다.

      그러므로 비동기 콜백 실행 당시에도 외부 스코프의 변수인 var는 여전히 참조가 가능한 상황이며 해당 변수 i는 3으로 할당이 된 상태이다.
      그래서 3으로 출력된다

    비슷한 원리로 아래의 코드 역시 마찬가지로 동작한다.

    의도적으로 let을 사용하여 i를 전역변수로 만든 것이다

      let i;
      for (i = 0; i < 3; i++) {
        setTimeout(function () {
          console.log(i); // 3 3 3
        }, 1000);
      }

    예시 2

    var list = [1, 2, 3, 4, 5];
    var list2 = [];
    var item;
    
    for (var i = 0; i < list.length; i++) {
     	item = list[i];
     	const newFunc = function () {
    	  console.log(item);
    	};
    	list2.push(newFunc);
    }
    
    for (var i = 0; i < list2.length; i++) {
     const func = list2[i];
     func();
    }
  • newFunc를 선언할 때 렉시컬 스코프에서 item변수를 참조한다

    (item = list[i]; 부분으로 인해 item변수를 참조)

    💡 item변수의 값을 고정해서 캡처하는게 아니라,
    앞으로 외부에서도 item변수를 계속 참조를 할 수 있는 것이다

  • 함수를 func로 받아서 호출한다

    함수 내부에 존재하는 item은 var로 되어있다.

    그러므로 함수 실행 당시에도 계속 참조가 가능한 상황이며 이때 item은 5로 할당이 된 상태이다.

    그래서 5가 출력된다

  • 해결책

    var list = [1, 2, 3, 4, 5];
    var list2 = [];
    
    for (let i = 0; i < list.length; i++) {
      let item = list[i]; // let을 사용하여 블록 스코프를 만듦
      const newFunc = function () {
        console.log(item);
      };
      list2.push(newFunc);
    }
    
    for (let i = 0; i < list2.length; i++) {
      const func = list2[i];
      func(); 
    }

    클로저로 참조된 item은 let이다.

    • 각 for문 루프가 끝날 때마다, item은 유효범위가 끝나면서 더이상 참조할 수 없다. 오직 newFunc 클로저에서만 참조가 가능하게 된 것이다 즉, item 변수는 각각의 list[i]값을 가진 상태에서 참조가 끝나버린 것이다
    • 이후 func를 호출할 때,

      각 func 내부에 있는 item은 현재 스코프에서 다른 값으로 계속 참조가 가능한 상황이 아니다.

      그래서 클로저 원리로 기존에 참조하고 있던 환경레코드의 item값을 각각 호출하는 것이다


클로저를 왜 사용할까?

만약 자바스크립트에 클로저라는 기능이 없다면 상태를 유지하기 위해 전역 변수를 사용할 수 밖에 없다.
전역 변수는 언제든지 누구나 접근할 수 있고 변경할 수 있기 때문에 많은 부작용을 유발해 오류의 원인이 되므로 사용을 억제해야 한다.


❗️장점1. 정보의 접근 제한 (캡슐화)

클로저의 핵심은 스코프를 이용해서, 변수의 접근 범위를 닫는(폐쇄)것에 있다.

  • 외부함수 스코프 에서 ⇒ 내부함수 스코프로 : 접근 불가
  • 내부함수 스코프 에서 ⇒ 외부함수 스코프로 : 접근이 기능 따라서 내부 함수는 외부함수에 선언된 변수에 접근 가능하다.

이러한 특성 때문에 전역변수를 사용하지 않으려는 목적을 달성할 수 있다.

아무 곳에서 나 접근해서 들여다보고 변경까지 할 수 있는 전역변수를 사용하는 것은 위험하므로

클로저를 사용하면 전역변수를 사용하지 않으면서도 클로저 함수 내부의 변수에 계속 접근 할 수 있기때문에 이런 문제가 해결된다.

예시 1

  1. 전역 변수로 사용 (bad)

    let foo = 1;
    function add() {
      foo = foo + 1;
      return foo;
    }
    
    console.log(add()); // 2
    console.log(add()); // 3
    console.log(add()); // 4

    우리가 의도한대로 동작하나, foo 가 전역 스코프에 있기 때문에 중간에 값이 조작될 수 있다는 문제점이 존재한다

  2. 지역 변수로 사용 (bad)

    function add() {
      let foo = 1;
      foo = foo + 1;
      return foo;
    }
    
    console.log(add()); // 2
    console.log(add()); // 2
    console.log(add()); // 2

    foo가 전역 스코프에 존재하지 않으므로 안전하다. 그렇지만 항상 2를 반환하게 된다.

<br >
  1. 클로저 적용 (good)

    우리의 의도와 맞으면서도 foo는 전역 스코프에 존재하지 않는다.

    이것이 클로저를 활용한 예제이다. 

    • getAdd() 함수에서 반환되는 함수는 자신만의 렉시컬 환경을 가지게 되고 이곳에는 
      foo = 1 에 대한 정보가 기록된다.
    • 이 함수는 add 라는 변수에 할당되고 이후에 add 함수가 호출될 때마다 렉시컬 환경에서 foo 에 대한 정보를 가져오게 된다.

      앞으로 add라는 함수에 할당된 foo라는 변수는 최초 렉시컬 환경에 저장된 이 변수를 지속적으로 참조할 수 있는 것이다

    function getAdd() {
      let foo = 1;
      return function () {
        foo = foo + 1;
        return foo;
      };
    }
    
    const add = getAdd();
    console.log(add()); // 2
    console.log(add()); // 3
    console.log(add()); // 4

    클로저는 함수 자신만이 가진 렉시컬 환경을 참조 혹은 수정할 수 있다

    즉, 함수는 클로저를 통해 private value를 만들어 사용할 수 있다.

    즉시실행함수를 이용하여 깔끔하게 리팩토링도 가능하다.

    const add = (function () {
      let foo = 1;
      return function () {
        foo = foo + 1;
        return foo;
      };
    })();
    
    console.log(add()); // 2
    console.log(add()); // 3
    console.log(add()); // 4

예시 2

이미지 출처: https://ts2ree.tistory.com/235


  1. 즉시실행함수는 클로저를 반환하고 종료된다

  2. 클로저인 이 함수는 자신이 선언됐을 때의 렉시컬 환경인 즉시실행함수의 스코프에 속한 지역변수 counter를 기억한다.

    따라서 즉시실행함수의 변수 counter에 접근할 수 있고 변수 counter는 자신을 참조하는 함수가 소멸될 때가지 유지된다.

즉시실행함수는 한번만 실행되므로 increase가 호출될 때마다 변수 counter가 재차 초기화될 일은 없을 것이다.

변수 counter는 외부에서 직접 접근할 수 없는 private 변수이므로 전역 변수를 사용했을 때와 같이 의도되지 않은 변경을 걱정할 필요도 없기 때문이 보다 안정적인 프로그래밍이 가능하다.


❗️장점2. 데이터를 보존할 수 있다.

클로저 함수는 외부 함수의 실행이 끝나더라도 외부 함수 내 변수를 사용할 수 있다.

클로저는 이처럼 특정 데이터를 스코프 안에 가두어 둔 채로 계속 사용할 수 있게하는 폐쇄성을 갖는다.

예시 1

  const fruits = ['apple', 'banana', 'peach'];
  const ul = document.createElement('ul');

  fruits.forEach(function (fruit) { // (A)
    const li = document.createElement('li');
    li.innerText = fruit;
    li.addEventListener('click', function () { 
      console.log('your choice is' + fruit); // (B)
    });
    ul.appendChild(li);
  });
  document.body.appendChild(ul);
  • 외부함수 (A): forEach의 콜백
  • 내부함수 (B): 이벤트 리스너

    렉시컬 스코프(A)의 인자인 fruit를 참조

이후 외부 함수가 종료되더라도 클로저인 이벤트 리스너에서는 계속해서 fruit값을 사용할 수 있게 된다



리액트에서는 아래와 같이 클로저를 활용할 수 있다

  const alertFruit = (fruit){
    console.log(`your choice is ${fruit}`);
  }
  
  ...
  
  fruits.forEach((fruit)=>{
    <button onClick={()=>alertFruit(fruit)}>버튼</button>
  })
  • 외부 함수 : forEach의 콜백
  • 내부 함수 : ()=>alertFruit(fruit)

내부함수에서 외부함수의 fruit를 참조



예시 2

클로저의 특징을 활용하여 현재 상태를 기억하고 변경된 최신 상태를 유지 할 수도 있다

이미지 출처: https://ts2ree.tistory.com/235


  1. 즉시실행함수는 먼저 실행되고, 함수를 반환하고 즉시 소멸한다.

    즉시실행함수가 반환한 함수는 isShow를 참조하는 클로저다.

  2. 클로저를 이벤트 핸들러로서 이벤트 프로퍼티에 할당했다.

    이벤트 핸들러인 클로저를 직접 제거하지 않는 한 클로저가 기억하는 렉시컬 환경의 변수 isShow는 소멸하지 않는다. 다시 말해 현재 상태를 기억한다.

  3. 버튼을 클릭하면 이벤트 프로퍼티에 할당한 이벤트 핸들러인 클로저가 호출된다.

    이때 .box 요소의 표시 상태를 나타내는 변수 isShow의 값이 변경된다.

    변수 isShow는 클로저에 의해 참조되고 있기 때문에 유효하며 자신의 변경된 최신 상태를 게속해서 유지한다.

이처럼 클로저는 현재 상태(위 예제의 경우 .box 요소의 표시 상태를 나타내는 isShow 변수)를 기억하고 이 상태가 변경되어도 최신 상태를 유지해야 하는 상황에 매우 유용하다.


❗️장점3. 모듈화에 유리하다.

클로저 함수를 각각의 변수에 할당하면 각자 독립적으로 값을 사용하고 보존할 수 있다.

이와 같이 함수의 재사용성을 극대화 함수 하나를 독립적인 부품의 형태로 분리하는 것을 모듈화라고한다.

클로저를 통해 데이터와 메소드를 묶어다닐 수 있기에 클로저는 모듈화에 유리하다.

post-custom-banner

2개의 댓글

comment-user-thumbnail
어제

좋은 내용 감사합니다 :)

1개의 답글