본문 바로가기

함수와 스코프

선언·표현식·화살표 함수와 매개변수를 익히고 전역·함수·블록 스코프가 변수 접근과 생명주기를 정하는 원리를 이해합니다.

프로그램을 쓰다 보면 같은 작업을 여러 번 하거나, 복잡한 처리를 작은 단위로 나눠 관리해야 할 때가 많습니다. 이때 쓰는 것이 함수(function)로, 어떤 일을 하는 코드 묶음에 이름을 붙여 필요할 때마다 다시 쓰게 합니다. 또 프로그램이 커지면 변수의 이름이 겹치거나, 어떤 데이터에 접근할 수 있는 곳을 제한해야 할 일이 생기는데, 이때 중요한 것이 변수에 접근할 수 있는 범위인 스코프(scope)입니다.

이 글에서는 앞 글의 조건문과 반복문을 함수 안에 묶어 씁니다. 함수를 선언하는 것과 호출하는 것은 다른 일이며, console.log()로 화면에 보여 주는 값과 return으로 돌려주는 값도 구분해야 합니다. 스코프는 이름을 어디서 찾을지 정하는 규칙이고, 값이 언제까지 남아 있는지는 그 값을 아직 쓰는 곳이 있는지와 함께 살펴봅니다.

함수 실행과 변수 이름 탐색

ARCHITECTURE

함수 실행과 변수 이름 탐색

함수 실행은 인수를 받아 본문을 실행하고 결과를 반환한다. 변수 이름은 호출한 곳이 아니라 선언 위치가 정한 렉시컬 환경을 따라 찾는다. 여기서 만든 내부 함수를 반환하면 필요한 지역 바인딩도 유지될 수 있다.

함수 실행과 선언 위치에 따른 이름 조회호출에서 함수 본문, 반환으로 이어지는 실행 경로와 현재 환경에서 선언된 바깥 범위, 모듈 또는 전역 환경으로 이어지는 이름 조회 경로를 구분한다. 반환된 내부 함수의 환경 참조는 원래 함수 본문의 필요한 바인딩을 보존한다. 모바일의 현재 범위 노드는 위 함수 본문의 같은 환경을 다시 표시한 것이다. 모듈 / 전역해당 최상위 환경선언된 바깥 범위이름이 없을 때 조회호출인수 전달함수 본문매개변수·지역 바인딩이름이 없으면 위로 조회반환값 또는 함수반환된 내부 함수정의한 환경을 참조보존한 환경 참조
함수 실행과 선언 위치에 따른 이름 조회호출에서 함수 본문, 반환으로 이어지는 실행 경로와 현재 환경에서 선언된 바깥 범위, 모듈 또는 전역 환경으로 이어지는 이름 조회 경로를 구분한다. 반환된 내부 함수의 환경 참조는 원래 함수 본문의 필요한 바인딩을 보존한다. 모바일의 현재 범위 노드는 위 함수 본문의 같은 환경을 다시 표시한 것이다.실행: 호출 → 본문 → 반환호출인수 전달함수 본문매개변수·지역 바인딩이름 조회반환값 또는 함수반환된 내부 함수정의한 환경 참조이름 조회: 없을 때만 바깥으로현재 범위함수 본문의 같은 환경선언된 바깥 범위모듈 / 전역

실행축

caller → function context → return. 인수·지역 상태·반환값이 한 번의 호출에서 이동한다.

이름 조회축

현재 context에 이름이 없을 때만 선언 위치가 정한 outer → module/global 순서로 올라간다.

closure retention

본문에서 만든 내부 함수를 반환하면 그 함수의 [[Environment]]가 이 호출의 필요한 지역 바인딩으로 이어진다. 종료된 호출을 스택에 남기는 것은 아니다.

  • 함수 호출과 반환
  • lexical name lookup
  • closure가 보존한 환경 참조

이름을 찾으면 더 바깥으로 넘어가지 않는다

가장 가까운 바인딩이 바깥의 같은 이름을 가린다. 그 바인딩이 초기화 전이면 TDZ 오류이며 바깥 이름으로 건너뛰지 않는다. 끝까지 이름이 없으면 일반적인 식별자 읽기는 ReferenceError다. 실행 순서와 이름 탐색 순서는 서로 다르다.

