본문 바로가기
NestJS

[NestJS] winston 로그 구현하기

by Programmer.Junny 2025. 6. 5.

1. Log란

로그(Log)는 애플리케이션이 실행되는 동안 발생하는 이벤트와 상태 변화를 시간순으로 기록한 데이터이다.

1.1. 로그가 필요한 이유

문제 해결 (Debugging)

❌ 로그 없는 상황

// 사용자가 "로그인이 안 돼요!" 라고 문의
async login(email: string, password: string) {
  const user = await this.findUser(email);
  const isValid = await this.validatePassword(password, user.password);
  if (isValid) {
    return this.generateToken(user);
  }
  throw new Error('로그인 실패');
}

개발자: "어디서 실패했는지 모르겠네요... 🤷‍♂️"

✅ 로그 있는 상황

async login(email: string, password: string) {
  this.logger.info('로그인 시도', { email: this.maskEmail(email) });
  
  const user = await this.findUser(email);
  if (!user) {
    this.logger.warn('존재하지 않는 사용자', { email: this.maskEmail(email) });
    throw new Error('사용자를 찾을 수 없습니다');
  }
  
  this.logger.debug('사용자 찾음', { userId: user.id });
  
  const isValid = await this.validatePassword(password, user.password);
  if (!isValid) {
    this.logger.warn('비밀번호 불일치', { userId: user.id });
    throw new Error('비밀번호가 일치하지 않습니다');
  }
  
  this.logger.info('로그인 성공', { userId: user.id });
  return this.generateToken(user);
}

로그 확인 결과:

2024-01-15 14:30:25 [INFO] 로그인 시도 { email: "u***@example.com" }
2024-01-15 14:30:26 [DEBUG] 사용자 찾음 { userId: 123 }
2024-01-15 14:30:27 [WARN] 비밀번호 불일치 { userId: 123 }

개발자: "아! 비밀번호가 틀렸네요. 사용자에게 안내드리겠습니다! ✅"

1.2. 실시간 시스템 상태 파악

// 포스트 생성 API
async createPost(dto: CreatePostDto, userId: number) {
  const startTime = Date.now();
  
  this.logger.info('포스트 생성 시작', { 
    userId, 
    titleLength: dto.title.length,
    contentLength: dto.content.length 
  });
  
  try {
    const post = await this.savePost(dto, userId);
    const duration = Date.now() - startTime;
    
    this.logger.info('포스트 생성 성공', { 
      postId: post.id, 
      userId, 
      duration: `${duration}ms` 
    });
    
    return post;
  } catch (error) {
    this.logger.error('포스트 생성 실패', { 
      userId, 
      error: error.message,
      duration: `${Date.now() - startTime}ms` 
    });
    throw error;
  }
}

로그 분석으로 알 수 있는 것들:

📊 성능: 포스트 생성이 평균 몇 ms 걸리는지
📈 사용량: 시간대별 포스트 생성 횟수
🚨 오류율: 성공/실패 비율
👥 사용자 패턴: 어떤 사용자가 많이 사용하는지

2. 패키지 설치

# 로그 관련 패키지 설치
npm install winston nest-winston
npm install -D @types/winston

# 추가 유틸리티 (선택사항)
npm install winston-daily-rotate-file  # 로그 파일 회전

3. winston.config.ts 작성

import { utilities as nestWinstonModuleUtilities } from 'nest-winston';
import * as winston from 'winston';
import 'winston-daily-rotate-file';

const logDir = 'logs';

export const winstonConfig = {
  transports: [
    // 콘솔 출력
    new winston.transports.Console({
      level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
      format: winston.format.combine(
        winston.format.timestamp(),
        winston.format.colorize(),
        nestWinstonModuleUtilities.format.nestLike('SNS_APP', {
          prettyPrint: true,
        }),
      ),
    }),
    
    // 에러 로그 파일
    new winston.transports.DailyRotateFile({
      level: 'error',
      dirname: logDir,
      filename: 'error-%DATE%.log',
      datePattern: 'YYYY-MM-DD',
      zippedArchive: true,
      maxSize: '20m',
      maxFiles: '14d',
      format: winston.format.combine(
        winston.format.timestamp(),
        winston.format.json(),
      ),
    }),
    
    // 일반 로그 파일
    new winston.transports.DailyRotateFile({
      level: 'info',
      dirname: logDir,
      filename: 'application-%DATE%.log',
      datePattern: 'YYYY-MM-DD',
      zippedArchive: true,
      maxSize: '20m',
      maxFiles: '14d',
      format: winston.format.combine(
        winston.format.timestamp(),
        winston.format.json(),
      ),
    }),
  ],
};

