npm error signal SIGSEGV 에러가 발생했다.
import { hash } from 'bcrypt'; @Transactional() async signup(request: UserRequest): Promise { const { token, provider, image, password, email } = request; this.LOGGER.log( '--------------------유저 생성 서비스 실행--------------------', ); const existingUser: User = await this.userRepository.findByEmail(email); this.LOGGER.log( 1. 기존 유저 조회: ${existingUser ? '존재함' : '존재하지 않음'}, ); if (existingUser) { throw new ApiException(ErrorCode.ALREADY_EXIST_EMAIL); } const id = uuid(); this.LOGGER.log(2. 새로운 유저 ID 생성); const hashedPassword = provider ? '' : hashSync(password, 10); this.LOGGER.log(3. 비밀번호 해싱 완료); ... } 위 코드를 보면 새로운 유저 ID 생성 로그가 발생한 후에 3번 로그인 비밀번호 해싱 완료로그가 발생하지 않았다. 그래서 정말 bcrypt 라이브러리에서 hash를 하는 과정에서 문제였는지 주석처리를 하고 테스트를 해보았다. // const hashedPassword = provider ? '' : hashSync(password, 10); const hashedPassword = 'TEST_PASSWORD_HASHED'; 이렇게 임의의 문자열을 넣고 회원가입 테스트를 해봤고, db에 정상적으로 잘 입력되었다. 사실 로컬환경에서는 도커를 사용하지 않고, 배포환경에서는 CI/CD 자동화 파이프라인이 구축되어있어서 환경이 많이 다르긴하다. 하지만 비밀번호 해싱해주는 라이브러리가 문제를 일으킬줄은 상상도 못했다. 찾아보니 bcrypt 라이브러리는 C++로 만들어진 C++ 네이티브 모듈이었다. 왜 C++ 만들었을까? 첫번째 이유는 성능 이다. 비밀번호 암호화는 많은 연산을 필요로 한다. 무차별 대입 공격(Brute-force attack)을 어렵게 만들기 위해 의도적으로 연산량이 많도록 설계된 라이브러리이다. 그래서 C++와 같은 고급언어 중 저수준 언어로 만들어서 성능을 최적화한 것이다. 두번째 이유는 보안 이다. 핵심 로직을 C++로 구현하면 일부 보안 취약점에 더 강할 수 있다. 물론 bcrypt의 핵심 부분이 C++로 되어 있다고 하여서 무조건 "C++가 JS보다 더 안전하다"는 일반론 적인 이야기는 아니다. 오히려 C++는 메모리에 직접 접근하기 때문에 메모리 버그(예: 버퍼 오버플로우)가 생기기 쉬워서 문제가 생길 수 있다. (설명이 길어질 수 있으므로 나중에 따로 정리하겠다.) 그래서 그게 왜 문제가 되었을까? 문제는 C++로 작성된 부분을 도커 컨테이너에 맞게 컴파일해서 사용한다는 점이다. 로컬환경에서는 이미 C++ 컴파일에 필요한 build-essential같은 도구들이 이미 준비되어 있거나 Node.js가 잘 처리해줘서 npm install할 때 bcrypt의 C++부분이 문제없이 잘 빌드가 되었기 때문에 SIGSEGV에러가 발생하지 않았을 가능성이 있다. 도커 이미지 안에는 개발 또는 빌드에 필요한 모든 도구가 기본적으로 다 제공되진 않는다. build-essential이나 python3같은 C++ 컴파일 관련 도구들이 없으면 npm install할 때 컴파일 도구를 찾지 못해서 에러를 내거나, 빌드에 관련 도구들이 누락이 되버린다. 그래서 나중에 컴파일 도구가 필요한 라이브러리를 사용하게 되면 지금과 같은 상황이 되버린다. 결국 SIGSEGV을 어떻게 해결했나? 이렇게 SIGSEGV가 떴던 원인은 정확히 "bcrypt 라이브러리의 C++ 네이티브 모듈이 도커 컨테이너 환경에서 제대로 빌드되지 않았기 때문" 이었다. 처음에 Dockerfile에 build-essential이랑 python3를 추가해서 컨테이너 안에 C++ 컴파일 도구를 넣어주는 시도도 해봤다. 하지만 이 악독한 SIGSEGV는 결국 나를 떠나지 않았다.. 결국, 고민 끝에 내린 특단의 조치는 바로 bcrypt 대신 bcryptjs를 사용하는 것이었다. bcryptjs는 뭔데? bcryptjs는 bcrypt와 동일한 암호화 알고리즘을 사용하지만, 순수 JavaScript로만 구현된 라이브러리 다. C++ 네이티브 모듈 부분이 전혀 없기 때문에, 컴파일 문제나 도커 환경에서의 호환성 문제로부터 완전히 자유로울 수 있다는 것이 핵심이다. bcryptjs가 순수 JS인데 느리지 않을까? bcrypt는 C++이라 빠르고, bcryptjs는 JS라 느려서 많이 답답할 것이라 생각할 것이다. 하지만 Node.js 환경에서 bcryptjs가 잘 작동하는 데는 중요한 이유가 있다. JavaScript는 기본적으로 싱글 스레드 기반이라 CPU 집약적인 동기 작업은 메인 스레드를 블로킹해서 서버를 먹통으로 만들 수 있다. 하지만 bcryptjs의 hash() 함수는 비동기적으로 동작 하도록 설계되어 있다. Node.js는 libuv라는 라이브러리를 통해 Worker Pool (스레드 풀) 기능을 제공하는데, bcryptjs의 CPU를 많이 쓰는 해싱 연산은 메인 JavaScript 스레드가 아닌, 이 백그라운드 스레드에서 처리 된다. 즉, 해싱 작업이 진행되는 동안에도 메인 스레드는 다른 요청들을 계속 처리할 수 있다는 것이다. 결국, bcryptjs는 C++ bcrypt보다 단일 해싱 작업 속도는 미세하게 느릴 수 있지만, Node.js의 비동기 처리 덕분에 메인 스레드를 블로킹하지 않아서 전체 서버의 처리량(Throughput)에는 큰 영향을 주지 않는다. 오히려 네이티브 모듈 컴파일 문제 없이 어느 환경에서나 쉽고 안정적으로 배포될 수 있다는 압도적인 장점 이 있는 것이다. 내가 겪었던 SIGSEGV 문제가 바로 bcryptjs가 왜 필요한지를 몸소 보여준 예시라고 할 수 있겠다. 적용 방법 기존 bcrypt 제거 및 bcryptjs 설치 npm uninstall bcrypt npm install bcryptjs 코드 수정 (UserService.ts 및 비밀번호 검증 로직) bcrypt를 import 했던 부분을 bcryptjs로 바꾸고, 비동기 hash 함수를 사용하도록 코드를 변경했다. // UserService.ts // import { hashSync } from 'bcrypt'; // 기존 import는 제거하거나 주석 처리 import * as bcrypt from 'bcryptjs'; // ⬅️ 이걸로 변경! async signup(request: UserRequest): Promise { // ... const hashedPassword = request.provider ? '' : await bcrypt.hash(request.password, 10); // ⬅️ await와 함께 비동기 hash 함수 사용 this.LOGGER.log(3. 비밀번호 해싱 완료); // ... return { /* UserResponse 객체 반환 */ }; } // 로그인 시 비밀번호 검증 부분도 마찬가지로 bcryptjs의 비동기 compare 함수 사용 // AuthService.ts 예시 (user && (await bcrypt.compare(dto.password, user.password))) async validateUser(dto: AuthRequest): Promise { const user: User = await this.userService.findByEmail(dto.email); if (!user) { throw new ApiException(ErrorCode.USER_NOT_FOUND); } if (user && (await bcrypt.compare(dto.password, user.password))) { // await와 함께 compare 함수 사용 const { password, ...rest } = user; return rest; } else { throw new ApiException(ErrorCode.INCORRECT_EMAIL_OR_PASSWORD); } } Dockerfile 원복 (이제 build-essential 필요 없음) build-essential이나 python3 설치 라인은 bcryptjs에게는 필요 없으니 Dockerfile에서 깨끗하게 지웠다. 다시 도커 커밋후 배포 결과는? 성공이었다. 드디어 SIGSEGV는 더 이상 뜨지 않았고, 회원가입과 로그인이 정상적으로 작동했다. moneybuddy-server-1 | info: --------------------이메일 토큰 검증 서비스 종료-------------------- {"context":"AuthService","timestamp":"2025-11-21 23:51:05"} moneybuddy-server-1 | info: 이메일 토큰 검증 완료 {"context":"AuthController","timestamp":"2025-11-21 23:51:05"} moneybuddy-server-1 | info: --------------------이메일 토큰 검증 컨트롤러 종료-------------------- {"context":"AuthController","timestamp":"2025-11-21 23:51:05"} moneybuddy-server-1 | info: {"url":"/api/auth/signup","method":"POST","body":{"name":"박아무개","email":"박아무개@naver.com","token":"865f6508-5bfb-4745-ae97-7dde6513b7fb","password":"12341234"}} {"timestamp":"2025-11-21 23:51:15"} moneybuddy-server-1 | info: --------------------회원가입 생성 컨트롤러 실행-------------------- {"context":"AuthController","timestamp":"2025-11-21 23:51:15"} moneybuddy-server-1 | info: 계정 생성 요청 받음 {"context":"AuthController","timestamp":"2025-11-21 23:51:15"} moneybuddy-server-1 | info: --------------------유저 생성 서비스 실행-------------------- {"context":"UserService","timestamp":"2025-11-21 23:51:15"} moneybuddy-server-1 | info: 1. 기존 유저 조회: 존재하지 않음 {"context":"UserService","timestamp":"2025-11-21 23:51:15"} moneybuddy-server-1 | info: 2. 새로운 유저 ID 생성 {"context":"UserService","timestamp":"2025-11-21 23:51:15"} moneybuddy-server-1 | info: 3. 비밀번호 해싱 완료 {"context":"UserService","timestamp":"2025-11-21 23:51:15"} moneybuddy-server-1 | info: 4. 이미지 생성 예정 (일반계정이면 건너뜀 7번으로 넘어감) {"context":"UserService","timestamp":"2025-11-21 23:51:15"} moneybuddy-server-1 | info: 7. 유저 엔티티 생성 완료 {"context":"UserService","timestamp":"2025-11-21 23:51:15"} moneybuddy-server-1 | info: 8. 유저 저장 완료 {"context":"UserService","timestamp":"2025-11-21 23:51:15"} moneybuddy-server-1 | info: 9. 이메일 토큰 삭제 및 회원가입 이벤트 실행 {"context":"UserService","timestamp":"2025-11-21 23:51:15"} moneybuddy-server-1 | info: 10. 이메일 토큰 삭제 완료 {"context":"UserService","timestamp":"2025-11-21 23:51:15"} moneybuddy-server-1 | info: --------------------계좌 생성 서비스 실행-------------------- {"context":"AccountService","timestamp":"2025-11-21 23:51:15"} moneybuddy-server-1 | info: 1. 유저 존재 여부 확인 완료 {"context":"AccountService","timestamp":"2025-11-21 23:51:15"} moneybuddy-server-1 | info: 2. 계좌 생성 완료 {"context":"AccountService","timestamp":"2025-11-21 23:51:15"} moneybuddy-server-1 | info: --------------------계좌 생성 서비스 종료-------------------- {"context":"AccountService","timestamp":"2025-11-21 23:51:15"} moneybuddy-server-1 | info: 11. 회원가입 이벤트 실행 완료 {"context":"UserService","timestamp":"2025-11-21 23:51:15"} moneybuddy-server-1 | info: --------------------유저 생성 서비스 종료-------------------- {"context":"UserService","timestamp":"2025-11-21 23:51:15"} moneybuddy-server-1 | info: 계정 생성 완료 {"context":"AuthController","timestamp":"2025-11-21 23:51:15"} moneybuddy-server-1 | info: --------------------회원가입 생성 컨트롤러 종료-------------------- {"context":"AuthController","timestamp":"2025-11-21 23:51:15"} moneybuddy-server-1 | info: {"url":"/api/auth/login","method":"POST","body":{"email":"박아무개@naver.com","password":"12341234"}} {"timestamp":"2025-11-21 23:51:15"} moneybuddy-server-1 | info: --------------------로그인 컨트롤러 실행-------------------- {"context":"AuthController","timestamp":"2025-11-21 23:51:15"} moneybuddy-server-1 | info: 로그인 요청 받음 {"context":"AuthController","timestamp":"2025-11-21 23:51:15"} moneybuddy-server-1 | info: --------------------로그인 서비스 실행------------------- {"context":"AuthService","timestamp":"2025-11-21 23:51:15"} moneybuddy-server-1 | info: 1. 유저 검증 진행 {"context":"AuthService","timestamp":"2025-11-21 23:51:15"} moneybuddy-server-1 | info: 2. 이메일로 유저 조회 {"context":"AuthService","timestamp":"2025-11-21 23:51:15"} moneybuddy-server-1 | info: 3. 유저가 존재하는가? 존재함 {"context":"AuthService","timestamp":"2025-11-21 23:51:15"} moneybuddy-server-1 | info: 4. 비밀번호 일치함 {"context":"AuthService","timestamp":"2025-11-21 23:51:15"} moneybuddy-server-1 | info: 2. 유저 검증 완료 {"context":"AuthService","timestamp":"2025-11-21 23:51:15"} moneybuddy-server-1 | info: 3. JWT 토큰 생성 완료 {"context":"AuthService","timestamp":"2025-11-21 23:51:15"} moneybuddy-server-1 | info: --------------------로그인 서비스 종료------------------- {"context":"AuthService","timestamp":"2025-11-21 23:51:15"} moneybuddy-server-1 | info: 로그인 완료 박아무개님 환영합니다! {"context":"AuthController","timestamp":"2025-11-21 23:51:15"} moneybuddy-server-1 | info: --------------------로그인 컨트롤러 종료-------------------- {"context":"AuthController","timestamp":"2025-11-21 23:51:15"} 배운 점 이번 삽질 대잔치를 통해 여러 가지를 느꼈다. 네이티브 모듈이 포함된 라이브러리를 사용할 때는 도커 컨테이너의 OS 환경과 빌드 도구 유무를 꼼꼼히 확인해야 한다. SIGSEGV와 같은 메모리 관련 에러는 대부분 저수준(C/C++, 하드웨어) 문제와 연결되어 있음을 인지하고 접근해야 한다. 처음에는 Prisma 문제인 줄 알고 삽질 오지게 했다... 문제가 발생했을 때 로그를 꼼꼼히 작성하고 분석하는 습관이 얼마나 중요한지 다시 한번 실감했다. 3. 비밀번호 해싱 완료 로그가 찍히기 전에 에러가 났다는 걸 아는 것이 문제 해결의 시작점이었다. 순수 JavaScript 대안 라이브러리(예: bcryptjs)의 중요성. 네이티브 모듈의 성능 이점을 잃는 대신, 배포의 복잡성과 에러 가능성을 크게 줄일 수 있다는 트레이드오프를 명확히 이해하게 되었다. 하지만, bcryptjs로 교체한 것은, 임시방편일 뿐.. 기말고사만 끝나면, 다시 한번 bcrypt를 시도해보려고 한다.
2025년 11월 21일
배포후 에러없이 강제종료되는 문제
moneybuddy-server-1 | info: Mapped {/api/transaction/expense/create, POST} route {"context":"RouterExplorer","timestamp":"2025-11-19 17:21:44"} moneybuddy-server-1 | info: Mapped {/api/transaction/income/create, POST} route {"context":"RouterExplorer","timestamp":"2025-11-19 17:21:44"} moneybuddy-server-1 | info: Mapped {/api/transaction/monthly, GET} route {"context":"RouterExplorer","timestamp":"2025-11-19 17:21:44"} moneybuddy-server-1 | info: Mapped {/api/transaction/:id, GET} route {"context":"RouterExplorer","timestamp":"2025-11-19 17:21:44"} moneybuddy-server-1 | info: Mapped {/api/transaction/income/update/:id, PUT} route {"context":"RouterExplorer","timestamp":"2025-11-19 17:21:44"} moneybuddy-server-1 | info: Mapped {/api/transaction/expense/update/:id, PUT} route {"context":"RouterExplorer","timestamp":"2025-11-19 17:21:44"} moneybuddy-server-1 | info: Mapped {/api/transaction/delete, DELETE} route {"context":"RouterExplorer","timestamp":"2025-11-19 17:21:44"} moneybuddy-server-1 | info: BudgetController {/api/budget}: {"context":"RoutesResolver","timestamp":"2025-11-19 17:21:44"} moneybuddy-server-1 | info: Mapped {/api/budget/create, POST} route {"context":"RouterExplorer","timestamp":"2025-11-19 17:21:44"} moneybuddy-server-1 | info: Mapped {/api/budget, GET} route {"context":"RouterExplorer","timestamp":"2025-11-19 17:21:44"} moneybuddy-server-1 exited with code 1 상황설명 개발 과정에서 애플리케이션에 명백한 오류가 발생함에도 불구하고 (exited with code 1), 서버 로그에 어떠한 에러 메시지도 출력되지 않는 현상이 관찰되었다. 프론트엔드에서는 500 Internal Server Error를 수신하며 서비스 이상이 감지되나, 백엔드 서버 콘솔이나 로그 파일에는 정상적인 INFO 또는 DEBUG 레벨의 로그만 기록될 뿐, 예외 상황에 대한 어떠한 정보도 확인할 수 없었다. 이로 인해 문제 발생 지점을 특정하기 어렵고, 디버깅에 막대한 시간이 소요되며, 시스템의 안정성을 위협하는 '조용한 실패' 상태에 직면하게 되었다. 근거: Winston 로거가 에러를 삼키는 원인 분석 Winston은 Node.js 환경에서 널리 사용되는 유연한 로깅 라이브러리이나, 특정 설정이나 사용 방식에 따라 에러를 "삼키는" 듯한 동작을 보일 수 있다. 주요 원인은 다음과 같다. exitOnError: false 옵션의 오해: Winston 로거 인스턴스를 생성할 때 exitOnError: false 옵션을 설정할 수 있다. 이 옵션은 Node.js의 uncaughtException 발생 시 애플리케이션 프로세스의 강제 종료를 방지하고 로깅만 수행하도록 하는 기능을 한다. 그러나 이 옵션을 false로 설정했을 때, 만약 Winston 로거 자체의 트랜스포트(콘솔, 파일 등) 구성에 문제가 있거나 로깅 과정에서 또 다른 에러가 발생하여 실제 로깅에 실패한다면, 프로세스는 종료되지 않으면서 에러 정보도 기록되지 않는 상황이 발생할 수 있다. 이는 치명적인 에러의 발생 사실 자체를 인지하지 못하게 만든다. uncaughtException 및 unhandledRejection 핸들러의 부재 또는 잘못된 설정: Node.js 환경에서 비동기 코드(Promise 등)의 에러(unhandledRejection) 및 전역에서 처리되지 않은 동기 예외(uncaughtException)는 애플리케이션의 안정성에 직접적인 영향을 미친다. Winston은 이러한 전역 예외를 처리하기 위한 기능을 제공하나, 로거 설정 시 handleExceptions: true 및 handleRejections: true 옵션을 명시적으로 트랜스포트에 적용하지 않거나, Node.js의 process 객체에 대한 별도의 이벤트 핸들러(예: process.on('uncaughtException'))를 구성하지 않으면, 해당 에러들이 로깅 시스템에 도달하지 못하고 버려질 수 있다. 특히 unhandledRejection의 경우, Node.js v15 이전 버전에서는 기본적으로 프로세스를 종료하지 않았으며, v15 이후에는 uncaughtException과 유사하게 프로세스를 종료시키는 방향으로 변경되었다. 그러나 로거에서 이를 명시적으로 처리하지 않으면 단순히 프로세스만 종료될 뿐 에러 로그를 남기지 못하는 문제가 발생할 수 있다. 로깅 레벨 설정 불일치: 각 트랜스포트(예: Console, File)는 독립적인 로깅 레벨(level)을 가질 수 있다. 만약 특정 트랜스포트의 level이 info로 설정되어 있다면, error나 warn 레벨의 로그는 해당 트랜스포트를 통해 출력되지 않는다. 따라서 error 레벨의 로그가 필요한 경우, 트랜스포트의 level을 error 또는 그보다 낮은 수준(예: debug)으로 설정해야 한다. 해결방법: 안정적인 Winston 로깅 시스템 구축 우리의 Winston 로거가 에러를 다시 제대로 기록하도록 만들기 위해 코드를 어떻게 수정했는지, 이전 코드와 개선된 코드를 비교하며 살펴보겠다. [이전 코드] 내가 처음 사용했던 Winston 로거 설정은 다음과 같았다. import { formatInTimeZone } from "date-fns-tz"; import { WinstonModule, utilities } from "nest-winston"; import * as winston from "winston"; import * as winstonDaily from "winston-daily-rotate-file"; const env = process.env.NODE_ENV; const apeendTimestamp = winston.format((info, opts: { tz: string }) => { if (opts.tz) { info.timestamp = formatInTimeZone( new Date(), opts.tz, "yyyy-MM-dd HH:mm:ss" ); } return info; }); const dailyOptions = { level: "http", datePattern: "YYYY-MM-DD", dirname: __dirname + "/../../../logs", filename: ${process.env.PROJECT_NAME}.log.%DATE%, maxFiles: 30, zippedArchive: true, colorize: false, handleExceptions: true, // 예외 처리는 되어 있었음 json: false, }; export const winstonLogger = WinstonModule.createLogger({ transports: [ new winston.transports.Console({ level: env === "production" ? "http" : "silly", format: env === "production" ? winston.format.simple() : winston.format.combine( winston.format.timestamp(), utilities.format.nestLike(process.env.APP_NAME, { prettyPrint: true, }) ), }), new winstonDaily(dailyOptions), ], format: winston.format.combine( apeendTimestamp({ tz: "Asia/Seoul" }), winston.format.json(), winston.format.printf((info) => { return ${info.timestamp} - ${info.level} [${process.pid}] : ${info.message}; }) ), }); 이전 코드의 문제점: handleRejections 누락: winston.transports.Console과 winstonDaily 트랜스포트 모두 handleRejections: true 옵션이 명시적으로 설정되어 있지 않았다. 이는 비동기 코드에서 발생하는 Promise 거부(Unhandled Rejection) 에러가 로거에 의해 포착되지 않고 조용히 사라질 수 있는 결정적인 원인이었다. winston.format.errors({ stack: true }) 부재: 에러 스택 트레이스를 로그에 포함시키기 위한 winston.format.errors({ stack: true }) 포맷이 winston.format.combine 체인에 없었다. 이로 인해 에러 객체가 제대로 파싱되지 않고 printf 함수로 전달될 때 스택 정보가 유실될 가능성이 높았다. printf 포맷의 한계: printf 함수 내부에서 info.message만 직접 사용하고 있어, winston.format.errors 포맷이 적용되지 않은 상태에서는 에러 객체로부터 스택 트레이스 등 상세 정보를 추출하기 어려웠다. [개선된 코드] 위에서 언급된 문제점들을 해결하여, Winston 로거가 모든 에러를 안정적으로 포착하고 상세한 정보를 함께 기록하도록 코드를 개선하였다. import { formatInTimeZone } from "date-fns-tz"; import { WinstonModule, utilities } from "nest-winston"; import * as winston from "winston"; import * as winstonDaily from "winston-daily-rotate-file"; import * as path from "path"; const env = process.env.NODE_ENV; const apeendTimestamp = winston.format((info, opts: { tz: string }) => { if (opts.tz) { info.timestamp = formatInTimeZone( new Date(), opts.tz, "yyyy-MM-dd HH:mm:ss" ); } return info; }); // 로그 파일 저장 경로 설정 (프로젝트 루트 디렉토리) const logDir = path.join(process.cwd(), "logs"); const dailyOptions = { level: "error", // ⭐ 'http' 대신 'error'로 변경하여 에러만 파일에 기록 (혹은 'info' 등 필요한 레벨로) datePattern: "YYYY-MM-DD", dirname: logDir, filename: ${process.env.PROJECT_NAME}.log.%DATE%, maxFiles: 30, zippedArchive: true, colorize: false, handleExceptions: true, handleRejections: true, // ⭐ 핵심 추가: Unhandled Rejection도 파일에 기록 json: false, // winston.format.json()과 중복될 수 있으므로 주의 (아래 global format 참고) }; export const winstonLogger = WinstonModule.createLogger({ transports: [ new winston.transports.Console({ level: env === "production" ? "info" : "debug", // ⭐ production 레벨을 'info'로 조정 (http는 너무 verbose할 수 있음) format: env === "production" ? winston.format.combine( // ⭐ production도 format combine으로 스택트레이스 포함 apeendTimestamp({ tz: "Asia/Seoul" }), // 타임스탬프는 공통 포맷으로 이동 (혹은 여기에 포함) winston.format.errors({ stack: true }), // ⭐ 핵심 추가: 에러 스택 트레이스 포함 winston.format.simple() // simple() 포맷 뒤에 stack이 붙도록 조정 ) : winston.format.combine( winston.format.timestamp(), winston.format.errors({ stack: true }), // ⭐ 핵심 추가: 에러 스택 트레이스 포함 utilities.format.nestLike(process.env.APP_NAME, { prettyPrint: true, }) ), handleExceptions: true, // 콘솔 트랜스포트에서 예외 처리 활성화 handleRejections: true, // ⭐ 핵심 추가: 콘솔 트랜스포트에서 Promise 거부 처리 }), new winstonDaily(dailyOptions), ], // ⭐⭐ Global format 개선: 에러 스택 트레이스를 포함하고 메시지를 더 명확하게 구성 format: winston.format.combine( apeendTimestamp({ tz: "Asia/Seoul" }), winston.format.errors({ stack: true }), // ⭐ 핵심 추가: 에러 스택 트레이스 포함! // winston.format.json(), // 필요에 따라 JSON 포맷 사용 (printf와 중복될 수 있으므로 주의) winston.format.printf((info) => { // ⭐ 에러 스택이 있으면 함께 출력하도록 수정 const message = info.message; const stack = info.stack ? \n${info.stack} : ""; // 스택이 있을 때만 추가 return `${info.timestamp} - ${info.level.toUpperCase()} [${ process.pid }] : ${message}${stack}`; }) ), // ⭐ exitOnError 설정에 대한 주의사항 명시. exitOnError: false, }); 개선된 코드의 주요 변경 사항 및 그 효과: handleRejections: true 추가: Console 트랜스포트와 winstonDaily (dailyOptions) 모두에 handleRejections: true 옵션을 명시적으로 추가하여, 비동기 Promise에서 발생하는 처리되지 않은 거부(Unhandled Rejection) 에러까지 로깅 시스템이 포착하고 기록하도록 변경했다. 이전에는 이 에러들이 로거를 통과하지 못하고 '삼켜질' 수 있었다. winston.format.errors({ stack: true }) 적용: winston.format.combine 체인에 winston.format.errors({ stack: true }) 포맷을 추가했다. 이 포맷은 에러 객체가 로그 레코드에 전달될 때, 에러의 스택 트레이스를 info.stack 속성에 포함시켜 준다. 이로써 printf 함수에서 상세한 스택 트레이스 정보를 쉽게 접근하고 로그에 출력할 수 있게 되었다. Console 트랜스포트의 개발(nestLike) 및 프로덕션(simple) 포맷에도 각각 적용하여, 콘솔 출력에서도 상세한 에러 정보가 나타나도록 했다. printf 포맷 개선: printf 함수 내부에서 info.stack 속성을 확인하여, 스택 트레이스가 존재할 경우 이를 메시지에 추가하도록 수정했다. 이는 에러 발생 시 개발자가 문제의 원인을 파악하는 데 결정적인 단서가 된다. 이전에는 단순히 info.message만 출력하여 스택 트레이스 정보를 놓칠 수 있었다. dailyOptions.level 조정: 파일 로그의 level을 error로 변경하여, 파일에는 치명적인 에러 로그만 집중적으로 기록되도록 조정했다. (필요에 따라 info 등으로 변경 가능) exitOnError 설정에 대한 상세 설명 추가: exitOnError: false 설정이 가지는 양면성에 대해 주석으로 상세 설명을 추가하여, 이 옵션의 사용에 있어 개발자가 충분히 인지하고 신중하게 결정할 것을 강조했다. 결론 로깅 시스템은 애플리케이션의 동작 상태를 파악하고 문제 발생 시 신속하게 원인을 진단하는 개발자의 핵심 도구이다. Winston 로거가 에러를 '삼키는' 현상은 잘못된 설정이나 Node.js의 예외 처리 메커니즘에 대한 오해에서 비롯되는 경우가 많다. 안정적인 로깅 시스템을 구축하기 위해서는 Winston의 각 트랜스포트에서 예외 처리(handleExceptions, handleRejections)를 명시적으로 활성화하고, 전역 예외(uncaughtException, unhandledRejection)를 포착하여 상세한 스택 트레이스까지 기록하는 포맷을 적용해야 한다. 또한, exitOnError 옵션의 의미를 정확히 이해하고 애플리케이션의 안정성 요구 사항에 맞춰 신중하게 설정한다. 로깅 시스템에 대한 면밀한 구성은 '조용한 실패'를 방지하고, 서비스의 신뢰성을 확보하며, 개발 과정의 효율성을 극대화하는 데 필수적인 요소이다. 아래는 숨겨진 에러가 모습을 드러낸 장면이다. moneybuddy-server-1 | info: Mapped {/api/budget, GET} route {"context":"RouterExplorer","timestamp":"2025-11-19 19:52:57"} moneybuddy-server-1 | error: unhandledRejection: Prisma Client could not locate the Query Engine for runtime "linux-musl-openssl-3.0.x". moneybuddy-server-1 | moneybuddy-server-1 | This happened because Prisma Client was generated for "debian-openssl-3.0.x", but the actual deployment required "linux-musl-openssl-3.0.x". moneybuddy-server-1 | Add "linux-musl-openssl-3.0.x" to binaryTargets in the "schema.prisma" file and run prisma generate after saving it: moneybuddy-server-1 | moneybuddy-server-1 | generator client { moneybuddy-server-1 | provider = "prisma-client-js" moneybuddy-server-1 | binaryTargets = ["native", "linux-musl-openssl-3.0.x"] moneybuddy-server-1 | } moneybuddy-server-1 | moneybuddy-server-1 | The following locations have been searched: moneybuddy-server-1 | /app/node_modules/.prisma/client moneybuddy-server-1 | /app/node_modules/@prisma/client moneybuddy-server-1 | /tmp/prisma-engines moneybuddy-server-1 | PrismaClientInitializationError: Prisma Client could not locate the Query Engine for runtime "linux-musl-openssl-3.0.x". moneybuddy-server-1 | moneybuddy-server-1 | This happened because Prisma Client was generated for "debian-openssl-3.0.x", but the actual deployment required "linux-musl-openssl-3.0.x". moneybuddy-server-1 | Add "linux-musl-openssl-3.0.x" to binaryTargets in the "schema.prisma" file and run prisma generate after saving it: moneybuddy-server-1 | moneybuddy-server-1 | generator client { moneybuddy-server-1 | provider = "prisma-client-js" moneybuddy-server-1 | binaryTargets = ["native", "linux-musl-openssl-3.0.x"] moneybuddy-server-1 | } moneybuddy-server-1 | moneybuddy-server-1 | The following locations have been searched: moneybuddy-server-1 | /app/node_modules/.prisma/client moneybuddy-server-1 | /app/node_modules/@prisma/client moneybuddy-server-1 | /tmp/prisma-engines moneybuddy-server-1 | at yl (/app/node_modules/@prisma/client/runtime/library.js:64:805) moneybuddy-server-1 | at async Object.loadLibrary (/app/node_modules/@prisma/client/runtime/library.js:111:10635) moneybuddy-server-1 | at async Gr.loadEngine (/app/node_modules/@prisma/client/runtime/library.js:112:448) moneybuddy-server-1 | at async Gr.instantiateLibrary (/app/node_modules/@prisma/client/runtime/library.js:111:14517) {"date":"Wed Nov 19 2025 10:52:57 GMT+0000 (Coordinated Universal Time)","error":{"clientVersion":"6.6.0","name":"PrismaClientInitializationError"},"os":{"loadavg":[4.97,4.56,4.45],"uptime":2159102.99},"process":{"argv":["/usr/local/bin/node","/app/dist/main"],"cwd":"/app","execPath":"/usr/local/bin/node","gid":0,"memoryUsage":{"arrayBuffers":108285,"external":3400647,"heapTotal":71860224,"heapUsed":41951136,"rss":133869568},"pid":1,"uid":0,"version":"v20.19.5"},"rejection":true,"stack":"PrismaClientInitializationError: Prisma Client could not locate the Query Engine for runtime \"linux-musl-openssl-3.0.x\".\n\nThis happened because Prisma Client was generated for \"debian-openssl-3.0.x\", but the actual deployment required \"linux-musl-openssl-3.0.x\".\nAdd \"linux-musl-openssl-3.0.x\" to binaryTargets in the \"schema.prisma\" file and run prisma generate after saving it:\n\ngenerator client {\n provider = \"prisma-client-js\"\n binaryTargets = [\"native\", \"linux-musl-openssl-3.0.x\"]\n}\n\nThe following locations have been searched:\n /app/node_modules/.prisma/client\n /app/node_modules/@prisma/client\n /tmp/prisma-engines\n at yl (/app/node_modules/@prisma/client/runtime/library.js:64:805)\n at async Object.loadLibrary (/app/node_modules/@prisma/client/runtime/library.js:111:10635)\n at async Gr.loadEngine (/app/node_modules/@prisma/client/runtime/library.js:112:448)\n at async Gr.instantiateLibrary (/app/node_modules/@prisma/client/runtime/library.js:111:14517)","timestamp":"2025-11-19 19:52:57","trace":[{"column":805,"file":"/app/node_modules/@prisma/client/runtime/library.js","function":"yl","line":64,"method":null,"native":false},{"column":10635,"file":"/app/node_modules/@prisma/client/runtime/library.js","function":"async Object.loadLibrary","line":111,"method":"loadLibrary","native":false},{"column":448,"file":"/app/node_modules/@prisma/client/runtime/library.js","function":"async Gr.loadEngine","line":112,"method":"loadEngine","native":false},{"column":14517,"file":"/app/node_modules/@prisma/client/runtime/library.js","function":"async Gr.instantiateLibrary","line":111,"method":"instantiateLibrary","native":false}]} moneybuddy-server-1 | error: unhandledRejection: Prisma Client could not locate the Query Engine for runtime "linux-musl-openssl-3.0.x". moneybuddy-server-1 | moneybuddy-server-1 | This happened because Prisma Client was generated for "debian-openssl-3.0.x", but the actual deployment required "linux-musl-openssl-3.0.x". moneybuddy-server-1 | Add "linux-musl-openssl-3.0.x" to binaryTargets in the "schema.prisma" file and run prisma generate after saving it: moneybuddy-server-1 | moneybuddy-server-1 | generator client { moneybuddy-server-1 | provider = "prisma-client-js" moneybuddy-server-1 | binaryTargets = ["native", "linux-musl-openssl-3.0.x"] moneybuddy-server-1 | } moneybuddy-server-1 | moneybuddy-server-1 | The following locations have been searched: moneybuddy-server-1 | /app/node_modules/.prisma/client moneybuddy-server-1 | /app/node_modules/@prisma/client moneybuddy-server-1 | /tmp/prisma-engines moneybuddy-server-1 | PrismaClientInitializationError: Prisma Client could not locate the Query Engine for runtime "linux-musl-openssl-3.0.x". moneybuddy-server-1 | moneybuddy-server-1 | This happened because Prisma Client was generated for "debian-openssl-3.0.x", but the actual deployment required "linux-musl-openssl-3.0.x". moneybuddy-server-1 | Add "linux-musl-openssl-3.0.x" to binaryTargets in the "schema.prisma" file and run prisma generate after saving it: moneybuddy-server-1 | moneybuddy-server-1 | generator client { moneybuddy-server-1 | provider = "prisma-client-js" moneybuddy-server-1 | binaryTargets = ["native", "linux-musl-openssl-3.0.x"] moneybuddy-server-1 | } moneybuddy-server-1 | moneybuddy-server-1 | The following locations have been searched: moneybuddy-server-1 | /app/node_modules/.prisma/client moneybuddy-server-1 | /app/node_modules/@prisma/client moneybuddy-server-1 | /tmp/prisma-engines moneybuddy-server-1 | at yl (/app/node_modules/@prisma/client/runtime/library.js:64:805) moneybuddy-server-1 | at async Object.loadLibrary (/app/node_modules/@prisma/client/runtime/library.js:111:10635) moneybuddy-server-1 | at async Gr.loadEngine (/app/node_modules/@prisma/client/runtime/library.js:112:448) moneybuddy-server-1 | at async Gr.instantiateLibrary (/app/node_modules/@prisma/client/runtime/library.js:111:14517) moneybuddy-server-1 | at async Gr.start (/app/node_modules/@prisma/client/runtime/library.js:112:2060) moneybuddy-server-1 | at async Proxy.onModuleInit (/app/dist/global/prisma/prisma.service.js:33:9) moneybuddy-server-1 | at async Promise.all (index 0) moneybuddy-server-1 | at async callModuleInitHook (/app/node_modules/@nestjs/core/hooks/on-module-init.hook.js:43:5) moneybuddy-server-1 | at async NestApplication.callInitHook (/app/node_modules/@nestjs/core/nest-application-context.js:234:13) moneybuddy-server-1 | at async NestApplication.init (/app/node_modules/@nestjs/core/nest-application.js:100:9) {"date":"Wed Nov 19 2025 10:52:57 GMT+0000 (Coordinated Universal Time)","error":{"clientVersion":"6.6.0","name":"PrismaClientInitializationError"},"os":{"loadavg":[4.97,4.56,4.45],"uptime":2159103},"process":{"argv":["/usr/local/bin/node","/app/dist/main"],"cwd":"/app","execPath":"/usr/local/bin/node","gid":0,"memoryUsage":{"arrayBuffers":116501,"external":3408903,"heapTotal":71860224,"heapUsed":42067832,"rss":134217728},"pid":1,"uid":0,"version":"v20.19.5"},"rejection":true,"stack":"PrismaClientInitializationError: Prisma Client could not locate the Query Engine for runtime \"linux-musl-openssl-3.0.x\".\n\nThis happened because Prisma Client was generated for \"debian-openssl-3.0.x\", but the actual deployment required \"linux-musl-openssl-3.0.x\".\nAdd \"linux-musl-openssl-3.0.x\" to binaryTargets in the \"schema.prisma\" file and run prisma generate after saving it:\n\ngenerator client {\n provider = \"prisma-client-js\"\n binaryTargets = [\"native\", \"linux-musl-openssl-3.0.x\"]\n}\n\nThe following locations have been searched:\n /app/node_modules/.prisma/client\n /app/node_modules/@prisma/client\n /tmp/prisma-engines\n at yl (/app/node_modules/@prisma/client/runtime/library.js:64:805)\n at async Object.loadLibrary (/app/node_modules/@prisma/client/runtime/library.js:111:10635)\n at async Gr.loadEngine (/app/node_modules/@prisma/client/runtime/library.js:112:448)\n at async Gr.instantiateLibrary (/app/node_modules/@prisma/client/runtime/library.js:111:14517)\n at async Gr.start (/app/node_modules/@prisma/client/runtime/library.js:112:2060)\n at async Proxy.onModuleInit (/app/dist/global/prisma/prisma.service.js:33:9)\n at async Promise.all (index 0)\n at async callModuleInitHook (/app/node_modules/@nestjs/core/hooks/on-module-init.hook.js:43:5)\n at async NestApplication.callInitHook (/app/node_modules/@nestjs/core/nest-application-context.js:234:13)\n at async NestApplication.init (/app/node_modules/@nestjs/core/nest-application.js:100:9)","timestamp":"2025-11-19 19:52:57","trace":[{"column":805,"file":"/app/node_modules/@prisma/client/runtime/library.js","function":"yl","line":64,"method":null,"native":false},{"column":10635,"file":"/app/node_modules/@prisma/client/runtime/library.js","function":"async Object.loadLibrary","line":111,"method":"loadLibrary","native":false},{"column":448,"file":"/app/node_modules/@prisma/client/runtime/library.js","function":"async Gr.loadEngine","line":112,"method":"loadEngine","native":false},{"column":14517,"file":"/app/node_modules/@prisma/client/runtime/library.js","function":"async Gr.instantiateLibrary","line":111,"method":"instantiateLibrary","native":false},{"column":2060,"file":"/app/node_modules/@prisma/client/runtime/library.js","function":"async Gr.start","line":112,"method":"start","native":false},{"column":9,"file":"/app/dist/global/prisma/prisma.service.js","function":"async Proxy.onModuleInit","line":33,"method":"onModuleInit","native":false},{"column":null,"file":null,"function":"async Promise.all","line":null,"method":"all","native":false},{"column":5,"file":"/app/node_modules/@nestjs/core/hooks/on-module-init.hook.js","function":"async callModuleInitHook","line":43,"method":null,"native":false},{"column":13,"file":"/app/node_modules/@nestjs/core/nest-application-context.js","function":"async NestApplication.callInitHook","line":234,"method":"callInitHook","native":false},{"column":9,"file":"/app/node_modules/@nestjs/core/nest-application.js","function":"async NestApplication.init","line":100,"method":"init","native":false}]} moneybuddy-server-1 | [VERY_EARLY_DEBUG] Main TS file loaded and starting bootstrap
2025년 11월 19일