JS04 - 함수와 스코프

함수 (다양한 함수의 형태 / 일급 객체 / 함수가 가지는 프로퍼티) / 스코프 (렉시컬 스코프 / 함수 레벨 스코프)

2022-01-23에 씀

본 시리즈는 모던 자바스크립트 Deep Dive 책을 참고하여 작성하고 있습니다.

함수란?

수학에서의 함수는 “입력”을 받아 “출력”을 내보내는 일련의 과정을 정의한 것이다. 프로그래밍에서의 함수는 일련의 과정을 문으로 구현하고, 이를 코드 블록으로 감싸서, 하나의 실행 단위로 정의한 것이다.

함수는 함수 정의를 통해 생성한다. 함수를 정의했다고 해서 함수가 실행되지는 않는다. 함수를 실행하기 위해서는 매개변수를 통해 함수에 인수를 전달하며 함수 실행을 명시적으로 지시해야 한다. 이것을 함수 호출이라고 한다.

  1. 함수 선언문을 통한 함수 정의
1// add: 함수 이름 (함수 몸체 안에서만 참조 가능)
2// x, y: 매개변수
3function add(x, y) {
4 return x + y; // 반환값
5}
  1. 함수를 호출하기
1var result = add(2, 5); // 인수로 2, 5를 전달하면서 함수 실행을 지시하기

함수를 사용하는 이유

  1. 코드의 재사용
1x = 1;
2y = 2;
3result = x + y;
4
5x = 3;
6y = 2;
7result = x + y;
8
9... // 100번 반복해야 한다면?
10
11// --> 이렇게 고칠 수 있다
12function add(x, y) {
13 return x + y;
14}
15
16result = add(1, 2);
17result = add(3, 2);
  1. 유지보수의 편의성 & 코드의 신뢰성

만약에 함수를 사용하지 않고 비슷한 로직을 반복해서 사용할 때, 그 로직을 수정해야 한다면, 로직이 사용된 부분을 모두 찾아서 일일이 수정해 주어야 한다. 그 과정에 걸리는 시간은 매우 길 것이고, 누락되는 부분이 있을 수도 있고 실수를 할 수도 있다.

함수로 로직을 묶어서 관리하면, 로직에 수정 사항이 생겼을 때 함수의 내용만 수정해 주면 되기 때문에 편리하고, 실수를 줄일 수 있어 코드의 신뢰성을 높일 수 있다.

  1. 코드의 가독성 향상

함수에는 이름을 붙일 수 있다. 그래서 적절한 이름을 잘 붙여 주면, 함수 내부의 코드를 분석하지 않아도 어떤 동작을 하는 함수인지 파악할 수 있다.

함수 리터럴

자바스크립트의 함수는 객체 타입의 값이다. 따라서 함수도 함수 리터럴로 생성할 수 있다. 그리고 변수에 할당할 수도 있다. 왜냐하면 함수 리터럴도 평가되어 값을 생성하며, 그 값은 객체이기 때문이다.

1var f = function add(x, y) {
2 return x + y;
3}

일급 객체

함수는 조금 특별한 객체이다. 일반 객체와는 달리 함수는 호출할 수 있다. 이러한 특징을 가지는 객체를 일급 객체라고 한다. 일급 객체는 다른 객체들에게 적용 가능한 연산을 모두 지원하는 객체이다. 함수가 일급 객체라는 것은, 함수를 객체와 동일하게 사용할 수 있으며, 함수를 값과 동일하게 취급할 수 있다는 것이다. 따라서 값을 사용할 수 있는 곳에서는 어디서든 리터럴로 정의 가능하며 런타임에 함수 객체로 평가된다.

일급 객체의 특징은 아래와 같다.

  1. 변수나 자료구조(객체, 배열) 안에 담을 수 있다
1var x = function add(x, y) { return x + y }
2var obj = {
3 add: function(x, y) { return x + y }
4}
  1. 파라미터로 전달할 수 있다
