공개 카탈로그의 클라이언트 상태 축소와 초기 JavaScript 비용

이 글의 목차 10개 항목
  • 분야: FE
  • 기술 스택: React · TypeScript · Vinext · Lighthouse
  • 관련 기술: hydration · 클라이언트 경계 · 서버 GET 폼 · 초기 자산

문제와 원인

공개 카탈로그는 검색어·분야·페이지·이동 상태를 클라이언트에 보관했다. 서버가 SQLD나 sw 조건을 전달해도 첫 HTML은 기본 SQL·SW 카드 2개를 표시했고, 검색은 브라우저 이벤트에 의존했다.

수정 전 코드

목록 계산

"use client";

import Link from "next/link";
import { useState, type MouseEvent } from "react";

const [navigatingPath, setNavigatingPath] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [fieldId, setFieldId] = useState("");
const [page, setPage] = useState(1);
const catalog = getCatalogPage(fields, { query, fieldId, page });

검색 입력

<input id="catalog-course-search" type="search" value={query}
  onChange={event => { setQuery(event.target.value); setPage(1); }}
  maxLength={100} placeholder="예: SQLD, 정보처리기사, 빅데이터 실기"
  aria-controls="catalog-results" />

수정 후 코드

URL의 GET 매개변수를 서버에서 filters로 전달하고 첫 렌더링에 같은 카드 검색 함수를 적용했다. 카드·페이지 이동은 일반 anchor로 바꾸고 카탈로그의 "use client" 선언을 제거했다.

목록 계산

const query = (filters.query ?? "").slice(0, 100);
const fieldId = fields.some(field => field.id === filters.fieldId)
  ? filters.fieldId ?? "" : "";
const catalog = getCatalogPage(fields, {
  query,
  fieldId,
  page: filters.page ?? 1,
});

검색 입력

<form className="catalog-discovery-controls" action="/" method="get"
  role="search" aria-label="학습 과정 검색">
  <select id="catalog-field-filter" name="field" defaultValue={fieldId}
    aria-controls="catalog-results">
    {/* 분야 옵션 */}
  </select>
  <input id="catalog-course-search" name="q" type="search"
    defaultValue={query} maxLength={100}
    placeholder="예: SQLD, 정보처리기사, 빅데이터 실기"
    aria-controls="catalog-results" />
  <button type="submit">검색</button>
</form>

검증 결과

서버 렌더링과 폼 제출

Node.js·React·TypeScript에서 원본 카탈로그·검색 모듈에 합성 SQL·SW 분야 2개를 전달했다. 하위 Link·아이콘·광고·가이드 요소는 최소 대역으로 연결하고, 필터 3종을 전후 각각 1회 서버 렌더링했다.

카탈로그 비교수정 전수정 후
useState 호출 위치4곳0곳
JSX 이벤트 핸들러 위치10곳0곳
네이티브 GET 검색 폼0개1개
검색 입력 이름q·field 없음q·field 있음
필터 없는 첫 HTML카드 2개카드 2개
query=SQLD의 첫 HTML카드 2개SQL 카드 1개
fieldId=sw의 첫 HTML카드 2개SW 카드 1개

로컬 독립 SSR 화면은 1,280×720 CSS px·DPR 2, 합성 분야 2개, 문서별 script 태그 0개로 구성했다. 전후 q=SQLD 문서 비교와 수정 후 GET 제출은 각각 1회 실행했다.

브라우저 문서수정 전수정 후
q=SQLD 카드SQL·SW 2개SQL 1개
검색 입력값빈 문자열SQLD
?field=sw&q= GET 제출—SW 카드 1개·선택값 sw·script 0개

같은 q=SQLD 조건에서 기존 첫 HTML은 필터되지 않은 SQL·SW 카드 2개를 반환했다.

수정 전: SQLD 조건에도 SQL·SW 카드 2개 표시원본 보기 (새 창)

수정 전: SQLD 조건에도 SQL·SW 카드 2개 표시

100%원본 보기 (새 창)

확대한 이미지는 상하좌우로 이동할 수 있습니다. 키보드는 이미지 영역에 초점을 두고 방향키를 사용하세요. Esc로 닫습니다.

서버에서 조건을 적용한 첫 HTML에는 SQLD 입력값과 SQL 카드 1개가 남았다.

수정 후: SQLD 입력과 SQL 카드 1개 표시원본 보기 (새 창)

수정 후: SQLD 입력과 SQL 카드 1개 표시

100%원본 보기 (새 창)

확대한 이미지는 상하좌우로 이동할 수 있습니다. 키보드는 이미지 영역에 초점을 두고 방향키를 사용하세요. Esc로 닫습니다.

분야를 SW로 바꿔 GET으로 제출하면 선택값 sw와 SW 카드 1개가 표시됐다.

GET 제출 후 SW 카드 1개 표시원본 보기 (새 창)

GET 제출 후 SW 카드 1개 표시

100%원본 보기 (새 창)

확대한 이미지는 상하좌우로 이동할 수 있습니다. 키보드는 이미지 영역에 초점을 두고 방향키를 사용하세요. Esc로 닫습니다.

초기 JavaScript 비용

과거 공개 홈의 Lighthouse mobile 원본 1회는 저장소 초기화를 포함한 cold-browser 합성 실행이다. /_next/static/ 앱 Script 10개의 전송량은 141,772B, 압축 해제 크기는 462,433B였고, 3개 자산의 첫 화면 절감 가능량은 71,881B로 추정됐다. 집계 대상은 앱 Script이며 외부 주입 스크립트·CSS·HTML·RSC는 제외했다.

초기 앱 JavaScript전송량압축 해제 크기
framework59,139B190,152B
Vinext runtime38,725B129,994B
앱 브라우저 진입점33,771B119,507B
카탈로그·Link·오류 경계 등 나머지 7개10,137B22,780B
합계 10개141,772B462,433B

보고서의 unused-javascript 진단은 첫 화면의 절감 대상으로 잡힌 자산 3개를 표시한다. 이 진단의 전송량 합계는 131,367B · 128.3KiB, 절감량 추정 합계는 71,881B · 70.2KiB다. 위 표의 network-requests.transferSize는 나머지 7개를 포함한 앱 Script 10개의 전송량이다.

보고서의 대상 자산진단 전송량절감량 추정
framework59,076B · 57.7KiB24,812B · 24.2KiB
Vinext runtime38,661B · 37.8KiB24,654B · 24.1KiB
앱 브라우저 진입점33,630B · 32.8KiB22,415B · 21.9KiB
합계 3개131,367B · 128.3KiB71,881B · 70.2KiB
Lighthouse 초기 JavaScript 절감 추정과 대상 자산원본 보기 (새 창)

Lighthouse 초기 JavaScript 절감 추정과 대상 자산

100%원본 보기 (새 창)

확대한 이미지는 상하좌우로 이동할 수 있습니다. 키보드는 이미지 영역에 초점을 두고 방향키를 사용하세요. Esc로 닫습니다.

현재 서버 LearnerPage가 카탈로그를 구성하고, StudyHomepage와 공개 헤더의 테마·메뉴는 클라이언트 요소로 실행된다.