본문 바로가기

개발 & IT/프론트엔드

Vue 개발자가 Next.js를 배우며 겪은 혼란: Server Component와 Client Component 이해하기

Vue와 Nuxt에 익숙한 개발자로서 Next.js를 처음 접했을 때, 가장 혼란스러웠던 부분은 "왜 데이터를 fetch 했는데 화면이 업데이트되지 않는가?"였습니다. Vue에서는 당연하게 작동하던 반응형 시스템이 Next.js에서는 전혀 다른 방식으로 동작했고, revalidatePath라는 낯선 함수를 호출해야 했습니다.

이 글은 Vue/Nuxt 개발자가 Next.js의 Server Component와 Client Component를 이해하는 과정을 담았습니다.

첫 번째 혼란: 왜 화면이 업데이트되지 않는가?

Vue/Nuxt에서의 당연함

Vue나 Nuxt에서 개발할 때, 반응형 시스템은 너무나 자연스럽게 작동합니다.

<script setup>
const posts = ref([])

const addPost = async (title) => {
  await $fetch('/api/posts', {
    method: 'POST',
    body: { title }
  })
  
  // 데이터 다시 가져오기
  const newPosts = await $fetch('/api/posts')
  posts.value = newPosts  // 자동으로 화면 업데이트!
}
</script>

<template>
  <div v-for="post in posts" :key="post.id">
    {{ post.title }}
  </div>
</template>

이 코드는 예상대로 작동합니다. 게시글을 추가하면 posts.value가 업데이트되고, Vue의 반응형 시스템이 자동으로 화면을 다시 렌더링합니다.

Next.js에서의 당혹감

하지만 Next.js의 Server Component에서는 같은 로직이 작동하지 않습니다.

// app/posts/page.tsx
export default async function PostsPage() {
  const posts = await prisma.post.findMany()
  
  return (
    <div>
      {posts.map(post => (
        <div key={post.id}>{post.title}</div>
      ))}
    </div>
  )
}

// actions.ts
export async function createPost(data) {
  await prisma.post.create({ data })
  // 여기서 끝? 화면이 안 바뀜!
}

게시글을 추가해도 화면에는 새 게시글이 나타나지 않습니다. 페이지를 새로고침해야만 새 데이터가 보입니다. Vue 개발자에게는 이것이 버그처럼 느껴집니다.

두 번째 혼란: revalidatePath는 무엇인가?

문제를 검색하다 보면 revalidatePath라는 함수를 발견하게 됩니다.

export async function createPost(data) {
  await prisma.post.create({ data })
  revalidatePath('/posts')  // 이게 뭐지?
}

이 코드를 추가하면 신기하게도 화면이 업데이트됩니다. 하지만 Vue 개발자에게는 여전히 의문이 남습니다.

"반응형 프레임워크를 사용하는데 왜 이런 함수가 필요한가?"

핵심 차이: Server Component vs Client Component

혼란의 근본 원인은 Next.js의 Server Component 개념을 이해하지 못했기 때문입니다.

Vue/Nuxt: Universal Rendering

Vue와 Nuxt의 컴포넌트는 기본적으로 클라이언트에서 실행되며 반응형입니다. 서버 사이드 렌더링(SSR)을 사용하더라도, 컴포넌트는 클라이언트에서 hydration 과정을 거쳐 완전한 반응형 컴포넌트가 됩니다.

<script setup>
// 이 코드는 서버에서 한 번, 클라이언트에서 한 번 실행
// 최종적으로 클라이언트에서 반응형으로 작동
const { data: posts } = await useFetch('/api/posts')
const search = ref('')  // 반응형!

const filtered = computed(() => 
  posts.value.filter(p => p.title.includes(search.value))
)
</script>

참고: hydration은 서버에서 렌더링된 정적 HTML에 JavaScript를 연결하여 인터랙티브하게 만드는 과정입니다.

Next.js: 두 가지 세계

Next.js는 근본적으로 다른 접근을 합니다. 컴포넌트를 두 가지 종류로 명확히 구분합니다.

Server Component (기본값)

// app/posts/page.tsx
// 'use client' 없음 = Server Component

export default async function PostsPage() {
  // 이 코드는 오직 서버에서만 실행
  const posts = await prisma.post.findMany()
  
  return (
    <div>
      {posts.map(post => <div key={post.id}>{post.title}</div>)}
    </div>
  )
}

