LogoSEO Jing
  • All Posts
  • SEO Jing
  • okayJing
  • KD Team
  • CLAB Coreteam
  • Study

Contact Me

© 2026 SEOJing. All rights reserved.

자바스크립트 퀴즈북 리마인드 Day 6: 스코프 체인과 호이스팅 | SEOJing

자바스크립트 퀴즈북 리마인드 Day 6: 스코프 체인과 호이스팅

2026년 6월 28일·16분 읽기

예상 읽기 시간: 18분
오늘의 질문: “콜백 안에서 읽는 값은 함수가 만들어진 곳을 볼까, 호출된 곳을 볼까?”

이 글의 원천 앵커

이 글은 자바스크립트 퀴즈북의 스코프·호이스팅·클로저 앞부분을 프론트엔드 코드 리뷰용으로 다시 묶은 글입니다. 단어를 외우는 순서는 별로 중요하지 않습니다. 실제 리뷰에서는 아래 질문을 계속 따라가야 합니다.

  • 식별자는 어느 렉시컬 환경에서 선언됐나?
  • 함수는 어느 위치에서 만들어졌고, 나중에 어느 위치에서 실행되나?
  • var, let, const 중 어떤 바인딩을 닫아두고 있나?
  • 선언 전 접근이 조용한 undefined 흐름인지, TDZ 에러인지?
  • const라는 말이 “재할당 금지”인지, “객체 내부 불변”인지?

먼저 맞혀보기

js
const label = "global";

function makeLogger() {
  const label = "local";
  return function log() {
    console.log(label);
  };
}

const log = makeLogger();

function run(callback) {
  const label = "runner";
  callback();
}

run(log);

출력은 local입니다. log가 run 안에서 호출되었으니 runner를 볼 것 같지만, 자바스크립트 함수는 호출 위치의 주변 변수를 새로 고르지 않습니다. 함수가 만들어진 위치의 렉시컬 환경을 기억하고, 실행될 때 그 환경 체인을 따라 이름을 찾습니다.

이 한 문장이 Day 7의 stale closure, Day 8의 async 실행 순서, Day 12의 이벤트 핸들러까지 이어집니다.

스코프 체인과 호이스팅 관계

틀리기 쉬운 직감

초보 때는 “함수가 실행되는 곳의 주변 값을 보겠지”라고 생각하기 쉽습니다. 하지만 함수는 호출될 때 아무 스코프나 다시 선택하지 않습니다. 함수 객체가 만들어질 때 바깥 렉시컬 환경에 대한 참조가 함께 붙고, 실행 때는 그 참조를 따라갑니다.

그래서 프론트엔드 버그는 이런 모양으로 나옵니다.

js
function createSubmitHandler(user) {
  return function handleSubmit() {
    sendAnalytics(user.id);
  };
}

handleSubmit은 실행 시점의 최신 user를 자동으로 찾아오지 않습니다. 만들어질 때 인자로 받은 user 바인딩을 닫아둡니다. React 컴포넌트에서 핸들러가 어느 렌더에서 만들어졌는지 묻는 이유가 여기에 있습니다.

스코프 체인은 “이름 찾기 순서”다

스코프 체인은 어려운 내부 구조라기보다 이름을 찾는 순서로 보면 됩니다.

js
const taxRate = 0.1;

function createPriceFormatter(discount) {
  return function format(price) {
    const discounted = price - discount;
    return discounted + discounted * taxRate;
  };
}

const format = createPriceFormatter(5);
console.log(format(100));

format 안에서 price는 자기 실행 환경에서 찾습니다. discount는 바깥 createPriceFormatter 환경에서 찾습니다. taxRate는 더 바깥 전역 환경에서 찾습니다. 이름을 찾지 못하면 그때 ReferenceError가 납니다.

리뷰할 때는 “이 변수 값이 어디서 왔지?”를 아래 순서로 좁히면 됩니다.

  1. 함수 안 지역 선언인가?
  2. 매개변수인가?
  3. 바깥 함수가 만든 바인딩인가?
  4. 모듈/전역 스코프인가?
  5. 실행 시점에 바뀔 수 있는 mutable binding인가?

