SiliconValley_survivor

SiliconValley_survivor

Frontend Optimistic UI, 충돌 해결

낙관적 UI는 확정 상태와 미확정 변경을 분리해 빠른 경험과 일관성을 함께 지킵니다.

SiliconValley_survivor's avatar
SiliconValley_survivor
Sep 22, 2026
∙ Paid

Introduction & Requirements

사용자가 대화 목록에서 제목을 변경합니다.

서버 응답을 기다리는 동안에도 화면에는 새 제목이 바로 나타납니다. 사용자는 이어서 같은 대화를 즐겨찾기에 추가합니다.

여기까지는 자연스럽습니다. 버튼을 눌렀는데 화면이 몇백 밀리초 동안 멈춰 있는 것보다 사용자의 의도를 먼저 보여주는 편이 빠르게 느껴집니다.

그런데 두 요청의 결과가 서로 다른 순서로 돌아왔다고 해보겠습니다.

즐겨찾기 변경은 성공했습니다. 하지만 조금 전에 보낸 제목 변경은 실패했습니다. 프론트엔드는 제목 변경 전에 저장해둔 대화 객체를 가져와 화면 전체를 되돌립니다.

그러자 제목뿐 아니라 방금 성공한 즐겨찾기까지 사라집니다.

Initial state
title = Original
pinned = false
User action A
title = Study Notes
User action B
pinned = true
Server result B
pinned = true
committed
Server result A
title change rejected
Naive rollback
title = Original
pinned = false

문제는 낙관적으로 화면을 바꿨다는 사실이 아닙니다.

실패한 작업 하나를 제거해야 하는데, 그 작업 이전의 화면 전체로 돌아갔다는 것이 문제입니다.

이전 화면을 저장해두었다가 실패하면 복구하는 방식은 단독 요청에서는 이해하기 쉽습니다. 하지만 그 사이에 다른 변경이 성공하거나, 다른 탭에서 새로운 값이 들어오거나, 사용자가 다음 입력을 했다면 이전 화면은 더 이상 돌아가도 되는 상태가 아닙니다.

Optimistic UI는 서버가 성공을 확정하기 전에 사용자의 변경을 화면에 반영하는 방식입니다. 여기서 낙관적이라는 말은 서버 검증을 생략한다는 뜻이 아닙니다. 검증을 기다리는 동안 사용자의 의도를 먼저 표현한다는 뜻입니다.

따라서 이번 설계에서는 두 가지 질문을 함께 다룹니다.

아직 확정되지 않은 변경을 어떻게 빠르게 보여줄 것인가.

서버 결과와 다른 변경이 도착했을 때 무엇을 유지하고, 무엇을 제거하고, 언제 사용자에게 다시 물어볼 것인가.

예시는 대화형 서비스의 제목 변경, 즐겨찾기, 보관 처리와 메시지 전송입니다. 낙관적 업데이트가 적합한 메타데이터 변경부터 시작하고, 동일한 접근을 적용하면 안 되는 작업도 구분하겠습니다.

Functional Requirements

* 사용자는 제목 변경, 즐겨찾기와 보관 처리를 요청한 직후 화면에서 자신의 변경을 확인할 수 있어야 합니다.

* 여러 변경이 이어져도 특정 요청의 실패가 다른 요청의 성공 결과나 이후 사용자 입력을 되돌리지 않아야 합니다.

* 다른 탭이나 기기에서 같은 데이터를 수정하면 충돌을 감지하고, 안전한 경우에만 자동으로 합쳐야 합니다.

* 연결이 끊기거나 응답을 받지 못한 경우에도 해당 작업이 성공했는지 다시 확인할 수 있어야 합니다.

* 저장 중, 결과 확인 중, 충돌과 명확한 실패를 구분하고, 사용자가 작성한 내용을 잃지 않은 상태에서 재시도하거나 수정할 수 있어야 합니다.

Non-Functional Requirements

* 낙관적 표시 대상으로 정한 상호작용은 사용자 입력 이후 p95 100ms 이내에 화면에 반영하는 것을 목표로 합니다.

* 서버 확정 지연과 화면 반영 지연을 별도로 측정하고, 빠르게 보였다는 이유로 저장 완료 시간을 숨기지 않습니다.

