Saeroon Developer Center
1Step 1 Start2Step 2 Connect· My statusDocs

이 문서의 큐

  1. 이건 전부 회원의 일입니다
  2. 보유 기록하기
  3. 읽기와 쓰기 — 응답 스키마
  4. 저장은 전체 교체입니다
  5. flags 는 사이트 어휘입니다
  6. 🔴 읽고-합치고-쓰는 사이를 지키세요
  7. 은퇴한 항목의 보유
  8. 은퇴 항목의 세 갈래
  9. 서버가 빼는 것은 dropped 로 돌아옵니다
  10. 🔴 읽기에 실패했으면 저장하지 마세요
  11. 그룹 표식
  12. 표식 REST
  13. 그룹원의 보유 보기
  14. 관련 문서

개요

시작하기

Quickstart

기능 붙이기

FormsMembersBoardsShopCatalogsGroupsHoldings블로그 가져오기

앱 붙이기

앱 백엔드 붙이기앱 콜백 브리지

레퍼런스

속성 계약 (data-saeroon-*)CLI 레퍼런스 (@saeroon/cli)APIMCP예약 경로

마켓플레이스

Marketplace

문서 / 기능 붙이기

이 문서의 큐
  1. 이건 전부 회원의 일입니다
  2. 보유 기록하기
  3. 읽기와 쓰기 — 응답 스키마
  4. 저장은 전체 교체입니다
  5. flags 는 사이트 어휘입니다
  6. 🔴 읽고-합치고-쓰는 사이를 지키세요
  7. 은퇴한 항목의 보유
  8. 은퇴 항목의 세 갈래
  9. 서버가 빼는 것은 dropped 로 돌아옵니다
  10. 🔴 읽기에 실패했으면 저장하지 마세요
  11. 그룹 표식
  12. 표식 REST
  13. 그룹원의 보유 보기
  14. 관련 문서

Holdings

회원이 카탈로그에서 무엇을 가졌는지 기록합니다. 도감에서 모은 종, 획득한 배지, 완료한 과정처럼 "이 회원이 이 항목을 가졌다"로 표현되는 것이 보유입니다.

여기에 하나가 더 붙습니다. 그룹 안에서 한 사람이 다른 사람의 항목에 표식을 남기는 것 — 누가 무엇을 맡을지 나누거나 서로의 진행을 확인하는 조율용 기록입니다.

이건 전부 회원의 일입니다

보유와 표식은 사이트 회원 세션으로만 돕니다. 운영자 키로 대신 기록하는 경로는 없고, 그래서 CLI 에도 대응 커맨드가 없습니다.

회원이 자기 보유를 기록하는 것을 운영자가 대신하면 권한 모델이 뒤집힙니다. 운영자가 하는 일은 그 기록이 가리킬 카탈로그를 정의하고, 누가 쓸 수 있는지 정책을 정하는 데까지입니다.

선행 조건이 셋입니다.

  1. 카탈로그가 있을 것 — 보유는 카탈로그 항목을 가리킵니다
  2. 회원 인증이 배선되어 있을 것 — 로그인한 회원만 기록합니다
  3. 표식을 쓰려면 그룹이 있을 것

보유 기록하기

<!-- 내가 가진 것 -->
<div data-saeroon-holding-list data-catalog="flowers">
  <template>
    <li>
      <span data-saeroon-field="itemName"></span>
      <em data-saeroon-field="quantity"></em>
    </li>
  </template>
  <p data-saeroon-empty>아직 기록한 보유가 없습니다.</p>
</div>

<!-- 항목 하나를 켜고 끄기 -->
<button data-saeroon-holding-toggle data-catalog="flowers" data-item-id="<itemId>">
  보유 표시
</button>

토글이 끝나면 버튼에 data-saeroon-held="true|false" 가 붙고 saeroon:holding-changed 이벤트가 올라옵니다. 같은 카탈로그를 보는 보유 목록은 자동으로 다시 그려집니다.

비로그인 방문자에게는 목록이 "로그인 후 볼 수 있습니다"로 표시됩니다. 인증이 필요한 요청을 보내 401 을 받는 대신, 세션이 없으면 아예 호출하지 않습니다.

읽기와 쓰기 — 응답 스키마

직접 API 를 부를 때 필요한 것을 먼저 적습니다. 모든 경로 앞에 접두사가 붙습니다.

https://api.saeroon.com/api/v1/hosting/public/sites/{siteId}

인증은 Authorization: Bearer samt_… 또는 세션 쿠키입니다. 자세한 규약은 Groups §인증 에 있습니다 — 쿠키는 SameSite=Lax 라 크로스사이트에서 안 실린다는 점만 기억하세요.

