document.querySelector("#save")가 null이라면 선택자가 틀린 것만은 아니다. 브라우저가 아직 버튼 HTML을 읽기 전인데 JavaScript가 먼저 실행됐을 수 있다.
defer는 HTML 파싱 뒤에 스크립트를 실행한다
외부 스크립트라면 head에서 defer를 쓰는 방식이 간단하다.
<head>
<script src="app.js" defer></script>
</head>
<body>
<button id="save">저장</button>
</body>defer 스크립트는 문서를 파싱하는 동안 내려받고, 파싱이 끝난 뒤 순서대로 실행된다. 따라서 app.js에서 버튼을 찾을 수 있다. 일반 스크립트를 head에 두면 다운로드와 실행이 HTML 파싱을 멈추므로 이 문제와 초기 화면 지연을 함께 만들 수 있다.
DOMContentLoaded는 DOM 준비 시점을 알려 준다
코드가 다른 방식으로 로드되거나 초기화 시점을 명시하고 싶다면 이벤트를 사용할 수 있다.
document.addEventListener("DOMContentLoaded", () => {
const saveButton = document.querySelector("#save");
if (!saveButton) throw new Error("#save 버튼이 없습니다");
saveButton.addEventListener("click", () => {
saveButton.textContent = "저장 버튼을 눌렀습니다";
});
});DOMContentLoaded는 HTML 파싱과 defer 스크립트 실행이 끝난 뒤 발생한다. 이미지 같은 모든 외부 리소스가 완료될 때까지 기다리는 load와는 범위가 다르다. 단순한 DOM 초기화에 window.onload를 쓰면 필요 이상으로 늦어질 수 있다.
위 HTML과 JavaScript를 연결했다면 버튼을 눌러 문구가 바뀌는지 확인한다. app.js에서 console.log(document.querySelector("#save") !== null)을 출력해 true인지 볼 수도 있다. 여전히 null이면 개발자 도구에서 실제 HTML의 id와 스크립트 로드 오류를 먼저 본다. defer와 이벤트 리스너를 동시에 쓸 필요는 없으므로, 페이지 구성에 맞는 한 방식을 선택하면 된다.
null을 무시하지 말고 원인을 구분한다
선택적 체이닝 ?.는 초기화 중 예외를 막지만, 필수 버튼이 없는데 조용히 기능이 사라지는 결과를 낳을 수도 있다. 필수 요소라면 개발 중에는 명시적으로 오류를 내고, 조건부 화면일 때만 존재 여부를 분기하는 편이 낫다.
동적으로 나중에 추가되는 요소는 DOM 준비와 별개다. 그 경우에는 추가하는 코드에서 이벤트를 연결하거나, 상위 요소의 이벤트 위임을 사용한다.
핵심 요약
DOM을 찾는 코드는 대상 HTML이 파싱된 뒤 실행돼야 한다. 외부 스크립트는 defer로 연결하거나 DOMContentLoaded에서 초기화하자. null을 만났을 때는 선택자·로드 순서·동적 렌더링을 차례로 구분하면 원인을 빠르게 찾을 수 있다.
작성자
기초 개념을 구현과 검증, 실제 운영 판단까지 연결해 기록합니다.

