개발 환경에서 진행한 성능 최적화로, 운영 환경보다 성능이 떨어질 수 있습니다.
데모데이로 캠퍼들이 서비스를 사용하며 개선할 사항들에 대해 정리해줬는데, 그 중 하나가 페이지 라우팅이 오래 걸린다는 점이었다.
우리는 웹소켓 장애 대응을 위해 sentry를 적용한 상태다.
마침 sentry에서 성능에 관련된 대시보드를 제공하기 때문에, 얼마나 지연되는지 평균을 찾아볼 수 있었다.
그 중 한 페이지를 사진으로 찍어왔다.

OPERATION 이라고 적힌 부분에서 어떤 타입에 대한 분석인지를 확인할 수 있다.
나는 라우팅과 관련된 사항을 개선하고자 하므로, navigation 이라고 적힌 부분을 보면 된다.
사진상에는 대부분이 navgation 이라고 적혀있으니 위 사진을 토대로 분석을 해보자.
/group/:groupId/post/:draftId (pageload)/group/:groupId (navigation)전체적으로 훑어봐도, navigation에서의 지연이 발생하고 있음을 파악할 수 있다.
이제 이에 대한 원인을 알아보자.
일단 가장 많은 유저가 접하게 될 루트 경로인 / 을 확인해봤다.

눈에 띄는 구간은
/ 경로 GET 요청이렇게 3개로 분류할 수 있으며, 불필요한 직렬 요청과 인증 과정이 중복되어 병목이 발생하고 있다.
1. 무거운 인증 및 세션 확인 절차
가장 큰 막대 그래프를 차지하는 부분이 인증 관련 로직이다.
POST /api/auth/callback/credentials 와 그 아래의 POST /v1/auth/excahnge 가 전체 로딩 시간의 상당 부분을 점유하고 있다.2. 미들웨어 및 서버 사이드 처리 지연
http.server - GET / 내부에서 페이지 컴포넌트를 해석하고 빌드하는 과정이 약 613.99ms 정도 소요되고 있다.2번에서 새로 알게 된 사실이 있다.
서버 컴포넌트와 라우팅 지연이 next.js app 라우터 구조에서 깊은 연관이 있다는 것이다.
일반적인 React 프로젝트(CSR)에서는 클릭하면 즉시 페이지가 바뀌게 된다.
그 다음, 페이지 내부에서 API를 호출하게 되기 때문에 데이터 로드에 대한 유저 피드백을 고려하게 된다.
하지만 Next.js 서버 컴포넌트에서는 서버에서 데이터를 다 가져온 뒤에 완성된 결과물인 RSC payload를 브라우저로 보낸다.
즉, 서버 컴포넌트에서의 api 패칭 처리와 같은 데이터 로드가 끝날 때까지 서버는 브라우저에 응답을 주지 않는다.
이때 유저는 클릭했지만 아무 변화가 없으니, 라우팅 자체가 안되고 지연되고 있다고 느끼게 되는 것이다.
서버 컴포넌트에서의 API 처리가 왜 라우팅에까지 영향을 주는것인지 이해가 안됐었는데, 바로 고개를 끄덕이게 됐다.
사실, 모든 서버 컴포넌트 페이지에서 이런식으로 동작하도록 코드를 적어놔서 이 부분만 수정해도 많이 개선될 것 같다.
일단 각 문제를 어떻게 개선할 수 있을지를 정리해보자.
1. 인증 로직 최적화
사실, 이미 인증 로직은 한 번 최적화 과정을 거쳤다.
next-auth로 세션을 관리하고 api 호출시에 세션이 필요해 api 호출시마다 매번 세션을 조회하도록 로직을 작성했었다.
그랬더니, 모든 api 호출마다 네트워크 탭에서 세션을 조회하는 api가 호출되고 있는걸 확인했다.
그래서 이는 메모리로 캐싱처리해서, 5분 동안은 캐싱된 데이터를 사용하도록 로직을 수정해주었다.
그럼에도 부족한 것으로 파악이 되니, 캐싱 시간을 5분에서 10분으로 늘리는 것으로 타협을 보려고 한다.
우리 서비스는 15분이 백엔드 토큰 만료시간이기 때문에, 이보다는 적은 시간으로 캐시 만료 시간을 정해두는게 안정성 측면에서 좋을 것으로 생각했다.
추가로, 해당 메인 페이지(/)가 로그인 후 세션을 저장하는 로직이 포함되어 있는 페이지와 연관되어 있어서 인증 관련 시간이 많이 소요된 것으로 파악된다.
2. Suspense를 통한 스트리밍 도입
서버 컴포넌트에서 hydration 처리만 해주고 suspense를 적용하지 않았다.
그래서 데이터 패칭이 완료될 때까지 페이지 이동 없이 지연되고 있던 문제이다.
Suspense 를 사용해서 레이아웃을 먼저 보여주고, 데이터가 필요한 부분만 로딩 상태를 보여주는 방식으로 UX를 개선할 수 있을 것 같다.
또한, 서버 컴포넌트에서 데이터를 클라이언트로 내려주겠다고 queryClient.setQueryData 를 해주고 있다.
tanstack query의 장점을 살리려면 prefetchQuery 를 사용해서 서버에서 미리 캐싱하는게 정석적일 것 같다.
setQueryData vs prefetchQuery
setQueryData prefetchQuery dateUpdatedAt타임스탬프없음 올바르게 설정 staleTime존중무시 만료 여부 추적 클라이언트에서 즉시 재요청 가능성 높음 stale 판단 후 결정 에러 처리 직접 해야 함 내장
setQueryData는 캐시에 데이터를 강제 주입하는거라 Tanstack Query가 이 데이터가 언제 패칭됐는지, 아직 유효한지 알 수 없다.
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ['records', 'preview', selectedDate, 'personal'],
queryFn: () => getCachedRecordPreviewList(selectedDate),
});
현재 코드는 Page 메인 함수 안에 들어있어서, 데이터가 다 오기 전까지 지금처럼 라우팅이 지연되는 문제가 발생한다.
데이터 패칭 로직을 메인 페이지 밖으로 옮겨서, 데이터가 없어도 일단 페이지 레이아웃부터 보여주도록 수정해야 한다.
// 현재
export default async function HomePage() {
// 여기서 await를 해버리면, 데이터가 올 때까지 아래 리턴문(HTML)은 브라우저에 전송되지 않음
const [data1, data2] = await Promise.all([fetch1, fetch2]);
return <Layout>{/* 데이터 사용 */}</Layout>;
}
// 수정 방법
export default function HomePage() {
return (
<Layout>
<WeekCalendar /> {/* 얘는 데이터가 필요 없으니 브라우저에 즉시 뜸 */}
<Suspense fallback={<HomeSkeleton />}>
{/* await는 이 안에서 수행됨 */}
<HomeContent />
</Suspense>
</Layout>
);
}
// 별도 파일 혹은 같은 파일 아래에 작성
async function HomeContent() {
// 여기서 await를 하더라도 HomePage는 이미 브라우저에 전달된 상태
const [data1, data2] = await Promise.all([fetch1, fetch2]);
return <RecordList data={data1} />;
}
이런식으로 서버 컴포넌트 로직을 모두 수정해주자.

