[웹 프론트 01] JavaScript - 변수와 자료형, 안다고 착각했다

변수 선언 정도야 부트캠프 첫 주에 배우는 내용이라 다들 안다고 생각한다. 나도 그랬다. var, let, const 표도 외웠고 필기시험도 만점 받았다. 그런데 입사하고 석 달쯤 됐을 때 상품 목록 페이지에서 할인가가 가끔 NaN으로 찍히는 버그를 잡다가, 원인이 결국 변수 선언 방식 하나였다는 걸 알고는 진짜 허탈했다. 기초를 안다고 넘기면 꼭 그 기초에서 발목을 잡힌다. 이 편은 그 착각을 하나씩 깨는 얘기다.

사고 친 코드는 이런 모양이었다. VIP 회원이면 할인율을 적용하고, 아니면 원가 그대로 반환하는 단순한 함수였다.


function getFinalPrice(user, price) {
  if (user.isVip) {
    var discount = price * 0.3;
  }
  return price - discount;
}
console.log(getFinalPrice({ isVip: false }, 10000)); // NaN, 에러 한 줄 없이 조용히 깨진다


var는 함수 스코프라 if문 블록 따위는 무시하고 함수 전체에 선언을 끌어올린다. VIP가 아닌 사용자가 들어오면 discount는 선언만 되고 값은 할당되지 않은 undefined 상태로 남아서, price - undefined는 에러가 아니라 NaN이 된다. 콘솔에 빨간 줄 하나 안 뜨고 그냥 숫자가 이상하게 나온다. 그날 나는 네트워크 탭부터 서버 응답값까지 다 의심했는데 정작 문제는 로컬 함수 안에 있었다. 그 뒤로 나는 기본값을 무조건 const로 잡고, 재할당이 필요할 때만 let을 쓴다. let으로 고치면 실행 시점에 바로 티가 난다.


function getFinalPriceFixed(user, price) {
  let discount = 0;
  if (user.isVip) {
    discount = price * 0.3;
  }
  return price - discount;
}
console.log(getFinalPriceFixed({ isVip: false }, 10000)); // 10000, 이제야 제대로 나온다


블록 스코프인 letconst{} 밖으로 나가면 그 변수가 아예 존재하지 않는다. 최소한 스코프 선에서 값이 새는 곳이 걸러지는 셈이다. 이거 하나만 지켜도 신입 때 자주 나는 버그의 절반은 사라진다고 본다.

객체와 배열은 완전히 다른 세계다. 장바구니 요약 화면을 만드는데, 원본 배열은 그대로 두고 정렬만 따로 보여주려고 이렇게 짰다.


const cartItems = [{ name: "키보드", qty: 1 }, { name: "마우스", qty: 3 }];
const backup = cartItems; // 복사한다고 생각했다
backup.sort((x, y) => x.qty - y.qty);
console.log(cartItems); // 원본도 같이 정렬돼 있다


backup은 새 배열이 아니라 cartItems와 똑같은 메모리 주소를 가리키는 또 하나의 이름표였을 뿐이다. 주문 내역 화면에서 순서가 뒤바뀌어 나온다고 QA한테 리포트 받고 나서야 이 사실을 몸으로 배웠다. 진짜 복사가 필요하면 이렇게 펼쳐야 한다.


const realBackup = [...cartItems]; // 얕은 복사, 이제 서로 다른 배열


이 참조 문제는 함수에 객체를 인자로 넘길 때도 똑같이 일어난다. 할인 적용 함수 안에서 매개변수의 속성을 직접 바꿨더니, 같은 상품 객체를 참조하던 다른 화면의 가격까지 같이 바뀌어버린 적이 있다. 처음엔 캐시 문제인 줄 알고 엉뚱한 곳을 한참 뒤졌는데, 알고 보니 두 화면이 애초에 같은 객체를 나눠 쓰고 있었다. 원본을 지키려면 함수 안에서 새 객체를 만들어 반환해야 한다.


