# CODEF 빠른조회 연동 구현 지침서

**Status:** Active · 2026-06-10
**Source:** laas-chain `main-server` CODEF 7개 파일 (V11~V117 운영 검증)
**Target:** cryptoments `common/client/codef/` + `core/p2p/P2pScrapingService`

---

## 0. 목적

P2P 매칭에서 **입금자(B)가 출금자(A) 계좌로 KRW 송금 완료 여부를 자동 확인**하기 위해
CODEF 빠른조회 API를 연동한다. Phase 3 마지막 TODO인 `P2pScrapingService.verify()` 구현.

---

## 1. 파일 구조

### 1.1 신규 생성 (common 모듈)

```
common/src/main/java/com/cryptoments/common/client/codef/
├── CodefAuthService.java          — OAuth 2.0 토큰 발급/캐싱
├── CodefRsaCipher.java            — 비밀번호 RSA-2048 암호화
├── CodefFastInquiryClient.java    — 빠른조회 HTTP 호출
├── CodefFastInquiryRequest.java   — 요청 DTO
├── CodefFastInquiryResponse.java  — 응답 DTO (결과코드 + 거래내역)
└── CodefTransaction.java          — 거래내역 1건 DTO
```

### 1.2 수정 대상

| 파일 | 변경 내용 |
|------|----------|
| `core/p2p/P2pScrapingService.java` | verify() TODO → 실제 CODEF 호출 구현 |
| `common/exception/ErrorCodes.java` | CODEF 관련 에러코드 추가 |
| `application.yml` (각 모듈) | CODEF 설정 프로퍼티 추가 |

---

## 2. CODEF API 특이점 (laas-chain 운영 경험)

### 2.1 URL-encode 규약

```
Request:  POST body = URLEncode(JSON문자열)
Response: body = URLEncoded(JSON문자열)  → URLDecode 후 JSON 파싱
```

### 2.2 비밀번호 RSA 암호화

```
password, accountPassword, fastPassword 필드 → CODEF 공개키(RSA-2048)로 암호화
패딩: PKCS#1 v1.5 (RSA/ECB/PKCS1Padding)
빈 값("")은 빈 문자열 그대로 전송 (암호화하지 않음)
```

### 2.3 OAuth 2.0

```
POST {oauth-url}
Authorization: Basic Base64(clientId:clientSecret)
Content-Type: application/x-www-form-urlencoded
Body: grant_type=client_credentials&scope=read

토큰 유효: 7일. 만료 1시간 전 자동 갱신 (캐싱).
```

### 2.4 결과 코드

```
CF-00000  = 성공 (거래내역 0건도 성공)
CF-03002  = 유효하지 않은 자격증명
CF-12100  = 은행 점검 중
CF-13000  = 잔액조회 기간 초과
CF-TRANSPORT = 네트워크 오류 (우리 측 분류)
CF-DECODE    = 응답 디코딩 실패 (우리 측 분류)
CF-PARSE     = JSON 파싱 실패 (우리 측 분류)
```

---

## 3. 설정 프로퍼티

```yaml
cryptoments:
  codef:
    api-mode: DEMO          # DEMO / PRODUCTION
    base-url-demo: https://development.codef.io
    base-url-prod: https://api.codef.io
    oauth-url: https://oauth.codef.io/oauth/token
    client-id: ${CODEF_CLIENT_ID:}
    client-secret: ${CODEF_CLIENT_SECRET:}
    public-key: ${CODEF_PUBLIC_KEY:}    # RSA-2048 X.509 DER (Base64)
    token-refresh-margin-seconds: 3600  # 만료 1시간 전 갱신
```

---

## 4. 구현 상세

### 4.1 CodefAuthService

**역할:** CODEF OAuth 2.0 access token 발급 + 메모리 캐싱.

