- 분야: 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개를 반환했다.
서버에서 조건을 적용한 첫 HTML에는 SQLD 입력값과 SQL 카드 1개가 남았다.
분야를 SW로 바꿔 GET으로 제출하면 선택값 sw와 SW 카드 1개가 표시됐다.
초기 JavaScript 비용
과거 공개 홈의 Lighthouse mobile 원본 1회는 저장소 초기화를 포함한 cold-browser 합성 실행이다. /_next/static/ 앱 Script 10개의 전송량은 141,772B, 압축 해제 크기는 462,433B였고, 3개 자산의 첫 화면 절감 가능량은 71,881B로 추정됐다. 집계 대상은 앱 Script이며 외부 주입 스크립트·CSS·HTML·RSC는 제외했다.
| 초기 앱 JavaScript | 전송량 | 압축 해제 크기 |
|---|---|---|
| framework | 59,139B | 190,152B |
| Vinext runtime | 38,725B | 129,994B |
| 앱 브라우저 진입점 | 33,771B | 119,507B |
| 카탈로그·Link·오류 경계 등 나머지 7개 | 10,137B | 22,780B |
| 합계 10개 | 141,772B | 462,433B |
보고서의 unused-javascript 진단은 첫 화면의 절감 대상으로 잡힌 자산 3개를 표시한다. 이 진단의 전송량 합계는 131,367B · 128.3KiB, 절감량 추정 합계는 71,881B · 70.2KiB다. 위 표의 network-requests.transferSize는 나머지 7개를 포함한 앱 Script 10개의 전송량이다.
| 보고서의 대상 자산 | 진단 전송량 | 절감량 추정 |
|---|---|---|
| framework | 59,076B · 57.7KiB | 24,812B · 24.2KiB |
| Vinext runtime | 38,661B · 37.8KiB | 24,654B · 24.1KiB |
| 앱 브라우저 진입점 | 33,630B · 32.8KiB | 22,415B · 21.9KiB |
| 합계 3개 | 131,367B · 128.3KiB | 71,881B · 70.2KiB |
현재 서버 LearnerPage가 카탈로그를 구성하고, StudyHomepage와 공개 헤더의 테마·메뉴는 클라이언트 요소로 실행된다.