← 문제로

16. 단일 JSON 컬럼 부분 갱신의 read-modify-write 유실 (C#)

난이도 상
내 리뷰 · C#
해설 · C#

해설 — 단일 JSON 컬럼 부분 갱신의 read-modify-write 유실 (C#)

난이도: 상

요약

하나의 JSON 컬럼 전체를 읽어(A) 애플리케이션에서 한 필드만 고치고(B) JSON 전체를 다시 쓴다(C). 이 read-modify-write는 원자적이지 않아, (A)와 (C) 사이에 다른 요청(다른 하위 필드를 갱신한)이 커밋하면 그 변경이 통째로 덮어써져 사라진다(lost update). 필드가 논리적으로 겹치지 않아도, 물리적으로 같은 컬럼을 통째로 덮어쓰기 때문에 유실된다.

문제점

  • [영속화/lost update] 전체 컬럼 blind overwrite. 요청1(gold 차감)과 요청2(quests 갱신)가 거의 동시에 (A)에서 같은 스냅샷을 읽으면, 각자 자기 필드만 바꾼 "전체 JSON"을 만든다. 나중에 커밋한 쪽이 먼저 커밋한 쪽의 필드를 옛 값으로 되돌려 쓴다. 서로 다른 필드인데도 한쪽 갱신이 사라진다.
  • [원자성 부재] 트랜잭션/락/버전 없음. SELECT와 UPDATE가 별개 왕복이고 아무 동시성 제어가 없다. 같은 필드(gold)를 동시에 차감하면 고전적 갱신 유실로 골드가 덜 빠진다(재화 복사).
  • [정합성] 문서 전체 재작성의 비용·위험. 큰 JSON을 매번 통째로 직렬화/역직렬화하고 덮어쓰므로 느리고, 스키마가 커질수록 충돌 창(window)이 넓어진다.

근본 원인

서로 독립적인 데이터를 하나의 물리 컬럼에 뭉쳐두고, 부분 갱신을 "전체 읽기 → 전체 쓰기"로 구현했다. 동시성 제어(원자 갱신·낙관적 버전·행 잠금) 중 무엇도 없다.

수정안 (택1 또는 조합)

1) DB의 JSON 부분 갱신 함수로 서버에서 원자적으로 수정 — 읽기-쓰기 왕복을 없앤다.

-- PostgreSQL (jsonb)
UPDATE characters
   SET progress = jsonb_set(progress, '{gold}',
        to_jsonb( (progress->>'gold')::int - @amount ))
 WHERE char_id = @id
   AND (progress->>'gold')::int >= @amount;   -- 음수 방지까지 원자적으로

DB가 행을 잠근 채 자기 필드만 바꾸므로 다른 필드 갱신과 충돌하지 않고, 갱신 유실도 없다.

2) 컬럼 분해(정규화). 자주·독립적으로 갱신되는 값(gold, lastZone 등)은 별도 컬럼/테이블로 빼서 UPDATE ... SET gold = gold - @amount WHERE gold >= @amount 같은 원자 갱신을 쓴다. JSON blob은 진짜로 함께 바뀌는 응집된 데이터에만 남긴다.

3) 낙관적 동시성(버전 컬럼). blob을 통째로 써야 한다면 version을 두고 UPDATE ... SET progress=@p, version=version+1 WHERE char_id=@id AND version=@readVersion. 영향 행이 0이면 충돌 → 다시 읽어 재시도(반드시 stale 값 버리고 재계산).

더 나은 설계

  • "함께 변하지 않는 것을 한 컬럼에 담지 말라." 갱신 단위가 다른 데이터는 물리적으로 분리한다.
  • 재화처럼 정확성이 중요한 값은 절대 blob RMW로 다루지 말고 원자 UPDATE(또는 원장/ledger)로.
  • JSON을 쓰더라도 DB의 부분 갱신(jsonb_set / JSON_SET) + 조건절로 원자성을 확보한다.

면접 포인트

  • 서로 다른 필드를 갱신하는데도 왜 유실이 나나 — 물리적으로 같은 컬럼을 전체 덮어쓰기 때문.
  • jsonb_set/원자 UPDATE vs 낙관적 버전 vs 컬럼 분해의 트레이드오프.
  • "read-modify-write를 서버(DB) 쪽으로 밀어넣기"가 왜 동시성에 유리한가.