<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>먹고 기도하고 코딩하라</title>
    <link>https://dev-dain.tistory.com/</link>
    <description>인터넷 항해기 작성중
깃허브: https://github.com/dev-dain</description>
    <language>ko</language>
    <pubDate>Mon, 1 Jun 2026 09:38:44 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>사과먹는사람</managingEditor>
    <image>
      <title>먹고 기도하고 코딩하라</title>
      <url>https://tistory1.daumcdn.net/tistory/3486408/attach/e8c68162100846a7bb11357dfe15f5d2</url>
      <link>https://dev-dain.tistory.com</link>
    </image>
    <item>
      <title>[SwiftUI] iOS 위젯에서 로컬 파일을 가져오기</title>
      <link>https://dev-dain.tistory.com/319</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에 회사 앱 위젯 개선 작업을 했다. 위젯 작업은 원래 다른 분께서 하시던 작업이었으나, 그 분께는 다른 메인 작업이 있어 작업을 가져왔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WidgetKit과 SwiftUI로 레이아웃을 만드는 방법만 안다면 위젯은 그렇게 어려운 작업은 아니다. 다만 이번에 오프라인 모드에서 이미지를 가져오는 작업이 조금 까다로웠어서 포스팅한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;오프라인 모드에서는 기기에 저장된 이미지 path를 불러와서 썸네일을 보여줘야 한다.&lt;/li&gt;
&lt;li&gt;그런데, path가 정확하고(documents) 이미지도 실재하는데 위젯에 표현이 안 된다.&lt;/li&gt;
&lt;/ul&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;WidgetKit은 경량화 app extension이다. WidgetKit에서는 로컬에 저장된 파일들을 마음대로 접근할 수 없다.&lt;/li&gt;
&lt;li&gt;그러니까 실제로 Documents 디렉터리에 저장이 돼 있어도 읽을 수가 없는 것이다.&lt;/li&gt;
&lt;/ul&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;App Group을 사용해 위젯과 공유해야 하는 파일들을 Documents 디렉터리에서 App Group으로 이동 혹은 복사한 다음 사용한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;App Group은 같은 팀이 개발한 여러 앱을 1개 이상의 공유 컨테이너로 접근할 수 있게 해준다. App Groups는 샌드박스 앱 간 커뮤니케이션도 가능하게 해주는 등 앱 간 데이터 공유가 필요할 때 유용하게 사용할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일단 App Group을 원래 사용하지 않았다면, 프로젝트의 capabilities에서 App Groups를 체크한 다음 애플 개발자 사이트에서 추가해서 사용할 수 있다. 문서에서 방법을 안내하고 있으므로, 더 자세히는 문서를 참고하면 좋겠다.&lt;/p&gt;
&lt;figure id=&quot;og_1729835077711&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;Configuring App Groups | Apple Developer Documentation&quot; data-og-description=&quot;Enable communication and data sharing between multiple installed apps created by the same developer.&quot; data-og-host=&quot;developer.apple.com&quot; data-og-source-url=&quot;https://developer.apple.com/documentation/xcode/configuring-app-groups&quot; data-og-url=&quot;https://docs.developer.apple.com/documentation/xcode/configuring-app-groups&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/W09hn/hyXpv8bUMb/E0Llxn3DqLqbduaM89ccG1/img.jpg?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/8WZJ1/hyXpsjllXZ/ldh4HsCLw2nGUCjqliajak/img.jpg?width=1024&amp;amp;height=512&amp;amp;face=0_0_1024_512&quot;&gt;&lt;a href=&quot;https://developer.apple.com/documentation/xcode/configuring-app-groups&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://developer.apple.com/documentation/xcode/configuring-app-groups&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/W09hn/hyXpv8bUMb/E0Llxn3DqLqbduaM89ccG1/img.jpg?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/8WZJ1/hyXpsjllXZ/ldh4HsCLw2nGUCjqliajak/img.jpg?width=1024&amp;amp;height=512&amp;amp;face=0_0_1024_512');&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;Configuring App Groups | Apple Developer Documentation&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Enable communication and data sharing between multiple installed apps created by the same developer.&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 data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다시 말하지만 이미 &lt;u&gt;&lt;b&gt;Documents 디렉터리에 저장된 걸 그대로 위젯에서 쓸 수는 없다.&lt;/b&gt;&lt;/u&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 위젯에서 정 필요한 정보라면, App Group의 공유 컨테이너 주소로 파일들을 이동/복사하고 공유 컨테이너에서 파일들을 사용하면 된다. (복사할 경우에는 원본 파일의 변경, 삭제 시 싱크를 맞춰줘야 할 것이다)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋은 조수 챗지피티에게 코드를 부탁했다.&lt;/p&gt;
&lt;pre class=&quot;swift&quot; data-ke-language=&quot;swift&quot;&gt;&lt;code&gt;import Foundation

// Function to get the Documents directory URL
func getDocumentsDirectory() -&amp;gt; URL {
    let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
    return paths[0]
}

// Function to get the App Group shared directory URL
func getSharedContainerURL() -&amp;gt; URL? {
    // 여기에선 지정한 group identifier를 넣으면 된다.
    return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: &quot;group.com.yourapp.widget&quot;)
}

