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)
기존처럼 로그인 시 콘솔에 위와 같이 상세한 로그가 뜨는 것을 볼 수 있다.
'NestJS' 카테고리의 다른 글
Github Actions를 사용한 CI/CD 구축 (0) | 2025.06.30 |
---|---|
시놀로지 나스 NestJS 개발서버 구축하기 - Docker Container (0) | 2025.06.19 |
[NestJS] 단위테스트 (1) | 2025.06.04 |
[NestJS] 자동 문서화 가이드 (Compodoc) (1) | 2025.05.30 |
[NestJS] 각종 보안 적용하기 (0) | 2025.05.27 |