근본적인 문제 해결의 중요성

이상함을 눈치채다

어느날 배포되어 있던 블로그를 이리저리 살펴보다가 이상한 부분을 발견했습니다.
뭐가 이상한지 눈치채셨나요? 바로 두 개의 로딩 UI가 존재한다는 것이였습니다.
첫번째 로딩 UI
첫번째 로딩 UI
두번째 로딩 UI
두번째 로딩 UI
높이가 없는 “로딩중입니다…” 라는 p 요소와 함께 푸터가 같이 보여진 뒤, 타이틀과 “기록들을 불러오는 중입니다…”라는 문구가 보여집니다. 로딩 상태가 두번에 걸쳐 보여지다니, 방문자 입장에서는 상당히 난해한 UI입니다.
어찌되었건 사용자에게 좋지 않은 경험이므로, 부끄럽지만 알게 된 지금이라도 고쳐보도록 합시다.

문제 상황

기존 코드는 다음과 같습니다.
<!-- app/records/page.tsx -->
<!-- RecordsPage -->

<RecordsPageLayout>
  <h1 className="mt-16 mb-6">
    여정의 발자취를 작은 기록들로 남겨봅니다. 🐾
  </h1>
  <Suspense fallback={<RecordsPageSkeleton />}>
    <Tags />
    <Records searchParams={searchParams} />
  </Suspense>
</RecordsPageLayout>
<!-- app/records/loading.tsx -->
<!-- RecordsPageSkeleton -->

<p>기록들을 불러오는 중입니다...</p>;
<!-- app/records/_layout.tsx -->
<!-- RecordsPageLayout -->

<main className="mx-auto min-h-screen max-w-[768px] px-4 lg:px-0">
  {props.children}
</main>
Next.js 에 익숙하신 분들은 바로 눈치채셨겠지만, 정말 로딩 UI가 두 번에 걸쳐서 보이도록 구현되어 있습니다.
  1. loading.tsx 로 인해 아무런 레이아웃이 적용되지 않은 단순한 문구만 달랑 보여지고
  2. Suspense 에 적용된 fallback 요소인 RecordsPageSkeleton 가 보여집니다.

개선 방향

현재 코드에서는 Suspense 를 이용한 로딩 UI를 굳이 보여 줄 필요가 없어보입니다.
온전히 loading.tsx 에게 역할을 전부 위임해봅시다.
<!-- app/records/page.tsx -->
<!-- RecordsPage -->

<RecordsPageLayout>
  <h1 className="mt-16 mb-6">
    여정의 발자취를 작은 기록들로 남겨봅니다. 🐾
  </h1>
  <Tags />
  <Records searchParams={searchParams} />
</RecordsPageLayout>
우선, 기록들 화면에서 Suspense 를 제거했습니다.
이러면 loading.tsx 만이 렌더링 될 텐데, 기본적인 레이아웃이 적용되길 원하니 RecordsPageLayout 로 감싸줍니다.
<!-- app/records/loading.tsx -->
<!-- RecordsPageSkeleton -->

<RecordsPageLayout>기록들을 불러오는 중입니다...</RecordsPageLayout>;
로딩 UI는 하나로 줄였지만, 로딩이 완료되면서 레이아웃 시프트가 일어납니다.
개인적으로 로딩 UI에 기록들 화면의 제목이 유지되었으면 좋을 것 같으니, 수정해봅시다.
<!-- app/records/page.tsx -->
<!-- RecordsPage -->

<RecordsPageLayout>
  <Tags />
  <Records searchParams={searchParams} />
</RecordsPageLayout>
<!-- app/records/_layout.tsx -->
<!-- RecordsPageLayout -->

<main className="mx-auto min-h-screen max-w-[768px] px-4 lg:px-0">
  <h1 className="mt-16 mb-6">
    여정의 발자취를 작은 기록들로 남겨봅니다. 🐾
  </h1>
  {props.children}
</main>
기록들 페이지에 있던 h1 요소를 레이아웃으로 옮겼습니다.
제목 요소를 유지함으로써 레이아웃 시프트를 방지했습니다. 이제 좀 깔끔하네요.

나의 실수

