NestJS 재귀 호출 무한루프 — API 504 타임아웃의 숨겨진 원인 찾기
📚 NestJS 실전 트러블슈팅 시리즈 (12편)
NestJS 서비스에서 메서드 간 재귀 호출이 발생하면 스택 오버플로우와 504 Gateway Timeout이 터진다. 통계 API에서 추세 계산이 무한루프에 빠진 실전 사례를 통해, 재귀 호출 탐지법과 skipFlag 패턴으로 방어하는 방법을 정리한다.
💡 Tip. 바쁜 현대인들을 위한 본문 요약
- 통계 API가 504 Gateway Timeout(10분)을 뿜으면, N+1이 아니라 재귀 호출 무한루프일 수 있음
- 메서드 A가 B를 호출하고, B가 다시 A를 호출하는 간접 재귀는 코드 리뷰에서 놓치기 쉬움
skipTrend같은 재귀 차단 플래그를 파라미터로 넘겨서 2단계 이상 진입을 막는 게 핵심- 호출 그래프를 그려보면 순환 참조를 사전에 발견 가능
- Node.js의 기본 콜 스택 크기는 약 15,000프레임 — 재귀가 깊어지면
RangeError: Maximum call stack size exceeded발생
금요일 오후 4시. 배포하고 30분 만에 Slack 알림이 울렸다.
“통계 API가 응답을 안 줘요.”
Nginx 로그를 까보니 504 Gateway Timeout. 그것도 10분짜리.
처음엔 N+1 쿼리를 의심했다. 이전에 N+1로 응답이 3–5초까지 느려진 경험이 있었기 때문이다. 근데 이번엔 차원이 달랐다.
🔥 증상 — 504 Gateway Timeout, 10분 후 사망

