서비스 초기에는 빠른 개발이 최우선 목표였기 때문에 별도의 API Docs 없이도 큰 문제가 없었다.
인증, 리소스 조회처럼 의도가 명확한 API들이 대부분이었고, Endpoint와 HTTP Method만 봐도 어떤 역할을 하는지 쉽게 유추할 수 있었기 때문이다.
하지만 서비스가 성장하면서 API의 개수가 급격히 늘어났고, Request / Response 구조가 복잡해졌으며, 단순 CRUD를 넘어 의도를 이해해야 하는 API들이 등장했다.
이 글은 서비스와 함께 성장하면서 개발 플로우와 협업 방식을 어떻게 계속 발전시켜왔는지,
그리고 그 과정의 끝에서 ts-rest과 여러 도구들을 통해 프론트엔드와 백엔드가 어떻게 최적화된 개발 사이클을 갖추게 되었는지를 정리해보았다.
1. Swagger + Postman : API 명세의 가시성 확보
Swagger는 프레임워크 단에서 자동 생성 해주지만, 기본 설정만으로는 Endpoint와 Base Response 정도만 확인할 수 있어 정보가 매우 부족했다.
그래서 새로 추가되는 API에는 API에 대한 설명과 목적, Request Data의 Typing, 각 Status Code에 대한 Example, Response DTO 명시를 추가하도록 하였다.
API 개발 시 Swagger Cursor Rule을 참조하도록 하여 직접 문서를 작성하지 않아도 Swagger가 자연스럽게 채워지도록 했다.
따라서 직접 설명을 치거나, 예시를 수작업으로 추가하지 않아도 되고, 큰 추가 공수 없이 Swagger 환경을 만들 수 있었다.
다음으로 진행한 것은 Postman이다.
인증 Token이나, 원하는 Body를 자유롭게 구성하는 등 Swagger가 제공하는 Playground에는 테스트에 한계가 있다고 생각했다.
이건 백엔드 입장에서도 필요하다고 생각이 든 것이, API Test 목적에서도 상당한 이점을 가져올 것이라고 생각했다.
그리고 Postman에서는 Test Environment 설정이 가능해서, Local, Dev, Stage, Production 환경을 분리해서 원하는 환경에서 작동하도록 하였다.

사진과 같이 Path Variable을 명시적으로 설정할 수도 있고, Global하게 Base URL, Bearer Token 등을 설정할 수 있어서 테스트 할 때는 이미 갖추어진 환경에서 Send 버튼만 누르면 바로 API를 테스트 할 수 있도록 하였다.
이 부분은 수작업이 들어가는 부분이지만, 백엔드도 개발하면서 테스트 할 겸, 프론트도 보고 정확한 API 동작을 파악할 겸 해서 큰 공수를 들이지 않는 선에서 도입하였다.

