JavaScript 변수와 데이터 타입: let, const, var와 원시 타입

이 글의 핵심

선언 전에 변수를 읽었더니 var는 undefined, let은 ReferenceError가 나는 차이가 호이스팅을 이해하는 출발점입니다. 원시 값은 복사되고 객체는 참조가 공유되는 이유, 문자열과 숫자가 섞일 때 생기는 자동 형변환 함정을 짚어 변수와 타입을 예측 가능하게 다루도록 돕습니다.

시리즈 안내

#02 | 📋 전체 목차 | 이전: #01 시작하기 · 다음: #03 함수


들어가며

변수란?

변수(Variable)는 데이터를 담는 상자와 같습니다. JavaScript는 동적 타입 언어(실행할 때 값에 따라 타입이 정해지는 방식)이므로, 값의 종류는 실행 시점에 정해집니다. 아래에서 다루는 호이스팅(선언이 마치 코드 맨 위로 끌어올려진 것처럼 처리되는 동작)은, 코드가 위에서 아래로만 읽힌다고 생각하기 쉽지만 실제로는 선언이 먼저 정리된 뒤 실행이 이어집니다. 엘리베이터가 먼저 층·호실을 인식한 뒤에 문이 열리듯, 선언이 스코프 상단으로 올라간 뒤에 할당과 실행이 이어진다고 이해하시면 됩니다.


let, const, var로 변수 선언하기

let: 재할당 가능

let age = 25;
console.log(age);  // 25
age = 26;  // 재할당 가능
console.log(age);  // 26
// 선언만 하고 나중에 할당
let name;
console.log(name);  // undefined
name = "홍길동";
console.log(name);  // 홍길동

const: 재할당 불가

const는 상수를 선언하며, 재할당이 불가능합니다:

const PI = 3.14159;
console.log(PI);  // 3.14159
// 재할당 시도 시 에러
// PI = 3.14;  // TypeError: Assignment to constant variable
// const로 선언된 변수는 다른 값으로 재할당 불가
// 선언과 동시에 초기화 필수
// const x;  // SyntaxError: Missing initializer in const declaration
// const는 선언만 하고 나중에 할당할 수 없음
// 중요: 객체/배열의 내부는 변경 가능
const person = { name: "홍길동", age: 25 };
// person 변수 자체는 재할당 불가
// 하지만 객체의 프로퍼티는 변경 가능
person.age = 26;  // ✅ OK (객체 내부 변경)
console.log(person);  // { name: '홍길동', age: 26 }
// person = {};  // ❌ TypeError (재할당 불가)
// person 변수가 다른 객체를 가리키도록 할 수 없음
const arr = [1, 2, 3];
// arr 변수 자체는 재할당 불가
// 하지만 배열의 요소는 변경 가능
arr.push(4);  // ✅ OK (배열 내부 변경)
console.log(arr);  // [1, 2, 3, 4]
arr[0] = 10;  // ✅ OK (요소 수정)
console.log(arr);  // [10, 2, 3, 4]
// arr = [];  // ❌ TypeError (재할당 불가)
// arr 변수가 다른 배열을 가리키도록 할 수 없음

“기본은 const”라는 권장은 불변성을 보장하려는 것이 아니라 읽는 사람에게 정보를 주려는 것입니다. const로 선언된 변수는 이후 코드 어디에서도 다른 값으로 바뀌지 않으므로, 긴 함수를 읽을 때 그 변수의 값을 추적할 필요가 없습니다. 반대로 let을 보면 “이 값은 아래에서 바뀐다”는 신호로 읽을 수 있습니다. ESLint의 prefer-const 규칙이 재할당이 없는 let을 경고하는 것도 이 신호를 정확하게 유지하기 위해서입니다. const의 의미:

// const는 "변수가 가리키는 참조"를 고정
// 참조 대상의 내용은 변경 가능
const obj = { x: 1 };
// obj는 특정 객체의 메모리 주소를 가리킴
obj.x = 2;  // ✅ 객체 내용 변경 (주소는 그대로)
// obj = { x: 2 };  // ❌ 다른 객체로 교체 (주소 변경 시도)

