# T2-c 구현 지침서 — 외부 호출(TORQ)을 잠금 밖으로

작성 2026-08-15 · 대상: Cowork 서브에이전트
선행: T2-a (`074b4b8`) · T2-b (`bf6a164`) — 둘 다 **미배포**
설계: [P2P_ASYNC_MATCHING_DESIGN.md](./P2P_ASYNC_MATCHING_DESIGN.md) §1 · [T2B_MATCHING_WORKER_GUIDE.md](./T2B_MATCHING_WORKER_GUIDE.md) §0

---

## 0. 이것이 매칭 트랙의 본래 목적이다

```
tryMatchDepositUnified (@Transactional 하나)
  ① findMatchableWithdrawOrdersForUpdate   출금 주문 후보 FOR UPDATE
  ② lockForMatch                           p2p_partner_locks FOR UPDATE
  ③ createMatch × N                        P2P 레그 확정
  ④ fillRemainderWithTorq                  ← 외부 동기 HTTP. 잠금 보유 중
  ⑤ COMMIT                                  여기서야 잠금 해제
```

InnoDB 는 `FOR UPDATE` 행 잠금을 커밋까지 유지한다. TORQ 가 느리면 후보 출금 주문과 파트너 잠금 행이 그동안 묶이고 **다른 구매자의 매칭이 대기한다.** 코드에 TODO 로 이미 적혀 있다.

최근 30일 주문의 **99.3% 가 TORQ 단독**이다. 즉 거의 모든 매칭이 이 구간을 지난다.

---

## 1. 정책은 바꾸지 않는다 — 이미 코드에 있다

착수 전에 오해를 없애 둔다. "TORQ 실패 시 어떻게 할 것인가"는 **이미 결정돼 있다.**

```java
/**
 * 통합 매칭 잔여 미충족 → 명시적 실패. 이 트랜잭션에서 생성한 P2P leg를 보상 취소(CANCELLED+unlock)하고
 * 주문을 FAILED 처리한다. (TORQ leg는 이 경로 진입 전 미생성이므로 대상 아님)
 */
private void failUnifiedOrder(P2pDepositOrder order)
```

**`failUnifiedOrder` 는 트랜잭션 롤백에 기대지 않는다.** 레그를 `CANCELLED` 로 바꾸고 `unlock` 을 명시적으로 호출한다. 그래서 P2P 레그가 이미 커밋된 뒤에 불러도 똑같이 동작한다.

그리고 `tryMatchDeposit` 의 불변식이 이것이다.

```
전액 커버 가능할 때만 매칭을 생성한다 (입금 주문 부분 잔여 금지)
```

**부분 성립은 금지다.** 이 지침서는 그 불변식을 유지한다. 바꾸는 것은 트랜잭션 경계뿐이다.

---

## 2. 멱등성 — 확인 완료 (2026-08-15) · 순서를 뒤집어 해결한다

### 2.1 무엇이 없는가

```java
// TorqService.java:205
String idempotencyKey = "cm-" + partnerId + "-" + partnerUserId + "-" + System.currentTimeMillis();
```

**멱등키 필드는 있는데 값이 시각 기반이다.** 같은 주문의 재호출이 반드시 다른 키가 되므로 멱등성이 0이다. 우리 쪽 선검사도 없고, `p2p_match_id` 는 `createTrade` 가 돌아온 **뒤에** 생기므로 선검사 키로 쓸 수도 없다.

방증: `TorqClient` 는 `acceptTrade` 에 409 무시(`:205`), `submitTransfer` 에 409/502/504 무시(`:242-262`)를 넣어 뒀는데 **`createTrade` 에만 없다**(`:157-175` 는 에러를 그대로 던진다).

지금은 외부 호출이 트랜잭션 안이라 재시도 경로 자체가 없어 가려져 있다. **T2-c 가 그 창을 연다.**

### 2.2 해결 — 레그를 먼저 만들고 escrow 를 나중에 채운다

```
현재   createTrade → escrow 받음 → 레그 생성        선점할 키가 없다
변경   레그 예약 생성 → createTrade → escrow 채움   p2p_match_id 를 먼저 잡는다
```

레그를 먼저 만들면 `TorqTradeRepository.findByP2pMatchId` 로 선검사가 되고, DB 가 두 번째를 거부한다. **T1-a 출금 원장의 `LOCK`(예약) → `SETTLE`(확정)과 같은 모양이다.**

