CSS 임포트 방식은 성능에 얼마나 영향을 줄까? @import는 사용해선 안 될까?

,

CSS 코드 냄새 1편2편은 CSS 개발자들에게 큰 도움이 되는 콘텐츠입니다.

그런데 최근 2편을 보면서 하나 의문이 든 게 있었습니다. CSS의 @import 구문을 사용하지 말라는 규칙이었습니다. 아래는 원문을 그대로 번역한 것입니다.

CSS @import

CSS @import는 단순히 코드 냄새가 아니라 실제로 문제를 일으키는 나쁜 사례라고까지 말하고 싶습니다. 중요한 자산인 CSS의 다운로드를 필요 이상으로 지연시킨다는 점에서 성능에 큰 불이익을 초래합니다. …

만약 @import를 [사용하는 대신 CSS들을 — 역자] 하나의 파일로 병합했다면 …

모든 CSS를 하나의 파일로 합칠 수 없는 경우엔(예컨대 Google 글꼴을 연결한 경우) HTML에 @import 대신 두 개의 <link /> 요소를 사용해야 합니다. 이렇게 하면 … 성능 측면에서는 훨씬 더 좋습니다.

위 글의 핵심은 두 가지인데요.

  1. 모든 CSS를 하나의 파일로 합치는 것이 성능상 가장 좋다.
  2. @import보다 <link /> 태그가 성능상 더 낫다.

단순한 의문이 들었습니다. 브라우저들이 @import를 처리하는 방식이 여전히 2017년의 방식에 머물러 있을까? 이것은 코딩 규약에 대한 이야기가 아니라 기술에 대한 이야기인데 6년 반 전의 이야기가 여전히 타당할까?

어떤 권장사항은 기술 발전에 따라서 달라지곤 합니다. 예컨대 이미지 스프라이트 기술은 과거엔 강력히 권장됐지만 지금은 그 정도는 아닙니다.

HTTP 1.1에서는 자원 하나당 연결을 하고 끊고 해야 했기 때문에 다운 받는 자원의 수를 늘리는 것이 부담이었습니다. 그러나 HTTP/2와 HTTP/3에서는 한 번에 다운받을 수 있는 자원의 수가 훨씬 늘어났기 때문에 성능상의 부담이 훨씬 줄어들었습니다.

병렬 다운로드를 할 수 있다면 @import 구문의 권장사항도 달라지지 않을까요?

결론부터 말하자면

결론부터 말하자면 @import냐, <link>냐, 하나로 모두 합친 CSS냐는 렌더링 성능(시작 이나 완료 시간)에 영향을 주지 않습니다.

병렬 다운로드를 해도 하나씩 다운로드할 때와 별 차이가 없었습니다. 어차피 다운로드가 전부 되지 않으면 렌더링을 시작하지 않기 때문입니다.

즉, 렌더링 성능에 영향을 미치는 요소는 CSS의 용량과 적용 시점입니다. CSS가 별도 조처 없이 들어가 있는 한, 렌더링은 CSS가 모두 다운로드된 다음에 시작됩니다(크롬, 엣지, 파이어폭스, 사파리 공통). CSS 파일의 용량이 같고, 별도 처리해 CSS가 비동기적으로 적용되게 하지 않는다면 @import를 하든 <link>를 여러 개 하든, 한 파일로 합치든 렌더링 시작 시점은 별 차이가 없었습니다.

따라서 렌더링 성능을 높이려면 CSS의 용량을 줄이는 것이 가장 효과적입니다.

그게 아니면 핵심 CSS만 미리 적용시켜 체감 성능을 높이는 방법이 있습니다. 핵심 CSS 외 나머지는 비동기로 로딩하는 방법입니다. 핵심 CSS를 <head> 안에 포함해 전체 CSS가 로드되기 전에도 사이트가 깨지지 않게 한 뒤 콘텐츠를 로딩하고, 남은 CSS는 비동기적으로 로딩하는 것입니다. 그러면 전체 CSS를 다운로드하기 전에 렌더링이 시작되기 때문에 체감 성능이 높아집니다. 아래 동영상이 있으니 참고해 보세요.

E-mc2 written on chalkboard
용량과 대역폭이 동일하면 속도도 동일할 수밖에 없습니다. (사진 JESHOOTS.COM, Unsplash)

실험

이를 위해 실험용 코드를 만들었습니다.

  1. 저용량 CSS 1개에 대용량 CSS 10개를 import로 넣은 경우
  2. 대용량 CSS 1개에 대용량 CSS 9개를 import로 넣은 경우
  3. 대용량 CSS 10개를 link로 연결한 경우
  4. 대용량 CSS 10개를 파일 하나로 합쳐서 넣은 경우
  5. [비동기 로드] 대용량 CSS 10개를 preload 처리한 link로 연결한 경우(크롬, 엣지)
  6. [비동기 로드] 대용량 CSS 10개를 닫는 body 태그 앞에 둔 경우(크롬, 엣지, 파이어폭스 호환)
  7. [비동기 로드] 대용량 CSS 10개를 ajax로 로드(사파리까지 호환)

