<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>JenaLab Technical Blog</title>
    <description>AI SaaS, macOS 네이티브 앱, 시스템 아키텍처에 대한 기술 블로그. Storida, jenaMemory, JenaNote, JenaImage 프로젝트의 설계와 구현을 다룹니다.</description>
    <link>https://jenalab-inc.github.io/</link>
    <atom:link href="https://jenalab-inc.github.io/feed.xml" rel="self" type="application/rss+xml" />
    <language>ko</language>
    <pubDate>Tue, 24 Mar 2026 23:34:43 +0900</pubDate>
    <lastBuildDate>Tue, 24 Mar 2026 23:34:43 +0900</lastBuildDate>
    <generator>Jekyll v3.10.0</generator>
    
      <item>
        <title>원탭 생성과 건당 결제 — 구독 모델에서 단건 결제로</title>
        <description>&lt;h2 id=&quot;동화책은-매달-만들지-않는다&quot;&gt;동화책은 매달 만들지 않는다&lt;/h2&gt;

&lt;p&gt;월 구독 모델에는 전제가 있다. 사용자가 매달 반복적으로 서비스를 쓴다는 것. AI 동화 생성은 이 전제에 맞지 않았다. 생일, 기념일, 특별한 순간에만 만든다. 연 2~3회.&lt;/p&gt;

&lt;p&gt;구독에서 건당 결제로 전환했다. 동시에, 생성의 진입장벽을 낮추기 위해 원탭 모드를 설계했다.&lt;/p&gt;

&lt;h2 id=&quot;원탭-오늘의-동화--이름-하나로-시작&quot;&gt;원탭 “오늘의 동화” — 이름 하나로 시작&lt;/h2&gt;

&lt;p&gt;기존 생성 플로우는 설정 항목이 많았다. 스타일, 프롬프트, 캐릭터, 장면 수, 글자 수. UX 간편화로 핵심 3개로 줄였지만, 더 극단적인 경로가 필요했다. 이름만 입력하면 끝나는 모드.&lt;/p&gt;

&lt;p&gt;원탭 모드의 설계 원칙은 하나. &lt;strong&gt;사용자가 결정하지 않는 것은 서버가 결정한다.&lt;/strong&gt;&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;사용자 입력: 아이 이름 (1개)
서버 결정:
  - 상황 → 템플릿 풀에서 랜덤 선택
  - 작문 스타일 → 랜덤 선택
  - 그림 스타일 → 랜덤 선택
  - 장면 수: 3 (고정)
  - 장면당 글자 수: 150 (고정)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;상황 템플릿은 시스템 설정 테이블에 JSON 배열로 저장한다. 코드 배포 없이 관리자가 추가·수정할 수 있다. 템플릿에는 `` 플레이스홀더가 들어간다. 서버가 치환한 뒤 기존 생성 파이프라인에 그대로 전달한다.&lt;/p&gt;

&lt;p&gt;프론트엔드 변경은 최소화한다. 기존 생성 API에 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mode&lt;/code&gt; 파라미터 하나를 추가한다. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mode: &apos;quick&apos;&lt;/code&gt;이면 서버가 나머지를 채운다. 클라이언트는 이름만 보내면 된다.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;생성 페이지 상단:
┌──────────────────────────────────────┐
│ &quot;오늘 밤, 아이에게 이야기를 들려주세요&quot; │
│                                      │
│ [아이 이름 입력]  [오늘의 동화 ▶]     │
│                                      │
│ ↓ 아래로 스크롤하면 상세 설정         │
└──────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;상세 설정은 그대로 유지한다. 원탭 모드는 진입점이지, 기존 기능의 대체가 아니다.&lt;/p&gt;

&lt;h2 id=&quot;건당-결제--빌링키가-아닌-단건-승인&quot;&gt;건당 결제 — 빌링키가 아닌 단건 승인&lt;/h2&gt;

&lt;p&gt;기존 구독 결제는 빌링키 방식이다. 카드를 등록하면 매월 자동 청구된다. 건당 결제는 다르다. 매번 사용자가 결제를 승인한다. 1회성이다.&lt;/p&gt;

&lt;p&gt;토스페이먼츠 기준, 두 방식의 차이는 명확하다.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;항목&lt;/th&gt;
      &lt;th&gt;구독 (빌링키)&lt;/th&gt;
      &lt;th&gt;건당 (단건결제)&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;카드 등록&lt;/td&gt;
      &lt;td&gt;필수 (authKey → billingKey)&lt;/td&gt;
      &lt;td&gt;불필요&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;결제 승인&lt;/td&gt;
      &lt;td&gt;서버가 자동 (billingKey 사용)&lt;/td&gt;
      &lt;td&gt;사용자가 직접&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;API&lt;/td&gt;
      &lt;td&gt;빌링키 발급 + 빌링키 결제&lt;/td&gt;
      &lt;td&gt;confirm (paymentKey + orderId)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;반복&lt;/td&gt;
      &lt;td&gt;매월 자동&lt;/td&gt;
      &lt;td&gt;매번 수동&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;기존 구독 결제에서 사용하던 토스 API 호출 로직을 공통 유틸로 추출했다. 빌링키 발급, 빌링키 결제, 단건 승인, 결제 취소를 하나의 클라이언트 클래스에 모은다. 구독 서비스와 토큰 구매 서비스가 동일한 클라이언트를 공유한다.&lt;/p&gt;

&lt;h2 id=&quot;토큰-상품-설계&quot;&gt;토큰 상품 설계&lt;/h2&gt;

&lt;p&gt;건당 결제의 단위는 “토큰 상품”이다. 상품 테이블을 두고, 관리자가 가격과 토큰 수량을 설정한다.&lt;/p&gt;

&lt;p&gt;예시 상품 구성:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;상품&lt;/th&gt;
      &lt;th&gt;토큰&lt;/th&gt;
      &lt;th&gt;가격&lt;/th&gt;
      &lt;th&gt;의미&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;동화 1편&lt;/td&gt;
      &lt;td&gt;750&lt;/td&gt;
      &lt;td&gt;₩3,900&lt;/td&gt;
      &lt;td&gt;기본 단위&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;동화 3편&lt;/td&gt;
      &lt;td&gt;2,250&lt;/td&gt;
      &lt;td&gt;₩9,900&lt;/td&gt;
      &lt;td&gt;묶음 할인&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;동화 10편&lt;/td&gt;
      &lt;td&gt;7,500&lt;/td&gt;
      &lt;td&gt;₩29,900&lt;/td&gt;
      &lt;td&gt;대량 할인&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;상품 목록은 공개 API로 제공한다. 인증 없이 조회 가능해야 한다. 비로그인 사용자도 가격을 먼저 확인할 수 있어야 하기 때문이다.&lt;/p&gt;

&lt;h2 id=&quot;결제-흐름&quot;&gt;결제 흐름&lt;/h2&gt;

&lt;p&gt;결제는 3단계로 나뉜다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1단계 — Checkout&lt;/strong&gt;: 사용자가 상품을 선택하면 서버에 주문을 생성한다. 서버는 주문 ID, 금액, 결제 클라이언트 키를 반환한다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2단계 — 결제 위젯&lt;/strong&gt;: 토스 결제 위젯이 화면에 표시된다. 사용자가 카드 정보를 입력하고 결제를 승인한다. 성공 시 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;paymentKey&lt;/code&gt;가 반환된다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3단계 — Confirm&lt;/strong&gt;: 클라이언트가 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;paymentKey&lt;/code&gt;를 서버로 전송한다. 서버가 토스 승인 API를 호출한다. 성공하면 토큰이 즉시 반영된다.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;사용자 → 상품 선택 → POST checkout → orderId 수신
       → 토스 위젯 결제 → paymentKey 수신
       → POST confirm → 토큰 충전 완료
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;기존 토큰 시스템의 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;addon_tokens&lt;/code&gt; 필드를 재사용한다. 토큰 히스토리에 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;source: &apos;addon_purchase&apos;&lt;/code&gt;로 기록한다. 구독 토큰과 별도로 관리되므로 차감 순서도 기존 로직(애드온 먼저 → 구독 토큰)을 따른다.&lt;/p&gt;

&lt;h2 id=&quot;기존-코드-재사용-전략&quot;&gt;기존 코드 재사용 전략&lt;/h2&gt;

&lt;p&gt;신규 기능이지만 새로운 패턴은 없다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;결제&lt;/strong&gt;: 토스 클라이언트를 공통 유틸로 추출. 구독/건당 모두 동일 클라이언트.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;토큰&lt;/strong&gt;: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;addon_tokens&lt;/code&gt;와 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;token_history&lt;/code&gt;를 그대로 사용. 새 테이블은 상품 목록 1개만.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;생성&lt;/strong&gt;: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mode&lt;/code&gt; 파라미터 추가. 나머지 파이프라인 변경 없음.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;결제 위젯&lt;/strong&gt;: 구독 결제에서 이미 사용 중인 토스 SDK를 재사용.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;아키텍처 변경 없이 기존 패턴을 따라 기능을 추가하는 것이 원칙이다.&lt;/p&gt;

&lt;h2 id=&quot;결론&quot;&gt;결론&lt;/h2&gt;

&lt;p&gt;동화책 생성의 빈도는 구독 모델과 맞지 않는다. 건당 결제가 사용자의 행동 패턴에 맞다. 원탭 모드는 “설정 피로”를 없앤다. 이 둘이 합쳐지면 진입장벽이 최소가 된다. 이름 입력 → 결제 → 동화 완성. 세 단계.&lt;/p&gt;
</description>
        <pubDate>Mon, 23 Mar 2026 23:00:00 +0900</pubDate>
        <link>https://jenalab-inc.github.io/architecture/2026/03/23/storida-onetap-payment.html</link>
        <guid isPermaLink="true">https://jenalab-inc.github.io/architecture/2026/03/23/storida-onetap-payment.html</guid>
        
        
        <category>architecture</category>
        
      </item>
    
      <item>
        <title>소셜 공유 파이프라인 설계 — SSR 공유 페이지에서 바이럴 루프까지</title>
        <description>&lt;h2 id=&quot;갤러리는-있는데-사용자가-없다&quot;&gt;갤러리는 있는데 사용자가 없다&lt;/h2&gt;

&lt;p&gt;AI 동화 생성 파이프라인은 완성되어 있다. 공개 갤러리도 구축했다. 빠진 것은 하나. 결과물이 다음 사용자를 데려오는 구조. 소셜 공유 파이프라인을 설계했다.&lt;/p&gt;

&lt;p&gt;구현은 4개 트랙으로 나뉜다. 공유 페이지(SSR), 카카오 SDK 연동, 프리셋 체인, 레퍼럴 쿠폰. 각 트랙의 의존성을 분석해 병렬 작업을 최대화했다.&lt;/p&gt;

&lt;h2 id=&quot;1-ssr-공유-페이지--동적-og의-설계&quot;&gt;1. SSR 공유 페이지 — 동적 OG의 설계&lt;/h2&gt;

&lt;p&gt;공유 링크를 받은 사람이 카카오톡에서 보는 것은 OG 미리보기다. 이 미리보기가 클릭을 결정한다. 정적 메타 태그로는 불가능하다. 작품마다 제목, 표지, 설명이 다르기 때문이다.&lt;/p&gt;

&lt;p&gt;Next.js App Router의 Server Component로 해결한다.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;/book/[id] 접근 시:
1. 서버에서 작품 데이터 fetch (SSR)
2. 비공개 또는 미존재 → 404
3. generateMetadata()로 동적 OG 태그 생성
4. opengraph-image 핸들러로 1200×630 이미지 생성
5. 캐싱: revalidate 3600 (1시간)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;OG 이미지는 표지 이미지 + 제목 + 브랜드 로고를 조합한다. 별도 이미지 서버 없이 Next.js의 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ImageResponse&lt;/code&gt;로 동적 생성한다.&lt;/p&gt;

&lt;p&gt;핵심은 공유 페이지가 단순 뷰어가 아니라 &lt;strong&gt;전환 장치&lt;/strong&gt;라는 점이다. 하단에 “이 스타일로 만들기” CTA를 배치한다. 이 버튼이 바이럴 루프의 시작점이다.&lt;/p&gt;

&lt;h2 id=&quot;2-카카오-sdk--3단계-폴백-전략&quot;&gt;2. 카카오 SDK — 3단계 폴백 전략&lt;/h2&gt;

&lt;p&gt;한국 사용자의 주요 공유 채널은 카카오톡이다. 카카오 SDK의 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sendDefault&lt;/code&gt;로 피드형 공유를 구현한다.&lt;/p&gt;

&lt;p&gt;공유 버튼은 3단계 폴백 구조를 가진다.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;우선순위&lt;/th&gt;
      &lt;th&gt;방식&lt;/th&gt;
      &lt;th&gt;조건&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;1&lt;/td&gt;
      &lt;td&gt;카카오 SDK&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;window.Kakao&lt;/code&gt; 존재&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;2&lt;/td&gt;
      &lt;td&gt;Web Share API&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;navigator.share&lt;/code&gt; 존재 (모바일)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;3&lt;/td&gt;
      &lt;td&gt;클립보드 복사&lt;/td&gt;
      &lt;td&gt;나머지 (데스크톱)&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;카카오 SDK는 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;lazyOnload&lt;/code&gt; 전략으로 로드한다. 페이지 로드를 차단하지 않는다. 초기화는 로드 완료 후 1회.&lt;/p&gt;

&lt;p&gt;공유 버튼 컴포넌트는 범용으로 설계한다. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;title&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;description&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;imageUrl&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;shareUrl&lt;/code&gt;을 props로 받는다. 공유 페이지, 갤러리 프리뷰, 카드 오버레이 어디서든 동일한 컴포넌트를 재사용한다.&lt;/p&gt;

&lt;h2 id=&quot;3-프리셋-체인--공유에서-생성까지&quot;&gt;3. 프리셋 체인 — 공유에서 생성까지&lt;/h2&gt;

&lt;p&gt;공유 페이지의 CTA를 클릭하면 생성 페이지로 이동한다. 이때 원본 작품의 스타일과 프롬프트가 URL 파라미터로 전달된다.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;/create?style_id=X&amp;amp;writing_prompt_id=Y
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;생성 페이지의 상태 관리 훅이 이 파라미터를 수신한다. 우선순위는 3단계다.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;URL 프리셋 &amp;gt; localStorage 이전 설정 &amp;gt; 시스템 기본값
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;URL 프리셋이 있으면 해당 스타일과 프롬프트가 미리 선택된 상태로 페이지가 열린다. 사용자는 이름만 입력하면 된다. 마찰을 최소화한다.&lt;/p&gt;

&lt;p&gt;이 패턴의 장점은 변경 범위가 작다는 것이다. 생성 페이지 자체를 수정하지 않는다. 상태 초기화 로직에 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;initialPreset&lt;/code&gt; 인자를 하나 추가하면 끝이다. 이후 갤러리의 “이 스타일로 만들기” 버튼도 동일한 URL 패턴을 사용한다.&lt;/p&gt;

&lt;h2 id=&quot;4-레퍼럴-쿠폰--공유에-인센티브-부여&quot;&gt;4. 레퍼럴 쿠폰 — 공유에 인센티브 부여&lt;/h2&gt;

&lt;p&gt;공유만으로는 부족하다. 수신자에게 인센티브가 필요하다.&lt;/p&gt;

&lt;p&gt;쿠폰 시스템의 흐름은 두 단계다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;생성&lt;/strong&gt;: 사용자가 공유 버튼을 클릭하면 서버가 고유 코드를 발급한다. 이 코드가 공유 URL에 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ref&lt;/code&gt; 파라미터로 붙는다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;적용&lt;/strong&gt;: 수신자가 공유 링크로 접속하면 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ref&lt;/code&gt; 값이 로컬 스토리지에 저장된다. 가입 완료 후 자동으로 쿠폰이 적용되어 무료 토큰이 지급된다.&lt;/p&gt;

&lt;p&gt;검증 규칙은 4가지다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;만료 여부 확인 (발급 후 30일)&lt;/li&gt;
  &lt;li&gt;미사용 상태 확인&lt;/li&gt;
  &lt;li&gt;자기 자신의 쿠폰 사용 불가&lt;/li&gt;
  &lt;li&gt;(선택) 사용자당 횟수 제한&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;5-의존성-기반-구현-순서&quot;&gt;5. 의존성 기반 구현 순서&lt;/h2&gt;

&lt;p&gt;4개 트랙의 의존성을 분석하면 병렬 작업을 최대화할 수 있다.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Week 1 ─────────────────────────────
├─ Track A (독립): UX 개선 3건
├─ Track B (순차): 공유 페이지 → 프리셋 → 카카오 버튼
│
Week 2 ─────────────────────────────
├─ Track C (독립): 건당 결제 + 원탭 생성
│
Week 3~4 ───────────────────────────
├─ Track D (B 완료 후): 복제 기능 + 쿠폰
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Track A와 B는 완전 병렬이다. Track C도 병렬 가능하지만 Week 1 이후 시작이 안정적이다. Track D만 B의 완료를 기다린다. 복제 기능은 공유 페이지와 프리셋이 선행되어야 하기 때문이다.&lt;/p&gt;

