실행 시맨틱, 제약, 보안

Script런타임에 어떻게 동작하는지(순서, 트랜잭션, 에러, 잠금), 저장 시 어떤 정적 제약을 받는지, 그리고 보안 모델을 정리합니다. 문법은 Statement 카탈로그값 표현식, 실전 조합은 쿡북을 참조하세요.

실행 순서

  • statements위에서 아래로 순차 실행됩니다. Return에 도달하면 그 지점에서 종료합니다.
  • 실행은 호출 요청을 처리하는 경로에서 인라인으로 이루어집니다. 호출의 응답이 곧 실행 결과이고(응답 형태는 Script 개요의 요청과 응답 참조), 한 번의 실행에 주어지는 시간은 아래 시간 예산에서 다룹니다.

실행 시맨틱

Guard (사전 조건)

전용 guard 문은 없습니다. Ifthen:[Return]으로 표현합니다. 조건 위반 시 결과를 반환하고 이후 statement는 실행하지 않습니다(guard가 없는 Script도 당연히 가능합니다).

{ "type": "If", "condition": { "<": [ "{ /wallet/fields/balance/en-US }", "{ /payload/fields/cost }" ] },
  "then": [ { "type": "Return", "value": { "ok": false, "reason": "insufficient credit" }, "statusCode": 402 } ] }

트랜잭션 없음과 best-effort 보상

Script트랜잭션이 아닙니다. 실패 시 엔진이 지금까지 한 작업의 보상(compensation)을 시도하고 에러 원인을 리턴하지만, 다음 한계가 있습니다(설계상 감수한 것입니다).

  • 삭제를 되돌리면sys.id가 만들어지므로, 이를 가리키던 참조가 깨집니다.
  • 외부 효과(Http)는 비가역입니다(이미 나간 호출과 과금은 되돌릴 수 없습니다).
  • 보상이 아예 실행되지 않아 미보상 상태가 남을 수 있습니다.

진짜 원자성이 필요하면 사용자가 Script로 직접 보상하거나, 되돌릴 수 없는 작업(외부 호출 등)을 맨 마지막에 배치합니다. "연쇄는 되는데 롤백은 안 되면서 안전해 보이는" 순서가 가장 위험합니다.

낙관적 잠금

update/patch 경합ResourceUpdateResourcePatchversion으로 좁힙니다. version(값표현식, Int)을 주면 대상의 현재 sys.version일치할 때만 갱신하고, 불일치는 버전 충돌 에러로 abort됩니다(Try/catch로 국소 처리 가능). 생략하면 검사 없이 last-write-wins입니다. 보통 ResourceReadResourceFind로 먼저 읽어 그 sys.version을 넘깁니다(쿡북의 낙관적 잠금 CAS 참조).

origin 기준 쓰기

쓰기는 항상 origin(draft)에 반영되고, delivery(CDA/ACDA) 노출은 publish로 제어합니다(ResourceCreate/ResourceUpdate/ResourcePatchpublish, 또는 ResourcePublish/ResourceUnpublish).

무엇이 실패인가

  • 진짜 실패는 statement 런타임 에러입니다: Http의 최종 status가 400 이상(4xx·5xx. ignoreStatusCode: true면 실패 아님)이거나 타임아웃, 응답 본문이 10MiB 초과, 리소스 작업 실패(대상 없음, 버전 충돌, 미지원 연산 등). 이런 실패는 엔진이 abort하고 보상하며, Try/catch/finally로 국소 처리할 수 있습니다.
  • Return은 에러가 아니라 정상 조기 종료입니다. catch 대상이 아닙니다(사용자 throw 개념 없음).
  • catch 안에서는 /error{ message }를 참조합니다. 어느 문에서 실패했는지는 담기지 않습니다.

서버측 집계는 건수만

건수는 ResourceCount가 서버에서 셉니다. 항목을 읽어 오지 않으므로 처리 항목 수 상한을 받지 않습니다.

sum과 group-by는 전용 서버 작업이 없습니다. 그런 집계는 ResourceForEach로 순회하면서 SetVar와 JsonLogic으로 직접 계산해야 하고, 따라서 처리 항목 수 상한에 묶입니다(수백만 건 집계엔 부적합). 건수만 필요하면 순회하지 말고 ResourceCount를 씁니다.

