함수 심화 - JavaScript가 함수를 다루는 특별한 방식

JavaScript를 처음 배울 때, 변수 선언하고 조건문 쓰고 반복문 돌리는 것까지는 어느 언어든 비슷합니다. 그런데 함수를 깊이 파고들기 시작하면 얘기가 달라집니다. 분명 선언하지 않은 변수를 사용했는데 에러가 안 나고, 함수 안에서 this가 가리키는 대상이 호출할 때마다 바뀌고, 같은 이름의 함수를 두 번 선언하면 조용히 덮어쓰기가 됩니다. 다른 언어에서 넘어온 개발자들은 여기서 당황합니다.
이번 장에서는 호이스팅, 암묵적 매개변수, 함수 오버로딩, 메모이제이션을 다룹니다. 3장까지 기본 함수 문법과 클로저, 스코프를 익혔다면 준비가 된 셈이고요. 이 네 가지를 이해하고 나면, 다른 사람이 작성한 코드를 읽을 때 "왜 이렇게 짰지?"라는 의문이 줄고, 직접 짤 때도 버그가 눈에 띄게 줄어듭니다.
이 주제들은 서로 연결되어 있기도 합니다. 호이스팅을 이해해야 함수 선언의 순서를 제어할 수 있고, 암묵적 매개변수를 알아야 객체 안에서 함수가 어떻게 동작하는지 예측할 수 있죠. 함수 오버로딩 패턴은 arguments 객체와 직결되고, 메모이제이션은 클로저와 객체를 활용한 성능 최적화 기법입니다. 순서대로 읽어가면 자연스럽게 이어집니다.
호이스팅
코드가 위에서 아래로 실행되지 않는다?
프로그래밍을 배울 때 가장 먼저 배우는 규칙이 있습니다. "코드는 위에서 아래로 순서대로 실행된다." 대부분의 언어에서 이건 깨지지 않는 규칙인데, JavaScript에서는 예외가 있습니다.
다음 코드를 보겠습니다.
console.log(name); // undefined (에러가 아닙니다) var name = "김철수"; console.log(name); // "김철수"
첫 번째 줄에서 name이라는 변수를 아직 선언하지 않았는데도 에러가 나지 않습니다. undefined가 출력되거든요. 다른 언어였다면 "선언되지 않은 변수" 에러가 나야 정상인데, JavaScript는 조용히 undefined를 내보냅니다.
이 현상을 호이스팅이라고 부릅니다. 영어로 hoisting, "끌어올림"이라는 뜻입니다. 코드에서 변수나 함수의 선언이 해당 스코프의 맨 위로 끌어올려진 것처럼 동작하는 현상입니다.
여기서 하나 짚고 넘어가면, MDN 공식 문서에 따르면 호이스팅이라는 용어는 ECMAScript 명세에 공식적으로 존재하지 않습니다. 실제로 코드가 물리적으로 이동하는 것도 아닙니다. 이 현상은 JavaScript 엔진이 코드를 실행하기 전에 거치는 준비 과정 때문에 발생합니다.
왜 이런 동작이 생겼는가
JavaScript 엔진이 코드를 실행할 때는 두 단계를 거칩니다.
첫 번째는 생성 단계입니다. 엔진이 코드를 한 번 쭉 훑으면서 "어떤 변수와 함수가 선언되어 있는지"를 미리 파악하는 거죠. 이때 변수 이름을 메모리에 등록하고, var로 선언된 변수는 undefined라는 임시 값을 넣어둡니다.
두 번째는 실행 단계입니다. 코드를 위에서 아래로 한 줄씩 실행하면서 실제 값을 할당합니다.
아까 코드를 다시 보면 이해가 됩니다.
// === 생성 단계에서 엔진이 하는 일 === // "name이라는 변수가 있구나. 일단 undefined로 등록해두자." // === 실행 단계 === console.log(name); // undefined (생성 단계에서 등록한 값) var name = "김철수"; // 이제야 "김철수"가 할당됨 console.log(name); // "김철수"
다음 그림은 JavaScript 엔진이 코드를 실행하기 전에 거치는 두 단계를 보여줍니다.

