next.js 동시성 제어(Concurrency Control)

52 sec read

구글 독스나 노션에서 여러 명이 동시에 하나의 문서를 편집해 본 경험이 있으신가요? 내가 글을 쓰는 동안 다른 사람의 수정 내용이 실시간으로 반영되고, 충돌 없이 자연스럽게 작업이 합쳐지는 것을 보면 신기하기만 합니다. 이런 마법 뒤에는 ‘동시성 제어’라는 매우 중요하고 정교한 기술이 숨어 있습니다.

동시성 제어란, 여러 사용자가 같은 데이터를 동시에 수정하려고 할 때 발생할 수 있는 데이터의 ‘꼬임’이나 ‘덮어쓰기’ 문제를 막고, 데이터의 일관성을 지키는 모든 기술을 의미합니다.

이번 글에서는 이 동시성 제어의 핵심 개념들을 알아보고, 여러 사용자가 함께 문서를 편집하는 ‘공동 문서 편집기’ 예제를 통해 충돌을 어떻게 감지하고 해결하는지 그 원리를 파헤쳐 보겠습니다.

📚 주요 개념 설명

동시성 제어라는 어려운 문제를 해결하기 위한 네 가지 무기를 소개합니다.

  • 버전 관리 (Versioning): ‘데이터의 주민등록번호’와 같습니다. 데이터가 한번 수정될 때마다 version 번호를 1씩 증가시키는 방식입니다. 내가 version 2 문서를 수정해서 저장하려고 할 때, 서버에 있는 문서가 이미 version 3이 되어 있다면? “아! 그 사이에 누군가 먼저 수정했구나!”라고 충돌을 감지할 수 있습니다.
  • 락킹 (Locking): ‘화장실에 들어가면 문 잠그기’와 같습니다. 한 사용자가 데이터를 수정하는 동안, 다른 사용자는 아예 수정할 수 없도록 데이터에 ‘잠금’을 거는 방식입니다. 매우 확실한 방법이지만, 다른 사용자들이 하염없이 기다려야 할 수 있다는 단점이 있습니다.
  • 충돌 해결 (Conflict Resolution): 충돌이 발생했을 때, 이 문제를 어떻게 해결할지 결정하는 과정입니다. “내가 수정한 내용을 무조건 덮어쓴다”, “다른 사람이 수정한 최신 내용을 따른다”, “두 내용을 비교해서 합친다” 등 다양한 전략이 있습니다.
  • 낙관적 락킹 (Optimistic Locking): “일단 수정하고, 저장할 때만 확인하기” 방식입니다. 락킹처럼 처음부터 수정을 막지는 않습니다. 대신, “충돌은 거의 일어나지 않을 거야”라고 낙관하고 자유롭게 수정하게 둡니다. 그리고 마지막에 저장하는 순간, 위에서 설명한 ‘버전 관리’를 통해 그사이에 누가 먼저 수정하지는 않았는지 확인합니다. 충돌이 거의 없는 환경에서 매우 효율적이며, 이 글의 예제에서 사용할 핵심 전략입니다.

💡 실제 사용 예제: 충돌을 해결하는 공동 문서 편집기

이제 ‘낙관적 락킹’과 ‘버전 관리’를 사용하여 여러 사용자가 함께 문서를 편집할 때 발생하는 충돌을 감지하고 해결하는 과정을 코드로 살펴보겠습니다.

1단계: 충돌 감지 로직이 포함된 문서 저장 기능 (handleSave)

문서를 저장하는 handleSave 함수가 동시성 제어의 핵심적인 역할을 수행합니다. 저장 요청을 보낼 때, 내가 가지고 있는 문서의 버전 정보를 HTTP 헤더에 담아 함께 보냅니다.

// 문서 저장 핸들러
const handleSave = async () => {
  if (!state.document || state.saving) return;

  setState(prev => ({ ...prev, saving: true }));

  try {
    // === 낙관적 락킹의 핵심 파트 ===
    const response = await fetch(`/api/documents/${documentId}`, {
      method: 'PUT',
      headers: {
        'Content-Type': 'application/json',
        // "내가 가진 문서 버전은 2번이야. 서버 버전도 2번일 때만 저장해줘!"
        'If-Match': state.document.version.toString() 
      },
      body: JSON.stringify(state.document)
    });

    // 서버가 409 Conflict 상태 코드를 보냈다면? -> 버전 충돌 발생!
    if (response.status === 409) {
      const serverData = await response.json(); // 서버의 최신 데이터를 받아옴
      // 충돌 상태를 true로 만들고, 서버 버전과 내 버전을 모두 저장
      setState(prev => ({
        ...prev,
        conflictData: {
          serverVersion: serverData,
          localVersion: prev.document!
        }
      }));
      return; // 저장을 중단하고 충돌 해결 모드로 진입
    }

    if (!response.ok) { throw new Error('저장에 실패했습니다'); }

    // 성공 시, 서버로부터 받은 최신 버전의 문서로 상태 업데이트
    const updatedDoc = await response.json();
    setState(prev => ({
      ...prev,
      document: updatedDoc,
      hasChanges: false,
    }));

  } catch (error) {
    // ... 에러 처리 ...
  } finally {
    setState(prev => ({ ...prev, saving: false }));
  }
};
  • If-Match 헤더: “만약 서버에 있는 데이터의 ETag(버전)가 내가 보내는 이 값과 일치한다면, 이 요청을 처리해줘. 그렇지 않으면 거절해!”라는 의미의 조건부 요청 헤더입니다. 우리는 여기에 문서의 version을 넣어 보냅니다.