완전한 불변 객체 만들기:

// Object.freeze(): 객체를 완전히 동결
const person = Object.freeze({ name: "홍길동", age: 25 });
// person.age = 26;  // 무시됨 (strict mode에서는 에러)
console.log(person.age);  // 25 (변경 안 됨)
// 중첩 객체는 별도로 freeze 필요
const user = Object.freeze({
    name: "홍길동",
    address: { city: "서울" }  // 이 객체는 freeze 안 됨
});
user.address.city = "부산";  // ✅ 변경됨
console.log(user.address.city);  // 부산

var: 구식 (사용 권장 안 함)

var x = 10;
var x = 20;  // 재선언 가능 (문제!)
console.log(x);  // 20
// 함수 스코프 (블록 스코프 무시)
if (true) {
    var y = 30;
}
console.log(y);  // 30 (블록 밖에서도 접근 가능!)
// 호이스팅 문제
console.log(z);  // undefined (에러 아님!)
var z = 40;

var의 재선언 허용이 실제로 문제가 되는 경우는 파일이 길어지거나 여러 스크립트가 같은 전역을 공유할 때입니다. 다른 사람이 위쪽에서 이미 쓰고 있는 var data를 모르고 아래에서 var data = ...로 다시 선언해도 에러가 전혀 나지 않고 기존 값이 조용히 덮어써집니다. let/const는 같은 스코프에서 재선언하면 SyntaxError: Identifier 'data' has already been declared로 실행 전에 막아 주므로, 이런 충돌을 코드를 돌려 보기도 전에 발견할 수 있습니다.

let vs const vs var 비교

특징letconstvar
재할당✅❌✅
재선언❌❌✅
스코프블록블록함수
호이스팅TDZTDZundefined
권장✅✅❌

TDZ (Temporal Dead Zone): 선언 전에 접근하면 에러

// let/const: TDZ
// console.log(x);  // ReferenceError
let x = 10;
// var: 호이스팅
console.log(y);  // undefined (에러 아님)
var y = 20;

블록·함수·전역 스코프

블록 스코프

let과 const는 블록 스코프를 가집니다:

// 블록 스코프: 중괄호 {} 내부에서만 유효
{
    let x = 10;
    const y = 20;
    console.log(x, y);  // 10 20
    // 이 블록 안에서만 x, y 사용 가능
}
// 블록 밖에서는 접근 불가
// console.log(x);  // ReferenceError: x is not defined
// console.log(y);  // ReferenceError: y is not defined
// x, y는 블록이 끝나면 소멸
// if 블록 스코프
if (true) {
    let z = 30;
    console.log(z);  // 30
    // z는 이 if 블록 안에서만 유효
}
// console.log(z);  // ReferenceError
// if 블록이 끝나면 z는 접근 불가
// for 블록 스코프
for (let i = 0; i < 3; i++) {
    // i는 이 for 루프 안에서만 유효
    console.log(i);  // 0 1 2
}
// console.log(i);  // ReferenceError
// 루프가 끝나면 i는 소멸
// 중첩 블록 스코프
{
    let x = 10;
    {
        let x = 20;  // 새로운 x (외부 x와 다름)
        console.log(x);  // 20 (내부 x)
    }
    console.log(x);  // 10 (외부 x)
}
// 실전 예시: 루프에서 클로저 사용
for (let i = 0; i < 3; i++) {
    // let은 각 반복마다 새로운 i 생성
    setTimeout(() => {
        console.log(i);  // 0 1 2 (각각 다른 i)
    }, 100);
}
// var를 사용하면?
for (var j = 0; j < 3; j++) {
    // var는 함수 스코프 (같은 j 공유)
    setTimeout(() => {
        console.log(j);  // 3 3 3 (모두 같은 j)
    }, 100);
}

블록 스코프의 장점:

  1. 변수 충돌 방지: 블록마다 독립적인 변수
  2. 참조 범위 축소: 블록 밖에서 참조할 수 없으므로, 클로저가 붙잡지 않는 한 가비지 컬렉터가 더 일찍 회수할 수 있음 (즉시 해제가 보장되는 것은 아님)
  3. 예측 가능: 변수의 생명주기가 명확
  4. 클로저 안전: 각 반복마다 새로운 변수

