기술 포스트

카운트는 다 달랐는데 차트는 평평했다


Article

2편에서 로딩바 하나에 세 번 속았다. 원인은 서버가 아니라 서버가 만든 HTML에 카드가 0개였던 것이었다. 데이터는 잘 가져왔는데, 그것을 그릴 컴포넌트가 서버에서 안 돌았다.

그 뒤로 두 번 더 같은 종류의 벽에 부딪혔다. 이미지에서 한 번, 통계에서 한 번.

둘 다 데이터는 맞았고, 화면만 틀렸다.


이미지 — 클라이언트 렌더의 청구서

2편의 결정 때문에 카드 목록은 브라우저가 그린다. 그러면 대가가 하나 따라온다.

초기 HTML에 이미지 주소가 없다. 브라우저는 HTML을 받자마자 그 안의 이미지 주소를 미리 훑어 다운로드를 시작하는데(preload scanner), 우리 HTML에는 Suspense fallback 하나뿐이다. 이미지 주소는 JS가 실행되고, react-query가 데이터를 받고, 카드가 그려진 뒤에야 나타난다.

거기에 next/image의 기본값 loading="lazy"까지 겹친다. 라이트하우스가 정확히 지적했다.

LCP 이미지에 fetchpriority=high를 적용하고 loading=lazy를 쓰지 말 것.

첫 카드 이미지가 뜨기까지
  1. HTML 도착이미지 주소가 없다. 회색 빈 화면뿐
  2. JS 실행react-query 가 목록을 요청한다
  3. 데이터 도착이제야 카드가 그려진다
  4. 이미지 발견여기서 처음 이미지 주소가 DOM 에 들어간다
  5. lazy 라 또 기다린다기본값이 loading="lazy"
서버 렌더였다면 첫 단계에서 이미 주소가 있었다. 클라이언트 렌더의 청구서다.

발견을 앞당길 수는 없다. 그건 2편의 설계상 남는 지연이다. 대신 발견한 다음을 앞당겼다.

첫 화면에 보이는 카드에만 priority를 준다. 몇 장인가. 데스크톱에서 순차 등장하는 카드가 넷이다.

// CardSwiperList.tsx
const STAGGER_VISIBLE = 4;
const staggered = offset >= 0 && offset < STAGGER_VISIBLE;

<CardSplashPanel card={card} rank={...} priority={staggered} />

등장 애니메이션을 받는 카드가 곧 화면에 보이는 카드다. 그래서 그 플래그를 그대로 priority로 넘겼다. 조건을 새로 계산하지 않았다.


두 겹으로 보이는데 요청은 한 번이다

카드에 호버하면 뒤 레이어가 크게 번지며 사라지는 잔상이 남는다. 이미지가 두 겹이다. 고스트 레이어와 원본 레이어.

이미지 두 장이면 요청도 두 번인가. 아니다.

// HoverPulseImage.tsx
<Image src={src} alt="" aria-hidden fill priority={priority} sizes={SIZES} className={ghost} />
<Image src={src} alt={alt} fill priority={priority} sizes={SIZES} className={breathe} />

src가 같다. 브라우저는 같은 주소의 요청을 하나로 합친다. 시각 효과는 두 겹인데 네트워크 비용은 한 장이다.

여기서 재밌는 걸 하나 배웠다. 처음엔 priority원본 레이어에만 줬다. 고스트는 어차피 같은 요청에 합쳐지니 불필요하다고 봤다.

그런데 라이트하우스가 고스트 레이어를 LCP 요소로 집었다. DOM에서 그게 먼저이기 때문이다. LCP로 지목된 요소에 priority가 없으니 경고가 그대로 남았다.

그래서 둘 다 준다. 요청은 어차피 하나로 합쳐지니 추가 다운로드가 없다.

priority 를 어디에 줄 것인가

원본 레이어에만

처음 생각

  • 고스트는 같은 요청에 합쳐지니 불필요하다고 봤다
  • 그런데 라이트하우스가 고스트를 LCP 로 집었다
  • DOM 상 그게 먼저다
  • 경고가 안 사라진다

두 레이어 모두

실제 코드

  • 어느 쪽이 LCP 로 잡혀도 우선 로드된다
  • src 가 같아 요청은 하나로 합쳐진다
  • 추가 다운로드 0
  • 경고가 사라진다
