본문 바로가기

동시 저장과 복구

두 요청이 같은 상태를 읽고 저장할 때 기록이 사라지는 과정을 단계별로 재현하고, 버전을 비교해 저장하는 방식과 다시 읽어 판단하는 재시도로 이를 막습니다. 백업을 해시와 형식, 불변 조건의 순서로 검사해 되살리는 최소 모델을 실행하고, 이 모델이 확인하지 못하는 운영상의 조건을 정리합니다.

아키텍처와 관점에서 두 신청을 동시에 처리했더니, 두 사람 모두 REGISTERED를 받았는데 저장된 명단에는 한 사람만 남았습니다. 이 글에서는 그 일이 어떤 순서로 일어나는지 단계별로 따라가고, 버전을 비교해 저장하는 방법으로 이를 막아 봅니다. 이어서 백업을 되살릴 때 무엇을 어떤 순서로 검사해야 하는지 실행해 봅니다. 모든 예제는 외부 패키지나 실제 파일 없이, Node.js 프로세스 하나의 메모리 안에서 동작하는 최소 모델입니다.

복사와 await만으로는 충돌을 막지 못한다

정원이 1이고 명단이 빈 모임에 min과 hana가 동시에 신청한다고 해 봅시다. 두 요청이 모두 빈 명단을 읽으면 각자 자기 아이디만 넣은 새 명단을 계산합니다. 저장소가 받은 상태를 그대로 덮어쓴다면, 두 사람은 모두 성공했다는 응답을 받지만 명단에는 나중에 저장한 한 사람만 남습니다. 정원을 1로 정해 둔 것만으로는 "성공 응답을 받은 사람과 최종 명단이 일치한다"는 약속을 지킬 수 없습니다.

서로 독립적인 읽기와 저장으로 성공 기록이 사라진다

빈 상태에서 자신의 새 상태를 계산한다. 낡은 next가 min의 기록을 잃게 한다. 각 단계의 값과 전환 이유를 비교합니다.

SOFTWARE ENGINEERING · READING CLUB

서로 독립적인 읽기와 저장이 성공 기록을 잃는다

공유 코어가 올바른 상태를 만들더라도 저장 계약이 교차한 요청의 결과를 결정합니다.

서로 독립적인 읽기와 저장이 성공 기록을 잃는다시간은 위에서 아래로 흐릅니다. min과 hana는 모두 빈 상태를 읽습니다. min 저장 성공 뒤 hana가 낡은 읽기에서 만든 상태를 덮어써 최종 hana만 남습니다. 두 응답은 성공이어도 min의 기록은 사라집니다. loadv0 []loadv0 []save minOKsave hanaOKmin독립 요청저장소공유 상태hana독립 요청최종 [hana] · min 소실
서로 독립적인 읽기와 저장이 성공 기록을 잃는다시간은 위에서 아래로 흐릅니다. min과 hana는 모두 빈 상태를 읽습니다. min 저장 성공 뒤 hana가 낡은 읽기에서 만든 상태를 덮어써 최종 hana만 남습니다. 두 응답은 성공이어도 min의 기록은 사라집니다. loadv0 []loadv0 []save minOKsave hanaOKmin독립 요청저장소공유 상태hana독립 요청최종 [hana] · min 소실

실선은 호출, 점선 화살표는 응답, 수직 점선은 참여자의 생명선입니다. 위치는 책임, 위아래는 이 한 실행의 시간 순서입니다. 여기서는 버전 조건 없는 save를 사용합니다. CAS 계약의 대조 사례는 다음 그림입니다. 이 그림은 멀티프로세스 저장소 구현이나 실제 디스크 내구성을 보장하지 않습니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 min 읽기부터 hana 덮어쓰기까지 진행합니다.
관계도의 보충 설명
전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목서로 독립적인 읽기와 저장으로 성공 기록이 사라진다
예의 값과 변화
관찰·상황값·보충 정보변화·이유
min 읽기

빈 상태에서 자신의 새 상태를 계산한다.

  • 읽은 값·저장값 1: shared []
  • 읽은 값·저장값 2: min next [min]
  • min load 완료
hana 읽기

min 저장 전에 같은 빈 상태를 읽는다.

  • 읽은 값·저장값 1: hana next [hana]
  • 읽은 값·저장값 2: shared []
  • hana load 완료