Server Component는:

  • 서버에서만 실행됩니다
  • 데이터베이스에 직접 접근할 수 있습니다
  • useState, useEffect 같은 훅을 사용할 수 없습니다
  • 이벤트 핸들러를 가질 수 없습니다
  • 반응형이 아닙니다

Client Component

// components/SearchBox.tsx
'use client'  // 이 선언이 Client Component를 만듦

import { useState } from 'react'

export default function SearchBox() {
  const [search, setSearch] = useState('')  // 이제 가능!
  
  return (
    <input 
      value={search}
      onChange={(e) => setSearch(e.target.value)}  // 반응형!
    />
  )
}

Client Component는:

  • 브라우저에서 실행됩니다
  • useState, useEffect 같은 훅을 사용할 수 있습니다
  • 이벤트 핸들러를 가질 수 있습니다
  • 반응형입니다
  • 데이터베이스에 직접 접근할 수 없습니다

왜 화면이 업데이트되지 않았는가?

이제 처음의 문제로 돌아가 봅시다. Server Component는 서버에서 한 번 실행되고 정적 HTML을 생성합니다. 그리고 Next.js는 성능을 위해 이 결과를 캐시합니다.

전체 흐름

// 1. 사용자가 페이지 방문
// Next.js: "posts 데이터 fetch하고 HTML 생성할게!"
const posts = await prisma.post.findMany()
// 결과: ['첫 번째 글', '두 번째 글']
// Next.js: "이 결과 캐시해둘게!"

// 2. 사용자가 새 게시글 추가
await prisma.post.create({ data: { title: '세 번째 글' } })
// DB: ['첫 번째 글', '두 번째 글', '세 번째 글'] ✓

// 3. 문제 발생
// 페이지는 여전히 캐시된 결과를 보여줌
// 화면: ['첫 번째 글', '두 번째 글']

// 4. revalidatePath로 해결
revalidatePath('/posts')
// Next.js: "캐시 무효화! 다음 요청 시 새로 fetch!"

// 5. 페이지가 새로고침되면
const posts = await prisma.post.findMany()
// 결과: ['첫 번째 글', '두 번째 글', '세 번째 글'] ✓

Vue의 반응형 시스템과 달리, Server Component는 정적이기 때문에 데이터가 변경되어도 자동으로 업데이트되지 않습니다. revalidatePath는 Next.js에게 "이 경로의 캐시를 무효화하고 다시 렌더링하라"고 명령하는 것입니다.

참고: Next.js의 캐싱 메커니즘은 실제로 더 복잡하지만, 이해를 돕기 위해 단순화했습니다.

왜 이런 복잡한 구조를 사용하는가?

Vue 개발자라면 당연히 이런 의문이 들 것입니다. "Vue처럼 반응형으로 만들면 되지 않나?"

Next.js가 Server Component와 Client Component를 분리한 이유는 성능과 개발자 경험 최적화입니다.

데이터베이스 직접 접근

Vue/Nuxt의 경우:

<!-- pages/posts.vue -->
<script setup>
// API 라우트 필요
const { data: posts } = await useFetch('/api/posts')
</script>
// server/api/posts.ts - 별도 파일 필요
export default defineEventHandler(async () => {
  const posts = await prisma.post.findMany()
  return posts
})

데이터베이스에 접근하려면 별도의 API 라우트를 만들어야 합니다.

Next.js의 경우:

// app/posts/page.tsx - 한 파일에서 끝!
export default async function PostsPage() {
  const posts = await prisma.post.findMany()  // 직접 접근!
  return <div>{posts.map(...)}</div>
}

Server Component에서 데이터베이스에 직접 접근할 수 있어 API 라우트를 만들 필요가 없습니다.

보안

Server Component의 코드는 서버에서만 실행되므로 환경 변수, API 키, 데이터베이스 쿼리 등이 클라이언트에 노출되지 않습니다.

실전 패턴: Server와 Client의 조합

실제 개발에서는 Server Component와 Client Component를 조합해서 사용합니다.

댓글 시스템 예시

// app/posts/[id]/page.tsx (Server Component)
import { prisma } from '@/lib/prisma'
import CommentList from './CommentList'
import CommentForm from './CommentForm'

