From 6b8ea7e74cdc4b22147c7e2c6667d84b01df5a04 Mon Sep 17 00:00:00 2001 From: Hyoseong Jo Date: Mon, 15 Dec 2025 09:55:43 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EB=A1=9C=EA=B7=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Dockerfile | 12 +- backend/src/auth/auth.service.ts | 236 +++++++----------- .../common/filters/all-exceptions.filter.ts | 59 ++--- .../interceptors/logging.interceptor.ts | 36 ++- backend/src/main.ts | 141 +++++++---- backend/src/shared/email/email.service.ts | 99 +++++--- .../verification/verification.service.ts | 198 +++++++++------ 7 files changed, 429 insertions(+), 352 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 21f088d..063e428 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -5,6 +5,14 @@ WORKDIR /app # 필요한 패키지 설치 RUN apk add --no-cache curl +# Node.js 로그 버퍼링 비활성화 (Docker에서 실시간 로그 출력) +ENV NODE_ENV=production +ENV NODE_OPTIONS="--enable-source-maps" + +# stdout/stderr 버퍼링 비활성화 +ENV PYTHONUNBUFFERED=1 +ENV NODE_NO_WARNINGS=1 + # package.json 복사 COPY package*.json ./ @@ -20,5 +28,5 @@ RUN npm run build # 포트 노출 EXPOSE 4000 -# 애플리케이션 실행 -CMD ["npm", "run", "start:prod"] +# 애플리케이션 실행 (unbuffered stdout) +CMD ["node", "--enable-source-maps", "dist/main.js"] diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index bd677f6..b80ac1b 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -3,6 +3,7 @@ import { Injectable, NotFoundException, UnauthorizedException, + Logger, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { UserModel } from '../user/entities/user.entity'; @@ -30,6 +31,8 @@ import { ConfigService } from '@nestjs/config'; @Injectable() export class AuthService { + private readonly logger = new Logger(AuthService.name); + constructor( @InjectRepository(UserModel) private readonly userRepository: Repository, @@ -41,57 +44,49 @@ export class AuthService { /** * 유저 로그인 - * - * @async - * @param {LoginDto} loginDto - * @returns {Promise} */ async login(loginDto: LoginDto): Promise { const { userId, userPassword } = loginDto; + this.logger.log(`[LOGIN] 로그인 시도 - userId: ${userId}`); - // 1. userId로 유저 찾기 const user = await this.userRepository.findOne({ where: { userId }, }); - // 2. user 없으면 에러 - if (!user) { - throw new UnauthorizedException('아이디 또는 비밀번호가 틀렸습니다'); //HTTP 401 상태 코드 예외 - } - // 3. 비밀번호 비교 (bcrypt) - const tempHash = await bcrypt.hash(userPassword, 10); - console.log('=========input password bcrypt hash========:', tempHash); - const isPasswordValid = await bcrypt.compare(userPassword, user.userPw); - if (!isPasswordValid) { + if (!user) { + this.logger.warn(`[LOGIN] 사용자 없음 - userId: ${userId}`); + throw new UnauthorizedException('아이디 또는 비밀번호가 틀렸습니다'); + } + + const isPasswordValid = await bcrypt.compare(userPassword, user.userPw); + if (!isPasswordValid) { + this.logger.warn(`[LOGIN] 비밀번호 불일치 - userId: ${userId}`); throw new UnauthorizedException('아이디 또는 비밀번호가 틀렸습니다'); } - // 4. 탈퇴 여부 확인 if (user.delDt !== null) { + this.logger.warn(`[LOGIN] 탈퇴 계정 - userId: ${userId}`); throw new UnauthorizedException('탈퇴한 계정입니다'); } - // 6. JWT 토큰 생성 + const payload = { userId: user.userId, userNo: user.pkUserNo, - }; - // Access Token 생성 (기본 설정 사용) const accessToken = this.jwtService.sign(payload as any); - - // Refresh Token 생성 (별도 secret과 만료시간 사용) const refreshOptions = { secret: this.configService.get('JWT_REFRESH_SECRET')!, expiresIn: this.configService.get('JWT_REFRESH_EXPIRES_IN') || '7d', }; const refreshToken = this.jwtService.sign(payload as any, refreshOptions as any); - // 7. 로그인 응답 생성 (LoginResponseDto) + this.logger.log(`[LOGIN] 로그인 성공 - userId: ${userId}`); + return { message: '로그인 성공', - accessToken, // JWT 토큰 추가 - refreshToken, // JWT 토큰 추가 + accessToken, + refreshToken, user: { pkUserNo: user.pkUserNo, userId: user.userId, @@ -104,32 +99,27 @@ export class AuthService { /** * 회원가입 - * - * @async - * @param {SignupDto} signupDto - * @returns {Promise} */ async register(signupDto: SignupDto, clientIp: string): Promise { const { userId, userEmail, userPhone } = signupDto; + this.logger.log(`[REGISTER] 회원가입 시도 - userId: ${userId}, email: ${userEmail}`); - // 0. 이메일 인증 확인 (Redis에 인증 완료 여부 체크) const verifiedKey = `signup-verified:${userEmail}`; const isEmailVerified = await this.verificationService.verifyCode(verifiedKey, 'true'); if (!isEmailVerified) { + this.logger.warn(`[REGISTER] 이메일 미인증 - email: ${userEmail}`); throw new UnauthorizedException('이메일 인증이 완료되지 않았습니다'); } - // 1. 중복 체크 (ID, 이메일, 전화번호, 사업자번호) const whereConditions = [{ userId }, { userEmail }, { userPhone }]; - const existingUser = await this.userRepository.findOne({ where: whereConditions, }); if (existingUser) { if (existingUser.userId === userId) { - throw new ConflictException('이미 사용 중인 아이디입니다'); //HTTP 409 상태 코드 예외 + throw new ConflictException('이미 사용 중인 아이디입니다'); } if (existingUser.userEmail === userEmail) { throw new ConflictException('이미 사용 중인 이메일입니다'); @@ -137,28 +127,24 @@ export class AuthService { if (existingUser.userPhone === userPhone) { throw new ConflictException('이미 사용 중인 전화번호입니다'); } - } - // 2. 비밀번호 해싱 (bcrypt) const saltRounds = parseInt(this.configService.get('BCRYPT_SALT_ROUNDS') || '10', 10); const hashedPassword = await bcrypt.hash(signupDto.userPassword, saltRounds); - // 3. 사용자 생성 + const newUser = this.userRepository.create({ userId: signupDto.userId, userPw: hashedPassword, userName: signupDto.userName, userPhone: signupDto.userPhone, userEmail: signupDto.userEmail, - - regIp: clientIp, // 등록 ip + regIp: clientIp, regUserId: signupDto.userId, }); - // 4. DB에 저장 const savedUser = await this.userRepository.save(newUser); + this.logger.log(`[REGISTER] 회원가입 성공 - userId: ${savedUser.userId}`); - // 5. 응답 구조 생성 (SignupResponseDto 반환) return { message: '회원가입이 완료되었습니다', redirectUrl: '/dashboard', @@ -169,10 +155,6 @@ export class AuthService { /** * 이메일 중복 체크 - * - * @async - * @param {string} userEmail - * @returns {Promise<{ available: boolean; message: string }>} */ async checkEmailDuplicate(userEmail: string): Promise<{ available: boolean; @@ -197,10 +179,6 @@ export class AuthService { /** * 회원가입 - 이메일 인증번호 발송 - * - * @async - * @param {SendSignupCodeDto} dto - * @returns {Promise<{ success: boolean; message: string; expiresIn: number }>} */ async sendSignupCode(dto: SendSignupCodeDto): Promise<{ success: boolean; @@ -208,42 +186,60 @@ export class AuthService { expiresIn: number; }> { const { userEmail } = dto; + this.logger.log(`[SEND-CODE] ========== 인증번호 발송 시작 ==========`); + this.logger.log(`[SEND-CODE] 이메일: ${userEmail}`); + process.stdout.write(`[SEND-CODE] 이메일: ${userEmail}\n`); - // 1. 이메일 중복 체크 - const existingUser = await this.userRepository.findOne({ - where: { userEmail }, - }); + try { + // 1. 이메일 중복 체크 + this.logger.log(`[SEND-CODE] Step 1: 이메일 중복 체크`); + const existingUser = await this.userRepository.findOne({ + where: { userEmail }, + }); - if (existingUser) { - throw new ConflictException('이미 사용 중인 이메일입니다'); + if (existingUser) { + this.logger.warn(`[SEND-CODE] 이메일 중복 - ${userEmail}`); + throw new ConflictException('이미 사용 중인 이메일입니다'); + } + this.logger.log(`[SEND-CODE] Step 1 완료: 중복 없음`); + + // 2. 인증번호 생성 + this.logger.log(`[SEND-CODE] Step 2: 인증번호 생성`); + const code = this.verificationService.generateCode(); + this.logger.log(`[SEND-CODE] 생성된 인증번호: ${code}`); + process.stdout.write(`[SEND-CODE] 생성된 인증번호: ${code}\n`); + + // 3. Redis에 저장 + this.logger.log(`[SEND-CODE] Step 3: Redis 저장`); + const key = `signup:${userEmail}`; + await this.verificationService.saveCode(key, code); + this.logger.log(`[SEND-CODE] Redis 저장 완료 - key: ${key}`); + + // 4. 이메일 발송 + this.logger.log(`[SEND-CODE] Step 4: 이메일 발송 시작`); + await this.emailService.sendVerificationCode(userEmail, code); + this.logger.log(`[SEND-CODE] 이메일 발송 완료`); + + this.logger.log(`[SEND-CODE] ========== 인증번호 발송 성공 ==========`); + + return { + success: true, + message: '인증번호가 이메일로 발송되었습니다', + expiresIn: VERIFICATION_CONFIG.CODE_EXPIRY_SECONDS, + }; + } catch (error) { + this.logger.error(`[SEND-CODE] ========== 에러 발생 ==========`); + this.logger.error(`[SEND-CODE] Error Name: ${error.name}`); + this.logger.error(`[SEND-CODE] Error Message: ${error.message}`); + this.logger.error(`[SEND-CODE] Stack: ${error.stack}`); + process.stdout.write(`[SEND-CODE] ERROR: ${error.message}\n`); + process.stdout.write(`[SEND-CODE] STACK: ${error.stack}\n`); + throw error; } - - // 2. 인증번호 생성 - const code = this.verificationService.generateCode(); - console.log(`[DEBUG] Generated code for ${userEmail}: ${code}`); - - // 3. Redis에 저장 (key: signup:이메일) - const key = `signup:${userEmail}`; - await this.verificationService.saveCode(key, code); - console.log(`[DEBUG] Saved code to Redis with key: ${key}`); - - // 4. 이메일 발송 - await this.emailService.sendVerificationCode(userEmail, code); - console.log(`[DEBUG] Email sent to: ${userEmail}`); - - return { - success: true, - message: '인증번호가 이메일로 발송되었습니다', - expiresIn: VERIFICATION_CONFIG.CODE_EXPIRY_SECONDS, - }; } /** * 회원가입 - 이메일 인증번호 검증 - * - * @async - * @param {VerifySignupCodeDto} dto - * @returns {Promise<{ success: boolean; message: string; verified: boolean }>} */ async verifySignupCode(dto: VerifySignupCodeDto): Promise<{ success: boolean; @@ -251,21 +247,21 @@ export class AuthService { verified: boolean; }> { const { userEmail, code } = dto; - console.log(`[DEBUG] Verifying code for ${userEmail}: ${code}`); + this.logger.log(`[VERIFY-CODE] 인증번호 검증 - email: ${userEmail}`); - // Redis에서 검증 const key = `signup:${userEmail}`; const isValid = await this.verificationService.verifyCode(key, code); - console.log(`[DEBUG] Verification result: ${isValid}`); if (!isValid) { + this.logger.warn(`[VERIFY-CODE] 인증 실패 - email: ${userEmail}`); throw new UnauthorizedException('인증번호가 일치하지 않거나 만료되었습니다'); } - // 검증 완료 표시 (5분간 유효) const verifiedKey = `signup-verified:${userEmail}`; await this.verificationService.saveCode(verifiedKey, 'true'); + this.logger.log(`[VERIFY-CODE] 인증 성공 - email: ${userEmail}`); + return { success: true, message: '이메일 인증이 완료되었습니다', @@ -274,19 +270,14 @@ export class AuthService { } /** - * 아이디 찾기 - 인증번호 발송 (이메일 인증) - * - * @async - * @param {SendFindIdCodeDto} dto - * @returns {Promise<{ success: boolean; message: string; expiresIn: number }>} + * 아이디 찾기 - 인증번호 발송 */ async sendFindIdCode( dto: SendFindIdCodeDto, ): Promise<{ success: boolean; message: string; expiresIn: number }> { const { userName, userEmail } = dto; - console.log(`[아이디 찾기] 인증번호 발송 요청 - 이름: ${userName}, 이메일: ${userEmail}`); + this.logger.log(`[FIND-ID] 인증번호 발송 - name: ${userName}, email: ${userEmail}`); - // 1. 사용자 확인 const user = await this.userRepository.findOne({ where: { userName, userEmail }, }); @@ -295,18 +286,12 @@ export class AuthService { throw new NotFoundException('일치하는 사용자 정보를 찾을 수 없습니다'); } - // 2. 인증번호 생성 const code = this.verificationService.generateCode(); - console.log(`[아이디 찾기] 생성된 인증번호: ${code} (이메일: ${userEmail})`); - - // 3. Redis에 저장 (key: find-id:이메일) const key = `find-id:${userEmail}`; await this.verificationService.saveCode(key, code); - console.log(`[아이디 찾기] Redis 저장 완료 - Key: ${key}`); - - // 4. 이메일 발송 await this.emailService.sendVerificationCode(userEmail, code); - console.log(`[아이디 찾기] 이메일 발송 완료 - 수신자: ${userEmail}`); + + this.logger.log(`[FIND-ID] 인증번호 발송 완료 - email: ${userEmail}`); return { success: true, @@ -317,26 +302,18 @@ export class AuthService { /** * 아이디 찾기 - 인증번호 검증 - * - * @async - * @param {VerifyFindIdCodeDto} dto - * @returns {Promise} */ async verifyFindIdCode(dto: VerifyFindIdCodeDto): Promise { const { userEmail, verificationCode } = dto; - console.log(`[아이디 찾기] 인증번호 검증 요청 - 이메일: ${userEmail}, 입력 코드: ${verificationCode}`); + this.logger.log(`[FIND-ID] 인증번호 검증 - email: ${userEmail}`); - // 1. 인증번호 검증 const key = `find-id:${userEmail}`; - console.log(`[아이디 찾기] 검증 Key: ${key}`); const isValid = await this.verificationService.verifyCode(key, verificationCode); - console.log(`[아이디 찾기] 검증 결과: ${isValid}`); if (!isValid) { throw new UnauthorizedException('인증번호가 일치하지 않거나 만료되었습니다'); } - // 2. 사용자 정보 조회 const user = await this.userRepository.findOne({ where: { userEmail }, }); @@ -345,7 +322,6 @@ export class AuthService { throw new NotFoundException('사용자를 찾을 수 없습니다'); } - // 3. 아이디 마스킹 const maskedUserId = this.maskUserId(user.userId); return { @@ -355,13 +331,6 @@ export class AuthService { }; } - /** - * 아이디 마스킹 (앞 4자리만 표시) - * - * @private - * @param {string} userId - * @returns {string} - */ private maskUserId(userId: string): string { if (userId.length <= 4) { return userId; @@ -372,19 +341,14 @@ export class AuthService { } /** - * 비밀번호 재설정 - 인증번호 발송 (이메일 인증) - * - * @async - * @param {SendResetPasswordCodeDto} dto - * @returns {Promise<{ success: boolean; message: string; expiresIn: number }>} + * 비밀번호 재설정 - 인증번호 발송 */ async sendResetPasswordCode( dto: SendResetPasswordCodeDto, ): Promise<{ success: boolean; message: string; expiresIn: number }> { const { userId, userEmail } = dto; - console.log(`[비밀번호 찾기] 인증번호 발송 요청 - 아이디: ${userId}, 이메일: ${userEmail}`); + this.logger.log(`[RESET-PW] 인증번호 발송 - userId: ${userId}, email: ${userEmail}`); - // 1. 사용자 확인 const user = await this.userRepository.findOne({ where: { userId, userEmail }, }); @@ -393,18 +357,12 @@ export class AuthService { throw new NotFoundException('일치하는 사용자 정보를 찾을 수 없습니다'); } - // 2. 인증번호 생성 const code = this.verificationService.generateCode(); - console.log(`[비밀번호 찾기] 생성된 인증번호: ${code} (이메일: ${userEmail})`); - - // 3. Redis에 저장 (key: reset-pw:이메일) const key = `reset-pw:${userEmail}`; await this.verificationService.saveCode(key, code); - console.log(`[비밀번호 찾기] Redis 저장 완료 - Key: ${key}`); - - // 4. 이메일 발송 await this.emailService.sendVerificationCode(userEmail, code); - console.log(`[비밀번호 찾기] 이메일 발송 완료 - 수신자: ${userEmail}`); + + this.logger.log(`[RESET-PW] 인증번호 발송 완료 - email: ${userEmail}`); return { success: true, @@ -414,29 +372,21 @@ export class AuthService { } /** - * 비밀번호 재설정 - 인증번호 검증 및 재설정 토큰 발급 - * - * @async - * @param {VerifyResetPasswordCodeDto} dto - * @returns {Promise<{ success: boolean; message: string; resetToken: string }>} + * 비밀번호 재설정 - 인증번호 검증 */ async verifyResetPasswordCode( dto: VerifyResetPasswordCodeDto, ): Promise<{ success: boolean; message: string; resetToken: string }> { const { userId, userEmail, verificationCode } = dto; - console.log(`[비밀번호 찾기] 인증번호 검증 요청 - 아이디: ${userId}, 이메일: ${userEmail}, 입력 코드: ${verificationCode}`); + this.logger.log(`[RESET-PW] 인증번호 검증 - userId: ${userId}, email: ${userEmail}`); - // 1. 인증번호 검증 const key = `reset-pw:${userEmail}`; - console.log(`[비밀번호 찾기] 검증 Key: ${key}`); const isValid = await this.verificationService.verifyCode(key, verificationCode); - console.log(`[비밀번호 찾기] 검증 결과: ${isValid}`); if (!isValid) { throw new UnauthorizedException('인증번호가 일치하지 않거나 만료되었습니다'); } - // 2. 사용자 확인 const user = await this.userRepository.findOne({ where: { userId, userEmail }, }); @@ -445,7 +395,6 @@ export class AuthService { throw new NotFoundException('사용자를 찾을 수 없습니다'); } - // 3. 비밀번호 재설정 토큰 생성 및 저장 (30분 유효) const resetToken = await this.verificationService.generateResetToken(userId); return { @@ -457,22 +406,17 @@ export class AuthService { /** * 비밀번호 재설정 - 새 비밀번호로 변경 - * - * @async - * @param {ResetPasswordDto} dto - * @returns {Promise} */ async resetPassword(dto: ResetPasswordDto): Promise { - const { resetToken, newPassword } = dto; // 요청 + const { resetToken, newPassword } = dto; + this.logger.log(`[RESET-PW] 비밀번호 변경 시도`); - // 1. 재설정 토큰 검증 const userId = await this.verificationService.verifyResetToken(resetToken); if (!userId) { throw new UnauthorizedException('유효하지 않거나 만료된 토큰입니다'); } - // 2. 사용자 조회 const user = await this.userRepository.findOne({ where: { userId }, }); @@ -481,14 +425,14 @@ export class AuthService { throw new NotFoundException('사용자를 찾을 수 없습니다'); } - // 3. 새 비밀번호 해싱 const saltRounds = parseInt(this.configService.get('BCRYPT_SALT_ROUNDS') || '10', 10); const hashedPassword = await bcrypt.hash(newPassword, saltRounds); - // 4. 비밀번호 업데이트 user.userPw = hashedPassword; await this.userRepository.save(user); + this.logger.log(`[RESET-PW] 비밀번호 변경 완료 - userId: ${userId}`); + return { message: '비밀번호가 변경되었습니다', }; diff --git a/backend/src/common/filters/all-exceptions.filter.ts b/backend/src/common/filters/all-exceptions.filter.ts index d861809..a451aee 100644 --- a/backend/src/common/filters/all-exceptions.filter.ts +++ b/backend/src/common/filters/all-exceptions.filter.ts @@ -9,11 +9,7 @@ import { import { Request, Response } from 'express'; /** - * 모든 예외 필터 - * - * @description - * HTTP 예외뿐만 아니라 모든 예외를 잡아서 처리합니다. - * 예상치 못한 에러도 일관된 형식으로 응답합니다. + * 모든 예외 필터 - Docker 환경에서 로그 출력 보장 */ @Catch() export class AllExceptionsFilter implements ExceptionFilter { @@ -40,11 +36,7 @@ export class AllExceptionsFilter implements ExceptionFilter { } } else { status = HttpStatus.INTERNAL_SERVER_ERROR; - message = '서버 내부 오류가 발생했습니다.'; - - if (exception instanceof Error) { - message = exception.message; - } + message = exception instanceof Error ? exception.message : '서버 내부 오류가 발생했습니다.'; } const errorResponse = { @@ -61,32 +53,41 @@ export class AllExceptionsFilter implements ExceptionFilter { (errorResponse as any).stack = exception.stack; } - // ========== 상세 로깅 ========== - this.logger.error( - `========== EXCEPTION ==========` - ); - this.logger.error( - `[${request.method}] ${request.url} - Status: ${status}` - ); - this.logger.error( - `Message: ${JSON.stringify(message)}` - ); - + // ========== 상세 로깅 (stdout으로 즉시 출력) ========== + const logMessage = [ + '', + '╔══════════════════════════════════════════════════════════════╗', + '║ EXCEPTION OCCURRED ║', + '╚══════════════════════════════════════════════════════════════╝', + ` Timestamp : ${errorResponse.timestamp}`, + ` Method : ${request.method}`, + ` Path : ${request.url}`, + ` Status : ${status}`, + ` Message : ${JSON.stringify(message)}`, + ]; + if (exception instanceof Error) { - this.logger.error(`Error Name: ${exception.name}`); - this.logger.error(`Error Message: ${exception.message}`); - this.logger.error(`Stack Trace:\n${exception.stack}`); - } else { - this.logger.error(`Exception: ${JSON.stringify(exception)}`); + logMessage.push(` Error Name: ${exception.name}`); + logMessage.push(` Stack :`); + logMessage.push(exception.stack || 'No stack trace'); } // Request Body 로깅 (민감정보 마스킹) const safeBody = { ...request.body }; if (safeBody.password) safeBody.password = '****'; if (safeBody.token) safeBody.token = '****'; - this.logger.error(`Request Body: ${JSON.stringify(safeBody)}`); - - this.logger.error(`===============================`); + logMessage.push(` Body : ${JSON.stringify(safeBody)}`); + logMessage.push('══════════════════════════════════════════════════════════════════'); + logMessage.push(''); + + // NestJS Logger 사용 (Docker stdout으로 출력) + this.logger.error(logMessage.join('\n')); + + // 추가로 console.error도 출력 (백업) + console.error(logMessage.join('\n')); + + // process.stdout으로 직접 출력 (버퍼링 우회) + process.stdout.write(logMessage.join('\n') + '\n'); response.status(status).json(errorResponse); } diff --git a/backend/src/common/interceptors/logging.interceptor.ts b/backend/src/common/interceptors/logging.interceptor.ts index d5dae89..7673b8d 100644 --- a/backend/src/common/interceptors/logging.interceptor.ts +++ b/backend/src/common/interceptors/logging.interceptor.ts @@ -3,50 +3,42 @@ import { NestInterceptor, ExecutionContext, CallHandler, + Logger, } from '@nestjs/common'; import { Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; import { Request } from 'express'; /** - * 로깅 인터셉터 - * - * @description - * API 요청/응답을 로깅하고 실행 시간을 측정합니다. - * - * @example - * // main.ts에서 전역 적용 - * app.useGlobalInterceptors(new LoggingInterceptor()); - * - * @export - * @class LoggingInterceptor - * @implements {NestInterceptor} + * 로깅 인터셉터 - Docker 환경에서 로그 출력 보장 */ @Injectable() export class LoggingInterceptor implements NestInterceptor { + private readonly logger = new Logger('HTTP'); + intercept(context: ExecutionContext, next: CallHandler): Observable { const request = context.switchToHttp().getRequest(); const { method, url, ip } = request; const userAgent = request.get('user-agent') || ''; const now = Date.now(); - console.log( - `[${new Date().toISOString()}] Incoming Request: ${method} ${url} - ${ip} - ${userAgent}`, - ); + const incomingLog = `[REQUEST] ${method} ${url} - IP: ${ip}`; + this.logger.log(incomingLog); + process.stdout.write(`${new Date().toISOString()} - ${incomingLog}\n`); return next.handle().pipe( tap({ - next: (data) => { + next: () => { const responseTime = Date.now() - now; - console.log( - `[${new Date().toISOString()}] Response: ${method} ${url} - ${responseTime}ms`, - ); + const successLog = `[RESPONSE] ${method} ${url} - ${responseTime}ms - SUCCESS`; + this.logger.log(successLog); + process.stdout.write(`${new Date().toISOString()} - ${successLog}\n`); }, error: (error) => { const responseTime = Date.now() - now; - console.error( - `[${new Date().toISOString()}] Error Response: ${method} ${url} - ${responseTime}ms - ${error.message}`, - ); + const errorLog = `[RESPONSE] ${method} ${url} - ${responseTime}ms - ERROR: ${error.message}`; + this.logger.error(errorLog); + process.stdout.write(`${new Date().toISOString()} - ${errorLog}\n`); }, }), ); diff --git a/backend/src/main.ts b/backend/src/main.ts index f8f6a50..dcbd817 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,64 +1,117 @@ import { NestFactory, Reflector } from '@nestjs/core'; import { AppModule } from './app.module'; -import { ValidationPipe } from '@nestjs/common'; +import { ValidationPipe, Logger } from '@nestjs/common'; import { JwtAuthGuard } from './common/guards/jwt-auth.guard'; import { AllExceptionsFilter } from './common/filters/all-exceptions.filter'; import { LoggingInterceptor } from './common/interceptors/logging.interceptor'; import { TransformInterceptor } from './common/interceptors/transform.interceptor'; import { types } from 'pg'; +// ========== 전역 에러 핸들러 (NestJS 도달 전 에러 캐치) ========== +process.on('uncaughtException', (error) => { + console.error('╔══════════════════════════════════════════════════════════════╗'); + console.error('║ UNCAUGHT EXCEPTION ║'); + console.error('╚══════════════════════════════════════════════════════════════╝'); + console.error(`Timestamp: ${new Date().toISOString()}`); + console.error(`Error: ${error.message}`); + console.error(`Stack: ${error.stack}`); + process.stdout.write(`[FATAL] Uncaught Exception: ${error.message}\n`); + process.stdout.write(`[FATAL] Stack: ${error.stack}\n`); +}); + +process.on('unhandledRejection', (reason, promise) => { + console.error('╔══════════════════════════════════════════════════════════════╗'); + console.error('║ UNHANDLED REJECTION ║'); + console.error('╚══════════════════════════════════════════════════════════════╝'); + console.error(`Timestamp: ${new Date().toISOString()}`); + console.error(`Reason: ${reason}`); + process.stdout.write(`[FATAL] Unhandled Rejection: ${reason}\n`); +}); + // PostgreSQL numeric/decimal 타입을 JavaScript number로 자동 변환 -// 1700 = numeric type OID types.setTypeParser(1700, parseFloat); async function bootstrap() { - const app = await NestFactory.create(AppModule); + const logger = new Logger('Bootstrap'); + + logger.log('========================================'); + logger.log('Application starting...'); + logger.log(`NODE_ENV: ${process.env.NODE_ENV}`); + logger.log(`REDIS_URL: ${process.env.REDIS_URL}`); + logger.log(`POSTGRES_HOST: ${process.env.POSTGRES_HOST}`); + logger.log('========================================'); + process.stdout.write(`[BOOTSTRAP] Starting application...\n`); - // CORS 추가 - app.enableCors({ - origin: (origin, callback) => { - // origin이 없는 경우 (서버 간 요청, Postman 등) 허용 - if (!origin) return callback(null, true); - - const allowedPatterns = [ - /^http:\/\/localhost:\d+$/, // localhost 모든 포트 - /^http:\/\/127\.0\.0\.1:\d+$/, // 127.0.0.1 모든 포트 - /^http:\/\/192\.168\.11\.\d+:\d+$/, // 192.168.11.* 대역 - /^https?:\/\/.*\.turbosoft\.kr$/, // *.turbosoft.kr - ]; - - const isAllowed = allowedPatterns.some(pattern => pattern.test(origin)); - callback(null, isAllowed); - }, - credentials: true, - }); + try { + // 로거 활성화 (Docker에서 모든 로그 출력) + const app = await NestFactory.create(AppModule, { + logger: ['error', 'warn', 'log', 'debug', 'verbose'], + bufferLogs: false, + }); - // ValidationPipe 추가 (Body 파싱과 Dto 유효성 global 설정) - app.useGlobalPipes( - new ValidationPipe({ - transform: true, - whitelist: false, // nested object를 위해 whitelist 비활성화 - transformOptions: { - enableImplicitConversion: true, + logger.log('NestFactory.create() 완료'); + + // CORS 추가 + app.enableCors({ + origin: (origin, callback) => { + if (!origin) return callback(null, true); + + const allowedPatterns = [ + /^http:\/\/localhost:\d+$/, + /^http:\/\/127\.0\.0\.1:\d+$/, + /^http:\/\/192\.168\.11\.\d+:\d+$/, + /^https?:\/\/.*\.turbosoft\.kr$/, + ]; + + const isAllowed = allowedPatterns.some(pattern => pattern.test(origin)); + callback(null, isAllowed); }, - }), - ); + credentials: true, + }); - // 전역 필터 적용 - 모든 예외를 잡아서 일관된 형식으로 응답 - app.useGlobalFilters(new AllExceptionsFilter()); + // ValidationPipe 추가 + app.useGlobalPipes( + new ValidationPipe({ + transform: true, + whitelist: false, + transformOptions: { + enableImplicitConversion: true, + }, + }), + ); - // 전역 인터셉터 적용 - app.useGlobalInterceptors( - new LoggingInterceptor(), // 요청/응답 로깅 - new TransformInterceptor(), // 일관된 응답 변환 (success, data, timestamp) - //backend\src\common\interceptors\transform.interceptor.ts 구현체 - ); + // 전역 필터 적용 + app.useGlobalFilters(new AllExceptionsFilter()); - // 전역 JWT 인증 가드 적용 (@Public 데코레이터가 있는 엔드포인트는 제외) - const reflector = app.get(Reflector); - app.useGlobalGuards(new JwtAuthGuard(reflector)); + // 전역 인터셉터 적용 + app.useGlobalInterceptors( + new LoggingInterceptor(), + new TransformInterceptor(), + ); - await app.listen(process.env.PORT ?? 4000); // 로컬 개발환경 - //await app.listen(process.env.PORT ?? 4000, '0.0.0.0'); // 모든 네트워크 외부 바인딩 + // 전역 JWT 인증 가드 적용 + const reflector = app.get(Reflector); + app.useGlobalGuards(new JwtAuthGuard(reflector)); + + const port = process.env.PORT ?? 4000; + await app.listen(port, '0.0.0.0'); + + logger.log('========================================'); + logger.log(`Application running on port ${port}`); + logger.log(`Environment: ${process.env.NODE_ENV || 'development'}`); + logger.log('========================================'); + process.stdout.write(`[BOOTSTRAP] Server listening on port ${port}\n`); + + } catch (error) { + logger.error('========================================'); + logger.error('Application failed to start!'); + logger.error(`Error: ${error.message}`); + logger.error(`Stack: ${error.stack}`); + logger.error('========================================'); + process.stdout.write(`[BOOTSTRAP] STARTUP FAILED: ${error.message}\n`); + process.stdout.write(`[BOOTSTRAP] STACK: ${error.stack}\n`); + process.exit(1); + } } -bootstrap(); + +bootstrap(); diff --git a/backend/src/shared/email/email.service.ts b/backend/src/shared/email/email.service.ts index 1404b78..1bc0557 100644 --- a/backend/src/shared/email/email.service.ts +++ b/backend/src/shared/email/email.service.ts @@ -1,55 +1,88 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as nodemailer from 'nodemailer'; /** * 이메일 발송 서비스 - * - * @export - * @class EmailService */ @Injectable() export class EmailService { - private transporter; // nodemailer 전송 객체 + private readonly logger = new Logger(EmailService.name); + private transporter; constructor(private configService: ConfigService) { - // SMTP 서버 설정 (AWS SES, Gmail 등) - this.transporter = nodemailer.createTransport({ // .env 파일 EMAIL CONFIGURATION - host: this.configService.get('SMTP_HOST'), - port: parseInt(this.configService.get('SMTP_PORT')), - secure: this.configService.get('SMTP_PORT') === '465', + const smtpHost = this.configService.get('SMTP_HOST'); + const smtpPort = this.configService.get('SMTP_PORT'); + const smtpUser = this.configService.get('SMTP_USER'); + const smtpPass = this.configService.get('SMTP_PASS'); + + this.logger.log(`[EMAIL] SMTP 설정 초기화`); + this.logger.log(`[EMAIL] Host: ${smtpHost}`); + this.logger.log(`[EMAIL] Port: ${smtpPort}`); + this.logger.log(`[EMAIL] User: ${smtpUser}`); + this.logger.log(`[EMAIL] Pass: ${smtpPass ? '****설정됨' : '미설정!!'}`); + process.stdout.write(`[EMAIL] SMTP Config - Host: ${smtpHost}, Port: ${smtpPort}, User: ${smtpUser}\n`); + + if (!smtpHost || !smtpPort || !smtpUser || !smtpPass) { + this.logger.error(`[EMAIL] SMTP 설정 누락! 환경변수를 확인하세요.`); + process.stdout.write(`[EMAIL] ERROR: SMTP 설정 누락!\n`); + } + + this.transporter = nodemailer.createTransport({ + host: smtpHost, + port: parseInt(smtpPort || '587'), + secure: smtpPort === '465', auth: { - user: this.configService.get('SMTP_USER'), - pass: this.configService.get('SMTP_PASS'), + user: smtpUser, + pass: smtpPass, }, }); } /** * 인증번호 이메일 발송 - * - * @async - * @param {string} email - 수신자 이메일 - * @param {string} code - 6자리 인증번호 - * @returns {Promise} */ async sendVerificationCode(email: string, code: string): Promise { - await this.transporter.sendMail({ - from: this.configService.get('FROM_EMAIL'), - to: email, - subject: '[한우 유전능력 시스템] 인증번호 안내', - html: ` -
-

