본문으로 건너뛰기

REST API (v1)

PlantPulse Edge 의 v1 REST API 매뉴얼. 화면 / 외부 시스템이 OPC, 태그, 모니터링, 외부 전송 헬스체크를 다루는 표준 인터페이스 입니다.

항목값
Base URLhttp(s)://<edge-host>/api/v1
Content-Typeapplication/json; charset=utf-8
인증API Key 강제 (edge.rest.api.auth=true) — X-API-Key / Authorization: Bearer, 또는 로그인 세션으로 통과. ?api_key= 는 401 로 거부
Audit 로그ApiAccessLogFilter — 모든 /api/* 호출을 한 줄로 기록 (메서드/경로/상태/지속/IP/사용자/auth 모드)
메인 컨트롤러plantpulse.app.edge.api.v1.* (OpcAPI, TagAPI, AppAPI, SystemAPI, MonitoringAPI)
오류 envelope{data, meta, errors} — filter/advice 경로도 동일
HTTPSedge.rest.api.https_only=true 기본. plain HTTP client 호출은 거부 또는 Tomcat HTTPS redirect 정책을 따른다

인증 (ApiAuthFilter)​

app.properties 에서 edge.rest.api.auth=true 로 활성화. 키는 edge.rest.api.key 이며 운영에서는 EDGE_REST_API_KEY_FILE 또는 EDGE_REST_API_KEY 로 주입합니다.

다음 3가지 중 하나라도 만족하면 통과:

A. 로그인 세션 통과 (브라우저)​

이미 /login/form 으로 로그인한 사용자 — 세션에 _USER_LOGIN attribute (JSONObject) 가 있으면 ApiAuthFilter 가 그대로 chain.doFilter() 합니다. 화면(JSP) 에서 ajax 로 호출하는 모든 /api/* 가 추가 키 없이 동작합니다.

B. X-API-Key 헤더 (권장)​

EDGE_REST_API_KEY="$(tr -d '\r\n' < /run/secrets/edge-rest-api-key)"
cfg="$(mktemp)"
trap 'rm -f "$cfg"' EXIT
printf 'header = "X-API-Key: %s"\n' "$EDGE_REST_API_KEY" > "$cfg"
curl --config "$cfg" https://edge.example.com/api/v1/tag/TAG_LS_XBC_0001/value

C. Authorization: Bearer <키> 헤더​

EDGE_REST_API_KEY="$(tr -d '\r\n' < /run/secrets/edge-rest-api-key)"
cfg="$(mktemp)"
trap 'rm -f "$cfg"' EXIT
printf 'header = "Authorization: Bearer %s"\n' "$EDGE_REST_API_KEY" > "$cfg"
curl --config "$cfg" https://edge.example.com/api/v1/tag/TAG_LS_XBC_0001/value

D. ?api_key=<키> 쿼리 파라미터 — 거부됨​

URL 에 키가 평문으로 들어가 액세스 로그/프록시 캐시/브라우저 history 에 남을 수 있으므로 v1 에서는 401 로 거부합니다. 헤더 방식(B/C)을 사용하세요.

실패 응답​

HTTP/1.1 401 Unauthorized
Content-Type: application/json; charset=utf-8

{
"data": null,
"meta": {
"timestamp": 1778069486934,
"status": "ERROR",
"http_status": 401,
"request_id": "3b89b8f1-0fc1-4ab4-b4eb-d12dc314d500"
},
"errors": [
{
"code": "UNAUTHORIZED",
"message": "Invalid or missing X-API-Key."
}
]
}

키 비교는 constantTimeEquals() (timing-attack 방어).

edge.rest.api.auth=false (개발 / 임시)​

인증을 끄면 ApiAuthFilter 가 즉시 chain.doFilter() — 외부에서 키 없이 호출 가능합니다. 운영망에서는 항상 true 권장.


Audit 로그 (ApiAccessLogFilter)​

모든 /api/* 호출을 한 줄 로그로 기록 — 인증 실패(401) 도 포함되어 audit 트레일을 남깁니다 (필터 체인 순서: ApiAccessLogFilter → ApiAuthFilter).

형식​

[API_ACCESS] method=GET path=/api/v1/tag/TAG_MS_1000/value query= status=200 duration=12ms
ip=100.127.147.98 user=admin auth=session ua=Mozilla/5.0...
필드의미
methodHTTP 메서드
pathrequest URI (쿼리 미포함)
query쿼리 문자열 — 거부되는 ?api_key=... 부분도 *** 로 마스킹
statusHTTP 응답 코드
duration처리 시간 (ms)
ip기본은 RemoteAddr. edge.rest.api.trust_x_forwarded_for=true 인 reverse proxy 배치에서만 X-Forwarded-For 첫 IP
usersession 사용자면 user_id / apikey 면 XXXX*** (len=16) (앞 4자 + 길이) / 익명이면 -
authsession (브라우저 로그인) / apikey (X-API-Key/Bearer) / deprecated_query (거부된 ?api_key= 시도) / none (키 누락 또는 익명)
uaUser-Agent (80자 초과 시 … 잘라짐)
error예외 발생 시 error=ClassName 추가

로그 레벨 분기​

상태레벨의도
5xx 또는 throwableERROR서버 오류 — 즉시 알람
401, 403WARN인증/권한 거절 — audit 우선
그 외 (2xx, 3xx, 4xx 의 4xx 일반)INFO일상 호출

활용​

  • 누가 키를 잘못 보내고 있나 — grep '\[API_ACCESS\].*status=401' 로 401 트레일 확인
  • 5xx 회귀 추적 — grep '\[API_ACCESS\].*status=5' + error= 필드
  • 호출 빈도 / 사용자별 집계 — auth=apikey/session 로 grouping, ip 별 분석
  • 응답 지연 슬로우 쿼리 — duration= 큰 라인 추출

키 평문은 어디에서도 로그에 남지 않습니다 — query 마스킹 + user 필드 prefix 4자 만으로 식별. 침해 사고 분석 에는 충분한 식별성 + 평문 노출 방지의 균형.

필터 체인 순서가 중요

web.xml 매핑 순서: ApiAccessLogFilter 가 먼저, 그 다음 ApiAuthFilter. AccessLog 가 finally 블록에서 status 를 기록하기 때문에 인증 실패한 401 호출도 그대로 audit 됩니다. 순서가 바뀌면 401 호출이 access log 에서 누락됩니다.


응답 envelope​

v1 REST API 응답은 공통 envelope 으로 감쌉니다 (RestApiSupport, ApiEnvelopeWriter).

정상 응답:

{
"data": { },
"meta": {
"timestamp": 1778069486934,
"status": "OK",
"request_id": "f818abd0-eb0f-4497-9b07-7ccc94b2e2d7"
}
}

에러 응답:

{
"data": null,
"meta": {
"timestamp": 1778069486934,
"status": "ERROR",
"http_status": 400,
"request_id": "3b89b8f1-0fc1-4ab4-b4eb-d12dc314d500"
},
"errors": [
{
"code": "VALIDATION_FAILED",
"message": "Validation failed"
}
]
}

옛 RPC-style envelope (result, _session_id, _user_id) 는 REST v1 에서 사용하지 않습니다.


1. 공통 규약​

엔드포인트를 보기 전에, 모든 /api/v1/* 이 함께 지키는 것들입니다. 이것을 모르면 개별 엔드포인트 문서를 읽어도 클라이언트가 틀립니다.

1.1 운영 헤더​

헤더방향용도
X-API-Key요청인증. ?api_key= 쿼리는 폐기(401)
Authorization: Bearer <키>요청API 키의 대안
X-Request-Id요청추적용. 응답 meta.request_id 로 echo 됩니다. 안 주면 서버가 만듭니다
Idempotency-Key요청POST 중복 방지. TTL 10분. 같은 키로 재시도하면 캐시된 응답과 함께 Idempotency-Replay: true 가 옵니다
If-Match요청낙관적 동시성. GET 응답의 ETag 와 다르면 412
ETag응답weak validator(W/"<hex>"). 현재 GET /api/v1/opc/{opcId} 만 냅니다
Location응답POST 생성(201) 시 새 리소스 URI
X-RateLimit-Limit · -Remaining · -Reset응답레이트리밋 정보
Retry-After응답429 일 때 기다릴 초

레이트리밋은 edge.rest.api.rate_limit.enabled 와 edge.rest.api.rate_limit.per_second(기본 1000)로 조절합니다.

재시도를 짜신다면 Idempotency-Key 를 쓰십시오

현장 회선은 끊깁니다. 응답을 못 받은 POST 는 「안 된 것」이 아니라 「모르는 것」입니다 — 그냥 다시 보내면 OPC 가 둘 생길 수 있습니다. 같은 키로 보내면 박스가 첫 번째 결과를 그대로 돌려줍니다.

1.2 페이지네이션​

목록 엔드포인트의 공통 쿼리입니다.

쿼리기본값설명
page00부터 세는 페이지 번호
size00 이면 전체를 돌려줍니다. 1..1000 이면 그만큼 잘라서 주고 meta.pagination 을 채웁니다

size 가 1000 을 넘거나 음수, page 가 음수면 400 INVALID_RANGE 입니다.

1.3 ID 규칙​

대상정규식
site_id^SITE_[A-Z0-9_]{1,60}$
opc_id^OPC_[A-Z0-9_]{1,60}$
tag_id^TAG_[A-Z0-9_]{1,60}$

data_type 은 Boolean · Integer · Long · Float · Double · String 여섯뿐입니다(Cassandra ENUM 과 일치). opc_type 은 드라이버가 가진 프로토콜 상수 화이트리스트이고(LS · OPCUA · MODBUS · MODBUS_RTU · HTTP · MELSEC · S7 · EIP …), 목록의 정본은 GET /v3/api-docs 입니다. 화이트리스트 밖의 값은 field_errors[].reason = "not_in_whitelist" 로 거부됩니다.


2. 엔드포인트 일람​

52개 전부입니다. OpenAPI 정본은 박스의 GET /v3/api-docs 이고, 화면으로 보려면 /public/swagger-ui/index.html 입니다.

2.1 OPC — 8​

MethodURL설명응답
GET/api/v1/opcOPC 목록 + 런타임 상태 + 60분 추이200 · 400
POST/api/v1/opcOPC 등록. tag_list 로 태그를 함께 넣을 수 있습니다201 · 400 · 409
GET/api/v1/opc/{opcId}상세 + ETag. ?include=tags 로 태그 배열을 함께200 · 404
PUT/api/v1/opc/{opcId}수정(If-Match 지원)200 · 404 · 412
PATCH/api/v1/opc/{opcId}부분 수정 — 보낸 필드만 바꿉니다200 · 400 · 404 · 412
DELETE/api/v1/opc/{opcId}OPC + 하위 태그까지 삭제200 · 404 · 412
POST/api/v1/opc/{opcId}/start수집 시작200 · 404
POST/api/v1/opc/{opcId}/stop수집 중지200 · 404

POST 필수 7필드 — site_id · opc_id · opc_name · opc_type · opc_agent_ip · opc_agent_port(1~65535) · timecycle(100ms 이상). 위반은 한꺼번에 돌려줍니다(field_errors[]).

tag_list 로 태그를 함께 등록합니다. 연결 하나에 태그 수십 개를 넣는 것이 보통이라, 등록을 두 번 나누면 OPC 는 생겼는데 태그가 없는 중간 상태가 남습니다. 한 번에 보내십시오 — OPC 만 보내고 나중에 POST /api/v1/opc/{opcId}/tag 로 하나씩 붙이는 것도 됩니다.

2.2 Tag — 10​

MethodURL설명응답
GET/api/v1/tag전체 태그 목록200 · 400
GET/api/v1/opc/{opcId}/tag한 OPC 의 태그 목록200 · 400
POST/api/v1/opc/{opcId}/tag태그 단건 등록201 · 400 · 409
GET/api/v1/tag/{tagId}태그 상세200 · 404
PUT/api/v1/tag/{tagId}태그 수정200 · 404
DELETE/api/v1/tag/{tagId}태그 삭제200 · 404
GET/api/v1/tag/{tagId}/value마지막 값. ?fresh=true 면 장치에서 새로 읽습니다200 · 404 · 503
POST/api/v1/tag/{tagId}/value값 쓰기 + 쓴 뒤 다시 읽기(actual_read)200 · 400 · 404 · 503
POST/api/v1/tag-values:read여러 태그의 캐시 값을 한 번에(최대 100건)200 · 400
GET/api/v1/tag/{tagId}/points시계열 조회 + 다운샘플200 · 400 · 500

POST 필수 6필드 — site_id · opc_id(경로에서 자동) · tag_id · tag_name · data_type · plc_address.

points 의 쿼리: from·to(epoch ms, from < to) · minutes(기본 10, 11440) · limit(기본 600, 15000 — 넘으면 등간격으로 솎아서 줍니다).

쓰기 실패의 코드 매핑 — 태그 없음 404 TAG_NOT_FOUND · 연결 끊김 503 DISCONNECTED · 수집 정지 503 OPC_STOPPED · 읽기전용 태그 400 RW_VIOLATION · 타입 불일치 400 TYPE_MISMATCH · 그 외 500 INTERNAL_ERROR.

2.3 앱(도커 컨테이너) — 9​

MethodURL설명응답
GET/api/v1/app컨테이너 목록200 · 400
POST/api/v1/app이미지 pull 후 run201 · 400 · 409 · 502
GET/api/v1/app/{name}docker inspect 결과200 · 404
DELETE/api/v1/app/{name}삭제(?image=true 면 이미지까지)200 · 404
POST/api/v1/app/{name}/start시작200 · 404
POST/api/v1/app/{name}/stop중지200 · 404
POST/api/v1/app/{name}/restart재시작200 · 404
GET/api/v1/app/{name}/logs?lines=N마지막 N 줄(1~5000, 기본 200)200 · 404
GET/api/v1/app/{name}/statsCPU · 메모리 · 네트워크 · 디스크 스냅샷200 · 404

name 과 image 는 정규식 + 셸 메타문자 차단을 통과해야 합니다(; & | \ $ ( ) < > \ " ' 공백). memory_limit_mb는 16 이상,cpu_limit은 0.1 이상,network는bridge·host·none` 입니다.

배포 실패는 단계를 알려 줍니다 — pull 실패 502 DOCKER_PULL_FAILED, run 실패 502 DOCKER_RUN_FAILED, 같은 이름이 이미 있으면 409 CONTAINER_EXISTS.

2.4 시스템 — 12​

MethodURL설명인증
GET/api/v1/system/healthliveness + 컴포넌트 8종 확인불필요
GET/api/v1/system/readyreadiness(모니터 + V5 클라이언트)불필요
GET/api/v1/system/version버전 · 빌드일 · 이미지 태그 · 컨테이너 여부불필요
GET/api/v1/system/time박스의 현재 시각(epoch ms)필요
POST/api/v1/system/time/sync플랫폼 시각에 맞춥니다({"platform_time":…})필요
GET/api/v1/system/logs최근 로그를 JSON 으로필요
GET/api/v1/system/logs/download로그 디렉터리를 tar.gz 로필요
GET/api/v1/system/eventsSSE 스트림(text/event-stream)필요
GET/api/v1/system/upgrade/check다운로드 서버의 최신 버전과 비교필요
POST/api/v1/system/upgrade업그레이드 트리거필요
GET/api/v1/system/upgrade/status업그레이드 진행 상태(polling)필요
POST/api/v1/system/firmware펌웨어 업데이트 트리거필요
POST/api/v1/system/reboot5초 뒤 호스트 재부팅강제
POST/api/v1/system/restart3초 뒤 엣지 에이전트 재시작강제
reboot · restart 는 인증을 «끌 수 없습니다»

edge.rest.api.auth=false 로 인증을 꺼 두어도 이 둘만은 세션 로그인이나 API 키를 요구합니다. 아무나 부를 수 있으면 그것 자체가 서비스 거부이기 때문입니다.

2.5 모니터링 · 엣지 — 4​

MethodURL설명
GET/api/v1/monitoring가동시간 · OPC 수 · 등록 태그 · 스캔 속도
GET/api/v1/edgeedge_id · site_id · 버전 · 호스트명 · 모드
GET/api/v1/edge/status엣지 상태 요약
GET/api/v1/transfer나가는 전송(MQTT · SparkplugB · 플랫폼 API)별 상태

monitoring 은 부팅 직후 모니터가 아직 없으면 503 MONITOR_NOT_READY 를 냅니다 — 고장이 아니라 「아직」입니다.

2.6 이중화(HA) — 4​

MethodURL설명
GET/api/v1/ha/status이 엣지의 쓰기 권한 · 모드(manual/auto) · 언제부터 · 상대 박스의 상태
PUT/api/v1/ha/write-authority이 엣지의 쓰기 권한을 올리거나 내립니다({"enabled":true})
PUT/api/v1/ha/failover/override자동 페일오버에 수동 개입 — auto 모드에서만(아니면 409 NOT_AUTO_MODE)
POST/api/v1/ha/definitions태그 정의를 상대에게 복제

쓰기 권한 — 읽기 전에 알아야 할 것​

무엇을 막습니까. 권한이 없는 엣지는 실 PLC 쓰기를 거절합니다(WRITE_NOT_AUTHORIZED). 설정 키는 edge.plc.write_authority 이고 없으면 true 입니다 — 갓 설치한 박스는 권한을 가진 채로 출발합니다.

HTTP 수집은 이 게이트를 지나지 않습니다 — 다만 «항상» 은 아닙니다

opc_type 이 HTTP 인 연결은 장치로 내보내는 쓰기가 아니라 들어오는 수집입니다. 그래서 권한을 내린 엣지에서도 HTTP 수집은 계속됩니다 — 이중 쓰기 위험이 없기 때문입니다.

⚠ 부팅 직후에는 그 HTTP 쓰기도 잠깐 거부될 수 있습니다. 게이트는 opc_type 을 캐시에서 읽는데, 캐시가 아직 안 찼거나 조회가 실패하면 「실 PLC」로 간주하고 막습니다(안전한 쪽으로 넘어지는 의도된 동작입니다). 그리고 그 조회 실패는 로그를 남기지 않습니다.

그래서 「HTTP 는 계속된다더니 거부됐다」를 만나면 기동 직후인지 먼저 보십시오. 캐시가 차면 저절로 풀립니다 — 설정을 고칠 일이 아닙니다.

🔴 이 API 는 «옮기지» 않습니다 — 올리기와 내리기 두 번의 독립된 호출입니다

이 호출은 자기 박스만 바꿉니다. 상대에게 묻지도, 잠그지도, 넘겨받지도 않습니다. 조율하는 코드가 없습니다.

그래서 두 박스가 동시에 권한을 갖는 상태가 실제로 가능합니다. 순서를 지키십시오 —

  1. A 를 내린다 — PUT /api/v1/ha/write-authority {"enabled":false}
  2. A 가 false 인 것을 확인한다 — GET /api/v1/ha/status
  3. B 를 올린다

반대로 하면 그 사이에 둘 다 true 인 창이 생기고, 그 창에서 두 박스가 같은 PLC 에 씁니다.

GET /api/v1/ha/status 의 peer.write_authority 가 상대 값을 보여주기는 합니다. 그러나 그것은 관측이지 강제가 아닙니다 — 상대가 권한을 갖고 있어도 이 호출은 막히지 않습니다.

persisted:false 는 「실패」가 아니라 「재시작하면 사라진다」입니다

토글은 두 단계입니다. ① 라이브 반영 — 게이트가 즉시 새 값을 봅니다(재시작 불필요). ② app.properties 영속화 — 이것이 실패해도 ①은 유지되고 persisted:false 로 보고합니다.

즉 지금은 먹었는데 다음 재시작에 옛 값으로 돌아갑니다. 권한을 내렸다고 믿고 재시작했더니 다시 올라와 있는 것이 현장에서 제일 물리기 쉬운 자리입니다. 재시작 전에 원인을 해결하십시오 — 대개 /etc/kopens/conf 의 쓰기 권한입니다. 진단은 응답이 아니라 서버 로그에 있습니다:

[HA] write-authority persist to app.properties failed — applied live only

since 는 「마지막으로 제어권이 옮겨간 시각」입니다. 값이 실제로 바뀐 경우에만 갱신되므로, 같은 값을 다시 보내도 움직이지 않습니다.

감사는 항상 남습니다 — 바뀌면 WARN, 안 바뀌어도 INFO 에 [no-op] 를 붙여 남기고 둘 다 호출자 IP 와 계정을 포함합니다.

[HA][AUDIT] write-authority false -> true by 10.0.0.5 (admin)

⚠ 전이 이력과 플랫폼 진단 푸시는 바뀔 때만이고, 그 둘이 실패해도 토글은 깨지지 않습니다(로그에만 남습니다). 「이력에 없다」가 「안 바뀌었다」는 아닙니다 — 확인은 status 로 하십시오.

엣지가 스스로 알려 주는 두 가지. 쌍을 감시하는 쪽이 어긋난 상태를 로그에 남깁니다. 설정을 고치지는 않습니다 — 운영자가 합니다.

[HA] DUAL WRITE AUTHORITY detected — both edges have edge.plc.write_authority=true.
[HA] NO WRITE AUTHORITY — both edges have edge.plc.write_authority=false.

뒤쪽은 어디에서도 PLC 쓰기가 안 되는 상태입니다 — 「둘 다 내린 뒤 올리는 것을 잊은」 경우입니다.

페일오버 수동 개입 — PUT /api/v1/ha/failover/override​

자동 페일오버가 이 엣지에 대해 판단하는 것을 멈추고 역할을 고정합니다. 푼 뒤에야 자동 판단이 돌아옵니다.

role뜻
"WRITER"하트비트를 계속 쓰고 권한을 쥡니다
"STANDBY"쓰기를 멈추고 권한을 놓습니다
null개입을 해제합니다 — 자동 판단으로 돌려보냅니다
{ "data": { "override": "WRITER", "previous": null }, "meta": { "status": "OK" } }
  • auto 모드 전용입니다. 자동 페일오버가 돌고 있지 않으면 409 NOT_AUTO_MODE 입니다 — 앞의 write-authority 는 두 모드 모두에서 되므로, 수동 운용 중이라면 그쪽을 쓰십시오.
  • WRITER·STANDBY 는 대소문자를 가리지 않습니다.
  • 값이 셋 중 하나가 아니면 400 VALIDATION_FAILED 에 field_errors:[{field:"role", reason:"invalid_value"}] 가 옵니다.

시각 맞추기 — POST /api/v1/system/time/sync​

{ "platform_time": 1789780169674 }

epoch 밀리초(양수)이고 숫자든 숫자 문자열이든 받습니다. platform_time 을 빼면 박스에 설정된 NTP 원본에서 맞춥니다 — 값을 보내는 것과 생략하는 것이 서로 다른 동작입니다.

응답은 {"applied":true,"method":"system","before":…,"after":…} 꼴입니다. before/after 를 비교하면 실제로 얼마나 틀어져 있었는지가 나옵니다.

인증이 «검증보다 먼저» 입니다

시스템을 바꾸는 호출이라 본문을 보기 전에 인증부터 확인합니다. 키나 세션이 없으면 401 이고, 인증이 된 뒤에 본문이 틀리면 400 VALIDATION_FAILED 입니다. 순서가 반대인 API 와 헷갈리지 마십시오 — 잘못된 본문에 401 이 오면 그것은 본문 문제가 아닙니다.

2.7 백업 · 복구 — 2​

MethodURL설명
POST/api/v1/connect/backup모든 OPC + 모든 태그를 JSON 하나로
POST/api/v1/connect/restore그 JSON 을 그대로 적용(upsert)

대칭입니다 — backup 응답을 그대로 restore 본문으로 보내면 됩니다. restore 응답의 skipped[] 를 반드시 보십시오: 개별 행이 실패해도 전체가 실패하지 않고 건너뛴 뒤 카운트만 줄어듭니다.

2.8 계산식 — 1​

MethodURL설명
POST/api/v1/tags/formula/preview계산식을 저장하기 전에 결과를 미리 봅니다 — 계산식

2.9 인증 없이 열리는 넷​

ApiAuthFilter 를 통과하는 것은 이 넷뿐입니다. 어떤 OPC·태그 정보도 내보내지 않습니다.

GET /api/v1/system/health · GET /api/v1/system/ready · GET /api/v1/system/version · GET /api/health(버전 없는 별칭)

«인증 불필요» 라는 것은 키를 검증할 수 없다는 뜻입니다

이 넷은 X-API-Key 가 없어도, 틀려도 200 을 돌려줍니다. 그래서 키가 제대로 바뀌었는지 확인할 때는 이 경로를 쓰면 안 됩니다 — GET /api/v1/opc 처럼 키가 필요한 경로로 확인하십시오.

/health 는 여덟 칸을 봅니다. 여덟이 전부 UP 이면 status=UP + 200, 하나라도 아니면 status=DEGRADED + 503 입니다.

{
"data": {
"status": "UP",
"uptime_ms": 1720008,
"components": {
"cassandra": "UP", "redis": "UP", "mqtt": "UP", "node_red": "UP",
"opc_ua": "UP", "edge_core": "UP", "tse": "UP", "grafana": "UP"
}
},
"meta": { "timestamp": 1778925572946, "request_id": "..." }
}

🔴 503 에도 components 가 그대로 들어옵니다. 그게 진단의 전부입니다 — 상태코드만 보고 본문을 버리면 여덟 중 무엇이 죽었는지 알 길이 없습니다.

세 칸은 이름만 보면 오해하기 쉬워 따로 적습니다.

칸실제로 보는 것
edge_corereadiness 입니다 — 수집기·모니터가 떴는지. 부팅 레이스 중이면 DOWN 이고 그래서 health 가 503 입니다. 플랫폼 연결과 무관해서 STANDALONE 박스에서도 뜻이 있습니다
opc_ua포트가 고정이 아닙니다. app.properties 의 opc.ua.server.tcp.port 를 따릅니다(개발 박스는 4840, 기본은 12000). 예전에 12000 으로 하드코딩했다가 개발 박스가 영원히 DOWN 으로 보고된 적이 있습니다
grafana2026-07 이미지부터 HTTPS 입니다. 평문 TCP 로 찔러 보면 Grafana 가 10초마다 TLS handshake error … EOF 를 로그에 남깁니다 — 그래서 박스는 핸드셰이크를 끝까지 하는 프로브를 먼저 쓰고, 구형 스택을 위해 TCP 확인을 폴백으로 둡니다

cassandra(9042) · redis(6379) · mqtt(1883) · node_red(1880) · tse(7800) 는 고정 포트의 TCP 확인입니다.

/version 예시:

{
"data": {
"product_name": "PlantPulse Edge",
"version": "2026",
"build_date": "20260523",
"image_tag": "2026-20260523",
"container_mode": true
},
"meta": { "timestamp": 1778925572946, "request_id": "..." }
}

OTA upgrade.sh 가 /api/health 를 300초 동안 찔러 자동 롤백을 판단하고, 도커 HEALTHCHECK 도 이 경로를 씁니다.

2.10 API 가 아닌 것 — /ui/opcua/* · /config/*​

박스에는 화면이 쓰는 경로도 있습니다. 새 클라이언트는 쓰지 마십시오.

  • /ui/opcua/info · /tree · /history — 내장 OPC UA 서버 화면 전용입니다. 제품의 v1 API 정본은 이것들을 폐기로 선언했습니다. 코드는 아직 답하지만 계약이 아닙니다.
  • /config/load · /save · /restart · /reboot · /upgrade · /firmware · /backup · /temp-clean · /platform/test — 관리자 화면이 쓰는 경로입니다. 같은 일을 하는 /api/v1/system/* 이 있으니 그쪽을 쓰십시오 — envelope 도 에러 코드도 v1 규약을 따릅니다.
  • /api/http(APIController) — 폐기. POST /api/v1/tag/{tagId}/value 를 쓰십시오.

3. 요청 / 응답 예시​

아래는 전부 현재 envelope 입니다. 옛 문서에 있던 {"result":"OK", ...} 모양의 예시는 제품과 맞지 않아 걷어냈습니다 — 그 모양으로 짠 클라이언트는 지금 동작하지 않습니다.

3.1 OPC 등록​

curl -sk -X POST "https://<엣지>/api/v1/opc" \
-H "X-API-Key: <키>" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"site_id": "SITE_00001",
"opc_id": "OPC_LS_XBM_0001",
"opc_name": "1라인 LS XBM",
"opc_type": "LS",
"opc_agent_ip": "192.168.0.10",
"opc_agent_port": 2004,
"timecycle": 1000,
"tag_list": [
{ "site_id": "SITE_00001", "opc_id": "OPC_LS_XBM_0001",
"tag_id": "TAG_LS_XBM_0001", "tag_name": "1호기 토출압력",
"data_type": "Float", "plc_address": "%MW100" }
]
}'

tag_list 는 빼도 됩니다 — 그러면 OPC 만 생깁니다.

201 + Location: /api/v1/opc/OPC_LS_XBM_0001:

{
"data": { "info": { "opc_id": "OPC_LS_XBM_0001", "opc_name": "1라인 LS XBM" } },
"meta": { "timestamp": 1778925572946, "status": "OK", "request_id": "..." }
}
opc_agent_port 와 timecycle 은 숫자로 보내도 됩니다

저장소의 해당 컬럼이 문자열이라 서버가 받아서 바꿔 넣습니다. 숫자로 보내고 문자열로 돌려받아도 이상한 것이 아닙니다.

3.2 삭제​

{
"data": { "deleted": true, "opc_id": "OPC_X" },
"meta": { "timestamp": 1778925572946, "status": "OK", "request_id": "..." }
}

DELETE 도 200 + 본문입니다. Swagger 주석에 204 로 적힌 것이 있어도 실제 동작은 200 입니다 — v1 은 모든 메서드가 envelope 을 돌려줍니다.

3.3 실패는 이렇게 옵니다​

POST /api/v1/opc/OPC_NONE/start — 404:

{
"meta": { "timestamp": 1778925572946, "status": "ERROR", "request_id": "..." },
"errors": [ { "code": "OPC_NOT_FOUND", "message": "OPC OPC_NONE 가 존재하지 않습니다." } ]
}

data 가 아예 없습니다(null 필드는 직렬화에서 생략됩니다). data 의 유무로 성패를 가르지 마시고 HTTP 상태나 meta.status 로 가르십시오.

3.4 여러 태그 값을 한 번에​

curl -sk -X POST "https://<엣지>/api/v1/tag-values:read" \
-H "X-API-Key: <키>" -H "Content-Type: application/json" \
-d '{ "tag_ids": ["TAG_LS_XBM_0001", "TAG_LS_XBM_0002"] }'
{
"data": [
{ "tag_id": "TAG_LS_XBM_0001", "value": "1234", "timestamp": "2026-05-15T11:32:45.000Z", "quality": "SUCCESS" },
{ "tag_id": "TAG_LS_XBM_0002", "value": null, "timestamp": null, "quality": "PENDING" }
],
"meta": { "timestamp": 1778925572946, "status": "OK", "request_id": "..." }
}

🔴 quality 를 보십시오. PENDING 은 「값이 0」이 아니라 **「아직 한 번도 안 읽혔다」**입니다. 그것을 0으로 읽으면 아무도 들여다보지 않은 설비가 「정상」으로 보고됩니다.

배열이 비었거나 100건을 넘으면 400(MISSING_FIELD / TOO_MANY)입니다.

3.5 재기동​

{
"data": { "message": "reboot scheduled in 5 seconds" },
"meta": { "timestamp": 1778925572946, "status": "OK", "request_id": "..." }
}

202 입니다 — 「했다」가 아니라 **「하기로 했다」**입니다. 응답을 받은 시점에는 아직 살아 있습니다.

3.6 아직 준비 안 됨​

GET /api/v1/system/ready — 503:

{
"meta": { "timestamp": 1778925572946, "status": "ERROR", "request_id": "..." },
"errors": [ { "code": "NOT_READY", "message": "엣지의 일부 구성요소가 준비되지 않았습니다." } ]
}

3.7 백업과 복구는 대칭입니다​

{
"data": {
"opcs": [ { "opc_id": "OPC_LS_XBM_0001", "opc_name": "..." } ],
"tags": [ { "tag_id": "TAG_LS_XBM_0001", "opc_id": "OPC_LS_XBM_0001" } ],
"count": { "opc": 1, "tag": 1 },
"exported_at": 1778925572946
},
"meta": { "timestamp": 1778925572946, "status": "OK", "request_id": "..." }
}

data 를 그대로 POST /api/v1/connect/restore 의 본문으로 보내면 됩니다. 복구 응답의 skipped[] 를 반드시 확인하십시오 — 개별 행이 실패해도 전체는 200 이고, 건너뛴 것만 거기 적힙니다. count 만 보면 조용히 덜 복구됩니다.


4. 에러 코드​

🔴 v1 은 실패를 HTTP 상태로 말합니다. 옛 RPC 방식({result:"ERROR"} 를 HTTP 200 으로 감싸던 것)은 폐기됐습니다. 상태코드를 안 보고 본문의 result 를 찾는 클라이언트는 모든 실패를 성공으로 읽습니다.

판정은 두 가지 중 하나로 하십시오 — HTTP 상태코드, 또는 meta.status("OK" / "ERROR"). errors[] 를 뒤질 필요 없이 한 키로 갈립니다.

codeHTTP뜻
MISSING_BODY400요청 본문이 비었습니다
MISSING_FIELD400필수 필드 누락
INVALID_JSON400JSON 파싱 실패
INVALID_RANGE400page·size·from~to 범위 위반
INVALID_ARGUMENT400일반 인자 오류(이미지·볼륨 화이트리스트 등)
INVALID_NAME400컨테이너 이름에 인젝션 문자
VALIDATION_FAILED400여러 필드가 한꺼번에 틀렸습니다 — field_errors[] 를 보십시오
TOO_MANY400일괄 요청 한도(100건) 초과
RW_VIOLATION · TYPE_MISMATCH400태그 쓰기 실패 — 읽기전용 태그 / 타입 불일치
UNAUTHORIZED401인증 누락 또는 무효
HTTPS_REQUIRED403평문 HTTP 로 왔습니다 — 키 문제가 아닙니다
OPC_NOT_FOUND · TAG_NOT_FOUND · APP_NOT_FOUND404대상이 없습니다
CONTAINER_EXISTS409같은 이름의 컨테이너가 이미 있습니다
PRECONDITION_FAILED412If-Match 의 ETag 가 다릅니다 — 그 사이 누가 고쳤습니다
START_FAILED · STOP_FAILED422OPC 수집 제어 실패
DOCKER_PULL_FAILED · DOCKER_RUN_FAILED502도커 명령 실패
MONITOR_NOT_READY · NOT_READY · DISCONNECTED · OPC_STOPPED503아직/끊김 — 요청이 틀린 것이 아닙니다
MISCONFIGURED500edge.rest.api.key 가 비었습니다
INTERNAL_ERROR · *_FAILED500그 밖의 내부 오류
503 을 400 처럼 다루지 마십시오

DISCONNECTED · OPC_STOPPED · MONITOR_NOT_READY 는 요청이 틀려서가 아니라 지금 못 해서입니다. 같은 요청을 나중에 다시 보내면 됩니다. 400 처럼 「고쳐서 다시」로 처리하면 고칠 것이 없는데 고치려 들게 됩니다.


5. 동작 메모​

  • 태그 단건 조회: APP_TAG.xml 의 R03 (WHERE TAG_ID = :tag_id ALLOW FILTERING).
  • OPC 등록 · 태그 등록 뒤에는 ConnectService.restartComponents() 가 돌아 캐시를 다시 읽고 OPC-UA 서버를 재시작합니다 — 등록 직후 잠깐 수집이 끊기는 것은 이 때문입니다.
  • 값 쓰기 (tagWriteByTagId): HTTP 는 HTTPDriver.bind(), 그 외는 driver.write(ProtocolAddress). 실패 시 (ok=false) 친절 힌트 (EIP 펌웨어 호환성 등) 포함. write 후 즉시 read 한 결과를 actual_read 로 함께 반환 (HTTP 제외).
  • 트랜스퍼 헬스 (/api/v1/transfer): TransferRegistry 에 등록된 모든 outbound 컴포넌트의 getStatus() 를 모음. Sparkplug B 카드 (/ui/main) 가 5초 주기로 호출.
  • OPC UA 서버 (/ui/opcua/*) 는 내부망 폴링 (2초) — OPCAndTagsCache.getInstance() + LastValueMap.getInstance() 만 사용해 부하 적음.
  • 레거시 deprecated: /api/http (APIController) — /api/v1/tag/{tagId}/value 사용 권장.

6. 코드 위치​

  • 컨트롤러: src/plantpulse/app/edge/api/v1/ — OpcAPI · TagAPI · AppAPI · SystemAPI · SystemTimeAPI · MonitoringAPI · EdgeStatusAPI · HaAPI · ConnectIoAPI · FormulaAPI
  • MCP 서버: src/plantpulse/app/edge/module/mcp/ — MCP 서버
  • envelope · 공통 응답: api/v1/RestApiSupport.java · api/v1/dto/Api*.java
  • OPC UA 서버: src/plantpulse/app/edge/module/opcua/OPCUAViewerController.java
  • 업그레이드 프록시: src/plantpulse/app/edge/module/system/config/ConfigController.java
  • 비즈니스 로직: src/plantpulse/app/edge/module/connect/ConnectService.java
  • 응답 envelope helper: plantpulse.app.edge.core.web.ControllerSupport (ok() / error())