마지막 질문이 중요합니다. 클로저는 보통 값을 복사해 두는 스냅샷이 아니라 바인딩을 따라갑니다. 바인딩이 바뀌면 나중 실행에서 보이는 값도 달라질 수 있습니다.

스냅샷이 아니라 “살아 있는 바인딩”이다

스코프 체인을 설명할 때 가장 위험한 말은 “함수가 값을 기억한다”입니다. 편하게 말하면 맞는 것 같지만, 실제 버그를 읽을 때는 조금 더 정확해야 합니다. 함수는 보통 값 복사본이 아니라 바인딩에 닿는 길을 가지고 있습니다.

js
function makeCounter() {
  let count = 0;

  return {
    inc() {
      count += 1;
    },
    read() {
      return count;
    },
  };
}

const counter = makeCounter();
counter.inc();
console.log(counter.read()); // 1

inc와 read가 각각 0이라는 값을 복사해 둔 게 아닙니다. 두 함수가 같은 count 바인딩을 보고 있습니다. 그래서 한쪽이 바꾸면 다른 쪽이 나중에 읽는 값도 바뀝니다. 이 감각은 Day 7의 stale closure와도 연결됩니다. “값이 살아 있다”는 말은 항상 최신 앱 상태를 본다는 뜻이 아니라, 그 렌더나 함수 호출이 만든 바인딩이 살아 있다는 뜻입니다.

React 코드에서 이 차이가 자주 헷갈립니다.

js
function makeHandler(user) {
  return () => user.id;
}

const handler = makeHandler({ id: "a" });

여기서 handler는 user 바인딩을 기억합니다. 하지만 그 바인딩 자체가 나중에 다른 객체로 재할당되지 않는다면 화면의 최신 user를 자동으로 따라가지 않습니다. 반대로 바인딩이 mutable이라면 나중 실행에서 바뀐 값을 볼 수 있습니다. 리뷰에서는 “클로저가 값을 기억했다”로 뭉개지 말고, 닫힌 것이 immutable 값인지, mutable object인지, 재할당 가능한 바인딩인지까지 봐야 합니다.

모듈 스코프와 전역 스코프도 분리해서 본다

브라우저에서 파일 하나가 전역처럼 보여도, ESM 모듈 안의 top-level 선언은 옛날 script 전역과 다르게 동작합니다.

js
// analytics.js
const queue = [];

export function track(event) {
  queue.push(event);
}

queue는 모듈 내부에서 오래 살아남는 바인딩입니다. 컴포넌트가 unmount되어도 모듈이 로드된 동안 유지됩니다. 이게 의도한 이벤트 큐라면 괜찮지만, 테스트 사이에 상태가 새거나 사용자 전환 후 이전 데이터가 남는다면 모듈 스코프 mutable state가 원인일 수 있습니다.

반면 오래된 script 코드에서 var top-level 선언은 window 프로퍼티처럼 보이는 경우가 있습니다. ESM으로 옮길 때 “파일 위에 있으니 전역”이라고 단순하게 생각하면 디버깅이 꼬입니다. Day 13의 module 글에서 더 다루지만, Day 6 단계에서도 최소한 이 질문은 남겨야 합니다.

이 바인딩은 함수 호출마다 새로 생기나, 모듈이 로드된 뒤 계속 살아 있나?

프론트엔드 테스트에서 flaky한 상태 공유를 잡을 때 이 질문이 꽤 자주 먹힙니다.

var와 let/const의 차이

var, let, const 모두 선언 처리 단계와 관련이 있습니다. 차이는 “어느 스코프에 바인딩이 생기고, 초기화 전 읽을 수 있는가”입니다.

선언선언 전 읽기스코프리뷰 포인트
varundefined함수루프·콜백에서 같은 바인딩을 공유하기 쉽다
letTDZ 에러블록선언 순서가 의도를 더 잘 드러낸다
constTDZ 에러블록재할당 금지이지 객체 불변은 아니다
js
console.log(a); // undefined
var a = 1;

console.log(b); // ReferenceError
let b = 1;

