[모던JS: Core] 함수 심화 (2)

KG·2021년 5월 15일
0

모던JS

목록 보기
8/47
post-thumbnail

Intro

본 포스팅은 여기에 올라온 게시글을 바탕으로 작성되었습니다.
파트와 카테고리 동일한 순서로 모든 내용을 소개하는 것이 아닌, 몰랐거나 새로운 내용 위주로 다시 정리하여 개인공부 목적으로 작성합니다.
중간중간 개인 판단 하에 필요하다고 생각될 시, 기존 내용에 추가로 보충되는 내용이 있을 수 있습니다.

변수의 유효범위

자바스크립트는 함수 지향 언어이다. 함수를 동적으로 생성하거나 생성한 함수를 다른 함수의 인수로 넘기고, 생성된 곳이 아닌 다른 곳에서 함수 호출이 가능하기도 하다. 또한 자바스크립트의 함수는 객체형이기도 하다.

함수를 선언함에 있어 어떤 패러다임을 사용하느냐에 따라 흔히 객체 지향형(OOP)인지 함수 지향형(FP)인지 구분하기도 한다. 중요한건 자바스크립트가 함수 지향 언어라고 해서, 객체 지향형 프로그래밍이 불가하다거나 함수 지향 프로그래밍 방식만을 사용해야 하는 것은 아니다.

본론으로 돌아와서, 자바스크립트의 함수는 다른 언어와 달리 독특한 쓰임을 몇 가지 가지고 있다. 먼저 앞서서 외부에서 선언된 변수에 대해 함수 내부에서 접근할 수 있는 것을 살펴보았다. 이때, 함수 생성 이후 외부 변수가 변경되는 경우 함수는 생성 시점 이전의 값을 취급할 지, 아니면 변경된 값을 취급할 지 의문이 있을 수 있다. 따라서 먼저 변수의 유효범위에 대해 먼저 간략히 살펴보자.

여기서 다루는 변수의 유효범위는 var 키워드를 제외한 letconst 키워드로 선언된 변수만을 말한다.

1) 코드 블록

코드 블록 { ... } 안에서 선언한 변수는 블록 안에서만 사용 가능하다. 이때 for문의 경우 반복 조건에 대한 괄호 안 값들은 코드 블록 밖에 위치하지만 동일한 코드 블록에 위치하는 것으로 간주한다.

{
  let message = 'hello';
  console.log(message);	// 'hello'
}

console.log(message);	// Error: 유효하지 않은 범위

if (true) {
  let message = 'hello';
  console.log(message);	// 'hello'
}

console.log(message);	// Error: 유효하지 않은 범위

// for (...) 괄호 안 변수는 코드 블록 내부로 취급
for (let i = 0; i < 10; i++) {
  console.log(i);
}

console.log(i);	// Error: 유효하지 않은 범위

중첩함수

함수 내부에서 선언된 함수를 중첩 함수라고 부른다. 자바스크립트에선 중첩 함수를 쉽게 만들 수 있다. 이런 중첩함수는 메인 함수에 부가적인 기능을 돕는 역할로 많이 사용되곤 한다.

function sayHi (firstName, lastName) {
  function getFullName() {
    return firstName + ' ' + lastName;
  }
  
  console.log('hi', getFullName());
}

이러한 중첩함수는 함수 그 자체로 반환될 수 있다. 자바스크립트에서 함수는 객체형인 값이기 때문이다. 이렇게 반환된 함수는 어디서든 호출하여 사용할 수 있고, 또한 이때도 동일하게 외부 변수에 접근할 수 있다.

function makeCounter () {
  let count = 0;
  
  return function () {
    return count++;
  };
}

const counter = makeCounter();	// 함수를 반환
// 호출 시 마다 count 값이 1씩 증가
counter();	// 0
counter();	// 1
counter();	// 2

이때 여러 변수에 MakeCounter()를 통해 함수를 할당하면 이들 중첩함수는 각각 독립적이며, 따라서 count 값 역시 공유하지 않고 각각의 값을 가지게 된다. 이러한 현상을 좀 더 자세히 살펴보자.