min 저장

조건 없는 save로 min을 저장한다.

  • 읽은 값·저장값 1: shared [min]
  • 읽은 값·저장값 2: min REGISTERED
  • 첫 save 완료
hana 덮어쓰기

낡은 next가 min의 기록을 잃게 한다.

  • 읽은 값·저장값 1: shared [hana]
  • 읽은 값·저장값 2: hana REGISTERED
  • 읽은 값·저장값 3: min 소실
  • 두 성공과 최종 기록 불일치

서로 독립적인 읽기와 저장으로 성공 기록이 사라진다

빈 상태에서 자신의 새 상태를 계산한다. 낡은 next가 min의 기록을 잃게 한다. 각 단계의 값과 전환 이유를 비교합니다.

SOFTWARE ENGINEERING · READING CLUB

서로 독립적인 읽기와 저장이 성공 기록을 잃는다

공유 코어가 올바른 상태를 만들더라도 저장 계약이 교차한 요청의 결과를 결정합니다.

서로 독립적인 읽기와 저장이 성공 기록을 잃는다시간은 위에서 아래로 흐릅니다. min과 hana는 모두 빈 상태를 읽습니다. min 저장 성공 뒤 hana가 낡은 읽기에서 만든 상태를 덮어써 최종 hana만 남습니다. 두 응답은 성공이어도 min의 기록은 사라집니다. loadv0 []loadv0 []save minOKsave hanaOKmin독립 요청저장소공유 상태hana독립 요청최종 [hana] · min 소실
서로 독립적인 읽기와 저장이 성공 기록을 잃는다시간은 위에서 아래로 흐릅니다. min과 hana는 모두 빈 상태를 읽습니다. min 저장 성공 뒤 hana가 낡은 읽기에서 만든 상태를 덮어써 최종 hana만 남습니다. 두 응답은 성공이어도 min의 기록은 사라집니다. loadv0 []loadv0 []save minOKsave hanaOKmin독립 요청저장소공유 상태hana독립 요청최종 [hana] · min 소실

실선은 호출, 점선 화살표는 응답, 수직 점선은 참여자의 생명선입니다. 위치는 책임, 위아래는 이 한 실행의 시간 순서입니다. 여기서는 버전 조건 없는 save를 사용합니다. CAS 계약의 대조 사례는 다음 그림입니다. 이 그림은 멀티프로세스 저장소 구현이나 실제 디스크 내구성을 보장하지 않습니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 min 읽기부터 hana 덮어쓰기까지 진행합니다.

관계도의 보충 설명

전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목서로 독립적인 읽기와 저장으로 성공 기록이 사라진다
예의 값과 변화
관찰·상황값·보충 정보변화·이유
min 읽기

빈 상태에서 자신의 새 상태를 계산한다.

  • 읽은 값·저장값 1: shared []
  • 읽은 값·저장값 2: min next [min]
  • min load 완료
hana 읽기

min 저장 전에 같은 빈 상태를 읽는다.

  • 읽은 값·저장값 1: hana next [hana]
  • 읽은 값·저장값 2: shared []
  • hana load 완료
min 저장

조건 없는 save로 min을 저장한다.

  • 읽은 값·저장값 1: shared [min]
  • 읽은 값·저장값 2: min REGISTERED
  • 첫 save 완료
hana 덮어쓰기

낡은 next가 min의 기록을 잃게 한다.

  • 읽은 값·저장값 1: shared [hana]
  • 읽은 값·저장값 2: hana REGISTERED
  • 읽은 값·저장값 3: min 소실
  • 두 성공과 최종 기록 불일치
순서요청과 저장소에서 일어나는 일결과
1min이 빈 명단을 읽는다새 명단 [min]을 계산한다
2hana도 min이 저장하기 전의 빈 명단을 읽는다새 명단 [hana]를 계산한다
3min이 저장한다명단 [min], 성공
4hana가 낡은 읽기를 바탕으로 저장한다명단 [hana], 성공, min의 신청이 사라짐