위 그림은 함수가 실행되는 흐름과 변수 이름을 찾는 흐름을 나눠 보여 줍니다. 실행은 호출하면서 인수를 넘기고, 함수 본문에서 매개변수와 지역 변수로 일한 뒤, 값이나 함수를 돌려주는 순서로 진행됩니다. 이름을 찾을 때는 함수를 어디서 호출했는지가 아니라 어디에 선언했는지를 따라, 현재 범위에 이름이 없으면 그 바깥 범위로, 그다음 모듈이나 전역으로 올라가며 찾습니다.

그림 오른쪽 아래는 함수 본문에서 만든 내부 함수를 돌려주면, 그 함수가 자기가 정의된 환경을 계속 가리켜 필요한 지역 변수가 남아 있게 된다는 것을 보여 줍니다. 이것이 뒤에서 볼 클로저입니다. 그림 맨 아래의 설명처럼 가까운 범위에서 이름을 찾으면 더 바깥은 보지 않으며, 끝까지 찾지 못하면 ReferenceError가 납니다.


함수

함수는 자동판매기와 비슷합니다. 동전을 넣고(입력) 버튼을 누르면(호출) 음료가 나오듯(출력), 함수는 입력을 받아 정해진 일을 하고 결과를 돌려줍니다.

함수 선언

함수를 만드는 가장 기본적인 방법은 function 키워드로 선언하는 것입니다. 다음 코드는 입력도 결과도 없는 함수, 입력(매개변수)을 받는 함수, 결과를 돌려주는(return) 함수를 차례로 만들고, 각 함수 아래에서 이름 뒤에 괄호를 붙여 호출합니다. 선언만 하고 호출하지 않으면 함수 안의 코드는 실행되지 않습니다.

// 1. 매개변수와 반환값이 없는 함수
function sayHello() {
    console.log("안녕하세요!");
}

sayHello(); // 함수 호출: "안녕하세요!" 출력

// 2. 매개변수가 있는 함수
function greet(name) { // name은 매개변수(parameter)
    console.log(`안녕하세요, ${name}님!`);
}

greet("김철수"); // 함수 호출: "김철수"는 인자(argument)
greet("이영희"); // 함수 재사용: 다른 인자를 전달하여 다른 결과 생성

// 3. 반환값이 있는 함수
function add(a, b) { // a와 b는 매개변수
    let sum = a + b;
    return sum; // 함수의 실행 결과를 반환합니다.
}

let result = add(5, 3); // 함수 호출: 5와 3은 인자
console.log(result);    // 결과: 8

console.log(add(10, 20)); // 함수 호출 결과를 직접 사용: 30

function 함수이름(매개변수1, 매개변수2)처럼 함수 이름과, 괄호 안에 매개변수(parameter)를 적습니다. 매개변수는 함수를 호출할 때 넘겨받을 값을 담는 변수이고, 호출할 때 실제로 넘기는 값은 인수(argument)라고 하며, 위 코드 주석의 인자도 같은 말입니다. 중괄호 안에는 호출했을 때 실행할 코드를 씁니다. return 값;은 함수를 끝내고 그 값을 호출한 곳으로 돌려주며, return이 없거나 return 뒤에 값이 없으면 undefined를 돌려줍니다.

greet("김철수")는 인사를 출력할 뿐 아무것도 돌려주지 않으므로, let message = greet("김철수")를 실행하면 message에는 인사가 아니라 undefined가 들어갑니다. 반면 add(5, 3)은 매개변수 a에 5, b에 3을 받아 계산한 8을 돌려주므로, 그 값을 result에 넣어 다시 쓸 수 있습니다. return을 만나면 함수가 끝나므로 그 아래의 코드는 실행되지 않습니다.

함수 표현식

자바스크립트에서는 함수도 값이라서 변수에 넣을 수 있습니다. 이렇게 함수를 변수에 넣는 방식을 함수 표현식(function expression)이라고 합니다.