대기와 지연 없음

Script에는 Delay 문이 없습니다. Script한 번 실행되고 끝이며, 외부 job이 끝날 때까지 내부에서 대기하거나 폴링하지 않습니다.

정적 제약 (저장 시 검증)

아래는 Script저장(생성/수정)하는 시점에 검사됩니다. 위반하면 저장이 거부됩니다(런타임이 아니라 저작 시 실패). 어떤 위반이 어떤 코드로 거부되는지는 오류에서 다룹니다.

제약
한 정의당 외부 호출 최대(Http·EmailSend)플랜별(요금제 참조)
ResourceForEach 총 처리 항목 최대(선언한 limit이 없으면 이 값까지 순회, 매치가 남은 채 걸리면 실패)10,000
한 정의당 SetVar 최대(중첩 포함)10
한 정의당 Cache 최대(중첩 포함, 연산과 무관하게 합산)5. 넘으면 저장 거부
Loop·ResourceForEach 블록 안의 Cache저장 거부
Cache.key리터럴만, 최대 128자. 값 표현식이면 저장 거부
Cache.ttl1에서 30초 사이, 생략 시 5초. 벗어나면 저장 거부
한 정의당 전체 statement 최대(중첩 포함)플랜별(요금제 참조)
Http.retry 상한2
Regex.pattern 길이128자
ServiceUser변경하는 문저장 거부. 읽기 세 문만 이 리소스를 받습니다
anonymousCallEnabledtruewherecreatedBy: ":self"저장 거부

위 표의 고정 한도는 플랫폼이 정한 값이라 플랜과 무관하게 같습니다. 반면 한 정의당 전체 statement 수와 외부 호출 수는 플랜별 한도입니다. 이 둘은 유효성 오류가 아니라 플랜 한도라서, 초과하면 저장·수정이 플랜 한도 초과로 거부되고(같은 정의도 상위 플랜에서는 허용됩니다) 업그레이드로 풀립니다. 플랜별 수치는 요금제에 있습니다.

Media 파일 인제스트는 Http·EmailSend 같은 외부 호출과 달리 한 정의당 외부 호출 한도에 포함되지 않습니다.

ResourceForEach는 자식을 소유하는 복합 statement라 그 자체는 외부 호출 수에 세지 않습니다. onEach 안의 외부 호출 문(Http·EmailSend)이 세어집니다(정적으로는 1로 세지만 순회하며 항목마다 실제로 실행됩니다). onEach에는 외부 호출이나 Media 파일 인제스트를 담을 수 있으며, 이는 Loop의 body도 마찬가지입니다. 반복이 실제로 몇 번 도는지는 이 카운트에 들어가지 않고, 대신 아래 시간 예산에서 곱셈으로 들어갑니다.

값 길이 상한 (런타임)

서명과 텍스트 처리 문, 그리고 Cache는 다루는 값의 크기에 상한이 있습니다. 표현식의 길이가 아니라 그 표현식이 resolve된 값의 길이이고({ /rawPayload } 열여섯 자가 수십 KB를 가리킵니다), 그래서 저장 시점이 아니라 실행 중에 검사됩니다.

대상상한초과하면
Signaturevalue65,536자그 문이 실패(status 422)
Hashvalue128자그 문이 실패(status 422)
Regexvalue10,240자(10KiB)그 문이 실패(status 400)
Cachevalue10,240바이트(10KiB)그 문이 실패(status 422)
  • 넷 다 다른 런타임 실패와 같아서 Try/catch로 국소 처리할 수 있습니다.
  • Signature의 상한은 실제 제공자가 보내는 본문 크기에 맞춘 것입니다(결제 이벤트는 수 KB, 주문 웹훅은 수십 KB에 이릅니다). Hash는 필드 몇 개를 이어 붙이는 자리라 훨씬 좁습니다.
  • Regex.pattern의 128자는 위 정적 제약에 있는 저장 시 검사입니다. 이 길이는 폭주를 막는 장치가 아닙니다((a+)+$는 여섯 자로도 위험합니다). 폭주를 막는 것은 패턴을 리터럴로만 쓰게 한 규칙과 아래 시간 예산이고, 이 길이가 보장하는 것은 사람이 읽고 검토할 수 있는 크기뿐입니다.

