Search

14. 기본 사용 권한 시스템

관리자 승인 기능 구현하기

이제 사용자가 제출한 보고서를 관리자가 승인하거나 거부하는 기능을 구현해보자.
기능에 대한 요구사항은 다음과 같다.
사용자가 제출한 자동차 견적 보고서는 기본적으로 미승인 상태로 생성된다.
관리자는 각 보고서를 검토 후 승인 또는 거부할 수 있다.
악의적인 사용자가 부정확한 가격을 입력해 견적을 조작하는 것을 방지하기 위해 도입되었다.
먼저 보고서의 승인 상태를 나타낼 수 있도록 report.entity.ts 파일에 다음과 같은 속성을 추가한다.
@Column({ default: false }) approved: boolean;
TypeScript
복사
@Column({ default: false })설정을 통해 보고서가 생성될 때 approved 값은 자동으로 false가 된다.
이제 보고서의 승인 여부를 전달받을 DTO를 만든다.
// dtos/approve-report.dto.ts import { IsBoolean } from 'class-validator'; export class ApproveReportDto { @IsBoolean() approved: boolean; }
TypeScript
복사
@IsBoolean 데코레이터는 클라이언트로부터 받은 approved 값이 true/false인지 검증한다.
이후 컨트롤러에 PATCH 라우터를 추가하여 특정 보고서를 승인하거나 승인 취소할 수 있도록 한다.
이때 @Param으로 URL의 ID 파라미터를 가져오고, @Body로 클라이언트가 보낸 본문 데이터를 가져온다.
// reports.controller.ts import { Patch, Param, Body } from '@nestjs/common'; import { ApproveReportDto } from './dtos/approve-report.dto'; @Patch('/:id') approveReport(@Param('id') id: string, @Body() body: ApproveReportDto) { return this.reportsService.changeApproval(id, body.approved); }
TypeScript
복사
서비스 로직에서는 해당 ID의 보고서를 찾아 approved 속성을 수정한 뒤 저장하는 로직을 작성한다.
이때, 존재하지 않는 보고서에 대해선 예외를 던진다.
// reports.service.ts import { NotFoundException } from '@nestjs/common'; async changeApproval(id: string, approved: boolean) { const report = await this.repo.findOne({ where: { id: parseInt(id) } }); if (!report) { throw new NotFoundException('report not found'); } report.approved = approved; return this.repo.save(report); }
TypeScript
복사
마지막으로 보고서 생성 또는 조회 시 approved 상태가 포함되도록 응답 DTO에 필드를 추가한다.
// report.dto.ts export class ReportDto { // ... approved: boolean; }
TypeScript
복사
이제 POST /reports로 보고서를 생성한 다음 PATCH /reports/:id로 요청하면 다음과 같이 approved를 true 또는 false로 변경하는걸 볼 수 있다.
{ "id": 7, "approved": true, "price": 500000, "make": "toyota", "model": "corolla", "year": 1980, "lng": 0, "lat": 0, "mileage": 100000 }
JSON
복사

관리자 인가(Authorization) 기능 구현하기

