쿡북 (실전 예시 모음)

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

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

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

{ "method": "Post",
  "statements": [
    { "type": "Http", "method": "POST", "url": "https://api.img.com/gen",
      "headers": [ { "key": "x-api-key", "value": "{ /secrets/IMAGE_API_KEY }" } ],
      "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 }" } } } ],
  "secrets": [ { "key": "IMAGE_API_KEY", "value": "...", "description": "이미지 생성 API 키" } ] }

Media를 name으로 만들고, ResourceCreate(Content)가 { /img/sys/id }를 참조 필드에 넣습니다. Media도 Content와 동일한 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": "{ /secrets/IMAGE_API_KEY }" } ],
      "body": { "prompt": "{ /payload/fields/prompt }" }, "name": "gen" },
    { "type": "ResourceCreate", "resource": "Media",
      "fields": { "file": { "en-US": { "source": "{ /gen/body/data/0/b64_json }", "encoding": "base64" } } } } ],
  "secrets": [ { "key": "IMAGE_API_KEY", "value": "...", "description": "이미지 생성 API 키" } ] }

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

{ "method": "Post",
  "statements": [
    { "type": "Http", "method": "POST", "url": "https://api.mod.com/check",
      "headers": [ { "key": "x-api-key", "value": "{ /secrets/MODERATION_API_KEY }" } ],
      "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 }" } } } ] } ],
  "secrets": [ { "key": "MODERATION_API_KEY", "value": "...", "description": "모더레이션 API 키" } ] }

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": "{ /secrets/GEN_API_KEY }" } ],
          "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" } } } ] } ],
  "secrets": [ { "key": "GEN_API_KEY", "value": "...", "description": "생성 API 키" } ] }

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

{ "method": "Post",
  "statements": [
    { "type": "Http", "method": "POST", "url": "https://api.llm.com/v1/gen",
      "headers": [ { "key": "Authorization", "value": "Bearer { /secrets/LLM_API_KEY }" } ],
      "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 } ] } ],
  "secrets": [ { "key": "LLM_API_KEY", "value": "sk-...", "description": "LLM 호출용 API 키" } ] }

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

구조화 출력(response_format)으로 계약을 걸어도, 응답이 길이 제한에 걸려 잘리거나 모델이 요청을 거부하면 JSON이 아닌 것이 옵니다. 그래서 Try로 감싸 파싱 실패를 502로 바꿉니다. 실패 메시지에는 파싱하려던 텍스트가 실려 무엇을 받았는지 볼 수 있습니다. 응답 봉투부터 JSON이 아닌 API라면 Http에 responseType: "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": "{ /secrets/FRAUD_API_KEY }" } ],
          "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": "{ /secrets/CREDIT_API_KEY }" } ], "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 } ] } ],
  "secrets": [ { "key": "FRAUD_API_KEY", "value": "...", "description": "사기 점수 API 키" },
               { "key": "CREDIT_API_KEY", "value": "...", "description": "신용 점수 API 키" } ] }

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

반복과 집계

Loop와 ResourceForEach는 시간 예산에서 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" } } } ] } ] }

for는 from부터 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 { /secrets/PAYMENT_API_KEY }" } ],
          "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 }" } } } ] } ],
  "secrets": [ { "key": "PAYMENT_API_KEY", "value": "sk-...", "description": "결제 API 시크릿 키" } ] }

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

24. 콘텐츠 스튜디오에서 고치는 메일 양식으로 발송 (templateBody)

{ "method": "Post",
  "statements": [
    { "type": "ResourceRead", "resource": "Content", "name": "tpl",
      "target": { "sys": { "id": "cnt_mail_shipping" } } },
    { "type": "ResourceRead", "resource": "Content", "name": "order",
      "target": { "sys": { "id": "{ /payload/orderId }" } } },
    { "type": "EmailSend", "account": { "sys": { "id": "eml_orders" } },
      "toServiceUser": { "sys": { "id": "{ /order/fields/buyer/en-US/sys/id }" } },
      "subject": "배송이 시작되었습니다 (주문번호 { /order/sys/id })",
      "templateBody": {
        "source": "{ /tpl/fields/html/en-US }",
        "data": { "name": "{ /order/fields/buyerName/en-US }",
                  "tracking": "{ /order/fields/trackingNo/en-US }" } } },
    { "type": "Return", "value": { "sent": true } } ] }

