JavaScript 시작하기: 개발 환경, 기본 문법, 브라우저에서 실행하기

이 글의 핵심

JavaScript를 처음 배울 때 문법부터 외우면 비동기 동작이나 스코프 문제 앞에서 금방 막힙니다. 그래서 엔진과 이벤트 루프, 메모리 모델을 가볍게 먼저 짚고, 브라우저 콘솔과 Node.js에서 코드를 실행해 보는 흐름으로 구성했습니다. 입문자가 자주 부딪히는 오류를 모은 트러블슈팅도 담았습니다.

시리즈 안내

#01 | 📋 전체 목차 | 다음: #02 변수와 데이터 타입


들어가며

JavaScript는 웹 브라우저가 기본으로 실행하는 유일한 스크립트 언어이고, Node.js 덕분에 서버와 CLI 도구까지 영역을 넓힌 범용 언어입니다. 이 글에서는 ECMAScript 표준과의 관계, 실행 환경, 기본 문법, var/let/const를 다루되, 입문서에서 흔히 건너뛰는 엔진과 이벤트 루프, 메모리, 실행 컨텍스트도 가볍게 짚습니다. 문법만 아는 것과 런타임이 왜 그렇게 동작하는지 아는 것의 차이는 비동기 버그나 성능 문제를 만났을 때 크게 드러납니다.

다음 글인 변수와 타입, 함수로 이어지도록 구성했습니다.


JavaScript 엔진: V8, SpiderMonkey, JavaScriptCore

ECMAScript 명세는 언어가 무엇을 해야 하는지만 정하고, 어떻게 빠르게 실행할지는 구현체인 엔진에 맡깁니다. Chrome, Edge, Node.js는 V8을, Firefox는 SpiderMonkey를, Safari는 JavaScriptCore를 씁니다. 세 엔진 모두 소스를 파싱해 AST를 만들고, 바이트코드로 바꿔 실행하다가 자주 실행되는 코드를 점점 더 최적화된 머신 코드로 옮기는 큰 흐름은 같습니다.

V8은 바이트코드를 실행하는 인터프리터 Ignition에서 시작해, 자주 호출되는 함수를 베이스라인 컴파일러 Sparkplug, 중간 단계 최적화 컴파일러 Maglev, 최상위 최적화 컴파일러 TurboFan으로 단계적으로 올립니다. SpiderMonkey도 베이스라인 인터프리터와 Baseline JIT, 최적화 컴파일러 Warp(Ion)로 이루어진 계층형 구조입니다.

개발자가 알아 둘 핵심은, 최적화 컴파일러가 “이 변수는 항상 숫자였다” 같은 관찰에 기대어 추측성 최적화를 한다는 점입니다. 추측이 틀리면 최적화된 코드를 버리고 느린 경로로 돌아가는 디옵티마이즈(deoptimization)가 일어납니다. 같은 변수에 타입이 계속 바뀌는 값을 넣거나, 객체에 프로퍼티를 제각각 다른 순서로 추가하는 코드는 이런 비용을 키울 수 있습니다.

JavaScript 코드는 기본적으로 한 스레드에서 실행됩니다. Web Worker나 Node.js의 worker_threads는 별도의 실행 환경을 따로 만듭니다. 그래서 긴 동기 연산은 브라우저에서 UI 갱신과 이벤트 처리를 막습니다. 반면 네트워크나 파일 I/O, 일부 암호화 작업은 런타임이 JavaScript 스레드 밖에서 처리하고 결과만 넘겨주므로, 느린 원인이 JavaScript 코드인지 런타임 쪽인지를 구분해서 볼 필요가 있습니다.


이벤트 루프: 호출 스택, 태스크, 마이크로태스크

JavaScript 런타임은 보통 호출 스택(call stack), 태스크 큐(매크로태스크 큐라고도 부름), 마이크로태스크 큐로 설명합니다. 동기 코드는 호출 스택에 쌓였다가 나중에 들어온 것부터 실행됩니다. setTimeout, Promise, I/O 완료 같은 비동기 API는 코드를 즉시 실행하지 않고, 나중에 큐에 넣을 작업을 예약합니다.

