쿡북 (실전 예시 모음)

다양한 시나리오를 완결된 ScriptDefinition으로 보여줍니다. 문법 근거는 Statement 카탈로그값 표현식, 실행과 제약은 실행 시맨틱, 제약, 보안을 참조하세요. 모든 예시는 쓰기 fields 값이 로케일 맵({ "<locale>": 값 })이며, 예시 로케일은 en-US로 통일했습니다. 모든 예시는 호출 요청을 처리하는 경로에서 인라인으로 실행되어, 호출의 응답 본문으로 결과를 돌려줍니다. 외부 호출(Http·EmailSend)이 있는 예시는 그 문의 timeoutMs가 실행 시간 예산에 더해지고(Http× (1 + retry)), 그 문이 반복(Loop·ResourceForEach) 안에 있으면 반복 상한만큼 곱해집니다(시간 예산).

목차

기본 CRUD

1. Content 생성과 발행

{ "method": "Post",
  "statements": [
    { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_post" } },
      "fields": { "title": { "en-US": "{ /payload/fields/title }" }, "body": { "en-US": "{ /payload/fields/body }" } },
      "publish": true, "name": "post" },
    { "type": "Return", "value": { "id": "{ /post/sys/id }" }, "statusCode": 201 } ] }

2. 계산 값으로 update (조회수 +1)

{ "method": "Post",
  "statements": [
    { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } },
      "fields": { "viewCount": { "en-US": { "$+": [ "{ /payload/fields/viewCount }", 1 ] } } } } ] }

3. 내 주문 목록 모아서 반환

{ "method": "Get",
  "statements": [
    { "type": "SetVar", "var": "orders", "value": [] },
    { "type": "ResourceForEach", "resource": "Content", "contentType": { "sys": { "id": "ct_order" } },
      "where": { "createdBy": ":self" }, "order": "-sys.createdAt", "from": "Current", "advanced": false,
      "limit": 20, "name": "order",
      "onEach": [
        { "type": "SetVar", "var": "orders", "value": { "$merge": [ "{ /vars/orders }", [ "{ /order }" ] ] } } ] },
    { "type": "Return", "value": { "orders": "{ /vars/orders }" } } ] }

createdBy: ":self"로 "내 것만" 순회하며 항목을 SetVar로 모아 반환합니다. ResourceForEach는 순회 결과를 컬렉션으로 바인딩하지 않으므로, 목록으로 돌려주려면 이렇게 직접 모읍니다. 순회는 시간 예산에서 onEach가 선언한 시간에 처리 항목 수를 곱한 값으로 잡힙니다. 여기서는 onEach에 외부 호출이 없어 선언 시간이 0이라 30초 기본 예산이 실질 한도이고, onEach에 외부 호출을 두면 그 곱셈이 그대로 예산에 들어가 상한 180초에 도달하면 거기서 중단됩니다(시간 예산). 목록을 읽어 돌려주기만 하면 될 때는, Script 순회 대신 프론트엔드에서 CDA/CMA 목록 API를 직접 호출하는 편이 낫습니다.

4. 단건 조회 후 guard 후 승인

{ "method": "Post",
  "statements": [
    { "type": "ResourceRead", "resource": "Content", "target": { "sys": { "id": "{ /payload/fields/orderId }" } }, "name": "order" },
    { "type": "If", "condition": { "!=": [ "{ /order/fields/status/en-US }", "pending" ] },
      "then": [ { "type": "Return", "value": { "reason": "not pending" }, "isError": true, "statusCode": 409 } ] },
    { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /order/sys/id }" } },
      "fields": { "status": { "en-US": "approved" } }, "publish": true },
    { "type": "Return", "value": { "ok": true } } ] }

ResourceRead로 단건을 이름에 바인딩하면 { /order/fields/... }로 직접 참조합니다. 없으면 조회에서 에러입니다(Try로 감쌀 수 있습니다).

조회와 upsert

5. slug upsert (find-then-upsert)

{ "method": "Post",
  "statements": [
    { "type": "ResourceFind", "resource": "Content", "contentType": { "sys": { "id": "ct_article" } },
      "where": { "fields.slug": { "eq": "{ /payload/fields/slug }" } }, "name": "found" },
    { "type": "If", "condition": { "!!": "{ /found/sys/id }" },
      "then": [
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /found/sys/id }" } },
          "fields": { "body": { "en-US": "{ /payload/fields/body }" } } },
        { "type": "Return", "value": { "id": "{ /found/sys/id }", "op": "updated" } } ],
      "else": [
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_article" } },
          "fields": { "slug": { "en-US": "{ /payload/fields/slug }" }, "body": { "en-US": "{ /payload/fields/body }" } }, "name": "created" },
        { "type": "Return", "value": { "id": "{ /created/sys/id }", "op": "created" }, "statusCode": 201 } ] } ] }

