함수와 클로저

Posted by yunki kim on October 1, 2021

  함수를 절차를 정리할 목적으로 사용하지만 단순히 그 뿐만 아니라 함수 자체를 연산의 대상으로 삼는 것과 클로저를 이해해야 함수형 프로그래밍을 이해할 수 있다.

함수 선언문과 함수 리터럴 식

  함수 선언문과 함수 리터럴 식에서 함수를 선언할 수 있다. 함수 선언문에서 선언한 함수는 함수 언선부가 나오기 전에 호출할 수도 있다(함수 선언문의 역행).

함수 호출 정리

  함수 호출의 차이를 토대로 함수의 분류를 다음과 같이 나눌 수 있다. 이는 함수 자체로 분류를 한것이 아니다. 따라서 특정 함수를 메서드 또는 생성자라 부르는 것은 엄밀히 말하면 잘못된 표현이다. 정확히 말하자면 그 함수를 메서드 또는 생성자로서 호출했는지 여부이기 때문이다. 즉, 함수, 메서드, 생성자는 호출 방법의 차이에 불과하다.

명칭 설명
메서드 호출 리시버 객체를 경유해서 함수 호출(apply, call도 포함)
생성자 호출 new 식으로 함수 호출
함수 호출 메서드호출, 생성자 이외의 호출

함수 선언문 역행

  함수 선언문에서 선언함 함수는 선언한 행보다 앞에서 호출할 수 있다.

1
2
3
4
5
f();//hi
 
function f() {
    console.log('hi');
}
cs

  하지만 함수 리터럴의 경우 위와 같이 동작하지 않는다.

1
2
3
4
5
f();//Uncaught ReferenceError: f is not defined
 
const a = function f() {
    console.log('hi');
}
cs

 

인자와 지역변수

  함수 안에서는 arguments 객체를 사용해 실질 인자에 접근할 수 있다. 또 한 arguments.length를 사용해 대응하는 형식 인자가 없는 실질 인자의 개수를 할 수 있기 때문에 가변인자 함수를 쓸 수 있다. 또 한 형식 인자의 개수는 함수 객체 자신의 length 프로퍼티로 구할 수 있다. arguments는 배열과 같이 사용할 수 있지만 배열 객체는 아니다. 따라서 Array의 메서드는 사용할 수 없다.

1
2
3
4
5
6
7
function func(...a) {
    console.log(arguments[0]);//2
    //1 0
    console.log(arguments.length, func.length);
}
 
func(2);
cs

 

스코프

  JS의 스코프는 전역, 함수 두 가지로 나뉘어져 있다. 함수 바깥(top-level scope)의 스코프는 전역이고 함수 안에서 선언한 이름은 함수 스코프를 가진다. 함수의 매개변수 역시 함수 스코프이다.

  함수 스코프의 동작은 자바 등 기타 언어의 지역 스코프와 차이점이 존재한다. 기타 언어의 지역 스코프는 선언한 행 이후부터 스코프를 가지지만 JS의 함수 스코프는 이와 무관하다.

1
2
3
4
5
6
function func() {
  console.log(x); //undefined
  var x = 2;
  console.log(x); //2
}
func();
cs

   웹브라우저와 스코프

      클라이언트 사이드 JS에서는 각 탭, 각 프레임(iframe)마다 전역 스코프가 존재한다. 위도우 간에 서로 전역 스코프 이름에 접근할 수 없다. 하지만 부모 프레임 사이에는 서로 접근이 가능하다

  중첩 함수와 스코프

      JS함수는 중첩으로 선언할 수 있다. 함수의 내부에서 바깥쪽 함수의 스코프에 접근할 수 있다.

  셰도잉

      스코프가 작은 같은 이름의 변수로 스코프가 큰 이름을 숨기는 것을 의미한다. 이는 버그의 원인이 되므로 주의해야 한다.

1
2
3
4
5
6
var n = 1;
function func() {
  var n = 2;
  console.log(n); //2
}
func();
cs

 

함수는 객체

  JS에서 함수는 객체이며 Function 객체를 상속 받는다. 

1
2
function f() {}
console.log(f.constructor); //f Function(){}
cs

  함수 리터럴은 변수에 함수를 대입하는 것과 함수 객체의 참조를 변수에 대입하는 것의 다른 표현이다. 통상적으로 함수라 부르는 것은 함수 객체의 참조와 같은 의미이다. 함수를 선언하는 것은 함수 객체를 생성하는 것과 같은 의미이다.

  아래의 예제는 걷보기에는 매우 다른거 같지만 실체의 생성과 그것을 참조하는 이름을 연결한다는 점에서 같다.