브라우저에서 이벤트 루프 한 바퀴는 개념적으로 다음 순서로 돌아갑니다.

  1. 태스크 큐에서 태스크 하나를 꺼내 호출 스택이 빌 때까지 실행합니다. 페이지의 첫 스크립트 실행도 하나의 태스크입니다.
  2. 마이크로태스크(Promise.then, queueMicrotask, MutationObserver 콜백)를 큐가 빌 때까지 모두 처리합니다.
  3. 필요하면 렌더링(스타일 계산, 레이아웃, 페인트)을 합니다. requestAnimationFrame 콜백은 이 직전에 실행됩니다.
  4. 다음 태스크로 돌아갑니다.

그래서 setTimeout(fn, 0)은 다음 태스크로 밀리고, Promise.resolve().then(fn)은 현재 동기 코드가 끝나자마자 그보다 먼저 실행됩니다. 흔한 실수는 상태를 바꾼 직후 화면이 이미 갱신되었으리라 가정하는 것입니다. 마이크로태스크를 처리하는 동안에는 렌더링이 일어나지 않으므로, 화면 반영 이후에 할 일은 requestAnimationFrame이나 다음 태스크로 미뤄야 합니다.

Node.js는 타이머, I/O 콜백, setImmediate 등을 단계(phase)별로 나눠 처리하고, process.nextTick 큐를 Promise 마이크로태스크보다도 먼저 비운다는 점이 브라우저와 다릅니다. 세부 단계는 달라도, 동기 코드, 마이크로태스크, 다음 태스크 순서라는 상대적인 흐름을 머릿속에 그릴 수 있으면 대부분의 비동기 순서 문제를 설명할 수 있습니다.


메모리 모델: 스택, 힙, 가비지 컬렉션

ECMAScript 명세에는 스택이나 힙이라는 개념이 없고, 값을 어디에 둘지는 엔진이 정합니다. 그래도 객체, 배열, 함수는 힙에 할당되고, 변수는 그 객체를 가리키는 참조를 담는다고 이해해 두면 이후에 배울 클로저와 공유 참조가 쉬워집니다. 원시 값도 엔진 내부에서는 작은 정수를 제외하면 힙에 두는 경우가 많으니, “원시 값은 스택에 있다”는 설명은 비유 정도로만 받아들이는 편이 좋습니다.

가비지 컬렉션(GC)은 도달 가능성(reachability)을 기준으로 메모리를 회수합니다. 전역 객체, 현재 실행 중인 함수의 지역 변수 같은 루트에서 참조를 따라 닿을 수 없는 객체는 회수 대상입니다. 세대별 GC와 증분·병렬 마킹 같은 기법은 엔진이 알아서 하므로, 개발자가 챙길 점은 다음 정도입니다.

  • 수명이 짧은 객체를 대량으로 만들면 GC가 자주 돌 수 있습니다. 애니메이션 프레임마다 큰 배열이나 객체를 새로 만드는 코드가 대표적입니다.
  • 오래 살아 있는 클로저나 이벤트 리스너가 큰 객체를 붙잡고 있으면, 더는 쓰지 않는데도 회수되지 않아 메모리 누수처럼 보입니다.
  • 객체에 부가 정보만 달아 두고 싶을 때는 WeakMap을 쓰면, 키 객체가 다른 곳에서 더 참조되지 않을 때 함께 회수됩니다.

JIT 최적화를 방해하지 않는 습관

