Next.js 서버 액션과 Supabase 실시간을 활용한 낙관적 UI 패턴

발행: (2026년 6월 12일 AM 08:00 GMT+9)
7 분 소요
원문: Dev.to

Source: Dev.to

Optimistic UI Patterns with Next.js Server Actions and Supabase Realtime의 표지 이미지

Mahdi BEN RHOUMA

Optimistic UI Patterns with Next.js Server Actions and Supabase Realtime

앱이 빠르게 느껴지는지 느리게 느껴지는지는 실제 지연 시간 때문이 아니라 UI가 서버보다 먼저 반응하느냐에 달려 있습니다. 낙관적 업데이트는 기대되는 결과를 즉시 보여주고, 이후 서버 응답과 조정합니다. 올바르게 구현하면 사용자는 네트워크 지연을 거의 느끼지 못합니다.

Next.js 15는 useOptimistic을 안정적인 React 19 훅으로 제공하며, Server Actions와 깔끔하게 통합됩니다. Supabase Realtime와 결합해 다중 사용자 동기화를 구현하면, 클라이언트 간 일관성을 유지하면서 즉각적인 인터페이스를 만들 수 있습니다.

예상 읽는 시간: 13분

Prerequisites

  • Next.js 15 (React 19) – useOptimistic을 안정적으로 사용하기 위해
  • Realtime가 활성화된 Supabase 프로젝트
  • Server Actions와 useTransition에 대한 기본 이해
  • TypeScript 사용을 권장

How useOptimistic Works

useOptimistic은 두 개의 인자를 받습니다: 현재 실제 상태와 낙관적 상태를 만들어 내는 업데이트 함수. 반환값은 낙관적 상태(사용자에게 보여지는)와 낙관적 업데이트를 트리거하는 함수입니다.

const [optimisticState, addOptimistic] = useOptimistic(
  realState,
  (currentState, optimisticValue) => {
    // 새로운 낙관적 상태 반환
    return [...currentState, optimisticValue]
  }
)

Server Action가 진행 중일 때

  • optimisticState는 낙관적 값을 표시합니다.
  • Action이 완료되면 React는 realState로 되돌아갑니다(이때 revalidatePath 혹은 revalidateTag를 통해 서버 응답을 반영해야 함).
  • Action이 오류를 발생시키면 React는 자동으로 이전 realState로 되돌아갑니다.

Pattern 1: Optimistic List Item Addition

가장 흔한 사용 사례 – 서버 응답을 기다리지 않고 리스트에 항목을 추가합니다.

// app/todos/TodoList.tsx
'use client'

import { useOptimistic, useTransition } from 'react'
import { addTodo } from './actions'

interface Todo {
  id: string
  text: string
  completed: boolean
  pending?: boolean  // 낙관적 항목을 표시하기 위한 플래그
}

export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
  const [isPending, startTransition] = useTransition()
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    initialTodos,
    (state: Todo[], newTodo: Todo) => [...state, newTodo]
  )

  async function handleSubmit(formData: FormData) {
    const text = formData.get('text') as string
    if (!text.trim()) return

    const optimisticTodo: Todo = {
      id: `temp-${Date.now()}`,  // 임시 ID
      text,
      completed: false,
      pending: true,
    }

    startTransition(async () => {
      addOptimisticTodo(optimisticTodo)
      await addTodo(text)
    })
  }

  return (
    <>
      <form onSubmit={handleSubmit}>
        <button type="submit">Add</button>
      </form>

      {optimisticTodos.map((todo) => (
        <div key={todo.id}>
          {todo.text}
          {todo.pending && <span>(saving...)</span>}
        </div>
      ))}
    </>
  )
}
// app/todos/actions.ts
'use server'

import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'

export async function addTodo(text: string) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) throw new Error('Unauthorized')

  const { error } = await supabase
    .from('todos')
    .insert({ text, user_id: user.id, completed: false })

  if (error) throw error

  revalidatePath('/todos')
}