1function fetchData(callback) { callback(); }
2
3fetchData((data) => console.log(data));
  1. 리턴 값으로 사용할 수 있다
1function calculator(x, y) {
2 var add = function add() { return x + y };
3 return add;
4}
5var calc = calculator(1, 2)
6calc() // 3
  1. 무명의 리터럴로 생성할 수 있다. 즉, 런타임 생성이 가능하다.
1var increase = function (num) {
2 return ++num;
3}
4
5var n = 3
6increase(3); // 4

함수 객체의 프로퍼티

console.dir 메서드를 사용하면 객체의 내부를 확인할 수 있다.

함수는 arguments, caller, length, name, prototype 프로퍼티를 가지고, 이는 함수 객체 고유 프로퍼티이다.

함수를 정의하는 방법

변수는 선언한다고 말하고, 함수는 정의한다고 말한다. 함수 선언문은 평가되면 식별자가 암묵적으로 생성되고 함수 객체가 할당된다.

  1. 함수 선언문
1function add (x, y) {
2 return x + y;
3}
  1. 함수 표현식
1var add = function (x, y) {
2 return x + y;
3}
  1. Function 생성자 함수
1var add = new Function('x', 'y', 'return x + y');
  1. 화살표 함수(ES6)
    • 항상 익명 함수로 정의된다.
    • 일반 함수와 다르게 동작한다.
      • 생성자 함수로 사용할 수 없고
      • this 바인딩 방식이 다르고
      • prototype 프로퍼티가 없으며
      • arguments 객체를 생성하지 않는다.
1var add = (x, y) => x + y;

자바스크립트 엔진은 코드의 문맥에 따라, 같은 형태의 함수 리터럴을 표현식이 아닌 문으로 해석할 때도 있고, 표현식인 문으로 해석할 때도 있다. 표현식이 아닌 문은 함수 선언문이 되고 표현식인 문은 함수 리터럴 표현식이 된다. 즉, 이름이 있는 함수 리터럴은 함수 선언문 혹은 함수 리터럴 표현식으로 해석될 수 있다. 중의적인 코드인 것이다.

함수 선언문으로 해석하는 경우

함수 리터럴 표현식으로 해석하는 경우

자바스크립트 엔진은 함수 선언문을 토대로 함수 객체를 생성한다. 이 때 선언되는 함수 이름은 함수 몸체 내부에서만 접근이 가능한 식별자이므로, 함수 밖에서는 함수 이름으로 함수를 호출할 수 없다. 즉, 함수에 접근하려면 객체를 가리키는 식별자가 필요하다.

함수 선언문

자바스크립트 엔진은 함수 선언문을 해석해서 함수 객체를 생성하고, 함수 이름과 동일한 이름의 식별자를 암묵적으로 생성하고, 여기에 생성된 함수 객체를 할당한다. 따라서 함수 이름과 식별자는 별개이다. 함수 이름은 함수 내부에서만 호출 가능하므로, 함수 외부에서 함수를 호출하는 것은 함수 이름이 아니라 식별자로 함수를 호출한 것이다.

1// 이런 함수 선언문이 있다면
2function add(x, y) {
3 return x + y;
4}
5
6// 사실은 이런 식으로 동작한다
7var add = function add(x, y) {
8 return x + y;
9}

함수 생성 시점과 함수 호이스팅

변수 챕터에서 살펴봤듯이, 자바스크립트 엔진은 코드를 실행하기 이전에 평가 과정을 거치고, 이 과정에서 선언문을 먼저 실행한다. 그래서 var 키워드로 선언한 변수는, 변수 선언문 이전에 변수에 접근해도 undefined라는 값을 얻을 수 있었다.

1console.log(add); // f add(x, y)
2console.log(sub); // undefined
3
4console.log(add(2, 3)) // 5
5console.log(sub(2, 3)) // sub is not a function
6
7function add(x, y) {
8 return x + y;
9}
10
11var sub = function(x, y) {
12 return x - y;
13}

