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

요청 id로 지키는 멱등 저장

~14 min · idempotency, request-id, concurrency, http

Level 0밑준비
0 XP0/43 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

다시 보내도 접시는 하나여야 해

약한 연결에 매달린 폰을 떠올려 봐. 폰이 접시를 보내. 엔진은 저장하고 답하는데, 그 답이 돌아오다 사라져. 폰 입장에선 요청이 실패했으니 접시를 또 보내. 두 번째 사본을 알아볼 장치가 없으면 아빠 기록엔 같은 저녁이 두 번 남아.

Sizzle의 답은 요청 id야. 클라이언트가 첫 시도 전에 만들고, 다시 보낼 때마다 같은 걸 써. 웹은 newRequestId("plate")로 만들어. 접두어, 대시, 그리고 crypto.randomUUID에서 뽑은 16진 문자 24개야. 폰은 접시를 아웃박스에 넣을 때 킷의 ClientRequestId로 만들고, 접시가 기다리는 동안 그 아웃박스 항목의 id가 곧 요청 id야. 라우트는 ^[A-Za-z0-9_-]{8,80}$에 맞는 id만 받아.

엔진 쪽에선 그 id가 encounter.captured 이벤트 안에 실려 다니고, 미러의 encounters.request_id 컬럼은 UNIQUE야. id가 저널에 들어 있으니까 중복 거르기가 재시작에도 살아남아. 엔진의 첫 저장 테스트가 딱 그걸 봐. 메모를 바꿔 다시 보내도 같은 접시가 돌아오고, 목록엔 접시가 하나뿐이고, 같은 데이터 위에 새로 띄운 엔진도 다시 짓고 나서 그 접시를 찾아.

검사 셋, 하는 일도 셋

요청이 들어오는 길에 같은 질문을 세 번 해. 이 요청 id를 본 적 있나? 자리마다 이유가 있어.

자리언제아끼는 것
라우트, backend/app.py본문 사진이나 스테이징 id를 읽기 전스테이징 사진이 이미 풀린 뒤에 온 재시도도 접시를 받아
capture, 첫 확인사진을 디코딩하고 다시 인코딩하기 전그냥 재전송일 뿐인데 들 뻔한 느린 이미지 작업
capture, 쓰기 잠금 안record 바로 전사본 둘이 경쟁할 때 저널 그 자체

라우트의 검사엔 이유를 적은 주석이 붙어 있어. 접시가 커밋되면 라우트는 그 접시의 스테이징 사진을 풀어 줘. 그 뒤에 온 재시도는 이미 사라진 스테이징 id를 대고, take는 그걸 거절할 거야. 재전송에 먼저 답해 버리니까 그 재시도도 저장된 접시를 들고 무사히 끝나.

셋째 검사가 저널을 지키는 이유

이번엔 같은 요청의 사본 둘이 동시에 도착했다고 해 봐. 첫 시도가 아직 사진을 디코딩하는 중에 클라이언트 재시도가 나간 거야. 라우트는 둘을 각각 asyncio.to_thread에 넘기니까 서로 다른 스레드에서 돌아. 아직 아무것도 저장 안 됐으니 둘 다 앞의 두 검사를 통과하고, 둘 다 사진을 받아들여.

셋째 검사를 빼면 둘 다 record를 불러. 레슨 1의 순서를 떠올려 봐. 덧붙이기가 먼저, 반영은 나중이야. 두 번째 사본의 이벤트가 저널에 들어가고, 그 반영이 UNIQUE 요청 id에 부딪혀 예외를 던져. 임시 폴더에서 같은 encounter.captured를 두 번 기록해 봤어. 두 번째 호출은 IntegrityError를 던졌고, 저널엔 두 줄이 남았고, 그 폴더 위에 새로 만든 Sizzle은 같은 에러로 열리길 거부했어. 중복 이벤트 하나가 앞으로의 모든 시작을 막는 거야.

self._write 안의 재확인이 그 틈을 닫아. 잠금을 두 번째로 잡은 스레드는 먼저 들어간 쪽의 접시를 찾아서 아무것도 안 쓰고 돌려줘. 코드 주석이 이름을 붙여 놨어. 동시에 온 재전송이 경주에서 이겼다고. _write가 재진입 가능한 잠금인 것도 이 때문이야. capture가 잡은 채로 record를 부르고, record가 그걸 또 잡거든.

솔직히 말하면 남는 문제가 하나 있어. 진 스레드는 잠금 바깥에서 자기 접시 id로 사진을 이미 받아들였어. 그 파일들은 어떤 이벤트도 가리키지 않는 폴더에 그대로 남아. 디스크만 조금 먹고 기록은 멀쩡해. 이 경주에서만 생기는 일이고. 엔진 테스트엔 동시 저장 테스트가 없어. 그래서 이 방어는 테스트가 아니라 코드를 읽어서 확인한 거야.

원칙: 저널이 먼저인 시스템에선 덧붙이기 전에 중복을 걸러. 투영만 지키는 제약은 중복 하나를 재생 못 하는 저널로 바꿔 놔.

재전송은 평범한 성공이야