const multiply = function(x, y) { // 함수를 익명 함수로 선언하고 multiply 변수에 할당
    return x * y;
}; // 함수 표현식 끝에는 세미콜론(;)을 붙이는 것이 일반적입니다.

console.log(multiply(4, 5)); // 결과: 20

// 변수에 할당되었으므로, 해당 변수 이름으로 호출합니다.
let calculate = multiply;
console.log(calculate(2, 7)); // 결과: 14

함수 표현식은 다른 함수에 함수를 인수로 넘기는 콜백(callback)을 다룰 때 특히 자주 씁니다. 6장에서 자세히 다룹니다.

화살표 함수

화살표 함수(arrow function)는 ES2015에서 도입된, 함수를 더 짧게 쓰는 문법입니다. 짧은 함수를 쓸 때 읽기 쉽습니다.

// 1. 매개변수가 하나일 경우 괄호 생략 가능
const greetPerson = name => {
    console.log(`안녕, ${name}!`);
};
greetPerson("수진"); // 결과: 안녕, 수진!

// 2. 매개변수가 없거나 두 개 이상일 경우 괄호 필수
const showCurrentTime = () => {
    console.log(new Date().toLocaleTimeString());
};
showCurrentTime(); // 현재 시각 출력 (예: 오후 11:30:45)

const divide = (a, b) => {
    return a / b;
};
console.log(divide(10, 2)); // 결과: 5

// 3. 함수 본문이 한 줄이고 `return`이 있는 경우, 중괄호와 `return` 생략 가능
const subtract = (x, y) => x - y;
console.log(subtract(10, 4)); // 결과: 6

// 객체를 반환할 때는 소괄호로 감싸야 합니다.
const createUser = (id, name) => ({ id: id, name: name });
console.log(createUser(1, "지훈")); // 결과: { id: 1, name: '지훈' }

매개변수가 하나면 괄호를 생략할 수 있고, 없거나 둘 이상이면 괄호가 필요합니다. (x, y) => x - y처럼 중괄호 없이 식 하나만 쓰면 그 식의 값을 바로 돌려줍니다. 하지만 x => { x * 2; }처럼 중괄호를 붙이면 한 줄이어도 return이 없어 undefined를 돌려주므로, 중괄호를 쓸 때는 x => { return x * 2; }처럼 직접 return을 씁니다. 객체를 돌려줄 때 ({ id: id, name: name })처럼 소괄호로 감싸는 것도, 객체의 중괄호를 함수 본문으로 읽지 않게 하기 위해서입니다. 화살표 함수는 this가 정해지는 방식도 일반 함수와 다른데, 6장에서 다룹니다.

매개변수 기본값

인수를 넘기지 않았거나 undefined를 넘겼을 때 쓸 기본값을 매개변수에 정할 수 있습니다. 매개변수 이름 뒤에 = 기본값을 붙이는 형식이며, 다음 코드는 같은 함수를 이름을 넘겨 한 번, 아무것도 넘기지 않고 한 번 호출합니다.

function welcome(name = "손님") { // name 매개변수의 기본값을 "손님"으로 설정
    console.log(`환영합니다, ${name}!`);
}

welcome("민준"); // 결과: 환영합니다, 민준!
welcome();      // 결과: 환영합니다, 손님! (인수를 넘기지 않아 기본값이 사용됨)

기본값은 값이 undefined일 때만 쓰입니다. 그래서 welcome(undefined)는 “환영합니다, 손님!”을 출력하지만, welcome(null)은 “환영합니다, null!”을, welcome("")은 이름 자리가 빈 인사를 출력합니다. null과 빈 문자열은 직접 넘긴 값으로 그대로 쓰이기 때문입니다. 기본값은 빠진 인수를 채울 뿐, 입력이 올바른지 검사하지는 않습니다.

더 깊이: 기본값은 언제 계산될까

기본값의 식은 함수를 정의할 때 한 번 계산되는 것이 아니라, 기본값이 필요한 호출마다 새로 계산됩니다. 그래서 function append(value, list = []) { list.push(value); return list; }에서 append(1)은 [1], 이어서 호출한 append(2)는 [1, 2]가 아니라 [2]를 돌려줍니다. 호출마다 새 빈 배열이 만들어지기 때문입니다. 함수를 정의할 때 기본값을 한 번만 만들어 모든 호출이 함께 쓰는 Python과는 반대이므로, 두 언어를 오가는 사람이 헷갈리기 쉬운 부분입니다.