지금까지 우리는 AuthGuard를 이용해 사용자의 ID를 조회해서 필요한 작업들을 수행했다. 하지만 보고서 승인 기능은 사용자가 누구인지 확인하는 인증(Authentication) 만으로는 부족하다. 보고서를 승인하는 기능은 오직 관리자만이 가능하다. 즉, 어떤 사용자가 특정 작업을 할 수 있는 권한이 있는지까지 확인해야 할 필요가 생겼다. 이 개념이 바로 인가(Authorization)이다.
인증 vs 인가
인증 (Authentication)
→ 이 요청을 보낸 사람이 누구인지 확인
→ 예: 로그인 여부 확인
인가 (Authorization)
→ 이 사용자가 특정 작업을 할 권한이 있는지 확인
→ 예: 관리자만 접근 가능한 기능
지금까지 우리가 인증과 관련해서 구현했던 기능들을 복습해보자.
우리는 CurrentUserInterceptor로 요청의 쿠키를 읽어 현재 사용자를 request.currentUser에 할당했다.
// users/interceptors/current-user.interceptor.ts export class CurrentUserInterceptor implements NestInterceptor { constructor(private readonly userService: UsersService) {} async intercept(context: ExecutionContext, handler: CallHandler<any>) { const request = context.switchToHttp().getRequest(); const { userId } = request.session || {}; if (userId) { const user = await this.userService.findOne(userId); request.currentUser = user; } return handler.handle(); } }
TypeScript
복사
그리고 라우트 핸들러에서 사용자가 로그인 상태인지 확인해야 하는 경우 AuthGuard를 사용해 인증 여부를 확인했다.
export class AuthGuard implements CanActivate { async canActivate(context: ExecutionContext): Promise<boolean> { const request = context.switchToHttp().getRequest(); const userId = request.session?.userId; return !!userId; } }
TypeScript
복사
그렇다면 AuthGuard와 유사한 AdminGuard를 통해 이 문제를 해결할 수 있지 않을까?
먼저 users 폴더 안에 있는 user.entity.ts 파일을 열어서 admin 속성을 추가한다.
@Column({ default: true }) // ⚠️ 테스트용 설정! admin: boolean;
TypeScript
복사
현재는 모든 사용자가 기본적으로 관리자가 되도록 설정했다. 이건 단지 테스트를 빠르게 진행하기 위한 임시 설정이다. 실제 운영 환경에서는 기본값을 false로 하고, 관리자 여부를 명시적으로 설정하는 방식으로 바꿔야 한다.
이제 guards 폴더로 이동해서 admin.guard.ts 파일을 생성한다.
AdminGuard는 기존에 만들었던 AuthGuard와 매우 비슷한 구조를 가진다.
여기서 핵심은 현재 로그인한 사용자가 관리자인지 확인하는 것이다.
// guards/admin.guard.ts import { CanActivate, ExecutionContext } from '@nestjs/common'; export class AdminGuard implements CanActivate { canActivate(context: ExecutionContext) { const request = context.switchToHttp().getRequest(); if (!request.currentUser) { return false; } return request.currentUser.admin; } }
TypeScript
복사
이제 라우트 핸들러에 AdminGuard를 추가해서 테스트를 진행해보자.
// reports.controller.ts @Patch('/:id') @UseGuards(AdminGuard) approveReport(@Param('id') id: string, @Body() body: ApproveReportDto) { return this.reportsService.changeApproval(id, body.approved); }
TypeScript
복사
하지만 예상과 다르게 테스트를 진행하면 403 에러가 발생한다. 왜 그럴까?
{ "statusCode": 403, "message": "Forbidden resource", "error": "Forbidden" }
TypeScript
복사
문제의 원인은 NestJS의 실행 순서에 있다.
NestJS에서 요청은 위 그림과 같은 순서로 실행된다.
현재 구조는 다음과 같다.
cookie-session 미들웨어 → 세션 추가
AdminGuard → 요청 차단 여부 판단
CurrentUserInterceptor → currentUser 설정
즉, AdminGuard가 실행되는 시점에는 아직 CurrentUser가 설정되지 않았다.
그래서 request.currentUser는 항상 undefined인 상태였고, 가드는 요청을 차단해버렸던 것이다.
즉, 인터셉터는 가드보다 뒤에 실행되기 때문에, currentUser 정보를 가드보다 먼저 설정하려면 인터셉터가 아닌 미들웨어로 만들어야 한다.

CurrentUser 인터셉터를 미들웨어로 리팩토링하기

먼저 users 디렉터리 안에 middlewares 폴더를 만들고, 미들웨어 파일을 생성한다.
// users/middlewares/current-user.middleware.ts import { Injectable, NestMiddleware } from '@nestjs/common'; import { NextFunction, Request, Response } from 'express'; import { UsersService } from '../users.service'; @Injectable() export class CurrentUserMiddleware implements NestMiddleware { constructor(private usersService: UsersService) {} async use(req: Request, res: Response, next: NextFunction) { const { userId } = req.session || {}; if (userId) { const user = await this.usersService.findOne(userId); // @ts-ignore req.currentUser = user; } next(); } }
TypeScript
복사
 @ts-ignore는 req.currentUser 속성이 Request 타입에 정의되어 있지 않기 때문에 발생하는 TypeScript 오류를 무시하기 위해 사용했다. 나중에 타입 선언을 확장해 수정할 예정이므로 임시로 달아두자.
이제 users 모듈에 전역 미들웨어를 적용한다.
그리고 미들웨어가 완전히 대체하게 되었으니, 기존 CurrentUserInterceptor 관련 코드는 삭제해도 된다.
// users.module.ts import { MiddlewareConsumer, Module } from '@nestjs/common'; import { CurrentUserMiddleware } from './middlewares/current-user.middleware'; @Module({ // ... }) export class UsersModule { configure(consumer: MiddlewareConsumer) { consumer .apply(CurrentUserMiddleware) .forRoutes('*'); // 모든 라우트에 적용 } }
TypeScript
복사
다시 보고서 승인 테스트를 해보면 403 에러가 발생하지 않고 정상적으로 인증/인가가 작동하는 것을 볼 수 있다.
마지막으로 Express.Request 인터페이스를 확장해서 currentUser를 명시적으로 선언해줌으로써 TypeScript가 더 이상 오류를 발생시키지 않게 리팩토링 해보자.
// current-user.middleware.ts declare global { namespace Express { interface Request { currentUser?: User; } } } @Injectable() export class CurrentUserMiddleware implements NestMiddleware { constructor(private usersService: UsersService) {} async use(req: Request, res: Response, next: NextFunction) { ... } }
TypeScript
복사