위 예제의 3 3 3이 나오는 이유를 정확히 짚어 보면 이렇습니다. setTimeout의 콜백은 루프가 모두 끝난 뒤에 실행됩니다. var j는 함수 전체에 변수가 하나뿐이므로, 세 콜백이 모두 같은 j를 참조하고 그 시점의 값은 루프 종료 조건을 만족한 3입니다. 반면 for (let i ...)는 명세상 반복마다 새로운 i 바인딩을 만들고 이전 값을 복사해 넘기므로, 각 콜백이 서로 다른 i를 붙잡습니다. ES6 이전에는 이 문제를 피하려고 즉시 실행 함수(IIFE)로 값을 감싸야 했는데, let 하나로 그 관용구가 필요 없어졌습니다. 이벤트 리스너를 루프로 등록할 때 모든 버튼이 마지막 인덱스로 동작하는 버그는 지금도 레거시 코드에서 자주 보이는 형태입니다.

함수 스코프

// 함수 정의 및 구현
function test() {
    let x = 10;  // 함수 스코프
    var y = 20;  // 함수 스코프
    
    if (true) {
        let x = 30;  // 블록 스코프 (새 변수)
        var y = 40;  // 함수 스코프 (같은 변수)
        console.log(x, y);  // 30 40
    }
    
    console.log(x, y);  // 10 40
}
test();
// console.log(x);  // ReferenceError

전역 스코프

// 전역 변수
let globalVar = "전역";
function test() {
    console.log(globalVar);  // 전역 접근 가능
}
test();  // 전역
// 브라우저: window 객체
// var global = "test";
// console.log(window.global);  // test
// Node.js: global 객체
// global.myVar = "test";

여기에는 var와 let의 또 다른 차이가 숨어 있습니다. 브라우저의 일반 스크립트 최상위에서 var로 선언하면 window의 프로퍼티가 되지만, let/const로 선언한 전역 변수는 window에 붙지 않습니다(window.globalVar는 undefined). 그래서 var name = "..."처럼 흔한 이름을 전역에 선언하면 window.name 같은 브라우저 내장 프로퍼티와 부딪칠 수 있습니다. 또 ES 모듈(<script type="module">, Node의 .mjs)에서는 최상위 선언이 모듈 스코프에 머물기 때문에 var라도 전역 객체에 붙지 않습니다. 모든 환경에서 전역 객체를 가리키려면 globalThis를 쓰면 됩니다.


원시 타입과 참조 타입

원시 타입 (Primitive Types)

JavaScript에는 7가지 원시 타입이 있습니다.

1. Number (숫자)

let integer = 42;
let float = 3.14;
let negative = -10;
// 특수 값
let inf = Infinity;
let negInf = -Infinity;
let notANumber = NaN;
console.log(10 / 0);  // Infinity
console.log("abc" / 2);  // NaN
// NaN 체크
console.log(isNaN(NaN));  // true
console.log(isNaN(10));   // false
// 안전한 정수 범위
console.log(Number.MAX_SAFE_INTEGER);  // 9007199254740991
console.log(Number.MIN_SAFE_INTEGER);  // -9007199254740991

JavaScript의 Number는 정수와 실수를 구분하지 않고 모두 IEEE 754 64비트 부동소수점으로 저장합니다. 그래서 0.1 + 0.2 === 0.3이 false가 되고, 2^53을 넘는 정수는 정확히 표현하지 못합니다. 서버가 64비트 정수 ID(예: 트위터/X 스타일의 스노플레이크 ID)를 JSON 숫자로 내려주면 JSON.parse 과정에서 끝자리가 바뀌는 문제가 실제로 자주 발생하므로, 큰 ID는 문자열로 주고받는 것이 관례입니다. 또 전역 isNaN()은 인자를 먼저 숫자로 변환하기 때문에 isNaN("abc")도 true를 돌려줍니다. 값이 “정말 NaN인지”만 확인하려면 변환을 하지 않는 Number.isNaN()을 쓰는 편이 정확합니다.