1
2
3
4
var obj = {};
var obj = new MyClass();
var obj = function() {}
function obj() {}
cs

 

함수명

  객체는 본질적으로 이름이 없다. 따라서 함수 객체 자체에서도 이름이 없다. 하지만 함수 객체 자체는 내부에 표시명을 가질 수 있다. 함수 선언문이나 함수 리터럴 식에서 함수명을 지정하는 경우가 여기에 해당된다. 

1
2
function func(){} //함수 선언문
var f = function fuc(){} //힘수 리터럴 식
cs

  사실 함수 표시명이라는 명칭은 존재하지 않지만 일반적인 함수명과 구별하기 위해 일단 사용하자.

  함수명은 함수 객체의 참조를 갖는 변수명이다. 그에 반해 함수 표시명은 함수 객체 자체에 새긴 이름이다. 함수 표시명 만으로는 함수를 호출하는 데 쓸 수 없지만 함수 선언문의 경우 function 뒤에 쓴 이름은 함수명도 되기 때문에 표면상으로는 구별할 수 없다. 

1
2
3
4
function fn(){}
var f = fn;
fn = null;
console.log(f); //f fn(){}
cs

 

중첩 함수 선언과 클로저

1
2
3
4
5
6
7
8
9
10
11
12
function f() {
  let cnt = 0;
  return function () {
      return ++cnt;
  }
}
 
const fn = f();
 
console.log(fn());//1
console.log(fn());//2
console.log(fn());//3
cs

  위 예제를 보면 지역 변수 cnt가 함수 f의 호출이 끝났음에도 살아있다. 이는 클로저의 한 예시이다.

  클로저의 구조

    클로저는 함수 중첩을 전제로 한다. 클로저를 이해하기 위해 우선 다음 코드의 세부적인 동작과정을 살펴보자.

1
2
3
4
5
6
7
function f() {
  function g() {
    console.log('g');
  }
 g();//함수f를 호출하면 g가 간접적으로 호출된다.
}
f();//g
cs

  탑 레벨 코드에서 함수 f의 선언은 함수 객체의 생성과 변수 f에 의한 함수 객체의 참조를 의미한다. 변수 f는 전역 객체의 프로퍼티이다. 또 한 js에서 함수를 호출할 때마다 Call객체가 암묵적으로 선언되고 함수 호출이 끝나면 Call객체는 소멸된다. 따라서 함수f() 내부의 함수 g()의 선언은 g에 대응하는 함수 객체를 생성하고 g에 대응되는 Call객체 역시 선언된다. 변수 g는 f에 대응되는 Call객체의 프로퍼티이다. 함수 g를 빠져나오면 g에 대응되는 Call객체는 자동으로 소멸되고, f에 대응되는 Call객체 역시 f를 빠져 나오면 자동으로 소멸된다. f에 대응되는 Call객체가 소멸되면 g가 참조하고 있는 함수 객체도 함께 소멸되므로(g가 참조하는 함수 객체는 f에 대응하는 Call객체의 프로퍼티이기 때문에) g는 참조할 곳이 없어져 gc에 의해 소멸된다.

  중첩함수와 클로저의 첫번째 예시 역시 위와 같이 동작한다. 이 예시에서는 익명함수가 자신의 Call객체 밖에 있는 변수를 사용하는데 이는 함수안에서의 변수명 해석으로 Call객체의 프로퍼티, 그 다음 바깥쪽 함수의 Call객체 프로퍼티 순으로 찾기 때문이다. 이런 구조를 스코프 체인이라 한다.

 

 클로저

1
2
3
4
5
6
7
8
9
function f() {
  let n = 1;
  function g() {
    console.log(n);
  }
  return g;
}
const g2 = f();
g2();//1
cs

  위 코드는 함수 g를 함수 f의 바깥에서 호출할 수 있으며 함수 f의 지역변수인 n이 함수 f의 호출이 끝나도 살아있다는 것을 보여준다. 함수 f를 호출할 때 생성된 f의 Call객체의 프로퍼티인 g가 참조하고 있던 함수 객체를 g2가 참조하게 된다. 따라서 gc는 g프로퍼티가 참조하고 있는 함수 객체를 수거하지 않는다. 또 한 g2가 참조하고 있는 함수객체(g가 참조하고 있던 함수 객체)는 f에 대응되는 Call객체로의 참조를 가진다(스코프 체인 때문에). 그러므로 g2가 참조하고 있는 함수 객체가 남아 있는 한 f에 대응되는 Call객체도 남아있게 된다. 

