본문 바로가기
카테고리 없음

[ 개선 ] Lighthouse 경고 하나씩 없애보기 3: Layout Shift Culprits 해결하기

by CODESIGN 2026. 5. 25.
반응형

 

Lighthouse에서 다음 경고가 나왔다.

 

Layout shift culprits



이 경고는 CLS, 즉 Cumulative Layout Shift와 관련된 항목이다.

Lighthouse 설명은 대략 이렇다.

Layout shifts occur when elements move absent any user interaction.


사용자가 아무 동작도 하지 않았는데 페이지 요소가 움직이면 layout shift가 발생한다.  
그리고 이런 이동이 누적되면 CLS 점수가 나빠진다.

이번 케이스에서는 Lighthouse가 특정 `div`를 layout shift culprit로 잡았다.  
스크린샷을 보면 보고서 목록 페이지의 본문 영역이 로딩 중 아래로 밀린 것으로 보였다.

 

CLS가 왜 문제일까?


CLS는 사용자가 페이지를 보는 도중 화면 요소가 얼마나 많이 움직였는지를 나타내는 지표다.

예를 들어 이런 상황이다.

- 버튼을 누르려는 순간 위쪽 배너가 늦게 나타나 버튼이 아래로 밀림
- 이미지 높이가 뒤늦게 잡혀 본문이 갑자기 내려감
- 폰트가 바뀌면서 텍스트 줄 바꿈이 달라짐
- 헤더 높이가 로딩 후 계산되면서 콘텐츠가 이동함

사용자 입장에서는 화면이 “튀는” 것처럼 느껴진다.

특히 클릭하려던 요소가 이동하면 실수 클릭으로 이어질 수 있다.

이번 문제의 원인


이번 프로젝트에서는 고정 메뉴 영역이 원인이었다.

메뉴는 특정 페이지에서 `fixed`로 동작하고 있었다.

<MenuContainer
  fixed={useViewportScroll}
/>


그리고 메뉴가 `fixed`일 때는 문서 흐름에서 빠지기 때문에, 본문이 메뉴 아래에 있도록 별도의 spacer를 넣고 있었다.

{fixed && menuHeight > 0 && <MenuSpacer $height={menuHeight} />}
<MenuWrapper ref={menuRef} $fixed={fixed}>
  ...
</MenuWrapper>



문제는 `menuHeight`의 초기값이었다.

const [menuHeight, setMenuHeight] = useState(0)


처음 렌더링 시점에는 메뉴 높이를 아직 모른다.  
그래서 `menuHeight`는 `0`이고, spacer도 렌더링 되지 않는다.

그 다음 `useEffect`에서 실제 메뉴 높이를 측정한다.

const updateMenuHeight = () => {
  setMenuHeight(menuRef.current?.getBoundingClientRect().height || 0)
}



이후 `menuHeight`가 실제 높이로 바뀌면 spacer가 생긴다.

브라우저 입장에서 렌더링 흐름은 이렇게 된다.


1. 첫 렌더링
   menuHeight = 0
   spacer 없음
   본문이 위쪽에 붙어서 렌더링 됨

2. useEffect 실행
   메뉴 실제 높이 측정

3. 상태 업데이트
   spacer가 생김

4. 본문이 아래로 밀림
   Layout Shift 발생


즉, 사용자가 아무 동작도 하지 않았는데 본문 영역이 아래로 이동했다.

이게 Lighthouse의 `Layout shift culprits`에 잡힌 것이다.

 

해결 방향



핵심은 간단하다.

> 나중에 생길 공간을 첫 렌더링부터 미리 예약한다.

이미 메뉴가 대략 어느 정도 높이를 차지하는지 알고 있다면, 초기값을 `0`으로 두면 안 된다.

기존 코드:

const [menuHeight, setMenuHeight] = useState(0)



수정 후:

const FIXED_MENU_RESERVED_HEIGHT = 166

const [menuHeight, setMenuHeight] = useState(
  fixed ? FIXED_MENU_RESERVED_HEIGHT : 0
)


그리고 spacer도 `menuHeight > 0`일 때만 렌더링 하지 않고, fixed 상태라면 처음부터 렌더링 되게 바꿨다.

