현록

JavaScript var let const 차이

JavaScript에서 변수를 선언할 때 var, let, const를 사용할 수 있다.
셋 다 이름과 값을 연결하지만 스코프, 재할당, 재선언 규칙이 다르다.

새 코드를 작성할 때는 보통 const를 기본으로 사용한다.
값을 다시 할당해야 할 때만 let을 사용한다.
var는 기존 코드를 읽거나 오래된 실행 환경을 다룰 때 주로 만난다.

이 기준을 제대로 적용하려면 “값이 바뀌는가”와 “변수가 보이는 범위가 어디인가”를 함께 봐야 한다.

변수 선언의 기본 형태

세 키워드의 기본 문법은 비슷하다.

var legacyName = '현록'
let page = 1
const siteUrl = 'https://hyun.pro'

varlet으로 선언한 변수에는 나중에 다른 값을 다시 할당할 수 있다.

let page = 1
page = 2

const로 선언한 binding에는 다른 값을 다시 할당할 수 없다.

const siteUrl = 'https://hyun.pro'
siteUrl = 'https://example.com' // TypeError

여기서 const가 보호하는 것은 변수 이름과 값의 연결이다.
객체 내부까지 자동으로 불변이 되는 것은 아니다.

스코프의 의미

스코프는 변수에 접근할 수 있는 코드 범위다.

함수 안에서만 보이는 변수가 있고, 중괄호 block 안에서만 보이는 변수도 있다.
같은 이름이라도 스코프가 다르면 서로 다른 binding이 될 수 있다.

const message = '바깥'

function printMessage() {
  const message = '함수 안'
  console.log(message)
}

printMessage() // 함수 안
console.log(message) // 바깥

변수 선언 키워드의 가장 큰 차이 가운데 하나가 이 스코프 규칙이다.

var의 함수 스코프

var는 함수 스코프를 가진다.
함수 안에서 선언하면 그 함수 전체에서 접근할 수 있다.

function checkLogin(isLoggedIn) {
  if (isLoggedIn) {
    var message = '로그인 상태'
  }

  console.log(message)
}

messageif block 안에서 선언됐지만 함수의 나머지 영역에서도 보인다.
중괄호가 var의 스코프를 만들지 않기 때문이다.

for (var i = 0; i < 3; i += 1) {
  console.log(i)
}

console.log(i) // 3

반복문 밖에서도 i에 접근할 수 있다.
작은 block 안에서만 쓰려던 변수가 바깥으로 새어 나가므로 코드가 커질수록 실수 가능성이 높아진다.

let의 블록 스코프

let은 block 스코프를 가진다.
if, for, 함수처럼 중괄호로 만든 block 안에서 선언하면 그 block 안에서만 접근할 수 있다.

function checkLogin(isLoggedIn) {
  if (isLoggedIn) {
    let message = '로그인 상태'
    console.log(message)
  }

  console.log(message) // ReferenceError
}

messageif block 바깥에서 보이지 않는다.
변수의 생명 범위가 실제 사용 범위와 가까워진다.

반복문에서도 loop variable이 밖으로 새지 않는다.

for (let i = 0; i < 3; i += 1) {
  console.log(i)
}

console.log(i) // ReferenceError

let은 block 안에서 값이 여러 번 바뀌어야 할 때 적합하다.

let total = 0

for (const price of [1000, 2000, 3000]) {
  total += price
}

total은 계산 과정에서 재할당되므로 let을 사용한다.
각 반복에서 꺼낸 price는 다시 할당하지 않으므로 const를 사용한다.

const의 블록 스코프

constlet과 같은 block 스코프를 가진다.
차이는 선언한 binding에 다른 값을 재할당할 수 없다는 점이다.

if (true) {
  const status = 'ready'
  console.log(status)
}

console.log(status) // ReferenceError

const는 선언할 때 초기값이 반드시 필요하다.

const status // SyntaxError

나중에 값을 넣을 계획이라면 let을 사용해야 한다.

let status

if (isReady) {
  status = 'ready'
} else {
  status = 'pending'
}

가능하다면 조건식의 결과를 바로 const에 할당해 선언과 초기화를 붙여 둘 수도 있다.

const status = isReady ? 'ready' : 'pending'

재할당의 차이

재할당은 이미 선언한 변수 이름에 다른 값을 연결하는 일이다.

varlet은 재할당할 수 있다.

var count = 0
count = 1

let page = 1
page = 2

const는 재할당할 수 없다.

const limit = 10
limit = 20 // TypeError