ResourceFind는 첫 매치를 바로 바인딩하고(없으면 null), { "!!": "{ /found/sys/id }" }로 존재 여부를 분기합니다.

6. 동적 필드 키와 동적 로케일 patch

{ "method": "Patch",
  "statements": [
    { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } },
      "fields": { "{ /payload/fields/fieldKey }": { "{ /payload/fields/locale }": "{ /payload/fields/value }" } } } ] }

필드 와 로케일 버킷 키 둘 다 { /ptr } 참조입니다. 번역을 특정 로케일 버킷에 넣을 때 씁니다.

외부 API

7. 크레딧 guard, 선차감(CAS), LLM 호출, 실패 시 환불 (대표 예시)

{ "method": "Post",
  "statements": [
    { "type": "ResourceFind", "resource": "Content", "contentType": { "sys": { "id": "ct_wallet" } },
      "where": { "createdBy": ":self" }, "name": "wallet" },
    { "type": "If", "condition": { "<": [ "{ /wallet/fields/balance/en-US }", "{ /payload/fields/cost }" ] },
      "then": [ { "type": "Return", "value": { "ok": false, "reason": "insufficient credit" }, "isError": true, "statusCode": 402 } ] },
    { "type": "Try",
      "body": [
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /wallet/sys/id }" } },
          "version": "{ /wallet/sys/version }",
          "fields": { "balance": { "en-US": { "$-": [ "{ /wallet/fields/balance/en-US }", "{ /payload/fields/cost }" ] } } },
          "name": "charged" } ],
      "catch": [ { "type": "Return", "value": { "ok": false, "reason": "version conflict, 다시 시도" }, "isError": true, "statusCode": 409 } ] },
    { "type": "Try",
      "body": [
        { "type": "Http", "method": "POST", "url": "https://api.llm.com/v1/gen",
          "headers": [ { "key": "Authorization", "value": "Bearer sk-...", "secret": true } ],
          "body": { "prompt": "{ /payload/fields/prompt }" }, "timeoutMs": 15000, "name": "resp" },
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_result" } },
          "fields": { "text": { "en-US": "{ /resp/body/choices/0/message/content }" } }, "name": "out" },
        { "type": "Return", "value": { "ok": true, "id": "{ /out/sys/id }", "remaining": "{ /charged/fields/balance/en-US }" } } ],
      "catch": [
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /wallet/sys/id }" } },
          "fields": { "balance": { "en-US": { "$+": [ "{ /charged/fields/balance/en-US }", "{ /payload/fields/cost }" ] } } } },
        { "type": "Return", "value": { "ok": false, "reason": "generation failed, refunded" }, "isError": true, "statusCode": 502 } ] } ] }

guard로 잔액이 충분한지 본 뒤, 외부 호출보다 먼저 차감합니다. 차감은 wallet의 sys.version으로 낙관적 잠금(CAS)을 겁니다. 읽은 잔액과 차감 사이에 다른 실행이 wallet을 바꿨다면 버전 불일치로 abort하고 catch409를 냅니다. 외부 호출은 하지 않으므로 동시 요청이 이중 차감되지 않습니다. 차감이 확정된 뒤에만 LLM을 호출하고, 그 호출이 실패하면 catch에서 차감분(cost)을 도로 더해 환불(보상)한 뒤 502를 냅니다. 되돌릴 수 없는 외부 호출 전에 과금을 확정하고, 실패했을 때만 보상하는 순서입니다. 비밀 키는 secret:true 헤더에 둡니다. 보상의 한계는 실행 시맨틱의 트랜잭션 없음과 보상을 참조하세요.

8. 이미지(URL)를 Media로 만들어 Content에 첨부

{ "method": "Post",
  "statements": [
    { "type": "Http", "method": "POST", "url": "https://api.img.com/gen",
      "headers": [ { "key": "x-api-key", "value": "...", "secret": true } ],
      "body": { "prompt": "{ /payload/fields/prompt }" }, "name": "gen" },
    { "type": "ResourceCreate", "resource": "Media",
      "fields": { "title": { "en-US": "{ /payload/fields/prompt }" },
                  "file":  { "en-US": { "source": "{ /gen/body/data/0/url }", "encoding": "url" } } }, "name": "img" },
    { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_artwork" } },
      "fields": { "prompt": { "en-US": "{ /payload/fields/prompt }" }, "image": { "en-US": "{ /img/sys/id }" } } } ] }