export default async function PostPage({ params }: { params: { id: string } }) {
  // 서버에서 데이터 fetch
  const post = await prisma.post.findUnique({
    where: { id: params.id },
    include: {
      comments: {
        orderBy: { createdAt: 'desc' }
      }
    }
  })
  
  return (
    <div>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
      
      <CommentForm postId={post.id} />  {/* Client Component */}
      <CommentList comments={post.comments} />  {/* Client Component */}
    </div>
  )
}
// components/CommentForm.tsx (Client Component)
'use client'

import { useState } from 'react'
import { createComment } from './actions'

export default function CommentForm({ postId }: { postId: string }) {
  const [content, setContent] = useState('')
  
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    await createComment({ postId, content })
    setContent('')
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <textarea 
        value={content}
        onChange={(e) => setContent(e.target.value)}
        placeholder="댓글을 입력하세요"
      />
      <button type="submit">등록</button>
    </form>
  )
}
// app/posts/[id]/actions.ts (Server Action)
'use server'

import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'

export async function createComment(data: { postId: string, content: string }) {
  await prisma.comment.create({ data })
  revalidatePath(`/posts/${data.postId}`)
}

이 패턴에서:

  • Server Component가 데이터를 가져옵니다
  • Client Component가 사용자 인터랙션을 처리합니다
  • Server Action이 데이터를 변경하고 캐시를 무효화합니다

Nuxt는 왜 이 구조를 채택하지 않았는가?

이쯤 되면 "Nuxt도 이런 식으로 바꾸면 안 되나?"라는 의문이 들 수 있습니다.

철학의 차이

React의 철학: 명시적

React는 항상 명시적이고 예측 가능한 것을 선호했습니다. Server Component와 Client Component를 명확히 구분하는 것도 이 철학의 연장선입니다.

Vue의 철학: 편의성

Vue는 개발자 편의성과 낮은 학습 곡선을 중시합니다. "그냥 작동해야 한다"는 철학 아래, 복잡한 개념을 숨기려 노력합니다.

역사적 맥락

React Server Components는 비교적 최근에 도입된 개념입니다. Nuxt는 오랜 기간 Universal Rendering 방식으로 서버 사이드 렌더링을 제공해왔고, 이 방식이 충분히 잘 작동했습니다.

Nuxt 팀은 기존 방식의 간결함을 유지하면서도 필요한 최적화를 제공하는 방향을 선택했습니다.

Nuxt의 접근법

Nuxt 3에는 <ClientOnly> 컴포넌트가 있어 클라이언트 전용 렌더링을 할 수 있지만, 기본적으로는 Universal Component를 사용하는 것을 권장합니다.

<script setup>
// 서버와 클라이언트 모두에서 실행
const { data: posts } = await useFetch('/api/posts')
const search = ref('')

// Nuxt가 자동으로 처리
// 서버: 초기 렌더링
// 클라이언트: hydration 후 반응형
</script>

비교표: Vue/Nuxt vs React/Next.js

특성 Vue/Nuxt React/Next.js

기본 실행 위치 서버 + 클라이언트 명시적 분리
반응형 대부분의 컴포넌트 Client Component만
DB 직접 접근 API 라우트 필요 Server Component에서 가능
학습 곡선 완만 가파름
개발 복잡도 낮음 높음
성능 최적화 좋음 매우 좋음
코드 분리 자연스러운 통합 명시적 분리

결론: 패러다임의 전환

Vue에서 Next.js로 넘어오는 것은 단순한 프레임워크 변경이 아니라 사고방식의 전환입니다.

Vue는 반응형 시스템을 기본으로 제공하여 개발자가 상태 변경에만 집중할 수 있게 합니다. 개발자는 상태를 변경하면 화면이 자동으로 업데이트된다는 것을 신뢰할 수 있습니다.

Next.js는 "적재적소에 맞는 도구"라는 접근을 택했습니다. Server Component는 데이터를 가져오는 데, Client Component는 인터랙션을 처리하는 데 최적화되어 있습니다. 이는 더 복잡하지만, 더 세밀한 최적화가 가능합니다.

어느 것이 더 나은가는 프로젝트의 요구사항과 팀의 상황에 달려 있습니다. 중요한 것은 각 프레임워크의 철학과 설계 의도를 이해하고, 그에 맞게 개발하는 것입니다.

Vue 개발자로서 Next.js를 배우는 것은 분명 도전적입니다. 하지만 이 과정에서 웹 개발의 근본적인 질문들 - "무엇을 서버에서 처리할 것인가?", "무엇을 클라이언트에서 처리할 것인가?" - 을 다시 생각해볼 수 있는 기회가 됩니다.

참고 자료

반응형