사용자 환경에서 상품 목록 INP 90% 개선하기

서연주·2026년 1월 1일

growdle

목록 보기
1/1

사용자 제보로 상품 목록 화면의 성능 개선 작업을 진행하고 구성원들에게 아래 내용을 공유했다.

Problem

1. 개발 환경과 사용자 환경 차이로 심각성 인지 어려움

구분개발자 PC (나)사용자 PC (이슈 리포트)비고
CPUApple M3 ProIntel Core i5-8400싱글 코어 성능 약 2~3배 차이
RAM18GB16GB멀티태스킹 시 가용 메모리 부족

➡️ CPU 6x slowdown 으로 사용자 환경과 유사한 환경 설정 가능

하단의 Lighthouse 측정 결과를 통해 실제 성능 차이를 확인할 수 있음(INP는 약 5배 차이, CLS는 3배 차이). 상단의 이미지가 CPU no-throttle, 하단의 이미지가 CPU 6x slowdown한 결과다.

20251203_local-stg_cpu-no-slowdown_network-no-throttle

20251203_local-stg_cpu-6x_network-no-throttle

➡️ 반응성은 5배, 레이아웃 안정성은 3배, 전체 실행 시간 대비 JS 실행 시간 비중이 40% 증가

무한 스크롤이 아닌, 페이지 당 최대 100개 항목을 표시하는 페이지네이션이 적용된 화면이었기에 더 측정 전까지 더 안일하게 생각하던 것도 있었다.

2. 문제는 떨어지는 반응성

📚 LCP(Largest Contentful Paint)란? *출처

  • 페이지에서 가장 큰 콘텐츠(예: 이미지, 텍스트 블록)가 화면에 렌더링되는 데 걸리는 시간
  • 이미지 최적화, 서버 응답 속도 개선, 폰트 로딩 최적화로 개선 가능

e.g.) 사용자가 사이트에 들어왔을 때, 화면 대부분을 차지하는 배너 이미지가 2.3초 만에 나타나면 → LCP = 2.3s

📚 INP(Interaction to Next Paint)란? *출처

  • 사용자가 버튼을 누르거나 입력을 했을 때, 화면에 반응이 렌더링되기까지 걸린 시간
  • JS 최적화, 이벤트 핸들러 경량화, 비동기 처리로 개선 가능

e.g.) 버튼 클릭 후 40ms 만에 새 UI가 뜨면 → INP = 40ms

📚 CLS (Cumulative Layout Shift)란? *출처

  • 페이지의 콘텐츠가 예고 없이 움직인 정도, 즉 ‘레이아웃 흔들림’을 측정
  • 이미지 크기 고정, 폰트 로딩 최적화, 광고 자리 예약으로 개선 가능

e.g.) 글 읽다가 갑자기 이미지가 로딩되며 아래 텍스트가 내려가면 → CLS ↑ (점수 나빠짐)

아래는 성능 최적화 전 PRD 페이지의 성능을 Lighthouse를 통해 측정한 결과이다. 위에서부터 차례대로 1) 수집상품 목록-카드뷰 2) 등록상품목록 - 카드뷰 3) 수집상품목록 - 리스트뷰 4) 등록상품목록 - 리스트뷰

수집상품 목록-카드뷰

등록상품목록 - 카드뷰

수집상품목록 - 리스트뷰

등록상품목록 - 리스트뷰

Solution

0. 목표 설정

Google의 RAIL(Response, Animation, Idle, Load) 모델과 Core Web Vitals 가이드를 기준으로 목표 설정

Core Web Vitals 가이드

지표목표 수치설정 근거 (RAIL 모델 & Web Vitals)
LCP2.5초 이하페이지 로딩 시작 후 주요 콘텐츠가 2.5초 이내에 떠야 사용자가 이탈하지 않고 집중력을 유지
INP200ms 이하사용자가 클릭 등 인터랙션을 했을 때, 100ms~200ms 이내에 반응해야 "즉각적이다"라고 느낌. 300ms가 넘어가면 렉이 걸린다고 인지
CLS0.1 이하0.1 이하는 시각적으로 안정적인 수준

