지난달부터 "대시보드 아무 메뉴나 눌러도 좀 느린 것 같다"는 이야기가 몇 번 나왔습니다. 처음엔 대수롭지 않게 넘겼는데, 실제로 개발자 도구를 켜고 확인해보니 페이지 하나 들어가는 데 3초 넘게 걸리는 경우가 있었습니다. 쿼리 몇 개 늘어난 정도로는 설명이 안 되는 수치였습니다.
원인을 찾고 보니 허무할 정도로 단순했습니다. 페이지 하나를 열 때마다 같은 인증 확인 요청을 4~6번씩 반복하고 있었습니다.

증상: 쿼리는 안 늘었는데 네트워크 요청은 늘어 있었다
먼저 의심한 건 DB 쿼리였습니다. 대시보드가 건물별 정산 데이터를 여러 테이블에서 긁어오다 보니 N+1 문제가 생겼을 거라고 짐작했죠. 그런데 Network 탭을 열어놓고 대시보드로 이동해보니, DB 쿼리보다 눈에 띄는 건 auth/v1/user로 나가는 요청이 한 페이지에서 여러 번 찍힌다는 점이었습니다.
Next.js App Router에서는 layout.tsx와 page.tsx가 각각 독립된 서버 컴포넌트로 렌더링됩니다. 그리고 저희 코드는 이 둘 모두에서 로그인 여부를 확인하려고 각자 supabase.auth.getUser()를 부르고 있었습니다.
// dashboard/layout.tsx
const { data: { user } } = await supabase.auth.getUser();
// dashboard/page.tsx (같은 요청, 다른 컴포넌트)
const { data: { user } } = await supabase.auth.getUser();
getUser()는 로컬에 있는 JWT를 그냥 디코딩하는 함수가 아닙니다. Supabase Auth 서버까지 갔다가 오는 실제 네트워크 왕복입니다. 그런데 대시보드 하위 라우트 대부분이 layout과 page 양쪽에서 이 함수를 부르고 있었고, 여기에 앞단 미들웨어의 세션 체크까지 더하면 페이지 하나 들어갈 때 인증 서버 왕복이 4~6번씩 겹쳐서 발생하고 있었습니다. 화면이 그려지기도 전에 말이죠.
고친 방법: 캐시가 아니라 "요청 하나 안에서만" 기억하기
여기서 가장 먼저 든 생각은 "그럼 유저 정보를 어딘가에 캐싱해두면 되지 않나"였는데, 이게 오히려 위험한 선택지였습니다. 모듈 최상단에 변수 하나 두고 거기에 담아두는 방식은, 서버리스 함수 인스턴스가 다음 요청에서 재사용될 때 이전 사용자의 세션 정보가 다음 사용자에게 그대로 노출될 수 있습니다. 인증 정보는 요청이 끝나면 반드시 같이 사라져야 합니다.
그래서 쓴 게 React의 cache() 함수입니다. 이건 전역 캐시가 아니라 요청 하나 범위 안에서만 살아있는 메모이제이션입니다. 같은 요청 안에서 여러 컴포넌트가 같은 함수를 불러도 실제 실행은 1번만 되고, 요청이 끝나면 그 결과는 같이 사라집니다.
// src/utils/supabase/server.ts
import { cache } from "react";
export const getAuthUser = cache(async () => {
const supabase = await createClient();
return supabase.auth.getUser();
});
그리고 layout.tsx, page.tsx에서 기존 supabase.auth.getUser() 호출부를 getAuthUser()로 바꿨습니다.
- const { data: { user } } = await supabase.auth.getUser();
+ const { data: { user } } = await getAuthUser();
대시보드와 관리자 라우트 아래 layout·page 15개 파일이 대상이었습니다. 코드 자체는 크게 어렵지 않은데, 두 가지는 일부러 손대지 않았습니다.
첫째, 미들웨어에 해당하는 proxy.ts는 그대로 뒀습니다. Edge 런타임은 실행 컨텍스트가 달라서 위 cache()가 거기까지 이어지지 않습니다. 억지로 캐시를 공유하려 하기보다는 별개 검증 지점으로 남겨두는 편이 안전하다고 판단했습니다.
둘째, Server Action이나 API 라우트 내부에서 부르는 getUser()는 이번 범위에서 뺐습니다. 렌더링 경로(layout/page)에서의 중복만 해결하는 걸 1차 목표로 잡았고, 나머지는 실제로 문제가 있는지 확인한 다음에 손대기로 했습니다.
인증과 맞닿은 코드라 머지 전에 별도로 보안 리뷰를 한 번 더 거쳤습니다. 세션이 다른 요청으로 새어나갈 경로가 없는지, 권한 판단 자체는 여전히 매번 새로 이뤄지는지(캐시가 인가 로직을 건너뛰게 만들지는 않는지) 확인하는 게 핵심이었습니다. 유닛 테스트와 관련 E2E까지 통과한 뒤에야 머지했습니다.
확인 사살: Server Action 쪽은 뒤져봤는데 문제가 없었다
1차 수정을 배포하고 나서, 남겨뒀던 Server Action 쪽도 혹시나 하는 마음에 다시 훑어봤습니다. 결과는 "문제 없음"이었습니다. 각 액션 함수 안에서 getUser()는 정확히 한 번씩만 호출되고 있었고, 같은 요청 안에서 중복 호출되는 패턴은 없었습니다. 의심 가는 곳을 뒤졌는데 아무 것도 안 나온 셈인데, 이것도 나름 의미 있는 결과라고 생각합니다. 적어도 다음에 이 코드를 보는 사람이 같은 의심을 반복할 필요는 없어졌으니까요.
그런데 이걸 고치고도 뭔가 더 남아 있었다
여기서 끝났으면 좋았겠지만, 배포 후에도 로딩이 눈에 띄게 빠르다고 하기엔 애매했습니다. 다시 파고들다가 두 가지를 더 찾았습니다.
하나는 인덱스 누락이었습니다. property_verifications 테이블의 user_id/building_id, subscription_payments 테이블의 user_id는 FK로 참조는 걸려 있었지만 정작 인덱스가 없어서, 라운지·결제내역 페이지에 들어갈 때마다 시퀀셜 스캔을 타고 있었습니다.
다른 하나는 좀 더 근본적인 문제였는데, Vercel 함수 리전과 Supabase 프로젝트 리전이 아예 다른 대륙에 있었습니다. Vercel은 iad1(미국 동부), Supabase는 ap-southeast-1(싱가포르)이었습니다. 코드에서 아무리 요청 횟수를 줄여도, 요청 하나하나가 지구 반 바퀴를 돌아야 한다면 거기서 이미 손해를 보고 시작하는 셈입니다. Vercel 함수 리전을 Supabase 쪽에 맞춰 옮기고 나서야 체감 속도가 한 번 더 달라졌습니다.
돌아보면 순서가 조금 아쉽습니다. 리전부터 확인했으면 더 빨리 끝났을 수도 있는 문제인데, 눈에 보이는 중복 호출부터 잡다 보니 리전 문제는 그 다음에야 발견했습니다.
결과
수정 전후를 정확한 숫자로 비교할 만한 벤치마크 환경을 따로 만들어두진 않았습니다. 대신 실제 운영 환경에서 개발자 도구 Network 탭을 열어 확인했고, 대시보드 진입 시 나가던 인증 서버 왕복이 눈에 띄게 줄어든 것과 체감 로딩 속도가 함께 개선된 것을 확인했습니다.
정리하면 이번에 손을 댄 지점은 세 가지였습니다.
| 원인 | 조치 |
|---|---|
layout·page가 각자 getUser() 호출 |
React cache()로 요청 스코프 메모이제이션 |
| 일부 테이블 인덱스 누락 | 누락된 컬럼에 인덱스 추가 |
| Vercel-Supabase 리전 불일치 | Vercel 함수 리전을 Supabase와 동일 리전으로 이동 |
코드 한 줄만 고쳐서 극적으로 좋아지는 경우는 생각보다 드물고, 이번에도 그랬습니다.
더 읽을거리
같은 요청 안에서 비동기 호출이 겹치는 문제는 사실 프론트엔드에서도 자주 만나는 이슈입니다. 컴포넌트 여러 개가 동시에 같은 작업을 벌이다가 경합이 생기는 케이스를 다룬 글을 최근에 봤는데, 결이 비슷해서 도움이 될 것 같습니다.
- React 18 비동기 경합 상태와 useEffect 클린업
- Next.js App Router 캐시 태그와 revalidateTag — 참고로 이건 이번 글의
cache()(요청 스코프 메모이제이션)와는 다른, fetch 결과를 요청 너머까지 재사용하는 별개의 캐시 레이어를 다룬 글입니다.
호마다(HOMADA)는 다세대·빌라 임대관리 SaaS를 Next.js와 Supabase로 운영하면서 나온 문제와 해결 과정을 이런 식으로 가끔 정리해서 남기고 있습니다. 비슷한 스택으로 서비스를 운영 중이거나, 저희 아키텍처에 대해 더 궁금한 점이 있다면 호마다 홈페이지에서 문의해 주세요.