// GET /catalogs/{catalogKey}/holdings/me
{
  "catalogKey": "flowers",
  "holdings": [
    { "itemId": "…", "itemName": "장미", "isRetired": false,
      "quantity": 1, "flags": ["wishlist"], "acquiredAt": "2026-08-01T…" }
  ],
  "updatedAt": "2026-08-10T…",
  "dropped": []
}

// PUT /catalogs/{catalogKey}/holdings/me
{ "holdings": [ { "itemId": "…", "quantity": 1, "flags": ["wishlist"] } ] }
// → 저장 후 위와 같은 GET 모양을 그대로 돌려줍니다

🔑 되싣기 매핑은 세 칸뿐입니다. 읽은 것을 고쳐 다시 저장할 때 원소에서 골라낼 것은 itemId · quantity · flags 입니다. itemName · isRetired · acquiredAt 은 서버가 만드는 읽기 전용 파생값이라 요청에 넣지 않습니다.

읽은 배열을 통째로 되보내도 서버가 남는 칸을 무시하므로 동작은 합니다. 다만 그 습관은 파생값이 늘어날 때 조용히 깨지니, 처음부터 세 칸만 뽑는 편이 안전합니다.

저장은 전체 교체입니다

PUT /catalogs/{catalogKey}/holdings/me 는 보낸 배열을 그 회원의 보유 전부로 덮어씁니다. 항목 하나만 담아 보내면 나머지 보유가 전부 사라집니다.

그래서 하나를 켜고 끄려면 현재 목록을 먼저 읽어 한 항목만 더하거나 뺀 새 목록을 보내야 합니다. SDK 의 data-saeroon-holding-toggle 은 그 합치기를 대신 해 줍니다. 직접 구현한다면 같은 순서를 지키세요 — 이 한 단계를 건너뛰면 클릭 한 번에 회원이 모은 기록이 통째로 지워집니다.

quantity 는 수량, flags 는 사이트가 자유롭게 쓰는 문자열 목록입니다. 둘 다 선택이며 생략하면 수량 1, 플래그 없음입니다. 한 번에 저장할 수 있는 항목은 1,000건까지이고, 넘으면 TOO_MANY_HOLDINGS 입니다.

flags 는 사이트 어휘입니다

플랫폼은 flags 값을 해석하지 않습니다. 저장하고 그대로 돌려줄 뿐이라, 「이 항목은 지금 쓰는 중」이나 「위시리스트」처럼 보유 하나에 붙이고 싶은 상태를 사이트가 직접 정합니다. 열거형이 아니고 사전 선언도 없습니다.

⚠️ 되싣기에서 모르는 값을 버리지 마세요. 지금 쓰는 태그가 하나뿐이라고 「그것 말고는 버린다」로 짜면, 나중에 어휘가 늘었을 때 다른 화면이 붙인 태그를 조용히 전멸시킵니다. 전체 교체라 되돌릴 수도 없습니다. 자기가 다루는 태그만 켜고 끄고 나머지는 통과시키세요.

🔴 읽고-합치고-쓰는 사이를 지키세요

읽은 뒤 저장하기까지 그 사이에 남이 저장하면 그 변경이 사라집니다. 보유는 한 번 덮이면 회원이 모아 온 기록이 통째로 없어지는 자리라, 이 도메인에서 가장 값이 큰 방어입니다.

GET /catalogs/{catalogKey}/holdings/me   → ETag: W/"h3f2a…"
PUT /catalogs/{catalogKey}/holdings/me   ← If-Match: W/"h3f2a…"      맞지 않으면 412

판본은 시각이 아니라 내용 지문입니다. 최종 수정 시각으로 대신하지 않는 이유가 둘 있습니다 — 같은 순간에 일어난 두 변경이 같은 값을 내고, 항목을 지우기만 한 변경은 남은 행의 시각을 건드리지 않아 판본이 움직이지 않습니다. 지문은 둘 다 잡습니다.

  • W/ 접두사나 따옴표를 떼고 돌려줘도 통과합니다
  • 412 를 받으면 다시 읽어서 그 위에 다시 얹으세요. 사용자에게 실패로 보이면 안 되는 자리입니다
  • 순서는 무관합니다. 같은 집합이면 서버가 어떤 순서로 읽어 오든 같은 판본입니다

🔴 다만 옵트인입니다. If-Match 를 안 보내면 종전대로 마지막 쓰기가 이깁니다. 필수로 만들면 이미 도는 호출자가 그 자리에서 전부 412 로 죽기 때문에 그렇게 두었습니다. 보내야 지켜지는 것이지, 안 보내도 서버가 알아서 막아 주지 않습니다.

