junyeokk
Blog
NestJS·2025. 11. 15

NestJS Health Check (@nestjs/terminus)

서비스를 배포하면 "지금 이 서버가 정상인가?"를 확인할 방법이 필요하다. 단순히 서버가 떠 있는지 확인하는 건 GET /에 200 반환하면 되지만, 실제 운영에서는 그것만으로 부족하다. 데이터베이스 연결이 끊겼거나, 디스크가 꽉 찼거나, 메모리가 임계치를 넘겼는데 서버 프로세스 자체는 살아 있는 경우가 빈번하다. 이런 상태에서 트래픽을 계속 받으면 장애가 전파된다.

헬스체크(Health Check)는 이 문제를 해결한다. 서버가 "나는 정상이다"라고 응답하는 게 아니라, 자신이 의존하는 리소스를 실제로 점검한 결과를 반환하는 것이다. 로드 밸런서나 Kubernetes는 이 응답을 보고 비정상 인스턴스를 트래픽에서 제외한다.

NestJS에서는 @nestjs/terminus 패키지가 이 역할을 담당한다. Terminus라는 이름은 Node.js 생태계의 @godaddy/terminus 라이브러리에서 유래했는데, graceful shutdown과 헬스체크를 위한 도구다. NestJS 버전은 이를 NestJS의 DI 시스템에 맞게 래핑한 것이다.


설치와 기본 구조

bash
npm install @nestjs/terminus

@nestjs/terminus가 제공하는 핵심 구성 요소는 세 가지다:

  • HealthCheckService: 여러 인디케이터를 실행하고 결과를 모아주는 오케스트레이터
  • Health Indicator: 특정 리소스의 상태를 점검하는 클래스 (DB, 메모리, 디스크 등)
  • @HealthCheck() 데코레이터: Swagger 문서에 헬스체크 응답 스키마를 자동으로 추가

기본적인 구조는 이렇다. 헬스체크 전용 모듈과 컨트롤러를 만들고, 필요한 인디케이터를 주입해서 사용한다.

