시작 하며...
API 서버에서 권한 관리는 매우 중요하다.
특히나 요구사항이 복잡해지고 서비스가 커질수록 사용자의 Role은 더욱 세분화되고 그에 따라 권한 역시 상세하게 세분화 된다. (최근에 지인이 개발중인 프로덕트에는 권한의 종류가 100종류가 넘는다고 한다...)

NestJS Guards는 NestJS의 Request Lifecycle에 속하는 레이어중 하나이다.
인증, 인가와 같이 권한에 따른 특정 API 요청의 승인 혹은 거절을 담당하기 위해 사용될 수 있다.
본 게시글에서는 "이러한 Guards가 무엇인가?" 는 다루지 않고, 단순히 개발 도중에 Nest JS의 Execution Context와 Guards를 활용한 사례에 대해서 기록하는 목적임을 미리 알려 드립니다. 혹여나 Guards가 뭔지 아직 잘 모르시는 분들은 여기로...
간단한 요구사항

🚨 게시판 서비스 요구사항 전달 드립니다~ 🚨
- 사용자는 ID, Email, Phone, PW를 사용해 회원가입 할 수 있다.
- 사용자는 ID, PW를 통해서 로그인할 수 있다.
- 사용자는 게시글을 작성, 조회, 수정 및 삭제 할 수 있다.
... 외 다른 내용들 ...
- 게시글 수정 및 삭제의 경우, 게시글의 소유자(작성자)만 가능하다.
위 요구사항은 API 서버를 개발할때 정말 자주 맞닥뜨리는 상황들이다.
오늘 포스팅에서는 이중 수정 및 삭제의 경우, 게시글의 소유자만 가능하다. 라는 기능에 대해 이야기 할 것이다.
일단 구현 들어갑니다잉
✅ 예제 코드는 NestJS + typeORM을 기준으로 작성되었습니다.
이러한 User 및 Post Entity가 있고....
// user.entity.ts
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
username: string;
@OneToMany(() => Post, (post) => post.author)
posts: Post[];
}
// post.entity.ts
@Entity()
export class Post {
@PrimaryGeneratedColumn()
id: number;
@Column()
title: string;
@Column()
content: string;
@ManyToOne(() => User, (user) => user.posts, { eager: true })
author: User;
}
그리고 사용자를 인증하는 역할의 AuthGuard는 이미 다음과 같이 구현되어 있다.
// auth.guard.ts
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private readonly jwtService: JwtService) {}
async canActivate(ctx: ExecutionContext): Promise<boolean> {
const request = ctx.switchToHttp().getRequest<Request>();
const authHeader = request.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new UnauthorizedException('정상적인 인증 헤더가 없습니다.');
}
const token = authHeader.split(' ')[1];
try {
const payload = await this.jwtService.verifyAsync(token);
request.user = payload; // { id: number, username: string, ... } 형태
return true;
} catch {
throw new UnauthorizedException('Invalid or expired token');
}
}
}
그럼 이제 Controller - Service 를 보자
// post.controller.ts
@Controller('posts')
export class PostController {
constructor(private readonly postService: PostService) {}
@UseGuards(JwtAuthGuard)
@Delete(':id')
async deletePost(@Param('id') id: string, @Request() req: any) {
const userId = req.user.id; // JwtAuthGuard 에서 세팅된 payload
await this.postService.deletePost(Number(id), userId);
return { message: '삭제 완료' };
}
}
// post.service.ts
@Injectable()
export class PostService {
constructor(
@InjectRepository(Post)
private readonly postRepository: Repository<Post>,
) {}
async deletePost(postId: number, userId: number): Promise<void> {
const post = await this.postRepository.findOne({
where: { id: postId },
relations: ['author'],
});
if (!post) {
throw new NotFoundException('게시글을 찾을 수 없음');
}
if (post.author.id !== userId) {
throw new ForbiddenException('삭제는 게시글 주인만 가능함 ㅈㅅ');
}
await this.postRepository.remove(post);
}
}
많이 보던 유형이다.
이 예제에서 삭제 API는 다음과 같은 흐름으로 동작한다.
- AuthGuard를 사용해 게시글 삭제를 요청한 사용자를 식별
- 해당 게시글이 존재하는지 확인
- 요청자가 게시글의 주인인지 확인
- 삭제 완료
위 로직은 아주 정직하고 직관적인것 처럼 보이지만, 게시글의 존재 여부 확인 + 권한 검증의 과정은 다른 API에서도 자주 반복될 확률이 높으며 (메서드 분리가 가능하긴 하지만) 이는 꽤나 지루하게 느껴질 수 있다.
그리고 요청자가 게시글의 주인인지 확인하는 과정은 관심사 기준으로는 도메인 로직보다 권한 판단쪽에 가깝게 느껴진다.
가만히 생각해보면 우리에게는 권한을 담당하는 친구는 따로있다. -> 걔(Guards)한테 이것도 짬때리면 안되나?
라는 발상으로 이어질 수 있다.