렉시컬 환경

자바스크립트에서 다음 목록은 렉시컬 환경(Lexical Environment)라고 불리는 내부 숨김 연관 객체(Internal hidden associated object)를 갖는다.

  • 실행중인 함수
  • 코드 블록 { ... }
  • 스크립트 전체

이러한 렉시컬 환경 객체는 두 부분으로 구성된다.

  1. 환경레코드(Environment Record) : 모든 지역 변수를 프로퍼티로 저장하고 있는 객체. this 값과 같은 기타 정보도 이곳에 저장된다.

    this 는 따로 지정되지 않으면 브라우저에서는 window객체를 할당한다.

  2. 외부 렉시컬 환경(Outer Lexical Environment)에 대한 참조 : 외부 코드에 대한 정보 저장

렉시컬 환경은 명세서에만 존재하는 이론상의 객체이다. 따라서 코드를 이용해 직접 렉시컬 환경을 얻거나 조작은 불가하다.

1) 변수

변수는 특수 내부 객체인 환경레코드의 프로퍼티에 속한다. 즉 변수에 접근하거나 값을 변경하는 행위는 환경레코드의 프로퍼티에 접근 또는 변경하는 것을 말한다.

다음 코드에서는 단 하나의 렉시컬 환경이 존재한다. 환경레코드는 존재하지만 외부 렉시컬 환경 참조는 존재하지 않는 상태이다. 아래와 같이 스크립트 전체와 관련된 렉시컬 환경은 전역 렉시컬 환경(Global Lexical Environment)라고 부른다.

코드의 흐름이 다음과 같이 이어지게 되면 렉시컬 환경이 그림과 같이 변한다. 여전히 외부 렉시컬 환경 참조는 존재하지 않는 상태이다.

  1. 스크립트 시작 시 스크립트 내 선언한 변수 전체가 렉시컬 환경에 올라간다(pre-populated). 이때 변수의 상태는 특수 내부 상태인 uninitialized가 된다. 자바스크립트 엔진은 해당 상태를 인지할 수는 있지만 let 키워드를 만나기 전까지 참조는 불가하다.
  2. let phrase를 마주하기에 참조가 가능하다. 그러나 할당된 값이 없기에 undefined인 상태이다.
  3. phrase에 값이 할당
  4. phrase의 값을 변경

2) 함수 선언문

함수 역시 값으로 취급된다. 그러나 함수 선언문의 경우는 앞서 살펴본 바와 같이 일반 변수와 달리 바로 초기화가 실행된다. 즉 함수 선언문으로 만든 함수의 경우 렉시컬 환경이 만들어지는 즉시 사용할 수 있다. 이를 보통 호이스팅(hoisting)이라고 부른다. 호이스팅 관련은 다음 파트에서 더 자세히 다뤄보자. 선언되기 전에도 함수 선언문으로 만든 함수를 사용할 수 있는 이유는 바로 위와 같다.

그러나 함수 표현식의 경우에는 호이스팅이 일어나지 않고, 선언 시점 이후에 함수를 호출할 수 있다. 따라서 함수 표현식의 경우엔 위 그림과 달리 uninitialized 상태가 적용될 것이다.

3) 내부와 외부 렉시컬 환경

함수를 호출해 실행하면 새로운 렉시컬 환경이 자동으로 만들어진다. 새로 만들어진 렉시컬 환경은 함수 호출 시 넘겨받은 매개변수와 함수 내 지역변수가 저장된다.

함수 호출 시점에서 새로운 렉시컬 환경이 생성되었고, 이 렉시컬 환경은 외부 참조 렉시컬 환경을 가지고 있다. 새로운 렉시컬 환경을 내부 렉시컬 환경으로 보자면, 내부 렉시컬 환경에서는 함수의 인자인 name 관련 프로퍼티만 존재한다. 외부 렉시컬 환경에서는 함수 say와 변수 phrase 프로퍼티가 존재한다.

