Tech Sep 19, 2026

함수 선언으로 알아보는 호이스팅

폼을 만들다 보면 필드 이름으로 검증 함수를 찾아 호출하는 코드를 쓰게 됩니다. 함수를 객체에 모아 두면 validators.email(value)처럼 이름으로 골라 쓸 수 있습니다.

const validators = {
  email: validateEmail,
};

function validateEmail(value) {
  // 주제에 집중하게 위해 최소한의 검증만 넣었습니다.
  return value.includes('@');
}

이 코드는 validateEmail이 객체보다 아래에 선언되어 있어도 동작합니다. 그런데 같은 함수를 const와 화살표 함수로 바꾸면 결과가 달라집니다. 함수의 위치도 검증 로직도 그대로 둔 채 선언 방식만 바꿔 보겠습니다.

const validators = {
  email: validateEmail,
};

const validateEmail = (value) => value.includes('@');

어떤 결과가 나올지 예상이 가시나요?

ReferenceError 오류가 발생하는데, 이 오류는 나중에 validators.email(...)을 호출할 때가 아니라, validators 객체를 만드는 도중에 발생합니다.

호출하지 않아도 함수 값은 읽습니다

오류를 해결하려면 email: validateEmail이 실제로 무슨 일을 하는 코드인지부터 봐야 합니다. 왼쪽의 email은 객체 속성의 이름이고, 오른쪽의 validateEmail은 그 속성에 저장할 값입니다. 자바스크립트에서는 함수도 값이므로, 이 코드는 validateEmail이 가리키는 함수 객체를 찾아 email 속성에 넣습니다.

// 함수 자체를 가져와 다른 변수에 저장합니다. 검증은 아직 하지 않습니다.
const handler = validateEmail;

// 저장해 둔 함수를 호출합니다. 이때 검증 로직이 실행됩니다.
const result = handler('a@example.com');

앞서 말한 email: validateEmail이 하는 일은 첫 번째 줄과 같습니다.

자바스크립트는 객체를 생성할 때 나중에 찾아올 이름을 적어두는 것이 아니라, 그 즉시 이름에 연결된 함수 값을 읽어옵니다. (앞으로 이 동작을 ‘값을 읽는다’고 표현하겠습니다.)

이 때문에 함수를 실제로 호출하지 않더라도 객체를 정의하는 시점에 함수 값이 미리 준비되어 있어야 합니다. function으로 선언했을 때는 이 값이 미리 준비되지만, const에 담았을 때는 아직 준비되지 않은 상태라 문제가 생깁니다.

여기서 function은 함수 선언문, const 시작은 함수 표현식이라고 하는데요, 왜 두 방식의 초기화 시점이 다른지 자바스크립트의 내부 동작 원리로 살펴보겠습니다.

선언문과 표현식, 그리고 화살표 함수

함수 선언문

function validateEmail(value) {
  return value.includes('@');
}

validateEmail('a@example.com'); // true

function 키워드 뒤에 이름과 매개변수, 본문을 적어 함수를 선언합니다. 이 코드에서는 선언 자체가 validateEmail이라는 이름을 만듭니다. 모듈 최상위의 함수 선언은 본문 실행 전에 함수 객체까지 준비되므로, 호출 코드를 선언 위로 옮겨도 동작합니다.

선언문이란?

선언문은 값을 만들어 어딘가로 넘기는 코드가 아니라, validateEmail이라는 함수가 있다고 알리는 문장입니다. 그래서 어디에도 담지 않고 그 한 줄로 끝납니다.

함수 표현식과 화살표 함수

const validateEmail = function (value) {
  return value.includes('@');
};

validateEmail('a@example.com'); // true

여기서는 const로 변수를 선언하고, 오른쪽의 function (value) { ... }를 평가해 만든 함수 객체를 그 변수에 넣습니다. 함수 선언문과 같은 function 키워드를 쓰지만, 이 위치에서는 값을 만드는 표현식이 됩니다.

표현식이란?

표현식은 계산하면 값이 되는 코드입니다. 1 + 2가 3이 되듯 function (value) { ... }도 함수라는 값이 됩니다. 값이기 때문에 const validateEmail =의 오른쪽처럼 값이 놓이는 자리에 올 수 있습니다.