기본값의 식에서는 왼쪽에 먼저 나온 매개변수를 쓸 수 있습니다. function greet(name, greeting, message = greeting + " " + name)처럼 앞의 두 값으로 세 번째 기본값을 만들 수 있지만, 오른쪽에 있는 매개변수는 아직 정해지지 않았으므로 쓸 수 없습니다. 또 기본값의 식은 함수 본문과 별도의 범위에서 계산되므로, 본문 안에서 선언한 변수나 함수를 기본값에서 부르면 ReferenceError가 납니다.

기본값은 함수의 length 속성에도 영향을 줍니다. length는 함수가 기대하는 인수의 개수를 나타내는데, 처음으로 기본값이 붙은 매개변수와 그 뒤의 매개변수는 세지 않습니다. 그래서 function f(x = 1, y) {}의 f.length는 0입니다. 자세한 규칙은 MDN의 기본값 매개변수 설명에 있습니다.

나머지 매개변수

나머지 매개변수(rest parameter)는 넘겨받은 인수들을 배열로 모읍니다. 매개변수 이름 앞에 ...을 붙입니다.

function sumAll(...numbers) { // numbers는 배열이 됩니다.
    let total = 0;
    for (let number of numbers) { // for...of 반복문은 배열의 요소를 순회할 때 유용합니다.
        total += number;
    }
    return total;
}

console.log(sumAll(1, 2, 3));         // 결과: 6
console.log(sumAll(10, 20, 30, 40));  // 결과: 100
console.log(sumAll());                // 결과: 0

sumAll(1, 2, 3)을 호출하면 numbers는 [1, 2, 3]이 되고, 인수를 하나도 넘기지 않으면 빈 배열이 되어 합계는 0입니다. for...of는 배열의 값을 하나씩 꺼내 반복합니다.


스코프

스코프는 어떤 변수에 어디서 접근할 수 있는지 정하는 규칙입니다. 변수를 어디에 선언했는지에 따라 쓸 수 있는 범위가 달라지며, 이는 프로그램을 예측 가능하게 만드는 데 중요합니다. 같은 이름이 여러 곳에 있으면 지금 위치에서 가장 가까운 선언이 쓰입니다. 또 함수를 호출한 쪽의 변수가 호출받은 함수 안에서 저절로 보이는 것은 아닙니다. 여기서는 전역과 지역을 먼저 비교하고, 지역 안에서 함수와 블록의 차이를 봅니다.

전역 스코프

다음 코드는 일반 <script>에서 실행한다고 가정합니다. 함수나 블록 밖의 맨 위에서 선언한 변수는 전역 스코프(global scope)에 놓여, 그 뒤의 코드 어디서나 읽을 수 있습니다. <script type="module">이나 모듈 파일의 맨 위에서 선언한 변수는 그 모듈의 스코프에 놓이며, 다른 모듈에서는 보이지 않습니다.

let globalVar = "나는 전역 변수입니다."; // 전역 변수 선언

function accessGlobal() {
    console.log(globalVar); // 함수 내부에서 전역 변수에 접근 가능
}

accessGlobal();     // 결과: 나는 전역 변수입니다.
console.log(globalVar); // 전역에서 접근 가능: 나는 전역 변수입니다.

전역 변수를 많이 쓰면 어디서 값이 바뀌는지 알기 어렵고, 다른 스크립트와 이름이 겹칠 위험이 커집니다. 그래서 꼭 필요할 때가 아니면 전역 변수를 피합니다.

지역 스코프

지역 스코프(local scope)는 특정 범위 안에서만 유효한 스코프로, 함수 스코프와 블록 스코프가 있습니다. var로 선언한 변수는 선언한 함수 안에서만 유효한 함수 스코프(function scope)를 따릅니다.