Medianame으로 만들고, ResourceCreate(Content)가 { /img/sys/id }를 참조 필드에 넣습니다. MediaContent와 동일한 fields 모델을 씁니다. file은 인제스트 지시 { source, encoding }입니다. 파일 인제스트는 선언한 시간이 없어 30초 기본 예산에서 나가고, 한 정의당 외부 호출 한도에도 포함되지 않습니다(정적 제약).

9. base64 이미지를 Media로

{ "method": "Post",
  "statements": [
    { "type": "Http", "method": "POST", "url": "https://api.img.com/gen",
      "headers": [ { "key": "x-api-key", "value": "...", "secret": true } ],
      "body": { "prompt": "{ /payload/fields/prompt }" }, "name": "gen" },
    { "type": "ResourceCreate", "resource": "Media",
      "fields": { "file": { "en-US": { "source": "{ /gen/body/data/0/b64_json }", "encoding": "base64" } } } } ] }

10. 모더레이션 후 조건부 publish 또는 삭제

{ "method": "Post",
  "statements": [
    { "type": "Http", "method": "POST", "url": "https://api.mod.com/check",
      "headers": [ { "key": "x-api-key", "value": "...", "secret": true } ],
      "body": { "text": "{ /payload/fields/body }" }, "name": "mod" },
    { "type": "If", "condition": { "==": [ "{ /mod/body/flagged }", true ] },
      "then": [ { "type": "ResourceDelete",  "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } } } ],
      "else": [ { "type": "ResourcePublish", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } } } ] } ] }

11. try/catch: 외부 실패 시 fallback

{ "method": "Post",
  "statements": [
    { "type": "Try",
      "body": [
        { "type": "Http", "method": "POST", "url": "https://primary.api/gen",
          "headers": [ { "key": "x-api-key", "value": "...", "secret": true } ],
          "body": { "prompt": "{ /payload/fields/prompt }" }, "timeoutMs": 8000, "name": "resp" },
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_result" } },
          "fields": { "text": { "en-US": "{ /resp/body/text }" }, "source": { "en-US": "primary" } } } ],
      "catch": [
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_result" } },
          "fields": { "text": { "en-US": "생성 실패" }, "error": { "en-US": "{ /error/message }" }, "source": { "en-US": "fallback" } } } ] } ] }

12. 글에 AI 요약과 태그를 채우기

{ "method": "Post",
  "statements": [
    { "type": "Http", "method": "POST", "url": "https://api.llm.com/v1/gen",
      "headers": [ { "key": "Authorization", "value": "Bearer sk-...", "secret": true } ],
      "body": { "prompt": "{ /payload/fields/body }", "response_format": { "type": "json_object" } },
      "timeoutMs": 15000, "name": "resp" },
 
    { "type": "Try",
      "body": [
        { "type": "ParseJson", "name": "ai", "value": "{ /resp/body/choices/0/message/content }" },
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } },
          "fields": { "summary": { "en-US": "{ /ai/summary }" },
                      "tags":    { "en-US": "{ /ai/tags }" } } },
        { "type": "Return", "value": { "ok": true, "tags": "{ /ai/tags }" } } ],
      "catch": [
        { "type": "Return", "value": { "ok": false, "reason": "model did not return JSON" }, "isError": true, "statusCode": 502 } ] } ] }

글이 등록되면 요약과 태그를 모델이 채우는 흐름입니다(Webhook의 연결 액션으로 Content.Create에 걸어 둡니다). 여기서 응답이 두 겹입니다. HttpresponseType은 기본이 Json이라 API의 응답 봉투는 이미 객체지만, 모델이 만든 답 자체는 choices/0/message/content문자열로 들어 있습니다. 그래서 ParseJson으로 한 겹 더 풀어야 { /ai/summary }·{ /ai/tags }로 값을 꺼낼 수 있습니다. 태그는 Array(원소 ShortText) 필드에 배열 그대로 씁니다.

구조화 출력(response_format)으로 계약을 걸어도, 응답이 길이 제한에 걸려 잘리거나 모델이 요청을 거부하면 JSON이 아닌 것이 옵니다. 그래서 Try로 감싸 파싱 실패를 502로 바꿉니다. 실패 메시지에는 파싱하려던 텍스트가 실려 무엇을 받았는지 볼 수 있습니다. 응답 봉투부터 JSON이 아닌 API라면 HttpresponseType: "Text"를 주고 { /resp/body }를 그대로 넘깁니다(Http, ParseJson 참조).