재할당이 필요한지는 변수의 실제 사용을 보고 판단한다.
값이 변할 가능성이 있다는 막연한 이유만으로 모두 let으로 선언할 필요는 없다.

현재 코드에서 다시 할당하지 않는다면 const가 의도를 더 분명하게 드러낸다.

재선언의 차이

재선언은 같은 스코프에서 같은 이름으로 선언문을 다시 쓰는 일이다.

var는 같은 스코프에서 재선언을 허용한다.

var name = '현록'
var name = 'hyun'

코드가 길어지면 실수로 같은 이름을 선언해도 오류가 나지 않을 수 있다.

letconst는 같은 스코프에서 같은 이름을 다시 선언할 수 없다.

let page = 1
let page = 2 // SyntaxError
const site = 'hyun.pro'
const site = 'example.com' // SyntaxError

다른 block에서는 같은 이름을 새로 선언할 수 있다.

const status = 'outer'

{
  const status = 'inner'
  console.log(status) // inner
}

console.log(status) // outer

두 변수는 스코프가 다르므로 서로 다른 binding이다.

const 객체의 변경

const는 객체를 불변으로 만들지 않는다.
객체를 가리키는 binding의 재할당만 막는다.

const user = {
  name: '현록',
}

user.name = 'hyun'
user.role = 'admin'

property 변경과 추가는 가능하다.

변수 자체에 다른 객체를 연결하는 것은 불가능하다.

const user = {
  name: '현록',
}

user = {
  name: 'hyun',
} // TypeError

배열도 같은 규칙을 따른다.

const posts = []

posts.push({ id: 1, title: '첫 글' })
posts.push({ id: 2, title: '둘째 글' })

배열 내부는 바뀌지만 posts binding은 같은 배열을 계속 가리킨다.

객체 자체를 바꾸지 못하게 만들고 싶다면 불변 데이터 처리 방식이나 Object.freeze() 같은 별도 도구가 필요하다.
const만으로 깊은 불변성을 기대하면 안 된다.

호이스팅과 초기화 차이

var, let, const 모두 선언 정보가 코드 실행 전에 처리된다.
하지만 선언 전에 접근했을 때의 결과가 다르다.

var는 선언 전에 읽으면 undefined가 나온다.

console.log(count) // undefined

var count = 1

개념적으로 선언이 함수 스코프 시작에서 만들어지고 undefined로 초기화된 뒤, 원래 코드 위치에서 값이 할당된다.

letconst는 block 시작부터 binding이 존재하지만 선언문에 도달하기 전에는 접근할 수 없다.

console.log(page) // ReferenceError

let page = 1
console.log(limit) // ReferenceError

const limit = 10

block 시작부터 초기화 지점까지를 temporal dead zone 또는 TDZ라고 부른다.

letconst가 호이스팅되지 않는다고 간단히 설명하기도 한다.
더 정확히는 선언이 처리되지만 초기화 전에 접근할 수 없다고 이해하는 편이 좋다.

가장 안전한 습관은 변수를 사용하기 전에 가까운 위치에서 선언하는 것이다.

반복문과 closure 차이

varlet의 차이는 비동기 callback이 있는 반복문에서 선명하게 드러난다.

for (var i = 0; i < 3; i += 1) {
  setTimeout(() => {
    console.log(i)
  }, 0)
}

callback이 실행될 때 반복문은 이미 끝났고 같은 i binding의 값은 3이다.
그래서 3이 세 번 출력된다.

let을 사용하면 반복마다 새로운 binding이 만들어진다.

for (let i = 0; i < 3; i += 1) {
  setTimeout(() => {
    console.log(i)
  }, 0)
}

결과는 0, 1, 2다.
각 callback이 자기 반복의 i를 기억한다.

비동기 실행 순서와 Promise 병렬 처리 기준은 Promise 다루기에서 더 확인할 수 있다.

전역 스코프의 차이

브라우저의 일반 script 최상위에서 선언한 var는 전역 객체의 property가 될 수 있다.

var legacyValue = 1

console.log(window.legacyValue) // 1

최상위 letconst는 전역 binding을 만들지만 window property로 추가되지 않는다.

let page = 1
const limit = 10

console.log(window.page) // undefined
console.log(window.limit) // undefined

환경과 script 종류에 따라 전역 처리 방식이 달라질 수 있다.
ES module에서는 각 module이 자체 scope를 가지므로 일반 script의 전역 동작과 같다고 가정하면 안 된다.