&lt;h2 id=&quot;결론&quot;&gt;결론&lt;/h2&gt;

&lt;p&gt;소셜 공유는 기능 하나가 아니다. SSR 페이지, 동적 OG, 카카오 SDK, 프리셋 체인, 쿠폰 시스템이 하나의 파이프라인으로 연결된다. 각 부분은 독립적으로 동작하지만, 전체가 모여야 바이럴 루프가 완성된다. 결과물이 다음 사용자를 데려오는 구조. 그것이 이 설계의 목적이다.&lt;/p&gt;
</description>
        <pubDate>Mon, 23 Mar 2026 19:00:00 +0900</pubDate>
        <link>https://jenalab-inc.github.io/architecture/2026/03/23/storida-share-pipeline.html</link>
        <guid isPermaLink="true">https://jenalab-inc.github.io/architecture/2026/03/23/storida-share-pipeline.html</guid>
        
        
        <category>architecture</category>
        
      </item>
    
      <item>
        <title>설정 9개를 3개로 — AI 생성 페이지 UX 간편화 설계</title>
        <description>&lt;h2 id=&quot;설정이-9개면-아무도-생성하지-않는다&quot;&gt;설정이 9개면 아무도 생성하지 않는다&lt;/h2&gt;

&lt;p&gt;AI 동화 생성 페이지에 설정 항목이 9개 있었다. 작문 스타일, 그림풍, 캐릭터 3명, 콘텐츠 타입, 이미지 비율, 이미지 모드, 장면 수, 장면당 글자 수, 상황 입력. 모든 항목이 같은 가중치로 화면에 나열되어 있었다.&lt;/p&gt;

&lt;p&gt;문제는 수량이 아니다. 계층이 없었다.&lt;/p&gt;

&lt;h2 id=&quot;핵심-발견-보이냐-숨기냐가-아니라-언제-결정하게-하느냐&quot;&gt;핵심 발견: “보이냐 숨기냐”가 아니라 “언제 결정하게 하느냐”&lt;/h2&gt;

&lt;p&gt;세 가지 관점으로 토론했다. 질문자, 방어자, 재구성자. 5라운드에 걸쳐 설정 노출 전략을 설계했다.&lt;/p&gt;

&lt;h3 id=&quot;질문자의-핵심-지적&quot;&gt;질문자의 핵심 지적&lt;/h3&gt;

&lt;p&gt;사용자가 “작품을 만들겠다”고 결심한 순간, 머릿속에 있는 것은 &lt;strong&gt;이야기 상황&lt;/strong&gt; 하나다. “숲에서 길을 잃은 강아지 이야기.” 이야기를 구상하기도 전에 이미지 비율, 장면당 글자 수, 이미지 모드를 묻고 있었다. 출판 단계의 결정을 창작 단계에서 요구한 것이다.&lt;/p&gt;

&lt;h3 id=&quot;방어자의-반론&quot;&gt;방어자의 반론&lt;/h3&gt;

&lt;p&gt;이 서비스는 &lt;strong&gt;토큰 기반 과금&lt;/strong&gt;이다. 설정을 숨기면 사용자가 원치 않는 결과에 토큰을 낭비한다. 재생성은 추가 비용이다. “나중에 바꾸세요”는 무료 서비스의 사고방식이다.&lt;/p&gt;

&lt;h3 id=&quot;재구성자의-전환&quot;&gt;재구성자의 전환&lt;/h3&gt;

&lt;p&gt;“설정이 많다”가 문제가 아니다. “모든 설정이 같은 시점에 동시에 보인다”가 문제다. 성공적인 생성형 AI 서비스는 설정이 적은 게 아니라 &lt;strong&gt;점진적 공개(Progressive Disclosure)&lt;/strong&gt;를 잘 한다.&lt;/p&gt;

&lt;h2 id=&quot;설정의-계층-분류&quot;&gt;설정의 계층 분류&lt;/h2&gt;

&lt;p&gt;토론을 통해 9개 설정을 4개 계층으로 분류했다.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;계층&lt;/th&gt;
      &lt;th&gt;항목&lt;/th&gt;
      &lt;th&gt;사용자 행동&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;매번 변경&lt;/td&gt;
      &lt;td&gt;상황 입력&lt;/td&gt;
      &lt;td&gt;핵심 입력 — 가장 큰 공간&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;자주 변경&lt;/td&gt;
      &lt;td&gt;작문 스타일, 그림풍&lt;/td&gt;
      &lt;td&gt;드롭다운 — 눈에 보이되 공간 적게&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;가끔 변경&lt;/td&gt;
      &lt;td&gt;장면 수, 캐릭터&lt;/td&gt;
      &lt;td&gt;보조 입력 — 상황 입력 근처에 작게&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;거의 안 변경&lt;/td&gt;
      &lt;td&gt;이미지 비율, 이미지 모드, 글자 수&lt;/td&gt;
      &lt;td&gt;요약 바 — 기본값으로 충분&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;콘텐츠 타입(동화/일반)은 독립 토글에서 제거했다. 작문 스타일을 선택하면 자동으로 결정된다. 9개에서 8개로, 실질적으로 사용자가 보는 것은 3개 + 요약 바 1줄이다.&lt;/p&gt;

&lt;h2 id=&quot;토큰-비용-투명성이-전제-조건&quot;&gt;토큰 비용 투명성이 전제 조건&lt;/h2&gt;

&lt;p&gt;설정을 숨기되, 비용을 숨기면 안 된다. 생성 버튼 옆에 예상 토큰 소모량을 실시간 표시한다. 장면 수를 바꾸면 예측치가 즉시 변한다. 요약 바를 펼쳐 글자 수를 조정하면 토큰도 변한다. 비용이 투명하면 기본값을 신뢰할 수 있다.&lt;/p&gt;

&lt;h2 id=&quot;리디자인-결과&quot;&gt;리디자인 결과&lt;/h2&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;┌──────────────────────────────────────────┐
│  [작문 스타일 ▾]      [그림풍 ▾]         │  핵심 스타일
├──────────────────────────────────────────┤
│  &quot;이야기를 들려주세요...&quot;                │  상황 입력
│                                          │
│  장면: [3 ▾]   + 캐릭터 추가             │  보조 설정
├──────────────────────────────────────────┤
│  16:9 펼침 · 100자/장면      [설정 ▾]   │  요약 바 (접힘)
├──────────────────────────────────────────┤
│  [ 생성하기 ]           예상 ~750 토큰   │  제출 + 토큰 예측
└──────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;변경-사항-요약&quot;&gt;변경 사항 요약&lt;/h3&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;변경 전&lt;/th&gt;
      &lt;th&gt;변경 후&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;콘텐츠 타입 토글&lt;/td&gt;
      &lt;td&gt;작문 스타일 선택 시 자동 결정&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;캐릭터 슬롯 3개&lt;/td&gt;
      &lt;td&gt;“+캐릭터 추가” 인라인 링크&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;이미지 비율·모드·글자 수 나열&lt;/td&gt;
      &lt;td&gt;요약 바로 그룹핑 (접힘 기본)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;장면 수 별도 영역&lt;/td&gt;
      &lt;td&gt;상황 입력 바로 아래&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;토큰 소모량 미표시&lt;/td&gt;
      &lt;td&gt;생성 버튼 옆 실시간 표시&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;CreationWizard + MainScreen 이중 패턴&lt;/td&gt;
      &lt;td&gt;MainScreen으로 통합&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;스마트-기본값&quot;&gt;스마트 기본값&lt;/h3&gt;

&lt;p&gt;기본값은 3단계로 결정된다.&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;시스템 기본값&lt;/strong&gt; — 첫 사용자에게 적용&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;이전 설정 복원&lt;/strong&gt; — 반복 사용자의 마지막 설정을 기억&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;요금제 제약 반영&lt;/strong&gt; — 플랜별 최대값을 초과하지 않도록 조정&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;고정 항목(예: Start 플랜 3장면)은 잠금 아이콘으로 표시한다. “추천”이라는 모호한 라벨 대신, 값 자체를 보여주고 변경 불가 사유를 툴팁으로 안내한다.&lt;/p&gt;

&lt;h2 id=&quot;결론&quot;&gt;결론&lt;/h2&gt;

&lt;p&gt;설정을 줄이는 것이 아니라 계층을 만드는 것이다. 매번 바뀌는 것은 크게, 거의 안 바뀌는 것은 요약으로. 비용은 숨기지 않는다. 이 원칙이 9개 설정을 3개처럼 느끼게 만든다.&lt;/p&gt;

&lt;hr /&gt;