통계 대시보드의 “반별 출석 현황” API가 문제였다. 로컬에서는 2초 안에 응답이 오는 엔드포인트다.
스테이징에 배포하니 이렇게 됐다.
$ curl -w "\n%{time_total}s" https://api.example.com/admin/classes/5/attendance-stats
curl: (52) Empty reply from server
600.123s
600초.
Nginx의 proxy_read_timeout 기본값이 60초인데, 운영팀이 넉넉하게 600초로 잡아둔 상태였다.
그 600초를 전부 소진하고 죽었다.
📌 핵심: 응답 시간이 3–5초면 N+1 쿼리를 의심하고, 분 단위면 무한루프를 의심해야 한다.
서버 프로세스의 CPU 사용률도 비정상이었다.
$ top -pid $(pgrep -f "nest start")
PID COMMAND %CPU MEM
12345 node 99.8 512M
Node.js 프로세스가 CPU를 100% 점유하고 있었다. DB 커넥션 풀에도 이상이 없었다. 쿼리 자체가 문제가 아니라는 뜻이다.
에러 로그 분석
PM2 로그를 확인하니 명확한 에러 메시지는 없었다. 504는 Nginx가 업스트림 타임아웃으로 끊은 거라, Node.js 쪽에서는 그냥 커넥션이 끊긴 것뿐이었다.
$ pm2 logs api --lines 100 | grep -i "error\|stack\|overflow"
# (출력 없음)
에러 로그가 없는 무한루프. 이게 더 무섭다.
⚠️ 주의: Node.js에서 재귀 호출이 비동기(
await)로 이뤄지면 콜 스택이 매 틱마다 리셋된다. 그래서Maximum call stack size exceeded가 안 터지고, 그냥 CPU만 태우면서 영원히 돌아간다.
🔍 탐색 — 처음엔 N+1을 의심했다
❌ 가설 1: N+1 쿼리 문제
가장 먼저 떠오른 건 N+1이었다. 이전에 비슷한 통계 API에서 650+개 쿼리가 발생해서 3–5초까지 느려진 적이 있었기 때문이다.
Prisma 쿼리 로그를 켰다.
// prisma.service.ts
const prisma = new PrismaClient({
log: [
{ emit: 'event', level: 'query' },
],
});
prisma.$on('query', (e) => {
console.log(`Query: ${e.query}`);
console.log(`Duration: ${e.duration}ms`);
});
로그가 쏟아져 나왔다. 근데 패턴이 이상했다.
Query: SELECT "classes"."id", ... FROM "classes" WHERE "id" = $1
Duration: 2ms
Query: SELECT "attendance"."id", ... FROM "attendance" WHERE ...
Duration: 3ms
Query: SELECT "classes"."id", ... FROM "classes" WHERE "id" = $1 ← 같은 쿼리 반복
Duration: 2ms
Query: SELECT "attendance"."id", ... FROM "attendance" WHERE ... ← 같은 쿼리 반복
Duration: 3ms
...
같은 쿼리가 무한 반복되고 있었다. N+1은 “다양한 쿼리가 많이” 나가는 거지, 동일 쿼리가 무한 반복되는 건 아니다.
🔍 단서: 같은 쿼리 세트가 끝없이 반복된다면, 루프 자체가 종료 조건 없이 돌고 있다는 뜻이다.
❌ 가설 2: 배치 크론이 API와 충돌
출석 통계를 계산하는 배치가 크론으로 매일 06:00에 돌고 있었다. 혹시 배치와 API가 같은 테이블을 동시에 접근하면서 데드락이 걸린 건 아닌가?
$ psql -c "SELECT * FROM pg_stat_activity WHERE state = 'active' AND wait_event_type = 'Lock';"
pid | state | wait_event_type
-----+--------+-----------------
(0 rows)
데드락 아니다. Lock도 없다.
✅ 가설 3: 코드 레벨 무한루프
DB도 정상이고, 락도 없고, 같은 쿼리만 무한 반복. 코드에서 무한루프가 도는 거다.
해당 API의 서비스 코드를 열었다.
// attendance-stats.application.service.ts
async getClassAttendanceStats(
classId: number,
startDate: Date,
endDate: Date,
): Promise<ClassAttendanceStats> {
// 1. 출석 데이터 조회
const records = await this.prisma.attendance.findMany({
where: { classId, date: { gte: startDate, lte: endDate } },
});
// 2. 통계 계산
const stats = this.calculateStats(records);
// 3. 전주 대비 추세 계산 ← 여기가 문제
const trend = await this.calculateClassTrend(classId, startDate, endDate);
return { ...stats, trend };
}
calculateClassTrend를 따라가 봤다.
async calculateClassTrend(
classId: number,
startDate: Date,
endDate: Date,
): Promise<'up' | 'down' | 'stable'> {
// 전주 기간 계산
const prevStart = subDays(startDate, 7);
const prevEnd = subDays(endDate, 7);
// 전주 통계 조회 ← 💀 여기!
const prevStats = await this.getClassAttendanceStats(classId, prevStart, prevEnd);
const currentStats = await this.getClassAttendanceStats(classId, startDate, endDate);
if (currentStats.rate > prevStats.rate + 5) return 'up';
if (currentStats.rate < prevStats.rate - 5) return 'down';
return 'stable';
}
찾았다.
getClassAttendanceStats()
→ calculateClassTrend()
→ getClassAttendanceStats() ← 다시 호출!
→ calculateClassTrend()
→ getClassAttendanceStats()
→ ... 무한 반복
getClassAttendanceStats가 calculateClassTrend를 호출하고, calculateClassTrend가 다시 getClassAttendanceStats를 호출한다.
**간접 재귀(Indirect Recursion)**다.
📊 데이터: Node.js의 기본 콜 스택 크기는 약 15,000프레임이다. 동기 재귀라면
RangeError: Maximum call stack size exceeded가 터진다. 하지만await가 끼어 있는 비동기 재귀는 매 틱마다 콜 스택이 풀리기 때문에 스택 오버플로우가 발생하지 않는다. 대신 이벤트 루프가 영원히 돌면서 CPU만 태운다.
이게 핵심이다. 동기 재귀는 스택이 터지면서 에러로 알려주기라도 한다. 비동기 재귀는 조용히 CPU를 갉아먹으면서 죽지도 않는다.
🔬 진짜 범인 — 비동기 간접 재귀의 함정

