현대 웹 앱에서 <script> 로딩에 대한 완전 가이드: async, defer, 그리고 ES Modules

발행: (2026년 1월 7일 오전 11:14 GMT+9)
14 min read
원문: Dev.to

Source: Dev.to

번역할 텍스트가 제공되지 않았습니다. 번역이 필요한 전체 내용(코드 블록을 제외한 마크다운 텍스트)을 알려주시면 한국어로 번역해 드리겠습니다.

Source:

목차

  • 스크립트 로딩 전략이 중요한 이유
  • 스크립트를 로드하는 네 가지 방법
  • 파싱, 실행 및 이벤트가 상호 작용하는 방식
  • 올바른 접근 방식 선택
  • ES 모듈 심층 분석
  • 성능 최적화 기법
  • DOM 타이밍 패턴
  • 보안 및 규정 준수
  • 흔히 발생하는 함정
  • 복사‑붙여넣기 템플릿
  • 최종 요약

스크립트 로딩 전략이 중요한 이유

스크립트 태그는 HTML 파싱을 차단하고, 인터랙티브가 지연되며, 이벤트 타이밍을 변경할 수 있습니다. 올바른 전략을 선택하면:

  • Core Web Vitals(특히 FID/INPLCP) 개선
  • 레이스 컨디션 및 깨지기 쉬운 의존성 방지
  • import/export와 코드‑스플리팅을 활용한 확장 가능한 아키텍처 구현

스크립트를 로드하는 네 가지 방법

1️⃣ 클래식 (블로킹)

  • 다운로드와 실행이 동시에 진행되는 동안 태그에서 HTML 파싱을 차단합니다.
  • 실행 순서는 HTML 순서를 따릅니다.
  • 파싱 중에 즉시 실행이 정말 필요할 때 오직 사용하세요.

2️⃣ async (클래식)

  • 병렬로 다운로드하고, 준비되는 즉시 실행합니다.
  • 실행 순서는 비결정적이며 (다운로드 완료 순서에 따라) 달라집니다.
  • 독립적인 스크립트에 이상적입니다: 분석, 광고, 비콘 등.

3️⃣ defer (클래식)

  • 병렬로 다운로드하고, HTML이 완전히 파싱된 그리고 DOMContentLoaded 에 실행합니다.
  • HTML 순서를 유지합니다.
  • 모듈을 사용할 수 없을 때 앱 코드에 가장 적합한 클래식 선택입니다.

4️⃣ ES 모듈 (type="module")

  • 엔트리 스크립트에 대해 기본적으로 defer와 유사하게 동작하며, 의존성 인식 로딩을 제공합니다.
  • 스코프가 제한되어 전역 오염 방지, strict mode, import/export, 동적 import(), 최상위 await, import map 등을 지원합니다.
  • 현대 애플리케이션의 기본 방식입니다.

파싱, 실행 및 이벤트 상호 작용

로드 유형파싱 영향실행 시점순서 보장
Classic (blocking)중단 태그에서 HTML 파싱다운로드 직후HTML 순서
async차단하지 않음 파싱스크립트 다운로드가 끝나는 즉시없음 (다운로드 순서)
defer차단하지 않음 파싱파싱이 완료된 후, DOMContentLoaded 이전HTML 순서
Module (entry)차단하지 않음 파싱파싱이 완료된 후, DOMContentLoaded 이전 (defer와 유사)의존성 그래프 순서

이벤트 시점

  • DOMContentLoaded는 모든 defer 및 모듈 엔트리 스크립트가 실행된 에 발생하고, DOMContentLoaded 이전에 시작된 async 스크립트가 완료된 에 발생합니다.
  • load는 모든 리소스(이미지, CSS 등)가 로드된 에 발생합니다.

올바른 접근 방식 선택

  • 애플리케이션 코드type="module" (기본값).
  • 모듈을 사용할 수 없는 경우의 클래식 스크립트defer.
  • 독립적인 서드파티 스크립트async.
  • 피해야 할 점 차단되는 클래식 스크립트—렌더링 및 인터랙티비티를 지연시킵니다.

ES 모듈 심층 탐구

가져오기와 내보내기

// app.js
export function init() {
  console.log('App started');
}

// main.js
import { init } from './app.js';
init();

인라인 모듈


  import { init } from '/js/app.js';
  init();

온‑디맨드 코드를 위한 동적 import()

