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

계획이 곧 결과물이야

~15 min · 계획, 검증, 비용, 빠른 실패

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

생성하지 않는 버튼

아빠가 스튜디오에서 Review generation을 눌러도 이미지는 한 장도 안 만들어져. 스튜디오는 plan() 함수 하나를 부르고, 돌아온 걸 보여줄 뿐이야. 이어서 Generate images를 누르면 스튜디오는 똑같은 요청 본문을 같은 요청 id 그대로 다시 보내. 엔진은 뭔가 저장하기 전에 plan()을 한 번 더 돌려. 미리보기와 제출은 같은 코드 길을 타는 거야. POST /api/generation/preview는 계획에서 Ember 카탈로그를 빼고, 첫 결과물에 들어갈 프롬프트와 파일을 붙여서 돌려줘. POST /api/generation은 generate() 안에서 plan()을 돌리고 배치를 담아 202로 답해. 피파 쪽 관문인 /internal/generation도 같은 함수를 지나.

계획은 돈을 쓰기 전에 요청 전체를 글로 펼쳐 놓은 거야. 작업 하나가 이미지 한 장이고, 프레임 하나, 후보 번호 하나, 생성기 하나로 정해져. 작업마다 확정된 공급자와 출력 크기, Ember가 그 줄에 적어둔 과금 종류, 조립된 프롬프트가 들어가. 레퍼런스 이미지 전부의 id와 SHA-256, 그려질 피사체, 요청한 소울도 같이 담겨. 자동 생성기를 골랐다면 Ember의 경로 결정까지 붙어. 워커가 나중에 알아서 판단할 몫은 하나도 안 남겨.

배치가 생기기 전에 이유를 대고 거절해

plan()의 대부분은 거절이야. 거절마다 사람이 읽고 바로 고칠 수 있는 문장을 담은 StudioError가 나오고, 앱은 그걸 422로 돌려줘. 공급자 연결이 막혀 있으면 Ember가 준 메시지 그대로 503이 와. 코드에서 걸리는 순서대로 대강 훑어보면 이래.

  1. 이미지 요청에 붙은 클립 설정. 클립은 따로 계획하고, 그건 Clips Are Paid 트랙에서 다뤄.
  2. Ember 어휘에 없는 화면비.
  3. 같은 생성기를 두 번 고른 요청. 반복은 후보 개수로 하라고 알려줘.
  4. 세트가 필요한 작업(create, regenerate, remix)인데 세트가 없는 경우, 그리고 프레임 이름이 겹치거나 규칙에 안 맞거나 세트에 없는 경우.
  5. 이미지 레퍼런스 자리에 들어온 클립.
  6. Ember 목록에서 사라진 생성기, 막힌 연결, 그 줄이 안 주는 크기나 품질.
  7. 고른 생성기의 자체 목록에 없는 화면비.
  8. 원본이 연결 안 된 리믹스 프레임, 그리고 모델이 받겠다고 밝힌 것보다 많은 레퍼런스.
  9. 요청한 소울이 그릴 수 없는 피사체. 이건 피파한테 물어보고 판단해.
  10. 배치 상한을 넘는 작업 수.

이 검사는 전부 행 하나 쓰기 전에 끝나. 그래서 거절된 요청은 배치도, 작업도, 잡도 안 남겨. 화면비 테스트가 거절 직후에 바로 그걸 확인해. 배치 목록과 작업 목록이 둘 다 비어 있어야 통과야.

오타는 검토에서 멈춰야 해

요청 계약은 생성기 하나당 후보 수를 1에서 50 사이로 묶고, 스튜디오 숫자 칸도 같은 범위로 잘라. 근데 그걸로 배치 크기까지 묶이진 않아. 후보는 곱해지거든. 아키텍처 문서에 나오는 예가 딱 그래. 15프레임 컬렉션 두 개, 생성기 두 개, 후보 다섯이면 이미지 300장이야. 근데 요청 하나는 세트를 하나만 가리킬 수 있어. 그래서 지금 계획기로는 컬렉션마다 150장짜리 배치 두 개가 돼. 그중 하나에서 5 대신 50을 치면 1,500장이 계획돼. 그래서 곱한 결과에 두 번째 상한이 걸려 있어. max_batch_tasks, 기본값 600이고 AVATARS_MAX_BATCH_TASKS에서 읽어. 옆 주석이 이유를 한 줄로 말해. 후보 수 오타는 검토 단계에서 멈춰야지, 공급자 청구서에서 멈추면 안 된다는 거야.

이 검사는 계획을 짜는 반복문 안에 있어. 그래서 601번째 작업이 나오는 순간 멈추고, 나머지는 아예 안 만들어. 거절 문장은 뭘 바꾸면 되는지도 알려줘. 후보나 프레임이나 생성기를 줄이든지, 배치를 여러 개로 나누라고. 테스트는 이미지 20장짜리 리믹스 요청에 상한을 10으로 낮춰 걸고, 10장을 넘는다는 거절이 나오는지 봐. 후보 수 500은 계획까지 가지도 못한다는 것도 보여줘. 요청을 해석하는 단계에서 계약이 먼저 거절하거든.

피사체 조합마다 한 번만 확인해

어떤 검사는 다른 서비스한테 물어야 해. 요청한 소울이 어떤 피사체를 그려도 되는지는 피파의 정책이라서, 계획은 피파한테 물어. 다만 피사체 조합 하나에 한 번만 묻고, 그 답을 계획 끝까지 들고 가. 같은 테스트가 호출 수도 세는데, 작업 20개짜리 계획에서 피파한테 피사체를 묻는 건 많아야 여섯 번이야. 작업마다 네트워크를 한 번씩 오가는 미리보기라면 너무 느려서 아무도 안 쓸 거야. 기다리기 싫은 검사는 결국 다들 건너뛰게 되거든.

