# T2-b 구현 지침서 — 매칭 워커 (`matching-worker` 모듈)

작성 2026-08-15 · 대상: Cowork 서브에이전트
선행: T2-a 주문 상태머신 (`074b4b8`) · T1-a 출금 원장 (`8702cda`)
설계: [P2P_ASYNC_MATCHING_DESIGN.md](./P2P_ASYNC_MATCHING_DESIGN.md) §2·§5 · 계획서 [P2P_REDESIGN_IMPLEMENTATION_PLAN.md](./P2P_REDESIGN_IMPLEMENTATION_PLAN.md) §2-1 T2-b

---

## 0. 이 단계의 계약

**배포해도 아무것도 바뀌지 않아야 한다.** 스위치 기본값이 `false` 이고, `false` 이면 동기 매칭이 지금 그대로 돈다. 워커는 배포되되 유휴 상태다.

```
p2p.async-matching.enabled = false   (기본 — 동작 불변)
                           = true    (워커가 집기 시작)
```

롤백은 값만 되돌리면 된다. 단 **워커를 먼저 내리지 말 것** — 전환 중 생성된 `PENDING` 주문을 마저 처리해야 한다.

### 왜 매칭이 지금 위험한가

`createAndMatch` 의 `@Transactional` 하나가 전부를 감싸고, 그 안에서 **비관적 잠금을 쥔 채 외부 HTTP 를 호출**한다.

```
① p2p_deposit_orders INSERT
② findMatchableWithdrawOrdersForUpdate   출금 주문 최대 10건 FOR UPDATE
③ findPartnerLockForUpdate               파트너 잠금 행 FOR UPDATE
④ TORQ getQuote / createTrade            외부 동기 HTTP   ← 잠금 보유 중
⑤ p2p_matches INSERT
⑥ COMMIT                                  ← 여기서야 잠금 해제
```

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

**지금 문제가 안 보이는 이유는 P2P 유동성이 적어서다.** 물량이 붙고 TORQ 가 한 번 느려지면 매칭이 줄줄이 밀린다.

> 외부 호출을 잠금 밖으로 빼는 것은 **T2-c** 다. 이번에는 큐잉만 만든다.

---

## 1. 모듈 신설

```
matching-worker/
  build.gradle
  src/main/java/com/cryptoments/matchingworker/
    MatchingWorkerApplication.java
    worker/P2pMatchingWorker.java
    worker/P2pMatchingZombieReaper.java
  src/main/resources/
    application.yml · application-dev.yml · application-prod.yml
```

`settings.gradle` 에 `include 'matching-worker'` 추가.

`build.gradle` 은 **`scheduler/build.gradle` 을 그대로 본뜬다.** `core` · `common` · Axim 3모듈 · spring-boot-starter-web · actuator · mybatis · mysql · jackson-jsr310.

> `spring-boot-starter-web` 을 빼지 말 것 — actuator 헬스체크가 필요하고, scheduler 도 같은 구성이다. 굳이 다르게 만들지 않는다.

`MatchingWorkerApplication` 은 `SchedulerApplication` 을 본뜨되 **컴포넌트 스캔 범위를 확인**할 것 (`@SpringBootApplication(scanBasePackages=...)` 로 `com.cryptoments` 를 잡고 있는지). MyBatis 매퍼 스캔 설정도 scheduler 와 동일하게.

### 1.1 ⚠️ 포트 충돌

`application-prod.yml` 의 `server.port` 를 **scheduler·open-api 와 겹치지 않게** 정한다. 두 서비스가 어느 포트를 쓰는지 먼저 확인하고 비어 있는 값을 쓸 것. 임의로 정하지 말고 보고할 것.

---

## 2. 스위치

이음매는 한 곳이다.

```java
// P2pDepositService.createAndMatch (~146)
.status(P2pDepositStatus.PENDING)
Long id = depositOrderRepo.save(order);
...
matchingService.tryMatchDeposit(order);   ← 스위치가 true 면 건너뛴다
return order;
```

