Skip to content

feat: 대학 목록 가상화 적용 - #581

Merged
manNomi merged 1 commit into
mainfrom
feat/virtualize-university-list
Jun 26, 2026
Merged

feat: 대학 목록 가상화 적용#581
manNomi merged 1 commit into
mainfrom
feat/virtualize-university-list

Conversation

@manNomi

@manNomi manNomi commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

관련 이슈

작업 내용

  • apps/university-web의 대학 목록 카드 렌더링을 TanStack Virtual 기반 window virtualizer로 변경했습니다.
  • 기존 문서 전체 스크롤 UX를 유지하기 위해 내부 스크롤 컨테이너를 만들지 않고 useWindowVirtualizer를 사용했습니다.
  • 목록 위 검색/필터/카운트 영역을 고려해 scrollMargin을 측정하고, virtual row 위치 계산에 반영했습니다.
  • getItemKey에 대학 id를 사용해 필터/검색 후에도 row key가 안정적으로 유지되도록 했습니다.
  • virtual row에 role="listitem", aria-posinset, aria-setsize를 부여해 가상화 이후에도 리스트 문맥을 보강했습니다.

성능 검증 요약

PageSpeed Insights 실제 측정

  • 측정 URL: https://www.solid-connection.com/university/kyunghee
  • 환경: PageSpeed Insights 모바일
  • 기준: PR 코멘트에 첨부한 before/after 스크린샷
Metric Before After Change
Performance score 70 89 +19 pt
First Contentful Paint 1.1 s 1.8 s +0.7 s
Largest Contentful Paint 4.7 s 2.2 s -2.5 s
Total Blocking Time 400 ms 150 ms -250 ms
Cumulative Layout Shift 0 0 유지
Speed Index 4.6 s 7.0 s +2.4 s

주요 개선 지점은 LCP와 TBT입니다. 긴 대학 목록에서 초기 렌더링해야 하는 카드, 이미지, DOM 수가 줄면서 메인 스레드 점유와 큰 콘텐츠 렌더링 시간이 함께 개선됐습니다. FCP와 Speed Index는 PageSpeed 기준으로 악화되어 보이지만, 전체 성능 점수는 70에서 89로 개선됐습니다.

로컬 상세 측정

  • 비교: HEAD^(가상화 전) vs HEAD(가상화 적용)
  • URL: http://localhost:{3101,3102}/university/inha
  • 빌드: UNIVERSITY_TERM_ID=1 NEXT_PUBLIC_UNIVERSITY_TERM_ID=1 pnpm --filter @solid-connect/university-web run buildnext start
Metric Before After Change
DOM elements 2,604 346 -86.7%
초기 카드 링크 136 13 -90.4%
Image requests 19 6 -68.4%
Image transfer 399 KiB 58 KiB -85.5%
HTML document 23 KiB 16 KiB -30.4%
Script transfer 623 KiB 627 KiB +4 KiB
Long task total (throttled) 584 ms 389 ms -33.4%

TanStack Virtual 추가로 JS가 약간 늘었지만, 전송량 기준 증가는 약 4KiB 수준입니다. 반면 이미지 요청/전송량, DOM 수, 초기 카드 렌더링 수는 크게 줄었습니다.

FCP/LCP 해석

Lighthouse 기본 simulate 값에서는 FCP/Speed Index가 악화된 것처럼 보였지만, 실제 trace 기반 provided 모드와 Chrome PerformanceObserver 직접 측정에서는 FCP/LCP가 악화되지 않았습니다.

측정 방식 Metric Before After
Lighthouse provided FCP 187 ms 146 ms
Lighthouse provided LCP 187 ms 146 ms
직접 브라우저 측정 FCP 156 ms 136 ms
직접 브라우저 측정 LCP 160 ms 144 ms
실제 throttling 적용 FCP 844 ms 836 ms
실제 throttling 적용 LCP 844 ms 836 ms

Lighthouse simulate에서 13초대 LCP가 나온 원인은 리스트 이미지가 아니라, 약 2MB인 PretendardVariable.woff2가 preload되어 모바일 throttling 모델에서 크게 잡힌 영향으로 확인했습니다. 폰트를 차단하면 simulate LCP도 13초대에서 1.5~2.4초대로 내려갑니다.