병렬

13. 병렬 외부 호출 2개를 합쳐 Content

{ "method": "Post",
  "statements": [
    { "type": "Parallel", "branches": [
      [ { "type": "Http", "method": "GET", "url": "https://api.a.com/x", "name": "a" } ],
      [ { "type": "Http", "method": "GET", "url": "https://api.b.com/y", "name": "b" } ] ] },
    { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_merged" } },
      "fields": { "left": { "en-US": "{ /a/body/value }" }, "right": { "en-US": "{ /b/body/value }" } } } ] }

14. 가입 심사: 병렬 점수 후 and 판정

{ "method": "Post",
  "statements": [
    { "type": "Parallel", "branches": [
      [ { "type": "Http", "method": "POST", "url": "https://api.fraud.com/score",
          "headers": [ { "key": "x-api-key", "value": "...", "secret": true } ],
          "body": { "email": "{ /payload/fields/email }" }, "name": "fraud" } ],
      [ { "type": "Http", "method": "GET", "url": "https://api.credit.com/v1/{ /payload/fields/userId }/score",
          "headers": [ { "key": "x-api-key", "value": "...", "secret": true } ], "name": "credit" } ] ] },
    { "type": "If",
      "condition": { "and": [ { "<": [ "{ /fraud/body/risk }", 0.5 ] }, { ">=": [ "{ /credit/body/score }", 700 ] } ] },
      "then": [
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_account" } },
          "fields": { "email": { "en-US": "{ /payload/fields/email }" }, "status": { "en-US": "approved" } } },
        { "type": "Return", "value": { "decision": "approved" }, "statusCode": 201 } ],
      "else": [ { "type": "Return", "value": { "decision": "manual-review" }, "statusCode": 202 } ] } ] }

URL 경로에도 { /ptr }를 삽입할 수 있습니다. 브랜치 결과는 조인 후 참조합니다. 외부 호출은 2개입니다. 한 정의가 담을 수 있는 외부 호출 수는 플랜별 한도이므로(요금제 참조) 그 한도 안인지 확인하세요.

반복과 집계

LoopResourceForEach는 시간 예산에서 body(onEach)가 선언한 시간에 반복 상한을 곱한 값으로 잡힙니다. 이 절의 예시는 body에 외부 호출이 없어 선언 시간이 0이므로, 30초 기본 예산이 실질 한도입니다. 반복이 실제로 걸리는 지점도 여기입니다(시간 예산, Loop).

15. 배열 입력으로 N개 Content (Loop over)

{ "method": "Post",
  "statements": [
    { "type": "Loop", "over": "{ /payload/fields/items }", "name": "item", "maxIterations": 100,
      "body": [
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_item" } },
          "fields": { "name": { "en-US": "{ /item/name }" }, "qty": { "en-US": "{ /item/qty }" } } } ] } ] }

16. counted loop (for): 슬롯 시드

{ "method": "Post",
  "statements": [
    { "type": "Loop", "for": { "from": 1, "to": 5 }, "name": "i", "maxIterations": 100,
      "body": [
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_slot" } },
          "fields": { "index": { "en-US": "{ /i }" }, "status": { "en-US": "open" } } } ] } ] }

forfrom부터 to까지 포함입니다(정수 리터럴, step 기본 1). name이 현재 카운터를 { /i }에 바인딩합니다.

17. cascade 삭제 (ForEach, Delete)

{ "method": "Delete",
  "statements": [
    { "type": "ResourceForEach", "resource": "Content", "contentType": { "sys": { "id": "ct_comment" } },
      "where": { "fields.postId": { "eq": "{ /payload/sys/id }" } }, "from": "Current", "advanced": false, "name": "comment",
      "onEach": [ { "type": "ResourceDelete", "resource": "Content", "target": { "sys": { "id": "{ /comment/sys/id }" } } } ] },
    { "type": "ResourceDelete", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } } } ] }

ResourceForEach가 매치를 내부적으로 페이징하며 항목마다 삭제하므로, 수동 페이지네이션 없이 조건에 맞는 댓글을 모두(플랫폼 상한까지) 지운 뒤 게시글 자신을 지웁니다. onEach가 선언한 시간이 없으므로 이 정의의 실행 시간 예산은 30초입니다.

18. 루프 누적: SetVar 합계