이처럼 다른 요청이 그 사이에 바꾼 내용을 모른 채 낡은 값을 바탕으로 덮어써 앞의 변경이 사라지는 문제를 갱신 손실(lost update)이라고 합니다. 이런 엇갈림이 모든 실행에서 언제나 일어나는 것은 아니지만, 실행 순서를 조절해 이런 반례가 일어날 수 있음을 보이면 설계의 빈틈을 찾을 수 있습니다. Promise.all은 여러 호출이 모두 끝나기를 기다릴 뿐, 요청을 하나씩 차례로 저장하게 잠가 주지는 않습니다.

버전을 비교하고 같은 단계에서 저장한다

이 문제를 막는 방법 가운데 하나는 저장된 상태에 버전 번호를 붙이는 것입니다. load()는 상태와 함께 지금의 버전을 돌려주고, compareAndSet(expectedVersion, next)는 읽을 때의 버전이 아직 지금의 버전과 같을 때만 저장하고 버전을 1 올립니다. 그 사이에 누군가 먼저 저장해 버전이 달라졌다면 저장하지 않고 충돌(conflict)을 알립니다. 이렇게 비교하고 바꾸는 일을 한 번에 처리하는 연산을 비교 후 교체(compare-and-set)라고 합니다.

이때 버전을 비교하는 일과 저장하는 일은 그 사이에 다른 요청이 끼어들 수 없는 하나의 단계로 이루어져야 하며, 이런 성질을 원자성(atomicity)이라고 합니다. 아래 예제의 compareAndSet은 비교와 저장 사이에 await가 없으므로, 이 프로세스 하나 안에서는 다른 호출이 그 사이에 끼어들지 못합니다. 하지만 원격 저장소에서 상태를 읽어 와 클라이언트에서 버전을 비교한 뒤 보통의 저장을 부르면, 비교와 저장 사이에 다른 요청이 끼어들 수 있어 같은 효과를 얻지 못합니다. 그런 환경에서는 격리 수준과 잠금에서 다루는 조건부 UPDATE처럼, 데이터베이스나 저장 서비스가 직접 제공하는 조건부 갱신이나 트랜잭션을 써야 합니다.

버전 계약이 두 번째 낡은 저장을 거절한다

두 요청이 같은 빈 상태를 읽었다. 최신 상태로 업무 규칙을 다시 판정한다. 각 단계의 값과 전환 이유를 비교합니다.

SOFTWARE ENGINEERING · READING CLUB

버전 계약이 두 번째 낡은 저장을 거절한다

공유 코어가 올바른 상태를 만들더라도 저장 계약이 교차한 요청의 결과를 결정합니다.

버전 계약이 두 번째 낡은 저장을 거절한다시간은 위에서 아래로 흐릅니다. min과 hana는 모두 빈 상태를 읽습니다. min의 버전0 저장만 버전1로 반영되고 hana의 버전0 저장은 충돌로 거절됩니다. 이후 재조회와 FULL 판단은 본문 표에서 이어집니다. loadv0 []loadv0 []CAS v0,mincommit v1CAS v0,hanaCONFLICTmin독립 요청저장소공유 상태hana독립 요청최종 v1 [min]
버전 계약이 두 번째 낡은 저장을 거절한다시간은 위에서 아래로 흐릅니다. min과 hana는 모두 빈 상태를 읽습니다. min의 버전0 저장만 버전1로 반영되고 hana의 버전0 저장은 충돌로 거절됩니다. 이후 재조회와 FULL 판단은 본문 표에서 이어집니다. loadv0 []loadv0 []CAS v0,mincommit v1CAS v0,hanaCONFLICTmin독립 요청저장소공유 상태hana독립 요청최종 v1 [min]

실선은 호출, 점선 화살표는 응답, 수직 점선은 참여자의 생명선입니다. 위치는 책임, 위아래는 이 한 실행의 시간 순서입니다. CAS는 기대 버전 확인과 저장을 하나로 수행합니다. 이 그림은 멀티프로세스 저장소 구현이나 실제 디스크 내구성을 보장하지 않습니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 같은 버전 읽기부터 재조회 후 FULL까지 진행합니다.
관계도의 보충 설명
전체 도표의 표기·관계·조건
구분전체 도표의 내용
관계 설명시간은 위에서 아래로 흐릅니다. min과 hana는 모두 빈 상태를 읽습니다. min의 버전 0 저장만 버전 1로 반영되고 hana의 버전 0 저장은 충돌로 거절됩니다. 이후 재조회와 FULL 판단은 본문 표에서 이어집니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
같은 버전 읽기

