ServiceNow Scripted REST API 심화 — 커스텀 엔드포인트 설계·request/response 계약·인증·버전·함정
sys_ws_definition/sys_ws_operation 2-테이블 구조와 /api/{namespace}/{version}/{api_id}/{relative_path} URL 계약, RESTAPIRequest(pathParams 문자열 vs queryParams 배열 비대칭, body 단일소비, 비기본 Content-Type 500)·RESTAPIResponse(setBody result 래퍼·sn_ws_err status 매핑·getStreamWriter 선설정), 인증(WHO) vs ACL 인가(WHAT) 두 독립 축과 snc_internal 함정·GlideRecordSecure, API 레벨 버전관리, rate limit 429 vs Transaction Quota 타임아웃까지.
개요
흔한 오해 두 가지를 먼저 정리한다.
오해 (1) — “Requires authentication 만 켜면 데이터가 보호된다”
아니다. 인증(WHO)과 ACL 인가(WHAT)는 서로 독립적인 축이다. 인증만으로는 어떤 ACL 도 자동 평가되지 않는다. 인증 성공 사용자가 읽어선 안 되는 레코드에 접근하는 것을 막으려면 별도 단계가 필요하다.
오해 (2) — “Requires authentication 미체크는 테스트 전용”
아니다. 체크를 해제하면 Guest 컨텍스트로 누구나 호출할 수 있다. 실전에서 반복되는 노출 사고 패턴의 출발점이다.
내부 통신(폼 클라이언트 → Script Include)에는 GlideAjax 가 맞다 — 배경은 Client Script onSubmit 에서 GlideAjax 동기화에서 다뤘다. 외부 시스템·서드파티·모바일 앱과의 API 계약에는 Scripted REST 가 필요하다. 단 단순 단일 테이블 CRUD 는 Table API 로 시작하고, ETL 파이프라인은 Import Set API 를 고려한다. “커스텀 로직·커스텀 응답 payload/status·여러 테이블 orchestration”이 필요할 때 Scripted REST 로 넘어오는 것이 원칙이다.
이 글의 범위: URL 계약, RESTAPIRequest/RESTAPIResponse 객체 계약, 인증·인가 두 독립 축, API 레벨 버전 관리, 429·Transaction Quota 함정. record/field ACL 평가는 ACL 평가 순서와 디버깅으로, 대용량 순회·페이징은 GlideAggregate 한계와 GlideRecord 페이징으로 위임한다.
OOTB(Out-of-the-Box, 기본 제공) Australia 릴리스 기준입니다. Scripted REST 의 request/response 객체·인증 옵션·버전 라우팅·에러 메시지 문구는 인스턴스 버전·플러그인·릴리스에 따라 다를 수 있으니, 정확한 필드·경로·에러 문자열은 대상 인스턴스에서 확인을 권장합니다.
§1 — 저장 구조와 URL 계약
API 정의는 sys_ws_definition 테이블에 저장된다. 각 resource(operation, 즉 단일 HTTP 메서드×경로 조합)는 sys_ws_operation 테이블에 저장되며, web_service_definition reference 필드로 부모 API 를 지정한다. 하나의 API 아래 여러 resource 를 묶는 구조다. resource 는 Name·HTTP method·Relative path 세 요소로 구성된다.
HTTP method 는 GET/POST/PUT/PATCH/DELETE 가 일반 선택지이나, http_method 는 닫힌 목록이 아니다(파리 이후 HEAD 등 추가 가능). 5개로 단정하지 않는다.
namespace 는 read-only 자동 생성이다. scoped app 은 scope 이름(x_company_appname), global scope 는 시스템 속성 glide.appcreator.company.code 값을 쓴다. api_id 는 서비스 이름 기반 기본값이지만 편집 가능하다. 동일 relative path 에 GET·PUT·DELETE 를 각각 별도 resource 로 등록하면 REST 패턴(/item/{id})을 완성할 수 있다.
최종 URL 형태: https://<instance>.service-now.com/api/{namespace}/{api_id}/{relative_path} (버전 미포함), 또는 https://<instance>.service-now.com/api/{namespace}/{version}/{api_id}/{relative_path} (버전 포함).
§2 — request 읽기: pathParams(문자열) vs queryParams(배열) 비대칭
Scripted REST resource 스크립트는 IIFE(즉시 실행 함수 표현식) 스켈레톤으로 시작한다. request·response 는 스크립트 컨텍스트가 자동 제공하는 객체다.
(function process(/*RESTAPIRequest*/ request, /*RESTAPIResponse*/ response) {
// 여기에 로직 작성
})(request, response);
path parameter 는 relative path 의 {id} 자리를 채운다. request.pathParams.id 로 읽으며, 반환형은 문자열이다. path params 는 라우트 매칭에 필수이므로 값이 없으면 호출 자체가 이루어지지 않는다.
핵심 비대칭: request.queryParams.<name> 은 항상 배열이다. 단일 값을 전달해도 배열로 감싸진다. 문자열로 직접 접근하면 java.lang.ClassCastException: NativeArray cannot be cast to java.lang.String 가 발생한다.
// ⚠ 위험 — queryParams 를 문자열로 직접 접근, ClassCastException 위험
var status = request.queryParams.status; // 배열인데 문자열 취급
gs.info('status: ' + status.toUpperCase()); // 런타임 에러 가능
// ✅ 안전 — [0] 인덱싱으로 단일값 추출, pathParams 는 문자열 직접 사용
var id = request.pathParams.id; // 문자열
var status = request.queryParams.status[0]; // 배열 첫 요소 = 문자열
gs.info('id=' + id + ', status=' + status);
RESTAPIRequest 의 주요 멤버: body, pathParams, queryParams, queryString, uri, url, headers, getHeader(name), getSupportedResponseContentTypes().
§3 — request body: 단일소비, 비기본 Content-Type 500
request.body 는 RESTAPIRequestBody 객체다. 세 접근자를 제공한다.
| 접근자 | 반환형 | 기본 CT 안전 | 비기본 CT 결과 |
|---|---|---|---|
body.data | 파싱된 JS 객체 (JSON/XML) | ✅ 안전 | HTTP 500 |
body.dataString | 원문 문자열 | ✅ 안전 | HTTP 500 |
body.dataStream | GlideScriptableInputStream | ✅ 안전 | ✅ 안전 (유일한 선택지) |
body.hasNext() / nextEntry() | boolean / 다음 엔트리 | ✅ 안전 (다중 엔트리) | HTTP 500 |
기본 Content-Type 은 application/json·application/xml·text/xml 이다. 이 외 Content-Type 으로 전송된 body 에 data·dataString·hasNext()·nextEntry() 를 사용하면 HTTP 500 이 발생한다.
단일소비 함정: 세 접근자는 같은 단일 스트림을 소비한다. body.data 로 객체를 dot-walk 한 뒤 body.dataString 을 읽으면 빈 값이 돌아온다. 반대도 마찬가지다. 두 형식이 모두 필요하다면 dataString 을 먼저 캡처하고 JSON.parse() 로 직접 파싱하는 것이 안전하다.
// ✅ 비기본 Content-Type body 처리 — dataStream + GlideTextReader
var stream = request.body.dataStream;
var reader = new GlideTextReader(stream);
var raw = '';
var line;
while ((line = reader.readLine()) !== null) {
raw += line;
}
gs.info('raw body: ' + raw);
// ✅ JSON body 의 두 형식 동시 필요 시 — dataString 먼저, JSON.parse 로 이중 처리
var rawStr = request.body.dataString; // 먼저 raw 캡처
var parsed = JSON.parse(rawStr); // 파싱
gs.info('field: ' + parsed.someField + ', raw length: ' + rawStr.length);
application/x-www-form-urlencoded body 는 request.queryParams 배열로 파싱·노출되며 raw 원문이 보존되지 않는 경우가 있다. HMAC 서명 검증처럼 raw body 가 필요한 시나리오에서는 이 동작을 인스턴스에서 직접 확인하기를 권장한다.
§4 — response: setBody 자동직렬화·result 래퍼·sn_ws_err·getStreamWriter
RESTAPIResponse 의 주요 메서드: setStatus(Number), setBody(Object), setHeader(name, value), setHeaders(Object), setContentType(String), setLocation(String), setError(Object), getStreamWriter().
setBody(object) 는 Accept 헤더에 따라 JSON 또는 XML 로 자동 직렬화한다. JSON.stringify() 를 수동으로 거칠 필요가 없다. 단 자동직렬화 시 payload 가 top-level result 키로 래핑된다 — 클라이언트는 response.result.myField 처럼 접근해야 한다. 이 래퍼를 제거하고 싶다면 getStreamWriter() 로 직접 write 해야 한다.
에러 처리는 두 갈래다.
- 예외 throw → generic HTTP 500(스택트레이스 포함 응답), 구조화 어려움.
sn_ws_err네임스페이스 → HTTP status 와 에러 구조를 명시적으로 매핑.
sn_ws_err 고정 매핑:
| 클래스 | HTTP status |
|---|---|
sn_ws_err.BadRequestError | 400 |
sn_ws_err.NotFoundError | 404 |
sn_ws_err.NotAcceptableError | 406 |
sn_ws_err.UnsupportedMediaTypeError | 415 |
sn_ws_err.ServiceError | 직접 지정 (setStatus 명시 권장) |
ServiceError 에 setStatus() 를 생략하면 기본 status 가 명세에서 확인되지 않으므로, 사용 시 setStatus() 를 명시하기를 권장한다.
getStreamWriter() 선설정 함정: 스트림을 사용하면 setBody 자동직렬화와 Accept 협상이 무효화된다. setStatus()·setContentType() 는 스트림 획득 이전에 호출해야 한다. 순서가 뒤바뀌면 chunked 헤더가 누락되고 대용량 payload 전송이 실패하는 경우가 있다.
(function process(/*RESTAPIRequest*/ request, /*RESTAPIResponse*/ response) {
var id = request.pathParams.id;
var gr = new GlideRecord('incident');
if (!gr.get(id)) {
// ✅ 구조화된 404
response.setError(new sn_ws_err.NotFoundError('incident ' + id + ' not found'));
return;
}
// ✅ 정상 — setBody 가 result 래퍼로 자동직렬화
response.setStatus(200);
response.setBody({ number: gr.getValue('number'), state: gr.getValue('state') });
})(request, response);
// ✅ 스트림 응답 — setStatus·setContentType 먼저, getStreamWriter 나중
(function process(/*RESTAPIRequest*/ request, /*RESTAPIResponse*/ response) {
response.setStatus(200); // ← 스트림 획득 전 필수
response.setContentType('application/json'); // ← 스트림 획득 전 필수
var writer = response.getStreamWriter();
writer.writeString('{"ok":true}');
})(request, response);
§5 — 인증(WHO) vs 인가(WHAT) 두 독립 축
축 1 — WHO (Requires authentication)
신원 확인: 누가 호출하는가
- 체크 시: 유효 자격증명(Basic·OAuth) 요구, 스크립트는 인증 사용자 컨텍스트로 실행
- 미체크 시: Guest 컨텍스트로 누구나 호출 가능 — 실전 노출 사고의 출발점
- 이 체크 단독으로는 어떤 ACL 도 평가하지 않는다
축 2 — WHAT (Requires ACL authorization)
인가: 이 엔드포인트에 접근할 수 있는가
- 기본 미체크 — 별도로 활성화해야 평가됨
REST_Endpoint타입 ACL 을 endpoint/path 레벨에서 평가- resource 스크립트 안 GlideRecord 가 닿는 table/field ACL 은 자동 강제 안 됨
- table/field ACL 을 호출자 권한에 맞추려면 스크립트 내
GlideRecordSecure사용
REST_Endpoint ACL 의 operations: http_get·http_post·http_put·http_patch·http_delete.
snc_internal 함정: 신규 resource 생성 시 기본 ACL “Scripted REST External Default” 가 snc_internal 롤 을 부여한다. 이 롤은 사실상 전 내부 인증 사용자에게 열린 것이나 다름없다. 배포 전 해당 ACL 을 제거하고, 필요한 특정 롤만 요구하는 커스텀 REST_Endpoint ACL 을 직접 부착하는 것이 베스트 프랙티스다.
record/field ACL 3-phase 평가 원리와 진단은 ACL 평가 순서와 디버깅에서 다룬다. resource 스크립트의 cross-scope 권한은 Scoped vs Global 권한 경계에서 다룬다.
§6 — 인증 방식과 릴리스 currency
| 보안 레이어 | 역할 | 릴리스 가용성 | 비고 |
|---|---|---|---|
| Basic 인증 | 사용자명·패스워드 base64 — WHO | 모든 릴리스 | 단순하지만 credential rotation 필요 |
| OAuth 2.0 | 토큰 기반 인증 — WHO | 모든 릴리스 | 베스트 프랙티스 |
| REST API Access Policy | API 별 허용 인증 방식 강제 + IP/location 제한 | 플러그인(com.glide.rest.policy) 필요 | 정확한 테이블·필드명은 인스턴스에서 확인 권장 |
| REST_Endpoint ACL | endpoint path + HTTP operation 매칭 인가 — WHAT | 모든 릴리스 | Requires ACL authorization 활성화 후 평가 |
| Path-Based REST ACL | resource 편집 없이 read-only로 endpoint 보호 | Australia 도입 | 기존 resource-level ACL 에 추가로 평가(둘 다 통과 필요) |
Australia 업데이트: Application Registry 의 “OAuth API endpoint for external clients”, “OAuth JWT API endpoint for external clients”, “OIDC provider to verify ID tokens” 는 deprecated(삭제 아님) 상태로, 신규 구성은 Machine Identity Console 로 유도된다. Machine Identity Console 은 Zurich 도입 경험으로, OAuth 생성·인바운드 목록·Basic 식별을 통합 관리한다.
Path-Based REST ACL 은 Australia 에서 도입된 REST_Endpoint 타입 ACL 이다. resource path 와 HTTP operation 을 매칭하며, resource 스크립트 편집 없이 read-only Scripted REST 를 보호할 수 있다. 기존 resource-level ACL 과 별개로 추가 평가된다 — 두 레이어를 모두 통과해야 접근이 허용된다. 대상 인스턴스 버전에서 가용성을 확인하기를 권장한다.
RESTAPIRequest·RESTAPIRequestBody·RESTAPIResponse 핵심 클래스의 signature 는 Australia 기준 변경·deprecation 이 없다. 기존 코드 안정성에 영향이 없다.
§7 — 버전 관리: 세그먼트 위치 함정
위치 함정: 공식 URL 형식은 /api/{namespace}/{version}/{api_id}/{relative_path} 다. version 세그먼트는 namespace 뒤, api_id 앞이다. 커뮤니티 블로그 등에서 /api/{namespace}/{api_id}/{version}/{relative_path} 형태로 오표기하는 경우가 많지만 공식 기준에서 틀렸다.
// ✅ 올바른 버전 URL
https://<instance>.service-now.com/api/x_company/v1/my_api/incidents
// ⚠ 흔한 오류 — api_id 뒤에 version
https://<instance>.service-now.com/api/x_company/my_api/v1/incidents
버전 세그먼트는 optional 이다. 생략하면 default 버전으로 라우팅된다. default 버전이 지정되지 않은 경우 버전을 명시해야 하는 경우가 있으니 인스턴스에서 직접 확인하기를 권장한다.
버전은 API 레벨(sys_ws_definition) 에서 관리된다. resource 별로 독립 버전을 지정할 수 없으며, 한 API 의 모든 resource 가 버전을 공유한다. v1/v2 를 동시에 서빙하는 것은 가능하다.
deprecation 은 수동 관리다. 자동 sunset 기능은 없다. 구버전을 유지하면서 per-version 호출량을 모니터링하고, 이관 완료 후 제거하는 것이 권장 패턴이다. 테스트 완료 전 신버전을 default 로 전환하는 것은 피한다.
§8 — REST 고유 성능·안정성: 429 vs Transaction Quota
rate limit — HTTP 429
opt-in, Retry-After 헤더 제공
- OOTB 기본 룰 없음 — admin 이 System Web Services > REST > Rate Limit Rules(
sys_rate_limit_rules)에서 직접 생성 - 초과 시 HTTP 429 + Retry-After 헤더(초 단위)
- 우선순위: user > role > all-users
- 카운트는 노드 메모리, 30초마다 DB 커밋 → 룰 변경 최대 30초 지연
Transaction Quota — 타임아웃
실행 시간 초과 → 트랜잭션 취소
- System Definition > Transaction Quota Rules 로 제어
- 장시간 inbound REST 가 쿼터 초과 시 취소 → 스크립트에서
com.glide.rest.util.RESTRuntimeException발생 - ”Transaction cancelled: maximum execution time exceeded” 메시지
- 정확한 임계값은 인스턴스 구성에 따라 다름 — 직접 확인 권장
429(rate limit) 와 Transaction Quota(타임아웃) 는 다른 메커니즘이다. 혼동하면 잘못된 방향으로 대응하게 된다.
대용량 데이터를 한 REST 호출에 직렬화하면 타임아웃·메모리 압박이 생긴다. Scripted REST 는 자동 페이징을 제공하지 않는다 — sysparm_limit/offset 은 Table API 관례일 뿐, Scripted REST 에서는 스크립트가 직접 파라미터를 읽어 페이징 로직을 구현해야 한다.
깊은 페이지를 chooseWindow 의 OFFSET-스타일로 순진하게 매핑하면 페이지 번호 증가에 따라 DB 스캔 비용이 커진다. 전체 순회에는 keyset(cursor) 페이징 계약을 권장한다. 구체적인 순회 패턴은 GlideAggregate 한계와 GlideRecord 페이징에서 다룬다.
total count 가 필요하면 GlideAggregate + addAggregate('COUNT') 를 사용한다. getRowCount() 는 GlideAggregate 컨텍스트에서 신뢰하지 않는다.
마무리
설계 체크리스트를 두 열로 정리합니다.
계약·request·response
- URL version 세그먼트는 namespace 뒤, api_id 앞
queryParams는 항상 배열 —[0]인덱싱 필수- body 접근자는 단일소비 — 한 접근자만, 또는 raw 먼저 캡처
- 비기본 Content-Type body 는
dataStream+GlideTextReader setBody는result래퍼 포함 — 래퍼 제거는getStreamWriter- 스트림 사용 시
setStatus·setContentType을 스트림 획득 전 선설정 - 에러는
sn_ws_err.*로 구조화, throw 는 generic 500
보안·운영·currency
- 인증(WHO)·인가(WHAT) 두 축 독립 — 인증만으론 데이터 미보호
snc_internal기본 ACL 교체 → 특정 롤 커스텀 REST_Endpoint ACL- table/field 레코드 보호는
GlideRecordSecure - rate limit 은 opt-in — 필요 시
sys_rate_limit_rules생성, 초과 시 429 + Retry-After - 장시간 실행 → Transaction Quota 취소, 페이징 방어 필수
- Australia: Path-Based REST ACL 가용, Machine Identity Console 신규 경험
- 버전 관리는 API 레벨, resource 별 독립 버전 불가
관련 글: ACL 평가 순서와 디버깅 · GlideAggregate 한계와 GlideRecord 페이징 · Client Script onSubmit 에서 GlideAjax 동기화 · Scoped vs Global 권한 경계.
다음으로 어떤 방향이 궁금하신가요? OAuth 2.0 아웃바운드 통합(RESTMessageV2)을 직접 스크립팅하는 심화 패턴, 또는 IntegrationHub 와 Scripted REST 의 선택 기준을 이어서 다룰 수 있습니다.