Nest.js 블로그 단계별 가이드: 인증 추가
Takashi Yamamoto
Infrastructure Engineer · Leapcell

이전 튜토리얼에서 사용자 등록 시스템과 로그인 유효성 검사를 위한 기본 로직을 성공적으로 구축했습니다. 사용자는 계정을 만들 수 있고, 애플리케이션은 사용자 이름과 비밀번호를 확인할 수 있습니다.
하지만 현재 로그인은 일회성 이벤트일 뿐입니다. 서버는 사용자의 로그인 상태를 기억하지 않습니다. 페이지를 새로고침할 때마다 사용자는 다시 게스트로 돌아갑니다.
이 글에서는 Passport.js와 세션을 사용하여 블로그의 실제 사용자 로그인 상태 관리를 구현합니다. 이를 통해 로그인해야 액세스할 수 있는 페이지와 기능을 보호하고 사용자 로그인 상태에 따라 인터페이스를 동적으로 업데이트할 수 있습니다.
세션 및 Passport.js 구성
로그인 로직과 세션 관리를 처리하기 위해 인기 있는 Node.js 인증 라이브러리인 passport
와 세션을 관리할 express-session
을 사용합니다.
관련 종속성을 설치합니다.
npm install @nestjs/passport passport passport-local express-session npm install -D @types/passport-local @types/express-session
Passport Local 전략 구현
Passport는 다양한 인증 방법을 처리하기 위해 "전략"을 사용합니다. 우리는 이전 글에서 작성한 사용자 이름과 비밀번호 검증 로직을 사용하는 "로컬 전략"을 구현할 것입니다.
src/auth
디렉터리에 local.strategy.ts
를 생성합니다.
// src/auth/local.strategy.ts import { Strategy } from 'passport-local'; import { PassportStrategy } from '@nestjs/passport'; import { Injectable, UnauthorizedException } from '@nestjs/common'; import { AuthService } from './auth.service'; @Injectable() export class LocalStrategy extends PassportStrategy(Strategy) { constructor(private authService: AuthService) { super(); } async validate(username: string, password: string): Promise<any> { const user = await this.authService.validateUser(username, password); if (!user) { throw new UnauthorizedException(); } return user; } }
validate
메서드는 Passport에 의해 자동으로 호출됩니다. 로그인 양식에서 username
과 password
를 받아 이전에 만든 authService.validateUser
를 사용하여 검증합니다.
Auth 모듈 업데이트
이제 auth.module.ts
에서 PassportModule
과 LocalStrategy
를 등록합니다.
// src/auth/auth.module.ts import { Module } from '@nestjs/common'; import { AuthService } from './auth.service'; import { UsersModule } from '../users/users.module'; import { PassportModule } from '@nestjs/passport'; import { LocalStrategy } from './local.strategy'; @Module({ imports: [UsersModule, PassportModule.register({ session: true })], // 세션 활성화 providers: [AuthService, LocalStrategy], exports: [AuthService], }) export class AuthModule {}
세션 저장에 Redis 사용
기본적으로 express-session
은 서버 메모리에 세션을 저장합니다. 이는 서버가 다시 시작되면 모든 사용자의 로그인 상태가 손실된다는 것을 의미합니다. 이 문제를 해결하기 위해 고성능 인메모리 데이터베이스인 Redis를 사용하여 세션을 지속합니다.
Redis가 없는 경우
Leapcell에서 Redis 인스턴스를 만들 수 있습니다. Leapcell은 백엔드 애플리케이션에 필요한 대부분의 도구를 제공합니다!
Redis 서비스가 없는 경우 express-session
은 자동으로 인메모리 저장소 사용으로 대체됩니다. 하지만 이는 프로덕션 환경에 대한 모범 사례가 아니며 문제를 야기할 수 있습니다.
Redis 관련 종속성을 설치합니다.
npm install redis connect-redis
이제 src/main.ts
파일을 열어 세션 미들웨어를 구성하고 Redis에 연결합니다.
// src/main.ts import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { NestExpressApplication } from '@nestjs/platform-express'; import { join } from 'path'; import * as session from 'express-session'; import * as passport from 'passport'; import { createClient } from 'redis'; import RedisStore from 'connect-redis'; async function bootstrap() { const app = await NestFactory.create<NestExpressApplication>(AppModule); // Redis 클라이언트 초기화 const redisClient = createClient({ // Redis에 비밀번호가 있거나 다른 호스트에 있는 경우 여기에서 구성을 수정하십시오. // url: 'redis://:password@hostname:port' url: 'redis://localhost:6379', }); await redisClient.connect().catch(console.error); // RedisStore 초기화 const redisStore = new RedisStore({ client: redisClient, prefix: 'blog-session:', }); app.use( session({ store: redisStore, // 저장소로 Redis 사용 secret: 'your-secret-key', // 임의의 복잡한 문자열로 바꾸세요 resave: false, saveUninitialized: false, cookie: { httpOnly: true, maxAge: 1000 * 60 * 60 * 24 * 7, // 7일 }, }) ); app.use(passport.initialize()); app.use(passport.session()); // Passport 세션 직렬화 및 역직렬화 // 사용자가 성공적으로 로그인하면 사용자 정보가 직렬화되어 세션에 저장됩니다. passport.serializeUser((user: any, done) => { done(null, user.id); }); // 각 요청마다 Passport는 세션의 ID를 통해 사용자를 찾고 역직렬화합니다. passport.deserializeUser((id: string, done) => { // 실제 애플리케이션에서는 여기서 데이터베이스에서 사용자를 찾아야 합니다. // const user = await usersService.findById(id); // 단순화를 위해 여기서는 ID만 포함하는 객체를 일시적으로 반환합니다. done(null, { id: id }); }); app.useStaticAssets(join(__dirname, '..', 'public')); app.setBaseViewsDir(join(__dirname, '..', 'views')); app.setViewEngine('ejs'); await app.listen(3000); } bootstrap();
serializeUser
: 이 메서드는 사용자가 성공적으로 로그인한 후 호출됩니다. 세션에 저장할 사용자 정보를 결정합니다. 여기서는user.id
만 저장합니다.deserializeUser
: 후속 각 요청에서 Passport는 세션의user.id
를 읽고 이를 사용하여 전체 사용자 정보를 가져온 다음 해당 사용자 정보를request.user
에 첨부합니다.
실제 로그인 및 로그아웃 라우트 구현
이제 구성이 완료되었으므로 auth.controller.ts
를 업데이트하여 로그인 및 로그아웃 처리에 Passport를 사용합니다.
// src/auth/auth.controller.ts import { Controller, Get, Post, Render, Request, Res, UseGuards } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { Response } from 'express'; @Controller('auth') export class AuthController { @Get('login') @Render('login') showLoginForm() { return; } @UseGuards(AuthGuard('local')) // 'local' 전략과 함께 AuthGuard 사용 @Post('login') async login(@Request() req, @Res() res: Response) { // 코드가 이 지점에 도달하면 LocalStrategy의 validate 메서드가 성공적으로 실행된 것입니다. // Passport는 자동으로 세션을 생성하고 req.user에 사용자 객체를 첨부합니다. res.redirect('/posts'); } @Get('logout') logout(@Request() req, @Res() res: Response) { req.logout((err) => { if (err) { console.log(err); } res.redirect('/'); }); } }
@UseGuards(AuthGuard('local'))
데코레이터는 /auth/login
에 대한 POST 요청을 자동으로 가로채고 LocalStrategy
를 실행합니다. 유효성 검사가 성공하면 세션을 설정하고 login
메서드 본문을 호출합니다. 실패하면 자동으로 401 Unauthorized
오류를 발생시킵니다.
라우트 보호 및 UI 업데이트
이제 로그인 메커니즘이 있으므로 최종 단계는 이를 사용하여 "게시물 생성" 기능을 보호하고 로그인 상태에 따라 다른 UI를 표시하는 것입니다.
인증 가드 생성
Nest.js의 가드는 주어진 요청을 처리해야 하는지 여부를 결정하는 클래스입니다. 사용자가 로그인했는지 확인하는 AuthenticatedGuard
를 생성합니다.
src/auth
디렉터리에 authenticated.guard.ts
를 생성합니다.
// src/auth/authenticated.guard.ts import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; @Injectable() export class AuthenticatedGuard implements CanActivate { async canActivate(context: ExecutionContext): Promise<boolean> { const request = context.switchToHttp().getRequest(); // request.isAuthenticated()는 Passport가 세션에 사용자가 있는지 확인하기 위해 추가한 메서드입니다. return request.isAuthenticated(); } }
가드 적용
src/posts/posts.controller.ts
를 열고 보호할 라우트에 @UseGuards(AuthenticatedGuard)
데코레이터를 적용합니다.
// src/posts/posts.controller.ts import { Controller, Get, Render, Post, Body, Res, UseGuards, Param } from '@nestjs/common'; import { AuthenticatedGuard } from '../auth/authenticated.guard'; // 경로 조정 필요 import { PostsService } from './posts.service'; import { Response } from 'express'; @Controller('posts') export class PostsController { constructor(private readonly postsService: PostsService) {} // ... findAll() 및 findOne() @UseGuards(AuthenticatedGuard) // <--- 가드 적용 @Get('new') @Render('new-post') newPostForm() { return; } @UseGuards(AuthenticatedGuard) // <--- 가드 적용 @Post() async create(@Body() body: { title: string; content: string }, @Res() res: Response) { await this.postsService.create(body); res.redirect('/posts'); } // ... }
이제 로그인하지 않은 사용자가 /posts/new
에 액세스하려고 하면 자동으로 가로채집니다(기본적으로 403 Forbidden
오류가 발생합니다).
프런트엔드 인터페이스 업데이트
마지막으로 UI를 업데이트하여 사용자의 로그인 상태에 따라 다른 버튼을 표시합니다. EJS 템플릿을 렌더링할 때 사용자 로그인 상태(req.user
)를 프런트엔드로 전달해야 합니다.
views/_header.ejs
를 수정하여 로그인/등록 및 로그아웃/새 게시물 링크를 추가합니다.
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title><%= title %></title> <link rel="stylesheet" href="/css/style.css" /> </head> <body> <header> <h1><a href="/">My Blog</a></h1> <div class="user-actions"> <% if (user) { %> <span>Welcome, User</span> <a href="/posts/new" class="new-post-btn">New Post</a> <a href="/auth/logout">Logout</a> <% } else { %> <a href="/auth/login">Login</a> <a href="/users/register">Register</a> <% } %> </div> </header> <main></main> </body> </html>
위 코드가 작동하려면 컨트롤러를 업데이트하여 user
정보를 뷰에 전달해야 합니다.
posts.controller.ts
에서 뷰를 렌더링하는 모든 메서드를 수정합니다.
// src/posts/posts.controller.ts import { Controller, Get, Render, Param, /*...,*/ Request } from '@nestjs/common'; // ... @Controller('posts') export class PostsController { // ... @Get() @Render('index') async findAll(@Request() req) { const posts = await this.postsService.findAll(); return { posts, user: req.user }; // 템플릿에 user 전달 } // ... @Get(':id') @Render('post') async findOne(@Param('id') id: string, @Request() req) { const post = await this.postsService.findOne(id); return { post, user: req.user }; // 템플릿에 user 전달 } }
실행 및 테스트
이제 애플리케이션을 다시 시작합니다.
npm run start:dev
http://localhost:3000
으로 이동합니다.
- 오른쪽 상단에 "Login" 및 "Register" 버튼이 표시됩니다.
- "Register"를 클릭하여 새 사용자를 만듭니다.
- 등록이 성공하면 로그인 페이지로 리디렉션됩니다. 방금 만든 계정으로 로그인합니다.
- 로그인이 성공하면 홈페이지로 리디렉션되며, 오른쪽 상단 모서리에 "Welcome, User", "New Post", "Logout" 버튼이 표시됩니다.
- 이 시점에서 "New Post"를 클릭하여 새 게시물을 작성할 수 있습니다. 로그아웃한 다음
/posts/new
에 액세스하려고 하면 거부됩니다.
이를 통해 블로그에 완전한 사용자 인증 시스템을 추가했습니다. 친구들이 블로그를 가지고 장난치는 것에 대해 더 이상 걱정하지 않아도 됩니다!