var가 안전해서 에러가 안 나는 것이 아닙니다. 버그가 더 멀리 흘러갈 뿐입니다. 선언 전에 읽은 undefined가 fallback 로직을 타거나, API payload에 섞이거나, 렌더링 조건을 바꾸면 원인을 찾기 어려워집니다.

TDZ는 귀찮은 제약이 아니라 조기 경고에 가깝습니다.

js
function renderTheme() {
  console.log(theme);
  const theme = getTheme();
}

이 코드는 바로 실패합니다. 반대로 var였다면 아래처럼 조용히 undefined를 흘릴 수 있습니다.

js
function renderTheme() {
  console.log(theme); // undefined
  var theme = getTheme();
}

리뷰에서는 “에러가 안 났다”보다 “선언 순서와 초기화 시점이 독자에게 보이는가”를 봐야 합니다.

함수 선언과 class 선언도 같은 단어로 “호이스팅된다”고만 말하면 오해가 생깁니다.

js
sayHi(); // 가능
function sayHi() {
  console.log("hi");
}

new User(); // ReferenceError
class User {}

함수 선언은 선언 전에 호출 가능한 형태로 준비되는 반면, class 선언은 이름이 스코프에 잡혀도 초기화 전 접근은 TDZ에 걸립니다. AI가 “선언은 위로 끌어올려진다”는 말 하나로 function/class/var/let/const를 같은 규칙처럼 설명하면 리뷰에서 바로 멈춰야 합니다. 실제 확인해야 할 것은 세 가지입니다.

선언 형태선언 전 접근 감각리뷰에서 볼 것
function 선언호출 가능의도적으로 아래에 둔 API인지, 순환 의존인지
class 선언TDZ 에러생성 시점이 선언보다 앞서지 않는지
varundefined로 읽힐 수 있음조용한 fallback/분기 오염이 없는지
let/constTDZ 에러선언 순서가 데이터 흐름을 설명하는지

프론트엔드 코드에서는 function 선언이 나쁜 게 아닙니다. 컴포넌트 파일 안에서 작은 helper를 아래에 두고 위에서 읽는 스타일은 흔합니다. 다만 class나 const 함수식을 같은 식으로 다루면 깨집니다.

js
render(); // ReferenceError

const render = () => {
  console.log("render");
};

이런 차이를 알고 있으면 “왜 이 함수만 위로 올려야 하지?” 같은 리뷰 코멘트가 더 정확해집니다.

루프 콜백은 바인딩 공유를 의심한다

스코프와 호이스팅이 실제 버그로 튀어나오는 대표 예시는 루프 안 콜백입니다.

js
function createHandlers() {
  const handlers = [];

  for (var index = 0; index < 3; index += 1) {
    handlers.push(() => index);
  }

  return handlers;
}

console.log(createHandlers().map((handler) => handler())); // [3, 3, 3]

여기서 문제는 “화살표 함수라서”가 아닙니다. 세 콜백이 같은 index 바인딩을 닫아두었기 때문입니다. var는 함수 스코프 바인딩 하나를 만들고, 루프가 그 바인딩을 계속 바꿉니다. 루프가 끝난 뒤 index는 3입니다.

let은 반복마다 새 바인딩을 만듭니다.

js
function createHandlers() {
  const handlers = [];

  for (let index = 0; index < 3; index += 1) {
    handlers.push(() => index);
  }

  return handlers;
}

console.log(createHandlers().map((handler) => handler())); // [0, 1, 2]

이 차이를 “var는 나쁘고 let은 좋다”로만 외우면 부족합니다. 리뷰 질문은 더 구체적이어야 합니다.

이 콜백들은 같은 바인딩을 공유해야 하는가, 반복마다 다른 바인딩을 닫아야 하는가?

대부분의 UI 핸들러는 반복마다 다른 항목 ID나 index를 닫아야 합니다. 하지만 일부 코드는 의도적으로 shared mutable binding을 사용합니다. 그러면 그 의도를 이름과 주석, 더 작은 함수로 드러내야 합니다.

const는 객체 불변이 아니다

Day 5의 참조와 바로 이어집니다. const는 바인딩 재할당을 막습니다. 객체 내부 변경을 막지는 않습니다.

js
const config = { theme: "dark" };

