Krong Dev.
Backend Nest.js TypeORM

NestJS 스터디 6주차 회고 — TodoList를 만들며 부딪힌 설계의 벽

TodoList 서버를 구현하며 부딪힌 DTO 분리·Update API 분리 고민과, 명세 우선 워크플로우로 돌아간 과정을 정리합니다.

NestJS 스터디 6주차 회고 — TodoList를 만들며 부딪힌 설계의 벽

그동안 게시판 프로젝트로 NestJS의 흐름을 익혔습니다. 이번 주차는 TodoList 서버를 직접 처음부터 설계하고 구현하는 차례였습니다.

Response DTO 를 어떻게 나눠야 할까? 기준은 뭐지?

Update API를 하나로 통일해야 할까, 기능별로 분리해야 할까?

두 질문 모두 코드 안에서 답이 나오지 않아 손이 멈췄습니다. 처음에는 ERD를 그리고 바로 구현에 들어갔는데, 매 결정마다 기준이 흔들렸고 앞으로 나아갈 수가 없었습니다. 직접 짠 ERD를 보면서도 “이 구조가 맞나?”라는 의구심은 사라지지 않았습니다.

실행 결과

이 글에서는 TodoList를 만들며 부딪힌 설계의 벽과, 이를 어떻게 풀어 나갔는지를 직접 겪은 흐름대로 정리합니다.


코드부터 짜다 멈춘 자리

기능명세는 머릿속에만 있고, 화면설계서나 와이어프레임 도 없는 상태에서 바로 컨트롤러 파일을 열었습니다. 처음 며칠은 게시판 때처럼 컨트롤러를 채우면서 빠르게 진도가 나갔습니다. 그러다 응답을 설계할 차례에서 멈췄습니다.

DTO를 처음 도입해 보면서

그동안의 게시판 프로젝트는 응답 형태가 단순해서 엔티티를 그대로 반환했습니다. 그러다 이전 글에서 응답에 비밀번호가 노출되는 사고를 한 번 겪고 나서, “엔티티를 그대로 반환하면 안 된다”는 감각은 얻은 상태였습니다. 다만 그때는 @Exclude() + ClassSerializerInterceptor로 임시 봉합한 수준이었지, 응답을 어떻게 설계해야 하는지에 대한 답은 없었습니다.

TodoList는 응답 형태가 게시판과 비교가 안 될 만큼 다양했습니다. 단건 조회(GET /todos/:id), 목록 조회(GET /todos), 태그를 포함한 응답, 반복 옵션을 포함한 응답까지 — 같은 투두라도 어떤 컨텍스트에서 가져오느냐에 따라 보내야 할 필드가 달랐습니다. 처음에는 모든 필드를 옵셔널로 가진 거대한 TodoResponseDto 하나를 만들어서 모든 응답에 같이 쓰려고 했습니다. 하지만 단건 조회에는 태그가 필요한데 목록 조회에는 성능 때문에 태그를 빼고 싶었고, 그 차이를 한 DTO로 표현하려니 옵셔널 필드만 늘어났습니다.

반대로 응답마다 DTO를 만들자니, “그러면 도대체 몇 개를 만들어야 하나”가 떠올랐습니다. 단건용, 목록용, 태그 포함용, 반복 포함용… 한 도메인에 DTO가 너무 많아지는 것도 이상했습니다. “Response DTO를 여러 개로 분리하는 게 맞나? 그렇다면 기준은 뭐지?” 라는 질문 앞에서 손이 멈췄습니다.


Update API를 통일할까, 분리할까

TodoList의 수정 동작은 한 종류가 아니었습니다. 제목·설명·마감일 같은 본체 필드 수정이 있고, 태그 변경, 반복 설정 변경, 삭제된 투두 복원처럼 수정의 성격이 서로 달랐습니다. 처음에는 모두 PATCH /todos/:id 하나로 묶으려고 시도했습니다. DTO는 모든 필드가 옵셔널인 UpdateTodoDto를 만들고, 클라이언트가 보낸 필드만 부분 업데이트하는 구조였습니다.