두 요청이 같은 빈 상태를 읽었다.

  • 버전·신청자 1: version0
  • 버전·신청자 2: participants []
  • 버전·신청자 3: min/hana expected0
  • 두 후보 상태 계산
min 커밋

비교와 저장을 한 원자적 동작으로 수행한다.

  • 버전·신청자 1: expected0=current0
  • 버전·신청자 2: participants [min]
  • 버전·신청자 3: version1
  • min REGISTERED
hana 충돌

오래된 버전의 쓰기를 거절한다.

  • 버전·신청자 1: expected0≠current1
  • 버전·신청자 2: CONFLICT
  • 버전·신청자 3: [min] 유지
  • hana 저장 없음
재조회 후 FULL

최신 상태로 업무 규칙을 다시 판정한다.

  • 버전·신청자 1: version1
  • 버전·신청자 2: capacity1
  • 버전·신청자 3: FULL
  • hana 새 저장 없음; 최종 [min],v1

버전 계약이 두 번째 낡은 저장을 거절한다

두 요청이 같은 빈 상태를 읽었다. 최신 상태로 업무 규칙을 다시 판정한다. 각 단계의 값과 전환 이유를 비교합니다.

SOFTWARE ENGINEERING · READING CLUB

버전 계약이 두 번째 낡은 저장을 거절한다

공유 코어가 올바른 상태를 만들더라도 저장 계약이 교차한 요청의 결과를 결정합니다.

버전 계약이 두 번째 낡은 저장을 거절한다시간은 위에서 아래로 흐릅니다. min과 hana는 모두 빈 상태를 읽습니다. min의 버전0 저장만 버전1로 반영되고 hana의 버전0 저장은 충돌로 거절됩니다. 이후 재조회와 FULL 판단은 본문 표에서 이어집니다. loadv0 []loadv0 []CAS v0,mincommit v1CAS v0,hanaCONFLICTmin독립 요청저장소공유 상태hana독립 요청최종 v1 [min]
버전 계약이 두 번째 낡은 저장을 거절한다시간은 위에서 아래로 흐릅니다. min과 hana는 모두 빈 상태를 읽습니다. min의 버전0 저장만 버전1로 반영되고 hana의 버전0 저장은 충돌로 거절됩니다. 이후 재조회와 FULL 판단은 본문 표에서 이어집니다. loadv0 []loadv0 []CAS v0,mincommit v1CAS v0,hanaCONFLICTmin독립 요청저장소공유 상태hana독립 요청최종 v1 [min]

실선은 호출, 점선 화살표는 응답, 수직 점선은 참여자의 생명선입니다. 위치는 책임, 위아래는 이 한 실행의 시간 순서입니다. CAS는 기대 버전 확인과 저장을 하나로 수행합니다. 이 그림은 멀티프로세스 저장소 구현이나 실제 디스크 내구성을 보장하지 않습니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 같은 버전 읽기부터 재조회 후 FULL까지 진행합니다.

관계도의 보충 설명

전체 도표의 표기·관계·조건
구분전체 도표의 내용
관계 설명시간은 위에서 아래로 흐릅니다. min과 hana는 모두 빈 상태를 읽습니다. min의 버전 0 저장만 버전 1로 반영되고 hana의 버전 0 저장은 충돌로 거절됩니다. 이후 재조회와 FULL 판단은 본문 표에서 이어집니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
같은 버전 읽기

두 요청이 같은 빈 상태를 읽었다.

  • 버전·신청자 1: version0
  • 버전·신청자 2: participants []
  • 버전·신청자 3: min/hana expected0
  • 두 후보 상태 계산
min 커밋

비교와 저장을 한 원자적 동작으로 수행한다.

  • 버전·신청자 1: expected0=current0
  • 버전·신청자 2: participants [min]
  • 버전·신청자 3: version1
  • min REGISTERED
hana 충돌

오래된 버전의 쓰기를 거절한다.

  • 버전·신청자 1: expected0≠current1
  • 버전·신청자 2: CONFLICT
  • 버전·신청자 3: [min] 유지
  • hana 저장 없음
재조회 후 FULL

최신 상태로 업무 규칙을 다시 판정한다.

  • 버전·신청자 1: version1
  • 버전·신청자 2: capacity1
  • 버전·신청자 3: FULL
  • hana 새 저장 없음; 최종 [min],v1
