Post

리뷰 사진이 늦게 뜨는 이유를 쟀더니, 1.7초 중 1.3초는 애니메이션이었다

리뷰 사진이 늦게 뜨는 이유를 쟀더니, 1.7초 중 1.3초는 애니메이션이었다

쇼핑몰의 상품 상세 페이지(스마트폰)에는 리뷰 목록이 있고, 리뷰에 붙은 사진을 탭하면 크게 볼 수 있다. 그런데 탭하고 나서 사진이 뜨기까지 로딩 스피너가 한동안 돈다. 리뷰는 구매를 정할 때 많이 보는 정보라서, 팀은 “리뷰 사진 확대를 빠르게 해서 고객의 구매 결정을 방해하지 않는다”를 과제로 잡았다. 기준은 두 단계였다.

  1. 대부분의 사용자가 지금 어떤 경험을 하는지 숫자로 잰다.
  2. 확대에 걸리는 시간을 지금보다 50% 줄인다.

내가 맡은 건 1단계를 위한 조사 두 가지였다.

  • 탭부터 사진이 다 보일 때까지, 어디서 시간이 걸리는지 찾는다.
  • 일반적인 이미지 표시 고속화 기법을 책으로 공부해서 조사 재료로 삼는다.

스피너가 돌면 보통 “사진을 받는 중”이라고 생각한다. 재 보니 그 시간의 대부분은 다운로드가 아니었다. 실제 사용자 기준 중앙값 1.7초 중 1.3초는 코드에 적힌 애니메이션 시간이었다.

재기 전에: 책에서 붙잡은 두 가지

참고한 책은 『Webフロントエンド ハイパフォーマンス チューニング』(웹 프론트엔드 하이 퍼포먼스 튜닝, 久保田光則, 技術評論社, 2017)이다. 렌더링 과정, HTTP, 이미지 최적화를 읽으며 정리했고, 조사를 시작하기 전에 두 가지를 붙잡았다.

  • 성능은 예측하지 말고 계측한다. 튜닝보다 먼저 병목이 어디인지 알아야 한다.
  • 목표 지표를 정한다. 평가하는 사람마다 “이 정도면 충분하다”의 기준이 다르기 때문이다.

두 번째를 읽으며 메모에 “내 눈에 괜찮으면 끝?”이라고 적었다. 지금까지는 화면을 만들면 직접 눌러 보고 괜찮으면 넘어갔다. 이번에는 “지금 몇 ms인가”와 “몇 ms가 되면 성공인가”부터 숫자로 정하기로 했다.

1단계: 실제 사용자 기준으로 재기

APM이 아니라 RUM

@Async 글에서는 Datadog APM으로 서버 구간을 쟀다. APM은 서버 안에서 요청 하나에 몇 ms가 걸렸는지를 잰다. 이번에 재고 싶은 건 사용자의 브라우저 안에서 일어나는 일이라, 같은 Datadog의 RUM(Real User Monitoring)을 썼다. RUM은 실제 사용자의 브라우저에 심은 스크립트가 화면 이동, 클릭, 오류 같은 것을 모아서 보낸다.

RUM은 클릭을 “액션”으로 자동 기록하고, 액션마다 loading_time을 붙인다. 클릭한 뒤 통신과 DOM 변경이 멈출 때까지의 시간이다. 100ms 동안 아무 활동이 없으면 끝난 것으로 본다. jQuery 애니메이션은 매 프레임 요소의 style을 바꾸므로 DOM 변경으로 잡힌다. 그래서 사진이 페이드인을 마칠 때까지가 대략 포함된다. “사진이 다 보인 순간”을 정확히 찍은 값은 아니어서, 근사값으로 다뤘다.

리뷰 사진 클릭만 골라내기

리뷰 사진의 마크업은 이렇다(주소는 자리표시자로 바꿨다).

1
2
3
<a href="{확대용 이미지 URL}" rel="lightbox[{리뷰 ID}]">
  <img class="lazyload" data-src="{썸네일 URL}" alt="리뷰 사진" />
</a>

