<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>찹모찌의 기록일지</title>
    <link>https://chopmozzi.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Mon, 6 Jul 2026 13:14:45 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>찹모찌</managingEditor>
    <image>
      <title>찹모찌의 기록일지</title>
      <url>https://tistory1.daumcdn.net/tistory/5440589/attach/819c6cf6bd63454d95f8bf05e217ded1</url>
      <link>https://chopmozzi.tistory.com</link>
    </image>
    <item>
      <title>AutoLayout 애니메이션 적용하기</title>
      <link>https://chopmozzi.tistory.com/27</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;시작하기 앞서 참고한 블로그를 명시합니다.&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://ios-development.tistory.com/908&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://ios-development.tistory.com/908&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1714472665823&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;[iOS - swift] Animation 테크닉 - 오토레이아웃, 단일 애니메이션, 연속 애니메이션, 스프링, 뒤집기 (au&quot; data-og-description=&quot;* 예제에서 UI 레이아웃을 코드로 편리하게 작성하기 위해 SnapKit 프레임워크 사용 (SnapKit 필수로 알아야 하는 것 포스팅 글 참고) 애니메이션에 사용될 뷰 준비 animationTargetView 선언 // ViewController.s&quot; data-og-host=&quot;ios-development.tistory.com&quot; data-og-source-url=&quot;https://ios-development.tistory.com/908&quot; data-og-url=&quot;https://ios-development.tistory.com/908&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/vjeTy/hyVZhQ5JTG/0sBML481VZuQ6sxsBaXHj0/img.gif?width=366&amp;amp;height=754&amp;amp;face=0_0_366_754,https://scrap.kakaocdn.net/dn/gY85p/hyVZibodU1/5lcEPTGo11EuCFkEy3G6L0/img.gif?width=366&amp;amp;height=754&amp;amp;face=0_0_366_754&quot;&gt;&lt;a href=&quot;https://ios-development.tistory.com/908&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://ios-development.tistory.com/908&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/vjeTy/hyVZhQ5JTG/0sBML481VZuQ6sxsBaXHj0/img.gif?width=366&amp;amp;height=754&amp;amp;face=0_0_366_754,https://scrap.kakaocdn.net/dn/gY85p/hyVZibodU1/5lcEPTGo11EuCFkEy3G6L0/img.gif?width=366&amp;amp;height=754&amp;amp;face=0_0_366_754');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[iOS - swift] Animation 테크닉 - 오토레이아웃, 단일 애니메이션, 연속 애니메이션, 스프링, 뒤집기 (au&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;* 예제에서 UI 레이아웃을 코드로 편리하게 작성하기 위해 SnapKit 프레임워크 사용 (SnapKit 필수로 알아야 하는 것 포스팅 글 참고) 애니메이션에 사용될 뷰 준비 animationTargetView 선언 // ViewController.s&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;ios-development.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지 AutoLayout은 Animation 적용이 제한적인 걸로 알고 있었는데, 우연찮게 한 블로그 글을 보고 작성합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는 AutoLayout 적용을 못시켜 다른 방법으로 애니메이션이 적용되는 것 처럼 보이게 했었는데(&lt;a href=&quot;https://chopmozzi.tistory.com/18&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;이전글&lt;/a&gt;), AutoLayout이 변경되는 것을 애니메이션화 할 수 있는 새로운 방법을 알게되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 기존 코드 입니다. UIView.animations에서 constraint를 변경합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1714484618138&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//
//  ViewController.swift
//  AnimationTest
//
//  Created by 황지웅 on 4/30/24.
//

import UIKit

final class ViewController: UIViewController {

    private lazy var animationView: UIView = {
        let view = UIView()
        view.backgroundColor = .systemBlue
        view.translatesAutoresizingMaskIntoConstraints = false
        self.view.addSubview(view)
        return view
    }()

    private lazy var animationButton: UIButton = {
        let button = UIButton()
        button.setTitle(&quot;애니메이션&quot;, for: .normal)
        button.setTitleColor(.systemBlue, for: .normal)
        button.translatesAutoresizingMaskIntoConstraints = false
        button.addTarget(self, action: #selector(showAnimation), for: .touchUpInside)
        self.view.addSubview(button)
        return button
    }()

    private var animationValue: Bool = false

    private lazy var constraint1: NSLayoutConstraint? = animationView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 50)
    private lazy var constraint2: NSLayoutConstraint? = animationView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20)
    private lazy var constraint3: NSLayoutConstraint? = animationView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20)
    private lazy var constraint4: NSLayoutConstraint? = animationView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -10)

    private lazy var constraint5: NSLayoutConstraint? = animationView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 200)
    private lazy var constraint6: NSLayoutConstraint? = animationView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 300)
    private lazy var constraint7: NSLayoutConstraint? = animationView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -300)
    private lazy var constraint8: NSLayoutConstraint? = animationView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -200)

    override func viewDidLoad() {
        super.viewDidLoad()
        NSLayoutConstraint.activate([
            animationButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            animationButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)
        ])
        setConstraints()
    }

    private func setConstraints() {
        constraint5?.isActive = false
        constraint6?.isActive = false
        constraint7?.isActive = false
        constraint8?.isActive = false
        constraint1?.isActive = true
        constraint2?.isActive = true
        constraint3?.isActive = true
        constraint4?.isActive = true
    }

    private func setConstraints2() {
        constraint5?.isActive = true
        constraint6?.isActive = true
        constraint7?.isActive = true
        constraint8?.isActive = true
    }

    @objc private func showAnimation() {
        if !animationValue {
            UIView.animate(withDuration: 0.5, animations: {
            	self.setConstraints2()
            })
        } else {
            UIView.animate(withDuration: 0.5, animations: {
            	self.setConstraints()
            })
        }
        animationValue = !animationValue
    }

}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Simulator Screen Recording - iPhone 13 mini - 2024-04-30 at 22.35.01.gif&quot; data-origin-width=&quot;296&quot; data-origin-height=&quot;640&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cZv2qB/btsG3BZMCov/Vj6XMUYX6mFupw1MC2uzak/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cZv2qB/btsG3BZMCov/Vj6XMUYX6mFupw1MC2uzak/img.gif&quot; data-alt=&quot;AutoLayout 애니메이션이 적용되지 않음&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cZv2qB/btsG3BZMCov/Vj6XMUYX6mFupw1MC2uzak/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/cZv2qB/btsG3BZMCov/Vj6XMUYX6mFupw1MC2uzak/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;296&quot; height=&quot;640&quot; data-filename=&quot;Simulator Screen Recording - iPhone 13 mini - 2024-04-30 at 22.35.01.gif&quot; data-origin-width=&quot;296&quot; data-origin-height=&quot;640&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;AutoLayout 애니메이션이 적용되지 않음&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 layoutIfNeeded를 이용하면 애니메이션을 적용할 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1714485254742&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//
//  ViewController.swift
//  AnimationTest
//
//  Created by 황지웅 on 4/30/24.
//

import UIKit

final class ViewController: UIViewController {

    private lazy var animationView: UIView = {
        let view = UIView()
        view.backgroundColor = .systemBlue
        view.translatesAutoresizingMaskIntoConstraints = false
        self.view.addSubview(view)
        return view
    }()

    private lazy var animationButton: UIButton = {
        let button = UIButton()
        button.setTitle(&quot;애니메이션&quot;, for: .normal)
        button.setTitleColor(.systemBlue, for: .normal)
        button.translatesAutoresizingMaskIntoConstraints = false
        button.addTarget(self, action: #selector(showAnimation), for: .touchUpInside)
        self.view.addSubview(button)
        return button
    }()

    private var animationValue: Bool = false

    private lazy var constraint1: NSLayoutConstraint? = animationView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 50)
    private lazy var constraint2: NSLayoutConstraint? = animationView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20)
    private lazy var constraint3: NSLayoutConstraint? = animationView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20)
    private lazy var constraint4: NSLayoutConstraint? = animationView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -10)

    private lazy var constraint5: NSLayoutConstraint? = animationView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 200)
    private lazy var constraint6: NSLayoutConstraint? = animationView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 300)
    private lazy var constraint7: NSLayoutConstraint? = animationView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -300)
    private lazy var constraint8: NSLayoutConstraint? = animationView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -200)

    override func viewDidLoad() {
        super.viewDidLoad()
        NSLayoutConstraint.activate([
            animationButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            animationButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)
        ])
        setConstraints()
    }

    private func setConstraints() {
        constraint5?.isActive = false
        constraint6?.isActive = false
        constraint7?.isActive = false
        constraint8?.isActive = false
        constraint1?.isActive = true
        constraint2?.isActive = true
        constraint3?.isActive = true
        constraint4?.isActive = true
    }

    private func setConstraints2() {
        constraint5?.isActive = true
        constraint6?.isActive = true
        constraint7?.isActive = true
        constraint8?.isActive = true
    }

    @objc private func showAnimation() {
        if !animationValue {
            setConstraints2()
            UIView.animate(withDuration: 0.5, animations: {
                self.view.layoutIfNeeded()
            })
        } else {
            setConstraints()
            UIView.animate(withDuration: 0.5, animations: {
                self.view.layoutIfNeeded()
            })
        }
        animationValue = !animationValue
    }

}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 animationView의 constraint가 변경되는데 상위 view의 layoutIfNeeded 적용으로 애니메이션이 변경되는지 궁금했습니다. 그래서 정보를 찾아보았습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-04-30 오후 11.05.52.png&quot; data-origin-width=&quot;1728&quot; data-origin-height=&quot;1050&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kXalU/btsG5TEGAus/13ehq4EMRYMwQ3BaTtWsV1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kXalU/btsG5TEGAus/13ehq4EMRYMwQ3BaTtWsV1/img.png&quot; data-alt=&quot;layoutIfNeeded 공식 문서&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kXalU/btsG5TEGAus/13ehq4EMRYMwQ3BaTtWsV1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkXalU%2FbtsG5TEGAus%2F13ehq4EMRYMwQ3BaTtWsV1%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;1728&quot; height=&quot;1050&quot; data-filename=&quot;스크린샷 2024-04-30 오후 11.05.52.png&quot; data-origin-width=&quot;1728&quot; data-origin-height=&quot;1050&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;layoutIfNeeded 공식 문서&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;layoutIfNeeded는 subviews의 layout을 즉시 업데이트 합니다. 그래서 view의 subview인 animationView의 constraints 적용이 애니메이션화 되었군요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또, layoutIfNeeded의 Discussion 부분을 보면 &lt;i&gt;When using Auto Layout, the layout engine updates the position of views as needed to satisfy changes in constraints.&lt;/i&gt; 라는 설명을 하고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;layoutIfNeeded를 통해 subviews의 constraints를 업데이트 하면서 frame을 변경하고 frame 변경은 애니메이션으로 이어집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에 isActive의 변경으로 Auto Layout의 크기를 조절했었는데, 이 부분은 단순  constraint의 변경으로 애니메이션이 적용되지 않음. 따라서 layoutIfNeeded로 애니메이션이 적용될 수 있도록 layout을 업데이트 시켜야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;찾다보니 isActive보다 constant 값을 변경해서 적용하는 것이 더 낫다고도 하네요!&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추가적으로 setNeedsLayout도 layout을 업데이트 시키는데 왜 애니메이션이 동작하지 않을까 했는데, setNeedsLayout은 다음 싸이클에, layoutIfNeeded는 즉시 업데이트 시키는 시점의 차이에 따른 결과인 것 같습니다. -&amp;gt; 추가적인 내용을 작성해보도록 하겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>iOS</category>
      <author>찹모찌</author>
      <guid isPermaLink="true">https://chopmozzi.tistory.com/27</guid>
      <comments>https://chopmozzi.tistory.com/27#entry27comment</comments>
      <pubDate>Tue, 30 Apr 2024 23:38:19 +0900</pubDate>
    </item>
    <item>
      <title>Clean Swift로 Unit Test 작성하기</title>
      <link>https://chopmozzi.tistory.com/26</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Clean Swift를 채택한 위치기반 숏폼 플랫폼 Layover에서 작성했던 Unit Test에 관한 이야기.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. Clean Swift란?&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-04-28 오후 6.19.36.png&quot; data-origin-width=&quot;2006&quot; data-origin-height=&quot;1126&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ciWTHj/btsGYNTPeWB/QEqHVwciWcJSxWfCM5IduK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ciWTHj/btsGYNTPeWB/QEqHVwciWcJSxWfCM5IduK/img.png&quot; data-alt=&quot;Clean Swift란?&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ciWTHj/btsGYNTPeWB/QEqHVwciWcJSxWfCM5IduK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FciWTHj%2FbtsGYNTPeWB%2FQEqHVwciWcJSxWfCM5IduK%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;2006&quot; height=&quot;1126&quot; data-filename=&quot;스크린샷 2024-04-28 오후 6.19.36.png&quot; data-origin-width=&quot;2006&quot; data-origin-height=&quot;1126&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Clean Swift란?&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Clean Swift를 처음 공부할 때 만들었던 발표 자료에서 발췌했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;VIP Cycle을 단방향으로 돌며 동작한다. Interactor에서는 Business Logic만 처리하고 Presenter에서는 데이터를 가공하여 ViewControlelr에서는 UI Logic만 동작할 수 있도록 책임을 분리한 것이 특징이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 구조속에서 Unit Test를 작성하려면 어떻게 해야 할까? Unit Test를 통해 어떤 동작을 확인해야 할까?&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. Clean Swift 속 Unit Test&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 Unit Test의 목적을 두 가지로 정했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1) Business Logic이 올바르게 동작하는지 확인&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2) VIP Cycle이 올바르게 순환하는지 확인&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 Business Logic이 올바르게 동작하는지 확인하기로 했다. 먼저 테스트해야 할 로직은 다음과 같다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-04-28 오후 6.49.34.png&quot; data-origin-width=&quot;1468&quot; data-origin-height=&quot;720&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dK1d9B/btsGYZsXMrd/Uj5FgMefCAmtZzwIQOyMhK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dK1d9B/btsGYZsXMrd/Uj5FgMefCAmtZzwIQOyMhK/img.png&quot; data-alt=&quot;무한 스크롤 Logic&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dK1d9B/btsGYZsXMrd/Uj5FgMefCAmtZzwIQOyMhK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdK1d9B%2FbtsGYZsXMrd%2FUj5FgMefCAmtZzwIQOyMhK%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;1468&quot; height=&quot;720&quot; data-filename=&quot;스크린샷 2024-04-28 오후 6.49.34.png&quot; data-origin-width=&quot;1468&quot; data-origin-height=&quot;720&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;무한 스크롤 Logic&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UICollectionView에서 기존 1, 2, 3 Cell로 구성되어있을 때, 무한 스크롤 효과를 주기 위해 3, 1, 2, 3, 1로 Cell 구성을 변경하고 마지막 1과 첫 번째 3에 도달했을 때 기존 Cell로 setContentOffset을 이용해 이동할 수 있도록 만들어 두었다. 이때 마지막 Cell을 삭제하게 된다면 Cell의 구조는 2, 1, 2, 1로 변경되어야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 로직을 테스트 하기 위해서는 기존 3, 1, 2, 3, 1의 구조가 삭제 동작 시 2, 1, 2, 1로 변경되는지 확인해야 한다.&lt;/p&gt;
&lt;pre id=&quot;code_1714297950588&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// MARK: Subject under test

var sut: PlaybackInteractor!

// MARK: Properties

typealias Models = PlaybackModels

// MARK: - Test lifecycle

override func setUp() {
    super.setUp()
    setupPlaybackInteracotr()
}

override func tearDown() {
    super.tearDown()
}

// MARK: - Test setup

func setupPlaybackInteracotr() {
    sut = PlaybackInteractor()
    sut.worker = MockPlaybackWorker()
}