순서기대한 버전저장소의 버전결과
min과 hana가 읽는다둘 다 00둘 다 신청할 수 있다고 계산한다
min이 조건부로 저장한다00저장, 버전 1, 명단 [min]
hana가 조건부로 저장한다01충돌, 명단은 그대로
hana가 다시 읽고 규칙을 다시 판단한다11정원 1이 이미 차서 FULL, 저장하지 않음

충돌이 났을 때 처음에 계산한 새 명단 [hana]를 그대로 다시 저장하면 min을 지우게 됩니다. 그래서 충돌하면 최신 상태를 다시 읽고, 요구의 우선순위대로 다시 판단한 뒤, 새 버전을 기준으로 저장해야 합니다. 다시 시도하는 횟수에도 상한을 두어, 아래 구현은 최대 세 번까지 시도하고 그래도 충돌하면 CONFLICT를 호출한 쪽에 돌려줍니다. 마지막 표의 FULL은 저장의 오류가 아니라, 최신 상태를 읽은 뒤에 내린 정상적인 업무 거절입니다.

더 깊이: HTTP API에서 같은 일을 하는 조건부 요청

이 버전 비교는 웹 API에서도 그대로 쓰입니다. HTTP에서는 서버가 응답에 그 자원의 현재 버전을 나타내는 ETag를 붙이고, 클라이언트는 고친 내용을 보낼 때 If-Match 헤더에 읽을 때 받은 ETag를 담습니다. RFC 9110은 If-Match가 여러 클라이언트가 같은 자원을 동시에 고칠 때의 갱신 손실을 막으려고 주로 PUT, POST, DELETE 같은 요청과 함께 쓰인다고 설명하고, 서버가 요청을 처리하기 전에 이 조건부터 평가하도록 정합니다. 조건이 거짓, 곧 그 사이에 다른 요청이 자원을 바꾸어 버전이 달라졌다면 서버는 요청한 메서드를 실행하지 않으며, 보통 412 Precondition Failed로 조건이 맞지 않았음을 알립니다.

412는 이 글의 compareAndSet이 돌려주는 충돌과 같은 뜻이므로, 클라이언트는 자원을 다시 읽어 최신 상태에서 다시 판단해야 합니다. 비교에는 강한 비교(strong comparison)를 써야 해서, 내용이 의미상 같다는 약한 ETag로는 이 조건을 만족시킬 수 없습니다. 클라이언트가 조건 없이 덮어쓰는 것 자체를 막고 싶다면, 서버는 RFC 6585의 428 Precondition Required로 조건부 요청만 받겠다고 알릴 수 있습니다.

두 읽기가 먼저 끝나도록 재현한다

엇갈림을 우연에 맡기면 실행할 때마다 결과가 달라질 수 있으므로, 처음 두 번의 읽기가 모두 끝날 때까지 두 요청을 기다리게 하는 장치를 둡니다. 다음 코드의 barrierForFirstTwoLoads는 저장소의 load를 감싸, 두 번째 읽기가 끝나는 순간 기다리던 두 요청을 함께 풀어 줍니다. 그 뒤의 다시 읽기에는 기다림을 두지 않습니다. reading-lab/examples/ch7-4-race.mjs로 저장해 실행합니다.

import { createSession, register, restoreSession } from "../core.mjs";

function createStore(initial, trace) {
  let version = 0;
  let state = initial;
  return {
    async load() {
      trace.push(`load v${version}`);
      return { version, state };
    },
    async save(next) {
      state = restoreSession(next);
      version += 1;
      trace.push(`save v${version} [${state.participants}]`);
    },
    async compareAndSet(expectedVersion, next) {
      if (expectedVersion !== version) {
        trace.push(`conflict v${expectedVersion}/v${version}`);
        return { ok: false };
      }
      state = restoreSession(next);
      version += 1;
      trace.push(`commit v${version} [${state.participants}]`);
      return { ok: true };
    },
    inspect() {
      return { version, state };
    },
  };
}

function barrierForFirstTwoLoads(store) {
  let reads = 0;
  let release;
  const bothRead = new Promise((resolve) => {
    release = resolve;
  });
  return {
    ...store,
    async load() {
      const snapshot = await store.load();
      reads += 1;
      if (reads <= 2) {
        if (reads === 2) release();
        await bothRead;
      }
      return snapshot;
    },
  };
}