양식을 Content에 두고(cnt_mail_shipping의 html Field) 그 빈칸만 채웁니다. 마케팅 담당자가 콘텐츠 스튜디오에서 문구를 고쳐도 Script는 그대로입니다. 양식은 이런 모양입니다.

<p>{ /name }님, 주문하신 상품의 배송이 시작되었습니다.</p>
<p>운송장 번호: { /tracking }</p>

source는 이 정의가 읽은 Content를 가리키는 포인터 하나여야 합니다. 양식을 읽는 ResourceRead는 EmailSend보다 앞에 둡니다. Statement는 적은 순서로 실행되므로, 아직 읽지 않은 이름을 가리키면 값이 비어 실행이 실패합니다. 양식의 빈칸이 읽는 것은 data뿐이라 양식에서 /payload를 직접 읽을 수는 없습니다. data에 없는 키를 가리킨 빈칸은 빈 글자가 됩니다.

25. 관리자가 쓴 뉴스레터를 구독자에게 (autoEscape)

{ "method": "Post",
  "statements": [
    { "type": "ResourceRead", "resource": "Content", "name": "issue",
      "target": { "sys": { "id": "{ /payload/issueId }" } } },
    { "type": "ResourceRead", "resource": "Content", "name": "tpl",
      "target": { "sys": { "id": "cnt_mail_newsletter" } } },
    { "type": "ResourceForEach", "resource": "Content", "contentType": { "sys": { "id": "ct_subscriber" } },
      "where": { "fields.active": { "eq": true } }, "order": "sys.createdAt,sys.id",
      "from": "Current", "advanced": false, "name": "sub",
      "onEach": [
        { "type": "EmailSend", "account": { "sys": { "id": "eml_news" } },
          "to": "{ /sub/fields/email/en-US }",
          "subject": "{ /issue/fields/title/en-US }",
          "autoEscape": false,
          "templateBody": {
            "source": "{ /tpl/fields/html/en-US }",
            "data": { "article": "{ /issue/fields/bodyHtml/en-US }" } } } ] } ] }

뉴스레터 본문은 관리자가 리치 텍스트로 쓴 HTML이라, 그대로 두면 태그가 글자로 보입니다. autoEscape를 false로 두면 data에 담긴 마크업이 렌더됩니다.

false로 두는 범위를 좁게 잡으세요. 이 설정은 그 EmailSend 한 통의 모든 보간 값에 걸립니다. 그래서 이 예시의 data에는 관리자가 쓴 article 하나만 담았습니다. 구독자 이름처럼 바깥에서 들어온 값을 같은 data에 섞으면, 그 값에 든 마크업까지 함께 렌더됩니다. 그런 값이 필요하면 양식 쪽에 두거나 발송을 나눕니다. 양식 자체도 autoEscape와 무관하게 언제나 날것으로 렌더되므로, 양식과 data에 들어가는 값이 모두 그 Content에 쓰기 권한을 가진 사람 손에서만 나오는지 확인하세요.

서명 검증

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

26. 웹훅 서명 검증 (포장된 헤더 풀기, 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": "{ /secrets/WEBHOOK_SIGNING_SECRET }",
      "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 } } ],
  "secrets": [ { "key": "WEBHOOK_SIGNING_SECRET", "value": "whsec_9f2c1b7ae4", "description": "결제 대행사 웹훅 서명 키" } ] }

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

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

외부 호출이 없어 선언한 시간이 없으므로 30초 기본 예산 안에서 끝나고, 제공자는 응답을 그 자리에서 받습니다. 서명 키는 secrets에 선언했으므로 WEEGLOO가 암호화해 안전하게 저장합니다. Statement에서는 { /secrets/WEBHOOK_SIGNING_SECRET }로 읽습니다. 다만 이 Script를 조회하면 원래 값이 보이므로, 이 Script를 읽을 수 있는 역할을 좁게 두세요(보안 모델의 secrets).

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

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

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

27. 키 없는 해시 서명 검증