따라서 모든 API를 Postman에서 테스트 할 수 있게 되면서, API 테스트 비용이 줄었고 장애 발생 시 원인 파악 속도도 눈에 띄게 빨라졌다.
2. Docs / Cursor Rules : API를 넘어서 기능을 설명하기
Swagger와 Postman으로 API 명세 문제는 상당 부분 해결되었다.
하지만 개발하는 기능들이 점점 복잡해지고, API Endpoint의 역할과 설명을 읽어도 넘어 그 API의 의도, 이 API의 원리를 파악하기가 어려워지는 문제가 생겼다.
아래는 브랜치 기능을 개발하면서 우리가 실제로 느낀 문제점이었다.
[브랜치 관련 글]
브랜치 기능을 도입하면서 prepare-inference 라는 API가 새로 생겼는데, 이 API는 채팅을 보내기 전에 호출하고, 이 채팅으로 브랜치가 만들어지거나, branchIds가 바뀌거나, 아니면 아무 동작도 일어나지 않는지를 DB 단에서 확인해서 반환하는 API다.
이 API는 복잡한 브랜치 구조 상, Deterministic 한 동작을 위해 필요하다고 생각했다.
브랜치가 만들어지는 경우는 Regen 한 채팅 중에, 브랜치가 확정되지 않은 채팅을 Select 하고 보냈을 때고, 이 판단을 프론트가 하는 것은 임시 브랜치를 들고 있어야 하는 프론트 입장에서는 하는 것이 좋지 않다고 판단했고, 백엔드에 이 로직을 맡기는 것이 바람직하다고 생각했다.
또한, branchIds가 바뀌거나 새 브랜치가 생성되었을 때 바뀐 채팅이나 새 Branch에 대한 정보를 받아야 하기 때문에 prepare-inference 라는 API는 반드시 필요했다.
이와 같이 prepare-inference라는 API는 이름 만으로는 정확한 동작을 파악하기 힘들고, 리턴 형태에 따라 프론트는 다른 동작을 해야 하는데, 그 의도와 동작을 정확히 이해해야 Integration을 할 수 있었다.
따라서 API 단의 이해가 아니라, 기능의 원리나 의도, 이 기능이 통합된 API의 흐름 등 더 큰 범위에서의 파악을 위해 우리는 Docs를 도입했다.
프로젝트의 /docs/feature 폴더에 개발하는 기능에 대한 문서를 매우 자세히 작성한다.
문서 작성은 Cursor나 LLM을 사용해서 적어도 되나, 개발자가 최종 확인을 하는 구조로 오류 없는 Docs 작성을 목표로 했다.
우리 같은 경우는 아래와 같이 해당 Feature와 연결된 여러 문서를 작성하도록 하였다.
/docs/feature/branch/policy.md/docs/feature/branch/api.md/docs/feature/branch/implementation.md/docs/feature/branch/flow.md/docs/feature/branch/migration.md
API는 각 Endpoint의 간단한 설명과 동작, 그리고 의도를 설명하도록 하였다.
Implementation은 Service, Provider 계층의 비즈니스 로직과 Interaction을 설명하도록 하였다.

Policy와 Flow에서는 이 Feature에 대한 자세한 Policy, 프론트에서는 어떤 API를 호출하여 어떤 Flow로 사용하게 되는지 등 Feature와 API에 대한 설명을 자세히 하도록 하였다.