* 같은 작업의 네트워크 재시도로 업무 변경이 중복 실행되지 않아야 합니다.

* 클라이언트가 알고 있는 확정 버전보다 오래된 응답으로 상태가 되돌아가지 않아야 합니다.

* 미확정 작업 수와 보관 크기에 상한을 두고, 계정 전환이나 권한 변경 이후 이전 작업을 다른 계정으로 재실행하지 않아야 합니다.

100ms는 이 글의 설계 목표입니다. 모든 서버 요청을 그 안에 완료하겠다는 뜻이 아닙니다.

여기서 줄이려는 것은 사용자가 자신의 행동을 확인하기까지의 시간입니다. 서버가 실제로 저장한 시점은 별도의 상태와 지표로 계속 관리합니다.

Out of Scope

* 여러 사람이 문서의 같은 문장을 동시에 편집하는 CRDT와 Operational Transformation의 내부 알고리즘은 직접 구현하지 않습니다.

* 결제 승인, 재고 예약과 권한 부여 같은 업무의 내부 처리는 제외하되, 확정 전에 성공으로 표시하면 안 되는 경계는 다룹니다.

* 모델의 추론 과정은 제외하고, 질문 접수와 생성 작업 식별에 필요한 클라이언트 동작만 포함합니다.

먼저 반영할 수 있는 변경과 기다려야 하는 결과

대화 제목을 바꾸는 상황에서는 사용자가 원하는 결과를 이미 알고 있습니다.

사용자가 입력한 새 제목을 먼저 표시하고 저장 중임을 함께 보여줄 수 있습니다. 서버가 거부하면 작성한 제목을 보존하면서 원인을 설명할 수 있습니다.

즐겨찾기도 비슷합니다. 사용자는 해당 대화를 즐겨찾기에 넣겠다는 의도를 명확하게 표현했습니다.

반면 모델이 어떤 답변을 생성할지는 브라우저가 알 수 없습니다. 질문을 대화창에 먼저 표시할 수는 있지만, 서버가 접수하기 전에 생성 작업이 시작됐다고 확정해서는 안 됩니다.

결제나 권한 부여도 마찬가지입니다. 버튼이 눌렸다는 사실을 즉시 보여줄 수는 있어도, 실제 승인을 받기 전에 결제 완료나 접근 가능 상태를 만들어서는 안 됩니다.

따라서 낙관적 업데이트 여부는 성공률 하나로 결정하지 않습니다.

실패했을 때 복구할 수 있는지, 사용자가 임시 결과를 믿고 다음 행동을 했을 때 어떤 손실이 생기는지, 그리고 서버의 검증 결과를 브라우저가 얼마나 정확하게 예상할 수 있는지를 함께 봅니다.

실패율이 낮더라도 잘못된 성공 표시의 비용이 크다면, 결과 대신 처리 중 상태를 먼저 보여주는 편이 낫습니다.

Traffic & Capacity Assumptions

DAU를 100만 명, 사용자당 하루 메타데이터 변경을 8회로 가정하겠습니다.

Daily mutations
1,000,000 users
x 8 changes
= 8,000,000 mutations
Average rate
8,000,000 / 86,400
= about 93 mutations per second
Peak rate
Average x 10
= about 926 mutations per second

낙관적으로 표시한다고 서버 요청이 없어지는 것은 아닙니다.

화면은 즉시 바뀌어도 서버는 여전히 권한을 확인하고 데이터를 변경해야 합니다. 요청 두 개를 하나로 합치는 별도의 정책이 없는 한 서버 쓰기 부하는 그대로입니다.

전송된 요청의 2퍼센트에서 응답 유실 등으로 추가 전송이 한 번씩 발생한다고 가정하면 하루 약 16만 건의 재전송이 생깁니다.

이 재전송을 모두 새 작업으로 처리하면 요청량뿐 아니라 변경 결과도 달라질 수 있습니다. 따라서 같은 작업을 식별하는 계약이 필요합니다.

충돌률도 규모와 함께 봅니다.

