ch12. 함수 — 내가 JS를 설계했다면 어떻게 도달했을까
모던 자바스크립트 Deep Dive 12장을 읽다가 한 문장에서 멈췄어요.
함수 표현식의 함수 이름은 함수 몸체 내에서만 참조할 수 있는 식별자다.
이게 무슨 말이지? 외부에서는 변수 이름으로 호출하고, 내부에서는 함수 이름으로 자기를 부른다고? 같은 함수에 이름이 두 갈래인 게 어색했어요. C나 파이썬에서는 함수 이름 하나로 안과 밖을 다 부르거든요.
그래서 직접 입장이 되어보기로 했습니다. 내가 JS 설계자라면, 어떤 시행착오를 거쳐 이 결론에 도달할까? 책을 덮고 머릿속으로 다시 만들어보는 거예요. 이 글은 그 회고록입니다.
1. 시작점: 함수도 그냥 값으로 다루고 싶다
맨 처음 든 생각은 단순했어요. 함수를 특별 취급하지 말자. 옆에 var a = 1 있는 것처럼, 함수도 그냥 변수에 묶어버리자.
var a = 1;
var f = function(x, y) { return x + y; };
f(2, 3); // 5
오 잘 되네요. a도 값이고 f도 값이에요. 함수가 일급 객체로 등극했어요.
이게 왜 좋냐면, 함수를 인자로 넘기거나 변수에 다시 담아서 옮길 수 있기 때문이에요.
var twice = f; // 같은 함수를 다른 이름으로
twice(10, 20); // 30 — 동작 OK
[1, 2, 3].map(function(n) { return n * 2; }); // 콜백으로 전달도 가능
여기까지는 행복합니다. 그런데 곧 문제를 만나요.
2. 그런데 함수면 재귀를 써야 하지 않나?
함수라면 자기 자신을 호출하는 순간이 와요. 피보나치, 팩토리얼, 트리 순회… 재귀 없이 짜기 귀찮은 게 너무 많아요.
C는 어떻게 했더라?
int fib(int n) {
return n < 2 ? n : fib(n-1) + fib(n-2);
}파이썬도 마찬가지예요.
def fib(n):
return n if n < 2 else fib(n-1) + fib(n-2)외부에서 부르는 이름과 내부에서 자기를 부르는 이름이 똑같아요. 그러면 우리도 똑같이 만들면 되겠죠?
var fib = function(n) {
return n < 2 ? n : fib(n-1) + fib(n-2);
};
fib(10); // 55
오 동작합니다. C나 파이썬과 다를 게 없네요. 끝난 줄 알았어요.
3. 그런데 우리는 ‘자유로운 JS’잖아?
여기서 우리만의 결정적 차이가 등장해요. 함수도 변수에 담긴 값이라는 우리의 철학이요.
C에서는 함수 이름을 한번 선언하면 같은 이름으로 변수를 만들 수 없어요. 컴파일러가 막아요. 그게 C의 규율이에요.
근데 우리는 다르잖아요. 함수도 그냥 값이에요. var fib = ...로 만든 거니까 다른 변수처럼 자유롭게 재할당할 수 있어야 해요. 그게 “자유"고 우리의 매력 포인트니까요.
var fib = function(n) { return n < 2 ? n : fib(n-1) + fib(n-2); };
fib = 10; // 어? 변수니까 당연히 가능해야지
여기까지는 별 문제 없어 보여요. fib라는 변수가 함수에서 숫자로 바뀐 것뿐이니까. 그런데 함정이 있어요.
var fib = function(n) { return n < 2 ? n : fib(n-1) + fib(n-2); };
var keep = fib; // 함수 객체를 다른 변수로 잡아둠
fib = 10; // 외부에서 fib를 재할당
keep(5); // 어떻게 될까?
keep은 그 함수 객체를 여전히 잡고 있어요. 그러니까 호출은 되겠죠. 함수 안에 들어가요. 그리고 만나요. fib(n-1)을.
이때 fib를 룩업해요. 어디서? 외부 스코프에서. 그런데 외부 fib는 이제 10이에요. 함수가 아니에요.
TypeError: fib is not a function큰일이에요. 함수가 자기 자신을 부를 수 없게 됐어요. 자유의 대가가 너무 컸어요.
이게 책에서 말하는 “외부 변수 의존 재귀의 위험성"이에요. 직접 마주쳐 보니 명확해져요.
4. 외부와 내부를 어떻게 분리할까?
문제의 본질은 이거예요.
외부에서 함수에 접근하는 이름과 함수가 자기 자신을 부르는 이름이 같은 변수 슬롯을 보고 있다. 외부에서 그 슬롯을 바꾸면 내부도 깨진다.
그러면 둘을 분리하면 되겠어요. 내부에서 자기를 부를 때는 함수 자체에 박혀 있는 별도의 이름을 쓰게 하자. 외부에서 절대 손댈 수 없는 이름으로요.
var fib = function self(n) { // self는 함수에 박힌 이름
return n < 2 ? n : self(n-1) + self(n-2);
};
var keep = fib;
fib = 10;
keep(5); // ✅ self는 외부 fib와 무관 → 여전히 동작
외부에서는 변수 식별자 fib로 부르고, 내부에서는 함수에 박힌 self로 자기를 불러요. 외부 fib를 뭐로 재할당하든 내부 self는 흔들리지 않아요.
이게 명명된 함수 표현식(named function expression) 의 정체예요. 이름이 두 갈래로 나뉘는 거죠.
- 외부용 이름 = 변수 식별자 (
fib) — 외부 스코프에 등록, 자유롭게 재할당 가능 - 내부용 이름 = 함수 자체에 박힌 이름 (
self) — 함수 몸체에서만 보임, 재할당 불가
5. 그런데 외부에서는 왜 self를 못 부를까?
여기서 한 발 더 들어가면 자연스러운 의문이 들어요.
self(5); // ❌ ReferenceError: self is not defined
분명히 코드에 self가 있는데 외부에서는 부를 수 없어요. 왜?
다시 처음 문제로 돌아가 보면 답이 나와요. 우리가 self를 만든 이유는 외부에서 손댈 수 없게 하기 위함이었어요. 만약 self도 외부 스코프에 등록됐다면? 누가 self = 10이라고 재할당해버리면 똑같은 문제가 다시 생겨요.
우리는
self를 함수가 자기 자신을 부르는 안전한 비밀 통로로 만들고 싶었지, 외부와 공유하는 또 다른 식별자로 만들고 싶었던 게 아니다.
self → 함수 객체라는 immutable 바인딩만 들어 있고, 함수 몸체는 이 환경을 부모 스코프로 갖습니다. 외부에서는 이 환경에 접근할 길이 없어서 self를 볼 수 없는 거예요.외부 스코프
↓
[ 비밀 환경: { self → 함수 } ] ← 함수 몸체만 이 환경을 본다
↓
함수 몸체외부에서는 이 비밀 환경에 접근할 길이 없어요. 함수 몸체 안에서만, 그것도 immutable로 보입니다.
6. 여기서 얻은 인사이트
이 시행착오를 거치고 나니까 이렇게 정리됐어요.
함수 표현식의 이름이 “함수 몸체 내에서만 참조할 수 있는 식별자"인 이유는, 단순히 그렇게 만든 게 아니라 외부 변수 재할당의 위험을 막기 위한 안전장치였어요.
- 외부에서 함수를 잃어버려도 (
fib = 10) 함수 자기 참조는 안전 - 함수가 다른 객체의 메서드로 옮겨가도 자기 참조는 그대로 작동
- IIFE처럼 외부에 잡아둘 변수 자체가 없는 경우에도 자기 참조 가능
// IIFE 안에서 자기 재귀 — 외부 변수 자체가 없는데도 가능
(function fib(n) {
return n < 2 ? n : fib(n-1) + fib(n-2);
})(10);7. 시점 전환 — 이번엔 함수를 ‘쓰는 쪽’에서 보자
지금까지 우리가 풀던 건 함수 안에서 자기를 부르는 문제였어요. 내부 스코프의 관점이었죠. 그래서 내부 전용 이름이라는 안전장치가 나왔고요.
이번엔 시점을 한 칸 밖으로 옮겨볼게요. 함수가 선언된 그 파일(스코프) 안에서 그 함수를 사용하는 입장이요. 외부 스코프 관점이에요.
함수를 한 파일에 모아두고 쓸 때 어떤 모양이 자연스러울까요? 직관적으로 떠오르는 건 이런 구조예요.
main();
function main() {
doStep1();
doStep2();
}
function doStep1() { ... }
function doStep2() { ... }main이 맨 위에 있고, main이 호출하는 헬퍼들이 아래에 정의돼 있어요. C, 파이썬, 자바, 어디서든 흔히 보는 구조예요. 추상도 높은 진입점이 위, 디테일이 아래. 코드를 읽을 때 “큰 그림 → 세부"로 자연스럽게 흘러가요.
그런데 이 흐름이 우리가 4번에서 만든 함수 표현식 형태에서는 깨져요.
main(); // ❌ TypeError: main is not a function
var main = function() {
doStep1();
};
var doStep1 = function() { ... };var는 변수 호이스팅 규칙을 따라요. 선언만 끌어올리고 함수 객체 할당은 그 자리에서 일어나죠. 그러니까 main()이 호출되는 시점에 main은 아직 undefined예요. 함수가 아니에요.
가독성을 살리려면 모든 정의를 위에 두고 호출은 아래에 두는 식으로 코드를 거꾸로 짜야 해요. 사람이 읽기엔 직관과 반대 방향이에요.
// 표현식만 쓰면 어쩔 수 없이 이런 모양이 된다
var doStep1 = function() { ... };
var doStep2 = function() { ... };
var main = function() {
doStep1();
doStep2();
};
main(); // 호출은 마지막에
여기서 우리가 원하는 게 명확해져요.
함수라면 이 파일 어디에 정의돼 있든 위치와 상관없이 부를 수 있어야 한다. 그래야 코드를 사람 읽는 순서대로 배치할 수 있다.
이걸 만족시키려면 함수 표현식과는 다른 동작이 필요해요. 변수처럼 동작하면 안 되고, 선언 위치와 무관하게 그 스코프의 영구 시민이 돼야 해요. 그래서 새 문법을 추가해요.
function main() { // 함수 선언문
doStep1();
doStep2();
}
function doStep1() { ... }
function doStep2() { ... }
main(); // ✅ 어디서든 호출 가능
이게 함수 선언문이고, 이때 일어나는 게 함수 호이스팅이에요. 변수 호이스팅과 결정적으로 달라요.
선언문은 함수 자체가 호이스팅되고, 표현식은 변수만 호이스팅된다. 12장에서 가장 자주 헷갈리는 부분이에요.
- 변수 호이스팅 (함수 표현식): 이름만 미리 등록, 값은
undefined. 할당은 코드 흐름대로. - 함수 호이스팅 (함수 선언문): 이름과 함수 객체까지 한 번에 등록. 즉시 호출 가능.
hello(); // ✅ "안녕"
function hello() { console.log("안녕"); }
bye(); // ❌ TypeError: bye is not a function
var bye = function() { console.log("잘가"); };8. 두 줄기가 같은 뿌리에서 나왔다
여기서 재밌는 게 보여요. 4~6번에서 한 얘기랑 7번에서 한 얘기가 별개처럼 보이지만, 사실 같은 뿌리에서 갈라져 나온 거예요.
| 관점 | 무엇을 원했나 | 결과 |
|---|---|---|
| 내부 스코프 | 함수 안에서 자기 자신을 안전하게 부르고 싶다 | 명명된 함수 표현식 — 외부와 분리된 내부 전용 immutable 이름 |
| 외부 스코프 | 파일 안 어디서든 함수를 부르고 싶다 (가독성) | 함수 선언문 — 위치 무관하게 살아 있는 함수 호이스팅 |
함수도 결국 값일 뿐이라는 일관성을 지키려다 보니 → 자기 참조에서 변수 재할당 위험이 따라왔고 → 그걸 막으려고 내부 전용 이름이 도입됐다. 같은 일관성 때문에 → 함수 표현식은 변수 호이스팅을 그대로 따르게 됐고 → 그 동작이 가독성과 충돌하니 → 위치 무관하게 살아 있는 함수 선언문이 따로 도입됐다.
함수에 이름이 여러 갈래로 등장하는 이유가, 결국 “함수도 값이다"라는 일관성과 “근데 함수만의 편의도 필요하다"는 실용성 사이의 절충에서 나온 거예요.
정리
내가 JS를 설계했다면, 함수의 두 가지 이름과 호이스팅 차이라는 기능에 이런 과정으로 도달했을 것 같아요.
[내부 스코프 관점]
- 함수를 값으로 다루고 싶다 → 함수 표현식 도입
- 함수면 재귀가 필요하다 → 자기 자신을 부를 방법 필요
- 외부 변수 의존은 재할당에 취약하다 → 외부와 분리된 내부 전용 이름
- 그 이름은 외부에서 손댈 수 없어야 한다 → 함수 몸체 안에서만 보이는 immutable 바인딩
[외부 스코프 관점]
- 표현식 형태만 있으면 가독성을 위해 호출을 항상 정의 뒤에 둬야 한다 → 위치 무관 호출이 필요
- 그러려면 함수가 스코프의 영구 시민이 되어야 한다 → 함수 선언문 도입
- 변수와는 다른 호이스팅 방식이 필요하다 → 함수 자체가 호이스팅되는 함수 호이스팅
책을 그냥 읽으면 “왜?“가 안 풀려요. 근데 직접 설계자 입장이 되어 보면 “아 이래서 이렇게 됐구나"가 자연스럽게 보여요. 결정마다 그 이전 결정의 부작용을 메우거나 다른 요구를 추가로 받아들이는 식으로 쌓여 있거든요.
다음 글에서는 12장 후반부 — 매개변수와 인수, arguments 객체, 다양한 함수 형태(IIFE, 콜백, 순수 함수) 를 정리해보려 해요. 이번 글에서 깐 흐름의 연장선에서 풀어보고 싶어요.