Migration은 배포 시 백엔드에서 진행해야 하는 DB Migration에 대한 Docs를 작성하도록 하였다.
Docs를 도입하면 위에서 언급했던 프론트엔드 단에서 API의 의도 이해 문제를 많은 부분 해결이 가능하고, 백엔드 입장에서는 API를 업데이트하거나 DB Migration 시 두 번 생각할 필요 없이 작업을 할 수 있다.
또한, 프론트/백 양쪽에서는 개발 시 이 문서들을 Cursor Rule로 정의해서 개발 시 컨텍스트로 제공해 이 Feature의 컨셉과 구현 방향, 그리고 의도까지 한 번에 전할 수 있어 반복되는 코드나 어렵지 않은 구현은 모두 Cursor에게 맡길 수 있다.
이렇게 하면 개발자는 기본적인 구현은 할 필요 없이 최적화나 특별히 집중해야 하는 로직 위주로 보면서 빠르게 개발할 수가 있다.
백엔드는 Controller부터 Provider까지 모든 계층의 1차 구현을 Cursor Rule에게 맡겨서 계속 반복되는 기본적인 CRUD 같은 건 빠르게 개발할 수 있게 되었고, 프론트엔드는 기능의 컨셉과 정책을 Cursor Rule로 넘겨서 정책에 따른 버튼 State, Animation Interaction 등 자잘하지만 시간이 오래 걸리는 디테일한 부분을 잡아 빠르게 개발할 수 있었다.
3. ts-rest : Integration은 Contract를 기반으로
우리는 비용 최적화 과정의 일환으로 백엔드의 일부를 Python에서 TypeScript로 마이그레이션 하였다.
Prisma가 Python에서 메모리를 너무 많이 먹어서 Cluster Node의 Spec이 트래픽에 비해 과도하게 프로비저닝 되었던 문제가 있었다.
당시 Creator Contest를 진행하고 있어 큰 기능 개발은 1-2주 정도 대기하고 있는 상황이었고, 동시에 프론트엔드는 위생 상태 개선 등 서비스의 질을 높이기 위한 개선에 들어갔기 때문에, 백엔드도 Migration을 진행하게 되었다.
예상 치로 Migration을 함으로써 비용 절약이 약 80% 정도 예상 되었기 때문에 지금이 적기라고 생각해서 빠르게 진행하게 되었다.
비용 분석 내용
Backend Nodes (us-west-1)
- Python Backend Server는 항시 메모리를 2.5-3GiB 정도 먹기에 적어도 t4g.medium 이상이어야 한다.
- 기본 Pod은 3개로 운영한다.
- 테스트 결과 Nest.js Backend Server는 항시 메모리를 250-400MiB 정도 먹기에 적어도 t4g.micro 이상이어야 한다.
[이전]
- On-demand t4g.medium 1대
- Spot t4g.medium 2대
$29.76 (On-demand) + $25.296 (Spot) = $55.056 / mo
[이후]
- On-demand t4g.micro 1대
- Spot t4g.micro 2대
$7.44 (On-demand) + $4.464 (Spot) = $11.904 / mo
[결과]
1 – (11.904 / 55.056) = 약 78.4%
Python 프로젝트 전체를 Cursor Context로 전달하고, Nest 프로젝트의 구조와 컨벤션을 Cursor Rule로 정의한 뒤 약 1주 반 만에 Inference를 제외한 모든 백엔드 API를 Nest.js로 마이그레이션할 수 있었다.
다만 이 과정에서 API 네이밍 컨벤션이 전면 수정되었고, Request Body 구조 역시 상당 부분 변경되면서 프론트엔드에서도 모든 변경 사항을 직접 반영해야 하는 상황이 발생했다.
예를 들어 메모리 기능과 관련된 API가 변경되었을 경우, 프론트엔드에서는 useMemory 훅 내부에서 memory.ts에 정의된 httpRequest 함수의 URL이나 Request Body를 직접 수정하고, 실제로 요청을 보내 정상 동작하는지 하나씩 확인해야 했다.
const insertMemoryResult = await httpRequest<Memory>(
`${process.env.REACT_APP_BACKEND_DOMAIN}/api/v1/memory/${memoryId}`,
'POST',
{ memory }
)이러한 작업은 마이그레이션 과정에서만 발생하는 문제가 아니었다.
새로운 API가 추가될 때마다 엔드포인트와 요청 형식을 확인한 뒤, 위와 같은 호출 함수를 직접 작성해야 했고, 이 과정은 점점 반복적인 보일러플레이트 작업처럼 느껴지기 시작했다.
우리가 원하는 목표는 API의 타입과 호출 구조를 백엔드에서 단일하게 관리하고, 프론트엔드와 백엔드 양쪽에서 동일한 스펙을 기준으로 개발하되, 변경 사항이 발생했을 때는 런타임 에러가 아니라 컴파일 타임에서 문제를 인지할 수 있는 구조를 만드는 것이었다.
이러한 배경에서 기존에 Swagger, Postman, Docs 형태로만 관리하던 API 스펙을 한 단계 더 발전시켜, API를 문서가 아닌 Contract로 관리하기 위해 ts-rest를 도입하게 되었다.
API를 Contract로 관리한다는 것은, 프론트엔드가 구체적인 URL이나 세부 구현을 알지 않아도 사전에 합의된 Contract를 통해 API를 호출할 수 있도록 만드는 것을 의미한다.
우리 서비스는 프론트엔드는 Next.js, 백엔드는 Nest.js로 구성된 TypeScript 기반 환경에서 동작하고 있기 때문에, 프론트엔드에서 리소스 타입을 별도로 정의하지 않고 Contract에 정의된 타입을 그대로 사용하는 구조를 만들 수 있었다. 이를 통해 프론트엔드와 백엔드 간의 타입 불일치 가능성을 최대한 줄이고자 했다.
다만 ts-rest로 Contract를 작성하는 것 자체도 하나의 개발 공수가 될 수 있기 때문에, API 구현 시 실제 모듈 코드를 참고해 ts-rest Contract를 함께 작성하도록 하는 Cursor Rule을 추가했다.
최종 목표는 Contract 작성이 기존 개발 흐름에 자연스럽게 포함되도록 만들어 체감 공수를 거의 0에 가깝게 줄이는 것이었다.
기존의 API 개발 흐름은 다음과 같았다.
- 백엔드에서 API 구현
- Swagger나 문서를 통해 API 스펙 공유
- 프론트엔드에서 이를 기반으로 Integration 진행
이 방식에서는 API 스펙이 코드가 아닌 문서에 의존하게 되고, 문서와 실제 구현이 어긋나는 순간부터 API 스펙에 대한 신뢰도가 급격히 떨어진다는 문제가 있었다.
ts-rest는 이 문제를 API 스펙을 TypeScript 타입 자체로 정의하고, 그 타입을 프론트엔드와 백엔드가 동시에 사용하는 방식으로 해결한다.
ts-rest에서의 Contract는 다음 정보를 모두 포함한다.
- API URL
- HTTP Method
- Request Body / Query / Params
- Response Status Code와 Response Body
이 Contract 자체가 API 스펙의 Single Source of Truth가 된다.
프로젝트 구조상 백엔드 구현 코드는 /src/**에 위치해 있었고, ts-rest Contract는 /packages/contracts 디렉토리에서 별도로 관리했다.
ts-rest는 백엔드의 서비스나 모듈과 직접 연결되지 않고, API URL을 기준으로 요청을 보내는 역할만 수행해야 했기 때문에 두 코드 영역은 명확히 분리되어야 했다.
API가 추가되거나 명세가 변경될 때마다 Contract를 수동으로 수정하는 작업은 충분히 반복 작업이 될 수 있었기 때문에, Cursor Rule을 활용해 API가 추가·삭제·변경되는 경우 /packages/contracts/**의 ts-rest Contract도 반드시 함께 수정되도록 규칙을 정의했다.
또한 ts-rest 코드를 작성할 때는 항상 .cursor/rules/ts-rest/**에 정의된 Rule Conventions을 참고하도록 하여, Contract, Controller, Schema, Test 코드가 항상 예측 가능한 형태로 작성되도록 했다.
contract.mdccontroller.mdcexceptions.mdcschemas.mdctests.mdc
아래는 Chat 모듈의 contract.ts의 일부이다.
export const chatContract = c.router({
updateDivergenceName: {
method: 'PATCH',
path: '/api/v1/chat/group/:chatGroupId/divergence-name',
pathParams: z.object({
chatGroupId: zObjectId,
}),
headers: z.object({
authorization: z.string(),
}),
responses: {
200: zUpdateDivergenceNameSuccess,
400: c.type<IApiException<ChatGroupNotDivergedException>>(),
404: c.type<IApiException<ChatGroupNotFoundException>>(),
},
body: zUpdateDivergenceNameRequest,
summary: 'Update divergence name',
description:
'Update the divergenceName field of a ChatGroup (used for labeling branch divergence points)',
},
...
})위 코드에서 알 수 있듯이, 하나의 API에 대한 모든 명세가 Typing까지 포함된 형태로 엄격하게 정의되어 있다.
이를 사용하는 Client 입장에서는 URL, HTTP Method, Params, Body 구조를 직접 알 필요 없이, updateDivergenceName라는 함수 이름을 통해 API를 호출할 수 있다.
모든 요청 인자와 Response는 타입으로 보호되기 때문에, API 스펙 변경으로 인한 오류는 런타임이 아닌 컴파일 타임에서 대부분 감지할 수 있게 되었다.
이 Contract 패키지는 npm private 패키지 형태로 배포하여 프론트엔드 프로젝트에서도 라이브러리처럼 사용할 수 있도록 구성했다.

또한 이 라이브러리는 환경(dev, stage, prod)별로 각각 배포되어 있으며, CI/CD Flow에서 빌드 단계에 맞춰 아래와 같이 필요한 버전을 설치하도록 했다.
pnpm add @nalyai/daly-backend-api@devpnpm add @nalyai/daly-backend-api@stagepnpm add @nalyai/daly-backend-api@latest
프론트엔드에서는 기능 단위로 훅을 만들고, ts-rest Contract 라이브러리를 활용해 API를 호출하는 패턴을 하나의 Convention으로 정의했다.
각 훅 내부에서는 useApiClient 훅을 통해 API 클라이언트를 로드하고, Contract에 정의된 함수들을 그대로 사용해 API를 호출한다. 이 방식에서는 URL이나 HTTP Method를 직접 다룰 필요가 없다.
useApiClient 훅은 내부적으로 useSession을 사용해 인증 정보를 관리하며, Authorization 헤더와 같은 공통 설정을 자동으로 처리한다.
프론트엔드에서는 API 호출 시 인증 헤더를 직접 신경 쓰지 않아도 되는 구조가 되었다.
export default function useMemory({ chatroomId }: { chatroomId: string }) {
const { status } = useSession()
const client = useApiClient()
const findBranches = useCallback(async (): Promise<BranchListResponse> => {
const response = await client.branch.getAll({
params: { chatroomId }
})
if (response.status === 200) {
return response.body.data as BranchListResponse
}
return []
}, [client, chatroomId])
const { data: branches } = useQuery({
queryKey: ['branches', chatroomId],
queryFn: findBranches,
enabled: status === 'authenticated' && !!chatroomId,
})
// ...
}또한 BranchListResponse와 같은 Response 타입 역시 프론트엔드에서 별도로 정의하지 않는다.
Contract 라이브러리에 정의된 타입을 그대로 import해서 사용하면 되기 때문에, 타입 정의를 중복으로 관리할 필요가 없어졌다.
import {
// ...
BranchListResponse,
// ...
} from '@.../daly-backend-api'이처럼 전체적인 개발 플로우를 정비하면서, 백엔드와 프론트엔드 모두에서 작업 속도와 안정성을 동시에 끌어올릴 수 있었다.
현재는 아래와 같은 흐름을 기준으로 개발이 진행되고 있다.
[Backend]
- 기능 정책과 구현, 플로우 등에 대한 Docs 작성
- Docs와 Cursor Rules로 API / Swagger / Test / ts-rest Contract 작성
- 코드 검증하면서 최종 수정 거치면서 Postman에 테스트 시나리오 추가
[Frontend]
- Contract 라이브러리를 Cursor Context로 활용해 기능 단위 훅 제작
- Figma, 백엔드 Docs, Contract Hook을 Cursor Context로 활용해서 페이지 제작 및 Integration
- 셀프 QA를 거치면서 기능 및 UI/UX 고도화
서비스 극초기에는 백엔드와 프론트엔드를 모두 혼자 개발하고 있었기 때문에, API와 기능 구조가 모두 머릿속에 정리되어 있어 별도의 문서 없이도 개발이 가능했다.
하지만 협업을 하게 되고 서비스 규모가 커지면서, 각자가 코드를 바라보는 시각과 문제를 접근하는 관점이 다르다는 것을 체감하게 되었다.
이 차이를 줄이고 팀 전체의 방향을 맞추기 위해서는 의식적인 Align 과정이 반드시 필요했다.
이를 위해 우리는 Docs 기반으로 서로의 생각과 의도를 공유했고, 더 깊은 Context 이해가 필요한 경우에는 1 on 1 미팅을 통해 빠르게 개념을 맞춘 뒤 개발을 진행했다.
다만 Docs나 회의만으로는 전체적인 방향성은 공유할 수 있었지만, 코드 레벨의 디테일이나 Request Body Format 같은 부분까지 명확히 맞추기에는 한계가 있었다.
이 지점에서 ts-rest를 도입하면서 이러한 문제를 효과적으로 해결할 수 있었다.
실제 서비스 개발은 혼자서 완성할 수 있는 영역이 아니기 때문에, 다른 사람과 함께 일하는 방식에 익숙해지는 것이 무엇보다 중요하다고 느꼈다.
개발 속도는 팀이 공유하는 기준과 구조가 매우 큰 영향을 끼친다고 생각한다.
정확한 정책과 규칙이 정의되어 있고, 팀원 모두가 이를 충분히 이해하고 있다면, 약속된 방식으로 동시에 개발을 진행할 수 있고 그만큼 속도도 자연스럽게 빨라진다.
또한 서로 다른 작업을 하나의 흐름으로 합치는 과정에서도, 공유된 기준과 구조가 명확하게 존재하기 때문에 빠르고 안정적으로 진행할 수 있었다.
우리는 매 스프린트가 끝난 뒤 회고를 진행하며, 개발이나 협업 과정에서 불편했던 점들을 팀원들과 투명하게 공유하고 있다.
앞으로도 이러한 과정을 통해 개발 과정의 불편함을 지속적으로 파악하고 개선해 나갈 생각이다.



댓글 남기기