프론트엔드 개발에서 '로딩 인디케이터(Loading Indicator)'를 활용하는 경우는 아주 흔합니다. 비동기 데이터 요청과 같이, 완료되기까지 약간의 시간이 필요한 작업이 수행되는 상황이 대표적입니다.
사용자로 하여금 무언가 진행되고 있으니 잠시 기다려야 한다는 것을 인지시키고, 작업을 끝까지 수행할 수 있도록 유도하여 더 나은 사용자 경험(UX)을 구현할 수 있습니다.
로딩 스피너(Loading Spinner), 로딩 바(Loading Bar), 로더(Loader), 프로그레스 인디케이터(Progress Indicator) 등 사용하는 목적이나 구체적인 형태에 따라 용어가 조금씩 달리 혼용되지만, 이 글에서는 포괄적인 '로딩 인디케이터'로 통칭하겠습니다.
모든 동작들이 지연을 체감하기 어려울 정도로 충분히 빠르다면, 로딩 인디케이터가 오히려 역효과를 야기할 수 있으므로 항상 사용할 필요는 없습니다. 이상적으로는 로딩 인디케이터가 아예 필요하지 않은 것이겠습니다만, 현실적으로는 필요한 상황이 꽤 있습니다.
필자의 경우에도 위에서 언급한 데이터 페칭과 같은 상황이나, 다른 외부 페이지로 이동하는 시점에서 아래와 같이 몇 개의 이미지로 구성된 GIF 포맷의 애니메이션 로딩 인디케이터를 사용한 적이 있습니다.
그런데 한 가지 특이한 현상을 발견하게 되었는데, 어째서인지 iOS의 사파리 브라우저에서만 로딩 인디케이터의 애니메이션이 중단되어 하나의 정지된 프레임(컷)만 노출되는 것이었습니다.
정확하게는 모든 상황에서 그런 것은 아니고, 페이지 전환이 시작되는 시점(현재 페이지를 떠나기 시작했을 때, beforeunload
시점)부터 이러한 현상이 발생했습니다.
설명을 위해 간단히 만든 데모 페이지를 예시로 들어보겠습니다. 아래는 인위적으로 지연 시간을 추가하여 재현한 상황으로, form
에서 submit
이벤트가 트리거되고 로딩 인디케이터를 토글한 직후에는 애니메이션이 잘 재생되다가, 이동할 페이지의 요청이 시작되는 순간 정지되는 모습을 확인할 수 있습니다.
페이지가 새로고침되지 않는 상황, 즉 SPA Loading
에서는 문제가 없기도 하고, 당장 큰 일이 날 정도(?)의 크리티컬한 이슈까지는 아니었습니다. 하지만 로딩 인디케이터란 기본적으로 애니메이션이어야 하고, 정적(Static)인 인디케이터는 사용자 경험 측면에서 금기시됩니다.
때문에 차라리 아예 노출을 안 하면 안 했지, 정지된 프레임을 보여주는 건 바람직하지 않으니 어쨌든 해결을 하긴 해야 했는데, 의외로 금방 문제의 원인이 짚어지지 않아 꽤나 고민을 해야 했습니다.
이번 글에서는, 이때 시도했던 내용들을 되짚어보면서 브라우저의 렌더링에 대해 생각해보고자 합니다.
웹 개발에서 크로스 브라우징 이슈는 필연적입니다. 동일한 HTML
, CSS
, JavaScript
라고 해도, 모든 브라우저에서의 동작이 일관적이지는 않기 때문입니다.
이는 웹 브라우저라는 프로그램의 코어를 구성하는 '엔진'이 제각기 다른 데다, 같은 엔진이라도 구체적인 구현의 차이가 존재하기 때문에 발생하는 현상입니다.
'웹 표준'이라는 구심점, 공유되는 철학이나 설계/규격 같은 것이 존재하기는 하지만, 그것을 해석하는 방향까지 완벽하게 일률적일 수는 없고, Vendor Prefix
의 사례에서처럼 같이 브라우저마다 어떤 실험적인 기능을 구현하는 경우도 있기에 더욱 그렇습니다.
악명 높던 IE
가 일단은 공식적으로 지원이 종료된 이후 데스크탑 플랫폼에서는 부담이 줄어든 상황이지만, 정작 모바일 플랫폼의 경우에는 여전히 문제가 되는 사례가 많습니다.
바로 iOS의 Safari
브라우저가 그러한데, 특히 렌더링 엔진의 차이로 인해 CSS와 관련한 이슈가 유달리 잦은 편입니다.
현재를 기준으로 대부분 Blink
엔진을 기반으로 하는 Chromium
계열의 메이저 브라우저들과는 달리(물론 Firefox
같은 경우는 Gecko
엔진입니다), Safari
는 WebKit
엔진인데다, iOS
와 iPadOS
에서는 서드파티 브라우저들도 WebKit
을 사용하도록 강제하고 있기 때문에, 결과적으로 모바일 플랫폼에서는 이러한 두 엔진을 고려하면서 접근합니다.
Chrome
의 Vendor Prefix가-webkit-
으로 사파리와 동일한 점에서 알 수 있다시피blink
역시 처음에는WebKit
에서 포크된 엔진이었기는 합니다만, 호환성 이슈를 피하고 굳이 새로운 접두사를 추가하지 않기 위해-webkit-
을 유지하고 있을 뿐 현재는 서로 별개입니다.
https://www.chromium.org/blink/developer-faq/
(일부 웹 개발자들은 사파리를 두고 '럭키 IE' 나 '새로운 IE' 라고 비꼴 정도이니, 그만큼 고통의 지점으로 작용하는 부분인 셈입니다. 필자 역시 비슷한 이유로 그다지 좋아하지 않습니다.)
Web Platform Test, WPT의 데이터를 확인해 보면, 사파리의 경우 웹 표준을 구현하지 않아 테스트에 실패한 수가 다른 브라우저보다 훨씬 많습니다.
어쨌든 기본적인 조건이 동일한 상황에서, 사파리에서만 애니메이션이 재생되지 않는다고 하면 뭔가 '브라우저 엔진의 차이겠지?' 라고 넘겨짚어 볼 수는 있겠지만, 구체적인 기전까지는 알 수 없었습니다.
WebKit
의 Bugzilla 리포트를 보면 무려 2007년에도 보고된 바 있는 유서 깊은(?) 이슈인데, 버그가 재현되지 않는다고 하여 딱히 해결되지는 않은 채 완료 처리되었음을 알 수 있습니다.
관련 키워드로 구글링을 해 보면 StackOverflow에도 비슷한 질문들이 꽤 많은데(아래 인용한 링크는 극히 일부입니다), 역시 명쾌한 해답을 찾을 수는 없었습니다.
버그인지, 아니면 의도된 구현인지 알 수 없는 상황이다 보니, 결국은 직접 알아보는 수밖에 없겠다고 생각했습니다.
가장 먼저 의심을 한 것은 이미지의 포맷, 즉 GIF
(정확하게는 Animated GIF
)였습니다.
GIF
는 웹에서 '움짤'을 대표하는, 여전히 압도적으로 널리 사용되는 포맷이긴 하지만, 256색(8비트)밖에 지원하지 못하는 것은 물론 압축 효율도 낮은 구시대의 산물입니다.
당연히 성능이나 컴퓨팅 리소스 효율도 매우 좋지 않은데, GIF
는 기본적으로 GPU 가속을 활용하지 못하고 오직 CPU만을 사용하기 때문에 오버헤드가 엄청납니다. 일각에서는 GIF
를 사용하는 것 자체가 잘못되었다는 얘기가 나올 정도인데, 그렇다 보니 이런 점은 분명히 애플이라면 극도로 싫어할 만한 포인트이지 않을까 하는 생각이 들었습니다.
비단 애플이 아니어도, 실제로 브라우저에 따라 최적화 목적으로 GIF
의 프레임을 제한하는 경우도 있었다 보니 가능성이 존재할 수도 있겠다고 보았습니다.
그래서 일단 이미지를 다른 걸로 바꿔보자는 생각에, APNG
, WebP
, AVIF
세 가지의 포맷으로 변환했습니다.
caniuse
에 따르면 지원하는 iOS 버전은 APNG
의 경우 8, WebP
는 14, AVIF
는 16.4 이므로 모두 정상적인 테스트가 가능한 범위였습니다.
하지만 아쉽게도 결과는 모두 실패였고, 어떤 포맷의 이미지도 문제는 동일하게 발생했습니다.
스프라이트(Sprite)란 컴퓨터 그래픽스 용어로, 여러 개의 이미지를 하나의 시트로 구성한 것을 말합니다.
아이콘과 같은 작은 이미지들을 여러 번 요청하지 않고, 하나의 이미지로 포함하여 한 번만 요청하는 식으로 동시 병렬 요청 수의 한계가 있는 HTTP/1.1
프로토콜에서 활용된 최적화 테크닉으로도 잘 알려져 있습니다.
사실 로딩 인디케이터로 이미지를 사용하는 것 자체가 넌센스가 아니었을까 하는 생각이 들기는 했습니다만, 그렇다고 로딩 인디케이터를 처음부터 새로 디자인하는 것은 조금 더 번거로운 일이 되므로, 일단은 가능하면 지금의 형태를 유지하고자 했습니다.
어쨌든 방법은 간단합니다. 아래와 같이 로딩 인디케이터 이미지를 구성하던 프레임들을 하나씩 나열해 스프라이트 시트를 만들고,
CSS에서 이미지의 위치, 오프셋을 변경시켜 각 스프라이트 이미지를 프레임으로 사용하는 애니메이션을 구현하는 식입니다.
<div class="loading-indicator-wrapper">
<div class="loading-indicator-content"></div>
</div>
.loading-indicator-wrapper {
position: fixed;
display: flex;
justify-content: center;
align-items: center;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.loading-indicator-content {
display: block;
width: 75px;
height: 75px;
background-image: url("/images/sprite.png");
background-repeat: no-repeat;
background-size: auto 100%;
animation: sprite 1.5s steps(5) infinite;
}
@keyframes sprite {
from {
background-position: 0px;
}
to {
background-position: -375px;
}
}
위 CSS에서 애니메이션 시퀀스를 제어하는 @keyframes
를 보면, background-position
이 0px 에서 시작해(from, 0%
) -375px에서 종료되도록(to, 100%
) 선언되었음을 알 수 있습니다.
75x75 사이즈의 이미지 5개를 사용하여 스프라이트 시트를 구성했으므로 전체 width
는 75 * 5 = 375px가 되고, 왼쪽에서 오른쪽으로 슬라이드되어야 하므로(시트를 왼쪽으로 이동시켜야 하므로) 음수 값으로 설정합니다.
animation
속성은 속기(shorthand) 형태로 작성된 것인데, sprite
는 animation-name을, 1.5s
는 animation-duration을, steps
는 animation-timing-function을, infinite
는 animation-iteration-count를 가리키며, 이는 'sprite 애니메이션을 1.5초에 걸쳐 5단계로 무한 반복하여' 진행한다는 것을 의미합니다.
하지만 이 방식 역시 통하지 않았고, 여전히 애니메이션은 재생되지 않았습니다.
어쩐지 아쉬운 느낌이 들어, 이미지를 base64
로 인코딩한 다음 Data URL의 형태로 CSS에 직접 삽입하는 방법도 시도해 보았지만 역시 실패였습니다.
인라인으로 포함해 외부 리소스로 간주되지 않게 하면, 브라우저의 렌더링 최적화 동작의 대상이 되지 않고 회피할 수 있는 여지가 있지 않을까 하는 생각이었지만 어림도 없었습니다.
background-image: url("data:image/png;base64,iVBORw0KG...");
딱히 의미는 없을 거라고 생각했지만, 어쨌든 DOM 업데이트가 발생하면 무언가 변경이 있지 않을까 하는 추측도 해 보았습니다.
잘 알려진 이 문서를 참고하여 reflow(layout)
를 강제로 트리거하는 방법을 시도해 보았습니다만, 역시 변화는 없었습니다.
사실 모든 것이 중단된 상황이니, 코드도 실행되지 않을 것이므로 애초에 가설을 검증할 수 없었을 것입니다.
// 이런 식이나
element.offsetHeight;
// 이런 식으로
element.style.width = `${element.offsetWidth}px`;
이번에는 ChatGPT
에게 이 문제에 대해 설명하고 도움을 얻어 보기로 했는데, 꽤 그럴싸한 트릭을 제시해 주었습니다. 바로 CSS의 의사 요소(psuedo-element)를 사용해 보라는 것이었습니다.
몇 번의 문답을 주고받아 보니, GPT는 이 문제가 배터리 등의 리소스 절약을 위한 사파리 브라우저(엔진) 특유의 렌더링 최적화 동작에 의한 것이며, 이것을 회피할 수 있을 만한 방법들을 시도해 보아야 한다고 했습니다.
요컨대 순수히 CSS로만 구현한다면 최적화를 우회할 가능성이 있다는 논리였는데, 의사 요소는 HTML이 아닌 CSS에 의해 추가되는 요소이자 실제 DOM Tree에는 존재하지 않는, 특수하고 독립적인 요소이므로 렌더링에서 무언가 차이가 있을 것이라는 추론이었습니다.
먼저 구현했던 내용에서 아래와 같이 .loading-indicator-content
의 CSS만 약간 수정해주면 됩니다.
<div class="loading-indicator-wrapper">
<div class="loading-indicator-content">
<!-- pseudo-element -->
::before
</div>
</div>
.loading-indicator-content::before {
content: '';
display: block;
width: 75px;
height: 75px;
background-image: url("/images/sprite.png");
background-repeat: no-repeat;
background-size: auto 100%;
animation: sprite 1.5s steps(5) infinite;
}
/* @keyframes ... */
가능성이 있는 솔루션이라고 생각했습니다만, 아쉽게도 여전히 문제는 해결되지 않았습니다. GPT는 이후로도 'CSS가 안 되면 JS로 해 보라'며 로딩 인디케이터 요소를 동적으로 제거/추가하여 DOM에 강제적인 변경을 유발하는 등의 방법도 제시해 주었습니다만, 위에서 이미 비슷한 방법을 시도해 보았듯 효과는 없었습니다.
이제는 이미지를 포기하는 방안도 고려해야 했는데, 통상적인 로딩 인디케이터들이 그렇듯 그냥 CSS와 SVG 등으로 구현하는 것도 단순하고 깔끔한 해결책이 될 수 있겠다고 보았습니다.
애니메이션 자체가 문제였다면 이것도 방법이 아니었겠지만, 어쨌든 이미지만 사용하지 않는다면 정상적으로 애니메이션이 재생되는 현상을 확인할 수 있었습니다.
<div class="loading-indicator-wrapper">
<span class="loading-indicator-spinner"></span>
</div>
.loading-indicator-wrapper {
position: fixed;
display: flex;
justify-content: center;
align-items: center;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.loading-indicator-spinner {
display: block;
width: 56px;
height: 56px;
border: 8px solid rgb(59, 130, 246);
border-bottom-color: transparent;
border-radius: 50%;
box-sizing: border-box;
animation: rotation 1s linear infinite;
}
@keyframes rotation {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
하지만 역시 이미지를 포기하기에는 아쉬웠기 때문에 조금 더 방법을 생각해 보기로 했는데, 이전의 테스트에서 아직 의심하지 않은 부분이 남아 있었습니다.
'사용한 CSS 속성에 뭔가 문제가 있지 않았을까?' 하는 생각이었고, 결론적으로는 그게 정답이었습니다.
자세한 내용은 아래에서 이어집니다.
'이미지가 문제이고, 애니메이션 자체는 문제가 아닐 것이다'라는 판단이 사실 틀린 것이었습니다.
그동안 테스트를 진행했던 흐름상 이미지를 사용한 경우에만 문제가 있었고, 애니메이션은 어떤 방식으로 구현해도 영향이 없었기 때문에 자연스럽게 그렇게 판단하게 되었지만, 구현 방법에 따라 렌더링 매커니즘이 다를 수 있겠다는 점을 생각했어야 했습니다.
일단 먼저 HTML/CSS가 어떻게 변경되었는지 살펴보겠습니다.
<div class="loading-indicator-wrapper">
<div class="loading-indicator-content">
<div class="loading-indicator-image"></div>
</div>
</div>
.loading-indicator-wrapper {
position: fixed;
display: flex;
justify-content: center;
align-items: center;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.loading-indicator-content {
width: 75px;
height: 75px;
overflow: hidden;
}
.loading-indicator-image {
width: 375px;
height: 75px;
background-image: url("/images/sprite.png");
background-repeat: no-repeat;
background-size: auto 100%;
animation: sprite 1.5s steps(5) infinite;
}
@keyframes sprite {
0% {
transform: translateX(0px);
}
100% {
transform: translateX(-375px);
}
}
이전 구현 내용과 거의 비슷한데, 결정적인 차이는 @keyframes
에서 스프라이트 시트의 위치를 이동시키는 방법입니다. background-position
속성이 아닌 transform
속성을 사용했는데, 이게 무슨 차이를 만든 것인지는 아래 예시를 보면 알 수 있습니다.
위쪽의 경우는 애니메이션에 background-position
속성을 사용했을 때, 아래는 transform
속성을 사용했을 때입니다. 위쪽은 프레임이 바뀔 때마다 녹색 박스로 하이라이팅되는 반면, 아래의 경우는 그렇지 않습니다.
하이라이팅은 바로 Paint
작업이 발생했음을 의미합니다. 말 그대로, 매 프레임마다 새로 그려지고 있다는 것입니다.
이는 DevTools
에서 Ctrl + Shift + P (Command + Shift + P)
를 눌러 Command Palette
를 열고, 아래와 같이 Rendering
탭에서 'Paint flashing'에 체크하면 확인할 수 있는 부분입니다.
널리 알려진 것처럼, transform
속성은 reflow(layout)
와 repaint
를 트리거하지 않는 Compositor-Only
속성입니다. 반면 background-position
속성은 repaint
를 트리거합니다.
이러한 Compositor-Only
속성은 파이프라인에서 다른 영향 없이 Composite
, 즉 합성 작업만 수행하는 것으로 화면을 다시 그립니다. 위에서 나타나듯 Paint
작업이 반복되지 않고 있다는 것은, 로딩 인디케이터 요소가 자체적인 레이어로 승격(새로운 레이어로 생성)되었고 GPU에 업로드되어 메인 스레드와 독립적으로 처리되고 있다는 사실을 나타냅니다.
Composite
자체가 효율적인 렌더링을 위해 Paint
이후 추가적으로 수행되는 작업인데, 화면에 변경이 발생했을 때 전체를 다시 그리는 것은 비효율적이니 일부 요소만 레이어로 분리, 그 부분만 합성을 통해 변경 사항을 업데이트하여 다시 그리는 것이므로, 이번 사례와 같이 애니메이션에서 중요한 차이를 발생시켰다고 할 수 있겠습니다.
정리하면 결국 핵심은 이렇습니다.
Safari
브라우저는 페이지를 떠날 때(unload) 모든 작업을 중단하고 어떤 작업도 수행하지 않습니다. 브라우저 엔진(WebKit
)의 특성이므로 다른 서드파티 브라우저인 경우라고 해도 iOS
나 iPadOS
라면 마찬가지입니다.repaint
작업이 수반되는 애니메이션은 재생되지 않고 정지되는 현상이 발생합니다.Compositor-Only
속성을 사용하여 애니메이션을 구현했다면, 렌더링 프로세스의 차이로 인해 이러한 현상에 영향을 받지 않을 수 있습니다.글 제목이 무색하게, 결국 Safari
가 애니메이션 등의 작업들을 강제로 차단하는 동작 자체에 대해서는 정확한 레퍼런스를 찾아내지 못했습니다만,
적어도 웹 (프론트엔드) 개발자에게 있어서 브라우저의 렌더링 프로세스와 매커니즘은 항상 신경쓰고 깊이 이해해야 하는 부분이라는 사실을 다시금 깨달을 수 있었습니다.
글 잘 읽었습니다. 도움이 되는 글 감사합니다.
움직이는 GIF 파일 형태로 Loding 이미지 사용은 IOS모바일 환경에서 멈춤 현상 해결은 어려운 것일지요. 대안으로 CSS를 사용해야 한는 것일지 문의드립니다.