함수 선언문도 선언문이기 때문에 런타임 시점 이전에 자바스크립트 엔진에 의해 먼저 실행된다. 즉, 함수 호이스팅이 발생한다. 그래서 위 예제 코드처럼, 함수 선언문 이전에 함수 이름을 참조하거나 함수를 호출해도 잘 동작한다. 이러한 현상을 함수 호이스팅이라고 한다. 함수 선언문을 통해 암묵적으로 생성된 식별자는 함수 객체로 초기화된다.

함수 표현식은 변수에 함수 객체를 할당하는 문이다. 즉, 변수 선언문과 변수 할당문을 한 번에 축약한 표현이다. 이 때, 변수 선언은 런타임 이전에 실행되어 undefined로 초기화된다. 즉, 함수 표현식에서는 변수 호이스팅이 발생한다. 함수 리터럴은 런타임 시점에 평가되어 함수 객체로 생성되고, 변수에 할당된다. 그래서 위의 sub 변수는 출력했을 때 undefined 값을 출력하고, 실행하려고 하면 함수가 아니라고 한다.

그런데 함수 호이스팅 현상은 함수를 호출하기 전에 함수를 선언해야 한다는 규칙을 무시한다. 그래서 함수 선언문 대신 함수 표현식을 사용할 것이 권장된다.

다양한 함수의 형태

순수 함수와 비순수 함수

1var count;
2
3function pure_increase(n) {
4 return ++n;
5}
6
7function impure_increase() {
8 return ++count;
9}

함수형 프로그래밍: 순수 함수를 통해 부수효과를 최대한 억제하여 오류를 피하고 프로그램의 안정성을 높이려는 프로그래밍 패러다임. 불변성을 지향

원시 타입 인수는 값 자체가 복사되어 넘어가므로, 즉 값에 의한 전달이 수행되므로 원본이 훼손되지 않는다. 부수 효과도 발생하지 않는다. 객체 타입 인수는 참조 값이 복사되어 넘어가므로, 즉 참조에 의한 전달이 수행되므로 함수 내부에서 객체를 수정하면 원본이 훼손된다. 따라서 부수 효과가 발생할 수도 있다. 이렇게 외부 상태를 변경하게 되면 상태 변화를 추적하기가 어려워지고, 코드가 복잡해지며, 가독성을 해친다.

객체의 변경을 추적하기 위해서 옵저버 패턴으로 대응을 하기도 한다. 객체가 변경되면 객체를 참조하는 모든 이들에게 변경되었다는 사실을 알려준다.

객체를 불변 객체로 만들어 사용하기도 한다. 객체를 마치 원시 값처럼 동작하게 만드는 것이다. 객체의 상태 변경이 필요하다면 객체의 방어적 복사(defensive copy), 즉 깊은 복사를 통해서 원본 객체를 완전히 복제한 새로운 객체를 생성하고 재할당해 교체한다. 외부 상태가 변경되는 부수 효과를 없앨 수 있지만, 객체를 만드는 비용이 든다.

즉시 실행 함수

1function foo() {
2 ...
3}();
4
5// --> 세미콜론 자동 삽입으로 인해, 이렇게 해석된다
6function foo() {}; ();
1// 가장 선호되는 방식 - 함수 리터럴 전체를 괄호로 묶기
2(function () {
3 ...
4}());
5
6(function () {
7 ...
8})();
9
10// 아래 단일 연산자의 경우 함수 리터럴로 평가되어 함수 객체가 생성된다
11!function () {
12 ...
13}();
14
15+function () {
16 ...
17}();

중첩 함수

1// 외부 함수
2function outer() {
3
4 // 내부 함수 = 중첩 함수
5 function inner() {
6 ...
7 }
8
9 inner();
10}

콜백 함수

