[Next.js] Optimistic Updates 구현 방식




현대 웹 애플리케이션에서 사용자는 즉각적인 반응을 기대합니다. “좋아요” 버튼을 클릭했을 때, 댓글을 작성했을 때, 또는 설정을 변경했을 때 서버 응답을 기다리는 시간조차 답답하게 느껴집니다.

Optimistic Updates(낙관적 업데이트)는 이러한 사용자 경험 문제를 해결하는 핵심 기법입니다. 서버 응답을 기다리지 않고 UI를 먼저 업데이트한 후, 나중에 서버와 동기화하는 방식으로 즉각적이고 부드러운 사용자 경험을 제공합니다.

이 글에서는 Optimistic Updates의 개념부터 React와 Next.js 환경에서의 구체적인 구현 방법, 그리고 실제 프로덕션에서 마주치는 복잡한 시나리오들까지 살펴보겠습니다.


Optimistic Updates란?

Optimistic Updates는 사용자의 액션이 성공할 것이라고 가정하고 UI를 먼저 업데이트한 후, 실제 서버 응답을 기다려서 결과를 확인하는 UI 패턴입니다.

기본 동작 흐름

1. 사용자 액션 (예: 좋아요 클릭)
2. UI 즉시 업데이트 (좋아요 상태 변경)
3. 백그라운드에서 서버 요청
4. 서버 응답 확인
   - 성공: UI 상태 유지
   - 실패: UI 상태 롤백 + 에러 처리

전통적인 방식 vs Optimistic Updates

전통적인 방식:

// 사용자가 좋아요 버튼 클릭
const handleLike = async () => {
  setIsLoading(true); // 로딩 상태 표시
  
  try {
    const response = await fetch('/api/like', { method: 'POST' });
    if (response.ok) {
      setIsLiked(true); // 성공 후 UI 업데이트
      setLikeCount(prev => prev + 1);
    }
  } catch (error) {
    showError('좋아요 처리 중 오류가 발생했습니다.');
  } finally {
    setIsLoading(false);
  }
};

Optimistic Updates 방식:

const handleLike = async () => {
  // 1. UI 즉시 업데이트
  const previousState = { isLiked, likeCount };
  setIsLiked(true);
  setLikeCount(prev => prev + 1);
  
  try {
    // 2. 백그라운드에서 서버 요청
    const response = await fetch('/api/like', { method: 'POST' });
    
    if (!response.ok) {
      throw new Error('Server error');
    }
    
    // 3. 성공 시 - UI는 이미 업데이트된 상태 유지
    console.log('Like successfully processed');
    
  } catch (error) {
    // 4. 실패 시 - 이전 상태로 롤백
    setIsLiked(previousState.isLiked);
    setLikeCount(previousState.likeCount);
    showError('좋아요 처리 중 오류가 발생했습니다.');
  }
};

React Query를 활용한 Optimistic Updates

React Query는 Optimistic Updates를 구현하는 가장 우아한 방법 중 하나를 제공합니다.

기본 구현

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';

// 좋아요 토글 훅
function useLikePost() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async ({ postId, isLiked }) => {
      const method = isLiked ? 'DELETE' : 'POST';
      const response = await fetch(`/api/posts/${postId}/like`, { method });
      
      if (!response.ok) {
        throw new Error('Failed to toggle like');
      }
      
      return response.json();
    },

    // Optimistic Update 설정
    onMutate: async ({ postId, isLiked }) => {
      // 1. 진행 중인 쿼리 취소 (동시성 이슈 방지)
      await queryClient.cancelQueries({ queryKey: ['post', postId] });

      // 2. 현재 상태 백업 (롤백용)
      const previousPost = queryClient.getQueryData(['post', postId]);

      // 3. UI 즉시 업데이트
      queryClient.setQueryData(['post', postId], (old) => {
        if (!old) return old;
        
        return {
          ...old,
          isLiked: !isLiked,
          likeCount: isLiked ? old.likeCount - 1 : old.likeCount + 1,
        };
      });

      // 4. 롤백 데이터 반환
      return { previousPost };
    },

    // 에러 발생 시 롤백
    onError: (error, { postId }, context) => {
      // 이전 상태로 복원
      if (context?.previousPost) {
        queryClient.setQueryData(['post', postId], context.previousPost);
      }
      
      // 에러 알림
      toast.error('좋아요 처리 중 오류가 발생했습니다.');
    },

    // 성공/실패 관계없이 최종 처리
    onSettled: (data, error, { postId }) => {
      // 서버 데이터와 동기화
      queryClient.invalidateQueries({ queryKey: ['post', postId] });
      queryClient.invalidateQueries({ queryKey: ['posts'] }); // 목록도 갱신
    },
  });
}