2단계: 사용자에게 선택권을 주는 충돌 해결 UI

충돌이 감지되면, 사용자에게 어떤 버전의 문서를 유지할지 선택할 수 있는 대화상자(Dialog)를 보여줍니다.

// 충돌 해결 대화상자 컴포넌트
<Dialog open={!!state.conflictData}>
  <DialogTitle>문서 충돌 발생</DialogTitle>
  <DialogContent>
    <Typography>
      다른 사용자가 이 문서를 수정했습니다. 어떤 버전을 유지하시겠습니까?
    </Typography>
    {/* 서버 버전과 내 버전을 나란히 보여줌 */}
    <Box sx={{ display: 'flex', gap: 2 }}>
      <TextField
        label="서버 버전 (최신)"
        multiline
        value={state.conflictData?.serverVersion.content || ''}
        InputProps={{ readOnly: true }}
      />
      <TextField
        label="내가 수정한 버전"
        multiline
        value={state.conflictData?.localVersion.content || ''}
        InputProps={{ readOnly: true }}
      />
    </Box>
  </DialogContent>
  <DialogActions>
    {/* 사용자의 선택에 따라 상태를 업데이트하는 버튼들 */}
    <Button onClick={() => handleResolveConflict(true)}>서버 버전 사용</Button>
    <Button onClick={() => handleResolveConflict(false)}>내 버전 유지하기</Button>
  </DialogActions>
</Dialog>
  • handleResolveConflict 함수: 사용자의 선택에 따라 document 상태를 서버 버전 또는 내 로컬 버전으로 업데이트하고, 충돌 상태(conflictData)를 null로 만들어 대화상자를 닫습니다.

🎓 주니어 개발자를 위한 핵심 정리

이 예제를 통해 우리는 무엇을 배울 수 있을까요?

  1. 데이터 무결성은 소중하다: 동시성 제어의 궁극적인 목표는 여러 사용자가 동시에 작업하더라도 데이터가 깨지거나 유실되지 않도록, 즉 무결성(Integrity)을 지키는 것입니다.
  2. HTTP 스펙을 활용하라: If-Match 헤더와 409 Conflict 상태 코드는 이 문제를 해결하기 위해 이미 표준으로 정의된 강력한 도구입니다. 웹 표준을 잘 이해하고 활용하는 것이 중요합니다.
  3. 사용자에게 선택권을 주자: 충돌이 발생했을 때 시스템이 임의로 한쪽 데이터를 버리는 것보다, 사용자에게 상황을 명확히 알리고 직접 선택하게 하는 것이 훨씬 더 나은 사용자 경험을 제공합니다.
  4. 자동 저장은 양날의 검: 자동 저장 기능은 사용자 편의성을 높이지만, 충돌 발생 확률도 높일 수 있습니다. 따라서 자동 저장을 구현할 때는 반드시 강력한 동시성 제어 메커니즘이 함께 구현되어야 합니다.
  5. 상태 설계의 중요성: 우리 예제의 EditorStatedocument, loading, error 같은 기본 상태 외에도 conflictData라는 충돌 상황을 위한 전용 상태를 가지고 있습니다. 이처럼 발생할 수 있는 다양한 시나리오를 고려하여 상태를 꼼꼼하게 설계하는 것이 견고한 애플리케이션의 시작입니다.

next.js 클라이언트 상태 관리 웹 캐싱 전략의 모든 것…

매번 똑같은 책을 빌리기 위해 도서관에 가는 것과, 자주 보는 책 몇 권은 아예 책상 위에 꺼내두고 보는 것 중 어느 쪽이 더 빠를까요?...
eve
1 min read

React 클라이언트 상태 관리 (전역 상태 관리와 Context API)

우리가 큰 건물을 짓는다고 상상해 봅시다. 건물 전체의 ‘중앙 난방 온도’나 ‘비상벨 작동 여부’ 같은 정보는 1층 로비, 10층 사무실, 지하 주차장 등 건물의...
eve
1 min read

 React 19 자동 메모이제이션 (Automatic Memoization)

혹시 개발 공부를 하다가 useMemo, useCallback 같은 훅(Hook)들을 만나고 “이건 왜 써야 하지? 너무 복잡해…”라고 생각했던 적 있으신가요? 이런 훅들은 React의 성능을 최적화하기 위해,...
eve
46 sec read