{ "method": "Post",
  "statements": [
    { "type": "SetVar", "var": "total", "value": 0 },
    { "type": "Loop", "over": "{ /payload/fields/items }", "name": "row", "maxIterations": 100,
      "body": [ { "type": "SetVar", "var": "total", "value": { "$+": [ "{ /vars/total }", "{ /row/qty }" ] } } ] },
    { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_summary" } },
      "fields": { "totalQty": { "en-US": "{ /vars/total }" } } } ] }

19. 조건에 맞는 전체 항목 일괄 처리

{ "method": "Post",
  "statements": [
    { "type": "ResourceForEach", "resource": "Content", "contentType": { "sys": { "id": "ct_post" } },
      "where": { "fields.status": { "eq": "draft" } }, "order": "sys.createdAt,sys.id",
      "from": "Current", "advanced": false, "name": "post",
      "onEach": [
        { "type": "ResourcePublish", "resource": "Content", "target": { "sys": { "id": "{ /post/sys/id }" } } } ] } ] }

ResourceForEach가 매치를 내부적으로 페이징하므로 cursor 루프(Loop while + SetVar 누적)가 필요 없습니다. 조건에 맞는 draft를 모두 찾아 항목마다 발행합니다. 개수가 매우 많아 완주가 어려우면 limit으로 한 번에 처리할 상한을 정하고, where를 "미처리" 조건으로 두어 재실행으로 이어서 처리합니다.

20. 이메일 목록으로 id 배치 수집 (merge)

{ "method": "Post",
  "statements": [
    { "type": "SetVar", "var": "ids",     "value": [] },
    { "type": "SetVar", "var": "missing", "value": [] },
    { "type": "Loop", "over": "{ /payload/fields/emails }", "name": "email", "maxIterations": 100,
      "body": [
        { "type": "ResourceFind", "resource": "Content", "contentType": { "sys": { "id": "ct_account" } },
          "where": { "fields.email": { "eq": "{ /email }" } }, "name": "acc" },
        { "type": "If", "condition": { "!!": "{ /acc/sys/id }" },
          "then": [ { "type": "SetVar", "var": "ids",     "value": { "$merge": [ "{ /vars/ids }",     [ "{ /acc/sys/id }" ] ] } } ],
          "else": [ { "type": "SetVar", "var": "missing", "value": { "$merge": [ "{ /vars/missing }", [ "{ /email }" ] ] } } ] } ] },
    { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_campaign" } },
      "fields": { "recipients": { "en-US": "{ /vars/ids }" }, "unresolved": { "en-US": "{ /vars/missing }" } } } ] }

읽기(ResourceFind)는 외부 호출이 아니므로 Loop body에서 허용됩니다. 존재와 부재를 각각 merge로 누적합니다.

사가와 동시성

21. 결제 사가 (Try/catch/finally)

{ "method": "Post",
  "statements": [
    { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_order" } },
      "fields": { "sku": { "en-US": "{ /payload/fields/sku }" }, "status": { "en-US": "reserved" } },
      "publish": false, "name": "order" },
    { "type": "Try",
      "body": [
        { "type": "Http", "method": "POST", "url": "https://api.pay.com/charge",
          "headers": [ { "key": "Authorization", "value": "Bearer sk-...", "secret": true } ],
          "body": { "amount": "{ /payload/fields/amount }", "ref": "{ /order/sys/id }" }, "timeoutMs": 10000, "name": "pay" },
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /order/sys/id }" } },
          "fields": { "status": { "en-US": "paid" }, "txId": { "en-US": "{ /pay/body/transactionId }" } }, "publish": true },
        { "type": "Return", "value": { "orderId": "{ /order/sys/id }", "status": "paid" }, "statusCode": 201 } ],
      "catch": [
        { "type": "ResourceDelete", "resource": "Content", "target": { "sys": { "id": "{ /order/sys/id }" } } },
        { "type": "Return", "value": { "reason": "payment failed", "detail": "{ /error/message }" }, "isError": true, "statusCode": 402 } ],
      "finally": [
        { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_paylog" } },
          "fields": { "orderRef": { "en-US": "{ /order/sys/id }" }, "amount": { "en-US": "{ /payload/fields/amount }" } } } ] } ] }

예약(draft)을 만든 뒤 결제가 성공하면 상태를 확정해 발행하고 201을 돌려줍니다. 실패하면 catch가 예약을 삭제해 보상하고 402를 돌려줍니다. finally는 성공하든 실패하든 로그를 남깁니다. 삭제로 보상하면 새 sys.id가 만들어지므로, 기존 sys.id를 가리키던 참조가 깨진다는 한계가 있습니다(실행 시맨틱의 트랜잭션 없음과 보상 참조).