"논리적으로 불필요하다"와 "측정 도구가 그렇게 본다"는 다른 이야기였다.

여기에 반응형 sizes를 붙였다. 한 화면에 최대 넉 장이니 화면 폭에 맞는 작은 최적화본을 받으면 된다.

(min-width:1280px) 25vw, (min-width:1024px) 33vw, (min-width:640px) 50vw, 100vw

next/image가 원본 PNG를 AVIF(우선) → WebP로 변환해 보낸다. 데스크톱에서 카드 한 장이 화면의 4분의 1이니, 원본 전체를 받을 이유가 없다.


통계 — 여기서 세 번 더 헛짚었다

카드가 쌓이자 통계 화면을 붙였다. MBTI 분포, 직군, 연령, 목표. 막대 차트다.

그런데 차트가 AI가 만든 것처럼 보였다. 성별 50:50, 직업 균등, MBTI 막대가 계단처럼 딱딱 떨어졌다. 사람이 모인 결과 같지 않았다.

헛짚기 1 — "시드가 ISFP에 쏠렸다"

통계 화면이 ISFP로 압도적이었다. 시드 데이터를 열어보니 웹퍼블리셔 카드 25장 중 11장(44%)이 ISFP였다. 초기에 5장일 때 쏠려 있던 걸 25장으로 늘리며 방치한 것이다.

16타입에 고르게 재분배했다.

헛짚기 2 — "균등 배분이라 티가 난다"

그러자 이번엔 너무 고르게 나왔다. modulo 순환으로 배분했으니 당연했다 — MBTI 12/9/9/9/6/6…, 성별 50:50, 연령 균등.

목표 분포를 직접 설계했다. 웹퍼블리셔가 71%, MBTI는 6·5·5·4·4·4·3·3·3·2·2·2·2·2·1·1처럼 흔한 유형이 많게. 49장에 서로소인 보폭을 줘서 조합까지 흩뜨렸다.

그런데도 차트가 여전히 평평했다. 그리고 여기서 이상한 걸 발견했다 — 로컬과 운영의 그림이 아예 달랐다. 원인이 환경마다 달랐던 것이다.

같은 코드, 다른 그림

로컬

더미 격자 켜짐

  • 표본 177장 중 128장(72%)이 표시용 격자
  • 그 격자는 캐릭터 이미지를 빠짐없이 노출하려고 만든 것 — 정의상 MBTI 마다 정확히 8장
  • 완벽한 균등 격자가 설계 시드 49장을 눌러버린다
  • 시드를 아무리 튜닝해도 격자가 이긴다

운영

격자 꺼짐

  • 표본이 49장뿐인데 MBTI 버킷은 16개
  • 설계값 6,5,5,4,4,4,3,3,3,2,2,2,2,2,1,1
  • 막대 너비로 바꾸면 100,83,83,67,67,67,50,50,50,…
  • 똑같은 막대 5개가 붙은 계단이 된다
"차트가 평평하다"는 같은 증상인데 원인이 둘이었다. 로컬에서 재현한 원인과 운영의 원인이 달랐다.

격자를 통계 표본에서 뺐다. 그건 갤러리 이미지를 빠짐없이 보여주려는 장치이지 모집단이 아니다. 그리고 시드를 49 → 150장으로 늘렸다. 이제 로컬과 운영이 같은 차트를 본다.

헛짚기 3 — 진짜 원인

카운트를 다 다르게 뒀다. 17·15·14·13·12·11·9·9·8·8·7·7·6·5·5·4. 최다와 최소가 4배 차이다.

차트는 여전히 균등해 보였다.

이번엔 데이터를 안 봤다. 화면을 그리는 코드를 열었다.

// StatsDashboard.tsx
const pct = (n, total) => (total ? Math.round((n / total) * 100) : 0);

Math.round. 총 150 기준이면 카운트 1은 0.67%다. 카운트 두 개가 한 퍼센트로 접힌다.

  • {7, 8} → 둘 다 5%
  • {4, 5} → 둘 다 3%
  • {1, 2} → 둘 다 1%

그래서 저 카운트가 화면에는 이렇게 찍혔다.

11 · 10 · 9 · 9 · 8 · 7 · 6 · 6 · 5 · 5 · 5 · 5 · 4 · 3 · 3 · 3

5%가 네 번, 3%가 세 번 줄줄이. 카운트는 다 달랐는데 화면은 평평했다.