&lt;details&gt;
  &lt;summary&gt;토론 전문 보기 (Socratic Dialogue 5 Rounds)&lt;/summary&gt;

  &lt;p&gt;&lt;strong&gt;Agents&lt;/strong&gt;: Questioner, Defender, Reframer — 5 Rounds&lt;/p&gt;

  &lt;h3 id=&quot;before&quot;&gt;현재 상태 (Before)&lt;/h3&gt;

  &lt;p&gt;MainScreen의 설정 항목 9개가 동일한 가중치로 나열:&lt;/p&gt;
  &lt;ol&gt;
    &lt;li&gt;Writing Style (작문 스타일 드롭다운)&lt;/li&gt;
    &lt;li&gt;Art Style (그림풍 드롭다운)&lt;/li&gt;
    &lt;li&gt;Character Selection (캐릭터 최대 3명 + 각각 커스텀 이름)&lt;/li&gt;
    &lt;li&gt;Content Type (동화/일반 토글)&lt;/li&gt;
    &lt;li&gt;Image Size (16:9, 3:2, 9:16, 1:1)&lt;/li&gt;
    &lt;li&gt;Image Mode (펼침/삽화 토글)&lt;/li&gt;
    &lt;li&gt;Scene Count (장면 수)&lt;/li&gt;
    &lt;li&gt;Chars Per Scene (장면당 글자 수)&lt;/li&gt;
    &lt;li&gt;Story Prompt (상황 텍스트)&lt;/li&gt;
  &lt;/ol&gt;

  &lt;hr /&gt;

  &lt;h3 id=&quot;round-1&quot;&gt;Round 1&lt;/h3&gt;

  &lt;p&gt;&lt;strong&gt;Questioner (Round 1)&lt;/strong&gt;&lt;/p&gt;

  &lt;ol&gt;
    &lt;li&gt;
      &lt;p&gt;&lt;strong&gt;사용자가 “작품을 만들겠다”고 결심한 순간, 머릿속에 있는 것은 무엇인가?&lt;/strong&gt; 대부분의 사용자는 “이런 이야기를 만들고 싶다”는 상황(Story Prompt)만 가지고 있을 것이다. 그런데 이야기를 쓰기도 전에 이미지 비율, 장면당 글자 수, 이미지 모드 같은 &lt;strong&gt;출판 단계의 결정&lt;/strong&gt;을 요구하고 있다. 이것은 사용자의 멘탈 모델과 일치하는가?&lt;/p&gt;
    &lt;/li&gt;
    &lt;li&gt;
      &lt;p&gt;&lt;strong&gt;Scene Count와 Chars Per Scene은 사용자가 직관적으로 판단할 수 있는 값인가?&lt;/strong&gt; “장면당 150자”가 어떤 결과를 만드는지 사용자가 예측할 수 있는가? 이것은 전문가 설정이지 일반 사용자 설정이 아니다.&lt;/p&gt;
    &lt;/li&gt;
    &lt;li&gt;
      &lt;p&gt;&lt;strong&gt;Content Type(동화/일반)이 왜 사용자 선택인가?&lt;/strong&gt; Writing Prompt 자체가 이미 스타일을 정의하고 있다면, content type은 writing prompt에 귀속되어야 하는 메타데이터가 아닌가?&lt;/p&gt;
    &lt;/li&gt;
  &lt;/ol&gt;

  &lt;p&gt;&lt;strong&gt;Defender (Round 1)&lt;/strong&gt;&lt;/p&gt;

  &lt;ol&gt;
    &lt;li&gt;
      &lt;p&gt;&lt;strong&gt;출판 설정이 앞에 있는 이유&lt;/strong&gt;: 동의한다 — 이미지 비율, 이미지 모드는 “결과물의 형태”에 대한 결정이고, 이야기 구상 단계에서 필수가 아니다. 하지만 &lt;strong&gt;Scene Count는 다르다&lt;/strong&gt;. 장면 수는 이야기 구조(3장면 짧은 이야기 vs 10장면 긴 이야기)에 직접 영향을 주므로 AI에게 지시하기 전에 결정되어야 한다.&lt;/p&gt;
    &lt;/li&gt;
    &lt;li&gt;
      &lt;p&gt;&lt;strong&gt;Chars Per Scene의 필요성&lt;/strong&gt;: 전문가 설정이라는 점은 인정한다. 하지만 이 서비스는 &lt;strong&gt;토큰 기반 과금&lt;/strong&gt;이다. 글자 수는 토큰 소모에 직접 영향을 주므로, 비용에 민감한 사용자에게는 제어권이 필요하다. 다만, &lt;strong&gt;기본값을 제공하고 고급 설정으로 숨기는 것&lt;/strong&gt;은 합리적이다.&lt;/p&gt;
    &lt;/li&gt;
    &lt;li&gt;
      &lt;p&gt;&lt;strong&gt;Content Type 분리 이유&lt;/strong&gt;: Writing Prompt를 content type으로 필터링하고 있기 때문에 기술적으로는 content type이 먼저 선택되어야 한다. 하지만 UX적으로는 &lt;strong&gt;Writing Prompt 선택 시 자동으로 content type이 결정되게&lt;/strong&gt; 할 수 있다는 점은 동의한다.&lt;/p&gt;
    &lt;/li&gt;
  &lt;/ol&gt;

  &lt;p&gt;&lt;strong&gt;Reframer (Round 1)&lt;/strong&gt;&lt;/p&gt;

  &lt;p&gt;두 에이전트 모두 “어떤 설정을 줄일까”에 집중하고 있지만, &lt;strong&gt;질문 자체를 재구성&lt;/strong&gt;해야 한다.&lt;/p&gt;

  &lt;p&gt;&lt;strong&gt;핵심 재구성: “설정이 많다”가 문제가 아니라, “모든 설정이 같은 시점에 동시에 보인다”가 문제다.&lt;/strong&gt;&lt;/p&gt;

  &lt;p&gt;ChatGPT, Midjourney, Canva 등 성공적인 생성형 AI 서비스는 설정이 적은 게 아니라 &lt;strong&gt;점진적 공개(Progressive Disclosure)&lt;/strong&gt; 를 잘 한다.&lt;/p&gt;

  &lt;p&gt;Storida의 경우:&lt;/p&gt;
  &lt;ul&gt;
    &lt;li&gt;&lt;strong&gt;생성 전 필수&lt;/strong&gt;: Story Prompt + Writing Style (이 두 가지가 AI의 핵심 입력)&lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;생성 전 선택&lt;/strong&gt;: Character, Art Style (시각적 선호)&lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;생성 후 조정 가능&lt;/strong&gt;: Image Size, Image Mode, Scene Count, Chars Per Scene&lt;/li&gt;
  &lt;/ul&gt;

  &lt;p&gt;“생성 후 조정 가능”이 기술적으로 가능한지가 핵심이다 — Scene Count를 바꾸면 재생성이 필요하므로 진짜 “조정”이 되는지 따져봐야 한다.&lt;/p&gt;

  &lt;hr /&gt;

  &lt;h3 id=&quot;round-2&quot;&gt;Round 2&lt;/h3&gt;

  &lt;p&gt;&lt;strong&gt;Questioner (Round 2)&lt;/strong&gt;&lt;/p&gt;

  &lt;ol&gt;
    &lt;li&gt;
      &lt;p&gt;&lt;strong&gt;“생성 후 조정”이라는 개념이 Storida에서 성립하는가?&lt;/strong&gt; 이 서비스는 &lt;strong&gt;토큰을 소모하는 유료 생성&lt;/strong&gt;이다. Scene Count를 바꾸면 재생성 = 추가 토큰 소모다. “나중에 바꾸면 된다”는 무료 서비스의 사고방식이고, 유료 서비스에서는 &lt;strong&gt;한 번에 원하는 결과를 만드는 것&lt;/strong&gt;이 사용자 기대다. 그렇다면 설정을 숨기는 것이 오히려 “돈을 낭비하게 만드는 UX”가 아닌가?&lt;/p&gt;
    &lt;/li&gt;
    &lt;li&gt;
      &lt;p&gt;&lt;strong&gt;“기본값 + 고급 설정”에 동의하지만, 기본값의 품질이 관건이다.&lt;/strong&gt; 기본값이 충분히 좋다면 사용자는 건드리지 않는다. 그런데 &lt;strong&gt;현재 기본값이 사용자의 요금제에 따라 달라진다&lt;/strong&gt;(plans 테이블의 제한). 이것은 기본값의 의미를 흐린다 — “추천값”인가, “최대 허용값”인가?&lt;/p&gt;
    &lt;/li&gt;
    &lt;li&gt;
      &lt;p&gt;&lt;strong&gt;Character 선택이 0~3명 사이인데, “없음” 선택 시 AI가 자동 생성한다.&lt;/strong&gt; 그렇다면 Character 선택은 왜 메인 화면에 있는가? 대부분의 신규 사용자는 캐릭터 없이 시작할 텐데, 빈 슬롯 3개가 화면에 노출되는 것이 첫인상을 복잡하게 만들지 않는가?&lt;/p&gt;
    &lt;/li&gt;
  &lt;/ol&gt;

  &lt;p&gt;&lt;strong&gt;Defender (Round 2)&lt;/strong&gt;&lt;/p&gt;

  &lt;ol&gt;
    &lt;li&gt;
      &lt;p&gt;&lt;strong&gt;토큰 소모와 설정 숨기기의 긴장&lt;/strong&gt;: 핵심적인 지적이다. 재생성 비용이 존재하므로 “나중에 바꾸세요”는 위험하다. 그러나 &lt;strong&gt;반대 관점&lt;/strong&gt;도 있다 — 설정이 9개 보이면 사용자가 &lt;strong&gt;결정 피로(Decision Fatigue)&lt;/strong&gt; 로 이탈한다. 생성을 한 번도 못 해보고 떠나는 사용자가, 기본값으로 한 번 생성해보고 다음부터 조정하는 사용자보다 더 큰 손실이다.&lt;/p&gt;

      &lt;p&gt;&lt;strong&gt;제안&lt;/strong&gt;: 핵심 설정(Prompt, Writing Style, Art Style)만 보여주되, &lt;strong&gt;예상 토큰 소모량을 실시간 표시&lt;/strong&gt;한다. 고급 설정을 열면 토큰 예측치가 변하는 것을 보여주면 “숨겨진 비용” 문제를 해결할 수 있다.&lt;/p&gt;
    &lt;/li&gt;
    &lt;li&gt;
      &lt;p&gt;&lt;strong&gt;기본값의 정의&lt;/strong&gt;: 기본값은 “요금제 내 최적값”이어야 한다. Start 플랜은 3장면 고정이므로 기본값 = 최대값이다. Basic 이상은 “가장 인기 있는 선택”을 기본값으로 설정하는 것이 맞다.&lt;/p&gt;
    &lt;/li&gt;
    &lt;li&gt;
      &lt;p&gt;&lt;strong&gt;Character 슬롯&lt;/strong&gt;: 동의한다. 빈 슬롯 3개는 불필요한 복잡성이다. &lt;strong&gt;“캐릭터 추가” 버튼 하나&lt;/strong&gt;로 시작하고, 클릭 시 선택 패널이 열리는 방식이 낫다.&lt;/p&gt;
    &lt;/li&gt;
  &lt;/ol&gt;

  &lt;p&gt;&lt;strong&gt;Reframer (Round 2)&lt;/strong&gt;&lt;/p&gt;

  &lt;p&gt;Questioner가 “토큰 비용 때문에 모든 설정을 미리 보여줘야 한다”고 주장하고, Defender가 “결정 피로 때문에 숨겨야 한다”고 반론했다. 둘 다 유효하지만 &lt;strong&gt;이분법에 갇혀 있다&lt;/strong&gt;.&lt;/p&gt;

  &lt;p&gt;&lt;strong&gt;재구성: “보이냐 숨기냐”가 아니라, “언제 결정하게 하느냐”의 문제다.&lt;/strong&gt;&lt;/p&gt;

  &lt;p&gt;대안 프레임: &lt;strong&gt;2단계 흐름&lt;/strong&gt;&lt;/p&gt;
  &lt;ul&gt;
    &lt;li&gt;&lt;strong&gt;Step 1 “뭘 만들까”&lt;/strong&gt;: Prompt + Writing Style + Character (선택) → 이야기의 내용&lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;Step 2 “어떻게 만들까”&lt;/strong&gt;: Art Style + Image Size + Image Mode + Scene Count + Chars Per Scene → 이야기의 형태&lt;/li&gt;
  &lt;/ul&gt;

  &lt;p&gt;하지만 이 2단계가 현재 CreationWizard와 다른 점은 무엇인가? 단계를 나누는 것 자체가 아니라 &lt;strong&gt;각 단계에서 무엇을 묻느냐의 분류 기준&lt;/strong&gt;이 핵심이다.&lt;/p&gt;

  &lt;hr /&gt;

  &lt;h4 id=&quot;decision-point-1&quot;&gt;Decision Point 1&lt;/h4&gt;

  &lt;p&gt;&lt;strong&gt;설정 노출 전략&lt;/strong&gt;&lt;/p&gt;

  &lt;table&gt;
    &lt;thead&gt;
      &lt;tr&gt;
        &lt;th&gt;Agent&lt;/th&gt;
        &lt;th&gt;Position&lt;/th&gt;
      &lt;/tr&gt;
    &lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;
        &lt;td&gt;Questioner&lt;/td&gt;
        &lt;td&gt;유료 서비스에서 설정을 숨기면 사용자가 원치 않는 결과에 토큰을 낭비한다&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
        &lt;td&gt;Defender&lt;/td&gt;
        &lt;td&gt;결정 피로로 인한 이탈이 더 큰 손실이다, 기본값 + 토큰 예측 표시로 해결 가능&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
        &lt;td&gt;Reframer&lt;/td&gt;
        &lt;td&gt;보이냐/숨기냐가 아니라 “내용 결정 → 형태 결정”으로 단계를 재분류해야 한다&lt;/td&gt;
      &lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;

  &lt;p&gt;&lt;strong&gt;Options:&lt;/strong&gt;&lt;/p&gt;
  &lt;ul&gt;
    &lt;li&gt;A) 단일 화면 유지 + 고급 설정 접기 — 핵심 3개만 노출, 나머지 토글 뒤로&lt;/li&gt;
    &lt;li&gt;B) 2단계 흐름 (내용 → 형태) — 논리적이지만 한 번 더 클릭 필요&lt;/li&gt;
    &lt;li&gt;C) 스마트 기본값 + 인라인 확장 — 모든 항목에 강한 기본값, 각 항목 옆에 개별 편집 버튼&lt;/li&gt;
  &lt;/ul&gt;

  &lt;p&gt;&lt;strong&gt;→ 선택: C&lt;/strong&gt;&lt;/p&gt;

  &lt;hr /&gt;

  &lt;h3 id=&quot;round-3&quot;&gt;Round 3&lt;/h3&gt;

  &lt;p&gt;&lt;strong&gt;Questioner (Round 3)&lt;/strong&gt;&lt;/p&gt;

  &lt;ol&gt;
    &lt;li&gt;
      &lt;p&gt;&lt;strong&gt;“스마트 기본값”의 기준은 무엇인가?&lt;/strong&gt; 신규 사용자의 첫 생성과, 10번째 생성은 다르다. 첫 생성에는 “가장 보편적인 값”이 맞지만, 반복 사용자에게는 &lt;strong&gt;“이전에 사용한 값”이 최적의 기본값&lt;/strong&gt;이다.&lt;/p&gt;
    &lt;/li&gt;
    &lt;li&gt;
      &lt;p&gt;&lt;strong&gt;“인라인 확장” 버튼이 9개 항목 각각에 붙으면, 그것 자체가 시각적 노이즈가 아닌가?&lt;/strong&gt; 결국 같은 복잡성을 다른 형태로 포장한 것 아닌가?&lt;/p&gt;
    &lt;/li&gt;
    &lt;li&gt;
      &lt;p&gt;&lt;strong&gt;9개 중 사용자가 실제로 변경하는 비율은?&lt;/strong&gt; 데이터 없이 UX를 설계하고 있다.&lt;/p&gt;
    &lt;/li&gt;
  &lt;/ol&gt;

  &lt;p&gt;&lt;strong&gt;Defender (Round 3)&lt;/strong&gt;&lt;/p&gt;

  &lt;ol&gt;
    &lt;li&gt;&lt;strong&gt;스마트 기본값의 3단계 전략:&lt;/strong&gt;
      &lt;ul&gt;
        &lt;li&gt;Tier 1 — 시스템 기본값 (최초 사용자)&lt;/li&gt;
        &lt;li&gt;Tier 2 — 사용자 히스토리 기반 (localStorage 마지막 설정 복원)&lt;/li&gt;
        &lt;li&gt;Tier 3 — 요금제 최적값 (플랜별 제약 반영)&lt;/li&gt;
      &lt;/ul&gt;
    &lt;/li&gt;
    &lt;li&gt;&lt;strong&gt;시각적 노이즈 해결 — 그룹핑:&lt;/strong&gt;
      &lt;ul&gt;
        &lt;li&gt;항상 보이는 영역: Prompt + Writing Style + Art Style + Character (핵심 4개)&lt;/li&gt;
        &lt;li&gt;요약 바(Summary Bar): 나머지 5개를 한 줄로 — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&quot;3장면 · 100자 · 16:9 펼침 · 동화&quot;&lt;/code&gt; — 클릭 시 펼쳐짐&lt;/li&gt;
        &lt;li&gt;편집 진입점이 1개(요약 바 클릭)이므로 시각적 노이즈 최소화&lt;/li&gt;
      &lt;/ul&gt;
    &lt;/li&gt;
    &lt;li&gt;사용 데이터 수집을 위한 analytics 이벤트 필요성 인정&lt;/li&gt;
  &lt;/ol&gt;

  &lt;p&gt;&lt;strong&gt;Reframer (Round 3)&lt;/strong&gt;&lt;/p&gt;

  &lt;p&gt;&lt;strong&gt;설정의 본질적 분류:&lt;/strong&gt;&lt;/p&gt;

  &lt;table&gt;
    &lt;thead&gt;
      &lt;tr&gt;
        &lt;th&gt;범주&lt;/th&gt;
        &lt;th&gt;항목&lt;/th&gt;
        &lt;th&gt;성격&lt;/th&gt;
      &lt;/tr&gt;
    &lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;
        &lt;td&gt;AI 지시&lt;/td&gt;
        &lt;td&gt;Prompt, Writing Style, Character&lt;/td&gt;
        &lt;td&gt;창작 의도 — 매번 달라짐&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
        &lt;td&gt;출력 포맷&lt;/td&gt;
        &lt;td&gt;Art Style, Image Size, Image Mode, Scene Count, Chars Per Scene&lt;/td&gt;
        &lt;td&gt;제작 사양 — 한 번 정하면 유지&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
        &lt;td&gt;메타 분류&lt;/td&gt;
        &lt;td&gt;Content Type&lt;/td&gt;
        &lt;td&gt;Writing Style에 귀속 가능&lt;/td&gt;
      &lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;

  &lt;p&gt;Content Type을 Writing Style에 흡수하면 &lt;strong&gt;9개 → 8개&lt;/strong&gt;로 줄고, “출력 포맷” 5개가 요약 바로 들어가면 사용자가 보는 것은 &lt;strong&gt;3개 + 요약 바 1줄&lt;/strong&gt;.&lt;/p&gt;

  &lt;p&gt;단, &lt;strong&gt;Art Style은 “출력 포맷”이 아니라 창작 의도에 가깝다&lt;/strong&gt; — 핵심 영역에 남겨야 한다.&lt;/p&gt;

  &lt;hr /&gt;

  &lt;h3 id=&quot;round-4&quot;&gt;Round 4&lt;/h3&gt;

  &lt;p&gt;&lt;strong&gt;Questioner (Round 4)&lt;/strong&gt;&lt;/p&gt;

  &lt;ol&gt;
    &lt;li&gt;
      &lt;p&gt;&lt;strong&gt;Character 선택이 “핵심 영역”에 있어야 하는가?&lt;/strong&gt; “없음”이 유효한 기본값이라면 핵심이 아니라 선택적 확장이다. 신규 사용자에게 빈 슬롯 3개가 첫인상을 복잡하게 만든다.&lt;/p&gt;
    &lt;/li&gt;
    &lt;li&gt;
      &lt;p&gt;&lt;strong&gt;요약 바의 한 줄 표시가 처음 쓰는 사용자에게 읽히는가?&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&quot;3장면 · 100자 · 16:9 펼침&quot;&lt;/code&gt; — 각 항목이 뭔지 이미 알아야 의미가 전달된다.&lt;/p&gt;
    &lt;/li&gt;
    &lt;li&gt;
      &lt;p&gt;&lt;strong&gt;Scene Count의 위치&lt;/strong&gt;: Prompt와 밀접하다. “긴 모험 이야기”에 3장면은 부족하고, “짧은 잠자리 동화”에 10장면은 과하다. Prompt 바로 아래에 놓으면 요약 바에는 순수 포맷 설정만 남는다.&lt;/p&gt;
    &lt;/li&gt;
  &lt;/ol&gt;

  &lt;p&gt;&lt;strong&gt;Defender (Round 4)&lt;/strong&gt;&lt;/p&gt;

  &lt;ol&gt;
    &lt;li&gt;
      &lt;p&gt;&lt;strong&gt;Character 절충안&lt;/strong&gt;: 핵심 영역에 기본 숨김 상태로 “+캐릭터 추가” 링크만 배치. 한 번이라도 사용한 적 있으면 이전 캐릭터 자동 제안.&lt;/p&gt;
    &lt;/li&gt;
    &lt;li&gt;
      &lt;p&gt;&lt;strong&gt;첫 사용자 vs 반복 사용자&lt;/strong&gt;: 두 UI 대신 &lt;strong&gt;같은 UI에서 상태에 따라 기본값과 노출 수준을 조정&lt;/strong&gt; — 첫 사용: “추천 설정” 라벨, 반복 사용: 이전 값 복원.&lt;/p&gt;
    &lt;/li&gt;
    &lt;li&gt;
      &lt;p&gt;&lt;strong&gt;Scene Count 이동 동의&lt;/strong&gt;: Prompt + Scene Count를 한 묶음으로.&lt;/p&gt;
    &lt;/li&gt;
  &lt;/ol&gt;

  &lt;p&gt;&lt;strong&gt;Reframer (Round 4)&lt;/strong&gt;&lt;/p&gt;

  &lt;p&gt;&lt;strong&gt;재구성된 레이아웃 제안:&lt;/strong&gt;&lt;/p&gt;

  &lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;┌─────────────────────────────────────────┐