이미 아는 요청 id가 오면 Sizzle은 저장된 접시를 보통 성공으로 돌려줘. 409도 없고, 클라이언트가 따로 처리할 에러도 없어. 클라이언트 쪽에선 재시도가 그냥 된 거야. 재전송이 id 조회까지 오기만 하면 나머지는 무시돼. 저장된 메모, 종류, 사진이 이겨. 테스트가 메모를 바꿔서 보내 보는 것도 그걸 확인하려는 거야. 근데 거기까지 오려면 문이 둘 있어. 핸들러가 돌기 전에 FastAPI가 폼 필드부터 검사해. id 패턴, 4000자 이하 메모, 64자 이하 시간대, 범위 안의 위도와 경도. 그다음 라우트가 id를 찾기 전에 kind를 확인해. 둘 중 하나에 걸린 재전송은 접시가 저장돼 있어도 에러로 거절돼. 처음 보낸 본문을 그대로 다시 보내는 클라이언트는 둘 다 통과하고, 이 설계가 노리는 재시도가 바로 그거야.

클라이언트가 다시 보낼 수 있는 곳이면 어디든 같은 모양이 나와. 대화 턴은 자기 request_id를 들고 다니고, turns 테이블에서 유일해(트랙 6). 스테이징 사진 업로드는 클라이언트 키로 멱등이야(레슨 5). 밑에 깔린 규칙은 http-rest-quest의 Semantics 트랙이 멱등 메서드에 대해 풀어 놓은 그대로야. 요청을 두 번 보내도 서버는 한 번 보냈을 때와 같은 자리에 있어야 해.

저장 경로에서 정하는 게 두 가지 더 있어. _capture_moment가 접시가 언제였는지 정해. 카메라 자기 시계가 먼저, 그다음 클라이언트의 captured_at, 그것도 없으면 지금이야. 그리고 자기 GPS를 가진 첫 사진이 클라이언트가 보낸 place를 이겨. 그 사실들이 어디서 오는지는 레슨 5에서 봐.

Code

cwkSizzle, backend/domain.py: Sizzle.capture, 이미지 작업 전에 한 번, 쓰기 잠금 안에서 또 한 번 재전송 확인 (원문 그대로, 77-107행)·python
        existing = self.store.encounter_by_request(request_id)
        if existing is not None:
            return existing
        if kind not in KINDS:
            raise DomainError(f"kind must be one of {', '.join(KINDS)}")
        if not files:
            raise DomainError("an encounter needs at least one photo")
        encounter_id = new_ulid()
        photos = [self.photos.ingest(encounter_id, new_ulid(), name, data) for name, data in files]
        moment, zone = _capture_moment(photos, captured_at, timezone_name)
        with self._write:
            again = self.store.encounter_by_request(request_id)
            if again is not None:  # a concurrent replay won the race
                return again
            place = next((p["gps"] for p in photos if p.get("gps")), place)
            self.record(
                "encounter.captured",
                {
                    "id": encounter_id,
                    "request_id": request_id,
                    "captured_at": moment.isoformat(),
                    "local_date": moment.date().isoformat(),
                    "timezone": zone,
                    "kind": kind,
                    "note": _text(note),
                    "place": place,
                    "photos": photos,
                },
            )
        self.on_reading_requested()
        return self.store.encounter(encounter_id)
cwkSizzle, backend/app.py: 저장 라우트는 뭘 읽기 전에 재전송에 답하고, 스테이징 사진은 커밋 뒤에만 풀어 (원문 그대로, 203-222행)·python
        if kind not in KINDS:
            raise DomainError(f"kind must be one of {', '.join(KINDS)}")
        # A replay answers the stored plate before anything is read, so a retry
        # after its staged photos were released still lands.
        existing = store.encounter_by_request(request_id)
        if existing is not None:
            staged.release(staged_ids)
            return encounter_payload(existing["id"])
        files = [(upload.filename or "photo", await upload.read()) for upload in photos]
        try:
            files += [(name, data) for name, data, _ in staged.take(staged_ids)]
        except StagedUploadError as exc:
            raise DomainError(str(exc)) from exc
        encounter = await asyncio.to_thread(
            sizzle.capture, request_id=request_id, kind=kind, note=note, captured_at=captured_at,
            timezone_name=timezone, files=files,
            place=None if lat is None or lon is None else {"lat": round(lat, 6), "lon": round(lon, 6)},
        )
        staged.release(staged_ids)
        return encounter_payload(encounter["id"])

External links

Exercise

네 소프트웨어에서 모바일이나 불안정한 클라이언트가 부르는 생성 엔드포인트를 하나 골라. 클라이언트가 만든 요청 id를 받고, 레코드와 같이 저장하고, 같은 id가 다시 오면 저장된 레코드를 돌려줘. 그리고 같은 요청을 스레드 둘에서 동시에 쏘는 테스트를 써. 끝나고 나면 테이블에도 이벤트 로그에도 레코드가 정확히 하나여야 해.
Hint
threading.Barrier를 쓰면 두 스레드가 아무것도 쓰기 전에 첫 검사를 같이 통과하게 만들 수 있어. 그게 바로 일으키고 싶은 상황이야. 쓰기가 로그 먼저라면 테이블 행만 세지 말고 로그 줄도 세. 테이블은 멀쩡해 보여도 로그엔 이미 중복이 들어가 있을 수 있어.

Progress

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

댓글 0

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

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