RUM SDK는 클릭한 요소의 이름을 aria-label, alt, title 같은 속성이나 안의 글자에서 자동으로 뽑는다. 여기서는 <img>의 alt가 이름이 되므로, 이 값으로 리뷰 사진 클릭만 골라낼 수 있었다. RUM Explorer에 다음 쿼리를 넣고, Measure로 @action.loading_time을 골라 p50·p75·p90을 봤다.

1
@type:action @action.type:click @view.url_path_group:"<상품 상세 페이지>" @action.target.name:"리뷰 사진"

결과: p50 1,690ms

기간은 9월 13일부터 10월 13일까지 30일이다.

p50p75p90평균
1,690ms1,950ms2,530ms2,040ms
  • 주별 p50은 1,660~1,800ms로 안정적이었다.
  • 평균이 p50보다 긴 건 느린 회선의 일부 클릭이 평균을 끌어올리기 때문이다. 대표값은 p50으로 정했다.

에픽의 기준인 “50% 단축”을 숫자로 바꾸면, 목표는 p50 845ms 이하다.

이 숫자를 볼 때의 주의점도 함께 적어 두었다.

  • RUM은 일부 세션만 수집하도록 설정되어 있어서, 30일 표본이 200클릭 남짓이다. 분포의 대략적인 모양을 보는 용도다.
  • 오류가 난 세션이 우선 남는 방식이라 치우침이 있다.
  • RUM 데이터는 30일이 지나면 사라진다. 개선을 배포한 뒤에는 “전에는 얼마였지?”를 다시 볼 수 없으니, 숫자와 쿼리를 배포 전에 문서로 남겼다.

INP로는 이 지연이 안 보인다

Core Web Vitals의 INP도 “탭부터 화면 반응까지”를 잰다. 다만 INP가 재는 건 탭한 뒤 다음 프레임이 그려질 때까지다. lightbox는 탭하자마자 검은 배경을 깔기 시작하므로, INP는 그 첫 프레임에서 끝난다. 사진이 다 보일 때까지 1초 넘게 걸려도 INP는 좋게 나올 수 있다. 그래서 “사진이 다 보일 때까지”에 더 가까운 RUM 액션의 loading_time을 썼다.

2단계: lightbox 코드 읽기

세 단계가 차례로 이어진다

사진 확대는 jQuery 플러그인 lightbox2가 맡고 있다. 쇼핑몰에 들어 있는 파일은 2015년 12월에 나온 v2.8.2였고, 공식 저장소의 같은 태그와 diff해 보니 한 글자도 다르지 않았다. 탭부터 사진이 다 보일 때까지를 코드에서 따라가면 세 단계가 나온다. 아래는 해당 부분만 줄여서 옮기고 주석을 단 것이다.

첫째, 스피너를 띄우고 확대용 이미지를 받는다.

1
2
3
4
5
6
7
8
9
// changeImage()
$('.lb-loader').fadeIn('slow'); // 스피너
// ...
var preloader = new Image();
preloader.onload = function() {
  // ...화면에 맞는 크기를 계산한 뒤
  self.sizeContainer($image.width(), $image.height());
};
preloader.src = this.album[imageNumber].link; // 여기서 다운로드 시작

둘째, 다 받으면 흰 틀을 사진 크기로 늘리는 애니메이션을 돌린다.

1
2
3
4
5
6
7
// sizeContainer()
this.$outerContainer.animate({
  width: newWidth,
  height: newHeight
}, this.options.resizeDuration, 'swing', function() { // resizeDuration 기본값 700
  postResize(); // 안에서 showImage() 호출
});

셋째, 틀이 다 늘어나면 스피너를 숨기고 사진을 서서히 보인다.

1
2
3
// showImage()
this.$lightbox.find('.lb-loader').stop(true).hide();
this.$lightbox.find('.lb-image').fadeIn('slow'); // jQuery의 'slow'는 600ms

각 단계는 앞 단계가 끝날 때 부르는 콜백 안에서 시작한다. Java로 치면 CompletableFuture를 thenCompose로 이어 붙인 것과 같아서, 세 시간은 겹치지 않고 그대로 더해진다.

순서하는 일시간스피너
1확대용 이미지 다운로드회선에 따라 다름보임
2흰 틀을 사진 크기로 늘리기700ms (resizeDuration)보임
3사진 페이드인600ms ('slow')숨김

사진이 다 보일 때까지는 다운로드 + 1,300ms다. 1,300ms는 회선과 상관없이 붙는다.