async function reserveWithoutVersion(store, memberId) {
  const { state } = await store.load();
  const result = register(state, memberId);
  if (result.ok) await store.save(result.state);
  return result.code;
}

async function reserveWithVersion(store, memberId, attempts = 3) {
  for (let attempt = 0; attempt < attempts; attempt += 1) {
    const { version, state } = await store.load();
    const result = register(state, memberId);
    if (!result.ok) return result.code;
    const write = await store.compareAndSet(version, result.state);
    if (write.ok) return result.code;
  }
  return "CONFLICT";
}

for (const [label, reserve] of [["버전 없이 저장", reserveWithoutVersion], ["버전을 비교해 저장", reserveWithVersion]]) {
  const trace = [];
  const store = createStore(createSession({ id: "reading", title: "가상 모임", capacity: 1 }), trace);
  const guarded = barrierForFirstTwoLoads(store);
  const codes = await Promise.all([reserve(guarded, "min"), reserve(guarded, "hana")]);
  console.log(`${label}: ${codes.join(",")}`);
  console.log(`  ${trace.join(" | ")}`);
  console.log(`  저장된 명단: ${store.inspect().state.participants.join(",")}`);
}
버전 없이 저장: REGISTERED,REGISTERED
  load v0 | load v0 | save v1 [min] | save v2 [hana]
  저장된 명단: hana
버전을 비교해 저장: REGISTERED,FULL
  load v0 | load v0 | commit v1 [min] | conflict v0/v1 | load v1
  저장된 명단: min

`load v${version}`처럼 백틱으로 감싼 문자열은 비기능 요구와 변경 추적에서 본 템플릿 리터럴로, ${} 안의 값을 문자열에 끼워 넣습니다. trace에는 저장소에서 일어난 일이 차례로 기록됩니다. 버전 없이 저장하면 두 요청이 모두 버전 0을 읽은 뒤 차례로 덮어써, 두 사람 모두 성공했는데 명단에는 hana만 남습니다. 이 결과는 설계의 빈틈을 보이려고 일부러 만든 반례입니다.

버전을 비교해 저장하면 min이 먼저 버전 1로 저장하고, 버전 0을 기대한 hana의 저장은 충돌로 거절됩니다. hana는 다시 읽어 버전 1의 상태에서 규칙을 다시 판단하고, 정원이 찼으므로 FULL을 받습니다. 성공 응답을 받은 사람과 저장된 명단이 이제 일치합니다. 다만 이 실행에서는 min의 요청을 먼저 시작했기 때문에 min이 먼저 저장했을 뿐, 실제 운영에서 먼저 신청한 사람이 언제나 이긴다는 공정성까지 약속하는 것은 아닙니다. 또 이 예제는 신청만 다루므로, 취소와 마감도 같은 방식으로 저장해야 업무 전체의 일관성을 말할 수 있습니다.

동시성과 복구는 따로 확인한다

메모리 안에서 버전을 비교했다고 해서 전원이 꺼져도 데이터가 남는 것은 아닙니다. 데이터를 잃지 않으려면 백업을 남기고, 되살릴 때 그 백업이 믿을 만한지 검사해야 합니다. 릴리스·복구·운영 피드백의 백업 실험은 해시 값의 길이가 64자인지, 곧 해시의 형식만 확인합니다. 여기서는 백업을 만들 때 계산해 둔 기준 해시와 되살릴 데이터로 새로 계산한 해시를 비교한 뒤, 저장 형식과 불변 조건까지 차례로 검사합니다.

복원 단계확인하는 것실패하면
기준 SHA-256 해시와 새로 계산한 해시 비교백업을 만든 뒤 내용이 바뀌지 않았는가거절
저장 형식의 버전과 정수 버전 검사이 프로그램이 읽을 수 있는 형식인가거절
restoreSession명단, 정원, 아이디의 불변 조건을 지키는가거절
검사를 통과한 결과 반환지금 상태를 대신할 후보가 되는가호출한 쪽이 확인한 뒤 사용