전역 변수 자체를 줄이고 module 단위로 의존성을 명시하는 편이 충돌을 예방하기 쉽다.

선언 위치와 script 실행

변수 선언 규칙을 이해해도 JavaScript 파일이 DOM보다 먼저 실행되면 요소를 찾지 못할 수 있다.

const button = document.querySelector('#save-button')

이 코드가 HTML button보다 먼저 실행되면 buttonnull일 수 있다.
이는 const의 문제가 아니라 script 실행 시점의 문제다.

defer, async, 문서 파싱 순서의 차이는 script async와 defer 차이에서 정리했다.

변수 선언 방식과 코드가 실행되는 시점을 분리해서 보면 원인을 찾기 쉽다.

var를 만나는 경우

새 프로젝트에서 var를 선택할 이유는 많지 않다.
block 스코프가 없고 같은 스코프의 재선언을 허용해 변수 범위를 예측하기 어렵기 때문이다.

그래도 오래된 코드나 ES2015 이전 환경을 지원하는 build 결과에서는 var를 볼 수 있다.
기존 함수를 수정할 때 var를 무조건 let이나 const로 기계적으로 바꾸면 동작이 달라질 수 있다.

다음 항목을 확인하고 변경해야 한다.

  1. block 바깥에서 해당 변수를 사용하는지 확인한다.
  2. 같은 이름을 재선언하는 코드가 있는지 확인한다.
  3. 반복문 callback이 하나의 binding을 공유하는지 확인한다.
  4. 전역 객체 property에 기대는 코드가 있는지 확인한다.
  5. 선언 전에 변수에 접근하는 코드가 있는지 확인한다.

동작을 파악한 뒤 작은 범위에서 바꾸고 테스트하는 편이 안전하다.

const를 기본으로 쓰는 이유

const는 이 변수 이름이 다른 값으로 재할당되지 않는다는 의도를 보여준다.
코드를 읽는 사람은 값의 출처를 추적할 때 재할당 지점을 찾지 않아도 된다.

const response = await fetch('/api/posts')
const posts = await response.json()

responseposts binding을 다시 할당하지 않는다면 const가 자연스럽다.

객체나 배열 내부를 변경할 수 있다는 이유만으로 let을 사용할 필요는 없다.
판단 기준은 binding을 다른 값으로 다시 연결하는지다.

let을 사용하는 기준

계산 과정에서 같은 변수에 값을 누적하거나 조건에 따라 나중에 값을 넣어야 하면 let을 사용한다.

let total = 0

for (const item of cart) {
  total += item.price
}
let message

if (response.ok) {
  message = '저장했습니다.'
} else {
  message = '저장하지 못했습니다.'
}

함수형 표현이나 조건식으로 재할당을 줄일 수 있다면 const로 바꿀 수도 있다.
하지만 억지로 복잡한 표현을 만들기보다 읽기 쉬운 let을 사용하는 편이 나을 때도 있다.

자주 생기는 실수

const 객체의 property도 바꿀 수 없다고 생각하지 않는다.

값을 다시 할당하지 않는데 습관적으로 let을 사용하지 않는다.

var가 iffor block 밖에서도 보인다는 점을 놓치지 않는다.

let과 const를 선언 전에 읽으면 undefined가 아니라 ReferenceError가 발생한다.

같은 스코프에서 let이나 const를 재선언하지 않는다.

반복문 callback에서 var가 하나의 binding을 공유할 수 있다는 점을 확인한다.

선택 기준

다시 할당하지 않는 값은 const로 선언한다.

const apiUrl = '/api/posts'

계산 과정이나 상태 변화로 다시 할당해야 하면 let을 사용한다.

let currentPage = 1
currentPage += 1

새 코드에서는 특별한 호환성 이유가 없다면 var 사용을 피한다.

기존 var 코드를 바꿀 때는 스코프, 재선언, 호이스팅, 전역 객체 의존성을 먼저 확인한다.

정리

var는 함수 스코프를 가지며 같은 스코프에서 재선언과 재할당이 가능하다.
block 밖으로 변수가 보일 수 있고 선언 전에 읽으면 undefined가 나온다.

let은 block 스코프를 가지며 재할당은 가능하지만 같은 스코프의 재선언은 불가능하다.
선언 전에 접근하면 TDZ 때문에 ReferenceError가 발생한다.

const는 block 스코프를 가지며 재할당과 같은 스코프의 재선언이 불가능하다.
객체와 배열 내부는 변경할 수 있으므로 const가 깊은 불변성을 뜻하지는 않는다.