그래서 표시 퍼센트를 기준으로 다시 설계했다. 한 퍼센트 구간에 카운트를 최대 두 개까지만 넣는다.

카운트 21·18·17·15·13·11·10·9·8·7·6·5·4·3·2·1 → 표시 퍼센트 14·12·11·10·9·7·7·6·5·5·4·3·3·2·1·1. 같은 숫자가 두 번을 넘지 않는다. 막대가 100%에서 5%까지 완만하게 기운다.


이번엔 규칙을 테스트했다

세 번을 헛짚었으니 네 번째가 없으리란 보장이 없다. 그래서 테스트를 붙였다. 다만 매직넘버를 박지 않았다.

// AptitudeStatsSampleTest
mbtiDisplayedPercentagesRepeatAtMostTwice()   // 퍼센트별 등장 횟수 ≤ 2
goalDistributionHasDistinctLowTail()          // 하위 3개 < 10% + 8개 전부 다른 숫자

21·18·17…이 맞는지 검사하지 않는다. "화면에 찍히는 퍼센트가 세 번 이상 반복되면 안 된다"는 규칙을 검사한다. 다음에 분포를 또 튜닝해도 이 규칙을 어기면 잡힌다.

그리고 테스트가 진짜로 실패하는지 확인했다. 임계값을 1로 낮춰 돌려보니 실제로 FAILED가 났다. 통과하는 걸 봤다고 검사가 됐다는 뜻은 아니다 — 아무것도 안 보고 통과했을 수도 있다.

통계 하나를 고치는 데 걸린 네 걸음
  1. 01

    시드가 ISFP 에 쏠려 있다

    25장 중 11장이 ISFP. 16타입에 고르게 재분배했다. → 이번엔 너무 고르게 나왔다.

  2. 02

    균등 배분이라 티가 난다

    modulo 순환을 버리고 목표 분포를 설계했다. → 여전히 평평했다. 그리고 로컬과 운영의 그림이 달랐다.

  3. 03

    환경마다 원인이 달랐다

    로컬은 128장 균등 격자가 표본의 72%. 운영은 표본 49장에 버킷 16개라 정수가 뭉쳤다. 격자를 표본에서 빼고 시드를 150장으로. → 그런데도 평평했다.

  4. 04

    화면을 그리는 코드를 열었다

    Math.round((n / total) * 100). 카운트 두 개가 한 퍼센트로 접히고 있었다. 저장된 값이 아니라 그려지는 값으로 설계해야 했다.

    const pct = (n, total) => (total ? Math.round((n / total) * 100) : 0);
세 번 다 데이터를 고쳤다. 답은 데이터와 화면 사이에 있었다.

배운 것

2편의 로딩바, 3편의 이미지와 통계 — 셋 다 같은 모양이었다.

세 번 다 같은 자리에서 넘어졌다

내가 본 것

데이터

  • 서버가 목록을 잘 가져온다
  • 이미지 주소가 카드에 잘 들어 있다
  • MBTI 카운트가 4배 차이로 다르다

화면이 본 것

그려지는 값

  • 서버 HTML 에는 카드가 0개 (2편)
  • HTML 에 이미지 주소가 없다 — 발견이 늦다
  • 반올림하면 퍼센트가 같은 칸에 뭉친다
데이터가 맞다고 화면이 맞는 게 아니다. 그 사이에 층이 하나 더 있다.

세 번 다 데이터 쪽을 고쳤다. 데이터가 멀쩡했으니 세 번 다 실패했다.

고친 순간은 매번 같았다. 추측을 멈추고 결과물을 직접 열었을 때다. 2편에서는 빌드된 HTML을 cat으로 열었고, 3편에서는 화면을 그리는 함수를 열었다. 둘 다 5분이면 되는 일이었다.

라이트하우스도 같은 걸 가르쳤다. "고스트 레이어는 논리적으로 priority가 필요 없다"는 내 판단보다, "측정 도구가 그것을 LCP로 집는다"는 사실이 이겼다.

내가 어떻게 설계했는지는 화면에 안 나온다. 화면에 나오는 것만 화면에 나온다.


여기까지가 DevTI를 만들며 세 번 넘어지고 세 번 배운 기록이다. 다음에 또 무언가 "고쳤는데 안 바뀐다"면, 데이터를 한 번 더 고치기 전에 결과물을 먼저 열어볼 생각이다.