Files
document-haness/docs/clean-architecture-backend-template/tech-log-studio/non-atomic-check-then-act/case/case-a05-f026-enqueue.md
T
DongHyeonkaandClaude Opus 5 b2963105a8 docs(keycloak-session-store): import the session-storage lab as a new project
The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.

Follows the import procedure in README.md.

  source/     the originating repository verbatim — 78 documents, 28 SVGs,
              8 manifests, plus .source-revision recording the commit
  final/      the SSOT
    document.md   729 lines written from the 29 experiment documents, not
                  concatenated: what was predicted, what was measured, and
                  where the measurement itself was wrong
    evidence/raw    125 outputs, flattened to <experiment>__<file> because
                    the originals collided (01-baseline.txt appeared three
                    times) and the audit only globs the top level
    evidence/meta   one per raw file; command and exitCode are null and the
                    README says why rather than inventing them
    evidence/browser  22 captures
    assets/       three diagrams through techviz
    .techviz/     their VizSpecs

A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.

Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.

verify-pipeline.py passes. audit-records.py reports no issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:51:59 +09:00

13 KiB

kind, slug, title, topic, project, status, sourceRevision, rootTreeNode, evidenceCapturedOn, assets, evidence, source
kind slug title topic project status sourceRevision rootTreeNode evidenceCapturedOn assets evidence source
CASE a05-f026-enqueue upsert 라고 적힌 연산이 갱신하고 안 되면 삽입한다 non-atomic-check-then-act clean-architecture-backend-template 게시 전 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 case:a05-f026-enqueue 2026-09-02
key file
a05-f026-enqueue ../../../final/evidence/rendered/a05-f026-enqueue.svg
key file
a05-f026-enqueue-race ../../../final/evidence/rendered/a05-f026-enqueue-race.svg
../../../final/evidence/raw/a05-f026-enqueue.txt
../../../final/evidence/raw/a05-f026-enqueue-race.txt
원본 분석 절은 `analysis/05-adapter-outbound-persistence-jpa.md` §82 다. 등급은 P2 이고, 부분 유일 인덱스가 진 트랜잭션을 정상 upsert 로 흡수하지 않는다는 판정과 그 실행 탐침이 그 절에 있다.
그 절의 권고는 둘이다. 부분 유일 술어와 같은 의미를 갖도록 native upsert 를 구성하거나, 유일 충돌을 잡아 제한된 갱신 재시도로 수렴시키는 것이다. 회귀는 장벽을 둔 두 트랜잭션의 최초 넣기에서 둘 다 성공하고 열린 행이 하나임을 고정해야 한다.
저장이 병합으로 가는 이유와 예외가 번역되는 지점, 두 호출자의 도달 조건은 이 기록에서 확인했다.

upsert 라고 적힌 연산이 갱신하고 안 되면 삽입한다

복구 큐의 넣기를 javadoc 이 upsert 로 정의한다. 구현은 갱신을 먼저 시도하고 0 이면 저장한다. 열린 항목이 아직 없는 상태에서 두 호출자가 같은 순간에 들어오면 뒤에 삽입한 쪽이 부분 유일 인덱스에 걸린다.

관계

  • CAS 튜플을 where 절에 전부 반복하고 update count를 답으로 쓴다 갱신된 행 수를 답으로 쓰라는 규칙이고, 이 사례는 그 답을 읽지 않는다.
  • 조건부 update로 행을 claim하고 읽은 값으로 판단하지 않는다 읽고 나서 쓰는 형태를 피하는 규칙이다.
  • Atomic 타입의 존재는 원자성의 증거가 아니다* 타입 이름이 아니라 어디까지가 한 번에 일어나는지를 확인해야 한다.

문제

복구 큐는 조정이 해결하지 못한 파일을 담는다. 큐의 javadoc 은 넣기를 upsert 로 정의하고, 덧붙이기만 하는 큐는 미해결 파일 하나당 순회마다 한 행씩 늘어 서로 다른 문제를 같은 문제의 반복 아래 묻는다고 적는다.

갱신 쪽 의미도 저장소 javadoc 에 적혀 있다. 이기는 쪽은 최신 이유다. 가장 최근 증거를 서술하는 것이 최신 이유이기 때문이다.

