본문으로 건너뛰기
Paul's Dev Notes

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 타임아웃까지.

· note scripting integration
검증 인스턴스: OOTB Australia · 검증일 2026-07-19

개요

흔한 오해 두 가지를 먼저 정리한다.

오해 (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})을 완성할 수 있다.

/api{namespace}{version}{api_id}{relative_path}고정scope 이름 / company code선택 — 없으면 default 라우팅서비스 이름 (편집 가능)resource 경로 ({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.bodyRESTAPIRequestBody 객체다. 세 접근자를 제공한다.

접근자반환형기본 CT 안전비기본 CT 결과
body.data파싱된 JS 객체 (JSON/XML)✅ 안전HTTP 500
body.dataString원문 문자열✅ 안전HTTP 500
body.dataStreamGlideScriptableInputStream✅ 안전✅ 안전 (유일한 선택지)
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.BadRequestError400
sn_ws_err.NotFoundError404
sn_ws_err.NotAcceptableError406
sn_ws_err.UnsupportedMediaTypeError415
sn_ws_err.ServiceError직접 지정 (setStatus 명시 권장)

ServiceErrorsetStatus() 를 생략하면 기본 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 PolicyAPI 별 허용 인증 방식 강제 + IP/location 제한플러그인(com.glide.rest.policy) 필요정확한 테이블·필드명은 인스턴스에서 확인 권장
REST_Endpoint ACLendpoint path + HTTP operation 매칭 인가 — WHAT모든 릴리스Requires ACL authorization 활성화 후 평가
Path-Based REST ACLresource 편집 없이 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
  • setBodyresult 래퍼 포함 — 래퍼 제거는 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 의 선택 기준을 이어서 다룰 수 있습니다.