최근에는 오래도록 재사용할 수 있는 TypeScript 프로젝트 템플릿을 정리하고 있다.
내 프로젝트는 대부분 AI 도구, 관리자 콘솔, 워크플로, 편집기, 데스크톱 연동 앱이다. 기술 스택은 React, TypeScript, Vite를 중심으로 하고, 백엔드에는 Node.js, PostgreSQL, Drizzle를 사용한다.
처음에는 라우팅, SSR, 데이터 조회, API, 배포를 하나로 묶을 수 있는 React 풀스택 프레임워크를 고르는 방법을 검토했다. Next.js, React Router Framework Mode, TanStack Start를 중점적으로 살펴봤고, Remix 3도 함께 참고했다.
한동안 조사해 보니, 프레임워크의 기능이 많을수록 항상 적합한 것은 아니었다. 상호작용이 많은 앱에서는 SSR과 동형 실행 모델이 추가적인 개념을 상당히 늘리며, 그 개념들이 곧바로 제품 이득으로 이어지지는 않는다.
마지막으로 정한 방향은 다음과 같다.
公开站点使用 SSR 或 SSG
登录后的产品使用 SPA
业务能力通过统一 HTTP API 提供
이 글에서는 구체적인 판단 과정을 기록해 둔다.
TanStack Start의 매력
TanStack Start는 내가 기대하는 현대적인 TypeScript 프레임워크와 잘 맞는다.
TanStack Router와 Vite를 기반으로 타입 안전한 라우팅, Search Params, Loader, Server Function, Middleware, SSR을 제공한다. TanStack Query, Form, Table까지 함께 쓰면 완전한 프런트엔드 앱 개발 체계를 갖출 수 있다.
간단한 서버 조회는 다음과 같이 작성할 수 있다.
라우트에서 직접 호출한다.
컴포넌트에서는 라우트를 통해 결과를 읽는다.
이 방식에서는 HTTP Client와 반환 타입을 직접 관리할 필요가 없다. Server Function은 클라이언트에서 평범한 비동기 함수처럼 동작하며, 입력과 출력 타입을 바로 추론할 수 있다.
단순한 페이지에서는 이 방식이 매우 자연스럽다.
문제는 주로 페이지 상호작용이 복잡해질 때 나타난다.
예를 들어 목록을 수동으로 갱신해야 한다면 다음처럼 호출한다.
목록에 TanStack Query를 사용한다면 다음과 같이 작성한다.
새 레코드를 추가한 뒤에는 라우트 Loader를 다시 실행할 수도 있다.
Query Cache를 무효화할 수도 있다.
이때 프로젝트 안에 두 개의 데이터 라이프사이클이 함께 존재할 수 있다.
Route Loader 管理路由数据
TanStack Query 管理客户端缓存
Route Loader가 라우트 데이터를 관리하고, TanStack Query가 클라이언트 캐시를 관리한다.
Query Options, Mutation, TanStack Form, TanStack Table, Zod까지 계속 넣으면 일반적인 Feature도 쉽게 여러 파일로 나뉜다.
project.server.ts
project.functions.ts
project.queries.ts
project.mutations.ts
project.schema.ts
project.form.ts
project.columns.tsx
각 모듈은 개별적으로 보면 합리적인 역할을 하지만, 한데 모으면 단순한 비즈니스에서도 프레임워크 코드가 차지하는 비중이 커진다.
TanStack 생태계 자체가 프로젝트를 반드시 이렇게 작성하도록 요구하는 것은 아니다. 문제는 제공하는 기능이 많아 개발자가 프로젝트 초반부터 완전한 해법을 깔아 놓기 쉽다는 점이다.
복잡한 대시보드, 실시간 데이터, 무한 목록, 낙관적 업데이트에서는 이런 추상화가 제 역할을 한다. 일반적인 CRUD에는 과도하게 무거워 보일 수 있다.
Next.js의 복잡도는 런타임 경계에 있다
Next.js의 코드는 보통 더 짧다.
Server Component에서 데이터베이스를 직접 조회할 수 있다.
수정 작업은 Server Action으로 작성할 수 있다.
페이지에서 바로 제출한다.
이 방식은 콘텐츠 사이트, 이커머스, 서버 사이드 렌더링이 중심인 페이지에 잘 맞는다. 페이지, 데이터 읽기와 변경 로직을 가까운 곳에 둘 수 있고, 프로젝트 초기의 코드량도 크지 않다.
클라이언트 상호작용이 늘어나면 코드는 다음과 같은 것까지 다뤄야 한다.
Server Component
Client Component
Server Action
Route Handler
Suspense
缓存失效
序列化边界
Next.js의 추가 비용은 보통 대량의 래퍼 함수가 아니라 런타임 모델과 프레임워크 규칙에서 드러난다.
편집기, 워크플로, 관리자 콘솔은 페이지 안에서 로컬 상태, 폴링, 실시간 데이터, 모달, 여러 영역에 걸친 상호작용을 많이 다뤄야 한다. 그래서 Server Component와 Client Component의 경계를 계속 관리해야 한다.
Next.js는 여전히 성숙한 프로덕션용 선택지지만, 서버 컴포넌트와 페이지 렌더링을 중심으로 앱을 구성하는 경향이 있어 내가 지금 만드는 제품 형태와 완전히 맞지는 않는다.
React Router의 데이터 모델은 비교적 통일되어 있다
React Router Framework Mode는 Loader와 Action으로 라우트 데이터를 구성한다.
이 모델은 전통적인 웹 개발과 비교적 가깝다.
读取数据使用 loader
修改数据使用 action
页面提交使用 Form 或 fetcher
Action이 끝나면 React Router가 관련 Loader를 다시 검증하므로, 일반적인 폼에서는 Query Cache를 따로 유지할 필요가 없다.
이 방식은 폼과 라우팅 중심 앱에 적합하다. Request, Response, Cookie, Session, 업로드 모두 표준 Web API를 사용하고, 디버깅 경로도 비교적 명확하다.
페이지 안에서 하는 작업이 많아지면 Action이 intent를 기준으로 서로 다른 업무를 분기해야 할 수 있다.
독립적인 Resource Route로 분리할 수도 있다.
React Router의 코드량은 보통 Next.js와 완전한 TanStack 구성 사이에 있다. 전통적인 웹 데이터 흐름에는 잘 맞지만, 복잡한 클라이언트 캐시나 여러 컴포넌트 간 원격 상태 공유가 필요하면 결국 TanStack Query를 추가하게 될 수 있다.
SPA의 데이터 흐름은 제품 관리 콘솔에 더 잘 맞는다
몇 가지 풀스택 프레임워크를 나란히 비교해 본 뒤, 일반적인 SPA를 다시 검토했다.
일반적인 SPA 프로젝트의 원격 데이터 흐름은 매우 직관적이다.
React Component
↓
TanStack Query
↓
HTTP API
↓
业务逻辑
↓
数据库
조회할 때 다음과 같이 작성한다.
수동으로 새로고침할 때 다음과 같이 작성한다.
추가한 뒤 목록을 갱신할 때 다음과 같이 작성한다.
이 모델은 원격 데이터 상태를 한 벌만 관리한다.
查询使用 useQuery
修改使用 useMutation
缓存刷新使用 invalidateQueries
页面参数使用 Router Search Params
本地状态使用 useState 或 Jotai
로그인 후 사용하는 관리 시스템이라면 이 모델만으로 충분하다.
관리 콘솔, AI 도구, 워크플로 앱은 보통 내부 페이지를 검색 엔진에 색인할 필요가 없다. 사용자는 로그인한 뒤 오랫동안 머물고, 대부분의 작업은 브라우저에서 이뤄진다.
이런 페이지에서 SSR의 이점은 제한적이지만, Hydration, 서버와 클라이언트 모듈 경계, Loader 라이프사이클, 캐시 동기화 같은 문제가 늘어난다.
그래서 제품 전체를 SSR 모델에 넣지 않기로 했다.
SSR과 SPA를 라우트 영역별로 나누기
마지막 제품 라우트는 세 영역으로 나눌 수 있다.
공개 영역에서는 SSR 또는 SSG를 사용해 다음을 담당한다.
- SEO;
- 첫 화면 콘텐츠;
- Open Graph;
- 블로그와 문서;
- 제품 소개;
- 공개 도구 페이지.
/app 영역에서는 SPA를 사용해 다음을 담당한다.
- 대시보드;
- 워크플로;
- 편집기;
- AI 생성;
- 실시간 데이터;
- 복잡한 테이블;
- 여러 패널 간 상호작용.
API는 다음을 담당한다.
- 인증;
- 권한;
- 비즈니스 로직;
- 데이터베이스 접근;
- 파일 업로드;
- Webhook;
- Desktop 및 기타 클라이언트.
이렇게 하면 각 영역에서 하나의 주요 데이터 모델만 관리하면 된다.
최종 Monorepo 구조
현재 내게 가장 잘 맞는 구조는 다음과 같다.
각 앱의 역할은 다음과 같다.
공유 패키지는 필요한 만큼만 유지한다.
packages/ui에는 기본 컴포넌트, 스타일 Token, 애니메이션 프리셋을 둔다.
packages/contracts에는 실제로 여러 클라이언트에 걸쳐 공유해야 하는 Zod Schema, 오류 구조, API 타입만 둔다.
packages/db에는 Drizzle Schema, Migration, 데이터베이스 Client를 둔다.
비즈니스 코드는 기본적으로 각 App 안에 남겨 두고, 프로젝트 시작 단계에서 packages/core로 추출하지 않는다.
API에는 Hono 사용
API 계층의 선택은 Hono다.
라우팅 코드는 비교적 가볍다.
클라이언트에서는 Hono RPC를 사용할 수 있다.
TanStack Query에서 호출한다.
전체 호출 체인은 다음과 같다.
Component
↓
TanStack Query
↓
Hono RPC Client
↓
Hono Route
↓
Feature Function
↓
Drizzle
이 API는 Web, Desktop, 브라우저 확장 프로그램, Agent, Webhook에 동시에 제공할 수 있다.
프레임워크 내부에서만 쓰는 Server Function에 비해, HTTP API는 더 넓게 재사용할 수 있다.
백엔드는 Feature 단위로 구성
프런트엔드와 백엔드를 분리한다고 해서 백엔드에 Java의 계층 방식을 그대로 복제할 필요는 없다.
다음과 같은 전역 디렉터리는 사용하지 않는다.
controllers/
services/
repositories/
mappers/
dto/
API는 여전히 Feature 단위로 구성한다.
단순한 Feature에는 꼭 필요한 파일만 둔다.
project.routes.ts
project.schema.ts
데이터베이스 인스턴스는 직접 import해도 된다.
여러 구현이 있거나, 테스트가 복잡하거나, 런타임에 교체가 필요할 때만 매개변수 주입이나 생성자 주입을 사용한다.
구조적인 형태를 유지한다는 이유로 모든 테이블마다 Repository, Service, Mapper를 만들지는 않는다.
툴체인은 과감하게, 런타임은 안정적으로
프로젝트 툴체인에는 상대적으로 새로운 방식을 사용한다.
Vite
Rolldown
Oxc
Oxlint
Oxfmt
Vite+
Vitest
Playwright
이 도구들은 운영 환경의 비즈니스 데이터를 직접 다루지 않는다. 특정 버전에서 호환 문제가 생겨도 각 도구의 하위 명령으로 롤백할 수 있다.
애플리케이션 계층에서는 다음을 사용한다.
React
TanStack Router
TanStack Query
TanStack Form
TanStack Table
Jotai
Tailwind CSS
shadcn/ui
Base UI
Motion
Lucide
TanStack Form, TanStack Table, Jotai는 모두 필요할 때만 사용하며, 모든 페이지의 고정 의존성은 아니다.
데이터와 보안 계층은 상대적으로 보수적으로 유지한다.
Node.js LTS
PostgreSQL
Drizzle ORM
Better Auth 稳定版本
Zod
正式数据库 Migration
Cookie Session
툴체인 업그레이드에 실패하면 개발 효율에 영향을 주고, 인증이나 데이터베이스 업그레이드에 실패하면 사용자와 데이터에 영향을 줄 수 있다. 두 영역에 같은 업그레이드 전략을 적용해서는 안 된다.
배포 방식
소스 코드를 여러 App으로 나눴다고 해서 프로덕션 환경에서 많은 서비스를 유지해야 하는 것은 아니다.
하나의 도메인을 사용할 수 있다.
example.com/
→ Site
example.com/app/*
→ SPA
example.com/api/*
→ API
게이트웨이가 경로에 따라 분기한다.
/ → Site
/app/* → Web
/api/* → API
인증은 동일 출처 Cookie를 유지한다. SPA가 /api를 호출할 때는 별도의 크로스 오리진 Token을 처리할 필요가 없다. Desktop 같은 외부 클라이언트에는 별도의 인증 방식을 사용하면 된다.
개인 프로젝트의 초기 단계에서는 Hono가 API와 SPA 정적 파일을 동시에 제공하도록 해 Docker 이미지 하나와 Node 프로세스 하나로 유지할 수도 있다.
결론
이번 기술 선택에서는 모든 기능을 하나로 관리하는 풀스택 프레임워크를 확정하지 않고, 서로 다른 유형의 페이지를 각각 더 적합한 런타임 모델에 배치했다.
公开内容使用 SSR 或 SSG
登录后的产品使用 SPA
通用业务能力使用 HTTP API
최종 기술 스택은 대략 다음과 같다.
이 구조는 현대적인 TypeScript 툴체인을 유지하면서, 비즈니스 관리 콘솔에서도 익숙한 SPA 데이터 흐름을 계속 쓸 수 있게 한다.
프레임워크 기능은 프로젝트 요구에 따라 추가하면 된다. 대부분의 도구형 제품에서는 모든 페이지에서 SSR, Loader, Server Function, Query를 한꺼번에 조합하는 것보다, 클라이언트·API·데이터 간의 경계를 분명히 유지하는 편이 관리하기 쉽다.