2. String (문자열)

let str1 = "큰따옴표";
let str2 = '작은따옴표';
let str3 = `백틱 (템플릿 리터럴)`;
// 템플릿 리터럴
let name = "홍길동";
let age = 25;
let message = `안녕하세요, ${name}님! ${age}살이시군요.`;
console.log(message);  // 안녕하세요, 홍길동님! 25살이시군요.
// 여러 줄 문자열
let multiline = `첫 번째 줄
두 번째 줄
세 번째 줄`;
// 이스케이프 문자
let escaped = "He said \"Hello\"";
console.log(escaped);  // He said "Hello"

3. Boolean (불리언)

let isTrue = true;
let isFalse = false;
// 비교 연산
console.log(10 > 5);   // true
console.log(10 === 5);  // false

4. undefined

let x;
console.log(x);  // undefined
console.log(typeof x);  // undefined
// 함수 반환값 없을 때
function test() {
    // return 없음
}
console.log(test());  // undefined

5. null

let empty = null;
console.log(empty);  // null
console.log(typeof empty);  // object (JavaScript 버그!)
// undefined vs null
let x;  // undefined (선언만)
let y = null;  // null (명시적 빈 값)

6. Symbol (ES6+)

let sym1 = Symbol("description");
let sym2 = Symbol("description");
console.log(sym1 === sym2);  // false (항상 고유)
// 객체 프로퍼티 키로 사용
let obj = {
    [sym1]: "value1"
};
console.log(obj[sym1]);  // value1

7. BigInt (ES2020+)

// Number의 안전 범위를 넘는 큰 정수
let bigNum = 9007199254740991n;  // n 접미사
let bigNum2 = BigInt("9007199254740991");
console.log(bigNum + 1n);  // 9007199254740992n
// Number와 혼용 불가
// console.log(bigNum + 1);  // TypeError
console.log(bigNum + BigInt(1));  // OK

참조 타입 (Reference Types)

Object (객체)

let person = {
    name: "홍길동",
    age: 25,
    greet: function() {
        console.log(`안녕하세요, ${this.name}입니다.`);
    }
};
console.log(person.name);  // 홍길동
person.greet();  // 안녕하세요, 홍길동입니다.

Array (배열)

let numbers = [1, 2, 3, 4, 5];
let mixed = [1, "hello", true, null, { name: "홍길동" }];
console.log(numbers[0]);  // 1
console.log(numbers.length);  // 5

Function (함수)

function add(a, b) {
    return a + b;
}
// 함수도 객체
console.log(typeof add);  // function

값 복사와 참조 공유

원시 타입과 참조 타입을 나누는 가장 실용적인 이유는 대입할 때 무엇이 복사되는가입니다.

let a = 10;
let b = a;   // 값 10이 복사됨
b = 20;
console.log(a);  // 10 (a는 영향 없음)

const p1 = { name: "홍길동" };
const p2 = p1;   // 객체를 가리키는 참조가 복사됨
p2.name = "김철수";
console.log(p1.name);  // 김철수 (같은 객체를 공유)

// 얕은 복사로 분리
const p3 = { ...p1 };
p3.name = "이영희";
console.log(p1.name);  // 김철수 (p3는 별도 객체)

p2 = p1은 객체를 복제하지 않고 같은 객체를 가리키는 화살표를 하나 더 만드는 것입니다. 그래서 함수에 객체를 넘기고 함수 안에서 프로퍼티를 수정하면 호출한 쪽의 객체도 바뀝니다. React 상태를 직접 수정(state.items.push(x))했더니 화면이 갱신되지 않는 문제도 같은 원리입니다. 참조가 그대로라서 React가 “바뀐 게 없다”고 판단하기 때문입니다. 스프레드({...obj}, [...arr])는 한 단계만 복사하는 얕은 복사라서 중첩 객체는 여전히 공유된다는 점도 기억해야 합니다. 중첩까지 복사해야 하면 structuredClone(obj)을 쓸 수 있습니다(함수나 DOM 노드는 복사하지 못합니다).