22. 낙관적 잠금 CAS

{ "method": "Post",
  "statements": [
    { "type": "ResourceFind", "resource": "Content", "contentType": { "sys": { "id": "ct_stock" } },
      "where": { "fields.sku": { "eq": "{ /payload/fields/sku }" } }, "name": "stock" },
    { "type": "If", "condition": { "<": [ "{ /stock/fields/qty/en-US }", "{ /payload/fields/amount }" ] },
      "then": [ { "type": "Return", "value": { "reason": "out of stock" }, "isError": true, "statusCode": 409 } ] },
    { "type": "Try",
      "body": [
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /stock/sys/id }" } },
          "version": "{ /stock/sys/version }",
          "fields": { "qty": { "en-US": { "$-": [ "{ /stock/fields/qty/en-US }", "{ /payload/fields/amount }" ] } } } },
        { "type": "Return", "value": { "ok": true } } ],
      "catch": [
        { "type": "Return", "value": { "reason": "version conflict, 다시 시도" }, "isError": true, "statusCode": 409 } ] } ] }

재고를 읽어 최신 sys.version을 확보한 뒤 그 버전으로 차감합니다(version). 읽기와 쓰기 사이에 다른 실행이 값을 바꿨다면 버전 불일치로 abort되고 catch가 409를 냅니다. 재고 부족 guard는 Try 밖입니다(정상 조기 종료).

이메일

23. 각 주문의 매수자에게 알림 메일 (ForEach + EmailSend)

{ "method": "Post",
  "statements": [
    { "type": "ResourceForEach", "resource": "Content", "contentType": { "sys": { "id": "ct_order" } },
      "where": { "fields.notified": { "ne": true } }, "order": "sys.createdAt,sys.id",
      "from": "Current", "advanced": false, "name": "order",
      "onEach": [
        { "type": "EmailSend", "account": { "sys": { "id": "eml_orders" } },
          "toServiceUser": { "sys": { "id": "{ /order/fields/buyer/en-US/sys/id }" } },
          "subject": "배송이 시작되었습니다",
          "body": "<p>주문하신 상품의 배송이 시작되었습니다.</p>" },
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /order/sys/id }" } },
          "fields": { "notified": { "en-US": true } } } ] } ] }

아직 알림을 안 보낸 주문(fields.notifiedtrue가 아닌 것)을 순회하며, 각 주문의 매수자에게 메일을 보내고 곧바로 notified를 표시합니다. EmailSend는 한 통에 수신자 1명이라, 다건 발송은 이렇게 ResourceForEach로 항목마다 보냅니다(onEach에는 외부 호출을 담을 수 있습니다). toServiceUser로 주면 회원 주소가 Script 변수 공간에 들어오지 않고 전송 직전에 resolve됩니다. where를 "미처리"로 두고 onEach 끝에서 완료를 표시했으므로, 중간에 끊겨도 재실행하면 남은 주문부터 이어집니다(부작용 성공 직후 표시가 실패하면 그 건은 다음 실행에서 중복될 수 있습니다. at-least-once).

서명 검증

결제 대행사(PG·MoR)는 웹훅을 보낼 때 본문에 서명을 붙입니다. 받는 쪽은 무엇을 하기 전에 그 서명이 자기가 가진 비밀 키로 재현되는지 확인해야 합니다. 아래 두 예시는 실제로 갈리는 두 방식입니다. 하나는 비밀 키로 코드를 만드는(keyed) 방식이고, 하나는 필드와 비밀 키를 이어 붙여 다이제스트를 계산하는 방식입니다.

24. 웹훅 서명 검증 (포장된 헤더 풀기, replay window)