다 받은 뒤에도 스피너는 700ms 더 돈다

표의 스피너 열을 보면, 스피너를 숨기는 건 3단계다. 사진을 다 받은 뒤에도 틀이 늘어나는 700ms 동안은 스피너가 계속 돈다. 사용자 눈에는 이 시간도 “아직 받는 중”으로 보인다. 스피너가 도는 시간이 전부 다운로드는 아니었다.

옵션으로 줄일 수 있는 것과 없는 것

lightbox2 v2.8.2의 시간 옵션은 두 개다.

옵션기본값쓰이는 곳사진이 보이는 시점에 영향
resizeDuration700틀 크기 애니메이션, 아래쪽 닫기 버튼 영역의 페이드인있음
fadeDuration500탭 직후 배경과 lightbox가 나타날 때, 닫을 때없음

이름만 보면 fadeDuration을 줄이면 사진이 빨리 나타날 것 같다. 하지만 이건 탭 직후 배경이 깔리는 애니메이션이고, 다운로드와 동시에 흘러간다. 줄여도 사진이 보이는 시점은 그대로다. 사진 자체의 페이드인은 fadeIn('slow')로 코드에 박혀 있어서 옵션으로는 바꿀 수 없다. 사진 페이드인 시간을 옵션(imageFadeDuration)으로 바꿀 수 있게 된 건 2016년에 나온 v2.9.0부터다.

확대용 이미지는 원본 해상도 그대로

다운로드 쪽도 봤다. href가 가리키는 확대용 이미지는 업로드 뒤에 백엔드의 Lambda가 만든다. 원본에서 썸네일과 확대용을 만드는데, 확대용은 방향만 바로잡고 크기는 줄이지 않는다.

1
2
3
4
// 썸네일: 폭 240px로 줄인다 (width = 240)
var bufferTn = await sharp(origimage).rotate().resize(width).toBuffer();
// 확대용: EXIF 방향만 바로잡고, 크기는 그대로
var buffer = await sharp(origimage).rotate().toBuffer();

업로드는 장당 10MB까지 받는다. 스마트폰 화면에 맞춰 줄여서 보여 줄 사진이라도, 올라온 해상도 그대로 내려갈 수 있다.

숫자 맞춰 보기

코드의 고정 시간 1,300ms를 RUM의 p50 1,690ms에서 빼면, 다운로드는 400ms 안팎이라는 어림셈이 나온다.

구간p50 기준비중
다운로드 (어림셈)약 390ms약 23%
틀 애니메이션700ms약 41%
사진 페이드인600ms약 36%
합계 (RUM p50)1,690ms100%

네트워크가 아무리 빨라져도 1,300ms는 남는다. p50 기준으로 4분의 3 이상이 애니메이션이다.

내 PC에서 직접 재 봤을 때는 약 1초였다. RUM 값이 더 긴 건, RUM이 페이드인이 끝날 때까지를 포함하고 실제 사용자의 회선 속도가 섞여 있기 때문이라고 봤다.

이 어림셈은 모든 탭에 1,300ms가 붙는다고 본 것이다. 실제로는 새 크기가 지금 틀 크기와 같으면 틀 애니메이션을 건너뛴다(틀의 처음 크기는 250×250이라 첫 사진은 사실상 늘 거친다). 또 RUM이 재는 끝은 사진 페이드인(600ms)보다 100ms 긴 닫기 버튼 영역의 페이드인(700ms)일 수도 있다. 그래서 다운로드 몫은 실제와 다를 수 있다. 다만 처음 여는 사진은 사실상 늘 1,300ms를 거치므로, 애니메이션이 가장 큰 몫이라는 결론은 그대로다.

목표까지의 거리

목표는 p50 845ms 이하다. 어림셈으로 따져 보면 이렇다.

바꾸는 것방법표시까지 (p50 어림셈)단축
지금-1,690ms-
틀 애니메이션 없애기옵션 resizeDuration약 990ms약 41%
사진 페이드인까지 없애기플러그인 코드를 고치거나 v2.9.0 이상으로 올리기약 390ms약 77%