performance 탭에서 녹화 기능을 사용해 라우팅에 얼마나 걸리는지, 혹은 오래 걸리는 작업은 없는지를 파악하고 있었는데
프로필 페이지에서 메인 페이지로 이동하니까 이동하기 직전에 저렇게 long task가 길게 잡혔다.
앞의 빨간 Long task는 프로파일링할 때의 오버헤드라서 제외했다.
Call tree를 분석해보면:
Run microtasks → handleResult → dispatchSetState → React 렌더링 작업이건 메인 페이지로 라우팅할 때 React가 새 페이지를 렌더링하면서 메인 스레드를 블로킹하는 문제이다.
아래 파일들은 모두 위 사진인 메인 페이지(
/)에서 사용하는 컴포넌트들이다.
RecordList.tsx를 보면
{(() => {
// 블록을 row별로 그룹화
const rowMap = new Map<number, Block[]>();
record.blocks.forEach((block) => { ... });
const sortedRows = Array.from(rowMap.entries()).sort(...);
return sortedRows.map(([rowNumber, blocks]) => {
const sortedBlocks = blocks.sort(...);
const hasFullWidth = sortedBlocks.some(...);
// ...
});
})()}
문제: 각 기록마다 매번 새로운 Map 생성 → 정렬 → 필터링을 반복한다.
기록이 10개면 이 작업이 10번 반복되고, 메인 스레드를 블로킹하게 된다.
// WeekCalendar.tsx
useEffect(() => {
if (!dateParam) {
const today = formatDateISO();
router.replace(`/?date=${today}`);
}
}, [dateParam, router]);
문제: 페이지 로드 시 URL에 date 파라미터가 없으면 router.replace를 호출해서 추가 렌더링이 발생한다.
주간 달력을 표현하는 컴포넌트에서의 문제인데,
AnimatePresence + motion.div + drag 기능이 초기 렌더링 시 많은 이벤트 리스너를 등록한다.
블록 정렬 로직을 별도 함수로 분리하고 useMemo로 감싼다.
// 각 기록의 블록을 useMemo로 최적화
const sortedRowsMap = useMemo(() => {
return records.map(record => {
const rowMap = new Map<number, Block[]>();
record.blocks.forEach((block) => {
const row = block.layout.row;
if (!rowMap.has(row)) rowMap.set(row, []);
rowMap.get(row)!.push(block);
});
return Array.from(rowMap.entries()).sort(([a], [b]) => a - b);
});
}, [records]);
RecordItem 컴포넌트로 분리useMemo로 감싸서 불필요한 재계산 방지React.memo로 컴포넌트 메모이제이션효과: 이전에는 각 기록마다 매번 Map 생성 → 정렬을 반복했지만, 이제는 블록이 변경될 때만 계산
서버 컴포넌트가 이미 날짜를 설정하고 있으니, WeekCalendar의 useEffect를 제거한다.
또한, 초기엔 날짜 파라미터가 url 상에 없으니 굳이 억지로 추가해 서버 컴포넌트가 두 번 실행되지 않도록 한다.
useEffect에서 router.replace 제거redirect 제거효과: 초기 렌더링 시 불필요한 추가 렌더링 제거
RecordList.tsx의 BlockContent를 memo 처리하면 불필요한 리렌더링을 방지할 수 있다.
BlockContent와 ImageBlock 컴포넌트를 React.memo로 감싸기효과: 블록 개수만큼 리렌더링 절감

