# XRestService / XWebClient 에러 전파 개선 가이드

**가이드 번호**: #40
**대상**: Axim REST Framework (`rest-framework` 프로젝트)
**버전**: Phase 1 → 1.2.3 (patch), Phase 1+2 → 1.3.0 (minor)
**작성일**: 2026-03-22

---

## 현재 문제 (3가지)

### 1. data 필드 유실

`XRestException(HttpStatus, ApiError)` 생성자가 `data`를 복사하지 않음.
Validation 에러의 field 목록 등이 프록시를 거치면 사라짐.

```java
// XRestException.java — 현재 코드
public XRestException(HttpStatus status, ApiError error) {
    super(error.getMessage());
    this.status = status;
    this.code = error.getCode();
    this.message = error.getMessage();
    this.description = error.getDescription();
    // BUG: error.getData() 복사 누락 → data 항상 null
}
```

### 2. 원본 응답 바디 유실

에러 응답이 ApiError로 파싱된 뒤 원본 JSON 문자열은 버려짐.
외부 API의 에러 포맷이 다를 경우 호출측에서 재파싱 불가.

### 3. 외부 API 에러 포맷 비호환

ApiError 포맷(`code`, `message`, `description`)이 아닌 응답은 필드가 null로 파싱되거나 raw 텍스트가 message에 들어감.

```
Axim:     { "code": "2001", "message": "...", "description": "..." }  -> OK
Node.js:  { "code": "WALLET_NOT_FOUND", "error": "...", "detail": "..." } -> code OK, message=null
OAuth:    { "error": "invalid_grant", "error_description": "..." }    -> code=null, message=null
RFC7807:  { "type": "...", "title": "...", "detail": "..." }          -> code=null, message=null
Stripe:   { "error": { "type": "card_error", "message": "..." } }    -> code=null, message=null
Plain:    "Internal Server Error"                                      -> 파싱 실패, raw 텍스트
```

---

## 개선 방안

### Phase 1: XRestException에 raw body + data 보존 (필수, v1.2.3)

핸들러 없이도 모든 에러 정보를 확보할 수 있게 함.
**하위 호환 100%** — 기존 코드 동작 변경 없음.

#### 1-1. XRestException 필드 추가

**파일:** `rest-api/src/main/java/one/axim/framework/rest/exception/XRestException.java`

```java
public class XRestException extends RuntimeException {
    // ... 기존 필드 (status, code, message, description, args, data)

    /** 원본 응답 바디 (항상 보존). XExceptionHandler가 클라이언트에 노출하지 않도록 @JsonIgnore */
    @JsonIgnore
    protected String rawResponseBody;

    /** 원격 서비스명 (프록시 경유 시 자동 설정). 로그/디버깅용 */
    @JsonIgnore
    protected String remoteServiceName;
}
```

추가할 것:

- `rawResponseBody` 필드 + getter/setter — **반드시 `@JsonIgnore`** (XExceptionHandler가 ApiError로 변환 시 내부 서비스 에러가 외부에 노출되는 것 방지)
- `remoteServiceName` 필드 + getter/setter — **반드시 `@JsonIgnore`**
- 기존 `XRestException(HttpStatus, ApiError)` 생성자에 `data` 복사 추가
- 신규 `XRestException(HttpStatus, ApiError, String rawResponseBody)` 생성자

```java
// 수정: ApiError 생성자 — data 복사 추가
public XRestException(HttpStatus status, ApiError error) {
    super(error.getMessage());
    this.status = status;
    this.code = error.getCode();
    this.message = error.getMessage();
    this.description = error.getDescription();
    this.data = error.getData();              // ★ 추가 (기존 누락 버그 수정)
}

// 신규: raw body 포함 생성자
public XRestException(HttpStatus status, ApiError error, String rawResponseBody) {
    this(status, error);
    this.rawResponseBody = rawResponseBody;
}
```

> **@JsonIgnore 필수 이유**: `XExceptionHandler`가 XRestException → ApiError 변환 시, rawResponseBody가 직렬화되면 원격 서비스의 내부 에러 상세(스택트레이스 등)가 최종 클라이언트에 노출됨. 이 필드는 서버 측 로그/디버깅 전용.

#### 1-2. XRestClient — throwRestException에서 raw body 보존