결론

  • 이번 PR은 대학 목록의 긴 리스트 렌더링 병목을 줄이는 데 효과가 있습니다.
  • 실제 PageSpeed 모바일 기준 성능 점수는 70 → 89로 개선됐고, LCP/TBT가 크게 줄었습니다.
  • DOM, 이미지 요청 수, 이미지 전송량, long task도 모두 감소했습니다.
  • FCP/Speed Index는 일부 측정에서 악화되어 보여 후속 개선 여지가 있습니다. 이 부분은 가상화보다 폰트 preload/서브셋/above-the-fold 렌더링 전략이 더 직접적인 후보입니다.

특이 사항

  • API, SSG 정책, 검색 파라미터 계약은 변경하지 않았습니다.
  • apps/web 홈 화면의 대학 preview 리스트는 이번 범위가 아니어서 수정하지 않았습니다.
  • 로컬 Node 버전이 v23.10.0이라 node: 22.x engine 경고가 출력됩니다.
  • 브라우저 검증 중 UNIVERSITY_TERM_ID=1 NEXT_PUBLIC_UNIVERSITY_TERM_ID=1은 stage dev API 학기 확인용으로만 사용했습니다.

검증

  • pnpm --filter @solid-connect/university-web run ci:check 통과
  • pnpm --filter @solid-connect/university-web run build 통과
  • pre-commit, pre-push CI parity checks 통과
  • 브라우저 확인
    • http://localhost:3001/university/inha
    • 초기: 총 151개 학교 중 DOM listitem 11개 렌더 확인
    • 깊은 스크롤: 렌더 구간이 42~60번째로 갱신되고 DOM listitem 19개 렌더 확인
    • 권역 필터: 유럽권 총 82개 중 DOM listitem 11개 렌더 확인
    • 검색 결과 0개 상태: empty state 유지 및 DOM listitem 0개 확인
    • 가로 overflow 없음 확인

리뷰 요구사항 (선택)

  • window virtualizer의 scrollMargin 측정과 row transform 계산이 기존 페이지 스크롤 UX와 잘 맞는지 봐주세요.
  • 카드 간격이 기존 UniversityCards의 spacing과 충분히 동일하게 유지되는지 봐주세요.

@manNomi
manNomi requested review from enunsnv and wibaek as code owners June 26, 2026 04:42
@vercel

vercel Bot commented Jun 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
solid-connect-university-web Ready Ready Preview, Comment Jun 26, 2026 4:43am
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
solid-connect-web-admin Skipped Skipped Jun 26, 2026 4:43am
solid-connection-web Skipped Skipped Jun 26, 2026 4:43am