│  [Writing Style ▾]    [Art Style ▾]     │  ← 핵심 스타일 (2개 드롭다운)
├─────────────────────────────────────────┤
│  &quot;이야기를 들려주세요...&quot;               │  ← Story Prompt (textarea)
│                                         │
│  장면 수: [3 ▾]  (+캐릭터 추가)         │  ← Prompt 보조 설정
├─────────────────────────────────────────┤
│  📐 16:9 펼침 · 100자/장면   [설정 ▾]  │  ← 요약 바 (클릭 시 확장)
├─────────────────────────────────────────┤
│           [ ✨ 생성하기 ]               │  ← 제출 + 예상 토큰
└─────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;  &lt;/div&gt;

  &lt;hr /&gt;

  &lt;h4 id=&quot;decision-point-2&quot;&gt;Decision Point 2&lt;/h4&gt;

  &lt;p&gt;&lt;strong&gt;Character 선택의 위치와 기본 상태&lt;/strong&gt;&lt;/p&gt;

  &lt;table&gt;
    &lt;thead&gt;
      &lt;tr&gt;
        &lt;th&gt;Agent&lt;/th&gt;
        &lt;th&gt;Position&lt;/th&gt;
      &lt;/tr&gt;
    &lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;
        &lt;td&gt;Questioner&lt;/td&gt;
        &lt;td&gt;반복 사용자가 발견하는 기능이므로 핵심 영역에서 빼야 한다&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
        &lt;td&gt;Defender&lt;/td&gt;
        &lt;td&gt;차별화 기능이므로 “+캐릭터 추가” 링크로 존재감을 유지해야 한다&lt;/td&gt;
      &lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;

  &lt;p&gt;&lt;strong&gt;Options:&lt;/strong&gt;&lt;/p&gt;
  &lt;ul&gt;
    &lt;li&gt;A) 완전 숨김 — 요약 바 안에 포함&lt;/li&gt;
    &lt;li&gt;B) 인라인 링크 — Prompt 근처에 “+캐릭터 추가” 텍스트 링크&lt;/li&gt;
    &lt;li&gt;C) 조건부 노출 — 캐릭터 1개 이상 등록 사용자에게만 표시&lt;/li&gt;
  &lt;/ul&gt;

  &lt;p&gt;&lt;strong&gt;→ 선택: B&lt;/strong&gt;&lt;/p&gt;

  &lt;hr /&gt;

  &lt;h3 id=&quot;round-5&quot;&gt;Round 5&lt;/h3&gt;

  &lt;p&gt;&lt;strong&gt;Questioner (Round 5)&lt;/strong&gt;&lt;/p&gt;

  &lt;ol&gt;
    &lt;li&gt;
      &lt;p&gt;&lt;strong&gt;Writing Style에 Content Type을 흡수하면, Writing Style 목록을 어떻게 보여줄 것인가?&lt;/strong&gt; 전체를 보여주되 동화/일반을 탭으로 구분하는가, 섞어서 보여주는가?&lt;/p&gt;
    &lt;/li&gt;
    &lt;li&gt;
      &lt;p&gt;&lt;strong&gt;요약 바의 기본값 라벨 — “추천”이라는 단어의 문제&lt;/strong&gt;: Start 플랜에서 3장면 고정인데 “추천”이라고 쓰면 선택의 여지가 없는 것을 마치 추천인 양 포장하는 셈이다.&lt;/p&gt;
    &lt;/li&gt;
    &lt;li&gt;
      &lt;p&gt;&lt;strong&gt;리디자인 후 MainScreen과 CreationWizard 두 패턴을 모두 유지할 것인가?&lt;/strong&gt;&lt;/p&gt;
    &lt;/li&gt;
  &lt;/ol&gt;

  &lt;p&gt;&lt;strong&gt;Defender (Round 5)&lt;/strong&gt;&lt;/p&gt;

  &lt;ol&gt;
    &lt;li&gt;
      &lt;p&gt;&lt;strong&gt;Content Type 통합&lt;/strong&gt;: API 레벨은 유지, UI에서만 흡수. Writing Style 드롭다운 내 탭으로 동화/일반 구분, 선택 시 자동 세팅.&lt;/p&gt;
    &lt;/li&gt;
    &lt;li&gt;
      &lt;p&gt;&lt;strong&gt;“추천” 라벨 제거&lt;/strong&gt;: 값 자체로 보여주기. 고정 항목은 자물쇠 아이콘 + 툴팁(“Start 플랜에서는 3장면 고정”).&lt;/p&gt;
    &lt;/li&gt;
    &lt;li&gt;
      &lt;p&gt;&lt;strong&gt;CreationWizard 제거, MainScreen 통합&lt;/strong&gt;: 이중 패턴 유지보수 비용 제거.&lt;/p&gt;
    &lt;/li&gt;
  &lt;/ol&gt;

  &lt;p&gt;&lt;strong&gt;Reframer (Round 5)&lt;/strong&gt;&lt;/p&gt;

  &lt;p&gt;&lt;strong&gt;핵심 발견: “설정이 많다”가 아니라 “설정의 계층이 없었다”는 것이다.&lt;/strong&gt;&lt;/p&gt;

  &lt;table&gt;
    &lt;thead&gt;
      &lt;tr&gt;
        &lt;th&gt;계층&lt;/th&gt;
        &lt;th&gt;항목&lt;/th&gt;
        &lt;th&gt;사용자 행동&lt;/th&gt;
      &lt;/tr&gt;
    &lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;
        &lt;td&gt;매번 변경&lt;/td&gt;
        &lt;td&gt;Story Prompt&lt;/td&gt;
        &lt;td&gt;핵심 입력 — 가장 큰 공간&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
        &lt;td&gt;자주 변경&lt;/td&gt;
        &lt;td&gt;Writing Style, Art Style&lt;/td&gt;
        &lt;td&gt;드롭다운 — 눈에 잘 보이되 공간 적게&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
        &lt;td&gt;가끔 변경&lt;/td&gt;
        &lt;td&gt;Scene Count, Character&lt;/td&gt;
        &lt;td&gt;보조 입력 — Prompt 근처에 작게&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
        &lt;td&gt;거의 안 변경&lt;/td&gt;
        &lt;td&gt;Image Size, Image Mode, Chars Per Scene&lt;/td&gt;
        &lt;td&gt;요약 바 — 기본값으로 충분&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
        &lt;td&gt;제거 가능&lt;/td&gt;
        &lt;td&gt;Content Type (독립 토글)&lt;/td&gt;
        &lt;td&gt;Writing Style에 흡수&lt;/td&gt;
      &lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;

  &lt;p&gt;&lt;strong&gt;예상 토큰 표시는 필수 전제 조건&lt;/strong&gt;이다.&lt;/p&gt;

  &lt;hr /&gt;

  &lt;h3 id=&quot;synthesis&quot;&gt;Synthesis&lt;/h3&gt;

  &lt;p&gt;&lt;strong&gt;Established Facts:&lt;/strong&gt;&lt;/p&gt;
  &lt;ul&gt;
    &lt;li&gt;9개 설정의 문제는 수량이 아니라 계층 부재&lt;/li&gt;
    &lt;li&gt;Content Type은 Writing Style에 흡수 가능 (9개 → 8개)&lt;/li&gt;
    &lt;li&gt;Scene Count는 Prompt 근처에 배치&lt;/li&gt;
    &lt;li&gt;Character는 “+캐릭터 추가” 인라인 링크로 선택적 노출&lt;/li&gt;
    &lt;li&gt;토큰 비용 투명성이 설정 간편화의 전제 조건&lt;/li&gt;
    &lt;li&gt;CreationWizard와 MainScreen 이중 패턴은 통합 대상&lt;/li&gt;
  &lt;/ul&gt;

  &lt;p&gt;&lt;strong&gt;Open Questions:&lt;/strong&gt;&lt;/p&gt;
  &lt;ul&gt;
    &lt;li&gt;Writing Style 드롭다운 내 동화/일반 탭 구분 vs 혼합&lt;/li&gt;
    &lt;li&gt;사용자 히스토리 기본값 저장 위치 (localStorage vs DB)&lt;/li&gt;
    &lt;li&gt;요약 바 펼침 시 애니메이션/레이아웃&lt;/li&gt;
    &lt;li&gt;Analytics 이벤트 설계&lt;/li&gt;
  &lt;/ul&gt;

  &lt;p&gt;&lt;strong&gt;User Decisions Made:&lt;/strong&gt;&lt;/p&gt;

  &lt;table&gt;
    &lt;thead&gt;
      &lt;tr&gt;
        &lt;th&gt;#&lt;/th&gt;
        &lt;th&gt;Decision Point&lt;/th&gt;
        &lt;th&gt;Choice&lt;/th&gt;
        &lt;th&gt;Rationale&lt;/th&gt;
      &lt;/tr&gt;
    &lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;
        &lt;td&gt;1&lt;/td&gt;
        &lt;td&gt;설정 노출 전략&lt;/td&gt;
        &lt;td&gt;C) 스마트 기본값 + 인라인 확장&lt;/td&gt;
        &lt;td&gt;—&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
        &lt;td&gt;2&lt;/td&gt;
        &lt;td&gt;Character 위치&lt;/td&gt;
        &lt;td&gt;B) 인라인 링크&lt;/td&gt;
        &lt;td&gt;—&lt;/td&gt;
      &lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;

  &lt;p&gt;&lt;strong&gt;Actionable Output — 리디자인 구현 계획:&lt;/strong&gt;&lt;/p&gt;

  &lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;┌──────────────────────────────────────────┐
│  [Writing Style ▾]      [Art Style ▾]    │  핵심 스타일 선택
├──────────────────────────────────────────┤
│  &quot;이야기를 들려주세요...&quot;                │  Story Prompt (auto-height)
│                                          │
│  장면: [3 ▾]   + 캐릭터 추가             │  보조 설정
├──────────────────────────────────────────┤
│  📐 16:9 펼침 · 100자/장면  [설정 ▾]    │  요약 바 (접힘 상태)
│  ┌─ 펼침 시 ──────────────────────────┐  │
│  │ 이미지 비율: ◻16:9 ◻3:2 ◻9:16 ◻1:1│  │
│  │ 이미지 모드: ◻펼침  ◻삽화          │  │
│  │ 장면당 글자: [100]                  │  │
│  └────────────────────────────────────┘  │
├──────────────────────────────────────────┤
│  [ ✨ 생성하기 ]     예상 ~750 토큰      │  제출 + 토큰 예측
└──────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;  &lt;/div&gt;

  &lt;p&gt;&lt;strong&gt;구현 항목:&lt;/strong&gt;&lt;/p&gt;
  &lt;ol&gt;
    &lt;li&gt;Content Type 토글 제거 → Writing Style 선택 시 자동 결정&lt;/li&gt;
    &lt;li&gt;Character 슬롯 3개 제거 → “+캐릭터 추가” 인라인 링크 + 팝오버&lt;/li&gt;
    &lt;li&gt;Image Size, Image Mode, Chars Per Scene → 요약 바로 그룹핑&lt;/li&gt;
    &lt;li&gt;Scene Count → Prompt 영역 하단으로 이동&lt;/li&gt;
    &lt;li&gt;예상 토큰 소모량 실시간 계산 표시&lt;/li&gt;
    &lt;li&gt;스마트 기본값 로직 (시스템 기본 → localStorage 이전 설정 복원)&lt;/li&gt;
    &lt;li&gt;CreationWizard 코드 제거, MainScreen으로 통합&lt;/li&gt;
  &lt;/ol&gt;

&lt;/details&gt;
</description>
        <pubDate>Sat, 21 Mar 2026 19:00:00 +0900</pubDate>
        <link>https://jenalab-inc.github.io/design/2026/03/21/storida-creation-ux-simplification.html</link>
        <guid isPermaLink="true">https://jenalab-inc.github.io/design/2026/03/21/storida-creation-ux-simplification.html</guid>
        
        
        <category>design</category>
        
      </item>
    
      <item>
        <title>콘텐츠 타입 확장 설계 — 동화 전용에서 범용 창작 플랫폼으로</title>
        <description>&lt;h2 id=&quot;문제-30곳에-하드코딩된-동화&quot;&gt;문제: 30곳에 하드코딩된 “동화”&lt;/h2&gt;

&lt;p&gt;“어린이 동화” 전용 서비스로 시작했다. “동화”, “아이들”, “children’s book”이 30곳 이상에 박혀 있었다. 성인 에세이를 생성해도 “어린이가 이해하기 쉬운 단어”가 적용된다. 부적절한 결과가 나왔다.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;하드코딩 위치&lt;/th&gt;
      &lt;th&gt;내용&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;scheduler/src/context.ts&lt;/td&gt;
      &lt;td&gt;“어린이 동화를 JSON 형식으로…”&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;scheduler/src/image-regen.ts&lt;/td&gt;
      &lt;td&gt;“children’s book illustration”&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;src/db/seed.ts&lt;/td&gt;
      &lt;td&gt;시스템 프롬프트, 평가 기준&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;src/modules/generation.service.ts&lt;/td&gt;
      &lt;td&gt;폴백 제목 “생성된 동화”&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;src/modules/prompt.routes.ts&lt;/td&gt;
      &lt;td&gt;“동화 샘플”, “어린이 콘텐츠 적합성”&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h2 id=&quot;설계-원칙&quot;&gt;설계 원칙&lt;/h2&gt;

&lt;p&gt;5가지 원칙으로 확장을 설계했다.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;원칙&lt;/th&gt;
      &lt;th&gt;적용&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;하위 호환 100%&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;content_type&lt;/code&gt; 기본값 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fairytale&lt;/code&gt; → 기존 코드 무변경 시 동일 동작&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;기존 패턴 준수&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Feature-based 구조, Drizzle ORM, Express, Zod 그대로&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;단일 진실 원천&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;프롬프트는 DB에서 조회, 하드코딩 폴백은 타입별 분기&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;점진적 확장&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;content_type&lt;/code&gt;을 text 타입 → 추후 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;essay&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;poem&lt;/code&gt; 등 추가 용이&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;최소 변경&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;기존 테이블에 컬럼 추가 우선, 새 테이블은 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;writings&lt;/code&gt;만&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h2 id=&quot;콘텐츠-타입-정의&quot;&gt;콘텐츠 타입 정의&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;값&lt;/th&gt;
      &lt;th&gt;라벨&lt;/th&gt;
      &lt;th&gt;대상&lt;/th&gt;
      &lt;th&gt;어휘 제한&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fairytale&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;동화&lt;/td&gt;
      &lt;td&gt;어린이&lt;/td&gt;
      &lt;td&gt;쉬운 어휘, 교육적&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;general&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;일반&lt;/td&gt;
      &lt;td&gt;성인&lt;/td&gt;
      &lt;td&gt;제한 없음, 자유 문체&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;기본값은 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fairytale&lt;/code&gt;이다. 기존 사용자는 아무 변경 없이 동일한 경험을 유지한다.&lt;/p&gt;

&lt;h2 id=&quot;핵심-아키텍처-promptresolver&quot;&gt;핵심 아키텍처: PromptResolver&lt;/h2&gt;

&lt;h3 id=&quot;문제&quot;&gt;문제&lt;/h3&gt;

&lt;p&gt;30곳을 각각 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;if (contentType === &apos;fairytale&apos;) ... else ...&lt;/code&gt;로 분기하면 코드가 2배로 늘어난다.&lt;/p&gt;

&lt;h3 id=&quot;해결-중앙-프롬프트-해석기&quot;&gt;해결: 중앙 프롬프트 해석기&lt;/h3&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;                    ┌──────────────────┐
 context.ts ───────▶│  PromptResolver  │
 image-regen.ts ───▶│                  │
 generation.svc ───▶│  resolve(key,    │──▶ app_config DB
 prompt.routes ────▶│    contentType)  │──▶ 폴백 상수 맵
                    └──────────────────┘
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PromptResolver&lt;/code&gt;는 하나의 진입점으로 모든 프롬프트 조회를 통합한다. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;content_type&lt;/code&gt;에 따라 적절한 프롬프트를 반환하되, DB에 없으면 폴백 상수를 사용한다.&lt;/p&gt;

&lt;p&gt;PromptResolver는 콘텐츠 타입에 따라 적절한 프롬프트를 반환한다.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;// 조회 우선순위:
// 1. 캐시 확인
// 2. DB 설정 테이블에서 타입별 키로 조회 (fairytale은 기존 키, general은 접미사 추가)
// 3. 폴백 상수 맵에서 조회
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;폴백-상수-맵-설계&quot;&gt;폴백 상수 맵 설계&lt;/h3&gt;

&lt;p&gt;폴백 프롬프트는 콘텐츠 타입별로 분리된다. 동화 타입은 아동 친화적 어휘와 교육적 톤을, 일반 타입은 자유로운 문체를 사용한다. 작문 시스템 프롬프트, 이미지 생성 프롬프트, 평가 프롬프트 각각에 타입별 폴백이 존재한다.&lt;/p&gt;

&lt;p&gt;DB 설정이 없을 때 폴백이 작동하므로, 새 콘텐츠 타입을 추가할 때 DB 시드를 먼저 넣지 않아도 서비스가 중단되지 않는다.&lt;/p&gt;

&lt;h2 id=&quot;db-스키마-변경&quot;&gt;DB 스키마 변경&lt;/h2&gt;

&lt;h3 id=&quot;기존-테이블-확장&quot;&gt;기존 테이블 확장&lt;/h3&gt;

&lt;p&gt;기존 테이블(&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;contents&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;prompt_templates&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;characters&lt;/code&gt;)에 타입 구분 컬럼을 추가한다. 기본값 설정으로 기존 데이터는 자동 호환된다.&lt;/p&gt;

&lt;p&gt;모든 기본값이 기존 동작과 동일하므로, &lt;strong&gt;롤백 시 코드만 되돌리면&lt;/strong&gt; DB는 그대로 작동한다.&lt;/p&gt;

&lt;h3 id=&quot;신규-테이블-writings-글쓰기&quot;&gt;신규 테이블: writings (글쓰기)&lt;/h3&gt;

&lt;p&gt;글쓰기 테이블은 사용자가 작성한 글 본문, 콘텐츠 타입, 상태(초안/사용됨)를 저장한다. 글쓰기 기능은 “아이디어를 먼저 글로 정리한 뒤, 나중에 책으로 만드는” 2단계 워크플로우를 지원한다. 글에서 생성된 콘텐츠에 대한 참조로 글에서 책으로의 전환을 추적한다.&lt;/p&gt;

&lt;h2 id=&quot;기능-범위-f1f10&quot;&gt;기능 범위 (F1~F10)&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;#&lt;/th&gt;
      &lt;th&gt;기능&lt;/th&gt;
      &lt;th&gt;설명&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;F1&lt;/td&gt;
      &lt;td&gt;콘텐츠 타입 선택&lt;/td&gt;
      &lt;td&gt;생성 시 “동화”/”일반” 선택 UI&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;F2&lt;/td&gt;
      &lt;td&gt;타입별 프롬프트 분기&lt;/td&gt;
      &lt;td&gt;PromptResolver 기반 자동 전환&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;F3&lt;/td&gt;
      &lt;td&gt;DB 스키마 확장&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;contents.content_type&lt;/code&gt; 컬럼&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;F4&lt;/td&gt;
      &lt;td&gt;하드코딩 제거&lt;/td&gt;
      &lt;td&gt;30곳 → DB 설정 또는 타입 분기&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;F5&lt;/td&gt;
      &lt;td&gt;글쓰기 기능&lt;/td&gt;
      &lt;td&gt;독립 글 작성·저장&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;F6&lt;/td&gt;
      &lt;td&gt;글 → 책 전환&lt;/td&gt;
      &lt;td&gt;저장된 글을 “상황” 입력으로 활용&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;F7&lt;/td&gt;
      &lt;td&gt;자동 캐릭터 프로파일링&lt;/td&gt;
      &lt;td&gt;글 생성 후 등장인물 시각 프로파일 자동 추출&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;F8&lt;/td&gt;
      &lt;td&gt;캐릭터 이미지 타입 분류&lt;/td&gt;
      &lt;td&gt;인물/풍경/사물 자동 분류&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;F9&lt;/td&gt;
      &lt;td&gt;작문 프롬프트 타입 연동&lt;/td&gt;
      &lt;td&gt;content_type별 프롬프트 분류&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;F10&lt;/td&gt;
      &lt;td&gt;이미지 모드&lt;/td&gt;
      &lt;td&gt;펼침(16:9) vs 삽화 선택&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h2 id=&quot;f7-자동-캐릭터-프로파일링&quot;&gt;F7: 자동 캐릭터 프로파일링&lt;/h2&gt;

