당신의 Next.js 앱은 페이지 로드당 동일한 데이터베이스 쿼리를 5번 수행합니다
Source: Dev.to

문제
Next.js 앱을 열고, 몇 개의 컴포넌트가 있는 페이지로 이동합니다. 이제 다음 쿼리가 몇 번 실행되는지 세어 보세요.
SELECT * FROM users WHERE id = ?
아마 모를 겁니다. 아무도 모릅니다. 모든 요청이 200을 반환하고, 모든 컴포넌트가 정상적으로 렌더링되며, 앱이 “작동”하기 때문입니다.
하지만 그 작동하는 페이지 뒤에서는, 같은 쿼리가 5번 실행될 수 있습니다:
- 네비게이션 바 한 번.
- 사이드바 한 번.
- 메인 콘텐츠 한 번.
- 설정 패널 한 번.
- React Strict Mode가 효과를 두 번 실행했기 때문에 한 번 더.
즉, 지난 200 ms 동안 변하지 않은 데이터에 대해 데이터베이스에 5번이나 동일한 라운드‑트립을 수행하는 것입니다.
이것은 가상의 상황이 아닙니다
다음은 일반적인 Next.js API 라우트 예시입니다:
// app/api/user/route.ts
export async function GET() {
const user = await prisma.user.findUnique({
where: { id: session.userId }
});
return NextResponse.json(user);
}
간단합니다. 깔끔합니다. 코드 리뷰도 통과합니다.
이제 이 엔드포인트가 여러 곳에서 호출됩니다:
| 컴포넌트 | /api/user를 가져오는 이유 |
|---|---|
| 레이아웃 네비게이션 바 | 사용자의 이름을 표시 |
| 대시보드 페이지 | 사용자 통계 표시 |
| 설정 페이지 | 폼에 사전 채우기 |
| 프로필 사이드바 | 아바타 표시 |
| 알림 벨 | 환경 설정 확인 |
각 컴포넌트가 독립적으로 fetch('/api/user')를 호출합니다. 각 호출이 API 라우트에 도달하고, prisma.user.findUnique()를 실행합니다. 각 쿼리는 PostgreSQL로 가서 동일한 사용자 레코드를 반환합니다.
다섯 개의 컴포넌트 → 다섯 번의 요청 → 다섯 번의 쿼리 → 하나의 사용자 레코드.
Why This Happens
It’s not bad code; it’s the natural result of component‑based architecture.
- In React/Next.js, components are self‑contained.
fetchfetches its own data.- Components check permissions independently.
This is good software design, but it means every component that needs user data makes its own request. The duplication goes unnoticed because:
- DevTools shows a flat list. You see 5 requests to
/api/user, mixed with 20 other requests. You’d have to manually notice they’re identical. - Each request succeeds. 200 OK, correct data, fast enough. No error, no warning.
- Different developers wrote different components. Developer A built the navbar, Developer B built the sidebar. Neither knows the other also fetches
/api/user. - Code review doesn’t catch it. Each component’s code is correct in isolation. The duplication only exists at runtime when all components render on the same page.
실제 비용
“그래서 뭐? 쿼리는 빠르잖아.”
수학을 해보자:
| 메트릭 | 값 |
|---|---|
| 페이지 로드당 중복 쿼리 | 5 |
| 쿼리당 시간 (워밍 DB) | 30 ms |
| 일일 활성 사용자 | 1,000 |
| 세션당 페이지 로드 | 10 |
불필요한 일일 쿼리: 50,000
페이지당 낭비된 지연 시간: 150 ms
데이터베이스에 부하가 걸리면, 그 “빠른” 30 ms 쿼리가 200 ms가 될 수 있어, 명확한 이유 없이 앱이 느리게 느껴진다.
각 중복은 추가 HTTP 요청, 추가 API 라우트 호출, 풀에서의 추가 연결, 그리고 추가 메모리 할당을 의미한다—모두 이미 가지고 있는 데이터를 위한 것이다.
React Strict Mode가 상황을 악화시킵니다
React Strict Mode(Next.js에서 기본적으로 활성화됨)는 개발 환경에서 모든 useEffect를 두 번 실행합니다. 효과 안에서 호출되는 모든 fetch()도 두 번 발생합니다.
당신의 5개의 중복 요청은 10이 됩니다. 5개의 데이터베이스 쿼리도 10이 됩니다.
혼란스러운 점은: /api/user에 대한 10개의 요청을 보고 “내 앱이 깨졌어.” 라고 생각하게 된다는 것입니다. 하지만 그 중 5개는 프로덕션에서는 발생하지 않는 Strict Mode의 유령 요청이고, 나머지 5개는 실제 중복 요청입니다. DevTools만으로는 어느 것이 어느인지 구분할 수 없습니다.
일반적으로 제안되는 솔루션
| 솔루션 | 장점 | 단점 |
|---|---|---|
| React Query / SWR 사용 | 클라이언트 측에서 중복을 제거합니다. 잘 동작합니다. | 모든 컴포넌트를 동일한 쿼리 키를 사용하도록 리팩터링해야 합니다. Server Components에는 도움이 되지 않습니다. |
| fetch를 상위 컴포넌트로 올리기 | 데이터를 props로 전달합니다. | 컴포넌트 캡슐화를 깨뜨립니다. |
| 공유 레이아웃 로더 사용 | layout.tsx에서 가져오고 Context를 통해 공유합니다. | 좋은 패턴이지만 사전에 아키텍처 설계가 필요합니다. |
| 캐시 추가 (Redis, 인‑메모리 등) | DB 부하를 줄입니다. | 캐시 무효화 문제를 야기하며, TTL을 결정해야 합니다. |
모두 유효하지만, 중복된 쿼리를 이미 알고 있어야 합니다—이것이 어려운 부분입니다. 보이지 않는 문제는 고칠 수 없습니다.
문제 파악
API 수준에서의 가시성은 매우 중요합니다. 애플리케이션 수준 로깅(너무 상세함)이나 인프라 모니터링(너무 추상적)도 아닙니다. API를 연결된 시스템으로 바라봐야 합니다—어떤 엔드포인트가 함께 호출되는지, 어떤 쿼리를 공유하는지, 그리고 그것이 사용자가 실제로 한 행동과 어떻게 연결되는지.
Brakit 은 바로 이 일을 수행하는 오픈소스 개발 도구입니다. Next.js 앱에 한 줄만 추가하면 모든 HTTP 요청, 데이터베이스 쿼리, 외부 fetch를 캡처하고—사용자 행동별로 그룹화합니다.
DevTools에서 10개의 요청이 평평하게 나열되는 대신, 모든 것이 중첩되고, 시간 순서대로, 연결된 형태로 표시됩니다:
React/Next.js에서 중복 쿼리