화살표 함수도 같은 자리에 오는 표현식입니다. 중괄호 없이 쓰면 value.includes('@')의 결과가 그대로 반환됩니다.

const validateEmail = (value) => value.includes('@');

결국 validateEmail을 언제부터 쓸 수 있는지는 함수 문법이 아니라 그 이름을 만든 쪽이 정합니다. 함수 선언문은 선언 자체가 이름을 만들지만, 두 표현식은 const가 만든 이름에 담기는 값이라 const의 초기화 규칙을 따릅니다.

그 규칙의 차이는 엔진이 모듈 본문을 실행하기 전에 무엇까지 준비해 두느냐에서 생깁니다.

이름이 만들어지는 시점과 값이 들어가는 시점

자바스크립트는 모듈의 첫 줄부터 곧바로 실행하지 않습니다. 먼저 모듈에 어떤 이름이 선언되어 있는지 확인하고, 그 이름을 통해 값을 찾을 수 있도록 환경을 만듭니다. 이 환경에서 관리하는 이름과 값의 연결을 바인딩(binding)이라고 합니다. 예제의 코드에서는 validators와 validateEmail이 각각 하나의 바인딩을 갖습니다.

바인딩은 만들어졌어도 아직 값을 읽을 수 없는 상태일 수 있습니다. const가 그 예입니다. 엔진은 validateEmail이라는 이름이 이 모듈에 선언되어 있다는 것은 알고 있지만, const validateEmail = ...를 실행하기 전까지는 그 이름으로 값을 읽는 것을 허용하지 않습니다. 이름을 등록하는 일과 최초의 값을 설정하는 초기화를 따로 하기 때문입니다. 이처럼 선언의 효과가 실제 코드 위치보다 먼저 나타나는 현상을 호이스팅이라고 부릅니다.

호이스팅이란?

엔진이 코드를 위로 끌어올리는 것이 아니라, 본문 실행 전에 바인딩(이름 등록)을 먼저 생성하기 때문에 발생하는 현상입니다.

여기서 흔한 오해 하나를 짚고 가겠습니다. const 역시 이름(바인딩) 자체는 본문 실행 전에 만들어집니다. 따라서 “호이스팅되지 않아서 존재를 모른다”는 설명은 실제 동작과 다릅니다. 이름은 이미 존재하지만, 초기화되기 전까지 접근을 막아두는 보호 구간이 존재할 뿐입니다. 자바스크립트에서는 이 구간을 TDZ라고 부릅니다.

TDZ란?

Temporal Dead Zone(일시적 사각지대)의 약자로, 바인딩이 생성된 후 초기화가 완료될 때까지 해당 변수를 읽을 수 없는 보호 구간을 의미합니다. (접근 시 ReferenceError 발생)

함수 선언은 본문 실행 전에 함수 값까지 준비됩니다

function validateEmail(value) { ... }처럼 함수를 선언하면, 엔진은 모듈의 환경을 만드는 단계에서 함수 객체도 생성해 validateEmail에 연결합니다. 그래서 본문 실행이 시작될 때는 이미 그 이름으로 함수 값을 읽을 수 있습니다.

함수 선언의 시작 상태

본문이 한 줄도 실행되지 않은 상태에서 시작합니다. 단계를 넘기며 두 바인딩이 언제 값을 갖는지 보세요.

1 / 5
  1. 1 인스턴스화
  2. 2 본문 평가
  1. const validators = {
  2. email: validateEmail,
  3. };
  4. function validateEmail(value) {
  5. return value.includes('@');
  6. }
모듈 환경 레코드
validateEmail function
초기화
✗
값
비어 있음
validators const
초기화
✗
값
비어 있음
CreateMutableBinding("validateEmail") CreateImmutableBinding("validators")

이름만 등록 · 슬롯은 빕니다

환경 레코드는 이름을 만드는 일과 값을 넣는 일을 따로 합니다. 명세의 평가 순서를 재구성한 모형입니다.