JIT 컴파일러는 실행 중에 수집한 프로파일을 바탕으로 인라이닝, 타입 특화, 불필요한 검사 제거 같은 최적화를 합니다. 처음 배우는 단계에서 이걸 신경 쓸 필요는 거의 없지만, 다음 습관은 가독성에도 도움이 되므로 알아 두면 좋습니다.

  • 같은 종류의 객체는 같은 프로퍼티를 같은 순서로 초기화합니다. 엔진은 객체의 형태(hidden class, shape)가 같으면 프로퍼티 접근을 빠르게 처리합니다.
  • 한 변수나 함수 인자에 숫자와 문자열을 번갈아 넣지 않습니다.
  • 대부분의 성능 문제는 JIT보다 알고리즘, 네트워크 요청, 불필요한 렌더링에서 옵니다. 미세 최적화 전에 DevTools의 Performance 패널로 먼저 측정합니다.

실행 컨텍스트와 스코프 체인

실행 컨텍스트는 코드가 실행되는 순간의 환경입니다. 스크립트가 시작되거나 함수가 호출될 때 새 컨텍스트가 만들어지고, 그 안에 변수와 함수 선언을 담는 렉시컬 환경(Lexical Environment)이 연결됩니다. 스코프 체인은 식별자를 찾을 때 현재 렉시컬 환경에서 시작해 바깥 환경으로 거슬러 올라가며 찾는 과정입니다.

let과 const는 선언 이전 구간이 TDZ(Temporal Dead Zone)라서, 선언문보다 먼저 접근하면 ReferenceError가 납니다. 반면 var는 스코프 시작 시점에 undefined로 초기화되어 있어서 선언 전에 읽으면 undefined가 나옵니다. 둘 다 “호이스팅된다”고 뭉뚱그려 말하면 헷갈리므로, 바인딩이 생기는 시점과 초기화되는 시점을 나눠 기억하는 것이 좋습니다.

클로저는 함수가 자신이 만들어질 때의 렉시컬 환경을 기억하는 것입니다. 모듈 패턴, 이벤트 핸들러, 고차 함수에서 핵심적으로 쓰이지만, 앞서 말한 것처럼 필요 이상으로 오래 살아남는 참조를 만들기도 합니다.


JavaScript의 역사와 ECMAScript

짧은 역사

  • 1995년: Netscape의 Brendan Eich가 웹 페이지에 동작을 넣기 위해 만든 언어가 Mocha, LiveScript를 거쳐 JavaScript라는 이름으로 나왔습니다. 이름의 “Java”는 당시 마케팅 때문에 붙은 것이고, 언어 설계는 Java와 다릅니다.
  • 1997년: 브라우저마다 제각각이던 구현을 맞추기 위해 Ecma International이 ECMAScript라는 표준 명세(ES1)를 제정했습니다. ES5, ES2015 같은 버전 이름이 이 표준의 세대입니다.
  • 2015년(ES2015, ES6): let/const, 화살표 함수, 클래스, 모듈(import/export), Promise 등 현대 JavaScript의 뼈대가 한꺼번에 들어온 큰 개정이었습니다.
  • 이후 매년 ES2016, ES2017처럼 새 버전이 나옵니다. 흔히 말하는 모던 JavaScript는 대략 ES2015 이후의 문법을 가리킵니다.

명세는 웹 호환성을 매우 중시해서, 이미 배포된 사이트가 깨지지 않도록 오래된 동작을 거의 제거하지 않습니다. 그래서 ==, var, 암묵적 형 변환 같은 함정은 표준에 그대로 남아 있고, 새 코드에서 쓰지 않는 방식으로 피해야 합니다.

ECMAScript와 JavaScript의 관계

용어의미
ECMAScript언어의 문법, 타입, 내장 객체를 정의한 표준 문서
JavaScriptECMAScript를 구현한 언어와 그 구현체들(V8, SpiderMonkey 등)

실무에서는 “어느 ECMAScript 버전까지 지원하는가”가 호환성의 기준이 됩니다. 예를 들어 ES2020의 BigInt나 ?.를 쓰려면 대상 브라우저 버전이나 빌드 타깃을 확인해야 합니다. Babel, TypeScript의 target 옵션, browserslist는 모두 이 차이를 메우는 도구입니다.