// Function to move image from Documents to App Group shared directory
func moveImageToSharedContainer(fileName: String) {
    // Get the file URL in the Documents directory
    let documentsURL = getDocumentsDirectory().appendingPathComponent(fileName)
    
    // Get the file URL in the App Group shared container
    guard let sharedContainerURL = getSharedContainerURL() else {
        print(&quot;Failed to get shared container URL&quot;)
        return
    }
    let destinationURL = sharedContainerURL.appendingPathComponent(fileName)
    
    do {
        // Check if the file exists in the Documents directory
        if FileManager.default.fileExists(atPath: documentsURL.path) {
            // Move the file to the App Group shared directory
            try FileManager.default.moveItem(at: documentsURL, to: destinationURL)
            print(&quot;Successfully moved image to shared container: \\(destinationURL.path)&quot;)
        } else {
            print(&quot;Image not found in Documents directory: \\(documentsURL.path)&quot;)
        }
    } catch {
        // Handle any errors during the move operation
        print(&quot;Error moving image to shared container: \\(error.localizedDescription)&quot;)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내 경우, 어떤 이미지 파일을 위젯에서 쓰게 될지 모르므로 이미지 파일들을 전부 복사해놔야 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 실제로는 이런 코드를 사용해 복사하게 된다. (지금은 모든 파일을 복사해두는 것이 비효율적이라는 리뷰를 받아 다른 방식으로 변경했지만, 복사하는 코드는 남겨둔다.)&lt;/p&gt;
&lt;pre class=&quot;swift&quot; data-ke-language=&quot;swift&quot;&gt;&lt;code&gt;func copyImageFileToSharedContainer() {
    guard let docURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { 
    return
    }
    do {
    var files: [String]? = try FileManager.default.contentsOfDirectory(atPath: docURL?.path ?? &quot;&quot;)
        // 이미지인지 아닌지 판별할 수 있는 방법은 더 지혜로운 게 있을 것이다.
        files = files?.filter { $0.hasSuffix(&quot;jpg&quot;) || $0.hasSuffix(&quot;png&quot;) }
        files?.forEach { name in
        moveToSharedContainer(fileName: name)
        }
    } catch {
        Log(error.localizedDescription)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위젯에서 이미지를 불러와서 사용할 때는, 인터넷상 이미지를 가져오는 것과 동일한 방법으로 가져올 수 있다.&lt;/p&gt;
&lt;pre class=&quot;swift&quot; data-ke-language=&quot;swift&quot;&gt;&lt;code&gt;// loadImage(imagePath: url.path ?? &quot;&quot;)

func loadImage(imagePath: String) -&amp;gt; Image {
    guard let url = URL(string: urlString), !urlString.isEmpty else { return self }
		
    if let data = try? Data(contentsOf: url) {
        return Image(uiImage: UIImage(data: data) ?? UIImage())
    }
    return self
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>앱/Swift</category>
      <category>app groups</category>
      <category>ios 위젯</category>
      <category>swift</category>
      <category>swift 위젯</category>
      <category>SwiftUI</category>
      <category>WidgetKit</category>
      <author>사과먹는사람</author>
      <guid isPermaLink="true">https://dev-dain.tistory.com/319</guid>
      <comments>https://dev-dain.tistory.com/319#entry319comment</comments>
      <pubDate>Fri, 25 Oct 2024 19:46:47 +0900</pubDate>
    </item>
    <item>
      <title>MacOS 15 업데이트 및 Xcode 16 업데이트 호환성 대응</title>
      <link>https://dev-dain.tistory.com/318</link>
      <description>&lt;p data-pm-slice=&quot;0 0 []&quot; data-ke-size=&quot;size16&quot;&gt;회사에서 이번에 맥을 세콰이어로 업데이트하고 Xcode도 16으로 업데이트하면서 소소한 문제가 있었습니다.&lt;/p&gt;
&lt;p data-pm-slice=&quot;0 0 []&quot; data-ke-size=&quot;size16&quot;&gt;해결하는 과정에서 팀과 공유하는 차원으로 문서를 썼는데, 혹시 누군가에게 도움이 될까 싶어 블로그에도 올립니다.&lt;/p&gt;
&lt;p data-pm-slice=&quot;0 0 []&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-pm-slice=&quot;0 0 []&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;목차&lt;/b&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Xcode 15 이하 버전 사용법&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;iOS 18 Simulator Runtime이 영원히 다운로드되지 않음&lt;/li&gt;
&lt;li&gt;Sentry pod 문제로 빌드 불가&lt;/li&gt;
&lt;li&gt;기타 pod 문제로 빌드 불가&lt;/li&gt;
&lt;li&gt;UIViewController에 .tab(UITab?) 변수 추가로 인한 기존 변수 리네임&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;iOS 18: 콜렉션뷰로 가져갈 셀이 아니라면 dequeue하지 말 것&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Xcode 15 이하 버전 사용법&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MacOS 15 Sequoia로 업데이트하면, 기존에 사용하던 Xcode 15는 바로 열어 사용할 수 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대신 사용이 불가한 &lt;u&gt;Xcode 앱 우클릭 &amp;gt; 패키지 내용 보기 &amp;gt; Contents &amp;gt; MacOS &amp;gt; Xcode&lt;/u&gt;로 이전 버전 Xcode를 열 수는 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서는 Xcode 16으로 업데이트한 후 발생할 수 있는 문제와 해결책에 대해 다룹니다.&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;b&gt;문제 케이스&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;iOS 18 Simulator Runtime 다운로드가 되지 않음&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시뮬레이터를 사용하지 않더라도 iOS 18 시뮬레이터 런타임을 다운로드받아야 앱을 빌드할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보통 Xcode를 켜면 자동으로 다운로드받게 되는데, PREPARING 상태에서 계속 멈춰 다운로드가 되지 않을 수 있습니다.&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;&lt;a href=&quot;https://developer.apple.com/download/all/?q=Simulator&quot;&gt;링크&lt;/a&gt;에서 런타임 dmg를 다운로드받습니다.&lt;/li&gt;
&lt;li&gt;터미널을 켜서 다음 명령어를 입력해 설치된 새 버전의 Xcode에 런타임을 import시켜줍니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;sudo xcode-select -s /Applications/Xcode.app
xcodebuild -runFirstLaunch
xcodebuild -importPlatform &amp;ldquo;시뮬레이터런타임위치/.dmg&amp;rdquo;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Sentry pod 문제로 빌드 불가&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-09-24 오전 10.30.35.png&quot; data-origin-width=&quot;261&quot; data-origin-height=&quot;223&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/QlWid/btsJKe1tt9z/kIUkDS6Kv7YdqjaJkcjcwk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/QlWid/btsJKe1tt9z/kIUkDS6Kv7YdqjaJkcjcwk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/QlWid/btsJKe1tt9z/kIUkDS6Kv7YdqjaJkcjcwk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FQlWid%2FbtsJKe1tt9z%2FkIUkDS6Kv7YdqjaJkcjcwk%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;261&quot; height=&quot;223&quot; data-filename=&quot;스크린샷 2024-09-24 오전 10.30.35.png&quot; data-origin-width=&quot;261&quot; data-origin-height=&quot;223&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제는 sentry를 낮은 버전으로 사용하고 있도록 Podfile에서 제한하고 있기 때문에 발생합니다.&lt;/p&gt;
&lt;figure id=&quot;og_1727165650380&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;No type named 'terminate_handler' in namespace 'std' &amp;middot; Issue #4354 &amp;middot; getsentry/sentry-cocoa&quot; data-og-description=&quot;Platform iOS Environment Production, Develop, TestFlight Installed CocoaPods Version 7.21.0 Xcode Version 16 Did it work on previous versions? xcode 15 is ok. Steps to Reproduce Upgrade xcode 16， b...&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/getsentry/sentry-cocoa/issues/4354&quot; data-og-url=&quot;https://github.com/getsentry/sentry-cocoa/issues/4354&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/19PQF/hyW6HhXhD0/CDZlopEJkI0ZzjxQX6PCLk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/getsentry/sentry-cocoa/issues/4354&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/getsentry/sentry-cocoa/issues/4354&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/19PQF/hyW6HhXhD0/CDZlopEJkI0ZzjxQX6PCLk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&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;No type named 'terminate_handler' in namespace 'std' &amp;middot; Issue #4354 &amp;middot; getsentry/sentry-cocoa&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Platform iOS Environment Production, Develop, TestFlight Installed CocoaPods Version 7.21.0 Xcode Version 16 Did it work on previous versions? xcode 15 is ok. Steps to Reproduce Upgrade xcode 16， b...&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1727165660380&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;Recent visionos change is breaking ios builds &amp;middot; Issue #3547 &amp;middot; getsentry/sentry-react-native&quot; data-og-description=&quot;OS: Windows MacOS Linux Platform: iOS Android SDK: @sentry/react-native (&amp;gt;= 1.0.0) react-native-sentry (&amp;lt;= 0.43.2) SDK version: 5.17.0 react-native version: 0.73.2 Are you using Expo? Yes No Are yo...&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/getsentry/sentry-react-native/issues/3547#issuecomment-1911991437&quot; data-og-url=&quot;https://github.com/getsentry/sentry-react-native/issues/3547&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cXJmq0/hyW6zKZhS2/KmmgGdpx8Iw6HJACeba2WK/img.png?width=1200&amp;amp;height=600&amp;amp;face=984_135_1062_219&quot;&gt;&lt;a href=&quot;https://github.com/getsentry/sentry-react-native/issues/3547#issuecomment-1911991437&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/getsentry/sentry-react-native/issues/3547#issuecomment-1911991437&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cXJmq0/hyW6zKZhS2/KmmgGdpx8Iw6HJACeba2WK/img.png?width=1200&amp;amp;height=600&amp;amp;face=984_135_1062_219');&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;Recent visionos change is breaking ios builds &amp;middot; Issue #3547 &amp;middot; getsentry/sentry-react-native&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;OS: Windows MacOS Linux Platform: iOS Android SDK: @sentry/react-native (&amp;gt;= 1.0.0) react-native-sentry (&amp;lt;= 0.43.2) SDK version: 5.17.0 react-native version: 0.73.2 Are you using Expo? Yes No Are yo...&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음과 같이 해결합니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;최신 버전으로 업데이트할 수 있도록 Podfile을 수정합니다.&lt;/li&gt;
&lt;li&gt;pod 버전을 업데이트하고, Sentry pod을 업데이트합니다.&lt;/li&gt;
&lt;li&gt;tag는 별 이유가 없다면 새로운 버전을 확인해 기입합니다. (2024년 9월 현재 8.36.0)&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;pod --version # 팟 버전 확인

sudo gem install cocoapods
pod update Sentry&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span data-emoji-text=&quot;:warning:&quot; data-emoji-id=&quot;atlassian-warning&quot; data-emoji-short-name=&quot;:warning:&quot;&gt;주의&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span data-text-custom-color=&quot;#ff5630&quot;&gt;trunk URL 문제&lt;/span&gt;&lt;/b&gt;로 업데이트가 되지 않을 수 있습니다. 어떤 팟이 문제가 된다고 쓰여 있을 텐데, 그 팟을 따로 건드릴 필요는 없습니다.&lt;/p&gt;
&lt;pre class=&quot;swift&quot; data-ke-language=&quot;swift&quot;&gt;&lt;code&gt;[!] CDN: trunk URL couldn't be downloaded: &amp;lt;링크&amp;gt; Response: Error in the HTTP2 framing layer&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경우엔 추가로 cocoapods 내 repo를 날려주고 다시 업데이트합니다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;sudo rm -rf ~/.cocoapods/repos&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;기타 pod 문제로 빌드 불가&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-09-24 오전 11.10.32.png&quot; data-origin-width=&quot;473&quot; data-origin-height=&quot;78&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/YJ9Yy/btsJKf7czrT/K5EMQyuKrZqHhkMdjUc3g0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/YJ9Yy/btsJKf7czrT/K5EMQyuKrZqHhkMdjUc3g0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/YJ9Yy/btsJKf7czrT/K5EMQyuKrZqHhkMdjUc3g0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FYJ9Yy%2FbtsJKf7czrT%2FK5EMQyuKrZqHhkMdjUc3g0%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;473&quot; height=&quot;78&quot; data-filename=&quot;스크린샷 2024-09-24 오전 11.10.32.png&quot; data-origin-width=&quot;473&quot; data-origin-height=&quot;78&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;SDK does not contain 'libarclite' at the path '/Application/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/arc/libarclite_iphoneos.a'; try increasing the minimum deploymnet target&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Pods에 포함된 파일들에서 문제가 생길 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경우 에러 메시지에서도 권고하듯, Pods의 iOS Deployment Target 버전이 낮은 게 문제가 될 수 있습니다. 원인은 여러 가지처럼 보이지만 결국 거의 deployment target이 핵심적인 문제입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-09-24 오전 11.07.56.png&quot; data-origin-width=&quot;580&quot; data-origin-height=&quot;150&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bD1MwH/btsJKS4s6Mz/V0SddobJVcrFk7f0MqW501/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bD1MwH/btsJKS4s6Mz/V0SddobJVcrFk7f0MqW501/img.png&quot; data-alt=&quot;iOS 15라고 되어 있지만, 실제로는 15.6으로 셋팅되어 있기 때문에 꼭 15.0인지 확인 필요&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bD1MwH/btsJKS4s6Mz/V0SddobJVcrFk7f0MqW501/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbD1MwH%2FbtsJKS4s6Mz%2FV0SddobJVcrFk7f0MqW501%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;580&quot; height=&quot;150&quot; data-filename=&quot;스크린샷 2024-09-24 오전 11.07.56.png&quot; data-origin-width=&quot;580&quot; data-origin-height=&quot;150&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;iOS 15라고 되어 있지만, 실제로는 15.6으로 셋팅되어 있기 때문에 꼭 15.0인지 확인 필요&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해결책은 iOS Deployment Target을 적당히 올려주면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저희 회사 앱은 minimum deployment target이 iOS 15.0이지만, pod들은 iOS Deployment Target이 iOS 15 미만으로 설정되어 있으므로 적당히 올려주었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 때 주의해야 할 점은 드롭다운에서 iOS 15를 선택할 경우 자동으로 15.6으로 세팅되기 때문에, &lt;b&gt;&lt;span data-text-custom-color=&quot;#ff5630&quot;&gt;Others에서 iOS 15.0으로 명시적으로 마이너 버전까지 지정&lt;/span&gt;&lt;/b&gt;해야 iOS 15.6 미만 버전 사용자들에게도 안정적으로 서브가 가능하다는 점입니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다른 팟들에서 생기는 문제들 역시 대부분은 target 올리면 해결됩니다.&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;UIViewController에 .tab(UITab?) 프로퍼티 추가로 인한 기존 변수 리네임&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;혹시 탭을 구현해서 사용하고 있는데, 현재 선택된 탭 등을 구별하려고 tab 등의 프로퍼티를 갖고 있다면.. 주목하십시오&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UIViewController에 UITab? 타입의 tab 프로퍼티가 추가됐습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇기 때문에 탭 구분하려고 뷰컨에 심은/심을 tab 변수는 네이밍을 한 번만 다시 생각해봐야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다른 타입으로 오버라이딩을 시도하는 꼴이라 빌드 자체가 안 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이것까지 수정하고 나면 비로소 빌드할 수 있습니다.  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;iOS 18: 콜렉션뷰로 가져갈 셀이 아니라면 dequeue하지 말 것&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여담으로 Xcode 16과 iOS 18에서는 콜렉션뷰의 dequeue 활동에 매우 민감하게 반응하고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Xcode 16에서 iOS 18.0 기기를 붙여 빌드했더니 앱이 강제종료되었는데요.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Expected dequeued view to be returned to the collection view in preparation for display. When the collection view's data source is asked to provide a view for a given index path, ensure that a single view is dequeued and returned to the collection view.&lt;span data-text-custom-color=&quot;#ff5630&quot;&gt; Avoid dequeuing views without a request from the collection view.&lt;/span&gt; For retrieving an existing view in the collection view, use -[UICollectionView cellForItemAtIndexPath:] or -[UICollectionView supplementaryViewForElementKind:atIndexPath:]&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;확인 결과  저희 앱 코드에서는 프로토콜의 한 함수에서 문제를 발견할 수 있었습니다.&lt;/p&gt;
&lt;pre class=&quot;swift&quot; data-ke-language=&quot;swift&quot;&gt;&lt;code&gt;// ❌ 이 부분
if collectionView.dequeueReusableCell(UICollectionViewCell.self, for: IndexPath(row: item, section: 0)) as? T != nil {
    collectionView.scrollToItem(at: IndexPath(item: item, section: 0), at: .left, animated: false)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경우, dequeueReusableCell을 하지 않아도 될 상황에 dequeue를 하고 있습니다. (존재 여부 확인만 하면 되는 상황)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쉽게 말해 셀을 dequeue해서 콜렉션뷰로 넘기는 상황이 아니라면 dequeue하지 말고 cellForItem(at:)으로 찾으라는 것이 요지입니다. 이전 버전까지는 융통성있게 넘어갔으나 새로 업데이트된 iOS 18에서 엄격해진 듯합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음과 같이 수정하면 정상적으로 동작합니다.&lt;/p&gt;
&lt;pre class=&quot;swift&quot; data-ke-language=&quot;swift&quot;&gt;&lt;code&gt;✅
if collectionView.cellForItem(at: IndexPath(row: item, section: 0)) as? T != nil {
    collectionView.scrollToItem(at: IndexPath(item: item, section: 0), at: .left, animated: false)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>앱/Swift</category>
      <category>ios 18</category>
      <category>macos 15</category>
      <category>macos sequoia</category>
      <category>xcode 16</category>
      <category>xcode 16 빌드 에러</category>
      <category>xcode 업데이트</category>
      <author>사과먹는사람</author>
      <guid isPermaLink="true">https://dev-dain.tistory.com/318</guid>
      <comments>https://dev-dain.tistory.com/318#entry318comment</comments>
      <pubDate>Tue, 24 Sep 2024 20:19:51 +0900</pubDate>
    </item>
    <item>
      <title>StoreKit2으로 결제 모듈 마이그레이션하기</title>
      <link>https://dev-dain.tistory.com/317</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에 회사 앱이 iOS 15.0을 minimum deployment로 올리게 되었다. 여러 가지 이유가 있는데, 이유 중 하나는 StoreKit1이 iOS 18.0 이상 버전에서 deprecated된다는 사실이었다.&lt;/p&gt;
&lt;figure id=&quot;og_1725351126321&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;Original API for In-App Purchase | Apple Developer Documentation&quot; data-og-description=&quot;Offer additional content and services in your app using the Original In-App Purchase API.&quot; data-og-host=&quot;developer.apple.com&quot; data-og-source-url=&quot;https://developer.apple.com/documentation/storekit/in-app_purchase/original_api_for_in-app_purchase&quot; data-og-url=&quot;https://docs.developer.apple.com/documentation/storekit/in-app_purchase/original_api_for_in-app_purchase&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/kIpeN/hyWV5446L2/hbffntTrv2DnzA5bzbYjM0/img.jpg?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/dftQq6/hyWV630G0C/FEZ3oYVqdql5vKGB9iXb51/img.jpg?width=1024&amp;amp;height=512&amp;amp;face=0_0_1024_512&quot;&gt;&lt;a href=&quot;https://developer.apple.com/documentation/storekit/in-app_purchase/original_api_for_in-app_purchase&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://developer.apple.com/documentation/storekit/in-app_purchase/original_api_for_in-app_purchase&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/kIpeN/hyWV5446L2/hbffntTrv2DnzA5bzbYjM0/img.jpg?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/dftQq6/hyWV630G0C/FEZ3oYVqdql5vKGB9iXb51/img.jpg?width=1024&amp;amp;height=512&amp;amp;face=0_0_1024_512');&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;Original API for In-App Purchase | Apple Developer Documentation&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Offer additional content and services in your app using the Original In-App Purchase API.&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 data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대신 Swift 기반의 IAP 방식을 사용해서 인앱 결제를 구현하도록 권유하고 있다.&lt;/p&gt;
&lt;figure id=&quot;og_1725351177186&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;In-App Purchase | Apple Developer Documentation&quot; data-og-description=&quot;Offer content and services in your app across Apple platforms using a Swift-based interface.&quot; data-og-host=&quot;developer.apple.com&quot; data-og-source-url=&quot;https://developer.apple.com/documentation/storekit/in-app_purchase&quot; data-og-url=&quot;https://docs.developer.apple.com/documentation/storekit/in-app_purchase&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/GUChF/hyWV3TGPy4/sxjQHiaov80aq6wMzH1FV0/img.jpg?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/cJKrcY/hyWV28hOki/FfNYJkliN23jlzNIGGyXV0/img.jpg?width=1024&amp;amp;height=512&amp;amp;face=0_0_1024_512&quot;&gt;&lt;a href=&quot;https://developer.apple.com/documentation/storekit/in-app_purchase&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://developer.apple.com/documentation/storekit/in-app_purchase&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/GUChF/hyWV3TGPy4/sxjQHiaov80aq6wMzH1FV0/img.jpg?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/cJKrcY/hyWV28hOki/FfNYJkliN23jlzNIGGyXV0/img.jpg?width=1024&amp;amp;height=512&amp;amp;face=0_0_1024_512');&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;In-App Purchase | Apple Developer Documentation&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Offer content and services in your app across Apple platforms using a Swift-based interface.&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 data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;StoreKit1과 StoreKit2 사이 차이점 몇 가지는 다음과 같다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;더 이상 영수증 데이터 자체를 갖고 구매를 검증하지 않는다. transactionId와 구매 시도 환경만 필요하다.&lt;/li&gt;
&lt;li&gt;구매 복원은 앱스토어 자체적으로 진행한다. (때문에 기존에 영수증을 갖고 서버 요청하는 절차가 있었다면, 이제 하지 않아도 된다)&lt;/li&gt;
&lt;li&gt;SKProductsRequest, SKProductResponse, SKPaymentTransaction, SKPaymentQueue 등 StoreKit1에서 사용하던 것을 더이상 사용하지 않는다.&lt;/li&gt;
&lt;/ul&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;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;SKProductsRequest(productIdentifiers:)를 요청해 상품 구매 요청 객체를 만든 후, delegate를 설정한 다음 request.start()를 해준다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;요청에 대한 응답이 오면 delegate로 구현한 메소드에서 [SKProduct]를 response로 받았다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;구매 파라미터로 넘어온 것들 중에 시그니쳐나 프로모션 코드가 있다면 프로모션 결제로 분기하고, 그렇지 않다면 일반적인 결제를 시도하게 된다. 이 과정에서는 SKMutablePayment(product:)로 결제 객체를 받아 SKPaymentQueue에 추가하게 되는데, 결제가 완료되면 SKPaymentTransaction을 받을 수 있었다. &lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;그럼 이 transaction.transactionState에 따라 최종적으로 분기를 하면 된다. .purchased라면 구매 완료이므로 구매 후 멤버십이나 상품을 넣어주는 처리를 하면 되고, .failed, .deferred의 경우에는 결제 실패 및 지연이므로 그에 맞는 처리를 해주면 된다.&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결제 흐름을 알면 그렇게 어려울 건 없지만 delegate를 해줄 객체가 필요하고, 결제 요청을 큐에 집어넣고 상태를 관찰하는 메소드 등을 추가로 작성해줘야 한다. 위에서 아래로 읽을 수 있는 코드가 아니다보니, 흐름이 탁 끊기는 것도 감수해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;StoreKit2에서는 async-await과 Task를 사용해 조금 더 읽기 쉽고 관리하기 쉬운 코드를 짤 수 있다. 지금부터 살펴보자.&lt;/p&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;&lt;b&gt;StoreKit2로 마이그레이션하기&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. 상품 불러오기&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;StoreKit2에서 상품을 불러올 때는 상품 identifier가 필요하다. 이 identifier는 스토어에 미리 등록해둔 상품 코드와 동일하다. 상품은 여러 개 불러올 수도 있지만, 여기서는 단일 상품만 구매한다고 가정하자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예전 StoreKit 코드에서는 매니저 클래스를 하나 생성해서 이 클래스가 상품 목록을 요청하고, 받아놓은 컴플리션 핸들러로 요청이 끝나면 request, response를 넘길 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1725351301473&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 신버전 코드
let product = try await Product.products(for: [productId]).first&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;swift&quot; data-ke-language=&quot;swift&quot;&gt;&lt;code&gt;// 구버전 코드
class IAPManager: SKPaymentTransactionObserver, SKProductsRequestDelegate {
    var products: [AnyHashable]?
    var request: SKProductsRequest?
    requestProductsHandler: completion method

    func requestProducts(*completion method*) {
        request = SKProductsRequest(productIdentifiers: productIdentifiers)
        request?.delegate = self
        requestProductsHandler = completion
        request?.start()
    }
    
    func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
        products = response.products
        self.request = nil
        requestProductsHandler?(request, response)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. 상품 옵션 만들기&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정상가 상품의 경우 옵션이 따로 필요하지 않지만, 프로모션 구매의 경우에는 옵션이 필요하다. iOS 15.0부터 사용할 수 있는 프로모션 구매에 필요한 옵션은 다음과 같은 파라미터를 갖는다.&lt;/p&gt;
&lt;pre id=&quot;code_1725351364188&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 신버전
let option: Set&amp;lt;Product.PurchaseOption&amp;gt; = [.promotionalOffer(
    offerID: String,
    keyID: String,
    nonce: UUID,
    signature: Data,
    timestamp: Int
)]&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;swift&quot; data-ke-language=&quot;swift&quot;&gt;&lt;code&gt;// 구버전
let discountOffer = SKPaymentDiscount(
    identifier: String,
    keyIdentifier: String,
    nonce: UUID,
    signature: String,
    timestamp: NSNumber
)
let payment = SKMutablePayment(product: SKProduct)
payment.paymentDiscount = discountOffer&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로모션 구매인 경우에는 각 항목을 채워서 옵션을 만들면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. 구매 시도&lt;/h4&gt;
&lt;pre id=&quot;code_1725351404671&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 신버전
guard let purchaseResult = try await product?.purchase(options: option) else { return }&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;swift&quot; data-ke-language=&quot;swift&quot;&gt;&lt;code&gt;// 구버전
// completion 메소드로 (SKProductsRequest?, SKProductsResponse?) -&amp;gt; Void를 넘길 수 있다
guard let product = IAPManager.shared.products?.first as? SKProduct else { return }
let payment = SKMutablePayment(product: SKProduct)
SKPaymentQueue.default().add(payment)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;product.purchase(options: Set&amp;lt;Product.PurchaseOption&amp;gt;)으로 상품 구매를 시도한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구매 완료까지 시간이 조금 걸리므로, await를 걸어서 구매 성공/실패 여부에 따른 행위는 반드시 응답을 받고 나서 할 수 있도록 한다.&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;결과는 5가지로 나뉜다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;구매 성공 &amp;amp; 인증 성공 (.success(.verified(Transaction))&lt;/li&gt;
&lt;li&gt;구매 성공 &amp;amp; 인증 실패 (.success(.unverified(Transaction, VerificationError)&lt;/li&gt;
&lt;li&gt;SCA, Ask to Buy(부모, 보호자 동의가 필요한 아동 계정의 구매)로 인한 대기 (.pending)&lt;/li&gt;
&lt;li&gt;취소 (.userCancelled)&lt;/li&gt;
&lt;li&gt;알 수 없음 (@unknown default)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;switch문으로 나눠 작성해주면 되는데, .success라도 .unverified 경우에는 구매 인증에 실패한 것이므로 완전한 성공이 아니다. 그러므로 .success(.verified(Transaction))인 경우만 성공한 것으로 간주해 다음으로 진행하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트랜잭션 완료를 위해 finish()를 호출한다.&lt;/p&gt;
&lt;pre id=&quot;code_1725351460347&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 신버전
switch purchaseResult {
case let .success(.verified(Transaction)):
    await transaction.finish()
    // 구매 성공 시의 처리
default: break
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;swift&quot; data-ke-language=&quot;swift&quot;&gt;&lt;code&gt;// 구버전
func handleTransaction(transaction: SKPaymentTransaction?) {
    let transactionState = transaction?.transactionState
    
    switch transactionState {
        case .purchased: // 구매됨
        case .purchasing: // 서버 큐에 트랜잭션이 올라가 있는 상태
        case .failed: // 서버 큐에 트랜잭션이 올라가기 전 취소되거나 실패함
        case .restored: // 유저의 구매 내역에서 트랜잭션이 복구됨
        case .deferred: // 트랜잭션이 큐ㅜ에 있지만 대기 상태로 멈춤
    }
}&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;이론상으로는 이렇게 하면 결제가 완료되긴 하지만, 실제로 결제 테스트를 해봤을 때는 보라색 삼각형과 함께 경고문구가 나타난다.&lt;/p&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;Making a purchase without listening for transaction updates risks missing successful purchases. Create a Task to iterate Transaction.updates at launch.
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트랜잭션 업데이트에 대한 리스너 없이 구매하는 건 구매 성공 업데이트를 놓칠 위험이 있으니, Transaction.updates로 순회할 Task를 만들라는 이야기다. &amp;rarr; 즉, 트랜잭션 리스너 역할의 Task 하나를 만들어야 한다. 부모님 동의를 받아 구매를 해야 하는 아동용 계정의 경우라든가.. 주로 .pending 단계에 transaction이 머무를 때 문제가 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트랜잭션 업데이트 관찰을 언제부터 할 것인가는 개발자의 선택에 달렸지만, 애플 문서에서는 앱을 시작하면서부터 관찰하도록 권장하고 있다. 만약 종료되지 않은 트랜잭션이 있다면, updates 리스너가 앱 구동되자마자 그 트랜잭션을 수신할 테니 그게 더 안전하다고 보는 것이다.&lt;/p&gt;
&lt;figure id=&quot;og_1725351490204&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;updates | Apple Developer Documentation&quot; data-og-description=&quot;The asynchronous sequence that emits a transaction when the system creates or updates transactions that occur outside the app or on other devices.&quot; data-og-host=&quot;developer.apple.com&quot; data-og-source-url=&quot;https://developer.apple.com/documentation/storekit/transaction/3851206-updates&quot; data-og-url=&quot;https://docs.developer.apple.com/documentation/storekit/transaction/3851206-updates&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/pF8RW/hyWZdUE30I/avPmZZijywosataQDPR240/img.jpg?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/eDord/hyWZmYmICR/eWQcKcsHtQSM9hbbS4e3U0/img.jpg?width=1024&amp;amp;height=512&amp;amp;face=0_0_1024_512&quot;&gt;&lt;a href=&quot;https://developer.apple.com/documentation/storekit/transaction/3851206-updates&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://developer.apple.com/documentation/storekit/transaction/3851206-updates&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/pF8RW/hyWZdUE30I/avPmZZijywosataQDPR240/img.jpg?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/eDord/hyWZmYmICR/eWQcKcsHtQSM9hbbS4e3U0/img.jpg?width=1024&amp;amp;height=512&amp;amp;face=0_0_1024_512');&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;updates | Apple Developer Documentation&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;The asynchronous sequence that emits a transaction when the system creates or updates transactions that occur outside the app or on other devices.&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;pre class=&quot;swift&quot;&gt;&lt;code&gt;var transactionListener: Task&amp;lt;Void, Error&amp;gt;?

    transactionListener = Task(priority: .background) {
        for await update in Transaction.updates {
            do {
                let transaction = try verifyPurchase(update)
                await transaction.finish()
            } catch {
                if let error = error as? Product.PurchaseError {
                    // 구매 에러 분기
                } else if let error = error as? Product.VerificationResult&amp;lt;Transaction&amp;gt;.VerificationError {
                    // 인증 에러 분기                
                }
            }
        }
    }

func verifyPurchase&amp;lt;T&amp;gt;(_ result: VerificationResult&amp;lt;T&amp;gt;) throws -&amp;gt; T {
    switch result {
    case .verified(let transaction):
        return transaction
    case .unverified(_, let error):
        throw error
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 태스크를 만들었다면, 트랜잭션 리스너를 갖고 있는 객체가 deinit될 때 태스크를 취소시켜야 한다. 그렇지 않으면 메모리 릭의 위험이 있다.&lt;/p&gt;
&lt;pre class=&quot;swift&quot; data-ke-language=&quot;swift&quot;&gt;&lt;code&gt;deinit {
    transactionListener?.cancel()
}&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;6. 애플 서버로 트랜잭션 ID, 환경을 전송해 최종 검증하기&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸로 끝난 건 아니고, 구매 결과인 트랜잭션 ID와 환경을 애플 서버로 다시 한 번 전송해 이 트랜잭션이 유효한지 확인해야 한다. 내 경우 이 작업은 서버가 대신해줬다. (클라이언트에서도 URLSession으로 시도할 수 있을지도.. 하지만 인앱결제가 있는 앱이 서버가 없을 것 같진 않다)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 개발자라면 이 문서가 도움이 될 것이다.&lt;/p&gt;
&lt;figure id=&quot;og_1725351515368&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;Get Transaction Info | Apple Developer Documentation&quot; data-og-description=&quot;Get information about a single transaction for your app.&quot; data-og-host=&quot;developer.apple.com&quot; data-og-source-url=&quot;https://developer.apple.com/documentation/appstoreserverapi/get_transaction_info&quot; data-og-url=&quot;https://docs.developer.apple.com/documentation/appstoreserverapi/get_transaction_info&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/k17EZ/hyWZk0yUXx/82g14cipoykNXbnwFMe8wK/img.jpg?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/gcl6Q/hyWZm48E1E/ENfJrWHpYvUHiYQPsd9J5K/img.jpg?width=1024&amp;amp;height=512&amp;amp;face=0_0_1024_512&quot;&gt;&lt;a href=&quot;https://developer.apple.com/documentation/appstoreserverapi/get_transaction_info&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://developer.apple.com/documentation/appstoreserverapi/get_transaction_info&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/k17EZ/hyWZk0yUXx/82g14cipoykNXbnwFMe8wK/img.jpg?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/gcl6Q/hyWZm48E1E/ENfJrWHpYvUHiYQPsd9J5K/img.jpg?width=1024&amp;amp;height=512&amp;amp;face=0_0_1024_512');&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;Get Transaction Info | Apple Developer Documentation&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Get information about a single transaction for your app.&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 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;인앱 결제를 지원하는 iOS 앱에는 구매 복원이라는 기능이 있고, 사용자에게 이것을 명시적으로 제공해야 한다. 이것은 구매 복원을 사실상 자동적으로 해주는 지금에도 유효하다. (웬만하면 사용자에게 &amp;ldquo;구매 복원을 했다&amp;rdquo;라는 느낌을 분명히 주도록 가이드)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;복원은 따로 할 필요는 없으나 정 필요할 경우에는 다음과 같이 시도할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1725351534380&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Task {
    do {
        try await AppStore.sync()
        for await result in Transaction.currentEntitlements {
            if case let .verified(transaction) = result {
                await transaction.finish()
                return
            }
        }
    } catch {
        // 에러 처리
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;swift&quot; data-ke-language=&quot;swift&quot;&gt;&lt;code&gt;func restoreReceipt(_ request: SKRequest) {
    guard let url = Bundle.main.appStoreReceiptURL else { return }

    if request is SKReceiptRefreshRequest, FileManager.default.fileExists(atPath: url.path) {
        if let receiptData = try? Data(contentsOf: url) {
            // 서버에 복원 요청
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;+ 번외: .updates / .currentEntitlements&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;막간을 이용해서, 결제를 관찰할 때는 .updates를 보는 것과 구매 복원 시에 .currentEntitlements를 보는 것 사이의 차이에 대해 간단히 보자면&lt;/p&gt;
&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;.updates: Transaction.Transactions&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시스템이 앱 바깥이나 다른 디바이스에서 일어나는 트랜잭션을 생성하거나 업데이트할 때, 그 트랜잭션을 방출하는 비동기 시퀀스&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앱 실행 중에 새로운 트랜잭션들을 수신할 때 .updates를 사용할 수 있다. Ask to Buy 트랜잭션이나 구독 리딤 코드, 앱스토어에서 발생하는 구매 등의 트랜잭션을 방출하는 시퀀스이다. 다른 기기에서 앱 내 구매를 완료했을 때도 트랜잭션을 방출한다.&lt;/p&gt;
&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;.currentEntitlements: Transaction.Transactions&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인앱 구매와 구독에 대해 고객에게 권리를 주는 최근의 트랜잭션들을 담은 시퀀스&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;비소모성 인앱결제에 대한 트랜잭션&lt;/li&gt;
&lt;li&gt;Product.SubscriptionInfo.RenewalState가 .subscribed, .inGracePeriod인 자동갱신되는 구독에 대한 최근 트랜잭션&lt;/li&gt;
&lt;li&gt;만료된 것을 포함해 갱신되지 않는 구독에 대한 최근 트랜잭션&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앱스토어에서 환불했거나 더이상 유효하지 않은 상품은 current entitlements에 나타나지 않는다. 소모성 인앱 결제 역시 나타나지 않는다. 종료되지 않은 소모성 상품에 대한 트랜잭션을 얻으려면, Transaction의 .unfinished, .all 시퀀스를 사용하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주로 구매한 상품들 정보를 다시 가져오거나 할 때 사용할 수 있다. 그러니까 .updates와는 사용 목적이 조금 다른 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;References&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/storekit/in-app_purchase&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;In-App Purchase&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://superwall.com/blog/make-a-swiftui-app-with-in-app-purchases-and-subscriptions-using-storekit-2&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;StoreKit 2 Tutorial with Swift UI - How to add In-App Purchases to your app&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>앱/Swift</category>
      <category>IAP</category>
      <category>in-app purchase</category>
      <category>StoreKit</category>
      <category>StoreKit2</category>
      <category>swift</category>
      <category>swift 인앱결제</category>
      <category>인앱결제</category>
      <author>사과먹는사람</author>
      <guid isPermaLink="true">https://dev-dain.tistory.com/317</guid>
      <comments>https://dev-dain.tistory.com/317#entry317comment</comments>
      <pubDate>Tue, 3 Sep 2024 19:22:43 +0900</pubDate>
    </item>
    <item>
      <title>UICollectionViewFlowLayout 설정으로 같은 줄의 셀 높이를 동일하게 맞추기</title>
      <link>https://dev-dain.tistory.com/316</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UICollectionViewFlowLayout에는 prepare() 메소드와 layoutAttributesForElement(in rect: CGRect) &amp;rarr; [UICollectionViewLayoutAttributes]? 라는 메소드가 있어 이것을 오버라이드할 수 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;prepare : 현재 레이아웃을 업데이트하도록 레이아웃 객체에게 지시
&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;매 레이아웃 업데이트 동안 콜렉션뷰는 prepare()을 먼저 호출해 레이아웃 객체에게 다음 레이아웃 수행을 위한 준비를 하도록 함&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;layoutAttributesForElement : 특정 직사각형 영역 내의 모든 셀과 뷰의 레이아웃 attributes를 가져온다. &lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 레이아웃 배치를 하는 코드가 길긴 하지만 일부 발췌해서 올린다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;셀을 하단으로 배치시키는 방법은 여러 가지가 있겠으나, 여기서 내가 사용한 트릭은 한 줄의 셀 중 가장 높은 셀로 높이를 통일시키고, 실제 이미지뷰 높이가 적은 경우에는 top constriant를 0보다 크도록 잡아서 그만큼 위로부터 띄우는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;swift&quot; data-ke-language=&quot;swift&quot;&gt;&lt;code&gt;// 가로의 아이템들(=같은 높이에 있는)의 높이를 가장 높은 것으로 맞춰준다.
    if rect.count &amp;gt; 2, let lastRect = array.last?.frame {
        let sameLineRects = array.filter { $0.frame.origin.y == lastRect.origin.y }
        let maxHeight = sameLineRects.map { $0.size.height }.max() ?? 0
                            
        sameLineRects.forEach { rect in
            array.frame.size.height = maxHeight
        }
    }&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;간단한 아이디어로, 같은 높이에 있는(frame.origin.y가 같은 것으로 판단) 셀들을 모아서 그 중 가장 높은 높이로 frame height를 재조정하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>앱/Swift</category>
      <category>swift</category>
      <category>UICollectionView</category>
      <category>uicollectionviewflowlayout</category>
      <author>사과먹는사람</author>
      <guid isPermaLink="true">https://dev-dain.tistory.com/316</guid>
      <comments>https://dev-dain.tistory.com/316#entry316comment</comments>
      <pubDate>Mon, 26 Aug 2024 19:36:54 +0900</pubDate>
    </item>
    <item>
      <title>Swift Concurrency 속 Continuation의 쓰임 (feat. 컴플리션 핸들러 &amp;rarr; async-await)</title>
      <link>https://dev-dain.tistory.com/315</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원래 비동기 작업 처리에 RxSwift를 주로 이용해서 앱을 만들었는데, 올해부터는 Swift Concurrency를 주로 사용해서 비동기 작업을 하고 있다. 개인적으로 Combine은 기존에 RxCocoa를 사용하던 UI 작업이나 비동기 작업 결과를 가공하는 데에 쓰기 좋고, async-await은 통신 작업과 같이 비동기 작업에서의 RxSwift를 대체하는 느낌이 강하다고 생각한다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;하반기 시작하면서 회사 앱에도 Swift Concurrency를 도입해서 기존 컴플리션 핸들러(!)를 사용해서 깊은 depth를 가진 코드를 변경하고자 통신부를 async-await 사용할 수 있도록 감싸는 작업을 했다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;기존 통신 로직은 이랬다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Requester를 만든다. 필요한 헤더, 베이스 URL, 쿼리, 바디 등은 미리 만들어두거나 받는다.&lt;/li&gt;
&lt;li&gt;메소드에 따라 Alamofire request를 생성한다.&lt;/li&gt;
&lt;li&gt;request 요청 후 실행할 responseData도 같이 구성한다. 요청 성공, 실패에 따라 분기한다.&lt;/li&gt;
&lt;li&gt;이 때 통신 완료/실패 처리에는 컴플리션 핸들러를 넘겨서 이용하기 때문에, 분기가 있거나 추가적인 작업이 있을 경우 depth가 깊어지기도 한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;작년에 이미 한 번 RxSwift 방식의 request 처리를 다뤄서 일부 사용하고 있기 때문에, 요청을 바로 하지 않고 Requester만 만드는 작업은 이미 해놨다. 그래서 범용적인 complete, failed 메소드만 처리하면 됐다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이번 작업은 네트워크 통신에서의 작업을 분리하기 이전에 먼저 가독성을 위해, 또 async-await 방식의 코드에 문제가 있지 않은지 베타 테스트를 할 겸 기존에 쓰던 통신 객체를 async-await하게 요청하도록 감싸도록 진행했다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;막간을 이용한 async-await의 원리&lt;/b&gt;&lt;/h4&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;보통 async-await을 이용한 비동기 코드는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;swift&quot; style=&quot;color: #000000; text-align: start;&quot; data-ke-language=&quot;swift&quot;&gt;&lt;code&gt;func getFollowerIdList(id: Int) async -&amp;gt; [Int]? {
		do {
				let profile = try await ProfileApi.asyncRequest()
				let user = try await UserApi.asyncRequest(profile)
				let followers = try await UserApi.followers.asyncRequest(user)
				return followers.compactMap { $0.id }		
		} catch let error {
				// handling error
		}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이걸 컴플리션 핸들러를 사용하는 방식으로 처리해야 한다면 요런 코드가 될 것이다.&lt;/p&gt;
&lt;pre class=&quot;swift&quot; style=&quot;color: #000000; text-align: start;&quot; data-ke-language=&quot;swift&quot;&gt;&lt;code&gt;func getFollowerIdList(id: Int, completion: ([Int]? -&amp;gt; Void) -&amp;gt; Void {
		ProfileApi.request() { profile in
				guard let profile else { return }
				UserApi.request(profile) { user in 
						guard let user else { return } 
						UserApi.followers.request(user) { followers in. 
								guard let followers else { return }
								completion(followers.compactMap { $0.id })
						} 
				}		
		}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 정도 depth의 코드를 async-await으로 간결하게 만드는 것 자체가 기적이다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이론적으로 봤을 때 메소드 시그니처에 async를 붙이고, await을 호출해 비동기 코드를 핸들링하는 것이 async-await를 쓰는 방법이다. 이 때, await 키워드는 중지점(suspension point)으로, 코드 수행 중 await 키워드를 만날 때 시스템에게 메소드를 잠시 중지하고(스레드의 제어권을 양보(yield)해서 시스템이 제어권을 가져온다) 다른 스레드에서 작업(다운로드라든지, 통신이라든지)을 할 것을 통지한다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;스위프트 시스템은 힙에 현재 함수의 상태(중지점에서 이어서 실행하는 데 필요한 컨텍스트)를 저장하고, continuation(연속)을 만든다. 돌아와서 계속 실행할 연속성을 만드는 것이다. 그래서 await로 호출한 작업이 종료되면 continuation이 resume되고, 중지돼있던 곳부터 다시 작업 수행이 시작되는 것이다. (단, suspend 과정에서 추가적인 스레드 생성 없이 작업할 수 있도록 적절한 스레드로 배정하는 과정이 포함되어 있기에, resume 시 배정받는 스레드는 이전과 다를 수도 있음)&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;힙에 저장되는 이유는, 스레드마다 생성되는 스택 영역과 달리 힙은 모든 스레드가 공유할 수 있기 때문에 다른 스레드에서 resume되더라도 일시정지된 함수 상태를 알 수 있기 때문이다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;async-await을 사용할 때 시스템은 자동으로 continuation을 관리하게 된다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Continuation&lt;/b&gt;&lt;/h4&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그런데 위 코드에 continuation은 끼어들 자리가 없다. 이미 async-await 자체로 Swift Concurrency를 이용한 비동기 작업을 완벽하게 수행하고 있다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그럼 continuation이란 게 왜 필요한가? 기존에 컴플리션 핸들러로 작성된 코드를 완벽하게 async-await으로 변경할 수 없을 때, 기존의 컴플리션 핸들러를 감싸서 사용하거나 해야 할 때 사용하는 것이다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;Continuation&lt;/span&gt;은 프로그램의 상태를 표현한다. 특히 UIKit에서 많이 사용되고, 이미 존재하는 델리게이트와 컴플리션 핸들러 코드를 async 방식으로 쉽게 옮길 수 있도록 도와주는 것이 Continuation API다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Continuation은 Unsafe, Checked 크게 2가지로 나뉘는데 resume을 딱 1번 호출하는지를 체크하느냐 그렇지 않느냐의 차이다. 비동기 작업을 실행하려면 continuation.resume을 호출해야 하는데, resume을 아예 호출하지 않으면 태스크를 계속 await 상태로 두기 때문에 관련 리소스의 누수가 일어날 수 있고, 2번 이상 호출하는 건 정의되지 않은 행위로 직접 수행해봤을 때는 앱이 죽는 결과를 가져왔다. 그러므로 정말 딱 1번만 호출할 수 있게끔 해야 하는데 개발자는 실수할 수 있으므로 적어도 개발하는 단계에서는 Checked를 사용하면 좋다. Checked는 규칙이 위반됐을 때 로그를 남겨준다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Checked의 경우 resume 동작이 없거나 여러 번 있는지를 런타임에서 체크하지만, Unsafe의 경우 오버헤드를 적게 하는 것이 목표이기 때문에 가능한 런타임 체크를 하지 않는다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;여기에 Throwing까지 추가하면, 이 continuation은 이제 에러가 있을 때 resume(throwing:)으로 에러를 던질 수도 있게 된다.&lt;/p&gt;
&lt;pre class=&quot;swift&quot; style=&quot;color: #000000; text-align: start;&quot; data-ke-language=&quot;swift&quot;&gt;&lt;code&gt;    func asyncRequest() async throws -&amp;gt; ResponseType? {
        try await withCheckedThrowingContinuation { [weak self] continuation in
            guard let self else { 
                continuation.resume(throwing: NSError.init(domain: &quot;Requester has disappeared.&quot;, code: -1))
                return
            }
            self.success = { data in
                guard let data else {
                    continuation.resume(throwing: NSError.init(domain: &quot;no data&quot;, code: 0))
                    return
                }
                continuation.resume(returning: data)
            }
            
            self.fail = { [weak self] error, data in
                if self?.failCount == 0 {
                    continuation.resume(throwing: error ?? NSError.init(domain: &quot;network error&quot;, code: 0))
                }
                return
            }
            
            self.request()
        }
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;사용하는 곳에선 이렇게 사용할 수 있다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Task는 비동기 작업의 단위이다. 때문에 Task를 사용하는 건.. 알아서 정해서 사용하면 된다.&lt;/p&gt;
&lt;pre class=&quot;swift&quot; style=&quot;color: #000000; text-align: start;&quot; data-ke-language=&quot;swift&quot;&gt;&lt;code&gt;Task {
		await withTaskCancellationHandler {
				do {
						let profile = try await ProfileApi.asyncRequest()
						// code
				}	catch let error {
						// handling error
				}
		} onCancel: {
				// cancel the task
		}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Continuation과 컴플리션 핸들러의 차이점&lt;/b&gt;&lt;/h4&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;얼핏 봐서는 continuation이 컴플리션 핸들러와 비슷해 보인다. Continuation을 쓰는 이점이 있을까?&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;GCD 방식에서, 우리는 주로 컴플리션 핸들러를 보내서 작업의 성공/실패에 따른 후속 작업을 하게 된다. 이 경우 스레드 블락 시 태스크를 다른 스레드로 보내는 데 Full thread context switching 비용이 발생한다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;하지만 continuation을 사용할 때는 function call 정도 비용으로 메소드 호출, 실행이 가능해진다. 왜냐하면 Swift concurrency에서는 &lt;b&gt;core 개수에 맞게 스레드를 사용&lt;/b&gt;하기 때문에 메모리와 스케줄링에 있어 부담을 덜 수 있고, 코어에서 실행하는 스레드를 갈아끼우는 컨텍스트 스위칭이 일어나지 않기 때문이다. 스레드를 사용하는 대신 Continuation을 사용해 작업이 다시 시작하는지 추적한다. 스위칭은 continuation 간에 일어난다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;References&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/videos/play/wwdc2021/10254/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;Swift concurrency: Behind the scenes&lt;/span&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.kodeco.com/38838074-swift-concurrency-continuations-getting-started&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Swift Concurrency Continuations: Getting Started&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://minsone.github.io/swift-concurrency-continuation&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;[Swift 5.7+][Concurrency] Continuations - Closure를 async 코드로 감싸 사용하기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://velog.io/@kimscastle/iOS-swift-concurrency%EC%9D%98-continuation%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;[iOS] swift concurrency의 continuation알아보기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://yongminlee26.tistory.com/383&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;async/await, continuation, Actor&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://velog.io/@wansook0316/Swift-ConcurrencyBehind-the-scenes-Part.-01&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Swift Concurrency: Behind the scenes Part. 01&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>앱/Swift</category>
      <category>async await</category>
      <category>continuation</category>
      <category>swift</category>
      <category>Swift Concurrency</category>
      <author>사과먹는사람</author>
      <guid isPermaLink="true">https://dev-dain.tistory.com/315</guid>
      <comments>https://dev-dain.tistory.com/315#entry315comment</comments>
      <pubDate>Mon, 22 Jul 2024 20:42:13 +0900</pubDate>
    </item>
    <item>
      <title>Constructing an object of class type with a metatype value must use a &amp;lsquo;required&amp;rsquo; initializer 해결하기</title>
      <link>https://dev-dain.tistory.com/314</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론만 먼저 쓰자면, metatype이 되는 그 클래스의 init에 required를 붙여주면 해결되는 문제다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그런데 왜 그래야 할까? 이유는 맥락과 차근차근 설명하는 걸로~&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;기존에 쓰던 통신 코드를 async-await처럼 사용할 수 있을까 고민하다가 기존 Requester 객체에서 complete 컴플리션 핸들러와 failed 컴플리션 핸들러를 continuation resume하는 것으로 변경하면 되겠다는 생각에 그렇게 수정했다. (추후 포스팅 예정)&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;통신에 앞서, 먼저 필요한 옵션을 생성하도록 되어 있다. 이 옵션은 프로토콜을 준수한 구체 클래스를 생성해서 집어넣는다. 엔드포인트, 쿼리, 바디 등 여러 가지 옵션을 포함한다. 그런데 엔드포인트만 지정하는 기본 옵션이라면 API 클래스마다 이 옵션까지 공통화할 수는 없을까? 하는 생각이 들어 묶어놓는 작업을 시작했다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;처음에 작성한 초안은 이렇다. 참고로, BaseRequestOption은 상술한 것과 같이 엔드포인트, 쿼리 등을 포함하고 있어 통신에 꼭 필요한 객체이며 클래스 타입이다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1720168098576&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;protocol APIProtocol: AnyObject {
    associatedtype DataType: Codable
    
    static func getRequester() -&amp;gt; Requester&amp;lt;DataType&amp;gt;
}

class API&amp;lt;DataType: Codable&amp;gt;: APIProtocol {
    class RequestOption: BaseRequestOption { }
    class func getRequester() -&amp;gt; Requester&amp;lt;DataType&amp;gt; {
        Requester(sender, option: self.RequestOption())
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;그리고 API를 상속받는 커스텀 API들이 있다. 이런 식으로 사용한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1720168119016&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;class UserApi: API&amp;lt;User&amp;gt; {
    class RequestOption: BaseRequestOption {
        override func endPoint() -&amp;gt; String { &quot;/user&quot; } 
    }
    
    // API 클래스마다 중복으로 사용하는 이 함수를 없애는 것이 목표 
    override class func getRequester() -&amp;gt; Requester&amp;lt;User&amp;gt; {
		    return Requester(option: RequestOption())
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만든 것까진 좋은데, UserApi 클래스의 getRequester 오버라이드 메소드를 지우고 요청했을 때 원하는 결과가 나오지 않는다. 이유는 UserApi 클래스에서 새로 생성한 RequestOption을 사용하는 것이 아니라 수퍼 클래스인 API 클래스의 빈 RequestOption을 사용하기 때문이다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 경우, getRequester 시그니처 매개변수에 Option을 추가하는 방식으로 풀 수는 있다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;하지만 그렇게 하면 이미 사용 중인 메소드들을 전부 변경해야 하기 때문에 해야 할 일이 조금 더 늘어난다. 목표는 함수 시그니처를 변경하지 않고 원래대로 사용할 수 있도록 하는 것이다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;문제 해결을 위해 APIProtocol의 associatedtype을 하나 더 추가했다.&lt;/p&gt;
&lt;pre id=&quot;code_1720168143651&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;protocol APIProtocol: AnyObject {
    associatedtype DataType: Codable
    associatedtype RequestOptionType: BaseRequestOption

    static func getRequester(_ sender: Requestable, _ option: RequestOptionType) -&amp;gt; Requester&amp;lt;DataType&amp;gt;
}

class API&amp;lt;DataType: Codable, RequestOptionType: BaseRequestOption&amp;gt;: APIProtocol {
    class func getRequester(_ sender: Requestable, _ option: RequestOptionType = RequestOptionType()) -&amp;gt; Requester&amp;lt;DataType&amp;gt; {
        Requester(sender, option: option)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 변경해주면, 하위 클래스에서 getRequester를 호출했을 때 하위 클래스에 선언한 RequestOption을 생성하여 Requester를 구성할 수 있다. (참고로, BaseRequestOption은 클래스 타입이다)&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그런데 이 때, RequestOptionType = RequestOptionType()으로 생성하려고 할 때에는 init에 required가 반드시 붙어야 한다. RequestOptionType은 BaseRequestOption을 상속하는 타입이라면 어떤 것이든 될 수 있다. 만약 만들려는 RequestOptionType()에 생성자가 없다면? 베이스 클래스 생성자라도 상속받아야 하지 않는가?&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;required 자체의 의미는 어떤 클래스의 모든 서브클래스들이 해당 생성자를 구현해야 한다는 것을 의미한다고 문서에 적혀 있다. required를 달면, 모든 서브 클래스들이 그 생성자를 구현해야 한다. 하지만 수퍼클래스의 생성자를 상속받는 것으로도 구현이 가능하다면 추가 구현은 면제된다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그러니까 이 required 키워드의 진짜 의미는, 컴파일러에게 어떤 클래스가 갖는 서브클래스들이 생성자를 상속받거나 같은 생성자를 새로 구현할 것이라고 고지해주는 것이다. 서브 클래스가 자신만의 생성자를 갖고 있다면 수퍼 클래스의 생성자는 상속받지 않는 것이다. 그렇기 때문에 수퍼클래스는 생성자가 필요하고, 서브클래스는 꼭 필요하지는 않은 것이다(상속받으면 되니까).&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;References&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://stackoverflow.com/questions/32163124/why-must-constructing-an-object-of-class-type-someclass-with-a-metatype-value&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Why must constructing an object of class type 'someClass' with a metatype value use a 'required' initializer?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://stackoverflow.com/questions/30085264/why-use-required-initializers-in-swift-classes&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Why use required initializers in Swift classes?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.swift.org/swift-book/documentation/the-swift-programming-language/initialization/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Intiailization&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>앱/Swift</category>
      <category>init</category>
      <category>Initializer</category>
      <category>required init</category>
      <category>swift</category>
      <category>swift 생성자</category>
      <author>사과먹는사람</author>
      <guid isPermaLink="true">https://dev-dain.tistory.com/314</guid>
      <comments>https://dev-dain.tistory.com/314#entry314comment</comments>
      <pubDate>Sat, 6 Jul 2024 11:31:56 +0900</pubDate>
    </item>
    <item>
      <title>식집사 입문기 + 강남 분갈이업체 더나무 추천 (내돈내산)</title>
      <link>https://dev-dain.tistory.com/313</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일단 추천부터..&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고로 분갈이는 내돈내산입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;돈 받고 후기쓴 거면 눈에 흙이 들어가도 암말 않겠습니다.&lt;/p&gt;
&lt;figure id=&quot;og_1713360641328&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;더나무 블로그 : 네이버 블로그&quot; data-og-description=&quot;서울*경기 출장분갈이 전문업체전문업체입니다 언제든지 문의주세요 ! https://smartstore.naver.com/thenamuu&quot; data-og-host=&quot;blog.naver.com&quot; data-og-source-url=&quot;https://blog.naver.com/baljji&quot; data-og-url=&quot;https://blog.naver.com/baljji&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/hAdZS/hyVSXrB6mi/HnTd3896WojcIFctowrWDK/img.jpg?width=204&amp;amp;height=204&amp;amp;face=0_0_204_204&quot;&gt;&lt;a href=&quot;https://blog.naver.com/baljji&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://blog.naver.com/baljji&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/hAdZS/hyVSXrB6mi/HnTd3896WojcIFctowrWDK/img.jpg?width=204&amp;amp;height=204&amp;amp;face=0_0_204_204');&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;더나무 블로그 : 네이버 블로그&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;서울*경기 출장분갈이 전문업체전문업체입니다 언제든지 문의주세요 ! https://smartstore.naver.com/thenamuu&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;blog.naver.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;강남역 인근에서 스파티필름 분갈이를 할 수 있는 업체를 알아보다가 사장님의 블로그를 발견하고 오픈카톡을 남겼습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정말 친절하게 상담해주셨고요, 가격도 미리 알려주신 금액에서 추가금 없이 깔끔하게 진행되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;휴일을 제외하고 하루만에 작업이 완료되어 직접 사무실로 가져다주셨는데요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좀 더 넓은 토분으로 스파티필름을 이사시켰는데, 마음에 듭니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;식물도 기뻐하는 느낌 ^^&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사장님이 아주 친절하시고 가격도 괜찮은 편이라고 생각합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가격은 화분 2만원, 흙과 분갈이 비용 및 가져다주시는 비용까지 2만원 해서 4만원 들었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;식물을 잘 몰라서 이거 싼 건가 비싼 건가.. 긴가민가했는데요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전문가에게 맡겨서 스파티가 건강하게 자랄 수 있으며, 인건비와 출장 오시는 비용까지 합쳐 생각해보면 전혀 비싸지 않고, 오히려 저렴하다는 생각까지 드네요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과적으로는 전혀 아깝지 않은 지출이었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;완전 강추!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;&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;때는 작년 12월, 제 생일 무렵...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;D 대리님이 생일 선물로 식물을 하나 사주신다고 하셔서 D 대리님이 식물을 공수하시는 스마트스토어를 살펴봤습니다.&lt;/p&gt;
&lt;figure id=&quot;og_1715253151186&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;치노꽃 : 네이버쇼핑 스마트스토어&quot; data-og-description=&quot;Flower/plant/goods&quot; data-og-host=&quot;smartstore.naver.com&quot; data-og-source-url=&quot;https://smartstore.naver.com/jino0324&quot; data-og-url=&quot;https://smartstore.naver.com/jino0324&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/gRl6Z/hyV2rfu8V1/mLhkrk5lUTDLfpLBmQTy2K/img.jpg?width=1000&amp;amp;height=1000&amp;amp;face=0_0_1000_1000,https://scrap.kakaocdn.net/dn/hkWyx/hyV2BWJPYr/7KtHEVL0vS6Ricg33EDd60/img.jpg?width=1000&amp;amp;height=1000&amp;amp;face=0_0_1000_1000,https://scrap.kakaocdn.net/dn/FBfLm/hyV2yTfHxc/93EKFG3Wbeq2kbi0EgtGWK/img.jpg?width=396&amp;amp;height=396&amp;amp;face=0_0_396_396&quot;&gt;&lt;a href=&quot;https://smartstore.naver.com/jino0324&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://smartstore.naver.com/jino0324&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/gRl6Z/hyV2rfu8V1/mLhkrk5lUTDLfpLBmQTy2K/img.jpg?width=1000&amp;amp;height=1000&amp;amp;face=0_0_1000_1000,https://scrap.kakaocdn.net/dn/hkWyx/hyV2BWJPYr/7KtHEVL0vS6Ricg33EDd60/img.jpg?width=1000&amp;amp;height=1000&amp;amp;face=0_0_1000_1000,https://scrap.kakaocdn.net/dn/FBfLm/hyV2yTfHxc/93EKFG3Wbeq2kbi0EgtGWK/img.jpg?width=396&amp;amp;height=396&amp;amp;face=0_0_396_396');&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;치노꽃 : 네이버쇼핑 스마트스토어&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Flower/plant/goods&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;smartstore.naver.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&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;span style=&quot;letter-spacing: 0px;&quot;&gt;참고로 전 그 동안 다육이, 로즈마리, 그 키우기 쉽다는 허브 등등 다 죽여온 마이너스의 손으로, 농사의 신 데메테르도 축복을 내리지 못하고 경악하며 외면할 식킬러였는데요.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 좌절하지 않고 계속 식물을 산다는 건 당신이 화훼 업계에 빛과 소금과 같은 존재나 다름없다는 위로를 받긴 했지만 그렇다고 기껏 사온 식물들을 죽여온 제 마음까지 치유가 되진 않더군요..&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇게 대학 졸업반 이후로 식물에게 마음을 닫게 됐다가 이번에 기르게 됐습니다.&lt;/p&gt;
&lt;p&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/bHuNwE/btsG8FlL1o2/Om4BjZUn7s663jmrqutTk0/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bHuNwE/btsG8FlL1o2/Om4BjZUn7s663jmrqutTk0/img.jpg&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;590&quot; data-origin-height=&quot;1280&quot; data-filename=&quot;IMG_5250.JPG&quot; data-widthpercent=&quot;33.33&quot; style=&quot;width: 32.5581%; margin-right: 10px;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bHuNwE/btsG8FlL1o2/Om4BjZUn7s663jmrqutTk0/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbHuNwE%2FbtsG8FlL1o2%2FOm4BjZUn7s663jmrqutTk0%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;590&quot; height=&quot;1280&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cKbJ9G/btsG8nZXuGZ/EKTbRS3WT6KjVYoIG7Bxjk/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cKbJ9G/btsG8nZXuGZ/EKTbRS3WT6KjVYoIG7Bxjk/img.jpg&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;590&quot; data-origin-height=&quot;1280&quot; data-filename=&quot;IMG_5260.JPG&quot; data-widthpercent=&quot;33.33&quot; style=&quot;width: 32.5581%; margin-right: 10px;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cKbJ9G/btsG8nZXuGZ/EKTbRS3WT6KjVYoIG7Bxjk/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcKbJ9G%2FbtsG8nZXuGZ%2FEKTbRS3WT6KjVYoIG7Bxjk%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;590&quot; height=&quot;1280&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/UsDJ7/btsG8bkYmtU/JpUczsM7tRK94lIuOPrGkK/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/UsDJ7/btsG8bkYmtU/JpUczsM7tRK94lIuOPrGkK/img.jpg&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;590&quot; data-origin-height=&quot;1280&quot; data-filename=&quot;IMG_5368.JPG&quot; data-widthpercent=&quot;33.34&quot; style=&quot;width: 32.5581%;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/UsDJ7/btsG8bkYmtU/JpUczsM7tRK94lIuOPrGkK/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FUsDJ7%2FbtsG8bkYmtU%2FJpUczsM7tRK94lIuOPrGkK%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;590&quot; height=&quot;1280&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 온 스파티는 아담한 사이즈의 토분에 담겨 왔습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 상태가 영.. 별로였다. 물을 줘도 별로 신나하는 것 같지도 않고 그냥 시큰둥.. 한 채로 있다고나 할까요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일 주일쯤 지나 대리님이 대신 꽃집 사장님께 연락을 드렸고, 사장님은 스파티가 냉해를 입은 것 같다고(12월이었으니 그럴만하긴 하다) 새로 보내주신다고 하셨습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇게 2024년 업무를 개시한 1월 첫 주에 저의 새 스파티가 왔습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제가 열흘 정도 맡아 키우던 스파티는 대리님이 기르시기로 했습니다. 가끔 대리님 자리에 가서 농담도 하고 일 얘기도 하고 하면서 보는데, 그 녀석 상태는 여전히 별로 좋지는 않네요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;KakaoTalk_20240509_202458668.jpg&quot; data-origin-width=&quot;590&quot; data-origin-height=&quot;1280&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/DAAwN/btsHjXAS3d9/R8AdUChQYXnnqlqTOQaxd1/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/DAAwN/btsHjXAS3d9/R8AdUChQYXnnqlqTOQaxd1/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/DAAwN/btsHjXAS3d9/R8AdUChQYXnnqlqTOQaxd1/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FDAAwN%2FbtsHjXAS3d9%2FR8AdUChQYXnnqlqTOQaxd1%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;590&quot; height=&quot;1280&quot; data-filename=&quot;KakaoTalk_20240509_202458668.jpg&quot; data-origin-width=&quot;590&quot; data-origin-height=&quot;1280&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;줄기가 굵고 선명한 녹색을 띠었으며, 이파리도 축 처지지 않고 씩씩하게 고개를 들고 있었죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무엇보다 기특한 건 온 지 얼마 안되어 하얀 불염포 속에 꽃을 여럿 피웠다는 겁니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;KakaoTalk_20240509_202458668_04.jpg&quot; data-origin-width=&quot;590&quot; data-origin-height=&quot;1280&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bkMGs9/btsHkm8uPNi/Jjo1aArmdnPjYh77Vzkt0k/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bkMGs9/btsHkm8uPNi/Jjo1aArmdnPjYh77Vzkt0k/img.jpg&quot; data-alt=&quot;친구가 선물해준 카피바라 인형과 함께..&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bkMGs9/btsHkm8uPNi/Jjo1aArmdnPjYh77Vzkt0k/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbkMGs9%2FbtsHkm8uPNi%2FJjo1aArmdnPjYh77Vzkt0k%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;590&quot; height=&quot;1280&quot; data-filename=&quot;KakaoTalk_20240509_202458668_04.jpg&quot; data-origin-width=&quot;590&quot; data-origin-height=&quot;1280&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;친구가 선물해준 카피바라 인형과 함께..&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇게 2달쯤 지났을까? 3월쯤에는 스파티가 새끼를 쳤습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;언제부턴가 메인 브랜치에서 새로운 브랜치로 갈라지더군요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;노랗게 떡잎이 올라오고 여린 싹들, 아직 펼쳐지지 않은 잎들이 줄기에 달린 채 조금씩 자라나고 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;KakaoTalk_20240509_202458668_07.jpg&quot; data-origin-width=&quot;590&quot; data-origin-height=&quot;1280&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/MzvsQ/btsHiCYwXy8/lpeVToKJJ1S1wCCV5ZuI01/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/MzvsQ/btsHiCYwXy8/lpeVToKJJ1S1wCCV5ZuI01/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/MzvsQ/btsHiCYwXy8/lpeVToKJJ1S1wCCV5ZuI01/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FMzvsQ%2FbtsHiCYwXy8%2FlpeVToKJJ1S1wCCV5ZuI01%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;590&quot; height=&quot;1280&quot; data-filename=&quot;KakaoTalk_20240509_202458668_07.jpg&quot; data-origin-width=&quot;590&quot; data-origin-height=&quot;1280&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;새끼까지 자라기에 화분이 좀 작다는 생각이 들었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;분갈이를 해줘야겠다고 마음 먹고 검색을 해봤는데...&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;스파티필름은 뿌리가 약하고 분갈이를 잘못 해주면 몸살을 앓을 수 있습니다.&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;분갈이를 해줘도 걱정인 게, 부모와 자식을 떨어뜨려놔도 어차피 화분이 하나 더 필요하고 흙도 충분히 필요하데요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예전에 저희 가족이 도시텃밭이 당첨된 적이 있는데, 그 때 저는 모종을 심고 식물을 가꾸는 게 아니라 우리 상추를 먹는 벌레를 호멩이로 찍어 없애는 게 주 임무였습니다. 한마디로 킬러로 고용됐는데 당연히 식물에 어떤 흙이 필요한지 모르는 상태였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무엇보다 문제였던 건, 회사에서 분갈이를 할 수 없으니 집으로 가져가야 하는데 도저히 집에 가져갈 타이밍이 안 났다는 겁니다. 강남역에서 집까지는 언제 가도 사람이 많았고 앉아서 가는 건 절대 꿈도 못 꿨기에... 화분을 안전히 집에 가져가는 것 자체가 챌린지였어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&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/c2Mfhx/btsHkLUxjUk/kpWdlwZxu0YtI0MwcWI9A1/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c2Mfhx/btsHkLUxjUk/kpWdlwZxu0YtI0MwcWI9A1/img.jpg&quot; data-filename=&quot;KakaoTalk_20240509_202458668_10.jpg&quot; data-origin-height=&quot;1440&quot; data-origin-width=&quot;810&quot; data-is-animation=&quot;false&quot; style=&quot;width: 42.3588%; margin-right: 10px;&quot; data-widthpercent=&quot;42.86&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c2Mfhx/btsHkLUxjUk/kpWdlwZxu0YtI0MwcWI9A1/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc2Mfhx%2FbtsHkLUxjUk%2FkpWdlwZxu0YtI0MwcWI9A1%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;810&quot; height=&quot;1440&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mnO9M/btsHjtNBDIa/Pif5HTQwIelwOL0N3x9orK/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mnO9M/btsHjtNBDIa/Pif5HTQwIelwOL0N3x9orK/img.jpg&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;1440&quot; data-filename=&quot;KakaoTalk_20240509_202458668_11.jpg&quot; style=&quot;width: 56.4784%;&quot; data-widthpercent=&quot;57.14&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mnO9M/btsHjtNBDIa/Pif5HTQwIelwOL0N3x9orK/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmnO9M%2FbtsHjtNBDIa%2FPif5HTQwIelwOL0N3x9orK%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;1080&quot; height=&quot;1440&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
  &lt;figcaption&gt;화분을 맡기러 가기 전 식사를 했습니다. 갬성있는 한컷&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 분갈이 업체를 알아보고 더나무에 맡기게 됐습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;분갈이 자체에는 만족합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만... 이 넓은 와중에도 부모와 새끼 스파티가 같이 있는 것을 보니 뭔가 둘이 분리해주고 싶다는 생각이 들긴 하네요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여름까지 무럭무럭 잘 큰다면 분리를 한 번 맡겨야하지 않을까... 싶습니다. 새끼 스파티! 독립하자!&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/oWB9f/btsHid5Nlud/xcZNWQKMofpRBrDRB4IiOk/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/oWB9f/btsHid5Nlud/xcZNWQKMofpRBrDRB4IiOk/img.jpg&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;590&quot; data-origin-height=&quot;1280&quot; data-filename=&quot;KakaoTalk_20240509_202458668_13.jpg&quot; style=&quot;width: 49.4186%; margin-right: 10px;&quot; data-widthpercent=&quot;50&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/oWB9f/btsHid5Nlud/xcZNWQKMofpRBrDRB4IiOk/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FoWB9f%2FbtsHid5Nlud%2FxcZNWQKMofpRBrDRB4IiOk%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;590&quot; height=&quot;1280&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bpy4ES/btsHjbTU1sq/HztPiPvte94A11hbtPe4w0/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bpy4ES/btsHjbTU1sq/HztPiPvte94A11hbtPe4w0/img.jpg&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;590&quot; data-origin-height=&quot;1280&quot; data-filename=&quot;KakaoTalk_20240509_202458668_14.jpg&quot; style=&quot;width: 49.4186%;&quot; data-widthpercent=&quot;50&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bpy4ES/btsHjbTU1sq/HztPiPvte94A11hbtPe4w0/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbpy4ES%2FbtsHjbTU1sq%2FHztPiPvte94A11hbtPe4w0%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;590&quot; height=&quot;1280&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;물을 8~10일에 한 번 주고, 2~3일에 한 번씩 분무를 충분히 해주고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실내에서 기르다 보니 온습도가 저절로 조절되는 편이라, 딱히 관리는 해주고 있지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여름에는 급수 텀을 조금 줄여볼 생각입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;</description>
      <category>일상</category>
      <category>강남출장분갈이더나무</category>
      <category>더나무</category>
      <category>사무실식물</category>
      <category>스파티필름</category>
      <author>사과먹는사람</author>
      <guid isPermaLink="true">https://dev-dain.tistory.com/313</guid>
      <comments>https://dev-dain.tistory.com/313#entry313comment</comments>
      <pubDate>Thu, 9 May 2024 20:29:23 +0900</pubDate>
    </item>
    <item>
      <title>2023 겨울 돌아보기</title>
      <link>https://dev-dain.tistory.com/312</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미 2월은 한 달도 전에 끝났지만, 늦게라도 회고를 올린다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1년 단위로 회고를 하려니까 뭔가 사건은 많았는데 추적하기 버거운 양이라서 앞으로는 분기마다 1번씩은 회고하려고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;직장: 기술&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작년 11월, 3.0.0 버전 메이저 업데이트 이후에 겨울 동안은 규모가 큰 작업을 하지는 않았다. 그래도 좀 중요한 작업을 꼽자면&amp;hellip;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;12월에 ReactorKit, Realm 사내 세미나를 진행했다.&lt;/li&gt;
&lt;li&gt;테스트 코드를 아주 일부 도입했다.&lt;/li&gt;
&lt;li&gt;세미나로 추진력을 얻어 1월에는 회원가입 마케팅 동의 페이지에 ReactorKit을 최초로 도입했다. ReactorKit은 프로젝트 일부에만 도입할 수 있다는 장점이 있어 좋다.&lt;/li&gt;
&lt;li&gt;앱 내 다운로드된 클립 파일이나 캐시 등을 전부 삭제할 수 있는 기능을 만들었다. 앱 내 샌드박스의 /documents, /tmp, /library 디렉터리에 어떤 것들이 저장되는지 알 수 있었다.&lt;/li&gt;
&lt;li&gt;플레이어, 전자책 동시이용 서비스를 지원했다. TTS를 이용할 때는 플레이어를 날려버려야 해서 MPRemoteCommand와 MPNowPlayingCenter를 조작해야 했다. 더 잘 알게 된 계기가 됐다고 생각한다.&lt;/li&gt;
&lt;li&gt;겨울 막바지에 privacy manifest 도입이 필요하다는 것을 깨닫고 팀에 공유한 뒤 업데이트 필요한 패키지들을 리스트업했다. &lt;s&gt;WWDC는 미리미리 챙겨봐야 한다&lt;/s&gt;.&lt;/li&gt;
&lt;li&gt;중첩 콜렉션뷰에서 재사용을 제대로 하지 못하고 있는 이슈 라이징이 있었다. 해결은 했지만, 여전히 중첩 콜렉션뷰에 대한 의문이 남아 있다.&lt;/li&gt;
&lt;li&gt;페이지 전환 애니메이션 작업을 지원했다. 스크롤뷰와 CAAffineTransform에 대해 잘 알 수 있는 계기가 됐다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;직장: 사람&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;2월 막바지에 우리 팀에 웹개발자 한 분이 새로 들어오셨다. 작년 8월에 웹개발자 분이 입사하셨을 때는 사수도 없고 어려운 상황이었어서 개발, 기획, 디자인팀과 한 번씩 식사를 주선해드리며 정서적으로 정착하실 수 있도록 도움을 드렸다.&lt;/li&gt;
&lt;li&gt;이번에는 그렇게까지 할 건 없었다. 8월에 이미 오신 분께서 잘 도와주신다. 웹과 협업할 일이 아주 많지는 않지만, 요즘은 그냥 가끔 식사 같이 하고 어려운 건 없는지 한 번씩 가볍게 여쭤보고 있다.&lt;/li&gt;
&lt;li&gt;결국 혼자 일하는 게 아니다 보니 사람이 중요함을 느껴서 살뜰하게는 아니더라도 넘 매정하지 않게 챙기려고 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;직장: 어떤 것이 가장 좋은 것인가?&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;개발자는 언제나 좋은 코드를 위해 노력해야 하는가? 물론 그렇다.&lt;/li&gt;
&lt;li&gt;그런데 스타트업 1년을 다니고 돌아보니 개발자도 결국 직장인이다. 피고용인은 고용된 회사의 이익을 위해 일정 시간 동안 일하고 그 대가로 급여를 받는다.&lt;/li&gt;
&lt;li&gt;기술 부채가 심각한 경우에는 이를 위한 일정을 따로 내는 것이 필요하지만 그럴 시간을 낼 수 없는 경우에는 코드 베이스의 상태가 더 나빠지지 않도록, 협업 일정을 우선으로 해서 이익을 낼 수 있도록 최선을 다해야 한다.&lt;/li&gt;
&lt;li&gt;이것이 직장인 개발자로서의 개인적인 생각이다.&lt;/li&gt;
&lt;li&gt;혼자 개발한다면 최신 기술 사용하고 스택도 내 마음대로 구성할 수 있겠지만 회사에선 그럴 수 없다. 어떤 기술을 도입하고자 하면 함께 일하는 동료가 겪을 러닝 커브와 기술을 익히는 데 필요한 시간적인 비용을 생각해야 하고, 사이드 이펙트 및 전환 비용 등을 꼼꼼히 따져본 뒤 신중하게 결정해야 한다.&lt;/li&gt;
&lt;li&gt;특히 요즘처럼 채용 시장이 어려운 시기에는 무조건 퇴사하기보다는 머무르는 것을 선택하고, 현재 회사에서 이룰 수 있는 것들은 이루고, 공부하고, 대화하고, 설득하고, 또 설득당하기도 하며 다음 스텝을 준비해야 한다고 생각한다.&lt;/li&gt;
&lt;li&gt;결론은&amp;hellip; 참선하자.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;해결해야 할 것들&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;모듈화의 필요성을 느낀다. 우리 프로젝트의 경우 모듈 간 커플링이 강한 것들이 좀 많고, 어느 화면에서든지 이용할 수 있는 싱글턴 서비스가 있다. 어떻게 모듈화할지 좀 고민하는 중이다.&lt;/li&gt;
&lt;li&gt;결국 모듈화하려는 건 타겟을 나눠 빌드할 수 있는 방법 찾기의 일환이다. 현재 우리 앱은 모놀리스로 구성되어 있고, 작은 기능 하나만 고쳐도 전체 앱을 다 빌드해서 시간이 오래 걸린다. 이 부분을 꼭 해결하고 싶다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;잡담&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;실무에 ReactorKit을 도입하기로 한 뒤부터 뒷탈이 없도록 열심히 공부해갔다. 그러다 보니 오히려 사이드 프로젝트에 적용할 때보다 더 빨리 성장한 것 같다. ㅎㅎ&lt;/li&gt;
&lt;li&gt;테스트 코드 역시 마찬가지다. 솔직히 테스트 코드를 짤 때 어디부터 어디까지가 테스트 영역인가? 자동화된 UI 테스트는 반드시 필요한가? 그렇다면 범위는 어디인가? 동작을 가능하게 하는 사전 구현은 어디에서 해야 하는가? 테스트 1개는 몇 개의 기능을 검사해야 할까? 많이 해도 될까? 등등&amp;hellip; 아직도 여러 가지 의문이 남아 있다.&lt;/li&gt;
&lt;li&gt;그리고 요즘따라 학교에서 배운 것들&amp;hellip; 다 쓸모있었구나 느끼고 있다. 이런 기본적인 것들 어디다 써먹나 했는데 다 쓸모가 있었다. 게다가 너무나 핵심적인 가치들이라 거의 신주단지 뫼시듯이 중요하게 생각해야 하는 것들이었다.&lt;/li&gt;
&lt;li&gt;&amp;lsquo;왜 그래야 하는가?&amp;rsquo;를 자주 생각하게 된다. &amp;ldquo;이거 쓰려구요&amp;rdquo; &amp;rarr; &amp;ldquo;그걸 쓰면 뭐가 좋은데요?&amp;rdquo; &amp;rarr; &amp;ldquo;이런이런 게 좋아요&amp;rdquo; &amp;rarr; &amp;ldquo;그 장점이 왜 필요하죠?&amp;rdquo; &amp;rarr; &amp;ldquo;우리 프로젝트에 이런 문제가 있고&amp;hellip;&amp;rdquo; &amp;rarr; &amp;ldquo;다른 대안도 있는데 굳이 그것을 선택한 이유는?&amp;rdquo; 등등&amp;hellip; 거의 5why급으로 파고들어가서 내 스스로 이유를 잘 설명할 수 있어야 한다고 생각한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;개인사&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;다니던 헬스장을 옮겨서 아침 운동을 다니고 있다. 6시부터 열어서 아침에 운동하고 출근할 수 있는 게 제일 좋다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;총평&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;잘했는데 조금 더 잘할 수 있었다.&lt;/li&gt;
&lt;li&gt;테스트 코드를 도입한 건 좋았지만 좀 더 정교한 테스트 코드를 위해 심화 응용이 필요하다. 테스트 코드만을 위해 공부하기보다는 기존 사이드 프로젝트에 적용하는 것을 목표로 테스트하며 배워야할 것 같다.&lt;/li&gt;
&lt;li&gt;모듈화의 꿈은 빨리 이룰 수 있도록 일정 조정이 필요하다.&lt;/li&gt;
&lt;li&gt;Structured 결제했으니까 시간 조정 잘 해서 유익하게 사용해야겠다...&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;2024 봄 목표&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;최근 사이드 프로젝트에 합류하게 됐는데, 일과 사이드 프로젝트 모두 잘 진행하고 싶다. 사이드 프로젝트에는 ReactorKit과 더불어 FlexLayout, PinLayout을 도입하고 Tuist도 살짝 경험해볼 예정이다.&lt;/li&gt;
&lt;li&gt;테스트 코드를 짜다가 한계에 부딪혀서, 좀 더 꼼꼼히 자료를 수집한 다음 다시 정밀한 테스트를 위해 노력할 예정이다. 일단 XCTest부터 잘 써야 한다.&lt;/li&gt;
&lt;li&gt;모듈러 아키텍처에 관심을 두고 있는데 정확히 어떻게 시도해야 하고, 레거시를 모듈러로 바꾸려면 어떤 것부터 해야 하는지 모호한 상태이다. 사례 위주로 찾아보고 적용하려 한다.&lt;/li&gt;
&lt;li&gt;이제는 SwiftUI로 앱 만들기를 더는 미룰 수 없을 듯하다. 예제 앱을 만들어본 다음 Combine을 경험해보고, 최종적으로 TCA까지 경험해 pure SwiftUI와 어떻게 다른지 비교해보려 한다. Flux 패턴을 기반으로 한 ReactorKit 경험이 있으니 TCA가 그렇게 어렵지는 않을 거라고&amp;hellip;? 기대한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>상자</category>
      <author>사과먹는사람</author>
      <guid isPermaLink="true">https://dev-dain.tistory.com/312</guid>
      <comments>https://dev-dain.tistory.com/312#entry312comment</comments>
      <pubDate>Thu, 4 Apr 2024 20:09:55 +0900</pubDate>
    </item>
    <item>
      <title>[iOS] 실무에 Privacy manifest를 적용하자</title>
      <link>https://dev-dain.tistory.com/311</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작년 WWDC 23에서 &lt;a href=&quot;https://developer.apple.com/videos/play/wwdc2023/10060&quot;&gt;Privacy Manifest 소개 영상&lt;/a&gt;이 발표됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;당초 2024년 4월 28일쯤까지로 예정되었던 privacy manifest 작성 강제가 5월 1일로 날짜가 픽스되면서 요즘 여기저기서 얘기가 나오고 있는 privacy manifest에 대해 알아보고, 어떻게 적용해야 리젝을 피할 수 있을지 알아보자.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;privacy manifest는 무엇인가?&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문서에 따르면, 앱이나 써드파티 SDK에서 &lt;b&gt;수집하는 데이터와 API 사용 등에 대한 사유&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;왜 중요한가?&amp;nbsp;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;심사 리젝을 회피하기 위해서이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;privacy manifest 작성이 필요한 앱은 2024년 5월 1일 이후 manifest를 작성하지 않고 심사를 등록했을 때 이 사유로 리젝당할 수 있다. 이는 앱스토어에 앱 심사 제출을 할 때 날아오는 이메일로 알 수 있으며, &lt;a href=&quot;https://developer.apple.com/news/?id=3d8a9yyh&quot;&gt;개발자 뉴스&lt;/a&gt;에서도 확인할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;어떻게 생성할까?&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 루트 디렉터리 밑에 App Privacy 파일을 만든다. File &amp;gt; New File 해서 privacy를 검색해보면 App Privacy가 결과로 나오는데, 이름 변경하지 않고 그냥 만들면 된다. 기본 이름이 PrivacyInfo로 설정된다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-03-28 오후 4.42.01.png&quot; data-origin-width=&quot;1436&quot; data-origin-height=&quot;512&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bME179/btsF9GgwSZu/rCLkP9ncaG7frjln9wqwZ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bME179/btsF9GgwSZu/rCLkP9ncaG7frjln9wqwZ0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bME179/btsF9GgwSZu/rCLkP9ncaG7frjln9wqwZ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbME179%2FbtsF9GgwSZu%2FrCLkP9ncaG7frjln9wqwZ0%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;1436&quot; height=&quot;512&quot; data-filename=&quot;스크린샷 2024-03-28 오후 4.42.01.png&quot; data-origin-width=&quot;1436&quot; data-origin-height=&quot;512&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;Privacy Manifest에는 어떤 것을 작성하게 되는가?&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;크게 4가지를 작성할 수 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;NSPrivacyTracking
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;앱이나 써드파티 SDK가 &lt;u&gt;ATT 프레임워크로 유저 데이터를 수집하는지 나타내는 값&lt;/u&gt;으로, Bool 값이다. ATT로 유저 데이터를 수집한다면 True로 설정하면 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;NSPrivacyTrackingDomains
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;앱이나 써드파티 SDK가 &lt;u&gt;트래킹에 관여하는 도메인 리스트&lt;/u&gt;를 작성한다. 만약 NSPrivacyTracking이 false라면 작성하지 않아도 되고, true라면 적어도 1개 이상의 도메인을 작성해야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;NSPrivacyCollectedDataTypes
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;앱이나 써드파티 SDK가 &lt;u&gt;수집하는 데이터&lt;/u&gt;를 기술한 배열이다. nutrition labels와 비슷하게 사용되며, 실제로 아카이브한 다음에 generate privacy report했을 때는 이 배열에 담긴 것들이 나타나게 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;NSPrivacyAccessedAPITypes
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;앱이나 써드파티 SDK가 &lt;u&gt;특정 API를 접근할 때, 그 API와 사유를 기술한 배열&lt;/u&gt;이다. 적절히 작성하지 않았을 때 이메일이 날아오는 주요 원인이 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에서는 3, 4번째 항목인 NSPrivacyCollectedDataTypes와 NSPrivacyAccessedAPITypes에 대해 다룬다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;필요한 것 채워넣기&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;글을 작성하는 2024년 3월 하순 기준으로, 앱스토어에 심사를 제출했을 때 이메일이 오는 기준은 Privacy Accessed API Types 배열에 적절하게 Reasons가 적용되지 않았을 때(아예 없거나, 사유가 틀렸거나)이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 NSPrivacyAccessedAPITypes에 해당하는 API들을 살펴보자. 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;File timestamp
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;파일의 creationDate, modificationDate 등&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;System boot time
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;systemUptime, mach_absolute_time()&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Disk space
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;systemFreeSize, systemSize 등&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Active Keyboard
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;activeInputModes&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;User defaults&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아마 많은 앱에서 UserDefaults는 사용하고 있지 않을까 한다. 이 항목을 필수로 작성해줘야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사유는 &lt;a href=&quot;https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_use_of_required_reason_api&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;문서&lt;/a&gt;에서 확인할 수 있으며, 가장 적당한 사유 1가지를 넣으면 되지 않을까 싶다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 식으로 작성해주면 된다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-03-28 오후 5.41.18.png&quot; data-origin-width=&quot;585&quot; data-origin-height=&quot;212&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ooXnh/btsGbbUl16P/pApUN3WdE1dwFXoJZkkLhK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ooXnh/btsGbbUl16P/pApUN3WdE1dwFXoJZkkLhK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ooXnh/btsGbbUl16P/pApUN3WdE1dwFXoJZkkLhK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FooXnh%2FbtsGbbUl16P%2FpApUN3WdE1dwFXoJZkkLhK%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;585&quot; height=&quot;212&quot; data-filename=&quot;스크린샷 2024-03-28 오후 5.41.18.png&quot; data-origin-width=&quot;585&quot; data-origin-height=&quot;212&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;참고로 모든 것을 수기로 작성할 필요는 없다. API Types 아래 아이템을 생성하게 되면, API Type과 API Reasons는 xcode가 미리 제공해준 것들 중에서 드롭박스로 선택할 수 있게 되어 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Privacy Accessed API Reasons 드롭박스에 모든 사유가 포함되어 있지는 않다. 이 때는 위와 같이 간단하게 사유 코드만 적으면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 가장 중요한 것&amp;hellip; 이 &lt;b&gt;파일의 Target Membership을 설정&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;Privacy Report에 들어가는 것&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API Types를 적고 아카이브한 다음에 Privacy Report를 생성해보면 비어 있는 경우도 있다. API Types는 엄밀히 따지면 privacy report에 들어가는 것이 아니라서 그렇지 않을까 싶다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;privacy report를 채우려면 NSPrivacyCollectedDataTypes를 추가로 작성해야 한다. &lt;a href=&quot;https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_data_use_in_privacy_manifests&quot;&gt;문서&lt;/a&gt;에서 작성해야 하는 항목을 제시하고 있는데, 양이 꽤 있고 체크해야 할 것들도 있다. 꼼꼼하게 보고 앱에서 수집하는 데이터가 있다면 작성해줘야 한다. 사실, 기존에 앱스토어에 제출할 때 수집하는 개인정보 항목들(Nutrition Labels)을 작성한 적이 있다면 무난하게 작성할 수 있으리라고 본다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 부분을 작성하고 나면 아카이브 후 privacy report를 생성했을 때 적절하게 레포트가 생성된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;써드파티 라이브러리에 대해&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고로 써드파티 라이브러리를 사용하고 있다면, 라이브러리 최신 버전 혹은 사용하고 있는 버전에 privacy manifest 파일이 적용되어 있는지 확인해야 한다. 간접적이긴 하지만 이 역시 리젝의 사유가 될 수 있을 것으로 예상된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;적용 중인 써드파티 라이브러리가 필수로 privacy manifest를 작성해야 하는 라이브러리인지 알고 싶다면 다음 &lt;a href=&quot;https://developer.apple.com/kr/support/third-party-SDK-requirements/&quot;&gt;문서&lt;/a&gt; 참고를 권장한다. 만약 라이브러리의 최신 버전에도 privacy manifest가 없다면, GitHub repo에서 privacy manifest 관련 이슈가 있는지 확인한다. 없다면 이슈 오픈으로 필요성을 적극 어필하는 것도 하나의 방법이 될 수 있을 것이다.&lt;/p&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;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;References&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/bundleresources/privacy_manifest_files&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Privacy manifest files&lt;/a&gt;&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_data_use_in_privacy_manifests&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Describing data use in privacy manifests&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_use_of_required_reason_api&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Describing use of required reason API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.adjust.com/glossary/privacy-nutrition-labels/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;What are Apple&amp;rsquo;s Privacy Nutrition Labels? &lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>앱/Swift</category>
      <category>privacy manifest</category>
      <category>Xcode</category>
      <author>사과먹는사람</author>
      <guid isPermaLink="true">https://dev-dain.tistory.com/311</guid>
      <comments>https://dev-dain.tistory.com/311#entry311comment</comments>
      <pubDate>Thu, 28 Mar 2024 20:10:48 +0900</pubDate>
    </item>
    <item>
      <title>레이아웃 업데이트를 위한 메소드 setNeedsLayout(), ifLayoutNeeded()</title>
      <link>https://dev-dain.tistory.com/310</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무지 기본적인 것에 무지한 관계로..&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;뷰를 업데이트하는 레이아웃 메소드를 살펴본다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;뷰 레이아웃이 어떻게 이뤄지는지 알기 위해서는 일단&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a style=&quot;color: #0070d1;&quot; href=&quot;https://developer.apple.com/documentation/foundation/runloop&quot;&gt;런루프&lt;/a&gt;를 조금 알아보는 게 좋겠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;런루프&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;swift에는 런루프(run loop)라는 게 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;런루프는 Event-driven 프로그래밍에서 사용된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조금 추상적인 개념이지만 어떤 이벤트를 대기하면서 스레드를 좀 놀려놨다가 이벤트가 생기면 스레드에게 그것을 처리하게끔 시키는 스레드 관리 시스템이라고 보면 이해가 쉬울 것 같다. (polling이 아니라 notification을 기다리는 느낌이랄까)&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 메인 스레드에서 실행되는 런루프를 메인 런루프라고 부른다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메인 런루프가 하는 주된 일은 앱 라이프사이클 주기 동안 계속 사용자 이벤트를 대기하고, 이벤트가 발생하면 그에 따라 이벤트 핸들러를 실행한다. 그런데, 이벤트가 발생할 때마다 루프가 돌진 않고 이벤트 큐에 이벤트들을 모아놨다가 사이클이 끝날 때 한꺼번에 처리하는 식이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메인 스레드의 중요한 특징 중 하나가 UI 작업을 하는 스레드라는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다. 메인 런루프도 UIView의 드로잉 사이클을 관리하는 역할을 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UIView에 변경 코드를 준다고 즉각적으로 갱신되지는 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사이클이 빠르기 때문에 거의 즉시 갱신되는 것으로 보이지만, 엄밀히 따지면 그렇지 않다.&lt;/p&gt;
&lt;pre id=&quot;code_1710674542934&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;let myButton = UIButton()
myButton.frame = .CGRect(x: 30, y: 0)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 코드를 짰다고 가정하면 frame이 실제로 변경되어 레이아웃에 반영되는 시점은 드로잉 사이클이 끝난 시점이다.&lt;/p&gt;
&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;메인 스레드의 메인 런루프가 UIView 드로잉 사이클을 관리한다. 즉, UIView에 일어나는 이벤트들을 이벤트 큐에 모았다가 사이클이 종료될 때 한번에 처리하고 앱 객체에 전달해서 뷰를 업데이트한다.&lt;/li&gt;
&lt;li&gt;더 좋은 요약글은 &lt;a href=&quot;https://ios-development.tistory.com/515&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;여기&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;레이아웃 업데이트 메소드&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://developer.apple.com/documentation/uikit/uiview/1622601-setneedslayout&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;setNeedsLayout()&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;receiver(뷰)의 현재 레이아웃을 더이상 유효하지 않도록 만들고, 다음 업데이트 사이클에 레이아웃 업데이트를 하도록 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;런루프의 다음 업데이트 사이클에 갱신&lt;/b&gt;하도록, 즉 다음 사이클에 layoutSubviews()를 호출하도록 요청하는 비동기 메소드다. 비동기이기 때문에 호출 뒤 바로 반환된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문서에는 서브뷰 레이아웃을 다시 하고 싶은 뷰를 대상으로 메인 스레드에서 호출하도록 가이드하고 있다. 즉시 업데이트하는 것을 강제하는 메소드가 아니기 때문에 뷰들이 업데이트되기 전에 뷰 레이아웃을 유효하지 않도록 만든다. 즉, 뷰에 속한 서브뷰들이 다른 업데이트 사이클에 따로따로 업데이트되는 것이 아니라 한 사이클에 몰아서 처리되는 것을 의미한다. 그래서 퍼포먼스 올리기에는 좋다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://developer.apple.com/documentation/uikit/uiview/1622507-layoutifneeded&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;layoutIfNeeded()&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;런루프에 즉시 갱신을 요청&lt;/b&gt;한다. 즉시 layoutSubviews()를 호출하는 동기 메소드다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레이아웃을 강제로 즉시 업데이트하고 싶을 때 사용한다. AutoLayout을 사용할 때는, 레이아웃 엔진이 constraint를 만족하는 선에서 뷰 위치를 업데이트하도록 한다.&lt;/p&gt;
&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/uikit/uiview/1622482-layoutsubviews&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;layoutSubviews()&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대상이 되는 뷰와 그 하위 subview들의 위치, 사이즈를 다시 계산한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;호출 즉시 값을 변경시키게 되는데, 웬만해서는 직접 호출하지 않는 것이 좋다. cost가 좀 부담이 되는 작업이다 보니...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;setNeedsLayout(), layoutIfNeeded() 모두 layoutSubviews()를 간접적으로 호출하는 메소드인데 그 시점의 차이가 있는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동기, 비동기인 것과 업데이트 요청 시점을 제외하면 실질적으로 무슨 차이인지 솔직히 와닿지는 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://poisonf2.tistory.com/73&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;뷰의 width, height값을 바꾸고 animate시키는데, layoutIfNeeded()를 할 때는 2초 동안 점차로 커지는 애니메이션 효과를 볼 수 있지만 setNeedsLayout()을 할 때는 애니메이션 효과 없이 바로 다음 업데이트 사이클에 (300, 300) 크기로 변경되는 것이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 애니메이션을 줄 때는 layoutIfNeeded()를 써줘야 원하는 결과를 얻을 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;References&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://poisonf2.tistory.com/73&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;[iOS] animate - layout, setNeedsLayout, layoutIfNeeded&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>앱/Swift</category>
      <category>layoutIfNeeded</category>
      <category>runloop</category>
      <category>setNeedsLayout</category>
      <category>swift</category>
      <category>UIView</category>
      <category>런루프</category>
      <author>사과먹는사람</author>
      <guid isPermaLink="true">https://dev-dain.tistory.com/310</guid>
      <comments>https://dev-dain.tistory.com/310#entry310comment</comments>
      <pubDate>Sun, 17 Mar 2024 19:03:25 +0900</pubDate>
    </item>
  </channel>
</rss>