npm install --save @nestjs/passport passport passport-local
npm install --save-dev @types/passport-local
@nestjs/passport는 Passport 전략을 Nest Guard 처럼 사용할 수 있게 해주는 연결 다리입니다.
Nest.JS 에서 Guard 는 Middleware Interceptor 사이 보통 권한 확인을 담당합니다.
// back/src/auth/local-auth.guard.ts
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class LocalAuthGuards extends AuthGuard('local') {
async canActivate(context: ExecutionContext): Promise<boolean> {
const can = await super.canActivate(context);
if (can) {
const request = context.switchToHttp().getRequest();
console.log('login for cookie');
await super.logIn(request);
}
return true;
}
}
// back/src/uesrs/users.controller.ts
@UseGuards(LocalAuthGuard)
@Post('login')
logIn(@User() user) {
return user;
}
위의 코드는 AuthGuard('local') 확장하여 Passport의 Local 전략을 사용합니다.
Guard 는 데코레이터로 필요한 컨트롤러에서 사용할 수 있습니다.
// back/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({ usernameField: 'email', passwordField: 'password' });
}
async validate(email: string, password: string, done: CallableFunction) {
const user = await this.authService.validateUser(email, password);
if (!user) {
throw new UnauthorizedException();
}
return done(null, user);
}
}
// back/src/auth/auth.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import bcrypt from 'bcrypt';
import { Repository } from 'typeorm';
import { Users } from '../entities/Users';
@Injectable()
export class AuthService {
constructor(
@InjectRepository(Users) private usersRepository: Repository<Users>,
) {}
async validateUser(email: string, password: string) {
const user = await this.usersRepository.findOne({
where: { email },
select: ['id', 'email', 'password'],
});
console.log(email, password, user);
if (!user) {
return null;
}
const result = await bcrypt.compare(password, user.password);
if (result) {
const { password, ...userWithoutPassword } = user;
return userWithoutPassword;
}
return null;
}
}
LocalStrategy 를 통한 로그인 전략입니다.
// back/src/auth/local.serializer.ts
import { Injectable } from '@nestjs/common';
import { PassportSerializer } from '@nestjs/passport';
import { AuthService } from './auth.service';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Users } from 'src/entities/Users';
@Injectable()
export class LocalSerializer extends PassportSerializer {
constructor(
private readonly authService: AuthService,
@InjectRepository(Users) private userRepository: Repository<Users>,
) {
super();
}
serializeUser(user: any, done: Function) {
done(null, user.id);
}
async (userId: string, done: Function) {
return await this.userRepository
.findOneOrFail({ where: { id: +userId }, select: ['id', 'email', 'nickname'], relations: ['Workspaces'] })
.then((user) => {
console.log('user', user);
done(null, user);
})
.catch((error) => done(error));
}
}
serializeUser deserializeUser 작업입니다.
// back/src/auth/logged-in.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Observable } from 'rxjs';
@Injectable()
export class LoggedInGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
const request = context.switchToHttp().getRequest();
return request.isAuthenticated();
}
}
// back/src/auth/not-logged-in.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';
@Injectable()
export class NotLoggedInGuard implements CanActivate {
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const request = context.switchToHttp().getRequest();
return !request.isAuthenticated();
}
}
// back/src/users/users.controller.ts
@UseGuards(new LoggedInGuard())
@Post('logout')
logOut(@Req() req, @Res() res) {
req.logOut();
res.clearCookie('connect.sid', { httpOnly: true });
res.send('ok');
}
로그인 유무를 확인하는 Guard 입니다.
@Injectable()
export class UsersService {
constructor(
@InjectRepository(Users)
private usersRepository: Repository<Users>,
@InjectRepository(WorkspaceMembers)
private workspaceMembersRepository: Repository<WorkspaceMembers>,
@InjectRepository(ChannelMembers)
private channelMembersRepository: Repository<ChannelMembers>,
private dataSource: DataSource,
) {}
getUser() {}
async join(email: string, nickname: string, password: string) {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
const user = await this.usersRepository.findOne({ where: { email } });
if (user) {
throw new Error('이미 존재하는 사용자입니다.');
}
const hashedPassword = await bcrypt.hash(password, 12);
try {
const returned = await queryRunner.manager.getRepository(Users).save({
email,
nickname,
password: hashedPassword,
});
await queryRunner.manager.getRepository(WorkspaceMembers).save({
UserId: returned.id,
WorkspaceId: 1,
});
await queryRunner.manager.getRepository(ChannelMembers).save({
UserId: returned.id,
ChannelId: 1,
});
await queryRunner.commitTransaction();
return true;
} catch (error) {
await queryRunner.rollbackTransaction();
} finally {
await queryRunner.release();
}
}
}
Transaction은 데이터베이스에서 여러 작업을 하나의 “묶음”으로 처리하는 개념입니다.
위의 코드는 유저 생성, 생성된 유저를 워크스페이스, 채널에 넣어주는 과정을 묶음으로 처리하였습니다.
// ParseIntPipe
@Get('/:myId')
getMyWorkspaces(@Param('myId', ParseIntPipe) myId: number) {
this.workspacesService.findMyWorkspaces(myId);
}
// ParseArrayPipe
@Param(new ParseArrayPipe({ items: String, separator: ',' }))
ParseIntPipe ParseArrayPipe 는 NestJS 에서 제공하는 내장 Pipe 중 하나입니다.
요청 데이터를 특정 타입으로 변환하고 유효성 검증합니다. @Param() @Query() @Body() 등에서 자주 사용할 수 있습니다.
async getWorkspaceMembers(url: string) {
this.usersRepository
.createQueryBuilder('user')
.innerJoin('user.WorkspaceMembers', 'member')
.innerJoin('member.Workspace', 'workspace', 'workspace.url = :url', { url: url })
.getMany();
}
createQueryBuilder 는 TypeORM 에서 SQL 을 직접 작성하듯 사용가능합니다. 복잡한 조회, JOIN, 조건, 정렬 등을 유연하게 처리 가능합니다.
$ npm i --save @nestjs/websockets @nestjs/platform-socket.io
NestJs 에서 양방향 소통을 위한 WebSocket 을 구현하기 위해서는 Gateway 를 사용합니다.
// back/src/events/events.gateway.ts
import {
ConnectedSocket,
MessageBody,
OnGatewayConnection,
OnGatewayDisconnect,
OnGatewayInit,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { onlineMap } from './onlineMap';
@WebSocketGateway({ namespace: /\/ws-.+/ })
export class EventsGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer() public server: Server;
@SubscribeMessage('test')
handleTest(@MessageBody() data: string) {
console.log(data);
}
@SubscribeMessage('login')
handleLogin(@MessageBody() data: { id: number; channels: number[] }, @ConnectedSocket() socket: Socket) {
const newNamespace = socket.nsp;
console.log('login', newNamespace);
onlineMap[socket.nsp.name][socket.id] = data.id;
newNamespace.emit('onLineList', Object.values(onlineMap[socket.nsp.name]));
data.channels.forEach((channel) => {
console.log('join', socket.nsp.name, channel);
socket.join(`${socket.nsp.name}-${channel}`);
});
}
afterInit(server: Server) {}
handleConnection(@ConnectedSocket() socket: Socket) {
if (!onlineMap[socket.nsp.name]) {
onlineMap[socket.nsp.name] = {};
}
socket.emit('hello', socket.nsp.name);
}
handleDisconnect(@ConnectedSocket() socket: Socket) {
console.log('disconnected', socket.nsp.name);
const newNamespace = socket.nsp;
delete onlineMap[socket.nsp.name][socket.id];
newNamespace.emit('onLineList', Object.values(onlineMap[socket.nsp.name]));
}
}
// back/src/events/events.module.ts
import { Module } from '@nestjs/common';
import { EventsGateway } from './events.gateway';
@Module({
providers: [EventsGateway],
exports: [EventsGateway],
})
export class EventsModule {}
새로운 모듈을 생성 후 Gateway 를 생성하였습니다. 해당 Gateway 를 다른 모듈의 서비스에서 사용하기 위해 모듈의 exports 에 추가해주어야 합니다.
Gateway 를 통해 구현한 WebSocket 은 express 내에서의 사용과 크게 다르지 않습니다. NestJs 가 추국하는 구조적이고 명확한 구분을 위해 모듈화 시킨 것이 큰 특징입니다.
// back/src/channels/channels.module.ts
@Module({
imports: [TypeOrmModule.forFeature([Channels, ChannelChats, Users, Workspaces, ChannelMembers]), EventsModule],
controllers: [ChannelsController],
providers: [ChannelsService],
})
export class ChannelsModule {}
// back/src/channels/channels.service.ts
export class ChannelsService {
constructor(
private eventsGateway: EventsGateway,
) {}
}
다른 모듈의 서비스에서 사용 시 Gateway 가 아닌 모듈을 imports 해주어야 합니다. Gateway 를 providers 에 넣으면 소켓이 중복 생성되어버립니다.
npm i -D @types/multer
NestJs 는 multer를 기반으로 하는 내장 모듈을 제공합니다.
// back/src/channels/channels.controller.ts
try {
fs.readdirSync('uploads');
} catch (error) {
console.error('uploads 폴더가 없어 uploads 폴더를 생성합니다.');
fs.mkdirSync('uploads');
}
@ApiTags('CHANNEL')
@Controller('api/workapces/:url/channels')
export class ChannelsController {
constructor(private channelsService: ChannelsService) {}
@UseInterceptors(
FilesInterceptor('image', 10, {
storage: multer.diskStorage({
destination(req, file, cb) {
cb(null, 'uploads/');
},
filename(req, file, cb) {
const ext = path.extname(file.originalname);
cb(null, path.basename(file.originalname, ext) + Date.now() + ext);
},
}),
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
}),
)
@Post(':name/images')
postImage(
@UploadedFiles() files: Express.Multer.File[],
@Param('url') url: string,
@Param('name') name: string,
@User() user,
) {
this.channelsService.createWorkspaceChannelImages(url, name, files, user);
}
}
UseInterceptors 데코레이터와 FilesInterceptor 를 이용하여 파일 업로드 기능을 인터셉터 기능으로 추가합니다.
npm install --save @nestjs/serve-static
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
@Module({
imports: [
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', 'client'),
}),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
NestJs 에서 정적 파일을 사용하기 위해 패키지를 제공합니다.
// back/src/main.ts
app.useStaticAssets(
process.env.NODE_ENV === 'production'
? path.join(__dirname, '..', '..', 'uploads')
: path.join(__dirname, '..', 'uploads'),
{
prefix: '/uploads',
},
);
app.useStaticAssets(
process.env.NODE_ENV === 'production'
? path.join(__dirname, '..', '..', 'public')
: path.join(__dirname, '..', 'public'),
{
prefix: '/dist',
},
);
또는 main.ts 내에서 express 처럼 설정할 수도 있습니다.
const app = await NestFactory.create(AppModule);
app.enableCors();
await app.listen(process.env.PORT ?? 3000);
enableCors() 를 사용하여 cors 처리가 가능합니다.
import { Test, TestingModule } from '@nestjs/testing';
import { UsersService } from './users.service';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Users } from 'src/entities/Users';
class MockUserRepository {
#data = [{ id: 1, email: 'test@test.com' }];
findOne({ email }) {
const data = this.#data.find((value) => value.email === email);
if (data) {
return data;
}
return null;
}
}
describe('UsersService', () => {
let service: UsersService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [UsersService, { provide: getRepositoryToken(Users), useClass: MockUserRepository }],
}).compile();
service = module.get<UsersService>(UsersService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('findByEmail은 이메일을 통해 유저를 찾아야 함', () => {
expect(service.findByEmail('test@test.com')).resolves.toStrictEqual({ email: 'test@test.com', id: 1 });
});
it('findByEmail은 유저를 못 찾으면 null을 반환해야 함', () => {
expect(service.findByEmail('test@tes.com')).resolves.toBe(null);
});
});
테스트 진행 시 Repository 를 모킹하여 사용할 수 있습니다.
e2e 테스트를 통해 앱 전체를 테스트 할 수 있는데 이때는 테스트용 DB를 생성하여 테스트를 추천합니다.
express를 오래전에 한번 사용한 경험이 있어 조금 이해는 되었지만 역시 직접 구현해보아야 NestJs를 십분 이해할 수 있을 것 같습니다. 무엇보다 모듈을 통한 구조로 이루어진 NestJs의 장정을 잘 이용하려면 NestJs가 제공하는 클래스 및 데코레이터에 대한 이해가 필요하다고 느꼈습니다.