시간 예산 (런타임)

한 번의 실행에 주어지는 시간은 하나의 식으로 정해집니다: min(30초 + 문들이 선언한 시간의 합, 180초).

  • 예산은 그 Script에서 계산합니다. 기본 예산에 정의가 선언한 시간만 더합니다. 선언된 시간은 HttpEmailSendtimeoutMs 하나뿐입니다. Http는 재시도할 때마다 자기 timeoutMs를 다시 쓰므로 timeoutMs × (1 + retry)로 세고, EmailSend는 재시도하지 않으므로 한 번으로 셉니다. timeoutMs를 적지 않으면 기본값(Http 30초, EmailSend 10초)으로 셉니다.
  • 선언된 시간이 없는 작업은 30초 기본 예산에서 나갑니다. 리소스 읽기와 쓰기, Media 파일 인제스트, 반복이 안에서 하는 일이 여기에 듭니다. 그래서 기본 예산이 형식적인 값이 아니라 실제 몫입니다.
  • 합치는 방법은 문의 구조를 따릅니다. 순서대로 놓인 문은 더하고, If는 두 분기 중 큰 쪽, Parallel은 브랜치 중 가장 큰 것을 취합니다. Loop는 body에 반복 횟수(maxIterations, 선언이 없으면 10,000)를, ResourceForEachonEach에 처리 항목 수(limit, 선언이 없으면 10,000)를 곱합니다.
  • 외부 호출이 없는 반복은 선언 시간이 0입니다. 그래서 30초 기본 예산이 실질 한도가 되고, 반복을 담은 Script가 실제로 걸리는 지점도 여기입니다.
  • 상한 180초는 저장을 막지 않고 잘라냅니다. 계산 결과가 상한을 넘어도 그 Script는 저장되고 실행되며, 180초에 도달하면 거기서 중단됩니다.

플랜별 개수 한도

ScriptOrganization당 개수가 플랜별로 제한됩니다.

PlanScript 개수
Free10
Basic30
Pro100
Enterprise무제한

이와 별개로, Script 정의가 담을 수 있는 statement 수와 외부 호출(Http·EmailSend) 수도 플랜별로 제한됩니다. 정의를 저장·수정할 때 그 플랜의 한도를 넘으면 거부되며, 구체 수치는 요금제를 참조하세요.

한도에 도달하면 새 Script 생성이 거부됩니다.

보안 모델

secret 헤더

Http.headerssecret:true 항목은 CMA(관리자) 전용으로, 최종 사용자(ServiceUser)에게 노출되지 않고 전송 직전에만 복호화됩니다. LLM API 키 같은 비밀을 여기에 둡니다(App Bundle로 패킹될 때도 secret 값은 마스킹되어 원본 Space 밖으로 나가지 않습니다).

SignaturesecretSpace 안에서 이 취급을 받지 않습니다. 암호화되지 않고 정의에 적은 그대로 저장되므로, 그 Script를 읽을 수 있는 역할에게는 값이 보입니다. 회원(ServiceUser)은 Script 정의를 읽을 수 없습니다(조회와 저작은 CMA 전용이고 ACMA에는 Script API가 없습니다). 검증용 키를 두는 Script는 그것을 읽을 수 있는 역할을 좁게 두는 편이 안전합니다.

Space 밖으로 나갈 때는 다릅니다. 그 ScriptApp Bundle로 패킹될 때 Signaturesecret마스킹되어 원본 Space 밖으로 나가지 않습니다. Http.headerssecret 플래그가 붙은 항목과 Authorization 헤더를 가리는 반면, Signaturesecret은 필드 자체가 서명 키라 조건 없이 가려집니다. If·Loop·Try 안에 중첩된 Signature도 같이 가려집니다.