@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

  1. UniversityCards에 윈도우 가상화가 추가되었습니다.
    • useWindowVirtualizer와 렌더링 상수가 도입되었습니다.
  2. 목록 렌더링 방식이 바뀌었습니다.
    • 전체 colleges.map 대신 가상 항목만 배치하고, 스크롤 기준 갱신과 aria 속성을 함께 적용했습니다.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested reviewers

  • wibaek
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed 대학 목록 가상화 적용이라는 핵심 변경을 간결하고 정확하게 요약합니다.
Description check ✅ Passed 필수 섹션인 관련 이슈, 작업 내용, 특이 사항이 모두 있고 검증 내용도 충분합니다.
Linked Issues check ✅ Passed TanStack Virtual, window virtualizer, scrollMargin, 안정적 key, 접근성, 범위 제한 등 이슈 요구사항을 충족합니다.
Out of Scope Changes check ✅ Passed 대학 페이지 가상화에만 집중했고 홈 프리뷰, API, SSG, 상세 페이지 변경은 없어 보입니다.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/virtualize-university-list

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/university-web/src/components/university/UniversityCards/index.tsx`:
- Around line 36-46: `useEffect` in `UniversityCards` only recalculates
`scrollMargin` on `resize`, so it can miss height changes from wrapped
filters/search chips or async card count updates. Update the measurement logic
around `measureScrollMargin` by adding a `ResizeObserver` on the
header/container element that affects the top offset, while keeping the existing
`window.resize` listener and initial `requestAnimationFrame` measurement as
fallback. Make sure the observer is created and cleaned up in the same effect as
`window.addEventListener("resize", ...)` so `scrollMargin` stays accurate when
the content height changes.
- Around line 50-62: The virtualized list spacing is being applied twice in
UniversityCards because useWindowVirtualizer’s gap is added on top of the item
wrapper padding. Update UniversityCards/index.tsx to use only one source of
spacing: either remove the gap option (and clean up UNIVERSITY_CARD_GAP) while
keeping the pb-2.5 wrapper padding, or remove the wrapper padding and align
estimateSize with the actual item height. Keep getItemKey and scrollMargin
behavior unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 18684a94-79f5-4f7a-b08d-233bf6b24847

📥 Commits

Reviewing files that changed from the base of the PR and between f3fb0d1 and e6851ce.

📒 Files selected for processing (1)
  • apps/university-web/src/components/university/UniversityCards/index.tsx

Comment on lines +36 to +46
useEffect(() => {
measureScrollMargin();

const animationFrameId = window.requestAnimationFrame(measureScrollMargin);
window.addEventListener("resize", measureScrollMargin);

return () => {
window.cancelAnimationFrame(animationFrameId);
window.removeEventListener("resize", measureScrollMargin);
};
}, [measureScrollMargin]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# UniversityCards를 렌더링하는 부모와 위쪽 UI 구성을 확인
rg -nP -C8 '<UniversityCards' apps/university-web/src

Repository: solid-connection/solid-connect-web

Length of output: 4449


scrollMargin이 동적 높이 변화를 놓치지 않도록 ResizeObserver를 함께 소개해 보아요.

반갑게도 현재 코드는 창 크기 조절(resize) 때만 고도를 재고 있어요. 하지만 검색어나 필터 칩이 줄바꿈되거나 비동기 개수 카드가 들어오면 창 크기는 그대로인데 콘텐츠 높이만 춤을 출 수 있죠. 그럴 때 scrollMargin이 구린 값을 잡으면 스크롤 위치가 살짝 어긋나는 상황이 벌어질 수 있어요.

따라서 아래 두 가지 걸음을 함께 걷는 건 어떨까요?

  1. 부모 영역의 호흡을 감지하는 망을 치아요

    • 학술 목록 상단 컨테이너(검색/필터/개수 UI 포함) 에 ResizeObserver 를 얹어두면, 창 크기와 상관없이 높이 변화만으로도 고도 재측정을 자연스럽게 켤 수 있어요.
  2. 정기적인 점검을 유지하면서도 유연하게 대응하아요

    • 기존 resize 이벤트 리스너는 창 크기 변화에 특화된 보조 수단으로 두어, ResizeObserver 가 먼저 변화에 반응하도록 해요.
// 예시 구조
const headerRef = useRef<HTMLElement | null>(null);

useEffect(() => {
  // 기본 측정
  measureScrollMargin();
  const animationFrameId = window.requestAnimationFrame(measureScrollMargin);
  window.addEventListener("resize", measureScrollMargin);

  // 동적 높이 변화 감지
  let resizeObserver: ResizeObserver | null = null;
  if (headerRef.current) {
    resizeObserver = new ResizeObserver(() => {
      measureScrollMargin();
    });
    resizeObserver.observe(headerRef.current);
  }

  return () => {
    window.cancelAnimationFrame(animationFrameId);
    window.removeEventListener("resize", measureScrollMargin);
    resizeObserver?.disconnect();
  };
}, [measureScrollMargin]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/university-web/src/components/university/UniversityCards/index.tsx`
around lines 36 - 46, `useEffect` in `UniversityCards` only recalculates
`scrollMargin` on `resize`, so it can miss height changes from wrapped
filters/search chips or async card count updates. Update the measurement logic
around `measureScrollMargin` by adding a `ResizeObserver` on the
header/container element that affects the top offset, while keeping the existing
`window.resize` listener and initial `requestAnimationFrame` measurement as
fallback. Make sure the observer is created and cleaned up in the same effect as
`window.addEventListener("resize", ...)` so `scrollMargin` stays accurate when
the content height changes.