config.theme = "light"; // 가능
config = {}; // TypeError

코드 리뷰에서 “const니까 안전하다”는 말이 나오면, 무엇이 안전한지 다시 물어야 합니다. 바인딩이 바뀌지 않는 것인지, 객체 내부가 바뀌지 않는 것인지 다릅니다. 내부 불변이 필요하면 업데이트 방식을 바꾸거나, Object.freeze, immutable data pattern, 타입 레벨 readonly 같은 별도 설계가 필요합니다.

js
const options = { retry: 3 };

function updateOptions() {
  options.retry += 1;
}

이 코드는 const지만 전역 mutable object입니다. 테스트 사이에 값이 새거나, 이벤트 핸들러가 예전 가정을 들고 실행될 수 있습니다. “재할당하지 않는다”와 “상태가 변하지 않는다”를 구분해야 합니다.

AI가 만든 코드에서 자주 보이는 실수

AI는 var를 let/const로 기계적으로 바꾸는 리팩터링을 잘합니다. 많은 경우 이 변화는 맞습니다. 하지만 루프 콜백, 함수 스코프 의존 코드, hoisting에 기대던 오래된 코드에서는 의미가 바뀔 수 있습니다.

js
for (var i = 0; i < nodes.length; i += 1) {
  nodes[i].onclick = function () {
    console.log(i);
  };
}

이 코드를 let으로 바꾸면 많은 UI 버그가 고쳐집니다. 각 핸들러가 자기 반복의 i를 닫기 때문입니다. 다만 기존 코드가 일부러 최종 index를 읽고 있었다면 동작이 바뀝니다. 좋은 리뷰는 “modern syntax로 바꿨다”에서 끝나지 않고, 각 콜백이 어떤 바인딩을 읽어야 하는지 확인합니다.

AI 코드에서 또 하나 자주 보이는 패턴은 선언을 위로 끌어올리며 의미를 흐리는 것입니다.

js
let selectedUser;

if (response.ok) {
  selectedUser = response.user;
}

renderProfile(selectedUser);

이 코드는 타입이나 린트가 잡아주지 않으면 undefined 흐름을 만들기 쉽습니다. 차라리 분기 안에서 return하거나, 성공/실패 상태를 명시적으로 모델링하는 편이 낫습니다. 여기서 Effective TypeScript Day 6의 “유효한 상태만 표현하기”와 연결됩니다.

프론트엔드 리뷰 체크리스트

  • 콜백이 만들어지는 위치와 실행되는 위치가 떨어져 있는가?
  • 닫힌 값이 스냅샷처럼 안전한 값인지, 나중에 바뀔 수 있는 바인딩/객체인지 구분했는가?
  • 모듈 스코프 mutable state가 테스트·사용자 전환·재방문 사이에 남지 않는가?
  • 루프 안 콜백이 같은 var 바인딩을 공유하고 있지 않은가?
  • let/const 선언 전 접근이 TDZ 에러로 드러나는가, 아니면 var의 undefined가 조용히 흐르는가?
  • function/class/const 함수식을 모두 같은 “호이스팅” 규칙으로 설명하고 있지 않은가?
  • const 객체를 내부에서 바꾸면서 불변처럼 말하고 있지 않은가?
  • 함수 밖 mutable binding을 여러 이벤트/요청이 공유하고 있지 않은가?
  • AI 리팩터링이 문법만 바꾸고 바인딩 공유 의미를 확인하지 않았는가?

스코프 체인은 문법 지식보다 “이 함수가 어떤 바인딩을 기억하고 실행되는지”를 추적하는 도구에 가깝습니다. Day 7에서는 이 모델이 오래된 값(stale closure) 문제로 어떻게 이어지는지 봅니다.

연결해서 읽기

  • Day 5 reference 글은 const 객체 내부 변경과 얕은 복사 문제를 이어서 봐야 합니다.
  • Day 7 closure 글은 여기서 나온 스코프 체인이 타이머·이벤트·React render에서 어떻게 오래된 값 문제로 보이는지 다룹니다.
  • Day 8 event loop 글은 “나중에 실행된다”는 사실이 task/microtask 순서와 결합될 때 어떤 버그를 만드는지 다룹니다.
  • Effective TypeScript Day 5의 narrowing도 같은 질문을 공유합니다. “지금 좁힌 값이 나중에도 같은 값인가?”를 스코프/바인딩 관점으로 다시 확인해야 합니다.
