문제 상황: 로그 없는 서버 종료
프로덕션 환경에서 이상한 현상이 2번이나 발생했습니다. NestJS 기반 백엔드 서버가 갑자기 종료되는데, 가장 당황스러운 점은 로깅 파일인 app.log에 아무런 기록이 남지 않았다는 것이었습니다.
서버 상태 확인
$ ps aux | grep node
# 프로세스가 사라져 있음
$tail -100 app.log
# 마지막 로그 이후 아무런 기록 없음
일반적인 애플리케이션 오류라면 스택 트레이스나 에러 메시지가 남아야 하는데, 완전히 깨끗한 상태였습니다. 마치 누군가 kill -9로 프로세스를 강제 종료한 것처럼 말이죠.
디버깅 과정, 메모리 누수부터 시스템 로그까지
1단계: 메모리 누수 의심
처음에는 Node.js의 고질적 문제인 메모리 누수를 의심했습니다. 서버가 1-2일 주기로 종료되는 패턴을 보였기 때문입니다.
// main.ts에 메모리 모니터링 추가
const logMemoryUsage = () => {
const used = process.memoryUsage();
const mb = (bytes: number) => Math.round(bytes / 1024 / 1024 * 100) / 100;
const uptimeHours = Math.round(process.uptime() / 3600 * 100) / 100;
logger.log(`📊 Memory Usage - RSS: ${mb(used.rss)}MB, Heap: ${mb(used.heapUsed)}/${mb(used.heapTotal)}MB, External: ${mb(used.external)}MB, Uptime: ${uptimeHours}h`);
};
setInterval(logMemoryUsage, 30 * 60 * 1000); // 30분마다
결과: 15시간 동안 단 4.5MB만 증가한 매우 안정적인 메모리 패턴을 확인했습니다. 메모리 누수는 완전히 배제되었습니다.
2단계: PostgreSQL 로그 분석의 반전
애플리케이션 로그가 아닌 시스템 로그를 확인해보니 특이한점을 발견했습니다.
# PostgreSQL 로그
2025-09-10 06:25:49 UTC [40982] LOG: received fast shutdown request
2025-09-10 06:25:49 UTC [40982] LOG: database system is shut down
2025-09-10 06:25:50 UTC [40982] LOG: starting PostgreSQL 16.10
PostgreSQL이 백엔드 서버 종료 의심 시점에 재시작되고 있었습니다.
3단계: 범인 특정 - APT Unattended Upgrades
더 깊이 파고들어 /var/log/apt/history.log를 확인한 결과, 결정적 원인을 발견했습니다.
Start-Date: 2025-09-10 06:25:49
Commandline: /usr/bin/unattended-upgrade
Upgrade: postgresql-16:amd64 (16.9-0ubuntu0.24.04.1, 16.10-0ubuntu0.24.04.1)
End-Date: 2025-09-10 06:25:52
타임스탬프가 정확히 일치했습니다. PostgreSQL 자동 업그레이드가 서버 종료와 동일한 시점에 발생했던 것입니다.
기술적 분석: 왜 서버가 크래시될까?
NestJS-TypeORM-PostgreSQL 연결 체인의 취약점
// app.module.ts의 TypeORM 설정
TypeOrmModule.forRootAsync({
// ... 기타 설정
retryAttempts: 3, // 단 3번만 재시도
retryDelay: 3000, // 3초 간격
})
PostgreSQL이 업그레이드로 재시작되면 다음과 같은 연쇄 반응이 발생합니다.
- Connection Pool 무효화: 기존 모든 DB 연결이 끊어짐
- 새 요청 시 연결 실패: API 요청이 들어오면 DB 연결 시도
- 재시도 한계 도달: TypeORM이 3번 재시도 후 포기
- UnhandledPromiseRejection: 처리되지 않은 Promise 거부
- Node.js 프로세스 종료: Node.js v15+에서 기본 동작
// Node.js의 기본 동작
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection:', reason);
process.exit(1); // 즉시 종료, 로그 버퍼 플러시 안됨
});
로그가 남지 않는 이유
process.exit(1)로 강제 종료되면
- 버퍼링된 로그가 디스크에 기록되지 않음
- Graceful shutdown 과정 생략
- 파일 시스템 동기화 없이 종료
이것이 app.log에 아무런 기록이 남지 않았던 이유입니다.
VM 환경에서 자동 업그레이드가 활성화된 이유
Ubuntu Server의 기본 보안 정책
# /etc/apt/apt.conf.d/20auto-upgrades
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
Ubuntu Server는 보안상의 이유로 기본적으로 자동 업그레이드가 활성화되어 있습니다. 이는 다음과 같은 이유 때문입니다.
- 보안 패치 자동 적용: 중요한 보안 업데이트를 놓치지 않기 위함
- 관리 부담 감소: 시스템 관리자의 수동 개입 최소화
- 컴플라이언스 요구사항: 기업 환경에서의 보안 정책 준수
가상화 환경의 특수성
가상머신 환경에서는 호스트 시스템과 분리되어 있어 더욱 적극적인 자동 업데이트 정책을 적용하는 경우가 많습니다.
# systemd 타이머로 관리되는 자동 업그레이드
$ systemctl list-timers | grep apt
apt-daily-upgrade.timer
apt-daily.timer
해결 방법: 단계별 대응 전략
1. 즉시 적용 가능한 해결책
A. PostgreSQL 패키지 블랙리스트 추가
# /etc/apt/apt.conf.d/50unattended-upgrades 수정
sudo nano /etc/apt/apt.conf.d/50unattended-upgrades
# 다음 섹션에 추가:
Unattended-Upgrade::Package-Blacklist {
"postgresql*";
"libpq*";
"postgresql-client*";
};
B. 패키지 홀드 설정
# PostgreSQL 관련 패키지를 홀드 상태로 설정
sudo apt-mark hold postgresql-16 postgresql-client-16 libpq5
# 설정 확인
apt-mark showhold
2. 애플리케이션 레벨 개선
A. TypeORM 연결 복원력 강화
// app.module.ts
TypeOrmModule.forRootAsync({
useFactory: (configService: ConfigService) => ({
// ... 기존 설정
retryAttempts: 10, // 3 → 10으로 증가
retryDelay: 5000, // 3초 → 5초로 증가
keepConnectionAlive: true, // 연결 유지 시도
extra: {
connectionLimit: 10, // 연결 풀 크기
acquireTimeout: 60000, // 연결 대기 시간
timeout: 60000, // 쿼리 타임아웃
reconnect: true, // 자동 재연결
idleTimeout: 300000, // 유휴 연결 타임아웃
}
}),
})
B. Graceful Error Handling
// 글로벌 에러 핸들러 추가
import { Logger } from '@nestjs/common';
process.on('unhandledRejection', (reason, promise) => {
const logger = new Logger('UnhandledRejection');
logger.error('Unhandled Rejection at:', promise, 'reason:', reason);
// 즉시 종료 대신 graceful shutdown 시도
setTimeout(() => {
logger.error('Forcing exit due to unhandled rejection');
process.exit(1);
}, 5000);
});
process.on('uncaughtException', (error) => {
const logger = new Logger('UncaughtException');
logger.error('Uncaught Exception:', error);
process.exit(1);
});
3. 모니터링 및 알림 체계 구축
A. 데이터베이스 연결 상태 모니터링
@Injectable()
export class HealthService {
constructor(
@InjectConnection() private connection: Connection,
) {}
@Cron('*/30 * * * * *') // 30초마다
async checkDatabaseHealth() {
try {
await this.connection.query('SELECT 1');
// 연결 정상
} catch (error) {
// 연결 실패 알림
this.logger.error('Database connection failed:', error);
// Slack/Email 알림 발송
}
}
}
B. 시스템 업그레이드 사전 알림
# 업그레이드 전 알림 스크립트
cat > /etc/apt/apt.conf.d/99notify-upgrade << 'EOF'
DPkg::Pre-Install-Pkgs {
"/usr/local/bin/notify-upgrade.sh";
};
EOF
# 알림 스크립트 작성
sudo nano /usr/local/bin/notify-upgrade.sh
결론 및 교훈
이번 사건을 통해 얻은 주요 교훈들
1. 인프라 레벨 이벤트의 중요성
애플리케이션 로그만으로는 모든 문제를 파악할 수 없습니다. 시스템 로그, 패키지 관리자 로그, 데이터베이스 로그를 종합적으로 분석해야 합니다.
2. 자동화의 양면성
자동 업그레이드는 보안에는 도움이 되지만, 프로덕션 안정성에는 위험 요소가 될 수 있습니다. 적절한 제어와 모니터링이 필요합니다.
3. 연결 의존성 관리
마이크로서비스 환경에서 데이터베이스 의존성을 가진 서비스는 연결 복원력(Connection Resilience)을 반드시 고려해야 합니다.
4. 모니터링의 다층화
- 애플리케이션 레벨: 메모리, CPU, 응답 시간
- 인프라 레벨: 시스템 이벤트, 네트워크, 디스크
- 서비스 레벨: 데이터베이스 연결, 외부 API 상태
이제 서버는 PostgreSQL 자동 업그레이드로부터 안전하며, 더 강력한 연결 복구 메커니즘을 갖추게 되었습니다. 무엇보다 "로그 없는 장애"라는 것은 없다는 것을 깨달았습니다. 단지 우리가 올바른 곳을 보지 못했을 뿐이었죠.
이 글이 비슷한 문제를 겪고 있는 개발자들에게 도움이 되기를 바랍니다. 특히 Ubuntu Server 환경에서 NestJS를 운영하는 분들은 자동 업그레이드 설정을 한 번 점검해보시기 바랍니다.