이 방식으로 짜다 보니 점점 어색해졌습니다. 태그 배열을 받았을 때는 기존 태그를 전부 날리고 새 배열로 덮어써야 하는데, 그 로직이 본체 필드 수정과 같은 핸들러 안에 들어가니 코드가 분기로 가득 찼습니다. 반복 설정도 마찬가지였습니다. recurrenceType, recurrenceStartAt, recurrenceEndAt 세 필드가 한꺼번에 들어와야 의미가 있는데, 본체 필드와 함께 옵셔널로 두니 “이 필드들 중 하나만 들어왔을 때 어떻게 처리하지?” 같은 질문이 계속 나왔습니다.

기능별로 분리하면 각 엔드포인트가 하나의 책임만 갖고 코드는 깔끔해집니다. 다만 API 표면이 늘어나고, 클라이언트가 한 번에 여러 종류를 수정하고 싶을 때는 요청을 여러 번 보내야 하는 비용이 생깁니다. “분리하면 편하긴 한데, 이게 진짜 좋은 방식인가?” 라는 질문이 풀리지 않았습니다. 두 방식 모두 장단이 명확해 보여서, 어느 쪽을 골라도 “내가 잘못 고른 건 아닐까”라는 의심이 따라왔습니다.


멈춘 이유는 결국 하나였다

두 질문 모두 코드 안에서는 답이 나오지 않았습니다. 명세가 없으니 매 결정마다 기준이 흔들렸고, 한 번 결정해도 다음 기능을 만들 때 다시 흔들렸습니다. 이 흔들림이 처음엔 “내가 아직 NestJS를 충분히 모르는 탓”이라고 생각했습니다. 하지만 결국 도구의 문제가 아니라 위 단계가 비어 있다는 신호였습니다. 코드를 멈추고, 더 위 단계의 사고를 해야 했습니다.

스터디와 지인에게 자문을 구하다

며칠을 더 코드 안에서 답을 찾아보려 했지만 같은 자리만 맴돌았습니다. 혼자 답이 안 나오니 스터디원과 현직 개발자 지인에게 같은 질문을 들고 갔습니다. 돌아온 답은 거의 같은 방향을 가리키고 있었습니다.

돌아온 답은 “코드보다 명세가 먼저”

DTO 분리 기준이든, Update API 분리 여부든, 코드 단계에서 정할 문제가 아니었습니다. 그 결정의 근거가 되는 건 “이 API가 어떤 화면에서, 어떤 데이터를, 어떤 모양으로 필요로 하는가”였습니다. 즉 결정의 근거는 화면이고, 화면을 추상화한 것이 기능명세이며, 기능명세를 데이터 표면으로 옮긴 것이 API 명세였습니다.

특히 인상적이었던 건 Update API 분리 기준에 대한 답이었습니다.

업데이트나 엔드포인트를 나누는 기준은 실제 화면 구성이 어떻게 되어 있느냐에 따라 방향이 바뀌는 거라, 정답은 없다.

이 한 줄을 듣고 나서야, 그동안의 질문이 잘못 출발했다는 게 보였습니다. 그동안은 “통일 vs 분리” 중에 어느 쪽이 더 옳은 방식인지 찾고 있었는데, 애초에 그 질문 자체가 잘못된 출발이었습니다. 화면이 어떻게 생겼는지, 사용자가 어떤 단위로 수정하는지에 따라 답은 매번 달라집니다. 즉 정답은 코드 패턴 안이 아니라 화면 안에 있고, 명세를 먼저 짜면 DTO도, Update API의 분리 여부도 거기서 자연스럽게 도출됩니다.


명세를 만드는 네 단계

화면설계서       →  사용자가 보는 모든 화면을 정의

와이어프레임      →  화면별 요소와 인터랙션의 골격