결론

구현은 갱신을 먼저 시도하고 0 이면 저장한다. 파일당 열린 항목이 하나여야 한다는 것은 데이터베이스가 강제한다. 상태가 대기 중인 행에 대한 부분 유일 인덱스다.

열린 항목이 없는 상태에서 둘이 같은 순간에 들어오면 둘 다 갱신에서 0 을 받고 둘 다 삽입으로 간다. 실제 PostgreSQL 16 에서 여섯 번 돌렸다. 여섯 번 다 한쪽이 삽입에 성공하고 다른 한쪽이 유일 제약 위반을 받으며, 남는 행은 하나다. 어느 쪽이 지는지는 회차마다 바뀐다.

호출자가 실제로 받는 것은 드라이버 예외가 아니다. 넣기를 감싼 경계가 SQLSTATE 23505 를 데이터베이스 유일 위반으로 번역해 던진다.

던져지는 시점도 넣기 안이 아니다. 버전 필드가 없고 식별자가 바깥에서 채워지므로 저장이 병합으로 넘어가고, 그러면 삽입이 플러시 시점까지 밀린다. 그래서 삽입이 실제로 나가는 것은 그것을 감싼 쓰기 경계가 커밋할 때다.

데이터베이스 불변식은 지켜진다. 인덱스는 중복 행을 막는다. 경합에서 진 요청은 수렴 없이 끝난다. 진 쪽이 들고 온 이유 코드도 롤백과 함께 사라지므로, 저장소 javadoc 이 말한 최신 이유가 이긴다는 것도 이 창에서는 성립하지 않는다.

마무리 경로에는 문제가 하나 더 있다. 커밋에서 실패하면 그 아래 예외 줄까지 닿지 못한다. 상위 경로가 그 예외 여부로 취소를 판단하기 때문에, 게시가 끝난 업로드까지 취소 쪽으로 흘러간다.

겹치는 조건은 좁다. 조정이 집어 올리는 것은 갱신 시각이 5분 이상 지난 레코드다. 조정 진입점에는 아직 호출자가 붙어 있지 않다. 지금 실제로 도달 가능한 넣기 지점은 마무리 경로 하나이고, 조정이 켜지면 둘이 된다.

수정 방향은 둘이다. native upsert 를 부분 유일 술어와 같은 의미로 구성하는 쪽, 아니면 유일 충돌을 잡아 제한된 갱신 재시도로 수렴시키는 쪽이다. 회귀 시험은 장벽을 둔 두 트랜잭션이 처음 넣을 때 둘 다 성공하고 열린 행은 하나로 남는지를 고정해야 한다.

검증 환경

OpenJDK : 21.0.12 데이터베이스 : PostgreSQL 16.15, 실제 실행 확인 방식 : 넣기 경로와 예외 번역 추적, 실제 PostgreSQL 에서 두 연결을 장벽으로 맞춰 동시에 실행 소스 수정 : x

재현 조건

  1. 큐와 저장소의 javadoc 을 나란히 읽는다.
  2. 넣기 구현과 그것이 부르는 갱신 질의를 읽는다.
  3. 엔티티에 버전 필드와 Persistable 구현이 있는지 센다.
  4. 그 테이블의 부분 유일 인덱스를 확인한다.
  5. 넣기를 감싼 경계가 예외를 어떻게 번역하는지 읽는다.
  6. PostgreSQL 을 띄우고 postgresql 과 jpa/core 와 jpa/fileserver 마이그레이션을 순서대로 적용한다.
  7. 열린 항목이 없는 상태에서 두 연결을 장벽으로 맞춰 풀고, 결과와 남은 행 수를 여러 회차 본다.
  8. 넣기를 부르는 프로덕션 코드와 각각의 도달 조건을 확인한다.

본문

복구 큐의 javadoc 은 넣기를 upsert 로 정의한다.

An enqueue is an upsert: the same file reported twice updates the open item rather than adding
a second one. Reconciliation runs on a schedule and re-raises whatever it still cannot settle, so
an append-only queue would grow one row per sweep per unresolved file and bury the distinct
problems under repetitions of the same one.

