자바스크립트 실행 환경 - 브라우저와 Node.js, defer async 스크립트 로딩
이 단원에서 배우는 것
이 커리큘럼의 첫 단원이다. 앞 단원이 없으므로 여기서는 문법 대신 내가 쓴 코드가 어디서 어떻게 실행되는지부터 잡는다. 이걸 건너뛰면 2단원부터 나오는 예제가 "왜 여기서는 되고 저기서는 안 되는지" 설명할 방법이 없다. 이 커리큘럼은 8단원 내내 개발자 스터디 참가자 명단이라는 하나의 소재를 쓴다. 이름·이메일·출석 횟수·회비 납부 여부를 다루는 작은 페이지를 단원마다 조금씩 키워 나간다.
- 브라우저와 Node.js가 각각 무엇을 실행해 주는지, 어떤 기능이 한쪽에만 있는지 구분한다.
<script>를 어디에 넣느냐에 따라 실행 시점이 달라지는 이유를 설명하고defer와async를 구분해 쓴다.- 같은 코드를 브라우저 콘솔·HTML 파일·Node 파일 세 곳에서 각각 실행해 본다.
버전 기준은 ES2022 이상이다. 브라우저는 2022년 이후 버전(크롬·엣지·사파리·파이어폭스 최신), Node.js는 20 또는 22 LTS를 가정한다.
왜 필요한가
자바스크립트를 처음 배우는 사람이 가장 먼저 부딪히는 오류는 문법 오류가 아니다. 아래 두 가지다.
Uncaught TypeError: Cannot read properties of null (reading 'textContent')
ReferenceError: document is not defined
둘 다 문법은 완벽하다. 첫 번째는 HTML이 아직 만들어지지 않은 시점에 화면 요소를 찾아서 나는 오류고, 두 번째는 화면이라는 것 자체가 없는 곳에서 화면 요소를 찾아서 나는 오류다. 즉 두 오류 모두 "코드가 어디서 언제 실행되는가"를 모른 채 짠 결과다.
자바스크립트는 언어 자체(변수, 함수, 배열 같은 것)와 실행 환경이 얹어 주는 기능이 분리돼 있다. 언어 명세(ECMAScript)에는 화면을 조작하는 기능도, 파일을 읽는 기능도 없다. 화면 조작은 브라우저가, 파일 읽기는 Node.js가 각자 얹어 준다. 그래서 같은 언어인데 실행 결과가 환경마다 다르다. 이 경계를 처음에 그어 두면 나중에 "이 함수 브라우저에서 쓸 수 있나?"를 매번 검색하지 않아도 된다.
문법과 예제
두 실행 환경
| 구분 | 브라우저 | Node.js |
|---|---|---|
| 실행 방법 | HTML이 <script>를 만나면 실행 | 터미널에서 node 파일.js |
| 화면(DOM) | document, window 있음 | 없음 — 쓰면 ReferenceError |
| 파일·네트워크 서버 | 없음(보안상 차단) | fs, http 등 |
| 둘 다 되는 것 | console, JSON, Date, Math, fetch | 배열·객체·문자열 기능은 전부 동일 |
표의 마지막 줄이 핵심이다. 2단원부터 6단원까지 배우는 내용은 전부 "둘 다 되는 것"에 속한다. 7·8단원의 DOM과 이벤트만 브라우저 전용이다.
브라우저에서 실행하기
study.html 파일 하나를 만들고 아래를 그대로 저장한 뒤 브라우저로 연다(파일을 더블클릭하면 된다).
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<title>스터디 참가자 명단</title>
<script src="study.js" defer></script>
</head>
<body>
<h1>스터디 참가자 명단</h1>
<p id="count">불러오는 중</p>
</body>
</html>
같은 폴더에 study.js를 만든다.
console.log("study.js 실행됨");
document.querySelector("#count").textContent = "참가자 3명";
화면에 "참가자 3명"이 뜨고 개발자 도구 콘솔(F12)에 로그가 찍히면 성공이다. 여기서 defer를 지우고 새로고침해 보면 바로 Cannot read properties of null이 난다. 이유는 다음 항목이다.
스크립트 로딩 시점 — defer와 async
브라우저는 HTML을 위에서 아래로 읽으면서 화면을 만든다. 읽다가 <script>를 만나면 HTML 읽기를 멈추고 스크립트를 내려받아 실행한 뒤 다시 읽는다. 그래서 <head>에 그냥 넣은 스크립트는 <body>가 만들어지기 전에 실행된다. 아직 없는 #count를 찾으니 null이 나오는 것이다.
| 작성 방식 | 내려받기 | 실행 시점 | 여러 개일 때 순서 |
|---|---|---|---|
<script src> | 즉시(HTML 파싱 중단) | 내려받는 즉시 | 작성 순서 보장 |
<script src defer> | 병렬(파싱 안 멈춤) | HTML을 다 읽은 뒤 | 작성 순서 보장 |
<script src async> | 병렬(파싱 안 멈춤) | 내려받기가 끝나는 대로 | 보장 안 됨 |
<script type="module"> | 병렬 | defer와 같음(기본 지연) | 작성 순서 보장 |
결론부터 말하면 실무에서 기본값은 defer다. 화면을 만지는 코드는 HTML이 다 준비된 뒤에 돌아야 하고, 파일이 여러 개면 순서도 지켜져야 한다. async는 다른 코드와 아무 상관 없이 혼자 도는 스크립트(방문 통계 수집 같은 것)에만 쓴다.
</body> 바로 앞에 <script>를 넣는 옛날 방식도 결과는 비슷하지만, defer는 내려받기를 HTML 파싱과 동시에 진행하므로 더 빠르다. 새로 짜는 코드라면 head + defer를 쓴다.
Node.js에서 실행하기
같은 study.js를 터미널에서 돌려 본다.
node study.js
첫 줄 로그는 찍히고 둘째 줄에서 ReferenceError: document is not defined가 난다. Node.js에는 화면이 없기 때문이다. Node용으로 고쳐 쓰면 이렇게 된다.
const members = ["김지훈", "박서연", "이도현"];
console.log("참가자 " + members.length + "명");
console.log("실행 환경:", process.version);
참가자 3명
실행 환경: v22.11.0
process는 Node.js가 얹어 주는 객체라 브라우저에는 없다. 반대로 document는 브라우저에만 있다. 이 대칭을 기억하면 된다. (const는 값을 다시 대입하지 않는 변수 선언이다. 2단원에서 자세히 다룬다.)
Node의 두 가지 모듈 방식
파일을 여러 개로 나눌 때 Node.js는 두 방식을 지원한다. 남의 코드를 읽을 때 헷갈리므로 형태만 알아 둔다.
// CommonJS — 확장자가 .js 이고 package.json 에 type 설정이 없을 때
const fs = require("fs");
module.exports = { members };
// ES 모듈 — 확장자가 .mjs 이거나 package.json 에 "type": "module" 일 때
import fs from "node:fs";
export { members };
브라우저의 <script type="module">은 아래쪽 ES 모듈 방식만 쓴다. 새 프로젝트라면 양쪽 모두 ES 모듈로 통일하는 편이 낫다. ES2022부터는 ES 모듈 안에서 함수 밖 await(최상위 await)도 쓸 수 있다.
어디서 실습할 것인가
- 브라우저 콘솔(F12 → Console) — 한두 줄 확인용. 가장 빠르다.
- HTML + JS 파일 — 화면을 다루는 7·8단원 실습은 반드시 이 방식으로 한다.
- Node.js 파일 — 2~6단원 문법 실습은 이쪽이 편하다. 저장하고
node 파일.js한 번이면 끝난다.
실무에서 자주 틀리는 것
1. head에 넣은 스크립트에서 화면 요소를 찾는다
가장 흔한 첫 오류다. defer 없이 <head>에 넣으면 document.querySelector가 전부 null이다. 그런데 여기서 진짜 문제는 대응 방식이다. 검색해서 나오는 해결책이 세 가지쯤 되는데 서로 효과가 다르다.
<!-- 방법 1: 권장 -->
<script src="study.js" defer></script>
<!-- 방법 2: 스크립트를 body 끝으로 이동 -->
<script src="study.js"></script></body>
<!-- 방법 3: 코드를 이벤트 안으로 감싸기(8단원에서 다룬다) -->
<script>
document.addEventListener("DOMContentLoaded", function () {
document.querySelector("#count").textContent = "참가자 3명";
});
</script>
방법 3은 defer를 이미 붙였다면 필요 없다. 두 개를 같이 쓴 코드를 실무에서 자주 보는데, 동작은 하지만 불필요한 중복이다.
2. async를 defer 대용으로 쓴다
둘 다 "HTML 파싱을 안 멈춘다"고 설명돼 있어서 같은 것으로 착각하기 쉽다. 하지만 async는 내려받기가 끝나는 순서대로 실행된다. 파일이 두 개면 어느 쪽이 먼저 돌지 알 수 없고, 심지어 HTML을 다 읽기 전에 돌 수도 있다.
<!-- 위험: 캐시 상태나 네트워크 속도에 따라 순서가 뒤집힌다 -->
<script src="members.js" async></script>
<script src="render.js" async></script>
이런 코드는 내 PC에서는 항상 잘 되고 남의 회선에서만 가끔 깨진다. 재현이 어려워서 원인 찾기가 특히 괴롭다. 순서가 중요하면 무조건 defer다.
3. 콘솔에서 되던 코드가 파일에서는 안 된다
브라우저 콘솔은 페이지가 이미 다 만들어진 뒤에 입력하므로 document 조회가 항상 성공한다. 콘솔에서 확인했다고 실행 시점 문제가 없는 게 아니다. 또 type="module"로 불러온 스크립트에서 선언한 변수는 그 모듈 안에서만 살아 있어서, 콘솔에 그 이름을 쳐도 is not defined가 나온다. 스크립트가 안 돈 게 아니라 범위가 다른 것이다.
4. type="module"을 file://로 연다
HTML 파일을 더블클릭해서 열면 주소가 file:///...이 된다. 일반 스크립트는 이래도 동작하지만 type="module"과 import는 보안 정책(CORS)에 막혀 실행되지 않는다. 모듈을 쓰는 순간부터는 아주 작은 정적 서버라도 띄워야 한다.
npx serve .
스스로 확인하기
<head>안에<script src="study.js"></script>를 넣고study.js에서document.querySelector("#count").textContent = "3명";을 실행했다. 어떤 오류가 나며defer한 단어로 왜 해결되는가?- 다음 코드는 브라우저에서는 동작하고
node로 돌리면 오류가 난다. 오류가 나는 줄은 어디이며 이유는 무엇인가?console.log("시작"); const box = document.querySelector("#count"); console.log(box); - 통계 수집용 스크립트
analytics.js와, 화면을 그리는render.js가 있다. 각각defer와async중 무엇을 붙여야 하며 그 이유는 무엇인가?
정답
Uncaught TypeError: Cannot read properties of null (reading 'textContent'). 브라우저가<head>의 스크립트를 만난 시점에는<body>를 아직 읽지 않아#count가 존재하지 않고,querySelector가null을 돌려주기 때문이다.defer를 붙이면 실행이 HTML을 다 읽은 뒤로 미뤄져 요소가 이미 만들어져 있다.- 두 번째 줄. Node.js에는 화면이 없어
document라는 전역 객체 자체가 없다.ReferenceError: document is not defined가 나며, 첫 줄의 "시작"은 정상적으로 출력된 뒤 멈춘다. analytics.js는async,render.js는defer. 통계 수집은 다른 스크립트나 화면과 의존 관계가 없어 아무 때나 돌아도 되고 빨리 돌수록 좋다. 반면 화면을 그리는 코드는 HTML이 완성된 뒤에, 그리고 다른 스크립트와의 순서가 지켜진 상태로 실행돼야 한다.