비교도 같은 규칙을 따릅니다. { a: 1 } === { a: 1 }은 내용이 같아도 서로 다른 객체이므로 false이고, 원시 타입은 값이 같으면 true입니다.


typeof와 타입 변환

typeof 연산자

console.log(typeof 42);         // number
console.log(typeof "hello");    // string
console.log(typeof true);       // boolean
console.log(typeof undefined);  // undefined
console.log(typeof null);       // object (버그!)
console.log(typeof {});         // object
console.log(typeof []);         // object (배열도 객체)
console.log(typeof function(){}); // function
// 배열 체크
console.log(Array.isArray([]));  // true
console.log(Array.isArray({}));  // false

타입 변환

문자열 변환

// 명시적 변환
let num = 42;
let str = String(num);
console.log(str, typeof str);  // 42 string
// toString()
let str2 = num.toString();
console.log(str2);  // 42
// 암묵적 변환
let result = 42 + " apples";
console.log(result);  // 42 apples (문자열)

숫자 변환

// 명시적 변환
let str = "42";
let num = Number(str);
console.log(num, typeof num);  // 42 number
// parseInt, parseFloat
console.log(parseInt("42px"));     // 42
console.log(parseFloat("3.14abc")); // 3.14
console.log(parseInt("abc"));      // NaN
// 암묵적 변환
console.log("42" - 10);  // 32 (숫자)
console.log("42" * 2);   // 84 (숫자)
console.log(+"42");      // 42 (단항 + 연산자)

Number()와 parseInt()는 실패 방식이 다릅니다. Number("42px")는 문자열 전체가 숫자여야 하므로 NaN이고, parseInt("42px")는 앞에서부터 숫자로 읽을 수 있는 부분까지만 읽어 42를 돌려줍니다. 사용자 입력을 검증할 때는 Number()가 더 엄격해서 안전하고, CSS 값처럼 단위가 붙은 문자열에서 숫자만 뽑을 때는 parseInt가 편합니다. 다만 Number("")와 Number(" ")는 NaN이 아니라 0이 된다는 함정이 있어, 빈 입력칸을 0으로 처리해 버리는 버그가 흔합니다. parseInt는 두 번째 인자로 진법을 받으므로 parseInt(value, 10)처럼 명시하는 습관을 들이는 것이 좋습니다.

불리언 변환

// 명시적 변환
console.log(Boolean(1));      // true
console.log(Boolean(0));      // false
console.log(Boolean("hello")); // true
console.log(Boolean(""));     // false
// Falsy 값 (false로 변환)
console.log(Boolean(false));     // false
console.log(Boolean(0));         // false
console.log(Boolean(""));        // false
console.log(Boolean(null));      // false
console.log(Boolean(undefined)); // false
console.log(Boolean(NaN));       // false
// Truthy 값 (true로 변환)
console.log(Boolean(1));         // true
console.log(Boolean("hello"));   // true
console.log(Boolean([]));        // true
console.log(Boolean({}));        // true
// 암묵적 변환
if ("hello") {
    console.log("Truthy!");  // 실행됨
}

호이스팅과 TDZ

호이스팅이란?

호이스팅(Hoisting)은 변수와 함수 선언이 스코프 안에서 맨 위로 끌어올려지는 것처럼 동작하는 현상입니다. 엔진이 실행 전에 이름표를 붙이는 단계를 거친다고 보시면 됩니다. 비유(엘리베이터): 층마다 호실이 정해지기 전에 엘리베이터가 먼저 어느 층에 어떤 호실이 있는지 인식하는 것과 비슷합니다. var는 문이 열리기 전에도 호실 번호는 잡혀 있지만 안은 비어(undefined) 있으며, let/const는 문이 열리기 전에는 임시 사각지대(TDZ) 때문에 들어갈 수 없습니다.

var 호이스팅

console.log(x);  // undefined (에러 아님!)
var x = 10;
console.log(x);  // 10
// 실제 동작 (내부적으로)
var x;  // 선언이 최상단으로
console.log(x);  // undefined
x = 10;  // 할당은 원래 위치
console.log(x);  // 10