관심사의 분리는 좋은 코드를 작성함에 있어서 상당히 중요한 요소이다. (가독성, 유지보수 및 관리, 기능확장등 다양한 측면에서 상당한 이점을 가질 수 있다.)
그럼 이걸 어떤식으로 분리 해보면 좋을까?
데코레이터를 직접 만들어서 사용해 보자.
이런식으로 어떤 동작을 수행하기 위해 소유자를 검증하는 과정은 여러 API에서 사용될 가능성이 매우 높다.
Controller에서 간단하게 명시해서 검사 처리를 하기 위해 우리는 NestJS의 Execution context기능을 사용해 데코레이터를 지정 만들어 줄 수 있다.
// authz/ownership.types.ts
import { EntityTarget } from 'typeorm';
export const OWNERSHIP_KEY = 'ownership_meta'; // reflector를 통해 메타데이터를 가져올 때 활용되는 값
export interface OwnershipMeta<T = any> {
entity: EntityTarget<T>; // 검사 대상 엔티티 (예: Post)
idParam?: string; // URL 파라미터명 (예: Post의 경우 id가 된다)
ownerProp?: string; // 엔티티의 소유자 필드명 (예: Post의 경우 author가 된다)
relations?: string[]; // ORM에게 미리 로드를 시킬 관계
attachKey?: string; // req에 붙일 데이터의 키 (예: 'post'로 하면 나중에 req.post로 접근 가능)
}
// authz/ownership.decorator.ts
import { SetMetadata } from '@nestjs/common';
import { OWNERSHIP_KEY, OwnershipMeta } from './ownership.types';
// Ownership이라는 데코레이터는 클래스에 아래와 같은 메타데이터를 부착한다...
export const Ownership = (meta: OwnershipMeta) =>
SetMetadata(OWNERSHIP_KEY, {
idParam: 'id',
ownerProp: 'owner',
attachKey: 'resource', // 여기는 기본 값들...
...meta,
});
저렇게 OwnershipMeta 라는 메타데이터 타입을 만들어두고, 우리는 이러한 타입의 데이터 형태를 Controller부에서 마음대로 메서드에 부착할 수 있다.
NestJS의 Execution Context 사용이 익숙하지 않다면, 어렵게 다가올 수 있다. (솔직히 나는 처음봤을때 이해하는데 억겁의 시간이 걸렸다.) 하지만, Controller 부에서 사용하는 예제를 보면 조금 쉽게 이해 될 것이다. 일단 완전히 이해가 안되더라도 Guards쪽과 Controller 쪽을 보자.
Controller - Service 에서는 이렇게 씁니다.
// post.controller.ts
import { Post } from '../entities/post.entity';
import { Ownership } from '../authz/ownership.decorator';
import { OwnershipGuard } from '../authz/ownership.guard';
@Controller('posts')
export class PostController {
constructor(private readonly postService: PostService) {}
@UseGuards(JwtAuthGuard, OwnershipGuard) // OwnershipGuard가 추가 되었다. (곧 나옴)
@Ownership({
entity: Post, // 소유권을 확인할 엔티티는?
idParam: 'id', // 해당 엔티티의 id를 알려주는 파라미터 명은?
ownerProp: 'author', // Post 엔티티에서 소유권을 나타내는 컬럼의 명은?
attachKey: 'post', // req에 부착할 키 이름은?
relations: ['author'], // Post 엔티티 조회할때 같이 조회할 관계는?
})
@Delete(':id')
async deletePost(@Param('id') id: string, @Request() req: any) {
// OwnershipGuard가 통과 되면 req.post에 엔티티가 있다.
await this.postService.remove(req.post);
return { message: '삭제 완료' };
}
}
// post.service.ts
import { Post } from '../entities/post.entity';
@Injectable()
export class PostService {
constructor(
@InjectRepository(Post)
private readonly postRepository: Repository<Post>,
) {}
// OwnershipGuard가 엔티티의 존재여부 및 권한을 보장해주므로 단순 삭제만 수행하면 됨.
async remove(post: Post): Promise<void> {
await this.postRepository.remove(post);
}
잘 보면 컨트롤러에 @Ownership 이라는 데코레이터가 추가되었고, OwnershipGuard라는 가드가 추가 되었다.
그리고 그 데코레이터에 인자로 아까 우리가 명시했던 OwnershipMeta 형태에 맞추어진 데이터들이 들어 가는 것을 알 수 있다.
아까 위의 기존예제를 보면 Service 단에서 존재 여부 확인 + 권한 검증의 과정을 거쳐줬지만, 지금은 Service가 매우 단순해졌다. 실제로 기존 예제와 현재 예제는 동일하게 동작 한다.
무슨 짓을 했길래 req.post를 하면 여기에 우리가 원하는 (삭제를 하려고 하는) Post 엔티티가 담겨서 넘어오는 걸까?
그리고 이 Post가 요청자의 소유인지는 제대로 검증이 된건가?
이런 마법같은 동작이 가능한 이유는 바로 우리가 앞으로 만들어줄 OwnershipGuard가 소유자 권한 검사 + 존재 여부 검사 두가지를 Guards 단에서 모두 처리해버렸기 때문이다.
OwnershipGuard는 이렇게 생겼습니다.
@Injectable()
export class OwnershipGuard implements CanActivate {
constructor(
private readonly ds: DataSource,
private readonly reflector: Reflector,
) {}
async canActivate(ctx: ExecutionContext): Promise<boolean> {
const meta = this.reflector.getAllAndOverride<OwnershipMeta>(
OWNERSHIP_KEY,
[ctx.getHandler(), ctx.getClass()],
); // getAllAndOverride를 사용해서 아까 Controller에서 부착한 메타데이터들을 가져온다.
if (!meta) return true; // 아무런 메타데이터(@Ownership 데코레이터에 정보)를 안주면 그냥 통과
const req = ctx.switchToHttp().getRequest<Express.Request & { user?: any }>();
// AuthGuard 에서 인증 처리를 하며req.user를 달아서 준다.
// 즉, OwnershipGuard는 AuthGuard보다 반드시 뒤에 달려야 한다. Guards는 왼쪽부터 순차 실행 됨.
const userId = req.user?.id;
if (!userId) throw new UnauthorizedException('로그인이 필요합니다.');
const idParam = meta.idParam ?? 'id';
const ownerProp = meta.ownerProp ?? 'owner';
const attachKey = meta.attachKey ?? 'resource';
const relations = meta.relations ?? [ownerProp];
const id = Number(req.params[idParam]);
// Pipes 보다 Guards가 먼저 수행되기에, 입력값 검증을 여기서 수행해야 함.
if (!Number.isFinite(id)) throw new NotFoundException('잘못된 ID입니다.');
// Guards에서 존재 여부 + 권한 검증을 모두 수행해버림
const repo = this.ds.getRepository(meta.entity);
const entity = await repo.findOne({ where: { id } as any, relations });
if (!entity) throw new NotFoundException('해당 자원을 찾을 수 없음');
const owner = (entity as any)[ownerProp];
if (!owner || owner.id !== userId) {
throw new ForbiddenException('해당 요청은 작성자만 수행할 수 있음 ㅈㅅ');
}
// 존재 여부 + 권한 검증이 모두 통과했다면 해당 엔티티를 req에 부착해서 전달
// 이걸 전달하지 않으면 Service에서 또 다시 한번 SELECT 쿼리를 수행해야 한다.
(req as any)[attachKey] = entity;
return true;
}
}
조금 복잡해 보일수도 있으나, 차근차근 읽어보면 수행하는 동작은 다음과 같다.
- 현재 실행 컨텍스트에서 제공된 메타데이터(Ownership)를 가져온다.
- 그 메타데이터에는 권한 검증을 수행할 엔티티(Post), 권한 검증 수단이 되는 컬럼명(author) 등의 정보가 포함되어 있다.
- 이 정보들을 기반으로 Repository에서 해당 엔티티의 존재 여부 + 권한을 검증한다.
- 모두 통과한다면, entity를 전달받은 attachKey에 부착한다.
- true를 반환해 요청을 정상 통과 시킨다.
이제야 위의 어지러운 Controller - Service 예제와 연결이 좀 된다.
우리는 OwnershipMeta에 우리가 원하는 데이터들을 넣어줄 수 있기 때문에 예를들어 Comment 라는 댓글 엔티티가 추가되었고, 댓글의 삭제의 경우에도 똑같이 동작해야 한다면 다음과 같이 작성해줄 수 있다.
@Controller('comments')
export class CommentController {
constructor(private readonly commentService: CommentService) {}
@UseGuards(JwtAuthGuard, OwnershipGuard)
@Ownership({ entity: Comment, ownerProp: 'author'})
@Delete(':id')
async deleteComment(@Param('id') id: string, @Request() req: any) {
await this.commentService.deleteComment(Number(id));
return { message: 'Comment deleted successfully' };
}
즉, @Ownership과 OwnershipGuard는 Post에만 사용할 수 있는 결합된 기능이 아닌 다양한 엔티티에 대응이 가능한 것이다.
정리를 해보자

오늘 기록한 방법의 장단점을 정리해보자.
장점
- 권한 검증 이라는 관심사를 분리했기에 서비스에서는 비즈니스 로직에 집중할 수 있다. (AOP 적이라고도 볼수 있나..?)
- Post뿐만 아니라 다양한 리소스에 적용 가능하므로 재사용에 적합하다.
- 마찬가지로, 관심사 분리로 인한 가독성 향상 및 관리(추후 권한 검증 로직이 변경되면 Ownership만 바꾸면 됨.)가 편해졌다.
단점
- Guards의 책임 증가(현재 예제에서는 존재 여부 판단 이라는 역할도 수행 중)에 따라 오히려 코드가 복잡해 질 수 있다. (장점이 역으로 단점으로 작용할 수 있는 것. 데이터 조회가 섞인다는 것 자체가 관심사 혼합으로 이어질 수 도 있다는 생각이 들었다.)
- 전반적인 구조를 파악하지 못한채로 코드를 처음 본다면(인수인계) 꽤나 머리가 아파올 수 있다.
- 테스트 복잡도 증가(Guard와 Service가 서로 얽히며 단위 테스트 시 mocking이 번거로워질 수 있다.)
- Type Safe 하지 않다. (전달하는 Metadata에 오탈자가 나는것을 빌드 타임에 캐치할 수 없다. 런타임에 되어서야 터져버림;) 하지만 이 경우는 타입스크립트의 타입을 활용하면 어느정도 해결은 가능하나, 사용되는 엔티티들이 많아질수록 타입 관리가 어려워 진다.

프로젝트를 진행하면서 어떻게 하면 더 좋은 코드를 짤 수 있을까? 를 고민하는 과정은 단 한번도 나에게 '캬, 완벽한 방법이군. 이게 최선이야. 아주 최고야!' 라는 기분을 심어준 적이 없는 것 같다.
항상 뭔가 좋은 것 같아서 막 쓰고나면 나중에 단점들이 서서히 보이기 시작하고, 그 단점들을 보완하기 위해 이것 저것 덕지 덕지 씌우기 시작하자니 기존의 장점들이 다 묻혀버릴 정도로 복잡도가 올라가서 의미가 없어져버린다.
본인 프로젝트의 상황, 본 방법 장단점에 따라서 잘 고민해보고 도입 해보는 것도 좋은 방법이 될 것 같다.
만약 이 방법에 잘못된 부분이 있거나 더 좋은 개선점이 있다면 댓글로 알려주시면 제게 큰 도움이 될 것 같습니다 :)
'Web' 카테고리의 다른 글
| TypeORM 의 Date String 반환 이슈 (0) | 2025.06.11 |
|---|---|
| 자바 vs 노드 당신의 선택은?! (8) | 2025.01.18 |
| NestJS + TypeORM + Testcontainers 를 사용한 통합 테스트 DB환경 구축하기 (2) | 2025.01.17 |
| 코딩테스트 준비를 위한 Java 입출력 정리 (1) | 2024.05.23 |
| Java record 에 대하여 (4) | 2024.03.10 |