// 컴포넌트에서 사용
function PostCard({ post }) {
  const likeMutation = useLikePost();

  const handleLikeClick = () => {
    likeMutation.mutate({
      postId: post.id,
      isLiked: post.isLiked,
    });
  };

  return (
    <div className="post-card">
      <h2>{post.title}</h2>
      <p>{post.content}</p>
      
      <button 
        onClick={handleLikeClick}
        disabled={likeMutation.isPending}
        className={`like-button ${post.isLiked ? 'liked' : ''}`}
      >
        ❤️ {post.likeCount}
        {likeMutation.isPending && ' (처리 중...)'}
      </button>
      
      {likeMutation.isError && (
        <p className="error">좋아요 처리에 실패했습니다.</p>
      )}
    </div>
  );
}

목록 데이터의 Optimistic Updates

// 댓글 추가 훅
function useAddComment() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async ({ postId, content, parentId = null }) => {
      const response = await fetch(`/api/posts/${postId}/comments`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ content, parentId }),
      });

      if (!response.ok) throw new Error('Failed to add comment');
      return response.json();
    },

    onMutate: async ({ postId, content, parentId }) => {
      await queryClient.cancelQueries({ queryKey: ['comments', postId] });

      const previousComments = queryClient.getQueryData(['comments', postId]);
      
      // 임시 댓글 객체 생성
      const tempComment = {
        id: `temp-${Date.now()}`, // 임시 ID
        content,
        parentId,
        author: {
          id: 'current-user',
          name: '나', // 현재 사용자 정보
          avatar: '/current-user-avatar.jpg',
        },
        createdAt: new Date().toISOString(),
        isTemporary: true, // 임시 댓글 표시
        likeCount: 0,
        replies: [],
      };

      // 댓글 목록에 즉시 추가
      queryClient.setQueryData(['comments', postId], (old) => {
        if (!old) return [tempComment];
        
        if (parentId) {
          // 대댓글인 경우
          return old.map(comment => 
            comment.id === parentId 
              ? { ...comment, replies: [...comment.replies, tempComment] }
              : comment
          );
        } else {
          // 최상위 댓글인 경우
          return [tempComment, ...old];
        }
      });

      return { previousComments, tempComment };
    },

    onSuccess: (newComment, variables, context) => {
      const { postId } = variables;
      const { tempComment } = context;

      // 임시 댓글을 실제 댓글로 교체
      queryClient.setQueryData(['comments', postId], (old) => {
        if (!old) return [newComment];

        const replaceTempComment = (comments) => {
          return comments.map(comment => {
            if (comment.id === tempComment.id) {
              return newComment; // 실제 서버 데이터로 교체
            }
            if (comment.replies?.length > 0) {
              return {
                ...comment,
                replies: replaceTempComment(comment.replies)
              };
            }
            return comment;
          });
        };

        return replaceTempComment(old);
      });
    },

    onError: (error, variables, context) => {
      const { postId } = variables;
      
      // 실패 시 이전 상태로 복원
      if (context?.previousComments) {
        queryClient.setQueryData(['comments', postId], context.previousComments);
      }

      toast.error('댓글 작성에 실패했습니다.');
    },
  });
}

// 댓글 컴포넌트
function CommentSection({ postId }) {
  const [newComment, setNewComment] = useState('');
  const addCommentMutation = useAddComment();
  
  const { data: comments, isLoading } = useQuery({
    queryKey: ['comments', postId],
    queryFn: () => fetchComments(postId),
  });

  const handleSubmit = (e) => {
    e.preventDefault();
    if (!newComment.trim()) return;

    addCommentMutation.mutate({
      postId,
      content: newComment,
    });

    setNewComment(''); // 입력 필드 클리어
  };

  if (isLoading) return <div>댓글 로딩 중...</div>;

  return (
    <div className="comment-section">
      <form onSubmit={handleSubmit}>
        <textarea
          value={newComment}
          onChange={(e) => setNewComment(e.target.value)}
          placeholder="댓글을 입력하세요..."
          disabled={addCommentMutation.isPending}
        />
        <button type="submit" disabled={!newComment.trim()}>
          {addCommentMutation.isPending ? '작성 중...' : '댓글 작성'}
        </button>
      </form>

      <div className="comments-list">
        {comments?.map(comment => (
          <CommentItem 
            key={comment.id} 
            comment={comment}
            isTemporary={comment.isTemporary}
          />
        ))}
      </div>
    </div>
  );
}