1
2
3
4
5
6
7
8
9
10
11
12
13
function f() {
  let n = 1;
  function g() {
    console.log(n);
  }
  return g;
}
const g2 = f();
const g3 = f();
g2();//1
g3();//1
let n = 4;
g3();//1
cs

  위와 같은 코드의 경우 g2, g3가 참조하고 있는 함수 객체는 서로 다른 함수 객체를 참조한다(Call 객체는 함수 호출마다 생기기 때문에).

1
2
3
4
5
6
7
8
9
10
11
function f(tmp) {
  let n = tmp;
  function g() {
    console.log(n);
  }
  return g;
}
const g2 = f(1);
const g3 = f(2);
g2();//1
g3();//2
cs

  위와 같은 코드로 부터 다음과 같은 사실을 알 수 있다. g2와 g3의 호출결과는 다르다. 이는 같은 코드에서 다른 상태를 갖는 함수를 만들 수 있다는 의미이다. 이것이 클로저이다. 즉, 함수를 호출하는 시점에서 변수명을 해석할 때의 환경을 유지한는 함수를 클로저라 한다.

  클로저를 조금 더 쉽게 표현하면 상태를 갖는 함수이가. 하지만 변수명을 해석할 때의 상태를 유지한다는게 객체의 상태를 전부 유지한다는 의미는 아니다. 즉, 클로저는 중첩 구조에서 바깥 쪽에 위치한 함수를 호출할 때 암묵적으로 생성되는 Call객체를 유지하지만, Call 객체의 프로퍼티에서 참조되는 객체의 상태까지는 보장하지 않는다. 따라서 다음과 같은 오류가 생길 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function f(args) {
  let n = 123 + Number(args);
  function g () {
    console.log("g:");
    console.log(n);
  }
  n++;
  function gg() {
    console.log("gg:");
    console.log(n);
  }
  return [g, gg];
}
 
const func = f(1);
func[0]();//g:125
func[1]();//gg:125
cs

  위 코드에서 func[0]()의 호출을 통해 기대한 값은 125이다. 그러나 125가 출력된다. 이는 함수 g와 함수 gg는 각각 지역 변수n을 포함하는 환경을 유지하지만 모두 같은 Call객체를 참조하고 있기 때문이다.

 

네임스페이스 오염 방지

  모듈

    순수 js에서 탑 레벨 코드에 쓴 이름(변수명과 함수명)은 전역 스코프를 가진다. 또 한 코드를 여러 파일로 분할해도 서로의 이름을 볼 수 있다. 이는 순수 js가 모듈기능이 없기 때문이다. 현재의 클라이언트 사이드 js에서는 하나의 HTML파일에 여러개의 js파일을 읽어들이면 전역 이름이 서로 충돌한다. 

      또한 전역 변수는 코드의 유지보수성을 떨어뜨린다. 하지만 우리는 전역변수가 나쁘다 라는 인식을 가질것이 아니라 불필요하게 넓은 스코프가 나쁘다 라는 인식을 가져야 한다. 스코프가 넓으면 코드를 수정했을 떄 그런 변경이 영향을 주는 범위를 파악하기 어렵다. 이 점이 바로 유지보수성을 떨어트리는 이유다.

  전역변수 회피

      형식적으로 전역변수를 회피하는 방법은 간단하다. 그저 전역 함수, 전역 변수를 객체 리터럴 안에 선언하면 된다. 물론 이 방식 또한 객체 리터럴을 가리키는 변수명이 충돌할 가능성은 존재하지만 이는 다른 언어 사양에도 존재하는 문제이다.

1
2
3
4
5
6
const MyModule = {
  sum: function(a) {
    console.log(a);
  },
  num: 3,
};
cs

    하지만 이 방법 역시 스코프가 지나치게 넓다는 문제를 해결하지는 않는다. 이 문제는 클로저로 해결할 수 있다.

  클로저에 의한 정보 은폐

    js의 언어사양으로 정보를 은폐하는 구문은 없지만 클로저를 활용해서 외부에서 보이지 않는 이름을 작성할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//함수리터럴인 익명함수는 그 자리에서 호출된다