**파일:** `rest-api/src/main/java/one/axim/framework/rest/proxy/XRestClient.java`

```java
// 수정: throwRestException()
private void throwRestException(ResponseEntity<String> response) throws XRestException {
    HttpStatus status = HttpStatus.valueOf(response.getStatusCode().value());
    String body = response.getBody();

    if (body == null || body.isBlank()) {
        throw new XRestException(status, new ApiError(status, status.getReasonPhrase(), null, false));
    }

    try {
        ApiError error = OBJECT_MAPPER.readValue(body, ApiError.class);
        throw new XRestException(status, error, body);  // ★ raw body 보존
    } catch (XRestException e) {
        throw e;  // 위에서 던진 XRestException 재throw
    } catch (Exception e) {
        throw new XRestException(status, new ApiError(status, body, e, false), body);  // ★ raw body 보존
    }
}
```

#### 1-3. XWebClient — handleErrorResponse에서 raw body 보존

**파일:** `rest-api/src/main/java/one/axim/framework/rest/proxy/XWebClient.java`

```java
// 수정: handleErrorResponse()
private void handleErrorResponse(HttpStatusCode statusCode, InputStream body) throws IOException {
    HttpStatus status = HttpStatus.valueOf(statusCode.value());

    if (body == null) {
        throw new XRestException(status, new ApiError(status, status.getReasonPhrase(), null, false));
    }

    byte[] bytes = body.readAllBytes();
    String rawBody = new String(bytes, java.nio.charset.StandardCharsets.UTF_8);  // ★ charset 명시

    if (bytes.length == 0) {
        throw new XRestException(status, new ApiError(status, status.getReasonPhrase(), null, false));
    }

    try {
        ApiError error = OBJECT_MAPPER.readValue(bytes, ApiError.class);
        throw new XRestException(status, error, rawBody);  // ★ raw body 보존
    } catch (XRestException e) {
        throw e;
    } catch (Exception e) {
        throw new XRestException(status, new ApiError(status, rawBody, e, false), rawBody);  // ★ raw body 보존
    }
}
```

#### 1-4. XRestClientProxy — remoteServiceName 설정

**파일:** `rest-api/src/main/java/one/axim/framework/rest/proxy/XRestClientProxy.java`

`invoke()` 메서드에서 XRestException을 catch하여 서비스명을 설정. 현재 invoke()에 XRestException 전용 catch 블록이 없으므로 **새로 추가**:

```java
@Override
public Object invoke(Object o, Method method, Object[] objects) throws Throwable {
    // ... 기존 로직 (Method 분석, URL 조합, 파라미터 바인딩)
    try {
        // ... client.get/post/put/delete 등 호출
    } catch (XRestException e) {
        e.setRemoteServiceName(service.value());  // ★ "blockchain-api", "relayer-api" 등
        throw e;
    }
    // ... 기존 나머지 로직
}
```

> **주의**: 기존에 `catch (Exception e)` 블록이 있다면, `catch (XRestException e)`를 **그 앞에** 배치해야 함.

#### Phase 1 사용 예시

```java
// 호출측 — 핸들러 없이도 원본 확보 가능
try {
    WalletDeriveResponse wallet = blockchainApiClient.deriveWallet(request);
} catch (XRestException e) {
    log.error("[{}] API 에러: status={}, code={}, message={}, raw={}",
        e.getRemoteServiceName(),  // "blockchain-api"
        e.getStatus(),             // 400 BAD_REQUEST
        e.getCode(),               // "INVALID_PARAMS" (Node.js가 반환한 코드)
        e.getMessage(),            // "networkId is required"
        e.getRawResponseBody());   // {"code":"INVALID_PARAMS","error":"networkId is required"}

    // data 필드 정상 접근 (Phase 1 버그 수정 덕분)
    e.getData();  // validation field 목록 등

    // 필요 시 원본 바디에서 직접 파싱
    NodeJsError err = objectMapper.readValue(e.getRawResponseBody(), NodeJsError.class);
}
```

---

### Phase 2: 서비스별 에러 핸들러 Bean (선택, v1.3.0)

반복 호출이 많은 서비스에 대해 매번 raw body를 직접 파싱하는 번거로움 해소.
Bean 이름 컨벤션으로 `@XRestService(value)` + `-error-handler` 접미사 사용.