```java
@Component
public class CodefAuthService {
    // 설정
    @Value("${cryptoments.codef.oauth-url}")     private String oauthUrl;
    @Value("${cryptoments.codef.client-id:}")    private String clientId;
    @Value("${cryptoments.codef.client-secret:}") private String clientSecret;
    @Value("${cryptoments.codef.token-refresh-margin-seconds:3600}") private long refreshMarginSeconds;

    // 캐시
    private volatile String cachedToken;
    private volatile Instant cachedExpiresAt = Instant.EPOCH;
    private final ReentrantLock refreshLock = new ReentrantLock();

    public String getAccessToken() {
        // double-check locking: 만료 임박 시 refreshLock 획득 후 재발급
        // POST oauthUrl, Authorization: Basic Base64(clientId:clientSecret)
        // grant_type=client_credentials, scope=read
        // 응답: { "access_token": "...", "expires_in": 604800 }
    }
}
```

**포인트:**
- RestTemplate 직접 사용 (Spring RestClient 와 혼용 가능하나 laas-chain 패턴 유지)
- clientId/clientSecret 미설정 시 `IllegalStateException` (부팅 OK, 호출 시 실패)

### 4.2 CodefRsaCipher

**역할:** CODEF 공개키로 비밀번호류 필드 RSA 암호화.

```java
@Component
public class CodefRsaCipher {
    @Value("${cryptoments.codef.public-key:}") private String publicKeyBase64;
    private PublicKey publicKey;

    @PostConstruct void init() {
        // Base64 → DER → X509EncodedKeySpec → RSA PublicKey
        // 미설정 시 경고 (부팅 OK, 암호화 호출 시 실패)
    }

    public String encrypt(String plaintext) {
        // Cipher.getInstance("RSA/ECB/PKCS1Padding")
        // null/빈문자열 → "" 반환
    }
}
```

### 4.3 CodefFastInquiryClient

**역할:** CODEF 빠른조회 HTTP 호출 (핵심).

```java
@Component
public class CodefFastInquiryClient {
    private static final String PATH = "/v1/kr/bank/p/fast-account/transaction-list";

    private final CodefAuthService authService;
    private final CodefRsaCipher rsaCipher;

    @Value("${cryptoments.codef.api-mode:DEMO}")     private String apiMode;
    @Value("${cryptoments.codef.base-url-demo}")      private String demoBaseUrl;
    @Value("${cryptoments.codef.base-url-prod}")      private String prodBaseUrl;

    public CodefFastInquiryResponse inquire(CodefFastInquiryRequest req) {
        // 1. ObjectNode 빌드 (비밀번호류 → rsaCipher.encrypt())
        // 2. JSON → URLEncode
        // 3. POST {baseUrl}/v1/kr/bank/p/fast-account/transaction-list
        //    Header: Authorization: Bearer {token}, Content-Type: application/json
        //    Body: URL-encoded JSON string
        // 4. Response body → URLDecode → JSON parse
        // 5. result.code 확인 → CodefFastInquiryResponse 빌드
    }

    /** 자격증명 Map → CodefFastInquiryRequest 변환 헬퍼 */
    public static CodefFastInquiryRequest buildRequest(
            String organization, String account, String identity,
            Map<String, Object> decryptedCreds, String startDate, String endDate) {
        // decryptedCreds에서 accountPassword, fastId, fastPassword 추출
    }
}
```

**CODEF Request body 필드:**

| 필드 | 패턴 | 설명 |
|------|------|------|
| organization | 전체 | CODEF 기관코드 (banks.vendor_organization) |
| account | 전체 | 계좌번호 |
| accountPassword | A, B, D | 계좌비밀번호 4자리 (RSA 암호화) |
| identity | A | 주민번호앞6자리 또는 사업자번호10자리 |
| fastId | D | 빠른조회 ID (RSA 암호화) |
| fastPassword | D | 빠른조회 비밀번호 (RSA 암호화) |
| startDate / endDate | 전체 | YYYYMMDD |
| orderBy | 전체 | "0"=최신순 |