&lt;p&gt;캐릭터를 지정하지 않아도 이미지 일관성을 확보하는 기능이다.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;텍스트 생성 완료
    │
    ▼
Claude가 등장인물 시각 프로파일 자동 추출
(캐릭터별 외형 묘사: 나이, 머리색, 복장 등)
    │
    ▼
이미지 생성 시 프로파일을 프롬프트에 주입
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;기존 멀티 캐릭터 시스템은 관리자가 등록한 캐릭터에 의존했다. F7은 텍스트에서 자동 추출한다. 사용자가 캐릭터를 선택하지 않아도 일관성을 확보한다.&lt;/p&gt;

&lt;h2 id=&quot;변경-영역-매핑&quot;&gt;변경 영역 매핑&lt;/h2&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;packages/db        → schema 확장 (컬럼 추가, writings 테이블)
        ↓
src/modules        → generation 검증, writings CRUD, prompts 타입 분기
        ↓
scheduler/src      → PromptResolver, character-profiler, image 크기 분기
        ↓
web/src            → content_type 선택 UI, 글쓰기 기능
admin/src          → prompts content_type 필드, characters image_type 뱃지
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;설계의-핵심-확장-가능성&quot;&gt;설계의 핵심: 확장 가능성&lt;/h2&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;content_type&lt;/code&gt;을 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;text&lt;/code&gt; 타입으로 설계했다. 향후 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;essay&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;poem&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;novel&lt;/code&gt;을 추가할 때 DB 마이그레이션이 필요 없다. 새 값만 추가하면 된다.&lt;/p&gt;

&lt;p&gt;새 타입 추가 시 폴백 프롬프트 추가, DB 설정 시드, UI 옵션 추가만 하면 된다. DB 스키마 변경은 필요 없다.&lt;/p&gt;

&lt;p&gt;PromptResolver의 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;resolve(baseKey, contentType)&lt;/code&gt; 패턴이 이를 가능하게 한다. 새 타입의 프롬프트가 DB에 없으면 폴백을 사용하고, 점진적으로 DB에 최적화된 프롬프트를 추가하면 된다.&lt;/p&gt;

&lt;h2 id=&quot;결론&quot;&gt;결론&lt;/h2&gt;

&lt;p&gt;“동화 전용”에서 “범용 창작 플랫폼”으로의 확장은 if-else를 추가하는 일이 아니다. 중앙 프롬프트 해석기라는 추상 레이어를 도입하고, 하위 호환 100%를 보장하는 마이그레이션 전략을 세워야 한다.&lt;/p&gt;

&lt;p&gt;기존 사용자에게는 아무것도 바뀌지 않는다. 새로운 가능성만 열린다.&lt;/p&gt;
</description>
        <pubDate>Thu, 19 Mar 2026 19:00:00 +0900</pubDate>
        <link>https://jenalab-inc.github.io/architecture/2026/03/19/storida-content-type-expansion.html</link>
        <guid isPermaLink="true">https://jenalab-inc.github.io/architecture/2026/03/19/storida-content-type-expansion.html</guid>
        
        
        <category>architecture</category>
        
      </item>
    
      <item>
        <title>JenaImage — Finder와 Preview 사이를 없앤 macOS 이미지 뷰어</title>
        <description>&lt;h2 id=&quot;이미지를-관리하려면-앱-두-개가-필요하다&quot;&gt;이미지를 관리하려면 앱 두 개가 필요하다&lt;/h2&gt;

&lt;p&gt;폴더를 탐색한다. Finder. 이미지를 본다. Preview. 다른 폴더로 옮긴다. 다시 Finder. 포맷을 변환한다. 또 다른 앱.&lt;/p&gt;

&lt;p&gt;이미지 작업은 항상 앱 사이를 오간다. JenaImage는 이 이동을 없앤다. 폴더 사이드바, 썸네일 그리드, 이미지 뷰어, 동영상 재생, 포맷 변환이 한 창에 있다.&lt;/p&gt;

&lt;h2 id=&quot;설계-결정&quot;&gt;설계 결정&lt;/h2&gt;

&lt;h3 id=&quot;appkit을-선택한-이유&quot;&gt;AppKit을 선택한 이유&lt;/h3&gt;

&lt;p&gt;이 앱은 세 가지 핵심 컴포넌트를 사용한다.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;컴포넌트&lt;/th&gt;
      &lt;th&gt;AppKit&lt;/th&gt;
      &lt;th&gt;SwiftUI&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;폴더 트리&lt;/td&gt;
      &lt;td&gt;NSOutlineView — 안정적&lt;/td&gt;
      &lt;td&gt;OutlineGroup — 대규모 트리에서 불안정&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;이미지 그리드&lt;/td&gt;
      &lt;td&gt;NSCollectionView — 성능 검증됨&lt;/td&gt;
      &lt;td&gt;LazyVGrid — 스크롤 끊김 보고&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;줌/팬 뷰어&lt;/td&gt;
      &lt;td&gt;NSScrollView magnification&lt;/td&gt;
      &lt;td&gt;ScrollView — 세밀한 제어 어려움&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;세 컴포넌트 모두 AppKit이 우위다. macOS 14 이상을 타겟으로 해도 SwiftUI의 이 영역은 성숙하지 않았다.&lt;/p&gt;

&lt;h3 id=&quot;swift-concurrency-gcd가-아닌-이유&quot;&gt;Swift Concurrency, GCD가 아닌 이유&lt;/h3&gt;

&lt;p&gt;이미지 뷰어는 비동기 작업이 많다. 폴더 탐색, 썸네일 생성, 대용량 이미지 로딩, 포맷 변환. 모두 백그라운드에서 처리해야 한다.&lt;/p&gt;

&lt;p&gt;GCD 대신 Swift Concurrency(&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;async/await&lt;/code&gt;)를 택했다. 이유는 하나다. &lt;strong&gt;작업 취소&lt;/strong&gt;. 폴더를 전환하면 이전 폴더의 썸네일 생성을 즉시 중단해야 한다. GCD에서는 취소 로직을 직접 구현해야 한다. Swift Concurrency는 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Task.isCancelled&lt;/code&gt;로 해결된다.&lt;/p&gt;

&lt;h2 id=&quot;아키텍처-한-창-안의-세-영역&quot;&gt;아키텍처: 한 창 안의 세 영역&lt;/h2&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;┌──────────────────────────────────────────────────────┐
│  MainWindowController (NSSplitViewController)        │
├──────────┬───────────────────────────────────────────┤
│ Sidebar  │  Browser (그리드) ←→ Viewer (상세)        │
│ 폴더 트리 │  ┌─────────────────────────────────────┐  │
│          │  │ 폴더 + 이미지 썸네일                  │  │
│ 폴더 선택 │  │ 또는                                │  │
│ 드래그    │  │ 이미지 뷰어 + 썸네일 스트립            │  │
│ 앤 드롭   │  └─────────────────────────────────────┘  │
└──────────┴───────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;NSSplitViewController가 전체를 관리한다. 왼쪽은 폴더 사이드바. 오른쪽은 브라우저(그리드)와 뷰어(상세)를 전환한다. MainWindowController가 중재자다.&lt;/p&gt;

&lt;h3 id=&quot;의존성-규칙&quot;&gt;의존성 규칙&lt;/h3&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;UI (ViewController) → Service → Model
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;역방향 의존은 금지한다. 기능 간 통신은 Delegate 패턴만 사용한다. NotificationCenter나 Combine은 쓰지 않는다. 타입 안전성을 보장하고 추적 가능한 흐름을 유지한다.&lt;/p&gt;

&lt;h3 id=&quot;파일-구조&quot;&gt;파일 구조&lt;/h3&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Sources/
├── app/        메인 윈도우, 메뉴, 설정, 도움말
├── sidebar/    폴더 트리 (NSOutlineView)
├── browser/    이미지 그리드 (NSCollectionView)
├── viewer/     이미지 뷰어, 줌, 썸네일 스트립, 동영상
├── services/   파일 관리, 이미지 처리, 캐시, 보안
└── models/     폴더 노드, 이미지 파일, 포맷 정의
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;썸네일-캐시-메모리만-디스크-없음&quot;&gt;썸네일 캐시: 메모리만, 디스크 없음&lt;/h2&gt;

&lt;p&gt;1,000장 이상의 이미지를 스크롤할 때 버벅이면 안 된다. 썸네일 캐시가 필요하다.&lt;/p&gt;

&lt;p&gt;NSCache 기반 메모리 캐시를 사용한다. 500MB 상한. LRU 정책으로 자동 해제한다. macOS가 메모리 부족을 감지하면 NSCache가 알아서 비운다.&lt;/p&gt;

&lt;p&gt;디스크 캐시는 두지 않았다. ImageIO의 썸네일 생성이 충분히 빠르다. 디스크 캐시를 추가하면 무효화 로직이 복잡해진다. 파일이 이동되거나 삭제되면 캐시와 불일치가 생긴다. 그 복잡도를 감수할 만큼 디스크 캐시의 이득이 크지 않았다.&lt;/p&gt;

&lt;h2 id=&quot;줌과-팬-nsscrollview의-magnification&quot;&gt;줌과 팬: NSScrollView의 magnification&lt;/h2&gt;

&lt;p&gt;이미지 뷰어는 10%에서 500%까지 확대한다. 확대하면 드래그로 이동한다.&lt;/p&gt;

&lt;p&gt;NSScrollView의 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;magnification&lt;/code&gt; 프로퍼티를 사용한다. 트랙패드 핀치, Cmd+/-, 키보드 단축키가 모두 이 하나의 프로퍼티를 조작한다. 별도 확대 로직을 구현하지 않았다.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;단축키&lt;/th&gt;
      &lt;th&gt;동작&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Cmd++&lt;/td&gt;
      &lt;td&gt;확대&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Cmd+-&lt;/td&gt;
      &lt;td&gt;축소&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Cmd+0&lt;/td&gt;
      &lt;td&gt;원본 크기 (100%)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Cmd+9&lt;/td&gt;
      &lt;td&gt;창에 맞춤&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h2 id=&quot;포맷-변환-imageio-양방향&quot;&gt;포맷 변환: ImageIO 양방향&lt;/h2&gt;

&lt;p&gt;JPEG, PNG, WebP, HEIC, HEIF, AVIF, TIFF, BMP, GIF를 지원한다. 입력과 출력 모두.&lt;/p&gt;

&lt;p&gt;변환은 ImageIO 프레임워크가 처리한다. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CGImageSource&lt;/code&gt;로 원본을 읽고, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CGImageDestination&lt;/code&gt;으로 대상 포맷에 쓴다. 외부 라이브러리 없이 시스템 프레임워크만으로 9개 포맷을 처리한다.&lt;/p&gt;

&lt;h2 id=&quot;파일-관리-finder를-열지-않아도&quot;&gt;파일 관리: Finder를 열지 않아도&lt;/h2&gt;

&lt;p&gt;삭제는 휴지통으로 보낸다. 영구 삭제가 아니다. 이동은 사이드바의 폴더로 드래그한다. 이름 변경은 인라인 편집이다. 클립보드 복사도 된다.&lt;/p&gt;

&lt;p&gt;모든 파일 작업은 Result 타입을 반환한다. 실패하면 에러를 명시한다. “권한이 없습니다”, “파일이 이미 존재합니다”. 사용자에게 실패 이유를 숨기지 않는다.&lt;/p&gt;

&lt;h2 id=&quot;동영상-재생&quot;&gt;동영상 재생&lt;/h2&gt;

&lt;p&gt;이미지 뷰어에서 동영상도 재생한다. MP4, MOV, M4V, AVI, MKV. AVKit의 인라인 플레이어를 사용한다. 별도 창을 열지 않는다. 이미지와 동영상이 같은 폴더에 섞여 있어도 자연스럽게 탐색한다.&lt;/p&gt;

&lt;h2 id=&quot;샌드박스와-보안&quot;&gt;샌드박스와 보안&lt;/h2&gt;

&lt;p&gt;macOS 앱은 사용자가 허용한 폴더만 접근할 수 있다. 처음 폴더를 선택하면 보안 범위 북마크를 저장한다. 앱을 재시작해도 같은 폴더에 접근할 수 있다.&lt;/p&gt;

&lt;p&gt;북마크가 없는 폴더에는 접근하지 못한다. 이것은 제약이 아니다. macOS 보안 모델을 따르는 것이다.&lt;/p&gt;

&lt;h2 id=&quot;빌드-makefile--swiftc&quot;&gt;빌드: Makefile + swiftc&lt;/h2&gt;

&lt;p&gt;JenaLab의 macOS 앱 빌드 방식과 동일하다.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;make run      # 빌드 + 실행
make build    # .app 번들 생성
make install  # ~/Applications에 설치
make dmg      # 배포용 디스크 이미지
make pkg      # 패키지 인스톨러
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;28개 Swift 파일. 외부 의존성 0개. Xcode 프로젝트 0개.&lt;/p&gt;

&lt;h2 id=&quot;정리&quot;&gt;정리&lt;/h2&gt;

&lt;p&gt;이미지 관리에 앱 두 개는 불필요하다. 폴더 트리, 썸네일 그리드, 줌 뷰어, 파일 관리, 포맷 변환. 한 창에 담는다.&lt;/p&gt;

&lt;p&gt;NSOutlineView, NSCollectionView, NSScrollView. AppKit의 기본 컴포넌트가 각자의 역할을 한다. 위에 얹은 것은 Delegate 패턴으로 연결한 중재자뿐이다.&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;strong&gt;JenaImage&lt;/strong&gt;는 MIT 오픈소스다.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/jenalab-com/jena-image&quot;&gt;GitHub&lt;/a&gt; · &lt;a href=&quot;https://github.com/jenalab-com/jena-image/releases/latest/download/JenaImage.dmg&quot;&gt;DMG 다운로드&lt;/a&gt;&lt;/p&gt;
</description>
        <pubDate>Wed, 18 Mar 2026 19:00:00 +0900</pubDate>
        <link>https://jenalab-inc.github.io/macos/2026/03/18/jenaimage-native-image-viewer.html</link>
        <guid isPermaLink="true">https://jenalab-inc.github.io/macos/2026/03/18/jenaimage-native-image-viewer.html</guid>
        
        
        <category>macos</category>
        
      </item>
    
      <item>
        <title>JenaNote — NSDocument로 만드는 WYSIWYG 마크다운 에디터</title>
        <description>&lt;h2 id=&quot;마크다운-기호가-보이지-않는-에디터&quot;&gt;마크다운 기호가 보이지 않는 에디터&lt;/h2&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;**굵게**&lt;/code&gt;를 입력하면 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;**&lt;/code&gt;가 사라지고 &lt;strong&gt;굵게&lt;/strong&gt;만 남는다. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;# 제목&lt;/code&gt;을 입력하면 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;#&lt;/code&gt;이 사라지고 큰 글씨만 보인다. 저장하면 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.md&lt;/code&gt; 파일이다. 다른 마크다운 뷰어에서 열어도 서식이 일치한다.&lt;/p&gt;

&lt;p&gt;JenaNote는 이 동작을 수행하는 macOS 네이티브 에디터다.&lt;/p&gt;

&lt;h2 id=&quot;문제-기존-도구의-틈&quot;&gt;문제: 기존 도구의 틈&lt;/h2&gt;

&lt;p&gt;마크다운 에디터는 두 부류로 나뉜다.&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;기호 노출형&lt;/strong&gt; (Obsidian, VS Code). 마크다운 문법을 알아야 한다. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;#&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;**&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-&lt;/code&gt; 기호가 화면에 그대로 보인다.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;서식 에디터&lt;/strong&gt; (TextEdit, Notes). WYSIWYG이지만 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.md&lt;/code&gt;로 저장하지 못한다.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Typora가 이 틈을 메웠지만, 유료 전환 후 대안이 필요해졌다. JenaNote는 그 자리를 MIT 오픈소스로 채운다.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;기준&lt;/th&gt;
      &lt;th&gt;JenaNote&lt;/th&gt;
      &lt;th&gt;Typora&lt;/th&gt;
      &lt;th&gt;Obsidian&lt;/th&gt;
      &lt;th&gt;TextEdit&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;WYSIWYG&lt;/td&gt;
      &lt;td&gt;O&lt;/td&gt;
      &lt;td&gt;O&lt;/td&gt;
      &lt;td&gt;X&lt;/td&gt;
      &lt;td&gt;O&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;.md 저장&lt;/td&gt;
      &lt;td&gt;O&lt;/td&gt;
      &lt;td&gt;O&lt;/td&gt;
      &lt;td&gt;O&lt;/td&gt;
      &lt;td&gt;X&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;기호 숨김&lt;/td&gt;
      &lt;td&gt;O&lt;/td&gt;
      &lt;td&gt;부분&lt;/td&gt;
      &lt;td&gt;X&lt;/td&gt;
      &lt;td&gt;해당없음&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;macOS 네이티브&lt;/td&gt;
      &lt;td&gt;O&lt;/td&gt;
      &lt;td&gt;O&lt;/td&gt;
      &lt;td&gt;X&lt;/td&gt;
      &lt;td&gt;O&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;오픈소스&lt;/td&gt;
      &lt;td&gt;MIT&lt;/td&gt;
      &lt;td&gt;유료&lt;/td&gt;
      &lt;td&gt;유료&lt;/td&gt;
      &lt;td&gt;시스템 앱&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h2 id=&quot;설계의-출발점-nsdocument&quot;&gt;설계의 출발점: NSDocument&lt;/h2&gt;