자동차 견적 조회 기능 구현하기

이제 GET /reports 라우트를 만들어 사용자가 자동차 정보를 입력하면 해당 차량의 견적을 반환하는 API를 만드는 과정을 설명한다. 쿼리 문자열로 자동차 정보를 입력받고, 이를 DTO를 이용해 검증한다.
우선, 사용자의 쿼리 문자열 입력을 검증할 DTO를 만들어야 한다. reports/dtos 디렉토리에 get-estimate.dto.ts 파일을 생성한다. 이 파일은 create-report.dto.ts와 매우 유사한 구조를 갖지만, 가격(price)는 입력받지 않으므로 제거한다.
// reports/dtos/get-estimate.dto.ts import { IsLatitude, IsLongitude, IsNumber, IsString, Max, Min, } from 'class-validator'; export class GetEstimateDto { @IsString() make: string; @IsString() model: string; @IsNumber() @Min(1930) @Max(2050) year: number; @IsNumber() @Min(0) @Max(1000000) mileage: number; @IsLongitude() lng: number; @IsLatitude() lat: number; }
TypeScript
복사
다음으로 ReportsController에서 쿼리 문자열을 처리할 GET 핸들러를 추가한다.
// reports.controller.ts @Get() getEstimate(@Query() query: GetEstimateDto) {}
TypeScript
복사
@Query() 데코레이터를 통해 쿼리 문자열을 GetEstimateDto로 매핑한다.
DTO의 유효성 검사가 잘 되는지 테스트 해보자.
자동차 정보를 포함한 GET 요청을 다음과 같이 전송해보면 오류가 발생한다. 왜 그럴까?
GET http://localhost:3000/reports?make=toyota&model=corolla&lng=0&lat=0&mileage=20000&year=1980
JSON
복사
{ "statusCode": 400, "message": [ "year must not be greater than 2050", "year must not be less than 1930", "year must be a number conforming to the specified constraints", "mileage must not be greater than 1000000", "mileage must not be less than 0", "mileage must be a number conforming to the specified constraints" ], "error": "Bad Request" }
JSON
복사
그 이유는 바로 GET 요청의 쿼리 문자열은 항상 문자열(string)로 들어오기 때문이다. NestJS는 이를 자동으로 숫자로 파싱하지 않기 때문에, @IsNumber() 등의 검증을 통과하지 못한다.
요청 위치
들어오는 타입
숫자로 인식됨?
Body
JSON
Query
string
아님
Param
string
아님
Body와 다르게 Query와 Param은 모두 문자열로 들어오기 때문에 다른 타입의 값으로 사용하려면 별도의 변환 과정이 필요하다.
이 문제를 해결하기 위해 class-transformer의 @Transform() 데코레이터를 다시 활용해보자.
// get-estimate.dto.ts import { Transform } from 'class-transformer'; import { IsNumber } from 'class-validator'; export class GetEstimateDto { @Transform(({ value }) => parseInt(value)) // 문자열 → 정수 @IsNumber() @Min(1930) @Max(2050) year: number; @Transform(({ value }) => parseInt(value)) @IsNumber() @Min(0) @Max(1000000) mileage: number; @Transform(({ value }) => parseFloat(value)) // 문자열 → 소수 @IsLongitude() lng: number; @Transform(({ value }) => parseFloat(value)) @IsLatitude() lat: number; }
TypeScript
복사
이번엔 obj가 아닌 value를 사용해서 변환을 수행했는데, 그 이유는 변환 대상이 되는 특정 속성 하나의 값만 다루기 때문이다. parseInt()는 문자열을 정수로 변환하고, parseFloat()는 소수로 변환한다.
테스트를 위해 컨트롤러에 로그를 찍어서 요청을 보내보자.
// reports.controller.ts @Get() getEstimate(@Query() query: GetEstimateDto) { console.log(query); }
TypeScript
복사
다시 핸들러에 요청을 보내면 다음과 같이 성공적으로 로그가 찍히는 모습을 볼 수 있다.
{ make: 'toyota', model: 'corolla', lng: 0, lat: 0, mileage: 20000, year: 1980 }
TypeScript
복사