
프로그래밍을 처음 배울 때 스코프는 보통 변수를 사용할 수 있는 코드의 범위라고 배운다.
function greet() {
const message = "Hello";
console.log(message);
}
console.log(message); // ReferenceError
함수 안에서 선언한 message는 함수 밖에서 사용할 수 없다.
스코프의 기본적인 개념을 이해하기에는 충분한 설명이다.
하지만 실제 서비스를 개발하기 시작하면 스코프를 설계한다는 것은 단순히 변수가 어디에서 보이는지 정하는 것보다 훨씬 많은 판단을 요구한다.
예를 들어 쿠폰을 발급하는 기능을 만든다고 생각해 보자.
let currentCoupon: Coupon;
let issuedCount: number;
이 값들이 어디에 선언되어 있는지에 따라 서비스의 안전성이 달라진다.
스코프는 코드에서 변수 이름이 어디까지 보이는지를 정하는 문법이다.
동시에 어떤 코드가 그 값을 읽고 바꿀 수 있는지, 그 값이 얼마나 오래 존재하는지도 결정한다.
실제 서비스에서 스코프를 설계한다는 것은 변수가 보이는 범위를 정하는 문법을 작성하는 일이 아니라, 데이터에 대한 접근 권한과 생명주기를 제한하는 일이다.
쿠폰 발급 개수를 관리하는 코드를 생각해 보자.
let issuedCount = 0;
function issueCoupon(recipientId: string): Coupon {
issuedCount += 1;
return createCoupon(recipientId);
}
function getIssuedCount(): number {
return issuedCount;
}
issuedCount는 모듈 최상위에 선언되어 있다.
이 파일 안의 어떤 함수도 issuedCount를 읽고 바꿀 수 있다.
function resetForTesting() {
issuedCount = 0;
}
function debugHack() {
issuedCount = 999999;
}
문제를 일으키는 코드가 아니더라도, issuedCount를 다루는 코드가 파일 안에 늘어날수록 이 값이 정확히 언제 어떻게 바뀌는지 추적하기 어려워진다.
issuedCount를 변경할 수 있는 범위를 좁히면 이 값에 어떤 코드가 영향을 줄 수 있는지 분명해진다.
function createCouponIssuer() {
let issuedCount = 0;
return {
issue(recipientId: string): Coupon {
issuedCount += 1;
return createCoupon(recipientId);
},
getIssuedCount(): number {
return issuedCount;
},
};
}
const issuer = createCouponIssuer();
issuer.issue("user_123");
issuer.getIssuedCount(); // 1
이제 issuedCount는 createCouponIssuer가 반환하는 함수들을 통해서만 변경할 수 있다.
파일의 다른 어떤 코드도 이 값을 직접 건드릴 수 없다.
스코프를 좁힌다는 것은 단순히 변수 이름의 충돌을 막는 것이 아니다.
이 값을 변경할 수 있는 코드의 범위를 명시적으로 제한하는 것이다.
값을 다루는 코드를 설계할 때는 먼저 다음 질문을 해야 한다.
이 값을 실제로 변경해야 하는 코드는 어디까지인가?
반대 방향의 문제도 있다.
쿠폰 검증 로직을 함수 안에 지역 변수로 두었다고 생각해 보자.
function validateCouponRequest(body: unknown): CreateCouponInput {
const titlePattern = /^.{1,50}$/;
const maxRemainingUses = 100;
// ... titlePattern과 maxRemainingUses를 사용한 검증
}
이 함수 하나만 있을 때는 문제가 없다.
하지만 쿠폰 제목 검증 규칙과 최대 사용 횟수 제한이 다른 함수에서도 똑같이 필요해지면 상황이 달라진다.
function validateCouponUpdateRequest(body: unknown) {
const titlePattern = /^.{1,50}$/; // 같은 규칙을 다시 선언
const maxRemainingUses = 100; // 다시 선언
}
같은 값이 여러 함수에 각각 지역 변수로 흩어지면 규칙이 바뀔 때 모든 함수를 찾아 수정해야 한다.
지역 스코프에 두는 것이 항상 정답은 아니다.
여러 함수가 공유해야 하는 규칙이라면 그 값의 스코프를 의도적으로 넓혀야 한다.
const COUPON_TITLE_PATTERN = /^.{1,50}$/;
const MAX_REMAINING_USES = 100;
function validateCouponRequest(body: unknown): CreateCouponInput {
// COUPON_TITLE_PATTERN과 MAX_REMAINING_USES를 사용
}
function validateCouponUpdateRequest(body: unknown) {
// 같은 상수를 재사용
}
스코프를 정하는 기준은 "무조건 좁게"가 아니다.
이 값을 실제로 알아야 하는 코드가 어디까지인지가 기준이다.
한 함수 안에서만 의미가 있는 값이라면 지역 스코프에 두고, 여러 함수가 같은 규칙을 공유해야 한다면 그 값을 알아야 하는 범위만큼 스코프를 넓힌다.
여러 쿠폰의 만료 알림을 예약하는 코드를 생각해 보자.
var notifications = [];
for (var i = 0; i < coupons.length; i++) {
var coupon = coupons[i];
notifications.push(function () {
sendExpiryAlert(coupon);
});
}
notifications.forEach((fn) => fn());
이 코드를 실행하면 모든 알림이 마지막 쿠폰에 대한 내용으로 전송된다.
var로 선언한 coupon은 함수 스코프를 가지기 때문에 반복문의 모든 반복이 같은 coupon 변수를 공유한다.
반복문이 끝난 시점에는 coupon이 이미 마지막 쿠폰을 가리키고 있다.
const notifications = [];
for (const coupon of coupons) {
notifications.push(function () {
sendExpiryAlert(coupon);
});
}
notifications.forEach((fn) => fn());
const와 let은 블록 스코프를 가지기 때문에 반복마다 새로운 coupon이 만들어진다.
각 함수는 자신이 선언될 때의 coupon을 독립적으로 기억한다.
이 차이는 문법적인 세부사항처럼 보이지만 실제로는 각 반복이 자신만의 데이터를 가지는가, 아니면 하나의 데이터를 공유하는가의 문제다.
비동기 작업이 섞이면 이 차이는 더 분명하게 드러난다.
async function sendExpiryNotifications(coupons: Coupon[]) {
for (const coupon of coupons) {
setTimeout(() => {
sendExpiryAlert(coupon);
}, 1000);
}
}
setTimeout이 실행되는 시점에는 반복문이 이미 끝나 있다.
coupon이 블록 스코프로 선언되어 있기 때문에 각 타이머는 자신이 만들어질 때의 쿠폰을 정확히 기억한다.
만약 coupon이 함수 전체에서 공유되는 값이었다면, 모든 타이머가 실행되는 시점에는 이미 마지막 쿠폰만 남아 있었을 것이다.
스코프는 반복되는 작업에서 각 반복이 자신만의 데이터를 가지는지, 아니면 다른 반복과 상태를 공유하는지를 결정한다.
쿠폰 할인율을 계산하는 함수를 만든다고 생각해 보자.
function createDiscountCalculator(discountRate: number) {
return function (price: number): number {
return price * (1 - discountRate);
};
}
const summerDiscount = createDiscountCalculator(0.2);
const winterDiscount = createDiscountCalculator(0.3);
summerDiscount(10000); // 8000
winterDiscount(10000); // 7000
createDiscountCalculator는 실행이 끝났지만, 안에서 반환한 함수는 여전히 discountRate에 접근할 수 있다.
discountRate는 함수 실행이 끝난 뒤에도 사라지지 않고, 반환된 함수의 스코프 안에 계속 살아 있다.
이것이 클로저다.
클로저는 편리하지만 값의 생명주기를 예상보다 길게 만든다.
function createCouponCache() {
const cache: Map<string, Coupon> = new Map();
return {
get(couponId: string) {
return cache.get(couponId);
},
set(couponId: string, coupon: Coupon) {
cache.set(couponId, coupon);
},
};
}
const couponCache = createCouponCache();
couponCache가 애플리케이션 전체에서 하나만 만들어져 계속 유지된다면, cache 안의 Map도 애플리케이션이 종료될 때까지 계속 커질 수 있다.
클로저 안의 값은 일반적인 지역 변수처럼 함수가 끝나면 사라지는 것이 아니라, 그 값을 참조하는 함수가 남아있는 한 계속 존재한다.
클로저를 사용할 때는 다음을 확인해야 한다.
이 클로저 안의 값은 언제까지 살아있어야 하는가? 그리고 그 값을 계속 붙잡고 있는 함수는 언제 사라지는가?
의도적으로 상태를 오래 유지하려는 것이라면 문제가 없다.
하지만 그럴 의도가 없었는데 클로저가 큰 데이터를 계속 붙잡고 있다면, 이는 메모리 누수로 이어질 수 있다.
여러 요청을 동시에 처리하는 서버에서 다음과 같은 코드를 작성했다고 생각해 보자.
let currentUser: User;
function handleRequest(request: Request) {
currentUser = request.currentUser;
return processCoupon(request);
}
function processCoupon(request: Request) {
// currentUser를 사용
}
서버가 동시에 여러 요청을 처리하는 환경에서 currentUser는 전역 변수다.
요청 A를 처리하는 도중 요청 B가 들어와 currentUser를 다른 사용자로 바꾸면, 요청 A가 이후에 currentUser를 읽을 때는 이미 요청 B의 사용자 정보로 바뀌어 있을 수 있다.
요청 A: currentUser = userA
요청 B: currentUser = userB ← 요청 A가 아직 처리 중인데 값이 바뀐다
요청 A: processCoupon() ← currentUser가 userB로 되어 있다
전역 스코프에 값을 두는 순간, 그 값은 하나의 요청만을 위한 데이터가 아니라 모든 요청이 공유하는 데이터가 된다.
사용자 정보처럼 요청마다 달라야 하는 값은 전역 스코프가 아니라 각 요청의 범위 안에 있어야 한다.
function handleRequest(request: Request) {
const currentUser = request.currentUser;
return processCoupon(request, currentUser);
}
function processCoupon(request: Request, currentUser: User) {
// 이 요청만을 위한 currentUser를 사용
}
값을 함수의 매개변수로 명시적으로 전달하면, 그 값은 이 호출 하나에만 속하게 된다.
다른 요청이 같은 이름의 변수를 사용하더라도 서로 영향을 주지 않는다.
전역 스코프를 사용할 때는 다음을 확인해야 한다.
이 값은 애플리케이션 전체에서 하나만 존재해야 하는가, 아니면 요청이나 사용자마다 독립적이어야 하는가?
설정값이나 상수처럼 애플리케이션 전체에서 하나만 있어도 되는 값은 전역 스코프가 적절하다.
하지만 요청마다, 사용자마다 달라져야 하는 값이라면 전역 스코프는 데이터가 서로 뒤섞이는 원인이 된다.
쿠폰 코드를 검증하는 로직을 담은 모듈을 생각해 보자.
// couponValidator.ts
const SECRET_SALT = "internal-validation-salt";
function hashCode(code: string): string {
return computeHash(code + SECRET_SALT);
}
export function isValidCouponCode(code: string): boolean {
const hashed = hashCode(code);
return knownHashes.has(hashed);
}
이 파일은 isValidCouponCode만 export한다.
SECRET_SALT와 hashCode는 이 모듈 밖에서 접근할 수 없다.
// 다른 파일에서
import { isValidCouponCode } from "./couponValidator";
isValidCouponCode("SUMMER2026"); // 사용 가능
SECRET_SALT; // ReferenceError, 접근 불가
만약 이 값들을 실수로 함께 export했다면 어떻게 될까.
export const SECRET_SALT = "internal-validation-salt";
export function hashCode(code: string): string { /* ... */ }
export function isValidCouponCode(code: string): boolean { /* ... */ }
다른 개발자가 hashCode를 직접 가져다 쓰기 시작하면, 나중에 해시 방식을 바꾸고 싶어도 이 함수를 사용하는 모든 곳을 찾아 함께 수정해야 한다.
내부 구현이 외부에 노출되는 순간, 그 구현은 더 이상 자유롭게 바꿀 수 없는 공개된 계약이 된다.
모듈에서 export하는 범위를 결정하는 것은 이 모듈이 무엇을 약속하고, 무엇은 내부의 자유로운 재량으로 남겨둘 것인지를 정하는 일이다.
export된 것 → 외부와의 계약, 함부로 바꾸면 다른 코드가 깨진다
export되지 않은 것 → 내부 구현, 자유롭게 바꿀 수 있다
스코프를 좁게 유지한다는 것은 코드를 숨기기 위한 것이 아니다.
무엇을 바꿔도 되고 무엇을 바꾸면 안 되는지의 경계를 명확히 하는 것이다.
같은 쿠폰 데이터라도 어느 스코프에 있는지에 따라 생명주기가 완전히 다르다.
function calculateDiscount(coupon: Coupon, price: number): number {
const discountAmount = price * coupon.discountRate; // 함수 실행 동안만 존재
return price - discountAmount;
}
function CouponBanner({ coupon }: { coupon: Coupon }) {
const [isExpanded, setIsExpanded] = useState(false); // 컴포넌트가 화면에 있는 동안 존재
// ...
}
localStorage.setItem("lastViewedCoupon", couponId); // 브라우저를 닫아도 유지된다
await couponRepository.save(coupon); // 서버가 재시작되어도 유지된다
같은 "쿠폰 데이터"라는 개념이지만 실제로는 서로 다른 생명주기를 가진다.
| 스코프 | 생명주기 |
|---|---|
| 함수 지역 변수 | 함수 실행이 끝나면 사라진다 |
| 컴포넌트 상태 | 컴포넌트가 화면에서 사라지면 소멸한다 |
| 모듈 스코프 | 애플리케이션이 실행되는 동안 유지된다 |
| 브라우저 저장소 | 사용자가 지우기 전까지 브라우저에 남는다 |
| 데이터베이스 | 명시적으로 삭제하기 전까지 영구히 남는다 |
스코프를 정하는 문제는 단순히 "이 변수가 어디서 보이는가"의 문제가 아니라 "이 값이 얼마나 오래 존재해야 하는가"의 문제와 직접 연결되어 있다.
값을 필요 이상으로 넓은 스코프에 두면, 그 값이 필요 이상으로 오래 살아남는다.
쿠폰 할인 계산 중간값을 함수 지역 변수 대신 모듈 스코프에 저장했다고 생각해 보자.
let lastDiscountAmount: number;
function calculateDiscount(coupon: Coupon, price: number): number {
lastDiscountAmount = price * coupon.discountRate;
return price - lastDiscountAmount;
}
이 함수를 여러 요청이 동시에 호출하면 lastDiscountAmount는 계산이 끝나기도 전에 다른 요청에 의해 덮어써질 수 있다.
원래 함수 실행 동안만 존재하면 충분한 값이 필요 이상으로 넓은 스코프에 머물면서 문제를 일으킨다.
값을 선언할 때는 다음 질문이 스코프의 위치를 결정한다.
이 값은 정확히 언제부터 언제까지 존재해야 하는가?
let currentUser: User;
function handleRequest(request: Request) {
currentUser = request.currentUser;
// 다른 요청이 동시에 currentUser를 바꿀 수 있다.
}
function handleRequest(request: Request) {
const currentUser = request.currentUser;
return processCoupon(request, currentUser);
}
요청마다 달라져야 하는 값은 함수의 매개변수로 전달한다.
for (var i = 0; i < coupons.length; i++) {
setTimeout(() => sendExpiryAlert(coupons[i]), 1000);
}
for (const coupon of coupons) {
setTimeout(() => sendExpiryAlert(coupon), 1000);
}
블록 스코프를 가지는 const나 let을 사용해 각 반복을 독립시킨다.
export const SECRET_SALT = "...";
export function hashCode(code: string) { /* ... */ }
다른 코드가 내부 구현에 의존하기 시작하면 자유롭게 바꿀 수 없다.
// SECRET_SALT와 hashCode는 export하지 않는다
export function isValidCouponCode(code: string): boolean { /* ... */ }
외부에 필요한 결과만 공개한다.
let lastDiscountAmount: number;
function calculateDiscount(coupon: Coupon, price: number) {
lastDiscountAmount = price * coupon.discountRate;
return price - lastDiscountAmount;
}
function calculateDiscount(coupon: Coupon, price: number) {
const discountAmount = price * coupon.discountRate;
return price - discountAmount;
}
값이 필요한 범위만큼만 스코프를 좁힌다.
function validateCreate(body: unknown) {
const maxUses = 100; // 여기서도 선언
}
function validateUpdate(body: unknown) {
const maxUses = 100; // 저기서도 선언
}
const MAX_REMAINING_USES = 100;
function validateCreate(body: unknown) {
// MAX_REMAINING_USES 사용
}
function validateUpdate(body: unknown) {
// MAX_REMAINING_USES 사용
}
여러 함수가 공유해야 하는 값은 그 함수들이 접근할 수 있는 범위로 스코프를 넓힌다.
function createCouponCache() {
const cache = new Map<string, Coupon>();
// cache가 애플리케이션 종료까지 계속 커질 수 있다.
return {
get: (id: string) => cache.get(id),
set: (id: string, c: Coupon) => cache.set(id, c),
};
}
function createCouponCache(maxSize: number) {
const cache = new Map<string, Coupon>();
return {
get: (id: string) => cache.get(id),
set: (id: string, c: Coupon) => {
if (cache.size >= maxSize) {
const oldestKey = cache.keys().next().value;
cache.delete(oldestKey);
}
cache.set(id, c);
},
};
}
클로저가 오래 유지하는 데이터라면 크기나 수명에 명시적인 제한을 둔다.
스코프는 변수를 사용할 수 있는 코드의 범위다.
function greet() {
const message = "Hello";
}
console.log(message); // ReferenceError
하지만 실제 서비스에서 스코프는 단순히 변수 이름이 보이는 범위를 정하는 문법이 아니다.
스코프는 데이터에 대한 접근 권한과 생명주기를 제한하는 방법이다.
function createCouponIssuer() {
let issuedCount = 0;
return {
issue(recipientId: string): Coupon {
issuedCount += 1;
return createCoupon(recipientId);
},
};
}
이 구조에는 다음과 같은 결정이 담겨 있다.
issuedCount는 누구나 바꿀 수 있는 값이 아니라 issue 함수를 통해서만 변경할 수 있다.createCouponIssuer가 반환한 객체가 존재하는 동안만 유지된다.스코프가 넓을수록 그 값에 접근할 수 있는 코드가 많아지고, 그만큼 그 값이 예상하지 못한 곳에서 바뀔 가능성도 커진다.
let currentUser: User; // 모든 요청이 공유하는 위험한 전역 상태
function handleRequest(request: Request) {
const currentUser = request.currentUser; // 이 요청에만 속하는 값
}
좋은 스코프 설계는 다음을 가능하게 한다.
결국 스코프를 정한다는 것은 다음 질문에 답하는 일이다.
이 값은 누가 접근할 수 있어야 하고, 언제부터 언제까지 존재해야 하는가?
스코프는 변수를 사용할 수 있는 범위가 아니다.
데이터의 접근 권한과 생명주기를 얼마나 좁게, 혹은 넓게 허용할 것인지를 설계하는 방법이다.