### 2.3 DB 방어선 — 적용 완료

```sql
UNIQUE KEY uk_torq_live_match
  ((CASE WHEN status IN ('CANCELLED','EXPIRED','FAILED') THEN NULL ELSE p2p_match_id END))
```

**단순 `UNIQUE(p2p_match_id)` 가 아니다.** 취소 후 재생성이 정당한 운영 절차이기 때문이다 — 매칭 855(esc810 `CANCELLED` → esc812 `COMPLETED`)가 2026-07-22 금액 불일치 보정의 정상 이력이다. 종결 상태를 인덱스에서 빼서 **"살아 있는 escrow 는 매칭당 하나"** 만 강제한다.

운영 적용 완료 · 855 두 행 보존 확인 · 살아 있는 중복 0.

### 2.4 멱등키도 안정값으로 바꾼다

`System.currentTimeMillis()` 를 **매칭 코드 기반**으로 교체한다.

```
cm-{matchCode}      예: cm-pm_4f0a5f58d7f8
```

레그가 먼저 생기므로 이 값을 쓸 수 있다. TORQ 가 이 키로 멱등을 보장하는지는 아직 미확인이나, **우리 쪽 방어선(§2.3)이 이미 닫혀 있으므로 이번 작업의 전제가 아니다.** 안정값으로 바꿔 두면 TORQ 측 멱등이 나중에 켜질 때 그대로 동작한다.

> ⚠️ 기존 키 포맷(`cm-{partnerId}-{partnerUserId}-{epochMs}`)에 의존하는 TORQ 측 로직이 있는지는 **확인할 수 없으므로 바꾸되 보고하라.** 형식이 `cm-` 접두사를 유지하므로 파싱 기반이 아니면 안전하다.

---

## 3. 3단계 분할

```
Phase 1 (tx)     P2P 레그 확정 + 잠금            → COMMIT (잠금 해제)
Phase 2 (tx 밖)  TORQ getQuote / createTrade     → 외부 HTTP
Phase 3 (tx)     TORQ 레그 저장 + MATCHED
                 실패 → failUnifiedOrder
```

### 3.1 Phase 1 이 끝났을 때의 상태

```
p2p_matches        P2P 레그 CREATED (잠금 보유)
p2p_deposit_orders MATCHING 유지 · remaining_amount = 잔여
```

**`MATCHED` 로 올리지 않는다.** 잔여가 안 채워졌으므로 아직 성립이 아니다. 기존 `MATCHING` 의미(= 매칭 진행 중)와 정확히 일치한다.

> P2P 레그가 0건이면(TORQ 단독 주문 — 99.3%) Phase 1 이 아무 잠금도 잡지 않으므로 되돌릴 것도 없다.

### 3.2 Phase 3 성공 경로

TORQ 레그 생성 + 양방향 링크 + `MATCHED` + `remaining_amount = 0`. 지금 `fillRemainderWithTorq` 가 하는 일 그대로다.

### 3.3 Phase 3 실패 경로

```
TORQ 실패 → fillRemainderWithPartner 시도 (기존 우선순위 유지)
          → 그것도 실패 → failUnifiedOrder(order)
```

**`failUnifiedOrder` 를 그대로 쓴다.** Phase 1 이 커밋됐어도 명시적 취소라 동작이 같다.

### 3.4 중간 상태 고아 — 회수 경로를 명시할 것

Phase 1 커밋 후 Phase 2/3 전에 프로세스가 죽으면 **P2P 레그가 `CREATED` 로 잠금을 쥔 채 남는다.**

기존 회수 장치가 이미 있다.

```
P2pMatchExpiryJob (30초)   CREATED 매칭이 expires_at 경과 시 FAILED + unlock
P2pMatchingZombieReaper    MATCHING 주문을 claimed_at 기준 PENDING 복귀 (T2-b)
```

**이 둘이 실제로 이 고아를 잡는지 코드로 확인하고 보고하라.** 안 잡히는 구멍이 있으면 보고만 하고 새 잡을 만들지 말 것.

---

## 4. 어디에 코드를 두는가 — **호출부 경계까지가 범위다**

