<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>CUSUMlog</title>
    <link>https://imjyh01.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Fri, 5 Jun 2026 16:48:06 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>cusum26</managingEditor>
    <item>
      <title>Etherium의 상태 저장 - Trie에서 Merkle Patricia Trie까지</title>
      <link>https://imjyh01.tistory.com/19</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;텍스트 기반 검색을 공부하다 보면 유독 Trie라는 자료구조를 자주 만나게 된다. 특히 문자열 검색, 자동완성, 사전 검색 같은 기능에서 Trie는 빠지지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RedisSearch나 ElasticSearch 같은 대형 검색 엔진에서도 term lookup, prefix matching 최적화에 Trie를 활용하고 있다고 한다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 183px;&quot; border=&quot;1&quot; width=&quot;100%&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 29.3022%; height: 19px;&quot;&gt;&lt;b&gt;서비스/엔진&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 70.5815%; height: 19px;&quot;&gt;&lt;b&gt;Trie 활용 방식&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 29.3022%; height: 19px;&quot;&gt;&lt;span&gt;&lt;b&gt;Redis Stack / RediSearch&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 70.5815%; height: 19px;&quot;&gt;&lt;span&gt;autocomplete suggestion을 trie 기반 자료구조에 저장. prefix 입력에 대한 추천어 검색에 사용&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 38px;&quot;&gt;
&lt;td style=&quot;width: 29.3022%; height: 38px;&quot;&gt;&lt;span&gt;&lt;b&gt;Elasticsearch / Apache Lucene&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 70.5815%; height: 38px;&quot;&gt;&lt;span&gt;전문 검색은 기본적으로 inverted index 기반이지만, term dictionary나 prefix 탐색에 FST 같은 Trie 계열 압축 구조를 사용&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 38px;&quot;&gt;
&lt;td style=&quot;width: 29.3022%; height: 38px;&quot;&gt;&lt;span&gt;&lt;b&gt;Solr / Apache Lucene&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 70.5815%; height: 38px;&quot;&gt;&lt;span&gt;Lucene 기반이라 Elasticsearch와 비슷하게 term lookup, prefix 검색 등에 FST/term dictionary 구조 활용&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 31px;&quot;&gt;
&lt;td style=&quot;width: 29.3022%; height: 31px;&quot;&gt;&lt;span&gt;&lt;b&gt;검색 자동완성 시스템&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 70.5815%; height: 31px;&quot;&gt;&lt;span&gt;검색어 추천, command completion, prefix matching에 Trie 또는 compressed Trie 활용&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 38px;&quot;&gt;
&lt;td style=&quot;width: 29.3022%; height: 38px;&quot;&gt;&lt;span&gt;&lt;b&gt;라우팅/네트워크 시스템&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 70.5815%; height: 38px;&quot;&gt;&lt;span&gt;IP prefix matching에 Patricia Trie/Radix Tree 계열 활용&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;뿐만 아니라 블록체인, 이더리움에서도 Merkle Patricia Trie라는 자료구조가 핵심적으로 사용된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;텍스트 검색부터 블록체인 주소 검색까지 대용량의 데이터를 빠르게 접근하는 Trie 구조와 이를 기반으로 한 Patricia Trie, Merkle Tree, Merkle Patricia Trie에 대해 알아보자.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. Trie란 무엇인가?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Trie는 문자열을 저장하기 위한 트리 기반 자료구조다. 핵심 아이디어는 간단하다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR'; color: #666666; text-align: center;&quot;&gt;여러 문자열이 공통으로 가지는 접두사(prefix)를 한 번만 저장한다.&lt;/span&gt;&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 다음과 같은 단어들이 있다고 하자.&lt;/p&gt;
&lt;pre class=&quot;ebnf&quot;&gt;&lt;code&gt;car
cat
can
dog&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반적인 방식으로 저장하면 각 문자열을 따로 저장한다.&lt;/p&gt;
&lt;pre class=&quot;ebnf&quot;&gt;&lt;code&gt;car
cat
can
dog&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경우 &lt;code&gt;car&lt;/code&gt;, &lt;code&gt;cat&lt;/code&gt;, &lt;code&gt;can&lt;/code&gt;은 모두 앞의 &lt;code&gt;ca&lt;/code&gt;를 공유하지만, 각각의 문자열 안에 &lt;code&gt;c&lt;/code&gt;, &lt;code&gt;a&lt;/code&gt;가 반복해서 저장된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Trie는 이를 다음과 같이 저장한다.&lt;/p&gt;
&lt;pre class=&quot;excel&quot;&gt;&lt;code&gt;(root)
 ├─ c
 │  └─ a
 │     ├─ r  &amp;rarr; car
 │     ├─ t  &amp;rarr; cat
 │     └─ n  &amp;rarr; can
 └─ d
    └─ o
       └─ g  &amp;rarr; dog&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;car&lt;/code&gt;, &lt;code&gt;cat&lt;/code&gt;, &lt;code&gt;can&lt;/code&gt;이 공유하는 &lt;code&gt;c &amp;rarr; a&lt;/code&gt; 경로를 한 번만 저장하고, 이후 달라지는 문자부터 가지를 나눈다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, Trie의 핵심은 다음과 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;공통 접두사는 공유하고, 달라지는 부분부터 분기한다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. Trie가 효율적인 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Trie의 장점은 크게 두 가지다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫째, 공통 접두사를 공유하기 때문에 중복 저장을 줄일 수 있다. 특히 비슷한 접두사를 가진 문자열이 많을수록 효과가 크다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘째, 검색 시간이 문자열의 개수보다 검색하려는 문자열의 길이에 비례한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 &lt;code&gt;cat&lt;/code&gt;을 찾는다고 하면, Trie에서는 다음 경로만 따라가면 된다.&lt;/p&gt;
&lt;pre class=&quot;excel&quot;&gt;&lt;code&gt;c &amp;rarr; a &amp;rarr; t&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단어가 10개 있든 100만 개 있든, &lt;code&gt;cat&lt;/code&gt;을 검색할 때 확인하는 문자는 3개다. 따라서 검색 시간복잡도는 보통 O(L) 같이 표현된다. 여기서 &lt;code&gt;L&lt;/code&gt;은 검색하려는 문자열의 길이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 특성 때문에 Trie는 자동완성 기능에 적합하다. 예를 들어 &lt;code&gt;ca&lt;/code&gt;로 시작하는 단어를 찾고 싶다면, 먼저 &lt;code&gt;c &amp;rarr; a&lt;/code&gt; 노드까지 이동한 뒤 그 아래에 있는 단어들을 모두 탐색하면 된다.&lt;/p&gt;
&lt;pre class=&quot;excel&quot;&gt;&lt;code&gt;ca
├─ r &amp;rarr; car
├─ t &amp;rarr; cat
└─ n &amp;rarr; can&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 prefix 검색을 빠르게 수행할 수 있다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. Trie의 단점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Trie가 항상 메모리 효율적인 것은 아니다. 일반 Trie는 문자 하나마다 노드를 만든다. 그래서 문자열들이 공통 접두사를 많이 공유하지 않거나, 각 노드가 많은 포인터 또는 map 구조를 가진다면 오히려 메모리 사용량이 커질 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 다음과 같은 단어들이 있다고 해보자.&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;apple
banana
coffee
dragon&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 단어들은 앞부분이 거의 겹치지 않는다. 이 경우 Trie의 접두사 공유 이점은 줄어든다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 일반 Trie에서는 중간에 갈림길이 없는 경로도 문자 단위로 노드를 계속 만든다.&lt;/p&gt;
&lt;pre class=&quot;excel&quot;&gt;&lt;code&gt;i &amp;rarr; n &amp;rarr; t &amp;rarr; e &amp;rarr; r &amp;rarr; n &amp;rarr; a &amp;rarr; l&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 중간에 분기점이 없다면, 굳이 문자 하나마다 노드를 둘 필요가 있을까? 이런 문제의식에서 Patricia Trie가 등장한다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. Patricia Trie란 무엇인가?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Patricia Trie는 일반 Trie를 압축한 구조다. 보통 압축 Trie 또는 Radix Tree 계열로 설명된다. 핵심 아이디어는 다음과 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;span style=&quot;color: #666666; text-align: center;&quot;&gt;자식이 하나뿐인 연속 경로를 하나로 압축한다.&lt;/span&gt;&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 다음 단어들이 있다고 하자.&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;bear
bell
bid
bull
buy&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반 Trie에서는 문자 하나마다 노드를 만든다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;(root)
 └─ b
    ├─ e
    │  ├─ a
    │  │  └─ r
    │  └─ l
    │     └─ l
    ├─ i
    │  └─ d
    └─ u
       ├─ l
       │  └─ l
       └─ y&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 &lt;code&gt;a &amp;rarr; r&lt;/code&gt;, &lt;code&gt;l &amp;rarr; l&lt;/code&gt;, &lt;code&gt;i &amp;rarr; d&lt;/code&gt;처럼 중간에 갈림길이 없는 경로들이 있다. Patricia Trie는 이런 부분을 압축한다.&lt;/p&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;(root)
 └─ b
    ├─ e
    │  ├─ ar &amp;rarr; bear
    │  └─ ll &amp;rarr; bell
    ├─ id &amp;rarr; bid
    └─ u
       ├─ ll &amp;rarr; bull
       └─ y  &amp;rarr; buy&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 일반 Trie가 문자 단위로 노드를 만든다면, Patricia Trie는 분기가 없는 문자열 구간을 하나의 간선 또는 노드로 저장한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;일반 Trie:
문자 하나마다 노드를 만든다.

Patricia Trie:
갈림길이 없는 연속 경로를 압축한다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. Patricia Trie의 장단점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Patricia Trie의 가장 큰 장점은 노드 수를 줄일 수 있다는 점이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 다음 문자열들이 있다고 하자.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;international
internet
internal&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 단어들은 &lt;code&gt;inter&lt;/code&gt;라는 공통 prefix를 가진다. Patricia Trie에서는 다음과 같이 압축해서 표현할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;(root)
 └─ inter
    ├─ national
    ├─ net
    └─ nal&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 일반 Trie보다 불필요한 중간 노드를 줄일 수 있고, 메모리 사용량도 줄일 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 단점도 있다. 구현이 일반 Trie보다 복잡하다. 일반 Trie는 문자 하나씩 따라가면 되지만, Patricia Trie는 압축된 문자열 조각을 비교해야 한다. 또한 삽입 과정에서 기존 경로와 새 문자열이 중간에서 갈라지면 노드를 쪼개야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 기존에 &lt;code&gt;internet&lt;/code&gt;이 있고 새로 &lt;code&gt;internal&lt;/code&gt;을 삽입한다고 하자.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;internet
internal&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 단어는 &lt;code&gt;inter&lt;/code&gt;까지 같고, 그 뒤부터 &lt;code&gt;net&lt;/code&gt;, &lt;code&gt;nal&lt;/code&gt;로 갈라진다. Patricia Trie는 이 공통 지점을 찾아 노드를 분리해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 Patricia Trie는 메모리 효율성은 좋아지지만, 삽입과 삭제 구현은 더 복잡해진다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. Merkle Tree란 무엇인가?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Merkle Patricia Trie를 이해하려면 Merkle Tree도 알아야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Merkle Tree는 각 노드가 해시값을 가지는 트리 구조다. 리프 노드는 데이터의 해시를 가지고, 부모 노드는 자식 노드들의 해시를 다시 해시해서 만든다.&lt;/p&gt;
&lt;pre class=&quot;mathematica&quot;&gt;&lt;code&gt;        Root Hash
        /       \
   Hash A       Hash B
   /   \        /   \
data1 data2  data3 data4&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Merkle Tree의 핵심은 다음과 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;span style=&quot;color: #666666; text-align: center;&quot;&gt;전체 데이터를 직접 비교하지 않아도, 루트 해시만 비교하면 데이터가 변경되었는지 확인할 수 있다.&lt;/span&gt;&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 &lt;code&gt;data3&lt;/code&gt;이 바뀌면 &lt;code&gt;Hash B&lt;/code&gt;가 바뀌고, 결국 &lt;code&gt;Root Hash&lt;/code&gt;도 바뀐다. 즉, Merkle Tree는 데이터 무결성을 검증하는 데 유용하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 특정 데이터가 트리에 포함되어 있다는 것을 증명할 때도 전체 데이터를 다 제공할 필요가 없다. 해당 데이터에서 루트까지 올라가는 경로에 필요한 해시들만 제공하면 된다. 이를 Merkle Proof라고 한다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. Merkle Patricia Trie란 무엇인가?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Merkle Patricia Trie는 이름 그대로 Merkle Tree와 Patricia Trie의 특징을 결합한 자료구조다. 간단히 말하면 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Patricia Trie:
key를 prefix 기반으로 효율적으로 저장하고 검색한다.

Merkle Tree:
각 노드에 해시를 붙여 데이터 무결성을 검증한다.

Merkle Patricia Trie:
key-value 데이터를 효율적으로 저장하면서,
각 노드의 해시를 통해 전체 상태의 무결성과 포함 여부를 검증한다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, Merkle Patricia Trie는 단순한 검색 자료구조가 아니라, 검색과 검증을 동시에 지원하는 자료구조다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 다음과 같은 key-value가 있다고 하자.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;do   &amp;rarr; verb
dog  &amp;rarr; puppy
doge &amp;rarr; coin&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Patricia Trie처럼 공통 prefix를 공유한다.&lt;/p&gt;
&lt;pre class=&quot;vim&quot;&gt;&lt;code&gt;do
├─ 끝 &amp;rarr; verb
└─ g
   ├─ 끝 &amp;rarr; puppy
   └─ e &amp;rarr; coin&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기에 Merkle Tree처럼 각 노드에 해시를 붙인다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;Hash(root)
└─ Hash(do)
   ├─ Hash(value: verb)
   └─ Hash(g)
      ├─ Hash(value: puppy)
      └─ Hash(e &amp;rarr; coin)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 &lt;code&gt;doge &amp;rarr; coin&lt;/code&gt; 값이 바뀌면, 그 값이 포함된 경로의 해시들이 바뀌고 최종적으로 root hash도 바뀐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 루트 해시 하나로 전체 상태가 바뀌었는지 확인할 수 있다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;8. 이더리움에서 Merkle Patricia Trie를 사용하는 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이더리움에는 수많은 계정 상태가 존재한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 다음과 같은 데이터들이 있다.&lt;/p&gt;
&lt;pre class=&quot;html xml&quot; data-ke-language=&quot;html&quot;&gt;&lt;code&gt;주소 A &amp;rarr; 잔액 10 ETH
주소 B &amp;rarr; 잔액 3 ETH
주소 C &amp;rarr; nonce 5
주소 D &amp;rarr; 스마트 컨트랙트 코드 정보&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;블록체인에서는 이 상태 데이터를 단순히 저장하는 것만으로는 부족하다. 중요한 것은 다음이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;span style=&quot;color: #666666; text-align: center;&quot;&gt;이 상태가 정말 블록에 기록된 상태와 일치하는지 검증할 수 있어야 한다.&lt;/span&gt;&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이더리움은 전체 계정 상태를 Merkle Patricia Trie에 저장하고, 그 루트 해시를 블록 헤더에 기록한다.&lt;/p&gt;
&lt;pre class=&quot;pf&quot;&gt;&lt;code&gt;Block Header
└─ stateRoot&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 &lt;code&gt;stateRoot&lt;/code&gt;는 현재 이더리움 전체 상태를 대표하는 해시값이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;계정 하나의 잔액이 바뀌어도 관련된 노드의 해시가 바뀌고, 최종적으로 &lt;code&gt;stateRoot&lt;/code&gt;도 바뀐다. 따라서 블록 헤더의 &lt;code&gt;stateRoot&lt;/code&gt;를 통해 해당 블록 시점의 전체 상태를 검증할 수 있다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;9. 이더리움 State Trie의 key와 value&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이더리움의 State Trie는 대략 다음과 같이 이해할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;key   = 계정 주소
value = 해당 주소의 계정 상태&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 정확히는 주소 자체를 그대로 key로 쓰기보다는, 주소를 해시한 값을 Trie의 key로 사용한다고 보면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 계정의 상태는 단순히 잔액만 의미하지 않는다. 이더리움의 account state에는 다음 정보들이 포함된다.&lt;/p&gt;
&lt;pre class=&quot;html xml&quot; data-ke-language=&quot;html&quot;&gt;&lt;code&gt;Account State
├─ nonce
├─ balance
├─ storageRoot
└─ codeHash&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 항목의 의미는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;avrasm&quot;&gt;&lt;code&gt;nonce:
해당 계정이 보낸 트랜잭션 수 또는 컨트랙트 생성 횟수와 관련된 값

balance:
계정의 ETH 잔액

storageRoot:
스마트 컨트랙트의 내부 저장소를 가리키는 Storage Trie의 루트 해시

codeHash:
스마트 컨트랙트 코드의 해시&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 이더리움의 State Trie는 다음과 같은 형태로 볼 수 있다.&lt;/p&gt;
&lt;pre class=&quot;html xml&quot; data-ke-language=&quot;html&quot;&gt;&lt;code&gt;address A &amp;rarr; account state A
address B &amp;rarr; account state B
address C &amp;rarr; account state C&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 어떤 주소가 스마트 컨트랙트 계정이라면, 그 계정의 내부 저장소는 &lt;code&gt;storageRoot&lt;/code&gt;를 통해 별도의 Storage Trie로 연결된다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;10. 스마트 컨트랙트의 세부 저장소는 어디에 저장될까?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스마트 컨트랙트는 상태 변수를 가질 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 Solidity 코드에서 다음과 같은 변수가 있다고 하자.&lt;/p&gt;
&lt;pre class=&quot;abnf&quot;&gt;&lt;code&gt;uint256 totalSupply;
mapping(address =&amp;gt; uint256) balances;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 값들은 컨트랙트 코드 내부에 직접 저장되는 것이 아니다. 컨트랙트 작성자가 별도로 어딘가에 저장하는 것도 아니다. 컨트랙트별 세부 저장소는 해당 컨트랙트 계정의 &lt;code&gt;storageRoot&lt;/code&gt;가 가리키는 별도의 Storage Trie에 저장된다. 그리고 이 데이터는 이더리움 노드들이 자신의 상태 데이터베이스에 저장한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구조를 단순화하면 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Ethereum Node
└─ State Database
   └─ State Trie
      └─ key: contract address
         value: Account State
            ├─ nonce
            ├─ balance
            ├─ storageRoot ──&amp;rarr; Storage Trie
            └─ codeHash&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 Storage Trie는 다음과 같은 형태다.&lt;/p&gt;
&lt;pre class=&quot;q&quot;&gt;&lt;code&gt;Storage Trie of Contract A
├─ key: storage slot 0 &amp;rarr; value
├─ key: storage slot 1 &amp;rarr; value
├─ key: storage slot 2 &amp;rarr; value
└─ ...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 컨트랙트 A의 상태 변수들은 컨트랙트 A의 Storage Trie에 저장된다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;11. 컨트랙트 storage가 변경되면 어떤 일이 일어날까?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 다음 코드가 실행된다고 하자.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;balances[msg.sender] = 100;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드는 EVM에서 실행되며, storage 값을 변경하는 명령으로 이어진다. 이때 이더리움 노드는 실행 결과에 따라 해당 컨트랙트의 Storage Trie를 갱신한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;흐름은 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;pf&quot;&gt;&lt;code&gt;1. EVM이 balances[msg.sender]가 저장될 storage slot을 계산한다.
2. 해당 컨트랙트의 Storage Trie에서 그 slot 값을 100으로 변경한다.
3. Storage Trie의 root hash가 변경된다.
4. Account State의 storageRoot가 변경된다.
5. State Trie의 root hash, 즉 stateRoot가 변경된다.
6. 변경된 stateRoot가 블록 헤더에 기록된다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조 덕분에 이더리움은 스마트 컨트랙트의 세부 상태 변경까지 최종적으로 하나의 &lt;code&gt;stateRoot&lt;/code&gt;에 반영할 수 있다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;12. 전체 구조 정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이더리움의 상태 저장 구조를 한 번에 정리하면 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Block Header
└─ stateRoot
   └─ State Trie
      ├─ address A &amp;rarr; Account State
      │              ├─ nonce
      │              ├─ balance
      │              ├─ storageRoot
      │              └─ codeHash
      │
      └─ contract address B &amp;rarr; Account State
                             ├─ nonce
                             ├─ balance
                             ├─ storageRoot ──&amp;rarr; Storage Trie
                             │                  ├─ slot 0 &amp;rarr; value
                             │                  ├─ slot 1 &amp;rarr; value
                             │                  └─ ...
                             └─ codeHash&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 다음이다.&lt;/p&gt;
&lt;pre class=&quot;pf&quot;&gt;&lt;code&gt;주소를 key로 해서 계정 상태를 찾는다.
계정 상태에는 balance, nonce, storageRoot, codeHash가 들어 있다.
컨트랙트의 내부 저장소는 storageRoot가 가리키는 별도 Storage Trie에 저장된다.
모든 변경은 최종적으로 stateRoot에 반영된다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;13. 결론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Trie는 문자열이나 key를 prefix 기반으로 효율적으로 저장하고 검색하기 위한 자료구조다. 일반 Trie는 문자 하나마다 노드를 만들지만, Patricia Trie는 갈림길이 없는 경로를 압축하여 노드 수를 줄인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Merkle Tree는 각 노드에 해시를 붙여 데이터의 무결성을 검증할 수 있게 한다. Merkle Patricia Trie는 이 둘을 결합하여, key-value 데이터를 효율적으로 저장하면서도 전체 데이터의 변경 여부와 특정 데이터의 포함 여부를 검증할 수 있게 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이더리움은 이러한 Merkle Patricia Trie를 사용하여 전체 계정 상태를 관리한다. 주소를 key로 사용해 account state를 찾고, account state 안의 &lt;code&gt;storageRoot&lt;/code&gt;를 통해 스마트 컨트랙트별 세부 저장소를 다시 찾는다. 그리고 이 모든 상태 변화는 최종적으로 블록 헤더의 &lt;code&gt;stateRoot&lt;/code&gt;에 반영된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 Merkle Patricia Trie는 이더리움에서 단순한 저장 자료구조가 아니라, 상태 저장, 검색, 무결성 검증, 포함 증명을 동시에 가능하게 하는 핵심 자료구조라고 볼 수 있다.&lt;/p&gt;</description>
      <category>cs/blockchain</category>
      <category>Merkle Trie</category>
      <category>MPT</category>
      <category>trie</category>
      <author>cusum26</author>
      <guid isPermaLink="true">https://imjyh01.tistory.com/19</guid>
      <comments>https://imjyh01.tistory.com/19#entry19comment</comments>
      <pubDate>Tue, 2 Jun 2026 17:53:43 +0900</pubDate>
    </item>
    <item>
      <title>Codex Skill 등록하기 - bcpprm 사례로 익히는 워크플로 자동화 가이드</title>
      <link>https://imjyh01.tistory.com/18</link>
      <description>&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;개발을 하다 보면 반복해서 수행하는 작업이 생깁니다. 변경 사항을 확인하고, 브랜치를 만들고, 커밋 메시지를 정리한 뒤, 원격 저장소에 푸시하고 PR까지 생성하는 흐름도 그중 하나입니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이런 반복 작업을 매번 자연어로 길게 설명하는 대신, Codex가 일정한 규칙에 따라 수행하도록 만들 수 있는 방법이&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Skill&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 글에서는 제가 실제로 등록한&lt;span&gt;&amp;nbsp;&lt;/span&gt;bcpprm&lt;span&gt;&amp;nbsp;&lt;/span&gt;Skill을 사례로 삼아, Codex에 새로운 Skill을 등록하는 전체 과정을 정리합니다. 이후 다른 자동화 Skill을 만들 때에도 그대로 참고할 수 있도록 기록합니다.&lt;/p&gt;
&lt;blockquote style=&quot;color: #000000; text-align: start;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예시로 사용하는&lt;span&gt;&amp;nbsp;&lt;/span&gt;bcpprm는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Branch &amp;rarr; Commit &amp;rarr; Push &amp;rarr; Pull Request&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;흐름을 지원하도록 만든 Skill입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;먼저 결정할 것: Skill인가, Plugin인가?&lt;/h2&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Codex에 새로운 동작을 추가하려고 할 때 가장 먼저 구분해야 하는 것은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Skill&lt;/b&gt;과&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Plugin&lt;/b&gt;의 역할입니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;Skill&lt;/h3&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Skill은 특정 요청이 들어왔을 때 Codex가 따라야 하는 작업 지침입니다. 사용자가 자연어로 특정 작업을 요청하면, Skill에 정의된 절차와 규칙을 기준으로 작업을 수행합니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 다음과 같은 요청을 반복적으로 처리하고 싶다면 Skill이 적합합니다.&lt;/p&gt;
&lt;pre class=&quot;armasm&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;bcpprm 해줘
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 요청이 들어왔을 때 Codex가 Git 상태를 확인하고, 브랜치&amp;middot;커밋&amp;middot;Push&amp;middot;PR 생성 절차를 정해진 규칙대로 수행하도록 만들 수 있습니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;Plugin&lt;/h3&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Plugin은 Skill뿐 아니라 assets, metadata 등 여러 구성 요소를 묶어 관리하는 패키지에 가깝습니다. 하나의 단순한 워크플로를 추가하려는 상황이라면 오히려 구조가 과해질 수 있습니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;무엇을 선택해야 할까?&lt;/h3&gt;
&lt;table style=&quot;color: #000000; text-align: start; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&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 style=&quot;width: 61.2791%;&quot;&gt;브랜치 생성, 커밋, PR 작성처럼 정해진 작업 절차 자동화&lt;/td&gt;
&lt;td style=&quot;width: 38.6047%;&quot;&gt;Skill&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 61.2791%;&quot;&gt;여러 기능과 리소스를 함께 배포&amp;middot;관리하는 패키지&lt;/td&gt;
&lt;td style=&quot;width: 38.6047%;&quot;&gt;Plugin 고려&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;bcpprm&lt;span&gt;&amp;nbsp;&lt;/span&gt;역시 처음에는 Plugin 형태를 고려했지만, 최종적으로는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;자연어 요청에 반응해 하나의 Git/PR 워크플로를 수행하는 기능&lt;/b&gt;이므로 Skill로 정리했습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;Codex Skill의 기본 설치 위치와 구조&lt;/h2&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;새로운 Skill은 사용자 홈 디렉터리 아래의 Codex Skill 경로에 둡니다.&lt;/p&gt;
&lt;pre class=&quot;awk&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;~/.codex/skills/&amp;lt;skill-name&amp;gt;/
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;bcpprm의 실제 설치 경로는 다음과 같습니다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; style=&quot;color: #000000; text-align: start;&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;/Users/username/.codex/skills/bcpprm/&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Skill의 최소 구조는 매우 단순합니다.&lt;/p&gt;
&lt;pre class=&quot;sqf&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;~/.codex/skills/&amp;lt;skill-name&amp;gt;/
└── SKILL.md
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;다만 브랜치명 규칙, 커밋 메시지 규칙, PR 템플릿처럼 별도로 관리할 설정이 있다면&lt;span&gt;&amp;nbsp;&lt;/span&gt;assets/&lt;span&gt;&amp;nbsp;&lt;/span&gt;디렉터리를 함께 두는 방식이 좋습니다.&lt;/p&gt;
&lt;pre class=&quot;sqf&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;~/.codex/skills/&amp;lt;skill-name&amp;gt;/
├── SKILL.md
└── assets/
    ├── conventions.json
    └── conventions.md
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;bcpprm는 다음 구조로 구성했습니다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; style=&quot;color: #000000; text-align: start;&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;/Users/username/.codex/skills/bcpprm/
├── SKILL.md
└── assets/
    ├── conventions.json
    └── conventions.md&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 구조의 핵심은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;동작 지침과 규칙 데이터를 분리하는 것&lt;/b&gt;입니다. Skill의 전체 흐름은&lt;span&gt;&amp;nbsp;&lt;/span&gt;SKILL.md가 담당하고, 반복적으로 수정될 수 있는 Git&amp;middot;PR 규칙은&lt;span&gt;&amp;nbsp;&lt;/span&gt;assets/에서 별도로 관리합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;Step 1: Skill 이름 정하기&lt;/h2&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Skill 이름은 이후 여러 위치에서 동일한 식별자로 사용됩니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Skill 디렉터리 이름&lt;/li&gt;
&lt;li&gt;SKILL.md&lt;span&gt;&amp;nbsp;&lt;/span&gt;frontmatter의&lt;span&gt;&amp;nbsp;&lt;/span&gt;name&lt;/li&gt;
&lt;li&gt;사용자가 자연어 요청에서 언급할 키워드&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;따라서 이름은 짧고, 기억하기 쉽고, 요청문에 자연스럽게 넣을 수 있어야 합니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;이름을 정할 때의 기준&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;영문 소문자 사용을 권장한다.&lt;/li&gt;
&lt;li&gt;공백 없이 작성한다.&lt;/li&gt;
&lt;li&gt;수행하는 작업을 연상할 수 있는 이름으로 짓는다.&lt;/li&gt;
&lt;li&gt;너무 일반적인 단어보다는 고유한 트리거가 되는 이름이 좋다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;bcpprm는 Branch, Commit, Push, Pull Request 흐름을 빠르게 떠올릴 수 있는 이름으로 정했습니다.&lt;/p&gt;
&lt;pre class=&quot;armasm&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;bcpprm
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이후 사용자는 다음처럼 요청할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;armasm&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;bcpprm 해줘
&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;Step 2: 핵심 파일&lt;span&gt;&amp;nbsp;&lt;/span&gt;SKILL.md&lt;span&gt;&amp;nbsp;&lt;/span&gt;만들기&lt;/h2&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;SKILL.md는 Skill의 핵심 파일입니다. Codex가 언제 이 Skill을 적용해야 하는지, 적용된 뒤 어떤 순서와 규칙으로 작업해야 하는지를 정의합니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;가장 기본적인 형태는 다음과 같습니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;---
name: myskill
description: Use when ...
---

# My Skill

이 스킬이 수행할 작업과 규칙을 작성합니다.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;Frontmatter에서 중요한 두 필드&lt;/h3&gt;
&lt;table style=&quot;color: #000000; text-align: start; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&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 style=&quot;width: 44.3023%;&quot;&gt;name&lt;/td&gt;
&lt;td style=&quot;width: 55.5814%;&quot;&gt;Skill의 식별자&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 44.3023%;&quot;&gt;description&lt;/td&gt;
&lt;td style=&quot;width: 55.5814%;&quot;&gt;어떤 사용자 요청에서 Skill을 적용해야 하는지 설명하는 트리거 정의&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;여기서 특히 중요한 것은&lt;span&gt;&amp;nbsp;&lt;/span&gt;description입니다. Skill이 잘 만들어져 있어도 사용자의 요청과 연결되지 않으면 동작을 기대하기 어렵습니다. 따라서 실제 사용자가 말할 법한 표현을 구체적으로 포함하는 것이 좋습니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;bcpprm의 frontmatter 예시&lt;/h3&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;---
name: bcpprm
description: Use when the user mentions `bcpprm`, tries `/bcpprm`, or asks to create a branch, commit, push, and draft or create a pull request ...
---
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 정의에는 다음 의도가 담겨 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;사용자가&lt;span&gt;&amp;nbsp;&lt;/span&gt;bcpprm라는 이름을 직접 말했을 때 감지한다.&lt;/li&gt;
&lt;li&gt;사용자가&lt;span&gt;&amp;nbsp;&lt;/span&gt;/bcpprm처럼 입력했을 때도 관련 요청으로 해석할 수 있게 한다.&lt;/li&gt;
&lt;li&gt;Skill 이름을 모르더라도 &amp;ldquo;브랜치 만들고 커밋하고 PR 올려줘&amp;rdquo; 같은 자연어 요청을 감지할 여지를 둔다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;단, 여기서 주의해야 할 점이 있습니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;description에&lt;span&gt;&amp;nbsp;&lt;/span&gt;/bcpprm을 포함했다고 해서, 이것이 곧바로 Codex의 내장 Slash Command로 등록되는 것은 아닙니다. 이 차이는 뒤에서 다시 다루겠습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;Step 3: Skill 본문에 실제 워크플로 정의하기&lt;/h2&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Frontmatter가 &amp;ldquo;언제 이 Skill을 사용할 것인가&amp;rdquo;를 정의한다면, 본문은 &amp;ldquo;Skill이 선택된 뒤 무엇을 할 것인가&amp;rdquo;를 정의합니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Skill 본문에는 최소한 다음 요소를 담는 것이 좋습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;작업 수행 순서&lt;/li&gt;
&lt;li&gt;반드시 지켜야 하는 동작 규칙&lt;/li&gt;
&lt;li&gt;트리거 예시&lt;/li&gt;
&lt;li&gt;최종 출력 또는 완료 조건&lt;/li&gt;
&lt;li&gt;위험한 작업에 대한 승인 규칙&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;재사용 가능한 기본 템플릿은 다음과 같이 만들 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;markdown&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;# Skill Name

이 스킬은 다음 순서로 동작합니다.

1. 현재 상태 확인
2. 필요한 규칙 파일 읽기
3. 작업 내용 결정
4. 변경 작업 실행
5. 실행 결과 보고

## 필수 동작

- ...

## 트리거 예시

- `myskill 해줘`

## 완료 조건

- ...
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;bcpprm에 정의한 작업 흐름&lt;/h3&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;bcpprm는 Git 변경 사항을 PR로 연결하는 Skill이므로, 아래와 같은 순서로 동작하도록 구성했습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;현재 Git 상태와 변경 사항을 확인한다.&lt;/li&gt;
&lt;li&gt;assets/&lt;span&gt;&amp;nbsp;&lt;/span&gt;아래의 규칙 파일을 읽는다.&lt;/li&gt;
&lt;li&gt;변경 내용을 바탕으로 브랜치명을 결정한다.&lt;/li&gt;
&lt;li&gt;스테이징 대상 파일을 확인한 뒤&lt;span&gt;&amp;nbsp;&lt;/span&gt;git add를 수행한다.&lt;/li&gt;
&lt;li&gt;규칙에 맞는 커밋 메시지를 생성하고 커밋한다.&lt;/li&gt;
&lt;li&gt;원격 저장소로 브랜치를 Push한다.&lt;/li&gt;
&lt;li&gt;이미 생성된 PR이 있는지 확인한다.&lt;/li&gt;
&lt;li&gt;PR이 없다면 정해진 형식에 따라 새 PR을 생성한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이처럼 본문에는 단순히 &amp;ldquo;PR을 만들어라&amp;rdquo;가 아니라,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;실제 작업에서 놓치면 안 되는 상태 확인과 중복 확인 과정&lt;/b&gt;까지 포함시키는 것이 중요합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;Step 4: 변할 수 있는 규칙은&lt;span&gt;&amp;nbsp;&lt;/span&gt;assets/로 분리하기&lt;/h2&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Skill을 작성하다 보면 동작 자체는 같지만, 팀이나 프로젝트에 따라 달라지는 규칙이 있습니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 다음 항목들은 자주 변경될 수 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;브랜치명 패턴&lt;/li&gt;
&lt;li&gt;커밋 메시지 컨벤션&lt;/li&gt;
&lt;li&gt;PR 제목 형식&lt;/li&gt;
&lt;li&gt;PR 본문 섹션&lt;/li&gt;
&lt;li&gt;기본 대상 브랜치&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이런 내용을&lt;span&gt;&amp;nbsp;&lt;/span&gt;SKILL.md&lt;span&gt;&amp;nbsp;&lt;/span&gt;본문에 모두 직접 적어두면, 규칙이 변경될 때마다 동작 설명까지 함께 수정해야 합니다. 반대로 규칙을 별도 asset으로 분리하면 Skill의 흐름은 유지하면서 설정만 바꿀 수 있습니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;권장하는 asset 구성&lt;/h3&gt;
&lt;table style=&quot;color: #000000; text-align: start; border-collapse: collapse; width: 100%; height: 57px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;파일&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;용도&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;conventions.json&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;Codex가 구조적으로 참고할 수 있는 규칙 데이터&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;conventions.md&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;사람이 읽고 수정하기 쉬운 설명과 예시&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;bcpprm의 규칙 예시&lt;/h3&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;bcpprm에서는 다음과 같은 규칙을 asset으로 관리했습니다.&lt;/p&gt;
&lt;table style=&quot;color: #000000; text-align: start; border-collapse: collapse; width: 87.2093%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 30.5147%;&quot;&gt;항목&lt;/td&gt;
&lt;td style=&quot;width: 56.5783%;&quot;&gt;규칙&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 30.5147%;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;브랜치명&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 56.5783%;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;type/summary&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 30.5147%;&quot;&gt;커밋 메시지&lt;/td&gt;
&lt;td style=&quot;width: 56.5783%;&quot;&gt;type: summary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 30.5147%;&quot;&gt;PR 제목&lt;/td&gt;
&lt;td style=&quot;width: 56.5783%;&quot;&gt;[TYPE] 제목&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 30.5147%;&quot;&gt;기본 대상 브랜치&lt;/td&gt;
&lt;td style=&quot;width: 56.5783%;&quot;&gt;main&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 30.5147%;&quot;&gt;PR 본문 섹션&lt;/td&gt;
&lt;td style=&quot;width: 56.5783%;&quot;&gt;요약,&lt;span&gt;&amp;nbsp;&lt;/span&gt;작업내용,&lt;span&gt;&amp;nbsp;&lt;/span&gt;변경파일,&lt;span&gt;&amp;nbsp;&lt;/span&gt;검증,&lt;span&gt;&amp;nbsp;&lt;/span&gt;비고&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;왜 JSON과 Markdown을 함께 두는가?&lt;/h3&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;conventions.json은 구조가 명확하기 때문에 자동화 규칙으로 참고하기 좋습니다. 반면&lt;span&gt;&amp;nbsp;&lt;/span&gt;conventions.md는 사람이 컨벤션의 배경과 예시를 이해하거나 직접 수정할 때 훨씬 편리합니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;즉, 두 파일은 중복이 아니라 목적이 다릅니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;JSON: 작업 수행 시 참조할 수 있는 명시적인 규칙&lt;/li&gt;
&lt;li&gt;Markdown: 규칙을 유지보수하는 사람을 위한 설명서&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;각 파일의 역할을 명확히 나누기&lt;/h2&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Skill을 만들 때 파일 수가 늘어나면 &amp;ldquo;어떤 내용을 어디에 두어야 하는가?&amp;rdquo;가 애매해질 수 있습니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;bcpprm를 기준으로 파일의 책임을 정리하면 다음과 같습니다.&lt;/p&gt;
&lt;table style=&quot;color: #000000; text-align: start; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;파일&lt;/td&gt;
&lt;td&gt;책임&lt;/td&gt;
&lt;td&gt;bcpprm에서의 역할&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SKILL.md&lt;/td&gt;
&lt;td&gt;Codex가 수행할 워크플로와 사용 조건 정의&lt;/td&gt;
&lt;td&gt;Git 상태 확인부터 PR 생성까지의 절차 정의&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;assets/conventions.json&lt;/td&gt;
&lt;td&gt;기계적으로 읽기 쉬운 규칙 데이터&lt;/td&gt;
&lt;td&gt;브랜치명&amp;middot;커밋 메시지&amp;middot;PR 형식 정의&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;assets/conventions.md&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 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 책임을 분리해 두면 새로운 Skill을 만들 때에도 같은 패턴을 재사용할 수 있습니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 배포 자동화 Skill이라면&lt;span&gt;&amp;nbsp;&lt;/span&gt;SKILL.md에 배포 절차를 두고,&lt;span&gt;&amp;nbsp;&lt;/span&gt;assets/에는 대상 환경, 브랜치 정책, 검증 체크리스트 등을 둘 수 있습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;Step 5: 등록 후에는 새 세션에서 테스트하기&lt;/h2&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Skill 파일을 생성했다고 해서 현재 열려 있는 Codex 세션이 즉시 새로운 Skill을 반영한다고 가정하면 안 됩니다. 이미 실행 중이던 세션은 새로 추가된 Skill을 다시 읽지 않을 수 있기 때문입니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;따라서 Skill 등록 이후에는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;새 Codex 세션을 열어 테스트하는 과정&lt;/b&gt;을 권장합니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;테스트 순서&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;새로운 Codex 세션을 연다.&lt;/li&gt;
&lt;li&gt;Skill 이름을 직접 포함한 요청을 보낸다.&lt;/li&gt;
&lt;li&gt;이름 없이 작업 의도만 담은 자연어 요청도 테스트한다.&lt;/li&gt;
&lt;li&gt;실제로 정의한 규칙 파일과 워크플로가 반영되는지 확인한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;bcpprm&lt;span&gt;&amp;nbsp;&lt;/span&gt;테스트 요청 예시&lt;/h3&gt;
&lt;pre class=&quot;armasm&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;bcpprm 해줘
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;armasm&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;bcpprm 규칙대로 진행해줘
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;stata&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;현재 변경 사항으로 브랜치 만들고 커밋한 뒤 PR까지 작성해줘
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;마지막 요청까지 잘 처리된다면, Skill 이름에만 의존하지 않고 작업 의도에 따라 선택될 수 있도록&lt;span&gt;&amp;nbsp;&lt;/span&gt;description이 충분히 작성되어 있는지 확인하는 데 도움이 됩니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;등록 여부를 확인하는 방법&lt;/h2&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;테스트 전에 가장 먼저 확인할 것은 실제 파일이 올바른 위치에 생성되어 있는지입니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;bcpprm의 경우 확인 대상 파일은 다음과 같습니다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; style=&quot;color: #000000; text-align: start;&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;/Users/username/.codex/skills/bcpprm/SKILL.md
/Users/username/.codex/skills/bcpprm/assets/conventions.json
/Users/username/.codex/skills/bcpprm/assets/conventions.md&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그 다음에는 Codex 환경에서 Skill 목록에 표시되는지 확인하고, 새 세션에서 트리거 요청을 실행합니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;확인 체크포인트&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;폴더명이&lt;span&gt;&amp;nbsp;&lt;/span&gt;SKILL.md의&lt;span&gt;&amp;nbsp;&lt;/span&gt;name과 일치하는가?&lt;/li&gt;
&lt;li&gt;SKILL.md가 올바른 경로에 존재하는가?&lt;/li&gt;
&lt;li&gt;description에 실제 사용할 요청 표현이 포함되어 있는가?&lt;/li&gt;
&lt;li&gt;asset 파일을 읽도록 본문에 명시되어 있는가?&lt;/li&gt;
&lt;li&gt;새 세션에서 Skill이 적용되는가?&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Codex 버전이나 실행 환경에 따라 Skill 목록을 확인하는 UI나 명령 노출 방식은 달라질 수 있으므로, 최종적으로는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;새 세션에서 실제 요청이 의도한 흐름으로 처리되는지&lt;/b&gt;를 기준으로 판단하는 것이 가장 안전합니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;모두 확인 후 입력창에 /skills -&amp;gt; List skills 결과, bcpprm이 보이면 정상적으로 등록된 것입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2356&quot; data-origin-height=&quot;302&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/SUnVR/dJMcaccfMWP/96o21Vq7WGU6YkoI9gjyJk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/SUnVR/dJMcaccfMWP/96o21Vq7WGU6YkoI9gjyJk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/SUnVR/dJMcaccfMWP/96o21Vq7WGU6YkoI9gjyJk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FSUnVR%2FdJMcaccfMWP%2F96o21Vq7WGU6YkoI9gjyJk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;77&quot; data-origin-width=&quot;2356&quot; data-origin-height=&quot;302&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;원격 저장소를 변경하는 Skill에는 승인 규칙을 넣자&lt;/span&gt;&lt;/h2&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;bcpprm&lt;/span&gt;&lt;span&gt;처럼 Git Push 또는 PR 생성을 포함하는 Skill은 단순한 문서 요약 Skill과 성격이 다릅니다. 실행 결과가 로컬에만 머물지 않고 원격 저장소에 영향을 주기 때문입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;따라서 다음과 같은 작업을 수행하는 Skill에는 승인 규칙과 실패 대응 방법을 명시하는 것이 좋습니다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;git push&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;git push --force-with-lease&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;gh pr create&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;배포 실행&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;원격 리소스 수정 또는 삭제&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;GitHub CLI(&lt;/span&gt;&lt;span&gt;gh&lt;/span&gt;&lt;span&gt;)가 필요한 이유&lt;/span&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;bcpprm&lt;/span&gt;&lt;span&gt;의 마지막 단계는 GitHub Pull Request를 확인하고, 필요한 경우 새 PR을 생성하는 작업입니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;git&lt;/span&gt;&lt;span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;명령어만으로 브랜치 생성, 커밋, 원격 저장소 Push까지는 처리할 수 있지만, GitHub 위에서 PR을 조회하거나 생성하려면 GitHub 서비스와 상호작용할 수 있는 도구가 필요합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이때 사용하는 도구가&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;&lt;span&gt;GitHub CLI&lt;/span&gt;&lt;/b&gt;&lt;span&gt;, 즉&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;gh&lt;/span&gt;&lt;span&gt;입니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;gh&lt;/span&gt;&lt;span&gt;를 이용하면 브라우저를 열어 수동으로 PR을 작성하지 않아도 터미널에서 인증 상태 확인, 기존 PR 조회, 새 PR 생성까지 이어서 수행할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;table style=&quot;color: #000000; text-align: start; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 61.7442%;&quot;&gt;&lt;span&gt;명령어&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 38.1395%;&quot;&gt;&lt;span&gt;역할&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 61.7442%;&quot;&gt;&lt;span&gt;gh auth status&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 38.1395%;&quot;&gt;&lt;span&gt;현재 환경에서 GitHub 계정 인증 상태 확인&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 61.7442%;&quot;&gt;&lt;span&gt;gh pr list --head &amp;lt;branch-name&amp;gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 38.1395%;&quot;&gt;&lt;span&gt;해당 브랜치로 이미 생성된 PR이 있는지 확인&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 61.7442%;&quot;&gt;&lt;span&gt;gh pr create --base main --title &quot;[TYPE] 제목&quot; --body-file &amp;lt;pr-body-file&amp;gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 38.1395%;&quot;&gt;&lt;span&gt;규칙에 맞는 제목과 본문으로 PR 생성&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;예를 들어 인증 상태는 다음 명령으로 확인할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1779461594020&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;gh auth status&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;현재 브랜치로 이미 생성된 PR이 있는지는 다음처럼 확인할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1779461647699&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;gh pr list --head &amp;lt;branch-name&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;기존 PR이 없고 새로 생성해야 한다면 다음과 같이 대상 브랜치, 제목, 본문을 지정할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1779461663052&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;gh pr create --base main --title &quot;[TYPE] 제목&quot; --body-file &amp;lt;pr-body-file&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;bcpprm&lt;/span&gt;&lt;span&gt;에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;git&lt;/span&gt;&lt;span&gt;과&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;gh&lt;/span&gt;&lt;span&gt;가 담당하는 역할&lt;/span&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;bcpprm&lt;/span&gt;&lt;span&gt;는 로컬 변경 사항을 GitHub PR까지 연결하는 워크플로입니다. 이 과정에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;git&lt;/span&gt;&lt;span&gt;과&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;gh&lt;/span&gt;&lt;span&gt;는 서로 다른 범위를 담당합니다.&lt;/span&gt;&lt;/p&gt;
&lt;table style=&quot;color: #000000; text-align: start; border-collapse: collapse; width: 100%; height: 57px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 29.3023%;&quot;&gt;&lt;span&gt;도구&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 70.5814%;&quot;&gt;&lt;span&gt;담당 범위&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 29.3023%;&quot;&gt;&lt;span&gt;git&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 70.5814%;&quot;&gt;&lt;span&gt;로컬 변경 사항 확인, 브랜치 생성, 스테이징, 커밋, 원격 저장소 Push&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 29.3023%;&quot;&gt;&lt;span&gt;gh&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 70.5814%;&quot;&gt;&lt;span&gt;GitHub 인증 상태 확인, 기존 PR 조회, 새 PR 생성&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;전체 흐름은 다음처럼 이어집니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1779461697156&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;git status
&amp;rarr; git checkout -b &amp;lt;branch-name&amp;gt;
&amp;rarr; git add &amp;lt;files&amp;gt;
&amp;rarr; git commit -m &quot;&amp;lt;commit-message&amp;gt;&quot;
&amp;rarr; git push -u origin &amp;lt;branch-name&amp;gt;
&amp;rarr; gh auth status
&amp;rarr; gh pr list --head &amp;lt;branch-name&amp;gt;
&amp;rarr; gh pr create ...&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;단, 모든 경우에&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;gh pr create&lt;/span&gt;&lt;span&gt;를 바로 실행하는 것은 적절하지 않습니다. 이미 같은 브랜치로 생성된 PR이 있다면 새 PR을 중복 생성하는 대신 기존 PR을 안내하거나 갱신된 커밋이 반영되었는지 확인해야 합니다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;bcpprm&lt;/span&gt;&lt;span&gt;에 적용할 수 있는 안전 규칙&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;Push 또는 PR 생성 전에 사용자 승인을 받도록 한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;강제 Push가 필요하다면 일반 Push보다 더 엄격하게 확인한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;PR 작업 전에&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;gh auth status&lt;/span&gt;&lt;span&gt;로 GitHub CLI 인증 상태를 점검할 수 있도록 한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;gh pr create&lt;/span&gt;&lt;span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;실행 전, 현재 브랜치에 연결된 기존 PR이 있는지 먼저 확인한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;인증 실패나 권한 부족으로 PR 생성이 실패하면 로그인 상태와 저장소 접근 권한을 우선 점검한다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이러한 규칙은 작업을 느리게 만들기 위한 것이 아니라, 자동화 범위가 커질수록 발생할 수 있는 원격 저장소 변경 실수와 중복 작업을 통제하기 위한 장치입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;bcpprm 테스트&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2264&quot; data-origin-height=&quot;894&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kRAHL/dJMcaaS3ypr/CtKmrc8bYFxzK16gTCVNN0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kRAHL/dJMcaaS3ypr/CtKmrc8bYFxzK16gTCVNN0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kRAHL/dJMcaaS3ypr/CtKmrc8bYFxzK16gTCVNN0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkRAHL%2FdJMcaaS3ypr%2FCtKmrc8bYFxzK16gTCVNN0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;237&quot; data-origin-width=&quot;2264&quot; data-origin-height=&quot;894&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2366&quot; data-origin-height=&quot;888&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cfMTmU/dJMcahR77zE/dkKVs3BJGDAaTDOf9bKKN1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cfMTmU/dJMcahR77zE/dkKVs3BJGDAaTDOf9bKKN1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cfMTmU/dJMcahR77zE/dkKVs3BJGDAaTDOf9bKKN1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcfMTmU%2FdJMcahR77zE%2FdkKVs3BJGDAaTDOf9bKKN1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;225&quot; data-origin-width=&quot;2366&quot; data-origin-height=&quot;888&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-06-02 오후 2.43.29.png&quot; data-origin-width=&quot;2936&quot; data-origin-height=&quot;1648&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Av3oy/dJMcacQPMNR/JMxLLDsKVGRbD4uWaqhGfk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Av3oy/dJMcacQPMNR/JMxLLDsKVGRbD4uWaqhGfk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Av3oy/dJMcacQPMNR/JMxLLDsKVGRbD4uWaqhGfk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FAv3oy%2FdJMcacQPMNR%2FJMxLLDsKVGRbD4uWaqhGfk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;337&quot; data-filename=&quot;스크린샷 2026-06-02 오후 2.43.29.png&quot; data-origin-width=&quot;2936&quot; data-origin-height=&quot;1648&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;branch 명명 규칙부터 commit, PR 메시지 convention 모두 지켜서 workflow를&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;제대로 수행한 것을 확인할 수 있습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;Skill과 Slash Command는 다르다&lt;/h2&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Skill을 처음 등록할 때 가장 혼동하기 쉬운 부분은 Slash Command와의 차이입니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Skill은 자연어 요청을 바탕으로 특정 작업 지침을 적용하는 방식입니다. 반면 Slash Command는 별도로 등록되거나 제품이 제공하는 명령 체계입니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;따라서 Skill 이름이&lt;span&gt;&amp;nbsp;&lt;/span&gt;bcpprm라고 해서 자동으로 다음 명령이 내장 명령처럼 동작하는 것은 아닙니다.&lt;/p&gt;
&lt;pre class=&quot;armasm&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;/bcpprm
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;하지만&lt;span&gt;&amp;nbsp;&lt;/span&gt;SKILL.md의&lt;span&gt;&amp;nbsp;&lt;/span&gt;description에 관련 표현을 넣고, 자연어 요청으로 다음과 같이 사용할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;armasm&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;bcpprm 해줘
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;정리하면&lt;span&gt;&amp;nbsp;&lt;/span&gt;bcpprm의 현재 형태는 다음과 같습니다.&lt;/p&gt;
&lt;table style=&quot;color: #000000; text-align: start; border-collapse: collapse; width: 68.3721%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 34.0379%;&quot;&gt;구분&lt;/td&gt;
&lt;td style=&quot;width: 31.5289%;&quot;&gt;해당 여부&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 34.0379%;&quot;&gt;Codex Skill&lt;/td&gt;
&lt;td style=&quot;width: 31.5289%;&quot;&gt;O&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 34.0379%;&quot;&gt;Plugin&lt;/td&gt;
&lt;td style=&quot;width: 31.5289%;&quot;&gt;X&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 34.0379%;&quot;&gt;내장 Slash Command&lt;/td&gt;
&lt;td style=&quot;width: 31.5289%;&quot;&gt;X&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 34.0379%;&quot;&gt;자연어 트리거 기반 워크플로&lt;/td&gt;
&lt;td style=&quot;width: 31.5289%;&quot;&gt;O&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;Plugin으로 시작했다가 Skill만 남기고 싶을 때&lt;/h2&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 구조를 넓게 잡아 Plugin으로 만들었다가, 실제로는 Skill 하나만 있으면 충분하다고 판단할 수도 있습니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;bcpprm&lt;span&gt;&amp;nbsp;&lt;/span&gt;역시 이런 정리 과정을 거쳤습니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Plugin을 걷어내고 Skill만 유지하려면 다음 순서로 정리할 수 있습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Plugin 안에서 필요한&lt;span&gt;&amp;nbsp;&lt;/span&gt;SKILL.md와&lt;span&gt;&amp;nbsp;&lt;/span&gt;assets/&lt;span&gt;&amp;nbsp;&lt;/span&gt;파일만 추출한다.&lt;/li&gt;
&lt;li&gt;~/.codex/skills/&amp;lt;skill-name&amp;gt;/&lt;span&gt;&amp;nbsp;&lt;/span&gt;경로에 복사한다.&lt;/li&gt;
&lt;li&gt;기존 Plugin 디렉터리를 삭제한다.&lt;/li&gt;
&lt;li&gt;로컬 marketplace 등에 등록된 Plugin 항목을 제거한다.&lt;/li&gt;
&lt;li&gt;새 Codex 세션에서 Skill 단독으로 동작하는지 검증한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;bcpprm에서는 다음 항목을 제거했습니다.&lt;/p&gt;
&lt;pre class=&quot;armasm&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;~/vscode/plugins/bcpprm
~/vscode/.agents/plugins/marketplace.json 내부의 bcpprm 항목
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 과정을 통해 최종 구조는 Plugin 의존 없이&lt;span&gt;&amp;nbsp;&lt;/span&gt;~/.codex/skills/bcpprm/&lt;span&gt;&amp;nbsp;&lt;/span&gt;아래의 파일만으로 관리되는 형태가 되었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;다른 Skill을 만들 때 그대로 재사용할 체크리스트&lt;/h2&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;새로운 Skill을 등록할 때마다 아래 항목을 순서대로 확인하면 빠뜨리는 부분을 줄일 수 있습니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;설계 단계&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;수행하려는 기능이 Skill에 적합한 단일 워크플로인가?&lt;/li&gt;
&lt;li&gt;여러 기능과 리소스를 패키지로 관리해야 한다면 Plugin이 더 적합하지 않은가?&lt;/li&gt;
&lt;li&gt;사용자가 자연어로 부르기 쉬운 Skill 이름을 정했는가?&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;파일 작성 단계&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;~/.codex/skills/&amp;lt;skill-name&amp;gt;/&lt;span&gt;&amp;nbsp;&lt;/span&gt;디렉터리를 만들었는가?&lt;/li&gt;
&lt;li&gt;SKILL.md에&lt;span&gt;&amp;nbsp;&lt;/span&gt;name을 정확히 작성했는가?&lt;/li&gt;
&lt;li&gt;description에 실제 요청문과 유사한 트리거 표현을 넣었는가?&lt;/li&gt;
&lt;li&gt;작업 순서, 완료 조건, 필수 규칙을 본문에 명시했는가?&lt;/li&gt;
&lt;li&gt;변경될 수 있는 규칙을&lt;span&gt;&amp;nbsp;&lt;/span&gt;assets/로 분리할 필요가 있는가?&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;검증 단계&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;새 Codex 세션에서 테스트했는가?&lt;/li&gt;
&lt;li&gt;Skill 이름을 포함한 요청과 자연어 작업 요청을 모두 시험했는가?&lt;/li&gt;
&lt;li&gt;원격 변경 작업이 있다면 승인 절차를 포함했는가?&lt;/li&gt;
&lt;li&gt;실패 시 확인할 인증&amp;middot;환경 점검 방법을 적어두었는가?&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;최종 정리: &quot;좋은 Skill은 작업 지시서이자 운영 규칙이다&quot;&lt;/h2&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;bcpprm를 등록하면서 가장 중요했던 점은 단순히 &amp;ldquo;브랜치와 PR을 자동으로 만들어주는 기능&amp;rdquo;을 추가하는 것이 아니었습니다. 실제로 반복 가능한 Skill을 만들기 위해서는 다음 요소가 함께 필요했습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;사용자의 요청을 안정적으로 감지할 수 있는 구체적인&lt;span&gt;&amp;nbsp;&lt;/span&gt;description&lt;/li&gt;
&lt;li&gt;순서가 명확한&lt;span&gt;&amp;nbsp;&lt;/span&gt;SKILL.md&lt;span&gt;&amp;nbsp;&lt;/span&gt;워크플로&lt;/li&gt;
&lt;li&gt;자주 바뀌는 컨벤션을 분리한&lt;span&gt;&amp;nbsp;&lt;/span&gt;assets/&lt;span&gt;&amp;nbsp;&lt;/span&gt;구조&lt;/li&gt;
&lt;li&gt;새 세션에서의 실제 동작 검증&lt;/li&gt;
&lt;li&gt;Push와 PR 생성 같은 원격 작업에 대한 안전 규칙&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;즉, Skill은 단순한 프롬프트 조각이 아니라&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;반복 작업을 신뢰할 수 있게 수행하도록 만드는 작은 운영 문서&lt;/b&gt;에 가깝습니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;현재&lt;span&gt;&amp;nbsp;&lt;/span&gt;bcpprm는 다음 세 파일을 중심으로 동작하는 자연어 트리거 기반 Codex Skill입니다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;~/.codex/skills/bcpprm/
├── SKILL.md
└── assets/
    ├── conventions.json
    └── conventions.md
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;앞으로 배포 체크, 코드 리뷰 준비, 문서 생성, 테스트 실행처럼 반복되는 개발 작업이 생긴다면, 같은 구조로 새로운 Skill을 설계해볼 수 있습니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;작업 순서를 명시하고, 변하는 규칙을 asset으로 분리하고, 위험한 동작에는 승인 기준을 두는 것.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 세 가지를 지키면 특정 사례를 넘어 재사용 가능한 자동화 워크플로로 발전시킬 수 있습니다.&lt;/p&gt;</description>
      <category>dev/ai</category>
      <category>bccprm</category>
      <category>CODEX</category>
      <category>codex skill</category>
      <category>GH</category>
      <category>Github</category>
      <category>Github CLI</category>
      <category>plugin</category>
      <category>PR</category>
      <category>skill</category>
      <author>cusum26</author>
      <guid isPermaLink="true">https://imjyh01.tistory.com/18</guid>
      <comments>https://imjyh01.tistory.com/18#entry18comment</comments>
      <pubDate>Sat, 23 May 2026 00:02:09 +0900</pubDate>
    </item>
    <item>
      <title>알림 아키텍처 (2) - Processing Layer</title>
      <link>https://imjyh01.tistory.com/17</link>
      <description>&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;알림 시스템에서 가장 먼저 마주하는 문제는 &amp;ldquo;알림이라는 작업을 어떻게 생성하고 누구에게 전달할 것인가&amp;rdquo;이다. 특히 하나의 이벤트가 수백만 명에게 전달되어야 하는 상황에서는 단순한 구현으로는 감당할 수 없는 부하가 발생한다. 이에 각 도메인 서비스에서 직접 알림 메시지를 생성하여 처리하기보다 알림 담당 도메인을 두고 event-driven 모델로 통신하는 구조가 적합하다. 이처럼 생성과 전달 책임을 분리하기 위해서 알림은 독립적인 이벤트 형태로 정의할 필요가 있다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;다음으로 고민해야 할 것은 이 이벤트를 어떻게 사용자에게 안정적으로 분배할 지이다. 알림 기능은 대부분 &lt;b&gt;유저 인터렉션을 위한 후속 작업&lt;/b&gt;의 성격으로 이용가능한 자원이 제한적이고 알림 이벤트 각각은 서로 독립적이다. 따라서 작업을 최대한 &lt;b&gt;균등하게 분배하고 비동기적으로 처리&lt;/b&gt;하는 구조가 핵심이 된다. 따라서 대부분의 대규모 알림 시스템은 Kafka와 같은 외부 시스템에 이벤트를 produce하며 돌입한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Kafka가 어떤 구조로 이벤트를 균등하게 분배하고 비동기 처리 구조를 지원하는지 궁금하다면 아래 포스팅 참고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://imjyh01.tistory.com/10&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;2026.01.22 - [dev/infra] - Kafka - 이벤트 기반의 비동기 작업 처리 구조로 알림 기능 구현하기 (3)&lt;/a&gt;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;알림 기능이 어떤 흐름으로 구현되는지 제대로 알아보자.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;도메인 이벤트&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;알림은 항상 어떤 &amp;ldquo;이벤트&amp;rdquo;로부터 시작된다. 예를 들어 한 사용자가 게시글에 좋아요를 눌렀다고 가정해보자. 시스템 내부에서는 단순히 &quot;좋아요가 눌렸다&quot;는 DB 상태 변화에 그치지 않고, 이를 계기로 알림 전송이나 행동 로그 기록과 같은 후속 로직이 함께 트리거된다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;이런 구조에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;각 로직을 서로 강하게 결합시키기보다는&lt;span&gt;&amp;nbsp;&lt;/span&gt;의미 있는 사건을 하나의 객체로 정의하고 이를 기반으로 소통하는 방식이 필요하다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이때&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;시스템 내에서 발생한 의미 있는 사건을 표현하는 단위&lt;/b&gt;가 이벤트이다. 위 예시에서 &quot;사용자가 게시글에 좋아요를 누른 사건&quot;을 도메인 이벤트로 표현하면 다음과 같다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;postLikeEvent&lt;br /&gt;- actor: A (좋아요를 누른 사용자)&lt;br /&gt;- target: B (게시글 작성자)&lt;br /&gt;- content: postId (게시글)&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스는 내부에서 직접 데이터를 처리하는 대신 이벤트의 형태로 외부로 전달할 수 있다. 좋아요 서비스는 producer로서 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;이 이벤트를&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;메시지 브로커에 발행하고 알림 서비스는 consumer로서 이를 구독하여 비동기적으로 처리하는 구조를 통해 알림 로직을 원래 서비스 로직과 분리할 수 있고, 새로운 consumer를 추가하는 것만으로 기능을 확장할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;알림 이벤트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;알림 시스템에서는 &amp;ldquo;알림 이벤트&amp;rdquo;와 &amp;ldquo;알림 데이터&amp;rdquo;를 구분해서 이해할 필요가 있다. 알림 이벤트는 특정 도메인 이벤트로부터 파생된 알림 도메인의 이벤트로 어떤 사용자에게 어떤 알림을 생성해야 하는지를 나타내는 중간 단계의 데이터이다. 반면 알림 데이터는 실제 DB에 저장되어 사용자에게 전달되는 최종 결과물로 메시지 형태로 가공되어 저장되고 읽음 상태 등을 포함한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 위의 postLikeEvent를 감지한 Consumer가 작성자에게 알림을 보내는 로직을 바로 호출한다면 &quot;좋아요 누른 사건&quot; 자체를 나타내는 도메인 이벤트는 알림 이벤트의 역할과 다르지 않다. 알림 서비스가 따로 분리되어있더라도 이 이벤트로부터 직접 알림을 생성해서 유저에게 리턴한다면 postLikeEvent는 도메인 이벤트이자 알림 이벤트가 되고, 생성한 결과물이 알림 데이터가 된다. 이런 구조에서는 postLikeEvent를 매개로 &lt;b&gt;좋아요 도메인과 알림 도메인이 강하게 결합&lt;/b&gt;된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이처럼 단순한 경우에는 도메인 이벤트가 곧 알림 이벤트로 이어질 수 있지만 현실적인 시스템에서는 아래와 같은 요구사항만 봐도 도메인간 결합도를 낮출 필요가 있기 때문에 두 이벤트를 분리하는 것이 일반적이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. 알림 수신 off인 상황&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋아요 이벤트가 발생하더라도 알림 수신을 꺼놓은 유저에게는 알림을 안 보내도록 해야한다. 만약 postLikeEvent가 곧 알림 이벤트인 구조라면 작성자는 알림 수신을 꺼놓았더라도 좋아요 이벤트에 대해 항상 알림 데이터를 저장하게 되어 비효율적이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000;&quot; data-ke-size=&quot;size20&quot;&gt;2. UI용 메시지&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도메인 이벤트를 알림 이벤트로 보는 구조에서는 이벤트 하나하나에 알림 데이터가 생성되어 &quot;A 외 99명이 좋아요를 누름&quot;와 같은 aggregation 메시지 구현에 비효율적이다. 따라서 좋아요 기능을 담당하는 도메인과 UI용 결과물을 담당하는 알림 도메인의 책임을 중간에서 분리시키는 독립적인 알림 이벤트의 존재가 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. 하나의 이벤트 &amp;rarr; 여러 알림&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하나의 도메인 이벤트에 대해 여러 알림을 생성해야 할 경우, 이 작업을 안정적으로 분배하기 위해서 중간 데이터가 필요하다. 앞선 예시에서는 좋아요를 받은 사용자 한 명에게만 알림을 보내면 되기 때문에 도메인 이벤트에서 바로 알림 데이터를 만들어도 큰 문제가 없다. 하지만 팔로워가 많은 사용자가 게시글을 작성했다고 가정해보자.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;span style=&quot;color: #666666;&quot;&gt;postCreateEvent&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;- writerId: 셀럽 유저 id (100만명의 팔로워를 가진 사용자)&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;- postId: 새로 생성된 게시글 id&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;100만명의 팔로워를 가진 셀럽이 새 게시글을 작성한 사건을 post 도메인의 이벤트 postCreateEvent라 하자. 모든 팔로워가 이에 대한 알림을 받게 하기 위해서는 수신자 정보가 필요하므로 postCreateEvent의 정보만으로는 부족하다.&lt;/p&gt;
&lt;blockquote style=&quot;background-color: #fcfcfc; color: #666666; text-align: left;&quot; data-ke-style=&quot;style3&quot;&gt;postNoficationEvent&lt;br /&gt;- actor: 셀럽 A (100만명의 팔로워를 가진 사용자)&lt;br /&gt;- target: B (A의 팔로워)&lt;br /&gt;- content: postId (새로 생성된 게시글)&lt;/blockquote&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;따라서 알림 데이터 생성에 필요한 정보를 담은 별도의 알림 이벤트 postNotificationEvent를 정의함으로써 post와 알림 두 도메인의 경계를 보다 명확히 할 수 있다. 그러나&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;이 경우 서비스는 단순히 하나의 도메인 이벤트 생성으로 끝나지 않고 &lt;b&gt;팔로워 하나하나에 대응하는 100만 개의 알림 데이터를 생성, 처리하는 작업으로 확장&lt;/b&gt;된다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;MapReduce 패턴&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;위에서 봤듯이&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt; 무작정 도메인 이벤트와 알림 이벤트를 분리한다고 효율적인 알림 시스템이 완성되지는 않는다. 핵심은 이 이벤트를&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;&amp;ldquo;어떻게&amp;rdquo;&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;확장할&lt;/span&gt; 것인가이다.&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;MapReduce 패턴&lt;/b&gt;은 대용량 데이터를 효율적으로 처리하기 위해 &lt;b&gt;작업을 분할(Map)하고 이를 분산 시스템을 통해 재배치(Shuffle)한 뒤, 병렬로 처리(Reduce)&lt;/b&gt;하는 분산 처리 모델이다. 이 패턴은 하나의 무거운 작업을 여러 개의 독립적인 작업 단위로 나누고 이를 여러 노드에서 동시에 처리함으로써 전체 처리 성능을 향상시키는 것을 목표로 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 &lt;b&gt;Map &amp;rarr; Shuffle &amp;rarr; Reduce&lt;/b&gt; 과정은 &lt;b&gt;분산 메시징 시스템을 활용한 fan-out 아키텍처&lt;/b&gt;에서 자연스럽게 제공된다. 아래는 Kafka 기반 실시간 이벤트 처리 시스템이 유저마다 서로 다른 알림 이벤트를 생성하는 무거운&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;반복 작업을 처리하는 과정&lt;span&gt;이다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;span style=&quot;color: #666666;&quot;&gt;[Post Service]&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;- event: PostCreateEvent&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;- producer: post-created-topic&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;&amp;nbsp;&amp;nbsp; &amp;darr;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;[Fanout Service]&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;- consumer: PostCreateEventConsumer (fanout-group)&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;- event: FanoutTaskEvent&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;- producer: FanoutTaskProducer (fanout-task-topic)&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;&amp;nbsp;&amp;nbsp; &amp;darr;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;[Notification Service]&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;- consumer: FanoutTaskConsumer (fanout-worker-group)&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;- event: PostNotificationEvent&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;- producer: post-notification-topic&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 도메인 이벤트 PostCreateEvent를 수신한 Fanout Service는 전체 알림 대상자를 직접 처리하지 않고 offset/limit 기반의 FanoutTaskEvent로 분할한다. 이 단계는 하나의 이벤트를 여러 개의 작업 단위로 나누는 Map 단계에 해당한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 생성된 FanoutTaskEvent는 Kafka 토픽으로 전송되며 Kafka는 메시지의 key를 기반으로 파티션에 분산 저장하고 이를 여러 컨슈머에게 분배한다. 이 과정은 데이터를 분산 환경으로 재배치하는 Shuffle 단계와 대응된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로 각 컨슈머는 할당받은 FanoutTaskEvent를 처리하면서 해당 범위의 팔로워를 조회하고 사용자별 NotificationEvent를 생성한다. 이는 각 작업 단위를 실제로 처리하며 결과로 변환하는 Reduce 단계에 해당한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과적으로 이 구조는 단일 이벤트를 직접 확장하지 않고 중간에 작업 단위로 분해한 뒤에 분산 처리함으로써 대규모 fan-out을 효율적으로 수행 가능하다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start; background-color: #f6e199;&quot;&gt;FanoutTask 이벤트&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start; background-color: #f6e199;&quot;&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;MapReduce 패턴이 시스템에서 효율적으로 작동하기 위해선 하나의 도메인 이벤트를 적절한 크기의 작업 단위로 분할하는 과정이 핵심이다. &lt;span style=&quot;color: #0e0e0e; text-align: start;&quot;&gt;특히 대규모 fan-out 작업의 경우, 단일 이벤트를 직접 처리하는 대신 중간 단계에서 작업을 분해해야 전체 부하를 효과적으로 분산시킬 수 있다. &lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;이를 위해 fan-out 작업용 중간 이벤트인 FanoutTaskEvent가 필요하다. &lt;span style=&quot;color: #0e0e0e; text-align: start;&quot;&gt;FanoutTaskEvent는 Consumer에게&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;offset/limit (또는 cursor 기반)으로 처리 범위를 지시함으로써 하나의 도메인 이벤트를 여러 개의 독립적인 처리 단위로 분할하는 역할을 한다. 이렇게 중복없이 분할된 각 작업은 서로 간섭 없이 병렬로 처리될 수 있는 구조를 갖게 된다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성된 FanoutTaskEvent는 Kafka 토픽을 통해 전파되며 Kafka는 메시지의 key를 기준으로 파티션에 분산 저장하고 이를 여러 컨슈머에 할당한다. 이 구조는 Map 단계에서 분할된 작업을 Shuffle 단계를 통해 분산 시스템에 재배치하고 이후 각 컨슈머가 독립적으로 작업을 수행하는 Reduce 단계로 자연스럽게 이어진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FanoutTaskEvent가 적용된 시스템이 대규모 fan-out을 어떻게 안정적으로 분산 처리하는지 실제 구현을 통해 알아보자.&amp;nbsp;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h4 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;FanoutTaskEvent&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1774881727017&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class FanoutTaskEvent {

    // 이벤트 식별용 ID
    private String eventId;        // &quot;postId-offset&quot; 형식 ex) &quot;123-0&quot;

    // 원본 도메인 이벤트 정보
    private Long postId;
    private Long creatorId;

    // fanout 범위
    private int offset;
    private int limit;

    private LocalDateTime createdAt;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;PostCreateEventConsumer&lt;/span&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1774884308427&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
@RequiredArgsConstructor
public class PostCreateEventConsumer {

    private final FollowerRepository followerRepository;
    private final FanoutTaskProducer fanoutTaskProducer;

    @KafkaListener(
        topics = &quot;post-created-topic&quot;,
        groupId = &quot;fanout-group&quot;
    )
    public void createFanoutTask(PostCreateEvent event) {

        Long creatorId = event.getCreatorId();

        // 전체 팔로워 수 조회 (유저 목록 조회 X)
        int totalFollowers =
            followerRepository.countByCreatorId(creatorId);

        // FanoutTaskEvent 생성 + Kafka 전송
        fanoutTaskProducer.produce(event, totalFollowers);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;FanoutTaskProducer&lt;/h4&gt;
&lt;pre id=&quot;code_1774879829019&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
@RequiredArgsConstructor
public class FanoutTaskProducer {

    private final KafkaTemplate&amp;lt;String, FanoutTask&amp;gt; kafkaTemplate;

    private static final int BATCH_SIZE = 1000;  //한 FanoutTaskEvent 당 1000명의 유저 담당

    public void produce(PostCreateEvent event, int totalFollowers) {

        Long creatorId = event.getCreatorId();

        for (int offset = 0; offset &amp;lt; totalFollowers; offset += BATCH_SIZE) {

            // FanoutTask 생성
            FanoutTaskEvent event = new FanoutTaskEvent(
                event.getPostId(),
                creatorId,
                offset,
                BATCH_SIZE
            );

            // shard 계산
            int shard = offset / BATCH_SIZE;

            // partition key 생성
            String key = creatorId + &quot;-&quot; + shard;

            // Kafka로 전송
            kafkaTemplate.send(
                &quot;fanout-task-topic&quot;,
                key,
                event
            );
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;위의 세 파일은 fan-out 도메인의 서비스로 post 도메인의 이벤트가 알림 도메인을 트리거하는 흐름에서 부하를 분산시키는 중간 과정을 담당한다. &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;도메인 이벤트인 post-create-topic을 구독한 PostCreateEventConsumer는 PostCreateEvent를 감지하면 fanoutTaskProducer를 호출하여 FanoutTaskEvent를 생성한다. 이때 FanoutTaskEvent는 &lt;/span&gt;팔로워 목록 전체를 처리하지 않고 offset, limit를 기반으로 &lt;b&gt;일정 범위의 팔로워를 처리하는 작업 단위 이벤트&lt;/b&gt;로 분할된다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;limit가 BATCH_SIZE=1000으로 정의된 &lt;b&gt;FanoutTaskEvent는 &lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;b&gt;팔로워 1000명을 하나의 작업 단위로 묶음&lt;/b&gt;으로써 100만명에 대한 알림 이벤트로 확장되기 전에&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;먼저 1000개의 작업 이벤트로 나눠지게 한다&lt;/b&gt;.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;FanoutTaskEvent는 &amp;ldquo;creatorId-shard&amp;rdquo; 형식의 파티션 키를 사용하여 Kafka의 분산 처리 기능을 활용한다. 이는 fan-out 대상이 creator를 기준으로 조회되는 follower 집합이기 때문에 데이터 접근 패턴과 분산 전략을 일치시키기 위함이다.&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;span style=&quot;color: #0e0e0e; text-align: start;&quot;&gt;하나의 도메인 이벤트가 대량의 알림 이벤트로 바로 확장되기 전에 여러 개의 작업 단위로 나뉘면서 부하가 분산된다.&lt;/span&gt;&lt;/span&gt;&lt;/blockquote&gt;
&lt;h4 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;FanoutTaskConsumer&lt;/h4&gt;
&lt;pre id=&quot;code_1774875857086&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
@RequiredArgsConstructor
public class FanoutTaskConsumer {

    private final FollowerRepository followerRepository;
    private final KafkaTemplate&amp;lt;String, PostCreateNotificationEvent&amp;gt; kafkaTemplate;

    @KafkaListener(
        topics = &quot;fanout-task-topic&quot;,
        groupId = &quot;fanout-worker-group&quot;,
        concurrency = &quot;3&quot; // 스레드 3개로 병렬 처리
    )
    public void createPostNoti(FanoutTaskEvent task) {

        // 해당 범위 팔로워 조회
        List&amp;lt;Long&amp;gt; followers = followerRepository.findFollowers(
            task.getCreatorId(),
            task.getOffset(),
            task.getLimit()
        );

        // 범위 내 모든 팔로워에 대한 NotificationEvent 생성
        for (Long userId : followers) {

            PostCreateNotificationEvent event =
                PostCreateNotificationEvent.from(userId, task);

            kafkaTemplate.send(
                &quot;notification-topic&quot;,
                userId.toString(), // partition key = userId
                event
            );
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;fanout-task-topic을 구독한 FanoutTaskConsumer는 Notification 도메인의 로직으로 fan-out 작업 이벤트를 감지하면 알림 이벤트를 생성한다. 각 브로커에 분산된 1000개의 FanoutTaskEvent를 세 개의 스레드가 병렬 처리하며 한 task당 1000개의 PostCreateNotificationEvent를 생성함으로써 1000개의 작업 이벤트를 100만개의 알림 이벤트로 확장시킨다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 확장된 알림 이벤트는 결국 모든 팔로워에 대한 알림 데이터에 대응한다. PostCreateNotificationEvent의 Consumer는 해당 이벤트로부터 알림 데이터를 생성하고 DB에 저장하는 작업 100만 개를 처리한다. 물론 이 작업은&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;Kafka에 의해 안정적으로 분배되고 비동기 처리 가능하다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2066&quot; data-origin-height=&quot;1237&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bIKi05/dJMcahKEZOh/c7ihNwT3cllktkMvYUBnM0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bIKi05/dJMcahKEZOh/c7ihNwT3cllktkMvYUBnM0/img.png&quot; data-alt=&quot;Post, Fanout, Notification 서비스가 Kafka를 통해 1개의 postCreateEvent를 100만개의 postNotificationEvent로 fanout하는 과정&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bIKi05/dJMcahKEZOh/c7ihNwT3cllktkMvYUBnM0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbIKi05%2FdJMcahKEZOh%2Fc7ihNwT3cllktkMvYUBnM0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2066&quot; height=&quot;1237&quot; data-origin-width=&quot;2066&quot; data-origin-height=&quot;1237&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Post, Fanout, Notification 서비스가 Kafka를 통해 1개의 postCreateEvent를 100만개의 postNotificationEvent로 fanout하는 과정&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: center;&quot;&gt;위는 postCreateEvent 1개가 1000개의 FanoutTaskEvent로 분할 후, 100만개의 PostNotificationEvent로 fan-out하는 과정이다. PostNotificationEvent의 Consumer가 알림 이벤트로부터 알림 데이터를 생성하여 DB에 저장하는 과정은 생략했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;FanoutTask 크기 설정&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;fan-out 결과로 생성되는 최종 이벤트 개수를 N, fan-out의 stage 수를 t라고 할 때, 각 단계에서의 fan-out 크기를 균등하게 분할하고 각 단계의 작업 크기를 &lt;span style=&quot;color: #0e0e0e; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #0e0e0e; text-align: start;&quot;&gt;N^{1/t}&lt;/span&gt;&lt;span style=&quot;color: #0e0e0e; text-align: start;&quot;&gt;로 설정하는 것이 연산 비용 측면에서 가장 효율적이다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;전체 fanout 과정을 하나의 트리 구조로 보았을 때 각 단계의 branching factor를 동일하게 유지하여 모든 단계의 부하를 균등하게 분산시키는 방식&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 예시에서는 최종적으로 100만개의 알림 이벤트를 위해 2번의 fanout 단계를 거치므로 각 FanoutTask 단위를 루트 100만 = 1000으로 설정하였다. 만약 1억개의 알림 이벤트를 위해 세 번의 fanout 단계를 넣는다면 fanout 크기는 100명 단위로 충분히 확장성있는 구현이 가능하다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;1 &amp;rarr;&amp;nbsp;100 &amp;rarr; 10,000 &amp;rarr; 100,000,000&amp;nbsp;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 이는 이론적인 수치로 fan-out 과정을 거치며 발생하는 &lt;b&gt;오버헤드와 DB 및 네트워크 IO 비용, 그리고 무엇보다 공간 비용&lt;/b&gt; 때문에 현실에서는 stage를 늘리면서 fan-out을 확장하는 일은 거의 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 무작정 도메인 이벤트, fanout 이벤트, 알림 이벤트를 분리한다고 효율적인 알림 시스템이 완성되지는 않는다. 핵심은 이 이벤트를 &lt;b&gt;&amp;ldquo;언제&amp;rdquo;&lt;/b&gt; 전달할 것인가이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;이 지점에서 알림 시스템은 중요한 설계 결정을 내려야 한다. &lt;b&gt;이벤트를 발생시키는 시점&lt;/b&gt;에 미리 모든 사용자에게 알림을 생성할 것인지, &lt;b&gt;사용자가 조회하는 시점&lt;/b&gt;에 필요한 알림을 계산할 것인지이다. &lt;/span&gt;이 선택이 바로 fan-out 전략이며 알림 시스템의 확장성을 결정짓는 핵심 요소가 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;통지, 알림, 공지 등을 모두 포괄하는 알림 도메인은 기본적으로 이벤트가 발생하는 시점에 수신 대상자가 이를 조회하도록 하는 것이 주 목적이므로 &lt;b&gt;fanout-on-write 방식이 직관적&lt;/b&gt;이다. 그러나 이 구조에 수반하는 비효율과 효율적인 알림 시스템은 이를 어떻게 피해가는지 fan-out 전략에 대해 알아보자.&lt;br /&gt;&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Fan-out 시점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;기본적으로 &lt;b&gt;fan-out은 데이터&lt;/b&gt;&lt;/span&gt;&lt;b&gt;를 여러 사용자에게 확산시키는 과정&lt;/b&gt;으로&lt;span&gt;&amp;nbsp;여러&amp;nbsp;&lt;/span&gt;사용자에게 보여줄 데이터를 생성하고 전달하는 방식이다. 유튜브를 예로 들면, 만약 구독자가 100만 명인 채널에서 영상을 업로드했을 때 각 구독자에 대해 새 영상 알림을 발송해야한다. 또는 각기 다른 100개의 채널을 구독한 여러 명의 유저가 피드를 새로고침했을 때 각자가 팔로우한 채널에서 새로 올린 영상들이 피드에서 조회돼야한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 기능 구현에 있어서 문제는 100만개의 알림 또는 100개의 영상 조합이라는 대용량 데이터를 언제 만들어서 전달할 것인가이다. 이 데이터를 write 시점에 생성할지, read 시점에 생성할지에 따라 fan-out 방식을 구분한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개인적으로 fanout-on-read와 fanout-on-write 개념은 &lt;b&gt;ORM에서의 lazy loading과 eager loading 개념&lt;/b&gt;이 시스템 아키텍처 수준으로 확장된 사례로 볼 수 있다고 생각한다. 즉 필요한 데이터를 &lt;b&gt;요청 시점에 계산할 것인지(lazy)&lt;/b&gt;, &lt;b&gt;미리 계산해 둘 것인지(eager)&lt;/b&gt;의 차이와 본질적으로 동일한 문제를 다룬다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Fanout-on-write (Push 모델)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;데이터가&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;작성되는 시점(Write)&lt;/b&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;에 팔로워의 목록을 확인하여 각 팔로워에게 즉시 전달하는 방식&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;b&gt;= 유튜버가 채널에&lt;/b&gt; &lt;b&gt;영상을 업로드하는 순간 &lt;/b&gt;&lt;/span&gt;&lt;b&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;모든 구독자에 대해 알림 이벤트&lt;/span&gt;&lt;/b&gt;를 복제 후 전달&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;video uploaded 이벤트&lt;br /&gt;&amp;nbsp; &amp;nbsp; &amp;darr;&lt;br /&gt;Kafka&lt;br /&gt;&amp;nbsp; &amp;nbsp; &amp;darr;&lt;br /&gt;Consumer가 fan-out 수행(알림 이벤트 produce)&lt;br /&gt;&amp;nbsp; &amp;nbsp; &amp;darr;&lt;br /&gt;Kafka&lt;br /&gt;&amp;nbsp; &amp;nbsp; &amp;darr;&lt;br /&gt;Consumer가 알림 로직 수행(DB write)&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; &amp;nbsp;.&lt;br /&gt;&amp;nbsp; &amp;nbsp; &amp;nbsp;.&lt;br /&gt;&amp;nbsp; &amp;nbsp; &amp;nbsp;.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;user 알림 저장&lt;br /&gt;&amp;nbsp; &amp;nbsp; &amp;darr;&lt;br /&gt;바로 조회&lt;/blockquote&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;장점&lt;/span&gt;&lt;span data-complete=&quot;true&quot; data-sfc-cb=&quot;&quot; data-sfc-root=&quot;c&quot; data-sfc-cp=&quot;&quot;&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;read query 단순해서 &lt;b&gt;read 비용 매우 작음&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;span data-complete=&quot;true&quot; data-sfc-cb=&quot;&quot; data-sfc-root=&quot;c&quot; data-sfc-cp=&quot;&quot;&gt;사용자의 Read 시점에 이미 데이터가 준비되어 있어&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;조회 속도가 매우 빠름&lt;/b&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span data-sfc-cp=&quot;&quot; data-sfc-root=&quot;c&quot; data-sfc-cb=&quot;&quot; data-complete=&quot;true&quot;&gt;Kafka와 결합하여 비동기 처리 가능&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;단점&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;write 비용 매우 큼&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;follower 많으면 DB write 과부하 문제 발생&lt;/li&gt;
&lt;li&gt;스토리지 소모 큼&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;Fanout-on-read (Pull 모델)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;사용자가 요청하는 시점(Read)&lt;/b&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;에 필요한 데이터를 동적으로 수집, 조합하여 보여주는 방식&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;=&lt;/span&gt;&amp;nbsp;&lt;/b&gt;영상&amp;nbsp;업로드시 원본 데이터 하나만 저장,&lt;b&gt; 팔로워가 피드를 조회하는 순간 &lt;span style=&quot;background-color: #f6e199;&quot;&gt;팔로잉한 모든 채널에 대해 새 영상&lt;/span&gt;&lt;/b&gt;을 실시간으로 가져와서 로드&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;video uploaded&lt;br /&gt;&amp;nbsp; &amp;nbsp; &amp;nbsp;&amp;darr;&lt;br /&gt;DB에 저장&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #fcfcfc; color: #666666; text-align: left;&quot;&gt;&lt;span&gt;&amp;nbsp; &amp;nbsp; &amp;nbsp; &lt;/span&gt;.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;background-color: #fcfcfc; color: #666666; text-align: left;&quot;&gt;&amp;nbsp; &amp;nbsp; &amp;nbsp; .&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;background-color: #fcfcfc; color: #666666; text-align: left;&quot;&gt;&amp;nbsp; &amp;nbsp; &amp;nbsp; .&lt;/span&gt;&lt;span style=&quot;background-color: #fcfcfc; color: #666666; text-align: left;&quot;&gt;&lt;/span&gt;&lt;span style=&quot;background-color: #fcfcfc; color: #666666; text-align: left;&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;user가 피드 조회 요청&lt;br /&gt;&amp;nbsp;&amp;nbsp; &amp;nbsp; &amp;darr;&lt;br /&gt;fan-out (following list 돌며 각 채널에 대해 새 video 집계)&lt;br /&gt;&amp;nbsp;&amp;nbsp; &amp;nbsp; &amp;darr;&lt;br /&gt;feed 데이터 구성&lt;br /&gt;&amp;nbsp; &amp;nbsp; &amp;nbsp;&amp;darr;&lt;br /&gt;조회 가능&lt;/blockquote&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;장점&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;write 비용 매우 작음&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;데이터 중복 저장이 없어 &lt;b&gt;스토리지 절약&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;구조 및 구현 단순&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;단점&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;join으로 인한 &lt;b&gt;read 비용이 큼&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;대용량 데이터를 실시간으로 집계하므로&amp;nbsp;&lt;/span&gt;&lt;b&gt;조회 성능이 느려질 수 있음&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;query 복잡&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;fanout-on-read 방식은 사실 유저의 request로 트리거되는 대부분의 도메인에서 활용되는 일반화된 방식이다.&lt;span&gt; Service 로직에서 Repository를 호출하여 복잡한 쿼리로 데이터를 뽑아오는 익숙한 흐름에 녹아있다.&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Fanout-on-write vs Fanout-on-read&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;fanout-on-write, fanout-on-read는 결국 유저가 필요로 하는 데이터를 언제 생성할지에 대한 전략의 차이로 볼 수 있다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 92.3256%; height: 131px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 16.3953%; height: 17px;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;width: 35.5649%; height: 17px;&quot;&gt;&lt;b&gt;fanout-on-read&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 40.6113%; height: 17px;&quot;&gt;&lt;b&gt;fanout-on-write&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 16.3953%; height: 19px;&quot;&gt;&lt;span&gt;병목 위치&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 35.5649%; height: 19px;&quot;&gt;&lt;span&gt;Read path&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 40.6113%; height: 19px;&quot;&gt;&lt;span&gt;Write path&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 16.3953%; height: 19px;&quot;&gt;&lt;span&gt;주요 작업&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 35.5649%; height: 19px;&quot;&gt;&lt;span&gt;scan, join, sort&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 40.6113%; height: 19px;&quot;&gt;&lt;span&gt;insert, index, replication&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 16.3953%; height: 19px;&quot;&gt;&lt;span&gt;주요 자원&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 35.5649%; height: 19px;&quot;&gt;&lt;span&gt;&lt;span&gt;CPU + &lt;/span&gt;&lt;/span&gt;&lt;span&gt;DB read IO + index scan + sort&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 40.6113%; height: 19px;&quot;&gt;&lt;span&gt;storage + disk IO + index write + network&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 16.3953%; height: 19px;&quot;&gt;&lt;span&gt;특징&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 35.5649%; height: 19px;&quot;&gt;&lt;span&gt;조회 시 동적 계산 &lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;br /&gt;&lt;span&gt;- 실시간 조인으로 가져오므로 &lt;b&gt;조회 시 느림&lt;/b&gt;&lt;br /&gt;&lt;/span&gt;- 중복 저장 없으므로 &lt;b&gt;쓰기 시 빠름&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 40.6113%; height: 19px;&quot;&gt;&lt;span&gt;이벤트 생성 시 선계산&lt;/span&gt;&lt;br /&gt;&lt;span&gt;- 중복 저장 비용으로 &lt;b&gt;쓰기 시 무거움&lt;/b&gt;&lt;br /&gt;&lt;/span&gt;- 조회 이전에 이미 데이터 준비되므로 &lt;b&gt;조회 시 빠름&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 16.3953%; height: 19px;&quot;&gt;&lt;span&gt;장점&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 35.5649%; height: 19px;&quot;&gt;&lt;span&gt;write 가벼움, 스토리지 절약&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 40.6113%; height: 19px;&quot;&gt;&lt;span&gt;read 빠름, 쿼리 복잡도 낮음&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 16.3953%; height: 19px;&quot;&gt;&lt;span&gt;유리한 사례&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 35.5649%; height: 19px;&quot;&gt;&lt;span&gt;&lt;span style=&quot;background-color: #f9f9f9; color: #333333; text-align: start;&quot;&gt;read 유연성 중요 + 중복 규모가 큼&lt;/span&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 40.6113%; height: 19px;&quot;&gt;&lt;span&gt;&lt;span style=&quot;background-color: #f9f9f9; color: #333333; text-align: start;&quot;&gt;read latency 중요 +&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;중복 규모가 적음&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 방식은 &lt;b&gt;시스템 병목이 read path와 write path 중 어디에 발생할지를 결정하는 트레이드오프&lt;/b&gt;로 서비스의 규모와 사용자 특성에 따라 적절히 선택되어야 한다.&lt;br /&gt;&lt;br /&gt;실제로 트위터의 피드 시스템은 초기에는 사용자 수와 팔로잉 규모가 작아 fanout-on-read 방식으로도 충분했지만 서비스가 성장하면서 조회 시 조인 비용이 증가하자 일부를 fanout-on-write 방식으로 전환하였다. 그러나 팔로워 수가 매우 많은 셀럽의 경우 DB write 폭발 문제를 유발하므로 최종적으로는 사용자 특성에 따라 fanout-on-read와 fanout-on-write를 혼합한 hybrid 구조로 발전하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Celebrity Problem&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본적인 알림 기능 구현에 있어서는 직관적인 fanout-on-write 방식이 적합하다. 하지만 위에서 봤듯이 write 시점에 심각한 병목을 만들 수 있다. &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;중복 규모, 즉 fan-out 규모가 클수록 저장 부담과 자원 소모가 심해져 시스템 capacity상 감당하지 못하는 지점이 생길 수 밖에 없다.&lt;span&gt; &lt;/span&gt;&lt;/span&gt;앞서 100만 팔로워를 가진 셀럽이 새 게시글을 작성한 예시를 생각해보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;fanout-on-write 전략을 사용할 경우, 셀럽이 게시글을 업로드하는 하나의 행동이 100만 건의 알림 이벤트 생성 작업으로 이어진다. 이러한 상황에서는 단순한 업로드 작업이 시스템 전체에 큰 부하를 유발하여 처리 지연, 저장공간 부담 등 다양한 문제를 연쇄적으로 일으킬 수 있다. 따라서 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;알림의 대상 범위가 넓어질수록&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;fan-out 전략은 자연스럽게 write에서 read 중심으로 이동하게 된다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;팔로워 수가 많은 사용자의 활동이 대규모 fan-out을 유발하며 시스템에 부담을 주는 상황을 Celebrity Problem이라고 한다.&amp;nbsp;&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;이러한 문제를 해결하기 위해 모든 사용자에게 동일한 방식으로 fan-out을 적용하기보다는 유저의 규모에 따라 다른 전략을 사용하는 방식이 등장하게 된다.&amp;nbsp;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해결 방안&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본적으로 일반 유저의 행동에 의한 알림은 fanout-on-write으로 구현된다. 많은 팔로워를 가진 유저의 행동은 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;서비스의 가용 자원을 넘어서는 fan-out을 유발하므로&lt;/span&gt; fanout-on-read에 기반하여 구현될 수 밖에 없다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Fanout-on-read + Lazy fanout&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;팔로워가 많은 유저의 행동은 알림 이벤트 생성까지 진행하지 않고 그 행동 이벤트 자체만 저장해둔다. 이 상태에서 팔로워가 피드 조회 같은 방식으로 팔로우한 셀럽의 행동 이벤트 조회를 요청하는 시점에 팔로우 목록을 돌며 저장된 행동 이벤트를 기반으로 알림 이벤트를 생성한다. 이는 유저가 조회하는 시점에 필요한 데이터를 계산하는 fanout-on-read 방식이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 유저가 최초 조회하는 시점에 알림 이벤트를 생성하고 알림을 저장하는 lazy fanout 방식이 사용된다. 이 구조는 유저가 처음 조회할 때까지 알림 도메인 로직을 실행하지 않는다. 따라서 일부 활성 유저에 대해서만 fan-out이 수행되고 스토리지를 효율적으로 사용할 수 있게 된다. 나아가&amp;nbsp;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;이후 조회부터는 행동 이벤트 수집없이 이미 생성된 알림 데이터만을 조회함으로써 read 병목을 최소한으로 하여 균형을 맞춘다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인스타그램에서 친구의 라이브 방송 알림은 라이브 시작과 거의 동시에 오는데 팔로우한 셀럽의 라이브 방송은 시작하고도 한참 알림이 뜨지 않은 경험이 자주 있다. 대부분 알림보다는 피드에 떠서 들어간 경우가 많은 것도 이 때문일 것이라 추측한다. 추가로 알림을 못받은 채 피드로 라이브 방송을 들어가고 나면, 그제서야 방송 시작 알림이 오던 경험도 이 Fanout-on-read + lazy fanout 방식으로 설명이 된다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;전체 공지&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공지와 같은 전체 알림의 경우에도&amp;nbsp;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;Celebrity Problem 상황과 본질적으로 동일하다. 유저 전체를 수신 대상자로 하는&lt;/span&gt; 알림을 미리 생성하는 fanout-on-write 방식은 비효율적이므로 하나의 공지 이벤트를 저장해두고 유저가 접속 또는 조회하는 시점에 보여주는 read 기반 전략이 주로 사용된다. 다만 실제 시스템에서는 읽음 상태 관리나 푸시 알림 처리와 같이 일부 write 작업이 함께 수행되는 hybrid 형태로 구현되는 경우가 많다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;결국&lt;span&gt; fan-out 시점에 따른&lt;/span&gt;&lt;/span&gt; 두 방식의 차이는 데이터를 언제 생성할 것인가의 문제로 귀결된다. 이 막대한 비용을 write 시점에 지불할 것인지 read 시점에 지불할 것인지에 대한 선택이다. 물론 이 트레이드오프는 비용 뿐만 아니라 서비스 성격, 트리거 도메인, 이벤트 특징을 고려하여 결정하게 된다.&lt;/p&gt;</description>
      <category>scalability</category>
      <category>Apache Kafka</category>
      <category>Fan-out</category>
      <category>fanout-on-read</category>
      <category>fanout-on-write</category>
      <category>FanoutTask</category>
      <category>lazy fanout</category>
      <category>공지</category>
      <category>도메인 이벤트</category>
      <category>알림</category>
      <author>cusum26</author>
      <guid isPermaLink="true">https://imjyh01.tistory.com/17</guid>
      <comments>https://imjyh01.tistory.com/17#entry17comment</comments>
      <pubDate>Tue, 31 Mar 2026 01:47:21 +0900</pubDate>
    </item>
    <item>
      <title>알림 아키텍처 (1)</title>
      <link>https://imjyh01.tistory.com/16</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;대규모 서비스 아키텍처의 주된 관심사는 확장성과 병목 관리라 해도 과언이 아니다. 이와 관련된 트레이드오프나 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;문제 상황을 경험해보고 핸들할 수 있는 판단력을&lt;/span&gt; 갖추는 것이 요즘 시대에 가장 필요한 개발 역량이 아닌가 싶다. 규모가 크지 않은 서비스라도 이와 비슷한 문제 상황을 자연스럽게 고민하게 되는 도메인이 있는데 개인적으로 알림 도메인이라고 생각한다. 어느정도 반복되는 구조를 답습하는 대부분의 도메인과 다르게 각자 서비스 성격에 따라 효율적인 시스템 디자인을 간접적으로 고민해 볼 수 있는 가장 현실적이고 자유도 높은 도메인이 아닐까한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본적으로 알림은 서비스가 유저에게 먼저 request를 보내는 거의 유일한 상호작용이다. 개발자이기 이전에 사용자로서 그동안 수많은 애플리케이션의 알림을 경험하며 귀찮게 하는 기능이라는 인식이 굳어졌다. &quot;유저 혜택, 알림 동의&quot;의 항목도 웬만하면 동의 안하고 넘어간다. 그럼에도 서비스 입장에서는 알림 기능이 비즈니스 목적 실현을 유도하는 나름 핵심 도메인이라는 사실은 부인할 수 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;유저가 떠나지 않게 하기 위한 서비스의 울부짖음&quot; 정도의 인상으로 유저에게 도달하기까지, 소셜 미디어의 확산과 함께 급격히 고도화된 현재의 알림 시스템은 어떻게 디자인되고 어떤 매커니즘으로 구현되는지 호기심이 들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히나 대형 서비스에서의 알림 도메인은 사실상 확장성 문제를 최전선에서 마주한다. &lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;단순히 메시지를 전달하는 기능을 넘어 &lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;대량의 이벤트 발생, 대량의 데이터 저장, 그리고 실시간 사용자 응답이라는 &lt;/span&gt;세 가지 축으로 동작하는 과정에서 수많은 문제와 트레이드오프를 자연스럽게 마주한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 처리, 저장, 유저 인터렉션 각 과정에서 나타나는 고유한 문제 상황과 책임에 따라 알림 도메인을 &lt;b&gt;Processing Layer, Storage Layer, Serving Layer&lt;/b&gt; 세 가지 레이어로 분리하여 각 시스템을 구성하는 핵심 개념에 대해 알아보자.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Processing layer&amp;nbsp;&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Processing Layer는 알림 데이터를 실제로 만들어내는 시작점으로 서비스 내에서 발생시킨 도메인 이벤트를 기반으로 알림을 생성하고 흐르게 하는 역할을 담당한다. 어떤 이벤트가 발생했는지 정의하고 해당 이벤트가 어떤 유저에게 전달되어야 하는지 결정하며, 이를 fanout하거나 aggregation하는 방식으로 분배 전략을 선택한다. 생성된 대량의 이벤트는 Kafka와 같은 메시지 브로커를 활용해 비동기적으로 처리하며 다음 레이어로 전달된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;주요 트레이드오프&lt;/h4&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px; background-color: #f6e199;&quot;&gt;Write-time vs Read-time&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;핵심 개념&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 396px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 24.0698%; height: 19px;&quot;&gt;개념&lt;/td&gt;
&lt;td style=&quot;width: 34.6511%; height: 19px;&quot;&gt;설명&lt;/td&gt;
&lt;td style=&quot;width: 41.1628%; height: 19px;&quot;&gt;예시&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 24.0698%; height: 19px;&quot;&gt;domain event&lt;/td&gt;
&lt;td style=&quot;width: 34.6511%; height: 19px;&quot;&gt;시스템에서 의미있는 사건을 나타내는 객체&lt;/td&gt;
&lt;td style=&quot;width: 41.1628%; height: 19px;&quot;&gt;domain event, fanout event, notification event&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 24.0698%; height: 19px;&quot;&gt;&lt;a style=&quot;color: #333333;&quot;&gt;Apache Kafka&lt;/a&gt;&lt;/td&gt;
&lt;td style=&quot;width: 34.6511%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;이벤트를 비동기로 전달하는 스트림 시스템&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 41.1628%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;like 이벤트 &amp;rarr; Kafka topic &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;rarr; consumer group&lt;/span&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 35px;&quot;&gt;
&lt;td style=&quot;width: 24.0698%; height: 35px;&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;producer / consumer&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 34.6511%; height: 35px;&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;이벤트 생성자 / 처리자&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 41.1628%; height: 35px;&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;service &amp;rarr; producer&lt;br /&gt;fanout worker &amp;rarr; consumer&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 24.0698%; height: 19px;&quot;&gt;&lt;b&gt;&lt;span&gt;fanout on write&lt;/span&gt;&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 34.6511%; height: 19px;&quot;&gt;&lt;span&gt;이벤트 발생 시 미리 유저별 데이터 생성&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 41.1628%; height: 19px;&quot;&gt;&lt;span&gt;글 작성 &amp;rarr; 팔로워 100명에게 알림 100개 생성&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 24.0698%; height: 19px;&quot;&gt;&lt;b&gt;&lt;span&gt;fanout on read&lt;/span&gt;&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 34.6511%; height: 19px;&quot;&gt;&lt;span&gt;조회 시점에 동적으로 데이터 생성&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 41.1628%; height: 19px;&quot;&gt;&lt;span&gt;피드 요청 &amp;rarr; 팔로잉 목록 기반 posts 조회&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 24.0698%; height: 19px;&quot;&gt;&lt;b&gt;&lt;span&gt;&lt;b&gt;&lt;span&gt;celebrity problem&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 34.6511%; height: 19px;&quot;&gt;&lt;span&gt;팔로워 많은 유저 fanout 폭발&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 41.1628%; height: 19px;&quot;&gt;&lt;span&gt;팔로워 1억&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 24.0698%; height: 19px;&quot;&gt;&lt;span&gt;hybrid fanout&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 34.6511%; height: 19px;&quot;&gt;&lt;span&gt;유저 규모에 따라 write/read 혼합&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 41.1628%; height: 19px;&quot;&gt;&lt;span&gt;일반 유저=write, 셀럽=read&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 24.0698%; height: 19px;&quot;&gt;&lt;span&gt;batch fanout&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 34.6511%; height: 19px;&quot;&gt;&lt;span&gt;여러 유저 insert를 묶어서 처리&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 41.1628%; height: 19px;&quot;&gt;&lt;span&gt;1000명씩 insert&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 24.0698%; height: 19px;&quot;&gt;&lt;span&gt;aggregation&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 34.6511%; height: 19px;&quot;&gt;&lt;span&gt;여러 이벤트를 하나의 알림으로 묶음&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 41.1628%; height: 19px;&quot;&gt;&lt;span&gt;&amp;ldquo;A, B, C liked your post&amp;rdquo;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 24.0698%; height: 19px;&quot;&gt;&lt;span&gt;aggregation window&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 34.6511%; height: 19px;&quot;&gt;&lt;span&gt;일정 시간 동안 이벤트를 모아서 처리&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 41.1628%; height: 19px;&quot;&gt;&lt;span&gt;5분 동안 like 모으기&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 24.0698%; height: 19px;&quot;&gt;&lt;span&gt;outbox pattern&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 34.6511%; height: 19px;&quot;&gt;&lt;span&gt;DB + 이벤트 발행을 원자적으로 보장&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 41.1628%; height: 19px;&quot;&gt;&lt;span&gt;comment insert + outbox insert&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 24.0698%; height: 19px;&quot;&gt;&lt;span&gt;CDC / poller&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 34.6511%; height: 19px;&quot;&gt;&lt;span&gt;outbox &amp;rarr; Kafka로 이벤트 전송&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 41.1628%; height: 19px;&quot;&gt;&lt;span&gt;Debezium&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 24.0698%; height: 19px;&quot;&gt;&lt;span&gt;idempotency&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 34.6511%; height: 19px;&quot;&gt;&lt;span&gt;동일 이벤트 중복 처리 방지&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 41.1628%; height: 19px;&quot;&gt;&lt;span&gt;UNIQUE(event_id)&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 24.0698%; height: 19px;&quot;&gt;&lt;span&gt;duplicate 문제&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 34.6511%; height: 19px;&quot;&gt;&lt;span&gt;Kafka 재처리로 중복 발생&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 41.1628%; height: 19px;&quot;&gt;&lt;span&gt;같은 알림 2번 생성&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 24.0698%; height: 19px;&quot;&gt;&lt;span&gt;event ordering&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 34.6511%; height: 19px;&quot;&gt;&lt;span&gt;이벤트 순서 보장 문제&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 41.1628%; height: 19px;&quot;&gt;&lt;span&gt;follow &amp;rarr; unfollow 순서 뒤바뀜&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 24.0698%; height: 19px;&quot;&gt;&lt;span&gt;partition key&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 34.6511%; height: 19px;&quot;&gt;&lt;span&gt;Kafka 순서 보장을 위한 key&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 41.1628%; height: 19px;&quot;&gt;&lt;span&gt;user_id 기준 partition&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 24.0698%; height: 19px;&quot;&gt;&lt;span&gt;candidate generation&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 34.6511%; height: 19px;&quot;&gt;&lt;span&gt;보여줄 후보 데이터 생성&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 41.1628%; height: 19px;&quot;&gt;&lt;span&gt;피드 후보 500개 생성&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Storage layer&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Storage Layer는 Processing Layer로부터 전달받은 알림 데이터 저장을 담당하는 영역으로 확장성 문제를 가장 직접 마주하는 영역이다. 유저 수에 따라 데이터가 폭발적으로 증가하는 문제를 해결하기 위해 어떤 DB 구조로 어떻게 쌓을지를 정의한다. 템플릿과 실제 전달 데이터를 분리하거나, 파라미터 기반 구조를 사용하는 등 다양한 저장 전략이 고려된다. 결국 이 레이어의 핵심은 대용량의 알림 데이터를 효율적으로 저장하면서도 Serving Layer에서 조회 성능을 확보하도록 설계하는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;주요 트레이드오프&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px; background-color: #f6e199;&quot;&gt;Space vs Join cost&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;핵심 개념&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 418px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 31.9768%;&quot;&gt;개념&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 36.3954%;&quot;&gt;설명&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 31.5115%;&quot;&gt;예시&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 31.9768%;&quot;&gt;&lt;b&gt;&lt;span&gt;notification table 단일 모델&lt;/span&gt;&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 36.3954%;&quot;&gt;&lt;span&gt;유저 알림 저장 테이블&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 31.5115%;&quot;&gt;&lt;span&gt;user_id, actor_id, type&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 31.9768%;&quot;&gt;&lt;b&gt;&lt;span&gt;template + delivery 전달 분리 모델&lt;/span&gt;&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 36.3954%;&quot;&gt;&lt;span&gt;템플릿과 유저 전달 데이터를 분리&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 31.5115%;&quot;&gt;&lt;span style=&quot;background-color: #f9f9f9; color: #333333; text-align: start;&quot;&gt;Notice + memeberNotice&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 31.9768%;&quot;&gt;&lt;span&gt;announcement&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 36.3954%;&quot;&gt;&lt;span&gt;공지 저장 테이블&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 31.5115%;&quot;&gt;&lt;span&gt;점검 안내&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 31.9768%;&quot;&gt;&lt;span&gt;announcement_read&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 36.3954%;&quot;&gt;&lt;span&gt;공지 읽음 상태&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 31.5115%;&quot;&gt;&lt;span&gt;user_id, announcement_id&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 31.9768%;&quot;&gt;&lt;span&gt;notification_template&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 36.3954%;&quot;&gt;&lt;span&gt;알림 메시지 템플릿 저장&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 31.5115%;&quot;&gt;&lt;span&gt;&amp;ldquo;{actor} liked your post&amp;rdquo;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 31.9768%;&quot;&gt;&lt;span&gt;notification_delivery&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 36.3954%;&quot;&gt;&lt;span&gt;유저별 알림 데이터&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 31.5115%;&quot;&gt;&lt;span&gt;user_id, template_id&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 31.9768%;&quot;&gt;&lt;span&gt;event table&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 36.3954%;&quot;&gt;&lt;span&gt;&lt;span&gt;이벤트 기록 테이블, &lt;/span&gt;&lt;/span&gt;&lt;span&gt;저장 구조지만 처리 목적&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 31.5115%;&quot;&gt;&lt;span&gt;outbox_event&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 31.9768%;&quot;&gt;&lt;span&gt;param_json / metadata&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 36.3954%;&quot;&gt;&lt;span&gt;알림 파라미터 저장&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 31.5115%;&quot;&gt;&lt;span&gt;template_id + params&lt;br /&gt;ex) {order_id:123}&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 31.9768%;&quot;&gt;&lt;span&gt;personal_msg&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 36.3954%;&quot;&gt;&lt;span&gt;개인화된 메시지 저장&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 31.5115%;&quot;&gt;&lt;span&gt;&amp;ldquo;철수님이 댓글&amp;rdquo;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 31.9768%;&quot;&gt;&lt;span&gt;is_read 컬럼&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 36.3954%;&quot;&gt;&lt;span&gt;읽음 여부 inline 저장&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 31.5115%;&quot;&gt;&lt;span&gt;boolean&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 31.9768%;&quot;&gt;&lt;span&gt;notification_read&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 36.3954%;&quot;&gt;&lt;span&gt;읽음 상태 분리 테이블&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 31.5115%;&quot;&gt;&lt;span&gt;user_id + notification_id&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 31.9768%;&quot;&gt;&lt;span&gt;append-only log&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 36.3954%;&quot;&gt;&lt;span&gt;수정 없이 insert만 하는 구조&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 31.5115%;&quot;&gt;&lt;span&gt;notification log&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 31.9768%;&quot;&gt;&lt;span&gt;sharding&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 36.3954%;&quot;&gt;&lt;span&gt;DB를 여러 개로 분산&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 31.5115%;&quot;&gt;&lt;span&gt;user_id % 100&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 31.9768%;&quot;&gt;&lt;span&gt;hot shard 문제&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 36.3954%;&quot;&gt;&lt;span&gt;특정 shard에 트래픽 집중&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 31.5115%;&quot;&gt;&lt;span&gt;인기 유저&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 31.9768%;&quot;&gt;&lt;span&gt;NoSQL (Cassandra 등)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 36.3954%;&quot;&gt;&lt;span&gt;대용량 write 최적화 DB&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 31.5115%;&quot;&gt;&lt;span&gt;wide-column DB&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 31.9768%;&quot;&gt;&lt;span&gt;TTL&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 36.3954%;&quot;&gt;&lt;span&gt;자동 만료 정책&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 31.5115%;&quot;&gt;&lt;span&gt;30일 후 삭제&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 31.9768%;&quot;&gt;&lt;span&gt;cold storage&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 36.3954%;&quot;&gt;&lt;span&gt;오래된 데이터 외부 저장&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 31.5115%;&quot;&gt;&lt;span&gt;S3&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 31.9768%;&quot;&gt;&lt;span&gt;storage explosion&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 36.3954%;&quot;&gt;&lt;span&gt;데이터 폭증 문제&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 31.5115%;&quot;&gt;&lt;span&gt;하루 10억 알림&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 31.9768%;&quot;&gt;&lt;span&gt;index 비용&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 36.3954%;&quot;&gt;&lt;span&gt;insert 시 index 업데이트 비용&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 31.5115%;&quot;&gt;&lt;span&gt;user_id index&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 31.9768%;&quot;&gt;&lt;span&gt;denormalized view&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 36.3954%;&quot;&gt;&lt;span&gt;조회용 비정규화 테이블&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 31.5115%;&quot;&gt;&lt;span&gt;notification_view&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 31.9768%;&quot;&gt;&lt;span&gt;timeline/feed table&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 36.3954%;&quot;&gt;&lt;span&gt;유저별 피드 저장&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 31.5115%;&quot;&gt;&lt;span&gt;user_feed&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Serving layer&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Serving Layer는 저장된 알림 데이터를 사용자에게 실제로 제공하는 단계로 유저 인터랙션을 담당하는 영역이다. 사용자는 읽지 않은 알림 개수를 즉시 확인하고 빠르게 목록을 조회할 수 있어야 한다. 이를 위해 캐시를 활용한 unread count 관리, 읽음 상태 저장 방식, 페이지네이션 전략 등이 함께 고려되며 궁극적으로는 사용자 경험을 해치지 않는 빠른 응답 속도를 보장하는 것이 목표다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;주요 트레이드오프&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px; background-color: #f6e199;&quot;&gt;Latency vs Accuracy&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;핵심 개념&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 304px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 35%; height: 19px;&quot;&gt;개념&lt;/td&gt;
&lt;td style=&quot;width: 38.3721%; height: 19px;&quot;&gt;설명&lt;/td&gt;
&lt;td style=&quot;width: 26.3953%; height: 19px;&quot;&gt;예시&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 35%; height: 19px;&quot;&gt;&lt;span&gt;notification API&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 38.3721%; height: 19px;&quot;&gt;&lt;span&gt;알림 목록 조회 API&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 26.3953%; height: 19px;&quot;&gt;&lt;span&gt;GET /notifications&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 35%; height: 19px;&quot;&gt;&lt;span&gt;announcement + notification merge 전략&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 38.3721%; height: 19px;&quot;&gt;&lt;span&gt;공지 + 알림 합쳐서 반환하는 전략&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 26.3953%; height: 19px;&quot;&gt;&lt;span&gt;API aggregation&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 35%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;unread count API&lt;/span&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 38.3721%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;안읽은 알림 수 반환&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 26.3953%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;  23&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 35%; height: 19px;&quot;&gt;&lt;b&gt;&lt;span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;a style=&quot;color: #333333;&quot;&gt;Redis&lt;/a&gt; &lt;/span&gt;unread count&lt;/span&gt;&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 38.3721%; height: 19px;&quot;&gt;&lt;span&gt;빠른 unread count 캐시로 구현&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 26.3953%; height: 19px;&quot;&gt;&lt;span&gt;unread:123 &amp;rarr; 5&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 35%; height: 19px;&quot;&gt;&lt;span&gt;badge 응답&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 38.3721%; height: 19px;&quot;&gt;&lt;span&gt;UI 빨간 점 숫자&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 26.3953%; height: 19px;&quot;&gt;&lt;span&gt;앱 상단 알림&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 35%; height: 19px;&quot;&gt;&lt;span&gt;pagination&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 38.3721%; height: 19px;&quot;&gt;&lt;span&gt;페이지 단위 조회&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 26.3953%; height: 19px;&quot;&gt;&lt;span&gt;limit 20&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 35%; height: 19px;&quot;&gt;&lt;span&gt;fanout on read 조회 최적화&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 38.3721%; height: 19px;&quot;&gt;&lt;span&gt;DB 조회 기반 피드 생성&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 26.3953%; height: 19px;&quot;&gt;&lt;span&gt;SELECT posts&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 35%; height: 19px;&quot;&gt;&lt;span&gt;cache&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 38.3721%; height: 19px;&quot;&gt;&lt;span&gt;빠른 응답을 위한 캐시&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 26.3953%; height: 19px;&quot;&gt;&lt;span&gt;feed cache&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 35%; height: 19px;&quot;&gt;&lt;span&gt;precomputed timeline&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 38.3721%; height: 19px;&quot;&gt;&lt;span&gt;미리 계산된 피드&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 26.3953%; height: 19px;&quot;&gt;&lt;span&gt;fanout on write 결과&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 38px;&quot;&gt;
&lt;td style=&quot;width: 35%; height: 38px;&quot;&gt;&lt;span&gt;ranking / relevance&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 38.3721%; height: 38px;&quot;&gt;&lt;span&gt;&lt;span&gt;보여줄 순서 결정, &lt;/span&gt;&lt;/span&gt;&lt;span&gt;후보 생성은 processing,&lt;br /&gt;정렬은 serving&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 26.3953%; height: 38px;&quot;&gt;&lt;span&gt;like 확률 기반&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 35%; height: 19px;&quot;&gt;&lt;span&gt;ordering/sorting&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 38.3721%; height: 19px;&quot;&gt;&lt;span&gt;정렬 방식&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 26.3953%; height: 19px;&quot;&gt;&lt;span&gt;시간순 vs 추천순&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 35%; height: 19px;&quot;&gt;&lt;span&gt;feed API&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 38.3721%; height: 19px;&quot;&gt;&lt;span&gt;피드 조회&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 26.3953%; height: 19px;&quot;&gt;&lt;span&gt;GET /feed&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 35%; height: 19px;&quot;&gt;&lt;span&gt;server rendering&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 38.3721%; height: 19px;&quot;&gt;&lt;span&gt;서버에서 메시지 생성&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 26.3953%; height: 19px;&quot;&gt;&lt;span&gt;API에서 문자열 생성&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 35%; height: 19px;&quot;&gt;&lt;span&gt;client rendering&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 38.3721%; height: 19px;&quot;&gt;&lt;span&gt;클라이언트에서 메시지 생성&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 26.3953%; height: 19px;&quot;&gt;&lt;span&gt;params 기반 렌더링&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;blockquote data-ke-size=&quot;size16&quot; data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;알림 도메인은 단순한 DB 설계를 너머 &lt;br /&gt;이벤트 기반 처리(Kafka, fanout), 대규모 저장(sharding, NoSQL), 고성능 조회(Redis, ranking)를 총괄하여 설계하는 시스템 아키텍쳐 문제다.&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>scalability</category>
      <author>cusum26</author>
      <guid isPermaLink="true">https://imjyh01.tistory.com/16</guid>
      <comments>https://imjyh01.tistory.com/16#entry16comment</comments>
      <pubDate>Fri, 27 Mar 2026 19:28:19 +0900</pubDate>
    </item>
    <item>
      <title>flutter - 플러터로 크로스 플랫폼 앱 개발하기(3)</title>
      <link>https://imjyh01.tistory.com/15</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;상태 관리 라이브러리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앱에는 고정된 데이터와 변경되는 데이터가 존재한다. 앱의 주된 사용 목적 중 대부분은 변경되는 데이터의 조회로 이 데이터를 어떻게 관리하고 표현하는지가 앱의 성능, 사용성에 큰 영향을 미친다. 상태 관리 라이브러리는 변경되는 데이터를 클래스 내부에서 관리하고 위젯에 담아 화면에 출력하는 클래스간 데이터 흐름을 총괄한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상태 관리 라이브러리로는 대표적으로 Provider, Bloc, Riverpod, GetX가 존재한다. GetX는 이제 거의 안 쓰이고, Provider는 일전에 설명한 관계로 Bloc, Riverpod 두 라이브러리를 중심으로 설명한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Bloc&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Business Logic Component&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #9feec3;&quot;&gt;imperative (명령형)&lt;/span&gt; : 외부 자극을 단일 경로를 따라 내부 상태 변화로 매핑하는 상태머신&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;UI &amp;rarr; Event &amp;rarr; Bloc &amp;rarr; State &amp;rarr; UI&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Bloc에서의 데이터 흐름은 다음과 같다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;외부 입력&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;UI가 Bloc에 Event 전달&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Bloc 객체에서 로직 실행&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;State 변경(emit)&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;BlocBuilder가 변경 감지&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;UI 반영(build)&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;state machine : 외부 이벤트를 감지 시 로직을 실행시켜 내부의 상태를 변화시키는 상태 머신
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;(현재 상태 + 이벤트 로직) &amp;mdash;&amp;mdash;&amp;mdash;(이벤트)&amp;mdash;&amp;mdash;&amp;gt; (새 상태)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Event기반 모델 - 상태 변경을 트리거한 동작을 명시하여 모든 변화를 추적(디버깅, 로깅, 테스트 용이)&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1772598759712&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;add(IncrementEvent);                       //Bloc

ref.read(counter.notifier).increment();    //Riverpod&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;관련 객체
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;BlocProvider: Bloc 객체를 트리에 주입&lt;/li&gt;
&lt;li&gt;Bloc: Widget에서 받은 Event를 처리하는 로직 수행&lt;/li&gt;
&lt;li&gt;BlocBuilder: State 구독 후 변경사항 UI에 반영&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;MVVM 패턴 표방 - Widget(View)과 Bloc(Model)의 분리&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위젯과 Bloc 코드&lt;/p&gt;
&lt;pre id=&quot;code_1772598890063&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//일반 Widget
...
ElevatedButton(
  onPressed: () {
    count++;
    setState(() {}); 
  },
  child: Text(&quot;증가&quot;),
);
//UI단에서 상태를 변경하는 로직 직접 실행 -&amp;gt; V+M 강하게 결합

Widget build(BuildContext context) {
  return Text('$count');
}
// setState에 의해 자동 빌드



//Bloc 적용 Widget
...
ElevatedButton(
  onPressed: () {
    context.read&amp;lt;CounterBloc&amp;gt;().add(IncrementEvent());
  },
  child: Text(&quot;증가&quot;),
);
//UI단에서는 이벤트를 수신할 Bloc에 적당한 이벤트를 던지고 
//Bloc에서 이벤트 감지 후 로직 실행하도록 분리

BlocBuilder&amp;lt;CounterBloc, int&amp;gt;(
  builder: (context, state) {
    return Text('$state');
  },
);
//Bloc 로직 실행으로 인해 변경 감지 시 빌드되도록 상태 구독&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1772599120282&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//Bloc 내부
on&amp;lt;IncrementEvent&amp;gt;((event, emit) async {
  emit(Loading());
  final result = await apiCall();    //비동기 처리되는 콜백 등록
  if (result) {
    emit(Success(state + 1));       //로직 실행후 변경된 상태 emit
  } else {
    emit(Failure());
  }
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;전통적인 setState()와 Bloc의 차이&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;상태의 소유권
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;setState 구조: 상태(State)는 UI(StatefulWidget)와 강하게 결합(존재가 독립적이긴 해도 로직상 의존적)&lt;/li&gt;
&lt;li&gt;Bloc 구조: 상태는 UI 외부의 Bloc이라는 객체 안에서 Event를 거침으로써 한번 flow가 끊김&amp;rarr; 독립성 강화&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;상태 변화의 예측 가능성
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;setState 구조: 위젯 내부에 변경 로직이 포함 + setState() 후 자동 빌드로 인해 입출력 흐름이 잘 안보임
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;UI 이벤트 &amp;rarr; state 변경 &amp;rarr; build 재호출이 한 파일 내에서 암묵적으로 진행&lt;/li&gt;
&lt;li&gt;setState 호출, count++ 등으로 어느 위치에서든 상태를 자유롭게 수정 가능&lt;/li&gt;
&lt;li&gt;변경 경로가 여러 개 존재 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Bloc: UI와 로직이 완전 분리되어 상태 변화 추적에 용이
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;add(Event)를 통한 입력(UI&amp;rarr;Bloc)과 on&amp;lt;Event&amp;gt; 를 통한 출력(Bloc&amp;rarr;UI) 흐름이 명시적으로 드러남&lt;/li&gt;
&lt;li&gt;외부 이벤트라는 단일 시작점 강제&lt;/li&gt;
&lt;li&gt;상태 변경 경로를 하나로 통일&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Riverpod&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Provider 구조를 계승하여 확장했다는 의미에서 애너그램(&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;provider -&amp;gt; riverpod)&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #9feec3;&quot;&gt;declarative (선언형)&lt;/span&gt; : 상태를 정의해두면 의존 관계에 따라 자동으로 갱신되는 반응형 시스템&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;UI &amp;rarr; Notifier &amp;rarr; State &amp;rarr; Provider 의존성 전파 &amp;rarr; Consumer &lt;span style=&quot;background-color: #f6e199; color: #333333; text-align: start;&quot;&gt;&amp;rarr;&lt;/span&gt; UI&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Riverpod에서의 데이터 흐름은 다음과 같다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;외부 입력&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;UI가 Notifier로 상태 변경 전달 (ref.read)&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Notifier에서 로직 실행&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;State 변경&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Notifier를 소유한 Provider가 변경 감지 (ref.watch로 연결)&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;의존성 그래프 전파 (Consumer까지 전달)&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;UI 반영(build)&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Provider의 상태 저장&lt;/li&gt;
&lt;li&gt;상태 변경 감지&lt;/li&gt;
&lt;li&gt;watch 의존성 관리&lt;/li&gt;
&lt;li&gt;rebuild 트리거&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1772599491546&quot; class=&quot;go&quot; data-ke-language=&quot;go&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Riverpod
 ├─ Provider (정의)
 ├─ Notifier (상태 조작)
 ├─ Consumer/ref.watch (구독)
 └─ ProviderContainer (핵심 저장소 + 의존성 관리)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Provider&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;값을 제공하는 선언 단위로 &lt;b&gt;값을 생성하는 규칙(함수)과 의존성을 정의하는 객체&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;상태를 직접 들고 있거나 다른 provider를 조합, 가공하는 객체 모두 해당&lt;/li&gt;
&lt;li data-end=&quot;149&quot; data-start=&quot;113&quot; data-section-id=&quot;1hlpv9c&quot;&gt;ref.watch()로 연결된 의존성 그래프의 모든 노드는 provider&lt;/li&gt;
&lt;li data-end=&quot;149&quot; data-start=&quot;113&quot; data-section-id=&quot;1hlpv9c&quot;&gt;위젯이 State에 접근할 수 있는 유일한 통로(상태에 대한 식별자 제공)&lt;/li&gt;
&lt;li data-end=&quot;149&quot; data-start=&quot;113&quot; data-section-id=&quot;1hlpv9c&quot;&gt;대부분 상태를 직접 들고 있지 않고 Notifier로 접근&lt;/li&gt;
&lt;li data-end=&quot;149&quot; data-start=&quot;113&quot; data-section-id=&quot;1hlpv9c&quot;&gt;상태에 접근할 통로를 어떤 방식으로 제공할지 정의&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1772599578958&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;final counterProvider = NotifierProvider&amp;lt;CounterNotifier, int&amp;gt;(CounterNotifier.new);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위는 int 타입의 상태 count를 관리하는 CounterNotifier라는 객체에 외부 위젯이 접근할 수 있는 통로를 counterProvider라는 이름으로 정의한 코드이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Notifier&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상태를 직접 관리하는 컨트롤러&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;실제로 state를 보유한 객체&lt;/li&gt;
&lt;li&gt;state를 변경하는 로직 포함&lt;/li&gt;
&lt;li&gt;위젯에게 노출되는 상태변경 통로&lt;/li&gt;
&lt;li&gt;위젯에 의한 상태 변경 흐름
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;위젯이 Notifier 메서드 호출&lt;/li&gt;
&lt;li&gt;Notifier가 state를 갱신&lt;/li&gt;
&lt;li&gt;ProviderContainer가 변경 감지&lt;/li&gt;
&lt;li&gt;watch 중인 위젯(Consumer)에 최신 state 전달 + rebuild&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Consumer&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상태를 구독하는 위젯&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;provider를 watch하는 위젯으로 Notifier를 통해 상태 변경 로직에 접근 가능&lt;/li&gt;
&lt;li&gt;상태가 바뀌면 자동 rebuild&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;ProviderContainer&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(provider-consumer)쌍을 저장하고 State의 변경 감지 시, watch 등록한 위젯에게 변경된 상태 전달&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;앱 전체 상태 저장
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;모든 provider 인스턴스 관리&lt;/li&gt;
&lt;li&gt;모든 Consumer의 watch 정보 추적&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;모든 State의 변경 감지
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;Notifier가 state 변경 시 ProviderContainer가 이를 감지&lt;/li&gt;
&lt;li&gt;변경된 상태가 속한 Provider 확인&lt;/li&gt;
&lt;li&gt;그 Provider를 watch 중인 위젯 목록 조회&lt;/li&gt;
&lt;li&gt;해당 위젯들에게 state 전달&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1772599727030&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;ProviderContainer   &amp;larr; 최상위 상태 관리자
   └─ Provider      &amp;larr; 상태 접근 경로 정의
        └─ Notifier &amp;larr; 상태 변경 관리 객체
             └─ State &amp;larr; 실제 상태&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Bloc과 Riverpod&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Bloc과 Riverpod의 유사점&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;UI &amp;rarr; Notifier&lt;/span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr; State &amp;rarr; Provider &amp;rarr; Consumer &amp;rarr; UI&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #f6e199; color: #333333; text-align: start;&quot;&gt;UI &amp;rarr; Event&lt;/span&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr; Bloc &amp;rarr; State &amp;rarr; UI&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이벤트 대신 Notifier 함수를 통한 상태 변경의 통제 구조 계승&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;상태 외부화 - 독립적인 상태를 두고 이를 참조하여 관리하는 객체(Bloc, Notifier)를 분리하여 상태 직접 수정 차단&lt;/li&gt;
&lt;li&gt;UI와 로직 분리&lt;/li&gt;
&lt;li&gt;Listen &amp;rarr; Watch
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;Bloc : 상태 변경 감지 책임이 수신 측에 존재 (상태를 인자로 받는 위젯 빌더)&lt;/li&gt;
&lt;li&gt;Riverpod : (송신 - 수신) 쌍을 따로 관리하는 제3자(ProviderContainer)에게 변경 감지 책임 위임(위젯이 Watch로 Provider 의존성 등록), 위젯은 UI 반영 책임만 존재&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;코드상 흐름 차이&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Bloc: UI &amp;rarr; add(Event) &amp;rarr; Bloc &amp;rarr; emit(state) &amp;rarr; BlocBuilder 리빌드(위젯 리빌드)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Riverpod: UI &amp;rarr; notifier.increment() &amp;rarr; state 변경 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;rarr;&lt;/span&gt; watch 중인 Provider가 감지 &amp;rarr; watch 중인 Consumer가 감지 &amp;rarr; 위젯 리빌드&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bdbxNm/dJMcabi3iqs/kGMxKAke4zGRhnC47Wa7ik/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bdbxNm/dJMcabi3iqs/kGMxKAke4zGRhnC47Wa7ik/img.png&quot; data-origin-width=&quot;926&quot; data-origin-height=&quot;730&quot; data-is-animation=&quot;false&quot; style=&quot;width: 50.2398%; margin-right: 10px;&quot; data-widthpercent=&quot;50.83&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bdbxNm/dJMcabi3iqs/kGMxKAke4zGRhnC47Wa7ik/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbdbxNm%2FdJMcabi3iqs%2FkGMxKAke4zGRhnC47Wa7ik%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;926&quot; height=&quot;730&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bcQ3Na/dJMcafeHjde/3HlQKLBHlzvnF2TxlF6581/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bcQ3Na/dJMcafeHjde/3HlQKLBHlzvnF2TxlF6581/img.png&quot; data-origin-width=&quot;908&quot; data-origin-height=&quot;740&quot; data-is-animation=&quot;false&quot; style=&quot;width: 48.5975%;&quot; data-widthpercent=&quot;49.17&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bcQ3Na/dJMcafeHjde/3HlQKLBHlzvnF2TxlF6581/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbcQ3Na%2FdJMcafeHjde%2F3HlQKLBHlzvnF2TxlF6581%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;908&quot; height=&quot;740&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1772599950184&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Riverpod 적용 위젯
class CounterPage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider); // 상태 구독

... 

        ElevatedButton(
          onPressed: () {
            ref.read(counterProvider.notifier).increment();
          },
          child: Text(&quot;증가&quot;),
        );
        // UI단에서는 상태를 변경할 Notifier의 메서드를 호출

        final count = ref.watch(counterProvider);
        // 특정 상태를 구독하고 변경 시 자동 rebuild

        Text('$count');&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1772599970889&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//Provider 내부 (provider + notifier)
final counterProvider =
    NotifierProvider&amp;lt;CounterNotifier, int&amp;gt;(CounterNotifier.new);

class CounterNotifier extends Notifier&amp;lt;int&amp;gt; {
  @override
  int build() =&amp;gt; 0;

  void increment() {
    state++;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table id=&quot;3185a9f0-7a68-80e6-866d-f34813b56fe7&quot; style=&quot;border-collapse: collapse; width: 84.7674%; height: 87px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px; width: 41.7442%;&quot;&gt;Bloc&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 42.907%;&quot;&gt;Riverpod&lt;/td&gt;
&lt;/tr&gt;
&lt;tr id=&quot;3185a9f0-7a68-8087-936a-e873722335b1&quot; style=&quot;height: 17px;&quot;&gt;
&lt;td id=&quot;euLc&quot; style=&quot;height: 17px; width: 41.7442%;&quot;&gt;add(Event)&lt;/td&gt;
&lt;td id=&quot;auDV&quot; style=&quot;height: 17px; width: 42.907%;&quot;&gt;notifier.method()&lt;/td&gt;
&lt;/tr&gt;
&lt;tr id=&quot;3185a9f0-7a68-80cb-8f31-ef206b021847&quot; style=&quot;height: 17px;&quot;&gt;
&lt;td id=&quot;euLc&quot; style=&quot;height: 17px; width: 41.7442%;&quot;&gt;emit(state)&lt;/td&gt;
&lt;td id=&quot;auDV&quot; style=&quot;height: 17px; width: 42.907%;&quot;&gt;state =&lt;/td&gt;
&lt;/tr&gt;
&lt;tr id=&quot;3185a9f0-7a68-80e4-9fb7-e23cbb246e33&quot; style=&quot;height: 17px;&quot;&gt;
&lt;td id=&quot;euLc&quot; style=&quot;height: 17px; width: 41.7442%;&quot;&gt;BlocBuilder&lt;/td&gt;
&lt;td id=&quot;auDV&quot; style=&quot;height: 17px; width: 42.907%;&quot;&gt;ref.watch()&lt;/td&gt;
&lt;/tr&gt;
&lt;tr id=&quot;3185a9f0-7a68-8061-8f4e-f8310a3527f7&quot; style=&quot;height: 19px;&quot;&gt;
&lt;td id=&quot;euLc&quot; style=&quot;height: 19px; width: 41.7442%;&quot;&gt;state 인자&lt;/td&gt;
&lt;td id=&quot;auDV&quot; style=&quot;height: 19px; width: 42.907%;&quot;&gt;final count 변수&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Stream 기반 반응 vs 의존성 추적 기반 반응&lt;/h4&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;상태 변경을 전달받고 반응하는 방식에 있어서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;Stream기반의 Bloc과 의존성 추적 기반의 Riverpod 사이에는&amp;nbsp;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;본질적인 차이가 존재한다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Bloc - 상태 변경 자체를 데이터로 표현하여 전달 - 이벤트 중심 아키텍쳐&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Riverpod - 변경된 상태를 감지하도록 연관된 객체끼리 연결 - 의존성 중심 아키텍쳐&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/V9D2Z/dJMcaih82Ta/cAk2fbPkePkHFRcveBJDrk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/V9D2Z/dJMcaih82Ta/cAk2fbPkePkHFRcveBJDrk/img.png&quot; data-origin-width=&quot;1354&quot; data-origin-height=&quot;1256&quot; data-is-animation=&quot;false&quot; style=&quot;width: 34.1331%; margin-right: 10px;&quot; data-widthpercent=&quot;34.95&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/V9D2Z/dJMcaih82Ta/cAk2fbPkePkHFRcveBJDrk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FV9D2Z%2FdJMcaih82Ta%2FcAk2fbPkePkHFRcveBJDrk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1354&quot; height=&quot;1256&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cnSy1m/dJMcaivEwb0/bxrPKb9Ta6Zkkca6sqSVv1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cnSy1m/dJMcaivEwb0/bxrPKb9Ta6Zkkca6sqSVv1/img.png&quot; data-origin-width=&quot;1338&quot; data-origin-height=&quot;1338&quot; data-is-animation=&quot;false&quot; style=&quot;width: 31.6626%; margin-right: 10px;&quot; data-widthpercent=&quot;32.42&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cnSy1m/dJMcaivEwb0/bxrPKb9Ta6Zkkca6sqSVv1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcnSy1m%2FdJMcaivEwb0%2FbxrPKb9Ta6Zkkca6sqSVv1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1338&quot; height=&quot;1338&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/1FYSm/dJMcadHR0BF/gbP0lKkDdqJ0ba2oakZNV0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/1FYSm/dJMcadHR0BF/gbP0lKkDdqJ0ba2oakZNV0/img.png&quot; data-origin-width=&quot;1180&quot; data-origin-height=&quot;1172&quot; data-is-animation=&quot;false&quot; style=&quot;width: 31.8787%;&quot; data-widthpercent=&quot;32.63&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/1FYSm/dJMcadHR0BF/gbP0lKkDdqJ0ba2oakZNV0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F1FYSm%2FdJMcadHR0BF%2FgbP0lKkDdqJ0ba2oakZNV0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1180&quot; height=&quot;1172&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;table id=&quot;3185a9f0-7a68-80dd-bc1d-cf6d044c9184&quot; style=&quot;border-collapse: collapse; width: 89.5349%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 17.4419%;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;width: 32.093%; text-align: center;&quot;&gt;Stream 기반&lt;/td&gt;
&lt;td style=&quot;width: 39.8837%; text-align: center;&quot;&gt;의존성 추적 기반&lt;/td&gt;
&lt;/tr&gt;
&lt;tr id=&quot;3185a9f0-7a68-8089-977f-e45f6c825892&quot;&gt;
&lt;td id=&quot;OP^}&quot; style=&quot;width: 17.4419%;&quot;&gt;중심 개념&lt;/td&gt;
&lt;td id=&quot;J_oK&quot; style=&quot;width: 32.093%;&quot;&gt;데이터 흐름&lt;/td&gt;
&lt;td id=&quot;WgPg&quot; style=&quot;width: 39.8837%;&quot;&gt;의존 관계&lt;/td&gt;
&lt;/tr&gt;
&lt;tr id=&quot;3185a9f0-7a68-80dc-9814-c01d1831eb2e&quot;&gt;
&lt;td id=&quot;OP^}&quot; style=&quot;width: 17.4419%;&quot;&gt;연결 방식&lt;/td&gt;
&lt;td id=&quot;J_oK&quot; style=&quot;width: 32.093%;&quot;&gt;구독(listener) 중심&lt;/td&gt;
&lt;td id=&quot;WgPg&quot; style=&quot;width: 39.8837%;&quot;&gt;watch 등록 (제3자가 연결 관리)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr id=&quot;3185a9f0-7a68-80ff-82a2-d04662dd79fc&quot;&gt;
&lt;td id=&quot;OP^}&quot; style=&quot;width: 17.4419%;&quot;&gt;구조&lt;/td&gt;
&lt;td id=&quot;J_oK&quot; style=&quot;width: 32.093%;&quot;&gt;선형 흐름&lt;/td&gt;
&lt;td id=&quot;WgPg&quot; style=&quot;width: 39.8837%;&quot;&gt;그래프 구조&lt;/td&gt;
&lt;/tr&gt;
&lt;tr id=&quot;3185a9f0-7a68-8039-b05e-f94d7053c697&quot;&gt;
&lt;td id=&quot;OP^}&quot; style=&quot;width: 17.4419%;&quot;&gt;철학&lt;/td&gt;
&lt;td id=&quot;J_oK&quot; style=&quot;width: 32.093%;&quot;&gt;이벤트 드리븐&lt;/td&gt;
&lt;td id=&quot;WgPg&quot; style=&quot;width: 39.8837%;&quot;&gt;선언적 의존성&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;API 통신&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;실시간으로 변경되는 데이터를 앱 외부에서 받아오기 위해서는 API 통신이 필수적이다. http, Dio 같은 라이브러리를 사용하여 JSON과 다트 객체 간의 변환과&amp;nbsp;&lt;/span&gt;API 요청 전송 및 응답 처리를 구현할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;API&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Application Programming Interface&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서로 다른 소프트웨어 애플리케이션이 서로 어떻게 통신하고 데이터를 공유할 지 미리 정의해둔 규칙이다. &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;API를 통해&lt;span&gt; &lt;/span&gt;&lt;/span&gt;클라이언트는 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;서버의 내부 구조를 알지 못하더라도&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;약속한 형식에 맞춰 요청을 보내기만 하면 약속한 형식으로 응답을 받아올 수 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;어떻게 하는지는 몰라도 무엇을 하는지는 안다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;플러터 앱에서 API 호출을 통해 데이터를 받아오는 흐름은 다음과 같다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;1. 사용자 UI 이벤트 처리&lt;br /&gt;&amp;nbsp; &amp;nbsp; - 버튼 클릭이나 입력 변경에 따른 사용자 행동에 대해 이벤트 생성, 해당 이벤트리스너에 의한 API 통신 프로세스 시작&lt;br /&gt;2. API 요청 전송&lt;br /&gt;&amp;nbsp; &amp;nbsp; - 이벤트리스너에서 적절한 파라미터를 채운 후 http 클라이언트를 통해 API 요청 전송&lt;br /&gt;3. API 응답 수신&lt;br /&gt;&amp;nbsp; &amp;nbsp; - 서버로부터 받은 API 응답에 대한 수신 처리(상태 코드 확인, 에러 핸들링)&lt;br /&gt;4. 응답 데이터 처리&lt;br /&gt;&amp;nbsp; &amp;nbsp; - API 응답으로 받은 데이터(JSON, XML)의 파싱 및 변환 후 앱의 로직에 맞게 조작&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;플러터에서 API 관련 기능을 제공하는 대표적인 라이브러리로 http, Dio 2가지가 존재한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;http 패키지&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;http 패키지는 HTTP(Hypertext Transfer Protocol)기반으로 API 요청을 작성하고 응답을 처리할 수 있도록 일반적인 http 메서드를 제공한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;GET (http.get)&amp;nbsp;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;데이터 조회&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1773741503990&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//패키지 내부 정의
Future&amp;lt;Response&amp;gt; get(Uri url, {Map&amp;lt;String, String&amp;gt;? headers})

//사용 예시
final response = await http.get(
 'https://api.example.com/users', headers : {'Authorization': 'Bearer token'}
);&lt;/code&gt;&lt;/pre&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li style=&quot;list-style-type: none;&quot;&gt;&amp;nbsp;&lt;/li&gt;
&lt;/ol&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;url : 요청을 보낼 목적지 URL&lt;/li&gt;
&lt;li&gt;header: GET 요청에 필요한 정보(일반적으로 인증 정보 또는 content-type)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;POST (http.post)&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;데이터 생성&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1773741693503&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 패키지 내부 정의
Future&amp;lt;Response&amp;gt; post(
  Uri url, {
  Map&amp;lt;String, String&amp;gt;? headers,
  Object? body,
  Encoding? encoding,
})

// 사용 예시
final response = await http.post(
  Uri.parse('https://api.example.com/users'),
  headers: {
    'Authorization': 'Bearer token',
    'Content-Type': 'application/json',
  },
  body: '{&quot;name&quot;: &quot;John&quot;}',
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;body: 서버에서 POST 요청에 대한 처리로 새 데이터를 생성할 때 필요한 정보&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;PUT (http.put)&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;전체 수정&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1773741908658&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 패키지 내부 정의
Future&amp;lt;Response&amp;gt; put(
  Uri url, {
  Map&amp;lt;String, String&amp;gt;? headers,
  Object? body,
  Encoding? encoding,
})

// 사용 예시
final response = await http.put(
  Uri.parse('https://api.example.com/users/1'),
  headers: {
    'Authorization': 'Bearer token',
    'Content-Type': 'application/json',
  },
  body: '{&quot;name&quot;: &quot;Updated&quot;}',
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;PATCH (http.patch)&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;부분 수정&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1773742034189&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 패키지 내부 정의
Future&amp;lt;Response&amp;gt; patch(
  Uri url, {
  Map&amp;lt;String, String&amp;gt;? headers,
  Object? body,
  Encoding? encoding,
})

// 사용 예시
final response = await http.patch(
  Uri.parse('https://api.example.com/users/1'),
  headers: {
    'Authorization': 'Bearer token',
    'Content-Type': 'application/json',
  },
  body: '{&quot;name&quot;: &quot;Partial Update&quot;}',
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;DELETE (http.delete)&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;데이터 삭제&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1773742092422&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 패키지 내부 정의
Future&amp;lt;Response&amp;gt; delete(
  Uri url, {
  Map&amp;lt;String, String&amp;gt;? headers,
  Object? body,
  Encoding? encoding,
})

// 사용 예시
final response = await http.delete(
  Uri.parse('https://api.example.com/users/1'),
  headers: {'Authorization': 'Bearer token'},
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;멱등성&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멱등성은 어떤 연산을 여러 번 수행해도 결과가 달라지지 않는 성질로 네트워크로 전달되는 API 통신 특성상 로직 설계에 중요한 고려 사항이 된다. 서버에서는 http의 각 메서드 특징을 고려하여 로직을 설계해야한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 98.4882%; height: 133px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 17.6441%; height: 19px; text-align: left;&quot;&gt;메서드&lt;/td&gt;
&lt;td style=&quot;width: 13.8589%; height: 19px; text-align: left;&quot;&gt;멱등성&lt;/td&gt;
&lt;td style=&quot;width: 38.4923%; height: 19px; text-align: left;&quot;&gt;반복 요청 시 행동&lt;/td&gt;
&lt;td style=&quot;width: 32.3367%; height: 19px; text-align: left;&quot;&gt;예시&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 17.6441%; height: 19px; text-align: left;&quot;&gt;GET&lt;/td&gt;
&lt;td style=&quot;width: 13.8589%; height: 19px; text-align: left;&quot;&gt;O&lt;/td&gt;
&lt;td style=&quot;width: 38.4923%; height: 19px; text-align: left;&quot;&gt;상태 변화 없이 같은 결과 반환&lt;/td&gt;
&lt;td style=&quot;width: 32.3367%; height: 19px; text-align: left;&quot;&gt;GET /users/1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 17.6441%; height: 19px; text-align: left;&quot;&gt;POST&lt;/td&gt;
&lt;td style=&quot;width: 13.8589%; height: 19px; text-align: left;&quot;&gt;X&lt;/td&gt;
&lt;td style=&quot;width: 38.4923%; height: 19px; text-align: left;&quot;&gt;호출마다 새 데이터 생성&lt;/td&gt;
&lt;td style=&quot;width: 32.3367%; height: 19px; text-align: left;&quot;&gt;POST/users/&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 17.6441%; height: 19px; text-align: left;&quot;&gt;PUT&lt;/td&gt;
&lt;td style=&quot;width: 13.8589%; height: 19px; text-align: left;&quot;&gt;O&lt;/td&gt;
&lt;td style=&quot;width: 38.4923%; height: 19px; text-align: left;&quot;&gt;받은 값으로 덮어쓰기(결과 동일)&lt;/td&gt;
&lt;td style=&quot;width: 32.3367%; height: 19px; text-align: left;&quot;&gt;
&lt;div&gt;
&lt;div&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;PUT /users/1&lt;/span&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;{name: &quot;John&quot;}&lt;/span&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 38px;&quot;&gt;
&lt;td style=&quot;width: 17.6441%; height: 38px; text-align: left;&quot;&gt;PATCH&lt;/td&gt;
&lt;td style=&quot;width: 13.8589%; height: 38px; text-align: left;&quot;&gt;△&lt;/td&gt;
&lt;td style=&quot;width: 38.4923%; height: 38px; text-align: left;&quot;&gt;상황에 따라 결과 달라질 수 있음&lt;/td&gt;
&lt;td style=&quot;width: 32.3367%; height: 38px; text-align: left;&quot;&gt;
&lt;div&gt;
&lt;div&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;PATCH /users/1&lt;/span&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;{count: count + 1}&lt;/span&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 17.6441%; height: 19px; text-align: left;&quot;&gt;DELETE&lt;/td&gt;
&lt;td style=&quot;width: 13.8589%; height: 19px; text-align: left;&quot;&gt;O&lt;/td&gt;
&lt;td style=&quot;width: 38.4923%; height: 19px; text-align: left;&quot;&gt;해당하는 데이터 존재할 경우에 삭제&lt;/td&gt;
&lt;td style=&quot;width: 32.3367%; height: 19px; text-align: left;&quot;&gt;DELETE&amp;nbsp;/users/1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;같은 요청을 여러 번 보내도 한 번 보낸 결과와 동일하면 멱등&lt;/li&gt;
&lt;li&gt;응답이 아닌 최종 상태 기준으로 판단&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;API 응답 데이터 처리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API 응답 데이터는 주로 다음과 같은 순서로 처리되어 UI에 반영된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;JSON &amp;rarr; Map &amp;rarr; Model(다트 객체) &amp;rarr; State &amp;rarr; UI&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;1. API 응답 (JSON)&lt;br /&gt;2. jsonDecode() 호출: JSON &amp;rarr; Map&lt;br /&gt;&amp;nbsp; &amp;nbsp; - jsonDecode() 메서드가 JSON 문자열 파싱 후 Dart 기본 타입 Map&amp;lt;String, dynamic&amp;gt; 객체로 역직렬화&lt;br /&gt;3. fromJson() 호출: Map &amp;rarr; 모델&lt;br /&gt;&amp;nbsp; &amp;nbsp; - 미리 정의해둔 Model.fromJson() 메서드가 Map&amp;lt;String, dynamic&amp;gt; 객체를 Model 객체로 변환&lt;br /&gt;4. State 변경&lt;br /&gt;5. UI 표시&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음과 같은 구조로 API를 처리하도록 설계된 애플리케이션에서 각 컴포넌트의 역할과 예시 코드는 다음과 같다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;UI &lt;br /&gt;&amp;darr; &lt;br /&gt;Notifier (상태관리) &lt;br /&gt;&amp;darr; &lt;br /&gt;Repository (비즈니스 로직) &lt;br /&gt;&amp;darr; &lt;br /&gt;ApiService (HTTP + 응답 처리) &lt;br /&gt;&amp;darr;&lt;br /&gt;http client&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Service = 실제 네트워크 통신 수행&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Model = 외부 데이터를 앱 내부에서 사용할 수 있도록 구조화한 Dart 객체 (응답의 최종 변환 결과)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Repository = 데이터 변환 및 비즈니스 로직 (데이터 조합, 조건 처리, 캐싱 전략)&lt;/b&gt;&lt;br /&gt;&lt;b&gt;Notifier = 상태 관리 및 변경 흐름(success, loading, error) 제어&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Provider = 변경 전파를 위한 의존성 연결 및 진입점 제공&lt;/b&gt;&lt;br /&gt;&lt;b&gt;UI =&amp;nbsp; 변경된 상태를 화면에 표시&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;Service&lt;/p&gt;
&lt;pre id=&quot;code_1773743468114&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;class ApiService {
  final String baseUrl;

  ApiService(this.baseUrl);

  Future&amp;lt;Map&amp;lt;String, dynamic&amp;gt;&amp;gt; get(String path) async {
    final response = await http.get(	//http 메서드 get 호출
      Uri.parse('$baseUrl$path'),
      headers: {
        'Authorization': 'Bearer token',
        'Content-Type': 'application/json',
      },
    );

    return _handleResponse(response);
  }

  Map&amp;lt;String, dynamic&amp;gt; _handleResponse(http.Response response) {
    if (response.statusCode &amp;gt;= 200 &amp;amp;&amp;amp; response.statusCode &amp;lt; 300) {
      return jsonDecode(response.body);
    } else {
      throw Exception('API Error: ${response.statusCode}');
    }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;Model&lt;/p&gt;
&lt;pre id=&quot;code_1773743488379&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;class User {
  final int id;
  final String name;

  User({required this.id, required this.name});

  factory User.fromJson(Map&amp;lt;String, dynamic&amp;gt; json) {
    return User(
      id: json['id'],
      name: json['name'],
    );
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;Repository&lt;/p&gt;
&lt;pre id=&quot;code_1773744981569&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;class UserRepository {
  final ApiService api;

  UserRepository(this.api);

  Future&amp;lt;User&amp;gt; getUser() async {
    final data = await api.get('/users/1');	//서비스의 메서드 get 호출
    return User.fromJson(data);
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;Notifier&lt;/p&gt;
&lt;pre id=&quot;code_1773745040016&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;class UserNotifier extends StateNotifier&amp;lt;User?&amp;gt; {
  final UserRepository repository;

  UserNotifier(this.repository) : super(null);

  Future&amp;lt;void&amp;gt; fetchUser() async {
    state = await repository.getUser(); //상태 변경
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;Provider&lt;/p&gt;
&lt;pre id=&quot;code_1773745239471&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;final apiServiceProvider = Provider(
  (ref) =&amp;gt; ApiService('https://api.example.com'),
);

final userRepositoryProvider = Provider(
  (ref) =&amp;gt; UserRepository(ref.read(apiServiceProvider)),
);

final userProvider = StateNotifierProvider&amp;lt;UserNotifier, User?&amp;gt;(
  (ref) =&amp;gt; UserNotifier(ref.read(userRepositoryProvider)),
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UI&lt;/p&gt;
&lt;pre id=&quot;code_1773743706266&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;...
    final user = ref.watch(userProvider);

    ElevatedButton(
      onPressed: () {
        ref.read(userProvider.notifier).fetchUser();
      },
      child: Text('Load User'),
    );

    Text(user?.name ?? 'Loading...');&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Dio 라이브러리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Dio는 플러터 애플리케이션 전용으로 최적화된 네트워킹 및 파일 입출력을 제공하는 라이브러리이다. http 패키지 위에 설계된 Dio의 유연한 http 기능 및 인터셉터, 타임아웃을 활용하여 API 요청 및 응답 처리 로직을 간편하게 구성할 수 있다. 다음은 Dio에서 추가로 제공하는 기능이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;http 클라이언트 기본 옵션 설정: http 클라이언트의 기본 헤더, 기본 URL, 연결 시간 제한 등의 옵션을 쉽게 설정 가능&lt;/li&gt;
&lt;li&gt;인터셉터 지원: 인터셉터를 통해 모든 요청 및 응답에 네트워크 통신의 공통 로직 적용 가능 (토큰 자동 갱신, 공통 헤더 추가, 공통 응답 처리, 로깅, 전역 오류 처리 등)&lt;/li&gt;
&lt;li&gt;사용자 정의: 사용자 지정 형식 처리나 직렬화 로직 등 자유도 높은 요청 및 응답 변환기 구현 가능&lt;/li&gt;
&lt;li&gt;파일 업로드 및 다운로드: FormData, MultipartFile 등 대용량 파일 업로드/다운로드 기능 지원&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Dio API 요청&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Dio를 통해 http 요청을 보내기 위해서는 클라이언트 기본 설정을 적용한 Dio 인스턴스를 만들어야한다.&lt;/p&gt;
&lt;pre id=&quot;code_1773762311852&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;final dio = Dio(
  BaseOptions(
    baseUrl: 'https://api.example.com',
    headers: {
      'Authorization': 'Bearer token',
      'Content-Type': 'application/json',
    },
    connectTimeout: Duration(seconds: 5),
    receiveTimeout: Duration(seconds: 5),
  ),
);&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;GET (dio.get)&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;dio.get(String)&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1773762636890&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 내부 정의
Future&amp;lt;Response&amp;lt;T&amp;gt;&amp;gt; get&amp;lt;T&amp;gt;(
  String path, {
  Map&amp;lt;String, dynamic&amp;gt;? queryParameters,
  Options? options,
  CancelToken? cancelToken,
  ProgressCallback? onReceiveProgress,
});

// 사용 예시
Future&amp;lt;void&amp;gt; fetchUser() async {
  try {
    final response = await dio.get('/users/1'); //파라미터로 엔드포인트 url 전달

    print(response.data);

  } on DioException catch (e) {
    // Dio 전용 에러 처리
    print('Dio error: ${e.message}');

    if (e.response != null) {
      print('StatusCode: ${e.response?.statusCode}');
      print('Error Data: ${e.response?.data}');
    }

  } catch (e) {
    // 기타 에러
    print('Unknown error: $e');
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;POST (dio.post)&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;dio.post(String, Map)&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1773762681977&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 내부 정의
Future&amp;lt;Response&amp;lt;T&amp;gt;&amp;gt; post&amp;lt;T&amp;gt;(
  String path, {
  Object? data,
  Map&amp;lt;String, dynamic&amp;gt;? queryParameters,
  Options? options,
  CancelToken? cancelToken,
  ProgressCallback? onSendProgress,
  ProgressCallback? onReceiveProgress,
});

// 사용 예시
Future&amp;lt;void&amp;gt; createUser() async {
  try {
    final response = await dio.post(
      '/users', //첫번째 파라미터 = 엔드포인트 url
      data: {'name': 'John'}, //두번째 파라미터 = body
    );

    print('Created: ${response.data}');

  } on DioException catch (e) {
    if (e.response?.statusCode == 400) {
      print('잘못된 요청');
    } else if (e.response?.statusCode == 401) {
      print('인증 필요');
    } else {
      print('기타 서버 에러');
    }

  } catch (e) {
    print('Unexpected error: $e');
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;PUT (dio.put)&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;dio.put(String, Map)&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1773763316669&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//내부 정의
Future&amp;lt;Response&amp;lt;T&amp;gt;&amp;gt; put&amp;lt;T&amp;gt;(
  String path, {
  Object? data,
  Map&amp;lt;String, dynamic&amp;gt;? queryParameters,
  Options? options,
  CancelToken? cancelToken,
  ProgressCallback? onSendProgress,
  ProgressCallback? onReceiveProgress,
});

// 사용 예시
Future&amp;lt;void&amp;gt; updateUser() async {
  try {
    final response = await dio.put(
      '/users/1', //첫번째 파라미터 = 엔드포인트 url
      data: {'name': 'Updated Name'}  //두번째 파라미터 = body
      ,
    );

    print('Updated: ${response.data}');

  } on DioException catch (e) {
    print('Error: ${e.message}');

    if (e.response != null) {
      print('StatusCode: ${e.response?.statusCode}');
      print('Error Data: ${e.response?.data}');
    }

  } catch (e) {
    print('Unexpected error: $e');
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;DELETE (dio.delete)&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;dio.delete(String)&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1773763772647&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//내부 정의
Future&amp;lt;Response&amp;lt;T&amp;gt;&amp;gt; delete&amp;lt;T&amp;gt;(
  String path, {
  Object? data,
  Map&amp;lt;String, dynamic&amp;gt;? queryParameters,
  Options? options,
  CancelToken? cancelToken,
});

//사용 예시
Future&amp;lt;void&amp;gt; deleteUser() async {
  try {
    final response = await dio.delete(
      '/users/1',
    );

    print('Deleted: ${response.data}');

  } on DioException catch (e) {
    print('Error: ${e.message}');

    if (e.response != null) {
      print('StatusCode: ${e.response?.statusCode}');
    }

  } catch (e) {
    print('Unexpected error: $e');
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Dio 라이브러리 내부 http 메서드 정의&lt;/h4&gt;
&lt;table style=&quot;border-collapse: collapse; width: 78.6047%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 35.6571%;&quot;&gt;파라미터&lt;/td&gt;
&lt;td style=&quot;width: 42.8313%;&quot;&gt;의미&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 35.6571%;&quot;&gt;path&lt;/td&gt;
&lt;td style=&quot;width: 42.8313%;&quot;&gt;요청 경로&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 35.6571%;&quot;&gt;data&lt;/td&gt;
&lt;td style=&quot;width: 42.8313%;&quot;&gt;body (POST/PUT/DELETE 가능)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 35.6571%;&quot;&gt;queryParameters&lt;/td&gt;
&lt;td style=&quot;width: 42.8313%;&quot;&gt;URL 쿼리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 35.6571%;&quot;&gt;options&lt;/td&gt;
&lt;td style=&quot;width: 42.8313%;&quot;&gt;헤더 등 추가 설정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 35.6571%;&quot;&gt;cancelToken&lt;/td&gt;
&lt;td style=&quot;width: 42.8313%;&quot;&gt;요청 취소&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 35.6571%;&quot;&gt;onSendProgress&lt;/td&gt;
&lt;td style=&quot;width: 42.8313%;&quot;&gt;업로드 진행률&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 35.6571%;&quot;&gt;onReceiveProgress&lt;/td&gt;
&lt;td style=&quot;width: 42.8313%;&quot;&gt;다운로드 진행률&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;POST, PUT 요청에서 대용량 파일 전송&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;http 요청에서 body에 해당하는 두번째 파라미터(Object)에 Map 형식으로 전송&lt;/p&gt;
&lt;pre id=&quot;code_1773764054918&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;final formData = FormData.fromMap({
  'name': 'john',
  'file': await MultipartFile.fromFile(
    '/path/image.png',
    filename: 'image.png',
  ),
});  //텍스트와 파일 데이터를 담은 Map 정의

final response = await dio.post(
  '/upload',
  data: formData,  //data 파라미터로 정의해둔 Map 전송
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>dev/app</category>
      <author>cusum26</author>
      <guid isPermaLink="true">https://imjyh01.tistory.com/15</guid>
      <comments>https://imjyh01.tistory.com/15#entry15comment</comments>
      <pubDate>Fri, 6 Mar 2026 23:10:06 +0900</pubDate>
    </item>
    <item>
      <title>flutter - 플러터로 크로스 플랫폼 앱 개발하기(4)</title>
      <link>https://imjyh01.tistory.com/13</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;개발환경 세팅과 플러터 핵심 개념 학습을 어느정도 완료했으니 이를&amp;nbsp;토대로 플러터 프로젝트를 생성해보자. AI와의 채팅을 통해 갈등 관리 기능을 제공하는 간단한 채팅 앱 conflicAI을 만들기로 했다. VScode를 사용한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;플러터 프로젝트 생성&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모바일 앱이므로 웹이나 데스크톱 전용 패키지를 따로 세팅하지 않도록 --platforms로 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;iOS와 Android를&amp;nbsp;&lt;/span&gt;설정하고 --org로 프로젝트 담당 조직(추후 앱의 식별자에 포함)을 명시해서 프로젝트를 생성한다. 이때 &lt;b&gt;프로젝트 이름은 snake_case로 설정해야 한다.&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1771698302289&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;flutter create --platforms ios,android --org com.cusum26 conflic_ai&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;create 명령어로 프로젝트를 생성하면 다음과 같은 파일 구조가 만들어지고 앱의 진입점인 main.dart에는 기본적인 위젯과 카운터를 구현한 코드가 존재한다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zz3t4/dJMcahXKiWI/2Tl3rUNkKlQK1MyFNclfH0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zz3t4/dJMcahXKiWI/2Tl3rUNkKlQK1MyFNclfH0/img.png&quot; data-origin-width=&quot;558&quot; data-origin-height=&quot;1106&quot; data-is-animation=&quot;false&quot; data-widthpercent=&quot;22.55&quot; style=&quot;width: 22.2927%; margin-right: 10px;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zz3t4/dJMcahXKiWI/2Tl3rUNkKlQK1MyFNclfH0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fzz3t4%2FdJMcahXKiWI%2F2Tl3rUNkKlQK1MyFNclfH0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;558&quot; height=&quot;1106&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c2GvhD/dJMcajgUf1X/t4k2sCj7rySQqdwd4Fqbak/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c2GvhD/dJMcajgUf1X/t4k2sCj7rySQqdwd4Fqbak/img.png&quot; data-origin-width=&quot;2304&quot; data-origin-height=&quot;1330&quot; data-is-animation=&quot;false&quot; style=&quot;width: 76.5445%;&quot; data-widthpercent=&quot;77.45&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c2GvhD/dJMcajgUf1X/t4k2sCj7rySQqdwd4Fqbak/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc2GvhD%2FdJMcajgUf1X%2Ft4k2sCj7rySQqdwd4Fqbak%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2304&quot; height=&quot;1330&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;플러터 프로젝트 구조&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/puWJR/dJMcagR8diP/OItPPlZQ0kmKcyMwg6YVOK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/puWJR/dJMcagR8diP/OItPPlZQ0kmKcyMwg6YVOK/img.png&quot; width=&quot;600&quot; height=&quot;621&quot; data-origin-width=&quot;1178&quot; data-origin-height=&quot;1220&quot; data-is-animation=&quot;false&quot; style=&quot;width: 57.0543%; margin-right: 10px;&quot; data-widthpercent=&quot;57.73&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/puWJR/dJMcagR8diP/OItPPlZQ0kmKcyMwg6YVOK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FpuWJR%2FdJMcagR8diP%2FOItPPlZQ0kmKcyMwg6YVOK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1178&quot; height=&quot;1220&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/WPDlu/dJMcacWua8Y/4mOffkQ56qxiVgY0OWzz4K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/WPDlu/dJMcacWua8Y/4mOffkQ56qxiVgY0OWzz4K/img.png&quot; data-origin-width=&quot;536&quot; data-origin-height=&quot;758&quot; data-is-animation=&quot;false&quot; style=&quot;width: 41.7829%;&quot; data-widthpercent=&quot;42.27&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/WPDlu/dJMcacWua8Y/4mOffkQ56qxiVgY0OWzz4K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FWPDlu%2FdJMcacWua8Y%2F4mOffkQ56qxiVgY0OWzz4K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;536&quot; height=&quot;758&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span&gt;저번 시간에 학습한 플러터 프로젝트의 구조와 실제 프로젝트의 파일 구조를 연결하면 다음과 같다.&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Framework - lib 디렉토리&lt;/li&gt;
&lt;li&gt;Embedder - android, ios 디렉토리&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span&gt;아직 구조가 잘 안 와닿는다면 복습&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1772192807514&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;flutter - 플러터로 크로스 플랫폼 앱 개발하기(22)&quot; data-og-description=&quot;본격적인 프로젝트 개발에 앞서 플러터의 필수 개념에 대해 알아보자. 플러터 아키텍쳐 플러터 프로젝트의 구조를 나름 비유하자면 다음과 같다. 무한하게 상상할 수 있는 Framework가 특정 규칙&quot; data-og-host=&quot;imjyh01.tistory.com&quot; data-og-source-url=&quot;https://imjyh01.tistory.com/14&quot; data-og-url=&quot;https://imjyh01.tistory.com/14&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cDj02S/dJMb9lL8RhW/rHtouONIbVLadK5bGjVbl0/img.png?width=614&amp;amp;height=743&amp;amp;face=0_0_614_743,https://scrap.kakaocdn.net/dn/bfezvu/dJMb88F2bPo/wOeJpVBVTOTiJhx2yEPM51/img.png?width=614&amp;amp;height=743&amp;amp;face=0_0_614_743,https://scrap.kakaocdn.net/dn/UCA3f/dJMb8866ao5/5aONZwDcG9oNBZpEtomRF0/img.png?width=1170&amp;amp;height=2532&amp;amp;face=0_0_1170_2532&quot;&gt;&lt;a href=&quot;https://imjyh01.tistory.com/14&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://imjyh01.tistory.com/14&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cDj02S/dJMb9lL8RhW/rHtouONIbVLadK5bGjVbl0/img.png?width=614&amp;amp;height=743&amp;amp;face=0_0_614_743,https://scrap.kakaocdn.net/dn/bfezvu/dJMb88F2bPo/wOeJpVBVTOTiJhx2yEPM51/img.png?width=614&amp;amp;height=743&amp;amp;face=0_0_614_743,https://scrap.kakaocdn.net/dn/UCA3f/dJMb8866ao5/5aONZwDcG9oNBZpEtomRF0/img.png?width=1170&amp;amp;height=2532&amp;amp;face=0_0_1170_2532');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;flutter - 플러터로 크로스 플랫폼 앱 개발하기(22)&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;본격적인 프로젝트 개발에 앞서 플러터의 필수 개념에 대해 알아보자. 플러터 아키텍쳐 플러터 프로젝트의 구조를 나름 비유하자면 다음과 같다. 무한하게 상상할 수 있는 Framework가 특정 규칙&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;imjyh01.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;pubspec.yaml&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;pubspec.yaml 파일은&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;앱에 필요한 모든 외부 자원과 설정을 관리하는데 주로 다음과 같은 역할을 한다.&lt;/span&gt;&lt;/p&gt;
&lt;h4 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;외부&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;라이브러리(패키지) 추가&lt;/span&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1771790141722&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;dependencies:
  flutter:
    sdk: flutter

  # 상태관리
  flutter_riverpod: ^2.5.1

  # 네트워크 (API 통신)
  dio: ^5.4.0

  # UI
  cupertino_icons: ^1.0.8
  flutter_svg: ^2.2.3
  
  # 네이버 지도 패키지
  flutter_naver_map: ^1.3.0&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;네이버 지도 연동, 카카오 로그인, API 통신 등을 구현하기 위해 해당 기능을 제공하는 외부 패키지를&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;dependencies&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span&gt;에 추가함으로써&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;플러터가 해당 패키지의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;소스 코드와 필요한 하위 라이브러리&lt;/b&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;를&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;자동으로 다운받고 관리&lt;/b&gt;하게 된다. 추가로&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;외부 패키지가 업데이트되며 기존 코드에 영향을 주는 것을 방지하기 위해 특정 버전을 지정(&lt;/span&gt;^1.3.0&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;)하여&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;안정성&lt;/b&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;을&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;보장&lt;/b&gt;할 수 있다.&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;리소스 등록&lt;/span&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1771790221841&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;flutter:
  uses-material-design: true
  
  # 이미지, 아이콘, JSON 등 일반 리소스 등록
  assets:
    - assets/images/logo.png
    - assets/images/fig1.png
  
  # 폰트 리소스 등록
  fonts:
    - family: MyCustomFont
      fonts:
        - asset: assets/fonts/Pretendard-Regular.ttf
        - asset: assets/fonts/Pretendard-Bold.ttf
          weight: 700  # bold 설정&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;assets, fonts 디렉토리에 저장된 이미지, 폰트 등 리소스의 경로를 pubspec.yaml에 등록하여 프로젝트가 이를 사용할 수 있게 하고 앱 실행환경에 포함시킨다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1771790256032&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;flutter pub get&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;pubspec.yaml 파일 수정 후에는 위 명령어를 통해 추가 패키지 설치나 리소스 등록을 완료하여 개발 환경이 변경사항을 반영하도록 해야 한다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;크로스 플랫폼 지원 아키텍쳐&lt;/h2&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;앱기능을 구현하고 화면을 그려내는 주된 작업 공간 lib 디렉토리 이외에도 플러터 프로젝트는 여러 디렉토리를 담고 있다. 플러터 엔진이 어떤 방식으로 크로스 플랫폼을 지원하는지 네이티브와의 연결 관점에서 더 알아보자.&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;&lt;span data-complete=&quot;true&quot; data-sfc-cp=&quot;&quot;&gt;&lt;b&gt;lib&lt;span&gt;&amp;nbsp;&lt;/span&gt;디렉토리&lt;/b&gt;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-complete=&quot;true&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot; data-sae=&quot;&quot; data-complete=&quot;true&quot; data-hveid=&quot;CAEIBBAB&quot;&gt;&lt;span data-complete=&quot;true&quot; data-sfc-cp=&quot;&quot;&gt;사진 촬영, 파일 접근, 버튼 색 설정과 같은 모든 논리적 명령을 내리고 화면 설계를 결정&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;283&quot; data-start=&quot;239&quot;&gt;&lt;span data-sfc-cp=&quot;&quot; data-complete=&quot;true&quot;&gt;&lt;b&gt;플러터 엔진 =&lt;/b&gt;&lt;/span&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;&amp;nbsp;&lt;b&gt;신경계 역할&lt;/b&gt;&lt;/span&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot; data-sae=&quot;&quot; data-complete=&quot;true&quot; data-hveid=&quot;CAEIBBAD&quot;&gt;&lt;span data-complete=&quot;true&quot; data-sfc-cp=&quot;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;척수와 신경 다발&lt;/span&gt; :&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/b&gt;lib의 코드를 해석하여 화면을 렌더링하고 &lt;b&gt;OS에서 요구하는 규칙&lt;/b&gt;에 따라 명령을 실행&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc;&quot; data-hveid=&quot;CAEIBBAD&quot; data-complete=&quot;true&quot; data-sae=&quot;&quot;&gt;&lt;span data-sfc-cp=&quot;&quot; data-complete=&quot;true&quot;&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;뇌&lt;/b&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;&amp;rarr;&lt;/span&gt;운동 뉴런&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;&amp;rarr;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;근육 &lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;: &lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;span data-complete=&quot;true&quot; data-sfc-cp=&quot;&quot;&gt;lib에서 내린 명령을 플러터 엔진이 &lt;b&gt;직렬화(Dart 객체&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&amp;rarr;공통 메시지 포맷)&lt;/span&gt;&lt;/b&gt; 후 네이티브로 전달하여 실행&lt;/span&gt;&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc;&quot; data-hveid=&quot;CAEIBBAD&quot; data-complete=&quot;true&quot; data-sae=&quot;&quot;&gt;&lt;span data-sfc-cp=&quot;&quot; data-complete=&quot;true&quot;&gt;&lt;span data-sfc-cp=&quot;&quot; data-complete=&quot;true&quot;&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;감각 기관&lt;/span&gt;&lt;/b&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;&amp;rarr; 감각 뉴런&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;&amp;rarr;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;뇌&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;: &lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;span data-complete=&quot;true&quot; data-sfc-cp=&quot;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;각&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;기기에서 발생하는 네이티브 이벤트(터치, 입력 동작)를 플러터 엔진이 &lt;b&gt;역직렬화(네이티브 객체&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&amp;rarr;Dart 객체)&lt;/span&gt;&lt;/b&gt; 후 lib로 전달하여 처리&lt;/span&gt;&lt;span data-complete=&quot;true&quot; data-sfc-cp=&quot;&quot;&gt;&lt;b&gt;&lt;/b&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span data-complete=&quot;true&quot; data-sfc-cp=&quot;&quot;&gt;&lt;b&gt;android,&lt;span&gt;&amp;nbsp;&lt;/span&gt;ios&lt;span&gt;&amp;nbsp;&lt;/span&gt;디렉토리&lt;/b&gt;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-complete=&quot;true&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot; data-sae=&quot;&quot; data-complete=&quot;true&quot; data-hveid=&quot;CAEIBBAF&quot;&gt;&lt;span data-complete=&quot;true&quot; data-sfc-cp=&quot;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;플러터 엔진의 동작을&amp;nbsp;&lt;/span&gt;각 &lt;b&gt;OS가 요구하는 형식&lt;/b&gt;에 맞게 정의한 앱 구조(컨테이너)를 제공하여 네이티브 환경과 호환&lt;/span&gt;&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc;&quot; data-sae=&quot;&quot; data-complete=&quot;true&quot; data-hveid=&quot;CAEIBBAF&quot;&gt;&lt;span data-complete=&quot;true&quot; data-sfc-cp=&quot;&quot;&gt;Dart 객체와 네이티브 객체가 공통 메시지 포맷을 통해 직렬화/역직렬화될 수 있도록 구조적으로 지원&lt;/span&gt;&lt;span data-complete=&quot;true&quot; data-sfc-cp=&quot;&quot;&gt;&lt;b&gt;&lt;/b&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&amp;nbsp;&lt;span data-complete=&quot;true&quot; data-sfc-cp=&quot;&quot;&gt;&lt;b&gt;네이티브 OS&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot; data-start=&quot;192&quot; data-end=&quot;283&quot;&gt;
&lt;li data-start=&quot;192&quot; data-end=&quot;238&quot;&gt;애플리케이션을 실행하기 위해 &lt;b&gt;특정 형식과 규칙&lt;/b&gt;을 요구&lt;/li&gt;
&lt;li data-start=&quot;192&quot; data-end=&quot;238&quot;&gt;&lt;b&gt;Android :&lt;/b&gt; Activity가 엔트리 포인트인 &lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;APK 앱 패키지 구조와 Android용 네이티브 바이너리 규칙(.so) 요구&lt;/span&gt;&lt;/li&gt;
&lt;li data-start=&quot;239&quot; data-end=&quot;283&quot;&gt;&lt;b&gt;iOS :&lt;/b&gt; AppDelegate가 엔트리 포인트인 IPA 앱 번들 구조와 iOS용 네이티브 바이너리 규칙(Mach-O) 요구&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;네이티브 HW&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-start=&quot;239&quot; data-end=&quot;283&quot;&gt;&lt;span data-complete=&quot;true&quot; data-sfc-cp=&quot;&quot;&gt;lib의 명령을 실행하여 실제 네이티브 환경에서 카메라 렌즈를 움직이거나 화면에 빛을 내는&lt;span&gt;&amp;nbsp;&lt;/span&gt;물리적인 동작 수행&lt;/span&gt;&lt;/li&gt;
&lt;li data-start=&quot;239&quot; data-end=&quot;283&quot;&gt;&lt;span data-complete=&quot;true&quot; data-sfc-cp=&quot;&quot;&gt;네이티브에서 발생한 터치, 입력 등의 동작을 감지하고 이벤트(MotionEvent, UIEvent)를 생성하여 lib로 전달&lt;/span&gt;&lt;span data-complete=&quot;true&quot; data-sfc-cp=&quot;&quot;&gt;&lt;/span&gt;&lt;span data-complete=&quot;true&quot; data-sfc-cp=&quot;&quot;&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote style=&quot;color: #666666; text-align: left;&quot; data-ke-style=&quot;style2&quot;&gt;&lt;b&gt;네이티브 &amp;rarr; lib 방향&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/b&gt;: &lt;br /&gt;수신 측 런타임인 플러터 엔진은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;네이티브 객체 &amp;rarr; Dart 객체 직접 변환을 지원&lt;/span&gt;하므로&lt;span&gt;&lt;br /&gt;송신 측인 &lt;b&gt;네이티브에서&lt;/b&gt; &lt;b&gt;공통 메시지 포맷으로&lt;/b&gt;&lt;/span&gt;&lt;b&gt;&amp;nbsp;변환할 필요 없이 데이터 전달 가능&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;lib &amp;rarr; 네이티브 방향&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/b&gt;: &lt;br /&gt;수신 측 런타임인 네이티브(Kotlin/Swift)는&lt;span&gt; &lt;/span&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;&amp;nbsp;Dart 객체 &amp;rarr; 네이티브 객체 직접 변환을 지원하지 않으므로&lt;/span&gt;&lt;span&gt; &lt;br /&gt;송신 측인 &lt;b&gt;플러터 엔진에서 &lt;/b&gt;&lt;/span&gt;&lt;b&gt;Dart 객체 &amp;rarr; 공통 메시지 포맷 직렬화 후 데이터 전달 필요&lt;br /&gt;&lt;/b&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;플러터 프로젝트의 네이티브 제어 흐름 예시&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #000000;&quot;&gt;1. lib에서 명령 호출&lt;/span&gt;&lt;/b&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&amp;nbsp; &amp;nbsp; - 사진 촬영이 필요한 기능에서 camera.takePicture() 함수 호출&lt;/span&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;&lt;span style=&quot;color: #000000;&quot;&gt;2. 직렬화&lt;/span&gt;&lt;/b&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&amp;nbsp; &amp;nbsp; - 플러터 엔진이 함수를 공통 메시지 포맷으로 직렬화&lt;b&gt;(Dart 객체 &amp;rarr; 바이너리)&lt;br /&gt;&lt;br /&gt;3. 플랫폼 채널을 통한 데이터 전송&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&amp;nbsp; &amp;nbsp; - 직렬화된 메시지를 플랫폼 채널을 통해 Android/iOS 네이티브로 전달&lt;/span&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;&lt;span style=&quot;color: #000000;&quot;&gt;4. 네이티브에서 실행&lt;/span&gt;&lt;/b&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&amp;nbsp; &amp;nbsp; - OS가 메시지를 받아 카메라 API 호출&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&amp;nbsp; &amp;nbsp; - 카메라 앱 실행 및 하드웨어 구동&lt;/span&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;&lt;span style=&quot;color: #000000;&quot;&gt;5. 플랫폼 채널을 통한 데이터 반환&lt;/span&gt;&lt;/b&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&amp;nbsp; &amp;nbsp; - OS는 촬영한 이미지 데이터를 플랫폼 채널로 전송&lt;/span&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;&lt;span style=&quot;color: #000000;&quot;&gt;6.역직렬화&lt;/span&gt;&lt;/b&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&amp;nbsp; &amp;nbsp; - 플러터 엔진은 전달받은 이미지 데이터를 역직렬화&lt;b&gt;(바이너리 &amp;rarr; Dart 객체)&lt;br /&gt;&lt;br /&gt;7. lib에서 처리&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&amp;nbsp; &amp;nbsp; - 네이티브에서 전달받은 이미지 데이터를 Dart 코드로 처리&lt;br /&gt;&lt;br /&gt;&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR'; color: #333333; text-align: center;&quot;&gt;각 네이티브 환경과 호환되도록 설계된 플러터 엔진과,&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Noto Serif KR'; color: #333333; text-align: center;&quot;&gt;이 엔진이&lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Serif KR'; color: #333333; text-align: center;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Serif KR'; color: #333333; text-align: center;&quot;&gt;네이티브 프로젝트 내부에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;font-family: 'Noto Serif KR'; color: #333333; text-align: center;&quot;&gt;실행되도록 지원하는 프로젝트 구조가&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Noto Serif KR'; color: #333333; text-align: center;&quot;&gt;플러터 프레임워크가 제공하는 크로스 플랫폼의 핵심이다.&lt;br /&gt;&lt;br /&gt;&lt;/span&gt;&lt;/span&gt;&lt;/blockquote&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;프로젝트 세팅&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;pubspec.yaml 패키지 추가&lt;/h3&gt;
&lt;pre id=&quot;code_1772181541195&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;name: conflic_ai
description: &quot;AI 상담 및 갈등 해결 서비스 앱&quot;
publish_to: 'none'

version: 1.0.0+1

environment:
  sdk: ^3.11.0

dependencies:
  flutter:
    sdk: flutter

  # 상태관리
  flutter_riverpod: ^2.5.1

  # 네트워크 (AI API 호출)
  dio: ^5.4.0

  # 로컬 저장 (토큰/설정)
  shared_preferences: ^2.2.2

  # UI
  cupertino_icons: ^1.0.8
  flutter_svg: ^2.2.3

  # 모델 비교
  equatable: ^2.0.8
  
  # 하드웨어 제어
  speech_to_text: ^7.0.0
  image_picker: ^1.1.2

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^6.0.0

flutter:
  uses-material-design: true&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앱 개발에 필수적인 기본 라이브러리 의존성을 추가한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;상태관리 - riverpod&lt;/li&gt;
&lt;li&gt;네트워크 통신 - dio&lt;/li&gt;
&lt;li&gt;UI - cupertino, flutter_svg&lt;/li&gt;
&lt;li&gt;하드웨어 제어 (필수는 아님) - STT, image_picker&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;프로젝트 실행&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Android 에뮬레이터보다 빠르게 돌아가는 iOS 시뮬레이터로 프로젝트를 실행해보자.&lt;/p&gt;
&lt;pre id=&quot;code_1771742876129&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;open -a Simulator
flutter run&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cAQzGK/dJMb996t4lH/sY0KR5oLz88i52oZhmVYLk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cAQzGK/dJMb996t4lH/sY0KR5oLz88i52oZhmVYLk/img.png&quot; data-origin-width=&quot;1266&quot; data-origin-height=&quot;964&quot; data-is-animation=&quot;false&quot; style=&quot;width: 73.6421%; margin-right: 10px;&quot; data-widthpercent=&quot;74.51&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cAQzGK/dJMb996t4lH/sY0KR5oLz88i52oZhmVYLk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcAQzGK%2FdJMb996t4lH%2FsY0KR5oLz88i52oZhmVYLk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1266&quot; height=&quot;964&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bulTRW/dJMcaaqMXNG/SiMlSkuJ8k6GiUYCuUckck/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bulTRW/dJMcaaqMXNG/SiMlSkuJ8k6GiUYCuUckck/img.png&quot; data-origin-width=&quot;718&quot; data-origin-height=&quot;1598&quot; data-is-animation=&quot;false&quot; style=&quot;width: 25.1951%;&quot; data-widthpercent=&quot;25.49&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bulTRW/dJMcaaqMXNG/SiMlSkuJ8k6GiUYCuUckck/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbulTRW%2FdJMcaaqMXNG%2FSiMlSkuJ8k6GiUYCuUckck%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;718&quot; height=&quot;1598&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 pubspec.yaml에 등록된 외부 패키지를 다운받은 후 시뮬레이터 위에서 앱의 진입점인 main.dart의 카운터가 실행되는 것을 볼 수 있다.&lt;/p&gt;</description>
      <category>dev/app</category>
      <author>cusum26</author>
      <guid isPermaLink="true">https://imjyh01.tistory.com/13</guid>
      <comments>https://imjyh01.tistory.com/13#entry13comment</comments>
      <pubDate>Sat, 28 Feb 2026 01:50:23 +0900</pubDate>
    </item>
    <item>
      <title>flutter - 플러터로 크로스 플랫폼 앱 개발하기(2)</title>
      <link>https://imjyh01.tistory.com/14</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;본격적인 프로젝트 개발에 앞서 플러터의 필수 개념에 대해 알아보자.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;플러터 아키텍쳐&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;950&quot; data-origin-height=&quot;982&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bC0H09/dJMcahKiran/Il9Y1gMZ91H2uYkbLjdcB1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bC0H09/dJMcahKiran/Il9Y1gMZ91H2uYkbLjdcB1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bC0H09/dJMcahKiran/Il9Y1gMZ91H2uYkbLjdcB1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbC0H09%2FdJMcahKiran%2FIl9Y1gMZ91H2uYkbLjdcB1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;620&quot; data-origin-width=&quot;950&quot; data-origin-height=&quot;982&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span&gt;플러터 프로젝트의 구조를 나름 비유하자면 다음과 같다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span&gt;무한하게 상상할 수 있는 Framework가 특정 규칙에 따라 상상력을 전기적 신호로 표현할 수 있는 Engine이라는 뇌로 실체화되고, Embedder를 통해 Native까지 전달된 뇌의 전기적 신호가 실제 물리적 움직임으로 구체화된다...&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;추상화된 하나의 뇌로 네이티브라는 서로 천차만별인 신체를 적절하게 제어하기 위해서는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;뇌의 신경이&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;각 신체 전용 제어 구조로 연결돼야 한다. 이때 Embedder가 각 몸뚱아리 전용 제어 틀을 이 추상화된 뇌에게 제공하는 역할을 한다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;Embedder&lt;/span&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1000&quot; data-origin-height=&quot;562&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bbiKcm/dJMcaiWGqTa/kzSjv3FYVl3BC7wypmJsXK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bbiKcm/dJMcaiWGqTa/kzSjv3FYVl3BC7wypmJsXK/img.png&quot; data-alt=&quot;세레브로&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bbiKcm/dJMcaiWGqTa/kzSjv3FYVl3BC7wypmJsXK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbbiKcm%2FdJMcaiWGqTa%2FkzSjv3FYVl3BC7wypmJsXK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;400&quot; height=&quot;225&quot; data-origin-width=&quot;1000&quot; data-origin-height=&quot;562&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;세레브로&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;플러터로 만든 앱이 기기에서 실행되려면 각 네이티브 환경의 인터페이스 규칙을 따르도록 설계되어야 한다.&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;임베더는&amp;nbsp;&lt;/span&gt;플러터 엔진이라는 뇌를&lt;span&gt; 크로스 플랫폼으로 동작시키기 위해 네이티브와의 연결부를 담당한다. &lt;/span&gt;&lt;/span&gt;영화 엑스맨에 나오는 세레브로라는 기계처럼 뇌와 기기를 연결하는 틀을 제공한다고 볼 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;임베더는&amp;nbsp;&lt;b&gt;각 OS별 네이티브 프로젝트 &lt;/b&gt;&lt;b&gt;구조를 제공&lt;/b&gt;하여&lt;span style=&quot;color: #0a0a0a;&quot;&gt;&amp;nbsp;플러터 엔진이 각 OS 런타임 위에서 문제없이 실행될 수 있게 한다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #353638; text-align: left;&quot; data-end=&quot;439&quot; data-start=&quot;346&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot; data-end=&quot;391&quot; data-start=&quot;346&quot;&gt;Android: Gradle, manifest, Java/Kotlin 코드&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc;&quot; data-end=&quot;439&quot; data-start=&quot;394&quot;&gt;iOS: Xcode 프로젝트, Info.plist, Swift/Obj-C 코드&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;플러터 엔진은 이&lt;/span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;디렉토리에서 정의된 규칙과 구조를 통해 Dart 코드로 네이티브 코드와 통신할 수 있고, OS가 제공하는 네이티브 API를 호출할 수 있다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;플러터로 만든&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;앱이 임베더를 매개로&lt;/span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/b&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&lt;b&gt;네이티브 앱으로서 실행되고, 그 런타임 내부에서 플러터 엔진을 작동&lt;/b&gt;시킴으로써 OS를 통해 기기&lt;/span&gt;&lt;/span&gt;(카메라, 블루투스, GPS, 센서 등)를 제어하고 그 결과를 다시 전달받아 처리할 수 있다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Engine&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span&gt;플러터 엔진은 다음과 같은 역할을 담당한다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;&lt;b&gt;Skia 자체 렌더링 (화가 역할)&lt;/b&gt;&lt;br /&gt;플러터 엔진은 각 기기의 OS가 제공하는 네이티브 UI를 빌려 쓰는 것이 아니라 스키아(Skia) 또는 임펠러(Impeller)라는 강력한 그래픽 엔진을 자체적으로 탑재하고 있다. 따라서 부드러운 화면을 직접 그려내며&lt;span&gt;&amp;nbsp;&lt;/span&gt;OS와 상관없이&amp;nbsp;동일한 UI/UX를 보장한다.&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc;&quot; data-processed=&quot;true&quot; data-complete=&quot;true&quot; data-hveid=&quot;CAEIBhAA&quot;&gt;&lt;b&gt;다트 런타임 (통역사 역할)&lt;/b&gt;&lt;br /&gt;lib에 작성된 Dart 코드를 실시간으로 실행하고 관리한다. 개발 환경에서 핫 리로드(Hot Reload)를 지원해 수정한 코드를 즉시 화면에 반영하고 배포 시에는 기계어로 변환해 네이티브 앱에 가까운 고성능을 보장한다.&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc;&quot; data-processed=&quot;true&quot; data-complete=&quot;true&quot; data-hveid=&quot;CAEIBxAA&quot;&gt;&lt;b&gt;플랫폼 채널 관리 (통로 역할)&lt;/b&gt;&lt;br /&gt;lib의 코드와 네이티브 환경 사이에서 데이터를 전달하는 &lt;b&gt;통로(플랫폼 채널)를 관리&lt;/b&gt;한다. lib에 작성된 카메라, 블루투스, 사진 접근과 같은 명령을 각 OS에&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;바이너리로 직렬화하여 전달&lt;/b&gt;하고, 네이티브로부터 전달받는 데이터를&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Dart로 역직렬화여 lib로 전달&lt;/b&gt;한다.&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc;&quot; data-processed=&quot;true&quot; data-complete=&quot;true&quot; data-hveid=&quot;CAEIBxAA&quot;&gt;&lt;b&gt;파일, 네트워크 IO (문지기 역할)&lt;br /&gt;&lt;/b&gt;플러터 엔진은 앱에서 발생하는 &lt;b&gt;파일 읽기&amp;middot;쓰기, 네트워크 요청&lt;/b&gt;&lt;span style=&quot;color: #333333; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;과 같은 입출력 작업을 관리한다.&lt;/span&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot; data-processed=&quot;true&quot; data-complete=&quot;true&quot; data-hveid=&quot;CAEIBxAA&quot;&gt;&lt;b&gt;파일 I/O&lt;/b&gt;: 로컬 저장소(앱 내부, 외부 저장소 등)에서 데이터를 읽고 쓰는 작업을 처리한다. 예를 들어, 앱 설정 저장, 이미지 다운로드 후 캐싱 등은 플러터 엔진이 OS별 파일 시스템 접근을 중재하여 안전하게 수행한다.&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc;&quot; data-processed=&quot;true&quot; data-complete=&quot;true&quot; data-hveid=&quot;CAEIBxAA&quot;&gt;&lt;b&gt;네트워크 I/O&lt;/b&gt;: HTTP 요청, 소켓 통신 등 네트워크 연동을 담당한다. Dart 코드에서 API 호출을 하면 플러터 엔진이 OS 네트워크 스택과 연결하여 데이터를 주고받고, 응답을 다시 Dart 코드로 전달한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;위젯&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위젯은 화면 표시에 있어서 가장 기본적인 단위로 StatelessWidget, StatefulWidget, InheritedWidget 세 종류가 존재한다. 아주 대표적인 앱의 홈 화면에서 각 위젯울 찾아보고 어떤 느낌인지 감을 잡자.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1170&quot; data-origin-height=&quot;2532&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/d6Bbsp/dJMcaaEo6kJ/C0W5Q0TiBzTEMuMIYFQg00/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/d6Bbsp/dJMcaaEo6kJ/C0W5Q0TiBzTEMuMIYFQg00/img.png&quot; data-alt=&quot;무신사 앱 UI&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/d6Bbsp/dJMcaaEo6kJ/C0W5Q0TiBzTEMuMIYFQg00/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fd6Bbsp%2FdJMcaaEo6kJ%2FC0W5Q0TiBzTEMuMIYFQg00%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;300&quot; height=&quot;649&quot; data-origin-width=&quot;1170&quot; data-origin-height=&quot;2532&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;무신사 앱 UI&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;StatelessWidget&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;134&quot; data-origin-height=&quot;60&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cDNeA8/dJMb99S0g52/seqLPRUWDmwMyKVK9eDyWK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cDNeA8/dJMb99S0g52/seqLPRUWDmwMyKVK9eDyWK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cDNeA8/dJMb99S0g52/seqLPRUWDmwMyKVK9eDyWK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcDNeA8%2FdJMb99S0g52%2FseqLPRUWDmwMyKVK9eDyWK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;134&quot; height=&quot;60&quot; data-origin-width=&quot;134&quot; data-origin-height=&quot;60&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;StatefulWidget&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;614&quot; data-origin-height=&quot;743&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/RrFrH/dJMcachUi3R/khwW2nCJLb5WcJLDop4hA1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/RrFrH/dJMcachUi3R/khwW2nCJLb5WcJLDop4hA1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/RrFrH/dJMcachUi3R/khwW2nCJLb5WcJLDop4hA1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FRrFrH%2FdJMcachUi3R%2FkhwW2nCJLb5WcJLDop4hA1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;300&quot; height=&quot;393&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;614&quot; data-origin-height=&quot;743&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;InheritedWidget&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;70&quot; data-origin-height=&quot;64&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/3Kfmf/dJMcaih6JyH/kf8nV1fW4nOdcnpKlFUYcK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/3Kfmf/dJMcaih6JyH/kf8nV1fW4nOdcnpKlFUYcK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/3Kfmf/dJMcaih6JyH/kf8nV1fW4nOdcnpKlFUYcK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F3Kfmf%2FdJMcaih6JyH%2Fkf8nV1fW4nOdcnpKlFUYcK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;70&quot; height=&quot;64&quot; data-origin-width=&quot;70&quot; data-origin-height=&quot;64&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 위젯은 화면의 알맹이가 되는 정보 전달용 위젯으로 statefulWidget이 담는 컨텐츠(데이터)를 뿌려주는 데이터 바구니라고 생각하면 편하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 위젯은 이런 구조로 구현할 수 있을 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CartProvider(InheritedWidget) &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;larr; 장바구니 데이터 뿌려주기&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; &amp;nbsp;└─ CartIcon (StatelessWidget)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; └─ ItemCountBadge (StatefulWidget) &amp;larr; 장바구니 숫자 받아오기&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Inherited를 통한 State의 전달&lt;/h4&gt;
&lt;pre id=&quot;code_1772594355856&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;상위 StatefulWidget (State 보유)
        └── MyInheritedWidget (State 포함)
                 └── 하위 StatefulWidget (context 통해 State 접근)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하위 StatefulWidget가 받는 State 변경 정보에 대한 데이터 소스는 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;위젯 필드 이용(부모가 전달) - 생성자에서 부모 필드에 직접 접근해서 받아옴 : didUpdateWidget()으로 감지 : 자동으로 리빌드&lt;/li&gt;
&lt;li&gt;InheritedWidget 이용(트리 기반 공유) - context 기반 전역, 상위 상태를 감지해서 받아옴 : 자동으로 리빌드&lt;/li&gt;
&lt;li&gt;일반 객체 (가장 흔함) - animation 같이 변경될 수 있는 값을 가진 위젯이 아닌 객체가 State에 존재 : 자동 rebuild 안 됨 &amp;rarr; setState를 따로 호출해야 반영됨&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;InheritedWidget이 상위 위젯 트리에서 데이터를 받아오는 거면 어차피 부모-자식으로 전달되는 위젯 필드로 충분히 전달 가능한데 InheritedWidget이 굳이 왜 필요한지 의문이 들 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;= 어차피 상위 트리에서 내려오는 거면 공통 부모에서 받아서 위젯 필드로 내려주면 되는 거 아닌가&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 충분히 가능하지만 규모가 커지면 다음과 같은 Prop Drilling 문제가 생긴다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1214&quot; data-origin-height=&quot;1154&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dffnKa/dJMcacWt8cA/ckjD4nK8NizVb4eOCBMwFK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dffnKa/dJMcacWt8cA/ckjD4nK8NizVb4eOCBMwFK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dffnKa/dJMcacWt8cA/ckjD4nK8NizVb4eOCBMwFK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdffnKa%2FdJMcacWt8cA%2FckjD4nK8NizVb4eOCBMwFK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;400&quot; height=&quot;380&quot; data-origin-width=&quot;1214&quot; data-origin-height=&quot;1154&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;State를 보유한 상위 StatefulWidget이 InheritedWidget에 담아 State를 전달하고 하위 StatefulWidget에서 해당 State를 참조하는 구조는 분명 직관적이다. 그러나 이런 구조는 View를 담당하는 StatefulWidget이 Model에 해당하는 State 변경 로직까지 책임진다는 점에서 &lt;b&gt;UI와 비즈니스 로직이 강하게 결합&lt;/b&gt;되어있다고 볼 수 있다. 이는 &lt;b&gt;위젯 중심의 선언형 UI로 MVVC를 지향하는 플러터의 컨셉과 배치&lt;/b&gt;된다. 이에 따라 Model과 View의 책임 분리를 제공하는 Provider 구조가 등장했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Provider&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본적으로 Provider는 위젯 간 State 전달을 담당하는 라이브러리이다. 앞서 언급한 InheritedWidget의 데이터 전달 구조와의 차이점은 다음과 같다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1552&quot; data-origin-height=&quot;688&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ZJH6T/dJMcafFHlnC/wDzomim1r7T9BO5kdljvXk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ZJH6T/dJMcafFHlnC/wDzomim1r7T9BO5kdljvXk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ZJH6T/dJMcafFHlnC/wDzomim1r7T9BO5kdljvXk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FZJH6T%2FdJMcafFHlnC%2FwDzomim1r7T9BO5kdljvXk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;266&quot; data-origin-width=&quot;1552&quot; data-origin-height=&quot;688&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;플러터에는 Provider를 포함하여 Bloc, Riverpod, getX 등 상태 관리와 전달을 담당하는 여러 패턴, 라이브러리가 존재한다. 이에 관해선 다음 시간에 더 자세히 알아보자.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;라이프사이클&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라이프사이클은 위젯이 생성되고 삭제, 종료되는 과정에서 발생하는 여러가지 이벤트들의 호출 순서이다. 기본적으로 화면을 담당하는 Widget은 애플리케이션 내내 발생하는 여러 이벤트들에 따라 충분히 변경되거나 삭제, 재생성될 수 있고, 이 결과로 화면은 바뀌거나 안 바뀔 수도 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;StatelessWidget&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot; data-token-index=&quot;0&quot;&gt;StatelessWidget은 기본적으로 상태가 존재하지 않으므로 생성 ~ 삭제 사이에 변경이 없다(immutable). 따라서 처음에 한 번 빌드되면 이후 재빌드 시 UI 변화가 없는 경우가 많다.&lt;/span&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #000000;&quot; data-token-index=&quot;0&quot;&gt;라이프사이클&lt;/span&gt;&lt;/h4&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;StatelessWidget&lt;/b&gt; &lt;b&gt;생성자&lt;/b&gt; : immutable &amp;rarr; final&lt;/li&gt;
&lt;li&gt;&lt;b&gt;createElement()&lt;/b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt; : 렌더링 메타데이터 생성(build때 쓰임)&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;build()&lt;/b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt; : 자식 위젯트리 반환 = ui 렌더링&lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;StatefulWidget&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상태를 담고있는 StatefulWidget은 기본적으로 생성 ~ 삭제 사이에 상태가 쉴 새 없이 변경된다. 따라서 State라는 별도 객체(위젯이 아님)로 상태를 분리한 채로 존재하며 빌드를 포함한 상태 관리는 전부 State에 위임한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;StatefulWidget 클래스:&lt;/b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt; 상태를 가지는 위젯&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;State 클래스:&lt;/b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt; 위젯의 상태를 관리하는 메서드를 제공하는 클래스 (위젯이 아님)&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Widget 내부에는 immutable한 최소 값만 남기고 변경될 값들은 따로 State 객체에 빼둠으로써 build()함수가 State에 존재한다. 따라서 StatefulWidget은 부모 위젯이 재빌드될 때에나 생명주기에 영향이 가고, 상태 변경은 위젯의 생명주기와 독립적으로 이뤄진다.(State의 setSate() 함수에 의존)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러나 여기서 놓치기 쉬운 점이 있는데&lt;b&gt; StatefulWidget이 State를 보유하며 setState 직접 호출이 가능하긴 하지만 State 인스턴스 자체는 완전히 독립적으로 존재하며 서로 참조하는 구조&lt;/b&gt;라는 것이다. 즉 &lt;span style=&quot;background-color: #f6e199;&quot;&gt;기존 인스턴스가 사라지고 새로운 위젯 인스턴스가 생성되더라도 기존 State 인스턴스는 유지될 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1276&quot; data-origin-height=&quot;816&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bcfb70/dJMcafloncX/glpZjWhMUMp4mKzDvwYZS0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bcfb70/dJMcafloncX/glpZjWhMUMp4mKzDvwYZS0/img.png&quot; data-alt=&quot;StatefulWidget과 State의 관계&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bcfb70/dJMcafloncX/glpZjWhMUMp4mKzDvwYZS0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbcfb70%2FdJMcafloncX%2FglpZjWhMUMp4mKzDvwYZS0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;384&quot; data-origin-width=&quot;1276&quot; data-origin-height=&quot;816&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;StatefulWidget과 State의 관계&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 구조로 다음을 알 수가 있다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;State가 유지되는 동안 참조하는 Widget 인스턴스는 여러 번 바뀔 수 있다 - State내부 widget 필드의 값만 변경&lt;/li&gt;
&lt;li&gt;StatefulWidget 내부의 key, runtimeType이라는 두 개 필드가 참조할 State를 유일하게 결정하고 이 필드는 불변이다 - 위젯 인스턴스가 유지되는 동안 한 State만 가질 수 있음&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;라이프사이클&lt;/h4&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;StatefulWidget 생성자 : immutable &amp;rarr; final (statefulwidget에 생성자로 전달받는 immutable한 필드는 위젯트리 계산에 사용)&lt;/li&gt;
&lt;li&gt;&lt;span data-token-index=&quot;0&quot;&gt;createState() - 한 번만 : &lt;/span&gt;위젯의&lt;span data-token-index=&quot;2&quot;&gt; &lt;/span&gt;상태를 구성하는 값들을 관리하는 State 객체 생성&lt;/li&gt;
&lt;li&gt;&lt;span data-token-index=&quot;0&quot;&gt;initState() - 한 번만 : &lt;/span&gt;State 객체의 필드 초기화 - context 접근 불가능 = inheritedWidget의 데이터 사용 불가
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;변수 초기화&lt;/li&gt;
&lt;li&gt;AnimationController 생성&lt;/li&gt;
&lt;li&gt;Stream 구독 시작&lt;/li&gt;
&lt;li&gt;API 호출 시작&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;didChangeDependencies() - 한 번만&lt;b&gt; :&lt;/b&gt; context 의존성 반영 - context 접근 가능
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Theme (앱의 전체 디자인 설정) &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;변경 반영&amp;nbsp;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;다크모드 &amp;harr; 라이트모드&lt;/li&gt;
&lt;li&gt;primaryColor 변경&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;MediaQuery(디바이스 환경 정보) 변경 반영
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;화면 회전 (가로/세로)&lt;/li&gt;
&lt;li&gt;키보드 올라옴&lt;/li&gt;
&lt;li&gt;화면 크기 변경&lt;/li&gt;
&lt;li&gt;시스템 폰트 크기 변경&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Provider 값 변경 반영
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;구독 중인 InheritedWidget의 데이터&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;build() - 여러 번 : 변경된 설계도로&lt;/b&gt; 화면 렌더링 - 그려야하는 데이터가 변하면 무조건 호출해서 화면에 반영
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;생성 직후 호출&lt;/li&gt;
&lt;li&gt;부모 변경 시 호출&lt;/li&gt;
&lt;li&gt;setState 이후 호출&lt;/li&gt;
&lt;li&gt;의존성 변경 시 호출&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span data-token-index=&quot;0&quot;&gt;&lt;b&gt;setState() - 여러 번&lt;/b&gt; : S&lt;/span&gt;tate 인스턴스의 필드 값을 변경하고 다음 프레임에 build를 실행시키는 트리거 함수
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;위젯과 State 인스턴스 자체는 그대로 유지, 값만 변경&lt;/li&gt;
&lt;li&gt;상태변경 로직을 안에 넣어서 사용 권장&lt;/li&gt;
&lt;li&gt;라이프사이클에 포함되지 않음&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;didUpdateWidget() - 여러 번 :&lt;/b&gt; 새 위젯 인스턴스가 생성된 상황에서 State의 기존 인스턴스는 유지하며 참조하는 위젯만 변경
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;AnimationController를 새 설정값으로 재구성할 때&lt;/li&gt;
&lt;li&gt;TextEditingController를 부모로부터 동기화할 때&lt;/li&gt;
&lt;li&gt;부모 리빌드에 의해 외부에서 전달받은 값이 바뀌었을 때 setState()로 내부 상태 업데이트 필요&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;dispose()- 한번만 &lt;b&gt;:&lt;/b&gt; 메모리 누수 방지하며 위젯 종료
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;진행 중인 작업 취소&lt;/li&gt;
&lt;li&gt;리소스 해제&lt;/li&gt;
&lt;li&gt;상태 정리&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;didUpdateWidget() 예시&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bfSTr8/dJMcagkhRpq/2Qxf5dFB1eMpidPcmBlHGK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bfSTr8/dJMcagkhRpq/2Qxf5dFB1eMpidPcmBlHGK/img.png&quot; width=&quot;300&quot; height=&quot;649&quot; data-origin-width=&quot;1170&quot; data-origin-height=&quot;2532&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.4186%; margin-right: 10px;&quot; data-widthpercent=&quot;50&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bfSTr8/dJMcagkhRpq/2Qxf5dFB1eMpidPcmBlHGK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbfSTr8%2FdJMcagkhRpq%2F2Qxf5dFB1eMpidPcmBlHGK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1170&quot; height=&quot;2532&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kLwt6/dJMcafeE8vc/ukDnpaut1OWBKXr2SudR81/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kLwt6/dJMcafeE8vc/ukDnpaut1OWBKXr2SudR81/img.png&quot; data-origin-width=&quot;1170&quot; data-origin-height=&quot;2532&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.4186%;&quot; data-widthpercent=&quot;50&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kLwt6/dJMcafeE8vc/ukDnpaut1OWBKXr2SudR81/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkLwt6%2FdJMcafeE8vc%2FukDnpaut1OWBKXr2SudR81%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1170&quot; height=&quot;2532&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위와 같은 캘린더 화면을 분석해보자.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;StatefulWidget&lt;/b&gt;: 일정 정보를 보여주는 위젯&lt;/li&gt;
&lt;li&gt;&lt;b&gt;State&lt;/b&gt;: 제목, 시작/종료 시간 같은 &lt;b&gt;상태 데이터&lt;/b&gt;를 보유&lt;/li&gt;
&lt;li&gt;&lt;b&gt;InheritedWidget&lt;/b&gt;: 일정 정보를 갖고 있는 데이터 소스&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;다음은&lt;b&gt; 23일에서 27일로 날짜를 옮겼을 때 화면이 변경&lt;/b&gt;되는 과정이다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;1. 날짜별 일정 정보를 담고 있는 InheritedWidget이 바뀜 (23일 &amp;rarr; 27일)&lt;/span&gt;&lt;br /&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;2. 27일에 대한 새 StatefulWidget 인스턴스가 생성됨&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&amp;nbsp; &amp;nbsp; - 새 Widget 인스턴스가 메모리에 만들어짐&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&amp;nbsp; &amp;nbsp; - Flutter 엔진이 새 인스턴스의 runtimeType과 key를 기존 Widget과 비교&lt;/span&gt;&lt;br /&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;3. State 인스턴스는 제목, 시작/종료 시간으로 동일 형식 = runtimeType과 key가 기존 위젯과 동일 &amp;rarr; &lt;b&gt;&lt;b&gt;기존 State 객체 유지&lt;br /&gt;&lt;/b&gt;&lt;/b&gt;&lt;span style=&quot;letter-spacing: 0px; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif;&quot;&gt;&amp;nbsp; &amp;nbsp; - 기존 State 객체와 새 Widget 연결&lt;br /&gt;&lt;/span&gt;&lt;span style=&quot;letter-spacing: 0px; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif;&quot;&gt;&amp;nbsp; &amp;nbsp; - State 객체의 widget 필드가 새 Widget 참조로 갱신&lt;/span&gt;&lt;/span&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&amp;nbsp; &amp;nbsp; - 이전 Widget 인스턴스는 oldWidget으로 전달&lt;br /&gt;&lt;br /&gt;4. &lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;didUpdateWidget(oldWidget) 호출&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&amp;nbsp; &amp;nbsp; - &lt;/span&gt;&lt;span style=&quot;color: #000000; letter-spacing: 0px; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif;&quot;&gt;oldWidget = 이전 StatefulWidget&lt;br /&gt;&amp;nbsp; &amp;nbsp; - &lt;/span&gt;&lt;span style=&quot;color: #000000; letter-spacing: 0px; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif;&quot;&gt;widget = 새 StatefulWidget&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;5. setState() 호출&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&amp;nbsp; &amp;nbsp; - State 내부 필드 값(일정 정보) 갱신&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;br /&gt;6. &lt;/span&gt;&lt;span style=&quot;color: #000000;&quot;&gt;build() 호출&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&amp;nbsp; &amp;nbsp; - 새 Widget 데이터 + 기존 State 구조를 사용해 UI 갱신&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;레이아웃 위젯&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 위젯에서 인식하는 제스처를 기준으로 분류하면 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;터치/클릭 : Container, Row&amp;amp;Column, Expanded, Stack, Positioned, SizedBox, TabBar&lt;/li&gt;
&lt;li&gt;스크롤 : ListView, GridView&lt;/li&gt;
&lt;li&gt;스와이프/슬라이드 : PageView&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;터치/클릭&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Container&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199; color: #000000;&quot;&gt;사각형 상자&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음과 같은 옵션이 존재한다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;padding: 내부 여백&lt;br /&gt;height : 높이&lt;br /&gt;width: 너비&lt;br /&gt;color: 배경색&lt;br /&gt;child: 자식 위젯에 적용될 설정&lt;br /&gt;decoration: 컨테이너의 외형(모양, 배경, 테두리 등)&lt;/blockquote&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Row &amp;amp; Column&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199; color: #000000;&quot;&gt;Row : 가로 레이아웃&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199; color: #000000;&quot;&gt;Column : 세로 레이아웃&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;다음과 같은 옵션이 존재한다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;mainAxisAlignment : 각 레이아웃 방향으로 정렬 &lt;br /&gt;crossAxisAlignment : 각 레이아웃 수직 방향으로 정렬 (부모 위젯의 높이, 너비 기준- 설정 필요) &lt;br /&gt;children : 정렬될 요소들을 리스트 형태로 화면에 출력 ( children : List.generate(&amp;hellip;) )&lt;/blockquote&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Expanded&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199; color: #000000;&quot;&gt;Row와 Column 안에서 남은 공간을 채우도록 확장하는 자식 위젯&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Expanded = Row/Column의 자식&lt;/li&gt;
&lt;li&gt;기존 자식 레이아웃은 변경되지 않음&lt;/li&gt;
&lt;li&gt;부모 위젯(Row/Column)의 공간을 분할/채움&lt;/li&gt;
&lt;li&gt;flex 속성으로 비율 조절 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Stack&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199; color: #000000;&quot;&gt;여러 위젯이 서로 겹쳐 배치된 위젯&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음과 같은 옵션이 존재한다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;fit:&amp;nbsp;&lt;br /&gt;&amp;nbsp; - loose : 자식 위젯의 크기를 스택 위젯 크기에 맞게 자유롭게 배치 가능&lt;br /&gt;&amp;nbsp; - expand: 자식 위젯이 스택 위젯과 동일한 크기 고정&lt;br /&gt;&amp;nbsp; - passthrough : 자식 위젯이 스택 위젯의 크기와 위치를 무시하고 자유롭게 배치 가능&lt;br /&gt;&lt;br /&gt;children:&lt;br /&gt;&amp;nbsp; - children= [ widget1, widget2 &amp;hellip;.] 로 정의 = List&amp;lt;Widget&amp;gt; 타입&lt;br /&gt;&amp;nbsp; - 선언되는 순서로 z-index 조절&lt;br /&gt;&amp;nbsp; - 스택: 밑에서부터 쌓아올림 = 먼저 선언된 자식 위젯이 밑에 배치&lt;/blockquote&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Positioned&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199; color: #000000;&quot;&gt;스택 내부에서 하위 위젯의 위치 설정을 제공하는 위젯&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;span style=&quot;background-color: #fcfcfc; text-align: left;&quot;&gt;부모 위젯을 기준으로&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;background-color: #fcfcfc; text-align: left;&quot;&gt;절대 위치를 설정하는 다음 옵션이 존재한다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;left&lt;br /&gt;top&lt;br /&gt;right&lt;br /&gt;bottom&amp;nbsp;&lt;/blockquote&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;SizedBox&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000; background-color: #f6e199;&quot;&gt;고정 크기 상자&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;아이템 간 간격 설정에 용이 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(Figma에서 그루핑하면 간격 일정하게 조절 가능한게 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;SizedBox로 선택되는 거 같음&lt;/span&gt;)&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;TabBar&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;탭 메뉴를 통한 화면 이동을 제공하는 위젯&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1170&quot; data-origin-height=&quot;268&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/d4GYel/dJMcad17QWt/nStDKirUXiQeaTb11HoCaK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/d4GYel/dJMcad17QWt/nStDKirUXiQeaTb11HoCaK/img.png&quot; data-alt=&quot;Toss앱의 탭 메뉴&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/d4GYel/dJMcad17QWt/nStDKirUXiQeaTb11HoCaK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fd4GYel%2FdJMcad17QWt%2FnStDKirUXiQeaTb11HoCaK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;400&quot; height=&quot;92&quot; data-origin-width=&quot;1170&quot; data-origin-height=&quot;268&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Toss앱의 탭 메뉴&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 개의 객체로 구성&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;TabBar : 화면 이동을 제공하는 탭 메뉴&lt;/li&gt;
&lt;li&gt;TabBarView : 탭바를 통해 이동 시 전환되는 각각의 화면&lt;/li&gt;
&lt;li&gt;TabBarController : 둘을 연결&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;옵션&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;controller: _tabController로 컨트롤러에서 설정한 length만큼의 탭 메뉴 등록&lt;br /&gt;tabs: const [Widget1, Widget2, Widget3 &amp;hellip;] 형식으로 TabBar에 보일 탭 메뉴 등록&lt;br /&gt;labelColor - 현재 선택된 메뉴의 색상&lt;br /&gt;unselectedLabelColor - 현재 선택되지 않은 메뉴의 색상 설정 &lt;br /&gt;labelPadding - 메뉴 간 간격 조정 &lt;br /&gt;indicatorWeight - 선택된 메뉴의 인디케이터 두께 조절 &lt;br /&gt;labelStyle, unselectedLabelStyle - 선택된 메뉴와 선택되지 않은 메뉴의 텍스트 스타일 설정&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;TabController&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기값 설정 필요&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;옵션&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;initialIndex: 초기 &amp;ldquo;탭-페이지&amp;rdquo; 설정&lt;br /&gt;animationDuration: 탭 메뉴를 눌렀을 때 인디케이터 애니메이션 효과 설정&lt;br /&gt;length: 메뉴(탭) 개수 설정 &lt;br /&gt;vsync: 애니메이션을 장치 디스플레이의 수직 동기화와 맞추는 역할 &lt;br /&gt;&amp;nbsp; &amp;nbsp; - with TickerProviderStateMixin로 클래스 추가 필요&lt;br /&gt;&amp;nbsp; &amp;nbsp; - 화면 새로고침 타이밍에 맞춰 프레임마다 알맞게 tick 이벤트를 전달 &lt;br /&gt;&amp;nbsp; &amp;nbsp; - 애니메이션 시작/중지/재생 속도 제어&lt;br /&gt;&amp;nbsp; &amp;nbsp; - vsync 없으면 표시 안되는 프레임의 애니메이션을 계속 계산 &amp;rarr; 배터리/CPU 낭비&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;TabBarView&lt;/h4&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;controller: _tabController로 컨트롤러에서 설정한 length만큼의 뷰 등록&lt;br /&gt;children: [Widget1, Widget2, Widget3] 형식으로 각 탭 메뉴에 대응하는 화면 정의&lt;/blockquote&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;스크롤&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;ListView&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;스크롤 가능 항목을 목록으로 배치&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;옵션&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;scrollDirection: 스크롤방향 &lt;br /&gt;&amp;nbsp; - &lt;span style=&quot;letter-spacing: 0px; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif;&quot;&gt;세로(디폴트 설정) : axis.vertical, 가로 : axis.horizental&lt;/span&gt;&lt;br /&gt;&lt;/span&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;&lt;span style=&quot;letter-spacing: 0px; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif;&quot;&gt;&lt;span style=&quot;letter-spacing: 0px; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif;&quot;&gt;&lt;br /&gt;reverse: &amp;nbsp;스크롤 방향과 스크롤 시작 위치를 뒤집음&lt;br /&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;letter-spacing: 0px; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif;&quot;&gt;&amp;nbsp; - ListView는 원래 위에서 시작해서 밑으로 렌더링하는데 reverese가 켜지면 밑에서 시작해서 위로 렌더&lt;br /&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #9d9d9d; letter-spacing: 0px; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif;&quot;&gt;&amp;nbsp; - &lt;b&gt;reverse = false&lt;/b&gt;) 스크롤 시작 위치 : 맨 위, 스크롤 방향 : 내려서 최신 컨텐츠 조회&lt;br /&gt;&lt;/span&gt;&lt;span style=&quot;color: #9d9d9d; letter-spacing: 0px; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif;&quot;&gt;&amp;nbsp; - &lt;b&gt;reverse = true&lt;/b&gt;) 스크롤 시작위치 : 맨 밑, 스크롤 방향 : 올려서 예전 컨텐츠 조회&lt;br /&gt;&lt;/span&gt;&lt;span style=&quot;color: #9d9d9d; letter-spacing: 0px; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif;&quot;&gt;&amp;nbsp; - 콘텐츠 순서 자체를 바꾸는 건 아님, 화면 상 렌더링 시작 위치와 스크롤 진행 방향만 반대로&lt;/span&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;&amp;nbsp; - 채팅창 구현에 적용&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;&lt;br /&gt;controller: 스크롤 제어, 이벤트 처리 제공&lt;br /&gt;&lt;/span&gt;&lt;span style=&quot;color: #9d9d9d; text-align: start;&quot;&gt;&amp;nbsp; - initState()와 연계하면 매 상태 생성 시 스크롤 위치 0부터 시작하도록 구현 가능 &lt;br /&gt;&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;rarr; 스크롤 끝에 도달 확인 후 새로운 데이터 불러오기(새로운 자식 위젯 생성)로 무한 스크롤 구현&lt;br /&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;&lt;br /&gt;pysics: 스크롤 동작 엔진 설정&lt;br /&gt;&lt;/span&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;&amp;nbsp; - BouncingScrollPhysics - 스크롤 끝에서 튕기는 효과(ios)&lt;br /&gt;&lt;/span&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;&amp;nbsp; - ClampingScrollPhysics - 스크롤 끝에서 멈추는 효과(android)&lt;br /&gt;&lt;/span&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;&amp;nbsp; - FixedExtentScrollPhysics - 스크롤 이동 단위 균일&lt;br /&gt;&lt;/span&gt;&lt;span style=&quot;color: #9d9d9d; text-align: left;&quot;&gt;&amp;nbsp; - NeverScrollableScrollPhysics - 스크롤 비활성화&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;&lt;br /&gt;padding: 아이템 간 간격 조정&lt;br /&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;cacheExtent: 스크롤로 화면 이동 시 기존 화면에서 추가로 캐시해 둘 영역 설정&lt;br /&gt;&lt;/span&gt;&lt;span style=&quot;color: #9d9d9d; text-align: left;&quot;&gt;&lt;span style=&quot;text-align: left;&quot;&gt;&amp;nbsp; - 반응 속도와 초기로딩 속도 간의 트레이드오프&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;background-color: #fcfcfc; color: #666666; text-align: left;&quot;&gt;&lt;br /&gt;&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;GridView&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199; color: #000000;&quot;&gt;스크롤 가능 위젯을 그리드로 배치&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인스타 피드처럼 동일 유형 위젯(이미지)을 모아보는 화면에 적용&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;gridDelegate: 그리드 레이아웃 정의&lt;br /&gt;&lt;/span&gt;&lt;span style=&quot;color: #9d9d9d; letter-spacing: 0px; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif;&quot;&gt;&amp;nbsp; - (열 수, 크기, 열 사이 간격 등) &amp;rarr; 한 줄에 몇 개 보여줄지 정하면 크기에서 나눠서 행 결정&lt;br /&gt;&lt;/span&gt;&lt;span style=&quot;color: #9d9d9d; letter-spacing: 0px; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif;&quot;&gt;&amp;nbsp; - SliverGridDelegateWithFixedCrossAxisCount&lt;/span&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;&amp;nbsp; &amp;nbsp; &amp;nbsp; -&amp;gt; &lt;b&gt;crossAxisCount: &lt;/b&gt;&lt;span style=&quot;text-align: left;&quot;&gt;&lt;b&gt;타일 크기와 관계없이 열 수 고정&lt;/b&gt;&amp;nbsp;&lt;/span&gt; (타일의 가로가 길 경우 양옆으로 화면 넘어갈 수 O)&lt;br /&gt;&lt;/span&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;&amp;nbsp; - SliverGridDelegateWithMaxCrossAxisExtent &lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;&amp;nbsp; &amp;nbsp; &amp;nbsp; -&amp;gt; &lt;b&gt;maxCrossAxisExtent: &lt;span style=&quot;text-align: left;&quot;&gt;타일 최대 너비를 고정&lt;/span&gt;&lt;/b&gt; (화면 가로 크기 / 타일 너비로 열 수 계산)&lt;br /&gt;&lt;/span&gt;&lt;span style=&quot;color: #9d9d9d; text-align: left;&quot;&gt;&amp;nbsp; - &lt;b&gt;mainAxisSpacing, crossAxisSpacing&lt;/b&gt; : 그리드 셀 간 간격&lt;/span&gt;&lt;/p&gt;
&lt;span style=&quot;color: #9d9d9d;&quot;&gt;scrollDirection&lt;/span&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;&lt;br /&gt;reverse&lt;/span&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;&lt;br /&gt;controller&lt;br /&gt;padding: GridView 내부 간격 설정 (테두리 간격)&lt;br /&gt;&lt;/span&gt;&lt;span style=&quot;color: #9d9d9d; letter-spacing: 0px; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif;&quot;&gt;&amp;nbsp; - 영화관 좌석처럼 GridView에 내부 간격을 두면 해당 여백을 잡고 스크롤하기에 UX적으로 유용&lt;/span&gt;&lt;/blockquote&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;스와이프/슬라이드&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;PageView&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;스와이프 제스처를 인식해서 페이징을 제공하는 위젯&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;슬라이드 배너 구현에 유용&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;옵션&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;children: 슬라이드로 등장할 자식 위젯, List&amp;lt;Widget&amp;gt; 형태로 구성&lt;br /&gt;scrollDirection&lt;br /&gt;controller&lt;br /&gt;pageSnapping: 스와이프 시 자동으로 페이지가 완전히 전환되도록 하는 효과&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;onPageChanged: 페이지 완전 이동 시 콜백 함수 등록, 페이지 이동 단위로 특정 이벤트 수행 가능&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1170&quot; data-origin-height=&quot;2532&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/RuG5h/dJMcadOAiR2/9h8xHlBpW7RhnWxWzgU18k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/RuG5h/dJMcadOAiR2/9h8xHlBpW7RhnWxWzgU18k/img.png&quot; data-alt=&quot;pageSnapping: false로 동작하는 아이폰 창 이동 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/RuG5h/dJMcadOAiR2/9h8xHlBpW7RhnWxWzgU18k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FRuG5h%2FdJMcadOAiR2%2F9h8xHlBpW7RhnWxWzgU18k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;300&quot; height=&quot;649&quot; data-origin-width=&quot;1170&quot; data-origin-height=&quot;2532&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;pageSnapping: false로 동작하는 아이폰 창 이동 화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Controller가 제공하는 addListener가 있는데 굳이 onPageChanged옵션으로 콜백 함수를 등록하는 이유는 다음과 같다.&lt;/p&gt;
&lt;table id=&quot;3145a9f0-7a68-8069-b79c-c9115ddefd4e&quot; style=&quot;border-collapse: collapse; width: 100%; height: 76px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;구분&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 19px;&quot;&gt;addListener&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 19px;&quot;&gt;onPageChanged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr id=&quot;3145a9f0-7a68-8097-b15e-f1dfe53f859b&quot; style=&quot;height: 19px;&quot;&gt;
&lt;td id=&quot;bmck&quot; style=&quot;height: 19px;&quot;&gt;호출 시점&lt;/td&gt;
&lt;td id=&quot;Lxs_&quot; style=&quot;height: 19px;&quot;&gt;스크롤 중 계속&lt;/td&gt;
&lt;td id=&quot;^QhH&quot; style=&quot;height: 19px;&quot;&gt;페이지 전환 완료&lt;/td&gt;
&lt;/tr&gt;
&lt;tr id=&quot;3145a9f0-7a68-8095-a57a-e9a32b0c1b45&quot; style=&quot;height: 19px;&quot;&gt;
&lt;td id=&quot;bmck&quot; style=&quot;height: 19px;&quot;&gt;값&lt;/td&gt;
&lt;td id=&quot;Lxs_&quot; style=&quot;height: 19px;&quot;&gt;float (진행률)&lt;/td&gt;
&lt;td id=&quot;^QhH&quot; style=&quot;height: 19px;&quot;&gt;int (페이지 index)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr id=&quot;3145a9f0-7a68-806a-8054-ff230057541d&quot; style=&quot;height: 19px;&quot;&gt;
&lt;td id=&quot;bmck&quot; style=&quot;height: 19px;&quot;&gt;용도&lt;/td&gt;
&lt;td id=&quot;Lxs_&quot; style=&quot;height: 19px;&quot;&gt;애니메이션 동기화, parallax, 스크롤 진행 상태&lt;/td&gt;
&lt;td id=&quot;^QhH&quot; style=&quot;height: 19px;&quot;&gt;페이지 인덱스 변경 후 처리, indicator, 상태 업데이트&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;addListener = 진행 상태 연속 추적&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;onPageChanged = 페이지 전환 완료 시점 알림&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구현 편의상 페이지 단위 전환 전용으로 onPageChanged를 제공하는 것&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;애니메이션&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;애니메이션은 시각적으로 흥미로운 피드백 제공하는 효과이다. 애니메이션은 투명도가 변하든, 위치나 크기가 변하든, &lt;b&gt;기본적으로 시간에 따라 값이 변하는 과정&lt;/b&gt; 그 자체이므로 당연히 StatefulWidget의 형태로 제공된다. 이 때문에 헷갈리기 쉬운데 앞서 설명했듯이 이 애니메이션 위젯에게 변화된 값을 뿌리는 데이터 소스는 별도 애니메이션 객체(AnimationController)이고 위젯이 내부에서 참조 중이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;플러터에서 제공하는 애니메이션은 암시적, 명시적 두 종류가 있다. 애니메이션 위젯이 혼자 애니메이션 효과를 그리는 것이 아님을 유념하고 플러터의 애니메이션 위젯을 알아보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;애니메이션이 그려지는 과정&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #000000;&quot;&gt;1. 초기값 &amp;rarr; 결과값 코드 실행&lt;/span&gt;&lt;/b&gt;&lt;br /&gt;&lt;b&gt;&lt;span style=&quot;color: #000000;&quot;&gt;2. &lt;span style=&quot;color: #000000; text-align: left;&quot;&gt;Tween 생성&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&amp;nbsp; &amp;nbsp; - Tween : 값 보간 공식&lt;/span&gt;&lt;br /&gt;&lt;b&gt;&lt;span style=&quot;color: #000000;&quot;&gt;3. Animation&amp;lt;double&amp;gt; 생성&lt;/span&gt;&lt;/b&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&amp;nbsp; &amp;nbsp; - Animation : 시간에 따른 값 변화를 저장&lt;/span&gt;&lt;br /&gt;&lt;b&gt;&lt;span style=&quot;color: #000000;&quot;&gt;4. controller.forward()&lt;/span&gt;&lt;/b&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&amp;nbsp; &amp;nbsp; - controller : 시간 진행 제어&amp;nbsp;&lt;/span&gt;&lt;br /&gt;&lt;b&gt;&lt;span style=&quot;color: #000000;&quot;&gt;5. 매 프레임마다 listener 실행&lt;/span&gt;&lt;/b&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&amp;nbsp; &amp;nbsp; - listener : 값 변경 감지&lt;/span&gt;&lt;br /&gt;&lt;b&gt;&lt;span style=&quot;color: #000000;&quot;&gt;6. setState() 호출로 값 변경&lt;/span&gt;&lt;/b&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&amp;nbsp; &amp;nbsp; - 새 값으로 갱신, 빌드 호출&lt;/span&gt;&lt;br /&gt;&lt;b&gt;&lt;span style=&quot;color: #000000;&quot;&gt;7. build() 호출로 화면 반영&lt;/span&gt;&lt;/b&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&amp;nbsp; &amp;nbsp; - 변경된 화면 표시&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;Tween&lt;/b&gt;이 값 범위를 보간하고&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;Animation&lt;/b&gt;이 시간에 따라 값을 만들고 &lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;Controller&lt;/b&gt;가 시간을 움직이고&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;listener&lt;/b&gt;가 rebuild를 트리거한다.&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;Flutter는 값이 바뀌었는지 감시하는 게 아니라 rebuild 신호가 왔는지를 감지하여 화면을 그리는 엔진이고,&lt;br /&gt;애니메이션은 프레임마다 rebuild 신호를 발생시키는 방식으로 구현된다.&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;암시적 애니메이션&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;플러터는 내부에 AnimationController가 이미 정의된 애니메이션 위젯을 제공한다. 개발자가 직접 AnimationController를 정의하고 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;중간 동작을 커스텀한 명시적 애니메이션을 만들 수도 있지만 여기선 암시적 애니메이션에 대해 알아보자.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;공통 옵션&lt;/span&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;duration: 애니메이션의 실행 시간 설정 = 값의 변화가 일어나는 시간&lt;/li&gt;
&lt;li&gt;Curve: 값이 변하는 속도를 동적으로 설정 = (AnimationController가 0&amp;rarr;1로 진행되는 과정을 시간별로 어떻게 보간할지)&lt;br /&gt;&amp;nbsp; &amp;nbsp;- 0&amp;rarr;1 변하는 과정을 x축을 시간으로하는 함수 개형으로 표현하면&lt;br /&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;Curves.linear : 선형 함수&lt;/li&gt;
&lt;li&gt;Curves.ease : sigmoid 함수&lt;/li&gt;
&lt;li&gt;Curves.easeIn : 지수 함수&lt;/li&gt;
&lt;li&gt;Curves.easeOut : 로그 함수&lt;/li&gt;
&lt;li&gt;Curves.easeInOut : sigmoid 함수&lt;/li&gt;
&lt;li&gt;Curves.fastOutSlowIn : tan 함수&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;기타 곡선 효과&amp;nbsp;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;Curves.bounceIn : 시작에 바운스&lt;/li&gt;
&lt;li&gt;Curves.bounceOut : 끝에 바운스&lt;/li&gt;
&lt;li&gt;Curves.elasticIn : 시작 부분에 탄성&lt;/li&gt;
&lt;li&gt;Curves.elasticOut : 끝 부분에 탄성&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;onEnd: 애니메이션 끝날때 호출되는 콜백 함수 = 애니메이션 종료 후 특정 이벤트 처리하도록 설정
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;암시적 애니메이션 = 내부에서 매프레임 자동 setState &amp;rarr; onEnd에서 별도 setState 불필요&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;명시적 애니메이션 = AnimationController 직접 정의 &amp;rarr; Animation listener에서 매 프레임 setState를 직접 호출 필요&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;AnimatedOpacity&amp;nbsp;&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;하위 위젯에 페이드 인, 페이드 아웃 애니메이션을 제공하는 위젯&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;opcaity는 double 타입 변수로 위젯의 불투명도(선명도) 값을 조절한다.&lt;/p&gt;
&lt;pre id=&quot;code_1772200214807&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;opacity : (flag ? 0.2 : 1)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-&amp;gt; duration 동안 불투명도가 0.2부터 1까지 tween에 따라 증가&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;AnimatedPositioned&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;Positioned 위젯의 위치와 크기를 애니메이션으로 변경하는 위젯&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Positioned 위젯에 애니메이션을 입힌거라 &lt;b&gt;스택의 하위 위젯&lt;/b&gt;으로 존재한다.&lt;/p&gt;
&lt;pre id=&quot;code_1772200460582&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;left : (flag ? 0 : 150)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-&amp;gt; duration 동안 Positioned의 x좌표가 부모 위젯 기준으로 0부터 150까지 tween에 따라 증가&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;AnimatedContainer&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;Container 위젯의 width, height, color, padding, alignment을 애니메이션으로 변경하는 위젯&lt;/span&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1772200567056&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;width : (opacity ? 100 : 150),
height : (opacity ? 100 : 150)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-&amp;gt; duration 동안 Container의 너비와 높이가 100부터 150까지 tween에 따라 증가&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;라우팅&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라우팅에서 등장하는 주요 개념은 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;MaterialApp&lt;/span&gt;&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;앱 전체를 감싸는 최상위 위젯&lt;/li&gt;
&lt;li&gt;앱을 실행하면 가장 처음 렌더링 되는 첫 위젯&amp;nbsp;&lt;/li&gt;
&lt;li&gt;내부에서 Navigator, Theme, Locale 등 전역 상태를 관리&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;WidgetBuilder&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Widget의 생성자 함수를 호출하는 함수타입&lt;/li&gt;
&lt;li&gt;routes 옵션에서 위젯의 이름(String)과 위젯의 생성자(WidgetBuilder) 쌍으로 등록&lt;/li&gt;
&lt;li&gt;화면 전환 시 페이지 단위로 위젯 빌더 호출&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Navigator&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;화면 전환을 담당하는 &lt;b&gt;스택 구조&lt;/b&gt; 객체&lt;/li&gt;
&lt;li&gt;들어갔던 화면&lt;b&gt;(Route 객체)&lt;/b&gt;을 &lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;스택에 쌓아두고 관리&lt;/b&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;push/pop으로 &lt;b&gt;새 화면 이동/이전 화면 복원&lt;/b&gt;&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Route&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-start=&quot;191&quot; data-end=&quot;224&quot;&gt;Navigator 스택에 들어가는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;화면 단위 객체&lt;/b&gt;로 push/pop의 대상&lt;/li&gt;
&lt;li&gt;내부적으로 builder: (context) =&amp;gt; MyPage()같은 함수를 통해 &lt;b&gt;위젯을 생성&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;1014&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cdpLi1/dJMcacIZxCa/zsBcM6tVLN0zJeWbm2lih0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cdpLi1/dJMcacIZxCa/zsBcM6tVLN0zJeWbm2lih0/img.png&quot; data-alt=&quot;navigator&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cdpLi1/dJMcacIZxCa/zsBcM6tVLN0zJeWbm2lih0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcdpLi1%2FdJMcacIZxCa%2FzsBcM6tVLN0zJeWbm2lih0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;400&quot; height=&quot;396&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;1014&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;navigator&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;이름 기반 명령형 라우팅 (Navigator 1.0 방식)&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이름을 전달받은 네비게이터가 &lt;b&gt;Map&amp;lt;String, WidgetBuilder&amp;gt;&lt;/b&gt;으로 builder를 찾아 Route를 생성하고 스택에 직접 push/pop함으로써 화면 전환&lt;/p&gt;
&lt;pre id=&quot;code_1772205484129&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;MaterialApp (최상위 위젯)
    └─ Navigator 생성(routes 라우팅 정보 전달)
        └─ pushNamed 호출 ('/second')
            └─ Navigator가 route name 수신 ('/second')
                └─ routes(Map&amp;lt;String, WidgetBuilder&amp;gt;)에서 builder 찾음
                    └─ 그 builder를 감싸는 Route 객체 생성 (MaterialPageRoute)
                        └─ Navigator 스택에 push
                            └─ Route가 builder 실행
                                └─ Widget 생성 (SecondPage)
                                    └─ build() 실행 &amp;rarr; 화면 렌더링&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Page 기반 선언형 라우팅 (Navigator 2.0 방식)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Page 기반에서는 push 호출 없이 &lt;b&gt;pages의 상태 변경&lt;/b&gt;만으로 화면 전환 구현&lt;/p&gt;
&lt;pre id=&quot;code_1772205640783&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;MaterialApp (최상위 위젯)
    └─ Navigator(pages: List&amp;lt;Page&amp;gt; 전달)
        └─ 앱 상태 변경 (pages 리스트에 새 Page 객체 추가)
            └─ setState()로 pages 리스트 변경
                └─ Navigator 리빌드
                    └─ Navigator가 기존 pages와 새 pages diff 비교
                        └─ 새 Page에 대해 createRoute(context) 호출
                            └─ Route 객체 생성 (MaterialPageRoute)
                                └─ Navigator가 Route를 Overlay에 추가
                                    └─ Route가 buildPage() 실행
                                        └─ Widget 생성 (SecondPage)
                                            └─ build() 실행 &amp;rarr; 화면 렌더링&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 선언형 라우팅 방식은 &lt;b&gt;웹(Web)과 상태 기반 앱 아키텍처를 제대로 지원&lt;/b&gt;하기 위해서 최근에 업데이트됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 더 복잡한 구조로 업데이트 됐는지에 대한 이유는 다음과 같다고 한다..&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/r5TLK/dJMcaaqRjYs/PuM8AtodLdd0lkPeIsGsL0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/r5TLK/dJMcaaqRjYs/PuM8AtodLdd0lkPeIsGsL0/img.png&quot; data-origin-width=&quot;1296&quot; data-origin-height=&quot;1228&quot; data-is-animation=&quot;false&quot; style=&quot;width: 40.2375%; margin-right: 10px;&quot; data-widthpercent=&quot;40.71&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/r5TLK/dJMcaaqRjYs/PuM8AtodLdd0lkPeIsGsL0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fr5TLK%2FdJMcaaqRjYs%2FPuM8AtodLdd0lkPeIsGsL0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1296&quot; height=&quot;1228&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/0Ad2Y/dJMcaca8AvS/knAziBLX4KBkQVpuUNmnPK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/0Ad2Y/dJMcaca8AvS/knAziBLX4KBkQVpuUNmnPK/img.png&quot; data-origin-width=&quot;1288&quot; data-origin-height=&quot;838&quot; data-is-animation=&quot;false&quot; style=&quot;width: 58.5997%;&quot; data-widthpercent=&quot;59.29&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/0Ad2Y/dJMcaca8AvS/knAziBLX4KBkQVpuUNmnPK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F0Ad2Y%2FdJMcaca8AvS%2FknAziBLX4KBkQVpuUNmnPK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1288&quot; height=&quot;838&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dgKcfj/dJMcag5GwOg/lUqvxjFTVoaKb5IupRo6IK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dgKcfj/dJMcag5GwOg/lUqvxjFTVoaKb5IupRo6IK/img.png&quot; data-origin-width=&quot;1032&quot; data-origin-height=&quot;720&quot; data-is-animation=&quot;false&quot; style=&quot;width: 42.844%; margin-right: 10px;&quot; data-widthpercent=&quot;43.35&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dgKcfj/dJMcag5GwOg/lUqvxjFTVoaKb5IupRo6IK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdgKcfj%2FdJMcag5GwOg%2FlUqvxjFTVoaKb5IupRo6IK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1032&quot; height=&quot;720&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bBBMXK/dJMcahXOtrW/e9OyenaWN0NEtNDWPE9aOK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bBBMXK/dJMcahXOtrW/e9OyenaWN0NEtNDWPE9aOK/img.png&quot; data-origin-width=&quot;1064&quot; data-origin-height=&quot;568&quot; data-is-animation=&quot;false&quot; style=&quot;width: 55.9933%;&quot; data-widthpercent=&quot;56.65&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bBBMXK/dJMcahXOtrW/e9OyenaWN0NEtNDWPE9aOK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbBBMXK%2FdJMcahXOtrW%2Fe9OyenaWN0NEtNDWPE9aOK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1064&quot; height=&quot;568&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;Navigator 1.0&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;Navigator 2.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;모바일 중심&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;웹 + 모바일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;명령형&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;선언형&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;push/pop 기록&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;상태 기반&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;URL 무관&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;URL 동기화 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #666666;&quot;&gt;그러나 내가 참고하는 책은 스택을 이용한 명령형 라우팅 기준으로 쓰여 있으므로 우선 Navigator 1.0 방식을 학습한다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Navigator 이동 방식&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Navigator.push, Navigator.pushNamed&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;새로운 페이지로 이동&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 앱에서의 페이지 전환 = 새로운 화면 = 네비게이터 스택에 push&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 새로운 화면 = 새로운 위젯 생성 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;rarr;&lt;span&gt; &lt;/span&gt;&lt;/span&gt;위젯 생성자 인터페이스는 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;Widget build(BuildContext context)&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp;= context를 인자로 전달하며 새 위젯 생성자를 호출&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 두 가지를 고려하면 화면 이동을 담당하는 다음 두 함수가 직관적으로 이해된다.&lt;/p&gt;
&lt;pre id=&quot;code_1772203894240&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Navigator.push(		// Route 객체를 직접 지정해서 화면 전환
  context,
  MaterialPageRoute(
    builder: (context) =&amp;gt; SecondPage()
  )
);


Navigator.pushNamed(context, '/second');  //이름 기반 라우팅&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 85.9302%; height: 57px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 15.6977%;&quot;&gt;메서드&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 13.7209%;&quot;&gt;전달 인자&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 56.3953%;&quot;&gt;특징&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 15.6977%;&quot;&gt;push&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 13.7209%;&quot;&gt;Route 객체&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 56.3953%;&quot;&gt;이름 없이 직접 Route/Widget 지정해서 생성자 호출&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 15.6977%;&quot;&gt;pushNamed&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 13.7209%;&quot;&gt;이름&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 56.3953%;&quot;&gt;MaterialApp.routes에 들러서 이름에 대응하는 위젯 생성자 찾아서 호출&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;aliasing 차이만 있을 뿐 사실상 같은 구조로 동작함을 알 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;Navigator.pop&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;현재 페이지 지우고 직전 페이지로 이동&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1772204401333&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;initialRoute: '/'  //홈화면
 
Navigator.pushNamed(context, '/second');  //홈화면 위에 두번째 화면

Navigator.pop(context); // 두번째 화면을 지움 = 이전화면(홈화면)이 top&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Navigator.pushReplacementNamed&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;현재 페이지 지우고 새 페이지로 이동&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1772204633314&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;initialRoute: '/'  //홈화면
 
Navigator.pushNamed(context, '/second');  //홈화면 위에 두번째 화면 

Navigator.pushReplacementNamed(context, '/third'); 
// 현재화면(두번째 화면)을 지우고 세번째 화면 푸시 = 홈화면 위에 세번째 화면&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Navigator.popUntil&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;현재 페이지부터 특정 페이지 사이의 모든 페이지를 지우고 원하는 특정 페이지로 이동&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1772204832126&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Navigator.popUntil(context, ModalRoute.withName(&quot;/&quot;))&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 현재 화면부터 이름이 `/`인 화면까지 모든 화면을 pop&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;회원 가입처럼 여러 단계에 걸친 동작 종료 후 새 화면으로 이동하도록 구현할 때 활용&lt;/p&gt;
&lt;pre id=&quot;code_1772204970473&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Navigator.pushNamed(context, '/home'); //홈화면

Navigator.pushNamed(context, '/first');  // 회원가입 첫번째 단계

Navigator.pushNamed(context, '/second');  // 회원가입 두번째 단계

Navigator.pushNamed(context, '/third');  // 회원가입 세번째 단계

Navigator.popUntil(context, ModalRoute.withName(&quot;/home&quot;)); 
//회원가입 완료 후 회원가입 단계 페이지 다 지우고 홈화면으로 복귀&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>dev/app</category>
      <author>cusum26</author>
      <guid isPermaLink="true">https://imjyh01.tistory.com/14</guid>
      <comments>https://imjyh01.tistory.com/14#entry14comment</comments>
      <pubDate>Fri, 27 Feb 2026 20:39:07 +0900</pubDate>
    </item>
    <item>
      <title>flutter - 플러터로 크로스 플랫폼 앱 개발하기(1)</title>
      <link>https://imjyh01.tistory.com/12</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;개발 환경 세팅&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;플러터 설치 (macOS 기준)&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;(1) 터미널로 설치하는 방법&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 터미널 앱에서 다음 명령어로 플러터 sdk 설치 (brew는 이미 설치 완료 가정)&lt;/p&gt;
&lt;pre id=&quot;code_1771680754937&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;brew install --cask flutter&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. flutter 명령어 사용을 위한 PATH 추가 (.zshrc 파일에 sdk가 설치된 경로를 등록)&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #666666;&quot;&gt;수동으로 설정한 디렉토리에 다운로드 했다면 &quot;which flutter&quot; 명령어로 sdk 설치된 경로 확인 가능&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1771680979298&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;echo 'export PATH=&quot;/opt/homebrew/bin:$PATH&quot;' &amp;gt;&amp;gt; ~/.zshrc
source ~/.zshrc&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 설치 확인&lt;/p&gt;
&lt;pre id=&quot;code_1771688016742&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;flutter --version&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1468&quot; data-origin-height=&quot;144&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cGZDVO/dJMcaducPAP/El8I7zA9XDljYV7dTy4mnk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cGZDVO/dJMcaducPAP/El8I7zA9XDljYV7dTy4mnk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cGZDVO/dJMcaducPAP/El8I7zA9XDljYV7dTy4mnk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcGZDVO%2FdJMcaducPAP%2FEl8I7zA9XDljYV7dTy4mnk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;59&quot; data-origin-width=&quot;1468&quot; data-origin-height=&quot;144&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. 적용 확인&lt;/p&gt;
&lt;pre id=&quot;code_1771681200983&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;flutter doctor&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #666666;&quot;&gt;Doctor summary (to see all details, run flutter doctor -v): &lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;background-color: #f6e199; color: #666666;&quot;&gt;&lt;span style=&quot;color: #409d00;&quot;&gt;[✓]&lt;/span&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&amp;nbsp;&lt;/span&gt;Flutter (Channel stable, 3.41.1, on macOS 15.7.3 24G419 darwin-arm64, locale ko-KR)&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt; [✗] &lt;/span&gt;Android toolchain - develop for Android devices &lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;✗ &lt;/span&gt;Unable to locate Android SDK. Install Android Studio from: &lt;a style=&quot;color: #666666;&quot; href=&quot;https://developer.android.com/studio/index.html&quot;&gt;https://developer.android.com/studio/index.html&lt;/a&gt; On first launch it will assist you in installing the Android SDK components. (or visit &lt;a style=&quot;color: #666666;&quot; href=&quot;https://flutter.dev/to/macos-android-setup&quot;&gt;https://flutter.dev/to/macos-android-setup&lt;/a&gt; for detailed instructions). If the Android SDK has been installed to a custom location, please use flutter config --android-sdk to update to that location. &lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;[&lt;span style=&quot;text-align: start;&quot;&gt;✗&lt;/span&gt;] &lt;/span&gt;Xcode - develop for iOS and macOS (Xcode 26.2)&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;✗&lt;/span&gt; Unable to get list of installed Simulator runtimes. &lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;✗&lt;/span&gt; CocoaPods not installed. CocoaPods is a package manager for iOS or macOS platform code. &lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;Without CocoaPods, plugins will not work on iOS or macOS. For more info, see &lt;a style=&quot;color: #666666;&quot; href=&quot;https://flutter.dev/to/platform-plugins&quot;&gt;https://flutter.dev/to/platform-plugins&lt;/a&gt; For installation instructions, see &lt;a style=&quot;color: #666666;&quot; href=&quot;https://guides.cocoapods.org/using/getting-started.html#installation&quot;&gt;https://guides.cocoapods.org/using/getting-started.html#installation&lt;/a&gt; &lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;&lt;span style=&quot;color: #409d00;&quot;&gt;[✓] &lt;/span&gt;Chrome - develop for the web&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;&lt;span style=&quot;color: #409d00;&quot;&gt;[✓]&lt;/span&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&amp;nbsp;&lt;/span&gt;Connected device (1 available) &lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;&lt;span style=&quot;color: #409d00;&quot;&gt;[✓]&lt;/span&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&amp;nbsp;&lt;/span&gt;Network resources&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위와 같은 결과 나오면 설치 완료&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;(2) VS Code에서 설치하는 방법&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. VS Code 마켓플레이스에서 Flutter 확장 프로그램 설치&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2804&quot; data-origin-height=&quot;1116&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/FBNVH/dJMcadVhZ7y/jWhWiHjrQeQcVkCm4cZpg0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/FBNVH/dJMcadVhZ7y/jWhWiHjrQeQcVkCm4cZpg0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/FBNVH/dJMcadVhZ7y/jWhWiHjrQeQcVkCm4cZpg0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FFBNVH%2FdJMcadVhZ7y%2FjWhWiHjrQeQcVkCm4cZpg0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;239&quot; data-origin-width=&quot;2804&quot; data-origin-height=&quot;1116&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. VS Code화면 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;우측 하단에 뜨는 Dart extension, &lt;/span&gt;flutter SDK 플러그인 설치를 물어보는 두 개 알림에 install을, 환경변수 PATH 추가를 물어보는 알림에 ok를 클릭&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 설치 확인&lt;/p&gt;
&lt;pre id=&quot;code_1771688076576&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;flutter --version&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1902&quot; data-origin-height=&quot;142&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/FEKE3/dJMcaf6GcMb/SwIsgWsFYP2oKBScYeSEpk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/FEKE3/dJMcaf6GcMb/SwIsgWsFYP2oKBScYeSEpk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/FEKE3/dJMcaf6GcMb/SwIsgWsFYP2oKBScYeSEpk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FFEKE3%2FdJMcaf6GcMb%2FSwIsgWsFYP2oKBScYeSEpk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;700&quot; height=&quot;52&quot; data-origin-width=&quot;1902&quot; data-origin-height=&quot;142&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. 적용 확인&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1771685373020&quot; class=&quot;ebnf&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;flutter doctor&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote style=&quot;color: #666666; text-align: left;&quot; data-ke-style=&quot;style2&quot;&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #666666;&quot;&gt;Doctor summary (to see all details, run flutter doctor -v):&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;background-color: #f6e199; color: #666666;&quot;&gt;&lt;span style=&quot;color: #409d00;&quot;&gt;[✓]&lt;span style=&quot;text-align: start;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;Flutter (Channel stable, 3.41.1, on macOS 15.7.3 24G419 darwin-arm64, locale ko-KR)&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;[✗]&lt;/span&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&amp;nbsp;Android toolchain - develop for Android devices&lt;/span&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;✗&lt;/span&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;&amp;nbsp;&lt;/span&gt;Unable to locate Android SDK. Install Android Studio from:&amp;nbsp;&lt;/span&gt;&lt;a style=&quot;color: #666666;&quot; href=&quot;https://developer.android.com/studio/index.html&quot;&gt;https://developer.android.com/studio/index.html&lt;/a&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&amp;nbsp;On first launch it will assist you in installing the Android SDK components. (or visit&amp;nbsp;&lt;/span&gt;&lt;a style=&quot;color: #666666;&quot; href=&quot;https://flutter.dev/to/macos-android-setup&quot;&gt;https://flutter.dev/to/macos-android-setup&lt;/a&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&amp;nbsp;for detailed instructions). If the Android SDK has been installed to a custom location, please use flutter config --android-sdk to update to that location.&lt;/span&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;[&lt;span style=&quot;text-align: start;&quot;&gt;✗&lt;/span&gt;]&lt;/span&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&amp;nbsp;Xcode - develop for iOS and macOS (Xcode 26.2)&lt;/span&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;✗&lt;/span&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&amp;nbsp;Unable to get list of installed Simulator runtimes.&lt;/span&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;✗&lt;/span&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&amp;nbsp;CocoaPods not installed. CocoaPods is a package manager for iOS or macOS platform code. &lt;br /&gt;Without CocoaPods, plugins will not work on iOS or macOS. For more info, see&amp;nbsp;&lt;/span&gt;&lt;a style=&quot;color: #666666;&quot; href=&quot;https://flutter.dev/to/platform-plugins&quot;&gt;https://flutter.dev/to/platform-plugins&lt;/a&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&amp;nbsp;For installation instructions, see&amp;nbsp;&lt;/span&gt;&lt;a style=&quot;color: #666666;&quot; href=&quot;https://guides.cocoapods.org/using/getting-started.html#installation&quot;&gt;https://guides.cocoapods.org/using/getting-started.html#installation&lt;/a&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;&lt;span style=&quot;color: #409d00;&quot;&gt;[✓]&lt;/span&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&lt;span style=&quot;color: #409d00;&quot;&gt;&amp;nbsp;&lt;/span&gt;Chrome - develop for the web&lt;/span&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;&lt;span style=&quot;color: #409d00;&quot;&gt;[✓]&lt;span style=&quot;text-align: start;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;Connected device (1 available)&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;&lt;span style=&quot;color: #409d00;&quot;&gt;[✓]&lt;/span&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&amp;nbsp;&lt;/span&gt;Network resources&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;위와 같은 결과 나오면 설치 완료&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;flutter not found 문제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;.zshrc에 PATH 추가 완료 후 flutter --version 입력 시 flutter not found가 뜨는 상황이 자주 발생한다. 특히 맥 기본 터미널에서는 flutter 명령어 사용이 되는데 VS Code 내장 터미널에서 flutter 명령어를 못 찾는 상황(또는 그 반대)이 발생할 수 있다. 이는 z&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;sh가&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;로그인 셸&lt;/b&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;로 실행될 때는&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;.&lt;b&gt;zprofile만 읽고,&lt;/b&gt;&lt;/span&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt; 인터랙티브 셸&lt;/span&gt;&lt;/b&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;로 실행될 때는&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;&lt;b&gt;.zshrc만 읽는 구조&lt;/b&gt;로 되어있기 때문이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;macOS는 기본적으로 &lt;/span&gt;&amp;nbsp;zsh 생성시 &lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;두 설정을 모두 불러오도록 처리해주지만&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt; 가끔 특정 동작 이후에 이 로직이 꼬여 다른 셸로 착각하고&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;.zprofile과 .zshrc 두 설정 중 하나만 읽고 끝내버리는 경우가 생긴다.&lt;/span&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot; data-complete=&quot;true&quot; data-processed=&quot;true&quot; data-hveid=&quot;CAEIAxAA&quot; data-sfc-cp=&quot;&quot;&gt;&lt;span&gt;따라서&amp;nbsp;&lt;/span&gt;.zprofile 끝에 .zshrc를 불러오라는 &quot;source ~/.zshrc&quot;를 작성해두면 VS Code나 macOS가 어떤 방식으로 셸을 열든 무조건&lt;span&gt;&amp;nbsp;&lt;/span&gt;.zshrc에 등록된 Flutter 경로를 읽을 수 있게 된다.&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot; data-complete=&quot;true&quot; data-processed=&quot;true&quot; data-hveid=&quot;CAEIAxAA&quot; data-sfc-cp=&quot;&quot;&gt;(.zshrc에 PATH 추가 완료 후)&lt;/div&gt;
&lt;pre id=&quot;code_1771683683562&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;open -e ~/.zprofile
echo '[[ -f ~/.zshrc ]] &amp;amp;&amp;amp; source ~/.zshrc' &amp;gt;&amp;gt; ~/.zprofile
source ~/.zprofile&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새 터미널 열어서 flutter --version 잘 나오는지 확인&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;Xcode 설치&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 앱스토어에서 Xcode 설치&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1876&quot; data-origin-height=&quot;1416&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mRSSf/dJMcagxJLdv/mws5AkJ60lvnLHkxLD9CgK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mRSSf/dJMcagxJLdv/mws5AkJ60lvnLHkxLD9CgK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mRSSf/dJMcagxJLdv/mws5AkJ60lvnLHkxLD9CgK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmRSSf%2FdJMcagxJLdv%2Fmws5AkJ60lvnLHkxLD9CgK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;453&quot; data-origin-width=&quot;1876&quot; data-origin-height=&quot;1416&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 설치 후 터미널에 Xcode의 &lt;b&gt;기본 버전 세팅, &lt;b&gt;권한 허용,&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot; data-complete=&quot;true&quot; data-sfc-cp=&quot;&quot;&gt;&lt;span&gt;&amp;nbsp;추&lt;/span&gt;&lt;/span&gt;&lt;b&gt;가 컴포넌트 설치 등을 진행하는 명령어 입력&lt;/b&gt;&lt;/b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1771688387563&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer
sudo xcodebuild -runFirstLaunch&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 라이선스 동의&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;span style=&quot;color: #666666;&quot;&gt;8.7 Entire Agreement; Governing Language This Agreement constitutes the entire agreement between the parties with respect to the use of the Apple Software and Apple Services licensed hereunder and supersedes all prior understandings regarding such subject matter. Notwithstanding the foregoing, to the extent that You have entered into the Apple Developer Program License Agreement (DPLA) with Apple and are validly licensed by Apple to exercise additional rights, or to use additional features or functionality of the Apple Software or Apple Services under the DPLA, You acknowledge and agree that the DPLA shall govern Your use of such additional rights and privileges. No amendment to or modification of this Agreement will be binding unless in writing and signed by Apple. The parties hereto confirm that they have requested that this Agreement and all related documents be drafted in English. Les parties ont exig&amp;eacute; que le pr&amp;eacute;sent contrat et tous les documents connexes soient r&amp;eacute;dig&amp;eacute;s en anglais.&lt;/span&gt;&lt;br /&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;EA1910&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;06/09/2025&lt;/span&gt;&lt;br /&gt;&lt;br /&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;Agreeing to the Xcode and Apple SDKs license requires admin privileges, please accept the Xcode license as the root user (e.g. 'sudo xcodebuild -license').&lt;/span&gt;&lt;/blockquote&gt;
&lt;pre id=&quot;code_1771693480755&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo xcodebuild -license accept&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. 설치 확인&lt;/p&gt;
&lt;pre id=&quot;code_1771689068510&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;xcodebuild -version&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;870&quot; data-origin-height=&quot;62&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cNMIhg/dJMcahclUGA/mSdMmzqiSye6k2K44MmnyK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cNMIhg/dJMcahclUGA/mSdMmzqiSye6k2K44MmnyK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cNMIhg/dJMcahclUGA/mSdMmzqiSye6k2K44MmnyK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcNMIhg%2FdJMcahclUGA%2FmSdMmzqiSye6k2K44MmnyK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;43&quot; data-origin-width=&quot;870&quot; data-origin-height=&quot;62&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5. Xcode 실행 후 cmd + , 로 Settings &lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&amp;rarr;&lt;/span&gt; Components &lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&amp;rarr;&lt;/span&gt; iOS&amp;nbsp; 26.2 옆에 Get 버튼을 눌러 iOS 시뮬레이터 설치&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/uBaOH/dJMcabpH0S5/KvpWQuBvGkHDkgl7O7BwU0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/uBaOH/dJMcabpH0S5/KvpWQuBvGkHDkgl7O7BwU0/img.png&quot; data-origin-width=&quot;910&quot; data-origin-height=&quot;902&quot; data-is-animation=&quot;false&quot; width=&quot;400&quot; height=&quot;396&quot; data-widthpercent=&quot;39.47&quot; style=&quot;width: 39.0124%; margin-right: 10px;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/uBaOH/dJMcabpH0S5/KvpWQuBvGkHDkgl7O7BwU0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FuBaOH%2FdJMcabpH0S5%2FKvpWQuBvGkHDkgl7O7BwU0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;910&quot; height=&quot;902&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cJjIdy/dJMcahQXTgq/hA8ipVzSABznOnLtrIyyQ1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cJjIdy/dJMcahQXTgq/hA8ipVzSABznOnLtrIyyQ1/img.png&quot; data-origin-width=&quot;1380&quot; data-origin-height=&quot;892&quot; data-is-animation=&quot;false&quot; style=&quot;width: 59.8248%;&quot; data-widthpercent=&quot;60.53&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cJjIdy/dJMcahQXTgq/hA8ipVzSABznOnLtrIyyQ1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcJjIdy%2FdJMcahQXTgq%2FhA8ipVzSABznOnLtrIyyQ1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1380&quot; height=&quot;892&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;6. 터미널에서 CocoaPods 설치&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;&lt;span style=&quot;color: #666666;&quot;&gt;CocoaPods &lt;/span&gt;&lt;/b&gt;&lt;span style=&quot;color: #666666;&quot;&gt;: &lt;/span&gt;&lt;b&gt;&lt;span style=&quot;color: #666666;&quot;&gt;iOS 전용 라이브러리 관리자&lt;/span&gt;&lt;/b&gt;&lt;span style=&quot;color: #666666;&quot;&gt;로 프로젝트 외부 앱 실행, 카메라 등 iOS 네이티브 코드에 접근해야 할 때 담당 라이브러리를 자동으로 다운받고 연결해주는 역할 수행&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1771690412625&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;brew install cocoapods&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;7. 적용 확인&lt;/p&gt;
&lt;pre id=&quot;code_1771691001424&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;flutter doctor&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote style=&quot;color: #666666; text-align: left;&quot; data-ke-style=&quot;style2&quot;&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #666666;&quot;&gt;Doctor summary (to see all details, run flutter doctor -v):&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;&lt;span style=&quot;color: #409d00;&quot;&gt;[✓]&lt;/span&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&amp;nbsp;&lt;/span&gt;Flutter (Channel stable, 3.41.1, on macOS 15.7.3 24G419 darwin-arm64, locale ko-KR)&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;[✗]&lt;/span&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;&amp;nbsp;&lt;/span&gt;Android toolchain - develop for Android devices&lt;/span&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;✗&lt;/span&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&amp;nbsp;Unable to locate Android SDK. Install Android Studio from:&amp;nbsp;&lt;/span&gt;&lt;a style=&quot;color: #666666;&quot; href=&quot;https://developer.android.com/studio/index.html&quot;&gt;https://developer.android.com/studio/index.html&lt;/a&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&amp;nbsp;On first launch it will assist you in installing the Android SDK components. (or visit&amp;nbsp;&lt;/span&gt;&lt;a style=&quot;color: #666666;&quot; href=&quot;https://flutter.dev/to/macos-android-setup&quot;&gt;https://flutter.dev/to/macos-android-setup&lt;/a&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&amp;nbsp;for detailed instructions). If the Android SDK has been installed to a custom location, please use flutter config --android-sdk to update to that location.&lt;/span&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;background-color: #f6e199; color: #666666;&quot;&gt;&lt;span style=&quot;color: #409d00;&quot;&gt;[✓]&lt;/span&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&amp;nbsp;Xcode - develop for iOS and macOS (Xcode 26.2)&lt;/span&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;&lt;span style=&quot;color: #409d00;&quot;&gt;[✓]&lt;/span&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&amp;nbsp;Chrome - develop for the web&lt;/span&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;&lt;span style=&quot;color: #409d00;&quot;&gt;[✓]&lt;span style=&quot;text-align: start;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;Connected device (1 available)&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #666666;&quot;&gt;&lt;span style=&quot;color: #409d00;&quot;&gt;[✓]&lt;span style=&quot;text-align: start;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;Network resources&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위와 같은 결과 나오면 설치 완료&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;안드로이드 스튜디오 설치&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 공식 웹사이트에서 안드로이드 스튜디오 설치&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2450&quot; data-origin-height=&quot;1438&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/r8xBf/dJMcabQJOq3/TPNGKQCbmRvPokcbc56Ji1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/r8xBf/dJMcabQJOq3/TPNGKQCbmRvPokcbc56Ji1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/r8xBf/dJMcabQJOq3/TPNGKQCbmRvPokcbc56Ji1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fr8xBf%2FdJMcabQJOq3%2FTPNGKQCbmRvPokcbc56Ji1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;352&quot; data-origin-width=&quot;2450&quot; data-origin-height=&quot;1438&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. Setup Wizard에 체크되어 있는 그대로 설치 진행&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. Android Studio 실행 후 cmd + , 로 Settings &lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&amp;rarr;&lt;/span&gt; Languages &amp;amp; Frameworks &lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&amp;rarr;&lt;/span&gt; Android SDK &lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&amp;rarr;&lt;/span&gt; SDK Tools &lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&amp;rarr;&lt;/span&gt; Android SDK Command-line Tools (latest) 적용&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tPq71/dJMcacvnNpb/RXF3fUDnIvV6085qiM62LK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tPq71/dJMcacvnNpb/RXF3fUDnIvV6085qiM62LK/img.png&quot; data-origin-width=&quot;1580&quot; data-origin-height=&quot;1284&quot; data-is-animation=&quot;false&quot; style=&quot;width: 46.9589%; margin-right: 10px;&quot; data-widthpercent=&quot;47.51&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tPq71/dJMcacvnNpb/RXF3fUDnIvV6085qiM62LK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtPq71%2FdJMcacvnNpb%2FRXF3fUDnIvV6085qiM62LK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1580&quot; height=&quot;1284&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/PIf0A/dJMcacWpmJ0/Bx6kFmVSH55O7lncyP0wkk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/PIf0A/dJMcacWpmJ0/Bx6kFmVSH55O7lncyP0wkk/img.png&quot; data-origin-width=&quot;1944&quot; data-origin-height=&quot;1430&quot; data-is-animation=&quot;false&quot; data-widthpercent=&quot;52.49&quot; style=&quot;width: 51.8783%;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/PIf0A/dJMcacWpmJ0/Bx6kFmVSH55O7lncyP0wkk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FPIf0A%2FdJMcacWpmJ0%2FBx6kFmVSH55O7lncyP0wkk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1944&quot; height=&quot;1430&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. 라이선스 동의&lt;/p&gt;
&lt;pre id=&quot;code_1771693658941&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;flutter doctor --android-licenses&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 나오는 라이선스 끝날 때까지 y 입력&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5. 적용 확인&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1771693875378&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;flutter doctor&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote style=&quot;color: #666666; text-align: left;&quot; data-ke-style=&quot;style2&quot;&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #666666;&quot;&gt;Doctor summary (to see all details, run flutter doctor -v):&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #409d00;&quot;&gt;[✓]&lt;/span&gt;&lt;span style=&quot;color: #666666; text-align: left;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;Flutter (Channel stable, 3.41.1, on macOS 15.7.3 24G419 darwin-arm64, locale ko-KR)&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #409d00; background-color: #f6e199;&quot;&gt;[✓]&lt;/span&gt;&lt;span style=&quot;background-color: #f6e199; color: #666666; text-align: left;&quot;&gt;&amp;nbsp;Android toolchain - develop for Android devices (Android SDK version 36.1.0)&lt;br /&gt;&lt;/span&gt;&lt;span style=&quot;color: #409d00;&quot;&gt;[✓]&lt;/span&gt;&lt;span style=&quot;color: #666666; text-align: left;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;Xcode - develop for iOS and macOS (Xcode 26.2)&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #409d00;&quot;&gt;[✓]&lt;/span&gt;&lt;span style=&quot;color: #666666; text-align: left;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;Connected device (2 available)&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #409d00;&quot;&gt;[✓]&lt;/span&gt;&lt;span style=&quot;color: #666666; text-align: left;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;Network resources&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위와 같은 결과 나오면 설치 완료&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;에뮬레이터 확인&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 에뮬레이터 목록 확인&lt;/p&gt;
&lt;pre id=&quot;code_1771695118413&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;flutter emulators&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1040&quot; data-origin-height=&quot;368&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/beQvnm/dJMcaioO0R0/U8MDqzFEUoKPHs7HCKk4t1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/beQvnm/dJMcaioO0R0/U8MDqzFEUoKPHs7HCKk4t1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/beQvnm/dJMcaioO0R0/U8MDqzFEUoKPHs7HCKk4t1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbeQvnm%2FdJMcaioO0R0%2FU8MDqzFEUoKPHs7HCKk4t1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;212&quot; data-origin-width=&quot;1040&quot; data-origin-height=&quot;368&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. ios 시뮬레이터 실행&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #666666;&quot;&gt;시뮬레이터 : 맥의 자원을 빌려&amp;nbsp;아이폰의&lt;b&gt; 소프트웨어 동작(UI, 앱 실행 등)을&lt;/b&gt;&amp;nbsp;비슷하게 흉내낸 실행 환경&lt;br /&gt;- 하드웨어 가상화가 없어 속도가 매우 빠르고 가벼우며 실제 아이폰의 동작과 미세한 차이 존재 가능&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1771695642310&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;open -a Simulator&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;714&quot; data-origin-height=&quot;1580&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/biLApW/dJMcahQXXje/FBbxTejentZ3Jt7IKXFoR1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/biLApW/dJMcahQXXje/FBbxTejentZ3Jt7IKXFoR1/img.png&quot; data-alt=&quot;IOS 시뮬레이터&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/biLApW/dJMcahQXXje/FBbxTejentZ3Jt7IKXFoR1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbiLApW%2FdJMcahQXXje%2FFBbxTejentZ3Jt7IKXFoR1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;300&quot; height=&quot;664&quot; data-origin-width=&quot;714&quot; data-origin-height=&quot;1580&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;IOS 시뮬레이터&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. Android 에뮬레이터 실행&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #666666;&quot;&gt;에뮬레이터 :&lt;/span&gt;&lt;span data-sfc-cp=&quot;&quot; data-complete=&quot;true&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #666666;&quot;&gt;맥의 자원을 빌려 안드로이드 폰의 &lt;b&gt;하드웨어(CPU, 메모리 등) 전체&lt;/b&gt;를 소프트웨어로 구현한 환경&lt;/span&gt;&lt;br /&gt;&lt;/span&gt;&lt;span style=&quot;color: #666666;&quot; data-sfc-cp=&quot;&quot; data-complete=&quot;true&quot;&gt;- 완전한 기기를 가상 환경으로 돌리는 것이므로 상당한 자원을 점유하며 실제 기기와 동일하게 동작&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1771696038667&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;flutter emulators --launch Medium_Phone_API_36.1&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;820&quot; data-origin-height=&quot;1616&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nUh4R/dJMcafyRZuZ/kBh9Fszmz7hRWTyYqfVPo1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nUh4R/dJMcafyRZuZ/kBh9Fszmz7hRWTyYqfVPo1/img.png&quot; data-alt=&quot;Android 에뮬레이터&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nUh4R/dJMcafyRZuZ/kBh9Fszmz7hRWTyYqfVPo1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnUh4R%2FdJMcafyRZuZ%2FkBh9Fszmz7hRWTyYqfVPo1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;300&quot; height=&quot;591&quot; data-origin-width=&quot;820&quot; data-origin-height=&quot;1616&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Android 에뮬레이터&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;본격적인 flutter 프로젝트 세팅은 다음 시간에 알아보자.&lt;/p&gt;</description>
      <category>dev/app</category>
      <author>cusum26</author>
      <guid isPermaLink="true">https://imjyh01.tistory.com/12</guid>
      <comments>https://imjyh01.tistory.com/12#entry12comment</comments>
      <pubDate>Sun, 22 Feb 2026 03:14:48 +0900</pubDate>
    </item>
    <item>
      <title>Kafka - 이벤트 기반의 비동기 작업 처리 구조로 알림 기능 구현하기 (4)</title>
      <link>https://imjyh01.tistory.com/11</link>
      <description>&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;도커 환경에서의 Kafka UI 설정&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;알림 기능이 제대로 동작하는 지 검증하기 앞서 Kafka 브로커가 설계한 대로 작동하는 지 Kafka UI를 통해 확인해보자.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Kafka UI는 Kafka 브로커에 저장된 메시지와 메타데이터를 조회해 시각적으로 표현하는 &lt;b&gt;HTTP 기반의 외부 웹 UI 도구&lt;/b&gt;이다. 일반적으로&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span&gt;&lt;span&gt;Docker&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;컨테이너로 배포되며 브라우저를 통해 &lt;b&gt;Kafka 브로커의 운영 및 모니터링 환경&lt;/b&gt;을 제공한다. 다음의 Docker 설정을 통해 브라우저 접속 환경을 구성할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1769063316117&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;kafka-ui:
  image: provectuslabs/kafka-ui:latest
  container_name: kafka-ui
  ports:
    - &quot;8085:8080&quot;  # 호스트의 브라우저는 8085로 접속 가능
  environment:
    KAFKA_CLUSTERS_0_NAME: local
    KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9092
  depends_on:
    - kafka
  restart: unless-stopped&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Kafka UI 접속&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 도커에서 Kafka를 실행하고 localhost:8085로 접속하면 다음 화면을 볼 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2892&quot; data-origin-height=&quot;1154&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cQAUwh/dJMcaaYnz6F/8jOvlkpxdfNkZdXV0BunYK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cQAUwh/dJMcaaYnz6F/8jOvlkpxdfNkZdXV0BunYK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cQAUwh/dJMcaaYnz6F/8jOvlkpxdfNkZdXV0BunYK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcQAUwh%2FdJMcaaYnz6F%2F8jOvlkpxdfNkZdXV0BunYK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;800&quot; height=&quot;319&quot; data-origin-width=&quot;2892&quot; data-origin-height=&quot;1154&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스 규모가 크지 않아 단일 브로커 구조로 설정했으므로, 현재 클러스터는 &lt;b&gt;1개의 브로커와 7개의 토픽&lt;/b&gt;으로 구성되어 있다. approval-show-topic만 3개의 파티션을 가지고 있고, 나머지 토픽은 각각 1개의 파티션으로 설정했는데도 &lt;b&gt;대시보드에 전체 파티션 수가 58개&lt;/b&gt;로 표시되어 다소 이상하게 보인다. 우선 토픽별로 파티션 구성을 확인해보자.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3008&quot; data-origin-height=&quot;1066&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dVUGZl/dJMcachz54Z/OuktGyiu0ykKiSXQhRNpg0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dVUGZl/dJMcachz54Z/OuktGyiu0ykKiSXQhRNpg0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dVUGZl/dJMcachz54Z/OuktGyiu0ykKiSXQhRNpg0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdVUGZl%2FdJMcachz54Z%2FOuktGyiu0ykKiSXQhRNpg0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;800&quot; height=&quot;284&quot; data-origin-width=&quot;3008&quot; data-origin-height=&quot;1066&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대시보드에서 확인한 7개의 토픽을 확인할 수 있다. 하위 6개는 직접 생성한 토픽이지만, 가장 위에는 생성한 적이 없는 &lt;b&gt;__consumer_offsets&lt;/b&gt; 토픽이 존재한다. 이 토픽에 대해 알아보자.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;__consumer_offsets&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;Kafka는 내부적으로 50개의 파티션을 사용하여 __consumer_offsets 토픽을 운영하며, 각&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;컨슈머 그룹의 커밋 내역과 상태 메타데이터를 기록&lt;/b&gt;한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 토픽에 생성된 메시지를 살펴보면 {&lt;b&gt;컨슈머 그룹, 토픽, 파티션}을 Key로 하는 메시지&lt;/b&gt;와 {&lt;b&gt;컨슈머 그룹}을 Key로 하는 메시지&lt;/b&gt;가&amp;nbsp;있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전자는 오프셋&amp;nbsp; 커밋 로그, 후자는 그룹 메타데이터 로그이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lqb5E/dJMcabQvNTC/Nqewp81tOZ7F0ETBBN4kTk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lqb5E/dJMcabQvNTC/Nqewp81tOZ7F0ETBBN4kTk/img.png&quot; data-origin-width=&quot;2488&quot; data-origin-height=&quot;1366&quot; data-is-animation=&quot;false&quot; data-widthpercent=&quot;52.01&quot; style=&quot;width: 51.41%; margin-right: 10px;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lqb5E/dJMcabQvNTC/Nqewp81tOZ7F0ETBBN4kTk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Flqb5E%2FdJMcabQvNTC%2FNqewp81tOZ7F0ETBBN4kTk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2488&quot; height=&quot;1366&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/etMM7D/dJMcajgFRSR/nkHUnWeaLO0bjvPPcYVDW1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/etMM7D/dJMcajgFRSR/nkHUnWeaLO0bjvPPcYVDW1/img.png&quot; data-origin-width=&quot;2470&quot; data-origin-height=&quot;1470&quot; data-is-animation=&quot;false&quot; data-widthpercent=&quot;47.99&quot; style=&quot;width: 47.4272%;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/etMM7D/dJMcajgFRSR/nkHUnWeaLO0bjvPPcYVDW1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FetMM7D%2FdJMcajgFRSR%2FnkHUnWeaLO0bjvPPcYVDW1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2470&quot; height=&quot;1470&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
  &lt;figcaption&gt;Key가 {&quot;group&quot;, &quot;topic&quot;, &quot;partition&quot;}인 오프셋 커밋 로그&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kzony/dJMcaaxjMDG/jLGicVnPvZX0DQ9e3zpqaK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kzony/dJMcaaxjMDG/jLGicVnPvZX0DQ9e3zpqaK/img.png&quot; data-origin-width=&quot;2456&quot; data-origin-height=&quot;1516&quot; data-is-animation=&quot;false&quot; style=&quot;width: 50.5168%; margin-right: 10px;&quot; data-widthpercent=&quot;51.11&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kzony/dJMcaaxjMDG/jLGicVnPvZX0DQ9e3zpqaK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fkzony%2FdJMcaaxjMDG%2FjLGicVnPvZX0DQ9e3zpqaK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2456&quot; height=&quot;1516&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/R9L6y/dJMcadOgUea/U1qHisICZ4pGNTg7cC4PBk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/R9L6y/dJMcadOgUea/U1qHisICZ4pGNTg7cC4PBk/img.png&quot; data-origin-width=&quot;2436&quot; data-origin-height=&quot;1572&quot; data-is-animation=&quot;false&quot; style=&quot;width: 48.3205%;&quot; data-widthpercent=&quot;48.89&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/R9L6y/dJMcadOgUea/U1qHisICZ4pGNTg7cC4PBk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FR9L6y%2FdJMcadOgUea%2FU1qHisICZ4pGNTg7cC4PBk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2436&quot; height=&quot;1572&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
  &lt;figcaption&gt;Key가 {&quot;group&quot;}인 그룹 메타데이터 로그&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;offset commit log&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;컨슈머 그룹, 토픽, 파티션을 Key로 하는 메시지는 일전에 서버 재시작 상황에서 언급했던 &lt;b&gt;컨슈머 오프셋의&lt;/b&gt; &lt;b&gt;커밋&amp;nbsp;로그&lt;/b&gt;이다. &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;이는 특정 파티션의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;작업 진행 상태를 나타내는 체크포인트&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;역할을 하며 애플리케이션의 갑작스러운 종료나 재시작 상황에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;유실 없이 마지막 처리 지점부터 작업을 재개&lt;/b&gt;할 수 있도록 보장한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;서버 재시작 상황에서 메모리가 날아간 컨슈머는 자신이 어디서부터 다시 읽어야 하는지에 대한 정보(=마지막으로 커밋된 오프셋=컨슈머 오프셋)를 브로커(Group Coodrinator)에게 요청한다. 컨슈머가 언제 꺼질지 모르는 Kafka 브로커는&lt;span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;각 컨슈머가 파티션의 어디까지 처리했는지 오프셋을 커밋할 때마다 내부 토픽에 로그로 기록&lt;/b&gt;해둔다. 이때 커밋 로그가 저장되는 내부 토픽이 __consumer_offsets이다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1779685900152&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;group-A / approval-show-topic / partition-0 -&amp;gt; committed offset 100
group-B / approval-show-topic / partition-1 -&amp;gt; committed offset 98
group-C / approval-show-topic / partition-2 -&amp;gt; committed offset 97&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;컨슈머 오프셋은&amp;nbsp;개별&amp;nbsp;컨슈머&amp;nbsp;인스턴스&amp;nbsp;단위가&amp;nbsp;아니라&amp;nbsp;consumer&amp;nbsp;group&amp;nbsp;+&amp;nbsp;topic&amp;nbsp;+&amp;nbsp;partition&amp;nbsp;단위로&amp;nbsp;저장된다.&amp;nbsp;따라서&amp;nbsp;재시작이나&amp;nbsp;리밸런싱으로&amp;nbsp;기존과&amp;nbsp;다른&amp;nbsp;컨슈머&amp;nbsp;인스턴스가&amp;nbsp;해당&amp;nbsp;파티션을&amp;nbsp;맡더라도,&amp;nbsp;같은&amp;nbsp;컨슈머&amp;nbsp;그룹에&amp;nbsp;속해&amp;nbsp;있다면&amp;nbsp;마지막으로&amp;nbsp;커밋된&amp;nbsp;오프셋부터&amp;nbsp;이어서&amp;nbsp;메시지를&amp;nbsp;가져올&amp;nbsp;수&amp;nbsp;있다.&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;nbsp;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;span&gt;즉, 커밋 로그에서 컨슈머 오프셋은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;컨슈머 그룹의 파티션별 처리 위치&lt;/b&gt;를 의미하게 된다.&lt;/span&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;Group metadata log&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;__consumer_offsets 토픽에서 컨슈머 그룹만을 Key로 하는 메시지는 해당 그룹의 구성원 정보, 각 파티션 구독 상태, 파티션 할당 상태를 관리하는 &lt;b&gt;그룹 메타데이터 로그&lt;/b&gt;이다. &lt;b&gt;컨슈머 그룹의 상태와 관련된 메타데이터&lt;/b&gt;를 기록하며 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;컨슈머 그룹의 존재 여부, 각 파티션 구독 상태, 현재 오프셋 위치, 리밸런스 발생 여부와 같은 이벤트를&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;상태 변경으로 간주하여 &lt;/b&gt;저장한다. &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;이 로그는 컨슈머 그룹 전체의 생명주기와 리밸런싱 현황 파악에 사용되며, 컨슈머 그룹 내 구성원 변경이나 세션 유지 상태 관리에 있어서 근거가 되는 정보이다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bMzpOq/dJMcabpskGT/41in3kmU1lz0XzKCLEYCG0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bMzpOq/dJMcabpskGT/41in3kmU1lz0XzKCLEYCG0/img.png&quot; data-origin-width=&quot;2510&quot; data-origin-height=&quot;1540&quot; data-is-animation=&quot;false&quot; style=&quot;width: 50.0452%; margin-right: 10px;&quot; data-widthpercent=&quot;50.63&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bMzpOq/dJMcabpskGT/41in3kmU1lz0XzKCLEYCG0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbMzpOq%2FdJMcabpskGT%2F41in3kmU1lz0XzKCLEYCG0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2510&quot; height=&quot;1540&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bnO5Mg/dJMcaaRCv2o/dcO6Z1Ps6uaN2XM93tfFI0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bnO5Mg/dJMcaaRCv2o/dcO6Z1Ps6uaN2XM93tfFI0/img.png&quot; data-origin-width=&quot;2498&quot; data-origin-height=&quot;1572&quot; data-is-animation=&quot;false&quot; data-widthpercent=&quot;49.37&quot; style=&quot;width: 48.792%;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bnO5Mg/dJMcaaRCv2o/dcO6Z1Ps6uaN2XM93tfFI0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbnO5Mg%2FdJMcaaRCv2o%2FdcO6Z1Ps6uaN2XM93tfFI0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2498&quot; height=&quot;1572&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
  &lt;figcaption&gt;commit 기록은 없지만 그룹 메타데이터 로그가 존재하는 hot-board-notice-group&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;따라서 메시지를 아직 소비하지 않아 커밋 기록이 없더라도, 컨슈머가 토픽을 구독하거나 그룹에 참여하는 순간 이런&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;상태 변경 정보&lt;/b&gt;는 컨슈머 그룹을 Key로 하는 메시지로 __consumer_offsets&lt;span&gt;에 저장&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;된다. 실제로&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;hot-board-notice-group&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;은 아직 소비 작업을 수행한 적이 없음에도 불구하고,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;리더 컨슈머 선출 및 파티션 할당이 성공적으로 완료&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;되었다는 &lt;b&gt;그룹 메타데이터 로그가 기록&lt;/b&gt;되어 있다. 이러한 기록들은 단순히 오프셋 추적을 넘어, 컨슈머와 브로커 간의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;멤버십 유지, 파티션 재할당, 업무 재분배 등에 &lt;/b&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;활용된다.&lt;/span&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;알림 기능 이벤트 전달 검증&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;등록 승인 알림&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dpE4No/dJMcacIFCKo/adpRfEKXrGyvxci2FZXOj0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dpE4No/dJMcacIFCKo/adpRfEKXrGyvxci2FZXOj0/img.png&quot; data-origin-width=&quot;2560&quot; data-origin-height=&quot;1426&quot; data-is-animation=&quot;false&quot; style=&quot;width: 50.1296%; margin-right: 10px;&quot; data-widthpercent=&quot;50.72&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dpE4No/dJMcacIFCKo/adpRfEKXrGyvxci2FZXOj0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdpE4No%2FdJMcacIFCKo%2FadpRfEKXrGyvxci2FZXOj0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2560&quot; height=&quot;1426&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/exwhDs/dJMcab32XRv/gjgAaDUGJcNKKkZeUaYUj1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/exwhDs/dJMcab32XRv/gjgAaDUGJcNKKkZeUaYUj1/img.png&quot; data-origin-width=&quot;2606&quot; data-origin-height=&quot;1494&quot; data-is-animation=&quot;false&quot; style=&quot;width: 48.7077%;&quot; data-widthpercent=&quot;49.28&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/exwhDs/dJMcab32XRv/gjgAaDUGJcNKKkZeUaYUj1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FexwhDs%2FdJMcab32XRv%2FgjgAaDUGJcNKKkZeUaYUj1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2606&quot; height=&quot;1494&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
  &lt;figcaption&gt;세 파티션에 분산 저장된 11개의 approval-show-topic 메시지&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내내 예로 들었던 approval-show-topic을 살펴보자. 11개의 메시지가 파티션 3개에 골고루 저장되어 있는 것을 볼 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3개의 컨슈머 그룹 모두 concurrency=3으로 설정되어있다. 따라서 10,000개의 이벤트가 생성되더라도, &lt;b&gt;3개의 워커 스레드가 각 파티션을 하나씩 담당&lt;/b&gt;하여 승인 알림, 구독 알림, 추천 알림 로직을 &lt;b&gt;총 9개의 스레드에서 메인 흐름과 독립적으로 처리&lt;/b&gt;할 수 있다. 설령 애플리케이션이 재시작되더라도, 브로커에 커밋된 오프셋을 기준으로 &lt;b&gt;중단된 위치부터 재처리&lt;/b&gt;가 이루어져 시스템의 &lt;b&gt;내구성과 처리 안정성&lt;/b&gt;을 보장한다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;등록 반려 알림&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bzm2ET/dJMcaaYnKZo/J8AaKzEhAddKHJ20v2B1T1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bzm2ET/dJMcaaYnKZo/J8AaKzEhAddKHJ20v2B1T1/img.png&quot; data-origin-width=&quot;2576&quot; data-origin-height=&quot;1248&quot; data-is-animation=&quot;false&quot; style=&quot;width: 52.0073%; margin-right: 10px;&quot; data-widthpercent=&quot;52.62&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bzm2ET/dJMcaaYnKZo/J8AaKzEhAddKHJ20v2B1T1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbzm2ET%2FdJMcaaYnKZo%2FJ8AaKzEhAddKHJ20v2B1T1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2576&quot; height=&quot;1248&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Ijnjd/dJMb99ZtliP/VDkjBnCCBB0SV10dlM3kNk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Ijnjd/dJMb99ZtliP/VDkjBnCCBB0SV10dlM3kNk/img.png&quot; width=&quot;800&quot; height=&quot;430&quot; data-origin-width=&quot;2524&quot; data-origin-height=&quot;1358&quot; data-is-animation=&quot;false&quot; data-widthpercent=&quot;47.38&quot; style=&quot;width: 46.8299%;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Ijnjd/dJMb99ZtliP/VDkjBnCCBB0SV10dlM3kNk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FIjnjd%2FdJMb99ZtliP%2FVDkjBnCCBB0SV10dlM3kNk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2524&quot; height=&quot;1358&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
  &lt;figcaption&gt;3개의 메시지 밖에 없음에도 768Byte를 차지하는 reject-show-topic&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1960&quot; data-origin-height=&quot;384&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dB5kUF/dJMcaf6rO3b/k0DGnzoKY74VKVDYpYbXp0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dB5kUF/dJMcaf6rO3b/k0DGnzoKY74VKVDYpYbXp0/img.png&quot; data-alt=&quot;매인 로직에서 반려 사유를 직접 전달하는 현재 구조&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dB5kUF/dJMcaf6rO3b/k0DGnzoKY74VKVDYpYbXp0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdB5kUF%2FdJMcaf6rO3b%2Fk0DGnzoKY74VKVDYpYbXp0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;800&quot; height=&quot;157&quot; data-origin-width=&quot;1960&quot; data-origin-height=&quot;384&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;매인 로직에서 반려 사유를 직접 전달하는 현재 구조&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;등록 반려 이벤트의 경우, 메인 로직으로부터 반려 사유를 전달받아야 하므로 최소 정보만 전달하는 다른 메시지에 비해 용량이 크다. 추후 반려 사유 탬플릿이 고정되면 ENUM으로 매핑시켜서 최소한의 파라미터만 전달하는 방안도 가능해보인다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;게시글 댓글 알림&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/GZ5jC/dJMcadgta7q/LwHQdtdyYQ1IrinpzL2l9K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/GZ5jC/dJMcadgta7q/LwHQdtdyYQ1IrinpzL2l9K/img.png&quot; data-is-animation=&quot;false&quot; data-origin-height=&quot;1504&quot; data-origin-width=&quot;2490&quot; style=&quot;width: 48.4548%; margin-right: 10px;&quot; data-widthpercent=&quot;49.02&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/GZ5jC/dJMcadgta7q/LwHQdtdyYQ1IrinpzL2l9K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FGZ5jC%2FdJMcadgta7q%2FLwHQdtdyYQ1IrinpzL2l9K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2490&quot; height=&quot;1504&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/GDRaO/dJMcagddyYk/UkmoKxigpkKZGtzQPt7ARk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/GDRaO/dJMcagddyYk/UkmoKxigpkKZGtzQPt7ARk/img.png&quot; data-is-animation=&quot;false&quot; data-origin-height=&quot;1436&quot; data-origin-width=&quot;2472&quot; style=&quot;width: 50.3824%;&quot; data-widthpercent=&quot;50.98&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/GDRaO/dJMcagddyYk/UkmoKxigpkKZGtzQPt7ARk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FGDRaO%2FdJMcagddyYk%2FUkmoKxigpkKZGtzQPt7ARk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2472&quot; height=&quot;1436&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
  &lt;figcaption&gt;문제없이 전달되는 댓글 이벤트와 대댓글 이벤트&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;댓글과 대댓글 이벤트도 잘 전달된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이로써 Kafka를 이용한 비동기 이벤트 처리 구조를 적용한 알림 기능이 잘 작동함을 확인했다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 Kafka를 공부하면서 줄곧 느낀 점은, &lt;b&gt;Kafka의 진정한 가치는 수십, 수백만 단위의 row를 처리해야하는 대규모 서비스 환경에서 비로소 드러난다&lt;/b&gt;는 것이다. 시스템이 available한 최대 capacity를 균일하게 유지하며 수많은 read, write 트래픽을 안정적으로 처리하고, 필요에 따라 수평 확장되는 구조에서 Kafka와 같은 이벤트 기반 통신이 없다면 어떻게 서비스를 안정적으로 설계하고 상태를 일관되게 관리할 지 잘 상상이 가지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞으로 MSA쪽도 공부해보며 이해를 넓히고, 실제 마이크로서비스 개발 과정에서 &lt;b&gt;Kafka를 직접 적용해 보며 그 가치를 검증해 보고 싶다.&lt;/b&gt;&lt;/p&gt;</description>
      <category>dev/infra</category>
      <category>commit log</category>
      <category>consumer offset</category>
      <category>group metadata</category>
      <category>Kafka</category>
      <category>kafka ui</category>
      <category>__consumer_offsets</category>
      <author>cusum26</author>
      <guid isPermaLink="true">https://imjyh01.tistory.com/11</guid>
      <comments>https://imjyh01.tistory.com/11#entry11comment</comments>
      <pubDate>Thu, 22 Jan 2026 16:46:15 +0900</pubDate>
    </item>
    <item>
      <title>Kafka - 이벤트 기반의 비동기 작업 처리 구조로 알림 기능 구현하기 (3)</title>
      <link>https://imjyh01.tistory.com/10</link>
      <description>&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;도커 환경에서의 Kafka 설정&lt;/h2&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Kafka의 로그 서버는 스프링 애플리케이션과 분리된 외부 브로커로 동작하며, 프로듀서와 컨슈머 클라이언트를 통해 애플리케이션과 통신한다. 따라서 일반적으로&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;도커 컨테이너나 별도의 서버 환경에서 운영&lt;/b&gt;된다. 여기서는 Kafka를 도커 컨테이너 안에서 실행하는 방식으로 설계했다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;스프링 애플리케이션은 로컬 환경에서는 IDE에서, 배포 환경에서는 도커 컨테이너 안에서 실행되므로 어느 환경에서 접속하더라도 도커 내부의 Kafka 브로커를 정확히 식별해 연결해야 한다. 이를 위해 Kafka는 도커 환경에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;포트를 2개 열어 두고, 각각에 리스너를 설정&lt;/b&gt;하여 두 가지 방식으로 접근하는 클라이언트와 모두 통신할 수 있도록 구성한다. 도커 내부에서는 클라이언트끼리 직접 연결 가능하므로 외부에서 접속할 포트의 매핑만 명시했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #0a0a0a; text-align: start;&quot; data-ke-list-type=&quot;disc&quot; data-processed=&quot;true&quot; data-complete=&quot;true&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot; data-hveid=&quot;CAEIBBAA&quot; data-complete=&quot;true&quot; data-sae=&quot;&quot;&gt;&lt;span data-sfc-cp=&quot;&quot; data-complete=&quot;true&quot;&gt;&lt;b&gt;개발 환경 (도커 밖):&lt;/b&gt;&lt;span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;로컬 호스트에서 IDE로 실행 중인 Spring 애플리케이션은&amp;nbsp;&lt;b&gt;KAFKA_BOOTSTRAP_SERVERS=localhost:29092&lt;/b&gt;로 접속&lt;/span&gt;&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc;&quot; data-sae=&quot;&quot; data-complete=&quot;true&quot; data-hveid=&quot;CAEIBBAA&quot;&gt;&lt;span data-complete=&quot;true&quot; data-sfc-cp=&quot;&quot;&gt;&lt;b&gt;배포 환경 (도커 안):&lt;/b&gt;&lt;span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;도커 컨테이너에서 돌아가는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;Spring 애플리케이션은 도커 내부 네트워크를 통해&lt;span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;KAFKA_BOOTSTRAP_SERVERS=&lt;/b&gt;&lt;/span&gt;&lt;b&gt;kafka:9092&lt;/b&gt;로 직접 접속&lt;/span&gt;&lt;span data-complete=&quot;true&quot; data-sfc-cp=&quot;&quot;&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span data-complete=&quot;true&quot; data-sfc-cp=&quot;&quot;&gt;다음은 도커 컨테이너에서 돌아가는 단일 브로커 Kafka 설정이다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1768930834477&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    kafka:
      image: confluentinc/cp-kafka:latest
      container_name: kafka
      ports:
        - &quot;29092:29092&quot;    # 로컬 호스트 접속 매핑용
      environment:
        # 필수 KRaft 설정
        CLUSTER_ID: &quot;hjeeg3q1SoCw7IKoRw-rMQ&quot;
        KAFKA_NODE_ID: 1
        KAFKA_PROCESS_ROLES: &quot;broker,controller&quot;  # 브로커와 컨트롤러 역할
        KAFKA_CONTROLLER_QUORUM_VOTERS: &quot;1@kafka:9093&quot;  # 컨트롤러 통신용 주소

        # 리스너 설정 (KRafts 모드용 CONTROLLER)
        KAFKA_LISTENERS: 'PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093,PLAINTEXT_HOST://0.0.0.0:29092'
        KAFKA_ADVERTISED_LISTENERS: 'PLAINTEXT://kafka:9092,PLAINTEXT_HOST://localhost:29092'
        KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: 'CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT'
        KAFKA_CONTROLLER_LISTENER_NAMES: 'CONTROLLER'

        # Mac(ARM) 호환 설정 및 성능 최적화
        _JAVA_OPTIONS: &quot;-XX:UseSVE=0&quot;
        KAFKA_HEAP_OPTS: &quot;-Xms512M -Xmx512M&quot;   # JVM 힙 메모리
        KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
        KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
        KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
        KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
      volumes:
        - kafka_data:/var/lib/kafka/data   # Docker 내부 볼륨만 사용
      restart: unless-stopped

  volumes:
    kafka_data:&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt; 앞서 언급한 ZooKeeper 기반 구조와의 차이로 인해 KRaft 모드에서는 전용 설정 항목들이 존재한다. &lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;각 브로커는 CLUSTER_ID와 KAFKA_NODE_ID를 통해 소속 클러스터와 노드 식별자를 명시하며 PROCESS_ROLES 설정에 controller를 포함함으로써 컨트롤러 역할을 수행할 수 있게 한다.&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;외부 코디네이터(ZooKeeper) 없이&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;Raft 합의 알고리즘&lt;/b&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;을 통해 활성 컨트롤러를 선출하므로, 노드 간 통신을 위한 격리된 전용 채널이 필수적이다. 이를 위해&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;CONTROLLER_LISTENER_NAMES&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;에 채널 명칭을 정의하고,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;LISTENERS&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;설정에 실제 대기 포트(주로 9093)를 할당한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;컨트롤러 채널은 브로커 간 내부 통신 전용이다. &lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;모든 브로커는 도커 내부에서 실행되며, &lt;/span&gt;도커 환경에서는 컨테이너 이름을 통해 즉시 식별 및 라우팅이 가능하다. 따라서 외부 클라이언트에게 자신의 주소를 알리는 용도인&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;ADVERTISED_LISTENERS&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;에는 컨트롤러 주소를 추가할 필요는 없다&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;LISTENER QUORUM_VOTERS에는 클러스터 내에서 컨트롤러 투표권을 가진 노드들의 ID와 주소가 명시된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f0f2f5; color: #0a0a0a; text-align: start;&quot;&gt;KRaft 모드의 Kafka는 구동 전&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;CLUSTER_ID&lt;/b&gt;&lt;span style=&quot;background-color: #f0f2f5; color: #0a0a0a; text-align: start;&quot; data-complete=&quot;true&quot; data-sfc-cp=&quot;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;생성을 통한 스토리지 초기화 과정을 거치는데, 따로 설정해두지 않았다면 다음 오류 메시지가 뜨며 실행조차 되지 않는다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1168&quot; data-origin-height=&quot;192&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nnJpr/dJMcabCZXc3/9Z51OzXJKAxQW4PVt78zD0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nnJpr/dJMcabCZXc3/9Z51OzXJKAxQW4PVt78zD0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nnJpr/dJMcabCZXc3/9Z51OzXJKAxQW4PVt78zD0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnnJpr%2FdJMcabCZXc3%2F9Z51OzXJKAxQW4PVt78zD0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;500&quot; height=&quot;82&quot; data-origin-width=&quot;1168&quot; data-origin-height=&quot;192&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음은 다중 브로커 구조에서의 설정이다.&lt;/p&gt;
&lt;pre id=&quot;code_1768930876557&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;version: &quot;3.9&quot;
services:
  kafka1:
    image: confluentinc/cp-kafka:latest
    container_name: kafka1
    ports:
      - &quot;29092:29092&quot;
    environment:
      CLUSTER_ID: &quot;hjeeg3q1SoCw7IKoRw-rMQ&quot; # 모든 노드 동일 필수
      KAFKA_NODE_ID: 1
      KAFKA_PROCESS_ROLES: &quot;broker,controller&quot;	# controller 역할 추가
      KAFKA_CONTROLLER_QUORUM_VOTERS: &quot;1@kafka1:9093,2@kafka2:9093,3@kafka3:9093&quot;
      KAFKA_LISTENERS: &quot;PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093,PLAINTEXT_HOST://0.0.0.0:29092&quot;
      KAFKA_ADVERTISED_LISTENERS: &quot;PLAINTEXT://kafka1:9092,PLAINTEXT_HOST://localhost:29092&quot;
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: &quot;CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT&quot;
      KAFKA_CONTROLLER_LISTENER_NAMES: &quot;CONTROLLER&quot;
      KAFKA_INTER_BROKER_LISTENER_NAME: &quot;PLAINTEXT&quot;
    volumes:
      - kafka1_data:/var/lib/kafka/data

  kafka2:
    image: confluentinc/cp-kafka:latest
    container_name: kafka2
    ports:
      - &quot;29093:29093&quot;
    environment:
      CLUSTER_ID: &quot;hjeeg3q1SoCw7IKoRw-rMQ&quot;
      KAFKA_NODE_ID: 2
      KAFKA_PROCESS_ROLES: &quot;broker,controller&quot;
      KAFKA_CONTROLLER_QUORUM_VOTERS: &quot;1@kafka1:9093,2@kafka2:9093,3@kafka3:9093&quot;
      KAFKA_LISTENERS: &quot;PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093,PLAINTEXT_HOST://0.0.0.0:29093&quot;
      KAFKA_ADVERTISED_LISTENERS: &quot;PLAINTEXT://kafka2:9092,PLAINTEXT_HOST://localhost:29093&quot;
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: &quot;CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT&quot;
      KAFKA_CONTROLLER_LISTENER_NAMES: &quot;CONTROLLER&quot;
      KAFKA_INTER_BROKER_LISTENER_NAME: &quot;PLAINTEXT&quot;
    volumes:
      - kafka2_data:/var/lib/kafka/data

  kafka3:
    image: confluentinc/cp-kafka:latest
    container_name: kafka3
    ports:
      - &quot;29094:29094&quot;
    environment:
      CLUSTER_ID: &quot;hjeeg3q1SoCw7IKoRw-rMQ&quot;
      KAFKA_NODE_ID: 3
      KAFKA_PROCESS_ROLES: &quot;broker,controller&quot;
      KAFKA_CONTROLLER_QUORUM_VOTERS: &quot;1@kafka1:9093,2@kafka2:9093,3@kafka3:9093&quot;
      KAFKA_LISTENERS: &quot;PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093,PLAINTEXT_HOST://0.0.0.0:29094&quot;
      KAFKA_ADVERTISED_LISTENERS: &quot;PLAINTEXT://kafka3:9092,PLAINTEXT_HOST://localhost:29094&quot;
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: &quot;CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT&quot;
      KAFKA_CONTROLLER_LISTENER_NAMES: &quot;CONTROLLER&quot;
      KAFKA_INTER_BROKER_LISTENER_NAME: &quot;PLAINTEXT&quot;
    volumes:
      - kafka3_data:/var/lib/kafka/data

volumes:
  kafka1_data:
  kafka2_data:
  kafka3_data:&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다중 브로커 구조에서 추가로 신경쓸 점은 다음과 같다.&lt;b&gt;&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;같은 클러스터를 구성하는 브로커들은 Cluster_ID 를 통일한다.&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;각 브로커는 외부 클라이언트와의 통신을 위한 포트와, 내부 컨트롤러 간 합의를 위한&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;전용 제어 채널&lt;/b&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;에 각각 독립된&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;LISTENER&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;를 바인딩한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;KAFKA_CONTROLLER_QUORUM_VOTERS&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;설정에는 클러스터 내 활성 컨트롤러 투표권을 가진 모든 노드의 정보를 명시한다. 예를 들어,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;1@kafka1:9093&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;노드 ID 1번&lt;/b&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;에 해당하는 컨트롤러의 주소가&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;kafka1:9093&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;임을 의미하며 노드들은 이 명부를 바탕으로 상호 연결 및 코디네이팅을 수행한다.&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1168&quot; data-origin-height=&quot;776&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dF64qu/dJMb99LUndP/P2VshdAMRKQKSCdE6y7Ro1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dF64qu/dJMb99LUndP/P2VshdAMRKQKSCdE6y7Ro1/img.png&quot; data-alt=&quot;KRaft 모드 Kafka 클러스터 구조&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dF64qu/dJMb99LUndP/P2VshdAMRKQKSCdE6y7Ro1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdF64qu%2FdJMb99LUndP%2FP2VshdAMRKQKSCdE6y7Ro1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;399&quot; data-origin-width=&quot;1168&quot; data-origin-height=&quot;776&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;KRaft 모드 Kafka 클러스터 구조&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1792&quot; data-origin-height=&quot;1436&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pvDZT/dJMcai9S4eS/Gyf20aqHEYideCWy9djFHK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pvDZT/dJMcai9S4eS/Gyf20aqHEYideCWy9djFHK/img.png&quot; data-alt=&quot;Zookeeper 기반 Kafka 클러스터 구조&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pvDZT/dJMcai9S4eS/Gyf20aqHEYideCWy9djFHK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FpvDZT%2FdJMcai9S4eS%2FGyf20aqHEYideCWy9djFHK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;481&quot; data-origin-width=&quot;1792&quot; data-origin-height=&quot;1436&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Zookeeper 기반 Kafka 클러스터 구조&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style5&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Kafka 클라이언트 및 인프라 구성&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음은 애플리케이션에 Kafka 도입을 위한 기본적인 클라이언트와 인프라 구성이다&lt;/p&gt;
&lt;pre id=&quot;code_1768989422542&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Configuration
public class KafkaConfig {

    @Value(&quot;${spring.kafka.bootstrap-servers}&quot;)
    private String bootstrapServers;

    @Value(&quot;${spring.kafka.consumer.group-id}&quot;)
    private String groupId;

    @Bean
    public ProducerFactory&amp;lt;String, DomainEvent&amp;gt; producerFactory() {
        Map&amp;lt;String, Object&amp;gt; props = new HashMap&amp;lt;&amp;gt;();
        props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
        props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
        props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class);

        props.put(JsonSerializer.ADD_TYPE_INFO_HEADERS, true);

        return new DefaultKafkaProducerFactory&amp;lt;&amp;gt;(props);
    }

    @Bean
    public KafkaTemplate&amp;lt;String, DomainEvent&amp;gt; kafkaTemplate() {
        return new KafkaTemplate&amp;lt;&amp;gt;(producerFactory());
    }

    @Bean
    public ConsumerFactory&amp;lt;String, DomainEvent&amp;gt; consumerFactory() {
        JsonDeserializer&amp;lt;DomainEvent&amp;gt; deserializer = new JsonDeserializer&amp;lt;&amp;gt;(DomainEvent.class);
        deserializer.addTrustedPackages(&quot;*&quot;);		//보안상 설정 필요
        deserializer.setUseTypeMapperForKey(false);

        Map&amp;lt;String, Object&amp;gt; props = new HashMap&amp;lt;&amp;gt;();
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
        props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId);
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, JsonDeserializer.class);
        props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, &quot;earliest&quot;);

        return new DefaultKafkaConsumerFactory&amp;lt;&amp;gt;(props, new StringDeserializer(), deserializer);
    }

    // 재시도 + DLQ 설정
    @Bean
    public DefaultErrorHandler errorHandler(KafkaTemplate&amp;lt;String, DomainEvent&amp;gt; kafkaTemplate) {
        DeadLetterPublishingRecoverer recoverer =
                new DeadLetterPublishingRecoverer(kafkaTemplate,
                        (record, ex) -&amp;gt; new TopicPartition(record.topic() + &quot;-dlq&quot;, record.partition()));

        // 1초 간격, 최대 3회 재시도
        FixedBackOff backOff = new FixedBackOff(1000L, 3);

        return new DefaultErrorHandler(recoverer, backOff);
    }

    @Bean
    public ConcurrentKafkaListenerContainerFactory&amp;lt;String, DomainEvent&amp;gt; kafkaListenerContainerFactory(KafkaTemplate&amp;lt;String, DomainEvent&amp;gt; kafkaTemplate) {
        ConcurrentKafkaListenerContainerFactory&amp;lt;String, DomainEvent&amp;gt; factory =
                new ConcurrentKafkaListenerContainerFactory&amp;lt;&amp;gt;();
        factory.setConsumerFactory(consumerFactory());
        factory.setConcurrency(3); // 컨슈머 병렬 처리 스레드 수
        factory.setCommonErrorHandler(errorHandler(kafkaTemplate)); //에러 발생시 처리 로직
        return factory;
    }


}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;기본적으로 애플리케이션과 Kafka 브로커 간의 안정적인 메시지 송수신을 위해&lt;/span&gt;&lt;span style=&quot;color: #0a0a0a; text-align: start;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;ProducerFactory&lt;/b&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;와&lt;/span&gt;&lt;span style=&quot;color: #0a0a0a; text-align: start;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;ConsumerFactory&lt;/b&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;를 정의하여 각 인스턴스의 핵심 설정을 구성한다.&lt;/span&gt; &lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;분산 환경에서 이벤트 객체는 네트워크를 통해 전송되어야 하므로, 데이터의 규격화와 호환성을 위해&lt;/span&gt;&lt;span style=&quot;color: #0a0a0a; text-align: start;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;JSON 형식&lt;/b&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;의 직렬화 및 역직렬화 과정을 거친다. &lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;Kafka 클라이언트의 기본 설정을 주입받고&lt;/span&gt;&lt;/span&gt;&amp;nbsp;문자열 Key와 JSON 형식 Value를 처리하는&amp;nbsp;&lt;b&gt;Serializer/Deserializer&lt;/b&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;를 명시적으로 설정하였다&lt;/span&gt;.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Producer가 발행하고, Consumer가 소비할 이벤트 객체를 담는 KafkaTemplate은 ProducerFactory를 인자로 받는다. Kafka에서 메시지는 일단 Producer가 생성하면 구독한 Consumer가 알아서 받아가는 구조이므로 &lt;b&gt;메시지에게 Consumer 정보는 필요없다&lt;/b&gt;.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전에 언급했듯이 &lt;span&gt;&lt;span&gt;Kafka&lt;/span&gt;&lt;/span&gt;는 시스템 장애 발생 시 애플리케이션이 자체적인 복구 메커니즘을 구현할 수 있는 환경을 제공하는데 DefaultErrorHandler로 이를 구현할 수 있다. 처리에 실패한 데이터를 유실하지 않고 별도의 토픽으로 분리하여 &lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;DLQ(Dead Letter Queue)&lt;/b&gt;라는 곳에&amp;nbsp;저장함으로써 &lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&lt;b&gt;장애 원인 분석 및 사후 재처리를 위한 물리적 환경&lt;/b&gt;을 제공&lt;/span&gt;한다. 이를 활용해 애플리케이션은 일부 이벤트 처리 실패가 전체 이벤트 흐름을 중단시키지 않도록 하는 부분 실패 허용 메커니즘을 구현할 수 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #0a0a0a; text-align: start;&quot; data-complete=&quot;true&quot; data-processed=&quot;true&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot; data-sae=&quot;&quot; data-complete=&quot;true&quot; data-hveid=&quot;CAEIBxAA&quot;&gt;&lt;span data-complete=&quot;true&quot; data-sfc-cp=&quot;&quot;&gt;&lt;b&gt;재시도 메커니즘 (FixedBackOff)&lt;/b&gt;: 일시적인 네트워크 장애나 일시적 오류 시 1초 간격으로 3번 재시도하도록 한 재처리 로직&lt;/span&gt;&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc;&quot; data-sae=&quot;&quot; data-complete=&quot;true&quot; data-hveid=&quot;CAEIBxAB&quot;&gt;&lt;span data-complete=&quot;true&quot; data-sfc-cp=&quot;&quot;&gt;&lt;b&gt;DLQ (Dead Letter Queue)&lt;/b&gt;: 3번의 재시도 후에도 실패한 작업은&lt;span&gt;&amp;nbsp;&lt;/span&gt;원본토픽-dlq라는 별도의 스토리지로 격리,&lt;/span&gt;&lt;span data-complete=&quot;true&quot; data-sfc-cp=&quot;&quot;&gt; 일시적이지 않은 장애나 서버 재시작에 상황에 대응하여 완료하지 못한 작업을 기억&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;ConcurrentKafkaListenerContainerFactory는 KafkaListener의 실행 환경을 구성하는 핵심 설정&lt;/b&gt;이다. 컨슈머 그룹 내에서 파티션의 병렬 처리 수준을 설정하고, 메시지 전달 및 복구 매커니즘을 제어함으로써 Kafka 컨슈머의 생명주기를 오케스트레이션한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;concurrency를 3으로 설정하면 리스너 내에서 브로커와 통신하는 전용 컨슈머 워커 스레드 3개가 할당된다. 이 스레드는 각자 &lt;b&gt;전용 파티션을 담당해서&lt;/b&gt; &lt;b&gt;폴링(Polling)을 수행하며 &lt;/b&gt;파티션 단위 병렬 처리를 가능하게 한다. 이 설정을 주입받은 컨슈머 그룹 수 X concurrency 값 만큼의 스레드 자원이 할당되므로&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt; 서버의 자원을 고려하여 설정해야한다. 특히&amp;nbsp;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;토픽의 파티션 개수보다 큰 값을 설정하는 경우 항상 유휴 상태인 스레드가 생기므로 주의해야한다.&amp;nbsp;&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-complete=&quot;true&quot; data-sfc-cp=&quot;&quot;&gt;기본적으로 이 설정은 &lt;b&gt;Kafka 전용 이벤트의 추상화인 DomainEvent 인터페이스를 기준으로 공통 적용&lt;/b&gt;되도록 햇다. 애플리케이션에서 상황에 따라 발행되는 실제 이벤트 객체는 &lt;b&gt;모두 DomainEvent를 상속받아 각 도메인별 비즈니스 로직에 맞게 확장&lt;/b&gt;되도록 설계했다.&lt;/span&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Kafka를 적용한 이벤트 처리 구조&lt;/h2&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;공연 도메인 서비스의 등록 승인 메서드&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 Kafka를 도입함으로써, 기존 예시의 등록 승인 흐름에서 세 종류의 알림 생성이 어떻게 처리되는지 살펴보자.&lt;/p&gt;
&lt;pre id=&quot;code_1768931718996&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Transactional
    public AdminAmateurShowSummaryResponseDTO approveShow(Long showId) {
        
        AmateurShow show = amateurShowRepository.findById(showId)
                .orElseThrow(() -&amp;gt; new GeneralException(ErrorStatus.AMATEURSHOW_NOT_FOUND));

        show.approve();

        Member performer  = show.getMember();

        // 승인 트랜잭션 커밋에 대해 이벤트 발행
        eventPublisher.publishEvent(
                new ApproveCommitEvent(show.getId(), performer.getId()
                )
        );

        return AdminAmateurShowSummaryResponseDTO.from(show);
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 도메인 로직상 순서 보장을 위해&lt;b&gt; @TransactionalEventListener의 phase=AFTER_COMMIT&lt;/b&gt;을 이용한다. approveShow()는 먼저 커밋에 대한 &lt;b&gt;Spring 이벤트인 ApproveCommitEvent를 발행&lt;/b&gt;해서 알림 생성 로직이 승인 트랜잭션 커밋 이후에 호출되도록 한다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;도메인 이벤트 리스너&lt;/h3&gt;
&lt;pre id=&quot;code_1768931732918&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class ApproveCommitEventListener {
    private final ApprovalShowProducer approvalShowProducer;

    @Async
    @TransactionalEventListener (phase = TransactionPhase.AFTER_COMMIT)
    public void onApproveCommit(ApproveCommitEvent event) {

        //APPROVED 수정 트랜잭션 커밋 이벤트 감지 후 kafka 이벤트 발송
        try {
            approvalShowProducer.publish(
                    new ApprovalShowEvent(event.amateurShowId(), event.performerId())
            );
        } catch (Exception e) {
            throw new IllegalStateException(
                    &quot;승인 이벤트 Kafka 발행 실패&quot;,
                    e
            );
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AFTER_COMMIT 설정으로 승인 트랜잭션 후에 호출된 onApproveCommit 리스너는 Kafka 전용 이벤트 ApprovalShowEvent를 생성해서 approvalShowProducer.publish()를 호출한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 Kafka 프로듀서의 publish 메서드는 내부적으로 KafkaTemplate.send()를 호출하는데 이는 별도의 자원을 사용해 외부 브로커와의&amp;nbsp; 통신을 수반하는 작업이다. &lt;b&gt;일반적으로 이런 외부 연동 로직은 핵심 도메인 흐름과 격리하기 위해 @Async를 적용하여 비동기로 처리한다.&lt;/b&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Kafka 이벤트&lt;/h3&gt;
&lt;pre id=&quot;code_1768993244031&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public record ApprovalShowEvent(
        Long amateurShowId,
        Long performerId
) implements DomainEvent {

    @Override
    public DomainEventType getEventType() {
        return DomainEventType.SHOW_APPROVED;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;ApprovalShowEvent는 앞서 설계한&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;Kafka 전용 이벤트 DomainEvent의 구현체&lt;/b&gt;이다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Kafka 프로듀서&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ApprovalShowEvent를 Kafka 브로커에게 전송하는 프로듀서 클라이언트인 approvalShowProducer는 다음과 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1768931745906&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
@RequiredArgsConstructor
public class ApprovalShowProducer {

    private final KafkaTemplate&amp;lt;String, DomainEvent&amp;gt; kafkaTemplate;
    private static final String TOPIC = &quot;approval-show-topic&quot;;

    public void publish(ApprovalShowEvent event) {
        if (event == null) return;

        // amateurShowId로 파티션
        kafkaTemplate.send(TOPIC, event.amateurShowId().toString(), event);
    }

}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;KafkaTemplate에 ApprovalShowEvent를 담아 &lt;b&gt;approval-show-topic을 토픽을 붙여&lt;/b&gt; &lt;b&gt;브로커에게 전송&lt;/b&gt;한다. 이때 파티션 결정에 쓰일&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;nbsp;key로&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;amateurShowId를 보내는데, 이는 Kafka 서버에서 파티셔닝을 위한 해시 계산에 사용되기 때문에 String으로 변환하여 전달한다. 이 메시지를 받은 Kafka 브로커는 ApprovalShowEvent를 Topic에 맞게 분류하고 전달받은 amateurShowId로 저장될 파티션을 결정한다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Kafka 메시지 토픽 설정&lt;/h3&gt;
&lt;pre id=&quot;code_1768996078544&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Configuration
public class KafkaTopicConfig {

    @Bean
    public NewTopic approvalShowTopic() {
        return TopicBuilder.name(&quot;approval-show-topic&quot;)
                .partitions(3)
                .replicas(3)	//브로커 수보다 클 수 없음
                .build();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Kafka 서버가 3개의 브로커 구조라고 가정하자. 위의 설정에 따라 approval-show-topic은 3개의 파티션과 3개의 replica로 구성된다. 예를 들어 amateuerShowId가 34, 15, 60인 이벤트가 연속적으로 들어왔다고 하면 브로커별 approval-show-topic의 구조는 다음과 같다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2046&quot; data-origin-height=&quot;566&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Gd8GV/dJMcabJLuCP/KMUmLvFZk7XM4bUaZxr3vK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Gd8GV/dJMcabJLuCP/KMUmLvFZk7XM4bUaZxr3vK/img.png&quot; data-alt=&quot;id=34인 이벤트 전송 시&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Gd8GV/dJMcabJLuCP/KMUmLvFZk7XM4bUaZxr3vK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FGd8GV%2FdJMcabJLuCP%2FKMUmLvFZk7XM4bUaZxr3vK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2046&quot; height=&quot;566&quot; data-origin-width=&quot;2046&quot; data-origin-height=&quot;566&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;id=34인 이벤트 전송 시&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;2018&quot; data-origin-height=&quot;518&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b9YqRW/dJMcagxvFjS/vhR01PszHogRoKZsqpY54k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b9YqRW/dJMcagxvFjS/vhR01PszHogRoKZsqpY54k/img.png&quot; data-alt=&quot;id=15인 이벤트 전송 시&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b9YqRW/dJMcagxvFjS/vhR01PszHogRoKZsqpY54k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb9YqRW%2FdJMcagxvFjS%2FvhR01PszHogRoKZsqpY54k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2018&quot; height=&quot;518&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;2018&quot; data-origin-height=&quot;518&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;id=15인 이벤트 전송 시&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;2020&quot; data-origin-height=&quot;540&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cITphh/dJMcabiGCiD/Ux0aBYszn94TnALwNHuTh0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cITphh/dJMcabiGCiD/Ux0aBYszn94TnALwNHuTh0/img.png&quot; data-alt=&quot;id=60인 이벤트 전송 시&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cITphh/dJMcabiGCiD/Ux0aBYszn94TnALwNHuTh0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcITphh%2FdJMcabiGCiD%2FUx0aBYszn94TnALwNHuTh0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2020&quot; height=&quot;540&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;2020&quot; data-origin-height=&quot;540&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;id=60인 이벤트 전송 시&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Kafka 컨슈머&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 이렇게 Kafka 브로커에 저장된 이벤트를 컨슈머가 어떻게 소비하는지 알아보자.&lt;/p&gt;
&lt;pre id=&quot;code_1768931760898&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
@RequiredArgsConstructor
public class ApprovalConsumerForSubscribers {

    private final NoticeService noticeService;

    @KafkaListener(
            topics = &quot;approval-show-topic&quot;,
            groupId = &quot;subscriber-group&quot;,
            containerFactory = &quot;kafkaListenerContainerFactory&quot;
    )

    @Transactional
    public void consume(ApprovalShowEvent event) {
        if (event == null) return;

        noticeService.notifySubscribers(event);

    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1769004392369&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
@RequiredArgsConstructor
public class ApprovalConsumerForPerformer {

    private final NoticeService noticeService;

    @KafkaListener(
            topics = &quot;approval-show-topic&quot;,
            groupId = &quot;approved-group&quot;,
            containerFactory = &quot;kafkaListenerContainerFactory&quot;
    )

    @Transactional
    public void consume(ApprovalShowEvent event) {
        if (event == null) return;

        noticeService.notifyPerformer(event);

    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1769004491375&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
@RequiredArgsConstructor
public class ApprovalConsumerForRecommendation {

    private final NoticeService noticeService;

    @KafkaListener(
            topics = &quot;approval-show-topic&quot;,
            groupId = &quot;recommended-group&quot;,
            containerFactory = &quot;kafkaListenerContainerFactory&quot;
    )
    @Transactional
    public void consume(ApprovalShowEvent event) {
        if (event == null) return;

        noticeService.notifyOthers(event);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@KafkaListener로 approval-show-topic 토픽을 구독하면 브로커로부터 해당 토픽의 메시지를 받아올 수 있다. groupId로 묶인 컨슈머 그룹은 &lt;b&gt;물리적으로 분리된 환경에서도 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;일관성이 보장된 데이터를 제공받아&lt;/span&gt; 병렬 처리가 가능&lt;/b&gt;하다. &lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot; data-processed=&quot;true&quot; data-complete=&quot;true&quot; data-sfc-cp=&quot;&quot;&gt;Kafka는 동일 그룹 내의 컨슈머들에게 파티션을 중복 없이 배분하여 처리량을 수평적으로 확장함과 동시에 &lt;b&gt;각 파티션에는 할당된&amp;nbsp; 담당 컨슈머만 접근하도록 제어&lt;/b&gt;함으로써&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;파티션 단위의 순차 처리를&lt;/b&gt; 보장한다.&lt;/span&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1854&quot; data-origin-height=&quot;1426&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b4f2pB/dJMb99S6FkI/kpVvUz0BOIaVWXPtLOFbp0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b4f2pB/dJMb99S6FkI/kpVvUz0BOIaVWXPtLOFbp0/img.png&quot; data-alt=&quot;Kafka 적용 비동기 이벤트 처리 구조에서 approveShow의 스레드별 실행 흐름&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b4f2pB/dJMb99S6FkI/kpVvUz0BOIaVWXPtLOFbp0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb4f2pB%2FdJMb99S6FkI%2FkpVvUz0BOIaVWXPtLOFbp0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;800&quot; height=&quot;615&quot; data-origin-width=&quot;1854&quot; data-origin-height=&quot;1426&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Kafka 적용 비동기 이벤트 처리 구조에서 approveShow의 스레드별 실행 흐름&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;위는 &lt;span style=&quot;color: #000000;&quot;&gt;amateurShowId=8인&lt;/span&gt; 공연의 등록을 승인함으로써 프로듀서가 approval-show-topic 메시지를 발행하고, 이 토픽을 구독한 세 컨슈머 그룹이 받아 각자 스레드에서 후속 작업을 비동기로 처리하는 상황이다. T2, T3, T4 스레드를 할당받은 컨슈머 그룹은 각각 구독자 알림, 등록자 알림, 추천 알림 생성을 수행한다.&lt;/span&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1936&quot; data-origin-height=&quot;1060&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qT8y8/dJMcafFNStU/jj7RB26T8r7yDNC5skbzb0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qT8y8/dJMcafFNStU/jj7RB26T8r7yDNC5skbzb0/img.png&quot; data-alt=&quot;Kafka 적용 비동기 이벤트 처리 구조에서 approveShow후 rejectShow의 스레드별 실행 흐름&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qT8y8/dJMcafFNStU/jj7RB26T8r7yDNC5skbzb0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqT8y8%2FdJMcafFNStU%2Fjj7RB26T8r7yDNC5skbzb0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1936&quot; height=&quot;1060&quot; data-origin-width=&quot;1936&quot; data-origin-height=&quot;1060&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Kafka 적용 비동기 이벤트 처리 구조에서 approveShow후 rejectShow의 스레드별 실행 흐름&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;위는 amateurShowId=23인 공연의 등록을 승인하고, &lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;amateurShowId=26인 공연의 등록을 반려한 상황이다. 프로듀서가 approval-show-topic 메시지를 발행해서 Broker 1에게 전송하자마자 구독 중인 컨슈머는 이를 가져와 세 종류의 알림 생성을 비동기로 처리한다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt; 메인 애플리케이션은 곧바로 reject-show-topic을 발행해서 Broker 2에게 전송하고, rejected-group의 컨슈머는 브로커로부터 이를 받아와 T5 스레드에서 등록 반려에 따른 후속 작업을 처리한다. T2, T4 스레드에서는 등록 승인에 따른 후속 작업이 실행 중인 와중에 T5에서는 등록 거부의 후속 작업이 독립적으로 실행되며 높은 처리량을 확보한다.&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;i&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start; font-family: 'Noto Serif KR';&quot;&gt;Kafka를 매개체로 활용하는 것만으로도 비동기 처리를 구현하고, 프로듀서와 컨슈머의 생명주기를 격리할 수 있다.&lt;/span&gt;&lt;/i&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;Producer-Message-Consumer 구조는&amp;nbsp;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;스프링 컨텍스트 내부에서 관리되던 객체를 직접 소비하는 방식이 아니라, &lt;b&gt;외부 브로커를 통해 유입된 데이터를 독립적으로 처리하는 구조&lt;/b&gt;이다. &lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;따라서 클라이언트 설정에서&amp;nbsp;&lt;b&gt;K&lt;/b&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&lt;b&gt;afkaListenerContainerFactory는 JVM으로부터 각&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt; 컨슈머 그룹이&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;고유한 워커 스레드&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;를 물리적으로 할당받도록 한다&lt;/span&gt;&lt;/b&gt;.&lt;/span&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;@KafkaListener로 이 설정을 주입받은 각 컨슈머는 각자의 스레드를 할당받고 애플리케이션의 메인 흐름과 분리되어 실행된다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;따라서&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;같은 JVM 안에 있더라도 프로듀서와 컨슈머는&amp;nbsp;&lt;/span&gt;서로 다른 스레드&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;에서 동작하며, 컨슈머는 프로듀서가 아닌&lt;b&gt; Kafka 브로커로부터 전달된 메시지를 비동기적으로 처리&lt;/b&gt;한다. 이로 인해 애플리케이션 재시작 상황에서도 &lt;b&gt;유실 걱정 없이&lt;/b&gt; &lt;b&gt;안정적 재처리를 보장&lt;/b&gt;할 수 있다.&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt; 이 구조는 동일한 애플리케이션 내에 구현되어 있더라도 &lt;b&gt;호출자와 피호출자의 생명주기를 논리적으로 격리한 효과&lt;/b&gt;를 내어 향후 물리적으로 격리되는&lt;b&gt; MSA 구조에서도 바로 적용 가능&lt;/b&gt;한 유연한 설계가 된다. 내구성 뿐만 아니라, 하드웨어의 추가만으로 무한한 처리량 증가를 보장하므로 앞서 정의한 5가지 문제&lt;span style=&quot;color: #ee2323;&quot;&gt;(응답 지연 문제, 순서 문제, 롤백 전파 문제, 처리량 문제, 내구성 문제)&lt;/span&gt;를 모두 해결한다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Kafka가 적용된 비동기 처리 구조의 가치&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;단일 애플리케이션&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Kafka의 진정한 가치는 분산 애플리케이션에서 마이크로서비스의 병렬 처리에서 드러나지만 단일 애플리케이션이라고 그 체감이 아예 없진 않다. 특히 fan-out 작업과 같은 대규모 처리 상황에서 &lt;b&gt;높은 안정성&lt;/b&gt;을 유지한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;시스템 내 모든 공연의 등록을 취소하는 메서드가 있다고 가정하자. 이 메서드를 수행하면 DB에 저장된 모든 공연의 등록 상태 column이 REJECTED로 변경되며 이에 따른 후속 작업은 rejected-show-topic을 구독한 KafkaListener에 의해 수행된다. 시스탬에 10000개의 공연이 등록된 상태라고 하자.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1972&quot; data-origin-height=&quot;1119&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/9ImOq/dJMcahDxoWG/f7PXNFbb3N7M2lgDWa8wZK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/9ImOq/dJMcahDxoWG/f7PXNFbb3N7M2lgDWa8wZK/img.png&quot; data-alt=&quot;Kafka 적용 비동기 이벤트 처리 구조의 10000개 이벤트 처리 동작&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/9ImOq/dJMcahDxoWG/f7PXNFbb3N7M2lgDWa8wZK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F9ImOq%2FdJMcahDxoWG%2Ff7PXNFbb3N7M2lgDWa8wZK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1972&quot; height=&quot;1119&quot; data-origin-width=&quot;1972&quot; data-origin-height=&quot;1119&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Kafka 적용 비동기 이벤트 처리 구조의 10000개 이벤트 처리 동작&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;위와 같이 3개의 replica와 partition으로 설정된 rejected-show-topic과 concurrency=3으로 설정된 리스너 환경에서 메인 서비스는 비동기적으로 처리되므로 10000번째 이벤트 발행 즉시 응답을 반환할 수 있다. 이 메시지를 구독한 rejected-group의 3개의 &lt;b&gt;워커 스레드들은&lt;/b&gt; 할당받은 파티션을 기반으로 &lt;b&gt;리더 브로커를 폴링(polling)&lt;/b&gt;하며 메시지를 소비한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;생성된 10000개의 이벤트들은 key에 따라 각 파티션에 균일하게(약 &lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;3333개씩&lt;span&gt;) 분산 저장&lt;/span&gt;&lt;/span&gt;되며 &lt;b&gt;컨슈머는 설정된 배치 단위(max.poll.records)만큼 퍼와서&lt;/b&gt; 각자 스레드에서 처리한다. 이때 각 워커 스레드는 &lt;/span&gt;&lt;b&gt;자신에게 할당된 파티션의 메시지&lt;/b&gt;만을 순차적으로 처리하며, 이러한 구조를 통해 제한된 자원 내에서도 안정적인 병렬 처리가 가능하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Kafka 적용 vs 미적용&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Kafka 구조에서는 메시지가 &lt;b&gt;토픽과 파티션을 통해 디스크 기반으로 분산 저장&lt;/b&gt;되며 컨슈머는 자신의&amp;nbsp;&lt;b&gt;처리 가능한 속도에 맞춰 메시지를 소비한다. &lt;/b&gt;이로 인해 자연스럽게 안정적인&amp;nbsp;&lt;b&gt;backpressure가 보장&lt;/b&gt;된다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&amp;nbsp;backpressure : 시스템의 처리 속도를 초과하는 입력을 제어함으로써 전체 시스템의 안정성을 유지하는 메커니즘&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Kafka는 Producer의 전송 속도가 Consumer의 처리 속도를 초과하더라도 밀린 메시지를 &lt;b&gt;디스크 기반 로그에 저장&lt;/b&gt;하여 안정적으로 버퍼링한다. 또한 Consumer가 처리 가능한 양만큼 데이터를 가져오는 &lt;b&gt;pull 방식으로 동작&lt;/b&gt;하기 때문에 시스템 전체의 처리 속도는 자연스럽게 Consumer의 처리 능력에 맞춰진다. 이와 같은 구조를 통해 Kafka는 유입 속도와 처리 속도간 불일치를 효과적으로 흡수하며 안정적인 backpressure를 제공하는 &lt;b&gt;버퍼&lt;/b&gt; 역할을 수행한다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 Spring이 제공하는 @Async 기반 이벤트 처리 구조는&lt;b&gt;&amp;nbsp;스레드 풀과 메모리 큐를 기반&lt;/b&gt;으로 동작하며 &lt;b&gt;동시에 실행 가능한 작업 수는 스레드 풀의 크기&lt;/b&gt;로 제한된다. 스레드 수를 초과한 작업은 &lt;b&gt;JVM 힙 메모리 상의 큐에 적재&lt;/b&gt;되며 작업의 유입 속도가 처리 속도를 초과할 경우 큐에 작업이 지속적으로 누적된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 과정에서 큐의 크기가 제한되어 있지 않거나 과도하게 크다면 메모리 사용량이 점점 증가하게 되고 결국 힙 메모리가 고갈되면서 &lt;b&gt;OutOfMemoryError(OOM)&lt;/b&gt;에 의해 프로그램이 다운될 수 있다. 반대로 큐의 크기가 제한된 경우에는 이를 초과한 작업이 거부되거나 손실될 수 있어 안정적인 처리가 어려워진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 메모리 사용량이 증가하는 상황에서는 이를 회수하기 위한 &lt;b&gt;GC(Garbage Collection)&lt;/b&gt;가 빈번하게 수행되고 이 과정에서 &lt;b&gt;Stop-The-World(STW)&lt;/b&gt;가 발생하여 애플리케이션의 응답 지연이 나타난다. 이러한 현상이 반복되면 전체 시스템의 처리량이 급격히 저하되고 서비스 중단으로 이어질 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과적으로 @Async 기반 구조는 작업이 메모리에 의존하여 누적되기 때문에 대규모 fan-out과 같이 순간적으로 많은 이벤트가 발생하는 상황에서 안정적인 부하 제어가 어렵고 Kafka와 같은 메시지 큐 기반 구조에 비해 확장성과 안정성이 떨어진다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;분산 애플리케이션&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Kafka의 진가가 가장 잘 드러나는 건 MSA 구조에서이다. 여러 개의 마이크로서비스들이 Kafka 브로커를 통해 정보를 전달하고 각자의 작업을 유기적으로 수행한다.&amp;nbsp; &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;이와 같은 예시 상황을 가정해보자.&lt;/span&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;다음은 커뮤니티 기능과 관련있는 3개의 마이크로서비스를 운영하는 분산 애플리케이션 환경이다.&lt;/span&gt;&lt;br /&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;먼저 &lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;Community 마이크로서비스&lt;/b&gt;&lt;/span&gt;는 게시글, 댓글, 대댓글을 관리하는 &lt;b&gt;핵심적인 커뮤니티 도메인 기능&lt;/b&gt;을 담당한다. 새로운 댓글이 생성되면 comment-created-topic 메시지를 브로커에 전송한다&lt;/span&gt;.&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;br /&gt;&lt;span style=&quot;background-color: #9feec3;&quot;&gt;&lt;b&gt;Patrol 마이크로서비스&lt;/b&gt;&lt;/span&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;는 커뮤니티의 건전성을 위해 악의적인 내용이 있는지 AI를 이용하여 주기적으로 모니터링한다. Community 마이크로서비스에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;발행한 모든 토픽을 구독하는 community-check-group 리스너&lt;/b&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;를 운영한다. 만약 악성 댓글이 감지된다면 해당 댓글을 삭제하고 malicious-comment-topic 메시지를 브로커에 전송한다.&lt;br /&gt;&lt;br /&gt;&lt;span style=&quot;background-color: #9feec3;&quot;&gt;&lt;b&gt;User Report 마이크로서비스&lt;/b&gt;&lt;/span&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;는 사용자 행동 데이터를 수집하여 이상 행동 감지 및 통계 지표를 제공한다. 이상 행동이나 악의적인 행동을 한 유저는 블랙리스트에 기록해둔다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;malicious가 붙은 모든 토픽을 구독하는 malicious-event-group 리스너&lt;/b&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;를 운영한다.&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1899&quot; data-origin-height=&quot;1395&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bMqRJQ/dJMcadnAE9S/5OSXD5BmBOWCDkG7T55NgK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bMqRJQ/dJMcadnAE9S/5OSXD5BmBOWCDkG7T55NgK/img.png&quot; data-alt=&quot;분산 애플리케이션에서 커뮤니티 기능을 담당하는 마이크로서비스(community-check-group의 concurrency=1)&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bMqRJQ/dJMcadnAE9S/5OSXD5BmBOWCDkG7T55NgK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbMqRJQ%2FdJMcadnAE9S%2F5OSXD5BmBOWCDkG7T55NgK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1899&quot; height=&quot;1395&quot; data-origin-width=&quot;1899&quot; data-origin-height=&quot;1395&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;분산 애플리케이션에서 커뮤니티 기능을 담당하는 마이크로서비스(community-check-group의 concurrency=1)&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2224&quot; data-origin-height=&quot;1392&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/deLxrL/dJMcaih92hh/8XOwg7Yt3DZjAeWTKrWsbk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/deLxrL/dJMcaih92hh/8XOwg7Yt3DZjAeWTKrWsbk/img.png&quot; data-alt=&quot;분산 애플리케이션에서 커뮤니티 기능을 담당하는 마이크로서비스(community-check-group의 concurrency=3)&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/deLxrL/dJMcaih92hh/8XOwg7Yt3DZjAeWTKrWsbk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdeLxrL%2FdJMcaih92hh%2F8XOwg7Yt3DZjAeWTKrWsbk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2224&quot; height=&quot;1392&quot; data-origin-width=&quot;2224&quot; data-origin-height=&quot;1392&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;분산 애플리케이션에서 커뮤니티 기능을 담당하는 마이크로서비스(community-check-group의 concurrency=3)&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;위의 그림은 악성 댓글 2개를 작성했을 때 각 마이크로서비스가 어떻게 작동하는지를 나타낸 그림으로 community-check-group의 concurrency 설정이 각각 1, 3인 경우이다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;먼저 Community 마이크로서비스에서 2개의 댓글 생성에 대한 이벤트를 생성해서 Broker 1,2로 각각 전송했다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Patrol 마이크로서비스의 community-check-group 리스너가 Broker 1로부터 첫번째 댓글에 대한 메시지를 받아와 내용을 검사하는 checkMalicious를 실행한다. 이에 악성 내용을 감지하고 삭제한 후 malicious-comment-topic 메시지를 Broker 2에 전송한다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;이후 리스너가 Broker 2로부터 받아온 두번째 댓글에 대해서도 checkMalicious를 실행한다. 두번째 댓글에서도 악성 내용을 감지하고 삭제 후 malicious-comment-topic 메시지를 Broker 2에 전송한다.(concurrency = 1 일때 동기, 3일때 비동기 처리)&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;User Report 마이크로서비스의 malicious-event-group 리스너는 Broker 2에서 malicious-comment-topic 메시지를 받아와 댓글 작성자를 블랙리스트에 올리는 blackList 메서드를 실행한다.&lt;/span&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;서버 장애로 인한 재시작 상황&lt;/span&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2068&quot; data-origin-height=&quot;1106&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wylcs/dJMcafeInXC/OEP30xmpBkdF8p6o39KWYk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wylcs/dJMcafeInXC/OEP30xmpBkdF8p6o39KWYk/img.png&quot; data-alt=&quot;서버 장애로 인한 재시작 상황(community-check- group의 concurrency=3)&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wylcs/dJMcafeInXC/OEP30xmpBkdF8p6o39KWYk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fwylcs%2FdJMcafeInXC%2FOEP30xmpBkdF8p6o39KWYk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;800&quot; height=&quot;428&quot; data-origin-width=&quot;2068&quot; data-origin-height=&quot;1106&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;서버 장애로 인한 재시작 상황(community-check- group의 concurrency=3)&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Patrol 마이크로서비스가 첫번째 댓글에 대한 메시지 처리 후 &lt;b&gt;두번째 댓글 이벤트에 대한&amp;nbsp;checkMalicious 실행 중 다운&lt;/b&gt;됐다고 하자.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Patrol 마이크로서비스가&lt;b&gt; 중단된 작업을 재개하는 과정&lt;/b&gt;은 다음과 같다.(concurrency=3)&lt;/span&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;서버 재시작 후, community-check-group 리스너의 워커 스레드(T1, T2, T3)는&lt;b&gt; 그룹 코디네이터&lt;/b&gt; &lt;b&gt;역할을 수행하는 브로커&lt;/b&gt;에게&amp;nbsp;comment-created-topic과 각자 담당 파티션을 보내며 &lt;b&gt;커밋 오프셋(해당 파티션에서 다음 순서로 처리해야 할 오프셋)&lt;/b&gt;을 물어본다. &lt;b&gt;(T1은 parition 2, T2는 partition 1, T3는 partition 0 담당)&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;커밋 오프셋이 변한 과정은 다음과 같다.(partition 0, partition 1, parition 2), LEO(Log End Offset)&amp;nbsp;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;첫번째 댓글 이벤트 생성, 처리 전: (3, 1, 2), LEO=(3, 1, 3)&lt;/li&gt;
&lt;li&gt;첫번째 댓글 이벤트 처리 후 커밋: (3, 1, 3), LEO=(3, 1, 3)&lt;/li&gt;
&lt;li&gt;두번째 댓글 이벤트 생성, 처리 전: (3, 1, 3), LEO=(3, 2, 3)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;두번째 댓글 이벤트 처리 중 서버 재시작: (3, 1, 3)&lt;/b&gt;, LEO=(3, 2, 3)&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;그룹 코디네이터 역할을 수행하는 브로커는 내부적으로 운영하는 &lt;b&gt;컨슈머 오프셋 로그를 참조하여&lt;/b&gt; 각 워커 스레드에게 &lt;b&gt;(담당 파티션, 커밋 오프셋, 리더 브로커 주소)&lt;/b&gt; 형식의 응답을 보낸다. 응답을 받은 각 워커 스레드는 자신이 담당하는 파티션의 리더 브로커에게 &lt;b&gt;커밋&amp;nbsp;오프셋에 해당하는 메시지의 fetch 요청&lt;/b&gt;을 한다.&lt;b&gt;(T1은 3, T2는 1, T3는 3 요청)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;T1은 Broker 1에게 partition 2의 &lt;b&gt;#3&lt;/b&gt;에 해당하는 메시지를 요청&lt;/li&gt;
&lt;li&gt;T2는 Broker 2에게 partition 1의 &lt;b&gt;#1&lt;/b&gt;에 해당하는 메시지를 요청&lt;/li&gt;
&lt;li&gt;T3는 Broker 1에게 partition 0의 &lt;b&gt;#3&lt;/b&gt;에 해당하는 메시지를 요청&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;각 브로커는 요청받은 오프셋의 메시지가 존재하면 바로 전송, &lt;b&gt;없으면 최대한 늦게 응답&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;데이터가 없을 때마다 &quot;없음&quot;이라고 즉시 응답할 경우 컨슈머는 바로 &quot;지금은?&quot;이라고 물어보는 무의미한 폴링이 발생하며 네트워크 자원이 낭비&lt;/blockquote&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Broker 1은 T1로부터 요청받은 partition 2의 #3인 메시지가 없음(0, 1, 2까지만 존재) -&amp;gt;&lt;b&gt; 지연 응답&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Broker 2는 T2로부터 요청받은 partition 1의 #1인 메시지가 존재&lt;/b&gt;(0, 1 존재) -&amp;gt; &lt;b&gt;즉시 응답&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;Broker 1은 T3로부터 요청받은 partition 0의 #3인 메시지가 없음(0, 1, 2까지만 존재) -&amp;gt; &lt;b&gt;지연 응답&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;5. 서버 재시작 후 &lt;b&gt;T2는 Broker 2로부터 partition 1의 #1인 메시지를 전달&lt;/b&gt;받고 두번째 댓글 이벤트에 대해 checkMalicious를 호출하여 &lt;b&gt;중단된&lt;/b&gt;&amp;nbsp;&lt;b&gt;작업을 안전하게 재처리&lt;/b&gt;하게 된다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2732&quot; data-origin-height=&quot;1193&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/exrgLh/dJMcacPrvOW/eLVLzUWZv3JayfamdkCm4k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/exrgLh/dJMcacPrvOW/eLVLzUWZv3JayfamdkCm4k/img.png&quot; data-alt=&quot;서버 재시작 상황에서 comment-created-topic의 파티션 상태&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/exrgLh/dJMcacPrvOW/eLVLzUWZv3JayfamdkCm4k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FexrgLh%2FdJMcacPrvOW%2FeLVLzUWZv3JayfamdkCm4k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;700&quot; height=&quot;306&quot; data-origin-width=&quot;2732&quot; data-origin-height=&quot;1193&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;서버 재시작 상황에서 comment-created-topic의 파티션 상태&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR'; color: #000000;&quot;&gt;Kafka의 메시지 시스템은 기존 단일 애플리케이션에서 인메모리로 처리되던 서비스 간 데이터 전송과 메서드 호출 구조를 JVM 바깥으로 옮겨, &lt;b&gt;MSA 환경에서 수평적 확장과 마이크로서비스 간 통신을 내구성 있게 보장&lt;/b&gt;한다. &lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Kafka를 적용한 이벤트 기반 비동기 작업 처리 구조로 구현한 알림 기능이 제대로 동작하는지는 다음 시간에 검증해보자.&lt;/p&gt;</description>
      <category>dev/infra</category>
      <category>concurrency</category>
      <category>docker-compose</category>
      <category>Kafka</category>
      <category>KafkaConfig</category>
      <category>kafkaListenerContainerFactory</category>
      <category>msa</category>
      <category>분산 애플리케이션</category>
      <category>비동기</category>
      <category>컨슈머 오프셋</category>
      <author>cusum26</author>
      <guid isPermaLink="true">https://imjyh01.tistory.com/10</guid>
      <comments>https://imjyh01.tistory.com/10#entry10comment</comments>
      <pubDate>Thu, 22 Jan 2026 04:47:17 +0900</pubDate>
    </item>
  </channel>
</rss>