Tech Stack
tRPC
TypeScript로 full stack을 개발하면 server와 client 사이의 요청의 파라미터와 응답의 형식을 TypeScript로 관리하고 싶어집니다.
빠른 개발 또는 잦은 요구사항 변경이 필요한 환경에서는 API 사양 변경역시 자주 발생합니다. 이때 TypeScript의 도움을 받을 수 있다면, API 파라미터나 응답의 이름 변경, 삭제, 추가에 빠르고 정확하게 대응할 수 있습니다.
REST, GraphQL의 경우 이러한 이점을 활용하려면 별도의 type 생성기가 필요합니다. Type 생성기는 코드 생성을 수반하기 때문에 매끄러운 개발 환경 구성을 위한 노력이 필요합니다.
반면, tRPC는 server API 라우트의 형식을 typeof
키워드로 가져와 client를 생성하면 type checking 환경을 바로 구현할 수 있습니다.
따라서 server 코드의 작성과 동시에 client 형검사가 가능합니다.
nanostores/nanostores
프레임워크에 종속적이지 않은 store 구현체 입니다.
atom
을 사용하여 immutable 상태를 공유하고 reactive한 처리가 가능합니다.
action
, task
등으로 store의 비동기 초기화 등이 가능합니다.
Micro front end 구성 시 서로 다른 프레임워크간 상태 공유가 가능한 이점이 있습니다.
webstudio-is/immerhin
immer
패키지의 Patches 기능을 활용하여 client, server 간 객체 상태 공유를 위한 라이브러리 입니다.
Web studio에서 사용하기 위해 자체 개발한 라이브러리 이며, Web studio와 관계없이 독립적인 사용이 가능합니다.
프로젝트 생성
Remix는 별도의 API routing 방식을 사용하지 않습니다 (API Routes | Remix).
가이드에 따르면, routes
폴더에 있는 파일을 기준으로 UI 렌더링을 위한 코드와 API 요청 처리 코드를 대략 다음과 같이 작성합니다.
- 렌더링을 위한 UI 컴포넌트:
default export
GET
요청 처리:export async function loader
GET
요청을 제외한 API 처리:export async function action
이러한 Remix 만의 API 호출 방식을 따르면서 tRPC 호출 처리를 하기 위해 다소 복잡한 구조를 가지고 있습니다.
공식 가이드에 따르면 tRPC client는 createTRPCProxyClient
함수를 사용하여 호출하도록 되어 있습니다.
하지만 Web studio는 별도의 tRPC 서버를 구성하지 않고 tRPC 요청을 Remix 프레임워크에서 받아내기 위해 xxx.xxx.$method.ts
형식의 라우트를 구성합니다.
그리고 이 라우트에서 tRPC 서버단 코드를 수행합니다.
Web studio client 에서는 Remix의 action
구현체 호출을 위해 useFetcher
를 호출할 수 있는 nested proxy 객체를 생성합니다.
tRPC의 서버 route 정의로 부터 mutation을 수행하는 경우 useMutation
을 그렇지 않은 경우 useQuery
호출이 가능한 proxy 객체를 만들어 냅니다.
이 proxy 객체는 route의 recode를 첫번째 key 값으로 가지고 useMutation
을 호출할 경우 POST
를 useQuery
를 호출할 경우 GET
요청으로 치환하여 useFetcher
의 submit
을 호출 합니다.
이 과정을 도식화하면 다음과 같습니다.
첫 번째 /dashboard
요청은 loader
함수를 통해 필요한 데이터를 server 단에서 취합합니다.
tRPC로 정의한 findMany
, findManyByIds
와 같은 route를 직접 호출하며 각 route는 Prisma를 사용하여 데이터베이스를 호출하고 응답을 처리합니다.
사용자가 프로젝트 생성 다이얼로그의 버튼을 누르면 Remix의 useFetcher
의 submit
을 호출합니다.
Remix route 프레임워크에 의해 dashboard.project.$method.tsx
파일의 loader
함수를 호출하고 이는 다시 서버단의 tRPC 구현체를 호출합니다.
다음은 project의 모델 입니다.
생성할 때 필요한 인자는 title
입니다.
view DashboardProject {
id String @id @default(uuid())
createdAt DateTime @default(now())
title String
domain String
userId String?
isDeleted Boolean @default(false)
isPublished Boolean
}
빌더(builder)
프로젝트 생성 후, 프로젝트 카드를 클릭하면 builder 화면을 표시합니다.
CSS grid로 레이아웃을 잡았습니다.
가운데 main 영역은 <iframe/>
을 배치하였으며, side bar를 drag하여 resizing이 가능합니다.
resizing은 pointerup
, pointerdown
, pointermove
이벤트를 처리합니다. (코드)
resizing은 canvasWidthStore
값을 변경합니다.
canvasWidthStore
값은 useCanvasStyle
hook을 사용하여 width를 변경합니다.
왼쪽 navigation 영역에서 컴포넌트 추가를 할 수 있습니다.
컴포넌트 팔레트 중 하나를 드래그 하면, instancesStore
의 값에 관련 정보를 추가 합니다.
instanceStore
는 immerhint가 관리하는 store이며 해당 값은 서버 동기화를 위해 /rest/patch
요청으로 전달합니다.
이렇게 immerhint에 의해 관리하는 초기화 과정 중에 설정합니다. (코드)
rest.patch.ts
에서 이 요청을 받으면, 다음과 같은 Build
모델의 형식으로 Prisma를 사용해 저장하게 됩니다.
model Build {
id String @unique @default(uuid())
version Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
pages String
project Project @relation(fields: [projectId], references: [id])
projectId String
breakpoints String @default("[]")
styles String @default("[]")
styleSources String @default("[]")
styleSourceSelections String @default("[]")
props String @default("[]")
dataSources String @default("[]")
instances String @default("[]")
deployment String?
publishStatus PublishStatus @default(PENDING)
@@id([id, projectId])
}
컴포넌트 팔레트에서 drag & drop을 사용하여 컴포넌트를 배치하는 경우 instances
의 정보를 업데이트 하게 됩니다.
마치며
프로젝트 생성과 컴포넌트 추가 과정을 살펴 보았습니다. Remix와 tRPC의 장점을 얻기위한 구조가 인상 깊었습니다. 물론 이로인해 구현이 다소 복잡해졌지만 tRPC의 route 추가와 파라미터, 응답의 변화에 기민하게 대응하면서 remix의 구현 의도를 해치지 않도록 고민을 한 흔적이 보였습니다.
또한 지속적으로 변하는 drawing 정보를 데이터 베이스에 동기화 하기 위해, immerhint를 사용한 부분은 놀라웠습니다. 비교적 최신 프로젝트 답게 nanostore를 single source of truth로 사용하고 immerhint를 통해 서버와 동기화하는 부분에서 많은 아이디어를 얻었습니다.
다만 단일 사용자 수정이 가능한 one way 동기화 방식임으로 여러명이 협업하고 동시 편집하는 기능 구현은 시간을 두고 지켜봐야 할 것 같습니다.