JavaScript 스코프와 클로저는 함수의 변수 접근과 상태를 어떻게 연결하나요?
함수가 읽는 변수는 정의된 위치의 범위와 연결됩니다. 클로저는 호출이 끝난 뒤에도 내부 상태를 사용하게 합니다. 두 카운터 중 하나만 증가시켜 각 상태가 독립적으로 남는지 비교합니다.
적용 조건·범위: 클로저는 ‘함수 안의 함수’라는 모양 자체가 아니라 함수가 자신이 정의된 외부 환경을 참조하는 동작입니다.
공식 문서 참고: MDN: 렉시컬 스코프와 클로저의 상태 보존 · MDN: 선언 종류별 호이스팅과 초기화 차이 · MDN: 실행 컨텍스트·태스크·마이크로태스크
혼자 실행해 보는 클로저와 상태 보호
JavaScript 실행 컨텍스트 기준: 스코프 호이스팅 클로저 연결하기 · 독립 실습
외부에서 내부 상태를 직접 바꾸지 않고 클로저로 읽고 변경하는 출력을 확인하세요.
예제 크게 보기 (새 탭)독자 질문: 함수 호출이 끝난 뒤에도 왜 내부 변수가 유지되는가?
js-11-example.mjs로 저장하고 node js-11-example.mjs을 실행하세요. 외부 패키지는 필요하지 않습니다.
function createCounter(initial = 0) {
let count = initial;
return {
increment() { count += 1; return count; },
read() { return count; },
};
}
const first = createCounter();
const second = createCounter(10);
console.log('첫 번째:', first.increment(), first.read());
console.log('두 번째:', second.read());
console.log('외부 접근:', Object.hasOwn(first, 'count'));
예상 출력:
첫 번째: 1 1
두 번째: 10
외부 접근: false
실행 검증: Node.js에서 위 출력과 일치했습니다. TaskFlow store의 내부 tasks를 반환할 때 객체 참조까지 복사할지 따로 판단하세요.
직접 풀어볼 문제
첫 번째 카운터만 두 번 더 증가시키세요. 첫 번째는 3, 두 번째는 10이어야 합니다.
스코프는 이름을 찾을 수 있는 범위입니다
const serviceName = 'TaskFlow';
function printTask() {
const title = 'JavaScript 공부';
console.log(serviceName);
console.log(title);
}
printTask() 안에서는 바깥의 serviceName과 함수 내부의 title을 모두 찾을 수 있습니다. 반대로 함수 바깥에서는 title을 찾을 수 없습니다.
JavaScript는 렉시컬 스코프를 사용합니다
함수가 어디에서 호출되었는지가 아니라 어디에서 정의되었는지를 기준으로 외부 변수를 찾습니다.
const value = 'outer';
function createPrinter() {
const value = 'inner';
return function print() {
console.log(value);
};
}
const print = createPrinter();
print();
print()은 createPrinter()가 끝난 뒤 호출되어도 정의된 위치의 value를 참조합니다.
호이스팅을 ‘코드가 위로 이동한다’고 외우지 않습니다
JavaScript 엔진은 실행 전에 스코프의 선언을 처리합니다. 하지만 선언 종류에 따라 초기화 시점이 다릅니다.
console.log(count);
var count = 1;
var는 선언 단계에서 undefined로 초기화되므로 첫 줄이 실행됩니다.
반면:
console.log(title);
const title = 'TaskFlow';
선언은 인식되지만 초기화 전에 접근하므로 ReferenceError가 발생합니다. 이 구간을 TDZ라고 부릅니다.
실행 컨텍스트는 함수 실행 단위를 이해하기 위한 모델입니다
함수가 호출되면 해당 호출에서 사용할 매개변수, 지역 변수, 외부 환경 참조 등을 가진 실행 단위가 만들어집니다.
function double(value) {
return value * 2;
}
function calculate() {
return double(3) + 1;
}
calculate();
호출 흐름을 단순화하면 다음처럼 볼 수 있습니다.
global
→ calculate()
→ double(3)
← 6
← 7
클로저는 함수와 외부 렉시컬 환경의 연결입니다
function createCounter() {
let count = 0;
return function increment() {
count += 1;
return count;
};
}
const increment = createCounter();
console.log(increment());
console.log(increment());
createCounter() 호출은 끝났지만 반환된 함수가 count를 계속 참조하므로 값이 유지됩니다.
클로저는 ‘함수 안의 함수’라는 모양 자체가 아니라 함수가 자신이 정의된 외부 환경을 참조하는 동작입니다.