코드에서 변수에 접근할 땐, 먼저 내부 렉시컬 환경에 접근한다. 만약 내부에서 원하는 값을 찾지 못한다면 검색 범위를 내부 렉시컬 환경이 참조하는 외부 렉시컬 환경으로 확장한다. 값을 찾지 못하는 경우 이 과정을 전역 렉시컬 환경에 닿을때까지 반복한다.

use strict 모드에서는 전역 렉시컬 환경까지 도달했는데도 불구하고 값을 찾지 못한 경우엔 에러가 발생한다. 해당 모드가 아닌 경우에는 에러 대신에 새로운 전역 변수가 만들어지는데, 이는 하위 호환성을 위해 남아있는 기능이라고 한다.

4) 반환 함수

위에서 사례로 들었던 중첩 함수의 예시를 다시 살펴보자.

function makeCounter () {
  let count = 0;
  
  return function () {
    return count++;
  };
}

const counter = makeCounter();

makeCounter()를 호출하면 호출마다 새로운 렉시컬 환경이 만들어진다. 이 렉시컬 환경에는 makeCounter를 실행하기 위해 필요한 변수들이 저장된다.

이때 makeCounter()는 실행 도중에 새로운 중첩 함수를 생성하고 반환한다. 현재 상태는 중첩함수를 생성만하고 실행은 아직 하지 않은 상태이다.

이때 중요한 점은, 모든 함수는 함수가 생성된 곳의 렉시컬 환경을 기억한다는 것이다. 함수는 [[Environment]]라고 불리는 숨김 프로퍼티를 갖는데, 여기에 함수가 만들어진 곳의 렉시컬 환경에 대한 참조가 저장된다.

따라서 makeCounter()의 값을 할당한 변수 countercounter.[[Environment]]를 통해 { count: 0 }이 있는 렉시컬 환경에 대해 접근할 수 있다. 호출 장소와 상관없이 함수가 자신이 태어난 곳을 기억할 수 있는 이유가 바로 [[Environment]] 프로퍼티와 관련이 있다. 이 값은 함수가 생성될 때 딱 한 번 설정되고 그 이후로 영원히 변하지 않는다.

이후 counter()를 호출하면 각 호출마다 새로운 렉시컬 환경이 생성된다. 그리고 이 렉시컬 환경은 counter.[[Environment]]에 저장된 렉시컬 환경을 외부 렉시컬 환경으로 참조하게 된다.

실행 흐름이 중첩 함수 본문으로 넘어오면 해당 함수 내부에서 count에 접근하고 있으므로 이 변수를 먼저 자체 렉시컬 환경에서 찾게 된다. 하지만 현재 렉시컬 환경에서는 지역 변수가 없기 때문에 <empty>인 상태이고 따라서 외부 렉시컬 환경으로 이동한다. 해당 외부 렉시컬 환경에서 count 값을 찾았으므로 더 이상 이동하지 않고 탐색을 마친다. 변수값의 갱신이 일어나는데, 갱신 역시 변수가 저장된 렉시컬 환경에서 이뤄진다.

클로저 (Closure)

클로저는 외부 변수를 기억하고, 이 외부 변수에 접근할 수 있는 함수를 의미한다. 몇몇 언어에서는 클로저를 구현하기 불가능하거나 까다로운 경우가 종종 있지만, 자바스크립트에서는 모든 함수가 자연스럽게 클로저가 된다. (예외 new Function 존재)

자바스크립트의 함수는 숨김 프로퍼티인 [[Environment]]를 이용해 자신이 어디서 만들어졌는지를 기억한다. 또한 함수 내부 코드는 자체 환경에서 찾고자 하는 값이 없다면 [[Environment]]가 참조하는 값을 사용해 외부 변수를 탐색한다.

클로저는 어떤 데이터와 그 데이터를 조작하는 함수를 연관시켜주기 때문에 유용하다. 이는 객체 지향형 프로그래밍에서 객체가 어떤 데이터와 하나 이상의 메소드를 연관시키는 점에서 굉장히 유사한 면모를 보인다.