동기 재귀 vs 비동기 재귀
같은 무한루프라도 동기와 비동기는 결과가 완전히 다르다.
❌ 동기 재귀 — 스택 터짐 (차라리 낫다)
function factorial(n: number): number {
return n * factorial(n - 1); // 종료 조건 없음
// → RangeError: Maximum call stack size exceeded (약 15,000프레임)
}
에러가 즉시 터진다. 에러 메시지도 명확하다. 디버깅이 쉽다.
⚠️ 비동기 재귀 — 조용한 죽음 (이게 진짜 위험)
async function fetchStats(id: number): Promise<Stats> {
const trend = await calculateTrend(id); // await 포함
return { ...data, trend };
}
async function calculateTrend(id: number): Promise<Trend> {
const prev = await fetchStats(id); // 다시 fetchStats 호출
// → 에러 없이 영원히 돌아감
// → CPU 100%, 메모리 점진적 증가
return compareTrend(prev, current);
}
await가 있으면 매 호출마다 현재 실행 컨텍스트가 마이크로태스크 큐로 넘어간다.
콜 스택은 비워진다.
그래서 스택 오버플로우가 안 터진다.
💡 팁:
await가 포함된 재귀 함수는Maximum call stack size exceeded가 절대 안 터진다. 메모리가 서서히 차오르다가 OOM으로 죽거나, 타임아웃으로 끊기거나, CPU를 영원히 태운다.
왜 코드 리뷰에서 놓쳤나
직접 재귀(Direct Recursion)는 쉽게 보인다.
// 직접 재귀 — 코드 리뷰에서 바로 보임
async function a() {
await a(); // 자기 자신 호출 → 즉시 의심
}
간접 재귀(Indirect Recursion)는 파일이 다르면 더 안 보인다.
// 파일 A: attendance-stats.application.service.ts
async getClassAttendanceStats() {
const trend = await this.calculateClassTrend(); // B 호출
}
// 같은 파일이지만 300줄 떨어진 곳
async calculateClassTrend() {
const prev = await this.getClassAttendanceStats(); // A 호출
}
같은 클래스 안이지만 메서드가 300줄 떨어져 있었다. PR 리뷰에서 diff만 보면 두 메서드의 관계가 보이지 않는다.

