--- kind: CASE slug: an-active-transaction-check-that-asked-the-wrong-question title: 활성 트랜잭션 검사가 data source를 묻지 않아 남의 트랜잭션이 통과했다 topic: owner-safe-state-machines project: clean-architecture-backend-template status: 게시 전 sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 rootTreeNode: case:an-active-transaction-check-that-asked-the-wrong-question evidenceCapturedOn: 2026-09-04 body: case-an-active-transaction-check-that-asked-the-wrong-question.body.md assets: - key: an-active-transaction-check-that-asked-the-wrong-question file: ../../../final/evidence/rendered/an-active-transaction-check-that-asked-the-wrong-question.svg evidence: - ../../../final/evidence/raw/an-active-transaction-check-that-asked-the-wrong-question.txt source: - 원본 분석 절은 final/document.md#4-1 이다. --- # 활성 트랜잭션 검사가 data source를 묻지 않아 남의 트랜잭션이 통과했다 옛 검사는 스레드에 트랜잭션이 열려 있는지만 확인했고 어느 데이터소스의 것인지는 확인하지 않았다. 지금은 `IdempotencyCapabilityGuard:101` 이 `hasResource(dataSource)` 를 뒤에 붙였는데, `dataSource` 가 널이면 그 검사를 건너뛴다. ## 관계 - **CAS 튜플과 update count가 답이 되는 구조** 그 개념에서 답이 되는 갱신 건수는 소유자 튜플을 반복한 문장이 하나의 트랜잭션 안에서 이 저장소의 커넥션 위로 실행됐을 때만 답이 된다. 그 전제를 거는 것이 이 가드다. - **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다** 같은 세 검사가 가드와 outbox 어댑터와 inbox 어댑터 세 곳에 각각 따로 구현돼 있고, 그중 가드의 사본만 `dataSource != null` 을 앞에 달아 검사를 건너뛸 수 있다. - **두 오버로드가 있었고 호출되는 쪽이 틀린 쪽이었다** 형제인 inbox 어댑터의 같은 세 검사를 그 기록이 먼저 적었다. 거기서는 셋이 한 조건으로 묶이고 널 가드가 없다. ## 문제 멱등성 저장소는 변경을 쓰기 전에 전제 셋을 통과해야 하고, 마지막 하나가 트랜잭션의 소유자를 가린다. 옛 검사는 그 셋 중 세 번째에 틀린 답을 냈고, 그 사실을 클래스 자바독이 사후 기록으로 남겼다. ## 결론 IdempotencyCapabilityGuard 의 클래스 자바독(:12~:21)이 무엇이 틀렸는지 남겨 두었다. 옛 검사는 트랜잭션의 존재만 보아 소유자를 가리지 못했고, 데이터소스가 둘인 배포에서 남의 트랜잭션이 그 검사를 통과했다. 그 변경이 커밋된 곳은 이 저장소의 커넥션이고, 그것을 감싸는 트랜잭션은 없었다. 지금 requirePrimaryWriteTransaction(:92~:108)에는 검사가 셋이다. :93 활성 트랜잭션, :97 읽기 전용 여부, :101 데이터소스 결속이다. 옛 검사가 첫 줄에 그대로 있고 새 검사가 맨 뒤에 붙었다. 맨 뒤 검사에는 앞선 조건이 하나 더 있다. dataSource 가 널이 아닐 때만 hasResource 를 평가한다. 생성자(:34~:39)에서 jdbc 와 activeCapabilitySql 은 requireNonNull 을 지나는데 dataSource 만 그대로 대입된다. 그 값은 PostgreSqlOwnerSafeIdempotencyStore:105~:109 의 dataSourceOf 가 정하고, JdbcTemplate 이 아니면 널이다. 프로덕션에서는 널이 되지 않는다. 조립 자리가 PostgreSqlIdempotencyProviderConfig:60 하나이고, 저장소에 JdbcOperations 구현이나 JdbcTemplate 하위 클래스가 0 건이라 그 빈은 JdbcTemplate 이다. 널 경로가 열려 있는 곳은 mock(JdbcOperations.class) 를 넘기는 유닛 시험(OwnerSafeIdempotencyPreconditionTest:45)이다. 그 검사에는 시험이 붙어 있지 않다. :101 이 던지는 메시지를 저장소에서 찾으면 던지는 줄 하나만 나오고, 전제 시험 셋은 트랜잭션 없음과 다른 벤더와 승인되지 않은 스키마만 단언한다. 형제 둘은 데이터소스를 생성자로 직접 받고 :158 과 :210 에서 requireNonNull 로 거른다. 그래서 널 가드를 둘 이유가 없다. ## 검증 환경 OpenJDK : 21.0.12 Spring Boot : 4.0.8 근거 : 저장소의 자바독이 사후 기록으로 남긴 회귀 확인 방식 : 가드 자바독의 사후 기록 확인, 필드와 생성자의 널 검사 유무 확인, 전제 검사 메서드의 세 검사와 각 실패 메시지 확인, 데이터소스를 정하는 메서드와 그 자바독 확인, 저장소를 만드는 자리 전수와 각각이 넘기는 값 확인, JdbcOperations 구현과 JdbcTemplate 하위 클래스 검색, 세 번째 검사의 메시지를 단언하는 시험 검색, 가드의 두 메서드를 부르는 자리 전수, 형제 어댑터 둘의 데이터소스 주입과 세 검사 형태 대조 소스 수정 : x ## 재현 조건 1. IdempotencyCapabilityGuard 의 클래스 자바독을 읽는다. 세 질문과 틀린 답이 적혀 있다. 2. 필드와 생성자를 읽고 어느 인자가 널 검사를 지나는지 본다. 3. 전제를 검사하는 메서드의 본문을 끝까지 읽고 검사가 몇 개인지, 각각 어떤 메시지로 실패하는지 적는다. 4. 데이터소스 검사에 붙은 조건을 읽고 그 값이 어디서 오는지 거슬러 올라간다. 5. 그 값을 정하는 메서드와 자바독을 읽는다. 6. 이 저장소를 만드는 자리를 전부 찾고 각각이 무엇을 넘기는지 확인한다. 7. 저장소에 그 인터페이스의 다른 구현이 있는지 찾는다. 8. 세 번째 검사가 던지는 메시지를 저장소에서 찾아 그것을 단언하는 시험이 있는지 본다. 9. 가드의 두 메서드를 부르는 자리를 전부 찾는다. 10. 형제인 outbox 와 inbox 어댑터가 데이터소스를 어떻게 받고 같은 세 검사를 어떤 형태로 거는지 확인한다. ## 본문 `IdempotencyCapabilityGuard` 는 멱등성 저장소가 SQL 을 돌리기 전에 만족해야 할 전제를 모아 둔 타입이다. 클래스 자바독이 이 타입이 따로 생긴 이유를 적는데, 전제가 서로 다른 세 질문이고 저장소가 세 번째를 틀리게 답했다는 것이다. ## 자바독이 남긴 사후 기록 :::evidence key="an-active-transaction-check-that-asked-the-wrong-question" alt="저장소 루트에서 돌린 정적 검색 출력 149줄. IdempotencyCapabilityGuard 의 클래스 자바독이 9번부터 23번 줄까지 원문 그대로 실려 세 질문과 틀린 답과 데이터소스가 둘일 때의 결과가 나온다. 이어서 필드 셋과 생성자가 28번부터 39번 줄까지 실리는데 jdbc 와 activeCapabilitySql 은 requireNonNull 을 지나고 dataSource 만 그대로 대입된다. requirePrimaryWriteTransaction 의 본문이 87번부터 109번 줄까지 나와 세 검사와 각각의 메시지가 보이고, 마지막 검사가 dataSource 가 널이 아닐 때만 hasResource 를 평가한다. 그 값을 정하는 dataSourceOf 가 JdbcTemplate 일 때만 getDataSource 를 돌려주고 아니면 널이라는 것과, 그 절충을 인정하는 자바독이 함께 나온다. 이 저장소를 만드는 자리 셋이 소스 세트별로 나오는데 프로덕션은 하나이고 그 자바독이 가드가 데이터소스를 식별하므로 다른 데이터소스의 트랜잭션은 통과할 수 없다고 약속한다. 유닛 시험은 mock 을 넘긴다. 저장소에 JdbcOperations 구현이나 JdbcTemplate 하위 클래스는 0 건이다. 세 번째 검사의 메시지를 찾으면 던지는 줄 하나만 나오고 시험은 없으며, 전제 시험 셋이 무엇을 단언하는지 이름으로 나온다. 마지막으로 가드를 부르는 열일곱 줄과 형제 어댑터 둘이 데이터소스를 생성자로 받아 requireNonNull 하는 것과 같은 세 검사를 거는 형태가 나온다." caption="세 질문과 틀린 답을 적은 자바독 · dataSource 만 널 검사를 지나지 않는 생성자 · 지금의 세 검사와 마지막에 붙은 널 조건 · 그 값을 정하는 dataSourceOf · 조립 자리 셋과 프로덕션 자바독의 약속 · JdbcOperations 구현 0 · 세 번째 검사를 덮는 시험 0 · 형제 둘의 생성자 주입과 세 검사 — 149줄 · exit 0" zoom="true" ::: 자바독 `:12`\~`:16` 은 세 질문을 나열한다. 스키마가 승인되었는가, 트랜잭션이 있는가, 그것이 이 저장소의 트랜잭션인가. 이어서 저장소가 세 번째를 틀리게 답했다고 적는다. 스레드에 활성 읽기 쓰기 트랜잭션이 있는지만 확인했는데 그 조건은 어느 데이터소스에서든 트랜잭션이 열려 있으면 참이고, 형제인 outbox 와 inbox 어댑터는 `hasResource(dataSource)` 를 확인하며 그것이 실제로 중요한 질문이라는 것이다. `:18`\~`:21` 이 결과를 적는다. 데이터소스가 둘인 애플리케이션에서 다른 쪽의 트랜잭션 안에서 발행된 변경이 옛 검사를 통과했고, 이 저장소의 커넥션에서 트랜잭션 없이 실행됐으며, 원자적이어야 할 작업과 독립적으로 커밋됐다. ## 지금의 검사는 셋이다 `requirePrimaryWriteTransaction:92` 가 세 검사를 차례로 건다. `:93` 이 `isActualTransactionActive()` 를 본다. 자바독이 틀렸다고 적은 바로 그 검사이고 지금도 첫 줄에 있다. `:97` 이 `isCurrentTransactionReadOnly()` 를 본다. `:101` 이 `dataSource != null && !hasResource(dataSource)` 를 본다. `:102`\~`:103` 의 주석이 마지막 것은 outbox 와 inbox 어댑터가 이미 하는 검사이고, 이것이 없을 때 다른 데이터소스의 트랜잭션이 가드를 만족시키는 동안 이 저장소의 작업이 따로 커밋됐다고 적는다. 옛 검사를 지우지 않고 뒤에 검사 하나를 더했다. 세 실패가 각각 다른 메시지를 낸다. ## dataSource 가 널이면 \:101 을 건너뛴다 `:101` 의 조건은 데이터소스를 아는 경우에만 뒤쪽을 평가한다. 그 값이 어떻게 들어오는지는 생성자에 있다. `:36` 이 `jdbc` 를, `:38` 이 `activeCapabilitySql` 을 각각 `Objects.requireNonNull` 로 받는데 `:37` 의 `this.dataSource = dataSource` 만 그대로 대입한다. 널이 허용된다는 것이 이 세 줄에 나란히 적혀 있다. 넣는 쪽은 `PostgreSqlOwnerSafeIdempotencyStore:91` 이다. 생성자가 `dataSourceOf(jdbc)` 로 값을 만드는데, `:105`\~`:109` 의 그 메서드는 `jdbc` 가 `JdbcTemplate` 이면 `template.getDataSource()` 를 돌려주고 아니면 널을 돌려준다. 그 자바독(`:98`\~`:104`)이 절충을 인정한다. `JdbcTemplate` 은 자기 데이터소스를 알지만 손으로 만든 `JdbcOperations` 는 모를 수 있고, 가드는 알 수 없는 데이터소스를 "검사할 수 없음" 으로 다루는데 그것은 outbox 어댑터의 정확한 검사보다 약하고 이전보다는 강하며 협력자를 정말로 식별할 수 없을 때의 정직한 답이라는 것이다. ## 그 널 경로가 실제로 열리는 곳 이 저장소를 만드는 자리는 셋이다. 프로덕션은 `PostgreSqlIdempotencyProviderConfig:60` 하나이고 `JdbcOperations` 빈을 받는다. 그 메서드의 자바독 `:55`\~`:56` 은 저장소의 트랜잭션 가드가 그것으로부터 자기 데이터소스를 식별하므로 다른 데이터소스에서 연 트랜잭션은 통과할 수 없다고 적는다. 저장소에는 `JdbcOperations` 를 구현하거나 `JdbcTemplate` 을 상속하는 클래스가 0 건이다. 그러므로 이 저장소가 조립하는 배포에서 그 빈은 `JdbcTemplate` 이고 `:101` 은 살아 있다. 널 경로가 실제로 열려 있는 곳은 저장소 자신의 유닛 시험이다. `OwnerSafeIdempotencyPreconditionTest:45` 가 `mock(JdbcOperations.class)` 를 만들고 `:47` 이 그것으로 저장소를 만든다. 그 시험들이 도는 동안 `:101` 은 매번 건너뛰어진다. ## 그 검사를 덮는 시험이 없다 `:101` 이 던지는 메시지를 저장소 전체에서 찾으면 나오는 것은 던지는 줄 하나다. 그것을 단언하는 시험이 없다. 전제 시험 셋이 단언하는 것은 트랜잭션이 없는 경우(`:57`), 다른 벤더인 경우(`:67`), 승인되지 않은 스키마 스트림인 경우(`:81`)다. 세 번째 질문은 그 목록에 없다. ## 형제 어댑터와 같은 형태인가 `PostgreSqlImmutableOutboxAppendAdapter` 는 `:321` 에서 활성 트랜잭션을, `:325` 에서 읽기 전용 여부를, `:328` 에서 `hasResource` 를 각각 다른 `if` 로 검사한다. `PostgreSqlSameStoreInboxAdapter:503`\~`:505` 는 같은 셋을 한 조건으로 묶는다. 가드도 셋을 각각 다른 `if` 로 나누므로 outbox 와 같은 모양이다. 갈리는 것은 데이터소스를 얻는 방법이다. 형제 둘은 생성자가 `DataSource` 를 직접 받고 `:158` 과 `:210` 이 `Objects.requireNonNull` 로 거른다. 널일 수 없으므로 널 가드가 필요 없다. 가드는 `JdbcOperations` 에서 추론하고, 추론이 실패하면 널이 된다. ## 이 가드를 부르는 자리 `PostgreSqlOwnerSafeIdempotencyStore` 의 여섯 자리 — `:122`, `:176`, `:211`, `:255`, `:303`, `:360` — 가 같은 클래스의 private `requirePrimaryWriteTransaction`(`:506`\~`:507`)을 부르고, 그 메서드가 `guard.requirePrimaryWriteTransaction` 으로 넘긴다. `requireActiveCapability` 를 부르는 자리는 `:123` 하나다. ## 원문과 갈리는 자리 원문은 이 수정이 세 번째 질문을 형제와 같은 형태로 바꾼 것이라고 적었다. 바꾼 것이 아니라 더한 것이다. `:93` 의 옛 검사가 그대로 첫 줄에 있다. 원문은 형제 어댑터의 검사를 `hasResource(dataSource)` 하나로 적었다. outbox 는 셋을 각각 다른 `if` 로 걸고 inbox 는 같은 셋을 한 조건으로 묶는다. 세 질문과 틀린 답, 데이터소스 둘일 때의 결과는 원문대로다. ## 확인하지 못한 것 데이터소스를 둘 띄워 옛 동작을 재연하지 않았다. 남아 있는 기록과 현재 코드와 형제 구현을 나란히 놓고 읽었다. `JdbcTemplate` 이 아닌 다른 `JdbcOperations` 구현을 넘겨 `:101` 이 열린 채 지나가는 것을 실행으로 보이지 않았다. 조건과 그 값을 정하는 메서드와 유닛 시험이 넘기는 값을 읽은 데까지다. 이 템플릿을 가져다 쓰는 애플리케이션이 자기 `JdbcOperations` 빈을 등록하는 경우는 보지 않았다. 이 저장소 안에 그런 구현이 없다는 것까지 확인했다. 이 템플릿을 가져다 쓰는 애플리케이션이 자기 `JdbcOperations` 빈을 등록하는 경우는 보지 않았다. 이 저장소 안에 그런 구현이 없다는 것까지 확인했다.