typescript
// health.module.ts
import { Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { HealthController } from './health.controller';

@Module({
  imports: [TerminusModule],
  controllers: [HealthController],
})
export class HealthModule {}
typescript
// health.controller.ts
import { Controller, Get } from '@nestjs/common';
import {
  HealthCheckService,
  HealthCheck,
  MemoryHealthIndicator,
} from '@nestjs/terminus';

@Controller('health')
export class HealthController {
  constructor(
    private health: HealthCheckService,
    private memory: MemoryHealthIndicator,
  ) {}

  @Get()
  @HealthCheck()
  check() {
    return this.health.check([
      () => this.memory.checkHeap('memory_heap', 150 * 1024 * 1024),
    ]);
  }
}

health.check()에 인디케이터 함수 배열을 넘긴다. 각 함수가 독립적으로 실행되고, 모든 결과가 하나의 응답 객체로 합쳐진다.


응답 형태

헬스체크 엔드포인트의 응답은 표준화된 형태를 따른다:

json
{
  "status": "ok",
  "info": {
    "memory_heap": {
      "status": "up"
    },
    "database": {
      "status": "up"
    }
  },
  "error": {},
  "details": {
    "memory_heap": {
      "status": "up"
    },
    "database": {
      "status": "up"
    }
  }
}
  • status: 전체 상태. 모든 인디케이터가 통과하면 "ok", 하나라도 실패하면 "error"
  • info: 정상인 인디케이터만 포함
  • error: 실패한 인디케이터만 포함
  • details: 모든 인디케이터의 상태 (info + error)

전체 status가 "error"일 때 HTTP 상태 코드는 503 Service Unavailable이 반환된다. 로드 밸런서는 이 503을 보고 해당 인스턴스를 풀에서 제거한다.


내장 인디케이터

@nestjs/terminus는 자주 쓰이는 점검 항목을 내장 인디케이터로 제공한다. 별도 설치 없이 바로 DI에서 꺼내 쓸 수 있다.

HttpHealthIndicator

외부 HTTP 엔드포인트가 살아 있는지 확인한다. 의존하는 외부 API(결제 게이트웨이, 인증 서버 등)의 상태를 점검할 때 쓴다.

typescript
import { HttpHealthIndicator } from '@nestjs/terminus';

// 생성자에 주입
constructor(private http: HttpHealthIndicator) {}

// 사용
() => this.http.pingCheck('google', 'https://google.com')

// responseCheck: 단순 ping이 아니라 응답 내용까지 검증
() => this.http.responseCheck(
  'payment-api',
  'https://api.payment.com/health',
  (res) => res.data.status === 'ok',
)

pingCheck은 200 응답만 확인하고, responseCheck는 응답 본문을 콜백으로 검증할 수 있다. 외부 API가 200을 반환하지만 내부적으로 에러 상태인 경우를 잡아낼 수 있다.

주의: HttpHealthIndicator를 사용하려면 HttpModule(@nestjs/axios)을 해당 모듈에 import해야 한다. 내부적으로 Axios를 사용하기 때문이다.

TypeOrmHealthIndicator / MikroOrmHealthIndicator / SequelizeHealthIndicator

데이터베이스 연결 상태를 확인한다. 사용하는 ORM에 맞는 인디케이터를 쓰면 된다.

typescript
import { TypeOrmHealthIndicator } from '@nestjs/terminus';

constructor(private db: TypeOrmHealthIndicator) {}

// 기본: 간단한 SELECT 쿼리 실행
() => this.db.pingCheck('database')

// 특정 커넥션 지정 (다중 DB 환경)
() => this.db.pingCheck('database', { connection: dataSource })

MikroORM의 경우:

typescript
import { MikroOrmHealthIndicator } from '@nestjs/terminus';

constructor(private db: MikroOrmHealthIndicator) {}

() => this.db.pingCheck('database')

내부적으로 SELECT 1 같은 가벼운 쿼리를 실행해서 DB가 응답하는지 확인한다. 단순히 커넥션 풀에 커넥션이 있는지 보는 게 아니라, 실제로 쿼리가 실행되는지 검증하는 것이다.

MemoryHealthIndicator

프로세스의 메모리 사용량을 점검한다. 두 가지 방식을 제공한다:

typescript
import { MemoryHealthIndicator } from '@nestjs/terminus';

constructor(private memory: MemoryHealthIndicator) {}

// Heap 메모리 사용량 체크 (V8 힙)
() => this.memory.checkHeap('memory_heap', 150 * 1024 * 1024)  // 150MB

// RSS(Resident Set Size) 체크 (프로세스 전체 메모리)
() => this.memory.checkRSS('memory_rss', 300 * 1024 * 1024)  // 300MB

Heap vs RSS의 차이: Heap은 V8 엔진이 JavaScript 객체를 저장하는 영역이고, RSS는 OS가 해당 프로세스에 할당한 전체 물리 메모리다. RSS는 Heap뿐 아니라 C++ 바인딩의 메모리, 스택, 코드 세그먼트 등을 모두 포함한다. 일반적으로 RSS가 Heap보다 크다.

메모리 릭을 감지하려면 Heap 체크가 적합하고, 컨테이너 메모리 제한에 근접하는지 확인하려면 RSS 체크가 적합하다.

DiskHealthIndicator

디스크 사용량을 점검한다. 로그 파일이나 업로드 파일이 쌓이는 서버에서 특히 유용하다.

typescript
import { DiskHealthIndicator } from '@nestjs/terminus';

constructor(private disk: DiskHealthIndicator) {}

// 사용률 기반 (전체의 90% 이상 사용 시 실패)
() => this.disk.checkStorage('storage', {
  path: '/',
  thresholdPercent: 0.9,
})

// 절대값 기반 (남은 공간이 1GB 미만이면 실패)
() => this.disk.checkStorage('storage', {
  path: '/',
  threshold: 1024 * 1024 * 1024,  // 1GB
})

thresholdPercent는 "사용률이 이 비율을 넘으면 실패", threshold는 "남은 공간이 이 바이트 미만이면 실패"다. 둘 중 하나만 사용한다.


커스텀 인디케이터 만들기

내장 인디케이터가 커버하지 않는 리소스(Redis, Elasticsearch, 외부 마이크로서비스 등)는 커스텀 인디케이터를 만들어야 한다. HealthIndicator를 상속하고 isHealthy 메서드를 구현한다.

typescript
import { Injectable } from '@nestjs/common';
import {
  HealthIndicator,
  HealthIndicatorResult,
  HealthCheckError,
} from '@nestjs/terminus';
import Redis from 'ioredis';

@Injectable()
export class RedisHealthIndicator extends HealthIndicator {
  constructor(private readonly redis: Redis) {
    super();
  }

  async isHealthy(key: string): Promise<HealthIndicatorResult> {
    try {
      const result = await this.redis.ping();
      
      if (result !== 'PONG') {
        throw new Error('Redis ping failed');
      }

      return this.getStatus(key, true);
    } catch (error) {
      throw new HealthCheckError(
        'Redis check failed',
        this.getStatus(key, false, { message: error.message }),
      );
    }
  }
}

핵심 포인트:

  1. this.getStatus(key, isHealthy, data?): 표준화된 응답 형태를 만들어주는 헬퍼. 첫 번째 인자가 응답에서 해당 인디케이터의 키 이름이 된다.
  2. 성공 시: this.getStatus(key, true)를 반환
  3. 실패 시: HealthCheckError를 throw. 두 번째 인자에 this.getStatus(key, false, 추가정보)를 넣으면 에러 응답에 상세 정보가 포함된다.

커스텀 인디케이터는 모듈의 providers에 등록하고, 컨트롤러에서 주입받아 사용한다:

typescript
@Module({
  imports: [TerminusModule],
  controllers: [HealthController],
  providers: [RedisHealthIndicator],
})
export class HealthModule {}
typescript
@Controller('health')
export class HealthController {
  constructor(
    private health: HealthCheckService,
    private redis: RedisHealthIndicator,
  ) {}

  @Get()
  @HealthCheck()
  check() {
    return this.health.check([
      () => this.redis.isHealthy('redis'),
    ]);
  }
}

Prisma처럼 공식 ORM 인디케이터가 없는 경우에도 같은 방식으로 만든다:

typescript
@Injectable()
export class PrismaHealthIndicator extends HealthIndicator {
  constructor(private readonly prisma: PrismaService) {
    super();
  }

  async isHealthy(key: string): Promise<HealthIndicatorResult> {
    try {
      await this.prisma.$queryRaw`SELECT 1`;
      return this.getStatus(key, true);
    } catch (e) {
      throw new HealthCheckError(
        'Prisma check failed',
        this.getStatus(key, false),
      );
    }
  }
}

실전 헬스체크 구성

실제 프로젝트에서는 여러 인디케이터를 조합해서 전체 시스템 상태를 한 번에 확인한다.

typescript
@Controller('health')
export class HealthController {
  constructor(
    private health: HealthCheckService,
    private db: TypeOrmHealthIndicator,
    private memory: MemoryHealthIndicator,
    private disk: DiskHealthIndicator,
    private redis: RedisHealthIndicator,
  ) {}

  @Get()
  @HealthCheck()
  check() {
    return this.health.check([
      () => this.db.pingCheck('database'),
      () => this.redis.isHealthy('redis'),
      () => this.memory.checkHeap('memory_heap', 200 * 1024 * 1024),
      () => this.memory.checkRSS('memory_rss', 512 * 1024 * 1024),
      () => this.disk.checkStorage('disk', {
        path: '/',
        thresholdPercent: 0.9,
      }),
    ]);
  }
}

이렇게 구성하면 하나의 GET /health 요청으로 DB, Redis, 메모리, 디스크 상태를 전부 확인할 수 있다.


Liveness vs Readiness

Kubernetes 환경에서는 헬스체크를 두 가지로 분리하는 것이 일반적이다:

  • Liveness Probe: "이 프로세스가 살아 있는가?" — 실패하면 컨테이너를 재시작
  • Readiness Probe: "이 프로세스가 트래픽을 받을 준비가 됐는가?" — 실패하면 트래픽에서 제외

이 구분이 왜 필요한지 생각해보자. 앱이 시작되면서 DB 마이그레이션을 실행하는 중이라고 하자. 프로세스는 살아 있지만(Liveness ✅) 아직 요청을 처리할 수 없다(Readiness ❌). 이때 Readiness만 실패하면 트래픽만 차단되고 컨테이너는 재시작되지 않는다.

반대로, 메모리 릭으로 프로세스가 교착 상태에 빠졌다면 Liveness가 실패하고 컨테이너가 재시작된다.

NestJS에서는 엔드포인트를 분리해서 구현한다:

typescript
@Controller('health')
export class HealthController {
  constructor(
    private health: HealthCheckService,
    private db: TypeOrmHealthIndicator,
    private memory: MemoryHealthIndicator,
  ) {}

  // Liveness: 프로세스가 살아 있는지만 확인
  @Get('liveness')
  @HealthCheck()
  liveness() {
    return this.health.check([
      () => this.memory.checkHeap('memory_heap', 300 * 1024 * 1024),
    ]);
  }

  // Readiness: 외부 의존성까지 확인
  @Get('readiness')
  @HealthCheck()
  readiness() {
    return this.health.check([
      () => this.db.pingCheck('database'),
      () => this.memory.checkHeap('memory_heap', 200 * 1024 * 1024),
    ]);
  }
}

Kubernetes 설정에서 이렇게 연결한다:

yaml
livenessProbe:
  httpGet:
    path: /health/liveness
    port: 3000
  initialDelaySeconds: 15
  periodSeconds: 10

readinessProbe:
  httpGet:
    path: /health/readiness
    port: 3000
  initialDelaySeconds: 5
  periodSeconds: 10

initialDelaySeconds는 컨테이너 시작 후 첫 번째 체크까지 대기 시간이다. 앱 부팅에 걸리는 시간을 고려해서 설정한다. periodSeconds는 체크 간격이다.


Graceful Shutdown과 헬스체크의 관계

헬스체크와 함께 고려해야 할 것이 Graceful Shutdown이다. 서버를 종료할 때 진행 중인 요청을 마무리하고, 새 요청을 거부한 다음 종료해야 한다.

NestJS에서는 enableShutdownHooks()로 이를 활성화한다:

typescript
// main.ts
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks();
await app.listen(3000);

이렇게 하면 SIGTERM 신호를 받았을 때 NestJS가 OnModuleDestroy, BeforeApplicationShutdown, OnApplicationShutdown 라이프사이클 훅을 순서대로 실행한다.

Kubernetes에서의 흐름은 이렇다:

  1. Pod에 SIGTERM 전송
  2. Readiness 체크 실패 → 트래픽 차단
  3. 진행 중인 요청 완료 대기
  4. DB 커넥션 정리, 캐시 플러시 등
  5. 프로세스 종료

terminationGracePeriodSeconds(기본 30초) 안에 종료되지 않으면 SIGKILL로 강제 종료된다.


헬스체크 보안

헬스체크 엔드포인트는 시스템 내부 정보(DB 상태, 메모리 사용량, 디스크 공간)를 노출한다. 공개 API에 그대로 노출하면 공격자에게 인프라 정보를 제공하는 셈이다.

일반적인 보호 전략:

  1. 내부 네트워크에서만 접근 가능하게 제한: 로드 밸런서만 접근 가능하고 외부에서는 차단
  2. Guard로 인증 적용: API 키나 토큰으로 보호
  3. 응답 최소화: 외부에는 { "status": "ok" }만 반환하고, 상세 정보는 내부 엔드포인트에서만 제공
typescript
// 외부용: 최소 정보
@Get()
check() {
  return { status: 'ok' };
}

// 내부용: 상세 정보 (Guard 적용)
@Get('details')
@UseGuards(InternalOnlyGuard)
@HealthCheck()
checkDetails() {
  return this.health.check([...]);
}

타임아웃 처리

인디케이터가 응답하지 않고 멈추는 경우를 대비해야 한다. DB 서버가 완전히 죽은 게 아니라 느려진 상태라면, 헬스체크 자체가 지연되면서 연쇄적으로 문제가 생긴다.

@nestjs/terminusHealthCheckService는 기본적으로 개별 인디케이터에 타임아웃을 걸지 않는다. 필요하다면 직접 구현해야 한다:

typescript
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(
      () => reject(new Error(`Health check timed out after ${ms}ms`)),
      ms,
    );
    promise
      .then(resolve)
      .catch(reject)
      .finally(() => clearTimeout(timer));
  });
}

// 사용
@Get()
@HealthCheck()
check() {
  return this.health.check([
    () => withTimeout(this.db.pingCheck('database'), 3000),
    () => withTimeout(this.redis.isHealthy('redis'), 2000),
  ]);
}

이렇게 하면 DB 체크가 3초 이내에 응답하지 않으면 실패로 처리된다. 헬스체크 자체가 빠르게 응답해야 로드 밸런서가 제때 판단할 수 있다.


정리

헬스체크는 "서버가 떠 있나?"를 넘어서 "서버가 제대로 작동하나?"를 확인하는 메커니즘이다. @nestjs/terminus는 이를 NestJS 스타일로 깔끔하게 구현할 수 있게 해준다.

  • HealthCheckService로 여러 인디케이터를 묶어서 한 번에 실행
  • 내장 인디케이터로 HTTP, DB, 메모리, 디스크를 즉시 점검
  • 커스텀 인디케이터로 Redis, Elasticsearch 등 무엇이든 점검 가능
  • Liveness/Readiness 분리로 Kubernetes와 자연스럽게 연동
  • 보안과 타임아웃까지 고려하면 프로덕션 수준의 헬스체크 완성

관련 문서