4. app.module.ts 에 등록

@Module({
  imports: [
    WinstonModule.forRoot(winstonConfig),

5. main.ts에 로거 적용

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import expressBasicAuth from 'express-basic-auth';
import { ConfigService } from '@nestjs/config';
import { AuthScheme } from './common/const/auth-schema.const';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { HttpExceptionFilter } from './common/exception-filter/http.exception-filter';
import { Logger } from 'winston';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  const logger: Logger = app.get(WINSTON_MODULE_PROVIDER);
  const configService = app.get(ConfigService);
  const swaggerUser = configService.get<string>('app.swagger.user');
  const swaggerPassword = configService.get<string>('app.swagger.password');
  if (!swaggerUser || !swaggerPassword) {
    throw new Error('Swagger credentials are not defined');
  }

  app.use(
    '/api',
    expressBasicAuth({
      challenge: true,
      users: {
        [swaggerUser]: swaggerPassword, // 지정된 ID/비밀번호
      },
    }),
  );

  app.useGlobalFilters(new HttpExceptionFilter(logger));

  app.useGlobalPipes(new ValidationPipe({
    transform: true,
    transformOptions: {
      enableImplicitConversion: true,
    },
    whitelist: true,
    forbidNonWhitelisted: true,
  }));

  const config = new DocumentBuilder()
    .setTitle('NestJS SNS 프로젝트')
    .setDescription('NestJS SNS API description')
    .setVersion('0.0.1')
    // "access" 이름의 AccessToken 스키마
  .addBearerAuth(
    {
      type: 'http',
      scheme: 'bearer',
      bearerFormat: 'JWT',
    },
    AuthScheme.ACCESS,
  )
  // "refresh" 이름의 RefreshToken 스키마
  .addBearerAuth(
    {
      type: 'http',
      scheme: 'bearer',
      bearerFormat: 'JWT',
    },
    AuthScheme.REFRESH,
  )
    .build();
  const documentFactory = () => SwaggerModule.createDocument(app, config);

  SwaggerModule.setup('api', app, documentFactory, {
  	swaggerOptions: {
      persistAuthorization: true, // Swagger에서 저장된 Bearer Token이 날아가지 않게 해줌(편의성)
    }
  });

  await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

6. 기존 http exception filter 수정

import { ExceptionFilter, Catch, ArgumentsHost, HttpException, Inject } from '@nestjs/common';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { Logger } from 'winston';

// 요청 본문 타입 정의
interface RequestBody {
  [key: string]: unknown;
}

// 민감한 정보가 제거된 본문 타입
interface SanitizedBody {
  [key: string]: unknown;
}

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
    constructor(
        @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
    ) {}

    catch(exception: HttpException, host: ArgumentsHost) {
        const ctx = host.switchToHttp();
        const response = ctx.getResponse();
        const request = ctx.getRequest();
        const status = exception.getStatus();

        const res = exception.getResponse();
        const error = typeof res === 'object' && res !== null && 'error' in res 
            ? (res as { error: string }).error 
            : exception.message;
        const message = typeof res === 'object' && res !== null && 'message' in res 
            ? (res as { message: string | string[] }).message 
            : exception.message;

        // 상세 로그 정보
        const logData = {
            statusCode: status,
            error,
            message,
            timestamp: new Date().toISOString(),
            path: request.url,
            method: request.method,
            userId: request.user?.id || 'anonymous',
            ip: request.ip,
            userAgent: request.headers?.['user-agent'] || '',
            body: this.sanitizeRequestBody(request.body as RequestBody),
        };

        // 로그 레벨 분리
        if (status >= 500) {
            this.logger.error('HTTP Server Error', logData);
        } else if (status >= 400) {
            this.logger.warn('HTTP Client Error', logData);
        }

        // 클라이언트 응답
        response.status(status).json({
            statusCode: status,
            error,
            message,
            timeStamp: new Date().toLocaleString('kr'),
            path: request.url,
        });
    }

    /**
     * 요청 본문에서 민감한 정보를 제거합니다
     * @param body 요청 본문
     * @returns 민감한 정보가 제거된 본문
     */
    private sanitizeRequestBody(body: RequestBody | null | undefined): SanitizedBody | null {
        if (!body || typeof body !== 'object') {
            return body as null;
        }
        
        const sensitiveFields = ['password', 'token', 'secret', 'accessToken', 'refreshToken'];
        const sanitized: SanitizedBody = { ...body };
        
        sensitiveFields.forEach(field => {
            if (field in sanitized) {
                sanitized[field] = '[REDACTED]';
            }
        });
        
        return sanitized;
    }
}

7. 로그 적용하기

기존 AuthService 로직: 

더보기
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { UsersModel } from 'src/users/entity/users.entity';
import { UsersService } from 'src/users/users.service';
import * as bcrypt from 'bcrypt';
import { RegisterUserDto } from './dto/register-user.dto';
import { ConfigType } from '@nestjs/config';
import appConfig from 'src/configs/app.config';
import { LoginDto } from './dto/login.dto';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { Logger } from 'winston';

/**
 * 인증 관련 비즈니스 로직을 처리하는 서비스
 * 사용자 인증, 토큰 생성 및 검증 기능을 제공합니다
 */
@Injectable()
export class AuthService {
    constructor(
        private readonly jwtService: JwtService,
        private readonly usersService: UsersService,
        @Inject(appConfig.KEY)
        private readonly config: ConfigType<typeof appConfig>,
        @Inject(WINSTON_MODULE_PROVIDER) 
        private readonly logger: Logger,
    ) {}

    /**
     * 토큰을 사용하게 되는 방식
     * 
     * 1) 사용자가 로그인 또는 회원가입을 진행하면
     *      accessToken 과 refreshToken을 발급받는다.
     * 
     * 2) 로그인 할때는 Basic 토큰과 함께 요청을 보낸다.
     *      Basic 토큰은 '이메일:비밀번호'를 Base64로 인코딩한 형태이다. (발급)
     *      예) {authorization: 'Basic {token}'}
     * 
     * 3) 아무나 접근 할 수 없는 정보 (private route)를 접근 할때는
     *      accessToken을 Header에 추가해서 요청과 함께 보낸다. (사용)
     *      예) {authorization: 'Bearer {token}'}
     * 
     * 4) 토큰 요청을 함께 받은 서버는 토큰 검증을 통해 현재 요청을 보낸
     *      사용자가 누구인지 알 수 있다.
     *      예를 들어 현재 로그인한 사용자가 작성한 포스트만 가져오려면
     *      토큰의 sub 값에 입력되어있는 사용자의 포스트만 따로 필터링 할 수 있다.
     *      특정 사용자의 토큰이 없다면 다른 사용자의 데이터를 접근 못한다.
     * 
     * 5) 모든 토큰은 만료기간이 있다. 만료기간이 지나면 새로 토큰을 발급받아야 한다.
     *      그렇지 않으면 jwtService.verify()에서 인증이 통과 안된다.
     *      그러니 accessToken 을 새로 발급 받을 수 있는 /auth/token/access 와
     *      refreshToken을 새로 발급받을 수 있는 /auth/token/refresh 가 필요하다.
     *      refreshToken 발급 여부는 시스템설계마다 다르다. (만료되면 로그아웃시키고 다시 로그인하도록 할 수 있음.)
     */

    /**
     * HTTP 헤더에서 토큰을 추출합니다
     * @param header Authorization 헤더 값
     * @param isBearer Bearer 토큰 여부 (true: Bearer, false: Basic)
     * @returns 추출된 토큰
     * @throws UnauthorizedException 토큰 형식이 잘못된 경우
     */
    extractTokenFromHeader(header: string, isBearer: boolean) {
        const splitToken = header.split(' ');

        const prefix = isBearer ? 'Bearer' : 'Basic';

        if(splitToken.length !== 2 || splitToken[0] !== prefix) {
            throw new UnauthorizedException('잘못된 토큰입니다.');
        }

        const token = splitToken[1];

        return token;
    }

    /**
     * Basic 인증 토큰을 디코딩합니다
     * @param base64String Base64로 인코딩된 토큰 문자열
     * @returns 디코딩된 이메일과 비밀번호 객체
     * @throws UnauthorizedException 토큰 형식이 잘못된 경우
     */
    decodeBasicToken(base64String: string) {
        const decoded = Buffer.from(base64String, 'base64').toString('utf8');

        const split = decoded.split(':');

        if(split.length !== 2) {
            throw new UnauthorizedException('잘못된 유형의 토큰입니다.');
        }

        const email = split[0];
        const password = split[1];

        return {
            email,
            password,
        }
    }

    /**
     * JWT 토큰을 검증합니다
     * @param token 검증할 JWT 토큰
     * @returns 디코딩된 토큰 정보
     * @throws UnauthorizedException 토큰이 유효하지 않거나 만료된 경우
     */
    verifyToken(token: string) {
        try {
            // eslint-disable-next-line @typescript-eslint/no-unsafe-return
            return this.jwtService.verify(token, {
                secret: this.config.jwt.secretKey,
            });
        } catch (error) {
            throw new UnauthorizedException(`${error} : 토큰이 만료되었거나 잘못된 토큰입니다.`);
        }
    }

    /**
     * 기존 토큰을 기반으로 새 토큰을 발급합니다
     * @param token 기존 리프레시 토큰
     * @param isRefreshToken 발급할 토큰 타입 (true: 리프레시 토큰, false: 액세스 토큰)
     * @returns 새로 발급된 토큰
     * @throws UnauthorizedException 리프레시 토큰이 아닌 경우
     */
    rotateToken(token: string, isRefreshToken: boolean) {
        const decoded = this.verifyToken(token);

        /**
         * sub: id
         * email: email,
         * type: 'access' | 'refresh'
         */
        if(decoded.type !== 'refresh') {
            throw new UnauthorizedException('토큰 재발급은 Refresh 토큰으로만 가능합니다!');
        }

        return this.signToken({
            ...decoded
        }, isRefreshToken);
    }

    /**
     * 만들려는 기능
     * 
     * 1) registerWithEmail
     *  - email, nickname, password를 입력받고 사용자 생성
     *  - 생성이 완료되면 accessToken과 refreshToken을 반환한다.
     *      - 회원가입 후 다시 로그인해주세요 <- 이러한 비효율적인 과정을 방지하기 위함.
     * 
     * 2) loginWithEmail
     *  - email, password를 입력하면 사용자 검증을 진행한다.
     *  - 검증이 완료되면 accessToken과 refreshToken을 반환한다.
     * 
     * 3) loginUser
     *  - (1)과 (2)에서 필요한 accessToken과 refreshToken을 반환하는 로직
     * 
     * 4) signToken
     *  - (3)에서 필요한 accessToken과 refreshToken을 sign하는 로직
     *  - 토큰을 생성하는 로직
     * 
     * 5) authenticateWithEmailAndPassword
     *  - (2)에서 로그인을 진행할때 필요한 기본적인 검증 진행
     *      1. 사용자가 존재하는지 확인 (email)
     *      2. 비밀번호가 맞는지 확인
     *      3. 모두 통과되면 찾은 사용자 정보 반환
     *      4. loginWithEmail 에서 반환된 데이터를 기반으로 토큰 생성
     */

    /**
     * JWT 토큰을 생성합니다
     * @param user 토큰에 포함할 사용자 정보
     * @param isRefreshToken 발급할 토큰 타입 (true: 리프레시 토큰, false: 액세스 토큰)
     * @returns 서명된 JWT 토큰
     */
    signToken(user: Pick<UsersModel, 'email' | 'id'>, isRefreshToken: boolean) {
        const payload = {
            email: user.email,
            sub: user.id,
            type: isRefreshToken ? 'refresh' : 'access',
        };

        return this.jwtService.sign(payload, {
            secret: this.config.jwt.secretKey,
            //seconds
            expiresIn: isRefreshToken ? 2592000 : 3600,
        })
    }

    /**
     * 사용자 로그인에 필요한 액세스 토큰과 리프레시 토큰을 생성합니다
     * @param user 토큰을 발급받을 사용자 정보
     * @returns 액세스 토큰과 리프레시 토큰 객체
     */
    loginUser(user: Pick<UsersModel, 'email' | 'id'>) {
        return {
            accessToken: this.signToken(user, false),
            refreshToken: this.signToken(user, true),
        }
    }

    /**
     * 이메일과 비밀번호로 사용자를 인증합니다
     * @param loginDto 로그인 정보가 담긴 DTO
     * @returns 인증된 사용자 정보
     * @throws UnauthorizedException 사용자가 존재하지 않거나 비밀번호가 일치하지 않는 경우
     */
    async authenticateWithEmailAndPassword(loginDto: LoginDto) {
        const existingUser = await this.usersService.getUserByEmail(loginDto.email);

        if (!existingUser) {
            throw new UnauthorizedException('존재하지 않는 사용자입니다.');
        }

        /**
         * 파라미터
         * 
         * 1) 입력된 비밀번호
         * 2) 기존 해시(hash) -> 사용자 정보에 저장되어있는 hash
         */
        const passOk = await bcrypt.compare(loginDto.password, existingUser.password);

        if (!passOk) {
            throw new UnauthorizedException('비밀번호가 틀렸습니다.');
        }

        return existingUser;
    }

    /**
     * 이메일과 비밀번호로 로그인합니다
     * @param loginDto 로그인 정보가 담긴 DTO
     * @returns 액세스 토큰과 리프레시 토큰 객체
     */
    async loginWithEmail(loginDto: LoginDto) {
        const existingUser = await this.authenticateWithEmailAndPassword(loginDto);

        return this.loginUser(existingUser);
    }

    /**
     * 이메일로 회원가입 후 로그인합니다
     * @param user 회원가입 정보가 담긴 DTO
     * @returns 액세스 토큰과 리프레시 토큰 객체
     */
    async registerWithEmail(user: RegisterUserDto) {
        const hash = await bcrypt.hash(
            user.password,
            parseInt(this.config.encrypt.hash_Rounds!),
        );

        const newUser = await this.usersService.createUser({
            ...user,
            password: hash,
        });

        return this.loginUser(newUser);
    }
}

로그를 적용한 AuthService 로직: 

import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { UsersModel } from 'src/users/entity/users.entity';
import { UsersService } from 'src/users/users.service';
import * as bcrypt from 'bcrypt';
import { RegisterUserDto } from './dto/register-user.dto';
import { ConfigType } from '@nestjs/config';
import appConfig from 'src/configs/app.config';
import { LoginDto } from './dto/login.dto';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { Logger } from 'winston';

/**
 * 인증 관련 비즈니스 로직을 처리하는 서비스
 * 사용자 인증, 토큰 생성 및 검증 기능을 제공합니다
 */
@Injectable()
export class AuthService {
    constructor(
        private readonly jwtService: JwtService,
        private readonly usersService: UsersService,
        @Inject(appConfig.KEY)
        private readonly config: ConfigType<typeof appConfig>,
        @Inject(WINSTON_MODULE_PROVIDER) 
        private readonly logger: Logger,
    ) {}

    /**
     * 토큰을 사용하게 되는 방식
     * 
     * 1) 사용자가 로그인 또는 회원가입을 진행하면
     *      accessToken 과 refreshToken을 발급받는다.
     * 
     * 2) 로그인 할때는 Basic 토큰과 함께 요청을 보낸다.
     *      Basic 토큰은 '이메일:비밀번호'를 Base64로 인코딩한 형태이다. (발급)
     *      예) {authorization: 'Basic {token}'}
     * 
     * 3) 아무나 접근 할 수 없는 정보 (private route)를 접근 할때는
     *      accessToken을 Header에 추가해서 요청과 함께 보낸다. (사용)
     *      예) {authorization: 'Bearer {token}'}
     * 
     * 4) 토큰 요청을 함께 받은 서버는 토큰 검증을 통해 현재 요청을 보낸
     *      사용자가 누구인지 알 수 있다.
     *      예를 들어 현재 로그인한 사용자가 작성한 포스트만 가져오려면
     *      토큰의 sub 값에 입력되어있는 사용자의 포스트만 따로 필터링 할 수 있다.
     *      특정 사용자의 토큰이 없다면 다른 사용자의 데이터를 접근 못한다.
     * 
     * 5) 모든 토큰은 만료기간이 있다. 만료기간이 지나면 새로 토큰을 발급받아야 한다.
     *      그렇지 않으면 jwtService.verify()에서 인증이 통과 안된다.
     *      그러니 accessToken 을 새로 발급 받을 수 있는 /auth/token/access 와
     *      refreshToken을 새로 발급받을 수 있는 /auth/token/refresh 가 필요하다.
     *      refreshToken 발급 여부는 시스템설계마다 다르다. (만료되면 로그아웃시키고 다시 로그인하도록 할 수 있음.)
     */

    /**
     * HTTP 헤더에서 토큰을 추출합니다
     * @param header Authorization 헤더 값
     * @param isBearer Bearer 토큰 여부 (true: Bearer, false: Basic)
     * @returns 추출된 토큰
     * @throws UnauthorizedException 토큰 형식이 잘못된 경우
     */
    extractTokenFromHeader(header: string, isBearer: boolean) {
        this.logger.debug('Auth: Extracting token from header', { 
            isBearer,
            hasHeader: !!header,
            headerPrefix: header?.split(' ')[0] || 'none'
        });

        const splitToken = header.split(' ');
        const prefix = isBearer ? 'Bearer' : 'Basic';

        if(splitToken.length !== 2 || splitToken[0] !== prefix) {
            this.logger.warn('Auth: Invalid token format', { 
                expectedPrefix: prefix,
                actualPrefix: splitToken[0],
                tokenParts: splitToken.length
            });
            throw new UnauthorizedException('잘못된 토큰입니다.');
        }

        const token = splitToken[1];
        
        this.logger.debug('Auth: Token extracted successfully', { 
            tokenType: prefix,
            tokenLength: token.length,
            tokenPrefix: token.substring(0, 10) + '...' // 보안: 일부만 로깅
        });

        return token;
    }

    /**
     * Basic 인증 토큰을 디코딩합니다
     * @param base64String Base64로 인코딩된 토큰 문자열
     * @returns 디코딩된 이메일과 비밀번호 객체
     * @throws UnauthorizedException 토큰 형식이 잘못된 경우
     */
    decodeBasicToken(base64String: string) {
        this.logger.debug('Auth: Decoding basic token', { 
            tokenLength: base64String.length 
        });

        try {
            const decoded = Buffer.from(base64String, 'base64').toString('utf8');
            const split = decoded.split(':');

            if(split.length !== 2) {
                this.logger.warn('Auth: Invalid basic token format', { 
                    expectedParts: 2, 
                    actualParts: split.length 
                });
                throw new UnauthorizedException('잘못된 유형의 토큰입니다.');
            }

            const email = split[0];
            const emailDomain = email.split('@')[1] || 'unknown';

            this.logger.debug('Auth: Basic token decoded successfully', { 
                emailDomain, // 보안: 도메인만 로깅
                hasPassword: !!split[1]
            });

            return {
                email,
                password: split[1],
            };
        } catch (error) {
            this.logger.error('Auth: Failed to decode basic token', { 
                error: error.message 
            });
            throw new UnauthorizedException('잘못된 유형의 토큰입니다.');
        }
    }

    /**
     * JWT 토큰을 검증합니다
     * @param token 검증할 JWT 토큰
     * @returns 디코딩된 토큰 정보
     * @throws UnauthorizedException 토큰이 유효하지 않거나 만료된 경우
     */
    verifyToken(token: string) {
        this.logger.debug('Auth: Verifying JWT token', { 
            tokenLength: token.length,
            tokenPrefix: token.substring(0, 20) + '...'
        });

        try {
            const decoded = this.jwtService.verify(token, {
                secret: this.config.jwt.secretKey,
            });

            this.logger.info('Auth: Token verified successfully', {
                userId: decoded.sub,
                tokenType: decoded.type,
                email: this.maskEmail(decoded.email)
            });

            // eslint-disable-next-line @typescript-eslint/no-unsafe-return
            return decoded;
        } catch (error) {
            this.logger.warn('Auth: Token verification failed', {
                error: error.message,
                tokenLength: token.length,
                errorType: error.constructor.name
            });
            throw new UnauthorizedException(`${error} : 토큰이 만료되었거나 잘못된 토큰입니다.`);
        }
    }

    /**
     * 기존 토큰을 기반으로 새 토큰을 발급합니다
     * @param token 기존 리프레시 토큰
     * @param isRefreshToken 발급할 토큰 타입 (true: 리프레시 토큰, false: 액세스 토큰)
     * @returns 새로 발급된 토큰
     * @throws UnauthorizedException 리프레시 토큰이 아닌 경우
     */
    rotateToken(token: string, isRefreshToken: boolean) {
        this.logger.info('Auth: Starting token rotation', { 
            isRefreshToken,
            tokenLength: token.length 
        });

        const decoded = this.verifyToken(token);

        if(decoded.type !== 'refresh') {
            this.logger.warn('Auth: Invalid token type for rotation', {
                actualType: decoded.type,
                requiredType: 'refresh',
                userId: decoded.sub
            });
            throw new UnauthorizedException('토큰 재발급은 Refresh 토큰으로만 가능합니다!');
        }

        const newToken = this.signToken({
            ...decoded
        }, isRefreshToken);

        this.logger.info('Auth: Token rotated successfully', {
            userId: decoded.sub,
            oldTokenType: decoded.type,
            newTokenType: isRefreshToken ? 'refresh' : 'access',
            email: this.maskEmail(decoded.email)
        });

        return newToken;
    }

    /**
     * 만들려는 기능
     * 
     * 1) registerWithEmail
     *  - email, nickname, password를 입력받고 사용자 생성
     *  - 생성이 완료되면 accessToken과 refreshToken을 반환한다.
     *      - 회원가입 후 다시 로그인해주세요 <- 이러한 비효율적인 과정을 방지하기 위함.
     * 
     * 2) loginWithEmail
     *  - email, password를 입력하면 사용자 검증을 진행한다.
     *  - 검증이 완료되면 accessToken과 refreshToken을 반환한다.
     * 
     * 3) loginUser
     *  - (1)과 (2)에서 필요한 accessToken과 refreshToken을 반환하는 로직
     * 
     * 4) signToken
     *  - (3)에서 필요한 accessToken과 refreshToken을 sign하는 로직
     *  - 토큰을 생성하는 로직
     * 
     * 5) authenticateWithEmailAndPassword
     *  - (2)에서 로그인을 진행할때 필요한 기본적인 검증 진행
     *      1. 사용자가 존재하는지 확인 (email)
     *      2. 비밀번호가 맞는지 확인
     *      3. 모두 통과되면 찾은 사용자 정보 반환
     *      4. loginWithEmail 에서 반환된 데이터를 기반으로 토큰 생성
     */

    /**
     * JWT 토큰을 생성합니다
     * @param user 토큰에 포함할 사용자 정보
     * @param isRefreshToken 발급할 토큰 타입 (true: 리프레시 토큰, false: 액세스 토큰)
     * @returns 서명된 JWT 토큰
     */
    signToken(user: Pick<UsersModel, 'email' | 'id'>, isRefreshToken: boolean) {
        this.logger.debug('Auth: Signing new token', {
            userId: user.id,
            email: this.maskEmail(user.email),
            tokenType: isRefreshToken ? 'refresh' : 'access',
            expiresIn: isRefreshToken ? '30 days' : '1 hour'
        });

        const payload = {
            email: user.email,
            sub: user.id,
            type: isRefreshToken ? 'refresh' : 'access',
        };

        const token = this.jwtService.sign(payload, {
            secret: this.config.jwt.secretKey,
            expiresIn: isRefreshToken ? 2592000 : 3600,
        });

        this.logger.info('Auth: Token signed successfully', {
            userId: user.id,
            tokenType: isRefreshToken ? 'refresh' : 'access',
            tokenLength: token.length
        });

        return token;
    }

    /**
     * 사용자 로그인에 필요한 액세스 토큰과 리프레시 토큰을 생성합니다
     * @param user 토큰을 발급받을 사용자 정보
     * @returns 액세스 토큰과 리프레시 토큰 객체
     */
    loginUser(user: Pick<UsersModel, 'email' | 'id'>) {
        this.logger.info('Auth: Generating login tokens', {
            userId: user.id,
            email: this.maskEmail(user.email)
        });

        const tokens = {
            accessToken: this.signToken(user, false),
            refreshToken: this.signToken(user, true),
        };

        this.logger.info('Auth: Login tokens generated successfully', {
            userId: user.id,
            hasAccessToken: !!tokens.accessToken,
            hasRefreshToken: !!tokens.refreshToken
        });

        return tokens;
    }

    /**
     * 이메일과 비밀번호로 사용자를 인증합니다
     * @param loginDto 로그인 정보가 담긴 DTO
     * @returns 인증된 사용자 정보
     * @throws UnauthorizedException 사용자가 존재하지 않거나 비밀번호가 일치하지 않는 경우
     */
    async authenticateWithEmailAndPassword(loginDto: LoginDto) {
        this.logger.info('Auth: Starting email/password authentication', {
            email: this.maskEmail(loginDto.email),
            hasPassword: !!loginDto.password
        });

        try {
            const existingUser = await this.usersService.getUserByEmail(loginDto.email);

            if (!existingUser) {
                this.logger.warn('Auth: User not found during authentication', {
                    email: this.maskEmail(loginDto.email),
                    attemptTime: new Date().toISOString()
                });
                throw new UnauthorizedException('존재하지 않는 사용자입니다.');
            }

            this.logger.debug('Auth: User found, checking password', {
                userId: existingUser.id,
                email: this.maskEmail(existingUser.email)
            });

            const passOk = await bcrypt.compare(loginDto.password, existingUser.password);

            if (!passOk) {
                this.logger.warn('Auth: Password verification failed', {
                    userId: existingUser.id,
                    email: this.maskEmail(existingUser.email),
                    attemptTime: new Date().toISOString()
                });
                throw new UnauthorizedException('비밀번호가 틀렸습니다.');
            }

            this.logger.info('Auth: Authentication successful', {
                userId: existingUser.id,
                email: this.maskEmail(existingUser.email),
                loginTime: new Date().toISOString()
            });

            return existingUser;
        } catch (error) {
            this.logger.error('Auth: Authentication process failed', {
                email: this.maskEmail(loginDto.email),
                error: error.message,
                errorType: error.constructor.name
            });
            throw error;
        }
    }

    /**
     * 이메일과 비밀번호로 로그인합니다
     * @param loginDto 로그인 정보가 담긴 DTO
     * @returns 액세스 토큰과 리프레시 토큰 객체
     */
    async loginWithEmail(loginDto: LoginDto) {
        this.logger.info('Auth: Email login attempt', {
            email: this.maskEmail(loginDto.email),
            timestamp: new Date().toISOString()
        });

        try {
            const existingUser = await this.authenticateWithEmailAndPassword(loginDto);
            const tokens = this.loginUser(existingUser);

            this.logger.info('Auth: Email login completed successfully', {
                userId: existingUser.id,
                email: this.maskEmail(existingUser.email)
            });

            return tokens;
        } catch (error) {
            this.logger.error('Auth: Email login failed', {
                email: this.maskEmail(loginDto.email),
                error: error.message
            });
            throw error;
        }
    }

    /**
     * 이메일로 회원가입 후 로그인합니다
     * @param user 회원가입 정보가 담긴 DTO
     * @returns 액세스 토큰과 리프레시 토큰 객체
     */
    async registerWithEmail(user: RegisterUserDto) {
        this.logger.info('Auth: User registration attempt', {
            email: this.maskEmail(user.email),
            nickname: user.nickname,
            timestamp: new Date().toISOString()
        });

        try {
            // 비밀번호 해싱
            this.logger.debug('Auth: Hashing password', {
                email: this.maskEmail(user.email),
                hashRounds: this.config.encrypt.hash_Rounds
            });

            const hash = await bcrypt.hash(
                user.password,
                parseInt(this.config.encrypt.hash_Rounds!),
            );

            // 사용자 생성
            this.logger.debug('Auth: Creating new user', {
                email: this.maskEmail(user.email),
                nickname: user.nickname
            });

            const newUser = await this.usersService.createUser({
                ...user,
                password: hash,
            });

            this.logger.info('Auth: User created successfully', {
                userId: newUser.id,
                email: this.maskEmail(newUser.email),
                nickname: newUser.nickname
            });

            // 토큰 생성
            const tokens = this.loginUser(newUser);

            this.logger.info('Auth: Registration completed with auto-login', {
                userId: newUser.id,
                email: this.maskEmail(newUser.email)
            });

            return tokens;
        } catch (error) {
            this.logger.error('Auth: Registration failed', {
                email: this.maskEmail(user.email),
                error: error.message,
                errorType: error.constructor.name
            });
            throw error;
        }
    }

    /**
     * 보안을 위해 이메일을 마스킹합니다
     * 예: john.doe@example.com -> j***@example.com
     */
    private maskEmail(email: string): string {
        if (!email || !email.includes('@')) {
            return 'invalid-email';
        }

        const [local, domain] = email.split('@');
        const maskedLocal = local.charAt(0) + '*'.repeat(Math.max(0, local.length - 1));
        return `${maskedLocal}@${domain}`;
    }
}

8. 결과 확인

Request - Method: POST, URL: /auth/login/email, IP: ::1 - 6/5/2025, 3:51:00 PM
  Auth: Email login attempt - { email: 't*****@test.com' }
  Auth: Starting email/password authentication - { email: 't*****@test.com', hasPassword: true }
query: SELECT "UsersModel"."id" AS "UsersModel_id", "UsersModel"."updatedAt" AS "UsersModel_updatedAt", "UsersModel"."createdAt" AS "UsersModel_createdAt", "UsersModel"."google" AS "UsersModel_google", "UsersModel"."kakao" AS "UsersModel_kakao", "UsersModel"."nickname" AS "UsersModel_nickname", "UsersModel"."email" AS "UsersModel_email", "UsersModel"."password" AS "UsersModel_password", "UsersModel"."role" AS "UsersModel_role", "UsersModel"."followerCount" AS "UsersModel_followerCount", "UsersModel"."followeeCount" AS "UsersModel_followeeCount" FROM "users_model" "UsersModel" WHERE (("UsersModel"."email" = $1)) LIMIT 1 -- PARAMETERS: ["test01@test.com"]
  Auth: User found, checking password - { userId: 1, email: 't*****@test.com' }
  Auth: Authentication successful - {
  userId: 1,
  email: 't*****@test.com',
  loginTime: '2025-06-05T06:51:00.630Z'
}
  Auth: Generating login tokens - { userId: 1, email: 't*****@test.com' }
  Auth: Signing new token - {
  userId: 1,
  email: 't*****@test.com',
  tokenType: 'access',
  expiresIn: '1 hour'
}
  Auth: Token signed successfully - { userId: 1, tokenType: 'access', tokenLength: 195 }
  Auth: Signing new token - {
  userId: 1,
  email: 't*****@test.com',
  tokenType: 'refresh',
  expiresIn: '30 days'
}
  Auth: Token signed successfully - { userId: 1, tokenType: 'refresh', tokenLength: 196 }
  Auth: Login tokens generated successfully - { userId: 1, hasAccessToken: true, hasRefreshToken: true }
  Auth: Email login completed successfully - { userId: 1, email: 't*****@test.com' }
Response - Request - Method: POST, URL: /auth/login/email, IP: ::1 - 6/5/2025, 3:51:00 PM (Execution time: 78ms)

기존처럼 로그인 시 콘솔에 위와 같이 상세한 로그가 뜨는 것을 볼 수 있다.

최근댓글

최근글

skin by © 2024 ttuttak