때문에 클로저를 이용해 메소드를 프라이빗(private) 형식으로 선언하는 방식을 흉내낼 수 있다. 클로저에서 참조하는 값은 내부에서 선언된 지역 변수가 아니고 외부에 있는 일종의 비공개 변수이다. 비공개인 이유는 함수의 지역 변수는 실행중에만 그 값이 메모리에 유지되며, 종료 후에는 외부에서는 이 값에 접근이 불가하기 때문이다. 하지만 클로저를 사용하면 컨텍스트를 통해 접근이 가능하기 때문에 해당 값에 접근할 수 있는 장점이 있다. 따라서 자바스크립트에서 비공개로 값을 숨겨 사용자로부터 보호하기 위한 통제 방법으로 클로저를 사용할 수 있다.

그러나 클로저는 잘못 사용했을 시 성능 및 메모리 문제가 일어날 수 있다. 클로저의 비공개 변수는 자바스크립트 엔진에서 언제 메모리 관리를 해야할 지 알기 어렵기 때문에 메모리 낭비로 이어질 수 있으며, 외부 참조 렉시컬 환경으로 계속 거슬러 올라가는 동작이 있기에 성능적인 문제가 간혹 발생할 수 있다.

가비지 컬렉션

함수 호출이 끝나면 함수에 대응하는 렉시컬 환경이 메모리에서 제거된다. 따라서 함수와 관련된 변수들은 이때 모두 사라지게 된다. 때문에 함수 호출이 끝나면 관련 변수를 참조할 수 없는 것이다. 앞서 살펴본바와 같이 자바스크립트에서 모든 객체는 도달 가능한 상태일 때만 메모리에서 유지된다.

그러나 호출이 끝났음에도 여전히 도달 가능한 중첩 함수가 있을 수 있다. 이때는 중첩함수의 [[Environment]] 프로퍼티에 외부 함수 렉시컬 환경에 대한 정보가 저장된다. 즉 해당 상태에서는 호출이 끝난 함수에 대해서 도달 가능한 상태가 유지되는 것이다. 따라서 함수 호출이 끝났지만 렉시컬 환경에 계속 메모리에 유지가 된다.

때문에 중첩 함수를 반환하는 클로저를 여러 번 생성하는 경우 각각의 렉시컬 환경을 가지게 된다. 아래 예시의 경우 3개의 렉시컬 환경이 만들어지고, 각각의 환경은 메모리에서 삭제되지 않는다. 각각 만들어지기 때문에 메모리 낭비 문제가 발생할 수 있다.

function f () {
  let value = Math.random();
  
  return function () {
    console.log(value);
  };
}

let arr = [ f(), f(), f() ];

렉시컬 환경 객체 역시 다른 객체와 마찬가지로 도달할 수 없을 때 메모리에서 제거된다. 따라서 아래와 같이 중첩 함수가 메모리에서 삭제되고 난 후에야, 이를 감싸는 렉시컬 환경도 메모리에서 제거 된다.

let g = f();	// g가 살아있는 동안엔 연관 렉시컬 환경 역시 메모리에서 유지

g = null;	// 도달할 수 없는 상태가 되어 메모리에서 제거

최적화 프로세스

즉 함수가 살아있는 동안엔 이론 상 모든 외부 변수 역시 메모리에 유지된다. 그러나 실제로는 자바스크립트 엔진이 이를 지속해서 최적화를 시도한다. 변수 사용을 분석하고 외부 변수가 사용되지 않는다고 판단되면 이를 메모리에서 자동으로 제거한다.

때문에 디버깅 시, 최적화 과정에서 제거된 변수를 사용할 수 없다는 점은 V8엔진의 주요 부작용 중에 하나이다.

let value = '111';