저는 위 내용을 작성한 뒤, 링크드인에 이 경험을 공유하기 위해 글과 코드를 천천히 곱씹어보다가 ‘공식 문서 지침대로 구현했는데, 왜 이중 로딩 UI가 나오게 된 걸까?’라는 의문이 들었습니다. 하지만 대부분의 경우 내가 잘못했을 확률이 높기 때문에, 제 코드에 잘못된 부분이 있을 것이라 생각했습니다.
문득 레이아웃 파일명이 이상함을 눈치챘습니다. Next.js 에서 권장하는 컨벤션은 layout.tsx 인데 저는 _layout.tsx 를 쓰고 있었습니다.
커밋 기록을 찾아보니, 중첩된 라우트에서 레이아웃 중복 현상을 해결하기 위함이었음을 기억해냈습니다. 당시 저는 /records/[slug] 페이지의 레이아웃이 상위 페이지인 /records 의 레이아웃과 겹치는 현상을 해결하기 위해 다음과 같이 코드를 수정했습니다.
app/
  records/
    _layout.tsx << 파일명에 _를 붙여서 기본 동작을 회피
    page.tsx
    [slug]/
	    _layout.tsx << 파일명에 _를 붙여서 기본 동작을 회피
	    page.tsx
// records/_layout.tsx
export function RecordsPageLayout(props) {
  return (
    <main className="...">
      {props.children}
    </main>
  );
}

// records/_layout.tsx
export default async function RecordsPage(params) {
  return (
    <RecordsPageLayout>
      <h1 className="...">
        여정의 발자취를 작은 기록들로 남겨봅니다. 🐾
      </h1>
      <Suspense fallback={<... />}>
				{/* ... */}
      </Suspense>
    </RecordsPageLayout>
  );
}
// records/[slug]/_layout.tsx
export function RecordPageLayout(props) {
  return (
    <div className="...">
	    {props.children}
    </div>
  );
}

// records/[slug]/page.tsx
export default async function RecordPage(params) {
  return (
    <RecordPageLayout>
			{/* ... */}
    </RecordPageLayout>
  );
}
옛날의 저는 중첩된 레이아웃 문제를 우아하게 해결하는 방법을 몰랐습니다. 그래서 단순히 Next.js 가 제공하는 파일 컨벤션을 회피하는 방식으로 문제를 해결했었습니다. (Next.js에 조예가 깊으신 분들은 “문제 상황” 부분에서 코드가 왜 이렇지? 라고 생각하셨을 것입니다.)
이 상태에서 이중 로딩 UI 문제가 불거졌고, 저는 그 문제를 해결하기 위해 컨벤션을 벗어난 코드를 작성해버렸습니다.
‘더 나은 방법이 없는 걸까?’ 라는 생각이 들어서 구글링을 해보니, 라우트 그룹 기능을 이용해 문제를 해결할 수 있음을 알게 되었습니다. 부끄럽기도 하고 허탈하네요.

근본적인 해결

어떤 문제를 근본적으로 해결하지 않고 회피해버리면 다른 문제가 파생되기도 합니다. 파생된 문제를 해결하기 위한 방법도 결국 우아한 해결법이 아닐 확률이 높습니다.
마찬가지로 근본적인 해결을 하지 못한 코드 위에서는 파생된 문제를 우아하게 해결하기 힘들 것입니다. 마치 모래 위의 누각처럼요.
다행히 라우트 그룹을 이용해 중첩된 레이아웃 문제를 해결했고, 자연스레 중첩된 레이아웃 문제를 회피하기 위한 이상한 코드를 제거했습니다. 이상한 코드를 제거하니 이중 로딩 UI 문제도 자연스럽게 해결되었습니다. (새삼 Next.js 가 어렵다고 느껴지네요.)
글을 쓰면서도 스스로가 부끄러워 삽질을 했던 윗 내용은 빼버릴까 생각했지만, 저와 비슷한 경험을 했거나 할 수도 있을 분들을 위해 남겨두기로 했습니다. 어찌 되었건 이제 저는 Next.js에서 중첩된 레이아웃을 해결할 줄 아는 엔지니어가 되었으니까 만족합니다. 😁