🛠️ 해결 — skipTrend 플래그로 재귀 차단
해결 방법은 단순하다. “추세 계산을 위해 전주 통계를 조회할 때는, 그 전주 통계에서 다시 추세를 계산하지 않는다.”
❌ Before — 무한 재귀
async getClassAttendanceStats(
classId: number,
startDate: Date,
endDate: Date,
): Promise<ClassAttendanceStats> {
const records = await this.prisma.attendance.findMany({
where: { classId, date: { gte: startDate, lte: endDate } },
});
const stats = this.calculateStats(records);
// ❌ 추세 계산이 다시 이 메서드를 호출 → 무한 재귀
const trend = await this.calculateClassTrend(classId, startDate, endDate);
return { ...stats, trend };
}
✅ After — skipTrend 플래그
async getClassAttendanceStats(
classId: number,
startDate: Date,
endDate: Date,
skipTrend = false, // ✅ 재귀 차단 플래그 (기본값: 추세 계산 포함)
): Promise<ClassAttendanceStats> {
const records = await this.prisma.attendance.findMany({
where: { classId, date: { gte: startDate, lte: endDate } },
});
const stats = this.calculateStats(records);
// ✅ skipTrend=true면 추세 계산 스킵 → 재귀 차단
const trend = skipTrend
? 'stable'
: await this.calculateClassTrend(classId, startDate, endDate);
return { ...stats, trend };
}
calculateClassTrend도 수정한다.
async calculateClassTrend(
classId: number,
startDate: Date,
endDate: Date,
): Promise<'up' | 'down' | 'stable'> {
const prevStart = subDays(startDate, 7);
const prevEnd = subDays(endDate, 7);
// ✅ 전주 통계 조회 시 skipTrend=true → 재귀 차단
const prevStats = await this.getClassAttendanceStats(
classId, prevStart, prevEnd, true,
);
const currentStats = await this.getClassAttendanceStats(
classId, startDate, endDate, true,
);
if (currentStats.rate > prevStats.rate + 5) return 'up';
if (currentStats.rate < prevStats.rate - 5) return 'down';
return 'stable';
}
호출 흐름이 이렇게 바뀐다.
getClassAttendanceStats(classId, start, end, false)
→ calculateClassTrend(classId, start, end)
→ getClassAttendanceStats(classId, prevStart, prevEnd, true) ← skipTrend!
→ 추세 계산 스킵, stats만 반환
→ getClassAttendanceStats(classId, start, end, true) ← skipTrend!
→ 추세 계산 스킵, stats만 반환
→ 추세 비교 후 'up' | 'down' | 'stable' 반환
→ 최종 결과 반환 ✅
📌 핵심:
skipTrend플래그의 기본값을false로 설정하면, 기존 호출 코드를 하나도 수정하지 않아도 된다. 외부 API에서 호출하는 코드는 그대로getClassAttendanceStats(classId, start, end)를 호출하면 된다.
대안: 메서드 분리 패턴
skipFlag가 마음에 안 들면, 메서드를 아예 분리할 수도 있다.
// ✅ 대안: 순수 통계 계산 메서드 분리
private async getStatsOnly(
classId: number,
startDate: Date,
endDate: Date,
): Promise<StatsData> {
const records = await this.prisma.attendance.findMany({
where: { classId, date: { gte: startDate, lte: endDate } },
});
return this.calculateStats(records);
}
// 외부 노출 메서드: 통계 + 추세
async getClassAttendanceStats(
classId: number,
startDate: Date,
endDate: Date,
): Promise<ClassAttendanceStats> {
const stats = await this.getStatsOnly(classId, startDate, endDate);
const trend = await this.calculateClassTrend(classId, startDate, endDate);
return { ...stats, trend };
}
// 추세 계산: getStatsOnly만 호출 → 재귀 불가능
async calculateClassTrend(
classId: number,
startDate: Date,
endDate: Date,
): Promise<'up' | 'down' | 'stable'> {
const prev = await this.getStatsOnly(classId, subDays(startDate, 7), subDays(endDate, 7));
const current = await this.getStatsOnly(classId, startDate, endDate);
// ...비교 로직
}
이 방식이 더 깔끔하다.
getStatsOnly는 calculateClassTrend를 호출하지 않으니 재귀가 구조적으로 불가능하다.
💡 팁: skipFlag 패턴은 빠른 핫픽스에 적합하고, 메서드 분리 패턴은 리팩토링 여유가 있을 때 적합하다. 프로덕션에서 당장 장애가 터졌다면 skipFlag로 먼저 막고, 이후에 메서드 분리로 개선하는 2단계 전략이 현실적이다.
✅ 검증 — 응답 시간 200ms로 복귀
수정 후 스테이징에 다시 배포했다.
$ curl -w "\n%{time_total}s" https://api.example.com/admin/classes/5/attendance-stats
{"classId":5,"rate":87.5,"trend":"up",...}
0.187s
600초 → 0.187초. 3,200배 개선이 아니라, 무한루프가 정상으로 돌아온 것이다.
쿼리 로그도 확인했다.
# 수정 전: 같은 쿼리가 무한 반복
Query count: ∞ (타임아웃까지 증가)
# 수정 후: 정확히 6개 쿼리
Query count: 6
- attendance records (이번주): 1
- attendance records (전주): 1
- class info: 2
- students: 2
📊 데이터: 수정 전에는 10분(600초) 동안 약 180,000개 이상의 쿼리가 발생한 것으로 추정된다 (쿼리당 평균 2ms 기준). 수정 후에는 6개.
CPU 사용률도 정상으로 돌아왔다.
$ top -pid $(pgrep -f "nest start")
PID COMMAND %CPU MEM
12345 node 1.2 256M
99.8% → 1.2%.
🛡️ 예방 — 재귀 호출 사전 탐지법

