들어가며
NestJS 공식 문서의 Serialization 챕터는
class-transformer 기반의 직렬화를 안내한다. Entity 클래스에 @Exclude()를 붙이고, ClassSerializerInterceptor가 응답을 가공하는 방식이다.typescript
// NestJS 공식 문서의 패턴
export class UserEntity {
id: number;
firstName: string;
lastName: string;
@Exclude()
password: string;
constructor(partial: Partial<UserEntity>) {
Object.assign(this, partial);
}
}
// Controller에서 인스턴스를 직접 반환
@UseInterceptors(ClassSerializerInterceptor)
@Get()
findOne(): UserEntity {
return new UserEntity({ id: 1, firstName: 'John', lastName: 'Doe', password: 'password' });
}
@UseInterceptors(ClassSerializerInterceptor)는 메서드나 컨트롤러에 붙일 수도 있지만, 실무에서는 보통 main.ts에서 글로벌로 설정한다.typescript
// main.ts
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
글로벌로 설정하면 모든 컨트롤러에서 데코레이터 없이
@Exclude() 기반 직렬화가 동작한다. 공식 문서에서는 @SerializeOptions({ type: UserEntity })를 사용하면 plain object를 반환해도 자동으로 class instance로 변환해주는 방법도 안내하고 있다.이 패턴은 TypeORM처럼 ORM이 class instance를 반환하는 환경에서 가장 자연스럽다. Prisma는 plain object를 반환하기 때문에 기본적으로
ClassSerializerInterceptor가 동작하지 않지만, @SerializeOptions({ type: UserEntity })나 plainToInstance를 사용하면 Prisma 환경에서도 적용할 수 있다.실제로 wanago.io의 NestJS + Prisma 시리즈에서는
plainToInstance로 Prisma의 반환값을 class instance로 변환하는 커스텀 인터셉터를 만들어서 이 문제를 해결하고 있다. 방법은 다르지만 근본적인 구조는 동일하다: Prisma의 plain object를 class instance로 감싸는 것이다.typescript
// plainToInstance 방식 (wanago.io)
export class UserResponseDto {
id: string;
email: string;
@Exclude() password: string;
@Exclude() deletedAt: Date;
}
// Service에서
const user = await this.prisma.user.findUnique({ where: { id } });
return plainToInstance(UserResponseDto, user);
Spring Boot를 먼저 접했기 때문에 응답 DTO에 생성자나 정적 팩터리 메서드를 정의하는 방식이 자연스러웠다.
kotlin
// Spring Boot: Entity에서 노출할 필드만 뽑아서 DTO로 변환
@Entity
class User(
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
val email: String,
val password: String,
val nickname: String,
)
data class UserResponse(
val id: Long,
val email: String,
val nickname: String,
) {
companion object {
fun from(user: User) = UserResponse(
id = user.id,
email = user.email,
nickname = user.nickname,
)
}
}
NestJS에서도 비슷한 구조를 만들려고 했다. 그런데 Prisma를 쓰면서 생각이 달라졌다.
Prisma의
select가 컴파일 타임에 반환 타입을 추론해주는데, 런타임 리플렉션에 의존하는 class-transformer가 정말 필요한가?class-transformer 기반 직렬화의 문제
공식 문서의
Partial<T> 생성자 패턴과 plainToInstance 패턴 모두, 근본적인 문제를 공유한다.1. DTO의 목적에 어긋난다
Response DTO는 API가 외부에 내보낼 필드를 명시적으로 열거하는 것이 목적이다. 그런데
@Exclude()를 쓰면 노출하지 않을 필드까지 DTO(또는 Entity)에 선언해야 한다. "내보낼 것만 정의"해야 하는데 "숨길 것을 정의"하고 있다.typescript
// ❌ 숨길 필드를 정의하고 있다
export class UserEntity {
id: number;
firstName: string;
lastName: string;
@Exclude() password: string; // 왜 응답 객체에 password가 있는가?
}
물론
@Expose() + excludeExtraneousValues: true 조합을 쓰면 화이트리스트 방식으로 전환할 수 있다. 노출할 필드에만 @Expose()를 붙이고, 나머지는 자동으로 제거하는 것이다. @Exclude()의 블랙리스트 문제는 해소된다.typescript
// @Expose 방식 - 화이트리스트
export class UserResponseDto {
@Expose() id: string;
@Expose() email: string;
// password, deletedAt → @Expose가 없으면 자동 제거
}
return plainToInstance(UserResponseDto, user, { excludeExtraneousValues: true });
하지만 두 번째 문제는 여전하다.
2. 타입 안전성이 없다
공식 문서의
Partial<T> 생성자 패턴과 plainToInstance 패턴 모두 런타임에 의존한다. 컴파일 타임에 필드 누락을 잡아주지 못한다.typescript
// 공식 문서 패턴: Partial<T>이므로 모든 필드가 optional
return new UserEntity({
id: 1,
// firstName, lastName 빠뜨려도 컴파일 에러 없음
});
// 커뮤니티 패턴: plainToInstance도 마찬가지
return plainToInstance(UserResponseDto, { id: user.id });
// email이 빠졌지만 undefined로 응답됨
3. Prisma와의 마찰
TypeORM에서는 이 패턴이 자연스럽다. TypeORM은 데코레이터로 정의한 Entity 클래스의 인스턴스를 직접 반환하기 때문에,
ClassSerializerInterceptor가 별다른 마찰 없이 동작한다. 하지만 Prisma는 class instance가 아닌 plain object를 반환한다. ClassSerializerInterceptor가 Prisma의 반환값을 인식하지 못하기 때문에, plainToInstance로 한 번 감싸거나, new Entity(partial) 생성자로 인스턴스를 만들어야 하는 추가 작업이 생긴다.실제로 NestJS + Prisma 환경에서 이 패턴을 적용하면 마찰이 심하다. 관계를 포함(
include)하면 중첩 객체까지 UserEntity로 감싸야 하고, 조인할 때마다 Object.assign + new UserEntity() 보일러플레이트가 늘어난다.TypeORM에서 합리적이었던 패턴이 Prisma에서는 불필요한 마찰이 된다.
Prisma select로 해결하기
Prisma의
select를 활용하면 이 문제를 컴파일 타임에 해결할 수 있다. select에 지정한 필드만 Prisma가 반환 타입으로 추론하기 때문에, password를 select에서 빼면 반환값의 타입에도 password가 존재하지 않는다.typescript
// ✅ select로 노출할 필드만 뽑고 그대로 반환
async findById(id: string): Promise<UserResponseDto> {
const user = await this.prisma.user.findFirst({
where: { id, deletedAt: null },
select: {
id: true,
email: true,
nickname: true,
role: true,
createdAt: true,
// password, deletedAt → select에 없으면 반환 타입에도 없음
// plainToInstance, @Exclude 불필요
},
});
if (!user) throw new NotFoundException(`User ${id} not found`);
return user; // select 결과 타입이 UserResponseDto와 구조적으로 일치하면 그대로 반환
}
plainToInstance도, @Exclude()도, ClassSerializerInterceptor도 필요 없다. Prisma가 쿼리 레벨에서 필요한 컬럼만 가져오고, TypeScript가 컴파일 타임에 타입을 검증한다. 응답에 password가 포함될 가능성이 원천 차단된다.성능 측면에서도 이점이 있다.
include로 전체를 가져온 뒤 런타임에 필드를 제거하는 것과 달리, select는 DB 쿼리 자체에서 필요한 컬럼만 가져온다. 네트워크 전송량과 메모리 사용량이 줄어든다.Response DTO 타입 정의
Prisma 타입을 직접 활용
Swagger가 필요 없다면
type으로 충분하다. Prisma의 GetPayload 유틸리티 타입으로 select 결과를 그대로 DTO 타입으로 사용한다.typescript
// dto/user-response.dto.ts
export type UserResponseDto = Prisma.UserGetPayload<{
select: {
id: true;
email: true;
nickname: true;
role: true;
createdAt: true;
};
}>;
Prisma 공식 문서에서도 이 패턴을 권장하고 있다.
"A cleaner solution to this is to use theUserGetPayloadtype that is generated and exposed by Prisma Client under thePrismanamespace in combination with TypeScript'ssatisfiesoperator."(번역) 더 깔끔한 해결책은 Prisma Client가Prisma네임스페이스 아래에 생성하여 노출하는UserGetPayload타입을, TypeScript의satisfies연산자와 조합해서 사용하는 것이다.
select 객체를 별도 상수로 분리하면 쿼리와 타입을 함께 관리할 수 있다.
typescript
const userSelect = {
id: true,
email: true,
nickname: true,
role: true,
createdAt: true,
} satisfies Prisma.UserSelect;
export type UserResponseDto = Prisma.UserGetPayload<{ select: typeof userSelect }>;
satisfies Prisma.UserSelect는 객체가 UserSelect 타입에 맞는지 검증하되, 추론된 타입은 리터럴 그대로 유지한다. 스키마에 없는 필드를 넣으면 컴파일 에러가 발생한다.Swagger가 필요한 경우
NestJS Swagger는 컨트롤러 메서드의 응답 모델을 TypeScript의 emitted metadata와 Reflection으로 생성하기 때문에
class 기반 DTO가 필요하다. type만으로는 Swagger 문서에 응답 스키마가 나타나지 않는다.이때
implements를 걸어두면 클래스 필드와 Prisma 반환 타입이 어긋날 때 컴파일 에러로 잡아준다.typescript
// dto/user-response.dto.ts
// Swagger용 class 선언 + implements로 타입 동기화 강제
export class UserResponseDto
implements Prisma.UserGetPayload<{ select: typeof userSelect }>
{
@ApiProperty() id: string;
@ApiProperty() email: string;
@ApiProperty() nickname: string;
@ApiProperty({ enum: Role }) role: Role;
@ApiProperty() createdAt: Date;
// select에 없는 필드를 여기 추가하면 implements 조건 불일치로 컴파일 에러
}
implements로 Prisma 반환 타입과 Swagger 클래스를 동기화하는 것이 이 패턴의 핵심이다. 스키마가 바뀌면 컴파일러가 잡아준다.다만, 이 방식에는 trade-off가 있다.
implements가 강제하는 건 "Prisma 반환 타입의 필드가 클래스에 존재하는지"이지, "클래스에 없는 필드가 Prisma에 존재하는지"는 아니다. 즉, 클래스에 필드를 추가하되 select에 안 넣으면 컴파일러가 잡지 못할 수 있다. 이런 경우에는 런타임에 해당 필드가 undefined로 내려간다. 완벽한 양방향 동기화까지는 아니지만, 대부분의 실수를 잡아주기에 실용적이다.변환 로직이 있는 경우
날짜 포맷 변환처럼 select 결과를 그대로 반환할 수 없는 경우가 있다. 이때는 리터럴 객체로 반환하고
satisfies로 타입을 검증한다.satisfies는 할당 대상의 타입을 체크하되, 추론된 타입은 리터럴 그대로 유지한다. as로 캐스팅하는 것과 달리 shape이 맞지 않으면 컴파일 에러가 발생한다.typescript
export class PostResponseDto {
@ApiProperty() id: number;
@ApiProperty() title: string;
@ApiProperty() publishedAt: string; // DB는 Date, 응답은 'YYYY-MM-DD' 문자열
}
// Service에서
async getPostDetail(id: number): Promise<PostResponseDto> {
const post = await this.prisma.post.findUnique({ where: { id } });
if (!post) throw new NotFoundException();
return {
id: post.id,
title: post.title,
publishedAt: post.publishedAt.toISOString().slice(0, 10), // Date → string 변환
} satisfies PostResponseDto;
// satisfies: 반환 객체가 PostResponseDto shape을 만족하는지 컴파일 타임 검증
// 필드 누락 시 에러, new PostResponseDto() 불필요
}
Prisma 공식 블로그에서도
satisfies를 Prisma 워크플로우에 활용하는 방법을 별도 포스트로 다루고 있다."The newsatisfiesoperator gives the same benefits, with no runtime impact, and automatically checks for excess or misspelled properties."(번역) 새로운satisfies연산자는 런타임 오버헤드 없이 동일한 이점을 제공하며, 불필요하거나 잘못된 프로퍼티를 자동으로 검사한다.
Partial 생성자 패턴은 피하자
한편 아래처럼 생성자에
Partial<T>를 받는 패턴이 흔히 보이는데, 이 방식은 모든 필드가 optional이 되어 누락을 컴파일러가 잡아주지 못한다.typescript
// ❌ NestJS 공식 문서 예제에도 나오는 패턴이지만 타입 안전성이 없음
export class PostResponseDto {
constructor(partial: Partial<PostResponseDto>) {
Object.assign(this, partial);
}
}
return new PostResponseDto({
id: post.id,
// title, publishedAt 빠뜨려도 컴파일 에러 없음 → 런타임에 undefined 응답
});
satisfies를 쓰면 필드 하나라도 빠뜨리는 순간 컴파일 에러가 발생한다. Partial 생성자보다 안전하고, new를 호출하는 보일러플레이트도 사라진다.여러 테이블 조인 결과를 하나의 DTO로
중첩 관계나 집계 필드가 필요한 경우
include 대신 select를 중첩해서 쓴다. Prisma가 결과 타입 전체를 자동으로 추론해주기 때문에 별도 인터페이스를 정의할 필요가 없다.typescript
// posts/dto/post-detail.dto.ts
const postDetailSelect = {
id: true,
title: true,
content: true,
createdAt: true,
author: {
select: { id: true, nickname: true, profileImage: true },
},
tags: {
select: { tag: { select: { name: true } } },
},
_count: {
select: { comments: true, likes: true },
},
} satisfies Prisma.PostSelect;
// 반환 타입이 자동 추론됨. 직접 interface 작성 불필요
export type PostDetailDto = Prisma.PostGetPayload<{ select: typeof postDetailSelect }>;
조인 결과를 가공해야 한다면 리터럴로 반환한다.
typescript
const post = await this.prisma.post.findUniqueOrThrow({
where: { id },
select: postDetailSelect,
});
// tags가 { tag: { name: string } }[] 구조 → 클라이언트에 string[]로 내려야 할 때
return {
...post,
tags: post.tags.map(t => t.tag.name),
};
그래서 실제로는 어떻게 쓰고 있는가
위에서 여러 패턴을 정리했지만, 실무에서 전부 쓰는 건 아니다.
현재 프로젝트에서는 Swagger를 쓰고 있기 때문에, 응답 DTO는
@ApiProperty() 붙은 class로 정의한다. 서비스 메서드에서는 select로 필요한 필드만 뽑은 뒤, 리터럴 객체로 반환하면서 satisfies로 shape을 검증한다.typescript
// dto/user-response.dto.ts
export class UserResponseDto {
@ApiProperty() id: string;
@ApiProperty() email: string;
@ApiProperty() nickname: string;
@ApiProperty({ enum: Role }) role: Role;
@ApiProperty() createdAt: Date;
}
// Service에서
async findById(id: string): Promise<UserResponseDto> {
const user = await this.prisma.user.findFirst({
where: { id, deletedAt: null },
select: { id: true, email: true, nickname: true, role: true, createdAt: true },
});
if (!user) throw new NotFoundException(`User ${id} not found`);
return user satisfies UserResponseDto;
}
이게 끝이다.
plainToInstance도, @Exclude()도, new UserResponseDto()도 없다.다만
satisfies의 동작을 정확히 이해하고 써야 한다. 객체 리터럴에 satisfies를 붙이면 excess property checking이 동작한다. 즉, DTO에 없는 필드를 넣으면 컴파일 에러가 발생한다.typescript
// ✅ 객체 리터럴 + satisfies: 양방향 검증
return {
id: user.id,
email: user.email,
nickname: user.nickname,
password: user.password, // 컴파일 에러: DTO에 없는 필드
} satisfies UserResponseDto;
하지만 변수에
satisfies를 붙이면 structural subtyping이 적용되어 추가 필드가 있어도 통과한다.typescript
// ⚠️ 변수 + satisfies: 단방향 검증
const user = await this.prisma.user.findFirst({
select: { id: true, email: true, nickname: true, password: true },
});
return user satisfies UserResponseDto;
// 컴파일 통과: id, email, nickname이 있으니 UserResponseDto를 만족
// 하지만 password가 응답에 포함됨
이 차이가 중요한 이유는, select 결과를 변환 없이 그대로 반환할 때 두 번째 케이스에 해당하기 때문이다. 다만 Prisma는 select에 지정한 필드만 반환하기 때문에, select 자체가 DTO와 정확히 일치하면 변수에 남은 필드가 있을 수가 없다. 이 경우에는 변수에 바로
satisfies를 붙여도 안전하다.결국 정리하면:
- select 결과를 그대로 반환하는 경우: select과 DTO 필드가 일치하면
return user satisfies UserResponseDto로 충분하다. - 가공이 필요한 경우 (날짜 포맷 변환, 관계 flatten 등): 객체 리터럴로 반환하면 excess property checking까지 동작한다.
Prisma.UserGetPayload<{ select: typeof x }> 타입을 별도로 정의하는 패턴은 쓰지 않고 있다. Swagger를 쓰는 이상 class가 이미 존재하고, satisfies class로 검증하면 되기 때문이다. GetPayload 타입이 필요한 건 Swagger 없이 type만으로 DTO를 정의하거나, implements로 class와 Prisma 타입을 양방향 동기화할 때인데, satisfies만으로 충분한 상황에서 굳이 타입 alias를 하나 더 만들 이유가 없었다.implements Prisma.UserGetPayload<...>로 Swagger class와 Prisma 반환 타입을 강하게 묶는 방식도 고려했지만, 현재 규모에서는 오버엔지니어링이라고 판단했다. 객체 리터럴로 반환하면 satisfies만으로 양방향 검증이 되고, select 객체와 class 정의가 같은 파일에 있으면 불일치를 눈으로도 잡을 수 있다.돌아보며
class-transformer기반 직렬화는 TypeORM에서 온 패턴이다. NestJS 공식 문서의ClassSerializerInterceptor+@Exclude()는 ORM이 class instance를 반환하는 환경을 전제로 한다. Prisma는 plain object를 반환하기 때문에 이 패턴이 자연스럽게 동작하지 않는다. Prisma를 쓴다면select기반 타입 추론을 활용하는 것이 관용적이다.- Response DTO는 "내보낼 것만 정의"하는 것이 목적이다. 숨길 필드를 나열하는
@Exclude()방식은 이 원칙에 어긋난다. satisfies는 Prisma와 궁합이 좋다. 런타임 오버헤드 없이 컴파일 타임에 shape을 검증할 수 있다. 리터럴 객체 반환, select 객체 정의, 필터 조합 등 다양한 곳에서 활용할 수 있다.- Swagger가 필요하면 class를 정의하고
satisfies로 검증하자. 객체 리터럴로 반환하면서satisfies를 붙이면 빠진 필드와 남은 필드 모두 컴파일 타임에 잡아준다.implements까지 가지 않아도 대부분의 상황에서 충분하다. - 다른 프레임워크의 패턴을 그대로 가져오지 말자. Spring의
fun from패턴이나 TypeORM의@Exclude()패턴은 각각의 맥락에서 합리적이지만, Prisma에서는 불필요한 보일러플레이트가 된다. 도구가 제공하는 타입 시스템을 최대한 활용하는 것이 맞다.
참고
- NestJS 공식 문서 - Serialization
- Prisma 공식 문서 - Operating against partial structures of model types
- Prisma 공식 문서 - Type safety
- Prisma Blog - How TypeScript 4.9
satisfiesYour Prisma Workflows - GitHub prisma/prisma #7377 - Type for Prisma query results
- GitHub prisma/prisma #9233 - How to serialize Prisma Object in NestJS?
- wanago.io - API with NestJS #112. Serializing the response with Prisma
NestJS + Prisma에서 응답 DTO에 대한 고찰
class-transformer 기반 직렬화가 Prisma와 맞지 않는 이유, Prisma select와 satisfies를 활용한 대안, 그리고 실무에서의 선택
들어가며
NestJS 공식 문서의 Serialization 챕터는
class-transformer 기반의 직렬화를 안내한다. Entity 클래스에 @Exclude()를 붙이고, ClassSerializerInterceptor가 응답을 가공하는 방식이다.typescript
// NestJS 공식 문서의 패턴
export class UserEntity {
id: number;
firstName: string;
lastName: string;
@Exclude()
password: string;
constructor(partial: Partial<UserEntity>) {
Object.assign(this, partial);
}
}
// Controller에서 인스턴스를 직접 반환
@UseInterceptors(ClassSerializerInterceptor)
@Get()
findOne(): UserEntity {
return new UserEntity({ id: 1, firstName: 'John', lastName: 'Doe', password: 'password' });
}
@UseInterceptors(ClassSerializerInterceptor)는 메서드나 컨트롤러에 붙일 수도 있지만, 실무에서는 보통 main.ts에서 글로벌로 설정한다.typescript
// main.ts
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
글로벌로 설정하면 모든 컨트롤러에서 데코레이터 없이
@Exclude() 기반 직렬화가 동작한다. 공식 문서에서는 @SerializeOptions({ type: UserEntity })를 사용하면 plain object를 반환해도 자동으로 class instance로 변환해주는 방법도 안내하고 있다.이 패턴은 TypeORM처럼 ORM이 class instance를 반환하는 환경에서 가장 자연스럽다. Prisma는 plain object를 반환하기 때문에 기본적으로
ClassSerializerInterceptor가 동작하지 않지만, @SerializeOptions({ type: UserEntity })나 plainToInstance를 사용하면 Prisma 환경에서도 적용할 수 있다.실제로 wanago.io의 NestJS + Prisma 시리즈에서는
plainToInstance로 Prisma의 반환값을 class instance로 변환하는 커스텀 인터셉터를 만들어서 이 문제를 해결하고 있다. 방법은 다르지만 근본적인 구조는 동일하다: Prisma의 plain object를 class instance로 감싸는 것이다.typescript
// plainToInstance 방식 (wanago.io)
export class UserResponseDto {
id: string;
email: string;
@Exclude() password: string;
@Exclude() deletedAt: Date;
}
// Service에서
const user = await this.prisma.user.findUnique({ where: { id } });
return plainToInstance(UserResponseDto, user);
Spring Boot를 먼저 접했기 때문에 응답 DTO에 생성자나 정적 팩터리 메서드를 정의하는 방식이 자연스러웠다.
kotlin
// Spring Boot: Entity에서 노출할 필드만 뽑아서 DTO로 변환
@Entity
class User(
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
val email: String,
val password: String,
val nickname: String,
)
data class UserResponse(
val id: Long,
val email: String,
val nickname: String,
) {
companion object {
fun from(user: User) = UserResponse(
id = user.id,
email = user.email,
nickname = user.nickname,
)
}
}
NestJS에서도 비슷한 구조를 만들려고 했다. 그런데 Prisma를 쓰면서 생각이 달라졌다.
Prisma의
select가 컴파일 타임에 반환 타입을 추론해주는데, 런타임 리플렉션에 의존하는 class-transformer가 정말 필요한가?class-transformer 기반 직렬화의 문제
공식 문서의
Partial<T> 생성자 패턴과 plainToInstance 패턴 모두, 근본적인 문제를 공유한다.1. DTO의 목적에 어긋난다
Response DTO는 API가 외부에 내보낼 필드를 명시적으로 열거하는 것이 목적이다. 그런데
@Exclude()를 쓰면 노출하지 않을 필드까지 DTO(또는 Entity)에 선언해야 한다. "내보낼 것만 정의"해야 하는데 "숨길 것을 정의"하고 있다.typescript
// ❌ 숨길 필드를 정의하고 있다
export class UserEntity {
id: number;
firstName: string;
lastName: string;
@Exclude() password: string; // 왜 응답 객체에 password가 있는가?
}
물론
@Expose() + excludeExtraneousValues: true 조합을 쓰면 화이트리스트 방식으로 전환할 수 있다. 노출할 필드에만 @Expose()를 붙이고, 나머지는 자동으로 제거하는 것이다. @Exclude()의 블랙리스트 문제는 해소된다.typescript
// @Expose 방식 - 화이트리스트
export class UserResponseDto {
@Expose() id: string;
@Expose() email: string;
// password, deletedAt → @Expose가 없으면 자동 제거
}
return plainToInstance(UserResponseDto, user, { excludeExtraneousValues: true });
하지만 두 번째 문제는 여전하다.
2. 타입 안전성이 없다
공식 문서의
Partial<T> 생성자 패턴과 plainToInstance 패턴 모두 런타임에 의존한다. 컴파일 타임에 필드 누락을 잡아주지 못한다.typescript
// 공식 문서 패턴: Partial<T>이므로 모든 필드가 optional
return new UserEntity({
id: 1,
// firstName, lastName 빠뜨려도 컴파일 에러 없음
});
// 커뮤니티 패턴: plainToInstance도 마찬가지
return plainToInstance(UserResponseDto, { id: user.id });
// email이 빠졌지만 undefined로 응답됨
3. Prisma와의 마찰
TypeORM에서는 이 패턴이 자연스럽다. TypeORM은 데코레이터로 정의한 Entity 클래스의 인스턴스를 직접 반환하기 때문에,
ClassSerializerInterceptor가 별다른 마찰 없이 동작한다. 하지만 Prisma는 class instance가 아닌 plain object를 반환한다. ClassSerializerInterceptor가 Prisma의 반환값을 인식하지 못하기 때문에, plainToInstance로 한 번 감싸거나, new Entity(partial) 생성자로 인스턴스를 만들어야 하는 추가 작업이 생긴다.실제로 NestJS + Prisma 환경에서 이 패턴을 적용하면 마찰이 심하다. 관계를 포함(
include)하면 중첩 객체까지 UserEntity로 감싸야 하고, 조인할 때마다 Object.assign + new UserEntity() 보일러플레이트가 늘어난다.TypeORM에서 합리적이었던 패턴이 Prisma에서는 불필요한 마찰이 된다.
Prisma select로 해결하기
Prisma의
select를 활용하면 이 문제를 컴파일 타임에 해결할 수 있다. select에 지정한 필드만 Prisma가 반환 타입으로 추론하기 때문에, password를 select에서 빼면 반환값의 타입에도 password가 존재하지 않는다.typescript
// ✅ select로 노출할 필드만 뽑고 그대로 반환
async findById(id: string): Promise<UserResponseDto> {
const user = await this.prisma.user.findFirst({
where: { id, deletedAt: null },
select: {
id: true,
email: true,
nickname: true,
role: true,
createdAt: true,
// password, deletedAt → select에 없으면 반환 타입에도 없음
// plainToInstance, @Exclude 불필요
},
});
if (!user) throw new NotFoundException(`User ${id} not found`);
return user; // select 결과 타입이 UserResponseDto와 구조적으로 일치하면 그대로 반환
}
plainToInstance도, @Exclude()도, ClassSerializerInterceptor도 필요 없다. Prisma가 쿼리 레벨에서 필요한 컬럼만 가져오고, TypeScript가 컴파일 타임에 타입을 검증한다. 응답에 password가 포함될 가능성이 원천 차단된다.성능 측면에서도 이점이 있다.
include로 전체를 가져온 뒤 런타임에 필드를 제거하는 것과 달리, select는 DB 쿼리 자체에서 필요한 컬럼만 가져온다. 네트워크 전송량과 메모리 사용량이 줄어든다.Response DTO 타입 정의
Prisma 타입을 직접 활용
Swagger가 필요 없다면
type으로 충분하다. Prisma의 GetPayload 유틸리티 타입으로 select 결과를 그대로 DTO 타입으로 사용한다.typescript
// dto/user-response.dto.ts
export type UserResponseDto = Prisma.UserGetPayload<{
select: {
id: true;
email: true;
nickname: true;
role: true;
createdAt: true;
};
}>;
Prisma 공식 문서에서도 이 패턴을 권장하고 있다.
"A cleaner solution to this is to use theUserGetPayloadtype that is generated and exposed by Prisma Client under thePrismanamespace in combination with TypeScript'ssatisfiesoperator."(번역) 더 깔끔한 해결책은 Prisma Client가Prisma네임스페이스 아래에 생성하여 노출하는UserGetPayload타입을, TypeScript의satisfies연산자와 조합해서 사용하는 것이다.
select 객체를 별도 상수로 분리하면 쿼리와 타입을 함께 관리할 수 있다.
typescript
const userSelect = {
id: true,
email: true,
nickname: true,
role: true,
createdAt: true,
} satisfies Prisma.UserSelect;
export type UserResponseDto = Prisma.UserGetPayload<{ select: typeof userSelect }>;
satisfies Prisma.UserSelect는 객체가 UserSelect 타입에 맞는지 검증하되, 추론된 타입은 리터럴 그대로 유지한다. 스키마에 없는 필드를 넣으면 컴파일 에러가 발생한다.Swagger가 필요한 경우
NestJS Swagger는 컨트롤러 메서드의 응답 모델을 TypeScript의 emitted metadata와 Reflection으로 생성하기 때문에
class 기반 DTO가 필요하다. type만으로는 Swagger 문서에 응답 스키마가 나타나지 않는다.이때
implements를 걸어두면 클래스 필드와 Prisma 반환 타입이 어긋날 때 컴파일 에러로 잡아준다.typescript
// dto/user-response.dto.ts
// Swagger용 class 선언 + implements로 타입 동기화 강제
export class UserResponseDto
implements Prisma.UserGetPayload<{ select: typeof userSelect }>
{
@ApiProperty({ description: '사용자 ID', example: 'clxyz...' })
id: string;
@ApiProperty({ description: '이메일', example: 'user@example.com' })
email: string;
@ApiProperty({ description: '닉네임', example: 'sillysillyman' })
nickname: string;
@ApiProperty({ description: '역할', enum: Role, example: Role.USER })
role: Role;
@ApiProperty({ description: '가입일' })
createdAt: Date;
// select에 없는 필드를 여기 추가하면 implements 조건 불일치로 컴파일 에러
}
implements로 Prisma 반환 타입과 Swagger 클래스를 동기화하는 것이 이 패턴의 핵심이다. 스키마가 바뀌면 컴파일러가 잡아준다.다만, 이 방식에는 trade-off가 있다.
implements가 강제하는 건 "Prisma 반환 타입의 필드가 클래스에 존재하는지"이지, "클래스에 없는 필드가 Prisma에 존재하는지"는 아니다. 즉, 클래스에 필드를 추가하되 select에 안 넣으면 컴파일러가 잡지 못할 수 있다. 이런 경우에는 런타임에 해당 필드가 undefined로 내려간다. 완벽한 양방향 동기화까지는 아니지만, 대부분의 실수를 잡아주기에 실용적이다.변환 로직이 있는 경우
날짜 포맷 변환처럼 select 결과를 그대로 반환할 수 없는 경우가 있다. 이때는 리터럴 객체로 반환하고
satisfies로 타입을 검증한다.satisfies는 할당 대상의 타입을 체크하되, 추론된 타입은 리터럴 그대로 유지한다. as로 캐스팅하는 것과 달리 shape이 맞지 않으면 컴파일 에러가 발생한다.typescript
export class PostResponseDto {
@ApiProperty({ description: '게시글 ID', example: 1 })
id: number;
@ApiProperty({ description: '제목', example: '첫 번째 게시글' })
title: string;
@ApiProperty({ description: '발행일 (YYYY-MM-DD)', example: '2026-03-29' })
publishedAt: string; // DB는 Date, 응답은 'YYYY-MM-DD' 문자열
}
// Service에서
async getPostDetail(id: number): Promise<PostResponseDto> {
const post = await this.prisma.post.findUnique({ where: { id } });
if (!post) throw new NotFoundException();
return {
id: post.id,
title: post.title,
publishedAt: post.publishedAt.toISOString().slice(0, 10), // Date → string 변환
} satisfies PostResponseDto;
// satisfies: 반환 객체가 PostResponseDto shape을 만족하는지 컴파일 타임 검증
// 필드 누락 시 에러, new PostResponseDto() 불필요
}
Prisma 공식 블로그에서도
satisfies를 Prisma 워크플로우에 활용하는 방법을 별도 포스트로 다루고 있다."The newsatisfiesoperator gives the same benefits, with no runtime impact, and automatically checks for excess or misspelled properties."(번역) 새로운satisfies연산자는 런타임 오버헤드 없이 동일한 이점을 제공하며, 불필요하거나 잘못된 프로퍼티를 자동으로 검사한다.
Partial 생성자 패턴은 피하자
한편 아래처럼 생성자에
Partial<T>를 받는 패턴이 흔히 보이는데, 이 방식은 모든 필드가 optional이 되어 누락을 컴파일러가 잡아주지 못한다.typescript
// ❌ NestJS 공식 문서 예제에도 나오는 패턴이지만 타입 안전성이 없음
export class PostResponseDto {
constructor(partial: Partial<PostResponseDto>) {
Object.assign(this, partial);
}
}
return new PostResponseDto({
id: post.id,
// title, publishedAt 빠뜨려도 컴파일 에러 없음 → 런타임에 undefined 응답
});
satisfies를 쓰면 필드 하나라도 빠뜨리는 순간 컴파일 에러가 발생한다. Partial 생성자보다 안전하고, new를 호출하는 보일러플레이트도 사라진다.여러 테이블 조인 결과를 하나의 DTO로
중첩 관계나 집계 필드가 필요한 경우
include 대신 select를 중첩해서 쓴다. Prisma가 결과 타입 전체를 자동으로 추론해주기 때문에 별도 인터페이스를 정의할 필요가 없다.typescript
// posts/dto/post-detail.dto.ts
const postDetailSelect = {
id: true,
title: true,
content: true,
createdAt: true,
author: {
select: { id: true, nickname: true, profileImage: true },
},
tags: {
select: { tag: { select: { name: true } } },
},
_count: {
select: { comments: true, likes: true },
},
} satisfies Prisma.PostSelect;
// 반환 타입이 자동 추론됨. 직접 interface 작성 불필요
export type PostDetailDto = Prisma.PostGetPayload<{ select: typeof postDetailSelect }>;
조인 결과를 가공해야 한다면 리터럴로 반환한다.
typescript
const post = await this.prisma.post.findUniqueOrThrow({
where: { id },
select: postDetailSelect,
});
// tags가 { tag: { name: string } }[] 구조 → 클라이언트에 string[]로 내려야 할 때
return {
...post,
tags: post.tags.map(t => t.tag.name),
};
그래서 실제로는 어떻게 쓰고 있는가
위에서 여러 패턴을 정리했지만, 실무에서 전부 쓰는 건 아니다.
현재 프로젝트에서는 Swagger를 쓰고 있기 때문에, 응답 DTO는
@ApiProperty() 붙은 class로 정의한다. 서비스 메서드에서는 select로 필요한 필드만 뽑은 뒤, 리터럴 객체로 반환하면서 satisfies로 shape을 검증한다.typescript
// dto/user-response.dto.ts
export class UserResponseDto {
@ApiProperty({ description: '사용자 ID', example: 'clxyz...' })
id: string;
@ApiProperty({ description: '이메일', example: 'user@example.com' })
email: string;
@ApiProperty({ description: '닉네임', example: 'sillysillyman' })
nickname: string;
@ApiProperty({ description: '역할', enum: Role, example: Role.USER })
role: Role;
@ApiProperty({ description: '가입일' })
createdAt: Date;
}
// Service에서
async findById(id: string): Promise<UserResponseDto> {
const user = await this.prisma.user.findFirst({
where: { id, deletedAt: null },
select: { id: true, email: true, nickname: true, role: true, createdAt: true },
});
if (!user) throw new NotFoundException(`User ${id} not found`);
return user satisfies UserResponseDto;
}
이게 끝이다.
plainToInstance도, @Exclude()도, new UserResponseDto()도 없다.다만
satisfies의 동작을 정확히 이해하고 써야 한다. 이를 위해 TypeScript의 두 가지 타입 검사 방식을 알아야 한다.TypeScript는 기본적으로 structural subtyping으로 동작한다. 타입이 요구하는 프로퍼티를 모두 가지고 있으면 추가 프로퍼티가 있어도 호환되는 것으로 본다.
{ id, email, nickname, password } 타입은 { id, email, nickname } 타입의 서브타입이다. 필요한 건 다 있으니 OK라는 논리다.하지만 object literal을 타입이 지정된 대상에 직접 할당할 때는 예외적으로 excess property checking이 동작한다. 타입에 정의되지 않은 프로퍼티가 있으면 컴파일 에러를 발생시킨다. object literal은 "지금 여기서 처음 만든 값"이니까, 안 쓰이는 프로퍼티가 있으면 실수일 가능성이 높다는 이유다. 변수를 거치면 이 검사가 적용되지 않는다.
satisfies도 이 규칙을 따른다. 객체 리터럴에 satisfies를 붙이면 excess property checking이 동작한다. DTO에 없는 필드를 넣으면 컴파일 에러가 발생한다.typescript
// ✅ 객체 리터럴 + satisfies: 양방향 검증
return {
id: user.id,
email: user.email,
nickname: user.nickname,
password: user.password, // 컴파일 에러: DTO에 없는 필드
} satisfies UserResponseDto;
하지만 변수에
satisfies를 붙이면 structural subtyping이 적용되어 추가 필드가 있어도 통과한다.typescript
// ⚠️ 변수 + satisfies: 단방향 검증
const user = await this.prisma.user.findFirst({
select: { id: true, email: true, nickname: true, password: true },
});
return user satisfies UserResponseDto;
// 컴파일 통과: id, email, nickname이 있으니 UserResponseDto를 만족
// 하지만 password가 응답에 포함됨
이 차이가 중요한 이유는, select 결과를 변환 없이 그대로 반환할 때 두 번째 케이스에 해당하기 때문이다. 다만 Prisma는 select에 지정한 필드만 반환하기 때문에, select 자체가 DTO와 정확히 일치하면 변수에 남은 필드가 있을 수가 없다. 이 경우에는 변수에 바로
satisfies를 붙여도 안전하다.결국 정리하면:
- select 결과를 그대로 반환하는 경우: select과 DTO 필드가 일치하면
return user satisfies UserResponseDto로 충분하다. - 가공이 필요한 경우 (날짜 포맷 변환, 관계 flatten 등): 객체 리터럴로 반환하면 excess property checking까지 동작한다.
Prisma.UserGetPayload<{ select: typeof x }> 타입을 별도로 정의하는 패턴은 쓰지 않고 있다. Swagger를 쓰는 이상 class가 이미 존재하고, satisfies class로 검증하면 되기 때문이다. GetPayload 타입이 필요한 건 Swagger 없이 type만으로 DTO를 정의하거나, implements로 class와 Prisma 타입을 양방향 동기화할 때인데, satisfies만으로 충분한 상황에서 굳이 타입 alias를 하나 더 만들 이유가 없었다.implements Prisma.UserGetPayload<...>로 Swagger class와 Prisma 반환 타입을 강하게 묶는 방식도 고려했지만, 현재 규모에서는 오버엔지니어링이라고 판단했다. 객체 리터럴로 반환하면 satisfies만으로 양방향 검증이 되고, select 객체와 class 정의가 같은 파일에 있으면 불일치를 눈으로도 잡을 수 있다.돌아보며
class-transformer기반 직렬화는 TypeORM에서 온 패턴이다. NestJS 공식 문서의ClassSerializerInterceptor+@Exclude()는 ORM이 class instance를 반환하는 환경을 전제로 한다. Prisma는 plain object를 반환하기 때문에 이 패턴이 자연스럽게 동작하지 않는다. Prisma를 쓴다면select기반 타입 추론을 활용하는 것이 관용적이다.- Response DTO는 "내보낼 것만 정의"하는 것이 목적이다. 숨길 필드를 나열하는
@Exclude()방식은 이 원칙에 어긋난다. satisfies는 Prisma와 궁합이 좋다. 런타임 오버헤드 없이 컴파일 타임에 shape을 검증할 수 있다. 리터럴 객체 반환, select 객체 정의, 필터 조합 등 다양한 곳에서 활용할 수 있다.- Swagger가 필요하면 class를 정의하고
satisfies로 검증하자. 객체 리터럴로 반환하면서satisfies를 붙이면 빠진 필드와 남은 필드 모두 컴파일 타임에 잡아준다.implements까지 가지 않아도 대부분의 상황에서 충분하다. - 다른 프레임워크의 패턴을 그대로 가져오지 말자. Spring의 정적 팩토리 메서드 패턴이나 TypeORM의
@Exclude()패턴은 각각의 맥락에서 합리적이지만, Prisma에서는 불필요한 보일러플레이트가 된다. 도구가 제공하는 타입 시스템을 최대한 활용하는 것이 맞다.
참고
- NestJS 공식 문서 - Serialization
- Prisma 공식 문서 - Operating against partial structures of model types
- Prisma 공식 문서 - Type safety
- Prisma Blog - How TypeScript 4.9
satisfiesYour Prisma Workflows - GitHub prisma/prisma #7377 - Type for Prisma query results
- GitHub prisma/prisma #9233 - How to serialize Prisma Object in NestJS?
- wanago.io - API with NestJS #112. Serializing the response with Prisma