1. 가상화 ; 화면에 보이는 요소만 렌더링하기

📚 가상화란?

실제로 화면에 보이는 요소만 렌더링 하고 보이지 않는 요소는 렌더링하지 않는 기법

React Profiler를 통해 DOM 업데이트 시간이 오래 걸린 부분의 병목 컴포넌트를 분석한 결과,

  • 개별 상품을 그리는 요소(ProductRow, ProductListView, ProductTags, SingleProductProcessingButton, Badge 등)가 지속적으로 상위권에 등장
  • 렌더링 사유가 대부분 “처음 렌더링” 혹은 “부모가 렌더링됨”으로 나타남 ➡️ 개별 컴포넌트의 state 변화보다 한꺼번에 많은 아이템을 렌더링하는 구조 자체가 문제

➡️ 한꺼번에 렌더링하는 요소를 줄일 수 있는 가상화 우선 적용

@tanstack/react-virtual

  • 수집상품목록 - 리스트뷰에서 사용하고 있는 @tanstack/react-table과의 호환성
    • 디자인 커스텀 용이
  • react-virtuoso(15KB)에 비해 작은 번들 크기(2.5KB)

이 이유로 가상화 라이브러리는 @tanstack/react-virtual을 선택해서 적용했다.

가상화만 적용한 결과(STG 환경 측정 결과)

안정적인 스크롤 경험을 위해 overscan 값을 보수적으로 카드뷰 3행, 리스트뷰 10행으로 설정

  • 수집상품목록 - 카드뷰
    LCP🟢 1.59s → 🟢 1.74s10% 증가
    INP🔴 899ms → 🟢 83ms90.8% 개선
    CLS🟡 0.16 → 🟢 0.00100% 개선
    메모리약 96.6 MB → 약 68.2 MB29.4% 감소
  • 수집상품목록 - 리스트뷰
    LCP🟢 2.11s → 🟢 2.49s18% 증가
    INP🔴 2,967ms → 🟡 568ms80.9% 개선
    CLS🟡 0.16 → 🟢 0.0287.5% 개선
    메모리약 97.1 MB → 약 67.5 MB30.6% 감소
  • 등록상품목록 - 카드뷰
    LCP🟡 3.00s → 🟢 1.41s47% 개선
    INP🔴 699ms → 🟢 62ms91.1% 개선
    CLS🟡 0.16 → 🟢 0.0287.5% 개선
    메모리약 88.9 MB → 약 58.1 MB34.6% 감소
  • 등록상품목록 - 리스트뷰
    LCP🟡 3.26s → 🟢 2.49s23.6% 개선
    INP🔴 764ms → 🟢 72ms90.6% 개선
    CLS🟡 0.16 → 🟢 0.0287.5% 개선
    메모리약 86.1 MB → 약 55.7 MB35.3% 감소

2. 메모이제이션 ; 바뀐 부분만 다시 렌더링하기

📚 메모이제이션이란?

계산 결과를 메모리에 저장해 두고 동일한 계산이 요청될 때 다시 계산하지 않고 저장된 값을 반환하는 기법

컴포넌트는 내부에 선언된 상태 혹은 부모로부터 내려받은 parameter가 변경되면 리렌더링됨

function IAmYourMother(){
	const [money, setMoney] = useState(3000)
	
	return (
		<IAmYourDaughter budget={money}/> 
		
		// 새로운 3000으로 덮어씌워짐(변경으로 인식)
		<button onClick={() => {setMoney(3000)}}>오직 3000원 뿐</button> 
	)
}

// budget이나 feeling이 변하면 리렌더링
function IAmYourDaughter(budget){
	const [feeling, setFeeling] = useState('happy')
	const 예산 = budget * 0.3
	
	return (
		<div>오늘 제 점심값은 {예산}입니다.</div>
		<div>오늘 제 기분은 {feeling}입니다.</div>
	)
}

