본문 바로가기
C.W.K.
Lesson 05 of 05 · published

지우지 말고 보관해

~13 min · 보관, 계보, 데이터 보존, api 설계

Level 0스케치
0 XP0/43 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

삭제는 되돌릴 수 있는 보관이야

아키텍처 문서의 지속성 절엔, 구호처럼 읽히지만 실은 규칙인 문장이 있어. 삭제는 되돌릴 수 있는 보관이다. 에셋, 배치, 프리셋 컬렉션엔 archived 표시가 붙어. 이걸 켜면 레코드가 기본 화면에서 빠져. 행도 남고 원본도 남고, 표시를 끄면 그대로 돌아와. 에셋 보관은 태어난 밤인 2026-09-29부터 있었고, 그때부터 아직 쓰이는 건 거절했어. 배치랑 프리셋 컬렉션엔 다음 날 이 표시가 붙었어.

읽는 방식마다 보관이 뜻하는 것

  • 목록에선 숨겨. 이미지 라이브러리는 요청이 archived=true를 달지 않는 한 보관 안 된 에셋만 보여 줘. Batches 페이지도 Archived 칸에 체크하기 전엔 보관된 배치를 숨기고. 딱 하나, 지금 열어 둔 배치는 예외야. 그걸 보관하거나, 선택한 채로 칸 체크를 풀어도 목록에 그대로 남아. 앞에 (archived) 표시만 붙고. 보던 화면에서 갑자기 사라지면 안 되니까.
  • 쓰려고 하면 거절해. 레퍼런스로 쓰든, 선택하든, 발행 프레임에 넣든 전부 Studio.asset이라는 문을 지나. 이 문은 보관된 이미지를 This image is archived. Restore it before use.로 막아. 보관된 이미지니까 쓰기 전에 복원하라는 말이야. 그래서 보관된 그림이 슬그머니 레퍼런스로 돌아오는 일은 없어.
  • 정책은 신경 안 써. export_policy 위 주석이 말해 줘. 보호된 이미지의 보관된 사본도 여전히 보호된 이미지라고. 보관은 정리일 뿐, 보호에서 빠져나가는 길이 아니야. 이 얘긴 트랙 8에서 이어 가.

원본을 붙잡아 두는 것

에셋 보관은 archived: true에 클라이언트가 읽은 버전을 실어 보내는 PATCH야. 표시를 바꾸기 전에 라우트가 네 가지를 물어. 하나라도 그렇다면 409가 나가고, 대신 뭘 해야 하는지 문장으로 알려 줘.

아직 쓰는 곳거절이 아빠한테 하는 말
지난 것까지 포함한 모든 발행의 프레임발행 이력이 붙잡고 있어서 복구용으로 남아 있어야 해
세트의 발행 맵이나 현재 선택보관하기 전에 세트에서 그 자리를 바꿔
레퍼런스 프로필이나 이름 붙은 룩보관하기 전에 프로필이나 룩에서 그 자리를 바꿔
안 끝난 태스크의 레퍼런스 (queued, preparing, submitting, running, copying, uncertain)그 생성을 먼저 끝내거나 취소해

발행 이력 줄이 제일 엄격해. 과거까지 덮으니까. 발행은 얼려 둔 스냅숏이고, 예전 발행을 복원하면 바로 그 원본들을 피파한테 다시 보내. 발행 미리보기는 프레임마다 Studio.asset으로 읽어. 원본 하나만 보관돼도, 예전 발행을 복원하는 버튼은 눌러도 실패하는 버튼이 돼 버려.

원칙: 뭔가를 사라지게 하기 전에, 그걸 가리키는 걸 과거까지 전부 세어 봐. 역사가 아직 이름을 부르는 레코드는 네가 치울 게 아니야.

배치와 컬렉션

배치는 안에서 돌아가는 게 없을 때 보관할 수 있어. 태스크 하나라도 queued, preparing, submitting, running, copying 상태면 막혀. 그때 나오는 말이 Finish or cancel this batch's running images before archiving it.야. 돌고 있는 이미지를 끝내거나 취소한 다음에 보관하라는 뜻이지. 눈여겨볼 데가 하나 있어. 에셋 규칙은 uncertain 태스크를 안 끝난 걸로 치는데, 배치 규칙은 안 쳐. 남은 문제가 uncertain 태스크 하나뿐인 배치는 보관할 수 있어. 그래도 그 태스크가 쓴 레퍼런스는 계속 붙잡혀 있어.