func test_map일_때_마지막_무한스크롤셀을_지울_경우_deleteVideo를_호출하면_presentDeleteVideo를_호출한다() async {
    // Arrange
    let spy = PlaybackPresentationLogicSpy()
    let testPost: Post = Seeds.Posts.post1
    let playbackVideo: Models.PlaybackVideo = Models.PlaybackVideo(
        displayedPost: Models.DisplayedPost(
            member: Models.Member(
                memberID: testPost.member.identifier,
                username: testPost.member.username,
                profileImageURL: testPost.member.profileImageURL),
            board: Models.Board(
                boardID: testPost.board.identifier,
                title: testPost.board.title,
                description: testPost.board.description,
                videoURL: testPost.board.videoURL!,
                latitude: testPost.board.latitude,
                longitude: testPost.board.longitude),
            tags: Seeds.Posts.post1.tag))
    sut.parentView = .map
    sut.presenter = spy
    sut.posts = [Seeds.Posts.thumbnailImageNilPost, Seeds.Posts.post1, Seeds.Posts.post2, Seeds.Posts.thumbnailImageNilPost ,Seeds.Posts.post1]
    sut.playbackVideoInfos = [Models.PlaybackInfo(memberID: 0, boardID: 3), Models.PlaybackInfo(memberID: 0, boardID: 1), Models.PlaybackInfo(memberID: 0, boardID: 2),  Models.PlaybackInfo(memberID: 0, boardID: 3), Models.PlaybackInfo(memberID: 0, boardID: 1)]

    // act
    await sut.deleteVideo(with: Models.DeletePlaybackVideo.Request(playbackVideo: playbackVideo, indexPathRow: 3))

    // assert
    XCTAssertTrue(spy.presentDeleteVideoDidCalled, &quot;deleteVideo가 presentDeleteVideo를 호출하지 않았습니다&quot;)
    XCTAssertEqual(sut.playbackVideoInfos[0].boardID, 1)
    XCTAssertEqual(sut.playbackVideoInfos[1].boardID, 2)
    XCTAssertEqual(sut.playbackVideoInfos[2].boardID, 1)
    XCTAssertEqual(sut.playbackVideoInfos[3].boardID, 2)
    XCTAssertEqual(sut.posts?[0].board.identifier, Seeds.Posts.post1.board.identifier)
    XCTAssertEqual(sut.posts?[1].board.identifier, Seeds.Posts.post2.board.identifier)
    XCTAssertEqual(sut.posts?[2].board.identifier, Seeds.Posts.post1.board.identifier)
    XCTAssertEqual(sut.posts?[3].board.identifier, Seeds.Posts.post2.board.identifier)
    XCTAssertEqual(sut.playbackVideoInfos.count, 4)
    XCTAssertEqual(sut.posts?.count, 4)
}&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;Mock Data와 sut를 활용하여 deleteVideo가 수행될 수 있도록 한다. 수행을 하고 나면 3, 1, 2, 3, 1의 구조가 2, 1, 2, 1로 변경되었는지 확인한다(비즈니스 로직 테스트의 대상이 PlaybackInteractor이므로 해당 컴포넌트가 sut가 된다).&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 방법을 통해 Business 로직이 제대로 동작하는지 확인할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음으로 VIP Cycle이 올바르게 순환하는지 확인한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-04-28 오후 7.37.21.png&quot; data-origin-width=&quot;1070&quot; data-origin-height=&quot;340&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/odwXw/btsGZlPXWP0/1XkYveD33qeOKT3hV99pj1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/odwXw/btsGZlPXWP0/1XkYveD33qeOKT3hV99pj1/img.png&quot; data-alt=&quot;VIP Cycle 순환 테스트&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/odwXw/btsGZlPXWP0/1XkYveD33qeOKT3hV99pj1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FodwXw%2FbtsGZlPXWP0%2F1XkYveD33qeOKT3hV99pj1%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;1070&quot; height=&quot;340&quot; data-filename=&quot;스크린샷 2024-04-28 오후 7.37.21.png&quot; data-origin-width=&quot;1070&quot; data-origin-height=&quot;340&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;VIP Cycle 순환 테스트&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동작의 주체를 sut로 놓고, VIP Cycle상 다음에 올 컴포넌트를 spy로 둔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Interactor의 경우 Presenter를 spy로 두고 테스트한다.&lt;/p&gt;
&lt;pre id=&quot;code_1714300839222&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;final class PlaybackPresentationLogicSpy: PlaybackPresentationLogic {
    var presentDeleteVideoDidCalled = false
    var presentDeleteVideoResponse: Models.DeletePlaybackVideo.Response!

    func presentDeleteVideo(with response: Layover.PlaybackModels.DeletePlaybackVideo.Response) {
        presentDeleteVideoDidCalled = true
        presentDeleteVideoResponse = response
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 식으로 PresentationLogicSpy를 두고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위의 테스트 코드 상에서 DidCalled가 true가 되었는지를 확인해 다음 컴포넌트가 제대로 호출되었는지를 확인한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 통해 Business Logic이 제대로 동작하는지, VIP Cycle이 제대로 순환하는지를 확인할 수 있다!&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위와 같은 방식으로 Clean Swift 디자인 패턴에서 Unit Test를 작성했다. Unit Test를 작성하며 코드의 안정성과 다른 컴포넌트에 끼치는 영향을 확인하며 작업을 진행할 수 있었다. 진행한 프로젝트가 궁금하다면 아래 Repository를 참고하면 좋을 듯하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/boostcampwm2023/iOS09-Layover&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/boostcampwm2023/iOS09-Layover&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1714300986314&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;GitHub - boostcampwm2023/iOS09-Layover: 내 여정의 경유지들을 기록하다 &amp;zwj; &amp;zwj; &amp;zwj;  - 위치기&quot; data-og-description=&quot;내 여정의 경유지들을 기록하다 &amp;zwj; &amp;zwj; &amp;zwj;  - 위치기반 숏폼 플랫폼. Contribute to boostcampwm2023/iOS09-Layover development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/boostcampwm2023/iOS09-Layover&quot; data-og-url=&quot;https://github.com/boostcampwm2023/iOS09-Layover&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/lboqv/hyVVBJUYh3/M5huyYZMbKsGpqVTX9ITLK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/boostcampwm2023/iOS09-Layover&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/boostcampwm2023/iOS09-Layover&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/lboqv/hyVVBJUYh3/M5huyYZMbKsGpqVTX9ITLK/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;GitHub - boostcampwm2023/iOS09-Layover: 내 여정의 경유지들을 기록하다 &amp;zwj; &amp;zwj; &amp;zwj;  - 위치기&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;내 여정의 경유지들을 기록하다 &amp;zwj; &amp;zwj; &amp;zwj;  - 위치기반 숏폼 플랫폼. Contribute to boostcampwm2023/iOS09-Layover development by creating an account on GitHub.&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;</description>
      <category>iOS</category>
      <author>찹모찌</author>
      <guid isPermaLink="true">https://chopmozzi.tistory.com/26</guid>
      <comments>https://chopmozzi.tistory.com/26#entry26comment</comments>
      <pubDate>Sun, 28 Apr 2024 19:43:41 +0900</pubDate>
    </item>
    <item>
      <title>RxSwift로 코드 개선하기(with. Clean Architecture)</title>
      <link>https://chopmozzi.tistory.com/25</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;RxSwift는 반응형 프로그래밍으로서의 장점뿐만 아니라 Completion Handler로 발생하는 Clousre의 코드 depth 또한 해결할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존코드와 개선한 코드를 비교해가며 이를 알아보자.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. 기존 코드&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 코드의 ViewModel, UseCase, Repository다. 사용하는 부분만 간단히 모아보면&lt;/p&gt;
&lt;pre id=&quot;code_1712981813212&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//
//  InterestStocksViewModel.swift
//  Or-rock-Nari-lock

import Foundation
import RxSwift

final class InterestStocksViewModel {
    private let loadInterestStocksUseCase: LoadInterestStocksUseCase
    private let checkTodayPriceUseCase: CheckTodayPriceUseCase

    private var checkStockTasks: [String:Cancellable?] = [:]
    private let mainQueue: DispatchQueueType

    private var stockInformationArray : [StockInformation] = []
    private let stockInformationsSubject = PublishSubject&amp;lt;[StockInformation]&amp;gt;()

    struct Input {
        let viewDidLoadEvent: Observable&amp;lt;Void&amp;gt;
    }

    struct Output {
        let stockInformations: Observable&amp;lt;[StockInformation]&amp;gt;
    }

    init(loadInterestStocksUseCase: LoadInterestStocksUseCase = DefaultLoadInterestStocksUseCase(),
         checkTodayPriceUseCase: CheckTodayPriceUseCase = DefaultCheckTodayPriceUseCase(),
         mainQueue: DispatchQueueType = DispatchQueue.main) {
        self.loadInterestStocksUseCase = loadInterestStocksUseCase
        self.checkTodayPriceUseCase = checkTodayPriceUseCase
        self.mainQueue = mainQueue
    }

    func transform(from input: Input, disposeBag: DisposeBag) -&amp;gt; Output {
        let output = createViewModelOutput()
        input.viewDidLoadEvent.subscribe(
            onNext: { [weak self] _ in
                guard let self = self else { return }
                self.load()
            }
        )
        .disposed(by: disposeBag)
        return output
    }
}

private extension InterestStocksViewModel {
    func createViewModelOutput() -&amp;gt; Output {
        Output(stockInformations: stockInformationsSubject.asObservable())
    }

    func load() {
        let names: [String] = loadInterestStocksUseCase.execute()
        names.forEach { name in
            let checkStockTask = checkTodayPriceUseCase.execute(stockName: name) { [weak self] result in
                self?.mainQueue.async {
                    switch result {
                    case .success(let stockInformation):
                        self?.stockInformationArray.append(stockInformation)
                        guard let stockInformationArray = self?.stockInformationArray else { return }
                        self?.stockInformationsSubject.onNext(stockInformationArray)
                    case .failure(let error):
                        print(error)
                    }
                }
            }
            checkStockTasks[name] = checkStockTask
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1712981848337&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//
//  CheckTodayPriceUseCase.swift
//  Or-rock-Nari-lock

import Foundation

protocol CheckTodayPriceUseCase {
    func execute(
        stockName: String,
        completion: @escaping (Result&amp;lt;StockInformation, Error&amp;gt;) -&amp;gt; Void
    ) -&amp;gt; Cancellable?
}

final class DefaultCheckTodayPriceUseCase: CheckTodayPriceUseCase {
    private let checkStockRepository: CheckStockRepository

    init(checkStockRepository: CheckStockRepository = DefaultCheckStockRepository()) {
        self.checkStockRepository = checkStockRepository
    }

    func execute(
        stockName: String,
        completion: @escaping (Result&amp;lt;StockInformation, Error&amp;gt;) -&amp;gt; Void)
    -&amp;gt; Cancellable? {
        checkStockRepository.fetchStockTodayPrices(stockNames: stockName) { result in
            completion(result)
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1712982011432&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//
//  CheckStockRepository.swift
//  Or-rock-Nari-lock

import Foundation

final class DefaultCheckStockRepository: CheckStockRepository {
    private let dataTransferService: DataTransfer
    private let backgroundQueue: DataTransferDispatchQueue = DispatchQueue.global(qos: .userInitiated)

    init(dataTransferService: DataTransfer = APIDataTransfer(apiProvider: APIProvider(sessionManager: MockNetworkSessionManager(response: nil, data: nil, error: nil)))) {
        self.dataTransferService = dataTransferService
    }
}

extension DefaultCheckStockRepository {
    func fetchStockTodayPrices(
        stockNames: String,
        completion: @escaping (Result&amp;lt;StockInformation, Error&amp;gt;) -&amp;gt; Void)
    -&amp;gt; Cancellable? {
        let task = RepositoryTask()
        let endPoint: EndPoint&amp;lt;StockInformationDTO&amp;gt; = EndPoint&amp;lt;StockInformationDTO&amp;gt;(path: &quot;/uapi/domestic-stock/v1/quotations/inquire-price&quot;, method: .GET)
        task.networkTask = self.dataTransferService.request(
            with: endPoint,
            on: backgroundQueue,
            completion: { result in
                switch result {
                case .success(let responseDTO):
                    completion(.success(responseDTO.toDomain()))
                case .failure(let error):
                    completion(.failure(error))
                }
            })
        return task
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위와 같이 Completion Handler를 사용하면서 각 코드 부분을 clousre로 처리하다 보니 코드의 depth가 깊어지는 것을 확인할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 RxSwift를 통해 개선해보면?&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. 개선코드&lt;/h4&gt;
&lt;pre id=&quot;code_1712982153978&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//
//  InterestStocksViewModel.swift
//  Or-rock-Nari-lock

import Foundation
import RxSwift

final class InterestStocksViewModel {
    private let loadInterestStocksUseCase: LoadInterestStocksUseCase
    private let checkTodayPriceUseCase: CheckTodayPriceUseCase

    private var checkStockTasks: [String:Cancellable?] = [:]
    private let mainQueue: DispatchQueueType

    private var stockInformationArray : [StockInformation] = []
    private let stockInformationsSubject = PublishSubject&amp;lt;[StockInformation]&amp;gt;()

    struct Input {
        let viewDidLoadEvent: Observable&amp;lt;Void&amp;gt;
    }

    struct Output {
        let stockInformations: Observable&amp;lt;[StockInformation]&amp;gt;
    }

    init(loadInterestStocksUseCase: LoadInterestStocksUseCase = DefaultLoadInterestStocksUseCase(),
         checkTodayPriceUseCase: CheckTodayPriceUseCase = DefaultCheckTodayPriceUseCase(),
         mainQueue: DispatchQueueType = DispatchQueue.main) {
        self.loadInterestStocksUseCase = loadInterestStocksUseCase
        self.checkTodayPriceUseCase = checkTodayPriceUseCase
        self.mainQueue = mainQueue
    }

    func transform(from input: Input, disposeBag: DisposeBag) -&amp;gt; Output {
        let output = createViewModelOutput()
        input.viewDidLoadEvent.subscribe(
            onNext: { [weak self] _ in
                guard let self = self else { return }
                fetchStockInformation(for: self.load(), disposeBag: disposeBag)
            }
        )
        .disposed(by: disposeBag)
        return output
    }
}

private extension InterestStocksViewModel {
    func createViewModelOutput() -&amp;gt; Output {
        Output(stockInformations: stockInformationsSubject.asObservable())
    }

    func load() -&amp;gt; [String] {
        loadInterestStocksUseCase.execute()
    }

    func fetchStockInformation(for names: [String], disposeBag: DisposeBag) {
        Observable.from(names)
            .flatMap { name in
                return self.checkTodayPriceUseCase.execute(stockName: name)
            }
            .observe(on: MainScheduler.instance)
            .subscribe(onNext: { [weak self] stockInformation in
                guard let stockInformation else { return }
                self?.stockInformationArray.append(stockInformation)
                guard let stockInformationArray = self?.stockInformationArray else { return }
                self?.stockInformationsSubject.onNext(stockInformationArray)
            }, onError: { error in
                print(error)
            })
            .disposed(by: disposeBag)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1712982255302&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//
//  CheckTodayPriceUseCase.swift
//  Or-rock-Nari-lock


import Foundation
import RxSwift

protocol CheckTodayPriceUseCase {
    func execute(stockName: String) -&amp;gt; Observable&amp;lt;StockInformation?&amp;gt;
}

final class DefaultCheckTodayPriceUseCase: CheckTodayPriceUseCase {
    private let checkStockRepository: CheckStockRepository

    init(checkStockRepository: CheckStockRepository = MockCheckStockRepository()) {
        self.checkStockRepository = checkStockRepository
    }

    func execute(stockName: String) -&amp;gt; Observable&amp;lt;StockInformation?&amp;gt; {
        checkStockRepository.fetchStockTodayPrices(stockName: stockName)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1712982282869&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//
//  CheckStockRepository.swift
//  Or-rock-Nari-lock

import Foundation
import RxSwift

final class DefaultCheckStockRepository {
    private let dataTransferService: DataTransfer
    private let backgroundQueue: DataTransferDispatchQueue = DispatchQueue.global(qos: .userInitiated)

    init(dataTransferService: DataTransfer = APIDataTransfer(apiProvider: APIProvider(sessionManager: MockNetworkSessionManager(response: nil, data: nil, error: nil)))) {
        self.dataTransferService = dataTransferService
    }
}

extension DefaultCheckStockRepository: CheckStockRepository {
    func fetchStockTodayPrices(stockName: String) -&amp;gt; Observable&amp;lt;StockInformation?&amp;gt; {
        return Observable.create { observer in
            let task = RepositoryTask()
            let endPoint: EndPoint&amp;lt;StockDayPriceDTO&amp;gt; = EndPoint&amp;lt;StockDayPriceDTO&amp;gt;(path: &quot;/uapi/domestic-stock/v1/quotations/inquire-price&quot;, method: .GET)
            task.networkTask = self.dataTransferService.request(
                with: endPoint,
                on: self.backgroundQueue,
                completion: { result in
                    switch result {
                    case .success(let responseDTO):
                        observer.onNext(responseDTO.toDomain(korName: stockName, engName: stockName))
                        observer.onCompleted()
                    case .failure(let error):
                        observer.onError(error)
                    }
                })
            return Disposables.create {
                task.cancel()
            }
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;훨씬 코드가 깔끔해진 것을 볼 수 있다. 기존 구성해 놓았던 Network Layer까지 변경하는 건 코드를 변경할 부분이 너무 많아진다고 생각했기 때문에 Repository에서부터 RxSwift를 사용하여 주식 시세 조회 결과를 Observable로 전달한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RxSwift가 반응형 프로그래밍으로서 역할도 하지만, 비동기 동적을 관리함에 있어서 코드의 가독성을 높여주는 역할도 할 수 있다는 것을 경험했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/App-in-App-le/Or-rock-Nari-lock/tree/iOS/dev&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/App-in-App-le/Or-rock-Nari-lock/tree/iOS/dev&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1712982858125&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;GitHub - App-in-App-le/Or-rock-Nari-lock: 주식 정보 iOS 애플리케이션&quot; data-og-description=&quot;주식 정보 iOS 애플리케이션. Contribute to App-in-App-le/Or-rock-Nari-lock development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/App-in-App-le/Or-rock-Nari-lock/tree/iOS/dev&quot; data-og-url=&quot;https://github.com/App-in-App-le/Or-rock-Nari-lock&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cnDzbk/hyVPJmQHfL/ATmeazk75zGD8pY5QiE961/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/App-in-App-le/Or-rock-Nari-lock/tree/iOS/dev&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/App-in-App-le/Or-rock-Nari-lock/tree/iOS/dev&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cnDzbk/hyVPJmQHfL/ATmeazk75zGD8pY5QiE961/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;GitHub - App-in-App-le/Or-rock-Nari-lock: 주식 정보 iOS 애플리케이션&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;주식 정보 iOS 애플리케이션. Contribute to App-in-App-le/Or-rock-Nari-lock development by creating an account on GitHub.&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;</description>
      <category>iOS</category>
      <author>찹모찌</author>
      <guid isPermaLink="true">https://chopmozzi.tistory.com/25</guid>
      <comments>https://chopmozzi.tistory.com/25#entry25comment</comments>
      <pubDate>Sat, 13 Apr 2024 13:31:06 +0900</pubDate>
    </item>
    <item>
      <title>MVVM + RxSwift 직접 써보기 // 주어진 API에서 최선을 다히기</title>
      <link>https://chopmozzi.tistory.com/24</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;현재 관심 종목의 일자별 시세를 알고, 그래프로 나타내는 주식 애플리케이션을 개발 중에 있다. 필자는 관심 종목 검색 및 일자별 시세 조회 기능을 담당하고 있는데, 이때 한국투자증권 API의 특징에 맞춰 기능 구현 및 MVVM과 RxSwift를 어떻게 활용하고 있는지에 대해 서술한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. 프로젝트 구조&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MVVM + RxSwift를 사용했다. 주식 애플리케이션의 특성상 현재는 일자별 시세 조회에 그치지만 더욱 외부 변화에 맞춰 애플리케이션이 동작할 필요가 있어보여 반응형 프로그래밍이 적합하다 판단했고, RxSwift의 특징인 반응형 프로그래밍과 잘 맞는 디자인 패턴이 MVVM이라고 생각했다.&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;검색 기능과 일자별 시세 조회 기능 중 일자별 시세 조회 기능을 먼저 진행하기로 하였다. 사용자가 관심 종목 리스트를 저장해두면, 그 관심 종목 리스트의 일자별 시세를 조회해 표시한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;관심 종목이 여러개일수 있다. 그러나 한국투자증권 API의 주식 API는 한 번에 하나의 주식 시세 밖에 조회하지 못한다. 만약 서버와 협업 중이었다면 리스트로 보내도 여러 개의 시세를 받아올 수 있도록 했겠지만, 이미 구현된 API를 사용해야 하기 때문에 그렇지 못했다. 따라서 한 번에 하나씩 조회한다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. 기능 로직&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에 고민했던게, 한 번에 하나씩 조회해야 하기 때문에 관심 종목 리스트의 모든 시세를 조회하는 것을 다 기다리려면 오랜 시간이 걸릴 것 같다는 생각이 들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 하나의 종목 시세 조회를 완료했을 때, 해당 데이터를 UICollectionView에 바로바로 적용해주는 것으로 했다. UICollectionView를 활용하고, iOS 버전도 13보다 높은 버전을 목표로 하고 있었기 때문에 UICollectionViewDiffableDataSource를 활용하면 될 것 같다는 생각이 들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본적인 Flow는 다음과 같다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-04-12 오후 3.20.17.png&quot; data-origin-width=&quot;896&quot; data-origin-height=&quot;229&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mrYEr/btsGzaPeasm/Wa5yMBzt7gAlnwwoSdo3k1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mrYEr/btsGzaPeasm/Wa5yMBzt7gAlnwwoSdo3k1/img.png&quot; data-alt=&quot;Flow&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mrYEr/btsGzaPeasm/Wa5yMBzt7gAlnwwoSdo3k1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmrYEr%2FbtsGzaPeasm%2FWa5yMBzt7gAlnwwoSdo3k1%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;896&quot; height=&quot;229&quot; data-filename=&quot;스크린샷 2024-04-12 오후 3.20.17.png&quot; data-origin-width=&quot;896&quot; data-origin-height=&quot;229&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Flow&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 때 시세 조회를 통해 Stock Information이 들어오면 UICollectionView가 update 되도록 반응형 프로그래밍을 사용하는 것이 적합할 것이다.&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;Clean Architecture를 따르고 있기 때문에 Repostiory -&amp;gt; UseCase -&amp;gt; ViewModel로 데이터들이 전달된다. 물론 요청은 ViewModel -&amp;gt; UseCase -&amp;gt; Repository 순.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-04-12 오후 3.46.05.png&quot; data-origin-width=&quot;472&quot; data-origin-height=&quot;504&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/blGiYA/btsGyEbRMyj/0o2l2u4Ew69PQFH9o1c8D0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/blGiYA/btsGyEbRMyj/0o2l2u4Ew69PQFH9o1c8D0/img.png&quot; data-alt=&quot;관심 종목 ViewModel에서 각 부분에 요청을 보낸다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/blGiYA/btsGyEbRMyj/0o2l2u4Ew69PQFH9o1c8D0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FblGiYA%2FbtsGyEbRMyj%2F0o2l2u4Ew69PQFH9o1c8D0%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;472&quot; height=&quot;504&quot; data-filename=&quot;스크린샷 2024-04-12 오후 3.46.05.png&quot; data-origin-width=&quot;472&quot; data-origin-height=&quot;504&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;관심 종목 ViewModel에서 각 부분에 요청을 보낸다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;pre id=&quot;code_1712904939687&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import Foundation
import RxSwift

final class InterestStocksViewModel {
    private let loadInterestStocksUseCase: LoadInterestStocksUseCase
    private let checkTodayPriceUseCase: CheckTodayPriceUseCase

    private var checkStockTasks: [String:Cancellable?] = [:]
    private let mainQueue: DispatchQueueType

    private var stockInformationArray : [StockInformation] = []
    private let stockInformationsSubject = BehaviorSubject&amp;lt;[StockInformation]&amp;gt;(value: [])

    struct Input {
        let viewDidLoadEvent: Observable&amp;lt;Void&amp;gt;
    }

    struct Output {
        let stockInformations: Observable&amp;lt;[StockInformation]&amp;gt;
    }

    init(loadInterestStocksUseCase: LoadInterestStocksUseCase = DefaultLoadInterestStocksUseCase(),
         checkTodayPriceUseCase: CheckTodayPriceUseCase = DefaultCheckTodayPriceUseCase(),
         mainQueue: DispatchQueueType = DispatchQueue.main) {
        self.loadInterestStocksUseCase = loadInterestStocksUseCase
        self.checkTodayPriceUseCase = checkTodayPriceUseCase
        self.mainQueue = mainQueue
    }

    func transform(from input: Input, disposeBag: DisposeBag) -&amp;gt; Output {
        let output = createViewModelOutput()
        input.viewDidLoadEvent.subscribe(
            onNext: { [weak self] _ in
                guard let self = self else { return }
                // view가 나타나면 load
                self.load()
            }
        )
        .disposed(by: disposeBag)
        return output
    }
}

private extension InterestStocksViewModel {
    func createViewModelOutput() -&amp;gt; Output {
        Output(stockInformations: stockInformationsSubject.asObservable())
    }

    func load() {
    	// 저장된 관심 종목 리스트를 받아온다.
        let names: [String] = loadInterestStocksUseCase.execute()
        // 종목 리스트를 돌면서 해당 종목의 시세를 조회!
        names.forEach { name in
            let checkStockTask = checkTodayPriceUseCase.execute(stockName: name) { [weak self] result in
                self?.mainQueue.async {
                    switch result {
                    case .success(let stockInformation):
                    // 종목 리스트의 내용이 추가되면서 변하기 때문에 BehaviorSubject를 사용해 이벤트를 전달한다.
                        self?.stockInformationArray.append(stockInformation)
                        guard let stockInformationArray = self?.stockInformationArray else { return }
                        self?.stockInformationsSubject.onNext(stockInformationArray)
                    case .failure(let error):
                        print(error)
                    }
                }
            }
            checkStockTasks[name] = checkStockTask
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;전체 코드는 위와 같다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;checkTodayPriceUseCase.execute의 결과로(주식 시세를 조회한다) 해당 주식의 정보를 받게 되면, 그 받은 내용을 stockInformationArray에 append 하고 stockInformationsSubject에 onNext의 파라미터로 전달한다. 그러면 stockInformationsSubject는 Output의 stockInformations에 asObservable 메서드를 사용하여 전달한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그럼 bind된 ViewController에서는 어떤 동작을 하고 있을까?&lt;/p&gt;
&lt;pre id=&quot;code_1712937924297&quot; class=&quot;swift&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;swift&quot;&gt;&lt;code&gt;//
//  InterestStocksViewController.swift
//  Or-rock-Nari-lock
//
//  Created by 황지웅 on 2/21/24.
//

import UIKit
import RxSwift

final class InterestStocksViewController: UIViewController {

    private lazy var collectionView: UICollectionView = {
        let config = UICollectionLayoutListConfiguration(appearance: .plain)
        let collectionView: UICollectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewCompositionalLayout.list(using: config))
        collectionView.register(StockCell.self, forCellWithReuseIdentifier: StockCell.identifier)
        return collectionView
    }()

    private lazy var dataSource =  UICollectionViewDiffableDataSource&amp;lt;UUID, StockInformation&amp;gt;(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: StockCell.identifier, for: indexPath) as? StockCell else { return UICollectionViewCell() }
        cell.setContent(itemIdentifier)
        return cell
    }

    private let disposeBag: DisposeBag = DisposeBag()
    // TODO: Coordinator에서 생성
    private var viewModel: InterestStocksViewModel = InterestStocksViewModel()
    // viewLoad는 Subject로서 viewWillAppear에서 view가 load됨을 알리고, ViewModel의 viewDidLoadEvent의 Input으로 전달한다.
    private let viewLoad = PublishSubject&amp;lt;Void&amp;gt;()

    override func viewDidLoad() {
        super.viewDidLoad()
        setupViews()
        bindViewModel()
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        viewLoad.onNext(())
    }

    private func setupViews() {
        setConstraints()
        collectionView.dataSource = dataSource
    }

    private func setConstraints() {
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(collectionView)
        NSLayoutConstraint.activate([
            collectionView.topAnchor.constraint(equalTo: view.topAnchor),
            collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
    }

    private func bindViewModel() {
        let input = InterestStocksViewModel.Input(viewDidLoadEvent: viewLoad.asObservable())
        let output = viewModel.transform(from: input, disposeBag: disposeBag)
	// stockInformations에 주식 정보가 추가 되면 관심 종목 리스트를 업데이트한다.
        output.stockInformations
            .subscribe(onNext: { [weak self] stockInformationArray in
                guard let self = self else { return }

                var snapshot = NSDiffableDataSourceSnapshot&amp;lt;UUID, StockInformation&amp;gt;()
                snapshot.appendSections([UUID()])
                snapshot.appendItems(stockInformationArray)
                self.dataSource.apply(snapshot, animatingDifferences: true)
            })
            .disposed(by: disposeBag)
    }

}

#Preview {
    InterestStocksViewController()
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;먼저 View가 로드될 때, 관심 종목 리스트를 불러올 것이기 때문에 bindViewModel에서 ViewModel의 Input에 viewLoad를 전달한다. 그리고 viewWillAppear에서 viewLoad.onNext를 통해 view가 나타남을 알린다. 이 프로젝트는 아직 개발 중이긴 한데, viewWillAppear가 viewDidLoad가 적합해 보이기도 한다(뷰가 매번 나타날 때마다 API를 쏴댈 테니). 무튼무튼아무튼&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 로직을 알기 쉽게 그림으로 정리해 보면,&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-04-13 오전 1.06.20.png&quot; data-origin-width=&quot;1064&quot; data-origin-height=&quot;525&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c97zbH/btsGz0su2q1/1KDAxU5iM0aaQMmwTB3ShK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c97zbH/btsGz0su2q1/1KDAxU5iM0aaQMmwTB3ShK/img.png&quot; data-alt=&quot;ViewController와 ViewModel간 로직&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c97zbH/btsGz0su2q1/1KDAxU5iM0aaQMmwTB3ShK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc97zbH%2FbtsGz0su2q1%2F1KDAxU5iM0aaQMmwTB3ShK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1064&quot; height=&quot;525&quot; data-filename=&quot;스크린샷 2024-04-13 오전 1.06.20.png&quot; data-origin-width=&quot;1064&quot; data-origin-height=&quot;525&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;ViewController와 ViewModel간 로직&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;bind를 할 때, ViewController에서 Input으로 viewLoad를 onNext로 ViewModel에 이벤트를 알리고, output으로 stockInformations를 Observable로 받아 구독한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ViewModel에서는 Input으로 viewLoad를 Observable로 받아 구독하고 Output으로 stockInformations을 ViewController에 전달한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;viewLoad.onNext()로 인해 구독 중이던 ViewModel에서 load가 실행돼 주식 정보를 stockInformations에 추가하면 stockInformations를 구독중이던 ViewController에서 dataSource를 업데이트한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;5. 결론&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 번에 하나만 조회해야 하는 API의 조건에 맞춰 MVVM과 RxSwift를 활용하여 관심 종목 리스트의 주식 정보를 추가하고 View를 업데이트할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;bind를 통해 ViewController와 ViewModel의 양방향 연결을 하는 부분에서 RxSwift의 반응형 프로그래밍의 특성을 발휘할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추가적으로 CleanArchitecture를 적용함에 따라 Repository, UseCase, ViewModel을 RxSwift로도 연결할 수 있다. RxSwift로 연결하는 방법과 개선효과에 대해서는 다음 글에서 서술하겠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a title=&quot;다음 글&quot; href=&quot;https://chopmozzi.tistory.com/25&quot; rel=&quot;noopener&quot;&gt;https://chopmozzi.tistory.com/25&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1712982801590&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;RxSwift로 코드 개선하기(with. Clean Architecture)&quot; data-og-description=&quot;RxSwift는 반응형 프로그래밍으로서의 장점뿐만 아니라 Completion Handler로 발생하는 Clousre의 코드 depth 또한 해결할 수 있다. 기존코드와 개선한 코드를 비교해가며 이를 알아보자. 1. 기존 코드 기존&quot; data-og-host=&quot;chopmozzi.tistory.com&quot; data-og-source-url=&quot;https://chopmozzi.tistory.com/25&quot; data-og-url=&quot;https://chopmozzi.tistory.com/25&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/eVEFZ/hyVPUIF0R9/6KPUEF3XD0LRUBBHykOCw0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/gglQa/hyVPKF4jDy/s4cjT7ldvyHSw0p0J9bLS0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/myYK2/hyVMWIakJK/oIFtBNGUFCfFkZkm4283Q0/img.jpg?width=1058&amp;amp;height=1058&amp;amp;face=383_481_518_628&quot;&gt;&lt;a href=&quot;https://chopmozzi.tistory.com/25&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://chopmozzi.tistory.com/25&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/eVEFZ/hyVPUIF0R9/6KPUEF3XD0LRUBBHykOCw0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/gglQa/hyVPKF4jDy/s4cjT7ldvyHSw0p0J9bLS0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/myYK2/hyVMWIakJK/oIFtBNGUFCfFkZkm4283Q0/img.jpg?width=1058&amp;amp;height=1058&amp;amp;face=383_481_518_628');&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;RxSwift로 코드 개선하기(with. Clean Architecture)&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;RxSwift는 반응형 프로그래밍으로서의 장점뿐만 아니라 Completion Handler로 발생하는 Clousre의 코드 depth 또한 해결할 수 있다. 기존코드와 개선한 코드를 비교해가며 이를 알아보자. 1. 기존 코드 기존&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;chopmozzi.tistory.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;a href=&quot;https://github.com/App-in-App-le/Or-rock-Nari-lock/tree/iOS/dev&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/App-in-App-le/Or-rock-Nari-lock/tree/iOS/dev&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1712982842797&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;GitHub - App-in-App-le/Or-rock-Nari-lock: 주식 정보 iOS 애플리케이션&quot; data-og-description=&quot;주식 정보 iOS 애플리케이션. Contribute to App-in-App-le/Or-rock-Nari-lock development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/App-in-App-le/Or-rock-Nari-lock/tree/iOS/dev&quot; data-og-url=&quot;https://github.com/App-in-App-le/Or-rock-Nari-lock&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cnDzbk/hyVPJmQHfL/ATmeazk75zGD8pY5QiE961/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/App-in-App-le/Or-rock-Nari-lock/tree/iOS/dev&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/App-in-App-le/Or-rock-Nari-lock/tree/iOS/dev&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cnDzbk/hyVPJmQHfL/ATmeazk75zGD8pY5QiE961/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;GitHub - App-in-App-le/Or-rock-Nari-lock: 주식 정보 iOS 애플리케이션&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;주식 정보 iOS 애플리케이션. Contribute to App-in-App-le/Or-rock-Nari-lock development by creating an account on GitHub.&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;</description>
      <category>iOS</category>
      <author>찹모찌</author>
      <guid isPermaLink="true">https://chopmozzi.tistory.com/24</guid>
      <comments>https://chopmozzi.tistory.com/24#entry24comment</comments>
      <pubDate>Sat, 13 Apr 2024 01:19:57 +0900</pubDate>
    </item>
    <item>
      <title>Mock 활용해서 환경 의존도 없애기(with. URLSession, UserDefaults)</title>
      <link>https://chopmozzi.tistory.com/22</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;iOS Client의 역할을 맡아 개발을 진행하다 보면 개발 환경에 의존할 때가 있다. Server의 상태라던가, 아니면 기기의 DB라던가.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발을 하면서 이 환경이 완벽하게 구성이 되기는 힘들 것이다. Network가 연결이 안 될 수도 있고, Server 측의 API가 아직 준비가 안 됐을 수도 있고, 기기 DB의 용량이 부족하다던가, 아니면 테스트할 데이터의 수가 부족하다던가와 같은 경우들이 있을 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이럴 때 환경에 의존하지 않고 Business Logic을 테스트하기 용이하도록 &lt;b&gt;Mock&lt;/b&gt;을 활용할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;URLSession과 UserDefaults를 Mock을 활용해 기능을 구현하거나 테스트를 하는 법을 알아보자.&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-04-10 오후 11.25.33.png&quot; data-origin-width=&quot;798&quot; data-origin-height=&quot;394&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/C0zgX/btsGvXoAKuB/wb0vPjRgkJ8IsEChkxsn61/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/C0zgX/btsGvXoAKuB/wb0vPjRgkJ8IsEChkxsn61/img.png&quot; data-alt=&quot;Mock을 사용하는 원리&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/C0zgX/btsGvXoAKuB/wb0vPjRgkJ8IsEChkxsn61/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FC0zgX%2FbtsGvXoAKuB%2Fwb0vPjRgkJ8IsEChkxsn61%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;798&quot; height=&quot;394&quot; data-filename=&quot;스크린샷 2024-04-10 오후 11.25.33.png&quot; data-origin-width=&quot;798&quot; data-origin-height=&quot;394&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Mock을 사용하는 원리&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하나의 기본 동작이 되는 Protocol을 만든다. 그리고 그 Protocol에 따르는 실제 환경과 상호작용하는 객체, Mock Data를 활용하는 객체를 만든다. 이를 활용해 환경에 의존하지 않고, 환경에 의존하는 객체를 분리하여 기능을 구현하고 테스트할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 예시를 보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Mock NetworkManager&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 주식 시세 조회 앱을 구현 중인데, 주식 API을 사용하는데 제한이 있어 Mock을 사용하고자 한다. 먼저 실제 환경과 상호작용하는 NetworkManager를 먼저 보자.&lt;/p&gt;
&lt;pre id=&quot;code_1712760385922&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// NetworkSessionManager Protocol request 메서드를 채택해놓은 상태.
protocol NetworkSessionManager {
    typealias CompletionHandler = (Data?, URLResponse?, Error?) -&amp;gt; Void

    func request(_ request: URLRequest, completion: @escaping CompletionHandler) -&amp;gt; NetworkCancellable
}

final class DefaultNetworkSessionManager: NetworkSessionManager {
    func request(_ request: URLRequest, completion: @escaping CompletionHandler) -&amp;gt; NetworkCancellable {
        // URLSession.shared.dataTask는 실제 네트워크를 필요로 한다.
        let task = URLSession.shared.dataTask(with: request, completionHandler: completion)
        task.resume()
        return task
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;request라는 Method를 가진 NetworkSessionManager를 DefaultNetworkSessionManager 채택해 사용 중이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 request부분을 보면 되는데, request의 task는 URLSession.shared의 dataTask를 이용해 Network의 데이터를 받아온다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때, 이 request를 가진 Mock Manager를 이용하면 실제 Network환경 없이도 기능 구현에 활용할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1712761355698&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// NetworkSessionManager Protocol을 채택
final class MockNetworkSessionManager: NetworkSessionManager {
    var response: HTTPURLResponse?
    let data: Data?
    let error: Error?

    init(response: HTTPURLResponse?, data: Data?, error: Error?) {
        self.response = response
        self.data = data
        self.error = error
    }
	
    // request method를 구현하는데 URLSession을 사용하는 것이 아닌 로컬의 json data를 가져와 사용
    func request(_ request: URLRequest,
                 completion: @escaping CompletionHandler) -&amp;gt; NetworkCancellable {
        response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)
        if let url = Bundle.main.url(forResource: &quot;MockStock&quot;, withExtension: &quot;json&quot;) {
            do {
                let jsonData = try Data(contentsOf: url)
                completion(jsonData, response, error)
            } catch {
                completion(nil, response, error)
            }
        } else {
            completion(nil, response, NSError(domain: &quot;MockNetworkSessionManager&quot;, code: 404))
        }
        return MockURLSessionDataTask()
    }
}

// cancel method도 따로 구현해줄수도 있다.
final class MockURLSessionDataTask: NetworkCancellable {
    func cancel() {
        print(&quot;DataTask cancelled&quot;)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 식으로 실제 Network를 사용하는 대신 Mock data를 활용하여 NetworkManager의 request method를 사용할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 request를 대체하는 대신, URLSession.shared의 dataTask를 Mock으로 대체하는 방법도 있다. 이 부분은 좀 더 상위 부분을 Mock으로 대체한 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러나 이런 방식으로 하게 되면, MockNetworkSessionManager의 request는 정해진 하나의 데이터 밖에 주지 못하기 때문에 불편한 점이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MockURLProtocol을 사용해 개선해 보자.&lt;/p&gt;
&lt;pre id=&quot;code_1712847268562&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// URLProtocol을 채택시킨 MockURLProtocol
final class MockURLProtocol: URLProtocol {
	// requestHandler를 이용해 request시 return 값을 내 맘대로 지정할 수 있다.
    static var requestHandler: ((URLRequest) -&amp;gt; (HTTPURLResponse?, Data?, Error?))?

    override class func canInit(with request: URLRequest) -&amp;gt; Bool {
        return true
    }

    override class func canonicalRequest(for request: URLRequest) -&amp;gt; URLRequest {
        return request
    }

    override func startLoading() {
        guard let handler = MockURLProtocol.requestHandler else { return }
        let (response, data, error) = handler(request)

        if let error = error {
          client?.urlProtocol(self, didFailWithError: error)
        }

        if let response = response {
          client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
        }

        if let data = data {
          client?.urlProtocol(self, didLoad: data)
        }

        client?.urlProtocolDidFinishLoading(self)
    }

    override func stopLoading() { }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;URLProtocol을 채택하여 requestHandler를 활용해 내가 원하는 대로 request의 return값을 변경할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1712847801691&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;extension URLSession {
	// Mock session으로 초기화
    static func initMockSession(configuration: URLSessionConfiguration = .ephemeral) -&amp;gt; URLSession {
        let configuration = URLSessionConfiguration.default
        configuration.protocolClasses = [MockURLProtocol.self]
        let urlSession = URLSession.init(configuration: configuration)
        return urlSession
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 통신에서는 URLSession.shared.dataTask를 사용하지만 Mock URLSession에서는 shared가 아닌 Mock Session을 사용하여 내가 원하는 데이터를 전달시킬 것이다.&lt;/p&gt;
&lt;pre id=&quot;code_1712848009764&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;final class MockCheckStockRepository {
    private let dataTransferService: DataTransfer
    private let backgroundQueue: DataTransferDispatchQueue = DispatchQueue.global(qos: .userInitiated)
    private var currentIndex = -1
    private var mockDatas: [Data] = []

    init(dataTransferService: DataTransfer = APIDataTransfer(apiProvider: APIProvider(sessionManager: DefaultNetworkSessionManager(session: .initMockSession()))) ) {
        self.dataTransferService = dataTransferService
    }
}

extension MockCheckStockRepository: CheckStockRepository {
    func fetchStockTodayPrices(stockName: String) -&amp;gt; Observable&amp;lt;StockInformation?&amp;gt; {
        var mockName: String
        switch stockName {
        case &quot;SK하이닉스&quot;:
            mockName = &quot;MockStock1&quot;
        case &quot;삼성&quot;:
            mockName = &quot;MockStock2&quot;
        case &quot;네이버&quot;:
            mockName = &quot;MockStock3&quot;
        default:
            mockName = &quot;MockStock1&quot;
        }
        
        guard let mockFileLocation = Bundle.main.url(forResource: mockName, withExtension: &quot;json&quot;),
              let mockData = try? Data(contentsOf: mockFileLocation) else {
            return Observable.just(nil)
        }
        // 원하는 데이터로 변경
        mockDatas.append(mockData)
        // requestHandler는 URLSession의 request가 실행될 때 안의 클로저가 실행되므로 순서를 맞출 필요가 있을 때가 있다.
        MockURLProtocol.requestHandler = { request in
            let response = HTTPURLResponse(url: request.url!,
                                           statusCode: 200,
                                           httpVersion: nil,
                                           headerFields: nil)
            self.currentIndex += 1
            let currentIndex = min(self.currentIndex, self.mockDatas.indices.last ?? 0)
            return (response, self.mockDatas[currentIndex], nil)
        }

        return Observable.create { observer in
            let task = RepositoryTask()
            let endPoint: EndPoint&amp;lt;StockDayPriceDTO&amp;gt; = EndPoint&amp;lt;StockDayPriceDTO&amp;gt;(path: &quot;/uapi/domestic-stock/v1/quotations/inquire-price&quot;, method: .GET)
            task.networkTask = self.dataTransferService.request(
                with: endPoint,
                on: self.backgroundQueue,
                completion: { result in
                    switch result {
                    case .success(let responseDTO):
                        observer.onNext(responseDTO.toDomain(korName: stockName, engName: stockName))
                        observer.onCompleted()
                    case .failure(let error):
                        observer.onError(error)
                    }
                })
            return Disposables.create {
                task.cancel()
            }
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드는 실제 프로젝트의 한 부분이다. 주식 목록의 Mock을 가져온다. 이때 stockName마다 주식 내용을 다르게 하기 위하여 MockURLProtocol의 requestHandler의 data를 배열에서 꺼내 사용한다.&lt;/p&gt;
&lt;pre id=&quot;code_1712848144736&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;protocol NetworkSessionManager {
    typealias CompletionHandler = (Data?, URLResponse?, Error?) -&amp;gt; Void

    func request(_ request: URLRequest, completion: @escaping CompletionHandler) -&amp;gt; NetworkCancellable
}

final class DefaultNetworkSessionManager: NetworkSessionManager {
    private let session: URLSession
	// 초기화시 URLSession을 shared로 할 지, Mock으로 할 지 골라 사용하면 된다.
    init(session: URLSession) {
        self.session = session
    }

    func request(_ request: URLRequest, completion: @escaping CompletionHandler) -&amp;gt; NetworkCancellable {
        let task = session.dataTask(with: request, completionHandler: completion)
        task.resume()
        return task
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방법의 장점은 기존 NetworkSessionManager를 그대로 사용할 수 있는 것이다. Mock을 사용하는 부분의 상위 모듈에서 requestHandler만 변경시켜 사용해 줘도 되기 때문에 구조상 Mock을 사용하기 용이하다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Mock UserDefaults&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;URLSession뿐만 아니라 다른 곳에서도 충분히 적용가능하다. UserDefaults 또한 UserDefaults의 standard를 Mock으로 대체할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1712763025519&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// set과 array 메서드가 있는 UserDefaultsProtocol
protocol UserDefaultsProtocol {
    func set(_ value: Any?, forKey defaultName: String)
    func array(forKey: String) -&amp;gt; [Any]?
}

// 실제 UserDefaults에도 채택!
extension UserDefaults: UserDefaultsProtocol {

}

// MockUserDefaults에도 채택!
final class MockUserDefaultsStandard: UserDefaultsProtocol {
	// Mock 데이터를 사용한다.
    var stocksList: [String] = [&quot;카카오&quot;, &quot;삼성&quot;, &quot;네이버&quot;]

    func set(_ value: Any?, forKey defaultName: String) {
        guard let stock: String = value as? String else { return }
        stocksList.append(stock)
    }
    
    func array(forKey: String) -&amp;gt; [Any]? {
        stocksList
    }
}

// 실제 UserDefaults를 사용할 때는 userDefaults 파라미터를 갈아끼우면서 사용하면된다.
final class UserDefaultsManager {
    private let userDefaults: UserDefaultsProtocol
    let userDefaultsKey: String = &quot;stocks&quot;
	
    // Mock을 사용하고 싶으면 초기화시 Mock생성자를 이용해 넣어주면 된다.
    init(userDefaults: UserDefaultsProtocol = UserDefaults.standard) {
        self.userDefaults = userDefaults
    }

    func save(stock: String) {
        var stockList: [String] = userDefaults.array(forKey: userDefaultsKey) as? [String] ?? []
        stockList.append(stock)
        userDefaults.set(stockList, forKey: userDefaultsKey)
    }

    func load() -&amp;gt; [String] {
        userDefaults.array(forKey: userDefaultsKey) as? [String] ?? []
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 방식으로 Mock을 사용해 Network, UserDefaults를 실제 환경에 구애받지 않고 원하는 데이터를 넣어가며 사용할 수 있다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Mock을 사용하게 되면, 실제 서버 API가 구현되어있지 않거나, 테스트 환경이 필요하다고 데이터를 일일이 직접 집어넣고 이런 식으로 테스트하지 않아도 되고, 간단한 Mock 데이터를 활용하여 원하는 환경을 조성할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 진행중인 프로젝트의 코드가 궁금하다면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/App-in-App-le/Or-rock-Nari-lock/tree/iOS/dev&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/App-in-App-le/Or-rock-Nari-lock/tree/iOS/dev&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1712982826442&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;GitHub - App-in-App-le/Or-rock-Nari-lock: 주식 정보 iOS 애플리케이션&quot; data-og-description=&quot;주식 정보 iOS 애플리케이션. Contribute to App-in-App-le/Or-rock-Nari-lock development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/App-in-App-le/Or-rock-Nari-lock/tree/iOS/dev&quot; data-og-url=&quot;https://github.com/App-in-App-le/Or-rock-Nari-lock&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cnDzbk/hyVPJmQHfL/ATmeazk75zGD8pY5QiE961/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/App-in-App-le/Or-rock-Nari-lock/tree/iOS/dev&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/App-in-App-le/Or-rock-Nari-lock/tree/iOS/dev&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cnDzbk/hyVPJmQHfL/ATmeazk75zGD8pY5QiE961/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;GitHub - App-in-App-le/Or-rock-Nari-lock: 주식 정보 iOS 애플리케이션&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;주식 정보 iOS 애플리케이션. Contribute to App-in-App-le/Or-rock-Nari-lock development by creating an account on GitHub.&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;</description>
      <category>iOS</category>
      <author>찹모찌</author>
      <guid isPermaLink="true">https://chopmozzi.tistory.com/22</guid>
      <comments>https://chopmozzi.tistory.com/22#entry22comment</comments>
      <pubDate>Thu, 11 Apr 2024 00:32:46 +0900</pubDate>
    </item>
    <item>
      <title>내 앨범이 너에게 닿기를: 서버 없이 앨범 전달받기</title>
      <link>https://chopmozzi.tistory.com/20</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;'NostalgiAlbum' 앱을 만들 때, 서버가 존재하지 않고 로컬에서만 동작하는 앱을 커스텀 파일 확장자 'nost'로 추출해 share sheet를 활용하여 앨범 공유 기능을 만든 이야기를 담고 있습니다.&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음으로 만든 iOS 앱은 앨범 앱으로, 사진을 내용과 함께 기록하고 앨범으로 저장할 수 있는 앱이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://apps.apple.com/kr/app/nostalgialbum/id6448299485&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://apps.apple.com/kr/app/nostalgialbum/id6448299485&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1710483499825&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;&amp;lrm;NostalgiAlbum&quot; data-og-description=&quot;&amp;lrm;당신만의 앨범을 만들고 추억을 간직하세요. 사진에 그 때 느꼈던 감정을 기록해보세요. 친구들과 앨범을 공유하며 추억을 나눠요. [1] 앨범 만들기 - 앨범 표지를 NostalgiAlbum만의 표지를 사용&quot; data-og-host=&quot;apps.apple.com&quot; data-og-source-url=&quot;https://apps.apple.com/kr/app/nostalgialbum/id6448299485&quot; data-og-url=&quot;https://apps.apple.com/kr/app/nostalgialbum/id6448299485&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/IGHgw/hyVBPUT3yO/QbL2VZfUCCpQWFBFoXVEZK/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/b2BXzM/hyVxwC3xBu/LDEhXwdemYsIM2hwi9Mvkk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://apps.apple.com/kr/app/nostalgialbum/id6448299485&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://apps.apple.com/kr/app/nostalgialbum/id6448299485&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/IGHgw/hyVBPUT3yO/QbL2VZfUCCpQWFBFoXVEZK/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/b2BXzM/hyVxwC3xBu/LDEhXwdemYsIM2hwi9Mvkk/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;&amp;lrm;NostalgiAlbum&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;&amp;lrm;당신만의 앨범을 만들고 추억을 간직하세요. 사진에 그 때 느꼈던 감정을 기록해보세요. 친구들과 앨범을 공유하며 추억을 나눠요. [1] 앨범 만들기 - 앨범 표지를 NostalgiAlbum만의 표지를 사용&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;apps.apple.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://app-in-app-le.github.io/nostalgialbum/NostalgiAlbum.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://app-in-app-le.github.io/nostalgialbum/NostalgiAlbum.html&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1710483599025&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;NostalgiAlbum&quot; data-og-description=&quot; &amp;nbsp;App Description 앨범을 만들고 추억을 간직하고 싶은 사진들을 넣어 제목, 내용과 함께 꾸밀 수 있습니다. 앨범을 다른 사람들과 공유하며 추억을 나눌 수 있습니다. 앨범 표지, 글꼴, 테마 색을&quot; data-og-host=&quot;app-in-app-le.github.io&quot; data-og-source-url=&quot;https://app-in-app-le.github.io/nostalgialbum/NostalgiAlbum.html&quot; data-og-url=&quot;https://app-in-app-le.github.io/nostalgialbum/NostalgiAlbum.html&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/roYKO/hyVBIapp89/ZAxspcWrwbvjfxF1AUc3EK/img.png?width=1179&amp;amp;height=2556&amp;amp;face=0_0_1179_2556,https://scrap.kakaocdn.net/dn/c7cP7d/hyVyea1gqW/gHyjdZ9ObRhqZCOsopW361/img.png?width=1179&amp;amp;height=2556&amp;amp;face=0_0_1179_2556,https://scrap.kakaocdn.net/dn/bw2ezn/hyVykCfFJF/zmySdjUpkiFpywHk8BJzP0/img.png?width=1179&amp;amp;height=2556&amp;amp;face=0_0_1179_2556&quot;&gt;&lt;a href=&quot;https://app-in-app-le.github.io/nostalgialbum/NostalgiAlbum.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://app-in-app-le.github.io/nostalgialbum/NostalgiAlbum.html&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/roYKO/hyVBIapp89/ZAxspcWrwbvjfxF1AUc3EK/img.png?width=1179&amp;amp;height=2556&amp;amp;face=0_0_1179_2556,https://scrap.kakaocdn.net/dn/c7cP7d/hyVyea1gqW/gHyjdZ9ObRhqZCOsopW361/img.png?width=1179&amp;amp;height=2556&amp;amp;face=0_0_1179_2556,https://scrap.kakaocdn.net/dn/bw2ezn/hyVykCfFJF/zmySdjUpkiFpywHk8BJzP0/img.png?width=1179&amp;amp;height=2556&amp;amp;face=0_0_1179_2556');&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;NostalgiAlbum&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt; &amp;nbsp;App Description 앨범을 만들고 추억을 간직하고 싶은 사진들을 넣어 제목, 내용과 함께 꾸밀 수 있습니다. 앨범을 다른 사람들과 공유하며 추억을 나눌 수 있습니다. 앨범 표지, 글꼴, 테마 색을&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;app-in-app-le.github.io&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;NostalgiAlbum은 서버가 존재하지 않고 사진과 내용을 로컬 저장소에 기록하여 사용한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-03-15 오후 3.53.36.png&quot; data-origin-width=&quot;618&quot; data-origin-height=&quot;860&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qsuwO/btsFPGAnlze/aRUFUFa2McWbENPHKrrGl1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qsuwO/btsFPGAnlze/aRUFUFa2McWbENPHKrrGl1/img.png&quot; data-alt=&quot;앨범의 내용을 저장하고 불러오는 구조&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qsuwO/btsFPGAnlze/aRUFUFa2McWbENPHKrrGl1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqsuwO%2FbtsFPGAnlze%2FaRUFUFa2McWbENPHKrrGl1%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;297&quot; height=&quot;413&quot; data-filename=&quot;스크린샷 2024-03-15 오후 3.53.36.png&quot; data-origin-width=&quot;618&quot; data-origin-height=&quot;860&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;앨범의 내용을 저장하고 불러오는 구조&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앨범에 사진과 내용을 기록하던 도중 내 앨범을 다른 사람과 나눌 수 있다면 좋겠다는 생각이 들었다. 근데 로컬에 저장하는 파일을 어떻게 다른 사람에게 넘겨줄 수 있을까? 서버 없이도 공유할 수 있는 방법은 없을까? 폴더를 어떻게 다른 사람에게 넘겨주지?&lt;/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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-03-15 오후 5.02.28.png&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;438&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/8D6Cl/btsFPcflmZd/Fk9KgdFx5CxzbHfVVunemk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/8D6Cl/btsFPcflmZd/Fk9KgdFx5CxzbHfVVunemk/img.png&quot; data-alt=&quot;앨범을 어떻게 넘겨줄 수 있을까?&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/8D6Cl/btsFPcflmZd/Fk9KgdFx5CxzbHfVVunemk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F8D6Cl%2FbtsFPcflmZd%2FFk9KgdFx5CxzbHfVVunemk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;800&quot; height=&quot;438&quot; data-filename=&quot;스크린샷 2024-03-15 오후 5.02.28.png&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;438&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;내 앱에서도 앨범 폴더를 통해 앨범을 사용하고 있으니 폴더를 그대로 넘겨주면 되지 않을까라는 생각을 했다. 그런데 폴더를 어떻게 넘겨줄 수 있지? iOS 샌드박스가 이런 방식을 허용할까? 여러모로 힘든 방법임에 분명했다(불가능에 가까울지도).&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;activity views.png&quot; data-origin-width=&quot;1480&quot; data-origin-height=&quot;832&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bd5OGo/btsFP93qJ2Q/PkaWxYLScR6vkN0ylT7jdK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bd5OGo/btsFP93qJ2Q/PkaWxYLScR6vkN0ylT7jdK/img.png&quot; data-alt=&quot;iOS activity views or share sheet&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bd5OGo/btsFP93qJ2Q/PkaWxYLScR6vkN0ylT7jdK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbd5OGo%2FbtsFP93qJ2Q%2FPkaWxYLScR6vkN0ylT7jdK%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;1480&quot; height=&quot;832&quot; data-filename=&quot;activity views.png&quot; data-origin-width=&quot;1480&quot; data-origin-height=&quot;832&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;iOS activity views or share sheet&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;iOS에서는 share sheet를 통해 파일을 주고받는다. 저장을 할 수도 있고, 메시지 앱이나 각종 메신저를 활용할 수도 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앨범을 share sheet를 이용해서 보낸다면 어떨까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;총 두 가지 정도의 방법을 고민했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫번째로 NostalgiAlbum에서 탐색기처럼 파일을 찾고, 그 파일을 열어 앨범을 추가하는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러나 이 방법은 사용자 경험에서 별로 좋지 못하다는 생각이 들었다. 주고받을 때, 파일을 다운로드하고, 그걸 또다시 앱을 켜서 파일을 찾아서, 앨범을 추가해야 한다는 과정이 좋은 방법으로는 느껴지지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 어떤 방법이 좋을까? 문득 pdf파일을 pdf 리더 앱으로 열던 기억이 떠올랐다. NostalgiAlbum에도 적용할 수 있지 않을까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 정한 방법은 파일을 열 때 NostalgiAlbum으로 열어 자동적으로 앨범이 추가될 수 있도록 하면 어떨까?&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;공유 앨범 파일과 NostalgiAlbum 연결하기&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앨범 폴더 자체를 공유하기는 쉽지 않을 것이다. 폴더 자체를 보내는 것도 문제겠지만, 그 폴더가 앨범 폴더라는 것을 알 방법이 없다. 그러면 폴더를 압축해서 하나의 파일로 보내는 방법은 어떨까? 파일 자체를 추출할 수는 있겠지만 마찬가지로 해당 파일이 앨범 폴더라는 것을 알 방법이 없다. 따라서 nost라는 확장자를 가진 파일을 만들어 nost라는 파일을 열 때 NostalgiAlbum으로 열 수 있도록 할 것이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-03-15 오후 6.55.00.png&quot; data-origin-width=&quot;1140&quot; data-origin-height=&quot;590&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cZgk3F/btsFOW5dugN/FIGqyVzm9p2uvJ3Qbv8vY0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cZgk3F/btsFOW5dugN/FIGqyVzm9p2uvJ3Qbv8vY0/img.png&quot; data-alt=&quot;앨범 파일로 추출하는 과정&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cZgk3F/btsFOW5dugN/FIGqyVzm9p2uvJ3Qbv8vY0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcZgk3F%2FbtsFOW5dugN%2FFIGqyVzm9p2uvJ3Qbv8vY0%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;1140&quot; height=&quot;590&quot; data-filename=&quot;스크린샷 2024-03-15 오후 6.55.00.png&quot; data-origin-width=&quot;1140&quot; data-origin-height=&quot;590&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;물론 단순 확장자를 .nost로 바꾼 파일이 열리면 안 되기 때문에 파일을 변경하는 과정에서 특수 문자를 넣어 NostalgiAlbum인 것을 확인할 수 있도록 했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-03-15 오후 7.11.28.png&quot; data-origin-width=&quot;1492&quot; data-origin-height=&quot;598&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vg6VJ/btsFP2jnLIT/n5p4fGcgrlhKdG7XmEdax1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vg6VJ/btsFP2jnLIT/n5p4fGcgrlhKdG7XmEdax1/img.png&quot; data-alt=&quot;NostalgiAlbum의 공유 과정&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vg6VJ/btsFP2jnLIT/n5p4fGcgrlhKdG7XmEdax1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fvg6VJ%2FbtsFP2jnLIT%2Fn5p4fGcgrlhKdG7XmEdax1%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;1492&quot; height=&quot;598&quot; data-filename=&quot;스크린샷 2024-03-15 오후 7.11.28.png&quot; data-origin-width=&quot;1492&quot; data-origin-height=&quot;598&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;NostalgiAlbum의 공유 과정&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앨범에서 공유 버튼을 누르면 nost 파일을 생성해 share sheet의 activityItems에 넣는다. 그걸 카카오톡과 같은 메신저에 넣어 보낼 수 있다. 그 후 nost 파일을 NostalgiAlbum으로 열게 되면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AppDelegate의&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1710497840858&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any]) -&amp;gt; Bool&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메서드에서 해당 파일의 url을 확인, 파일 검사를 한 후, nost 파일을 다시 압축해제 하여 앨범을 추가할 수 있는 화면을 띄운다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 방식으로 서버 없이 다른 메신저 앱을 이용해 앨범을 공유할 수 있었다. 이제 앨범을 카카오톡으로 서로 주고 받으며 신나게 얘기하면 끝!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;첫 iOS 앱이다 보니 그 때는 안 보였던 여러 개선 부분들이 보이는데(굳이 txt파일을?? 이 때는 json의 존재를 몰랐다) 이러한 부분들을 고쳐나가 보고 싶다.&lt;/i&gt;&lt;/p&gt;</description>
      <category>iOS</category>
      <author>찹모찌</author>
      <guid isPermaLink="true">https://chopmozzi.tistory.com/20</guid>
      <comments>https://chopmozzi.tistory.com/20#entry20comment</comments>
      <pubDate>Fri, 15 Mar 2024 19:23:34 +0900</pubDate>
    </item>
    <item>
      <title>무인기 자율운항 강화학습 패러독스: AI가 이보 전진을 위한 일보 후퇴를 이해할 수 있는가?</title>
      <link>https://chopmozzi.tistory.com/19</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;필자는 광운대학교에서 LIG넥스원과 산학연계 프로젝트로 무인기 자율운항 프로젝트를 진행한 적이 있다.&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;이때, 무인기가 목적지까지 장애물을 피해 도달하도록 하기 위해 강화학습을 진행하던 중, 큰 난관에 봉착한 적이 있다.&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;이는 그 난관을 넘어서는 과정을 담은 이야기다.&lt;/i&gt;&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;문제를 설명하기 앞서, 프로젝트의 기본 틀을 이야기하자면, Unity환경에서 Unity ML-Agents 플러그인의 내장 PPO 알고리즘을 활용하며 진행한다. 프로젝트의 기본 목표는 무인기가 장애물(정적, 동적)에 부딪히지 않고 목표지점까지 도달하는 것을 목표로 한다.&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;보상.PNG&quot; data-origin-width=&quot;695&quot; data-origin-height=&quot;258&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/uHqgB/btsFGnIjYrG/dOsK7UbZoHauTKLvxgH7L1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/uHqgB/btsFGnIjYrG/dOsK7UbZoHauTKLvxgH7L1/img.png&quot; data-alt=&quot;기본적인 보상 함수&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/uHqgB/btsFGnIjYrG/dOsK7UbZoHauTKLvxgH7L1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FuHqgB%2FbtsFGnIjYrG%2FdOsK7UbZoHauTKLvxgH7L1%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;695&quot; height=&quot;258&quot; data-filename=&quot;보상.PNG&quot; data-origin-width=&quot;695&quot; data-origin-height=&quot;258&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;기본적인 보상 함수&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;목표물까지 얼마만큼 움직였나를 이전 목표물과의 거리와 현재 목표물과의 거리의 차로 판단하여 보상을 준다.&lt;/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;기초환경.PNG&quot; data-origin-width=&quot;494&quot; data-origin-height=&quot;334&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/V0Ivo/btsFF2Ld2Y5/eW4tQSfNqfjaRkqm9EhFoK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/V0Ivo/btsFF2Ld2Y5/eW4tQSfNqfjaRkqm9EhFoK/img.png&quot; data-alt=&quot;무인기, 목표물&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/V0Ivo/btsFF2Ld2Y5/eW4tQSfNqfjaRkqm9EhFoK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FV0Ivo%2FbtsFF2Ld2Y5%2FeW4tQSfNqfjaRkqm9EhFoK%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;494&quot; height=&quot;334&quot; data-filename=&quot;기초환경.PNG&quot; data-origin-width=&quot;494&quot; data-origin-height=&quot;334&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;무인기, 목표물&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;첫학습 (1).gif&quot; data-origin-width=&quot;274&quot; data-origin-height=&quot;378&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pBKlR/btsFGVyd9a9/9BLlz6jabSuH6sT5VYoKXk/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pBKlR/btsFGVyd9a9/9BLlz6jabSuH6sT5VYoKXk/img.gif&quot; data-alt=&quot;학습 과정&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pBKlR/btsFGVyd9a9/9BLlz6jabSuH6sT5VYoKXk/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/pBKlR/btsFGVyd9a9/9BLlz6jabSuH6sT5VYoKXk/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;274&quot; height=&quot;378&quot; data-filename=&quot;첫학습 (1).gif&quot; data-origin-width=&quot;274&quot; data-origin-height=&quot;378&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;학습 과정&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;기초학습.gif&quot; data-origin-width=&quot;558&quot; data-origin-height=&quot;334&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nIIFw/btsFIuGHVey/chk4NqZ2E9qMG6SKasWt8K/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nIIFw/btsFIuGHVey/chk4NqZ2E9qMG6SKasWt8K/img.gif&quot; data-alt=&quot;학습을 시킨 결과(목표물 범위를 좀 넓게 잡아놨다 )&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nIIFw/btsFIuGHVey/chk4NqZ2E9qMG6SKasWt8K/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/nIIFw/btsFIuGHVey/chk4NqZ2E9qMG6SKasWt8K/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;558&quot; height=&quot;334&quot; data-filename=&quot;기초학습.gif&quot; data-origin-width=&quot;558&quot; data-origin-height=&quot;334&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;학습을 시킨 결과(목표물 범위를 좀 넓게 잡아놨다 )&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지는 문제가 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 장애물이 생긴다면?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;장애물이 있을때의 보상 함수&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 구상한 보상 함수 알고리즘은 장애물에 부딪히면 마이너스 보상 값을 주어 장애물을 피해서 갈 수 있도록 하는 것이었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;장애물 피하는 보상.PNG&quot; data-origin-width=&quot;677&quot; data-origin-height=&quot;286&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/9hU1p/btsFIkROLEL/xxL9XYZpToL2MJ2Ky88m90/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/9hU1p/btsFIkROLEL/xxL9XYZpToL2MJ2Ky88m90/img.png&quot; data-alt=&quot;장애물 피하는 보상 함수 설계&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/9hU1p/btsFIkROLEL/xxL9XYZpToL2MJ2Ky88m90/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F9hU1p%2FbtsFIkROLEL%2FxxL9XYZpToL2MJ2Ky88m90%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;677&quot; height=&quot;286&quot; data-filename=&quot;장애물 피하는 보상.PNG&quot; data-origin-width=&quot;677&quot; data-origin-height=&quot;286&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;장애물 피하는 보상 함수 설계&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;목표물에 가까이 갈 때 장애물에 부딪히면 마이너스 보상을 주고 에피소드를 종료시킨다. 이걸 반복해서 학습시키면 무인기는 장애물을 피해서 갈 것이다.....&lt;/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;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;가야 하는 경로.PNG&quot; data-origin-width=&quot;468&quot; data-origin-height=&quot;578&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lv1NV/btsFHqxLRBi/HjXZt8rgtuwP5GhFBekLPk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lv1NV/btsFHqxLRBi/HjXZt8rgtuwP5GhFBekLPk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lv1NV/btsFHqxLRBi/HjXZt8rgtuwP5GhFBekLPk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Flv1NV%2FbtsFHqxLRBi%2FHjXZt8rgtuwP5GhFBekLPk%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;468&quot; height=&quot;578&quot; data-filename=&quot;가야 하는 경로.PNG&quot; data-origin-width=&quot;468&quot; data-origin-height=&quot;578&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/td&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;일직선보다 더 먼 경로.PNG&quot; data-origin-width=&quot;447&quot; data-origin-height=&quot;571&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/FeBFo/btsFEXXTJND/OvDBCOhncyn7YgAu6XIc3k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/FeBFo/btsFEXXTJND/OvDBCOhncyn7YgAu6XIc3k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/FeBFo/btsFEXXTJND/OvDBCOhncyn7YgAu6XIc3k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FFeBFo%2FbtsFEXXTJND%2FOvDBCOhncyn7YgAu6XIc3k%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;447&quot; height=&quot;571&quot; data-filename=&quot;일직선보다 더 먼 경로.PNG&quot; data-origin-width=&quot;447&quot; data-origin-height=&quot;571&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그림과 같은 장애물에서 장애물을 왼쪽으로 돌아가야 하기 때문에, 왼쪽으로 이동할 때 필연적으로 보상값이 마이너스가 될 수밖에 없다. 보상값을 많이 받기 위해선 앞으로 가야 하는데, 제일 많은 보상값을 받기 위해서는 지금 보상값이 낮아져야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;말 그대로 &lt;b&gt;이보 전진을 위한 일보 후퇴&lt;/b&gt;를 해야 하는데, 과연 AI가 이를 이해할 수 있을까?&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;가르치기.PNG&quot; data-origin-width=&quot;573&quot; data-origin-height=&quot;397&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bcNAkG/btsFHtOZ5va/93QJtMgKVDoMY7XxbHp0AK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bcNAkG/btsFHtOZ5va/93QJtMgKVDoMY7XxbHp0AK/img.png&quot; data-alt=&quot;어... 그게 뭐냐면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bcNAkG/btsFHtOZ5va/93QJtMgKVDoMY7XxbHp0AK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbcNAkG%2FbtsFHtOZ5va%2F93QJtMgKVDoMY7XxbHp0AK%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;573&quot; height=&quot;397&quot; data-filename=&quot;가르치기.PNG&quot; data-origin-width=&quot;573&quot; data-origin-height=&quot;397&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;아예벽인.gif&quot; data-origin-width=&quot;792&quot; data-origin-height=&quot;924&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bYPtWH/btsFHrjkdT3/N6AYkLyDO8ezeOZeP04aek/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bYPtWH/btsFHrjkdT3/N6AYkLyDO8ezeOZeP04aek/img.gif&quot; data-alt=&quot;무인기의 경로방향과 수직인 장애물을 회피하지 못함.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bYPtWH/btsFHrjkdT3/N6AYkLyDO8ezeOZeP04aek/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/bYPtWH/btsFHrjkdT3/N6AYkLyDO8ezeOZeP04aek/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;596&quot; height=&quot;695&quot; data-filename=&quot;아예벽인.gif&quot; data-origin-width=&quot;792&quot; data-origin-height=&quot;924&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;무인기의 경로방향과 수직인 장애물을 회피하지 못함.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이보 전진을 위한 일보 후퇴를 이해를 못하면 이런 상황이 나온다. 어떻게든 앞으로만 가려고 하지 장애물을 회피한다는 개념이 없다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본적으로 PPO알고리즘을 사용하는지라 Hyper Parameters의 Beta값을 조절해 무인기가 랜덤 한 행동을 할 수 있도록 해 어떻게든 운으로 장애물을 넘길 수 있도록 할 수는 있겠지만... 그것은 근본적인 해결책이 되지 않았다(실제로 어마무시한 학습량과 매우 제한적인 환경이 강요된다).&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;살짝 앞으로 기울어진.gif&quot; data-origin-width=&quot;624&quot; data-origin-height=&quot;728&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lXWzE/btsFFLXdXbY/VgepibwIs7KX1V04zKtTQ1/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lXWzE/btsFFLXdXbY/VgepibwIs7KX1V04zKtTQ1/img.gif&quot; data-alt=&quot;살짝 앞으로 열려있는 벽&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lXWzE/btsFFLXdXbY/VgepibwIs7KX1V04zKtTQ1/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/lXWzE/btsFFLXdXbY/VgepibwIs7KX1V04zKtTQ1/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;624&quot; height=&quot;728&quot; data-filename=&quot;살짝 앞으로 기울어진.gif&quot; data-origin-width=&quot;624&quot; data-origin-height=&quot;728&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;살짝 앞으로 열려있는 벽&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조금이라도 앞으로 갈 수 있다면 그 길을 따라 보상 함수를 더 받을 수 있어 학습이 진행되지만... 그렇지 않은 경우엔 어떻게 해야 할까?&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Overfitting 문제 해결&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;학습 모델을 테스트하던 중 현재 강화학습 환경의 문제점을 발견할 수 있었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;잘안된모습.gif&quot; data-origin-width=&quot;428&quot; data-origin-height=&quot;838&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cr9eKC/btsFIutaFPA/lAZOincH3kEMWL9U24uplK/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cr9eKC/btsFIutaFPA/lAZOincH3kEMWL9U24uplK/img.gif&quot; data-alt=&quot;S자를 그리며 가는 경로&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cr9eKC/btsFIutaFPA/lAZOincH3kEMWL9U24uplK/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/cr9eKC/btsFIutaFPA/lAZOincH3kEMWL9U24uplK/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;401&quot; height=&quot;785&quot; data-filename=&quot;잘안된모습.gif&quot; data-origin-width=&quot;428&quot; data-origin-height=&quot;838&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;S자를 그리며 가는 경로&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;이는 바로 Overfitting문제였는데, 장애물을 충돌로 감지하다 보니 그 장애물이 있는 경로를 그냥 외워버리는 것.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;아차.PNG&quot; data-origin-width=&quot;669&quot; data-origin-height=&quot;488&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nuvfq/btsFJpZqHyy/6rxPPkkd71nr9uTPHrBNCk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nuvfq/btsFJpZqHyy/6rxPPkkd71nr9uTPHrBNCk/img.png&quot; data-alt=&quot;아.....&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nuvfq/btsFJpZqHyy/6rxPPkkd71nr9uTPHrBNCk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fnuvfq%2FbtsFJpZqHyy%2F6rxPPkkd71nr9uTPHrBNCk%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;669&quot; height=&quot;488&quot; data-filename=&quot;아차.PNG&quot; data-origin-width=&quot;669&quot; data-origin-height=&quot;488&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;먼저 이 문제를 해결하기 위해 무인기가 장애물을 인식하게 하는 것이 필요했다. 이 부분은 현실의 Lidar와 비슷한 Unity ML-Agents의 RayPerceptionSensor를 이용해 해결했다. RayPerceptionSensor는 센서가 닿은 곳의 오브젝트와 닿았는지, 닿았다면 거리가 얼마인지 알 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;드론.PNG&quot; data-origin-width=&quot;539&quot; data-origin-height=&quot;391&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/u3bpK/btsFIlpDQGc/MIQPeJU7vJIFLfp7GQOBnk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/u3bpK/btsFIlpDQGc/MIQPeJU7vJIFLfp7GQOBnk/img.png&quot; data-alt=&quot;무인기 in 산악환경&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/u3bpK/btsFIlpDQGc/MIQPeJU7vJIFLfp7GQOBnk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fu3bpK%2FbtsFIlpDQGc%2FMIQPeJU7vJIFLfp7GQOBnk%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;505&quot; height=&quot;366&quot; data-filename=&quot;드론.PNG&quot; data-origin-width=&quot;539&quot; data-origin-height=&quot;391&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;무인기 in 산악환경&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무인기는 장애물을 인지하면 기존 보상값의 절반만 받는다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;그래그거야.PNG&quot; data-origin-width=&quot;584&quot; data-origin-height=&quot;442&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cEr8hi/btsFI4nG9tC/AuCqoUkImALr7v5dqJLv5k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cEr8hi/btsFI4nG9tC/AuCqoUkImALr7v5dqJLv5k/img.png&quot; data-alt=&quot;무인기는 최대한 센서에 장애물 닿지 않게 하고 목표물에 도달하기 위해 노력할 것이다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cEr8hi/btsFI4nG9tC/AuCqoUkImALr7v5dqJLv5k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcEr8hi%2FbtsFI4nG9tC%2FAuCqoUkImALr7v5dqJLv5k%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;584&quot; height=&quot;442&quot; data-filename=&quot;그래그거야.PNG&quot; data-origin-width=&quot;584&quot; data-origin-height=&quot;442&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;무인기가 장애물을 인지했을 때 마이너스 절대 값을 주거나, 아니면 멀어질 때 플러스를 해주기 위해 보상값에 -1을 곱해보았지만 절반으로 줄이는 게 제일 좋았다(보상값에 마이너스를 곱했더니 벽을 타고 문워크를 했다 ).&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;학습중인모습.gif&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;744&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bCgPyk/btsFIifn8rY/PnCynFe8b5EXT09zO6UyW1/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bCgPyk/btsFIifn8rY/PnCynFe8b5EXT09zO6UyW1/img.gif&quot; data-alt=&quot;RayPerceptionSensor로 장애물을 감지하며 학습 중인 모습&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bCgPyk/btsFIifn8rY/PnCynFe8b5EXT09zO6UyW1/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/bCgPyk/btsFIifn8rY/PnCynFe8b5EXT09zO6UyW1/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;441&quot; height=&quot;547&quot; data-filename=&quot;학습중인모습.gif&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;744&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;RayPerceptionSensor로 장애물을 감지하며 학습 중인 모습&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Sensor의 추가로 학습 환경이 조금 개선되었지만. 추가적인 개선이 필요했다. 근본적인 문제인 무인기가 어떻게 장애물을 회피할 것인가? 장애물 인지까지는 성공했으니 이제 피한다는 개념의 학습이 필요했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음으로 돌아와서 Overfitting이 왜 생기는지 고민을 했다. 그리고 여러 고민 끝에 그 답을 찾을 수 있었다.&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;현재 무인기 자율운항 프로젝트는 강화학습으로 진행하지만, 다른 지도학습이나 비지도학습에서도 Overfitting이 일어난다. 지도학습, 비지도학습에서는 그 Overfitting을 방지하기 위해 다양한 &lt;b&gt;데이터 셋&lt;/b&gt;을 활용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 아차 싶었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지 계속 강화학습을 하나의 환경에서만 진행해 왔던 것. 이렇게 되면 Overfitting이 일어날 수밖에 없겠구나.&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;다양한 환경.PNG&quot; data-origin-width=&quot;530&quot; data-origin-height=&quot;392&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/8QgLD/btsFGjsOY1p/Ltp1qklukC2ZW3lrsv8kOK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/8QgLD/btsFGjsOY1p/Ltp1qklukC2ZW3lrsv8kOK/img.png&quot; data-alt=&quot;다양한 환경&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/8QgLD/btsFGjsOY1p/Ltp1qklukC2ZW3lrsv8kOK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F8QgLD%2FbtsFGjsOY1p%2FLtp1qklukC2ZW3lrsv8kOK%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;530&quot; height=&quot;392&quot; data-filename=&quot;다양한 환경.PNG&quot; data-origin-width=&quot;530&quot; data-origin-height=&quot;392&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;다양한 환경&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;장애물이 아예 없는 환경, 조금은 앞으로 갈 수 있는 환경에서 벽의 각도를 조절하는 등 다양한 환경에서 학습을 실행시켰다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;네모네모통과 (1).gif&quot; data-origin-width=&quot;646&quot; data-origin-height=&quot;778&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/58Tno/btsFGDR8aBA/nUcx8ZokVVwlvt3RzaxzV0/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/58Tno/btsFGDR8aBA/nUcx8ZokVVwlvt3RzaxzV0/img.gif&quot; data-alt=&quot;드디어 통과?&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/58Tno/btsFGDR8aBA/nUcx8ZokVVwlvt3RzaxzV0/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/58Tno/btsFGDR8aBA/nUcx8ZokVVwlvt3RzaxzV0/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;500&quot; height=&quot;602&quot; data-filename=&quot;네모네모통과 (1).gif&quot; data-origin-width=&quot;646&quot; data-origin-height=&quot;778&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;드디어 통과?&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 수직인 벽을 통과할 수는 있게 되었지만 개선사항이 남아있었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;변화에 적응을 못함.gif&quot; data-origin-width=&quot;728&quot; data-origin-height=&quot;876&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/TSHpd/btsFJ3axtpn/9Jr7xITjMW7xbPynhQnxo0/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/TSHpd/btsFJ3axtpn/9Jr7xITjMW7xbPynhQnxo0/img.gif&quot; data-alt=&quot;Overfitting&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/TSHpd/btsFJ3axtpn/9Jr7xITjMW7xbPynhQnxo0/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/TSHpd/btsFJ3axtpn/9Jr7xITjMW7xbPynhQnxo0/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;529&quot; height=&quot;637&quot; data-filename=&quot;변화에 적응을 못함.gif&quot; data-origin-width=&quot;728&quot; data-origin-height=&quot;876&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Overfitting&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;벽을 만났을 때 오른쪽 벽이 있는 곳에서만 학습해서 왼쪽으로 회피하는 것이 고정되어 버렸다. 이 문제를 해결하기 위해서 더 다양한 환경을 주었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;더 다양한 환경.PNG&quot; data-origin-width=&quot;712&quot; data-origin-height=&quot;313&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bXtXhR/btsFGjTR8jJ/MQ6Etle2fVaqR0xHT11C2K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bXtXhR/btsFGjTR8jJ/MQ6Etle2fVaqR0xHT11C2K/img.png&quot; data-alt=&quot;더더 다양한 환경&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bXtXhR/btsFGjTR8jJ/MQ6Etle2fVaqR0xHT11C2K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbXtXhR%2FbtsFGjTR8jJ%2FMQ6Etle2fVaqR0xHT11C2K%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;712&quot; height=&quot;313&quot; data-filename=&quot;더 다양한 환경.PNG&quot; data-origin-width=&quot;712&quot; data-origin-height=&quot;313&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;더더 다양한 환경&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;다 잘됨 (1).gif&quot; data-origin-width=&quot;1108&quot; data-origin-height=&quot;634&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dvRBAY/btsFI33mUT3/Iv5mkoJIvLhRfskjfr1nW1/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dvRBAY/btsFI33mUT3/Iv5mkoJIvLhRfskjfr1nW1/img.gif&quot; data-alt=&quot;두개가 동시에&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dvRBAY/btsFI33mUT3/Iv5mkoJIvLhRfskjfr1nW1/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/dvRBAY/btsFI33mUT3/Iv5mkoJIvLhRfskjfr1nW1/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;750&quot; height=&quot;429&quot; data-filename=&quot;다 잘됨 (1).gif&quot; data-origin-width=&quot;1108&quot; data-origin-height=&quot;634&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;두개가 동시에&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 식으로 무인기에게 이보 전진을 위한 일보 후퇴를 이해시킬 수 있을 것인가? 는 조금은 성공이지 않았나 싶다. 무인기 자율운항이지만 정해진 경로가 따로 있는 것이 아니기 때문에 더욱 복잡한 환경에선 힘들겠지만, 장애물을 회피할 수 있도록 학습한다라는 초기의 목적은 달성했다고 볼 수 있겠다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;일보전진을위한이보후퇴.PNG&quot; data-origin-width=&quot;516&quot; data-origin-height=&quot;375&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/H0FN3/btsFI5Vi2Z3/iKLZO9PKjAdbK3iTsbkIIK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/H0FN3/btsFI5Vi2Z3/iKLZO9PKjAdbK3iTsbkIIK/img.png&quot; data-alt=&quot;이보전진을 위한 일보후퇴라는 말을 알고 있진 않겠지만, 어찌됐건 학습 성공!&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/H0FN3/btsFI5Vi2Z3/iKLZO9PKjAdbK3iTsbkIIK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FH0FN3%2FbtsFI5Vi2Z3%2FiKLZO9PKjAdbK3iTsbkIIK%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;516&quot; height=&quot;375&quot; data-filename=&quot;일보전진을위한이보후퇴.PNG&quot; data-origin-width=&quot;516&quot; data-origin-height=&quot;375&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;이보전진을 위한 일보후퇴라는 말을 알고 있진 않겠지만, 어찌됐건 학습 성공!&lt;/figcaption&gt;
&lt;/figure&gt;
&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;프로젝트를 진행하는 동안 어떻게 보면 가장 크게 부딪힌 문제였는데, 다양한 환경 구성과 RayPerceptionSensor의 조화로 문제를 해결할 수 있었다.&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://youtu.be/bww7y7dfSkc&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://youtu.be/bww7y7dfSkc&lt;/a&gt;&lt;/p&gt;
&lt;figure data-ke-type=&quot;video&quot; data-ke-style=&quot;alignCenter&quot; data-video-host=&quot;youtube&quot; data-video-url=&quot;https://www.youtube.com/watch?v=bww7y7dfSkc&quot; data-video-thumbnail=&quot;https://scrap.kakaocdn.net/dn/b5TF3C/hyVxrgKoAS/eJExAXcAfFxx7at8JkhkU0/img.jpg?width=1280&amp;amp;height=720&amp;amp;face=0_0_1280_720&quot; data-video-width=&quot;860&quot; data-video-height=&quot;484&quot; data-video-origin-width=&quot;860&quot; data-video-origin-height=&quot;484&quot; data-ke-mobilestyle=&quot;widthContent&quot; data-original-url=&quot;&quot; data-video-title=&quot;&quot;&gt;&lt;iframe src=&quot;https://www.youtube.com/embed/bww7y7dfSkc&quot; width=&quot;860&quot; height=&quot;484&quot; frameborder=&quot;&quot; allowfullscreen=&quot;true&quot;&gt;&lt;/iframe&gt;
&lt;figcaption&gt;프로젝트 동영상&lt;/figcaption&gt;
&lt;/figure&gt;</description>
      <category>프로젝트 일지</category>
      <author>찹모찌</author>
      <guid isPermaLink="true">https://chopmozzi.tistory.com/19</guid>
      <comments>https://chopmozzi.tistory.com/19#entry19comment</comments>
      <pubDate>Tue, 12 Mar 2024 02:15:10 +0900</pubDate>
    </item>
    <item>
      <title>설명 뷰가 늘어나면서 올라왔으면 좋겠습니다(애니메이션 구현기).</title>
      <link>https://chopmozzi.tistory.com/18</link>
      <description>&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-08 오후 10.06.17.png&quot; data-origin-width=&quot;1260&quot; data-origin-height=&quot;1352&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/H18Ni/btsFGl3QCXe/u20rKFBJlhOFyhwHiiMbQk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/H18Ni/btsFGl3QCXe/u20rKFBJlhOFyhwHiiMbQk/img.png&quot; data-alt=&quot;구현해야 하는 것&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/H18Ni/btsFGl3QCXe/u20rKFBJlhOFyhwHiiMbQk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FH18Ni%2FbtsFGl3QCXe%2Fu20rKFBJlhOFyhwHiiMbQk%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;508&quot; data-filename=&quot;스크린샷 2024-03-08 오후 10.06.17.png&quot; data-origin-width=&quot;1260&quot; data-origin-height=&quot;1352&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;구현해야 하는 것&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재생 화면의 내용이 길 경우 내용을 그라데이션 효과로 숨김 처리를 주고 터치 시 늘어나는 뷰를 구현해야 한다.&lt;/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;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;첫 재생화면 ui (online-video-cutter.com).gif&quot; data-origin-width=&quot;538&quot; data-origin-height=&quot;1166&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bO6lJM/btsFF84v49Z/SfsOWNC6oJcsgTdKhTvFR1/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bO6lJM/btsFF84v49Z/SfsOWNC6oJcsgTdKhTvFR1/img.gif&quot; data-alt=&quot;첫 구현 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bO6lJM/btsFF84v49Z/SfsOWNC6oJcsgTdKhTvFR1/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/bO6lJM/btsFF84v49Z/SfsOWNC6oJcsgTdKhTvFR1/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;350&quot; height=&quot;759&quot; data-filename=&quot;첫 재생화면 ui (online-video-cutter.com).gif&quot; data-origin-width=&quot;538&quot; data-origin-height=&quot;1166&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;첫 구현 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;뭔가 의도대로 되는거 같긴 한데.. 뭔가.. 뭔가.. 아쉽다. 늘어나면서 올라가야하는데, 올라가고 난 뒤, 늘어나고. 줄어들 때도 마찬가지로 줄어든 뒤, 내려간다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경우가 왜 이렇게 일어나냐면, gradient Layer를 통해 세부 내용을 가리고, 올라갈 때 세부 내용을 가리고 있는 gradient Layer를 숨겨야 하는데 이 점이 순차적으로 일어나게 되어 그렇다.&lt;/p&gt;
&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;요구 사항을 보면 세부 내용 Label이 늘어난다. &lt;b&gt;하지만 늘리지 않는다.&lt;/b&gt; 이미 뷰를 늘려놓은 상태의 뷰로 만들어놓고 올라오면서 가려진 부분이 드러나게 한다면 어떨까?&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;설명11.png&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;2340&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/1I950/btsFF15pNCc/ToIQQTu2LQtIyOKOz2OeB0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/1I950/btsFF15pNCc/ToIQQTu2LQtIyOKOz2OeB0/img.png&quot; data-alt=&quot;첫번째 상태&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/1I950/btsFF15pNCc/ToIQQTu2LQtIyOKOz2OeB0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F1I950%2FbtsFF15pNCc%2FToIQQTu2LQtIyOKOz2OeB0%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;286&quot; height=&quot;620&quot; data-filename=&quot;설명11.png&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;2340&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;처음에 제목 + 내용의 크기를 합친 상태의 UIView를 만들어 준 후, 가리고 싶은 만큼 밑으로 내려둔다. 안의 내용 UILabel의 경우 clipsToBound 프로피터를 True로 넣어주어 바깥으로 튀어나간 부분이 보이지 않게 만들어준다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;설명22.png&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;2340&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/0thNX/btsFF8DsATN/YSOW0iyiygpo9mtBJf6mcK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/0thNX/btsFF8DsATN/YSOW0iyiygpo9mtBJf6mcK/img.png&quot; data-alt=&quot;두번째 상태&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/0thNX/btsFF8DsATN/YSOW0iyiygpo9mtBJf6mcK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F0thNX%2FbtsFF8DsATN%2FYSOW0iyiygpo9mtBJf6mcK%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;333&quot; height=&quot;722&quot; data-filename=&quot;설명22.png&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;2340&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;터치 시 원래 비어있던 공간만큼 올라오게 되면서 성공적으로 애니메이션이 수행될 수 있었다. gradient Layer만 애니메이션 효과를 주면 완성&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;재생뷰 동영상 (1).gif&quot; data-origin-width=&quot;692&quot; data-origin-height=&quot;1500&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ZMVZy/btsFF4A2SX6/XY4xedVcmNNqZGMBWMr4x1/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ZMVZy/btsFF4A2SX6/XY4xedVcmNNqZGMBWMr4x1/img.gif&quot; data-alt=&quot;완성!&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ZMVZy/btsFF4A2SX6/XY4xedVcmNNqZGMBWMr4x1/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/ZMVZy/btsFF4A2SX6/XY4xedVcmNNqZGMBWMr4x1/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;361&quot; height=&quot;783&quot; data-filename=&quot;재생뷰 동영상 (1).gif&quot; data-origin-width=&quot;692&quot; data-origin-height=&quot;1500&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;완성!&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;글자에 배경색으로 그라데이션을 주고 있었는데 막상 적용해보고 나니 별로여서 글자에 그라데이션을 준 건 덤.&lt;/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://github.com/boostcampwm2023/iOS09-Layover&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/boostcampwm2023/iOS09-Layover&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1709912543888&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;GitHub - boostcampwm2023/iOS09-Layover: 내 여정의 경유지들을 기록하다 &amp;zwj; &amp;zwj; &amp;zwj;  - 위치기&quot; data-og-description=&quot;내 여정의 경유지들을 기록하다 &amp;zwj; &amp;zwj; &amp;zwj;  - 위치기반 숏폼 플랫폼. Contribute to boostcampwm2023/iOS09-Layover development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/boostcampwm2023/iOS09-Layover&quot; data-og-url=&quot;https://github.com/boostcampwm2023/iOS09-Layover&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/b4VIA4/hyVunlQ0EY/exzwD2v48xGkJ30NAGtDVK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/boostcampwm2023/iOS09-Layover&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/boostcampwm2023/iOS09-Layover&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/b4VIA4/hyVunlQ0EY/exzwD2v48xGkJ30NAGtDVK/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;GitHub - boostcampwm2023/iOS09-Layover: 내 여정의 경유지들을 기록하다 &amp;zwj; &amp;zwj; &amp;zwj;  - 위치기&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;내 여정의 경유지들을 기록하다 &amp;zwj; &amp;zwj; &amp;zwj;  - 위치기반 숏폼 플랫폼. Contribute to boostcampwm2023/iOS09-Layover development by creating an account on GitHub.&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우연찮게 AutoLayout 애니메이션 적용하는 법을 알게 돼서&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://chopmozzi.tistory.com/27&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://chopmozzi.tistory.com/27&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1714487944294&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;AutoLayout 애니메이션 적용하기&quot; data-og-description=&quot;시작하기 앞서 참고한 블로그를 명시합니다.https://ios-development.tistory.com/908&amp;nbsp;[iOS - swift] Animation 테크닉 - 오토레이아웃, 단일 애니메이션, 연속 애니메이션, 스프링, 뒤집기 (au* 예제에서 UI 레이아&quot; data-og-host=&quot;chopmozzi.tistory.com&quot; data-og-source-url=&quot;https://chopmozzi.tistory.com/27&quot; data-og-url=&quot;https://chopmozzi.tistory.com/27&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bAVNiO/hyVVAY7jrW/kJJv3IvbFhuAbleXIl5Jak/img.gif?width=296&amp;amp;height=640&amp;amp;face=0_0_296_640,https://scrap.kakaocdn.net/dn/cWtixx/hyVZubQ36X/sm6e56gGvk8cz5bqpwSCNK/img.gif?width=296&amp;amp;height=640&amp;amp;face=0_0_296_640,https://scrap.kakaocdn.net/dn/ddLCnO/hyVVAdMraX/EQNw6frKNtJqrAgROHSZ9K/img.png?width=1728&amp;amp;height=1050&amp;amp;face=0_0_1728_1050&quot;&gt;&lt;a href=&quot;https://chopmozzi.tistory.com/27&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://chopmozzi.tistory.com/27&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bAVNiO/hyVVAY7jrW/kJJv3IvbFhuAbleXIl5Jak/img.gif?width=296&amp;amp;height=640&amp;amp;face=0_0_296_640,https://scrap.kakaocdn.net/dn/cWtixx/hyVZubQ36X/sm6e56gGvk8cz5bqpwSCNK/img.gif?width=296&amp;amp;height=640&amp;amp;face=0_0_296_640,https://scrap.kakaocdn.net/dn/ddLCnO/hyVVAdMraX/EQNw6frKNtJqrAgROHSZ9K/img.png?width=1728&amp;amp;height=1050&amp;amp;face=0_0_1728_1050');&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;AutoLayout 애니메이션 적용하기&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;시작하기 앞서 참고한 블로그를 명시합니다.https://ios-development.tistory.com/908&amp;nbsp;[iOS - swift] Animation 테크닉 - 오토레이아웃, 단일 애니메이션, 연속 애니메이션, 스프링, 뒤집기 (au* 예제에서 UI 레이아&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;chopmozzi.tistory.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;</description>
      <category>iOS</category>
      <author>찹모찌</author>
      <guid isPermaLink="true">https://chopmozzi.tistory.com/18</guid>
      <comments>https://chopmozzi.tistory.com/18#entry18comment</comments>
      <pubDate>Sat, 9 Mar 2024 00:42:33 +0900</pubDate>
    </item>
    <item>
      <title>숏폼 플랫폼 재생 화면 정보를 불러올 때는 어떻게 불러오는게 좋을까?</title>
      <link>https://chopmozzi.tistory.com/17</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;위치기반 숏폼 플랫폼 Layover에서 재생 화면 기능을 맡아 개발한 경험이 있다.&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;재생화면.png&quot; data-origin-width=&quot;1290&quot; data-origin-height=&quot;2796&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/2GmW2/btsFFXPBHGE/KgXKuEPKUDbVAeROKYKI50/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/2GmW2/btsFFXPBHGE/KgXKuEPKUDbVAeROKYKI50/img.png&quot; data-alt=&quot;재생 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/2GmW2/btsFFXPBHGE/KgXKuEPKUDbVAeROKYKI50/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F2GmW2%2FbtsFFXPBHGE%2FKgXKuEPKUDbVAeROKYKI50%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;247&quot; height=&quot;535&quot; data-filename=&quot;재생화면.png&quot; data-origin-width=&quot;1290&quot; data-origin-height=&quot;2796&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;재생 화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위와 같은 재생 화면에서 제목, 내용, 태그, 유저 이름 까지는 따로 비동기적인 동작이 필요하지 않았다. 왜냐하면 영상 데이터를 불러올 때 같이 불러오기 때문이다.&lt;/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-08 오후 9.26.20.png&quot; data-origin-width=&quot;1886&quot; data-origin-height=&quot;604&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/WIx8S/btsFCeZElv2/IUH5Cm7PTWsCWgXuxgQFak/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/WIx8S/btsFCeZElv2/IUH5Cm7PTWsCWgXuxgQFak/img.png&quot; data-alt=&quot;최초 로직&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/WIx8S/btsFCeZElv2/IUH5Cm7PTWsCWgXuxgQFak/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FWIx8S%2FbtsFCeZElv2%2FIUH5Cm7PTWsCWgXuxgQFak%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;1886&quot; height=&quot;604&quot; data-filename=&quot;스크린샷 2024-03-08 오후 9.26.20.png&quot; data-origin-width=&quot;1886&quot; data-origin-height=&quot;604&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;CollectionView의 Cell을 구성할 때, 제목, 내용, 태그, 유저 이름과 같은 정보들을 넣어주고 같이 유저 프로필과 위치 정보를 넣어주었는데, 유저 프로필과 위치 정보를 기다려야 하는 탓에 재생 화면 스크롤을 내릴 때 끊김이 발생했다.&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-08 오후 9.33.17.png&quot; data-origin-width=&quot;1836&quot; data-origin-height=&quot;588&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cQWCGp/btsFFjMnT5f/iRtQNYRzXdqUAS8V6uxjGk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cQWCGp/btsFFjMnT5f/iRtQNYRzXdqUAS8V6uxjGk/img.png&quot; data-alt=&quot;다음 해결 로직&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cQWCGp/btsFFjMnT5f/iRtQNYRzXdqUAS8V6uxjGk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcQWCGp%2FbtsFFjMnT5f%2FiRtQNYRzXdqUAS8V6uxjGk%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;1836&quot; height=&quot;588&quot; data-filename=&quot;스크린샷 2024-03-08 오후 9.33.17.png&quot; data-origin-width=&quot;1836&quot; data-origin-height=&quot;588&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;다음 해결 로직&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면, 영상을 먼저 재생하고 영상 정보를 불러오기 때문에, 아무런 정보 없이 영상만 틀어져 있는 경우가 발생했다.&lt;/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;재생화면동영상 (online-video-cutter.com).gif&quot; data-origin-width=&quot;214&quot; data-origin-height=&quot;464&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cAvM5U/btsFD6mhEDD/wxYpTRpMqjSLlWTOPuI1aK/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cAvM5U/btsFD6mhEDD/wxYpTRpMqjSLlWTOPuI1aK/img.gif&quot; data-alt=&quot;수정된 재생 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cAvM5U/btsFD6mhEDD/wxYpTRpMqjSLlWTOPuI1aK/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/cAvM5U/btsFD6mhEDD/wxYpTRpMqjSLlWTOPuI1aK/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;214&quot; height=&quot;464&quot; data-filename=&quot;재생화면동영상 (online-video-cutter.com).gif&quot; data-origin-width=&quot;214&quot; data-origin-height=&quot;464&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;수정된 재생 화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;영상을 보면 처음 들어갈 때 프로필 이미지가 로드되지 않았지만 자연스럽게 로드하고 있는 것을 확인할 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-03-08 오후 9.44.36.png&quot; data-origin-width=&quot;1948&quot; data-origin-height=&quot;532&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mFbA5/btsFFKbJPfj/sBmmXwepyBuRJwAnMBHSY0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mFbA5/btsFFKbJPfj/sBmmXwepyBuRJwAnMBHSY0/img.png&quot; data-alt=&quot;최종 로직&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mFbA5/btsFFKbJPfj/sBmmXwepyBuRJwAnMBHSY0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmFbA5%2FbtsFFKbJPfj%2FsBmmXwepyBuRJwAnMBHSY0%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;1948&quot; height=&quot;532&quot; data-filename=&quot;스크린샷 2024-03-08 오후 9.44.36.png&quot; data-origin-width=&quot;1948&quot; data-origin-height=&quot;532&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;최종 로직&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드로 보면 다음과 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1709902192019&quot; class=&quot;roboconf&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;swift&quot;&gt;&lt;code&gt;func configureDataSource(viewModel: PlaybackModels.ConfigurePlaybackCell.ViewModel) {
        playbackCollectionView.register(PlaybackCell.self, forCellWithReuseIdentifier: PlaybackCell.identifier)
        dataSource = UICollectionViewDiffableDataSource&amp;lt;Section, Models.PlaybackVideo&amp;gt;(collectionView: playbackCollectionView) { (collectionView, indexPath, playbackVideo) -&amp;gt; UICollectionViewCell? in
            guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PlaybackCell.identifier, for: indexPath) as? PlaybackCell else { return PlaybackCell() }
            // 일단 정보들을 넣어두고!
            cell.setPlaybackContents(post: playbackVideo.displayedPost)
            if let teleportIndex = viewModel.teleportIndex {
                if indexPath.row == 0 || indexPath.row == teleportIndex {
                    cell.playbackView.resetPlayer()
                    return cell
                }
            }
            // 재생하고!
            cell.addAVPlayer(url: playbackVideo.displayedPost.board.videoURL)
            // 그 다음에 로드를 하자구!
            Task {
                await self.interactor?.loadProfileImageAndLocation(with: Models.LoadProfileImageAndLocation.Request(
                    curCell: cell,
                    profileImageURL: playbackVideo.displayedPost.member.profileImageURL,
                    latitude: playbackVideo.displayedPost.board.latitude,
                    longitude: playbackVideo.displayedPost.board.longitude))
            }
            cell.delegate = self
            return cell
        }
    }&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;물론 이도 완벽하지는 않아 Prefetch기능을 추가했다. 다음 글에서 확인할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://chopmozzi.tistory.com/16&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://chopmozzi.tistory.com/16&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1709902218119&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;Swift Concurrency를 이용한 UICollectionView Prefetch&quot; data-og-description=&quot;Layover 프로젝트를 진행하던 도중 재생화면의 프로필 이미지와 위치 정보가 뒤늦게 나타나는 경우가 있어 이를 개선하고자 Prefetch를 적용시켜보았다. UIKit에서 CollectionView나 TableView는 PrefetchingData&quot; data-og-host=&quot;chopmozzi.tistory.com&quot; data-og-source-url=&quot;https://chopmozzi.tistory.com/16&quot; data-og-url=&quot;https://chopmozzi.tistory.com/16&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cqrt5W/hyVukbBs9j/XE3qZAY5enTo2J6d985gxk/img.png?width=554&amp;amp;height=448&amp;amp;face=0_0_554_448,https://scrap.kakaocdn.net/dn/IEWdM/hyVuluLUaY/fzGF1cgHMg8klw1g8iebXK/img.png?width=554&amp;amp;height=448&amp;amp;face=0_0_554_448,https://scrap.kakaocdn.net/dn/4Mre4/hyVut0FIw0/QOm5kY1Dh3Xyhqn3fdzLhK/img.png?width=617&amp;amp;height=491&amp;amp;face=0_0_617_491&quot;&gt;&lt;a href=&quot;https://chopmozzi.tistory.com/16&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://chopmozzi.tistory.com/16&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cqrt5W/hyVukbBs9j/XE3qZAY5enTo2J6d985gxk/img.png?width=554&amp;amp;height=448&amp;amp;face=0_0_554_448,https://scrap.kakaocdn.net/dn/IEWdM/hyVuluLUaY/fzGF1cgHMg8klw1g8iebXK/img.png?width=554&amp;amp;height=448&amp;amp;face=0_0_554_448,https://scrap.kakaocdn.net/dn/4Mre4/hyVut0FIw0/QOm5kY1Dh3Xyhqn3fdzLhK/img.png?width=617&amp;amp;height=491&amp;amp;face=0_0_617_491');&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;Swift Concurrency를 이용한 UICollectionView Prefetch&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Layover 프로젝트를 진행하던 도중 재생화면의 프로필 이미지와 위치 정보가 뒤늦게 나타나는 경우가 있어 이를 개선하고자 Prefetch를 적용시켜보았다. UIKit에서 CollectionView나 TableView는 PrefetchingData&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;chopmozzi.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Layover 프로젝트에 관심이 있다면 여기로.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/boostcampwm2023/iOS09-Layover&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/boostcampwm2023/iOS09-Layover&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1709902245989&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;GitHub - boostcampwm2023/iOS09-Layover: 내 여정의 경유지들을 기록하다 &amp;zwj; &amp;zwj; &amp;zwj;  - 위치기&quot; data-og-description=&quot;내 여정의 경유지들을 기록하다 &amp;zwj; &amp;zwj; &amp;zwj;  - 위치기반 숏폼 플랫폼. Contribute to boostcampwm2023/iOS09-Layover development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/boostcampwm2023/iOS09-Layover&quot; data-og-url=&quot;https://github.com/boostcampwm2023/iOS09-Layover&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/b4VIA4/hyVunlQ0EY/exzwD2v48xGkJ30NAGtDVK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/boostcampwm2023/iOS09-Layover&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/boostcampwm2023/iOS09-Layover&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/b4VIA4/hyVunlQ0EY/exzwD2v48xGkJ30NAGtDVK/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;GitHub - boostcampwm2023/iOS09-Layover: 내 여정의 경유지들을 기록하다 &amp;zwj; &amp;zwj; &amp;zwj;  - 위치기&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;내 여정의 경유지들을 기록하다 &amp;zwj; &amp;zwj; &amp;zwj;  - 위치기반 숏폼 플랫폼. Contribute to boostcampwm2023/iOS09-Layover development by creating an account on GitHub.&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;</description>
      <category>iOS</category>
      <author>찹모찌</author>
      <guid isPermaLink="true">https://chopmozzi.tistory.com/17</guid>
      <comments>https://chopmozzi.tistory.com/17#entry17comment</comments>
      <pubDate>Fri, 8 Mar 2024 21:51:11 +0900</pubDate>
    </item>
    <item>
      <title>Swift Concurrency와 UICollectionView Prefetch</title>
      <link>https://chopmozzi.tistory.com/16</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Layover 프로젝트를 진행하던 도중 재생 화면의 프로필 이미지와 위치 정보가 뒤늦게 나타나는 경우가 있어 이를 개선하고자 Prefetch를 적용시켜보았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UIKit에서 CollectionView나 TableView는  DataSourcePrefetching을 이용해 Prefetching을 지원한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://developer.apple.com/documentation/uikit/uicollectionviewdatasourceprefetching/prefetching_collection_view_data&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://developer.apple.com/documentation/uikit/uicollectionviewdatasourceprefetching/prefetching_collection_view_data&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1709307244164&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;Prefetching collection view data | Apple Developer Documentation&quot; data-og-description=&quot;Load data for collection view cells before they display.&quot; data-og-host=&quot;developer.apple.com&quot; data-og-source-url=&quot;https://developer.apple.com/documentation/uikit/uicollectionviewdatasourceprefetching/prefetching_collection_view_data&quot; data-og-url=&quot;https://docs.developer.apple.com/documentation/uikit/uicollectionviewdatasourceprefetching/prefetching_collection_view_data&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/KwhsZ/hyVutYZGRc/WKEotZNjhfEzEk6okpEto1/img.jpg?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/DN0Lq/hyVuowCfeJ/KKiVUwJKE3m1EtPzox9mJk/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/uikit/uicollectionviewdatasourceprefetching/prefetching_collection_view_data&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://developer.apple.com/documentation/uikit/uicollectionviewdatasourceprefetching/prefetching_collection_view_data&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/KwhsZ/hyVutYZGRc/WKEotZNjhfEzEk6okpEto1/img.jpg?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/DN0Lq/hyVuowCfeJ/KKiVUwJKE3m1EtPzox9mJk/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;Prefetching collection view data | Apple Developer Documentation&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Load data for collection view cells before they display.&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 id=&quot;code_1709307282695&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;collectionView.dataSource = dataSource

collectionView.prefetchDataSource = dataSource

// 이와 같은 방식도 가능
collectionView.prefetchDataSource = self

// UICollectionViewDataSourcePrefetching을 채택시켜 해당 메소드를 사용 가능하다.
// Prefetching이 필요한 시점에서 동작
extension ViewController: UICollectionViewDataSourcePrefetching {
    func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) {
				for indexPath in indexPaths {
            let model = models[indexPath.row]
            asyncFetcher.fetchAsync(model.identifier)
        }
    }

    func collectionView(_ collectionView: UICollectionView, cancelPrefetchingForItemsAt indexPaths: [IndexPath]) {
				for indexPath in indexPaths {
            let model = models[indexPath.row]
            asyncFetcher.cancelFetch(model.identifier)
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위의 Prefetching CollectionView에서는 UICollectionViewDataSourcePrefetching을 채택시켜 prefetchItem의 indexPaths를 받아 prefetch한다.&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-01-20 오후 7.27.19.png&quot; data-origin-width=&quot;554&quot; data-origin-height=&quot;448&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/breEzi/btsFsmPdoQ0/Mnvyxqqa3GrUeKSFWI7UPK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/breEzi/btsFsmPdoQ0/Mnvyxqqa3GrUeKSFWI7UPK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/breEzi/btsFsmPdoQ0/Mnvyxqqa3GrUeKSFWI7UPK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbreEzi%2FbtsFsmPdoQ0%2FMnvyxqqa3GrUeKSFWI7UPK%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;554&quot; height=&quot;448&quot; data-filename=&quot;스크린샷 2024-01-20 오후 7.27.19.png&quot; data-origin-width=&quot;554&quot; data-origin-height=&quot;448&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Main Thread에서 UICollectionViewDataSourcePrefetching와 같은 방식을 통해 prefetch가 Trigger되면 비동기적인 방식으로 Prefetch Thread에서 prefetch가 동작한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Main Thread에서 prefetch를 요청하고 Main Thread가 아닌 다른 Thread에서 prefetch를 진행해 해당 data를 Main Thread에서 쓴다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-01-20 오후 7.44.21.png&quot; data-origin-width=&quot;505&quot; data-origin-height=&quot;499&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bvdrE6/btsFsbfZqSA/nzeOiLAj99BWpoAsFk93bk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bvdrE6/btsFsbfZqSA/nzeOiLAj99BWpoAsFk93bk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bvdrE6/btsFsbfZqSA/nzeOiLAj99BWpoAsFk93bk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbvdrE6%2FbtsFsbfZqSA%2FnzeOiLAj99BWpoAsFk93bk%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;505&quot; height=&quot;499&quot; data-filename=&quot;스크린샷 2024-01-20 오후 7.44.21.png&quot; data-origin-width=&quot;505&quot; data-origin-height=&quot;499&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;필자는 여기서 궁금증이 하나 생겼는데, 그러면 Cache에는 아직 저장되지 않고, prefetching중인데 해당 데이터를 필요로 한다면?&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-02-01 오후 7.49.50.png&quot; data-origin-width=&quot;502&quot; data-origin-height=&quot;479&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qjNhN/btsFs0SPhHA/eXno8MgI4KaFrAuAcakAl1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qjNhN/btsFs0SPhHA/eXno8MgI4KaFrAuAcakAl1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qjNhN/btsFs0SPhHA/eXno8MgI4KaFrAuAcakAl1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqjNhN%2FbtsFs0SPhHA%2FeXno8MgI4KaFrAuAcakAl1%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;502&quot; height=&quot;479&quot; data-filename=&quot;스크린샷 2024-02-01 오후 7.49.50.png&quot; data-origin-width=&quot;502&quot; data-origin-height=&quot;479&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Prefetch를 통해 데이터를 로드해 Cache에 저장하는데, 아직 로드 중일 때, Cache에 저장되지 않은 데이터를 사용해야할 순간이 오면 어떻게 될까? CollectionView의 경우 scroll을 내리다 보면 충분히 발생가능한 일이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Prefetch가 되지 않았으니 데이터 로드를 한번 더 한다. &amp;larr;이는 너무 비효율적인 동작이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예제에서는 OperationQueue와 Completion Handler를 통해 이를 관리해주고 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 Layover에서는 Swift Concurrency를 사용하고 있었기 때문에 이에 맞춰 변경을 해줄 필요가 있었다.&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-02-01 오후 9.02.24.png&quot; data-origin-width=&quot;617&quot; data-origin-height=&quot;491&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/IFIM6/btsFlPeFyIM/s1WuRPXz59ftlbNBamKFsK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/IFIM6/btsFlPeFyIM/s1WuRPXz59ftlbNBamKFsK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/IFIM6/btsFlPeFyIM/s1WuRPXz59ftlbNBamKFsK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FIFIM6%2FbtsFlPeFyIM%2Fs1WuRPXz59ftlbNBamKFsK%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;617&quot; height=&quot;491&quot; data-filename=&quot;스크린샷 2024-02-01 오후 9.02.24.png&quot; data-origin-width=&quot;617&quot; data-origin-height=&quot;491&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Task Dictionary안에 Prefetch Task들을 넣어 놓고 관리한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;prefetching중인 데이터를 필요로 할 경우 id를 통해 Task Dictionary에 접근해서 해당 데이터 로드를 이어서 기다린다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주요 코드는 다음과 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1709307505343&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;private let imageCache: NSCache&amp;lt;NSURL, NSData&amp;gt; = {
        let cache = NSCache&amp;lt;NSURL, NSData&amp;gt;()
        // 10MB
        cache.totalCostLimit = 10 * 1024 * 1024
        return cache
    }()

private var fetchingImageTasks: [URL: Task&amp;lt;Data, Error&amp;gt;] = [:]
private let provider: ProviderType = Provider()

// PrefetchImage method URL을 key값으로 받아 사용한다.
func prefetchImage(for key: URL) async -&amp;gt; Data? {
	// imageCache에 해당 key가 있으면 cache에서 먼저 데이터를 가져온다.
    if let cachedData = imageCache.object(forKey: key as NSURL) as? Data {
        return cachedData
    }
	// cache에 데이터가 없으면 prefetching중인지 확인하고 있다면 해당 데이터를 기다린다.
    if let prefetchingData = fetchingImageTasks[key] {
        do {
            return try await prefetchingData.value
        } catch {
	          os_log(.error, log: .data, &quot;%@&quot;, error.localizedDescription)
            return nil
        }
    }
      
	// 없을 경우 Task Dictionary에 key값과 Task동작을 넣어준다.
    let fetchTask: Task&amp;lt;Data, Error&amp;gt; = download(key)
    fetchingImageTasks[key] = fetchTask

	// data load 시작!
    do {
        let fetchedData: Data = try await fetchTask.value
        imageCache.setObject(fetchedData as NSData, forKey: key as NSURL)
        fetchingImageTasks[key] = nil
        return fetchedData
    } catch {
        os_log(.error, log: .data, &quot;%@&quot;, error.localizedDescription)
        return nil
    }
}

// provider는 Layover의 Network를 담당.
// URLSession의 data method를 사용.
private func download(_ url: URL) -&amp;gt; Task&amp;lt;Data, Error&amp;gt; {
    return Task {
        try await provider.request(url: url)
	// 만약 사용한다면 이런식으로
	// let (data, _) = try await URLSession.shared.data(from: url)
        // return data
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 코드는 다음과 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1709307524774&quot; class=&quot;swift&quot; data-ke-language=&quot;swift&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//
//  Prefetcher.swift
//  Layover
//
//  Created by 황지웅 on 1/18/24.
//  Copyright &amp;copy; 2024 CodeBomber. All rights reserved.
//

import Foundation
import CoreLocation
import OSLog

protocol PrefetchProtocol {
    func prefetchImage(for key: URL) async -&amp;gt; Data?
    func prefetchLocation(latitude: Double, longitude: Double) async -&amp;gt; String?
    func cancelPrefetchImage(for key: URL) async
    func cancelPrefetchLocation(latitude: Double, longitude: Double) async
}

actor Prefetcher: PrefetchProtocol {
    private let imageCache: NSCache&amp;lt;NSURL, NSData&amp;gt; = {
        let cache = NSCache&amp;lt;NSURL, NSData&amp;gt;()
        // 10MB
        cache.totalCostLimit = 10 * 1024 * 1024
        return cache
    }()

    private let locationCache: NSCache&amp;lt;NSString, NSString&amp;gt; = {
        let cache = NSCache&amp;lt;NSString, NSString&amp;gt;()
        // 5MB
        cache.totalCostLimit = 5 * 1024 * 1024
        return cache
    }()

    private var fetchingImageTasks: [URL: Task&amp;lt;Data, Error&amp;gt;] = [:]
    private var fetchingLocationTasks: [String: Task&amp;lt;String?, Error&amp;gt;] = [:]

    private let provider: ProviderType = Provider()

    func prefetchImage(for key: URL) async -&amp;gt; Data? {
        if let cachedData = imageCache.object(forKey: key as NSURL) as? Data {
            return cachedData
        }

        if let prefetchingData = fetchingImageTasks[key] {
            do {
                return try await prefetchingData.value
            } catch {
                os_log(.error, log: .data, &quot;%@&quot;, error.localizedDescription)
                return nil
            }
        }
        
        let fetchTask: Task&amp;lt;Data, Error&amp;gt; = download(key)
        fetchingImageTasks[key] = fetchTask

        do {
            let fetchedData: Data = try await fetchTask.value
            imageCache.setObject(fetchedData as NSData, forKey: key as NSURL)
            fetchingImageTasks[key] = nil
            return fetchedData
        } catch {
            os_log(.error, log: .data, &quot;%@&quot;, error.localizedDescription)
            return nil
        }
    }

    func prefetchLocation(latitude: Double, longitude: Double) async -&amp;gt; String? {
        let cacheKey = &quot;\(latitude)-\(longitude)&quot;

        if let cachedData = locationCache.object(forKey: cacheKey as NSString) as? String {
            return cachedData
        }

        if let prefetchingData = fetchingLocationTasks[cacheKey] {
            do {
                return try await prefetchingData.value
            } catch {
                os_log(.error, log: .data, &quot;%@&quot;, error.localizedDescription)
                return nil
            }
        }

        let fetchTask: Task&amp;lt;String?, Error&amp;gt; = loadLocationInfo(latitude: latitude, longitude: longitude)
        fetchingLocationTasks[cacheKey] = fetchTask

        do {
            guard let fetchedData: String = try await fetchTask.value else { return nil }
            locationCache.setObject(fetchedData as NSString, forKey: cacheKey as NSString)
            return fetchedData
        } catch {
            os_log(.error, log: .data, &quot;%@&quot;, error.localizedDescription)
            return nil
        }
    }

    func cancelPrefetchImage(for key: URL) async {
        fetchingImageTasks[key]?.cancel()
        fetchingImageTasks[key] = nil
    }

    func cancelPrefetchLocation(latitude: Double, longitude: Double) async {
        let key = &quot;\(latitude)-\(longitude)&quot;
        fetchingLocationTasks[key]?.cancel()
        fetchingLocationTasks[key] = nil
    }

    private func download(_ url: URL) -&amp;gt; Task&amp;lt;Data, Error&amp;gt; {
        return Task {
            try await provider.request(url: url)
        }
    }

    private func loadLocationInfo(latitude: Double, longitude: Double) -&amp;gt; Task&amp;lt;String?, Error&amp;gt; {
         return Task {
            let findLocation: CLLocation = CLLocation(latitude: latitude, longitude: longitude)
            let geoCoder: CLGeocoder = CLGeocoder()
            let localeIdentifier = Locale.preferredLanguages.first != nil ? Locale.preferredLanguages[0] : Locale.current.identifier
            let locale = Locale(identifier: localeIdentifier)
            let place = try await geoCoder.reverseGeocodeLocation(findLocation, preferredLocale: locale)
            return place.last?.administrativeArea
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Layover에서는 재생 화면에서 프로필 이미지와 위치를 로드하기 때문에 두 경우를 Prefetch해서 사용했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 점은 Actor를 사용한 것인데 class로 사용할 수도 있었겠지만 그럴 경우 data race가 발생할 수 있는 위험이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;prefetchImage나 prefetchLocation의 경우 각 tasks dictionary에 접근을 하고 있는데 스크롤 속도가 빠른 숏폼 플랫폼의 특성상 동시에 접근할 수 있는 일이 생길 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 Actor를 사용하여 data race를 방지할 수 있는 코드로 작성하는 것이 중요하다.&lt;/p&gt;</description>
      <category>iOS</category>
      <author>찹모찌</author>
      <guid isPermaLink="true">https://chopmozzi.tistory.com/16</guid>
      <comments>https://chopmozzi.tistory.com/16#entry16comment</comments>
      <pubDate>Sat, 2 Mar 2024 00:39:03 +0900</pubDate>
    </item>
  </channel>
</rss>