생성 단계에서 var로 선언된 변수는 undefined로 초기화되고, 함수 선언문은 전체가 등록됩니다. 반면 let과 const는 선언만 등록되고 TDZ에 놓여 접근할 수 없습니다. 실행 단계에서 비로소 실제 값이 할당됩니다.
JavaScript가 1995년에 만들어졌을 때, 브렌던 아이크는 이 언어를 10일 만에 설계했습니다. 당시에는 웹 페이지에 간단한 상호작용을 추가하는 게 목적이었고, 함수를 어디서든 호출할 수 있도록 유연하게 설계하는 게 우선이었습니다. 호이스팅은 그 유연함을 추구하다 보니 생긴 부산물이라고 볼 수 있는데요. 함수를 선언하기 전에 호출해도 동작하게 만들려다 보니, 변수에도 같은 메커니즘이 적용되면서 예상치 못한 동작이 생긴 겁니다.
var의 호이스팅이 만드는 버그
var의 호이스팅이 실제로 어떤 문제를 만드는지 보겠습니다.
var count = 10; function printCount() { console.log(count); // undefined가 출력됩니다 var count = 20; console.log(count); // 20 } printCount();
첫 번째 console.log에서 10이 출력될 것 같지만, undefined가 나옵니다. printCount 함수 안에 var count = 20이 있기 때문에, 함수 스코프 안에서 count가 호이스팅되거든요. 바깥의 count = 10은 가려지고, 아직 값이 할당되기 전인 undefined가 출력되는 겁니다.
이런 버그는 코드가 길어지면 찾기 어렵습니다. 변수 이름이 겹치는 줄도 모르고, 왜 undefined가 나오는지 한참을 헤매게 됩니다.
또 하나 흔한 실수가 있습니다.
for (var i = 0; i < 3; i++) { setTimeout(function() { console.log(i); }, 1000); } // 1초 후 출력: 3, 3, 3 (0, 1, 2가 아닙니다)
왜 0, 1, 2가 아니라 3, 3, 3이 나올까요? 이 코드에는 함정이 두 개 있습니다. 첫째, setTimeout에 전달한 함수는 "지금 당장" 실행되지 않고 1초 뒤에 실행됩니다. 그 1초 사이에 for 반복문은 이미 끝나 있죠. 둘째, var는 블록 스코프가 아니라 함수 스코프를 가집니다. for문의 중괄호 안에서 선언했지만, i는 for문 바깥에서도 살아 있습니다. 결국 1초 뒤 세 개의 콜백이 실행될 때, 세 개 모두 같은 i를 바라보고 있고, 그 i는 이미 3이 된 상태입니다. 이 문제는 JavaScript 커뮤니티에서 수년간 골칫거리였습니다.
let과 const가 등장한 이유
ES6(2015년)에서 let과 const가 등장한 가장 큰 이유는 바로 var 호이스팅이 만드는 문제점을 해결하기 위해서입니다. 바로 앞에서 본 setTimeout + var 문제도 let으로 바꾸면 깔끔하게 해결되고요.
for (let i = 0; i < 3; i++) { setTimeout(function() { console.log(i); }, 1000); } // 1초 후 출력: 0, 1, 2 (의도한 대로 동작합니다)
let은 블록 스코프를 가지기 때문에, for문이 반복될 때마다 새로운 i가 만들어집니다. 각 콜백이 자기만의 i를 가지게 되어, 1초 뒤에 각각 0, 1, 2를 출력하는 거죠. var에서 let으로 한 글자만 바꿨을 뿐인데 결과가 완전히 달라집니다. 이게 블록 스코프의 실질적인 가치입니다.
console.log(age); // ReferenceError: Cannot access 'age' before initialization let age = 25;
let으로 선언하면 선언 전에 접근했을 때 에러가 납니다. 이것이 정상적인 동작입니다. 선언하지 않은 변수에 접근하면 에러가 나야 버그를 빨리 발견할 수 있습니다.
여기서 좀 의외인 부분이 있는데요. let과 const도 사실 호이스팅은 됩니다. 엔진은 생성 단계에서 "이런 변수가 있다"는 사실을 미리 파악합니다. 하지만 var와 달리 undefined로 초기화하지 않습니다. 선언문이 실행되기 전까지 접근할 수 없는 상태, 즉 TDZ에 놓입니다.
TDZ는 Temporal Dead Zone의 약자로, 직역하면 "일시적 사각지대"입니다. 변수가 스코프 시작 지점에서 선언문까지 접근 불가능한 구간을 말합니다.
{ // --- TDZ 시작 (score는 존재하지만 접근 불가) --- console.log(score); // ReferenceError // --- TDZ 끝 --- let score = 100; // 이 줄이 실행되어야 접근 가능 console.log(score); // 100 }
MDN의 let 문서는 이를 명확히 설명합니다. "let 변수는 블록 시작부터 초기화 실행까지 TDZ에 있으며, 이 구간에서 접근하면 ReferenceError가 발생합니다."
TDZ는 ES6 설계자들이 의도적으로 만든 안전장치입니다. var의 "조용한 undefined" 문제를 없애기 위한 것이고요. 에러가 나야 개발자가 코드를 고칠 수 있으니까요.
함수 선언문 vs 함수 표현식
호이스팅에서 실무적으로 가장 눈여겨봐야 할 부분은 함수 선언문과 함수 표현식의 차이입니다.
// 함수 선언문 - 호이스팅됩니다 sayHello(); // "안녕하세요!" (에러 없이 동작) function sayHello() { console.log("안녕하세요!"); }
함수 선언문은 전체가 호이스팅됩니다. 이름뿐 아니라 본문까지 통째로 끌어올려지기 때문에, 선언 전에 호출해도 정상적으로 동작하죠. var 호이스팅과 다른 점인데, var는 이름만 올라가고 값은 undefined인 반면 함수 선언문은 함수 전체가 올라갑니다.
// 함수 표현식 - 호이스팅되지 않습니다 sayGoodbye(); // TypeError: sayGoodbye is not a function var sayGoodbye = function() { console.log("안녕히 가세요!"); };
함수 표현식은 변수에 함수를 대입하는 형태입니다. 이때 var sayGoodbye는 호이스팅되어 undefined가 되지만, 함수 자체는 대입되지 않은 상태입니다. undefined를 함수처럼 호출하려고 하니 TypeError가 발생합니다.
const로 함수 표현식을 쓰면 더 안전합니다.
sayGoodbye(); // ReferenceError: Cannot access 'sayGoodbye' before initialization const sayGoodbye = function() { console.log("안녕히 가세요!"); };
이 경우 TDZ 덕분에 ReferenceError가 나서, 문제를 더 빠르게 발견할 수 있습니다. "undefined is not a function"이라는 모호한 에러보다 "Cannot access before initialization"이 훨씬 명확합니다.
같은 이름의 함수를 두 번 선언하면
함수 선언문의 호이스팅에서 주의할 점이 하나 더 있습니다.
function greet() { console.log("좋은 아침입니다!"); } function greet() { console.log("좋은 저녁입니다!"); } greet(); // "좋은 저녁입니다!"
같은 이름의 함수 선언문이 두 개 있으면, 나중에 선언된 것이 앞의 것을 덮어씁니다. JavaScript는 이에 대해 아무런 경고나 에러 메시지를 보여주지 않습니다. 코드가 수백 줄이 넘어가면 이런 실수를 찾기가 매우 어렵습니다.
호이스팅을 이해하면 할 수 있는 것
호이스팅을 이해하고 나면 다음과 같은 판단을 할 수 있습니다.
첫째, 변수 선언에서 var 대신 let과 const를 써야 하는 이유를 논리적으로 설명할 수 있습니다. "그냥 최신 문법이니까"가 아니라, var의 호이스팅이 만드는 버그를 구체적으로 알기 때문에 확신을 가지고 let/const를 선택할 수 있습니다.
둘째, 다른 사람이 작성한 오래된 JavaScript 코드를 읽을 수 있습니다. 2015년 이전에 작성된 코드에는 var가 가득합니다. 호이스팅을 이해하지 못하면 이런 코드의 동작을 예측할 수 없습니다.
셋째, 함수 선언문과 함수 표현식을 상황에 맞게 선택할 수 있습니다. 파일 어디서든 호출하고 싶은 유틸리티 함수는 함수 선언문으로, 특정 시점 이후에만 사용해야 하는 콜백은 const로 함수 표현식으로 작성하는 식입니다.
암묵적 매개변수
함수에 직접 전달하지 않은 값이 들어온다
함수를 호출할 때, 우리가 직접 전달하는 인자 외에 JavaScript 엔진이 몰래 전달하는 값들이 있습니다. 이것을 암묵적 매개변수라고 부릅니다. 대표적인 것이 this와 arguments입니다.
"암묵적"이라는 말은 "선언하지 않았는데 사용할 수 있는"이라는 뜻입니다. 함수를 정의할 때 매개변수 목록에 this나 arguments를 적지 않아도, 함수 안에서 자유롭게 사용할 수 있습니다.
function showInfo() { console.log(this); console.log(arguments); } showInfo("안녕", 42);
showInfo의 매개변수 목록은 비어 있습니다. 하지만 함수 안에서 this와 arguments에 접근할 수 있습니다. this는 함수가 어떻게 호출되었는지에 따라 결정되는 "문맥 객체"이고, arguments는 전달된 모든 인자를 담고 있는 유사 배열 객체입니다.
this - 호출 방식이 결정하는 문맥
this는 JavaScript에서 가장 많이 헷갈리는 개념입니다. 대부분의 언어에서 this(또는 self)는 "이 메서드가 속한 객체"를 가리키는데, JavaScript의 this는 함수가 정의된 위치가 아니라 호출되는 방식에 따라 달라집니다.
이 차이가 왜 중요한지 예제로 보겠습니다.
var user = { name: "김영희", greet: function() { console.log(this.name + "님, 환영합니다!"); } }; user.greet(); // "김영희님, 환영합니다!"
user.greet()로 호출하면 this는 user 객체를 가리킵니다. 여기까지는 직관적입니다.
하지만 같은 함수를 다른 방식으로 호출하면 결과가 달라집니다.
var user = { name: "김영희", greet: function() { console.log(this.name + "님, 환영합니다!"); } }; var myGreet = user.greet; // 함수를 변수에 복사 myGreet(); // 브라우저: "님, 환영합니다!" / Node.js: "undefined님, 환영합니다!"
user.greet 함수를 myGreet이라는 변수에 담고 호출하면, 더 이상 user 객체의 메서드로서 호출된 것이 아닙니다. 그냥 일반 함수로 호출된 것입니다. 이때 this는 전역 객체를 가리키게 됩니다. 브라우저에서는 전역 객체가 window이고, window.name은 빈 문자열("")이므로 "님, 환영합니다!"가 출력됩니다. Node.js에서는 전역 객체에 name 속성이 없어서 undefined가 되고, "undefined님, 환영합니다!"가 출력됩니다. 실행 환경에 따라 결과가 다르지만, 핵심은 같습니다. this가 user가 아닌 전역 객체로 바뀌었다는 것입니다.
이게 "this는 호출 방식에 따라 결정된다"는 말의 의미입니다.
this의 네 가지 규칙
MDN 공식 문서에서 정리한 this 바인딩 규칙을 네 가지로 정리할 수 있습니다.
다음 그림은 this가 호출 방식에 따라 어떻게 달라지는지 네 가지 규칙을 한눈에 보여줍니다.

일반 함수 호출에서는 전역 객체, 메서드 호출에서는 점(.) 앞의 객체, 생성자 호출에서는 새로 만든 객체, call/apply/bind에서는 직접 지정한 객체가 this가 됩니다. 화살표 함수는 자체 this가 없어서 바깥 스코프의 this를 그대로 사용합니다.
규칙 1. 일반 함수 호출에서 this는 전역 객체입니다.
function showThis() { console.log(this === globalThis); // true (브라우저, Node.js 모두) } showThis();
그냥 함수를 호출하면 this는 전역 객체를 가리킵니다. 브라우저에서는 window, Node.js에서는 global이 전역 객체인데, globalThis는 ES2020에서 도입된 표준으로 어떤 환경에서든 전역 객체를 가리킵니다. 단, 'use strict'를 선언하거나 ES6 모듈, 클래스 안에서 자동으로 적용되는 엄격 모드에서는 this가 undefined가 됩니다.
규칙 2. 메서드 호출에서 this는 그 메서드를 가진 객체입니다.
var calculator = { value: 0, add: function(num) { this.value = this.value + num; console.log("현재 값: " + this.value); } }; calculator.add(5); // "현재 값: 5" (this는 calculator) calculator.add(3); // "현재 값: 8" (this는 calculator)
점(.) 앞의 객체가 this가 됩니다. calculator.add()에서 점 앞의 calculator가 this입니다.
규칙 3. 생성자 함수에서 this는 새로 만들어진 객체입니다.
function Person(name) { this.name = name; this.sayName = function() { console.log("제 이름은 " + this.name + "입니다."); }; } var person1 = new Person("이철수"); person1.sayName(); // "제 이름은 이철수입니다."
new 키워드와 함께 함수를 호출하면, JavaScript가 빈 객체를 하나 만들고 그 객체를 this로 설정합니다. 생성자 안에서 this.name = name은 그 새 객체에 name 속성을 추가하는 것입니다.
규칙 4. call, apply, bind로 this를 직접 지정할 수 있습니다.
function introduce(greeting) { console.log(greeting + ", 저는 " + this.name + "입니다."); } var student = { name: "박지민" }; var teacher = { name: "최선생" }; introduce.call(student, "안녕하세요"); // "안녕하세요, 저는 박지민입니다." introduce.call(teacher, "안녕하세요"); // "안녕하세요, 저는 최선생입니다."
call은 첫 번째 인자로 this가 될 객체를 지정하고, 나머지 인자들을 함수에 전달합니다. apply는 call과 같은데 인자를 배열로 전달합니다. bind는 this가 고정된 새 함수를 반환합니다.
// apply는 인자를 배열로 전달 introduce.apply(student, ["반갑습니다"]); // "반갑습니다, 저는 박지민입니다." // bind는 새 함수를 만듭니다 var studentIntro = introduce.bind(student); studentIntro("여보세요"); // "여보세요, 저는 박지민입니다."
this를 잃어버리는 문제
this가 호출 방식에 따라 결정되기 때문에 생기는 대표적인 문제가 있습니다. javascript.info에서도 "losing this"라는 제목으로 다루는 흔한 패턴입니다.
var timer = { seconds: 0, start: function() { setInterval(function() { this.seconds = this.seconds + 1; console.log(this.seconds + "초 경과"); }, 1000); } }; timer.start(); // 기대: "1초 경과", "2초 경과", ... // 실제: "NaN초 경과", "NaN초 경과", ...
setInterval 안의 콜백 함수는 일반 함수 호출이기 때문에, this가 timer가 아니라 전역 객체를 가리키거든요. 전역 객체에는 seconds가 없으니 undefined + 1이 되어 NaN이 출력됩니다.
이 문제를 해결하는 고전적인 방법은 this를 변수에 미리 저장해두는 것입니다.
var timer = { seconds: 0, start: function() { var self = this; // this를 변수에 저장 setInterval(function() { self.seconds = self.seconds + 1; console.log(self.seconds + "초 경과"); }, 1000); } };
var self = this 또는 var that = this라는 패턴은 ES6 이전의 JavaScript 코드에서 정말 많이 보입니다. 구식이지만 원리를 이해하기에는 좋은 예제입니다.
화살표 함수가 this 문제를 해결하다
ES6에서 도입된 화살표 함수는 자체적인 this를 가지지 않습니다. 대신 화살표 함수가 정의된 위치의 this를 그대로 사용합니다. MDN 공식 문서에서는 이를 "렉시컬 this"라고 설명합니다.
var timer = { seconds: 0, start: function() { setInterval(() => { // 화살표 함수 사용 this.seconds = this.seconds + 1; console.log(this.seconds + "초 경과"); }, 1000); } }; timer.start(); // "1초 경과", "2초 경과", "3초 경과" ... (정상 동작)
화살표 함수 안의 this는 화살표 함수가 정의된 시점에서의 this, 즉 start 메서드의 this인 timer 객체를 가리킵니다. 더 이상 var self = this 같은 우회가 필요 없습니다.
다만 주의할 게 있는데요. 화살표 함수는 자체 this가 없기 때문에, 객체의 메서드로 쓰면 안 됩니다.
var user = { name: "김영희", greet: () => { console.log(this.name + "님, 환영합니다!"); // this가 user가 아니라 외부 스코프의 this를 가리킵니다 } }; user.greet(); // "님, 환영합니다!" (this.name이 undefined)
이 경우 화살표 함수의 this는 user가 아니라, user 객체 바깥의 this(전역 객체)를 가리킵니다. 객체의 메서드를 정의할 때는 일반 함수를 사용해야 합니다.
arguments 객체
모든 일반 함수(화살표 함수 제외) 안에서는 arguments라는 특별한 객체를 사용할 수 있습니다.
function sum() { console.log(arguments); // { 0: 1, 1: 2, 2: 3, length: 3 } console.log(arguments.length); // 3 console.log(arguments[0]); // 1 var total = 0; for (var i = 0; i < arguments.length; i++) { total = total + arguments[i]; } return total; } console.log(sum(1, 2, 3)); // 6 console.log(sum(10, 20)); // 30 console.log(sum(5)); // 5
함수를 정의할 때 매개변수를 하나도 선언하지 않았는데, 인자를 몇 개든 전달할 수 있고 arguments로 접근할 수 있습니다. JavaScript는 함수에 선언된 매개변수보다 많거나 적은 인자를 전달해도 에러를 내지 않습니다. 그래서 전달된 모든 인자를 담아두는 객체가 필요했던 거죠.
arguments는 배열처럼 생겼지만 배열이 아닙니다. "유사 배열 객체"라고 부릅니다. length 속성이 있고 인덱스로 접근할 수 있지만, forEach나 map 같은 배열 메서드는 사용할 수 없습니다.
나머지 매개변수가 arguments를 대체하다
ES6에서는 나머지 매개변수 문법이 도입되었습니다.
function sum(...numbers) { console.log(numbers); // [1, 2, 3] (진짜 배열) console.log(Array.isArray(numbers)); // true var total = 0; for (var i = 0; i < numbers.length; i++) { total = total + numbers[i]; } return total; } console.log(sum(1, 2, 3)); // 6
...numbers는 전달된 모든 인자를 진짜 배열로 모아줍니다. arguments와 달리 배열 메서드를 바로 사용할 수 있고, 화살표 함수에서도 동작합니다.
나머지 매개변수는 선언된 매개변수 이후의 나머지 인자만 모을 수도 있습니다.
function introduce(greeting, ...names) { console.log(greeting); // "안녕하세요" console.log(names); // ["김철수", "이영희", "박지민"] } introduce("안녕하세요", "김철수", "이영희", "박지민");
첫 번째 인자는 greeting에 들어가고, 나머지가 names 배열에 모입니다. arguments는 모든 인자를 담기 때문에 이런 구분이 불가능했습니다.
암묵적 매개변수를 이해하면 할 수 있는 것
this를 이해하면 객체 지향 패턴을 제대로 활용할 수 있습니다. 메서드 체이닝, 이벤트 핸들러, 콜백 패턴에서 this가 어디를 가리키는지 예측할 수 있으니, 프레임워크나 라이브러리를 쓸 때 훨씬 수월하고요.
arguments와 나머지 매개변수를 이해하면, 인자 개수가 유동적인 유틸리티 함수를 만들 수 있습니다. 다음 섹션의 함수 오버로딩 패턴도 이 지식이 바탕이 됩니다.
함수 오버로딩
같은 이름, 다른 동작을 원할 때
Java나 C++ 같은 언어에서는 같은 이름의 함수를 매개변수의 수나 타입을 달리해서 여러 개 만들 수 있습니다. 이것을 함수 오버로딩이라고 합니다.
// Java의 함수 오버로딩 (참고용) int add(int a, int b) { return a + b; } double add(double a, double b) { return a + b; } int add(int a, int b, int c) { return a + b + c; }
같은 add라는 이름이지만, 정수 두 개를 받는 버전, 실수 두 개를 받는 버전, 정수 세 개를 받는 버전이 각각 존재합니다. 컴파일러가 호출 시 전달된 인자의 수와 타입을 보고 어떤 함수를 실행할지 결정합니다.
JavaScript에서는 이것이 불가능합니다. 같은 이름의 함수를 두 번 선언하면, 뒤에 선언된 함수가 앞의 것을 완전히 덮어씁니다.
function add(a, b) { return a + b; } function add(a, b, c) { return a + b + c; } console.log(add(1, 2)); // NaN (a=1, b=2, c=undefined이므로 1+2+undefined) console.log(add(1, 2, 3)); // 6
두 번째 add가 첫 번째 add를 덮어썼기 때문에, add(1, 2)를 호출해도 세 개의 매개변수를 받는 버전이 실행됩니다. c에는 undefined가 들어가고, 2 + undefined는 NaN이 됩니다.
왜 JavaScript에는 오버로딩이 없는가
GeeksforGeeks의 분석에 따르면, JavaScript에 함수 오버로딩이 없는 근본적인 이유는 동적 타입 때문입니다. Java나 C++은 정적 타입 언어여서, 컴파일 시점에 매개변수의 타입을 알 수 있습니다. "int 두 개를 받는 add"와 "double 두 개를 받는 add"를 구별할 수 있습니다.
하지만 JavaScript는 변수에 타입이 없습니다. 숫자를 넣을 수도 있고, 문자열을 넣을 수도 있습니다. 컴파일 단계도 없습니다. 그래서 "어떤 add를 호출해야 하는지"를 결정할 근거가 없습니다.
JavaScript의 설계 철학은 "하나의 함수가 유연하게 여러 상황에 대응한다"입니다. 오버로딩처럼 여러 함수를 만드는 대신, 하나의 함수 안에서 인자를 확인하고 다르게 동작하는 방식을 택한 거죠.
arguments.length를 이용한 오버로딩 패턴
JavaScript에서 오버로딩을 흉내 내는 가장 전통적인 방법은, 전달된 인자의 수를 확인하는 것입니다.
function makeMessage(name, age, job) { if (arguments.length === 1) { return name + "님, 환영합니다!"; } else if (arguments.length === 2) { return name + "님(" + age + "세), 환영합니다!"; } else if (arguments.length === 3) { return name + "님(" + age + "세, " + job + "), 환영합니다!"; } } console.log(makeMessage("김철수")); // "김철수님, 환영합니다!" console.log(makeMessage("김철수", 30)); // "김철수님(30세), 환영합니다!" console.log(makeMessage("김철수", 30, "개발자")); // "김철수님(30세, 개발자), 환영합니다!"
하나의 함수가 인자 수에 따라 세 가지 다른 동작을 합니다. arguments.length로 몇 개의 인자가 전달되었는지 확인하고, 그에 맞는 로직을 실행합니다.
typeof를 이용한 오버로딩 패턴
인자의 수가 아니라 타입에 따라 다르게 동작하게 할 수도 있습니다.
function display(value) { if (typeof value === "string") { console.log("문자열: " + value); } else if (typeof value === "number") { console.log("숫자: " + value + " (두 배: " + value * 2 + ")"); } else if (Array.isArray(value)) { console.log("배열: " + value.length + "개 항목"); } else if (typeof value === "object" && value !== null) { console.log("객체: " + Object.keys(value).join(", ")); } } display("안녕하세요"); // "문자열: 안녕하세요" display(42); // "숫자: 42 (두 배: 84)" display([1, 2, 3]); // "배열: 3개 항목" display({ name: "철수" }); // "객체: name"
이 패턴은 실제로 많은 라이브러리에서 쓰입니다. jQuery가 대표적이죠. jQuery의 $() 함수는 문자열을 넣으면 CSS 셀렉터로 처리하고, DOM 요소를 넣으면 jQuery 객체로 감싸고, 함수를 넣으면 문서 로딩 완료 시 실행하는 콜백으로 처리합니다. 하나의 함수가 인자의 타입에 따라 완전히 다른 동작을 합니다.
jQuery 창시자 John Resig은 자신의 블로그에서 "addMethod"라는 오버로딩 패턴을 공개한 적이 있습니다. Function.prototype.length(함수에 선언된 매개변수 수)와 실제 전달된 인자 수를 비교하여, 클로저 체인으로 올바른 함수를 선택하는 기법입니다. 이 패턴은 jQuery 내부에서 실제로 사용됩니다.
기본 매개변수 - 현대적인 해결책
ES6에서 도입된 기본 매개변수는 함수 오버로딩의 가장 깔끔한 대안입니다.
function makeMessage(name, age, job) { if (age === undefined && job === undefined) { return name + "님, 환영합니다!"; } else if (job === undefined) { return name + "님(" + age + "세), 환영합니다!"; } else { return name + "님(" + age + "세, " + job + "), 환영합니다!"; } }
위 코드를 기본 매개변수로 다시 쓰면 이렇게 됩니다.
function createProfile(name, age = "비공개", job = "비공개") { return { name: name, age: age, job: job, toString: function() { var ageText = (typeof age === "number") ? age + "세" : age; return name + " / " + ageText + " / " + job; } }; } console.log(createProfile("김철수").toString()); // "김철수 / 비공개 / 비공개" console.log(createProfile("김철수", 30).toString()); // "김철수 / 30세 / 비공개" console.log(createProfile("김철수", 30, "개발자").toString()); // "김철수 / 30세 / 개발자"
age와 job에 기본값을 지정해두면, 인자를 전달하지 않았을 때 기본값이 사용됩니다. if 문으로 일일이 확인할 필요가 없습니다.
MDN 공식 문서에 따르면, 기본 매개변수는 undefined가 전달될 때만 적용됩니다. null, 0, 빈 문자열("") 같은 다른 falsy 값을 전달하면 기본값이 아닌 전달된 값이 사용됩니다. 이 부분은 꼭 기억해두어야 할 주의 사항입니다.
function setScore(score = 100) { console.log("점수: " + score); } setScore(); // "점수: 100" (기본값 사용) setScore(undefined); // "점수: 100" (기본값 사용) setScore(0); // "점수: 0" (0이 전달됨, 기본값 아님) setScore(null); // "점수: null" (null이 전달됨, 기본값 아님)
옵션 객체 패턴
인자가 많아지면, 순서를 기억하는 것이 부담이 됩니다. 이때 객체를 인자로 받는 패턴이 유용합니다.
function createUser(options) { var defaults = { name: "이름 없음", age: 0, job: "미정", active: true }; // options에 있는 값으로 defaults를 덮어씁니다 var config = {}; var keys = Object.keys(defaults); for (var i = 0; i < keys.length; i++) { var key = keys[i]; if (options[key] !== undefined) { config[key] = options[key]; } else { config[key] = defaults[key]; } } return config; } console.log(createUser({ name: "김철수" })); // { name: "김철수", age: 0, job: "미정", active: true } console.log(createUser({ name: "이영희", age: 28, job: "디자이너" })); // { name: "이영희", age: 28, job: "디자이너", active: true }
이 패턴은 인자의 순서를 몰라도 되고, 필요한 것만 전달하면 됩니다. jQuery, Lodash 같은 라이브러리가 이 방식을 많이 씁니다.
함수 오버로딩을 이해하면 할 수 있는 것
JavaScript에 공식적인 함수 오버로딩이 없다는 것을 알면, "왜 jQuery의 $() 함수가 그렇게 여러 가지 동작을 하는지"를 이해할 수 있습니다. 라이브러리 코드를 읽을 때 typeof 체크나 arguments.length 검사를 보면, "아, 오버로딩을 흉내 내는 것이구나"라고 바로 파악할 수 있습니다.
직접 유연한 API를 설계할 수도 있습니다. 기본 매개변수와 옵션 객체 패턴을 활용하면, 호출하는 쪽이 편리한 함수를 만들 수 있고요. 나중에 TypeScript를 배우면, 타입 시스템으로 오버로딩을 좀 더 체계적으로 관리하는 방법도 알게 됩니다.
메모이제이션
같은 계산을 왜 두 번 하나
학교에서 구구단 시험을 볼 때, 7 곱하기 8이 나오면 매번 7을 8번 더하지는 않습니다. "56"이라는 답을 이미 기억하고 있기 때문입니다. 메모이제이션도 같은 원리입니다. 한 번 계산한 결과를 기억해두고, 같은 입력이 들어오면 기억한 결과를 바로 돌려주는 기법입니다.
"메모이제이션"이라는 이름은 "memo"(메모)에서 온 것입니다. 결과를 메모해두는 것이니까요. Douglas Crockford는 저서 "JavaScript: The Good Parts"에서 메모이제이션을 "함수가 이전 연산 결과를 기억하기 위해 객체를 사용하는 최적화 기법"으로 정의합니다.
왜 이런 기법이 필요할까요? 간단한 예부터 보겠습니다.
function square(n) { console.log(n + "의 제곱을 계산합니다..."); return n * n; } console.log(square(5)); // "5의 제곱을 계산합니다..." → 25 console.log(square(5)); // "5의 제곱을 계산합니다..." → 25 console.log(square(5)); // "5의 제곱을 계산합니다..." → 25
같은 입력(5)으로 세 번 호출하면, 세 번 모두 계산을 합니다. 제곱은 간단한 연산이라 상관없지만, 계산이 복잡해지면 이야기가 달라집니다.
피보나치 수열로 보는 성능 차이
메모이제이션의 효과를 가장 극적으로 보여주는 예제가 피보나치 수열입니다. 피보나치 수열은 "앞의 두 수를 더하면 다음 수가 되는" 수열입니다. 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, ...
재귀로 구현하면 이렇습니다.
function fibonacci(n) { if (n <= 1) { return n; } return fibonacci(n - 1) + fibonacci(n - 2); } console.log(fibonacci(10)); // 55 console.log(fibonacci(20)); // 6765
이 코드는 정확하게 동작하지만, n이 커지면 심각하게 느려집니다. fibonacci(40)만 해도 컴퓨터가 몇 초간 멈출 수 있습니다. fibonacci(50)은 일반적인 컴퓨터에서 약 9분이 걸린다는 측정 결과도 있습니다.
왜 이렇게 느린지 fibonacci(5)의 호출 과정을 살펴보면 이해가 됩니다.
fibonacci(5) fibonacci(4) fibonacci(3) fibonacci(2) fibonacci(1) = 1 fibonacci(0) = 0 fibonacci(1) = 1 fibonacci(2) ← 이미 위에서 계산했는데 또 계산 fibonacci(1) = 1 fibonacci(0) = 0 fibonacci(3) ← 이것도 이미 계산했는데 또 계산 fibonacci(2) fibonacci(1) = 1 fibonacci(0) = 0 fibonacci(1) = 1
fibonacci(2)가 세 번, fibonacci(3)이 두 번 호출됩니다. n이 커질수록 이런 중복 계산이 기하급수적으로 늘어납니다. fibonacci(50)을 구하려면 약 200억 번의 함수 호출이 필요합니다.
메모이제이션 적용하기
이제 한 번 계산한 결과를 기억해두는 코드를 작성하겠습니다. 객체를 캐시로 사용합니다.
function fibonacciMemo() { var cache = {}; // 결과를 저장할 객체 function fib(n) { // 이미 계산한 적 있으면 저장된 결과를 반환 if (cache[n] !== undefined) { return cache[n]; } // 기본 경우 if (n <= 1) { cache[n] = n; return n; } // 계산하고 캐시에 저장 cache[n] = fib(n - 1) + fib(n - 2); return cache[n]; } return fib; } var fibonacci = fibonacciMemo(); console.log(fibonacci(10)); // 55 (즉시) console.log(fibonacci(20)); // 6765 (즉시) console.log(fibonacci(50)); // 12586269025 (즉시!)
cache라는 객체가 이미 계산한 결과를 저장합니다. fibonacci(50)을 호출하면, 0부터 50까지 각각 한 번씩만 계산합니다. 200억 번의 호출이 51번으로 줄어든 겁니다.
다음 그림은 메모이제이션 적용 전후의 차이를 보여줍니다.

왼쪽은 메모이제이션 없이 fibonacci(5)를 계산하는 과정입니다. 같은 값이 여러 번 반복 계산되어 fibonacci(50)은 약 200억 번의 호출이 필요합니다. 오른쪽은 cache 객체에 결과를 저장하여, 이미 계산한 값은 바로 가져오는 방식입니다. DEV Community의 측정에 따르면, 피보나치 50번째 항 계산이 약 564,000밀리초(9분 이상)에서 1밀리초 미만으로 단축됩니다.
여기서 클로저가 중요한 역할을 합니다. cache 객체는 fibonacciMemo 함수 안에 선언되어 있지만, 반환된 fib 함수가 이 cache에 계속 접근할 수 있습니다. 이것이 클로저입니다. 3장에서 배운 클로저가 이런 식으로 실전에서 활용됩니다.
범용 메모이제이션 함수 만들기
피보나치뿐 아니라 어떤 함수에든 메모이제이션을 적용할 수 있도록 범용 함수를 만들어 보겠습니다.
function memoize(fn) { var cache = {}; return function() { // 인자들을 문자열로 변환하여 캐시 키로 사용 var key = ""; for (var i = 0; i < arguments.length; i++) { if (i > 0) key = key + ","; key = key + arguments[i]; } if (cache[key] !== undefined) { console.log("캐시에서 가져옴: " + key); return cache[key]; } console.log("새로 계산: " + key); cache[key] = fn.apply(this, arguments); return cache[key]; }; }
이 memoize 함수는 다른 함수를 인자로 받아서, 메모이제이션이 적용된 새 함수를 반환합니다. arguments로 전달된 인자를 문자열로 변환해서 캐시 키로 사용합니다.
다만 이 구현에는 한계가 있습니다. 인자가 숫자나 문자열 같은 원시값이면 잘 동작하지만, 객체를 인자로 전달하면 문제가 생기거든요. JavaScript에서 객체를 문자열로 변환하면 "[object Object]"가 됩니다. 서로 다른 객체를 넣어도 캐시 키가 같아져서, 의도하지 않은 캐시 히트가 발생합니다. Lodash 같은 라이브러리가 _.memoize에 resolver 옵션을 제공하는 이유입니다. 캐시 키를 어떻게 만들지 사용자가 직접 지정할 수 있게 한 것입니다. 여기서는 원시값만 다루는 범위에서 사용한다는 점을 기억하면 됩니다.
실제로 사용해 보겠습니다.
function slowMultiply(a, b) { // 의도적으로 느린 연산을 시뮬레이션 var result = 0; for (var i = 0; i < 1000000; i++) { result = a * b; } return result; } var fastMultiply = memoize(slowMultiply); console.log(fastMultiply(7, 8)); // "새로 계산: 7,8" → 56 console.log(fastMultiply(7, 8)); // "캐시에서 가져옴: 7,8" → 56 console.log(fastMultiply(3, 4)); // "새로 계산: 3,4" → 12 console.log(fastMultiply(7, 8)); // "캐시에서 가져옴: 7,8" → 56
첫 번째 호출에서는 실제로 계산하지만, 같은 인자로 다시 호출하면 캐시에서 바로 결과를 가져옵니다. 이 memoize 함수에는 앞에서 배운 개념이 다 들어가 있습니다. 클로저(cache 접근), arguments 객체(인자 수집), this 바인딩(apply 사용)까지요.
메모이제이션이 효과적인 경우와 그렇지 않은 경우
Google Chrome 팀의 Addy Osmani는 메모이제이션이 만능이 아니라는 점을 강조합니다. 메모이제이션은 특정 조건에서만 효과적입니다.
효과적인 경우는 세 가지입니다. 첫째, 같은 입력으로 반복 호출되는 함수여야 합니다. 같은 입력이 들어올 일이 없다면 캐시에 저장해봤자 소용이 없으니까요. 둘째, 계산 비용이 큰 함수여야 합니다. 단순한 덧셈에 메모이제이션을 적용하면, 캐시를 확인하는 오버헤드가 오히려 더 느릴 수 있습니다. 셋째, 순수 함수여야 합니다. 같은 입력에 항상 같은 출력을 내는 함수만 메모이제이션할 수 있습니다.
순수 함수가 아닌 경우의 예를 보겠습니다.
var taxRate = 0.1; function calculateTax(price) { return price * taxRate; } // 이 함수는 메모이제이션하면 안 됩니다! // taxRate가 바뀌면 같은 price에 대해 다른 결과가 나와야 하지만, // 캐시에는 이전 taxRate 기준의 결과가 남아있기 때문입니다.
calculateTax는 price만 인자로 받지만, 실제로는 외부 변수 taxRate에 의존합니다. taxRate가 0.1에서 0.2로 바뀌어도, 캐시에는 0.1 기준의 결과가 남아있어서 잘못된 값을 반환하게 됩니다.
메모이제이션의 또 다른 트레이드오프는 메모리 사용량입니다. 캐시 객체에 결과를 계속 저장하므로, 입력의 종류가 매우 다양한 함수에 메모이제이션을 적용하면 메모리가 계속 증가합니다. freeCodeCamp의 분석에 따르면, 메모이제이션은 "속도와 메모리의 교환"입니다. 메모리를 더 쓰는 대신 속도를 얻는 것이고, 이 교환이 이득인 상황에서만 사용해야 합니다.
실무에서의 메모이제이션
메모이제이션은 개념만 알면 끝이 아니라, 실제 프로젝트에서 광범위하게 사용됩니다.
Lodash 라이브러리는 _.memoize라는 함수를 제공합니다. npm에서 주간 수천만 건 다운로드되는 라이브러리에서 메모이제이션 유틸리티를 공식 제공한다는 건, 그만큼 실무에서 자주 필요하다는 뜻이죠.
React에서도 메모이제이션을 많이 씁니다. React.memo는 컴포넌트를, useMemo는 계산 결과를, useCallback은 함수를 메모이제이션합니다. DEV Community의 측정에 따르면, 2,000개 이상의 항목을 표시하는 대시보드에서 useMemo를 적용한 결과 렌더링 성능이 40fps에서 60fps로 안정화되었습니다.
지금 당장 React를 모른다고 해도 걱정할 것은 없습니다. 메모이제이션의 원리만 알면 어떤 프레임워크에서든 "이건 메모이제이션이구나"라고 바로 파악할 수 있으니까요.
직접 만들어 보는 간단한 캐시
메모이제이션의 원리를 확실히 이해하기 위해, 실용적인 예제를 하나 더 작성해 보겠습니다.
function createPriceCalculator() { var cache = {}; function calculate(product, quantity) { var key = product + "_" + quantity; if (cache[key] !== undefined) { console.log("[캐시] " + key); return cache[key]; } // 가격표 var prices = { "사과": 1000, "바나나": 500, "딸기": 2000 }; var basePrice = prices[product]; if (basePrice === undefined) { return "알 수 없는 상품입니다"; } // 10개 이상이면 10% 할인 var total = basePrice * quantity; if (quantity >= 10) { total = total * 0.9; } console.log("[계산] " + key); cache[key] = total; return total; } // 캐시 상태를 확인하는 함수도 함께 반환 function getCacheSize() { return Object.keys(cache).length; } return { calculate: calculate, getCacheSize: getCacheSize }; } var calc = createPriceCalculator(); console.log(calc.calculate("사과", 5)); // [계산] 사과_5 → 5000 console.log(calc.calculate("사과", 5)); // [캐시] 사과_5 → 5000 console.log(calc.calculate("바나나", 10)); // [계산] 바나나_10 → 4500 console.log(calc.calculate("바나나", 10)); // [캐시] 바나나_10 → 4500 console.log("캐시 항목 수: " + calc.getCacheSize()); // 2
이 예제는 객체와 배열만 알면 이해할 수 있는 수준입니다. 가격 계산 결과를 캐시에 저장하고, 같은 상품과 수량으로 다시 계산을 요청하면 캐시에서 바로 가져옵니다.
메모이제이션을 이해하면 할 수 있는 것
메모이제이션을 이해하면, "성능 최적화"라는 주제에 첫발을 딛게 됩니다. 코드를 작성할 때 "이 함수는 같은 입력이 반복되는가?", "계산 비용이 큰가?", "순수 함수인가?"를 판단할 수 있고, 그에 따라 적용 여부를 결정할 수 있죠.
클로저의 실전 활용을 체감하는 것도 큰 수확입니다. 클로저는 개념만 들으면 추상적인데, 메모이제이션처럼 구체적인 문제를 해결할 때 "아, 이래서 클로저가 필요하구나"를 비로소 실감하게 됩니다.
마무리
이번 장에서 다룬 네 가지 주제는, JavaScript를 "그냥 쓰는 것"과 "이해하고 쓰는 것"의 차이를 만드는 개념들입니다.
호이스팅을 알면 변수 선언의 위치가 왜 중요한지 설명할 수 있고, 암묵적 매개변수를 알면 this 때문에 생기는 버그를 예측할 수 있습니다. 함수 오버로딩 패턴을 알면 라이브러리 코드가 읽히기 시작하고, 메모이제이션을 알면 느린 코드를 개선하는 첫 번째 도구를 갖게 됩니다.
이 네 가지가 서로 연결되어 있다는 점도 기억하면 좋습니다. 호이스팅은 JavaScript 엔진의 실행 방식에서 비롯되고, 암묵적 매개변수는 함수 호출 메커니즘과 관련되고, 함수 오버로딩은 arguments 객체를 활용하고, 메모이제이션은 클로저로 구현됩니다. 따로따로가 아니라 하나로 연결된 체계라는 걸 알면, 앞으로 만나는 JavaScript 코드가 훨씬 자연스럽게 읽힐 겁니다.
참고 자료
- MDN Web Docs - Hoisting: https://developer.mozilla.org/en-US/docs/Glossary/Hoisting
- MDN Web Docs - let: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/let
- MDN Web Docs - this: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this
- MDN Web Docs - The arguments object: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/arguments
- MDN Web Docs - Arrow function expressions: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions
- MDN Web Docs - Default parameters: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Default_parameters
- ui.dev - The Ultimate Guide to Hoisting, Scopes, and Closures: https://ui.dev/ultimate-guide-to-execution-contexts-hoisting-scopes-and-closures-in-javascript
- freeCodeCamp - Temporal Dead Zone and Hoisting Explained: https://www.freecodecamp.org/news/javascript-temporal-dead-zone-and-hoisting-explained/
- javascript.info - Function binding: https://javascript.info/bind
- John Resig - JavaScript Method Overloading: https://johnresig.com/blog/javascript-method-overloading/
- Douglas Crockford - JavaScript: The Good Parts (O'Reilly): https://www.oreilly.com/library/view/javascript-the-good/9780596517748/
- Addy Osmani - Faster JavaScript Memoization: https://addyosmani.com/blog/faster-javascript-memoization/
- freeCodeCamp - Memoization in JavaScript and React: https://www.freecodecamp.org/news/memoization-in-javascript-and-react/






댓글
댓글을 작성하려면 이 필요합니다.