실행 환경: 브라우저 vs Node.js

문법은 ECMAScript로 같지만, 사용할 수 있는 API는 환경마다 다릅니다. 파일 읽기, DOM, 네트워크 같은 기능은 언어가 아니라 JavaScript를 실행하는 호스트 환경이 제공합니다.

브라우저

  • HTML/CSS로 그린 화면을 DOM(HTML 문서를 객체 트리로 다루는 인터페이스)으로 조작하고, 클릭이나 입력 같은 이벤트에 반응합니다.
  • document, window, fetch, localStorage 같은 웹 API를 씁니다.
  • HTML의 <script> 태그로 불러오며, type="module"을 붙이면 ES 모듈로 실행됩니다.
<script>
  console.log(document.title);
</script>

document는 현재 문서에 접근하는 진입점이고, document.title은 <title> 태그의 내용을 돌려줍니다. 스크립트는 HTML을 파싱하다가 만난 위치에서 바로 실행되므로, 이 스크립트가 <title>보다 앞에 있으면 아직 제목이 없어 빈 문자열이 나옵니다. 그래서 DOM을 다루는 스크립트는 defer 속성이나 type="module"로 문서 파싱 이후에 실행되게 하거나, DOMContentLoaded 이벤트 이후에 실행합니다. 이 한 줄에서 문법은 JavaScript이지만 document 객체는 브라우저가 제공한 것이라는 경계를 볼 수 있습니다.

Node.js

  • 서버, 빌드 스크립트, CLI 도구처럼 파일, 프로세스, 네트워크를 직접 다루는 작업에 씁니다.
  • fs, path, http, process 같은 내장 모듈을 제공하며, 브라우저의 window나 document는 없습니다.
  • 터미널에서 node 파일.js로 실행하거나 package.json의 스크립트로 실행합니다.
// Node.js (예: server.js)
const http = require("http");
// 또는 "type": "module" 인 프로젝트에서는 import 사용

require는 CommonJS 모듈 시스템의 동기 로더이고, http는 HTTP 서버와 클라이언트를 만드는 내장 모듈입니다. 이 두 줄만으로는 서버가 뜨지 않고, http.createServer로 요청 처리 함수를 등록한 뒤 listen을 호출해야 합니다. import 문법(ES 모듈)을 쓰려면 package.json에 "type": "module"을 넣거나 파일 확장자를 .mjs로 합니다.

비교

구분브라우저Node.js
대표 목적UI·페이지 상호작용서버·도구·자동화
DOM있음없음
모듈<script type="module">의 import/export, 또는 번들러require(CommonJS) 또는 import(ES 모듈)

기초 문법은 어디서 연습해도 같으므로 브라우저 개발자 도구 콘솔이든 Node든 편한 곳에서 시작하면 됩니다. 웹 화면을 만드는 것이 목표라면 HTML과 함께 브라우저에서, 백엔드가 목표라면 Node를 병행하는 편이 자연스럽습니다.


기본 문법: 변수, 타입, 연산자

변수 선언

값에 이름을 붙이는 것이 변수입니다. 선언 키워드는 var, let, const 세 가지이며, 차이는 아래 var vs let vs const에서 정리합니다.

let count = 0;
const siteName = "pkglog";

let count는 나중에 다른 값을 다시 대입할 수 있는 변수를 만들고, const siteName은 재대입할 수 없는 변수를 만듭니다. 다만 const가 막는 것은 변수에 다른 값을 대입하는 것이지, 값이 객체일 때 그 내부를 바꾸는 것까지 막지는 않습니다. 기본은 const로 쓰고 반복 카운터나 누적값처럼 바뀌어야 하는 것만 let으로 쓰는 관례가 널리 퍼져 있습니다. 이렇게 하면 의도치 않은 재대입을 코드 리뷰나 린터가 쉽게 잡아냅니다.

타입