function CommentItem({ comment, isTemporary }) {
  return (
    <div className={`comment ${isTemporary ? 'temporary' : ''}`}>
      <div className="comment-header">
        <img src={comment.author.avatar} alt={comment.author.name} />
        <span className="author-name">{comment.author.name}</span>
        <span className="timestamp">
          {isTemporary ? '전송 중...' : formatDate(comment.createdAt)}
        </span>
      </div>
      <p className="comment-content">{comment.content}</p>
      
      {comment.replies?.map(reply => (
        <CommentItem 
          key={reply.id} 
          comment={reply} 
          isTemporary={reply.isTemporary}
        />
      ))}
    </div>
  );
}

Zustand를 활용한 Global State Optimistic Updates

// stores/todoStore.js
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';

export const useTodoStore = create(
  immer((set, get) => ({
    todos: [],
    
    // 낙관적 할 일 추가
    addTodoOptimistic: async (text) => {
      const tempId = `temp-${Date.now()}`;
      const tempTodo = {
        id: tempId,
        text,
        completed: false,
        isTemporary: true,
        createdAt: new Date().toISOString(),
      };

      // 1. UI 즉시 업데이트
      set((state) => {
        state.todos.unshift(tempTodo);
      });

      try {
        // 2. 서버 요청
        const response = await fetch('/api/todos', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ text }),
        });

        if (!response.ok) throw new Error('Failed to add todo');
        
        const newTodo = await response.json();

        // 3. 성공: 임시 할 일을 실제 할 일로 교체
        set((state) => {
          const index = state.todos.findIndex(todo => todo.id === tempId);
          if (index !== -1) {
            state.todos[index] = newTodo;
          }
        });

      } catch (error) {
        // 4. 실패: 임시 할 일 제거
        set((state) => {
          state.todos = state.todos.filter(todo => todo.id !== tempId);
        });
        
        throw error; // 에러를 상위로 전파
      }
    },

    // 낙관적 할 일 완료 토글
    toggleTodoOptimistic: async (todoId) => {
      // 현재 상태 백업
      const currentTodo = get().todos.find(todo => todo.id === todoId);
      if (!currentTodo) return;

      const previousCompleted = currentTodo.completed;

      // 1. UI 즉시 업데이트
      set((state) => {
        const todo = state.todos.find(t => t.id === todoId);
        if (todo) {
          todo.completed = !todo.completed;
          todo.isOptimistic = true; // 낙관적 업데이트 표시
        }
      });

      try {
        // 2. 서버 요청
        const response = await fetch(`/api/todos/${todoId}`, {
          method: 'PATCH',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ completed: !previousCompleted }),
        });

        if (!response.ok) throw new Error('Failed to toggle todo');

        const updatedTodo = await response.json();

        // 3. 성공: 낙관적 표시 제거
        set((state) => {
          const todo = state.todos.find(t => t.id === todoId);
          if (todo) {
            Object.assign(todo, updatedTodo);
            delete todo.isOptimistic;
          }
        });

      } catch (error) {
        // 4. 실패: 이전 상태로 롤백
        set((state) => {
          const todo = state.todos.find(t => t.id === todoId);
          if (todo) {
            todo.completed = previousCompleted;
            delete todo.isOptimistic;
          }
        });

        throw error;
      }
    },

    // 낙관적 할 일 삭제
    deleteTodoOptimistic: async (todoId) => {
      // 현재 할 일 백업
      const todoToDelete = get().todos.find(todo => todo.id === todoId);
      const todoIndex = get().todos.findIndex(todo => todo.id === todoId);
      
      if (!todoToDelete) return;

      // 1. UI에서 즉시 제거 (하지만 백업 유지)
      set((state) => {
        state.todos = state.todos.filter(todo => todo.id !== todoId);
      });

      try {
        // 2. 서버 요청
        const response = await fetch(`/api/todos/${todoId}`, {
          method: 'DELETE',
        });

        if (!response.ok) throw new Error('Failed to delete todo');

        // 3. 성공: 이미 UI에서 제거됨, 추가 작업 없음
        console.log('Todo deleted successfully');

      } catch (error) {
        // 4. 실패: 할 일 복원
        set((state) => {
          state.todos.splice(todoIndex, 0, todoToDelete);
        });

        throw error;
      }
    },

    // 초기 데이터 로드
    loadTodos: async () => {
      try {
        const response = await fetch('/api/todos');
        const todos = await response.json();
        
        set((state) => {
          state.todos = todos;
        });
      } catch (error) {
        console.error('Failed to load todos:', error);
      }
    },
  }))
);

// components/TodoApp.js
import { useTodoStore } from '@/stores/todoStore';
import { useState } from 'react';
import { toast } from 'react-hot-toast';