고차 함수는 콜백 함수를 자신의 일부분으로 합성한다. 고차 함수는 매개변수를 통해 콜백 함수를 전달받고, 함수 내부에서 콜백 함수의 호출 시점을 결정해서 호출한다. 콜백 함수는 고차 함수에 의해 호출되고, 호출될 때 인수를 전달할 수도 있다. 고차 함수에 콜백 함수를 전달할 때는 함수 객체 자체를 전달해야 한다. 함수는 일급 객체이므로 값으로 다룰 수 있고, 매개변수를 통해 전달할 수 있다. 비동기 처리나 배열 고차 함수에서 사용된다.

어떤 함수들은 공통 로직을 가지고 있으면서 일부분만 다른 경우가 있다. 이러한 경우 공통의 로직이 있더라도 함수를 새롭게 정의해야 한다는 불편함이 있다. 따라서 공통 로직을 고차 함수로 정의해 두고, 변경되는 로직은 추상화하여 콜백 함수 형태로 함수 외부에서 함수 내부로 전달하는 것이다.

1function repeatAndLogOdd(n) {
2 for (var i = 0; i < n; i++) {
3 if (i % 2) console.log(i);
4 }
5}
6
7function repeatAndLogEven(n) {
8 for (var i = 0; i < n; i++) {
9 if (i % 2 === 0) console.log(i);
10 }
11}
12
13repeatAndLogOdd(7);
14repeatAndLogEven(7);
15
16// --> 이렇게 바꿀 수 있다
17
18function repeat(n, callback) {
19 for (var i = 0; i < n; i++) {
20 callback(i);
21 }
22}
23
24function logOdd(n) {
25 if (i % 2) console.log(i);
26}
27
28function logEven(n) {
29 if (i % 2 === 0) console.log(i);
30}
31
32repeat(7, logOdd);
33repeat(7, logEven);

콜백 함수를 전달할 때는 함수 리터럴 방식으로 전달하는 방식이 일반적이다. 그런데 이렇게 전달되는 콜백 함수는 고차 함수가 호출될 때마다 평가되어 함수 객체가 생성된다. 따라서 고차 함수가 자주 호출되는 함수라면 함수 외부에서 콜백 함수를 정의한 후에 함수의 참조를 고차 함수에 전달하는 것이 효율적이다.