const btn = document.querySelector('#showChart');
btn.addEventListener('click', async () => {
  const { renderChart } = await import('./chart.js');
  renderChart();
});

최상위 await

// config.js
export const cfg = await fetch('/api/config')
  .then(r => r.json());

// main.js
import { cfg } from './config.js';
console.log('Config:', cfg);

모듈 스코프와 엄격 모드

// my-module.js
const secret = 42;               // Not on window
export const api = { hello: 'world' };

// Expose globally only if you must:
window.myApi = { doSomething() { /* … */ } };

베어 스페시파이어를 위한 Import maps


{
  "imports": {
    "lodash": "/vendor/lodash-es/lodash.js",
    "@app/": "/static/app/"
  }
}

  import _ from 'lodash';
  import util from '@app/util.js';
  console.log(_.chunk([1,2,3,4], 2), util);

웹 워커에서 모듈

// main thread
const worker = new Worker('/js/worker.js', { type: 'module' });

// worker.js
import { heavyCompute } from './heavy.js';
self.onmessage = e => postMessage(heavyCompute(e.data));

성능 기법

리소스 힌트를 신중하게 사용하기

캐싱 및 번들링

  • 프로덕션 자산에 대해 콘텐츠 해시를 사용한 장기 캐싱을 적용합니다.
  • HTTP/2/3 환경에서도 번들링/청킹은 요청 오버헤드를 줄이고 캐시 적중률을 향상시킵니다.
  • 동적 import()를 활용해 크고 잘 사용되지 않는 모듈을 지연 로드합니다.

코드 스플리팅 및 지연 로딩

// router.js – load route component only when needed
export async function loadDashboard() {
  const { Dashboard } = await import('./pages/dashboard.js');
  return new Dashboard();
}

최소화 및 압축

  • JavaScript를 gzip 또는 Brotli(br) 형식으로 제공하세요.
  • 최소화를 위해 esbuild, SWC, Terser와 같은 도구를 사용합니다.

중복 작업 방지

  • 같은 스크립트를 클래식 방식과 모듈 방식으로 동시에 로드하지 않습니다.
  • 순서가 중요한 경우 서드파티 라이브러리를 하나의 번들로 통합합니다.

DOM 타이밍 패턴

PatternWhen to useExample
DOMContentLoaded listener전체 DOM가 필요하지만 외부 리소스는 필요 없는 코드document.addEventListener('DOMContentLoaded', initApp);
load listener이미지, 폰트, 기타 미디어에 의존하는 코드window.addEventListener('load', () => { /* layout calculations */ });
requestIdleCallback메인 스레드가 유휴 상태일 때 연기해도 되는 비핵심 작업requestIdleCallback(() => { prefetchData(); });
setTimeout(..., 0)마이크로‑태스크 큐 플러시; 최신 async 패턴에서는 거의 필요 없음setTimeout(() => console.log('next tick'), 0);

보안 및 컴플라이언스

  • type="module" 스크립트는 자동으로 strict mode이며 module‑scope를 가집니다 (우발적인 전역 변수 방지).
  • 제3자 스크립트에 대해 Subresource Integrity (SRI) 를 사용하십시오:
  • Content‑Security‑Policy (script-src) 를 설정하여 신뢰할 수 있는 출처를 허용하고 unsafe-inline 을 금지합니다.
  • 필요한 경우 인라인 모듈에 대해 nonce 또는 hash 기반 CSP 를 선호합니다.

Common pitfalls

  1. 동일 스크립트에 asyncdefer를 혼용 – 마지막 속성이 적용됩니다; 혼동을 피하세요.
  2. 모듈에서 전역 변수를 의존 – 모듈은 스코프가 제한됩니다; 필요한 것만 export로 노출하세요.
  3. 동적으로 생성된 <script> 요소에 type="module"을 추가하는 것을 잊음 – 기본값은 클래식 스크립트입니다.
  4. 모듈 로딩이 시작된 후 document.write 사용 – 일부 브라우저에서 모듈 로딩을 중단시킬 수 있습니다.
  5. modulepreload가 모든 브라우저에서 동작한다고 가정 – 구형 브라우저용 대체 <script>를 제공하세요.

복사‑붙여넣기 템플릿

클래식 차단 (드물게 필요)

비동기 서드파티

클래식 앱 번들 지연 로드

ES 모듈 진입점

CSP 논스를 사용한 인라인 모듈


  import { init } from '/js/app.js';
  init();

임포트 맵 (HTML5)