//함수리터럴의 반환값은 함수이다
const sum = (function() {
  //함수 외부에서는 이 이름에 접근할 수 없다.
  //사실상 비공개 변수
  //하지만 클로저로인해 익명함수 내붓에서는
  //사용 가능
  let position = {x: 2};
  //같은 함수의 외부에서 접근할 수 없는 비공개 함수
  function sum2(a, b) {
    return Number(a) + Number(b);
  }
  return function(a, b) {
    console.log('x:' + position.x);
    return sum2(a, b);
  }
})();
console.log(sum(12));
//출력:
//x:2
//3
cs

    위 예제를 추상화하면 (function() {})()처럼 된다. 이는 함수 스코프에 이름을 가두는 것과 클로저에서 함수를 빠져나간 후에도 살아있는 이름이라는 두 가지 특성을 활용한거다. 이 기법은 클로저를 활용했기 때문에 반환값으로 반드시 함수를 반환해야 한다.

  클로저와 클래스

    JS에는 접근제어자가 존재하지 않는다. 하지만 함수 스코프와 클로저를 이용하면 접근제어를 할 수 있다. 기본적인 개념은 위에서 설명한 클로저에 의한 정보 은폐와 같지만 클로저를 사용하는 클래스는 인스턴스를 생성할 때마다 호출할 수 있게 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function counter(init) {
  let cnt = init || 0;
  //return this는 메서드 체인을 위해 사용
  return {
    show: function () {
      console.log(cnt);
    },
    up: function () {
      cnt++;
      return this;
    },
    down: function () {
      cnt--;
      return this;
    }
  };
}
const cnt = counter();
cnt.show();//0
cnt.up();
cnt.show();//1
//메서드 체인
cnt.up().down().up().show();//2
cs

Expression closure

  Expression closure는 함수의 내용이 return 하나 뿐인 경우 {}와 return을 생략할 수 있는 구문이다. 하지만 이미 deprecated된 방식이므로 더이상 사용하지 말자.

1
2
3
var sum = function (a, b) {return Numebr(a) + Number(b);}
//위와 동일한 기능을 한다
var sum = function(a, b) Number(a) + Number(b);
cs

 

콜백 패턴

  콜백과 제어의 역전(IOC - Inversion Of Control)

    콜백은 호출해주길 바라는 함수, 객체를 전달하고 필요에 따라 그것들이 호출되게 하는 기법이다. 호출하는 쪽과 호출되는 쪽의 의존성이 역전되기 떄문에 제어의 역전이 된다.

    JS는 프로그래밍에서 콜백을 상당히 많이 사용한다. 이런 현상이 나타나는 이유는 다음과 같다

     1. 클라이언트 사이드 JS는 기본적으로 GUI프로그래밍이다.

        GUI프로그래밍은 이벤트 드리븐과 잘 맞는다. 이벤트 드리븐은 콜백 패턴과 딱 맞아 떨어진다. 클라이언트 사이드 JS 프로그래밍은          DOM의 이벤트를 기반으로 한 이벤트 구동형 프로그래밍이다.

     2. 클라이언트 사이드 JS에서는 다중 스레드 프로그래밍이 불가능하다.

        물론 HTML5의 웹워커는 클라이언트 사이드 JS에서 다중 스레드를 사용하지만 이는 최근의 이야기이다. 콜백은 비동기 처리와의 결         합으로 병렬 처리를 구현할 수 있다. 

     3. JS에는 함수 선언식과 클로저가 있기 때문이다.

 

자바스크립트와 콜백

  다음 예시는 콜백을 흉내낸 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const emitter = {
  callBacks: [],
  register: function(fn) {
    this.callBacks.push(fn);
  },
  onOpen: function() {
    this.callBacks.forEach(f => f());
  },
};
emitter.register(function(){console.log("event handler 1")});
emitter.register(function(){console.log("event handler 2")});
emitter.onOpen();
//출력:
//event handler 1
//event handler 2
cs

  위 예시에서 emitter라는 변수명을 사용했다. 이벤트 드리븐으로 콜백 함수를 호출하는 쪽에는 이벤트 발행(emit)나 이벤트 발화(fire)라는 용어를 사용한다. 반면 호출되는 쪽에서는 on이라는 접두사를 사용한다.

  위 예제의 콜백은 인자를 즉, 상태를 가지지 않느 메서드이다. 콜백 함수가 상태를 가질 수 있으면 활용 폭이 넓어진다. 아래 예제에서 사용한 bind()는 apply, call과 비슷한 동작을 하며 Function.property객체 메서드이다. 함수에 대해  bind를 호출하면 새로운 함수가 반환된다. 새롭게 반환한 함수는 원래 함수와 같은 내용을 실행하지만 this 참조가 bind의 첫 번째 인자로 지정한 객체가 된다. apply, call은 호출하면 바로 대상 함수를 호출하지만 bind는 함수(클로저)를 반환한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const emitter = {
  callBacks: [],
  register: function(fn) {
    this.callBacks.push(fn);
  },
  onOpen: function() {
    this.callBacks.forEach(f => f());
  },
};
 
function MyClass(msg) {
  this.msg = msg;
  this.show = function() {
    console.log(msg);
  }
}
 
let obj1 = new MyClass(1);
let obj2 = new MyClass(2);
emitter.register(obj1.show.bind(obj1));
emitter.register(obj2.show.bind(obj2));
emitter.onOpen();
//출력:
//1
//2
cs