### 4.4 CodefFastInquiryRequest / Response / Transaction

laas-chain과 동일한 구조를 사용. 패키지만 변경:
- `io.laas.service.scraping.codef.*` → `com.cryptoments.common.client.codef.*`

### 4.5 P2pScrapingService.verify() 구현

```java
@Transactional
public String verify(String credentialId, LocalDateTime fromDate, LocalDateTime toDate) {
    ScrapingCredential credential = getCredential(credentialId);
    // 상태 검증: ACTIVE만 허용

    Bank bank = bankRepository.findByCode(credential.getBankCode());
    // bank == null || vendorOrganization == null → 에러

    // 1) 자격증명 복호화
    String credentialJson = decrypt(credential.getEncryptedBlob());
    Map<String, Object> creds = objectMapper.readValue(credentialJson, Map.class);

    // 2) identity 추출 (A_TYPE0 패턴만 사용)
    String identity = creds.containsKey("identity") ? String.valueOf(creds.get("identity")) : null;

    // 3) CODEF 요청 빌드
    String startDate = fromDate.format(YYYYMMDD);
    String endDate = toDate.format(YYYYMMDD);
    CodefFastInquiryRequest req = CodefFastInquiryClient.buildRequest(
            bank.getVendorOrganization(),
            credential.getAccountNumber(),
            identity, creds, startDate, endDate);

    // 4) 호출 + 시간 측정
    LocalDateTime startedAt = LocalDateTime.now();
    CodefFastInquiryResponse res = codefClient.inquire(req);
    long latencyMs = Duration.between(startedAt, LocalDateTime.now()).toMillis();

    // 5) 결과코드 정규화
    String resultCode = normalizeResultCode(res.getCode());
    String vendorRawCode = res.getCode();

    // 6) call_logs 기록
    String callId = UUID.randomUUID().toString();
    ScrapingCallLog callLog = ScrapingCallLog.builder()
            .callId(callId)
            .credentialId(credentialId)
            .bankCode(credential.getBankCode())
            .vendorUsed("CODEF")
            .resultCode(resultCode)
            .vendorRawCode(vendorRawCode)
            .latencyMs((int) latencyMs)
            .txnCount(res.getTransactions().size())
            .failoverUsed(false)
            .startedAt(startedAt)
            .completedAt(LocalDateTime.now())
            .build();
    callLogRepository.save(callLog);

    // 7) credential 상태 갱신
    if ("OK".equals(resultCode)) {
        credential.setLastVerifiedAt(LocalDateTime.now());
        credential.setLastErrorCode(null);
        credential.setLastErrorAt(null);
    } else {
        credential.setLastErrorCode(resultCode);
        credential.setLastErrorAt(LocalDateTime.now());
    }
    credentialRepository.modify(credential);

    return callId;
}

private String normalizeResultCode(String codefCode) {
    if ("CF-00000".equals(codefCode)) return "OK";
    if (codefCode != null && codefCode.startsWith("CF-03")) return "INVALID_CREDENTIAL";
    if ("CF-12100".equals(codefCode)) return "BANK_MAINTENANCE";
    if ("CF-TRANSPORT".equals(codefCode)) return "TRANSPORT_ERROR";
    return "VENDOR_ERROR";
}
```

### 4.6 verify() 확장 — 거래내역 반환

현재 verify()는 callId만 반환하지만, 스케줄러(P2pScrapingVerifyJob)가 실제 매칭에 사용하려면 거래내역도 필요.

**옵션 A (추천):** verify()가 VerifyResult DTO 반환

```java
public record VerifyResult(
    String callId,
    String resultCode,
    List<CodefTransaction> transactions
) {}
```

**옵션 B:** call_logs에 거래내역 JSON 저장 (call_logs 테이블 확장 필요)

→ 옵션 A 추천. 거래내역은 일회성 데이터이므로 DB 저장 불필요. 호출자가 즉시 매칭에 사용.

