백엔드, 프론트엔드와 API 타입 공유하기

타입 공유를 선택한 계기
백엔드에서 API들을 구현하면서 다양한 API 타입들을 정의하게 되었다. 이 때, 프론트엔드와 백엔드에서 사용하는 타입을 공유하기로 결정했다. 이를 통해 다음과 같은 이점을 얻을 수 있었다.
- 타입 안정성
- 프론트엔드와 백엔드에서 사용하는 타입이 동일하다면, 타입 불일치로 인한 오류를 줄일 수 있다.
- 타입 불일치로 인한 오류를 빌드 타임에서 알 수 있다면, 런타임 에러를 줄여 코드의 안정성을 올릴 수 있다고 생각했다.
- 프론트엔드 개발자와의 협업
- 결국 프론트엔드와 백엔드는 인터페이스로 소통한다. 설계 단계에서 미리 타입을 정의하고 공유한다면, 각자의 개발이 더 수월해질 것이라고 생각했다.
- 타입스크립트의 장점
- 프론트엔드와 동일한 언어인 타입스크립트 사용의 장점을 최대한 활용할 수 있다.
타입 공유 방식 선정
타입 공유를 어떻게 하는 게 좋을까? 처음엔 다음과 같은 방식을 사용했다.
방법 1 : 프론트와 백엔드가 api-types 패키지를 주입 받기
이 방법은 처음에 생각했던 방법이다. 프론트와 백엔드에서 api-types
패키지를 주입 받아 사용하도록 했다.
따라서 각자 타입을 정의하기 위해선 모노레포의 api-types
패키지 내부에 타입을 정의하고, 이를 빌드한 후 사용했다.
그러나 이런 방법은 생산성에 좋지 않았는데, 타입 하나를 추가하려 해도 굉장히 번거로워진다.
api-types
패키지에 원하는 타입 추가api-types
패키지를 빌드- 빌드된
api-types
패키지를 프론트와 백엔드에 주입 하기 위해yarn install
실행
타입 하나를 추가하려 할 때마다 항상 위의 과정을 거쳐야 했고, 이는 굉장히 번거로웠다. 따라서 다음과 같은 방법으로 개선하기로 했다.
방법 2 : 타입들은 백엔드 내에서 관리하며, 백엔드 업데이트 시 이를 api-types
패키지로 push
이 방법을 선택한 이유는 다음과 같다.
- 타입 추가가 용이
- 백엔드에서 타입을 추가하면, 이를
api-types
패키지로 push하는 것만으로 프론트에서도 사용할 수 있다.
- 백엔드에서 타입을 추가하면, 이를
- 백엔드는 어차피 DTO를 만든다
- 백엔드에서는 정말 다양한 DTO를 만들게 되는데, 이를
api-types
패키지로 push하는 것은 큰 부담이 아니다.
- 백엔드에서는 정말 다양한 DTO를 만들게 되는데, 이를
- 타입의 추가와 백엔드의 업데이트의 주기가 같다
- 보통 타입이 새로 생긴다는 것은 API가 추가된 것과 같다. 따라서 백엔드의 업데이트 주기와 타입의 추가 주기가 같다고 생각했다.
- Type 제공을 API와 함께 제공되는 설명서(?)의 개념으로 생각했다.
- 내가 더 시간이 많다
마침 당시 일관성 있는 응답 객체 만들기나 에러 객체 만들기와 같은 개념을 공부하던 중이었기에, 이를 api-types
패키지로 관리하는 것이 꽤 괜찮을 것이라고 생각했다.
해당 방식에 관해서는 Nestia의 sdk를 사용해보면서 아이디어를 얻었다.
Nestia의 방식?
Nestia에서는 SDK를 생성할 수 있도록 도와준다. 이는 nest.js의 controller에서 정의된 타입들을 기반으로 sdk를 생성해주는 것이다. 생성된 sdk에서는 해당 타입들을 사용할 수 있으며, 해당 api를 함수처럼 사용하여 호출할 수 있도록 fetcher 기반의 함수를 제공하는데, 이는 백엔드에서 지정한 타입과 일치하여 type-safety한 장점이 있다.
처음엔 해당 sdk로 의사소통을 하려 했으나, 프론트엔드 입장에서는 fetcher와 달리 swr 등의 hook을 사용하고 있어, 이를 사용하기에는 어려움이 있었다. 또한 단순히 타입만 사용하기 위해서 해당 sdk를 빌드하고 관리하는 것은 약간 무거운 작업이라 느껴졌다.
타입 공유하기 (feat. script 파일)
따라서 나만의 타입 sdk 빌드 도구를 만들기로 했다. 제일 좋은건 Nestia 처럼 controller에 명시된 메소드들을 기반으로 타입을 추출하는 것이지만, 이는 굉장히 복잡하다.
해당 방식은 차차 배워가며 적용해보기로 했고, 일단은 백엔드에서 정의한 타입을 api-types
패키지로 직접 push하는 방식을 사용하기로 했다.
방법은 다음과 같다.
1. 백엔드의 api-types, dto 와 같은 폴더를 api-types
패키지에서도 동일한 구조로 가져간다.
아래는 현재 nestjs의 프로젝트 구조이다. 기능별로 모듈화한 프로젝트 구조를 가지고 있다. 각 모듈 내에 해당 기능에 부합한 api-types와 dto의 디렉토리가 존재한다.
백엔드 빌드 시 script 파일을 실행하여 백엔드의 api-types, dto 디렉토리를 api-types
패키지로 복사한다.
아래는 해당 스크립트 파일이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# copy-api-types.sh
source_folder="src/auth/dto"
destination_folder="../../packages/api-types/src/api-types/auth/dto"
rm "$destination_folder/*"
# 원본 폴더 안의 모든 파일과 폴더를 대상 폴더로 복사
rsync -av --progress "$source_folder/" "$destination_folder/"
source_folder="src/letter/dto"
destination_folder="../../packages/api-types/src/api-types/letter/dto"
rm "$destination_folder/*"
# 원본 폴더 안의 모든 파일과 폴더를 대상 폴더로 복사
rsync -av --progress "$source_folder/" "$destination_folder/"
echo "복사가 완료되었습니다."
현재 스크립트를 보면 알 수 있듯이 단순히 파일 복사를 하는 것이다. 해당 스크립트가 편한 건 사실이지만 사용하다보니 아직은 아쉬운 점이 있다. 나중엔 js로 파일 및 디렉토리 구조를 읽어다가 조금 더 스마트하게 타입 추출하는 방식으로 개선하고 싶다.
2. api-types
패키지를 빌드하여 주입 가능한 형태로 만든다.
해당 부분은 이전 글에서 다룬 내용과 동일하다. api-types
패키지를 빌드하여 다른 프로젝트에서 사용할 수 있게 한다.
현재 문진은 모노레포를 사용 중이니 npm publish 보다는 그냥 프로젝트 내 package로 제공하고 있다. 해당 package는 commonjs와 es모듈 방식
모두에서 쓸 수 있도록 tsc로 빌드하여 제공하고 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
{ // package/api-types/package.json
"name": "@moonjin/api-types",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"dependencies": {
"tsconfig": "*"
},
"devDependencies": {
"typescript": "latest"
},
"scripts": {
"build": "tsc"
}
}
모노레포에서 Package를 만들어 공유하는 방식에 대해선 공식 사이트를 참고하자.
Turbo : Internal Packages
3. 프론트에서 api-types
패키지를 사용한다.
프론트에서는 해당 패키지를 사용하기만 하면 된다. 해당 패키지를 사용하기 위해선 해당 패키지를 의존성에 추가하면 된다. npm은 먼저 node_modules에서 해당 패키지를 찾고, 없다면 packages나 npm에 등록되어 있는 패키지를 찾는다. 본 프로젝트는 모노레포 내의 package를 활용하는 것임으로 아래와 같이 추가해준다.
1
2
3
4
5
6
7
8
{ // client/package.json
"name": "client",
"version": "0.1.0",
"private": true,
"dependencies": {
"@moonjin/api-types": "workspace:^"
}
}
파이프라인 정리
위와 같이 타입을 공유하는 방식에 관해서 세팅이 끝났다면 이제 사용하는 입장에서 정리해보자.
- 프론트엔드에서 원격 서버의 변경점을 확인하여 반영한다. (
git pull
) - 프론트엔드에서는 주로 개발할 때
yarn dev
명령어를 사용하는데, 이 때api-types
패키지가 변경되었다면 해당 패키지를 먼저 빌드해야 한다. - 따라서
yarn dev
가 실행되기 이전에 해당 패키지들이 먼저 빌드되도록 파이프라인을 정해주자 api-types
패키지에 build 스크립트를 명시하고, 모노레포 최상위의turbo.json
과package.json
을 아래와 같이 설정한다.- 이후
yarn install
을 통해 해당 패키지를node_modules
나.pnp.cjs
등에 설치하고,yarn dev
를 실행하면 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
{ // turbo.json
"start": {
"dependsOn": [
"^build"
]
},
"dev": {
"cache": false,
"persistent": true,
"dependsOn": [
"^build"
]
}
}
1
2
3
4
5
6
7
8
{ // package.json
"scripts": {
"init": "turbo run init",
"build": "turbo run build",
"start": "turbo run start",
"dev": "turbo run dev"
}
}
마치며
타입을 공유하는 방식에 대해선 여러 방법이 있을 수 있다. 나는 현재 이 방법을 사용하고 있는데, 이 방법이 최선인지는 모르겠지만 현재 해당 방식을 통해 프론트엔드와 백엔드는 개발 생산성과 안정성을 둘 다 얻을 수 있었다. API 문서화 또한 자동화를 하고 싶은데, swagger는 사용해본 결과 문서를 작성하기 위한 문법을 배우는 것도 끌리지 않았고, 무엇보다 결과물도 만족스럽지 않았다. nestia에서도 제공하는 swagger 툴이 있지만 굉장히 편리하나 개인적으로 swagger 자체의 불편함이 있어 사용했다 제거했다. API 문서화에 대해선 gpt를 활용해볼까 생각중인데, 해당 부분도 이 프로젝트를 하면서 열심히 연구해 봐야겠다.