변경 요청의 0.5퍼센트에서 충돌이 발생한다는 가정을 적용하면 하루 4만 건입니다. 비율은 작아 보여도 수동 복구 화면이 하루 수만 번 나타나는 규모입니다.

충돌을 무조건 사용자에게 넘기거나, 반대로 전부 자동 재시도하는 방식 모두 비용이 큽니다. 어떤 충돌을 자동으로 해결할 수 있는지 분류해야 합니다.

작업 결과를 보관하는 비용도 계산합니다

응답을 받지 못한 요청의 결과를 다시 확인하려면 서버가 작업 식별자와 처리 결과를 보관해야 합니다.

작업 결과 기록 하나가 평균 800바이트이고 하루 800만 건을 7일 동안 보관한다고 가정하겠습니다.

Operation results
8,000,000
x 800 bytes
x 7 days
= about 44.8GB

인덱스와 복제본, 거절된 시도와 추가 메타데이터는 제외한 값입니다.

이 비용을 줄이려고 기록을 너무 빨리 삭제하면 오래된 요청이 재전송되었을 때 이전에 처리한 작업인지 알 수 없게 됩니다.

따라서 이 설계에서는 자동 재전송을 허용하는 기간보다 작업 결과 기록을 더 오래 유지합니다. 예를 들어 자동 재전송은 24시간 이내로 제한하고 결과 기록은 7일 동안 유지하는 식입니다.

이 값들은 서비스의 오프라인 정책과 데이터 보관 요구사항에 맞춰 조정해야 합니다.

Core Entities & Data Modeling

낙관적 업데이트를 구현할 때 가장 먼저 분리할 것은 서버 상태와 화면 상태입니다.

이 글에서는 세 가지를 따로 둡니다.

Confirmed Snapshot은 서버가 확정했다고 알려준 데이터입니다.

Pending Changes는 사용자가 요청했지만 아직 최종 결과를 확인하지 못한 변경 목록입니다.

Projected View는 확정 데이터에 유효한 미확정 변경을 순서대로 적용해 계산한 화면입니다.

Confirmed Snapshot
        +
Pending Changes
        |
        v
Projected View

이 구조에서는 화면이 먼저 바뀌어도 서버의 확정 데이터를 직접 덮어쓰지 않습니다.

실패가 확인되면 이전 객체 전체를 복원하는 대신, 실패한 변경만 목록에서 제거한 뒤 화면을 다시 계산합니다.

대화 메타데이터의 버전을 따로 둡니다

대화의 제목과 즐겨찾기를 수정할 때 사용하는 버전을, 생성 중인 답변의 모든 텍스트 이벤트와 공유하지 않습니다.

답변 한 조각이 추가될 때마다 대화 전체 버전을 올리면 제목 변경도 계속 오래된 버전으로 판정될 수 있기 때문입니다.

이번 설계에서는 제목, 즐겨찾기와 보관 상태를 대화 메타데이터라는 하나의 수정 단위로 둡니다.

type ConversationFields = {
  title: string
  pinned: boolean
  archived: boolean
}
type ConfirmedSnapshot = {
  id: string
  revision: number
  fields: ConversationFields
}

`revision`, 같은 메타데이터 객체의 확정 버전입니다. 클라이언트가 낙관적으로 필드를 바꿀 때 이 숫자를 임의로 증가시키지 않습니다.

화면에 새 제목이 보이더라도 서버 확정 버전은 아직 이전 값일 수 있습니다. 그 차이를 숨기지 않는 것이 중요합니다.

변경 명령은 사용자의 의도를 표현합니다

전체 대화 객체를 그대로 보내는 대신 무엇을 바꾸려는지 명시합니다.

type Edit =
  | {
      type: “setTitle”
      value: string
    }
  | {
      type: “setPinned”
      value: boolean
    }
  | {
      type: “setArchived”
      value: boolean
    }

즐겨찾기를 반전하라는 `togglePinned`보다, 즐겨찾기를 켜라는 `setPinned(true)`가 재시도 상황에서 해석하기 쉽습니다.

같은 반전 명령을 두 번 실행하면 원래 값으로 돌아갈 수 있습니다. 원하는 값을 지정하면 사용자의 최종 의도를 더 직접적으로 표현할 수 있습니다.