{ "method": "Post",
  "statements": [
    { "type": "Regex", "name": "sig", "mode": "Capture",
      "pattern": "^t=(\\d+),v1=([0-9a-f]{64})$", "value": "{ /headers/x-provider-signature }" },
    { "type": "If", "condition": { "==": [ "{ /sig }", null ] },
      "then": [ { "type": "Return", "value": { "ok": false, "reason": "malformed signature header" }, "isError": true, "statusCode": 400 } ] },
 
    { "type": "Signature", "name": "verified", "algorithm": "SHA256",
      "secret": "whsec_9f2c1b7ae4",
      "value": "{ /sig/1 }.{ /rawPayload }", "expected": "{ /sig/2 }" },
    { "type": "If", "condition": { "!": "{ /verified }" },
      "then": [ { "type": "Return", "value": { "ok": false, "reason": "signature mismatch" }, "isError": true, "statusCode": 401 } ] },
 
    { "type": "If", "condition": { ">=": [ { "-": [ "{ /now/seconds }", "{ /sig/1 }" ] }, 300 ] },
      "then": [ { "type": "Return", "value": { "ok": false, "reason": "timestamp outside the replay window" }, "isError": true, "statusCode": 401 } ] },
 
    { "type": "ResourceFind", "resource": "Content", "contentType": { "sys": { "id": "ct_order" } },
      "where": { "fields.orderId": { "eq": "{ /payload/data/orderId }" } }, "name": "order" },
    { "type": "If", "condition": { "==": [ "{ /order }", null ] },
      "then": [ { "type": "Return", "value": { "ok": false, "reason": "unknown order" }, "isError": true, "statusCode": 404 } ] },
    { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /order/sys/id }" } },
      "fields": { "status": { "en-US": "paid" }, "paidAt": { "en-US": "{ /now/iso }" } }, "publish": true },
    { "type": "Return", "value": { "ok": true } } ] }

제공자가 타임스탬프와 코드를 한 헤더에 함께 담아 보내므로(t=1492774577,v1=<64자 hex>), 헤더를 풀기 전에는 서명 대상 메시지를 만들 수 없습니다. 그래서 순서가 이렇게 정해집니다.

  1. RegexCapture가 헤더를 풀어 { /sig/1 }(타임스탬프)과 { /sig/2 }(코드)로 나눕니다. 인덱스 0은 매치 전체이고 1부터가 캡쳐 그룹입니다. 형식이 안 맞으면 { /sig }null이라 그 자리에서 400으로 돌려보냅니다.
  2. Signature"<타임스탬프>.<원문 본문>"을 메시지로 삼아 코드를 만들고 { /sig/2 }와 비교합니다. 핵심은 메시지를 파싱 전 원문({ /rawPayload })으로 잡는 것입니다. 파싱된 /payload를 다시 문자열로 만들면 공백과 숫자 표기가 정규화되어 상대가 서명한 바이트로 돌아오지 않습니다. 두 포인터를 문자열에 나란히 넣으면 그대로 이어 붙기 때문에 연산자가 필요 없습니다.
  3. { /verified }false401입니다. 서명이 틀린 것과 헤더가 없는 것은 둘 다 false 하나로 처리됩니다(어느 쪽이 틀렸는지 보낸 쪽에 알려 주지 않습니다).
  4. 서명이 맞아도 오래된 요청은 거절합니다. { /now/seconds }는 이 실행이 시작된 시각이라, 서명에 실린 타임스탬프와의 차이가 replay window(여기서는 300초)를 넘는지 봅니다. 타임스탬프는 헤더에서 문자열로 왔지만 산술 연산이 숫자로 바꿔 줍니다.
  5. 여기까지 통과한 뒤에야 주문을 찾아 상태를 바꿉니다.

외부 호출이 없어 선언한 시간이 없으므로 30초 기본 예산 안에서 끝나고, 제공자는 응답을 그 자리에서 받습니다. 검증에 쓰는 secretHttp 헤더의 secret: true와 달리 암호화되어 저장되지 않으므로, 이 Script를 읽을 수 있는 역할을 좁게 두세요(보안 모델의 secret 헤더).

제공자가 이 창구를 부를 수 있게 하는 방법은 두 가지이고, 그 제공자가 커스텀 헤더를 보낼 수 있는지가 갈림길입니다.

  • 보낼 수 있으면ScriptExecute 권한만 담은 토큰을 발급해 Authorization 헤더로 넣게 하고 /execute를 부르게 합니다. 특정 Script 하나로 좁히는 역할은 SpaceRole의 script 권한에서, 토큰은 Space Access Token에서 다룹니다. 이쪽이 기본입니다.
  • 보낼 수 없으면(콜백 URL만 등록할 수 있고 헤더를 붙이는 설정이 없는 제공자) 그 ScriptanonymousCallEnabled를 켜고 /execute/anonymous 주소를 콜백으로 등록합니다. 이때 실행은 작성자 신원이 되므로 이 Script가 고치는 주문의 updatedBy도 작성자가 되고, 인증이 없으니 위 서명 검증이 이 창구의 유일한 인증이 됩니다. 조건과 저장 규칙은 익명 호출에서 다룹니다.