함수 레벨

  • 판매가 연산, 등록 상품 삭제, 상품명 렌더링 함수 메모이제이션
    const expectedMargin = useMemo(
        () =>
          getEstimatedProfit({
            price: product.newSalePrice,
            purchaseCost: purchaseCost,
          }),
        [product.newSalePrice, purchaseCost], 
        // 의존성으로 정의된 부분이 변경될 때만 갱신
      )
  • useToggle 훅 메모이제이션
    const toggle = useCallback(() => setToggleState((prev) => !prev), [])
    const on = useCallback(() => setToggleState(true), [])
    const off = useCallback(() => setToggleState(false), [])
    const set = useCallback((value: boolean) => setToggleState(value), [])

컴포넌트 레벨

export default React.memo(ProductItemCard)

💡 export const 컴포넌트명 = … & import { 컴포넌트명 } from …

⇒ 메모이제이션 되지 않은 원본 사용

export default React.memo(컴포넌트명) & import 컴포넌트명 from …

⇒ 메모이제이션 된 버전 사용

🔥 가상화로 인해 스크롤할 때마다 컴포넌트가 리렌더링 → 구독 정보 요청 API가 호출되는 현상 발생

➡️ 파라미터로 구독 정보 전달하도록 구조 수정

적용 결과

수집 상품 목록 Before-After

수집 상품 목록 Before

수집 상품 목록 After

등록 상품 목록 Before-After

등록 상품 목록 Before

등록 상품 목록 After

Insight

최종 성능 개선 결과

📽️ 수집 상품 목록 - 뷰타입 전환 속도 체감 영상

(Before) https://drive.google.com/file/d/1Fyl-XdpmVE9ctkKUxTqhwGOlpZIZhFAO/view?usp=drive_link

(After) https://drive.google.com/file/d/1W1hIV0aZ7CIOiimfywoUgR2wywCE_3PI/view?usp=sharing

📽️ 등록 상품 목록 - 뷰타입 전환 속도 체감 영상

(Before) https://drive.google.com/file/d/1-Bz1Y1npMuHv9rVlMNHgTnQIUxMZNms7/view?usp=drive_link

(After) https://drive.google.com/file/d/1sW_wKwi6owXiqK7tvVdHSZldhSbzddsw/view?usp=sharing

  • 수집상품목록 - 카드뷰
    LCP🟢 1.45s → 🟢 1.32s9% 개선
    INP🔴 1248ms → 🟢 176ms85.9% 개선
    CLS🟡 0.22 → 🟢 0.0195.5% 개선
    메모리약 103 MB → 약 69 MB29.4% 감소
  • 수집상품목록 - 리스트뷰
    LCP🟢 1.67s → 🟢 1.42s15% 개선
    INP🔴 10064ms → 🟡 480ms95% 개선
    CLS🟡 0.22 → 🔴 1.60
    메모리약 124 MB → 약74.6 MB39.8% 감소
  • 등록상품목록 - 카드뷰
    LCP🟡 3.13s → 🟢 1.41s55% 개선
    INP🔴 1047ms → 🟢 24ms97.7% 개선
    CLS🟡 0.22 → 🟢 0.0195.5% 개선
    메모리약 71 MB → 약 63.2 MB11% 감소
  • 등록상품목록 - 리스트뷰
    LCP🟢 1.45s → 🟢 2.49s11.7% 개선
    INP🔴 1256ms → 🟢 48ms96.2% 개선
    CLS🟢 0.01 → 🟡 0.23
    메모리약 75.8 MB → 약 61.8 MB18.4% 감소

평균적으로 LCP 22%, INP 93.7%, 메모리는 20% 개선,

CLS는 카드뷰에서는 95.5% 개선되었으나 리스트뷰에서는 성능이 저하된 부분 존재.

가능한 후속 작업

  • CLS 개선
    • estimateSize 정확도 개선
    • 행 높이 절대값 고정, 스켈레톤 컴포넌트 높이 일치
  • overscan 감축
  • MemoModal 공통 사용
profile
pizz@ttang

0개의 댓글