Making a NestJS World Listing Query 7x Faster: From N+1 to a Single JOIN NestJS 월드 리스팅 쿼리 7배 개선: N+1에서 단일 JOIN까지
The Problem
The listWorlds API was consistently slow. The logs showed 2–3 seconds per request — even though there were only 45 published worlds at the time.
Open up the code and the reason is obvious right away.
// world.service.ts — before optimization
async getPublishedWorldsForListing(filter) {
const worlds = await this.worldRepo.findManyWorlds(filter) // fetches every world + place + version
return Promise.all(
worlds
.filter(world => !world.isBanned && world.accessStatus !== 'PRIVATE' && world.ownerGroupId)
.map(async (world) => {
const group = await this.groupService.getGroupById(world.ownerGroupId) // gRPC ×N
const place = await this.placeService.getMainPlace(world.id) // DB ×N
const version = await this.placeRepo.findLatestPublished(place.id) // DB ×N
return { ...world, ownerGroup: group, publishedVersion: version }
})
)
}
With N=45, that’s 1 + 3×45 = 136 external calls. Even worse, banned/private worlds are all fetched from the DB and only then dropped in the application.
Three-Step Optimization
Step 1: Batch queries
I started by collapsing N calls into one.
Before: gRPC getGroupById() ×N → After: gRPC getGroupsByIds() ×1
Before: DB getMainPlace() ×N → After: DB getPublishedVersionsByPlaceIds() ×1
The number of DB calls dropped from 1 + 3N → 3. But two problems remained.
First, the initial findManyWorlds() still fetched every world × every place × every version. Banned/private filtering still happened in the application.
Second, enrichVersionMeta (which reads mapName and maxPlayerCount from the CDN) was being called N times concurrently via Promise.all, with no concurrency cap.
Step 2: A single JOIN (current)
Push the filters down into the DB’s WHERE clause, and fetch only the data you need in one JOIN.
// world.db-client.ts
async findPublishedForListing(filter, orderBy?) {
return this.prisma.world.findMany({
where: {
isDraft: false,
accessStatus: { not: 'PRIVATE' },
ownerGroupId: { not: null },
OR: [
{ inspectionReport: null },
{ inspectionReport: { status: { not: 'BANNED' } } },
],
places: {
some: {
isMainPlace: true,
versions: { some: { status: 'PUBLISHED' } },
},
},
...(filter.categories && { categories: { hasSome: filter.categories } }),
},
include: {
ownerGroup: true,
places: {
where: { isMainPlace: true },
include: {
versions: {
where: { status: 'PUBLISHED' },
orderBy: { version: 'desc' },
take: 1, // only the latest published version
},
},
},
inspectionReport: true,
},
orderBy: orderBy ?? { updatedAt: 'desc' },
})
}
One DB call. Banned/private worlds are never fetched at all. Zero gRPC calls (ownerGroup is included via the JOIN).
I kept enrichVersionMeta. The mapName/maxPlayerCount read from the CDN are only known once cooking is complete, and they’re currently cached in Redis with a 1-day TTL. Limiting it to a concurrency cap of 20 also fixed the uncapped problem.
// world.service.ts
async getPublishedWorldsForListing(filter) {
const worlds = await this.worldRepo.findPublishedWorldsForListing(filter)
const tasks = worlds.map((world) => async () => ({
...world,
publishedVersion: await this.placeService.enrichVersionMeta(world.publishedVersion),
}))
const results = await executeWithConcurrencyAllSettled(tasks, 20)
return results.filter(isFulfilled).map((r) => r.value)
}
Before vs. after:
| Item | Original N+1 | Batched | Single JOIN |
|---|---|---|---|
| DB queries | 1 + 3N | 3 | 1 |
| gRPC calls | N | 1 | 0 |
| Version rows fetched | All | All | Latest 1 |
| Fetches banned/private | Yes | Yes | No |
| Concurrent CDN request cap | None | None | 20 |
Setting Up the Measurement Environment
To see how much faster the code actually got, you have to measure at a data scale similar to live. I used an isolated environment (the query-opt namespace) in dev, loaded with live-scale data.
Understanding live data volume
First, I checked how much data live actually had.
| Item | Count |
|---|---|
| Total worlds | 3,031 |
| Draft | 1,954 |
| Non-draft PUBLIC | 578 |
| Non-draft PRIVATE | 490 |
| Non-draft PAUSE | 9 |
| Published (listing targets) | 45 |
| Total places | 3,031 (1 per world) |
| Total place_versions | 9,244 |
The version distribution mattered too. It wasn’t enough to match the total count — the query planner only behaves like it does in live if you reproduce the real distribution (most places have 1–2 versions, a few have dozens to over a hundred).
I pulled the cumulative distribution of version counts per place from live and hard-coded it into the seed SQL as a CASE expression with 62 buckets.
-- place_versions seed (reproducing the live distribution)
INSERT INTO place_versions (place_id, version, ...)
SELECT vc.place_id, v, ...
FROM (
SELECT
p.id AS place_id,
w.is_draft,
(name BETWEEN 1955 AND 1999) AS is_published,
CASE
WHEN rn <= 2501 THEN 1 -- 82.5% of places: 1 version
WHEN rn <= 2646 THEN 2
WHEN rn <= 2710 THEN 3
-- ...62 buckets...
ELSE 198 -- top outliers
END AS max_ver
FROM (
SELECT p.id, ROW_NUMBER() OVER (ORDER BY p.id) AS rn
FROM places p WHERE ...
) p
JOIN worlds w ON w.id = p.world_id
) vc
CROSS JOIN generate_series(1, vc.max_ver) AS v;
The result was 3,031 worlds and 8,630 place_versions (live’s 9,244 minus 2 outliers).
Pinning the CDN
enrichVersionMeta reads metadata.json from the CDN. It was unclear whether the dev environment could reach the live CDN, and the CDN wasn’t the bottleneck in the first place. So I pinned it to a single CDN URL that actually exists in the dev environment.
-- set cooked_build_file only on the last version of published worlds
cooked_build_file = 'assets/place/cook/place/32/1' -- a path that actually exists in dev
Once the Redis cache is warm, no CDN calls happen. If you need to measure in a cold state, just flush Redis before measuring.
Measurement script
#!/usr/bin/env bash
ENDPOINT="https://eterno-query-opt.ovdr.io/backend/overdare/listUgcWorlds"
N="${1:-30}"
for i in $(seq 1 "$N"); do
ms=$(curl -s -o /dev/null -w "%{time_total}" \
-X POST "$ENDPOINT" \
-H "Content-Type: application/json" \
-d '{"category":null}' \
| awk '{ printf "%.0f\n", $1 * 1000 }')
echo "[$i/$N] ${ms}ms"
done
Results
Live-equivalent data volume, warm Redis, 30 runs.
| Metric | main (before) | fix (after) | Improvement |
|---|---|---|---|
| p50 | 2,804 ms | 400 ms | -85.7% |
| p95 | 3,022 ms | 436 ms | -85.6% |
| p99 | 3,436 ms | 441 ms | -87.2% |
| avg | 2,906 ms | 406 ms | -86.0% |
More than a 7x improvement. At p50, 2.8s → 400ms.
What If the Data Grows?
With only 45 published worlds today, the 45 enrichVersionMeta calls finish quickly. If published worlds grow to 500 or 5,000, where will the bottlenecks appear?
Problem 1: enrichVersionMeta is O(N)
It’s capped at a concurrency of 20. In a cold state with 500 published worlds:
500 ÷ 20 concurrent × ~500ms average CDN response time = 12.5 seconds
In a warm state it’s fine because it hits Redis, but cold hits happen right after a deploy or when Redis restarts.
Solution: Store mapName and maxPlayerCount in the place_versions table. These values are known once cooking completes, so the cooking-completion event handler can write them to the DB. The listing query can then fetch all the data in a single JOIN with no CDN/Redis calls.
Problem 2: No pagination in findPublishedForListing
Right now findMany has no take/skip. With 5,000 published worlds, it would fetch all 5,000 at once.
Solution: Introduce cursor-based pagination.
async findPublishedForListing(filter: {
categories?: string[]
cursor?: bigint // the last world.id received
take?: number // page size
}) {
return this.prisma.world.findMany({
where: { ... },
cursor: filter.cursor ? { id: filter.cursor } : undefined,
take: filter.take ?? 50,
skip: filter.cursor ? 1 : 0,
orderBy: { id: 'desc' },
})
}
If you sort by updatedAt, duplicate values are possible, so using id as the cursor key is the safer choice.
Problem 3: Indexes on the worlds table
The worlds table currently has only @@index(ownerGroupId). The isDraft and accessStatus columns used in the WHERE clause have no index.
As the number of published worlds grows, the planner may choose a sequential scan.
// recommended additional index
@@index([isDraft, accessStatus]) // worlds filter
The places table has @@index([worldId]), but since the EXISTS subquery uses WHERE world_id=? AND is_main_place=true, a composite index would be more efficient.
@@index([worldId, isMainPlace]) // places EXISTS subquery
The same goes for place_versions.
@@index([placeId, status]) // place_versions EXISTS subquery
At the current scale (45 published worlds), the single JOIN alone delivered a 7x improvement. Once published worlds grow into the hundreds or more, pagination plus storing the values in DB columns (removing enrichVersionMeta) will be the next improvements to make.
문제
listWorlds API가 지속적으로 느렸다. 로그를 보면 요청 하나에 2~3초. 현재 published world는 45개밖에 없는데도 그랬다.
코드를 열어보면 이유가 바로 보인다.
// world.service.ts — 최적화 전
async getPublishedWorldsForListing(filter) {
const worlds = await this.worldRepo.findManyWorlds(filter) // 모든 world + place + version fetch
return Promise.all(
worlds
.filter(world => !world.isBanned && world.accessStatus !== 'PRIVATE' && world.ownerGroupId)
.map(async (world) => {
const group = await this.groupService.getGroupById(world.ownerGroupId) // gRPC ×N
const place = await this.placeService.getMainPlace(world.id) // DB ×N
const version = await this.placeRepo.findLatestPublished(place.id) // DB ×N
return { ...world, ownerGroup: group, publishedVersion: version }
})
)
}
N=45 기준으로 외부 호출이 1 + 3×45 = 136회. 더 심각한 건 banned/private world도 DB에서 전부 fetch하고 나서 앱에서 드롭한다는 점이다.
세 단계 최적화
1단계: 배치 쿼리
N번 호출을 1번으로 줄이는 것부터 시작했다.
이전: gRPC getGroupById() ×N → 이후: gRPC getGroupsByIds() ×1
이전: DB getMainPlace() ×N → 이후: DB getPublishedVersionsByPlaceIds() ×1
DB 호출 횟수는 1 + 3N → 3으로 줄었다. 하지만 두 가지 문제가 남았다.
첫째, 초기 findManyWorlds()가 여전히 모든 world × 모든 place × 모든 version을 fetch한다. banned/private 필터링은 여전히 앱에서 한다.
둘째, enrichVersionMeta (CDN에서 mapName, maxPlayerCount를 읽어오는 작업)가 Promise.all로 N개 동시 호출된다. concurrency cap 없음.
2단계: 단일 JOIN (현재)
필터를 DB WHERE 절로 내리고, 필요한 데이터만 한 번에 JOIN해서 가져온다.
// world.db-client.ts
async findPublishedForListing(filter, orderBy?) {
return this.prisma.world.findMany({
where: {
isDraft: false,
accessStatus: { not: 'PRIVATE' },
ownerGroupId: { not: null },
OR: [
{ inspectionReport: null },
{ inspectionReport: { status: { not: 'BANNED' } } },
],
places: {
some: {
isMainPlace: true,
versions: { some: { status: 'PUBLISHED' } },
},
},
...(filter.categories && { categories: { hasSome: filter.categories } }),
},
include: {
ownerGroup: true,
places: {
where: { isMainPlace: true },
include: {
versions: {
where: { status: 'PUBLISHED' },
orderBy: { version: 'desc' },
take: 1, // 최신 published version 1개만
},
},
},
inspectionReport: true,
},
orderBy: orderBy ?? { updatedAt: 'desc' },
})
}
DB 호출 1회. banned/private world는 아예 fetch하지 않는다. gRPC 호출은 0회 (ownerGroup을 JOIN으로 포함).
enrichVersionMeta는 유지했다. CDN에서 읽는 mapName/maxPlayerCount는 쿠킹이 완료된 시점에만 알 수 있고, 현재는 Redis에 1일 TTL로 캐싱한다. concurrency cap 20으로 제한해서 uncapped 문제도 해결.
// world.service.ts
async getPublishedWorldsForListing(filter) {
const worlds = await this.worldRepo.findPublishedWorldsForListing(filter)
const tasks = worlds.map((world) => async () => ({
...world,
publishedVersion: await this.placeService.enrichVersionMeta(world.publishedVersion),
}))
const results = await executeWithConcurrencyAllSettled(tasks, 20)
return results.filter(isFulfilled).map((r) => r.value)
}
변경 전후 비교:
| 항목 | 원본 N+1 | 배치 | 단일 JOIN |
|---|---|---|---|
| DB 쿼리 수 | 1 + 3N | 3 | 1 |
| gRPC 호출 수 | N | 1 | 0 |
| fetch되는 version row | 전체 | 전체 | 최신 1개 |
| banned/private fetch | O | O | X |
| CDN 동시 요청 cap | 없음 | 없음 | 20 |
측정 환경 구성
코드가 얼마나 빨라졌는지 확인하려면 live와 유사한 데이터 규모에서 측정해야 한다. 개발 환경에 live 규모 데이터를 넣은 격리 환경(query-opt 네임스페이스)을 사용했다.
live 데이터 규모 파악
먼저 live에 데이터가 얼마나 있는지 확인했다.
| 항목 | 수량 |
|---|---|
| 전체 world | 3,031 |
| draft | 1,954 |
| non-draft PUBLIC | 578 |
| non-draft PRIVATE | 490 |
| non-draft PAUSE | 9 |
| published (listing 대상) | 45 |
| 전체 place | 3,031 (world당 1개) |
| 전체 place_version | 9,244 |
버전 분포도 중요했다. 단순히 총 개수만 맞추는 게 아니라 실제 분포(대부분 12개, 소수가 수십백 개 이상)를 재현해야 쿼리 플래너가 live와 비슷하게 동작한다.
live에서 place별 version 수 누적 분포를 뽑아서 seed SQL에 62개 구간 CASE 표현식으로 박았다.
-- place_versions 시드 (live 분포 재현)
INSERT INTO place_versions (place_id, version, ...)
SELECT vc.place_id, v, ...
FROM (
SELECT
p.id AS place_id,
w.is_draft,
(name BETWEEN 1955 AND 1999) AS is_published,
CASE
WHEN rn <= 2501 THEN 1 -- 82.5% of places: 1 version
WHEN rn <= 2646 THEN 2
WHEN rn <= 2710 THEN 3
-- ...62개 구간...
ELSE 198 -- 상위 이상치
END AS max_ver
FROM (
SELECT p.id, ROW_NUMBER() OVER (ORDER BY p.id) AS rn
FROM places p WHERE ...
) p
JOIN worlds w ON w.id = p.world_id
) vc
CROSS JOIN generate_series(1, vc.max_ver) AS v;
결과적으로 world 3,031개, place_version 8,630개(live 9,244에서 이상치 2개 제외)가 세팅됐다.
CDN 고정
enrichVersionMeta는 CDN에서 metadata.json을 읽는다. dev 환경에서 live CDN에 접근이 가능한지 불확실하고, CDN은 애초에 병목이 아니다. 그래서 dev 환경에서 실제로 존재하는 CDN URL 하나를 고정해서 세팅했다.
-- published world의 last version에만 cooked_build_file 세팅
cooked_build_file = 'assets/place/cook/place/32/1' -- dev에서 실존하는 경로
Redis 캐시가 warm되면 CDN 호출은 발생하지 않는다. cold 상태에서의 측정이 필요할 경우엔 Redis flush 후 측정하면 된다.
측정 스크립트
#!/usr/bin/env bash
ENDPOINT="https://eterno-query-opt.ovdr.io/backend/overdare/listUgcWorlds"
N="${1:-30}"
for i in $(seq 1 "$N"); do
ms=$(curl -s -o /dev/null -w "%{time_total}" \
-X POST "$ENDPOINT" \
-H "Content-Type: application/json" \
-d '{"category":null}' \
| awk '{ printf "%.0f\n", $1 * 1000 }')
echo "[$i/$N] ${ms}ms"
done
결과
live 동일 규모 데이터, Redis warm 상태, 30회 측정.
| 지표 | main (before) | fix (after) | 개선율 |
|---|---|---|---|
| p50 | 2,804 ms | 400 ms | -85.7% |
| p95 | 3,022 ms | 436 ms | -85.6% |
| p99 | 3,436 ms | 441 ms | -87.2% |
| avg | 2,906 ms | 406 ms | -86.0% |
7배 이상 개선. p50 기준 2.8초 → 400ms.
데이터가 더 늘어난다면
현재 published world가 45개라서 enrichVersionMeta 45회 호출이 빠르게 끝난다. published world가 500개, 5,000개로 늘어나면 어디서 병목이 생길까.
문제 1: enrichVersionMeta가 O(N)
concurrency cap 20으로 막혀 있다. cold 상태에서 published world 500개라면:
500개 ÷ 20 동시 × CDN 평균 응답시간 ~500ms = 12.5초
warm 상태에서는 Redis 접근이므로 괜찮지만, 배포 직후나 Redis 재시작 시에는 cold hit이 발생한다.
해결책: mapName과 maxPlayerCount를 place_versions 테이블에 저장한다. 쿠킹 완료 시점에 이 값을 알 수 있으므로, 쿠킹 완료 이벤트 핸들러에서 DB에 기록하면 된다. listing 쿼리에서 CDN/Redis 호출 없이 JOIN 한 번으로 모든 데이터를 가져올 수 있게 된다.
문제 2: findPublishedForListing에 pagination 없음
현재 findMany에 take/skip이 없다. published world가 5,000개면 5,000개를 한꺼번에 fetch한다.
해결책: cursor-based pagination 도입.
async findPublishedForListing(filter: {
categories?: string[]
cursor?: bigint // 마지막으로 받은 world.id
take?: number // 페이지 크기
}) {
return this.prisma.world.findMany({
where: { ... },
cursor: filter.cursor ? { id: filter.cursor } : undefined,
take: filter.take ?? 50,
skip: filter.cursor ? 1 : 0,
orderBy: { id: 'desc' },
})
}
updatedAt 기준 정렬이라면 중복 값 가능성 때문에 cursor key로 id를 쓰는 게 안전하다.
문제 3: worlds 테이블 인덱스
현재 worlds 테이블에는 @@index(ownerGroupId)만 있다. WHERE 절의 isDraft, accessStatus 컬럼에는 인덱스가 없다.
published world 수가 늘어나면 플래너가 sequential scan을 선택할 수 있다.
// 추가 권장 인덱스
@@index([isDraft, accessStatus]) // worlds 필터
places 테이블은 @@index([worldId]) 있지만, EXISTS 서브쿼리가 WHERE world_id=? AND is_main_place=true를 쓰므로 복합 인덱스가 더 효율적이다.
@@index([worldId, isMainPlace]) // places EXISTS 서브쿼리
place_versions도 마찬가지.
@@index([placeId, status]) // place_versions EXISTS 서브쿼리
현 규모(published 45개)에서는 단일 JOIN만으로 7배 개선이 됐다. published world가 수백 개 이상으로 늘어나는 시점이 오면, pagination + DB 컬럼 저장(enrichVersionMeta 제거)이 다음 개선 포인트가 될 것이다.