Quiz1 / 5
Q.첫 번째 예제에서 run(log)를 호출했을 때 출력은 무엇일까요?

Post Q&A

오케이징에게 물어보기

자바스크립트 퀴즈북 리마인드 Day 6: 스코프 체인과 호이스팅 전체를 기준으로 질문과 피드백을 받아요.답을 본 뒤에는 이 내용을 댓글로 달아서 서징에게도 물어볼 수 있어요. 작성자가 직접 볼 수 있어요!

0/500

포스트 목록

/study/javascript-quizbook
파일 13개, 폴더 0개
자바스크립트 퀴즈북 리마인드 Day 1: 숫자는 왜 가끔 믿을 수 없을까자바스크립트 퀴즈북 리마인드 Day 2: 같은 값인지 묻는 네 가지 방법자바스크립트 퀴즈북 리마인드 Day 3: + 연산자와 ToPrimitive 흐름자바스크립트 퀴즈북 리마인드 Day 4: 프로퍼티 descriptor와 freeze의 경계자바스크립트 퀴즈북 리마인드 Day 5: 참조, 복사, Map/Set의 메모리 감각자바스크립트 퀴즈북 리마인드 Day 6: 스코프 체인과 호이스팅자바스크립트 퀴즈북 리마인드 Day 7: 클로저 버그와 오래된 값자바스크립트 퀴즈북 리마인드 Day 8: 이벤트 루프와 Promise 타이밍자바스크립트 퀴즈북 리마인드 Day 9: this는 호출한 쪽이 정한다자바스크립트 퀴즈북 리마인드 Day 10: 함수는 값이고 경계다자바스크립트 퀴즈북 리마인드 Day 11: class를 봐도 프로토타입을 읽는다자바스크립트 퀴즈북 리마인드 Day 12: 이벤트는 target에서 끝나지 않는다자바스크립트 퀴즈북 리마인드 Day 13: 모듈은 파일 묶음이 아니라 의존성 그래프다

같은 섹션의 대표 이미지

13 posts · latest first
ESM live binding과 dynamic import, circular dependency 리뷰 포인트를 보여주는 module graph 다이어그램
Study26. 07. 09.

자바스크립트 퀴즈북 리마인드 Day 13: 모듈은 파일 묶음이.

ESM live binding, default/named export, circular dependency, dynamic import, CJS와 ESM 차이를 프론트엔드 코드 리뷰 관점에서 정리합니다.

26. 07. 09.SEOJing
DOM 이벤트가 capture 단계로 내려가고 bubble 단계로 올라가는 경로 다이어그램
Study26. 07. 08.

자바스크립트 퀴즈북 리마인드 Day 12: 이벤트는.

DOM 이벤트의 capture/bubble, delegation, default action, React synthetic event 경계를 프론트엔드 코드 리뷰 관점에서 정리합니다.

26. 07. 08.SEOJing
인스턴스에서 프로토타입과 Object.prototype으로 이어지는 프로퍼티 조회 경로 다이어그램
Study26. 07. 07.

자바스크립트 퀴즈북 리마인드 Day 11: class를 봐도.

프로토타입 조회, class 문법, 인스턴스 필드와 prototype 메서드 차이를 프론트엔드 코드 리뷰 관점에서 정리합니다.

26. 07. 07.SEOJing
입력, 순수 계산, 출력, 부수 효과 경계를 나누어 함수 설계를 보여주는 다이어그램
Study26. 07. 06.

자바스크립트 퀴즈북 리마인드 Day 10: 함수는 값이고 경계다.

고차 함수, 순수 함수, 부수 효과, async 함수의 Promise 반환을 코드 리뷰에서 어떻게 읽을지 정리합니다.

26. 07. 06.SEOJing
메서드 호출, 분리된 함수, 이벤트 콜백, bind와 arrow function의 this 차이를 보여주는 다이어그램
Study26. 07. 05.