클로저로 상태 접근 범위를 줄이기
현재 TaskFlow는 모듈 최상단의 tasks를 여러 함수가 공유합니다. 모듈 자체도 스코프를 제공하지만 store를 생성하는 함수로 한 번 더 경계를 만들면 각 store 인스턴스가 독립된 상태를 가질 수 있습니다.
function createStore() {
let value = 0;
return {
getValue() {
return value;
},
increase() {
value += 1;
},
};
}
자주 발생하는 오류
호이스팅을 코드 이동으로 설명합니다
실제로 소스가 위로 이동하는 것이 아니라 실행 전에 선언이 처리되는 방식의 결과입니다.
클로저가 생기면 메모리 누수라고 생각합니다
클로저 자체는 정상적인 언어 기능입니다. 더 이상 필요하지 않은 큰 객체를 장기간 참조하는 구조가 문제일 수 있습니다.
getTasks()가 내부 배열을 그대로 반환합니다
호출한 코드가 배열이나 객체를 직접 바꿀 수 있습니다. store 경계를 만들었다면 외부가 내부 참조를 임의로 수정하지 않도록 반환 기준도 정해야 합니다.
학습 완료 체크리스트
- 스코프와 렉시컬 스코프를 설명할 수 있습니다.
var와let/const의 초기화 시점 차이를 이해했습니다.- TDZ에서 발생하는 오류를 설명할 수 있습니다.
- 클로저가 외부 상태를 유지하는 이유를 이해했습니다.
- 클로저를 이용해 상태 접근 경계를 만들 수 있습니다.
독립 실습은 여기서 마칠 수 있습니다. 아래 선택 실습은 TaskFlow 프로젝트 파일로 진행합니다.
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 JavaScript로 화면에 동작을 붙입니다.
createTaskStore로 상태와 CRUD를 묶고 기존 날짜·필터·URL 복원 동작을 확인합니다.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow 프로젝트 적용
JavaScript 실행 컨텍스트 기준: 스코프 호이스팅 클로저 연결하기 · 단계별 TaskFlow
작업 추가·완료 전환·삭제와 현재 단계의 필터/출력을 확인하세요. 검색 조건을 바꿔 가상 URL을 확인하세요.
예제 크게 보기 (새 탭)변경 설명과 부분 예제를 읽은 뒤, 시작·완료 파일과 적용 순서에서 필요한 선언·import·화면 연결을 포함한 전체 파일을 확인하세요. 이번 단계의 변경 이유와 적용 위치도 함께 확인합니다.
시작본과 이번 변경
JavaScript Date UTC KST 차이: 시간대 변환 기준 잡기의 완료 체크포인트를 시작본으로 사용합니다.
js/task-store.js를 아래 전체 파일로 교체한 뒤 app.js의 개별 함수 import를 createTaskStore로 바꾸세요. store는 한 번 생성하고 기존 CRUD 호출 앞에 store를 붙입니다. 날짜·필터·URL 복원 결과가 기존과 같은지 확인합니다.
연속 프로젝트 선수 조건
- 함수와 ES Module
- TaskFlow store의 현재 CRUD API
javascript-step-10-date-time체크포인트
이전 체크포인트
javascript-step-10-date-time
목표
모듈 전역의 tasks를 createTaskStore() 내부로 옮기고 앞으로 fetch가 사용할 replaceTasks()까지 공개 API를 확정합니다.
이번 회차 변경 코드와 전체본 연결
이번 체크포인트의 핵심은 store 경계 리팩터링입니다. HTML/CSS와 view 모듈은 기존 구조를 유지합니다.
전체 파일·코드 보기
js/task-store.js 전체 코드
function cloneTask(task) {
return { ...task };
}
const defaultTasks = [
{
id: '1',
title: 'JavaScript 과정 정리',
completed: false,
createdAt: '2026-09-20T00:00:00.000Z',
dueAt: null,
},
{
id: '2',
title: 'TaskFlow 화면 확인',
completed: true,
createdAt: '2026-09-20T01:00:00.000Z',
dueAt: null,
},
];
export function createTaskStore(initialTasks = defaultTasks) {
let tasks = initialTasks.map(cloneTask);
function getTasks() {
return tasks.map(cloneTask);
}
function addTask(title, dueAt = null) {
const task = {
id: String(Date.now()),
title,
completed: false,
createdAt: new Date().toISOString(),
dueAt,
};
tasks = [...tasks, task];
return cloneTask(task);
}
function toggleTask(id) {
let updatedTask = null;
tasks = tasks.map((task) => {
if (task.id !== id) return task;
updatedTask = {
...task,
completed: !task.completed,
};
return updatedTask;
});
return updatedTask ? cloneTask(updatedTask) : null;
}
function deleteTask(id) {
tasks = tasks.filter((task) => task.id !== id);
}
function filterTasks(status = 'all', query = '') {
const normalizedQuery = query.trim().toLowerCase();
return tasks
.filter((task) => {
const matchesStatus =
status === 'all' ||
(status === 'completed' && task.completed) ||
(status === 'pending' && !task.completed);
const matchesQuery =
!normalizedQuery ||
task.title.toLowerCase().includes(normalizedQuery);
return matchesStatus && matchesQuery;
})
.map(cloneTask);
}
function getTaskSummary() {
return tasks.reduce(
(summary, task) => {
summary.total += 1;
summary.completed += task.completed ? 1 : 0;
summary.pending += task.completed ? 0 : 1;
return summary;
},
{ total: 0, completed: 0, pending: 0 },
);
}
function replaceTasks(nextTasks) {
tasks = nextTasks.map(cloneTask);
}
return {
getTasks,
addTask,
toggleTask,
deleteTask,
filterTasks,
getTaskSummary,
replaceTasks,
};
}
js/app.js 핵심 변경
기존 개별 함수 import 대신 store 인스턴스를 하나 만듭니다.
import { createTaskStore } from './task-store.js';
const store = createTaskStore();
이후 코드는 다음처럼 호출합니다.
store.addTask(title, dueAt);
store.toggleTask(id);
store.deleteTask(id);
store.filterTasks(status, query);
store.getTaskSummary();
회귀 검증
- 작업 추가
- 완료/미완료 변경
- 삭제
- 상태 필터
- 검색
- URL 복원
- createdAt / dueAt 표시
리팩터링 전과 기능 결과가 같아야 합니다.
중간부터 시작하는 전체 파일과 적용 순서
시작 ZIP은 직전 프로젝트 단계 javascript-step-10-date-time의 실제 완료 파일입니다. 프로젝트 순서를 이어가는 기준이며 사이트의 기술 읽기 순서와는 구분합니다.
- 시작 ZIP을 새 폴더에 풉니다. 이 폴더가 작업 루트이며 기존 체크포인트 파일을 별도로 찾을 필요가 없습니다.
- store를 클로저 전체본으로 교체하고 app에서 한 번 생성한 store를 사용합니다. 보강 전체본은 직전 3개 초기 데이터를 유지합니다.
- 아래 변경 파일을 같은 상대 경로에 추가하거나 전체 교체합니다. 표시하지 않은 파일과 images는 시작본 그대로 유지합니다. package-lock.json은 완료 ZIP에 있는 파일을 함께 적용합니다.
- 작업 루트에서 명령을 실행하고 예상 결과를 확인합니다. 완료 ZIP은 직접 적용한 결과를 비교하는 기준입니다.
추가·교체 파일
CHANGES.md— 전체 교체README.md— 전체 교체js/app.js— 전체 교체js/task-store.js— 전체 교체
실행과 확인
npm start
npm test
개발 서버는 실행 상태로 두고 다른 터미널에서 검사 명령을 실행하세요. Node.js 24.18.0에서 검증했습니다. JavaScript는 http://127.0.0.1:4173, TypeScript는 Vite 실행 출력의 주소로 접속합니다.
예상 결과: CRUD·URL·날짜 동작 유지, store 두 개의 상태 분리, 반환 객체 변경이 내부 상태에 영향을 주지 않습니다.
실제 검증: JavaScript 모듈·DOM·경계값 실행 검사 통과. 시작 ZIP에 이 글의 변경 파일만 적용한 결과와 완료 ZIP의 모든 파일 내용이 일치했습니다. 브라우저 화면 배치·실제 기기·외부 CDN 가용성은 이 검사에 포함하지 않았습니다.
이 절의 전체 파일은 ZIP과 일치합니다. 위쪽 원문의 짧은 예제나 생략 표시가 있는 부분 코드는 개념·변경 위치 설명용입니다. 서버 저장과 인증은 제공하지 않으며 JSON fixture는 로컬 읽기 실습용입니다.
복사할 변경 파일 전체 코드
js/app.js — 전체 파일
import { createTaskStore } from './task-store.js';
const store = createTaskStore();
import {
bindTaskControls,
bindTaskForm,
bindTaskList,
getTaskControls,
renderTasks,
setTaskControls,
} from './task-view.js';
import { readUrlState, writeUrlState } from './url-state.js';
function render() {
const controls = getTaskControls();
const tasks = store.filterTasks(controls.status, controls.query);
renderTasks(tasks, store.getTaskSummary());
}
const initialUrlState = readUrlState();
setTaskControls(initialUrlState);
bindTaskForm(({ title, dueAt }) => {
store.addTask(title, dueAt);
render();
});
bindTaskControls(() => {
const controls = getTaskControls();
writeUrlState(controls);
render();
});
bindTaskList(({ id, action }) => {
if (action === 'toggle') store.toggleTask(id);
if (action === 'delete') store.deleteTask(id);
render();
});
render();
js/task-store.js — 전체 파일
function cloneTask(task) {
return { ...task };
}
const defaultTasks = [
{ id: '3', title: '다음 글 읽기', completed: false, createdAt: '2026-09-20T00:00:00.000Z', dueAt: null },
{
id: '1',
title: 'JavaScript 과정 정리',
completed: false,
createdAt: '2026-09-20T00:00:00.000Z',
dueAt: null,
},
{
id: '2',
title: 'TaskFlow 화면 확인',
completed: true,
createdAt: '2026-09-20T01:00:00.000Z',
dueAt: null,
},
];
export function createTaskStore(initialTasks = defaultTasks) {
let tasks = initialTasks.map(cloneTask);
function getTasks() {
return tasks.map(cloneTask);
}
function addTask(title, dueAt = null) {
const task = {
id: crypto.randomUUID(),
title,
completed: false,
createdAt: new Date().toISOString(),
dueAt,
};
tasks = [...tasks, task];
return cloneTask(task);
}
function toggleTask(id) {
let updatedTask = null;
tasks = tasks.map((task) => {
if (task.id !== id) return task;
updatedTask = {
...task,
completed: !task.completed,
};
return updatedTask;
});
return updatedTask ? cloneTask(updatedTask) : null;
}
function deleteTask(id) {
tasks = tasks.filter((task) => task.id !== id);
}
function filterTasks(status = 'all', query = '') {
const normalizedQuery = query.trim().toLowerCase();
return tasks
.filter((task) => {
const matchesStatus =
status === 'all' ||
(status === 'completed' && task.completed) ||
(status === 'pending' && !task.completed);
const matchesQuery =
!normalizedQuery ||
task.title.toLowerCase().includes(normalizedQuery);
return matchesStatus && matchesQuery;
})
.map(cloneTask);
}
function getTaskSummary() {
return tasks.reduce(
(summary, task) => {
summary.total += 1;
summary.completed += task.completed ? 1 : 0;
summary.pending += task.completed ? 0 : 1;
return summary;
},
{ total: 0, completed: 0, pending: 0 },
);
}
function replaceTasks(nextTasks) {
tasks = nextTasks.map(cloneTask);
}
return {
getTasks,
addTask,
toggleTask,
deleteTask,
filterTasks,
getTaskSummary,
replaceTasks,
};
}
완료 체크포인트
javascript-step-11-execution-context
다음 학습
다음 글에서는 외부 JSON을 fetch()로 읽어 store의 replaceTasks()에 전달하고 loading·success·empty·error 상태를 화면에 분리합니다.
다음 글: JavaScript fetch 오류 처리: Promise부터 404까지
글 소개·학습 준비·전체 목차

JavaScript · 11 / 15 · 필수 원리 · 실행 컨텍스트·스코프·호이스팅·클로저
스코프, 호이스팅, 클로저를 각각 따로 암기하면 실제 코드에서 왜 그런 결과가 나오는지 연결하기 어렵습니다.
이번 글에서는 ‘함수가 어디에서 정의되었고 어떤 변수를 찾을 수 있는가’라는 흐름으로 세 개념을 연결한 뒤, TaskFlow의 상태를 클로저 안에 캡슐화합니다.
이번 글에서 배우는 내용
- 전역·함수·블록 스코프
- 렉시컬 스코프
var,let,const호이스팅 차이- TDZ
- 실행 컨텍스트 개념
- 클로저
- 상태 캡슐화
먼저 알아야 할 내용
함수·반환값·객체를 알면 됩니다. store 구현 경험 없이 카운터 두 개로 클로저를 확인합니다.
목차
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 15개 · 필수 14개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.