let/const 호이스팅 (TDZ)

// TDZ (Temporal Dead Zone): 선언 전 접근 불가
// console.log(x);  // ReferenceError
let x = 10;
console.log(x);  // 10
// const도 동일
// console.log(y);  // ReferenceError
const y = 20;

let/const도 사실은 호이스팅됩니다. 호이스팅되지 않는다면 아래 코드의 안쪽 console.log(x)는 바깥 x(1)를 출력해야 하지만, 실제로는 에러가 납니다.

let x = 1;
{
    // console.log(x);  // ReferenceError: Cannot access 'x' before initialization
    let x = 2;
}

안쪽 블록의 let x가 블록 시작 시점에 이미 등록되어 바깥 x를 가리고 있지만, 초기화 전이라 접근이 금지되는 구간이 TDZ입니다. 에러 메시지도 구분해서 읽으면 도움이 됩니다. x is not defined는 그런 이름이 스코프 어디에도 없다는 뜻이고, Cannot access 'x' before initialization은 선언은 있지만 아직 그 줄에 도달하지 않았다는 뜻입니다. 저는 후자를 보면 대부분 모듈 간 순환 import나, 함수 안에서 같은 이름의 변수를 뒤늦게 선언한 경우부터 의심합니다.

함수 호이스팅

// 함수 선언문: 호이스팅됨
greet();  // 안녕하세요! (선언 전 호출 가능)
function greet() {
    console.log("안녕하세요!");
}
// 함수 표현식: 호이스팅 안 됨
// sayHello();  // ReferenceError
const sayHello = function() {
    console.log("Hello!");
};
sayHello();  // Hello!

함수 표현식이 실패하는 방식은 변수 선언 키워드에 따라 다릅니다. const/let에 담았다면 TDZ 때문에 ReferenceError가 나고, var sayHello = function(){}로 담았다면 변수는 undefined로 호이스팅되므로 TypeError: sayHello is not a function이 납니다. 함수 선언문은 함수 본문까지 통째로 호이스팅되기 때문에 파일 아래쪽에 헬퍼 함수를 모아 두고 위에서 호출하는 스타일이 가능하지만, 선언 순서에 의존하지 않는 코드를 원하는 팀은 const + 화살표 함수로 통일하기도 합니다.


변수 스왑부터 불변 객체까지, 짧은 예제

예제 1: 변수 스왑

// 방법 1: 임시 변수
let a = 10;
let b = 20;
let temp = a;
a = b;
b = temp;
console.log(a, b);  // 20 10
// 방법 2: 구조 분해 (ES6+)
let x = 10;
let y = 20;
[x, y] = [y, x];
console.log(x, y);  // 20 10

예제 2: 타입 체크 함수

function getType(value) {
    if (value === null) return "null";
    if (Array.isArray(value)) return "array";
    return typeof value;
}
console.log(getType(42));        // number
console.log(getType("hello"));   // string
console.log(getType(true));      // boolean
console.log(getType(null));      // null
console.log(getType(undefined)); // undefined
console.log(getType([]));        // array
console.log(getType({}));        // object
console.log(getType(() => {}));  // function

예제 3: 안전한 숫자 변환

function safeParseInt(value, defaultValue = 0) {
    const parsed = parseInt(value);
    return isNaN(parsed) ? defaultValue : parsed;
}
console.log(safeParseInt("42"));      // 42
console.log(safeParseInt("abc"));     // 0
console.log(safeParseInt("abc", -1)); // -1
console.log(safeParseInt("3.14"));    // 3

예제 4: 객체 불변성 유지