&lt;p&gt;macOS에는 문서 기반 앱을 위한 표준 패턴이 있다. NSDocument다. 이 클래스를 상속하면 다음이 공짜로 따라온다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;저장 / 다른 이름으로 저장 대화상자&lt;/li&gt;
  &lt;li&gt;창 제목의 변경 표시 점(●)&lt;/li&gt;
  &lt;li&gt;닫기 전 “저장하시겠습니까?” 확인 시트&lt;/li&gt;
  &lt;li&gt;앱 종료 후 재시작 시 문서 복원&lt;/li&gt;
  &lt;li&gt;Undo / Redo 스택&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;직접 구현하면 이 목록만으로 수백 줄이다. NSDocument를 쓰면 0줄이다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;결정: NSDocument를 상속하고, 커스텀 파일 관리 코드를 작성하지 않는다.&lt;/strong&gt;&lt;/p&gt;

&lt;h2 id=&quot;3레이어-아키텍처&quot;&gt;3레이어 아키텍처&lt;/h2&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;┌─────────────────────────────────────────┐
│  UI Layer                               │
│  텍스트 뷰 · 서식 툴바 · 뷰컨트롤러      │
└────────────────┬────────────────────────┘
                 │ NSAttributedString 읽기/쓰기
┌────────────────▼────────────────────────┐
│  Document Layer                         │
│  마크다운 문서 (NSDocument 서브클래스)     │
└────────────────┬────────────────────────┘
                 │ 직렬화 / 역직렬화
┌────────────────▼────────────────────────┐
│  Infrastructure Layer                   │
│  마크다운 시리얼라이저                    │
│  (NSAttributedString ↔ CommonMark .md)  │
└─────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;의존성은 위에서 아래로만 흐른다. 세 가지 규칙을 정했다.&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;UI는 시리얼라이저를 직접 호출하지 않는다&lt;/li&gt;
  &lt;li&gt;Document는 AppKit UI 컴포넌트에 의존하지 않는다&lt;/li&gt;
  &lt;li&gt;시리얼라이저는 순수 함수다. 상태가 없다&lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;핵심-결정-내부-표현으로-nsattributedstring&quot;&gt;핵심 결정: 내부 표현으로 NSAttributedString&lt;/h3&gt;

&lt;p&gt;마크다운 에디터를 만들 때 내부 표현의 선택이 아키텍처 전체를 결정한다.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;선택지&lt;/th&gt;
      &lt;th&gt;장점&lt;/th&gt;
      &lt;th&gt;단점&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;마크다운 AST&lt;/td&gt;
      &lt;td&gt;CommonMark 완전 지원&lt;/td&gt;
      &lt;td&gt;NSTextView와 별도 동기화 필요&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;NSAttributedString&lt;/td&gt;
      &lt;td&gt;NSTextView 직접 통합, Undo 자동&lt;/td&gt;
      &lt;td&gt;복잡한 중첩 구조에 한계&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;NSAttributedString을 선택했다. NSTextView가 이 형식을 네이티브로 처리한다. 볼드, 이탤릭, 제목, 목록을 속성(attribute)으로 저장하면 화면에 즉시 반영된다. Undo/Redo도 NSUndoManager가 자동 처리한다.&lt;/p&gt;

&lt;p&gt;트레이드오프는 있다. 마크다운의 복잡한 중첩 구조(리스트 안의 코드 블록 안의 볼드)를 완벽하게 표현하기 어렵다. 메모 앱의 범위에서는 허용 가능한 제약이다.&lt;/p&gt;

&lt;h2 id=&quot;데이터-흐름&quot;&gt;데이터 흐름&lt;/h2&gt;

&lt;h3 id=&quot;파일-열기&quot;&gt;파일 열기&lt;/h3&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Finder에서 .md 더블클릭
  → NSDocumentController가 Document 인스턴스 생성
  → 시리얼라이저: 마크다운 텍스트 → NSAttributedString
  → 뷰컨트롤러가 텍스트 뷰에 로드
  → 기호 없이 서식 적용된 상태로 표시
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;서식-적용&quot;&gt;서식 적용&lt;/h3&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Cmd+B 또는 툴바 B 클릭
  → 뷰컨트롤러가 서식 명령 호출
  → 텍스트 스토리지에 속성 변경 (Undo 등록)
  → 화면 즉시 반영. 기호는 보이지 않는다.
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;저장&quot;&gt;저장&lt;/h3&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Cmd+S
  → NSDocument가 저장 처리 (AppKit 자동)
  → 시리얼라이저: NSAttributedString → CommonMark 텍스트
  → .md 파일로 디스크 기록
  → 창 제목의 ● 표시 제거
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;시리얼라이저-양방향-변환&quot;&gt;시리얼라이저: 양방향 변환&lt;/h2&gt;

&lt;p&gt;시리얼라이저는 두 가지 일을 한다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;파싱&lt;/strong&gt;: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.md&lt;/code&gt; 파일의 마크다운 텍스트를 받아 NSAttributedString으로 변환한다. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;**텍스트**&lt;/code&gt;는 볼드 속성이 적용된 “텍스트”가 된다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;직렬화&lt;/strong&gt;: NSAttributedString의 속성을 읽어 CommonMark 텍스트로 변환한다. 볼드 속성이 있으면 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;**&lt;/code&gt;로 감싼다.&lt;/p&gt;

&lt;p&gt;이 모듈은 순수 함수다. 입력을 받아 출력을 반환한다. 상태를 저장하지 않는다. Foundation만 import한다. 테스트가 쉬운 이유다.&lt;/p&gt;

&lt;h2 id=&quot;macos-표준을-따르는-이유&quot;&gt;macOS 표준을 따르는 이유&lt;/h2&gt;

&lt;h3 id=&quot;메뉴와-단축키&quot;&gt;메뉴와 단축키&lt;/h3&gt;

&lt;p&gt;macOS의 Responder Chain을 그대로 사용한다. 별도 메뉴 관리 코드가 필요 없다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;파일 메뉴(저장, 열기) → NSDocument가 자동 처리&lt;/li&gt;
  &lt;li&gt;편집 메뉴(Undo, 복사, 붙여넣기) → NSTextView가 자동 처리&lt;/li&gt;
  &lt;li&gt;서식 메뉴(Bold, Italic) → 커스텀 액션만 연결&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;커스텀 코드가 필요한 곳은 서식 적용뿐이다. 나머지는 AppKit이 처리한다.&lt;/p&gt;

&lt;h3 id=&quot;미저장-경고&quot;&gt;미저장 경고&lt;/h3&gt;

&lt;p&gt;변경 사항이 있는 상태에서 창을 닫으면 확인 시트가 뜬다. “저장”, “저장 안 함”, “취소”. 이 동작도 NSDocument가 자동 제공한다. 직접 구현하면 닫기, 종료, 새 문서 열기 등 모든 경로에서 경고를 호출해야 한다. 빠뜨리면 데이터 손실이다.&lt;/p&gt;

&lt;h2 id=&quot;빌드-xcode-없이&quot;&gt;빌드: Xcode 없이&lt;/h2&gt;

&lt;p&gt;jenaMemory와 같은 방식이다. Makefile + &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;swiftc&lt;/code&gt;.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;make run      # 빌드 + 실행
make build    # .app 번들 생성
make install  # ~/Applications에 설치
make dmg      # 배포용 디스크 이미지
make pkg      # 패키지 인스톨러
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Swift 파일을 하나의 바이너리로 컴파일한다. Xcode 프로젝트 파일이 없으므로 Git diff가 깨끗하다. 설정 변경이 Makefile 한 줄에 담긴다.&lt;/p&gt;

&lt;h2 id=&quot;정리&quot;&gt;정리&lt;/h2&gt;

&lt;p&gt;WYSIWYG 마크다운 에디터를 만드는 데 필요한 것은 세 가지다.&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;NSDocument&lt;/strong&gt; — 파일 관리를 macOS에 맡긴다&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;NSAttributedString&lt;/strong&gt; — 내부 표현과 화면 표시를 일치시킨다&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;양방향 시리얼라이저&lt;/strong&gt; — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.md&lt;/code&gt; 파일과의 변환을 담당한다&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;나머지는 AppKit이 처리한다. 플랫폼이 제공하는 것을 다시 만들지 않는다. 이것이 네이티브 앱의 설계 원칙이다.&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;strong&gt;JenaNote&lt;/strong&gt;는 MIT 오픈소스다.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/jenalab-com/jena-note&quot;&gt;GitHub&lt;/a&gt; · &lt;a href=&quot;https://github.com/jenalab-com/jena-note/releases/latest/download/JenaNote-1.0.0.dmg&quot;&gt;DMG 다운로드&lt;/a&gt;&lt;/p&gt;
</description>
        <pubDate>Mon, 16 Mar 2026 19:00:00 +0900</pubDate>
        <link>https://jenalab-inc.github.io/macos/2026/03/16/jenanote-wysiwyg-markdown-editor.html</link>
        <guid isPermaLink="true">https://jenalab-inc.github.io/macos/2026/03/16/jenanote-wysiwyg-markdown-editor.html</guid>
        
        
        <category>macos</category>
        
      </item>
    
      <item>
        <title>jenaMemory — Xcode 없이 만드는 macOS 메뉴바 앱</title>
        <description>&lt;h2 id=&quot;메뉴바에-메모리-사용량을-띄운다&quot;&gt;메뉴바에 메모리 사용량을 띄운다&lt;/h2&gt;

&lt;p&gt;macOS 메뉴바 오른쪽에 숫자 하나가 뜬다. 현재 여유 메모리 퍼센트다. 2초마다 갱신된다. 클릭하면 상세 통계가 나온다. 한 번 더 클릭하면 캐시를 비운다.&lt;/p&gt;

&lt;p&gt;jenaMemory는 이 동작을 수행하는 경량 유틸리티다. Swift 파일 8개, Xcode 프로젝트 없이 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;swiftc&lt;/code&gt;로 빌드한다.&lt;/p&gt;

&lt;h2 id=&quot;왜-직접-만들었나&quot;&gt;왜 직접 만들었나&lt;/h2&gt;

&lt;p&gt;메모리 모니터 앱은 App Store에 넘친다. 대부분 두 가지 문제가 있다.&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;무겁다&lt;/strong&gt;. 메모리를 감시하는 앱이 메모리를 잡아먹는다.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;팝업이 많다&lt;/strong&gt;. 구독 유도, 프리미엄 잠금, 광고.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;필요한 건 숫자 하나와 버튼 하나다. 그래서 직접 만들었다.&lt;/p&gt;

&lt;h2 id=&quot;설계-결정&quot;&gt;설계 결정&lt;/h2&gt;

&lt;h3 id=&quot;appkit-swiftui가-아닌-이유&quot;&gt;AppKit, SwiftUI가 아닌 이유&lt;/h3&gt;

&lt;p&gt;SwiftUI는 macOS 13부터 안정적이다. 하지만 메뉴바 앱에서는 AppKit이 더 적합하다.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;기준&lt;/th&gt;
      &lt;th&gt;SwiftUI&lt;/th&gt;
      &lt;th&gt;AppKit&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;NSStatusItem 제어&lt;/td&gt;
      &lt;td&gt;래핑 필요&lt;/td&gt;
      &lt;td&gt;네이티브&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;메뉴 동적 갱신&lt;/td&gt;
      &lt;td&gt;번거로움&lt;/td&gt;
      &lt;td&gt;직관적&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;번들 크기&lt;/td&gt;
      &lt;td&gt;더 큼&lt;/td&gt;
      &lt;td&gt;최소&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;macOS 12 이하 지원&lt;/td&gt;
      &lt;td&gt;불안정&lt;/td&gt;
      &lt;td&gt;안정&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;메뉴바 앱은 UI가 단순하다. SwiftUI의 선언적 장점이 발휘되지 않는다. AppKit으로 직접 제어하는 편이 가볍고 예측 가능하다.&lt;/p&gt;

&lt;h3 id=&quot;xcode-없이-빌드하는-이유&quot;&gt;Xcode 없이 빌드하는 이유&lt;/h3&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;swiftc &lt;span class=&quot;nt&quot;&gt;-framework&lt;/span&gt; AppKit &lt;span class=&quot;nt&quot;&gt;-O&lt;/span&gt; Sources/&lt;span class=&quot;k&quot;&gt;*&lt;/span&gt;.swift &lt;span class=&quot;nt&quot;&gt;-o&lt;/span&gt; jenaMemory
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;한 줄이면 빌드된다. Xcode 프로젝트 파일(.xcodeproj)은 Git diff가 읽기 어렵다. 설정 변경이 XML 안에 묻힌다. Makefile로 빌드하면 모든 설정이 텍스트로 추적된다.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;make run      # 빌드 + 즉시 실행
make build    # .app 번들 생성
make install  # ~/Applications에 설치
make pkg      # .pkg 인스톨러 생성
make dmg      # .dmg 배포 이미지 생성
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;아키텍처-8개-파일-역할-분리&quot;&gt;아키텍처: 8개 파일, 역할 분리&lt;/h2&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;┌──────────────────────────────────────────────┐
│  main.swift → AppDelegate                    │
│       │                                      │
│       ▼                                      │
│  MenuBarController (메인 컨트롤러)             │
│       │                                      │
│       ├── MemoryMonitor (시스템 메모리 조회)    │
│       ├── PrivilegedExecutor (권한 실행)       │
│       ├── SettingsWindowController (설정)      │
│       ├── AboutWindowController (정보)         │
│       └── Localization (9개 언어)              │
└──────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;파일 수가 8개다. 의도적이다. 메뉴바 유틸리티에 레이어드 아키텍처나 MVVM은 과잉이다. 역할별로 파일을 나누되, 추상화 계층은 만들지 않았다.&lt;/p&gt;

&lt;h3 id=&quot;menubarcontroller--모든-것의-중심&quot;&gt;MenuBarController — 모든 것의 중심&lt;/h3&gt;

&lt;p&gt;이 파일이 앱의 중심이다. NSStatusItem을 생성하고, 2초 타이머로 메모리를 갱신하고, 메뉴를 구성한다.&lt;/p&gt;

&lt;p&gt;메뉴바 아이콘은 RAM 칩 모양이다. 메모리 사용률에 따라 채워지는 비율이 달라진다. 라이트/다크 모드에 자동 대응한다. 16x18 픽셀에 이 모든 정보를 담는다.&lt;/p&gt;

&lt;h3 id=&quot;memorymonitor--darwin-mach-api-직접-호출&quot;&gt;MemoryMonitor — Darwin Mach API 직접 호출&lt;/h3&gt;

&lt;p&gt;메모리 통계를 Foundation이 아닌 Darwin Mach API로 조회한다. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vm_statistics64&lt;/code&gt;를 직접 호출한다. Foundation을 거치면 불필요한 오버헤드가 생긴다. 2초마다 호출되는 함수에서 오버헤드는 누적된다.&lt;/p&gt;

&lt;p&gt;메모리 계산 모델은 macOS Activity Monitor와 일치시켰다.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;항목&lt;/th&gt;
      &lt;th&gt;계산&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;사용 중&lt;/td&gt;
      &lt;td&gt;Wired + Active + Compressed&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;캐시&lt;/td&gt;
      &lt;td&gt;Inactive (필요 시 회수 가능)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;여유&lt;/td&gt;
      &lt;td&gt;Free + Inactive&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;사용률&lt;/td&gt;
      &lt;td&gt;사용 중 ÷ 전체 × 100&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Inactive 메모리를 “여유”에 포함한다. macOS는 Inactive를 캐시처럼 관리하며, 새 프로세스가 요청하면 즉시 회수한다.&lt;/p&gt;

&lt;h3 id=&quot;privilegedexecutor--2단계-권한-상승&quot;&gt;PrivilegedExecutor — 2단계 권한 상승&lt;/h3&gt;

&lt;p&gt;메모리 최적화는 시스템의 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;purge&lt;/code&gt; 명령을 실행한다. 관리자 권한이 필요하다.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;첫 실행:
  AppleScript → 시스템 암호 입력창 → sudoers 규칙 설치 → purge 실행

이후 실행:
  sudoers 규칙 확인 → 암호 없이 purge 실행
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;첫 실행에서 한 번만 암호를 입력하면, 이후에는 묻지 않는다. sudoers 규칙을 설치하여 해당 명령만 무암호로 허용한다. 전체 sudo 권한을 열지 않는다.&lt;/p&gt;