#### 2-1. XErrorResponseHandler 인터페이스

**파일(신규):** `rest-api/src/main/java/one/axim/framework/rest/handler/XErrorResponseHandler.java`

```java
package one.axim.framework.rest.handler;

import one.axim.framework.rest.exception.XRestException;
import org.springframework.http.HttpStatus;

/**
 * 서비스별 에러 응답 파싱 핸들러.
 * {@code @XRestService(value)} + "-error-handler" 이름의 Bean으로 등록하면 자동 매칭.
 *
 * <pre>
 * // 예시: @XRestService(value = "stripe-api") 에 매칭
 * {@literal @}Component("stripe-api-error-handler")
 * public class StripeErrorHandler implements XErrorResponseHandler { ... }
 * </pre>
 */
@FunctionalInterface
public interface XErrorResponseHandler {

    /**
     * 에러 응답을 파싱하여 XRestException으로 변환.
     *
     * @param status       HTTP 상태 코드
     * @param responseBody 원본 응답 바디 문자열
     * @return 변환된 XRestException (null 반환 시 기본 핸들러로 폴백)
     */
    XRestException handle(HttpStatus status, String responseBody);
}
```

#### 2-2. 기본 핸들러 (프레임워크 내장)

**파일(신규):** `rest-api/src/main/java/one/axim/framework/rest/handler/DefaultErrorResponseHandler.java`

```java
package one.axim.framework.rest.handler;

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

/**
 * Axim ApiError 포맷 기본 파서.
 * 커스텀 핸들러가 없거나 null 반환 시 이 핸들러로 폴백.
 */
public class DefaultErrorResponseHandler implements XErrorResponseHandler {

    private final ObjectMapper objectMapper;

    public DefaultErrorResponseHandler(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

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

        try {
            ApiError error = objectMapper.readValue(responseBody, ApiError.class);
            return new XRestException(status, error, responseBody);
        } catch (Exception e) {
            return new XRestException(status,
                    new ApiError(status, responseBody, e, false), responseBody);
        }
    }
}
```

#### 2-3. Bean 등록 — 접미사 컨벤션 `-error-handler`

사용자는 `@Component`로 등록. Bean 이름은 **`@XRestService(value)` + `-error-handler`** 컨벤션.

> **접미사 컨벤션 이유**: `@XRestService(value = "stripe-api")`가 이미 Bean으로 등록될 수 있으므로, 동일 이름 `@Component("stripe-api")`는 Bean 이름 충돌 위험. `-error-handler` 접미사로 충돌 회피.

```java
// 서비스명 "stripe-api" + "-error-handler" = "stripe-api-error-handler"
@Component("stripe-api-error-handler")
public class StripeErrorHandler implements XErrorResponseHandler {

    private final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public XRestException handle(HttpStatus status, String responseBody) {
        try {
            StripeErrorResponse err = objectMapper.readValue(responseBody, StripeErrorResponse.class);

            ApiError apiError = new ApiError();
            apiError.setCode(err.getError().getCode());
            apiError.setMessage(err.getError().getMessage());

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

어노테이션 변경 없음 (기존 `@XRestService` 그대로 사용):
```java
@XRestService(value = "stripe-api", host = "${STRIPE_HOST}")
public interface StripeClient {
    @XRestAPI(value = "/v1/charges", method = XHttpMethod.POST)
    Charge createCharge(@RequestBody ChargeRequest request);
}
```

#### 2-4. XRestClientProxy — 핸들러 조회 로직

**파일:** `rest-api/src/main/java/one/axim/framework/rest/proxy/XRestClientProxy.java`

```java
public class XRestClientProxy implements InvocationHandler {

    @Autowired
    private ApplicationContext applicationContext;

    // ... 기존 필드

