<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="ko_KR"><generator uri="https://jekyllrb.com/" version="4.2.2">Jekyll</generator><link href="https://updaun.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://updaun.github.io/" rel="alternate" type="text/html" hreflang="ko_KR" /><updated>2026-06-14T23:55:23+09:00</updated><id>https://updaun.github.io/feed.xml</id><title type="html">Up &amp;amp; Daun</title><subtitle>AWS, Python, 클라우드, 머신러닝, DevOps, 인공지능 등 최신 IT 트렌드와 실무 경험, 자격증 준비 노하우를 공유하는 개발자 블로그입니다. 실전 아키텍처, 개발 팁, 공부 자료까지 모두 제공합니다.</subtitle><author><name>updaun</name><email>your-email@example.com</email></author><entry><title type="html">Django Ninja로 커뮤니티 기능 만들기 — 글·댓글·좋아요, 시니어가 신경 쓰는 설계 2026</title><link href="https://updaun.github.io/django/python/api/2026/06/14/django-ninja-community-features-senior-guide/" rel="alternate" type="text/html" title="Django Ninja로 커뮤니티 기능 만들기 — 글·댓글·좋아요, 시니어가 신경 쓰는 설계 2026" /><published>2026-06-14T00:00:00+09:00</published><updated>2026-06-14T00:00:00+09:00</updated><id>https://updaun.github.io/django/python/api/2026/06/14/django-ninja-community-features-senior-guide</id><content type="html" xml:base="https://updaun.github.io/django/python/api/2026/06/14/django-ninja-community-features-senior-guide/">&lt;h1 id=&quot;django-ninja로-커뮤니티-기능-만들기--글댓글좋아요-시니어가-신경-쓰는-설계-2026&quot;&gt;Django Ninja로 커뮤니티 기능 만들기 — 글·댓글·좋아요, 시니어가 신경 쓰는 설계 2026&lt;/h1&gt;

&lt;p&gt;“게시글 CRUD + 댓글 + 좋아요” — 겉보기엔 &lt;strong&gt;2~3일짜리 기능&lt;/strong&gt;입니다.&lt;/p&gt;

&lt;p&gt;그런데 실서비스에 올리면 금방 이런 일이 생깁니다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;좋아요 수가 &lt;strong&gt;실제와 1~2 차이&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;삭제한 글에 &lt;strong&gt;댓글 알림&lt;/strong&gt;이 계속 감&lt;/li&gt;
  &lt;li&gt;피드 API가 &lt;strong&gt;포스트 20개에 쿼리 200번&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;악성 사용자가 &lt;strong&gt;댓글 폭탄·HTML 삽입&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;“대댓글 7단” UI가 &lt;strong&gt;페이지 로딩 5초&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;시니어 개발자는 &lt;strong&gt;화면 기능 목록&lt;/strong&gt;보다 먼저 &lt;strong&gt;실패 모드(failure mode)&lt;/strong&gt; 를 적습니다. 이 글은 Django Ninja로 &lt;strong&gt;글쓰기·댓글·좋아요&lt;/strong&gt; 커뮤니티를 만들 때, 프로덕션에서 반드시 신경 써야 할 설계·코드·운영 포인트를 정리합니다.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;트랜잭션·동시성은 &lt;a href=&quot;/2026/02/18-django-transaction-management-best-practices/&quot;&gt;Django 트랜잭션 가이드&lt;/a&gt;, 인덱스·쿼리는 &lt;a href=&quot;/2026/02/16-django-database-indexing-performance-optimization/&quot;&gt;DB 인덱싱&lt;/a&gt;·&lt;a href=&quot;/2026/01/14-django-ninja-high-traffic-optimization-strategies/&quot;&gt;고트래픽 최적화&lt;/a&gt;, 인증은 &lt;a href=&quot;/2026/02/26-django-nextjs-jwt-authentication-complete-guide/&quot;&gt;JWT 가이드&lt;/a&gt;와 함께 보세요.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;0-결론부터-시니어-체크리스트-10가지&quot;&gt;0. 결론부터: 시니어 체크리스트 10가지&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;1&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;도메인&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Post·Comment·Like &lt;strong&gt;분리&lt;/strong&gt;, 소프트 삭제 기본&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;2&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;권한&lt;/strong&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;3&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;좋아요&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;유니크 제약 + idempotent API + 카운트 전략&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;4&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;댓글&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;depth 제한&lt;/strong&gt;, 트리 vs flat+path&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;5&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;피드&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;cursor pagination&lt;/strong&gt;, N+1 제거&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;6&lt;/td&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;select_for_update&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;F()&lt;/code&gt; / 트랜잭션&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;7&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;보안&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;HTML sanitize, rate limit, CSRF/JWT&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;8&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;모더레이션&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;신고·숨김·ban, 감사 로그&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;9&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;API&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Ninja Schema·Router 분리, 버전&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;10&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;관측&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;슬로우 쿼리, like drift 알람&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;1-커뮤니티-기능의-범위--mvp-vs-프로덕션&quot;&gt;1. 커뮤니티 기능의 범위 — MVP vs 프로덕션&lt;/h2&gt;

&lt;h3 id=&quot;11-mvp-1주&quot;&gt;1.1 MVP (1주)&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;Post CRUD (제목·본문·작성자)&lt;/li&gt;
  &lt;li&gt;Comment CRUD (1-depth 또는 대댓글 1단)&lt;/li&gt;
  &lt;li&gt;Like toggle (게시글만)&lt;/li&gt;
  &lt;li&gt;목록 + offset pagination&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;12-프로덕션-24주&quot;&gt;1.2 프로덕션 (+2~4주)&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;좋아요 &lt;strong&gt;중복 방지 DB 제약&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;race condition&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;cursor&lt;/strong&gt; 피드&lt;/td&gt;
      &lt;td&gt;offset 깊어질수록 느림&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;댓글 &lt;strong&gt;depth cap&lt;/strong&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;Rate limit&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;like_count&lt;/code&gt; &lt;strong&gt;정합성&lt;/strong&gt; 전략&lt;/td&gt;
      &lt;td&gt;denorm vs realtime&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;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;strong&gt;시니어 조언&lt;/strong&gt;: MVP 스키마를 &lt;strong&gt;처음부터 확장 가능하게&lt;/strong&gt; 짜되, &lt;strong&gt;알림·검색·DM은 Phase 2&lt;/strong&gt;로 미룹니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;2-도메인-모델--post--comment--like&quot;&gt;2. 도메인 모델 — Post · Comment · Like&lt;/h2&gt;

&lt;h3 id=&quot;21-erd-핵심&quot;&gt;2.1 ERD (핵심)&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;erDiagram
  User ||--o{ Post : writes
  User ||--o{ Comment : writes
  User ||--o{ Like : gives
  Post ||--o{ Comment : has
  Post ||--o{ Like : receives
  Comment ||--o{ Comment : replies

  Post {
    uuid id PK
    uuid author_id FK
    string title
    text body
    int like_count
    int comment_count
    datetime created_at
    datetime deleted_at
  }

  Comment {
    uuid id PK
    uuid post_id FK
    uuid author_id FK
    uuid parent_id FK
    int depth
    text body
    datetime deleted_at
  }

  Like {
    uuid id PK
    uuid user_id FK
    uuid post_id FK
    datetime created_at
  }
&lt;/code&gt;&lt;/pre&gt;

&lt;h3 id=&quot;22-모델-코드&quot;&gt;2.2 모델 코드&lt;/h3&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# community/models.py
&lt;/span&gt;&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;uuid&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;django.conf&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;settings&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;django.db&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;django.utils&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;timezone&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;User&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;settings&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AUTH_USER_MODEL&lt;/span&gt;


&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;SoftDeleteQuerySet&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;QuerySet&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;alive&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;filter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;deleted_at__isnull&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;


&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;Post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Model&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;nb&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;UUIDField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;primary_key&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;default&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;uuid&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;uuid4&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;editable&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;False&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;author&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ForeignKey&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;User&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;on_delete&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CASCADE&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;related_name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;posts&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;title&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CharField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;max_length&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;200&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;body&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;TextField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;like_count&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;PositiveIntegerField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;default&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;comment_count&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;PositiveIntegerField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;default&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;is_pinned&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;BooleanField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;default&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;False&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;created_at&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DateTimeField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;auto_now_add&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;db_index&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;updated_at&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DateTimeField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;auto_now&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;deleted_at&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DateTimeField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;blank&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;db_index&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;objects&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;SoftDeleteQuerySet&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;as_manager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;Meta&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;indexes&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Index&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;fields&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;-created_at&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;post_feed_idx&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Index&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;fields&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;author&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;-created_at&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]),&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;soft_delete&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
        &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;deleted_at&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;timezone&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;now&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
        &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;save&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;update_fields&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;deleted_at&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;updated_at&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;


&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;Comment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Model&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;MAX_DEPTH&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;2&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# 본댓글(0) → 대댓글(1) → 대대댓글(2)
&lt;/span&gt;
    &lt;span class=&quot;nb&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;UUIDField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;primary_key&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;default&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;uuid&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;uuid4&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;editable&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;False&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;post&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ForeignKey&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;on_delete&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CASCADE&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;related_name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;comments&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;author&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ForeignKey&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;User&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;on_delete&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CASCADE&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;related_name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;comments&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;parent&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ForeignKey&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;self&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;blank&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;on_delete&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CASCADE&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;related_name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;replies&quot;&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;depth&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;PositiveSmallIntegerField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;default&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;body&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;TextField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;like_count&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;PositiveIntegerField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;default&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;created_at&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DateTimeField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;auto_now_add&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;db_index&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;deleted_at&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DateTimeField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;blank&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;objects&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;SoftDeleteQuerySet&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;as_manager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;Meta&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;indexes&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Index&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;fields&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;post&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;created_at&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]),&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Index&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;fields&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;post&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;parent&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;created_at&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]),&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;


&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;Like&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Model&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;&quot;&quot;게시글 좋아요 — 유저당 1번&quot;&quot;&quot;&lt;/span&gt;
    &lt;span class=&quot;nb&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;UUIDField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;primary_key&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;default&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;uuid&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;uuid4&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;editable&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;False&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;user&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ForeignKey&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;User&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;on_delete&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CASCADE&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;related_name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;likes&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;post&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ForeignKey&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;on_delete&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CASCADE&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;related_name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;likes&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;created_at&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DateTimeField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;auto_now_add&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;Meta&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;constraints&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;UniqueConstraint&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;fields&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;user&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;post&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;uniq_user_post_like&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;indexes&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Index&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;fields&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;post&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;created_at&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]),&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;23-시니어가-모델에서-신경-쓰는-것&quot;&gt;2.3 시니어가 모델에서 신경 쓰는 것&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;피하기&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;PK&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;UUID&lt;/strong&gt; (공개 URL·merge)&lt;/td&gt;
      &lt;td&gt;연속 int 노출&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;삭제&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;soft delete&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;hard delete만&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;like_count&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;denormalized + 주기 검증&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;매번 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;COUNT(*)&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;댓글 트리&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;parent + depth cap&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;무한 재귀&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Comment Like&lt;/td&gt;
      &lt;td&gt;Phase 2&lt;/td&gt;
      &lt;td&gt;MVP에 전부 넣기&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;3-django-ninja-api-구조&quot;&gt;3. Django Ninja API 구조&lt;/h2&gt;

&lt;h3 id=&quot;31-router-분리&quot;&gt;3.1 Router 분리&lt;/h3&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# community/api.py
&lt;/span&gt;&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;ninja&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Router&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;ninja.security&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HttpBearer&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;.schemas&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;PostOut&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;PostIn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;CommentOut&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;CommentIn&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;api&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Router&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;tags&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;community&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;posts_router&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Router&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;tags&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;posts&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;comments_router&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Router&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;tags&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;comments&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;likes_router&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Router&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;tags&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;likes&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;api&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;add_router&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/posts&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;posts_router&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;api&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;add_router&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;comments_router&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;   &lt;span class=&quot;c1&quot;&gt;# /posts/{id}/comments
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;api&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;add_router&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;likes_router&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;      &lt;span class=&quot;c1&quot;&gt;# /posts/{id}/like
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;32-schema--목록-vs-상세-분리&quot;&gt;3.2 Schema — 목록 vs 상세 분리&lt;/h3&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# community/schemas.py
&lt;/span&gt;&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;ninja&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Schema&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;uuid&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;UUID&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;datetime&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;datetime&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;AuthorOut&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Schema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;nb&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;UUID&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;username&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;avatar_url&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;PostListOut&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Schema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;nb&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;UUID&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;title&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;excerpt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;           &lt;span class=&quot;c1&quot;&gt;# 본문 120자 — 목록용
&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;author&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;AuthorOut&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;like_count&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;comment_count&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;liked_by_me&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;bool&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;created_at&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;datetime&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;PostDetailOut&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;PostListOut&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;PostIn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Schema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;title&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;CommentOut&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Schema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;nb&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;UUID&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;author&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;AuthorOut&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;depth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;parent_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;UUID&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;like_count&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;created_at&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;datetime&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;시니어 포인트&lt;/strong&gt;: 목록 API에 &lt;strong&gt;전문 body&lt;/strong&gt; 넣지 않기 — 대역폭·캐시 효율.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;4-글쓰기post--시니어가-챙기는-것&quot;&gt;4. 글쓰기(Post) — 시니어가 챙기는 것&lt;/h2&gt;

&lt;h3 id=&quot;41-생성수정삭제&quot;&gt;4.1 생성·수정·삭제&lt;/h3&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# community/services/posts.py
&lt;/span&gt;&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;django.db&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;transaction&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;django.shortcuts&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;get_object_or_404&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;community.models&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Post&lt;/span&gt;

&lt;span class=&quot;o&quot;&gt;@&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;transaction&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;atomic&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;create_post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;author&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;title&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;body&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;sanitize_html&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# XSS — 아래 8장
&lt;/span&gt;    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;objects&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;create&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;author&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;author&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;title&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;title&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;strip&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;


&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;update_post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;actor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;title&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;assert_can_edit_post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;actor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;title&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;title&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;strip&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;body&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;sanitize_html&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;save&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;update_fields&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;title&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;body&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;updated_at&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;


&lt;span class=&quot;o&quot;&gt;@&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;transaction&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;atomic&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;delete_post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;actor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;assert_can_edit_post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;actor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;soft_delete&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;# 댓글은 cascade hard 아님 — soft_delete 유지 or 일괄 숨김 task
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;42-권한&quot;&gt;4.2 권한&lt;/h3&gt;

&lt;div class=&quot;language-python 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;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;assert_can_edit_post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;deleted_at&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;raise&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;PermissionError&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;deleted&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;author_id&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;is_staff&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;raise&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;PermissionError&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;forbidden&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&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;본인만 수정&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;기본&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;staff 삭제/숨김&lt;/strong&gt;&lt;/td&gt;
      &lt;td&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;404 (일반), 410+reason (작성자)&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;43-피드-api--cursor-pagination&quot;&gt;4.3 피드 API — cursor pagination&lt;/h3&gt;

&lt;p&gt;offset &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;?page=500&lt;/code&gt; 은 &lt;strong&gt;느려집니다&lt;/strong&gt;. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;(created_at, id)&lt;/code&gt; 커서를 씁니다.&lt;/p&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# community/api/posts.py
&lt;/span&gt;&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;ninja&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Router&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Query&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;typing&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;List&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;uuid&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;UUID&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;datetime&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;datetime&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;posts_router&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Router&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;

&lt;span class=&quot;o&quot;&gt;@&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;posts_router&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;List&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;PostListOut&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;list_posts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;cursor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;datetime&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;limit&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Query&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;20&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;le&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;50&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;qs&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;Post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;objects&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;alive&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;select_related&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;author&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;order_by&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;-created_at&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;-id&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;cursor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;qs&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;qs&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;filter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;created_at__lt&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;cursor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;posts&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;list&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;qs&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;limit&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;has_next&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;len&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;posts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;limit&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;posts&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;posts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[:&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;limit&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;liked_ids&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;get_liked_post_ids&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;p&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;posts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;serialize_post_list&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;liked_by_me&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;liked_ids&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;p&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;posts&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;N+1 방지&lt;/strong&gt;: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;select_related(&apos;author&apos;)&lt;/code&gt;, 좋아요는 &lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WHERE post_id IN (...)&lt;/code&gt; 1쿼리&lt;/strong&gt;.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;5-댓글comment--트리깊이정렬&quot;&gt;5. 댓글(Comment) — 트리·깊이·정렬&lt;/h2&gt;

&lt;h3 id=&quot;51-depth-cap-필수&quot;&gt;5.1 depth cap (필수)&lt;/h3&gt;

&lt;div class=&quot;language-python 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;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;django.db&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;transaction&lt;/span&gt;

&lt;span class=&quot;o&quot;&gt;@&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;transaction&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;atomic&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;create_comment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;author&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;parent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Comment&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Comment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;deleted_at&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;raise&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;ValueError&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;post_deleted&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;depth&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;parent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;parent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;post_id&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;raise&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;ValueError&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;parent_mismatch&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;depth&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;parent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;depth&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;depth&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Comment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;MAX_DEPTH&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;raise&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;ValueError&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;max_depth_exceeded&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;comment&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Comment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;objects&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;create&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;author&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;author&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;parent&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;parent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;depth&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;depth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sanitize_html&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;Post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;objects&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;filter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pk&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pk&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;update&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;comment_count&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;F&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;comment_count&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;comment&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;52-목록-전략&quot;&gt;5.2 목록 전략&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;단점&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Flat + &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;parent_id&lt;/code&gt;&lt;/strong&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;strong&gt;Nested serializer&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;UI 직관&lt;/td&gt;
      &lt;td&gt;N+1·깊이 폭발&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Adjacency + path materialized&lt;/strong&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;strong&gt;실무 추천&lt;/strong&gt;: &lt;strong&gt;flat list&lt;/strong&gt; (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;post_id&lt;/code&gt; + &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;created_at&lt;/code&gt;) → 프론트에서 트리 구성. depth ≤ 2면 충분.&lt;/p&gt;

&lt;h3 id=&quot;53-삭제-정책&quot;&gt;5.3 삭제 정책&lt;/h3&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;정책&lt;/th&gt;
      &lt;th&gt;UX&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;soft + “삭제된 댓글”&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;스레드 유지&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;hard delete&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;대댓글 orphan&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;작성자만 삭제, staff 블라인드&lt;/strong&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;comment_count&lt;/code&gt; 는 soft delete 시 &lt;strong&gt;감소&lt;/strong&gt;시키되, 숨김 댓글도 “자리”를 남길지 제품 결정.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;6-좋아요like--시니어가-가장-신경-쓰는-부분&quot;&gt;6. 좋아요(Like) — 시니어가 가장 신경 쓰는 부분&lt;/h2&gt;

&lt;h3 id=&quot;61-실패-모드&quot;&gt;6.1 실패 모드&lt;/h3&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Double click&lt;/strong&gt; → like 2번&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;동시 toggle&lt;/strong&gt; → count +2 / -1&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;UniqueViolation&lt;/strong&gt; 미처리 → 500&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;denorm drift&lt;/strong&gt; → count 99, 실제 100&lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;62-idempotent-toggle-정석&quot;&gt;6.2 Idempotent toggle (정석)&lt;/h3&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# community/services/likes.py
&lt;/span&gt;&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;django.db&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;transaction&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;IntegrityError&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;django.db.models&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;F&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;community.models&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Like&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Post&lt;/span&gt;

&lt;span class=&quot;o&quot;&gt;@&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;transaction&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;atomic&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;toggle_like&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;dict&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;deleted_at&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;raise&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;ValueError&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;post_deleted&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;existing&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Like&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;objects&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;filter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;first&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;existing&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;existing&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;delete&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;Post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;objects&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;filter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pk&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pk&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;update&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;like_count&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;F&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;like_count&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;liked&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;False&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;Like&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;objects&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;create&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;except&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;IntegrityError&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;c1&quot;&gt;# 동시 요청 — 이미 좋아요됨 → idempotent success
&lt;/span&gt;            &lt;span class=&quot;n&quot;&gt;liked&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;Post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;objects&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;filter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pk&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pk&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;update&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;like_count&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;F&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;like_count&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;liked&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;refresh_from_db&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;fields&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;like_count&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;liked&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;liked&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;like_count&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;like_count&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&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;ul&gt;
  &lt;li&gt;DB &lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UniqueConstraint(user, post)&lt;/code&gt;&lt;/strong&gt; 가 진실의 원천&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;IntegrityError&lt;/code&gt; → &lt;strong&gt;200 + liked=true&lt;/strong&gt; (클라이언트 재시도 안전)&lt;/li&gt;
  &lt;li&gt;count는 &lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;F()&lt;/code&gt; expression&lt;/strong&gt; — &lt;a href=&quot;/2026/02/18-django-transaction-management-best-practices/&quot;&gt;트랜잭션 가이드&lt;/a&gt; 참고&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;like_count&lt;/code&gt; 음수 방지: DB &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CHECK (like_count &amp;gt;= 0)&lt;/code&gt; 또는 주기 reconcile&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;63-reconcile-job-denorm-검증&quot;&gt;6.3 Reconcile job (denorm 검증)&lt;/h3&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# Celery task — 주 1회 or 알람 시
&lt;/span&gt;&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;django.db.models&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Count&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;F&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;reconcile_like_counts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;():&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;drift&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;Post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;objects&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;alive&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;annotate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;real&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Count&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;likes&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;exclude&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;like_count&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;F&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;real&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;p&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;drift&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[:&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;100&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;Post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;objects&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;filter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pk&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pk&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;update&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;like_count&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;p&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;real&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href=&quot;/2026/01/14-django-ninja-high-traffic-optimization-strategies/&quot;&gt;고트래픽 가이드&lt;/a&gt;처럼 &lt;strong&gt;배치·캐시&lt;/strong&gt;를 섞을 수 있지만, &lt;strong&gt;MVP는 DB denorm + reconcile&lt;/strong&gt;이 단순합니다.&lt;/p&gt;

&lt;h3 id=&quot;64-ninja-엔드포인트&quot;&gt;6.4 Ninja 엔드포인트&lt;/h3&gt;

&lt;div class=&quot;language-python 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;o&quot;&gt;@&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;likes_router&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/posts/{post_id}/like&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;toggle_post_like&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;post_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;UUID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;post&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;get_object_or_404&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;objects&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;alive&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;pk&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;post_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;result&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;toggle_like&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;result&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;PUT idempotent&lt;/strong&gt; vs &lt;strong&gt;POST toggle&lt;/strong&gt; — 팀 컨vention 통일. 모바일은 &lt;strong&gt;POST toggle&lt;/strong&gt;이 흔함.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;7-인증권한--django-ninja&quot;&gt;7. 인증·권한 — Django Ninja&lt;/h2&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# core/auth.py
&lt;/span&gt;&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;ninja.security&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HttpBearer&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;django.contrib.auth&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;get_user_model&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;JWTAuth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;HttpBearer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;authenticate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;user&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;verify_access_token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# simplejwt 등
&lt;/span&gt;        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;user&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;and&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;is_active&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# api.py
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;api&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;NinjaAPI&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;auth&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JWTAuth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&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;GET 피드 (public)&lt;/td&gt;
      &lt;td&gt;optional — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;liked_by_me&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;POST 글·댓글·좋아요&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;required&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;DELETE&lt;/td&gt;
      &lt;td&gt;author or staff&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;a href=&quot;/2026/02/26-django-nextjs-jwt-authentication-complete-guide/&quot;&gt;JWT 가이드&lt;/a&gt;의 refresh rotation과 함께 설계.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;8-보안모더레이션&quot;&gt;8. 보안·모더레이션&lt;/h2&gt;

&lt;h3 id=&quot;81-xss--본문-sanitize&quot;&gt;8.1 XSS — 본문 sanitize&lt;/h3&gt;

&lt;div class=&quot;language-python 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;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;bleach&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;ALLOWED_TAGS&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;p&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;br&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;strong&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;em&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;a&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;ul&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;ol&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;li&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;code&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;ALLOWED_ATTRS&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;a&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;href&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;title&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;rel&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]}&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;sanitize_html&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;raw&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;bleach&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;clean&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;raw&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;tags&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ALLOWED_TAGS&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;attributes&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ALLOWED_ATTRS&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;strip&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Markdown 렌더 시 &lt;strong&gt;서버 sanitize&lt;/strong&gt; 필수. 프론트만 믿지 않기.&lt;/p&gt;

&lt;h3 id=&quot;82-rate-limit&quot;&gt;8.2 Rate limit&lt;/h3&gt;

&lt;div class=&quot;language-python 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;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;django_ratelimit.decorators&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ratelimit&lt;/span&gt;

&lt;span class=&quot;o&quot;&gt;@&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ratelimit&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;key&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;user&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;rate&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;10/m&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;method&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;POST&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;@&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;posts_router&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;PostDetailOut&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;create_post_api&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;PostIn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&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;10/분&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;댓글&lt;/td&gt;
      &lt;td&gt;30/분&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;좋아요&lt;/td&gt;
      &lt;td&gt;60/분&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;로그인 실패&lt;/td&gt;
      &lt;td&gt;IP별&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;83-신고블라인드-phase-2-스키마&quot;&gt;8.3 신고·블라인드 (Phase 2 스키마)&lt;/h3&gt;

&lt;div class=&quot;language-python 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;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;Report&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Model&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;reporter&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ForeignKey&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;User&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;on_delete&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CASCADE&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;post&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ForeignKey&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;on_delete&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CASCADE&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;comment&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ForeignKey&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Comment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;on_delete&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CASCADE&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;reason&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CharField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;max_length&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;50&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;created_at&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DateTimeField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;auto_now_add&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;staff 대시보드 or Django Admin + &lt;strong&gt;감사 로그&lt;/strong&gt;.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;9-성능인덱스&quot;&gt;9. 성능·인덱스&lt;/h2&gt;

&lt;h3 id=&quot;91-필수-인덱스&quot;&gt;9.1 필수 인덱스&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;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ORDER BY created_at DESC&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;(created_at DESC)&lt;/code&gt; partial &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;deleted_at IS NULL&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;댓글 by post&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;(post_id, created_at)&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;liked by me&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;(user_id, post_id)&lt;/code&gt; unique&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;(author_id, created_at)&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;a href=&quot;/2026/02/16-django-database-indexing-performance-optimization/&quot;&gt;DB 인덱싱 가이드&lt;/a&gt; 참고.&lt;/p&gt;

&lt;h3 id=&quot;92-캐시-트래픽-올라가면&quot;&gt;9.2 캐시 (트래픽 올라가면)&lt;/h3&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;대상&lt;/th&gt;
      &lt;th&gt;TTL&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;인기글 Top 10&lt;/td&gt;
      &lt;td&gt;60s&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;post detail&lt;/td&gt;
      &lt;td&gt;30s, invalidation on update&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;like_count&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;캐시보다 DB denorm&lt;/strong&gt; 우선&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;93-읽기쓰기-분리&quot;&gt;9.3 읽기/쓰기 분리&lt;/h3&gt;

&lt;p&gt;댓글·좋아요 폭주 시 &lt;strong&gt;read replica&lt;/strong&gt; + write master. &lt;a href=&quot;/2026/01/14-django-ninja-high-traffic-optimization-strategies/&quot;&gt;고트래픽 가이드&lt;/a&gt; 2단계.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;10-테스트--시니어-최소-세트&quot;&gt;10. 테스트 — 시니어 최소 세트&lt;/h2&gt;

&lt;div class=&quot;language-python 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;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;pytest&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;concurrent.futures&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ThreadPoolExecutor&lt;/span&gt;

&lt;span class=&quot;o&quot;&gt;@&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pytest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;mark&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;django_db&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;test_toggle_like_idempotent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;force_login&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;r1&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/api/posts/&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;/like&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;r2&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/api/posts/&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;/like&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;assert&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;r1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;liked&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;is&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;assert&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;r2&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;liked&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;is&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;False&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;refresh_from_db&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;assert&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;like_count&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;

&lt;span class=&quot;o&quot;&gt;@&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pytest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;mark&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;django_db&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;test_concurrent_likes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;like_once&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;():&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;toggle_like&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;with&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ThreadPoolExecutor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;max_workers&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;10&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ex&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;nb&quot;&gt;list&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ex&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;map&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;lambda&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;_&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;like_once&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;range&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;10&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)))&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;assert&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;likes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;count&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;refresh_from_db&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;assert&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;like_count&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&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;soft delete post → comment 403&lt;/td&gt;
      &lt;td&gt; &lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;max depth 초과&lt;/td&gt;
      &lt;td&gt; &lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;non-author update 403&lt;/td&gt;
      &lt;td&gt; &lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;list N+1&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;assertNumQueries&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;like reconcile&lt;/td&gt;
      &lt;td&gt;drift 0&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;11-api-설계-디테일&quot;&gt;11. API 설계 디테일&lt;/h2&gt;

&lt;h3 id=&quot;111-http-상태-코드&quot;&gt;11.1 HTTP 상태 코드&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;201&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;삭제&lt;/td&gt;
      &lt;td&gt;204&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;soft deleted&lt;/td&gt;
      &lt;td&gt;404&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;권한 없음&lt;/td&gt;
      &lt;td&gt;403&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;validation&lt;/td&gt;
      &lt;td&gt;422 (Ninja default)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;rate limit&lt;/td&gt;
      &lt;td&gt;429&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;112-에러-형식-통일&quot;&gt;11.2 에러 형식 통일&lt;/h3&gt;

&lt;div class=&quot;language-python 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;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;ninja.errors&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HttpError&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;raise&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HttpError&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;403&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;You cannot edit this post&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;113-버전&quot;&gt;11.3 버전&lt;/h3&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/api/v1/community/...&lt;/code&gt; — 필드 추가는 하위 호환, breaking은 v2.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;12-멀티테넌시커뮤니티&quot;&gt;12. 멀티테넌시·커뮤니티&lt;/h2&gt;

&lt;p&gt;SaaS에서 &lt;strong&gt;워크스pace별 게시판&lt;/strong&gt;이면 모든 쿼리에 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;workspace_id&lt;/code&gt; 필터:&lt;/p&gt;

&lt;div class=&quot;language-python 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;n&quot;&gt;Post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;objects&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;filter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;workspace&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;workspace&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href=&quot;/2026/01/21-django-ninja-workspace-based-multi-tenancy-authentication/&quot;&gt;Workspace Multi-tenancy 가이드&lt;/a&gt;와 동일 패턴. &lt;strong&gt;Like unique는 (workspace, user, post)&lt;/strong&gt; 또는 post가 workspace에 종속.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;13-흔한-실수-12가지&quot;&gt;13. 흔한 실수 12가지&lt;/h2&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Like without unique constraint&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;offset pagination만&lt;/strong&gt; — deep page 지옥&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;list API에 body 전문&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;hard delete post&lt;/strong&gt; — 댓글·알림 orphan&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;count = COUNT(*) 매 요청&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;댓글 무한 depth&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;sanitize 없음&lt;/strong&gt; — stored XSS&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;rate limit 없음&lt;/strong&gt; — 스팸&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;N+1 on author·liked&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;IntegrityError 500&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;staff 모더레이션 없음&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;like_count 음수&lt;/strong&gt; — reconcile 없음&lt;/li&gt;
&lt;/ol&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;14-90일-로드맵&quot;&gt;14. 90일 로드맵&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Phase&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;strong&gt;1 MVP&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;1~2주&lt;/td&gt;
      &lt;td&gt;Post·Comment(1depth)·Like·JWT&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;2 Production&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;3~5주&lt;/td&gt;
      &lt;td&gt;cursor·soft delete·depth·sanitize·rate limit&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;3 Scale&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;6~8주&lt;/td&gt;
      &lt;td&gt;reconcile·캐시·신고&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;4 Growth&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;9~12주&lt;/td&gt;
      &lt;td&gt;알림·검색·이미지 첨부&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;15-정리&quot;&gt;15. 정리&lt;/h2&gt;

&lt;p&gt;Django Ninja로 커뮤니티를 만든다는 것은 &lt;strong&gt;CRUD API 3개&lt;/strong&gt;가 아닙니다.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;글·댓글·좋아요는 “소셜 상태”를 DB에 쌓는 일&lt;/strong&gt;이고,&lt;br /&gt;
시니어는 &lt;strong&gt;동시성·삭제·권한·피드 성능·악용&lt;/strong&gt;을 먼저 설계합니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Post / Comment / Like&lt;/strong&gt; 분리 + &lt;strong&gt;soft delete&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Like&lt;/strong&gt; = DB unique + &lt;strong&gt;idempotent toggle&lt;/strong&gt; + &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;F()&lt;/code&gt; + reconcile&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Comment&lt;/strong&gt; = &lt;strong&gt;depth cap&lt;/strong&gt; + flat list&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Feed&lt;/strong&gt; = &lt;strong&gt;cursor&lt;/strong&gt; + &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;select_related&lt;/code&gt; + batch liked&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;보안&lt;/strong&gt; = sanitize + rate limit + staff 모더레이션&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;오늘 바로: &lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Like&lt;/code&gt;에 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UniqueConstraint(user, post)&lt;/code&gt; 추가&lt;/strong&gt;하고, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;toggle_like&lt;/code&gt;에 &lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;IntegrityError&lt;/code&gt; 처리&lt;/strong&gt; 넣어 보세요. 커뮤니티 품질의 80%가 여기서 갈립니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;참고-자료&quot;&gt;참고 자료&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://django-ninja.dev/&quot;&gt;Django Ninja 공식 문서&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/mozilla/bleach&quot;&gt;bleach — HTML sanitization&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.postgresql.org/docs/current/indexes-partial.html&quot;&gt;PostgreSQL Partial Index&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;관련-글&quot;&gt;관련 글&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/02/18-django-transaction-management-best-practices/&quot;&gt;Django 트랜잭션 관리&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/02/16-django-database-indexing-performance-optimization/&quot;&gt;Django DB 인덱싱&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/01/14-django-ninja-high-traffic-optimization-strategies/&quot;&gt;Django Ninja 고트래픽&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/02/26-django-nextjs-jwt-authentication-complete-guide/&quot;&gt;JWT 인증 (Django + Next.js)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/01/21-django-ninja-workspace-based-multi-tenancy-authentication/&quot;&gt;Workspace Multi-tenancy&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/04/21-django-ninja-exception-handling/&quot;&gt;Django Ninja Exception Handling&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/03/24-django-ninja-celery-beat-complete-guide/&quot;&gt;Django Ninja + Celery Beat&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content><author><name>updaun</name></author><category term="django" /><category term="python" /><category term="api" /><category term="django-ninja" /><category term="community" /><category term="comments" /><category term="likes" /><category term="post" /><category term="api-design" /><category term="concurrency" /><category term="moderation" /><category term="pagination" /><category term="senior-developer" /><summary type="html">커뮤니티 CRUD는 하루면 됩니다. &apos;실서비스급&apos; 커뮤니티는 좋아요 경쟁 조건, 댓글 트리 깊이, 소프트 삭제, 스팸·권한, 피드 N+1에서 갈립니다. 시니어는 기능 목록보다 실패 모드부터 설계합니다.</summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-06-14-django-ninja-community-features-senior-guide.webp" /><media:content medium="image" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-06-14-django-ninja-community-features-senior-guide.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">동물 캐릭터 릴스 효율 제작 &amp;amp; 코딩 자동화 — 2D 마스코트부터 AI 파이프라인까지 2026</title><link href="https://updaun.github.io/python/marketing/automation/ai/2026/06/13/animal-character-reels-production-automation-guide/" rel="alternate" type="text/html" title="동물 캐릭터 릴스 효율 제작 &amp;amp; 코딩 자동화 — 2D 마스코트부터 AI 파이프라인까지 2026" /><published>2026-06-13T00:00:00+09:00</published><updated>2026-06-13T00:00:00+09:00</updated><id>https://updaun.github.io/python/marketing/automation/ai/2026/06/13/animal-character-reels-production-automation-guide</id><content type="html" xml:base="https://updaun.github.io/python/marketing/automation/ai/2026/06/13/animal-character-reels-production-automation-guide/">&lt;h1 id=&quot;동물-캐릭터-릴스-효율-제작--코딩-자동화--2d-마스코트부터-ai-파이프라인까지-2026&quot;&gt;동물 캐릭터 릴스 효율 제작 &amp;amp; 코딩 자동화 — 2D 마스코트부터 AI 파이프라인까지 2026&lt;/h1&gt;

&lt;p&gt;“고양이 캐릭터가 말하는 릴스” “강아지가 오늘의 팁 알려주는 30초” — &lt;strong&gt;동물 캐릭터 숏폼&lt;/strong&gt;은 저장·공유율이 높고, 브랜드·교육·쇼핑몰·로컬 업체 홍보에 잘 맞습니다.&lt;/p&gt;

&lt;p&gt;하지만 한 편마다 &lt;strong&gt;캐릭터 그리기 → 대본 → 녹음 → 입 움직임 → 자막 → BGM&lt;/strong&gt;을 반복하면 금방 지칩니다.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;효율의 핵심&lt;/strong&gt;은 매번 새 캐릭터를 만드는 게 아니라,&lt;br /&gt;
&lt;strong&gt;같은 캐릭터 + 같은 포맷 + 자동화 가능한 구간&lt;/strong&gt;을 최대화하는 것입니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;이 글은 &lt;strong&gt;수동으로 빠르게 만드는 워크플로&lt;/strong&gt;와 &lt;strong&gt;코딩으로 자동화하는 파이프라인&lt;/strong&gt;을 나눠 설명하고, &lt;strong&gt;어디까지 코드로 가능한지&lt;/strong&gt; 솔직하게 정리합니다.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;업체 홍보용 슬라이드 릴스 파이프라인은 &lt;a href=&quot;/2026/06/09-business-promo-reels-auto-generator-guide/&quot;&gt;업체 릴스 자동 제작기&lt;/a&gt;, TTS 백엔드는 &lt;a href=&quot;/2026/01/27-django-ninja-stt-tts-production/&quot;&gt;Django Ninja STT/TTS&lt;/a&gt;, 인스타 전략은 &lt;a href=&quot;/2026/03/22-instagram-marketing-beginner-guide/&quot;&gt;인스타 마케팅 가이드&lt;/a&gt;를 함께 보면 좋습니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;0-결론부터-효율-3단--자동화-가능-범위&quot;&gt;0. 결론부터: 효율 3단 + 자동화 가능 범위&lt;/h2&gt;

&lt;h3 id=&quot;효율-제작-3단&quot;&gt;효율 제작 3단&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;① 캐릭터 고정 (Character Bible 1벌)
  → ② 포맷 고정 (릴스 템플릿 3종)
    → ③ 대본만 바꿔 양산 (주 5~7편)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;자동화-가능-범위-2026-현실&quot;&gt;자동화 가능 범위 (2026 현실)&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;도구&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;◎ 90%&lt;/td&gt;
      &lt;td&gt;LLM + 캐릭터 페르소나 프롬프트&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;음성&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;◎ 95%&lt;/td&gt;
      &lt;td&gt;TTS (캐릭터별 voice preset)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;자막&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;◎ 95%&lt;/td&gt;
      &lt;td&gt;SRT/ASS 생성&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;배경·장면&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;○ 70%&lt;/td&gt;
      &lt;td&gt;템플릿 PNG / AI 배경&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;캐릭터 표정·포즈&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;○ 60%&lt;/td&gt;
      &lt;td&gt;스프라이트 세트 / AI 일관성&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;립싱크&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;△ 50~80%&lt;/td&gt;
      &lt;td&gt;Rhubarb / Wav2Lip / 단순 입 3장&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;표정·연기 퀄리티&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;△ 30%&lt;/td&gt;
      &lt;td&gt;수동 보정 또는 Live2D&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;/td&gt;
      &lt;td&gt;사람 기획 필수&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;strong&gt;코딩 자동화의 sweet spot&lt;/strong&gt;: &lt;strong&gt;“교육·팁·브랜드 정보를 말하는 20~40초 2D 캐릭터 토크 릴스”&lt;/strong&gt;.&lt;br /&gt;
&lt;strong&gt;한계&lt;/strong&gt;: 매 편 완전 다른 연출·드라마틱 액션은 아직 &lt;strong&gt;반자동 + 편집자 1명&lt;/strong&gt;이 현실적입니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;1-동물-캐릭터-릴스-유형--어떤-방식이-효율적인가&quot;&gt;1. 동물 캐릭터 릴스 유형 — 어떤 방식이 효율적인가&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;th&gt;적합&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;A. 2D 스프라이트 + 입 3장&lt;/strong&gt;&lt;/td&gt;
      &lt;td&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;strong&gt;B. AI 일러스트 + Ken Burns&lt;/strong&gt;&lt;/td&gt;
      &lt;td&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;strong&gt;C. Live2D / Rive 리깅&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;중~상&lt;/td&gt;
      &lt;td&gt;○&lt;/td&gt;
      &lt;td&gt;△&lt;/td&gt;
      &lt;td&gt;IP·시리즈&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;D. AI 영상 (Runway/Kling 등)&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;중&lt;/td&gt;
      &lt;td&gt;△&lt;/td&gt;
      &lt;td&gt;△&lt;/td&gt;
      &lt;td&gt;실험·훅 1컷&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;E. 실사 + 캐릭터 스티커&lt;/strong&gt;&lt;/td&gt;
      &lt;td&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;strong&gt;양산·코딩 자동화 1순위&lt;/strong&gt;: &lt;strong&gt;A (2D 스프라이트)&lt;/strong&gt;&lt;br /&gt;
&lt;strong&gt;팔로워 성장 + 브랜드&lt;/strong&gt;: &lt;strong&gt;A + C 혼합&lt;/strong&gt; (주 5편 A, 월 1편 C)&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;2-캐릭터-bible--한-번만-제대로-만들기&quot;&gt;2. 캐릭터 Bible — 한 번만 제대로 만들기&lt;/h2&gt;

&lt;p&gt;양산 전에 &lt;strong&gt;캐릭터를 고정&lt;/strong&gt;합니다. 매 편 AI로 새 고양이를 그리면 &lt;strong&gt;일관성·자동화&lt;/strong&gt; 모두 깨집니다.&lt;/p&gt;

&lt;h3 id=&quot;21-character-bible-템플릿&quot;&gt;2.1 Character Bible 템플릿&lt;/h3&gt;

&lt;div class=&quot;language-yaml 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;na&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;냥냥이&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;대리&quot;&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;species&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;고양이&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;personality&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;츤데레, 정보 전달은 또박또박&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;speech_style&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;~냥&quot;&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;반말, 문장 짧게&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;visual&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;colors&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;#FFB347&quot;&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;#FFFFFF&quot;&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;#333333&quot;&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;]&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;outfit&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;노란 후드&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;forbidden&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;매번&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;다른&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;품종·색&quot;&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;voice&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;tts&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;nova&quot;&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# 또는 ElevenLabs voice_id&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;speed&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;1.08&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;formats&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;오늘의&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;한&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;줄&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;팁&quot;&lt;/span&gt;
  &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;틀린&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;vs&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;맞는&quot;&lt;/span&gt;
  &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;3초&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;퀴즈&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;22-필수-에셋-세트-2d-스프라이트&quot;&gt;2.2 필수 에셋 세트 (2D 스프라이트)&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;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;char_neutral.png&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;기본, 투명 PNG&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;char_mouth_open.png&lt;/code&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;char_mouth_closed.png&lt;/code&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;char_happy.png&lt;/code&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;char_think.png&lt;/code&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;bg_office.png&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;1080×1920 배경 1~3종&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;strong&gt;한 벌만&lt;/strong&gt; 9:16에 맞춰 export. &lt;a href=&quot;/2026/06/09-business-promo-reels-auto-generator-guide/&quot;&gt;업체 릴스 가이드&lt;/a&gt;의 Ken Burns·슬라이드 로직을 &lt;strong&gt;캐릭터 레이어&lt;/strong&gt;에 얹으면 됩니다.&lt;/p&gt;

&lt;h3 id=&quot;23-ai로-캐릭터-만들-때-일관성-팁&quot;&gt;2.3 AI로 캐릭터 만들 때 (일관성 팁)&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Midjourney &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--cref&lt;/code&gt; / DALL·E character reference / Flux LoRA&lt;/strong&gt; 로 같은 얼굴 유지&lt;/li&gt;
  &lt;li&gt;“매 편 생성” 대신 &lt;strong&gt;정면·측면·표정 6장&lt;/strong&gt;을 먼저 확정 → PNG로 고정&lt;/li&gt;
  &lt;li&gt;상업 이용·저작권 &lt;strong&gt;라이선스&lt;/strong&gt; 확인 (LoRA·스톡·자체 제작)&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;3-릴스-포맷-3종--대본만-갈아끼우기&quot;&gt;3. 릴스 포맷 3종 — 대본만 갈아끼우기&lt;/h2&gt;

&lt;h3 id=&quot;포맷-1-30초-팁-가장-양산하기-쉬움&quot;&gt;포맷 1: &lt;strong&gt;「30초 팁」&lt;/strong&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;[0~3초]  훅: &quot;아직도 ○○하고 있어?&quot;
[3~25초] 팁 3개 (캐릭터 + 자막)
[25~30초] CTA: &quot;저장해두면 나중에 편해~&quot;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;포맷-2-틀린-vs-맞는&quot;&gt;포맷 2: &lt;strong&gt;「틀린 vs 맞는」&lt;/strong&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;[0초]   ❌ 틀린 방법 (캐릭터 sad)
[12초]  ✅ 맞는 방법 (캐릭터 happy)
[25초]  한 줄 요약
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;포맷-3-3초-퀴즈--정답&quot;&gt;포맷 3: &lt;strong&gt;「3초 퀴즈 → 정답」&lt;/strong&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;[0~5초]  질문 + 선택지 A/B
[5~8초]  &quot;정답은…!&quot;
[8~25초] 설명
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;주간 캘린더 예&lt;/strong&gt;: 월·수·금 팁 / 화 퀴즈 / 목 틀린vs맞는 / 토·일 (휴 또는 재업)&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;4-수동-제작--최단-워크플로-1편-30분&quot;&gt;4. 수동 제작 — 최단 워크플로 (1편 30분)&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;ChatGPT + Character Bible&lt;/td&gt;
      &lt;td&gt;5분&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;음성&lt;/td&gt;
      &lt;td&gt;ElevenLabs / OpenAI TTS&lt;/td&gt;
      &lt;td&gt;2분&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;립싱크&lt;/td&gt;
      &lt;td&gt;CapCut 자동 / Rhubarb&lt;/td&gt;
      &lt;td&gt;5분&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;합성&lt;/td&gt;
      &lt;td&gt;CapCut / Canva 템플릿&lt;/td&gt;
      &lt;td&gt;15분&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;업로드&lt;/td&gt;
      &lt;td&gt;인스타·틱톡&lt;/td&gt;
      &lt;td&gt;3분&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;strong&gt;CapCut 템플릿&lt;/strong&gt;에 캐릭터 PNG·자막 스타일을 저장해 두면 &lt;strong&gt;15분/편&lt;/strong&gt;까지 가능합니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;5-코딩-자동화--파이프라인-설계&quot;&gt;5. 코딩 자동화 — 파이프라인 설계&lt;/h2&gt;

&lt;h3 id=&quot;51-전체-아키텍처&quot;&gt;5.1 전체 아키텍처&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;flowchart LR
  subgraph Input
    Topic[주제 / 키워드]
    Bible[Character Bible YAML]
  end

  subgraph Code[&quot;Python Pipeline&quot;]
    LLM[대본 생성]
    TTS[음성 MP3]
    Lip[립싱크 타임라인]
    Comp[FFmpeg 합성]
  end

  subgraph Assets
    Sprites[캐릭터 PNG 세트]
    BG[배경 PNG]
    BGM[BGM mp3]
  end

  Topic --&amp;gt; LLM
  Bible --&amp;gt; LLM
  LLM --&amp;gt; TTS
  TTS --&amp;gt; Lip
  Lip --&amp;gt; Comp
  Sprites --&amp;gt; Comp
  BG --&amp;gt; Comp
  BGM --&amp;gt; Comp
  Comp --&amp;gt; MP4[1080x1920 MP4]
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;a href=&quot;/2026/06/09-business-promo-reels-auto-generator-guide/&quot;&gt;업체 릴스 자동 제작기&lt;/a&gt;와 &lt;strong&gt;동일한 뼈대&lt;/strong&gt;에 &lt;strong&gt;캐릭터 레이어 + 립싱크&lt;/strong&gt;만 추가하면 됩니다.&lt;/p&gt;

&lt;h3 id=&quot;52-프로젝트-구조&quot;&gt;5.2 프로젝트 구조&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;character-reels/
├── assets/
│   ├── cat/neutral.png
│   ├── cat/mouth_open.png
│   ├── backgrounds/office.png
│   └── bgm/calm.mp3
├── characters/
│   └── nyang.yaml          # Character Bible
├── templates/
│   └── tip_30s.yaml        # 포맷별 장면 정의
├── pipeline/
│   ├── script_gen.py       # LLM
│   ├── tts.py
│   ├── lipsync.py          # Rhubarb
│   ├── compose.py          # FFmpeg
│   └── render.py           # CLI 진입점
└── output/
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;53-llm-대본-생성&quot;&gt;5.3 LLM 대본 생성&lt;/h3&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# pipeline/script_gen.py
&lt;/span&gt;&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;httpx&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;SYSTEM&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;&quot;&quot;
당신은 {name}입니다. 종: {species}. 말투: {speech_style}
릴스 30초 분량. 문장 5~7개. 이모지 금지. TTS용 구어체.
포맷: {format_name}
주제: {topic}
출력: JSON {{ &quot;lines&quot;: [&quot;...&quot;, &quot;...&quot;], &quot;hook&quot;: &quot;...&quot; }}
&quot;&quot;&quot;&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;generate_script&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;character&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;dict&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;topic&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;format_name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;dict&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;prompt&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;SYSTEM&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;format&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;**&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;character&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;topic&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;topic&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;format_name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;format_name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;# OpenAI / Claude API 호출
&lt;/span&gt;    &lt;span class=&quot;p&quot;&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Character Bible을 YAML로 두면 &lt;strong&gt;캐릭터 추가 = 파일 1개&lt;/strong&gt;입니다.&lt;/p&gt;

&lt;h3 id=&quot;54-tts--캐릭터-보이스-preset&quot;&gt;5.4 TTS — 캐릭터 보이스 preset&lt;/h3&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# pipeline/tts.py — OpenAI TTS 예시
&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;synthesize&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;out_path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;voice&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;nova&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;speed&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;float&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;1.08&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;with&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;httpx&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AsyncClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;timeout&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;60&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;resp&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;https://api.openai.com/v1/audio/speech&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;headers&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Authorization&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Bearer &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;API_KEY&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;s&quot;&gt;&quot;model&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;gpt-4o-mini-tts&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                &lt;span class=&quot;s&quot;&gt;&quot;voice&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;voice&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                &lt;span class=&quot;s&quot;&gt;&quot;input&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                &lt;span class=&quot;s&quot;&gt;&quot;response_format&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;mp3&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                &lt;span class=&quot;s&quot;&gt;&quot;speed&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;speed&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;resp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;raise_for_status&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;Path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;out_path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;write_bytes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;resp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&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;voice&lt;/code&gt;·&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;speed&lt;/code&gt;를 Bible에 고정 → &lt;strong&gt;브랜드 음성&lt;/strong&gt; 유지.&lt;/p&gt;

&lt;h3 id=&quot;55-립싱크--rhubarb-오픈소스-자동화-친화&quot;&gt;5.5 립싱크 — Rhubarb (오픈소스, 자동화 친화)&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Rhubarb Lip Sync&lt;/strong&gt;는 음성에서 &lt;strong&gt;mouth cue (A~H, X)&lt;/strong&gt; 타임라인을 뽑습니다. 2D는 입 2~3장만 있어도 됩니다.&lt;/p&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;&lt;span class=&quot;c&quot;&gt;# 설치 후&lt;/span&gt;
rhubarb &lt;span class=&quot;nt&quot;&gt;-f&lt;/span&gt; json &lt;span class=&quot;nt&quot;&gt;-o&lt;/span&gt; cues.json voice.mp3
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;start&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;mf&quot;&gt;0.0&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;end&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;mf&quot;&gt;0.12&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;value&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;X&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;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;start&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;mf&quot;&gt;0.12&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;end&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;mf&quot;&gt;0.35&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;value&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;D&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;span class=&quot;err&quot;&gt;...&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;Python에서 cue → &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mouth_open&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mouth_closed&lt;/code&gt; 전환:&lt;/p&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# pipeline/lipsync.py
&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;cues_to_mouth_states&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;cues&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;list&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;fps&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;30&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;list&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]:&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;&quot;&quot;프레임별 &apos;open&apos; | &apos;closed&apos;&quot;&quot;&quot;&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;frames&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[]&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;cue&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;cues&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;n&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;cue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;end&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;cue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;start&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;fps&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;mouth&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;open&quot;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;cue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;value&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;X&quot;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;closed&quot;&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;frames&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;extend&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;([&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;mouth&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;max&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;n&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;frames&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;한계&lt;/strong&gt;: 감정·눈 깜빡임 없음. &lt;strong&gt;팁·정보 릴스&lt;/strong&gt;에는 충분, 드라마엔 부족.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;고급&lt;/strong&gt;: Wav2Lip(얼굴 영상), SadTalker(정면 사진+음성) — GPU·품질 트레이드오프.&lt;/p&gt;

&lt;h3 id=&quot;56-ffmpeg-합성--캐릭터--배경--자막&quot;&gt;5.6 FFmpeg 합성 — 캐릭터 + 배경 + 자막&lt;/h3&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# pipeline/compose.py — 개념: 프레임 시퀀스 → mp4
&lt;/span&gt;&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;subprocess&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;pathlib&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Path&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;render_segment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;bg&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;char_closed&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;char_open&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;mouth_frames&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;list&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;duration&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;float&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;out&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;fps&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;30&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;&quot;&quot;mouth_frames에 따라 open/closed PNG를 이어 붙여 클립 생성&quot;&quot;&quot;&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;# 1) mouth_frames 길이만큼 PNG 시퀀스 생성 (Pillow composite)
&lt;/span&gt;    &lt;span class=&quot;c1&quot;&gt;# 2) ffmpeg -framerate 30 -i frame_%04d.png -pix_fmt yuv420p segment.mp4
&lt;/span&gt;    &lt;span class=&quot;p&quot;&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;자막·BGM·음성 mux는 &lt;a href=&quot;/2026/06/09-business-promo-reels-auto-generator-guide/&quot;&gt;업체 릴스 가이드 7~8장&lt;/a&gt;과 동일:&lt;/p&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;ffmpeg &lt;span class=&quot;nt&quot;&gt;-i&lt;/span&gt; video.mp4 &lt;span class=&quot;nt&quot;&gt;-i&lt;/span&gt; voice.mp3 &lt;span class=&quot;nt&quot;&gt;-i&lt;/span&gt; bgm.mp3 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-filter_complex&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;
    [1:a]volume=1[voice];
    [2:a]volume=0.15[bgm];
    [voice][bgm]amix=inputs=2:duration=first[aout];
    [0:v]subtitles=subs.ass[vout]
  &quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-map&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;[vout]&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-map&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;[aout]&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-shortest&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-movflags&lt;/span&gt; +faststart final.mp4
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;57-cli--한-줄로-릴스-생성&quot;&gt;5.7 CLI — 한 줄로 릴스 생성&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;python &lt;span class=&quot;nt&quot;&gt;-m&lt;/span&gt; pipeline.render &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--character&lt;/span&gt; characters/nyang.yaml &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--format&lt;/span&gt; tip_30s &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--topic&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;고양이 털 관리 여름철&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--output&lt;/span&gt; output/reel_001.mp4
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Celery + Django Ninja API&lt;/strong&gt;로 감싸면 &lt;a href=&quot;/2026/06/09-business-promo-reels-auto-generator-guide/&quot;&gt;업체 릴스 SaaS&lt;/a&gt;와 같은 &lt;strong&gt;“주제만 넣으면 MP4”&lt;/strong&gt; 제품이 됩니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;6-자동화-수준별-로드맵&quot;&gt;6. 자동화 수준별 로드맵&lt;/h2&gt;

&lt;h3 id=&quot;level-0--템플릿만-코드-없음&quot;&gt;Level 0 — 템플릿만 (코드 없음)&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;CapCut/Canva 템플릿 + Character Bible&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;주 3~5편&lt;/strong&gt;, 1편 20~30분&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;level-1--스크립트-반자동&quot;&gt;Level 1 — 스크립트 반자동&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;LLM 대본 + TTS + 수동 CapCut&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;주 5~7편&lt;/strong&gt;, 1편 15분&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;level-2--ffmpeg-파이프라인-추천&quot;&gt;Level 2 — FFmpeg 파이프라인 (추천)&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;LLM + TTS + Rhubarb + PNG 합성 + ASS 자막&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;주 7~14편&lt;/strong&gt;, 1편 &lt;strong&gt;코드 2분 + 검수 5분&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;level-3--saas--스케줄&quot;&gt;Level 3 — SaaS / 스케줄&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;Django Ninja API + Celery + S3&lt;/li&gt;
  &lt;li&gt;n8n/Celery Beat으로 &lt;strong&gt;주제 큐 → 매일 1편 자동 렌더&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/03/24-django-ninja-celery-beat-complete-guide/&quot;&gt;Celery Beat 가이드&lt;/a&gt; 참고&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;level-4--ai-영상-실험&quot;&gt;Level 4 — AI 영상 (실험)&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;Runway / Kling / Pika로 &lt;strong&gt;캐릭터 3초 클립&lt;/strong&gt; 생성 → FFmpeg concat&lt;/li&gt;
  &lt;li&gt;비용·일관성·저작권 리스크 — &lt;strong&gt;훅 1컷만&lt;/strong&gt; AI, 본문은 Level 2&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;7-효율을-깨는-실수-vs-지키는-규칙&quot;&gt;7. 효율을 깨는 실수 vs 지키는 규칙&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;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;매 편 새 캐릭터 디자인&lt;/td&gt;
      &lt;td&gt;Bible + 스프라이트 &lt;strong&gt;1벌 고정&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;90초 스토리&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;20~40초&lt;/strong&gt; 정보형&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;립싱크 완벽주의&lt;/td&gt;
      &lt;td&gt;2D는 &lt;strong&gt;입 2장 + Rhubarb&lt;/strong&gt;로 충분&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;BGM·자막 매번 변경&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;프리셋 2종&lt;/strong&gt;만&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&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;AI 캐릭터 저작권 미확인&lt;/td&gt;
      &lt;td&gt;자작·라이선스·LoRA 약관&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;8-비용시간-level-2-기준-1편&quot;&gt;8. 비용·시간 (Level 2 기준, 1편)&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;LLM 대본&lt;/td&gt;
      &lt;td&gt;~₩20&lt;/td&gt;
      &lt;td&gt;10초&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;TTS (~100자)&lt;/td&gt;
      &lt;td&gt;~₩100&lt;/td&gt;
      &lt;td&gt;5초&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Rhubarb + FFmpeg (로컬)&lt;/td&gt;
      &lt;td&gt;₩0&lt;/td&gt;
      &lt;td&gt;30~90초&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;사람 검수·썸네일&lt;/td&gt;
      &lt;td&gt;—&lt;/td&gt;
      &lt;td&gt;5분&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;~₩150&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;~6분&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;CapCut만 쓸 때 30분 → 파이프라인 &lt;strong&gt;6분&lt;/strong&gt; + 일관된 브랜드.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;9-확장-아이디어&quot;&gt;9. 확장 아이디어&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;/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;같은 대본 JSON → TTS 3언어 → 자막 3버전&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;시리즈 IP&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;캐릭터 YAML 5개 = 5개 채널&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;B2B 납품&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;펫샵·동물병원·키즈 브랜드에 &lt;strong&gt;월 N편 API&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;A/B 훅&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;LLM으로 hook 3개 → 썸네일·첫 3초만 교체&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 + DB 문제 bank&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;a href=&quot;/2026/06-01-ai-solo-founder-subscription-global-strategy/&quot;&gt;1인 구독 SaaS&lt;/a&gt; 관점: &lt;strong&gt;“월 29,000원 · 캐릭터 1마리 · 주 7릴스”&lt;/strong&gt; 플랜.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;10-90일-실행-계획&quot;&gt;10. 90일 실행 계획&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;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;1~2주&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Character Bible + PNG 6장 + 포맷 1종&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;3~4주&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;CapCut 템플릿, 주 3편 업로드&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;5~8주&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Level 2 Python 파이프라인 (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;render.py&lt;/code&gt;)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;9~12주&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;API·스케줄, KPI(저장률) 기반 포맷 조정&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;11-정리&quot;&gt;11. 정리&lt;/h2&gt;

&lt;p&gt;동물 캐릭터 릴스를 &lt;strong&gt;효율적으로&lt;/strong&gt; 만든다는 것:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;캐릭터·포맷·음성&lt;/strong&gt;을 고정하고 &lt;strong&gt;대본만 바꾼다&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;2D 스프라이트 + Rhubarb + FFmpeg가 &lt;strong&gt;자동화 sweet spot&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;LLM·TTS·자막·합성&lt;/strong&gt;은 코드로 &lt;strong&gt;80~90%&lt;/strong&gt; 가능&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;연출·바이럴 기획&lt;/strong&gt;은 사람 20%가 남는다&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/06/09-business-promo-reels-auto-generator-guide/&quot;&gt;업체 릴스 파이프라인&lt;/a&gt;에 &lt;strong&gt;캐릭터 레이어&lt;/strong&gt;를 얹으면 제품화까지 이어진다&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;오늘 시작: &lt;strong&gt;고정 캐릭터 PNG 3장(무표정·입벌·웃음) + 30초 팁 포맷 1개 + TTS 1줄&lt;/strong&gt; → CapCut 또는 Python으로 &lt;strong&gt;첫 MP4&lt;/strong&gt;를 뽑아보세요.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;참고-자료&quot;&gt;참고 자료&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/DanielSWolf/rhubarb-lip-sync&quot;&gt;Rhubarb Lip Sync&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://ffmpeg.org/documentation.html&quot;&gt;FFmpeg 공식 문서&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://platform.openai.com/docs/guides/text-to-speech&quot;&gt;OpenAI Text-to-Speech&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://rive.app/&quot;&gt;Rive&lt;/a&gt; — 웹·앱용 2D 리깅 (수동 후 export)&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;관련-글&quot;&gt;관련 글&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/06-09-business-promo-reels-auto-generator-guide/&quot;&gt;업체 홍보 릴스 자동 제작기&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/01/27-django-ninja-stt-tts-production/&quot;&gt;Django Ninja STT/TTS&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/03/22-instagram-marketing-beginner-guide/&quot;&gt;인스타그램 마케팅 가이드&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/06/12-aerial-work-platform-sky-marketing-reels-strategy/&quot;&gt;고소작업차·릴스 마케팅 전략&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/03/24-django-ninja-celery-beat-complete-guide/&quot;&gt;Django Ninja + Celery Beat&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/06-01-ai-solo-founder-subscription-global-strategy/&quot;&gt;AI 1인 구독 SaaS&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content><author><name>updaun</name></author><category term="python" /><category term="marketing" /><category term="automation" /><category term="ai" /><category term="animal-character" /><category term="mascot" /><category term="reels" /><category term="instagram" /><category term="tiktok" /><category term="character-animation" /><category term="tts" /><category term="lip-sync" /><category term="ffmpeg" /><category term="automation" /><category term="llm" /><category term="short-form-video" /><category term="2d-animation" /><summary type="html">동물 캐릭터 릴스의 병목은 &apos;그림 실력&apos;이 아니라 &apos;캐릭터·포맷·대본&apos;의 반복 설계입니다. 캐릭터 시트 1벌 + 장면 템플릿 3종 + Python 파이프라인이면 주 7편도 가능하고, AI·코드로 80%는 자동화할 수 있습니다.</summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-06-13-animal-character-reels-production-automation-guide.webp" /><media:content medium="image" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-06-13-animal-character-reels-production-automation-guide.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">고소작업차·스카이 업체 마케팅 전략 — 네이버·릴스·틱톡 채널 설계와 릴스 컨셉 2026</title><link href="https://updaun.github.io/marketing/local-business/social-media/2026/06/12/aerial-work-platform-sky-marketing-reels-strategy/" rel="alternate" type="text/html" title="고소작업차·스카이 업체 마케팅 전략 — 네이버·릴스·틱톡 채널 설계와 릴스 컨셉 2026" /><published>2026-06-12T00:00:00+09:00</published><updated>2026-06-12T00:00:00+09:00</updated><id>https://updaun.github.io/marketing/local-business/social-media/2026/06/12/aerial-work-platform-sky-marketing-reels-strategy</id><content type="html" xml:base="https://updaun.github.io/marketing/local-business/social-media/2026/06/12/aerial-work-platform-sky-marketing-reels-strategy/">&lt;h1 id=&quot;고소작업차스카이-업체-마케팅-전략--네이버릴스틱톡-채널-설계와-릴스-컨셉-2026&quot;&gt;고소작업차·스카이 업체 마케팅 전략 — 네이버·릴스·틱톡 채널 설계와 릴스 컨셉 2026&lt;/h1&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;blockquote&gt;
  &lt;p&gt;고소작업차 업체의 진짜 고객은 &lt;strong&gt;일반 대중 100만 명&lt;/strong&gt;이 아닙니다.&lt;br /&gt;
&lt;strong&gt;“내일 외벽 점등 교체해야 하는데 12m 높이”&lt;/strong&gt; 같은 문제를 가진 &lt;strong&gt;현장 소장·시공사·간판업체·시설관리팀&lt;/strong&gt;입니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;이 글은 그 전제 위에서 &lt;strong&gt;많은 사람에게 알리는 것&lt;/strong&gt;과 &lt;strong&gt;문의·견적으로 연결하는 것&lt;/strong&gt;을 구분하고, 네이버·릴스·틱톡 역할을 나누며, &lt;strong&gt;새로운 릴스 컨셉&lt;/strong&gt;까지 잡을 수 있게 정리합니다.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;인스타 기본기는 &lt;a href=&quot;/2026/03/22-instagram-marketing-beginner-guide/&quot;&gt;인스타 마케팅 가이드&lt;/a&gt;, 릴스 제작 자동화는 &lt;a href=&quot;/2026/06/09-business-promo-reels-auto-generator-guide/&quot;&gt;업체 홍보 릴스 자동 제작기&lt;/a&gt;, 검색·키워드는 &lt;a href=&quot;/2026/03/14-naver-keyword-ad-api-django-ninja-integration/&quot;&gt;네이버 키워드 API·SEO&lt;/a&gt; 글과 함께 보면 좋습니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;0-결론부터-고소작업차-마케팅-3축&quot;&gt;0. 결론부터: 고소작업차 마케팅 3축&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;strong&gt;발견 (Discovery)&lt;/strong&gt;&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;&lt;strong&gt;신뢰 (Trust)&lt;/strong&gt;&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;&lt;strong&gt;기억 (Recall)&lt;/strong&gt;&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;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;strong&gt;실패 패턴&lt;/strong&gt;: 세 채널에 &lt;strong&gt;같은 홍보 영상&lt;/strong&gt;을 올리고, 해시태그만 바꾸기.&lt;br /&gt;
&lt;strong&gt;성공 패턴&lt;/strong&gt;: 채널마다 &lt;strong&gt;목적이 다르고&lt;/strong&gt;, 릴스는 &lt;strong&gt;한 가지 컨셉으로 30편 시리즈&lt;/strong&gt;.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;1-우리-업체-누가-고객인가&quot;&gt;1. 우리 업체, 누가 고객인가&lt;/h2&gt;

&lt;h3 id=&quot;11-핵심-고객-돈이-되는-쪽&quot;&gt;1.1 핵심 고객 (돈이 되는 쪽)&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;검색·문의 키워드&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;고소작업차 대여, 스카이 임대&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;/td&gt;
      &lt;td&gt;12m 고소작업차, 전기차 고소&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;시설·FM&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;LED·환풍기·채광창&lt;/td&gt;
      &lt;td&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;/td&gt;
      &lt;td&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;/td&gt;
      &lt;td&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;/td&gt;
      &lt;td&gt;이벤트 고소, 촬영용 스카이&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;12-비핵심-조회수는-오지만-전환-낮음&quot;&gt;1.2 비핵심 (조회수는 오지만 전환 낮음)&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;일반 시청자의 “와 대박” 반응만 노린 바iral&lt;/li&gt;
  &lt;li&gt;우리 서비스 지역 밖 팔로워&lt;/li&gt;
  &lt;li&gt;장비만 멋있게 보여주고 &lt;strong&gt;연락처·지역·가격대&lt;/strong&gt; 없는 콘텐츠&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;릴스 조회수 ≠ 견적 문의.&lt;/strong&gt; 둘 다 필요하지만, &lt;strong&gt;B2B·로컬은 “지역 + 작업 유형”이 맞는 조회&lt;/strong&gt;가 중요합니다.&lt;/p&gt;

&lt;h3 id=&quot;13-구매-여정-현장-담당자-버전&quot;&gt;1.3 구매 여정 (현장 담당자 버전)&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;문제 발생 (&quot;12m 높이 작업 필요&quot;)
  → 네이버/구글 검색 (&quot;○○구 고소작업차&quot;, &quot;스카이 대여 당일&quot;)
  → 블로그·플레이스·릴스 프로필 확인 (안전? 장비? 후기?)
  → 전화 / 카톡 / 네이버 톡톡
  → 견적 · 일정 · 현장 출동
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;마케팅은 &lt;strong&gt;2~3번(신뢰)&lt;/strong&gt; 을 짧게 압축하는 일입니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;2-채널별-역할--다-올리기-대신-나누기&quot;&gt;2. 채널별 역할 — “다 올리기” 대신 “나누기”&lt;/h2&gt;

&lt;h3 id=&quot;21-채널-매트릭스&quot;&gt;2.1 채널 매트릭스&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;콘텐츠&lt;/th&gt;
      &lt;th&gt;주기&lt;/th&gt;
      &lt;th&gt;KPI&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;지역+작업별 장문&lt;/td&gt;
      &lt;td&gt;주 1~2&lt;/td&gt;
      &lt;td&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;/td&gt;
      &lt;td&gt;사진, 후기, Q&amp;amp;A&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;strong&gt;인스타 릴스&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;신뢰·기억&lt;/td&gt;
      &lt;td&gt;15~45초 현장&lt;/td&gt;
      &lt;td&gt;주 3~4&lt;/td&gt;
      &lt;td&gt;프로필 방문, DM, 저장&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;/td&gt;
      &lt;td&gt;릴스 재활용+트렌드&lt;/td&gt;
      &lt;td&gt;주 2~3&lt;/td&gt;
      &lt;td&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;/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;strong&gt;유튜브 Shorts&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;(선택) SEO&lt;/td&gt;
      &lt;td&gt;릴스 동일&lt;/td&gt;
      &lt;td&gt;주 1~2&lt;/td&gt;
      &lt;td&gt;구독, 설명란 링크&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;22-네이버-블로그--많은-사람보다-지금-찾는-사람&quot;&gt;2.2 네이버 블로그 — “많은 사람”보다 “지금 찾는 사람”&lt;/h3&gt;

&lt;p&gt;블로그는 &lt;strong&gt;발견 축&lt;/strong&gt;의 중심입니다. 고소작업차는 &lt;strong&gt;지역 + 작업 유형&lt;/strong&gt; 키워드가 전환율이 높습니다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;글 제목 패턴 (URL 1페이지 = 질문 1개)&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;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;{구/시} + 고소작업차 + 대여&lt;/code&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;{작업} + 고소작업차 + 높이&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;간판 설치 12m 고소작업차 임대 비용&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;{장비명} + 차이&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;스카이 vs 붐리프트 vs 스파이더 선택법&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;{상황} + 주의사항&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;좁은 골목 고소작업차 진입 가능 여부&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;ol&gt;
  &lt;li&gt;&lt;strong&gt;한 줄 답&lt;/strong&gt; — “영통구 당일 12m 스카이 대여 가능합니다”&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;작업 높이·장비 스펙 표&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;현장 사진 5장+&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;li&gt;&lt;strong&gt;실제 출동 후기&lt;/strong&gt; 1건&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;a href=&quot;/2026/03/06/complete-seo-optimization-strategy-guide/&quot;&gt;SEO 가이드&lt;/a&gt;의 “질문 단위 URL” 원칙 그대로 적용합니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;블로그만의 강점&lt;/strong&gt;: 검색으로 &lt;strong&gt;6개월~2년&lt;/strong&gt; 유입. 릴스 한 편은 48시간이면 사라집니다. &lt;strong&gt;블로그 = 자산&lt;/strong&gt;, 릴스 = &lt;strong&gt;광고&lt;/strong&gt;.&lt;/p&gt;

&lt;h3 id=&quot;23-인스타그램--b2b도-릴스가-맞다&quot;&gt;2.3 인스타그램 — B2B도 릴스가 맞다&lt;/h3&gt;

&lt;p&gt;건설·시설 담당자도 인스타를 봅니다. 특히 &lt;strong&gt;30~50대 소장·팀장&lt;/strong&gt;이 “이 업체 현장 어떻게 나가나”를 릴스로 판단합니다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;프로필 5초 설계&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;[업체명] 고소작업차·스카이 전문
📍 서울·경기 · 당일 출동
☎ 010-XXXX-XXXX | DM 견적
✅ 자격·보험 · 12m·15m·스파이더
👇 작업별 견적 가이드 (하이라이트)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;하이라이트 5개&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;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;장비&lt;/td&gt;
      &lt;td&gt;12m / 15m / 스파이더 스펙&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;견적&lt;/td&gt;
      &lt;td&gt;전화·DM·필요 정보&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;24-틱톡--릴스의-확산-레이어&quot;&gt;2.4 틱톡 — 릴스의 “확산 레이어”&lt;/h3&gt;

&lt;p&gt;틱톡은 &lt;strong&gt;같은 영상을 그대로&lt;/strong&gt; 올려도 됩니다. 다만:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;첫 1초 훅&lt;/strong&gt;을 틱톡용으로 따로 (아래 릴스 컨셉 참고)&lt;/li&gt;
  &lt;li&gt;설명란에 &lt;strong&gt;지역 + 전화&lt;/strong&gt; 반복&lt;/li&gt;
  &lt;li&gt;“전국” 바iral보다 &lt;strong&gt;“수도권 고소작업”&lt;/strong&gt; 타겟&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;3-릴스-컨셉--새롭게-잡는-방법&quot;&gt;3. 릴스 컨셉 — “새롭게” 잡는 방법&lt;/h2&gt;

&lt;h3 id=&quot;31-컨셉을-못-잡을-때-하는-실수&quot;&gt;3.1 컨셉을 못 잡을 때 하는 실수&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;매번 다른 주제: 오늘 장비, 내일 안전, 모레 후기 → &lt;strong&gt;브랜드 기억 0&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;회사 소개만 반복 → &lt;strong&gt;저장·공유 안 됨&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;BGM만 트렌디하고 현장 정보 없음 → &lt;strong&gt;조회수만 있고 DM 없음&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;32-추천-마스터-컨셉-공중-pov&quot;&gt;3.2 추천 마스터 컨셉: &lt;strong&gt;「공중 POV」&lt;/strong&gt;&lt;/h3&gt;

&lt;p&gt;한 문장으로 통일합니다.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;“바닥에선 안 보이는 것, 우리 스카이에서는 보입니다.”&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;모든 릴스를 이 프레임 아래 &lt;strong&gt;시리즈&lt;/strong&gt;로 만듭니다. 시청자는 “아, 그 공중 POV 업체”로 기억합니다.&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;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;첫 0.5초&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;캡 올라가는 POV&lt;/strong&gt; 또는 &lt;strong&gt;바닥→공중 전환&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;자막&lt;/td&gt;
      &lt;td&gt;하단 2줄, 흰 글씨 + 검은 테두리&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;엔딩 3초&lt;/td&gt;
      &lt;td&gt;로고 + &lt;strong&gt;“○○구 12m 당일” + 전화&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;BGM&lt;/td&gt;
      &lt;td&gt;업-tempo 1종 + 잔잔한 1종 (2트랙만)&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;4-릴스-컨셉-7가지--바로-촬영-가능&quot;&gt;4. 릴스 컨셉 7가지 — 바로 촬영 가능&lt;/h2&gt;

&lt;h3 id=&quot;컨셉-1-몇-미터-스펙--만족&quot;&gt;컨셉 1: &lt;strong&gt;「몇 미터?」&lt;/strong&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;[0초]  &quot;12m면 어디까지?&quot; (바닥에서 카메라 올림)
[3초]  캡 올라가며 건물 3층·간판·LED 지나감
[8초]  &quot;간판 교체 / 외벽 점등 / 에어컨 실외기&quot;
[12초] &quot;○○구 당일 출동 · DM 견적&quot;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;왜 먹히나&lt;/strong&gt;: 담당자가 &lt;strong&gt;가장 먼저 묻는 게 높이&lt;/strong&gt;이기 때문.&lt;/p&gt;

&lt;hr /&gt;

&lt;h3 id=&quot;컨셉-2-골목-끝까지-스파이더좁은-현장&quot;&gt;컨셉 2: &lt;strong&gt;「골목 끝까지」&lt;/strong&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;[0초]  &quot;여기 고소작업차 들어갈까?&quot; (좁은 골목)
[3초]  스파이더/소형 스카이 진입
[8초]  아웃트리거 펼침 타임랩스
[12초] &quot;좁은 현장 전문 · ○○구&quot;
&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;hr /&gt;

&lt;h3 id=&quot;컨셉-3-30초-안전-체크-신뢰&quot;&gt;컨셉 3: &lt;strong&gt;「30초 안전 체크」&lt;/strong&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;[0초]  &quot;출동 전 우리가 꼭 하는 3가지&quot;
[3초]  ① 지반·아웃트리거  ② PPE  ③ 작업 반경
[15초] &quot;보험·자격 ○○ · 안전 먼저&quot;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;B2B 전환율 최고&lt;/strong&gt; — 저장·DM 많음. 조회수는 낮아도 &lt;strong&gt;견적 품질&lt;/strong&gt; 좋음.&lt;/p&gt;

&lt;hr /&gt;

&lt;h3 id=&quot;컨셉-4-before--after-공중-전후-비교&quot;&gt;컨셉 4: &lt;strong&gt;「Before / After 공중」&lt;/strong&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;[0초]  작업 전 (낡은 간판·깨진 유리)
[4초]  (전환) 작업 후 같은 각도
[8초]  &quot;○○ 현장 · 4시간 · 12m&quot;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;블로그 후기와 &lt;strong&gt;같은 현장&lt;/strong&gt;을 릴스로 잘라 쓰면 SEO·SNS 시너지.&lt;/p&gt;

&lt;hr /&gt;

&lt;h3 id=&quot;컨셉-5-현장-소장이-고른-이유-인터뷰&quot;&gt;컨셉 5: &lt;strong&gt;「현장 소장이 고른 이유」&lt;/strong&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;[0초]  &quot;왜 스카이 썼어요?&quot; (현장 소장 1문장)
[5초]  작업 장면 B-roll
[12초] &quot;비슷한 현장 · 견적 DM&quot;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;실제 고객 한 마디가 &lt;strong&gt;100번 홍보보 강함&lt;/strong&gt;. 동의 받고 촬영.&lt;/p&gt;

&lt;hr /&gt;

&lt;h3 id=&quot;컨셉-6-틀린-선택-vs-맞는-선택-교육&quot;&gt;컨셉 6: &lt;strong&gt;「틀린 선택 vs 맞는 선택」&lt;/strong&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;[0초]  &quot;사다리 vs 고소작업차&quot;
[3초]  ❌ 높이·날씨·추락 리스크 (도식)
[8초]  ✅ 스카이 + 안전난간 + 2인 1조
[12초] &quot;합법·안전 작업 · 상담&quot;
&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;hr /&gt;

&lt;h3 id=&quot;컨셉-7-1일-3현장-busy--신뢰&quot;&gt;컨셉 7: &lt;strong&gt;「1일 3현장」&lt;/strong&gt; (busy = 신뢰)&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;[0초]  &quot;오늘 3곳 다녀왔어요&quot; (타임스탬프)
[3초]  현장 A 간판 / B 외벽 / C 조명
[15초] &quot;내일 ○○구 잔여 1대&quot;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;가용 일정·바쁨&lt;/strong&gt;은 B2B에서 “살아있는 업체” 신호.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;5-콘텐츠-캘린더--주간-운영-1인-마케팅-기준&quot;&gt;5. 콘텐츠 캘린더 — 주간 운영 (1인 마케팅 기준)&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;strong&gt;월&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;블로그&lt;/td&gt;
      &lt;td&gt;지역+작업 키워드 1편&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;/td&gt;
      &lt;td&gt;「공중 POV」시리즈 #N&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;/td&gt;
      &lt;td&gt;안전·교육 (컨셉 3 or 6)&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;/td&gt;
      &lt;td&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;/td&gt;
      &lt;td&gt;Before/After or 1일 N현장&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;/td&gt;
      &lt;td&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;/td&gt;
      &lt;td&gt;플레이스 후기 답변&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;strong&gt;월 4블로그 + 12릴스&lt;/strong&gt; — 촬영은 &lt;strong&gt;현장 나갈 때마다 15분 추가&lt;/strong&gt;가 가장 효율적입니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;6-많은-사람에게-알리는-확장-전략&quot;&gt;6. “많은 사람”에게 알리는 확장 전략&lt;/h2&gt;

&lt;p&gt;SNS만으로는 한계가 있습니다. &lt;strong&gt;검색 + 제휴 + 지역&lt;/strong&gt;을 겹치세요.&lt;/p&gt;

&lt;h3 id=&quot;61-검색로컬-필수&quot;&gt;6.1 검색·로컬 (필수)&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;strong&gt;네이버 플레이스&lt;/strong&gt; 최적화&lt;/td&gt;
      &lt;td&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;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;블로그 &lt;strong&gt;지역명 30회 반복&lt;/strong&gt; (자연스럽게)&lt;/td&gt;
      &lt;td&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;a href=&quot;/2026/03/14-naver-keyword-ad-api-django-ninja-integration/&quot;&gt;키워드 API 가이드&lt;/a&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;62-b2b-제휴-전환율-최고&quot;&gt;6.2 B2B 제휴 (전환율 최고)&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;건설·인테리어&lt;/td&gt;
      &lt;td&gt;단가표 공유&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;FM·관리사무소&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;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;strong&gt;릴스 1편 + 파트너 DM 10통&lt;/strong&gt;이 바iral 10만 조회보다 매출에 가깝습니다.&lt;/p&gt;

&lt;h3 id=&quot;63-유료-광고-소액-테스트&quot;&gt;6.3 유료 광고 (소액 테스트)&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;타겟&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;월 30~50만&lt;/td&gt;
      &lt;td&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;게시물당 1~3만&lt;/td&gt;
      &lt;td&gt;반경 20km, 30~55세&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;/td&gt;
      &lt;td&gt;소형 작업&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;a href=&quot;/2026/03/20-naver-powerlink-marketing-strategy-guide/&quot;&gt;네이버 파워링크 전략&lt;/a&gt; 참고.&lt;/p&gt;

&lt;h3 id=&quot;64-오프라인--온라인&quot;&gt;6.4 오프라인 → 온라인&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;차량 &lt;strong&gt;랩핑&lt;/strong&gt; + QR (블로그·카카오)&lt;/li&gt;
  &lt;li&gt;현장 &lt;strong&gt;출입증·안전표지&lt;/strong&gt;에 연락처&lt;/li&gt;
  &lt;li&gt;견적서 PDF에 &lt;strong&gt;릴스 QR&lt;/strong&gt; (“작업 사례 보기”)&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;7-릴스틱톡-제작-실무-팁&quot;&gt;7. 릴스·틱톡 제작 실무 팁&lt;/h2&gt;

&lt;h3 id=&quot;71-촬영-체크리스트-현장-15분&quot;&gt;7.1 촬영 체크리스트 (현장 15분)&lt;/h3&gt;

&lt;ul class=&quot;task-list&quot;&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;캡 상승 &lt;strong&gt;POV 10초&lt;/strong&gt; (손떨림 보정)&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;바닥에서 &lt;strong&gt;전체 장비&lt;/strong&gt; 5초&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;작업 &lt;strong&gt;전·후&lt;/strong&gt; 같은 각도&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;&lt;strong&gt;번호판·고객사명&lt;/strong&gt; 모자이크&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;&lt;strong&gt;안전장비 착용&lt;/strong&gt; 장면&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;72-편집&quot;&gt;7.2 편집&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;길이 &lt;strong&gt;20~35초&lt;/strong&gt; (B2B는 45초까지 OK)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;자막 필수&lt;/strong&gt; — 현장에서 무음 시청 많음&lt;/li&gt;
  &lt;li&gt;첫 화면에 &lt;strong&gt;“12m · ○○구”&lt;/strong&gt; 텍스트&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/06/09-business-promo-reels-auto-generator-guide/&quot;&gt;릴스 자동 제작기&lt;/a&gt;로 사진+나레이션 슬라이드도 가능 (현장 영상과 &lt;strong&gt;7:3&lt;/strong&gt; mix)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;73-해시태그-복사용&quot;&gt;7.3 해시태그 (복사용)&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;#고소작업차 #스카이 #고소작업대 #고소작업차대여 #스카이대여
#간판설치 #외벽작업 #현장 #건설 #시설관리
#{지역} #{구} #당일출동
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;지역 해시 3개 + 업종 5개 + 일반 2개. &lt;strong&gt;30개 남발 금지&lt;/strong&gt;.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;8-kpi--무엇을-재야-하나&quot;&gt;8. KPI — 무엇을 재야 하나&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;지표&lt;/th&gt;
      &lt;th&gt;목표 (3개월)&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;검색 유입&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;WoW +10%&lt;/td&gt;
      &lt;td&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;월 20+&lt;/td&gt;
      &lt;td&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;/td&gt;
      &lt;td&gt;인스타 인사이트&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;DM·전화 견적&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;주 3건+&lt;/td&gt;
      &lt;td&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;30%+&lt;/td&gt;
      &lt;td&gt;CRM·엑셀&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;조회수 10만 &lt;strong&gt;1편&lt;/strong&gt;보다, &lt;strong&gt;전화 1통&lt;/strong&gt;이 이 업종에선 가치가 큽니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;9-90일-로드맵&quot;&gt;9. 90일 로드맵&lt;/h2&gt;

&lt;h3 id=&quot;phase-1-14주-기반&quot;&gt;Phase 1 (1~4주): 기반&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;1&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;블로그 지역 키워드 4편&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;3&lt;/td&gt;
      &lt;td&gt;「공중 POV」릴스 4편&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;4&lt;/td&gt;
      &lt;td&gt;파트너 업체 DM 10곳&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;phase-2-58주-시리즈&quot;&gt;Phase 2 (5~8주): 시리즈&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;5~6&lt;/td&gt;
      &lt;td&gt;릴스 컨셉 7종 중 5종 각 2편&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;7&lt;/td&gt;
      &lt;td&gt;Before/After 블로그+릴스 연동&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;8&lt;/td&gt;
      &lt;td&gt;네이버 검색광고 소액 테스트&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;phase-3-912주-최적화&quot;&gt;Phase 3 (9~12주): 최적화&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;9~10&lt;/td&gt;
      &lt;td&gt;전환 많은 키워드·릴스 유형만 집중&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;11&lt;/td&gt;
      &lt;td&gt;후기·플레이스 리뷰 10건&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;12&lt;/td&gt;
      &lt;td&gt;KPI 리뷰, 캘린더 v2&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;10-흔한-실수-10가지&quot;&gt;10. 흔한 실수 10가지&lt;/h2&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;li&gt;&lt;strong&gt;블로그 방치&lt;/strong&gt; — 릴스만 100편&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;안전·자격 미노출&lt;/strong&gt; — B2B 신뢰 실패&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;li&gt;&lt;strong&gt;댓글·DM 늦음&lt;/strong&gt; — 1시간 내 답장&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;한 번 바iral 기대&lt;/strong&gt; — 시리즈 30편 전에 판단 금지&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;파트너 채널 무시&lt;/strong&gt; — 간판·건설사가 최대 채널&lt;/li&gt;
&lt;/ol&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;11-정리&quot;&gt;11. 정리&lt;/h2&gt;

&lt;p&gt;고소작업차·스카이 업체가 &lt;strong&gt;많은 사람에게 알려지려면&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;“많은 사람” = 전국 대중&lt;/strong&gt;이 아니라 &lt;strong&gt;우리 지역 + 작업 담당자&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;네이버 블로그·플레이스&lt;/strong&gt; = 발견, &lt;strong&gt;릴스&lt;/strong&gt; = 신뢰·기억, &lt;strong&gt;틱톡&lt;/strong&gt; = 확산&lt;/li&gt;
  &lt;li&gt;릴스는 &lt;strong&gt;「공중 POV」&lt;/strong&gt; 한 컨셉으로 &lt;strong&gt;7가지 시리즈&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;현장 나갈 때마다 &lt;strong&gt;15분 촬영&lt;/strong&gt; + 주간 캘린더&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;간판·건설·FM 제휴&lt;/strong&gt;가 SNS만큼 중요&lt;/li&gt;
&lt;/ol&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;캡 상승 POV 10초&lt;/strong&gt; 찍기&lt;/li&gt;
  &lt;li&gt;블로그 &lt;strong&gt;“{우리 구} 고소작업차 대여”&lt;/strong&gt; 글 1편 쓰기&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;이 세 가지면 “뭘 해야 할지 모르겠다”는 상태에서 &lt;strong&gt;실행 가능한 마케팅&lt;/strong&gt;으로 넘어갈 수 있습니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;참고-자료&quot;&gt;참고 자료&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://searchadvisor.naver.com/&quot;&gt;네이버 서치어드바이저&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.naver.com/&quot;&gt;네이버 플레이스&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.facebook.com/business/instagram/reels&quot;&gt;Meta for Business — Reels&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.moel.go.kr/&quot;&gt;중대재해처벌법 — 안전관리&lt;/a&gt; (작업 안전 콘텐츠 시 참고)&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;관련-글&quot;&gt;관련 글&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/03/22-instagram-marketing-beginner-guide/&quot;&gt;인스타그램 마케팅 완벽 가이드&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/06/09-business-promo-reels-auto-generator-guide/&quot;&gt;업체 홍보 릴스 자동 제작기&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/03/06/complete-seo-optimization-strategy-guide/&quot;&gt;블로그 SEO 최적화&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/03/14-naver-keyword-ad-api-django-ninja-integration/&quot;&gt;네이버 키워드 API + Django Ninja&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/03/20-naver-powerlink-marketing-strategy-guide/&quot;&gt;네이버 파워링크 마케팅&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/06/03-web-service-aeo-optimization-complete-guide/&quot;&gt;웹서비스 AEO 최적화&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content><author><name>updaun</name></author><category term="marketing" /><category term="local-business" /><category term="social-media" /><category term="고소작업차" /><category term="스카이" /><category term="aerial-work-platform" /><category term="naver-blog" /><category term="instagram-reels" /><category term="tiktok" /><category term="b2b-marketing" /><category term="local-seo" /><category term="construction" /><category term="short-form-video" /><category term="branding" /><summary type="html">고소작업차 업체의 고객은 &apos;많은 사람&apos;이 아니라 &apos;지금 당장 작업이 필요한 현장 담당자&apos;입니다. 채널을 늘리기보다 검색·신뢰·현장 영상 3축을 맞추고, 릴스는 &apos;높이에서 보는 세상&apos;이라는 한 컨셉으로 시리즈화해야 합니다.</summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-06-12-aerial-work-platform-sky-marketing-reels-strategy.webp" /><media:content medium="image" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-06-12-aerial-work-platform-sky-marketing-reels-strategy.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">AWS에 가장 효율적인 GitLab CE 구성하기 — 비용·성능·HA까지 2026 실전 가이드</title><link href="https://updaun.github.io/aws/devops/gitlab/2026/06/10/aws-gitlab-ce-efficient-architecture-guide/" rel="alternate" type="text/html" title="AWS에 가장 효율적인 GitLab CE 구성하기 — 비용·성능·HA까지 2026 실전 가이드" /><published>2026-06-10T00:00:00+09:00</published><updated>2026-06-10T00:00:00+09:00</updated><id>https://updaun.github.io/aws/devops/gitlab/2026/06/10/aws-gitlab-ce-efficient-architecture-guide</id><content type="html" xml:base="https://updaun.github.io/aws/devops/gitlab/2026/06/10/aws-gitlab-ce-efficient-architecture-guide/">&lt;h1 id=&quot;aws에-가장-효율적인-gitlab-ce-구성하기--비용성능ha까지-2026-실전-가이드&quot;&gt;AWS에 가장 효율적인 GitLab CE 구성하기 — 비용·성능·HA까지 2026 실전 가이드&lt;/h1&gt;

&lt;p&gt;팀이 커지면 GitHub 유료 플랜·Runner 분·프라이빗 레지스트리 비용이 빠르게 늘어납니다. &lt;strong&gt;GitLab Community Edition(CE)&lt;/strong&gt; 은 셀프호스트 시 &lt;strong&gt;코어 DevOps 기능을 라이선스 비용 없이&lt;/strong&gt; 쓸 수 있어, AWS에 올려두면 “우리만의 CI/CD 허브”가 됩니다.&lt;/p&gt;

&lt;p&gt;다만 &lt;strong&gt;효율적&lt;/strong&gt;이라는 말은 “가장 싼 EC2”가 아닙니다.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;앱은 EC2, 상태는 RDS·ElastiCache·S3, CI는 Runner 분리, 백업은 자동화&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href=&quot;https://docs.gitlab.com/administration/reference_architectures/&quot;&gt;GitLab 공식 문서&lt;/a&gt;도 동일한 방향을 권장합니다. 번들 PostgreSQL·Redis 대신 &lt;strong&gt;Amazon RDS·ElastiCache·S3&lt;/strong&gt;를 쓰고, 1,000명 이하라면 &lt;strong&gt;단일 EC2 + 스냅샷 전략&lt;/strong&gt;으로 시작할 수 있습니다.&lt;/p&gt;

&lt;p&gt;이 글은 &lt;strong&gt;5명 소규모 팀이 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;t4g.medium&lt;/code&gt;에서 겨우 돌아가는 설정&lt;/strong&gt;부터, 팀이 커졌을 때 &lt;strong&gt;RDS·S3·Runner 분리&lt;/strong&gt;까지 규모별로 &lt;strong&gt;가장 비용 대비 성능이 좋은 GitLab CE AWS 구성&lt;/strong&gt;을 정리합니다.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;컨테이너·배포 패턴은 &lt;a href=&quot;/2025/07/08/aws-saa-ecs-fargate-rds-web-architecture/&quot;&gt;ECS Fargate 아키텍처&lt;/a&gt;와 &lt;a href=&quot;/2025/10/11-django-ninja-docker-deployment-guide/&quot;&gt;Docker 배포 가이드&lt;/a&gt;를, IaC는 &lt;a href=&quot;/2025/07/10/aws-saa-cloudformation-cdk-infrastructure-as-code/&quot;&gt;CloudFormation/CDK&lt;/a&gt; 글과 함께 보면 좋습니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;0-결론부터-규모별-가장-효율적인-선택&quot;&gt;0. 결론부터: 규모별 “가장 효율적인” 선택&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;HA&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;~5명, 비용 최우선&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;t4g.medium&lt;/code&gt; Omnibus 올인원&lt;/strong&gt; + 메모리 튜닝&lt;/td&gt;
      &lt;td&gt;✗&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;$30~45&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;~30명, CI 가벼움&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;EC2 1대 (Omnibus) + EBS 스냅샷&lt;/td&gt;
      &lt;td&gt;✗&lt;/td&gt;
      &lt;td&gt;$80~150&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;~100명, 프로덕션 CI&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;EC2 + &lt;strong&gt;RDS&lt;/strong&gt; + &lt;strong&gt;ElastiCache&lt;/strong&gt; + &lt;strong&gt;S3&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;✗&lt;/td&gt;
      &lt;td&gt;$250~450&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;~300명, 24/7&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;EC2 ASG(2) + RDS Multi-AZ + S3 + NLB&lt;/td&gt;
      &lt;td&gt;△ (앱만)&lt;/td&gt;
      &lt;td&gt;$600~1,000&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;1,000명+ / HA 필수&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://docs.gitlab.com/administration/reference_architectures/&quot;&gt;Reference Architecture&lt;/a&gt; + &lt;strong&gt;GET&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;✓&lt;/td&gt;
      &lt;td&gt;$2,000+&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;strong&gt;5명 이하 팀&lt;/strong&gt;은 첫 번째 줄(올인원 + 튜닝)로 시작해도 충분합니다. &lt;strong&gt;~100명&lt;/strong&gt;이 sweet spot인 &lt;strong&gt;RDS·ElastiCache·S3 분리&lt;/strong&gt;는 메모리·운영 여유가 생긴 뒤 단계적으로 적용하세요.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;1-소규모-팀-실전--5명t4gmedium으로-gitlab-운영하기&quot;&gt;1. 소규모 팀 실전 — 5명·&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;t4g.medium&lt;/code&gt;으로 GitLab 운영하기&lt;/h2&gt;

&lt;p&gt;실제 5명 규모 팀이 AWS &lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;t4g.medium&lt;/code&gt;(2 vCPU, 4 GiB RAM, Graviton)&lt;/strong&gt; 에 GitLab CE Omnibus를 올려 운영 중인 사례를 기준으로, &lt;strong&gt;겨우 돌아가는 선에서 안정화하는 방법&lt;/strong&gt;을 정리합니다.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;GitLab 공식 &lt;a href=&quot;https://docs.gitlab.com/omnibus/settings/memory_constrained_envs/&quot;&gt;메모리 제약 환경 가이드&lt;/a&gt;도 &lt;strong&gt;4GB 미만·소규모 팀&lt;/strong&gt;을 전제로 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;puma[&apos;worker_processes&apos;] = 0&lt;/code&gt; 등을 권장합니다. 다만 커뮤니티에서는 &lt;strong&gt;체감 속도를 위해 8GB(&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;t4g.large&lt;/code&gt;)&lt;/strong&gt; 를 권하는 의견도 많습니다. 4GB는 &lt;strong&gt;비용 최우선·동시 접속 1~2명&lt;/strong&gt; 전제입니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;11-5명-팀에-맞는-아키텍처-미니멀&quot;&gt;1.1 5명 팀에 맞는 아키텍처 (미니멀)&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;flowchart LR
  Dev[개발자 5명] --&amp;gt; GL[t4g.medium Omnibus]
  GL --&amp;gt; EBS[(gp3 40~80GB)]
  GL -.선택.-&amp;gt; S3[(S3 백업만)]
  Dev2[로컬 PC / t4g.small] -.CI 필요 시.-&amp;gt; Runner[GitLab Runner]
  Runner --&amp;gt; GL
&lt;/code&gt;&lt;/pre&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;항목&lt;/th&gt;
      &lt;th&gt;5명 팀 현실적 선택&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;GitLab 본체&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;t4g.medium&lt;/code&gt; 1대, &lt;strong&gt;Omnibus 올인원&lt;/strong&gt; (PG·Redis 내장)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;RDS / ElastiCache&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;당분간 생략&lt;/strong&gt; — RAM 4GB에 매니지드 분리까지는 부담&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Container Registry&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;끄기&lt;/strong&gt; — Docker Hub·GHCR·ECR Public 사용&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;GitLab Pages&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;끄기&lt;/strong&gt; — Vercel·S3 정적 호스팅&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;GitLab KAS&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;끄기&lt;/strong&gt; — Kubernetes Agent 미사용 시 불필요&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;모니터링 스택&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;끄기&lt;/strong&gt; — CloudWatch 기본 알람만&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;CI&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;같은 EC2에 두지 말 것&lt;/strong&gt; — 로컬 Runner 또는 빌드 시만 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;t4g.small&lt;/code&gt; 기동&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;12-메모리가-어디로-가는가&quot;&gt;1.2 메모리가 어디로 가는가&lt;/h3&gt;

&lt;p&gt;Omnibus 기본 설치는 &lt;strong&gt;Prometheus·Grafana·Exporter·KAS·Registry&lt;/strong&gt;까지 켜면 4GB에서 금방 OOM(Out Of Memory)이 납니다. 대략적인 감각은 다음과 같습니다.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;프로세스&lt;/th&gt;
      &lt;th&gt;대략 RAM&lt;/th&gt;
      &lt;th&gt;5명 팀&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Puma (clustered, worker 2+)&lt;/td&gt;
      &lt;td&gt;1.5~3 GB+&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;worker 0 → 단일 프로세스&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Sidekiq&lt;/td&gt;
      &lt;td&gt;500MB~1GB+&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;concurrency 5 이하&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;PostgreSQL (내장)&lt;/td&gt;
      &lt;td&gt;200~500 MB&lt;/td&gt;
      &lt;td&gt;유지 (공식 최소 구성)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Redis (내장)&lt;/td&gt;
      &lt;td&gt;50~200 MB&lt;/td&gt;
      &lt;td&gt;유지&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Gitaly&lt;/td&gt;
      &lt;td&gt;200~500 MB&lt;/td&gt;
      &lt;td&gt;유지&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Prometheus + Exporters&lt;/td&gt;
      &lt;td&gt;300~800 MB&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;전부 끄기&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Registry / Pages / KAS&lt;/td&gt;
      &lt;td&gt;각 100~300 MB&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;끄기&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;13-실전-gitlabrb--메모리-절약-5명-팀-적용-예&quot;&gt;1.3 실전 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gitlab.rb&lt;/code&gt; — 메모리 절약 (5명 팀 적용 예)&lt;/h3&gt;

&lt;p&gt;아래는 &lt;strong&gt;실제 5명 팀이 적용해 동작을 확인한 설정&lt;/strong&gt;입니다. &lt;a href=&quot;https://docs.gitlab.com/omnibus/settings/memory_constrained_envs/&quot;&gt;공식 메모리 제약 가이드&lt;/a&gt;와 함께 보세요.&lt;/p&gt;

&lt;div class=&quot;language-ruby 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;c1&quot;&gt;# /etc/gitlab/gitlab.rb — 소규모·메모리 제약 (t4g.medium)&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# --- Puma: 클러스터 모드 해제 (단일 프로세스) ---&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;# worker_processes = 0 은 공식 권장. 100~400MB 절감, 동시 처리량은 감소.&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;puma&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;worker_processes&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;puma&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;min_threads&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;puma&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;max_threads&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;2&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# --- Sidekiq: 백그라운드 job 동시성 축소 ---&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;# 키는 max_concurrency 가 표준. 구버전/별칭으로 concurrency 도 쓰이는 경우 있음.&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;sidekiq&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;max_concurrency&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;5&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# --- 내장 모니터링 전부 끄기 (CloudWatch로 대체) ---&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;prometheus_monitoring&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;enable&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kp&quot;&gt;false&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;prometheus&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;enable&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kp&quot;&gt;false&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;grafana&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;enable&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kp&quot;&gt;false&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;alertmanager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;enable&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kp&quot;&gt;false&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;node_exporter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;enable&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kp&quot;&gt;false&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;redis_exporter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;enable&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kp&quot;&gt;false&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;postgres_exporter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;enable&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kp&quot;&gt;false&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;gitlab_exporter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;enable&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kp&quot;&gt;false&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# --- 5명 팀에 불필요한 부가 기능 ---&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;gitlab_kas&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;enable&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kp&quot;&gt;false&lt;/span&gt;          &lt;span class=&quot;c1&quot;&gt;# Kubernetes Agent&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;gitlab_pages&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;enable&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kp&quot;&gt;false&lt;/span&gt;        &lt;span class=&quot;c1&quot;&gt;# 정적 사이트 호스팅&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;registry&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;enable&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kp&quot;&gt;false&lt;/span&gt;            &lt;span class=&quot;c1&quot;&gt;# Container Registry&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;gitlab_rails&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;registry_enabled&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kp&quot;&gt;false&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# --- jemalloc: 메모리 반환 촉진 (공식 권장) ---&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;gitlab_rails&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;env&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;s1&quot;&gt;&apos;MALLOC_CONF&apos;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;dirty_decay_ms:1000,muzzy_decay_ms:1000&apos;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;적용:&lt;/p&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;&lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;gitlab-ctl reconfigure
&lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;gitlab-ctl restart
&lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;gitlab-rake gitlab:check &lt;span class=&quot;nv&quot;&gt;SANITIZE&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;puma[&apos;worker_processes&apos;] = 0&lt;/code&gt;이란?&lt;/strong&gt;&lt;br /&gt;
Puma &lt;strong&gt;클러스터 모드를 끄고&lt;/strong&gt; 워커 프로세스 1개만 쓰는 설정입니다. “워커 0개”가 아니라 &lt;strong&gt;단일 Puma&lt;/strong&gt;로 동작합니다. MR·push가 겹치면 응답이 잠깐 느려질 수 있지만, 5명 규모에서는 허용 가능한 트레이드오프입니다.&lt;/p&gt;

&lt;h3 id=&quot;14-ec2디스크스왑--4gb에서-oom-막기&quot;&gt;1.4 EC2·디스크·스왑 — 4GB에서 OOM 막기&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;strong&gt;EBS&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;gp3 &lt;strong&gt;40GB 이상&lt;/strong&gt;, IOPS &lt;strong&gt;최소 3000&lt;/strong&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;strong&gt;2GB&lt;/strong&gt; 파일 스왑 추가 (OOM 킬 방지용 안전망)&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;t4g&lt;/code&gt;는 버스트형 — CI를 같은 인스턴스에서 돌리면 CPU 크레딧 고갈&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&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;&lt;span class=&quot;c&quot;&gt;# 스왑 2GB (재부팅 후에도 유지하려면 /etc/fstab 등록)&lt;/span&gt;
&lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;fallocate &lt;span class=&quot;nt&quot;&gt;-l&lt;/span&gt; 2G /swapfile
&lt;span class=&quot;nb&quot;&gt;sudo chmod &lt;/span&gt;600 /swapfile
&lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;mkswap /swapfile
&lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;swapon /swapfile
&lt;span class=&quot;nb&quot;&gt;echo&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;/swapfile none swap sw 0 0&apos;&lt;/span&gt; | &lt;span class=&quot;nb&quot;&gt;sudo tee&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-a&lt;/span&gt; /etc/fstab

&lt;span class=&quot;c&quot;&gt;# swappiness 낮추기 — RAM 우선, 스왑은 비상용&lt;/span&gt;
&lt;span class=&quot;nb&quot;&gt;echo&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;vm.swappiness=10&apos;&lt;/span&gt; | &lt;span class=&quot;nb&quot;&gt;sudo tee&lt;/span&gt; /etc/sysctl.d/99-gitlab-swappiness.conf
&lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;sysctl &lt;span class=&quot;nt&quot;&gt;-p&lt;/span&gt; /etc/sysctl.d/99-gitlab-swappiness.conf
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;메모리 확인 루틴:&lt;/p&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;free &lt;span class=&quot;nt&quot;&gt;-h&lt;/span&gt;
&lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;gitlab-ctl status
ps aux &lt;span class=&quot;nt&quot;&gt;--sort&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;-%mem | &lt;span class=&quot;nb&quot;&gt;head&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-15&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;15-소규모-팀-운영-원칙&quot;&gt;1.5 소규모 팀 운영 원칙&lt;/h3&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;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;MR·코드 리뷰·이슈만 GitLab&lt;/td&gt;
      &lt;td&gt;핵심 가치&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Docker 이미지는 &lt;strong&gt;외부 레지스트리&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Registry RAM·디스크 절약&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;정적 문서·블로그는 &lt;strong&gt;별도 호스팅&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Pages 비활성&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;LFS는 &lt;strong&gt;당분간 자제&lt;/strong&gt; 또는 S3 연동 후 소량만&lt;/td&gt;
      &lt;td&gt;디스크·RAM&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;strong&gt;CI/CD&lt;/strong&gt;&lt;/p&gt;

&lt;div class=&quot;language-yaml 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;c1&quot;&gt;# .gitlab-ci.yml — 5명 팀: 가벼운 job만 같은 망에서&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;default&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;interruptible&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;true&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;timeout&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;15m&lt;/span&gt;

&lt;span class=&quot;na&quot;&gt;variables&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;ARTIFACT_EXPIRATION&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;days&quot;&lt;/span&gt;

&lt;span class=&quot;na&quot;&gt;test&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;stage&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;test&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;script&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;pytest -q&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;rules&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;$CI_PIPELINE_SOURCE == &quot;merge_request_event&quot;&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;tags&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;local-runner&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;]&lt;/span&gt;   &lt;span class=&quot;c1&quot;&gt;# 개발 PC 또는 별도 소형 EC2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;무거운 Docker build&lt;/strong&gt;는 GitLab EC2에서 실행하지 않기&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;interruptible: true&lt;/code&gt; — 새 push 시 이전 파이프라인 취소&lt;/li&gt;
  &lt;li&gt;아티팩트 &lt;strong&gt;3~7일&lt;/strong&gt; 만료 (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Admin → Settings → CI/CD&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;업그레이드·백업&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;GitLab &lt;strong&gt;마이너 업그레이드&lt;/strong&gt; 전 반드시 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gitlab-backup create&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;백업만큼은 &lt;strong&gt;S3 1버킷&lt;/strong&gt;(&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gl-backups-*&lt;/code&gt;) 연결 권장 — 월 $1 미만으로 DR 확보&lt;/li&gt;
  &lt;li&gt;업그레이드 직후 1~2일은 메모리 사용량 증가 사례 있음 → &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;htop&lt;/code&gt; 관찰&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;16-소규모-최적화-추가-체크리스트&quot;&gt;1.6 소규모 최적화 추가 체크리스트&lt;/h3&gt;

&lt;div class=&quot;language-ruby 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;c1&quot;&gt;# 추가로 검토할 gitlab.rb (상황에 따라)&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# 로그 레벨·보관 축소&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;logging&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;log_level&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;warn&apos;&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# 내장 PostgreSQL (올인원일 때)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;postgresql&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;shared_buffers&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;128MB&apos;&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;postgresql&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;max_connections&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;100&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# 내장 Redis&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;redis&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;maxmemory&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;128mb&apos;&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;redis&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;maxmemory_policy&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;allkeys-lru&apos;&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# 아티팩트 로컬 디스크 압박 시 — 여유 생기면 S3로 이전&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;# gitlab_rails[&apos;artifacts_enabled&apos;] = true&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;# gitlab_rails[&apos;object_store&apos;][&apos;enabled&apos;] = true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;관측 (Prometheus 대신)&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;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;EC2 메모리·CPU&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;CloudWatch&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mem_used_percent&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CPUUtilization&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;disk_used_percent&lt;/code&gt; &amp;gt; 80% 알람&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;GitLab 살아있음&lt;/td&gt;
      &lt;td&gt;외부 &lt;strong&gt;UptimeRobot&lt;/strong&gt; / Route 53 Health Check&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Sidekiq 적체&lt;/td&gt;
      &lt;td&gt;주 1회 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sudo gitlab-rails runner &quot;puts Sidekiq::Queue.all.map(&amp;amp;:size)&quot;&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;17-언제-스펙을-올릴까--신호등&quot;&gt;1.7 언제 스펙을 올릴까 — 신호등&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;주 1회 이상 OOM·&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gitlab-ctl&lt;/code&gt; 재시작&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;t4g.large&lt;/code&gt;(8GB)&lt;/strong&gt; 또는 튜닝 재검토&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;MR 중 UI 10초+ 로딩이 일상&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;puma max_threads&lt;/code&gt; 2→4, 또는 스펙 업&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Sidekiq queue가 계속 쌓임&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;max_concurrency&lt;/code&gt; 5→8 + 스펙 업&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;CI를 GitLab에 붙이고 싶다&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;Runner 전용 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;t4g.small&lt;/code&gt;&lt;/strong&gt; 분리&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;팀 10명+·repo 50GB+&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;S3 아티팩트&lt;/strong&gt; + &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;m7g.large&lt;/code&gt; 검토&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;24/7 SLA 필요&lt;/td&gt;
      &lt;td&gt;RDS 분리·Multi-AZ — 이 글 3장 이후 구성&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;18-5명-팀-월-비용-서울&quot;&gt;1.8 5명 팀 월 비용 (서울)&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;월 (USD)&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;EC2&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;t4g.medium&lt;/code&gt; on-demand&lt;/td&gt;
      &lt;td&gt;~27&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;EBS&lt;/td&gt;
      &lt;td&gt;gp3 50GB&lt;/td&gt;
      &lt;td&gt;~5&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Elastic IP&lt;/td&gt;
      &lt;td&gt;1개 (인스턴스에 연결 시 무료)&lt;/td&gt;
      &lt;td&gt;0&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;S3 백업&lt;/td&gt;
      &lt;td&gt;10GB&lt;/td&gt;
      &lt;td&gt;~1&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;/td&gt;
      &lt;td&gt;&lt;strong&gt;~33&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;1년 &lt;strong&gt;Reserved&lt;/strong&gt;·&lt;strong&gt;Savings Plan&lt;/strong&gt;이면 ~20% 추가 절감. &lt;strong&gt;HA·RDS 없이&lt;/strong&gt; 월 $35 안팎이 5명 팀 GitLab의 현실적인 바닥선입니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;2-왜-aws--gitlab-ce인가&quot;&gt;2. 왜 AWS + GitLab CE인가&lt;/h2&gt;

&lt;h3 id=&quot;21-ce로-가능한-것-2026-기준-핵심&quot;&gt;2.1 CE로 가능한 것 (2026 기준 핵심)&lt;/h3&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;기능&lt;/th&gt;
      &lt;th&gt;CE&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Git 저장소·MR·코드 리뷰&lt;/td&gt;
      &lt;td&gt;✓&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;CI/CD (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.gitlab-ci.yml&lt;/code&gt;)&lt;/td&gt;
      &lt;td&gt;✓&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Container Registry&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;제한 (Ultimate 기능은 유료)&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;소규모·중소 팀의 &lt;strong&gt;빌드·배포·레지스트리&lt;/strong&gt;에는 CE만으로 충분한 경우가 많습니다.&lt;/p&gt;

&lt;h3 id=&quot;22-aws를-쓰는-이유&quot;&gt;2.2 AWS를 쓰는 이유&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;strong&gt;RDS/ElastiCache&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;DB·Redis 패치·백업 자동화&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;S3&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;아티팩트·LFS·백업 — GB당 저렴&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;IAM Role&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;EC2에 키 없이 S3 접근&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;ASG + Runner&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;CI 부하만 탄력 확장&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;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;3-효율적인-아키텍처-한-장&quot;&gt;3. 효율적인 아키텍처 한 장&lt;/h2&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;5명·t4g.medium&lt;/strong&gt; 팀은 &lt;a href=&quot;#1-소규모-팀-실전--5명t4gmedium으로-gitlab-운영하기&quot;&gt;1장 소규모 실전&lt;/a&gt; 구성을 먼저 보세요. 이 장부터는 &lt;strong&gt;팀이 커지거나 RDS·S3 분리&lt;/strong&gt;를 검토할 때의 설계입니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;31-권장-구성-100명-내외-팀&quot;&gt;3.1 권장 구성 (100명 내외 팀)&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;flowchart TB
  subgraph Users[&quot;개발자&quot;]
    Dev[Git Push / MR / CI]
  end

  subgraph Edge[&quot;엣지&quot;]
    R53[Route 53]
    ACM[ACM TLS]
    NLB[NLB / ALB]
  end

  subgraph App[&quot;Private Subnet&quot;]
    GL[EC2 GitLab Omnibus]
    Runner[EC2 GitLab Runner ASG]
  end

  subgraph Managed[&quot;AWS Managed&quot;]
    RDS[(RDS PostgreSQL)]
    Redis[(ElastiCache Redis)]
    S3[(S3 Buckets)]
  end

  Dev --&amp;gt; R53 --&amp;gt; ACM --&amp;gt; NLB --&amp;gt; GL
  GL --&amp;gt; RDS
  GL --&amp;gt; Redis
  GL --&amp;gt; S3
  Runner --&amp;gt; GL
  Runner --&amp;gt; S3
&lt;/code&gt;&lt;/pre&gt;

&lt;h3 id=&quot;32-gitlab-컴포넌트--aws-서비스-매핑&quot;&gt;3.2 GitLab 컴포넌트 ↔ AWS 서비스 매핑&lt;/h3&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;GitLab 역할&lt;/th&gt;
      &lt;th&gt;비효율 (올인원 디스크)&lt;/th&gt;
      &lt;th&gt;&lt;strong&gt;효율 (권장)&lt;/strong&gt;&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;PostgreSQL&lt;/td&gt;
      &lt;td&gt;Omnibus 내장 DB&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;RDS PostgreSQL&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Redis&lt;/td&gt;
      &lt;td&gt;Omnibus 내장&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;ElastiCache Redis&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;아티팩트·LFS·업로드&lt;/td&gt;
      &lt;td&gt;로컬 디스크&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;S3&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Container Registry&lt;/td&gt;
      &lt;td&gt;로컬 디스크&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;S3 (registry backend)&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;백업&lt;/td&gt;
      &lt;td&gt;수동 tarball&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;S3 + RDS 스냅샷&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;CI 실행&lt;/td&gt;
      &lt;td&gt;GitLab EC2에서 빌드&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;별도 Runner EC2/ASG&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;공식 AWS 솔루션 가이드도 동일하게 &lt;strong&gt;RDS + ElastiCache + S3 + ELB&lt;/strong&gt; 조합을 검증했습니다. (&lt;a href=&quot;https://docs.gitlab.com/solutions/cloud/aws/gitlab_instance_on_aws/&quot;&gt;Provision GitLab on AWS&lt;/a&gt;)&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;4-네트워크-설계-vpc&quot;&gt;4. 네트워크 설계 (VPC)&lt;/h2&gt;

&lt;h3 id=&quot;41-cidr-예시-ap-northeast-2&quot;&gt;4.1 CIDR 예시 (ap-northeast-2)&lt;/h3&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;리소스&lt;/th&gt;
      &lt;th&gt;AZ-a&lt;/th&gt;
      &lt;th&gt;AZ-c&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Public &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;10.0.0.0/24&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;NLB, NAT&lt;/td&gt;
      &lt;td&gt;NLB&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Private App &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;10.0.10.0/24&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;GitLab EC2&lt;/td&gt;
      &lt;td&gt;GitLab EC2 (선택)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Private Runner &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;10.0.20.0/24&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Runner&lt;/td&gt;
      &lt;td&gt;Runner ASG&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;DB &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;10.0.30.0/24&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;RDS&lt;/td&gt;
      &lt;td&gt;RDS standby&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;ul&gt;
  &lt;li&gt;GitLab·Runner·RDS는 &lt;strong&gt;Private Subnet&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;NLB만 Public (또는 ALB + ACM)&lt;/li&gt;
  &lt;li&gt;Runner가 외부 이미지 pull 시 &lt;strong&gt;NAT Gateway&lt;/strong&gt; 1개 (비용 주의 — &lt;a href=&quot;https://docs.aws.amazon.com/vpc/latest/privatelink/vpc-endpoints-s3.html&quot;&gt;VPC 엔드포인트&lt;/a&gt;로 S3/ECR 트래픽 절감)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;42-보안-그룹-최소&quot;&gt;4.2 보안 그룹 (최소)&lt;/h3&gt;

&lt;div class=&quot;language-yaml 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;na&quot;&gt;gitlab-sg&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;inbound&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;port 443 from nlb-sg&lt;/span&gt;
    &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;port 22 from bastion-sg only&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;outbound&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;all (또는 RDS/Redis/S3 엔드포인트만)&lt;/span&gt;

&lt;span class=&quot;na&quot;&gt;rds-sg&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;inbound&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;port 5432 from gitlab-sg, runner-sg&lt;/span&gt;

&lt;span class=&quot;na&quot;&gt;elasticache-sg&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;inbound&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;port 6379 from gitlab-sg&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;5-컴퓨트-사이징--reference-architecture-기준&quot;&gt;5. 컴퓨트 사이징 — Reference Architecture 기준&lt;/h2&gt;

&lt;p&gt;&lt;a href=&quot;https://docs.gitlab.com/administration/reference_architectures/&quot;&gt;GitLab Reference Architectures&lt;/a&gt;의 &lt;strong&gt;1,000 users / 20 RPS&lt;/strong&gt; 를 소규모 프로덕션 출발점으로 씁니다.&lt;/p&gt;

&lt;h3 id=&quot;51-ec2-gitlab-앱-노드&quot;&gt;5.1 EC2 (GitLab 앱 노드)&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;vCPU&lt;/th&gt;
      &lt;th&gt;RAM&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;마이크로 (5명)&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;t4g.medium&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;2&lt;/td&gt;
      &lt;td&gt;4 GiB&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;올인원 + 1장 튜닝 필수&lt;/strong&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;t4g.large&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;2&lt;/td&gt;
      &lt;td&gt;8 GiB&lt;/td&gt;
      &lt;td&gt;5~15명, 튜닝 완화&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;m7i.large&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;2&lt;/td&gt;
      &lt;td&gt;8 GiB&lt;/td&gt;
      &lt;td&gt;~50명, CI 적음&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;m7i.xlarge&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;4&lt;/td&gt;
      &lt;td&gt;16 GiB&lt;/td&gt;
      &lt;td&gt;~100~200명&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;CI 분리 후&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;m7i.large&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;2&lt;/td&gt;
      &lt;td&gt;8 GiB&lt;/td&gt;
      &lt;td&gt;앱만 (Runner 별도)&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;ul&gt;
  &lt;li&gt;디스크: &lt;strong&gt;gp3 100~200GB&lt;/strong&gt; (로그·잠깐의 로컬 캐시; 대용량은 S3)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Reserved Instance (1년)&lt;/strong&gt; 적용 시 컴퓨트 30~40% 절감&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;52-rds-postgresql&quot;&gt;5.2 RDS PostgreSQL&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;PostgreSQL &lt;strong&gt;16+&lt;/strong&gt; (GitLab 19.x는 PG 17 권장 — &lt;a href=&quot;https://gitlab.com/gitlab-org/gitlab-environment-toolkit&quot;&gt;GET 3.10&lt;/a&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;db.m7g.large&lt;/code&gt; (2 vCPU, 8 GiB) 시작&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;스토리지&lt;/td&gt;
      &lt;td&gt;gp3 100GB, autoscaling&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Multi-AZ&lt;/td&gt;
      &lt;td&gt;100명 미만 단일 AZ 가능 / 프로덕션은 Multi-AZ&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;max_connections&lt;/code&gt; GitLab 튜닝 가이드 따름&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;RDS Proxy&lt;/strong&gt;는 GitLab 공식 &lt;strong&gt;미검증&lt;/strong&gt;. 직접 RDS 연결 + PgBouncer(Omnibus 내장) 조합이 일반적입니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;53-elasticache-redis&quot;&gt;5.3 ElastiCache Redis&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;Redis 7.x&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;cache.t4g.medium&lt;/code&gt; (스타터) → &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cache.m7g.large&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;클러스터&lt;/td&gt;
      &lt;td&gt;단일 노드로 시작, HA 필요 시 replication group&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;54-s3-버킷-분리&quot;&gt;5.4 S3 버킷 분리&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;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gl-artifacts-{account}&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;CI 아티팩트&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gl-lfs-{account}&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Git LFS&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gl-uploads-{account}&lt;/code&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;gl-registry-{account}&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Container Registry&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gl-backups-{account}&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gitlab-backup&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;strong&gt;수명 주기&lt;/strong&gt;: 아티팩트 30~90일 만료, 백업은 Glacier 전환.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;6-iam--키-없이-s3-접근&quot;&gt;6. IAM — 키 없이 S3 접근&lt;/h2&gt;

&lt;p&gt;EC2 인스턴스 프로필에 최소 권한 정책을 붙입니다. (&lt;a href=&quot;https://docs.gitlab.com/install/aws/&quot;&gt;공식 POC 가이드&lt;/a&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;Version&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;2012-10-17&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;Statement&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;p&quot;&gt;[&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;span class=&quot;nl&quot;&gt;&quot;Effect&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;Allow&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;Action&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;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;s3:PutObject&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;s3:GetObject&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;s3:DeleteObject&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;Resource&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;arn:aws:s3:::gl-*/*&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;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;Effect&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;Allow&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;Action&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;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
        &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;s3:ListBucket&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;s3:AbortMultipartUpload&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;s3:ListMultipartUploadParts&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;span class=&quot;nl&quot;&gt;&quot;Resource&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;arn:aws:s3:::gl-*&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;span class=&quot;p&quot;&gt;]&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;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gitlab.rb&lt;/code&gt;에는 &lt;strong&gt;Access Key를 넣지 않고&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;use_iam_profile true&lt;/code&gt;를 사용합니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;7-gitlab-설정-gitlabrb--효율-구성-핵심&quot;&gt;7. GitLab 설정 (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gitlab.rb&lt;/code&gt;) — 효율 구성 핵심&lt;/h2&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;5명·4GB&lt;/strong&gt; 환경은 &lt;a href=&quot;#13-실전-gitlabrb--메모리-절약-5명-팀-적용-예&quot;&gt;1.3절 소규모 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gitlab.rb&lt;/code&gt;&lt;/a&gt;를 우선 적용하세요. 이 절은 &lt;strong&gt;RDS·S3 분리 후&lt;/strong&gt; 중규모 구성입니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;71-외부-dbrediss3-연결&quot;&gt;7.1 외부 DB·Redis·S3 연결&lt;/h3&gt;

&lt;div class=&quot;language-ruby 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;c1&quot;&gt;# /etc/gitlab/gitlab.rb (발췌)&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;external_url&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;https://gitlab.example.com&apos;&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# PostgreSQL → RDS&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;gitlab_rails&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;db_adapter&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;postgresql&apos;&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;gitlab_rails&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;db_host&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;gitlab.xxxxx.ap-northeast-2.rds.amazonaws.com&apos;&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;gitlab_rails&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;db_port&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;5432&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;gitlab_rails&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;db_database&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;gitlabhq_production&apos;&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;gitlab_rails&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;db_username&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;gitlab&apos;&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;gitlab_rails&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;db_password&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;{{ Secrets Manager에서 주입 }}&apos;&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# Redis → ElastiCache&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;gitlab_rails&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;redis_host&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;gitlab.xxxxx.cache.amazonaws.com&apos;&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;gitlab_rails&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;redis_port&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;6379&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# Object Storage → S3 (통합 설정 예시)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;gitlab_rails&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;object_store&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;][&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;enabled&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kp&quot;&gt;true&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;gitlab_rails&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;object_store&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;][&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;connection&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;s1&quot;&gt;&apos;provider&apos;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;AWS&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;s1&quot;&gt;&apos;region&apos;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;ap-northeast-2&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;s1&quot;&gt;&apos;use_iam_profile&apos;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;kp&quot;&gt;true&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;gitlab_rails&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;object_store&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;][&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;objects&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;][&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;artifacts&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;][&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;bucket&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;gl-artifacts-123456&apos;&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;gitlab_rails&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;object_store&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;][&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;objects&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;][&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;lfs&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;][&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;bucket&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;gl-lfs-123456&apos;&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;gitlab_rails&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;objects&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;][&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;uploads&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;][&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;bucket&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;gl-uploads-123456&apos;&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# Container Registry&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;registry&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;enable&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kp&quot;&gt;true&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;gitlab_rails&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;registry_enabled&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kp&quot;&gt;true&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;gitlab_rails&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;registry_bucket&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;gl-registry-123456&apos;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;적용:&lt;/p&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;&lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;gitlab-ctl reconfigure
&lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;gitlab-ctl restart
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;72-내장-postgresqlredis-비활성화&quot;&gt;7.2 내장 PostgreSQL·Redis 비활성화&lt;/h3&gt;

&lt;p&gt;RDS/ElastiCache로 옮긴 뒤에는 Omnibus 번들 서비스를 끕니다.&lt;/p&gt;

&lt;div class=&quot;language-ruby 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;n&quot;&gt;postgresql&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;enable&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kp&quot;&gt;false&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;redis&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;enable&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kp&quot;&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;이렇게 해야 &lt;strong&gt;EC2 메모리를 앱·Sidekiq·Gitaly에 집중&lt;/strong&gt;할 수 있습니다.&lt;/p&gt;

&lt;h3 id=&quot;73-sidekiqpuma-튜닝--규모별&quot;&gt;7.3 Sidekiq·Puma 튜닝 — 규모별&lt;/h3&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;규모&lt;/th&gt;
      &lt;th&gt;puma&lt;/th&gt;
      &lt;th&gt;sidekiq&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;5명 / t4g.medium&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;worker_processes = 0&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;max_threads = 2&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;max_concurrency = 5&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;~50명 / m7i.large&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;worker_processes = 2&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;max_concurrency = 10&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;~100명 / m7i.xlarge&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;worker_processes = 4&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;max_concurrency = 20&lt;/code&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;htop&lt;/code&gt;·CloudWatch로 조정합니다. Omnibus Prometheus를 끈 소규모 팀은 &lt;strong&gt;1.6절 CloudWatch 알람&lt;/strong&gt;으로 대체하세요.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;8-gitlab-runner--비용-효율의-핵심&quot;&gt;8. GitLab Runner — 비용 효율의 핵심&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;GitLab EC2에서 CI까지 돌리면&lt;/strong&gt; push 한 번에 웹 UI가 느려집니다. Runner를 분리하는 것이 &lt;strong&gt;가성비 최고&lt;/strong&gt;입니다.&lt;/p&gt;

&lt;h3 id=&quot;81-runner-배치-옵션&quot;&gt;8.1 Runner 배치 옵션&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;적합&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;개발 PC / Shell Runner&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;무료&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;5명 팀 MVP&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;빌드 시만 t4g.small 기동&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;저&lt;/td&gt;
      &lt;td&gt;주 2~3회 CI&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;EC2 1대 (always on)&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;중&lt;/td&gt;
      &lt;td&gt;빌드 24시간, 팀 ~30명&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;ASG + Scheduled Scaling&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;중~저&lt;/td&gt;
      &lt;td&gt;업무시간 CI 집중&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Spot 인스턴스 Runner&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;저&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;실패 재시도 가능한 빌드&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Docker executor&lt;/strong&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;h3 id=&quot;82-runner-등록-docker-executor&quot;&gt;8.2 Runner 등록 (Docker executor)&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;&lt;span class=&quot;c&quot;&gt;# Runner EC2에 설치&lt;/span&gt;
curl &lt;span class=&quot;nt&quot;&gt;-L&lt;/span&gt; https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh | &lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;bash
&lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;apt-get &lt;span class=&quot;nb&quot;&gt;install &lt;/span&gt;gitlab-runner

&lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;gitlab-runner register &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--url&lt;/span&gt; https://gitlab.example.com &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--token&lt;/span&gt; &amp;lt;RUNNER_TOKEN&amp;gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--executor&lt;/span&gt; docker &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--docker-image&lt;/span&gt; docker:27-dind &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--description&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;aws-docker-spot&quot;&lt;/span&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;/etc/gitlab-runner/config.toml&lt;/code&gt;:&lt;/p&gt;

&lt;div class=&quot;language-toml 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;py&quot;&gt;concurrent&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;4&lt;/span&gt;

&lt;span class=&quot;nn&quot;&gt;[[runners]]&lt;/span&gt;
  &lt;span class=&quot;py&quot;&gt;name&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;aws-docker&quot;&lt;/span&gt;
  &lt;span class=&quot;py&quot;&gt;url&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;https://gitlab.example.com&quot;&lt;/span&gt;
  &lt;span class=&quot;py&quot;&gt;token&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;...&quot;&lt;/span&gt;
  &lt;span class=&quot;py&quot;&gt;executor&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;docker&quot;&lt;/span&gt;
  &lt;span class=&quot;nn&quot;&gt;[runners.docker]&lt;/span&gt;
    &lt;span class=&quot;py&quot;&gt;image&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;docker:27&quot;&lt;/span&gt;
    &lt;span class=&quot;py&quot;&gt;privileged&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;
    &lt;span class=&quot;py&quot;&gt;volumes&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/cache&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;/var/run/docker.sock:/var/run/docker.sock&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
  &lt;span class=&quot;nn&quot;&gt;[runners.cache]&lt;/span&gt;
    &lt;span class=&quot;py&quot;&gt;Type&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;s3&quot;&lt;/span&gt;
    &lt;span class=&quot;py&quot;&gt;Shared&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;
    &lt;span class=&quot;nn&quot;&gt;[runners.cache.s3]&lt;/span&gt;
      &lt;span class=&quot;py&quot;&gt;BucketName&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;gl-runner-cache-123456&quot;&lt;/span&gt;
      &lt;span class=&quot;py&quot;&gt;BucketLocation&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;ap-northeast-2&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;S3 distributed cache&lt;/strong&gt;를 쓰면 Runner ASG 확장 시 캐시 hit률이 올라가 빌드 시간·비용이 줄어듭니다.&lt;/p&gt;

&lt;h3 id=&quot;83-gitlab-ciyml-효율-팁&quot;&gt;8.3 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.gitlab-ci.yml&lt;/code&gt; 효율 팁&lt;/h3&gt;

&lt;div class=&quot;language-yaml 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;na&quot;&gt;default&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;image&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;python:3.12-slim&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;cache&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;key&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;${CI_COMMIT_REF_SLUG}&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;paths&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;.pip-cache/&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;tags&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;aws-docker&lt;/span&gt;

&lt;span class=&quot;na&quot;&gt;stages&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;test&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;build&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;deploy&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;]&lt;/span&gt;

&lt;span class=&quot;na&quot;&gt;test&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;stage&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;test&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;script&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;pip install -r requirements.txt -q --cache-dir .pip-cache&lt;/span&gt;
    &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;pytest -q&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;rules&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;$CI_PIPELINE_SOURCE == &quot;merge_request_event&quot;&lt;/span&gt;

&lt;span class=&quot;na&quot;&gt;build&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;stage&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;build&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;script&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .&lt;/span&gt;
    &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;rules&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;MR에서만 test&lt;/strong&gt;, main에서만 &lt;strong&gt;build/push&lt;/strong&gt; — Runner 분 절약&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;interruptible: true&lt;/strong&gt; — 새 파이프라인 시 이전 job 취소&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;9-프로비저닝-방법--무엇을-쓸까&quot;&gt;9. 프로비저닝 방법 — 무엇을 쓸까&lt;/h2&gt;

&lt;h3 id=&quot;91-방법-비교&quot;&gt;9.1 방법 비교&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;효율&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;수동 Omnibus + 콘솔&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;하&lt;/td&gt;
      &lt;td&gt;POC&lt;/td&gt;
      &lt;td&gt;1~2일 실습&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;공식 AWS POC 가이드&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;중&lt;/td&gt;
      &lt;td&gt;학습&lt;/td&gt;
      &lt;td&gt;HA 아님&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;&lt;a href=&quot;https://gitlab.com/gitlab-org/gitlab-environment-toolkit&quot;&gt;GitLab Environment Toolkit (GET)&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;상&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;프로덕션&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Terraform + Ansible&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;자체 Terraform 모듈&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;상&lt;/td&gt;
      &lt;td&gt;맞춤&lt;/td&gt;
      &lt;td&gt;GET 포크 가능&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;1,000명 이하·&lt;strong&gt;HA 불필요&lt;/strong&gt; → EC2 1대 + EBS 스냅샷도 공식 권장입니다.&lt;/p&gt;

&lt;p&gt;1,000명 이상 또는 &lt;strong&gt;Gitaly Cluster HA&lt;/strong&gt; → &lt;strong&gt;GET&lt;/strong&gt;이 사실상 표준입니다.&lt;/p&gt;

&lt;h3 id=&quot;92-get-한-줄-개요&quot;&gt;9.2 GET 한 줄 개요&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;&lt;span class=&quot;c&quot;&gt;# 개념 흐름&lt;/span&gt;
git clone https://gitlab.com/gitlab-org/gitlab-environment-toolkit.git
&lt;span class=&quot;nb&quot;&gt;cd &lt;/span&gt;gitlab-environment-toolkit
&lt;span class=&quot;c&quot;&gt;# terraform/environments/ 에 aws 환경 변수 정의&lt;/span&gt;
terraform init &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; terraform apply
&lt;span class=&quot;c&quot;&gt;# ansible로 gitlab.rb 배포·클러스터 구성&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;GET은 Reference Architecture 사이즈(2K, 3K, 5K users…)를 &lt;strong&gt;변수로 선택&lt;/strong&gt;하게 되어 있어, 처음부터 과대 프로비저닝하지 않게 돕습니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;10-비용-최적화-체크리스트&quot;&gt;10. 비용 최적화 체크리스트&lt;/h2&gt;

&lt;h3 id=&quot;101-즉시-적용-효과-큼&quot;&gt;10.1 즉시 적용 (효과 큼)&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;Runner를 GitLab EC2에서 &lt;strong&gt;분리&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;앱 인스턴스 다운사이징&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;아티팩트·LFS → &lt;strong&gt;S3&lt;/strong&gt; + lifecycle&lt;/td&gt;
      &lt;td&gt;EBS 용량·스냅샷 비용&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Graviton (m7g/r7g)&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;x86 대비 10~20%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;EC2 &lt;strong&gt;1년 RI&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;온디맨드 대비 ~35%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Runner &lt;strong&gt;Spot&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;CI 컴퓨트 ~70%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;NAT 대신 &lt;strong&gt;S3/ECR VPC Endpoint&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;NAT GB당 과금 절감&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;102-월-비용-대략&quot;&gt;10.2 월 비용 대략&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;5명·t4g.medium (올인원)&lt;/strong&gt; — &lt;a href=&quot;#18-5명-팀-월-비용-서울&quot;&gt;1.8절&lt;/a&gt; 참고 (~$33).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;100명 팀, Runner 분리&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;월 (USD)&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;EC2 GitLab&lt;/td&gt;
      &lt;td&gt;m7i.xlarge on-demand&lt;/td&gt;
      &lt;td&gt;~140&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;RDS&lt;/td&gt;
      &lt;td&gt;db.m7g.large Single-AZ&lt;/td&gt;
      &lt;td&gt;~120&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;ElastiCache&lt;/td&gt;
      &lt;td&gt;cache.t4g.medium&lt;/td&gt;
      &lt;td&gt;~50&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;S3 + 전송&lt;/td&gt;
      &lt;td&gt;200GB&lt;/td&gt;
      &lt;td&gt;~20&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;NLB&lt;/td&gt;
      &lt;td&gt;1개&lt;/td&gt;
      &lt;td&gt;~25&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Runner&lt;/td&gt;
      &lt;td&gt;m7i.large 8h/day Spot&lt;/td&gt;
      &lt;td&gt;~40&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;/td&gt;
      &lt;td&gt;&lt;strong&gt;~400&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;팀 규모·빌드 빈도에 따라 2~3배까지 변동합니다. &lt;strong&gt;CloudWatch Cost Anomaly&lt;/strong&gt; 알람을 꼭 켜세요.&lt;/p&gt;

&lt;h3 id=&quot;103-비효율-패턴-피하기&quot;&gt;10.3 비효율 패턴 (피하기)&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;strong&gt;t4g.medium에 CI·Registry·Prometheus 동시&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;OOM·UI 멈춤 (5명 팀 최대 실수)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Omnibus 올인원 + 거대 EBS&lt;/td&gt;
      &lt;td&gt;백업·복구 느림, 디스크 비쌈&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;GitLab EC2에서 Docker 빌드&lt;/td&gt;
      &lt;td&gt;UI·Git 응답 지연&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;아티팩트 무제한 보관&lt;/td&gt;
      &lt;td&gt;S3 비용 선형 증가&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;t3.micro·스왑 없이 4GB&lt;/td&gt;
      &lt;td&gt;메모리 부족·OOM&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;퍼블릭 RDS/Redis&lt;/td&gt;
      &lt;td&gt;보안 사고&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;11-백업복구업그레이드&quot;&gt;11. 백업·복구·업그레이드&lt;/h2&gt;

&lt;h3 id=&quot;111-백업-3-2-1&quot;&gt;11.1 백업 (3-2-1)&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;&lt;span class=&quot;c&quot;&gt;# /etc/cron.d/gitlab-backup&lt;/span&gt;
0 3 &lt;span class=&quot;k&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;*&lt;/span&gt; root /opt/gitlab/bin/gitlab-backup create &lt;span class=&quot;nv&quot;&gt;CRON&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gitlab.rb&lt;/code&gt;에 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gitlab_rails[&apos;backup_upload_connection&apos;]&lt;/code&gt; → &lt;strong&gt;S3&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;RDS &lt;strong&gt;자동 스냅샷&lt;/strong&gt; 7~35일&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gitlab-secrets.json&lt;/code&gt; · &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gitlab.rb&lt;/code&gt; → &lt;strong&gt;Secrets Manager&lt;/strong&gt; 별도 보관&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;112-복구-드릴&quot;&gt;11.2 복구 드릴&lt;/h3&gt;

&lt;p&gt;분기 1회 &lt;strong&gt;스테이징 VPC&lt;/strong&gt;에서 restore 테스트. “백업은 있는데 복구를 안 해봤다”는 가장 비싼 실수입니다.&lt;/p&gt;

&lt;h3 id=&quot;113-업그레이드&quot;&gt;11.3 업그레이드&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;&lt;span class=&quot;c&quot;&gt;# 메이저 업그레이드 전&lt;/span&gt;
&lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;gitlab-ctl stop puma sidekiq
&lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;gitlab-backup create
&lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;apt-get &lt;span class=&quot;nb&quot;&gt;install &lt;/span&gt;gitlab-ce&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&amp;lt;target-version&amp;gt;
&lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;gitlab-ctl reconfigure
&lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;gitlab-rake gitlab:check &lt;span class=&quot;nv&quot;&gt;SANITIZE&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;PostgreSQL 메이저 버전은 &lt;strong&gt;RDS 파라미터·호환 매트릭스&lt;/strong&gt;를 먼저 확인합니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;12-보안관측&quot;&gt;12. 보안·관측&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;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;TLS&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;ACM&lt;/strong&gt; + NLB&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;비밀&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;Secrets Manager&lt;/strong&gt; → &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gitlab.rb&lt;/code&gt; 템플릿&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;암호화&lt;/td&gt;
      &lt;td&gt;RDS·S3 &lt;strong&gt;KMS&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;SSH&lt;/td&gt;
      &lt;td&gt;SSM Session Manager (22 닫기)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;로그&lt;/td&gt;
      &lt;td&gt;CloudWatch Logs agent / OTLP&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;메트릭&lt;/td&gt;
      &lt;td&gt;중규모: GitLab Prometheus / &lt;strong&gt;소규모: CloudWatch만&lt;/strong&gt; (&lt;a href=&quot;#16-소규모-최적화-추가-체크리스트&quot;&gt;1.6절&lt;/a&gt;)&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;121-필수-알람&quot;&gt;12.1 필수 알람&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;EC2 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;StatusCheckFailed&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;RDS &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FreeStorageSpace&lt;/code&gt; &amp;lt; 20%&lt;/li&gt;
  &lt;li&gt;ElastiCache &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Evictions&lt;/code&gt; &amp;gt; 0&lt;/li&gt;
  &lt;li&gt;GitLab &lt;strong&gt;Sidekiq queue size&lt;/strong&gt; 급증&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;13-단계별-구축-로드맵&quot;&gt;13. 단계별 구축 로드맵&lt;/h2&gt;

&lt;h3 id=&quot;phase-0-1일-5명-팀-미니멀&quot;&gt;Phase 0 (1일): 5명 팀 미니멀&lt;/h3&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;t4g.medium&lt;/code&gt; + Omnibus CE&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#13-실전-gitlabrb--메모리-절약-5명-팀-적용-예&quot;&gt;1.3절 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gitlab.rb&lt;/code&gt;&lt;/a&gt; 적용 + 스왑 2GB&lt;/li&gt;
  &lt;li&gt;HTTPS (Let’s Encrypt 또는 ACM)&lt;/li&gt;
  &lt;li&gt;CI는 &lt;strong&gt;로컬 Shell Runner&lt;/strong&gt; 또는 수동 배포&lt;/li&gt;
  &lt;li&gt;S3 백업 버킷만 연결&lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;phase-1-13일-poc-1030명&quot;&gt;Phase 1 (1~3일): POC (10~30명)&lt;/h3&gt;

&lt;ol&gt;
  &lt;li&gt;EC2 1대 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;m7i.large&lt;/code&gt; 또는 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;t4g.large&lt;/code&gt; + Omnibus&lt;/li&gt;
  &lt;li&gt;Route 53 + ACM&lt;/li&gt;
  &lt;li&gt;Runner &lt;strong&gt;분리&lt;/strong&gt; 검토&lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;phase-2-1주-효율-구성&quot;&gt;Phase 2 (1주): 효율 구성&lt;/h3&gt;

&lt;ol&gt;
  &lt;li&gt;RDS + ElastiCache 마이그레이션&lt;/li&gt;
  &lt;li&gt;S3 object storage 연결&lt;/li&gt;
  &lt;li&gt;Runner EC2 분리&lt;/li&gt;
  &lt;li&gt;백업 S3 cron&lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;phase-3-24주-프로덕션&quot;&gt;Phase 3 (2~4주): 프로덕션&lt;/h3&gt;

&lt;ol&gt;
  &lt;li&gt;NLB + Private subnet&lt;/li&gt;
  &lt;li&gt;ASG Runner + Spot&lt;/li&gt;
  &lt;li&gt;Terraform(GET)으로 IaC화&lt;/li&gt;
  &lt;li&gt;복구 드릴&lt;/li&gt;
&lt;/ol&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;14-흔한-실수-10가지&quot;&gt;14. 흔한 실수 10가지&lt;/h2&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;t4g.medium에 기본 Omnibus 그대로&lt;/strong&gt; — Prometheus·Registry 켜진 채로 OOM&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;CE 한계 무시&lt;/strong&gt; — 필요한 보안 기능이 Ultimate 전용인지 사전 확인&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;단일 AZ RDS를 소규모에 과도 적용&lt;/strong&gt; — 5명 팀은 올인원이 나을 수 있음&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;gitlab-secrets 분실&lt;/strong&gt; — 복구 불가&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Runner privileged Docker&lt;/strong&gt; — 격리·네트워크 정책 필수&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;S3 퍼블릭 버킷&lt;/strong&gt; — Block Public Access&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;디스크 full&lt;/strong&gt; — 로그 로테이션·아티팩트 만료&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;버전 스큐&lt;/strong&gt; — GitLab·PG·Redis 호환 표 미확인&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;스왑 없이 4GB 운영&lt;/strong&gt; — OOM 킬 후 복구 지연&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;모니터링·스펙 업그레이드 신호 무시&lt;/strong&gt; — &lt;a href=&quot;#17-언제-스펙을-올릴까--신호등&quot;&gt;1.7절 신호등&lt;/a&gt; 참고&lt;/li&gt;
&lt;/ol&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;15-정리&quot;&gt;15. 정리&lt;/h2&gt;

&lt;p&gt;AWS에서 &lt;strong&gt;가장 효율적인 GitLab CE&lt;/strong&gt;는 팀 규모에 따라 다릅니다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5명·비용 최우선&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;t4g.medium&lt;/code&gt; 올인원 + Puma 단일 프로세스 + Sidekiq 5 + 모니터링·Registry·Pages·KAS 끄기 + CI는 GitLab 밖&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;~100명·성장기&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;Omnibus는 “앱 실행”만, PostgreSQL·Redis·파일은 RDS·ElastiCache·S3로 분리하고, CI는 Runner를 Spot·ASG로 탄력 운영&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;5명&lt;/strong&gt;이면 &lt;a href=&quot;#1-소규모-팀-실전--5명t4gmedium으로-gitlab-운영하기&quot;&gt;1장&lt;/a&gt; 설정으로 &lt;strong&gt;월 ~$35&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;t4g.large&lt;/code&gt;(8GB)가 가장 싸은 업그레이드&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;~100명&lt;/strong&gt;이면 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;m7i.xlarge&lt;/code&gt; + RDS + S3 + Runner 분리&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;HA·1,000명+&lt;/strong&gt;는 Reference Architecture + GET&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;백업·복구 드릴&lt;/strong&gt;은 규모와 무관하게 필수&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;오늘 5명 팀이라면: &lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gitlab.rb&lt;/code&gt; 메모리 튜닝 + 스왑 + CI 분리&lt;/strong&gt; 세 가지만 해도 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;t4g.medium&lt;/code&gt;에서 체감이 크게 달라집니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;참고-자료&quot;&gt;참고 자료&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://docs.gitlab.com/administration/reference_architectures/&quot;&gt;GitLab Reference Architectures&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://docs.gitlab.com/install/aws/&quot;&gt;Installing GitLab POC on AWS&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://docs.gitlab.com/solutions/cloud/aws/gitlab_instance_on_aws/&quot;&gt;Provision GitLab Instances on AWS&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://gitlab.com/gitlab-org/gitlab-environment-toolkit&quot;&gt;GitLab Environment Toolkit&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://docs.gitlab.com/administration/backup_restore/&quot;&gt;GitLab backup &amp;amp; restore&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://docs.gitlab.com/omnibus/settings/memory_constrained_envs/&quot;&gt;Running GitLab in a memory-constrained environment&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://docs.gitlab.com/administration/operations/puma/&quot;&gt;Puma worker_processes = 0 (clustered mode off)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://docs.gitlab.com/runner/runners/autoscale/&quot;&gt;GitLab Runner autoscale on AWS&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;관련-글&quot;&gt;관련 글&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;/2025/07/08/aws-saa-ecs-fargate-rds-web-architecture/&quot;&gt;AWS ECS Fargate + RDS 웹 아키텍처&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2025/07/10/aws-saa-cloudformation-cdk-infrastructure-as-code/&quot;&gt;CloudFormation/CDK IaC&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2025/10/11-django-ninja-docker-deployment-guide/&quot;&gt;Django Ninja Docker 배포&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2025/08/24-s3-cloudfront-static-website-hosting/&quot;&gt;S3 + CloudFront 정적 호스팅&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2025/12/19-dockerfile-complete-guide-from-basics-to-advanced/&quot;&gt;Dockerfile 완전 가이드&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/01/14-django-ninja-high-traffic-optimization-strategies/&quot;&gt;Django Ninja 고트래픽 최적화&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content><author><name>updaun</name></author><category term="aws" /><category term="devops" /><category term="gitlab" /><category term="gitlab-ce" /><category term="aws" /><category term="ec2" /><category term="t4g" /><category term="rds" /><category term="elasticache" /><category term="s3" /><category term="gitlab-runner" /><category term="cicd" /><category term="small-team" /><category term="memory-tuning" /><category term="terraform" /><category term="devops" /><category term="cost-optimization" /><summary type="html">5명 팀이 t4g.medium(4GB)에 GitLab CE를 올리려면 &apos;기능 끄기 + Puma 단일 프로세스 + Sidekiq 축소&apos;가 필수입니다. 소규모는 올인원으로 시작하고, CI·아티팩트·백업만 단계적으로 분리하는 전략이 가장 현실적입니다.</summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-06-10-aws-gitlab-ce-efficient-architecture-guide.webp" /><media:content medium="image" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-06-10-aws-gitlab-ce-efficient-architecture-guide.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">업체 홍보용 릴스 자동 제작기 만들기 — AI 음성·BGM·자막·사진 전환까지 2026 완전 가이드</title><link href="https://updaun.github.io/python/marketing/automation/2026/06/09/business-promo-reels-auto-generator-guide/" rel="alternate" type="text/html" title="업체 홍보용 릴스 자동 제작기 만들기 — AI 음성·BGM·자막·사진 전환까지 2026 완전 가이드" /><published>2026-06-09T00:00:00+09:00</published><updated>2026-06-09T00:00:00+09:00</updated><id>https://updaun.github.io/python/marketing/automation/2026/06/09/business-promo-reels-auto-generator-guide</id><content type="html" xml:base="https://updaun.github.io/python/marketing/automation/2026/06/09/business-promo-reels-auto-generator-guide/">&lt;h1 id=&quot;업체-홍보용-릴스-자동-제작기-만들기--ai-음성bgm자막사진-전환까지-2026-완전-가이드&quot;&gt;업체 홍보용 릴스 자동 제작기 만들기 — AI 음성·BGM·자막·사진 전환까지 2026 완전 가이드&lt;/h1&gt;

&lt;p&gt;카페, 미용실, 학원, 인테리어 업체… 공통점이 하나 있습니다.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;인스타 사진은 많은데, 릴스를 매주 못 올린다.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;편집 앱으로 30초 영상 하나 만드는 데 1~2시간이 걸리고, 나레이션 녹음·자막·BGM 밸런스는 더 번거롭습니다. 그래서 &lt;strong&gt;“사진 5장 + 홍보 문구만 넣으면 릴스가 나오는 자동 제작기”&lt;/strong&gt; 는 로컬 비즈니스·마케팅 대행·1인 SaaS 모두에게 실용적인 제품입니다.&lt;/p&gt;

&lt;p&gt;이 글은 다음을 한 파이프라인으로 묶는 방법을 다룹니다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;AI 음성(TTS)&lt;/strong&gt; 나레이션&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;잔잔한 배경음악(BGM)&lt;/strong&gt; — 음성보다 작게, 덕킹(ducking) 적용&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;자막&lt;/strong&gt; — 읽기 쉬운 위치·크기·타이밍&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;사진 자동 전환&lt;/strong&gt; — Ken Burns(줌·팬), 크로스페이드&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
  &lt;p&gt;TTS 인프라 설계는 &lt;a href=&quot;/2026/01/27-django-ninja-stt-tts-production/&quot;&gt;Django Ninja STT/TTS 가이드&lt;/a&gt;를, 인스타 마케팅 맥락은 &lt;a href=&quot;/2026/03/22-instagram-marketing-beginner-guide/&quot;&gt;인스타 마케팅 가이드&lt;/a&gt;와 함께 보면 좋습니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;0-결론부터-릴스-자동-제작-파이프라인-5단계&quot;&gt;0. 결론부터: 릴스 자동 제작 파이프라인 5단계&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;[입력] 업체명·문구·사진 3~8장
  → ① 스크립트 생성(또는 직접 입력)
  → ② AI 음성(TTS) MP3
  → ③ 자막 타임라인(SRT/ASS)
  → ④ 사진 슬라이드 영상(무음)
  → ⑤ 음성 + BGM 믹스 + 자막 번인
  → [출력] 1080×1920 MP4 (9:16)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&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;strong&gt;스크립트&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;90초 넘는 장문&lt;/td&gt;
      &lt;td&gt;15~45초, 문장 5~7개&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;TTS&lt;/strong&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;strong&gt;자막&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;한 화면에 3줄 이상&lt;/td&gt;
      &lt;td&gt;2줄 이내, 키워드 강조&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;/td&gt;
      &lt;td&gt;9:16 크롭·Ken Burns&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;믹스&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;BGM이 음성 가림&lt;/td&gt;
      &lt;td&gt;-18~-24 LUFS BGM, 덕킹&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;1-왜-릴스-자동-제작기인가&quot;&gt;1. 왜 “릴스 자동 제작기”인가&lt;/h2&gt;

&lt;h3 id=&quot;11-릴스가-로컬-비즈니스에-맞는-이유&quot;&gt;1.1 릴스가 로컬 비즈니스에 맞는 이유&lt;/h3&gt;

&lt;p&gt;&lt;a href=&quot;/2026/03/22-instagram-marketing-beginner-guide/&quot;&gt;인스타 마케팅 가이드&lt;/a&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;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;짧은 길이 (15~60초)&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;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;strong&gt;자동화에 잘 맞는 업종&lt;/strong&gt;: 카페·베이커리, 미용·네일, 피트니스, 학원·과외, 인테리어·시공, 펜션·숙소.&lt;/p&gt;

&lt;h3 id=&quot;12-제품-형태-3가지&quot;&gt;1.2 제품 형태 3가지&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;수익&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;인건비 절감&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;B2B SaaS&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;소상공인 월 구독&lt;/td&gt;
      &lt;td&gt;MRR&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;API&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;마케팅 툴·IM웹·예약 시스템&lt;/td&gt;
      &lt;td&gt;건당·API 호출&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;a href=&quot;/2026/06/01/ai-solo-founder-subscription-global-strategy/&quot;&gt;1인 구독 SaaS 전략&lt;/a&gt; 관점에서 &lt;strong&gt;“월 9,900원에 주 4개 릴스”&lt;/strong&gt; 는 설명이 쉬운 플랜입니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;2-시스템-아키텍처&quot;&gt;2. 시스템 아키텍처&lt;/h2&gt;

&lt;h3 id=&quot;21-전체-구조&quot;&gt;2.1 전체 구조&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;flowchart TB
  subgraph Input[&quot;입력&quot;]
    Form[웹 폼 / API]
    Photos[사진 3~8장]
    Script[스크립트 또는 LLM 생성]
  end

  subgraph Worker[&quot;Celery Worker&quot;]
    TTS[AI TTS]
    Sub[자막 타임라인]
    Slide[슬라이드 렌더]
    Mix[FFmpeg 믹스]
  end

  subgraph Storage[&quot;스토리지&quot;]
    S3[(S3 / R2)]
    DB[(PostgreSQL)]
  end

  Form --&amp;gt; DB
  Photos --&amp;gt; S3
  Script --&amp;gt; TTS
  TTS --&amp;gt; Sub
  Sub --&amp;gt; Mix
  Photos --&amp;gt; Slide
  Slide --&amp;gt; Mix
  Mix --&amp;gt; S3
  S3 --&amp;gt; Form
&lt;/code&gt;&lt;/pre&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;동기 HTTP로 영상 렌더 금지&lt;/strong&gt; — 30초~수 분 소요. &lt;a href=&quot;/2026/03/24-django-ninja-celery-beat-complete-guide/&quot;&gt;Celery Beat&lt;/a&gt;로 비동기 처리.&lt;/li&gt;
  &lt;li&gt;중간 산출물(음성·무음 영상)은 &lt;strong&gt;S3/R2&lt;/strong&gt;에 저장 (&lt;a href=&quot;/2025/09-27-django-ninja-aws-s3-presigned-url-guide/&quot;&gt;Presigned URL 가이드&lt;/a&gt; 참고).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;22-기술-스택-추천&quot;&gt;2.2 기술 스택 추천&lt;/h3&gt;

&lt;div class=&quot;language-python 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;s&quot;&gt;&quot;API&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;Django Ninja&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;Queue&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;Celery + Redis&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;Video&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;FFmpeg (프로덕션) / MoviePy (프로토타입)&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;Image&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;Pillow&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;TTS&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;OpenAI TTS / ElevenLabs / Naver Clova&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;Subtitle&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;pysubs2 / FFmpeg subtitles filter&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;Storage&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;S3 또는 Cloudflare R2&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;Optional LLM&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;스크립트 초안 생성&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;프로토타입&lt;/strong&gt;은 MoviePy로 빠르게, &lt;strong&gt;프로덕션&lt;/strong&gt;은 FFmpeg로 통일하는 팀이 많습니다. FFmpeg가 메모리·속도·자막 번인에서 유리합니다.&lt;/p&gt;

&lt;h3 id=&quot;23-출력-스펙-인스타-릴스&quot;&gt;2.3 출력 스펙 (인스타 릴스)&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;strong&gt;1080 × 1920&lt;/strong&gt; (9:16)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;프레임&lt;/td&gt;
      &lt;td&gt;30 fps&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;길이&lt;/td&gt;
      &lt;td&gt;15~60초 (업체 홍보는 &lt;strong&gt;20~35초&lt;/strong&gt; sweet spot)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;코덱&lt;/td&gt;
      &lt;td&gt;H.264 + AAC&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;파일 크기&lt;/td&gt;
      &lt;td&gt;50MB 이하 (업로드 안정)&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;3-입력-설계--무엇을-받을-것인가&quot;&gt;3. 입력 설계 — 무엇을 받을 것인가&lt;/h2&gt;

&lt;h3 id=&quot;31-최소-입력-스키마&quot;&gt;3.1 최소 입력 스키마&lt;/h3&gt;

&lt;div class=&quot;language-python 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;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;ninja&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Schema&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;typing&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;List&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Optional&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;ReelsJobIn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Schema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;business_name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;tagline&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;                    &lt;span class=&quot;c1&quot;&gt;# 한 줄 슬로건
&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;bullets&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;List&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;              &lt;span class=&quot;c1&quot;&gt;# 핵심 포인트 3~5개
&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;cta&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;지금 예약하세요&quot;&lt;/span&gt;     &lt;span class=&quot;c1&quot;&gt;# 마지막 행동 유도
&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;photo_urls&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;List&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;           &lt;span class=&quot;c1&quot;&gt;# 3~8장
&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;voice&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;nova&quot;&lt;/span&gt;             &lt;span class=&quot;c1&quot;&gt;# TTS 보이스 ID
&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;bgm_track&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;calm-01&quot;&lt;/span&gt;      &lt;span class=&quot;c1&quot;&gt;# 라이브러리 키
&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;template&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;clean&quot;&lt;/span&gt;         &lt;span class=&quot;c1&quot;&gt;# clean | bold | luxury
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;32-스크립트-자동-생성-선택&quot;&gt;3.2 스크립트 자동 생성 (선택)&lt;/h3&gt;

&lt;p&gt;LLM으로 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bullets&lt;/code&gt;를 &lt;strong&gt;말하듯 자연스러운 5~7문장&lt;/strong&gt;으로 변환합니다.&lt;/p&gt;

&lt;div class=&quot;language-python 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;n&quot;&gt;PROMPT&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;&quot;&quot;
당신은 로컬 비즈니스 인스타 릴스 카피라이터입니다.
조건:
- 전체 낭독 시간 25~35초 (한국어 약 80~120자)
- 문장은 짧고 구어체
- 마지막 문장은 CTA
- 이모지·해시태그 금지 (TTS용)
입력: {business_name}, {tagline}, {bullets}
&quot;&quot;&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;팁&lt;/strong&gt;: 스크립트에 &lt;strong&gt;쉼표·마침표&lt;/strong&gt;를 넣으면 TTS 억양이 자연스러워집니다.&lt;/p&gt;

&lt;h3 id=&quot;33-사진-가이드-업체에-안내할-체크리스트&quot;&gt;3.3 사진 가이드 (업체에 안내할 체크리스트)&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;가로·세로 혼합 가능 — &lt;strong&gt;서버에서 9:16 중앙 크롭&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;최소 &lt;strong&gt;1080px 긴 변&lt;/strong&gt; 이상&lt;/li&gt;
  &lt;li&gt;텍스트·워터마크 과다 사진은 피하기&lt;/li&gt;
  &lt;li&gt;인물 사진은 &lt;strong&gt;얼굴이 중앙&lt;/strong&gt;에 오게&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;4-ai-음성tts--나레이션-만들기&quot;&gt;4. AI 음성(TTS) — 나레이션 만들기&lt;/h2&gt;

&lt;h3 id=&quot;41-tts-엔진-선택&quot;&gt;4.1 TTS 엔진 선택&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;단점&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;OpenAI TTS&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;품질·속도·API 단순&lt;/td&gt;
      &lt;td&gt;비용(문자당)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;ElevenLabs&lt;/strong&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;strong&gt;Naver Clova&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;한국어 자연스러움&lt;/td&gt;
      &lt;td&gt;SSML 학습&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Amazon Polly&lt;/strong&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;a href=&quot;/2026/01/27-django-ninja-stt-tts-production/&quot;&gt;STT/TTS 프로덕션 가이드&lt;/a&gt;의 Polly·OpenAI 예시를 그대로 확장할 수 있습니다.&lt;/p&gt;

&lt;h3 id=&quot;42-openai-tts-예시&quot;&gt;4.2 OpenAI TTS 예시&lt;/h3&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# services/tts.py
&lt;/span&gt;&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;httpx&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;pathlib&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Path&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;synthesize_narration&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;out_path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;voice&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;nova&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;with&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;httpx&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AsyncClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;timeout&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;60&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;resp&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;https://api.openai.com/v1/audio/speech&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;headers&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Authorization&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Bearer &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;s&quot;&gt;&quot;model&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;gpt-4o-mini-tts&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                &lt;span class=&quot;s&quot;&gt;&quot;voice&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;voice&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                &lt;span class=&quot;s&quot;&gt;&quot;input&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                &lt;span class=&quot;s&quot;&gt;&quot;response_format&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;mp3&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                &lt;span class=&quot;s&quot;&gt;&quot;speed&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;1.05&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# 릴스는 약간 빠르게
&lt;/span&gt;            &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;resp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;raise_for_status&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;out_path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;write_bytes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;resp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;out_path&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;43-음성-톤-가이드-업종별&quot;&gt;4.3 음성 톤 가이드 (업종별)&lt;/h3&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;업종&lt;/th&gt;
      &lt;th&gt;voice&lt;/th&gt;
      &lt;th&gt;speed&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;soft female&lt;/td&gt;
      &lt;td&gt;1.0&lt;/td&gt;
      &lt;td&gt;따뜻함&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;피트니스&lt;/td&gt;
      &lt;td&gt;energetic&lt;/td&gt;
      &lt;td&gt;1.1&lt;/td&gt;
      &lt;td&gt;에너지&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;법률·세무&lt;/td&gt;
      &lt;td&gt;neutral male&lt;/td&gt;
      &lt;td&gt;0.95&lt;/td&gt;
      &lt;td&gt;신뢰&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;뷰티&lt;/td&gt;
      &lt;td&gt;warm female&lt;/td&gt;
      &lt;td&gt;1.05&lt;/td&gt;
      &lt;td&gt;친근&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;5-자막--읽히게-만드는-법&quot;&gt;5. 자막 — 읽히게 만드는 법&lt;/h2&gt;

&lt;h3 id=&quot;51-릴스-자막-원칙&quot;&gt;5.1 릴스 자막 원칙&lt;/h3&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;한 화면 최대 2줄&lt;/strong&gt; (세로 화면 기준 12~16자 × 2줄)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;하단 20~25%&lt;/strong&gt; — UI(좋아요·댓글)와 겹치지 않게&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;키워드 bold·색상&lt;/strong&gt; (ASS 스타일로 처리)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;음성과 0.1~0.2초 선행&lt;/strong&gt; — 인지 부하 감소&lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;52-문장-단위-srt-생성-mvp&quot;&gt;5.2 문장 단위 SRT 생성 (MVP)&lt;/h3&gt;

&lt;p&gt;정확한 단어 단위 싱크가 필요하면 Whisper 타임스탬프를 쓰고, MVP는 &lt;strong&gt;문장 균등 분할&lt;/strong&gt;로 시작합니다.&lt;/p&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# services/subtitles.py
&lt;/span&gt;&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;pathlib&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Path&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;script_to_srt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sentences&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;list&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;total_duration&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;float&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;out_path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;n&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;len&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sentences&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;slot&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;total_duration&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;/&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;n&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;lines&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[]&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;i&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;sent&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;enumerate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sentences&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;start&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;i&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;slot&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;end&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;i&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;slot&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;0.05&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;lines&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;append&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;i&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;lines&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;append&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_fmt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;start&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; --&amp;gt; &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_fmt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;end&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;lines&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;append&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;strip&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;())&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;lines&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;append&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;out_path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;write_text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;join&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;lines&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;encoding&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;utf-8&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;out_path&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;_fmt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sec&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;float&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;h&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sec&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;//&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;3600&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;m&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sec&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;%&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;3600&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;//&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;60&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;s&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sec&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;%&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;60&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;ms&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sec&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sec&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1000&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;h&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;02&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;d&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;m&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;02&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;d&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;s&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;02&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;d&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ms&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;03&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;d&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;53-ass로-릴스-스타일-자막&quot;&gt;5.3 ASS로 “릴스 스타일” 자막&lt;/h3&gt;

&lt;p&gt;SRT보다 &lt;strong&gt;ASS(Advanced SubStation Alpha)&lt;/strong&gt; 가 테두리·폰트·위치 제어에 유리합니다.&lt;/p&gt;

&lt;div class=&quot;language-ini 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;nn&quot;&gt;[V4+ Styles]&lt;/span&gt;
&lt;span class=&quot;err&quot;&gt;Format:&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;Name,&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;Fontname,&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;Fontsize,&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;PrimaryColour,&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;OutlineColour,&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;Bold,&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;Alignment,&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;MarginV&lt;/span&gt;
&lt;span class=&quot;err&quot;&gt;Style:&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;Reels,Noto&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;Sans&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;CJK&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;KR&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;Bold,52,&amp;amp;H00FFFFFF,&amp;amp;H00000000,-1,2,120&lt;/span&gt;

&lt;span class=&quot;nn&quot;&gt;[Events]&lt;/span&gt;
&lt;span class=&quot;err&quot;&gt;Format:&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;Layer,&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;Start,&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;End,&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;Style,&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;Text&lt;/span&gt;
&lt;span class=&quot;err&quot;&gt;Dialogue:&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;0,0:00:00.00,0:00:03.50,Reels,{\an2}성수동에서&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;찾은&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;작은&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;카페&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Alignment=2&lt;/code&gt;: 하단 중앙&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MarginV=120&lt;/code&gt;: 하단 여백&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Outline&lt;/code&gt;: 배경이 밝아도 가독성 확보&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;6-사진-자동-전환--슬라이드--ken-burns&quot;&gt;6. 사진 자동 전환 — 슬라이드 + Ken Burns&lt;/h2&gt;

&lt;h3 id=&quot;61-타이밍-규칙&quot;&gt;6.1 타이밍 규칙&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;총 길이 = 나레이션 길이 (또는 나레이션 + 0.5초 여유)
사진 1장당 시간 = 총 길이 / 사진 수
전환 = 0.4~0.6초 crossfade
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;예: 나레이션 28초, 사진 7장 → 장당 4초, crossfade 0.5초.&lt;/p&gt;

&lt;h3 id=&quot;62-ffmpeg로-ken-burns--크로스페이드&quot;&gt;6.2 FFmpeg로 Ken Burns + 크로스페이드&lt;/h3&gt;

&lt;p&gt;각 사진을 &lt;strong&gt;살짝 줌인&lt;/strong&gt;하면 정적 슬라이드가 “영상 같아” 보입니다.&lt;/p&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;&lt;span class=&quot;c&quot;&gt;# 단일 이미지 → 4초 클립 (1080x1920, 줌인)&lt;/span&gt;
ffmpeg &lt;span class=&quot;nt&quot;&gt;-loop&lt;/span&gt; 1 &lt;span class=&quot;nt&quot;&gt;-i&lt;/span&gt; photo.jpg &lt;span class=&quot;nt&quot;&gt;-t&lt;/span&gt; 4 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-vf&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;
       zoompan=z=&apos;min(zoom+0.0015,1.15)&apos;:d=120:s=1080x1920:fps=30&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-pix_fmt&lt;/span&gt; yuv420p clip_01.mp4
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;여러 클립을 &lt;strong&gt;xfade&lt;/strong&gt;로 이어붙입니다.&lt;/p&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;ffmpeg &lt;span class=&quot;nt&quot;&gt;-i&lt;/span&gt; clip_01.mp4 &lt;span class=&quot;nt&quot;&gt;-i&lt;/span&gt; clip_02.mp4 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-filter_complex&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;[0:v][1:v]xfade=transition=fade:duration=0.5:offset=3.5[v]&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-map&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;[v]&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-an&lt;/span&gt; slideshow.mp4
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;63-python에서-일괄-생성&quot;&gt;6.3 Python에서 일괄 생성&lt;/h3&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# services/slideshow.py
&lt;/span&gt;&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;subprocess&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;pathlib&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Path&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;render_slideshow&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;images&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;list&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;duration_per&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;float&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;out&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;clips&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[]&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;idx&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;img&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;enumerate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;images&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;clip&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;out&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;parent&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;/&lt;/span&gt; &lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;clip_&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;idx&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;02&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;d&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;.mp4&quot;&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;frames&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;duration_per&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;30&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;vf&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,&quot;&lt;/span&gt;
            &lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;zoompan=z=&apos;min(zoom+0.0012,1.12)&apos;:d=&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;frames&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;:s=1080x1920:fps=30&quot;&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;subprocess&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;run&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;([&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;ffmpeg&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;-y&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;-loop&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;1&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;-i&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;img&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;-t&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;duration_per&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;-vf&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;vf&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;-pix_fmt&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;yuv420p&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;clip&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;],&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;check&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;clips&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;append&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;clip&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;c1&quot;&gt;# 2장 이상이면 xfade 체인 (MVP는 concat demuxer로 단순화 가능)
&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;_concat_with_xfade&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;clips&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;out&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;fade&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.5&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;out&lt;/span&gt;
&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;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;template&lt;/th&gt;
      &lt;th&gt;전환&lt;/th&gt;
      &lt;th&gt;Ken Burns&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;clean&lt;/td&gt;
      &lt;td&gt;fade&lt;/td&gt;
      &lt;td&gt;약한 줌 (1.08)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;bold&lt;/td&gt;
      &lt;td&gt;wipeleft&lt;/td&gt;
      &lt;td&gt;강한 줌 (1.18)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;luxury&lt;/td&gt;
      &lt;td&gt;fadeblack&lt;/td&gt;
      &lt;td&gt;느린 줌 (1.05)&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;7-bgm--잔잔하게-음성은-또렷하게&quot;&gt;7. BGM — 잔잔하게, 음성은 또렷하게&lt;/h2&gt;

&lt;h3 id=&quot;71-믹싱-원칙&quot;&gt;7.1 믹싱 원칙&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;strong&gt;나레이션&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;-16 ~ -14 LUFS, 중심&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;BGM&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;-24 ~ -18 LUFS, 음성 대비 &lt;strong&gt;-12~-18 dB&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;“잔잔히 깔린다”는 &lt;strong&gt;BGM이 존재감은 있되 가사·음성을 방해하지 않는 상태&lt;/strong&gt;입니다. 업체 홍보 릴스에는 &lt;strong&gt;보컬 없는 로파이·어쿠스틱·앰비언트&lt;/strong&gt;가 안전합니다.&lt;/p&gt;

&lt;h3 id=&quot;72-bgm-라이브러리-운영&quot;&gt;7.2 BGM 라이브러리 운영&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;저작권 클리어&lt;/strong&gt; 트랙만 (Artlist, Epidemic, 직접 제작 루프)&lt;/li&gt;
  &lt;li&gt;업종별 5~10곡 &lt;strong&gt;프리셋&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;루프 포인트 맞춘 &lt;strong&gt;30~60초 편집본&lt;/strong&gt; 미리 준비&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;73-ffmpeg-믹스--덕킹&quot;&gt;7.3 FFmpeg 믹스 + 덕킹&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;&lt;span class=&quot;c&quot;&gt;# voice.mp3 + bgm.mp3 → final_audio.mp3&lt;/span&gt;
ffmpeg &lt;span class=&quot;nt&quot;&gt;-i&lt;/span&gt; voice.mp3 &lt;span class=&quot;nt&quot;&gt;-i&lt;/span&gt; bgm.mp3 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-filter_complex&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;
    [1:a]volume=0.18,afade=t=in:st=0:d=1,afade=t=out:st=27:d=2[bgm];
    [0:a][bgm]amix=inputs=2:duration=first:dropout_transition=2,
    loudnorm=I=-16:TP=-1.5:LRA=11[aout]
  &quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-map&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;[aout]&quot;&lt;/span&gt; final_audio.mp3
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;덕킹(고급)&lt;/strong&gt;: 음성 구간에서 BGM을 자동으로 더 낮춥니다.&lt;/p&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;&lt;span class=&quot;c&quot;&gt;# sidechaincompress — 음성이 나올 때 BGM 레벨 하락&lt;/span&gt;
ffmpeg &lt;span class=&quot;nt&quot;&gt;-i&lt;/span&gt; voice.mp3 &lt;span class=&quot;nt&quot;&gt;-i&lt;/span&gt; bgm.mp3 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-filter_complex&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;[1:a][0:a]sidechaincompress=threshold=0.02:ratio=6:attack=50:release=400[bgmduck];[0:a][bgmduck]amix=inputs=2:duration=first&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  final_audio.mp3
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;8-최종-합성--영상--음성--자막&quot;&gt;8. 최종 합성 — 영상 + 음성 + 자막&lt;/h2&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;ffmpeg &lt;span class=&quot;nt&quot;&gt;-i&lt;/span&gt; slideshow.mp4 &lt;span class=&quot;nt&quot;&gt;-i&lt;/span&gt; final_audio.mp3 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-vf&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;subtitles=subs.ass:fontsdir=./fonts&quot;&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-c&lt;/span&gt;:v libx264 &lt;span class=&quot;nt&quot;&gt;-preset&lt;/span&gt; medium &lt;span class=&quot;nt&quot;&gt;-crf&lt;/span&gt; 22 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-c&lt;/span&gt;:a aac &lt;span class=&quot;nt&quot;&gt;-b&lt;/span&gt;:a 128k &lt;span class=&quot;nt&quot;&gt;-shortest&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-movflags&lt;/span&gt; +faststart &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  output_reels.mp4
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;shortest&lt;/code&gt;: 영상·음성 길이 맞춤&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;faststart&lt;/code&gt;: 모바일 스트리밍·업로드 최적화&lt;/li&gt;
  &lt;li&gt;폰트는 &lt;strong&gt;Noto Sans KR&lt;/strong&gt; 등 상업 이용 가능 폰트 번들&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;81-celery-작업으로-묶기&quot;&gt;8.1 Celery 작업으로 묶기&lt;/h3&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# tasks/reels.py
&lt;/span&gt;&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;celery&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;shared_task&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;pathlib&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Path&lt;/span&gt;

&lt;span class=&quot;o&quot;&gt;@&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;shared_task&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;bind&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;max_retries&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;render_reels_job&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;job_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;job&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ReelsJob&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;objects&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pk&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;job_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;work&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/tmp/reels/&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;job_id&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;work&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;mkdir&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;parents&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;exist_ok&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;script&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;job&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;script_text&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;voice_path&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;synthesize_narration_sync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;script&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;work&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;/&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;voice.mp3&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;job&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;voice&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;duration&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;get_audio_duration&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;voice_path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;srt&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;script_to_srt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;split_sentences&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;script&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;duration&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;work&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;/&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;subs.srt&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;ass&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;srt_to_ass&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;srt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;template&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;job&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;template&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;work&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;/&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;subs.ass&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;images&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;download_images&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;job&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;photo_urls&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;work&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;/&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;img&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;slideshow&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;render_slideshow&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;images&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;duration&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;/&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;len&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;images&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;work&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;/&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;slideshow.mp4&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;bgm&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;pick_bgm&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;job&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;bgm_track&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;final_audio&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;mix_audio&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;voice_path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;bgm&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;duration&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;work&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;/&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;final_audio.mp3&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;output&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;work&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;/&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;output.mp4&quot;&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;burn_subtitles_and_mux&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;slideshow&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;final_audio&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ass&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;output&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;url&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;upload_to_r2&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;output&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;key&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;reels/&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;job_id&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;.mp4&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;job&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;status&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;done&quot;&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;job&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;output_url&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;url&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;job&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;save&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;url&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;9-django-ninja-api--클라이언트-인터페이스&quot;&gt;9. Django Ninja API — 클라이언트 인터페이스&lt;/h2&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# api/reels.py
&lt;/span&gt;&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;ninja&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Router&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;tasks.reels&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;render_reels_job&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;router&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Router&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;

&lt;span class=&quot;o&quot;&gt;@&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;router&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/jobs&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ReelsJobOut&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;create_job&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ReelsJobIn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;script&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;script&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;or&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;generate_script&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;job&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ReelsJob&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;objects&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;create&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;business_name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;business_name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;script_text&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;script&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;photo_urls&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;photo_urls&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;voice&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;voice&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;bgm_track&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;bgm_track&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;template&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;template&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;status&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;queued&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;render_reels_job&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;delay&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;job&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;id&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;job&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;status&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;job&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;status&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;o&quot;&gt;@&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;router&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/jobs/{job_id}&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ReelsJobOut&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;get_job&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;job_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;job&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ReelsJob&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;objects&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pk&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;job_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;job&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;프론트(Next.js)는 &lt;strong&gt;폴링 또는 SSE&lt;/strong&gt;로 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;status&lt;/code&gt;를 확인하고, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;done&lt;/code&gt;이면 미리보기·다운로드 링크를 표시합니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;10-uiux--업체가-쓰기-쉽게&quot;&gt;10. UI/UX — 업체가 쓰기 쉽게&lt;/h2&gt;

&lt;h3 id=&quot;101-3단계-마법사&quot;&gt;10.1 3단계 마법사&lt;/h3&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; — 보이스, BGM, 템플릿 미리듣기·미리보기&lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;102-미리보기-전략&quot;&gt;10.2 미리보기 전략&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;전체 렌더 전 &lt;strong&gt;3초 저해상도 프리뷰&lt;/strong&gt; (워터마크)&lt;/li&gt;
  &lt;li&gt;유료 플랜에서 워터마크 제거·1080p&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;103-인스타-업로드-연동-선택&quot;&gt;10.3 인스타 업로드 연동 (선택)&lt;/h3&gt;

&lt;p&gt;&lt;a href=&quot;/2026/03/10-django-ninja-instagram-api-integration-guide/&quot;&gt;Instagram API 가이드&lt;/a&gt;로 &lt;strong&gt;Graph API 컨테이너 업로드&lt;/strong&gt;까지 연결하면 “제작→게시” 원스톱이 됩니다. 다만 앱 심사·비즈니스 계정 요건이 있으므로 MVP 이후 단계로 권장합니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;11-품질비용속도-튜닝&quot;&gt;11. 품질·비용·속도 튜닝&lt;/h2&gt;

&lt;h3 id=&quot;111-렌더-시간-목표&quot;&gt;11.1 렌더 시간 목표&lt;/h3&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;환경&lt;/th&gt;
      &lt;th&gt;30초 릴스 목표&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;로컬 M1/M2&lt;/td&gt;
      &lt;td&gt;20~40초&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;2 vCPU VPS&lt;/td&gt;
      &lt;td&gt;1~3분&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;GPU (NVENC)&lt;/td&gt;
      &lt;td&gt;10~20초&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;libx264 -preset veryfast&lt;/code&gt;는 MVP, 프로덕션은 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;medium&lt;/code&gt; + 큐 워커 스케일아웃.&lt;/p&gt;

&lt;h3 id=&quot;112-비용-대략-건당&quot;&gt;11.2 비용 대략 (건당)&lt;/h3&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;항목&lt;/th&gt;
      &lt;th&gt;30초 1건&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;OpenAI TTS (~100자)&lt;/td&gt;
      &lt;td&gt;₩50~150&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;스토리지·전송&lt;/td&gt;
      &lt;td&gt;₩10~30&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;컴pute (VPS 분)&lt;/td&gt;
      &lt;td&gt;₩20~80&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;₩100~300&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;월 29,000원에 주 8건이면 건당 ₩900 미만 — &lt;strong&gt;마진 설계 가능&lt;/strong&gt;.&lt;/p&gt;

&lt;h3 id=&quot;113-흔한-품질-이슈&quot;&gt;11.3 흔한 품질 이슈&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;해결&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;MarginV↑&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;MP3 비트레이트 낮음&lt;/td&gt;
      &lt;td&gt;128kbps+&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;BGM 갑자기 끊김&lt;/td&gt;
      &lt;td&gt;루프 미처리&lt;/td&gt;
      &lt;td&gt;afade out + 루프 편집&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;12-비즈니스운영-체크리스트&quot;&gt;12. 비즈니스·운영 체크리스트&lt;/h2&gt;

&lt;h3 id=&quot;121-법무저작권&quot;&gt;12.1 법무·저작권&lt;/h3&gt;

&lt;ul class=&quot;task-list&quot;&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;BGM 라이선스 문서 보관&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;업체 제공 사진 &lt;strong&gt;사용 권한&lt;/strong&gt; 동의&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;AI 생성 음성·문구 &lt;strong&gt;이용약관&lt;/strong&gt; 명시&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;허위·과장 광고 문구 필터 (의료·금융 등)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;122-출시-전-기술-체크&quot;&gt;12.2 출시 전 기술 체크&lt;/h3&gt;

&lt;ul class=&quot;task-list&quot;&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;9:16 1080p 일관 출력&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;한글 자막 iOS·Android 인스타 앱에서 가독성 확인&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;50MB 이하&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;실패 작업 재시도·알림&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;PII 로그 마스킹&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;123-90일-로드맵&quot;&gt;12.3 90일 로드맵&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;strong&gt;1~2주&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;CLI로 사진+문구→MP4 1파이프라인&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;3~5주&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Django API + Celery + S3&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;6~8주&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;웹 UI + 템플릿 3종 + 결제&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;9~12주&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;대행사·프랜차이즈 파일럿 3곳&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;13-확장-아이디어&quot;&gt;13. 확장 아이디어&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;/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;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;리뷰 텍스트 → 릴스&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;UGC 활용&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;예약 시스템·IM웹 웹훅&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;신규 메뉴 자동 릴스&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;A/B 썸네일·첫 3초&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;전환율&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;유튜브 Shorts·틱톡 동시 출력&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;멀티 채널&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;a href=&quot;/2026/05/30/ai-report-employee-daily-briefing-automation/&quot;&gt;AI Report 직원&lt;/a&gt;으로 &lt;strong&gt;주간 인스타 인사이트 + 다음 릴스 주제&lt;/strong&gt;를 브리핑하면 운영도 자동화할 수 있습니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;14-정리&quot;&gt;14. 정리&lt;/h2&gt;

&lt;p&gt;업체 홍보용 릴스 자동 제작기의 본질은 &lt;strong&gt;“편집 실력”을 “파이프라인”으로 바꾸는 것&lt;/strong&gt;입니다.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;짧은 스크립트 → AI 음성 → 사진 Ken Burns → 잔잔한 BGM 덕킹 → 릴스형 자막 → 9:16 MP4&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;FFmpeg 중심&lt;/strong&gt;으로 프로덕션 품질을 확보하고&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Celery&lt;/strong&gt;로 렌더를 비동기화하며&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;템플릿·BGM·보이스 프리셋&lt;/strong&gt;으로 업종별 차별화하고&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;월 구독 SaaS&lt;/strong&gt; 또는 &lt;strong&gt;대행사 내부 툴&lt;/strong&gt;로 수익화한다&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;오늘 당장은 서버 없이 &lt;strong&gt;로컬 Python 스크립트 하나&lt;/strong&gt;로 사진 5장과 홍보 문구만 넣어 30초 MP4를 뽑아보세요. 그 한 파일이 전체 제품의 코어입니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;참고-자료&quot;&gt;참고 자료&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://ffmpeg.org/documentation.html&quot;&gt;FFmpeg 공식 문서&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://platform.openai.com/docs/guides/text-to-speech&quot;&gt;OpenAI Audio Speech API&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://developers.facebook.com/docs/instagram-api&quot;&gt;Instagram Reels 스펙 (Meta)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://zulko.github.io/moviepy/&quot;&gt;MoviePy 문서&lt;/a&gt; — 프로토타입용&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;관련-글&quot;&gt;관련 글&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/01-27-django-ninja-stt-tts-production/&quot;&gt;Django Ninja STT/TTS 프로덕션&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/03/22-instagram-marketing-beginner-guide/&quot;&gt;인스타그램 마케팅 완벽 가이드&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/03/10-django-ninja-instagram-api-integration-guide/&quot;&gt;Django Ninja Instagram API 연동&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/03/24/django-ninja-celery-beat-complete-guide/&quot;&gt;Django Ninja + Celery Beat&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/06-01/ai-solo-founder-subscription-global-strategy/&quot;&gt;AI로 1인 구독 서비스&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/05/30/ai-report-employee-daily-briefing-automation/&quot;&gt;AI Report 직원 — 일일 브리핑&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2025/09-27-django-ninja-aws-s3-presigned-url-guide/&quot;&gt;S3 Presigned URL 가이드&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content><author><name>updaun</name></author><category term="python" /><category term="marketing" /><category term="automation" /><category term="reels" /><category term="instagram" /><category term="short-form-video" /><category term="tts" /><category term="ffmpeg" /><category term="moviepy" /><category term="django-ninja" /><category term="celery" /><category term="ai-voice" /><category term="subtitle" /><category term="bgm" /><category term="automation" /><category term="local-business" /><summary type="html">카페·미용실·학원처럼 사진은 있는데 영상 편집 시간이 없는 업체가 많습니다. 스크립트 → AI 음성 → 자막 → 사진 슬라이드 → BGM 믹스까지 한 번에 돌리는 릴스 자동 제작기를 만들면 &apos;콘텐츠 공장&apos;이 됩니다.</summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-06-09-business-promo-reels-auto-generator-guide.webp" /><media:content medium="image" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-06-09-business-promo-reels-auto-generator-guide.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">마이리얼트립 API 분석과 수익 극대화 전략 — 파트너 API·MCP·CPS 어필리에이트 실전 가이드 2026</title><link href="https://updaun.github.io/api/marketing/indie-hacker/2026/06/08/myrealtrip-api-revenue-maximization-strategy/" rel="alternate" type="text/html" title="마이리얼트립 API 분석과 수익 극대화 전략 — 파트너 API·MCP·CPS 어필리에이트 실전 가이드 2026" /><published>2026-06-08T00:00:00+09:00</published><updated>2026-06-08T00:00:00+09:00</updated><id>https://updaun.github.io/api/marketing/indie-hacker/2026/06/08/myrealtrip-api-revenue-maximization-strategy</id><content type="html" xml:base="https://updaun.github.io/api/marketing/indie-hacker/2026/06/08/myrealtrip-api-revenue-maximization-strategy/">&lt;h1 id=&quot;마이리얼트립-api-분석과-수익-극대화-전략--파트너-apimcpcps-어필리에이트-실전-가이드-2026&quot;&gt;마이리얼트립 API 분석과 수익 극대화 전략 — 파트너 API·MCP·CPS 어필리에이트 실전 가이드 2026&lt;/h1&gt;

&lt;p&gt;2026년 4월, 마이리얼트립은 &lt;strong&gt;마케팅 파트너 API&lt;/strong&gt;와 &lt;strong&gt;MCP 서버&lt;/strong&gt;를 외부 개발자에게 공개했습니다.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;예전: 블로그·인스타에 홍보 링크 붙이기 → 클릭 기대&lt;br /&gt;
지금: 항공 최저가 비교 앱, AI 여행 에이전트, 가격 알림 봇 → &lt;strong&gt;검색·추천·전환까지 코드로 설계&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;공식 팀의 표현대로, 여행 어필리에이트는 콘텐츠 중심에서 &lt;strong&gt;서비스(코드) 중심&lt;/strong&gt;으로 이동하고 있습니다. 파트너사 A·B는 API 연동 후 &lt;strong&gt;월 최고 거래액 28억·12억 원&lt;/strong&gt; 수준의 실적이 공개되었고, &lt;a href=&quot;https://flightdeal.kr/&quot;&gt;flightdeal.kr&lt;/a&gt;·캐치프로그 같은 서비스가 이미 운영 중입니다.&lt;/p&gt;

&lt;p&gt;이 글은 API 스펙 나열이 아니라, &lt;strong&gt;API를 분석해 수익을 극대화하는 비즈니스 구상·기술 설계·운영 전략&lt;/strong&gt;을 한 장의 지도로 정리합니다.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;키워드·SEO 기반 트래픽 설계는 &lt;a href=&quot;/2026/03/14/naver-keyword-ad-api-django-ninja-integration/&quot;&gt;네이버 키워드 API 가이드&lt;/a&gt;와 &lt;a href=&quot;/2026/03/06/complete-seo-optimization-strategy-guide/&quot;&gt;SEO 최적화 가이드&lt;/a&gt;를, 1인 SaaS 관점은 &lt;a href=&quot;/2026/06/01/ai-solo-founder-subscription-global-strategy/&quot;&gt;AI 구독 서비스 전략&lt;/a&gt;을 함께 보면 좋습니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;0-결론부터-수익-극대화의-4축&quot;&gt;0. 결론부터: 수익 극대화의 4축&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;strong&gt;Intent&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;“지금 살 이유”가 있는 트래픽&lt;/td&gt;
      &lt;td&gt;조회수만 많고 예약 0건&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;AOV&lt;/strong&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;strong&gt;Conversion&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;검색 → 마이링크 → MRT 결제 경로가 짧고 명확&lt;/td&gt;
      &lt;td&gt;API 결과와 결제 페이지 불일치&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Retention&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;재방문·알림·구독으로 7일 쿠키 창을 넓힘&lt;/td&gt;
      &lt;td&gt;일회성 랜딩만 운영&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;strong&gt;가장 흔한 실수&lt;/strong&gt;는 API 키만 받아 “스카이스캐너 클론”을 만드는 것입니다. 마이리얼트립 API의 본질은 &lt;strong&gt;검색 인프라 + CPS 정산 인프라&lt;/strong&gt;이고, 수익은 &lt;strong&gt;니치·타이밍·신뢰&lt;/strong&gt;에서 갈립니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;1-마이리얼트립-파트너-api란-무엇인가&quot;&gt;1. 마이리얼트립 파트너 API란 무엇인가&lt;/h2&gt;

&lt;h3 id=&quot;11-포지셔닝-b2b2c-어필리에이트-인프라&quot;&gt;1.1 포지셔닝: B2B2C 어필리에이트 인프라&lt;/h3&gt;

&lt;p&gt;마이리얼트립 마케팅 파트너 API는 &lt;strong&gt;예약 시스템을 직접 구축하는 API가 아닙니다.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;공식 팀이 GeekNews 댓글에서 밝힌 설계 흐름은 다음과 같습니다.&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;서비스 API(항공/숙소/투어 조회)
  → 내 앱·사이트에 결과 노출
  → 마이링크 API로 어필리에이트 URL 생성
  → 사용자가 클릭해 마이리얼트립으로 이동
  → MRT에서 예약·결제 완료
  → 클릭 후 7일 이내 전환 시 수수료 발생
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;즉 파트너의 역할은 &lt;strong&gt;보여주고 보내기(Search + Link)&lt;/strong&gt; 입니다. 상품 소싱, 결제, 24시간 CS, 정산은 마이리얼트립이 담당합니다(Zero Operation).&lt;/p&gt;

&lt;h3 id=&quot;12-수익-구조-cps&quot;&gt;1.2 수익 구조 (CPS)&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;strong&gt;모델&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;CPS (Cost Per Sale) — 실결제 금액 기준&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;수수료&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;MRT 중개 수수료의 &lt;strong&gt;50%&lt;/strong&gt; 정산 → 실질 &lt;strong&gt;판매액 최대 7%&lt;/strong&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;strong&gt;클릭 후 7일&lt;/strong&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;strong&gt;익월 30일&lt;/strong&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;strong&gt;보너스&lt;/strong&gt; (공식 문서·파트너 센터 확인)&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;개인 개발자는 &lt;a href=&quot;https://partner.myrealtrip.com/welcome/marketing_partner&quot;&gt;파트너 가입&lt;/a&gt; 즉시 API 키 발급이 가능하고, 법인은 사업자등록증·통장 사본 제출 후 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;marketing_partner@myrealtrip.com&lt;/code&gt;으로 승인 요청합니다.&lt;/p&gt;

&lt;h3 id=&quot;13-api도구-구성&quot;&gt;1.3 API·도구 구성&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;비고&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;항공권 조회 API&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;날짜별 최저가, 다중 목적지 비교&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/v1/products/flight/calendar&lt;/code&gt; 등&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;숙소 검색 API&lt;/strong&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;strong&gt;투어티켓 검색 API&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;투어·티켓·액티비티&lt;/td&gt;
      &lt;td&gt;2026년 4월 기준 순차 오픈&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;마이링크 API&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;수익 추적 가능한 홍보 URL 생성&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;수익화의 핵심&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;수익 조회 API&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;예약·수익 실시간 확인&lt;/td&gt;
      &lt;td&gt;KPI 대시보드용&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;MCP 서버&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Cursor·Claude에서 상품 검색&lt;/td&gt;
      &lt;td&gt;인증 없이 프로토타입 가능&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;개발자 센터&lt;/strong&gt;: &lt;a href=&quot;https://docs.myrealtrip.com/&quot;&gt;docs.myrealtrip.com&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;MCP 엔드포인트&lt;/strong&gt;: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://mcp-servers.myrealtrip.com/mcp&lt;/code&gt;&lt;/li&gt;
&lt;/ul&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;mcpServers&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;myrealtrip&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;url&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;https://mcp-servers.myrealtrip.com/mcp&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;span class=&quot;p&quot;&gt;}&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;MCP는 &lt;strong&gt;수익 정산과 무관&lt;/strong&gt;합니다. 프로토타입·AI 에이전트 실험용이고, 매출을 내려면 &lt;strong&gt;파트너 API 키 + 마이링크&lt;/strong&gt;가 필수입니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;2-api-아키텍처-분석&quot;&gt;2. API 아키텍처 분석&lt;/h2&gt;

&lt;h3 id=&quot;21-레이어-구조&quot;&gt;2.1 레이어 구조&lt;/h3&gt;

&lt;pre&gt;&lt;code class=&quot;language-mermaid&quot;&gt;flowchart LR
  subgraph Partner[&quot;파트너 서비스&quot;]
    UI[웹/앱/봇 UI]
    Cache[(Redis Cache)]
    Analytics[전환·수익 대시보드]
  end

  subgraph MRT_API[&quot;마이리얼트립 API&quot;]
    Search[상품 검색 API]
    Link[마이링크 API]
    Revenue[수익 조회 API]
  end

  subgraph MRT_Platform[&quot;마이리얼트립 플랫폼&quot;]
    Catalog[상품·가격]
    Checkout[결제·예약]
    Settlement[정산]
  end

  UI --&amp;gt; Search
  Search --&amp;gt; Catalog
  UI --&amp;gt; Link
  Link --&amp;gt; Checkout
  Checkout --&amp;gt; Settlement
  Settlement --&amp;gt; Revenue
  Revenue --&amp;gt; Analytics
  Search --&amp;gt; Cache
&lt;/code&gt;&lt;/pre&gt;

&lt;h3 id=&quot;22-항공-api--알아둘-제약&quot;&gt;2.2 항공 API — 알아둘 제약&lt;/h3&gt;

&lt;p&gt;GeekNews 피드백에 따르면 &lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/v1/products/flight/calendar&lt;/code&gt;는 국제선만 지원&lt;/strong&gt;합니다. GMP→CJU 같은 국내선은 빈 배열(&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;data: []&lt;/code&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;depCityCd&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;ICN&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;arrCityCd&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;NRT&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;period&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;mi&quot;&gt;3&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;startDate&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;2026-07-01&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;endDate&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;2026-08-31&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;&lt;strong&gt;수익 전략 함의&lt;/strong&gt;: 국내선 특가 서비스보다 &lt;strong&gt;국제선·장거리·고객단가 높은 구간&lt;/strong&gt;이 API 현 시점과 잘 맞습니다.&lt;/p&gt;

&lt;h3 id=&quot;23-문서ai-코딩-시-주의&quot;&gt;2.3 문서·AI 코딩 시 주의&lt;/h3&gt;

&lt;p&gt;개발자 센터가 SPA(클라이언트 렌더링)라 Cursor·Claude에 URL만 던지면 내용을 못 읽는 경우가 있습니다. 연동 시:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;a href=&quot;https://docs.myrealtrip.com/&quot;&gt;docs.myrealtrip.com&lt;/a&gt;에서 필요한 페이지를 &lt;strong&gt;로컬 Markdown으로 복사&lt;/strong&gt;해 컨텍스트에 넣기&lt;/li&gt;
  &lt;li&gt;첫 호출은 &lt;strong&gt;Postman/curl로 검증&lt;/strong&gt; 후 코드 생성&lt;/li&gt;
  &lt;li&gt;공식 팀에 정적 문서·Markdown export 요청 가능 (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;marketing_partner@myrealtrip.com&lt;/code&gt;)&lt;/li&gt;
&lt;/ol&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;3-수익-극대화-공식&quot;&gt;3. 수익 극대화 공식&lt;/h2&gt;

&lt;p&gt;단순히 “API 호출 수 × 전환율”이 아니라, &lt;strong&gt;기대 수익(E)&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;E = Traffic × CTR × BookingRate × AOV × CommissionRate × (1 - ChurnBeforePurchase)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&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;strong&gt;Traffic&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;유입&lt;/td&gt;
      &lt;td&gt;SEO·커뮤니티·뉴스레터·앱스토어&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;CTR&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;검색 결과 → MRT 클릭&lt;/td&gt;
      &lt;td&gt;가격·한정·리뷰·CTA&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;BookingRate&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;MRT 내 결제 전환&lt;/td&gt;
      &lt;td&gt;의도 높은 키워드·구체적 일정&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;AOV&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;1건당 결제액&lt;/td&gt;
      &lt;td&gt;항공+숙소·장기·프리미엄&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;CommissionRate&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;수수료율 (최대 7%)&lt;/td&gt;
      &lt;td&gt;카테고리별 상품 믹스&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;ChurnBeforePurchase&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;7일 내 이탈&lt;/td&gt;
      &lt;td&gt;리마인더·가격 변동 알림&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;strong&gt;레버가 큰 순서&lt;/strong&gt;(일반적): &lt;strong&gt;AOV &amp;gt; BookingRate &amp;gt; Traffic &amp;gt; CTR&lt;/strong&gt;. 저가 트래픽 100만보다 &lt;strong&gt;“다음 달 오사카 3박, 일정 확정”&lt;/strong&gt; 유저 1,000명이 수익에 유리한 경우가 많습니다.&lt;/p&gt;

&lt;h3 id=&quot;31-카테고리별-수익-특성-실무-관점&quot;&gt;3.1 카테고리별 수익 특성 (실무 관점)&lt;/h3&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;카테고리&lt;/th&gt;
      &lt;th&gt;AOV&lt;/th&gt;
      &lt;th&gt;구매 주기&lt;/th&gt;
      &lt;th&gt;API 활용도&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;국제 항공&lt;/strong&gt;&lt;/td&gt;
      &lt;td&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;strong&gt;숙소&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;중~높음&lt;/td&gt;
      &lt;td&gt;항공과 연동&lt;/td&gt;
      &lt;td&gt;높음&lt;/td&gt;
      &lt;td&gt;목적지+날짜 랜딩 SEO&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;/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;strong&gt;패키지·기획전&lt;/strong&gt;&lt;/td&gt;
      &lt;td&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;h3 id=&quot;32-7일-쿠키-창을-넓히는-설계&quot;&gt;3.2 7일 쿠키 창을 넓히는 설계&lt;/h3&gt;

&lt;p&gt;쿠키가 7일이므로 &lt;strong&gt;당일 결제만 노리는 UI는 손해&lt;/strong&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;: “3개 후보 중 고르기” 플로우&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;리타겟 콘텐츠&lt;/strong&gt;: “지난번 본 도쿄 숙소, 이번 주 12% 하락”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href=&quot;/2026/05/30/ai-report-employee-daily-briefing-automation/&quot;&gt;AI Report 직원&lt;/a&gt;으로 &lt;strong&gt;일일 특가·가격 변동 브리핑&lt;/strong&gt;을 자동화하면 운영 비용 없이 리텐션 레버를 당길 수 있습니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;4-비즈니스-모델-7가지--무엇을-만들-것인가&quot;&gt;4. 비즈니스 모델 7가지 — 무엇을 만들 것인가&lt;/h2&gt;

&lt;h3 id=&quot;41-모델-비교표&quot;&gt;4.1 모델 비교표&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;난이도&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;&lt;strong&gt;항공 최저가 알리미&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;중&lt;/td&gt;
      &lt;td&gt;높음&lt;/td&gt;
      &lt;td&gt;구간·날짜 니치 (flightdeal.kr 유형)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;2&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;목적지 SEO 허브&lt;/strong&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;3&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;AI 여행 플래너&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;중~상&lt;/td&gt;
      &lt;td&gt;중&lt;/td&gt;
      &lt;td&gt;MCP+파트너 API, 일정→링크 일괄 생성&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;4&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;커뮤니티·봇&lt;/strong&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;5&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;뉴스레터·구독&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;하&lt;/td&gt;
      &lt;td&gt;중&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;/2026/06/01/ai-solo-founder-subscription-global-strategy/&quot;&gt;구독 모델&lt;/a&gt; + CPS&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;6&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;B2B 위젯&lt;/strong&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;7&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;버티컬 SaaS&lt;/strong&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;h3 id=&quot;42-1인인디해커에게-유리한-조합&quot;&gt;4.2 1인·인디해커에게 유리한 조합&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;추천 스택 (MVP 2~4주)&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;니치 1개&lt;/strong&gt; (예: “동남아 직항 최저가”, “유럽 성수기 역주행”)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Next.js 랜딩 + Django Ninja API 프록시&lt;/strong&gt; (API 키 서버 보관)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Celery Beat&lt;/strong&gt;으로 가격 스냅샷·알림 (&lt;a href=&quot;/2026/03/24/django-ninja-celery-beat-complete-guide/&quot;&gt;스케줄 가이드&lt;/a&gt;)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;수익 조회 API&lt;/strong&gt;로 주간 KPI 대시보드&lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;43-피해야-할-함정&quot;&gt;4.3 피해야 할 함정&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;strong&gt;범용 메타서치&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;스카이스캐너·구글 플라이트와 정면 충돌&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;API 키 클라이언트 노출&lt;/strong&gt;&lt;/td&gt;
      &lt;td&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;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;저가 투어만 링크&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;AOV·수수료 절대액 낮음&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;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;5-기술-설계--django-ninja-연동-예시&quot;&gt;5. 기술 설계 — Django Ninja 연동 예시&lt;/h2&gt;

&lt;h3 id=&quot;51-아키텍처-원칙&quot;&gt;5.1 아키텍처 원칙&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;[Browser/App] → [Next.js] → [Django Ninja BFF] → [MRT Partner API]
                              ↑
                         API Key (서버만)
                         Redis Cache
                         PostgreSQL (검색 로그·스냅샷)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;API 키는 반드시 백엔드&lt;/strong&gt;에만 둡니다.&lt;/li&gt;
  &lt;li&gt;검색 결과는 &lt;strong&gt;짧은 TTL 캐시&lt;/strong&gt;(예: 항공 15~30분, 숙소 5~15분).&lt;/li&gt;
  &lt;li&gt;클릭·전환은 &lt;strong&gt;자체 UTM + 마이링크&lt;/strong&gt;로 이중 추적합니다.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;52-환경-변수&quot;&gt;5.2 환경 변수&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;&lt;span class=&quot;c&quot;&gt;# .env (서버 전용 — Git 커밋 금지)&lt;/span&gt;
&lt;span class=&quot;nv&quot;&gt;MYREALTRIP_API_KEY&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;your_partner_api_key
&lt;span class=&quot;nv&quot;&gt;MYREALTRIP_API_BASE&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;https://api.myrealtrip.com  &lt;span class=&quot;c&quot;&gt;# 공식 문서 기준 URL 확인&lt;/span&gt;
&lt;span class=&quot;nv&quot;&gt;REDIS_URL&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;redis://localhost:6379/0
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;53-항공-최저가-프록시-개념-코드&quot;&gt;5.3 항공 최저가 프록시 (개념 코드)&lt;/h3&gt;

&lt;blockquote&gt;
  &lt;p&gt;엔드포인트·헤더·응답 필드는 &lt;a href=&quot;https://docs.myrealtrip.com/&quot;&gt;공식 문서&lt;/a&gt; 최신 스펙을 따르세요. 아래는 &lt;strong&gt;설계 패턴&lt;/strong&gt; 예시입니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# services/myrealtrip.py
&lt;/span&gt;&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;hashlib&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;httpx&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;django.conf&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;settings&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;django.core.cache&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;cache&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;MyRealTripClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;__init__&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
        &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;base&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;settings&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;MYREALTRIP_API_BASE&lt;/span&gt;
        &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;headers&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;Authorization&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Bearer &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;settings&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;MYREALTRIP_API_KEY&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;Content-Type&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;application/json&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;_cache_key&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;prefix&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;dict&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;raw&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;prefix&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;sorted&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;items&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()))&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;mrt:&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;hashlib&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sha256&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;raw&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;encode&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()).&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;hexdigest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()[&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;16&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;search_flight_calendar&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;params&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;dict&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;dict&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;key&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_cache_key&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;flight&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;params&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;cached&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;cache&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;key&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;cached&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;cached&lt;/span&gt;

        &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;with&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;httpx&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AsyncClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;timeout&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;30.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;resp&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
                &lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;base&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;/v1/products/flight/calendar&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;headers&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;headers&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;params&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;params&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;resp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;raise_for_status&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;data&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;resp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;

        &lt;span class=&quot;n&quot;&gt;cache&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;set&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;key&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;timeout&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;60&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;20&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# 20분
&lt;/span&gt;        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# api/flights.py — Django Ninja
&lt;/span&gt;&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;ninja&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Router&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Schema&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;services.myrealtrip&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;MyRealTripClient&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;router&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Router&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;MyRealTripClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;FlightSearchIn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Schema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;dep_city_cd&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;arr_city_cd&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;period&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;3&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;start_date&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;end_date&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;

&lt;span class=&quot;o&quot;&gt;@&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;router&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/flights/calendar&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;flight_calendar&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;FlightSearchIn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;data&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;search_flight_calendar&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;dict&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;())&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;# 클릭 추적용 search_id를 DB에 저장하면 퍼널 분석에 유리
&lt;/span&gt;    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;results&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;data&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[]),&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;meta&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;meta&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{})}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;54-마이링크-생성--수익의-마지막-1cm&quot;&gt;5.4 마이링크 생성 — 수익의 마지막 1cm&lt;/h3&gt;

&lt;p&gt;검색 API만 쓰고 &lt;strong&gt;일반 MRT URL&lt;/strong&gt;을 붙이면 어트리뷰션이 안 될 수 있습니다. &lt;strong&gt;반드시 마이링크 API&lt;/strong&gt;로 URL을 생성하세요.&lt;/p&gt;

&lt;div class=&quot;language-python 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;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;create_affiliate_link&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;target_url&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;campaign&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;with&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;httpx&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AsyncClient&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;timeout&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;15.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;resp&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;base&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;/v1/links&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# 실제 경로는 공식 문서 확인
&lt;/span&gt;            &lt;span class=&quot;n&quot;&gt;headers&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;headers&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;url&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;target_url&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;campaign&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;campaign&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;resp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;raise_for_status&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;resp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;data&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;][&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;shortUrl&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;UX 패턴&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;카드마다 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;예약하러 가기&lt;/code&gt; 버튼 → 마이링크&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;가격 비교 중…&lt;/code&gt; 스켈레톤 후 CTA 노출 (신뢰↑)&lt;/li&gt;
  &lt;li&gt;외부 링크임을 한 줄 표기 (어필리에이트 고지)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;55-수익-대시보드&quot;&gt;5.5 수익 대시보드&lt;/h3&gt;

&lt;p&gt;수익 조회 API로 &lt;strong&gt;일·주·월 GMV·건수·수수료&lt;/strong&gt;를 Pull해 대시보드에 띄웁니다.&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;GMV&lt;/strong&gt;&lt;/td&gt;
      &lt;td&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;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;EPC&lt;/strong&gt; (수익/클릭)&lt;/td&gt;
      &lt;td&gt;SEO·광고 ROI&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;구간별 AOV&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;다음 니치 선정&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;6-트래픽전환-전략&quot;&gt;6. 트래픽·전환 전략&lt;/h2&gt;

&lt;h3 id=&quot;61-seo--질문-단위-url&quot;&gt;6.1 SEO — “질문 단위 URL”&lt;/h3&gt;

&lt;p&gt;&lt;a href=&quot;/2026/03/06/complete-seo-optimization-strategy-guide/&quot;&gt;SEO 가이드&lt;/a&gt;와 동일하게, &lt;strong&gt;키워드 1개 = 페이지 1개&lt;/strong&gt;가 아니라 &lt;strong&gt;질문 1개 = 페이지 1개&lt;/strong&gt;입니다.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;URL 패턴&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;/flights/icn-bkk-july&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;7월 방콕 최저가 언제?&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/hotels/osaka-family&lt;/code&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;/alerts/nrt-under-300k&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;도쿄 30만 원 이하 항공 알림&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;각 페이지 상단에 &lt;strong&gt;Answer Chunk&lt;/strong&gt;(한 줄 답) + 표 + 마이링크 CTA를 두면 검색·&lt;a href=&quot;/2026/06/03/web-service-aeo-optimization-complete-guide/&quot;&gt;AEO&lt;/a&gt; 모두에 유리합니다.&lt;/p&gt;

&lt;h3 id=&quot;62-키워드-리서치-자동화&quot;&gt;6.2 키워드 리서치 자동화&lt;/h3&gt;

&lt;p&gt;&lt;a href=&quot;/2026/03/14/naver-keyword-ad-api-django-ninja-integration/&quot;&gt;네이버 키워드 API&lt;/a&gt;로 “{도시} 항공권”, “{도시} 숙소 추천” 검색량을 뽑고, &lt;strong&gt;검색량 × 예상 AOV × 경쟁도&lt;/strong&gt;로 우선순위를 매깁니다.&lt;/p&gt;

&lt;h3 id=&quot;63-전환율ctr-올리는-ui&quot;&gt;6.3 전환율(CTR) 올리는 UI&lt;/h3&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;/li&gt;
  &lt;li&gt;&lt;strong&gt;비교 테이블&lt;/strong&gt; (날짜 3~5개 열)&lt;/li&gt;
  &lt;li&gt;모바일에서 &lt;strong&gt;CTA 고정 하단 바&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;64-시즌이벤트-캘린더&quot;&gt;6.4 시즌·이벤트 캘린더&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;여름 성수기&lt;/td&gt;
      &lt;td&gt;2~3개월 전 “얼리버드” 알림 수집&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;Celery로 API 폴링 → 푸시/메일&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;7-aimcp-활용-전략&quot;&gt;7. AI·MCP 활용 전략&lt;/h2&gt;

&lt;h3 id=&quot;71-mcp-vs-파트너-api-역할-분리&quot;&gt;7.1 MCP vs 파트너 API 역할 분리&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;MCP (Cursor/Claude)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;프로덕션 검색·링크&lt;/td&gt;
      &lt;td&gt;파트너 API&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;수익 확인&lt;/td&gt;
      &lt;td&gt;수익 조회 API&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;BuildWithTrae 해커톤 사례처럼 &lt;strong&gt;MCP로 AI 여행 에이전트&lt;/strong&gt;를 만든 뒤, 실서비스에서는 파트너 API로 교체하는 패턴이 현실적입니다.&lt;/p&gt;

&lt;h3 id=&quot;72-ai-에이전트-수익-모델&quot;&gt;7.2 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;사용자: &quot;12월 제주 대신 오키나와 4박, 예산 150만&quot;
  → 에이전트가 항공·숙소 API 조회
  → 3안 요약 + 각안 마이링크
  → 찜·알림 등록 (7일 윈도우 내 재방문)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;차별화는 &lt;strong&gt;도메인 지식&lt;/strong&gt;(비자·환승·시즌)·&lt;strong&gt;개인화 저장&lt;/strong&gt;·&lt;strong&gt;가격 추적&lt;/strong&gt;에서 나옵니다. “ChatGPT에 물어보세요”와 동일하면 이탈합니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;8-운영정산컴플라이언스&quot;&gt;8. 운영·정산·컴플라이언스&lt;/h2&gt;

&lt;h3 id=&quot;81-정산-캘린더&quot;&gt;8.1 정산 캘린더&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;T+0&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;예약·결제 (7일 이내)&lt;/td&gt;
      &lt;td&gt;T+0~7&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;수익 조회 API 반영&lt;/td&gt;
      &lt;td&gt;API 스펙 기준 (통상 지연 있음)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;정산금 입금&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;익월 30일&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;현금흐름이 &lt;strong&gt;1~2개월 래그&lt;/strong&gt; 있으므로, 초기에는 &lt;strong&gt;운영비·API·호스팅&lt;/strong&gt;을 최소화한 &lt;a href=&quot;/2026/06/01/ai-solo-founder-subscription-global-strategy/&quot;&gt;1인 SaaS 원칙&lt;/a&gt;이 맞습니다.&lt;/p&gt;

&lt;h3 id=&quot;82-표시법무-체크리스트&quot;&gt;8.2 표시·법무 체크리스트&lt;/h3&gt;

&lt;ul class=&quot;task-list&quot;&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;어필리에이트·제휴 광고 &lt;strong&gt;고지 문구&lt;/strong&gt; (상단 또는 CTA 근처)&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;가격 &lt;strong&gt;면책&lt;/strong&gt; (“실시간 변동, 최종 가격은 마이리얼트립 기준”)&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;개인정보처리방침 (이메일·알림 수집 시)&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;API 이용약관·브랜드 가이드 준수 (공식 문서)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;83-장애품질-대응&quot;&gt;8.3 장애·품질 대응&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;API 빈 결과(국내선 등): &lt;strong&gt;사용자 메시지&lt;/strong&gt;로 대체, SEO 페이지는 noindex 처리&lt;/li&gt;
  &lt;li&gt;가격 불일치: 캐시 TTL 단축, “최종 확인 시각” 노출&lt;/li&gt;
  &lt;li&gt;전환 급감: 마이링크 만료·UTM·쿠키 차단 여부 점검&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;9-90일-로드맵&quot;&gt;9. 90일 로드맵&lt;/h2&gt;

&lt;h3 id=&quot;phase-1-day-114-검증&quot;&gt;Phase 1 (Day 1~14): 검증&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;파트너 가입·API 키&lt;/td&gt;
      &lt;td&gt;샌드박스 호출 성공&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;니치 1개 선정&lt;/td&gt;
      &lt;td&gt;10개 구간·키워드 리스트&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;MCP로 프로토타입&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;flightdeal.kr 등 UX 메모&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;phase-2-day-1545-mvp&quot;&gt;Phase 2 (Day 15~45): MVP&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;Django Ninja BFF + 캐시&lt;/td&gt;
      &lt;td&gt;검색 API 프록시&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;마이링크 일괄 생성&lt;/td&gt;
      &lt;td&gt;클릭 추적 DB&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;랜딩 10~30페이지&lt;/td&gt;
      &lt;td&gt;SEO 구조&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;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;phase-3-day-4690-성장&quot;&gt;Phase 3 (Day 46~90): 성장&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;7일 쿠키 창 활용&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;A/B CTA·카피&lt;/td&gt;
      &lt;td&gt;EPC +10% 목표&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;인센티브 구간 분석&lt;/td&gt;
      &lt;td&gt;고AOV 구간 집중&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;a href=&quot;/2026/06/03/web-service-aeo-optimization-complete-guide/&quot;&gt;AEO&lt;/a&gt; 보강&lt;/td&gt;
      &lt;td&gt;AI 인용용 FAQ·표&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;10-kpi-대시보드-템플릿&quot;&gt;10. KPI 대시보드 템플릿&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;주간 지표&lt;/th&gt;
      &lt;th&gt;목표 예시 (니치 MVP)&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;유기 검색 세션&lt;/td&gt;
      &lt;td&gt;WoW +15%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;검색 → 클릭 CTR&lt;/td&gt;
      &lt;td&gt;8~15%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;클릭 → 예약 (7일)&lt;/td&gt;
      &lt;td&gt;0.5~2% (카테고리별 편차 큼)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;평균 AOV&lt;/td&gt;
      &lt;td&gt;80만 원+ (항공·숙소 믹스)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;EPC&lt;/td&gt;
      &lt;td&gt;클릭당 500원+ (성숙 후)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;월 GMV&lt;/td&gt;
      &lt;td&gt;인센티브 구간 진입 여부&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;strong&gt;첫 달은 GMV보다 “전환 1건의 퍼널”&lt;/strong&gt;을 깨는 것이 우선입니다. 어느 구간·어떤 CTA에서 클릭이 나왔는지 로그를 남기세요.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;11-흔한-실수-10가지&quot;&gt;11. 흔한 실수 10가지&lt;/h2&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;일반 MRT URL 공유&lt;/strong&gt; — 마이링크 미사용&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;API 키 프론트 노출&lt;/strong&gt; — 키 폐기·재발급 사태&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;국내선만 다루는 서비스&lt;/strong&gt; — 현재 항공 API 제약과 불일치&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;캐시 없이 실시간만&lt;/strong&gt; — 비용·속도·차단 리스크&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;범용 비교 사이트&lt;/strong&gt; — 니치 없이 CAC 전쟁&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;가격만 나열&lt;/strong&gt; — 일정·리뷰·신뢰 요소 부재&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;7일 쿠키 무시&lt;/strong&gt; — 일회성 트래픽에만 의존&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;수익 API 미연동&lt;/strong&gt; — 감으로만 운영&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;어필리에이트 미표기&lt;/strong&gt; — 신뢰·규제 이슈&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;문서 URL만 AI에 던짐&lt;/strong&gt; — SPA 문서 파싱 실패&lt;/li&gt;
&lt;/ol&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;12-정리--api는-인프라-수익은-설계에서-난다&quot;&gt;12. 정리 — API는 인프라, 수익은 설계에서 난다&lt;/h2&gt;

&lt;p&gt;마이리얼트립 파트너 API는 &lt;strong&gt;여행 커머스의 백엔드를 빌려 쓰는 CPS 인프라&lt;/strong&gt;입니다.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;검색 API로 의도를 모으고, 마이링크로 수익을 붙이고, 7일 쿠키·알림·니치 SEO로 전환을 키운다.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;국제선·숙소·고AOV&lt;/strong&gt;에 API와 수익 모델을 맞추고&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Django Ninja BFF + 캐시 + 수익 대시보드&lt;/strong&gt;로 운영 가능하게 만들며&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;니치 1개에 깊게&lt;/strong&gt; 파서 범용 메타서치를 피하고&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;MCP는 실험, 파트너 API는 매출&lt;/strong&gt;로 역할을 분리한다&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;다음 단계로는 &lt;a href=&quot;https://partner.myrealtrip.com/welcome/marketing_partner&quot;&gt;파트너 가입&lt;/a&gt; 후 &lt;strong&gt;구간 1개·랜딩 1개·마이링크 1개&lt;/strong&gt;로 첫 전환을 만드는 것이 가장 빠릅니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;참고-자료&quot;&gt;참고 자료&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://docs.myrealtrip.com/&quot;&gt;마이리얼트립 API 개발자 센터&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://partner.myrealtrip.com/welcome/marketing_partner&quot;&gt;마케팅 파트너 가입&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://news.hada.io/topic?id=28372&quot;&gt;GeekNews — 파트너 API &amp;amp; MCP 공개&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://velog.io/@anyria/%EB%A7%88%EC%9D%B4%EB%A6%AC%EC%96%BC%ED%8A%B8%EB%A6%BD%EC%97%90%EC%84%9C-%ED%95%AD%EA%B3%B5%EC%88%99%EC%86%8C%EB%A5%BC-%EA%B2%80%EC%83%89%ED%95%A0-%EC%88%98-%EC%9E%88%EB%8A%94-MCP-%EC%84%9C%EB%B2%84%EB%A5%BC-%EB%A7%8C%EB%93%A4%EC%97%88%EC%8A%B5%EB%8B%88%EB%8B%A4-%ED%8C%8C%ED%8A%B8%EB%84%88-API-%EA%B3%B5%EA%B0%9C&quot;&gt;Velog — MCP 서버 소개 (마이리얼트립 PE)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://flightdeal.kr/&quot;&gt;flightdeal.kr — 연동 사례&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;문의: marketing_partner@myrealtrip.com&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;관련-글&quot;&gt;관련 글&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/03/14/naver-keyword-ad-api-django-ninja-integration/&quot;&gt;네이버 키워드 API + Django Ninja&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/03/06/complete-seo-optimization-strategy-guide/&quot;&gt;블로그 SEO 최적화 완벽 가이드&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/06/03/web-service-aeo-optimization-complete-guide/&quot;&gt;웹서비스 AEO 최적화 가이드&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/06/01/ai-solo-founder-subscription-global-strategy/&quot;&gt;AI로 1인 구독 서비스 — 글로벌 성장&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/03/24/django-ninja-celery-beat-complete-guide/&quot;&gt;Django Ninja + Celery Beat&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/05/30/ai-report-employee-daily-briefing-automation/&quot;&gt;AI Report 직원 — 일일 브리핑 자동화&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/04/28/serpapi-complete-guide/&quot;&gt;SerpAPI 완전 가이드&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content><author><name>updaun</name></author><category term="api" /><category term="marketing" /><category term="indie-hacker" /><category term="myrealtrip" /><category term="affiliate" /><category term="cps" /><category term="travel-api" /><category term="mcp" /><category term="django-ninja" /><category term="nextjs" /><category term="revenue" /><category term="api-integration" /><category term="indie-hacker" /><category term="seo" /><summary type="html">여행 어필리에이트는 &apos;링크 붙이기&apos; 시대를 넘어 &apos;서비스(코드)&apos; 시대로 바뀌었습니다. 마이리얼트립 파트너 API는 상품 소싱·결제·CS 없이 검색+링크만으로 최대 7% CPS를 받을 수 있는 인프라입니다. 핵심은 API를 어떻게 조합해 전환율과 객단가를 올리느냐입니다.</summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-06-08-myrealtrip-api-revenue-maximization-strategy.webp" /><media:content medium="image" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-06-08-myrealtrip-api-revenue-maximization-strategy.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">웹서비스 AEO 최적화 완전 가이드 — AI 답변 엔진에 인용·추천되게 만드는 실전 전략 2026</title><link href="https://updaun.github.io/seo/marketing/web-development/2026/06/03/web-service-aeo-optimization-complete-guide/" rel="alternate" type="text/html" title="웹서비스 AEO 최적화 완전 가이드 — AI 답변 엔진에 인용·추천되게 만드는 실전 전략 2026" /><published>2026-06-03T00:00:00+09:00</published><updated>2026-06-03T00:00:00+09:00</updated><id>https://updaun.github.io/seo/marketing/web-development/2026/06/03/web-service-aeo-optimization-complete-guide</id><content type="html" xml:base="https://updaun.github.io/seo/marketing/web-development/2026/06/03/web-service-aeo-optimization-complete-guide/">&lt;h1 id=&quot;웹서비스-aeo-최적화-완전-가이드--ai-답변-엔진에-인용추천되게-만드는-실전-전략-2026&quot;&gt;웹서비스 AEO 최적화 완전 가이드 — AI 답변 엔진에 인용·추천되게 만드는 실전 전략 2026&lt;/h1&gt;

&lt;p&gt;사용자의 검색 행동이 바뀌었습니다.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;예전: “Django Celery Beat 설정” → 구글 → 블로그 5개 탭&lt;br /&gt;
지금: “Celery Beat로 매일 9시에 작업 돌리려면?” → ChatGPT / Perplexity / Google AI 개요 → &lt;strong&gt;답변 1개 + 출처 3~5개&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;이때 노출의 단위는 &lt;strong&gt;클릭 가능한 10개 블루링크&lt;/strong&gt;가 아니라, &lt;strong&gt;답변 안에 들어가는 인용(citation)&lt;/strong&gt; 입니다. 웹서비스·SaaS·기술 블로그가 이 흐름에 맞춰 최적화하는 것이 &lt;strong&gt;AEO(Answer Engine Optimization, 답변 엔진 최적화)&lt;/strong&gt; 입니다.&lt;/p&gt;

&lt;p&gt;이 글은 마케팅 슬로건이 아니라, &lt;strong&gt;Next.js/Django 등 실제 웹서비스에 바로 적용할 수 있는&lt;/strong&gt; AEO 설계·구현·측정·운영 가이드입니다.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;전통 검색 노출은 &lt;a href=&quot;/2026/03/06/complete-seo-optimization-strategy-guide/&quot;&gt;블로그 SEO 최적화 완벽 가이드&lt;/a&gt;와 함께 봐야 합니다. &lt;strong&gt;SEO는 여전히 기반&lt;/strong&gt;이고, AEO는 그 위에 “AI가 인용하기 좋은 형태”를 추가하는 레이어입니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;0-결론부터-aeo의-4축&quot;&gt;0. 결론부터: AEO의 4축&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;strong&gt;Extractability&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;AI가 한 덩어리로 잘라 답을 만들 수 있음&lt;/td&gt;
      &lt;td&gt;긴 산문만 있고 정의·단계·표가 없음&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Authority&lt;/strong&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;strong&gt;Coverage&lt;/strong&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;strong&gt;Technical signals&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;크롤·스키마·정책이 일관&lt;/td&gt;
      &lt;td&gt;noindex·깨진 JSON-LD·느린 LCP&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;strong&gt;가장 흔한 실수&lt;/strong&gt;는 “ChatGPT용 SEO”라고 하면서 &lt;strong&gt;메타 키워드만 바꾸는 것&lt;/strong&gt;입니다. AEO는 메타 태그 한 줄이 아니라 &lt;strong&gt;콘텐츠 구조 + 신뢰 신호 + 기술적 추출 가능성&lt;/strong&gt;의 조합입니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;1-aeoseogeo--무엇이-같고-무엇이-다른가&quot;&gt;1. AEO·SEO·GEO — 무엇이 같고 무엇이 다른가&lt;/h2&gt;

&lt;h3 id=&quot;11-용어-정리&quot;&gt;1.1 용어 정리&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;목표&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;SEO&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Search Engine Optimization&lt;/td&gt;
      &lt;td&gt;검색 결과 &lt;strong&gt;순위·클릭&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Google, Naver, Bing&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;AEO&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Answer Engine Optimization&lt;/td&gt;
      &lt;td&gt;AI 답변 &lt;strong&gt;인용·추천&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;ChatGPT, Perplexity, Copilot, Gemini&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;GEO&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Generative Engine Optimization&lt;/td&gt;
      &lt;td&gt;생성형 검색/답변 노출 (AEO와 혼용)&lt;/td&gt;
      &lt;td&gt;Google AI Overviews, AI Mode&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;실무에서는 &lt;strong&gt;GEO ≈ AEO&lt;/strong&gt;로 쓰이는 경우가 많습니다. 이 글에서는 &lt;strong&gt;“답변 엔진에 잘 인용되게 만든다”&lt;/strong&gt;는 실무 관점으로 &lt;strong&gt;AEO&lt;/strong&gt;라고 통일합니다.&lt;/p&gt;

&lt;h3 id=&quot;12-seo-vs-aeo--목표와-성공-지표&quot;&gt;1.2 SEO vs AEO — 목표와 성공 지표&lt;/h3&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;구분&lt;/th&gt;
      &lt;th&gt;SEO&lt;/th&gt;
      &lt;th&gt;AEO&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;성공 지표&lt;/td&gt;
      &lt;td&gt;순위, CTR, 세션&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;인용 횟수&lt;/strong&gt;, 브랜드 언급, 직접 유입(Referral)&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;strong&gt;질문–답(Q&amp;amp;A) 청크&lt;/strong&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;strong&gt;LLM 인용·요약&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;링크의 역할&lt;/td&gt;
      &lt;td&gt;PageRank, 앵커&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;출처 URL로서의 신뢰&lt;/strong&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;strong&gt;날짜·버전·사실 검증&lt;/strong&gt;이 더 중요&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;13-seo를-버리지-말-것&quot;&gt;1.3 SEO를 버리지 말 것&lt;/h3&gt;

&lt;p&gt;AEO는 SEO를 대체하지 않습니다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Google AI Overviews는 여전히 &lt;strong&gt;웹 인덱스·품질 신호&lt;/strong&gt;에 의존합니다.&lt;/li&gt;
  &lt;li&gt;Perplexity·ChatGPT도 &lt;strong&gt;실시간 검색·크롤 데이터&lt;/strong&gt;를 사용합니다.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;느린 사이트, noindex, 얇은 콘텐츠&lt;/strong&gt;는 SEO·AEO 둘 다 망칩니다.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;권장 순서&lt;/strong&gt;: Technical SEO 정리 → 핵심 질문 페이지 설계 → 구조화 데이터 → AEO 전용 자산(llms.txt 등) → 인용 모니터링.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;2-답변-엔진이-출처를-고르는-방식실무-모델&quot;&gt;2. 답변 엔진이 출처를 고르는 방식(실무 모델)&lt;/h2&gt;

&lt;p&gt;공개된 “AEO 랭킹 공식”은 없습니다. 다만 2026년 실무에서 반복되는 &lt;strong&gt;인용 패턴&lt;/strong&gt;은 다음과 같습니다.&lt;/p&gt;

&lt;h3 id=&quot;21-인용에-유리한-신호&quot;&gt;2.1 인용에 유리한 신호&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;strong&gt;질문과 직접 대응하는 제목·H2&lt;/strong&gt;&lt;/td&gt;
      &lt;td&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;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;최신 날짜·버전 명시&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;“2024년 기준” 답변 리스크 ↓&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;1차 출처 링크&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;공식 문서·RFC·벤더 문서 인용&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;저자·조직·연락처&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;E-E-A-T(경험·전문성·권위·신뢰)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;일관된 엔티티&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Organization, Product 스키마&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;중복 없는 고유 정보&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;다른 100개 글과 같은 내용이면 인용 가치 ↓&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;22-인용에-불리한-신호&quot;&gt;2.2 인용에 불리한 신호&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;제목만 SEO이고 본문은 광고·잡담&lt;/li&gt;
  &lt;li&gt;paywall 뒤 핵심 답(크롤러/봇이 못 읽음)&lt;/li&gt;
  &lt;li&gt;자동 생성 콘텐츠 느낌(반복 문장, 환각성 “확실히”)&lt;/li&gt;
  &lt;li&gt;사실과 다른 코드/설정(한 번 틀리면 신뢰 회복 어려움)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;23-인용과-추천-구분&quot;&gt;2.3 “인용”과 “추천” 구분&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;전략&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;인용(Citation)&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;“~에 따르면 &lt;a href=&quot;url&quot;&gt;yourblog.com&lt;/a&gt; …”&lt;/td&gt;
      &lt;td&gt;팩트·가이드·비교표&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;추천(Recommendation)&lt;/strong&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;strong&gt;브랜드 언급&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;“OO 서비스는 …”&lt;/td&gt;
      &lt;td&gt;카테고리 대표 포지션, 미디어·커뮤니티&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;h2 id=&quot;3-aeo-콘텐츠-아키텍처--웹서비스에-맞는-페이지-타입&quot;&gt;3. AEO 콘텐츠 아키텍처 — 웹서비스에 맞는 페이지 타입&lt;/h2&gt;

&lt;h3 id=&quot;31-5가지-핵심-페이지-타입&quot;&gt;3.1 5가지 핵심 페이지 타입&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;strong&gt;Definition&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;“OO란?” 한 문단 답&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/docs/what-is-celery-beat&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;How-to&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;단계별 절차&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/guides/django-celery-beat-daily&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Comparison&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;A vs B 표&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/compare/stripe-vs-paddle&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Troubleshooting&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;에러 메시지 → 해결&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/help/migration-error-xxx&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Policy / Trust&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;가격·보안·개인정보&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/pricing&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/security&lt;/code&gt;&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;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;❌ /blog/post-123
✅ /guides/django-ninja-celery-beat-every-day-9am
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;32-답변-청크answer-chunk-작성-규칙&quot;&gt;3.2 “답변 청크(Answer Chunk)” 작성 규칙&lt;/h3&gt;

&lt;p&gt;AI가 잘라 쓰기 좋은 단위를 &lt;strong&gt;Answer Chunk&lt;/strong&gt;라고 부릅니다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;좋은 청크 구조&lt;/strong&gt;&lt;/p&gt;

&lt;div class=&quot;language-markdown 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;gu&quot;&gt;## Celery Beat이란?&lt;/span&gt;

Celery Beat은 &lt;span class=&quot;gs&quot;&gt;**주기적 작업 스케줄러**&lt;/span&gt;입니다. Worker가 실행할 작업을
cron처럼 등록해 두면, 정해진 시간에 큐에 메시지를 넣습니다.
&lt;span class=&quot;p&quot;&gt;
-&lt;/span&gt; &lt;span class=&quot;gs&quot;&gt;**언제 쓰나**&lt;/span&gt;: 매일 리포트, 주간 정리, 구독 갱신 알림
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;gs&quot;&gt;**언제 쓰지 않나**&lt;/span&gt;: 실시간 이벤트(그때는 Consumer + 이벤트 스트림)

| 항목 | Celery Beat | cron |
|------|-------------|------|
| 분산 | O (여러 Worker) | 서버 1대 |
| 재시도·모니터링 | Flower 등 연동 | 직접 구현 |
&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;H2 = &lt;strong&gt;질문 또는 명확한 주제&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;첫 문단 = &lt;strong&gt;40~80자 정의&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;li&gt;마지막에 &lt;strong&gt;“다음에 읽을 글”&lt;/strong&gt; 내부 링크 2~3개&lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;33-faq를-장식이-아니라-인용-단위로&quot;&gt;3.3 FAQ를 “장식”이 아니라 “인용 단위”로&lt;/h3&gt;

&lt;p&gt;페이지 하단 FAQ 3개는 AEO에 거의 도움이 안 됩니다. &lt;strong&gt;본문 전체가 FAQ 집합&lt;/strong&gt;이어야 합니다.&lt;/p&gt;

&lt;div class=&quot;language-markdown 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;gu&quot;&gt;## 자주 묻는 질문&lt;/span&gt;

&lt;span class=&quot;gu&quot;&gt;### Q. Celery Beat과 cron 중 뭘 써야 하나요?&lt;/span&gt;

&lt;span class=&quot;gs&quot;&gt;**A.**&lt;/span&gt; 팀이 이미 Celery를 쓰고 있고 작업 실패·재시도·모니터링이 필요하면 Beat이 낫습니다.
서버 한 대의 단순 스크립트면 cron이 더 가볍습니다.

&lt;span class=&quot;gu&quot;&gt;### Q. Beat 스케줄이 중복 실행되면?&lt;/span&gt;

&lt;span class=&quot;gs&quot;&gt;**A.**&lt;/span&gt; &lt;span class=&quot;sb&quot;&gt;`task_always_eager`&lt;/span&gt; 설정, timezone, &lt;span class=&quot;sb&quot;&gt;`CELERY_BEAT_SCHEDULE`&lt;/span&gt; 중복 키를 확인하세요.
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;34-esc-prompting과-aeo&quot;&gt;3.4 &lt;a href=&quot;/2026/05/28/esc-equalizer-socratic-chain-prompting/&quot;&gt;ESC Prompting&lt;/a&gt;과 AEO&lt;/h3&gt;

&lt;p&gt;긴 가이드를 쓸 때 &lt;strong&gt;Equalizer(균형)·Socratic(질문)·Chain(단계)&lt;/strong&gt; 구조는 AEO에도 그대로 맞습니다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Equalizer&lt;/strong&gt;: 장단점·비교표 → AI가 “균형 잡힌 답”으로 인용하기 쉬움&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Socratic&lt;/strong&gt;: “왜 Beat인가?” 질문–답 블록 → 질문 매칭 ↑&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Chain&lt;/strong&gt;: 1→2→3 단계 → How-to 인용 ↑&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;4-온페이지-aeo--제목메타첫-화면&quot;&gt;4. 온페이지 AEO — 제목·메타·첫 화면&lt;/h2&gt;

&lt;p&gt;SEO 가이드의 원칙을 &lt;strong&gt;답변 엔진용&lt;/strong&gt;으로 재해석합니다.&lt;/p&gt;

&lt;h3 id=&quot;41-title--검색어가-아니라-질문&quot;&gt;4.1 Title — “검색어”가 아니라 “질문”&lt;/h3&gt;

&lt;div class=&quot;language-yaml 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;c1&quot;&gt;# ❌ 키워드 나열&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;title&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Django&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Celery&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Beat&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Redis&quot;&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# ✅ 질문·결과 약속&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;title&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Django&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Celery&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Beat으로&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;매일&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;9시&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;작업&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;실행하기&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;—&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;설정·타임존·중복&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;방지&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;42-description--excerpt--인용될-한-줄-요약&quot;&gt;4.2 Description / Excerpt — 인용될 한 줄 요약&lt;/h3&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;description&lt;/code&gt;은 검색 스니펫이자, AI가 페이지를 &lt;strong&gt;요약할 때 참고하는 텍스트&lt;/strong&gt;입니다.&lt;/p&gt;

&lt;div class=&quot;language-yaml 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;na&quot;&gt;description&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Django&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;5.x&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Celery&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;5.x&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;기준으로&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Beat&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;스케줄&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;등록,&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;timezone&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;설정,&lt;/span&gt;
&lt;span class=&quot;s&quot;&gt;중복&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;실행&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;방지까지&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;15분&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;안에&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;적용하는&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;단계별&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;가이드입니다.&quot;&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;excerpt&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Beat은&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;언제&apos;를&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;담당하고&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Worker는&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;실행&apos;을&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;담당합니다.&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;이&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;글은&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;매일&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;9시&lt;/span&gt;
&lt;span class=&quot;s&quot;&gt;리포트&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;발송을&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;end-to-end로&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;붙입니다.&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;43-첫-200단어에-답을-넣기&quot;&gt;4.3 첫 200단어에 답을 넣기&lt;/h3&gt;

&lt;p&gt;답변 엔진·AI Overviews는 &lt;strong&gt;페이지 상단&lt;/strong&gt; 가중치가 큽니다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;서론 3문단 뒤에 답이 나오면 인용률 ↓&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;첫 H2 아래 첫 단락&lt;/strong&gt;에 결론·정의·표 1개&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;44-날짜버전수정-이력&quot;&gt;4.4 날짜·버전·수정 이력&lt;/h3&gt;

&lt;div class=&quot;language-yaml 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;na&quot;&gt;date&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;2026-06-03&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;last_modified_at&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;2026-06-03&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;본문에도 명시:&lt;/p&gt;

&lt;div class=&quot;language-markdown 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;gt&quot;&gt;&amp;gt; **검증 환경**: Django 5.1, Celery 5.4, Redis 7.2 (2026-06-03 기준)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;5-구조화-데이터schemaorg--aeo의-기술-핵심&quot;&gt;5. 구조화 데이터(Schema.org) — AEO의 기술 핵심&lt;/h2&gt;

&lt;p&gt;구조화 데이터는 “리치 스니펫용 장식”이 아니라 &lt;strong&gt;엔티티·관계를 기계가 읽게 하는 레이어&lt;/strong&gt;입니다.&lt;/p&gt;

&lt;h3 id=&quot;51-우선순위-스키마-웹서비스&quot;&gt;5.1 우선순위 스키마 (웹서비스)&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;우선순위&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Organization&lt;/strong&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;strong&gt;WebSite&lt;/strong&gt; + &lt;strong&gt;SearchAction&lt;/strong&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;strong&gt;Article&lt;/strong&gt; / &lt;strong&gt;TechArticle&lt;/strong&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;strong&gt;FAQPage&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;FAQ 전용 페이지&lt;/td&gt;
      &lt;td&gt;높음&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;HowTo&lt;/strong&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;strong&gt;SoftwareApplication&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;SaaS 제품&lt;/td&gt;
      &lt;td&gt;제품 페이지&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Product&lt;/strong&gt; + &lt;strong&gt;Offer&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;가격&lt;/td&gt;
      &lt;td&gt;Pricing&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;BreadcrumbList&lt;/strong&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;h3 id=&quot;52-article--faqpage-조합-예시-json-ld&quot;&gt;5.2 Article + FAQPage 조합 예시 (JSON-LD)&lt;/h3&gt;

&lt;p&gt;Jekyll &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;post&lt;/code&gt; 레이아웃 또는 Next.js &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;layout.tsx&lt;/code&gt;에 삽입:&lt;/p&gt;

&lt;div class=&quot;language-html 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;nt&quot;&gt;&amp;lt;script &lt;/span&gt;&lt;span class=&quot;na&quot;&gt;type=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;application/ld+json&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;@context&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;https://schema.org&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;@graph&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;@type&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;TechArticle&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;headline&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;Django Celery Beat으로 매일 9시 작업 실행하기&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;datePublished&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;2026-06-03&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;dateModified&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;2026-06-03&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;author&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;@type&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;Person&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;updaun&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;https://updaun.github.io/about&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
      &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;publisher&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;@type&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;Organization&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;updaun&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;logo&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
          &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;@type&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;ImageObject&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
          &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;https://updaun.github.io/assets/img/logo.png&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
      &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;mainEntityOfPage&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;https://updaun.github.io/2026/06/03/example/&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;@type&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;FAQPage&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;mainEntity&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
          &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;@type&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;Question&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
          &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;Celery Beat과 cron 중 무엇을 써야 하나요?&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
          &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;acceptedAnswer&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;@type&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;Answer&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;이미 Celery를 쓰는 팀은 Beat이 재시도·모니터링 측면에서 유리합니다. 단일 서버의 단순 작업은 cron이 더 가볍습니다.&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;
          &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;주의&lt;/strong&gt;: FAQPage의 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Answer&lt;/code&gt;는 &lt;strong&gt;페이지에 보이는 텍스트와 동일&lt;/strong&gt;해야 합니다. 스키마만 키워드 채우면 스팸으로 처리될 수 있습니다.&lt;/p&gt;

&lt;h3 id=&quot;53-howto--단계형-가이드&quot;&gt;5.3 HowTo — 단계형 가이드&lt;/h3&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;@type&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;HowTo&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;name&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;Celery Beat 매일 9시 스케줄 등록&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;step&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;p&quot;&gt;[&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;span class=&quot;nl&quot;&gt;&quot;@type&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;HowToStep&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;name&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;settings.py에 Beat 스케줄 추가&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;text&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;CELERY_BEAT_SCHEDULE에 crontab(hour=9, minute=0)을 등록합니다.&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;span class=&quot;p&quot;&gt;]&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;h3 id=&quot;54-softwareapplication--saas-제품-페이지&quot;&gt;5.4 SoftwareApplication — SaaS 제품 페이지&lt;/h3&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;@type&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;SoftwareApplication&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;name&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;applicationCategory&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;BusinessApplication&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;operatingSystem&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;Web&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;offers&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;@type&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;Offer&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;price&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;9.00&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;priceCurrency&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;USD&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;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;h3 id=&quot;55-json-ld-검증&quot;&gt;5.5 JSON-LD 검증&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://search.google.com/test/rich-results&quot;&gt;Google Rich Results Test&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;Search Console → 개선 사항&lt;/li&gt;
  &lt;li&gt;배포 후 &lt;strong&gt;소스 보기&lt;/strong&gt;로 스크립트 중복·문법 오류 확인&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;6-nextjs-웹서비스-aeo-구현--실전-코드&quot;&gt;6. Next.js 웹서비스 AEO 구현 — 실전 코드&lt;/h2&gt;

&lt;h3 id=&quot;61-app-router-메타데이터&quot;&gt;6.1 App Router 메타데이터&lt;/h3&gt;

&lt;div class=&quot;language-tsx 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;c1&quot;&gt;// app/guides/[slug]/page.tsx&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;type&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Metadata&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;next&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;generateMetadata&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;params&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}):&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Promise&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;Metadata&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;guide&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;getGuide&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;params&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;slug&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;title&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;guide&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;title&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;description&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;guide&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;description&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;openGraph&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;title&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;guide&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;title&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;description&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;guide&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;description&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;article&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;publishedTime&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;guide&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;publishedAt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;modifiedTime&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;guide&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;updatedAt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;alternates&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;canonical&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;guide&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;canonicalUrl&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;};&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;62-json-ld-컴포넌트&quot;&gt;6.2 JSON-LD 컴포넌트&lt;/h3&gt;

&lt;div class=&quot;language-tsx 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;c1&quot;&gt;// components/JsonLd.tsx&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;JsonLd&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;data&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Record&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kr&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;unknown&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;})&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;script&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;application/ld+json&quot;&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;dangerouslySetInnerHTML&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;__html&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;JSON&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;stringify&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;63-mdx콘텐츠에서-answer-chunk-자동-추출선택&quot;&gt;6.3 MDX/콘텐츠에서 Answer Chunk 자동 추출(선택)&lt;/h3&gt;

&lt;p&gt;프론트 matter에 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;aeo_summary&lt;/code&gt; 필드를 두면 AI·내부 도구가 동일 요약을 씁니다.&lt;/p&gt;

&lt;div class=&quot;language-yaml 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;nn&quot;&gt;---&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;title&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;...&quot;&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;aeo_summary&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Celery&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Beat은&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;분산&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;환경에서&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;cron&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;대체로&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;쓰는&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;스케줄러입니다.&quot;&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;aeo_questions&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Celery&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Beat과&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;cron&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;차이는?&quot;&lt;/span&gt;
  &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;매일&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;9시&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;실행&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;설정은?&quot;&lt;/span&gt;
&lt;span class=&quot;nn&quot;&gt;---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;64-ssr-vs-정적--aeo-관점&quot;&gt;6.4 SSR vs 정적 — AEO 관점&lt;/h3&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;방식&lt;/th&gt;
      &lt;th&gt;AEO&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;SSR/ISR&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;최신 날짜·동적 FAQ 반영 용이&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;SSG&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;속도·안정성 좋음, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;revalidate&lt;/code&gt;로 수정일 갱신 필수&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;strong&gt;느린 TTFB&lt;/strong&gt;는 크롤·사용자 모두에 불리합니다. &lt;a href=&quot;/2026/03/18/vercel-nextjs-static-site-deployment-guide/&quot;&gt;Vercel 배포 가이드&lt;/a&gt;의 성능 체크리스트를 함께 적용하세요.&lt;/p&gt;

&lt;h3 id=&quot;65-robotstxt--ai-크롤러-정책&quot;&gt;6.5 robots.txt — AI 크롤러 정책&lt;/h3&gt;

&lt;p&gt;2026년 기준 여러 서비스가 &lt;strong&gt;전용 User-agent&lt;/strong&gt;를 씁니다. 정책은 팀마다 다르지만, &lt;strong&gt;의도적으로 막을지 허용할지 문서화&lt;/strong&gt;해야 합니다.&lt;/p&gt;

&lt;pre&gt;&lt;code class=&quot;language-txt&quot;&gt;# 예시 — 허용 정책 (팀 정책에 맞게 수정)
User-agent: *
Allow: /

# 사내/스테이징 차단
Disallow: /staging/

Sitemap: https://app.example.com/sitemap.xml
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;일부 팀은 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GPTBot&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;OAI-SearchBot&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PerplexityBot&lt;/code&gt; 등을 &lt;strong&gt;Allow&lt;/strong&gt;해 인용 기회를 넓히고, 다른 팀은 &lt;strong&gt;Disallow&lt;/strong&gt;해 학습·스크래핑을 제한합니다. &lt;strong&gt;법무·개인정보&lt;/strong&gt;와 함께 결정하세요.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;7-llmstxt--aitxt--답변-엔진용-사이트-설명서&quot;&gt;7. llms.txt · ai.txt — 답변 엔진용 “사이트 설명서”&lt;/h2&gt;

&lt;p&gt;커뮤니티에서 확산 중인 관행은 루트에 &lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/llms.txt&lt;/code&gt;&lt;/strong&gt; (또는 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;llms-full.txt&lt;/code&gt;)를 두어 &lt;strong&gt;AI·개발자에게 사이트 지도를 제공&lt;/strong&gt;하는 것입니다.&lt;/p&gt;

&lt;h3 id=&quot;71-llmstxt-예시&quot;&gt;7.1 llms.txt 예시&lt;/h3&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/public/llms.txt&lt;/code&gt; 또는 Jekyll &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;llms.txt&lt;/code&gt;:&lt;/p&gt;

&lt;div class=&quot;language-markdown 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;gh&quot;&gt;# My SaaS Docs&lt;/span&gt;
&lt;span class=&quot;gt&quot;&gt;
&amp;gt; B2B 리포트 자동화 SaaS. Django API + Next.js 대시보드.&lt;/span&gt;

&lt;span class=&quot;gu&quot;&gt;## Docs&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;Getting Started&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;](&lt;/span&gt;&lt;span class=&quot;sx&quot;&gt;https://app.example.com/docs/start&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;: 5분 온보딩
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;Pricing&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;](&lt;/span&gt;&lt;span class=&quot;sx&quot;&gt;https://app.example.com/pricing&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;: 플랜·한도
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;Security&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;](&lt;/span&gt;&lt;span class=&quot;sx&quot;&gt;https://app.example.com/security&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;: SOC2, 데이터 보관

&lt;span class=&quot;gu&quot;&gt;## Guides&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;Celery Beat daily report&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;](&lt;/span&gt;&lt;span class=&quot;sx&quot;&gt;https://blog.example.com/guides/celery-beat-daily&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;: 매일 9시 리포트

&lt;span class=&quot;gu&quot;&gt;## Contact&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; support@example.com
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;72-seo-사이트맵과-병행&quot;&gt;7.2 SEO 사이트맵과 병행&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;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sitemap.xml&lt;/code&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;llms.txt&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;LLM·에이전트·개발자 요약&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;robots.txt&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;크롤 정책&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;llms.txt는 &lt;strong&gt;공개 마케팅 페이지만&lt;/strong&gt; 링크하고, 로그인 뒤·PII 영역은 넣지 마세요.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;8-e-e-a-t와-신뢰--aeo에서-더-중요해진-이유&quot;&gt;8. E-E-A-T와 신뢰 — AEO에서 더 중요해진 이유&lt;/h2&gt;

&lt;p&gt;답변 엔진은 &lt;strong&gt;틀린 답의 비용&lt;/strong&gt;이 큽니다. 그래서 “누가 썼는지”가 SEO보다 더 자주 드러납니다.&lt;/p&gt;

&lt;h3 id=&quot;81-experience--expertise--authoritativeness--trust&quot;&gt;8.1 Experience · Expertise · Authoritativeness · Trust&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;strong&gt;Experience&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;“우리가 프로덕션에서 쓴다” + 스크린샷·수치&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Expertise&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;저자 프로필, 자격, GitHub·발표 링크&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Authoritativeness&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;다른 매체 인용, 공식 파트너, 문서 링크&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Trust&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;HTTPS, Privacy, 연락처, 수정일, 오류 정정&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;82-저자조직-페이지&quot;&gt;8.2 저자·조직 페이지&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/about&lt;/code&gt; — Person 스키마&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/team&lt;/code&gt; 또는 저자 카드&lt;/li&gt;
  &lt;li&gt;글마다 &lt;strong&gt;동일한 author&lt;/strong&gt; front matter (&lt;a href=&quot;/2026/03/06/complete-seo-optimization-strategy-guide/&quot;&gt;블로그 SEO 가이드&lt;/a&gt; 참고)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;83-출처인용-규칙&quot;&gt;8.3 출처·인용 규칙&lt;/h3&gt;

&lt;p&gt;기술 글은 &lt;strong&gt;1차 출처&lt;/strong&gt;를 링크합니다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Django 공식 문서&lt;/li&gt;
  &lt;li&gt;Celery release note&lt;/li&gt;
  &lt;li&gt;GitHub issue (버그 재현 시)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;“기억에 의존한 API”는 한 번 틀리면 AI 인용에서 영구히 불리해집니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;9-분산다채널-aeo--검색만이-아님&quot;&gt;9. 분산·다채널 AEO — 검색만이 아님&lt;/h2&gt;

&lt;h3 id=&quot;91-채널별-역할&quot;&gt;9.1 채널별 역할&lt;/h3&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;채널&lt;/th&gt;
      &lt;th&gt;AEO 역할&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;strong&gt;정본(canonical)&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;GitHub README&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;개발자 질문·코파일럿 맥락&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;YouTube / Shorts&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;절차 시각화 → 설명란에 문서 링크&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Reddit / Stack Overflow&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;질문에 &lt;strong&gt;문서 URL&lt;/strong&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;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;92-키워드-리서치--질문형으로-전환&quot;&gt;9.2 키워드 리서치 — “질문형”으로 전환&lt;/h3&gt;

&lt;p&gt;&lt;a href=&quot;/2026/03/14/naver-keyword-ad-api-django-ninja-integration/&quot;&gt;네이버 키워드 API&lt;/a&gt;·&lt;a href=&quot;/2026/04/28/serpapi-complete-guide/&quot;&gt;SerpAPI&lt;/a&gt;로 &lt;strong&gt;검색량&lt;/strong&gt;을 보더라, AEO용으로는 &lt;strong&gt;질문 문장&lt;/strong&gt;을 수집합니다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;AnswerThePublic&lt;/li&gt;
  &lt;li&gt;Google “People also ask”&lt;/li&gt;
  &lt;li&gt;고객 지원 티켓 FAQ&lt;/li&gt;
  &lt;li&gt;ChatGPT/Perplexity에 &lt;strong&gt;브랜드 없이&lt;/strong&gt; 질문해 보고 출처 URL 수집&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;93-ai-report-직원으로-경쟁-인용-모니터링&quot;&gt;9.3 &lt;a href=&quot;/2026/05/30/ai-report-employee-daily-briefing-automation/&quot;&gt;AI Report 직원&lt;/a&gt;으로 경쟁 인용 모니터링&lt;/h3&gt;

&lt;p&gt;주간 브리핑 항목 예시:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;“우리 카테고리 질문 10개”에 Perplexity 인용 URL&lt;/li&gt;
  &lt;li&gt;경쟁사 문서 vs 우리 문서 &lt;strong&gt;질문 커버리지 갭&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;신규 벤더 기능(예: Django 6) → &lt;strong&gt;수정 필요 글 목록&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;10-측정--aeo-kpi와-도구&quot;&gt;10. 측정 — AEO KPI와 도구&lt;/h2&gt;

&lt;p&gt;AEO는 SEO만큼 단일 대시보드가 없습니다. &lt;strong&gt;프록시 지표&lt;/strong&gt;를 조합합니다.&lt;/p&gt;

&lt;h3 id=&quot;101-kpi-테이블&quot;&gt;10.1 KPI 테이블&lt;/h3&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;KPI&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;strong&gt;AI Referral 세션&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;GA4 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;session_source&lt;/code&gt; = chatgpt.com, perplexity.ai 등&lt;/td&gt;
      &lt;td&gt;전월 대비 +20%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;브랜드 검색량&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Search Console 브랜드 쿼리&lt;/td&gt;
      &lt;td&gt;상승 추세&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;인용 URL 수동 샘플&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;주 10질문 × 3엔진 수동 체크&lt;/td&gt;
      &lt;td&gt;인용 30%→50%&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;/docs/*&lt;/code&gt; engagement&lt;/td&gt;
      &lt;td&gt;2분+&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Rich result 노출&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Search Console&lt;/td&gt;
      &lt;td&gt;FAQ/HowTo 유효&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;지원 티켓 FAQ 전환&lt;/strong&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;h3 id=&quot;102-ga4-커스텀-채널개념&quot;&gt;10.2 GA4 커스텀 채널(개념)&lt;/h3&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;AI Answers:
  source contains chatgpt | perplexity | copilot | gemini | claude
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;103-search-console--ai-overviews&quot;&gt;10.3 Search Console + AI Overviews&lt;/h3&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;가 가이드/FAQ인지 확인&lt;/li&gt;
  &lt;li&gt;CTR 하락 + 노출 증가 = &lt;strong&gt;AI Overviews가 답을 가져감&lt;/strong&gt; 가능 → AEO 콘텐츠 강화&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;104-수동-인용-감사audit-템플릿&quot;&gt;10.4 수동 인용 감사(Audit) 템플릿&lt;/h3&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;질문&lt;/th&gt;
      &lt;th&gt;ChatGPT&lt;/th&gt;
      &lt;th&gt;Perplexity&lt;/th&gt;
      &lt;th&gt;Google AI&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;OO 설정 방법&lt;/td&gt;
      &lt;td&gt;인용 O/X&lt;/td&gt;
      &lt;td&gt;URL&lt;/td&gt;
      &lt;td&gt;URL&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;A vs B 비교&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;월 1회, &lt;strong&gt;동일 질문 세트&lt;/strong&gt;로 반복 측정해야 추세가 보입니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;11-운영-플레이북--90일-aeo-로드맵&quot;&gt;11. 운영 플레이북 — 90일 AEO 로드맵&lt;/h2&gt;

&lt;h3 id=&quot;phase-1-day-114-기반&quot;&gt;Phase 1 (Day 1~14): 기반&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;Technical SEO 점검&lt;/td&gt;
      &lt;td&gt;sitemap, robots, canonical, CWV&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Organization + WebSite JSON-LD&lt;/td&gt;
      &lt;td&gt;전역 레이아웃&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;llms.txt 게시&lt;/td&gt;
      &lt;td&gt;공개 문서 맵&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Top 20 고객 질문 수집&lt;/td&gt;
      &lt;td&gt;스프레드시트&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;phase-2-day-1545-콘텐츠&quot;&gt;Phase 2 (Day 15~45): 콘텐츠&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;질문당 1 URL&lt;/td&gt;
      &lt;td&gt;10~20개 Definition/How-to&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;기존 인기 글 AEO 리라이트&lt;/td&gt;
      &lt;td&gt;Answer Chunk, FAQ, 날짜&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;FAQPage / HowTo 스키마&lt;/td&gt;
      &lt;td&gt;상위 10페이지&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;phase-3-day-4690-확장측정&quot;&gt;Phase 3 (Day 46~90): 확장·측정&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;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/compare&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/help&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;AI Referral 대시보드&lt;/td&gt;
      &lt;td&gt;GA4&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;a href=&quot;/2026/05/30/ai-report-employee-daily-briefing-automation/&quot;&gt;Report 직원&lt;/a&gt; 연동&lt;/td&gt;
      &lt;td&gt;주간 브리핑&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;12-ai로-콘텐츠를-만들-때-aeo-주의사항&quot;&gt;12. AI로 콘텐츠를 만들 때 AEO 주의사항&lt;/h2&gt;

&lt;p&gt;&lt;a href=&quot;/2026/04/11/ai-agent-as-employee-hiring-guide/&quot;&gt;AI 직원&lt;/a&gt;·&lt;a href=&quot;/2026/04/09/ai-prompt-engineering-for-developers-guide/&quot;&gt;프롬프트 엔지니어링&lt;/a&gt;으로 글을 빠르게 쓸 수 있지만, AEO에는 &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;/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;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;고유 경험 1섹션 이상&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;중복 콘텐츠 필터&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;코드는 CI에서 실행&lt;/strong&gt;&lt;/td&gt;
      &lt;td&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;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;human review before publish&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;E-E-A-T&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;13-흔한-실수-10가지&quot;&gt;13. 흔한 실수 10가지&lt;/h2&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;SEO 제목만 바꾸고 본문 구조는 그대로&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;FAQPage 스키마와 본문 FAQ 불일치&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;paywall 뒤에 핵심 답 숨김&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;날짜·버전 없는 기술 글&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;같은 주제 블로그 50편&lt;/strong&gt;(얇은 중복)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;외부 링크 없는 “확실히” 문장&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;llms.txt에 비공개 URL&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;AI 크롤러 정책 미결정&lt;/strong&gt;(법무 리스크)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;인용 측정 없이 “잘 됐을 것”&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;AEO만 하고 제품·지원 품질 방치&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;14-체크리스트--출시-전글-발행-전&quot;&gt;14. 체크리스트 — 출시 전·글 발행 전&lt;/h2&gt;

&lt;h3 id=&quot;사이트-전역&quot;&gt;사이트 전역&lt;/h3&gt;

&lt;ul class=&quot;task-list&quot;&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;HTTPS, canonical, sitemap 제출&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;Organization / WebSite JSON-LD&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/llms.txt&lt;/code&gt; (공개 문서만)&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;robots 정책 문서화&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;Core Web Vitals 양호&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;글문서-1페이지&quot;&gt;글/문서 1페이지&lt;/h3&gt;

&lt;ul class=&quot;task-list&quot;&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;Title = 질문 또는 명확한 결과&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;첫 H2 아래 정의·표·목록&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;FAQ 3개 이상(본문)&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;검증 환경·날짜&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;1차 출처 링크&lt;/li&gt;
  &lt;li&gt;
    &lt;table&gt;
      &lt;tbody&gt;
        &lt;tr&gt;
          &lt;td&gt;[ ] Article + (FAQPage&lt;/td&gt;
          &lt;td&gt;HowTo) JSON-LD&lt;/td&gt;
        &lt;/tr&gt;
      &lt;/tbody&gt;
    &lt;/table&gt;
  &lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;내부 링크 2~3&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;aeo_summary&lt;/code&gt; 또는 excerpt 정제&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;15-정리--aeo는-순위가-아니라-인용-설계&quot;&gt;15. 정리 — AEO는 “순위”가 아니라 “인용 설계”&lt;/h2&gt;

&lt;p&gt;웹서비스 AEO 최적화는 다음 한 문장으로 요약됩니다.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;AI가 사용자 질문에 답할 때, 당신의 페이지에서 잘라 쓸 수 있는 정확한 청크를 제공하고, 그 페이지를 신뢰할 수 있는 출처로 만든다.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;SEO 기반&lt;/strong&gt; 위에 &lt;strong&gt;질문 단위 URL + Answer Chunk&lt;/strong&gt;를 쌓고&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Schema.org + llms.txt&lt;/strong&gt;로 기계 가독성을 높이며&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;E-E-A-T·출처·날짜&lt;/strong&gt;로 인용 신뢰를 확보하고&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;AI Referral·인용 감사&lt;/strong&gt;로 개선 루프를 돌린다&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;다음 액션 하나만 고르세요.&lt;/p&gt;

&lt;ul class=&quot;task-list&quot;&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;고객 질문 Top 20 → URL 10개 기획&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;상위 트래픽 글 3개 AEO 리라이트&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;전역 Organization JSON-LD + llms.txt&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;GA4 AI Referral 세그먼트 생성&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;참고-자료&quot;&gt;참고 자료&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data&quot;&gt;Google Search Central — 구조화 데이터&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://schema.org/&quot;&gt;Schema.org — FAQPage, HowTo, TechArticle&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://llmstxt.org/&quot;&gt;llms.txt 제안 (community)&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://search.google.com/search-console&quot;&gt;Google Search Console&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.perplexity.ai/&quot;&gt;Perplexity&lt;/a&gt; — 수동 인용 감사용&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;관련-글&quot;&gt;관련 글&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/03/06/complete-seo-optimization-strategy-guide/&quot;&gt;블로그 SEO 최적화 완벽 가이드&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/05/28/esc-equalizer-socratic-chain-prompting/&quot;&gt;ESC Prompting — 긴 가이드 구조화&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/04/28/serpapi-complete-guide/&quot;&gt;SerpAPI 완전 가이드&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/03/14/naver-keyword-ad-api-django-ninja-integration/&quot;&gt;네이버 키워드 API + Django Ninja&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/05/30/ai-report-employee-daily-briefing-automation/&quot;&gt;AI Report 직원 — 주간 브리핑&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/06/01/ai-solo-founder-subscription-global-strategy/&quot;&gt;AI로 1인 구독 서비스 — 글로벌 성장&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/03/18/vercel-nextjs-static-site-deployment-guide/&quot;&gt;Vercel + Next.js 배포&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content><author><name>updaun</name></author><category term="seo" /><category term="marketing" /><category term="web-development" /><category term="aeo" /><category term="answer-engine-optimization" /><category term="geo" /><category term="seo" /><category term="structured-data" /><category term="llms-txt" /><category term="chatgpt" /><category term="perplexity" /><category term="google-ai-overviews" /><category term="nextjs" /><category term="content-strategy" /><category term="citation" /><summary type="html">SEO만으로는 부족해졌습니다. 사용자는 &apos;10개 링크&apos;가 아니라 &apos;한 줄 답&apos;을 원하고, AI는 그 답의 출처로 당신의 페이지를 고릅니다. AEO는 순위 게임이 아니라 &apos;추출·인용·신뢰&apos;를 설계하는 일입니다.</summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-06-03-web-service-aeo-optimization-complete-guide.webp" /><media:content medium="image" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-06-03-web-service-aeo-optimization-complete-guide.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Next.js 웹서비스를 React Native WebView 앱으로 전환하기 — Expo부터 스토어 출시까지 2026 완전 가이드</title><link href="https://updaun.github.io/react-native/mobile/2026/06/02/nextjs-to-react-native-webview-app-complete-guide/" rel="alternate" type="text/html" title="Next.js 웹서비스를 React Native WebView 앱으로 전환하기 — Expo부터 스토어 출시까지 2026 완전 가이드" /><published>2026-06-02T00:00:00+09:00</published><updated>2026-06-02T00:00:00+09:00</updated><id>https://updaun.github.io/react-native/mobile/2026/06/02/nextjs-to-react-native-webview-app-complete-guide</id><content type="html" xml:base="https://updaun.github.io/react-native/mobile/2026/06/02/nextjs-to-react-native-webview-app-complete-guide/">&lt;h1 id=&quot;nextjs-웹서비스를-react-native-webview-앱으로-전환하기--expo부터-스토어-출시까지-2026-완전-가이드&quot;&gt;Next.js 웹서비스를 React Native WebView 앱으로 전환하기 — Expo부터 스토어 출시까지 2026 완전 가이드&lt;/h1&gt;

&lt;p&gt;이미 Next.js로 웹서비스를 운영 중인데, 어느 날 이런 요구가 들어옵니다.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;“앱스토어/플레이스토어에도 올려야 해요.”&lt;br /&gt;
“푸시 알림도 필요해요.”&lt;br /&gt;
“모바일에서 더 ‘앱 같은’ 경험이었으면 좋겠어요.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;그때 가장 먼저 떠오르는 접근이 &lt;strong&gt;React Native로 앱을 새로 만드는 것&lt;/strong&gt;이지만, 실제로는 “처음부터 네이티브 UI를 전부 다시”가 아니라 &lt;strong&gt;웹을 WebView로 감싸는 래핑 앱&lt;/strong&gt;으로 충분한 케이스가 많습니다.&lt;/p&gt;

&lt;p&gt;이 글은 &lt;strong&gt;Next.js 웹서비스를 React Native(WebView) 앱으로 전환&lt;/strong&gt;하는 과정을, “앱을 실제로 출시해서 운영”하는 관점에서 아주 상세히 정리합니다.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;이미 비슷한 목적의 글로 Android는 TWA/Capacitor 중심, iOS는 Capacitor 중심으로 다뤘습니다. 먼저 큰 그림에서 방법 선택이 필요하다면 아래 글도 함께 보세요.&lt;/p&gt;
  &lt;ul&gt;
    &lt;li&gt;&lt;a href=&quot;/2026/05/26/django-nextjs-android-app-deploy-guide/&quot;&gt;Django + Next.js 웹앱을 안드로이드 앱으로 만들어 플레이스토어에 배포하기&lt;/a&gt;&lt;/li&gt;
    &lt;li&gt;&lt;a href=&quot;/2026/05/27/ios-app-deploy-for-django-nextjs/&quot;&gt;Django + Next.js 웹앱을 iOS 앱으로 배포하기 — TestFlight/App Store&lt;/a&gt;&lt;/li&gt;
  &lt;/ul&gt;
&lt;/blockquote&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;0-전제-webview-앱이-가장-좋은-선택인-경우&quot;&gt;0. 전제: WebView 앱이 “가장 좋은 선택”인 경우&lt;/h2&gt;

&lt;p&gt;React Native WebView 래핑은 &lt;strong&gt;개발 속도&lt;/strong&gt;와 &lt;strong&gt;웹 재사용률&lt;/strong&gt;이 장점입니다. 대신, WebView가 포함된 앱은 스토어 정책·성능·UX에서 종종 불리합니다.&lt;/p&gt;

&lt;h3 id=&quot;01-webview-래핑이-잘-맞는-경우&quot;&gt;0.1 WebView 래핑이 잘 맞는 경우&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;이미 웹에서 &lt;strong&gt;핵심 UX가 완성&lt;/strong&gt;되어 있고, 모바일은 “스토어 유통 + 푸시 + 딥링크”가 주 목적&lt;/li&gt;
  &lt;li&gt;화면 전환/애니메이션보다 &lt;strong&gt;콘텐츠/업무 흐름&lt;/strong&gt;이 중요한 서비스&lt;/li&gt;
  &lt;li&gt;SSR/서버 기능이 많아 &lt;strong&gt;정적 번들 방식이 부담&lt;/strong&gt;되고, 앱은 URL 로딩으로 운영하고 싶음&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;02-webview-래핑이-맞지-않는-경우처음부터-rn네이티브-고려&quot;&gt;0.2 WebView 래핑이 맞지 않는 경우(처음부터 RN/네이티브 고려)&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;스크롤/제스처가 많은 &lt;strong&gt;고성능 피드형 UI&lt;/strong&gt;(대형 커뮤니티, 쇼츠/릴스류)&lt;/li&gt;
  &lt;li&gt;카메라·BLE·백그라운드 작업 등 &lt;strong&gt;네이티브 API가 핵심 가치&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;결제/구독이 앱스토어 IAP 정책에 강하게 묶이는 상품(특히 디지털 콘텐츠/기능 언락)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;03-방법-선택-한-장-요약-androidios-공통&quot;&gt;0.3 방법 선택 한 장 요약 (Android/iOS 공통)&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;단점&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;React Native + WebView&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;JS/TS로 iOS+Android 한 번에, 네이티브 기능을 필요만큼만 추가 가능&lt;/td&gt;
      &lt;td&gt;정책/성능/UX에서 “웹뷰 앱” 티가 나면 심사 리스크&lt;/td&gt;
      &lt;td&gt;푸시/딥링크/로그인만 얹고 빠르게 출시&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Capacitor&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;웹 중심(Next.js) + 플러그인, 설정이 단순한 편&lt;/td&gt;
      &lt;td&gt;iOS/Android 각 빌드 파이프라인 세팅 필요&lt;/td&gt;
      &lt;td&gt;“웹앱을 앱으로”에 집중, RN 생태계는 불필요&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Android TWA&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Chrome 엔진, PWA 강점(오프라인/푸시), 웹과 동일&lt;/td&gt;
      &lt;td&gt;iOS 불가(대체가 없음)&lt;/td&gt;
      &lt;td&gt;Android만 빠르게 스토어 유통&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;완전 RN/네이티브&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;최고 UX/성능, 스토어 친화적&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;strong&gt;React Native + WebView&lt;/strong&gt;를 다루며, &lt;strong&gt;Capacitor/TWA&lt;/strong&gt; 대비 장단점과 “스토어 출시 관점”에서 막히는 포인트를 중심으로 설명합니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;1-아키텍처-결정-webview는-원격-url-로딩이-정답대부분&quot;&gt;1. 아키텍처 결정: WebView는 “원격 URL” 로딩이 정답(대부분)&lt;/h2&gt;

&lt;p&gt;Next.js 웹서비스가 이미 운영 중이라면, 앱은 웹을 &lt;strong&gt;원격 URL&lt;/strong&gt;로 로드하는 방식이 가장 현실적입니다.&lt;/p&gt;

&lt;h3 id=&quot;11-원격-url-방식-대부분의-서비스에서-추천&quot;&gt;1.1 원격 URL 방식 (대부분의 서비스에서 추천)&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;앱은 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://app.example.com&lt;/code&gt;을 WebView로 로딩&lt;/li&gt;
  &lt;li&gt;웹 배포 = 앱 UI 즉시 반영(스토어 심사 없이 수정 가능)&lt;/li&gt;
  &lt;li&gt;Next.js의 SSR/Server Actions/API Route 등 &lt;strong&gt;서버 기능 그대로 유지&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;12-번들정적-방식은-앱-오프라인이-핵심일-때만&quot;&gt;1.2 번들(정적) 방식은 “앱 오프라인”이 핵심일 때만&lt;/h3&gt;

&lt;p&gt;정적 번들(Next.js export)을 앱에 넣는 방식은, “오프라인에서도 화면이 떠야 한다” 같은 목적이 있어야만 고려할 만합니다. 대부분의 SaaS/콘텐츠 서비스는 원격 URL이 더 단순하고 운영 비용이 낮습니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;2-프로젝트-시작-expo로-빠르게-webview-앱-뼈대-만들기&quot;&gt;2. 프로젝트 시작: Expo로 빠르게 WebView 앱 뼈대 만들기&lt;/h2&gt;

&lt;p&gt;React Native는 크게 &lt;strong&gt;Expo(관리형)&lt;/strong&gt; vs &lt;strong&gt;Bare(네이티브 직접)&lt;/strong&gt; 두 길이 있습니다. WebView 래핑 앱은 Expo가 시작점으로 매우 좋습니다.&lt;/p&gt;

&lt;h3 id=&quot;21-expo로-프로젝트-생성&quot;&gt;2.1 Expo로 프로젝트 생성&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;npx create-expo-app myapp
&lt;span class=&quot;nb&quot;&gt;cd &lt;/span&gt;myapp
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;22-필수-라이브러리-설치&quot;&gt;2.2 필수 라이브러리 설치&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;npm i react-native-webview
npm i @react-navigation/native @react-navigation/native-stack
npm i react-native-safe-area-context react-native-screens
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Expo 환경에서 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;react-native-screens&lt;/code&gt; 등이 요구하는 설정은 템플릿이 대부분 해결해줍니다.&lt;/p&gt;

&lt;h3 id=&quot;221-권장-eas-빌드-준비-실제-스토어-배포용-파이프라인&quot;&gt;2.2.1 (권장) EAS 빌드 준비: 실제 스토어 배포용 파이프라인&lt;/h3&gt;

&lt;p&gt;개발 단계는 Expo Go로도 되지만, &lt;strong&gt;푸시/딥링크/권한&lt;/strong&gt;이 들어가는 순간부터는 &lt;strong&gt;Dev Build(EAS)&lt;/strong&gt; 로 가는 편이 깔끔합니다.&lt;/p&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;npm i &lt;span class=&quot;nt&quot;&gt;-g&lt;/span&gt; eas-cli
eas login
eas build:configure
&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;app.json&lt;/code&gt;(또는 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;app.config.ts&lt;/code&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;expo&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;name&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;slug&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;myapp&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;scheme&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;myapp&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;ios&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;bundleIdentifier&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;com.example.myapp&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;span class=&quot;nl&quot;&gt;&quot;android&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;package&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;com.example.myapp&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;span class=&quot;p&quot;&gt;}&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;blockquote&gt;
  &lt;p&gt;딥링크를 제대로 하려면 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scheme&lt;/code&gt;은 초기에 확정하는 편이 안전합니다(나중에 바꾸면 링크/마케팅 자산/설정이 다 깨집니다).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;23-가장-단순한-webview-화면부터&quot;&gt;2.3 가장 단순한 WebView 화면부터&lt;/h3&gt;

&lt;div class=&quot;language-tsx 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;c1&quot;&gt;// app/App.tsx (또는 expo-router를 쓰면 구조가 다를 수 있음)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;React&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;react&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;SafeAreaView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Platform&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;react-native&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;WebView&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;react-native-webview&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;APP_URL&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;https://app.example.com&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;default&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;App&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;nc&quot;&gt;SafeAreaView&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;style&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;flex&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;gt;&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;nc&quot;&gt;WebView&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;source&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;uri&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;APP_URL&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;originWhitelist&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;javaScriptEnabled&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;domStorageEnabled&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;setSupportMultipleWindows&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;kc&quot;&gt;false&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;allowsBackForwardNavigationGestures&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;Platform&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;OS&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;ios&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;startInLoadingState&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;&amp;lt;/&lt;/span&gt;&lt;span class=&quot;nc&quot;&gt;SafeAreaView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;여기까지는 “진짜로” 30분이면 됩니다. 문제는 이제부터입니다. 실제 앱이 되려면 아래 기능들이 필요합니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;3-webview-앱이-반드시-갖춰야-하는-10가지실전-체크리스트&quot;&gt;3. WebView 앱이 반드시 갖춰야 하는 10가지(실전 체크리스트)&lt;/h2&gt;

&lt;p&gt;WebView 래핑은 “그냥 띄우면 끝”이 아닙니다. 서비스마다 편차는 있지만, 운영 가능한 앱이 되려면 보통 아래가 필요합니다.&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;strong&gt;네비게이션(뒤로가기/딥링크)&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;앱의 기본 UX&lt;/td&gt;
      &lt;td&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;결제/로그인/OAuth&lt;/td&gt;
      &lt;td&gt;WebView에서 깨지거나 심사 리젝&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;/td&gt;
      &lt;td&gt;로그인이 매번 풀림&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;브릿지(postMessage)&lt;/strong&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;strong&gt;푸시 알림 + 딥링크&lt;/strong&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;strong&gt;파일 업로드/다운로드&lt;/strong&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;strong&gt;에러/오프라인 화면&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;네트워크 품질 대응&lt;/td&gt;
      &lt;td&gt;빈 흰 화면(white screen)&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;/td&gt;
      &lt;td&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;/td&gt;
      &lt;td&gt;세션 탈취/URL 스푸핑&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;/td&gt;
      &lt;td&gt;“웹뷰만 있는 앱” 리젝&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;이제 각 항목을 구현합니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;4-내비게이션-뒤로가기새-창외부-링크-처리&quot;&gt;4. 내비게이션: 뒤로가기/새 창/외부 링크 처리&lt;/h2&gt;

&lt;h3 id=&quot;41-android-하드웨어-뒤로가기&quot;&gt;4.1 Android 하드웨어 뒤로가기&lt;/h3&gt;

&lt;p&gt;WebView는 기본적으로 히스토리를 갖지만, RN에서 하드웨어 back을 연결해야 합니다.&lt;/p&gt;

&lt;div class=&quot;language-tsx 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;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;React&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;useEffect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;useRef&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;useState&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;react&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;BackHandler&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;SafeAreaView&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;react-native&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;WebView&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;react-native-webview&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;APP_URL&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;https://app.example.com&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;default&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;App&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;webviewRef&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;useRef&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;WebView&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kc&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;canGoBack&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;setCanGoBack&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;useState&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kc&quot;&gt;false&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;useEffect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;sub&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;BackHandler&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;addEventListener&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;hardwareBackPress&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;canGoBack&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;nx&quot;&gt;webviewRef&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;current&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;goBack&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
      &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;false&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;sub&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;remove&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;canGoBack&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]);&lt;/span&gt;

  &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;nc&quot;&gt;SafeAreaView&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;style&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;flex&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;gt;&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;nc&quot;&gt;WebView&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;ref&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;webviewRef&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;source&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;uri&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;APP_URL&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;onNavigationStateChange&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;nav&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;setCanGoBack&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;nav&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;canGoBack&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;&amp;lt;/&lt;/span&gt;&lt;span class=&quot;nc&quot;&gt;SafeAreaView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;42-외부-링크는-시스템-브라우저로-보내기기본-원칙&quot;&gt;4.2 외부 링크는 시스템 브라우저로 보내기(기본 원칙)&lt;/h3&gt;

&lt;p&gt;결제, 약관, OAuth 등은 WebView 안에서 깨지거나 정책 리스크가 생길 수 있어, &lt;strong&gt;특정 도메인/경로는 외부 브라우저로&lt;/strong&gt; 여는 편이 안전합니다.&lt;/p&gt;

&lt;div class=&quot;language-tsx 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;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Linking&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;expo-linking&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;shouldOpenExternally&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kr&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;u&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;URL&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;u&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;hostname&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;endsWith&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;example.com&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;false&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;catch&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;false&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;그리고 WebView의 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;onShouldStartLoadWithRequest&lt;/code&gt;에서 차단하고 외부로 보냅니다.&lt;/p&gt;

&lt;div class=&quot;language-tsx 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;&amp;lt;&lt;/span&gt;&lt;span class=&quot;nc&quot;&gt;WebView&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;source&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;uri&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;APP_URL&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;onShouldStartLoadWithRequest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;shouldOpenExternally&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;Linking&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;openURL&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
      &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;false&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;/&amp;gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;blockquote&gt;
  &lt;p&gt;결제/구독이 걸린 앱은 특히 정책이 민감합니다. iOS/Android 배포 글의 “결제/디지털 상품 정책 위반” 섹션은 꼭 참고하세요.&lt;/p&gt;
  &lt;ul&gt;
    &lt;li&gt;&lt;a href=&quot;/2026/05/27/ios-app-deploy-for-django-nextjs/&quot;&gt;iOS 배포 가이드: 정책/심사 포인트&lt;/a&gt;&lt;/li&gt;
  &lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;43-target_blank--windowopen--tel-같은-스킴-처리&quot;&gt;4.3 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;target=_blank&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;window.open&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tel:&lt;/code&gt; 같은 스킴 처리&lt;/h3&gt;

&lt;p&gt;실서비스에서는 아래 같은 링크가 섞입니다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;target=&quot;_blank&quot;&lt;/code&gt; 링크(결제/약관/외부 도움말)&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mailto:support@...&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tel:+82...&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;intent://&lt;/code&gt;(Android) / 커스텀 스킴&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;원칙은 동일합니다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;내 도메인(앱 본문)&lt;/strong&gt;: WebView에서 열기&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;외부/특수 스킴&lt;/strong&gt;: 시스템 핸들러로 열기&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;shouldOpenExternally&lt;/code&gt;에 스킴 기준도 추가해 두면 운영 중 사고가 줄어듭니다.&lt;/p&gt;

&lt;div class=&quot;language-ts 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;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;isSpecialScheme&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kr&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;startsWith&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;mailto:&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;||&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;startsWith&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;tel:&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;||&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;startsWith&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;sms:&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&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;onShouldStartLoadWithRequest&lt;/code&gt;에서 우선 처리합니다.&lt;/p&gt;

&lt;div class=&quot;language-tsx 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;nx&quot;&gt;onShouldStartLoadWithRequest&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;isSpecialScheme&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;Linking&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;openURL&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;false&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;shouldOpenExternally&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;Linking&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;openURL&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;req&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;false&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;5-webview-브릿지-웹nextjs--앱react-native-연결하기&quot;&gt;5. WebView 브릿지: 웹(Next.js) ↔ 앱(React Native) 연결하기&lt;/h2&gt;

&lt;p&gt;앱이 “앱다워지려면” WebView 내부의 웹앱이 &lt;strong&gt;네이티브 정보를 받아야&lt;/strong&gt; 합니다. 대표적으로:&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;/ul&gt;

&lt;h3 id=&quot;51-rn--web-postmessage-보내기&quot;&gt;5.1 RN → Web: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;postMessage&lt;/code&gt; 보내기&lt;/h3&gt;

&lt;div class=&quot;language-tsx 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;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;injectedJS&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;`
  window.__APP__ = { platform: &quot;rn-webview&quot; };
  true;
`&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;p&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;nc&quot;&gt;WebView&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;injectedJavaScript&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;injectedJS&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;onLoadEnd&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;payload&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;JSON&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;stringify&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;APP_READY&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;platform&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;ios_or_android&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;appVersion&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;1.0.0&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;webviewRef&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;current&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;postMessage&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;/&amp;gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;blockquote&gt;
  &lt;p&gt;구현 포인트: “첫 로드 이후”에 보내야 웹이 수신 준비가 되어 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;52-web--rn-웹에서-windowreactnativewebviewpostmessage&quot;&gt;5.2 Web → RN: 웹에서 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;window.ReactNativeWebView.postMessage&lt;/code&gt;&lt;/h3&gt;

&lt;p&gt;Next.js 쪽에서 아래처럼 보내면 RN의 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;onMessage&lt;/code&gt;로 들어옵니다.&lt;/p&gt;

&lt;div class=&quot;language-ts 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;c1&quot;&gt;// Next.js (클라이언트)&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;postToApp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kr&quot;&gt;any&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;payload&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;JSON&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;stringify&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
  &lt;span class=&quot;c1&quot;&gt;// @ts-expect-error: injected by RN WebView&lt;/span&gt;
  &lt;span class=&quot;nb&quot;&gt;window&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;ReactNativeWebView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;postMessage&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;nx&quot;&gt;postToApp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;OPEN_NATIVE_SETTINGS&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;RN에서 수신:&lt;/p&gt;

&lt;div class=&quot;language-tsx 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;&amp;lt;&lt;/span&gt;&lt;span class=&quot;nc&quot;&gt;WebView&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;onMessage&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;msg&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;JSON&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;parse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;nativeEvent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
      &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;msg&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kd&quot;&gt;type&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;OPEN_NATIVE_SETTINGS&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;c1&quot;&gt;// 필요 시 네이티브 화면으로 이동&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;catch&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;c1&quot;&gt;// ignore&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;/&amp;gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;53-브릿지-프로토콜을-문서화하라&quot;&gt;5.3 “브릿지 프로토콜”을 문서화하라&lt;/h3&gt;

&lt;p&gt;운영이 시작되면 웹과 앱의 릴리즈 속도가 다릅니다. 그래서 브릿지 메시지는 아래 원칙으로 고정해두는 게 중요합니다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;메시지는 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;{ type: string, payload?: object, version?: number }&lt;/code&gt; 형태 고정&lt;/li&gt;
  &lt;li&gt;앱이 못 알아듣는 메시지는 &lt;strong&gt;무시&lt;/strong&gt;(크래시 금지)&lt;/li&gt;
  &lt;li&gt;웹이 앱을 못 찾으면(브라우저 접속) &lt;strong&gt;대체 UX 제공&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;6-로그인세션-쿠키가-풀리는-앱이-가장-흔한-실패&quot;&gt;6. 로그인/세션: “쿠키가 풀리는 앱”이 가장 흔한 실패&lt;/h2&gt;

&lt;p&gt;WebView 앱에서 자주 터지는 이슈는 다음입니다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;로그인 후 새로고침하면 풀림&lt;/li&gt;
  &lt;li&gt;결제/OAuth 후 돌아오면 세션이 끊김&lt;/li&gt;
  &lt;li&gt;iOS에서 특히 쿠키 정책이 까다로움&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;61-원칙-인증은-가능하면-웹이-담당하고-앱은-래핑만&quot;&gt;6.1 원칙: 인증은 가능하면 “웹”이 담당하고 앱은 래핑만&lt;/h3&gt;

&lt;p&gt;가장 단순한 전략은:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;로그인은 웹에서 진행&lt;/li&gt;
  &lt;li&gt;앱은 쿠키/세션을 WebView에 유지&lt;/li&gt;
  &lt;li&gt;앱이 별도 토큰을 저장해서 API를 직접 치지 않는다(초기 단계)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;611-webview-쿠키-옵션특히-ios&quot;&gt;6.1.1 WebView 쿠키 옵션(특히 iOS)&lt;/h3&gt;

&lt;p&gt;서비스에 따라 아래 옵션이 필요할 수 있습니다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sharedCookiesEnabled&lt;/code&gt;&lt;/strong&gt;: 시스템 쿠키 저장소 공유(iOS에서 도움이 되는 경우가 있음)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;thirdPartyCookiesEnabled&lt;/code&gt;&lt;/strong&gt;: (Android) 서드파티 쿠키가 필요한 구조라면 고려(가능하면 회피)&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;language-tsx 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;&amp;lt;&lt;/span&gt;&lt;span class=&quot;nc&quot;&gt;WebView&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;source&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;uri&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;APP_URL&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;sharedCookiesEnabled&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;thirdPartyCookiesEnabled&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;kc&quot;&gt;false&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;blockquote&gt;
  &lt;p&gt;최선은 “서드파티 쿠키가 필요 없는 도메인 설계”입니다. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;app.example.com&lt;/code&gt;과 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;api.example.com&lt;/code&gt;처럼 분리하더라도, 인증은 가능하면 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;app.example.com&lt;/code&gt;의 1st-party 컨텍스트에서 끝내세요.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;612-nextjs-인증-쿠키-설정실전-체크&quot;&gt;6.1.2 Next.js 인증 쿠키 설정(실전 체크)&lt;/h3&gt;

&lt;p&gt;앱(WebView)에서 로그인 유지가 불안정하다면, 웹 쪽에서 아래를 우선 점검합니다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;HTTPS + &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Secure&lt;/code&gt;&lt;/strong&gt; 쿠키&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SameSite&lt;/code&gt; 정책&lt;/strong&gt; (OAuth/리디렉션이 걸리면 영향)&lt;/li&gt;
  &lt;li&gt;여러 서브도메인에서 공유해야 하면 &lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Domain=.example.com&lt;/code&gt;&lt;/strong&gt; 을 명확히(필요할 때만)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;프레임/임베드 구조를 쓰면 정책이 급격히 어려워지므로, WebView 앱에서는 가능한 한 &lt;strong&gt;정상 top-level 네비게이션&lt;/strong&gt;으로 인증 흐름을 설계하는 편이 안전합니다.&lt;/p&gt;

&lt;h3 id=&quot;62-리디렉션콜백-url을-정리하라&quot;&gt;6.2 리디렉션/콜백 URL을 정리하라&lt;/h3&gt;

&lt;p&gt;OAuth/결제 콜백은 “앱 스킴으로 돌아오기”를 꿈꾸기 전에, 먼저 &lt;strong&gt;웹에서 완결&lt;/strong&gt;되게 하는 것이 운영적으로 안정적입니다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;좋은 패턴: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://app.example.com/auth/callback&lt;/code&gt;에서 모든 처리를 끝냄&lt;/li&gt;
  &lt;li&gt;그 다음에 “앱 내부로 돌아오기”는 딥링크로 개선&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;7-푸시-알림--딥링크-webview-앱의-핵심-가치&quot;&gt;7. 푸시 알림 + 딥링크: WebView 앱의 핵심 가치&lt;/h2&gt;

&lt;p&gt;WebView 앱이 웹과 다르게 “설치할 이유”를 만들어주는 가장 강력한 기능은 보통 &lt;strong&gt;푸시 알림&lt;/strong&gt;입니다.&lt;/p&gt;

&lt;p&gt;여기서는 “구현 가능한 수준”으로 흐름을 잡습니다. (서버 발송은 사용하는 푸시 공급자/백엔드에 따라 달라집니다.)&lt;/p&gt;

&lt;h3 id=&quot;71-흐름개념&quot;&gt;7.1 흐름(개념)&lt;/h3&gt;

&lt;p&gt;1) 앱이 푸시 토큰을 획득&lt;br /&gt;
2) WebView 브릿지로 토큰을 웹에 전달&lt;br /&gt;
3) 웹이 서버 API로 토큰을 저장&lt;br /&gt;
4) 서버가 이벤트 발생 시 푸시 발송&lt;br /&gt;
5) 사용자가 알림을 탭하면 특정 URL로 딥링크 → WebView가 해당 경로 로드&lt;/p&gt;

&lt;h3 id=&quot;711-expo-푸시-토큰-획득앱&quot;&gt;7.1.1 Expo 푸시 토큰 획득(앱)&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;npm i expo-notifications expo-device
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-ts 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;c1&quot;&gt;// push.ts (개념 예시)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Device&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;expo-device&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Notifications&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;expo-notifications&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;getPushToken&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;():&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Promise&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kr&quot;&gt;string&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;Device&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;isDevice&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;// 시뮬레이터는 제한이 있음&lt;/span&gt;

  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;status&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;existing&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Notifications&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;getPermissionsAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;final&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;existing&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;granted&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;?&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;existing&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Notifications&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;requestPermissionsAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()).&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;status&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

  &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;final&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!==&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;granted&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;token&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Notifications&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;getExpoPushTokenAsync&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;앱에서 토큰을 얻으면 WebView 브릿지로 웹에 넘깁니다.&lt;/p&gt;

&lt;div class=&quot;language-tsx 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;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;token&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;getPushToken&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;webviewRef&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;current&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;postMessage&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;JSON&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;stringify&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;PUSH_TOKEN&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;provider&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;expo&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;token&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;웹(Next.js)은 이 메시지를 받아 &lt;strong&gt;서버 API&lt;/strong&gt;로 저장합니다.&lt;/p&gt;

&lt;h3 id=&quot;72-딥링크-url을-제품의-url로-통일&quot;&gt;7.2 “딥링크 URL”을 제품의 URL로 통일&lt;/h3&gt;

&lt;p&gt;딥링크를 설계할 때, 내부 화면을 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;myapp://&lt;/code&gt; 같은 전용 스킴으로만 만들면 웹과 분기돼서 운영 비용이 급증합니다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;추천: 딥링크는 결국 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://app.example.com/notifications/123&lt;/code&gt; 같은 &lt;strong&gt;웹 URL로 귀결&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;앱은 “그 URL을 열어준다”만 책임&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;721-딥링크를-webview로-전달하는-구현-패턴&quot;&gt;7.2.1 딥링크를 WebView로 전달하는 구현 패턴&lt;/h3&gt;

&lt;p&gt;딥링크/알림 탭으로 들어온 URL을 앱이 받으면, 결국 WebView를 그 경로로 보내야 합니다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;이미 WebView가 떠 있으면: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;webviewRef.current?.injectJavaScript(...)&lt;/code&gt; 또는 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;postMessage&lt;/code&gt;로 “이 URL로 이동” 이벤트 전달&lt;/li&gt;
  &lt;li&gt;아직 WebView가 안 떠 있으면: 앱 초기 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;initialUrl&lt;/code&gt;로 보관했다가 첫 로드 후 전달&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;개념 예시:&lt;/p&gt;

&lt;div class=&quot;language-ts 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;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Linking&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;expo-linking&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;getInitialIncomingUrl&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;():&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Promise&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kr&quot;&gt;string&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;url&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Linking&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;getInitialURL&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;그리고 들어온 URL을 서비스 URL로 매핑합니다.&lt;/p&gt;

&lt;div class=&quot;language-ts 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;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;mapIncomingToAppUrl&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;incoming&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kr&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;c1&quot;&gt;// 예: myapp://open?path=/notifications/123&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;parsed&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Linking&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;parse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;incoming&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;path&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;typeof&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;parsed&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;queryParams&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;path&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;?&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;parsed&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;queryParams&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;path&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;/&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;`https://app.example.com&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;${&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;`&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;blockquote&gt;
  &lt;p&gt;“앱 안의 화면 ID”로 이동시키기보다, &lt;strong&gt;웹 URL(path)&lt;/strong&gt; 로 귀결시키면 운영이 쉬워집니다. 웹과 앱이 같은 라우팅을 공유할 수 있기 때문입니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;8-파일-업로드다운로드-심사-직전에-터지는-지뢰&quot;&gt;8. 파일 업로드/다운로드: 심사 직전에 터지는 지뢰&lt;/h2&gt;

&lt;p&gt;WebView 래핑에서 사용자들이 제일 먼저 발견하는 버그가 “업로드 버튼이 안 눌린다” 입니다.&lt;/p&gt;

&lt;h3 id=&quot;81-업로드는-서비스-요구사항을-미리-정의&quot;&gt;8.1 업로드는 서비스 요구사항을 미리 정의&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;프로필 사진 업로드가 필요한가?&lt;/li&gt;
  &lt;li&gt;첨부파일이 필요한가?&lt;/li&gt;
  &lt;li&gt;카메라 캡처가 필요한가?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;필요한 경우, WebView가 파일 선택/카메라 권한을 제대로 처리하는지 iOS/Android 각각 확인해야 합니다.&lt;/p&gt;

&lt;h3 id=&quot;82-다운로드는-외부-브라우저로-보내기가-가장-안전&quot;&gt;8.2 다운로드는 “외부 브라우저로 보내기”가 가장 안전&lt;/h3&gt;

&lt;p&gt;PDF/CSV 다운로드를 WebView 내부에서 처리하려 하면 플랫폼별 이슈가 많습니다. 초기에는 다운로드 링크를 외부로 보내는 것이 안정적입니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;9-성능ux-웹뷰-앱-같다는-느낌을-줄이는-방법&quot;&gt;9. 성능/UX: “웹뷰 앱 같다”는 느낌을 줄이는 방법&lt;/h2&gt;

&lt;p&gt;스토어 심사에서 “웹뷰만 감싼 앱”은 리젝 사유가 될 수 있습니다. 실제로는 WebView를 쓰더라도, 아래를 챙기면 앱 퀄리티가 확 달라집니다.&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;/li&gt;
  &lt;li&gt;상단 Safe Area/노치 처리&lt;/li&gt;
  &lt;li&gt;앱 아이콘·런치 스크린·권한 설명 문구&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;91-화이트-스크린-방지최소-ux&quot;&gt;9.1 화이트 스크린 방지(최소 UX)&lt;/h3&gt;

&lt;p&gt;최소한 아래는 기본으로 넣는 편이 안전합니다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;startInLoadingState&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;타임아웃 기반의 “네트워크 문제” 안내&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;onError&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;onHttpError&lt;/code&gt; 로 사용자 메시지 출력&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;이걸 빼면, 장애 상황에서 사용자는 그냥 “하얀 화면”만 보고 앱을 지웁니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;10-스토어-출시-체크리스트-webview-앱-전용&quot;&gt;10. 스토어 출시 체크리스트 (WebView 앱 전용)&lt;/h2&gt;

&lt;h3 id=&quot;101-iosapp-store에서-자주-보는-리젝-포인트&quot;&gt;10.1 iOS(App Store)에서 자주 보는 리젝 포인트&lt;/h3&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;개인정보 수집 고지(Privacy Policy) 누락&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;관련 포인트는 iOS 배포 글의 “심사 체크리스트”가 큰 도움이 됩니다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/05/27/ios-app-deploy-for-django-nextjs/&quot;&gt;Django + Next.js 웹앱을 iOS 앱으로 배포하기&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;102-androidgoogle-play에서-자주-보는-리젝경고-포인트&quot;&gt;10.2 Android(Google Play)에서 자주 보는 리젝/경고 포인트&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;WebView 기반인데 앱 설명이 과장됨&lt;/li&gt;
  &lt;li&gt;권한을 과도하게 요청(필요 최소로)&lt;/li&gt;
  &lt;li&gt;개인정보/데이터 안전 섹션 미기재&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Android 배포 글의 스토어 제출 파트도 함께 확인하세요.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/05/26/django-nextjs-android-app-deploy-guide/&quot;&gt;Django + Next.js 웹앱을 안드로이드 앱으로 배포하기&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;11-배포업데이트-전략-앱은-느리고-웹은-빠르다&quot;&gt;11. 배포/업데이트 전략: 앱은 느리고 웹은 빠르다&lt;/h2&gt;

&lt;p&gt;WebView 앱의 장점은 “웹을 배포하면 앱 화면이 바로 바뀐다”입니다. 그래서 앱 배포는 아래에 집중하는 것이 효율적입니다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;WebView 컨테이너 안정성&lt;/li&gt;
  &lt;li&gt;푸시/딥링크/권한/브릿지&lt;/li&gt;
  &lt;li&gt;크래시/로그 수집&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;UI/콘텐츠/대부분의 기능은 웹에서 빠르게 개선합니다.&lt;/p&gt;

&lt;h3 id=&quot;111-eas로-스토어-빌드-만들기실전-흐름&quot;&gt;11.1 EAS로 스토어 빌드 만들기(실전 흐름)&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;eas build &lt;span class=&quot;nt&quot;&gt;-p&lt;/span&gt; android
eas build &lt;span class=&quot;nt&quot;&gt;-p&lt;/span&gt; ios
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;권장 운영 패턴:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;WebView 컨테이너가 바뀌는 경우만 앱 업데이트&lt;/li&gt;
  &lt;li&gt;기능/화면/카피는 웹 배포로 빠르게 개선&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;12-자주-터지는-문제운영-트러블슈팅&quot;&gt;12. 자주 터지는 문제(운영 트러블슈팅)&lt;/h2&gt;

&lt;h3 id=&quot;121-로그인이-자꾸-풀려요&quot;&gt;12.1 “로그인이 자꾸 풀려요”&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;웹 쿠키 속성(&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Secure&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SameSite&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Domain&lt;/code&gt;) 점검&lt;/li&gt;
  &lt;li&gt;리디렉션/OAuth가 “외부 브라우저”로 빠졌다가 돌아오는지 점검&lt;/li&gt;
  &lt;li&gt;WebView 쿠키 옵션(&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sharedCookiesEnabled&lt;/code&gt;) 적용 여부 점검&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;122-결제-페이지가-깨져요&quot;&gt;12.2 “결제 페이지가 깨져요”&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;결제/인증 플로우는 외부 브라우저로 분리하는 전략을 우선 고려&lt;/li&gt;
  &lt;li&gt;스토어 정책(특히 iOS)에서 우회로 보이지 않도록, 앱 메타데이터/문구 정리&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;123-푸시-토큰은-받는데-눌러도-원하는-화면으로-안-가요&quot;&gt;12.3 “푸시 토큰은 받는데, 눌러도 원하는 화면으로 안 가요”&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;딥링크 → URL 매핑 규칙을 한 군데에서 관리&lt;/li&gt;
  &lt;li&gt;알림 payload에는 &lt;strong&gt;웹 URL(path)&lt;/strong&gt; 을 넣고 앱은 그대로 로드&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;13-정리--webview-앱은-앱의-최소-기능--웹의-속도-조합&quot;&gt;13. 정리 — WebView 앱은 “앱의 최소 기능 + 웹의 속도” 조합&lt;/h2&gt;

&lt;p&gt;Next.js 웹서비스를 React Native(WebView)로 전환할 때 성공 확률을 올리는 핵심은 단순합니다.&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;원격 URL 로딩&lt;/strong&gt;으로 운영을 단순화하고&lt;/li&gt;
  &lt;li&gt;앱은 &lt;strong&gt;푸시·딥링크·외부 링크 처리·브릿지&lt;/strong&gt;만 정확히 완성하며&lt;/li&gt;
  &lt;li&gt;심사에서 문제가 되는 포인트(결제/정책/가치 설명)를 문서와 메타데이터로 선제 대응한다&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;WebView는 “어중간한 타협”이 아니라, 올바르게 설계하면 &lt;strong&gt;1인/소팀이 글로벌 스토어 유통까지 뚫는 가장 빠른 경로&lt;/strong&gt;가 될 수 있습니다.&lt;/p&gt;</content><author><name>updaun</name></author><category term="react-native" /><category term="mobile" /><category term="nextjs" /><category term="react-native" /><category term="expo" /><category term="webview" /><category term="ios" /><category term="android" /><category term="deep-link" /><category term="push" /><category term="app-store" /><category term="google-play" /><category term="capacitor" /><category term="twa" /><summary type="html">웹을 ‘그대로’ 앱으로 내는 건 가능하지만, 그대로 하면 거의 반드시 막힙니다. 로그인 쿠키, 딥링크, 푸시, 파일 업로드, 외부 결제/정책, 업데이트 방식까지 — WebView 앱이 실제로 굴러가려면 네이티브 껍데기에 필요한 최소 기능을 정확히 채워야 합니다.</summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-06-02-nextjs-to-react-native-webview-app-complete-guide.webp" /><media:content medium="image" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-06-02-nextjs-to-react-native-webview-app-complete-guide.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">AI로 1인 구독 서비스 만들기 — 전 세계를 고객으로 삼는 실전 전략 2026</title><link href="https://updaun.github.io/ai/indie-hacker/saas/2026/06/01/ai-solo-founder-subscription-global-strategy/" rel="alternate" type="text/html" title="AI로 1인 구독 서비스 만들기 — 전 세계를 고객으로 삼는 실전 전략 2026" /><published>2026-06-01T00:00:00+09:00</published><updated>2026-06-01T00:00:00+09:00</updated><id>https://updaun.github.io/ai/indie-hacker/saas/2026/06/01/ai-solo-founder-subscription-global-strategy</id><content type="html" xml:base="https://updaun.github.io/ai/indie-hacker/saas/2026/06/01/ai-solo-founder-subscription-global-strategy/">&lt;h1 id=&quot;ai로-1인-구독-서비스-만들기--전-세계를-고객으로-삼는-실전-전략-2026&quot;&gt;AI로 1인 구독 서비스 만들기 — 전 세계를 고객으로 삼는 실전 전략 2026&lt;/h1&gt;

&lt;p&gt;스타트업 팀이 없어도, 2026년에는 &lt;strong&gt;한 명이 구독형 서비스를 전 세계에 팔 수 있는&lt;/strong&gt; 조건이 갖춰졌습니다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;제품&lt;/strong&gt;: Cursor·Claude Code로 백엔드·프론트·인프라를 주 단위로 올린다.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;운영&lt;/strong&gt;: &lt;a href=&quot;/2026/05/30/ai-report-employee-daily-briefing-automation/&quot;&gt;AI Report 직원&lt;/a&gt;·&lt;a href=&quot;/2026/04/11/ai-agent-as-employee-hiring-guide/&quot;&gt;AI 직원&lt;/a&gt;으로 모니터링·콘텐츠·1차 지원을 자동화한다.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;수익&lt;/strong&gt;: 월 구독(MRR)으로 &lt;strong&gt;시간을 팔지 않고&lt;/strong&gt; 소프트웨어를 판다.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;이 글은 “아이디어 나열”이 아니라, &lt;strong&gt;1인 + AI + 구독 + 글로벌&lt;/strong&gt; 네 가지를 동시에 만족할 때 실제로 필요한 &lt;strong&gt;제품 선택, 기술 스택, 가격·결제, 신뢰·법무, 성장 루프&lt;/strong&gt;를 한 장의 지도로 정리합니다.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;개발 속도를 올리는 도구 습관은 &lt;a href=&quot;/2026/05/29/cursor-developer-tips-community-collection/&quot;&gt;Cursor 개발자 꿀팁&lt;/a&gt;과 &lt;a href=&quot;/2026/05/28/esc-equalizer-socratic-chain-prompting/&quot;&gt;ESC Prompting&lt;/a&gt;을, 백엔드·스케줄은 &lt;a href=&quot;/2026/03/24/django-ninja-celery-beat-complete-guide/&quot;&gt;Django Ninja + Celery Beat&lt;/a&gt;를 함께 보면 좋습니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;0-결론부터-1인-글로벌-구독-서비스의-4축&quot;&gt;0. 결론부터: 1인 글로벌 구독 서비스의 4축&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;strong&gt;Problem–ICP&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;“누가, 매달, 왜 돈을 내는가”가 명확&lt;/td&gt;
      &lt;td&gt;트래픽은 있는데 전환 0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Product&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;온보딩 5분 안에 “아하” 순간&lt;/td&gt;
      &lt;td&gt;데모만 길고 첫 가치가 늦음&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Global Stack&lt;/strong&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;strong&gt;AI Leverage&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;사람 시간 대신 &lt;strong&gt;마진&lt;/strong&gt;을 키움&lt;/td&gt;
      &lt;td&gt;AI 비용만 늘고 MRR 정체&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;strong&gt;가장 흔한 실수&lt;/strong&gt;는 “전 세계 대상”이라고 쓰면서 &lt;strong&gt;한국어 UI + 원화만 + 카카오 로그인만&lt;/strong&gt; 열어두는 것입니다. 글로벌은 마케팅 문구가 아니라 &lt;strong&gt;결제·지원·약관·가격 단위&lt;/strong&gt; 설계입니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;1-왜-지금-1인--ai--구독인가&quot;&gt;1. 왜 지금 1인 + AI + 구독인가&lt;/h2&gt;

&lt;h3 id=&quot;11-비용-구조가-바뀌었다&quot;&gt;1.1 비용 구조가 바뀌었다&lt;/h3&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;항목&lt;/th&gt;
      &lt;th&gt;2020년대 초&lt;/th&gt;
      &lt;th&gt;2026년 1인 팀&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;MVP 개발&lt;/td&gt;
      &lt;td&gt;외주·공동창업 필수&lt;/td&gt;
      &lt;td&gt;AI 코딩 + 템플릿으로 &lt;strong&gt;2~4주&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;콘텐츠·SEO&lt;/td&gt;
      &lt;td&gt;에이전시·작가&lt;/td&gt;
      &lt;td&gt;Agent + &lt;a href=&quot;/2026/05/30/ai-report-employee-daily-briefing-automation/&quot;&gt;Report 직원&lt;/a&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;1차 지원&lt;/td&gt;
      &lt;td&gt;사람 상주&lt;/td&gt;
      &lt;td&gt;FAQ 봇 + 이메일 초안 자동화&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;인프라&lt;/td&gt;
      &lt;td&gt;자체 서버&lt;/td&gt;
      &lt;td&gt;Vercel·Fly·Railway + 관리형 DB&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;고정비는 &lt;strong&gt;도메인 + 호스팅 + 결제 수수료 + AI/API 사용량&lt;/strong&gt;으로 압축됩니다. 변동비는 &lt;strong&gt;활성 사용자당 LLM·검색·스토리지&lt;/strong&gt;이므로, &lt;strong&gt;구독 단가 &amp;gt; 한 유저당 변동비&lt;/strong&gt;가 되도록 설계해야 합니다.&lt;/p&gt;

&lt;h3 id=&quot;12-구독이-1인에게-맞는-이유&quot;&gt;1.2 구독이 1인에게 맞는 이유&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;예측 가능한 현금흐름&lt;/strong&gt; — MRR이 있으면 다음 기능 우선순위를 정할 수 있다.&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;strong&gt;동일 바이너리&lt;/strong&gt;를 판다.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;AI 비용 상쇄&lt;/strong&gt; — Pro 티어에서 API 한도·우선 처리를 번들링하면 마진을 방어한다.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;구독이 맞지 않는 경우: &lt;strong&gt;일회성 대량 작업&lt;/strong&gt;(번역 100만 자), &lt;strong&gt;규제가 강한 B2B&lt;/strong&gt;(장기 계약·보안 심사), &lt;strong&gt;오프라인 필수&lt;/strong&gt; 서비스.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;2-ai-1인에게-잘-맞는-제품-유형&quot;&gt;2. AI 1인에게 잘 맞는 제품 유형&lt;/h2&gt;

&lt;p&gt;“AI로 뭔가 만들기”보다 &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;AI 역할&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;Workflow SaaS&lt;/strong&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;strong&gt;Vertical tool&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;특정 직군의 Pain이 깊음&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;strong&gt;API + Dashboard&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;팀이 API를 붙여 씀&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;strong&gt;Data / Insight&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;의사결정에 쓰는 정보&lt;/td&gt;
      &lt;td&gt;수집·정제·브리핑&lt;/td&gt;
      &lt;td&gt;경쟁사·키워드·GA 요약&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;strong&gt;1인이 피해야 할 함정&lt;/strong&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;/li&gt;
  &lt;li&gt;&lt;strong&gt;“ChatGPT 래퍼”만&lt;/strong&gt; — 차별화·락인·데이터 축적이 없으면 이탈이 빠름.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;좋은 1인 제품은 &lt;strong&gt;좁은 ICP × 명확한 Job × 매주 쓰는 습관&lt;/strong&gt;입니다. “모든 개발자”보다 “Django 팀의 주간 GA 리포트”가 낫습니다. (&lt;a href=&quot;/2026/03/12/django-ninja-google-analytics-weekly-report-service/&quot;&gt;GA 주간 리포트 서비스&lt;/a&gt; 참고)&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;3-ai로-1인-개발-속도를-내는-실전-분업&quot;&gt;3. AI로 1인 개발 속도를 내는 실전 분업&lt;/h2&gt;

&lt;p&gt;팀이 없을 때 &lt;strong&gt;역할을 Agent에게 나누는 것&lt;/strong&gt;이 핵심입니다. &lt;a href=&quot;/2026/04/11/ai-agent-as-employee-hiring-guide/&quot;&gt;AI 직원 고용하기&lt;/a&gt;의 프레임을 1인 SaaS에 맞게 축소하면 아래와 같습니다.&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;strong&gt;Builder&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;기능·버그·리팩터&lt;/td&gt;
      &lt;td&gt;Cursor Agent, Plan Mode, 테스트&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Reviewer&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;보안·엣지 케이스&lt;/td&gt;
      &lt;td&gt;별도 채팅/모델로 diff 리뷰&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Report&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;경쟁·기술 동향&lt;/td&gt;
      &lt;td&gt;Automations, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/loop&lt;/code&gt;, Celery&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Support draft&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;티켓 초안·FAQ&lt;/td&gt;
      &lt;td&gt;이메일·Discord 연동 Agent&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Content&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;블로그·changelog&lt;/td&gt;
      &lt;td&gt;ESC Prompting + 검색 근거&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;ol&gt;
  &lt;li&gt;&lt;strong&gt;프로덕션 배포는 사람이 승인&lt;/strong&gt; — Agent는 PR·스테이징까지.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Rules로 스택 고정&lt;/strong&gt; — &lt;a href=&quot;/2026/05/29-cursor-developer-tips-community-collection/&quot;&gt;Cursor Rules&lt;/a&gt;, &lt;a href=&quot;/2026/04/15-agents-md-strategy-for-ai-harness-engineering/&quot;&gt;AGENTS.md&lt;/a&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;측정 가능한 Agent만 유지&lt;/strong&gt; — “가끔 도움” Agent는 과금만 키움.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;권장 스택(이 블로그 기준, 이미 검증된 조합):&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;API&lt;/strong&gt;: Django Ninja + PostgreSQL&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;비동기&lt;/strong&gt;: Celery + Redis (&lt;a href=&quot;/2026/03/24/django-ninja-celery-beat-complete-guide/&quot;&gt;Celery Beat 가이드&lt;/a&gt;)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;웹&lt;/strong&gt;: Next.js on Vercel (&lt;a href=&quot;/2026/03/18-vercel-nextjs-static-site-deployment-guide/&quot;&gt;배포 가이드&lt;/a&gt;)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;모바일 필요 시&lt;/strong&gt;: PWA → Android/iOS (&lt;a href=&quot;/2026/05/26-django-nextjs-android-app-deploy-guide/&quot;&gt;Android&lt;/a&gt;, &lt;a href=&quot;/2026/05/27-ios-app-deploy-for-django-nextjs/&quot;&gt;iOS&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;4-글로벌-구독-전략--가격&quot;&gt;4. 글로벌 구독 전략 — 가격&lt;/h2&gt;

&lt;h3 id=&quot;41-앵커-통화는-usd-또는-eur&quot;&gt;4.1 앵커 통화는 USD (또는 EUR)&lt;/h3&gt;

&lt;p&gt;전 세계 B2B·프로슈머는 &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;strong&gt;Free&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;바이럴·SEO·체험&lt;/td&gt;
      &lt;td&gt;핵심 가치 1회 맛보기, API 한도 엄격&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Pro&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;1인·소팀 메인 수익&lt;/td&gt;
      &lt;td&gt;$9~29/월 구간이 전환 많음&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Team&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;시트·권한·감사 로그&lt;/td&gt;
      &lt;td&gt;per-seat, 연간 2개월 무료&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Enterprise&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;“문의하기”만&lt;/td&gt;
      &lt;td&gt;1인이 직접 영업하지 말 것&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;strong&gt;연간 결제(annual)&lt;/strong&gt; 를 기본 권장 UI로 두면 이탈·수수료·환율 리스크가 줄어듭니다.&lt;/p&gt;

&lt;h3 id=&quot;42-ppp지역-할인-선택&quot;&gt;4.2 PPP·지역 할인 (선택)&lt;/h3&gt;

&lt;p&gt;트래픽이 인도·동남아·라틴에서 많다면:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Stripe &lt;strong&gt;지역별 가격&lt;/strong&gt; 또는 쿠폰&lt;/li&gt;
  &lt;li&gt;“Developing region” 30~50% 할인 (남용 방지: 카드 발급국·VPN 아닌 &lt;strong&gt;결제 빌링 주소&lt;/strong&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;과도한 할인은 &lt;strong&gt;선진국 사용자의 아비트라지&lt;/strong&gt;를 부릅니다. 할인은 &lt;strong&gt;랜딩별&lt;/strong&gt;이 아니라 &lt;strong&gt;결제 시점&lt;/strong&gt;에 적용하는 편이 안전합니다.&lt;/p&gt;

&lt;h3 id=&quot;43-ai-비용을-가격에-반영&quot;&gt;4.3 AI 비용을 가격에 반영&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;strong&gt;크레딧&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;월 N회 실행, 초과 시 업그레이드&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;토큰 캡&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Pro = 500 runs, Team = unlimited fair use&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;BYOK&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;고급 사용자가 자신의 API 키 연결&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;strong&gt;실수&lt;/strong&gt;: 무제한 AI를 $9에 제공 → 한 명의 파워 유저가 마진을 깎음.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;5-글로벌-구독-전략--결제세금법무&quot;&gt;5. 글로벌 구독 전략 — 결제·세금·법무&lt;/h2&gt;

&lt;h3 id=&quot;51-결제-스택-선택&quot;&gt;5.1 결제 스택 선택&lt;/h3&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;제공자&lt;/th&gt;
      &lt;th&gt;1인에게 좋은 점&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;Stripe Billing&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;구독·웹훅·Customer Portal 성숙&lt;/td&gt;
      &lt;td&gt;해외 VAT 직접 신경 쓸 수 있음&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Paddle / Lemon Squeezy&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Merchant of Record, 세금·인보이스 위임&lt;/td&gt;
      &lt;td&gt;수수료↑, 커스터마이즈 제한&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;앱스토어 IAP&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;모바일 결제 신뢰&lt;/td&gt;
      &lt;td&gt;15~30%, &lt;a href=&quot;/2026/05/27-ios-app-deploy-for-django-nextjs/&quot;&gt;정책&lt;/a&gt; 준수&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;strong&gt;실전 추천&lt;/strong&gt;: 웹 SaaS는 &lt;strong&gt;Stripe&lt;/strong&gt;로 시작, EU 매출이 커지면 MoR 검토. 모바일 앱은 &lt;strong&gt;웹에서 구독·앱은 로그인만&lt;/strong&gt; (스토어 정책은 매년 변하므로 출시 전 문서 재확인).&lt;/p&gt;

&lt;h3 id=&quot;52-웹훅구독-상태-머신&quot;&gt;5.2 웹훅·구독 상태 머신&lt;/h3&gt;

&lt;p&gt;구독 서비스의 심장은 &lt;strong&gt;PaymentProvider 이벤트 → 내부 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;subscription_status&lt;/code&gt;&lt;/strong&gt; 동기화입니다.&lt;/p&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# 개념 예시 — idempotent webhook handler
&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;@&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;router&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/webhooks/stripe&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;stripe_webhook&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;StripeEventIn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;event_id&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;id&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;WebhookLog&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;objects&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;filter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;provider&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;stripe&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;event_id&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;event_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;exists&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;():&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;ok&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;with&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;transaction&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;atomic&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;():&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;WebhookLog&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;objects&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;create&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;provider&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;stripe&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;event_id&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;event_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;model_dump&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;())&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;match&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;customer.subscription.updated&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;sync_subscription&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;object&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;case&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;invoice.payment_failed&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;mark_past_due_and_email&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;object&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;ok&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;멱등성&lt;/strong&gt;: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;event_id&lt;/code&gt; 중복 저장 방지 (&lt;a href=&quot;/2026/02/18-django-transaction-management-best-practices/&quot;&gt;트랜잭션 가이드&lt;/a&gt; 참고)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Grace period&lt;/strong&gt;: 결제 실패 후 3~7일 기능 유지 → 이메일 3통&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Customer Portal&lt;/strong&gt;: 1인이 “플랜 변경·카드·취소”를 직접 처리하게&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;53-법무-최소-세트-글로벌&quot;&gt;5.3 법무 최소 세트 (글로벌)&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;strong&gt;Terms of Service&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;서비스 범위·환불·중단&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Privacy Policy&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;GDPR/CCPA, 데이터 보관·삭제&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Cookie / Analytics&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;EU 동의 배너 (GA 사용 시)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;DPA&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;B2B 팀 플랜 요청 시&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;1인도 &lt;strong&gt;템플릿 + 변호사 1회 리뷰&lt;/strong&gt;(또는 Termly·Iubenda) 비용은 MRR 100달러 넘기 전에 지불하는 편이 싸다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;6-글로벌-구독-전략--현지화지원신뢰&quot;&gt;6. 글로벌 구독 전략 — 현지화·지원·신뢰&lt;/h2&gt;

&lt;h3 id=&quot;61-언어-i18n-vs-영어-only&quot;&gt;6.1 언어: i18n vs 영어 only&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;strong&gt;영어 only v1&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;글로벌 B2B·개발자 도구, 검증 단계&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;en + ko&lt;/strong&gt;&lt;/td&gt;
      &lt;td&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;유료 전환 국가가 3개 이상일 때&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;UI 문자열만 번역하고 &lt;strong&gt;지원·문서·온보딩 이메일&lt;/strong&gt;이 영어면 “반쯤 현지화”입니다. 최소한 &lt;strong&gt;가격 페이지·결제·에러 메시지&lt;/strong&gt;는 사용자 언어로.&lt;/p&gt;

&lt;h3 id=&quot;62-시간대sla-기대치&quot;&gt;6.2 시간대·SLA 기대치&lt;/h3&gt;

&lt;p&gt;1인에게 “24/7 전화 지원”은 불가능합니다. 대신:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;상태 페이지&lt;/strong&gt; (Statuspage, Better Stack)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;문서 + 검색 가능한 FAQ&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;이메일 SLA “48시간 내”&lt;/strong&gt; 명시&lt;/li&gt;
  &lt;li&gt;심각 장애만 &lt;strong&gt;Discord / X DM&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;AI는 &lt;strong&gt;1차 답변 초안&lt;/strong&gt;까지. 환불·법적 분쟁·보안 사고는 사람이 처리.&lt;/p&gt;

&lt;h3 id=&quot;63-신뢰-신호-해외-전환율에-직결&quot;&gt;6.3 신뢰 신호 (해외 전환율에 직결)&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https&lt;/code&gt;·명확한 &lt;strong&gt;회사/개인 사업자 정보&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;Stripe &lt;strong&gt;결제 보안 배지&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;실사용 스크린샷·짧은 데모 영상&lt;/strong&gt; (AI 슬롭 이미지 지양)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;취소·환불 정책&lt;/strong&gt; 랜딩 하단 노출&lt;/li&gt;
  &lt;li&gt;GitHub &lt;strong&gt;public roadmap&lt;/strong&gt; 또는 changelog&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;7-성장-1인이-지속-가능한-채널&quot;&gt;7. 성장: 1인이 지속 가능한 채널&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;채널&lt;/th&gt;
      &lt;th&gt;1인 적합도&lt;/th&gt;
      &lt;th&gt;AI 활용&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;SEO / 블로그&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;높음&lt;/td&gt;
      &lt;td&gt;키워드 리서치, 초안, &lt;a href=&quot;/2026/05/30/ai-report-employee-daily-briefing-automation/&quot;&gt;Report 직원&lt;/a&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Product Hunt&lt;/strong&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;strong&gt;X / LinkedIn&lt;/strong&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;strong&gt;콜드 이메일&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;낮음 (시간 대비)&lt;/td&gt;
      &lt;td&gt;개인화만 Agent&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;/td&gt;
      &lt;td&gt;LTV 검증 후&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;strong&gt;루프&lt;/strong&gt;: 콘텐츠 → 랜딩 → Free trial → &lt;strong&gt;온보딩 이메일 3통&lt;/strong&gt; → Pro 전환 → NPS·이탈 인터뷰.&lt;/p&gt;

&lt;p&gt;온보딩 이메일 예시 (자동화 가능):&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Day 0: “첫 가치” 한 가지 액션 (템플릿 링크)&lt;/li&gt;
  &lt;li&gt;Day 2: 사례·FAQ&lt;/li&gt;
  &lt;li&gt;Day 6: Pro 한정 기능 + 연간 20% OFF&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href=&quot;/2026/03/24/django-ninja-celery-beat-complete-guide/&quot;&gt;Celery Beat&lt;/a&gt;로 스케줄 발송하면 1인도 운영 가능합니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;8-지표--1인이-매주-볼-대시보드&quot;&gt;8. 지표 — 1인이 매주 볼 대시보드&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;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;MRR / New MRR / Churned MRR&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;생존&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Trial → Paid %&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;PMF&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Activation&lt;/strong&gt; (가치 1회 경험)&lt;/td&gt;
      &lt;td&gt;온보딩 품질&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Logo churn vs Revenue churn&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;다운그레이드 vs 이탈&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;CAC (대략)&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;채널 효율&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;API cost / paying user&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;AI 마진&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Support tickets / 100 users&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Agent·문서 투자 우선순위&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Stripe + Metabase, 또는 &lt;a href=&quot;/2026/03/12/django-ninja-google-analytics-weekly-report-service/&quot;&gt;GA 주간 리포트&lt;/a&gt; 패턴으로 &lt;strong&gt;매주 월요일 10분 리뷰&lt;/strong&gt;를 고정하세요.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;9-리스크--미리-알아둘-것&quot;&gt;9. 리스크 — 미리 알아둘 것&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;/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;디지털 상품 IAP 규칙, &lt;a href=&quot;/2026/05/27-ios-app-deploy-for-django-nextjs/&quot;&gt;iOS/Android 가이드&lt;/a&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;AI 환각이 제품 기능&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;출처·로그·사용자 수정 UI&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;a href=&quot;/2026/02/18-django-transaction-management-best-practices/&quot;&gt;트랜잭션·권한&lt;/a&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;USD 앵커, 연간 결제&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;번아웃&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;기능 동결 주 1회, “No” 기본값&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;10-30일-롤아웃-플랜-1인--ai&quot;&gt;10. 30일 롤아웃 플랜 (1인 + AI)&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Week&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;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;ICP·Job·가격 가설&lt;/td&gt;
      &lt;td&gt;10명 인터뷰 또는 랜딩 + 대기자&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;MVP (핵심 Job 1개)&lt;/td&gt;
      &lt;td&gt;Stripe test mode, 로그인&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;온보딩·이메일·문서&lt;/td&gt;
      &lt;td&gt;Trial → Paid 플로우 E2E&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;4&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;소프트 런칭&lt;/td&gt;
      &lt;td&gt;PH 또는 블로그 1편, 지표 대시보드&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;AI 사용량은 &lt;strong&gt;Week 2부터 측정&lt;/strong&gt;해 Pro 가격에 넣을지 결정합니다. 나중에 올리면 기존 사용자 반발이 큽니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;11-정리--1인-글로벌-구독은-작게-팔고-넓게-받기&quot;&gt;11. 정리 — 1인 글로벌 구독은 “작게 팔고 넓게 받기”&lt;/h2&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;AI&lt;/strong&gt;는 인력 대체가 아니라 &lt;strong&gt;제품·콘텐츠·지원의 throughput&lt;/strong&gt;을 키운다.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;구독&lt;/strong&gt;은 MRR과 피드백을 준다. 변동비(AI·API)를 티어에 반영한다.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;글로벌&lt;/strong&gt;은 USD·Stripe·영문 신뢰·약관·지원 SLA로 완성된다.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;1인&lt;/strong&gt;은 플랫폼이 아니라 &lt;strong&gt;좁은 ICP + 주간 습관 제품&lt;/strong&gt;에서 이긴다.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;다음 액션 하나만 고르세요.&lt;/p&gt;

&lt;ul class=&quot;task-list&quot;&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;ICP 1문장 + Pro 가격 $___/월&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;Stripe test subscription E2E&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;영문 랜딩 + Privacy/Terms&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;Report 직원으로 경쟁·키워드 주간 브리핑&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;참고-자료&quot;&gt;참고 자료&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://stripe.com/docs/billing&quot;&gt;Stripe Billing 문서&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://stripe.com/docs/payments/currencies&quot;&gt;Stripe — Global pricing&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.paddle.com/&quot;&gt;Paddle — Merchant of Record&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://www.lemonsqueezy.com/&quot;&gt;Lemon Squeezy&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://ico.org.uk/for-organisations/uk-gdpr-guidance-and-resources/&quot;&gt;GDPR 체크리스트 (ICO)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;관련-글&quot;&gt;관련 글&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/04/11/ai-agent-as-employee-hiring-guide/&quot;&gt;AI 직원 고용하기&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/05/30/ai-report-employee-daily-briefing-automation/&quot;&gt;AI Report 직원 — 매일 브리핑 자동화&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/05/29/cursor-developer-tips-community-collection/&quot;&gt;Cursor 개발자 꿀팁 2026&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/03/24/django-ninja-celery-beat-complete-guide/&quot;&gt;Django Ninja + Celery Beat&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/03/12/django-ninja-google-analytics-weekly-report-service/&quot;&gt;GA 주간 리포트 서비스&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/03/18-vercel-nextjs-static-site-deployment-guide/&quot;&gt;Vercel + Next.js 배포&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/05/27-ios-app-deploy-for-django-nextjs/&quot;&gt;iOS 앱 배포 (Django + Next.js)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content><author><name>updaun</name></author><category term="ai" /><category term="indie-hacker" /><category term="saas" /><category term="ai-coding" /><category term="solo-founder" /><category term="subscription" /><category term="saas" /><category term="stripe" /><category term="global" /><category term="mrr" /><category term="cursor" /><category term="django" /><category term="nextjs" /><category term="indie-hacker" /><summary type="html">팀 없이도 AI로 제품·마케팅·지원의 80%를 커버할 수 있는 시대입니다. 핵심은 &apos;무엇을 팔지&apos;보다 &apos;누가 매달 돈을 내는 이유&apos;와 &apos;전 세계에서 결제·신뢰·온보딩이 막히지 않게 설계하는 것&apos;입니다.</summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-06-01-ai-solo-founder-subscription-global-strategy.webp" /><media:content medium="image" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-06-01-ai-solo-founder-subscription-global-strategy.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">AI Report 직원 만들기 — 웹 검색으로 정보를 모아 매일 정해진 시간에 보고하기</title><link href="https://updaun.github.io/cursor/ai/automation/2026/05/30/ai-report-employee-daily-briefing-automation/" rel="alternate" type="text/html" title="AI Report 직원 만들기 — 웹 검색으로 정보를 모아 매일 정해진 시간에 보고하기" /><published>2026-05-30T00:00:00+09:00</published><updated>2026-05-30T00:00:00+09:00</updated><id>https://updaun.github.io/cursor/ai/automation/2026/05/30/ai-report-employee-daily-briefing-automation</id><content type="html" xml:base="https://updaun.github.io/cursor/ai/automation/2026/05/30/ai-report-employee-daily-briefing-automation/">&lt;h1 id=&quot;ai-report-직원-만들기--웹-검색으로-정보를-모아-매일-정해진-시간에-보고하기&quot;&gt;AI Report 직원 만들기 — 웹 검색으로 정보를 모아 매일 정해진 시간에 보고하기&lt;/h1&gt;

&lt;p&gt;개발자·창업자·마케터에게 반복되는 아침 루틴이 하나 있습니다.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;“경쟁사 뭐 올렸지?”&lt;br /&gt;
“우리 키워드 순위 변했나?”&lt;br /&gt;
“어제 나온 Django/AI 관련 뉴스 뭐 있지?”&lt;br /&gt;
“GitHub 이슈·Slack에서 내가 놓친 게 있나?”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;이걸 &lt;strong&gt;사람이 매일 30~60분&lt;/strong&gt; 쓰는 대신, &lt;strong&gt;AI Report 직원&lt;/strong&gt;에게 맡기는 구조를 만들 수 있습니다. 핵심은 단순합니다.&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; — LLM 기억이 아니라 &lt;strong&gt;웹 검색·API·MCP&lt;/strong&gt;로 최신 근거를 수집한다.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;언제 어디로 보낼지&lt;/strong&gt; — cron·Automations·Celery Beat로 &lt;strong&gt;같은 시간·같은 형식&lt;/strong&gt;으로 배달한다.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;이 글은 &lt;a href=&quot;/2026/04/11/ai-agent-as-employee-hiring-guide/&quot;&gt;AI 직원 고용하기&lt;/a&gt;에서 다룬 “Agent = 목표 + 도구 + 자율 실행” 개념을, &lt;strong&gt;정보 수집·요약·정기 보고&lt;/strong&gt;에 특화한 &lt;strong&gt;Report 직원&lt;/strong&gt; 한 명을 실제로 세팅하는 방법입니다.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;프롬프트 설계 원칙은 &lt;a href=&quot;/2026/05/28/esc-equalizer-socratic-chain-prompting/&quot;&gt;ESC Prompting&lt;/a&gt;과 &lt;a href=&quot;/2026/04/09/ai-prompt-engineering-for-developers-guide/&quot;&gt;프롬프트 엔지니어링 가이드&lt;/a&gt;를 함께 보면 Report 품질이 올라갑니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;0-결론부터-report-직원의-3대-구성요소&quot;&gt;0. 결론부터: Report 직원의 3대 구성요소&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;strong&gt;Brief (업무 정의서)&lt;/strong&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;strong&gt;Research Stack (조사 도구)&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Web Search, SerpAPI, MCP, 내부 API&lt;/td&gt;
      &lt;td&gt;2024년 지식으로 쓴 “그럴듯한 환각”&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Delivery (배달·스케줄)&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Slack, 이메일, Notion, cron&lt;/td&gt;
      &lt;td&gt;조사는 되는데 사람이 못 봄&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;strong&gt;가장 흔한 실수&lt;/strong&gt;는 ChatGPT에 “매일 뉴스 알려줘”라고만 말하는 것입니다. 그건 &lt;strong&gt;챗봇&lt;/strong&gt;이지 &lt;strong&gt;직원&lt;/strong&gt;이 아닙니다. Report 직원은 &lt;strong&gt;스케줄&lt;/strong&gt;, &lt;strong&gt;도구 사용 권한&lt;/strong&gt;, &lt;strong&gt;고정된 보고서 템플릿&lt;/strong&gt;이 있어야 합니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;1-ai-report-직원-vs-일반-ai-챗봇&quot;&gt;1. AI Report 직원 vs 일반 AI 챗봇&lt;/h2&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;AI Report 직원&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;strong&gt;정해진 시간에 자동 실행&lt;/strong&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;strong&gt;웹 검색·API·DB&lt;/strong&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;strong&gt;고정 템플릿&lt;/strong&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;strong&gt;출처 URL·날짜·중복 제거&lt;/strong&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;실행당 (토큰 + 검색 API)&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Report 직원이 잘 맞는 업무:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;산업·기술 동향&lt;/strong&gt; — “Django 6.x”, “Cursor changelog”, “국내 스마트팜 정책”&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;경쟁·시장&lt;/strong&gt; — 경쟁사 블로그, Product Hunt, 키워드 순위&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;운영 지표&lt;/strong&gt; — GA 주간 비교 (&lt;a href=&quot;/2026/03/12/django-ninja-google-analytics-weekly-report-service/&quot;&gt;GA 주간 리포트 서비스&lt;/a&gt; 참고)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;개인 생산성&lt;/strong&gt; — 캘린더·이메일·Slack 하이라이트 (MCP/커넥터 연동)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Report 직원에 맞지 않는 업무:&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;/li&gt;
  &lt;li&gt;&lt;strong&gt;실시간&lt;/strong&gt; 트레이딩·장중 호가 (지연·환각 리스크)&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;2-구현-경로-비교--무엇을-쓸지-고르기&quot;&gt;2. 구현 경로 비교 — 무엇을 쓸지 고르기&lt;/h2&gt;

&lt;p&gt;2026년 기준, Report 직원을 만드는 대표 경로는 아래 네 가지입니다.&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;&lt;strong&gt;Cursor Automations&lt;/strong&gt; (no-repo)&lt;/td&gt;
      &lt;td&gt;Cursor 구독자, 코드 없이 빠르게&lt;/td&gt;
      &lt;td&gt;클라우드 cron, MCP·Slack 내장, 레포 불필요&lt;/td&gt;
      &lt;td&gt;Cursor 사용량 과금, 커스텀 파이프라인 제한&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Cursor &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/loop&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;IDE 안에서 프로토타입·개발 중 모니터링&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;&lt;strong&gt;Claude Code Routines&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Claude Code Desktop/CLI 사용자&lt;/td&gt;
      &lt;td&gt;클라우드 스케줄, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/schedule&lt;/code&gt; 대화형 설정&lt;/td&gt;
      &lt;td&gt;Anthropic 생태계에 종속&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Django Ninja + Celery Beat&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;직접 서비스로 운영하고 싶을 때&lt;/td&gt;
      &lt;td&gt;완전 통제, DB·히스토리·다중 사용자&lt;/td&gt;
      &lt;td&gt;구축·운영 비용&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;strong&gt;추천 순서&lt;/strong&gt;: Cursor 사용자라면 &lt;strong&gt;Automations로 MVP → 품질이 검증되면 Django로 제품화&lt;/strong&gt;.&lt;br /&gt;
이미 &lt;a href=&quot;/2026/03/03/n8n-workflow-automation-guide/&quot;&gt;n8n 워크플로우&lt;/a&gt;를 쓰고 있다면, n8n cron + HTTP 노드 + LLM 노드 조합도 같은 패턴입니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;3-경로-a--cursor-automations으로-report-직원-고용-가장-빠른-mvp&quot;&gt;3. 경로 A — Cursor Automations으로 Report 직원 고용 (가장 빠른 MVP)&lt;/h2&gt;

&lt;p&gt;2026년 5월, Cursor는 &lt;strong&gt;Automations&lt;/strong&gt;로 클라우드 에이전트를 cron·Slack·GitHub 이벤트 등에 연결할 수 있게 했습니다. 특히 &lt;strong&gt;no-repo 자동화&lt;/strong&gt;는 코드 저장소 없이 &lt;strong&gt;외부 시스템 감시 + 요약 보고&lt;/strong&gt;에 맞습니다.&lt;/p&gt;

&lt;p&gt;공식 문서: &lt;a href=&quot;https://cursor.com/docs/cloud-agent/automations&quot;&gt;Cursor Automations&lt;/a&gt;&lt;br /&gt;
블로그 소개: &lt;a href=&quot;https://cursor.com/blog/automations&quot;&gt;Build agents that run automatically&lt;/a&gt;&lt;/p&gt;

&lt;h3 id=&quot;31-설정-5단계&quot;&gt;3.1 설정 5단계&lt;/h3&gt;

&lt;ol&gt;
  &lt;li&gt;Cursor 대시보드 또는 Agents 창 → &lt;strong&gt;Automations&lt;/strong&gt; → &lt;strong&gt;Create&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Trigger&lt;/strong&gt;: Scheduled → 매일 원하는 시간 (또는 cron, 예: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0 8 * * 1-5&lt;/code&gt; = 평일 08:00)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Repository&lt;/strong&gt;: &lt;strong&gt;No repository&lt;/strong&gt; — 정보 수집·보고만 할 때&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Tools&lt;/strong&gt;: Web Search(또는 MCP), &lt;strong&gt;Send to Slack&lt;/strong&gt; / 이메일 / Webhook&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Prompt&lt;/strong&gt;: 아래 Brief 템플릿 붙여넣기 → &lt;strong&gt;Run Now&lt;/strong&gt;로 테스트 → 저장&lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;32-report-직원-brief-프롬프트-템플릿&quot;&gt;3.2 Report 직원 Brief (프롬프트) 템플릿&lt;/h3&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# Role
You are my AI Report employee. Your job is research + synthesis, not chat.

# Schedule context
Report for: {YYYY-MM-DD}, timezone Asia/Seoul.
Audience: backend developer running Django/Next.js side projects and a tech blog.

# Research rules (MANDATORY)
1. Use web search aggressively for EVERY factual claim. Do NOT rely on training data for news, versions, or pricing.
2. Prefer sources from the last 7 days unless I ask for historical context.
3. For each bullet, include: headline, 1-sentence insight, source URL, published date (if available).
4. Deduplicate overlapping stories. Skip press-release fluff without substance.
5. If search is inconclusive, say &quot;확인 불가&quot; — never invent.

# Topics (search each separately)
- Cursor IDE: changelog, Automations, pricing (last 7 days)
- Django / Python backend: security advisories, LTS news
- AI dev tools: Claude Code, Copilot agent updates
- Competitors / references: {경쟁사 A}, {경쟁사 B} — product or blog posts
- Optional internal: summarize if MCP {analytics/slack} is connected

# Output format (Korean, fixed sections)
## 🔴 오늘 꼭 볼 것 (max 3)
## 📰 업계·도구 동향 (max 5)
## 🎯 내 프로젝트에 영향 (max 3, actionable)
## 📎 출처 목록 (URL only, no duplicates)
## ⏱️ 다음 리포트 제안 (1 line: what to add/remove)

Keep total length under 800 words.
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;33-mcp로-내부-정보까지-합치기&quot;&gt;3.3 MCP로 “내부 정보”까지 합치기&lt;/h3&gt;

&lt;p&gt;웹 검색만으로는 &lt;strong&gt;내 GA 숫자, Slack 스레드, Linear 이슈&lt;/strong&gt;를 못 봅니다. Automations에 MCP 서버를 연결하면 Report 직원이 &lt;strong&gt;공개 웹 + 내부 도구&lt;/strong&gt;를 한 번에 조사합니다.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;MCP / 커넥터&lt;/th&gt;
      &lt;th&gt;Report에 쓰는 방법&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Google Analytics&lt;/td&gt;
      &lt;td&gt;어제 대비 트래픽·전환 변화&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Slack&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;#dev&lt;/code&gt; 채널 하이라이트&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Linear / GitHub&lt;/td&gt;
      &lt;td&gt;열린 P0 이슈, 어제 머지된 PR&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;SerpAPI / Brave Search&lt;/td&gt;
      &lt;td&gt;키워드·뉴스 정밀 검색 (&lt;a href=&quot;/2026/04/28/serpapi-complete-guide/&quot;&gt;SerpAPI 가이드&lt;/a&gt;)&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Cursor &lt;a href=&quot;/2026/05/29/cursor-developer-tips-community-collection/&quot;&gt;개발자 꿀팁&lt;/a&gt;에서 다룬 것처럼, MCP는 &lt;strong&gt;Report 직원의 “눈과 손”&lt;/strong&gt;입니다. Rules 파일에 “항상 URL 인용” 같은 헌법을 두면 품질이 안정됩니다.&lt;/p&gt;

&lt;h3 id=&quot;34-비용-감각&quot;&gt;3.4 비용 감각&lt;/h3&gt;

&lt;p&gt;no-repo 일일 리포트(입력 ~50K 토큰, 출력 ~2K)는 Composer 계열 기준 &lt;strong&gt;월 $1 미만&lt;/strong&gt; 수준으로 잡히는 경우가 많습니다(2026년 5월 Cursor Automations 요금 구조 기준, 실제 사용량·모델에 따라 변동). &lt;strong&gt;Run Now&lt;/strong&gt;로 하루치 품질을 본 뒤 cron을 켜세요.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;4-경로-b--cursor-loop으로-프로토타입개발-중-브리핑&quot;&gt;4. 경로 B — Cursor &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/loop&lt;/code&gt;으로 프로토타입·개발 중 브리핑&lt;/h2&gt;

&lt;p&gt;IDE 세션 안에서 &lt;strong&gt;“매일 8시”가 아니라 “5분마다 배포 상태 확인”&lt;/strong&gt; 같은 반복 조사가 필요할 때는 &lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/loop&lt;/code&gt;&lt;/strong&gt; 가 맞습니다.&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;/loop 1d 오늘 날짜 기준으로 Cursor·Django·AI 도구 뉴스를 웹 검색해 요약하고,
출처 URL 포함한 5줄 브리핑을 이 채팅에 남겨줘.
&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;/loop&lt;/code&gt; 특성:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;세션 스코프&lt;/strong&gt; — 새 채팅을 열면 루프가 끊김&lt;/li&gt;
  &lt;li&gt;PC·Cursor가 켜져 있어야 함&lt;/li&gt;
  &lt;li&gt;개발 중 &lt;strong&gt;프롬프트·주제 튜닝&lt;/strong&gt;에 최적&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Automations로 넘기기 전에 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/loop 1d&lt;/code&gt;로 &lt;strong&gt;일주일치 샘플&lt;/strong&gt;을 모아 Brief를 다듬는 워크플로를 추천합니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;5-경로-c--django-ninja--celery-beat으로-진짜-report-서비스&quot;&gt;5. 경로 C — Django Ninja + Celery Beat으로 “진짜” Report 서비스&lt;/h2&gt;

&lt;p&gt;팀·고객에게 Report를 &lt;strong&gt;제품&lt;/strong&gt;으로 제공하거나, &lt;strong&gt;히스토리·알림·다중 구독&lt;/strong&gt;이 필요하면 자체 서비스가 낫습니다. 이 블로그의 스택(Django Ninja, Celery, Redis)과 잘 맞습니다.&lt;/p&gt;

&lt;h3 id=&quot;51-아키텍처&quot;&gt;5.1 아키텍처&lt;/h3&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;[Celery Beat cron 08:00 KST]
        ↓
[generate_daily_report_task]
        ↓
┌───────────────────────────────────────┐
│ 1. ReportProfile DB에서 주제·키워드 로드 │
│ 2. SerpAPI / Tavily로 웹 검색 (병렬)    │
│ 3. LLM: dedupe + 섹션별 요약 + JSON     │
│ 4. ReportSnapshot DB 저장               │
│ 5. Slack / Email / Webhook 발송         │
└───────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Celery Beat 설정 패턴은 &lt;a href=&quot;/2026/03/24/django-ninja-celery-beat-complete-guide/&quot;&gt;Celery Beat 가이드&lt;/a&gt;의 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;daily-statistics&lt;/code&gt; 예시와 동일합니다.&lt;/p&gt;

&lt;h3 id=&quot;52-핵심-코드-스케치&quot;&gt;5.2 핵심 코드 스케치&lt;/h3&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# reports/tasks.py
&lt;/span&gt;&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;celery&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;shared_task&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;datetime&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;date&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;httpx&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;openai&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;OpenAI&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;OpenAI&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;SEARCH_QUERIES&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;Django security advisory site:djangoproject.com&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;Cursor IDE changelog 2026&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;AI coding agent automation news&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;


&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;web_search&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;query&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;num&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;5&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;list&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;dict&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]:&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;&quot;&quot;SerpAPI 등 검색 API — LLM 환각 방지용 1차 근거.&quot;&quot;&quot;&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;resp&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;httpx&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;https://serpapi.com/search.json&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;params&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;engine&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;google&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;q&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;query&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;api_key&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;settings&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;SERPAPI_KEY&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;num&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;num&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;timeout&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;30&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;resp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;raise_for_status&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;title&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;r&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;title&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;link&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;r&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;link&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;snippet&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;r&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;snippet&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)}&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;r&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;resp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;().&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;organic_results&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[])&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;


&lt;span class=&quot;o&quot;&gt;@&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;shared_task&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;reports.generate_daily_report&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;generate_daily_report&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;profile_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;profile&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ReportProfile&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;objects&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pk&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;profile_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;raw_hits&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[]&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;q&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;profile&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;queries&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;raw_hits&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;extend&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;web_search&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;q&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;prompt&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&quot;&quot;
    You are an AI Report employee. Synthesize the search results below.
    Rules: cite URLs, Korean output, skip duplicates, flag uncertainty.
    Template sections: &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;profile&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;template_sections&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;
    Search results: &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;raw_hits&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;40&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;
    &quot;&quot;&quot;&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;completion&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;chat&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;completions&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;create&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;model&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;gpt-4.1-mini&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;messages&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;role&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;user&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;content&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;prompt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}],&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;body&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;completion&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;choices&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;].&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;content&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;snapshot&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ReportSnapshot&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;objects&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;create&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;profile&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;profile&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;report_date&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;date&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;today&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;body_markdown&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;raw_sources&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;raw_hits&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;send_slack&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;profile&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;slack_webhook_url&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;snapshot&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# config/celery.py — beat schedule 예시
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;app&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;conf&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;beat_schedule&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;daily-ai-report-08am&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;task&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;reports.generate_daily_report&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;schedule&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;crontab&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;hour&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;8&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;minute&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# Celery timezone 설정 확인
&lt;/span&gt;        &lt;span class=&quot;s&quot;&gt;&quot;args&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,),&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# profile_id
&lt;/span&gt;    &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;왜 SerpAPI(또는 Tavily)를 끼우나&lt;/strong&gt;: LLM에게 “검색해”만 시키면 도구 호출이 빠지거나 구식 정보를 씁니다. &lt;strong&gt;검색 API → 구조화된 결과 → LLM 요약&lt;/strong&gt; 2단계가 Report 직원의 표준 패턴입니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;6-report-품질을-올리는-프롬프트운영-팁&quot;&gt;6. Report 품질을 올리는 프롬프트·운영 팁&lt;/h2&gt;

&lt;h3 id=&quot;61-웹-검색을-적극-쓰게-만드는-문장&quot;&gt;6.1 웹 검색을 “적극” 쓰게 만드는 문장&lt;/h3&gt;

&lt;p&gt;Brief에 아래 문장을 &lt;strong&gt;항상&lt;/strong&gt; 넣으세요.&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Before writing each section, run at least one targeted web search.
If you did not search, do not write that section.
Training data cutoff is NOT a valid source for version numbers, pricing, or news.
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;62-esc-prompting을-report에-적용&quot;&gt;6.2 ESC Prompting을 Report에 적용&lt;/h3&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;ESC 단계&lt;/th&gt;
      &lt;th&gt;Report 직원 적용&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Equalizer&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;“800자 이하, 섹션 4개 고정, 불릿만”&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Socratic&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;“내 프로젝트에 왜 중요한지 한 줄” 자문&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Chain&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;검색 → 중복 제거 → 우선순위 → 최종 템플릿&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;63-사람-20-검토-루틴&quot;&gt;6.3 사람 20% 검토 루틴&lt;/h3&gt;

&lt;p&gt;AI Report 직원도 &lt;strong&gt;80/20&lt;/strong&gt;이 안전합니다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;🔴 섹션만 사람이 2분 스캔&lt;/li&gt;
  &lt;li&gt;틀린 URL·날짜가 보이면 Brief에 &lt;strong&gt;금지 출처·선호 출처&lt;/strong&gt; 추가&lt;/li&gt;
  &lt;li&gt;한 달에 한 번 &lt;strong&gt;주제·키워드&lt;/strong&gt; 정리 (Report 직원 “성과 평가”)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;64-실패-패턴-체크리스트&quot;&gt;6.4 실패 패턴 체크리스트&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;수정&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;쿼리를 5~10개로 쪼개기&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;옛날 버전 번호&lt;/td&gt;
      &lt;td&gt;검색 생략&lt;/td&gt;
      &lt;td&gt;Mandatory search 규칙 강화&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;너무 김&lt;/td&gt;
      &lt;td&gt;Equalizer 없음&lt;/td&gt;
      &lt;td&gt;글자 수·불릿 수 상한&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Slack만 안 옴&lt;/td&gt;
      &lt;td&gt;Tool 미선택&lt;/td&gt;
      &lt;td&gt;Automations Tools 재확인&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;7-claude-code-routines--cursor-대안-한-줄-비교&quot;&gt;7. Claude Code Routines — Cursor 대안 한 줄 비교&lt;/h2&gt;

&lt;p&gt;Claude Code 사용자라면 &lt;strong&gt;Routines&lt;/strong&gt;(&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;claude.ai/code/routines&lt;/code&gt;)나 Desktop &lt;strong&gt;Scheduled tasks&lt;/strong&gt;로 같은 Report 직원을 만들 수 있습니다. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/schedule weekday industry briefing at 8:30am&lt;/code&gt;처럼 대화형으로 cron을 잡고, MCP 커넥터로 캘린더·Slack을 붙이는 패턴입니다.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt; &lt;/th&gt;
      &lt;th&gt;Cursor Automations&lt;/th&gt;
      &lt;th&gt;Claude Routines&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;클라우드 스케줄&lt;/td&gt;
      &lt;td&gt;✅ cron&lt;/td&gt;
      &lt;td&gt;✅ (최소 1시간 간격 등 제약 확인)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;no-repo 보고&lt;/td&gt;
      &lt;td&gt;✅&lt;/td&gt;
      &lt;td&gt;✅&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;IDE &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/loop&lt;/code&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;Django 코드베이스와 통합&lt;/td&gt;
      &lt;td&gt;MCP / webhook&lt;/td&gt;
      &lt;td&gt;MCP / webhook&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;strong&gt;도구는 하나만 고르면 됩니다.&lt;/strong&gt; Report 직원의 본질은 &lt;strong&gt;Brief + Research Stack + Delivery&lt;/strong&gt;이지 IDE 브랜드가 아닙니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;8-7일-롤아웃-플랜&quot;&gt;8. 7일 롤아웃 플랜&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Day&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;관심 주제 5~10개, 제외 키워드, 출력 템플릿 초안&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;2&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/loop 1d&lt;/code&gt; 또는 &lt;strong&gt;Run Now&lt;/strong&gt;로 3회 테스트&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;3&lt;/td&gt;
      &lt;td&gt;출처·날짜 누락 수정, Brief 강화&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;4&lt;/td&gt;
      &lt;td&gt;Slack/이메일 배달 연결&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;5&lt;/td&gt;
      &lt;td&gt;cron Automations 또는 Celery Beat 활성화&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;6~7&lt;/td&gt;
      &lt;td&gt;아침 2분 검토 → Brief 미세 조정&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;9-정리--report-직원은-더-똑똑한-챗봇이-아니다&quot;&gt;9. 정리 — Report 직원은 “더 똑똑한 챗봇”이 아니다&lt;/h2&gt;

&lt;p&gt;AI Report 직원은 &lt;strong&gt;매일 같은 시간에, 같은 형식으로, 검색 근거와 함께&lt;/strong&gt; 조사 결과를 가져오는 &lt;strong&gt;역할이 고정된 Agent&lt;/strong&gt;입니다.&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Brief&lt;/strong&gt;로 무엇을 찾을지 명시하고&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;웹 검색·MCP·API&lt;/strong&gt;로 최신성을 확보하며&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Automations / Celery / Routines&lt;/strong&gt;로 사람 개입 없이 배달한다&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Cursor만 쓴다면 &lt;strong&gt;Automations(no-repo) + Run Now 검증 + 평일 cron&lt;/strong&gt;이 가장 빠른 첫 고용입니다. Report가 비즈니스가 되면 &lt;a href=&quot;/2026/03/12/django-ninja-google-analytics-weekly-report-service/&quot;&gt;GA 주간 리포트&lt;/a&gt;처럼 Django 파이프라인으로 옮기면 됩니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;참고-자료&quot;&gt;참고 자료&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://cursor.com/docs/cloud-agent/automations&quot;&gt;Cursor Automations 공식 문서&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://cursor.com/blog/automations&quot;&gt;Cursor — Build agents that run automatically&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://code.claude.com/docs/en/routines&quot;&gt;Claude Code — Automate work with routines&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://code.claude.com/docs/en/scheduled-tasks&quot;&gt;Claude Code — Run prompts on a schedule (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/loop&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;관련-글&quot;&gt;관련 글&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/04/11/ai-agent-as-employee-hiring-guide/&quot;&gt;AI 직원 고용하기: Agent로 팀 자동화&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/05/29/cursor-developer-tips-community-collection/&quot;&gt;Cursor 개발자 꿀팁 모음 2026&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/04/28/serpapi-complete-guide/&quot;&gt;SerpAPI 완전 가이드&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/03/24/django-ninja-celery-beat-complete-guide/&quot;&gt;Django Ninja + Celery Beat 가이드&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/03/03/n8n-workflow-automation-guide/&quot;&gt;n8n 워크플로우 자동화&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/05/28/esc-equalizer-socratic-chain-prompting/&quot;&gt;ESC Prompting&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content><author><name>updaun</name></author><category term="cursor" /><category term="ai" /><category term="automation" /><category term="cursor" /><category term="ai-agent" /><category term="automation" /><category term="daily-report" /><category term="web-search" /><category term="mcp" /><category term="serpapi" /><category term="productivity" /><category term="briefing" /><summary type="html">매일 아침 같은 질문을 반복하지 마세요. &apos;무엇을 찾을지&apos;, &apos;어떻게 검증할지&apos;, &apos;언제 어디로 보낼지&apos;만 정의하면 AI Report 직원이 대신 조사하고 요약해 옵니다. 웹 검색을 적극 쓰는 설계가 핵심입니다.</summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-05-30-ai-report-employee-daily-briefing-automation.webp" /><media:content medium="image" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-05-30-ai-report-employee-daily-briefing-automation.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Cursor 개발자 꿀팁 모음 — 커뮤니티에서 검증된 실전 워크플로우 2026</title><link href="https://updaun.github.io/cursor/developer-tools/2026/05/29/cursor-developer-tips-community-collection/" rel="alternate" type="text/html" title="Cursor 개발자 꿀팁 모음 — 커뮤니티에서 검증된 실전 워크플로우 2026" /><published>2026-05-29T00:00:00+09:00</published><updated>2026-05-29T00:00:00+09:00</updated><id>https://updaun.github.io/cursor/developer-tools/2026/05/29/cursor-developer-tips-community-collection</id><content type="html" xml:base="https://updaun.github.io/cursor/developer-tools/2026/05/29/cursor-developer-tips-community-collection/">&lt;h1 id=&quot;cursor-개발자-꿀팁-모음--커뮤니티에서-검증된-실전-워크플로우-2026&quot;&gt;Cursor 개발자 꿀팁 모음 — 커뮤니티에서 검증된 실전 워크플로우 2026&lt;/h1&gt;

&lt;p&gt;Cursor를 쓰는 개발자들 사이에서 자주 나오는 말이 있습니다.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;“처음엔 신세계인데, 한 달 뒤엔 왜 이렇게 헤매지지?”&lt;br /&gt;
“같은 질문인데 어제랑 오늘 답이 완전 다르다.”&lt;br /&gt;
“Agent가 파일을 통째로 지워버렸다…”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;이건 Cursor가 ‘못 쓰는’ 도구라서가 아니라, &lt;strong&gt;IDE + LLM을 한 세트로 운영하는 방법&lt;/strong&gt;을 아직 안 잡았을 때 흔히 겪는 현상입니다. Reddit &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;r/cursor&lt;/code&gt;, X(Twitter), GitHub 팁 저장소, 실무 팀 블로그에서 &lt;strong&gt;반복적으로 언급되는 패턴&lt;/strong&gt;만 골라, 바로 적용할 수 있게 정리했습니다.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;이 글은 “기능 나열”이 아니라 &lt;strong&gt;왜 이 팁이 먹히는지&lt;/strong&gt;와 &lt;strong&gt;언제 쓰면 안 되는지&lt;/strong&gt;까지 같이 다룹니다.&lt;br /&gt;
프롬프트 문장 자체를 깊게 다루려면 &lt;a href=&quot;/2026/04/09/ai-prompt-engineering-for-developers-guide/&quot;&gt;개발자를 위한 AI 프롬프트 엔지니어링 가이드&lt;/a&gt;와 &lt;a href=&quot;/2026/05/28/esc-equalizer-socratic-chain-prompting/&quot;&gt;ESC Prompting&lt;/a&gt;도 함께 보면 좋습니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;0-결론부터-cursor-꿀팁의-핵심은-3가지&quot;&gt;0. 결론부터: Cursor 꿀팁의 핵심은 3가지&lt;/h2&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;strong&gt;설정&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;프로젝트 헌법을 파일로 고정&lt;/td&gt;
      &lt;td&gt;Rules(&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.cursor/rules&lt;/code&gt;), &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.cursorignore&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;/td&gt;
      &lt;td&gt;Ask / Cmd+K / Agent / Plan Mode&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;/td&gt;
      &lt;td&gt;@멘션, Fresh Chat, 커밋 습관&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;아래는 이 순서대로 “실무에서 바로 쓰는” 팁만 모았습니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;1-한-번만-해두면-계속-이득인-설정-팁&quot;&gt;1. 한 번만 해두면 계속 이득인 설정 팁&lt;/h2&gt;

&lt;h3 id=&quot;11-프로젝트-rules를-헌법으로-쓰기&quot;&gt;1.1 프로젝트 Rules를 “헌법”으로 쓰기&lt;/h3&gt;

&lt;p&gt;가장 많이 추천되는 첫 단계는 &lt;strong&gt;프로젝트별 AI 규칙 파일&lt;/strong&gt;입니다. 예전 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.cursorrules&lt;/code&gt; 단일 파일 방식에서, 지금은 &lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.cursor/rules/*.mdc&lt;/code&gt;&lt;/strong&gt; 형태로 파일·폴더별 규칙을 나누는 패턴이 일반적입니다.&lt;/p&gt;

&lt;div class=&quot;language-yaml 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;nn&quot;&gt;---&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;description&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Django&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;API&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;규칙&quot;&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;globs&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;**/api/**/*.py&quot;&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;]&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;alwaysApply&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;false&lt;/span&gt;
&lt;span class=&quot;nn&quot;&gt;---&lt;/span&gt;

&lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;Django Ninja 1.x 스타일 유지&lt;/span&gt;
&lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;모든 엔드포인트에 Pydantic 스키마 + 예외 처리&lt;/span&gt;
&lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;비즈니스 로직은 services/에만 둔다&lt;/span&gt;
&lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;placeholder(`# ... existing code`) 금지, 수정 구간은 전체 블록으로 제시&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;커뮤니티에서 특히 많이 언급되는 Rules 문장(“Anti-Lazy”)&lt;/strong&gt;:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;You DO NOT use placeholders.
Output the FULL content of the file when editing.
Do not be lazy.
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;왜 먹히나&lt;/strong&gt;: Agent는 “짧게 답하는” 쪽으로 기울기 쉽습니다. Rules는 그 기본값을 프로젝트 수준에서 뒤집습니다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;주의&lt;/strong&gt;: Rules를 너무 길게 쓰면 오히려 컨텍스트를 잡아먹습니다. &lt;strong&gt;자주 깨지는 규칙 5~10개&lt;/strong&gt;만 남기는 게 실무에서 더 잘 맞습니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h3 id=&quot;12-cursorignore로-인덱스-노이즈-줄이기&quot;&gt;1.2 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.cursorignore&lt;/code&gt;로 인덱스 노이즈 줄이기&lt;/h3&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;node_modules&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dist&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.venv&lt;/code&gt;, 대용량 로그, 생성물 등을 제외하면:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;코드베이스 검색/인덱싱이 빨라지고&lt;/li&gt;
  &lt;li&gt;AI가 엉뚱한 파일을 근거로 삼는 일이 줄어듭니다&lt;/li&gt;
&lt;/ul&gt;

&lt;pre&gt;&lt;code class=&quot;language-gitignore&quot;&gt;# .cursorignore 예시
node_modules/
dist/
.next/
.venv/
__pycache__/
*.log
coverage/
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;왜 먹히나&lt;/strong&gt;: Cursor의 강점은 “레포 전체 맥락”인데, 노이즈가 많으면 그 강점이 약해집니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h3 id=&quot;13-docs로-버전-맞는-문서-붙이기&quot;&gt;1.3 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@docs&lt;/code&gt;로 “버전 맞는” 문서 붙이기&lt;/h3&gt;

&lt;p&gt;공식 문서 URL을 Docs로 등록해 두고, 채팅에서 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@docs&lt;/code&gt;로 참조하면 &lt;strong&gt;학습 데이터 시점과 다른 API&lt;/strong&gt;를 쓰는 실수를 줄일 수 있습니다.&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;@docs Django Ninja
@docs Next.js App Router
이 프로젝트 설정 기준으로 인증 미들웨어 예시를 작성해줘.
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;왜 먹히나&lt;/strong&gt;: LLM은 “대략 맞는” 예제를 잘 만들지만, &lt;strong&gt;마이너 버전 차이&lt;/strong&gt;에서 깨집니다. Docs는 그 간극을 메웁니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;2-모드-선택-팁--도구를-잘못-고르면-느려진다&quot;&gt;2. 모드 선택 팁 — “도구를 잘못 고르면” 느려진다&lt;/h2&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;&lt;strong&gt;Ask&lt;/strong&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;strong&gt;Cmd+K (Inline Edit)&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;diff 범위가 작아 안전&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;여러 파일 기능 추가&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;Agent&lt;/strong&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;strong&gt;Plan Mode&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;코드 쓰기 전에 계획 승인&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;21-plan-mode-먼저-설계-나중에-코드&quot;&gt;2.1 Plan Mode: “먼저 설계, 나중에 코드”&lt;/h3&gt;

&lt;p&gt;2025~2026년에 가장 체감이 큰 변화 중 하나가 &lt;strong&gt;Plan Mode&lt;/strong&gt;입니다. Composer/Agent에서 &lt;strong&gt;계획을 먼저 보고 승인한 뒤&lt;/strong&gt; 실행하는 흐름입니다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;커뮤니티에서 많이 하는 패턴&lt;/strong&gt;:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;[Plan Mode ON]
목표: User 모듈을 services/ 레이어로 분리
제약: API 응답 스키마 변경 금지, 테스트는 Vitest 유지
산출물: 영향 파일 목록 + 단계별 계획 + 롤백 포인트
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;왜 먹히나&lt;/strong&gt;: Agent가 “일단 코드부터” 쓰면 되돌리기 비용이 큽니다. Plan은 &lt;strong&gt;잘못된 방향으로 가는 시간&lt;/strong&gt;을 앞단에서 차단합니다.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;실무 팁: Plan 결과를 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;plan.md&lt;/code&gt;나 이슈 코멘트로 저장해 두면, 다음 채팅에서 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@plan.md&lt;/code&gt;로 이어갈 수 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;hr /&gt;

&lt;h3 id=&quot;22-cmdk는-수술-agent는-공사&quot;&gt;2.2 Cmd+K는 “수술”, Agent는 “공사”&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Cmd+K&lt;/strong&gt;: 함수 하나, 타입 수정, import 정리처럼 &lt;strong&gt;범위가 명확&lt;/strong&gt;할 때&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Agent&lt;/strong&gt;: 라우팅·서비스·테스트·문서까지 &lt;strong&gt;연쇄 변경&lt;/strong&gt;이 필요할 때&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;왜 먹히나&lt;/strong&gt;: Agent는 편하지만 &lt;strong&gt;변경 반경이 커집니다&lt;/strong&gt;. 작은 수정에 Agent를 쓰면 리뷰 부담이 커지고, “Revert/삭제” 이슈에 더 자주 걸립니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;3-컨텍스트-운영-팁--멘션과-fresh-chat&quot;&gt;3. 컨텍스트 운영 팁 — @멘션과 Fresh Chat&lt;/h2&gt;

&lt;h3 id=&quot;31-멘션으로-근거를-고정하기&quot;&gt;3.1 @멘션으로 “근거”를 고정하기&lt;/h3&gt;

&lt;p&gt;채팅에서 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@&lt;/code&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;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@file&lt;/code&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;@folder&lt;/code&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;@git&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;최근 변경·PR 맥락&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@web&lt;/code&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;@docs&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;공식 문서 기준 답변&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;strong&gt;커뮤니티 팁&lt;/strong&gt;: “그냥 질문”보다 &lt;strong&gt;@file + 제약 + 출력 형식&lt;/strong&gt; 3종 세트가 훨씬 안정적입니다.&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;@src/auth/service.ts
목표: refresh token rotation 버그 수정
제약: API 스키마 변경 금지
출력: (1) 원인 가설 (2) 패치 diff (3) 회귀 테스트 케이스 3개
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h3 id=&quot;32-fresh-chat-규칙--20메시지-넘으면-새-채팅&quot;&gt;3.2 Fresh Chat 규칙 — 20메시지 넘으면 새 채팅&lt;/h3&gt;

&lt;p&gt;Reddit &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;r/cursor&lt;/code&gt;에서 자주 보이는 &lt;strong&gt;“Fresh Chat Rule”&lt;/strong&gt;:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;디버깅이 20메시지를 넘기면 컨텍스트가 오염된다.&lt;br /&gt;
요약본을 들고 &lt;strong&gt;새 채팅&lt;/strong&gt;을 연다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;새 채팅에 붙일 요약 템플릿&lt;/strong&gt;:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;[Context Summary]
- 프로젝트: (스택/버전)
- 목표: (지금 하려는 일)
- 시도한 것: (이미 해본 해결책)
- 현재 상태: (에러 메시지/재현 조건)
- 금지: (건드리면 안 되는 파일/정책)
다음 액션만 제안해줘.
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;왜 먹히나&lt;/strong&gt;: 긴 대화는 모델이 &lt;strong&gt;초반 맥락을 잊거나&lt;/strong&gt;, 서로 모순된 지시를 섞어 해석하기 쉽습니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h3 id=&quot;33-대화-중-모델을-바꾸지-않기&quot;&gt;3.3 대화 중 모델을 바꾸지 않기&lt;/h3&gt;

&lt;p&gt;같은 작업을 GPT 계열에서 Claude로, 중간에 바꾸면 &lt;strong&gt;스타일·가정·코딩 습관&lt;/strong&gt;이 달라져 diff가 흔들립니다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;커뮤니티 권장&lt;/strong&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;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;4-워크플로우-꿀팁--빠르게가-아니라-안전하게-빠르게&quot;&gt;4. 워크플로우 꿀팁 — “빠르게”가 아니라 “안전하게 빠르게”&lt;/h2&gt;

&lt;h3 id=&quot;41-agent-돌리기-전에-커밋또는-stash&quot;&gt;4.1 Agent 돌리기 전에 커밋(또는 stash)&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;“Delete Bug”&lt;/strong&gt;로 불리는 이슈: Agent가 파일을 &lt;strong&gt;부분 수정 대신 삭제 후 재생성&lt;/strong&gt;하는 경우가 있습니다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;커뮤니티 표준 습관&lt;/strong&gt;:&lt;/p&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;git add &lt;span class=&quot;nt&quot;&gt;-A&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; git commit &lt;span class=&quot;nt&quot;&gt;-m&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;checkpoint: before cursor agent&quot;&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;# 또는&lt;/span&gt;
git stash push &lt;span class=&quot;nt&quot;&gt;-m&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;before agent&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;왜 먹히나&lt;/strong&gt;: AI 편집은 Git diff로 되돌릴 수 있지만, &lt;strong&gt;삭제·이동·대량 생성&lt;/strong&gt;이 섞이면 추적이 어려워집니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h3 id=&quot;42-research--plan--execute--audit-4단계&quot;&gt;4.2 Research → Plan → Execute → Audit 4단계&lt;/h3&gt;

&lt;p&gt;많은 팀이 쓰는 &lt;strong&gt;Research-First Protocol&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Discovery&lt;/strong&gt;: “User 컴포넌트 사용처만 맵핑해줘” (코드 변경 없음)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Plan&lt;/strong&gt;: “리팩터 계획 + 영향 파일 + 리스크”&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Execute&lt;/strong&gt;: 승인된 계획으로 Agent/Composer 실행&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Audit&lt;/strong&gt;: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git diff&lt;/code&gt;를 사람이 한 줄씩 리뷰&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;왜 먹히나&lt;/strong&gt;: Agent는 “실행”은 빠른데, &lt;strong&gt;방향이 틀리면&lt;/strong&gt; 더 빠르게 망가집니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h3 id=&quot;43-터미널-에러는-복붙-말고-debug-with-ai&quot;&gt;4.3 터미널 에러는 복붙 말고 “Debug with AI”&lt;/h3&gt;

&lt;p&gt;터미널에서 실패한 명령 옆 &lt;strong&gt;Debug with AI&lt;/strong&gt;를 쓰면, 에러 로그·cwd·명령어가 같이 전달됩니다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;왜 먹히나&lt;/strong&gt;: 개발자가 복붙할 때 빠뜨리는 &lt;strong&gt;스택 트레이스 앞뒤 맥락&lt;/strong&gt;이 줄어듭니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h3 id=&quot;44-ui-버그는-스크린샷이-이김&quot;&gt;4.4 UI 버그는 스크린샷이 이김&lt;/h3&gt;

&lt;p&gt;CSS/레이아웃 이슈는 텍스트 설명보다 &lt;strong&gt;스크린샷 + 기대/실제&lt;/strong&gt;가 진단이 빠르다는 팁이 반복됩니다.&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;[첨부: 스크린샷]
기대: 모바일에서 버튼이 하단 고정
실제: 키보드 올라오면 버튼이 가려짐
환경: iOS Safari, Next.js 15
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h3 id=&quot;45-tdd로-ai-출력-품질-올리기&quot;&gt;4.5 TDD로 AI 출력 품질 올리기&lt;/h3&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;1) &quot;이 요구사항에 대한 실패 테스트부터 작성해줘&quot;
2) 테스트 리뷰
3) &quot;이 테스트를 통과하는 최소 구현만 작성해줘&quot;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;왜 먹히나&lt;/strong&gt;: 테스트가 &lt;strong&gt;완료 조건&lt;/strong&gt;이 되어, Agent의 과잉 구현을 줄입니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;5-비용모델-운영-팁-2026년-이후-더-중요&quot;&gt;5. 비용·모델 운영 팁 (2026년 이후 더 중요)&lt;/h2&gt;

&lt;p&gt;2026년부터는 일부 프론티어 모델이 &lt;strong&gt;Max Mode(토큰 기반 과금)&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;추론 강한 모델&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;CRUD, 테스트 스캐폴딩&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;&lt;strong&gt;실무 팁 3개&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Agent를 &lt;strong&gt;무한 루프&lt;/strong&gt;에 두지 않기 (반복 실패 시 사람이 끊기)&lt;/li&gt;
  &lt;li&gt;OpenAI/Anthropic/Cursor 대시보드에 &lt;strong&gt;지출 한도&lt;/strong&gt; 설정&lt;/li&gt;
  &lt;li&gt;“Auto 모드” 사용 시 &lt;strong&gt;크레딧 소모&lt;/strong&gt;를 주기적으로 확인&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;6-주의해야-할-함정--커뮤니티가-경고하는-것들&quot;&gt;6. 주의해야 할 함정 — 커뮤니티가 경고하는 것들&lt;/h2&gt;

&lt;h3 id=&quot;61-revert-bug-코드가-조용히-되돌아감&quot;&gt;6.1 Revert Bug (코드가 조용히 되돌아감)&lt;/h3&gt;

&lt;p&gt;2026년 초~중반 커뮤니티에서 많이 보고된 이슈입니다. Agent Review 탭·클라우드 동기화(OneDrive/iCloud)·Format on Save가 겹치면 &lt;strong&gt;저장이 되돌아간 것처럼&lt;/strong&gt; 보일 수 있습니다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;완화 팁(커뮤니티 공통)&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Agent 세션 중 &lt;strong&gt;Agent Review 탭을 닫고&lt;/strong&gt; “Fix in Chat” 사용&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;li&gt;이상 징후가 있으면 Cursor 공식 changelog/forum 이슈 확인&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;62-rules만-믿고-리뷰를-생략하지-않기&quot;&gt;6.2 Rules만 믿고 리뷰를 생략하지 않기&lt;/h3&gt;

&lt;p&gt;Rules는 “실수 확률”을 낮출 뿐, &lt;strong&gt;보안·비즈니스 로직·엣지케이스&lt;/strong&gt;를 대신 검증해 주지는 않습니다. 최종 게이트는 여전히 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;git diff&lt;/code&gt; + 테스트입니다.&lt;/p&gt;

&lt;h3 id=&quot;63-비밀키pii를-채팅에-넣지-않기&quot;&gt;6.3 비밀키·PII를 채팅에 넣지 않기&lt;/h3&gt;

&lt;p&gt;로그, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.env&lt;/code&gt;, 고객 데이터를 그대로 붙이는 실수는 팀 어디서나 반복됩니다. &lt;strong&gt;마스킹된 샘플&lt;/strong&gt;과 &lt;strong&gt;재현 최소 케이스&lt;/strong&gt;만 공유하는 습관이 필요합니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;7-한-단계-더-mcp병렬-작업자동화&quot;&gt;7. 한 단계 더: MCP·병렬 작업·자동화&lt;/h2&gt;

&lt;p&gt;조금 더 나아간 팀/파워유저 팁입니다.&lt;/p&gt;

&lt;h3 id=&quot;71-mcp로-외부-도구-연결&quot;&gt;7.1 MCP로 “외부 도구” 연결&lt;/h3&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.cursor/mcp.json&lt;/code&gt;에 MCP 서버를 연결하면, 문서·이슈 트래커·DB 스키마 등을 &lt;strong&gt;에이전트가 직접 조회&lt;/strong&gt;하는 흐름을 만들 수 있습니다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;왜 쓰나&lt;/strong&gt;: “최신 문서/티켓/스키마”를 채팅에 수동으로 붙이는 비용을 줄입니다.&lt;/p&gt;

&lt;h3 id=&quot;72-git-worktree로-실험-분리&quot;&gt;7.2 git worktree로 실험 분리&lt;/h3&gt;

&lt;p&gt;큰 리팩터나 실험 브랜치를 &lt;strong&gt;worktree&lt;/strong&gt;로 분리하면, Agent가 main 작업 트리를 건드리지 않고 병렬로 돌리기 쉽습니다.&lt;/p&gt;

&lt;h3 id=&quot;73-automations-이벤트-기반-에이전트&quot;&gt;7.3 Automations (이벤트 기반 에이전트)&lt;/h3&gt;

&lt;p&gt;Cursor 2.6 전후로 강조되는 흐름: PR 생성, Slack 메시지, 스케줄(cron) 등 &lt;strong&gt;트리거 기반&lt;/strong&gt;으로 리뷰·테스트·티켓 생성을 자동화.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;주의&lt;/strong&gt;: 자동화는 편하지만, &lt;strong&gt;권한·비용·잘못된 PR 생성&lt;/strong&gt; 리스크가 있으므로 작은 태스크부터 시작하는 편이 안전합니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;8-바로-복붙하는-일일-cursor-체크리스트&quot;&gt;8. 바로 복붙하는 “일일 Cursor 체크리스트”&lt;/h2&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;[시작 전]
- Rules / cursorignore 최신화?
- 이번 작업 범위(파일/모듈) 정의했나?
- Agent 전 커밋했나?

[실행]
- 작은 수정 → Cmd+K / 큰 변경 → Plan → Agent
- @file @docs 로 근거 고정
- 모델/채팅은 작업 단위로 유지

[끝]
- git diff 라인 리뷰
- 테스트/린트 통과
- 20메시지 넘었으면 Fresh Chat + 요약
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;9-정리--cursor-꿀팁은-기능이-아니라-운영이다&quot;&gt;9. 정리 — Cursor 꿀팁은 “기능”이 아니라 “운영”이다&lt;/h2&gt;

&lt;p&gt;커뮤니티가 공유하는 Cursor 꿀팁의 공통점은 분명합니다.&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;프로젝트 헌법&lt;/strong&gt;(Rules)으로 AI 기본 행동을 고정하고&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;작업 크기에 맞는 모드&lt;/strong&gt;(Ask / Cmd+K / Plan / Agent)를 고르고&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;컨텍스트·비용·Git 습관&lt;/strong&gt;으로 실수 비용을 낮춘다&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Cursor를 “더 똑똑한 자동완성”으로만 쓰면 체감이 빨리 한계에 닿습니다. &lt;strong&gt;시니어 개발자처럼 일하는 운영 습관&lt;/strong&gt;을 얹을 때, 팀 전체 속도가 올라갑니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;참고-자료&quot;&gt;참고 자료&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;Cursor 공식 문서: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://docs.cursor.com&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;Cursor Changelog: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://cursor.com/changelog&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;커뮤니티 팁 저장소(영문): &lt;a href=&quot;https://github.com/murataslan1/cursor-ai-tips&quot;&gt;murataslan1/cursor-ai-tips&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;Rules 예시 모음: &lt;a href=&quot;https://github.com/PatrickJS/awesome-cursorrules&quot;&gt;PatrickJS/awesome-cursorrules&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;Reddit: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://www.reddit.com/r/cursor/&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;관련-글&quot;&gt;관련 글&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/04/09/ai-prompt-engineering-for-developers-guide/&quot;&gt;개발자를 위한 AI 프롬프트 엔지니어링 실전 가이드&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/2026/05/28/esc-equalizer-socratic-chain-prompting/&quot;&gt;ESC(Equalizer + Socratic + Chain) Prompting&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content><author><name>updaun</name></author><category term="cursor" /><category term="developer-tools" /><category term="cursor" /><category term="ai-coding" /><category term="productivity" /><category term="agent" /><category term="plan-mode" /><category term="cursor-rules" /><category term="mcp" /><category term="developer-tips" /><summary type="html">Cursor는 &apos;질만 잘하면&apos; 되는 도구가 아닙니다. Rules, Plan Mode, @컨텍스트, Fresh Chat 같은 운영 습관이 쌓여야 체감 속도가 납니다. 커뮤니티에서 반복적으로 검증된 팁만 골라 실전 가이드로 모았습니다.</summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-05-29-cursor-developer-tips-community-collection.webp" /><media:content medium="image" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-05-29-cursor-developer-tips-community-collection.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">ESC(Equalizer + Socratic + Chain) Prompting — 일관된 결과를 만드는 실전 프롬프트 프레임워크</title><link href="https://updaun.github.io/ai/prompt/2026/05/28/esc-equalizer-socratic-chain-prompting/" rel="alternate" type="text/html" title="ESC(Equalizer + Socratic + Chain) Prompting — 일관된 결과를 만드는 실전 프롬프트 프레임워크" /><published>2026-05-28T00:00:00+09:00</published><updated>2026-05-28T00:00:00+09:00</updated><id>https://updaun.github.io/ai/prompt/2026/05/28/esc-equalizer-socratic-chain-prompting</id><content type="html" xml:base="https://updaun.github.io/ai/prompt/2026/05/28/esc-equalizer-socratic-chain-prompting/">&lt;h1 id=&quot;escequalizer--socratic--chain-prompting--일관된-결과를-만드는-실전-프롬프트-프레임워크&quot;&gt;ESC(Equalizer + Socratic + Chain) Prompting — 일관된 결과를 만드는 실전 프롬프트 프레임워크&lt;/h1&gt;

&lt;p&gt;LLM을 쓰다 보면 이런 순간이 자주 옵니다.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;같은 요청을 했는데 결과가 들쭉날쭉하다&lt;br /&gt;
답이 “그럴듯”한데, 내 상황/제약을 잘못 가정한다&lt;br /&gt;
한 번에 끝내려다가, 수정 요청을 여러 번 왕복한다&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;이럴 때 도움이 되는 프롬프트 운영 방식이 &lt;strong&gt;ESC (Equalizer + Socratic + Chain) Prompting&lt;/strong&gt;입니다.&lt;br /&gt;
핵심은 “좋은 문장 하나”를 만드는 게 아니라, &lt;strong&gt;대화를 3단계로 설계해서 품질을 안정화&lt;/strong&gt;하는 것입니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;esc-prompting-한-문장-정의&quot;&gt;ESC Prompting 한 문장 정의&lt;/h2&gt;

&lt;p&gt;ESC는 아래 흐름으로 LLM과 일을 나누는 프레임워크입니다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Equalizer&lt;/strong&gt;: 목표/제약/용어/출력형식을 먼저 맞춰서(정렬해서) 오해를 줄인다&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Socratic&lt;/strong&gt;: 질문을 통해 요구사항을 명확히 하고, 빠진 정보/리스크를 드러낸다&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Chain&lt;/strong&gt;: 작업을 단계로 쪼개어 계획→실행→검증 순서로 결과물을 만든다&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;왜-esc를-써야-하나-프롬프트가-안정해지는-이유&quot;&gt;왜 ESC를 써야 하나 (프롬프트가 “안정”해지는 이유)&lt;/h2&gt;

&lt;p&gt;LLM 결과가 흔들리는 큰 이유는 대체로 세 가지입니다.&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;/li&gt;
  &lt;li&gt;&lt;strong&gt;작업이 한 번에 너무 큼&lt;/strong&gt;: 큰 문제를 한 번에 요청하면, 중요한 누락이 생기기 쉬움&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;ESC는 이 문제를 구조적으로 막습니다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Equalizer&lt;/strong&gt;가 “추측 기반 답변”을 줄이고&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Socratic&lt;/strong&gt;이 “필수 질문”을 앞당기며&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Chain&lt;/strong&gt;이 “한 번에 큰 실수” 대신 “작은 단계의 누적”으로 완성도를 올립니다&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;1-equalizer-기준을-먼저-맞추는-단계&quot;&gt;1) Equalizer: 기준을 먼저 맞추는 단계&lt;/h2&gt;

&lt;p&gt;Equalizer는 말 그대로 모델과 &lt;strong&gt;기준을 동일하게 맞추는 단계&lt;/strong&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;/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;li&gt;&lt;strong&gt;성공 기준&lt;/strong&gt;: 무엇이 “완료”인지&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;예시 템플릿:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;[Equalizer]
목표: (무엇을 얻고 싶은지)
배경/맥락: (현재 상태, 문제 상황)
환경: (언어/프레임워크/버전/OS/도구)
제약: (시간, 정책, 금지사항, 선호)
출력 형식: (예: 마크다운 포스트, 섹션 헤딩, 예시 포함)
성공 기준: (검증 가능한 완료 조건)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;2-socratic-답을-내기-전에-필수-질문을-하는-단계&quot;&gt;2) Socratic: 답을 내기 전에 “필수 질문”을 하는 단계&lt;/h2&gt;

&lt;p&gt;Socratic은 “바로 답하기”를 잠깐 멈추고, &lt;strong&gt;성공에 필요한 질문을 먼저 던지는 단계&lt;/strong&gt;입니다.&lt;br /&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;: 가장 중요한 1~2개는?&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;li&gt;&lt;strong&gt;예시/반례&lt;/strong&gt;: 원하는 예/원하지 않는 예는?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;예시 템플릿:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;[Socratic]
정확도를 위해 5~8개 질문만 먼저 할게요.
1) (핵심 모호점 질문)
2) (환경/버전/데이터 확인)
3) (범위/우선순위)
4) (금지사항/정책/보안)
5) (원하는 출력 예시/톤)
질문에 답하면 그 다음 단계로 바로 실행할게요.
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;blockquote&gt;
  &lt;p&gt;“질문이 많아지면 느려지는 것 아닌가?”&lt;br /&gt;
오히려 반대인 경우가 많습니다. &lt;strong&gt;질문 5개로 왕복 10번을 줄이는&lt;/strong&gt; 구조라서요.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;3-chain-계획실행검증을-연쇄로-진행하는-단계&quot;&gt;3) Chain: 계획→실행→검증을 ‘연쇄’로 진행하는 단계&lt;/h2&gt;

&lt;p&gt;Chain은 작업을 &lt;strong&gt;작은 단계로 쪼개어&lt;/strong&gt; 진행합니다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Plan&lt;/strong&gt;: 단계별 계획(짧게)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Do&lt;/strong&gt;: 단계별 결과물&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Check&lt;/strong&gt;: 누락/모순/테스트/검증 체크&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Deliver&lt;/strong&gt;: 최종 산출물(요구 형식대로)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;예시 템플릿:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;[Chain]
Step 1) 계획: (3~6단계로 짧게)
Step 2) 실행: (각 단계 결과물 생성)
Step 3) 검증: (체크리스트로 누락/모순 확인)
Step 4) 최종 출력: (요구한 포맷으로 정리)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;esc를-복붙해서-바로-쓰는-마스터-프롬프트&quot;&gt;ESC를 “복붙해서” 바로 쓰는 마스터 프롬프트&lt;/h2&gt;

&lt;p&gt;아래 블록을 그대로 복사해서, 괄호만 채우면 됩니다.&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;너는 (역할: 예: 시니어 엔지니어/기술 작가/PM)다.

[Equalizer]
목표: (내가 원하는 최종 결과)
배경/맥락: (현재 상황/문제/대상)
환경: (언어/프레임워크/버전/OS/도구)
제약: (시간, 정책, 금지사항, 선호)
출력 형식: (예: 마크다운, 섹션 구조, 톤, 길이)
성공 기준: (완료 조건)

[Socratic]
답을 만들기 전, 성공에 꼭 필요한 질문을 5~8개만 해줘.
각 질문은 “왜 필요한지”를 한 줄로 덧붙여줘.

[Chain]
내 답변을 받으면,
1) 짧은 실행 계획을 제시하고
2) 그 계획에 따라 결과물을 만들고
3) 마지막에 체크리스트로 검증해서
4) 최종 결과물만 깔끔하게 출력해줘.
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;활용-예시-1-오늘자-블로그-포스트를-안정적으로-쓰기&quot;&gt;활용 예시 1) “오늘자 블로그 포스트”를 안정적으로 쓰기&lt;/h2&gt;

&lt;p&gt;목표가 “글 작성”일 때 ESC가 특히 강한 이유는, 글은 &lt;strong&gt;톤/구성/독자/범위&lt;/strong&gt;가 흔들리면 품질이 급락하기 때문입니다.&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;너는 기술 블로그 에디터다.

[Equalizer]
목표: ESC(Equalizer+Socratic+Chain) Prompting에 대한 오늘자 포스트 초안을 작성
배경/맥락: LLM 사용 시 결과가 들쭉날쭉해지는 문제를 해결하고 싶음
환경: 한국어, Jekyll 마크다운 포스트
제약: 과장 금지, 실무 관점, 예시 3개 이상 포함
출력 형식: (1) 제목 (2) 요약 (3) 본문 헤딩 구조 (4) 예시 프롬프트 블록 (5) 마무리
성공 기준: 초보자도 바로 따라할 수 있고, 왜 써야 하는지 납득됨

[Socratic]
글이 실제로 도움되려면 필요한 확인 질문 6개만 해줘.

[Chain]
질문 답변을 받으면, 섹션별로 글을 완성해줘.
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;왜 ESC가 좋나&lt;/strong&gt;: 글을 “그럴듯하게” 쓰는 게 아니라, &lt;strong&gt;독자/범위/형식&lt;/strong&gt;을 먼저 맞춰서 편집 비용을 줄입니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;활용-예시-2-디버깅원인-분석로그-기반에서의-esc&quot;&gt;활용 예시 2) 디버깅/원인 분석(로그 기반)에서의 ESC&lt;/h2&gt;

&lt;p&gt;디버깅은 “정보 부족”이 가장 큰 적입니다. ESC는 여기서 &lt;strong&gt;질문을 강제&lt;/strong&gt;하고, &lt;strong&gt;재현/검증 단계를 체계화&lt;/strong&gt;합니다.&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;너는 시니어 백엔드 엔지니어다.

[Equalizer]
목표: 에러의 원인을 좁히고, 재현/수정/검증 플랜을 만든다
배경/맥락: (에러 메시지, 발생 시점, 영향 범위)
환경: (언어/프레임워크/버전/인프라: 예: Django 5, Postgres 16, Nginx)
제약: 프로덕션에서 위험한 변경 금지, 로그/메트릭 기반으로 우선 진단
출력 형식: (1) 가설 Top 3 (2) 확인 질문 (3) 진단 순서 (4) 수정안 (5) 검증 체크리스트
성공 기준: “다음 액션”이 명확하고, 실패해도 다음 분기가 준비됨

[Socratic]
원인 규명을 위해 꼭 필요한 질문을 우선순위대로 7개만 해줘.

[Chain]
답변을 받으면, 가장 싼 검증부터 순서대로 진단 플랜을 작성해줘.
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;왜 ESC가 좋나&lt;/strong&gt;: 디버깅은 “답”보다 “절차”가 중요합니다. ESC는 절차를 자동으로 만들게 해서 &lt;strong&gt;랜덤 워크(찍기)를 줄입니다.&lt;/strong&gt;&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;활용-예시-3-요구사항이-자주-바뀌는-기능-기획명세에서의-esc&quot;&gt;활용 예시 3) 요구사항이 자주 바뀌는 기능 기획/명세에서의 ESC&lt;/h2&gt;

&lt;p&gt;기획/명세는 “말이 바뀌는” 게 아니라, &lt;strong&gt;초기 Equalizer가 없어서 기준이 없던 것&lt;/strong&gt;인 경우가 많습니다.&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;너는 제품 기획자 겸 테크 리드다.

[Equalizer]
목표: 기능 요구사항을 PRD 수준으로 명확히 정리
배경/맥락: (기능 아이디어, 해결하려는 문제)
환경: (서비스 형태, 사용자, 플랫폼)
제약: 일정 (예: 2주 MVP), 인력(예: FE 1, BE 1), 정책/보안(예: 개인정보 최소화)
출력 형식: 문제정의 → 목표/비목표 → 유저 스토리 → 화면/플로우 → 데이터/이벤트 → 엣지케이스 → 수용 기준
성공 기준: 개발자가 구현 범위를 오해하지 않음

[Socratic]
PRD를 쓰기 위해 모호한 지점을 질문 8개만 해줘.

[Chain]
답변 후 PRD 초안을 작성하고, 마지막에 “누락 가능성 높은 항목” 체크리스트를 붙여줘.
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;왜 ESC가 좋나&lt;/strong&gt;: “좋은 문서”가 아니라 &lt;strong&gt;오해를 줄이는 문서&lt;/strong&gt;를 만들게 해줍니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;esc-운영-팁-실전에서-더-잘-먹히는-방법&quot;&gt;ESC 운영 팁 (실전에서 더 잘 먹히는 방법)&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Equalizer는 짧게, 그러나 단호하게&lt;/strong&gt;: 형식/제약/성공 기준은 초반에 못 박을수록 이득&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Socratic 질문 개수 제한&lt;/strong&gt;: 5~8개로 제한하면 템포가 좋아짐(과도한 질문 방지)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Chain에서 ‘검증’을 빼먹지 않기&lt;/strong&gt;: 마지막 체크리스트가 품질을 올리는 핵심&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;반례를 한 줄 넣기&lt;/strong&gt;: “이런 답은 원치 않는다” 한 줄이 결과를 크게 바꿈&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

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

&lt;p&gt;ESC Prompting은 “프롬프트 문장”이 아니라 &lt;strong&gt;프롬프트 운영 방식&lt;/strong&gt;입니다.&lt;br /&gt;
기준을 맞추고(Equalizer), 질문으로 모호함을 제거하고(Socratic), 단계적으로 완성(Chain)하면 &lt;strong&gt;결과의 일관성과 재현성&lt;/strong&gt;이 크게 올라갑니다.&lt;/p&gt;

&lt;p&gt;다음번에 LLM이 자꾸 “잘못 알아서” 답을 만들거나, 수정 요청을 여러 번 왕복하고 있다면, 오늘부터는 한 번 &lt;strong&gt;ESC로 대화를 설계&lt;/strong&gt;해보세요.&lt;/p&gt;</content><author><name>updaun</name></author><category term="ai" /><category term="prompt" /><category term="prompt-engineering" /><category term="esc" /><category term="equalizer" /><category term="socratic" /><category term="chain-of-thought" /><category term="llm" /><category term="workflow" /><summary type="html">ESC(Equalizer + Socratic + Chain) Prompting — 일관된 결과를 만드는 실전 프롬프트 프레임워크</summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-05-28-esc-equalizer-socratic-chain-prompting.webp" /><media:content medium="image" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-05-28-esc-equalizer-socratic-chain-prompting.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Django + Next.js 웹앱을 iOS 앱으로 배포하기 — 2026 App Store / TestFlight 실전 가이드</title><link href="https://updaun.github.io/ios/django/2026/05/27/ios-app-deploy-for-django-nextjs/" rel="alternate" type="text/html" title="Django + Next.js 웹앱을 iOS 앱으로 배포하기 — 2026 App Store / TestFlight 실전 가이드" /><published>2026-05-27T00:00:00+09:00</published><updated>2026-05-27T00:00:00+09:00</updated><id>https://updaun.github.io/ios/django/2026/05/27/ios-app-deploy-for-django-nextjs</id><content type="html" xml:base="https://updaun.github.io/ios/django/2026/05/27/ios-app-deploy-for-django-nextjs/">&lt;h1 id=&quot;django--nextjs-웹앱을-ios-앱으로-배포하기--2026-app-store--testflight-실전-가이드&quot;&gt;Django + Next.js 웹앱을 iOS 앱으로 배포하기 — 2026 App Store / TestFlight 실전 가이드&lt;/h1&gt;

&lt;p&gt;Django + Next.js로 웹앱을 이미 잘 만들고 있는데, iOS 앱도 필요해지는 순간이 옵니다.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;“iOS는 TWA 같은 거 없나?”&lt;br /&gt;
“웹앱 그대로 감싸서 App Store에 올릴 수 있나?”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;결론부터 말하면, &lt;strong&gt;iOS는 Android(TWA)처럼 ‘Chrome 기반 전체화면’ 배포가 없습니다.&lt;/strong&gt;&lt;br /&gt;
대신 현실적인 선택지는 크게 두 가지입니다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;PWA로 끝내기(스토어 미배포)&lt;/strong&gt;: Safari “홈 화면에 추가”로 설치 유도. 빠르고 싸지만 App Store에는 올라가지 않음.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;웹앱을 iOS 앱으로 래핑해서 App Store 배포&lt;/strong&gt;: 보통 &lt;strong&gt;Capacitor(iOS WKWebView)&lt;/strong&gt; 로 감싸서 배포.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;이 글은 &lt;strong&gt;Django + Next.js 웹앱을 Capacitor로 iOS 앱으로 만든 뒤 TestFlight → App Store&lt;/strong&gt;까지 배포하는 과정을 “처음 해보는 기준”으로 아주 상세히 정리합니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;0-ios-배포에서-가장-중요한-현실-체크&quot;&gt;0. iOS 배포에서 가장 중요한 현실 체크&lt;/h2&gt;

&lt;h3 id=&quot;01-mac이-사실상-필수&quot;&gt;0.1 Mac이 사실상 필수&lt;/h3&gt;

&lt;p&gt;iOS 앱을 App Store에 올리려면 &lt;strong&gt;Xcode&lt;/strong&gt;로 아카이브/서명/업로드를 해야 하는데, Xcode는 macOS에서만 동작합니다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;개인 Mac / 회사 Mac&lt;/strong&gt;: 가장 편함&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Mac 빌드 서버(클라우드 Mac)&lt;/strong&gt;: 가능하지만 서명/키체인 등 난이도가 올라감&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;02-apple-developer-program-비용&quot;&gt;0.2 Apple Developer Program 비용&lt;/h3&gt;

&lt;p&gt;App Store 배포를 위해서는 Apple Developer Program 가입이 필요합니다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;연 $99&lt;/strong&gt;(연간 구독)&lt;br /&gt;
(Apple 공식 안내: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://developer.apple.com/support/compare-memberships&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;03-2026년-sdkxcode-요구사항&quot;&gt;0.3 2026년 SDK/Xcode 요구사항&lt;/h3&gt;

&lt;p&gt;Apple은 특정 시점 이후 &lt;strong&gt;App Store Connect에 업로드하는 앱은 특정 Xcode/SDK로 빌드해야 한다&lt;/strong&gt;는 요구사항을 둡니다.&lt;br /&gt;
2026년 4월 28일 이후에는 &lt;strong&gt;Xcode 26+&lt;/strong&gt; 및 해당 플랫폼 SDK(iOS 26 등)로 빌드해야 업로드가 가능하다고 공지되어 있습니다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;참고: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://developer.apple.com/news/upcoming-requirements/?id=02032026a&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
  &lt;p&gt;사용자의 최소 지원 iOS 버전(예: iOS 16+)과는 다른 이야기입니다. “빌드에 사용하는 SDK” 요구사항이라고 이해하면 됩니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;1-방법-선택-원격-url-vs-정적-번들-capacitor&quot;&gt;1. 방법 선택: “원격 URL” vs “정적 번들” (Capacitor)&lt;/h2&gt;

&lt;p&gt;Capacitor로 iOS 앱을 만들 때 Next.js를 어떻게 넣을지 먼저 결정해야 합니다.&lt;/p&gt;

&lt;h3 id=&quot;11-원격-url-방식추천-ssr-유지&quot;&gt;1.1 원격 URL 방식(추천: SSR 유지)&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;앱이 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://app.example.com&lt;/code&gt; 같은 웹 URL을 WKWebView로 로딩&lt;/strong&gt;합니다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;장점&lt;/strong&gt;
    &lt;ul&gt;
      &lt;li&gt;Next.js SSR / Server Actions / API Route를 그대로 사용 가능&lt;/li&gt;
      &lt;li&gt;웹 배포 = 앱 화면 즉시 반영 (스토어 심사 없이 UI 수정 가능)&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;단점&lt;/strong&gt;
    &lt;ul&gt;
      &lt;li&gt;오프라인 경험은 제한적&lt;/li&gt;
      &lt;li&gt;네트워크 품질이 UX에 직접 영향&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;12-정적-번들-방식주의-ssr-포기&quot;&gt;1.2 정적 번들 방식(주의: SSR 포기)&lt;/h3&gt;

&lt;p&gt;Next.js를 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;output: &apos;export&apos;&lt;/code&gt;로 정적 빌드(out/)해서 앱에 번들링합니다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;장점&lt;/strong&gt;
    &lt;ul&gt;
      &lt;li&gt;초기 로딩이 안정적&lt;/li&gt;
      &lt;li&gt;네트워크가 느려도 “앱 껍데기”는 뜸&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;단점&lt;/strong&gt;
    &lt;ul&gt;
      &lt;li&gt;Next.js의 SSR, API Route 등 서버 기능을 쓸 수 없음(별도 백엔드 호출로 전환 필요)&lt;/li&gt;
      &lt;li&gt;웹 UI 수정 시 앱을 다시 빌드/업로드해야 할 가능성이 큼&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;이 글에서는 &lt;strong&gt;Django + Next.js 조합에서 가장 현실적인 “원격 URL 방식”을 메인&lt;/strong&gt;으로 설명하고, 정적 번들은 “선택지”로만 덧붙입니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;2-앱-패키징웹ios-capacitor-기본-셋업&quot;&gt;2. 앱 패키징(웹→iOS): Capacitor 기본 셋업&lt;/h2&gt;

&lt;h3 id=&quot;21-nextjs-쪽-준비공통&quot;&gt;2.1 Next.js 쪽 준비(공통)&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;HTTPS 배포(필수)&lt;/li&gt;
  &lt;li&gt;모바일 화면 대응(뷰포트, 폰트, 터치 영역)&lt;/li&gt;
  &lt;li&gt;로그인/결제 등 앱 내 플로우에서 Safari 외부로 튀지 않도록 링크 처리&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;그리고 &lt;strong&gt;Django API 도메인&lt;/strong&gt;과 &lt;strong&gt;Next.js 프론트 도메인&lt;/strong&gt;은 분리해두는 것이 좋습니다.&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Next.js: https://app.example.com
Django API: https://api.example.com
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;22-capacitor-설치&quot;&gt;2.2 Capacitor 설치&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;npm &lt;span class=&quot;nb&quot;&gt;install&lt;/span&gt; @capacitor/core @capacitor/cli
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;23-초기화원격-url-방식&quot;&gt;2.3 초기화(원격 URL 방식)&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;npx cap init &lt;span class=&quot;s2&quot;&gt;&quot;내서비스&quot;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;com.example.myapp&quot;&lt;/span&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;capacitor.config.ts&lt;/code&gt;를 만들고, 서버 URL을 지정합니다.&lt;/p&gt;

&lt;div class=&quot;language-ts 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;c1&quot;&gt;// capacitor.config.ts&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;type&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;CapacitorConfig&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;@capacitor/cli&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;CapacitorConfig&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;appId&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;com.example.myapp&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;appName&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;내서비스&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;webDir&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;out&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;// 원격 URL이면 사실상 사용 비중 낮음&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;server&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;https://app.example.com&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;cleartext&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;false&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;};&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;default&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;blockquote&gt;
  &lt;p&gt;개발 중에는 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;server.url&lt;/code&gt;을 로컬/프리뷰 URL로 바꿔서 빠르게 테스트할 수 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;24-ios-플랫폼-추가&quot;&gt;2.4 iOS 플랫폼 추가&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;npm &lt;span class=&quot;nb&quot;&gt;install&lt;/span&gt; @capacitor/ios
npx cap add ios
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;이제 iOS 프로젝트(&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ios/&lt;/code&gt;)가 생성됩니다.&lt;/p&gt;

&lt;h3 id=&quot;25-xcode로-열기&quot;&gt;2.5 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;npx cap open ios
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Xcode가 열리면 좌측 프로젝트 네비게이터에서 App 타겟을 선택하고:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Bundle Identifier&lt;/strong&gt;: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;com.example.myapp&lt;/code&gt; (Play와 마찬가지로 바꾸기 어렵습니다)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Version / Build&lt;/strong&gt;: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;1.0.0&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;1&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Team&lt;/strong&gt;: Apple Developer Program 팀 선택&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Automatically manage signing&lt;/strong&gt;: 켜기(초보자에게 강력 추천)&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;3-django--nextjs에서-ios용으로-꼭-챙길-것&quot;&gt;3. Django + Next.js에서 iOS용으로 꼭 챙길 것&lt;/h2&gt;

&lt;h3 id=&quot;31-인증-쿠키-기반-세션-vs-jwt&quot;&gt;3.1 인증: 쿠키 기반 세션 vs JWT&lt;/h3&gt;

&lt;p&gt;WKWebView 안에서 쿠키 세션도 가능하지만, 실제 운영에서는 &lt;strong&gt;JWT(토큰) 방식&lt;/strong&gt;이 더 예측 가능할 때가 많습니다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;쿠키 세션&lt;/strong&gt;: CSRF, SameSite, 도메인 분리 시 난이도 상승&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;JWT&lt;/strong&gt;: 모바일(웹뷰)에서 저장/갱신 흐름을 명확히 만들기 쉬움&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;어떤 방식을 쓰든 &lt;strong&gt;“app.example.com”과 “api.example.com” 사이의 인증/쿠키 정책&lt;/strong&gt;을 iOS WebView 환경에서 한 번은 꼭 점검하세요.&lt;/p&gt;

&lt;h3 id=&quot;32-cors&quot;&gt;3.2 CORS&lt;/h3&gt;

&lt;p&gt;원격 URL 방식이면 브라우저와 동일하게 동작합니다.&lt;/p&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# settings.py
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CORS_ALLOWED_ORIGINS&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
  &lt;span class=&quot;s&quot;&gt;&quot;https://app.example.com&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;33-딥링크앱-내부-링크-전략&quot;&gt;3.3 딥링크(앱 내부 링크) 전략&lt;/h3&gt;

&lt;p&gt;iOS에서 알림이나 링크 클릭 시 앱 특정 화면으로 보내고 싶으면 대체로:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;웹 딥링크 URL&lt;/strong&gt;을 통일(예: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/orders/123&lt;/code&gt;)&lt;/li&gt;
  &lt;li&gt;앱은 그 URL을 그대로 WebView에 로딩&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;이 구조가 Django/Next.js 조합에서는 가장 단순하고 유지보수성이 좋습니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;4-app-store-connect-준비-앱-정보가-심사의-절반&quot;&gt;4. App Store Connect 준비: “앱 정보”가 심사의 절반&lt;/h2&gt;

&lt;p&gt;App Store 심사는 코드만 보는 게 아니라 “메타데이터”와 “정책 대응”이 큰 비중을 차지합니다.&lt;/p&gt;

&lt;h3 id=&quot;41-app-store-connect에서-앱-생성&quot;&gt;4.1 App Store Connect에서 앱 생성&lt;/h3&gt;

&lt;ol&gt;
  &lt;li&gt;App Store Connect 로그인&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;My Apps → New App&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;플랫폼 iOS 선택, 번들 ID 선택, SKU 입력&lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;42-privacy-policy-url은-무조건-필요&quot;&gt;4.2 Privacy Policy URL은 ‘무조건’ 필요&lt;/h3&gt;

&lt;p&gt;Apple은 &lt;strong&gt;모든 앱에 Privacy Policy URL을 요구&lt;/strong&gt;합니다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;공식 문서: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://developer.apple.com/help/app-store-connect/manage-app-information/manage-app-privacy/&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;App Privacy Details 안내: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://developer.apple.com/app-store/app-privacy-details/&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
  &lt;p&gt;기능이 단순하고 개인정보를 “수집하지 않는다” 하더라도, URL 자체는 필수입니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;43-app-privacy영양성분표-작성&quot;&gt;4.3 App Privacy(“영양성분표”) 작성&lt;/h3&gt;

&lt;p&gt;App Store Connect에서 앱이 수집하는 데이터 유형, 추적 여부 등을 선언해야 합니다.&lt;br /&gt;
여기서 거짓말하면 리젝/퇴출 리스크가 큽니다(서드파티 SDK 포함).&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;참고: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://developer.apple.com/app-store/app-privacy-details/&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;44-스크린샷-규격2026&quot;&gt;4.4 스크린샷 규격(2026)&lt;/h3&gt;

&lt;p&gt;App Store Connect는 스크린샷 사이즈를 엄격히 봅니다. 다행히 “큰 사이즈 1세트”만 올리면 자동 스케일되는 구조가 있어졌습니다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;공식 스펙(6.9&quot;/6.5&quot; 등): &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://developer.apple.com/help/app-store-connect/reference/screenshot-specifications&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;업로드 안내: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://developer.apple.com/help/app-store-connect/manage-app-information/upload-app-previews-and-screenshots&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;가장 안전한 추천(아이폰)&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;iPhone &lt;strong&gt;6.9&quot; 스크린샷&lt;/strong&gt;(예: 1320×2868, 1290×2796 등) 1~10장&lt;/li&gt;
  &lt;li&gt;6.9&quot;를 못 올리면 6.5&quot; 세트가 대체로 요구됨(공식 스펙에 명시)&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
  &lt;p&gt;iPad도 지원한다면 iPad 스크린샷도 요구될 수 있습니다. (Universal 앱이면 특히)&lt;/p&gt;
&lt;/blockquote&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;5-xcode-아카이브--업로드가장-많이-막히는-구간&quot;&gt;5. Xcode 아카이브 &amp;amp; 업로드(가장 많이 막히는 구간)&lt;/h2&gt;

&lt;h3 id=&quot;51-왜-아카이브archive가-필요한가&quot;&gt;5.1 왜 “아카이브(Archive)”가 필요한가&lt;/h3&gt;

&lt;p&gt;App Store에 올릴 바이너리는 그냥 Run 빌드가 아니라, &lt;strong&gt;Distribution용 Archive&lt;/strong&gt;를 만들어야 합니다.&lt;/p&gt;

&lt;p&gt;Xcode 메뉴:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;상단 Scheme을 “Any iOS Device (arm64)” 같은 디바이스 계열로 선택&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Product → Archive&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;52-서명signing-초보자에게는-자동-서명-추천&quot;&gt;5.2 서명(Signing): 초보자에게는 “자동 서명” 추천&lt;/h3&gt;

&lt;p&gt;대부분은 Xcode에서 아래만 제대로 맞추면 자동으로 풀립니다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Team이 올바른가?&lt;/li&gt;
  &lt;li&gt;Bundle Identifier가 App Store Connect의 App ID와 일치하는가?&lt;/li&gt;
  &lt;li&gt;Automatically manage signing이 켜져 있는가?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Apple은 자동 서명을 권장합니다(코드 사인 관련 기본 안내).&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;참고(자동 서명 개념): &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://developer.apple.com/library/archive/qa/qa1814/_index.html&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;53-app-store-connect-업로드&quot;&gt;5.3 App Store Connect 업로드&lt;/h3&gt;

&lt;p&gt;Archive가 생성되면 Organizer가 뜹니다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Distribute App → App Store Connect → Upload&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;업로드 후 App Store Connect에서 빌드가 “Processing” 상태로 몇 분~수십 분 걸릴 수 있습니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;6-testflight-배포실제-기기-검증-루프&quot;&gt;6. TestFlight 배포(실제 기기 검증 루프)&lt;/h2&gt;

&lt;p&gt;App Store에 바로 올리기 전에 &lt;strong&gt;TestFlight로 베타 배포&lt;/strong&gt;를 강력 추천합니다.&lt;/p&gt;

&lt;h3 id=&quot;61-내부외부-테스터-차이&quot;&gt;6.1 내부/외부 테스터 차이&lt;/h3&gt;

&lt;p&gt;Apple 공식 TestFlight 문서 기준:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Internal testers&lt;/strong&gt;: 최대 100명&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;External testers&lt;/strong&gt;: 최대 10,000명, 첫 빌드는 &lt;strong&gt;Beta App Review&lt;/strong&gt;가 필요할 수 있음&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;참고:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;TestFlight overview: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://developer.apple.com/help/app-store-connect/test-a-beta-version/testflight-overview/&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;TestFlight 소개: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://developer.apple.com/testflight/&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;62-가장-빠른-루트추천&quot;&gt;6.2 가장 빠른 루트(추천)&lt;/h3&gt;

&lt;ol&gt;
  &lt;li&gt;App Store Connect에 빌드 업로드&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;TestFlight → Internal Testing&lt;/strong&gt; 그룹 생성&lt;/li&gt;
  &lt;li&gt;팀원(Users and Access)을 내부 테스터로 추가&lt;/li&gt;
  &lt;li&gt;TestFlight에서 설치 후 실제 로그인/결제/푸시 등 확인&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
  &lt;p&gt;외부 테스터까지 가는 건 “첫 출시 전 QA” 때만 열어도 됩니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;7-app-store-출시심사-통과를-위한-체크리스트&quot;&gt;7. App Store 출시(심사 통과를 위한 체크리스트)&lt;/h2&gt;

&lt;h3 id=&quot;71-앱-심사에서-자주-걸리는-포인트&quot;&gt;7.1 앱 심사에서 자주 걸리는 포인트&lt;/h3&gt;

&lt;p&gt;웹앱 래핑(iOS WebView) 앱에서 특히 자주 걸리는 것들:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;로그인 계정이 필요한데 심사팀이 접근 불가&lt;/strong&gt;&lt;br /&gt;
→ App Review 정보에 테스트 계정/비밀번호 제공&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;결제/구독/디지털 상품&lt;/strong&gt; 관련 정책 위반(웹 결제를 우회하거나, 기능 제한 설명이 불명확)&lt;br /&gt;
→ Apple 가이드 준수(이 주제는 별도 포스트로 분리 추천)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Privacy Policy / App Privacy 미작성 또는 부정확&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;앱 내 링크가 Safari로 튀어 UX가 깨짐&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;빈 화면/오프라인/서버 점검 시 크래시&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;72-제출-흐름&quot;&gt;7.2 제출 흐름&lt;/h3&gt;

&lt;ol&gt;
  &lt;li&gt;App Store Connect에서 버전 생성&lt;/li&gt;
  &lt;li&gt;스크린샷/설명/키워드/카테고리/연령등급/Privacy 채우기&lt;/li&gt;
  &lt;li&gt;빌드 선택&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Submit for Review&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;8-운영-전략-웹-업데이트와-앱-업데이트를-분리하자&quot;&gt;8. 운영 전략: “웹 업데이트”와 “앱 업데이트”를 분리하자&lt;/h2&gt;

&lt;p&gt;원격 URL 방식(Capacitor WebView)으로 가면 운영이 이렇게 단순해집니다.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;일상적인 UI/기능 개선&lt;/strong&gt;: Next.js 배포 → iOS 앱 즉시 반영&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;앱 스토어 업데이트가 필요한 변경&lt;/strong&gt;:
    &lt;ul&gt;
      &lt;li&gt;앱 아이콘/스플래시 변경&lt;/li&gt;
      &lt;li&gt;네이티브 플러그인 추가(카메라, 푸시 등)&lt;/li&gt;
      &lt;li&gt;Bundle ID/Entitlements 변경&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;이 분리 전략이 Django + Next.js 개발자에게 iOS 배포 난이도를 크게 낮춰줍니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;9-선택-정적-번들-방식으로-가고-싶다면&quot;&gt;9. (선택) 정적 번들 방식으로 가고 싶다면&lt;/h2&gt;

&lt;p&gt;Next.js를 정적으로 export하고, 앱에 번들링하면 “초기 로딩”은 좋아질 수 있습니다. 하지만 Django + Next.js에서 SSR이나 API Route를 쓰고 있었다면 전환 비용이 큽니다.&lt;/p&gt;

&lt;p&gt;요약:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;next.config.ts&lt;/code&gt;에 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;output: &quot;export&quot;&lt;/code&gt; 적용&lt;/li&gt;
  &lt;li&gt;빌드 산출물(out/)을 Capacitor가 iOS 앱에 포함&lt;/li&gt;
  &lt;li&gt;SSR 로직은 Django API 호출로 재작성&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;이 경로는 “앱이 오프라인에서도 최소 화면을 보여줘야 한다” 같은 요구가 있을 때만 추천합니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;10-정리-django--nextjs-개발자에게-가장-좋은-ios-배포-루트&quot;&gt;10. 정리: Django + Next.js 개발자에게 가장 좋은 iOS 배포 루트&lt;/h2&gt;

&lt;p&gt;현실적인 우선순위는 보통 이렇습니다.&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;App Store가 꼭 필요한가?&lt;/strong&gt;
    &lt;ul&gt;
      &lt;li&gt;아니라면: PWA + 홈 화면 추가로도 충분한 경우가 많음&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;App Store가 필요하다면: &lt;strong&gt;Capacitor(원격 URL) + TestFlight → App Store&lt;/strong&gt;
    &lt;ul&gt;
      &lt;li&gt;SSR 유지, 웹 업데이트 즉시 반영, 유지보수 비용 최소&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;카메라/푸시/파일 등 네이티브 기능이 늘어나면: Capacitor 플러그인을 단계적으로 추가&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;iOS는 Android보다 “서명/프로비저닝/메타데이터”가 진입장벽이지만, 한 번 파이프라인을 만들면 이후 릴리즈는 빠르게 반복할 수 있습니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;참고-자료&quot;&gt;참고 자료&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;Apple Developer Program 가입/비용: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://developer.apple.com/support/compare-memberships&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;SDK/Xcode 업로드 요구사항(2026): &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://developer.apple.com/news/upcoming-requirements/?id=02032026a&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;TestFlight 개요: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://developer.apple.com/help/app-store-connect/test-a-beta-version/testflight-overview/&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;TestFlight 소개: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://developer.apple.com/testflight/&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;App Privacy(필수): &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://developer.apple.com/app-store/app-privacy-details/&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;Privacy Policy URL 요구(필수): &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://developer.apple.com/help/app-store-connect/manage-app-information/manage-app-privacy/&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;스크린샷 스펙(공식): &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://developer.apple.com/help/app-store-connect/reference/screenshot-specifications&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;스크린샷 업로드 가이드: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://developer.apple.com/help/app-store-connect/manage-app-information/upload-app-previews-and-screenshots&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;</content><author><name>updaun</name></author><category term="ios" /><category term="django" /><category term="django" /><category term="nextjs" /><category term="ios" /><category term="capacitor" /><category term="app-store" /><category term="testflight" /><category term="app-store-connect" /><summary type="html">Django + Next.js 웹앱을 iOS 앱으로 배포하기 — 2026 App Store / TestFlight 실전 가이드</summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-05-27-ios-app-deploy-for-django-nextjs.webp" /><media:content medium="image" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-05-27-ios-app-deploy-for-django-nextjs.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Django + Next.js 웹앱을 안드로이드 앱으로 만들어 플레이스토어에 배포하기 — 2026 실전 가이드</title><link href="https://updaun.github.io/android/django/2026/05/26/django-nextjs-android-app-deploy-guide/" rel="alternate" type="text/html" title="Django + Next.js 웹앱을 안드로이드 앱으로 만들어 플레이스토어에 배포하기 — 2026 실전 가이드" /><published>2026-05-26T00:00:00+09:00</published><updated>2026-05-26T00:00:00+09:00</updated><id>https://updaun.github.io/android/django/2026/05/26/django-nextjs-android-app-deploy-guide</id><content type="html" xml:base="https://updaun.github.io/android/django/2026/05/26/django-nextjs-android-app-deploy-guide/">&lt;h1 id=&quot;django--nextjs-웹앱을-안드로이드-앱으로-만들어-플레이스토어에-배포하기--2026-실전-가이드&quot;&gt;Django + Next.js 웹앱을 안드로이드 앱으로 만들어 플레이스토어에 배포하기 — 2026 실전 가이드&lt;/h1&gt;

&lt;p&gt;Django로 백엔드 API를 만들고, Next.js로 프론트를 구성하는 조합은 이미 익숙합니다. 문제는 여기서 “이걸 앱으로도 내고 싶다”고 생각하는 순간 시작됩니다.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Kotlin으로 처음부터 다시 짜야 하나? React Native로 포팅해야 하나?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;아닙니다.&lt;/strong&gt; 이미 돌아가는 웹앱이 있다면, 그걸 &lt;strong&gt;거의 그대로&lt;/strong&gt; 안드로이드 앱으로 만드는 방법이 있습니다. 이 글은 Django + Next.js 스택을 쓰는 개발자가 &lt;strong&gt;웹앱 코드 재작성 없이&lt;/strong&gt; 안드로이드 앱을 만들고, 구글 플레이스토어에 출시하기까지의 전체 과정을 다룹니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;방법-선택--twa-vs-capacitor-vs-네이티브&quot;&gt;방법 선택 — TWA vs Capacitor vs 네이티브&lt;/h2&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;strong&gt;TWA (Trusted Web Activity)&lt;/strong&gt;&lt;/th&gt;
      &lt;th&gt;&lt;strong&gt;Capacitor&lt;/strong&gt;&lt;/th&gt;
      &lt;th&gt;&lt;strong&gt;Kotlin 네이티브&lt;/strong&gt;&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;Chrome이 웹사이트를 전체 화면으로 렌더링&lt;/td&gt;
      &lt;td&gt;WebView 안에 정적 빌드를 번들링&lt;/td&gt;
      &lt;td&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;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;output: &apos;export&apos;&lt;/code&gt; 설정 필요&lt;/td&gt;
      &lt;td&gt;전부 새로 작성&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;네이티브 API&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Service Worker 범위 내 (알림, 오프라인)&lt;/td&gt;
      &lt;td&gt;카메라·GPS·파일시스템 등 플러그인&lt;/td&gt;
      &lt;td&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;1~5MB&lt;/td&gt;
      &lt;td&gt;5~20MB&lt;/td&gt;
      &lt;td&gt;기능에 따라 다름&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Django SSR&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;서버 렌더링 그대로 사용 가능&lt;/td&gt;
      &lt;td&gt;정적 export만 가능 (SSR 불가)&lt;/td&gt;
      &lt;td&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;/td&gt;
      &lt;td&gt;웹 코드 변경 시 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cap sync&lt;/code&gt; + 스토어 재배포&lt;/td&gt;
      &lt;td&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;SSR·SEO 필요, 웹과 동일한 경험&lt;/td&gt;
      &lt;td&gt;카메라·블루투스 등 디바이스 API 필요&lt;/td&gt;
      &lt;td&gt;복잡한 네이티브 UX&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;strong&gt;Part A&lt;/strong&gt;에서는 대부분의 Django + Next.js 앱에 가장 현실적인 &lt;strong&gt;TWA 방식&lt;/strong&gt;을 다루고, &lt;strong&gt;Part B&lt;/strong&gt;에서는 디바이스 네이티브 기능이 필요할 때 쓰는 &lt;strong&gt;Capacitor 방식&lt;/strong&gt;을 다룹니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;part-a-twa--웹앱을-거의-그대로-앱으로&quot;&gt;Part A: TWA — 웹앱을 거의 그대로 앱으로&lt;/h1&gt;

&lt;p&gt;TWA(Trusted Web Activity)는 &lt;strong&gt;Chrome이 내 웹사이트를 전체 화면으로 보여주는 네이티브 앱 껍데기&lt;/strong&gt;입니다. WebView가 아니라 실제 Chrome 엔진이 돌기 때문에 Service Worker, 푸시 알림, 오프라인 캐싱이 모두 동작합니다.&lt;/p&gt;

&lt;p&gt;핵심 장점: &lt;strong&gt;Django SSR, API Route, middleware — 모든 서버 기능을 그대로 쓸 수 있습니다.&lt;/strong&gt; 앱은 단지 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;https://your-domain.com&lt;/code&gt;을 Chrome으로 여는 것이고, Django 서버가 평소처럼 응답합니다.&lt;/p&gt;

&lt;h2 id=&quot;a-1-전제-조건&quot;&gt;A-1. 전제 조건&lt;/h2&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;✅ Django + Next.js 앱이 HTTPS로 배포되어 있음
✅ 도메인을 소유하고 있음 (예: app.example.com)
✅ Node.js 18+ 설치됨
✅ JDK 17+ 설치됨
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;a-2-nextjs를-pwa로-만들기&quot;&gt;A-2. Next.js를 PWA로 만들기&lt;/h2&gt;

&lt;p&gt;TWA가 동작하려면 웹앱이 &lt;strong&gt;PWA 설치 조건&lt;/strong&gt;을 충족해야 합니다:&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;HTTPS로 서빙&lt;/li&gt;
  &lt;li&gt;Web App Manifest (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;manifest.json&lt;/code&gt;)&lt;/li&gt;
  &lt;li&gt;Service Worker 등록&lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;a-2-1-web-app-manifest-생성&quot;&gt;A-2-1. Web App Manifest 생성&lt;/h3&gt;

&lt;p&gt;Next.js App Router에서는 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;app/manifest.ts&lt;/code&gt;를 만들면 자동으로 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/manifest.webmanifest&lt;/code&gt;를 생성합니다.&lt;/p&gt;

&lt;div class=&quot;language-typescript 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;c1&quot;&gt;// app/manifest.ts&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;type&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;MetadataRoute&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;next&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;default&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;manifest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;():&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;MetadataRoute&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;Manifest&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;내 서비스 이름&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;short_name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;내서비스&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;description&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;Django + Next.js 기반 서비스&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;start_url&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;/&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;display&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;standalone&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;background_color&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;#ffffff&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;theme_color&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;#6b5b95&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;orientation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;portrait&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;icons&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;src&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;/icons/icon-192x192.png&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;sizes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;192x192&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;image/png&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;purpose&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;maskable&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;src&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;/icons/icon-512x512.png&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;sizes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;512x512&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;image/png&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;};&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;display: &quot;standalone&quot;&lt;/code&gt; 필수&lt;/strong&gt;: TWA가 이 값을 보고 전체 화면 모드를 활성화합니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;a-2-2-아이콘-준비&quot;&gt;A-2-2. 아이콘 준비&lt;/h3&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;public/icons/&lt;/code&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;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;icon-192x192.png&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;manifest, Android 런처&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;icon-512x512.png&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;manifest, 스플래시&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;icon-maskable-192x192.png&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;적응형 아이콘 (안전 영역 고려)&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;maskable 아이콘&lt;/strong&gt;: Android가 원형·사각형 등 다양한 모양으로 잘라 쓰는 아이콘입니다. &lt;a href=&quot;https://maskable.app/editor&quot;&gt;maskable.app&lt;/a&gt;에서 미리보기 가능합니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;a-2-3-service-worker-등록&quot;&gt;A-2-3. Service Worker 등록&lt;/h3&gt;

&lt;p&gt;가장 간단한 방법 — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;public/sw.js&lt;/code&gt;에 최소 Service Worker를 만듭니다:&lt;/p&gt;

&lt;div class=&quot;language-javascript 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;c1&quot;&gt;// public/sw.js&lt;/span&gt;
&lt;span class=&quot;nb&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;addEventListener&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;install&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nb&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;skipWaiting&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;

&lt;span class=&quot;nb&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;addEventListener&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;activate&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;waitUntil&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;clients&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;claim&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;());&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;

&lt;span class=&quot;nb&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;addEventListener&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;fetch&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;respondWith&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;fetch&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;));&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;루트 레이아웃에서 등록:&lt;/p&gt;

&lt;div class=&quot;language-typescript 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;c1&quot;&gt;// app/layout.tsx 내 &amp;lt;head&amp;gt; 또는 useEffect&lt;/span&gt;
&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;use client&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;useEffect&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;react&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;ServiceWorkerRegister&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;useEffect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;serviceWorker&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;navigator&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;nb&quot;&gt;navigator&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;serviceWorker&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;register&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;/sw.js&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;scope&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;/&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[]);&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;오프라인 캐싱이 필요하다면&lt;/strong&gt;: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Serwist&lt;/code&gt;(&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@serwist/next&lt;/code&gt;) 또는 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;next-pwa&lt;/code&gt;를 쓰면 런타임 캐싱·프리캐싱을 자동 구성할 수 있습니다. 이 글에서는 TWA 등록 자체에 집중하므로 최소 구현만 다룹니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;a-2-4-pwa-검증&quot;&gt;A-2-4. PWA 검증&lt;/h3&gt;

&lt;p&gt;배포 후 Chrome DevTools → &lt;strong&gt;Application → Manifest&lt;/strong&gt; 탭에서:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;✅ “Installability” 항목이 모두 초록색&lt;/li&gt;
  &lt;li&gt;✅ Service Worker가 등록됨&lt;/li&gt;
  &lt;li&gt;✅ &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;display: standalone&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;또는 &lt;strong&gt;Lighthouse PWA 감사&lt;/strong&gt;를 돌려서 100점을 확인합니다.&lt;/p&gt;

&lt;h2 id=&quot;a-3-bubblewrap으로-twa-안드로이드-프로젝트-생성&quot;&gt;A-3. Bubblewrap으로 TWA 안드로이드 프로젝트 생성&lt;/h2&gt;

&lt;p&gt;Bubblewrap은 구글 크롬 팀이 만든 CLI로, PWA의 manifest를 읽어 &lt;strong&gt;TWA 안드로이드 프로젝트를 자동 생성&lt;/strong&gt;합니다.&lt;/p&gt;

&lt;h3 id=&quot;a-3-1-설치&quot;&gt;A-3-1. 설치&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;npm &lt;span class=&quot;nb&quot;&gt;install&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-g&lt;/span&gt; @aspect-build/aspect-cache 2&amp;gt;/dev/null
npm &lt;span class=&quot;nb&quot;&gt;install&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-g&lt;/span&gt; @aspect-build/bazelrc 2&amp;gt;/dev/null
npm &lt;span class=&quot;nb&quot;&gt;install&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-g&lt;/span&gt; @aspect-build/bazel 2&amp;gt;/dev/null
npm &lt;span class=&quot;nb&quot;&gt;install&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-g&lt;/span&gt; @aspect-build/webpack 2&amp;gt;/dev/null

npm &lt;span class=&quot;nb&quot;&gt;install&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-g&lt;/span&gt; @bubblewrap/cli
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;a-3-2-프로젝트-초기화&quot;&gt;A-3-2. 프로젝트 초기화&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;&lt;span class=&quot;nb&quot;&gt;mkdir &lt;/span&gt;my-app-twa &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;cd &lt;/span&gt;my-app-twa

bubblewrap init &lt;span class=&quot;nt&quot;&gt;--manifest&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;https://app.example.com/manifest.webmanifest
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Bubblewrap이 manifest를 읽고 대화형으로 물어봅니다:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;? Domain: app.example.com
? URL path: /
? Application name: 내 서비스 이름
? Short name: 내서비스
? Application ID: com.example.myapp       ← 플레이스토어에서 영구히 사용되는 ID
? Starting version code: 1
? Starting version name: 1.0.0
? Display mode: standalone
? Status bar color: #6b5b95
? Splash screen color: #ffffff

? Do you want to create a signing key? (Y/n) Y
? Keystore password: ******
? Key alias: upload
? Key password: ******
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;Application ID는 변경 불가&lt;/strong&gt;: 한번 플레이스토어에 등록하면 바꿀 수 없습니다. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;com.회사명.앱이름&lt;/code&gt; 형식을 권장합니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;Keystore 파일 백업 필수&lt;/strong&gt;: 분실하면 앱을 업데이트할 수 없습니다. 안전한 곳에 별도 보관하세요.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;이 과정이 끝나면 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;twa-manifest.json&lt;/code&gt;과 Android 프로젝트 파일이 생성됩니다.&lt;/p&gt;

&lt;h3 id=&quot;a-3-3-빌드&quot;&gt;A-3-3. 빌드&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;bubblewrap build
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&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;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;app-release-signed.apk&lt;/code&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;app-release-bundle.aab&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;플레이스토어 업로드용&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h2 id=&quot;a-4-digital-asset-links--브라우저-바-제거의-핵심&quot;&gt;A-4. Digital Asset Links — 브라우저 바 제거의 핵심&lt;/h2&gt;

&lt;p&gt;TWA에서 가장 중요하면서 가장 많이 실수하는 부분입니다. &lt;strong&gt;assetlinks.json&lt;/strong&gt; 파일이 올바르지 않으면 앱 상단에 Chrome 주소창이 표시됩니다.&lt;/p&gt;

&lt;h3 id=&quot;a-4-1-구조&quot;&gt;A-4-1. 구조&lt;/h3&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;err&quot;&gt;//&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;err&quot;&gt;https://app.example.com/.well-known/assetlinks.json&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;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;relation&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;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;delegate_permission/common.handle_all_urls&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;target&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;namespace&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;android_app&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;package_name&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;com.example.myapp&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;sha256_cert_fingerprints&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;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
        &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;AA:BB:CC:DD:...&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;span class=&quot;p&quot;&gt;}&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;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;h3 id=&quot;a-4-2-어떤-fingerprint를-넣어야-하나&quot;&gt;A-4-2. 어떤 fingerprint를 넣어야 하나?&lt;/h3&gt;

&lt;p&gt;여기서 90%의 실수가 발생합니다. &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;Debug key&lt;/td&gt;
      &lt;td&gt;Android Studio 기본&lt;/td&gt;
      &lt;td&gt;로컬 테스트용&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Upload key&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Bubblewrap이 생성한 keystore&lt;/td&gt;
      &lt;td&gt;AAB에 서명해서 업로드&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;App signing key&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Play Console이 생성&lt;/td&gt;
      &lt;td&gt;실제 사용자에게 배포되는 APK 서명&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;strong&gt;assetlinks.json에 넣어야 할 것: App signing key의 SHA-256&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;이 키는 &lt;strong&gt;AAB를 처음 Play Console에 업로드한 후에만&lt;/strong&gt; 확인할 수 있습니다.&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Play Console → 앱 선택 → 설정 → 앱 무결성 → 앱 서명 키 인증서
→ SHA-256 인증서 핑거프린트 복사
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;a-4-3-nextjs에서-assetlinksjson-서빙&quot;&gt;A-4-3. Next.js에서 assetlinks.json 서빙&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;방법 1: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;public&lt;/code&gt; 폴더 (정적)&lt;/strong&gt;&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;public/.well-known/assetlinks.json
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;주의&lt;/strong&gt;: Next.js의 middleware가 이 경로를 리다이렉트하면 안 됩니다. middleware에서 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/.well-known/&lt;/code&gt;을 제외하세요.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class=&quot;language-typescript 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;c1&quot;&gt;// middleware.ts&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;NextResponse&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;next/server&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;type&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;NextRequest&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;next/server&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;middleware&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;NextRequest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;nextUrl&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;pathname&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;startsWith&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;/.well-known/&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;NextResponse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;next&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;c1&quot;&gt;// 기존 middleware 로직...&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;방법 2: Route Handler (동적)&lt;/strong&gt;&lt;/p&gt;

&lt;div class=&quot;language-typescript 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;c1&quot;&gt;// app/.well-known/assetlinks.json/route.ts&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;NextResponse&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;next/server&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;GET&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;assetLinks&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;relation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;delegate_permission/common.handle_all_urls&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;namespace&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;android_app&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;package_name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;com.example.myapp&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;sha256_cert_fingerprints&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
          &lt;span class=&quot;nx&quot;&gt;process&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;env&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;PLAY_SIGNING_KEY_SHA256&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;];&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;NextResponse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;assetLinks&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;a-4-4-검증&quot;&gt;A-4-4. 검증&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;&lt;span class=&quot;c&quot;&gt;# 1. 파일이 접근 가능한지&lt;/span&gt;
curl &lt;span class=&quot;nt&quot;&gt;-v&lt;/span&gt; https://app.example.com/.well-known/assetlinks.json

&lt;span class=&quot;c&quot;&gt;# 2. Content-Type이 application/json인지&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;# 3. Google의 공식 검증 도구&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href=&quot;https://developers.google.com/digital-asset-links/tools/generator&quot;&gt;Google Digital Asset Links 검증 도구&lt;/a&gt;에서 도메인·패키지명·핑거프린트를 넣고 &lt;strong&gt;“Test statement”&lt;/strong&gt; 통과를 확인합니다.&lt;/p&gt;

&lt;h2 id=&quot;a-5-django-쪽에서-할-일&quot;&gt;A-5. Django 쪽에서 할 일&lt;/h2&gt;

&lt;p&gt;Django가 API 서버이고 Next.js가 프론트라면, Django 자체에 특별히 수정할 것은 없습니다. 하지만 다음을 확인하세요:&lt;/p&gt;

&lt;h3 id=&quot;a-5-1-cors-설정-확인&quot;&gt;A-5-1. CORS 설정 확인&lt;/h3&gt;

&lt;p&gt;TWA는 Chrome이므로 &lt;strong&gt;일반 웹 요청과 동일&lt;/strong&gt;합니다. Django의 CORS 설정에 프론트 도메인이 이미 허용되어 있다면 추가 작업 불필요.&lt;/p&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# settings.py
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CORS_ALLOWED_ORIGINS&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;https://app.example.com&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;a-5-2-푸시-알림-django--앱&quot;&gt;A-5-2. 푸시 알림 (Django → 앱)&lt;/h3&gt;

&lt;p&gt;TWA에서는 &lt;strong&gt;Web Push API&lt;/strong&gt;가 그대로 동작합니다. Django 서버에서 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pywebpush&lt;/code&gt;로 푸시를 보내면 앱에서도 받을 수 있습니다.&lt;/p&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# Django 측 — Web Push 발송
&lt;/span&gt;&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;pywebpush&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;webpush&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;webpush&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;subscription_info&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user_subscription&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# 프론트에서 받은 구독 정보
&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;새 알림이 있습니다&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;vapid_private_key&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;your-vapid-private-key&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;vapid_claims&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;sub&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;mailto:admin@example.com&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-typescript 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;c1&quot;&gt;// Next.js 측 — 구독 등록&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;registration&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;navigator&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;serviceWorker&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;ready&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;subscription&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;registration&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;pushManager&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;subscribe&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;userVisibleOnly&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;applicationServerKey&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;process&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;env&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;NEXT_PUBLIC_VAPID_PUBLIC_KEY&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// Django API로 구독 정보 전송&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;fetch&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;/api/push/subscribe/&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;method&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;POST&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;headers&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;Content-Type&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;application/json&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;JSON&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;stringify&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;subscription&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;a-5-3-django-인증--세션&quot;&gt;A-5-3. Django 인증 &amp;amp; 세션&lt;/h3&gt;

&lt;p&gt;TWA 안에서 로그인하면 Chrome의 쿠키에 세션이 저장됩니다. &lt;strong&gt;JWT, 세션 쿠키, OAuth 모두 웹과 동일하게 동작&lt;/strong&gt;합니다. 별도 처리 불필요.&lt;/p&gt;

&lt;h2 id=&quot;a-6-플레이스토어-배포&quot;&gt;A-6. 플레이스토어 배포&lt;/h2&gt;

&lt;h3 id=&quot;a-6-1-전체-흐름&quot;&gt;A-6-1. 전체 흐름&lt;/h3&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;[1] Play Console 개발자 계정 생성 ($25)
      ↓
[2] 앱 생성 → 스토어 리스팅 작성
      ↓
[3] 내부 테스트 트랙에 AAB 업로드
      ↓
[4] App signing key SHA-256 확인 → assetlinks.json 업데이트 → 배포
      ↓
[5] 실기기에서 URL바 없이 동작하는지 확인
      ↓
[6] 클로즈드 테스트 (개인 계정: 12명 × 14일)
      ↓
[7] 프로덕션 신청 → 심사 (3~7 영업일)
      ↓
[8] 출시 🎉
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;a-6-2-스토어-리스팅-필수-항목&quot;&gt;A-6-2. 스토어 리스팅 필수 항목&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;최대 30자&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;짧은 설명&lt;/td&gt;
      &lt;td&gt;최대 80자&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;상세 설명&lt;/td&gt;
      &lt;td&gt;최대 4,000자&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;앱 아이콘&lt;/td&gt;
      &lt;td&gt;512×512px, PNG 32비트&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;대표 그래픽&lt;/td&gt;
      &lt;td&gt;1024×500px, JPEG 또는 24비트 PNG&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;스크린샷&lt;/td&gt;
      &lt;td&gt;최소 2장, JPEG/PNG&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;개인정보처리방침 URL&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;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;a-6-3-개인-계정의-클로즈드-테스트-14일-장벽&quot;&gt;A-6-3. 개인 계정의 클로즈드 테스트 (14일 장벽)&lt;/h3&gt;

&lt;p&gt;2023년 11월 이후 생성된 &lt;strong&gt;개인 계정&lt;/strong&gt;은 프로덕션 출시 전 필수:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;테스터 &lt;strong&gt;12명&lt;/strong&gt;이 &lt;strong&gt;14일 연속&lt;/strong&gt; 옵트인 유지&lt;/li&gt;
  &lt;li&gt;카운트가 12 미만으로 떨어지면 &lt;strong&gt;리셋&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;15~18명으로 시작해서 버퍼 확보 권장&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;조직 계정&lt;/strong&gt;(사업자등록증 필요)은 이 단계 면제&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;a-6-4-twa-앱-업데이트가-쉬운-이유&quot;&gt;A-6-4. TWA 앱 업데이트가 쉬운 이유&lt;/h3&gt;

&lt;p&gt;TWA의 가장 큰 장점: &lt;strong&gt;웹을 업데이트하면 앱도 즉시 반영됩니다.&lt;/strong&gt;&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Django 코드 수정 → 서버 배포
Next.js 코드 수정 → Vercel/서버 배포
  ↓
앱 사용자가 다음에 열면 → 최신 버전 자동 표시
  ↓
스토어 심사 불필요!
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;AAB 자체를 업데이트해야 하는 경우:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;앱 아이콘 변경&lt;/li&gt;
  &lt;li&gt;Application ID나 버전 코드 변경&lt;/li&gt;
  &lt;li&gt;스플래시 화면 색상 변경&lt;/li&gt;
&lt;/ul&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;&lt;span class=&quot;c&quot;&gt;# twa-manifest.json에서 versionCode, versionName 수정 후&lt;/span&gt;
bubblewrap build
&lt;span class=&quot;c&quot;&gt;# 새 AAB를 Play Console에 업로드&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;part-b-capacitor--네이티브-api가-필요할-때&quot;&gt;Part B: Capacitor — 네이티브 API가 필요할 때&lt;/h1&gt;

&lt;p&gt;카메라, GPS, 블루투스, 파일 시스템, 네이티브 푸시(FCM) 등이 필요하다면 Capacitor가 답입니다. Capacitor는 웹앱을 &lt;strong&gt;네이티브 WebView 안에 번들링&lt;/strong&gt;하고, 플러그인을 통해 디바이스 API를 호출할 수 있게 합니다.&lt;/p&gt;

&lt;h2 id=&quot;b-1-제약-사항-정적-export-필수&quot;&gt;B-1. 제약 사항: 정적 export 필수&lt;/h2&gt;

&lt;p&gt;Capacitor는 웹 파일을 앱 안에 &lt;strong&gt;패키징&lt;/strong&gt;하므로, Next.js의 SSR·API Route·middleware는 앱 내부에서 동작하지 않습니다.&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;strong&gt;정적 export&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;output: &apos;export&apos;&lt;/code&gt;로 HTML/CSS/JS 생성, 앱에 번들링&lt;/td&gt;
      &lt;td&gt;API는 Django에 fetch. SSR 불가&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;원격 URL 모드&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;WebView가 배포된 웹 URL을 로딩&lt;/td&gt;
      &lt;td&gt;SSR 가능하지만 오프라인 불가, TWA와 유사&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;이 글에서는 &lt;strong&gt;정적 export + Django API&lt;/strong&gt; 조합을 다룹니다.&lt;/p&gt;

&lt;h2 id=&quot;b-2-nextjs-정적-export-설정&quot;&gt;B-2. Next.js 정적 export 설정&lt;/h2&gt;

&lt;div class=&quot;language-typescript 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;c1&quot;&gt;// next.config.ts&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;type&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;NextConfig&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;next&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;nextConfig&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;NextConfig&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;output&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;export&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;trailingSlash&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;images&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;unoptimized&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;c1&quot;&gt;// next/image 최적화는 서버 필요 → 비활성&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;};&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;default&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;nextConfig&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&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;npm run build
&lt;span class=&quot;c&quot;&gt;# → out/ 폴더에 정적 파일 생성&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;SSR·API Route를 쓰고 있었다면&lt;/strong&gt;: 해당 로직을 &lt;strong&gt;Django API 호출&lt;/strong&gt;로 전환해야 합니다. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;getServerSideProps&lt;/code&gt; → &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;useEffect + fetch&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;API Route&lt;/code&gt; → Django endpoint.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2 id=&quot;b-3-capacitor-설치--초기화&quot;&gt;B-3. Capacitor 설치 &amp;amp; 초기화&lt;/h2&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;&lt;span class=&quot;c&quot;&gt;# Capacitor CLI 및 코어 설치&lt;/span&gt;
npm &lt;span class=&quot;nb&quot;&gt;install&lt;/span&gt; @capacitor/core @capacitor/cli

&lt;span class=&quot;c&quot;&gt;# 초기화&lt;/span&gt;
npx cap init &lt;span class=&quot;s2&quot;&gt;&quot;내서비스&quot;&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;com.example.myapp&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--web-dir&lt;/span&gt; out

&lt;span class=&quot;c&quot;&gt;# Android 플랫폼 추가&lt;/span&gt;
npm &lt;span class=&quot;nb&quot;&gt;install&lt;/span&gt; @capacitor/android
npx cap add android
&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;capacitor.config.ts&lt;/code&gt;가 생성됩니다:&lt;/p&gt;

&lt;div class=&quot;language-typescript 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;c1&quot;&gt;// capacitor.config.ts&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;type&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;CapacitorConfig&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;@capacitor/cli&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;CapacitorConfig&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;appId&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;com.example.myapp&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;appName&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;내서비스&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;webDir&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;out&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;server&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// 개발 중 라이브 리로드 (프로덕션에서는 제거)&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// url: &quot;http://192.168.0.10:3000&quot;,&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// cleartext: true,&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;};&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;default&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;b-4-빌드--실행&quot;&gt;B-4. 빌드 &amp;amp; 실행&lt;/h2&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;&lt;span class=&quot;c&quot;&gt;# 1. Next.js 빌드&lt;/span&gt;
npm run build

&lt;span class=&quot;c&quot;&gt;# 2. 웹 파일을 Android 프로젝트에 복사&lt;/span&gt;
npx cap &lt;span class=&quot;nb&quot;&gt;sync&lt;/span&gt;

&lt;span class=&quot;c&quot;&gt;# 3. Android Studio에서 열기&lt;/span&gt;
npx cap open android
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Android Studio에서 &lt;strong&gt;Run&lt;/strong&gt; 버튼으로 에뮬레이터 또는 실기기에서 테스트합니다.&lt;/p&gt;

&lt;h2 id=&quot;b-5-django-api-연결&quot;&gt;B-5. Django API 연결&lt;/h2&gt;

&lt;p&gt;Capacitor 앱 안에서 Django API를 호출할 때 주의점:&lt;/p&gt;

&lt;h3 id=&quot;b-5-1-api-base-url-관리&quot;&gt;B-5-1. API Base URL 관리&lt;/h3&gt;

&lt;div class=&quot;language-typescript 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;c1&quot;&gt;// lib/api.ts&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;API_BASE&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;process&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;env&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;NEXT_PUBLIC_API_URL&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;||&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;https://api.example.com&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;apiFetch&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kr&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;options&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;RequestInit&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;res&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;fetch&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;`&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;${&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;API_BASE&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}${&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;`&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;...&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;options&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;headers&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;Content-Type&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;application/json&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;...&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;options&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;headers&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;res&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;ok&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;throw&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Error&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;`API error: &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;${&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;res&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;status&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;`&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;res&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;b-5-2-django-cors-설정&quot;&gt;B-5-2. Django CORS 설정&lt;/h3&gt;

&lt;p&gt;Capacitor 앱의 Origin은 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;capacitor://localhost&lt;/code&gt; (Android) 또는 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ionic://localhost&lt;/code&gt; (iOS)입니다:&lt;/p&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# settings.py
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CORS_ALLOWED_ORIGINS&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;https://app.example.com&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;      &lt;span class=&quot;c1&quot;&gt;# 웹
&lt;/span&gt;    &lt;span class=&quot;s&quot;&gt;&quot;capacitor://localhost&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;          &lt;span class=&quot;c1&quot;&gt;# Capacitor Android
&lt;/span&gt;    &lt;span class=&quot;s&quot;&gt;&quot;ionic://localhost&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;              &lt;span class=&quot;c1&quot;&gt;# Capacitor iOS
&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# 또는 개발 중에는
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CORS_ALLOW_ALL_ORIGINS&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# 프로덕션에서는 절대 비활성화
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;b-5-3-인증-토큰-관리&quot;&gt;B-5-3. 인증 토큰 관리&lt;/h3&gt;

&lt;p&gt;Capacitor에서는 쿠키 기반 세션보다 &lt;strong&gt;JWT 토큰&lt;/strong&gt;이 더 안정적입니다:&lt;/p&gt;

&lt;div class=&quot;language-typescript 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;c1&quot;&gt;// lib/auth.ts&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Preferences&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;@capacitor/preferences&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;saveToken&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kr&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Preferences&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kd&quot;&gt;set&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;key&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;auth_token&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;value&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;token&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;getToken&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;():&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Promise&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kr&quot;&gt;string&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;value&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Preferences&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kd&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;key&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;auth_token&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;value&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;authFetch&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kr&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;options&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;RequestInit&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;token&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;getToken&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;apiFetch&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;...&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;options&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;headers&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;...&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;options&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;headers&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;Authorization&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;token&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;?&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;`Bearer &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;${&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;`&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# Django 측 — djangorestframework-simplejwt 사용 예시
# urls.py
&lt;/span&gt;&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;rest_framework_simplejwt.views&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;TokenObtainPairView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;TokenRefreshView&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;urlpatterns&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;api/token/&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;TokenObtainPairView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;as_view&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()),&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;api/token/refresh/&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;TokenRefreshView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;as_view&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()),&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;b-6-네이티브-기능-추가-예시&quot;&gt;B-6. 네이티브 기능 추가 예시&lt;/h2&gt;

&lt;h3 id=&quot;b-6-1-푸시-알림-fcm--django&quot;&gt;B-6-1. 푸시 알림 (FCM + Django)&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;npm &lt;span class=&quot;nb&quot;&gt;install&lt;/span&gt; @capacitor/push-notifications
npx cap &lt;span class=&quot;nb&quot;&gt;sync&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-typescript 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;c1&quot;&gt;// hooks/usePushNotifications.ts&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;PushNotifications&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;@capacitor/push-notifications&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;authFetch&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;@/lib/auth&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;initPush&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;permission&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;PushNotifications&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;requestPermissions&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;permission&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;receive&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!==&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;granted&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

  &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;PushNotifications&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;register&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;PushNotifications&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;addListener&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;registration&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// Django 서버에 FCM 토큰 등록&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;authFetch&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;/api/devices/register/&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;method&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;POST&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;JSON&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;stringify&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;registration_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;value&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;android&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;}),&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;PushNotifications&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;addListener&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;pushNotificationReceived&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;notification&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;console&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;log&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;Push received:&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;notification&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;PushNotifications&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;addListener&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;pushNotificationActionPerformed&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;notification&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;c1&quot;&gt;// 알림 탭 시 특정 페이지로 이동&lt;/span&gt;
      &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;url&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;notification&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;notification&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
      &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;window&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;location&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;href&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# Django 측 — fcm-django 사용
# models.py
&lt;/span&gt;&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;fcm_django.models&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;FCMDevice&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# 푸시 발송
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;device&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;FCMDevice&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;objects&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;filter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;target_user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;active&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;first&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;device&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;send_message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;title&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;새 알림&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;주문이 접수되었습니다.&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;url&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;/orders/123&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;b-6-2-카메라&quot;&gt;B-6-2. 카메라&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;npm &lt;span class=&quot;nb&quot;&gt;install&lt;/span&gt; @capacitor/camera
npx cap &lt;span class=&quot;nb&quot;&gt;sync&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-typescript 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;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Camera&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;CameraResultType&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;@capacitor/camera&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;takePhoto&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;photo&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Camera&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;getPhoto&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;resultType&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;CameraResultType&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;Base64&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;quality&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;80&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;

  &lt;span class=&quot;c1&quot;&gt;// Django API로 이미지 업로드&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;authFetch&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;/api/photos/upload/&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;method&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;POST&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;JSON&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;stringify&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;image&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;photo&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;base64String&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}),&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;b-7-릴리즈-빌드--서명&quot;&gt;B-7. 릴리즈 빌드 &amp;amp; 서명&lt;/h2&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;&lt;span class=&quot;c&quot;&gt;# 1. Next.js 빌드&lt;/span&gt;
npm run build

&lt;span class=&quot;c&quot;&gt;# 2. Capacitor 동기화&lt;/span&gt;
npx cap &lt;span class=&quot;nb&quot;&gt;sync&lt;/span&gt;

&lt;span class=&quot;c&quot;&gt;# 3. Android Studio에서 릴리즈 빌드&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;# Build → Generate Signed Bundle / APK → Android App Bundle → release&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;또는 CLI로:&lt;/p&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;&lt;span class=&quot;nb&quot;&gt;cd &lt;/span&gt;android
./gradlew bundleRelease
&lt;span class=&quot;c&quot;&gt;# → android/app/build/outputs/bundle/release/app-release.aab&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;android/app/build.gradle&lt;/code&gt;에서 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;targetSdk = 35&lt;/code&gt; 확인 필수 (2026년 요구사항).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;hr /&gt;

&lt;h1 id=&quot;공통-어떤-방식이든-확인해야-할-것&quot;&gt;공통: 어떤 방식이든 확인해야 할 것&lt;/h1&gt;

&lt;h2 id=&quot;체크리스트&quot;&gt;체크리스트&lt;/h2&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;[ ] targetSdk = 35 (API 레벨 35 이상)
[ ] AAB 파일로 빌드 (APK 아님)
[ ] Keystore 파일 안전한 곳에 백업
[ ] 개인정보처리방침 URL 준비
[ ] 스크린샷 최소 2장 (1080×1920 이상 권장)
[ ] 대표 그래픽 1024×500px
[ ] 앱 아이콘 512×512px
[ ] 데이터 안전 섹션 작성
[ ] 콘텐츠 등급 설문 완료
[ ] (TWA) assetlinks.json에 Play signing key SHA-256
[ ] (TWA) Chrome DevTools에서 브라우저 바 안 뜨는지 확인
[ ] (Capacitor) CORS에 capacitor://localhost 허용
[ ] (개인 계정) 클로즈드 테스트 12명 × 14일
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&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;TWA&lt;/th&gt;
      &lt;th&gt;Capacitor&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Django API 수정&lt;/td&gt;
      &lt;td&gt;서버 배포만 → 앱 자동 반영&lt;/td&gt;
      &lt;td&gt;서버 배포만 → 앱 자동 반영&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Next.js UI 수정&lt;/td&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;build&lt;/code&gt; → &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cap sync&lt;/code&gt; → &lt;strong&gt;스토어 재배포&lt;/strong&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;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bubblewrap build&lt;/code&gt; → 스토어 재배포&lt;/td&gt;
      &lt;td&gt;Android Studio → 스토어 재배포&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;추천-의사결정-트리&quot;&gt;추천 의사결정 트리&lt;/h2&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Django + Next.js 웹앱이 이미 있다
    │
    ├─ 카메라·GPS·블루투스 등 디바이스 API가 필요한가?
    │   ├─ Yes → Capacitor (Part B)
    │   └─ No
    │       ├─ SSR·서버 기능을 앱에서도 그대로 쓰고 싶은가?
    │       │   ├─ Yes → TWA (Part A) ← 대부분 여기
    │       │   └─ No → 정적 export 가능하면 Capacitor도 OK
    │       └─ 웹 배포 = 앱 업데이트가 중요한가?
    │           ├─ Yes → TWA (Part A)
    │           └─ No → 둘 다 OK
    │
    └─ 앱 전용 복잡한 UX가 필요한가?
        ├─ Yes → Kotlin 네이티브 (이 글 범위 밖)
        └─ No → TWA 또는 Capacitor
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&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;TWA&lt;/th&gt;
      &lt;th&gt;Capacitor&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;PWA/정적 export 설정&lt;/td&gt;
      &lt;td&gt;반나절&lt;/td&gt;
      &lt;td&gt;1~2일 (SSR → fetch 전환)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;앱 프로젝트 생성&lt;/td&gt;
      &lt;td&gt;1~2시간&lt;/td&gt;
      &lt;td&gt;반나절&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;assetlinks / CORS 설정&lt;/td&gt;
      &lt;td&gt;반나절 (삽질 포함)&lt;/td&gt;
      &lt;td&gt;1시간&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;네이티브 기능 추가&lt;/td&gt;
      &lt;td&gt;해당 없음&lt;/td&gt;
      &lt;td&gt;1~3일&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;1일&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;클로즈드 테스트 (개인 계정)&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;14일&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;14일&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;심사&lt;/td&gt;
      &lt;td&gt;3~7 영업일&lt;/td&gt;
      &lt;td&gt;3~7 영업일&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;약 3~4주&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;약 4~5주&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&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;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Next.js PWA 공식 가이드&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://nextjs.org/docs/app/guides/progressive-web-apps&quot;&gt;nextjs.org/docs/app/guides/progressive-web-apps&lt;/a&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Bubblewrap CLI&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://github.com/GoogleChromeLabs/bubblewrap&quot;&gt;github.com/GoogleChromeLabs/bubblewrap&lt;/a&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;PWABuilder&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://www.pwabuilder.com/&quot;&gt;pwabuilder.com&lt;/a&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Digital Asset Links 검증&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://developers.google.com/digital-asset-links/tools/generator&quot;&gt;developers.google.com/digital-asset-links/tools/generator&lt;/a&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Capacitor 공식 문서&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://capacitorjs.com/docs&quot;&gt;capacitorjs.com/docs&lt;/a&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Capacitor + Next.js 가이드&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://capgo.app/blog/nextjs-mobile-app-capacitor-from-scratch/&quot;&gt;capgo.app/blog/nextjs-mobile-app-capacitor-from-scratch&lt;/a&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;fcm-django&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://github.com/xtrinch/fcm-django&quot;&gt;github.com/xtrinch/fcm-django&lt;/a&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Serwist (Service Worker)&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://serwist.pages.dev/&quot;&gt;serwist.pages.dev&lt;/a&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;클로즈드 테스트 요구사항&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://support.google.com/googleplay/android-developer/answer/14151465&quot;&gt;support.google.com/googleplay/android-developer/answer/14151465&lt;/a&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;이 블로그: 안드로이드 앱 입문 가이드&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;/android/2026/05/25/android-app-google-play-store-beginner-guide/&quot;&gt;이전 포스트 참고&lt;/a&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

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

&lt;p&gt;Django + Next.js 웹앱을 안드로이드 앱으로 만드는 가장 현실적인 경로를 정리하면:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;대부분의 경우 TWA면 충분합니다.&lt;/strong&gt; 이미 잘 돌아가는 웹앱을 Bubblewrap으로 감싸면 3~5MB짜리 앱이 나오고, Django의 SSR·인증·API가 모두 그대로 동작합니다. 웹을 업데이트하면 앱도 즉시 반영되니 운영 부담도 적습니다. 핵심은 &lt;strong&gt;assetlinks.json의 SHA-256 핑거프린트를 Play signing key 것으로 넣는 것&lt;/strong&gt;입니다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;카메라·GPS 같은 디바이스 API가 필요하면 Capacitor&lt;/strong&gt;를 쓰되, Next.js를 정적 export로 전환하고 SSR 로직을 Django API 호출로 바꿔야 합니다.&lt;/p&gt;

&lt;p&gt;어떤 방식이든 &lt;strong&gt;스토어 배포 과정&lt;/strong&gt;(계정 등록, 리스팅, 클로즈드 테스트, 심사)은 동일합니다. 가장 오래 걸리는 건 코딩이 아니라 &lt;strong&gt;14일 클로즈드 테스트 대기&lt;/strong&gt;입니다.&lt;/p&gt;</content><author><name>updaun</name></author><category term="android" /><category term="django" /><category term="django" /><category term="nextjs" /><category term="android" /><category term="pwa" /><category term="twa" /><category term="capacitor" /><category term="google-play" /><category term="mobile" /><summary type="html">Django + Next.js 웹앱을 안드로이드 앱으로 만들어 플레이스토어에 배포하기 — 2026 실전 가이드</summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-05-26-django-nextjs-android-app-deploy-guide.webp" /><media:content medium="image" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-05-26-django-nextjs-android-app-deploy-guide.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">안드로이드 앱 처음 만들어서 구글 플레이스토어에 출시하기 — 2026 완전 가이드</title><link href="https://updaun.github.io/android/mobile/2026/05/25/android-app-google-play-store-beginner-guide/" rel="alternate" type="text/html" title="안드로이드 앱 처음 만들어서 구글 플레이스토어에 출시하기 — 2026 완전 가이드" /><published>2026-05-25T00:00:00+09:00</published><updated>2026-05-25T00:00:00+09:00</updated><id>https://updaun.github.io/android/mobile/2026/05/25/android-app-google-play-store-beginner-guide</id><content type="html" xml:base="https://updaun.github.io/android/mobile/2026/05/25/android-app-google-play-store-beginner-guide/">&lt;h1 id=&quot;안드로이드-앱-처음-만들어서-구글-플레이스토어에-출시하기--2026-완전-가이드&quot;&gt;안드로이드 앱 처음 만들어서 구글 플레이스토어에 출시하기 — 2026 완전 가이드&lt;/h1&gt;

&lt;p&gt;“앱 하나 만들어서 플레이스토어에 올려보고 싶은데, 뭐부터 해야 하지?”&lt;/p&gt;

&lt;p&gt;이 글은 &lt;strong&gt;안드로이드 앱을 한 번도 만들어 본 적 없는 사람&lt;/strong&gt;이 &lt;strong&gt;개발 환경 설치 → 앱 코딩 → 서명 → 스토어 등록 → 심사 통과&lt;/strong&gt;까지 한 사이클을 끝내는 데 필요한 모든 과정을 정리합니다. 2026년 기준으로 변경된 정책(클로즈드 테스트 의무화, API 레벨 35 타겟 등)도 반영했습니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;전체-로드맵-한눈에-보기&quot;&gt;전체 로드맵 한눈에 보기&lt;/h2&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;[1] 개발 환경 준비              ← Android Studio 설치, 에뮬레이터 세팅
      ↓
[2] 첫 앱 만들기                ← Kotlin + Jetpack Compose &quot;Hello World&quot;
      ↓
[3] 기능 개발 &amp;amp; 디자인           ← 화면 추가, 데이터 저장, API 연동
      ↓
[4] 릴리즈 빌드 &amp;amp; 앱 서명        ← keystore 생성, AAB 빌드
      ↓
[5] 개발자 계정 등록             ← $25 결제, 신원 인증
      ↓
[6] 스토어 리스팅 작성           ← 스크린샷, 설명, 개인정보처리방침
      ↓
[7] 클로즈드 테스트 (14일)       ← 12명 테스터, 14일 연속 유지
      ↓
[8] 프로덕션 신청 &amp;amp; 심사         ← 3~7 영업일
      ↓
[9] 출시 완료 🎉
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;각 단계를 하나씩 풀어보겠습니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;1단계-개발-환경-준비&quot;&gt;1단계: 개발 환경 준비&lt;/h2&gt;

&lt;h3 id=&quot;11-android-studio-설치&quot;&gt;1.1 Android Studio 설치&lt;/h3&gt;

&lt;p&gt;Android Studio는 구글이 공식 지원하는 안드로이드 개발 IDE입니다.&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;a href=&quot;https://developer.android.com/studio&quot;&gt;developer.android.com/studio&lt;/a&gt;에서 최신 버전 다운로드&lt;/li&gt;
  &lt;li&gt;설치 마법사를 따라 진행 — &lt;strong&gt;Android SDK&lt;/strong&gt;, &lt;strong&gt;Android Emulator&lt;/strong&gt;, &lt;strong&gt;Android SDK Build-Tools&lt;/strong&gt;가 함께 설치됨&lt;/li&gt;
  &lt;li&gt;설치 완료 후 &lt;strong&gt;SDK Manager&lt;/strong&gt;에서 &lt;strong&gt;Android 15 (API 35)&lt;/strong&gt; SDK가 설치되어 있는지 확인&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;PC 사양 참고&lt;/strong&gt;: RAM 16GB 이상 권장. 8GB에서도 돌아가지만 에뮬레이터와 IDE를 동시에 띄우면 상당히 느립니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;12-에뮬레이터-생성&quot;&gt;1.2 에뮬레이터 생성&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Device Manager&lt;/strong&gt;(AVD Manager)에서 가상 디바이스를 만듭니다.&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Phone → Pixel 8&lt;/strong&gt; 등 최신 디바이스 프로필 선택&lt;/li&gt;
  &lt;li&gt;시스템 이미지: &lt;strong&gt;API 35 (Android 15)&lt;/strong&gt; 다운로드 후 선택&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Finish&lt;/strong&gt; → 재생 버튼으로 부팅 확인&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;실물 폰이 있다면 USB 케이블 연결 + &lt;strong&gt;개발자 옵션 → USB 디버깅&lt;/strong&gt; 활성화로 실기기 테스트도 가능합니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;2단계-첫-앱-만들기--hello-world&quot;&gt;2단계: 첫 앱 만들기 — Hello World&lt;/h2&gt;

&lt;p&gt;2026년 안드로이드 개발의 표준은 &lt;strong&gt;Kotlin + Jetpack Compose&lt;/strong&gt;입니다. 예전처럼 XML 레이아웃을 작성하지 않고, &lt;strong&gt;Kotlin 코드만으로 UI를 선언적으로 구성&lt;/strong&gt;합니다.&lt;/p&gt;

&lt;h3 id=&quot;21-프로젝트-생성&quot;&gt;2.1 프로젝트 생성&lt;/h3&gt;

&lt;ol&gt;
  &lt;li&gt;Android Studio → &lt;strong&gt;New Project&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;템플릿: &lt;strong&gt;Phone and Tablet → Empty Activity&lt;/strong&gt; (Compose 기본 템플릿)&lt;/li&gt;
  &lt;li&gt;설정:
    &lt;ul&gt;
      &lt;li&gt;&lt;strong&gt;Name&lt;/strong&gt;: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MyFirstApp&lt;/code&gt;&lt;/li&gt;
      &lt;li&gt;&lt;strong&gt;Package name&lt;/strong&gt;: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;com.yourname.myfirstapp&lt;/code&gt; (나중에 변경 불가하니 신중히)&lt;/li&gt;
      &lt;li&gt;&lt;strong&gt;Language&lt;/strong&gt;: Kotlin (유일한 선택지)&lt;/li&gt;
      &lt;li&gt;&lt;strong&gt;Minimum SDK&lt;/strong&gt;: API 24 (Android 7.0 — 2026년 기준 전 세계 기기 97%+ 커버)&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Finish&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;22-기본-코드-구조&quot;&gt;2.2 기본 코드 구조&lt;/h3&gt;

&lt;p&gt;생성된 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MainActivity.kt&lt;/code&gt;를 열면 이런 코드가 있습니다:&lt;/p&gt;

&lt;div class=&quot;language-kotlin 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;kd&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;MainActivity&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;ComponentActivity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;override&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;fun&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;onCreate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;savedInstanceState&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;Bundle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;?)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;super&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;onCreate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;savedInstanceState&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;nf&quot;&gt;setContent&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;nc&quot;&gt;MyFirstAppTheme&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;nc&quot;&gt;Greeting&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;World&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;nd&quot;&gt;@Composable&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;fun&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;Greeting&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;String&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nc&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;Hello $name!&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;nd&quot;&gt;@Preview&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;showBackground&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;nd&quot;&gt;@Composable&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;fun&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;GreetingPreview&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nc&quot;&gt;MyFirstAppTheme&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;nc&quot;&gt;Greeting&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;World&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;핵심 개념 3가지&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;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;setContent { }&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;액티비티가 표시할 UI를 Compose로 정의하는 진입점&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@Composable&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;“이 함수는 UI를 그리는 함수입니다”라는 표시. Compose 함수끼리만 호출 가능&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@Preview&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;에뮬레이터 없이 Android Studio 안에서 미리보기 렌더링&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;▶ 버튼(또는 Shift+F10)을 누르면 에뮬레이터에 “Hello World!”가 표시됩니다.&lt;/p&gt;

&lt;h3 id=&quot;23-xml이-없다&quot;&gt;2.3 XML이 없다?&lt;/h3&gt;

&lt;p&gt;맞습니다. Compose 프로젝트에는 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;res/layout/activity_main.xml&lt;/code&gt;이 없습니다. UI 전체를 Kotlin 코드로 작성합니다. 2026년에 새로 시작한다면 &lt;strong&gt;Compose 방식만 배우면 됩니다&lt;/strong&gt;. 구글의 공식 문서, 튜토리얼, 최신 라이브러리가 모두 Compose 기준으로 작성되고 있습니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;3단계-기능-개발--간단한-앱-예시&quot;&gt;3단계: 기능 개발 — 간단한 앱 예시&lt;/h2&gt;

&lt;p&gt;“Hello World”에서 한 발짝 나아가, &lt;strong&gt;버튼을 누르면 카운터가 올라가는 앱&lt;/strong&gt;을 만들어 봅시다.&lt;/p&gt;

&lt;div class=&quot;language-kotlin 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;nd&quot;&gt;@Composable&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;fun&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;CounterScreen&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;var&lt;/span&gt; &lt;span class=&quot;py&quot;&gt;count&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;by&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;remember&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;mutableIntStateOf&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;nc&quot;&gt;Column&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;modifier&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;Modifier&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;fillMaxSize&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;padding&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;32&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;dp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;verticalArrangement&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;Arrangement&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nc&quot;&gt;Center&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;horizontalAlignment&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;Alignment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nc&quot;&gt;CenterHorizontally&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;nc&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;text&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;$count&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;fontSize&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;64&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;fontWeight&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;FontWeight&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nc&quot;&gt;Bold&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;nc&quot;&gt;Spacer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;modifier&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;Modifier&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;height&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;24&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;dp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
        &lt;span class=&quot;nc&quot;&gt;Button&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;onClick&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;count&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;++&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;})&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;nc&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;눌러주세요&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;fontSize&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;20&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;remember&lt;/code&gt;&lt;/strong&gt;: 화면이 다시 그려져도(recomposition) 값을 유지. Compose의 상태 관리 핵심입니다.&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;화면 이동(내비게이션)&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;androidx.navigation:navigation-compose&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;네트워크 API 호출&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Retrofit&lt;/code&gt; + &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;kotlinx.serialization&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;Room&lt;/code&gt; (SQLite ORM) 또는 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DataStore&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;Coil&lt;/code&gt; (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;coil-compose&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;Hilt&lt;/code&gt; 또는 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Koin&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;비동기 처리&lt;/td&gt;
      &lt;td&gt;Kotlin &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Coroutines&lt;/code&gt; + &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Flow&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;이 라이브러리들을 하나씩 붙여가며 앱을 키워나갈 수 있습니다.&lt;/p&gt;

&lt;h3 id=&quot;프로젝트-구조-권장-패턴&quot;&gt;프로젝트 구조 권장 패턴&lt;/h3&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;app/src/main/java/com/yourname/myfirstapp/
├── MainActivity.kt
├── ui/
│   ├── theme/
│   │   ├── Color.kt
│   │   ├── Theme.kt
│   │   └── Type.kt
│   └── screens/
│       ├── HomeScreen.kt
│       └── DetailScreen.kt
├── data/
│   ├── repository/
│   └── model/
└── network/
    └── ApiService.kt
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;4단계-릴리즈-빌드--앱-서명&quot;&gt;4단계: 릴리즈 빌드 &amp;amp; 앱 서명&lt;/h2&gt;

&lt;p&gt;개발이 끝나면 구글 플레이에 올릴 &lt;strong&gt;서명된 AAB(Android App Bundle)&lt;/strong&gt; 파일을 만들어야 합니다.&lt;/p&gt;

&lt;h3 id=&quot;41-keystore-생성&quot;&gt;4.1 Keystore 생성&lt;/h3&gt;

&lt;p&gt;Keystore는 앱의 &lt;strong&gt;디지털 신분증&lt;/strong&gt;입니다. 잃어버리면 그 앱은 영원히 업데이트할 수 없으니 반드시 백업하세요.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Android Studio에서 생성하는 방법&lt;/strong&gt;:&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Build → Generate Signed Bundle / APK&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Android App Bundle&lt;/strong&gt; 선택 → Next&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Create new&lt;/strong&gt; → Keystore 정보 입력:
    &lt;ul&gt;
      &lt;li&gt;&lt;strong&gt;Key store path&lt;/strong&gt;: 안전한 위치에 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.jks&lt;/code&gt; 파일 저장&lt;/li&gt;
      &lt;li&gt;&lt;strong&gt;Password&lt;/strong&gt;: 강력한 비밀번호&lt;/li&gt;
      &lt;li&gt;&lt;strong&gt;Key alias&lt;/strong&gt;: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;upload&lt;/code&gt; (권장)&lt;/li&gt;
      &lt;li&gt;&lt;strong&gt;Key password&lt;/strong&gt;: keystore 비밀번호와 동일해도 됨&lt;/li&gt;
      &lt;li&gt;&lt;strong&gt;이름, 조직 등&lt;/strong&gt;: 아무거나 입력 가능&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Next → release&lt;/strong&gt; 빌드 타입 선택 → &lt;strong&gt;Create&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;커맨드라인으로 생성하는 방법&lt;/strong&gt;:&lt;/p&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;keytool &lt;span class=&quot;nt&quot;&gt;-genkey&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-v&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-keystore&lt;/span&gt; my-release-key.jks &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-keyalg&lt;/span&gt; RSA &lt;span class=&quot;nt&quot;&gt;-keysize&lt;/span&gt; 2048 &lt;span class=&quot;nt&quot;&gt;-validity&lt;/span&gt; 10000 &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;-alias&lt;/span&gt; upload
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;42-릴리즈-빌드-설정&quot;&gt;4.2 릴리즈 빌드 설정&lt;/h3&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;app/build.gradle.kts&lt;/code&gt;에서 릴리즈 빌드 타입을 확인합니다:&lt;/p&gt;

&lt;div class=&quot;language-kotlin 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;nf&quot;&gt;android&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;// 2026년 기준 필수: API 35 타겟&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;compileSdk&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;35&lt;/span&gt;

    &lt;span class=&quot;nf&quot;&gt;defaultConfig&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;applicationId&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;com.yourname.myfirstapp&quot;&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;minSdk&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;24&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;targetSdk&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;35&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;versionCode&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;versionName&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;1.0.0&quot;&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;nf&quot;&gt;signingConfigs&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;nf&quot;&gt;create&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;release&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;storeFile&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;file&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;path/to/my-release-key.jks&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;storePassword&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;System&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;getenv&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;KEYSTORE_PASSWORD&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;?:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;&quot;&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;keyAlias&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;upload&quot;&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;keyPassword&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;System&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;getenv&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;KEY_PASSWORD&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;?:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;&quot;&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;nf&quot;&gt;buildTypes&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;nf&quot;&gt;release&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;isMinifyEnabled&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;true&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;isShrinkResources&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;true&lt;/span&gt;
            &lt;span class=&quot;nf&quot;&gt;proguardFiles&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
                &lt;span class=&quot;nf&quot;&gt;getDefaultProguardFile&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;proguard-android-optimize.txt&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
                &lt;span class=&quot;s&quot;&gt;&quot;proguard-rules.pro&quot;&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;signingConfig&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;signingConfigs&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nf&quot;&gt;getByName&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;release&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;targetSdk = 35 필수&lt;/strong&gt;: 2026년 기준, 신규 앱과 업데이트 모두 &lt;strong&gt;Android 15 (API 35)&lt;/strong&gt; 이상을 타겟해야 합니다. 이보다 낮으면 Play Console에서 업로드를 거부합니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;43-aab-파일-생성&quot;&gt;4.3 AAB 파일 생성&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;./gradlew bundleRelease
&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;app/build/outputs/bundle/release/app-release.aab&lt;/code&gt; 파일이 생성됩니다.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;AAB vs APK&lt;/strong&gt;: 2021년 8월부터 신규 앱은 반드시 &lt;strong&gt;AAB&lt;/strong&gt; 형식으로 업로드해야 합니다. AAB를 올리면 구글이 각 기기에 최적화된 APK를 자동 생성해 배포하므로, 앱 용량이 평균 15~20% 줄어듭니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;44-play-app-signing&quot;&gt;4.4 Play App Signing&lt;/h3&gt;

&lt;p&gt;Play Console에 처음 AAB를 업로드하면 &lt;strong&gt;Play App Signing&lt;/strong&gt;에 자동 등록됩니다:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Upload key&lt;/strong&gt; (내가 가진 keystore): AAB를 서명해서 올리는 데 사용&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;App signing key&lt;/strong&gt; (구글이 관리): 실제 사용자에게 배포되는 APK를 서명&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Upload key를 분실해도 구글에 재설정을 요청할 수 있습니다. 하지만 &lt;strong&gt;keystore 원본은 반드시 별도 백업&lt;/strong&gt;하세요.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;5단계-google-play-개발자-계정-등록&quot;&gt;5단계: Google Play 개발자 계정 등록&lt;/h2&gt;

&lt;h3 id=&quot;51-계정-생성&quot;&gt;5.1 계정 생성&lt;/h3&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;a href=&quot;https://play.google.com/console&quot;&gt;play.google.com/console&lt;/a&gt; 접속&lt;/li&gt;
  &lt;li&gt;Google 계정으로 로그인&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;개발자 배포 계약&lt;/strong&gt; 동의&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;등록비 $25&lt;/strong&gt; 결제 (일회성, 평생 유효)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;계정 유형&lt;/strong&gt; 선택:
    &lt;ul&gt;
      &lt;li&gt;&lt;strong&gt;개인&lt;/strong&gt;: 신분증 인증 필요, &lt;strong&gt;클로즈드 테스트 의무&lt;/strong&gt;&lt;/li&gt;
      &lt;li&gt;&lt;strong&gt;조직&lt;/strong&gt;: 사업자 인증 필요, 클로즈드 테스트 면제&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;중요&lt;/strong&gt;: 2023년 11월 13일 이후 생성된 개인 계정은 &lt;strong&gt;클로즈드 테스트 통과 전까지 프로덕션 출시가 불가능&lt;/strong&gt;합니다. 이 정책이 개인 개발자에게 가장 큰 허들이 됩니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;52-신원-인증&quot;&gt;5.2 신원 인증&lt;/h3&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;: DUNS 번호 또는 사업자 등록 서류&lt;/li&gt;
  &lt;li&gt;인증 처리: 보통 24~48시간&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;6단계-스토어-리스팅-작성&quot;&gt;6단계: 스토어 리스팅 작성&lt;/h2&gt;

&lt;p&gt;Play Console에서 앱을 생성하면 &lt;strong&gt;대시보드&lt;/strong&gt;가 출시 전 필수 항목을 체크리스트로 안내합니다.&lt;/p&gt;

&lt;h3 id=&quot;61-필수-텍스트&quot;&gt;6.1 필수 텍스트&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;팁&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;최대 30자&lt;/td&gt;
      &lt;td&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;최대 80자&lt;/td&gt;
      &lt;td&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;최대 4,000자&lt;/td&gt;
      &lt;td&gt;핵심 기능 → 사용법 → 차별점 순서&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;62-필수-그래픽-에셋&quot;&gt;6.2 필수 그래픽 에셋&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;비고&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;512×512px, PNG 32비트&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mipmap&lt;/code&gt; 아이콘과 별개&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;대표 그래픽&lt;/strong&gt; (Feature Graphic)&lt;/td&gt;
      &lt;td&gt;1024×500px, JPEG 또는 24비트 PNG (투명 불가)&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;필수&lt;/strong&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;최소 2장, 최대 8장 / 기기 유형별&lt;/td&gt;
      &lt;td&gt;JPEG 또는 24비트 PNG, 가로·세로 320~3840px&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;스크린샷 팁&lt;/strong&gt;: 에뮬레이터에서 앱 실행 → Android Studio의 &lt;strong&gt;캡처 버튼&lt;/strong&gt; (카메라 아이콘)으로 바로 찍을 수 있습니다. Figma나 Canva로 프레임+텍스트 설명을 씌우면 훨씬 전문적으로 보입니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;63-필수-설정&quot;&gt;6.3 필수 설정&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;strong&gt;개인정보처리방침 URL&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;사용자 데이터를 수집하지 않아도 넣어두는 게 안전. GitHub Pages에 간단한 페이지를 만들어 링크&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;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;콘텐츠 등급&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;설문 응답 → 자동 등급 부여 (전체이용가, 12세 등)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;타겟 연령&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;아동 대상이 아니면 “13세 이상” 선택이 무난&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;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;개인정보처리방침-간단히-만들기&quot;&gt;개인정보처리방침 간단히 만들기&lt;/h3&gt;

&lt;p&gt;앱이 어떤 개인정보도 수집하지 않는다면 아래처럼 한 페이지면 충분합니다:&lt;/p&gt;

&lt;div class=&quot;language-markdown 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;gh&quot;&gt;# 개인정보처리방침&lt;/span&gt;

본 앱은 사용자의 개인정보를 수집, 저장, 전송하지 않습니다.
문의: your-email@example.com
최종 수정: 2026-05-25
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;GitHub Pages, Notion Public 페이지, Google Sites 등에 올리고 URL을 복사하면 됩니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;7단계-클로즈드-테스트-개인-계정-필수&quot;&gt;7단계: 클로즈드 테스트 (개인 계정 필수)&lt;/h2&gt;

&lt;p&gt;2026년 기준 가장 많은 사람이 막히는 단계입니다.&lt;/p&gt;

&lt;h3 id=&quot;71-요구사항&quot;&gt;7.1 요구사항&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;strong&gt;최소 12명&lt;/strong&gt; (과거 20명에서 완화)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;테스트 기간&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;14일 연속&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;옵트인 상태&lt;/td&gt;
      &lt;td&gt;14일 내내 12명 이상이 &lt;strong&gt;연속으로&lt;/strong&gt; 옵트인 유지&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;계정 유형&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;2023년 11월 이후 생성된 개인 계정&lt;/strong&gt;만 해당&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;72-진행-순서&quot;&gt;7.2 진행 순서&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1) AAB 업로드&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Play Console → &lt;strong&gt;Testing → Closed testing&lt;/strong&gt; → &lt;strong&gt;Create new track&lt;/strong&gt; → 릴리즈 생성 → AAB 업로드&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;이메일 주소 목록&lt;/strong&gt;: 테스터들의 Google 계정 이메일을 직접 추가&lt;/li&gt;
  &lt;li&gt;또는 &lt;strong&gt;Google 그룹스&lt;/strong&gt; 활용: 그룹 메일을 하나 만들어 등록하면 관리가 편함&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;3) 테스트 링크 배포&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;테스트 트랙이 활성화되면 &lt;strong&gt;옵트인 URL&lt;/strong&gt;이 생성됩니다. 이 링크를 테스터에게 전달하면:&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;테스터가 링크를 클릭 → “테스터로 참여” 수락&lt;/li&gt;
  &lt;li&gt;플레이스토어에서 앱 설치 가능해짐&lt;/li&gt;
  &lt;li&gt;설치 + 실제 사용이 이루어져야 “참여 중”으로 인정&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;4) 14일 대기&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Play Console → &lt;strong&gt;Testing → Closed testing → Testers&lt;/strong&gt; 탭에서 매일 옵트인 수를 확인합니다.&lt;/p&gt;

&lt;h3 id=&quot;73-생존-팁&quot;&gt;7.3 생존 팁&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;15~18명으로 시작하세요&lt;/strong&gt;: 테스터 이탈 버퍼. 12명 딱 맞추면 한 명만 빠져도 카운트 리셋됩니다.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;테스트 중 빌드를 업데이트하지 마세요&lt;/strong&gt;: 새 AAB를 올리면 테스터가 업데이트해야 하고, 이 과정에서 카운트가 흔들릴 수 있습니다.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;가짜 계정 사용 금지&lt;/strong&gt;: 구글은 에뮬레이터 신호, VPN, 최근 생성 계정을 감지해 조용히 제외합니다.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;주변 지인에게 부탁&lt;/strong&gt;: 가족, 친구, 동료에게 “플레이스토어에서 앱 설치하고 14일만 놔둬줘”라고 부탁하는 게 가장 현실적입니다.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;조직 계정이면 면제&lt;/strong&gt;: 사업자등록증이 있다면 조직 계정으로 등록하면 이 단계를 건너뛸 수 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;8단계-프로덕션-신청--심사&quot;&gt;8단계: 프로덕션 신청 &amp;amp; 심사&lt;/h2&gt;

&lt;h3 id=&quot;81-프로덕션-접근-신청&quot;&gt;8.1 프로덕션 접근 신청&lt;/h3&gt;

&lt;p&gt;14일 클로즈드 테스트 완료 후 Play Console 대시보드에 &lt;strong&gt;“Apply for production access”&lt;/strong&gt; 버튼이 활성화됩니다.&lt;/p&gt;

&lt;p&gt;설문에서 묻는 것들:&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;/ul&gt;

&lt;h3 id=&quot;82-릴리즈-생성&quot;&gt;8.2 릴리즈 생성&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Production → Create new release&lt;/strong&gt; → AAB 업로드(테스트 때와 같은 파일 또는 최종 수정본) → 릴리즈 노트 작성 → &lt;strong&gt;Send for review&lt;/strong&gt;&lt;/p&gt;

&lt;h3 id=&quot;83-심사-기간&quot;&gt;8.3 심사 기간&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;3~7 영업일 (최대 2주)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;기존 앱 업데이트&lt;/td&gt;
      &lt;td&gt;24~72시간&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;민감 카테고리 (금융, 건강, VPN 등)&lt;/td&gt;
      &lt;td&gt;최대 2주+&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;84-흔한-리젝거부-사유와-대응&quot;&gt;8.4 흔한 리젝(거부) 사유와 대응&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;개인정보처리방침 URL 누락&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;targetSdk가 35 미만&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;build.gradle.kts&lt;/code&gt;에서 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;targetSdk = 35&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;AndroidManifest.xml&lt;/code&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;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;isMinifyEnabled = true&lt;/code&gt;로 빌드 + &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Log.d()&lt;/code&gt; 정리&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;9단계-출시-후--이게-끝이-아닙니다&quot;&gt;9단계: 출시 후 — 이게 끝이 아닙니다&lt;/h2&gt;

&lt;h3 id=&quot;91-모니터링&quot;&gt;9.1 모니터링&lt;/h3&gt;

&lt;p&gt;Play Console에서 확인할 수 있는 지표:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;ANR(Application Not Responding) 비율&lt;/strong&gt;: 0.47% 이하 유지 권장&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;크래시 비율&lt;/strong&gt;: 1.09% 이하 유지 권장&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;/ul&gt;

&lt;h3 id=&quot;92-업데이트-배포&quot;&gt;9.2 업데이트 배포&lt;/h3&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;versionCode&lt;/code&gt; 증가 (1 → 2)&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;versionName&lt;/code&gt; 변경 (“1.0.0” → “1.1.0”)&lt;/li&gt;
  &lt;li&gt;새 AAB 빌드 → Production에 새 릴리즈 생성 → 심사(24~72시간)&lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;93-단계적-배포-staged-rollout&quot;&gt;9.3 단계적 배포 (Staged Rollout)&lt;/h3&gt;

&lt;p&gt;처음에 사용자의 20%에게만 배포 → 크래시 없으면 50% → 100%로 늘려갈 수 있습니다. 실수를 해도 전체 사용자가 영향받지 않으므로, &lt;strong&gt;업데이트 시 항상 단계적 배포를 권장&lt;/strong&gt;합니다.&lt;/p&gt;

&lt;hr /&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;Google Play 개발자 등록&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;$25 (일회성)&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;평생 유효, 앱 개수 무제한&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Android Studio&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;연 $1M까지 &lt;strong&gt;15%&lt;/strong&gt;, 초과분 &lt;strong&gt;30%&lt;/strong&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;중고 폰이면 5~10만원&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;사실상 &lt;strong&gt;$25만 있으면 시작&lt;/strong&gt;할 수 있습니다.&lt;/p&gt;

&lt;hr /&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;/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;간단한 앱 개발&lt;/td&gt;
      &lt;td&gt;1~4주 (복잡도에 따라)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;릴리즈 빌드&lt;/td&gt;
      &lt;td&gt;1일&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;개발자 계정 인증&lt;/td&gt;
      &lt;td&gt;1~2일&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;스토어 리스팅 작성&lt;/td&gt;
      &lt;td&gt;1~2일&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;클로즈드 테스트&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;14일&lt;/strong&gt; (최소, 카운트 리셋 없을 때)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;심사&lt;/td&gt;
      &lt;td&gt;3~7 영업일&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;5~8주&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;가장 오래 걸리는 건 앱 개발 자체와 14일 클로즈드 테스트입니다. 테스터 확보를 &lt;strong&gt;개발과 병행&lt;/strong&gt;하면 시간을 아낄 수 있습니다.&lt;/p&gt;

&lt;hr /&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;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Jetpack Compose 공식 튜토리얼&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://developer.android.com/develop/ui/compose/tutorial&quot;&gt;developer.android.com/develop/ui/compose/tutorial&lt;/a&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Compose 샘플 앱 모음&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://github.com/android/compose-samples&quot;&gt;github.com/android/compose-samples&lt;/a&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;앱 서명 가이드&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://developer.android.com/studio/publish/app-signing&quot;&gt;developer.android.com/studio/publish/app-signing&lt;/a&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Play Console 시작 가이드&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://support.google.com/googleplay/android-developer/answer/6112435&quot;&gt;support.google.com/googleplay/android-developer/answer/6112435&lt;/a&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;클로즈드 테스트 요구사항&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://support.google.com/googleplay/android-developer/answer/14151465&quot;&gt;support.google.com/googleplay/android-developer/answer/14151465&lt;/a&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;스토어 리스팅 에셋 가이드&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://support.google.com/googleplay/android-developer/answer/9866151&quot;&gt;support.google.com/googleplay/android-developer/answer/9866151&lt;/a&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;API 레벨 요구사항&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://support.google.com/googleplay/android-developer/answer/11926878&quot;&gt;support.google.com/googleplay/android-developer/answer/11926878&lt;/a&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

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

&lt;p&gt;안드로이드 앱을 만들어서 플레이스토어에 올리는 과정은 &lt;strong&gt;생각보다 기술적이지 않습니다&lt;/strong&gt;. 진짜 장벽은 코딩이 아니라 &lt;strong&gt;서명 키 관리&lt;/strong&gt;, &lt;strong&gt;클로즈드 테스트 14일 기다림&lt;/strong&gt;, &lt;strong&gt;스토어 리스팅 꼼꼼히 채우기&lt;/strong&gt; 같은 행정적 단계입니다.&lt;/p&gt;

&lt;p&gt;순서를 다시 정리하면:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Android Studio 설치 → Kotlin + Compose로 앱 제작&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;targetSdk = 35&lt;/code&gt;, AAB 빌드, keystore &lt;strong&gt;백업 필수&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;Play Console $25 등록 → 리스팅 작성 (스크린샷, 설명, 개인정보처리방침)&lt;/li&gt;
  &lt;li&gt;클로즈드 테스트 12명 × 14일 (개인 계정) → 프로덕션 신청&lt;/li&gt;
  &lt;li&gt;심사 통과 → 출시&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;한 번 이 사이클을 돌려보면, 두 번째 앱부터는 훨씬 빠르게 진행할 수 있습니다.&lt;/p&gt;</content><author><name>updaun</name></author><category term="android" /><category term="mobile" /><category term="android" /><category term="kotlin" /><category term="jetpack-compose" /><category term="google-play" /><category term="app-publishing" /><summary type="html">안드로이드 앱 처음 만들어서 구글 플레이스토어에 출시하기 — 2026 완전 가이드</summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-05-25-android-app-google-play-store-beginner-guide.webp" /><media:content medium="image" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-05-25-android-app-google-play-store-beginner-guide.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">스마트팜 워크플로우 아키텍처 설계 — 센서·제어·LLM을 Django Ninja로 모델링하기</title><link href="https://updaun.github.io/iot/django/architecture/2026/05/19/smartfarm-workflow-architecture-django-ninja/" rel="alternate" type="text/html" title="스마트팜 워크플로우 아키텍처 설계 — 센서·제어·LLM을 Django Ninja로 모델링하기" /><published>2026-05-19T00:00:00+09:00</published><updated>2026-05-19T00:00:00+09:00</updated><id>https://updaun.github.io/iot/django/architecture/2026/05/19/smartfarm-workflow-architecture-django-ninja</id><content type="html" xml:base="https://updaun.github.io/iot/django/architecture/2026/05/19/smartfarm-workflow-architecture-django-ninja/">&lt;h1 id=&quot;스마트팜-워크플로우-아키텍처-설계--센서제어llm을-django-ninja로-모델링하기&quot;&gt;스마트팜 워크플로우 아키텍처 설계 — 센서·제어·LLM을 Django Ninja로 모델링하기&lt;/h1&gt;

&lt;p&gt;최근 n8n, Make, Retool 같은 &lt;strong&gt;워크플로우 UI&lt;/strong&gt;를 쓰는 서비스들을 보면, 복잡한 로직을 코드 없이 한눈에 이해할 수 있다는 점이 인상적이었습니다.&lt;/p&gt;

&lt;p&gt;스마트팜에 이 패턴을 적용하면 이런 그림이 그려집니다.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;온실 A동 온도가 28℃를 넘으면 → 환기창 30% 개방 → 5분 후에도 27℃ 이상이면 → 알람 + 난방 OFF&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;이걸 &lt;strong&gt;if-else 코드&lt;/strong&gt;로만 관리하면, 농장주·현장 기사·개발자 모두 같은 파일을 열어야 합니다. 워크플로우 UI라면 &lt;strong&gt;센서 → 조건 → 액추에이터&lt;/strong&gt; 흐름을 캔버스에서 보고, 슬라이더로 임계값을 바꿔가며 즉시 시험해 볼 수 있습니다. 여기에 &lt;strong&gt;“28도 넘으면 환기창 열어줘”&lt;/strong&gt; 라고 말하면 LLM이 그래프를 편집해 주면, 비개발자도 운영할 수 있는 제어 패널이 됩니다.&lt;/p&gt;

&lt;p&gt;이번 글은 그 아이디어를 &lt;strong&gt;Django Ninja로 API·스키마를 어떻게 모델링할지&lt;/strong&gt;, 그리고 &lt;strong&gt;노드 간 관계·통신&lt;/strong&gt;을 어떻게 설계할지 고민한 설계 노트입니다. (이전 글 &lt;a href=&quot;/iot/2026/05/06/smartfarm-sensor-realtime-architecture/&quot;&gt;스마트팜 센서 데이터 아키텍처&lt;/a&gt;가 &lt;strong&gt;데이터 수집·실시간 표시&lt;/strong&gt;였다면, 이번 글은 &lt;strong&gt;제어 로직의 구조화&lt;/strong&gt;에 초점을 둡니다.)&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;1-다른-사람들은-어떻게-하나--자료-조사-요약&quot;&gt;1. 다른 사람들은 어떻게 하나 — 자료 조사 요약&lt;/h2&gt;

&lt;p&gt;워크플로우 기반 자동화는 이미 IoT·업무 자동화 양쪽에서 검증된 패턴입니다. 대표 사례를 스마트팜 관점에서만 정리했습니다.&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;&lt;strong&gt;&lt;a href=&quot;https://nodered.org/&quot;&gt;Node-RED&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;MQTT·Modbus 등 IoT 노드 풍부, 엣지·게이트웨이 배포&lt;/td&gt;
      &lt;td&gt;노드 배열 + &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;wires&lt;/code&gt;로 출력 포트 연결&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;플랫 JSON&lt;/strong&gt;, 로컬 실행, 브로커 config 노드 분리&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;&lt;a href=&quot;https://n8n.io/&quot;&gt;n8n&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;SaaS 통합·조건 분기·실행 이력&lt;/td&gt;
      &lt;td&gt;Directed graph, 노드 간 &lt;strong&gt;item 배열&lt;/strong&gt; (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;json&lt;/code&gt;/&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;binary&lt;/code&gt;)&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;스택 기반 실행 엔진&lt;/strong&gt;, 부분 실행, 멀티 입력 join&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;&lt;a href=&quot;https://docs.losant.com/workflows/overview&quot;&gt;Losant&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;IoT 네이티브, 디바이스 state 트리거&lt;/td&gt;
      &lt;td&gt;Device State Trigger → Logic → Device State Output&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;속성(attribute) 단위 필터&lt;/strong&gt;, 무한 루프 방지 필터&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;&lt;a href=&quot;https://aws.amazon.com/iot-things-graph/&quot;&gt;AWS IoT Things Graph&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;디바이스 모델 시각 연결&lt;/td&gt;
      &lt;td&gt;Flow + Thing 모델, 상태 추적·재시도&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;디바이스 추상화&lt;/strong&gt;와 워크플로우 분리&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Priva / 그린플러스류&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;상용 온실 제어&lt;/td&gt;
      &lt;td&gt;UI는 폐쇄, 내부는 룰 엔진 + 로컬 제어&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;제어 루프는 엣지&lt;/strong&gt;, 클라우드는 설정·이력&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;그래프 = 노드(정점) + 엣지(연결)&lt;/strong&gt; — UI 좌표(&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;x&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;y&lt;/code&gt;)와 실행 그래프는 분리 저장하는 경우가 많음.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;트리거와 액션을 분리&lt;/strong&gt; — “언제 돌릴까”(센서 이벤트, cron, 수동)와 “무엇을 할까”(MQTT publish, HTTP, DB)를 다른 노드 타입으로 둠.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;실행 컨텍스트&lt;/strong&gt; — 한 번의 실행(run)마다 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;payload&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;variables&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;trace&lt;/code&gt;를 들고 다음 노드로 전달.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;안전장치&lt;/strong&gt; — 사이클 검출, 최대 실행 횟수, 디바이스 피드백 루프 방지(Losant의 attribute 필터처럼).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;n8n은 내부적으로 &lt;strong&gt;DirectedGraph&lt;/strong&gt;와 &lt;strong&gt;실행 스택&lt;/strong&gt;으로 워크플로우를 돌립니다. Node-RED는 &lt;strong&gt;단순한 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;wires&lt;/code&gt; 배열&lt;/strong&gt;이지만, IoT 현장에서는 오히려 이 단순함이 배포·디버깅에 유리합니다. 스마트팜 MVP는 Node-RED에 가까운 단순 그래프 + Losant식 디바이스 state 모델을 섞는 쪽이 현실적입니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;2-스마트팜-워크플로우가-풀어야-할-문제&quot;&gt;2. 스마트팜 워크플로우가 풀어야 할 문제&lt;/h2&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;“습도 80% 넘으면 제습기 켜줘”를 말로 설정&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&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;개발자&lt;/td&gt;
      &lt;td&gt;새 센서 타입·액추에이터를 &lt;strong&gt;노드 플러그인&lt;/strong&gt;으로 추가&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;기술적-제약&quot;&gt;기술적 제약&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;제어 지연&lt;/strong&gt;: 환기·난방은 수백 ms 안에 반응해야 함 → &lt;a href=&quot;/iot/2026/05/06/smartfarm-sensor-realtime-architecture/&quot;&gt;이전 아키텍처 글&lt;/a&gt;처럼 &lt;strong&gt;엣지(게이트웨이)에서 1차 실행&lt;/strong&gt;, 클라우드는 정책 동기화.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;실패 시 동작&lt;/strong&gt;: 브로커 끊김 시 &lt;strong&gt;안전 기본값&lt;/strong&gt;(fail-safe) — 예: 환기창 일부 개방.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;감사·롤백&lt;/strong&gt;: 누가 어떤 룰을 언제 바꿨는지, 실행당 입·출력 스냅샷.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&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;┌─────────────────────────────────────────────────────────────────┐
│  프론트 (React Flow 등)                                          │
│  - 캔버스 편집 / 슬라이더로 파라미터 실시간 수정                    │
│  - 실행 시뮬레이션 / 배포 버튼                                    │
│  - 자연어 → LLM → graph JSON patch                               │
└───────────────────────────┬─────────────────────────────────────┘
                            │ REST (Django Ninja)
┌───────────────────────────▼─────────────────────────────────────┐
│  Control Plane (Django)                                          │
│  - Workflow / Node / Edge CRUD                                     │
│  - 버전·배포·권한                                                  │
│  - LLM Orchestrator (graph diff 생성 → 검증 → 저장)               │
└───────────────┬─────────────────────────────┬───────────────────┘
                │ deploy (MQTT / HTTP)          │ execution logs
┌───────────────▼──────────────┐   ┌────────────▼──────────────────┐
│  Edge Runtime (게이트웨이)     │   │  Observability               │
│  - 컴파일된 룰 캐시            │   │  TimescaleDB / Redis stream  │
│  - 센서 subscribe → run       │   │  WebSocket/SSE 실행 상태      │
│  - 액추에이터 publish          │   └──────────────────────────────┘
└───────────────┬──────────────┘
                │ MQTT
┌───────────────▼──────────────┐
│  센서 / 환기창 / 난방 / CO₂   │
└──────────────────────────────┘
&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;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;Editor graph&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;UI용 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;position&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;viewport&lt;/code&gt;, 주석&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Execution graph&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;런타임용 topology + &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;config&lt;/code&gt; + &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;compiled&lt;/code&gt; 표현&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Runtime&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;이벤트 수신 → DAG/룰 평가 → 디바이스 명령&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;프론트가 보내는 React Flow 형식(&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nodes&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;edges&lt;/code&gt;)을 그대로 DB에 넣지 말고, &lt;strong&gt;정규화된 WorkflowDefinition&lt;/strong&gt;으로 변환해 저장하는 편이 장기적으로 안전합니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;4-노드-타입과-관계--무엇을-연결할-것인가&quot;&gt;4. 노드 타입과 관계 — 무엇을 연결할 것인가&lt;/h2&gt;

&lt;h3 id=&quot;41-노드-카테고리-스마트팜용&quot;&gt;4.1 노드 카테고리 (스마트팜용)&lt;/h3&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;[Trigger]     sensor_threshold, schedule_cron, manual_run, mqtt_message
[Condition]   compare, range, and_or, debounce, rate_limit
[Transform]   moving_avg, unit_convert, map_zone
[Action]      actuator_command, notification, log_only, webhook
[Flow]        delay, branch, merge (join), sub_workflow
[AI]          llm_summarize, llm_classify  (운영 리포트용, 제어 직결은 신중히)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;42-포트port와-엣지edge&quot;&gt;4.2 포트(Port)와 엣지(Edge)&lt;/h3&gt;

&lt;p&gt;n8n처럼 &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;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;compare&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;true&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;false&lt;/code&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;debounce&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;passed&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;suppressed&lt;/code&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;actuator_command&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;success&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;error&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;제어 성공/실패&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&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;source_node_id&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;n_compare_1&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;source_port&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;true&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;target_node_id&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;n_vent_1&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;target_port&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;in&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;Node-RED의 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;wires: [[&quot;id1&quot;], [&quot;id2&quot;]]&lt;/code&gt;는 &lt;strong&gt;포트 인덱스&lt;/strong&gt;로 분기를 표현합니다. 스마트팜에서는 &lt;strong&gt;이름 있는 포트&lt;/strong&gt;가 디버깅에 유리합니다.&lt;/p&gt;

&lt;h3 id=&quot;43-노드-간-통신--메시지가-아니라-실행-컨텍스트&quot;&gt;4.3 노드 간 “통신” — 메시지가 아니라 실행 컨텍스트&lt;/h3&gt;

&lt;p&gt;노드끼리 HTTP로 직접 부르지 않습니다. &lt;strong&gt;실행 엔진이 하나의 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ExecutionContext&lt;/code&gt;를 들고 순회&lt;/strong&gt;합니다.&lt;/p&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# 개념 모델 (런타임 내부)
&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;@&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;dataclass&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;ExecutionContext&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;run_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;workflow_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;workflow_version&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;triggered_at&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;datetime&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;trigger&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;dict&lt;/span&gt;          &lt;span class=&quot;c1&quot;&gt;# {&quot;type&quot;: &quot;sensor&quot;, &quot;device_id&quot;: &quot;...&quot;, &quot;metric&quot;: &quot;temp&quot;}
&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;dict&lt;/span&gt;          &lt;span class=&quot;c1&quot;&gt;# 현재 노드까지 누적된 데이터
&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;variables&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;dict&lt;/span&gt;        &lt;span class=&quot;c1&quot;&gt;# 워크플로우 전역 변수 (카운터, 마지막 명령 시각)
&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;trace&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;list&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;dict&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;      &lt;span class=&quot;c1&quot;&gt;# 노드별 입출력 (디버그·감사)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;한 노드의 출력&lt;/strong&gt;은 다음 형태로 통일하는 것을 권장합니다 (n8n의 item 배열을 단순화):&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;items&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;p&quot;&gt;[&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;span class=&quot;nl&quot;&gt;&quot;zone_id&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&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;temp&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;mf&quot;&gt;28.4&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;humidity&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;mf&quot;&gt;72.1&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;ts&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;mi&quot;&gt;1716100000000&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;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;meta&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;source_node&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;n_sensor_1&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;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;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;strong&gt;In-process context&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;엣지 런타임, Celery 워커&lt;/td&gt;
      &lt;td&gt;같은 프로세스/태스크 안에서 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;payload&lt;/code&gt; 전달&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;MQTT&lt;/strong&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;strong&gt;Redis Pub/Sub&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;클라우드 ↔ UI 실시간 실행 로그&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;/iot/2026/05/06/smartfarm-sensor-realtime-architecture/&quot;&gt;센서 실시간 글&lt;/a&gt;의 SSE 브리지와 동일 패턴&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;DB append&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;실행 이력, 감사&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WorkflowRun&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;NodeExecution&lt;/code&gt; 테이블&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;5-django-모델--데이터-구조&quot;&gt;5. Django 모델 — 데이터 구조&lt;/h2&gt;

&lt;h3 id=&quot;51-er-개요&quot;&gt;5.1 ER 개요&lt;/h3&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Farm ─┬─ Zone ─┬─ Device (sensor | actuator)
      │        └─ DeviceBinding (topic, metric_key)
      │
      └─ Workflow ─┬─ WorkflowVersion (graph snapshot, status)
                   ├─ WorkflowNode
                   ├─ WorkflowEdge
                   └─ WorkflowDeployment (edge_gateway_id, compiled_rules)

WorkflowRun ─ NodeExecution
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;52-핵심-모델&quot;&gt;5.2 핵심 모델&lt;/h3&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# apps/control/models.py
&lt;/span&gt;&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;django.db&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;uuid&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;Workflow&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Model&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;nb&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;UUIDField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;primary_key&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;default&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;uuid&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;uuid4&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;editable&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;False&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;farm&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ForeignKey&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;farms.Farm&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;on_delete&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CASCADE&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;zone&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ForeignKey&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;farms.Zone&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;blank&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;on_delete&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CASCADE&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;name&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CharField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;max_length&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;120&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;description&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;TextField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;blank&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;is_enabled&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;BooleanField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;default&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;False&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;created_at&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DateTimeField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;auto_now_add&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;WorkflowVersion&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Model&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;Status&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;TextChoices&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;DRAFT&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;draft&quot;&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;PUBLISHED&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;published&quot;&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;ARCHIVED&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;archived&quot;&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;workflow&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ForeignKey&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Workflow&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;on_delete&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CASCADE&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;related_name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;versions&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;version&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;PositiveIntegerField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;status&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CharField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;max_length&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;20&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;choices&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Status&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;choices&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;default&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Status&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DRAFT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
  &lt;span class=&quot;c1&quot;&gt;# UI 원본 + 실행용 정규화 그래프 (분리 저장 권장)
&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;editor_state&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JSONField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;default&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;dict&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;   &lt;span class=&quot;c1&quot;&gt;# React Flow export
&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;definition&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JSONField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;default&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;dict&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;     &lt;span class=&quot;c1&quot;&gt;# nodes/edges 정규화
&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;compiled&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JSONField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;default&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;dict&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;       &lt;span class=&quot;c1&quot;&gt;# 엣지 런타임용 (optional)
&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;published_at&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DateTimeField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;blank&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;published_by&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ForeignKey&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;auth.User&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;on_delete&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;SET_NULL&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;Meta&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;unique_together&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;workflow&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;version&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;WorkflowNode&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Model&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;NodeKind&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;TextChoices&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;TRIGGER&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;trigger&quot;&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;CONDITION&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;condition&quot;&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;TRANSFORM&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;transform&quot;&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;ACTION&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;action&quot;&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;FLOW&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;flow&quot;&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;version&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ForeignKey&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;WorkflowVersion&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;on_delete&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CASCADE&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;related_name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;nodes&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;node_key&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CharField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;max_length&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;64&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# 그래프 내 stable id (n_compare_1)
&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;kind&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CharField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;max_length&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;20&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;choices&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;NodeKind&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;choices&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;nb&quot;&gt;type&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CharField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;max_length&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;64&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;      &lt;span class=&quot;c1&quot;&gt;# sensor_threshold, actuator_command, ...
&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;config&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JSONField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;default&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;dict&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;position_x&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;FloatField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;default&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;position_y&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;FloatField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;default&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;Meta&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;unique_together&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;version&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;node_key&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;WorkflowEdge&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Model&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;version&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ForeignKey&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;WorkflowVersion&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;on_delete&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CASCADE&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;related_name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;edges&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;source_node&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ForeignKey&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;WorkflowNode&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;on_delete&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CASCADE&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;related_name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;out_edges&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;target_node&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ForeignKey&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;WorkflowNode&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;on_delete&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CASCADE&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;related_name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;in_edges&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;source_port&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CharField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;max_length&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;32&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;default&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;out&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;target_port&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CharField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;max_length&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;32&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;default&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;in&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;WorkflowRun&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Model&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;Status&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;TextChoices&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;RUNNING&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;running&quot;&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;SUCCESS&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;success&quot;&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;FAILED&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;failed&quot;&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;CANCELLED&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;cancelled&quot;&lt;/span&gt;

    &lt;span class=&quot;nb&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;UUIDField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;primary_key&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;default&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;uuid&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;uuid4&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;editable&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;False&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;version&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ForeignKey&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;WorkflowVersion&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;on_delete&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;PROTECT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;status&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CharField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;max_length&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;20&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;choices&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Status&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;choices&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;trigger_payload&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JSONField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;default&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;dict&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;started_at&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DateTimeField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;auto_now_add&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;finished_at&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DateTimeField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;blank&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;error_message&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;TextField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;blank&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;NodeExecution&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Model&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;run&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ForeignKey&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;WorkflowRun&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;on_delete&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CASCADE&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;related_name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;steps&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;node_key&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CharField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;max_length&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;64&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;status&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CharField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;max_length&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;20&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;input_payload&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JSONField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;default&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;dict&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;output_payload&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;JSONField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;default&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;dict&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;duration_ms&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;PositiveIntegerField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;null&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;executed_at&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DateTimeField&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;auto_now_add&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;왜 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WorkflowVersion&lt;/code&gt;을 두나&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;캔버스에서 슬라이더를 움직이는 동안은 &lt;strong&gt;DRAFT&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;PUBLISHED&lt;/code&gt; 스냅샷을 엣지에 push → 실행 중인 농장은 항상 고정 버전 참조.&lt;/li&gt;
  &lt;li&gt;롤백 = 이전 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;version&lt;/code&gt;을 다시 publish.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;6-django-ninja--api스키마-모델링&quot;&gt;6. Django Ninja — API·스키마 모델링&lt;/h2&gt;

&lt;h3 id=&quot;61-pydantic-스키마-프론트--백엔드-계약&quot;&gt;6.1 Pydantic 스키마 (프론트 ↔ 백엔드 계약)&lt;/h3&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# apps/control/schemas.py
&lt;/span&gt;&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;ninja&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Schema&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;typing&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Literal&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Any&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;uuid&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;UUID&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;NodePositionSchema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Schema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;x&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;float&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;y&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;float&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;WorkflowNodeSchema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Schema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;nb&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;kind&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Literal&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;trigger&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;condition&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;transform&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;action&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;flow&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;nb&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;dict&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Any&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;position&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;NodePositionSchema&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;WorkflowEdgeSchema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Schema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;nb&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;source&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;source_port&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;out&quot;&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;target_port&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;in&quot;&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;WorkflowDefinitionSchema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Schema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;nodes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;list&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;WorkflowNodeSchema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;edges&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;list&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;WorkflowEdgeSchema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;WorkflowVersionOutSchema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Schema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;workflow_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;UUID&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;version&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;status&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;definition&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;WorkflowDefinitionSchema&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;updated_at&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;WorkflowVersionInSchema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Schema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;definition&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;WorkflowDefinitionSchema&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;editor_state&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;dict&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Any&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# 노드별 config 검증 — 타입별로 쪼개면 LLM 출력 검증에도 유리
&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;SensorThresholdConfig&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Schema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;device_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;metric&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Literal&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;temp&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;humidity&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;co2&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;par&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;operator&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Literal&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;gt&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;gte&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;lt&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;lte&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;between&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;value&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;float&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;value_high&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;float&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;debounce_seconds&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;ActuatorCommandConfig&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Schema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;device_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;command&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Literal&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;set_position&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;on&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;off&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;pulse&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;params&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;dict&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Any&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# {&quot;position_pct&quot;: 30}
&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;safe_mode_fallback&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;dict&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Any&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;62-api-라우터&quot;&gt;6.2 API 라우터&lt;/h3&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# apps/control/api.py
&lt;/span&gt;&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;ninja&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Router&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;ninja.errors&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HttpError&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;uuid&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;UUID&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;.models&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Workflow&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;WorkflowVersion&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;.schemas&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;WorkflowVersionInSchema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;WorkflowVersionOutSchema&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;.services&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;validate_graph&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;compile_graph&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;publish_version&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;router&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Router&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;tags&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;workflows&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;

&lt;span class=&quot;o&quot;&gt;@&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;router&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/workflows/{workflow_id}/versions/{version}&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;WorkflowVersionOutSchema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;get_version&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;workflow_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;UUID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;version&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;wv&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;WorkflowVersion&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;objects&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;select_related&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;workflow&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;workflow_id&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;workflow_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;version&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;version&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;workflow_id&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;workflow_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;version&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;wv&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;version&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;status&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;wv&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;status&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;definition&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;wv&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;definition&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;updated_at&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;wv&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;published_at&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;isoformat&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;wv&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;published_at&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;o&quot;&gt;@&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;router&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;put&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/workflows/{workflow_id}/versions/{version}&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;WorkflowVersionOutSchema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;save_draft&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;workflow_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;UUID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;version&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;WorkflowVersionInSchema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;wv&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;WorkflowVersion&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;objects&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;workflow_id&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;workflow_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;version&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;version&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;wv&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;status&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;WorkflowVersion&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Status&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DRAFT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;raise&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HttpError&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;409&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;Published version cannot be edited directly&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;errors&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;validate_graph&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;definition&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;errors&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;raise&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HttpError&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;422&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;validation_errors&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;errors&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;wv&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;definition&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;definition&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;model_dump&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;editor_state&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;wv&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;editor_state&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;editor_state&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;wv&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;save&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;update_fields&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;definition&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;editor_state&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;get_version&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;workflow_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;version&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;o&quot;&gt;@&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;router&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/workflows/{workflow_id}/versions/{version}/publish&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;publish&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;workflow_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;UUID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;version&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;wv&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;WorkflowVersion&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;objects&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;workflow_id&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;workflow_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;version&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;version&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;compiled&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;compile_graph&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;wv&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;definition&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;wv&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;compiled&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;compiled&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;wv&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;status&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;WorkflowVersion&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Status&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;PUBLISHED&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;wv&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;save&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;publish_version&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;wv&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# MQTT: farm/{id}/workflows/{wf_id}/config
&lt;/span&gt;    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;ok&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;compiled_hash&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;compiled&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;hash&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;63-그래프-검증-validate_graph&quot;&gt;6.3 그래프 검증 (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;validate_graph&lt;/code&gt;)&lt;/h3&gt;

&lt;p&gt;배포 전에 반드시 막아야 할 것들:&lt;/p&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# apps/control/services/validation.py
&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;validate_graph&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;defn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;list&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;errors&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;list&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[]&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;node_ids&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;n&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;n&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;defn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;nodes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;triggers&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;n&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;n&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;defn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;nodes&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;n&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;kind&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;trigger&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;len&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;triggers&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;errors&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;append&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;workflow must have exactly one trigger node&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;e&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;defn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;edges&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;source&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;not&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;node_ids&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;or&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;target&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;not&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;node_ids&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;errors&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;append&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;edge references unknown node: &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;source&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;errors&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;append&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;self-loop not allowed: &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;has_cycle&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;defn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;errors&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;append&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;cycle detected in workflow graph&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;n&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;defn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;nodes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;n&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;type&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;sensor_threshold&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;cfg&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;SensorThresholdConfig&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;**&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;n&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# pydantic 검증
&lt;/span&gt;        &lt;span class=&quot;k&quot;&gt;elif&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;n&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;type&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;actuator_command&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;ActuatorCommandConfig&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;**&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;n&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;errors&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;64-시뮬레이션-api--슬라이더로-값-바꿔보며-제어&quot;&gt;6.4 시뮬레이션 API — “슬라이더로 값 바꿔보며 제어”&lt;/h3&gt;

&lt;p&gt;실제 액추에이터에 명령을 내리지 않고, &lt;strong&gt;가상 센서값&lt;/strong&gt;으로 그래프만 태워 보는 엔드포인트입니다.&lt;/p&gt;

&lt;div class=&quot;language-python 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;o&quot;&gt;@&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;router&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/workflows/{workflow_id}/versions/{version}/simulate&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;simulate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;workflow_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;UUID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;version&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;SimulateInSchema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;&quot;&quot;
    payload.example:
    {
      &quot;inject&quot;: {&quot;zone_id&quot;: &quot;A&quot;, &quot;temp&quot;: 29.1, &quot;humidity&quot;: 81},
      &quot;dry_run&quot;: true
    }
    &quot;&quot;&quot;&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;wv&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;WorkflowVersion&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;objects&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;workflow_id&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;workflow_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;version&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;version&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;result&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;run_workflow&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;wv&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;compiled&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;or&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;compile_graph&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;wv&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;definition&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;inject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;dry_run&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;steps&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;result&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;trace&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;actions&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;result&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;planned_actions&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&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;onChange&lt;/code&gt;마다 debounce 300ms로 이 API를 호출하면, &lt;strong&gt;캔버스 위 노드가 하이라이트&lt;/strong&gt;되며 어떤 분기를 탔는지 보여 줄 수 있습니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;7-실행-엔진--노드-순회-알고리즘&quot;&gt;7. 실행 엔진 — 노드 순회 알고리즘&lt;/h2&gt;

&lt;h3 id=&quot;71-컴파일-결과-예시&quot;&gt;7.1 컴파일 결과 예시&lt;/h3&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;compile_graph&lt;/code&gt;는 런타임이 빠르게 돌 수 있게 &lt;strong&gt;인접 리스트 + 트리거 매핑&lt;/strong&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;hash&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;a1b2c3&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;entry&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;n_trigger_1&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;nodes&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;n_trigger_1&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;type&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;sensor_threshold&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;config&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;err&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;span class=&quot;nl&quot;&gt;&quot;next&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;default&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;n_compare_1&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;n_compare_1&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;type&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;compare&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;next&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;true&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;n_vent_1&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;false&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;n_end&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;n_vent_1&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;type&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;actuator_command&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;config&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;err&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;span class=&quot;nl&quot;&gt;&quot;next&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;default&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;n_end&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;p&quot;&gt;}&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;h3 id=&quot;72-실행-루프-의사-코드&quot;&gt;7.2 실행 루프 (의사 코드)&lt;/h3&gt;

&lt;p&gt;n8n·auxx.ai 등에서 흔히 쓰는 &lt;strong&gt;while-loop + 명시적 next&lt;/strong&gt; 패턴입니다. 재귀보다 긴 워크플로우·취소에 유리합니다.&lt;/p&gt;

&lt;div class=&quot;language-python 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;n&quot;&gt;MAX_STEPS&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;200&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;run_workflow&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;compiled&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;dict&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;inject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;dict&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;dry_run&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;bool&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;False&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;RunResult&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;ctx&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ExecutionContext&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;items&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;inject&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]},&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;variables&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{},&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;trace&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[])&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;current&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;compiled&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;entry&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;steps&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;while&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;current&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;and&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;steps&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;MAX_STEPS&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;node&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;compiled&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;nodes&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;][&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;current&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;handler&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;NODE_REGISTRY&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;node&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;type&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]]&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;out&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;handler&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;run&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;node&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;config&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ctx&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;dry_run&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;dry_run&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;ctx&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;trace&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;append&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;node&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;current&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;out&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;out&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;status&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;payload&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;out&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;

        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;out&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;stop&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;break&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;current&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;node&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;next&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;].&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;out&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;port&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;or&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;node&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;next&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;].&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;default&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;ctx&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;out&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;steps&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;RunResult&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;trace&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ctx&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;trace&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;planned_actions&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;out&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;actions&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;dry_run&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;73-debounce--delay-노드&quot;&gt;7.3 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;debounce&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;delay&lt;/code&gt; 노드&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;debounce&lt;/strong&gt;: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;variables[last_fired_at]&lt;/code&gt;와 비교해 N초 내 재트리거 억제 (Losant식 “너무 자주 도는 워크플로” 방지).&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;delay&lt;/strong&gt;: 엣지에서는 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;asyncio.sleep&lt;/code&gt; / Celery &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;countdown&lt;/code&gt; — &lt;strong&gt;제어 루프에 delay가 길면 클라우드가 아니라 엣지 스케줄러&lt;/strong&gt;에 넣는 게 맞습니다.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;74-멀티-입력-join-n8n-패턴&quot;&gt;7.4 멀티 입력 Join (n8n 패턴)&lt;/h3&gt;

&lt;p&gt;“온도 조건 &lt;strong&gt;AND&lt;/strong&gt; 습도 조건”을 합류시키려면 &lt;strong&gt;merge 노드&lt;/strong&gt;가 두 입력을 모두 받을 때까지 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;waiting&lt;/code&gt; 큐에 둡니다. MVP에서는 &lt;strong&gt;단일 compare 체인&lt;/strong&gt;으로 피하고, v2에서 join을 도입해도 늦지 않습니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;8-llm으로-자연어--워크플로우-편집&quot;&gt;8. LLM으로 자연어 → 워크플로우 편집&lt;/h2&gt;

&lt;h3 id=&quot;81-파이프라인&quot;&gt;8.1 파이프라인&lt;/h3&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;사용자 발화
  → LLM (structured output: GraphPatch[])
  → validate_graph(merged_definition)
  → 실패 시 LLM에 에러 메시지 feedback (최대 2회)
  → 성공 시 DRAFT version 저장 + diff 요약 반환
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;GraphPatch&lt;/strong&gt; 예 — 전체 그래프를 매번 생성하지 말고 &lt;strong&gt;변경 조각&lt;/strong&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;patches&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;p&quot;&gt;[&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;span class=&quot;nl&quot;&gt;&quot;op&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;upsert_node&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;node&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
        &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;id&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;n_compare_temp&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;kind&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;condition&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;type&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;sensor_threshold&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;config&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;device_id&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;temp-A1&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;metric&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;temp&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;operator&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;gt&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;value&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;mi&quot;&gt;28&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;p&quot;&gt;}&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;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;op&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;upsert_edge&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;edge&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;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;id&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;e1&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;source&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;n_trigger_zone&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;target&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;n_compare_temp&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;source_port&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;out&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;target_port&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;in&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;p&quot;&gt;}&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;span class=&quot;nl&quot;&gt;&quot;summary_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;A동 온도 28℃ 초과 시 환기창 30% 개방 룰을 추가했습니다.&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;h3 id=&quot;82-django-ninja--llm-엔드포인트&quot;&gt;8.2 Django Ninja + LLM 엔드포인트&lt;/h3&gt;

&lt;div class=&quot;language-python 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;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;NaturalLanguageEditIn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Schema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;workflow_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;UUID&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;base_version&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;NaturalLanguageEditOut&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Schema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;version&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;summary&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;definition&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;WorkflowDefinitionSchema&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;validation_warnings&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;list&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[]&lt;/span&gt;

&lt;span class=&quot;o&quot;&gt;@&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;router&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/workflows/nl-edit&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;NaturalLanguageEditOut&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;nl_edit&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;NaturalLanguageEditIn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;base&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;WorkflowVersion&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;objects&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;workflow_id&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;workflow_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;version&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;base_version&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;patches&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;llm_generate_patches&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;user_message&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;current_definition&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;base&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;definition&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;device_catalog&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;list_devices&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;workflow_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;merged&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;apply_patches&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;base&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;definition&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;patches&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;errors&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;validate_graph&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;WorkflowDefinitionSchema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;**&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;merged&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;errors&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;patches&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;llm_fix_patches&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;merged&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;errors&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;merged&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;apply_patches&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;base&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;definition&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;patches&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;errors&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;validate_graph&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;WorkflowDefinitionSchema&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;**&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;merged&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;errors&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;raise&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HttpError&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;422&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;validation_errors&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;errors&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;new_version&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;clone_as_draft&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;base&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;merged&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;version&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;new_version&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;version&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;summary&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;patches&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;summary_ko&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;definition&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;merged&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;83-llm에-넘길-컨텍스트&quot;&gt;8.3 LLM에 넘길 컨텍스트&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;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;definition&lt;/code&gt; JSON&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;device_catalog&lt;/code&gt; (id, type, zone, metric)&lt;/td&gt;
      &lt;td&gt;환각 device_id 방지&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;허용 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;node types&lt;/code&gt; + config JSON Schema&lt;/td&gt;
      &lt;td&gt;structured output 범위 제한&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;금지 규칙&lt;/strong&gt; (예: 난방 OFF + 환기 100% 동시)&lt;/td&gt;
      &lt;td&gt;안전 정책&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;제어에 직결되는 명령은 &lt;strong&gt;LLM이 액추에이터 MQTT를 직접 쏘지 않게&lt;/strong&gt; 하고, 반드시 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;validate_graph&lt;/code&gt; → &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;publish&lt;/code&gt; → 엣지 런타임 경로만 타게 합니다. (&lt;a href=&quot;/django/2026/01/25/django-ninja-ai-integration-guide/&quot;&gt;Django Ninja AI 통합 가이드&lt;/a&gt;에서 다룬 structured output·에러 핸들링 패턴을 그대로 적용하면 됩니다.)&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;9-스마트팜-예시-워크플로우-개념&quot;&gt;9. 스마트팜 예시 워크플로우 (개념)&lt;/h2&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;[sensor_threshold: temp-A1 &amp;gt; 28℃]
        │ true
        ▼
[debounce: 60s]
        │ passed
        ▼
[actuator_command: vent-A1 set_position 30%]
        │
        ▼
[delay: 5min]
        │
        ▼
[sensor_threshold: temp-A1 &amp;gt; 27℃]  ← 여전히 높으면
        │ true
        ▼
[notification: slack #farm-alerts]
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;MQTT 토픽 설계는 &lt;a href=&quot;/iot/2026/05/06/smartfarm-sensor-realtime-architecture/&quot;&gt;센서 아키텍처 글&lt;/a&gt;의 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;farm/{zone}/sensor&lt;/code&gt;와 맞추고, 제어는 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;farm/{zone}/actuator/{id}/command&lt;/code&gt;로 분리합니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;10-아직-열어둔-질문들-고민-메모&quot;&gt;10. 아직 열어둔 질문들 (고민 메모)&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;현재 leaning&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;그래프 저장&lt;/td&gt;
      &lt;td&gt;JSON blob only vs Node/Edge 테이블&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;버전 스냅샷은 JSON&lt;/strong&gt;, 검색·감사 필요 노드만 테이블&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;실행 위치&lt;/td&gt;
      &lt;td&gt;클라우드 only vs 엣지 primary&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;엣지 primary&lt;/strong&gt;, 클라우드는 시뮬·이력&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;실시간 UI&lt;/td&gt;
      &lt;td&gt;폴링 vs SSE vs WS&lt;/td&gt;
      &lt;td&gt;실행 로그는 &lt;strong&gt;SSE&lt;/strong&gt;, 제어 명령은 &lt;strong&gt;MQTT/WS&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;LLM 역할&lt;/td&gt;
      &lt;td&gt;그래프 편집 only vs 런타임 노드&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;편집 only&lt;/strong&gt; (v1), 분석 리포트는 v2&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;버전 동시 편집&lt;/td&gt;
      &lt;td&gt;optimistic lock vs CRDT&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;version + etag&lt;/strong&gt; 낙관적 잠금&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;11-mvp-구현-순서-제안&quot;&gt;11. MVP 구현 순서 제안&lt;/h2&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;WorkflowVersion + definition JSON&lt;/strong&gt; CRUD (Django Ninja)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;validate_graph + simulate&lt;/strong&gt; (dry_run)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;compile → MQTT deploy&lt;/strong&gt; to gateway&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;엣지 런타임&lt;/strong&gt; Python/Node 소형 인터프리터&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;React Flow&lt;/strong&gt; 캔버스 + simulate 하이라이트&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;nl-edit&lt;/strong&gt; LLM 패치 (device_catalog 필수)&lt;/li&gt;
&lt;/ol&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;12-정리&quot;&gt;12. 정리&lt;/h2&gt;

&lt;p&gt;워크플로우 UI는 스마트팜에서 &lt;strong&gt;“센서 → 조건 → 제어”를 한 화면에&lt;/strong&gt;, &lt;strong&gt;슬라이더·시뮬레이션으로 안전하게&lt;/strong&gt;, &lt;strong&gt;자연어로 진입 장벽을 낮추는&lt;/strong&gt; 인터페이스입니다. 구현의 본질은 예쁜 캔버스가 아니라 &lt;strong&gt;(1) 정규화된 그래프 데이터&lt;/strong&gt;, &lt;strong&gt;(2) 검증 가능한 배포 파이프라인&lt;/strong&gt;, &lt;strong&gt;(3) 엣지 친화 실행 엔진&lt;/strong&gt;, &lt;strong&gt;(4) 감사 가능한 실행 이력&lt;/strong&gt;입니다.&lt;/p&gt;

&lt;p&gt;Node-RED의 단순한 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;wires&lt;/code&gt;, n8n의 실행 스택, Losant의 device state 트리거에서 각각 한 가지씩 가져오면, Django Ninja 위에 &lt;strong&gt;스마트팜 전용 제어 플레인&lt;/strong&gt;을 단계적으로 쌓을 수 있습니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;참고-자료&quot;&gt;참고 자료&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/node-red/node-red/wiki/Flow-Format&quot;&gt;Node-RED Flow Format&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://docs.n8n.io/data/data-structure/&quot;&gt;n8n Data structure&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://docs.losant.com/workflows/overview&quot;&gt;Losant Workflows Overview&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://docs.losant.com/workflows/triggers/device-state&quot;&gt;Losant Device State Trigger&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://auxx.ai/blog/workflows-part-1-visual-editor&quot;&gt;Building a Visual Workflow Engine (Part 1 &amp;amp; 2) — auxx.ai&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/automation/2026/03/03/n8n-workflow-automation-guide/&quot;&gt;이 블로그: n8n 워크플로우 자동화 가이드&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;/iot/2026/05/06/smartfarm-sensor-realtime-architecture/&quot;&gt;이 블로그: 스마트팜 센서 실시간 아키텍처&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content><author><name>updaun</name></author><category term="iot" /><category term="django" /><category term="architecture" /><category term="smartfarm" /><category term="workflow" /><category term="django-ninja" /><category term="iot" /><category term="node-red" /><category term="n8n" /><category term="llm" /><summary type="html">스마트팜 워크플로우 아키텍처 설계 — 센서·제어·LLM을 Django Ninja로 모델링하기</summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-05-19-smartfarm-workflow-architecture-django-ninja.webp" /><media:content medium="image" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-05-19-smartfarm-workflow-architecture-django-ninja.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">백엔드 개발자 포트폴리오 작성 노하우 — 채용 담당자가 보는 것과 구체적 예시</title><link href="https://updaun.github.io/career/2026/05/17/backend-developer-portfolio-writing-guide/" rel="alternate" type="text/html" title="백엔드 개발자 포트폴리오 작성 노하우 — 채용 담당자가 보는 것과 구체적 예시" /><published>2026-05-17T00:00:00+09:00</published><updated>2026-05-17T00:00:00+09:00</updated><id>https://updaun.github.io/career/2026/05/17/backend-developer-portfolio-writing-guide</id><content type="html" xml:base="https://updaun.github.io/career/2026/05/17/backend-developer-portfolio-writing-guide/">&lt;h1 id=&quot;백엔드-개발자-포트폴리오-작성-노하우--채용-담당자가-보는-것과-구체적-예시&quot;&gt;백엔드 개발자 포트폴리오 작성 노하우 — 채용 담당자가 보는 것과 구체적 예시&lt;/h1&gt;

&lt;p&gt;백엔드 포트폴리오는 &lt;strong&gt;“무슨 기술을 썼다”&lt;/strong&gt; 가 아니라 &lt;strong&gt;“어떤 문제를, 어떤 제약 안에서, 어떻게 풀었고, 그 결과가 무엇이었는지”&lt;/strong&gt; 가 보여야 합니다. 프론트는 화면 한 장으로 설득할 수 있지만, 백엔드는 코드와 아키텍처·운영 지표가 말해 줘야 합니다.&lt;/p&gt;

&lt;p&gt;이 글은 실제로 이력서·GitHub·Notion 포트폴리오를 검토할 때 기준이 되는 항목을 정리하고, &lt;strong&gt;나쁜 예 → 좋은 예&lt;/strong&gt; 로 바꿔 쓸 수 있는 문장·구조를 그대로 제공합니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;1-채용-담당자면접관이-3분-안에-보는-것&quot;&gt;1. 채용 담당자·면접관이 3분 안에 보는 것&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;1&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;한 줄 요약&lt;/strong&gt;&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;&lt;strong&gt;대표 프로젝트 1~2개&lt;/strong&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;strong&gt;숫자·범위&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;과장이 아닌지, 스케일 감각이 있는지&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;4&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;본인 기여&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;팀 프로젝트에서 무엇을 책임졌는지&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;5&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;GitHub / 문서 링크&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;말과 코드가 일치하는지&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;프론트 포트폴리오처럼 &lt;strong&gt;스크린샷만 크게&lt;/strong&gt; 두면 백엔드에서는 “API가 뭐가 있지?”부터 다시 찾게 됩니다. &lt;strong&gt;문제 → 설계 → 구현 포인트 → 결과(지표)&lt;/strong&gt; 순서가 읽기 가장 빠릅니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;2-포트폴리오-한-페이지-구조-복붙용-템플릿&quot;&gt;2. 포트폴리오 한 페이지 구조 (복붙용 템플릿)&lt;/h2&gt;

&lt;div class=&quot;language-markdown 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;gh&quot;&gt;# 홍길동 | Backend Developer&lt;/span&gt;

&lt;span class=&quot;gu&quot;&gt;## 한 줄 소개&lt;/span&gt;
Django/FastAPI 기반 B2B API 설계·운영 경험. 결제·예약 도메인에서 동시성·트랜잭션 이슈를 다룬 경력 3년.

&lt;span class=&quot;gu&quot;&gt;## 대표 프로젝트 (2개만)&lt;/span&gt;
&lt;span class=&quot;gu&quot;&gt;### [1] 티켓 예약 API — 동시성 제어&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; 문제: 선착순 오픈 시 중복 예약·초과 판매
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; 역할: API 설계, DB 락 전략, 부하 테스트
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; 결과: 오픈 10분 3,200 req/s, 중복 예약 0건 (2주 운영)

&lt;span class=&quot;gu&quot;&gt;### [2] 주문·결제 마이크로서비스 분리&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; 문제: 단일 모놀리스 배포 시 결제 장애가 주문 전체 중단
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; 역할: 주문/결제 경계 정의, Outbox 패턴 도입
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; 결과: 결제 장애 시 주문 조회는 유지, 장애 복구 시간 40분 → 8분

&lt;span class=&quot;gu&quot;&gt;## 기술 스택 (쓴 것만)&lt;/span&gt;
Python, Django, PostgreSQL, Redis, Celery, AWS ECS, GitHub Actions

&lt;span class=&quot;gu&quot;&gt;## 링크&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; GitHub: ...
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; 기술 블로그: ...
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; API 문서(Swagger): ...
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;핵심:&lt;/strong&gt; 프로젝트는 최대 &lt;strong&gt;2~3개&lt;/strong&gt;, 나머지는 “기타 프로젝트” 한 줄 링크로 접어두는 편이 낫습니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;3-프로젝트-1건을-쓸-때--star--기술-깊이&quot;&gt;3. 프로젝트 1건을 쓸 때 — STAR + 기술 깊이&lt;/h2&gt;

&lt;p&gt;면접에서 자주 쓰는 &lt;strong&gt;STAR&lt;/strong&gt;(Situation, Task, Action, Result)에 백엔드만의 &lt;strong&gt;Technical Depth&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;S&lt;/td&gt;
      &lt;td&gt;어떤 상황이었나&lt;/td&gt;
      &lt;td&gt;사내 B2B SaaS, 월 활성 고객사 120곳, 피크 시간 API p95 2.1초&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;T&lt;/td&gt;
      &lt;td&gt;내가 맡은 과제는&lt;/td&gt;
      &lt;td&gt;예약 슬롯 API 응답 500ms 이하, 중복 예약 0&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;A&lt;/td&gt;
      &lt;td&gt;무엇을 했나&lt;/td&gt;
      &lt;td&gt;비관적 락 → Redis 선점 + DB unique 제약으로 2단계 검증&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;R&lt;/td&gt;
      &lt;td&gt;결과는&lt;/td&gt;
      &lt;td&gt;p95 380ms, 오픈 트래픽 2주간 중복 0건&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;T (기술)&lt;/td&gt;
      &lt;td&gt;왜 그 선택인가&lt;/td&gt;
      &lt;td&gt;락만 쓰면 커넥션 풀 고갈 → 선점으로 DB 부하 분산&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;나쁜-예-vs-좋은-예&quot;&gt;나쁜 예 vs 좋은 예&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;나쁜 예 (기술 나열형)&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Django로 REST API를 만들었습니다. JWT 인증, Celery, Redis를 사용했습니다. 배포는 AWS입니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;좋은 예 (문제·선택·결과형)&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;선착순 공연 티켓 오픈에서 &lt;strong&gt;동시 요청 3,000건/분&lt;/strong&gt; 시 중복 예약이 발생했습니다.&lt;br /&gt;
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SELECT FOR UPDATE&lt;/code&gt;만으로는 커넥션 풀이 고갈되어, &lt;strong&gt;Redis &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SETNX&lt;/code&gt;로 1차 좌석 선점&lt;/strong&gt; 후 PostgreSQL &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UNIQUE(seat_id, show_id)&lt;/code&gt;로 최종 확정하도록 설계했습니다.&lt;br /&gt;
k6 부하 테스트로 오픈 시나리오를 재현했고, &lt;strong&gt;2주 운영 기간 중복 예약 0건&lt;/strong&gt;, API p95 &lt;strong&gt;420ms → 290ms&lt;/strong&gt;를 확인했습니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;차이는 단순합니다. &lt;strong&gt;“뭘 썼다”&lt;/strong&gt; 가 아니라 &lt;strong&gt;“왜 그걸 썼고, 무엇이 나아졌는지”&lt;/strong&gt; 입니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;4-숫자-넣는-법--과장-없이-설득하기&quot;&gt;4. 숫자 넣는 법 — 과장 없이 설득하기&lt;/h2&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;일 50만 요청, 피크 800 RPS&lt;/td&gt;
      &lt;td&gt;평균만 쓰지 말고 피크도&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;지연&lt;/td&gt;
      &lt;td&gt;p95 380ms, p99 720ms&lt;/td&gt;
      &lt;td&gt;평균만 쓰면 의심받기 쉬움&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;가용성&lt;/td&gt;
      &lt;td&gt;월 가용성 99.9%&lt;/td&gt;
      &lt;td&gt;SLA 정의를 한 줄로&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;데이터&lt;/td&gt;
      &lt;td&gt;일 200만 row 적재, 파티션 12개월&lt;/td&gt;
      &lt;td&gt;보관·쿼리 전략과 연결&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;비용/효율&lt;/td&gt;
      &lt;td&gt;RDS 비용 30% 절감 (읽기 replica 분리)&lt;/td&gt;
      &lt;td&gt;Before/After 기간 명시&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;팀&lt;/td&gt;
      &lt;td&gt;백엔드 2명, API 40개 엔드포인트&lt;/td&gt;
      &lt;td&gt;규모 감각&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;blockquote&gt;
  &lt;p&gt;주문 조회 API가 Black Friday에 &lt;strong&gt;p95 3.2초&lt;/strong&gt;까지 튀어 CS 문의가 늘었습니다.&lt;br /&gt;
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;order_items&lt;/code&gt; N+1 제거 + 커버링 인덱스 추가 후 &lt;strong&gt;p95 480ms&lt;/strong&gt;, 해당 기간 CS &lt;strong&gt;주문 조회 문의 62% 감소&lt;/strong&gt;(2주 비교).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;숫자가 없을 때:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;부하 테스트(k6) 시나리오: 오픈 1분간 가상 사용자 500명, 좌석 1,000석 — &lt;strong&gt;중복 예약 재현 후 수정, 재테스트에서 0건&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;“측정은 못 했지만 재현·검증은 했다”도 가치가 있습니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;5-아키텍처는-그림-1장--본인-담당-범위&quot;&gt;5. 아키텍처는 그림 1장 + 본인 담당 범위&lt;/h2&gt;

&lt;p&gt;텍스트만으로도 ASCII로 충분합니다. &lt;strong&gt;전체 그림&lt;/strong&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;[Client] → [ALB] → [API (Django)] → [PostgreSQL]
                         ↓
                    [Redis] ← 선점 키 seat:{show}:{seat}
                         ↓
                    [Celery] → 결제 웹훅 처리
&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;blockquote&gt;
  &lt;p&gt;위 구조에서 &lt;strong&gt;API·Redis 선점·DB 제약·Celery 웹훅 idempotency&lt;/strong&gt;를 설계·구현했습니다. 인프라(Terraform)는 동료가 담당했습니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;팀 프로젝트에서 &lt;strong&gt;“전체를 다 했다”&lt;/strong&gt; 고 쓰면 면접에서 바로 파고듭니다. &lt;strong&gt;경계를 명시&lt;/strong&gt;하는 것이 오히려 신뢰를 줍니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;6-github-readme--백엔드용-최소-세트&quot;&gt;6. GitHub README — 백엔드용 최소 세트&lt;/h2&gt;

&lt;p&gt;저장소만 던지면 리뷰어는 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;views.py&lt;/code&gt;부터 뒤집습니다. README에 아래를 넣으세요.&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;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;docker compose up&lt;/code&gt; 한 줄이라도)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;API 문서 링크&lt;/strong&gt; (Swagger/OpenAPI)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;디렉터리 구조&lt;/strong&gt; (domain / infra / tests)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;의도적 트레이드오프&lt;/strong&gt; (“왜 Kafka 안 썼는지”)&lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;readme-나쁜-예&quot;&gt;README 나쁜 예&lt;/h3&gt;

&lt;div class=&quot;language-markdown 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;gh&quot;&gt;# ticket-api&lt;/span&gt;
Django project
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;readme-좋은-예&quot;&gt;README 좋은 예&lt;/h3&gt;

&lt;div class=&quot;language-markdown 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;gh&quot;&gt;# ticket-api&lt;/span&gt;

선착순 공연 좌석 예약 API. 중복 예약 방지를 위해 Redis 선점 + DB UNIQUE 제약을 사용합니다.

&lt;span class=&quot;gu&quot;&gt;## Quick start&lt;/span&gt;
docker compose up -d
curl http://localhost:8000/api/docs

&lt;span class=&quot;gu&quot;&gt;## Architecture&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;sb&quot;&gt;`apps/booking/`&lt;/span&gt;: 예약 도메인, 좌석 선점 서비스
&lt;span class=&quot;p&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;sb&quot;&gt;`apps/payment/`&lt;/span&gt;: 결제 웹훅 (idempotent by payment_id)

&lt;span class=&quot;gu&quot;&gt;## Load test&lt;/span&gt;
k6 run scripts/opening_spike.js
&lt;span class=&quot;gh&quot;&gt;# 시나리오: VU 500, 60s — README에 결과 스크린샷 링크&lt;/span&gt;
&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;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tests/&lt;/code&gt;에 &lt;strong&gt;동시성·웹훅 중복&lt;/strong&gt; 테스트 1개라도 있으면 차별화됩니다.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;docs/adr/001-seat-lock-strategy.md&lt;/code&gt; 처럼 &lt;strong&gt;결정 기록(ADR)&lt;/strong&gt; 이 있으면 시니어 감각으로 읽힙니다.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;7-경력별로-강조할-것&quot;&gt;7. 경력별로 강조할 것&lt;/h2&gt;

&lt;h3 id=&quot;주니어-02년&quot;&gt;주니어 (0~2년)&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;끝까지 배포·운영해 본 토이/사이드&lt;/strong&gt; 1개 (도메인은 단순해도 됨)&lt;/li&gt;
  &lt;li&gt;CRUD만이 아니라 &lt;strong&gt;한 가지 난이도&lt;/strong&gt; (인증, 페이지네이션, 트랜잭션, 테스트)&lt;/li&gt;
  &lt;li&gt;“혼자 다 했다”보다 &lt;strong&gt;학습 과정·실패·수정&lt;/strong&gt;이 솔직한 글&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;예시 한 줄&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;사이드: 알림 서비스 — Celery retry 정책을 3회 exponential backoff로 바꿔 &lt;strong&gt;실패 알림 18% → 4%&lt;/strong&gt; (1주 로그 기준).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;미들-35년&quot;&gt;미들 (3~5년)&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;장애·성능·리팩토링&lt;/strong&gt; 스토리 1개&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;다른 팀/프론트와의 API 계약&lt;/strong&gt; (버전링, breaking change 대응)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;관측&lt;/strong&gt; (로그 구조, 메트릭, 알람)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;예시 한 줄&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;결제 PG 타임아웃 시 주문 상태 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PENDING_PAYMENT&lt;/code&gt; 고착 — Outbox + 재시도 워커로 &lt;strong&gt;24시간 내 자동 복구율 94%&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;시니어&quot;&gt;시니어&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;시스템 경계·조직적 결정&lt;/strong&gt; (모놀리스 분리, 팀 구조, SLA)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;비용·리스크·일정&lt;/strong&gt; 트레이드오프&lt;/li&gt;
  &lt;li&gt;후배 온보딩·코딩 컨벤션·리뷰 문화 (리더십)&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;8-자주-하는-실수-7가지&quot;&gt;8. 자주 하는 실수 7가지&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;기술 스택만 20줄&lt;/td&gt;
      &lt;td&gt;역량이 아니라 키워드 나열로 보임&lt;/td&gt;
      &lt;td&gt;프로젝트당 스택 3~5개만&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;데모 URL 없음&lt;/td&gt;
      &lt;td&gt;백엔드는 실행·문서가 신뢰&lt;/td&gt;
      &lt;td&gt;Swagger + docker compose&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;평균 응답시간만&lt;/td&gt;
      &lt;td&gt;피크에서 무너지는 경우 많음&lt;/td&gt;
      &lt;td&gt;p95/p99, 피크 RPS&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;비밀 없는데 코드 비공개&lt;/td&gt;
      &lt;td&gt;검증 불가&lt;/td&gt;
      &lt;td&gt;핵심 로직만 공개 repo 또는 gist&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;AI 생성 README 그대로&lt;/td&gt;
      &lt;td&gt;면접에서 깊이 질문 시 불일치&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;깊이 없어 보임&lt;/td&gt;
      &lt;td&gt;대표 2개 + 나머지 링크&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;9-실전-포트폴리오-문단-before--after-3종&quot;&gt;9. 실전: 포트폴리오 문단 Before / After (3종)&lt;/h2&gt;

&lt;h3 id=&quot;1-사내-레거시-api-개선&quot;&gt;(1) 사내 레거시 API 개선&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Before&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;레거시 API를 리팩토링했습니다. 성능이 좋아졌습니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;After&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;주문 목록 API가 &lt;strong&gt;N+1 쿼리 47회/요청&lt;/strong&gt;으로 p95 &lt;strong&gt;2.8초&lt;/strong&gt;였습니다.&lt;br /&gt;
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;prefetch_related&lt;/code&gt; + 목록용 DTO projection 도입 후 &lt;strong&gt;쿼리 3회, p95 410ms&lt;/strong&gt;.&lt;br /&gt;
배포 전 &lt;strong&gt;staging에서 동일 픽스처 1만 건&lt;/strong&gt;으로 회귀 테스트 자동화했습니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;2-신규-서비스-01&quot;&gt;(2) 신규 서비스 0→1&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Before&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;FastAPI로 채팅 서버를 만들었습니다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;After&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;실시간 상담 &lt;strong&gt;동시 접속 200명&lt;/strong&gt; 요구. WebSocket + Redis Pub/Sub로 인스턴스 2대 수평 확장.&lt;br /&gt;
연결 끊김 시 &lt;strong&gt;마지막 읽은 message_id&lt;/strong&gt; 기준 재동기화 API 제공.&lt;br /&gt;
4주 파일럿에서 &lt;strong&gt;메시지 유실 재현 0건&lt;/strong&gt;(장애 주입 테스트 포함).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;3-인프라배포&quot;&gt;(3) 인프라·배포&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Before&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Docker, AWS 사용 경험 있음.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;After&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;GitHub Actions로 &lt;strong&gt;main 머지 시 ECS 롤링 배포&lt;/strong&gt;, 헬스체크 실패 시 자동 롤백.&lt;br /&gt;
배포 시간 &lt;strong&gt;22분 → 9분&lt;/strong&gt;, 배포 중 5xx 비율 &lt;strong&gt;0.3% 이하&lt;/strong&gt; 유지(CloudWatch 대시보드 캡처 첨부).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;10-제출-전-체크리스트-30분-점검&quot;&gt;10. 제출 전 체크리스트 (30분 점검)&lt;/h2&gt;

&lt;ul class=&quot;task-list&quot;&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;상단 &lt;strong&gt;한 줄 소개&lt;/strong&gt;에 도메인·년차·강점 키워드 1개&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;대표 프로젝트 &lt;strong&gt;2개&lt;/strong&gt;에 문제/역할/결과/숫자&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;팀 프로젝트 &lt;strong&gt;본인 담당&lt;/strong&gt; 박스 표시&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;GitHub README에 &lt;strong&gt;실행 방법 + API 문서&lt;/strong&gt;&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;과장 없는 &lt;strong&gt;지표&lt;/strong&gt; (없으면 부하 테스트·재현 시나리오)&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;민감 정보 제거 (키, 내부 URL, 고객사 실명)&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;링크 전부 &lt;strong&gt;클릭 테스트&lt;/strong&gt; (만료된 Swagger 주의)&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;마무리&quot;&gt;마무리&lt;/h2&gt;

&lt;p&gt;백엔드 포트폴리오의 본질은 &lt;strong&gt;“이 사람이 프로덕션에서 무엇을 걱정할 줄 아는가”&lt;/strong&gt; 입니다. 프레임워크 이름보다 &lt;strong&gt;동시성, 일관성, 장애, 관측, API 계약&lt;/strong&gt;이 문장 속에 드러나야 합니다.&lt;/p&gt;

&lt;p&gt;위 템플릿과 Before/After만 프로젝트 2개에 맞게 채워도, 이력서·Notion·GitHub 프로필 중 어디에 두어도 &lt;strong&gt;면접 질문이 ‘무슨 기술 썼어요?’가 아니라 ‘왜 그렇게 설계했어요?’로 바뀌는&lt;/strong&gt; 수준까지는 올라갑니다.&lt;/p&gt;

&lt;p&gt;다음 글에서는 &lt;strong&gt;“포트폴리오용 사이드 프로젝트 아이디어 5가지 + 2주 MVP 범위”&lt;/strong&gt; 를 주제로 정리해 볼 예정입니다.&lt;/p&gt;</content><author><name>updaun</name></author><category term="career" /><summary type="html">백엔드 개발자 포트폴리오 작성 노하우 — 채용 담당자가 보는 것과 구체적 예시</summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-05-17-backend-developer-portfolio-writing-guide.webp" /><media:content medium="image" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-05-17-backend-developer-portfolio-writing-guide.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">교회 홈페이지를 만들기 전에 — 서울 대형교회 웹 벤치마킹과 체크리스트</title><link href="https://updaun.github.io/web%20development/%EA%B8%B0%ED%9A%8D/2026/05/12/seoul-megachurch-website-benchmark-church-web-planning/" rel="alternate" type="text/html" title="교회 홈페이지를 만들기 전에 — 서울 대형교회 웹 벤치마킹과 체크리스트" /><published>2026-05-12T00:00:00+09:00</published><updated>2026-05-12T00:00:00+09:00</updated><id>https://updaun.github.io/web%20development/%EA%B8%B0%ED%9A%8D/2026/05/12/seoul-megachurch-website-benchmark-church-web-planning</id><content type="html" xml:base="https://updaun.github.io/web%20development/%EA%B8%B0%ED%9A%8D/2026/05/12/seoul-megachurch-website-benchmark-church-web-planning/">&lt;h1 id=&quot;교회-홈페이지를-만들기-전에--서울-대형교회-웹-벤치마킹과-체크리스트&quot;&gt;교회 홈페이지를 만들기 전에 — 서울 대형교회 웹 벤치마킹과 체크리스트&lt;/h1&gt;

&lt;p&gt;곧 교회 홈페이지를 맡게 될 예정이라, &lt;strong&gt;무엇을 기준으로 설계할지&lt;/strong&gt;부터 정리해 두고 싶었습니다. 교회 사이트는 일반 기업 랜딩과 달리 이해관계자가 많고, 콘텐츠 유형(설교, 행사, 헌금, 교육)도 섞여 있어서 초기에 방향을 잡지 않으면 나중에 구조가 자주 뒤집힙니다.&lt;/p&gt;

&lt;p&gt;이 글에서는 (1) &lt;strong&gt;서울권 대형 한국교회&lt;/strong&gt;가 실제로 어떤 웹·서브도메인 구조를 쓰는지 벤치마킹 포인트만 짚고, (2) &lt;strong&gt;개발·운영 전에 고려할 항목&lt;/strong&gt;을 체크리스트 형태로 정리합니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;1-왜-대형교회-사이트를-벤치마킹하는가&quot;&gt;1. 왜 “대형교회 사이트”를 벤치마킹하는가&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;/li&gt;
  &lt;li&gt;&lt;strong&gt;새가족·헌금·봉사 신청&lt;/strong&gt; 등 민감한 기능이 공존&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;즉, “예쁜 홈페이지”가 아니라 &lt;strong&gt;운영 프로세스와 정보 구조(IA)&lt;/strong&gt; 를 참고하는 것이 목적입니다. 기술 스택까지 그대로 따라 할 필요는 없고, &lt;strong&gt;정보를 어디에 두고 어떻게 나누는지&lt;/strong&gt;를 보는 것이 핵심입니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;2-서울-대형교회-웹--벤치마킹할-때-볼-url과-관찰-포인트&quot;&gt;2. 서울 대형교회 웹 — 벤치마킹할 때 볼 URL과 관찰 포인트&lt;/h2&gt;

&lt;p&gt;아래는 공개적으로 널리 쓰이는 &lt;strong&gt;대표 도메인&lt;/strong&gt;입니다. 실제 서비스는 서브도메인·별도 앱으로 나뉘는 경우가 많으니, 브라우저 개발자 도구로 리다이렉트·CDN만 훑어도 “어디까지를 웹으로 묶었는지” 감이 잡힙니다.&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;a href=&quot;https://www.onnuri.or.kr/&quot;&gt;onnuri.or.kr&lt;/a&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;a href=&quot;http://vision.onnuri.or.kr/&quot;&gt;vision.onnuri.or.kr&lt;/a&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;a href=&quot;https://www.sarang.org/&quot;&gt;sarang.org&lt;/a&gt;&lt;/td&gt;
      &lt;td&gt;설교·QT·교육 콘텐츠 허브형 구조&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;온사랑(분교 등)&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://onsarang.org/&quot;&gt;onsarang.org&lt;/a&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;a href=&quot;https://www.fgtv.com/&quot;&gt;fgtv.com&lt;/a&gt;&lt;/td&gt;
      &lt;td&gt;방송·예배 중심 IA, 회원·등록 기능&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;FGTV 라이브&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://live.fgtv.com/&quot;&gt;live.fgtv.com&lt;/a&gt;&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;라이브만&lt;/strong&gt; 분리한 서브도메인 전략&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;ul&gt;
  &lt;li&gt;첫 화면에서 &lt;strong&gt;누구를 위해&lt;/strong&gt; 무엇을 노출하는가 (새가족 vs 정기 출석 vs 해외 방문자).&lt;/li&gt;
  &lt;li&gt;설교·방송은 &lt;strong&gt;자체 호스팅&lt;/strong&gt;인지, &lt;strong&gt;YouTube 임베드&lt;/strong&gt; 위주인지.&lt;/li&gt;
  &lt;li&gt;행사·소식은 &lt;strong&gt;뉴스형 목록&lt;/strong&gt;인지, &lt;strong&gt;캘린더·PDF 안내&lt;/strong&gt;인지.&lt;/li&gt;
  &lt;li&gt;로그인이 필요한 영역(헌금 내역, 교육 이수 등)이 &lt;strong&gt;메인과 어떻게 분리&lt;/strong&gt;되는지.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;3-교회-홈페이지-기획개발-전-체크리스트&quot;&gt;3. 교회 홈페이지 기획·개발 전 체크리스트&lt;/h2&gt;

&lt;h3 id=&quot;31-목적과-우선순위&quot;&gt;3.1 목적과 우선순위&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;1차 사용자&lt;/strong&gt; 정의: 새가족, 정기 교인, 청년·부서 담당, 언론·협력 기관 등.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;필수 기능 vs 나중에&lt;/strong&gt;: 예를 들어 “헌금 영수증 조회”는 보안·개인정보 이슈가 커서 초기에 범위를 정해야 합니다.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;32-정보-구조ia와-운영&quot;&gt;3.2 정보 구조(IA)와 운영&lt;/h3&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;: 구글 캘린더 연동 vs CMS 내 캘린더 vs PDF만 배포 — 유지보수 난이도가 크게 달라집니다.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;33-접근성다국어&quot;&gt;3.3 접근성·다국어&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;웹 접근성(KWCAG/WCAG)은 공공기관만의 이야기가 아니고, &lt;strong&gt;고령 교인·시각 장애 교인&lt;/strong&gt;에게 직접 영향을 줍니다.&lt;/li&gt;
  &lt;li&gt;영어·중국어 페이지가 필요한지, &lt;strong&gt;요약 페이지만&lt;/strong&gt; 있으면 되는지.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;34-법윤리-특히-헌금개인정보&quot;&gt;3.4 법·윤리 (특히 헌금·개인정보)&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;개인정보처리방침, &lt;strong&gt;헌금 관련 안내&lt;/strong&gt; 문구, 미성년자 사진·실명 노출 정책.&lt;/li&gt;
  &lt;li&gt;온라인 헌금을 넣는다면 PG·금융 규정, &lt;strong&gt;영수증·기부금 증명&lt;/strong&gt; 흐름을 사전에 법무·회계와 맞출 것.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;35-기술-선택-현실적인-스펙트럼&quot;&gt;3.5 기술 선택 (현실적인 스펙트럼)&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;정적 사이트 + 외부 서비스&lt;/strong&gt;: Jekyll/Hugo 등 + YouTube + 구글 폼/예약 링크. 운영 부담이 가장 적습니다.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;CMS (워드프레스, 카페24, 아임웹 등)&lt;/strong&gt;: 비개발자 편집이 중요할 때.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;커스텀 백엔드&lt;/strong&gt;: 헌금·출석·교육 이수 등 &lt;strong&gt;업무 규칙이 복잡&lt;/strong&gt;할 때만 단계적으로 도입하는 편이 안전합니다.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;36-성능보안장애-대응&quot;&gt;3.6 성능·보안·장애 대응&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;주일·특별집회 시간대 &lt;strong&gt;트래픽 스파이크&lt;/strong&gt; 가정 (캐시, CDN, 이미지 최적화).&lt;/li&gt;
  &lt;li&gt;관리자 계정 &lt;strong&gt;2FA&lt;/strong&gt;, 백업, SSL, 폼 스팸 방지.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;37-측정&quot;&gt;3.7 측정&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;“방문자 수”만이 아니라 &lt;strong&gt;새가족 등록 전환&lt;/strong&gt;, &lt;strong&gt;설교 페이지 체류&lt;/strong&gt;, &lt;strong&gt;행사 페이지에서 오프라인 참여&lt;/strong&gt; 등 교회가 원하는 지표를 먼저 합의합니다.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;4-정리&quot;&gt;4. 정리&lt;/h2&gt;

&lt;p&gt;교회 웹은 &lt;strong&gt;디자인 시안&lt;/strong&gt;보다 먼저 &lt;strong&gt;누가 무엇을 얼마나 자주 갱신하는지&lt;/strong&gt;가 정해져야 합니다. 서울 대형교회 사이트들은 그 답을 “도메인 분리·방송 분리·부서/캠퍼스 분리”로 풀어 놓은 사례가 많으니, 우리 교회 규모에 맞게 &lt;strong&gt;축소·단순화해서&lt;/strong&gt; 가져오면 됩니다.&lt;/p&gt;

&lt;p&gt;다음 단계로는 교회 측과 &lt;strong&gt;페르소나 2~3명&lt;/strong&gt;과 &lt;strong&gt;MVP 기능 목록 10개 이내&lt;/strong&gt;만 잡고, 그다음 IA 스케치를 하는 순서를 추천합니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;em&gt;이 글에 적힌 URL은 작성 시점 기준 공개 정보이며, 교회 정책에 따라 변경될 수 있습니다.&lt;/em&gt;&lt;/p&gt;</content><author><name>updaun</name></author><category term="Web Development" /><category term="기획" /><summary type="html">교회 홈페이지를 만들기 전에 — 서울 대형교회 웹 벤치마킹과 체크리스트</summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-05-12-seoul-megachurch-website-benchmark-church-web-planning.webp" /><media:content medium="image" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-05-12-seoul-megachurch-website-benchmark-church-web-planning.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">스마트팜 센서 데이터 아키텍처 - 수집부터 실시간 대시보드까지</title><link href="https://updaun.github.io/iot/2026/05/06/smartfarm-sensor-realtime-architecture/" rel="alternate" type="text/html" title="스마트팜 센서 데이터 아키텍처 - 수집부터 실시간 대시보드까지" /><published>2026-05-06T00:00:00+09:00</published><updated>2026-05-06T00:00:00+09:00</updated><id>https://updaun.github.io/iot/2026/05/06/smartfarm-sensor-realtime-architecture</id><content type="html" xml:base="https://updaun.github.io/iot/2026/05/06/smartfarm-sensor-realtime-architecture/">&lt;h1 id=&quot;스마트팜-센서-데이터-아키텍처---수집부터-실시간-대시보드까지&quot;&gt;스마트팜 센서 데이터 아키텍처 - 수집부터 실시간 대시보드까지&lt;/h1&gt;

&lt;p&gt;스마트팜 프로젝트를 시작하면 가장 먼저 부딪히는 질문이 있습니다.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;“온도 센서값을 1초마다 받는데, 이걸 다 DB에 넣어야 하나요? 프론트엔드에는 어떻게 실시간으로 보여주죠?”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;답은 단순하지 않습니다. &lt;strong&gt;얼마나 자주 저장할지, 얼마나 오래 보관할지, 어떤 채널로 프론트에 밀어줄지&lt;/strong&gt;는 모두 농장 규모와 의사결정 주기에 따라 달라집니다. 이번 포스트에서는 실제로 운영되는 스마트팜의 데이터 파이프라인을 분석하고, 효율적인 아키텍처를 단계별로 정리합니다.&lt;/p&gt;

&lt;hr /&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;[온도 센서 / DS18B20, SHT3x 등]
        ↓ 1Hz ~ 10Hz
[게이트웨이 / Raspberry Pi, ESP32]
        ↓ MQTT (QoS 1)
[브로커 / EMQX, Mosquitto, AWS IoT Core]
        ↓
   ┌────┴────────────────────────┐
   ↓                             ↓
[스트림 처리]                  [실시간 fan-out]
Telegraf / Kafka              Redis Pub/Sub
   ↓                             ↓
[시계열 DB]                   [WebSocket 서버]
TimescaleDB / InfluxDB           ↓
   ↓                          [브라우저 대시보드]
[다운샘플링 / 연속 집계]
   ↓
[장기 보관 - S3 Parquet]
&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;hr /&gt;

&lt;h2 id=&quot;1-센서-측에서의-결정---얼마나-자주-측정할까&quot;&gt;1. 센서 측에서의 결정 - 얼마나 자주 측정할까?&lt;/h2&gt;

&lt;p&gt;대부분의 스마트팜 온실 환경에서 온도는 &lt;strong&gt;물리적으로 빨리 변하지 않습니다&lt;/strong&gt;. 1초에 0.01℃ 단위로 진동하는 값을 모두 저장하는 건 의미가 없습니다.&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;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;5초 ~ 1분&lt;/td&gt;
      &lt;td&gt;환경 변화가 느림, 액추에이터 제어 주기와 일치&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;CO₂&lt;/td&gt;
      &lt;td&gt;30초 ~ 1분&lt;/td&gt;
      &lt;td&gt;센서 자체가 평균값 출력&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;광량(PAR)&lt;/td&gt;
      &lt;td&gt;1초 ~ 10초&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분 ~ 5분&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;환기창 제어용&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;strong&gt;현장 팁&lt;/strong&gt;: 게이트웨이에서 &lt;strong&gt;이동 평균(moving average)&lt;/strong&gt; 또는 &lt;strong&gt;변화량 임계치(deadband)&lt;/strong&gt; 를 적용해 노이즈를 걸러낸 뒤 전송하면 데이터량이 5~10배 줄어듭니다.&lt;/p&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# ESP32 / 게이트웨이의 deadband 필터 예시
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;last_sent&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;DEADBAND&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;0.2&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# 0.2℃ 이상 변화할 때만 전송
&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;maybe_publish&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;temp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;float&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;global&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;last_sent&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;last_sent&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;is&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;or&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;abs&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;temp&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;last_sent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;DEADBAND&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;mqtt_client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;publish&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;farm/zone1/temp&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;temp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;qos&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;last_sent&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;temp&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;2-전송-프로토콜---왜-mqtt인가&quot;&gt;2. 전송 프로토콜 - 왜 MQTT인가&lt;/h2&gt;

&lt;p&gt;HTTP POST로도 보낼 수 있지만, 센서 100개가 동시에 1초마다 POST를 날리면 게이트웨이 CPU와 모바일 회선이 먼저 죽습니다. 스마트팜에서는 거의 표준처럼 &lt;strong&gt;MQTT&lt;/strong&gt;를 씁니다.&lt;/p&gt;

&lt;h3 id=&quot;mqtt가-적합한-이유&quot;&gt;MQTT가 적합한 이유&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;상시 연결(Long-lived TCP)&lt;/strong&gt;: 매 메시지마다 핸드셰이크 비용 없음&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Pub/Sub 구조&lt;/strong&gt;: 센서는 토픽에 발행만, 소비자(저장/대시보드)는 구독만&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;QoS 레벨&lt;/strong&gt;: 농장 인터넷이 끊겨도 재전송 보장(QoS 1, 2)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Last Will&lt;/strong&gt;: 게이트웨이 다운을 즉시 감지&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# Python paho-mqtt 게이트웨이 예시
&lt;/span&gt;&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;paho.mqtt.client&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;mqtt&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;time&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;mqtt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;client_id&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;gw-zone1&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;username_pw_set&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;gateway&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;secret&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;will_set&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;farm/zone1/status&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;offline&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;qos&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;retain&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;connect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;broker.farm.local&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1883&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;publish&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;farm/zone1/status&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;online&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;qos&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;retain&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;while&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;ts&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1000&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;temp&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;read_ds18b20&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;&quot;humidity&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;read_sht3x_humidity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;publish&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;farm/zone1/sensor&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;dumps&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;qos&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sleep&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;10&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&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;farm/{지역}/{구역}/{센서종류}&lt;/code&gt; 같은 계층 구조로 잡아두면 나중에 와일드카드 구독(&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;farm/+/+/temp&lt;/code&gt;)으로 유연하게 처리할 수 있습니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;3-데이터베이스---시계열-db가-정답인-이유&quot;&gt;3. 데이터베이스 - 시계열 DB가 정답인 이유&lt;/h2&gt;

&lt;p&gt;PostgreSQL이나 MySQL에 그냥 넣으면 안 되냐? 처음에는 됩니다. &lt;strong&gt;6개월 뒤에 안 됩니다.&lt;/strong&gt;&lt;/p&gt;

&lt;h3 id=&quot;일반-rdbms의-문제&quot;&gt;일반 RDBMS의 문제&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;센서 1개 × 10초 주기 = 하루 8,640행&lt;/li&gt;
  &lt;li&gt;센서 100개 × 1년 = &lt;strong&gt;3.15억 행&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;B-tree 인덱스가 비대해져 INSERT 성능 급락&lt;/li&gt;
  &lt;li&gt;“지난 1주일 시간당 평균”같은 쿼리가 매번 풀스캔&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;시계열-db가-해결하는-것&quot;&gt;시계열 DB가 해결하는 것&lt;/h3&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;기능&lt;/th&gt;
      &lt;th&gt;일반 DB&lt;/th&gt;
      &lt;th&gt;시계열 DB&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;자동 (chunk/shard)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;압축&lt;/td&gt;
      &lt;td&gt;없음&lt;/td&gt;
      &lt;td&gt;10~20배 (열 지향)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;다운샘플링&lt;/td&gt;
      &lt;td&gt;직접 SQL&lt;/td&gt;
      &lt;td&gt;Continuous Aggregate / Task&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;보존 정책&lt;/td&gt;
      &lt;td&gt;직접 DELETE&lt;/td&gt;
      &lt;td&gt;Retention Policy로 자동 삭제&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;ul&gt;
  &lt;li&gt;&lt;strong&gt;TimescaleDB&lt;/strong&gt;: PostgreSQL 확장. 기존 SQL 그대로 쓰고 싶으면 1순위&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;InfluxDB&lt;/strong&gt;: 자체 쿼리(Flux/InfluxQL). 작은 농장에 OSS로 띄우기 편함&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;QuestDB / VictoriaMetrics&lt;/strong&gt;: 대규모 처리용&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;timescaledb-실전-스키마&quot;&gt;TimescaleDB 실전 스키마&lt;/h3&gt;

&lt;div class=&quot;language-sql 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;c1&quot;&gt;-- 1. 하이퍼테이블 생성 (시간 기준 자동 파티션)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;CREATE&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;TABLE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;sensor_data&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;nb&quot;&gt;time&lt;/span&gt;        &lt;span class=&quot;n&quot;&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;NOT&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;NULL&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;farm_id&lt;/span&gt;     &lt;span class=&quot;nb&quot;&gt;INT&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;NOT&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;NULL&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;zone_id&lt;/span&gt;     &lt;span class=&quot;nb&quot;&gt;INT&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;NOT&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;NULL&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;sensor_id&lt;/span&gt;   &lt;span class=&quot;nb&quot;&gt;TEXT&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;NOT&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;NULL&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;temperature&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;DOUBLE&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;PRECISION&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;humidity&lt;/span&gt;    &lt;span class=&quot;nb&quot;&gt;DOUBLE&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;PRECISION&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;co2&lt;/span&gt;         &lt;span class=&quot;nb&quot;&gt;DOUBLE&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;PRECISION&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;create_hypertable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;sensor_data&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;time&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;chunk_time_interval&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;INTERVAL&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;1 day&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;CREATE&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;INDEX&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;ON&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;sensor_data&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;farm_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;zone_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;time&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;DESC&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;-- 2. 압축 정책 (7일 지난 청크 자동 압축)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;ALTER&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;TABLE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;sensor_data&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;SET&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;timescaledb&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;compress&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;timescaledb&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;compress_segmentby&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;farm_id, zone_id, sensor_id&apos;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;add_compression_policy&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;sensor_data&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;INTERVAL&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;7 days&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;-- 3. 1분 단위 연속 집계 (대시보드는 이 뷰를 조회)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;CREATE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;MATERIALIZED&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;VIEW&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;sensor_data_1m&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;WITH&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;timescaledb&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;continuous&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;time_bucket&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;1 minute&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;bucket&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;farm_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;zone_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;sensor_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;avg&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;temperature&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;avg_temp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;max&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;temperature&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;max_temp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;min&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;temperature&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AS&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;min_temp&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;sensor_data&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;GROUP&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;BY&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;bucket&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;farm_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;zone_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;sensor_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;add_continuous_aggregate_policy&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;sensor_data_1m&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;start_offset&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;INTERVAL&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;1 hour&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;end_offset&lt;/span&gt;   &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;INTERVAL&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;1 minute&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;schedule_interval&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;INTERVAL&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;1 minute&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;-- 4. 보존 정책 (원본은 90일만, 집계는 영구 보관)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;add_retention_policy&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;&apos;sensor_data&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;INTERVAL&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;90 days&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;이 구조를 잡으면 &lt;strong&gt;“지난 1년간 8월 평균 온도”&lt;/strong&gt; 같은 쿼리도 1초 안에 끝납니다. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sensor_data_1m&lt;/code&gt;을 다시 1시간/1일 단위로 cascading 집계하면 더 빨라집니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;4-얼마나-오래-보관해야-하나&quot;&gt;4. 얼마나 오래 보관해야 하나?&lt;/h2&gt;

&lt;p&gt;스마트팜에서 데이터는 &lt;strong&gt;목적에 따라 보존 기간이 다릅니다.&lt;/strong&gt; 하나의 정책으로 통일하면 비용이 폭발합니다.&lt;/p&gt;

&lt;h3 id=&quot;계층화된-보존-전략-hot--warm--cold&quot;&gt;계층화된 보존 전략 (Hot / Warm / Cold)&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;┌─────────────────────────────────────────────────────┐
│ Hot (7일)   - 원본 raw, 압축 X, 빠른 조회            │
│             - 실시간 대시보드, 알람, 제어 로직       │
├─────────────────────────────────────────────────────┤
│ Warm (90일) - 원본 압축, 1분 집계 활성              │
│             - 주간/월간 리포트, 이상 탐지           │
├─────────────────────────────────────────────────────┤
│ Cold (5년+) - 1시간/1일 집계만 + S3 Parquet 백업    │
│             - 연도별 비교, 작황 분석, 정부 보고서   │
└─────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&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;ul&gt;
  &lt;li&gt;센서 50개 × 10초 주기 × 8바이트(double) × 6개 채널 = &lt;strong&gt;약 21MB/일&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;1년치 raw = 7.6GB → TimescaleDB 압축 후 &lt;strong&gt;약 500MB&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;1분 집계 1년치 = 약 50MB&lt;/li&gt;
  &lt;li&gt;1일 집계 10년치 = 약 1MB&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;→ 단일 농장은 100GB SSD면 충분합니다. 비용보다 &lt;strong&gt;쿼리 속도&lt;/strong&gt;와 &lt;strong&gt;백업 전략&lt;/strong&gt;에 집중하세요.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;5-프론트엔드로-실시간-전달하기---무엇을-쓸까&quot;&gt;5. 프론트엔드로 실시간 전달하기 - 무엇을 쓸까&lt;/h2&gt;

&lt;p&gt;여기서 많은 분들이 헷갈립니다. &lt;strong&gt;WebSocket이 항상 정답은 아닙니다.&lt;/strong&gt;&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;th&gt;단점&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;HTTP Polling&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;1분 이상 주기, 사용자 적음&lt;/td&gt;
      &lt;td&gt;빈번한 요청, 지연&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;SSE (Server-Sent Events)&lt;/strong&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;strong&gt;WebSocket&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;양방향, 다채널, 제어 명령 송신&lt;/td&gt;
      &lt;td&gt;인프라 복잡, 스케일링 시 sticky session 필요&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;MQTT over WebSocket&lt;/strong&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;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;프론트가 데이터를 받기만 하나? (제어 안 함)
   ├─ Yes → SSE 추천 (구현 가장 간단)
   └─ No  → WebSocket
              ├─ 양방향 메시지 적음(채팅 X) → 일반 WebSocket
              └─ 토픽이 많고 권한이 복잡 → MQTT over WS
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;대부분의 스마트팜 대시보드는 &lt;strong&gt;SSE로 충분합니다&lt;/strong&gt;. “온도 그래프 실시간 업데이트 + 가끔 알람”이라면 WebSocket이 오히려 오버엔지니어링입니다.&lt;/p&gt;

&lt;h3 id=&quot;django--sse-최소-구현&quot;&gt;Django + SSE 최소 구현&lt;/h3&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# views.py - StreamingHttpResponse + Redis Pub/Sub
&lt;/span&gt;&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;redis&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;django.http&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;StreamingHttpResponse&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;r&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;redis&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Redis&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;host&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;localhost&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;decode_responses&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;sensor_stream&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;farm_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;event_stream&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;():&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;pubsub&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;r&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pubsub&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;pubsub&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;subscribe&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;farm:&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;farm_id&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;:sensor&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;c1&quot;&gt;# 최초 진입 시 마지막 값 즉시 전송
&lt;/span&gt;        &lt;span class=&quot;n&quot;&gt;last&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;r&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;farm:&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;farm_id&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;:last&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;last&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;yield&lt;/span&gt; &lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;data: &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;last&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;msg&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;pubsub&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;listen&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;():&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;msg&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;type&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;message&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
                &lt;span class=&quot;k&quot;&gt;yield&lt;/span&gt; &lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;data: &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;msg&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;data&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;StreamingHttpResponse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;event_stream&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;content_type&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;text/event-stream&quot;&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Cache-Control&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;no-cache&quot;&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;X-Accel-Buffering&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;no&quot;&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# Nginx 버퍼링 방지
&lt;/span&gt;    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-javascript 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;c1&quot;&gt;// 프론트엔드 - 3줄로 끝남&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;es&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;EventSource&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;`/api/farms/&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;${&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;farmId&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;/stream/`&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;nx&quot;&gt;es&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;onmessage&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;temp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;humidity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;ts&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;JSON&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;parse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;chart&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;append&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;ts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;temp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;};&lt;/span&gt;
&lt;span class=&quot;nx&quot;&gt;es&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;onerror&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;console&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;log&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;재연결 자동 처리됨&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;mqtt--redis--sse-브리지&quot;&gt;MQTT → Redis → SSE 브리지&lt;/h3&gt;

&lt;p&gt;MQTT 메시지를 받아 Redis로 fan-out하는 작은 워커 하나만 띄우면 됩니다.&lt;/p&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# bridge.py - 백그라운드 워커
&lt;/span&gt;&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;paho.mqtt.client&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;mqtt&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;redis&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;json&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;r&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;redis&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Redis&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;on_message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;userdata&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;msg&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;farm_id&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;msg&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;topic&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;split&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)[&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# farm/zone1/sensor
&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;data&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;msg&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;decode&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;r&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;publish&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;farm:&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;farm_id&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;:sensor&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;r&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;set&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;farm:&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;farm_id&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;:last&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ex&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;3600&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# 마지막 값 캐시
&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;mqtt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;on_message&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;on_message&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;connect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;broker.farm.local&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;subscribe&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;farm/+/sensor&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;loop_forever&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;6-효율적인-스마트팜은-실제로-어떻게-하고-있나&quot;&gt;6. 효율적인 스마트팜은 실제로 어떻게 하고 있나&lt;/h2&gt;

&lt;p&gt;국내외 대형 스마트팜(네덜란드 Priva, 국내 그린플러스, 우듬지팜 등) 운영 패턴을 보면 공통점이 있습니다.&lt;/p&gt;

&lt;h3 id=&quot;공통-패턴-5가지&quot;&gt;공통 패턴 5가지&lt;/h3&gt;

&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Edge에서 1차 가공&lt;/strong&gt;: 게이트웨이가 평균/이상치를 계산해서 네트워크로 보냄. 클라우드 비용을 70% 이상 절감.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;제어 루프와 분석 루프 분리&lt;/strong&gt;: 환기창/난방 제어는 게이트웨이 로컬에서 즉시 처리(레이턴시 50ms 이하). 클라우드는 분석/시각화만.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;알람은 Push, 그래프는 Pull/Stream&lt;/strong&gt;: 임계치 초과는 즉시 푸시 알림(FCM/카카오)으로, 일반 그래프는 SSE로. 채널을 분리해야 알람 누락이 없습니다.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;모든 시계열 데이터에 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;device_id&lt;/code&gt;와 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;firmware_version&lt;/code&gt; 태그&lt;/strong&gt;: 센서 교체나 펌웨어 업데이트 시 데이터 단절을 추적할 수 있어야 분석이 신뢰됩니다.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;재처리 가능한 파이프라인&lt;/strong&gt;: Kafka 또는 MQTT 영구 구독으로 raw 메시지를 7일 이상 보관. 집계 로직 버그 발생 시 다시 돌릴 수 있어야 합니다.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;실제-사례---네덜란드-토마토-농장&quot;&gt;실제 사례 - 네덜란드 토마토 농장&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;1ha 온실에 센서 약 800개&lt;/li&gt;
  &lt;li&gt;측정 주기는 센서별로 차등 (광량 1초, 토양 5분)&lt;/li&gt;
  &lt;li&gt;TimescaleDB 단일 노드로 운영 (압축 후 연 30GB)&lt;/li&gt;
  &lt;li&gt;대시보드는 Grafana + 자체 React 앱&lt;/li&gt;
  &lt;li&gt;React 앱은 &lt;strong&gt;MQTT over WebSocket 직접 구독&lt;/strong&gt; (Django 같은 중간 서버 없음)&lt;/li&gt;
  &lt;li&gt;제어는 별도 PLC + OPC-UA, 클라우드는 권고만 내림&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;여기서 배울 점은 &lt;strong&gt;“센서 → DB → API → 프론트”라는 전형적 흐름을 깨고, 실시간 채널은 별도로 빼는 것&lt;/strong&gt;입니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;7-실수하기-쉬운-함정&quot;&gt;7. 실수하기 쉬운 함정&lt;/h2&gt;

&lt;p&gt;직접 운영하면서 만나는 실수들입니다.&lt;/p&gt;

&lt;h3 id=&quot;-insert마다-트랜잭션-커밋&quot;&gt;❌ INSERT마다 트랜잭션 커밋&lt;/h3&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# 안 좋은 예
&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;sensor&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;sensors&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;cursor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;execute&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;INSERT ...&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;sensor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;conn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;commit&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# 매번 커밋 → 디스크 fsync 폭증
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;→ 1초 단위 배치 INSERT 또는 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;COPY&lt;/code&gt; 사용. TimescaleDB는 한 번에 1만 행 INSERT가 가장 효율적.&lt;/p&gt;

&lt;h3 id=&quot;-타임존-없는-타임스탬프&quot;&gt;❌ 타임존 없는 타임스탬프&lt;/h3&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TIMESTAMP WITHOUT TIME ZONE&lt;/code&gt;으로 저장하면 서버 이전, DST 처리에서 반드시 사고가 납니다. &lt;strong&gt;항상 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TIMESTAMPTZ&lt;/code&gt; + UTC 저장&lt;/strong&gt;.&lt;/p&gt;

&lt;h3 id=&quot;-websocket-한-연결에-모든-토픽&quot;&gt;❌ WebSocket 한 연결에 모든 토픽&lt;/h3&gt;

&lt;p&gt;대시보드가 5개 페이지인데 센서 200개를 모두 한 WebSocket으로 밀면, 사용자가 페이지 하나만 열어도 200개 데이터를 다 받습니다. &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;p&gt;브라우저에 1주일치 1초 데이터(60만 포인트)를 보내면 Canvas가 멈춥니다. &lt;strong&gt;서버에서 화면 픽셀 수에 맞춰 LTTB 다운샘플링&lt;/strong&gt; 후 전송하세요.&lt;/p&gt;

&lt;div class=&quot;language-sql 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;c1&quot;&gt;-- TimescaleDB의 LTTB 함수&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;value&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;unnest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;lttb&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;temperature&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1000&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
     &lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;sensor_data&lt;/span&gt;
     &lt;span class=&quot;k&quot;&gt;WHERE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;farm_id&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;AND&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;time&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;now&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;INTERVAL&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;7 days&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;8-정리---단계별-체크리스트&quot;&gt;8. 정리 - 단계별 체크리스트&lt;/h2&gt;

&lt;p&gt;스마트팜을 처음 설계한다면 다음 순서로 가세요.&lt;/p&gt;

&lt;ul class=&quot;task-list&quot;&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;센서별 측정 주기를 환경 변화 속도에 맞게 차등 설정&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;게이트웨이에서 deadband 필터 + MQTT QoS 1로 전송&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;TimescaleDB 하이퍼테이블 + 1분 연속 집계 + 7일 압축 정책&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;원본 90일 / 집계 5년 / S3 Parquet 백업의 3계층 보존&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;실시간 채널은 SSE를 1순위 검토, 양방향 필요 시 WebSocket&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;MQTT → Redis Pub/Sub → SSE/WebSocket 브리지 워커 분리&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;알람은 별도 Push 채널, 임계치 로직은 Edge에서&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;모든 시계열에 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;device_id&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;firmware_version&lt;/code&gt; 태그&lt;/li&gt;
  &lt;li class=&quot;task-list-item&quot;&gt;&lt;input type=&quot;checkbox&quot; class=&quot;task-list-item-checkbox&quot; disabled=&quot;disabled&quot; /&gt;차트는 LTTB 다운샘플링으로 1000포인트 이하&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;마치며&quot;&gt;마치며&lt;/h2&gt;

&lt;p&gt;스마트팜 데이터 시스템의 본질은 &lt;strong&gt;“실시간성”과 “장기 분석”이라는 서로 다른 요구를 한 파이프라인에서 풀려고 하지 않는 것&lt;/strong&gt;입니다. 측정 주기, 저장 형식, 보존 기간, 전달 채널을 모두 분리해서 설계해야 합니다.&lt;/p&gt;

&lt;p&gt;작게 시작한다면 &lt;strong&gt;MQTT + TimescaleDB + Django SSE&lt;/strong&gt; 조합으로 충분합니다. 농장이 커지면 Kafka, 분산 처리, MQTT over WebSocket으로 점진적으로 확장하세요. 처음부터 Kafka를 끌고 오는 건 99% 오버엔지니어링입니다.&lt;/p&gt;

&lt;p&gt;다음 포스트에서는 이 아키텍처 위에 &lt;strong&gt;이상 탐지(Anomaly Detection)와 예측 제어&lt;/strong&gt;를 얹는 방법을 다뤄보겠습니다.&lt;/p&gt;</content><author><name>updaun</name></author><category term="iot" /><summary type="html">스마트팜 센서 데이터 아키텍처 - 수집부터 실시간 대시보드까지</summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-05-06-smartfarm-sensor-realtime-architecture.webp" /><media:content medium="image" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-05-06-smartfarm-sensor-realtime-architecture.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">YouTube 구조를 참고해 웨딩 라이브 송출 시스템 설계하기</title><link href="https://updaun.github.io/django/2026/04/30/youtube-inspired-wedding-live-streaming/" rel="alternate" type="text/html" title="YouTube 구조를 참고해 웨딩 라이브 송출 시스템 설계하기" /><published>2026-04-30T00:00:00+09:00</published><updated>2026-04-30T00:00:00+09:00</updated><id>https://updaun.github.io/django/2026/04/30/youtube-inspired-wedding-live-streaming</id><content type="html" xml:base="https://updaun.github.io/django/2026/04/30/youtube-inspired-wedding-live-streaming/">&lt;h1 id=&quot;youtube-구조를-참고해-웨딩-라이브-송출-시스템-설계하기&quot;&gt;YouTube 구조를 참고해 웨딩 라이브 송출 시스템 설계하기&lt;/h1&gt;

&lt;p&gt;저번 포스트에서 Django + Nginx-RTMP로 웨딩 라이브 스트리밍 시스템의 기초를 구현했습니다. 이번엔 한 걸음 더 나아가 &lt;strong&gt;“YouTube는 어떻게 동작하는가?”&lt;/strong&gt; 를 분석하고, 그 구조를 웨딩 서비스에 맞게 현실적으로 가져오는 방법을 설계합니다. OBS 없이 스마트폰만으로 송출하는 구조까지 포함합니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;youtube-live-streaming-구조-분석&quot;&gt;YouTube Live Streaming 구조 분석&lt;/h2&gt;

&lt;p&gt;YouTube가 전 세계 수백만 명에게 라이브를 서빙할 수 있는 이유는 기술 자체가 특별해서가 아닙니다. &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;[송출자]
    ↓ RTMP / RTMPS
[인제스트 서버] — 전 세계 엣지, 가장 가까운 곳으로 연결
    ↓
[트랜스코더] — 1080p / 720p / 480p / 360p 동시 생성
    ↓
[패키저] — HLS / DASH 세그먼트(~2초) 생성
    ↓
[CDN] — 전 세계 PoP에서 시청자 근처로 서빙
    ↓
[시청자] — ABR로 네트워크 상황에 따라 자동 해상도 전환
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&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;RTMP&lt;/td&gt;
      &lt;td&gt;~0.5초&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;인제스트 → 트랜스코딩&lt;/td&gt;
      &lt;td&gt;내부 처리&lt;/td&gt;
      &lt;td&gt;~1초&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;트랜스코딩 → CDN&lt;/td&gt;
      &lt;td&gt;HLS 세그먼트&lt;/td&gt;
      &lt;td&gt;~2~4초&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;CDN → 시청자&lt;/td&gt;
      &lt;td&gt;HLS ABR&lt;/td&gt;
      &lt;td&gt;~5~10초&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;/td&gt;
      &lt;td&gt;&lt;strong&gt;약 15~30초&lt;/strong&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;DASH chunked&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;약 3~7초&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;우리가-가져올-것-버릴-것&quot;&gt;우리가 가져올 것, 버릴 것&lt;/h2&gt;

&lt;p&gt;YouTube의 구조는 &lt;strong&gt;수백만 명&lt;/strong&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;YouTube&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;단일 Nginx-RTMP&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;FFmpeg (서버 1대)&lt;/td&gt;
      &lt;td&gt;수백 명엔 충분&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;다중 해상도&lt;/td&gt;
      &lt;td&gt;5~6개&lt;/td&gt;
      &lt;td&gt;720p / 360p 2개&lt;/td&gt;
      &lt;td&gt;모바일 대역폭 배려&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;배포&lt;/td&gt;
      &lt;td&gt;Google CDN&lt;/td&gt;
      &lt;td&gt;Cloudflare Stream 또는 직서빙&lt;/td&gt;
      &lt;td&gt;비용·복잡도 절감&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;실시간 채팅&lt;/td&gt;
      &lt;td&gt;WebSocket 전용 인프라&lt;/td&gt;
      &lt;td&gt;Django Channels&lt;/td&gt;
      &lt;td&gt;규모 충분&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;송출 도구&lt;/td&gt;
      &lt;td&gt;RTMP 모든 클라이언트&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;스마트폰 앱 (OBS 불필요)&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;현장 편의성&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;핵심은 &lt;strong&gt;“RTMP 인제스트 → FFmpeg 트랜스코딩 → HLS 배포”&lt;/strong&gt; 파이프라인은 YouTube와 동일하게 가져오되, 규모만 현실에 맞게 줄이는 것입니다.&lt;/p&gt;

&lt;hr /&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;[스마트폰 앱 - Larix Broadcaster or 커스텀 앱]
            ↓ RTMP
      [Nginx-RTMP 서버]
            ↓
      [FFmpeg 트랜스코더]
       ↓           ↓
  720p HLS      360p HLS    ← 다중 해상도 (YouTube 방식 차용)
       ↓           ↓
  [Django Static / Cloudflare CDN]
            ↓
      [시청자 브라우저]
       - hls.js ABR 재생    ← YouTube 방식 차용
       - WebSocket 축하 메시지
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;구현-단계&quot;&gt;구현 단계&lt;/h2&gt;

&lt;h3 id=&quot;1단계-nginx-rtmp--ffmpeg-다중-해상도-트랜스코딩&quot;&gt;1단계: Nginx-RTMP + FFmpeg 다중 해상도 트랜스코딩&lt;/h3&gt;

&lt;p&gt;YouTube에서 가장 핵심적으로 차용할 부분입니다. 단일 스트림을 받아 &lt;strong&gt;720p와 360p를 동시에 생성&lt;/strong&gt;합니다.&lt;/p&gt;

&lt;div class=&quot;language-nginx 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;c1&quot;&gt;# /etc/nginx/nginx.conf&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;rtmp&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kn&quot;&gt;server&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kn&quot;&gt;listen&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1935&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
        &lt;span class=&quot;kn&quot;&gt;chunk_size&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;4096&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

        &lt;span class=&quot;kn&quot;&gt;application&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;live&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;kn&quot;&gt;live&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;on&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
            &lt;span class=&quot;kn&quot;&gt;record&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;off&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

            &lt;span class=&quot;c1&quot;&gt;# 수신 즉시 FFmpeg로 다중 해상도 트랜스코딩&lt;/span&gt;
            &lt;span class=&quot;kn&quot;&gt;exec&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;ffmpeg&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;-i&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;rtmp://localhost/live/&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$name&lt;/span&gt;
                &lt;span class=&quot;c1&quot;&gt;# 720p&lt;/span&gt;
                &lt;span class=&quot;s&quot;&gt;-c:v&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;libx264&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;-preset&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;veryfast&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;-b:v&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;2000k&lt;/span&gt;
                &lt;span class=&quot;s&quot;&gt;-maxrate&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;2000k&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;-bufsize&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;4000k&lt;/span&gt;
                &lt;span class=&quot;s&quot;&gt;-vf&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;scale=-2:720&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;-g&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;60&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;-c:a&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;aac&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;-b:a&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;128k&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;-ac&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;2&lt;/span&gt;
                &lt;span class=&quot;s&quot;&gt;-f&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;flv&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;rtmp://localhost/hls720/&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$name&lt;/span&gt;

                &lt;span class=&quot;c1&quot;&gt;# 360p&lt;/span&gt;
                &lt;span class=&quot;s&quot;&gt;-c:v&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;libx264&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;-preset&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;veryfast&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;-b:v&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;600k&lt;/span&gt;
                &lt;span class=&quot;s&quot;&gt;-maxrate&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;600k&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;-bufsize&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1200k&lt;/span&gt;
                &lt;span class=&quot;s&quot;&gt;-vf&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;scale=-2:360&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;-g&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;60&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;-c:a&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;aac&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;-b:a&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;96k&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;-ac&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;2&lt;/span&gt;
                &lt;span class=&quot;s&quot;&gt;-f&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;flv&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;rtmp://localhost/hls360/&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

        &lt;span class=&quot;c1&quot;&gt;# 720p HLS 생성&lt;/span&gt;
        &lt;span class=&quot;kn&quot;&gt;application&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;hls720&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;kn&quot;&gt;live&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;on&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
            &lt;span class=&quot;kn&quot;&gt;hls&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;on&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
            &lt;span class=&quot;kn&quot;&gt;hls_path&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;/var/www/hls/720&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
            &lt;span class=&quot;kn&quot;&gt;hls_fragment&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;2s&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
            &lt;span class=&quot;kn&quot;&gt;hls_playlist_length&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;10s&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
            &lt;span class=&quot;kn&quot;&gt;deny&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;play&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;all&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

        &lt;span class=&quot;c1&quot;&gt;# 360p HLS 생성&lt;/span&gt;
        &lt;span class=&quot;kn&quot;&gt;application&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;hls360&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;kn&quot;&gt;live&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;on&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
            &lt;span class=&quot;kn&quot;&gt;hls&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;on&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
            &lt;span class=&quot;kn&quot;&gt;hls_path&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;/var/www/hls/360&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
            &lt;span class=&quot;kn&quot;&gt;hls_fragment&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;2s&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
            &lt;span class=&quot;kn&quot;&gt;hls_playlist_length&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;10s&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
            &lt;span class=&quot;kn&quot;&gt;deny&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;play&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;all&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;server&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kn&quot;&gt;listen&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;80&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

    &lt;span class=&quot;kn&quot;&gt;location&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;/hls&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kn&quot;&gt;alias&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;/var/www/hls&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
        &lt;span class=&quot;kn&quot;&gt;add_header&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;Cache-Control&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;no-cache&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
        &lt;span class=&quot;kn&quot;&gt;add_header&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;Access-Control-Allow-Origin&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
        &lt;span class=&quot;kn&quot;&gt;types&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;kn&quot;&gt;application/vnd.apple.mpegurl&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;m3u8&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
            &lt;span class=&quot;kn&quot;&gt;video/mp2t&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;ts&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;이 설정으로 스마트폰에서 RTMP 송출을 시작하는 순간 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/var/www/hls/720/&lt;/code&gt;과 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/var/www/hls/360/&lt;/code&gt; 에 HLS 세그먼트가 자동 생성됩니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h3 id=&quot;2단계-master-playlist-생성-abr---youtube-방식&quot;&gt;2단계: Master Playlist 생성 (ABR - YouTube 방식)&lt;/h3&gt;

&lt;p&gt;YouTube가 네트워크 상황에 따라 해상도를 자동으로 전환하는 &lt;strong&gt;ABR(Adaptive Bitrate)&lt;/strong&gt; 을 구현합니다. 핵심은 두 해상도를 하나의 마스터 플레이리스트로 묶는 것입니다.&lt;/p&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# wedding_live/views.py
&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;django.http&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HttpResponse&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;master_playlist&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;stream_key&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;&quot;&quot;
    두 해상도를 묶는 HLS 마스터 플레이리스트를 동적으로 생성.
    hls.js가 이 파일을 읽고 네트워크 상황에 따라 자동 전환.
    &quot;&quot;&quot;&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;content&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&quot;&quot;#EXTM3U
#EXT-X-VERSION:3

#EXT-X-STREAM-INF:BANDWIDTH=2128000,RESOLUTION=1280x720
/hls/720/&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;stream_key&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;.m3u8

#EXT-X-STREAM-INF:BANDWIDTH=696000,RESOLUTION=640x360
/hls/360/&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;stream_key&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;.m3u8
&quot;&quot;&quot;&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HttpResponse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;content_type&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;application/vnd.apple.mpegurl&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# wedding_live/urls.py
&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;django.urls&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;path&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;.&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;views&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;urlpatterns&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;watch/&amp;lt;str:stream_key&amp;gt;/&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;views&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;watch&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;watch&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;stream/&amp;lt;str:stream_key&amp;gt;/master.m3u8&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;views&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;master_playlist&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;master_playlist&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h3 id=&quot;3단계-스마트폰-앱으로-송출-obs-대체&quot;&gt;3단계: 스마트폰 앱으로 송출 (OBS 대체)&lt;/h3&gt;

&lt;p&gt;OBS는 데스크탑 전용 소프트웨어입니다. 결혼식 현장에서는 스마트폰이 훨씬 현실적입니다.&lt;/p&gt;

&lt;h4 id=&quot;옵션-a-larix-broadcaster-앱-즉시-사용-가능&quot;&gt;옵션 A: Larix Broadcaster 앱 (즉시 사용 가능)&lt;/h4&gt;

&lt;p&gt;설치 후 Settings → Connections → New Connection에서 아래 정보만 입력합니다.&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: rtmp://서버IP/live
Stream Name: wedding_abc123
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;무료이며 iOS / Android 모두 지원합니다. 결혼 당일 담당자 스마트폰에 미리 설치하고 QR코드로 설정값을 전달하면 됩니다.&lt;/p&gt;

&lt;h4 id=&quot;옵션-b-커스텀-앱-내-송출-기능-flutter&quot;&gt;옵션 B: 커스텀 앱 내 송출 기능 (Flutter)&lt;/h4&gt;

&lt;p&gt;웨딩 서비스 자체 앱이 있다면 송출 기능을 직접 내장할 수 있습니다.&lt;/p&gt;

&lt;div class=&quot;language-yaml 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;c1&quot;&gt;# pubspec.yaml&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;dependencies&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;camera&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;^0.10.5&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;flutter_rtmp_publisher&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;^0.1.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-dart 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;c1&quot;&gt;// wedding_stream_page.dart&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;WeddingStreamPage&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;extends&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;StatefulWidget&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;final&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;String&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;streamKey&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;WeddingStreamPage&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt;&lt;span class=&quot;kd&quot;&gt;required&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;this&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;streamKey&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;

  &lt;span class=&quot;nd&quot;&gt;@override&lt;/span&gt;
  &lt;span class=&quot;n&quot;&gt;State&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;WeddingStreamPage&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;createState&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;_WeddingStreamPageState&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;_WeddingStreamPageState&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;extends&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;State&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;WeddingStreamPage&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;late&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;RtmpPublisher&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;_publisher&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;kt&quot;&gt;bool&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;_isStreaming&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;false&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

  &lt;span class=&quot;nd&quot;&gt;@override&lt;/span&gt;
  &lt;span class=&quot;kt&quot;&gt;void&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;initState&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;super&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;initState&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;_publisher&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;RtmpPublisher&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
      &lt;span class=&quot;nl&quot;&gt;url:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&apos;rtmp://서버IP/live&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;nl&quot;&gt;streamKey:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;widget&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;streamKey&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;nl&quot;&gt;videoConfig:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;VideoConfig&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;nl&quot;&gt;width:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1280&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;nl&quot;&gt;height:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;720&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;nl&quot;&gt;fps:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;30&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;nl&quot;&gt;bitrate:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;2000&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1024&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
      &lt;span class=&quot;nl&quot;&gt;audioConfig:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;AudioConfig&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;nl&quot;&gt;bitrate:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;128&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1024&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;nl&quot;&gt;sampleRate:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;44100&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

  &lt;span class=&quot;kt&quot;&gt;void&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;_toggleStream&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_isStreaming&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;_publisher&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;stop&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;_publisher&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;start&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;setState&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;_isStreaming&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_isStreaming&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

  &lt;span class=&quot;nd&quot;&gt;@override&lt;/span&gt;
  &lt;span class=&quot;n&quot;&gt;Widget&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;build&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;BuildContext&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;context&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Scaffold&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
      &lt;span class=&quot;nl&quot;&gt;body:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Stack&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;nl&quot;&gt;children:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
          &lt;span class=&quot;n&quot;&gt;CameraPreview&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_publisher&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;cameraController&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
          &lt;span class=&quot;n&quot;&gt;Positioned&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;nl&quot;&gt;bottom:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;40&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;nl&quot;&gt;child:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ElevatedButton&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
              &lt;span class=&quot;nl&quot;&gt;onPressed:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;_toggleStream&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
              &lt;span class=&quot;nl&quot;&gt;style:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ElevatedButton&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;styleFrom&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
                &lt;span class=&quot;nl&quot;&gt;backgroundColor:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;_isStreaming&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;?&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Colors&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;red&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Colors&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;pink&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
              &lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
              &lt;span class=&quot;nl&quot;&gt;child:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_isStreaming&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;?&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&apos;송출 중지&apos;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&apos;결혼식 라이브 시작&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
          &lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h3 id=&quot;4단계-django-channels---실시간-축하-메시지&quot;&gt;4단계: Django Channels - 실시간 축하 메시지&lt;/h3&gt;

&lt;p&gt;WebSocket Consumer는 이전 포스트와 동일한 구조입니다. 여기서는 &lt;strong&gt;이모지 반응&lt;/strong&gt; 기능을 추가합니다. 시청자가 이모지를 누르면 모든 시청자 화면에 이모지가 떠오르는 YouTube의 Super Thanks와 유사한 기능입니다.&lt;/p&gt;

&lt;div class=&quot;language-python 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;c1&quot;&gt;# wedding_live/consumers.py
&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;json&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;channels.generic.websocket&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;AsyncWebsocketConsumer&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;channels.db&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;database_sync_to_async&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;.models&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;WeddingEvent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Congratulation&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;WeddingConsumer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AsyncWebsocketConsumer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;connect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
        &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;stream_key&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;scope&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;url_route&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;][&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;kwargs&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;][&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;stream_key&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
        &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;room_group_name&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;wedding_&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;stream_key&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;&lt;/span&gt;

        &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;channel_layer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;group_add&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;room_group_name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;channel_name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;accept&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_broadcast_viewer_count&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;delta&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;disconnect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;close_code&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;channel_layer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;group_discard&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;room_group_name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;channel_name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_broadcast_viewer_count&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;delta&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=-&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;receive&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;text_data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;data&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;loads&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text_data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;type&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&apos;congratulation&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;congrat&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_save_congratulation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;channel_layer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;group_send&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;room_group_name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;s&quot;&gt;&apos;type&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&apos;congratulation_message&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                &lt;span class=&quot;o&quot;&gt;**&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;congrat&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;

        &lt;span class=&quot;k&quot;&gt;elif&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;type&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&apos;emoji_reaction&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;c1&quot;&gt;# DB 저장 없이 즉시 브로드캐스트 (YouTube Super Thanks 방식)
&lt;/span&gt;            &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;channel_layer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;group_send&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;room_group_name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;s&quot;&gt;&apos;type&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&apos;emoji_burst&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                &lt;span class=&quot;s&quot;&gt;&apos;emoji&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;emoji&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;congratulation_message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;send&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text_data&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;dumps&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;type&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&apos;congratulation&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;**&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}))&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;emoji_burst&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;send&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text_data&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;dumps&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;type&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&apos;emoji_burst&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&apos;emoji&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;emoji&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]}))&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;viewer_count_update&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;send&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text_data&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;dumps&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;type&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&apos;viewer_count&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&apos;count&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;count&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]}))&lt;/span&gt;

    &lt;span class=&quot;o&quot;&gt;@&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;database_sync_to_async&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;_save_congratulation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;event&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;WeddingEvent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;objects&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;stream_key&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;stream_key&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;c&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Congratulation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;objects&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;create&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;name&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&apos;익명&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;message&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;message&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;emoji&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;emoji&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&apos;💍&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&apos;name&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;c&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&apos;message&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;c&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&apos;emoji&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;c&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;emoji&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&apos;created_at&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;c&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;created_at&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;strftime&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;%H:%M&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;_broadcast_viewer_count&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;delta&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;count&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_update_viewer_count&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;delta&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;channel_layer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;group_send&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;room_group_name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&apos;type&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&apos;viewer_count_update&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&apos;count&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;count&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;})&lt;/span&gt;

    &lt;span class=&quot;o&quot;&gt;@&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;database_sync_to_async&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;_update_viewer_count&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;delta&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
        &lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;django.db.models&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;F&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;WeddingEvent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;objects&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;filter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;stream_key&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;stream_key&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;update&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;viewer_count&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;F&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;viewer_count&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;delta&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;WeddingEvent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;objects&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;stream_key&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;stream_key&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;viewer_count&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h3 id=&quot;5단계-프론트엔드---abr-재생--이모지-버스트&quot;&gt;5단계: 프론트엔드 - ABR 재생 + 이모지 버스트&lt;/h3&gt;

&lt;div class=&quot;language-html 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;cp&quot;&gt;&amp;lt;!DOCTYPE html&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;html&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;lang=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;ko&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;head&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;&amp;lt;meta&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;charset=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;UTF-8&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;&amp;lt;title&amp;gt;&lt;/span&gt;{{ event.groom }} ♥ {{ event.bride }} 결혼식 라이브&lt;span class=&quot;nt&quot;&gt;&amp;lt;/title&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;&amp;lt;script &lt;/span&gt;&lt;span class=&quot;na&quot;&gt;src=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;https://cdn.jsdelivr.net/npm/hls.js@latest&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;&amp;lt;style&amp;gt;&lt;/span&gt;
        &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;box-sizing&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;border-box&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;margin&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;padding&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;nt&quot;&gt;body&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;font-family&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&apos;Noto Sans KR&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;sans-serif&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;background&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;#fdf6f0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;nc&quot;&gt;.container&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;max-width&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;1000px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;margin&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;0&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;auto&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;padding&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;20px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;nt&quot;&gt;h1&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;text-align&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;center&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;color&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;#8b4f6e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;padding&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;20px&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;font-size&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;22px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

        &lt;span class=&quot;c&quot;&gt;/* 플레이어 */&lt;/span&gt;
        &lt;span class=&quot;nc&quot;&gt;.player-wrap&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;position&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;relative&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;background&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;#000&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;border-radius&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;12px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;overflow&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;hidden&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;nf&quot;&gt;#video&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;width&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;100%&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;display&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;block&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;max-height&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;500px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;nc&quot;&gt;.badge&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;nl&quot;&gt;position&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;absolute&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;top&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;12px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;padding&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;4px&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;12px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
            &lt;span class=&quot;nl&quot;&gt;border-radius&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;20px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;font-size&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;13px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;font-weight&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;bold&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;nc&quot;&gt;.live-badge&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;left&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;12px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;background&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;#e74c3c&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;color&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;#fff&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;nc&quot;&gt;.viewer-badge&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;right&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;12px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;background&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;rgba&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;0.55&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;color&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;#fff&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

        &lt;span class=&quot;c&quot;&gt;/* 이모지 버스트 레이어 */&lt;/span&gt;
        &lt;span class=&quot;nf&quot;&gt;#emoji-layer&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;nl&quot;&gt;position&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;absolute&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;py&quot;&gt;inset&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
            &lt;span class=&quot;nl&quot;&gt;pointer-events&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;none&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;overflow&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;hidden&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;nc&quot;&gt;.floating-emoji&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;nl&quot;&gt;position&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;absolute&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;bottom&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;font-size&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;28px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
            &lt;span class=&quot;nl&quot;&gt;animation&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;floatUp&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;2.5s&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ease-out&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;forwards&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;@keyframes&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;floatUp&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;err&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;%&lt;/span&gt;   &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;transform&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;translateY&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;scale&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;opacity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
            &lt;span class=&quot;err&quot;&gt;100&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;%&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;transform&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;translateY&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;-300px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;scale&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;1.4&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;opacity&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

        &lt;span class=&quot;c&quot;&gt;/* 하단 패널 */&lt;/span&gt;
        &lt;span class=&quot;nc&quot;&gt;.panel&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;display&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;flex&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;py&quot;&gt;gap&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;16px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;margin-top&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;20px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;nc&quot;&gt;.chat-box&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;nl&quot;&gt;flex&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;background&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;#fff&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;border-radius&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;12px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
            &lt;span class=&quot;nl&quot;&gt;padding&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;16px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;height&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;340px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;overflow-y&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;auto&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
            &lt;span class=&quot;nl&quot;&gt;box-shadow&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;0&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;2px&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;8px&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;rgba&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;0.07&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;nc&quot;&gt;.msg&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;padding&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;10px&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;border-bottom&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;1px&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;solid&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;#f5e8f0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;nc&quot;&gt;.msg&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;.meta&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;font-weight&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;bold&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;color&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;#8b4f6e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;font-size&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;14px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;nc&quot;&gt;.msg&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;.text&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;color&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;#444&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;font-size&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;14px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;margin-top&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;3px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;nc&quot;&gt;.msg&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;.time&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;color&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;#bbb&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;font-size&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;11px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;margin-top&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;2px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

        &lt;span class=&quot;nc&quot;&gt;.input-box&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;nl&quot;&gt;width&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;280px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;background&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;#fff&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;border-radius&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;12px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
            &lt;span class=&quot;nl&quot;&gt;padding&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;16px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;box-shadow&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;0&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;2px&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;8px&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;rgba&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;0.07&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
            &lt;span class=&quot;nl&quot;&gt;display&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;flex&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;flex-direction&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;column&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;py&quot;&gt;gap&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;10px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;nc&quot;&gt;.input-box&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;h3&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;color&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;#8b4f6e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;font-size&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;15px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;nt&quot;&gt;input&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;textarea&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;nl&quot;&gt;width&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;100%&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;border&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;1px&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;solid&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;#e0c8d8&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;border-radius&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;8px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
            &lt;span class=&quot;nl&quot;&gt;padding&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;8px&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;12px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;font-size&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;13px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;font-family&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;inherit&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;nt&quot;&gt;textarea&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;height&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;70px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;resize&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;none&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

        &lt;span class=&quot;c&quot;&gt;/* 이모지 선택 */&lt;/span&gt;
        &lt;span class=&quot;nc&quot;&gt;.emoji-row&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;display&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;flex&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;py&quot;&gt;gap&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;6px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;flex-wrap&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;wrap&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;nc&quot;&gt;.emoji-btn&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;nl&quot;&gt;font-size&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;20px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;background&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;none&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
            &lt;span class=&quot;nl&quot;&gt;border&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;2px&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;solid&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;transparent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;border-radius&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;8px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
            &lt;span class=&quot;nl&quot;&gt;padding&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;2px&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;5px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;cursor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;pointer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;nc&quot;&gt;.emoji-btn.selected&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;border-color&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;#8b4f6e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;background&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;#fdf0f6&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

        &lt;span class=&quot;c&quot;&gt;/* 즉각 이모지 반응 버튼 */&lt;/span&gt;
        &lt;span class=&quot;nc&quot;&gt;.reaction-row&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;display&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;flex&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;py&quot;&gt;gap&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;8px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;nc&quot;&gt;.reaction-btn&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;nl&quot;&gt;flex&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;font-size&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;22px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;background&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;#fdf0f6&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
            &lt;span class=&quot;nl&quot;&gt;border&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;none&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;border-radius&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;10px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
            &lt;span class=&quot;nl&quot;&gt;padding&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;6px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;cursor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;pointer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;transition&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;transform&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;0.1s&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;nc&quot;&gt;.reaction-btn&lt;/span&gt;&lt;span class=&quot;nd&quot;&gt;:active&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;transform&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;scale&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;1.3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

        &lt;span class=&quot;c&quot;&gt;/* 전송 버튼 */&lt;/span&gt;
        &lt;span class=&quot;nc&quot;&gt;.send-btn&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;nl&quot;&gt;background&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;#8b4f6e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;color&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;#fff&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;border&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;none&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
            &lt;span class=&quot;nl&quot;&gt;border-radius&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;8px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;padding&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;10px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;font-size&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;14px&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;cursor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;pointer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;nc&quot;&gt;.send-btn&lt;/span&gt;&lt;span class=&quot;nd&quot;&gt;:hover&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nl&quot;&gt;background&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;#7a3d5d&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;&amp;lt;/style&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;/head&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;body&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;div&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;class=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;container&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;💍 {{ event.groom }} &lt;span class=&quot;err&quot;&gt;&amp;amp;&lt;/span&gt; {{ event.bride }} 결혼식&lt;span class=&quot;nt&quot;&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;

    &lt;span class=&quot;nt&quot;&gt;&amp;lt;div&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;class=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;player-wrap&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
        &lt;span class=&quot;nt&quot;&gt;&amp;lt;video&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;id=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;video&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;controls&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;playsinline&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;autoplay&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;muted&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&amp;lt;/video&amp;gt;&lt;/span&gt;
        &lt;span class=&quot;nt&quot;&gt;&amp;lt;span&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;class=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;badge live-badge&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;🔴 LIVE&lt;span class=&quot;nt&quot;&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
        &lt;span class=&quot;nt&quot;&gt;&amp;lt;span&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;class=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;badge viewer-badge&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;id=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;viewer-count&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;👁 0명&lt;span class=&quot;nt&quot;&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
        &lt;span class=&quot;nt&quot;&gt;&amp;lt;div&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;id=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;emoji-layer&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

    &lt;span class=&quot;nt&quot;&gt;&amp;lt;div&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;class=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;panel&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
        &lt;span class=&quot;nt&quot;&gt;&amp;lt;div&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;class=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;chat-box&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;id=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;chat-box&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
            {% for msg in recent_messages %}
            &lt;span class=&quot;nt&quot;&gt;&amp;lt;div&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;class=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;msg&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
                &lt;span class=&quot;nt&quot;&gt;&amp;lt;div&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;class=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;meta&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;{{ msg.emoji }} {{ msg.name }}&lt;span class=&quot;nt&quot;&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
                &lt;span class=&quot;nt&quot;&gt;&amp;lt;div&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;class=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;text&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;{{ msg.message }}&lt;span class=&quot;nt&quot;&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
                &lt;span class=&quot;nt&quot;&gt;&amp;lt;div&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;class=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;time&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;{{ msg.created_at|time:&quot;H:i&quot; }}&lt;span class=&quot;nt&quot;&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
            &lt;span class=&quot;nt&quot;&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
            {% endfor %}
        &lt;span class=&quot;nt&quot;&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

        &lt;span class=&quot;nt&quot;&gt;&amp;lt;div&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;class=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;input-box&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
            &lt;span class=&quot;nt&quot;&gt;&amp;lt;h3&amp;gt;&lt;/span&gt;💌 축하 메시지&lt;span class=&quot;nt&quot;&gt;&amp;lt;/h3&amp;gt;&lt;/span&gt;
            &lt;span class=&quot;nt&quot;&gt;&amp;lt;input&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;type=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;text&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;id=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;name-input&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;placeholder=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;이름 (익명 가능)&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;maxlength=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;20&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;

            &lt;span class=&quot;nt&quot;&gt;&amp;lt;div&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;class=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;emoji-row&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;id=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;emoji-row&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
                {% for e in &quot;💍 🎊 🌸 💐 🥂 ❤️ 🎉 🕊️&quot;|split:&quot; &quot; %}
                &lt;span class=&quot;nt&quot;&gt;&amp;lt;button&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;class=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;emoji-btn&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;data-emoji=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;{{ e }}&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;{{ e }}&lt;span class=&quot;nt&quot;&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
                {% endfor %}
            &lt;span class=&quot;nt&quot;&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

            &lt;span class=&quot;nt&quot;&gt;&amp;lt;textarea&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;id=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;msg-input&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;placeholder=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;두 분의 앞날을 축복합니다 ✨&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;maxlength=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;100&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&amp;lt;/textarea&amp;gt;&lt;/span&gt;
            &lt;span class=&quot;nt&quot;&gt;&amp;lt;button&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;class=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;send-btn&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;id=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;send-btn&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;축하 보내기 💍&lt;span class=&quot;nt&quot;&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;

            &lt;span class=&quot;nt&quot;&gt;&amp;lt;div&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;style=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;border-top: 1px solid #f0e0ea; padding-top: 10px;&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
                &lt;span class=&quot;nt&quot;&gt;&amp;lt;div&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;style=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;font-size: 13px; color: #999; margin-bottom: 8px;&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;즉각 반응&lt;span class=&quot;nt&quot;&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
                &lt;span class=&quot;nt&quot;&gt;&amp;lt;div&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;class=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;reaction-row&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;
                    &lt;span class=&quot;nt&quot;&gt;&amp;lt;button&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;class=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;reaction-btn&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;data-emoji=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;🎊&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;🎊&lt;span class=&quot;nt&quot;&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
                    &lt;span class=&quot;nt&quot;&gt;&amp;lt;button&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;class=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;reaction-btn&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;data-emoji=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;❤️&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;❤️&lt;span class=&quot;nt&quot;&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
                    &lt;span class=&quot;nt&quot;&gt;&amp;lt;button&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;class=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;reaction-btn&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;data-emoji=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;👏&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;👏&lt;span class=&quot;nt&quot;&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
                    &lt;span class=&quot;nt&quot;&gt;&amp;lt;button&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;class=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;reaction-btn&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;data-emoji=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;😭&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&lt;/span&gt;😭&lt;span class=&quot;nt&quot;&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
                &lt;span class=&quot;nt&quot;&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
            &lt;span class=&quot;nt&quot;&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
        &lt;span class=&quot;nt&quot;&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

&lt;span class=&quot;nt&quot;&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;// ─── HLS ABR 재생 (YouTube 방식) ───────────────────────────&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;video&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;document&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;getElementById&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;video&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;masterUrl&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;{% url &apos;master_playlist&apos; event.stream_key %}&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;Hls&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;isSupported&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;())&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;hls&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Hls&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;lowLatencyMode&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;hls&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;loadSource&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;masterUrl&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;hls&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;attachMedia&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;video&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;video&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;canPlayType&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;application/vnd.apple.mpegurl&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;video&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;src&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;masterUrl&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// ─── WebSocket 연결 ────────────────────────────────────────&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;ws&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;WebSocket&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;`ws://&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;${&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;location&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;host&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;/ws/wedding/{{ event.stream_key }}/`&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;chatBox&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;document&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;getElementById&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;chat-box&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;nx&quot;&gt;ws&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;onmessage&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;({&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;data&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;})&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;msg&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;JSON&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;parse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;msg&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;type&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;congratulation&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;div&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;document&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;createElement&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;div&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
        &lt;span class=&quot;nx&quot;&gt;div&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;className&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;msg&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
        &lt;span class=&quot;nx&quot;&gt;div&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;innerHTML&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;`
            &amp;lt;div class=&quot;meta&quot;&amp;gt;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;${&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;msg&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;emoji&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;${&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;msg&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;lt;/div&amp;gt;
            &amp;lt;div class=&quot;text&quot;&amp;gt;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;${&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;msg&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;lt;/div&amp;gt;
            &amp;lt;div class=&quot;time&quot;&amp;gt;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;${&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;msg&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;created_at&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;lt;/div&amp;gt;
        `&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
        &lt;span class=&quot;nx&quot;&gt;chatBox&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;prepend&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;div&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;msg&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;type&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;viewer_count&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;nb&quot;&gt;document&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;getElementById&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;viewer-count&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;textContent&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;`👁 &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;${&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;msg&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;count&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;명`&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;msg&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;type&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;emoji_burst&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;nx&quot;&gt;spawnEmoji&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;msg&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;emoji&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;};&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// ─── 이모지 버스트 애니메이션 ──────────────────────────────&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;spawnEmoji&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;emoji&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;layer&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;document&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;getElementById&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;emoji-layer&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;el&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;document&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;createElement&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;span&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;el&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;className&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;floating-emoji&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;el&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;textContent&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;emoji&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;el&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;style&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;left&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;10&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Math&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;random&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;80&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;%&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;layer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;appendChild&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;el&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;setTimeout&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;el&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;remove&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;2500&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// ─── 이모지 선택 ───────────────────────────────────────────&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;selectedEmoji&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;💍&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;nb&quot;&gt;document&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;querySelectorAll&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;.emoji-btn&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;forEach&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;btn&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;btn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;addEventListener&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;click&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;nb&quot;&gt;document&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;querySelectorAll&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;.emoji-btn&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;forEach&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;b&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;b&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;classList&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;remove&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;selected&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;));&lt;/span&gt;
        &lt;span class=&quot;nx&quot;&gt;btn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;classList&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;add&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;selected&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
        &lt;span class=&quot;nx&quot;&gt;selectedEmoji&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;btn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;dataset&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;emoji&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
&lt;span class=&quot;nb&quot;&gt;document&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;querySelector&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;.emoji-btn&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;classList&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;add&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;selected&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// ─── 축하 메시지 전송 ──────────────────────────────────────&lt;/span&gt;
&lt;span class=&quot;nb&quot;&gt;document&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;getElementById&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;send-btn&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;addEventListener&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;click&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;name&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;document&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;getElementById&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;name-input&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;value&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;trim&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;||&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;익명&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;message&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;document&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;getElementById&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;msg-input&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;value&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;trim&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

    &lt;span class=&quot;nx&quot;&gt;ws&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;send&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;JSON&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;stringify&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;congratulation&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;message&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;emoji&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;selectedEmoji&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}));&lt;/span&gt;
    &lt;span class=&quot;nb&quot;&gt;document&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;getElementById&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;msg-input&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;value&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&apos;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;// ─── 즉각 이모지 반응 ──────────────────────────────────────&lt;/span&gt;
&lt;span class=&quot;nb&quot;&gt;document&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;querySelectorAll&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;.reaction-btn&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;forEach&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;btn&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;btn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;addEventListener&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;click&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;nx&quot;&gt;ws&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;send&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;JSON&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;stringify&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;emoji_reaction&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;emoji&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;btn&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;dataset&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;emoji&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}));&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;
&lt;span class=&quot;nt&quot;&gt;&amp;lt;/html&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&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. 담당자 스마트폰 → Larix Broadcaster 실행
   → rtmp://서버IP/live/wedding_abc123 으로 RTMP 송출

서버 (자동 처리)
─────────────────────────────────────────────────────
2. Nginx-RTMP 수신
3. FFmpeg 트랜스코딩 → 720p + 360p HLS 동시 생성
4. Django가 마스터 플레이리스트(master.m3u8)로 두 해상도 묶음

하객 시청 (링크 접속)
─────────────────────────────────────────────────────
5. hls.js가 마스터 플레이리스트 읽고 ABR 재생 시작
   → Wi-Fi 환경이면 720p, LTE 약하면 자동으로 360p 전환
6. WebSocket 연결 → 실시간 축하 메시지 + 이모지 반응
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;youtube와-최종-비교&quot;&gt;YouTube와 최종 비교&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;항목&lt;/th&gt;
      &lt;th&gt;YouTube&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;OBS, 모바일 앱&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;스마트폰 앱 (Larix / 커스텀)&lt;/strong&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;Nginx-RTMP 단일 서버&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;strong&gt;FFmpeg (서버 1대)&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;다중 해상도&lt;/td&gt;
      &lt;td&gt;5~6개&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;720p + 360p&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;배포&lt;/td&gt;
      &lt;td&gt;Google CDN&lt;/td&gt;
      &lt;td&gt;직서빙 or Cloudflare&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;ABR 전환&lt;/td&gt;
      &lt;td&gt;자체 플레이어&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;hls.js&lt;/strong&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;strong&gt;Django Channels + Redis&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;이모지 반응&lt;/td&gt;
      &lt;td&gt;Super Thanks&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;WebSocket emoji burst&lt;/strong&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;YouTube의 구조를 &lt;strong&gt;그대로 모방하되, 규모를 현실에 맞게&lt;/strong&gt; 줄인 것이 이번 설계의 핵심입니다. 기술 선택의 방향은 같고, 인프라 규모만 다릅니다.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;마무리&quot;&gt;마무리&lt;/h2&gt;

&lt;p&gt;“YouTube는 어떻게 동작하는가”를 이해하면 라이브 스트리밍의 각 구간이 왜 그렇게 설계됐는지 보입니다. RTMP 인제스트, FFmpeg 트랜스코딩, HLS ABR 배포는 YouTube가 발명한 개념이 아닙니다. 오픈소스 기술들을 조합한 것이고, 우리도 같은 조합을 사용할 수 있습니다.&lt;/p&gt;

&lt;p&gt;결혼식은 일생에 한 번입니다. 오지 못한 소중한 분들도 실시간으로 함께하고 이모지로 축하를 전할 수 있는 경험, 생각보다 가까운 기술로 만들 수 있습니다. 💍&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;em&gt;구현 중 막히는 부분은 댓글로 남겨주세요!&lt;/em&gt;&lt;/p&gt;</content><author><name>updaun</name></author><category term="django" /><summary type="html">YouTube 구조를 참고해 웨딩 라이브 송출 시스템 설계하기</summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-04-30-youtube-inspired-wedding-live-streaming.webp" /><media:content medium="image" url="https://pub-f5c290ac8b834bddbdf454a2a01e3a9b.r2.dev/assets/img/posts/2026-04-30-youtube-inspired-wedding-live-streaming.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>