[NestJS] 라우트 보호와 게시물 소유권
5편에서 로그인 시 액세스 토큰을 발급하는 데까지 완성했다. 이번 편에서는 그 토큰을 실제로 활용한다. 보호가 필요한 라우트에 AuthGuard를 걸어 토큰 없이는 접근하지 못하게 하고, 인증된 사용자 정보를 @GetUser 커스텀 데코레이터로 꺼낸다. 마지막으로 User와 Board를 관계로 묶어 "내가 쓴 게시물만" 다루도록 만든다.
1. AuthGuard 소개
AuthGuard()는 @nestjs/passport 패키지가 제공하는 Guard 팩토리 함수다. 인자 없이 호출하면 PassportModule.register({ defaultStrategy: 'jwt' })에 등록된 전략을 자동으로 사용한다. @UseGuards(AuthGuard())를 붙이면 컨트롤러 핸들러가 실행되기 전에 Guard가 먼저 개입해 토큰의 유효성을 검증한다.
Guard는 NestJS 실행 파이프라인에서 미들웨어 이후, 인터셉터 이전에 동작한다. Guard가 false를 반환하거나 예외를 던지면 요청은 핸들러에 도달하지 못하고 즉시 401 Unauthorized로 응답된다.
AuthGuard('jwt')처럼 전략명을 명시할 수도 있다. 인자를 생략하면defaultStrategy를 참조한다. 이 프로젝트에서는 PassportModule에defaultStrategy: 'jwt'를 설정했기 때문에AuthGuard()만으로 JWT 검증이 동작한다.
컨트롤러 레벨 vs 라우트 레벨 가드
Guard는 메서드 단위 또는 컨트롤러 클래스 단위 모두에 적용할 수 있다.
| 적용 위치 | 코드 | 보호 범위 |
|---|---|---|
| 라우트 레벨 | @Get() @UseGuards(AuthGuard()) | 해당 메서드만 보호. 나머지 라우트는 인증 없이 접근 가능. |
| 컨트롤러 레벨 | @Controller('boards') @UseGuards(AuthGuard()) | 컨트롤러의 모든 라우트가 자동으로 보호. 중복 선언 불필요. |
이 프로젝트의 BoardsController는 모든 라우트에 인증이 필요하므로 컨트롤러 레벨에 Guard를 적용한다.
2. BoardsController에 AuthGuard 적용
@UseGuards(AuthGuard())를 클래스 레벨에 붙이고, 각 핸들러 파라미터에 @GetUser()를 사용해 인증된 User 객체를 직접 받는다.
// boards/boards.controller.ts
import { Controller, Get, Post, Delete,
Body, Param, Logger, ParseIntPipe,
UsePipes, ValidationPipe, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { GetUser } from '../auth/get-user.decorator';
import { User } from '../auth/user.entity';
@Controller('boards')
@UseGuards(AuthGuard())
export class BoardsController {
private logger = new Logger('BoardsController');
constructor(private boardsService: BoardsService) {}
@Get()
getAllBoards(@GetUser() user: User): Promise<Board[]> {
this.logger.verbose(`User "${user.username}" retrieving all boards`);
return this.boardsService.getAllBoards(user);
}
@Post()
@UsePipes(ValidationPipe)
createBoard(
@Body() createBoardDto: CreateBoardDto,
@GetUser() user: User,
): Promise<Board> {
this.logger.verbose(
`User "${user.username}" creating a new board. Data: ${JSON.stringify(createBoardDto)}`,
);
return this.boardsService.createBoard(createBoardDto, user);
}
@Delete('/:id')
deleteBoard(
@Param('id', ParseIntPipe) id: number,
@GetUser() user: User,
): Promise<void> {
return this.boardsService.deleteBoard(id, user);
}
}
@GetUser() user: User 파라미터가 추가되었다. Guard가 설정한 req.user를 커스텀 데코레이터가 꺼내 주입한다. 서비스 메서드에 user를 전달해 게시물 생성 시 작성자를 기록하거나, 조회·삭제 시 본인 게시물인지 확인하는 로직으로 이어진다.
3. @GetUser 커스텀 데코레이터
createParamDecorator는 파라미터 수준에서 동작하는 데코레이터 팩토리다. 핸들러 파라미터에 붙이면 NestJS가 팩토리 콜백을 호출해 반환값을 해당 파라미터에 자동으로 주입한다.
// auth/get-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { User } from './user.entity';
export const GetUser = createParamDecorator(
(data, ctx: ExecutionContext): User => {
const req = ctx.switchToHttp().getRequest();
return req.user;
},
);
구성 요소 설명
| 요소 | 설명 |
|---|---|
| createParamDecorator | 파라미터 수준 데코레이터 팩토리. 콜백의 반환값이 파라미터에 바인딩된다. |
| data | 데코레이터 호출 시 전달하는 인자. @GetUser('username')처럼 특정 필드명을 넘기면 data에 'username'이 담긴다. |
| ExecutionContext | 현재 실행 컨텍스트를 감싸는 래퍼. HTTP, WebSocket, gRPC 등 다양한 전송 계층에서 공통으로 쓰인다. |
| ctx.switchToHttp().getRequest() | ExecutionContext에서 HTTP 전송 계층으로 전환하고 Express의 Request 객체를 꺼낸다. |
| req.user | Passport가 JwtStrategy.validate() 반환값을 자동으로 주입하는 필드. Guard가 통과된 후에만 존재한다. |
Passport가 req.user를 설정하는 원리
5편에서 만든 JwtStrategy.validate()가 반환하는 값을 Passport가 자동으로 req.user에 할당한다. 이것이 Guard와 커스텀 데코레이터가 연동되는 핵심 연결 고리다.
// auth/jwt.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
@InjectRepository(User) private userRepository: Repository<User>,
private configService: ConfigService,
) {
super({
secretOrKey: configService.getOrThrow('JWT_SECRET'),
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
});
}
async validate(payload: { username: string }): Promise<User> {
const { username } = payload;
const user = await this.userRepository.findOneBy({ username });
if (!user) {
throw new UnauthorizedException();
}
return user; // 이 반환값이 req.user에 자동 할당됨
}
}
validate()가 User 객체를 반환하면 Passport 인터널이 해당 객체를 req.user에 세팅한다. validate()가 예외를 던지거나 null을 반환하면 Guard는 요청을 차단하고 401을 응답한다.
4. AuthModule과 BoardsModule 연결
BoardsController에서 AuthGuard()를 사용하려면 PassportModule이 제공하는 인프라가 BoardsModule 스코프에서도 사용 가능해야 한다. 다른 모듈의 프로바이더를 사용하려면 해당 모듈을 imports에 추가한다.
// boards/boards.module.ts
@Module({
imports: [TypeOrmModule.forFeature([Board]), AuthModule],
controllers: [BoardsController],
providers: [BoardsService],
})
export class BoardsModule {}
AuthModule exports 설정
BoardsModule이 AuthModule을 import할 때 실제로 사용 가능한 것은 AuthModule.exports에 명시된 항목뿐이다.
// auth/auth.module.ts
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({ ... }),
TypeOrmModule.forFeature([User]),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [JwtStrategy, PassportModule],
})
export class AuthModule {}
- PassportModule:
AuthGuard()가 내부적으로 PassportModule의 설정을 참조한다. exports에 없으면BoardsModule에서AuthGuard()가 동작하지 않는다. - JwtStrategy: Guard가 실행할 전략 클래스다. exports에 포함해야
BoardsModule스코프에서도 DI 컨테이너가 전략을 찾을 수 있다.
exports 누락 시 발생하는 문제
providers에만 있고exports에 없으면 보호된 라우트를 처음 호출할 때Unknown authentication strategy "jwt"류의 에러가 발생한다. 서버 기동 시점이 아니라 런타임에 터지기 때문에 놓치기 쉽다.AuthModule을 import하는 모든 모듈에서AuthGuard를 쓸 계획이라면 처음부터 exports를 추가하는 것이 안전하다.
5. User-Board 관계 설정
이제 게시물에 "작성자"를 붙인다. 하나의 User는 여러 Board를 가질 수 있으므로 User쪽에는 @OneToMany, Board쪽에는 @ManyToOne을 붙인다.
board.entity.ts
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { BoardStatus } from './board-status.enum';
import { User } from '../auth/user.entity';
@Entity()
export class Board {
@PrimaryGeneratedColumn()
id: number;
@Column()
title: string;
@Column()
description: string;
@Column('varchar')
status: BoardStatus;
@ManyToOne(() => User, (user) => user.boards, { eager: false })
user: User;
}
user.entity.ts
import { Column, Entity, OneToMany, PrimaryGeneratedColumn, Unique } from 'typeorm';
import { Board } from '../boards/board.entity';
@Entity()
@Unique(['username'])
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
username: string;
@Column()
password: string;
@OneToMany(() => Board, (board) => board.user, { eager: true })
boards: Board[];
}
관계 데코레이터 구조
@ManyToOne(() => User, (user) => user.boards)에서 첫 번째 인자는 대상 엔티티를 반환하는 함수, 두 번째 인자는 대상 엔티티에서 역방향 관계 프로퍼티를 가리키는 함수다. TypeORM이 두 엔티티의 관계를 연결해 boards 테이블에 외래 키(userId)를 자동으로 생성한다.
@OneToMany는 단독으로 존재할 수 없다. 반드시 반대편 엔티티에 @ManyToOne이 있어야 하며, 외래 키는 항상 @ManyToOne이 선언된 테이블(Board)에 생성된다.
eager 로딩
eager 옵션은 해당 엔티티를 조회할 때 연관 데이터를 자동으로 함께 불러올지 결정한다.
| 설정 | 위치 | 동작 |
|---|---|---|
| eager: true | User → boards | User를 조회하면 해당 User의 Board 목록이 자동으로 함께 로드된다. |
| eager: false | Board → user | Board를 조회해도 user 정보는 자동으로 로드되지 않는다. 명시적으로 relations 옵션 또는 leftJoinAndSelect를 지정해야 조회할 수 있다. |
무한 순환 방지
Board에도eager: true를 설정하면 User 조회 시 boards가 로드되고, 그 board마다 다시 user가 로드되는 순환이 발생한다. Board쪽은 반드시eager: false로 유지해야 한다.
6. 게시물 생성 시 작성자 연결
게시물을 생성할 때 로그인한 유저 정보를 함께 저장한다. Guard를 통해 컨트롤러에서 추출한 user 객체를 서비스까지 전달한다.
// boards/boards.service.ts
async createBoard(createBoardDto: CreateBoardDto, user: User): Promise<Board> {
const { title, description } = createBoardDto;
const board = this.boardRepository.create({
title,
description,
status: BoardStatus.PUBLIC,
user,
});
await this.boardRepository.save(board);
return board;
}
create()로 Board 객체를 생성할 때 user 필드에 User 엔티티를 직접 할당하면, TypeORM이 save() 시점에 boards 테이블의 userId 컬럼에 해당 User의 id를 자동으로 저장한다. 컨트롤러에서는 @GetUser() user: User로 인증된 유저를 추출해 서비스에 넘긴다.
7. 본인 게시물만 조회 — QueryBuilder
전체 게시물 목록 조회 시 현재 로그인한 유저의 게시물만 반환하도록 QueryBuilder로 WHERE 조건을 추가한다.
// boards/boards.service.ts
async getAllBoards(user: User): Promise<Board[]> {
const query = this.boardRepository.createQueryBuilder('board');
query.where('board.userId = :userId', { userId: user.id });
return query.getMany();
}
| 코드 | 설명 |
|---|---|
| createQueryBuilder('board') | Board 레포지토리에서 QueryBuilder를 생성하고 별칭(alias)을 board로 지정한다. |
| .where('board.userId = :userId', { userId: user.id }) | :userId는 파라미터 바인딩이다. SQL 인젝션을 방지하며 두 번째 인자 객체의 값으로 치환된다. |
| .getMany() | 조건에 맞는 Board 엔티티 배열을 반환한다. 단건 조회는 getOne()을 사용한다. |
용어: QueryBuilder
TypeORM에서 복잡한 쿼리를 타입 안전하게 조립할 수 있는 빌더 패턴 API다. 단순 CRUD는find(),findOne()으로 처리하고, JOIN이나 복잡한 WHERE 조건이 필요할 때 QueryBuilder를 사용한다.
8. 소유자만 삭제 가능
삭제 요청에 id뿐만 아니라 user 조건을 함께 전달해 본인 소유의 게시물만 삭제되도록 제한한다.
// boards/boards.service.ts
async deleteBoard(id: number, user: User): Promise<void> {
const result = await this.boardRepository.delete({ id, user });
if (result.affected === 0) {
throw new NotFoundException(`Can't find Board with id ${id}`);
}
}
| 코드 | 설명 |
|---|---|
| delete({ id, user }) | TypeORM이 내부적으로 WHERE id = ? AND userId = ? 조건을 생성한다. id가 맞아도 userId가 다르면 삭제되지 않는다. |
| result.affected === 0 | 삭제된 행이 0개라는 의미다. 게시물이 아예 없거나, 있더라도 본인 소유가 아닌 경우 이 조건에 해당한다. |
| NotFoundException | NestJS 내장 예외 클래스다. HTTP 404 응답을 자동으로 반환한다. 소유권 검증과 존재 여부 검증을 하나의 분기로 처리한다. |
보안 설계 관점
타인의 게시물 삭제 시도를 "권한 없음(403)"이 아닌 "없음(404)"으로 응답하는 것은 의도된 선택이다. 403으로 응답하면 해당 id의 게시물이 존재한다는 정보가 노출된다. 404로 통일하면 리소스 존재 여부를 감출 수 있다.
9. 전체 데이터 흐름
인증된 요청이 들어왔을 때 User 정보가 각 레이어를 거쳐 처리되는 흐름이다.
HTTP 요청 (Authorization: Bearer <token>)
|
v
@UseGuards(AuthGuard()) // 토큰 검증 위임
|
v
JwtStrategy.validate(payload) // username으로 User 조회
|
v
req.user = validate() 반환값 // Passport가 자동 주입
|
v
핸들러 실행 + @GetUser() user // req.user 추출
|
v
boardsService.createBoard(dto, user) / getAllBoards(user) / deleteBoard(id, user)
|
v
TypeORM: boards.userId (FK)에 작성자 연결 / 조건 필터링
| 레이어 | 역할 | 핵심 코드 |
|---|---|---|
| Guard | JWT 토큰 검증 후 user 객체를 request에 첨부한다. | @UseGuards(AuthGuard()) |
| Controller | 커스텀 데코레이터로 user를 추출해 서비스에 전달한다. | @GetUser() user: User |
| Service | 비즈니스 로직에서 user를 활용해 생성·조회·삭제한다. | createBoard(dto, user) |
| TypeORM | User 엔티티의 id를 boards 테이블 외래 키에 저장하고 조건 필터링한다. | userId (FK) |
10. 핵심 키워드
| 키워드 | 설명 |
|---|---|
| AuthGuard() | @nestjs/passport의 Guard 팩토리. 인자 없이 호출하면 defaultStrategy를 사용하고, 토큰 검증 실패 시 401을 자동 응답한다. |
| @UseGuards() | Guard를 적용하는 데코레이터. 클래스에 붙이면 모든 핸들러에 일괄 적용된다. |
| createParamDecorator | 파라미터 수준 커스텀 데코레이터 팩토리. 콜백 반환값이 파라미터에 주입된다. @GetUser()가 이 방식으로 만들어졌다. |
| req.user | Passport가 validate() 반환값을 자동 할당하는 Request 필드. @GetUser()가 이를 꺼낸다. |
| @ManyToOne / @OneToMany | 다대일/일대다 관계. 외래 키(userId)는 항상 @ManyToOne이 선언된 Board 테이블에 생성된다. 둘은 쌍으로 선언한다. |
| eager | true면 조회 시 연관 데이터를 자동 JOIN한다. 양쪽 모두 true면 무한 순환이 발생한다. |
| createQueryBuilder | 복잡한 WHERE/JOIN을 타입 안전하게 조립하는 API. :param 바인딩으로 SQL 인젝션을 방지한다. |
| result.affected | delete()/update() 반환값의 영향 행 수. 0이면 조건에 맞는 행이 없었다는 의미다. |
11. 정리
이번 편에서 인증과 데이터를 연결했다. AuthGuard()로 라우트를 보호하고, @GetUser()로 인증 사용자를 꺼내며, User-Board 관계를 통해 "누가 쓴 글인지"를 DB에 기록했다. 그 결과 본인 게시물만 조회하고 삭제하는 기본적인 인가(authorization)까지 완성됐다.
핵심 연결 고리는 JwtStrategy.validate()의 반환값이 req.user가 되고, 그것이 @GetUser()를 거쳐 서비스로, 다시 TypeORM 관계를 통해 userId 외래 키로 이어지는 한 줄기 흐름이라는 점이다.