    /**
     * 서비스명 + "-error-handler" 컨벤션으로 Bean 조회.
     * getOrCreateClient()에서 한 번만 호출되어 캐싱됨 → 매 요청마다 Bean 조회 X.
     */
    private XErrorResponseHandler findErrorHandler(String serviceName) {
        try {
            return applicationContext.getBean(
                serviceName + "-error-handler",
                XErrorResponseHandler.class
            );
        } catch (Exception e) {
            return null;  // Bean 없으면 기본 핸들러 사용
        }
    }
}
```

> **성능 참고**: `findErrorHandler()`는 `getOrCreateClient()` 내부의 `computeIfAbsent`에서만 호출되므로, 서비스당 최초 1회만 실행됨. Bean 조회 실패 시 발생하는 `NoSuchBeanDefinitionException`은 초기화 시 한 번뿐.

#### 2-5. XRestClient — errorHandler 주입

**파일:** `rest-api/src/main/java/one/axim/framework/rest/proxy/XRestClient.java`

```java
public class XRestClient {
    // ... 기존 필드
    private XErrorResponseHandler errorHandler;

    public void setErrorHandler(XErrorResponseHandler errorHandler) {
        this.errorHandler = errorHandler;
    }

    private void throwRestException(ResponseEntity<String> response) throws XRestException {
        HttpStatus status = HttpStatus.valueOf(response.getStatusCode().value());
        String body = response.getBody();

        // ★ 커스텀 핸들러 우선 시도
        if (errorHandler != null) {
            XRestException customException = errorHandler.handle(status, body);
            if (customException != null) {
                customException.setRawResponseBody(body);           // raw body 보장
                customException.setRemoteServiceName(/* proxy에서 설정 */);
                throw customException;
            }
            // null 반환 → 아래 기본 핸들러로 폴백
        }

        // 기본 핸들러 (Phase 1 로직)
        if (body == null || body.isBlank()) {
            throw new XRestException(status, new ApiError(status, status.getReasonPhrase(), null, false));
        }

        try {
            ApiError error = OBJECT_MAPPER.readValue(body, ApiError.class);
            throw new XRestException(status, error, body);
        } catch (XRestException e) {
            throw e;
        } catch (Exception e) {
            throw new XRestException(status, new ApiError(status, body, e, false), body);
        }
    }
}
```

#### 2-6. XWebClient — errorHandler 체이닝

**파일:** `rest-api/src/main/java/one/axim/framework/rest/proxy/XWebClient.java`

```java
public class XWebClient {
    // ... 기존 필드
    private XErrorResponseHandler errorHandler;

    /** 에러 핸들러 설정 (체이닝 지원) */
    public XWebClient errorHandler(XErrorResponseHandler handler) {
        this.errorHandler = handler;
        return this;
    }

    // handleErrorResponse에서도 동일하게 커스텀 핸들러 우선 시도
    private void handleErrorResponse(HttpStatusCode statusCode, InputStream body) throws IOException {
        HttpStatus status = HttpStatus.valueOf(statusCode.value());

        if (body == null) {
            throw new XRestException(status, new ApiError(status, status.getReasonPhrase(), null, false));
        }

        byte[] bytes = body.readAllBytes();
        String rawBody = new String(bytes, java.nio.charset.StandardCharsets.UTF_8);

        if (bytes.length == 0) {
            throw new XRestException(status, new ApiError(status, status.getReasonPhrase(), null, false));
        }

        // ★ 커스텀 핸들러 우선 시도
        if (errorHandler != null) {
            XRestException customException = errorHandler.handle(status, rawBody);
            if (customException != null) {
                customException.setRawResponseBody(rawBody);
                throw customException;
            }
        }

        // 기본 핸들러
        try {
            ApiError error = OBJECT_MAPPER.readValue(bytes, ApiError.class);
            throw new XRestException(status, error, rawBody);
        } catch (XRestException e) {
            throw e;
        } catch (Exception e) {
            throw new XRestException(status, new ApiError(status, rawBody, e, false), rawBody);
        }
    }
}
```

사용:
```java
XWebClient client = webClientFactory.create("https://api.stripe.com")
    .errorHandler(new StripeErrorHandler());

// 또는 인라인 람다
XWebClient client = webClientFactory.create("https://api.external.com")
    .errorHandler((status, body) -> {
        JsonNode node = objectMapper.readTree(body);
        ApiError error = new ApiError();
        error.setCode(node.path("error").asText());
        error.setMessage(node.path("error_description").asText());
        return new XRestException(status, error, body);
    });