계획에서 의도로, 트랜잭션 하나로

generate()는 먼저 스튜디오의 의도 잠금을 잡고 이 요청 id를 본 적이 있는지 확인해. 그리고 계획을 다시 짜고, 배치와 작업 전부를 요청 id와 함께 SQLite 트랜잭션 하나로 써. 트리 전체가 있거나, 아무것도 없거나 둘 중 하나야. 테스트가 삽입 도중에 일부러 실패를 일으키면 배치도 작업도 요청 id도 하나도 안 남아. 같은 요청 id에 같은 입력이 오면 이미 만든 배치를 그대로 돌려줘. 그래서 Generate images를 두 번 눌러도 배치는 하나야. 같은 id에 다른 입력이 오면 충돌로 거절해. 이 저장 구조는 트랙 2에서 다뤄.

여기서 중요한 건 계획이 고스란히 의도가 된다는 점이야. 작업은 전부 queued 상태에 빈 시도 목록을 달고 저장돼. 워커는 계획이 얼려둔 것만 읽어. 레퍼런스 이미지 해시도 마찬가지라서, Ember에 한 바이트라도 보내기 전에 그 해시를 다시 확인해.

원칙: 배치가 생기기 전에 거절하면 문장 하나로 끝나. 같은 거절이 제출 뒤에 나오면 실패한 잡이 하나 남고, 때로는 청구서까지 날아와.

Code

cwkAvatars, backend/studio.py: plan()의 첫 거절들 (원문 그대로, 373-394행)·python
    async def plan(self, body: Generation, actor=None):
        if body.workflow == "clip":
            return await self.plan_clip(body, actor)
        if body.clip is not None:
            raise StudioError("Clip settings belong to the clip workflow.")
        catalog = await self.upstream.catalog()
        providers = {p["id"]: p for p in catalog["providers"]}
        ratios = aspect_ids(catalog.get("aspect_ratios"))
        if ratios and body.aspect_ratio not in ratios:
            raise StudioError("This aspect ratio is not available in Ember.")
        if len({p.id for p in body.providers}) != len(body.providers):
            raise StudioError("Choose each provider once; use the candidate count for repetitions.")
        collection = await self.io("get", body.set_id, "set") if body.set_id else None
        if collection:
            await self.subjects([collection["soul_pk"]], actor)
        if body.workflow in {"create", "regenerate", "remix"} and not collection:
            raise StudioError("Choose an avatar set for this workflow.")
        frames = body.frames or (collection["frames"] if collection else ["image"])
        if len(set(frames)) != len(frames) or any(not FRAME.fullmatch(f) for f in frames):
            raise StudioError("Frame names must be unique and valid.")
        if collection and not set(frames) <= set(collection["frames"]):
            raise StudioError("A requested frame does not belong to this set.")
cwkAvatars, backend/studio.py: generate()가 계획을 의도 하나로 바꾸는 부분 (원문 그대로, 548-562행)·python
    async def generate(self, body, actor=None):
        payload = {**body.model_dump(), "actor_soul_id": actor}
        async with self.intent_lock:
            existing = await self.io("request", body.request_id, payload)
            if existing:
                return existing
            plan = await self.plan(body, actor)
            spending = plan.get("spending")
            if spending:
                # Nothing is queued without the consent Ember's policy asks for.
                if spending["blocked"]:
                    raise StudioError(spending["blocked"], 409)
                if spending["requires_confirmation"] and not spending["confirmed"]:
                    raise StudioError("This clip needs confirmation before it is paid for. " + " ".join(spending["reasons"]), 409)
            return await self.io("create_intent", body.request_id, payload, "batch", {"title": body.title, "request": payload, "source_version": plan["source_version"], "frames": plan["frames"], "image_count": plan["image_count"], "set_count": plan["set_count"], "catalog": plan["catalog"], "archived": False}, parent=body.set_id or "", children=[("task", {**item, "state": "queued", "phase": "Queued", "attempts": [], "job_id": None, "asset_id": None, "error": None}) for item in plan["tasks"]])
cwkAvatars, backend/settings.py: 배치 상한과 그 이유 (원문 그대로, 35-37행)·python
    # One request plans at most this many metered images; a typo in the
    # candidate count must stop at review, never at the provider's invoice.
    max_batch_tasks: int = field(default_factory=lambda: max(1, int(os.getenv("AVATARS_MAX_BATCH_TASKS", "600"))))

External links

Exercise

만들고 있는 소프트웨어에서 네트워크 건너편에 돈이나 자원을 쓰는 동작 하나를 골라봐. 유료 API 호출이든, 메일 한 묶음이든, 클라우드 잡 여러 개든 좋아. 그 동작에서 plan()을 떼어내. plan()은 시작할 단위 전부를 각 단위가 쓸 입력과 함께 목록으로 돌려주고, 잘못된 요청은 문제를 짚는 문장으로 거절해야 해. 미리보기와 제출이 둘 다 plan()을 부르게 만들어. 단위 수엔 상한을 두되 설정에서 읽고, 왜 있는지 주석으로 남겨. 끝으로 일부러 숫자를 잘못 친 요청을 제출해서, 저장된 것도 나간 것도 없는지 확인해.
Hint
거절부터 쓰고 정상 경로는 나중에 써. 상한 검사는 단위를 만드는 반복문 안에 둬. 그래야 거대한 요청이 개수를 세느라 만 개를 다 만들기 전에 일찍 멈춰. 다른 서비스에 물어야 하는 검사라면 서로 다른 입력마다 한 번만 묻고, 답은 계획 끝까지 들고 가.

Progress

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

댓글 0

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

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