// const는 재할당만 막음
const config = {
    apiUrl: "https://api.example.com",
    timeout: 5000
};
config.timeout = 10000;  // OK (내부 변경)
console.log(config);  // { apiUrl: '...', timeout: 10000 }
// 완전한 불변 객체: Object.freeze()
const frozenConfig = Object.freeze({
    apiUrl: "https://api.example.com",
    timeout: 5000
});
frozenConfig.timeout = 10000;  // 무시됨 (strict mode에서는 에러)
console.log(frozenConfig.timeout);  // 5000 (변경 안 됨)
// 중첩 객체는 freeze 안 됨
const user = Object.freeze({
    name: "홍길동",
    address: { city: "서울" }
});
// user.name = "김철수";  // 무시됨
user.address.city = "부산";  // OK (중첩 객체는 변경 가능)
console.log(user.address.city);  // 부산
// 깊은 freeze (재귀)
function deepFreeze(obj) {
    Object.freeze(obj);
    Object.keys(obj).forEach(key => {
        if (typeof obj[key] === 'object' && obj[key] !== null) {
            deepFreeze(obj[key]);
        }
    });
    return obj;
}

var·const·==가 일으키는 버그

실수 1: var 사용

// ❌ 잘못된 방법
for (var i = 0; i < 3; i++) {
    setTimeout(() => console.log(i), 100);
}
// 3 3 3 (의도: 0 1 2)
// ✅ 올바른 방법
for (let i = 0; i < 3; i++) {
    setTimeout(() => console.log(i), 100);
}
// 0 1 2

실수 2: const 오해

// ❌ 잘못된 이해
const arr = [1, 2, 3];
// arr = [4, 5, 6];  // TypeError (재할당 불가)
// ✅ 올바른 이해
const arr = [1, 2, 3];
arr.push(4);  // OK (내부 변경)
arr[0] = 100;  // OK
console.log(arr);  // [100, 2, 3, 4]

실수 3: == vs ===

// ❌ == (타입 변환 후 비교)
console.log(5 == "5");   // true (문제!)
console.log(0 == false);  // true
console.log("" == false); // true
// ✅ === (타입까지 비교)
console.log(5 === "5");   // false
console.log(0 === false);  // false
console.log("" === false); // false

==가 위험한 이유는 변환 규칙이 직관적이지 않고 추이성도 깨지기 때문입니다. "" == 0도 true, "0" == 0도 true인데 "" == "0"은 false입니다. 유일하게 널리 허용되는 예외는 value == null로, 이 식은 null과 undefined에만 true가 되어 “값이 없음”을 한 번에 검사하는 관용구로 쓰입니다. 그 외에는 ===를 쓰고, 필요한 변환은 Number()처럼 명시적으로 하는 것이 코드 리뷰에서도 의도를 읽기 쉽습니다.

실수 4: 타입 변환 실수

// ❌ 의도하지 않은 문자열 연결
console.log("10" + 20);  // 1020 (문자열)
// ✅ 명시적 변환
console.log(Number("10") + 20);  // 30
console.log(parseInt("10") + 20);  // 30

이 실수는 거의 항상 DOM 입력값에서 시작됩니다. <input type="number">라도 input.value는 항상 문자열이므로, 수량 입력 두 개를 더하는 계산기를 만들면 1 + 2가 "12"로 나옵니다. 저도 처음 장바구니 합계를 만들 때 이 문제로 금액이 이어 붙여지는 화면을 본 적이 있습니다. 숫자 입력은 input.valueAsNumber를 쓰거나 읽는 즉시 Number()로 변환해 두면, 이후 코드에서 +가 덧셈인지 연결인지 고민할 필요가 없어집니다. -, *, /는 항상 숫자로 변환하지만 +만 한쪽이 문자열이면 문자열 연결로 동작한다는 비대칭이 원인입니다.

실수 5: 전역 변수 오염

// ❌ 전역 변수
count = 0;  // var/let/const 없이 선언 (전역 변수!)
function increment() {
    count++;
}
// ✅ 명시적 선언
let count = 0;
function increment() {
    count++;
}
// 또는 strict mode 사용 (파일이나 함수의 첫 문장이어야 적용됨)
// "use strict";
// count = 0;  // ReferenceError: count is not defined