```

#### 2-7. XRestClientProxy — getOrCreateClient에서 핸들러 연결

**파일:** `rest-api/src/main/java/one/axim/framework/rest/proxy/XRestClientProxy.java`

```java
private XRestClient getOrCreateClient(XRestService service) {
    String cacheKey = service.host() + "|" + service.value() + "|" + service.version();

    return clientCache.computeIfAbsent(cacheKey, key -> {
        // ... 기존 client 생성 로직

        client.setDebug(isDebug);

        // ★ 서비스명으로 에러 핸들러 Bean 조회 후 연결 (한 번만 실행)
        XErrorResponseHandler handler = findErrorHandler(service.value());
        if (handler != null) {
            client.setErrorHandler(handler);
        }

        return client;
    });
}
```

---

## Phase 2 사용 예시

### 케이스 1: Axim 프레임워크끼리 (핸들러 불필요)

```java
@XRestService(value = "user-service", host = "${USER_SERVICE_HOST}")
public interface UserServiceClient {
    @XRestAPI(value = "/users/{id}", method = XHttpMethod.GET)
    User getUser(@PathVariable("id") Long id);
}

// 호출측 — 기본 핸들러가 ApiError 파싱
try {
    User user = userClient.getUser(1L);
} catch (XRestException e) {
    e.getCode();         // "2001"
    e.getMessage();      // "이미 존재하는 이메일"
    e.getData();         // [{field: "email", ...}] — Phase 1에서 수정됨
}
```

### 케이스 2: 외부 API — 핸들러 없이 raw body로 처리 (Phase 1만으로 가능)

```java
try {
    stripeClient.createCharge(request);
} catch (XRestException e) {
    // raw body에서 직접 파싱
    StripeError err = objectMapper.readValue(e.getRawResponseBody(), StripeError.class);
    log.error("[{}] Stripe error: {} - {}",
        e.getRemoteServiceName(),
        err.getError().getCode(),
        err.getError().getMessage());
}
```

### 케이스 3: 외부 API — Bean 핸들러로 자동 변환 (Phase 2 필요)

```java
@Component("stripe-api-error-handler")  // 접미사 컨벤션
public class StripeErrorHandler implements XErrorResponseHandler { ... }

// 호출측 — 자동으로 StripeErrorHandler가 적용됨
try {
    stripeClient.createCharge(request);
} catch (XRestException e) {
    e.getCode();    // "expired_card" (StripeErrorHandler가 변환)
    e.getMessage(); // "Your card has expired."
}
```

### 케이스 4: Cryptoments — blockchain-api 에러 핸들러

```java
// Node.js blockchain-api 전용 핸들러
@Component("blockchain-api-error-handler")
public class BlockchainApiErrorHandler implements XErrorResponseHandler {