&lt;p&gt;임시 파일은 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;defer&lt;/code&gt;로 자동 삭제한다. 권한 관련 코드에서 임시 파일이 남으면 보안 위험이다.&lt;/p&gt;

&lt;h2 id=&quot;자동-최적화&quot;&gt;자동 최적화&lt;/h2&gt;

&lt;p&gt;여유 메모리가 설정한 임계값 이하로 떨어지면 자동으로 최적화를 실행한다. 60초 쿨다운을 둔다. 이 쿨다운이 없으면 임계값 근처에서 최적화가 반복 실행된다.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;2초마다 메모리 확인
  │
  ├── 여유 &amp;gt; 임계값 → 대기
  └── 여유 ≤ 임계값
       ├── 마지막 실행 후 60초 미경과 → 대기
       └── 60초 경과 → 자동 최적화 실행
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;다국어-지원-9개-언어&quot;&gt;다국어 지원: 9개 언어&lt;/h2&gt;

&lt;p&gt;한국어, 영어, 중국어, 일본어, 스페인어, 독일어, 프랑스어, 러시아어, 힌디어를 지원한다.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.strings&lt;/code&gt; 파일이나 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.lproj&lt;/code&gt; 번들을 쓰지 않았다. Swift enum으로 모든 문자열을 관리한다. 메뉴바 앱에서 Xcode 로컬라이제이션 시스템은 과잉이다.&lt;/p&gt;

&lt;p&gt;언어를 변경하면 메뉴, 설정 창, 정보 창이 즉시 갱신된다. 앱 재시작이 필요 없다. NotificationCenter로 변경 이벤트를 전파하고, 각 컨트롤러가 UI를 다시 그린다.&lt;/p&gt;

&lt;h2 id=&quot;dock에-아이콘이-없다&quot;&gt;Dock에 아이콘이 없다&lt;/h2&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;LSUIElement = true&lt;/code&gt; 설정으로 Dock에서 숨긴다. 메뉴바 앱은 Dock에 있을 이유가 없다. 항상 메뉴바에 있고, 설정 창을 열 때만 포커스를 받는다. macOS의 시스템 유틸리티들이 따르는 패턴이다.&lt;/p&gt;

&lt;h2 id=&quot;배포-dmg와-pkg&quot;&gt;배포: DMG와 PKG&lt;/h2&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;make dmg    # 디스크 이미지 (.dmg)
make pkg    # 패키지 인스톨러 (.pkg)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;두 형식 모두 Makefile 한 줄로 생성한다. 코드 서명과 노터라이제이션은 별도 처리가 필요하지만, 빌드 자체는 자동화되어 있다.&lt;/p&gt;

&lt;h2 id=&quot;정리&quot;&gt;정리&lt;/h2&gt;

&lt;p&gt;메뉴바 유틸리티에 필요한 것은 많지 않다. AppKit, Mach API, Makefile. 8개 파일. 외부 의존성 0개. Xcode 프로젝트 0개.&lt;/p&gt;

&lt;p&gt;복잡한 도구가 필요하지 않을 때, 도구를 줄이는 것도 설계다.&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;strong&gt;jenaMemory&lt;/strong&gt;는 MIT 오픈소스다.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/jenalab-com/jenaMemory&quot;&gt;GitHub&lt;/a&gt; · &lt;a href=&quot;https://github.com/jenalab-com/jenaMemory/releases/latest/download/jenaMemory-1.0.0.dmg&quot;&gt;DMG 다운로드&lt;/a&gt;&lt;/p&gt;
</description>
        <pubDate>Sat, 14 Mar 2026 19:00:00 +0900</pubDate>
        <link>https://jenalab-inc.github.io/macos/2026/03/14/jenamemory-macos-menubar-app.html</link>
        <guid isPermaLink="true">https://jenalab-inc.github.io/macos/2026/03/14/jenamemory-macos-menubar-app.html</guid>
        
        
        <category>macos</category>
        
      </item>
    
      <item>
        <title>Cloudflare R2 마이그레이션과 Scheduler 모듈화 리팩토링</title>
        <description>&lt;h2 id=&quot;part-1-supabase-storage에서-cloudflare-r2로&quot;&gt;Part 1: Supabase Storage에서 Cloudflare R2로&lt;/h2&gt;

&lt;h3 id=&quot;마이그레이션-동기&quot;&gt;마이그레이션 동기&lt;/h3&gt;

&lt;p&gt;Supabase Storage는 초기 개발에 편리했다. 서비스가 성장하면서 한계가 드러났다.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;기준&lt;/th&gt;
      &lt;th&gt;Supabase Storage&lt;/th&gt;
      &lt;th&gt;Cloudflare R2&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;비용&lt;/td&gt;
      &lt;td&gt;무료 티어 한정, 이후 급증&lt;/td&gt;
      &lt;td&gt;10GB 무료, 이후 $0.015/GB&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Egress 비용&lt;/td&gt;
      &lt;td&gt;있음&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;무료&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;CDN&lt;/td&gt;
      &lt;td&gt;Supabase CDN&lt;/td&gt;
      &lt;td&gt;Cloudflare 글로벌 CDN 자동&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;커스텀 도메인&lt;/td&gt;
      &lt;td&gt;제한적&lt;/td&gt;
      &lt;td&gt;자유 설정&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Presigned URL&lt;/td&gt;
      &lt;td&gt;미지원&lt;/td&gt;
      &lt;td&gt;S3 호환 API 완전 지원&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Egress 비용이 결정적이었다. 동화책 서비스는 이미지 트래픽이 많다. 페이지당 이미지가 있고, 사용자가 반복 열람한다. R2는 Egress가 무료다.&lt;/p&gt;

&lt;h3 id=&quot;r2-구성&quot;&gt;R2 구성&lt;/h3&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;환경&lt;/th&gt;
      &lt;th&gt;버킷&lt;/th&gt;
      &lt;th&gt;공개 URL&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;개발&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;myapp-dev&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cdn-dev.example.com&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;운영&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;myapp&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cdn.example.com&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;디렉토리 구조를 Supabase 버킷과 1:1 매핑하여 마이그레이션의 복잡도를 최소화했다.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;기존 Supabase 버킷&lt;/th&gt;
      &lt;th&gt;R2 디렉토리&lt;/th&gt;
      &lt;th&gt;용도&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;media&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;media/&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;장면 이미지, PDF, 미리보기&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;shared&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;shared/&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;폰트, 공지 이미지&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;(신규)&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;assets/&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;캐릭터 이미지&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;presigned-url-워크플로우&quot;&gt;Presigned URL 워크플로우&lt;/h3&gt;