기능명세서       →  각 화면이 요구하는 동작과 비즈니스 규칙

API 명세서       →  기능명세를 HTTP 표면으로 변환

UI에서 시작해야 데이터 모델이 “왜 그래야 하는지”의 근거를 갖습니다. 데이터 모델부터 짜면, 화면이 정해질 때 모델이 또 흔들립니다. 화면 → 기능 → API 순으로 내려가야 위 단계가 아래 단계의 흔들림을 막아 줍니다. 그동안 백엔드만 만지다 보니 이 순서를 무의식적으로 거꾸로 잡고 있었다는 걸 이번에 깨달았습니다.


Claude Design과 Figma Make로 디자인까지 압축

화면설계서를 그리려면 디자인이 필요한데, 1인 개발 환경에서 디자인까지 직접 잡는 건 시간이 너무 많이 듭니다. Claude Design으로 화면 컨셉을 빠르게 뽑고, 그 결과물을 Figma Make에 프롬프트로 넘겨 와이어프레임 수준의 화면을 빠르게 형성했습니다. AI 도구를 쓰는 목적은 “디자인을 잘하는 것”이 아니라, “기능명세를 확정하기 위한 최소한의 화면 근거”를 마련하는 것이었습니다. 시간을 단축한 만큼 본질인 명세 작업에 더 많은 시간을 쓸 수 있었습니다.


API 명세서로 정리하고 나니 보이는 것들

자문에서 받은 답을 들고 작업을 멈췄던 자리로 돌아갔습니다. 화면설계서와 와이어프레임으로 사용자 흐름을 정리하고, 그 위에서 기능명세를 뽑은 뒤, 마지막에 API 명세서를 표 형태로 정리했습니다. Notion 표에 카테고리, HTTP Method, 기능명, Endpoint, 응답 모양, 요청 본문, 비고 컬럼을 잡고 한 줄씩 채워 갔습니다.

표를 채우다 보니 그동안 머릿속에서 뒤엉켜 있던 것들이 한 자리씩 자기 위치를 찾아갔습니다.

먼저 DTO 분리 문제가 풀렸습니다. 명세서의 각 엔드포인트가 응답 모양을 명시했더니, 어떤 응답에는 태그가 필요하고 어떤 응답에는 필요 없다는 게 한 줄에 드러났습니다. DTO는 “엔티티별”이 아니라 “응답 단위별” 로 나누면 됐습니다. 단건 응답, 목록 응답, 태그 포함 응답이 각각 별도의 Response DTO를 갖는 식입니다. 공통 필드는 부모 DTO에 두고 추가 필드만 자식 DTO에서 확장하면 중복도 피할 수 있었습니다. 기준은 추상적이지 않았습니다. 명세서에 적힌 응답 모양이 그대로 기준이었습니다.

Update API 분리 여부도 같은 방식으로 풀렸습니다. 명세서를 보니 사용자가 화면에서 수정하는 단위가 자연스럽게 드러났습니다. 본체 필드(제목·설명·마감일)는 한 화면에서 함께 수정되는 묶음이고, 태그 선택은 별도의 모달이나 화면에서 일어나며, 반복 설정도 별개의 옵션 화면에서 다뤄집니다. 화면에서 한 단위로 묶이는 것은 한 엔드포인트로, 화면에서 분리되는 것은 분리된 엔드포인트로 — 이 기준이 그제야 손에 잡혔습니다. 결과적으로 본체는 PATCH /todos/:id, 태그는 PATCH /todos/:id/tags, 반복 설정은 PATCH /todos/:id/recurrence, 복원은 PATCH /todos/:id/restore로 분리됐습니다. 이 분리는 구현 편의가 아니라 화면이 요구한 분리였습니다.

다시 구현하면서 새로 배운 것들

명세가 잡히고 나니 구현은 빠르게 진행됐습니다. 그 과정에서 그동안 몰랐던 도구 두 가지를 새로 익혔습니다.

