Nest.js 블로그 단계별 가이드: 인증 추가
Wenhao Wang
Dev Intern · Leapcell

이전 튜토리얼에서 기능하는 개인 블로그를 성공적으로 구축했습니다. 누구나 방문하여 새 게시물을 작성할 수 있었습니다. 그러나 진정한 개인 블로그의 경우 일반적으로 블로거 자신만이 콘텐츠를 게시하고 관리할 수 있습니다.
이 튜토리얼에서는 블로그에 사용자 인증 및 권한 부여 기능을 추가합니다. 다음을 구현합니다.
- 사용자 등록 및 로그인 기능.
- 사용자 로그인 상태를 관리하기 위한 세션-쿠키 메커니즘.
- 서비스 재시작 후 로그인 상태가 손실되지 않도록 Redis를 사용하여 세션 지속성.
- 로그인한 사용자만 액세스할 수 있도록 게시물 작성 기능 보호.
더 이상 지체하지 않고 계속 진행하겠습니다.
1. 인증 방법 선택
웹 개발에서 가장 일반적인 두 가지 인증 방법은 **토큰 기반(예: JWT)**과 **세션 기반(쿠키)**입니다.
- JWT (JSON Web Tokens): 서버는 사용자 상태를 저장하지 않습니다. 사용자가 로그인하면 서버는 암호화된 토큰을 생성하여 클라이언트에게 반환합니다. 클라이언트는 후속 요청에 이 토큰을 포함하며 서버는 해당 토큰의 유효성을 검사합니다. 이 방법은 상태 비저장 RESTful API 및 단일 페이지 애플리케이션(SPA)에 이상적입니다.
- 세션-쿠키: 사용자가 로그인하면 서버는 세션을 생성하고 세션 ID를 쿠키를 통해 브라우저로 다시 보냅니다. 브라우저는 후속 요청에 이 쿠키를 자동으로 포함합니다. 서버는 쿠키의 세션 ID를 사용하여 해당 세션 정보를 찾음으로써 사용자를 식별합니다.
이 튜토리얼에서는 세션-쿠키 방법을 선택합니다. 그 이유는 당사의 블로그가 서버 측 렌더링 페이지(EJS)를 갖춘 전통적인 애플리케이션이기 때문입니다. 이 모델에서는 브라우저와 서버가 긴밀하게 결합되어 있어 쿠키 기반 세션 관리가 가장 직접적이고 고전적이며 안전한 방법입니다. Nest.js는 또한 이 접근 방식에 대한 훌륭한 내장 지원을 제공합니다.
2. 사용자 모듈 생성
이전 튜토리얼에서 posts
모듈을 생성한 것과 마찬가지로 사용자 관리를 위한 모듈이 필요합니다.
Nest CLI를 사용하여 필요한 파일을 신속하게 생성합니다.
nest generate module users nest generate controller users nest generate service users
다음으로 데이터베이스의 users
테이블에 매핑하기 위해 src/users
디렉토리에 사용자 엔티티 파일 user.entity.ts
를 생성합니다.
// src/users/user.entity.ts import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; @Entity() export class User { @PrimaryGeneratedColumn('uuid') id: string; @Column({ unique: true }) username: string; @Column() password: string; // 저장된 비밀번호는 암호화된 해시가 됩니다. }
그런 다음 User
엔티티를 작업할 수 있도록 UsersModule
(src/users/users.module.ts
)에 TypeOrmModule
을 등록합니다.
// src/users/users.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { UsersController } from './users.controller'; import { UsersService } from './users.service'; import { User } from './user.entity'; @Module({ imports: [TypeOrmModule.forFeature([User])], controllers: [UsersController], providers: [UsersService], exports: [UsersService], // 다른 모듈에서 사용하기 위해 UsersService 내보내기 }) export class UsersModule {}
마지막으로 Leapcell 또는 로컬 PostgreSQL 데이터베이스에서 다음 SQL 문을 실행하여 user
테이블을 생성합니다.
CREATE TABLE "user" ( "id" UUID PRIMARY KEY DEFAULT gen_random_uuid(), "username" VARCHAR UNIQUE NOT NULL, "password" VARCHAR NOT NULL );
3. 등록 및 비밀번호 암호화 구현
사용자 비밀번호는 데이터베이스에 평문으로 저장해서는 안 됩니다. 비밀번호를 해시하고 암호화하기 위해 bcrypt
라이브러리를 사용할 것입니다.
먼저 필요한 종속성을 설치합니다.
npm install bcrypt npm install -D @types/bcrypt
다음으로 사용자 생성 및 찾기 로직을 추가하기 위해 src/users/users.service.ts
를 업데이트합니다.
// src/users/users.service.ts import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { User } from './user.entity'; import * as bcrypt from 'bcrypt'; @Injectable() export class UsersService { constructor( @InjectRepository(User) private usersRepository: Repository<User> ) {} async create(user: Partial<User>): Promise<User> { const saltRounds = 10; const hashedPassword = await bcrypt.hash(user.password || '', saltRounds); const newUser = this.usersRepository.create({ username: user.username, password: hashedPassword, }); return this.usersRepository.save(newUser); } async findOne(username: string): Promise<User | null> { return this.usersRepository.findOneBy({ username }); } }
4. 세션 및 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
인증 모듈 생성
모든 인증 관련 로직(로그인, 로그아웃, 전략 등)을 별도의 auth
모듈에 배치할 것입니다.
nest generate module auth nest generate service auth
비밀번호 유효성 검사 로직을 추가하기 위해 src/auth/auth.service.ts
를 수정합니다.
// src/auth/auth.service.ts import { Injectable } from '@nestjs/common'; import { UsersService } from '../users/users.service'; import * as bcrypt from 'bcrypt'; @Injectable() export class AuthService { constructor(private usersService: UsersService) {} async validateUser(username: string, pass: string): Promise<any> { const user = await this.usersService.findOne(username); if (user && (await bcrypt.compare(pass, user.password))) { const { password, ...result } = user; return result; // 유효성 검사 성공, 비밀번호를 제외한 사용자 객체 반환 } return null; // 유효성 검사 실패 } }
Passport 로컬 전략 구현
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; } }
인증 모듈 구성
이제 우리가 방금 만든 서비스와 전략을 auth.module.ts
에 조립해 보겠습니다.
// 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], providers: [AuthService, LocalStrategy], }) export class AuthModule {}
5. Redis를 사용하여 세션 저장
기본적으로 express-session
은 서버의 메모리에 세션을 저장합니다. 이는 서버가 재시작되면 모든 사용자 로그인 상태가 손실된다는 것을 의미합니다. 이를 해결하기 위해 Redis라는 고성능 인메모리 데이터베이스를 사용하여 세션을 유지합니다.
Redis가 없으면 어떻게 되나요? 현재 Redis 서비스를 사용할 수 없다면 이 단계를 건너뛸 수 있습니다.
express-session
은 자동으로 메모리 저장소 사용으로 대체됩니다. 이는 로컬 개발에는 완벽하지만 프로덕션 환경에는 적합하지 않다는 점을 기억하십시오.
Redis 관련 종속성을 설치합니다.
npm install redis connect-redis
이제 src/main.ts
파일을 열어 세션 미들웨어를 구성하고 Redis에 연결합니다.
// src/main.ts import { NestFactory } from '@nestjs/core'; import { NestExpressApplication } from '@nestjs/platform-express'; import { join } from 'path'; import { AppModule } from './app.module'; 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({ // 비밀번호가 있거나 다른 호스트에 있는 경우 여기에 구성을 수정하십시오 // 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(async (id: string, done) => { // 여기에 UsersService에 나중에 추가할 findById 메서드가 필요합니다. // const user = await usersService.findById(id); // 단순화를 위해 임시로 ID만 가진 객체를 반환합니다. done(null, { id }); }); app.useStaticAssets(join(__dirname, '..', 'public')); app.setBaseViewsDir(join(__dirname, '..', 'views')); app.setViewEngine('ejs'); await app.listen(3000); } bootstrap();
참고: deserializeUser
가 올바르게 작동하려면 UsersService
에 findById
메서드를 추가해야 합니다.
6. 로그인 및 등록 페이지 생성
사용자가 상호 작용할 수 있는 인터페이스를 제공해야 합니다. views
폴더에 login.ejs
및 register.ejs
를 생성합니다.
-
register.ejs
<%- include('_header', { title: 'Register' }) %> <form action="/users/register" method="POST" class="post-form"> <h2>Register</h2> <div class="form-group"> <label for="username">Username</label> <input type="text" id="username" name="username" required /> </div> <div class="form-group"> <label for="password">Password</label> <input type="password" id="password" name="password" required /> </div> <button type="submit">Register</button> </form> <%- include('_footer') %>
-
login.ejs
<%- include('_header', { title: 'Login' }) %> <form action="/auth/login" method="POST" class="post-form"> <h2>Login</h2> <div class="form-group"> <label for="username">Username</label> <input type="text" id="username" name="username" required /> </div> <div class="form-group"> <label for="password">Password</label> <input type="password" id="password" name="password" required /> </div> <button type="submit">Login</button> </form> <%- include('_footer') %>
7. 라우트 및 컨트롤러 로직 구현
이제 등록 및 로그인 요청을 처리하는 라우트를 생성해 보겠습니다.
등록 라우트
src/users/users.controller.ts
를 업데이트합니다.
// src/users/users.controller.ts import { Controller, Get, Post, Render, Body, Res } from '@nestjs/common'; import { UsersService } from './users.service'; import { Response } from 'express'; @Controller('users') export class UsersController { constructor(private readonly usersService: UsersService) {} @Get('register') @Render('register') showRegisterForm() { return; } @Post('register') async register(@Body() body: any, @Res() res: Response) { // 단순화를 위해 복잡한 유효성 검사는 하지 않겠습니다. await this.usersService.create(body); res.redirect('/auth/login'); // 성공적인 등록 후 로그인 페이지로 리디렉션 } }
로그인 및 로그아웃 라우트
로그인 및 로그아웃을 처리하기 위해 새로운 auth.controller.ts
를 생성합니다.
nest generate controller auth
src/auth/auth.controller.ts
를 수정합니다.
// 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')) @Post('login') async login(@Request() req, @Res() res: Response) { // 성공적인 유효성 검사 후 Passport는 사용자 객체를 req.user에 첨부합니다. res.redirect('/posts'); } @Get('logout') logout(@Request() req, @Res() res: Response) { req.logout((err) => { if (err) { console.log(err); } res.redirect('/'); }); } }
마지막으로 app.module.ts
에서 UsersModule
과 AuthModule
을 가져오는 것을 잊지 마세요.
// src/app.module.ts // ... imports import { UsersModule } from './users/users.module'; import { AuthModule } from './auth/auth.module'; @Module({ imports: [ // ... TypeOrmModule.forRoot(...) PostsModule, UsersModule, AuthModule, ], // ... }) export class AppModule {}
8. 라우트 보호 및 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(); return request.isAuthenticated(); // Passport에서 추가한 메서드로, 세션에 사용자가 있는지 확인합니다. } }
가드 적용
src/posts/posts.controller.ts
를 열고 보호해야 하는 라우트에 @UseGuards(AuthenticatedGuard)
데코레이터를 적용합니다.
// src/posts/posts.controller.ts import { Controller, Get, Render, Param, Post, Body, Res, UseGuards } from '@nestjs/common'; import { AuthenticatedGuard } from 'src/auth/authenticated.guard'; // ...다른 imports @Controller('posts') export class PostsController { // ... constructor // ... findAll() @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'); } // ... findOne() }
이제 로그인하지 않은 사용자가 /posts/new
에 액세스하려고 하면 자동으로 차단됩니다.
프런트엔드 UI 업데이트
마지막으로 사용자의 로그인 상태에 따라 다른 버튼을 표시하도록 UI를 업데이트합니다. 이를 위해서는 app.controller.ts
및 posts.controller.ts
를 약간 수정하여 사용자 로그인 상태를 EJS 템플릿 렌더링 시 프런트엔드로 전달해야 합니다.
가장 간단한 방법은 @Render
를 사용하는 모든 라우트에 req.user
를 수동으로 전달하는 것입니다.
_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.username || '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>
posts.controller.ts
의 root
메서드를 업데이트하여 user
정보를 뷰에 전달해야 합니다.
// src/posts/posts.controller.ts // ... import { Request } from 'express'; // Request 가져오기 @Controller('posts') export class PostsController { // ... @Get() @Render('index') async root(@Request() req) { // Request 객체 주입 const posts = await this.postsService.findAll(); return { posts, user: req.user }; // 사용자 객체를 템플릿에 전달 } // ...다른 @Render 라우트에도 유사한 변경 적용 @Get(':id') @Render('post') async post(@Param('id') id: string, @Request() req) { const post = await this.postsService.findOne(id); return { post, user: req.user }; } }
9. 실행 및 테스트
이제 애플리케이션을 다시 시작합니다.
npm run start:dev
http://localhost:3000
을 방문합니다.
- 오른쪽 상단에 "Login" 및 "Register" 버튼이 표시됩니다.
- "Register"를 클릭하고 새 사용자를 생성합니다.
- 등록이 성공적으로 완료되면 로그인 페이지로 리디렉션됩니다. 방금 만든 계정으로 로그인합니다.
- 로그인이 성공적으로 완료되면 오른쪽 상단의 버튼이 "New Post" 및 "Logout"으로 변경됩니다. 이제 새 게시물을 작성할 수 있습니다.
이 시점에서 블로그에 완전한 사용자 인증 시스템을 성공적으로 추가했습니다.