다만 원하는 값을 지정했다고 동시성 문제가 모두 없어지는 것은 아닙니다.

다른 탭이 그사이에 다른 값을 저장했다면 누구의 변경을 유지할지 여전히 결정해야 합니다. 중복 요청 방지와 동시 수정 처리는 서로 다른 문제입니다.

미확정 변경에는 식별자와 상태가 필요합니다

type PendingChange = {
  operationId: string
  base: ConfirmedSnapshot
  edit: Edit
  status: “queued” | “sending” | “unknown” | “conflict”
}
type ClientState = {
  confirmed: ConfirmedSnapshot
  pending: readonly PendingChange[]
}

`operationId`, 서버에 제출하는 작업 한 번을 식별합니다. 동일 작업을 네트워크 문제로 재전송할 때는 같은 값을 사용합니다.

`base`, 사용자가 변경을 만들 때 기준으로 본 서버 상태입니다. 이후 충돌이 발생하면 무엇이 달라졌는지 비교하는 데 사용합니다.

이 예제에서는 필드가 세 개뿐이므로 작은 Snapshot 전체를 보관했습니다. 큰 객체라면 수정한 필드와 필요한 기준값만 보관할 수 있습니다.

`queued`, 사용자의 변경은 등록됐지만 아직 서버에 전송하지 않은 상태입니다.

`sending`, 서버에 요청을 보낸 상태입니다.

`unknown`, 응답을 받지 못해 결과를 확인해야 하는 상태입니다.

`conflict`, 자동 반영을 멈추고 변경 내용을 비교하거나 사용자 선택이 필요한 상태입니다.

실제 전송 계층에는 대상 계정, 요청 본문, 버전 조건과 요청 해시도 필요합니다. 여기서는 화면 상태를 설명하는 모델과 전송 계약을 나눠서 보겠습니다.

RDBMS에서 제외할 데이터

Projected View를 서버의 업무 원본으로 저장하지 않습니다.

서버에는 확정된 메타데이터를 저장하고, 브라우저가 임시로 보여주는 상태는 클라이언트에서 계산합니다.

사용자가 한 글자씩 작성 중인 초안도 매번 서버의 제목으로 저장하지 않습니다. 자동 저장을 지원한다면 저장 단위와 충돌 정책을 별도로 정합니다.

다만 서버의 작업 처리 결과 기록은 필요합니다.

이 기록에는 작업 식별자, 요청 내용의 해시, 성공 또는 거절 결과와 확정 버전이 들어갑니다. 클라이언트의 Pending Changes와 달리, 실제로 처리한 요청을 다시 확인하기 위한 서버 기록입니다.

성능 이벤트에는 편집한 제목이나 질문 원문을 무조건 넣지 않습니다. 변경 종류, 대기 시간, 충돌 분류와 버전 차이처럼 필요한 정보만 남깁니다.

API Signatures

이번 API는 낙관적 화면을 서버에 그대로 저장하는 계약이 아닙니다.

클라이언트가 어떤 버전을 기준으로 어떤 변경을 요청했는지 보내고, 서버는 그 조건을 확인한 결과를 반환합니다.

먼저 메타데이터를 조회합니다.

GET /api/conversations/c_1/metadata HTTP/1.1
Accept: application/json
HTTP/1.1 200 OK
Content-Type: application/json
ETag: “meta-c_1-41”
{
  “id”: “c_1”,
  “revision”: 41,
  “fields”: {
    “title”: “초안”,
    “pinned”: false,
    “archived”: false
  }
}

ETag는 서버가 반환한 해당 표현의 식별자입니다. 클라이언트는 문자열을 해석해 버전을 계산하지 않고, 변경 요청의 조건으로 그대로 전달합니다.

조건이 맞을 때만 변경합니다

PATCH /api/conversations/c_1/metadata HTTP/1.1
Content-Type: application/json
If-Match: “meta-c_1-41”
Idempotency-Key: op_a7
{
  “type”: “setTitle”,
  “value”: “여행 준비”
}

이 요청은 사용자가 읽은 버전이 여전히 유효할 때 제목을 변경해달라는 뜻입니다.

