서버 드리븐 UI 도입과 롤백: 완벽한 아키텍처가 아닌 필요한 아키텍처

문제의 발견

KnotNet의 대시보드는 사용자가 작성한 노트 수에 따라 다른 인사이트 섹션을 보여준다.

javascript
// 프론트엔드에 하드코딩된 비즈니스 로직
{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 보여줘야겠다.”
  • 프론트엔드가 비즈니스 로직을 알고 있음

서버 드리븐 방식:

  • 프론트엔드: “서버야, 뭘 보여줄지 알려줘”
  • 서버: “이거랑 저거 보여줘. 데이터도 함께 줄게”
  • 프론트엔드: “알았어, 그냥 그대로 렌더링할게”

장점이 명확해 보였다:

  1. Single Source of Truth: 비즈니스 로직이 서버 한 곳에만
  2. 플랫폼 독립적: Web, iOS, Android 모두 같은 API
  3. 유연한 제어: A/B 테스트, 기능 플래그를 서버에서만 관리
  4. 구독 플랜 대응: 유료/무료에 따른 기능 제어가 쉬움

완벽해 보였다.

설계의 진화

하지만 어떻게 구현할 것인가? 여러 버전을 고민했다.

v1: 단순 플래그 방식

typescript
{
  "showSummary": true,
  "showPatternInsights": false,
  "showDiscovery": false
}

문제: 여전히 프론트엔드가 어떤 컴포넌트를 어떻게 렌더링할지 알아야 한다.

v2: 컴포넌트 이름 전달

typescript
{
  "sections": ["Summary", "PatternInsights"]
}

문제: 프론트엔드가 컴포넌트 이름을 알아야 하고, 각 섹션의 props는?

v3: 완전한 서버 드리븐 (최종 선택)

typescript
{
  "sections": [
    {
      "type": "text-card",
      "title": "요즘의 나는...",
      "content": "목표와 도전과제를 중심으로 생각하고 있어요",
      "color": "orange"
    },
    {
      "type": "pattern-list",
      "title": "반복되는 패턴",
      "patterns": [
        { "text": "주말마다 골프", "noteId": "123" }
      ]
    }
  ]
}

프론트엔드는 type만 보고 적절한 컴포넌트를 렌더링하면 된다. 데이터도 모두 포함되어 있다.

완벽하다!

구현

Step 1: 통합 API 엔드포인트

기존의 여러 API를 하나로 통합했다.

typescript
// /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: 프론트엔드 단순화

프론트엔드 코드가 극적으로 단순해졌다.

typescript
// 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를 사용해 타입 안전성을 확보했다.

typescript
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를 사용하면 된다.

swift
// iOS
for section in response.sections {
    switch section.type {
    case "text-card":
        renderTextCard(section)
    case "pattern-list":
        renderPatternList(section)
    case "discovery-card":
        renderDiscoveryCard(section)
    }
}
kotlin
// 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명 (나)
  • 개발 속도: 중요함
  • 복잡도: 증가함

트레이드오프 분석:

항목 서버 드리븐 기존 방식
플랫폼 독립성 ✅ 우수 ❌ 각각 구현
개발 속도 ❌ 느림 ✅ 빠름
복잡도 ❌ 높음 ✅ 낮음
캐싱 ❌ 복잡 ✅ 단순
현재 필요성 ❌ 낮음 ✅ 높음

현재 단계에서는 기존 방식이 더 적합했다.

롤백 과정

롤백은 생각보다 쉬웠다.

  1. 통합 API를 다시 개별 API로 분리
  2. 프론트엔드 조건부 렌더링 복원
  3. 타입 정의 단순화

한나절 만에 완료했다.

배운 점

1. 좋은 아키텍처 ≠ 지금 필요한 아키텍처

서버 드리븐 UI는 훌륭한 패턴이다. Netflix, Airbnb 같은 대기업들이 사용한다.

하지만 그들은:

  • 여러 플랫폼을 가지고 있고
  • 큰 팀이 있고
  • A/B 테스트를 자주 하고
  • 자주 변경되는 비즈니스 로직이 있다

나는:

  • 웹만 있고
  • 혼자 개발하고
  • A/B 테스트는 아직이고
  • 빠른 개발이 중요하다

컨텍스트가 다르다.

2. 점진적 발전

완벽한 아키텍처를 처음부터 만들 필요는 없다.

필요할 때 리팩토링하면 된다.

지금:

  • 웹만 있음
  • 조건부 렌더링으로 충분

나중에 (모바일 앱 출시):

  • 서버 드리븐 UI 재도입
  • 이번 경험이 도움될 것

3. 실용주의

이론적으로 우수한 것과 실무적으로 적합한 것은 다르다.

  • 책에서 배운 패턴
  • 대기업 사례
  • 컨퍼런스 발표

모두 좋다. 하지만 내 상황에 맞는가?

4. 롤백은 실패가 아니다

롤백을 결정하면서 실패한 것 같은 기분이 들었다.

“시간 낭비한 거 아냐?”

하지만 아니었다.

배운 것:

  • 서버 드리븐 UI 패턴을 깊이 이해
  • 트레이드오프를 체험적으로 학습
  • 모바일 앱 출시 시 더 나은 설계 가능
  • 언제 사용하고 언제 사용하지 말아야 하는지 앎

이것은 학습이었다. 투자였다.

다음 계획

모바일 앱 출시 시점에 서버 드리븐 UI를 다시 도입할 것이다.

하지만 이번에는 다르게:

개선점:

  1. 섹션별 독립 캐싱: 각 섹션이 독립적인 TTL을 가짐
  2. 개발 워크플로우: 프론트 수정이 잦은 부분은 클라이언트에 유연성 제공
  3. 더 나은 타입 시스템: Zod나 io-ts로 런타임 검증 추가
  4. 점진적 도입: 한 번에 모든 화면이 아니라 필요한 부분부터

결론

완벽한 아키텍처를 추구하는 것은 좋다. 하지만 지금 필요한 아키텍처가 더 중요하다.

서버 드리븐 UI는 훌륭한 패턴이다. 하지만:

  • 팀 규모
  • 제품 단계
  • 플랫폼 수
  • 개발 속도 요구사항

을 고려해야 한다.

나는 이번에 도입했다가 롤백했다. 실패가 아니라 현명한 선택이었다.

그리고 모바일 앱이 나올 때, 나는 더 나은 서버 드리븐 UI를 구현할 준비가 되어 있을 것이다.

좋은 엔지니어는 최신 기술을 쓰는 사람이 아니라, 적절한 기술을 선택하는 사람이다.

그리고 때로는, 가장 적절한 선택은 “아직 아니야”일 수 있다.