현록

script async와 defer 차이

HTML에서 JavaScript 파일을 불러올 때 script 태그를 사용한다.
처음에는 아래처럼 단순하게 넣는 경우가 많다.

<script src="/main.js"></script>

이 방식이 항상 틀린 것은 아니다.
하지만 script가 어디에 놓였는지, asyncdefer가 붙었는지에 따라 브라우저가 HTML을 파싱하는 흐름과 JavaScript 실행 시점이 달라진다.

초보자 입장에서는 asyncdefer가 둘 다 “빠르게 로딩하는 옵션”처럼 보일 수 있다.
핵심 차이는 다운로드가 아니라 실행 시점과 실행 순서다.

기본 script의 동작

아무 속성도 없는 일반 script는 브라우저의 HTML 파싱을 멈출 수 있다.
브라우저는 HTML을 위에서 아래로 읽다가 script를 만나면 JavaScript 파일을 가져오고 실행한 뒤 다시 HTML 파싱을 이어간다.

<head>
  <script src="/main.js"></script>
</head>
<body>
  <button id="save-button">저장</button>
</body>

이 구조에서는 /main.js를 가져오고 실행하는 동안 뒤에 있는 HTML 파싱이 지연될 수 있다.
파일이 작으면 체감하기 어렵지만, 파일이 커지거나 네트워크가 느리면 화면 표시가 늦어질 수 있다.

그래서 오래된 방식에서는 scriptbody 끝에 두는 패턴을 자주 사용했다.

<body>
  <button id="save-button">저장</button>
  <script src="/main.js"></script>
</body>

이렇게 하면 주요 HTML을 먼저 파싱한 뒤 JavaScript를 실행할 가능성이 높아진다.
다만 요즘은 defertype="module"을 함께 고려하는 편이 더 명확하다.

async의 기준

async는 외부 JavaScript 파일을 HTML 파싱과 병렬로 다운로드하게 한다.
다운로드 중에는 HTML 파싱을 계속할 수 있다.

<script async src="/analytics.js"></script>

중요한 지점은 실행 시점이다.
async 스크립트는 다운로드가 끝나는 즉시 실행된다.
그래서 실행 시점이 HTML 파싱 중간일 수도 있고, 파싱이 끝난 뒤일 수도 있다.

여러 개의 async 스크립트가 있을 때 실행 순서도 보장되지 않는다.
먼저 적힌 파일이 먼저 실행된다는 보장이 없다.
더 빨리 다운로드된 파일이 먼저 실행될 수 있다.

<script async src="/library.js"></script>
<script async src="/feature.js"></script>

feature.jslibrary.js의 전역 변수를 필요로 한다면 이 코드는 위험하다.
feature.js가 먼저 다운로드되어 먼저 실행될 수 있기 때문이다.

async는 다른 스크립트나 DOM 상태에 의존하지 않는 코드에 어울린다.
대표적으로 분석 스크립트, 광고 스크립트, 독립적인 위젯처럼 실행 순서가 중요하지 않은 코드에 적합하다.

defer의 기준

defer도 외부 JavaScript 파일을 HTML 파싱과 병렬로 다운로드하게 한다.
하지만 실행은 HTML 문서 파싱이 끝난 뒤로 미룬다.

<script defer src="/main.js"></script>

defer 스크립트는 DOM이 만들어진 뒤 실행되므로, HTML 요소를 찾는 코드와 잘 맞는다.

<head>
  <script defer src="/main.js"></script>
</head>
<body>
  <button id="save-button">저장</button>
</body>
const button = document.querySelector('#save-button')

button?.addEventListener('click', () => {
  console.log('saved')
})

이 경우 main.js는 문서 파싱이 끝난 뒤 실행되므로 #save-button을 찾을 수 있다.
script를 꼭 body 끝으로 옮기지 않아도 실행 시점을 의도할 수 있다.

defer의 또 다른 장점은 실행 순서다.
문서에 적힌 순서대로 실행된다.

<script defer src="/library.js"></script>
<script defer src="/feature.js"></script>

이 구조에서는 library.js가 먼저 실행되고, 그 다음 feature.js가 실행된다.
파일 다운로드가 병렬로 진행되더라도 실행 순서는 문서 순서를 따른다.

실행 순서 차이

asyncdefer를 구분할 때는 아래 기준이 가장 실용적이다.

방식 다운로드 실행 시점 실행 순서
기본 script 파싱 중 만난 시점에 가져옴 가져온 뒤 바로 실행 문서 순서
async HTML 파싱과 병렬 다운로드 완료 즉시 보장되지 않음
defer HTML 파싱과 병렬 HTML 파싱 완료 후 문서 순서