function f () {
  let value = '222';
  
  function g () {
    debugger;
    // 디버거에 의해 lock이 걸린 상태에서
    // 콘솔에 value 값을 출력하면
    // 원칙상 가장 가까이 위치한 value인 222 값이 출력되어야 하나
    // 해당 값은 사용하고 있지 않아 엔진이 자동으로 최적화 진행
    // 따라서 이 값은 메모리에서 제거가 된 상태이므로
    // 그 위에 존재하는 value 값 111을 출력
  }
  
  return g;
}

let g = f();
g();

이는 버그라기 보단 V8엔진만의 특별한 기능에 의한 부작용으로 볼 수 있다. 또한 미래에는 해당 기능이 변경될 수도 있다.

연습문제

1) 다음 코드의 실행 결과는?

let x = 1;

function func () {
  console.log(x);
  
  let x = 2;
}

func();

x값을 참조할 수 없다는 에러가 뜬다. 자바스크립트에서 함수는 클로저가 될 수 있으므로 외부에 있는 let x = 1에 접근할 수 있을 것으로 보인다. 그러나 함수 스코프에 보면 지역 변수로 다시 let x = 2를 가지고 있는 것을 알 수 있다. 따라서 해당 코드 블록 내에서는 지역변수 x가 존재하고,

  1. x = <uninitialized>
  2. x = 2

위 순서로 선언과 할당이 이루어질 것이다. 이때 uninitialized는 자바스크립트 엔진이 인지는 하지만 접근할 수 없는 값이므로 에러를 뱉게 된다.

2) 제대로 된 번호 출력

function makeArmy() {
  let shooters = [];
  
  let i = 0;
  while(i < 10) {
    let shooter = function () {
      alert(i);
    };
    shooter.push(shooter);
    i++;
  }
  
  return shooters;
}

let army = makeArmy();

army[0]();	// 10 출력
army[5]();	// 10 출력

각기 다른 i 값을 지정해 그에 알맞은 값을 출력해야 할 것 같지만 결과는 모두 10을 출력하고 있는 것을 볼 수 있다. 이 역시 변수의 유효범위와 렉시컬 환경을 통해 그 원인을 파악할 수 있다.

변수 let iwhile문 밖에 선언되어 있다. 따라서 while 문 내에 있는 익명함수에서 iwhile문 밖에 위치한 i의 값에 접근해야 한다. 그리고 이러한 i의 값은 makeArmy() 함수가 동작할 때 유효하다.

이때 내부에 있는 shooter 함수는 숨김 프로퍼티 [[Environment]]에 생성 시점을 외부 렉시컬 환경으로 참조하고 있기 때문에 i값에 접근은 가능하다. 그러나 이 시점에서 이미 makeArmy() 함수의 동작이 완료되었고, 그에 따라 i 값은 이미 10이 된 상태이다. 이 상태에서 i값을 참조하기 때문에 모든 함수의 출력값이 10이 된다.

이를 수정하기 위해서는 여러 방법이 있으나 가장 간단한 건 for문과 let키워드를 이용하는 방법이 있다. for문 괄호에 선언된 let 변수는 코드 블록 내에 있는 것으로 취급되기 때문에 정확한 숫자 시점일 때의 i 값 접근이 가능하다.

...
for(let i = 0; i < 10; i++) {
  let shooter = function () {
    alert(i);
  };
  ...
}
  
army[0]();	// 0 출력
army[5]();	// 5 출력

또는 while문을 이용해 풀이하려면 현재 숫자 값을 반복문 내부에서 저장하고, 이에 접근하도록 하는 방법이 있다.

let i = 0;
while(i < 10) {
  let j = i;
  let shooter = function () {
    alert(i);
  }
  ...
}
  
army[0]();	// 0 출력
army[5]();	// 5 출력

References

  1. https://ko.javascript.info/advanced-functions
  2. https://developer.mozilla.org/ko/docs/Web/JavaScript/Closures
  3. https://www.zerocho.com/category/JavaScript/post/5741d96d094da4986bc950a0
profile
개발잘하고싶다

0개의 댓글