은퇴한 항목의 보유

카탈로그 항목이 은퇴해도 보유 기록은 남습니다. 응답에 isRetired: true 로 표시되니 화면에서 다르게 보여 줄 수 있습니다.

기록을 남기는 것이 계약입니다. 운영자가 도감에서 어떤 종을 내렸다고 해서 그것을 모았던 회원의 기록까지 사라지면 안 되기 때문입니다. 이것이 카탈로그에 삭제가 없고 은퇴만 있는 이유이기도 합니다.

직접 API 를 쓴다면 한 가지를 더 지켜야 합니다. 보유 목록을 다시 저장할 때 은퇴한 항목의 보유도 함께 실어 보내세요. 전체 교체라서, 걸러내고 보내면 그 기록이 지워집니다.

은퇴 항목의 세 갈래

읽기와 쓰기가 다르게 움직입니다. 스키마만 보면 읽기 쪽은 짐작이 되는데 쓰기 쪽은 짐작할 근거가 없으니, 셋을 그대로 적어 둡니다.

상황결과
읽기 — 은퇴 항목을 가진 회원의 GET그 행이 그대로 나오고 isRetired: true 가 붙는다
쓰기 ① — 이미 가진 은퇴 항목을 되실어 저장보존된다. 저장 한 번으로 사라지지 않는다
쓰기 ② — 은퇴 항목을 새로 담기빠진다. 오류가 아니라 무시 — 대신 dropped 에 retired_new 로 나온다

가진 사람은 유지하고 새로 담는 것만 막는 구조입니다. 그래서 운영자가 도감에서 어떤 종을 내려도 그것을 모았던 회원의 기록은 그대로입니다.

서버가 빼는 것은 dropped 로 돌아옵니다

저장 요청의 항목 중 아래 넷은 오류가 아니라 무시됩니다. 200 이 오고 응답의 holdings 에 그 행이 없습니다. 대신 무엇을 왜 뺐는지 dropped 에 실려 옵니다.

// PUT /catalogs/{catalogKey}/holdings/me  응답
{
  "catalogKey": "flowers",
  "holdings": [ … 저장된 상태 … ],
  "updatedAt": "…",
  "dropped": [ { "itemId": "…", "reason": "retired_new" } ]
}
reason언제
invalid_iditemId 가 빈 값입니다. 정당한 경우가 없는 호출자 버그입니다
duplicate같은 itemId 를 두 번 보냈습니다 — 마지막 것을 채택하고 앞의 것을 버렸습니다
not_found이 카탈로그에 없는 항목입니다(오타·다른 카탈로그·다른 사이트의 ID)
retired_new은퇴한 항목을 새로 담으려 했습니다(위 쓰기 ②)

retired_new 는 오류가 아니라 정당한 경합입니다 — 회원이 화면을 보는 사이에 운영자가 그 항목을 내렸을 수 있습니다. 그래서 저장 전체를 실패시키지 않고 그 한 줄만 빼고 나머지를 저장합니다. 이름이 retired 가 아니라 retired_new 인 이유도 여기입니다. 규칙은 「은퇴」가 아니라 「은퇴 이면서 기존 보유가 아님」이라, 이미 가진 은퇴 항목은 되실어도 안 빠지고 dropped 에도 안 나옵니다.

400 을 기다리지 마세요. 영영 오지 않습니다. 대신 dropped 를 보세요.

보낸 개수 = holdings.length + dropped.length

저장 응답을 진실로 삼으세요. 요청이 아니라 응답이 저장된 상태입니다. 응답에서 화면 상태를 세우면 이 어긋남이 다음 저장으로 번지지 않습니다. 요청에서 세우면 빠진 항목이 화면엔 「담겼다」로 남고, 회원은 같은 것을 계속 다시 담게 됩니다.

dropped 는 이번 요청의 보고이지 자원의 상태가 아니라, ETag 에는 들어가지 않습니다. 같은 보유 집합이면 dropped 가 있든 없든 판본이 같습니다.

🔴 읽기에 실패했으면 저장하지 마세요

전체 교체라서 「못 읽었다」와 「0건이다」를 구분하지 못하면 그 자리가 보유 전멸입니다. 응답 파싱에 실패했는데 빈 목록으로 접고, 거기에 항목 하나를 더해 저장하면, 서버는 그것을 그 회원의 보유 전부로 받습니다.