실행 신원과 인가

  • 실행 신원: 실행 중 모든 리소스 작업은 /execute를 호출한 사용자 신원으로 수행됩니다. 만들어지거나 수정되는 리소스의 createdBy/updatedBy가 호출자이고, createdBy: ":self" 스코프도 호출자를 기준으로 풀립니다. 예외는 익명 호출입니다. /execute/anonymous로 들어온 실행에는 호출자가 없으므로 두 가지 모두 작성자를 기준으로 풀립니다(익명 호출).
  • 인가 경계는 둘이며, 런타임에는 statement마다 리소스 권한을 재검사하지 않습니다.
    1. 저작 시(저장): Script를 저장할 때, 작성자가 그 statement들이 쓰는 리소스와 액션 권한을 실제로 갖는지 검사합니다. 하나라도 없으면 저장이 거부됩니다. 즉, 권한 없는 작업이 담긴 Script는 애초에 저장되지 않습니다. 리소스를 고르는 문은 leaf든 블록을 소유하는 ResourceForEach든 모두 이 검사를 받습니다. 이미 저장해 둔 정의도 수정할 때 다시 검사되므로, 권한이 회수된 뒤에는 그 정의를 고쳐 저장할 수 없습니다.
      • 회원 디렉터리(ServiceUser)는 권한 맵이 아니라 설정 축으로 검사합니다. 읽기 세 문에 resource: "ServiceUser"를 쓰려면 작성자의 SpaceRole settingsSETTING_SERVICE_LOGIN(또는 SETTING_ALL)이 있어야 합니다(SpaceRole의 settings). 회원 디렉터리가 다른 모든 경로에서도 Space 설정이 관장하는 리소스이기 때문입니다.
      • 회원을 변경하는 문은 어떤 역할로도 저장되지 않습니다. Script에서 회원을 만들거나 고치거나 지우는 길이 아예 없으므로, 권한 부족(403)이 아니라 잘못 쓴 문(400)으로 거부됩니다. 권한을 더해 닫을 수 있는 공백이 아니라는 뜻입니다.
    2. 호출 시(/execute): 호출자의 Script Execute 권한만 검사합니다. 권한이 없으면 403입니다. 통과하면 statement별 리소스 권한은 런타임에 다시 확인하지 않고 실행합니다. 프로그래밍의 함수 실행 권한과 같은 방식입니다. 함수를 실행할 권한만 있으면 그 안의 개별 작업 권한은 다시 묻지 않습니다. 익명 호출 경로에는 이 검사가 없습니다. 검사할 호출자가 없기 때문이고, 그래서 그 경로를 여는 것은 Script 하나를 무인증으로 공개하는 일과 같습니다.
  • 직접 호출 차단 (directCallEnabled): ScriptdirectCallEnabledfalse이면 /execute 직접 호출 자체가 거부됩니다. 이 게이트는 Execute 권한 검사를 통과한 뒤에 걸리므로, Execute 권한이 있어도 막힙니다. 권한이 없는 호출자는 이 게이트에 닿기 전에 403을 받습니다. 이 게이트는 그 엔드포인트에만 있으므로, Webhook의 연결 액션(script)과 Scheduler는 그대로 실행합니다. 기본값은 true(직접 호출 허용)입니다.
  • 익명 호출 (anonymousCallEnabled): 기본값은 false입니다. true로 두면 그 Script만 인증 없는 전용 경로(/execute/anonymous)로도 실행되고, 그때 실행 신원은 호출자가 아니라 작성자입니다. 위 두 경계 중 호출 시 검사(Execute 권한)가 그 경로에는 없으므로, 실질적인 인증은 Script가 스스로 합니다(받은 요청의 서명 검증). 켜는 조건과 저장 규칙은 익명 호출에서 다룹니다.
  • 소유권 스코프: where 필터의 createdBy: ":self"는 "현재 호출자가 만든 것만"을 뜻합니다(예: 내 지갑만 조회). 익명 호출을 허용한 Script에서는 이 필터를 쓸 수 없습니다. 호출자가 없어 작성자로 풀리므로, 소유권 스코프라는 원래 뜻이 성립하지 않습니다.
  • 위임된 권한 (작성 주의): 위 두 경계를 합치면, Script 실행은 작성자의 권한을 위임받아 수행되는 것과 같습니다. 호출자는 Execute 하나만 있으면 되고, Script 안 statement는 작성자가 저장 시 인가받은 범위에서 그대로 실행됩니다. 따라서 호출자가 스스로는 하지 못하는 리소스 작업도 Script를 통해 일어날 수 있습니다. 작성자에게 부여된 권한이 곧 그 Script의 영향 범위이므로, Script에 담는 동작은 신중히 정합니다.