위 시각화가 진행한 순서를 정리하면 다음과 같습니다.

  1. 본문 실행 전에 validateEmail이 함수 객체로 초기화됩니다. validators는 아직 초기화되지 않습니다.
  2. const validators = { ... }의 오른쪽 객체를 만듭니다. email에 넣을 함수 값을 validateEmail에서 읽습니다.
  3. 객체 생성이 끝나면 그 객체로 validators를 초기화합니다.

이 과정에서 검증 함수의 본문은 실행되지 않습니다. 함수 객체를 만드는 것만으로 검증이 수행되지는 않으며, 나중에 validators.email(...)을 호출해야 본문이 실행됩니다.

const는 해당 선언을 실행할 때 초기화됩니다

const validateEmail = (value) => ...는 변수를 선언하고, 오른쪽의 화살표 함수 표현식으로 만든 값을 그 변수에 넣는 코드입니다. 함수 선언과 달리, 본문을 실행하다가 이 선언에 도달해야 함수 객체를 만들고 validateEmail을 초기화합니다.

그런데 예제의 두 번째 코드에서는 const validators = ...가 먼저 실행됩니다. 객체에 넣을 값을 얻으려고 validateEmail을 읽는 순간, 아직 초기화 전이므로 ReferenceError가 발생합니다. 그 자리에서 실행이 중단되어 아래쪽의 화살표 함수를 만드는 줄까지 도달하지 못합니다.

이 차이는 화살표 함수의 문법이 아니라 const의 초기화 시점에서 비롯됩니다. 오른쪽을 일반 함수 표현식으로 바꾼 const validateEmail = function (value) { ... }도 선언 전에 읽으면 같은 오류가 발생합니다.

아래 시각화도 본문 실행 전의 상태에서 시작합니다. 앞의 함수 선언과 달리 validateEmail의 값 슬롯이 비어 있고, ‘다음’을 누르면 email 속성에 넣을 값을 읽는 자리에서 실행이 멈춥니다.

const 화살표 함수의 시작 상태

앞의 함수 선언과 같은 자리에서 같은 이름을 읽습니다. 초기화 플래그가 어떻게 다른지 보세요.

1 / 2
  1. 1 인스턴스화
  2. 2 본문 평가
  1. const validators = {
  2. email: validateEmail,
  3. };
  4. const validateEmail = (value) =>
  5. value.includes('@');
모듈 환경 레코드
validateEmail const
초기화
✗
값
비어 있음
validators const
초기화
✗
값
비어 있음
CreateImmutableBinding("validateEmail") CreateImmutableBinding("validators")

이름만 등록 · 슬롯은 빕니다

환경 레코드는 이름을 만드는 일과 값을 넣는 일을 따로 합니다. 명세의 평가 순서를 재구성한 모형입니다.

오류가 난 뒤에도 validators의 값 슬롯이 비어 있는 이유도 같은 규칙에서 나옵니다. const validators = ...는 오른쪽의 객체를 모두 만든 뒤에야 초기화되는데, 속성에 넣을 값을 읽다가 실패했으니 객체 생성과 변수 초기화 어느 쪽도 끝나지 않은 것입니다.

이처럼 자바스크립트 엔진이 본문을 실행하기 전에 바인딩을 먼저 만들고, 함수를 호출할 때 지역 변수를 준비하는 과정은 ECMAScript 명세(InitializeEnvironment, FunctionDeclarationInstantiation)에도 단계별 규칙으로 명확히 정의되어 있습니다.

함수로 감싸면 값을 읽는 시점이 미뤄집니다

다시 본론으로 들어와서, 오류를 해결하려면 객체를 만들기 전에 validateEmail의 초기화를 끝내야 합니다. 먼저 떠올릴 수 있는 방법은 선언 순서를 바꾸는 것입니다. 다음 코드는 화살표 함수 객체를 만들어 변수에 넣은 뒤, 그 값을 email 속성에 저장합니다.

const validateEmail = (value) => value.includes('@');

const validators = {
  email: validateEmail,
};

그런데 실제 코드를 읽다 보면 검증 함수보다 등록 코드가 위에 있는데도 멀쩡히 동작하는 경우가 있습니다. 아래처럼 다른 함수로 한 겹 감싸서 등록한 코드입니다.