자바스크립트 퀴즈북 리마인드 Day 9: this는 호출한 쪽이.

this 바인딩을 정의 위치가 아니라 호출 형태, 메서드 분리, arrow function, bind 기준으로 읽는 방법을 코드 리뷰 관점에서 정리합니다.

26. 07. 05.SEOJing
콜 스택이 비워진 뒤 마이크로태스크 큐가 먼저 실행되고 다음 태스크로 넘어가는 이벤트 루프 다이어그램
Study26. 07. 04.

자바스크립트 퀴즈북 리마인드 Day 8: 이벤트 루프와.

콜 스택, 태스크 큐, 마이크로태스크 큐를 기준으로 Promise와 setTimeout 실행 순서를 코드 리뷰 관점에서 정리합니다.

26. 07. 04.SEOJing
클로저가 생성 위치의 바인딩을 기억해 나중 실행에서 오래된 값을 읽을 수 있음을 보여주는 다이어그램
Study26. 06. 29.

자바스크립트 퀴즈북 리마인드 Day 7: 클로저 버그와 오래된.

클로저가 값 복사가 아니라 렉시컬 환경 참조라는 점을 stale closure, 루프 콜백, React 이벤트 리뷰 관점에서 정리합니다.

26. 06. 29.SEOJing
스코프 체인과 호이스팅 관계를 단순화한 다이어그램
Study26. 06. 28.

자바스크립트 퀴즈북 리마인드 Day 6: 스코프 체인과 호이스팅.

스코프 체인, 렉시컬 환경, var/let/const 호이스팅 차이를 콜백·상태 버그 리뷰 관점에서 다시 정리합니다.

26. 06. 28.SEOJing
JavaScript 객체 참조와 Map, Set, WeakMap의 참조 구조를 비교한 다이어그램
Study26. 06. 27.

자바스크립트 퀴즈북 리마인드 Day 5: 참조, 복사,.

객체 참조와 얕은 복사, Object와 Map/Set의 차이, WeakMap/WeakSet이 필요한 메모리 상황을 프론트엔드 코드 리뷰 관점에서 정리합니다.

26. 06. 27.SEOJing
JavaScript 프로퍼티 descriptor가 data descriptor와 accessor descriptor로 갈라지는 구조 다이어그램
Study26. 06. 26.

자바스크립트 퀴즈북 리마인드 Day 4: 프로퍼티.

객체 프로퍼티를 key/value가 아니라 descriptor로 읽는 법, getter/setter와 defineProperty, preventExtensions/seal/freeze의 경계를 코드 리뷰 관점에서 정리합니다.

26. 06. 26.SEOJing
객체가 ToPrimitive를 거쳐 + 연산자의 문자열 연결 또는 숫자 덧셈으로 갈라지는 흐름 다이어그램
Study26. 06. 25.

자바스크립트 퀴즈북 리마인드 Day 3: + 연산자와.

객체가 원시값으로 바뀌는 ToPrimitive 흐름, + 연산자의 문자열 연결과 숫자 덧셈 분기, Symbol.toPrimitive가 코드 리뷰에서 왜 중요한지 정리합니다.

26. 06. 25.SEOJing
==, ===, Object.is, SameValueZero의 차이를 정리한 JavaScript equality 다이어그램
Study26. 06. 24.

자바스크립트 퀴즈북 리마인드 Day 2: 같은 값인지 묻는 네.

==, ===, Object.is, SameValueZero가 각각 어떤 비교 알고리즘을 쓰는지 정리하고, includes와 indexOf, Map/Set 키 비교에서 생기는 프론트엔드 리뷰 포인트를 잡습니다.

26. 06. 24.SEOJing
JavaScript Number, safe integer, NaN, -0, BigInt를 한 장으로 정리한 다이어그램
Study26. 06. 23.

자바스크립트 퀴즈북 리마인드 Day 1: 숫자는 왜 가끔 믿을.

자바스크립트의 Number가 왜 정수처럼 보여도 부동소수점 모델 위에서 움직이는지, NaN과 -0, safe integer, BigInt를 프론트엔드 코드 리뷰 관점에서 다시 정리합니다.

26. 06. 23.SEOJing