<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>성일노트</title>
    <link>https://yeoseongil.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Sun, 5 Apr 2026 07:26:28 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>여성일</managingEditor>
    <item>
      <title>[리팩토링] 09. DesignSystem 플랫폼 호환성 문제 해결</title>
      <link>https://yeoseongil.tistory.com/234</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;iOS 중심으로 시작했던 탭탭 프로젝트를 macOS까지 확장하면서, DesignSystem 모듈의 구조적 한계가 드러났습니다. &lt;b&gt;UIKit 의존 코드가 곳곳에 섞여 있었고, 공용 컴포넌트라고 생각했던 컴포넌트들 역시 실제로는 특정 플랫폼(iOS)에 종속되어 있어 macOS 빌드가 깨지는 문제가 발생&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;처음에는 단순히 #if canImport(UIKit)와 같은 조건부 분기로 간단히 해결할 수 있을 거라 생각&lt;/b&gt;했습니다. &lt;b&gt;실제로 import UIKit 자체는 분기 처리로 막을 수 있었지만, UIViewRepresentable, UIFont, UIScreen처럼 UIKit 타입에 직접 의존하는 코드들이 공용 레이어에 포함되어 있었고, 이것들은 단순한 import 분기만으로는 해결되지 않았습니다.&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;플랫폼 분기가 아니라, 플랫폼에 의존적인 코드가 공용 레이어에 섞여 있었다는 구조 자체&lt;/b&gt;에 있었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;왜 플랫폼별 디자인시스템 모듈을 따로 나누지 않았을까요?&lt;/h4&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;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;/p&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;iOS, macOS, 공용 컴포넌트 파악&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 먼저 한 작업은, &lt;b&gt;현재 코드베이스에서 iOS 전용, macOS 전용, 그리고 공용으로 사용 가능한 컴포넌트를 명확하게 분류&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;겉으로 보기에는 모두 디자인시시스템 모듈에 들어있는 공용 컴포넌트처럼 보였지만, 실제로 그렇지 않은 경우가 많았습니다..(매우 ㅠㅠ)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;UIViewRepresentable, AVKit, UIScreen 등을 사용하는 컴포넌트들은 사실상 iOS 전용&lt;/b&gt;이었고, &lt;b&gt;반대로 macOS에서만 사용하는 컴포넌트들도 존재&lt;/b&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;p data-ke-size=&quot;size16&quot;&gt;그래서 각 컴포넌트를 아래와 같은 기준으로 분류하였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 이 컴포넌트가 UIKit / AppKit에 의존하는가?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- SwiftUI만으로 동작하는가?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 특정 플랫폼에서만 의미 있는 UI인가?&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;이 과정을 통해 컴포넌트를 iOS 전용, macOS 전용, 공용으로 나눌 수 있었습니다.&lt;/p&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;DesignSystem 레이어 구조 재정의&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;컴포넌트들을 iOS / macOS / 공용으로 분리한 이후에는, 이를 바탕으로 디자인 시스템 모듈의 레이어 구조를 재정의&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 data-ke-size=&quot;size16&quot;&gt;- Foundation&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Shared&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- iOS&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- macOS&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- NameSpace&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;1. Foundation&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디자인 시스템의 가장 하위 레이어로, 색상, 타이포그래피, spacing과 같은 순수 값만 정의합니다. 이 레이어는 플랫폼과 완전히 분리되어야 하기 때문에, UIKit, AppKit의 의존도를 최소화 했습니다.&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;2. Shared&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;iOS와 macOS에서 공용으로 사용하는 SwiftUI 컴포넌트를 담당합니다. 이 레이어에서는 플랫폼 의존 코드를 사용하지 않는 것을 원칙으로 하고, 최대한 순수 SwiftUI만으로 UI를 구성했습니다.&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;3. iOS, macOS&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각각의 플랫폼에 종속적인 구현을 담당합니다. UIKit 기반 코드는 iOS 레이어로, AppKit 기반 구현은 macOS 레이어로 분리했습니다.&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;4. NameSpace&lt;/b&gt;&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 data-ke-size=&quot;size23&quot;&gt;명시적 분리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;p data-ke-size=&quot;size16&quot;&gt;이때 사용한 방법이 &lt;b&gt;#if os(...) 조건부 컴파일&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;예를 들어, UIKit이나 AppKit을 사용하는 경우 아래와 같이 명확하게 분리했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1774931993262&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#if os(iOS)
import UIKit
#endif

#if os(macOS)
import AppKit
#endif&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;또한 Representable과 같이 플랫폼별 타입이 다른 경우에도 동일한 방식으로 처리했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1774932015348&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#if os(iOS)
struct CustomView: UIViewRepresentable { ... }
#elseif os(macOS)
struct CustomView: NSViewRepresentable { ... }
#endif&lt;/code&gt;&lt;/pre&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 data-ke-size=&quot;size23&quot;&gt;#if canImport(...) VS #if os(...)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로 정리해볼 부분은, 처음에 사용했던 #if canImport(UIKit)과 최종적으로 사용하게 된 #if os(iOS)의 차이입니다.&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;초기에는 단순히 UIKit import만 막으면 문제가 해결될 것이라고 생각했고, 그래서 아래와 같이 코드를 수정했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1774932156952&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#if canImport(UIKit)
import UIKit
#endif&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;canImport는 말 그대로 &quot;이 모듈을 import할 수 있는 환경인가?&quot;를 기준으로 분리&lt;/b&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;p data-ke-size=&quot;size16&quot;&gt;하지만 문제가 있었습니다. UIkit 을 import하는 것 자체를 막을 수 있었지만, 실제로는 아래와 같은 코드들이 여전히 공용 레이어에 남아있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- UIViewRepresentable&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- UIViewControllerRepresentable&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- UIScreen&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- UIFont&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;단순히 import 여부와 상관 없이, iOS에서만 의미를 가지는 구현&lt;/b&gt;입니다. &lt;b&gt;즉, canImport(UIKit)은 import 수준의 분기만 해결할 뿐, 플랫폼 자체의 차이를 해결해주지 못합니다.&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;반면 #if os(iOS)는 완전히 다른 기준입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1774932269970&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#if os(iOS)
// iOS에서만 컴파일되는 코드
#endif&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식은 특정 모듈이 아니라, &lt;b&gt;현재 빌드 대상 플랫폼 자체를 기준으로 분기&lt;/b&gt;합니다. 따라서 &lt;b&gt;UIKit 기반 구현, iOS 전용 로직, 화면 관련 API 등을 명확하게 분리&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 data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- #if canImport(...) -&amp;gt; 라이브러리 존재 여부를 확인할 때만 사용&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- #if os(...) -&amp;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;그리고 디자인 시스템에서는 대부분의 경우가 플랫폼 분기에 해당했기 때문에, 최종적으로는 #if os(...)를 사용하는 방식으로 정리하게 되었습니다.&lt;/p&gt;</description>
      <category>탭탭 - TapTap/리팩토링</category>
      <author>여성일</author>
      <guid isPermaLink="true">https://yeoseongil.tistory.com/234</guid>
      <comments>https://yeoseongil.tistory.com/234#entry234comment</comments>
      <pubDate>Tue, 31 Mar 2026 13:48:45 +0900</pubDate>
    </item>
    <item>
      <title>[리팩토링] 02. MKTileOverlay에 캐싱을 적용해봅시다.</title>
      <link>https://yeoseongil.tistory.com/233</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;맵 타일에 캐싱을 적용한 이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MapKit 기반 UI를 구성하면서 &lt;span&gt;MKTileOverlay&lt;/span&gt;를 사용해 커스텀 타일을 렌더링했는데, &lt;b&gt;실제 사용성과 QA 과정에서 몇 가지 명확한 문제가 드러났습니다.&lt;/b&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;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 네트워크가 없을 때 &quot;빈 지도&quot;가 노출되는 문제&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;IMG_5724.PNG&quot; data-origin-width=&quot;1179&quot; data-origin-height=&quot;2556&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dKNIOa/dJMcabXW8PB/ac5rK8qKwQPB4F7YzCE2H1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dKNIOa/dJMcabXW8PB/ac5rK8qKwQPB4F7YzCE2H1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dKNIOa/dJMcabXW8PB/ac5rK8qKwQPB4F7YzCE2H1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdKNIOa%2FdJMcabXW8PB%2Fac5rK8qKwQPB4F7YzCE2H1%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;336&quot; height=&quot;728&quot; data-filename=&quot;IMG_5724.PNG&quot; data-origin-width=&quot;1179&quot; data-origin-height=&quot;2556&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&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;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 Flyleaf는 Firebase, 검색 API 등 &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;그럼에도 불구하고 아래와 이유로 캐싱이 필요하다고 판단했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 홈 화면은 앱 진입 시 가장 먼저 노출되는 화면&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 사용자가 &quot;지도 상태&quot;만이라도 확인하고 싶어할 가능성 존재&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 최소한 최근에 본 지도라도 유지하는 것이 UX 측면에서 자연스럽다고 판단&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;2. 줌/지도 이동 시 발생하는 타일 깜빡임(플리커 문제)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 번째 이유는 더 직접적이고, &lt;b&gt;실제 QA 및 피드백에서 가장 많이 지적된 문제&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; 이동하거나 줌 할때마다 새로운 영역에 필요한 타일을 서버에서 요청하고, 해당 타일이 도착하면 화면에 렌더링되는 구조이기 때문에 이 과정에서 타일이 교체되며 깜빡이는 현상(플리커)가 발생&lt;/b&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 data-ke-size=&quot;size23&quot;&gt;서버 응답 확인&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;캐싱을 바로 구현하기 전에, 먼저 타일 서버가 캐싱에 필요한 응답 헤더를 제대로 내려주는지 확인&lt;/b&gt;했습니다. &lt;b&gt;아무리 클라이언트에서 캐싱을 구현해도 서버가 Cache-Control 헤더를 올바르게 내려주지 않으면 의미가 없기 때문&lt;/b&gt;입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1774792286917&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;curl -I &quot;https://a.basemaps.cartocdn.com/dark_nolabels/5/27/13.png&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-29 오후 9.48.29.png&quot; data-origin-width=&quot;1273&quot; data-origin-height=&quot;474&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pD8UX/dJMcaiJuP8l/r67pVrIcuPOtA7DpabktB0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pD8UX/dJMcaiJuP8l/r67pVrIcuPOtA7DpabktB0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pD8UX/dJMcaiJuP8l/r67pVrIcuPOtA7DpabktB0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FpD8UX%2FdJMcaiJuP8l%2Fr67pVrIcuPOtA7DpabktB0%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;1273&quot; height=&quot;474&quot; data-filename=&quot;스크린샷 2026-03-29 오후 9.48.29.png&quot; data-origin-width=&quot;1273&quot; data-origin-height=&quot;474&quot;/&gt;&lt;/span&gt;&lt;/figure&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;b&gt;1. 캐싱 가능 여부&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1774792343959&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;cache-control: public, max-age=15552000&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;public은 클라이언트와 중간 프록시 모두 캐싱 가능하다는 의미&lt;/b&gt;이고, &lt;b&gt;'max-age=15552000`은 약 180일간 유효&lt;/b&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;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. CDN 캐시 상태&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1774792415781&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;x-cache: HIT, HIT, HIT