@Transform@Type — 이름은 비슷하지만 역할이 다르다

둘 다 class-transformer 가 제공하는 데코레이터인데, 처음에는 언제 어떤 걸 써야 하는지 헷갈렸습니다.

@Transform은 값의 형태를 내가 직접 함수로 바꾸고 싶을 때 사용합니다. 데이터를 받자마자 지정한 함수를 실행해서 결과값으로 바꿔치기하는 방식입니다. 쿼리 스트링의 "true"/"false" 문자열을 실제 boolean으로 바꿀 때 유용합니다.

@Transform(({ value }) => value === "true")
isDone?: boolean;
// 클라이언트가 "?isDone=true"라고 보내면
// value는 "true"(문자열)이고, 결과값은 true(불리언)가 되어 저장됩니다.

@Type은 특정 클래스나 생성자 함수(Date, Number, 다른 DTO 클래스 등)를 기준으로 타입을 변환할 때 사용합니다. “이 필드는 무조건 이 타입(객체)이어야 한다”고 알려주는 가이드라인입니다. ISO 날짜 문자열을 Date 객체로 바꿀 때 필수입니다.

@Type(() => Date)
@IsDate()
dueFrom?: Date;
// 클라이언트가 "2026-04-28"을 보내면
// @Type이 이걸 new Date("2026-04-28")로 만들어서 Date 객체로 변환해 줍니다.

정리하면, @Transform은 “값 자체를 함수로 변환”, @Type은 “타입(클래스)으로 변환”이라는 차이로 기억하면 헷갈리지 않습니다.


In 연산자 — 여러 ID를 한 번에 조회하기

투두에 태그를 여러 개 한꺼번에 연결하는 기능을 만들 때 처음 써 봤습니다. TypeORM의 In 연산자는 SQL의 WHERE id IN (1, 2, 3)을 그대로 표현한 도구입니다. 출석부에서 “1번, 3번, 5번 학생만 나와”라고 부르는 것과 같다고 보면 됩니다. 리스트 하나만 넘기면 여러 개의 데이터를 한 번에 찾아 줍니다.

import { In } from "typeorm";

const tags = await this.tagRepository.find({
  where: { id: In(tagIds) },
});

만약 In을 모르고 있었다면, tagIdsfor 문으로 돌리며 findOneBy를 반복 호출했을 것입니다. 그게 바로 N+1 문제의 출발점입니다.


명세대로 구현 완료

명세서에 정리한 모든 엔드포인트를 차례로 구현했습니다. 흔들리던 자리가 명세 위에서는 흔들리지 않았습니다.

실행 결과

구현 디테일은 각 엔드포인트의 결과를 캡처하는 대신 명세서의 ‘완료’ 컬럼 하나로 대체했습니다. 명세서를 보면 구글 OAuth 로그인 항목만 ‘시작 전’으로 남아있는데, 이번 구현 범위에서는 의도적으로 제외했습니다. 소셜 로그인은 별도 OAuth 플로우와 외부 서비스 연동이 필요해서, 현재 스터디 단계에서 다루기엔 범위가 넓다고 판단했기 때문입니다.


마치며

TodoList를 만들며 손이 멈춘 지점에서 자문을 구했고, 결국 코드가 아니라 명세에서 시작했어야 한다는 답으로 돌아왔습니다.

직접 부딪히면서 두 가지가 확실히 와닿았습니다.

  • 명세는 단순한 산출물이 아니라 결정의 근거입니다. DTO 분리 기준도, Update API 분리 여부도, 코드가 아니라 명세 안에서 답이 나옵니다.
  • DTO나 API 분리는 코드 패턴 안에 정답이 있는 게 아니라, 화면이 결정합니다. “정답은 없다”는 말이 곧 “화면을 보라”는 뜻이었습니다.

다음 글에서는 이번 TodoList 프로젝트의 운영 준비 — Winston/Pino 연동과 Zod 기반 환경 변수 검증에 대해 정리할 예정입니다.