위 정의는 그대로 익명 Script의 조건을 만족합니다. createdBy: ":self" 필터를 쓰지 않으며, 서명과 replay window를 통과하지 못한 요청은 아무것도 건드리기 전에 Return으로 끊습니다.

25. 키 없는 해시 서명 검증

{ "method": "Post",
  "statements": [
    { "type": "Hash", "name": "expectedSign", "algorithm": "SHA256", "encoding": "HexUpper",
      "value": "{ /payload/orderId }{ /payload/amount }9f2c1b7ae4" },
    { "type": "If", "condition": { "!=": [ "{ /expectedSign }", "{ /payload/signature }" ] },
      "then": [ { "type": "Return", "value": { "ok": false, "reason": "signature mismatch" }, "isError": true, "statusCode": 401 } ] },
    { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /payload/orderRef }" } },
      "fields": { "status": { "en-US": "paid" } }, "publish": true },
    { "type": "Return", "value": { "ok": true } } ] }

HMAC 대신 "정해진 필드들과 비밀 키를 이어 붙여 SHA256을 계산"하는 방식입니다. Hash에는 secret 필드가 없고, 비밀 키(9f2c1b7ae4)를 그 스킴이 놓는 자리에 value 안에 직접 적습니다. 스킴마다 키가 앞·뒤·중간으로 갈리므로 이 편이 모든 자리를 표현합니다.

encoding은 상대의 표기에 맞춥니다(Hex·HexUpper·Base64·Base64Url). Signature와 달리 결과가 문자열이라 비교를 직접 해야 하고, 그 비교는 일반 동등 비교입니다. value의 상한이 128자이므로 본문 전체를 대상으로 계산하는 스킴에는 Signature를 씁니다.

회원 조회

26. 이메일로 회원 찾아 쿠폰과 알림 메일

{ "method": "Post",
  "statements": [
    { "type": "ResourceFind", "resource": "ServiceUser",
      "where": { "sys.email": { "eq": "{ /payload/fields/email }" } }, "name": "member" },
    { "type": "If", "condition": { "==": [ "{ /member }", null ] },
      "then": [ { "type": "Return", "value": { "ok": false, "reason": "member not found" }, "isError": true, "statusCode": 404 } ] },
 
    { "type": "ResourceCreate", "resource": "Content", "contentType": { "sys": { "id": "ct_coupon" } },
      "fields": { "code": { "en-US": "WELCOME-{ /member/sys/id }" },
                  "owner": { "en-US": "{ /member/sys/id }" } }, "publish": false, "name": "coupon" },
 
    { "type": "EmailSend", "account": { "sys": { "id": "eml_orders" } },
      "toServiceUser": { "sys": { "id": "{ /member/sys/id }" } },
      "subject": "쿠폰이 발급되었습니다",
      "body": "<p>{ /member/nickname }님께 쿠폰 { /coupon/fields/code/en-US }을 드렸습니다.</p>" },
    { "type": "Return", "value": { "ok": true, "memberId": "{ /member/sys/id }" } } ] }

이메일 한 개로 회원을 찾아, 그 sys.id를 쿠폰의 소유자와 메일 수신자로 씁니다. 회원 디렉터리를 읽을 때의 규칙은 이렇습니다.

  • sys.email은 암호화되어 저장되므로 정확 일치 계열 연산자만 받습니다(eq·ne·in·nin). prefix처럼 다른 연산자를 주면 조용히 0건을 돌려주는 것이 아니라 실행이 실패합니다.
  • 매치가 없으면 ResourceFindnull을 바인딩하므로, Content를 찾을 때와 같은 모양으로 존재 여부를 분기합니다.
  • 회원의 필드는 Content·Media와 달리 로케일 맵이 아닙니다. { /member/nickname }처럼 그대로 참조합니다.
  • 메일을 보낼 때 주소를 꺼내지 않고 toServiceUsersys.id를 넘깁니다. 엔진이 전송 직전에 주소를 resolve하므로 회원 주소가 Script 변수 공간에 들어오지 않습니다.
  • 이 정의를 저장하려면 작성자의 SpaceRole settingsSETTING_SERVICE_LOGIN이 있어야 합니다. 회원을 만들거나 고치거나 지우는 문은 어떤 역할로도 저장되지 않습니다(회원 디렉터리 읽기).

EmailSendtimeoutMs(선언이 없으면 10초)를 실행 시간 예산에 더하고, 한 정의당 외부 호출 한도에 하나로 세어집니다.