-
Theme Inspector를 사용하면 Chrome DevTools에서 섹션별 렌더링 시간을 확인할 수 있습니다.
-
Server-Timing 헤더를 통해 Liquid와 네트워크 처리 시간을 하나의 숫자로 비교할 수 있습니다.
-
전체 컬렉션을 대상으로 하는 중첩 루프가 일반적인 속도 저하 원인입니다.
-
render 태그가 적용된 캐시된 스니펫을 사용하면 반복 작업을 거의 0에 가깝게 줄일 수 있었습니다.
[IMG:N]
렌더링하는 데 900ms가 걸리던 제품 페이지의 Liquid 속도가, 전체 예산(time budget)을 차지하는 하나의 섹션을 찾은 후 200ms 미만으로 떨어졌습니다. 해결책이 특별한 것은 아니었습니다. 단지 400개 제품 컬렉션에 대한 루프 안의 루프였습니다. 여기서는 추측 대신 그런 종류의 문제를 찾는 방법을 설명합니다.
테마가 이미 제공하는 숫자로 시작하기
어떤 코드도 건드리기 전에, 솔직한 하나의 숫자를 원합니다. 즉, 서버에서 이 페이지를 렌더링하는 데 Liquid가 얼마나 걸리는지입니다. Shopify는 그 숫자를 두 곳에 제공하며, 저는 두 곳 모두 확인합니다.
첫 번째는 Chrome의 Shopify Theme Inspector입니다. 확장 프로그램을 설치하고 스토어에 로그인한 다음 DevTools를 열면, 페이지의 모든 Liquid 파셜(partial)과 해당 렌더링 시간이 표시된 플레임 그래프(flame graph)를 얻을 수 있습니다. 섹션이든, 스니펫이든 상관없습니다. 헤더가 4ms 걸렸고
저는 데모 페이지가 아닌 실제 페이지를 측정합니다. 제품 8개로 구성된 컬렉션 페이지는 제품 400개로 구성된 동일한 템플릿과 전혀 다르게 작동합니다. 느린 루프는 실제 데이터에서만 나타납니다.
제가 이 모든 것을 프로파일링할 수 있는 파일에 보관하는 이유에 대한 더 깊은 맥락을 원한다면, [Why Every RAXXO Section Lives in Code, Not the Customizer]를 참조하세요(https://dev.to/blogs/lab/why-every-raxxo-section-lives-in-code-not-the-customizer). 커스터마이저가 숨기는 것은 프로파일링할 수 없습니다.
따라서 루틴은 간단합니다. 페이지를 열고, 전체 시간을 확인하기 위해 Server-Timing을 읽고, 세부 분석을 위해 인스펙터를 연 다음, 가장 느린 섹션 세 가지를 적습니다. 그 세 가지가 저의 전체 할 일 목록입니다. 나머지 모든 것은 그대로 두어도 됩니다.
중첩 루프(Nested loops)가 거의 항상 실제 비용을 차지합니다
느린 섹션을 알게 되면, 제가 가장 먼저 찾는 것은 루프 안의 루프입니다. 이것은 제가 작성한 것을 포함하여 테마에서 발견하는 가장 흔한 성능 버그입니다.
문제의 형태는 이렇습니다. 컬렉션의 제품들을 반복(loop)합니다. 그 루프 안에서 각 제품에 대해 모든 변형(variant)을 반복합니다. 그리고 그 안에서, 어쩌면 변형 메타필드(variant metafields)를 반복할 수도 있습니다. 제품 3개와 변형 4개는 아무것도 아닙니다. 변형이 각각 6개인 제품 400개는 2,400번의 내부 반복을 의미하며, Liquid는 단 하나의 바이트가 브라우저에 도달하기 전에 이 모든 것을 서버에서 실행합니다.
수정 방법은 보통 필요하지 않은 작업을 중단하는 것입니다. 제가 제품 카드를 표시한다면, 모든 변형이 필요한 것은 아닙니다. 저는 사용 가능한 첫 번째 변형과 아마도 가격 범위만 필요하며, 이 두 가지 모두 루프 없이 사용할 수 있습니다. product.price_min과 product.price_max는 저에게 범위를 직접 제공합니다. product.selected_or_first_available_variant는 제가 실제로 보여줄 변형을 제공합니다.
다른 중첩 루프 함정은 루프 안에서의 필터링입니다. 저는 전체 컬렉션을 반복하고 특정 태그를 가진 제품만 표시하기 위해 if문을 사용하는 코드를 본 적이 있습니다. 이는 단지 12개의 제품만 일치하더라도 Liquid가 여전히 전체 컬렉션을 반복한다는 것을 의미합니다. 루프 전에 데이터를 좁히는 것이 더 좋습니다.
Shopify의 where 필터가 여기서 도움이 됩니다. collection.products | where: "type", "Poster"를 사용하면 더 작은 배열을 얻게 되고, 이제 루프는 400개 항목 대신 12개 항목에 대해 실행됩니다. 이 필터는 한 번만 발생합니다. 루프는 적은 횟수만큼 실행됩니다. 두 가지 장점이 결합되어 시너지를 냅니다.
제한(Limits)도 중요합니다. for product in collection.products limit: 8을 사용하면 컬렉션 크기가 아무리 커도 루프가 8회 반복된 후 중단됩니다. 8장의 카드를 보여주는 홈페이지 캐러셀의 경우, 400개를 렌더링할 이유가 없습니다.
제가 가장 먼저 사용하는 단축 패턴들은 Liquid로 테마 빌드 시간을 절반으로 줄인 패턴들에서 다루었습니다. 이 패턴들 중 상당수는 단순히 짧아서가 아니라 의도적으로 빠르도록 설계되었습니다.
제가 따르는 규칙은 다음과 같습니다: 올바른 출력을 생성하는 가장 작은 데이터 세트만 건드린다(touch). 루프가 페이지에 표시되는 것보다 더 많은 데이터를 읽고 있다면, 그것은 낭비된 서버 시간이며, 인스펙터에서 두꺼운 막대로 나타날 것입니다.
동일한 작업을 반복하는 스니펫 캐싱하기
두 번째 큰 장점은 캐싱이며, Shopify는 사람들이 생각하는 것보다 더 많은 기능을 제공합니다.
include 대신 render 태그를 사용하면 렌더링된 스니펫이 격리됩니다(isolated). 이 스니펫은 전달받은 변수만 볼 수 있습니다. 이러한 격리가 캐싱을 안전하게 만드는 이유인데, 출력 결과가 실행될 때 우연히 존재하는 어떤 전역 상태(global state)에도 의존하지 않고 오직 입력값에만 의존하기 때문입니다.
Shopify는 내부적으로 렌더링된 조각(fragment)을 가능한 경우 캐싱하며, 스니펫을 순수하게(pure) 유지함으로써 도움을 줄 수 있습니다. 제품 하나를 받아 카드 형태로 반환하는 스니펫이 cart, customer 및 세 개의 섹션 설정에 동시에 접근하는 스니펫보다 더 잘 캐시됩니다. 스니펫이 의존하는 것이 많을수록 재사용할 수 있는 범위가 좁아집니다.
따라서 컬렉션 페이지에서 40번 렌더링되는 제품 카드가 있을 때, 저는 다음과 같이 하나의 입력값만 가진 깔끔한 스니펫으로 만듭니다:
{% render 'product-card', product: product %}
그게 전부입니다. 전역 변수도 없고, 예상치 못한 읽기 작업(surprise reads)도 없습니다. Shopify는 동일한 입력을 동일한 작업으로 취급할 수 있습니다.
방문자마다 실제로 변하지 않는 데이터의 경우, Liquid에서 계산하기보다는 metafield를 활용하는 편입니다. 제품의 '사용 방법'이나 미리 포맷된 사양표가 필요하다면, 이를 metafield로 저장하고 직접 읽어옵니다. 루프(loop)도 없고, 문자열 생성(string building)도 없고, 렌더링할 때마다 조건부 로직(conditional logic)을 거칠 필요도 없습니다. 제가 신뢰하는 패턴은 Shopify Metafields Done Right에 정리해 두었습니다.
더 조용한 형태의 캐싱도 있습니다. 루프 내부에서 한 번만 계산할 수 있는 것을 다시 계산하지 않는 것입니다. 모든 반복(iteration)마다 동일한 통화를 포맷하거나 동일한 상점 설정을 조회한다면, 이를 바깥으로 빼내야 합니다(hoist). 루프가 시작되기 전에 변수에 할당하고 그 변수를 내부에서 참조하세요. 40번의 조회가 1회로 줄어듭니다.
또한 루프 내부에서 읽는 섹션 설정도 주의합니다. 각 section.settings.something 자체는 비용이 적게 들지만, 400번 호출되면 누적됩니다. 상단에서 로컬 변수로 한 번만 읽으세요. 작은 변화이지만 플레임 그래프(flame graph)에서 측정 가능한 차이를 만듭니다.
스토어프론트(storefront)와 체크아웃(checkout) 간에 컨텍스트를 재계산하지 않고 전달해야 할 때, 상태를 다시 구축하는 대신 cart attributes를 사용합니다. 배경 지식으로는 Shopify Cart Attributes가 설정 과정을 안내합니다.
측정하고, 그리고 멈추기
사람들이 건너뛰는 부분은 수정 후 재측정하는 것입니다. 저는 어떤 변경이 도움이 되었다고 스스로를 설득했지만, Server-Timing 수치는 전혀 움직이지 않았습니다. 추측은 진전처럼 느껴집니다. 헤더(header)는 감정을 신경 쓰지 않습니다.
그래서 모든 변경 후에는 두 번 새로고침하고 숫자를 다시 읽어봅니다. 만약 느렸던 섹션이 310ms에서 40ms로 떨어졌다면, 좋습니다. 다음 바(bar)로 넘어갑니다. 만약 거의 움직이지 않았다면, 원인을 잘못 이해했기 때문에 되돌아가서 더 깊이 살펴봐야 합니다.
저는 작업하면서 작은 로그를 남겨둡니다. 섹션 이름, 이전 시간, 이후 시간이 적힌 단순한 텍스트 메모죠. 최근의 컬렉션 템플릿에서 세 개의 가장 느린 섹션은 각각 310ms, 180ms, 90ms로 시작했습니다. 루프와 캐싱 작업을 거친 후에는 각각 40ms, 55ms, 45ms가 되었습니다. 총 Liquid 시간이 대략 900ms에서 200ms 미만으로 줄어든 겁니다. 이게 다섯 개의 숫자로 된 전체 이야기이고, 양쪽 끝을 모두 측정했기 때문에 믿을 수 있는 거죠.
언제 멈춰야 하는지 아는 것도 어디서부터 시작해야 하는지 아는 것만큼 중요합니다. 페이지가 Liquid를 몇백 밀리초 만에 렌더링하게 되면, 다음 개선점은 보통 Liquid 자체가 아닙니다. 이미지 크기, 서드파티 스크립트, 또는 폰트일 가능성이 높습니다. 이들은 Shopify 서버가 아닌 브라우저에서 작동하며, Server-Timing Liquid 수치로는 이를 바꿀 수 없습니다. 이때까지 Liquid를 파고드는 것은 잘못된 레이어에 노력을 쏟는 것입니다.
페이지 작업을 완료하기 전에 제가 거치는 간단한 체크리스트입니다:
-
웜 로드(warm load) 시 Server-Timing Liquid가 약 250ms 미만일 것.
-
특별히 좋은 이유가 없는 한, 인스펙터에서 어떤 섹션도 60ms를 넘지 않을 것.
-
루프가 페이지에 표시되는 것보다 더 많은 데이터를 읽지 않을 것.
-
반복되는 스니펫은 include 대신 render를 사용할 것.
이 네 가지가 모두 통과하면, 저는 탭을 닫습니다. 페이지는 충분히 빠르며, 나머지 밀리초들은 짜내기에는 복잡성이 너무 크기 때문입니다.
또 하나의 습관: 저는 이미 배포한 템플릿에서 프로파일링합니다. 단순히 새로운 작업만 하는 것이 아닙니다. 스토어는 변합니다. 30개의 제품으로 빠르던 컬렉션이 아무도 코드를 변경하지 않았는데도 300개로 늘어나면 느려집니다. 큰 템플릿들을 몇 달에 한 번씩 재프로파일링하면 고객이 느끼기 전에 그 변화를 포착할 수 있습니다.
핵심 요약 (Bottom Line)
만약 Shopify 페이지가 느리게 느껴진다면, 테마를 다시 작성하지 마세요. '테마 인스펙터(Theme Inspector)'를 열고, Server-Timing 헤더를 읽은 다음, 예산(budget)을 많이 소모하는 섹션을 찾으세요. 10번 중 9번은 너무 커져서 네스팅된 루프가 컬렉션을 반복 처리하고 있는 것이 원인이며, 해결책은 데이터를 좁히거나 render를 사용해 깨끗한 스니펫을 캐싱하는 것입니다.
측정부터 시작하세요. 전체 Liquid 시간과 섹션별 분석이라는 두 숫자를 얻기 전까지는 모든 것이 추측에 불과합니다. 그런 다음 가장 큰 막대를 수정하고, 다시 측정하며, 페이지가 수백 밀리초(milliseconds) 내에 렌더링될 때까지 반복하세요.
Shopify에서 스토어를 구축하고 테마를 코드로 유지하여 프로파일링 및 버전 관리가 가능한 방식의 사고 과정이 궁금하다면, Claude Blueprint에 제가 사용하는 전체 접근 방식을 담았습니다. 먼저 프로파일링을 하고, 비용이 많이 드는 부분을 수정하며, 나머지는 그대로 두세요.
본 기사에는 제휴 링크가 포함되어 있습니다. 이 링크를 통해 가입하시면 추가 비용 없이 저에게 소액의 수수료가 지급될 수 있습니다. (광고)