셰프 세트 같은 프리셋 컬렉션은 보관할 수 있어. 하지만 standard/default 컬렉션은 절대 안 돼. The default collection anchors this soul's references and cannot be archived. 기본 컬렉션이 이 소울 레퍼런스의 닻이라서 보관할 수 없다는 말이야. 기본 모드에서 소울의 레퍼런스 프로필은 이 컬렉션의 default 프레임을 따라가. 이걸 보관하면 그 소울의 새 그림 전부가 발밑을 잃어. 이 거절은 409가 아니라 그냥 422로 와. 남의 쓰기가 끼어든 게 아니라, 규칙이 아예 금지하는 거니까.

단 하나의 삭제

Store.delete가 있긴 해. docstring이 선을 그어. 혈통이 없는 레코드, 그러니까 고칠 수 있는 설정만 지운다고. 부르는 곳은 딱 하나야. 워크플로 템플릿을 초기화하면 아빠가 저장해 둔 문구가 지워지고, 워크플로는 기본 문구로 돌아가. 템플릿을 조상으로 부르는 그림은 없으니까, 잃는 건 아빠가 버리기로 한 설정뿐이야. 원본을 지우는 코드도 엔진 어디에도 없어. 백엔드에서 파일을 지우는 건 가족 킷의 원자적 쓰기가 실패한 뒤 자기 임시 파일을 치우는 것 하나뿐이야.

스튜디오가 이렇게 고집스러운 이유

Avatars는 Ember 이력을 정리하다가 아빠가 고른 아바타까지 쓸려 가지 않게, 중요한 그림마다 오래 가는 두 번째 사본을 쥐려고 있는 앱이야. 삭제 버튼이 있으면 그 구멍이 안에서 다시 뚫려. 보관은 아빠한테 깔끔한 라이브러리를 주면서도, 되돌릴 수 없는 건 하나도 안 만들어. 거절은 정리하다가 세트, 프로필, 돌고 있는 작업, 복원을 깨뜨리는 일을 막아 주고.

이걸 다 확인하는 테스트는 짧은 이야기처럼 읽혀. 레퍼런스 하나와 아무 데도 안 쓰는 이미지 하나를 올리고, 레퍼런스를 기본 세트에 넣은 다음 보관하려다 실패해. 남는 이미지는 이름을 바꾸고 보관해서, 라이브러리에서 빠지고 archived=true에 나타나는 걸 봐. 대기 중인 태스크가 있는 배치는 보관에 실패하고, 태스크를 취소한 뒤엔 성공해. 마지막으로 기본 세트를 보관하려다 422를 받아.

Code

cwkAvatars, backend/app.py: 에셋 PATCH 라우트, 보관 전에 묻는 네 가지 (원문 그대로, 299-322행)·python
    @app.patch("/api/assets/{id}")
    async def patch_asset(id: str, body: AssetPatch):
        asset = await studio.io("get", id, "asset")
        await studio.subjects(asset["subjects"])
        changes = {}
        if body.label is not None:
            changes["label"] = body.label.strip()
        if body.archived is not None:
            changes["archived"] = body.archived
        if body.archived:
            sets = await studio.io("list", "set")
            profiles = await studio.io("list", "profile")
            publications = await studio.io("list", "publication")
            if any(id in p["frames"].values() for p in publications):
                raise StudioError("This original is retained by publication history and must stay available for recovery.", 409)
            if any(id in s.get("published", {}).values() or id in s.get("selection", {}).values() for s in sets):
                raise StudioError("This image is used by a set. Replace that use before archiving.", 409)
            if any(id in p["assets"] or any(id in look["assets"] for look in p.get("variants", {}).values()) for p in profiles):
                raise StudioError("This image is used by a reference profile or a saved look. Replace that use before archiving.", 409)
            if any(id in [r["id"] for r in t["references"]] for t in await studio.io("list", "task", states=("queued", "preparing", "submitting", "running", "copying", "uncertain"))):
                raise StudioError("This original is used by an unfinished generation. Finish or cancel it before archiving.", 409)
        if not changes:
            raise StudioError("Nothing to change.")
        return with_exports(await studio.io("update", id, expected=body.version, **changes))