`tryMatchDepositUnified` 를 셋으로 쪼갠다. 트랜잭션 경계는 T2-b 의 `P2pAsyncMatchingService` 처럼 public 메서드 분리 + 프록시 경유로 잡는다(자기호출은 `@Transactional` 이 안 먹는다).

> ⚠️ **초안의 "`P2pMatchingService` 안에서 한다"는 부족했다.** 호출부 두 곳이 자체 `@Transactional` 이라 안에서만 쪼개면 **Phase 2 가 여전히 바깥 트랜잭션 안**이다.
> ```
> P2pDepositService.createAndMatch:96            @Transactional
> P2pAsyncMatchingService.runMatchAttempt:117    @Transactional
> ```
> 동기 경로는 방금 INSERT 한 `p2p_deposit_orders` 행 잠금을 커밋까지 쥔다. **호출부 경계도 함께 손봐야 한다** — `runMatchAttempt` 의 `@Transactional` 제거, `createAndMatch` 는 주문 생성 커밋 후 매칭 호출.

동기 경로와 워커 경로 **둘 다** 새 3단계를 타야 한다. 한쪽만 바꾸면 스위치 ON/OFF 로 동작이 갈린다.

> ⚠️ 동기 경로는 사용자 요청 스레드다. Phase 2 가 거기서 돌면 **응답이 그만큼 늦어진다** — 지금과 같다(개선이 없을 뿐 악화도 아니다). 진짜 이득은 스위치 ON 이후에 난다. 그래도 **잠금은 이미 풀린 뒤**라 다른 구매자를 막지 않는다. 이것이 이 단계의 핵심 성과다.

---

## 5. 하지 말 것

```
✘ 부분 성립 허용                          불변식 위반 (입금 주문 부분 잔여 금지)
✘ failUnifiedOrder 대체·수정              이미 보상 취소다. 그대로 쓴다
✘ 후보 조회 쿼리 수정                     T1-c 와 같은 쿼리. 두 번 고치게 된다
✘ 재매칭 부활                             2026-06-16 폐기
✘ 새 회수 잡 신설                         기존 만료 잡 + 좀비 회수로 충분한지 먼저 확인
✘ TORQ 멱등키 임의 신설                   §2 확인 후 오케스트레이터 판단
✘ .gitlab-ci.yml 수정
```

---

## 6. 완료 기준

```
□ §2 createTrade 멱등성 확인 결과 보고 (이게 먼저다)
□ tryMatchDepositUnified 3단계 분할
□ Phase 1 커밋 후 FOR UPDATE 잠금 보유 0 — 외부 호출 시점에 잠금이 없을 것
□ 동기 경로 · 워커 경로 둘 다 같은 3단계를 탈 것
□ 실패 시 failUnifiedOrder 그대로 호출
□ §3.4 고아 회수 경로 확인 결과 보고
□ ./gradlew build -x test 그린
```

## 7. 즉시 반려

```
MyBatis @Select <script> 안의  <  ·  <=  ·  <>
Phase 2(외부 호출)가 트랜잭션 안에 남아 있음      ← 이 작업의 전부다
동기 경로와 워커 경로의 동작이 다름
부분 성립(remaining > 0 인데 MATCHED)
```

## 8. 검증 (배포 후)

```sql
-- 매칭 소요 시간 분포 — 잠금 보유 구간이 줄었는지
SELECT TIMESTAMPDIFF(SECOND, o.created_at, m.created_at) 초, COUNT(*)
  FROM p2p_deposit_orders o JOIN p2p_matches m ON m.deposit_order_id = o.id
 WHERE o.created_at >= '<배포시각>' GROUP BY 1 ORDER BY 1;

-- 고아 레그 (Phase 1 만 끝나고 멈춘 것)
SELECT m.id, m.match_code, m.status, m.created_at, o.status 주문상태
  FROM p2p_matches m JOIN p2p_deposit_orders o ON o.id = m.deposit_order_id
 WHERE m.status = 'CREATED' AND o.status = 'MATCHING'
   AND m.created_at < NOW() - INTERVAL 5 MINUTE;
-- 0 이어야 한다

-- TORQ escrow 중복 (멱등성 결함 시 여기서 드러난다)
SELECT p2p_match_id, COUNT(*) FROM torq_trades
 WHERE created_at >= '<배포시각>' AND p2p_match_id IS NOT NULL
 GROUP BY 1 HAVING COUNT(*) > 1;
-- 0행이어야 한다
```