저장소의 javadoc 은 갱신 쪽 의미도 적는다. 최신 이유가 이기고, 그것이 가장 최근 증거를 서술하기 때문이다.

javadoc 이 약속한 것과 구현이 하는 것

:::evidence key="a05-f026-enqueue" alt="복구 큐와 저장소 인터페이스의 javadoc, 넣기 메서드의 구현과 엔티티의 버전 필드 및 Persistable 구현 수, 그 테이블의 부분 유일 인덱스, 넣기를 감싼 경계가 예외를 번역하는 코드와 SQLSTATE 매핑, 넣기를 부르는 프로덕션 코드 둘과 마무리 경로의 감싼 구간, 조정 진입점의 프로덕션 호출자 수와 조정이 집어 올리는 대상의 조건, 그리고 같은 저장소가 승패를 데이터베이스에 물어보는 자리를 출력한 터미널 기록." caption="두 javadoc 의 약속 · 갱신 뒤 0 이면 저장 · 엔티티에 @Version 0, Persistable 0 · 부분 유일 인덱스 · 23505 를 DB_UNIQUE_VIOLATION 으로 번역 · 조정 진입점의 프로덕션 호출자 0 · 조정은 5분 지난 것만 집음 — 69줄 · exit 0" zoom="true" :::

public void enqueue(FileId fileId, String reasonCode) {
  Instant now = clock.instant();
  if (items.refreshPending(fileId.value(), reasonCode, now) > 0) {
    return;
  }
  items.save(
      new RecoveryItemEntity(UUID.randomUUID(), fileId.value(), reasonCode, STATUS_PENDING, now));
}

데이터베이스는 파일당 열린 항목을 하나로 강제한다.

CREATE UNIQUE INDEX uq_fs_recovery_open
    ON fs_recovery_item (file_id)
    WHERE status = 'PENDING';

열린 항목이 없을 때 둘이 같은 순간에 들어오면

:::evidence key="a05-f026-enqueue-race" alt="실제 PostgreSQL 컨테이너를 세우고 postgresql 과 jpa/core 와 jpa/fileserver 마이그레이션을 순서대로 적용한 뒤, 열린 항목이 없는 상태에서 두 연결을 장벽으로 맞춰 동시에 풀어 갱신과 삽입 순서를 실행하는 것을 여섯 번 반복하고 회차마다 이긴 쪽과 진 쪽의 결과와 남은 행 수를 출력한 터미널 기록." caption="PostgreSQL 16.15 에 세 마이그레이션 적용 · 장벽으로 맞춘 두 연결을 6회 반복 · 매번 한쪽만 삽입 성공, 다른 한쪽은 uq_fs_recovery_open 위반, 남은 행은 하나 · 이기는 쪽은 회차마다 바뀜 — 10줄 · exit 0" zoom="true" :::

1 회차: 이긴 쪽 B · 진 쪽 duplicate key value violates unique constraint "uq_fs_recovery_open" · 남은 행 1
3 회차: 이긴 쪽 A · 진 쪽 duplicate key value violates unique constraint "uq_fs_recovery_open" · 남은 행 1

여섯 번 다 결과의 모양은 같다. 둘 다 갱신에서 0 을 받은 뒤 삽입으로 가고, 뒤에 온 쪽이 인덱스에 걸리고, 남는 행은 하나다. 어느 쪽이 뒤가 되는지는 고정이 아니다. 여기의 A 와 B 는 같은 순서를 도는 두 연결이지 아래에 나오는 두 프로덕션 호출자가 아니다.

호출자가 실제로 받는 것

탐침은 삽입을 직접 쓴다. 실제 경로는 items.save(...) 다. 엔티티에 버전 필드가 없고 식별자가 밖에서 채워져 들어오므로 Spring Data 의 저장은 병합으로 가고, 병합은 할당된 식별자의 삽입을 플러시까지 미룬다. 그래서 삽입이 나가는 시점은 넣기 안이 아니라 그것을 감싼 쓰기 경계가 커밋할 때다.

예외도 그대로 올라오지 않는다.

