자바스크립트 변수와 자료형 - var let const 차이와 typeof 함정
이 단원에서 배우는 것
1단원에서 코드가 브라우저와 Node.js 두 곳에서 각각 어떻게 실행되는지 확인했다. 이번 단원부터 6단원까지는 어느 환경에서든 똑같이 동작하는 언어 자체를 다룬다. 실습은 node 파일.js가 가장 편하다. 첫 주제는 값을 담는 그릇, 즉 변수와 그 안에 들어가는 값의 종류다. 소재는 계속해서 스터디 참가자 명단이다.
var·let·const가 어떻게 다른지 설명하고, 실무에서 왜const를 기본값으로 쓰는지 근거를 댄다.- 원시값과 객체를 구분하고, 값을 다른 변수에 넣었을 때 무슨 일이 벌어지는지 예측한다.
typeof의 결과를 믿어도 되는 경우와 믿으면 안 되는 경우를 구분한다.
ES2022 기준이다. let·const는 ES2015에서 들어왔고 지금은 모든 최신 브라우저와 Node 20 이상에서 문제없이 쓸 수 있다.
왜 필요한가
참가자 한 명의 정보를 화면에 그리려면 이름·이메일·출석 횟수를 어딘가 담아 두고 여러 곳에서 꺼내 써야 한다. 그 그릇이 변수다. 여기까지는 어느 언어나 같다. 문제는 자바스크립트에 그릇을 만드는 키워드가 세 개라는 점이다.
세 개인 이유는 순전히 역사 때문이다. 1995년에 만들어진 var는 20년 동안 유일한 선언 방법이었는데, 다음 두 가지 성질이 사고를 너무 많이 냈다.
var fee = 30000;
var fee = "삼만원"; // 오타로 같은 이름을 또 선언해도 조용히 덮어쓴다
console.log(fee); // 삼만원
{
var total = 30000; // 블록 안에서 선언했는데
}
console.log(total); // 30000 — 블록 밖에서도 살아 있다
수백 줄짜리 파일에서 이름이 겹치면 어디서 값이 바뀌었는지 추적할 방법이 없다. ES2015에서 이 두 문제를 고친 let과 const가 들어왔고, 이후로 var는 새 코드에 쓸 이유가 없다. 그런데도 배워야 하는 이유는 남이 짠 옛날 코드를 읽어야 하기 때문이다.
문법과 예제
세 키워드의 차이
| 구분 | var | let | const |
|---|---|---|---|
| 값 재대입 | 가능 | 가능 | 불가 |
| 같은 이름 재선언 | 가능(위험) | 불가(오류) | 불가(오류) |
| 유효 범위 | 함수 전체 | 블록 { } | 블록 { } |
| 선언 전 사용 | undefined | 오류 | 오류 |
const name = "김지훈";
let sessions = 5;
sessions = 6; // let 이므로 문제없다
// name = "박서연"; // TypeError: Assignment to constant variable.
{
const name = "이도현"; // 다른 블록이므로 별개의 변수다
console.log(name); // 이도현
}
console.log(name); // 김지훈
실무 규칙은 단순하다. 일단 전부 const로 쓰고, 값을 다시 대입해야 하는 순간에만 let으로 바꾼다. 이렇게 하면 변수 선언만 보고도 "이 값은 끝까지 안 바뀐다"를 알 수 있어서, 코드를 읽을 때 머리에 담아 둘 것이 줄어든다.
선언 전에 쓰면 어떻게 되는가
console.log(oldFee); // undefined — 오류가 아니다
var oldFee = 30000;
console.log(newFee); // ReferenceError: Cannot access 'newFee' before initialization
let newFee = 30000;
var는 선언이 위로 끌어올려지고 값만 나중에 채워져서, 잘못 쓴 코드가 오류 없이 undefined로 굴러간다. 오류가 안 나는 쪽이 더 나쁘다. 문제가 열 줄쯤 아래에서 엉뚱한 모습으로 터지기 때문이다. let·const는 선언 줄에 도달하기 전에 접근하면 즉시 오류를 낸다. 이 구간을 TDZ(일시적 사각지대)라고 부르는데, 용어보다 "선언 전엔 못 쓴다"는 결과만 기억하면 된다.
값의 종류 — 원시값 7가지와 객체
자바스크립트의 값은 원시값과 객체 둘로 나뉜다. 원시값은 더 쪼갤 수 없는 값이다.
| 종류 | 예 | 메모 |
|---|---|---|
| string | "김지훈" | 작은따옴표·큰따옴표 차이 없음 |
| number | 5, 30000, 1.5 | 정수·소수 구분이 없다 |
| boolean | true, false | 회비 납부 여부 등 |
| null | null | "값이 없음"을 개발자가 명시 |
| undefined | undefined | "아직 값이 안 들어감"을 엔진이 넣음 |
| bigint | 9007199254740993n | 아주 큰 정수. 실무에선 드물다 |
| symbol | Symbol("id") | 라이브러리 내부용. 초급에선 안 쓴다 |
이 일곱 가지가 아닌 값은 전부 객체다. 배열도 객체고 함수도 객체다.
const member = {
name: "김지훈",
email: "jihun@example.com",
sessions: 5,
paid: true,
memo: null // 메모를 안 남겼다는 뜻을 명시
};
console.log(member.name); // 김지훈
console.log(member.phone); // undefined — 없는 키를 꺼내면 undefined
console.log(member["email"]); // jihun@example.com
null과 undefined의 구분이 실제로 쓸모 있는 지점이 여기다. memo: null은 "메모 칸이 있고 비어 있다", member.phone의 undefined는 "그런 칸이 아예 없다"를 뜻한다. 서버 응답을 다룰 때 이 둘을 섞어 쓰면 나중에 조건문이 지저분해진다. 내가 값을 비울 때는 null, 확인하는 쪽은 둘 다 대비하는 정도로 잡아 두면 된다.
원시값과 객체의 결정적 차이 — 복사
let a = 5;
let b = a;
b = 6;
console.log(a, b); // 5 6 — 서로 남남이다
const m1 = { name: "김지훈", sessions: 5 };
const m2 = m1;
m2.sessions = 6;
console.log(m1.sessions); // 6 — 같은 객체를 함께 보고 있다
원시값을 대입하면 값 자체가 복사되지만, 객체를 대입하면 객체가 있는 위치만 복사된다. m1과 m2는 이름만 둘이고 실체는 하나다. 이 성질은 6단원의 얕은 복사·깊은 복사로 다시 이어진다. 지금은 "객체를 다른 변수에 넣어도 새 객체가 생기지 않는다"만 확실히 해 둔다.
문자열 조립은 템플릿 리터럴로
const name = "김지훈";
const sessions = 5;
console.log("이름: " + name + " / 출석 " + sessions + "회"); // 옛날 방식
console.log(`이름: ${name} / 출석 ${sessions}회`); // 템플릿 리터럴
백틱(`)으로 감싸면 ${ } 안에 값을 그대로 넣을 수 있고 줄바꿈도 그대로 유지된다. 7단원에서 화면에 붙일 문자열을 만들 때 계속 쓴다.
실무에서 자주 틀리는 것
1. const를 "값이 안 변한다"로 이해한다
const가 막는 것은 그 변수에 다른 값을 다시 대입하는 것뿐이다. 객체 내부는 얼마든지 바뀐다.
const member = { name: "김지훈", sessions: 5 };
member.sessions = 6; // 된다
member.paid = true; // 키 추가도 된다
console.log(member); // { name: '김지훈', sessions: 6, paid: true }
// member = { name: "박서연" }; // 이것만 TypeError
그래서 배열을 const로 선언해도 push로 요소를 넣는 데는 아무 문제가 없다. 초보가 "배열은 바뀌니까 let으로 해야지"라고 판단하는 경우가 많은데 그럴 필요 없다. 내용이 정말 바뀌면 안 된다면 Object.freeze(member)를 써야 하지만, 실무에서 이걸 쓸 일은 생각보다 드물다.
2. typeof 결과를 그대로 믿는다
console.log(typeof "김지훈"); // string
console.log(typeof 5); // number
console.log(typeof true); // boolean
console.log(typeof undefined); // undefined
console.log(typeof null); // object ← 값은 원시값인데 object 가 나온다
console.log(typeof [1, 2, 3]); // object ← 배열인지 알 수 없다
console.log(typeof function () {}); // function
console.log(typeof NaN); // number ← 숫자가 아닌데 number 다
typeof null이 "object"인 것은 초창기 구현의 버그인데, 고치면 기존 웹이 깨지기 때문에 30년째 그대로 두고 있다. 실무에서 필요한 판별은 아래처럼 한다.
const value = [1, 2, 3];
console.log(Array.isArray(value)); // true — 배열 판별은 이것만 쓴다
console.log(value === null); // false — null 판별은 === 로
console.log(Number.isNaN(Number("abc"))); // true — NaN 판별은 Number.isNaN
console.log(typeof value === "object" && value !== null && !Array.isArray(value)); // 순수 객체인지
참고로 typeof는 선언되지 않은 이름에 써도 오류가 나지 않는 유일한 연산자다. typeof undeclared는 "undefined"를 돌려준다. 옛날 코드에서 이 성질을 쓴 방어 코드를 종종 보게 된다.
3. 소수 계산 결과를 그대로 비교한다
console.log(0.1 + 0.2); // 0.30000000000000004
console.log(0.1 + 0.2 === 0.3); // false
자바스크립트의 number는 전부 부동소수점이라 십진 소수를 정확히 담지 못한다. 자바스크립트만의 문제는 아니지만, 정수·소수 구분이 없어서 더 자주 만난다. 회비·금액은 소수로 다루지 말고 원 단위 정수로 다룬다. 표시할 때만 나눠서 보여주면 된다.
const feeWon = 30000;
console.log(new Intl.NumberFormat("ko-KR").format(feeWon)); // 30,000
4. 여전히 var를 쓴다
인터넷의 오래된 예제를 그대로 복사하면 var가 딸려 온다. 동작은 하지만 위에서 본 재선언·범위 문제를 그대로 안고 간다. 새로 쓰는 코드에서 var를 쓸 이유는 없다. 옛 코드를 고칠 때는 var를 기계적으로 let으로 바꾸지 말고, 그 변수가 정말 재대입되는지 확인해서 대부분 const로 바꾼다.
스스로 확인하기
- 다음 코드의 출력은 무엇이며 왜 그런가?
const member = { name: "김지훈", sessions: 5 }; const copy = member; copy.sessions = 10; console.log(member.sessions); typeof만으로는 배열과null과 일반 객체를 구분할 수 없다. 세 값을 각각 정확히 판별하는 조건을 하나씩 쓰라.- 다음 코드 두 줄 중 하나는 오류가 나고 하나는 정상 동작한다. 어느 쪽이며 이유는?
const list = ["김지훈"]; list.push("박서연"); list = ["이도현"];
정답
10. 객체를 대입하면 객체 자체가 복사되는 것이 아니라 같은 객체를 가리키는 이름이 하나 더 생긴다.copy를 통해 바꿔도member가 보는 객체가 같으므로 함께 바뀐다.- 배열은
Array.isArray(value),null은value === null, 일반 객체는typeof value === "object" && value !== null && !Array.isArray(value). - 세 번째 줄이
TypeError: Assignment to constant variable.를 낸다.push는 배열이라는 객체의 내용을 바꿀 뿐이라const와 무관하지만,list라는 변수에 새 배열을 다시 대입하는 것은const가 금지한다.