해시(hash)는 임의의 데이터에서 정해진 길이의 값을 계산하는 함수의 결과로, 데이터가 조금만 바뀌어도 전혀 다른 값이 나옵니다. SHA-256은 256비트, 곧 16진수 64자의 해시를 만듭니다. 다음 코드를 reading-lab/examples/ch7-4-snapshot.mjs로 저장해 실행합니다.

import { createHash } from "node:crypto";
import { createSession, register, restoreSession } from "../core.mjs";

function sha256(text) {
  return createHash("sha256").update(text).digest("hex");
}

function encodeSnapshot(version, state) {
  const encoded = JSON.stringify({ schemaVersion: 1, version, session: state });
  return { encoded, hash: sha256(encoded) };
}

function restoreSnapshot(encoded, expectedHash) {
  if (sha256(encoded) !== expectedHash) throw new Error("snapshot checksum mismatch");
  const record = JSON.parse(encoded);
  if (record.schemaVersion !== 1 || !Number.isSafeInteger(record.version) || record.version < 0) {
    throw new Error("unsupported snapshot");
  }
  return { version: record.version, state: restoreSession(record.session) };
}

function attempt(label, encoded, hash) {
  try {
    const restored = restoreSnapshot(encoded, hash);
    console.log(`${label} → 복원됨: ${restored.state.participants.join(",")}`);
  } catch (error) {
    console.log(`${label} → ${error.message}`);
  }
}

const state = register(createSession({ id: "reading", title: "가상 모임", capacity: 1 }), "min").state;
const backup = encodeSnapshot(1, state);
attempt("정상 백업", backup.encoded, backup.hash);

const tampered = JSON.parse(backup.encoded);
tampered.session.participants = ["hana"];
attempt("명단만 바꾼 백업", JSON.stringify(tampered), backup.hash);

const future = JSON.stringify({ ...JSON.parse(backup.encoded), schemaVersion: 2 });
attempt("형식 버전 2", future, sha256(future));

const duplicated = JSON.stringify({ schemaVersion: 1, version: 1, session: { ...state, participants: ["min", "min"] } });
attempt("중복 아이디", duplicated, sha256(duplicated));
정상 백업 → 복원됨: min
명단만 바꾼 백업 → snapshot checksum mismatch
형식 버전 2 → unsupported snapshot
중복 아이디 → stored participants violate the session rules

정상 백업은 min이 든 명단으로 되살아났습니다. 명단만 hana로 바꾼 백업은 JSON의 문법은 맞지만 기준 해시와 달라 첫 단계에서 거절되었습니다. 나머지 두 백업은 일부러 해시를 새로 계산해 붙였으므로 첫 단계는 통과했지만, 형식 버전이 다른 백업은 둘째 단계에서, 중복 아이디가 든 백업은 셋째 단계의 restoreSession에서 거절되었습니다. 해시가 맞는다는 것은 내용이 바뀌지 않았다는 뜻일 뿐 그 내용이 올바르다는 뜻은 아니므로, 단계마다 다른 것을 확인해야 합니다.

이 모델에도 한계가 있습니다. 백업 데이터와 기준 해시를 같은 곳에 두면, 둘을 함께 바꾼 공격자는 해시 비교를 통과합니다. 출처까지 확인하려면 기준 해시를 믿을 수 있는 다른 곳에 두거나, 암호화의 기초에서 다루는 것처럼 비밀 키로 태그를 만드는 HMAC이나 개인 키로 서명하는 전자 서명을 써야 합니다. 또 디스크에 남기는 내구성을 다루려면 쓰기가 끝났는지, 운영체제의 버퍼를 디스크로 내보냈는지, 파일을 교체할 때 폴더의 정보까지 기록되었는지, 장애가 난 뒤 어떤 순서로 되살릴지를 실행 환경에 맞게 정해야 합니다. 운영체제가 쓴 데이터를 언제 디스크로 내보내는지는 파일 시스템 실무에서 다룹니다. Node.js의 writeFile 문서도 기본적으로 디스크로 내보내지 않는다는 점과 같은 파일에 여러 번 쓸 때의 주의점을 설명합니다. 이 글의 메모리 실험을 실제 전원 장애를 견디는지에 대한 시험으로 여겨서는 안 됩니다.