새 코드에서는 const를 기본으로 두고, binding을 다시 할당할 때만 let을 쓰는 기준이 가장 단순하다.

참고 자료

관련 포스트
URL path parameter와 query string 차이 thumbnail
URL path parameter와 query string 차이
URL path parameter와 query string의 차이를 초보자 기준으로 정리합니다. 리소스 식별자, 조회 조건, URLSearchParams, 인코딩, API 설계 기준을 함께 봅니다.
쿠키와 localStorage 차이 thumbnail
쿠키와 localStorage 차이
쿠키와 localStorage의 차이를 초보자 기준으로 정리합니다. 서버 자동 전송, JavaScript 접근, 만료 시간, HttpOnly, Secure, SameSite, 로그인 상태 저장 기준을 함께 봅니다.
script async와 defer 차이 thumbnail
script async와 defer 차이
HTML script 태그의 기본 동작, async와 defer의 다운로드 방식, 실행 순서, DOMContentLoaded와의 관계를 초보자 기준으로 정리합니다.
CORS 에러와 해결 기준 thumbnail
CORS 에러와 해결 기준
브라우저의 Same-Origin Policy, CORS 응답 헤더, preflight, 서버에서 해결해야 하는 이유를 초보자 기준으로 정리합니다.
fetch와 axios 차이 thumbnail
fetch와 axios 차이
fetch와 axios의 차이를 초보자 기준으로 정리합니다. 설치 여부, JSON 처리, HTTP 에러 처리, timeout, interceptor, 언제 어떤 방식을 쓰면 좋은지 함께 봅니다.
Vue 3 입문기 thumbnail
Vue 3 입문기
Vue의 메인 버전이 3가 된지 꽤 됐다. 작성 당시에는 Nuxt 3가 RC 단계였기 때문에 실서비스에 바로 적용하기에는 조심스러웠다. 그래서 궁금하고 심심하던 참에 간단한 Todo App을 만들어봤다.
marked.js renderer custom 하기 thumbnail
marked.js renderer custom 하기
marked.js renderer custom 방법을 정리합니다. Renderer의 역할, image와 link 렌더러 커스텀, alt와 rel 처리, sanitize-html 같은 HTML 정화 주의점까지 함께 봅니다.
가독성 있게 상수 넣기 thumbnail
가독성 있게 상수 넣기
오늘 회사 동료의 PR 리뷰 과정에서 좋은 기능을 공유해주셔서 TIL로 남겨봅니다. 아래와 같이 상수에 언더바(_)로 콤마처럼 구분을 시켜줄 수 있습니다. 앞으로 깔끔하고 좋은 코드를 작성하기 위해 자주 사용해야겠습니다 :)
TS에서 generic optional 하게 설정하기 thumbnail
TS에서 generic optional 하게 설정하기
오늘 next에서 `getStaticProps`와 `getLayout` 패턴을 함께 사용할 때, typescript generic을 넘겨주는 작업을 하고 있었는데, 기본값이 없다보니 기존 코드에 에러가 발생했었다. 이를 해결하기 위해 찾아보니 단순히 아래 예시처럼 `= {}`을 추가해주면 해결된다고 한다.
Backend에서 API Response가 snake_case인 경우엔? thumbnail
Backend에서 API Response가 snake_case인 경우엔?
안녕하세요. 프론트엔드 개발자의 경우, 가끔 백엔드의 API Response 값이 snake_case일 경우 어떻게 관리할지에 대해 고민에 빠지게 됩니다. 저도 오늘 같은 상황을 겪게 되었는데,  이번엔 네이밍 컨벤션을 맞춰주기로 했습니다. 컨벤션을 맞추는 데에는 여러가지 방법이 있겠지만, 고민 끝에 저는 axios의 interceptors를 통해 해결을 해보았습니다.
Promise 다루기 (feat. 병렬실행, 순차실행) thumbnail
Promise 다루기 (feat. 병렬실행, 순차실행)
오늘은 Promise를 통해 구문을 동기 처리 할 때, 여러 Promise들을 다루는 법을 소개해보겠습니다. Javscript를 작성하다 보면, 가끔 여러 Promise들을 다룰 때가 있습니다. 필자도 Nodejs 서버에서 동시에 여러 쿼리를 실행할 때 자주 마주쳤었는데요. 오늘은 어떻게 하면 Promise들을 유연하게 다룰 수 있는지 알아보겠습니다. 시작하기 앞서, 네 가지 Promise를 선언하고, 그들을 하나의 Array에 묶어보겠습니다.