기존:

{fixed && menuHeight > 0 && <MenuSpacer $height={menuHeight} />}


수정 후:

{fixed && (
  <MenuSpacer $height={menuHeight || FIXED_MENU_RESERVED_HEIGHT} />
)}



이렇게 하면 첫 렌더링부터 메뉴가 차지할 높이만큼 공간이 확보된다.

이후 실제 메뉴 높이를 측정해서 조금 보정하더라도, 처음부터 본문이 크게 밀리는 문제는 줄어든다.

 

Public 메뉴와 Admin 메뉴 모두 적용



프로젝트에는 일반 메뉴와 관리자 메뉴가 따로 있었다.

- `MenuContainer`
- `AdminMenuContainer`

둘 다 비슷한 방식으로 fixed 메뉴와 spacer를 사용하고 있었다.

그래서 두 파일 모두 같은 방식으로 수정했다.

const FIXED_MENU_RESERVED_HEIGHT = 166

 

const [menuHeight, setMenuHeight] = useState(
  fixed ? FIXED_MENU_RESERVED_HEIGHT : 0
)

 

{fixed && (
  <MenuSpacer $height={menuHeight || FIXED_MENU_RESERVED_HEIGHT} />
)}



이렇게 public 메뉴와 admin 메뉴 모두 초기 공간을 예약하도록 했다.

Lazy loading fallback도 함께 보정



이전에 라우트 단위 lazy loading을 적용하면서 Suspense fallback을 추가했다.

기존 fallback은 단순히 spinner만 보여주는 형태였다.

const RouteFallback = () => (
  <div style={{ display: 'flex', justifyContent: 'center', padding: '48px 0' }}>
    <Spin />
  </div>
)



하지만 fallback 영역이 너무 작으면 lazy chunk 로딩 중에 본문 높이가 작게 잡혔다가, 실제 페이지가 로드되면서 다시 커질 수 있다.

그래서 최소 높이를 추가했다.

const RouteFallback = () => (
  <div
    style={{
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'center',
      minHeight: '240px',
      padding: '48px 0'
    }}
  >
    <Spin />
  </div>
)



이건 메뉴 spacer만큼 핵심 원인은 아니었지만, 라우트 전환 중 화면 흔들림을 줄이는 데 도움이 된다.

이번 작업 요약



이번 CLS 문제는 이미지 크기나 JavaScript 실행 시간이 아니라, **초기 레이아웃 공간 예약 실패**가 원인이었다.

문제 흐름은 이랬다.


fixed 메뉴 사용
→ 메뉴가 문서 흐름에서 빠짐
→ 본문 보정을 위해 spacer 사용
→ spacer 높이를 useEffect에서 나중에 계산
→ 첫 렌더링 때 spacer 없음
→ 이후 spacer가 생기면서 본문이 아래로 밀림
→ CLS 발생


해결은 다음과 같이 했다.

1. fixed 메뉴의 예상 높이를 상수로 정의
2. menuHeight 초기값을 0이 아니라 예약 높이로 설정
3. fixed 상태에서는 spacer를 첫 렌더링부터 렌더링
4. 실제 높이는 이후 측정해서 보정
5. lazy fallback에도 minHeight 추가

마무리



CLS를 줄일 때 가장 중요한 생각은 이것이다.

> 나중에 나타날 요소의 공간을 처음부터 확보하자.

이미지라면 `width`, `height`를 넣고,  
광고나 배너라면 컨테이너 높이를 미리 잡고,  
fixed header라면 header가 차지할 공간을 미리 예약해야 한다.

이번 케이스에서는 fixed 메뉴의 spacer가 늦게 생기면서 본문이 이동했다.  
그래서 메뉴 높이를 미리 예약하는 방식으로 해결했다.

Lighthouse가 `Layout shift culprits`를 보여줄 때는, 해당 요소 자체만 보지 말고 “그 위쪽에서 나중에 생기는 요소가 있는지”를 같이 봐야 한다.

이번 문제도 실제로 움직인 건 본문 영역이었지만, 원인은 그 위에 있는 고정 메뉴 spacer였다.

 

반응형

댓글