서버 드리븐 UI 도입과 롤백: 완벽한 아키텍처가 아닌 필요한 아키텍처
문제의 발견
KnotNet의 대시보드는 사용자가 작성한 노트 수에 따라 다른 인사이트 섹션을 보여준다.
// 프론트엔드에 하드코딩된 비즈니스 로직
{notes.length >= 2 && <Summary />}
{notes.length >= 4 && <PatternInsights />}
{notes.length >= 10 && <Discovery />}
각 섹션은 개별 API를 호출한다:
/api/insights
– Summary용/api/pattern-insights
– Pattern Insights용/api/insights-discovery
– Discovery용
코드를 보다가 문득 생각이 들었다.
“모바일 앱 만들면 이 로직 또 구현해야 하잖아?”
iOS에서 한 번, Android에서 한 번. 그리고 변경사항이 생기면? 세 곳 모두 수정.
이것은 좋지 않다.
아이디어: 서버 드리븐 UI
해결책이 떠올랐다. 서버 드리븐 UI (Server-Driven UI).
기존 방식:
- 프론트엔드: “노트가 몇 개지? 4개네. 그럼 Pattern Insights 보여줘야겠다.”
- 프론트엔드가 비즈니스 로직을 알고 있음
서버 드리븐 방식:
- 프론트엔드: “서버야, 뭘 보여줄지 알려줘”
- 서버: “이거랑 저거 보여줘. 데이터도 함께 줄게”
- 프론트엔드: “알았어, 그냥 그대로 렌더링할게”
장점이 명확해 보였다:
- Single Source of Truth: 비즈니스 로직이 서버 한 곳에만
- 플랫폼 독립적: Web, iOS, Android 모두 같은 API
- 유연한 제어: A/B 테스트, 기능 플래그를 서버에서만 관리
- 구독 플랜 대응: 유료/무료에 따른 기능 제어가 쉬움
완벽해 보였다.
설계의 진화
하지만 어떻게 구현할 것인가? 여러 버전을 고민했다.
v1: 단순 플래그 방식
{
"showSummary": true,
"showPatternInsights": false,
"showDiscovery": false
}
문제: 여전히 프론트엔드가 어떤 컴포넌트를 어떻게 렌더링할지 알아야 한다.
v2: 컴포넌트 이름 전달
{
"sections": ["Summary", "PatternInsights"]
}
문제: 프론트엔드가 컴포넌트 이름을 알아야 하고, 각 섹션의 props는?
v3: 완전한 서버 드리븐 (최종 선택)
{
"sections": [
{
"type": "text-card",
"title": "요즘의 나는...",
"content": "목표와 도전과제를 중심으로 생각하고 있어요",
"color": "orange"
},
{
"type": "pattern-list",
"title": "반복되는 패턴",
"patterns": [
{ "text": "주말마다 골프", "noteId": "123" }
]
}
]
}
프론트엔드는 type
만 보고 적절한 컴포넌트를 렌더링하면 된다. 데이터도 모두 포함되어 있다.
완벽하다!
구현
Step 1: 통합 API 엔드포인트
기존의 여러 API를 하나로 통합했다.
// /api/dashboard/route.ts
export async function GET(request: NextRequest) {
const userId = request.searchParams.get('userId')
const noteCount = await getNoteCount(userId)
const sections: DashboardSection[] = []
// 서버에서 모든 비즈니스 로직 처리
if (noteCount >= 2) {
const summary = await fetchSummary(userId)
sections.push({
type: 'text-card',
title: '요즘의 나는...',
content: summary.content,
color: 'orange'
})
}
if (noteCount >= 4) {
const patterns = await fetchPatterns(userId)
sections.push({
type: 'pattern-list',
title: '반복되는 패턴',
patterns: patterns
})
}
if (noteCount >= 10) {
const discovery = await fetchDiscovery(userId)
sections.push({
type: 'discovery-card',
discoveries: discovery
})
}
return NextResponse.json({ sections })
}
Step 2: 프론트엔드 단순화
프론트엔드 코드가 극적으로 단순해졌다.
// Before: 복잡한 조건부 렌더링
{notes.length >= 2 && insights && (
<Summary data={insights.summary} />
)}
{notes.length >= 4 && patternInsights && (
<PatternInsights data={patternInsights} />
)}
{notes.length >= 10 && discovery && (
<Discovery data={discovery} />
)}
// After: 단순 반복 렌더링
{sections.map((section, index) => {
switch(section.type) {
case 'text-card':
return <TextCard key={index} {...section} />
case 'pattern-list':
return <PatternList key={index} {...section} />
case 'discovery-card':
return <DiscoveryCard key={index} {...section} />
}
})}
조건부 로직이 사라지고, 단순한 매핑만 남았다.
Step 3: 타입 안전성
TypeScript Union Types를 사용해 타입 안전성을 확보했다.
type TextCardSection = {
type: 'text-card'
title: string
content: string
color: string
}
type PatternListSection = {
type: 'pattern-list'
title: string
patterns: Pattern[]
}
type DiscoveryCardSection = {
type: 'discovery-card'
discoveries: Discovery[]
}
type DashboardSection =
| TextCardSection
| PatternListSection
| DiscoveryCardSection
interface DashboardResponse {
sections: DashboardSection[]
expectedSections: string[] // 스켈레톤 로딩용
}
any
타입 없이 완벽한 타입 추론이 가능했다.
모바일 대응 완료
이제 iOS나 Android 앱을 만들 때 동일한 API를 사용하면 된다.
// iOS
for section in response.sections {
switch section.type {
case "text-card":
renderTextCard(section)
case "pattern-list":
renderPatternList(section)
case "discovery-card":
renderDiscoveryCard(section)
}
}
// Android
for (section in response.sections) {
when (section.type) {
"text-card" -> renderTextCard(section)
"pattern-list" -> renderPatternList(section)
"discovery-card" -> renderDiscoveryCard(section)
}
}
비즈니스 로직은 한 곳에만. 플랫폼은 렌더링만.
완벽하다!
그런데…
사용하다 보니 문제가 보이기 시작했다.
문제 1: 캐시 동기화
각 섹션이 독립적인 데이터를 가지고 있어, 캐싱 전략이 복잡해졌다.
- Summary는 매일 갱신
- Pattern은 주 단위로 갱신
- Discovery는 실시간
하나의 API에서 모두 처리하니, 캐시 invalidation 전략이 복잡해졌다.
문제 2: 개발 속도
프론트엔드 수정을 하려면 서버도 수정해야 했다.
“Summary 카드 디자인 바꾸고 싶은데… 서버 API 응답 구조도 바꿔야 하네?”
작은 UI 수정에도 서버 배포가 필요했다.
문제 3: 복잡도
아키텍처가 복잡해졌다. 새로운 팀원이 코드를 이해하는 데 시간이 걸렸다.
“섹션 추가하려면 어떻게 해요?” “서버 API 먼저 수정하고, 타입 정의하고, 프론트 렌더러 추가하고…”
문제 4: 현실
그리고 결정적으로: 지금 모바일 앱이 없다.
웹만 있는 상황에서 플랫폼 독립성을 위한 추상화는… 오버엔지니어링이었다.
롤백 결정
고민 끝에 롤백을 결정했다.
좋은 아키텍처다. 맞다. 하지만 지금 필요한가?
- 모바일 앱: 아직 없음
- 팀 크기: 1명 (나)
- 개발 속도: 중요함
- 복잡도: 증가함
트레이드오프 분석:
항목 | 서버 드리븐 | 기존 방식 |
---|---|---|
플랫폼 독립성 | ✅ 우수 | ❌ 각각 구현 |
개발 속도 | ❌ 느림 | ✅ 빠름 |
복잡도 | ❌ 높음 | ✅ 낮음 |
캐싱 | ❌ 복잡 | ✅ 단순 |
현재 필요성 | ❌ 낮음 | ✅ 높음 |
현재 단계에서는 기존 방식이 더 적합했다.
롤백 과정
롤백은 생각보다 쉬웠다.
- 통합 API를 다시 개별 API로 분리
- 프론트엔드 조건부 렌더링 복원
- 타입 정의 단순화
한나절 만에 완료했다.
배운 점
1. 좋은 아키텍처 ≠ 지금 필요한 아키텍처
서버 드리븐 UI는 훌륭한 패턴이다. Netflix, Airbnb 같은 대기업들이 사용한다.
하지만 그들은:
- 여러 플랫폼을 가지고 있고
- 큰 팀이 있고
- A/B 테스트를 자주 하고
- 자주 변경되는 비즈니스 로직이 있다
나는:
- 웹만 있고
- 혼자 개발하고
- A/B 테스트는 아직이고
- 빠른 개발이 중요하다
컨텍스트가 다르다.
2. 점진적 발전
완벽한 아키텍처를 처음부터 만들 필요는 없다.
필요할 때 리팩토링하면 된다.
지금:
- 웹만 있음
- 조건부 렌더링으로 충분
나중에 (모바일 앱 출시):
- 서버 드리븐 UI 재도입
- 이번 경험이 도움될 것
3. 실용주의
이론적으로 우수한 것과 실무적으로 적합한 것은 다르다.
- 책에서 배운 패턴
- 대기업 사례
- 컨퍼런스 발표
모두 좋다. 하지만 내 상황에 맞는가?
4. 롤백은 실패가 아니다
롤백을 결정하면서 실패한 것 같은 기분이 들었다.
“시간 낭비한 거 아냐?”
하지만 아니었다.
배운 것:
- 서버 드리븐 UI 패턴을 깊이 이해
- 트레이드오프를 체험적으로 학습
- 모바일 앱 출시 시 더 나은 설계 가능
- 언제 사용하고 언제 사용하지 말아야 하는지 앎
이것은 학습이었다. 투자였다.
다음 계획
모바일 앱 출시 시점에 서버 드리븐 UI를 다시 도입할 것이다.
하지만 이번에는 다르게:
개선점:
- 섹션별 독립 캐싱: 각 섹션이 독립적인 TTL을 가짐
- 개발 워크플로우: 프론트 수정이 잦은 부분은 클라이언트에 유연성 제공
- 더 나은 타입 시스템: Zod나 io-ts로 런타임 검증 추가
- 점진적 도입: 한 번에 모든 화면이 아니라 필요한 부분부터
결론
완벽한 아키텍처를 추구하는 것은 좋다. 하지만 지금 필요한 아키텍처가 더 중요하다.
서버 드리븐 UI는 훌륭한 패턴이다. 하지만:
- 팀 규모
- 제품 단계
- 플랫폼 수
- 개발 속도 요구사항
을 고려해야 한다.
나는 이번에 도입했다가 롤백했다. 실패가 아니라 현명한 선택이었다.
그리고 모바일 앱이 나올 때, 나는 더 나은 서버 드리븐 UI를 구현할 준비가 되어 있을 것이다.
좋은 엔지니어는 최신 기술을 쓰는 사람이 아니라, 적절한 기술을 선택하는 사람이다.
그리고 때로는, 가장 적절한 선택은 “아직 아니야”일 수 있다.