
매쉬업 노드팀 10분 세미나를 준비하면서 이번 부트캠프 프로젝트가 생각났어요.
제가 백엔드를 설계할 때 무엇을 먼저 떠올리는지 다시 보게 됐어요.
저는 원래 NestJS 스타일에 익숙했어요.
뭔가를 만들기 시작하면 자연스럽게 이런 흐름으로 갔어요.
class-validator로 validation 규칙을 붙이고이 방식은 분명 장점이 많았어요.
구조도 잘 잡히고, 역할도 명확하고, NestJS와도 정말 잘 맞았어요.
저도 오랫동안 이 방식이 자연스럽다고 생각했어요.
설계를 시작할 때도 늘 먼저 드는 생각은 비슷했어요.
어떤 클래스를 만들어야 하지?
이 책임은 어느 클래스에 둬야 하지?
그런데 이번 프로젝트에서는 그 감각이 조금 안 맞았어요.
좋은 class를 먼저 나누는 것보다, 같은 데이터 규칙을 끝까지 같은 방식으로 유지하는 것이 더 중요하게 느껴졌어요.
그래서 이번 글에서는, 제가 왜 익숙한 방식에서 다른 기준점으로 이동하게 됐는지 정리해보려고 해요.
제가 원래 익숙했던 방식은 전형적인 NestJS 스타일이었어요.
예를 들면 이런 식이었어요.
export class CreatePostDto {
@IsString()
@Length(1, 100)
title: string;
@IsString()
content: string;
}
DTO class를 만들고, 여기에 decorator로 validation 규칙을 붙였어요.
TypeORM을 쓴다면 Entity도 보통 이런 식이었어요.
@Entity()
export class Post {
@PrimaryGeneratedColumn()
id: number;
@Column()
title: string;
@Column()
content: string;
}
이 구조는 분명 편했어요.
그래서 저도 한동안은 이게 정석이라고 생각했어요.
그런데 이 방식으로 계속 가다 보니, 설계할 때 질문이 항상 class 중심으로 흐른다는 걸 느꼈어요.
처음에는 이게 당연하다고 생각했는데, 프로젝트를 하다 보니 이런 생각이 들기 시작했어요.
지금 나한테 더 중요한 건 좋은 class 구조일까?
아니면 한 번 정한 데이터 규칙을 끝까지 같은 방식으로 가져가는 걸까?
가장 크게 불편했던 건, 결국 같은 데이터를 여러 번 설명하게 된다는 점이었어요.
예를 들어 어떤 요청이 하나 들어온다고 하면 보통 이렇게 돼요.
물론 각각의 역할은 달라요.
그런데 실제로는 같은 데이터를 여러 곳에서 반복해서 설명하는 느낌이 들었어요.
예를 들면 이런 거예요.
title은 문자열이어야 하고title은 몇 글자까지 허용되고content는 필수고이런 정보가 DTO, 타입, 문서, 모델에 흩어지기 시작해요.
그 결과는 보통 비슷했어요.
저는 이걸 단순히 중복의 문제라고 보기보다, 협업 비용의 문제라고 느꼈어요.
특히 이번 프로젝트는 부트캠프 프로젝트였고, 프론트와 백엔드가 모두 처음인 팀원들도 있었어요.
그런 상황에서는 사람이 요청/응답 형식을 계속 다시 해석해야 하는 구조가 더 부담스럽게 느껴졌어요.
이번 경험을 정리하면서 ORM도 다시 보게 됐어요.
TypeORM은 제가 느끼기에 가장 객체지향적인 감각이 강했어요.
Entity class가 중심이고이 엔티티는 어떤 책임을 가지지?로 흘렀어요이건 장점이기도 했어요.
특히 도메인 모델을 풍부하게 설계하고 싶을 때는 분명 잘 맞는 방식이라고 생각했어요.
Prisma는 느낌이 조금 달랐어요.
schema.prisma를 기준으로 DB를 정의하고다만 제 경험상 Prisma는 DB schema는 정말 좋았지만, API 명세까지 하나의 기준점으로 이어진다는 느낌은 덜했어요.
실제로는 요청 validation이나 응답 형식이 다시 다른 계층으로 분리되기 쉬웠어요.
Drizzle은 저한테 TypeScript 안에서 schema와 query를 더 가깝게 다루는 느낌을 줬어요.
이게 왜 중요했냐면, 저는 단순히 DB 정의만 하고 끝내고 싶지 않았거든요.
그리고 여기서 같이 중요했던 게 drizzle-zod였어요.
drizzle-zod는 Drizzle schema를 기준으로 Zod schema를 만들어주는 도구예요.
예를 들면 Drizzle에서 테이블 구조를 정의해두고, 그걸 다시 Zod validation schema로 자연스럽게 이어갈 수 있어요.
즉, DB 구조를 이미 한 번 정의해뒀는데 요청/응답 형식을 위해 비슷한 구조를 또 다시 쓰는 부담을 줄여준다고 느꼈어요.
제가 그리고 싶었던 흐름은 이런 느낌이었어요.
Drizzle schema -> drizzle-zod -> Zod schema -> OpenAPI -> Orval -> Frontend
즉, DB 정의와 API 명세가 너무 멀리 떨어져 있지 않은 구조를 원했어요.
Drizzle은 그 점에서 꽤 잘 맞는다고 느꼈어요.
validation 쪽에서는 차이가 더 선명하게 느껴졌어요.
class-validator는 보통 이런 식으로 쓰잖아요.
export class CreateCapsuleDto {
@IsString()
@MinLength(1)
title: string;
@Matches(/^\d{4}$/)
password: string;
}
이 방식은 결국 class 선언에 validation 규칙을 얹는 방식이라고 생각했어요.
반면 Zod는 이런 식이었어요.
const createCapsuleBodySchema = z.object({
title: z.string().min(1),
password: z.string().regex(/^\d{4}$/),
});
그리고 요청이 들어오면 plain object를 바로 parse해요.
const payload = createCapsuleBodySchema.parse(req.body);
제가 느낀 핵심 차이는 문법이 아니었어요.
class-validator는 class 중심Zod는 data 중심즉,
이번 프로젝트에서는 검증하고 싶은 대상이 class라기보다, 결국 요청/응답 형식이었어요.
그래서 Zod 쪽이 더 자연스럽게 느껴졌어요.
이번 프로젝트에서 Zod를 쓰고 싶었던 이유는 세 가지였어요.
NestJS에서는 DTO class, ValidationPipe, decorator 기반 흐름이 자연스럽잖아요.
그런데 Express에서는 훨씬 더 얇은 레이어 위에서 요청 데이터를 다루게 돼요.
이때는 DTO class를 만들고 transform하는 방식보다, 그냥 plain object를 schema로 바로 parse하는 흐름이 더 잘 맞는다고 느꼈어요.
저는 reflect-metadata, class-transformer, decorator 기반 흐름보다,
입력값을 바로 parse하는 쪽이 더 단순하고 lean하다고 느꼈어요.
성능을 엄밀히 벤치마크한 건 아니지만, 적어도 실행 경로와 의존성 복잡도 면에서는 Zod가 더 단순하다고 판단했어요.
이게 제일 중요했어요.
저는 이번 프로젝트에서
이 DTO class가 유효한가?
보다
이 요청 데이터는 어떤 규칙을 만족해야 하지?
를 먼저 쓰고 싶었어요.
즉 class-first보다 schema-first가 더 잘 맞았어요.
그리고 이건 단순히 개인 취향 때문만은 아니었어요.
모두가 같은 요청/응답 형식을 보고, 같은 기준으로 이해할 수 있게 만들고 싶었거든요.
여기에 drizzle-zod도 꽤 큰 역할을 했어요.
Drizzle에서 정의한 구조를 Zod schema와 더 자연스럽게 이어갈 수 있으니까, validation을 따로 또 다른 세계의 정의처럼 느끼지 않게 해줬어요.
이 부분은 오해하기 쉬워서 꼭 적고 싶었어요.
Zod를 쓰면 DTO가 없어지는가?
제 생각엔 아니었어요.
DTO의 개념이 사라진 게 아니라, DTO를 정의하는 방식이 바뀐 것에 가까웠어요.
예전에는 이렇게 했어요.
class CreateCapsuleDto {
@IsString()
title: string;
}
이번 프로젝트에서는 이렇게 갔어요.
const createCapsuleBodySchema = z.object({
title: z.string(),
});
type CreateCapsuleInputDto =
z.infer<typeof createCapsuleBodySchema>;
const payload =
createCapsuleBodySchema.parse(req.body);
여기서 payload의 타입은 사실상 CreateCapsuleInputDto예요.
즉 이렇게 정리할 수 있었어요.
DTO가 없어진 게 아니라,
DTO를 class로 직접 선언하던 방식에서
schema를 기준으로 타입을 파생시키는 방식으로 바뀐 거예요.
이건 저한테 꽤 중요한 관점 변화였어요.
이번 프로젝트에서 제가 중요하게 본 건 결국 Single Source of Truth였어요.
쉽게 말하면 이거예요.
한 번 정한 데이터 규칙을 끝까지 같은 방식으로 가져가고 싶었어요.
같은 데이터를
특히 이번 프로젝트는 부트캠프 프로젝트였고, 팀원들이 프론트와 백엔드 모두 처음이었어요.
그래서 사람이 요청/응답 형식을 계속 반복해서 해석하는 구조를 줄이는 게 더 중요하게 느껴졌어요.
저는 검증, 타입, 문서의 기준점을 class가 아니라 schema에 두고 싶었어요.
물론 DB schema와 API schema가 완전히 같은 건 아니에요.
그래도 drizzle-zod를 쓰면 적어도 DB 정의와 validation 정의 사이의 거리를 꽤 줄일 수 있다고 느꼈어요.
저는 이 점이 schema-first 흐름을 더 실용적으로 만들어준다고 생각했어요.
이 프로젝트에서 제가 가장 만족했던 부분은 여기였어요.
저는 schema를 단순히 백엔드 내부 validation 규칙으로만 두고 싶지 않았어요.
그걸 프론트와 백엔드가 함께 보는 API 명세의 기준점으로 만들고 싶었어요.
그래서 흐름을 이렇게 만들었어요.
Zod Schema -> OpenAPI -> Orval -> Frontend Client
구체적으로는:
zod-to-openapi로 OpenAPI를 생성하고즉, 백엔드가 만든 API 명세를 프론트가 다시 손으로 옮겨 적지 않게 만들었어요.
특히 이 부분은 제가 직접 설정했어요.
왜냐하면 프론트와 백엔드가 모두 처음인 상황에서는, 사람이 문서를 읽고 다시 타입을 맞추는 과정에서 오해가 정말 많이 생길 수 있다고 느꼈거든요.
제가 원했던 건 단순히 문서를 자동 생성하는 게 아니었어요.
제가 원했던 건:
이게 제가 말하고 싶은 SSOT의 실제 구현이었어요.
이 관점 변화는 코드 구조에서도 바로 드러났어요.
const payload =
createCapsuleBodySchema.parse(req.body);
type CreateCapsuleInputDto =
z.infer<typeof createCapsuleBodySchema>;
return capsulesService.createCapsule(payload);
이 코드에서 중요한 건 문법보다 흐름이었어요.
req.body를 schema로 바로 검증했어요DTO class를 만들고 transform하는 대신, 들어온 plain object를 바로 schema로 검증했어요.
별도 DTO type을 또 정의하는 대신, z.infer로 schema에서 타입을 가져왔어요.
payload는 schema를 통과한 값이고, 그게 그대로 service로 넘어갔어요.
여기서 service는 풍부한 도메인 객체라기보다,
검증된 데이터를 받아 저장하고 후속 처리를 이어주는 orchestration에 가까웠어요.
즉, 설계의 시선이 객체보다 데이터 흐름 쪽으로 이동했다고 느꼈어요.
저는 이걸 OOP를 버렸다고 말하고 싶진 않았어요.
오히려 설계의 질문이 달라졌다고 말하는 게 더 정확하다고 생각했어요.
예전에는 먼저 이런 질문을 했어요.
어떤 class가 이 책임을 가져야 하지?
이번에는 이런 질문을 먼저 하게 됐어요.
이 데이터는 어떤 규칙을 통과하고
어떤 shape로 흘러가지?
이건 OOP가 틀렸다는 뜻은 아니에요.
복잡한 도메인에서는 여전히 class 중심 설계가 더 잘 맞을 수 있다고 생각해요.
하지만 이번 프로젝트처럼 API 명세와 협업 비용이 중요했던 상황에서는 schema-first가 더 강하게 느껴졌어요.
이번 프로젝트를 지나면서 제가 얻은 결론은 꽤 명확했어요.
한 문장으로 정리하면 이거예요.
예전에는 class를 먼저 설계하려 했고,
이번에는 데이터 규칙을 먼저 세우려 했어요.
그래서 저는 이번 프로젝트를 지나면서
Entity와 DTO 대신 Schema를 믿기로 했다고 말하고 싶었어요.
이 글은 NestJS는 별로고 Zod가 정답이다라는 이야기를 하려는 글은 아니에요.
오히려 이런 이야기예요.
제가 느낀 건 결국 하나였어요.
더 나은 도구를 찾았다기보다,
더 중요한 기준이 무엇인지 다시 보게 됐어요.
혹시 비슷한 고민을 하고 있다면, 아래 질문부터 해보면 좋겠다고 생각했어요.
이 질문에 대한 답이 바뀌면, 기술 선택도 꽤 달라질 수 있다고 생각해요.