`false` → 지금 그대로. `true` → `tryMatchDeposit` 호출을 건너뛰고 `PENDING` 상태로 반환한다. 워커가 집는다.

프로퍼티는 `core` 에서 `@Value("${p2p.async-matching.enabled:false}")` 로 읽는다. **기본값을 코드에 `false` 로 박아둔다** — yml 누락 시 안전한 쪽으로 떨어져야 한다.

호출부 2곳(`P2pWidgetController:178` · `partner-api P2pController:231`)과 `P2pDepositLinkService:172` 는 **손대지 않는다.** 응답 DTO 가 `PENDING` 을 그대로 실어 보내면 된다. 위젯 대기 화면은 T2-d 다.

---

## 3. claim — `FOR UPDATE SKIP LOCKED`

`common/mapper/P2pMatchingMapper` 에 추가한다.

```java
@Select("<script>"
      + "SELECT o.* FROM p2p_deposit_orders o "
      + "WHERE o.status = 'PENDING' "
      + "  AND o.expires_at &gt; NOW() "
      + "ORDER BY o.created_at ASC "
      + "LIMIT #{batchSize} "
      + "FOR UPDATE SKIP LOCKED"
      + "</script>")
List<P2pDepositOrder> claimPendingOrdersForUpdate(@Param("batchSize") int batchSize);
```

> ⚠️ `>` 는 그대로 써도 되지만 `<` `<=` `<>` 는 **절대 금지**다. XML 파싱돼 기동 시점에 `SAXParseException` 으로 전 서비스가 죽는다(2026-06-11 운영 장애). `&lt;` 또는 `<![CDATA[ ]]>` 를 쓸 것.
>
> `ORDER BY` / `LIMIT` 직접 작성 금지 규칙은 **`XPage` 반환 메서드에 해당**한다. 이 메서드는 `List` 반환이므로 명시해도 된다 — 같은 파일의 `findWaitingDepositOrdersForUpdate` 가 선례다.

집은 즉시 `MATCHING` + `claimed_at = NOW()` 으로 전이하고 **커밋한다.** 그 다음 주문별로 새 트랜잭션을 연다.

`SKIP LOCKED` 덕에 워커 인스턴스를 늘려도 같은 주문을 두 번 집지 않는다.

### 3.1 ✅ 확인 완료 (2026-08-15) — `MATCHING` 은 커밋 후에도 남는다

설계 문서는 "`MATCHING` 은 enum 에 있으나 현재 아무도 거치지 않는다"고 적었다. **틀렸다.**

```java
// P2pMatchingService:350-354  — 레거시 경로 유동성 부족
depositOrder.setStatus(P2pDepositStatus.MATCHING);
depositOrder.setRemainingAmount(remaining);
depositOrderRepo.modify(depositOrder);
```

이 뒤로 커밋까지 status 를 덮는 코드가 없다. 호출부는 `P2pDepositService.createAndMatch:146` 한 곳이고, `:158` 의 `modify` 는 `id`+`feeAmount` 부분 빌더라 status 를 포함하지 않는다.

**실측 0건이었던 이유는 코드가 안전해서가 아니라 토글이 켜져 있어서다.**

```
p2p.unified_matching_enabled      운영 = true    ← 통합 경로만 탄다
                                  코드 기본 = false ← 설정이 없으면 레거시가 기본
p2p.unified_matching_partner_ids  빈값 = 전체 적용
```

어드민 콘솔에서 상시 변경 가능하다(`P2pPolicyService:33,99`). **끄는 순간 (b) 경로가 산다.**

### 3.2 진짜 문제는 claim 이 아니라 실패 회계다

`claimed_at IS NOT NULL` 조건이 동기 경로와 워커 점유분을 갈라주므로 claim 자체는 안전하다. 깨지는 것은 **실패를 세는 방식**이다.

