개발자
류준열
Vercel로 배포했는데 네트워크가 너무 느리면
Next.js로 만든 독서 SNS를 배포했다. 로컬에서는 큰 불편이 없었는데, 배포한 사이트에서는 페이지를 이동할 때마다 기다리는 느낌이 강했다.
처음에는 서버 컴포넌트의 데이터 조회 방식이 원인이라고 생각했다. 화면을 클라이언트 컴포넌트로 전환하면 해결될 것 같았다.
하지만 화면이 먼저 나타나도록 바꾼 뒤에도 데이터는 여전히 느리게 도착했다. 최종적으로 속도를 크게 개선한 것은 컴포넌트가 아니라 Vercel Function의 리전 설정이었다.
이번 글은 첫 번째 가설이 무엇이었고, 어떤 변경을 거쳐 실제 지연 요인을 찾았는지 정리한 기록이다.
1. 첫 번째 가설: 서버에서 데이터를 기다리느라 화면이 늦게 나오는 걸까?
서비스는 Next.js App Router와 Supabase를 사용하고 있었다. 서버 컴포넌트에서 인증 상태와 데이터를 조회한 뒤 화면을 반환하는 구조였다.
책 상세 페이지를 단순화하면 다음과 비슷했다.
export default async function BookPage() {
const [book, user] = await Promise.all([
getBook(id),
currentUser(),
]);
const [myRecord, reviews] = await Promise.all([
user ? getMyRecord(user.id, id) : null,
getPublicReviews(id),
]);
return (
<BookDetail
book={book}
myRecord={myRecord}
reviews={reviews}
/>
);
}
일부 조회는 병렬로 실행하고 있었지만, 페이지의 JSX를 반환하기 전에 데이터를 기다린다는 점은 같았다. 로딩 경계도 충분히 나뉘어 있지 않았다.
그래서 다음과 같은 가설을 세웠다.
인증과 데이터 조회가 끝날 때까지 새 화면을 보여주지 못해서 페이지 이동이 느리게 느껴지는 것 아닐까?
이 가설은 화면이 늦게 나타나는 현상을 설명할 수 있지만 조회 자체가 왜 오래 걸리는지까지 설명하지는 못했다.
다만 직관적으로 서버컴포넌트에서 블로킹이 생기는 것만 해결하려 했다.
2. Suspense를 검토하면서 생긴 고민
먼저 서버 컴포넌트를 유지하면서 Suspense로 로딩 영역을 나누는 방식을 검토하고 구현을 시도했다.
페이지 헤더
├─ 책 정보
├─ 내 기록
└─ 공개 감상
페이지 전체를 기다리지 않고 준비된 영역부터 보여주자는 접근이었다.
하지만 페이지 전체를 loading.tsx의 스켈레톤으로 교체하는 방식은 원하지 않았다. 짧은 대기에도 화면이 로딩 UI로 바뀌었다 돌아오면, 오히려 깜빡임 때문에 불편할 수 있기 때문이다.
스켈레톤을 쓰더라도 다음 기준은 필요했다.
- 전체 화면보다 데이터 영역 단위로 나눌 것
- 실제 UI와 표지 크기, 버튼 높이, 여백을 맞출 것
- 빠르게 끝나는 요청에는 로딩 표시가 불필요하게 번쩍이지 않게 할 것
이 과정에서 방향을 다시 정했다. 화면 전환과 데이터 로딩을 더 명확히 분리하기 위해 클라이언트 중심 구조로 바꿔보기로 했다.
3. CSR로 전환했지만 해결되지 않는 문제
페이지 UI를 클라이언트 컴포넌트로 옮기고, 조회는 서버 API를 호출하도록 변경했다.
변경 전
페이지 이동 → 서버에서 데이터 조회 → 화면 표시
변경 후
페이지 이동 → 화면 틀 표시 → API 조회 → 데이터 영역 표시
TanStack Query로 조회와 메모리 캐시를 관리하고, 데이터 영역에는 Suspense와 스켈레톤을 적용했다. 저장·삭제 같은 변경 작업은 기존 Server Action을 유지했다.
화면 틀이 먼저 보이니 페이지 이동의 반응은 좋아졌지만 여전히 부분 데이터가 표시되기까지 여전히 2초 이상 걸리는 경우가 있었다. (정밀측정값은 아니고 체감)
여기서 두 가지를 구분할 수 있었다.
| 구분 | 확인할 문제 |
|---|---|
| 화면 표시 지연 | 데이터를 기다리는 동안 무엇을 보여줄 것인가? |
| 데이터 응답 지연 | 데이터가 도착하는 데 왜 오래 걸리는가? |
클라이언트 전환은 첫 번째 문제를 개선했다. 하지만 두 번째 문제를 해결한 것은 아니었다.
4. DB 상태보다 먼저 확인했어야 했던 것
Supabase 대시보드를 확인했다. DB는 싱가포르에 있었고, 화면에 표시된 CPU나 연결 수에서 뚜렷한 과부하 징후는 보이지 않았다.
물론 순간적인 사용량만으로 느린 쿼리가 없다고 결론 내릴 수는 없다. 하지만 다음으로 확인할 대상은 명확했다.
이 DB에 요청하는 배포 서버는 어디에 있을까?
Vercel 프로젝트의 Settings → Functions → Function Region을 열어보니 북미 리전인 iad1이 선택되어 있었다.
| 구성 요소 | 확인한 위치 |
|---|---|
| 사용자 | 한국 |
| Vercel Function | 미국 iad1 |
| Supabase DB | 싱가포르 ap-southeast-1 |
즉, 데이터 요청 경로는 대략 다음과 같았다.
한국의 브라우저
↓
미국의 Vercel Function
↓
싱가포르의 Supabase
로컬에서는 내 컴퓨터가 Supabase에 요청했지만, 배포 후에는 미국의 서버를 거쳐 Supabase에 요청하고 있었던 것이다.
Vercel은 정적 파일을 CDN으로 전달하지만, API나 서버 코드를 실행하는 Function의 위치는 별도로 봐야 한다. 가까운 CDN에서 JavaScript를 받는다고 해서 데이터 조회도 가까운 서버에서 처리되는 것은 아니었다.
Vercel 공식 문서도 함수와 데이터 소스 사이의 물리적 거리가 응답 시간에 영향을 주므로, 함수를 DB 가까이에 배치하도록 권장한다. Vercel Function 리전 문서
5. Function 리전을 변경
Function 리전을 북미에서 아시아로 변경하고 새 배포에 적용한 뒤, 데이터 로딩이 확실히 빨라졌다.
설정 화면에도 안내되어 있듯이, 리전 변경은 저장만으로 기존 배포에 적용되는 것이 아니라 새 배포가 필요하다.
이 사례에서 확인한 것은 다음과 같다.
- 배포 함수와 DB가 서로 먼 지역에 있었다.
- 클라이언트 전환 이후에도 데이터 응답 지연이 남았다.
- 함수 리전을 아시아로 변경한 뒤 사용 중 느끼던 로딩 지연이 크게 개선됐다.
다만 변경 전후의 동일한 API를 반복 측정한 기록은 없다. 따라서 몇 배 빨라졌는지, 전체 지연 중 리전 차이가 정확히 몇 퍼센트였는지까지 말할 수는 없다.
그럼에도 리전 간 통신 지연이 주요 요인이었다는 강한 근거는 얻었다.
6. CSR은 불필요했을까
클라이언트 전환과 리전 변경은 서로 다른 문제를 다뤘다.
클라이언트 중심 구조는 화면 틀을 먼저 보여주고, 데이터별 로딩과 캐시를 관리하기 위한 선택이었다. 리전 변경은 서버가 데이터를 가져오는 통신 경로를 개선한 조치였다.
따라서 이번 경험을 다음처럼 정리하는 것이 맞다.
CSR은 렌더링 속도(FCP)를 개선했고, 리전 정렬은 실제 데이터 로딩 지연 감소
컴포넌트 구조를 크게 바꾸기 전에 배포 환경과 응답 시간을 먼저 확인했어야 했다.
리전 변경 후 로딩속도가 1초미만인걸로 봐서 그냥 서버컴포넌트를 이용해도 될 것 같다.
다음에 같은 문제를 만나면
앞으로는 배포 후 로딩이 느리면 다음 순서로 확인하는게 좋을 것 같다.
- 느린 요청을 특정 문서, API, 이미지 중 무엇이 느린지 구분한다.
- 대기와 다운로드를 구분 Network 패널에서 TTFB와 다운로드 시간을 확인. (TTFB에는 서버 처리 시간도 포함)
- 배포 함수와 DB의 리전을 확인 사용자의 위치만 보지 말고 서버와 데이터 소스 사이의 거리도 확인
- 인증·DB·외부 API 시간을 나눠 측정 하나의 느린 요청 안에서도 실제로 기다리는 구간은 다를 수 있음
- 프론트 구조 개선. 그다음 Suspense, 스켈레톤, 조회 병렬화, 클라이언트 캐시를 적용한다.
즉 네트워크 문제인지 확인 후에 네트워크 문제가 없다면 프론트 구조를 변경하는 멘탈모델을 구축해야겠다.
마무리
처음에는 “서버 컴포넌트가 데이터를 기다려서 느리다”는 가설에서 클라이언트로 전환하니 화면은 먼저 나타났지만 데이터는 여전히 늦게 도착했고 미국에 있던 배포 함수를 아시아로 옮기면서 속도가 확실히 개선됐다.
이번에 얻은 가장 큰 교훈은 간단하다.
화면 전환이 불편한 이유는 아래 2가지가 있는데 이번 경우는 네트워크 요청이 느린것이 원인이었다.
- 화면 전환 UX 관리 (프론트)
- 네트워크 요청 문제 (인프라 or 백엔드)