{ "method": "Post",
  "statements": [
    { "type": "Hash", "name": "expectedSign", "algorithm": "SHA256", "encoding": "HexUpper",
      "value": "{ /payload/orderId }{ /payload/amount }{ /secrets/MERCHANT_KEY }" },
    { "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 } } ],
  "secrets": [ { "key": "MERCHANT_KEY", "value": "9f2c1b7ae4", "description": "결제 대행사와 공유한 가맹점 키" } ] }

HMAC 대신 "정해진 필드들과 비밀 키를 이어 붙여 SHA256을 계산"하는 방식입니다. Hash에는 secret 필드가 없습니다. 비밀 키는 secrets에 선언하고, 그 스킴이 키를 놓는 자리에 value 안에서 { /secrets/MERCHANT_KEY }로 적습니다. 스킴마다 키가 앞·뒤·중간으로 갈리는데, 이렇게 적으면 어느 자리든 표현할 수 있습니다.

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

회원 조회

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

{ "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건을 돌려주는 것이 아니라 실행이 실패합니다.
  • 매치가 없으면 ResourceFind는 null을 바인딩하므로, Content를 찾을 때와 같은 모양으로 존재 여부를 분기합니다.
  • 회원의 필드는 Content·Media와 달리 로케일 맵이 아닙니다. { /member/nickname }처럼 그대로 참조합니다.
  • 메일을 보낼 때 주소를 꺼내지 않고 toServiceUser에 sys.id를 넘깁니다. 엔진이 전송 직전에 주소를 resolve하므로 회원 주소가 Script 변수 공간에 들어오지 않습니다.
  • 이 정의를 저장하려면 작성자의 SpaceRole settings에 SETTING_SERVICE_LOGIN이 있어야 합니다. 회원을 만들거나 지우는 Statement는 어떤 역할로도 저장되지 않습니다(회원 디렉터리 다루기).

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

파일 전송

29. 상품 사진을 이미지 분석 서비스로 보내 결과 기록 (multipartBody)

{ "method": "Post",
  "statements": [
    { "type": "ResourceRead", "resource": "Content", "target": { "sys": { "id": "{ /payload/sys/id }" } }, "name": "product" },
    { "type": "ResourceRead", "resource": "Media",
      "target": { "sys": { "id": "{ /product/fields/photo/en-US/sys/id }" } }, "name": "photo" },
    { "type": "Try",
      "body": [
        { "type": "Http", "method": "POST", "url": "https://api.vision.example/v2/analyze",
          "headers": [ { "key": "x-api-key", "value": "{ /secrets/VISION_API_KEY }" } ],
          "multipartBody": [
            { "name": "image", "url": "{ /photo/fields/file/en-US/url }" },
            { "name": "language", "value": "ko" },
            { "name": "features", "value": "labels" },
            { "name": "features", "value": "text" } ],
          "timeoutMs": 20000, "name": "analysis" },
        { "type": "ResourcePatch", "resource": "Content", "target": { "sys": { "id": "{ /product/sys/id }" } },
          "fields": { "aiLabels": { "en-US": "{ /analysis/body/labels }" },
                      "aiText":   { "en-US": "{ /analysis/body/text }" } } },
        { "type": "Return", "value": { "ok": true, "labels": "{ /analysis/body/labels }" } } ],
      "catch": [
        { "type": "Return", "value": { "ok": false, "reason": "image analysis failed", "detail": "{ /error/message }" }, "isError": true, "statusCode": 502 } ] } ],
  "secrets": [ { "key": "VISION_API_KEY", "value": "...", "description": "이미지 분석 API 키" } ] }

판매자가 상품을 등록하면 상품 사진에서 라벨과 사진 속 글자를 추출해 상품에 기록하는 흐름입니다(Webhook의 연결 액션으로 Content.Create에 걸어 둡니다). 상품 Content의 photo 참조 필드로 Media를 읽고, 그 파일의 url을 multipartBody의 파일 part에 적습니다. api.vision.example은 예시용 가상 서비스이며, 이 서비스가 labels(문자열 배열)와 text(문자열)를 돌려준다고 가정했습니다. aiLabels는 Array(원소 ShortText) 필드라 배열을 그대로 씁니다. 실제로 쓰는 서비스의 응답 모양에 맞춰 { /analysis/body/... } 경로를 고치세요.

  • part 구성: 배열의 원소 하나가 part 하나이며, part는 배열 순서대로 전송됩니다. image는 url을 지정한 파일 part이고, 나머지는 value를 지정한 텍스트 part입니다. features처럼 같은 이름을 여러 번 쓰면 각각 별도의 part로 전송됩니다.
  • Content-Type: 요청 전체는 항상 multipart/form-data로 전송되므로 headers에 지정하지 않습니다. image part는 contentType을 생략했으므로 파일 URL이 응답한 Content-Type을 사용합니다. 파일 이름도 생략했으므로 파일 URL의 마지막 경로 조각이 파일 이름이 됩니다.
  • 실패 처리: 파일이 다운로드 한도를 넘거나, 파일 URL에서 파일을 받지 못하거나, 시간이 초과되면 그 Http는 실패합니다. 대상 서비스가 400 이상의 status를 응답해도 실패합니다. 이 예시는 ignoreStatusCode를 지정하지 않았으므로(기본값 false), 어느 경우든 catch로 넘어가고, catch의 Return이 status 502를 반환합니다. 상품은 수정되지 않습니다. 실패의 종류별 처리 방식은 Http가 실패하는 두 방식에서 다룹니다.
  • 시간 예산: Pro 플랜이면 이 Script에는 약 60.5초가 허용됩니다(기본 30초 + timeoutMs 20초 + 사진 한 장의 다운로드 시간 약 10.5초). 계산 방법은 시간 예산에서 다룹니다.

파일 하나를 본문 전체로 보내는 API(업로드 URL에 PUT하는 형태)라면 multipartBody 대신 urlBody를 씁니다. 두 필드의 규칙과 Content-Type이 정해지는 순서는 파일을 본문으로 보내기에서 다룹니다.

파일 변환

30. 구매자 이메일을 찍은 전자책 PDF 사본 (PdfWatermark)

{ "method": "Post",
  "payloadSchema": {
    "type": "object", "required": [ "fields" ],
    "properties": {
      "fields": {
        "type": "object", "required": [ "ebookMediaId", "email", "orderId" ],
        "properties": {
          "ebookMediaId": { "type": "string" },
          "email":   { "type": "string", "minLength": 1, "maxLength": 41, "not": { "pattern": "[^ -~]" } },
          "orderId": { "type": "string", "minLength": 1, "maxLength": 20, "not": { "pattern": "[^A-Za-z0-9-]" } } } } } },
  "statements": [
    { "type": "ResourceRead", "resource": "Media",
      "target": { "sys": { "id": "{ /payload/fields/ebookMediaId }" } }, "name": "ebook" },
    { "type": "ResourceCreate", "resource": "Media", "publish": false,
      "fields": {
        "title": { "en-US": "{ /ebook/fields/title/en-US }" },
        "file": { "en-US": {
          "source": "{ /ebook/fields/file/en-US/url }", "encoding": "url",
          "transformations": [
            { "type": "PdfWatermark", "text": "{ /payload/fields/email } · { /payload/fields/orderId }" } ] } } },
      "name": "copy" },
    { "type": "Return", "value": { "mediaId": "{ /copy/sys/id }" } } ] }

결제가 확인된 뒤 구매자에게 건넬 전자책 사본을 만드는 흐름입니다. 요청 payload로 원본 전자책 Media의 id, 구매자 이메일, 주문 번호를 받습니다. 원본 Media는 그대로 두고, 모든 페이지에 이메일 · 주문 번호가 대각선으로 찍힌 새 Media를 만들어 그 id를 돌려줍니다.

  • 문구 길이: 템플릿 밖 고정 문구는 · 3자입니다. payloadSchema가 이메일을 41자 이하, 주문 번호를 20자 이하로 제한했으므로 찍히는 문구는 64자를 넘지 않습니다. 두 값 모두 허용한 글자 밖의 글자(줄바꿈 포함)가 하나라도 있으면 요청이 실행 전에 거부되므로, 문구 때문에 변환 단계에서 실패하지 않습니다. 이메일이 받는 ASCII 출력 문자는 모두 쓸 수 있는 글자에 들어 있습니다.
  • 색: color를 생략했으므로 기본값 #808080(회색)으로 찍힙니다.
  • 발행: 구매자 한 사람을 위한 사본이므로 publish를 false로 두었습니다. 사본은 CDA/ACDA로 전달되지 않습니다.
  • 실패 처리: 원본 파일이 PDF가 아니거나 암호화되어 있으면 ResourceCreate가 status 422로 실패하고, 새 Media는 만들어지지 않습니다. 실패 경우의 전체 목록은 파일 변환이 실패하는 경우에서 다룹니다.
  • 시간 예산: 파일을 받고 변환하는 시간은 30초 기본 예산에서 나갑니다(시간 예산).
  • 구매 확인: 이 예시에는 결제와 구매 여부를 확인하는 단계가 없습니다. 이대로 두면 실행 권한이 있는 호출자는 어떤 Media id로든 사본을 만들 수 있습니다. 실제로 쓸 때는 주문 Content를 먼저 읽어 구매자와 상품이 맞는지 확인한 뒤 진행하세요.