cwkAvatars, backend/studio.py: 세트와 배치 보관 (원문 그대로, 227-238행)·python
    async def archive_set(self, set_id, body):
        collection = await self.io("get", set_id, "set")
        await self.subjects([collection["soul_pk"]])
        if body.archived and collection["channel"] == "standard" and collection["name"] == "default":
            raise StudioError("The default collection anchors this soul's references and cannot be archived.")
        return await self.io("update", set_id, expected=body.version, archived=body.archived)

    async def archive_batch(self, batch_id, body):
        record = await self.io("get", batch_id, "batch")
        if body.archived and await self.io("list", "task", batch_id, states=("queued", "preparing", "submitting", "running", "copying")):
            raise StudioError("Finish or cancel this batch's running images before archiving it.", 409)
        return await self.io("update", batch_id, expected=body.version, archived=body.archived)
cwkAvatars, backend/tests/test_content_workflows.py: API를 처음부터 끝까지 지나는 보관 규칙 (원문 그대로, 153-176행)·python
@pytest.mark.asyncio
async def test_archive_routes_hide_records_and_refuse_referenced_originals(tmp_path):
    app = create_app(Settings(data=tmp_path, run_workers=False), Authority(), auth_check=lambda request, upstream: asyncio.sleep(0))
    with TestClient(app, client=("127.0.0.1", 1)) as client:
        upload = client.post("/api/assets", files={"file": ("ref.png", image(), "image/png")}, data={"subjects": '["pippa"]'}).json()
        stray = client.post("/api/assets", files={"file": ("stray.png", image("green"), "image/png")}, data={"subjects": "[]"}).json()
        created = client.post("/api/sets", json={"soul_pk": "pippa", "name": "default", "frames": ["default"]}).json()
        client.patch(f"/api/sets/{created['id']}/selection", json={"version": created["version"], "choices": {"default": upload["id"]}})
        assert client.patch(f"/api/assets/{upload['id']}", json={"version": upload["version"], "archived": True}).status_code == 409
        renamed = client.patch(f"/api/assets/{stray['id']}", json={"version": stray["version"], "label": "Fireside thumbnail"}).json()
        assert renamed["label"] == "Fireside thumbnail" and renamed["exports"][0]["filename"] == "fireside-thumbnail-youtube.jpg"
        archived = client.patch(f"/api/assets/{stray['id']}", json={"version": renamed["version"], "archived": True}).json()
        assert archived["archived"]
        assert [a["id"] for a in client.get("/api/assets").json()["items"]] == [upload["id"]]
        assert [a["id"] for a in client.get("/api/assets?archived=true").json()["items"]] == [stray["id"]]
        assert client.get(f"/api/assets?origin=uploaded&q=fireside").json()["total"] == 0
        # Library search reaches prompts, not only labels.
        app.state.studio.store.update(upload["id"], prompt="A cozy fireside talk")
        assert client.get("/api/assets?q=cozy%20fireside").json()["total"] == 1
        batch = client.post("/api/generation", json={"request_id": "b1", "prompt": "Scene", "providers": [{"id": "first"}]}).json()
        assert client.patch(f"/api/batches/{batch['id']}", json={"version": batch["version"], "archived": True}).status_code == 409  # queued work
        app.state.studio.store.update((app.state.studio.store.list("task", batch["id"]))[0]["id"], state="cancelled")
        assert client.patch(f"/api/batches/{batch['id']}", json={"version": batch["version"], "archived": True}).json()["archived"]
        assert client.patch(f"/api/sets/{created['id']}", json={"version": created["version"] + 1, "archived": True}).status_code == 422

External links

Exercise

네 앱에서 다른 레코드가 가리키는 레코드 종류를 하나 골라. 올린 파일이든, 고객이든, 상품이든. 그 삭제를 archived 표시로 바꿔. 지난 주문이나 지난 릴리스 같은 이력까지 포함해서, 보관을 막아야 하는 관계를 전부 적어. 관계마다 사용자가 다음에 뭘 하면 되는지 말해 주는 거절 문장을 하나씩 써. 그리고 막혀야 하는 상태마다 보관을 시도하는 테스트를 쓰고, 마지막엔 아무것도 안 가리키는 레코드로 하나 더 써.
Hint
보관이 목록에서, 사용할 때, 권한이나 정책을 따질 때 각각 뭘 뜻하는지 따로 정해. Avatars는 목록에선 숨기고, 쓰려 하면 거절하고, 보호를 따질 땐 표시를 아예 무시해. 진짜 삭제는 혈통으로 엮인 게 없는 레코드에만 남겨. 돌아갈 기본값이 있는 설정 같은 거 말이야.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.