빌드 중 10,000개 이상의 데이터베이스 쿼리 처리: Node.js 연결 풀링 이야기
I’m sorry, but I can’t access external websites to retrieve the article’s content. If you provide the text you’d like translated (excluding any code blocks or URLs), I’ll be happy to translate it into Korean while preserving the original formatting.
개요
- PostgreSQL 데이터베이스? ✅
- Prisma ORM? ✅
- Node.js 정적 사이트 생성? ✅
FATAL: role "role_xxxxx"에 대한 연결이 너무 많음
Prisma Accelerate는 이러한 오류를 방지하기 위해 내장된 연결 풀링을 제공합니다.
내 빌드 프로세스는 70개 이상의 페이지를 생성했으며, 각 페이지마다 여러 데이터베이스 쿼리가 필요했습니다. 계산은 간단하고 잔인했습니다:
70 pages × 4 queries per page × concurrent execution = connection‑pool explosion.
문제: Node.js 이벤트 루프 vs. 데이터베이스 연결
대부분의 튜토리얼은 이 점을 알려주지 않습니다: Node.js는 동시성에 너무 뛰어납니다. 정적 페이지를 생성할 때 Node.js는 한 페이지가 끝날 때까지 정중히 기다리지 않고 모든 페이지 생성을 동시에 시작합니다. 각 페이지는 다음을 수행해야 합니다:
- 데이터베이스에서 제품 조회
- 관련 콘텐츠를 위한 블로그 포스트 조회
- 카테고리 데이터 조회
- 동적 사이트맵 항목 생성
이는 5‑10개의 연결로 제한된 풀에 280개 이상의 동시 데이터베이스 연결이 발생할 가능성이 있습니다.
결과: 빌드 실패, 타임아웃 오류, 그리고 많은 좌절감.
표준 솔루션이 실패한 이유
시도 1: 연결 제한 증가
DATABASE_URL="postgresql://user:pass@host:5432/db?connection_limit=20"
결과: 여전히 실패했습니다. 문제는 제한이 아니라 연결 생성 속도였습니다.
시도 2: 수동 연결 관리
await prisma.$connect();
const data = await prisma.product.findMany();
await prisma.$disconnect();
결과: 상황이 더 악화되었습니다. 연결을 빠르게 생성하고 파괴하면서 DB 서버를 압도했습니다.
시도 3: Prisma 내장 풀링
const prisma = new PrismaClient();
결과: 충분하지 않았습니다. Prisma의 싱글톤 패턴은 런타임 요청에는 잘 작동하지만, 정적 생성의 동시성 특성 때문에 이를 우회했습니다.
해결책: Node.js에서 쿼리 큐잉
빌드 단계에서는 데이터베이스 작업을 직렬화하고, 런타임에서는 동시에 실행할 필요가 있었습니다. 다음과 같은 패턴이 작동했습니다:
// lib/db.ts
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
queryQueue: Promise<any> | undefined;
};
const createPrismaClient = () => {
return new PrismaClient({
log:
process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
});
};
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
/**
* Serialize queries during the production build.
* In dev/runtime the function runs immediately.
*/
const queueQuery = async (fn: () => Promise<any>): Promise<any> => {
if (
process.env.NEXT_PHASE === 'phase-production-build' ||
process.env.NODE_ENV === 'production'
) {
const previousQuery = globalForPrisma.queryQueue ?? Promise.resolve();
const currentQuery = previousQuery
.then(() => fn())
.catch(() => fn()); // ensure the chain continues on error
globalForPrisma.queryQueue = currentQuery;
return currentQuery;
}
return fn();
};
export async function withPrisma(
callback: (prisma: PrismaClient) => Promise<any>
): Promise<any> {
return queueQuery(async () => {
try {
return await callback(prisma);
} catch (error) {
console.error('Database operation failed:', error);
throw error;
}
});
}
핵심 인사이트
Promise 체인을 큐로 활용합니다. 각 쿼리는 빌드 중에 이전 쿼리가 끝날 때까지 기다리지만, 런타임에서는 즉시 실행됩니다.
작동 방식
| 컨텍스트 | 실행 흐름 |
|---|---|
| 빌드 (프로덕션) | Query 1 → Query 2 → Query 3 → … (serial) |
| 런타임 (서버) | Query 1 ↘Query 2 → Query 3 → … (concurrent) |
globalForPrisma.queryQueue는 체크포인트 역할을 합니다. 새로운 쿼리는 각각:
- 이전 프로미스가 해결될 때까지 대기합니다.
- DB 작업을 실행합니다.
- 다음 쿼리를 위한 새로운 체크포인트가 됩니다.
코드베이스 전반에 걸친 구현
사이트맵 생성
// app/sitemap.ts
export default async function sitemap() {
const baseUrl = process.env.BASE_URL ?? 'https://example.com';
const staticPages = [
{ url: `${baseUrl}/`, priority: 1 },
// …other static routes
];
// ---- Products -------------------------------------------------
let productPages: { url: string; lastModified: Date; priority: number }[] =
[];
try {
const products = await getAllAmazonProducts({ limit: 1000 });
productPages = products.map((p) => ({
url: `${baseUrl}/products/${p.slug}`,
lastModified: p.updatedAt,
priority: p.featured ? 0.85 : 0.75,
}));
} catch (error) {
console.error('Error fetching products for sitemap:', error);
}
// ---- Blog posts -----------------------------------------------
let blogPages: { url: string; lastModified: Date; priority: number }[] = [];
try {
const posts = await prisma.blogPost.findMany({
where: { status: 'published' },
});
blogPages = posts.map((post) => ({
url: `${baseUrl}/blog/${post.slug}`,
lastModified: post.updatedAt,
priority: 0.65,
}));
} catch (error) {
console.error('Error fetching blog posts for sitemap:', error);
}
return [...staticPages, ...productPages, ...blogPages];
}
각 try‑catch 블록은 실패를 우아하게 처리합니다—DB 호출이 실패하면 해당 섹션은 전체 빌드가 중단되지 않고 단순히 건너뛰어집니다.
생산 결과
| 지표 | 전 | 후 |
|---|---|---|
| Build success rate | ~30 % | 100 % |
| Average build time | ~2 분 (불안정) | 45 초 (안정적) |
| Connection‑timeout errors | 자주 발생 | 없음 |
| Pages generated | 70 개 이상 (자주 실패) | 70 개 이상 (모두 성공) |
이 아키텍처가 elyvora.us 에서 프로덕션 트래픽을 처리하는 모습을 확인할 수 있습니다 – 70개 이상의 데이터베이스 기반 페이지가 모두 연결 오류 없이 구축되었습니다.
Lessons Learned
- Environment‑specific behavior matters – Don’t assume build‑time behavior matches runtime behavior. Node.js behaves differently in each context.
- Global state isn’t always evil – Using
globalThisto maintain a promise queue across module boundaries solves the “multiple‑instance” problem during static generation. - Serializing DB work during a build can save you – A simple promise‑chain queue prevents connection‑pool exhaustion without sacrificing runtime concurrency.
Happy building, and may your connection pools stay happy!
Graceful Degradation Over Perfection
My sitemap has fallback logic. If product queries fail, it still generates static pages. Partial success > complete failure.
Log Everything During Build
console.log(`✅ Sitemap generated with ${allPages.length} URLs`);
console.log(` - Product pages: ${productPages.length}`);
console.log(` - Blog pages: ${blogPages.length}`);
These logs saved hours of debugging. You can’t attach a debugger to a build process, so stdout is your best friend.
이 패턴을 사용할 때
이 접근 방식은 다음과 같은 경우에 가장 적합합니다:
- Static site generation (데이터베이스 기반 콘텐츠와 함께)
- High page counts (50 + 페이지)
- Limited database connection pools (공유 호스팅, 무료 티어)
- Multiple queries per page (복잡한 데이터 관계)
런타임 전용 애플리케이션(순수 API 서버)에서는 표준 연결 풀링을 사용하세요.
결론
Node.js의 비동기 특성은 보통 강력한 장점이 됩니다. 하지만 데이터베이스 의존성을 가진 정적 생성 과정에서는 도전 과제가 됩니다. 해결책은 Node.js의 동시성을 억제하는 것이 아니라 동시성이 발생하는 시점을 제어하는 것입니다. 빌드 단계에서는 쿼리를 순차적으로 실행하고, 런타임에서는 동시에 실행하도록 하면 두 세계의 장점을 모두 누릴 수 있습니다: 안정적인 빌드와 빠른 서버 응답.
업데이트: 이 패턴은 elyvora.us에서 3주 이상 프로덕션에 적용돼 왔으며, 연결 문제가 전혀 발생하지 않았습니다. 70개 이상의 페이지가 매시간 재빌드되면서도 전혀 문제 없이 동작합니다.