const validators = {
  email: (value) => validateEmail(value),
};

const validateEmail = (value) => value.includes('@');

console.log(validators.email('a@example.com')); // true

두 코드의 차이는 validateEmail의 값을 언제 읽느냐에 있습니다. email: validateEmail에서는 이미 만들어진 함수를 읽어 속성에 저장했지만, email: (value) => validateEmail(value)에서는 화살표 함수 객체를 새로 만들어 저장합니다. 이 새 함수의 본문에 validateEmail이라는 이름이 적혀 있어도, 함수를 만드는 시점에 본문까지 실행하지는 않습니다.

새 함수는 나중에 실행될 때 바깥 스코프의 validateEmail을 참조합니다. 함수를 만드는 시점에 validateEmail의 현재 값을 미리 복사해 두는 방식이 아닙니다. 그래서 validateEmail이 아직 초기화 전이어도, 그 값을 읽지 않고 함수를 만드는 것까지는 가능합니다.

위 예제의 실행 순서는 다음과 같습니다.

  1. 화살표 함수 (value) => validateEmail(value)를 만들어 validators.email에 저장합니다. 이때 validateEmail은 읽지 않습니다.
  2. 다음 선언에서 검증 함수를 만들어 validateEmail을 초기화합니다.
  3. validators.email('a@example.com')을 호출합니다. 그제야 감싸 둔 함수의 본문이 실행되어 validateEmail을 읽고 호출합니다.

세 번째 단계에서는 이미 초기화가 끝났으므로 true가 반환됩니다. 반대로 호출을 두 번째 단계보다 앞으로 옮기면, 감싸 둔 함수의 본문에서 같은 ReferenceError가 발생합니다.

const validators = {
  email: (value) => validateEmail(value),
};

// 함수 등록은 끝났지만, validateEmail의 초기화는 아직입니다.
validators.email('a@example.com'); // ReferenceError

const validateEmail = (value) => value.includes('@');

TDZ를 ‘선언 위쪽에 적힌 코드’로만 이해하면 이 차이를 설명하기 어렵습니다. validateEmail(value)가 적힌 위치는 두 예제에서 같기 때문입니다. 달라진 것은 그 코드가 실행되는 시점이고, 초기화가 끝난 뒤 실행하면 값을 읽을 수 있고 그전에 실행하면 실패합니다.

시각화의 ‘직접 등록’, ‘나중에 호출’, ‘먼저 호출’을 차례로 비교해 보겠습니다. 뒤의 두 경우는 객체를 만드는 단계까지 똑같이 통과하지만, 검증 함수를 호출하는 단계에서 validateEmail의 상태가 갈립니다.

등록 시점과 읽는 시점

같은 콜백도 초기화 전에 호출하면 실패합니다. 세 경우를 비교해 보세요.

1 / 2
  1. 1 인스턴스화
  2. 2 본문 평가
  1. const validators = {
  2. email: validateEmail,
  3. };
  4. const validateEmail = (value) =>
  5. value.includes('@');
모듈 환경 레코드
validateEmail const
초기화
✗
값
비어 있음
validators const
초기화
✗
값
비어 있음
CreateImmutableBinding("validateEmail") CreateImmutableBinding("validators")

이름만 등록 · 슬롯은 빕니다

환경 레코드는 이름을 만드는 일과 값을 넣는 일을 따로 합니다. 명세의 평가 순서를 재구성한 모형입니다.

이 구분은 이벤트 핸들러나 라이브러리의 콜백을 읽을 때도 필요합니다. 콜백으로 전달했다고 해서 반드시 나중에 실행되는 것은 아니기 때문입니다. 예를 들어 배열의 map은 호출하는 동안 콜백을 실행하므로, 아래 코드는 다음 선언까지 기다리지 않습니다.

const results = ['a@example.com'].map((value) => validateEmail(value));
// map의 콜백 안에서 ReferenceError가 발생합니다.

const validateEmail = (value) => value.includes('@');

두 방식 중에서는 순서를 바꾸는 쪽을 먼저 고려하는 편이 낫습니다. 함수로 감싸는 방식은 값을 읽는 시점을 호출 시점까지 미루는 것이라, 그 함수를 누가 언제 호출하는지까지 확인해야 안전한지 알 수 있습니다.