175.7ms → 134.9ms로 Run microtasks 활동에 대한 작업 시간을 줄였다.
메인 페이지에서 프로필 페이지로 이동할 때도 performance를 돌려봤다.
아래 사진은 프로필 페이지인 마이페이지다.

Timer fired는 Effect 실행 시간이다.
프로필 페이지에선 통계 자료를 보여주도록 되어 있는데, 이에 대한 useEffect 실행에 의한 것이라 판단했다.
<div className={cn(
'overflow-hidden transition-all duration-300 ease-in-out',
isChartVisible ? 'max-h-300 opacity-100' : 'max-h-0 opacity-0',
)}>
<MonthlyUsageChart /> // 항상 렌더링됨
<PlaceDashboard />
<EmotionDashboard />
</div>
문제: display: none이 아니라 max-h-0로 숨기고 있어서, 차트가 항상 렌더링되고 있다.
즉, CSS로만 숨기고 있어서 차트가 항상 렌더링되고, useEffect 등이 모두 실행되는 문제이다.
이를 필요할 때만 렌더링되도록 해 불필요한 useEffect 실행을 막아주자
{isChartVisible && ( // 필요할 때만 렌더링
<div className="...">
<MonthlyUsageChart />
<PlaceDashboard />
<EmotionDashboard />
</div>
)}
결과적으론, 해당 문제에 대한 long task가 사라졌다.
함께 기록함 페이지에서, 하단 네비게이션바의 + 버튼을 클릭하면 어떤 그룹에 기록을 추가할 것인지 묻는 drawer가 표시된다.
그런데 이 drawer가 표시되는 데 시간이 꽤 걸리길래, performance로 측정해봤다.

클릭 이벤트가 처리되는 데 시간이 꽤 걸리는 것으로 보인다.
/shared 페이지에서 + 버튼 클릭 시 GroupSelectDrawer가 열리는데, 이때 groups 데이터를 enabled: isGroupSelectOpen으로 조건부로만 패치하고 있어서 지연이 발생한다.
해결 방법: /shared 페이지에서는 drawer가 열리기 전에 미리 데이터를 prefetch하도록 수정

데이터 패치는 /shared 페이지에서 불러오는 데이터와 동일하기 때문에, 캐시된 데이터를 사용하게 된다.
그렇기에 빠르게 데이터를 불러올 수 있게 되에 위 사진처럼 265.6ms → 102.0ms로 이벤트 처리 시간을 줄일 수 있었다.
수정 전 사진과 조금 형태가 다른데, 캐시된 데이터를 사용하게 되어서 빨리 뜰까봐 페이지 이동과 동시에 바로 drawer가 뜨도록 인터렉션을 걸어봤다.
/group/:groupId 경로 개선sentry에서 확인했을 때, draft 페이지 다음으로 지연이 심했던 페이지가 특정 그룹 내부 페이지로 이동할 때였다.
그래서 함께 기록함에서 그룹 페이지로 이동할 때의 performance를 측정해봤다.


