16. 단일 JSON 컬럼 부분 갱신의 read-modify-write 유실 (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) 쪽으로 밀어넣기"가 왜 동시성에 유리한가.
해설 — 단일 JSON(blob) 컬럼 부분 갱신의 read-modify-write 유실 (C++)
난이도: 상
요약
JSON 컬럼 전체를 읽어(A) 한 필드만 고치고(B) 문서 전체를 다시 쓴다(C). SELECT와 UPDATE가 별개 왕복이고 동시성 제어가 없어, 그 사이 다른 요청이 커밋한 변경(다른 하위 필드라도)이 통째로 덮어써져 사라진다(lost update). 같은 필드(gold)를 동시에 차감하면 재화가 덜 빠진다(복사).
문제점
- [영속화/lost update] 전체 컬럼 blind overwrite. 논리적으로 겹치지 않는 필드를 갱신해도 물리적으로 같은 컬럼을 통째로 덮어쓰므로 나중 커밋이 앞선 커밋을 무효화한다.
- [원자성 부재] 트랜잭션·행 잠금·버전 없음. (A)와 (C)가 원자적이지 않다. 동시 골드 차감이 고전적 갱신 유실을 일으킨다.
- [성능/충돌 창] 문서 전체 왕복. blob이 커질수록 매번 전체 파싱/직렬화 비용이 들고, (A)~(C) 사이 창이 넓어져 충돌 확률이 커진다.
근본 원인
독립적으로 갱신되는 데이터를 하나의 물리 컬럼에 뭉쳐두고, 부분 갱신을 클라이언트 측 read-modify-write로 구현했다. C++/C# 어느 쪽이든 결함의 본질은 동일하다(언어 무관, DB 동시성 제어의 문제).
수정안 (택1 또는 조합)
1) DB에서 원자적으로 부분 갱신 — 읽기-쓰기 왕복 제거.
-- PostgreSQL(jsonb): 행을 잠근 채 gold 만 갱신, 음수 방지까지 원자적으로
UPDATE characters
SET progress = jsonb_set(progress, '{gold}',
to_jsonb( (progress->>'gold')::int - ? ))
WHERE char_id = ?
AND (progress->>'gold')::int >= ?;
-- 영향 행이 0이면 잔액 부족으로 처리
2) 컬럼 분해. 재화처럼 정확성이 중요한 값은 blob에서 빼서 전용 컬럼으로:
UPDATE characters SET gold = gold - ? WHERE char_id = ? AND gold >= ?;
3) 낙관적 버전. blob 통째 쓰기가 불가피하면 버전 컬럼으로 CAS:
// 트랜잭션 안에서:
auto [raw, ver] = db_->QueryProgressAndVersion(charId);
Json doc = Json::Parse(raw); doc.SetInt("gold", doc.GetInt("gold") - amount);
int affected = db_->Exec(
"UPDATE characters SET progress=?, version=version+1 "
"WHERE char_id=? AND version=?", doc.Dump(), charId, ver);
if (affected == 0) throw ConflictRetry{}; // 다시 읽어 재계산(stale 값 폐기)
더 나은 설계
- "함께 변하지 않는 것을 한 컬럼에 담지 말라." 갱신 단위가 다른 데이터는 분리한다.
- 재화는 blob RMW 금지 — 원자 UPDATE 또는 원장(ledger) 테이블로.
- JSON을 쓰더라도 DB 부분 갱신(jsonb_set/JSON_SET) + 조건절로 원자성 확보.
- 재시도 경로에서는 반드시 최신 값을 다시 읽어 재계산한다(낡은 스냅샷으로 재시도 금지).
면접 포인트
- 서로 다른 필드를 갱신해도 유실되는 이유(물리 컬럼 전체 덮어쓰기).
- 원자 부분 갱신 vs 컬럼 분해 vs 낙관적 버전의 선택 기준.
- 재시도 시 stale 스냅샷 폐기가 왜 필수인가.