요약 체크리스트

저장 전에 다음을 확인합니다.

  • 익명 호출(anonymousCallEnabled)을 켰다면 wherecreatedBy: ":self"가 없고, 받은 요청을 검증하는 문(Signature 등)을 맨 앞에 두었습니다.
  • 외부 호출(Http·EmailSend) 수와 전체 statement 수는 플랜 한도 이내이고, SetVar는 10개 이하, Cache는 5개 이하입니다.
  • Cache를 썼다면 key를 리터럴로 적었고 LoopResourceForEach 안에 두지 않았습니다.
  • ResourceForEach로 큰 집합을 돌면 limit을 선언하거나 완주 가능한 크기인지 확인했습니다.
  • 반복(Loop·ResourceForEach)을 담았다면 그 반복이 곱셈으로 시간 예산에 들어가는 것을 확인했습니다(외부 호출이 없으면 30초 기본 예산이 한도입니다).
  • secret 값은 Http.headerssecret:true로만 넣었습니다(Signature.secret은 암호화 저장이 아니므로 그 Script를 읽을 수 있는 역할을 확인했습니다).
  • 서명 검증에 쓰는 메시지는 /payload가 아니라 { /rawPayload }로 지정했습니다.
  • 회원(ServiceUser)을 읽는 문이 있으면 작성자에게 SETTING_SERVICE_LOGIN이 있고, 그 리소스를 변경하는 문은 넣지 않았습니다.
  • 되돌릴 수 없는 작업(외부 호출)은 되도록 뒤에 두었습니다.
  • update/patch 경합이 걱정되면 ResourceUpdateResourcePatchversion을 씁니다.
  • 결과를 돌려주려면 Return.value를 명시했습니다.

오류

정의의 형태가 정적 제약을 어겨 저장이 거부될 때 나오는 코드입니다. 값 표현식 규칙을 어긴 코드는 값 표현식의 오류에, 호출·삭제 시 나오는 코드는 엔드포인트의 오류에 있습니다. 모든 리소스에 공통인 코드는 공통 오류를 참조하세요.

코드조건
WGL400066한 정의에 담은 Cache 문이 5개를 넘습니다.
WGL400068Cache 문을 LoopResourceForEach의 블록 안에 두었습니다.
WGL400067Cache 문의 key에 리터럴이 아니라 { /pointer } 참조를 적었습니다.
WGL400065Cache 문의 ttl이 허용 범위를 벗어났습니다.
WGL400063Cache 문에서 action에 해당하지 않는 필드를 적었습니다(Getttl, SetdefaultValue).
WGL400060쓰기 계열 문(ResourceCreate·ResourceUpdate·ResourcePatch·ResourceDelete와 발행·보관 문)의 resource"ServiceUser"를 적었습니다.
WGL400061익명 호출을 허용한 Script(anonymousCallEnabled)에서 읽기 문의 wherecreatedBy: ":self"를 적었습니다.
WGL400023한 정의에 담은 SetVar 문이 10개를 넘습니다.
WGL400026Http 문의 retry가 상한 2를 넘습니다.
WGL400036ResourceForEach가 처리할 수 있는 항목 수 상한을 넘습니다.
WGL429005한 정의에 담은 전체 statement 수가 요금제 한도를 넘습니다.
WGL429006한 정의에 담은 외부 호출(Http·EmailSend) 수가 요금제 한도를 넘습니다.
WGL403015정의에 담은 문이 다루는 리소스와 액션의 권한을 작성자가 갖고 있지 않습니다. 권한이 있어도 그 허용에 contentType·createdBy·tag 필터가 붙어 있으면 저장이 거부됩니다. 조건 없는 허용이라야 합니다. 유일한 예외는 Content Create로, 이때는 contentType 범위를 정한 허용도 인정되며 문에 적은 contentType과 대조합니다(Media Create에는 이 예외가 없습니다). 읽기 문의 resource"ServiceUser"를 적었는데 작성자에게 SETTING_SERVICE_LOGIN이 없는 경우도 이 코드입니다.