function createLocalVar() {
    var localVar = "나는 함수 스코프 변수입니다.";
    console.log(localVar);
}
createLocalVar(); // 결과: 나는 함수 스코프 변수입니다.
// console.log(localVar); // ReferenceError: localVar is not defined
                         // 함수 외부에서는 localVar에 접근할 수 없습니다.

let과 const로 선언한 변수는 선언한 블록 안에서만 유효한 블록 스코프(block scope)를 따릅니다. if나 for의 본문처럼 중괄호로 감싼 코드가 블록입니다. 다만 const user = { name: "민수" };의 중괄호는 객체를 만드는 표기라 블록이 아닙니다.

if (true) {
    let blockVar = "나는 블록 스코프 변수입니다.";
    const BLOCK_CONST = 123;
    console.log(blockVar);   // 결과: 나는 블록 스코프 변수입니다.
    console.log(BLOCK_CONST); // 결과: 123
}
// console.log(blockVar);   // ReferenceError: blockVar is not defined
// console.log(BLOCK_CONST); // ReferenceError: BLOCK_CONST is not defined
// 블록 외부에서는 접근할 수 없습니다.

정리하면 var는 함수 안에서 선언하면 그 함수의 범위를, 일반 스크립트의 맨 위에서 선언하면 전역을 따릅니다. let과 const는 블록 안에서 선언하면 그 블록의 범위를 따르고, 맨 위에서 선언하면 일반 스크립트인지 모듈인지에 따라 범위가 정해집니다. 또 let과 const는 선언하기 전에 읽으면 오류가 나므로, 범위 안에 있다고 언제든 읽을 수 있는 것은 아닙니다. 이 부분은 8장의 let, const와 블록 스코프에서 자세히 다룹니다.

스코프 체인

변수를 읽으면 자바스크립트는 먼저 지금의 스코프에서 이름을 찾고, 없으면 선언 위치를 기준으로 바깥 스코프로 한 단계씩 올라가며 찾습니다. 다음 일반 스크립트에서는 전역까지 이어지며, 이렇게 이어진 범위를 스코프 체인(scope chain)이라고 합니다.

let globalValue = "전역 값";

function outerFunction() {
    let outerValue = "외부 함수 값";

    function innerFunction() {
        let innerValue = "내부 함수 값";
        console.log(innerValue);  // "내부 함수 값" (현재 스코프)
        console.log(outerValue);  // "외부 함수 값" (상위 스코프)
        console.log(globalValue); // "전역 값" (최상위 스코프)
    }
    innerFunction();
    // console.log(innerValue); // ReferenceError: innerValue is not defined (innerFunction 내부 변수는 밖에서 접근 불가)
}

outerFunction();
// console.log(outerValue); // ReferenceError: outerValue is not defined (outerFunction 내부 변수는 밖에서 접근 불가)

innerFunction 안에서 innerValue는 자기 스코프에서, outerValue는 바깥 함수에서, globalValue는 전역에서 찾습니다. 반대로 바깥에서는 안쪽의 변수를 볼 수 없어서, outerFunction 안에서 innerValue를 읽거나 전역에서 outerValue를 읽으면 ReferenceError가 납니다.

클로저

스코프는 이름을 찾을 수 있는 범위를 정합니다. 그런데 함수가 끝난 뒤에도, 그 안에서 만든 함수가 바깥으로 전달되어 지역 변수를 쓰고 있다면 그 변수는 사라지지 않고 남습니다. 이렇게 함수가 자기가 만들어진 환경의 변수를 기억하는 것을 클로저(closure)라고 합니다.

function makeCounter() {
    let count = 0;
    return function () {
        count += 1;
        return count;
    };
}
const firstCounter = makeCounter();
const secondCounter = makeCounter();
console.log(firstCounter());  // 1
console.log(firstCounter());  // 2
console.log(secondCounter()); // 1

makeCounter()의 호출은 이미 끝났지만, 돌려받은 함수가 그 호출의 count를 계속 쓰므로 firstCounter()를 부를 때마다 1, 2로 늘어납니다. makeCounter()를 두 번 호출하면 호출마다 별개의 count가 만들어지므로, secondCounter()는 다시 1부터 시작합니다. 앞 그림의 점선이 이 환경 참조를 뜻하며, 끝난 호출을 실행 스택에 계속 쌓아 둔다는 뜻은 아닙니다. 클로저는 6장에서 자세히 다룹니다.


