<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>또 만드는 한톨</title>
    <link>https://thinkyside.tistory.com/</link>
    <description>스스로에게 솔직해지고 싶은 공간</description>
    <language>ko</language>
    <pubDate>Fri, 24 Jul 2026 12:01:23 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>thinkyside</managingEditor>
    <image>
      <title>또 만드는 한톨</title>
      <url>https://tistory1.daumcdn.net/tistory/5636175/attach/eb1d73bbdcd74f6683f81f6dc50a01da</url>
      <link>https://thinkyside.tistory.com</link>
    </image>
    <item>
      <title>onChange는 사실 조금 이상하다</title>
      <link>https://thinkyside.tistory.com/120</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;'특정 View가 화면에 노출되는 시점'을 감지하기 위해 커스텀 ViewModifier를 구현하던 중 요상한 문제에 부딪혔습니다. 특정 값이 변화할 때를 감지하기 위해 SwiftUI에서 흔히 사용하는 `onChange` ViewModifier를 이용해 함수를 실행하는 코드였는데, 값이 분명 변화해`&lt;b&gt;onChange`가 실행되었음에도 예상하는 값과 다르게 사용되는 문제&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;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;상위 View에서 전달받은 Environment 값(containerFrame)을 받아오고&lt;/li&gt;
&lt;li&gt;onChange에서 이 Environment 값의 변화를 감지하면,&lt;/li&gt;
&lt;li&gt;checkVisible 함수에서 Environment 값을 이용해 화면에 노출되었는지를 검사합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre id=&quot;code_1768355095282&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;struct OnVisibilityViewModifier: ViewModifier {

    @Environment(\.containerFrame) private var containerFrame
    
    func body(content: Content) -&amp;gt; some View {
        content
            .onChange(of: containerFrame) { _ in
                checkVisible()
            }
    }
    
    private func checkVisible() {
        containerFrame... // containerFrame을 가지고 계산 어쩌구저쩌구...
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;직관적으로 containerFrame 값이 `.zero`에서 `CGRect(&lt;span&gt;x&lt;/span&gt;: &lt;span&gt;100&lt;/span&gt;, &lt;span&gt;y&lt;/span&gt;: &lt;span&gt;100&lt;/span&gt;, &lt;span&gt;width&lt;/span&gt;: &lt;span&gt;100&lt;/span&gt;, &lt;span&gt;height&lt;/span&gt;: &lt;span&gt;100&lt;/span&gt;)`로 값이 변화했을 때, 우리는 checkVisible 함수에서 새로 업데이트된 값으로 계산되기를 바랄 것입니다. 하지만 전-혀 직관적이지 않게도 전혀 다른 결과를 마주하게 됩니다.&lt;/p&gt;
&lt;pre id=&quot;code_1768356172648&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 1. TestView
struct TestView: View {

    @State private var frame: CGRect = .zero

    var body: some View {
        VStack {
            Button(&quot;frame 업데이트&quot;) {
                frame = .init(x: 100, y: 100, width: 100, height: 100)
            }
            // 여기서 containerFrame 값 받아와서 사용할 것
            .modifier(OnVisibilityViewModifier())
        }
        // containerFrame 값 environment로 주입
        .environment(\.containerFrame, frame)
    }
}

// 2. ViewModifier
struct OnVisibilityViewModifier: ViewModifier {

    @Environment(\.containerFrame) private var containerFrame

    func body(content: Content) -&amp;gt; some View {
        content
            .onAppear {
                print(&quot;onAppear 클로저에서 실행한 containerFrame: \(containerFrame)&quot;)
            }
            .onChange(of: containerFrame) { _ in
                print(&quot;onChange 클로저에서 실행한 containerFrame: \(containerFrame)&quot;)
                checkVisible()
            }
    }

    private func checkVisible() {
        print(&quot;checkVisible 함수에서 실행한 containerFrame: \(containerFrame)&quot;)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;화면 기록 2026-01-14 오전 11.07.23.gif&quot; data-origin-width=&quot;1200&quot; data-origin-height=&quot;846&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cll9Om/dJMcajgCuwA/FdcvwJaic9KXEfefr5JfI0/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cll9Om/dJMcajgCuwA/FdcvwJaic9KXEfefr5JfI0/img.gif&quot; data-alt=&quot;???&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cll9Om/dJMcajgCuwA/FdcvwJaic9KXEfefr5JfI0/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/cll9Om/dJMcajgCuwA/FdcvwJaic9KXEfefr5JfI0/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;700&quot; height=&quot;494&quot; data-filename=&quot;화면 기록 2026-01-14 오전 11.07.23.gif&quot; data-origin-width=&quot;1200&quot; data-origin-height=&quot;846&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;onChange 클로저가 호출되었다는 뜻은, 실제로 containerFrame 값이 변화했다는 의미입니다.(그래서 print가 찍히고 있구요!) 하지만 클로저 내부에서도, checkVisible 함수에서도 containerFrame 값은 여전히 기본값을 출력하고 있습니다. 대체 여기서 찍히고 있는 저 값은 어떻게 해석해야 할까요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;일단 해결해보기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 문제를 해결하는 가장 간단한 방법은,,, 클로저에 전달된 인자를 사용하는 것입니다! 같은 값일 거라고 생각하고 당연~하게도 Environment 값에 접근해 사용하던 것을 요렇게 바꿔주면 우리가 원하는 값을 찾을 수 있게 됩니다.&lt;/p&gt;
&lt;pre id=&quot;code_1768357076203&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;struct OnVisibilityViewModifier: ViewModifier {

    @Environment(\.containerFrame) private var containerFrame

    func body(content: Content) -&amp;gt; some View {
        content
            .onChange(of: containerFrame) { value in
                print(&quot;onChange 클로저에서 전달 받은 containerFrame: \(value)&quot;)
                checkVisible(value) // ⭐️ 전달 받은 값을 넣어주기
            }
    }

    private func checkVisible(_ value: CGRect) {
        print(&quot;checkVisible 함수에서 전달받은 containerFrame: \(value)&quot;)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1668&quot; data-origin-height=&quot;108&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/oI1Tv/dJMcadOdQMf/z0ttMnWKGs2esNd6mxkmPk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/oI1Tv/dJMcadOdQMf/z0ttMnWKGs2esNd6mxkmPk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/oI1Tv/dJMcadOdQMf/z0ttMnWKGs2esNd6mxkmPk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FoI1Tv%2FdJMcadOdQMf%2Fz0ttMnWKGs2esNd6mxkmPk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;700&quot; height=&quot;45&quot; data-origin-width=&quot;1668&quot; data-origin-height=&quot;108&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클로저로 전달된 value와 Environment가 같은 값이 아니라니,,, 뭔가 이상합니다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;onChange는 사실 조금 이상하다?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 우리가 유추할 수 있는 것은 onChange를 사용할 때 대상이 되는 참조 값(여기서는 EnvironmentValue로 주입된 containerFrame)은 변경 전 상태를, 클로저의 인자로 전달되는 값(value)은 변경 후 상태를 나타낸다는 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;애플이 작성해 둔 &lt;a href=&quot;https://developer.apple.com/documentation/swiftui/view/onchange(of:perform:)&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;onChange 공식 예제&lt;/a&gt;에서도 아래와 같이 사용하는 것을 확인할 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1769648506875&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;struct MyScene: Scene {
    @Environment(\.scenePhase) private var scenePhase
    @StateObject private var cache = DataCache()

    var body: some Scene {
        WindowGroup {
            MyRootView()
        }
        .onChange(of: scenePhase) { newScenePhase in
            if newScenePhase == .background {
                cache.empty()
            }
        }
    }
}&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;하지만 조금 생각해 보면 무척이나 이상하긴 구조이긴 합니다. onChange의 의미는,,, 이름에서부터 &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;왜 이미 참조할 수 있는 값(scenePhase)이 있음에도 불구하고 굳이 굳이 클로저의 인자를 사용해야 '최신 값'에 접근할 수 있는 걸까요? 아래와 같이 작성했을 때 이상해 보이지 않는 이유도 이 때문일 것입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1769648973868&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;.onChange(of: scenePhase) { _ in
    if scenePhase == .background {
        cache.empty()
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;그래서 등장한 새로운 onChange&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;애플도 이런 문제를 인지한 것인지 iOS 17.0에서 &lt;a href=&quot;https://developer.apple.com/documentation/swiftui/view/onchange(of:initial:_:)-8wgw9&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;새로운 onChange&lt;/a&gt;를 내놓게 됩니다! 이에 따라 기존 onChange는 Deprecated 되었고, 위에서 함께 살펴본 문제에 대해 언급하며 이유를 설명해줍니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;b&gt;Deprecated&lt;/b&gt;&lt;br /&gt;Use&amp;nbsp;onChange(of:initial:_:)&amp;nbsp;or&amp;nbsp;onChange(of:initial:_:)&amp;nbsp;instead. The trailing closure in each case takes either zero or two input parameters, compared to this method which takes one.Be aware that the replacements have slightly different behavior. This modifier&amp;rsquo;s closure captures values that represent the state before the change. The new modifiers capture values that correspond to the new state. The new behavior makes it easier to perform updates that rely on values other than the one that caused the modifier&amp;rsquo;s closure to run.&lt;br /&gt;&lt;br /&gt;onChange(of:initial:_:) 또는 onChange(of:initial:_:)를 사용하세요. 각 메서드의 후행 클로저는 입력 매개변수를 0개 또는 2개 받는 반면, 이 메서드는 1개만 받습니다. 대체 메서드는 동작 방식이 약간 다릅니다. &lt;b&gt;기존 modifier의 클로저는 변경 전 상태를 나타내는 값을 캡처&lt;/b&gt;하는 반면, &lt;b&gt;새로운 modifier는 변경 후 상태에 해당하는 값을 캡처&lt;/b&gt;합니다. 이러한 새로운 동작 덕분에 modifier의 클로저를 실행시킨 값이 아닌 다른 값에 의존하는 업데이트를 더 쉽게 수행할 수 있습니다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새롭게 만들어진 onChange는 우리가 직관적으로 해석하기 쉬운 형태인 &lt;b&gt;'변경 후' 상태에 해당하는 값을 캡처&lt;/b&gt;한다고 하는 것이 핵심입니다! 바로 테스트 해볼까요?&lt;/p&gt;
&lt;pre id=&quot;code_1769651420145&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;struct OnVisibilityViewModifier: ViewModifier {

    @Environment(\.containerFrame) private var containerFrame

    func body(content: Content) -&amp;gt; some View {
        content
            .onAppear {
                print(&quot;onAppear 클로저에서 실행한 containerFrame: \(containerFrame)&quot;)
            }
            .onChange(of: containerFrame) { // ⭐️ 새로운 onChange 사용
                print(&quot;onChange 클로저에서 전달 받은 containerFrame: \(containerFrame)&quot;)
                checkVisible()
            }
    }

    private func checkVisible() {
        print(&quot;checkVisible 함수에서 전달받은 containerFrame: \(containerFrame)&quot;)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;화면 기록 2026-01-29 오전 10.29.34.gif&quot; data-origin-width=&quot;1255&quot; data-origin-height=&quot;885&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b4gyVX/dJMcaiISPoJ/lpFY53FUx0RWoxFfYHy6z1/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b4gyVX/dJMcaiISPoJ/lpFY53FUx0RWoxFfYHy6z1/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b4gyVX/dJMcaiISPoJ/lpFY53FUx0RWoxFfYHy6z1/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/b4gyVX/dJMcaiISPoJ/lpFY53FUx0RWoxFfYHy6z1/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;700&quot; height=&quot;494&quot; data-filename=&quot;화면 기록 2026-01-29 오전 10.29.34.gif&quot; data-origin-width=&quot;1255&quot; data-origin-height=&quot;885&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;만약 변경되기 이전의 값을 받아오고 싶다면 아래와 같이 사용도 가능합니다!(레거시 onChange가 '변경 전' 값을 캡처하도록 만들어진 이유도 '변경 전' 값을 사용하는 상황을 고려해서,,, 였을까요?)&lt;/p&gt;
&lt;pre id=&quot;code_1769651959321&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;.onChange(of: containerFrame) { oldValue, newValue in
    print(&quot;변경 전: \(oldValue)&quot;)
    print(&quot;변경 후: \(newValue)&quot;)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;iOS 16 이하에서는 여전히 주의가 필요함!&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 최소 지원 버전이 iOS 17 이상인 프로젝트였다면 아래와 같은 노란색 에러를 없애기 위해서라도(?) 자연스럽게 문제를 해결할 수도 있었을 것입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2470&quot; data-origin-height=&quot;246&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zpudE/dJMcagj2f8Y/3IeBzzEB2zYmyZqgkA6zu1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zpudE/dJMcagj2f8Y/3IeBzzEB2zYmyZqgkA6zu1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zpudE/dJMcagj2f8Y/3IeBzzEB2zYmyZqgkA6zu1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzpudE%2FdJMcagj2f8Y%2F3IeBzzEB2zYmyZqgkA6zu1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;700&quot; height=&quot;70&quot; data-origin-width=&quot;2470&quot; data-origin-height=&quot;246&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만,,, 제가 진행 중인 프로젝트는 iOS 16이 최소지원 버전이었고 이를 위해 레거시 onChange를 사용해야 했기 때문에 문제를 발견하기 더 어려웠지 않았나 싶습니다.(물론 근본적인 동작 방식을 알고 있었다면 == 공식 문서를 잘 읽었다면 이런 일은 일어나지 않았겠지만 말이에요)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아무튼 이번 기회에 왜 굳이 잘 동작하는(&lt;s&gt;그렇지 않았지만&lt;/s&gt;) onChange를 다시 만들었을까? 하는 작은 의문점이 있었는데 이것도 해결하고 동작 원리도 이해하는 좋은 기회가 되었던 것 같습니다!  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;번외: State-Binding 값은 어떻게 동작할까?&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문득 Environment 값이 아닌 &lt;b&gt;State-Binding으로 이어줘도 레거시 onChange는 '변경 전' 값을 캡처할까?&lt;/b&gt; 라는 호기심이 생겼습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1769652581890&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;struct TestView: View {

    @State private var frame: CGRect = .zero

    var body: some View {
        VStack {
            Button(&quot;frame 업데이트&quot;) {
                frame = .init(x: 100, y: 100, width: 100, height: 100)
            }
            .modifier(OnVisibilityViewModifier(containerFrame: $frame))
        }
    }
}

struct OnVisibilityViewModifier: ViewModifier {

    @Binding var containerFrame: CGRect

    func body(content: Content) -&amp;gt; some View {
        content
            .onAppear {
                print(&quot;onAppear 클로저에서 실행한 containerFrame: \(containerFrame)&quot;)
            }
            .onChange(of: containerFrame) { _ in
                print(&quot;onChange 클로저에서 전달 받은 containerFrame: \(containerFrame)&quot;)
                checkVisible()
            }
    }

    private func checkVisible() {
        print(&quot;checkVisible 함수에서 전달받은 containerFrame: \(containerFrame)&quot;)
    }
}&lt;/code&gt;&lt;/pre&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-01-29 오전 11.10.17.gif&quot; data-origin-width=&quot;1255&quot; data-origin-height=&quot;885&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ch0fvm/dJMcaiB7zqY/JD1sckU41N12WHcEOL99JK/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ch0fvm/dJMcaiB7zqY/JD1sckU41N12WHcEOL99JK/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ch0fvm/dJMcaiB7zqY/JD1sckU41N12WHcEOL99JK/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/ch0fvm/dJMcaiB7zqY/JD1sckU41N12WHcEOL99JK/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;700&quot; height=&quot;494&quot; data-filename=&quot;화면 기록 2026-01-29 오전 11.10.17.gif&quot; data-origin-width=&quot;1255&quot; data-origin-height=&quot;885&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아니!!! 변경 전 값을 캡처한다면서요??! &amp;lt;&amp;lt; 라고 생각할 수 있지만,,, 이것은 Environment와 State-Binding의 근본적인 동작 차이 때문일 것으로 추측하고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본적으로 State-Binding은 항상 원본 값에 대한 참조, 즉 Source of Truth 원칙을 지키기 때문에 onChange에서 캡처한 시점은 '변경 전'일지 몰라도, 실제로 가져온 것은 &lt;b&gt;'Source of Truth에 대한 메모리 주소 값'&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;반면 Environment는 &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;iOS 16 이하 버전 + Environment 값을 사용할 때는 항상 주의, 또 주의할 것!&lt;/b&gt;&lt;/p&gt;</description>
      <category>내게 필요한 개발 공부</category>
      <category>Binding</category>
      <category>deprecated</category>
      <category>Environment</category>
      <category>onChange</category>
      <category>state</category>
      <category>SwiftUI</category>
      <author>thinkyside</author>
      <guid isPermaLink="true">https://thinkyside.tistory.com/120</guid>
      <comments>https://thinkyside.tistory.com/120#entry120comment</comments>
      <pubDate>Thu, 29 Jan 2026 12:38:07 +0900</pubDate>
    </item>
    <item>
      <title>사이드 이펙트</title>
      <link>https://thinkyside.tistory.com/119</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Point-Free의 첫 번째 에피스드인 Function 편 이후 이어지는 &lt;a href=&quot;https://www.pointfree.co/episodes/ep2-side-effects&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Side Effects&lt;/a&gt; 편 스크립트를 기반으로 내용을 정리해봤습니다. 이번 에피소드도 감탄의 연속,,, 어떤 사고 과정을 통해 이런 방향성을 자신있게 제시할 수 있는지, 이를 근거 있게 전달할 수 있는 흐름 등의 내용이 무척 인상깊었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Introduction&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;지금까지 함수들이 어떻게 합성(compose)되는지 강조하며 에피소드를 진행했음.&lt;/li&gt;
&lt;li&gt;그러나 함수의 시그니처(선언부)만으로는 포착되지 않는 수많은 동작이 존재함 &amp;rarr; Side Effects&lt;/li&gt;
&lt;li&gt;Side Effects는 코드 복잡성의 주된 원인 중 하나임.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;테스트가 어려움.&lt;/li&gt;
&lt;li&gt;합성이 잘 되지 않음.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Side Effects의 이러한 복잡성은 함수 합성을 적극적으로 활용하는데 있어 걸림돌이 됨.&lt;/li&gt;
&lt;li&gt;이번 에피소드에서는 몇 가지 Side Effects의 유형을 다루고, 이것들이 왜 걸림돌이 되는지 보고, 문제를 깔끔히 해결해 볼 것임.&lt;/li&gt;
&lt;li&gt;먼저 Side Effects가 없는 함수를 살펴보겠음.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;swift&quot;&gt;&lt;code&gt;func compute(_ x: Int) -&amp;gt; Int {
  return x * x + 1
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;위 함수를 호출하면 결과가 반환됨.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;compute(2)  // 5&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Side Effects가 없는 함수의 가장 좋은 속성은, &lt;b&gt;동일한 입력으로 몇 번을 호출하든 동일한 출력&lt;/b&gt;을 얻는다는 것임.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;compute(2)  // 5
compute(2)  // 5
compute(2)  // 5&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이러한 예측 가능성 덕분에 테스트 작성 또한 매우 간단함.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;assertEqual(5, compute(2))  // ✅&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;만약 잘못된 예상값을 입력으로 넣으면 항상 실패할 것임.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;assertEqual(4, compute(2)) // ❌
assertEqual(5, compute(3)) // ❌&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이제 함수에 Side Effect를 넣어보겠음.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;swift&quot;&gt;&lt;code&gt;func computeWithEffect(_ x: Int) -&amp;gt; Int {
  let computation = x * x + 1
  print(&quot;Computed \(computation)&quot;)
  return computation
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;중간에 &lt;code&gt;print&lt;/code&gt; 문을 넣었음.&lt;/li&gt;
&lt;li&gt;이전과 동일한 입력으로 위 함수를 호출하면 동일한 출력을 얻음.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;computeWithEffect(2)  // 5&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;하지만 콘솔을 살펴보면 여기에 추가 출력이 발생한 것을 확인할 수 있음.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Computed 5&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;함수 시그니처를 비교하면 &lt;code&gt;computeWithEffect&lt;/code&gt; 는 &lt;code&gt;compute&lt;/code&gt; 함수와 정확히 동일하지만 &lt;b&gt;시그니처만으로는 설명할 수 없는 작업이 수행되고 있음.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;print 기능은 외부 어딘가로 나아가 어떤 &amp;lsquo;변화&amp;rsquo;를 일으키고 있고, 이 경우 콘솔에 출력하는 일임.&lt;/li&gt;
&lt;li&gt;Side Effects는 함수 본문 내부에 숨어있다는 것을 알아야 한다는 것임.&lt;/li&gt;
&lt;li&gt;이제 이 함수에 대해 테스트를 작성해보겠음.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;assertEqual(5, computeWithEffect(2))  // ✅&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이 테스트 코드는 통과하지만 또 새로운 줄이 콘솔에 출력됐을 것임.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Computed 5
Computed 5&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이 동작은 우리가 테스트할 수 없는 동작임.&lt;/li&gt;
&lt;li&gt;이 상황에서는 콘솔에 print 하는 것이 별 문제가 아닌 것처럼 보일 수 있음.&lt;/li&gt;
&lt;li&gt;하지만 디스크에 쓰기, API 요청, Analysis 트래킹과 같은 다른 Side Effects가 발생하게 되면 이야기가 달라짐.&lt;/li&gt;
&lt;li&gt;Side Effects는 우리의 구성적 직관성(compositional intuitions)을 깨트릴 수 있음.&lt;/li&gt;
&lt;li&gt;이전 에피소드(함수)에서 두 개의 함수가 포함된 배열에 대한 mapping이 어떻게 기존 방식과 동일할 수 있을지 논의했었음.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;[2, 10].map(compute).map(compute)  // [26, 10202] 두 개의 함수를 체이닝(기존)
[2, 10].map(compute &amp;gt;&amp;gt;&amp;gt; compute)   // [26, 10202] 두 개의 함수를 합성(새로운 방식)&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이제 이 방식처럼 &lt;code&gt;computeWithEffect&lt;/code&gt; 함수를 사용해보겠음.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;[2, 10].map(computeWithEffect).map(computeWithEffect) // [26, 10202]
[2, 10].map(computeWithEffect &amp;gt;&amp;gt;&amp;gt; computeWithEffect) // [26, 10202]&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;반환 값은 동일하지만 콘솔을 보면 출력이 동일하지 않는 것임!&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;Computed 5
Computed 101
Computed 26
Computed 10202
--
Computed 5
Computed 26
Computed 101
Computed 10202&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Side Effects를 고려하지 않고서는 더 이상 함수 합성을 활용하기 어려울 것임.&lt;/li&gt;
&lt;li&gt;이런 종류의 리팩토링은 실질적인 성능 최적화 수단임.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;배열을 2번 순회하는 대신 함수 합성으로 1번만 순회하게 하는 것.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;그러나 함수에 Side Effects가 있다면 실행 순서가 동일하게 유지되지 않음.&lt;/li&gt;
&lt;li&gt;결국 Side Effects가 있는 환경에서는 성능 최적화를 진행하는 것은 코드를 오동작하게 만들 위험이 있음.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Hidden Outputs&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이제 Side Effects를 제어할 수 있는 가장 간단한 방법을 알아볼 것임.&lt;/li&gt;
&lt;li&gt;함수 본문 안에서 Side Effects를 직접 수행하는 대신, 무엇을 출력해야하는지 설명하는 값을 추가로 반환하는 것임.&lt;/li&gt;
&lt;li&gt;함수는 여러 반환값을 가질 수 있으므로, 이를 표현하기 위해 &lt;code&gt;[String]&lt;/code&gt; 을 모델로 사용해보겠음.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;swift&quot;&gt;&lt;code&gt;func computeAndPrint(_ x: Int) -&amp;gt; (Int, [String]) {
  let computation = x * x + 1
  return (computation, [&quot;Computed \(computation)&quot;])
}

computeAndPrint(2)  // (5, [&quot;Computed 5&quot;])&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이제 계산 결과 뿐 아니라 print하려는 로그가 담긴 배열도 반환받게 됨.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;assertEqual(
 (5, [&quot;Computed 5&quot;]),
  computeAndPrint(2)
) // ✅&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이제 우리는 계산 결과 뿐 아니라 수행하려는 효과(Effect)에 대해서도 다룰 수 있게 됨!&lt;/li&gt;
&lt;li&gt;이제 Side Effects가 예상치 못한 포맷이라면 테스트가 실패함.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;assertEqual(
 (5, [&quot;Computed 5&quot;]),
  computeAndPrint(3)
) // ❌&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이 함수에서 데이터는 매우 간단하지만 실제 프로덕트에서는 API 요청이나 이벤트 분석과 같은 것들을 설명하는 중요한 데이터일 수 있음을 기억해야 함.&lt;/li&gt;
&lt;li&gt;이 방식을 활용하면 우리가 의도한 대로 Side Effects들이 준비되고 있는지 검증하는 테스트 코드를 작성할 수 있게 될 것임.&lt;/li&gt;
&lt;li&gt;이런 관점에서 보면 외부 세계에 변화를 주는 &lt;b&gt;Side Effects는 사실 함수의 감춰진(암묵적) 출력값이나 다름 없음.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;프로그래밍 세계에서 무언가 &amp;lsquo;암묵적&amp;rsquo;이라는 것은 대개 좋은 징조가 아님.&lt;/li&gt;
&lt;li&gt;이제 우리는 궁금해질 것임. &amp;rarr; 그럼 Side Effects는 누가 실행하는가?&lt;/li&gt;
&lt;li&gt;우리는 Effect를 명시적 반환 타입으로 끌어올림으로써, &lt;b&gt;Effect를 실행할 책임을 함수 내부가 아닌 함수를 호출하는 주체에게 넘긴 것임.&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;gauss&quot;&gt;&lt;code&gt;// 1. Effect를 명시적 반환 타입으로 받고
let (computation, logs) = computeAndPrint(2)

// 2. 함수를 호출하는 주체가 특정 Action을 취할 수 있게 함.
logs.forEach { print($0) }&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;우리는 함수를 호출하는 주체 역시도 Side Effects를 직접 처리하고 싶지 않을 수도 있음.&lt;/li&gt;
&lt;li&gt;그럼 그 함수를 호출한 또 다른 상위로 Side Effects를 넘기고, 넘기고, 넘길 것임.&lt;/li&gt;
&lt;li&gt;이에 대한 해결책을 알아보기 전에, 이 문제가 정확히 어떤 것인지 구체적으로 이해할 필요가 있음.&lt;/li&gt;
&lt;li&gt;Side Effects 문제를 드디어 해결한 것처럼 보일 수 있지만, 안타깝게도 이 과정에서 우리는 함수의 가장 중요한 특징 중 하나인 합성(Composition)을 망가뜨리고 말았음.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;compute&lt;/code&gt; 함수는 자체적으로 forward-compose를 하기 때문에 적절함.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;coq&quot;&gt;&lt;code&gt;compute &amp;gt;&amp;gt;&amp;gt; compute  // (Int) -&amp;gt; Int&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;computeAndPrint&lt;/code&gt; 함수 또한 자체적으로 forward-compose 하기 때문에 실제로 꽤 괜찮아보임.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;autoit&quot;&gt;&lt;code&gt;computeWithEffect &amp;gt;&amp;gt;&amp;gt; computeWithEffect  // (Int) -&amp;gt; Int&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;둘 모두 값을 파이프(&lt;code&gt;|&amp;gt;&lt;/code&gt;)로 연결해 결과를 얻을 수도 있을 것.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;basic&quot;&gt;&lt;code&gt;2 |&amp;gt; compute &amp;gt;&amp;gt;&amp;gt; compute  // 26
2 |&amp;gt; computeWithEffect &amp;gt;&amp;gt;&amp;gt; computeWithEffect  // 26&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;물론 콘솔에 print가 찍히겠지만 말임.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Computed 5
Computed 26&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;하지만 사실 &lt;code&gt;computeAndPrint&lt;/code&gt; 함수는 합성되지 않음.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;❌ Cannot convert value of type '(Int) -&amp;gt; (Int, [String])' to expected argument type '((Int, [String])) -&amp;gt; (Int, [String])'

computeAndPrint 함수의 출력은 (Int, [String])이라는 튜플 형태인 반면, 입력은 그저 Int일 뿐입니다.&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;앞으로 우리는 이런 상황을 반복해서 마주치게 될 것.&lt;/li&gt;
&lt;li&gt;Side Effects를 수행해야 하는 함수가 있을 때마다 그 Effect를 묘사하기 위해 반환 타입을 확장할 것이고, 그 때마다 함수 합성은 깨질 것.&lt;/li&gt;
&lt;li&gt;우리는 이런 형태의 함수들도 다시 잘 합성될 수 있도록 새로운 방식을 고민해야함.&lt;/li&gt;
&lt;li&gt;튜플을 반환하는 함수의 경우 이 문제를 우아하게 해결할 수 있음.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;func compose&amp;lt;A, B, C&amp;gt;(
  _ f: @escaping (A) -&amp;gt; (B, [String]),
_ g: @escaping (B) -&amp;gt; (C, [String])
  ) -&amp;gt; (A) -&amp;gt; (C, [String]) {
  &amp;hellip;
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이 구조는 이전에 만들었던 &lt;code&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/code&gt; 함수와 시그니처가 매우 비슷함.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;(A) -&amp;gt; B&lt;/code&gt;, &lt;code&gt;(B) -&amp;gt; C&lt;/code&gt;, 그리고 &lt;code&gt;(A) -&amp;gt; C&lt;/code&gt;라는 흐름은 같지만, 옆에 약간의 추가 정보(Side Effects)가 곁들여져 있을 뿐임.&lt;/li&gt;
&lt;li&gt;우리가 가진 함수들의 타입과 사용 가능한 값들이 무엇인지 하나씩 살펴보다 보면 이 함수를 어떻게 구현해야 할지 알 수 있음.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;func compose&amp;lt;A, B, C&amp;gt;(
  _ f: @escaping (A) -&amp;gt; (B, [String]),
  _ g: @escaping (B) -&amp;gt; (C, [String])
) -&amp;gt; (A) -&amp;gt; (C, [String]) {

  return { a in
    let (b, logs) = f(a)
    let (c, moreLogs) = g(b)
    return (c, logs + moreLogs)
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;우리가 새로운 함수를 반환해야 한다는 사실을 알고 있으니, 우선 함수를 정의하고 입력값 &lt;code&gt;a&lt;/code&gt;를 받아오는 것부터 시작해보겠음.&lt;/li&gt;
&lt;li&gt;우리에게는 &lt;code&gt;a&lt;/code&gt;를 인자로 받는 함수 &lt;code&gt;f&lt;/code&gt;가 있으니, &lt;code&gt;f&lt;/code&gt;에 &lt;code&gt;a&lt;/code&gt;를 전달한 뒤 그 결과값인 &lt;code&gt;b&lt;/code&gt;와 발생한 &lt;code&gt;logs&lt;/code&gt;(Side Effects)들을 추출.&lt;/li&gt;
&lt;li&gt;이제 함수 &lt;code&gt;g&lt;/code&gt;에 들어갈 &lt;code&gt;b&lt;/code&gt;가 생겼으니 &lt;code&gt;g&lt;/code&gt;에 &lt;code&gt;b&lt;/code&gt;를 넣으면 &lt;code&gt;c&lt;/code&gt;와 &lt;code&gt;logs&lt;/code&gt;(Side Effects)가 돌아올 것.&lt;/li&gt;
&lt;li&gt;이제 이를 &lt;code&gt;f&lt;/code&gt;에서 받은 &lt;code&gt;logs&lt;/code&gt;와 함께 반환하면 될 것임.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;compose(computeAndPrint, computeAndPrint)
// (Int) -&amp;gt; (Int, [String])&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이제 우리는 &lt;code&gt;computeAndPrint&lt;/code&gt; 함수를 두 번 호출하는 완전히 새로운 함수를 만들어냈음.&lt;/li&gt;
&lt;li&gt;여기에 데이터를 넣으면 최종 계산 결과 뿐 아니라 각각의 실행 단계마다 누적된 모든 log(Side Effects) 기록 까지 한 번에 확인할 수 있음.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;2 |&amp;gt; compose(computeAndPrint, computeAndPrint)
// (26, [&quot;Computed 5&quot;, &quot;Computed 26&quot;])&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Introducing &amp;gt;=&amp;gt;&lt;/b&gt;&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;합성 기능을 복구하고 문제를 완전히 해결한 것처럼 보이지만 2개 이상의 함수를 합성하면 꽤나 지저분해지기 시작함.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;2 |&amp;gt; compose(
  compose(computeAndPrint, computeAndPrint),
  computeAndPrint
)&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;더 나쁜 점은 동일한 합성 기능을 만드는데 2가지 다른 방법이 있다는 것.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;2 |&amp;gt; compose(
  compose(computeAndPrint, computeAndPrint),
  computeAndPrint
)
2 |&amp;gt; compose(
  computeAndPrint,
  compose(computeAndPrint, computeAndPrint)
)&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;괄호는 언제나 함수 합성의 적이었음.&lt;/li&gt;
&lt;li&gt;그렇다면 괄호의 천적은 무엇일까? &amp;rarr; 바로 중위 연산자(infix operator)임.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;precedencegroup EffectfulComposition {
  associativity: left
  higherThan: ForwardApplication
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이제 우리는 친숙해보이는 형태로 중위 연산자를 하나 정의해볼 수 있을 것임.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;dart&quot;&gt;&lt;code&gt;infix operator &amp;gt;=&amp;gt;: EffectfulComposition&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이 연산자는 &lt;code&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/code&gt; 와 매우 닮았지만, 가운데 화살표를 튜브 모양의 &lt;code&gt;=&lt;/code&gt;으로 바꿨음.&lt;/li&gt;
&lt;li&gt;이 연산자의 이름은 피쉬(Fish)로 불리기도 함.&lt;/li&gt;
&lt;li&gt;이제 이전에 만든 &lt;code&gt;compose&lt;/code&gt; 함수의 이름을 이 연산자로 바꿀 수 있음.&lt;/li&gt;
&lt;li&gt;이제 괄호를 어떻게 배치해야 할지 고민해야 하는 번거로움 없이도 Side Effects가 있는 함수들을 매끄럽게 이어붙일 수 있을 것.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;func &amp;gt;=&amp;gt; &amp;lt;A, B, C&amp;gt;(
  _ f: @escaping (A) -&amp;gt; (B, [String]),
  _ g: @escaping (B) -&amp;gt; (C, [String])
) -&amp;gt; (A) -&amp;gt; (C, [String]) {

  return { a in
    let (b, logs) = f(a)
    let (c, moreLogs) = g(b)
    return (c, logs + moreLogs)
  }
}

computeAndPrint &amp;gt;=&amp;gt; computeAndPrint &amp;gt;=&amp;gt; computeAndPrint
// (Int) -&amp;gt; (Int, [String])&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이제 값들을 여러 줄에 걸쳐 코드를 작성함으로써 물 흐르듯 자연스럽게 파이프라인을 만들 수 있음.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;coq&quot;&gt;&lt;code&gt;2
  |&amp;gt; computeAndPrint
  &amp;gt;=&amp;gt; computeAndPrint
  &amp;gt;=&amp;gt; computeAndPrint&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;함수 합성의 또다른 장점은 기존에 쓰던 연산자들과도 아주 훌륭하게 조화를 이룬다는 점.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;coq&quot;&gt;&lt;code&gt;2
  |&amp;gt; computeAndPrint
  &amp;gt;=&amp;gt; (incr &amp;gt;&amp;gt;&amp;gt; computeAndPrint)
  &amp;gt;=&amp;gt; (square &amp;gt;&amp;gt;&amp;gt; computeAndPrint)&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이제 우리는 합성을 통해 Side Effects가 있는 함수의 결과값을 &amp;rarr; Side Effects가 없는 순수 함수에 전달하며 자유롭게 활용할 수 있게 되었음.&lt;/li&gt;
&lt;li&gt;비록 새로운 괄호 문제가 생기긴 했지만 이 또한 해결이 가능함.&lt;/li&gt;
&lt;li&gt;괄호가 나타나는 위치와 그 사이의 입력 / 출력 타입을 살펴보면 &lt;b&gt;순수 함수의 합성이 언제나 더 높은 우선순위를 가져야 한다는 결론에 도달하게 됨.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;따라서 앞에서 만든 &lt;code&gt;EffectfulComposition&lt;/code&gt; 우선순위 그룹을 업데이트 할 필요가 있음.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;precedencegroup EffectfulComposition {
  associativity: left
  higherThan: ForwardApplication
  lowerThan: ForwardComposition
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이제 우리는 더 이상 괄호가 필요하지 않음!&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;shell&quot;&gt;&lt;code&gt;2
  |&amp;gt; computeAndPrint
  &amp;gt;=&amp;gt; incr
  &amp;gt;&amp;gt;&amp;gt; computeAndPrint
  &amp;gt;=&amp;gt; square
  &amp;gt;&amp;gt;&amp;gt; computeAndPrint&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이제 코드의 모든 줄은 그 의미를 명확히 해주는 연산자와 함께함.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/code&gt; 로 시작하는 줄은 Side Effects가 없는 순수 함수의 결과물을 다루고 있음을 알리고,&lt;/li&gt;
&lt;li&gt;&lt;code&gt;&amp;gt;=&amp;gt;&lt;/code&gt; 로 시작하는 줄은 Side Effects가 있는 연산 결과를 다루고 있음을 보여줌.&lt;/li&gt;
&lt;li&gt;새로운 연산자를 도입했으니 우리 코드에 이 연산자를 추가할 명분을 증명할 차례임.&lt;/li&gt;
&lt;/ul&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;이 연산자가 이미 Swift에 존재하는 혹은 의미를 가지고 있는가? &amp;rarr; X&lt;/li&gt;
&lt;li&gt;기존에 널리 쓰이던 선례가 있고 모양이 직관적인가? &amp;rarr; O / Haskell 이나 PureScript에서 기본으로 제공되는 연산자이며, 수많은 함수형 프로그래밍 커뮤니티에서 라이브러리로 채택하고 있음.&lt;/li&gt;
&lt;li&gt;범용적인 연산자인가? &amp;rarr; O / 현재는 튜플을 처리하도록 정의되어 있지만 이 연산자가 묘사하는 구조는 프로그래밍 전반에서 나타날 것.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;func &amp;gt;=&amp;gt; &amp;lt;A, B, C&amp;gt;(
  _ f: @escaping (A) -&amp;gt; B?,
  _ g: @escaping (B) -&amp;gt; C?
) -&amp;gt; ((A) -&amp;gt; C?) {

  return { a in
    fatalError()  // an exercise for the viewer
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이제 튜플 대신 옵셔널을 사용해보겠음.&lt;/li&gt;
&lt;li&gt;위 형태는 옵셔널을 반환하는 함수들을 합성할 때 유용한 연산자를 하나 얻을 수 있게 됨.&lt;/li&gt;
&lt;li&gt;이 연산자를 활용하면 실패할 가능성이 있는 initializer 들을 매끄럽게 하나로 연결할 수 있음.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;String.init(utf8String:) &amp;gt;=&amp;gt; URL.init(string:)
// (UnsafePointer&amp;lt;Int8&amp;gt;) -&amp;gt; URL?&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;덕분에 우리는 아무런 노력 없이도 실패 가능성이 있는 완전히 새로운 생성자를 얻게 되었음.&lt;/li&gt;
&lt;li&gt;이 연산자를 활용하면 배열을 반환하는 함수들 사이의 합성 능력도 한층 더 강화할 수 있음.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;func &amp;gt;=&amp;gt; &amp;lt;A, B, C&amp;gt;(
  _ f: @escaping (A) -&amp;gt; [B],
  _ g: @escaping (B) -&amp;gt; [C]
) -&amp;gt; ((A) -&amp;gt; [C]) {

  return { a in
    fatalError()  // an exercise for the viewer
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;만약 &lt;code&gt;Promise&lt;/code&gt;나 &lt;code&gt;Future&lt;/code&gt; 타입을 사용하고 있다면 이 연산자를 활용해 프로미스를 반환하는 함수들도 아주 쉽게 합성할 수 있음.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;func &amp;gt;=&amp;gt; &amp;lt;A, B, C&amp;gt;(
  _ f: @escaping (A) -&amp;gt; Promise&amp;lt;B&amp;gt;,
  _ g: @escaping (B) -&amp;gt; Promise&amp;lt;C&amp;gt;
) -&amp;gt; ((A) -&amp;gt; Promise&amp;lt;C&amp;gt;) {

  return { a in
    fatalError()  // an exercise for the viewer
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;매우 강력한 타입 시스템을 가진 몇몇 언언에서는 이 연산자를 단 한번만 정의하면 모든 구현체를 즉시 얻을 수 있기도 함. &amp;rarr; 아직 Swift에는 그런 기능이 없어서 새로운 타입이 생길 때 마다 직접 정의해 주어야 함.&lt;/li&gt;
&lt;li&gt;하지만 덕분에 우리는 이러한 구조에 대한 직관을 기를 수 있고, 수많은 타입에 걸쳐 공유할 수 있게 되었음.&lt;/li&gt;
&lt;li&gt;이제 &lt;code&gt;&amp;gt;=&amp;gt;&lt;/code&gt; 연산자를 볼 때마다 우리는 이것이 어떤 종류의 Side Effects 속으로 로직을 연쇄적으로 이어주고 있다는 것을 바로 알 수 있을 것임.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Hidden Inputs&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;지금까지 우리는 암묵적 출력을 만들어내는 Side Effects를 살펴보고, 그 출력을 함수의 명시적 출력으로 바꿈으로써 합성 가능성을 유지한 채 Side Effects를 제어하는 방법을 알아봤음.&lt;/li&gt;
&lt;li&gt;그런데 이것보다 조금 더 까다로운 또 다른 종류의 Side Effects가 있음.&lt;/li&gt;
&lt;li&gt;사용자를 위한 인사말을 생성하는 아주 간단한 함수를 예시로 들어보겠음.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;func greetWithEffect(_ name: String) -&amp;gt; String {
  let seconds = Int(Date().timeIntervalSince1970) % 60
  return &quot;Hello \(name)! It's \(seconds) seconds past the minute.&quot;
}

greetWithEffect(&quot;Blob&quot;)
// &quot;Hello Blob! It's 14 seconds past the minute.&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이 코드를 다시 실행하면 이전과는 다른 결과값이 나올 가능성이 높음.&lt;/li&gt;
&lt;li&gt;이는 우리가 앞서 보았던 &lt;code&gt;compute&lt;/code&gt; 함수가 가졌던 &amp;lsquo;예측 가능성&amp;rsquo;과는 정반대 되는 상황임.&lt;/li&gt;
&lt;li&gt;이런 상태에서 테스트 코드를 작성한다면 그 테스트는 거의 매번 실패하고 말 것임.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;assertEqual(
  &quot;Hello Blob! It's 32 seconds past the minute.&quot;,
  greetWithEffect(&quot;Blob&quot;)
) // ❌&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이것은 특히나 골치아픈 Side Effects임.&lt;/li&gt;
&lt;li&gt;이전 사례(암묵적 출력)에서는 적어도 출력값에 대해 테스트라도 할 수 있었지만, 이번에는 출력값이 계속 변하기 때문에 테스트 코드를 작성하는 것조차 불가능함.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;print&lt;/code&gt;는 입력은 받지만 반환값이 없는 함수였음. 반면 이번 사례인 &lt;code&gt;Date&lt;/code&gt;는 입력은 없지만 반환값이 있는 함수임.&lt;/li&gt;
&lt;li&gt;이번에도 비슷한 해결책을 적용해 볼 것임.&lt;/li&gt;
&lt;li&gt;앞서 &lt;code&gt;print&lt;/code&gt;의 Effect를 &lt;code&gt;compute&lt;/code&gt; 함수의 반환값으로 명시했던 것처럼, &lt;code&gt;D**ate&lt;/code&gt;의 Effect를 함수의 인자로 명시해보는 것임.**&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;lasso&quot;&gt;&lt;code&gt;func greet(at date: Date, name: String) -&amp;gt; String {
  let seconds = Int(date.timeIntervalSince1970) % 60
  return &quot;Hello \(name)! It's \(seconds) seconds past the minute.&quot;
}

greet(at: Date(), name: &quot;Blob&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이 함수는 이전과 동일하게 동작하지만 한 가지 결정적인 차이점이 있음.&lt;/li&gt;
&lt;li&gt;이제 우리가 &lt;code&gt;Date&lt;/code&gt;를 완벽하게 통제할 수 있게 되었고, 덕분에 언제 실행하더라도 항상 통과하는 테스트 코드를 작성할 수 있게됨.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;assertEqual(
  &quot;Hello Blob! It's 39 seconds past the minute.&quot;,
  greetWithEffect(at: Date(timeIntervalSince1970: 39), name: &quot;Blob&quot;)
) // ✅&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;약간의 보일러플레이트를 감수하고 테스트 가능성을 되찾아 왔음.&lt;/li&gt;
&lt;li&gt;하지만 이제 함수를 호출할 때마다 &lt;code&gt;Date&lt;/code&gt;를 명시적으로 넘겨줘야 하는데, 테스트 상황이 아닐 때는 이게 불필요하게 느껴질 수도 있음.&lt;/li&gt;
&lt;li&gt;그래서 우리는 구현 세부 사항을 감추고, 호출부의 코드를 깔끔하게 유지하기 위해 기본 인자를 지정해 현재 날짜라는 의존성을 함수에 주입하고 싶은 유혹을 느낄지도 모름.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;lasso&quot;&gt;&lt;code&gt;func greet(at date: Date = Date(), name: String) -&amp;gt; String {
  let s = Int(date.timeIntervalSince1970) % 60
  return &quot;Hello \(name)! It's \(s) seconds past the minute.&quot;
}

greet(name: &quot;Blob&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;코드가 조금 더 깔끔해지긴 했지만 더 큰 문제가 생겼음. &amp;rarr; 또 다시 함수 합성이 깨져버린 것.&lt;/li&gt;
&lt;li&gt;이전의 &lt;code&gt;greetWithEffect&lt;/code&gt; 함수는 &lt;code&gt;(String) &amp;rarr; String&lt;/code&gt; 이라는 깔끔한 모양 덕분에 문자열을 반환하거나 문자열을 입력으로 받는 다른 함수들과 자유롭게 결합할 수 있었음.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;go&quot;&gt;&lt;code&gt;func uppercased(_ string: String) -&amp;gt; String {
  return string.uppercased()
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;위 함수는 &lt;code&gt;greetWithEffect&lt;/code&gt; 함수와 멋지게 합성될 수 있음.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;uppercased &amp;gt;&amp;gt;&amp;gt; greetWithEffect
greetWithEffect &amp;gt;&amp;gt;&amp;gt; uppercased&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;또한 파이프로 연결해 각 합성에 대해 서로 다른 동작을 얻을 수도 있음.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;&quot;Blob&quot; |&amp;gt; uppercased &amp;gt;&amp;gt;&amp;gt; greetWithEffect
// &quot;Hello BLOB! It's 56 seconds past the minute.&quot;
&quot;Blob&quot; |&amp;gt; greetWithEffect &amp;gt;&amp;gt;&amp;gt; uppercased
// &quot;HELLO BLOB! IT'S 56 SECONDS PAST THE MINUTE.&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;그러나 &lt;code&gt;greet&lt;/code&gt; 함수의 경우 합성이 불가능함.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;elm&quot;&gt;&lt;code&gt;&quot;Blob&quot; |&amp;gt; uppercased &amp;gt;&amp;gt;&amp;gt; greet
&quot;Blob&quot; |&amp;gt; greet &amp;gt;&amp;gt;&amp;gt; uppercased
// Cannot convert value of type '(Date, String) -&amp;gt; String' to expected argument type '(_) -&amp;gt; _'&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;함수가 2개의 입력을 받게 되면서 다른 함수의 출력값을 이 함수로 직접 연결할 방법이 사라졌음.&lt;/li&gt;
&lt;li&gt;하지만 &lt;code&gt;Date&lt;/code&gt;의 입력을 잠시 제쳐두고 보면, 여전히 그 내부에는 &lt;code&gt;(String) &amp;rarr; String&lt;/code&gt; 이라는 모양이 숨어있음을 알 수 있음.&lt;/li&gt;
&lt;li&gt;사실 시그니처에서 &lt;code&gt;Date&lt;/code&gt;를 분리해낼 수 있는 묘수가 있는데, &lt;code&gt;greet&lt;/code&gt; 함수가 &lt;code&gt;Date&lt;/code&gt;를 먼저 입력받되, 그 결과로 실제 인사말 로직을 처리하는 새로운 &lt;code&gt;(String) &amp;rarr; String&lt;/code&gt; 함수를 반환하도록 구조를 바꾸는 것.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;func greet(at date: Date) -&amp;gt; (String) -&amp;gt; String {
  return { name in
    let s = Int(date.timeIntervalSince1970) % 60
    return &quot;Hello \(name)! It's \(s) seconds past the minute.&quot;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이제 &lt;code&gt;Date&lt;/code&gt;와 함께 &lt;code&gt;greeting&lt;/code&gt; 함수를 호출해 완전히 새로운 &lt;code&gt;(String) &amp;rarr; String&lt;/code&gt; 함수를 얻을 수 있음.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;greet(at: Date())  // (String) -&amp;gt; String&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이 함수는 합성이 가능함!&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;uppercased &amp;gt;&amp;gt;&amp;gt; greet(at: Date())  // (String) -&amp;gt; String
greet(at: Date()) &amp;gt;&amp;gt;&amp;gt; uppercased  // (String) -&amp;gt; String&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이제 파이프를 이용해 값을 얻을 수도 있음!&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;&quot;Blob&quot; |&amp;gt; uppercased &amp;gt;&amp;gt;&amp;gt; greet(at: Date())
// &quot;Hello BLOB! It's 37 seconds past the minute.&quot;
&quot;Blob&quot; |&amp;gt; greet(at: Date()) &amp;gt;&amp;gt;&amp;gt; uppercased
// &quot;HELLO BLOB! IT'S 37 SECONDS PAST THE MINUTE.&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;우리는 합성과 테스트 가능성을 모두 복구할 수 있게 되었음.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;assertEqual(
  &quot;Hello Blob! It's 37 seconds past the minute.&quot;,
  &quot;Blob&quot; |&amp;gt; greet(at: Date(timeIntervalSince1970: 37))
) // ✅&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;드디어 우리는 테스트가 불가능했던 그 context를 함수의 입력값으로 옮김으로써 Side Effects를 제어하는데 성공했음.&lt;/li&gt;
&lt;li&gt;이것은 우리가 앞서 보았던 Side Effects 사례와 정확히 대칭을 이루는 방식임.&lt;/li&gt;
&lt;li&gt;이전 Side Effects는 외부 세계에 영향을 주어 변화를 일으키는 &amp;lsquo;암묵적 출력&amp;rsquo;&lt;/li&gt;
&lt;li&gt;이번 Side Effects는 외부 세계의 상태에 의존하는 &amp;lsquo;암묵적 입력&amp;rsquo;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;모든 Side Effects는 결국 이 2가지 형태 중 하나로 나타나게 됨.&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Mutation&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이제 상태 변경(Mutation)이라는 아주 특수한 형태의 Side Effects를 자세히 분석해보겠음.&lt;/li&gt;
&lt;li&gt;우리 모두 코드를 작성하며 상태 변경을 다뤄왔지만, 이는 종종 코드를 매우 복잡하게 만듦.&lt;/li&gt;
&lt;li&gt;다행히 Swift는 상태 변경이 언제, 어디서 발생하는지 제어하고 명확히 기록할 수 있도록 돕는 타입 수준의 기능들을 제공함.&lt;/li&gt;
&lt;li&gt;상태 변경이 얼마나 지저분해질 수 있는지 보여주는 예시를 가져왔음.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;swift&quot;&gt;&lt;code&gt;let formatter = NumberFormatter()

func decimalStyle(_ format: NumberFormatter) {
  format.numberStyle = .decimal
  format.maximumFractionDigits = 2
}

func currencyStyle(_ format: NumberFormatter) {
  format.numberStyle = .currency
  format.roundingMode = .down
}

func wholeStyle(_ format: NumberFormatter) {
  format.maximumFractionDigits = 0
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;여기 &lt;code&gt;Foundation&lt;/code&gt; 프레임워크에서 제공하는 &lt;code&gt;NumberFormatter&lt;/code&gt;와 이 Formatter에 특정 스타일을 입혀주는 여러 함수가 있음.&lt;/li&gt;
&lt;li&gt;이 스타일링 함수들을 사용하려면 우리가 만든 Formatter 인스턴스에 직접 적용하기만 하면 됨.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;decimalStyle(formatter)
wholeStyle(formatter)
formatter.string(for: 1234.6)  // &quot;1,235&quot;

currencyStyle(formatter)
formatter.string(for: 1234.6)  // &quot;$1,234&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;그런데 여기서 아까 사용했던 첫 번째 Formatter 설정들을 다시 적용하려고 하면 문제가 생김. &amp;rarr; 이전 함수들이 남긴 설정값이 객체에 그대로 남아 있기 때문&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;decimalStyle(formatter)
wholeStyle(formatter)
formatter.string(for: 1234.6)  // &quot;1,234&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;출력값이 &lt;code&gt;&amp;ldquo;1,235&amp;rdquo;&lt;/code&gt; 에서 &lt;code&gt;&amp;ldquo;1,234&amp;rdquo;&lt;/code&gt; 로 바뀌어 버렸음. &amp;rarr; 바로 상태 변경 때문.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;currencyStyle&lt;/code&gt; 함수가 저지른 변경 사항이 Formatter의 다른 용도에까지 스며들게 된 것.&lt;/li&gt;
&lt;li&gt;이런 버그는 규모가 큰 프로젝트에서 정말 추적하기 힘든 골칫덩이가 됨.&lt;/li&gt;
&lt;li&gt;이것이 바로 상태 변경이 까다로운 이유.&lt;/li&gt;
&lt;li&gt;이전의 모든 코드가 무슨 짓을 했는지 일일히 추적해 보지 않고서는 &lt;b&gt;지금 내 눈앞의 코드 한 줄이 정확히 어떤 동작을 할지 알 방법이 없기 때문.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;사실 상태 변경은 우리가 앞서 만났던 2가지 Side Effects가 합쳐진 형태임.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;함수 사이에 주고 받는 &amp;lsquo;변경 가능한 데이터&amp;rsquo;는 그 자체로 암묵적 입력이자, 동시에 암묵적 출력이 되는 것!&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;우리가 이런 문제를 겪는 이유는 &lt;code&gt;NumberFormatter&lt;/code&gt;가 참조(Reference) 타입이기 때문.&lt;/li&gt;
&lt;li&gt;Swift에서 class는 참조 타입임.&lt;/li&gt;
&lt;li&gt;참조 타입의 인스턴스는 단 하나의 객체이고, 이 객체의 상태가 바뀌면 참조를 가진 코드 베이스의 모든 곳이 영향을 받게 됨.&lt;/li&gt;
&lt;li&gt;어떤 코드들이 동일한 참조를 공유하고 있는지 추적할 쉬운 방법이 없기에, 상태 변경이 개입되는 순간 혼란이 가중되는 것.&lt;/li&gt;
&lt;li&gt;만약 이 예제 코드가 실제 앱에 쓰였고 새로운 기능을 개발하면서 이 &lt;code&gt;Formatter&lt;/code&gt;에 의존했다면 완전히 엉뚱한 곳에서 미묘한 버그들이 기어 나오기 시작했을 것.&lt;/li&gt;
&lt;li&gt;이제 이 코드를 값(Value) 타입을 사용하는 방식으로 리팩토링 해볼 것.&lt;/li&gt;
&lt;li&gt;먼저 &lt;code&gt;NumberFormatter&lt;/code&gt;에서 수행하던 설정 작업들을 감싸줄 수 있는 struct를 만드는 것부터 시작해보겠음.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;struct NumberFormatterConfig {
  var numberStyle: NumberFormatter.Style = .none
  var roundingMode: NumberFormatter.RoundingMode = .up
  var maximumFractionDigits: Int = 0

  var formatter: NumberFormatter {
    let result = NumberFormatter()
    result.numberStyle = self.numberStyle
    result.roundingMode = self.roundingMode
    result.maximumFractionDigits = self.maximumFractionDigits
    return result
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이 struct는 적절한 기본값을 가지고 있으며, 새롭고 &amp;lsquo;정직한&amp;rsquo;(honest) &lt;code&gt;NumberFormatter&lt;/code&gt;를 뽑아낼 수 있는 계산 속성인 &lt;code&gt;formatter&lt;/code&gt;를 제공함.&lt;/li&gt;
&lt;li&gt;이제 기존의 스타일링 함수들을 &lt;code&gt;NumberFormatterConfig&lt;/code&gt; 를 사용해 업데이트하면 어떤 모습이 될까?&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;stata&quot;&gt;&lt;code&gt;func decimalStyle(
  _ format: NumberFormatterConfig
) -&amp;gt; NumberFormatterConfig {
  var format = format
  format.numberStyle = .decimal
  format.maximumFractionDigits = 2
  return format
}

func currencyStyle(
  _ format: NumberFormatterConfig
) -&amp;gt; NumberFormatterConfig {
  var format = format
  format.numberStyle = .currency
  format.roundingMode = .down
  return format
}

func wholeStyle(
  _ format: NumberFormatterConfig
) -&amp;gt; NumberFormatterConfig {
  var format = format
  format.maximumFractionDigits = 0
  return format
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;각 스타일링 함수는 &lt;code&gt;NumberFormatterConfig&lt;/code&gt; 를 인자로 받은 뒤 var 키워드를 사용해 이를 복사함.&lt;/li&gt;
&lt;li&gt;그리고 지역 복사본(local copy)의 값을 변경한 뒤 호출자에게 다시 반환하는 방식임.&lt;/li&gt;
&lt;li&gt;이 방식을 실제로 사용하는 모습은 이전과 조금 달라진 것.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;let config = NumberFormatterConfig()

wholeStyle(decimalStyle(config))
  .formatter
  .string(for: 1234.6)
// &quot;1,235&quot;

currencyStyle(config)
  .formatter
  .string(for: 1234.6)
// &quot;$1,234&quot;

wholeStyle(decimalStyle(config))
  .formatter
  .string(for: 1234.6)
// &quot;1,235&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이번에는 &lt;code&gt;config&lt;/code&gt;를 스타일링 함수에 전달할 때마다 새로운 복사본을 받게 되어 아까 버그를 고칠 수 있게 될 것임.&lt;/li&gt;
&lt;li&gt;사실 참조 타입에서도 이와 비슷하게 처리할 방법이 있긴함. &amp;rarr; &lt;code&gt;NSCopying&lt;/code&gt; 프로토콜을 구현한 class들에서 &lt;code&gt;copy&lt;/code&gt; 메서드를 호출해 복사본을 만들고 이를 명시적으로 반환해주는 것.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;func decimalStyle(_ format: NumberFormatter) -&amp;gt; NumberFormatter {
  let format = format.copy() as! NumberFormatter
  format.numberStyle = .decimal
  format.maximumFractionDigits = 2
  return format
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;하지만 안타깝게도 참조 타입을 복사하는 위 방식은, 원본 &lt;code&gt;Formatter&lt;/code&gt;를 변경하지 않았다는 것을 컴파일러가 보장해 주지 않음.&lt;/li&gt;
&lt;li&gt;또한 호출자가 복사본을 받았다고 생각하고 마음껏 상태를 변경하기 시작하면 복잡성은 더 커질 수도 있을 것임.&lt;/li&gt;
&lt;li&gt;하지만 참조 타입은 매번 복사할 필요가 없기 때문에 분명 성능적 이점이 있음.&lt;/li&gt;
&lt;li&gt;다행히도 Swift는 값 타입을 사용하면서도 성능을 챙길 수 있게 방법을 만들어뒀음 &amp;rarr; &lt;code&gt;inout&lt;/code&gt; 키워드임.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;func inoutDecimalStyle(_ format: inout NumberFormatterConfig) {
  format.numberStyle = .decimal
  format.maximumFractionDigits = 2
}

func inoutCurrencyStyle(_ format: inout NumberFormatterConfig) {
  format.numberStyle = .currency
  format.roundingMode = .down
}

func inoutWholeStyle(_ format: inout NumberFormatterConfig) {
  format.maximumFractionDigits = 0
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이 방식은 &lt;code&gt;NumberFormatter&lt;/code&gt;의 상태를 직접 변경하던 원래 방식과 매우 비슷해보임.&lt;/li&gt;
&lt;li&gt;값을 일일이 복사하거나 다시 반환할 걱정 없이 그 자리에서 즉시 상태를 바꿀 수 있음.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;let config = NumberFormatterConfig()

inoutDecimalStyle(config)
inoutWholeStyle(config)
config.formatter.string(from: 1234.6)&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;하지만 컴파일 에러가 발생했음!&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Cannot pass immutable value as inout argument: 'config' is a 'let' constant&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Swift가 var로 바꾸라고 제안해주는 것.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;var config = NumberFormatterConfig()

inoutDecimalStyle(config)
inoutWholeStyle(config)
config.formatter.string(from: 1234.6)&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;하지만 충분하지 않음. 또 컴파일 에러가 발생함.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Passing value of type 'NumberFormatterConfig' to an inout parameter requires explicit '&amp;amp;'&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Swift가 데이터를 변경하는 것에 동의하는 것에 대한 표시를 하도록 하는 것.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;inoutDecimalStyle(&amp;amp;config)
inoutWholeStyle(&amp;amp;config)
config.formatter.string(from: 1234.6)  // &quot;1,235&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;다른 스타일링 함수에도 이와 같이 적용할 수 있음.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;inoutCurrencyStyle(&amp;amp;config)
config.formatter.string(from: 1234.6)  // &quot;$1,234&quot;

inoutDecimalStyle(&amp;amp;config)
inoutWholeStyle(&amp;amp;config)
config.formatter.string(from: 1234.6)  // &quot;1,234&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;다시 버그가 나타났음. &amp;rarr; 그러나 이번에는 상황이 좀 다름! &amp;rarr; 지금 우리 코드에는 &lt;b&gt;상태 변경에 대한 수많은 문법적 장치들이 깔려 있기 때문.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;덕분에 이런 종류의 버그를 추적하기 훨씬 쉬워졌을 것.&lt;/li&gt;
&lt;li&gt;상태 변경이 일어나는 위치와 그 영향 범위를 제어할 수 있도록 Swift가 타입 수준의 기능을 제공한다는 점은 정말 훌륭함.&lt;/li&gt;
&lt;li&gt;하지만 이 기능을 제대로 활용하려면 여전히 해결해야 하는 문제가 있음. &amp;rarr; 이전에 우리가 사용했던 매번 새로운 복사본을 반환하던 스타일링 함수들은 아주 깔끔한 형태를 가지고 있었음.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;(NumberFormatterConfig) -&amp;gt; NumberFormatterConfig&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;위 함수들은 입력과 출력의 타입이 서로 같음.&lt;/li&gt;
&lt;li&gt;즉, 이들끼리 자유롭게 합쳐질 수 있을 뿐 아니라 &lt;code&gt;NumberFormatterConfig&lt;/code&gt;를 반환하거나 입력으로 받는 그 어떤 함수들과도 완벽하게 결합될 수 있다는 뜻!&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;decimalStyle &amp;gt;&amp;gt;&amp;gt; currencyStyle
// (NumberFormatterConfig) -&amp;gt; NumberFormatterConfig&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이제 우리는 작은 조각들을 합쳐서 만든 완전히 새로운 스타일링 함수를 갖게 되었음.&lt;/li&gt;
&lt;li&gt;반면 아까 살펴본 &lt;code&gt;inout&lt;/code&gt; 함수들은 이런 모양을 갖추고 있지 않음.&lt;/li&gt;
&lt;li&gt;입력과 출력의 타입이 맞지 않아서 일반적인 함수들과는 합성이 되지 않음.&lt;/li&gt;
&lt;li&gt;하지만 두 방식 모두 담고 있는 로직은 동일하기 때문에 분명 연결할 방법이 있을 것.&lt;/li&gt;
&lt;li&gt;입력과 출력 타입이 같은 일반 함수를 &lt;code&gt;inout&lt;/code&gt; 함수로 변환해주는 &lt;code&gt;toInout&lt;/code&gt; 이라는 함수를 정의해 볼 것.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;func toInout&amp;lt;A&amp;gt;(
  _ f: @escaping (A) -&amp;gt; A
) -&amp;gt; ((inout A) -&amp;gt; Void) {

  return { a in
    a = f(a)
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;또한 역변환을 수행하는 &lt;code&gt;fromInout&lt;/code&gt; 함수를 정의할 수 있음.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;func fromInout&amp;lt;A&amp;gt;(
  _ f: @escaping (inout A) -&amp;gt; Void
) -&amp;gt; ((A) -&amp;gt; A) {

  return { a in
    var copy = a
    f(&amp;amp;copy)
    return copy
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;여기서 우리가 알 수 있는 점은 &lt;code&gt;(A) &amp;rarr; A&lt;/code&gt; 형태의 함수와 &lt;code&gt;(inout A) &amp;rarr; Void&lt;/code&gt; 형태의 함수 사이에는 아주 자연스러운 대응 관계가 존재한다는 점임.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;(A) &amp;rarr; A&lt;/code&gt; 함수들은 매우 우아하게 합성되는 특징이 있는데, 이런 대응 관계를 잘 활용한다면 &lt;code&gt;(inout A) &amp;rarr; Void&lt;/code&gt; 함수들 역시 그 강력한 합성 능력을 공유할 수 있을 것임.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Introducing &amp;lt;&amp;gt;&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;비록 &lt;code&gt;(A) &amp;rarr; A&lt;/code&gt; 형태의 함수들이 &lt;code&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/code&gt; 연산자로 합성되는 것을 확인했지만, 이 연산자를 그대로 재사용하지는 않을 것.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/code&gt;는 허용하는 범위가 너무 넓기 때문.&lt;/li&gt;
&lt;li&gt;우리는 그보다 훨씬 더 제약이 명확한, 즉 &amp;lsquo;단일 타입&amp;rsquo;(Single Type) 간의 합성에 집중하려고 함.&lt;/li&gt;
&lt;li&gt;자 그럼 새로운 연산자를 정의해보겠음.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;precedencegroup SingleTypeComposition {
  associativity: left
  higherThan: ForwardApplication
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;infix operator &amp;lt;&amp;gt;: SingleTypeComposition&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이 연산자에 붙여진 재밌는 이름은 바로 &amp;lsquo;다이아몬드&amp;rsquo; 연산자임.&lt;/li&gt;
&lt;li&gt;이 연산자를 &lt;code&gt;(A) &amp;rarr; A&lt;/code&gt; 함수들에 대해 정의하는 방법은 꽤나 간단함.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;func &amp;lt;&amp;gt; &amp;lt;A&amp;gt;(
  f: @escaping (A) -&amp;gt; A,
  g: @escaping (A) -&amp;gt; A
) -&amp;gt; ((A) -&amp;gt; A) {

  return f &amp;gt;&amp;gt;&amp;gt; g
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;기존 연산자를 또 다른 연산자로 한 번 더 감싸는 것이 이상해보일 수 있음.&lt;/li&gt;
&lt;li&gt;하지만 우리는 이를 통해 연산자의 사용 범위를 제한하고 명확한 의미를 부여했음.&lt;/li&gt;
&lt;li&gt;즉, 이 연산자를 보는 순간 &amp;ldquo;아, 지금은 단일 타입을 다루고 있구나&amp;rdquo;라고 바로 알 수 있게 된 것!&lt;/li&gt;
&lt;li&gt;이제 &lt;code&gt;inout&lt;/code&gt; 함수들을 위한 &lt;code&gt;&amp;lt;&amp;gt;&lt;/code&gt; 연산자도 정의해보겠음.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;func &amp;lt;&amp;gt; &amp;lt;A&amp;gt;(
  f: @escaping (inout A) -&amp;gt; Void,
  g: @escaping (inout A) -&amp;gt; Void
) -&amp;gt; ((inout A) -&amp;gt; Void) {

  return { a in
    f(&amp;amp;a)
    g(&amp;amp;a)
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이제 이전에 작성했던 함수들이 잘 합성됨!&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;decimalStyle &amp;lt;&amp;gt; currencyStyle&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;더 좋은 방법은 &lt;code&gt;inout&lt;/code&gt; 함수를 직접 합성하는 것.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;inoutDecimalStyle &amp;lt;&amp;gt; inoutCurrencyStyle&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;값을 파이프해 함수 합성을 하면 어떤 일이 일어날까요?&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;config |&amp;gt; decimalStyle &amp;lt;&amp;gt; currencyStyle
config |&amp;gt; inoutDecimalStyle &amp;lt;&amp;gt; inoutCurrencyStyle&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;우리의 버전에서는 에러가 발생할 것입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Cannot convert value of type '(inout Int) -&amp;gt; ()' to expected argument type '(_) -&amp;gt; _'&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이건 우리가 만든 &lt;code&gt;|&amp;gt;&lt;/code&gt; 연산자가 아직 &lt;code&gt;inout&lt;/code&gt; 세계에서는 동작하지 않기 때문.&lt;/li&gt;
&lt;li&gt;하지만 우리는 함수 오버로딩을 통해 이를 추가로 정의할 수 있음.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;func |&amp;gt; &amp;lt;A&amp;gt;(a: inout A, f: (inout A) -&amp;gt; Void) -&amp;gt; Void {
  f(&amp;amp;a)
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이젠 우리는 드디어 자유롭게 값을 파이프할 수 있게 되었음!&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;config |&amp;gt; inoutDecimalStyle &amp;lt;&amp;gt; inoutCurrencyStyle&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;정말 멋진 결과임! Swift의 훌륭한 기능들을 활용하면서도 함수형 프로그래밍의 핵심인 합성 가능성(Composability)을 전혀 희생할 필요가 없었음.&lt;/li&gt;
&lt;li&gt;물론 또 하나의 연산자를 추가하는 비용을 치렀으니, 다시 한번 자격을 체크할 차례임.&lt;/li&gt;
&lt;/ul&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;이 연산자가 Swift에 이미 존재하는가? &amp;rarr; X / 따라서 기존 기능과 혼동될 여지가 전혀 없음.&lt;/li&gt;
&lt;li&gt;참고할 만한 사례가 있는가? &amp;rarr; O / Haskell, PureScript 등 함수형 커뮤니티가 탄탄한 언어들에서 이미 널리 채택되어 사용 중임. 양방향을 가리키는 이 기호 모양은 무언가를 하나로 &amp;lsquo;결합&amp;rsquo;한다는 의미를 아주 잘 전달함.&lt;/li&gt;
&lt;li&gt;이것은 범용적인 연산자인가? &amp;rarr; O / 지금까지는 &lt;code&gt;(A) &amp;rarr; A&lt;/code&gt; 와 &lt;code&gt;(inout A) &amp;rarr; Void&lt;/code&gt; 함수를 위해서만 정의했지만 사실 &lt;code&gt;&amp;lt;&amp;gt;&lt;/code&gt;는 같은 타입의 두 요소를 하나로 합치는 모든 상황에서 훨씬 범용적으로 사용됨. &amp;rarr; 이것은 계산의 가장 근본적인 단위라고도 할 수 있음.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;What&amp;rsquo;s the point?&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이제 스스로에게 물어볼 시간임. &amp;rarr; 이 모든 것의 목적이 무엇인가?&lt;/li&gt;
&lt;li&gt;우리는 코드에 복잡성을 더하고 테스트를 어렵게 만드는 수많은 Side Effects를 마주했음.&lt;/li&gt;
&lt;li&gt;이를 해결하기 위해 입력과 출력이라는 타입 시스템 안에 Side Effects를 명시하는 약간의 사전 작업을 감수했음.&lt;/li&gt;
&lt;li&gt;하지만 그 과정에서 함수 합성이 깨졌고, 이를 복구하기 위한 Side Effects 전용 합성 연산자들을 도입해야 했음.&lt;/li&gt;
&lt;li&gt;과연 그럴 가치가 있었는가? &amp;rarr; Point-Free의 대답은 당연히 그렇다!! 임.&lt;/li&gt;
&lt;li&gt;격리된 상태에서는 테스트도 불가능하고 추론하기도 힘들었던 Side Effects 덩어리의 코드들을 &lt;b&gt;명시적으로 드러나게 끌어올렸기 때문.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;이제 우리는 이전 줄의 코드가 무슨 일을 했는지 일일히 파악하지 않고도 단 한 줄의 코드만으로 그 동작을 완벽히 이해하고 테스트할 수 있음.&lt;/li&gt;
&lt;li&gt;이 모든 것은 &amp;lsquo;함수 합성&amp;rsquo;이라는 강력한 무기를 잃지 않고 해낸 것. &amp;rarr; 이것은 정말 강력한 힘임!&lt;/li&gt;
&lt;li&gt;비록 초기에 약간의 수고가 더 들었지만 이는 복잡하게 얽힌 상태 변경을 디버깅하거나,&lt;/li&gt;
&lt;li&gt;코드 여기저기에 뿌려진 Side Effects 때문에 발생하는 버그를 잡는데 들어갈 엄청난 시간을 절약해 줄 것.&lt;/li&gt;
&lt;li&gt;또한 테스트를 위해 억지로 코드를 끼워 맞추는 고통에서도 해방될 것.&lt;/li&gt;
&lt;li&gt;Side Effects는 정말 방대한 주제이며, 우리는 이제 겨우 그 겉면을 봤을 뿐임.&lt;/li&gt;
&lt;li&gt;앞으로 우리는 Side Effects를 제어하는 훨씬 더 흥미로운 방법들을 탐험하게 될 것!&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>내게 필요한 개발 공부</category>
      <category>Point Free</category>
      <category>Side Effects</category>
      <category>swift</category>
      <category>TCA</category>
      <author>thinkyside</author>
      <guid isPermaLink="true">https://thinkyside.tistory.com/119</guid>
      <comments>https://thinkyside.tistory.com/119#entry119comment</comments>
      <pubDate>Sun, 11 Jan 2026 18:50:21 +0900</pubDate>
    </item>
    <item>
      <title>SwiftUI TextEditor 줄바꿈 스크롤 점핑 현상 해결하기</title>
      <link>https://thinkyside.tistory.com/118</link>
      <description>&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;  문제 정의&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;objc + 스토리보드로 구현되어 있는 메모 화면을 리팩토링하기 위해 SwiftUI의 &lt;a href=&quot;https://developer.apple.com/documentation/swiftui/texteditor&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;TextEditor&lt;/a&gt;는 여러 줄의 텍스트를 입력 받을 때 사용할 수 있는 공식 API를 사용했다.&lt;/p&gt;
&lt;pre id=&quot;code_1767494562344&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;struct ContentView: View {

    @State var text: String = &quot;&quot;

    var body: some View {
        VStack(spacing: 0) {
            NavigationBar()
            TextEditor(text: $text)
                .lineSpacing(4)
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;하지만 왜인지(?) 화면 너머까지 텍스트를 입력하고 줄바꿈할 때 스크롤이 점핑(튀는)하는 현상을 발견해 이를 해결하고자 여러 가지를 시도해봤다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;ScreenRecording_11-13-2025 15-51-45_1.gif&quot; data-origin-width=&quot;750&quot; data-origin-height=&quot;1624&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bFhSNf/dJMcagc7b58/1wpOUFrT51TB9c2lBtm3tK/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bFhSNf/dJMcagc7b58/1wpOUFrT51TB9c2lBtm3tK/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bFhSNf/dJMcagc7b58/1wpOUFrT51TB9c2lBtm3tK/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/bFhSNf/dJMcagc7b58/1wpOUFrT51TB9c2lBtm3tK/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;300&quot; height=&quot;650&quot; data-filename=&quot;ScreenRecording_11-13-2025 15-51-45_1.gif&quot; data-origin-width=&quot;750&quot; data-origin-height=&quot;1624&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;++ 아래 링크에서 같은 문제를 겪은 사람이 있다,,, iOS 16 TextEditor long text jumping (SwiftUI)&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://developer.apple.com/forums/thread/724330&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://developer.apple.com/forums/thread/724330&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1767495908972&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 16 TextEditor long text jumpin&amp;hellip; | Apple Developer Forums&quot; data-og-description=&quot;Text jumping (I don't know how else to name that weird behavior) to the top(?) every time user enters a new character. I can reproduce the issue only on iPad OS 16. It affects the user who attempts to write long (several paragraphs) text in the TextEditor.&quot; data-og-host=&quot;developer.apple.com&quot; data-og-source-url=&quot;https://developer.apple.com/forums/thread/724330&quot; data-og-url=&quot;https://developer.apple.com/forums/thread/724330&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://developer.apple.com/forums/thread/724330&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://developer.apple.com/forums/thread/724330&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;iOS 16 TextEditor long text jumpin&amp;hellip; | Apple Developer Forums&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Text jumping (I don't know how else to name that weird behavior) to the top(?) every time user enters a new character. I can reproduce the issue only on iPad OS 16. It affects the user who attempts to write long (several paragraphs) text in the TextEditor.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;developer.apple.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;  삽질 기록&lt;/h3&gt;
&lt;h4 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;1. UIRepresentable을 이용한 UITextView 사용해보기&lt;/h4&gt;
&lt;pre id=&quot;code_1767495935061&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;struct ContentView: View {

    @State var text: String = &quot;&quot;

    var body: some View {
        VStack(spacing: 0) {
            NavigationBar()
            TextViewWrapper(text: $text)
        }
    }
}

struct TextViewWrapper: UIViewRepresentable {
    @Binding var text: String

    func makeUIView(context: Context) -&amp;gt; UITextView {
        let textView = UITextView()
        textView.delegate = context.coordinator
        return textView
    }

    func updateUIView(_ uiView: UITextView, context: Context) {
        let selectedRange = uiView.selectedRange
        uiView.text = text
        uiView.selectedRange = selectedRange
    }

    func makeCoordinator() -&amp;gt; Coordinator {
        Coordinator(self)
    }

    class Coordinator: NSObject, UITextViewDelegate {
        var parent: TextViewWrapper

        init(_ parent: TextViewWrapper) {
            self.parent = parent
        }

        func textViewDidChange(_ textView: UITextView) {
            parent.text = textView.text
        }
    }
}&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;ScreenRecording_11-13-2025 15-54-16_1.gif&quot; data-origin-width=&quot;750&quot; data-origin-height=&quot;1624&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/RqvBL/dJMcahC4J5P/lMpv7wuspUdqMi6WX5hppk/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/RqvBL/dJMcahC4J5P/lMpv7wuspUdqMi6WX5hppk/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/RqvBL/dJMcahC4J5P/lMpv7wuspUdqMi6WX5hppk/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/RqvBL/dJMcahC4J5P/lMpv7wuspUdqMi6WX5hppk/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;300&quot; height=&quot;650&quot; data-filename=&quot;ScreenRecording_11-13-2025 15-54-16_1.gif&quot; data-origin-width=&quot;750&quot; data-origin-height=&quot;1624&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;스크롤 점핑 현상이 완화(같은 줄 안에서는 점핑이 없어졌지만, 줄바꿈 시 발생)되긴 했지만 여전히 발생하긴 함.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;2. SwiftUI의 TextField 사용&lt;/h4&gt;
&lt;pre id=&quot;code_1767496085076&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;struct ContentView: View {

    @State var text: String = &quot;&quot;

    var body: some View {
        VStack(spacing: 0) {
            NavigationBar()
            TextField(text: $text, axis: .vertical) {}
                .lineSpacing(4)
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;ScreenRecording_11-13-2025 15-59-09_1.gif&quot; data-origin-width=&quot;750&quot; data-origin-height=&quot;1624&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bwvbpD/dJMcaiBWzId/KOEOraPQJuiKclvKtN0VjK/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bwvbpD/dJMcaiBWzId/KOEOraPQJuiKclvKtN0VjK/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bwvbpD/dJMcaiBWzId/KOEOraPQJuiKclvKtN0VjK/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/bwvbpD/dJMcaiBWzId/KOEOraPQJuiKclvKtN0VjK/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;300&quot; height=&quot;650&quot; data-filename=&quot;ScreenRecording_11-13-2025 15-59-09_1.gif&quot; data-origin-width=&quot;750&quot; data-origin-height=&quot;1624&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;스크롤 점핑 현상 해결(정상 동작)&lt;/li&gt;
&lt;li&gt;하지만 TextEditor에 있던 Scroll Indicator가 보여지지 않음.&lt;/li&gt;
&lt;li&gt;Focus 됐을 때와 안됐을 때의 라인이 달라보일 때가 있음.(Focus 되면 갑자기 줄이 늘어나는 것처럼 보임,,?)&lt;/li&gt;
&lt;li&gt;Line 수가 적을 때 height 값도 작아지기 때문에(TextEditor는 기본적으로 가능한 가장 큰 크기를 차지) FocusField가 활성화 시킬 수 있는 영역에 대한 추가 구현이 필요.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. ScrollView + TextField 사용&lt;/h4&gt;
&lt;pre id=&quot;code_1767496290546&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;struct ContentView: View {

    @State var text: String = &quot;&quot;

    var body: some View {
        VStack(spacing: 0) {
            NavigationBar()
            ScrollView {
                TextField(text: $text, axis: .vertical) {}
                    .lineSpacing(4)
            }
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;ScreenRecording_11-13-2025 16-07-58_1.gif&quot; data-origin-width=&quot;750&quot; data-origin-height=&quot;1624&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/YiVnP/dJMcagqDp2O/la9KKqqdTn2QFkvJLBEr60/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/YiVnP/dJMcagqDp2O/la9KKqqdTn2QFkvJLBEr60/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/YiVnP/dJMcagqDp2O/la9KKqqdTn2QFkvJLBEr60/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/YiVnP/dJMcagqDp2O/la9KKqqdTn2QFkvJLBEr60/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;300&quot; height=&quot;650&quot; data-filename=&quot;ScreenRecording_11-13-2025 16-07-58_1.gif&quot; data-origin-width=&quot;750&quot; data-origin-height=&quot;1624&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;위 방식에서 ScrollView만 추가해본 방식.&lt;/li&gt;
&lt;li&gt;스크롤은 정상 동작하고 Scroll Indicator도 표시되지만, 줄바꿈 시 자동으로 스크롤되는 기능이 누락됨.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;4. UITextView + NSAttributedString 사용&lt;/h4&gt;
&lt;pre id=&quot;code_1767496472071&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;struct ContentView: View {

    @State var text: String = &quot;asdasd&quot;

    var body: some View {
        VStack(spacing: 0) {
            NavigationBar()
            TextViewRepresentable(
                text: $text,
                font: .systemFont(ofSize: 20),
                textColor: .red,
                tintColor: .blue,
                lineSpacing: 8,
                contentInset: UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
            )
        }
    }

    @ViewBuilder
    private func NavigationBar() -&amp;gt; some View {
        Rectangle()
            .frame(height: 40)
            .foregroundStyle(.black)
    }
}

struct TextViewRepresentable: UIViewRepresentable {

    @Binding var text: String

    let font: UIFont
    let textColor: UIColor
    let tintColor: UIColor
    let lineSpacing: CGFloat
    let contentInset: UIEdgeInsets

    init(
        text: Binding&amp;lt;String&amp;gt;,
        font: UIFont,
        textColor: UIColor,
        tintColor: UIColor,
        lineSpacing: CGFloat = 4,
        contentInset: UIEdgeInsets = .zero
    ) {
        self._text = text
        self.font = font
        self.textColor = textColor
        self.tintColor = tintColor
        self.lineSpacing = lineSpacing
        self.contentInset = contentInset
    }

    func makeUIView(context: Context) -&amp;gt; UITextView {
        let textView = UITextView()

        // 1. Delegate 설정
        textView.delegate = context.coordinator

        // 2. ContentInset 설정
        textView.textContainerInset = contentInset

        // 3. TintColor 설정
        textView.tintColor = tintColor

        // 4. paragraphStyle 설정
        let paragraphStyle = NSMutableParagraphStyle()
        paragraphStyle.lineSpacing = lineSpacing

        // 5. NSAttributedString 속성 세트 생성
        let attributes: [NSAttributedString.Key: Any] = [
            .font: font,
            .foregroundColor: textColor,
            .paragraphStyle: paragraphStyle
        ]

        // 6. 기본 텍스트 속성 설정
        textView.attributedText = NSAttributedString(
            string: text,
            attributes: attributes
        )

        // 7. 타이핑 속성 설정
        textView.typingAttributes = attributes
        return textView
    }

    func updateUIView(_ uiView: UITextView, context: Context) { }

    func makeCoordinator() -&amp;gt; Coordinator {
        Coordinator(self)
    }

    final class Coordinator: NSObject, UITextViewDelegate {

        @Binding var text: String

        var parent: TextViewRepresentable

        init(_ parent: TextViewRepresentable, _ text: Binding&amp;lt;String&amp;gt;) {
            self.parent = parent
        }

        func textViewDidChange(_ textView: UITextView) {
            parent.text = textView.text
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;ScreenRecording_11-13-2025 17-59-00_1.gif&quot; data-origin-width=&quot;750&quot; data-origin-height=&quot;1624&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/83Gkb/dJMcacaHSyH/6ogDx3AQpdIVpRLkwziu40/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/83Gkb/dJMcacaHSyH/6ogDx3AQpdIVpRLkwziu40/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/83Gkb/dJMcacaHSyH/6ogDx3AQpdIVpRLkwziu40/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/83Gkb/dJMcacaHSyH/6ogDx3AQpdIVpRLkwziu40/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;300&quot; height=&quot;650&quot; data-filename=&quot;ScreenRecording_11-13-2025 17-59-00_1.gif&quot; data-origin-width=&quot;750&quot; data-origin-height=&quot;1624&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/danielsaidi/RichTextKit&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;RichTextKit&lt;/a&gt; 라이브러리를 참고해 `NSAttributedString` 사용에서 힌트를 얻음.&lt;/li&gt;
&lt;li&gt;스크롤 점핑 현상 해결 + Scroll Indicator 정상적으로 보임.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;  문제 해결&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 이것저것 시도해보다 `NSAttributedString`에서 답을 얻을 수 있었다,,! 애플이 TextEditor를 고쳐준다면야 가장 좋겠지만 마냥 기다릴 순 없으니 아래와 같이 컴포넌트로 만들어두고 잘 돌려써먹어볼 예정이다.&lt;/p&gt;
&lt;pre id=&quot;code_1767496687090&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import SwiftUI

/// SwiftUI `TextEditor`의 스크롤 점핑 문제를 해결하기 위해 구현된 커스텀 텍스트 에디터.
///
/// - Note: `UITextView`와 `NSAttributedString`을 활용해 스크롤 점핑 문제를 해결했습니다.
public struct StableTextEditor: UIViewRepresentable {

    @Binding var isFocused: Bool
    @Binding var text: String

    let font: UIFont
    let textColor: UIColor
    let tintColor: UIColor
    let backgroundColor: UIColor
    let lineSpacing: CGFloat
    let contentInset: UIEdgeInsets

    public init(
        isFocused: Binding&amp;lt;Bool&amp;gt;,
        text: Binding&amp;lt;String&amp;gt;,
        font: UIFont,
        textColor: Color,
        tintColor: Color,
        backgroundColor: Color,
        lineSpacing: CGFloat,
        contentInset: UIEdgeInsets = .zero
    ) {
        self._isFocused = isFocused
        self._text = text
        self.font = font
        self.textColor = UIColor(textColor)
        self.tintColor = UIColor(tintColor)
        self.backgroundColor = UIColor(backgroundColor)
        self.lineSpacing = lineSpacing
        self.contentInset = contentInset
    }

    public func makeUIView(context: Context) -&amp;gt; UITextView {
        let textView = UITextView()
        textView.delegate = context.coordinator
        textView.tintColor = tintColor
        textView.backgroundColor = backgroundColor
        textView.textContainerInset = contentInset
        textView.attributedText = NSAttributedString(string: text, attributes: attributes)
        textView.typingAttributes = attributes
        return textView
    }

    public func updateUIView(_ uiView: UITextView, context: Context) {
        if uiView.text != text {
            uiView.attributedText = NSAttributedString(string: text, attributes: attributes)
            uiView.typingAttributes = attributes
        }

        if isFocused {
            uiView.becomeFirstResponder()
        } else {
            uiView.resignFirstResponder()
        }
    }

    public func makeCoordinator() -&amp;gt; Coordinator {
        Coordinator(self)
    }
}

// MARK: - Helper
extension StableTextEditor {

    /// NSAttributedString 속성 Dictionary를 반환합니다.
    private var attributes: [NSAttributedString.Key: Any] {
        let paragraphStyle = NSMutableParagraphStyle()
        paragraphStyle.lineSpacing = lineSpacing
        return [
            .font: font,
            .foregroundColor: textColor,
            .paragraphStyle: paragraphStyle
        ]
    }
}

// MARK: - Coordinator
extension StableTextEditor {

    public final class Coordinator: NSObject, UITextViewDelegate {

        var parent: StableTextEditor

        public init(_ parent: StableTextEditor) {
            self.parent = parent
        }

        public func textViewDidChange(_ textView: UITextView) {
            parent.text = textView.text
        }

        public func textViewDidBeginEditing(_ textView: UITextView) {
            parent.isFocused = true
        }

        public func textViewDidEndEditing(_ textView: UITextView) {
            parent.isFocused = false
        }
    }
}&lt;/code&gt;&lt;/pre&gt;</description>
      <category>트러블슈팅</category>
      <category>SwiftUI</category>
      <category>TextEditor</category>
      <author>thinkyside</author>
      <guid isPermaLink="true">https://thinkyside.tistory.com/118</guid>
      <comments>https://thinkyside.tistory.com/118#entry118comment</comments>
      <pubDate>Sun, 4 Jan 2026 12:22:57 +0900</pubDate>
    </item>
    <item>
      <title>영원회귀</title>
      <link>https://thinkyside.tistory.com/117</link>
      <description>&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;1. 일&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;/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. 나는 얼마나 나를 위해 살고 있는가&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하루 24시간 중 8시간은 잠을, 9시간은 일을, 3시간은 지하철에서 보내고 나면 내게 남는 시간은 4시간이다. 그중 식사와 씻는 시간 등 인생의 공과금을 지불하고 나면 온전히 쓸 수 있는 시간은 3시간 남짓이다. 턱없이 부족하다 느껴지는 시간이다. 그렇다면 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;나의 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;둘 째는 영화를 보는 것이다. 최근 인문학, 철학 등에 관심이 많아지며 자연스레 책과 영화에 시선이 가게 되었고 나와 다른 하루를 살아가는 이들의 이야기는 한정된 세계를 넓혀주었다. 이전에는 오락의 역할 뿐이었다면, 이제는 경험의 역할로서 다양한 생각과 삶의 태도를 비유적으로 건네주고 있다. 푹신한 침대 위에서 매일 다른 곳을 여행하는 기분을 느낀다. 평소 보고 싶었던, 누군가 추천해 준 좋은 영화를 감상하는 일은 내 하루를 차갑게 진정시켜 준다.&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. 순응하는 삶, 투쟁하는 삶&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;/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. 불확실한 사람에 관하여&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;/p&gt;
&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;</description>
      <category>솔직해보려는 회고</category>
      <category>회고</category>
      <author>thinkyside</author>
      <guid isPermaLink="true">https://thinkyside.tistory.com/117</guid>
      <comments>https://thinkyside.tistory.com/117#entry117comment</comments>
      <pubDate>Thu, 25 Dec 2025 17:21:12 +0900</pubDate>
    </item>
    <item>
      <title>외계인 자서전 - 지극히 특별하고 평범한 외로움</title>
      <link>https://thinkyside.tistory.com/116</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/1vMJQ/dJMcabCETlY/s0Zz4zrPKRNk35tZqQjeoK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/1vMJQ/dJMcabCETlY/s0Zz4zrPKRNk35tZqQjeoK/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;400&quot; data-origin-height=&quot;400&quot; data-filename=&quot;blob&quot; width=&quot;300&quot; height=&quot;300&quot; data-widthpercent=&quot;56.76&quot; style=&quot;width: 56.0968%; margin-right: 10px;&quot; id=&quot;kEditorPhotosEditingImage-0&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/1vMJQ/dJMcabCETlY/s0Zz4zrPKRNk35tZqQjeoK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F1vMJQ%2FdJMcabCETlY%2Fs0Zz4zrPKRNk35tZqQjeoK%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;400&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Le5NK/dJMcahizB50/4nQ9yjHKxdKHL9GG86lkaK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Le5NK/dJMcahizB50/4nQ9yjHKxdKHL9GG86lkaK/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;400&quot; data-origin-height=&quot;525&quot; data-filename=&quot;blob&quot; width=&quot;300&quot; height=&quot;394&quot; data-widthpercent=&quot;43.24&quot; style=&quot;width: 42.7404%;&quot; id=&quot;kEditorPhotosEditingImage-1&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Le5NK/dJMcahizB50/4nQ9yjHKxdKHL9GG86lkaK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FLe5NK%2FdJMcahizB50%2F4nQ9yjHKxdKHL9GG86lkaK%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;525&quot;/&gt;&lt;/span&gt;&lt;/div&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;/p&gt;</description>
      <category>생각을 담은 독서</category>
      <category>마리 헐린 버티노</category>
      <category>외계인 자서전</category>
      <author>thinkyside</author>
      <guid isPermaLink="true">https://thinkyside.tistory.com/116</guid>
      <comments>https://thinkyside.tistory.com/116#entry116comment</comments>
      <pubDate>Sun, 30 Nov 2025 19:12:45 +0900</pubDate>
    </item>
    <item>
      <title>Let'Swift 2025 짤막한 후기</title>
      <link>https://thinkyside.tistory.com/115</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1454&quot; data-origin-height=&quot;1452&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cbv3if/dJMcadtDaHa/Kxo3K0DnHOB7SZKbqbPnmK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cbv3if/dJMcadtDaHa/Kxo3K0DnHOB7SZKbqbPnmK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cbv3if/dJMcadtDaHa/Kxo3K0DnHOB7SZKbqbPnmK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcbv3if%2FdJMcadtDaHa%2FKxo3K0DnHOB7SZKbqbPnmK%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;399&quot; data-origin-width=&quot;1454&quot; data-origin-height=&quot;1452&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오랜만에 아카데미 분들도 봐서 좋았고, 무엇보다 다시금 iOS에 대한 열정의 기억을 되살릴 수 있었습니다. 사이드 프로젝트도, 디자인도, 완전히 다른 것도 재밌는게 많지만 제겐 역시 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;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;열심히 준비해 좋은 기회 주신 회사 동료 분께 다시 한번 감사 인사 드립니다   (다들 너무 고생 많으심,,, 늘 응원합니닷)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;컨퍼런스 내용 간단 정리&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;AI 친화적 코드베이스: 중대규모 코드베이스에서 AI 에이전트 활용도 높이기&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;Claude 코드 기반 설명.&lt;/li&gt;
&lt;li&gt;관련 없는 맥락이 인풋으로 들어가면 답변의 질이 떨어짐 &amp;rarr; 컨텍스트 부패&lt;/li&gt;
&lt;li&gt;Why? 1 &amp;rarr; 문자열 리터럴 충돌 &amp;rarr; 동일 함수/클래스명으로 인한 혼란&lt;/li&gt;
&lt;li&gt;Why? 2 &amp;rarr; Claude의 Read 방법 &amp;rarr; 필요한 부분을 동적으로 로드하기 때문에 전체 파일 읽어야 함&lt;/li&gt;
&lt;li&gt;해결 시도 1 &amp;rarr; Claude의 Read 방법을 바꿔주는 &lt;b&gt;Claude Context MCP&lt;/b&gt; 사용 &amp;rarr; 토큰 사용량 및 호출 횟수 최적화&lt;/li&gt;
&lt;li&gt;해결 시도 2 &amp;rarr; Smart Compact 기법 사용 &amp;rarr; &lt;b&gt;각 단계별 요약 문서&lt;/b&gt;(project.md 등) 생성 &amp;rarr; 관련 없는 코드베이스 탐색 및 요약을 줄여줌&lt;/li&gt;
&lt;li&gt;하지만 2가지 방식 모두 완전히 컨텍스트 부패를 막을 수는 없었음,,, &amp;rarr; 이를 위해 &lt;b&gt;코드베이스에서의 수정&lt;/b&gt;이 필요하다 느낌&lt;/li&gt;
&lt;li&gt;코드베이스 수정 1 &amp;rarr; Layer Based 폴더링이 아닌, &lt;b&gt;Feature Based 폴더링&lt;/b&gt; 채택 &amp;rarr; Claude의 파일 탐색을 더 정확하고 빠르게 해줌.(회사 프로젝트도 폴더링 개선이 필요해 보임 인간에게나 AI에게나 필요해보임)&lt;/li&gt;
&lt;li&gt;코드베이스 수정 2 &amp;rarr; &lt;b&gt;단일 책임 File 구조&lt;/b&gt;(SuperFile 지양) &amp;rarr; 전체 파일을 읽으려 하기 때문에 이를 막아줌 &amp;rarr; 프로토콜 및 구현체의 파일을 적절히 분리할 수 있음.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;CGAffineTransform으로 화면 렌더링 이해도 올리기&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;안타깝게도 transform(x: 0, y: -1) 하면 뒤집어 진다,,, 까지 이해했음(?)&lt;/li&gt;
&lt;li&gt;CGAffineTransform이 어떻게 이런 단순한 값으로 좌표를 표현하는지에 대한 수학적 설명이 주된 내용이었음.(벡터, 행렬 등)&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;SwiftUI로 디자인시스템 약간 오버 엔지니어링 설계하기(feat: OOP)&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;디자인 시스템을 설계하기 위해 &lt;b&gt;각 컴포넌트 내 공통되는 디자인 요소, variation 등을 분석&lt;/b&gt;하는 과정이 선행됨.&lt;/li&gt;
&lt;li&gt;이후에는 &lt;b&gt;protocol을 이용해 각 요소를 적절히 추상화&lt;/b&gt;하고 UIComponent(View)에 적용.&lt;/li&gt;
&lt;li&gt;연사자 분이 왜 오버 엔지니어링이라고 제목을 붙이셨는지 공감이 감,,,(개인적으로는 만들어진 컴포넌트에서 수정이나 추가가 거의 적을 것이라 판단해 하나의 View에 다양한 속성을 받을 수 있게 생성자를 만들고 기본값을 부여해 적당한 리소스로 관리 가능한 컴포넌트를 구현했을 것 같음)&lt;/li&gt;
&lt;li&gt;마지막에 연사자 분도 &lt;b&gt;프로젝트의 맥락과 필요에 따라 선택&lt;/b&gt;이 필요하다고 언급해주셨음.&lt;/li&gt;
&lt;li&gt;특히&lt;b&gt; 디자이너 분들과의 적극적인 소통&lt;/b&gt;이 필요하다는 말이 가장 인상 깊었음.(진짜 제일 중요한듯!!!)&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;모놀로식에서 모듈화로, 그 중간 어딘가&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;모듈화는 왜 하는가? &amp;rarr; 생산성 및 효율 증가 / 도메인 분리 / 의존성 및 재사용성 관리 / 빌드 시간 최적화 / 데모 앱&lt;/li&gt;
&lt;li&gt;연사자 분 팀의 모듈화에서의 목표 &amp;rarr; 한번에 큰 변화가 아닌, &lt;b&gt;점진적인 개선으로 모듈화&lt;/b&gt; 진행하기(모듈화 진행 중에도 안정적인 서비스를 유지하고, 영향 범위를 크게 만들지 않아 팀원의 부담을 줄이는 방향성)&lt;/li&gt;
&lt;li&gt;How? &amp;rarr; 모듈 분리 부터 하려 하지 말고 &lt;b&gt;메인 앱의 적절한 의존성 분리부터 선행&lt;/b&gt;(그래야 모듈화가 더 수월해짐)&lt;/li&gt;
&lt;li&gt;점진적 방법 1 &amp;rarr; &lt;b&gt;저수준 레이어(Core) 부터 의존성 분리&lt;/b&gt;하기 &amp;rarr; 이후 다른 객체, 레이어와의 의존성 분리의 토대가 될 것.&lt;/li&gt;
&lt;li&gt;점진적 방법 2 &amp;rarr; &lt;b&gt;DI Container를 이용해 간접적으로 의존성 분리&lt;/b&gt;(최종 목표는 DI Container도 걷어낼 수 있어야 함, 점진적인 개선을 위한 방편) &amp;rarr; 순환 의존성을 간접적으로 느슨하게 만들어줄 수 있음&lt;/li&gt;
&lt;li&gt;어느 정도 메인 앱의 의존성 분리가 됐을 때 &lt;b&gt;Component(UI) 모듈&lt;/b&gt;을 만들었음. &amp;rarr; 최종적으로 각 Feature 모듈을 여러 개 만들어 데모 앱을 만드는 게 목표인데, 이를 위핸 토대가 Component 이기 때문.&lt;/li&gt;
&lt;li&gt;Component 모듈을 만들어서 &lt;b&gt;스토리북&lt;/b&gt;(모든 팀원들이 보고 참고할 수 있는 UI 컴포넌트의 집합 데모 앱 같은 느낌)으로 QA 영향 범위를 파악하고, 컴포넌트 재사용률을 높일 수 있음.&lt;/li&gt;
&lt;li&gt;이 때 활용할 수 있는 좋은 툴이 Uber에서 만든 &lt;a href=&quot;https://github.com/uber/ios-snapshot-test-case&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;snapshot-test-case&lt;/a&gt; 라이브러리.&lt;/li&gt;
&lt;li&gt;연사자 분 팀에서는 PR의 diff를 토대로 &lt;b&gt;PR의 스냅샷, develop 브랜치의 스냅샷을 만들고 이를 비교해 다른 점이 발생했을 때의 차이를 스크린샷으로 정리&lt;/b&gt;해주는 액션을 만들어 영향 범위를 효과적으로 파악할 수 있게 만들었음.&lt;/li&gt;
&lt;li&gt;회사 프로젝트에서도 모듈화를 조금씩 진행 중인데 지금처럼 새롭고 작은 기능은 모듈로 나눠가면서도, &lt;b&gt;메인 앱의 코드 의존성을 점진적으로 개선해나갈 수 있는 액션 아이템&lt;/b&gt;을 만들 수 있으면 좋겠다고 생각했음.&lt;/li&gt;
&lt;li&gt;tmi) 카카오 스타일스의 '지그재그' 팀의 이야기였음!&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;Modern Network와 WebSocket&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;iOS 26에서는 WebSocket을 더 Modern하게 사용할 수 있도록 새로운 API가 이것저것 추가되었음.&lt;/li&gt;
&lt;li&gt;특히 SwiftUI의 &lt;b&gt;ResultBuilder 패턴 처럼 WebSocket을 쉽고 직관적으로 생성할 수 있는 방법&lt;/b&gt;도 추가됨!&lt;/li&gt;
&lt;li&gt;개인적으로 Networking, WebSocket 등에 대한 기본 지식이 부족하다 느껴 이 부분은 추후 탄탄히 쌓아보도록 공부가 필요함.(조만간 찜해둔 Network 기본기 인프런 강의 완강하기)&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;Swift 개발자를 위한 온디바이스 AI LLM&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;Apple Intelligence &amp;rarr; &lt;b&gt;온디바이스 LLM&lt;/b&gt; &amp;rarr; OS 내장이기 때문에 앱 용량 증가 X&lt;/li&gt;
&lt;li&gt;Apple Intelligence의 장점: &lt;b&gt;요약 / 정보 추출 / 텍스트 분류 / 간단한 QA / 작은 Context에서의 작업&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;Apple Intelligence의 약점: ChatGPT, Gemini 등 대비 지식 부족, 복잡한 추론 능력 부족, 아직은 부족한 한글 능력,,&lt;/li&gt;
&lt;li&gt;모델 사용 전 필수 체크! &lt;b&gt;기기가 Intelligence를 지원하는가?&lt;/b&gt;(iPhone 15 Pro 이상) / 사용자가 기기에서 &lt;b&gt;'온디바이스 Siri 및 탐색'&lt;/b&gt; 기능을 활성화 했는가? / &lt;b&gt;모델이 다운로드&lt;/b&gt; 되었는가?&lt;/li&gt;
&lt;li&gt;&lt;b&gt;LanguageModelSession&lt;/b&gt;? AFM(Apple Foundation Model)의 핵심 객체 &amp;rarr; Swift 코드에서 요 객체로 다양한 동작 가능&lt;/li&gt;
&lt;li&gt;핵심 꿀팁 1 &amp;rarr; &lt;b&gt;모델 사용 전 사전 예열로 성능 최적화&lt;/b&gt;(session.prewarm) &amp;rarr; 첫 응답 지연 감소 / 최적 호출 시점&lt;/li&gt;
&lt;li&gt;핵심 꿀팁 2 &amp;rarr; &lt;b&gt;Playground 매크로로 AFM 테스트&lt;/b&gt;(#Playground) &amp;rarr; SwiftUI Preview와 비슷하게 여러가지 LLM 요청 결과 확인&lt;/li&gt;
&lt;li&gt;사용법 1 &amp;rarr; AFM 기본 응답 생성하기 &amp;rarr; session.&lt;b&gt;responde(to:)&lt;/b&gt; &amp;rarr; 여기서 파라미터로 들어가는 &lt;b&gt;GenerationOption&lt;/b&gt; 값에 따라 응답 전략(창의적 결과, 일관성, 조정 등), 표현의 다양성(신뢰도, 창의성 등)을 바꿔줄 수 있음&lt;/li&gt;
&lt;li&gt;AFM 응답 생성 시 오류 발생 케이스 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;rarr;&lt;span&gt; 유해, 민감 콘텐츠 / 예측 불가능한 출력 / 의도된 역할 이탈 / 구조화된 출력 위반 / 지원되지 않는 언어 등 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;rarr;&lt;span&gt; 이에 대한 &lt;b&gt;에러 처리 적극 권장!&lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;사용법 2 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;rarr;&lt;span&gt; session.&lt;b&gt;streamResponse()&lt;/b&gt;를 통해 비동기 스트리밍 작업 시작 &amp;rarr; 각 출력 값을 바인딩 된 변수에 누적해줄 수 있음.&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;사용법 3 &amp;rarr; Guided Generation(유도 생성) &amp;rarr; &lt;b&gt;개발자가 정의한 Swift 타입(구조체, 열거형)에 맞춰 모델이 엄격한 형식의 출력을 생성하도록 유도 &lt;/b&gt;&amp;rarr; 별도의 파싱 없이 Type-Safe한 Swift 객체를 즉시 얻을 수 있음 &amp;rarr; &lt;b&gt;@Generable&lt;/b&gt;, &lt;b&gt;@Guide&lt;/b&gt; 매크로 활용&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;사용법 4 &amp;rarr; Tools(모델의 손발) &amp;rarr; &lt;b&gt;모델이 스스로 외부 코드(Swift 함수)를 호출해 정보를 얻거나 특정 행동을 수행하게 만드는 기능&lt;/b&gt; &amp;rarr; Tool Calling 또는 Function Calling이라고도 함. &amp;rarr; RAG(검색 증강 생성) 구현, 앱의 액션(DB에 새 정보를 쓰거나, 외부 API를 호출하는 등)을 호출하는데도 활용 가능.&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;요걸 활용해볼 다양한 아이디어? 게임 NPC 랜덤 대사 생성 / 사용자 입력 기반 여행 일정 자동 생성 / 온디바이스 개인 맞춤형 비서 등&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;복잡한 작업이 어렵고 한국어에 약해 맛있게 써먹기는 어렵겠지만 분명 &lt;b&gt;온디바이스의 장점을 살려 여러가지 시도를 해볼만 할 것 같다&lt;/b&gt;고 느꼈다.(회사 프로젝트든, 개인 프로젝트든 어쨌든 무료로 다양한걸 해볼 수 있다는데 해볼만 하지 않을까?)&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>대외활동</category>
      <category>ios</category>
      <category>Let'Swift</category>
      <author>thinkyside</author>
      <guid isPermaLink="true">https://thinkyside.tistory.com/115</guid>
      <comments>https://thinkyside.tistory.com/115#entry115comment</comments>
      <pubDate>Mon, 24 Nov 2025 23:15:18 +0900</pubDate>
    </item>
    <item>
      <title>Firebase Remote Config로 배포 없이 값 &amp;quot;딸깍&amp;quot; 바꿔주기</title>
      <link>https://thinkyside.tistory.com/114</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;앱에 새로 출시할 기능이 상당히 실험적이고 도전적이라 '어떤 비즈니스 이펙트를 가져올지 예상할 수 없을 때' 어떤 선택을 할 수 있을까요? 여기서 전제는 '&lt;b&gt;앱 배포'이기 때문에 되돌리거나 새로운 변경사항으로 바꾸고 싶으면 기존 방식으로는 길고 긴 심사를 다시 거쳐야한다는 것&lt;/b&gt;입니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;점진 배포하기&lt;/li&gt;
&lt;li&gt;Server-Driven 방식으로 분기처리 하기&lt;/li&gt;
&lt;li&gt;그냥 다시 배포하고 기도하기(?)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;952&quot; data-origin-height=&quot;498&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/HdeAx/dJMcacar8Zm/189YQI0eOYnn96dxWgxQjK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/HdeAx/dJMcacar8Zm/189YQI0eOYnn96dxWgxQjK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/HdeAx/dJMcacar8Zm/189YQI0eOYnn96dxWgxQjK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FHdeAx%2FdJMcacar8Zm%2F189YQI0eOYnn96dxWgxQjK%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;209&quot; data-origin-width=&quot;952&quot; data-origin-height=&quot;498&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3가지 방식 모두 장단점이 있지만, 오늘은 그 중에서도 가장 적은 리소스로 가장 빠르게 프론트에서 적용할 수 있는 방식인 &lt;b&gt;Firebase Remote Config&lt;/b&gt; 방식에 대해 정리해보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Firebase Remote Config?&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2244&quot; data-origin-height=&quot;944&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ceIyYX/dJMcabCCcmx/kroKTlUPQSHmTjmyhoiIk1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ceIyYX/dJMcabCCcmx/kroKTlUPQSHmTjmyhoiIk1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ceIyYX/dJMcabCCcmx/kroKTlUPQSHmTjmyhoiIk1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FceIyYX%2FdJMcabCCcmx%2FkroKTlUPQSHmTjmyhoiIk1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;252&quot; data-origin-width=&quot;2244&quot; data-origin-height=&quot;944&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;을 도와주는 친구입니다.(Firebase,,, 대체 얼마나 해주는거야!!) &lt;a href=&quot;https://firebase.google.com/docs/remote-config?authuser=0&amp;amp;hl=ko&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;공식 개발자 문서&lt;/a&gt;에 따르면 아래와 같은 기능을 도입할 수 있다고 해요.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;&lt;b&gt;앱 사용자 층에 변경사항을 빠르게 출시&lt;/b&gt;: 파라미터 값을 원격으로 바꿔 앱 내 변화.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;사용자층의 특정 세그먼트에 앱 맞춤설정&lt;/b&gt;: GA 세그먼트를 기준으로 다양한 사용자 환경 제공 가능.&lt;/li&gt;
&lt;li&gt;타겟팅 및 점진 배포, 커스텀 이벤트 측정, A/B 테스트 설정 등등...&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원격으로 값을 바꿔줄 수 있기 때문에 &lt;b&gt;Server-Driven 방식처럼 분기 처리로 배포와 상관 없이 앱 내 변화&lt;/b&gt;를 줄 수 있게 됩니다. 이렇게 좋은 기능이 있었는데,,, 왜 작년엔 서버 없다고 3번 방식(&lt;s&gt;기도 메타&lt;/s&gt;)만 사용했는지 모르겠네요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;일단 한번 해보기(빠른 시작 가이드)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본적으로 프로젝트 내 Firebase 세팅이 되어있다는 걸 기준으로 정리해보겠습니다.(안했다면 요 &lt;a href=&quot;https://firebase.google.com/docs/ios/setup?authuser=0&amp;amp;hl=ko&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;공식 문서&lt;/a&gt;를!) 또 Remote Config 사용을 위해선 Google Analytics 사용 설정도 필수이기 때문에 이 친구 또한 되어 있어야 합니다.(안했다면 요 &lt;a href=&quot;https://firebase.google.com/docs/analytics/get-started?platform=ios&amp;amp;authuser=0&amp;amp;hl=ko&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;공식 문서&lt;/a&gt;를!)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자 우선 어떤 값을 원격으로 사용하고 제어하고 싶은지 명시해야겠죠? Firebase Console에서 쉽게 설정할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;496&quot; data-origin-height=&quot;1048&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nO7gC/dJMcafSyroe/aIAuJt4TLcNbo6UkCydOK0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nO7gC/dJMcafSyroe/aIAuJt4TLcNbo6UkCydOK0/img.png&quot; data-alt=&quot;실행 &amp;amp;gt; Remote Config&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nO7gC/dJMcafSyroe/aIAuJt4TLcNbo6UkCydOK0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnO7gC%2FdJMcafSyroe%2FaIAuJt4TLcNbo6UkCydOK0%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;200&quot; height=&quot;423&quot; data-origin-width=&quot;496&quot; data-origin-height=&quot;1048&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;실행 &amp;gt; Remote Config&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Remote Config를 처음 설정하게 되면 아래 화면으로 진입하게 됩니다. 첫 구성을 만들기 위해 '구성 만들기' 버튼을 클릭합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2458&quot; data-origin-height=&quot;1174&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cF3ntB/dJMb99Y6jUI/Ehg2au6rISXsMY51NjSfR0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cF3ntB/dJMb99Y6jUI/Ehg2au6rISXsMY51NjSfR0/img.png&quot; data-alt=&quot;'구성 만들기' 버튼 클릭&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cF3ntB/dJMb99Y6jUI/Ehg2au6rISXsMY51NjSfR0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcF3ntB%2FdJMb99Y6jUI%2FEhg2au6rISXsMY51NjSfR0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;287&quot; data-origin-width=&quot;2458&quot; data-origin-height=&quot;1174&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;이제 매개변수를 만들면 됩니다! 필드도 몇개 없고 직관적이라 어떤 값들이 들어가야 할지 쉽게 파악이 가능합니다. 저는 제 강아지 이름을 원격으로 제어하고자 `puppy_name`의 Key와 '민톨'이라는 Value를 문자열 타입으로 설정해주었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;++ 중요! Remote Config 파라미터 Key 또는 Value에는 민감한 데이터를 저장하지 말 것을 권장합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1592&quot; data-origin-height=&quot;210&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kRIJ8/dJMcaaqaQEn/T0IskDzK7TkwXEGMk0SwfK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kRIJ8/dJMcaaqaQEn/T0IskDzK7TkwXEGMk0SwfK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kRIJ8/dJMcaaqaQEn/T0IskDzK7TkwXEGMk0SwfK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkRIJ8%2FdJMcaaqaQEn%2FT0IskDzK7TkwXEGMk0SwfK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;79&quot; data-origin-width=&quot;1592&quot; data-origin-height=&quot;210&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1176&quot; data-origin-height=&quot;1018&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/brBNs0/dJMcaajpfgB/fYo0M2mFkkSh2rTBijUPy1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/brBNs0/dJMcaajpfgB/fYo0M2mFkkSh2rTBijUPy1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/brBNs0/dJMcaajpfgB/fYo0M2mFkkSh2rTBijUPy1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbrBNs0%2FdJMcaajpfgB%2FfYo0M2mFkkSh2rTBijUPy1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;519&quot; data-origin-width=&quot;1176&quot; data-origin-height=&quot;1018&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1164&quot; data-origin-height=&quot;844&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/buukH0/dJMcai2LvHP/TuXPM2zhrhGYYFpFeAMEG0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/buukH0/dJMcai2LvHP/TuXPM2zhrhGYYFpFeAMEG0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/buukH0/dJMcai2LvHP/TuXPM2zhrhGYYFpFeAMEG0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbuukH0%2FdJMcai2LvHP%2FTuXPM2zhrhGYYFpFeAMEG0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;435&quot; data-origin-width=&quot;1164&quot; data-origin-height=&quot;844&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Firebase Console에서의 설정은 끝났습니다. 이제 프로젝트에도 Remote Config를 가져오기 위한 기본 세팅을 진행해줍니다. 먼저 제가 기존에 사용하던 `FirebaseManager`에 이를 위한 함수를 하나 생성해주었습니다.&lt;/p&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 FirebaseRemoteConfig` 부분에서 컴파일 에러가 난다면, 프로젝트 타겟에 `FirebaseRemoteConfig`가 추가되었는지 확인해보세요!(없다면 추가 고고)&lt;/p&gt;
&lt;pre id=&quot;code_1763864583458&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import FirebaseCore
import FirebaseRemoteConfig // ⭐️ 추가

enum FirebaseManager {
    
    /// Firebase를 초기화합니다.
    static func initialize() {
        FirebaseApp.configure()
    }
    
    /// ⭐️ Remote Config를 설정합니다.
    static func setupRemoteConfig() {
        let remoteConfig = RemoteConfig.remoteConfig() // ⭐️ 싱글톤
        let settings = RemoteConfigSettings() // ⭐️ 설정 객체
        settings.minimumFetchInterval = 0 // ⭐️ 얼마나 자주 값을 가져올지(0 == 최대한 자주)
        remoteConfig.configSettings = settings // ⭐️ 싱글톤에 설정 객체 넘기기
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 앱 초기화 시점에 Firebase 초기화와 함께 RemoteConfig 설정도 진행해주었습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1763864621417&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@main
class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -&amp;gt; Bool {
        // ...
        FirebaseManager.initialize()
        FirebaseManager.setupRemoteConfig() // ⭐️ 앱 초기화 시 Remote Config 설정
        // ...
    }
}&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;자 이제 대망의 값을 가져와보겠습니다! Firebase Console에서 작성했던 Key와 타입을 이용해 아래와 같이 작성하면 우리가 설정했던 '민톨' 값을 정상적으로 가져오는 것을 확인할 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1763865075926&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// ⭐️ 당연한 이야기지만 비동기로 가져오지 않으면 기본값(타입 기본값 or 내가 지정한 기본값)으로 설정됨
Task {
    let remoteConfig = RemoteConfig.remoteConfig()
    try await remoteConfig.fetch()
    try await remoteConfig.activate()
    let puppyName = remoteConfig[&quot;puppy_name&quot;].stringValue
    print(&quot;제 강아지 이름은 \(puppyName)이에요.&quot;)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1026&quot; data-origin-height=&quot;156&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/W3fDj/dJMcagRsout/EFjduvxppwnPnKZ8DUndpK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/W3fDj/dJMcagRsout/EFjduvxppwnPnKZ8DUndpK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/W3fDj/dJMcagRsout/EFjduvxppwnPnKZ8DUndpK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FW3fDj%2FdJMcagRsout%2FEFjduvxppwnPnKZ8DUndpK%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;61&quot; data-origin-width=&quot;1026&quot; data-origin-height=&quot;156&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;h3 data-ke-size=&quot;size23&quot;&gt;다양한 타입 설정도 가능해요&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Remote Config는 총 4개의 데이터 타입을 지원합니다. 위에서 예시로 포함한 문자열(`String`)과 숫자(`Int`), 부울(`Bool`), `JSON`을 이용해 다양한 데이터를 표현할 수 있죠. 각각 사용은 아래와 같이 가능합니다. JSON을 지원하기 때문에 사실상 대부분의 데이터는 모두 표현이 가능하다 봐도 무방합니다 ㅎㅎ&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1858&quot; data-origin-height=&quot;886&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dkIrvR/dJMcaaX0M67/9YDj5Bn0XeLKmkTYVNdTd0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dkIrvR/dJMcaaX0M67/9YDj5Bn0XeLKmkTYVNdTd0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dkIrvR/dJMcaaX0M67/9YDj5Bn0XeLKmkTYVNdTd0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdkIrvR%2FdJMcaaX0M67%2F9YDj5Bn0XeLKmkTYVNdTd0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;286&quot; data-origin-width=&quot;1858&quot; data-origin-height=&quot;886&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre id=&quot;code_1763866335854&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Task {
    let remoteConfig = RemoteConfig.remoteConfig()
    try await remoteConfig.fetch()
    try await remoteConfig.activate()
    let puppyName = remoteConfig[&quot;puppy_name&quot;].stringValue
    let puppyAge = remoteConfig[&quot;puppy_age&quot;].numberValue
    let isCute = remoteConfig[&quot;isCute&quot;].boolValue
    let character = try remoteConfig[&quot;character&quot;].decoded(asType: Character.self)
    print(&quot;제 강아지 이름은 \(puppyName)이고 \(puppyAge)살이고 \(isCute ? &quot;귀여워요&quot; : &quot;별로에요&quot;).&quot;)
    dump(character)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1770&quot; data-origin-height=&quot;314&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/uFSFt/dJMcafdWSf5/E0fjdFkTIxuPYJkMCDRKSk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/uFSFt/dJMcafdWSf5/E0fjdFkTIxuPYJkMCDRKSk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/uFSFt/dJMcafdWSf5/E0fjdFkTIxuPYJkMCDRKSk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FuFSFt%2FdJMcafdWSf5%2FE0fjdFkTIxuPYJkMCDRKSk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;106&quot; data-origin-width=&quot;1770&quot; data-origin-height=&quot;314&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;h3 data-ke-size=&quot;size23&quot;&gt;인앱 기본값 설정도 가능해요&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금가지는 Firebase Console에서 기본값을 설정해주었지만 각각의 앱 별로 별도의 기본값을 사용해줘야 할 일이 생기기도 합니다.(ex: iOS, Android에 따른 기본값 분기 처리 등)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인앱 기본값을 설정하기 위해 Firebase Console에서 기본값 파일을 다운로드 받은 후, 앱 내 적용해 이를 구현할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1736&quot; data-origin-height=&quot;942&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/VVHfS/dJMcac2x91I/IyKEckOtUSfn58OgLaduY1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/VVHfS/dJMcac2x91I/IyKEckOtUSfn58OgLaduY1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/VVHfS/dJMcac2x91I/IyKEckOtUSfn58OgLaduY1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FVVHfS%2FdJMcac2x91I%2FIyKEckOtUSfn58OgLaduY1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;326&quot; data-origin-width=&quot;1736&quot; data-origin-height=&quot;942&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1210&quot; data-origin-height=&quot;1098&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/owqo0/dJMcaiaFLcG/Wa5yEJfxRGvBcXLLlbgRa0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/owqo0/dJMcaiaFLcG/Wa5yEJfxRGvBcXLLlbgRa0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/owqo0/dJMcaiaFLcG/Wa5yEJfxRGvBcXLLlbgRa0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fowqo0%2FdJMcaiaFLcG%2FWa5yEJfxRGvBcXLLlbgRa0%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;363&quot; data-origin-width=&quot;1210&quot; data-origin-height=&quot;1098&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원격 구성 기본값을 다운로드 받으면 `remote_config_defaults.plist` 파일이 다운로드 됩니다. 해당 파일을 프로젝트에 적용 후, 설정하고 싶은 기본값을 구성합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1314&quot; data-origin-height=&quot;220&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dXyB04/dJMcaiIsUyW/7XGb7IXz2XElzjhs94TQSK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dXyB04/dJMcaiIsUyW/7XGb7IXz2XElzjhs94TQSK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dXyB04/dJMcaiIsUyW/7XGb7IXz2XElzjhs94TQSK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdXyB04%2FdJMcaiIsUyW%2F7XGb7IXz2XElzjhs94TQSK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;100&quot; data-origin-width=&quot;1314&quot; data-origin-height=&quot;220&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre id=&quot;code_1763867279828&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;static func setupRemoteConfig() {
    let remoteConfig = RemoteConfig.remoteConfig()
    let settings = RemoteConfigSettings()
    settings.minimumFetchInterval = 0
    remoteConfig.configSettings = settings
    remoteConfig.setDefaults(fromPlist: &quot;remote_config_defaults&quot;) // ⭐️ 기본값 설정
}&lt;/code&gt;&lt;/pre&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-origin-width=&quot;1784&quot; data-origin-height=&quot;296&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/llJEB/dJMcafyfAeN/DswiQyZjVdgyHcvjTJXaa1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/llJEB/dJMcafyfAeN/DswiQyZjVdgyHcvjTJXaa1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/llJEB/dJMcafyfAeN/DswiQyZjVdgyHcvjTJXaa1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FllJEB%2FdJMcafyfAeN%2FDswiQyZjVdgyHcvjTJXaa1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;100&quot; data-origin-width=&quot;1784&quot; data-origin-height=&quot;296&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;h3 data-ke-size=&quot;size23&quot;&gt;실시간 업데이트도 가능해요&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지 RemoteConfig를 받아오는 방식은 매번 명시적으로 불러오는 것이었습니다.(ex: viewDidLoad, onAppear 시점에 RemoteConfig 값 호출)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 Rx나 Combine, AsyncStream처럼 비동기 스트림 형태, 즉 실시간으로 값을 받을 수 있는 방법도 있습니다!&lt;/p&gt;
&lt;pre id=&quot;code_1763867930028&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// ⭐️ Remote Config 리스너 추가
remoteConfig.addOnConfigUpdateListener { configUpdate, error in

    // ⭐️ 에러 처리
    if let error {
        print(&quot;에러 발생: \(error.localizedDescription)&quot;)
        return
    }
    
    // ⭐️ 업데이트가 있을 시 로직 호출
    if let _ = configUpdate {
        Task {
            let remoteConfig = RemoteConfig.remoteConfig()
            try await remoteConfig.fetch()
            try await remoteConfig.activate()
            let puppyName = remoteConfig[&quot;puppy_name&quot;].stringValue
            print(&quot;실시간 민톨 이름 업데이트: \(puppyName)&quot;)
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;608&quot; data-origin-height=&quot;168&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/IKRoF/dJMcadAnExH/Xz6K8typoPF0vwzRONxseK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/IKRoF/dJMcadAnExH/Xz6K8typoPF0vwzRONxseK/img.png&quot; data-alt=&quot;민톨이는 100세까지 살자&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/IKRoF/dJMcadAnExH/Xz6K8typoPF0vwzRONxseK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FIKRoF%2FdJMcadAnExH%2FXz6K8typoPF0vwzRONxseK%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;111&quot; data-origin-width=&quot;608&quot; data-origin-height=&quot;168&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;민톨이는 100세까지 살자&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;SwiftUI용 프로퍼티 래퍼도 있어요&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반 함수 안에서는 위와 같이 리스너를 추가해 실시간 값을 받아올 수 있지만, SwiftUI View 용으로만 사용한다면 전용 프로퍼티 래퍼 `@RemoteConfigProperty` 사용을 고려해봐도 좋습니다. 아래와 같이 추가하면 정말 쉽게 실시간 View 업데이트를 사용할 수 있게 됩니다!&lt;/p&gt;
&lt;pre id=&quot;code_1763868234798&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;struct ContentView: View {
  @RemoteConfigProperty(key: &quot;puppy_name&quot;, fallback: &quot;기본 민톨&quot;) var puppyName

  var body: some View {
    VStack {
      Text(puppyName)
    }
    .onAppear {
      RemoteConfig.remoteConfig().fetchAndActivate()
    }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;역시 좋은 기능은 잘 찾아오면 이미 있다,,, ㅎㅎ&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최근 회사 프로젝트에서 광고 SDK 관련 민감한 로직의 분기 처리를 빠르게 적용하기 위해 Sever-Driven 대신 Firebase Remote Config를 사용했었습니다. 이미 여기저기 사용 중이었기 때문에 이미 있던 값들을 가져다 쓰기만 했었는데, 역시 이렇게 직접 구현해보니 더 제 것이 되는 것 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예전 캐플 프로젝트 때도 Remote Config를 적극적으로 활용했으면 서버 분들께 부담 드리지 않고 다양한 이벤트를 진행해 볼 수 있었을 것 같다는 아쉬움도 남네요 ㅜ  &lt;/p&gt;</description>
      <category>실무 내용 내껄로 만들기</category>
      <category>Firebase</category>
      <category>ios</category>
      <category>remote config</category>
      <author>thinkyside</author>
      <guid isPermaLink="true">https://thinkyside.tistory.com/114</guid>
      <comments>https://thinkyside.tistory.com/114#entry114comment</comments>
      <pubDate>Sun, 23 Nov 2025 12:28:42 +0900</pubDate>
    </item>
    <item>
      <title>팩트풀니스 - 그럼에도 불구하고 세계는 생각보다 괜찮다</title>
      <link>https://thinkyside.tistory.com/113</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Book.jpg&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bGpLHB/dJMcafSsn9B/Y7OaHdQpeHLuPDeQ1Ki85K/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bGpLHB/dJMcafSsn9B/Y7OaHdQpeHLuPDeQ1Ki85K/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bGpLHB/dJMcafSsn9B/Y7OaHdQpeHLuPDeQ1Ki85K/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbGpLHB%2FdJMcafSsn9B%2FY7OaHdQpeHLuPDeQ1Ki85K%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;300&quot; height=&quot;300&quot; data-filename=&quot;Book.jpg&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;1024&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;오랜만에 책을 직접 사서 읽었다. 밀리의 서재 등을 이용해 읽는 방식도 한 때 시도해 봤지만 역시 종이책의 감성과 질감을 포기하기란 쉽지 않다. 책을 마음껏 사용하고 싶은 마음도 있었다. 도서관에서 빌려온 책들이 아무렇게나 접혀있거나 세월의 흔적들을 보는 게 즐겁긴 하지만 내 생각을 펼치기엔 아무래도 모두가 읽을 책이라는 점에서 양심에 걸리니 말이다. 직접 사서 읽는 경험이 꽤나 좋았기 때문에 앞으로도 읽고 싶은 책이 있을 때 자주 서점을 들락 나락 해볼 생각이다.&lt;/p&gt;
&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;이렇게 느낄 수 있었던 것은 책 제목에서도 드러나있듯 '팩트풀니스', 즉 '사실충실성'에 기반한 객관적 수치와 자료를 바탕으로 한 내용들이었기 때문이다. 세계는 객관적으로 긍정적인 방향으로 발전해가고 있고, 우리가 이를 정확히 판단하지 못하는 이유는 오랜 세월 축적되어 온 인간의 본능 때문이다. 어렵겠지만 이제는 정확한 판단과 진단, 그리고 실천 가능한 해결 방안을 위해 '사실충실성'에&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;책에서는 세계에 대해서 이야기했지만, 나는 개인의 인생에 있어서도 '사실충실성'이 필요하다 느낀다. 10가지 본능은 스스로의 인생에 대해서도 과도하고 부정적으로 인식하게 만든다. 늘 뒤쳐저있는 것 같고, 잘하고 있는지 확신이 들지 않고, 어떻게 하면 더 잘할 수 있을까 궁리하며 스트레스를 받는다. 하지만 중요한 건 나는 조금씩 성장하고 있다는 것이다. 스스로 만족하지 못하고 나쁜 상태라고 생각이 들어도, 긍정적인 변화를 축적하고 있다. 그 사실은 변함이 없고, 앞으로도 그러기 위해 노력할 것이다. 그러니 불필요한 공포감은 내려놓자. 나는 낙관주의자가 되려는 것이 아닌 실천주의자가 되려는 것이다.&lt;/p&gt;</description>
      <category>생각을 담은 독서</category>
      <category>factfulness</category>
      <category>독서</category>
      <category>팩트풀니스</category>
      <category>한스 로슬링</category>
      <author>thinkyside</author>
      <guid isPermaLink="true">https://thinkyside.tistory.com/113</guid>
      <comments>https://thinkyside.tistory.com/113#entry113comment</comments>
      <pubDate>Sat, 8 Nov 2025 17:41:57 +0900</pubDate>
    </item>
    <item>
      <title>딥링크, URL 스킴, 유니버셜 링크, 다이나믹 링크 뭐가 뭔데?</title>
      <link>https://thinkyside.tistory.com/112</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;어떤 버튼을 눌렀을 때 특정 앱이 열리는 기능은 우리에게 익숙합니다. 그런데 도대체 이 기능을 뭐라고 불러야 할까요? 어디서는 딥링크, 어디서는 URL 스킴, 유니버셜&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;오늘은 iOS 앱 개발 관점에서 알쏭달쏭 헷갈리는 용어들에 대해 확실하게 이해해 보는 시간을 가져보겠습니다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;사실은 모두 딥링크&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이것저것 단어가 많지만 사실은 모두 '딥링크'라고 할 수 있습니다. URL 스킴, 유니버셜 링크, 다이나믹 링크 등 &lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;&quot;앱 내 특정 페이지로 바로 연결되는 모든 기술&quot;은 딥링크&lt;/b&gt;&lt;/span&gt;라는 큰 기술 카테고리에 묶인다고 이해하면 됩니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1586&quot; data-origin-height=&quot;1204&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b6UT0z/dJMb9X5qpqT/5kB38y2GW97rKYvYrm5eP1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b6UT0z/dJMb9X5qpqT/5kB38y2GW97rKYvYrm5eP1/img.png&quot; data-alt=&quot;딥링크 == 일반적인 용어(대 카테고리)&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b6UT0z/dJMb9X5qpqT/5kB38y2GW97rKYvYrm5eP1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb6UT0z%2FdJMb9X5qpqT%2F5kB38y2GW97rKYvYrm5eP1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;500&quot; height=&quot;380&quot; data-origin-width=&quot;1586&quot; data-origin-height=&quot;1204&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;딥링크 == 일반적인 용어(대 카테고리)&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;가장 전통적인 방법, URL Scheme&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;URL Scheme은 딥링크의 가장 &lt;b&gt;전통적인 방식&lt;/b&gt;입니다. 앱이 고유하게 등록한 URL 형식을 사용해 앱을 실행하는 방식으로 `myphotoapp:albumname?name=&quot;albumname&quot;`과 같은 구조로 사용됩니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;다른 앱의 실행을 요청하거나 간단한 데이터 전달 가능.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;앱이 설치되어 있을 때만&lt;/b&gt; 작동.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;iOS 9.0 이전&lt;/b&gt;까지의 표준 딥링크 방식.&lt;/li&gt;
&lt;li&gt;앱에 잠재적인 공격 경로를 제공할 수 있기 때문에, 매개변수의 유효성을 검사하고 잘못된 URL은 삭제해야 함.(특히 개인정보를 포함하는 것은 위험!)&lt;/li&gt;
&lt;li&gt;앱스토어에서 &lt;b&gt;Scheme의 고유성을 보장해주지 않기 때문&lt;/b&gt;에 다른 곳에서 무단 사용할 수 있음.(이 때문에 애플은&amp;nbsp;아래에서&amp;nbsp;후술 할&amp;nbsp;유니버설&amp;nbsp;링크&amp;nbsp;사용을&amp;nbsp;권장함.)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;애플에서 제공하는 URL Scheme 공식 문서, &lt;a href=&quot;https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Defining a custom URL scheme for your app&lt;/a&gt;를 참고해 직접 URL Scheme을 가지고 놀아보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. 내 앱으로 이동할 수 있는 URL Scheme 추가해 보기&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Xcode의 Targets - Info - URL Types에 새로운 URL Scheme을 추가해 보겠습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1410&quot; data-origin-height=&quot;548&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/eGjF5k/dJMb9P0InPd/oamzmzQJ7nT6TFBrvW5Kw0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/eGjF5k/dJMb9P0InPd/oamzmzQJ7nT6TFBrvW5Kw0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/eGjF5k/dJMb9P0InPd/oamzmzQJ7nT6TFBrvW5Kw0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FeGjF5k%2FdJMb9P0InPd%2FoamzmzQJ7nT6TFBrvW5Kw0%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;720&quot; height=&quot;280&quot; data-origin-width=&quot;1410&quot; data-origin-height=&quot;548&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 첫 번째 필드인 &lt;b&gt;Identifier에는 다른 앱과 내 앱을 구분하는 데 사용될 식별자를 지정&lt;/b&gt;합니다. 애플 공식문서에서는 고유성을 보장하기 위해 회사 도메인(저의 경우 개발자 이름!)과 앱 이름을 포함하는 역방향 DNS 문자열 지정을 권장합니다.(하지만 근본적으로 다른 앱과 동일한 Scheme 등록을 막을 수는 없다고 합니다  )&lt;/p&gt;
&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;URL Schemes에는 딥링크 URL 앞에 들어갈 접두사&lt;/b&gt;를 지정합니다. 저의 경우 앱 이름인 'poporazzi'를 지정해 주었으며, 앞으로 `poporazzi://`의 형태로 앱이 열릴 수 있게 만들어 줄 수 있을 겁니다!&lt;/p&gt;
&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;일부 URL Scheme은 애플 시스템 전용으로 예약&lt;/b&gt;되어 있습니다. 기본 메일, 전화, 지도, 음악 앱 등이 이에 포함되어 있으며 자세한 내용은 &lt;a href=&quot;https://developer.apple.com/library/archive/featuredarticles/iPhoneURLScheme_Reference/Introduction/Introduction.html#//apple_ref/doc/uid/TP40007899&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;App URL Scheme Reference&lt;/a&gt; 페이지에서 확인할 수 있습니다.(한 마디로 애플이 미리 등록해 놓은 URL Scheme은 사용 못 한다~ 얘기입니닷)&lt;/p&gt;
&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;size14&quot;&gt;세 번째 필드인 &lt;b&gt;Icon은 URL Scheme과 연결된 문서 아이콘을 지정&lt;/b&gt;합니다. 딥링크로 열 수 있는 파일이나 문서 등을 표시할 때 사용된다고 하는데, 일반적인 경우엔 사용하지 않는다고 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;네 번째 필드인 &lt;b&gt;Role은 앱이 URL을 어떻게 처리하는지 정의&lt;/b&gt;합니다. 지정할 수 있는 옵션은 Viewer(읽기 전용), Editor(읽기/쓰기), None(역할 정의 X) 3가지로 나뉘는데 식별을 위한 요소일 뿐이지 기능적인 영향은 없다고 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;그 아래 Additional url type properties에는 커스텀 속성을 정의할 수 있습니다. URL Scheme의 버전 정보나 설명 등을 추가할 수 있다고 하는데 이 또한 기능적인 영향은 없어 대부분의 딥링크 구현에선 비워도 무방합니다.&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. URL Scheme으로 내 앱 열어보기&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요렇게 만들어본 URL Scheme으로 제 앱을 열어봐야겠죠? 위에서도 언급했듯, &lt;b&gt;URL Scheme은 앱이 설치되어 있을 때만 동작&lt;/b&gt;하기 때문에 꼭 새로 빌드 후 테스트 해보는 걸 잊지 않으며, Safari에 URL Scheme을 입력해 보면 아주 잘 열리는 것을 확인할 수 있습니다!&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Simulator Screen Recording - iPhone 17 - 2025-10-26 at 14.32.15.gif&quot; data-origin-width=&quot;295&quot; data-origin-height=&quot;640&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/0uyZI/dJMb9c2BmU8/Ksf2tKJvjNqBSKIxUpd6y1/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/0uyZI/dJMb9c2BmU8/Ksf2tKJvjNqBSKIxUpd6y1/img.gif&quot; data-alt=&quot;위에서 지정한 접두사로 딥링크 입력&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/0uyZI/dJMb9c2BmU8/Ksf2tKJvjNqBSKIxUpd6y1/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/0uyZI/dJMb9c2BmU8/Ksf2tKJvjNqBSKIxUpd6y1/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;295&quot; height=&quot;640&quot; data-filename=&quot;Simulator Screen Recording - iPhone 17 - 2025-10-26 at 14.32.15.gif&quot; data-origin-width=&quot;295&quot; data-origin-height=&quot;640&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;++ 만약 앱이 설치되어 있지 않다면, URL Scheme을 찾을 수 없기 때문에 Safari에서 유효하지 않은 주소라고 알려주게 됩니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;780&quot; data-origin-height=&quot;326&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kUgqC/dJMb9Nu3t1A/LCOghPQkh5aYmAfAX8KEy1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kUgqC/dJMb9Nu3t1A/LCOghPQkh5aYmAfAX8KEy1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kUgqC/dJMb9Nu3t1A/LCOghPQkh5aYmAfAX8KEy1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkUgqC%2FdJMb9Nu3t1A%2FLCOghPQkh5aYmAfAX8KEy1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;500&quot; height=&quot;209&quot; data-origin-width=&quot;780&quot; data-origin-height=&quot;326&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;h4 data-ke-size=&quot;size20&quot;&gt;3. URL Scheme으로 들어온 이벤트 핸들링하기&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;URL Scheme으로 들어온 이벤트에 대해 특정 행동을 하거나, URL 매개변수를 파싱해 특정 화면으로 이동시키고 싶은 경우엔 `SceneDelegate`의 LifeCycle에서 이벤트를 핸들링할 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1761458409150&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 1. 앱이 실행되지 않은 상태에서 URL Scheme이 실행될 때
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    if let url = connectionOptions.urlContexts.first?.url {
        // 여기서 핸들링!
    }
}

// 2. 앱이 이미 실행 중일 때 딥링크가 열릴 때
func scene(_ scene: UIScene, openURLContexts URLContexts: Set&amp;lt;UIOpenURLContext&amp;gt;) {
    if let url = URLContexts.first?.url {
        // 여기서 핸들링!
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 옵셔널 바인딩 되는 &lt;b&gt;`URL` 타입을 가지고 host, path, query 등을 추출해 다양한 URL Scheme에 따라 분기 처리&lt;/b&gt;를 하면 되겠죠? 주의해야 할 점은, &lt;b&gt;앱의 실행 여부에 따라 실행되는 SceneDelegate 내 메서드가 다르기 때문에 목적에 따라 구현&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. 내 앱에서 다른 앱의 URL Scheme 열기&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`UIApplication`에서 제공하는 `open` 함수를 사용해 다른 앱의 URL Scheme을 열 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1761459837376&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;if let url = URL(string: &quot;poporazzi://&quot;) {
    UIApplication.shared.open(url) { result in
        if result {
           // URL Scheme 실행 결과 handler
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Simulator Screen Recording - iPhone 17 - 2025-10-26 at 15.27.14.gif&quot; data-origin-width=&quot;295&quot; data-origin-height=&quot;640&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b6f08c/dJMb9LqtmuW/6kKkU3FZx6kNtL0c0Vz3e0/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b6f08c/dJMb9LqtmuW/6kKkU3FZx6kNtL0c0Vz3e0/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b6f08c/dJMb9LqtmuW/6kKkU3FZx6kNtL0c0Vz3e0/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/b6f08c/dJMb9LqtmuW/6kKkU3FZx6kNtL0c0Vz3e0/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;295&quot; height=&quot;640&quot; data-filename=&quot;Simulator Screen Recording - iPhone 17 - 2025-10-26 at 15.27.14.gif&quot; data-origin-width=&quot;295&quot; data-origin-height=&quot;640&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;h3 data-ke-size=&quot;size23&quot;&gt;현재 표준이 된 유니버셜 링크 Universal Link&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유니버셜 링크는 &lt;b&gt;Apple의 공식 딥링크 기술&lt;/b&gt;입니다.(안드로이드의 App Link와 대응) 위에서 소개한 URL Scheme의 한계점인 앱 설치의 필요성, 고유성 보장, 보안 등의 문제를 해결해 줄 수 있어 현재 가장 보편적으로 사용되고 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;https://myphotoapp.com/name/albumname 형식으로 사용.(일반 HTTPS 형식을 따름)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;앱 미설치 시 웹사이트로 이동할 수 있게 처리 가능.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;iOS 9.0 이후 &lt;b&gt;표준 딥링크 방식.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;앱의 Bundle Id를 미리 특정 도메인의 웹사이트에 등록하는 방식으로, &lt;b&gt;고유성을 보장&lt;/b&gt;할 수 있음.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;일반 웹 URL&lt;/b&gt;이기 때문에 SEO 최적화.&lt;/li&gt;
&lt;li&gt;Apple이 서버 인증서(AASA)로 검증해 &lt;b&gt;보안 향상.&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 보면 URL Scheme을 사용하지 않을 이유가 없어 보이지만, &lt;b&gt;도메인과 서버 설정이 필수적이기 때문에 구현과 테스트가 어렵&lt;/b&gt;다는 단점도 존재합니다. 그래서 작은 앱을 만들 때는 URL Scheme을 사용하는 것이 효율적일 수도 있겠습니다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트 입장에서 유니버셜 링크를 테스트해보고 싶기 때문에 간단하게 Vercel을 사용해 진행해 보겠습니다.(이 과정에서 서버 관련 지식이 한참 부족하다 보니 좀 헤매버렸습니다,,)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. 서버&amp;nbsp; - AASA(apple-app-site-association) 파일 추가&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유니버셜 링크를 사용하기 위해 필수적인 것이 도메인과 서버 설정입니다. 이 때 &lt;b&gt;도메인 정보가 포함된 JSON 파일인 AASA&lt;/b&gt;(apple-app-site-association) 파일을 서버에 업로드 해야 해당 파일을 확인 후 앱을 실행시켜주는 프로세스입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AASA 파일 구성 시 중요한 부분은 appID와 components(path)입니다. app ID에는 {Team-ID}.{Bundle-ID} 로 구성된 값을 넣어주고, components에는 이동할 경로를 명시합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1761478064549&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
  &quot;applinks&quot;: {
    // 유니버셜 링크에서는 사용하지 않지만 빈 배열로 명시 필요
    &quot;apps&quot;: [],
    // 웹사이트에서 이동 가능한 앱 목록.
    &quot;details&quot;: [
      {
        // {Team-ID}.{Bundle-ID}
        &quot;appIDs&quot;: [&quot;9XG4S4XZWN.com.thinkyside.poporazzi&quot;],
        // 웹사이트 경로
        &quot;components&quot;: [
          {
            &quot;/&quot;: &quot;*&quot;
          }
        ]
      }
    ]
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 구성 시 확인해야 할 점은 HTTPS와 SSL 지원 여부입니다. 애플의 보안 정책 때문인지 기본적으로 챙길 수 있는 것들은 체크가 필요할 듯 합니다! 또한 AASA 파일은 웹사이트 내 표준화된 메타데이터나 설정 파일을 저장하는 디렉토리인 `.well-known`에 추가되어야 합니다.&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. 클라이언트 - Associated Domains(Capabilities) 설정&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트(iOS 개발자)는 Xcode를 이용해 Associated Domains Capabilities를 설정해 주어야 합니다. Targets - Signing &amp;amp; Capabilites 탭에서 + Capability 버튼을 클릭합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;6100&quot; data-origin-height=&quot;1308&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cx9gpi/dJMb9Nu3zD7/Ln6SAGjPyUqoBaKm0Pkfj1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cx9gpi/dJMb9Nu3zD7/Ln6SAGjPyUqoBaKm0Pkfj1/img.png&quot; data-alt=&quot;Targets - Signing &amp;amp;amp; Capabilites 탭에서 + Capability 버튼 클릭&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cx9gpi/dJMb9Nu3zD7/Ln6SAGjPyUqoBaKm0Pkfj1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcx9gpi%2FdJMb9Nu3zD7%2FLn6SAGjPyUqoBaKm0Pkfj1%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;720&quot; height=&quot;154&quot; data-origin-width=&quot;6100&quot; data-origin-height=&quot;1308&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Targets - Signing &amp;amp; Capabilites 탭에서 + Capability 버튼 클릭&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 유니버셜 링크를 설정해 줄 수 있는 Capability인 Associated Domains를 클릭해 추가해줍니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2848&quot; data-origin-height=&quot;2160&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/z35BJ/dJMb83EAJmQ/eIkdy6nYc2zkI4XkYXjZn0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/z35BJ/dJMb83EAJmQ/eIkdy6nYc2zkI4XkYXjZn0/img.png&quot; data-alt=&quot;Associated Domains 검색 후 추가&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/z35BJ/dJMb83EAJmQ/eIkdy6nYc2zkI4XkYXjZn0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fz35BJ%2FdJMb83EAJmQ%2FeIkdy6nYc2zkI4XkYXjZn0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;500&quot; height=&quot;379&quot; data-origin-width=&quot;2848&quot; data-origin-height=&quot;2160&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Associated Domains 검색 후 추가&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요렇게 추가된 Associated Domains 아래 Domains Row에 우리의 도메인을 추가하면 완료입니다. 아래 추가할 도메인은 `applinks:{YOUR_DOMAIN}` 형식으로 작성되어야 합니다. 저는 vercel에서 내려준 도메인을 입력해주었습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1158&quot; data-origin-height=&quot;296&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/KPOAD/dJMb9LjIkNu/SwG0EM4ElkZL6IvFPKMpMk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/KPOAD/dJMb9LjIkNu/SwG0EM4ElkZL6IvFPKMpMk/img.png&quot; data-alt=&quot;applinks:{YOUR_DOMAIN} 추가&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/KPOAD/dJMb9LjIkNu/SwG0EM4ElkZL6IvFPKMpMk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FKPOAD%2FdJMb9LjIkNu%2FSwG0EM4ElkZL6IvFPKMpMk%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;720&quot; height=&quot;184&quot; data-origin-width=&quot;1158&quot; data-origin-height=&quot;296&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;applinks:{YOUR_DOMAIN} 추가&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 테스트 해보면! 딥링크가 아주 잘 동작하는 것을 확인할 수 있습니다.(만약 딥링크가 동작을 안한다면 10~20분 정도 기다려본 후 다시 시도해보는게 도움이 될 수 있습니다! Apple CDN이 캐시를 업데이트 하는 시간이 필요하다고 하네요,,, &amp;gt;&amp;gt; 이것 때문에 다른 곳에서 삽질 엄청함)&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Simulator Screen Recording - iPhone 17 - 2025-10-26 at 20.18.57.gif&quot; data-origin-width=&quot;295&quot; data-origin-height=&quot;640&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/OHpzU/dJMb89Slqjw/24WFIvxRhybf0NdKwX50g0/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/OHpzU/dJMb89Slqjw/24WFIvxRhybf0NdKwX50g0/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/OHpzU/dJMb89Slqjw/24WFIvxRhybf0NdKwX50g0/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/OHpzU/dJMb89Slqjw/24WFIvxRhybf0NdKwX50g0/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;295&quot; height=&quot;640&quot; data-filename=&quot;Simulator Screen Recording - iPhone 17 - 2025-10-26 at 20.18.57.gif&quot; data-origin-width=&quot;295&quot; data-origin-height=&quot;640&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;h4 data-ke-size=&quot;size20&quot;&gt;3. 유니버셜 링크로 들어온 이벤트 핸들링하기&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유니버셜 링크 또한 `SceneDelegate`에서 이벤트를 핸들링할 수 있는데 `URLContext`를 이용했던 URL Scheme과 달리, `NSUserActivity`를 이용해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://developer.apple.com/documentation/foundation/nsuseractivity&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;NSUserActivity&lt;/a&gt;는 특정 시점의 앱 상태를 나타내는 객체로, 사용자가 수행한 작업에 대한 정보를 캡처합니다. 보통 그 아래 속성인 `webPageURL`을 이용해 URL 타입을 반환 받고 path, query에 따라 분기 처리를 진행하면 됩니다.&lt;/p&gt;
&lt;pre id=&quot;code_1761479680519&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 1. 앱이 실행되지 않은 상태에서 URL Scheme이 실행될 때
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    if let url = connectionOptions.userActivities.first?.webpageURL {
        // 여기서 핸들링!
    }
}

// 2. 앱이 이미 실행 중일 때 딥링크가 열릴 때
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
    if let url = userActivity.webpageURL {
        // 여기서 핸들링!
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앱이 실행되지 않을 때는 URL Scheme과 마찬가지로 `willConnectTo` Delegate 메서드를 이용하면 되지만, &lt;b&gt;앱이 이미 실행 중일 때는 `&lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;continueUserActivity&lt;/span&gt;` Delegate 메서드를 사용해야 한다는 점&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;다른 앱의 유니버셜 링크를 여는 방법은 URL Scheme과 동일합니다~!&lt;/p&gt;
&lt;pre id=&quot;code_1761480302550&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;if let url = URL(string: &quot;https://poporazzi.vercel.app/home&quot;) {
    UIApplication.shared.open(url)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;하나의 링크로 여러 플랫폼을 관리하는 똑똑한 다이나믹 링크 Dynamic Link&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막은 다이나믹 링크입니다. 보통 앱 개발 시 iOS와 안드로이드 OS를 지원하게 되는데, 유니버셜 링크의 경우 애플의 독자적인 딥링크 기술이기 때문에 안드로이드에서의 딥링크는 앱 링크(App Link)로 별도 구현이 필요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이는 딥링크가 새로 추가될 때마다 작업량도 두배, 수정될 때도 체크해야 할 포인트가 두배가 되기 때문에 유지보수성이 떨어질 수 밖에 없게 됩니다. 이를 해결해줄 수 있도록 하나의 링크로 모든 OS에 대한 딥링크를 연결해주는 &lt;a href=&quot;https://www.appsflyer.com/ko/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;AppsFlyer&lt;/a&gt;, &lt;a href=&quot;https://www.branch.io/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Branch.io&lt;/a&gt; 등이 제공하는 것이 다이나믹 링크입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1766&quot; data-origin-height=&quot;1230&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/AQ3LG/dJMb9YiXFlO/V2eApGRNvGQpbrDscXpcCk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/AQ3LG/dJMb9YiXFlO/V2eApGRNvGQpbrDscXpcCk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/AQ3LG/dJMb9YiXFlO/V2eApGRNvGQpbrDscXpcCk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FAQ3LG%2FdJMb9YiXFlO%2FV2eApGRNvGQpbrDscXpcCk%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;720&quot; height=&quot;501&quot; data-origin-width=&quot;1766&quot; data-origin-height=&quot;1230&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;다이나믹 링크를 지원하는 서비스에 따라 조금씩 다르지만, 크게 아래와 같은 기능들을 제공합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;하나의 링크로 iOS/Android &lt;b&gt;멀티 플랫폼 딥링크&lt;/b&gt; 지원.(각각의 기술로 컨버팅)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Deferred Deep Link&lt;/b&gt; 기술 지원.(앱 미설치 사용자에게도 딥링크 정보를 전달할 수 있는 기술, 이건 별도 포스팅 해보면 좋을듯!)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;링크 단축&lt;/b&gt; 기능 제공.&lt;/li&gt;
&lt;li&gt;링크 클릭 수, 앱 설치 전환율, 플랫폼별 통계 등 &lt;b&gt;데이터 제공&lt;/b&gt;.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;A/B 테스팅&lt;/b&gt; 지원.&lt;/li&gt;
&lt;/ul&gt;
&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;h3 data-ke-size=&quot;size23&quot;&gt;최종 정리&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 104px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style5&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 19px; text-align: center;&quot;&gt;&lt;b&gt;기능&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px; text-align: center;&quot;&gt;&lt;b&gt;URL Scheme&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px; text-align: center;&quot;&gt;&lt;b&gt;Universal Link&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px; text-align: center;&quot;&gt;&lt;b&gt;Dynamic Link&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 17px; text-align: center;&quot;&gt;&lt;b&gt;형식&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px; text-align: center;&quot;&gt;myapp://&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px; text-align: center;&quot;&gt;https://myapp.com&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px; text-align: center;&quot;&gt;https://myapp.page.link&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 17px; text-align: center;&quot;&gt;&lt;b&gt;iOS 버전&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px; text-align: center;&quot;&gt;iOS 9.0 미만&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px; text-align: center;&quot;&gt;iOS 9.0 이상&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px; text-align: center;&quot;&gt;iOS 9.0 이상&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 17px; text-align: center;&quot;&gt;&lt;b&gt;앱 미설치 시 동작&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px; text-align: center;&quot;&gt;&amp;nbsp;❌ 경고 발생&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px; text-align: center;&quot;&gt;✅ 웹페이지 이동&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px; text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #f8fbfb; color: #333333; text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #f8fbfb; color: #333333; text-align: center;&quot;&gt;&amp;nbsp;✅ 스토어&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span&gt;&amp;rarr; 설치&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&amp;rarr; 딥링크(Deferred)&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 17px; text-align: center;&quot;&gt;&lt;b&gt;도메인 필요 여부&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px; text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #f8fbfb; color: #333333; text-align: center;&quot;&gt;❌ 불필요&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px; text-align: center;&quot;&gt;&amp;nbsp;✅ 필수&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px; text-align: center;&quot;&gt;&amp;nbsp;✅ 필수(서비스가 제공해줌)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 17px; text-align: center;&quot;&gt;&lt;b&gt;서버 설정&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px; text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #f8fbfb; color: #333333; text-align: center;&quot;&gt;❌ 불필요&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px; text-align: center;&quot;&gt;✅ 필수(AASA 파일 추가)&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px; text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #f8fbfb; color: #333333; text-align: center;&quot;&gt;❌ 서비스가 관리&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 25%; text-align: center;&quot;&gt;&lt;b&gt;고유성 보장&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #f8fbfb; color: #333333; text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #f8fbfb; color: #333333; text-align: center;&quot;&gt;❌ 보장하지 않음&lt;/span&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; text-align: center;&quot;&gt;&amp;nbsp;✅ 도메인으로 보장&lt;/td&gt;
&lt;td style=&quot;width: 25%; text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #f8fbfb; color: #333333; text-align: center;&quot;&gt;&amp;nbsp;✅ 서비스가 보장&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 25%; text-align: center;&quot;&gt;&lt;b&gt;보안&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #f8fbfb; color: #333333; text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #f8fbfb; color: #333333; text-align: center;&quot;&gt;&amp;nbsp;⚠️&amp;nbsp;낮음&lt;/span&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; text-align: center;&quot;&gt;&amp;nbsp;✅ 높음(Apple 검증)&lt;/td&gt;
&lt;td style=&quot;width: 25%; text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #f8fbfb; color: #333333; text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #f8fbfb; color: #333333; text-align: center;&quot;&gt;&amp;nbsp;✅ 높음&lt;/span&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 25%; text-align: center;&quot;&gt;&lt;b&gt;크로스플랫폼&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #f8fbfb; color: #333333; text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #f8fbfb; color: #333333; text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #f8fbfb; color: #333333; text-align: center;&quot;&gt;❌ OS별 구현&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #f8fbfb; color: #333333; text-align: center;&quot;&gt;❌ OS별 구현&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #f8fbfb; color: #333333; text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #f8fbfb; color: #333333; text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #f8fbfb; color: #333333; text-align: center;&quot;&gt;&amp;nbsp;✅ 하나로 관리 가능&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 25%; text-align: center;&quot;&gt;&lt;b&gt;추천 용도&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #f8fbfb; color: #333333; text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #f8fbfb; color: #333333; text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #f8fbfb; color: #333333; text-align: center;&quot;&gt;앱 간 통신, 간단한 앱&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #f8fbfb; color: #333333; text-align: center;&quot;&gt;표준 딥링크, 비즈니스 레벨의 앱&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #f8fbfb; color: #333333; text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #f8fbfb; color: #333333; text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #f8fbfb; color: #333333; text-align: center;&quot;&gt;유지보수, 마케팅이 필요한 앱&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>내게 필요한 개발 공부</category>
      <category>ios</category>
      <category>URL 스킴</category>
      <category>다이나믹 링크</category>
      <category>딥링크</category>
      <category>앱스킴</category>
      <category>유니버셜 링크</category>
      <author>thinkyside</author>
      <guid isPermaLink="true">https://thinkyside.tistory.com/112</guid>
      <comments>https://thinkyside.tistory.com/112#entry112comment</comments>
      <pubDate>Sun, 26 Oct 2025 21:45:44 +0900</pubDate>
    </item>
    <item>
      <title>Firebase App Distribution + Fastlane으로 초간단 QA 빌드 뽑아보기</title>
      <link>https://thinkyside.tistory.com/111</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;아카데미에서 개발을 하지 않는 팀원분들께 중간중간 만들어진 앱을 공유, 즉 QA를 위해 사용했던 방법은 이랬습니다. 아이폰을 맥북에 연결해 직접 빌드하기, 혹은 TestFlight를 이용해 내부 테스터로 초대하기 등이었죠.(그냥 제 아이폰을 주는 방법도 있었답니다) 그리고 생각보다 아이폰을 직접 연결해 빌드한다는 단순무식(?)한 방법을 자주 사용하곤 했는데, TestFlight라는 좋은 Tool을 놔두고 왜 그랬을까요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;기존 배포의 불편한 점&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://developer.apple.com/kr/testflight/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;TestFlight&lt;/a&gt;는 애플이 제공하는 공식 베타 테스트 플랫폼입니다. TestFlight에 초대되었다면 누구든 베타 버전을 쉽게 받아볼 수 있죠. 하지만 TestFlight의 배포 프로세스는 생각보다 오래 걸립니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Xcode 빌드 및 Archive&lt;/li&gt;
&lt;li&gt;애플 서버에 업로드&lt;/li&gt;
&lt;li&gt;애플 서버 내에서 유효성 검사 및 처리(이 과정은 수분에서 수십 분 이상 소요)&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 과정은 앱 크기나 환경에 따라 달라질 수 있겠지만 제 경험 상 &lt;b&gt;약 20분 이상 소요&lt;/b&gt;됩니다. 아카이브 빌드가 되길 기다리고,,, App Store Connect 설정을 마치고,,, 업로드가 되더라도 언제 완료될지 모르는 유효성 검사를 기다려야 합니다.(이 과정이 생각보다 김,,,) 한마디로 귀찮고 비효율적인 작업이죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TestFlight의 배포 프로세스가 이렇게 귀찮고 꽤나 오래걸리다보니, 급한 상황에선 가장 빠르고 확실한 디바이스 연결을 통한 직접 빌드 방식을 많이 선택하게 되었습니다. 물론 이 방법은 서로 오프라인 내 같은 공간에 있어야 하다 보니 절대 좋은 방법이라고 이야기할 수는 없었습니다.(팀원과의 돈독함이 올라가는 긍정적인 효과는 있었지만 말이죠)&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;792&quot; data-origin-height=&quot;714&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bYpHz2/dJMb84cpkkF/m8pLv5DkKbEXcBrKiyHx80/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bYpHz2/dJMb84cpkkF/m8pLv5DkKbEXcBrKiyHx80/img.png&quot; data-alt=&quot;나노 바나나가 나름 적절한 짤을 뽑아줬다&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bYpHz2/dJMb84cpkkF/m8pLv5DkKbEXcBrKiyHx80/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbYpHz2%2FdJMb84cpkkF%2Fm8pLv5DkKbEXcBrKiyHx80%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;361&quot; data-origin-width=&quot;792&quot; data-origin-height=&quot;714&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;나노 바나나가 나름 적절한 짤을 뽑아줬다&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;처음 맛본 배포 자동화&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇게 입사 후 얼마 안 됐을 때쯤, 회사에서 QA 빌드를 뽑아야 한다 들었을 때 자연스럽게 TestFlight를 통한 배포를 생각했습니다. 하지만 그것보다 훨씬 빠르고 효과적인 방법을 사용하고 있었습니다! 터미널에 `fastlane ios dev`라고 명령하면 자동으로 배포 프로세스가 돌고 약 10분 이후 Firebase App Distribution에서 QA 빌드를 설치할 수 있다는 메일을 확인할 수 있었습니다.&lt;/p&gt;
&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;TestFlight 대비 훨씬 빠르게 배포&lt;/b&gt;할 수 있었습니다. 그래서 오늘은 제가 맛본 자동화를 도와줄 Firebase App Distribution + Fastlane을 직접 적용해 보며 정리해 보겠습니다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Firebase App Distribution을 통한 빠른 배포&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;822&quot; data-origin-height=&quot;373&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cttHFD/dJMb9LKKcSS/Z1UtCHfbBySUcLzK6CIsG0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cttHFD/dJMb9LKKcSS/Z1UtCHfbBySUcLzK6CIsG0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cttHFD/dJMb9LKKcSS/Z1UtCHfbBySUcLzK6CIsG0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcttHFD%2FdJMb9LKKcSS%2FZ1UtCHfbBySUcLzK6CIsG0%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;373&quot; data-origin-width=&quot;822&quot; data-origin-height=&quot;373&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://firebase.google.com/docs/app-distribution?hl=ko&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Firebase App Distribution&lt;/a&gt;은 Google Firebase에서 제공하는 모바일 앱 배포 플랫폼으로 아래 주요 기능을 제공합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;크로스 플랫폼 지원(iOS 및 Android 플랫폼 관리)&lt;/li&gt;
&lt;li&gt;빠른 배포(SDK를 설치할 필요 없이 신속한 온보딩, 인스턴트 앱 배포)&lt;/li&gt;
&lt;li&gt;CI 통합 기능 제공(fastlane 등)&lt;/li&gt;
&lt;li&gt;테스터 관리&lt;/li&gt;
&lt;li&gt;Crashlytics 연동&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이것저것 장점이 많지만 가장 중요한 기능은 아무래도 &lt;b&gt;빠른 배포와 CI 통합 기능 제공&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;1. 테스터 기기 등록&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 테스터들이 다운로드 받을 기기를 등록합니다. UUID를 통한 등록이며, 등록에 따른 시간도 소요되기 때문에 가장 먼저 추가하는 것을 권장합니다!&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;6364&quot; data-origin-height=&quot;1744&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/xTRSW/dJMb9Lqrm8E/4I6USoKkI4pROudim8G7b0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/xTRSW/dJMb9Lqrm8E/4I6USoKkI4pROudim8G7b0/img.png&quot; data-alt=&quot;Devices + 클릭&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/xTRSW/dJMb9Lqrm8E/4I6USoKkI4pROudim8G7b0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FxTRSW%2FdJMb9Lqrm8E%2F4I6USoKkI4pROudim8G7b0%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;720&quot; height=&quot;197&quot; data-origin-width=&quot;6364&quot; data-origin-height=&quot;1744&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Devices + 클릭&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;6256&quot; data-origin-height=&quot;3616&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/E5nSu/dJMb9MXbpMi/TKFAf0y93opLcKA6kX2b51/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/E5nSu/dJMb9MXbpMi/TKFAf0y93opLcKA6kX2b51/img.png&quot; data-alt=&quot;Device Name, UUID 입력 후 Continue 클릭&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/E5nSu/dJMb9MXbpMi/TKFAf0y93opLcKA6kX2b51/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FE5nSu%2FdJMb9MXbpMi%2FTKFAf0y93opLcKA6kX2b51%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;720&quot; height=&quot;416&quot; data-origin-width=&quot;6256&quot; data-origin-height=&quot;3616&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Device Name, UUID 입력 후 Continue 클릭&lt;/figcaption&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;h4 data-ke-size=&quot;size20&quot;&gt;2. Xcode 아카이빙 및 IPA 파일 export&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Firebase App Distribution에 빌드를 업로드하려면 IPA(iOS App tore Package) 파일이 필요합니다. 이를 위해 Xcode를 통해 Archive하고 IPA 파일을 export합니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;{Version}({BuildNumber}) 형식으로 네이밍 되니 이 점을 참고해 아카이빙 전 체크하면 좋습니다!&lt;br /&gt;ex) 1.5.4(1)&lt;/blockquote&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1158&quot; data-origin-height=&quot;446&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bGSutZ/dJMb9NBNtS5/8paXrWKvayj19Kp6dfu80k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bGSutZ/dJMb9NBNtS5/8paXrWKvayj19Kp6dfu80k/img.png&quot; data-alt=&quot;Product &amp;amp;gt; Archive&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bGSutZ/dJMb9NBNtS5/8paXrWKvayj19Kp6dfu80k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbGSutZ%2FdJMb9NBNtS5%2F8paXrWKvayj19Kp6dfu80k%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;720&quot; height=&quot;277&quot; data-origin-width=&quot;1158&quot; data-origin-height=&quot;446&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Product &amp;gt; Archive&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2706&quot; data-origin-height=&quot;1378&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ewqgwg/dJMb9YiWoOc/NFIjVofA2KbSzkk2ewkKak/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ewqgwg/dJMb9YiWoOc/NFIjVofA2KbSzkk2ewkKak/img.png&quot; data-alt=&quot;Distribute App&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ewqgwg/dJMb9YiWoOc/NFIjVofA2KbSzkk2ewkKak/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fewqgwg%2FdJMb9YiWoOc%2FNFIjVofA2KbSzkk2ewkKak%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;720&quot; height=&quot;367&quot; data-origin-width=&quot;2706&quot; data-origin-height=&quot;1378&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Distribute App&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3392&quot; data-origin-height=&quot;2048&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/F30LT/dJMb9O1MaMr/L4qi5tBhOPcJf3UfofdqZk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/F30LT/dJMb9O1MaMr/L4qi5tBhOPcJf3UfofdqZk/img.png&quot; data-alt=&quot;Custom&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/F30LT/dJMb9O1MaMr/L4qi5tBhOPcJf3UfofdqZk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FF30LT%2FdJMb9O1MaMr%2FL4qi5tBhOPcJf3UfofdqZk%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;720&quot; height=&quot;435&quot; data-origin-width=&quot;3392&quot; data-origin-height=&quot;2048&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Custom&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3220&quot; data-origin-height=&quot;1968&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b2rN1K/dJMb9NPkpCQ/fu91noivuccPpVl6phIxEK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b2rN1K/dJMb9NPkpCQ/fu91noivuccPpVl6phIxEK/img.png&quot; data-alt=&quot;Debugging&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b2rN1K/dJMb9NPkpCQ/fu91noivuccPpVl6phIxEK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb2rN1K%2FdJMb9NPkpCQ%2Ffu91noivuccPpVl6phIxEK%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;720&quot; height=&quot;440&quot; data-origin-width=&quot;3220&quot; data-origin-height=&quot;1968&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Debugging&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3192&quot; data-origin-height=&quot;1984&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/0Lpdy/dJMb9d8gaLC/MKdlwyGAK0FlATqJ8zHHiK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/0Lpdy/dJMb9d8gaLC/MKdlwyGAK0FlATqJ8zHHiK/img.png&quot; data-alt=&quot;Next&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/0Lpdy/dJMb9d8gaLC/MKdlwyGAK0FlATqJ8zHHiK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F0Lpdy%2FdJMb9d8gaLC%2FMKdlwyGAK0FlATqJ8zHHiK%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;720&quot; height=&quot;448&quot; data-origin-width=&quot;3192&quot; data-origin-height=&quot;1984&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Next&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3232&quot; data-origin-height=&quot;2016&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/btnC26/dJMb9O8xzjK/Lb9WnFCSSxji82owCVoA11/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/btnC26/dJMb9O8xzjK/Lb9WnFCSSxji82owCVoA11/img.png&quot; data-alt=&quot;Next&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/btnC26/dJMb9O8xzjK/Lb9WnFCSSxji82owCVoA11/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbtnC26%2FdJMb9O8xzjK%2FLb9WnFCSSxji82owCVoA11%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;720&quot; height=&quot;449&quot; data-origin-width=&quot;3232&quot; data-origin-height=&quot;2016&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Next&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3148&quot; data-origin-height=&quot;1960&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bxoXKx/dJMb9PTUCds/Gkfoh0rT6EykJncM35s9B1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bxoXKx/dJMb9PTUCds/Gkfoh0rT6EykJncM35s9B1/img.png&quot; data-alt=&quot;Export&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bxoXKx/dJMb9PTUCds/Gkfoh0rT6EykJncM35s9B1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbxoXKx%2FdJMb9PTUCds%2FGkfoh0rT6EykJncM35s9B1%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;720&quot; height=&quot;448&quot; data-origin-width=&quot;3148&quot; data-origin-height=&quot;1960&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Export&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 진행 후 Export 하면 필요했던 IPA 파일을 확인할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1876&quot; data-origin-height=&quot;876&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b3A0Tc/dJMb9YpH0cW/ktyZAGo4D7NEmlEYgYqGak/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b3A0Tc/dJMb9YpH0cW/ktyZAGo4D7NEmlEYgYqGak/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b3A0Tc/dJMb9YpH0cW/ktyZAGo4D7NEmlEYgYqGak/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb3A0Tc%2FdJMb9YpH0cW%2FktyZAGo4D7NEmlEYgYqGak%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;720&quot; height=&quot;336&quot; data-origin-width=&quot;1876&quot; data-origin-height=&quot;876&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. IPA 파일 업로드&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 export 한 IPA 파일을 Firebase App Distribution에 업로드해야 합니다. Firebase Console로 이동 후, 왼쪽 탭에서 App Distribution으로 이동합니다. 이후 IPA 파일을 드래그 앤 드롭 해 업로드합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;4912&quot; data-origin-height=&quot;1980&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dh8PSN/dJMb9MQpL6v/qpJxgJub8q9RBKzRcKfuZ1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dh8PSN/dJMb9MQpL6v/qpJxgJub8q9RBKzRcKfuZ1/img.png&quot; data-alt=&quot;IPA 파일 드래그 앤 드롭&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dh8PSN/dJMb9MQpL6v/qpJxgJub8q9RBKzRcKfuZ1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdh8PSN%2FdJMb9MQpL6v%2FqpJxgJub8q9RBKzRcKfuZ1%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;720&quot; height=&quot;290&quot; data-origin-width=&quot;4912&quot; data-origin-height=&quot;1980&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;IPA 파일 드래그 앤 드롭&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;4956&quot; data-origin-height=&quot;1324&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dieMhn/dJMb9gjBS3v/A3PkqohllgeK13Kvs2g5g0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dieMhn/dJMb9gjBS3v/A3PkqohllgeK13Kvs2g5g0/img.png&quot; data-alt=&quot;업로드 중...&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dieMhn/dJMb9gjBS3v/A3PkqohllgeK13Kvs2g5g0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdieMhn%2FdJMb9gjBS3v%2FA3PkqohllgeK13Kvs2g5g0%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;720&quot; height=&quot;192&quot; data-origin-width=&quot;4956&quot; data-origin-height=&quot;1324&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;업로드 중...&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;4892&quot; data-origin-height=&quot;3584&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/2glEx/dJMb9Nu1wLl/iKUA8Sqzy5JcAyKyiitFj1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/2glEx/dJMb9Nu1wLl/iKUA8Sqzy5JcAyKyiitFj1/img.png&quot; data-alt=&quot;완료!&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/2glEx/dJMb9Nu1wLl/iKUA8Sqzy5JcAyKyiitFj1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F2glEx%2FdJMb9Nu1wLl%2FiKUA8Sqzy5JcAyKyiitFj1%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;720&quot; height=&quot;527&quot; data-origin-width=&quot;4892&quot; data-origin-height=&quot;3584&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;완료!&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;4. 테스터 추가 및 배포&lt;/h4&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-origin-width=&quot;3684&quot; data-origin-height=&quot;2340&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bhRilp/dJMb9NPkpMf/b3rLJCVnLQud2CCpG5Nksk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bhRilp/dJMb9NPkpMf/b3rLJCVnLQud2CCpG5Nksk/img.png&quot; data-alt=&quot;테스터 추가(그룹에 미리 등록해두면 편하겠죠?)&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bhRilp/dJMb9NPkpMf/b3rLJCVnLQud2CCpG5Nksk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbhRilp%2FdJMb9NPkpMf%2Fb3rLJCVnLQud2CCpG5Nksk%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;720&quot; height=&quot;457&quot; data-origin-width=&quot;3684&quot; data-origin-height=&quot;2340&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;테스터 추가(그룹에 미리 등록해두면 편하겠죠?)&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3680&quot; data-origin-height=&quot;2436&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bjMAtP/dJMb8XqOVmP/R6qoxyHZIyshxzazWouiAk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bjMAtP/dJMb8XqOVmP/R6qoxyHZIyshxzazWouiAk/img.png&quot; data-alt=&quot;optional) 출시 노트 추가&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bjMAtP/dJMb8XqOVmP/R6qoxyHZIyshxzazWouiAk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbjMAtP%2FdJMb8XqOVmP%2FR6qoxyHZIyshxzazWouiAk%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;720&quot; height=&quot;477&quot; data-origin-width=&quot;3680&quot; data-origin-height=&quot;2436&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;optional) 출시 노트 추가&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1934&quot; data-origin-height=&quot;316&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cbOBp4/dJMb9MbOi27/KTtIGEJ3fzoLEXkkr4x9bK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cbOBp4/dJMb9MbOi27/KTtIGEJ3fzoLEXkkr4x9bK/img.png&quot; data-alt=&quot;배포 완료!&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cbOBp4/dJMb9MbOi27/KTtIGEJ3fzoLEXkkr4x9bK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcbOBp4%2FdJMb9MbOi27%2FKTtIGEJ3fzoLEXkkr4x9bK%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;720&quot; height=&quot;118&quot; data-origin-width=&quot;1934&quot; data-origin-height=&quot;316&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;배포 완료!&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;5. 테스터 다운로드&lt;/h4&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_5132.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/ET0Qr/dJMb9LKKcDm/COOrykXOr4ZQZBCig7QiR1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ET0Qr/dJMb9LKKcDm/COOrykXOr4ZQZBCig7QiR1/img.png&quot; data-alt=&quot;Get started 버튼 클릭&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ET0Qr/dJMb9LKKcDm/COOrykXOr4ZQZBCig7QiR1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FET0Qr%2FdJMb9LKKcDm%2FCOOrykXOr4ZQZBCig7QiR1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;300&quot; height=&quot;650&quot; data-filename=&quot;IMG_5132.PNG&quot; data-origin-width=&quot;1179&quot; data-origin-height=&quot;2556&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Get started 버튼 클릭&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 초대 수락 시, 약관 동의 및 Firebase 프로필 다운로드가 필요합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;IMG_5133.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/Mm9Tc/dJMb9W6u0L9/KObcD0ev6zyuhoVVxcmMB0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Mm9Tc/dJMb9W6u0L9/KObcD0ev6zyuhoVVxcmMB0/img.png&quot; data-alt=&quot;약관 동의 및 초대 수락&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Mm9Tc/dJMb9W6u0L9/KObcD0ev6zyuhoVVxcmMB0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FMm9Tc%2FdJMb9W6u0L9%2FKObcD0ev6zyuhoVVxcmMB0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;300&quot; height=&quot;650&quot; data-filename=&quot;IMG_5133.PNG&quot; data-origin-width=&quot;1179&quot; data-origin-height=&quot;2556&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;약관 동의 및 초대 수락&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1034&quot; data-origin-height=&quot;1068&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/eJkGRf/dJMb83EzqgV/7NYTM3MDwBRmvIRl1v7UY1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/eJkGRf/dJMb83EzqgV/7NYTM3MDwBRmvIRl1v7UY1/img.png&quot; data-alt=&quot;수락된 링크 이동 시, 배포된 앱 확인 가능. 다운로드하려면 기기 등록(프로필 설치) 필요&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/eJkGRf/dJMb83EzqgV/7NYTM3MDwBRmvIRl1v7UY1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FeJkGRf%2FdJMb83EzqgV%2F7NYTM3MDwBRmvIRl1v7UY1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;620&quot; data-origin-width=&quot;1034&quot; data-origin-height=&quot;1068&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;수락된 링크 이동 시, 배포된 앱 확인 가능. 다운로드하려면 기기 등록(프로필 설치) 필요&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;1062&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cGWtrB/dJMb9M3XBjv/RLkR0322k0EfhkHmdSubBK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cGWtrB/dJMb9M3XBjv/RLkR0322k0EfhkHmdSubBK/img.png&quot; data-alt=&quot;설정 앱 &amp;amp;gt; 일반 &amp;amp;gt; VPN 및 기기 관리 &amp;amp;gt; Firebase App Distribution 설치&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cGWtrB/dJMb9M3XBjv/RLkR0322k0EfhkHmdSubBK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcGWtrB%2FdJMb9M3XBjv%2FRLkR0322k0EfhkHmdSubBK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;622&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;1062&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;설정 앱 &amp;gt; 일반 &amp;gt; VPN 및 기기 관리 &amp;gt; Firebase App Distribution 설치&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;약관 동의 및 기기 등록까지 마무리 후 App Distribution 페이지에서 다운로드가 가능합니다. 이렇게 한번 등록해둔 테스터들은 이후 빌드부턴 바로 다운로드 할 수 있게 됩니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;IMG_5138.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/bBisxd/dJMb9LYhslR/a9IrqPkSzy0iLLLbZH2KkK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bBisxd/dJMb9LYhslR/a9IrqPkSzy0iLLLbZH2KkK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bBisxd/dJMb9LYhslR/a9IrqPkSzy0iLLLbZH2KkK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbBisxd%2FdJMb9LYhslR%2Fa9IrqPkSzy0iLLLbZH2KkK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;300&quot; height=&quot;650&quot; data-filename=&quot;IMG_5138.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;h3 data-ke-size=&quot;size23&quot;&gt;Fastlane을 통한 간단한 배포&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기기 및 테스터 등록을 미리 해두었다면, 개발자가 할 일은 아카이빙 &amp;gt; IPA 파일 추출 &amp;gt; IPA 파일 업로드 순일 것입니다. 이 방식은 TestFlight 보다 빠르게 배포할 수 있지만(애플 서버를 통한 유효성 검증 과정이 생략되기 때문) 여전히 너무 귀찮은 작업이긴 합니다. 이를 해결할 수 있도록 Firebase는 Fastlane을 통한 CI 기능을 제공합니다.&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;765&quot; data-origin-height=&quot;208&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dffP2X/dJMb9YcaOXJ/Jsq6QZKyosoiVbhXi38xi1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dffP2X/dJMb9YcaOXJ/Jsq6QZKyosoiVbhXi38xi1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dffP2X/dJMb9YcaOXJ/Jsq6QZKyosoiVbhXi38xi1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdffP2X%2FdJMb9YcaOXJ%2FJsq6QZKyosoiVbhXi38xi1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;300&quot; height=&quot;208&quot; data-origin-width=&quot;765&quot; data-origin-height=&quot;208&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://fastlane.tools/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Fastlane&lt;/a&gt;은 앱 배포 프로세스를 단순화하고 일관된 워크플로우로 통합할 수 있도록 돕는 오픈 소스 플랫폼입니다. Fastlane의 기능은 뒤에 설명할 베타 배포 자동화 이외에도 코드 서명 및 인증서 관리, 테스트 코드 실행 등 유용한 기능이 많지만 이 부분은 추후 별도의 포스팅에서 다뤄보는 것으로 하고, 오늘은 Firebase App Distribution과의 통합을 중점적으로 다뤄보겠습니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. Fastlane 설치&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;homebrew를 통해 Fastlane을 설치합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1760880614732&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;h4 data-ke-size=&quot;size20&quot;&gt;2. Fastlane 프로젝트 생성&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작업 중인 디렉토리에서 아래 명령어를 실행 후, Fastlane 프로젝트를 생성합니다. 중간중간 어떤 용도로 사용할거냐는 내용들이 나오는데, 나중에 얼마든지 수정이 가능하니 적당한걸로 선택하면 됩니다!&lt;/p&gt;
&lt;pre id=&quot;code_1760880720509&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;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. Fastlane 플러그인 설치&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 Firebase App Distribution 연결을 위한 플러그인을 설치합니다. 우선 Pluginfile을 만들어 어떤 플러그인을 사용할 것인지 정의해 주고,&lt;/p&gt;
&lt;pre id=&quot;code_1760880886320&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;gem 'fastlane-plugin-firebase_app_distribution'&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Gemfile에서 해당 파일을 사용할 수 있도록 다음과 같이 수정합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1760881074428&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;source &quot;https://rubygems.org&quot;

gem &quot;fastlane&quot;

plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
eval_gemfile(plugins_path) if File.exist?(plugins_path)&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;4. Firebase 로그인&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 Firebase를 사용하기 위해 Firebase CLI를 설치 후 로그인 합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1760881201054&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# Firebase CLI 설치
brew install firebase-cli

# Firebase 로그인
firebase login&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;5. 환경 변수 파일 생성&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각종 환경 변수를 관리하기 위해 `.env` 파일을 생성 후, Firebase App ID를 등록해주었습니다. Firebase에 프로젝트를 등록할 때 추가했던 `GoogleService-Info.plist` 파일의 `GOOGLE_APP_ID`를 참고해 추가할 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1760881493567&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;FIREBASE_APP_ID={GOOGLE_APP_ID}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;6. Fastfile 설정&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이것저것 준비 과정이 길었는데 이제 본격적인 배포 커스텀입니다. 저는 아래와 같이 구현했습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;`fastlane qa notes:&quot;어쩌구저쩌구 출시노트&quot;` 로 실행&lt;/li&gt;
&lt;li&gt;날짜/시간 기반 빌드 번호 생성 ex) 1.5.4 (2510192247)&lt;/li&gt;
&lt;li&gt;internal 테스트 그룹에 배포&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;* 사내 Fastfile에선 QA 빌드 뿐만이 아닌 자동으로 버전, 커밋을 기준으로 출시노트를 작성해주는 Production 빌드 등도 사용하고 있었답니다. 각자 목적에 맞게 여러가지로 커스텀 해두면 생산성이 정말 많이 올라갈 것 같아요!&lt;/p&gt;
&lt;pre id=&quot;code_1760881268241&quot; class=&quot;ruby&quot; data-ke-language=&quot;ruby&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;default_platform(:ios)

platform :ios do
  desc &quot;QA 빌드를 배포합니다.&quot;
  lane :qa do |options|
    _set_build_number_with_timestamp
    _build
    release_notes = _prepare_release_notes(options)
    _upload_to_firebase_app_distribution(release_notes: release_notes)
  end

  # 날짜/시간 기반 빌드 번호 생성 (YYMMDDHHMM)
  private_lane :_set_build_number_with_timestamp do
    new_build_number = Time.now.strftime(&quot;%y%m%d%H%M&quot;)
    increment_build_number(
      xcodeproj: &quot;poporazzi.xcodeproj&quot;,
      build_number: new_build_number
    )
  end

  # 앱 빌드
  private_lane :_build do
    build_app(
      scheme: &quot;poporazzi&quot;,
      export_method: &quot;development&quot;
    )
  end

  # 출시 노트 준비
  private_lane :_prepare_release_notes do |options|
    options[:notes] || &quot;QA 빌드&quot;
  end

  # Firebase App Distribution에 업로드
  private_lane :_upload_to_firebase_app_distribution do |options|
    firebase_app_distribution(
      app: ENV[&quot;FIREBASE_APP_ID&quot;], # .env 파일에서 관리
      groups: &quot;internal&quot;, # 테스트 그룹명
      release_notes: options[:release_notes], # 출시 노트
      firebase_cli_path: &quot;/usr/local/bin/firebase&quot;
    )
  end
end&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;7. 실행해보기&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 만들어둔 fastlane 커맨드를 이용해 요렇게 입력해주고 기다리다보면,,,&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1608&quot; data-origin-height=&quot;390&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zLcm6/dJMb9O8xGwe/QEt7UFyFGK9aKh1roMc270/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zLcm6/dJMb9O8xGwe/QEt7UFyFGK9aKh1roMc270/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zLcm6/dJMb9O8xGwe/QEt7UFyFGK9aKh1roMc270/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzLcm6%2FdJMb9O8xGwe%2FQEt7UFyFGK9aKh1roMc270%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;720&quot; height=&quot;175&quot; data-origin-width=&quot;1608&quot; data-origin-height=&quot;390&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-origin-width=&quot;1254&quot; data-origin-height=&quot;1020&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/MwRra/dJMb9VGwoKT/qC2Y12QnDulcYQDuyKINz1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/MwRra/dJMb9VGwoKT/qC2Y12QnDulcYQDuyKINz1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/MwRra/dJMb9VGwoKT/qC2Y12QnDulcYQDuyKINz1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FMwRra%2FdJMb9VGwoKT%2FqC2Y12QnDulcYQDuyKINz1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;500&quot; height=&quot;407&quot; data-origin-width=&quot;1254&quot; data-origin-height=&quot;1020&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 테스터들은 QA 빌드를 다운받을 수 있는 메일을 출시노트와 함께 받을 수 있고, 빠른 배포 프로세스가 완성된 것을 확인할 수 있습니다!&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;IMG_5142.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/b2SLIP/dJMb9PfiXp4/nAVKL5Qhut5Afse9kBw7FK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b2SLIP/dJMb9PfiXp4/nAVKL5Qhut5Afse9kBw7FK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b2SLIP/dJMb9PfiXp4/nAVKL5Qhut5Afse9kBw7FK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb2SLIP%2FdJMb9PfiXp4%2FnAVKL5Qhut5Afse9kBw7FK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;300&quot; height=&quot;650&quot; data-filename=&quot;IMG_5142.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;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;적용 후기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 한번 적용해두니 이전 방식으로 되돌아갈 수 없을 정도로 편리하다는 생각이 들었습니다. 처음 설정 과정도 공식 문서 등을 통해 쉽게 따라할 수 있으며, Fastfile 작성 또한 생소한 Ruby 언어이나 문법이 어렵지 않아 금방 익힐 수 있고 또는 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;팀 프로젝트에선 오늘과 같이 QA 빌드 lane을 만들어두면 제일 잘 써먹을 수 있을 것 같고, 개인 프로젝트에선 Production 빌드 lane을 만들어 하루에도 여러번 배포할 수 있는 환경을 구성해보면 어떨까 합니다.(다음 개인 프로젝트에선 꼭 써먹어 보는 걸루  )&lt;/p&gt;</description>
      <category>실무 내용 내껄로 만들기</category>
      <category>CI</category>
      <category>Fastlane</category>
      <category>Firebase App Distribution</category>
      <category>ios</category>
      <author>thinkyside</author>
      <guid isPermaLink="true">https://thinkyside.tistory.com/111</guid>
      <comments>https://thinkyside.tistory.com/111#entry111comment</comments>
      <pubDate>Sun, 19 Oct 2025 23:00:34 +0900</pubDate>
    </item>
  </channel>
</rss>