컴퓨터의 인터넷 속도를 운영체제 차원에서 10Mbps(1.25MB/s)로 제한한 뒤, 개발자 도구의 네트워크 탭을 열어서 캐시를 사용하지 않게 설정하고 렌더링 시작 시점을 쟀습니다.

CSS는 용량을 매우 큰 것으로 11개 준비했습니다. 이미지를 base64로 인코딩해서 CSS에 넣은 것인데요. CSS 하나가 로딩될 때마다 이미지가 하나씩 표시되도록 설계했습니다. CSS 파일의 총 용량은 6.8MB였습니다.

가설은 이랬습니다.

  • @import 구문으로 넣은 CSS는 모든 CSS 파일을 다운받은 후 렌더링할 것이므로 렌더링 시점이 모든 CSS를 다운받은 시점으로 늦춰질 것이다.
  • 모든 CSS를 하나로 합쳐 <link>로 넣은 CSS 파일은 @import와 비슷할 것이다. @import 구문도 병렬 다운로드를 하므로 성능차가 별로 없을 것이기 때문이다.
  • <link>로 여러 개 넣은 CSS는 각 CSS를 다운받은 시점에 렌더링을 시작할 것이다. 따라서 가장 빠를 것이다.
  • 2017년과 달리 여러 개의 <link> CSS가 가장 성능이 좋을 것이다.

결과는 예상밖이었습니다. 1~4까지 모두 비슷비슷한 렌더링 성능을 보인 것입니다. CSS를 어떻게 넣든 모든 CSS를 다운받기 전까지 렌더링은 시작되지 않았습니다. 즉, 전체 CSS 용량과 인터넷 대역폭 두 가지가 가장 중요한 요인이었던 것입니다.

그래서 5~7번 실험을 추가했습니다. CSS를 다운받는 대로 곧장 렌더링을 시작하는 방법을 찾으려고 한 것입니다. 5번 실험은 아래에서 볼 수 있듯이 렌더링 시작 시점이 매우 빨랐습니다.

5번 실험 이후 6번 실험을 추가했습니다. 5번 방법은 크롬, 엣지 같은 블링크 엔진 브라우저에서만 작동했기 때문입니다. 6번 방법은 파이어폭스에서도 작동했습니다. 그러나 사파리에서는 5, 6번 방법 모두 의미가 없었고 다른 경우와 성능이 비슷하게 나왔습니다.

그래서 7번 ajax 비동기 로딩 방식까지 찾았습니다. 이렇게 하면 사파리에서도 체감 성능을 높일 수 있었습니다.

아래에서는 우선 해리 로버츠가 비판한 1번과 추천한 4번을 비교해 보겠습니다. 아래 영상에서 각각 보시죠.

1번, @import 구문 사용

우선 1번, 11개의 대용량 CSS를 @import 구문으로 넣은 경우입니다. 로딩에 7초가 걸렸습니다.

오른쪽 맨 밑에 보면 초가 올라갑니다.

4번, 커다란 하나의 CSS

아래는 4번 사례입니다. 10개의 CSS를 하나의 큰 번들 파일(6.4MB)로 묶고 맨 마지막 사진 하나만 별도의 CSS 파일(400KB)로 넣은 경우죠.(뒤에 링크한 400KB짜리 CSS가 먼저 로드될 가능성을 염두에 두고 그렇게 한 것인데 아무 효과가 없었습니다.)

로딩에는 7.84초가 걸렸습니다. 앞선 7초보다 오히려 늦는다고 생각할 수 있는데 여러 번 테스트하면 앞서거니 뒷서거니 합니다. 즉, 큰 의미가 없는 차이입니다.

5번, preload와 JS의 결합

마지막으로 체감성능이 가장 좋았던 경우입니다. preload 속성을 넣고, 로드가 다 되면 CSS link 태그로 추가하도록 JS를 넣은 코드입니다. 코드는 아래와 같습니다.

<link rel="preload" href="./big01.css" as="style"
onload="this.onload=null;this.rel='stylesheet'">

onload가 중요한데요. 로드가 다 되면 우선 onload를 없애고, this.relstylesheet를 넣는 것입니다. 이러면 CSS 파일 하나가 로딩되는대로 곧장 화면에 렌더링이 됩니다. 전체가 다 렌더링되는 데는 비슷한 시간이 걸리지만 체감상은 더 빠릅니다. 로딩이 되는 과정을 지켜보기 때문입니다.

화면 전체의 렌더링이 완료되는 속도는 비슷하지만 DomContentLoaded 이벤트가 발동하고 첫 CSS 파일이 렌더링되는 시각은 압도적으로 빠른 것을 보실 수 있습니다.

단, 이 코드는 크롬, 안드로이드 크롬, 엣지 같은 블링크 계열 브라우저와 iOS 크롬에서만 원하는 효과를 냅니다(신기하게도 iOS 크롬은 웹킷 엔진을 사용하는데도 다른 블링크 엔진 브라우저들처럼 비동기 로딩이 작동했습니다. 커스텀을 한 듯하네요). 그래서 실험한 게 6번 방법인데, 이 방법은 블링크 계열뿐 아니라 파이어폭스까지는 효과를 봤지만 역시 사파리와 모바일 사파리에서는 작동하지 않았습니다. 사파리와 모바일 사파리까지 유효한 방법은 7번 ajax 방식이었습니다.