정리

  • 두 요청이 같은 상태를 읽은 뒤 차례로 덮어쓰면, 둘 다 성공 응답을 받고도 한쪽의 변경이 사라지는 갱신 손실이 생깁니다.
  • 비교 후 교체는 읽을 때의 버전이 그대로일 때만 저장하며, 비교와 저장은 다른 요청이 끼어들 수 없는 원자적 단계여야 합니다.
  • 충돌하면 처음 계산한 상태를 다시 저장하지 않고, 최신 상태를 다시 읽어 규칙을 다시 판단하며, 재시도에는 상한을 둡니다.
  • 엇갈림을 재현할 때는 우연한 지연에 기대지 않고, 기다림 장치로 실행 순서를 정해 둡니다.
  • 백업은 해시, 형식, 불변 조건의 순서로 검사해 되살리며, 출처의 인증과 디스크의 내구성은 이 모델과 별도로 확인합니다.

연습 문제

1. 충돌을 성공으로 바꾸자는 제안

팀원 한 명이 "충돌이 나면 처음 계산한 새 상태를 보통의 저장으로 저장하고 성공을 돌려주자"고 제안했습니다. 정원 1인 모임에 두 요청이 들어온 경우를 들어 이 제안의 문제를 설명하고, 올바른 처리를 적으세요.

해설 보기

처음 계산한 새 상태는 낡은 읽기를 바탕으로 하므로, 그 사이에 저장된 다른 참가자를 포함하지 않습니다. hana의 충돌 뒤에 [hana]를 그대로 저장하면 min의 신청이 사라지고, 두 사람 모두 성공 응답을 받는 갱신 손실이 다시 생깁니다. 충돌하면 최신 버전을 다시 읽고 요구의 우선순위대로 다시 판단해야 하며, 이 경우 hana는 FULL을 받아야 합니다. 그리고 재시도에 상한을 두어 계속 충돌하면 CONFLICT를 호출한 쪽에 돌려줍니다.

2. 재시도에 상한을 두는 이유

reserveWithVersion이 충돌할 때마다 끝없이 다시 시도하게 바꾸면 어떤 문제가 생길 수 있나요? 상한에 이르면 사용자에게 무엇을 안내해야 할까요?

해설 보기

신청이 몰려 저장이 계속 충돌하면, 한 요청이 끝없이 다시 읽고 다시 판단하며 응답을 돌려주지 못한 채 자원을 쓰게 됩니다. 여러 요청이 모두 이렇게 되면 시스템 전체가 느려질 수 있습니다. 상한에 이르면 신청이 처리되지 않았다는 것을 분명히 알리고, 잠시 뒤 다시 시도하라고 안내합니다. 이때 성공했는지 모르는 상태로 남기지 않도록, 이 응답이 "저장하지 않았음"을 뜻한다는 것을 인터페이스에 적어 둡니다.

3. 해시만으로 충분할까

백업 파일 옆에 그 파일의 SHA-256 해시를 적은 파일을 함께 두었습니다. 누군가 두 파일을 모두 고칠 수 있다면 본문의 restoreSnapshot은 바뀐 백업을 막을 수 있을까요?

해설 보기

막을 수 없습니다. 공격자가 명단을 바꾼 뒤 그 내용으로 해시를 새로 계산해 해시 파일까지 바꾸면, 비교하는 두 해시가 같아져 첫 단계를 통과합니다. 해시는 우연한 손상이나 실수로 바뀐 것을 찾는 데는 쓸모가 있지만, 누가 만들었는지를 확인해 주지는 않습니다. 기준 해시를 공격자가 고칠 수 없는 다른 곳에 보관하거나, 비밀 키가 있어야 계산할 수 있는 HMAC이나 개인 키로 서명하는 전자 서명을 써야 출처까지 확인할 수 있습니다.

이 장에서는 아키텍처의 관점부터 패턴, 저장 방식의 교체, 동시 저장과 복구까지 다루었습니다. 다음 장의 작업 분해와 역할에서는 이런 일을 실제로 계획하기 위해 작업을 나누고 맡을 사람을 정하는 방법을 다룹니다.


원자료와 더 읽기

이 글의 동시 저장, 조건부 저장, 기준 해시 비교는 원자료에 없는 내용을 한정된 모델로 보강해 새로 쓴 설명입니다. 입문용 core.mjs의 상태 고정 방식과 아이디 규칙은 바꾸지 않았습니다.