&lt;p&gt;기존에는 클라이언트가 Supabase SDK로 직접 업로드했다. R2에서는 Presigned URL 패턴으로 전환했다.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Client → POST /api/upload/presign
         { bucket: &quot;assets&quot;, path: &quot;abc.jpg&quot;, contentType: &quot;image/jpeg&quot; }
       ← { uploadUrl: &quot;https://...(서명된 URL)&quot;, publicUrl: &quot;https://cdn.example.com/assets/abc.jpg&quot; }

Client → PUT uploadUrl (파일 바이너리 전송)

Client → publicUrl로 접근
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;이 패턴의 장점:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Backend가 파일을 중계하지 않음&lt;/strong&gt;: 대역폭 절약&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;권한 검증을 Backend에서 수행&lt;/strong&gt;: Presign 요청 시 인증 확인&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;클라이언트 SDK 불필요&lt;/strong&gt;: 표준 HTTP PUT으로 업로드&lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;레거시-url-호환-전략&quot;&gt;레거시 URL 호환 전략&lt;/h3&gt;

&lt;p&gt;기존 DB에는 Supabase Storage URL이 저장되어 있다. 한꺼번에 바꾸면 위험하다. 점진적 전환을 택했다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;기존 Supabase URL: 유지 (Supabase Storage public 설정 유지)&lt;/li&gt;
  &lt;li&gt;새 업로드: R2 URL로 저장&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;deleteFile&lt;/code&gt;: R2 URL만 삭제, Supabase URL은 warn 로그만 출력&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;시간이 지나면서 자연스럽게 R2 URL 비율이 높아지고, 충분히 전환된 후 Supabase Storage를 제거한다.&lt;/p&gt;

&lt;h3 id=&quot;변경-범위&quot;&gt;변경 범위&lt;/h3&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;앱&lt;/th&gt;
      &lt;th&gt;변경 사항&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Backend&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;supabase-storage.ts&lt;/code&gt; → &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;r2-storage.ts&lt;/code&gt; (동일 인터페이스)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Scheduler&lt;/td&gt;
      &lt;td&gt;Supabase SDK 제거, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;uploadToR2()&lt;/code&gt; 함수 추가&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Admin&lt;/td&gt;
      &lt;td&gt;Supabase 직접 업로드 → Presigned URL 방식&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Web&lt;/td&gt;
      &lt;td&gt;PDF 생성 시 Presigned URL 사용&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;r2-storage.ts&lt;/code&gt;는 기존 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;supabase-storage.ts&lt;/code&gt;와 동일한 함수 시그니처를 유지한다. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;uploadImage&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;downloadImage&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;deleteFile&lt;/code&gt; — import 경로만 바꾸면 된다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;part-2-scheduler-모듈화-리팩토링&quot;&gt;Part 2: Scheduler 모듈화 리팩토링&lt;/h2&gt;

&lt;h3 id=&quot;문제-638줄-단일-파일&quot;&gt;문제: 638줄 단일 파일&lt;/h3&gt;

&lt;p&gt;R2 마이그레이션을 진행하면서 Scheduler의 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;src/index.ts&lt;/code&gt;가 638줄이라는 사실을 직면했다. DB 스키마, Lock 관리, 토스페이먼츠 API, 자동 결제, 공지 발행, Job 복구, Job 처리 루프가 모두 하나의 파일에 있었다.&lt;/p&gt;

&lt;h3 id=&quot;모듈화-원칙&quot;&gt;모듈화 원칙&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;역할 단위 분리&lt;/strong&gt;와 &lt;strong&gt;의존성 주입&lt;/strong&gt; 두 가지 원칙으로 리팩토링했다.&lt;/p&gt;

&lt;h3 id=&quot;변경-전&quot;&gt;변경 전&lt;/h3&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;scheduler/src/
├── constants.ts
├── index.ts              # 638줄 — 모든 로직
└── services/
    └── generation.service.ts
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;변경-후&quot;&gt;변경 후&lt;/h3&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;scheduler/src/
├── constants.ts
├── index.ts              # ~100줄 — 메인 루프만
├── db/
│   ├── schema.ts         # Drizzle 테이블 정의
│   └── client.ts         # DB 연결 팩토리
├── utils/
│   └── lock.ts           # Lock 파일 관리
└── services/
    ├── payment-gateway.ts    # 결제 API 유틸
    ├── publisher.service.ts  # 예약 발행
    ├── billing.service.ts    # 자동 결제 (3개 함수)
    ├── recovery.service.ts   # Stale Job 복구
    ├── job.service.ts        # Job 처리 루프
    └── generation.service.ts # AI 생성 파이프라인
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;db-클라이언트-팩토리-패턴&quot;&gt;DB 클라이언트 팩토리 패턴&lt;/h3&gt;

&lt;p&gt;모듈 스코프 변수를 캡처하는 대신, &lt;strong&gt;팩토리 함수&lt;/strong&gt;로 DB 클라이언트를 생성하고 각 서비스에 주입한다.&lt;/p&gt;

&lt;p&gt;DB 클라이언트 팩토리 함수가 연결을 생성하고, 메인 엔트리에서 이를 각 서비스에 주입한다. 모든 서비스 함수가 DB 클라이언트를 첫 번째 파라미터로 받는다. 테스트 시 Mock DB를 주입할 수 있고, 함수가 &lt;strong&gt;외부 상태에 의존하지 않는&lt;/strong&gt; 순수한 구조가 된다.&lt;/p&gt;

&lt;h3 id=&quot;토스-api-시그니처-변경&quot;&gt;토스 API 시그니처 변경&lt;/h3&gt;

&lt;p&gt;기존에 모듈 스코프 변수를 클로저로 캡처하던 방식을 명시적 파라미터로 전환했다.&lt;/p&gt;

&lt;p&gt;기존에는 모듈 스코프 변수를 클로저로 캡처하여 암묵적으로 사용했다. 리팩토링 후 모든 외부 의존성을 명시적 파라미터로 전달한다. 결제 API 키, DB 클라이언트 등이 함수 시그니처에 드러난다.&lt;/p&gt;

&lt;h3 id=&quot;indexts의-역할&quot;&gt;index.ts의 역할&lt;/h3&gt;

&lt;p&gt;리팩토링 후 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;index.ts&lt;/code&gt;는 &lt;strong&gt;오케스트레이터&lt;/strong&gt; 역할만 한다.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;index.ts 담당:
1. 환경변수 검증 (DATABASE_URL 필수)
2. Lock 획득 (중복 실행 방지)
3. Signal 핸들러 등록 (SIGTERM, SIGINT)
4. DB 클라이언트 생성
5. 3개 타이머 등록:
   - 30초: Job 처리
   - 60초: 공지 발행 + Job 복구
   - 1시간: 자동 결제
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;무변경-보장&quot;&gt;무변경 보장&lt;/h3&gt;

&lt;p&gt;동작 변경은 없다. 로직 자체는 그대로이며 파일 구조만 변경했다. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tsup&lt;/code&gt;이 단일 번들로 컴파일하므로 배포에도 영향 없다. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;yarn build:scheduler&lt;/code&gt;로 빌드 검증 후 배포했다.&lt;/p&gt;

&lt;h2 id=&quot;두-작업의-연관성&quot;&gt;두 작업의 연관성&lt;/h2&gt;

&lt;p&gt;R2 마이그레이션과 모듈화를 같은 날 진행했다. 638줄 파일에서 Storage 코드를 찾아 교체하려니 비효율적이었다. 모듈을 먼저 분리하니 변경 범위가 명확해졌다.&lt;/p&gt;

&lt;p&gt;리팩토링은 미루면 영원히 하지 않는다. 필요한 시점에 필요한 만큼 하는 편이 낫다.&lt;/p&gt;

&lt;p&gt;다음 글은 콘텐츠 타입 확장 설계를 다룬다.&lt;/p&gt;
</description>
        <pubDate>Fri, 06 Mar 2026 19:00:00 +0900</pubDate>
        <link>https://jenalab-inc.github.io/infrastructure/2026/03/06/storida-r2-migration-modularization.html</link>
        <guid isPermaLink="true">https://jenalab-inc.github.io/infrastructure/2026/03/06/storida-r2-migration-modularization.html</guid>
        
        
        <category>infrastructure</category>
        
      </item>
    
      <item>
        <title>토큰 기반 요금제 설계 — AI SaaS의 과금 모델</title>
        <description>&lt;h2 id=&quot;문제-ai-비용은-변동적이다&quot;&gt;문제: AI 비용은 변동적이다&lt;/h2&gt;

&lt;p&gt;동화 3장면과 20장면의 AI API 비용은 7배 차이가 난다. “월 N권”으로 요금제를 설계하면 1권의 정의가 모호해진다. 사용자도 서비스도 손해를 본다.&lt;/p&gt;

&lt;p&gt;토큰 기반 과금으로 이 문제를 풀었다.&lt;/p&gt;

&lt;h2 id=&quot;토큰-단위-설계&quot;&gt;토큰 단위 설계&lt;/h2&gt;

&lt;h3 id=&quot;ai-실사용-토큰과의-관계&quot;&gt;AI 실사용 토큰과의 관계&lt;/h3&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Storida 토큰 = AI API 실사용 토큰 ÷ 10
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;AI API가 소비하는 토큰을 10으로 나눈 값이다. 10으로 나누면 사용자가 다루기 편한 숫자가 된다.&lt;/p&gt;

&lt;h3 id=&quot;장면당-소모-토큰&quot;&gt;장면당 소모 토큰&lt;/h3&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;항목&lt;/th&gt;
      &lt;th&gt;AI 토큰&lt;/th&gt;
      &lt;th&gt;Storida 토큰&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;이미지 생성 (Gemini)&lt;/td&gt;
      &lt;td&gt;~1,800&lt;/td&gt;
      &lt;td&gt;~180&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;작문 생성 (Claude)&lt;/td&gt;
      &lt;td&gt;~500~700&lt;/td&gt;
      &lt;td&gt;~50~70&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;장면당 합계&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;~2,500&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;~250&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;이 수치를 바탕으로 역산하면:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;장면 수&lt;/th&gt;
      &lt;th&gt;토큰 소모&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;3장면 (최소)&lt;/td&gt;
      &lt;td&gt;750&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;5장면&lt;/td&gt;
      &lt;td&gt;1,250&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;10장면&lt;/td&gt;
      &lt;td&gt;2,500&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;20장면&lt;/td&gt;
      &lt;td&gt;5,000&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;30장면 (최대)&lt;/td&gt;
      &lt;td&gt;7,500&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h2 id=&quot;요금제-플랜-구성&quot;&gt;요금제 플랜 구성&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt; &lt;/th&gt;
      &lt;th&gt;Start&lt;/th&gt;
      &lt;th&gt;Basic&lt;/th&gt;
      &lt;th&gt;Standard&lt;/th&gt;
      &lt;th&gt;Artist&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;가격&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;무료&lt;/td&gt;
      &lt;td&gt;9,900원/월&lt;/td&gt;
      &lt;td&gt;19,900원/월&lt;/td&gt;
      &lt;td&gt;39,900원/월&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;월 토큰&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;2,500&lt;/td&gt;
      &lt;td&gt;15,000&lt;/td&gt;
      &lt;td&gt;40,000&lt;/td&gt;
      &lt;td&gt;120,000&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;최대 장면&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;3 (고정)&lt;/td&gt;
      &lt;td&gt;10&lt;/td&gt;
      &lt;td&gt;20&lt;/td&gt;
      &lt;td&gt;30&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;3장면 기준&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;~3권&lt;/td&gt;
      &lt;td&gt;~20권&lt;/td&gt;
      &lt;td&gt;~53권&lt;/td&gt;
      &lt;td&gt;~160권&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;10장면 기준&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;—&lt;/td&gt;
      &lt;td&gt;~6권&lt;/td&gt;
      &lt;td&gt;~16권&lt;/td&gt;
      &lt;td&gt;~48권&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;설계-원칙&quot;&gt;설계 원칙&lt;/h3&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Start 플랜 = 체험&lt;/strong&gt;: 2,500 토큰으로 3장면 3권(750 × 3 = 2,250) + 여유. 3장면 고정으로 과도한 토큰 소모 방지&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Basic = 입문 유료&lt;/strong&gt;: 10장면까지 허용, 6권 분량. 커스텀 캐릭터 개방&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Standard = 주력&lt;/strong&gt;: 20장면, PDF 다운로드, POD 신청 가능&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Artist = 프로&lt;/strong&gt;: 30장면, 커스텀 작문 스타일 개방&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id=&quot;토큰-관리-정책&quot;&gt;토큰 관리 정책&lt;/h2&gt;

&lt;h3 id=&quot;이월-없음&quot;&gt;이월 없음&lt;/h3&gt;

&lt;p&gt;매월 구독 갱신 시 구독 토큰 잔액이 초기화된다. 미사용 토큰은 소멸한다.&lt;/p&gt;

&lt;p&gt;구독 갱신 시 보유 토큰을 플랜의 월간 토큰으로 초기화하고, 다음 갱신일을 기록한다.&lt;/p&gt;

&lt;p&gt;이월을 허용하면 토큰이 축적된다. 한꺼번에 소진되면 AI 비용이 급증한다. 월간 사용량을 예측 가능하게 유지하기 위해 이월을 차단했다.&lt;/p&gt;

&lt;h3 id=&quot;단건-구매-토큰--영구-유효&quot;&gt;단건 구매 토큰 — 영구 유효&lt;/h3&gt;

&lt;p&gt;구독 토큰이 부족할 때 추가로 구매하는 단건 토큰은 &lt;strong&gt;만료 없이 영구 유효&lt;/strong&gt;하다.&lt;/p&gt;

&lt;p&gt;사용자 테이블에는 두 종류의 토큰 잔액이 존재한다. 구독 토큰(매월 초기화)과 단건 구매 토큰(영구 유효)이다.&lt;/p&gt;

&lt;h3 id=&quot;차감-순서&quot;&gt;차감 순서&lt;/h3&gt;

&lt;p&gt;생성 시 단건 구매 토큰을 먼저 차감하고, 부족분은 구독 토큰에서 차감한다.&lt;/p&gt;

&lt;p&gt;단건 구매 토큰을 먼저 소진한다. “돈을 주고 산 토큰이 먼저 사용된다”는 사용자 기대에 맞다. 구독 토큰은 어차피 매월 초기화된다. 단건 토큰을 아끼는 편이 사용자에게 유리하다.&lt;/p&gt;

&lt;h2 id=&quot;db-스키마&quot;&gt;DB 스키마&lt;/h2&gt;

&lt;h3 id=&quot;credit_logs--모든-변동-추적&quot;&gt;credit_logs — 모든 변동 추적&lt;/h3&gt;

&lt;p&gt;크레딧 이력은 모든 충전과 사용을 추적한다. 각 변동마다 유형(충전/사용), 출처, 변동량, 변동 후 잔액 스냅샷을 기록한다. 생성된 콘텐츠나 구매 건에 대한 참조도 선택적으로 포함된다.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;source&lt;/th&gt;
      &lt;th&gt;type&lt;/th&gt;
      &lt;th&gt;발생 시점&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;subscription_reset&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;charge&lt;/td&gt;
      &lt;td&gt;매월 구독 갱신&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;subscription_signup&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;charge&lt;/td&gt;
      &lt;td&gt;신규 구독 가입&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;addon_purchase&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;charge&lt;/td&gt;
      &lt;td&gt;단건 토큰 구매&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;book_generation&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;usage&lt;/td&gt;
      &lt;td&gt;동화 생성&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;admin_adjustment&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;charge/usage&lt;/td&gt;
      &lt;td&gt;관리자 수동 조정&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;설계-결정-잔액-스냅샷&quot;&gt;설계 결정: 잔액 스냅샷&lt;/h3&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;balance_after&lt;/code&gt;와 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;addon_balance_after&lt;/code&gt; 필드는 변동 시점의 잔액 스냅샷이다. 이렇게 설계한 이유:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;시계열 잔액 조회&lt;/strong&gt;: 특정 시점의 잔액을 DB 한 번 조회로 확인 가능&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;불일치 감지&lt;/strong&gt;: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;balance_after&lt;/code&gt;의 연속성으로 토큰 누락/이중 차감을 감지 가능&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;감사(audit)&lt;/strong&gt;: CS 문의 시 정확한 토큰 변동 이력 제공&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id=&quot;비즈니스-로직-생성-가능-여부-검증&quot;&gt;비즈니스 로직: 생성 가능 여부 검증&lt;/h2&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;// 검증 로직
// 1. 필요 토큰 = 장면당 토큰(250) × 장면 수
// 2. 보유 토큰 = 구독 잔여 + 추가 구매
// 3. 부족하면 부족량과 함께 에러 반환
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;부족 시 클라이언트에 부족량(shortfall)을 반환하여, 단건 구매 유도 팝업에서 &lt;strong&gt;정확한 필요 토큰 수&lt;/strong&gt;를 표시한다.&lt;/p&gt;

&lt;h2 id=&quot;토큰-선차감-전략&quot;&gt;토큰 선차감 전략&lt;/h2&gt;

&lt;p&gt;토큰은 &lt;strong&gt;생성 요청 시 즉시 차감&lt;/strong&gt;한다. 생성 완료 후 차감하면, 요청과 완료 사이(2~4분)에 다른 요청이 같은 토큰을 사용하는 &lt;strong&gt;이중 소진&lt;/strong&gt; 문제가 발생한다.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;요청 시: 토큰 차감 → Job 등록 → 즉시 응답
생성 실패 시: 토큰 환불 (credit_logs에 refund 기록)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;이 방식의 트레이드오프는 생성 실패 시 환불 로직이 필요하다는 점이지만, 이중 소진 방지가 더 중요하다.&lt;/p&gt;

&lt;h2 id=&quot;결론&quot;&gt;결론&lt;/h2&gt;

&lt;p&gt;3장면은 750 토큰, 10장면은 2,500 토큰. 단순한 공식이 사용자의 비용 예측을 돕고, 서비스의 수익성을 보장한다.&lt;/p&gt;

&lt;p&gt;다음 글은 Cloudflare R2 스토리지 마이그레이션과 Scheduler 모듈화 리팩토링을 다룬다.&lt;/p&gt;
</description>
        <pubDate>Wed, 04 Mar 2026 19:00:00 +0900</pubDate>
        <link>https://jenalab-inc.github.io/business/2026/03/04/storida-token-pricing.html</link>
        <guid isPermaLink="true">https://jenalab-inc.github.io/business/2026/03/04/storida-token-pricing.html</guid>
        
        
        <category>business</category>
        
      </item>
    
      <item>
        <title>멀티 캐릭터 시스템 — Claude Vision으로 이미지 일관성 확보하기</title>
        <description>&lt;h2 id=&quot;문제-페이지마다-달라지는-캐릭터&quot;&gt;문제: 페이지마다 달라지는 캐릭터&lt;/h2&gt;

&lt;p&gt;1페이지에서 갈색 머리의 소녀가 3페이지에서 금발이 된다. 5페이지에서는 완전히 다른 사람이다. AI 이미지 생성의 고질적 문제다.&lt;/p&gt;

&lt;p&gt;기존 시스템은 캐릭터 이미지를 “참조 이미지”로 전달했다. Gemini가 이미지에서 무엇을 봐야 하는지 가이드가 없었다.&lt;/p&gt;

&lt;h2 id=&quot;해결-전략-이미지-분석--텍스트-설명--프롬프트-주입&quot;&gt;해결 전략: 이미지 분석 → 텍스트 설명 → 프롬프트 주입&lt;/h2&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;관리자 이미지 업로드 (최대 5장)
         │
         ▼
Claude Vision 분석 ──────▶ 구조화된 설명 (영문)
                           │
                           ▼
이미지 생성 시 프롬프트에 주입 ──▶ Gemini 이미지 생성
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;이미지를 텍스트로 변환한다. Claude Vision이 캐릭터 이미지를 분석하여 영문 설명을 생성한다. 이 설명을 Gemini 이미지 프롬프트에 주입한다.&lt;/p&gt;

&lt;h2 id=&quot;db-스키마-변경&quot;&gt;DB 스키마 변경&lt;/h2&gt;

&lt;h3 id=&quot;characters-테이블&quot;&gt;characters 테이블&lt;/h3&gt;

&lt;p&gt;캐릭터 테이블에 분석 결과(한국어/영문 설명)를 JSONB로 저장하는 컬럼을 추가했다.&lt;/p&gt;

&lt;h3 id=&quot;content_assets-테이블&quot;&gt;content_assets 테이블&lt;/h3&gt;

&lt;p&gt;콘텐츠-캐릭터 연결 테이블에 커스텀 이름과 주인공 여부 컬럼을 추가했다. 이 테이블이 콘텐츠와 캐릭터의 N:M 관계를 관리하며, 주인공/보조 캐릭터를 구분한다.&lt;/p&gt;

&lt;h2 id=&quot;claude-vision-분석-api&quot;&gt;Claude Vision 분석 API&lt;/h2&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;POST /api/assets/analyze
// 1~5장의 이미지 URL을 전달
// 한국어/영문 캐릭터 외형 설명을 반환
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;분석-프롬프트&quot;&gt;분석 프롬프트&lt;/h3&gt;

&lt;p&gt;Claude에게 캐릭터 이미지를 전달한다. 이미지 생성 AI가 일관되게 재현할 수 있는 시각적 특징을 추출하도록 지시한다.&lt;/p&gt;

&lt;p&gt;분석 결과 예시:&lt;/p&gt;

&lt;div class=&quot;language-json highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;description_ko&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;갈색 곱슬머리의 소녀, 큰 갈색 눈,
    둥근 볼, 분홍색 원피스, 운동화 착용&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;description_en&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;A young girl with curly brown hair,
    large brown eyes, round cheeks, wearing a pink dress
    and sneakers, approximately 6-7 years old&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;한국어 설명은 관리자가 확인·편집할 수 있고, 영문 설명은 Gemini 프롬프트에 직접 사용된다.&lt;/p&gt;

&lt;h2 id=&quot;멀티-캐릭터-생성-요청&quot;&gt;멀티 캐릭터 생성 요청&lt;/h2&gt;

&lt;h3 id=&quot;변경-전&quot;&gt;변경 전&lt;/h3&gt;

&lt;p&gt;단일 캐릭터 ID만 전달하는 방식이었다.&lt;/p&gt;

&lt;h3 id=&quot;변경-후&quot;&gt;변경 후&lt;/h3&gt;

&lt;p&gt;최대 3개 캐릭터를 전달하며, 각 캐릭터에 커스텀 이름과 주인공 여부를 지정한다. Zod validation으로 &lt;strong&gt;정확히 1명의 주인공&lt;/strong&gt;이 포함되어야 함을 검증한다. 1~3개 캐릭터 범위도 함께 검증한다.&lt;/p&gt;

&lt;h2 id=&quot;scheduler-프롬프트에-캐릭터-정보-주입&quot;&gt;Scheduler: 프롬프트에 캐릭터 정보 주입&lt;/h2&gt;

&lt;h3 id=&quot;텍스트-생성-claude&quot;&gt;텍스트 생성 (Claude)&lt;/h3&gt;

&lt;p&gt;작문 프롬프트 템플릿에 캐릭터 정보 변수를 추가했다.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;### 주인공 캐릭터
- 이름: {{주인공_이름}}
- 종류: {{캐릭터_종류}}
- 외형: {{캐릭터_외형_설명}}

### 보조 캐릭터
{{보조_캐릭터_정보}}
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;{{캐릭터_외형_설명}}&lt;/code&gt;에 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;analysis_report.description_ko&lt;/code&gt;가, 보조 캐릭터 정보에 각 보조 캐릭터의 이름과 설명이 들어간다.&lt;/p&gt;

&lt;h3 id=&quot;이미지-생성-gemini&quot;&gt;이미지 생성 (Gemini)&lt;/h3&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Main character: A young girl with curly brown hair,
  large brown eyes, round cheeks, wearing a pink dress
  and sneakers. Her name is &quot;토토&quot;.

Supporting character: A small white rabbit with long ears
  and a blue ribbon. Name: &quot;미미&quot;.

[참조 이미지 첨부]

Style: watercolor illustration, soft and gentle...

Scene: 토토와 미미가 숲 속에서 나비를 쫓아갔어요.
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;analysis_report.description_en&lt;/code&gt;이 텍스트로 주입된다. 이미지 AI가 매 페이지에서 동일한 시각적 특징을 재현한다. 참조 이미지만 전달할 때와 차이가 뚜렷하다.&lt;/p&gt;

&lt;h2 id=&quot;web-ui-멀티-선택-인터페이스&quot;&gt;Web UI: 멀티 선택 인터페이스&lt;/h2&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;┌─────────┐  ┌─────────┐  ┌─────────┐
│ ★ 토토  │  │ ✓ 미미  │  │ ✓ 뭉치  │
│  (주인공) │  │  (보조)  │  │  (보조)  │
│ 노란 ring│  │ 파란 ring│  │ 파란 ring│
└─────────┘  └─────────┘  └─────────┘
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;ul&gt;
  &lt;li&gt;첫 번째 선택 = 자동으로 주인공(★ + 노란색 ring)&lt;/li&gt;
  &lt;li&gt;추가 선택(최대 2개) = 보조 캐릭터(✓ + 파란색 ring)&lt;/li&gt;
  &lt;li&gt;토글 방식: 클릭으로 선택/해제&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;이미지-분석의-한계와-대응&quot;&gt;이미지 분석의 한계와 대응&lt;/h2&gt;

&lt;p&gt;Claude Vision 분석에는 한계가 있다.&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;추상적 캐릭터&lt;/strong&gt;: 캐릭터가 실제 사물이 아닌 판타지 존재일 때 설명이 모호해짐&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;다중 이미지 불일치&lt;/strong&gt;: 5장의 이미지가 서로 다른 앵글이나 스타일이면 통합 설명이 어려움&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;색상 정확도&lt;/strong&gt;: 미묘한 색상 차이를 텍스트로 정확히 전달하기 어려움&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;분석 결과를 관리자가 편집할 수 있도록 Textarea를 제공한다. AI 분석은 초안이다. 관리자가 최종 검수한다.&lt;/p&gt;

&lt;h2 id=&quot;성과&quot;&gt;성과&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;지표&lt;/th&gt;
      &lt;th&gt;개선 전&lt;/th&gt;
      &lt;th&gt;개선 후&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;캐릭터 일관성&lt;/td&gt;
      &lt;td&gt;페이지별 외모 변동 빈번&lt;/td&gt;
      &lt;td&gt;핵심 특징(머리색, 의상) 유지&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;캐릭터 수&lt;/td&gt;
      &lt;td&gt;1명만 가능&lt;/td&gt;
      &lt;td&gt;최대 3명&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;프롬프트 품질&lt;/td&gt;
      &lt;td&gt;참조 이미지만 전달&lt;/td&gt;
      &lt;td&gt;텍스트 설명 + 이미지 병행&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;관리자 작업&lt;/td&gt;
      &lt;td&gt;수동 특징 입력&lt;/td&gt;
      &lt;td&gt;AI 분석 + 편집&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;다음 글에서는 토큰 기반 요금제 시스템 설계를 다룬다.&lt;/p&gt;
</description>
        <pubDate>Wed, 18 Feb 2026 19:00:00 +0900</pubDate>
        <link>https://jenalab-inc.github.io/ai/2026/02/18/storida-character-system.html</link>
        <guid isPermaLink="true">https://jenalab-inc.github.io/ai/2026/02/18/storida-character-system.html</guid>
        
        
        <category>ai</category>
        
      </item>
    
  </channel>
</rss>