옵션으로 바꿀 수 있는 틀 애니메이션만 없애면 약 41%에 그쳐 목표(50%)에 못 미친다. 사진 페이드인도 줄여야 하는데, 이건 지금 버전에서는 옵션으로 안 된다.

책에서 본 이미지 고속화 기법을 이 화면에 대 보기

책과 공부 메모에서 이미지 표시와 관련된 기법을 골라, 이 화면의 어느 구간에 듣는지 맞춰 봤다.

기법줄이는 구간이 화면에서는
이미지 용량 줄이기 (WebP·AVIF, 화면에 맞는 크기, 압축)다운로드확대용 이미지는 원본 해상도 그대로라 여지가 있다
미리 받기 (<link rel="preload">, new Image())다운로드lightbox는 사진을 보여 준 뒤에야 앞뒤 사진을 미리 받는다. 처음 탭한 사진은 탭한 순간부터 받는다
지연 로딩페이지 첫 로딩썸네일에는 이미 적용되어 있다(lazyload)
애니메이션은 transform·opacity로애니메이션 중 렌더링틀 애니메이션은 width·height를 바꾸는 방식이라, 브라우저가 매 프레임 레이아웃을 다시 계산한다
구간을 나눠 병목 찾기 (LCP의 4구간)-이번 조사에 쓴 방식이다

마지막 줄은 LCP(Largest Contentful Paint)를 공부하면서 가져온 생각이다. LCP는 “HTML 첫 바이트 → 이미지 요청 시작 → 이미지 다운로드 → 화면에 그리기”의 네 구간으로 나눠, 어디가 긴지 보고 거기를 고치라고 한다. 리뷰 사진에서는 마지막 구간, 즉 다 받은 뒤 화면에 보이기까지가 길었다. LCP에서 이 구간을 늘리는 건 보통 렌더링을 막는 JS나 CSS인데, 여기서는 일부러 넣은 애니메이션이었다.

다음 단계: 어떻게 줄일지는 시안을 보고 정한다

숫자만 보면 애니메이션을 모두 없애면 된다. 하지만 책에도 나오듯 튜닝에는 늘 트레이드오프가 있다. 애니메이션은 무엇이 어디서 열리는지 보여 주는 역할도 하니, 다 없앴을 때 사진이 툭 튀어나오는 느낌이 괜찮은지는 숫자로 알 수 없다.

그래서 다음 단계에서는 다른 쇼핑몰의 리뷰 사진 화면도 참고해서 시안을 여러 개 만들고(애니메이션을 반으로 줄이는 안, 아예 없애는 안, 틀 애니메이션만 없애는 안 등), 팀 투표로 고르기로 했다. 개선 전후를 비교하는 계측 계획은 다음 글에서 정리한다.

다시 본다면

“다 보인 순간”을 직접 찍기

이번에는 RUM의 loading_time이라는 근사값에서 코드의 고정 시간을 빼는 식으로 구간을 나눴다. 그래서 위의 어림셈 오차가 남았다. 최근 RUM SDK에는 시작과 끝을 코드에서 직접 찍는 기능(startDurationVital()·stopDurationVital())이 있다. 탭할 때 시작하고 사진 페이드인이 끝날 때 멈추면, “다 보인 순간”까지를 직접 잴 수 있다. 다운로드가 끝나는 onload에서도 하나 더 찍으면, 다운로드와 애니메이션을 나눠서 볼 수 있다. 다만 v2.8.2의 fadeIn('slow')에는 끝났을 때 부르는 콜백이 없어서, 끝나는 시점을 잡는 코드를 따로 넣어야 한다.

배운 점

  • 성능은 먼저 재고, 목표를 숫자로 정한다. “내 눈에 괜찮다”로는 끝낼 수 없다
  • 사용자 브라우저에서 일어나는 일은 RUM으로 잰다. INP처럼 다음 프레임까지만 보는 지표로는 이런 지연이 안 보일 수 있다
  • 스피너가 도는 시간이 전부 다운로드는 아니다. 코드를 읽으면 회선과 상관없는 고정 시간이 보인다
  • 옵션은 이름만 보고 판단하지 않는다. fadeDuration은 사진의 페이드인이 아니었다
  • RUM 데이터는 30일이면 사라진다. 개선 전 숫자는 배포 전에 남겨 둔다
This post is licensed under CC BY 4.0 by the author.