Suspense 를 적용하기 전보다는 라우팅 지연이 개선되긴 했지만, 그럼에도 지연되는 부분이 존재했다.
페이지를 이동한 뒤에는 Timer fired 문제가, 이동 전 함께 기록함에서는 forced reflow 문제가 발생하는 것으로 파악이 된다.
코드를 확인한 결과,
/shared 페이지의 RecordCard들이 memo 없이 매번 재렌더링되며, 각 카드의 AssetImage가 layout recalculation을 유발MonthRecords도 동일한 문제여기서 Timer fired를 조금 더 자세히 적자면,
useEffect 훅 실행MonthRecords 컴포넌트의 여러 RecordCard들AssetImage 로딩GalleryDrawer (항상 렌더링되고 있었음)즉, 리렌더링 문제로 파악을 해서 주요 컴포넌트들을 React.memo로 최적화를 진행했다.
그리고 memo를 적용해줘도 props가 자꾸 바뀌면 memo가 작동하지 않기 때문에, 이 부분도 같이 확인해줬다. (props 인라인 함수)
그래서 불필요하게 바뀌는 props도 callback으로 최적화를 시켜줬다.
결과는 다음 사진들과 같다.


Timer fired는 163.3ms → 65.8ms로, forced reflow와 연관된 작업은 375.7ms → 171.3ms로 개선됐다.
그리고 직접 라우팅을 할 때도 개선하기 전보다 이동이 빨라진게 느껴졌다.
그룹 프로필 수정 페이지에 개인 프로필을 설정할 수 있는 수정 페이지가 존재한다.
해당 페이지로 라우팅할 때, 지연이 발생하는 것을 확인해서 이 부분도 같이 측정해봤다.
Call tree 보단 Bottom-up이 원인을 파악하기에 더 수월한 것 같아서 사진을 바꿔봤다.

코드를 확인했을 때, 매번 인라인 함수로 멤버 리스트를 생성하고 있어서 리렌더링 문제가 발생한걸로 추측되었다.
그래서 이 인라인 함수들을 모두 분리해주는 등 최적화 작업을 진행해줬다.

이전/이후 전체 시간을 찍고 내부 시간들을 찍어야 하는데 까먹는다..
가장 눈에 띄었던 Layout 작업이 143.1ms → 101.6ms로 개선된 것을 확인할 수 있었다.
가장 지연이 심했던 작성 페이지를 개선해볼까 한다.


Layout과 Recalculate style에 지연이 발생하고 있음을 파악할 수 있다.
여기도 비슷하게 인라인 함수를 많이 사용하고 있는걸 코드상에서 파악을 해서 똑같이 수정해줬다.

long task가 사라졌다…
인라인 함수 수정과 메모이제이션만 적용해줬는데 개선이 된걸 보고, 컴포넌트에 넘겨줄 때 인라인 함수가 얼마나 안좋은지 알게 된 것 같다.
똑같은 Layout은 173.9ms → 31.7ms, Recalculate style은 14.4ms → 7.7ms로 줄었다.
그 외 다른 것들도 조금씩 감소된걸 확인할 수 있다.
라우팅 지연 최적화 작업은 사실 2주? 정도 된 작업이다.
다른 최적화 작업을 진행하느라 정리를 제대로 못해서 한 번에 정리해서 적어봤다.
애초에 작업하면서 찍어놓고 정리한 것도 이해하면서 읽기 편한 상태가 아니었어서 정리를 한 뒤에도 조금 가독성이 떨어지는 것 같다.
이번 작업을 하면서 깨달은 것은, 인라인 함수는 리렌더링이 될 때 새로 생성되기 때문에 props로 해당 함수를 전달받는 컴포넌트가 매번 리렌더링 된다는 것이다.
그렇기에 인라인 함수로 자식 컴포넌트에게 전달하기 보단, useCallback으로 감싸거나 인라인이 아닌 함수화해서 해당 함수를 전달하는 방식을 선택해야 한다는 것이다.
그리고 컴포넌트를 렌더링하는 영역인 return 함수 안에는 계산 로직이 들어가지 않도록 하는게 마찬가지로 리렌더링 이슈 개선에 좋다는 것도 알게 된 것 같다.
이 최적화 작업에 대한 PR이 이미 머지된 상태인데, 아직 이전과 비교할만한 아직 데이터가 쌓이지 않아서 전후 비교를 하지 못하고 있다.
스토어 출시를 목표로 하고 있으니, 이후에 쌓인 데이터로 전후 비교를 할 수 있지 않을까 싶다!