# Guide #39 — Spring Boot ↔ Node.js API 에러 핸들링 개선 지침서

**가이드 번호**: #39 (v2 — Guide #40 ERROR_PROPAGATION_GUIDE 기반 재작성)
**작성일**: 2026-03-22
**대상**: Node.js (blockchain-api, relayer-api), Spring Boot (core, admin-api, scheduler)
**선행 조건**: Axim REST Framework v1.2.3+ (Guide #40 Phase 1 적용 필수)
**권장 조건**: Axim REST Framework v1.3.0+ (현재 v1.3.1 — Guide #40 Phase 2 — XErrorResponseHandler)

---

## 1. 현재 문제 진단

### 1-1. Node.js 에러 응답 — 비표준, 4가지 패턴 혼재

전체 **88개** 에러 응답이 4가지 패턴으로 분산:

| 패턴 | 예시 | 사용 빈도 | 문제 |
|------|------|----------|------|
| A. 단순 error | `{ error: 'message' }` | 가장 많음 | code 없음 |
| B. error + detail | `{ error: '...', detail: msg }` | system-admin, nonce | 비표준 |
| C. error + 추가 필드 | `{ error: '...', address: ... }` | system-admin 409 | 필드 불일치 |
| D. Generic 500 | `{ error: 'Internal Server Error' }` | relayer-api catch-all | 원인 소실 |

### 1-2. Spring Boot — Node.js 에러 정보 접근 불가

`@XRestService` 프록시가 HTTP 에러 시 `XRestException`을 던지지만:

- **현재 (v1.2.2)**: `rawResponseBody` 없음 → Node.js 에러 메시지 유실
- **v1.2.3 (Guide #40 Phase 1)**: `rawResponseBody` 보존 → 수동 파싱 가능
- **v1.3.0 (Guide #40 Phase 2)**: `XErrorResponseHandler` Bean → 자동 파싱

### 1-3. 파일별 에러 응답 현황

**blockchain-api** (7 라우트, 59개 에러 응답):

| 라우트 | 400 | 404 | 409 | 500 | 합계 |
|--------|-----|-----|-----|-----|------|
| system-admin.ts | 14 | 6 | 1 | 11 | **32** |
| balance.ts | 3 | - | - | 3 | **6** |
| wallet.ts | 1 | - | - | 2 | **3** |
| tx.ts | 2 | - | - | 2 | **4** |
| gas.ts | 2 | - | - | 2 | **4** |
| nonce.ts | 1 | 1 | - | 1 | **3** |
| block.ts | 1 | - | - | 1 | **2** |

**relayer-api** (1 라우트, 29개 에러 응답):

| 라우트 | 400 | 404 | 500 | 합계 |
|--------|-----|-----|-----|------|
| relayer.ts | 5 | 4 | 11 | **20** |
| 글로벌 미들웨어 | - | 1 | 1 | **2** |

---

## 2. 개선 전략 — Framework 버전별 2단계

```
                              Framework v1.2.3              Framework v1.3.0
                              (Phase 1 적용 후)              (Phase 2 적용 후)
                              ─────────────────             ─────────────────
Node.js:  errors.ts 생성     →  동일                        →  동일
          미들웨어 개선       →  동일                        →  동일
          라우트 점진 개선     →  동일                        →  동일

Spring:   XRestException에서  →  rawResponseBody 수동 파싱   →  Bean 핸들러가 자동 파싱
          catch 후 처리           (임시 유틸 메서드)              (BlockchainApiErrorHandler)
```

**핵심**: Node.js 측 작업은 Framework 버전과 무관하게 **즉시** 시작 가능.
Spring 측은 Framework 업그레이드 후 적용 (v1.2.3이면 수동 파싱, v1.3.0이면 Bean 핸들러).

---

## 3. Node.js 측 개선

### 3-1. 표준 에러 모듈 생성

**파일(신규)**: `node-service/packages/common/src/errors.ts`

```typescript
/**
 * 표준 API 에러 응답 포맷.
 * Spring Boot의 XRestException.rawResponseBody로 전달되어 파싱됨.
 */
export interface ApiErrorResponse {
  /** 에러 코드 (머신 파싱용 — Spring에서 switch/비교에 사용) */
  code: string;
  /** 에러 메시지 (사람 읽기용) */
  error: string;
  /** 상세 정보 (선택 — 디버깅용) */
  detail?: string;
}

/**
 * 비즈니스 에러.
 * throw new AppError(400, ErrorCodes.INVALID_PARAMS, 'networkId is required')
 * → 글로벌 미들웨어가 catch → { code, error, detail } 응답
 */
export class AppError extends Error {
  constructor(
    public readonly statusCode: number,
    public readonly code: string,
    message: string,
    public readonly detail?: string,
  ) {
    super(message);
    this.name = 'AppError';
  }
}

/**
 * 표준 에러 응답 생성 헬퍼.
 * 기존 라우트에서 점진적으로 교체:
 *   Before: res.status(400).json({ error: 'networkId is required' })
 *   After:  res.status(400).json(errorResponse(ErrorCodes.INVALID_PARAMS, 'networkId is required'))
 */
export function errorResponse(code: string, error: string, detail?: string): ApiErrorResponse {
  return { code, error, ...(detail && { detail }) };
}

/**
 * 에러 코드 상수.
 * Spring Boot의 XRestException.getCode()에 매핑됨.
 */
export const ErrorCodes = {
  // ── 공통 ──
  INVALID_PARAMS: 'INVALID_PARAMS',
  NOT_FOUND: 'NOT_FOUND',
  CONFLICT: 'CONFLICT',
  INTERNAL_ERROR: 'INTERNAL_ERROR',

  // ── 지갑 ──
  WALLET_NOT_FOUND: 'WALLET_NOT_FOUND',
  WALLET_ALREADY_EXISTS: 'WALLET_ALREADY_EXISTS',
  HD_WALLET_NOT_FOUND: 'HD_WALLET_NOT_FOUND',
  KEY_NOT_FOUND: 'KEY_NOT_FOUND',

  // ── 잔액 ──
  INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE',
  BALANCE_QUERY_FAILED: 'BALANCE_QUERY_FAILED',

  // ── 트랜잭션 ──
  TX_NOT_FOUND: 'TX_NOT_FOUND',
  TX_FAILED: 'TX_FAILED',
  TX_SEND_FAILED: 'TX_SEND_FAILED',
  NONCE_CONFLICT: 'NONCE_CONFLICT',

  // ── 컨트랙트 ──
  CONTRACT_NOT_FOUND: 'CONTRACT_NOT_FOUND',
  CONTRACT_CALL_FAILED: 'CONTRACT_CALL_FAILED',

  // ── Relayer ──
  RELAYER_NOT_FOUND: 'RELAYER_NOT_FOUND',
  RELAYER_BUSY: 'RELAYER_BUSY',

  // ── 네트워크 ──
  NETWORK_NOT_SUPPORTED: 'NETWORK_NOT_SUPPORTED',
  RPC_ERROR: 'RPC_ERROR',
} as const;

export type ErrorCode = typeof ErrorCodes[keyof typeof ErrorCodes];
```

### 3-2. common/src/index.ts — export 추가

**파일**: `node-service/packages/common/src/index.ts`

```typescript
// 기존 exports 끝에 추가
export { AppError, errorResponse, ErrorCodes, type ApiErrorResponse, type ErrorCode } from './errors';
```

### 3-3. 글로벌 에러 미들웨어 개선

**blockchain-api/src/app.ts** 및 **relayer-api/src/app.ts** 둘 다 동일하게 변경:

기존:
```typescript
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
  logger.error('Unhandled error', err);
  res.status(500).json({ error: 'Internal Server Error' });
});
```

변경:
```typescript
import { AppError, errorResponse, ErrorCodes } from '@cryptoments/common';

// 404 핸들러
app.use((_req: express.Request, res: express.Response) => {
  res.status(404).json(errorResponse(ErrorCodes.NOT_FOUND, 'Not Found'));
});

// 글로벌 에러 핸들러
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
  // AppError — 비즈니스 에러 (라우트에서 throw된 것)
  if (err instanceof AppError) {
    logger.warn(`AppError: [${err.code}] ${err.message}`, { statusCode: err.statusCode });
    res.status(err.statusCode).json(errorResponse(err.code, err.message, err.detail));
    return;
  }

  // 시스템 에러 — 원본 메시지 포함 (Spring에서 rawResponseBody로 파싱 가능)
  logger.error('Unhandled error', err);
  res.status(500).json(errorResponse(
    ErrorCodes.INTERNAL_ERROR,
    err.message || 'Internal Server Error'
  ));
});
```

> **핵심 변경**: `'Internal Server Error'` 대신 `err.message`를 응답에 포함. 이것만으로 Spring에서 에러 원인 파악 가능.

### 3-4. 라우트 전환 전략 — 3단계 점진적

**전환 우선순위**: 글로벌 미들웨어 > 고빈도 라우트 > 나머지

#### 단계 1 — 즉시 (글로벌 미들웨어 + errors.ts)

위 3-1, 3-2, 3-3만 적용. 기존 라우트 코드는 그대로 둠.
이것만으로도 500 에러에 원본 메시지가 포함됨.

#### 단계 2 — 고빈도 라우트 (blockchain-api 핵심 6개 파일)

기존 패턴을 `errorResponse()` 헬퍼로 교체:

**wallet.ts** (3개):
```typescript
// Before
res.status(400).json({ error: 'networkId and hdWalletId are required' });
// After
res.status(400).json(errorResponse(ErrorCodes.INVALID_PARAMS, 'networkId and hdWalletId are required'));

// Before
res.status(500).json({ error: error.message });
// After
res.status(500).json(errorResponse(ErrorCodes.INTERNAL_ERROR, 'Wallet derive failed', error.message));
```

**balance.ts** (6개):
```typescript
// Before (400)
res.status(400).json({ error: 'address and networkId are required' });
// After
res.status(400).json(errorResponse(ErrorCodes.INVALID_PARAMS, 'address and networkId are required'));

// Before (500)
res.status(500).json({ error: error.message });
// After
res.status(500).json(errorResponse(ErrorCodes.BALANCE_QUERY_FAILED, 'Token balance query failed', error.message));
```

**nonce.ts** (3개):
```typescript
// Before (404)
res.status(404).json({ error: `Wallet not found: address=${address}, networkId=${networkId}` });
// After
res.status(404).json(errorResponse(ErrorCodes.WALLET_NOT_FOUND, `Wallet not found: address=${address}, networkId=${networkId}`));

// Before (500 — 이미 detail 패턴)
res.status(500).json({ error: 'Failed to retrieve on-chain nonce', detail: msg });
// After
res.status(500).json(errorResponse(ErrorCodes.RPC_ERROR, 'Failed to retrieve on-chain nonce', msg));
```

**tx.ts** (4개), **gas.ts** (4개), **block.ts** (2개) — 동일 패턴 적용.

#### 단계 3 — relayer-api + system-admin.ts

**relayer.ts** (20개): 특히 500 catch-all 11개가 `'Internal Server Error'` → `error.message`로 교체 필요:

```typescript
// Before — relayer.ts 500 catch-all (11곳)
catch (error) {
  logger.error('Relayer register error', error as Error);
  res.status(500).json({ error: 'Internal Server Error' });
}

// After — 원인 포함
catch (error) {
  const msg = error instanceof Error ? error.message : 'Unknown error';
  logger.error('Relayer register error', error as Error);
  res.status(500).json(errorResponse(ErrorCodes.INTERNAL_ERROR, 'Relayer register failed', msg));
}
```

**system-admin.ts** (32개): 가장 많은 에러 응답. 이미 `detail` 패턴 사용하는 11곳은 code만 추가:

```typescript
// Before — system-admin.ts (이미 detail 패턴)
res.status(500).json({ error: 'Native transfer failed', detail: msg });
// After
res.status(500).json(errorResponse(ErrorCodes.TX_SEND_FAILED, 'Native transfer failed', msg));

// Before — system-admin.ts (409 conflict)
res.status(409).json({ error: '이미 ADMIN 지갑이 존재합니다', existing: { ... } });
// After
res.status(409).json(errorResponse(ErrorCodes.WALLET_ALREADY_EXISTS, '이미 ADMIN 지갑이 존재합니다'));
```

#### 단계 4 — throw AppError 패턴 (향후)

```typescript
// 최종 형태 — throw로 통일, 글로벌 미들웨어가 처리
if (!networkId) {
  throw new AppError(400, ErrorCodes.INVALID_PARAMS, 'networkId is required');
}
```

> 현재는 라우트 내에서 직접 `res.status().json()` 응답하고 있고, `next(err)` 미사용.
> throw 패턴 전환은 `express-async-errors` 패키지 설치 후 적용 권장.

---

## 4. Spring Boot 측 개선

### 4-1. Framework v1.2.3 (Phase 1) — rawResponseBody 수동 파싱

Guide #40 Phase 1 적용 후, `XRestException.getRawResponseBody()`로 Node.js 에러를 직접 파싱 가능:

**파일(신규)**: `common/src/main/java/com/cryptoments/common/client/NodeApiErrorParser.java`

```java
package com.cryptoments.common.client;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import one.axim.framework.rest.exception.XRestException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Node.js API 에러 응답 파싱 유틸리티.
 *
 * <p>Framework v1.2.3의 rawResponseBody에서 Node.js 에러 정보를 추출한다.
 * Framework v1.3.0의 XErrorResponseHandler로 전환 시 이 클래스는 제거 가능.
 */
public final class NodeApiErrorParser {

    private static final Logger log = LoggerFactory.getLogger(NodeApiErrorParser.class);
    private static final ObjectMapper MAPPER = new ObjectMapper();

    private NodeApiErrorParser() {}

    /**
     * XRestException에서 Node.js 에러 코드 추출.
     * @return 에러 코드 (없으면 null)
     */
    public static String getErrorCode(XRestException e) {
        return parseField(e, "code");
    }

    /**
     * XRestException에서 Node.js 에러 메시지 추출.
     * @return 에러 메시지 (없으면 XRestException.getMessage())
     */
    public static String getErrorMessage(XRestException e) {
        String msg = parseField(e, "error");
        return msg != null ? msg : e.getMessage();
    }

    /**
     * XRestException에서 Node.js 에러 상세 추출.
     * @return detail 문자열 (없으면 null)
     */
    public static String getErrorDetail(XRestException e) {
        return parseField(e, "detail");
    }

    private static String parseField(XRestException e, String field) {
        String raw = e.getRawResponseBody();
        if (raw == null || raw.isBlank()) return null;
        try {
            JsonNode node = MAPPER.readTree(raw);
            JsonNode value = node.get(field);
            return value != null && !value.isNull() ? value.asText() : null;
        } catch (Exception ex) {
            log.debug("Node.js 에러 응답 파싱 실패: {}", raw);
            return null;
        }
    }
}
```

**서비스 사용 예시** (WalletService):

```java
// 기존
try {
    var resp = blockchainApiClient.deriveWallet(request);
} catch (Exception e) {
    log.warn("지갑 생성 실패: {}", e.toString(), e);  // 원인 불명
}

// 개선 (v1.2.3)
try {
    var resp = blockchainApiClient.deriveWallet(request);
} catch (XRestException e) {
    String code = NodeApiErrorParser.getErrorCode(e);       // "WALLET_ALREADY_EXISTS"
    String msg = NodeApiErrorParser.getErrorMessage(e);     // "이미 ADMIN 지갑이 존재합니다"
    String detail = NodeApiErrorParser.getErrorDetail(e);   // null

    log.warn("[{}] 지갑 생성 실패: status={}, code={}, error={}",
        e.getRemoteServiceName(), e.getStatus(), code, msg);

    // 비즈니스 로직 분기 가능
    if ("WALLET_ALREADY_EXISTS".equals(code)) {
        // 이미 존재 → skip
    } else {
        throw e;  // 다른 에러 → 전파
    }
}
```

### 4-2. Framework v1.3.0 (Phase 2) — Bean 핸들러 자동 파싱

Guide #40 Phase 2 적용 후, Bean 등록만으로 자동 에러 파싱:

**파일(신규)**: `common/src/main/java/com/cryptoments/common/client/BlockchainApiErrorHandler.java`

```java
package com.cryptoments.common.client;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import one.axim.framework.rest.exception.XRestException;
import one.axim.framework.rest.handler.XErrorResponseHandler;
import one.axim.framework.rest.model.ApiError;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;

/**
 * blockchain-api 전용 에러 핸들러.
 *
 * <p>Bean 이름 = @XRestService(value = "blockchain-api") + "-error-handler"
 * → Framework이 자동 매칭.
 *
 * <p>Node.js 에러 포맷 { code, error, detail } → Axim ApiError로 변환.
 */
@Component("blockchain-api-error-handler")
public class BlockchainApiErrorHandler implements XErrorResponseHandler {

    private static final ObjectMapper MAPPER = new ObjectMapper();

    @Override
    public XRestException handle(HttpStatus status, String responseBody) {
        if (responseBody == null || responseBody.isBlank()) return null;

        try {
            JsonNode node = MAPPER.readTree(responseBody);

            ApiError apiError = new ApiError();
            apiError.setCode(getTextOrNull(node, "code"));
            apiError.setMessage(getTextOrNull(node, "error"));
            apiError.setDescription(getTextOrNull(node, "detail"));

            return new XRestException(status, apiError, responseBody);
        } catch (Exception e) {
            return null;  // 파싱 실패 → 기본 핸들러로 폴백
        }
    }

    private String getTextOrNull(JsonNode node, String field) {
        JsonNode value = node.get(field);
        return value != null && !value.isNull() ? value.asText() : null;
    }
}
```

**파일(신규)**: `common/src/main/java/com/cryptoments/common/client/RelayerApiErrorHandler.java`

```java
package com.cryptoments.common.client;

import org.springframework.stereotype.Component;

/**
 * relayer-api 전용 에러 핸들러.
 * blockchain-api와 동일 포맷이므로 상속으로 처리.
 */
@Component("relayer-api-error-handler")
public class RelayerApiErrorHandler extends BlockchainApiErrorHandler {
}
```

**서비스 사용 예시** (v1.3.0 — 핸들러 자동 적용 후):

```java
// Bean 핸들러가 자동으로 Node.js 에러를 변환해주므로
// XRestException의 기본 getter로 바로 접근 가능
try {
    var resp = blockchainApiClient.deriveWallet(request);
} catch (XRestException e) {
    // 핸들러가 자동 변환한 결과:
    e.getStatus();             // 400 BAD_REQUEST (Node.js 원본 상태)
    e.getCode();               // "WALLET_ALREADY_EXISTS" (Node.js code 필드)
    e.getMessage();            // "이미 ADMIN 지갑이 존재합니다" (Node.js error 필드)
    e.getDescription();        // null (Node.js detail 필드)
    e.getRawResponseBody();    // {"code":"WALLET_ALREADY_EXISTS","error":"..."}
    e.getRemoteServiceName();  // "blockchain-api"
}
```

> **v1.3.0 적용 후 `NodeApiErrorParser` 제거 가능** — Bean 핸들러가 동일 기능을 자동으로 수행.

### 4-3. 서비스별 에러 핸들링 개선

#### WalletService — 잔액 동기화

```java
// 기존: catch (Exception e) — 모든 에러 swallow
catch (Exception e) {
    log.warn("잔액 동기화 실패: walletId={}, error={}", wallet.getId(), e.toString(), e);
}

// 개선: XRestException 분기
catch (XRestException e) {
    String code = e.getCode();  // v1.3.0: 핸들러가 자동 설정
    if ("NETWORK_NOT_SUPPORTED".equals(code) || "CONTRACT_NOT_FOUND".equals(code)) {
        log.debug("잔액 동기화 스킵: walletId={}, code={}", wallet.getId(), code);
    } else {
        log.warn("잔액 동기화 실패: walletId={}, service={}, status={}, code={}, error={}",
            wallet.getId(), e.getRemoteServiceName(), e.getStatus(), code, e.getMessage());
    }
} catch (Exception e) {
    log.warn("잔액 동기화 실패 (비 API 에러): walletId={}, error={}", wallet.getId(), e.toString(), e);
}
```

#### NonceTrackerService — Nonce 조회

```java
// 기존: catch (Exception e) — 연결 실패도 같은 처리
catch (Exception e) {
    log.error("온체인 Nonce 조회 실패: {}", e.getMessage(), e);
    throw new ExternalServiceException(ErrorCodes.EXTERNAL_SERVICE_ERROR.code(), e.getMessage());
}

// 개선: 상태별 분기
catch (XRestException e) {
    log.error("[{}] Nonce 조회 실패: status={}, code={}, error={}",
        e.getRemoteServiceName(), e.getStatus(), e.getCode(), e.getMessage());

    if (e.getStatus().is4xxClientError()) {
        // 파라미터 오류 → 그대로 전파
        throw e;
    } else {
        // 5xx 또는 연결 실패 → 외부 서비스 에러
        throw new ExternalServiceException(ErrorCodes.EXTERNAL_SERVICE_ERROR.code(),
            String.format("Nonce 조회 실패 (%s): %s", e.getCode(), e.getMessage()));
    }
}
```

### 4-4. SchedulerBlockchainApiClient 교체

현재 `scheduler` 모듈은 별도 `SchedulerBlockchainApiClient`(직접 HttpClient 사용)가 있음.
Framework 업그레이드 후 common의 `BlockchainApiClient` (`@XRestService`)를 직접 사용 가능.

**삭제**: `scheduler/src/main/java/com/cryptoments/scheduler/client/SchedulerBlockchainApiClient.java`

**변경**: `StaleTxMonitorJob.java`

```java
// 기존
private final SchedulerBlockchainApiClient blockchainApiClient;

// 개선 — common의 @XRestService 클라이언트 직접 사용
private final BlockchainApiClient blockchainApiClient;

// 에러 핸들링은 BlockchainApiErrorHandler Bean이 자동 처리
```

> scheduler 모듈의 `build.gradle`에 common 의존성이 이미 있으므로, `BlockchainApiClient`와 `BlockchainApiErrorHandler` 모두 자동으로 사용 가능.

---

## 5. 파일 변경 체크리스트

### Node.js 측 (Framework 버전 무관 — 즉시 적용)

| # | 파일 | 변경 | 단계 |
|---|------|------|------|
| 1 | `common/src/errors.ts` | **신규** — AppError, errorResponse, ErrorCodes | 1 |
| 2 | `common/src/index.ts` | errors.ts export 추가 | 1 |
| 3 | `blockchain-api/src/app.ts` | 글로벌 에러 미들웨어 개선 (AppError 분기 + err.message 포함) | 1 |
| 4 | `relayer-api/src/app.ts` | 동일 | 1 |
| 5 | `blockchain-api/src/routes/wallet.ts` | 3개 에러 응답 → `errorResponse()` 교체 | 2 |
| 6 | `blockchain-api/src/routes/balance.ts` | 6개 에러 응답 → `errorResponse()` 교체 | 2 |
| 7 | `blockchain-api/src/routes/nonce.ts` | 3개 에러 응답 → `errorResponse()` 교체 | 2 |
| 8 | `blockchain-api/src/routes/tx.ts` | 4개 에러 응답 → `errorResponse()` 교체 | 2 |
| 9 | `blockchain-api/src/routes/gas.ts` | 4개 에러 응답 → `errorResponse()` 교체 | 2 |
| 10 | `blockchain-api/src/routes/block.ts` | 2개 에러 응답 → `errorResponse()` 교체 | 2 |
| 11 | `blockchain-api/src/routes/system-admin.ts` | 32개 에러 응답 → `errorResponse()` 교체 | 3 |
| 12 | `relayer-api/src/routes/relayer.ts` | 20개 에러 응답 → `errorResponse()` 교체 | 3 |

### Spring Boot 측 — Framework v1.2.3 (Phase 1)

| # | 파일 | 변경 |
|---|------|------|
| 13 | `common/.../client/NodeApiErrorParser.java` | **신규** — rawResponseBody 파싱 유틸 |
| 14 | `core/.../wallet/WalletService.java` | `catch (Exception e)` → `catch (XRestException e)` + NodeApiErrorParser |
| 15 | `core/.../nonce/NonceTrackerService.java` | 동일 |
| 16 | 기타 서비스에서 blockchainApiClient 사용하는 곳 | 동일 |

### Spring Boot 측 — Framework v1.3.0 (Phase 2, 추가)

| # | 파일 | 변경 |
|---|------|------|
| 17 | `common/.../client/BlockchainApiErrorHandler.java` | **신규** — Bean 핸들러 `@Component("blockchain-api-error-handler")` |
| 18 | `common/.../client/RelayerApiErrorHandler.java` | **신규** — Bean 핸들러 `@Component("relayer-api-error-handler")` |
| 19 | `common/.../client/NodeApiErrorParser.java` | **삭제** — Bean 핸들러가 대체 |
| 20 | `scheduler/.../SchedulerBlockchainApiClient.java` | **삭제** — common의 BlockchainApiClient로 대체 |
| 21 | `scheduler/.../job/StaleTxMonitorJob.java` | SchedulerBlockchainApiClient → BlockchainApiClient |
| 22 | 서비스 코드의 `NodeApiErrorParser.getXxx()` 호출 | `e.getCode()`, `e.getMessage()` 직접 사용으로 교체 |

---

## 6. 구현 순서

```
Phase A — Node.js 표준화 (즉시, Framework 무관):
  1. common/src/errors.ts 생성 + index.ts export
  2. blockchain-api/relayer-api 글로벌 미들웨어 개선
  3. 고빈도 라우트 errorResponse() 적용 (wallet, balance, nonce, tx, gas, block)
  4. system-admin.ts + relayer.ts 적용

Phase B — Spring 에러 파싱 (Framework v1.2.3 이후):
  5. NodeApiErrorParser 유틸 생성
  6. WalletService, NonceTrackerService 등 catch 블록 개선

Phase C — Spring Bean 핸들러 (Framework v1.3.0 이후):
  7. BlockchainApiErrorHandler + RelayerApiErrorHandler Bean 등록
  8. NodeApiErrorParser 삭제 (Bean 핸들러가 대체)
  9. SchedulerBlockchainApiClient 삭제 → BlockchainApiClient 직접 사용
  10. 서비스 코드 단순화 (XRestException getter 직접 사용)
```

---

## 7. 개선 전/후 비교

### Before (현재)

```
Node.js 400: { error: "networkId is required" }
  → @XRestService proxy → XRestException (rawBody 없음)
  → Spring: catch(Exception e) → log.warn("실패: {}", e.toString())
  → 사용자: HTTP 504 "외부 서비스 오류"  ← 원인 알 수 없음
```

### After — Phase A + B (Framework v1.2.3)

```
Node.js 400: { code: "INVALID_PARAMS", error: "networkId is required" }
  → @XRestService proxy → XRestException (rawBody 보존됨)
  → Spring: catch(XRestException e)
    → NodeApiErrorParser.getErrorCode(e) = "INVALID_PARAMS"
    → NodeApiErrorParser.getErrorMessage(e) = "networkId is required"
  → 사용자: HTTP 400 "networkId is required"  ← 정확한 원인
```

### After — Phase A + C (Framework v1.3.0)

```
Node.js 400: { code: "INVALID_PARAMS", error: "networkId is required" }
  → @XRestService proxy → BlockchainApiErrorHandler (자동 파싱)
    → XRestException.getCode() = "INVALID_PARAMS"
    → XRestException.getMessage() = "networkId is required"
  → Spring: catch(XRestException e) → e.getCode() 직접 사용
  → 사용자: HTTP 400 "networkId is required"  ← 정확한 원인
```