---

## 5. 자격증명 패턴별 JSON 스키마

credentials.encrypted_blob 에 저장되는 평문 JSON:

| 패턴 | JSON 키 | 은행 |
|------|---------|------|
| A_TYPE0 | `{ "accountPassword": "1234", "identity": "900101", "identityType": "PERSONAL" }` | 14개 은행 |
| B_NO_IDENTITY | `{ "accountPassword": "1234" }` | iM뱅크 |
| C_FAST_ONLY | `{ "fastId": "myid", "fastPassword": "mypw" }` | (정의만, 활성 0) |
| D_FAST_PLUS_ACCT | `{ "accountPassword": "1234", "fastId": "myid", "fastPassword": "mypw" }` | KB, 신한 |

**검증 규칙:**
- accountPassword: 4자리 숫자
- identity (PERSONAL): 6자리 숫자 (YYMMDD)
- identity (BUSINESS): 10자리 숫자
- fastId / fastPassword: 4~32자 영숫자

---

## 6. application.yml 설정 위치

CODEF 호출은 `core` 모듈의 P2pScrapingService에서 발생하고, `common`의 CodefAuthService 등이 빈으로 등록되므로:

- `open-api/src/main/resources/application.yml` — Widget 에서 계좌 등록 시 verify
- `scheduler/src/main/resources/application.yml` — P2pScrapingVerifyJob 에서 주기적 verify
- `partner-api/src/main/resources/application.yml` — 파트너 관리 화면에서 수동 verify

각 모듈에 동일한 CODEF 설정 블록 필요. `application-common.yml` 분리 또는 환경변수 통일.

---

## 7. 은행 점검시간 사전 차단 (선택)

bank_maintenance_windows 테이블을 활용해 verify() 호출 전 점검 시간 체크.
Phase 3에서는 선택사항이나, 불필요한 CODEF 호출 방지 + 에러율 감소 효과.

```java
// BankMaintenanceService (선택 구현)
public boolean isInMaintenance(String bankCode) {
    // bank_maintenance_windows에서 현재 KST 시각이 점검 범위 내인지 확인
}
```

---

## 8. laas-chain → cryptoments 매핑 요약

| laas-chain | cryptoments | 비고 |
|------------|-------------|------|
| `io.laas.service.scraping.codef.*` | `com.cryptoments.common.client.codef.*` | 패키지 이동 |
| `Seller.scrapingCredentialsEnc` | `ScrapingCredential.encryptedBlob` | 별도 테이블로 분리 |
| `Seller.bankAccountNumber` | `ScrapingCredential.accountNumber` | |
| `Seller.bankCode` | `ScrapingCredential.bankCode` | |
| `BankCode.codefOrganization` | `Bank.vendorOrganization` | |
| `AesCipher` (공유 빈) | `P2pScrapingService.encrypt/decrypt` (인라인) | 이미 구현됨 |
| `CodefCredentialService` | P2pScrapingService.storeCredential 확장 | 패턴별 검증 강화 |
| `CodefVerificationService` | P2pScrapingService.verify | 통합 |
| `laas.codef.*` 프로퍼티 | `cryptoments.codef.*` 프로퍼티 | |
| `RestTemplate` | `RestTemplate` (또는 RestClient) | CODEF URL-encode 특성상 RestTemplate 유지 권장 |

---

## 9. 구현 순서

1. **common/client/codef/** 6개 파일 생성 (DTO → Cipher → Auth → Client)
2. **P2pScrapingService** 에 CodefFastInquiryClient 주입 + verify() 구현
3. **application.yml** CODEF 설정 추가 (DEMO 모드)
4. **빌드 검증** (`./gradlew :common:compileJava :core:compileJava`)
5. (후속) P2pScrapingVerifyJob 에서 verify + 거래내역 매칭 연동
6. (후속) storeCredential에 패턴별 검증 강화 (CodefCredentialService 패턴)