function applyDiscountSafe(product) {
  return { ...product, price: product.price * 0.9 }; // 새 객체를 반환
}


const라고 다 막아주는 것도 아니다. const는 재할당만 막을 뿐 내용물 변경까지 막지는 않는다. 이름 때문에 다들 상수라고 헷갈린다.


const user = { name: "김철수" };
user.name = "김영희"; // OK, 객체 내부 속성은 바뀐다
// user = {}; // TypeError, 변수 자체를 바꿔치기하는 것만 막힌다


상자 자체를 다른 상자로 바꿔치기하는 걸 막을 뿐, 상자 안 물건은 자유롭게 바뀐다. 진짜로 내부까지 못 바꾸게 얼리려면 Object.freeze를 따로 써야 하는데, 이것도 얕게만 얼려서 중첩 객체까지는 못 막는다. 화면 테마 설정 객체를 얼려놨다고 안심했다가, 색상 값들을 담은 하위 객체는 여전히 자유롭게 바뀌어서 또 한 번 데인 적이 있다.

typeof는 생각보다 구멍이 많다. 데이터가 배열인지 검사하겠다고 typeof를 쓴 적이 있는데, 서버에서 값이 null로 내려온 케이스에서 사고가 났다.


function printList(data) {
  if (typeof data === "object") {
    console.log(data.length);
  }
}
printList(null); // typeof null도 "object"라서 검사를 통과하고, data.length에서 그대로 터진다


typeof null"object"를 돌려주는 건 자바스크립트 초창기부터 있던 유명한 버그인데, 지금 고치면 기존 코드가 다 깨지니까 그냥 언어 스펙으로 굳어버렸다. 배열인지 확실히 검사하려면 typeof 말고 Array.isArray를 써야 한다.


function printListFixed(data) {
  if (Array.isArray(data)) {
    console.log(data.length);
  }
}
printListFixed(null); // 조건 자체가 거짓이라 조용히 건너뛴다


NaN은 세상에서 유일하게 자기 자신과도 다른 값이다. 회원가입 폼에서 나이 입력값을 검증할 때 실제로 이 버그를 밟았다.


const input = Number("스물다섯"); // 숫자로 안 바뀌는 문자열, NaN이 된다
if (input === NaN) {
  console.log("숫자 아님"); // 이 줄은 절대 실행되지 않는다
}
if (Number.isNaN(input)) {
  console.log("숫자 아님"); // 이게 진짜 동작하는 검사다
}


NaN === NaN은 항상 false다. 검증 로직을 넣었다고 안심하고 배포했는데, 실제로는 아무것도 걸러지지 않고 있었던 거다. 전역 함수 isNaN도 검사 전에 값을 숫자로 강제 변환해버려서 isNaN("문자")true가 나오니, NaN 판별은 Number.isNaN으로 해야 한다.

돌아보면 여기 나온 버그들 전부 문법책 첫 페이지에 이미 적혀 있던 내용이다. 호이스팅도, 참조값도, typeof null도, NaN도 몰래 숨겨둔 지식이 아니었다. 근데 눈으로 읽어서 아는 것과 진짜 사고 치고 몸으로 아는 건 완전히 다른 문제더라. 요즘 새로 들어온 팀원 코드를 봐줄 때도 딱 이 네 가지 지점부터 확인한다. 변수 선언에 var가 섞여 있는지, 배열이나 객체를 복사한다면서 그냥 대입만 한 건 아닌지, constfreeze를 걸었다고 안심하고 있는 건 아닌지, 숫자 비교하는 곳에 NaN이 끼어들 여지는 없는지. 이 네 개만 걸러도 신입 때 나를 밤새게 만들었던 버그의 태반은 미리 막힌다. 기본기라는 게 원래 그렇다. 어설프게 안다고 넘기는 순간 어디선가 반드시 값을 치른다.