성능만 보면 async가 항상 더 좋아 보일 수 있다.
하지만 실행 순서가 필요한 코드에는 async가 오히려 문제를 만들 수 있다.

웹 앱의 메인 번들처럼 DOM을 다루고 다른 코드와 순서가 연결되는 파일은 보통 defer가 더 안전하다.
완전히 독립적으로 실행되는 파일은 async를 고려할 수 있다.

DOMContentLoaded와의 관계

DOMContentLoaded는 HTML 문서 파싱이 끝났을 때 발생하는 이벤트다.
정확히는 지연된 스크립트까지 다운로드되고 실행된 뒤에 발생한다.

document.addEventListener('DOMContentLoaded', () => {
  const button = document.querySelector('#save-button')
  button?.addEventListener('click', () => {
    console.log('saved')
  })
})

defer 스크립트는 DOMContentLoaded 전에 실행된다.
그래서 defer로 불러온 메인 스크립트 안에서는 단순히 DOM 요소를 찾기 위해 매번 DOMContentLoaded 리스너를 감쌀 필요가 줄어든다.

반대로 async 스크립트는 DOMContentLoaded를 기다리지 않는다.
DOMContentLoadedasync 스크립트가 끝날 때까지 기다리지 않는다.
그래서 async로 불러온 코드가 DOM 준비 상태에 의존한다면 별도 확인이 필요하다.

function setup() {
  const button = document.querySelector('#save-button')
  button?.addEventListener('click', () => {
    console.log('saved')
  })
}

if (document.readyState === 'loading') {
  document.addEventListener('DOMContentLoaded', setup)
} else {
  setup()
}

독립적인 스크립트라면 이런 확인이 필요 없을 수 있다.
하지만 DOM 요소를 조작하는 코드라면 실행 시점이 명확해야 한다.

inline script의 주의점

asyncdefer는 보통 src가 있는 외부 스크립트에서 의미가 있다.
아래처럼 인라인 스크립트에 defer를 붙여도 기대한 지연 실행이 되지 않는다.

<script defer>
  console.log('inline script')
</script>

인라인 스크립트를 뒤로 미루고 싶다면 코드를 DOMContentLoaded 안에 넣거나, 별도 파일로 분리해서 defer로 불러오는 편이 낫다.

<script defer src="/main.js"></script>

asyncdefer를 동시에 쓰면 실무에서는 의도가 흐려진다.
MDN 문서 기준으로 두 속성이 함께 있으면 async만 지정된 것처럼 동작한다.
둘 중 하나만 선택하는 편이 읽는 사람에게도 명확하다.

module script의 기본 동작

type="module"로 불러온 스크립트는 기본적으로 지연 실행된다.
문서 파싱이 끝나고 모듈과 그 의존성이 준비된 뒤 실행된다.

<script type="module" src="/main.js"></script>

그래서 모듈 스크립트에는 보통 defer를 따로 붙이지 않아도 된다.
MDN 문서도 defer가 module script에는 효과가 없다고 설명한다.

다만 type="module"async를 붙이면 이야기가 달라진다.
모듈과 의존성을 병렬로 가져온 뒤 준비되는 즉시 실행할 수 있다.
실행 순서나 DOM 준비 상태에 의존하는 코드라면 이 차이를 알고 선택해야 한다.

선택 기준

처음에는 defer를 기본 선택지로 두는 편이 좋다.
대부분의 웹 페이지 메인 스크립트는 DOM을 다루고, 여러 파일 사이의 실행 순서도 중요하기 때문이다.

<script defer src="/main.js"></script>

스크립트가 독립적이고 실행 순서가 중요하지 않다면 async를 사용할 수 있다.

<script async src="/analytics.js"></script>

코드가 다른 파일에 의존한다면 async를 피한다.
DOM 요소를 조작해야 한다면 기본 scripthead에 두기보다 defer, type="module", 또는 body 끝 배치를 고려한다.

정리하면 기준은 단순하다.
순서가 중요하면 defer다.
DOM이 필요하면 defer가 편하다.
독립적으로 빨리 실행돼도 되면 async다.

참고 자료

관련 포스트
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 renderer custom 하기 thumbnail
marked renderer custom 하기
현록을 개발하면서 티스토리에 있던 포스트들을 markdown으로 변환하여 올리고 있는데, 간혹 이미지 가로 사이즈가 viewport width보다 크면 overflow되는 현상이 있었습니다. 사실 css로 해결하였지만, styled-components 환경에서 nesting하는 것을 꺼리기도 하고, marked에서 직접 inline style을 넣는 방법이 궁금하기도 해서 찾아보았습니다.
가독성 있게 상수 넣기 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에 묶어보겠습니다.