3. 응답 형식 및 에러 처리
V5 API의 모든 호출은 타입 DTO 또는 boolean/long을 반환합니다. 실패 시에는 다음과 같은 안전 기본값을 반환합니다.
3.1 V5 응답 패턴
| 메서드 패턴 | 성공 반환 | 실패 반환 |
|---|---|---|
create(req) / update(id, req) / get(id) | *ResponseV5 인스턴스 | null |
list() 계열 | List<*ResponseV5> | 빈 리스트 (절대 null 아님) |
delete(id) | true | false |
exists(id) | true (있음) | false (없거나 오류) |
count() 계열 | long (≥0) | 0 |
Order 라이프사이클 (start/end/pause/resume/abort) | true (성공) | false (전이 불가 등) |
예외가 throw되지 않습니다. HTTP 오류, 서버 ERROR, JSON 파싱 실패는 모두 위 표의 안전 기본값으로 흡수됩니다. 에러 원인은 디버그 로그나 envelope의
_code/_message에서 확인합니다.
3.2 내부 wire format (참고)
V5 서버는 다음과 같은 envelope 형태로 응답합니다. 클라이언트 라이브러리가 자동으로 파싱하므로, 일반 사용 시에는 신경 쓸 필요가 없습니다.
// 단건 성공
{ "_status": "OK", "data": { ... } }
// 목록 성공
{ "_status": "OK", "data": [ { ... }, { ... } ] }
// 목록 — items 키 안에 배열
{ "_status": "OK", "data": { "items": [ ... ] } }
// 에러
{
"_status": "ERROR",
"_code": "E1002",
"_message": "validation failed: ...",
"_http_status": 400
}
3.3 V5 ErrorCode 매핑
서버가 ERROR 응답을 보낼 때 _code 필드에 다음 코드가 들어옵니다. 정본은 제품의
plantpulse.core.exception.ErrorCode 이고, HTTP 상태도 그 열거형이 함께 정합니다 —
코드와 상태는 짝이므로 둘 중 하나만 보고 분기해도 됩니다.
| 코드 | HTTP | 이름 | 일반적 원인 |
|---|---|---|---|
E1000 | 400 | INVALID_REQUEST | 문법 오류, 타입 불일치 |
E1001 | 400 | MISSING_PARAMETER | 필수 파라미터 누락 |
E1002 | 422 | VALIDATION_FAILED | 값의 범위·형식·도메인 규칙 위반 (ID 접두사 등) |
E1100 | 401 | UNAUTHORIZED | api_key 누락 또는 무효 |
E1101 | 429 | AUTH_TOO_MANY_ATTEMPTS | 같은 IP 의 인증 실패 누적 — Retry-After 를 봅니다 |
E1102 | 401 | TOKEN_EXPIRED | 토큰 만료 — 재발급으로 복구됩니다 |
E1103 | 401 | TOKEN_REVOKED | 토큰 회수 — 같은 토큰은 복구되지 않습니다 |
E1104 | 401 | TOKEN_IP_MISMATCH | 토큰에 묶인 IP 와 호출 IP 가 다름 |
E1200 | 403 | FORBIDDEN | 인증은 됐으나 권한 없음 |
E1300 | 404 | NOT_FOUND | 해당 ID 없음 |
E1400 | 405 | METHOD_NOT_ALLOWED | 허용되지 않은 메서드 |
E1500 | 409 | CONFLICT | 리소스 충돌 |
E1501 | 409 | DUPLICATE_ENTITY | 이미 존재하는 엔티티 |
E1600 | 413 | PAYLOAD_TOO_LARGE | 요청 본문 과대 |
E1700 | 415 | UNSUPPORTED_MEDIA_TYPE | 지원하지 않는 미디어 타입 |
E1800 | 422 | IDEMPOTENCY_CONFLICT | 같은 Idempotency-Key + 다른 body |
E1801 | 409 | IDEMPOTENCY_IN_PROGRESS | 같은 키가 처리 중 — 잠시 후 재시도 |
E1900 | 429 | RATE_LIMIT_EXCEEDED | 레이트리밋 초과 |
E5000 | 500 | INTERNAL_ERROR | 서버 내부 오류 |
E5100 | 500 | SERVICE_ERROR | 서비스 처리 오류 |
E5200 | 500 | DAO_ERROR | 데이터 접근 오류 |
E5300 | 500 | ENGINE_ERROR | 엔진 처리 오류 |
E5400 | 502 | EXTERNAL_SERVICE_ERROR | 외부 시스템 호출 실패 |
E5500 | 503 | SERVICE_UNAVAILABLE | 서비스 불가 — 판정이 아니라 「못 했다」입니다. Retry-After 를 봅니다 |
E5503 | 503 | SHUTTING_DOWN | 서버 종료 중 — 다른 노드로 넘기거나 Retry-After 만큼 기다립니다 |
E5600 | 504 | TIMEOUT | 서버측 요청 타임아웃 |
E1900(레이트리밋)은 속도를 늦추면 풀립니다. E1101(인증 시도 과다)은 늦춘다고 풀리지
않습니다 — 자격증명 자체가 틀렸고, 그 IP 가 창이 닫힐 때까지 잠겨 있는 것입니다. 둘을 같은
백오프로 묶으면 뒤쪽은 영원히 재시도만 합니다.
400 은 요청을 «해석» 할 수 없다는 뜻이고, 422 는 해석은 됐는데 «내용» 이 규칙에 맞지 않는다는 뜻입니다(2026-08-31 부터). 형식을 고쳐야 하는지 값을 고쳐야 하는지가 상태코드로 갈립니다 — 사람은 메시지를 읽지만 에이전트는 상태코드로 다음 시도를 정하기 때문입니다.
자주 만나는 거절
| HTTP | reason | 뜻 |
|---|---|---|
| 422 | asset_statement.eql.missing_columns | 스테이트먼트 EQL 이 종류의 필수 칸을 내지 않습니다 — 설비 스테이트먼트 |
| 422 | asset_statement.eql.unknown_type | 없는 스테이트먼트 종류입니다 |
| 422 | alarm.invalid.alarm_config_id | 알람 설정 생성에 직접 지은 ID 를 넣었습니다. GET /alarm/config/new-id 로 받습니다 |
| 409 | — | 같은 태그에 둘째 대역(밴드) 알람 설정을 만들려 했습니다. 태그당 하나입니다 |
3.4 에러 정보 추출
V5 서비스 메서드는 envelope을 직접 노출하지 않으므로, 에러 상세는 다음 두 가지 방법으로 확인합니다.
방법 1 — 디버그 모드
APIClient_V5 client = new APIClient_V5(proto, host, port, user, token, true);
client.connect();
SiteResponseV5 result = client.site().create(req);
if (result == null) {
// 콘솔 로그에서 envelope 확인 가능
System.err.println("등록 실패 — 디버그 로그 확인");
}
콘솔 출력:
[V5] POST /api/v5/site body={...}
[V5] response: 400 {"_status":"ERROR","_code":"E1002","_message":"site_id must start with SITE_"}
방법 2 — BaseServiceV5 static 메서드 사용 (커스텀 확장)
BaseServiceV5의 헬퍼는 JSONObject 응답에서 에러 정보를 추출합니다. 라이브러리를 확장해 envelope를 캡처하는 커스텀 서비스를 만들 때 유용합니다.
import plantpulse.api.v5.service.BaseServiceV5;
import plantpulse.json.JSONObject;
// 환경 (envelope) JSONObject 가 있다면
String code = BaseServiceV5.getErrorCode(env); // 예: "E1002"
String message = BaseServiceV5.getErrorMessage(env); // 예: "validation failed: ..."
int httpStatus = BaseServiceV5.getHttpStatus(env); // 예: 400
3.5 안전한 호출 패턴
CRUD 호출
import plantpulse.api.v5.dto.request.SiteRequestV5;
import plantpulse.api.v5.dto.response.SiteResponseV5;
SiteRequestV5 req = new SiteRequestV5();
req.setSite_id("SITE_DJ");
req.setSite_name("대전공장");
SiteResponseV5 created = client.site().create(req);
if (created == null) {
// 실패 처리 — 예: 알람, 재시도, 다른 경로 시도
log.warn("사이트 생성 실패");
return;
}
System.out.println("등록 완료: " + created.getSite_id());
목록 호출
List<SiteResponseV5> sites = client.site().list();
// 실패해도 null 이 아님 — 그대로 순회 가능
for (SiteResponseV5 s : sites) {
System.out.println(s.getSite_id());
}
if (sites.isEmpty()) {
log.info("등록된 사이트 없음");
}
존재 확인 → 조회
if (client.site().exists("SITE_DJ")) {
SiteResponseV5 site = client.site().get("SITE_DJ");
// ...
}
Order 라이프사이클 (boolean 반환)
String orderId = "ORDER_000001";
if (!client.order().start(orderId)) {
// 전이 불가 (예: 이미 START 상태)
log.warn("작업 시작 실패 — 현재 상태 확인");
return;
}
// 작업 진행 ...
if (!client.order().end(orderId)) {
log.warn("작업 종료 실패");
}
3.6 재시도 패턴
V5는 자동 재시도를 하지 않습니다. 일시적 네트워크 오류를 대비한 재시도가 필요하면 직접 구현하시면 됩니다.
import java.util.function.Supplier;
public static <T> T callWithRetry(Supplier<T> apiCall, int maxRetries, long baseDelayMs) {
int attempt = 0;
while (true) {
T result = apiCall.get();
if (result != null) return result; // 성공
if (++attempt >= maxRetries) return null; // 포기
try {
Thread.sleep(baseDelayMs * attempt); // 지수 백오프
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return null;
}
}
}
// 사용
SiteResponseV5 site = callWithRetry(
() -> client.site().get("SITE_DJ"),
3,
1_000L);
503 · 429 와 Retry-After
재기동이나 과부하로 지금 받지 못할 때는 Retry-After(초 단위 정수)가 함께 옵니다. Java 클라이언트는 이 헤더를 지키되 기본으로 30초를 넘겨 기다리지는 않습니다. 직접 부를 때는 다음을 따릅니다.
| 상황 | 응답 |
|---|---|
| 웹 계층이 내려가 있음(재기동 · 재배포) — 프록시가 답합니다 | 503 E5500, Retry-After: 15 |
| 서버 종료 진행 중 | 503 E5503, Retry-After: 15 |
| 뒤 저장소 일시 불가(DB 재기동 등) | 503 E5500, Retry-After: 15 |
| 토큰별 요청 한도 초과 | 429 E1900, Retry-After 는 한도가 계산합니다 |
| 스트림 동시 수 초과 | 429 E1900 또는 503 E5500, Retry-After |
- 읽기는 그만큼 기다렸다 다시 부릅니다.
- 쓰기는 같은
Idempotency-Key로 다시 부릅니다. 재기동에 끊긴 요청은 이미 반영됐을 수 있습니다. - 500 은 고장이고, 502
E5400은 외부 호출 실패입니다. 둘 다Retry-After가 없습니다.
3.7 빈 응답과 null 응답 구별
V5에서 null 또는 false는 다음 두 가지 의미를 모두 포함합니다:
- 서버에 데이터가 없음 (
NOT_FOUND,E1300) - 호출이 실패함 (네트워크 오류, 검증 실패 등)
이를 구별하려면 exists()를 함께 사용하시면 됩니다.
if (!client.site().exists("SITE_DJ")) {
log.info("사이트가 등록되어 있지 않습니다");
// 신규 등록 로직
} else {
SiteResponseV5 site = client.site().get("SITE_DJ");
if (site == null) {
log.error("사이트는 존재하지만 조회 실패 — 일시적 오류 가능");
}
}
다음 단계
- System 서비스 — 헬스체크
- Site 서비스 — 마스터 데이터 시작점