GET 은 언제나 {catalogKey, holdings, updatedAt, dropped} 모양이고 dropped 는 조회에서 늘 빈 배열입니다(조회는 아무것도 버리지 않지만 칸은 항상 있습니다 — 있다 없다 하면 부재를 「이 서버는 안 알려준다」로 읽게 됩니다). 아는 모양이 아니면 실패로 처리하고 저장 경로로 넘어가지 마세요. 빈 배열은 「보유 없음」이라는 정상 응답이지, 읽기 실패의 기본값이 아닙니다.

그룹 표식

그룹 안에서 다른 사람의 항목에 표식을 남깁니다. 자기 보유를 기록하는 것과 주체가 반대라서 권한이 따로 있습니다.

<button data-saeroon-group-mark
        data-group-id="<groupId>"
        data-member-id="<대상 회원 ID>"
        data-item-id="<itemId>"
        data-mark-key="assigned">표식</button>

표식을 찍으려면 그 사람의 직위에 marks.manage 권한이 있어야 합니다. 없으면 서버가 403 으로 막고 SDK 가 안내 문구를 띄웁니다. 버튼을 숨기는 것은 편의일 뿐이고, 최종 판정은 언제나 서버가 합니다.

markKey 는 사이트가 정하는 표식의 종류입니다. 담당 배정, 확인 완료, 우선순위처럼 필요한 어휘를 그대로 쓰면 됩니다. 자유 문자열이고 사전 선언이 필요 없습니다(최대 40자) — 정책 응답에 마크 스키마가 없는 것이 정상입니다.

표식 REST

하려는 것경로
조회 (그룹원 전원 가능)GET /groups/{groupId}/marks
설정·변경PUT /groups/{groupId}/marks ← {targetMemberId, itemId?, markKey?}
1건 해제 (멱등)POST /groups/{groupId}/marks/clear ← {targetMemberId, itemId?}
전부 삭제 (라운드 초기화)DELETE /groups/{groupId}/marks

해제 경로가 POST 인 이유는 DELETE 에 본문을 싣기 어려워서입니다. 두 삭제 경로의 뜻은 「1건 해제」 대 「전부 삭제」 로 갈립니다 — 「없음 → 배정 → 제외 → 없음」처럼 도는 화면의 마지막 칸은 /marks/clear 입니다.

itemId 를 비우면 항목이 아니라 회원 단위 표식입니다.

조회 응답 원소는 이렇습니다.

{ "id", "targetMemberId", "targetDisplayName",
  "catalogItemId", "markKey", "markedByMemberId", "createdAt", "updatedAt" }

⚠️ 조회 응답의 항목 칸 이름은 itemId 가 아니라 catalogItemId 입니다. 요청 본문 쪽은 itemId 라, 이 하나만 이름이 어긋납니다.

그룹원의 보유 보기

그룹 안에서는 서로의 보유를 볼 수 있습니다.

GET /api/v1/hosting/public/sites/{siteId}/groups/{groupId}/catalogs/{catalogKey}/holdings
[ { "siteMemberId": "…", "displayName": "…",
    "holdings": [ { …내 보유와 같은 원소… } ] } ]

회원당 한 행이고 페이지네이션이 없습니다 — 그룹 인원과 도감 크기를 곱한 만큼이 한 번에 옵니다. 누가 무엇을 이미 가졌는지 보고 남은 것을 나누는 흐름에 씁니다.

⚠️ 이 응답만 곱셈입니다. 항목 목록은 항목 수만큼이라 도감이 커져도 선형인데, 여기는 인원과 도감이 함께 커지면 곱으로 큽니다. 보유행이 20,000행을 넘으면 서버가 GROUP_HOLDINGS_TOO_LARGE 로 거절합니다 — 조용히 자르지 않는 이유는, 잘린 목록이 「이 사람은 아무것도 안 가졌다」와 구분이 안 되고 조율 화면에서 그 오독이 곧 잘못된 배정이 되기 때문입니다. 걸리면 그룹을 나누거나 카탈로그를 나누세요.

이 조회도 회원 세션이 필요하며, 그룹에 속하지 않은 회원은 볼 수 없습니다.

관련 문서

  • Catalogs — 보유가 가리키는 대상 정의하기
  • Groups — 표식 권한이 나오는 직위 체계
  • Members — 회원 인증 (보유 기능의 선행 조건)

요약

회원이 카탈로그에서 무엇을 가졌는지 기록합니다. 도감에서 모은 종, 획득한 배지, 완료한 과정처럼 "이 회원이 이 항목을 가졌다"로 표현되는 것이 보유입니다.

마크다운 원문/docs/holdings.md