호출 그래프 그리기
새 메서드를 추가할 때, 호출 관계를 그래프로 그려보면 순환 참조를 사전에 발견할 수 있다.
# 안전한 호출 그래프 (DAG — 방향 비순환 그래프)
A() → B() → C() → D() ✅
# 위험한 호출 그래프 (순환)
A() → B() → C() → A() ❌
복잡한 서비스라면 의존성 주입(DI) 그래프처럼 호출 그래프도 시각화하는 것이 좋다.
자동 탐지: grep으로 순환 참조 찾기
같은 클래스 내에서 메서드 간 호출 관계를 확인하는 간단한 방법이 있다.
# 1. 클래스의 메서드 목록 추출
grep -n "async \w\+(" attendance-stats.application.service.ts
# 2. 각 메서드가 같은 클래스의 다른 메서드를 호출하는지 확인
grep -n "this\.\(getClassAttendanceStats\|calculateClassTrend\)" \
attendance-stats.application.service.ts
출력에서 메서드 A 범위 내에 B 호출, 메서드 B 범위 내에 A 호출이 보이면 순환이다.
💡 팁: ESLint에는 함수 간 순환 호출을 탐지하는 기본 룰이 없다. 하지만 madge 같은 도구로 모듈 단위 순환 의존성은 탐지할 수 있다.
npx madge --circular --extensions ts src/를 CI에 추가하면 모듈 레벨 순환은 자동으로 잡힌다.
코드 리뷰 체크리스트
PR 리뷰 시 아래 항목을 추가로 확인하면 좋다.
- 새 메서드가 같은 서비스의 다른 public 메서드를 호출하는가?
- Yes → 그 메서드가 현재 메서드를 다시 호출하는지 확인
- 통계 + 추세/비교 계산이 포함된 메서드인가?
- Yes → “비교 대상 데이터 조회”가 “현재 메서드”를 재호출하는지 확인
- 재귀 가능성이 있으면 skipFlag 또는 메서드 분리를 적용했는가?
방어적 코딩: 최대 깊이 제한
skipFlag보다 더 안전한 방어 패턴.
async getStats(
classId: number,
startDate: Date,
endDate: Date,
_depth = 0, // 재귀 깊이 추적
): Promise<Stats> {
// ✅ 최대 깊이 초과 시 안전하게 종료
if (_depth > 2) {
console.warn(`[getStats] Max recursion depth exceeded: ${_depth}`);
return { ...emptyStats, trend: 'stable' };
}
const stats = await this.getStatsOnly(classId, startDate, endDate);
const trend = await this.calculateTrend(classId, startDate, endDate, _depth + 1);
return { ...stats, trend };
}
이 패턴은 예상치 못한 재귀 경로가 생기더라도 최대 깊이에서 강제 종료된다.
프로덕션에서는 console.warn 대신 로깅 시스템으로 알림을 보내는 게 좋다.
⚠️ 주의:
_depth파라미터는 외부 API로 노출하면 안 된다. 컨트롤러에서는 항상 기본값(0)으로 호출하고, 내부 호출에서만 깊이를 증가시켜야 한다.
📋 정리 — 핵심 요약
| 상황 | 안티패턴 | 권장 패턴 |
|---|---|---|
| 통계 + 추세 계산 | 추세 메서드가 통계 메서드를 재호출 | skipTrend 플래그 또는 메서드 분리 |
| 비동기 간접 재귀 | await 포함 재귀 → 에러 없이 CPU 100% | 호출 그래프 사전 검증 |
| 무한루프 의심 | Nginx 504 + CPU 100% + 같은 쿼리 반복 | Prisma 쿼리 로그로 반복 패턴 확인 |
| 코드 리뷰 | 같은 서비스 내 메서드 간 호출 미확인 | ”A→B→A” 순환 여부 체크리스트 |
| 빠른 핫픽스 | 코드 전면 리팩토링 | skipFlag로 먼저 막고, 이후 메서드 분리 |
비동기 재귀는 스택이 안 터져서 더 위험하다.
동기 재귀는 Maximum call stack size exceeded가 터지면서 바로 알려주기라도 한다.
비동기 재귀는 조용히 CPU를 태우면서 504를 뱉을 뿐이다.
호출 그래프를 그려라. A → B → A가 보이면, 그게 다음 장애의 원인이다 ✨
📚 NestJS 실전 트러블슈팅 시리즈 (12편)
- 1. NestJS + Prisma에서 N+1 쿼리 문제 해결하기
- 2. NestJS CORS 삽질 총정리 — PATCH만 안 되는 이유
- 3. Prisma 마이그레이션 실수 방지 — 컬럼 누락 해결기
- 4. NestJS DTO 클래스 필수인 이유 — interface로 만들면 터지는 두 가지
- 5. NestJS FK 제약 위반 디버깅 — Level ID 검증으로 500 에러 잡기
- 6. Prisma enum vs 도메인 타입 캐스팅 함정 — TypeScript 타입 불일치 해결기
- 7. Seed 데이터 FK 삭제 순서 삽질 — Prisma deleteMany가 터지는 이유
- 8. NestJS DI 에러 디버깅 — Nest can't resolve dependencies 3가지 원인과 서버 기동 테스트
- 9. Docker 빌드에서 pnpm 모노레포 삽질 — 데코레이터 에러 3132개의 정체
- 10. NestJS 재귀 호출 무한루프 — API 504 타임아웃의 숨겨진 원인 찾기
- 11. Soft Delete 필터가 빠진 곳 찾기 — 삭제한 데이터가 되살아나는 미스터리
- 12. prisma generate 누락 — 빌드는 되는데 런타임 에러가 나는 이유