```
① tx A  claim: PENDING → MATCHING, claimed_at=NOW, 커밋
② tx B  tryMatchDeposit → 레거시 유동성 부족 → :352 가 MATCHING 세팅
        ★ 예외가 아니라 정상 반환 ★
③ 워커가 "성공" 으로 간주 → match_attempt_count 증가 없음
④ 5분 뒤 좀비 회수 → PENDING
⑤ ① 로 복귀 — expires_at(30분)까지 약 6회 순환
```

영구 고착은 아니다(만료 잡이 닫는다). 그러나 **임계 종결이 무력화된다** — 재매칭 루프를 막는 유일한 안전장치가 §7.1 의 `match_attempt_count` 인데, 그 카운터가 catch 블록 기반이라 "정상 반환한 미체결"을 못 센다. §11 감시 지표도 1건도 안 잡힌다.

### 3.3 결정 — 워커의 성공 판정은 **예외가 아니라 결과**로 한다

```
✘ try/catch 로만 실패를 센다        정상 반환한 미체결을 놓친다
✔ tx B 반환 후 주문 상태를 재조회    매칭이 붙었는가로 판정한다
```

`tryMatchDeposit` 내부는 손대지 않는다(§7 유지). 워커가 결과를 확인한다.

```
tx B 반환 후 재조회
  status 가 MATCHED / CANCELLED / 그 밖의 종결   → 성공. 카운터 건드리지 않음
  status 가 여전히 MATCHING (또는 PENDING)       → 미체결. match_attempt_count++
      임계 미만  → PENDING 복귀 + claimed_at=NULL (백오프)
      임계 초과  → closeDepositOrder(CANCELLED, 'MATCH_FAILED')
예외 발생                                        → 미체결과 동일 처리
```

이러면 통합/레거시 어느 경로든 같은 회계가 선다. **통합 경로에서는 미체결이 즉시 `CANCELLED`(`failUnifiedOrder`) 로 끝나므로 카운터가 애초에 돌지 않고, 레거시 경로에서만 유계 재시도가 걸린다.**

> **부수 관찰 — 통합 경로에서 재시도는 사실상 무의미하다.** 잔여 미충족이 즉시 `CANCELLED` 로 종결되므로 워커가 다시 볼 주문이 없다. 지금 운영은 통합이 켜져 있으니, 이 워커의 실질 가치는 재시도가 아니라 **사용자 요청 스레드에서 잠금과 외부 HTTP 를 걷어내는 것**이다(§0). 그것이 본래 목적이므로 문제는 아니지만, 재시도 파라미터를 성능 튜닝 지점으로 오해하지 말 것.

> ⚠️ 별도 백로그 — `p2p.unified_matching_enabled` 의 **코드 기본값이 `false`** 다. 설정이 없는 환경(신규 배포·로컬·스테이징)은 레거시로 떨어진다. 운영과 다른 경로를 타게 되므로 기본값 전환 또는 레거시 폐기를 별도로 판단해야 한다. **이번 범위 밖.**

---

## 4. 루프

```
poll(주기 1s)
  ① claim(batchSize)                          트랜잭션 A — 짧게, 즉시 커밋
  ② for each order:
       try:
           matchingService.tryMatchDeposit(order)   트랜잭션 B — 주문 1건
       catch: (아래 미체결과 동일 처리)

       ③ 결과 판정 — §3.3. 주문을 재조회한다               트랜잭션 C — 주문 1건
           종결/MATCHED  → 성공. 아무것도 하지 않는다
           MATCHING/PENDING 잔류 또는 예외 → 미체결
               match_attempt_count++
               임계 미만 → PENDING 복귀 + claimed_at = NULL
               임계 초과 → closeDepositOrder(CANCELLED, 'MATCH_FAILED')
```

**③ 을 트랜잭션 B 안에서 하지 말 것.** B 가 예외로 롤백된 경우에도 카운터는 올라가야 하는데, 같은 트랜잭션이면 카운터 증가까지 함께 롤백된다.

