[NestJS 강의] Slack 클론 코딩[백엔드 with NestJS + TypeORM] 섹션 4, 5

강경서·2025년 9월 21일
post-thumbnail

📟 본격 슬랙 백엔드 만들기

로그인

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 입니다.


TypeORM transaction

  
@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, ParseArrayPipe, PickType

// 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() 등에서 자주 사용할 수 있습니다.


createQueryBuilder

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, 조건, 정렬 등을 유연하게 처리 가능합니다.


WebSocket

$ 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 에 넣으면 소켓이 중복 생성되어버립니다.


multer, static, cors

multer

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 를 이용하여 파일 업로드 기능을 인터셉터 기능으로 추가합니다.


static

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 처럼 설정할 수도 있습니다.


cors

const app = await NestFactory.create(AppModule);
app.enableCors();
await app.listen(process.env.PORT ?? 3000);

enableCors() 를 사용하여 cors 처리가 가능합니다.


Test

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가 제공하는 클래스 및 데코레이터에 대한 이해가 필요하다고 느꼈습니다.


🧾 Reference

profile
기록하고 배우고 시도하고

0개의 댓글