NestJS 재귀 호출 무한루프 — API 504 타임아웃의 숨겨진 원인 찾기

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분 후 사망

🔥 증상 — 504 Gateway Timeout, 10분 후 사망 도중 예상치 못한 문제 발견
🔥 증상 — 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()
          → ...  무한 반복

getClassAttendanceStatscalculateClassTrend를 호출하고, 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만 보면 두 메서드의 관계가 보이지 않는다.

직접 정리한 비동기 간접 재귀 호출 흐름 — getClassAttendanceStats와 calculateClassTrend의 순환 구조 도식
직접 정리한 비동기 간접 재귀 호출 흐름 — getClassAttendanceStats와 calculateClassTrend의 순환 구조 도식

🛠️ 해결 — 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);
  // ...비교 로직
}

이 방식이 더 깔끔하다. getStatsOnlycalculateClassTrend를 호출하지 않으니 재귀가 구조적으로 불가능하다.

💡 팁: 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 리뷰 시 아래 항목을 추가로 확인하면 좋다.

  1. 새 메서드가 같은 서비스의 다른 public 메서드를 호출하는가?
    • Yes → 그 메서드가 현재 메서드를 다시 호출하는지 확인
  2. 통계 + 추세/비교 계산이 포함된 메서드인가?
    • Yes → “비교 대상 데이터 조회”가 “현재 메서드”를 재호출하는지 확인
  3. 재귀 가능성이 있으면 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가 보이면, 그게 다음 장애의 원인이다 ✨