**주문 하나의 실패가 다른 주문을 막아서는 안 된다.** 배치 전체를 한 트랜잭션으로 묶지 말 것.

파라미터는 전부 프로퍼티로 뺀다.

```yaml
p2p:
  async-matching:
    enabled: false
    batch-size: 10
    poll-interval-ms: 1000
    max-attempts: 5
    zombie-timeout-minutes: 5
```

종결 시에는 **T2-a 의 `closeDepositOrder` 헬퍼를 쓴다.** 상태·`closed_at`·`close_reason` 을 직접 세팅하지 말 것.

---

## 5. 좀비 회수

워커가 죽으면 주문이 `MATCHING` 으로 남는다. 별도 잡이 회수한다.

```sql
UPDATE p2p_deposit_orders
   SET status = 'PENDING', claimed_at = NULL
 WHERE status = 'MATCHING'
   AND claimed_at IS NOT NULL
   AND claimed_at &lt; NOW() - INTERVAL #{timeoutMinutes} MINUTE
```

**`claimed_at IS NOT NULL` 조건이 핵심이다.** 동기 경로(§3.1)가 `MATCHING` 을 세팅하더라도 `claimed_at` 은 워커만 채우므로, 회수 대상이 워커 점유분으로 한정된다.

동기 경로에서 `MATCHING` 으로 남은 주문은 회수 대상이 아니지만 **갇히지는 않는다** — `P2pDepositLinkExpiryJob:69-87` 이 이름과 달리 링크 여부와 무관하게 전체 입금 주문을 30초 주기로 훑어 `expires_at` 경과분(`PENDING`/`MATCHING`/`MATCHED`)을 종결한다.

회수 잡의 주기는 `zombie-timeout` 의 1/5 수준(기본 1분)으로 둔다.

---

## 6. 배포 인프라 — **미결. 코드만 만들고 손대지 말 것**

새 모듈은 CI 잡·서버 프로세스·감시가 따라온다. 이번 작업 범위 밖이다.

| 항목 | 상태 |
|---|---|
| `.gitlab-ci.yml` 빌드/배포 잡 | **하지 말 것** — 오케스트레이터가 별도 판단 |
| 배포 서버 | 미정 (app-01 유력, 확정 아님) |
| systemd 유닛 | 미정 |
| Vigil agent | 미정 |

> ⚠️ `.gitlab-ci.yml` 의 각 잡은 `rules.changes` 경로 매칭 시에만 생성된다. `matching-worker/` 만 바뀐 push 는 기존 spring 잡을 만들지 않을 수 있다. 이번 push 에 다른 모듈 변경이 섞여 있는지 확인할 것.

---

## 7. 하지 말 것

```
✘ 외부 호출(TORQ)을 잠금 밖으로 빼기               → T2-c
✘ 위젯 대기 화면 · 폴링 전환                        → T2-d
✘ 후보 조회 쿼리(findMatchableWithdrawOrdersForUpdate 등) 수정
                                                    → T1-c 와 같은 쿼리다. 두 번 고치게 된다
✘ 게이트 4곳 통일                                   → T1-c
✘ 재매칭 부활 (findWaitingDepositOrdersForUpdate 를 살리지 말 것)
                                                    → 2026-06-16 에 루프 때문에 폐기된 기능이다.
                                                      match_attempt_count 임계 종결이 그 자리를 대신한다
✘ 스케줄러 spring.task.scheduling.pool.size 변경     → §8 참조
✘ tryMatchDeposit 내부 로직 변경                     → 호출 위치만 바꾼다
```

### 7.1 재매칭을 부활시키지 않는다

`findWaitingDepositOrdersForUpdate` 는 호출부 0건인 죽은 쿼리다. **PENDING 큐잉과 겉모습이 비슷해 보여도 다른 것이다.**