중복은 자동으로 표시됩니다. 여러 엔드포인트에서 실행되는 SELECT users가 강조 표시됩니다. React Strict Mode 중복은 별도로 표시됩니다 — “React Strict Mode duplicate — does not happen in production” — 따라서 어떤 것을 수정하고 어떤 것을 무시해야 할지 알 수 있습니다.
어떻게 할까
Once you can see the duplication, the fixes are straightforward:
1. 요청 수준에서 중복 제거
Use React Query or SWR so multiple components share one request:
// hooks/useUser.ts
export function useUser() {
return useQuery({
queryKey: ['user'],
queryFn: () => fetch('/api/user').then(r => r.json()),
staleTime: 30_000,
});
}
useUser()를 호출하는 모든 컴포넌트가 이제 동일한 요청과 캐시를 공유합니다.
2. 쿼리 수준에서 중복 제거
Extract shared queries to a data‑access layer:
// lib/data/user.ts
import { cache } from 'react';
export const getUser = cache(async (userId: string) => {
return prisma.user.findUnique({ where: { id: userId } });
});
React의 cache()는 단일 서버 렌더링 내에서 중복을 제거합니다. 요청 간 캐싱을 위해서는 unstable_cache 또는 전용 캐시 레이어를 사용하세요.
3. 공유 데이터를 레이아웃으로 이동
Fetch once and share via context:
// app/layout.tsx
export default async function Layout({ children }) {
const user = await getUser(session.userId);
return (
<UserContext.Provider value={user}>
{children}
</UserContext.Provider>
);
}
더 넓은 패턴
중복 쿼리는 컴포넌트 기반 아키텍처에서 가장 흔한 백엔드 성능 문제입니다. 코드 리뷰에서는 보이지 않고, 단위 테스트에서는 감지되지 않으며, 실제 컴포넌트가 실제 페이지에 조합될 때 런타임에서만 나타납니다.
해결책은 복잡하지 않으며, 어려운 부분은 문제를 보는 것입니다.
페이지가 로드될 때마다 Next.js 앱이 실제로 수행하는 작업—어떤 쿼리가 실행되고, 어떤 쿼리가 중복되며, 어떤 컴포넌트가 책임이 있는지—을 보고 싶다면 **Brakit**을 사용해 보세요. 한 번의 명령, 설정 없이, 중복 쿼리를 놓칠 수 없습니다.
- 오픈 소스이며, 로컬에서 실행되고, 프로덕션에서는 아무 작업도 하지 않습니다.
- 데이터가 여러분의 머신을 떠나지 않습니다.