x-cache-hits: 5, 6, 1&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 단에서도 &lt;b&gt;이미 CDN 캐시가 동작 중&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;3. 응답 속도&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1774792451565&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;x-timer: S1774788499.804033, VS0, VE1&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;응답 속도도 &lt;b&gt;1ms 수준으로 매우 빠른 상태&lt;/b&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 data-ke-size=&quot;size23&quot;&gt;캐싱을 적용해 봅시다&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;URLCache.shared 초기화&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 &lt;b&gt;앱 시작 시점에 URLCache.shared를 초기화&lt;/b&gt;했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1774792533728&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// SceneDelegate
URLCache.shared = URLCache(
  memoryCapacity: 30 * 1024 * 1024, // 30MB
  diskCapacity: 300 * 1024 * 1024 // 300MB
)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;맵타일 내부에 독립적인 URLCache 인스턴스를 만들지 않고 URLCache.share를 사용한 이유는, &lt;b&gt;VC 생명주기와 무관하게 앱 전역에서 캐시를 공유하기 위함&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;캐시 크기는 타일 하나의 평균 크기가 2~3KB인 점을 기준으로 계산&lt;/b&gt;했습니다. &lt;b&gt;메모리 30MB이면 약 10,000 ~ 15,000개의 타일을 만들 수 있고, 한 화면에 표시되는 타일이 약 50개인 점을 감안하면 충분한 수준이라고 판단&lt;/b&gt;했습니다. &lt;b&gt;디스크 300MB는 Library/Caches 디렉토리에 저장되며 앱 번들 용량과 무관하고, iOS가 저장 공간 부족 시 정리&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;메모리와 디스크 캐시를 둘 다 사용한 이유는 역할이 다르기 떄문&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 메모리 캐시 -&amp;gt; 속도&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지도 스크롤 중 동일한 타일 반복 요청 시 디스크 I/O 없이 즉시 반환&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;2. 디스크 캐시 -&amp;gt; 영속성&lt;/b&gt;&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;style2&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;CachedMapTileOverlay 구현&lt;/h4&gt;
&lt;pre id=&quot;code_1774792757606&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;final class CachedMapTileOverlay: MKTileOverlay {
  private let session: URLSession

  override init(urlTemplate: String?) {
    let configuration = URLSessionConfiguration.default
    configuration.urlCache = URLCache.shared
    configuration.requestCachePolicy = .returnCacheDataElseLoad
    self.session = URLSession(configuration: configuration)
    super.init(urlTemplate: urlTemplate)
  }

  override func loadTile(
    at path: MKTileOverlayPath,
    result: @escaping (Data?, Error?) -&amp;gt; Void
  ) {
    let request = URLRequest(url: url(forTilePath: path))
    session.dataTask(with: request) { data, _, error in
      result(data, error)
    }.resume()
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 URLSessionConfiguration에 두 가지를 설정한 것입니다.&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;1. urlCache = URLCache.shared&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SceneDelegate에서 설정한 캐시를 그대로 사용&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;2. requestCachePolicy = .returnCacheDataElseLoad&lt;/b&gt;&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;style2&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;setuoMapView() 교체&lt;/h4&gt;
&lt;pre id=&quot;code_1774792852599&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;func setupMapView() {
  mapView.delegate = self
  mapView.addSubview(gradientOverlayView)

  let tileOverlay = CachedMapTileOverlay(urlTemplate: MapTile.darkNolabels)
  tileOverlay.canReplaceMapContent = true
  mapView.addOverlay(tileOverlay, level: .aboveRoads)
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;캐시 동작 검증&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;구현 후 실제로 캐시가 동작하는지 검증하기 위해 loadTile 내부에 로그를 추가 후 검증&lt;/b&gt;했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1774792902572&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;if URLCache.shared.cachedResponse(for: request) != nil {
  print(&quot;캐시 HIT z:\(path.z) x:\(path.x) y:\(path.y)&quot;)
} else {
  print(&quot;캐시 MISS z:\(path.z) x:\(path.x) y:\(path.y)&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;결과를 확인해 보니 &lt;b&gt;한 번 받은 타일은 이후 동일 영역에서 재방문 시 전부 HIT가 찍히는 것을 확인&lt;/b&gt;했습니다. &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-filename=&quot;스크린샷 2026-03-29 오후 10.22.36.png&quot; data-origin-width=&quot;548&quot; data-origin-height=&quot;187&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/PIqS4/dJMcaibIQK4/ZRaZtnaI2R6Z5JNTmTAwu1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/PIqS4/dJMcaibIQK4/ZRaZtnaI2R6Z5JNTmTAwu1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/PIqS4/dJMcaibIQK4/ZRaZtnaI2R6Z5JNTmTAwu1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FPIqS4%2FdJMcaibIQK4%2FZRaZtnaI2R6Z5JNTmTAwu1%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;548&quot; height=&quot;187&quot; data-filename=&quot;스크린샷 2026-03-29 오후 10.22.36.png&quot; data-origin-width=&quot;548&quot; data-origin-height=&quot;187&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;style5&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;결과 및 한계&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;해결된 것&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 네트워크 미연결 시 캐시된 타일은 정상 표시&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 이미 본 영역 재방문 시 서버 요청 없이 즉시 렌더링&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;해결되지 않은 것 - 플리커 현상&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;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;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;- 응답 속도는 1ms 수준&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 캐시 HIT도 정상적으로 발생&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;다음으로 MapKit 레벨에서 원인을 찾기 위해 여러 설정을 변경해보았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. mapType 변경&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;hybridFlyover -&amp;gt; standard, satelliteFlyover&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Flyover 모드가 상대적으로 렌더링 비용이 크기 때문에 렌더링 부하로 인한 플리커 가능성을 검증하기 위함이었으나, 유의미한 차이는 없었습니다.&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;2. titleOverlay.canReplaceMapContent 변경&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;true -&amp;gt; 기본 맵을 대체&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;false -&amp;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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제를 다시 분석하면서 &quot;지도 이동에서는 왜 개선됐을까?&quot;에 집중하여 분석&lt;/b&gt;해보았습니다. &lt;b&gt;지도 이동은 같은 줌 레벨(z축)에서 x/y타일만추가로 로드되는 구조&lt;/b&gt;입니다. &lt;b&gt;이미 캐싱된 타일이 재사용되면서 자연스럽게 이어지는 렌더링이 가능&lt;/b&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;줌은 완전히 다른 동작을 하니다. 줌이 발생하면 z레벨이 변경됨 -&amp;gt; 기존 타일 세트 제거 -&amp;gt; 새로운 z레벨의 타일이 다시 그려짐.&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;p data-ke-size=&quot;size16&quot;&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;캐싱으로 해결하는 것은 네트워크 요청 감소, 타일 로딩 속도 개선과 같은 것&lt;/b&gt;입니다. &lt;b&gt;하지만 타일 레벨 변경 시 발생하는 렌더링 교체 자체는 해결이 불가능하다고 생각&lt;/b&gt;합니다. &lt;b&gt;타일이 캐싱에 있어도 기존 타일을 유지하면서 전환하는 기능은 MKTileOverlay에서 지원하지 않기 때문&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;근본적인 해결을 위해서는 MapLibre와 같은 커스텀 맵 라이브러리 도입이 필요&lt;/b&gt;하며, &lt;b&gt;홈지도는 가장 중요한 기능이기 때문에 UX 개선을 위해 MapLibre 도입을 검토&lt;/b&gt;하고 있습니다.&amp;nbsp;&lt;/p&gt;</description>
      <category>Flyleaf - 독서를 여행처럼/리팩토링</category>
      <author>여성일</author>
      <guid isPermaLink="true">https://yeoseongil.tistory.com/233</guid>
      <comments>https://yeoseongil.tistory.com/233#entry233comment</comments>
      <pubDate>Sun, 29 Mar 2026 23:15:32 +0900</pubDate>
    </item>
    <item>
      <title>[리팩토링] 01. AppCoordinator의 비대함 문제를 해결해봅시다 (feat. Route)</title>
      <link>https://yeoseongil.tistory.com/232</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Flyleaf는 Coordinator를 통해 화면 전환을 처리하고 있고, 모든 화면 전환을 AppCoordinator에서 관리하는 구조를 선택했습니다. 이 선택은 나름 자연스러운(?) 저의 사고였는데요.. 앱의 진입점이기도 하고, 전체 흐름을 한 곳에서 관리하는 것이 직관적이라고 판단했기 때문입니다.&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;개발을 진행하면서 가장 많은 고민이 있었는데요..! 바로 AppCoordinator의 역할이 점점 비대해지고 있다는 점&lt;/b&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;h3 data-ke-size=&quot;size23&quot;&gt;모든 흐름이 AppCoordinator에 몰리기 시작했다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;초기에는 구조가 단순&lt;/b&gt;했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인 -&amp;gt; 메인 화면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메인 -&amp;gt; 특정 화면 이동&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정도의 흐름만 있었기 때문에 AppCoordinator 하나로 충분했습니다. 하지만 기능이 추가되면서 상황이 조금 달라졌는데요.&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;- 체크인 플로우&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;- 검색 플로우&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;와 같은 &lt;b&gt;모든 흐름들이 결국 AppCoordinator로 모이게 되었고,&lt;/b&gt; &lt;b&gt;결과적으로 AppCoordinator는 단순한 진입점 Coordinator가 아니라, 모든 피쳐의 네비게이션을 직접 관리하는 거대한 객체&lt;/b&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 data-ke-size=&quot;size23&quot;&gt;코드도 점점 비대해졌다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조에서 &lt;b&gt;AppCoordinator 내부에 메소드가 계속 늘어난 이유는, 단순히 화면이 많아졌기 때문만은 아니었습니다.&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;제 구조에서는 각 피쳐가 자신의 화면을 직접 push 하거나 다른 피쳐를 직접 생성하지 않도록 설계했습니다. 대신 VC는 사용자 액션을 클로저를 통해 외부로 전달하고, 실제 네비게이션은 Coordinator가 담당하는 구조를 사용하고 있었습니다.&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;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;- 티켓 생성을 완료하거나&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;등과 같은 이벤트가 발생하면, 뷰컨은 이를 onTapBack과 같은 클로저로 외부에 전달합니다. 그리고 이 이벤트를 받아 실제로 어떤 화면을 띄울지 결정하는 역할을 AppCoordinator가 맡고 있었습니다.&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;구조가 모든 피쳐에서 동일하게 반복되면서 결과적으로 AppCoordinator에 모든 흐름 제어 로직이 누적되기 시작되었다는 점&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;- 위시리시트 등록 화면에서 도서 검색을 누르면 검색 화면을 띄워야 하고&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;- 마지막에는 티켓 생성 화면으로 이동해야 했습니다.&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;모든 흐름을 앱코디네이터가 직접 알고 있어야 했기 때문에, 자연스럽게 아래와 같은 메소드들이 계속 추가될 수밖에 없었습니다.&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1774456882174&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;func showRegisterWishlist() { ... }
func showWishTicket(...) { ... }
func showCheckInWishTicket(...) { ... }

func showRegisterHistory() { ... }
func showDetailHistory(...) { ... }

func showRegisterJourney() { ... }
func showJourneyTicket(...) { ... }

func showBookSearch() { ... }
func showDepartureAirportSearch() { ... }
func showArrivalAirportSearch() { ... }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 이 &lt;b&gt;메소드들은 단순히 화면 수가 많아서 생긴 것이 아니라, ViewController에서는 이벤트만 전달하고, 실제 흐름은 모두 AppCoordinator가 결정한다는 현재 구조에서 자연스럽게 AppCoordinator 안에 모이게 된 코드&lt;/b&gt;들이었습니다. &lt;b&gt;처음에는 이 방식이 명확해 보였지만, 기능과 화면이 늘어날수록 AppCoordinator는 앱 전체 흐름뿐 아니라 각 피쳐 내부의 세부 플로우까지 모두 알고 있어야 하는 구조가 되었고, 그 결과 코드도 점점 비대&lt;/b&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 data-ke-size=&quot;size23&quot;&gt;각 피쳐가 자신의 흐름을 관리하는 Coordinator를 갖자&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;정말 이 흐름들을 AppCoordinator가 모두 알고 있어야 할까? 라는 근본적인 질문&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 data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 &lt;b&gt;이 흐름들은 AppCoordinator가 아니라 각 피쳐 내부에서 스스로 관리하는 것이 더 자연스럽지 않을까라는 생각&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 data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- AppCoordinator가 앱 전체 흐름 + Feature 내부 흐름 모두 담당하고 있다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 그로 인해 책임이 과도하게 집중되고 있다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;p data-ke-size=&quot;size16&quot;&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;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;기존에는 AppCoordinator 하나가 모든 화면 전환을 관리하는 구조였다면, 리팩토링 후에는 각 피쳐가 자신의 흐름을 관리하는 Coordinator를 가지는 구조로 개선&lt;/b&gt;하기로 했습니다. 이렇게 분리하게 되면 &lt;b&gt;각 피쳐의 흐름은 해당 코디네이터 안에서만 관리되고 AppCoordinator는 다시 앱 전체 흐름을 조립하는 역할&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;책임의 경계를 명확하게 나누고, 구조를 확장해 유리한 형태로 개선&lt;/b&gt;할 수 있게 됩니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. Coordinator를 어디에 둘 것인가&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Feature 단위로 코디네이터를 분리하기로 결정한 이후, 다음으로 고민했던 지점은 이 Feature별 Coordiantor를 어디에 위치시킬 것&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;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. App 모듈에 모든 Coordinator를 모아두는 방식&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 각 Feature 모듈 내부에 Coordiantor를 두는 방식&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;Feature 모듈 내부에 Coordinator를 두는 구조를 고려&lt;/b&gt;했습니다. 이 방식은&amp;nbsp; &lt;b&gt;Feature 내부 흐름을 완전히 캡슐화할 수 있고, 모듈 단위 책임이 명확하게 나뉘는 장점&lt;/b&gt;이 있습니다. 하지만 &lt;b&gt;이 구조를 적용하려면 Feature 모듈이 네비게이션 컨트롤러를 직접 다루거나, 상위 흐름과의 연결을 위한 인터페이스 설계가 필요&lt;/b&gt;했습니다. 또한 &lt;b&gt;Feature 간 흐름 연결이 많아질수록 모듈 간 의존 관계를 더 신경 써야 하는 부담&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;App 모듈에 두는 구조는 navigaiton 흐름을 한 곳에서 관리할 수 있고, 기존 구조를 크게 변경하지 않으면서도 Feature 단위로 책임을 나누는 것이 가능&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;Feature의 VC는 그대로 두면서, 흐름 제어만 Coordinator로 분리하는 점진적인 리팩토링이 가능했습니다. 기존 구조를 크게 깨지 않고 적용할 수 있고, 네비게이션 흐름을 App 모듈 한 곳에서 관리가 가능하다는 점에서 결국 App 모듈에 모든 코디네이터를 모아두는 방식을 선택&lt;/b&gt;했습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. Feature Coordinator 도입&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구조를 결정한 이후, 각 Feature에 Coordinator를 도입하기 시작했습니다.&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;예를 들어 Wishlist 피쳐를 기준으로 보면,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는 AppCoordinator가 아래와 같은 흐름을 직접 처리하고 있었습니다.&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;- 공항 선택 화면 이동&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;이제 이 흐름을 WishlistCoordinator로 이동시켰습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1774457872573&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;final class WishlistCoordinator: Coordinator {
  weak var parentCoordinator: Coordinator?
  var childCoordinators: [Coordinator] = []
  let navigationController: UINavigationController

  func start() {
    showRegisterWishlist()
  }

  private func showRegisterWishlist() {
    // 등록 화면 진입
  }

  private func showWishTicket(...) {
    // 티켓 생성 화면
  }

  private func startBookSearch(...) {
    // 검색 플로우
  }
}&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;기존에는 아래와 같이 AppCoordinator가 직접 화면을 생성하고 push 했다면,&lt;/p&gt;
&lt;pre id=&quot;code_1774457893551&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;func showRegisterWishlist() {
  let vc = registerWishlistBuilder.build(...)
  navigationController.pushViewController(vc, animated: 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;이제는 Feature Coordinator를 생성하고 시작만 하도록 변경되었습니다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1774457914340&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;func startWishlistFlow() {
  let coordinator = WishlistCoordinator(
    navigationController: navigationController,
    ...
  )

  coordinator.parentCoordinator = self
  childCoordinators.append(coordinator)
  coordinator.start()
}&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;AppCoordinator의 코드량이 줄어들고, 피쳐 단위의 흐름이 명확하게 분리&lt;/b&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;h3 data-ke-size=&quot;size23&quot;&gt;아직 끝나지 않았다?&lt;/h3&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;b&gt;Feature Coordinator로 분리했음에도 불구하고, 여전히 ViewController와 Coordinator 사이의 이벤트 전달 구조가 복잡하다는 점&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;여러 개의 클로저 기반 콜백은 여전히 코드 가독성과 확장성에 부담&lt;/b&gt;이 되고 있었습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ViewController에서&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;- 출발 공항을 선택하려고 할 때&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;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;예를 들어 RegisterWishlistViewController는 아래와 같이 여러 개의 클로저를 가지고 있었습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1774458208942&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public var onTapBack: (() -&amp;gt; Void)?
public var onTapRegisterBookSearch: ((@escaping (BookInfo) -&amp;gt; Void) -&amp;gt; Void)?
public var onTapSelectDepartureButton: ((@escaping (AirportInfo) -&amp;gt; Void) -&amp;gt; Void)?
public var onTapSelectDestinationButton: ((@escaping (AirportInfo) -&amp;gt; Void) -&amp;gt; Void)?
public var onTapCreateTicket: ((BookInfo, AirportInfo, AirportInfo, String) -&amp;gt; Void)?&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이벤트 하나하나를 모두 개별 클로저로 분리하다 보니, ViewController의 프로퍼티도 점점 많아지고 있었습니다.&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;size16&quot;&gt;이 구조는 &lt;b&gt;Builder 시그니처도 함께 복잡&lt;/b&gt;하게 만들었습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1774458242542&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public protocol RegisterWishlistBuildable {
  func build(
    onTapBack: (() -&amp;gt; Void)?,
    onTapRegisterBookSearch: ((@escaping (BookInfo) -&amp;gt; Void) -&amp;gt; Void)?,
    onTapSelectDepartureButton: ((@escaping (AirportInfo) -&amp;gt; Void) -&amp;gt; Void)?,
    onTapSelectDestinationButton: ((@escaping (AirportInfo) -&amp;gt; Void) -&amp;gt; Void)?,
    onTapCreateTicket: ((BookInfo, AirportInfo, AirportInfo, String) -&amp;gt; Void)?
  ) -&amp;gt; UIViewController
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, &lt;b&gt;ViewController에서 이벤트가 늘어날수록 Builder의 시그니처도 함께 비대해지는 구조&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;Coordinator에서 Builder를 호출하는 코드 역시 자연스럽게 복잡해질 수밖에 없었습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1774458283584&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;let registerWishlistVC = registerWishlistBuilder.build(
  onTapBack: { [weak self] in
    self?.finishFlow()
  },
  onTapRegisterBookSearch: { [weak self] onSelected in
    self?.startBookSearch(onSelected: onSelected)
  },
  onTapSelectDepartureButton: { [weak self] onSelected in
    self?.startDepartureAirportSearch(onSelected: onSelected)
  },
  onTapSelectDestinationButton: { [weak self] onSelected in
    self?.startArrivalAirportSearch(onSelected: onSelected)
  },
  onTapCreateTicket: { [weak self] book, departure, destination, reason in
    self?.showWishTicket(
      book: book,
      departure: departure,
      destination: destination,
      reason: reason
    )
  }
)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;style5&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;그래서 Route를 도입했다.&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 &lt;b&gt;문제를 해결하기 위해, 여러 개로 흩어져 있던 클로저를 하나의 Route enum으로 통합&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;예를 들어 RegisterWishlist의 경우 이벤트를 아래와 같이 하나의 enum으로 표현했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1774458380932&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public enum RegisterWishlistRoute {
  case back
  case bookSearch((BookInfo) -&amp;gt; Void)
  case departureSearch((AirportInfo) -&amp;gt; Void)
  case destinationSearch((AirportInfo) -&amp;gt; Void)
  case createTicket(BookInfo, AirportInfo, AirportInfo, String)
}&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 id=&quot;code_1774458395526&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public protocol RegisterWishlistBuildable {
  func build(
    onRoute: ((RegisterWishlistRoute) -&amp;gt; Void)?
  ) -&amp;gt; UIViewController
}&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;이제 ViewController는 여러 개의 클로저를 따로 가지고 있을 필요 없이, 하나의 onRoute만 외부로 전달하면 됩니다.&lt;/p&gt;
&lt;pre id=&quot;code_1774458419167&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public var onRoute: ((RegisterWishlistRoute) -&amp;gt; Void)?&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;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. VC는 어떤 이벤트가 발생했는지만 전달&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. Coordinator는 그 이벤트를 받아 실제 흐름을 결정&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. Builder는 길고 복잡한 시그니처 대신 단일 Route만 주입&lt;/b&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;확장에도 유리하다.&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Route를 도입하면서 크게 느낀 장점 중 하나는 기능 추가 시 변경 범위가 훨씬 줄어든다는 점&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 data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 &quot;임시 저장(Draft 저장)&quot; 기능을 추가한다고 가정해봅시다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. ViewController에 새로운 클로저 추가&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1774458553485&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public var onTapSaveDraft: (() -&amp;gt; Void)?&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;2. Builder 시그니처 수정&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1774458563988&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;func build(
  onTapBack: (() -&amp;gt; Void)?,
  ...
  onTapSaveDraft: (() -&amp;gt; Void)?   // 추가
) -&amp;gt; UIViewController&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;3. Builder 구현부 수정&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1774458923153&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public final class RegisterWishlistBuilder: RegisterWishlistBuildable {
  public init() {}
  
  public func build(
    onTapBack: (() -&amp;gt; Void)?,
    onTapRegisterBookSearch: ((@escaping (BookInfo) -&amp;gt; Void) -&amp;gt; Void)?,
    onTapSelectDepartureButton: ((@escaping (AirportInfo) -&amp;gt; Void) -&amp;gt; Void)?,
    onTapSelectDestinationButton: ((@escaping (AirportInfo) -&amp;gt; Void) -&amp;gt; Void)?,
    onTapCreateTicket: ((BookInfo, AirportInfo, AirportInfo, String) -&amp;gt; Void)?,
    onTapSaveDraft: (() -&amp;gt; Void)?
  ) -&amp;gt; UIViewController {
    let viewModel = RegisterWishlistViewModel()
    let viewController = RegisterWishlistViewController(viewModel: viewModel)
    
    viewController.onTapBack = onTapBack
    viewController.onTapRegisterBookSearch = onTapRegisterBookSearch
    viewController.onTapSelectDepartureButton = onTapSelectDepartureButton
    viewController.onTapSelectDestinationButton = onTapSelectDestinationButton
    viewController.onTapCreateTicket = onTapCreateTicket
    viewController.onTapSaveDraft = onTapSaveDraft
    
    return viewController
  }
}&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;4. Coordinator에서 Builder 호출부 수정&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1774458590859&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;onTapSaveDraft: { [weak self] in
  self?.saveDraft()
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;p data-ke-size=&quot;size16&quot;&gt;하지만 &lt;b&gt;Route 구조에서는 이 변경이 훨씬 단순&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;1. Route enum에 케이스만 추가&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1774458634971&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;enum RegisterWishlistRoute {
  case back
  case bookSearch((BookInfo) -&amp;gt; Void)
  case departureSearch((AirportInfo) -&amp;gt; Void)
  case destinationSearch((AirportInfo) -&amp;gt; Void)
  case createTicket(BookInfo, AirportInfo, AirportInfo, String)
  case saveDraft // 추가
}&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;2. ViewController에서 이벤트 발생 시 route 호출&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1774458650492&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;onRoute?(.saveDraft)&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;3. Coordinator에서 switch case 하나만 추가&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1774458670079&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;switch route {
case .saveDraft:
  self.saveDraft()
...
}&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;중요한 포인트는, 더이상 ViewController에 클로저를 추가하지 않아도 되고, 빌더 시그니처는 전혀 변경되지 않는다는 점&lt;/b&gt;입니다. &lt;b&gt;즉, Route enum + Coordinator의 분기만 수정하면 기능 추가가 완료&lt;/b&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 data-ke-size=&quot;size23&quot;&gt;여기서 끝이 아니다 (찐막) - Coordinator의 생명주기 관리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Feature Coordinator를 분리하고, Route 기반으로 이벤트 전달 구조까지 정리하고 나니 전체 구조는 많이 깔끔해졌습니다..만! 하나의 문제가 남아 있었습니다. &lt;b&gt;바로 Coordinator의 생명주기를 언제, 어떻게 정리할 것인가에 대한 문제&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;초기에는 뒤로가기 버튼을 눌렀을 때, 직접 childDidFinish()를 호출해서 Coordinator를 정리하는 방식을 사용&lt;/b&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;p data-ke-size=&quot;size16&quot;&gt;예를 들면,&lt;/p&gt;
&lt;pre id=&quot;code_1774459049991&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;func finishFlow() {
  navigationController.popViewController(animated: true)
  parentCoordinator?.childDidFinish(self)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;직접 흐름을 종료하면서, 해당 코디네이터를 상위 코디네이터의 childCoordinators 배열에서 제거하는 방식&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;iOS의 화면 전환은 항상 코드로만 발생하지 않습니다. 네비게이션 바의 뒤로가기 버튼, 백 스와이프 처럼 사용자 제스처로도 화면이 pop될 수 있기 때문&lt;/b&gt;입니다. &lt;b&gt;이 경우에는 ViewController가 직접 finishFlow()를 호출하지 않기 때문에, 화면은 사라졌지만 Coordinator는 여전히 childCoordinators에 남아 있을 수 있습니다.&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;size16&quot;&gt;즉, &lt;b&gt;화면은 pop되었지만, Coordinator가 정리되지 않는 상황이 생길 수 있었고, 이는 결국 Coordinator의 생명주기와 실제 네비게이션 스택 상태가 어긋나는 문제&lt;/b&gt;로 이어질 수 있었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;UINavigationControllerDelegate를 사용했습니다&lt;/h4&gt;
&lt;figure id=&quot;og_1774459633607&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;iOS Swift Coordinator pattern and back button of Navigation Controller&quot; data-og-description=&quot;I am using pattern MVVM+Coordinator. Every my controllers are created by coordinators. But what is the correct way to stop my coordinators when tapping on back button of Navigation Controller? class&quot; data-og-host=&quot;stackoverflow.com&quot; data-og-source-url=&quot;https://stackoverflow.com/questions/54156384/ios-coordinator-pattern-and-back-navigation&quot; data-og-url=&quot;https://stackoverflow.com/questions/54156384/ios-swift-coordinator-pattern-and-back-button-of-navigation-controller&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cIn5vb/dJMb9hC0Xfh/8S8j5ZDRiC9vq9KMmZSX1k/img.png?width=360&amp;amp;height=360&amp;amp;face=0_0_360_360&quot;&gt;&lt;a href=&quot;https://stackoverflow.com/questions/54156384/ios-coordinator-pattern-and-back-navigation&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://stackoverflow.com/questions/54156384/ios-coordinator-pattern-and-back-navigation&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cIn5vb/dJMb9hC0Xfh/8S8j5ZDRiC9vq9KMmZSX1k/img.png?width=360&amp;amp;height=360&amp;amp;face=0_0_360_360');&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;iOS Swift Coordinator pattern and back button of Navigation Controller&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;I am using pattern MVVM+Coordinator. Every my controllers are created by coordinators. But what is the correct way to stop my coordinators when tapping on back button of Navigation Controller? class&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;stackoverflow.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;figure id=&quot;og_1774459666448&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;Coordinator &amp;amp; MVVM - Clean Navigation and Back Button in Swift&quot; data-og-description=&quot;After introducing how to implement Coordinator pattern with an MVVM structure, it feels natural for me to go further and cover some of the blank spots of Coordinator and how to fix along the way.&quot; data-og-host=&quot;benoitpasquier.com&quot; data-og-source-url=&quot;https://benoitpasquier.com/coordinator-pattern-navigation-back-button-swift/&quot; data-og-url=&quot;https://benoitpasquier.com/coordinator-pattern-navigation-back-button-swift/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bRDJef/dJMb8868sfm/qmOpyn4dYknfgYbwPJzbZ0/img.png?width=1000&amp;amp;height=706&amp;amp;face=0_0_1000_706,https://scrap.kakaocdn.net/dn/rHgbo/dJMb86nXcqc/lvPI414YiefzLEoqAuZ860/img.png?width=1000&amp;amp;height=706&amp;amp;face=0_0_1000_706,https://scrap.kakaocdn.net/dn/uTomt/dJMb88e0hup/V2F1JFZPQTIMVnwSqRCDO1/img.jpg?width=500&amp;amp;height=500&amp;amp;face=120_137_309_343&quot;&gt;&lt;a href=&quot;https://benoitpasquier.com/coordinator-pattern-navigation-back-button-swift/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://benoitpasquier.com/coordinator-pattern-navigation-back-button-swift/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bRDJef/dJMb8868sfm/qmOpyn4dYknfgYbwPJzbZ0/img.png?width=1000&amp;amp;height=706&amp;amp;face=0_0_1000_706,https://scrap.kakaocdn.net/dn/rHgbo/dJMb86nXcqc/lvPI414YiefzLEoqAuZ860/img.png?width=1000&amp;amp;height=706&amp;amp;face=0_0_1000_706,https://scrap.kakaocdn.net/dn/uTomt/dJMb88e0hup/V2F1JFZPQTIMVnwSqRCDO1/img.jpg?width=500&amp;amp;height=500&amp;amp;face=120_137_309_343');&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;Coordinator &amp;amp; MVVM - Clean Navigation and Back Button in Swift&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;After introducing how to implement Coordinator pattern with an MVVM structure, it feels natural for me to go further and cover some of the blank spots of Coordinator and how to fix along the way.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;benoitpasquier.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;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;iOS에서 Coordinator 패턴을 사용할 때 언급되는 문제 중 하나는 네비게이션 뒤로가기 시 Coordiantor를 어떻게 정리할 것인가&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;백버튼이나 스와이프로 화면이 pop되는 경우, Coordinator는 이를 직접 감지할 수 없기 때문에 생명주기 관리가 어긋날 수 있습니다.&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;문제에 대한 대표적인 해결 방법으로 여러 레퍼런스에서 공통적으로 제시하는 방법이 UINavigationControllerDelegate를 활용하여 실제 pop이 발생한 시점을 감시하는 방법&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;pre id=&quot;code_1774459687561&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;func navigationController(
  _ navigationController: UINavigationController,
  didShow viewController: UIViewController,
  animated: Bool
) {
  guard let fromViewController = navigationController.transitionCoordinator?.viewController(forKey: .from) else {
    return
  }

  if navigationController.viewControllers.contains(fromViewController) {
    return
  }

  removeFinishedCoordinator(
    from: childCoordinators,
    poppedViewController: fromViewController
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 로직은 다음과 같은 방식으로 동작합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 화면 전환이 완려된 뒤 didShow가 호출된다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 전환 직전의 fromViewController를 확인한다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. 만약 그 VC가 네비게이션 스택에 더 이상 존재하지 않는다면, 실제로 pop 된 것으로 판단한다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4. 그 VC를 root로 가지는 Coordinator를 찾아 제거한다.&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;size16&quot;&gt;이 방식을 사용하기 위해 &lt;b&gt;각 Coordinator는 자신이 시작한 화면을 rootViewController로 보관하도록 했습니다.&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;pre id=&quot;code_1774459766558&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;final class WishlistCoordinator: Coordinator {
  var rootViewController: UIViewController?

  func start() {
    let registerWishlistVC = registerWishlistBuilder.build(...)
    rootViewController = registerWishlistVC
    navigationController.pushViewController(registerWishlistVC, animated: true)
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 &lt;b&gt;navigation stack에서 어떤 ViewController가 사라졌는지 기준으로 어떤 Coordinator를 정리해야 하는지 판단&lt;/b&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;h3 data-ke-size=&quot;size23&quot;&gt;아직 뭔가 아쉽다.. (DI Container?)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Feature Coordinator로 흐름을 분리하고, Route 기반으로 이벤트 전달 구조까지 정리하면서 전체 구조는 이전보다 훨씬 명확해졌습니다.&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;b&gt;AppCoordinator가 여전히 너무 많은 의존성을 알고 있다는 점&lt;/b&gt;이었습니다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1774460094776&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;private let authService: AuthServicing
private let homeBuilder: HomeBuildable
private let loginBuilder: LoginBuildable
private let searchBuilder: SearchBuildable
private let wishlistBuilder: WishlistBuildable
private let checkInWishTicketBuilder: CheckInWishTicketBuildable
private let registerWishlistBuilder: RegisterWishlistBuildable
private let wishTicketBuilder: WishTicketBuildable
private let journeyTicketBuilder: JourneyTicketBuildable
private let registerHistoryBuilder: RegisterHistoryBuildable
private let registerJourneyBuilder: RegisterJourneyBuildable
private let jourenyBuilder: JourneyBuildable
private let historyBuilder: HistoryBuildable
private let detailHistoryBuilder: DetailHistoryBuildable&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Feature Coordinator로 책임을 나누었음에도 불구하고, AppCoordinator는 여전히 각 Feature에서 필요한 빌더와 서비스를 모두 알고 있어야 했습니다. 즉, 흐름의 책임은 분리되었지만 생성의 책임은 여전히 AppCoordinator를 지나가고 있는 상태&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;객체를 &quot;쓰는 쪽&quot;이 아니라 &quot;전달하는 쪽&quot;이 너무 많다.&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 구조를 보면, 실제로 어떤 빌더를 사용하는 주체는 가장 아래쪽의 Feature Coordinator인 경우가 많습니다.&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;예를 들어 SearchBuilder를 실제로 사용하는 것은 SearchCoordinator인데, 생성과 전달의 흐름은 대략 아래와 같습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1774460219708&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SceneDelegate -&amp;gt; AppCoordinator -&amp;gt; WishlistCoordinator -&amp;gt; SearchCoordinator
     생성             전달                 전달                       사용&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 실제로 사용하는 것은 SearchCoordinator인데, 그 전에 SceneDelegate, AppCoordinator, WishlistCoordinator는 그 객체를 그냥 들고 있다가 전달만 하는 역할을 하고 있는 셈입니다. 이 구조는 동작에는 문제가 없지만, 조금 더 구조적으로 생각해보면 아쉬움이 많이 남아있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 실제로 쓰지 않는 객체를 상위 계층이 알고 있어야 하고&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 생성 책임과 사용 책임이 분리되지 않았으며&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 의존성이 아래로 내려갈 수록 &quot;전달만 하는 코드&quot;가 늘어날 수 있기 때문입니다.&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;DI Container?&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이 지점에서 자연스럽게 떠오른 것이 DI Container&lt;/b&gt;였습니다. &lt;b&gt;DI Container를 도입하는 핵심 이유는 객체를 &quot;어떻게 생성하는가&quot;와 &quot;어떻게 사용하는가&quot;를 분리하는 데 있다고 생각&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;지금 구조에서는&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- SceneDelegate가 객체를 생성하고&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- AppCoordinator가 이를 전달하고&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 하위 Coordinator가 또 전달하고&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 최종적으로 가장 아래 Coordinator가 실제로 사용합니다.&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;객체를 사용하는 쪽이 아니라 중간 계층이 생성 방식과 전달 구조를 모두 알고 있어야 하는 상태&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;DI Contaienr를 도입한다면, 이 생성 책임을 하나의 조립 지점으로 모을 수 있습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들면 개념적으로는 아래와 같은 흐름이 될 수있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1774460427714&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;DIContainer          AppCoordinator -&amp;gt; WishlistCoordinator -&amp;gt; SearchCoordinator
   생성                                                           resolve / 사용&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;p data-ke-size=&quot;size16&quot;&gt;즉, &lt;b&gt;생성 책임은 Container가 맡고, Coordinator는 흐름 제어에 더 집중할 수 있는 구조&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;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네.. 고민 중입니다. DI Container는 분명 이 문제를 해결하는 좋은 방법이 될 수 있지만, 그 자체로도 또 하나의 구조적 복잡성을 추가하는 선택이기 때문입니다.&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;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 피쳐의 수가 아주 많은 편은 아님.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 의존성 그래프가 감당 불가능할 정도로 복잡하지 않음.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;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;b&gt;- AppCoordinator가 여전히 너무 많은 빌더를 알고 있다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 생성 책임과 사용 책임이 완전히 분리되지는 않았다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- DI Container는 이 문제를 해결할 수 있는 후보가 될 수 있다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 하지만 현재 규모에서는 조금 과한 선택이지 않을까?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Flyleaf - 독서를 여행처럼/리팩토링</category>
      <author>여성일</author>
      <guid isPermaLink="true">https://yeoseongil.tistory.com/232</guid>
      <comments>https://yeoseongil.tistory.com/232#entry232comment</comments>
      <pubDate>Thu, 26 Mar 2026 02:15:55 +0900</pubDate>
    </item>
    <item>
      <title>[회고] 첫 릴리즈를 마치고.. (나를 돌아보기, AI에 대한 나의 생각)</title>
      <link>https://yeoseongil.tistory.com/231</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;2월 초, 기획과 디자인을 마무리하고 개발을 시작한지 약 한 달 반.&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;프로젝트를 시작하기 전에는 Micro-Features Architecture, 의존성 관리 같은 개념들이 머리로는 이해되는 것 같으면서도 어딘가 추상적으로 느껴졌습니다. 하지만 실제로 프로젝트를 진행하면서 이 개념들이 왜 필요한지, 어떻게 적용되는지 체감할 수 있었고, 그 과정에서 제 나름의 기준도 조금씩 정립할 수 있었습니다.&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;복잡한 뷰와 애니메이션을 구현하고, 새로운 구조와 아키텍처를 적용해보기도 하면서 즐겁게 진행했던 프로젝트였습니다.&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;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;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;예전에 제가 존경하는 대학 선배가 저에게 이런 말을 한 적이 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;넌 형이랑 비슷해 완벽주의자 같아&quot;&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 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;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;- 네이밍이 마음에 들지 않는 것&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;- 재사용 가능한지&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Service를 Core가 아니라 따로 모듈로 분리할지&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;- 검색 서비스를 검색 모듈에서만 사용하는데, 코어 모듈에 위치해야하는지? 검색 모듈에만 위치해야하는지?&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;b&gt;문제는 그게 &quot;지금 당장 중요한가?&quot;와는 별개라는 점&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 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;p data-ke-size=&quot;size16&quot;&gt;&quot;조금은 쉬면서 해라&quot;, &quot;그렇게 까지 해서 뭐하냐&quot;, &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;/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;이런 성향이 꼭 나쁘다고만 생각하지는 않습니다. 이 덕분에 디테일을 놓치지 않았고, 구조를 더 고민하게 되었고, 결과적으로 더 나은 방향을 선택할 수 있었던 순간들도 분명히 있었기 때문&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;모든 것을 완벽하게 만드는 것이 항상 좋은 선택은 아니라는 것은 확실히 느꼈습니다. 어떤 것은 지금 바로 해결해야 하고, 어떤 것은 나중으로 미뤄도 되는 문제라는 것을 구분하는 것도 때로는 필요하다는 것&lt;/b&gt;을 느꼈습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조금은 다른 이야기지만, 누군가는 저에게 이렇게 이야기 합니다.&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;&quot;왜 그렇게 열심히 하냐, 어차피 AI 딸깍 아니냐?&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;/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;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;그리고 한 가지 더 느낀게 있는데, 저는 &lt;b&gt;제 분야에서 누군가에게 &quot;잘한다&quot;라는 말을 듣는 것을 좋아하는 사람&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;인정이 좋아서, 조금 더 잘해보고 싶고, 조금 더 깊게 파보고 싶고, 그래서 더 열심히 하게 되는 것&lt;/b&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 data-ke-size=&quot;size23&quot;&gt;AI에 대한 나의 생각&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 &lt;b&gt;한때&lt;/b&gt; &lt;b&gt;AI에 대해 굉장히 회의적이었던 사람&lt;/b&gt;이었습니다. 어느 정도였냐면, 작년 애플 디벨로퍼 아카데미 챌린지2 발표에서 &quot;AI와의 전쟁&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;b&gt;AI 사용에 대한 고민이 많았고, 이게 정말 도움이 되는 도구인지 아니면 오히려 방해가 되는 도구인지에 대해 계속 의문&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;AI를 배척하기보다 &quot;AI를 어떻게 잘 활용할 것인가&quot;를 고민&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 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;AI도 비슷하다고 생각합니다. AI 때문에 개발자가 완전히 사라진다기보다는, AI를 잘 활용하는 개발자가 더 중요한 시대가 되고 있다고 느꼈습니다. (적어도 지금까지는요.. AI가 더 발전하면 정말 어떻게 될진 모르겠지만ㅠ)&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;정말 한 가지 확실한 건 AI코드를 그대로 복붙하는 방식은 절대 통하지 않는다고 생각&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;AI는 할루시네이션이 존재하고, 프로젝트 전체 맥락을 완벽하게 이해하지 못한 상태에서 그럴듯한 코드를 만들어내기도 합니다. 결국 그 &lt;b&gt;코드를 제대로 사용하기 위해서는 개발자가 직접 이해하고, 검증할 수 있어야 합니다. 그래서 저는 AI를 메인 도구가 아니라, 보조 도구이자 학습 도구로 생각&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;이 생각은 실제 팀플을 하면서 더 확실해졌는데요.. 아카데미에서 팀플을 진행할 때, AI로 생성된 코드를 그대로 사용하는 경우들이 종종 있습니다. 그 결과, 코드 리뷰가 어려워지는 경우가 있었고, 코드의 의도를 설명하지 못하는 상황이 발생했고, 이후 리팩토링 단계에서 구조를 이해하기 어려운 문제가 생기기도 했습니다.&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;특히 AI는 우리 프로젝트의 전체 구조를 완전히 이해하고 있는 것이 아니기 때문에, 부분적으로는 맞지만 전체적으로는 어긋난 코드가 만들어지는 경우도 많았습니다.&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;그래서 지금은 AI를 이렇게 사용하려고 합니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 내가 알고 있는 영역을 더 빠르게 확장하는 도구&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 놓치기 쉬운 케이스를 보완해주는 도구&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;p data-ke-size=&quot;size16&quot;&gt;그리고 그 &lt;b&gt;모든 과정에서, 최종 판단과 책임은 항상 개발자&lt;/b&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;h3 data-ke-size=&quot;size23&quot;&gt;Flyleaf를 개발하면서 AI를 활용한 방법 (feat. 저만의 프롬프트 작성법을 공개합니다)&lt;/h3&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;Python 자동화 코드 작성, 데이터 가공, 지도 기반 거리 계산 수식 검증 뿐만아니라 기획, 디자인, QA, UX 라이팅, 단위 테스트 설계까지 개발 전반에 거쳐 AI를 적극적으로 활용했습니다.&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;특히 디자인이나 기획처럼 제가 상대적으로 익숙하지 않은 영역에서도 AI를 통해 빠르게 방향을 잡고, 결과적으로 작업 범위를 확장할 수 있었습니다.&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;또, 아키텍처와 구조적인 고민을 AI와 함께 풀어나갔는데요.&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;예를 들어, 이번 프로젝트에서는 Micro-Features Architecture를 적용하면서 아래와 같은 고민이 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Feature 내부 구현을 외부에서 직접 참조하는 것이 맞는 구조인지&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Interface를 통해 의존성을 분리하는 것이 실제로 의미가 있는지&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Builder를 통해 화면을 생성하는 방식이 적절한지&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- App 모듈에서 화면을 조립하려면 결국 구현체를 참조해야하는데 이것이 맞는 구조인지&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;이런 부분들은 단순히 정답이 있는 문제가 아니라 설계 의도를 기반으로 검증해야 하는 영역이었습니다.&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;AI를 사용할 떄도 단순한 질문 방식은 지양&lt;/b&gt;했습니다.&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&quot;이 구조 맞아?&quot;, &quot;이 코드 괜찮아?&quot; 보다는&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;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;- &quot;Micro-Features Architectur를 적용하면서 각 Feature 간 직접 의존을 피하기 위해 Interface를 통해 의존성을 분리하고 있습니다. 그런데 코디네이터에서 화면을 생성하는 과정에서 결국 인터페이스의 구현체(Builder)를 생성해야 하기 때문에, SceneDelegate에서는 Feature 구현 모듈을 직접 참조하게 되는 구조입니다. 이처럼 SceneDelegate에서만 구현 의존을 허용하는 방식이 Micro-Features Architecture 관점에서 적절한 구조인지 궁금합니다. 혹시 이와 유사한 구조나 아키텍처 사례가 있다면 같이 알려주고, 해당 정보의 레퍼런스와 링크를 같이 알려주세요.&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;UICollectionView에서 무한 스크롤을 구현할 때, 현재는 willDisplay를 활용해 마지막 셀이 노출되는 시점에 다음 페이지를 요청하는 구조를 사용하고 있습니다. 다만 이 방식이 사용자 경험이나 성능 측면에서 최적의 방식인지 고민이되어, UICollectionViewDataSourcePrefetching을 활용한 방식과 비교했을 때 어떤 상황에서 더 적절한 방법인지 궁금합니다. 특히 네트워크 요청 타이밍, 스크롤 성능, 중복 요청 방지 측면에서 장단점을 기준으로 설명해주세요. 또, 해당 정보의 레퍼런스와 링크를 같이 알려주세요.&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;최근 검색어 기능을 UserDefaults로 구현했는데, 현재 구조에서는 단순히 최근 검색 기록을 저장하고 빠르게 불러오는 캐시 성격으로 사용하고 있습니다. 이런 경우 UserDefaults를 사용하는 것이 적절한 선택인지, 아니면 데이터의 성격에 따라 별도의 계층이나 로컬 스토리지(CoreData, SwiftData 등)으로 분리하는 것이 더 좋은 구조인지 고민 됩니다. 특히 데이터의 중요도, 데이터의 양, 기능 확장 가능성 관점에서 어떤 기준으로 저장소를 선택하는 것이 적절한지 궁금합니다. 또, 해당 정보의 레퍼런스와 링크를 같이 알려주세요.&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;b&gt;먼저 가설을 세우고, AI와 함께 검증하는 방식으로 활용&lt;/b&gt;했습니다. 확실하게 느낀 건, &lt;b&gt;AI는 질문을 대신해주는 도구가 아니라, 좋은 질문을 했을 때 더 좋은 답을 주는 도구라는 점&lt;/b&gt;이었습니다. 그리고 그 &lt;b&gt;질문의 퀄리티는 개발자가 얼마나 이해하고 알고 있는지에 따라 달라진다고 느꼈습니다.&amp;nbsp;&lt;/b&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 data-ke-size=&quot;size23&quot;&gt;기술적인 이야기도 해봅시다!&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. 생각보다 Feature간 직접 참조할 일이 거의 없다?&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트를 시작하기 전에는 &quot;A 피쳐의 기능이 필요하면, 그 기능 일부를 B 피쳐에서 가져다 써야하지 않을까?&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;그래서 Feature 간 기능을 어떻게 분리하고, 어떻게 재사용할지에 대해 꽤 많은 고민을 했었습니다.&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;생각보다 피쳐 간 직접적인 기능 참조는 거의 발생하지 않았습니다.&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;대부분의 경우는 A Feature -&amp;gt; B Feature로 화면 전환을 하는 경우였고, 특정 기능을 다른 Feature에서 재사용하는 구조는 거의 등장하지 않았습니다.&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 data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 프로젝트에서 실제 비즈니스 로직은 Core 모듈의 Service 레이어에 위치하고 있고, 각 Feature는 해당 Service를 활용해 UI와 상태를 구성하는 역할을 담당하고 있습니다.&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;또한 Service 역시 인터페이스 기반으로 설계되어 있어, Feature는 구현체가 아니라 추상화에 의존하도록 구성되어 있습니다. 여기에 더해, 코디네이터 역시 중요한 역할을 하고 있었습니다.&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;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;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;인터페이스 모듈을 살펴보면 대부분이 Buildable 형태의 화면 생성 인터페이스만 존재하고 있습니다. 즉, &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;/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;예를 들어, 쇼핑 도메인(장바구니, 결제, 할인), SNS 도메인(피드, 댓글, 알림)과 같이 기능 간 결합도가 높은 경우에는 피쳐 간 의존이 자연스럽게 발생할 수도 있다고 생각합니다. 이런 경우에는 단순히 피쳐를 분리하는 것만으로는 한계가 있고, 공통 기능을 별도의 영역으로 분리하는 방식이 더 적절할 수 있지 않을까 생각합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. Core 모듈에 대한 고민&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 프로젝트 구조를 설계할 때는 서비스 모듈을 별도로 분리하지 않고, 공통 모델과 서비스 로직을 모두 Core 모듈 안에 두는 방식을 선택했습니다. 이유는 단순했습니다. 프로젝트 규모에 비해 서비스 모듈을 처음부터 분리하는 것은 오버엔지니어링이라고 판단했기 때문입니다.&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;서비스 개수가 많지 않았고, 실제로 존재하는 서비스도 2~3개 수준이었습니다. 이 정도 규모에서 서비스를 별도 모듈로 나누기 시작하면 구조는 더 정교해 보일 수 있지만, 오히려 관리할 포인트만 늘어날 수 있다고 판단했습니다.&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;그래서 초기에는 Core 모듈 안에 공통 모델, 서비스 프로토콜, 서비스 구현체를 함께 두고, Feature에서는 이를 사용하는 방식으로 구조를 단순하게 유지하는 것을 우선 선택했습니다.&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;p data-ke-size=&quot;size16&quot;&gt;또 하나 고민이 되었던 부분은 의존성이었습니다. 현재 구조에서는 각 피쳐가 코어 모듈을 Import하여 필요한 서비스를 사용하는 방식입니다. 이 구조 자체는 문제가 되지 않지만, 조금 더 생각해보면 한 가지 특징이 있습니다.&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;예를 들면 SearchFeature는 SearchService, Book 관련 로직 정도만 필요하지만, 현재 구조에서는 코어 모듈 전체를 import하기 때문에 결과적으로 다른 서비스, User 관련 로직, 기타 공통 요소까지 포함된 모듈에 함께 의존하게 됩니다.&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;물론 이 구조에서 Service가 변경된다고 해서 각 Feature에 직접적인 컴파일 오류가 발생하는 문제는 아닙니다. 하지만 이 구조는 모듈의 책임 범위를 넓히고, Feature가 불필요하게 더 큰 영역에 의존하게 만든다고 생각했습니다.&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;하지만 코어 모듈 내부에 서로 다른 책임이 계속 쌓이고 있고, 피쳐가 필요 이상의 범위에 의존하고 있으며, 구조적으로 경계를 더 명확히 나눌 수 있는 시점이 보인다면 Service를 별도의 모듈로 분리하는 것이 더 적절한 선택이 될 수도 있다고 생각하고 있습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. AppCoordinator의 비대함&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발을 진행하면서 &lt;b&gt;가장 많은 고민이 있던 지점 중 하나는 AppCoordinator의 역할이 점점 비대해지고 있다는 점&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;코디네이터를 하나로 두고 앱의 전체 흐름을 관리하는 구조가 단순하고 명확하다고 생각&lt;/b&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;- 탭바 구성&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;b&gt;Wishlist, Journey, History, Search, Home 등 각 피쳐의 플로우가 복잡해지기 시작하면서 AppCoordinator가 모든 화면 흐름을 직접 처리하는 구조&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;gt; 검색 -&amp;gt; 공항 선택 -&amp;gt; 티켓 생성&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 여정 등록 -&amp;gt; 검색 -&amp;gt; 티켓 생성&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 히스토리 등록 -&amp;gt; 상세 조회&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;p data-ke-size=&quot;size16&quot;&gt;이 구조에서는 두 가지 문제가 있다고 생각합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 책임이 과도하게 집중된다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 이벤트 전달 구조가 점점 복잡하다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 구조에서는 빌더를 통해 뷰컨을 생성할 때, onTapBack, onUploadCompleted, onTapRegisterBookSearch 등&amp;nbsp; 여러 콜백을 전달하고 있습니다. 처음에는 간단했지만, &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;Flow 단위로 코디네이터를 분리하고, 콜백 기반 이벤트를 라우트로 통합하여 개선하는 방식으로 리팩토링을 진행&lt;/b&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 data-ke-size=&quot;size23&quot;&gt;앞으로 어떻게 할 것인가?&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. AppCoordinator 구조 개선 리팩토링&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 AppCoordinator에 집중된 화면 흐름을 Feature(Flow) 단위 Coordinator로 분리하여, 각 기능의 책임 범위를 명확히 나누고 의존성과 복잡도를 줄이는 방향으로 리팩토링을 진행할 예정입니다. 또한 콜백 기반 이벤트 전달 구조를 Route 기반으로 개선하여, 화면 전환 흐름을 더 명확하게 관리할 계획입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. 유저 테스트 기반 UX 및 편의성 개선&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-25 오후 9.34.48.png&quot; data-origin-width=&quot;891&quot; data-origin-height=&quot;936&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tmXsV/dJMcacJisF9/Kv5Z8jFs6OHXY56dvvRIkK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tmXsV/dJMcacJisF9/Kv5Z8jFs6OHXY56dvvRIkK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tmXsV/dJMcacJisF9/Kv5Z8jFs6OHXY56dvvRIkK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtmXsV%2FdJMcacJisF9%2FKv5Z8jFs6OHXY56dvvRIkK%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;481&quot; height=&quot;505&quot; data-filename=&quot;스크린샷 2026-03-25 오후 9.34.48.png&quot; data-origin-width=&quot;891&quot; data-origin-height=&quot;936&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&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;style2&quot; /&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;3. AI QA 매니저 구축&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;AI를 활용하여 시나리오 기반 QA를 자동화하는 AI QA 매니저를 구축하여 도입할 예정입니다. QA 프로세스 전반을 보조하는 도구로 활용하는 것을 목표로 하고 있습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;4. 새로운 기능 확장&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 핵심 기능을 기반으로, UX를 더 풍부하게 만들 수 있는 기능들을 단계적으로 확장할 계획입니다. 단순 기능 추가보다는 기존 구조를 유지하면서 자연스럽게 확장 가능한 방향으로 설계하고 구현해 나갈 예정입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Flyleaf - 독서를 여행처럼</category>
      <author>여성일</author>
      <guid isPermaLink="true">https://yeoseongil.tistory.com/231</guid>
      <comments>https://yeoseongil.tistory.com/231#entry231comment</comments>
      <pubDate>Wed, 25 Mar 2026 20:04:51 +0900</pubDate>
    </item>
    <item>
      <title>[개발일지] 08. Fastlane으로 iOS 배포 자동화 구축하기</title>
      <link>https://yeoseongil.tistory.com/230</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;iOS 앱을 개발하다 보면, 번거로운 작업이 있는데요, 바로 배포 과정입니다.&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;앱을 배포하기 위해서는&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 배포를 위해 인증서나 프로비저닝을 설정&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- Xcode에서 Archive&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 수동으로 업로드&lt;/b&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;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;p data-ke-size=&quot;size16&quot;&gt;뿐만 아니라 &lt;b&gt;팀 프로젝트에서는 인증서 공유나 환경 설정 문제가 빈번하게 발생&lt;/b&gt;합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 인증서 공유 문제&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 환경 설정 차이&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;p data-ke-size=&quot;size16&quot;&gt;특히 애플 디벨로퍼 아카데미에서 팀 프로젝트를 진행할 때 팀 동료한테 가장 많이 들었던 이야기 중 하나인 &lt;b&gt;&quot;제 컴퓨터에서는 잘 됩니다!&quot;&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;이러한 이유로 Fastlane과 match를 활용하여 배포 자동화를 구축했고, 이에 대해 이야기 해보려고 합니다.&lt;/p&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;Fastlane?&lt;/h3&gt;
&lt;figure id=&quot;og_1774281416143&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;fastlane - App automation done right&quot; data-og-description=&quot;The easiest way to build and release mobile apps. fastlane handles tedious tasks so you don&amp;rsquo;t have to. Developer hours saved 10,558,200&quot; data-og-host=&quot;fastlane.tools&quot; data-og-source-url=&quot;https://fastlane.tools/&quot; data-og-url=&quot;https://fastlane.tools/&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://fastlane.tools/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://fastlane.tools/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&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;fastlane - App automation done right&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;The easiest way to build and release mobile apps. fastlane handles tedious tasks so you don&amp;rsquo;t have to. Developer hours saved 10,558,200&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;fastlane.tools&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 번거로운 배포 과정을 어떻게 자동화할 수 있을까요? 바로 Fastlane입니다.&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;Fastlane은 iOS, AOS 앱의 빌드, 테스트, 배포 과정을 자동화해주는 오픈소스 도구&lt;/b&gt;입니다. 간단히 말하면, 명&lt;b&gt;령어 한 줄로 앱 빌드부터 배포까지 처리할 수 있는 자동화 도구&lt;/b&gt;입니다. &lt;s&gt;(역시&amp;nbsp; 딸깍)&lt;/s&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;예를 들어, 아래와 같은 Fastlane 명령어를 입력하면&lt;/p&gt;
&lt;pre id=&quot;code_1774281053993&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;fastlane ios beta&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 인증서 및 프로비저닝 설정&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 앱 빌드&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 테플 업로드&amp;nbsp;&lt;/b&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;b&gt;동일한 방식으로 배포&lt;/b&gt;할 수 있고, &lt;b&gt;배포 과정에서의 실수를 줄일 수 있고&lt;/b&gt;, &lt;b&gt;CI/CD와도 쉽게 연결&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;Fastlane은 단순한 배포 도구가 아니라, &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;style2&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Fastlane이 필요한 이유&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 iOS 배포 과정은 아래와 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 인증서 및 프로비저닝 설정&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Xcode에서 Archive&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;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;특히 &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;문제나, 번거로움을 해결하기 위해 Fastlane이 필요합니다. 특히, 인증서 문제는 match를 통해 해결&lt;/b&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 data-ke-size=&quot;size23&quot;&gt;인증서 관리 문제와&amp;nbsp; match&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;iOS 개발에서 가장 &lt;b&gt;까다로운 부분 중 하나는 인증서 관리&lt;/b&gt;입니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 인증서 만료&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 팀원 간 공유 문제&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- Keychain 꼬임&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;실제로 팀 프로젝트를 진행하면서 가장 많이 겪었던 문제&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;인증서가 꼬이거나, 누군가는 되고 누구는 안 되는 상황이 반복되면서 개발보다 환경 문제를 해결하는 데 더 많은 시간을 쓰게 되는 경우&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 data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Fastlane에서는 이를 해결하기 위해 &lt;b&gt;match라는 기능을 제공&lt;/b&gt;합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;match?&lt;/h4&gt;
&lt;figure id=&quot;og_1774281433647&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;match - fastlane docs&quot; data-og-description=&quot;type Define the profile type, can be appstore, adhoc, development, enterprise, developer_id, mac_installer_distribution, developer_id_installer development&quot; data-og-host=&quot;docs.fastlane.tools&quot; data-og-source-url=&quot;https://docs.fastlane.tools/actions/match/&quot; data-og-url=&quot;https://docs.fastlane.tools/actions/match/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/dmEGS2/dJMb8XkeNnC/K1FOHJPAKpazIOFNPnj7Tk/img.png?width=1872&amp;amp;height=654&amp;amp;face=0_0_1872_654,https://scrap.kakaocdn.net/dn/cCFqM5/dJMb8VNuOXn/3bZRsZkxg9KUsZkfoHXzOK/img.png?width=1000&amp;amp;height=773&amp;amp;face=0_0_1000_773,https://scrap.kakaocdn.net/dn/djebyc/dJMb8U8S2to/QkculYq3nYxtIOKyOTvGcK/img.png?width=871&amp;amp;height=304&amp;amp;face=0_0_871_304&quot;&gt;&lt;a href=&quot;https://docs.fastlane.tools/actions/match/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://docs.fastlane.tools/actions/match/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/dmEGS2/dJMb8XkeNnC/K1FOHJPAKpazIOFNPnj7Tk/img.png?width=1872&amp;amp;height=654&amp;amp;face=0_0_1872_654,https://scrap.kakaocdn.net/dn/cCFqM5/dJMb8VNuOXn/3bZRsZkxg9KUsZkfoHXzOK/img.png?width=1000&amp;amp;height=773&amp;amp;face=0_0_1000_773,https://scrap.kakaocdn.net/dn/djebyc/dJMb8U8S2to/QkculYq3nYxtIOKyOTvGcK/img.png?width=871&amp;amp;height=304&amp;amp;face=0_0_871_304');&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;match - fastlane docs&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;type Define the profile type, can be appstore, adhoc, development, enterprise, developer_id, mac_installer_distribution, developer_id_installer development&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;docs.fastlane.tools&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;match는 인증서와 프로비저닝 프로파일을 Git 저장소로 관리하고 자동으로 설치해주는 기능&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, &lt;b&gt;인증서는 Git 저장소에 저장&lt;/b&gt;되어있고,&lt;b&gt; 필요할 때 자동으로 다운로드&lt;/b&gt;하여 &lt;b&gt;Keychain에 설치&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;인증서를 수동으로 관리할 필요가 없어집니다.&lt;/b&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 data-ke-size=&quot;size23&quot;&gt;구축 과정&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. Fastlane 설치&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;먼저 Fastlane을 설치&lt;/b&gt;해야 합니다. 저는 Homebrew를 통해 설치했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1774281565773&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;brew install fastlane&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 id=&quot;code_1774281735275&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;fastlane --version&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;2. Fastlane 초기화&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설치가 완료되면 &lt;b&gt;프로젝트 루트에서 Fastlane을 초기화&lt;/b&gt;합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1774281584910&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;fastlane init&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 과정을 통해 기본적으로 fastlane 디렉토리와 함께 Fastfile, Appfile 등의 설정 파일이 생성됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- Fastfile : 실제 배포 명령(lane)을 정의하는 파일&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- Appfile: 앱 식별자나 Apple 계정 관련 정보를 정의하는 파일&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;3. App Store Connect API Key 방식으로 인증 전환&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는 Apple ID 기반 로그인 방식도 사용할 수 있지만, 팀 프로젝트에서는 로그인 세션이나 2차 인증 이슈 때문에 관리가 번거로울 수 있습니다.&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;그래서 Flyleaf에서는 &lt;b&gt;App Store Connect API Key 방식으로 인증을 구성&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;pre id=&quot;code_1774281860203&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;APP_STORE_CONNECT_KEY_ID
APP_STORE_CONNECT_ISSUER_ID
APP_STORE_CONNECT_KEY_FILEPATH&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;예를 들어 .env에는 아래와 같이 설정했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1774281875754&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;APP_STORE_CONNECT_KEY_ID=...
APP_STORE_CONNECT_ISSUER_ID=...
APP_STORE_CONNECT_KEY_FILEPATH=./fastlane/AuthKey_XXXXXX.p8&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;Apple ID 세션에 의존하지 않고, 보다 안정적으로 자동화 파이프라인을 구성할 수 있다는 점&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;4. 인증서 관리 방식으로 match 도입&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음으로 가장 중요한 &lt;b&gt;인증서 문제를 해결하기 위해 match를 도입&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;앞서 이야기했듯이 iOS 배포에서는 인증서와 프로비저닝 프로파일 관리가 항상 문제였습니다. 그래서 &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;&lt;b&gt;먼저 match용 레포를 준비하고, Matchfile을 구성했습니다.&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;pre id=&quot;code_1774281980406&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;git_url(match용 레포 URL)
git_branch(&quot;main&quot;)
storage_mode(&quot;git&quot;)

type(&quot;appstore&quot;)

app_identifier([
  &quot;com.yeo.flyleaf.dev&quot;,
  &quot;com.yeo.flyleaf&quot;
])

username(&quot;...&quot;)
readonly(false)&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;/p&gt;
&lt;pre id=&quot;code_1774282002262&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;fastlane match development
fastlane match appstore&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;인증서와 프로비저닝 프로파일이 암호화되어 저장소에 저장되고, 필요할 때마다 match가 자동으로 내려받아 Keychain에 설치&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;팀원 입장에서는 인증서를 직접 복사하거나 수동으로 설치할 필요가 없어집니다.&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;5. dev / prod 배포 흐름 분리&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Flyleaf는 &lt;b&gt;개발용 앱과 실제 배포용 앱이 분리되어 있기 때문에 Fastlane도 이에 맞춰 beta / release 두 가지 흐름으로 분리&lt;/b&gt;했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- FlyleafDev -&amp;gt; 테플 배포&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- Flyleaf -&amp;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;b&gt;개발용 빌드와 실제 릴리즈 빌드는 아이콘, 번들, ID, Firebase 설정, 서명 방식 등이 다를 수 있기 때문&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;단일 lane으로 처리하기보다, 배포 목적에 따라 명확하게 lane을 나누는 것이 안정적이라고 판단하여 두 가지 흐름으로 분리&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;6. Fastfile에 lane 구성&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 실&lt;b&gt;제 배포 명령을 Fastfile에 정의&lt;/b&gt;했습니다. Flyleaf에서는 베타와 릴리즈 두 개의 lane을 구성했습니다.&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_1774282176958&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;platform :ios do
  def asc_api_key
    app_store_connect_api_key(
      key_id: ENV[&quot;APP_STORE_CONNECT_KEY_ID&quot;],
      issuer_id: ENV[&quot;APP_STORE_CONNECT_ISSUER_ID&quot;],
      key_filepath: ENV[&quot;APP_STORE_CONNECT_KEY_FILEPATH&quot;],
      duration: 1200,
      in_house: false
    )
  end

  desc &quot;FlyleafDev를 빌드하고 TestFlight에 업로드합니다&quot;
  lane :beta do
    api_key = asc_api_key

    match(
      type: &quot;appstore&quot;,
      app_identifier: [&quot;com.yeo.flyleaf.dev&quot;],
      api_key: api_key,
      readonly: true
    )

    build_app(
      workspace: &quot;Flyleaf.xcworkspace&quot;,
      scheme: &quot;FlyleafDev&quot;,
      configuration: &quot;Release&quot;,
      clean: true,
      export_method: &quot;app-store&quot;
    )

    upload_to_testflight(
      api_key: api_key,
      skip_waiting_for_build_processing: true
    )
  end

  desc &quot;Flyleaf를 App Store 배포용으로 빌드합니다&quot;
  lane :release do
    api_key = asc_api_key

    match(
      type: &quot;appstore&quot;,
      app_identifier: [&quot;com.yeo.flyleaf&quot;],
      api_key: api_key,
      readonly: true
    )

    build_app(
      workspace: &quot;Flyleaf.xcworkspace&quot;,
      scheme: &quot;Flyleaf&quot;,
      configuration: &quot;Release&quot;,
      clean: true,
      export_method: &quot;app-store&quot;
    )

    upload_to_app_store(
      api_key: api_key,
      submit_for_review: false,
      automatic_release: false,
      skip_metadata: true,
      skip_screenshots: true,
      run_precheck_before_submit: false
    )
  end
end&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 설정을 통해 배포 과정이 명확히 분리 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- beta -&amp;gt; 개발용 스킴 빌드 + 테플 업로드&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- release -&amp;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;7. 민감한 값은 .env로 분리&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Fastlane을 구성하다 보면 API Key, Git URL, Team ID 같은 값들이 설정 파일 안에 직접 들어가기 쉽습니다.&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;b&gt;민감한 설정을 .env로 분리&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;pre id=&quot;code_1774282268796&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;APP_STORE_CONNECT_KEY_ID=...
APP_STORE_CONNECT_ISSUER_ID=...
APP_STORE_CONNECT_KEY_FILEPATH=./fastlane/AuthKey_XXXXXX.p8
MATCH_GIT_URL=...
FASTLANE_USER=...
APP_IDENTIFIER_DEV=com.yeo.flyleaf.dev
APP_IDENTIFIER_PROD=com.yeo.flyleaf
APP_STORE_TEAM_ID=...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 구성하면 Fastlane 설정 파일은 유지하고, 민감한 값만 따로 관리할 수 있습니다.&lt;/p&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;p data-ke-size=&quot;size16&quot;&gt;최종적으로 Flyleaf의 배포 흐름은 아래처럼 정의되었습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1774282327135&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;fastlane ios beta
-&amp;gt; match 인증서 설치
-&amp;gt; FlyleafDev 빌드
-&amp;gt; 테플 업로드&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1774282339694&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;fastlane ios release
-&amp;gt; match 인증서 설치
-&amp;gt; Flyleaf 빌드
-&amp;gt; App Store Connect 업로드&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Flyleaf - 독서를 여행처럼/개발일지</category>
      <author>여성일</author>
      <guid isPermaLink="true">https://yeoseongil.tistory.com/230</guid>
      <comments>https://yeoseongil.tistory.com/230#entry230comment</comments>
      <pubDate>Tue, 24 Mar 2026 01:13:40 +0900</pubDate>
    </item>
    <item>
      <title>[개발일지] 07. Tuist Scaffold로 모듈 생성 자동화하기</title>
      <link>https://yeoseongil.tistory.com/229</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Micro-Features Architecture로 모듈화된 환경에서는 새로운 기능을 추가할 때마다 하나의 Feature 모듈을 생성&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;Tuist 기반으로 모듈화를 구성해두었기 때문에, 새로운 Feature를 추가하는 과정 자체는 어렵지 않았습니다.&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;하지만 실제로 하나의 Feature를 만들기 위해서는 다음과 같은 작업이 반복됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Feature 모듈 생성&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Interface 모듈 생성&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Tests 모듈 생성&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Test 모듈 생성&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Example 모듈 생성&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;하나의 Feature를 추가할 때마다&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;총 5개의 모듈을 생성해야 하고, 이 과정을 매번 반복&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;각 모듈마다 기본적으로 필요한 placeholder 파일과 구조도 함께 생성&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;시간이 오래 걸리고 구조를 빠뜨리거나 일관성이 깨질 가능성도 존재&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;반복 작업을 줄이고, 항상 동일한 구조를 보장할 수 있는 자동화 방법이 필요&lt;/b&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 data-ke-size=&quot;size23&quot;&gt;Tuist Scaffold?&lt;/h3&gt;
&lt;figure id=&quot;og_1774277662240&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;What is Tuist? | Tuist&quot; data-og-description=&quot;&quot; data-og-host=&quot;docs.tuist.dev&quot; data-og-source-url=&quot;https://docs.tuist.dev/en/cli/scaffold&quot; data-og-url=&quot;https://docs.tuist.dev&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cHxkpc/dJMb84p77YU/k22iLdcFafcA17jKWYbeyk/img.jpg?width=4584&amp;amp;height=4584&amp;amp;face=0_0_4584_4584,https://scrap.kakaocdn.net/dn/YwfGR/dJMb82MCxl7/KZlRIZx1ZVdKgNI0WeKoR0/img.jpg?width=4584&amp;amp;height=4584&amp;amp;face=0_0_4584_4584&quot;&gt;&lt;a href=&quot;https://docs.tuist.dev/en/cli/scaffold&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://docs.tuist.dev/en/cli/scaffold&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cHxkpc/dJMb84p77YU/k22iLdcFafcA17jKWYbeyk/img.jpg?width=4584&amp;amp;height=4584&amp;amp;face=0_0_4584_4584,https://scrap.kakaocdn.net/dn/YwfGR/dJMb82MCxl7/KZlRIZx1ZVdKgNI0WeKoR0/img.jpg?width=4584&amp;amp;height=4584&amp;amp;face=0_0_4584_4584');&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;What is Tuist? | Tuist&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;docs.tuist.dev&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;scaffold는 템플릿 기반으로 파일과 폴더 구조를 자동 생성해주는 기능&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 data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;s&gt;&quot;딸깍&quot;&lt;/s&gt;&lt;/b&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;왜 Scaffold를 사용할까요?&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;scaffold를 사용하면&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;- 실수 방지&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;b&gt;Micro-Features Architecture처럼 구조가 중요한 프로젝트에서는 Scaffold의 효과가 크게 나타납니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;일관된 구조를 생성할 수 있기 때문&lt;/b&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;h3 data-ke-size=&quot;size23&quot;&gt;Stencil?&lt;/h3&gt;
&lt;figure id=&quot;og_1774277993339&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Stencil &amp;ndash; Swift Package Index&quot; data-og-description=&quot;Stencil by Stencil Project on the Swift Package Index &amp;ndash; Stencil is a simple and powerful template language for Swift.&quot; data-og-host=&quot;swiftpackageindex.com&quot; data-og-source-url=&quot;https://swiftpackageindex.com/stencilproject/Stencil&quot; data-og-url=&quot;https://swiftpackageindex.com/stencilproject/Stencil&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/PHt5H/dJMb8Wey40y/0dWI2cr0qryxVusMg0t8Uk/img.png?width=1024&amp;amp;height=1024&amp;amp;face=0_0_1024_1024,https://scrap.kakaocdn.net/dn/6U4B5/dJMb9g5aBML/KkvnDDk2lSz22qWCJct2Sk/img.png?width=1024&amp;amp;height=1024&amp;amp;face=0_0_1024_1024&quot;&gt;&lt;a href=&quot;https://swiftpackageindex.com/stencilproject/Stencil&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://swiftpackageindex.com/stencilproject/Stencil&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/PHt5H/dJMb8Wey40y/0dWI2cr0qryxVusMg0t8Uk/img.png?width=1024&amp;amp;height=1024&amp;amp;face=0_0_1024_1024,https://scrap.kakaocdn.net/dn/6U4B5/dJMb9g5aBML/KkvnDDk2lSz22qWCJct2Sk/img.png?width=1024&amp;amp;height=1024&amp;amp;face=0_0_1024_1024');&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;Stencil &amp;ndash; Swift Package Index&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Stencil by Stencil Project on the Swift Package Index &amp;ndash; Stencil is a simple and powerful template language for Swift.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;swiftpackageindex.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;scaffold는 단순히 파일을 복사하는 방식이 아니라, Stencil 템플릿을 기반으로 동작합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Stencil은 Swift에서 사용하는 템플릿 엔진으로, 미리 정의된 템플릿에 변수를 주입하여 파일을 생성&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;pre id=&quot;code_1774277828906&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;final class {{ name }}ViewController: UIViewController {

}&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;여기서 {{ name }}은 변수입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1774277844626&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;tuist scaffold MicroFeature --name Home&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 이런 명령어를 실행하면&lt;/p&gt;
&lt;pre id=&quot;code_1774277863926&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;final class HomeViewController: UIViewController {

}&lt;/code&gt;&lt;/pre&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;h4 data-ke-size=&quot;size20&quot;&gt;왜 Stencil을 사용할까요?&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 파일을 복사하고 붙여넣는 것과 달리, Stencil을 아래와 같은 장점이 있습니다.&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;- 반복되는 코드 패턴 자동화&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;예를 들어, HomeVC, LoginVC, SearchVC 모두 하나의 템플릿으로 생성 가능합니다.&lt;/p&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;p data-ke-size=&quot;size16&quot;&gt;Tuist Scaffold와 Stencil이 무엇인지 간단히 알아보았으니, 이제 제가 어떻게 실제로 템플릿을 구성하고 자동화했는지 이야기해보겠습니다.&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Scaffold 디렉토리 구조&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Micro Feature 생얼을 위해 아래와 같은 Scaffold를 구성했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1774278296675&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Tuist/
 └── Scaffold/
     └── Templates/
         ├── Project.stencil
         ├── MicroFeature.swift
         ├── InterfacePlaceholder.stencil
         ├── RootViewController.stencil
         ├── SceneDelegate.stencil
         ├── Tests.stencil
         └── TestingPlaceholder.stencil&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반적으로 Tuist Scaffold는 `template.stencil` 파일을 진입점으로 사용하지만,&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;하나의 템플릿 파일이 아닌 여러 stencil 파일을 조합하는 방식으로 설계했습니다.&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;/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;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Feature를 구성하는 요소들은 각각 역할이 다릅니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;- 예제 앱&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;- 피쳐 구현 코드&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;- 인터페이스 정의&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&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;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;각 파일은 역할별로 나뉘어 있으며, Tuist는 해당 디렉토리의 모든 템플릿을 읽어 하나의 Feature 구조를 생성합니다.&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;style2&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. &quot;생성 결과물&quot; 설계&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 먼저, &lt;b&gt;Scaffold로 최종적으로 어떤 구조를 만들지 결정&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;예를 들어 Home Feature를 만들면 아래 구조가 먼저 생성되도록 정의합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1774279564966&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Features/
 └── Home/
     ├── Feature
     ├── Interface
     ├── Tests
     ├── Testing
     └── Example&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;p data-ke-size=&quot;size16&quot;&gt;Flyleaf 프로젝트는 각 모듈 안에&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Feature -&amp;gt; VC, ViewModel, Builder&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Interface -&amp;gt; Placeholder&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Tests -&amp;gt; 기본 테스트 코드&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Testing -&amp;gt; Placeholder&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Example -&amp;gt; SceneDelegate&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;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;Placeholder&lt;/b&gt;가 뭔지 궁금해 하실텐데요!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Interface, Testing과 같은 모듈은 초기에는 실제 코드가 필요 없습니다.&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;Xcode는 빈 폴더를 인식하지 않기 때문에, 파일이 하나도 없으면 해당 디렉토리가 프로젝트에 포함되지 않습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 문제를 해결하기 위해, &lt;b&gt;각 모듈에 최소한의 파일을 유지하기 위한 Placeholder 파일을 추가&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;2. 각 파일에 대한 Stencil 템플릿 작성&lt;/h4&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;b&gt;1. RootViewController.stencil&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Feature모듈의 진입 VC 템플릿입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 기본 UI 구조&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;2. InterfacePlaceholder.stencil&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Interface 모듈을 위한 Placeholder 템플릿입니다.&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;3. SceneDelegate.stencil&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Example 실행을 위한 SceneDelegate 템플릿입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-&amp;nbsp; Feature를 독립적으로 실행할 수 있도록 구성&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;4. Tests.stencil / TestingPlaceholder.stencil&lt;/b&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;- 기본 테스트 구조&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;5. Project.stencil&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Tuist 프로젝트 정의를 생성하는 템플릿입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Feature, Interface, Tests 등 각 모듈의 Project.swift 생성&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;h4 data-ke-size=&quot;size20&quot;&gt;3. Project.swift 자동 생성 템플릿 생성&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Tuist를 사용하고 있기 때문에, Project.swift 파일까지 함께 생성되어야 합니다.&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;예를 들어 아래와 같이 작성할 수 있습니다. (단순한 예제코드일뿐, Tuist 세팅에 맞게 작성해야합니다.)&lt;/p&gt;
&lt;pre id=&quot;code_1774279313005&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import ProjectDescription

let project = Project(
  name: &quot;{{ name }}Feature&quot;,
  targets: [
    Target(
      name: &quot;{{ name }}Feature&quot;,
      platform: .iOS,
      product: .framework,
      bundleId: &quot;com.app.{{ name }}Feature&quot;,
      sources: [&quot;Sources/**&quot;],
      dependencies: [
        .project(target: &quot;{{ name }}Interface&quot;, path: &quot;../{{ name }}Interface&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 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;4. Template 정의&lt;/h4&gt;
&lt;figure id=&quot;og_1774280049550&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;What is Tuist? | Tuist&quot; data-og-description=&quot;&quot; data-og-host=&quot;docs.tuist.dev&quot; data-og-source-url=&quot;https://docs.tuist.dev/ko/guides/features/projects/templates&quot; data-og-url=&quot;https://docs.tuist.dev&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/dBpw8o/dJMb9b3RuXo/UP6UXqGx8MACfaKka6K37k/img.jpg?width=4584&amp;amp;height=4584&amp;amp;face=0_0_4584_4584,https://scrap.kakaocdn.net/dn/eLlqe/dJMb9lk6Ah8/dBnB4nqgzHaGakufBKFGak/img.jpg?width=4584&amp;amp;height=4584&amp;amp;face=0_0_4584_4584&quot;&gt;&lt;a href=&quot;https://docs.tuist.dev/ko/guides/features/projects/templates&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://docs.tuist.dev/ko/guides/features/projects/templates&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/dBpw8o/dJMb9b3RuXo/UP6UXqGx8MACfaKka6K37k/img.jpg?width=4584&amp;amp;height=4584&amp;amp;face=0_0_4584_4584,https://scrap.kakaocdn.net/dn/eLlqe/dJMb9lk6Ah8/dBnB4nqgzHaGakufBKFGak/img.jpg?width=4584&amp;amp;height=4584&amp;amp;face=0_0_4584_4584');&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;What is Tuist? | Tuist&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;docs.tuist.dev&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Stencil 템플릿을 작성했다면, 이제 어떤 파일을 어디에 생성할지 정의&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;Tuist에서는 이를 `Template`로 정의합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1774280095888&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;let template = Template(
  description: &quot;MicroFeature scaffold&quot;,
  attributes: [
    .required(&quot;name&quot;),
    .required(&quot;case&quot;)
  ],
  items: [
    .file(
      path: &quot;Features/{{ name }}/Project.swift&quot;,
      templatePath: &quot;Project.stencil&quot;
    ),
    ...
  ]
)&lt;/code&gt;&lt;/pre&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;- 파일이 생성될 경로 지정&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 어떤 stencil 템플릿을 사용할지 연결&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;h4 data-ke-size=&quot;size20&quot;&gt;5. Scaffold 등록&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Stencil 템플릿과, Tuist 템플릿 파일을 다 만들었다면, 이제 &lt;b&gt;Tuist가 템플릿을 인식할 수 있도록 연결&lt;/b&gt;해야 합니다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1774279385989&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;let config = Config(
  plugins: [],
  templates: [
    .string(name: &quot;Feature&quot;, path: &quot;Tuist/Scaffold&quot;)
  ]
)&lt;/code&gt;&lt;/pre&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;pre id=&quot;code_1774280287049&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;tuist scaffold MicroFeature --name Home --case home&lt;/code&gt;&lt;/pre&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;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>Flyleaf - 독서를 여행처럼/개발일지</category>
      <author>여성일</author>
      <guid isPermaLink="true">https://yeoseongil.tistory.com/229</guid>
      <comments>https://yeoseongil.tistory.com/229#entry229comment</comments>
      <pubDate>Tue, 24 Mar 2026 00:40:06 +0900</pubDate>
    </item>
    <item>
      <title>[개발일지] 06. 단위 테스팅을 해봅시다! (feat. AI)</title>
      <link>https://yeoseongil.tistory.com/228</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;단위 테스팅을 하는 이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Flyleaf를 개발하면서 단위 테스팅을 적극적으로 하고 있습니다.&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;작은 단위의 로직이 의도한 대로 정확하게 동작하는지 검증하는 과정&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;테스트 코드를 작성하는 과정에서 코드 구조를 다시 고민&lt;/b&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;p data-ke-size=&quot;size16&quot;&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;p data-ke-size=&quot;size16&quot;&gt;예를 들어 HomeViewModel에서는 독서 진행률을 계산하는 로직을 테스트했습니다. 이런 로직은 &lt;b&gt;계산 자체는 단순하지만, 실제로는 값이nil인 경우, 전체 페이지 수가 0인 경우, 현재 페이지가 전체 페이지 수를 초과하는 경우처럼 같이 확인해야 할 케이스가 생각보다 많았습니다.&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;size16&quot;&gt;이러한 &lt;b&gt;케이스들을 실기기에서 하나씩 확인하려면, 매번 다른 값을 가진 데이터를 준비하고 화면에 진입해서 결과를 직접 확인&lt;/b&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;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;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;단위 테스팅 메소드 (feat. XCTest)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단위 테스팅을 작성하면서 XCTest에서 제공하는 여러 메소드들을 사용하게 되었습니다. 자주 사용하는 것들이 여러 개 있으니 한번 이야기 해보려고 합니다.&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;XCTAssertEqual&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 두 값이 서로 같은지 비교할 때 사용하는 메소드&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;p data-ke-size=&quot;size16&quot;&gt;가장 기본적으로 많이 사용한 것은 XCTAssertEqual 입니다. 예를 들어 진행률 계산처럼 입력값에 대해 정확한 결과가 나와야 하는 경우 사용했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1773945866462&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;XCTAssertEqual(progress, 200.0 / 584.0, accuracy: 0.001)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;부동소수점 계산에서는 미세한 오차가 발생할 수 있기 때문에 accuracy를 같이 사용하였습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;XCTAssertNil&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 값이 nil인지 확인할 때 사용하는 메소드&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;p data-ke-size=&quot;size16&quot;&gt;값이 존재하지 않아야 하는 경우에는 XCTAssertNil을 사용했습니다. 예를 들어 업로드 실패 시 성공 콜백이 호출되지 않았는지를 확인할 때 사용했습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;XCTAssertNotNil&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 값이 nil이 아닌지 확인하는 메소드&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;p data-ke-size=&quot;size16&quot;&gt;반대로 값이 반드시 존재해야 하는 경우에는 XCTAssertNotNil을 사용했습니다. 예를 들어 시작일을 선택하는 로직에서는, 날짜를 입력했을 때 실제로 값이 잘 저장되는지를 확인할 때 사용했습니다. 특히, 옵셔널 값을 다루는 경우에는, nil이 아닌지 자체를 확인하는 것이 더 중요한 경우가 많아서 자주 사용했습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;XCTAssertTrue, XCTAssertFalse&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- Bool 값이 각각 true 또는 false인지 확인하는 메소드&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;p data-ke-size=&quot;size16&quot;&gt;단순한 Bool 검증에서는 XCTAssertTure, XCTAssertFalse를 사용했습니다. 예를 들어 특정 조건에서 버튼 활성화 여부나 선택 가능 여부를 검증할 때 사용했습니다.&lt;/p&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;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;비동기 로직을 테스트할 때는 expectation을 활용&lt;/b&gt;했습니다.&amp;nbsp; 도서 검색 테스트 코드를 보면서 이야기 해보겠습니다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1773947715629&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;let exp = expectation(description: &quot;onBooksChanged called&quot;)

var received: [BookSearchItem] = []

sut.onBooksChanged = { books in
  guard !books.isEmpty else { return }
  received = books
  exp.fulfill()
}

await sut.search(query: &quot;소설&quot;)

await fulfillment(of: [exp], timeout: 1.0)&lt;/code&gt;&lt;/pre&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;expectation&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;일반적인 동기 코드와 다르게 &lt;b&gt;비동기 코드는 실행 시점과 결과가 언제 반환될지 알 수 없기 때문에, 기다렸다가 검증하는 구조가 필요&lt;/b&gt;합니다. 이때 사용하는 것이 expectation입니다.&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;b&gt;expectation은 작업이 완료되기를 기다리겠다는 약속을 만드는 객체&lt;/b&gt;입니다. 위 코드에서는 onBooksChanged 콜백이 호출되는 것을 기다리는 상황입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;h4 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;fulfill()&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;expectation이 만족되었음을 알리는 메소드&lt;/b&gt;입니다. 즉, 기다리던 일이 실제로 발생했다는 신호입니다. 위 코드에서는 onBooksChanged가 호출 됐을 때, exp.fulfill()을 호출하고 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;h4 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;fulfillment(of:timeout:)&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;지정한 expectation이 fulfill될 때까지 기다리는 역할&lt;/b&gt;을 합니다. 만약 &lt;b&gt;일정 시간(timeout)안에 fulfill되지 않으면 테스트는 실패&lt;/b&gt;합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;흐름으로 이해해봅시다.&lt;/h4&gt;
&lt;pre id=&quot;code_1773947955153&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;let exp = expectation(description: &quot;onBooksChanged called&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 expectation으로 onBooksChanged가 호출되기를 기다린다는 기준을 하나 만듭니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1773948004985&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sut.onBooksChanged = { books in
  guard !books.isEmpty else { return }
  received = books
  exp.fulfill()
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 다음 sut.onBooksChanged 클로저 안에서, 실제로 원하는 이벤트가 발생했을 때 exp.fulfill()을 호출합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1773948031481&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;await sut.search(query: &quot;소설&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 search 메소드를 실행하면, 내부적으로 비동기 검색이 수행됩니다. 검색이 끝나고 onBooksChanged 콜백이 호출되면, 그 시점에 exp.fulfill()이 실행됩니다.&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;&lt;b&gt;1. expectation으로 기다릴 이벤트를 등록한다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 비동기 작업이 끝났을 때 fulfill()로 완료 신호를 보낸다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. fulfillment()로 그 완료 신호가 올 때까지 기다린다.&lt;/b&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 data-ke-size=&quot;size23&quot;&gt;setUP과 tearDown&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;setUp()&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;셋업은 각 테스트가 실행되기 전에 호출되는 메소드&lt;/b&gt;입니다. &lt;b&gt;테스트에 필요한 객체를 생성하거나, 초기 상태를 준비하는 역할&lt;/b&gt;을 합니다. 예를 들어 HomeViewModel 테스트에서 매 테스트마다 동일한 환경을 만들기 위해 아래처럼 사용했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1773946208483&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;override func setUp() {
  super.setUp()
  mockReadingJourneyService = MockReadingJourneyService()
  sut = HomeViewModel(readingJourneyService: mockReadingJourneyService)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 &lt;b&gt;각 테스트마다 새로운 뷰모델과 Mock 객체가 생성되기 때문에, 이전 테스트의 영향 없이 독립적으로 테스트를 실행&lt;/b&gt;할 수 있습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;tearDown()&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 &lt;b&gt;tearDown은 각 테스트가 끝나고 호출되는 메소드&lt;/b&gt;입니다. &lt;b&gt;테스트에서 사용한 객체를 정리하거나 초기화하는 데 사용&lt;/b&gt;합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1773946302157&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;override func tearDown() {
  sut = nil
  mockReadingJourneyService = nil
  super.tearDown()
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 &lt;b&gt;객체를 nil로 정리해두면, 테스트 간 상태가 섞이는 것을 방지하고 메모리도 깔꼼하게 관리&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;setUp()과 tearDown()은 각 테스트를 서로 완전히 독립된 환경에서 실행하기 위한 일종의 장치&lt;/b&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 data-ke-size=&quot;size23&quot;&gt;Given - When - Then&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 코드를 작성할 때 가장 보편적으로 사용되는 방식 중 하나인 Given - When - Then을 사용하여 테스트를 작성했습니다. 이 패턴은 테스트를 세 단계로 나누어 작성하는 방식으로, 각각 다음과 같은 의미를 가집니다.&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;- Given: 테스트를 위한 상태와 조건을 준비 (어떤 상태가 주어졌을 때)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- When: 테스트 대상 동작을 실행 (어떤 행동을 하면)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- Then: 실행 결과를 검증 (어떤 결과가 나와야 한다)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1773946820364&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;/*
독서 진행률이 정상적으로 계산되는지 검증하는 테스트
- Given: currentPage와 전체 페이지 수(itemPage)가 있는 ReadingJourney
- When: calculateProgress(journey:) 호출
- Then: currentPage / itemPage 값으로 진행률이 정상 계산되는지 확인합니다.
*/

func test_calculateProgress_normal() {
  // Given
  let journey = makeReadingJourney(
    currentPage: 200,
    itemPage: 584
  )

  // When
  let progress = sut.calculateProgress(journey: journey)

  // Then
  XCTAssertEqual(progress, 200.0 / 584.0, accuracy: 0.001)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 독서 진행률을 계산하는 테스트 코드를 보면 Given - When - Then 구조가 어떤 구조인지 확인할 수 있습니다. (아마도요?!)&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;Given 단계에서는 테스트를 위한 데이터를 준비&lt;/b&gt;합니다. 이 테스트에서는 currentPage가 200이고 전체 페이지 수가 584인 ReadingJourney를 생성하여, 진행률을 계산할 수 있는 상태를 만들어줍니다.&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;When 단계에서는 실제로 테스트하고 싶은 동작을 실행&lt;/b&gt;합니다. 여기서는 calculateProgress(journey:) 메소드를 호출하여 진행률을 계산합니다.&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;Then 단계에서는 결과를 검증&lt;/b&gt;합니다. 계산된 진행률이 200.0 / 584.0과 동일한지 비교하면서, 해당 로직이 정상적으로 동작하는지를 확인합니다.&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;이처럼 Give - When - Then 구조로 나누어 작성하면, 테스트 코드의 구조와 목적을 쉽게 파악할 수 있습니다.&lt;/p&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;테스트 케이스 (feat. AI)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 코드를 작성하는 것은 크게 어렵지 않습니다. 오히려 더 어려웠던 부분은, &lt;b&gt;어떤 테스트 케이스를 설정해야하는지였습니다.&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;size16&quot;&gt;처음에는 비교적 단순하게 접근했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 calculateProgress(joureny:)와 같은 로직에서는, currentPage와 itemPage가 주어졌을 때, 진행률이 정확히 계산되는지와 같은 정상 케이스를 중심으로 테스트를 작성했습니다.&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;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;- currentPage가 nil인 경우&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 전체 페이지 수(itemPage)가 0인 경우&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;이런 케이스들은 하나씩 직접 떠올리려면 생각보다 놓치는 부분이 많았고, 테스트가 충분히 커버되지 않는다는 느낌을 받았습니다. 그래서 저는 테스트 케이스를 설계하는 과정에서 AI를 적극 활용했습니다.&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;- 이 메소드에서 발생할 수 있는 예외 상황은?&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 data-ke-size=&quot;size23&quot;&gt;Mock&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Mock 객체&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단위 테스트를 작성하면서 중요하게 느낀 것이 Mock 객체입니다.&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;ViewModel을 테스트하다 보면, 네트워크 요청이나 데이터 저장소처럼 외부 의존성이 포함되는 경우가 많습니다. 이런 의존성을 그대로 사용하면 테스트가 느려지거나, 테스트 결과가 외부 상태에 영향을 받게 되는 문제가 발생합니다.&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;실제 서비스 대신, 원하는 값을 직접 제어할 수 있는 Mock 객체를 만들어 테스트에 사용&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;예를 들어 HomeViewModel에서는 ReadingJourneyService를 직접 사용하지 않고, 테스트용 MockReadingJourneyService를 주입해서 테스트를 진행했습니다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1773948554543&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;mockReadingJourneyService.stubbedFetchReadingJourneysResult = [
  makeReadingJourney(id: &quot;journey-1&quot;),
  makeReadingJourney(id: &quot;journey-2&quot;)
]

await sut.loadReadingJourneys()

XCTAssertEqual(sut.tripCount, 2)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 실제 API 호출 없이도, 여행이 2개 있을 때 tripCount가 2가 되는지를 정확히 검증할 수 있습니다.&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;또한 Mock을 사용하면 단순히 결과 값뿐만 아니라, 어떤 메소드가 호출되었는지까지 검증할 수 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1773948605290&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;XCTAssertEqual(mockReadingJourneyService.createJourneyCallCount, 1)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이처럼 Mock 객체에 호출 횟수나 전달된 값을 기록해두면, 로직이 실제로 기대한 방식으로 동작했는지도 함께 검증할 수 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;의존성 주입&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단위 테스트를 하면서 가장 크게 체감한 부분 중 하나는, &lt;b&gt;DI 구조가 테스트를 쉽게 만들어준다는 것&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;예를 들어 HomeViewModel에서는 데이터를 가져오기 위해 구현체에 직접 의존하는 것이 아니라, 프로토콜을 통해 의존성을 주입받도록 설계되어 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1773948774380&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public init(
  readingJourneyService: ReadingJourneyServicing
) {
  self.readingJourneyService = readingJourneyService
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조 덕분에 테스트에서는 실제 서비스 대신 Mock 객체를 자유롭게 사용할 수 있었습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1773948867050&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sut = HomeViewModel(readingJourneyService: mockReadingJourneyService)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, &lt;b&gt;ViewModel은 어떤 구현체인지는 신경 쓰지 않고 단순히 프로토콜을 따르는 객체만 받으면 되기 때문에 테스트 상황에 맞는 객체를 외부에서 주입할 수 있게 됩니다.&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;size16&quot;&gt;이렇게 &lt;b&gt;의존성 주입이 가능해지면 테스트 작성이 훨씬 유연&lt;/b&gt;해집니다. &lt;b&gt;원하는 데이터를 직접 설정한 뒤, 그 결과가 ViewModel에 어떻게 반영되는지를 정확하게 검증&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;반대로 의존성 주입을 하지 않고, ViewModel 내부에서 직접 서비스 객체를 생성하는 구조라면 어떻게 될까요?&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_1773948975393&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;final class HomeViewModel {
  private let readingJourneyService = ReadingJourneyService()

  func loadReadingJourneys() async {
    let journeys = try? await readingJourneyService.fetchReadingJourneys()
    // ...
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경우 HomeViewModel은 ReadingJourneyService라는 구현체에 강하게 결합되어 있습니다.&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_1773949009358&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;let sut = HomeViewModel()

await sut.loadReadingJourneys()&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;원하는 테스트 상황을 만들 수 없는 문제가 발생합니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 특정 데이터를 반환하도록 설정할 수 없음&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 에러 상황을 강제로 만들기 어려움&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;p data-ke-size=&quot;size16&quot;&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;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결국 의존성을 주입하지 않은 구조에서는 테스트가 제어 불가능한 상태가 되고, 의존성을 주입한 구조에서는 테스트를 통제 가능한 상태로 만들 수 있습니다.&amp;nbsp;&lt;/b&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 data-ke-size=&quot;size23&quot;&gt;마무리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Flyleaf 개발하면서 단위 테스트를 꾸준히 작성해보니, 그동안 추상적이었던 개념들을 조금은 실제로 체감할 수 있었습니다.&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; 라는 말을 많이 들어왔지만, 직접 적용해보니 왜 그런지 명확하게 이해할 수 있었습니다. 프로토콜 기반으로 의존성을 분리하고 Mock 객체를 주입하는 구조를 사용하면서, 테스트를 훨씬 유연하고 통제 가능한 상태에서 작성할 수 있다는 것을 경험하게 되었습니다.&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; 케이스뿐만 아니라 다양한 예외 상황과 엣지 케이스까지 자연스럽게 고려하게 되었고, 이 과정에서 AI를 적극 활용해 테스트 범위를 확장해본 경험도 재밌는 경험이었습니다.&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;</description>
      <category>Flyleaf - 독서를 여행처럼/개발일지</category>
      <author>여성일</author>
      <guid isPermaLink="true">https://yeoseongil.tistory.com/228</guid>
      <comments>https://yeoseongil.tistory.com/228#entry228comment</comments>
      <pubDate>Fri, 20 Mar 2026 04:42:33 +0900</pubDate>
    </item>
    <item>
      <title>[UX/사용성] 내가 경험한 좋은 UX와 아쉬운 UX</title>
      <link>https://yeoseongil.tistory.com/227</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 제가 실제 서비스를 사용하면서 느꼈던 좋은 UX/사용성과 아쉬운 UX/사용성을 이야기해보려고 합니다.&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;사실 UX와 사용성은 완전히 객관적인 기준으로 판단하기 어렵고, 어느 정도는 개인의 경험과 맥락에 따라 달라질 수 있는 영역이라고 생각합니다. 그래서 제가 이야기하는 내용이 정답이라고 보기는 어렵습니다.&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;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;내가 생각하는 좋은 UX/사용성&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;&lt;b&gt;1-1. 유튜브 미리보기&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;IMG_5547.PNG&quot; data-origin-width=&quot;1179&quot; data-origin-height=&quot;2556&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rVuyK/dJMcac3sN4T/xYkVCCOrkeMi3fLMccoKD1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rVuyK/dJMcac3sN4T/xYkVCCOrkeMi3fLMccoKD1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rVuyK/dJMcac3sN4T/xYkVCCOrkeMi3fLMccoKD1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrVuyK%2FdJMcac3sN4T%2FxYkVCCOrkeMi3fLMccoKD1%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;270&quot; height=&quot;585&quot; data-filename=&quot;IMG_5547.PNG&quot; data-origin-width=&quot;1179&quot; data-origin-height=&quot;2556&quot;/&gt;&lt;/span&gt;&lt;/figure&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;/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;제 현재 상황을 고려하고 있다는 느낌을 받았기 때문&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 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;- 공공장소&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;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;style2&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. 토스&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2-1. 토스 증권&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;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;b&gt;토스 증권은 비교적 쉬운 UX를 제공&lt;/b&gt;한다고 느꼈습니다. 실제로 최근에 저희 어머니께서 주식을 시작하셨는데, 처음에는 다른 증권 앱을 사용하시다가 너무 어렵다고 느끼셔서 토스 증권으로 바꾸셨고, 별다른 도움 없이도 직접 종목을 검색하시고, 매도/매수를 진행하시더라고요.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;IMG_5548.PNG&quot; data-origin-width=&quot;1179&quot; data-origin-height=&quot;2556&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/CnfXe/dJMcaibACLa/dXeyjXG0oTMDKCNjA1Jtrk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/CnfXe/dJMcaibACLa/dXeyjXG0oTMDKCNjA1Jtrk/img.png&quot; data-alt=&quot;삼전 20만 시대..&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/CnfXe/dJMcaibACLa/dXeyjXG0oTMDKCNjA1Jtrk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FCnfXe%2FdJMcaibACLa%2FdXeyjXG0oTMDKCNjA1Jtrk%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;270&quot; height=&quot;585&quot; data-filename=&quot;IMG_5548.PNG&quot; data-origin-width=&quot;1179&quot; data-origin-height=&quot;2556&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;삼전 20만 시대..&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;별 거 아닐 수도 있지만, 토스 증권에서는 &quot;매수&quot;라는 표현 대신 &quot;구매하기&quot;라는 용어를 사용하고 있습니다. 사실 &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;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;이 &lt;b&gt;작은 디테일이 UX/사용성에서도 크게 느껴지지 않을까 생각&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;&quot;이 버튼이 어떤 행동을 하는지&quot;를 고민하지 않아도 된다는 점에서, 이러한 작은 디테일이 쌓여 진입장벽을 낮춰준다고 생각&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;2-2. 모든 금융 처리를 앱에서 손쉽게 처리할 수 있는 것&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;p data-ke-size=&quot;size16&quot;&gt;특히 인상 깊었던 점은, 원래라면 &lt;b&gt;각각 다른 서비스에서 따로 진행해야 할 복잡한 인증 과정들을 토스 안에서 한 번에 처리할 수 있다는 점&lt;/b&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;p data-ke-size=&quot;size16&quot;&gt;또 하나 인상 깊었던 부분은 홈 화면입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;IMG_5549.jpg&quot; data-origin-width=&quot;1178&quot; data-origin-height=&quot;566&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/DXZGl/dJMcahX1N2o/KDKMIsNaylOLRZUMcNfBAk/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/DXZGl/dJMcahX1N2o/KDKMIsNaylOLRZUMcNfBAk/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/DXZGl/dJMcahX1N2o/KDKMIsNaylOLRZUMcNfBAk/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FDXZGl%2FdJMcahX1N2o%2FKDKMIsNaylOLRZUMcNfBAk%2Fimg.jpg&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;192&quot; data-filename=&quot;IMG_5549.jpg&quot; data-origin-width=&quot;1178&quot; data-origin-height=&quot;566&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;토스 &lt;b&gt;홈 상단을 보면 &quot;납부하기&quot;, &quot;돌려받기&quot;, &quot;환급액 찾기&quot; 처럼 행동 동사 중심으로 지금 해야 하는 일을 바로 제시&lt;/b&gt;해줍니다. 이 구조 덕분에 &lt;b&gt;사용자는 앱을 켜자마자 &quot;지금 내가 해야 할 게 있나?&quot;, &quot;지금 할 수 있는 건 뭐지?&quot;를 고민하지 않아도 됩니다.&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;2-3. 탭 기억(?)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;며칠 전에 알게 되어 '와..? 와....' 했던 그런 UX를 이야기 해보려고 합니다.&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;너무 당연하고 자연스럽게 사용하고 있어서 크게 의식하지 못했는데, 알고보니 정말 치밀한(?) UX였던 것 같습니다.&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;보통 탭바가 있는 앱들은 앱을 종료했다가 다시 실행하면, 기본으로 설정된 첫 번째 탭으로 이동하는 경우가 많습니다.&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;토스는 앱을 종료한 뒤 다시 실행하더라도, 마지막으로 사용하던 탭을 그대로 유지한 상태로 돌아옵니다.&lt;/b&gt; 처음에는 크게 인식하지 못했던 부분이지만, 앱을 사용하다 보니 이 차이가 꽤 크게 느껴졌습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷, 2026-03-19 오후 7.19.18.png&quot; data-origin-width=&quot;1179&quot; data-origin-height=&quot;308&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bGCiON/dJMcaibADzP/THiZB30Af1kTpGHabjmabK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bGCiON/dJMcaibADzP/THiZB30Af1kTpGHabjmabK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bGCiON/dJMcaibADzP/THiZB30Af1kTpGHabjmabK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbGCiON%2FdJMcaibADzP%2FTHiZB30Af1kTpGHabjmabK%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;104&quot; data-filename=&quot;스크린샷, 2026-03-19 오후 7.19.18.png&quot; data-origin-width=&quot;1179&quot; data-origin-height=&quot;308&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;예를 들어 증권 탭에서 종목을 확인하다 다시 앱을 실행했을 때&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&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;이전 상태 그대로 이어서 사용할 수 있다는 점이 자연스럽게 느껴졌습니다.&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;size16&quot;&gt;&lt;b&gt;좋은 UX는 사용자의 흐름을 끊지 않는다는 것&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;style5&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. 당근&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3-1. 끌올&lt;/b&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷, 2026-03-19 오후 7.22.23.png&quot; data-origin-width=&quot;1179&quot; data-origin-height=&quot;505&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c8WfSH/dJMcagdLEOV/MBx5dZErwRmHI9v0ESULgk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c8WfSH/dJMcagdLEOV/MBx5dZErwRmHI9v0ESULgk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c8WfSH/dJMcagdLEOV/MBx5dZErwRmHI9v0ESULgk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc8WfSH%2FdJMcagdLEOV%2FMBx5dZErwRmHI9v0ESULgk%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;171&quot; data-filename=&quot;스크린샷, 2026-03-19 오후 7.22.23.png&quot; data-origin-width=&quot;1179&quot; data-origin-height=&quot;505&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;판매자는 자신이 몇 번 끌올 했는지 확인할 수 있지만, 다른 사용자에게는 이 정보가 보이지 않습니다. 이게 생각해보니까 꽤 디테일한 UX 설계라고 느꼈습니다.&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;만약 구매자가 구매하려는 상품이 10번이나 끌올 됐다는 정보를 보게 된다면 구매자는 어떤 생각을 할까요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;이거 잘 안팔리는 물건인가?&quot;, &quot;하자가 있는 제품인가?&quot;, &quot;잘 안팔리나보네.. 네고하면 조금 싸게 살 수 있지 않을까?&quot;&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;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 판매자에게는 이 정보가 그대로 보입니다. &quot;10번이나 끌올 했는데 왜 안팔리지?&quot;, &quot;가격을 좀 낮춰야하나?&quot;&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;뭔가.. 당연하다고 느꼈던 것이 그렇지 않았다는 사실에 충격이었던.. 그런 UX입니다. &lt;b&gt;같은 정보라도 누구에게 보여주느냐에 따라 전혀 다른 UX가 된다는 것을 느끼게 된 경험&lt;/b&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;내가 생각하는 아쉬운 UX/사용성&lt;/h3&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;1. 네이버&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1-1. 네이버 카페&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;IMG_5555.jpg&quot; data-origin-width=&quot;1179&quot; data-origin-height=&quot;191&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/yihNh/dJMcaaxQOVj/PVxiw3K5uKIKoNUBLOI4jk/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/yihNh/dJMcaaxQOVj/PVxiw3K5uKIKoNUBLOI4jk/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/yihNh/dJMcaaxQOVj/PVxiw3K5uKIKoNUBLOI4jk/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FyihNh%2FdJMcaaxQOVj%2FPVxiw3K5uKIKoNUBLOI4jk%2Fimg.jpg&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;65&quot; data-filename=&quot;IMG_5555.jpg&quot; data-origin-width=&quot;1179&quot; data-origin-height=&quot;191&quot;/&gt;&lt;/span&gt;&lt;/figure&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;/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;카페에 들어가면 &quot;지금 어떤 글이 올라왔는지&quot;를 먼저 확인하는 흐름에 익숙해져 있었는데, 추천글이 먼저 보이면서 매번 한 번 더 눌러서 최신글로 이동해야 했기 때문&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;한 번의 추가 행동이 계속 반복되면서 번거롭게 느껴졌습니다.&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;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;b&gt;자신이 익숙한 흐름을 기반으로 서비스를 사용합니다. 그 흐름을 바꾸는 순간, 작은 변화라도 불편함으로&amp;nbsp; 느껴질 수 있다고 생각&lt;/b&gt;합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. 잡코리아&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1-1. 과다한 광고 노출&lt;/b&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;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;IMG_5556.PNG&quot; data-origin-width=&quot;1179&quot; data-origin-height=&quot;2556&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/btcOjE/dJMcag5TVLP/ojzWhPs0OtyhV9GCQxzEJK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/btcOjE/dJMcag5TVLP/ojzWhPs0OtyhV9GCQxzEJK/img.png&quot; data-alt=&quot;나는 분명히 iOS로 검색했는데...&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/btcOjE/dJMcag5TVLP/ojzWhPs0OtyhV9GCQxzEJK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbtcOjE%2FdJMcag5TVLP%2FojzWhPs0OtyhV9GCQxzEJK%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;270&quot; height=&quot;585&quot; data-filename=&quot;IMG_5556.PNG&quot; data-origin-width=&quot;1179&quot; data-origin-height=&quot;2556&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;검색을 했을 때, &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;물론 기업 입장에서는 충분히 이해가 되는 구조입니다. 더 많은 지원자를 확보하기 위해 상단 노출을 원하는 것은 자연스러운 일이고, 이는 잡코리아의 비즈니스 모델 중 하나이기 때문이죠.&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;, &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;/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;사용자의 흐름을 방해하지 않는 선에서 어떻게 보여줄 것인가가 더 중요한 포인트라고 생각&lt;/b&gt;합니다. &lt;b&gt;검색 UX에서 가장 중요한 것은 사용자가 원하는 정보를 빠르게 찾을 수 있도록 도와주는 것이라고 생각&lt;/b&gt;합니다.&lt;/p&gt;</description>
      <category>UX, 사용성</category>
      <author>여성일</author>
      <guid isPermaLink="true">https://yeoseongil.tistory.com/227</guid>
      <comments>https://yeoseongil.tistory.com/227#entry227comment</comments>
      <pubDate>Mon, 16 Mar 2026 11:41:54 +0900</pubDate>
    </item>
    <item>
      <title>[개발일지] 05. 홈 독서 지도를 어떻게 구현했을까요? (MapKit 커스텀 + 항공 경로 시각화)</title>
      <link>https://yeoseongil.tistory.com/226</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;MapTile을 적용하다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디자인 요구사항상 미니멀한 블랙 톤의 지도 스타일을 구현해야 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 &lt;b&gt;MapKit의 기본 지도 스타일(standard, hybrid, imagery)만으로는 한계&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;도로나 지형, 지명, 텍스트, POI(장소 정보) 등과 같은 노이즈는 MapView의 pointOfInterestFilter 속성을 .excludingAll로 처리하여 제거할 수 있었지만, 지도 자체의 디자인(대륙 색상, 바다 색상 등)은 여전히 커스터마이징이 어려웠습니다.&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;size16&quot;&gt;결국 &lt;b&gt;기존 지도를 수정하는 방식으로는 한계가 있다고 판단&lt;/b&gt;했고, &lt;b&gt;지도 자체를 직접 구성하는 방향으로 접근을 전환&lt;/b&gt;했습니다. 이 과정에서 MapTile 개념을 알게 되었고, MapKit에서도 MKTileOverlay를 통해 타일 기반 지도를 적용할 수 있다는 점을 확인했습니다.&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;h4 data-ke-size=&quot;size20&quot;&gt;pointOfInterestFilter&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;MapKit이 자동으로 그려주는 POI(장소 정보)를 제어하는 속성&lt;/b&gt;입니다. 카페, 식당, 편의점, 병원, 학교와 같은 장소 정보를 필터링(제어)할 수 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1773807541298&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 특정 카테고리 포함
let filter = MKPointOfInterestFilter(including: [.cafe, .restaurant, .hotel])
mapView.pointOfInterestFilter = filter

// 특정 카테고리 제외
let filter = MKPointOfInterestFilter(excluding: [.parking, .gasStation])
mapView.pointOfInterestFilter = filter

// 모든 POI 숨김
mapView.pointOfInterestFilter = .excludingAll

// 모든 POI 표시
mapView.pointOfInterestFilter = .includingAll&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 코드처럼 특정 카테고리만 포함하거나, 특정 카테고리를 제외하거나, 모든 POI를 표시할 수도 있지만, Flyleaf 홈맵에서는 POI 정보가 필요하지 않기 때문에 &lt;b&gt;.excludingAll을 사용하여 모든 POI를 숨김처리&lt;/b&gt; 했습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;MapTile?&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;타일&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;타일&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;타일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;타일&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;타일&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;타일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;타일&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;타일&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;타일&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;style2&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;MKTileOverlay&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;MapKit에서는 MKTileOverlay를 사용해서 맵타일을 적용&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;1. 타일 지도 생성&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1773806426432&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;let tileOverlay = MKTileOverlay(
  urlTemplate: MapTile.darkNolabels
)
tileOverlay.canReplaceMapContent = true
mapView.addOverlay(tileOverlay, level: .aboveRoads)&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;urlTemplate는 타일 이미지를 가져오는 주소입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1773806956387&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;https://tile.server/{z}/{x}/{y}.png&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;URL은 보통 위와 같은 형태이며, x, y는 타일 위치이고 z는 줌 레벨을 의미합니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 해당 위치 (x, y) 타일 이미지를 가져오고, 확대하면 z가 증가합니다.&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;보통 URL은 MapTile을 제공하는 서비스의 URL을 사용하기 때문에 URL자체에 신경 쓸 필요는 없습니다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1773807018335&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;tileOverlay.canReplaceMapContent = true&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;canReplaceMapContent는 MapKit에서&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;size16&quot;&gt;&lt;b&gt;false이면 기본 지도 타일 위에 커스텀 타일을 오버레이로 덮어씌웁니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;true이면 Apple 기본 지도 타일을 완전히 숨기고 커스텀 타일만 표시&lt;/b&gt;합니다.&lt;span&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;쉽게 생각하면, &lt;b&gt;Apple 지도를 완전히 다른 지도로 커스텀하고 싶을때 canReplaceMapContent 속성을 true로 설정&lt;/b&gt;합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1773807852451&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;mapView.addOverlay(tileOverlay)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;addOverlay는 지도 위에 도형, 선, 타일 등을 추가하는 메소드&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;2. 렌더러 등록&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;오버레이를 추가했다고 타일이 바로 적용되지 않습니다. delegate에서 렌더러를 반환해야 실제로 맵타일이 그려집니다.&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1773810991215&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;func mapView(
  _ mapView: MKMapView, 
  rendererFor overlay: MKOverlay
  ) -&amp;gt; MKOverlayRenderer {
    if let tileOverlay = overlay as? MKTileOverlay {
      return MKTileOverlayRenderer(tileOverlay: tileOverlay)
    }
    return MKOverlayRenderer(overlay: overlay)
}&lt;/code&gt;&lt;/pre&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;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-18 오후 2.20.01.png&quot; data-origin-width=&quot;1012&quot; data-origin-height=&quot;614&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/r0Sa0/dJMcaaR8MuR/Sbfz4aH7py6NknyCeMYky0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/r0Sa0/dJMcaaR8MuR/Sbfz4aH7py6NknyCeMYky0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/r0Sa0/dJMcaaR8MuR/Sbfz4aH7py6NknyCeMYky0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fr0Sa0%2FdJMcaaR8MuR%2FSbfz4aH7py6NknyCeMYky0%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;539&quot; height=&quot;327&quot; data-filename=&quot;스크린샷 2026-03-18 오후 2.20.01.png&quot; data-origin-width=&quot;1012&quot; data-origin-height=&quot;614&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디자인 요구사항상, 실제 공항 위치에 출발지와 도착지를 명확하게 표시해야 했습니다. 애플 디벨로퍼 아카데미 스터디에서 MapKit의&amp;nbsp;어노테이션 기능을 사용해본 경험이 있었기 때문에, 이번에도 &lt;b&gt;MapKit의 어노테이션을 활용하는 방향으로 구현을 시작&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;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 출발 공항인지 도착 공항인지 구분&lt;/b&gt; &lt;b&gt;되어야 함. (이륙 아이콘, 착륙 아이콘으로 구별)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 공항 코드가 함께 보여야 함.&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;즉, 단순한 마커/핀이 아닌 공항 정보를 가진 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;어노테이션 모델 정의&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 가장 먼저, &lt;b&gt;지도에 올릴 데이터를 명확하게 표현하기 위해 어노테이션 모델을 정의&lt;/b&gt;했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1773811756303&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;final class AirportAnnotation: NSObject, MKAnnotation {
  let iconType: AirportIconType
  let code: String
  let coordinate: CLLocationCoordinate2D
  
  init(
    iconType: AirportIconType,
    code: String,
    coordinate: CLLocationCoordinate2D,
  ) {
    self.iconType = iconType
    self.code = code
    self.coordinate = coordinate
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;p data-ke-size=&quot;size16&quot;&gt;특히 &lt;b&gt;iconType은 출발/도착 공항에 따라 서로 다른 아이콘을 보여주기 위해 필요&lt;/b&gt;했고, &lt;b&gt;code는 공항의 IATA 코드를 마커에 함께 표시하기 위해 추가&lt;/b&gt;했습니다.&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&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;/p&gt;
&lt;pre id=&quot;code_1773812173207&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;final class AirportAnnotationView: MKAnnotationView&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;MapKit에서는 지도 위의 어노테이션을 표시할 때 기본적으로 MKAnnotaionView를 사용&lt;/b&gt;합니다. 기본 제공 스타일로는 앱에서 원하는 UI를 만들기 어려웠기 때문에, &lt;b&gt;MKAnnotaionView를 상속한 AirportAnnotationView를 만들어 공항 마커를 직접 구성&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;커스텀 어노테이션 뷰에서 중요했던 부분은 prepareForReuse() 부분&lt;/b&gt;이었습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1773812225130&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;override func prepareForReuse() {
  super.prepareForReuse()
  codeLabel.text = nil
  iconImageView.image = nil
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;MapKit의 어노테이션 뷰도 테이블뷰 셀처럼 재사용&lt;/b&gt; 됩니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;이전 어노테이션의 표시 정보가 남아 있으면, 다른 공항이 같은 뷰를 재사용하는 순간 잘못된 IATA 코드나 아이콘이 잠깐 보이는 문제&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;prepareForReuse()에서 이전 상태를 초기화해주는 처리가 필요&lt;/b&gt;했습니다. 위 코드에서는 &lt;b&gt;codeLabel.text와 iconImageView.image를 비워서, 재사용되는 순간 이전 데이터가 남아 있지 않도록 처리&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;마찬가지로 어노테이션을 지도에 표시하려면 mapView(_:viewFor:) 델리게이트 메서드를 구현해야 합니다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1773812839788&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public func mapView(
  _ mapView: MKMapView,
  viewFor annotation: any MKAnnotation
) -&amp;gt; MKAnnotationView? {
  if let airportAnnotation = annotation as? AirportAnnotation {
    let view = mapView.dequeueReusableAnnotationView(
      withIdentifier: AirportAnnotationView.identifier
    ) as? AirportAnnotationView ?? AirportAnnotationView(
      annotation: airportAnnotation,
      reuseIdentifier: AirportAnnotationView.identifier
    )

    view.annotation = airportAnnotation
    view.configure(annotation: airportAnnotation)
    return view
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전달 받은 어노테이션의 타입을 확인한 후, dequeueReusableAnnotationView로 재사용 가능한 뷰를 꺼내고, 없으면 새로 생성합니다. 이후 configure() 메소드를 통해 데이터를 주입하고 뷰를 반환하면 지도에 마커가 표시됩니다.&lt;/p&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;p data-ke-size=&quot;size16&quot;&gt;공항 위치를 표시한 다음에는, 출발지와 도착지를 하나로 연결해 보여주는 경로 표현이 필요했습니다. 그래서 &lt;b&gt;두 공항 좌표를 연결하는 방식으로 경로를 함께 출력&lt;/b&gt;하기로 했고, &lt;b&gt;MapKit에서 경로를 표현할 수 있는 MKPolyline을 사용해 구현&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;MKPolyline&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;MKPolyline은 여러 좌표를 순서대로 이어 선을 그리는 클래스입&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;1. 출발지와 도착지 좌표를 연결하는 overlay 생성&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1773813150493&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;let coordinates = [departure, arrival]
let polyline = MKPolyline(coordinates: coordinates, count: coordinates.count)
mapView.addOverlay(polyline)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- coordinates: CLLocationCoordinate2D 배열로, 선을 이을 좌표들입니다. 배열 순서대로 점과 점이 연결됩니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- count: 배열 요소의 개수입니다. 배열 길이를 별도로 넘겨줘야합니다.&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;coordinates 배열은 출발지와 도착지 두 점만 있으니 직선 하나&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;2. 렌더러 등록&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;맵타일과 마찬가지로 &lt;b&gt;polyline도 렌더러를 반환해야 지도에 표시&lt;/b&gt;됩니다.&lt;/p&gt;
&lt;pre id=&quot;code_1773813434941&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public func mapView(
  _ mapView: MKMapView,
  rendererFor overlay: MKOverlay
) -&amp;gt; MKOverlayRenderer {
  if let polyline = overlay as? MKPolyline {
    let renderer = MKPolylineRenderer(polyline: polyline)
    renderer.strokeColor = .n0.withAlphaComponent(0.5)
    renderer.lineWidth = 0.5
    renderer.lineCap = .round
    renderer.lineJoin = .round
    return renderer
  }

  return MKOverlayRenderer(overlay: overlay)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 &lt;b&gt;mapView(_:rendererFor:) delegate 메소드에서 MKPolylineRenderer를 반환하도록 구현&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;polyline도 조금의 커스텀이 가능&lt;/b&gt;합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- strokeColor: 라인 색상&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- lineWidth: 라인 두께&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- lineCap: 선 끝부분 모양&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- lineJoin: 선이 꺾이는 부분 모양&lt;/b&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 data-ke-size=&quot;size23&quot;&gt;비행기 어노테이션 표시&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;커스텀 어노테이션&lt;/h4&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;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;그래서 비행기 위치를 표시하기 위한 전용 어노테이션 모델을 따로 정의했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1773815825038&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;final class FlightAnnotation: NSObject, MKAnnotation {
  dynamic var coordinate: CLLocationCoordinate2D
  
  init(coordinate: CLLocationCoordinate2D) {
    self.coordinate = coordinate
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 &lt;b&gt;중요한 부분은 coordinate를 dynamic으로 선언한 것&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;MapKit은 어노테이션의 위치가 변경되었을 때 이를 감지해 화면을 갱신&lt;/b&gt;해야 하는데, 이때 &lt;b&gt;내부적으로 KVO를 사용&lt;/b&gt;합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;coordinate를 dynamic으로 선언해야 MapKit이 이 변화를 감지할 수 있고, 그래야 비행기 위치를 업데이트했을 때 지도에서도 자연스럽게 이동하는 것처럼 보이게 됩니다.&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;그 다음으로는 비행기 마커를 위한 커스텀 어노테이션 뷰 MKAnnotaionView를 상속하여 구현했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1773815909046&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;final class FlightAnnotationView: MKAnnotationView&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;pre id=&quot;code_1773816003862&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;override func prepareForReuse() {
  super.prepareForReuse()
  imageView.transform = .identity
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 &lt;b&gt;prepareForReuse()에서 transform을 초기 상태(.identity)로 되돌려, 이 문제를 방지&lt;/b&gt;했습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;위치 계산&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;p data-ke-size=&quot;size16&quot;&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;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;b&gt;출발지와 도착지 사이의 중간 좌표를 단순히 계산해서 배치하는 방식으로 접근&lt;/b&gt;했습니다. &lt;b&gt;처음에는 이 방식이 가장 직관적이라고 생각&lt;/b&gt;했습니다. &lt;b&gt;출발 좌표와 도착 좌표가 있으니, 두 점 사이를 progress 비율만큼 보간하면 경로 위 어딘가의 좌표가 나올 것이라고 판단&lt;/b&gt;했기 때문입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1773815356978&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;func interpolatedCoordinate(
  start: CLLocationCoordinate2D,
  end: CLLocationCoordinate2D,
  progress: Double
) -&amp;gt; CLLocationCoordinate2D {
  let latitude = start.latitude + (end.latitude - start.latitude) * progress
  let longitude = start.longitude + (end.longitude - start.longitude) * progress
  
  return CLLocationCoordinate2D(
    latitude: latitude,
    longitude: longitude
  )
}&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-filename=&quot;IMG_5441.PNG&quot; data-origin-width=&quot;1179&quot; data-origin-height=&quot;2556&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lwHz5/dJMcahResDw/vFaN4WP3Nqf95DNLkzVSak/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lwHz5/dJMcahResDw/vFaN4WP3Nqf95DNLkzVSak/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lwHz5/dJMcahResDw/vFaN4WP3Nqf95DNLkzVSak/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlwHz5%2FdJMcahResDw%2FvFaN4WP3Nqf95DNLkzVSak%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;282&quot; height=&quot;611&quot; data-filename=&quot;IMG_5441.PNG&quot; data-origin-width=&quot;1179&quot; data-origin-height=&quot;2556&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 계산한 좌표로 비행기 어노테이션을 찍었을 때, 처음 화면에서는 경로 위에 잘 올라가 있는 것처럼 보였습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;IMG_5442.PNG&quot; data-origin-width=&quot;1179&quot; data-origin-height=&quot;2556&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ciEe7y/dJMcahjrcuM/9yYCBDlBxrNjShPvPM0Kj0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ciEe7y/dJMcahjrcuM/9yYCBDlBxrNjShPvPM0Kj0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ciEe7y/dJMcahjrcuM/9yYCBDlBxrNjShPvPM0Kj0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FciEe7y%2FdJMcahjrcuM%2F9yYCBDlBxrNjShPvPM0Kj0%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;282&quot; height=&quot;611&quot; data-filename=&quot;IMG_5442.PNG&quot; data-origin-width=&quot;1179&quot; data-origin-height=&quot;2556&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;처음 구현은 위도/경도 값을 단순히 선형 보간한 방식이었는데, MapKit에서 실제로 지도 위에 그려지는 경로는 단순한 위도/경도 직선이 아니라 Map Projection을 거쳐 표현&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;제가 계산한 좌표는 위도/경도 기준 중간 지점이었고, 실제 화면에 그려진 경로는 지도 투영 이후의 선이었기 때문에 줌 레벨이 바뀌거나 확대해서 볼수록 두 결과 사이의 차이가 발생한 것&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;위도/경도를 직접 보간하는 방식 대신 MapKit이 실제로 사용하는 좌표계인 MKMapPoint 기준으로 다시 계산하는 방식으로 접근을 수정&lt;/b&gt;했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1773815635252&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;func interpolatedCoordinate(
  start: CLLocationCoordinate2D,
  end: CLLocationCoordinate2D,
  progress: Double
) -&amp;gt; CLLocationCoordinate2D {
  let startPoint = MKMapPoint(start)
  let endPoint = MKMapPoint(end)
  
  let x = startPoint.x + (endPoint.x - startPoint.x) * progress
  let y = startPoint.y + (endPoint.y - startPoint.y) * progress
  
  return MKMapPoint(x: x, y: y).coordinate
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style2&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;비행기의 방향&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;p data-ke-size=&quot;size16&quot;&gt;처음 구현에서는 &lt;b&gt;단순히 비행기 아이콘을 경로 위에 올려두기만 했기 때문에, 아이콘이 항상 같은 방향을 바라보고 있었습니다.&lt;/b&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;p data-ke-size=&quot;size16&quot;&gt;방향을 계산하기 위해 &lt;b&gt;단순히 출발지와 도착지 좌표를 사용하는 대신,&amp;nbsp; 두 좌표를 기준으로 방향 벡터를 계산하는 방식으로 접근&lt;/b&gt;했습니다. (구글링하고 AI한테 물어보니까 접선 기반 방향 계산 방식이 있다고 하네요.. atan2와 같은.. 그래서 그걸 활용하여 구현했습니다. 마침 swift에 atan2가 구현되어 있더군요!)&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;- atan2&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Atan2&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://en.wikipedia.org/wiki/Atan2&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2D 평면에서 벡터의 방향을 계산할 때는 atan2(dy, dx)가 표준적으로 사용되며, 이는 x/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;&lt;b&gt;- 접선 벡터&lt;/b&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;b&gt;- 두 점으로 방향 구하는 방식&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;벡터 = (end - start) why? 벡터 연산은 두 점의 차이!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;방향 = atan2(dy, dx)&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;pre id=&quot;code_1773818186495&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;func flightRotationAngle(
  start: CLLocationCoordinate2D,
  end: CLLocationCoordinate2D
) -&amp;gt; CGFloat {
  let dx = end.longitude - start.longitude
  let dy = end.latitude - start.latitude
  
  return atan2(dy, dx)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;처음에는 가장 단순하게, 출발지와 도착지의 위경도 차이를 이용해 방향 벡터를 계산하는 방식으로 접근&lt;/b&gt;했습니다. &lt;b&gt;즉, 지도 위 두 좌표를 기준으로 벡터를 만들고, 그 벡터의 각도를 atan2로 계산해 비행기 어노테이션에 적용하는 방식&lt;/b&gt;이었습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1773816535896&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;view.setRotation(angle)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;계산된 각도는 어노테이션 뷰에 전달되어 비행기 이미지의 transform으로 적용됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1773816572513&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;func setRotation(_ angle: CGFloat) {
  imageView.transform = CGAffineTransform(rotationAngle: angle)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;setRotation 메소드는 커스텀 어노테이션 뷰에 구현한 커스텀 메소드입니다.&lt;/p&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-filename=&quot;ScreenRecording_03-10-2026 00-53-40_1.gif&quot; data-origin-width=&quot;400&quot; data-origin-height=&quot;866&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/1tgBm/dJMcagx0KTy/aD2YORaPmZkEcGqmIUoN70/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/1tgBm/dJMcagx0KTy/aD2YORaPmZkEcGqmIUoN70/img.gif&quot; data-alt=&quot;?!&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/1tgBm/dJMcagx0KTy/aD2YORaPmZkEcGqmIUoN70/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/1tgBm/dJMcagx0KTy/aD2YORaPmZkEcGqmIUoN70/img.gif&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;307&quot; height=&quot;665&quot; data-filename=&quot;ScreenRecording_03-10-2026 00-53-40_1.gif&quot; data-origin-width=&quot;400&quot; data-origin-height=&quot;866&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;여기까지 구현하고 나니 또 하나의 문제가 발생했습니다. &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;&lt;b&gt;MapKit의 어노테이션은 지도 좌표계 기준으로 회전&lt;/b&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;p data-ke-size=&quot;size16&quot;&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;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;처음에는 이 문제를 해결하기 위해 지도의 회전값을 직접 보정하는 방법도 고민&lt;/b&gt;했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1773818271071&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;let mapRotation = mapView.camera.heading * (.pi / 180)
let finalAngle = angle - mapRotation&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;p data-ke-size=&quot;size16&quot;&gt;그래서 최종적으로는 위경도 기준 계산을 버리고, &lt;b&gt;경로 앞뒤 두 지점을 화면 좌표로 변환한 뒤 방향을 계산하는 방식으로 수정&lt;/b&gt;했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1773817190282&quot; class=&quot;vim&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;swift&quot;&gt;&lt;code&gt;func flightRotationAngle(
  start: CLLocationCoordinate2D,
  end: CLLocationCoordinate2D,
  progress: Double
) -&amp;gt; CGFloat {
  let previousProgress = max(0.0, progress - 0.01)
  let nextProgress = min(1.0, progress + 0.01)

  let previousCoordinate = interpolatedCoordinate(
    start: start,
    end: end,
    progress: previousProgress
  )

  let nextCoordinate = interpolatedCoordinate(
    start: start,
    end: end,
    progress: nextProgress
  )

  let previousPoint = mapView.convert(previousCoordinate, toPointTo: mapView)
  let nextPoint = mapView.convert(nextCoordinate, toPointTo: mapView)

  let dx = nextPoint.x - previousPoint.x
  let dy = nextPoint.y - previousPoint.y

  return atan2(dy, dx)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식의 &lt;b&gt;핵심은 mapView.convert(_:toPointTo:)&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;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;p data-ke-size=&quot;size16&quot;&gt;이번 구현은 .. 쉽지 않은 구현이었습니다. 좌표계 표현 방식이나 거리 벡터.. atan2와 같은 생소한 개념을 활용해야했기 때문에 조금은 어려웠지만! 재밌었던 경험이었습니다.&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;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;style5&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;레퍼런스&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://developer.apple.com/documentation/spatial/angle2d/atan2(y:x:)&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://developer.apple.com/documentation/spatial/angle2d/atan2(y:x:)&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://blog.spiralmoon.dev/entry/%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D-%EC%9D%B4%EB%A1%A0-%EB%91%90-%EC%A0%90-%EC%82%AC%EC%9D%B4%EC%9D%98-%EC%A0%88%EB%8C%80%EA%B0%81%EB%8F%84%EB%A5%BC-%EC%9E%AC%EB%8A%94-atan2&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://blog.spiralmoon.dev/entry/%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D-%EC%9D%B4%EB%A1%A0-%EB%91%90-%EC%A0%90-%EC%82%AC%EC%9D%B4%EC%9D%98-%EC%A0%88%EB%8C%80%EA%B0%81%EB%8F%84%EB%A5%BC-%EC%9E%AC%EB%8A%94-atan2&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Atan2&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://en.wikipedia.org/wiki/Atan2&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://lime-juice.tistory.com/entry/%EC%9D%B4%EB%A1%A0-%EC%8A%A4%ED%84%B0%EB%94%94-%EB%B2%A1%ED%84%B0-%EC%82%AC%EC%9D%B4%EC%9D%98-%EA%B1%B0%EB%A6%AC-%EA%B0%81%EB%8F%84-%EA%B3%84%EC%82%B0&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://lime-juice.tistory.com/entry/%EC%9D%B4%EB%A1%A0-%EC%8A%A4%ED%84%B0%EB%94%94-%EB%B2%A1%ED%84%B0-%EC%82%AC%EC%9D%B4%EC%9D%98-%EA%B1%B0%EB%A6%AC-%EA%B0%81%EB%8F%84-%EA%B3%84%EC%82%B0&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://openmaptiles.org/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://openmaptiles.org/&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://developer.apple.com/documentation/mapkit/mappolyline&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://developer.apple.com/documentation/mapkit/mappolyline&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Flyleaf - 독서를 여행처럼/개발일지</category>
      <author>여성일</author>
      <guid isPermaLink="true">https://yeoseongil.tistory.com/226</guid>
      <comments>https://yeoseongil.tistory.com/226#entry226comment</comments>
      <pubDate>Mon, 16 Mar 2026 11:41:23 +0900</pubDate>
    </item>
    <item>
      <title>[개발일지]  04. Composition Root (feat. 어디까지 몰라야하는가?)</title>
      <link>https://yeoseongil.tistory.com/225</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;App 레이어는 정말 Interface에만 의존할까?&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이전 글에서는 &quot;&lt;b&gt;App 레이어는 Feature 내부 구현을 직접 알지 않는다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/b&gt;대신&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Feature의 Interface에만 의존한다.&quot;라고 이야기 했습니다.&lt;span&gt;&amp;nbsp;&lt;/span&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;/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;- 정말 App 레이어는 Feature 구현을 전혀 몰라야 할까?&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;- 그렇다면 실제 Feature 객체는 누가 생성해야 할까?&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;&quot;App은 Feature 구현을 전혀 몰라야한다!&quot; 라고 생각&lt;/b&gt;했습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그래서 &lt;b&gt;AppCoordinator에서도 Feature 모듈을 import하지 않고 Interface만 의존하도록 구조를 설계&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;예를 들어 AppCoordinator는 아래와 같이 Interface 타입만 알고 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1773230111401&quot; class=&quot;swift&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;swift&quot;&gt;&lt;code&gt;import HomeInterface
import LoginInterface

private let homeBuilder: HomeBuildable
private let loginBuilder: LoginBuildable&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;그리고 실제 화면을 생성할 때도 Interface를 통해 화면을 요청합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1773230111402&quot; class=&quot;nix&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;swift&quot;&gt;&lt;code&gt;let loginVC = loginBuilder.build&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 구조에서는 AppCoordinator가 LoginViewController, LoginViewModel 같은 Feature 내부 구현을 전혀 알 필요가 없습니다. 여기까지는 이상적인 구조처럼 보였습니다. 하지만 계속 구현을 하면서 한 가지 문제가 생겼습니다.&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: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;Interface의 구현체는 누가 생성해야 할까?&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그렇다면 &lt;b&gt;Interface의 구현체는 누가 생성할까? &lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Builder 패턴을 사용하더라도 결국 LoginBuilder, HomeBuilder와 같은 구현 객체는 어딘가에서 생성&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;Flyleaf에서는 이 역할을 SceneDelegate에서 담당하고 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1773230111402&quot; class=&quot;swift&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;swift&quot;&gt;&lt;code&gt;import HomeFeature
import LoginFeature

let homeBuilder = HomeBuilder()
let loginBuilder = LoginBuilder()

let coordinator = AppCoordinator(
  navigationController: navigationController,
  authService: authService,
  homeBuilder: homeBuilder,
  loginBuilder: loginBuilder
)&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 코드를 보면 SceneDelegate에서는 Feature 구현 모듈을 직접 import하고 있습니다.&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;처음에는 이 구조가 &quot;결국 App Layer는 Feature에 의존할 수 밖에 없네? 이게 맞는 건가?&quot;라는 고민이 들었습니다.&lt;/p&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;b&gt;Composition Root&lt;/b&gt;&lt;/h3&gt;
&lt;figure data-ke-type=&quot;video&quot; data-ke-style=&quot;alignCenter&quot; data-video-host=&quot;youtube&quot; data-video-url=&quot;https://www.youtube.com/watch?v=qaDpuaoDxuU&quot; data-video-thumbnail=&quot;https://scrap.kakaocdn.net/dn/o564z/dJMb9gxjM4F/LKFhjCtrhSgTPsw3qSiWok/img.jpg?width=1280&amp;amp;height=720&amp;amp;face=0_0_1280_720,https://scrap.kakaocdn.net/dn/ddb6kE/dJMb86O0cHo/aA3LWlMqCHUJwtTLYuKF5K/img.jpg?width=1280&amp;amp;height=720&amp;amp;face=0_0_1280_720&quot; data-video-width=&quot;860&quot; data-video-height=&quot;484&quot; data-video-origin-width=&quot;860&quot; data-video-origin-height=&quot;484&quot; data-ke-mobilestyle=&quot;widthContent&quot; data-video-title=&quot;Composition Root - The Only DI Pattern&quot; data-original-url=&quot;&quot;&gt;&lt;iframe src=&quot;https://www.youtube.com/embed/qaDpuaoDxuU&quot; width=&quot;860&quot; height=&quot;484&quot; frameborder=&quot;&quot; allowfullscreen=&quot;true&quot;&gt;&lt;/iframe&gt;
&lt;figcaption style=&quot;display: none;&quot;&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 고민을 정리하는 과정에서 알게 된 개념이 바로 &lt;b&gt;Composition Root&lt;/b&gt;였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Composition Root는 앱의 모든 객체를 조립하는 최상위 지점&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 data-ke-size=&quot;size16&quot;&gt;- &lt;b&gt;어떤 객체를 생성&lt;/b&gt;할지&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;b&gt;어떤 의존성을 주입&lt;/b&gt;할지&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;style5&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Flyleaf에서의 Composition Root&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Flyleaf에서는 이 역할을 SceneDelegate가 담당합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1773287948259&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SceneDelegate 
    ├ Builder 생성
    └ AppCoordinator에 주입&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; 지점에서는 Feature의 구현을 알고 있어도 괜찮습니다.&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;Composition Root의 역할 자체가 모든 객체를 조립하는 것&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;1. SceneDelegate(Composition Root)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Feature 구현 모듈을 알고 있어도 괜찮다.&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;b&gt;2. AppCoordinator 이후 레이어&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Feature 구현이 아닌 Interface에만 의존한다.&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;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-03-12 오후 1.03.06.png&quot; data-origin-width=&quot;391&quot; data-origin-height=&quot;559&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bNhh3C/dJMcabi8Cqc/LCeRLk0rGf9Ixo5HmWWg50/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bNhh3C/dJMcabi8Cqc/LCeRLk0rGf9Ixo5HmWWg50/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bNhh3C/dJMcabi8Cqc/LCeRLk0rGf9Ixo5HmWWg50/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbNhh3C%2FdJMcabi8Cqc%2FLCeRLk0rGf9Ixo5HmWWg50%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;391&quot; height=&quot;559&quot; data-filename=&quot;스크린샷 2026-03-12 오후 1.03.06.png&quot; data-origin-width=&quot;391&quot; data-origin-height=&quot;559&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;style5&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;결국 중요한 것은 &quot;어디까지 알게 할 것인가?&quot;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Micro-Features Architecture를 적용하면서 느낀 점은&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;아무도 아무것도 몰라야 한다&quot;는 것이 목표가 아니라는 것&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;구현 의존성이 앱 전체로 퍼지지 않도록 경계를 만드는 것&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 data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. App은 Feature를 몰라야 한다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. Feature끼리는 서로 완전히 몰라야 한다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. 모든 레이어는 서로 완전히 분리되어야 한다.&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;마치 모든 모듈이 서로 완전히 독립적이어야 하는 것처럼 느껴집니다.&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;실제로 앱을 개발하다 보면 어딘가는 반드시 다른 모듈을 알아야 하는 지점이 존재&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 data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- Feature 객체는 누군가 생성해야 하고&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 의존성은 어디선가 주입되어야 하고&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;p data-ke-size=&quot;size16&quot;&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;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Flyleaf - 독서를 여행처럼/개발일지</category>
      <author>여성일</author>
      <guid isPermaLink="true">https://yeoseongil.tistory.com/225</guid>
      <comments>https://yeoseongil.tistory.com/225#entry225comment</comments>
      <pubDate>Wed, 11 Mar 2026 20:54:43 +0900</pubDate>
    </item>
  </channel>
</rss>