1// repeatAndLogEven을 바꾸면
2repeat(6, (n) => {
3 if (n % 2) console.log(n);
4}

스코프

모든 식별자는 자신이 선언된 위치에 의해 다른 코드가 식별자 자신을 참조할 수 있는 유효 범위가 결정된다. 스코프는 식별자가 유효한 범위이다. 식별자에는 변수 이름, 함수 이름, 클래스 이름 등이 있다.

1var a = 3;
2
3function foo(a) {
4 console.log(a);
5}
6
7foo(5);

위의 상황처럼 같은 이름의 식별자가 있을 때, 자바스크립트 엔진이 어떤 것을 참조해야 할 지 결정하는 과정을 식별자 결정이라 한다. 스코프는 자바스크립트 엔진이 식별자를 검색할 때 사용하는 규칙이라고 할 수도 있다. 스코프라는 개념이 없다면 같은 변수 이름을 프로그램 내에서 한 번 밖에 사용하지 못할 것이다. 스코프는 변수 이름의 충돌을 방지해 같은 이름의 변수를 사용 가능하게 한다. 스코프는 네임스페이스이다.

스코프의 종류

변수는 자신이 선언된 위치에 의해 스코프가 결정된다. 전역에서 선언되면 전역 스코프를 가지고, 함수의 내부인 지역에서 선언된 변수는 지역 스코프를 가진다.

스코프 체인

함수는 중첩될 수 있다. 따라서 지역 스코프도 중첩될 수 있다. 즉, 스코프는 함수의 중첩에 의해 계층 구조를 가진다. 중첩 함수의 외부 함수의 스코프를 상위 스코프라고 부른다. 모든 스코프는 하나의 계층적 구조로 연결되고, 최상위 스코프는 전역 스코프이다. 이처럼 계층적으로 연결되는 것을 스코프 체인이라고 한다.

변수를 참조하면, 자바스크립트 엔진은 스코프 체인을 통해 변수를 검색한다. 변수를 참조하는 코드의 스코프에서 시작해서 상위 스코프 방향으로 이동하며 선언된 변수를 검색한다.

1var x = 3;
2var y = 2;
3
4function outer() {
5 var x = 65;
6 var y = 33;
7
8 console.log(x, y);
9
10 function inner() {
11 var x = 333;
12
13 console.log(x, y);
14 }
15}
16
17console.log(x, y);

자바스크립트 엔진은 코드를 실행하기에 앞서서 렉시컬 환경이라는 자료구조를 실제로 생성한다. 선언된 변수가 있다면 렉시컬 환경에 키 값이 식별자 이름으로 등록되고, 변수 할당이 일어나면 변수 식별자에 해당하는 값이 변경된다. 스코프 체인은 렉시컬 환경을 단방향으로 연결한 것이다. 함수의 렉시컬 환경은 함수가 호출되면 바로 생성된다.

스코프 체인을 검색할 때는 현재 스코프에서 상위 스코프로 향하는 방향으로만 검색한다. 따라서 현재 스코프보다 하위 스코프에 유효한 변수는 참조할 수 없다.

함수도 객체이다. 함수 선언문으로 함수를 정의하면 함수 객체가 생성되고, 이 객체는 함수 이름과 동일한 이름으로 암묵적으로 선언되는 식별자에 할당된다. 따라서 함수도 식별자에 할당되므로 위의 그림처럼 스코프를 가진다. 따라서 스코프는 식별자를 검색하는 규칙이라고 보는 것이 적합하다.

함수 레벨 스코프

지역 스코프는 함수에 의해서만 생성된다. 지역은 함수 내부를 말한다. var 키워드로 선언된 변수는 함수 몸체만을 지역 스코프로 인정한다. 이러한 특성을 함수 레벨 스코프라고 한다. 반면에 대부분의 프로그래밍 언어에서는 모든 코드 블록(if, for, while, ...)이 지역 스코프를 만든다. 이러한 특성은 블록 레벨 스코프라고 한다.

1var i = 10;
2
3for (var i = 0; i < 5; i++) {
4 ...
5}
6
7console.log(i); // 5

위의 예시처럼 함수 레벨 스코프는 전역 변수의 값을 의도치 않게 변경하게 될 여지가 있다. 따라서 ES6부터는 블록 레벨 스코프를 지원하는 const, let 을 사용하기를 권장한다.

렉시컬 스코프

자바스크립트는 렉시컬 스코프를 따르므로 함수를 어디서 정의했는지에 따라 상위 스코프가 결정된다. 함수의 상위 스코프는 항상 자신이 정의된 스코프이다. 함수의 상위 스코프는 함수 정의가 실행될 때 정적으로 결정된다. 함수 객체는 정의될 때 결정된 상위 스코프를 기억한다.

1var x = 1;
2
3function foo() {
4 var x = 10;
5 bar();
6
7function bar() {
8 console.log(x);
9}
10
11foo()
12bar()

위 예제에서 bar 함수는 두 번 호출된다. 호출의 결과는 두 가지로 예측이 가능하다.

1// 1) 호출한 위치에 의해 스코프가 결정된다면
210, 1
3
4// 2) 정의한 위치에 의해 스코프가 결정된다면
51, 1

1)의 경우를 동적 스코프라고 한다. 함수가 호출되는 시점에 동적으로 상위 스코프를 결정하는 방식이다.

2)의 경우는 렉시컬 스코프 혹은 정적 스코프이다. 함수 정의가 평가되는 시점에 스코프가 정적으로 결정된다. 대부분의 프로그래밍 언어가 렉시컬 스코프를 따른다.

프로필 사진

조예진

이전 포스트
Props와 State와 Ref
다음 포스트
JS05 - 전역 변수의 문제점 & 프로퍼티 어트리뷰트