본문 바로가기
C.W.K.
Lesson 04 of 05 · published

화면은 둘, 선택은 하나

~13 min · 동시성, 낙관적 잠금, sqlite, ui

Level 0스케치
0 XP0/43 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

화면 둘에 세트 하나

아빠는 어디에 있든 후보를 리뷰해. Mac의 웹 스튜디오랑 iPhone, iPad 앱은 같은 WebUI야. 앱은 그걸 WebKit 안에 띄우고 같은 엔진이랑 얘기해. 아키텍처 문서도 분명히 적어 뒀어. 선택은 서버 거고, 낙관적 리비전이 붙고, 웹과 iOS가 저장된 선택을 같이 쓴다고. 그러니까 이런 경쟁 상황을 떠올려 봐. 배치 리뷰가 두 화면에 다 열려 있어. 아빠가 iPad에서 후보 하나를 골라. 조금 뒤에 한 시간째 페이지가 열려 있던 Mac으로 가서, 거기서 다른 후보를 골라.

쓸 때마다 움직이는 숫자

records의 모든 행엔 1부터 시작하는 version 컬럼이 있어. update는 새 body를 쓰는 바로 그 문장에서 이 숫자를 하나 올려. 클라이언트는 레코드를 읽을 때 버전도 같이 읽고, 바꾼 걸 보낼 때 그 숫자를 돌려보내. Store.update는 둘을 비교해. 저장된 버전이 클라이언트가 읽은 게 아니면, 그 사이에 누가 썼다는 뜻이야. 스토어는 409로 거절해. This record changed. Refresh before saving again. 레코드가 바뀌었으니 새로고침한 다음에 다시 저장하라는 말이야.

이게 낙관적 동시성이야. 아빠가 화면을 보는 동안엔 한 시간이 걸려도 잠금을 하나도 안 쥐어. 경쟁에서 지면 거절 한 번이 비용이고, 그 비용은 진 쓰기만 치러.

BEGIN IMMEDIATE가 중요한 이유

비교와 쓰기는 한 걸음이어야 해. Studio.io는 스토어 호출을 전부 asyncio.to_thread로 넘기니까, 선택 두 개가 스레드 두 개에 실려 같은 순간 스토어에 닿을 수 있어. update는 BEGIN IMMEDIATE로 시작해. 뭘 읽기도 전에 SQLite 쓰기 잠금부터 잡는 거야. 두 번째 스레드는 연결의 10초 타임아웃 안에서 기다렸다가, 첫 번째가 막 쓴 버전을 읽고 비교에서 떨어져.

이 줄을 빼면 가드에 구멍이 나. 파이썬 sqlite3 모듈은 기본 모드에서 쓰는 문장 앞에서만 트랜잭션을 열고, SELECT 앞에선 안 열어. 두 스레드가 다 버전 7을 읽고, 업데이트 둘이 다 들어갈 수 있어. UPDATE 자체는 id로만 행을 찾으니까.

고칠 수 있는 레코드엔 전부 버전이 붙어

아빠가 고치는 것라우트버전
세트의 선택PATCH /api/sets/{id}/selection필수
에셋 이름이나 보관 표시PATCH /api/assets/{id}필수
세트나 배치의 보관 표시PATCH /api/sets/{id}, PATCH /api/batches/{id}필수
레퍼런스 프로필, 워크플로 템플릿PUT /api/souls/{id}/references, PUT /api/templates/{workflow}처음 저장할 때만 생략

마지막 줄은 경쟁 상황을 하나 더 막아. 두 화면이 같은 소울의 첫 프로필을 동시에 만드는 경우야. 저장은 스튜디오의 intent 잠금을 잡고 이미 있는 프로필을 찾아. 버전 없는 요청은 아직 아무것도 저장 안 됐다는 주장이야. 그런데 뭔가 저장돼 있으면 그 주장이 틀린 거니까 Reference settings changed. Refresh first.로 거절해. 레퍼런스 설정이 바뀌었으니 먼저 새로고침하라는 말이야. 아무것도 없는데 버전을 들고 오면 그것도 똑같이 틀린 거라 같은 거절을 받아. 템플릿도 같은 규칙이야.

버전 없이 쓰는 경우도 있어. 워커가 자기 태스크 상태를 옮길 때, 그리고 발행이 끝날 때야. 두 번째는 열려 있는 모든 화면에 닿아. 발행이 끝나면 세트의 새 발행 맵과 라이브 리비전을 쓰고, 그때 세트 버전이 올라가. 그래서 옛 숫자를 쥔 화면은 다음 선택에서 409를 받고 다시 불러와.

서버는 자기 병합도 지켜