    private final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public XRestException handle(HttpStatus status, String responseBody) {
        try {
            // Node.js 에러 포맷: { "code": "WALLET_NOT_FOUND", "error": "...", "detail": "..." }
            JsonNode node = objectMapper.readTree(responseBody);

            ApiError apiError = new ApiError();
            apiError.setCode(node.path("code").asText(null));
            apiError.setMessage(node.path("error").asText(null));
            apiError.setDescription(node.path("detail").asText(null));

            return new XRestException(status, apiError, responseBody);
        } catch (Exception e) {
            return null;  // 폴백
        }
    }
}
```

---

## 체크리스트

### Phase 1 (필수 — 하위 호환, v1.2.3)

- [ ] `XRestException` — `rawResponseBody` 필드 추가 (**`@JsonIgnore` 필수**)
- [ ] `XRestException` — `remoteServiceName` 필드 추가 (**`@JsonIgnore` 필수**)
- [ ] `XRestException` — getter/setter 추가 (rawResponseBody, remoteServiceName)
- [ ] `XRestException` — `(HttpStatus, ApiError)` 생성자에 `this.data = error.getData()` 추가
- [ ] `XRestException` — `(HttpStatus, ApiError, String rawBody)` 생성자 신규 추가
- [ ] `XRestClient.throwRestException()` — raw body 보존 (`new XRestException(status, error, body)`)
- [ ] `XWebClient.handleErrorResponse()` — raw body 보존 + `StandardCharsets.UTF_8` 명시
- [ ] `XRestClientProxy.invoke()` — `catch (XRestException e)` 블록 추가, `remoteServiceName` 설정
- [ ] 빌드 확인: `./gradlew :rest-api:build`
- [ ] 버전 bump: `1.2.2` → `1.2.3`

### Phase 2 (선택 — 편의 기능, v1.3.0)

- [ ] `XErrorResponseHandler` 인터페이스 신규 생성 (`handler/` 패키지)
- [ ] `DefaultErrorResponseHandler` 신규 생성 (`handler/` 패키지)
- [ ] `XRestClient` — `errorHandler` 필드 + setter + `throwRestException` 분기
- [ ] `XWebClient` — `errorHandler` 필드 + 체이닝 메서드 + `handleErrorResponse` 분기
- [ ] `XRestClientProxy` — `findErrorHandler()` 메서드 추가 (접미사 `-error-handler` 컨벤션)
- [ ] `XRestClientProxy` — `getOrCreateClient()`에서 핸들러 조회 + 연결 (한 번만, 캐싱)
- [ ] 빌드 확인: `./gradlew :rest-api:build`
- [ ] 버전 bump: `1.2.3` → `1.3.0`

---

## 영향 범위

| 구분 | Phase 1 (v1.2.3) | Phase 2 (v1.3.0) |
|---|---|---|
| 기존 `@XRestService` 사용자 | 동작 변경 없음. `getData()` 정상화만 | 동작 변경 없음 |
| 기존 `XWebClient` 사용자 | 동작 변경 없음 | 동작 변경 없음 |
| 기존 `XExceptionHandler` | 변경 불필요 — 신규 필드 `@JsonIgnore` | 변경 불필요 |
| 새 에러 핸들러 등록 | N/A | `@Component` Bean 추가만으로 자동 매칭 |
| `XRestException` catch 코드 | `getRawResponseBody()`, `getRemoteServiceName()` 사용 가능 | 동일 |

---

## 버전 전략

| 버전 | 포함 범위 | 성격 |
|------|----------|------|
| **1.2.3** | Phase 1만 | Patch — 버그 수정 (data 유실) + 정보 보존 (raw body) |
| **1.3.0** | Phase 1 + Phase 2 | Minor — 신규 기능 (XErrorResponseHandler) |

Phase 1만 먼저 릴리스하고, Phase 2는 실제 외부 API 연동이 다양해질 때 추가하는 것을 권장.
Cryptoments의 경우 blockchain-api/relayer-api가 모두 내부 Node.js 서비스이므로, **Phase 1만으로도 충분**. Phase 2는 향후 Axim Pay 등 외부 결제 API 연동 시 활용.

---

## Cryptoments 프로젝트 적용 (Framework 업그레이드 후)

Framework v1.2.3 이상으로 업그레이드한 후, Cryptoments 프로젝트에서의 활용:

### 1. build.gradle 의존성 업데이트

```groovy
// root build.gradle
ext {
    aximVersion = '1.3.1'  // 1.2.3+ 또는 1.3.0+ 권장
}
```

### 2. 기존 ExternalServiceException 개선 (선택)

현재 `ExternalServiceException`은 항상 HTTP 504를 반환. Framework 업그레이드 후:

```java
// 기존: 항상 504
throw new ExternalServiceException(ErrorCodes.EXTERNAL_SERVICE_ERROR.code(), e.getMessage());

// 개선: 원격 서비스의 HTTP 상태를 전파
catch (XRestException e) {
    log.error("[{}] API error: status={}, code={}, raw={}",
        e.getRemoteServiceName(), e.getStatus(), e.getCode(), e.getRawResponseBody());
    // e.getStatus()가 4xx면 4xx 그대로 전파, 5xx면 502로 변환 등
    throw e;  // 또는 필요한 변환 후 throw
}
```

### 3. SchedulerBlockchainApiClient 대체

Framework 업그레이드 후 `SchedulerBlockchainApiClient` (직접 HttpClient 사용)를 제거하고,
common의 `BlockchainApiClient` (`@XRestService`)를 직접 사용 가능:

```java
// 제거 대상: scheduler/src/.../client/SchedulerBlockchainApiClient.java
// 대체: common의 BlockchainApiClient 직접 사용 (에러 처리는 Framework이 담당)
```