private <T> T executeLegacy(TransactionTemplate template, Supplier<T> action) {
  try {
    return template.execute(status -> action.get());
  } catch (RuntimeException failure) {
    throw exceptionTranslator
        .translate(failure)
        .map(RuntimeException.class::cast)
        .orElse(failure);
  }
}

SQLSTATE 23505 는 데이터베이스 유일 위반으로 번역된다. 위 기록은 순서 자체가 재현되는지를 본 것이고, 예외의 타입은 실제 경로 쪽을 따라야 한다.

깨지는 쪽은 어댑터 계약이다

인덱스는 중복 행을 막을 뿐이다. 진 쪽 요청은 upsert 로 수렴하지 않고 그대로 실패한다.

진 쪽이 들고 온 이유 코드도 롤백과 함께 사라진다. 큐에 남는 이유는 이긴 쪽 것 하나이고, 저장소 javadoc 이 말한 최신 이유가 이긴다는 것도 이 창에서는 성립하지 않는다.

마무리 경로에서는 이것과 다른 문제가 하나 더 나온다.

} catch (RuntimeException exception) {
  transactions.inWrite(
      () -> {
        recoveryQueue.enqueue(verifying.fileId(), "READY_COMMIT_UNCONFIRMED");
      });
  throw new AmbiguousCompletionException(

넣기를 감싼 경계가 커밋에서 실패하면 그다음 줄의 예외는 던져지지 않는다. 그 예외인지 아닌지로 취소 여부를 가르는 상위 경로가 있으므로, 바이트가 이미 게시된 업로드가 취소 분기로 넘어간다.

프로덕션 호출자는 둘, 겹치는 조건은 좁다

DefaultFinalizeUploadService.java:323   recoveryQueue.enqueue(verifying.fileId(), "READY_COMMIT_UNCONFIRMED");
DefaultFileReconciliationService.java:220   recoveryQueue.enqueue(record.fileId(), reasonCode);

조정은 갱신 시각이 5분 이상 지난 레코드만 집어 올린다. 같은 파일에서 두 호출이 만나려면 검증 중에 들어간 지 5분이 지난 마무리가 그때 커밋에 실패해야 한다.

그리고 이 리비전에서 조정 진입점을 부르는 프로덕션 코드는 없다. 빈 정의와 시험뿐이고, javadoc 이 말하는 일정 순회는 배선되어 있지 않다. 지금 도달 가능한 넣기 지점은 마무리 경로 하나이고, 조정이 켜지는 순간 이 자리는 두 개가 된다.

같은 저장소가 승패를 데이터베이스에 물어보는 자리

인박스 예약과 관리 작업 기록이 충돌 시 아무것도 하지 않는 삽입을 쓰고, 바뀐 행 수를 답으로 쓴다.

JdbcInboxRepository.java:36        ON CONFLICT (message_id, consumer_id) DO NOTHING
JdbcAdminOperationJournal.java:51  ON CONFLICT (approval_ticket, plan_digest) DO NOTHING

다만 그 형태를 이 큐에 그대로 옮길 수는 없다. 넣기의 계약은 두 번째 보고를 버리는 것이 아니라 열린 항목의 이유를 최신으로 바꾸고 시도를 올리는 것이므로, 필요한 것은 아무것도 하지 않는 삽입이 아니라 갱신하는 삽입이다. 유일 인덱스가 부분 인덱스라 충돌 대상에도 그 조건을 같이 적어야 한다.

여기 두 자리가 보여 주는 것은 답 자체가 아니라, 이 저장소가 승패를 데이터베이스에게 물어보는 형태를 이미 쓸 줄 안다는 사실이다. 알림 쪽 결과 타입의 javadoc 에는 그 형태를 고른 이유도 함께 적혀 있다. 이긴 호출자에게는 행이 돌아오고 진 호출자에게는 돌아오지 않는다.

확인하지 못한 것

조정 쪽은 아직 프로덕션 호출자가 없어 두 지점이 겹치는 일은 현재 배선에서 일어나지 않는다. 확인한 것은 겹칠 때의 결과다.

병합이 미루는 삽입이 실제 플러시 시점에 같은 경쟁을 만드는지는 탐침으로 재현하지 않았다. 탐침은 INSERT 를 손수 날린다.