감싸면 새 함수가 하나 더 만들어진다는 차이도 따라옵니다. 직접 등록하면 validators.email === validateEmail이지만, 감싸서 등록하면 두 값은 서로 다른 함수입니다.

JS의 역사 - 왜 초기화 전 접근을 막았을까

초기화 전에 접근을 금지하는 이 규칙은 ES6에서 let과 const를 설계할 때 여러 대안을 검토한 끝에 결정된 방식입니다.

당시 검토했던 대안 중에는 “초기화되기 전까지는 상위 스코프의 같은 이름을 대신 참조하게 하자” 는 아이디어도 있었습니다. 하지만 이 방식은 아래 코드처럼 예측하기 어려운 문제를 발생시킵니다.

const message = '바깥 값';

{
  function readMessage() {
    return message;
  }

  readMessage(); // 현재 JavaScript에서는 ReferenceError
  const message = '안쪽 값';
  readMessage(); // 첫 호출을 제거하면 '안쪽 값'
}

만약 초기화 전 상위 스코프를 대신 읽게 만든다면, 첫 번째 호출에서는 ‘바깥 값’을, 두 번째 호출에서는 ‘안쪽 값’을 반환하는 문제가 생깁니다. 단순히 값이 변하는 수준을 넘어, 같은 함수 안의 message 변수가 호출 시점에 따라 완전히 다른 스코프를 가리키게 되는 것이죠. 2011년 7월 TC39 회의에서도 이 같은 혼란을 막기 위해 대안을 배제했습니다.

현재의 규칙은 readMessage안의 message가 처음부터 항상 안쪽 변수만을 가리킵니다. 선언 전 호출하면 ReferenceError가 발생하고, 초기화가 끝난 뒤에야 정상적으로 읽히게 되죠. 즉, 이름이 가리키는 대상을 고정해 두고, 안전하게 사용할 수 있도록 사용 시점만 제한한 것입니다.

이러한 설계에는 함수 간 상호 재귀나 const의 일관성 유지도 큰 영향을 미쳤습니다.

  • 상호 재귀 지원: 서로를 호출하는 함수들은 선언 순서와 무관하게 참조할 수 있어야 합니다. 이를 위해선 선언 위치뿐 아니라 실제 값을 읽는 시점까지 일관되게 통제하는 규칙이 필수적이었습니다.

  • const 및 let의 일관성: 초기화 전에 undefined가 읽힌다면 const(상수)라는 이름이 무색하게 값 변동이 관찰됩니다. 그래서 Allen Wirfs-Brock을 비롯한 설계자들은 const와 let 모두 초기화 전 접근을 동일하게 금지하여 자바스크립트 변수에 일관된 의미를 부여했습니다.

(단, 이는 ES6에서 let·const와 TDZ를 설계한 배경일 뿐이며, 초기 자바스크립트의 var 호이스팅까지 같은 목적이었던 것은 아닙니다.)

마치며

Cannot access 'x' before initialization 에러는 “x라는 이름은 알고 있지만, 아직 값이 들어가지 않아 쓸 수 없다”는 뜻입니다. 이 에러를 만난다면 다음 3단계로 원인을 찾아보세요.

  1. 값을 사용한 곳 찾아보기 email: validateEmail처럼 변수 이름만 적어 속성에 넣는 것도 값을 바로 읽는 동작입니다. validateEmail()처럼 괄호가 붙은 호출 줄만 찾다 보면 진짜 원인을 놓칠 수 있습니다.0

  2. 왜 선언보다 먼저 실행됐는지 확인하기 변수를 선언하기도 전에 객체를 만드는 코드가 위에 있었거나, map 같은 메서드가 즉시 콜백을 실행하지 않았는지 확인해 보세요.

  3. 선언이 먼저 끝나도록 순서 바꾸기 가장 쉬운 해결책은 함수 선언을 객체보다 위에 두는 것입니다. 순서를 바꿀 수 없다면, 실제로 함수가 호출될 때까지 값을 읽는 시점을 뒤로 미뤄야 합니다.