revalidatePath가 실행되면 Next.js는 Server Component 데이터를 다시 가져옵니다. 실제 DB에서 생성된 ID를 가진 진짜 todo가 낙관적 todo를 대체합니다.

Pattern 2: Optimistic Toggle (Like / Complete)

토글은 가장 단순한 낙관적 패턴입니다 – 새로운 상태는 현재 상태의 반대값입니다.

// app/todos/TodoItem.tsx
'use client'

import { useOptimistic, useTransition } from 'react'
import { toggleTodo } from './actions'

interface Todo {
  id: string
  text: string
  completed: boolean
}

export function TodoItem({ todo }: { todo: Todo }) {
  const [, startTransition] = useTransition()
  const [optimisticCompleted, setOptimisticCompleted] = useOptimistic(
    todo.completed,
    (_, newValue: boolean) => newValue
  )

  function handleToggle() {
    startTransition(async () => {
      setOptimisticCompleted(!optimisticCompleted)
      await toggleTodo(todo.id, !todo.completed)
    })
  }

  return (
    <div onClick={handleToggle}>
      {todo.text}
    </div>
  )
}

토글이 즉시 반영됩니다. 서버 액션이 실패하면 optimisticCompleted는 자동으로 todo.completed 값으로 되돌아갑니다.

Pattern 3: Optimistic Delete with Error Recovery

삭제는 신중히 다루어야 합니다 – 삭제가 실패하면 항목이 다시 나타나야 합니다.

// app/todos/TodoList.tsx
'use client'

import { useOptimistic, useTransition, useState } from 'react'
import { deleteTodo } from './actions'

export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
  const [, startTransition] = useTransition()
  const [error, setError] = useState(null)

  const [optimisticTodos, removeOptimisticTodo] = useOptimistic(
    initialTodos,
    (state: Todo[], idToRemove: string) =>
      state.filter((t) => t.id !== idToRemove)
  )

  function handleDelete(id: string) {
    setError(null)
    startTransition(async () => {
      removeOptimisticTodo(id)
      try {
        await deleteTodo(id)
      } catch (err: any) {
        setError(`Failed to delete: ${err.message}`)
        // useOptimistic가 자동으로 되돌리므로 항목이 다시 나타납니다
      }
    })
  }

  return (
    <>
      {error && <div>{error}</div>}

      {optimisticTodos.map((todo) => (
        <div key={todo.id}>
          {todo.text}
          <button onClick={() => handleDelete(todo.id)}>Delete</button>
        </div>
      ))}
    </>
  )
}

startTransition 내부의 try/catch가 Server Action 오류를 잡아 사용자에게 보여줍니다. 전환이 원래 상태와 함께 완료되었기 때문에 useOptimistic이 자동으로 상태를 복구합니다.

Pattern 4: Combining Optimistic Updates with Supabase Realtime

다중 사용자 앱에서는 현재 사용자에 대한 낙관적 업데이트와 다른 사용자에 대한 Realtime 업데이트를 모두 처리해야 합니다. 중복 업데이트를 방지하는 것이 핵심입니다.

// app/todos/RealtimeTodoList.tsx
'use client'

import { createClient } from '@/lib/supabase/client'
import { useOptimistic, useTransition, useEffect, useState } from 'react'
import { addTodo } from './actions'

export function RealtimeTodoList({ initialTodos }: { initialTodos: Todo[] }) {
  const [todos, setTodos] = useState(initialTodos)
  const [pendingIds] = useState(() => new Set())
  const [, startTransition] = useTransition()

  const [optimisticTodos
0 조회
Back to Blog

관련 글

더 보기 »

Eidentic 소개

Today we're releasing Eidentic, an open-source TypeScript SDK for building AI agents with self-improving memory and the production fundamentals built in — not b...

Typescript의 타입

Introdução Tipos são uma forma de definir a “forma” ou o contrato dos dados que estamos usando no código. Pensando em Javascript puro, ele é dinâmico: você pode...