HTTP의 `If-Match`는 이런 조건부 변경에 사용할 수 있습니다. 조건이 맞지 않으면 서버는 변경을 수행하지 않고 일반적으로 412 응답으로 알립니다. 비교에는 약한 검증자가 아닌 강한 ETag가 사용됩니다.

성공 응답에는 어떤 작업이 확정됐는지와 그 결과를 함께 넣습니다.

HTTP/1.1 200 OK
Content-Type: application/json
ETag: “meta-c_1-42”
{
  “operationId”: “op_a7”,
  “status”: “committed”,
  “entity”: {
    “id”: “c_1”,
    “revision”: 42,
    “fields”: {
      “title”: “여행 준비”,
      “pinned”: false,
      “archived”: false
    }
  }
}

서버가 제목을 정규화하거나 길이 정책을 적용할 수도 있습니다. 따라서 성공했다고 클라이언트의 임시 값을 그대로 확정하지 않고, 서버가 반환한 결과를 반영합니다.

충돌은 실패 원인을 구분해서 반환합니다

다른 탭이 먼저 제목을 바꿨다면 다음과 같은 응답을 받을 수 있습니다.

HTTP/1.1 412 Precondition Failed
Content-Type: application/json
{
  “operationId”: “op_a7”,
  “status”: “rejected”,
  “code”: “VERSION_CONFLICT”,
  “currentETag”: “meta-c_1-42”,
  “current”: {
    “id”: “c_1”,
    “revision”: 42,
    “fields”: {
      “title”: “팀 회의”,
      “pinned”: false,
      “archived”: false
    }
  }
}

이 응답의 현재 데이터도 해당 사용자에게 읽기 권한이 있을 때만 제공합니다.

이제 클라이언트는 자신이 읽었던 제목, 자신이 작성한 제목, 서버의 현재 제목을 비교할 수 있습니다.

단순히 저장에 실패했다는 토스트만 띄우는 것보다 충돌을 해결할 정보가 많아집니다.

결과를 받지 못했을 때 작업을 조회합니다

GET /api/operations/op_a7 HTTP/1.1
Accept: application/json
{
  “operationId”: “op_a7”,
  “status”: “committed”,
  “entityId”: “c_1”,
  “resultRevision”: 42
}

현재 제목이 자신이 요청한 값과 같다는 사실만으로 자신의 작업이 성공했다고 판단하지 않습니다. 다른 탭이 같은 값을 저장했을 수도 있기 때문입니다.

작업 결과는 작업 식별자로 확인합니다.

아직 결과 기록을 찾지 못했다는 응답도 실패의 증거는 아닙니다. 원래 요청이 이동 중이거나 트랜잭션이 끝나지 않았을 수 있습니다.

이 경우에는 동일한 작업 식별자와 동일한 요청 내용으로 다시 확인하거나 재전송합니다.

멱등성 키와 버전 조건은 서로 다른 질문에 답합니다

멱등성 키는 이 작업을 이미 처리했는지 확인합니다.

버전 조건은 이 작업을 지금의 데이터에 적용해도 되는지 확인합니다.

같은 키로 재시도한 요청은 기존 처리 결과를 찾아야 합니다. 반면 새로운 요청이 오래된 데이터 버전을 기준으로 했다면 충돌 여부를 검사해야 합니다.

서버에서 같은 키의 처리 결과를 저장하고 요청 내용이 달라졌는지도 검사하는 방식은 실제 멱등성 API에서도 사용됩니다. 다만 보관 기간과 어떤 결과를 기록하는지는 서비스별 계약으로 정해야 합니다.

이 설계에서는 요청 본문뿐 아니라 대상 리소스와 버전 조건도 작업의 내용에 포함합니다.

충돌 이후 새로운 서버 버전을 기준으로 다시 제출한다면 새로운 작업 식별자를 사용합니다. 이미 전송한 키의 의미를 나중에 바꾸지 않습니다.

High-Level Design

브라우저에서는 화면 반영과 서버 전송을 분리합니다.

This post is for paid subscribers

Already a paid subscriber? Sign in
© 2026 실리콘밸리_생존자 · Privacy ∙ Terms ∙ Collection notice
Start your SubstackGet the app
Substack is the home for great culture