```
폐기된 재매칭   출금 주문이 새로 생기면 → 대기 중 입금 주문을 다시 훑는다  → 루프
이번 큐잉       주문 생성 시 PENDING → 워커가 한 번 집는다 → 임계 초과 시 종결
```

되살리지도, 지우지도 말 것. T2-b 범위 밖이다.

---

## 8. 스케줄러 스레드 풀을 건드리지 않는 이유 (2026-08-15 판단)

```
spring.task.scheduling.pool.size   설정 없음 → Spring Boot 기본값 1
@Scheduled 잡                       20개가 그 스레드 하나를 공유
```

풀을 올리면 지금까지 **암묵적으로 보장돼 온 직렬화가 사라진다.**

```
P2pMatchExpiryJob:69   scrapingVerifyJob.verifyMatch(match)
```

만료 잡이 스크래핑 잡의 로직을 직접 호출한다. 지금은 스레드가 하나라 둘이 동시에 `verifyMatch` 를 돌리지 않는다. 풀을 올리면 두 스레드가 같은 계좌를 동시에 스크래핑하는데, `P2pScrapingVerifyJob` 은 클래스 전체에 `@Transactional` 이 없어 `countRefUsageInAccountScope` 의 ref 중복소비 가드가 경계 없이 돈다. **같은 입금 ref 를 두 매칭이 소비할 수 있다** — 이중 확인이고 곧 이중 지급이다.

매칭 레벨 자체는 안전하다(`claimMatchForConfirm`/`Terminate`/`Dispute`/`Fail` 조건부 UPDATE). 뚫리는 것은 주문 레벨과 스크래핑 경로다.

**워커를 별도 모듈로 뺐으므로 스케줄러 부하가 늘지 않는다 — 지금 올릴 이유가 없다.** T3(입금 확인 테이블 `UNIQUE`)로 근본 방어선을 만든 뒤에 올린다.

---

## 9. 완료 기준

```
□ settings.gradle include · matching-worker/build.gradle
□ MatchingWorkerApplication + application{,-dev,-prod}.yml (포트 확인 후 보고)
□ p2p.async-matching.enabled 스위치 — createAndMatch 한 곳
□ claimPendingOrdersForUpdate (SKIP LOCKED) — < 문자 0개
□ 워커 루프: claim 트랜잭션 / 주문별 트랜잭션 분리
□ 좀비 회수 잡 (claimed_at IS NOT NULL 조건 필수)
□ 실패 시 match_attempt_count++ · 임계 초과 CANCELLED(closeDepositOrder 사용)
□ §3.1 MATCHING 재사용 가능 여부 확인 결과 보고
□ ./gradlew :matching-worker:compileJava :core:compileJava 그린
□ ./gradlew build 전체 그린 (새 모듈이 기존 빌드를 깨지 않는지)
```

## 10. 즉시 반려

```
MyBatis @Select <script> 안의  <  ·  <=  ·  <>
엔티티에 있는데 DDL 파일에 없는 컬럼
배치 전체를 한 트랜잭션으로 묶기
스위치 기본값이 true
.gitlab-ci.yml 수정
```

## 11. 스위치 ON 이후 감시 (배포 시점 참고)

| 지표 | 임계 |
|---|---|
| `PENDING` 적체 건수 | 10건 이상 지속 |
| `MATCHING` 체류 시간 | 1분 초과 |
| `match_attempt_count >= max` 건수 | 1건이라도 |
| 워커 프로세스 | Vigil agent |

```sql
-- 적체
SELECT status, COUNT(*), MIN(created_at) 최고참
  FROM p2p_deposit_orders WHERE status IN ('PENDING','MATCHING') GROUP BY status;

-- 좀비
SELECT order_code, claimed_at, TIMESTAMPDIFF(MINUTE, claimed_at, NOW()) 체류분
  FROM p2p_deposit_orders WHERE status='MATCHING' AND claimed_at IS NOT NULL;
```