JavaScript는 동적 타입 언어라서, 같은 변수에 숫자를 넣었다가 문자열을 넣는 것이 문법상 허용됩니다. 실무에서는 헷갈리므로 피하는 편이 좋습니다.

원시 타입(primitive)은 string, number, bigint, boolean, undefined, symbol, null 일곱 가지입니다. undefined는 값이 아직 정해지지 않았음을, null은 의도적으로 비어 있음을 나타낼 때 씁니다. 그 밖의 배열, 함수, 일반 객체 {}는 모두 객체 타입이며 참조로 다룹니다.

typeof 42;           // "number"
typeof "hello";      // "string"
typeof true;         // "boolean"
typeof undefined;     // "undefined"
typeof { a: 1 };     // "object"
typeof (() => {});   // "function"

typeof는 값의 타입을 문자열로 돌려주는 연산자입니다. 유명한 함정은 typeof null이 "object"라는 것으로, 초기 구현의 버그가 호환성 때문에 그대로 남은 결과입니다. 배열도 typeof []가 "object"이므로 배열인지는 Array.isArray로 확인합니다. 함수는 객체이지만 호출할 수 있는 객체라서 명세상 "function"으로 따로 구분됩니다.

자주 쓰는 연산자

  • 산술: +, -, *, /, %, **(거듭제곱)
  • 비교: ===, !==, <, >, <=, >=
  • 논리: &&, ||, !
  • 널 병합과 옵셔널 체이닝(ES2020): ??, ?.
// == 는 형 변환을 해서 예측이 어렵습니다. === 를 기본으로 쓰세요.
0 == false;   // true (피하기)
0 === false;  // false
const name = null;
const display = name ?? "guest";  // "guest"

==는 비교 전에 문자열, 숫자, 불리언 사이에서 암묵적으로 형 변환을 하므로 결과를 예측하기 어렵습니다. 그래서 ===와 !==를 기본으로 씁니다. ??는 왼쪽 값이 null이나 undefined일 때만 오른쪽 값을 씁니다. ||와 달리 0이나 빈 문자열 같은, 거짓으로 취급되지만 유효한 값을 그대로 살리므로 설정값 기본 처리에 자주 쓰입니다. 참고로 a || b ?? c처럼 ??를 &&나 ||와 괄호 없이 섞으면 문법 오류가 나므로, 반드시 괄호로 우선순위를 표시해야 합니다.

변수와 타입, 형 변환은 JavaScript 변수와 데이터 타입에서 더 자세히 다룹니다.


var vs let vs const

한눈에 비교

키워드스코프재선언재할당비고
var함수 스코프가능(같은 스코프)가능선언 전 접근 시 undefined
let블록 스코프불가(같은 블록)가능선언 전 접근 시 ReferenceError
const블록 스코프불가불가객체 내용 변경은 가능

let과 const를 기본으로

// ✅ 권장: 기본은 const, 바꿔야 할 때만 let
const apiBase = "https://api.example.com";
let retryCount = 0;
retryCount += 1;
// ❌ 새 코드에서 var는 피하기
var oldStyle = 1;

apiBase처럼 한 번 정하고 읽기만 하는 값은 const, retryCount처럼 재시도할 때마다 바뀌는 값은 let입니다. 이렇게 구분해 두면 코드를 읽는 사람이 어떤 값이 바뀌는지 바로 알 수 있습니다. var는 같은 스코프에서 다시 선언해도 에러가 나지 않는 등 실수를 숨기기 쉬워서, ESLint의 no-var 규칙으로 막는 팀이 많습니다.

var의 대표적인 문제

var는 블록이 아니라 함수 단위로 스코프가 잡히고, 선언이 스코프 맨 위로 끌어올려진 것처럼 동작해서 실수하기 쉽습니다.

if (true) {
  var x = 1;
}
console.log(x); // 1 — 블록 밖에서도 보임 (let이었다면 ReferenceError)
console.log(hoist);
var hoist = 2;  // 위 줄은 undefined 출력 (선언만 끌어올려지고 대입은 제자리)