인증번호 안내

-

아래 인증번호를 입력해주세요.

-
-

${code}

+ const fromEmail = this.configService.get('FROM_EMAIL'); + + this.logger.log(`[EMAIL] ========== 이메일 발송 시작 ==========`); + this.logger.log(`[EMAIL] From: ${fromEmail}`); + this.logger.log(`[EMAIL] To: ${email}`); + this.logger.log(`[EMAIL] Code: ${code}`); + process.stdout.write(`[EMAIL] Sending to: ${email}, code: ${code}\n`); + + try { + const result = await this.transporter.sendMail({ + from: fromEmail, + to: email, + subject: '[한우 유전능력 시스템] 인증번호 안내', + html: ` +
+

인증번호 안내

+

아래 인증번호를 입력해주세요.

+
+

${code}

+
+

인증번호는 3분간 유효합니다.

+
+

본 메일은 발신 전용입니다.

-

인증번호는 3분간 유효합니다.

-
-

본 메일은 발신 전용입니다.

-
- `, - }); + `, + }); + + this.logger.log(`[EMAIL] 발송 성공 - MessageId: ${result.messageId}`); + this.logger.log(`[EMAIL] Response: ${JSON.stringify(result)}`); + process.stdout.write(`[EMAIL] SUCCESS - MessageId: ${result.messageId}\n`); + + } catch (error) { + this.logger.error(`[EMAIL] ========== 발송 실패 ==========`); + this.logger.error(`[EMAIL] Error Name: ${error.name}`); + this.logger.error(`[EMAIL] Error Message: ${error.message}`); + this.logger.error(`[EMAIL] Error Code: ${error.code}`); + this.logger.error(`[EMAIL] Stack: ${error.stack}`); + process.stdout.write(`[EMAIL] ERROR: ${error.message}\n`); + process.stdout.write(`[EMAIL] STACK: ${error.stack}\n`); + throw error; + } } } diff --git a/backend/src/shared/verification/verification.service.ts b/backend/src/shared/verification/verification.service.ts index 7adf895..e7dff22 100644 --- a/backend/src/shared/verification/verification.service.ts +++ b/backend/src/shared/verification/verification.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { InjectRedis } from '@nestjs-modules/ioredis'; import Redis from 'ioredis'; import * as crypto from 'crypto'; @@ -6,92 +6,138 @@ import { VERIFICATION_CONFIG } from 'src/common/config/VerificationConfig'; /** * 인증번호 생성 및 검증 서비스 (Redis 기반) - * - * @export - * @class VerificationService */ @Injectable() export class VerificationService { - constructor(@InjectRedis() private readonly redis: Redis) {} + private readonly logger = new Logger(VerificationService.name); - /** - * 6자리 인증번호 생성 - * - * @returns {string} 6자리 숫자 - */ - generateCode(): string { - return Math.floor(100000 + Math.random() * 900000).toString(); - } + constructor(@InjectRedis() private readonly redis: Redis) { + this.logger.log(`[REDIS] VerificationService 초기화`); + process.stdout.write(`[REDIS] VerificationService initialized\n`); + + // Redis 연결 상태 로깅 + this.checkRedisConnection(); + } - /** - * 인증번호 저장 (Redis 3분 후 자동 삭제) - * - * @async - * @param {string} key - Redis 키 (예: find-id:test@example.com) - * @param {string} code - 인증번호 - * @returns {Promise} - */ - async saveCode(key: string, code: string): Promise { - await this.redis.set(key, code, 'EX', VERIFICATION_CONFIG.CODE_EXPIRY_SECONDS); - } + private async checkRedisConnection(): Promise { + try { + const pong = await this.redis.ping(); + this.logger.log(`[REDIS] 연결 상태: ${pong}`); + process.stdout.write(`[REDIS] Connection status: ${pong}\n`); + } catch (error) { + this.logger.error(`[REDIS] 연결 실패: ${error.message}`); + process.stdout.write(`[REDIS] Connection FAILED: ${error.message}\n`); + } + } - /** - * 인증번호 검증 - * - * @async - * @param {string} key - Redis 키 - * @param {string} code - 사용자가 입력한 인증번호 - * @returns {Promise} 검증 성공 여부 - */ - async verifyCode(key: string, code: string): Promise { - const savedCode = await this.redis.get(key); - console.log(`[DEBUG VerificationService] Key: ${key}, Input code: ${code}, Saved code: ${savedCode}`); + /** + * 6자리 인증번호 생성 + */ + generateCode(): string { + const code = Math.floor(100000 + Math.random() * 900000).toString(); + this.logger.log(`[REDIS] 인증번호 생성: ${code}`); + return code; + } - if (!savedCode) { - console.log(`[DEBUG VerificationService] No saved code found for key: ${key}`); - return false; // 인증번호 없음 (만료 또는 미발급) - } + /** + * 인증번호 저장 (Redis) + */ + async saveCode(key: string, code: string): Promise { + this.logger.log(`[REDIS] ========== 코드 저장 시작 ==========`); + this.logger.log(`[REDIS] Key: ${key}`); + this.logger.log(`[REDIS] Code: ${code}`); + this.logger.log(`[REDIS] TTL: ${VERIFICATION_CONFIG.CODE_EXPIRY_SECONDS}초`); + process.stdout.write(`[REDIS] Saving - Key: ${key}, Code: ${code}\n`); - if (savedCode !== code) { - console.log(`[DEBUG VerificationService] Code mismatch - Saved: ${savedCode}, Input: ${code}`); - return false; // 인증번호 불일치 - } + try { + const result = await this.redis.set(key, code, 'EX', VERIFICATION_CONFIG.CODE_EXPIRY_SECONDS); + this.logger.log(`[REDIS] 저장 결과: ${result}`); + process.stdout.write(`[REDIS] Save result: ${result}\n`); + } catch (error) { + this.logger.error(`[REDIS] ========== 저장 실패 ==========`); + this.logger.error(`[REDIS] Error: ${error.message}`); + this.logger.error(`[REDIS] Stack: ${error.stack}`); + process.stdout.write(`[REDIS] SAVE ERROR: ${error.message}\n`); + process.stdout.write(`[REDIS] STACK: ${error.stack}\n`); + throw error; + } + } - // 검증 성공 시 Redis에서 삭제 (1회용) - await this.redis.del(key); - console.log(`[DEBUG VerificationService] Code verified successfully!`); - return true; - } + /** + * 인증번호 검증 + */ + async verifyCode(key: string, code: string): Promise { + this.logger.log(`[REDIS] ========== 코드 검증 시작 ==========`); + this.logger.log(`[REDIS] Key: ${key}`); + this.logger.log(`[REDIS] Input Code: ${code}`); + process.stdout.write(`[REDIS] Verifying - Key: ${key}, Input: ${code}\n`); - /** - * 비밀번호 재설정 토큰 생성 및 저장 - * - * @async - * @param {string} userId - 사용자 ID - * @returns {Promise} 재설정 토큰 - */ - async generateResetToken(userId: string): Promise { - const token = crypto.randomBytes(VERIFICATION_CONFIG.TOKEN_BYTES_LENGTH).toString('hex'); - await this.redis.set(`reset:${token}`, userId, 'EX', VERIFICATION_CONFIG.RESET_TOKEN_EXPIRY_SECONDS); - return token; - } + try { + const savedCode = await this.redis.get(key); + this.logger.log(`[REDIS] Saved Code: ${savedCode}`); + process.stdout.write(`[REDIS] Saved code: ${savedCode}\n`); - /** - * 비밀번호 재설정 토큰 검증 - * - * @async - * @param {string} token - 재설정 토큰 - * @returns {Promise} 사용자 ID 또는 null - */ - async verifyResetToken(token: string): Promise { - const userId = await this.redis.get(`reset:${token}`); + if (!savedCode) { + this.logger.warn(`[REDIS] 저장된 코드 없음 (만료 또는 미발급)`); + return false; + } - if (!userId) { - return null; // 토큰 없음 (만료 또는 미발급) - } + if (savedCode !== code) { + this.logger.warn(`[REDIS] 코드 불일치 - Saved: ${savedCode}, Input: ${code}`); + return false; + } - // 검증 성공 시 토큰 삭제 (1회용) - await this.redis.del(`reset:${token}`); - return userId; - } + await this.redis.del(key); + this.logger.log(`[REDIS] 검증 성공, 코드 삭제됨`); + return true; + + } catch (error) { + this.logger.error(`[REDIS] ========== 검증 실패 ==========`); + this.logger.error(`[REDIS] Error: ${error.message}`); + this.logger.error(`[REDIS] Stack: ${error.stack}`); + process.stdout.write(`[REDIS] VERIFY ERROR: ${error.message}\n`); + throw error; + } + } + + /** + * 비밀번호 재설정 토큰 생성 및 저장 + */ + async generateResetToken(userId: string): Promise { + this.logger.log(`[REDIS] 리셋 토큰 생성 - userId: ${userId}`); + + try { + const token = crypto.randomBytes(VERIFICATION_CONFIG.TOKEN_BYTES_LENGTH).toString('hex'); + await this.redis.set(`reset:${token}`, userId, 'EX', VERIFICATION_CONFIG.RESET_TOKEN_EXPIRY_SECONDS); + this.logger.log(`[REDIS] 리셋 토큰 저장 완료`); + return token; + } catch (error) { + this.logger.error(`[REDIS] 리셋 토큰 생성 실패: ${error.message}`); + throw error; + } + } + + /** + * 비밀번호 재설정 토큰 검증 + */ + async verifyResetToken(token: string): Promise { + this.logger.log(`[REDIS] 리셋 토큰 검증`); + + try { + const userId = await this.redis.get(`reset:${token}`); + + if (!userId) { + this.logger.warn(`[REDIS] 리셋 토큰 없음 또는 만료`); + return null; + } + + await this.redis.del(`reset:${token}`); + this.logger.log(`[REDIS] 리셋 토큰 검증 성공 - userId: ${userId}`); + return userId; + + } catch (error) { + this.logger.error(`[REDIS] 리셋 토큰 검증 실패: ${error.message}`); + throw error; + } + } }