function TodoApp() {
  const [inputText, setInputText] = useState('');
  const { todos, addTodoOptimistic, toggleTodoOptimistic, deleteTodoOptimistic } = useTodoStore();

  const handleAddTodo = async (e) => {
    e.preventDefault();
    if (!inputText.trim()) return;

    try {
      await addTodoOptimistic(inputText);
      setInputText('');
      toast.success('할 일이 추가되었습니다!');
    } catch (error) {
      toast.error('할 일 추가에 실패했습니다.');
    }
  };

  const handleToggleTodo = async (todoId) => {
    try {
      await toggleTodoOptimistic(todoId);
    } catch (error) {
      toast.error('할 일 상태 변경에 실패했습니다.');
    }
  };

  const handleDeleteTodo = async (todoId) => {
    try {
      await deleteTodoOptimistic(todoId);
      toast.success('할 일이 삭제되었습니다!');
    } catch (error) {
      toast.error('할 일 삭제에 실패했습니다.');
    }
  };

  return (
    <div className="todo-app">
      <form onSubmit={handleAddTodo} className="add-todo-form">
        <input
          type="text"
          value={inputText}
          onChange={(e) => setInputText(e.target.value)}
          placeholder="새 할 일을 입력하세요..."
        />
        <button type="submit">추가</button>
      </form>

      <ul className="todo-list">
        {todos.map(todo => (
          <li 
            key={todo.id} 
            className={`todo-item ${todo.completed ? 'completed' : ''} ${
              todo.isTemporary || todo.isOptimistic ? 'processing' : ''
            }`}
          >
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => handleToggleTodo(todo.id)}
              disabled={todo.isOptimistic}
            />
            
            <span className="todo-text">{todo.text}</span>
            
            {todo.isTemporary && (
              <span className="status">추가 중...</span>
            )}
            
            {todo.isOptimistic && (
              <span className="status">처리 중...</span>
            )}

            <button 
              onClick={() => handleDeleteTodo(todo.id)}
              className="delete-button"
              disabled={todo.isOptimistic}
            >
              삭제
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

복잡한 시나리오: 실시간 협업 환경

// 실시간 문서 편집에서의 Optimistic Updates
import { useCallback, useRef } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useSocket } from '@/hooks/useSocket';

function useDocumentEditor(documentId) {
  const queryClient = useQueryClient();
  const socket = useSocket();
  const pendingChangesRef = useRef(new Map());

  // 문서 변경사항 적용
  const updateDocumentMutation = useMutation({
    mutationFn: async ({ changes, version }) => {
      const response = await fetch(`/api/documents/${documentId}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ changes, version }),
      });

      if (!response.ok) {
        const error = await response.json();
        throw new Error(error.message || 'Failed to update document');
      }

      return response.json();
    },

    onMutate: async ({ changes, changeId }) => {
      // 진행 중인 쿼리 취소
      await queryClient.cancelQueries({ queryKey: ['document', documentId] });

      // 현재 문서 상태 백업
      const previousDocument = queryClient.getQueryData(['document', documentId]);

      // 변경사항을 대기 목록에 추가
      pendingChangesRef.current.set(changeId, {
        changes,
        timestamp: Date.now(),
        status: 'pending'
      });

      // UI 즉시 업데이트
      queryClient.setQueryData(['document', documentId], (old) => {
        if (!old) return old;

        return {
          ...old,
          content: applyChanges(old.content, changes),
          version: old.version + 1,
          lastModified: new Date().toISOString(),
          pendingChanges: Array.from(pendingChangesRef.current.values())
        };
      });

      return { previousDocument, changeId };
    },

    onSuccess: (result, variables, context) => {
      const { changeId } = context;
      
      // 성공한 변경사항을 대기 목록에서 제거
      pendingChangesRef.current.delete(changeId);

      // 서버에서 받은 최신 버전으로 업데이트
      queryClient.setQueryData(['document', documentId], (old) => {
        if (!old) return result.document;

        return {
          ...result.document,
          pendingChanges: Array.from(pendingChangesRef.current.values())
        };
      });

      // 다른 사용자들에게 변경사항 브로드캐스트
      socket.emit('document:change', {
        documentId,
        changes: variables.changes,
        version: result.document.version,
        userId: 'current-user-id'
      });
    },

    onError: (error, variables, context) => {
      const { changeId, previousDocument } = context;

      // 실패한 변경사항 처리
      const pendingChange = pendingChangesRef.current.get(changeId);
      if (pendingChange) {
        pendingChange.status = 'failed';
        pendingChange.error = error.message;
      }

      // 충돌 해결이 필요한 경우
      if (error.message.includes('version_conflict')) {
        handleVersionConflict(variables.changes, changeId);
      } else {
        // 일반적인 에러의 경우 롤백
        if (previousDocument) {
          queryClient.setQueryData(['document', documentId], {
            ...previousDocument,
            pendingChanges: Array.from(pendingChangesRef.current.values())
          });
        }
      }
    },
  });

  // 버전 충돌 해결
  const handleVersionConflict = useCallback(async (changes, changeId) => {
    try {
      // 최신 문서 버전 가져오기
      const latestDocument = await queryClient.fetchQuery({
        queryKey: ['document', documentId],
        queryFn: () => fetchDocument(documentId),
      });

      // 변경사항을 최신 버전에 맞게 재조정
      const resolvedChanges = resolveConflicts(changes, latestDocument);

      // 재시도
      updateDocumentMutation.mutate({
        changes: resolvedChanges,
        version: latestDocument.version,
        changeId: `${changeId}-resolved`
      });

    } catch (error) {
      console.error('Failed to resolve version conflict:', error);
      
      // 충돌 해결 실패 시 사용자에게 알림
      toast.error('문서 충돌이 발생했습니다. 페이지를 새로고침해주세요.');
    }
  }, [documentId, queryClient, updateDocumentMutation]);

  // 다른 사용자의 변경사항 수신
  useEffect(() => {
    socket.on('document:change', ({ changes, version, userId }) => {
      if (userId === 'current-user-id') return; // 자신의 변경사항은 무시

      queryClient.setQueryData(['document', documentId], (old) => {
        if (!old || old.version >= version) return old;

        return {
          ...old,
          content: applyChanges(old.content, changes),
          version,
          lastModified: new Date().toISOString(),
          collaborators: updateCollaboratorActivity(old.collaborators, userId)
        };
      });
    });

    return () => socket.off('document:change');
  }, [socket, queryClient, documentId]);

  return {
    updateDocument: (changes) => {
      const changeId = `change-${Date.now()}-${Math.random()}`;
      updateDocumentMutation.mutate({ 
        changes, 
        changeId,
        version: queryClient.getQueryData(['document', documentId])?.version || 0
      });
    },
    isUpdating: updateDocumentMutation.isPending,
    pendingChanges: Array.from(pendingChangesRef.current.values()),
  };
}

// 변경사항 적용 함수
function applyChanges(content, changes) {
  let updatedContent = content;
  
  // 변경사항을 역순으로 적용 (인덱스 변화 방지)
  const sortedChanges = [...changes].sort((a, b) => b.position - a.position);
  
  for (const change of sortedChanges) {
    switch (change.type) {
      case 'insert':
        updatedContent = 
          updatedContent.slice(0, change.position) + 
          change.text + 
          updatedContent.slice(change.position);
        break;
        
      case 'delete':
        updatedContent = 
          updatedContent.slice(0, change.position) + 
          updatedContent.slice(change.position + change.length);
        break;
        
      case 'replace':
        updatedContent = 
          updatedContent.slice(0, change.position) + 
          change.text + 
          updatedContent.slice(change.position + change.length);
        break;
    }
  }
  
  return updatedContent;
}

// 문서 편집기 컴포넌트
function DocumentEditor({ documentId }) {
  const { data: document, isLoading } = useQuery({
    queryKey: ['document', documentId],
    queryFn: () => fetchDocument(documentId),
  });

  const { updateDocument, isUpdating, pendingChanges } = useDocumentEditor(documentId);
  
  const [content, setContent] = useState('');

  // 문서 내용이 로드되면 로컬 상태 업데이트
  useEffect(() => {
    if (document?.content) {
      setContent(document.content);
    }
  }, [document?.content]);

  const handleContentChange = useCallback((newContent) => {
    const oldContent = content;
    setContent(newContent);

    // 변경사항 계산
    const changes = calculateChanges(oldContent, newContent);
    if (changes.length > 0) {
      // 디바운스를 적용하여 너무 빈번한 업데이트 방지
      updateDocument(changes);
    }
  }, [content, updateDocument]);

  if (isLoading) return <div>문서 로딩 중...</div>;

  return (
    <div className="document-editor">
      <div className="editor-header">
        <h1>{document?.title}</h1>
        <div className="editor-status">
          {isUpdating && <span className="status saving">저장 중...</span>}
          {pendingChanges.length > 0 && (
            <span className="status pending">
              {pendingChanges.length}개 변경사항 대기 중
            </span>
          )}
          <span className="version">v{document?.version}</span>
        </div>
      </div>

      <textarea
        value={content}
        onChange={(e) => handleContentChange(e.target.value)}
        className="document-content"
        placeholder="문서 내용을 입력하세요..."
      />

      {/* 실패한 변경사항 표시 */}
      {pendingChanges.some(change => change.status === 'failed') && (
        <div className="error-banner">
          일부 변경사항이 저장되지 않았습니다. 
          <button onClick={() => window.location.reload()}>
            새로고침
          </button>
        </div>
      )}

      {/* 협업자 표시 */}
      <div className="collaborators">
        {document?.collaborators?.map(collaborator => (
          <div key={collaborator.id} className="collaborator">
            <img src={collaborator.avatar} alt={collaborator.name} />
            <span>{collaborator.name}</span>
            {collaborator.isActive && <span className="active-indicator">●</span>}
          </div>
        ))}
      </div>
    </div>
  );
}

에러 처리 및 사용자 경험 향상

스마트 에러 복구

// 지능적인 에러 복구 시스템
class OptimisticErrorRecovery {
  constructor() {
    this.failedOperations = new Map();
    this.retryQueue = [];
    this.maxRetries = 3;
  }

  // 실패한 작업 기록
  recordFailure(operationId, operation, context) {
    const failures = this.failedOperations.get(operationId) || 0;
    this.failedOperations.set(operationId, failures + 1);

    if (failures < this.maxRetries) {
      // 재시도 큐에 추가
      this.retryQueue.push({
        id: operationId,
        operation,
        context,
        retryCount: failures + 1,
        nextRetry: Date.now() + (Math.pow(2, failures) * 1000) // 지수 백오프
      });
    } else {
      // 최대 재시도 횟수 초과
      this.handlePermanentFailure(operationId, operation, context);
    }
  }

  // 재시도 처리
  async processRetryQueue() {
    const now = Date.now();
    const readyToRetry = this.retryQueue.filter(item => item.nextRetry <= now);

    for (const item of readyToRetry) {
      try {
        await item.operation();
        
        // 성공 시 큐에서 제거
        this.retryQueue = this.retryQueue.filter(i => i.id !== item.id);
        this.failedOperations.delete(item.id);
        
        toast.success('작업이 성공적으로 완료되었습니다.');
        
      } catch (error) {
        // 재시도 실패
        this.recordFailure(item.id, item.operation, item.context);
      }
    }
  }

  // 영구 실패 처리
  handlePermanentFailure(operationId, operation, context) {
    // 사용자에게 수동 해결 옵션 제공
    toast.error(
      '작업 완료에 실패했습니다. 잠시 후 다시 시도하거나 페이지를 새로고침해주세요.',
      {
        duration: 0, // 수동으로 닫을 때까지 표시
        action: {
          label: '다시 시도',
          onClick: () => this.manualRetry(operationId, operation, context)
        }
      }
    );
  }

  async manualRetry(operationId, operation, context) {
    try {
      await operation();
      this.failedOperations.delete(operationId);
      toast.success('작업이 완료되었습니다.');
    } catch (error) {
      toast.error('다시 시도해도 실패했습니다. 네트워크를 확인해주세요.');
    }
  }

  // 주기적 재시도 처리 시작
  startAutoRetry() {
    setInterval(() => {
      this.processRetryQueue();
    }, 5000); // 5초마다 확인
  }
}

// 전역 에러 복구 인스턴스
const errorRecovery = new OptimisticErrorRecovery();
errorRecovery.startAutoRetry();

사용자 피드백 향상

// 향상된 사용자 피드백 시스템
function useOptimisticFeedback() {
  const [operations, setOperations] = useState(new Map());

  const startOperation = useCallback((operationId, message = '처리 중...') => {
    setOperations(prev => new Map(prev.set(operationId, {
      id: operationId,
      message,
      status: 'pending',
      startTime: Date.now(),
    })));
  }, []);

  const completeOperation = useCallback((operationId, successMessage) => {
    setOperations(prev => {
      const newMap = new Map(prev);
      const operation = newMap.get(operationId);
      if (operation) {
        newMap.set(operationId, {
          ...operation,
          status: 'success',
          message: successMessage,
          completedTime: Date.now(),
        });

        // 3초 후 자동 제거
        setTimeout(() => {
          setOperations(current => {
            const updated = new Map(current);
            updated.delete(operationId);
            return updated;
          });
        }, 3000);
      }
      return newMap;
    });
  }, []);

  const failOperation = useCallback((operationId, errorMessage, canRetry = true) => {
    setOperations(prev => {
      const newMap = new Map(prev);
      const operation = newMap.get(operationId);
      if (operation) {
        newMap.set(operationId, {
          ...operation,
          status: 'error',
          message: errorMessage,
          canRetry,
          failedTime: Date.now(),
        });
      }
      return newMap;
    });
  }, []);

  const retryOperation = useCallback((operationId) => {
    setOperations(prev => {
      const newMap = new Map(prev);
      const operation = newMap.get(operationId);
      if (operation) {
        newMap.set(operationId, {
          ...operation,
          status: 'pending',
          message: '다시 시도 중...',
          retryCount: (operation.retryCount || 0) + 1,
        });
      }
      return newMap;
    });
  }, []);

  return {
    operations: Array.from(operations.values()),
    startOperation,
    completeOperation,
    failOperation,
    retryOperation,
  };
}

// 피드백 UI 컴포넌트
function OperationFeedback() {
  const { operations, retryOperation } = useOptimisticFeedback();

  if (operations.length === 0) return null;

  return (
    <div className="operation-feedback">
      {operations.map(operation => (
        <div key={operation.id} className={`feedback-item ${operation.status}`}>
          <div className="feedback-content">
            {operation.status === 'pending' && (
              <div className="spinner" />
            )}
            
            {operation.status === 'success' && (
              <svg className="check-icon" viewBox="0 0 20 20">
                <path d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"/>
              </svg>
            )}
            
            {operation.status === 'error' && (
              <svg className="error-icon" viewBox="0 0 20 20">
                <path d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"/>
              </svg>
            )}
            
            <span className="feedback-message">{operation.message}</span>
          </div>

          {operation.status === 'error' && operation.canRetry && (
            <button 
              onClick={() => retryOperation(operation.id)}
              className="retry-button"
            >
              다시 시도
            </button>
          )}
        </div>
      ))}
    </div>
  );
}

// 실제 사용 예시
function EnhancedLikeButton({ postId, initialLiked, initialCount }) {
  const { startOperation, completeOperation, failOperation } = useOptimisticFeedback();
  const [isLiked, setIsLiked] = useState(initialLiked);
  const [likeCount, setLikeCount] = useState(initialCount);

  const handleLike = async () => {
    const operationId = `like-${postId}-${Date.now()}`;
    const wasLiked = isLiked;
    const previousCount = likeCount;

    // 1. 피드백 시작
    startOperation(
      operationId, 
      isLiked ? '좋아요 취소 중...' : '좋아요 추가 중...'
    );

    // 2. UI 즉시 업데이트
    setIsLiked(!wasLiked);
    setLikeCount(prev => wasLiked ? prev - 1 : prev + 1);

    try {
      const response = await fetch(`/api/posts/${postId}/like`, {
        method: wasLiked ? 'DELETE' : 'POST',
      });

      if (!response.ok) throw new Error('네트워크 오류');

      // 3. 성공 피드백
      completeOperation(
        operationId, 
        wasLiked ? '좋아요를 취소했습니다' : '좋아요를 추가했습니다'
      );

    } catch (error) {
      // 4. 실패 시 롤백
      setIsLiked(wasLiked);
      setLikeCount(previousCount);
      
      failOperation(
        operationId, 
        '좋아요 처리 중 오류가 발생했습니다', 
        true
      );
    }
  };

  return (
    <button 
      onClick={handleLike}
      className={`like-button ${isLiked ? 'liked' : ''}`}
    >
      ❤️ {likeCount}
    </button>
  );
}

성능 고려사항 및 최적화

배치 처리를 통한 최적화

// 배치 처리를 통한 효율적인 Optimistic Updates
class BatchOptimisticUpdates {
  constructor(flushInterval = 1000) {
    this.pendingUpdates = new Map();
    this.flushInterval = flushInterval;
    this.flushTimer = null;
  }

  // 업데이트를 배치에 추가
  addUpdate(key, updateFn, rollbackFn) {
    // 기존 업데이트가 있으면 합성
    if (this.pendingUpdates.has(key)) {
      const existing = this.pendingUpdates.get(key);
      this.pendingUpdates.set(key, {
        updateFn: () => {
          existing.updateFn();
          updateFn();
        },
        rollbackFn: existing.rollbackFn, // 첫 번째 상태로 롤백
        timestamp: Date.now(),
      });
    } else {
      this.pendingUpdates.set(key, {
        updateFn,
        rollbackFn,
        timestamp: Date.now(),
      });
    }

    // UI 즉시 업데이트
    updateFn();

    // 배치 플러시 스케줄링
    this.scheduleFlush();
  }

  scheduleFlush() {
    if (this.flushTimer) {
      clearTimeout(this.flushTimer);
    }

    this.flushTimer = setTimeout(() => {
      this.flush();
    }, this.flushInterval);
  }

  async flush() {
    if (this.pendingUpdates.size === 0) return;

    const updates = Array.from(this.pendingUpdates.entries());
    this.pendingUpdates.clear();

    // 배치 서버 요청
    try {
      const response = await fetch('/api/batch-updates', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          updates: updates.map(([key, { timestamp }]) => ({
            key,
            timestamp,
          })),
        }),
      });

      if (!response.ok) throw new Error('Batch update failed');

      const results = await response.json();
      
      // 실패한 업데이트 롤백
      results.failures?.forEach(failedKey => {
        const failedUpdate = updates.find(([key]) => key === failedKey);
        if (failedUpdate) {
          failedUpdate[1].rollbackFn();
        }
      });

    } catch (error) {
      // 전체 배치 실패 시 모든 업데이트 롤백
      updates.forEach(([key, { rollbackFn }]) => {
        rollbackFn();
      });

      console.error('Batch update failed:', error);
    }
  }
}

// 사용 예시
const batchUpdater = new BatchOptimisticUpdates();

function useBatchedLike() {
  return useMutation({
    mutationFn: ({ postId, isLiked }) => {
      return new Promise((resolve, reject) => {
        const updateKey = `like-${postId}`;
        
        batchUpdater.addUpdate(
          updateKey,
          // 낙관적 업데이트
          () => {
            queryClient.setQueryData(['post', postId], old => ({
              ...old,
              isLiked: !isLiked,
              likeCount: isLiked ? old.likeCount - 1 : old.likeCount + 1,
            }));
          },
          // 롤백 함수
          () => {
            queryClient.setQueryData(['post', postId], old => ({
              ...old,
              isLiked,
              likeCount: isLiked ? old.likeCount + 1 : old.likeCount - 1,
            }));
            reject(new Error('Like update failed'));
          }
        );
        
        resolve();
      });
    },
  });
}

메모리 최적화

// 메모리 효율적인 Optimistic Updates 관리
class OptimisticUpdateManager {
  constructor(maxCacheSize = 100, ttl = 30000) {
    this.cache = new Map();
    this.maxCacheSize = maxCacheSize;
    this.ttl = ttl;
    
    // 주기적 정리
    setInterval(() => this.cleanup(), ttl / 2);
  }

  // 업데이트 상태 저장
  set(key, data) {
    // 캐시 크기 제한
    if (this.cache.size >= this.maxCacheSize) {
      // LRU 방식으로 오래된 항목 제거
      const firstKey = this.cache.keys().next().value;
      this.cache.delete(firstKey);
    }

    this.cache.set(key, {
      data,
      timestamp: Date.now(),
    });
  }

  get(key) {
    const item = this.cache.get(key);
    if (!item) return null;

    // TTL 확인
    if (Date.now() - item.timestamp > this.ttl) {
      this.cache.delete(key);
      return null;
    }

    return item.data;
  }

  cleanup() {
    const now = Date.now();
    for (const [key, item] of this.cache.entries()) {
      if (now - item.timestamp > this.ttl) {
        this.cache.delete(key);
      }
    }
  }

  clear() {
    this.cache.clear();
  }
}

const optimisticManager = new OptimisticUpdateManager();

마무리

Optimistic Updates는 현대 웹 애플리케이션에서 뛰어난 사용자 경험을 제공하는 핵심 기법입니다. 서버 응답을 기다리지 않고 UI를 즉시 업데이트함으로써 반응성과 생산성을 크게 향상시킬 수 있습니다.

Optimistic Updates의 핵심 이점:

  • 즉각적인 사용자 피드백으로 앱의 반응성 향상
  • 네트워크 지연 시간 숨기기로 원활한 사용자 경험
  • 높은 성공률을 가진 작업에서 효과적인 UX 개선

성공적인 구현을 위한 핵심 원칙:

  • 적절한 에러 핸들링과 롤백 메커니즘
  • 사용자에게 명확한 상태 피드백 제공
  • 복잡한 시나리오에서의 데이터 일관성 보장
  • 메모리와 성능 최적화 고려

구현 시 주의사항:

  • 모든 작업에 적용하지 말고 높은 성공률을 가진 작업에만 사용
  • 충돌 해결 및 버전 관리 전략 수립
  • 적절한 사용자 피드백과 에러 복구 메커니즘 구현

React Query, Zustand 등의 현대적 상태 관리 도구들을 활용하면 복잡한 Optimistic Updates 로직을 더 쉽고 안전하게 구현할 수 있습니다. 사용자가 기대하는 즉각적이고 반응적인 웹 애플리케이션을 구축하기 위해 적절한 시나리오에서 Optimistic Updates를 활용해보세요.




댓글 남기기