var x는 if 블록이 아니라 바깥 함수(여기서는 전역) 스코프에 속하므로 블록 밖에서도 보입니다. let x였다면 블록 밖에서 ReferenceError가 납니다. 두 번째 예에서는 var hoist의 선언만 스코프 시작 시점에 처리되고 = 2 대입은 원래 자리에서 실행되므로, 그 전에 읽으면 undefined가 나옵니다. 이 성질은 for 루프 안에서 비동기 콜백을 만들 때 모든 콜백이 같은 변수를 공유하는 문제로 이어지는데, 반복마다 새 바인딩을 만드는 let의 동작과 함께 다음 글들에서 다시 다룹니다.

새 코드에서는 const를 기본으로, 필요할 때만 let을 쓰고, var는 오래된 코드를 읽을 수 있을 정도로만 알아 두면 충분합니다.


트러블슈팅

  • 값이 예상과 달리 undefined: 선언 전에 var 변수를 읽었는지, 함수 인자를 빠뜨렸는지, 비동기 결과가 아직 도착하지 않았는지를 의심합니다. 문제를 재현하는 가장 작은 코드를 만들고 DevTools에서 호출 스택을 확인하면 빨리 좁혀집니다.
  • this가 이상한 값: 일반 함수인지 화살표 함수인지, 엄격 모드인지, call/apply/bind로 호출했는지를 점검합니다. 함수 편에서 자세히 다룹니다.
  • 메모리가 계속 늘어남: DevTools의 힙 스냅샷에서 문서에서 제거되었지만 참조가 남은(detached) DOM 노드, 해제하지 않은 이벤트 리스너, 계속 커지는 캐시를 찾습니다.
  • 페이지 초기 로딩이 느림: 한꺼번에 불러오는 큰 스크립트, 필요 없는 폴리필, 메인 스레드를 오래 점유하는 초기화 코드를 의심하고 코드 분할을 검토합니다.
  • Node와 브라우저에서 결과가 다름: 타이머 동작, 모듈 해석 방식, 사용 가능한 전역 객체가 다를 수 있으므로 어느 환경에서 재현되는지 명확히 합니다.

첫 코드 예제

브라우저 콘솔이나 Node에서 바로 실행해 볼 수 있는 최소 예제입니다.

function greet(name) {
  return `Hello, ${name}!`;
}
const user = "developer";
console.log(greet(user));

greet는 호출될 때마다 새 실행 컨텍스트를 만들고 인자 name에 값을 바인딩합니다. 백틱으로 감싼 템플릿 리터럴은 ${} 안의 값을 문자열에 끼워 넣습니다. greet(user) 호출은 호출 스택에 올라갔다가 문자열을 반환하고 내려오며, 그 결과를 console.log가 출력합니다. console은 ECMAScript 표준이 아니라 호스트가 제공하는 객체이지만, 브라우저와 Node 모두 비슷하게 동작합니다.

HTML 파일에서 실행할 때는 <script> 태그 안에 넣거나 <script src="app.js" defer>로 파일을 분리합니다. 일반 <script>의 최상위에 선언한 const/let은 다른 스크립트와 전역 스코프를 공유하므로, 규모가 커지면 type="module"이나 번들러로 파일마다 스코프를 나누는 편이 안전합니다.


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. 'use strict'는 지금도 써야 하나요?

A. ES 모듈(type="module", import/export)과 class 본문은 자동으로 엄격 모드이므로 따로 적을 필요가 없습니다. 모듈이 아닌 일반 <script>나 CommonJS 파일에서는 여전히 의미가 있고, 선언하지 않은 변수에 대입하는 실수처럼 조용히 넘어가던 오류를 에러로 바꿔 줍니다. 새로 시작하는 코드라면 모듈로 작성해 엄격 모드를 기본으로 쓰는 편이 간단합니다.