Comment on lines +50 to +62
const virtualizer = useWindowVirtualizer({
count: colleges.length,
estimateSize: () => ESTIMATED_UNIVERSITY_CARD_ROW_HEIGHT,
gap: UNIVERSITY_CARD_GAP,
getItemKey,
overscan: 6,
scrollMargin,
initialRect: {
width: 0,
height: INITIAL_VIEWPORT_HEIGHT,
},
useFlushSync: false,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

In @tanstack/react-virtualv3 useWindowVirtualizer, does thegap option add spacing in addition to the height returned by measureElement (i.e. element padding plus gap), or does gap replace/include item padding?

💡 Result:

In @tanstack/react-virtual v3, the gap option adds spacing in addition to the size of the items [1][2]. It does not replace or include existing padding within your items; rather, it provides a dedicated mechanism to inject consistent spacing between virtualized items without requiring manual margin or padding adjustments [1][2]. Because the library manages absolute positioning of items, traditional CSS margins on items often do not behave as expected [2][3]. The gap option is designed to handle this by increasing the calculated size/offset of the items so that they remain correctly separated [2]. If you are using dynamic measurements (measureElement), the virtualizer accounts for the measured size of the element and adds the gap value to the space allocated for that item in the virtual list's coordinate system [2][3]. In summary: - The gap option acts as an additive spacing value [2]. - It does not automatically consume or replace the internal padding of your rendered components [1][2]. - It is specifically intended to solve the problem of managing space in an absolutely positioned layout where standard margin-based spacing fails [2].

Citations:


가상 리스트의 간격이 의도치 않게 두 배로 벌어지고 있어요

TanStack Virtual 공식 문서를 확인한 결과, useWindowVirtualizergap 속성은 아이템의 측정된 높이 (measureElement 반환값, 내부 패딩 포함) 에 추가되는 값이라고 합니다. 현재 코드에서는 아이템 래퍼에 pb-2.5(10px) 패딩이 있고, 가상러에도 gap: 10px을 설정하여 실제 간격이 약 20px 로 나타나고 있습니다.

이 문제를 해결하고 시각적 일관성을 확보하기 위해 아래 변경을 제안드립니다.

  1. 간격 중복 제거 (권장)
    가상러 설정의 gap 속성을 제거하고, 간격은 기존 아이템 래퍼의 pb-2.5 패딩 하나로 통일해주세요. UNIVERSITY_CARD_GAP 상수는 더 이상 사용되지 않으므로 함께 정리하면 코드가 깔끔해집니다.

      const virtualizer = useWindowVirtualizer({
        count: colleges.length,
        estimateSize: () => ESTIMATED_UNIVERSITY_CARD_ROW_HEIGHT,
    -    gap: UNIVERSITY_CARD_GAP,
        getItemKey,
        overscan: 6,
        scrollMargin,
  2. 대안: gap 우선 적용
    만약 향후 일정한 간격을 유지하기 위해 gap 속성을 유지하고 싶다면, 아이템 래퍼의 pb-2.5 클래스를 제거하고 estimateSize 계산 시 패딩 값을 제외하여 일치시켜주세요.

  3. 위치 안정성 참고
    스크롤 마진 (scrollMargin) 측정 로직이 상단 콘텐츠 크기 변화 (필터 등) 를 감지하지 못할 경우 아이템 위치가 어긋날 수 있으니, 추후 유동적인 레이아웃이 추가될 시 고려해주시면 좋겠습니다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const virtualizer = useWindowVirtualizer({
count: colleges.length,
estimateSize: () => ESTIMATED_UNIVERSITY_CARD_ROW_HEIGHT,
gap: UNIVERSITY_CARD_GAP,
getItemKey,
overscan: 6,
scrollMargin,
initialRect: {
width: 0,
height: INITIAL_VIEWPORT_HEIGHT,
},
useFlushSync: false,
});
const virtualizer = useWindowVirtualizer({
count: colleges.length,
estimateSize: () => ESTIMATED_UNIVERSITY_CARD_ROW_HEIGHT,
getItemKey,
overscan: 6,
scrollMargin,
initialRect: {
width: 0,
height: INITIAL_VIEWPORT_HEIGHT,
},
useFlushSync: false,
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/university-web/src/components/university/UniversityCards/index.tsx`
around lines 50 - 62, The virtualized list spacing is being applied twice in
UniversityCards because useWindowVirtualizer’s gap is added on top of the item
wrapper padding. Update UniversityCards/index.tsx to use only one source of
spacing: either remove the gap option (and clean up UNIVERSITY_CARD_GAP) while
keeping the pb-2.5 wrapper padding, or remove the wrapper padding and align
estimateSize with the actual item height. Keep getItemKey and scrollMargin
behavior unchanged.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e6851cef6a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

height: `${virtualizer.getTotalSize()}px`,
}}
>
{virtualItems.map((virtualItem) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep detail links in static list HTML

Because this component is rendered on the statically generated /university/[homeUniversity] pages, switching the render loop from colleges.map to virtualItems.map leaves only the initial viewport of UniversityCard anchors in the pre-rendered/no-JS HTML; the rest of the detail links are created only after client JS runs and the user scrolls. Since sitemap() currently emits only /university, /university/search, and /university/${slug} entries (apps/university-web/src/app/sitemap.ts:18-37), crawlers or no-JS users lose discovery of most detail pages generated by generateStaticParams, so please keep a server/no-JS fallback with all links or add an equivalent discoverable source.

Useful? React with 👍 / 👎.

@manNomi

manNomi commented Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

Web Vitals / Lighthouse 성능 체크

측정 조건

  • 비교: HEAD^(가상화 전) vs HEAD(가상화 적용)
  • URL: http://localhost:{3101,3102}/university/inha
  • 빌드: UNIVERSITY_TERM_ID=1 NEXT_PUBLIC_UNIVERSITY_TERM_ID=1 pnpm --filter @solid-connect/university-web run buildnext start
  • 도구: Lighthouse 13.4.0, mobile 기본 설정, 3회 측정 median
Metric before after change
Performance score 73 71 -2 pt
FCP 1,702 ms 2,593 ms +892 ms (+52.4%)
LCP 13,357 ms 13,361 ms +4 ms (+0.0%)
TBT 125 ms 74 ms -51 ms (-40.8%)
CLS 0.000 0.000 0
Speed Index 1,702 ms 2,776 ms +1,074 ms (+63.1%)
Main-thread work 879 ms 734 ms -146 ms (-16.5%)
JS boot-up 549 ms 485 ms -64 ms (-11.7%)
Transfer size 3,127 KiB 2,784 KiB -343 KiB (-11.0%)
DOM elements 2,555 297 -2,258 (-88.4%)

SSR HTML 기준

  • 초기 카드 링크 수: 135개 → 15개
  • HTML 크기: 308,854 bytes → 125,867 bytes (-59.2%)

해석

  • 긴 대학 목록 병목에는 효과가 큽니다. DOM 수, HTML payload, TBT, main-thread work가 모두 줄었습니다.
  • 다만 Lighthouse의 초기 시각 지표(FCP/Speed Index)는 median 기준 악화됐습니다. CWV 점수 자체를 올리는 목적이라면, above-the-fold 정적 렌더링을 유지하고 이후 구간만 가상화하는 방식 등 후속 개선을 따로 보는 게 좋아 보입니다.
  • LCP는 양쪽 모두 13.36s대로 거의 동일했고, 이번 변경의 영향권 밖에 가깝습니다.

@manNomi

manNomi commented Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

추가 확인입니다. 위 표의 FCP/Speed Index는 Lighthouse mobile의 simulated throttling 값입니다. 같은 Lighthouse JSON의 trace observed 값 기준으로는 FCP 회귀가 보이지 않습니다.

Metric before median after median
observed FCP 180 ms 143 ms
observed Speed Index 201 ms 183 ms

네트워크 분해상 JS transfer는 622 KiB → 627 KiB로 약 +5 KiB 수준이고, 전체 transfer/HTML/이미지/DOM은 감소했습니다. 그래서 FCP 점수 하락은 TanStack Virtual 라이브러리 무게가 주 원인이라기보다 Lighthouse 시뮬레이션 모델/측정 노이즈 영향으로 보는 게 더 맞아 보입니다.

@manNomi

manNomi commented Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

FCP/LCP 상세 재측정

추가로 Lighthouse 기본 simulate 값만 보지 않고, 실제 trace 기반 provided 모드와 Chrome PerformanceObserver 직접 측정을 나눠서 확인했습니다.

1. Lighthouse 기본 simulate vs 실제 trace provided

Mode Metric before after delta
simulate FCP 1,702 ms 2,593 ms +892 ms
simulate LCP 13,357 ms 13,361 ms +4 ms
simulate Speed Index 1,702 ms 2,776 ms +1,074 ms
simulate TBT 125 ms 74 ms -51 ms
provided FCP 187 ms 146 ms -41 ms
provided LCP 187 ms 146 ms -41 ms
provided Speed Index 218 ms 183 ms -35 ms

즉 Lighthouse 기본 simulate에서만 FCP/Speed Index가 악화처럼 나오고, 실제 trace 기반으로는 FCP/LCP/Speed Index가 모두 개선됩니다.

2. 직접 브라우저 측정 PerformanceObserver

Chrome headless + mobile viewport + cache disabled, 5회 median:

Metric before after
FCP 156 ms 136 ms
LCP 160 ms 144 ms
DOM elements 2,604 346
초기 카드 링크 136 13

실제 throttling 적용, 1.6Mbps/150ms/CPU 4x, 2회 median:

Metric before after
FCP 844 ms 836 ms
LCP 844 ms 836 ms
Long task total 584 ms 389 ms
DOM elements 2,604 346

3. 네트워크 변화

Resource before after
Document 23 KiB 16 KiB
Script 623 KiB 627 KiB
Image 399 KiB / 19 requests 58 KiB / 6 requests
Font 2,010 KiB 2,010 KiB

JS 증가는 약 +4 KiB 수준이고, 이미지/HTML/DOM은 크게 감소했습니다.

4. 왜 simulate LCP가 13초인가

LCP element는 이미지가 아니라 세 번째 카드의 학교명 텍스트(네바다주립대학 라스베이거스(어학연수))입니다. 그리고 PretendardVariable.woff2가 약 2MB로 preload 되고 있습니다.

폰트 요청을 막고 Lighthouse simulate를 다시 돌리면:

Set FCP LCP TBT Perf total bytes
before + font 1,702 ms 13,357 ms 112 ms 73 3,127 KiB
after + font 2,599 ms 13,361 ms 74 ms 71 2,784 KiB
before no font 1,067 ms 2,422 ms 93 ms 97 1,117 KiB
after no font 1,067 ms 1,512 ms 32 ms 100 821 KiB

결론: 이미지/DOM 최적화 효과는 실제 브라우저 지표와 provided trace에서 개선으로 확인됩니다. 기본 Lighthouse simulate의 13초 LCP는 리스트 이미지가 아니라 2MB webfont가 모바일 throttling 모델에서 크게 잡힌 영향이 큽니다.

@manNomi
manNomi merged commit 5ceca2f into main Jun 26, 2026
13 checks passed
@manNomi

manNomi commented Jun 26, 2026

Copy link
Copy Markdown
Contributor Author
스크린샷 2026-06-26 오후 2 51 36 before

@manNomi

manNomi commented Jun 26, 2026

Copy link
Copy Markdown
Contributor Author
스크린샷 2026-06-26 오후 3 00 00 이후

@manNomi

manNomi commented Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

최종 성능 검증 정리

PageSpeed Insights 실제 모바일 측정 기준으로 성능 개선을 확인했습니다.

  • 측정 URL: https://www.solid-connection.com/university/kyunghee
  • Performance score: 70 → 89 (+19pt)
  • LCP: 4.7s → 2.2s (-2.5s)
  • TBT: 400ms → 150ms (-250ms)
  • CLS: 0 유지
Metric Before After 판단
Performance score 70 89 개선
FCP 1.1s 1.8s 악화
LCP 4.7s 2.2s 개선
TBT 400ms 150ms 개선
CLS 0 0 유지
Speed Index 4.6s 7.0s 악화

로컬 상세 측정에서도 긴 리스트 병목이 줄어든 것을 확인했습니다.

항목 Before After
DOM elements 2,604 346
초기 카드 링크 136 13
Image requests 19 6
Image transfer 399 KiB 58 KiB
Long task total(throttled) 584ms 389ms

정리하면, 이번 PR의 목표였던 긴 대학 목록 렌더링 부담은 확실히 줄었습니다. PageSpeed 기준 전체 성능 점수도 70에서 89로 올랐고, 사용자가 체감하기 쉬운 LCP/TBT가 개선됐습니다.

다만 FCP와 Speed Index는 PageSpeed 기준 악화되어 후속 개선 여지가 있습니다. 추가 분석 결과 JS 증가는 약 +4KiB 수준이라 TanStack Virtual 자체보다는, 2MB PretendardVariable.woff2 preload와 above-the-fold 렌더링 전략이 다음 개선 후보로 보입니다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf: 대학 페이지 긴 목록 렌더링 가상화

1 participant