"use strict"는 스크립트나 함수 본문의 맨 첫 문장에 있을 때만 지시어로 인식되고, 위 예시처럼 코드 중간에 두면 그냥 버려지는 문자열 표현식이 됩니다. ES 모듈과 class 본문은 자동으로 strict mode이므로, 번들러나 type="module"로 작성하는 요즘 코드에서는 따로 쓰지 않아도 선언 없는 대입이 ReferenceError로 막힙니다.


const 우선, 이름 짓기, strict mode

팁 1: const 우선, let 필요시

// ✅ 기본은 const
const API_URL = "https://api.example.com";
const MAX_RETRY = 3;
// ✅ 재할당 필요시 let
let retryCount = 0;
let result = null;

팁 2: 의미 있는 변수명

// ❌ 나쁜 이름
let x = 25;
let data = [1, 2, 3];
let temp = "홍길동";
// ✅ 좋은 이름
let userAge = 25;
let scores = [1, 2, 3];
let userName = "홍길동";
// 상수는 대문자 + 언더스코어
const MAX_USERS = 100;
const API_BASE_URL = "https://api.example.com";

팁 3: strict mode 사용

"use strict";
// 선언 없는 변수 사용 불가
// x = 10;  // ReferenceError
// 읽기 전용 속성 수정 불가
// const obj = {};
// Object.defineProperty(obj, "x", { value: 42, writable: false });
// obj.x = 100;  // TypeError
// 중복 매개변수 불가
// function test(a, a) {}  // SyntaxError

연습 문제: 스왑, 숫자 변환, Falsy 필터

문제 1: 변수 스왑 (3개)

3개의 변수를 회전시키세요. (a → b, b → c, c → a)

let a = 1;
let b = 2;
let c = 3;
// 풀이
[a, b, c] = [c, a, b];
console.log(a, b, c);  // 3 1 2

문제 2: 타입 변환 체크

주어진 값을 숫자로 변환하며, 실패하면 기본값을 반환하세요.

function toNumber(value, defaultValue = 0) {
    const num = Number(value);
    return isNaN(num) ? defaultValue : num;
}
// 테스트
console.log(toNumber("42"));       // 42
console.log(toNumber("abc"));      // 0
console.log(toNumber("abc", -1));  // -1
console.log(toNumber(null));       // 0
console.log(toNumber(undefined));  // 0

문제 3: Truthy/Falsy 필터

배열에서 Falsy 값을 제거하세요.

function removeFalsy(arr) {
    return arr.filter(item => item);  // Truthy만 통과
}
// 또는
function removeFalsy(arr) {
    return arr.filter(Boolean);
}
// 테스트
let arr = [0, 1, false, 2, "", 3, null, undefined, NaN, 4];
console.log(removeFalsy(arr));  // [1, 2, 3, 4]

변수·스코프·타입 요약

  1. 변수 선언:
    • const: 재할당 불가 (기본 선택)
    • let: 재할당 가능
    • var: 사용 금지 (구식)
  2. 스코프:
    • 블록 스코프: let, const
    • 함수 스코프: var
  3. 데이터 타입:
    • 원시 타입: Number, String, Boolean, undefined, null, Symbol, BigInt
    • 참조 타입: Object, Array, Function
  4. 타입 변환:
    • Number(), String(), Boolean()
    • parseInt(), parseFloat()
    • Truthy/Falsy 값
  5. 호이스팅:
    • var: undefined로 초기화
    • let/const: TDZ (접근 불가)

변수 선언 습관

  1. ✅ const 우선, 재할당 필요시 let
  2. ✅ === 사용 (== 금지)
  3. ✅ "use strict" 사용
  4. ✅ 의미 있는 변수명
  5. ✅ 전역 변수 최소화

다음 단계


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. const로 선언한 배열에 push가 되는 이유는 무엇인가요?

A. const가 막는 것은 변수에 다른 값을 다시 대입하는 것이지, 변수가 가리키는 객체나 배열의 내용을 바꾸는 것이 아닙니다. 그래서 arr.push(4)는 되지만 arr = [4, 5, 6]은 TypeError가 납니다. 내용까지 바꾸지 못하게 하려면 Object.freeze()를 쓰거나, 스프레드 문법으로 새 배열을 만들어 불변 방식으로 다루면 됩니다.