{
  "imports": {
    "react": "/libs/react/react.production.min.js",
    "react-dom": "/libs/react/react-dom.production.min.js"
  }
}

최종 요약

  • ES 모듈을 기본으로 사용하세요; 새로운 코드라면 모두 ES 모듈을 기본 선택하십시오. 이는 defer‑와 같은 로딩, strict 모드, 그리고 깔끔한 의존성 그래프를 제공합니다.
  • **defer**는 모듈을 지원하지 않는 환경에서 클래식 스크립트를 지원해야 할 때만 사용하십시오.
  • **async**는 DOM 준비나 순서가 필요 없는 완전히 독립적인 서드파티 리소스에만 사용하십시오.
  • 올바른 로딩 전략을 리소스 힌트, 캐싱, 그리고 CSP/SRI와 결합하여 성능과 보안을 극대화하십시오.

적절한 스크립트 로딩 기법을 의식적으로 선택함으로써 더 빠르고, 더 안정적이며, 더 안전한 웹 경험을 제공할 수 있습니다.

실행 순서와 예시

async vs defer

모듈 및 의존성 순서

// entry.js
import './a.js'; // a.js imports b.js
console.log('entry');

// a.js
import './b.js';
console.log('a');

// b.js
console.log('b');

실행 순서: b → a → entry

DOM‑Timing 패턴

defer / 모듈을 사용한 안전한 DOM 접근

// app.js
document.querySelector('#btn').addEventListener('click', () => {
  console.log('clicked');
});

DOM을 기다려야 하는 비동기 스크립트

// metrics.js
window.addEventListener('DOMContentLoaded', () => {
  // Safe to query the DOM
});

보안 및 컴플라이언스

헤더, CORS, MIME

  • JavaScript를 올바른 MIME 타입 application/javascript 로 제공하십시오.
  • ES 모듈은 CORS를 따릅니다; 교차 출처 가져오기는 적절한 CORS 헤더가 필요합니다.
  • 모듈에 file:// URL 사용을 피하고 로컬 HTTP(S) 서버를 사용하십시오.

CSP 및 서브리소스 무결성 (SRI)

Enterprise note: 서드파티 CDN, 라이브러리 또는 분석 도구를 추가하기 전에 조직의 보안, 프라이버시 및 컴플라이언스 가이드라인에 부합하는지 확인하십시오.

일반적인 함정

브라우저 import에서 .js 확장자 누락

// ❌ Wrong
import { x } from './utils';

// ✅ Correct
import { x } from './utils.js';

Import‑map 위치





기타 주의사항

  • async가 순서를 보장한다는 가정. 실제로는 그렇지 않으니, 순서를 기대하며 여러 async 스크립트를 연결하지 마세요.
  • 널리 공유되는 모듈에서 최상위 await를 사용하면 전체 앱이 지연될 수 있습니다. 가능한 경우 지연 로딩을 선호하세요.

복사‑붙여넣기 템플릿

모듈이 포함된 최신 앱 + 레거시 폴백


  
  Modern Module App
  

  

  
  {
    "imports": {
      "@app/": "/js/"
    }
  }
  

  
  

defer가 있는 클래식


  
  Classic Defer

  Click

  
  

독립적인 서드파티 스크립트를 위한 Async


  
  Async Example
  

  

최종 요약

  • **type="module"**을 선호하세요: 현대적인 개발을 위해 확장 가능한 아키텍처, 안전한 스코핑, 그리고 내장 코드 스플리팅을 제공합니다.
  • 모듈 사용이 어려운 경우 클래식 스크립트에 **defer**를 사용하세요.
  • 독립적이며 순서에 구애받지 않는 스크립트에만 **async**를 사용하세요.
  • 이벤트 타이밍, 캐싱, 보안(CSP, SRI, CORS)을 유념하세요.
  • 앱이 성장함에 따라 modulepreload, 동적 import, import map을 활용하세요.

서드파티 CDN이나 라이브러리를 사용할 계획이라면, 해당 서비스가 조직의 내부 보안 및 컴플라이언스 요구사항을 충족하는지 확인하세요.

Back to Blog

관련 글

더 보기 »

초보자를 위한 JavaScript DOM 설명

DOM이란 무엇인가요? DOM은 Document Object Model의 약자입니다. 이것은 JavaScript가 다음과 같은 작업을 할 수 있는 HTML 문서의 트리‑구조와 같은 표현입니다: - 읽기 - 변경하기 - 추가하기 - 제거하기