정리

  • 함수는 코드 묶음에 이름을 붙여 다시 쓰게 하며, 선언과 호출은 다른 일이고 return이 없으면 undefined를 돌려줍니다.
  • 함수는 function 선언, 변수에 넣는 함수 표현식, 짧게 쓰는 화살표 함수로 만들 수 있습니다.
  • 화살표 함수에서 중괄호 없이 쓴 식은 그 값을 돌려주지만, 중괄호를 쓰면 return을 직접 써야 합니다.
  • 매개변수 기본값은 값이 undefined일 때만 쓰이고, 나머지 매개변수는 넘겨받은 인수를 배열로 모읍니다.
  • var는 함수 스코프, let과 const는 블록 스코프를 따르며, 이름은 선언 위치를 기준으로 안에서 바깥으로 찾습니다.
  • 함수가 끝나도 안에서 만든 함수가 지역 변수를 쓰고 있으면 그 변수는 클로저로 남습니다.

연습 문제

1. 결제액 함수

정수 단가와 양의 정수 수량을 받아 결제액을 돌려주는 함수를 만들어 보세요. 상품 금액이 10,000원 이상이면 배송비가 0원, 미만이면 2,500원이라는 규칙입니다. 단가 2,000원에 수량 3개와 5개일 때의 결과를 구하고, 함수 안에서 출력하지 말고 호출한 곳에서 돌려받은 값을 출력하세요. 입력이 올바른지 검사하는 것은 이번 문제에서 제외합니다.

해설 보기
function paymentAmount(unitPrice, quantity) {
    const subtotal = unitPrice * quantity;
    const shipping = subtotal >= 10000 ? 0 : 2500;
    return subtotal + shipping;
}
console.log(paymentAmount(2000, 3)); // 8500
console.log(paymentAmount(2000, 5)); // 10000

첫 호출은 상품 금액 6,000원에 배송비 2,500원을 더해 8,500원입니다. 두 번째 호출은 상품 금액이 정확히 10,000원이라 >= 조건을 만족하므로 배송비가 0원입니다. subtotal과 shipping은 함수 안의 지역 변수라 밖에서 이름으로 읽을 수 없지만, 돌려준 값은 호출한 곳에서 쓸 수 있습니다.

2. 출력과 반환

다음 코드의 출력을 순서대로 적어 보세요.

function double(x) {
    console.log(x * 2);
}
const value = double(4);
console.log(value);
해설 보기

8과 undefined가 차례로 출력됩니다. double(4)를 호출하면 함수 안의 console.log()가 8을 출력하지만, return이 없으므로 함수는 undefined를 돌려주고 그 값이 value에 들어갑니다.

계산한 값을 다른 곳에서 쓰려면 console.log(x * 2) 대신 return x * 2;로 돌려줘야 합니다. 화면에 보이는 것과 돌려주는 것은 다른 일입니다.

3. 같은 이름의 변수

다음 코드의 세 출력은 무엇일까요?

let label = "전역";
function show() {
    let label = "함수";
    if (true) {
        let label = "블록";
        console.log(label);
    }
    console.log(label);
}
show();
console.log(label);
해설 보기

순서대로 블록, 함수, 전역입니다. if 블록 안의 console.log()는 가장 가까운 블록의 label을, 블록 밖의 함수 안에서는 함수의 label을, 함수 밖에서는 전역의 label을 찾습니다.

세 label은 이름만 같은 서로 다른 변수입니다. 안쪽에서 같은 이름을 선언하면 바깥의 변수가 가려질 뿐 바뀌지는 않으므로, 함수와 블록이 끝난 뒤에도 전역의 label은 “전역” 그대로입니다.

함수와 클로저, 매개변수 기본값의 규칙은 MDN의 함수와 기본값 매개변수 문서에서 확인할 수 있습니다.

다음 글 객체와 배열 기초에서는 여러 데이터를 이름이나 순서로 묶어 다루는 객체와 배열을 살펴봅니다.