7번, ajax 방식 – 사파리에도 유효

모바일 사파리에서는 7번 ajax 방식으로 CSS를 불러왔을 때 체감 성능을 높일 수 있었습니다. 아래는 ajax 방식으로 CSS를 불러오는 코드입니다.

function loadCSSAsync(url) {
fetch(url)
.then(response => {
if (response.ok) {
return response.text();
}
throw new Error('Network response was not ok.');
})
.then(css => {
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
})
.catch(error => {
console.error('Failed to load CSS:', error);
});
}

loadCSSAsync('./big01.css');
// ...

아래는 좀 제한된 테스트입니다만, 7번 방식으로 했을 때 체감상 로딩이 빠른 것을 보실 수 있습니다. 7번 방식은 시작하자마자 이미지들이 로딩되기 시작합니다. 나머지는 이미지가 모두 비어있다가 한꺼번에 로딩됩니다. 새로고침 타이밍을 잘 알아채기 힘드실 텐데요. 화면이 공백이 된 다음 바로 새로고침 눌렀다고 생각하시면 됩니다.

결론

용량이 같으면 기본 방법으로는 어떻게 CSS를 넣든 별 차이가 없었습니다. @import든, <link> 여러 개든, 하나로 묶어 <link>로 가져오든 용량이 줄지 않으면 의미가 없었습니다. 렌더링은 거의 비슷한 시간에 완료됐습니다.

HTTP/3의 병렬 다운로드도 유의미한 차이를 가져오지 않았습니다. 인터넷 속도가 제한된 상황에서는 병렬 다운로드하든 하나씩 다운로드하든 유의미한 차이가 발생하지 않을 것이기 때문입니다.

따라서 CSS 용량 자체를 줄이는 것이 첫 방문시 렌더링 성능을 높이는 가장 좋은 방법입니다. 그런 점에서 Utility-First나 OOCSS 같은 방법론이 정당성을 얻을 수 있겠습니다. 두 방법론 모두 CSS 용량을 줄여 주니까요.

CSS 용량을 당장 줄이기 힘들 때 체감 성능을 높이는 방법은 코어 CSS를 <head> 영역에 넣고 나머지를 비동기로 로딩하는 것입니다. 이러면 체감 성능을 높일 수 있습니다.

그런데 비동기 로딩 방식을 쓰는 것은 꼭 선택하지 않아도 된다는 생각입니다. 개발 효율 때문인데요.

코어 CSS를 고르는 방법을 자동화하는 게 관건일 텐데 이게 시간이 꽤 들 것 같거든요(구글 Lighthouse 테스트 결과에서는 자동화된 툴(addyosmani/critical)을 소개해 주지만 꽤 오래된 툴입니다). 버그도 많이 유발할 것 같고요.

그래서 들이는 노력 대비 효과가 클지 모르겠습니다. 아무리 큰 CSS도 압축 전송하면 1MB 미만이고, 제가 관리하는 사이트는 25.5KB밖에 안 됩니다. 이걸 위해서 커다란 노력을 들이는 게 저로선 효과적이란 생각이 들진 않습니다. 사용자가 엄청나게 많고 CSS 용량이 꽤 나가는 사이트라면 시도해 볼만 하겠지요.

그냥 일반으로 CSS 용량을 줄이는 게 더 편리한 접근이 아닐까 생각합니다.


부록

전체 테스트 결과표

아래는 맥에서 Edge 브라우저로 각 5회 테스트한 결과입니다(Edge는 구글 크롬과 마찬가지로 Blink 엔진을 사용합니다). 5번 preload 테스트는 DOMContentLoaded 이벤트 발동 시각도 적었습니다. 나머지는 DOMContentLoaded 이벤트 발동 시각이 화면 렌더링 시각과 큰 차이가 없었습니다.


12345평균
대용량 CSS 10개를 import로 넣은 경우6.506.676.947.477.116.94
대용량 CSS 1개에서 대용량 CSS 9개를 import로 넣은 경우6.226.626.826.706.746.62
대용량 CSS 10개를 link로 연결한 경우6.249.506.787.046.597.23
대용량 CSS 10개를 파일 하나로 합쳐서 넣은 경우6.467.637.156.819.117.43
대용량 CSS 10개를 preload 처리한 link로 연결한 경우7.606.409.606.526.067.24
대용량 CSS 10개를 preload 처리한 link로 연결한 경우의 DOMContendLoaded 이벤트 발동 시간0.5941.080.9601.240.6630.91
6.8MB CSS 용량, 10Mbps 인터넷 속도로 렌더링이 완료되는 데 걸린 시간(단위: 초)

👉 파이어폭스, 사파리 실험 결과까지 있는 구글 스프레드시트 링크

전체 테스트 영상

아래는 1~5까지 5회씩 엣지로 테스트하는 영상입니다.

👇 카테고리 글 목록

,

대표글

댓글 남기기