select는 세트를 읽고, 고른 후보를 하나씩 확인해. 맞는 소울인지, 생성된 후보라면 이 세트의 그 프레임에 끝난 태스크가 있는지. 그리고 새 선택을 기존 선택에 합쳐서 expected=body.version으로 써. 읽기와 쓰기 사이엔 피파 레지스트리로 가는 호출이 끼어 있어. 그 틈에 다른 쓰기가 들어오면 버전이 움직였으니, 옛 읽기 위에 쌓은 병합은 거절돼. 숫자 하나가 클라이언트가 본 화면도, 서버 자신이 읽고 합치고 쓰는 과정도 같이 지켜.

409를 받은 화면이 하는 일

Batches 페이지는 자기가 쥔 세트 버전을 보내. 호출이 실패하면 에러를 보여 주고, 메시지에 레코드가 바뀌었다는 말이 있으면 세트를 다시 불러와. 그래야 다음 클릭에 새 버전이 실려. 선택을 다시 보내진 않아. 난 이게 맞다고 봐. iPad에서 고른 게 아빠가 원한 것일 수도 있잖아. 덮어쓰기 전에 그걸 먼저 봐야지.

솔직한 약점도 하나 있어. 페이지는 충돌을 409 상태 코드가 아니라 메시지 속 changed라는 단어로 알아봐. 지금 이 경로의 충돌 메시지엔 그 단어가 들어 있어. 서버가 문장을 바꾸면 다시 불러오기가 소리 없이 멈출 거야. 상태 코드로 확인했다면 상관없었을 텐데.

선택이 뭘 뜻하는지, 왜 고르는 게 발행이 아닌지는 트랙 7의 첫 레슨에서 다뤄.

Code

cwkAvatars, backend/store.py: update, 잠금 하나 아래서 버전을 비교하고 써 (원문 그대로, 92-102행)·python
    def update(self, id: str, expected: int | None = None, **changes) -> dict:
        with self.tx() as db:
            db.execute("BEGIN IMMEDIATE")
            row = db.execute("SELECT * FROM records WHERE id=?", (id,)).fetchone()
            if row is None:
                raise StudioError("Record not found", 404)
            if expected is not None and row["version"] != expected:
                raise Conflict("This record changed. Refresh before saving again.")
            body = {**json.loads(row["body"]), **changes}
            db.execute("UPDATE records SET body=?,version=version+1,updated_at=? WHERE id=?", (json.dumps(body), now(), id))
        return self.get(id)
cwkAvatars, frontend/src/Batches.tsx: choose, 버전을 보내고 충돌이면 다시 불러와 (원문 그대로, 94-111행)·typescript
  async function choose(choices: Record<string, string>) {
    if (!collection) return;
    setBusy(true);
    try {
      await api(
        `/sets/${collection.id}/selection`,
        { version: collection.version, choices },
        "PATCH",
      );
      onReload();
    } catch (e) {
      error(e);
      // Another device chose first: refresh so the next click uses its version.
      if (e instanceof Error && e.message.includes("changed")) onReload();
    } finally {
      setBusy(false);
    }
  }
cwkAvatars, backend/studio.py: save_profile, 버전이 없으면 아직 저장된 게 없다는 주장이야 (원문 그대로, 279-288행)·python
        async with self.intent_lock:
            existing = await self.profile(soul)
            if existing["id"]:
                if body.version is None:
                    raise Conflict("Reference settings changed. Refresh first.")
                await self.io("update", existing["id"], expected=body.version, **changes)
            else:
                if body.version is not None:
                    raise Conflict("Reference settings changed. Refresh first.")
                await self.io("add", "profile", parent=soul, **{"variants": {}, **changes})

External links

Exercise

네 앱에서 기기 두 대나 브라우저 탭 두 개가 같이 고칠 수 있는 레코드를 하나 골라. version 컬럼을 더하고, 읽을 때마다 돌려주고, 쓸 때마다 요구해. 쓰기는 저장된 버전이 다르면 409로 거절하는 비교 후 교체로 만들어. 같은 레코드를 탭 두 개에 열고 양쪽에서 저장해 봐. 그리고 진 탭이 뭘 보여 주고, 사람이 다음에 뭘 할 수 있게 할지 정해.
Hint
SQLite에서 비교 후 교체를 원자적으로 만드는 길은 둘이야. BEGIN IMMEDIATE 다음에 읽고 비교하고 쓰거나, UPDATE ... WHERE id = ? AND version = ? 한 문장으로 쓰고 바뀐 행이 정확히 하나인지 확인하거나. 클라이언트에선 메시지 문구 말고 상태 코드로 갈라.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.