<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>s_sub.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Sat, 21 Sep 2024 06:02:24 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <copyright>Copyright (C) 2019. s_sub.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/s_sub" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[API Response with RxSwift]]></title>
            <link>https://velog.io/@s_sub/Reactive-Programming-with-RxSwift</link>
            <guid>https://velog.io/@s_sub/Reactive-Programming-with-RxSwift</guid>
            <pubDate>Sat, 21 Sep 2024 06:02:24 GMT</pubDate>
            <description><![CDATA[<h2 id="api-통신">API 통신</h2>
<ul>
<li>RxSwift, CleanArchitecture, Reactor, Moya</li>
</ul>
<h3 id="궁금증을-가진-코드">궁금증을 가진 코드</h3>
<ul>
<li>기능 : 닉네임 입력 후 확인 버튼 클릭 시, 중복된 닉네임인지 확인하는 API 통신</li>
</ul>
<br>

<ul>
<li><p>SetNicknameReactor</p>
<pre><code class="language-swift">// SetNicknameReactor.swift

enum Action {
    /* ... */
    case confirmButtonTap(text: String)
}

enum Mutation {
    /* ... */
    case isValid(status: SetNicknameUseCase.TextFieldStatus)
}

func mutate(action: Action) -&gt; Observable&lt;Mutation&gt; {
    switch action {
        /* ... */
        case .confirmButtonTap(let text):
            let dto = ...
            return setNickNameUseCase.isValidText(dto: dto)
                .map { Mutation.isValid(status: $0) }
}</code></pre>
</li>
</ul>
<br>

<ul>
<li><p>SetNickNameUseCase</p>
<pre><code class="language-swift">// SetNickNameUseCase.swift

enum TextFieldStatus {
    case duplicationNickname // 중복된 닉네임
    case unknownError // 알 수 없는 에러
    case validNickname // 사용 가능한 닉네임
    case readyToRequest // API 요청 전
}

func isValidText(dto: PostIsValidNickNameRequestDTO) -&gt; Observable&lt;TextFieldStatus&gt; {
    let request = setNickNameRepository
                    .postIsValidNickName(dto: dto)
                    .share()

    let success = request
                    .compactMap { $0.element }
                    .map { $0 ? TextFieldStatus.validNickname : TextFieldStatus.duplicationNickname } 

    let fail = request
                .compactMap { $0.error }
                .map { _ in TextFieldStatus.unknownError }

    return Observable.merge(success, fail)
}</code></pre>
</li>
</ul>
<br>

<ul>
<li>SetNickNameRepository<pre><code class="language-swift">// SetNickNameRepository.swift
func postIsValidNickName(dto: PostIsValidNickNameRequestDTO) -&gt; Observable&lt;Event&lt;Bool&gt;&gt; {
    return provider.log.rx.request(.postIsValidNickName(dto: dto))
        .map(ResponseDTO&lt;PostRefreshTokenResponseDTO&gt;.self)
        .map{ $0.isSuccess }
        .asObservable()
        .materialize()
}</code></pre>
</li>
</ul>
<br>

<ul>
<li>위 코드에서, 응답값이 어떤 방식으로 저장되는지 궁금해서 질문을 드렸다.<ul>
<li>응답값의 종류는 다음과 같다.<ul>
<li>성공</li>
<li>실패 (닉네임 중복 or 기타 에러)</li>
</ul>
</li>
</ul>
</li>
</ul>
<ul>
<li>놓치고 있었던 부분은, Observable은 <strong>값을 저장</strong>하는 역할이 아닌, <strong>값이 흐를 수 있는 파이프라인, 즉 stream</strong>을 의미한다.</li>
</ul>
<br>

<h3 id="코드-해석">코드 해석</h3>
<ul>
<li><p>SetNickNameRepository</p>
<pre><code class="language-swift">// SetNickNameRepository.swift
func postIsValidNickName(dto: PostIsValidNickNameRequestDTO) -&gt; Observable&lt;Event&lt;Bool&gt;&gt; {
    return provider.log.rx.request(.postIsValidNickName(dto: dto))
        .map(ResponseDTO&lt;PostRefreshTokenResponseDTO&gt;.self)    // 응답 DTO로 parsing
        .map{ $0.isSuccess }    // 파싱한 데이터의 isSuccess 필드 확인
        .asObservable()        // Single을 Observable&lt;Bool&gt;로 변환
        .materialize()        // Observable&lt;Event&lt;Bool&gt;&gt;로 변환
}</code></pre>
<ul>
<li><p>서버 응답값은 다음과 같고, statusCode 200으로 통신이 성공하게 되면 <code>isSuccess</code> 값에 true을 보내준다.</p>
<pre><code class="language-swift">struct ResponseDTO&lt;T: Codable&gt;: Codable {
    let isSuccess: Bool
    let code: String
    let message: String
    let data: T?
}</code></pre>
</li>
<li><p>닉네임 중복 API의 경우, 응답값이 따로 없기 때문에 <code>Observable&lt;Bool&gt;</code> 타입으로 받게 된다. 성공/실패에 따라 Bool에 흘러가는 값이 달라지게 된다.</p>
</li>
<li><p><code>materialize</code> : Event 형태로 바꿔준다.</p>
<ul>
<li>나는 개인적으로 Single을 이용해서 응답값을 주로 래핑했는데, 팀원분은 materalize를 사용했다.</li>
<li>error, completed를 사용하면 스트림이 끊기는 이슈가 발생할 수 있기 때문에 래핑은 필요하다.</li>
<li>Single을 활용하는 것과 materialize를 활용하는 것의 장단점은 따로 공부할 예정이다.</li>
</ul>
</li>
</ul>
</li>
</ul>
<br>

<ul>
<li><p>SetNickNameUseCase</p>
<pre><code class="language-swift">func isValidText(dto: PostIsValidNickNameRequestDTO) -&gt; Observable&lt;TextFieldStatus&gt; {
  let request = setNickNameRepository
                  .postIsValidNickName(dto: dto)
                  .share()    // 응답 결과는 아직 모른다. Observable&lt;Event&lt;Bool&gt;&gt; 타입

  let success = request
                  .compactMap { $0.element }
                  .map { $0 ? TextFieldStatus.validNickname : TextFieldStatus.duplicationNickname }

  let fail = request
              .compactMap { $0.error }
              .map { _ in TextFieldStatus.unknownError }

  return Observable.merge(success, fail)
}</code></pre>
<br>

<ul>
<li><code>share()</code> 사용<ul>
<li>Observable은 subscribe할 때마다 create을 통해 새로운 Observable을 생성한다.
즉, subscribe를 하는 횟수마다 새로운 Observable이 생성된다.</li>
<li>이를 방지하고자 <strong>share</strong>를 사용하면, subscribe할 때마다 새로운 Observable이 생성되지 않고, 하나의 Observable 시퀀스에서 방출된 아이템을 공유해서 사용할 수 있다.</li>
<li>여기서는 success와 fail이 하나의 request에 대한 값만 받을 수 있도록 <code>share()</code>를 활용한다.</li>
</ul>
</li>
</ul>
</li>
</ul>
  <br>


<ul>
<li><p><code>compactMap { }</code> 사용</p>
<ul>
<li><p>compactMap은 해당 값이 존재하면 해당 값을 방출하고, 값이 nil이면 해당 이벤트를 무시한다.</p>
</li>
<li><p>즉, <code>compactMap { $0.element }</code> 는 element가 있는 이벤트만을 방출하고, <code>compactMap { $0.error }</code>는 오류가 발생했을 때만 그 오류를 스트림으로 방출하게 된다.</p>
</li>
<li><p>compactMap 사용 예시</p>
<pre><code class="language-swift">let observable: Observable&lt;Int?&gt; = Observable.of(1, nil, 3, nil, 5)
observable
    .compactMap { $0 }
  .subscribe(onNext: { print($0) })

// 출력 : 1, 3, 5</code></pre>
</li>
</ul>
<br>
</li>
<li><p>enum Event</p>
<pre><code class="language-swift">  // Event.swift (RxSwift)
  @frozen public enum Event&lt;Element&gt; {
      case next(Element)
      case error(Swift.Error)
      case completed
  }

  extension Event {
      // If &#39;next&#39; event, returns element value
      public var element: Element? {
          if case .next(let value) = self {
              return value
          }
          return nil
      }

      // If `error` event, returns error.
      public var error: Swift.Error? {
          if case .error(let error) = self {
              return error
          }
          return nil
      }
  }</code></pre>
<br>
</li>
<li><p><code>merge()</code> : success 또는 fail 둘 중 하나만 방출될 수 있따.</p>
</li>
</ul>
<br>

<ul>
<li><p>데이터의 흐름</p>
<ol>
<li>Repository -&gt; 네트워크 통신 -&gt; 응답<ul>
<li><strong>네트워크 통신 성공</strong> (isSuccess == true) -&gt; <code>Observable&lt;true&gt;</code> : 사용 가능한 닉네임</li>
<li><strong>네트워크 통신 성공</strong> (isSuccess == false) -&gt;<code>Observable&lt;false&gt;</code> : 중복된 닉네임 (사용 불가)</li>
<li><strong>네트워크 통신 실패</strong><code>Observable&lt;Error&gt;</code> : 알 수 없는 에러</li>
</ul>
</li>
</ol>
<br>

<ol start="2">
<li>UseCase -&gt; Repo 메서드 실행 -&gt; 응답<ul>
<li><code>Observable&lt;true&gt;</code> =&gt; <code>.validNickname</code></li>
<li><code>Observable&lt;false&gt;</code> =&gt; <code>.duplicationNickname</code></li>
<li><code>Observable&lt;Error&gt;</code> =&gt; <code>.unknownError</code></li>
</ul>
</li>
</ol>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[새싹 iOS] iOS 앱 개발자 데뷔 과정 PLUS 3기 수료 후기]]></title>
            <link>https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-iOS-%EC%95%B1-%EA%B0%9C%EB%B0%9C%EC%9E%90-%EB%8D%B0%EB%B7%94-%EA%B3%BC%EC%A0%95-PLUS-3%EA%B8%B0-%EC%88%98%EB%A3%8C-%ED%9B%84%EA%B8%B0</link>
            <guid>https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-iOS-%EC%95%B1-%EA%B0%9C%EB%B0%9C%EC%9E%90-%EB%8D%B0%EB%B7%94-%EA%B3%BC%EC%A0%95-PLUS-3%EA%B8%B0-%EC%88%98%EB%A3%8C-%ED%9B%84%EA%B8%B0</guid>
            <pubDate>Mon, 01 Jul 2024 13:33:17 GMT</pubDate>
            <description><![CDATA[<p>원래 수료하자마자 정리하려고 했는데... 프로젝트가 하느라... 프로젝트 끝나니까... 개강하고... 과제 내고.. 시험 보고 하느라.... 수료한 지 반년 만에 후기글을 작성합니다!</p>
<br>


<h2 id="0-새싹-전">0. 새싹 전</h2>
<ul>
<li>대학교 4학년 1학기 끝 (한 학기 남은 상태)</li>
<li>컴퓨터공학 복수전공 (주요 CS 전공과목 수강)</li>
<li>앨런 Swift 문법 마스터 스쿨 수료 (4학년 1학기와 병행)</li>
<li>Flutter 만지작 경험 (UI 잡는 정도?)</li>
</ul>
<ul>
<li>iOS 쪽으로 해야겠다 결심한 후, 앨런 강의를 통해 문법을 다지고, <strong>프로젝트 경험의 필요성</strong>을 가장 많이 느꼈습니다. </li>
<li>복수전공자이기 때문에 교내에서도 특별한 프로젝트가 없었기에 더욱 부트캠프를 고려했습니다.</li>
<li>iOS 분야를 준비하는 입장에서 고려했던 부트캠프는 네부캠, 새싹, 애플아카데미 정도가 있었고, 결과적으로 새싹에 참여하게 되었습니다.</li>
<li><strong>수료한 지 6개월이 지난 지금까지도 너무나 좋은 선택이었다고 생각합니다</strong></li>
</ul>
<br>


<h2 id="1-지원-과정">1. 지원 과정</h2>
<ol>
<li>자기소개서<ul>
<li>구글폼을 통해 자기소개서를 제출합니다.</li>
<li>정말 말 그대로 <strong>본인에 대한 자기소개</strong>와 <strong>개발 경험에 대한 문항</strong>이 있었습니다.</li>
<li>저는 컴공을 복전한 내용과 전공과목에서 과제로 진행한 프로젝트에 대해 설명했습니다. 앨런에서 Swift 문법 공부를 한 내용도 적었습니다.</li>
</ul>
</li>
</ol>
<br>


<ol start="2">
<li>테스트 (23/07/02)<ul>
<li>미리 강의를 제공해주시고, 해당 강의 내용 기반으로 오프라인 시험을 봅니다.</li>
<li>강의만 잘 보면 충분히 다 풀 수 있는 난이도로 출제됩니다.</li>
</ul>
</li>
</ol>
<br>


<ol start="3">
<li>면접 (23/07/04 ~ 23/07/08)<ul>
<li>오프라인으로 가서 면접을 보게 됩니다. J님과 H님께서 직접 면접을 봐주십니다.</li>
<li>자세한 내용을 설명할 수는 없지만, 워낙 질문의 폭이 다양하기 때문에 단기적으로 준비할 수는 없다고 생각합니다.</li>
<li>다른 후기를 봐도 <strong>열정을 보여주면 된다!</strong> 라고 하는데... 제가 열정을 잘 보여드렸던건지... 잘 모르겠습니다. 그래도 아마 잘 봐주신 것 같아요</li>
</ul>
</li>
</ol>
<br>


<ol start="4">
<li>최종 합격 (23/07/10)<ul>
<li>메일로 최종 합격 소식을 받았습니다.</li>
<li>나중에 과정에 참여해서 다른 분들과 이야기를 해보면.. 대부분 <strong>내가 왜 여기 붙은지 모르겠다</strong> 라는 말씀을 많이 하십니다 ㅎ..</li>
<li>지금 생각해보면 6개월동안 과정 내에서 다양한 사람들을 많이 만났는데, 대부분 너무 좋은 분들이었던 것 같아요. 힘들 때 같이 으쌰으쌰 하면서 서로 동기부여도 시켜주고, 서로 웃겨주면서 정말 재미있게 시간을 보낼 수 있었습니다.</li>
<li>면접관 분들의 시각에는 좋은 분들이 다 보이시나봐요 ㅎ</li>
</ul>
</li>
</ol>
<br>


<h2 id="2-교육과정">2. 교육과정</h2>
<ul>
<li>아무래도 기간이 6개월이다보니 정말 하고 싶은 이야기가 많지만... 하루종일 이걸 쓰고 있을 순 없으니 주저리주저리 하지 말고 최대한 필요한 이야기만 해보겠습니다</li>
</ul>
<h3 id="1-수업">1. 수업</h3>
<ul>
<li>월화수목금 오전부터 오후까지 수업을 진행하십니다. 대략 오후 3시까지는 수업이라고 생각하셔야 합니다.</li>
<li><strong>수업 퀄리티가 압도적입니다.</strong>  강의력은 말 할 것도 없고, 지금 생각하면 내용적인 측면에서 커리큘럼이 너무 좋았던 것 같아요.</li>
<li>iOS 앱 개발이 아예 처음인 사람에게는 최고의 커리큘럼이었다고 생각합니다. (정말... 그냥 다 알려주세요)</li>
<li>여담으로.. 수업해주시는 J님의 강의력은... 제가 여태까지 수강한 강의들(중,고,대 수업, 수능 인강 등등..) 중에서도 정말 탑 급이었습니다. 머리통이 아예 빈 백지인데도 수업을 너무 잘해주셔서 꽉꽉 채울 수 있었어요</li>
</ul>
<ul>
<li>구체적인 수업 내용을 알고 싶으신 분은 <a href="https://velog.io/@s_sub/series/%EC%83%88%EC%8B%B9-iOS">제 블로그</a>나 깃허브 레포(<a href="https://github.com/limsub/SeSAC_Week1-5">Week1~4</a>, <a href="https://github.com/limsub/SeSAC_Week6-10">Week 6~10</a>, <a href="https://github.com/limsub/SeSAC_Week16-">Week 16~17</a>)를 참고해주시면 될 것 같습니다. 매번 깃허브에 정리하려고 했는데 중간중간 정신 못차리고... 똑바로 안했나봐요.</li>
</ul>
<ul>
<li>매일매일 수업해주신 내용에 대해 강의자료를 제공해주십니다.</li>
<li>강의자료에는 <strong>체크리스트, 과제, 미션</strong>이 있는데요. 최소한 과제까지는 그날 무조건 끝내야 한다고 생각합니다. (바로 다음날 수업이 있기 때문에, 한 번 밀리면 돌이킬 수가 없어요) 
다음날 과제에 대한 리뷰도 해주시기 때문에, <strong>꼭! 과제는 그날 끝내야 합니다</strong></li>
</ul>
<ul>
<li>그리고 수업 때 적어주시는 코드를 강의자료로 다 제공해주시기 때문에, 
수업 때 뇌 빼고 부랴부랴 코드만 적는 것 보다는 <strong>내용을 최대한 이해하려고 노력</strong>하는 게 더 도움이 되는 것 같아요.</li>
<li>어차피 끝나고 못 적은 코드는 동기들이나 멘토님께 물어보면 되기 때문에,
저 같은 경우도 일단 내용을 백프로 흡수하려고 노력을 많이 했습니다.</li>
<li>수업 끝나고 프로젝트를 열어보면 <strong>코드 반, 주석 반</strong> 이었던 것 같아요</li>
</ul>
<br>


<h3 id="2-프로젝트">2. 프로젝트</h3>
<ul>
<li>사실 프로젝트가 메인이라고 할 수 있겠죠. <strong>크게 5번의 프로젝트를 진행했습니다</strong></li>
<li>대부분 이 프로젝트를 기반으로 수료 시점에 포트폴리오를 만드시게 됩니다.</li>
</ul>
<br>

<h4 id="1-1차-리캡-240805">1. 1차 리캡 (24/08/05)</h4>
<ul>
<li><a href="https://github.com/limsub/Tamagotchi-Project">깃헙 레포</a></li>
</ul>
<ul>
<li>일반 과제와 달리, 3~4일 정도의 시간을 주십니다. 간단한 앱을 만드는 과제라고 보시면 되는데요. PDF로 앱의 요구사항을 명시해주시고, 거기에 맞춰서 새로운 프로젝트를 만들면 됩니다.</li>
<li>과정을 시작한 지 3주 정도 후에 진행했기 때문에, 아주 <strong>애기애기스러운 코드</strong>를 적게 됩니다. 지금은... 열어보고 싶지 않아요</li>
</ul>
<ul>
<li>사실 리캡의 하이라이트는 본인이 만든 프로젝트보다, <strong>피드백</strong>입니다. 내가 이걸 공짜로 받아도 되나 싶은 정도의 퀄리티로 피드백을 주세요... </li>
<li>정말 제 프로젝트를 다 뜯어보시고, 모든 빈틈에 대한 피드백을 주십니다... 아마 수료하신 분들은 다 느끼실 거에요</li>
</ul>
<br>


<h4 id="2-2차-리캡-240907">2. 2차 리캡 (24/09/07)</h4>
<ul>
<li><a href="https://github.com/limsub/ShoppingList-Project">깃헙 레포</a><ul>
<li>이 때 당시 리드미를 어떻게 쓰는지 아예 몰라서.. 그냥 무작정 생각나는 내용을 다 적었습니다. 나중에 수정해야겠다~ 생각했는데, 가끔 보고 읽어보면 옛날 생각도 나서 그냥 내버려 뒀습니다.</li>
</ul>
</li>
</ul>
<ul>
<li>형식은 1차 리캡과 크게 다른 점은 없습니다.</li>
<li>다만, 이 때부터는 본격적으로 포폴에 적을만한 기술 스택을 사용하기 때문에, 대부분 <strong>2차 리캡으로 진행한 프로젝트를 포트폴리오에 추가</strong>하셨습니다.</li>
</ul>
<ul>
<li>이때는 왜 그랬는지 몰라도 정말 혼을 갈아서 프로젝트를 만들었던 것 같습니다. 이왕 제출하는거 제대로 한 번 해보자 생각했던 것 같아요.</li>
<li>모든 예외처리를 하려고 노력하고, 요구사항에 명시되지 않았지만 일반적으로 사용자가 기대하는 기능도 구현하고... 그런데도 어마어마한 피드백을 보고 아직 멀었구나... 생각하면서 현타가 왔던 기억이 있네요 ㅎ..</li>
</ul>
<br>


<h4 id="3-개인-앱-240925">3. 개인 앱 (24/09/25)</h4>
<ul>
<li><a href="https://github.com/limsub/MULOG">깃헙 레포</a></li>
</ul>
<ul>
<li>웬만큼 수업이 진행된 후, 대략 한달 간 <strong>개인 앱 프로젝트</strong>를 진행하게 됩니다.</li>
<li>기획부터 디자인, 개발 및 배포까지 모두 혼자 진행해야 하기 때문에 생각하지 못한 지점에서 시간이 많이 소요됩니다. 미리미리 어떤 앱을 만들지, 그리고 어떤 UI로 만들지 생각을 해둬야 <strong>개발에만 진심으로 임할 수 있습니다</strong>.</li>
</ul>
<ul>
<li>저는 생각보다 일정이 미뤄지지 않았고, 나중에 가니까 지쳐서 뭘 더 하려고 하지 않았던 것 같습니다... 좀 더 힘을 냈으면 어땠을까 하는 아쉬움이 있어요</li>
</ul>
<br>


<h4 id="4-light-service-level-project-lslp-231127">4. Light Service Level Project (LSLP) (23/11/27)</h4>
<ul>
<li><a href="https://github.com/limsub/TravelWithMe">깃헙 레포</a></li>
</ul>
<ul>
<li>원래 계획된 프로젝트는 아니지만, 교육생 분들의 빵빵한 포트폴리오를 위해 새롭게 만드신 과정이라고 하셨어요!</li>
<li>이전까지의 프로젝트와 다른 점은, <strong>서버를 제공</strong> 해주십니다. 다양한 API를 제공해주시고, 그걸 활용해서 본인만의 앱을 만드는 프로젝트였습니다.</li>
<li>개인적으로 이 때, <strong>네트워크 코드 추상화</strong>랑 <strong>네트워크 에러 처리</strong> 공부하기가 정말 좋았습니다.</li>
</ul>
<ul>
<li>프로젝트가 완성되면, 본인의 서비스를 발표하는 대회가 열립니다. (우당탕탕 LSLP 경진대회) 열심히 준비해서 내가 개발한 내용을 발표할 수 있어서 굉장히 뿌듯했던 기억이 있습니다.</li>
</ul>
<br>


<h4 id="5-service-level-project-240101">5. Service Level Project (24/01/01)</h4>
<ul>
<li><a href="https://github.com/limsub/Sooda4">깃헙 레포</a></li>
</ul>
<ul>
<li>그냥 <strong>미쳐버린 프로젝트</strong>입니다. 이 정도 스케일을 경험할 수 있어서 정말 감사하면서 진행했던 프로젝트였어요</li>
<li>뭐 온갖 내용, 이슈 다 나오는데 그 과정에서 얻어가는 게 정말 많았습니다.</li>
<li>과정 기간동안 열심히 뛰어오셨다면, 아마 이 때 정말 포텐이 터지실 거에요. 개인적으로 저는 그랬던 것 같습니다</li>
</ul>
<ul>
<li>프로젝트 내용에 대해서는 리드미에 적어뒀기 때문에 생략하겠습니다. (사실 리드미에 까먹고 못 쓴 내용도 한 바가지 나옵니다... 미쳐버린 프로젝트에요)</li>
</ul>
<br>


<h3 id="3-면접준비">3. 면접준비</h3>
<ul>
<li>수료 시점이 다가오면 슬슬 취업준비를 하시게 됩니다. 이력서 + 리드미 + 포폴을 모두 만들고 나면 서류 지원을 시작하게 되고, 면접을 보게 되겠죠.</li>
<li><strong>면접 질문 리스트를</strong> 제공해주시고, <strong>모의 면접</strong>을 봐주십니다</li>
</ul>
<ul>
<li>사실 저는 이 경험을 해보지 못했지만,, 동기 분들의 이야기를 들어보면 큰 도움이 되었다고 합니다.</li>
</ul>
<br>


<h2 id="3-기타">3. 기타</h2>
<h3 id="1-노력한-점">1. 노력한 점</h3>
<h4 id="1-기록">1. 기록</h4>
<ul>
<li>위에 말씀드렸듯이 수업 일정이 굉장이 빽빽하고 내용이 방대하기 때문에, 
제대로 정리해두지 않으면 다 잊어버리겠다 생각했습니다. (기억력에 한계가 있기 때문에...)</li>
<li>주변 분들을 보니까 <strong>노션</strong>을 많이 쓰시더라구요. 전 이 때 처음 노션을 사용했는데, 좋은 선택이었다고 생각합니다.</li>
</ul>
<ul>
<li><strong>수업 내용</strong> + <strong>복습 내용</strong> 을 정리했습니다. 아무래도 수업 때 따라 적다보면, 형식 없이 으아아악 하고 받아적게 되는데요. 수업이 끝나고 나면 내용을 다시 읽어보면서, 또 따로 구글링해야하는 건 하면서, 그 날 내용을 정리했습니다.
<img src="https://velog.velcdn.com/images/s_sub/post/13d0a6d2-b8fb-4572-be61-e3315dc02592/image.png" alt="">
<img src="https://velog.velcdn.com/images/s_sub/post/f6424dc6-891b-4e36-8481-297d107e4a4f/image.png" alt=""></li>
</ul>
<br>



<ul>
<li><strong>깃허브 레포</strong> 를 따로 파서 코드도 모두 정리했습니다. 지금 생각하면 초반에는 거의 매일매일 프로젝트를 새로 만들고, 처음부터 코드를 다시 적는 걸 반복했던 것 같아요. 리드미를 좀 더 자세하게 적어두었다면 어땠을까 하는 아쉬움이 있습니다.
<img src="https://velog.velcdn.com/images/s_sub/post/df0c22c7-cb2c-49cc-a93d-0dd39e14668d/image.png" alt=""></li>
</ul>
<br>


<ul>
<li><strong>매주 1개 이상 블로그 포스트</strong>를 작성했습니다. 사실 꾸준함을 보여주기 위한 가장 좋은 지표로 보일 수 있다고 생각했기 때문에, 그 주에 수업에서 배운 내용 또는 스스로 공부한 내용을 정리해서 블로그에 작성했습니다.
<img src="https://velog.velcdn.com/images/s_sub/post/ba9b4065-3412-46ac-97e4-36d50a7de0e8/image.png" alt=""></li>
</ul>
<br>


<h4 id="2-플러스-알파">2. 플러스 알파</h4>
<ul>
<li>개인적으로 6개월동안 많이 성장하고 싶은 생각이 있었기 때문에, 수업에서 배운 내용에서 좀 더 뭘 해보려고 했습니다.</li>
</ul>
<ul>
<li>기본적으로 과제가 나오면, <strong>이전에 배웠던 내용을 접목시켜서 추가적인 기능을 구현</strong>하려고 많이 노력했습니다. 이게 나중에 갈수록 배웠던 양이 정말 많아지는데, 이 과정에서 스스로 복습이 많이 되었던 것 같습니다.</li>
</ul>
<br>


<h4 id="3-알고리즘">3. 알고리즘</h4>
<ul>
<li>시간이 지나면서 항상 딜레마였던 것 중 하나입니다. </li>
<li><em>iOS를 더 열심히 공부할까 vs. 알고리즘 병행할까*</em></li>
<li>새싹 중간에 이전 기수분들과 만날 수 있는 시간이 있었는데, 이전 수료생 분께서 알고리즘은 하는 게 좋다는 뉘앙스를 듣고 저는 후자를 선택하게 되었습니다.</li>
</ul>
<ul>
<li><p>매일 한 문제 이상 풀려고 노력했습니다. 백준 보니까 67일 연속으로 문제를 풀었다고 나오네요. 
<img src="https://velog.velcdn.com/images/s_sub/post/584a1286-1185-4290-8d6b-3610b888bdfa/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/s_sub/post/91ab9159-bcb6-4844-8036-c827411bff63/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/s_sub/post/fc6b9f27-4cab-4c8a-acac-3cbf428bf7a4/image.png" alt=""></p>
</li>
</ul>
<br>

<ul>
<li>사실, 수업이 끝나고 복습하고 과제하면 밤이 됩니다. 그래서 문제 풀 시간이 없어요. 
그래서... (이 때 무슨 광기였는지 모르겠는데) <strong>아침 8시까지 새싹에 가서</strong> 수업 전에 1~2시간 문제 풀고 수업에 들어갔습니다.</li>
<li>아마 안풀었다면 수업 시간에 맞춰서 새싹에 갔을 거라서, 
iOS와 알고리즘 두 마리 토끼를 다 잡을 수 있지 않았나 생각합니다.</li>
</ul>
<br>


<h4 id="4-오프라인">4. 오프라인</h4>
<ul>
<li>아무래도 새싹이 역세권도 아니고, 서울 왼쪽에 있기 때문에 통학 시간이 부담스러우신 분들이 많습니다. 그래서 주 2회만 필수 오프라인으로 진행하고, 주 3회는 온라인 줌으로 수업을 진행합니다.</li>
<li>저는 코로나 대학 시절을 경험했는데요, <strong>정말 온라인이랑 맞지 않는 사람</strong>이라는 걸 뼈저리게 느꼈습니다. 그냥... 수업 틀어놓고 밥먹었던 것 같아요..</li>
<li>아무튼 그래서 저는 <strong>무조건 오프라인 수업에 참여</strong>했습니다. 6개월 동안 시설 공사할 때 빼고는 단 한번도 온라인으로 수업에 참여한 적이 없습니다. 당연히 지각한 적도 없고요.</li>
</ul>
<ul>
<li>나중에 동기분들과 친해지고 나서, <strong>주말에도 다같이 공부했습니다</strong>. 사당역 근처에 좋은 공유오피스가 있어서, 주로 거기에 가서 다같이 으쌰으쌰 할 수 있었어요. (토요일에는 새싹도 열어서 새싹으로도 몇 번 갔어요)</li>
</ul>
<ul>
<li>그니까... 지금 보면... 그냥 맨날 나가서 공부했네요.. 열품타를 이렇게 태워본 적이 없는 것 같습니다.
<img src="https://velog.velcdn.com/images/s_sub/post/375728de-c740-45b8-a151-4d53e10bf7d0/image.png" alt=""></li>
</ul>
<br>


<h3 id="2-아쉬운-점">2. 아쉬운 점</h3>
<h4 id="1-알고리즘">1. 알고리즘</h4>
<ul>
<li>좀 더 했으면 어땠을까 하는 아쉬움이 있습니다. 사실 2달 공부했다 해서 금방 실력이 오르는게 아니기 때문에, <strong>미리 좀 시작했다면...</strong> 하는 아쉬움이 남아요.</li>
<li>솔직히.. 막상 하니까 <strong>시간은 핑계였구나</strong> 라는 생각이 들었기 때문에 더욱 아쉬움이 남는 것 같습니다.</li>
</ul>
<br>


<h4 id="2-프로젝트별-정리">2. 프로젝트별 정리</h4>
<ul>
<li><p>저는 거의 매일 새로운 프로젝트를 만들었습니다. 근데 그렇게 하지 말았어야 했어요.</p>
</li>
<li><p>예를 들어,</p>
<ol>
<li>영화 리스트를 볼 수 있는 화면을 Storyboard로 구현</li>
<li>영화 리스트를 볼 수 있는 화면을 Code based로 구현</li>
<li>TMDB API를 활용해서 영화 리스트 화면 구현</li>
<li>Realm을 이용해서 영화 리스트에 좋아요 기능 구현</li>
</ol>
</li>
<li><p>이 내용이 다 다른 날에 과제로 나옵니다. 그럼 저는 그냥 귀찮아서 매일 새로운 프로젝트를 만들고, 다시 처음부터 뚝딱뚝딱 만들었어요. 
(스토리보드였던거 스냅킷으로 바꾸는거 귀찮아ㅏㅏㅏ 하면서...)</p>
</li>
</ul>
<ul>
<li>하지만, 만약 하나의 프로젝트에 이렇게 조금씩 구현하다보면 그 자체가 <strong>하나의 포트폴리오용 프로젝트</strong>가 됩니다. 그때그때 구현한 내용을 정리도 잘 해두었다면, 더 좋은 퀄리티로 만들 수 있겠죠. </li>
<li>과거의 내가 왜 그랬을까... 하는 아쉬움이 있습니다.</li>
</ul>
<ul>
<li>결과적으로 제가 과제로 만들었던 모든 프로젝트는 실력 향상에는 도움이 되었지만, 뭔가 정리해서 내세울 수는 없는 결과물들이 되었습니다ㅠ</li>
</ul>
<br>


<h4 id="3-공수산정">3. 공수산정</h4>
<ul>
<li>큼직큼직한 프로젝트를 시작하기 전, 항상 J님께서 강조하십니다. </li>
<li>*&quot;미리미리 공수산정 잘 해두시고, 계획 내용 &amp; 진행 과정 등등 잘 정리해두세요!&quot;**
제발 좀 잘 정리해두어야 합니다ㅠㅡㅠ</li>
</ul>
<ul>
<li>처음엔 열심히 합니다. 한 일주일..? 그 다음부터는 개발하느라 지쳐서 뭐 노션 켜지도 않아요.... 이러지 말았어야 했는데....</li>
<li>J님이 말씀하신 것처럼 <strong>매일 30분은 텍스트로 기록한다</strong>고 생각하고 미리미리 정리해두어야 합니다....</li>
<li>포폴이랑 리드미 쓸 때 코드 보려고 프로젝트 여니까 그냥 지옥이었어요....</li>
</ul>
<ul>
<li><p>뮤로그 공수산정
<img src="https://velog.velcdn.com/images/s_sub/post/5fdc0f57-b4a3-4f4e-86c5-d78a2815df50/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/s_sub/post/b693adbf-d636-416b-b337-0a4dc19777ce/image.png" alt=""></p>
</li>
</ul>
<ul>
<li><p>LSLP 공수산정
<img src="https://velog.velcdn.com/images/s_sub/post/5ba76107-3e2a-4510-b98f-8259f28aaf3e/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/s_sub/post/00ed1711-4669-4e2d-bfea-5adbad544fb9/image.png" alt=""></p>
</li>
</ul>
<ul>
<li><p>SLP 공수산정
<img src="https://velog.velcdn.com/images/s_sub/post/ef315c9f-65c2-41a6-be71-bd0d9c27d91b/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/s_sub/post/99f42509-b945-474f-b5e4-e81804a95758/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/s_sub/post/d5db8df1-7454-45b5-a5cc-5193edca824a/image.png" alt="">  </p>
</li>
</ul>
<h3 id="3-마무리">3. 마무리</h3>
<ul>
<li>어쨌든 벌써 수료한 지 6개월이 지나가지만, 아직까지도 정말 좋은 기억으로 남아있습니다.</li>
<li>실력적으로도 너무나 큰 성장을 할 수 있었고, 멘토님들과 동기분들처럼 좋은 사람들을 만날 수 있었어요. 지금도 꾸준히 연락하면서 싱글벙글 놀고 있습니다.</li>
<li>인생을 살면서 이런 경험을 할 수 있었다는 게 너무 감사하고, 소중한 기억으로 남을 것 같아요.</li>
</ul>
<ul>
<li>만약 누군가 새싹 iOS를 추천하냐고 물어본다면, 잔말 말고 빨리 자소서 쓰라고 하고 싶습니다!!</li>
</ul>
<br>



<p><em>긴 글 읽어주셔서 감사합니다. 궁금하신 점 있으시면 편하게 알려주세요</em></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[알고리즘용 c++ 문법 정리]]></title>
            <link>https://velog.io/@s_sub/lhn3avum</link>
            <guid>https://velog.io/@s_sub/lhn3avum</guid>
            <pubDate>Fri, 28 Jun 2024 21:06:21 GMT</pubDate>
            <description><![CDATA[<h2 id="vector-arr">vector, arr</h2>
<pre><code class="language-c">int arr[10000];
vector&lt;int&gt; v;</code></pre>
<h3 id="1-초기화-제거">1. 초기화, 제거</h3>
<pre><code class="language-c">fill(arr, arr + n, 0);    // 0으로 초기화 (bfs).
fill(v.begin(), v.end(), 0);

memset(arr, 0, sizeof(arr));    // memset. 빠름.

v.clear();    // 크기까지 없앤다.

// remove - size 변화 없음 (다른 값으로 대체)
remove(v.begin(), v.begin() + 10, 3);
// erase - size 변화 있음 (뒤에 있는거 당김)
v.erase(v.begin(), v.begin() + 10);</code></pre>
<h3 id="2-정렬">2. 정렬</h3>
<pre><code class="language-c++">sort(arr, arr + n);
sort(v.begin(), v.end());
sort(v.vegin(), v.end(), greator&lt;int&gt;());    // 내림차순

// 사용자 정의
bool compare(int, a, int b) {
    return a &lt; b;
}
sort(v.begin(), v.end(), compare);</code></pre>
<h3 id="3-최대-최소">3. 최대 최소</h3>
<pre><code class="language-c">*max_element(arr, arr + n);    // 최댓값
*max_element(v.begin(), v.end());
*min_element(arr, arr + n);    // 최솟값
*min_element(v.begin(), v.end());</code></pre>
<h2 id="string-char-int">string, char, int</h2>
<h3 id="1-타입-변환">1. 타입 변환</h3>
<pre><code class="language-c">// char -&gt; int
char ch = &#39;1&#39;;
int i = ch - &#39;0&#39;;

// int -&gt; char
int i = 3;
char ch = i + &#39;0&#39;;

// int -&gt; string 
int i = 10010;
string s = to_string(i);

// string -&gt; int 
string s = &quot;10010&quot;;
int i = stoi(s);

// string -&gt; *char
string s = &quot;Hello World&quot;;
char *ch = s.c_str();
char ch2[100];
strcpy(ch2, s.c_str());</code></pre>
<h3 id="2-find">2. find</h3>
<pre><code class="language-c">// 문자열 포함 여부 확인
string s1;
string s2;
if (s1.find(s2) == string::npos) { cout &lt;&lt; &quot;Not Found&quot;; }
else { cout &lt;&lt; &quot;Found&quot;; }

// 문자열 내부의 문자열 제거
while (s1.find(s2) != string::npos) {
    size_t pos = s1.find(s2);
    s1.erase(pos, s2.size());
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[WWDC] Engineering For Testability]]></title>
            <link>https://velog.io/@s_sub/WWDC-Engineering-For-Testability</link>
            <guid>https://velog.io/@s_sub/WWDC-Engineering-For-Testability</guid>
            <pubDate>Wed, 06 Mar 2024 23:57:46 GMT</pubDate>
            <description><![CDATA[<p><em>WWDC 영상이 내려갔지만, 내용이 꽤 좋아보여서 여러 레퍼런스 참고해서 정리하였습니다</em></p>
<h1 id="engineering-for-testability">Engineering For Testability</h1>
<ol>
<li>Testable app code<ul>
<li>Testable한 코드가 무엇인지 알고, App 코드를 Testable하게 개선하는 방법</li>
</ul>
</li>
</ol>
<ol start="2">
<li>Scalable test code<ul>
<li>앱의 크기나 복잡성이 커져도 대응 가능한 확장성 있는 Test 코드를 작성하는 방법</li>
</ul>
</li>
</ol>
<h1 id="1-testable-app-code">1. Testable App Code</h1>
<hr>
<h3 id="unit-test의-구조">Unit Test의 구조</h3>
<p><img src="https://velog.velcdn.com/images/s_sub/post/15b63775-3b6a-4ced-b1d3-e9ffe232e7ec/image.png" alt=""></p>
<ul>
<li><strong>AAA 패턴 : (Arrange - Act - Assert)</strong><ul>
<li>input : <strong>Arrange</strong></li>
<li>test : <strong>Act</strong></li>
<li>output : <strong>Assert</strong></li>
</ul>
</li>
</ul>
<br>


<h3 id="testable-code의-특징">Testable Code의 특징</h3>
<ul>
<li><strong>Control over inputs</strong><ul>
<li>입력을 제어할 수 있어야 한다</li>
</ul>
</li>
<li><strong>Visibility into outputs</strong><ul>
<li>출력을 검사할 수 있어야 한다</li>
</ul>
</li>
<li><strong>No hidden state</strong><ul>
<li>(코드 동작에 영향을 줄 수 있는) 내부 상태에 의존하지 말아야 한다</li>
</ul>
</li>
</ul>
<br>


<h2 id="testability-techniques">Testability Techniques</h2>
<h3 id="1-protocols-and-parameterization">1. Protocols and Parameterization</h3>
<h4 id="sample-view">Sample View</h4>
<ul>
<li><strong>segment control</strong> (View / Edit) + <strong>button</strong> (open)
<img src="https://velog.velcdn.com/images/s_sub/post/921a0475-cf94-440e-831f-b1acd54ad457/image.png" alt=""></li>
</ul>
<br>



<h4 id="testable하지-않은-코드">Testable하지 않은 코드</h4>
<ul>
<li>버튼 클릭 시 동작하는 메서드
<img src="https://velog.velcdn.com/images/s_sub/post/53e2e42c-06a4-4705-a52b-487d2b8ec9e6/image.png" alt=""><ul>
<li>URL 구성하는 비즈니스 로직</li>
<li>UIApplication 을 통해 URL에 해당하는 앱 전환</li>
</ul>
</li>
</ul>
<br>


<ul>
<li>Unit Test 실행
<img src="https://velog.velcdn.com/images/s_sub/post/ce981897-a2ab-4b10-a716-d47dcb31342b/image.png" alt=""><ul>
<li>(Arrange - Act - Assert) 에서 Assertion을 해야 하는데,
앱을 전환하기 위해 생성된 URL을 검사할 방법이 없다.</li>
<li><code>XCTAssertTrue(예상 URL == Output URL)</code> 을 해야 하는데, url 자체를 꺼내올 수 없는 상황</li>
</ul>
</li>
</ul>
<br>


<h4 id="코드의-문제점">코드의 문제점</h4>
<ol>
<li><strong>비즈니스 로직(메서드)이 VC에 있다</strong><ul>
<li>테스트 시 VC를 생성해야 함</li>
</ul>
</li>
</ol>
<ol start="2">
<li><strong>뷰의 입력 상태(<code>segmentControl.selectedsegmentIndex</code>)를 직접 가져온다</strong><ul>
<li>테스트 시 뷰의 속성값을 지정해서 간접적으로 입력을 제공주어야 함</li>
</ul>
</li>
</ol>
<ol start="3">
<li><strong>UIApplication 인스턴스 사용</strong></li>
</ol>
<ol start="4">
<li><strong>canOpenURL의 반환값에 따라 openTapped의 동작 변화</strong><ul>
<li>제어하지 못하는 input이 되어버림.</li>
</ul>
</li>
</ol>
<ol start="5">
<li><strong>open을 통해 앱을 열었을 때 side effect에 대한 Unit Test 작성 불가</strong><ul>
<li>추가적으로, 앱을 다시 foreground로 가져올 방법이 없다.</li>
</ul>
</li>
</ol>
<br>


<h4 id="testable한-코드-만들기">Testable한 코드 만들기</h4>
<ol>
<li>비즈니스 로직을 VC에서 분리<ul>
<li><strong>DocumentOpener</strong> 라는 클래스 생성 후 로직 이동</li>
<li>테스트 시 VC 생성할 필요 x</li>
<li><code>document</code>와 <code>mode</code> 매개변수를 넣어줄 수 있게 해서 뷰의 상태 가져올 필요 x
<img src="https://velog.velcdn.com/images/s_sub/post/8696e82f-0a76-48ce-8d77-d3bf0846f8b4/image.png" alt=""></li>
</ul>
</li>
</ol>
<br>


<ol start="2">
<li>UIApplication 주입<ul>
<li>생성자에서 UIApplication 인스턴스 주입</li>
<li>DocumentOpenr에서 직접 호출하지 않도록 함
<img src="https://velog.velcdn.com/images/s_sub/post/fcae40b1-a1c0-446d-a70c-0fb1c1abc98a/image.png" alt="">
<img src="https://velog.velcdn.com/images/s_sub/post/19f32cde-0b4b-4e3d-901a-71b5cc621100/image.png" alt=""></li>
</ul>
</li>
</ol>
<br>


<ol start="3">
<li><h2 id="testdocumentopenerwhenitcanopen-not-completed"><strong>testDocumentOpenerWhenItCanOpen</strong> (NOT completed)</h2>
<img src="https://velog.velcdn.com/images/s_sub/post/c589c4cd-ce40-4709-a42b-a6d869bb16dc/image.png" alt=""><ul>
<li>Test를 위해 <code>app</code> 인스턴스를 만들어서 넘겨주어야 한다. </li>
<li>하지만, 타입인 <code>UIApplication</code>을 맞춰주어야 하는데,<br> <code>UIApplication</code>은 싱글톤이기 때문에 인스턴스를 생성하려고 하면 에러가 발생한다.</li>
<li>그렇다고 <code>UIApplication</code>을 상속하려고 해도, 싱글톤 성격을 강제하기 때문에 예외가 발생할 수 있다.
(And throws an exception to try to make a second instance, even if it&#39;s a subclass)</li>
</ul>
</li>
</ol>
<br>


<ol start="4">
<li>필요한 메서드를 가진 프로토콜 생성 후 생성자 타입 변경<ul>
<li>이미 <code>UIApplication</code>에 구현된 메서드를 선언하면, 추가적으로 구현할 필요가 없다</li>
<li>이미 매개변수의 개수와 타입이 완벽히 일치하는 메서드들이 구현되어 있기 때문</li>
<li><em>메서드 시그니쳐가 동일하다*</em>
<img src="https://velog.velcdn.com/images/s_sub/post/3447bd40-f93f-43a8-96e2-67e65f365c60/image.png" alt="">
<img src="https://velog.velcdn.com/images/s_sub/post/bfc4cdc7-4e80-4819-bea7-95da023b6e0f/image.png" alt=""></li>
</ul>
</li>
</ol>
<br>


<ol start="5">
<li>제어가 불가능한 <code>UIApplication</code> 대신 제어가 가능한 <strong>Mock 객체</strong> 생성<ul>
<li>프로토콜을 준수하는 Mock 객체 생성</li>
<li><strong>canOpenURL</strong>은 DocumentOpener로부터의 인풋처럼 행동 <ul>
<li>Test가 컨트롤해야 함</li>
</ul>
</li>
<li><strong>open</strong> 은 DocumentOpener의 출력처럼 행동 <ul>
<li>Test는 이 메서드를 통과하는 URL에 접근하고자 함. -&gt; 프로퍼티에 저장해두고 나중에 Test가 읽을 수 있도록 함
<img src="https://velog.velcdn.com/images/s_sub/post/7867cf34-5fdc-4f01-8b0b-719f704c72ef/image.png" alt=""></li>
</ul>
</li>
</ul>
</li>
</ol>
<br>


<ol start="6">
<li><strong>testDocumentOpenerWhenItCanOpen</strong> (Completed)<ul>
<li><strong>Mock 객체</strong>를 DocumentOpener의 생성인자로 넘겨준다.</li>
<li><code>DocumentOpener</code>의 <code>open</code> 메서드에 <strong>input</strong></li>
<li>예상 URL과 mock의 URL 비교 후 검증 (<strong>assert</strong>)
<img src="https://velog.velcdn.com/images/s_sub/post/4e52ad86-1445-450e-8c4a-f26606ea9a0d/image.png" alt=""></li>
</ul>
</li>
</ol>
<br>


<h4 id="summary">Summary</h4>
<ul>
<li><strong>Reduce references to shared instances &amp; Accept parameterized input</strong><ul>
<li>UIApplication 싱글톤 인스턴스에 대한 직접 참조를 없애고, 매개변수화된 Input으로 대체  </li>
<li><em>의존성 주입*</em></li>
</ul>
</li>
</ul>
<ul>
<li><strong>Intoduce a protocol</strong><ul>
<li>클래스에 직접 의존하기보다는 프로토콜을 이용하여 인터페이스 분리</li>
</ul>
</li>
</ul>
<ul>
<li><strong>Create a testing implementation</strong><ul>
<li>테스트 시 UIApplication 객체를 mock 객체로 대체</li>
</ul>
</li>
</ul>
<br>
<br>



<h3 id="2-separating-logic-and-effects">2. Separating Logic and Effects</h3>
<h4 id="sample">Sample</h4>
<ul>
<li><p>캐시에 저장할 아이템을 정의한 구조체 + 저장된 아이템을 불러오는 프로퍼티
<img src="https://velog.velcdn.com/images/s_sub/post/5cabd50a-57f0-4240-81bf-e1565ccc1954/image.png" alt=""></p>
</li>
<li><p><code>cleanCache(maxSize: Int)</code> : 캐시 저장 용량을 초과한 아이템을 비워주는 메서드
<img src="https://velog.velcdn.com/images/s_sub/post/d6c1e201-d8e3-4af5-91ff-647d6c758e88/image.png" alt=""></p>
</li>
</ul>
<h4 id="특징">특징</h4>
<ul>
<li>Input 1 : 캐시의 저장 용량.<ul>
<li>저장 용량은 인자로 넣어줄 수 있기 때문에 <strong>제어 가능</strong>하다</li>
</ul>
</li>
</ul>
<ul>
<li>Input 2 : 캐시에 저장된 아이템 (FileManager)<ul>
<li>캐시에 저장된 아이템을 불러오기 위해 <strong>FileManager</strong> 사용</li>
<li><ul>
<li>-&gt; 실제 File System에 대한 의존성을 가지고 있다는 문제 발생**<blockquote>
<p>side effect : 메서드가 외부의 속성을 변경하는 것. ex). 메서드 호출 시 전역 변수 변경</p>
</blockquote>
</li>
</ul>
</li>
<li>따라서, <code>cleanCache</code> 메서드를 통해 실제 File System의 아이템이 clean 되는 게 아니라, 
어떤 것을 clean할 지 리턴 값으로 주는 것이 더 낫다!</li>
</ul>
</li>
</ul>
<br>


<h4 id="protocols-and-parameterization-도입">Protocols and Parameterization 도입</h4>
<ul>
<li>FileManager에 직접 의존하지 않으면서, 아이템을 받으면 뭘 삭제할지 반환하는 구조로 만들어야 한다.</li>
<li>또한, 어떤 캐시 정책으로 아이템을 관리할지도 분리한다 </li>
<li><blockquote>
<p>직접 지우는 코드와 뭘 지울지 결정하는 코드 분리 가능</p>
</blockquote>
</li>
</ul>
<br>


<ol>
<li>캐시에 있는 아이템 받아서 뭘 삭제할지 반환하는 프로토콜<ul>
<li><img src="https://velog.velcdn.com/images/s_sub/post/01af8b1d-44f4-4c1b-b57f-1a9eee3da0c9/image.png" alt=""></li>
</ul>
</li>
</ol>
<br>


<ol start="2">
<li>삭제할 아이템 반환 메서드 구현<ul>
<li><img src="https://velog.velcdn.com/images/s_sub/post/ea456ac8-33e9-4724-b512-0a750fff463a/image.png" alt=""></li>
<li><img src="https://velog.velcdn.com/images/s_sub/post/0444e129-de53-4ac5-b12d-674df56bfd88/image.png" alt=""></li>
</ul>
</li>
</ol>
<br>


<ol start="3">
<li><strong>testMaxSizeCleanupPolicy</strong><ol>
<li>input 정의</li>
<li>메서드 호출</li>
<li>Output 받아</li>
<li>예상 값과 일치하는지 확인
<img src="https://velog.velcdn.com/images/s_sub/post/083b431c-0c25-4233-909f-ce19bfc37f0d/image.png" alt=""></li>
</ol>
<ul>
<li>Testable Code의 특징 만족<ul>
<li><strong>입력 제어 가능</strong></li>
<li><strong>출력 검사 가능</strong></li>
<li><strong>그 외에 어떤 영향을 주는 hidden state x</strong></li>
</ul>
</li>
</ul>
</li>
</ol>
<br>


<ol start="4">
<li><strong>cleanCache</strong><ul>
<li>프로토콜을 채택한 구조체를 paramter로 받고 해당 캐시 정책을 통해 어떤 아이템 제거할지 리턴으로 받는다.</li>
<li>즉, cleanCache에는 실제 데이터에 영향을 주는 side effect만 남게 되었다
<img src="https://velog.velcdn.com/images/s_sub/post/3460f188-3dc8-4111-8ff3-e734abfca8e8/image.png" alt=""></li>
</ul>
</li>
</ol>
<br>


<h4 id="summary-1">Summary</h4>
<ul>
<li><strong>Extract algorithms</strong><ul>
<li>side effect를 고려하여 비즈니스 로직과 알고리즘 분리</li>
</ul>
</li>
</ul>
<ul>
<li><strong>Functional style with value type</strong><ul>
<li>Input과 Output을 보이기 위해 값 타입을 사용하는 functional style</li>
<li>알고리즘이 input과 output을 보일 때, functional style을 취한다.</li>
</ul>
</li>
</ul>
<ul>
<li><strong>Thin layer on top to execute effects</strong><ul>
<li><code>cleanCache</code> 메서드는 side effect를 일으키는 소량의 코드만 존재
(side effect : FileManger를 통해 외부 실제 데이터에 영향 주는 것)</li>
</ul>
</li>
</ul>
<br>
<br>
<br>



<h2 id="레퍼런스">레퍼런스</h2>
<p><a href="https://gist.github.com/timd/3acdb6aa75efb40c03bb25cb60709c6d">https://gist.github.com/timd/3acdb6aa75efb40c03bb25cb60709c6d</a></p>
<p><a href="https://eunjin3786.tistory.com/90">[Unit Test] Testable한 코드를 위한 2가지 스킬과 예제</a>
<a href="https://levenshtein.tistory.com/601">[WWDC17] Engineering for Testability 정리 (1) Testable App Code</a>
<a href="https://baechukim.tistory.com/96">WWDC2017 Engineering for Testability / Testable App Code - 번역</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[새싹 iOS] 17주차_MapKit 실시간 장소 검색 및 Apple Map 위치 표시]]></title>
            <link>https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-17%EC%A3%BC%EC%B0%A8MapKit-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EC%9E%A5%EC%86%8C-%EA%B2%80%EC%83%89-%EB%B0%8F-Apple-Map-%EC%9C%84%EC%B9%98-%ED%91%9C%EC%8B%9C</link>
            <guid>https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-17%EC%A3%BC%EC%B0%A8MapKit-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EC%9E%A5%EC%86%8C-%EA%B2%80%EC%83%89-%EB%B0%8F-Apple-Map-%EC%9C%84%EC%B9%98-%ED%91%9C%EC%8B%9C</guid>
            <pubDate>Sat, 17 Feb 2024 07:43:51 GMT</pubDate>
            <description><![CDATA[<h3 id="구현">구현</h3>
<table>
<thead>
<tr>
<th align="center"><img src="https://velog.velcdn.com/images/s_sub/post/6e8f521c-9873-4fc7-8878-f1a249ed999e/image.gif" alt=""></th>
<th align="center"><img src="https://velog.velcdn.com/images/s_sub/post/148e5a6b-efd9-453f-8288-026c1135c88a/image.gif" alt=""></th>
</tr>
</thead>
<tbody><tr>
<td align="center">실시간 장소 검색</td>
<td align="center">Apple Map Annotation</td>
</tr>
</tbody></table>
<br>

<h2 id="1-실시간-장소-검색">1. 실시간 장소 검색</h2>
<h3 id="view">View</h3>
<ul>
<li><p>검색할 텍스트를 입력할 <strong>searchBar</strong>, 결과를 나타낼 <strong>tableView</strong></p>
<pre><code class="language-swift">let searchBar = {
    let view = UISearchBar()
    view.placeholder = &quot;떠나고 싶은 장소를 검색하세요&quot;
    view.backgroundImage = UIImage()
    view.searchTextField.backgroundColor = UIColor.appColor(.inputGray)
    return view
}()

let tableView = {
    let view = UITableView(frame: .zero, style: .plain)
    view.register(SelectLocationTableViewCell.self, forCellReuseIdentifier: &quot;SelectLocationTableViewCell&quot;)
    view.rowHeight = 66
    view.separatorInset = UIEdgeInsets(top: 0, left: 18, bottom: 0, right: 18)
    view.contentMode = .scaleAspectFill
    return view
}()</code></pre>
</li>
</ul>
<br>

<h3 id="필요-변수">필요 변수</h3>
<ul>
<li><p><code>searchCompleter</code>: 입력한 문자열에 대해 해당되는 장소를 검색</p>
</li>
<li><p><code>searchResults</code> : 검색 결과 장소 배열 저장</p>
</li>
<li><p><code>localSearch</code> : 검색된 장소의 정보 저장</p>
<pre><code class="language-swift">
private var searchCompleter = MKLocalSearchCompleter()
private var searchResults = [MKLocalSearchCompletion]()

private var localSearch: MKLocalSearch? = nil {
    willSet {
        localSearch?.cancel()
    }
}</code></pre>
</li>
</ul>
<br>

<h3 id="setting"><code>setting</code></h3>
<ul>
<li><p>delegate 설정</p>
<pre><code class="language-swift">
func settingSearch() {
    mainView.searchBar.delegate = self

    searchCompleter.delegate = self
    searchCompleter.resultTypes = .pointOfInterest
}

func settingTableView() {
    mainView.tableView.delegate = self
    mainView.tableView.dataSource = self
}</code></pre>
</li>
</ul>
<br>


<h3 id="uitableviewdatasource"><code>UITableViewDataSource</code></h3>
<ul>
<li><p><code>searchResults</code> 의 요소를 테이블뷰에 나타냄</p>
<pre><code class="language-swift">extension SelectLocationViewController: UITableViewDataSource {

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -&gt; Int {
        return searchResults.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -&gt; UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: &quot;SelectLocationTableViewCell&quot;, for: indexPath) as? SelectLocationTableViewCell else { return UITableViewCell() }

        cell.titleLabel.text = searchResults[indexPath.row].title
        cell.subTitleLabel.text = searchResults[indexPath.row].subtitle

        return cell
    }
}</code></pre>
</li>
</ul>
<br>


<h3 id="uisearchbardelegate"><code>UISearchBarDelegate</code></h3>
<ul>
<li><p>searchBar에 실시간으로 텍스트가 입력될 때마다 
<code>searchCompleter.queryFragment</code>에 텍스트를 넘겨주어서 해당하는 장소를 검색하도록 한다</p>
<pre><code class="language-swift">extension SelectLocationViewController: UISearchBarDelegate {

    func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
        // 서치바에 입력할 때마다 텍스트 넘겨주기
        if searchText.count != 0 {
            searchCompleter.queryFragment = searchText
        }
    }
}</code></pre>
</li>
</ul>
<br>

<h3 id="mklocalsearchcompleterdelegate"><code>MKLocalSearchCompleterDelegate</code></h3>
<ul>
<li><p><code>searchCompleter</code>의 장소 검색에 대한 결과</p>
<pre><code class="language-swift">extension SelectLocationViewController: MKLocalSearchCompleterDelegate {

    func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
        searchResults = completer.results  
        mainView.tableView.reloadData()
    }

    func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) {
        print(&quot;location 가져오기 에러 발생 : \(error.localizedDescription)&quot;)
    }

}</code></pre>
</li>
</ul>
<br>

<h3 id="uitableviewdelegate"><code>UITableViewDelegate</code></h3>
<ul>
<li><p>셀을 클릭했을 때, 해당 장소에 대한 정보 전달 (delegate pattern)</p>
<pre><code class="language-swift">extension SelectLocationViewController: UITableViewDelegate {
    // MKLocalSearchCompletion 타입 데이터
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        search(for: searchResults[indexPath.row])
    }

    // searchRequest 생성
    func search(for suggestedCompletion: MKLocalSearchCompletion) {
        let searchRequest = MKLocalSearch.Request(completion: suggestedCompletion)
        search(using: searchRequest)
    }

    // 장소 정보
    func search(using searchRequest: MKLocalSearch.Request) {
        searchRequest.resultTypes = .pointOfInterest  // 검색 유형

        localSearch = MKLocalSearch(request: searchRequest)

        localSearch?.start { [weak self] (response, error) in
            guard error == nil else { return }

            guard let place = response?.mapItems[0] else { return }

            // 데이터 전달하기 위해 필요한 정보 : 이름, 주소, 위도, 경도
            let placeName = place.name ?? &quot;&quot;
            let placeAddress = place.placemark.title ?? &quot;&quot;
            let placeLatitude = Double(place.placemark.coordinate.latitude)
            let placeLongtitude = Double(place.placemark.coordinate.longitude)

            self?.delegate?.sendLocation?(
                name: placeName,
                address: placeAddress,
                latitude: placeLatitude,
                longitude: placeLongtitude
            )

            self?.navigationController?.popViewController(animated: true)
        }
    }</code></pre>
</li>
</ul>
<br>

<h2 id="2-apple-map-위치-표시">2. Apple Map 위치 표시</h2>
<h3 id="model">Model</h3>
<ul>
<li>장소 정보에 대한 구조체<pre><code class="language-swift">struct TourLocation: Codable {
    let name: String
    let address: String
    let latitude: Double
    let longtitude: Double
}</code></pre>
</li>
</ul>
<h3 id="view-1">View</h3>
<ul>
<li><p><code>MKMapView</code> 사용</p>
<pre><code class="language-swift">// 뷰 객체 선언
let locationView = {
    let view = MKMapView()
    view.clipsToBounds = true
    view.layer.cornerRadius = 10
    return view
}()

// 뷰 업데이트
func setUpMapView(sender: TourLocation) {

    // 위도, 경도 설정
    let centerLocation = CLLocationCoordinate2D(
        latitude: locationStruct.latitude,
        longitude: locationStruct.longtitude
    )

    // 지도에 표시할 범위
    let region = MKCoordinateRegion(
        center: centerLocation,
        latitudinalMeters: 100,
        longitudinalMeters: 100
    )

    // annotation (pin 설정)
    let annotation = MKPointAnnotation()
    annotation.title = locationStruct.name
    annotation.coordinate = centerLocation

    // locationView 세팅
    locationView.addAnnotation(annotation)
    locationView.setRegion(region, animated: true)
}
</code></pre>
</li>
</ul>
<br>





<h3 id="레퍼런스">레퍼런스</h3>
<ul>
<li><a href="https://developer.apple.com/documentation/mapkit/mapkit_for_appkit_and_uikit/interacting_with_nearby_points_of_interest">Interaction with nearby points of interest</a></li>
<li><a href="https://velog.io/@sainkr/iOS-%EC%9C%84%EC%B9%98-%EC%9E%90%EB%8F%99%EC%99%84%EC%84%B1-%EA%B2%80%EC%83%89">[iOS] 위치 자동완성 검색</a></li>
<li><a href="https://roniruny.tistory.com/157">[iOS] MapKit 사용해서 위치 자동완성 검색 기능 구현해보기
</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[새싹 iOS] 28주차_채팅 기능을 구현하며 고민했던 지점들]]></title>
            <link>https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-28%EC%A3%BC%EC%B0%A8</link>
            <guid>https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-28%EC%A3%BC%EC%B0%A8</guid>
            <pubDate>Mon, 29 Jan 2024 00:56:27 GMT</pubDate>
            <description><![CDATA[<p><em>채팅 서비스를 구현하면서 고민했던 지점들에 대해 정리해보았습니다. 
시간적으로 여유가 없어서, 일단 글로 풀어서 정리했습니다. 추후 시각 자료를 더 첨부하겠습니다.
제가 잘못 이해하고 있는 내용이 있거나 질문이 있으시면 편하게 알려주세요</em> 🙂</p>
<h3 id="기술-스택">기술 스택</h3>
<p>UIKit, RxSwift
MVVM - C, Input / Output
Realm, Alamofire, Socket.IO
<a href="https://github.com/limsub/Sooda4">Sooda 깃허브 레포</a></p>
<br>


<h2 id="1-소켓-연결---해제-시점">1. 소켓 연결 /  해제 시점</h2>
<h3 id="1---1-초기-데이터-불러오는-과정-중-소켓-오픈-시점">1 - 1. 초기 데이터 불러오는 과정 중 소켓 오픈 시점</h3>
<p><a href="https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-27%EC%A3%BC%EC%B0%A8#1-load-data">초기 채팅 데이터 불러오는 로직</a></p>
<p>채팅방 진입 시, 초기 데이터를 불러오는 로직은 다음과 같다.</p>
<ol>
<li><code>Realm</code> realm 탐색 후 <strong>저장된 채팅의 마지막 날짜</strong> 조회</li>
<li><code>HTTP</code> 해당 날짜를 cursor_date 파라미터로 추가하여 <strong>읽지 않은 채팅 데이터</strong> 요청</li>
<li><code>Realm</code> 응답받은 <strong>읽지 않은 채팅 데이터</strong> 디비에 저장</li>
<li><code>Realm</code> <strong>읽은 채팅 n개, 읽지 않은 채팅 n개</strong> 디비에서 조회 후 VM 배열에 추가</li>
<li><code>View</code> 뷰 업데이트</li>
</ol>
<p>위 과정에서 소켓을 오픈하는 적절한 시점이 언제일지 고민했다</p>
<ul>
<li>HTTP에서 응답받은 데이터 이후의 채팅 데이터를 소켓으로 받는 것이 가장 이상적이다</li>
</ul>
<ul>
<li>2에서의 HTTP 통신을 간단하게 그려본다면 다음과 같다
<img src="https://velog.velcdn.com/images/s_sub/post/af242a13-afe3-4b2f-bc4d-ce856276ffac/image.jpeg" alt=""><ul>
<li><strong>1. send request</strong> 에서 소켓 오픈
: 1 ~ 3 사이에 오는 채팅을 중복으로 받게 된다. (HTTP, 소켓)<br></li>
<li><strong>4. receive response</strong> 에서 소켓 오픈
: 3 ~ 4 사이에 오는 채팅이 누락된다. (HTTP, 소켓 어디서도 받을 수 없다)<br></li>
<li><strong>2 receive request</strong>, <strong>3. send response</strong> 시점에 소켓을 열어주는 게 가장 좋다고 생각했지만, 이 시점을 클라이언트에서 파악할 수는 없다..</li>
</ul>
</li>
</ul>
<br>

<ul>
<li>최종적으로 내가 소켓 오픈으로 결정한 시점은 <strong>1. send request</strong> 이다.<ul>
<li>중복으로 받는 채팅은 DB에 추가할 때 예외처리를 해서 걸러줄 수 있지만, 
누락되는 채팅은 다시 받아올 방법이 없다.</li>
<li>따라서 응답받은 채팅을 DB에 저장할 때, 이미 저장된 채팅 데이터인지 확인하는 로직을 추가한다</li>
<li>이를 통해 소켓 통신과 HTTP 통신의 데이터 중복 이슈를 해결하고,
서버 오류로 인한 중복 데이터 응답에 대해서도 대응할 수 있다</li>
</ul>
</li>
</ul>
<br>


<ul>
<li><p>HTTP 통신 + 소켓 오픈</p>
<pre><code class="language-swift">/* HTTP 통신 + 소켓 오픈 */
private func fetchRecentChatting(completion: @escaping () -&gt; Void) {

    // 요청 모델 생성 (cursor date 포함)
    var requestModel = /* ... */

    // HTTP 요청 - Repo에서 DB에 넣어주는 작업까지 진행
    channelChattingUseCase.fetchRecentChatting(
        channelChattingRequestModel: requestModel,
        completion: completion
    )

    // 소켓 오픈 및 응답 대기 &lt;- HTTP 응답이 오기 전에 실행 (completion x)
    self.openSocket()
    self.receiveSocket()
}</code></pre>
</li>
</ul>
<ul>
<li><p>DB에 채팅 데이터 저장</p>
<pre><code class="language-swift">/* DB에 채팅 데이터 저장 */
func addChannelChattingData(dtoData: ChannelChattingDTO, workSpaceId: Int) {

    guard let realm else { return }

    // 0. 디비에 저장하려고 하는 채팅이 이미 디비에 있는 채팅인지 확인하는 작업
        // - 서버 오류로 인해 중복된 채팅을 받을 가능성이 있음
        // - request를 보냄과 동시에 소켓이 오픈되기 때문에, 서버 입장에서 request를 받기 전, 소켓을 통해 이미 디비에 채팅이 저장될 가능성이 있음.
    if let _ = realm.objects(ChannelChattingInfoTable.self).filter(&quot;chat_id == %@&quot;, dtoData.chat_id).first {
        print(&quot;디비에 이미 있는 채팅. 걸러&quot;)
        return
    }

    /* ... */
}</code></pre>
</li>
</ul>
<br>


<h3 id="1---2-소켓-open--close-시점">1 - 2. 소켓 open / close 시점</h3>
<ul>
<li><p>기본적으로 채팅 화면이 나타났을 때 open, 화면이 사라졌을 때 close가 되어야 하기 때문에 <strong>viewWillAppear</strong> 와 <strong>viewWillDisappear</strong> 에 해당 코드를 작성하였다.</p>
<pre><code class="language-swift">override func viewWillAppear(_ animated: Bool) {
    super.viewDidAppear(animated)

    loadData()    // connectSocket 포함
    startObservingSocket()
}

override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)

    viewModel.disconnectSocket()
    removeObservingSocket()
}</code></pre>
<ul>
<li>채팅 화면에서 push 로 화면 전환이 더 일어날 수 있기 때문에 <strong>viewDidLoad</strong> 에서 실행하지 않았다.</li>
</ul>
</li>
</ul>
<br>


<ul>
<li>추가적으로 앱이 백그라운드로 나갔을 때 close, 포그라운드로 들어왓을 때 open이 되어야 한다. 이 작업을 하지 않으면, 불필요하게 계속 소켓이 연결되어 있을 수도 있다.<ul>
<li>SceneDelegate의 <strong>sceneDidBecomeActive</strong> 와 <strong>sceneDidEnterBackground</strong> 에 해당 코드를 작성한다.</li>
<li>채팅 화면에서 해당 시점을 알게 하기 위해 <code>NotificationCenter</code> 를 활용한다.</li>
<li>소켓이 연결되어 있는 상태에서 백그라운드로 나가면, <strong>소켓 연결 해제</strong> 하고, 다음에 포그라운드로 들어올 때 <strong>소켓을 연결해야 한다는 신호</strong>를 남겨둔다</li>
</ul>
</li>
</ul>
<pre><code class="language-swift">/* VC */
// 노티 등록
private func startObservingSocket() {
    // SceneDidBecomeActive에서 소켓 재연결의 필요성을 확인하고, 노티를 보낸다 -&gt; loadData
    NotificationCenter.default.addObserver(
        self,
        selector: #selector(socketReconnectAndReloadData),
        name: NSNotification.Name(&quot;socketShouldReconnect&quot;),
        object: nil
    )
}

@objc
private func socketReconnectAndReloadData() {
    print(&quot;Scene에서 연락 받음 : 다시 소켓 연결해야 함!\n&quot;)

    self.loadData()
}

// 노티 제거
private func removeObservingSocket() {
    NotificationCenter.default.removeObserver(
        self,
        name: NSNotification.Name(&quot;socketShouldReconnect&quot;),
        object: nil
    )
}</code></pre>
<pre><code class="language-swift">/* SceneDelegate */
func sceneDidEnterBackground(_ scene: UIScene) {

    // 소켓이 연결되어 있는 상태에서 백그라운드로 앱을 보내면,
    // 1. 일단 소켓 끊어주고
    // 2. 다시 포그라운드 진입 시 소켓 연결하도록 한다
    if socketManager.isOpen {
        socketManager.closeConnection()      // 1.
        socketManager.shouldReconnect = true // 2.
    } else {
        socketManager.shouldReconnect = false
    }
}

func sceneDidBecomeActive(_ scene: UIScene) {

    if socketManager.shouldReconnect {
        // Notification Center로 해당 화면(채팅 화면 - 소켓이 열리는 화면)에 노티 보내기
        NotificationCenter.default.post(
            name: NSNotification.Name(&quot;socketShouldReconnect&quot;),
            object: nil
        )
    }
}</code></pre>
<ul>
<li><p>이 과정에서 같은 소켓에 대해 중복된 handler를 등록할 수가 있기 때문에, 소켓 연결 해제 시 반드시 <code>removeAllHandler</code> 를 실행시킨다.</p>
<pre><code class="language-swift">/* SocketIOManager */
func closeConnection() {
    socket.disconnect()
    socket.removeAllHandlers()
    self.isOpen = false
}</code></pre>
<ul>
<li>소켓 이벤트 중복 이슈</li>
</ul>
<table>
<thead>
<tr>
<th align="center"><img src="https://velog.velcdn.com/images/s_sub/post/590d8c51-549e-420e-851a-9ec8adc621d8/image.gif" alt=""></th>
</tr>
</thead>
<tbody><tr>
<td align="center">중복된 handler로 인해 여러 개의 채팅을 받는 것처럼 동작</td>
</tr>
</tbody></table>
</li>
</ul>
<br>


<h2 id="2-서버와-로컬-db-데이터-동기화">2. 서버와 로컬 DB 데이터 동기화</h2>
<ul>
<li>현재 로컬 DB (realm) 에는 <strong>채팅 정보</strong> 외에도 
채팅이 이루어지는 <strong>채널 정보</strong>와 채팅을 전송/수신 하는 <strong>유저 정보</strong> 가 저장되어 있다.
<img src="https://velog.velcdn.com/images/s_sub/post/25edddce-fc8d-442e-bc06-c535b981218d/image.jpeg" alt=""></li>
</ul>
<ul>
<li>즉, 채팅 데이터를 DB에 저장할 때, <strong>channel_ID</strong> 와 <strong>user_ID</strong> 를 DB에서 탐색해서, 이미 저장된 데이터라면 해당 데이터를 FK로 연결하여 채팅 데이터를 저장한다</li>
</ul>
<ul>
<li>하지만, 만약 유저 정보 (user_name, user_profileImage) 또는 채널 정보 (channel_name) 이 서버에서 수정되었다면 문제가 생긴다.</li>
</ul>
<ul>
<li>로컬 DB 데이터가 업데이트되지 않으면 같은 유저인데도 채팅방에서 다른 유저처럼 보이는 이슈가 생긴다</li>
</ul>
<ul>
<li><p>따라서 <strong>채팅방에 들어간 시점</strong>에, 항상 채널 정보와 유저 정보 api를 통해 최신 데이터를 받고, 로컬 DB를 업데이트한다!</p>
</li>
<li><p>한계 : 결국 업데이트 되는 시점은 정해져 있기 때문에, 해당 시점을 통하지 않는다면 동기화가 되지 않는다. 
즉, <strong>채팅방 내에 있을 때 변경되는 데이터</strong>에 대해서는 반영할 수 없다. </p>
</li>
<li><p>동기화되지 않은 경우와 동기화(해결)한 경우</p>
<table>
<thead>
<tr>
<th align="center"><img src="https://velog.velcdn.com/images/s_sub/post/79e00a30-41db-4ea0-a71d-c65231efbc34/image.gif" alt=""></th>
<th align="center"><img src="https://velog.velcdn.com/images/s_sub/post/66984207-d59a-4d5f-9312-6e305220ed2b/image.gif" alt=""></th>
</tr>
</thead>
<tbody><tr>
<td align="center">동기화 o</td>
<td align="center">동기화 x</td>
</tr>
</tbody></table>
</li>
</ul>
<br>



<h2 id="3-같은-디바이스에-다른-계정으로-로그인하는-경우---로컬-db-공유-이슈">3. 같은 디바이스에 다른 계정으로 로그인하는 경우 - 로컬 DB 공유 이슈</h2>
<ul>
<li>프로젝트에서는 <strong>사용자가 읽은 채팅</strong> 을 로컬 DB (realm) 로 관리한다.</li>
<li>프로젝트에서는 로그인/로그아웃 이 가능하기 때문에 여러 계정이 로그인할 수 있다.</li>
</ul>
<ul>
<li>위의 2가지 기획을 구현 중, 여러 계정이 로컬 DB를 공유하는 이슈를 경험하였다.<ul>
<li>단순히 기기의 realm 파일을 하나 생성해서 채팅 데이터를 다 저장하면, 
여러 계정의 데이터가 당연히 섞여서 들어가게 된다.</li>
<li>이렇게 되면 A 계정에서 읽은 채팅을 B 계정에서는 읽지 않았는데, 읽음 처리가 된다.</li>
<li>서버에 요청하는 pagination date도 비정상적으로 받아오게 된다.</li>
</ul>
</li>
</ul>
<ul>
<li>위 이슈를 해결하기 위해 여러 방법을 생각해 보았다.</li>
</ul>
<br>


<h3 id="1-새-계정-로그인-시-db-초기화">1. 새 계정 로그인 시 DB 초기화</h3>
<ul>
<li>데이터 공유 이슈 해결</li>
<li>새로운 계정으로 로그인 시 이전 계정의 데이터가 모두 손실되기 때문에 서비스적으로 좋은 방법은 아니라고 생각</li>
</ul>
<h3 id="2-채팅-테이블에-수신자-column-추가">2. 채팅 테이블에 &#39;수신자&#39; column 추가</h3>
<ul>
<li>각 유저의 고유한 <strong>user ID</strong> 를 이용해서 채팅 데이터가 realm에 저장되는 테이블에 column을 하나 추가한다.</li>
<li>유저 별 데이터가 분리되기 때문에 데이터 공유 이슈 해결</li>
<li>DB 데이터를 지워야 할 필요 없기 때문에 데이터 손실 문제 해결</li>
</ul>
<ul>
<li>PK 변경 필요 : (chat ID) -&gt; (chat ID, receive user ID)</li>
<li>데이터 추출할 때마다 receive user ID 필요 -&gt; 코드 유지보수 측면에서 좋은 방법은 아니라고 생각</li>
</ul>
<h3 id="3-계정-간-별개의-db-파일-생성-채택">3. 계정 간 별개의 DB 파일 생성 (채택)</h3>
<ul>
<li>realm의 configuration을 이용하여 계정 별 별개의 realm 파일 생성</li>
<li>파일 명 : &quot;SoodaRealm_user_(userId).realm&quot;</li>
<li>DB CRUD를 위한 RealmManager 인스턴스 생성 시점에
키체인에 저장된 user ID 이용해서 <strong>realm 파일 새로 생성 or 기존 파일 식별</strong></li>
</ul>
<ul>
<li>만약 너무 많은 계정이 로그인한다면 그 때마다 계속해서 파일 생성 -&gt; 메모리 오버헤드 발생</li>
<li>따라서, 최대 5개의 파일만 생성이 가능하도록 설정</li>
<li>5개 초과 시 <strong>수정일이 가장 오래된 파일 제거 (LRU)</strong>
<img src="https://velog.velcdn.com/images/s_sub/post/ff7c52c7-41c8-4b59-aa4a-661230ddcf72/image.png" alt=""><br>


</li>
</ul>
<h2 id="4-다른-디바이스에-같은-계정으로-로그인하는-경우---소켓-응답-예외처리">4. 다른 디바이스에 같은 계정으로 로그인하는 경우 - 소켓 응답 예외처리</h2>
<ul>
<li>채팅 화면에서 화면이 업데이트되는 경우는 <strong>채팅 전송</strong> 과 <strong>채팅 수신</strong> 이 있다.</li>
</ul>
<ul>
<li>채팅 전송 : 채팅 전송 (HTTP 통신) 이 성공했을 때, 200 응답과 함께 받은 데이터 이용</li>
<li>채팅 수신 : 채팅 수신 (소켓 통신) 이 성공했을 때, 수신한 데이터 이용</li>
</ul>
<ul>
<li>이 때, <strong>전송한 채팅</strong> 은 해당 채팅방의 모든 유저에게 전송되기 때문에, 중복해서 소켓으로 응답받게 된다.
이에 대한 예외처리가 필요하다.</li>
</ul>
<h3 id="구현-방법">구현 방법</h3>
<ul>
<li>소켓으로 응답받은 데이터(<code>socketChatData</code>) 의 <strong>발신자 user ID</strong> 와 <strong>현재 계정 user ID</strong> 를 비교한다</li>
<li>두 값이 같다면, 해당 데이터(<code>socketChatData</code>)는 현재 계정이 보낸 채팅이므로, <strong>전송한 채팅</strong> 이라고 할 수 있다.</li>
<li>즉, 두 값이 같을 때는 소켓으로 응답을 받았더라도, 데이터 처리를 해주지 않는다.</li>
</ul>
<h3 id="이슈">이슈</h3>
<ul>
<li><p>하나의 기기로만 테스트했을 때는 위 방법에 전혀 문제가 없었지만, 여러 계정에 동시에 로그인한 경우 문제가 발생한다.</p>
</li>
<li><p>두 기기에 접속했을 때, 하나의 기기에서 채팅을 보내더라도 실시간으로 다른 기기에서도 업데이트가 되어야 한다.</p>
</li>
<li><p>하지만, 소켓으로 응답받은 데이터의 user ID와 현재 계정 user ID가 같으면 수신 처리를 하지 않았기 때문에, 
현재 계정이 보낸 데이터라고 판단하고, 응답 처리를 하지 않는다.</p>
</li>
<li><p>이러면 로컬 DB에 들어가는 채팅 순서도 엉키기 때문에, <strong>예외처리에 대한 추가적인 기준</strong> 이 필요하다.</p>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/s_sub/post/1369c5a0-7fed-41fc-beea-d8997496483a/image.gif" alt=""></p>
<h3 id="해결-방법--실패">해결 방법 &amp; 실패</h3>
<ul>
<li><p>단순히 user ID로만 비교하면 위에 보이듯이 멀티 디바이스에 대한 대응이 되지 않기 때문에 user ID로 조건 처리를 하는 것은 적절하지 않다.</p>
</li>
<li><p>그렇다면 결국, <strong>채팅 데이터</strong> 가 정확히 현재 계정이 보낸 채팅인지 확인해야 한다.
즉, 채팅 데이터의 고유한 값인 chat ID 를 이용한다.</p>
</li>
<li><p>내가 생각한 로직은 다음과 같다</p>
<ol>
<li>채팅 전송</li>
<li>전송 성공 응답 (200) + 성공한 채팅 데이터 (<code>httpChatData</code>)</li>
<li><code>httpChatData</code> 의 chat ID를 <strong>UserDefaults</strong> 에 저장</li>
<li>소켓 응답 (<code>socketChatData</code>)</li>
<li><code>socketChatData</code>의 chat ID와 UserDefaults의 chat ID 비교 후 현재 계정에서 보낸 채팅인지 분기 처리</li>
</ol>
</li>
<li><p>위 로직대로면 발생한 문제가 모두 해결되고, 정상적으로 멀티 디바이스 대응이 가능해진다</p>
</li>
</ul>
<ul>
<li>하지만, 실패했다.<ul>
<li><strong>4. 소켓 응답 이 2. HTTP 응답 보다 빠르게 온다</strong></li>
<li>애초에 UserDefaults에 전송한 채팅 데이터를 저장하기도 전에 소켓 응답이 와버리기 때문에 분기처리 자체가 불가능했다.</li>
<li>chat ID 는 서버에서 정해서 주기 때문에, HTTP 응답이 오기 전까지는 절대 알 수가 없다.</li>
</ul>
</li>
</ul>
<ul>
<li>결과적으로 <strong>멀티 디바이스 대응 이슈</strong> 에 대해서는 해결할 수 없었다...
찾아보니 멀티 디바이스에 대한 대응을 위해서는 이 정도 수준보다 훨씬 많은 내용을 더 알아야 하는 것 같다.
추후 기회가 된다면, 이 쪽 내용에 대해서도 공부를 좀 해봐겠다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[새싹 iOS] 27주차_채팅 로직 구현]]></title>
            <link>https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-27%EC%A3%BC%EC%B0%A8</link>
            <guid>https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-27%EC%A3%BC%EC%B0%A8</guid>
            <pubDate>Tue, 23 Jan 2024 12:38:53 GMT</pubDate>
            <description><![CDATA[<h3 id="기술-스택">기술 스택</h3>
<p>UIKit, RxSwift
MVVM, Input / Output
Realm, Alamofire, Socket.IO
UITableView + Cursor-based pagination</p>
<p><a href="https://github.com/limsub/Sooda4">Sooda 깃허브 레포</a></p>
<h1 id="로직">로직</h1>
<h2 id="1-load-data">1. Load Data</h2>
<p><img src="https://velog.velcdn.com/images/s_sub/post/f90b86af-d7e4-41e5-9a62-f7f19723fd93/image.png" alt=""></p>
<p>채팅방에 진입했을 때 해야 할 일은 먼저 화면에 보여주기 위한 
<strong>채팅 데이터를 불러오는 것</strong> 이다.</p>
<p>이 때, 채팅 데이터는 크게 2가지로 나눌 수 있다.</p>
<ol>
<li>이미 읽은 채팅 데이터</li>
<li>아직 읽지 않은 채팅 데이터</li>
</ol>
<br>

<p>현재 프로젝트에서는 기본적으로 Realm DB에 모든 채팅 데이터를 저장한다.
즉, <strong>읽은 채팅</strong> 은 모두 Realm DB에서 꺼내온다.</p>
<br>

<p><strong>읽지 않은 채팅</strong> 은 서버에 요청에서 받아온다.</p>
<ol>
<li>읽은 채팅들 (DB에 저장된 채팅) 중 <strong>가장 최신 채팅 데이터의 날짜</strong>를 변수에 저장한다. -&gt; <code>var lastChattingDate: Date</code></li>
<li><code>lastChattingDate</code> 부터 <code>Date() // 현재 시각</code> 까지의 채팅 데이터를 서버에 요청한다. 이 데이터들이 <strong>읽지 않은 채팅</strong> 이 된다.</li>
</ol>
<br>

<p>채팅방에 들어간 순간, 읽지 않은 채팅들도 모두 읽음 처리가 되어야 하기 때문에,
서버에서 응답받은 위 데이터들은 <strong>DB에 바로 저장</strong>한다.</p>
<br>


<p>모든 채팅 데이터가 DB에 저장이 완료되었을 때, 
그제서야 DB에서 데이터를 꺼내서 화면에 보여준다.</p>
<br>

<p>채팅방 진입 시, 실시간으로 오는 채팅도 받아야 하기 때문에
<strong>소켓을 오픈</strong>해주어야 한다. 오픈 시점에 대한 이야기는 일단 패스한다.</p>
<p><img src="https://velog.velcdn.com/images/s_sub/post/52f1a5e0-8dfc-496c-b676-8d423aac9186/image.jpeg" alt=""></p>
<br>


<h2 id="2-pagination">2. Pagination</h2>
<p>채팅방에 처음 들어갔을 때, 
스크롤 시점은 <code>&quot;여기까지 읽으셨습니다&quot;</code> 셀이 가운데에 위치하도록 했다.</p>
<pre><code class="language-swift">    self.mainView.chattingTableView.scrollToRow(
        at: indexPath,
        at: .middle,
        animated: false
    )</code></pre>
<br>

<p>따라서, 해당 셀 기준으로 <strong>위 / 아래 방향 pagination</strong>이 가능해야 한다</p>
<table>
<thead>
<tr>
<th>위 방향</th>
<th>아래 방향</th>
</tr>
</thead>
<tbody><tr>
<td><img src="https://velog.velcdn.com/images/s_sub/post/23aa6231-e4f8-4e80-a9e7-85311a50a42e/image.gif" alt=""></td>
<td><img src="https://velog.velcdn.com/images/s_sub/post/7694b891-a1dd-4e92-b6b5-2e4a7b2f2ce9/image.gif" alt=""></td>
</tr>
</tbody></table>
<br>

<p>채팅 데이터는 모두 DB에 저장되어 있기 때문에 지정한 개수만큼 DB에서 꺼내온다
&quot;<strong>특정 날짜</strong> 이전/이후 로 30개의 채팅 데이터를 꺼낸다&quot;</p>
<p>따라서 <strong>pagination의 offset으로 이용할 변수</strong>가 필요하다.
<code>var previousOffsetTargetDate: Date?</code>
<code>var nextOffsetTargetDate: Date?</code></p>
<p>pagination을 통해 채팅 데이터를 불러왔으면, offset 변수를 업데이트한다.</p>
<br>


<p>아래 방향 pagination은 기존에도 여러 번 구현해본 적이 있었기 때문에 큰 어려움 없이 할 수 있었다.
배열에 <code>append</code>로 추가 데이터를 붙이고, <code>tableView.reloadData()</code> 를 실행하면 된다.</p>
<br>


<h3 id="위-방향-pagination-uitableview-reverse-pagination">위 방향 Pagination (UITableView Reverse Pagination)</h3>
<h4 id="트러블-슈팅">트러블 슈팅</h4>
<p>처음엔 아래 방향 pagination과 크게 다를 게 없다고 생각했다.</p>
<p>pagination이 일어나는 시점에
<code>arr.insert(contentsOf: newArr, at: 0)</code>
<code>tableView.reloadData()</code> 
를 실행했지만, 이러면 <strong>순식간에 여러 번</strong> Pagination이 일어난다.</p>
<p>이러한 문제가 발생하는 이유는 (내가 판단했을 때)
<code>tableView.reloadData</code> 가 완료되면
사용자의 스크롤 시점은 <strong>기존에 보고있던 cell 위치와 동일</strong>해야 한다.
이건 당연히 그래야 하고, 구현 목적에도 일치한다.</p>
<p>이 cell 위치는 <strong>tableView의 indexPath</strong>를 기준으로 맞춰진다.
즉, 내가 기존에 <code>indexPath [0, 10]</code> 셀을 보고 있었다면
<code>tableView.reloadData()</code> 이후에도 <code>[0, 10]</code> 번째 셀을 보게 된다.</p>
<p>하지만 위로 Pagination을 구현할 때는 배열의 앞에 새로운 데이터를 넣어주기 때문에
<code>tableView.reloadData()</code> 이전과 이후에 <code>[0, 10]</code> 번째 데이터는 다른 데이터가 된다.</p>
<p>예를 들어, 내가 추가로 30개의 데이터를 배열의 맨 앞에 넣어줬을 때,
reload 이전에 보고 있던 셀의 위치가 <code>[0, 10]</code> 이라면
reload 이후에 해당 셀의 위치는 <code>[0, 40]</code> 이 된다.</p>
<p>즉, 내가 보고 있어야 할 셀은 <code>[0, 40]</code> 인데, 
새로 넣어준 <code>[0, 10]</code> 번째 데이터를 보고 있으니까
pagination 조건에 또 맞게 되고, 순식간에 여러 번 pagination이 일어나버린다.</p>
<p><strong>prefetchRowsAt</strong> 을 이용한다면,
pagination이 실행되는 조건 (ex. <code>indexPath.row == 1</code>) 이 
<code>tableView.reload</code> 이후에도 연속으로 계속 실행된다</p>
<p><strong>scrollViewDidScroll</strong> 을 이용해도 역시,
pagination이 실행되는 조건 (ex. <code>contentOffset.y &lt; 100</code>) 이
<code>tableView.reload</code> 이후에도 연속으로 계속 실행된다.
이 때는 아예 <code>contentOffset.y</code> 값이 커지지가 않고, 음수까지 내려가버리게 된다.</p>
<p><strong>willDisplayRow</strong> 도 이하 동문.</p>
<br>

<h4 id="해결-방안">해결 방안</h4>
<p><code>CGAffineTransform(scaleX: 1, y: -1)</code> 메서드나 &quot;ReverseExtension&quot; 라이브러리를 통해서 테이블뷰 자체를 상하 반전시키게 되면 쉽게 구현이 가능하다.
하지만 나는 위 / 아래 pagination을 모두 구현시켜야 하기 때문에 이건 의미가 없다.</p>
<p>결국 내가 구현해야 하는 건
<strong>새로운 데이터는 배열 앞에 붙이고,
테이블뷰 위에 새로운 셀도 그리지만,
스크롤 위치는 기존과 동일해야 한다</strong></p>
<p><strong>tableView.insertRows</strong> 메서드를 이용했다.
사실 위에 새롭게 붙는 데이터에 대해서만 cell을 다시 그려주면 되기 때문에
테이블뷰의 모든 셀을 다시 그리는 <code>tableView.reloadData</code>를 반드시 사용할 필요는 없다. 그래서 <code>insertRows</code> 메서드를 떠올렸다.</p>
<p><code>insertRows</code> 메서드를 사용함으로써 위에 겪었던 트러블 
(indexPath 기준으로 위치 고정) 을 해결할 수 있었다.</p>
<p>다만 이 때는 새롭게 붙이는 데이터의 개수를 알고 있어야 하기 때문에
기존 메서드의 수정이 필요했다. (<code>completionHandler의 매개변수 cnt</code>)</p>
<pre><code class="language-swift">viewModel.paginationPreviousData { [weak self] cnt in
    let indexPaths = (0..&lt;cnt).map { IndexPath(row: $0, section: 0) }
    self?.mainView.chattingTableView.insertRows(at: indexPaths, with: .bottom) 
}</code></pre>
<p><img src="https://velog.velcdn.com/images/s_sub/post/d6e2705f-edcb-451c-b3f4-0a21de11d012/image.jpeg" alt=""></p>
<br>


<h2 id="3-newmessagetoastview">3. NewMessageToastView</h2>
<p>채팅방에 진입하면 소켓 통신을 통해 실시간 채팅 응답을 받을 수 있다.
실시간 채팅 응답에 대해 UI적으로 어떻게 반응해야 할 지 고민해보았다.</p>
<ol>
<li><p>스크롤 위치가 상대적으로 아래에 있을 때
새로운 채팅을 맨 아래에 보여주고, 스크롤 시점도 맨 아래로 움직인다</p>
</li>
<li><p>스크롤 위치가 상대적으로 위에 있을 때
새로운 채팅을 <strong>toast view</strong> 형태로 보여주고, 스크롤 시점은 유지한다
뷰를 클릭하거나 스크롤을 아래로 내리면 hidden 처리한다</p>
</li>
</ol>
<p>이 때 &quot;상대적&quot;의 기준은 모호하기 때문에 일단 임의로 설정하였다.</p>
<pre><code class="language-swift">// isBottom이 true 일 때 &quot;상대적으로 아래에 있다&quot;고 판단한다
let isBottom = scrollView.contentSize.height - scrollView.contentOffset.y &lt; 800</code></pre>
<table>
<thead>
<tr>
<th>case 1</th>
<th>case 2</th>
</tr>
</thead>
<tbody><tr>
<td><img src="https://velog.velcdn.com/images/s_sub/post/a7bbc0bf-e872-4943-8b68-6d1b44e43073/image.gif" alt=""></td>
<td><img src="https://velog.velcdn.com/images/s_sub/post/93cc98e3-ce9c-41b6-9c3b-9b416a106e2c/image.gif" alt=""></td>
</tr>
</tbody></table>
<br>

<p>뷰를 클릭하거나 스크롤을 아래로 내리면 toastView hidden</p>
<table>
<thead>
<tr>
<th>클릭</th>
<th>스크롤</th>
</tr>
</thead>
<tbody><tr>
<td><img src="https://velog.velcdn.com/images/s_sub/post/a8eab601-c609-4dec-8acf-5128d7777650/image.gif" alt=""></td>
<td><img src="https://velog.velcdn.com/images/s_sub/post/4bdbf6f0-f8a5-4952-b93b-3a677903d97c/image.gif" alt=""></td>
</tr>
</tbody></table>
<br>

<h3 id="실시간-채팅-응답-로직">실시간 채팅 응답 로직</h3>
<p>위 과정이 UI적으로 봤을 때는 경우의 수가 2개이지만, 
실제 코드로 접근하려고 하면 3개로 늘어난다.
<strong>아래 방향 Pagination</strong> 이 있기 때문이다</p>
<p><strong>아래 방향 pagination의 완료 여부</strong> 에 따라 추가로 분기 처리를 진행했다.
이 처리를 하지 않으면, 채팅 순서가 꼬일 수 있다.</p>
<p>아직 디비에서 채팅 데이터를 모두 꺼내지도 않았는데,
새로 온 채팅을 무작정 VM 배열에 붙이게 되면 순서가 꼬이게 된다</p>
<br>

<p>경우의 수</p>
<ol>
<li>스크롤 위치가 상대적으로 아래 (<code>isBottom == true</code>)
 (이 경우는 반드시 아래 방향 pagination이 완료되어 있다)</li>
</ol>
<ol start="2">
<li><p>스크롤 위치가 상대적으로 위 (<code>isBottom == false</code>) 
&amp;&amp; 아래 방향 pagination 완료 o (<code>isDoneNextPagination == true</code>)</p>
</li>
<li><p>스크롤 위치가 상대적으로 위 (<code>isBottom == false</code>)
&amp;&amp; 아래 방향 pagination 완료 x (<code>isDoneNextPagination == false</code>)</p>
</li>
</ol>
<br>

<pre><code>소켓 통신으로 newChat 데이터 응답
tableView에 나타날 데이터 배열 : chatArr (VM)

case 1.
1. (Repository) DB에 newChat 저장
2. (VM) chatArr.append(newChat)
3. (VC) tableView.reloadData()
4. (VC) tableView.scrollToBottom()


case 2.
1. (Repository) DB에 newChat 저장
2. (VM) chatArr.append(newChat)
3. (VC) tableView.reloadData()
2. (VC) showNewMessageToastView(newChat)


case 3.
1. (Repository) DB에 newChat 저장
2. (VC) showNewMessageToastView(newChat)
</code></pre><br>

<h3 id="newmesssagetoastview-클릭-로직">newMesssageToastView 클릭 로직</h3>
<p>뷰를 클릭하면 테이블뷰의 맨 아래로 스크롤 시점을 움직여야 한다.
이 때도 역시 <strong>아래 방향 pagination의 완료 여부</strong>에 따라 분기 처리가 필요하다</p>
<p>디비에서 채팅을 모두 꺼낸 상태가 아니라면, 스크롤 시점을 맨 아래로 내리더라도 최신 데이터를 확인할 수 없기 때문이다
따라서 이 경우에는 <strong>디비에 남은 채팅 데이터를 모두 꺼내는 과정</strong> 이 필요하다</p>
<br>

<p>경우의 수</p>
<ol>
<li>아래 방향 pagination 완료 o</li>
<li>아래 방향 pagination 완료 x</li>
</ol>
<br>

<pre><code>case 1.
1. (VC) tableView.scrollToBottom()


case 2. 
1. (VM) fetchAllNextChattingData
2. (VC) tableView.reloadData()
3. (VC) tableView.scrollToBottom()</code></pre><br>

<h2 id="4-send-message">4. Send Message</h2>
<p>채팅을 구현할 때
<strong>메세지 수신은 소켓, 메세지 전송은 HTTP</strong> 를 이용했다</p>
<p>따라서 전송 버튼을 눌렀을 때, HTTP Request로 채팅 전송 요청을 하게 되고,
성공 응답을 받았을 때 해당 채팅 데이터(<code>newChat</code>)를 VM 배열에 붙여주는 방식이다.</p>
<p>이 때도 역시 <strong>아래 방향 pagination의 완료 여부</strong>에 따라 분기 처리가 필요하다. 
(newMessageToastView 클릭 로직의 경우와 동일하다)</p>
<p>메세지를 전송하게 되면 결국 소켓으로도 해당 응답을 받기 때문에, 
소켓 응답에서는 내가 보낸 메세지를 필터링해주는 작업이 필요하다 (추후 정리)</p>
<table>
<thead>
<tr>
<th>case 1</th>
<th>case 2</th>
</tr>
</thead>
<tbody><tr>
<td><img src="https://velog.velcdn.com/images/s_sub/post/5a9afa15-8aef-44c0-858e-9224bf5c28e3/image.gif" alt=""></td>
<td><img src="https://velog.velcdn.com/images/s_sub/post/f2a16200-ff8d-4921-9e27-2cedc4ee3fa0/image.gif" alt=""></td>
</tr>
</tbody></table>
<br>

<p>경우의 수</p>
<ol>
<li>아래 방향 pagination 완료 o</li>
<li>아래 방향 pagination 완료 x</li>
</ol>
<br>

<pre><code>case 1.
1. (VM) chatArr.append(newChat)
2. (VC) tableView.reloadData()
3. (VC) tableView.scrollToBottom()
4. (VC) initInputView()


case 2. 
1. (VM) fetchAllNextChattingData()
2. (VM) chatArr.append(newChat)
3. (VC) tableView.reloadData()
4. (VC) tableView.scrollToBottom()
5. (VC) initInputView()</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[[새싹 iOS] 26주차_채팅 UI 구현]]></title>
            <link>https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-26%EC%A3%BC%EC%B0%A8</link>
            <guid>https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-26%EC%A3%BC%EC%B0%A8</guid>
            <pubDate>Wed, 10 Jan 2024 01:45:11 GMT</pubDate>
            <description><![CDATA[<h3 id="기술-스택">기술 스택</h3>
<p>UIKit, RxSwift
DispatchGroup
PHPicker, UIDocumentPicker, UIDocumentInteractionController
Remote Push Notification, NotificationCenter
RxDataSources, RxTableViewSectionedAnimatedDataSource</p>
<p><a href="https://github.com/limsub/Sooda4">Sooda 깃허브 레포</a></p>
<h1 id="ui">UI</h1>
<h2 id="1-chatting-input-view">1. Chatting Input View</h2>
<ul>
<li><p>채팅으로 전송할 데이터를 입력하는 곳
텍스트, 이미지, 파일 전송 가능</p>
<table>
<thead>
<tr>
<th align="center">No data <br>- button disabled</th>
<th align="center">only text <br>- 1 line</th>
<th align="center">only text <br>- 4 or more lines</th>
<th align="center">with files</th>
</tr>
</thead>
<tbody><tr>
<td align="center"><img src="https://velog.velcdn.com/images/s_sub/post/8a3f58ca-1eaa-462a-b80d-0f6c2e315fb7/image.png" alt=""></td>
<td align="center"><img src="https://velog.velcdn.com/images/s_sub/post/ffc9d67b-420c-4ba2-8daf-7bae4bb51c4c/image.png" alt=""></td>
<td align="center"><img src="https://velog.velcdn.com/images/s_sub/post/555aeb71-9159-413d-b8de-6fbe9f9ade41/image.png" alt=""></td>
<td align="center"><img src="https://velog.velcdn.com/images/s_sub/post/cba4ae1b-c4ae-43cd-abdf-6259394fd84e/image.png" alt=""></td>
</tr>
</tbody></table>
</li>
</ul>
<br>

<p><img src="https://velog.velcdn.com/images/s_sub/post/79f47462-5497-4827-97c7-ed87476ebb7c/image.jpeg" alt=""></p>
<h3 id="1-chattingtextview-uitextview">1. <code>chattingTextView: UITextView</code></h3>
<ul>
<li><p>텍스트 3줄까지 화면에 보이게 구현. 4줄 이상부터 스크롤 가능</p>
<table>
<thead>
<tr>
<th align="center">no file</th>
<th align="center">with files</th>
</tr>
</thead>
<tbody><tr>
<td align="center"><img src="https://velog.velcdn.com/images/s_sub/post/3f9cf4b0-848b-43be-96f4-4ca59205fa10/image.gif" alt=""></td>
<td align="center"><img src="https://velog.velcdn.com/images/s_sub/post/13964709-a762-4981-909a-e3450eb8fd99/image.gif" alt=""></td>
</tr>
</tbody></table>
<pre><code class="language-swift">// View
let chattingTextView = {
    let view = ChannelChattingTextView()
    view.isScrollEnabled = false    // 초기 스크롤 불가능 (4줄 이상부터 가능)
    return view
}()

override setConstraints() {
    [
        chattingTextView.topAnchor.constraint(equalTo: self.topAnchor, constant: 3.2),
        chattingTextView.leadingAnchor.constraint(equalTo: plusButton.trailingAnchor, constant: 8),
        chattingTextView.trailingAnchor.constraint(equalTo: sendButton.leadingAnchor, constant: -8),
        chattingTextView.heightAnchor.constraint(equalToConstant: 31.6),   
    ].forEach{ $0.isActive = true }

    // fileImageCollectionView의 유무에 따라 bottom Layout 결정
    chattingTextView.bottomAnchor.constraint(
        equalTo: self.bottomAnchor, constant: -3.2
    ).isActive = fileImageCollectionView.isHidden
}

</code></pre>
</li>
</ul>
<p>  // VC
  // TextView
  extension VC: UITextViewDelegate {</p>
<pre><code>  func textViewDidChange(_ textView: UITextView) {

      let size = CGSize(width: view.frame.width, height: .infinity)
      let estimatedSize = textView.sizeThatFits(size)

      // estimatedSize
      // 1줄일 때 31.6
      // 2줄일 때 47.3
      // 3줄일 때 62.6

      if estimatedSize.height &gt; 65 {
          textView.isScrollEnabled = true
          return
      } else {
          textView.isScrollEnabled = false

          // 레이아웃 중 height 수정
          textView.constraints.forEach { constraint in
              if constraint.firstAttribute == .height {
                  constraint.constant = estimatedSize.height
              }
          }
      }
  }</code></pre><p>  }</p>
<pre><code>
&lt;br&gt;

### 2. `fileCollectionView`
- 전송할 이미지 또는 파일 표시
- 이미지(.jpeg, .png, .jpg)는 썸네일 표시,
파일(.pdf, .zip, .mp3, ...)은 아이콘 표시
- x 버튼 눌러서 삭제 가능

&lt;br&gt;


### 3. `plusButton`
- 전송할 이미지 또는 파일 추가
- 이미지, 파일 합 최대 5개 전송 가능

  이미지 추가|파일 추가|둘 다 추가 + 삭제|
  :--:|:--:|:--:|
  ![](https://velog.velcdn.com/images/s_sub/post/9c9e38f3-50e8-4f7d-a3d4-f7108e3fe91d/image.gif)|![](https://velog.velcdn.com/images/s_sub/post/7885dab9-50ff-4357-ba22-85a57f0a0176/image.gif)|![](https://velog.velcdn.com/images/s_sub/post/dff50f32-4d52-471d-ae73-15eb630706e9/image.gif)|



&lt;br&gt;


- 이미지 추가 : **PHPicker** 
  ```swift
  extension VC: PHPickerViewControllerDelegate {

      func showPHPicker() {
          var configuration = PHPickerConfiguration()

          configuration.selectionLimit = 5
          configuration.filter = .images

          let picker = PHPickerViewController(configuration: configuration)
          picker.delegate = self
          present(picker, animated: true)
      }

      func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {

          if results.isEmpty { return }

          // 이미지 + 파일 합 최대 5개
          guard let curCnt = try? viewModel.fileData.value() else { return }
          let enableCnt = 5 - curCnt.count
          if results.count &gt; enableCnt {
              // show alert
              return
          }

          // 선택한 순서에 맞춰서 넣어주기 위해 미리 size 맞춰서 배열 선언
          var imageArr = Array(
              repeating: FileDataModel(
                  fileName: &quot;image.jpeg&quot;,
                  data: Data(),
                  fileExtension: .jpeg
              ),
              count: results.count
          )

          // 비동기 작업의 종료 시점 파악하기 위해 DispatchGroup 활용
          var group = DispatchGroup()

          for (index, item) in results.enumerated() {
              group.enter()
              let itemProvider = item.itemProvider
              if itemProvider.canLoadObject(ofClass: UIImage.self) {
                  itemProvider.loadObject(ofClass: UIImage.self) { [weak self] image , error  in

                      guard let image = image as? UIImage else { return }
                      guard let imageData = image.jpegData(compressionQuality: 0.01) else { return }     
                      imageArr[index].data = imageData
                      group.leave()

                  }
              }
          }
          picker.dismiss(animated: true)

          // 종료 시접에 VM 배열 업데이트
          group.notify(queue: .main) { [weak self] in
              guard var fileArr = try? self?.viewModel.fileData.value() else { return }
              fileArr.append(contentsOf: imageArr)
              self?.viewModel.fileData.onNext(fileArr)
          }

      }
  }</code></pre><br>

<ul>
<li><p>파일 추가 : <strong>UIDocumentPicker</strong></p>
<pre><code class="language-swift">extension VC: UIDocumentPickerDelegate {

    func showDocumentPicker() {

        let picker = UIDocumentPickerViewController(
            forOpeningContentTypes: [.pdf, .gif, .avi, .zip, .text, .mp3, .movie],
            asCopy: true
        )

        picker.delegate = self
        picker.allowsMultipleSelection = true
        present(picker, animated: true)
    }

    func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {

        // 이미지 + 파일 합 최대 5개
        guard let curCnt = try? viewModel.fileData.value() else { return }
        let enableCnt = 5 - curCnt.count
        if urls.count &gt; enableCnt {
            // show alert
            return
        }

        // 배열에 넣어줄 타입으로 변환
        var dataArr: [FileDataModel] = []
        for url in urls {
            // url : FileManager 주소
            if let fileName = url.absoluteString.extractFileName(),
               let fileExtension = url.absoluteString.fileExtension() {
                dataArr.append(
                    FileDataModel(
                        fileName: fileName,
                        data: (try? Data(contentsOf: url)) ?? Data(),
                        fileExtension: fileExtension
                    )
                )
            }
        }

        // VM 배열 업데이트
        guard var fileArr = try? viewModel.fileData.value() else { return }
        fileArr.append(contentsOf: dataArr)
        viewModel.fileData.onNext(fileArr)
    }

}</code></pre>
</li>
</ul>
<br>



<h3 id="4-sendbutton">4. <code>sendButton</code></h3>
<ul>
<li>텍스트 또는 이미지, 파일이 있을 때 버튼 활성화 (빈 값 전송 불가능)</li>
<li><a href="https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-27%EC%A3%BC%EC%B0%A8#4-send-message">채팅 데이터 전송 로직 실행</a></li>
</ul>
<br>



<h3 id="5-keyboard-appear--disappear">5. keyboard appear / disappear</h3>
<ul>
<li><p>기기 키보드가 올라오고 내려감에 따라 테이블뷰의 시점을 맞춰줌</p>
</li>
<li><p>최대한 상수를 쓰고 싶지 않았지만,, 일단 구현을 최우선으로 둠.. 
기기 대응 시 코드 수정 필요함</p>
</li>
<li><p>추후 구현해보고 싶은 점 : 카카오톡, 슬랙처럼 테이블뷰의 스크롤에 키보드 스크롤이 같이 적용되는 UI</p>
<table>
<thead>
<tr>
<th align="center">no data</th>
<th align="center">with data</th>
</tr>
</thead>
<tbody><tr>
<td align="center"><img src="https://velog.velcdn.com/images/s_sub/post/a3d65bd8-2bd6-4eac-8205-2b748a810998/image.gif" alt=""></td>
<td align="center"><img src="https://velog.velcdn.com/images/s_sub/post/29e71bf8-5678-4097-b25a-235af6bdc121/image.gif" alt=""></td>
</tr>
</tbody></table>
<pre><code class="language-swift">extension VC {
    // observer 등록
    private func startObservingKeyboard() {
        let notificationCenter = NotificationCenter.default

        notificationCenter.addObserver(
            forName: UIResponder.keyboardWillShowNotification,
            object: nil,
            queue: nil,
            using: keyboardWillAppear
        )

</code></pre>
</li>
</ul>
<pre><code>      notificationCenter.addObserver(
          forName: UIResponder.keyboardWillHideNotification,
          object: nil,
          queue: nil,
          using: keyboardWillDisappear
      )
  }

  // keyboard appear
  private func keyboardWillAppear(_ notification: Notification) {
      print(&quot;*** keyboardWillAppear ***&quot;)

      let key = UIResponder.keyboardFrameEndUserInfoKey
      guard let keyboardFrame = notification.userInfo?[key] as? CGRect else { return }

      // 스크롤을 이동할 높이
      let height = keyboardFrame.height - 83 

      let currentOffset = mainView.chattingTableView.contentOffset.y
      let newOffset = max(currentOffset + height, 0)

      // 키보드가 움직이는 시간 0.25 second (Keyboard Responder로 확인 가능)
      UIView.animate(withDuration: 0.25) {
          self.mainView.chattingTableView.setContentOffset(CGPoint(x: 0, y: newOffset), animated: false)
      }
  }

  private func keyboardWillDisappear(_ notification: Notification) {
      print(&quot;*** keyboardWillDisappear ***&quot;)

      let key = UIResponder.keyboardFrameEndUserInfoKey
      guard let keyboardFrame = notification.userInfo?[key] as? CGRect else { return }

      // 이슈 : keyboardFrame.height가 75 정도로 나온다.
      // &quot;일단&quot; keyboardWillAppear에서 확인한 값 상수로 선언

      let keyboardHeight: CGFloat = 336

      let height = keyboardHeight - 83

      let currentOffset = mainView.chattingTableView.contentOffset.y

      let newOffset = currentOffset - height

      UIView.animate(withDuration: 0.25) {
          self.mainView.chattingTableView.setContentOffset(CGPoint(x: 0, y: newOffset), animated: false)
      }
  }</code></pre><p>  }</p>
<pre><code>


- 참고 레퍼런스 : 카카오톡, 슬랙, 잔디

  카카오톡|슬랙|잔디|
  :--:|:--:|:--:|
  ![](https://velog.velcdn.com/images/s_sub/post/4a941ac7-f52c-4f3d-a5a5-74c883c85fca/image.gif)|![](https://velog.velcdn.com/images/s_sub/post/abd5689d-f4e6-468d-9b51-71de92030cb6/image.gif)|![](https://velog.velcdn.com/images/s_sub/post/896b6c81-71aa-4152-a08e-370ed5283cef/image.gif)|
  * 테이블뷰 스크롤 + 키보드 스크롤 맞물림|* 테이블뷰 스크롤 + 키보드 스크롤 맞물림|* 테이블뷰 스크롤과 키보드 상관 x|
  * 테이블뷰 탭 시 &lt;br&gt;키보드 disappear&lt;br&gt;+ 스크롤 시점 같이 이동 o|* 테이블뷰 탭 시 &lt;br&gt;상세페이지 이동&lt;br&gt;키보드 상관 x|* 테이블뷰 탭 시&lt;br&gt; 키보드 disappear &lt;br&gt;+ 스크롤 시점 이동 x|




&lt;br&gt;


## 2. Chatting TableView Cell
- 채팅을 통해 받을 수 있는 데이터는 크게 다음과 같다. 
    1. 텍스트
      2. 사진
      3. 파일
    각 데이터의 유무에 따라 셀 디자인이 달라진다. - **stackView 활용**

![](https://velog.velcdn.com/images/s_sub/post/fb8131d1-10d1-4972-a37d-cbd6c3232840/image.jpeg)

![](https://velog.velcdn.com/images/s_sub/post/633e9266-cd31-45d8-8747-997bfb22c835/image.jpeg)


![](https://velog.velcdn.com/images/s_sub/post/78840327-4f55-4f1b-a60e-b926d036bcc1/image.jpeg)




### 1. 텍스트 - `UILabel`
- 텍스트가 써있는 UILabel과, 테두리 역할을 하는 UIView를 만들어서 구현

&lt;br&gt;

### 2. 이미지 - `ChannelChattingCellContentImageSetView`
- 이미지 개수는 최대 5장까지 가능하며, 개수에 따라 레이아웃이 다르다.
- 뷰 내에 imageView를 미리 5개 만들어두고, 들어오는 이미지 배열의 count에 따라 다른 레이아웃 함수를 실행한다.
![](https://velog.velcdn.com/images/s_sub/post/4a842407-8afe-471b-8e26-c0b864954a52/image.jpeg)



### 3. 파일 - `FileContentView`
- 파일 개수 역시 최대 5개까지 가능하다. 
- 미리 인스턴스 5개 만들어두고, `stackView.addArrangedSubView(view)`
- 파일 개수에 따라 Hidden 처리
- 파일 확장자에 따라 다른 아이콘 (`UIImageView`) + 파일 이름 (`UILabel`)


- 뷰 클릭 시 **UIDocumentInteractionController** 이용해서 preview 화면 - **delegate pattern 활용**

  .pdf|.zip
  :--:|:--:
  ![](https://velog.velcdn.com/images/s_sub/post/238a524f-d777-422c-a0d9-815c0be08022/image.gif)|![](https://velog.velcdn.com/images/s_sub/post/27222b05-c688-4ceb-8a40-ef3dc5b091f4/image.gif)

  ```swift
  // VC
  func downloadAndOpenFile(_ fileURL: String) {
      // fileURL : 서버에 저장된 파일 주소

      // 1. 네트워크 통신으로 파일 Data 다운
      NetworkManager.shared.requestCompletionData(
          api: .downLoadFile(fileURL)) { response in
              switch response {
              case .success(let data):
                  print(data)

                  // 마지막 &#39;/&#39; 기준 뒤 문자열이 파일 이름
                  guard let fileName = fileURL.extractFileName() else { return }

                  // Document 내부 경로 설정
                  let fileManager = FileManager()
                  let documentPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent(&quot;\(fileName)&quot;)

                  do {
                      try data.write(to: documentPath)
                  } catch {
                      print(error)
                  }

                  // 파일 오픈
                  DispatchQueue.main.async {
                      self.interaction = UIDocumentInteractionController(url: documentPath)
                      self.interaction?.delegate = self
                      self.interaction?.presentPreview(animated: true)
                  }

              case .failure(let networkError):
                  print(&quot;에러 발생 : \(networkError)&quot;)
              }
          }
  }</code></pre><br>



<h2 id="3-update-chatting-list-view">3. Update Chatting List View</h2>
<ul>
<li>채팅 리스트들을 볼 수 있는 화면에서도 실시간 채팅에 대한 대응을 해주어야 한다. (가장 최신 채팅 내용이 화면에 나타나야 한다)</li>
<li>하지만 그렇다고 모든 채팅방에 대해 소켓을 열어두고, 실시간 채팅에 대한 응답을 처리하는 건 비효율적이다.</li>
<li>그래서 <strong>push notification</strong> 을 이용했다.<ul>
<li>push로 받은 데이터를 확인해서, 실시간으로 화면에 최신 채팅을 보여준다</li>
</ul>
</li>
<li>이 과정에서 자연스러운 애니메이션을 보여주기 위해 <strong>RxDataSource</strong>의 <strong>RxTableViewSectionedAnimatedDataSource</strong>를 이용했다.</li>
</ul>
<table>
<thead>
<tr>
<th align="center">기본 Chatting List View</th>
<th align="center">실시간 채팅이 왔을 때 대응</th>
</tr>
</thead>
<tbody><tr>
<td align="center"><img src="https://velog.velcdn.com/images/s_sub/post/7d41b00b-a77c-4f06-b0d1-6f502c75924a/image.gif" alt=""></td>
<td align="center"><img src="https://velog.velcdn.com/images/s_sub/post/8540f28d-3460-412e-a644-17ea6602d2bb/image.gif" alt=""></td>
</tr>
</tbody></table>
<br>

<ul>
<li><h4 id="rxdatasource-tableview-구현"><code>RxDataSource tableView 구현</code></h4>
<pre><code class="language-swift">// VC
func bind() {
    // DMListTableView - RxDataSource
    let dataSource = RxTableViewSectionedAnimatedDataSource&lt;DMListSectionData&gt;(
        animationConfiguration: AnimationConfiguration(
            insertAnimation: .fade,
            reloadAnimation: .fade,
            deleteAnimation: .fade
        )
    ) { data, tableView, indexPath, item in
        guard let cell = tableView.dequeueReusableCell(withIdentifier: DMListTableViewCell.description(), for: indexPath) as? DMListTableViewCell else { return UITableViewCell() }
        cell.designCell(item)
        return cell
    }

    output.dmRoomSectionsArr
        .bind(to: mainView.dmListTableView.rx.items(dataSource: dataSource))
        .disposed(by: disposeBag)
}

</code></pre>
</li>
</ul>
<p>  // VM
  let dmRoomSectionArr = BehaviorSubject&lt;[DMListSectionData]&gt;(value: [])
  // 타입은 배열이긴 하지만, 실질적인 배열의 크기는 1</p>
<p>  // Model
  struct DMListSectionData {
      var header: String
      var items: [Item]
  }</p>
<p>  extension DMListSectionData: AnimatableSectionModelType {
      typealias Item = DMChattingCellInfoModel
      typealias Identity = String</p>
<pre><code>  var identity: String {
      return header
  }

  init(original: DMListSectionData, items: [DMChattingCellInfoModel]) {
      self = original
      self.items = items
  }</code></pre><p>  }</p>
<p>  struct DMChattingCellInfoModel {
      let roomId: Int
      let userInfo: UserInfoModel
      var lastContent: String
      var lastDate: Date
      var unreadCount: Int
  }</p>
<pre><code>

- #### `Push Notification 응답`
  ```swift
  // AppDelegate
  func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -&gt; Void) {
      // 포그라운드 상태에서 알림 받기

      guard let userInfo = notification.request.content.userInfo as? [String: Any] else { return }

      /* ... */

      // 2. 디엠 채팅인 경우
      if let dmChatInfo: PushDMChattingDTO = self.decodingData(userInfo: userInfo) {    // 디코딩 메서드 따로 구현

          // 현재 보고 있는 채팅방은 아닌지 확인 (현재 접속한 채팅방의 채팅은 푸시 알림 x)
          if !self.checkCurrentDMRoom(chatInfo: dmChatInfo) {

              // 푸시 알림
              completionHandler([.list, .badge, .sound, .banner])

              // NotificationCenter 이용해서 DMListView에 새로운 채팅이 왔음을 알림
              let userInfo: [String: Any] = [
                  &quot;workspaceId&quot;: Int(dmChatInfo.workspace_id)!,
                  &quot;opponentId&quot;: Int(dmChatInfo.opponent_id)!,
                  &quot;content&quot;: dmChatInfo.aps.alert.body,
                  &quot;opponentName&quot;: dmChatInfo.aps.alert.title
              ]

              NotificationCenter.default.post(
                  name: Notification.Name(&quot;receiveDMChattingPushNotification&quot;),
                  object: nil,
                  userInfo: userInfo
              )
          }
      }
  }


  // VM
  func transform(_ input: Input) -&gt; Output {

      /* ... */

      // Observer 등록
      NotificationCenter.default.addObserver(
          self,
          selector: #selector(receiveDMChattingPushNotification),
          name: Notification.Name(&quot;receiveDMChattingPushNotification&quot;),
          object: nil
      )
  }

  @objc private func receiveDMChattingPushNotification(_ notification: Notification) {

      if let userInfo = notification.userInfo,
         let opponentId = userInfo[&quot;opponentId&quot;] as? Int,
         let workspaceId = userInfo[&quot;workspaceId&quot;] as? Int,
         let content = userInfo[&quot;content&quot;] as? String,
         let opponentName = userInfo[&quot;opponentName&quot;] as? String
      {
          // VM에서 가지고 있는 dmRoomSectionArr 중, 해당되는 채팅방을 찾고, (opponent id 이용)
          // 해당 채팅을 배열에서 맨 앞으로 옮긴다 (remove -&gt; insert)

          do {
              var newArr = try self.dmRoomSectionsArr.value()    // 새롭게 onNext로 넣어줄 배열

              var targetIndex: Int = 0    // 해당되는 채팅의 index

              for i in 0..&lt;newArr[0].items.count {
                  if newArr[0].items[i].userInfo.userId == opponentId {
                      targetIndex = i
                      break
                  }
              }

              // 새롭게 업데이트될 채팅방 정보
              var newItem = newArr[0].items.remove(at: targetIndex)
              newItem.lastDate = Date()
              newItem.lastContent = content
              newItem.unreadCount += 1

              // 1. remove 후 onNext (애니메이션 때문)
              self.dmRoomSectionsArr.onNext(newArr)

              // 2. insert 후 onNext (애니메이션 때문)
              newArr[0].items.insert(newItem, at: 0)
              self.dmRoomSectionsArr.onNext(newArr)

          } catch {
              print(&quot;Error&quot;)
          }
      }
  }</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[[새싹 iOS] 25주차_Coordinator Pattern 적용기]]></title>
            <link>https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-25%EC%A3%BC%EC%B0%A8</link>
            <guid>https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-25%EC%A3%BC%EC%B0%A8</guid>
            <pubDate>Mon, 08 Jan 2024 02:40:42 GMT</pubDate>
            <description><![CDATA[<h4 id="sooda">Sooda</h4>
<ul>
<li>UIKit, RxSwift</li>
<li>Clean Architecture, MVVM-C</li>
<li><a href="https://github.com/limsub/Sooda4">Sooda 깃허브 레포</a></li>
</ul>
<h2 id="1-개요">1. 개요</h2>
<h3 id="1-선택한-이유">1. 선택한 이유</h3>
<ul>
<li>VC에서 화면 전환에 대한 로직을 떼어내기 위해</li>
<li>보다 독립적으로 VC 인스턴스 관리 (이전/다음 화면을 알지 못하게 함)</li>
</ul>
<br>

<h3 id="2-과정">2. 과정</h3>
<ul>
<li>여러 레퍼런스에서 대부분 비슷한 Coordinator 클래스를 사용하고 있어서 처음 학습하는 데 큰 어려움은 없었다.</li>
<li>다만, 탭바 코디네이터 관련해서는 구조가 헷갈리는 부분이 있어서 <a href="https://somevitalyz123.medium.com/coordinator-pattern-with-tab-bar-controller-33e08d39d7d">Coordinator pattern with Tab Bar Controller</a> 이 글의 도움을 많이 받았다</li>
<li>코디네이터 클래스 자체에 대한 이해보다는 화면 전환 플로우 방식을 이해하는 데 시간을 많이 소요했다.</li>
<li>코디네이터 패턴을 구현하면서 내가 필요하다고 느끼는 부분에 대해서는 코드를 추가했다.</li>
</ul>
<br>


<h2 id="2-코드">2. 코드</h2>
<h3 id="1-protocol-coordinator"><code>1. protocol Coordinator</code></h3>
<ol>
<li>가장 최상위 코디네이터인 <code>AppCoordinator</code> 를 제외하고 모두 부모 코디네이터를 갖는다</li>
<li>각 코디네이터는 하나의 <code>navigationController</code> 를 갖는다.<ul>
<li>두 개 이상을 갖지 못하도록 스스로(?) 규칙을 정했다.</li>
</ul>
</li>
<li>현재 살아있는(?) 자식 코디네이터들을 저장한 배열이 있다.<ul>
<li>탭바 코디네이터를 제외하고는 대부분 단 하나의 자식 코디네이터만 살아있다.</li>
</ul>
</li>
<li>모든 코디네이터의 타입은 <code>enum CoordinatorType</code> 의 case에 존재하고, 각 코디네이터마다 본인의 타입을 저장하는 프로퍼티가 있다.<ul>
<li>코디네이터가 종료될 때, 부모 코디네이터에서 자식 코디네이터 배열을 초기화할 때 사용한다.</li>
</ul>
</li>
<li>새로운 코디네이터가 생성된다는 건 결국 새로운 화면이 나타난다는 뜻이기 때문에, 첫 번째로 어떤 화면 또는 어떤 플로우가 실행될지 정의해주어야 한다.</li>
<li>현재 코디네이터가 종료될 때 다음 타겟 코디네이터를 정하고 종료되어야 한다.<ul>
<li>모든 코디네이터에 대해 동일하기 때문에, <code>extension Coordinator</code> 에서 메서드를 선언한다.</li>
</ul>
</li>
</ol>
<pre><code class="language-swift">  protocol Coordinator: AnyObject {

      // 1. 부모 코디네이터
      var finishDelegate: CoordinatorFinishDelegate? { get set }

      // 2. 각 코디네이터는 하나의 nav를 갖는다
      var navigationController: UINavigationController { get set }
      init(_ navigationController: UINavigationController)

      // 3. 현재 살아있는 자식 코디네이터 배열.
      var childCoordinators: [Coordinator] { get set }

      // 4. Flow 타입
      var type: CoordinatorType { get }

      // 5. Flow 시작 시점 로직
      func start()

      // 6. Flow 종료 시점 로직. (extension에서 선언)
      func finish(_ nextFlow: ChildCoordinatorTypeProtocol?)
  }

  extension Coordinator {
      func finish(_ nextFlow: ChildCoordinatorTypeProtocol?) {
          // 1. 자식 코디 다 지우기
          childCoordinators.removeAll()

          // 2. 부모 코디에게 알리기
          finishDelegate?.coordinatorDidFinish(
              childCoordinator: self,
              nextFlow: nextFlow  
          )
      }
  }</code></pre>
<br>

<h3 id="2-protocol-coordinatorfinishdelegate-anyobject"><code>2. protocol CoordinatorFinishDelegate: AnyObject</code></h3>
<ul>
<li>자식 코디네이터가 종료된 경우, 부모 코디네이터가 이를 알아야 하기 때문에 delegate pattern 을 이용해서 알려준다.</li>
<li>만약 자식 코디네이터가 없는 경우, 이 프로토콜을 채택하지 않는다.</li>
<li>알려줄 때, 2가지 정보를 전달한다<ol>
<li>종료된 자식 코디네이터의 타입</li>
<li>다음에 실행되어야 할 코디네이터의 타입<pre><code class="language-swift">protocol CoordinatorFinishDelegate: AnyObject {
func coordinatorDidFinish(childCoordinator: Coordinator, nextFlow: ChildCoordinatorTypeProtocol?)
}

</code></pre>
</li>
</ol>
</li>
</ul>
<p>// ex.
extension AppCoordinator: CoordinatorFinishDelegate {</p>
<pre><code>func coordinatorDidFinish(
    childCoordinator: Coordinator,
    nextFlow: ChildCoordinatorTypeProtocol?
) {
    // 1. 자식 코디 배열 초기화 (사실상 하나밖에 없었기 때문에 빈 배열이 된다)
    childCoordinators = childCoordinators.filter { $0.type != childCoordinator.type }
    // 2. nav에 연결된 VC 모두 제거
    navigationController.viewControllers.removeAll()

    // 3. 다음 타겟 코디 실행
    // 3 - 0
    if nextFlow == nil { }

    // 3 - 1. Child 코디인 경우
    else if let nextFlow = nextFlow as? ChildCoordinatorType {
        /* ... */
    }

    // 3 - 2. Child 코디보다 더 하위 코디인 경우 -&gt; 직접 특정해야 한다 
    // ex). 탭바의 자식 코디인 경우
    else if let nextFlow = nextFlow as? TabBarCoordinator.ChildCoordinatorType {
        /* ... */
    }

    // 3 - 3. 부모 코디를 타고 가야 하는 경우
    else {
        self.finish(nextFlow)
    }</code></pre><p>}</p>
<pre><code>
&lt;br&gt;

### `3. protocol ChildCoordinatorTypeProtocol`
- 모든 부모 코디네이터는 자식 코디네이터의 정보를 `enum` 으로 저장한다.
- 코디네이터가 종료되는 시점에서, target 코디네이터의 정보를 전달할 때 이 `enum` 으로 다음 코디네이터를 특정한다.
- 따라서 `coordinatorDidFinish` 메서드에서는 모든 자식 코디네이터를 매개변수로 받을 수 있어야 하고, 이를 위해 이 프로토콜을 선언한다.
- 모든 부모 코디네이터에서 저장하는 자식 코디네이터 `enum` 은 이 프로토콜을 채택한다

```swift
protocol ChildCoordinatorTypeProtocol {
}


// ex.
// AppCoordinator
extension AppCoordinator {
    enum ChildCoordinatorType: ChildCoordinatorTypeProtocol {
        case splash
        case loginScene
        case tabBarScene
        case homeEmptyScene
    }
}</code></pre><br>

<h3 id="4-coordinatortype"><code>4. CoordinatorType</code></h3>
<ul>
<li><p>앱 내에 있는 모든 코디네이터의 종류</p>
</li>
<li><p>각 코디네이터별로 프로퍼티로 어떤 종류인지 저장하고 있다</p>
<pre><code class="language-swift">enum CoordinatorType {
  case app
  case splash, loginScene, homeEmptyScene, tabBarScene

  /* ... */
}</code></pre>
</li>
</ul>
<br>

<h2 id="3-내가-적용한-코디네이터-패턴">3. 내가 적용한 코디네이터 패턴</h2>
<h3 id="1-새로운-코디네이터를-만드는-기준">1. 새로운 코디네이터를 만드는 기준</h3>
<ul>
<li>결론부터 말하면, 내가 정한 새로운 코디네이터를 만드는 기준은 <strong>새로운 navigationController가 필요한 경우</strong> 이다.</li>
</ul>
<br>


<h4 id="트러블-슈팅">트러블 슈팅</h4>
<ul>
<li>이 기준이 없을 때는, 대강 하나의 플로우는 하나의 코디로 관리하면 되겠지~ 라고 생각하고 구현을 시작했다.</li>
<li>이렇게 시작하고, 바로 멈추게 되는 지점이 있었는데, <strong>연속으로 화면 present가 실행되는 경우</strong> 이다.
(이 때, present를 실행하는 객체는 코디의 navigationController 이다)
<img src="https://velog.velcdn.com/images/s_sub/post/dda294dd-f0aa-48c9-8d48-0edf015fe02a/image.jpeg" alt=""><ol>
<li>Onboarding View를 관리하는 코디에서 SelectAuth View present</li>
<li>SelectAuth View를 관리하는 코디에서 EmailLogin View present</li>
</ol>
</li>
</ul>
<ul>
<li>위 과정은 하나의 Coordinator에서 진행할 수 없다. <ul>
<li>하나의 VC는 반드시 하나의 VC만 present 할 수 있다 (multiple 불가능)</li>
<li>즉, 코디는 nav가 하나만 있기 때문에 present로 화면 전환을 할 수 있는 건 단 한 번 뿐이다.</li>
</ul>
</li>
</ul>
<ul>
<li>결국, 위와 같은 플로우에서는 추가적인 코디가 필요할 수밖에 없다...<ul>
<li>새로운 VC를 present시켜줄 코디의 nav가 필요하기 때문이다</li>
</ul>
</li>
</ul>
<ul>
<li>그래서 1번 과정에서는 새로운 코디를 생성하고, 그 코디의 nav를 present하는 방식으로 구현했다.</li>
<li><em>새로운 navigationController가 필요한 경우*</em></li>
</ul>
<ul>
<li>다만 2번 과정에서는 새로운 코디를 생성할 필요가 없다. EmailLogin VC의 navigationController는 따로 하는 일이 없기 때문이다. 
즉, <strong>새로운 navigationController가 필요 없다</strong></li>
</ul>
<ul>
<li>이게 내가 세운 <strong>새로운 코디네이터를 만드는 기준</strong> 이다. 
만약 EmailLoginView의 nav에서 push나 present 등 추가로 해야 하는 일이 있다면, 이 역시 새로운 코디를 생성해야 할 것이다.</li>
</ul>
<br>

<h4 id="코드-정리">코드 정리</h4>
<pre><code class="language-swift">// LoginSceneCoordinator (Onboarding View)
class LoginSceneCoordinator: LoginSceneCoordinatorProtocol {

    /* ... */

    func showSelectAuthFlow() {
        // TODO: selectAuthFlow 시작. present로 진행. 별도의 navigationController 주입

        // 새로 만드는 nav는 어차피 여기서 관리하지는 않을거고, 새로운 코디에서 관리할 예정이기 때문에 현재 코디에서 따로 변수로 가지고 있을 필요는 없다
        // 단지, 현재 코디와 다른 nav를 가질 수 있도록 주입해주는 것 뿐임
        let nav = UINavigationController()
        let selectAuthCoordinator = SelectAuthCoordinator(nav)
        selectAuthCoordinator.finishDelegate = self
        childCoordinators.append(selectAuthCoordinator)
        selectAuthCoordinator.start()   // 코디의 첫 화면 세팅

        // 여기서 직접 present 실행
        navigationController.present(selectAuthCoordinator.navigationController, animated: true)
    }
}


// SelectAuthCoordinator (SelectAuth View)
class SelectAuthCoordinator: SelectAuthCoordinatorProtocol {

    /* ... */

    func start() {
        showSelectAuthView()
    }

    // 초기 화면
    func showSelectAuthView() {
        let selectAuthVM = /* ... */
        let selectAuthVC = /* ... */
        selectAuthVM.didSendEventClosure = /* ... */

        navigationController.pushViewController(selectAuthVC, animated: false)
    }

    // present로 EmailLoginView (추가적인 nav가 필요 없기 때문에 여기서 present 실행)
    func showEmailLoginView() {
        let emailLoginVM = /* ... */
        let emailLoginVC = /* ... */
        emailLoginVM.didSendEventClosure = /* ... */

        // nav는 화면에 보이는 것 외에 다른 기능은 없다
        let nav = UINavigationController(rootViewController: emailLoginVC)
        navigationController.present(nav, animated: true)
    }
}</code></pre>
<br>

<h3 id="2-코디네이터-종료-시-다음-타겟-코디네이터-지정">2. 코디네이터 종료 시, 다음 타겟 코디네이터 지정</h3>
<ul>
<li><p>내가 참고한 코디네이터 레퍼런스에서는
코디네이터가 finish로 종료되면,
부모 코디에서 그 코디의 타입에 따라 다음 코디를 정하고, 이를 실행시켰다.</p>
</li>
<li><p>하지만 하나의 코디가 종료될 때 상태에 따라 서로 다른 코디가 실행될 가능성이 있기 때문에 추가적인 코드의 필요성을 느꼈다.</p>
</li>
</ul>
<br>

<h4 id="1-child--타겟-지정-후-finish">1. Child : 타겟 지정 후 finish</h4>
<ul>
<li>모든 코디네이터에 대해 <code>finish</code> 메서드는 <code>extension Coordinator</code>에서 미리 선언이 되어있다.
여기서 delegate pattern을 이용해 다음 타겟 코디를 매개변수로 넣어서 메서드를 실행한다</li>
</ul>
<ul>
<li><p>이 때, 매개변수로 넣는 타겟 코디는 </p>
</li>
<li><p><em>타겟코디의_부모코디.ChildCoordinatorType.타겟코디*</em> 형식으로 넣어준다.</p>
<ul>
<li><p>모든 부모코디는 <code>ChildCoordinatorType</code> 으로 본인의 자식 코디 종류를 저장하고 있다.</p>
<pre><code class="language-swift">extension TabBarCoordinator {
    enum ChildCoordinatorType: ChildCoordinatorTypeProtocol {
    case homeDefaultScene(workspaceId: Int)
    case dmScene(workspaceID: Int)
    case searchScene, settingScene
}   </code></pre>
</li>
<li><p>즉,다음 타겟 코디를 넘겨줄 때는, 그 부모 코디가 누구인지도 알아야 한다.</p>
<pre><code class="language-swift">self?.finish(
  TabBarCoordinator.ChildCoordinatorType.homeDefaultScene(workSpaceId: workSpaceId)
)</code></pre>
</li>
<li><p>모든 코디의 <code>finish</code> 메서드는 동일</p>
<pre><code class="language-swift">extension Coordinator {
  func finish(_ nextFlow: ChildCoordinatorTypeProtocol?) {
      // 1. 자식 코디 다 지우기
      childCoordinators.removeAll()

      // 2. 부모 코디에게 알리기
      finishDelegate?.coordinatorDidFinish(
          childCoordinator: self,
          nextFlow: nextFlow  // 다음 타겟 코디
      )
  }
}</code></pre>
</li>
</ul>
</li>
</ul>
<br>

<h4 id="2-parent--didfinsih로-종료된-코디와-다음-코디-확인">2. Parent : didFinsih로 종료된 코디와 다음 코디 확인</h4>
<ul>
<li>결과적으로 자식 코디가 종료될 때, 부모 코디에서 전달받는 값은</li>
<li><em>자식 코디의 타입 (CoordinatorType)*</em> 과</li>
<li><em>다음 타겟 코디 (ChildCoordinatorTypeProtocol)*</em> 이다.</li>
</ul>
<ul>
<li>자식 코디의 타입은 코디의 자식 코디 배열을 초기화하는 용도로 사용한다.<pre><code class="language-swift">childCoordinators = childCoordinators.filter { $0.type != childCoordinator.type }</code></pre>
</li>
</ul>
<ul>
<li>다음 타겟 코디는 분기 처리가 필요하다
<img src="https://velog.velcdn.com/images/s_sub/post/2eaa0b3d-9e33-496a-bd3e-daaafe41f5e7/image.jpeg" alt=""><ul>
<li>위 구조에서, A 코디네이터가 종료된 상황을 생각해보자</li>
<li>그럼 P2 코디에서 <code>didFinish</code> 가 실행될 것이고, 다음 타겟 코디를 실행시켜주어야 한다. </li>
<li>분기 케이스는 총 4개이다
  <strong>1. 타겟 코디가 없는 경우</strong>
  <strong>2. P2의 Child 코디인 경우 (ex. C)</strong>
<strong>3. P2의 Child 코디보다 하위 코디인 경우 (ex. D)</strong>
<strong>4. P2의 Parent 코디를 타고 가야 하는 경우 (ex. B, P3)</strong></li>
</ul>
</li>
</ul>
<br>

<p>*<em>1. 타겟 코디가 없는 경우 *</em></p>
<ul>
<li>nextFlow가 nil인 경우. 따로 작업할 내용 없다<pre><code class="language-swift">if nextFlow == nil { }</code></pre>
</li>
</ul>
<p><strong>2. Child 코디인 경우</strong></p>
<ul>
<li>해당 플로우를 시작하는 메서드 실행<pre><code class="language-swift">else if nextFlow = nextFlow as? ChildCoordinatorType {
    /* ... */
}</code></pre>
</li>
</ul>
<p><strong>3. Child 코디보다 더 하위 코디인 경우</strong></p>
<ul>
<li>직접 특정해서 정확히 누구인지 알아야 한다<pre><code class="language-swift">ex). 탭바코디의 자식코디
else if let nextFlow = nextFlow as? TabBarCoordinator.ChildCoordinatorType {
    /* ... */
}</code></pre>
</li>
</ul>
<p><strong>4. Parent 코디를 타고 가야 하는 경우</strong></p>
<ul>
<li>현재 코디네이터도 종료시키고, 타겟 코디를 그대로 부모에게 전달한다<pre><code class="language-swift">else {
    self.finish(nextFlow)
}</code></pre>
</li>
</ul>
<br>

<h3 id="3-커스텀-얼럿-vc을-띄우는-위치">3. 커스텀 얼럿 VC을 띄우는 위치</h3>
<ul>
<li>프로젝트에서 자주 사용되는 커스텀 얼럿을 ViewController로 구현하였다.
<img src="https://velog.velcdn.com/images/s_sub/post/e150d419-9ad1-443b-bb98-be91ac31a149/image.jpeg" alt=""></li>
</ul>
<ul>
<li>처음에는 위 얼럿도 하나의 VC이기 때문에 코디네이터에서 띄워주어야 한다고 생각했다.</li>
<li>그래서 코디네이터에서 얼럿 창을 띄우는 메서드를 구현했다.</li>
</ul>
<ul>
<li>그럼 얼럿 창의 버튼 클릭 시 네트워크 통신이 필요한 경우, </li>
<li><em>코디네이터에서 네트워크 통신을 진행해야 한다....*</em><ul>
<li>물론 버튼 클릭 후 화면 전환을 바로 진행할 수 있는 건 좋지만,</li>
<li>화면 전환 로직이 목적인 코디에서 네트워크 콜을 하고 있는 건 절대 좋지 않다.</li>
</ul>
</li>
</ul>
<ul>
<li>그래서 이 이슈 이후에는 얼럿 창 정도는 VC에서 띄워주기로 결정했다. 버튼 클릭 시 네트워크 통신까지는 해당 VM의 역할이라고 판단했다. 이후 화면 전환이 필요한 경우에만 Coordinator에게 event로 알려준다.</li>
</ul>
<br>

<h3 id="4-coordinator와-vm의-통신">4. Coordinator와 VM의 통신</h3>
<ul>
<li>화면 전환이 필요한 시점에, VM은 Coordinator에게 이 사실을 알려주어야 한다.</li>
<li>생각나는 방법은 두 가지였다. <strong>클로저</strong>와 <strong>delegate</strong></li>
</ul>
<br>

<h4 id="delegate를-통해-이벤트-전달">delegate를 통해 이벤트 전달</h4>
<ul>
<li>이를 구현하기 위해서는 프로토콜을 통해 <strong>VM에서 코디네이터를 알고 있어야 한다</strong></li>
<li>결국, 해당 VM에서는 무조건 하나의 코디네이터를 특정해서 알고 있어야 하고, 해당 코디에서만 실행되는 이벤트를 전달해야 한다. <strong>중심이 코디</strong></li>
<li>하지만, 어떤 View는 여러 코디네이터에서 사용되기도 한다. 이 때, VM이 하나의 코디만 알고 있게 구현한다면 다른 코디에서는 VM에게 이벤트를 전달받지 못하게 된다.</li>
<li>물론, 여러 코디를 알고 있는 VM에게 이벤트를 받기 위해 한 번 더 프로토콜을 추상화할 수는 있지만, 너무 비효율적이고 유지보수에 좋아 보이지는 않는다.</li>
</ul>
<br>

<h4 id="closure를-통해-이벤트를-전달">closure를 통해 이벤트를 전달</h4>
<ul>
<li>클로저를 통해 이벤트를 전달하면, <strong>중심이 VM</strong> 이 된다.</li>
<li>VM은 본인이 실행할 이벤트를 실행하면 되고, 해당 VM에 연결된 코디에서 각 이벤트별로 화면 전환을 실행한다.</li>
</ul>
<ul>
<li>이를 위해 모든 VM에는 어떤 화면으로 전환될지에 대한 <code>enum Event</code> 가 존재한다.<pre><code class="language-swift">// HomeDefaultVM
extension HomeDefaultViewModel {
  enum Event {
      case presentWorkSpaceListView(workSpaceId: Int)
      case presentInviteMemberView
      case presentMakeChannelView
      case goExploreChannelFlow        
      case goChannelChatting(workSpaceId: Int, channelId: Int, channelName: String)
      case goBackOnboarding
  }
}
</code></pre>
</li>
</ul>
<p>// HomeDefaultCoordinator - showHomeDefaultView
class HomeDefaultSceneCoordinator: HomeDefaultSceneCoordinatorProtocol {</p>
<pre><code>/* ... */

func showHomeDefaultView(_ workSpaceId: Int) {
    let homeDefaultVM = /* ... */
    homeDefaultVM.didSendEventClosure = { [weak self] event in
        switch event {
        case .presentWorkSpaceListView(let workSpaceId):
            self?.showWorkSpaceListFlow(workSpaceId: workSpaceId)

        case .presentInviteMemberView:
            self?.showInviteMemberView()

        case .presentMakeChannelView:
            self?.showMakeChannelView()

        case .goExploreChannelFlow:
            self?.showExploreChannelFlow(workSpaceId: (self?.workSpaceId)!)

        case .goChannelChatting(let workSpaceId, let channelId, let channelName):
            self?.showChannelChattingView(
                workSpaceId: workSpaceId,
                channelId: channelId,
                channelName: channelName
            )

        case .goBackOnboarding:  
            self?.finish(AppCoordinator.ChildCoordinatorType.loginScene)
        }
    }
    let vc = HomeDefaultViewController.create(with: homeDefaultVM)
    navigationController.pushViewController(vc, animated: true)
}</code></pre><p>}</p>
<pre><code>

&lt;br&gt;

### 5. 특정 화면으로 즉시 이동하기 (Push Notification 클릭)

[Push Notification 설정](https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-23%EC%A3%BC%EC%B0%A8#3-push-notification-%EC%84%A4%EC%A0%95)

&lt;br&gt;

## 4. 후기
### 1. 장점
#### VM에서 VC로 굳이 화면 전환 시점을 알려줄 필요 없음
- 기존에는 VC에서 화면 전환을 담당했기 때문에
VM에서 특정 시점이 되면 VC로 결과를 알려주어야 했다. (Output)

- 코디네이터를 쓴 이후에는 이 과정 자체가 필요가 없다. VC는 더 이상 화면 전환을 담당하지 않기 때문에 VM에서는 VC에게 시점을 알리지 않고, 바로 클로저를 통해 코디에게 시점을 알려준다.


- 단순히 VC의 코드가 줄었다고만 생각했는데, Output으로 VC에게 이벤트를 전달하는 코드도 줄어들었다.


&lt;br&gt;

#### VC의 독립성
- 물론 대부분의 화면은 이전/다음 화면이 명확하기 때문에 VC에서 이 흐름을 알고 있더라도 큰 문제는 없다.
- 그래도 코디네이터를 이용하면 각 VC는 이전 화면이 뭐였는지, 다음 화면은 뭐가 될 지 전혀 알지 못하기 때문에 보다 독립적인 인스턴스가 될 수 있다.

&lt;br&gt;

### 2. 단점
- 만약 연속된 present가 많고, present로 올라온 뷰에서도 계속 화면 전환이 필요하다면, 그만큼 코디네이터가 또 필요하게 된다. **(코디네이터를 새로 만드는 기준)**


- 이러면 하나의 코디네이터가 거의 하나의 View만 관리하게 되기 때문에 굳이 코디네이터를 쓸 필요가 없다고 생각했다.

- 물론 화면 전환에 대한 로직을 분리시킬 수는 있지만, 이 정도를 위해 매번 코디네이터를 새로 만드는 건 리소스 낭비라고 생각이 든다.</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[[새싹 iOS] 24주차_Clean Architecture 적용기]]></title>
            <link>https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-24%EC%A3%BC%EC%B0%A8</link>
            <guid>https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-24%EC%A3%BC%EC%B0%A8</guid>
            <pubDate>Sun, 24 Dec 2023 01:17:16 GMT</pubDate>
            <description><![CDATA[<h4 id="sooda">Sooda</h4>
<ul>
<li>UIKit, RxSwift</li>
<li>Clean Architecture, MVVM-C</li>
<li><a href="https://github.com/limsub/Sooda4">Sooda 깃허브 레포</a></li>
</ul>
<br>

<h2 id="1-개요">1. 개요</h2>
<h3 id="1-선택-이유">1. 선택 이유</h3>
<ul>
<li>MVVM 적용 후, 확실히 VC의 역할이 많이 줄어들었으나 여전히 VM이 너무 많은 역할을 수행하고 있었다. 레이어 분리를 통해 가능한 한 VM의 역할을 더 줄여보고자 했다.</li>
<li>계획 상 test를 진행해보려고 했기 때문에, test에 용이하다는 코드를 적용시키고자 했다.</li>
</ul>
<br>

<h3 id="2-진행-과정">2. 진행 과정</h3>
<ul>
<li><p>구글링해서 정말 많은 레퍼런스를 읽어보았지만,, 솔직히 글만 읽었을 때 직관적으로 이게 뭔지 잘 이해하기 쉽지 않았다</p>
</li>
<li><p>그래서 제일 많이 등장한 프로젝트인 <a href="https://github.com/kudoleh/iOS-Clean-Architecture-MVVM">iOS-Clean-Architecture-MVVM</a>를 클론하면서 레이어별로 어떤 코드를 작성했는지 보고, 어떤 방식으로 레이어가 연결되는지 공부했다. 확실히 코드를 먼저 작성해보고 다시 레퍼런스를 읽어보니까 눈에 좀 보이기 시작했다.</p>
</li>
<li><p>어느 정도 구조를 익힌 후, 바로 프로젝트를 생성하고 Clean Architecture를 도입했다.
 <img src="https://velog.velcdn.com/images/s_sub/post/42c515b7-677c-4fd6-ad6a-c0a5bc956ddd/image.png" alt="">   </p>
<p><img src="https://velog.velcdn.com/images/s_sub/post/537dd28b-0081-4f6d-a92c-0513be25c5d0/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/s_sub/post/61fc3283-e52f-460c-8eb2-43655c7f4fbe/image.png" alt=""></p>
<ul>
<li>머릿속에 그림이 도저히 그려지지 않을 땐, 직접 굿노트에 써가면서 구조를 익혔다</li>
</ul>
</li>
</ul>
<br>


<h3 id="3-글-작성-방향">3. 글 작성 방향</h3>
<ul>
<li><p>블로그에 정리를 한다면 아키텍처 내용에 대한 글을 적어야 하나 생각했는데, 그러기에는 이미 너무 좋은 글들이 많다. 굳이 또 같은 그림을 넣고, 똑같은 설명을 해야 할까 싶었다. 그리고 사실,, 추상적인 글을 보고 나조차도 제대로 이해하지 못했기 때문에,,,</p>
</li>
<li><p>그래서 아키텍처에 대한 설명보다는 내가 어떻게 적용했는지에 대해 정리해보려고 한다. 아무래도 사람마다 기준이 다르기 때문에, 내가 이해하고 적용한 방법이 좋은 방향이 아닐 수도 있다. 몇 년 후의 나는 또 다른 구조를 생각하고 있을 수도 있다. 어쨌든, &quot;클린 아키텍처에 대한 내용&quot; 보다는 &quot;내가 적용한 클린 아키텍처&quot;에 대해 작성해보려고 한다.</p>
</li>
</ul>
<br>


<h2 id="2-내가-적용한-클린-아키텍처">2. 내가 적용한 클린 아키텍처</h2>
<h4 id="아키텍처-구조">아키텍처 구조</h4>
<p><img src="https://velog.velcdn.com/images/s_sub/post/c4e418ca-49f7-4ce6-8190-5455edf7bafe/image.jpeg" alt=""></p>
<br>

<h4 id="폴더링">폴더링</h4>
<pre><code class="language-swift">  Sooda
  ├── Util
  │   ├── Enum
  │   ├── Constant
  │   └── Extension
  │
  ├── Resource
  │   ├── Assets
  │   ├── Font
  │   └── Base
  │
  ├── Domain
  │   ├── Entity
  │   ├── UseCase
  │   └── Interface
  │   
  ├── Presentation
  │   ├── CustomView
  │   ├── Scene #1
  │   │   ├── View
  │   │   ├── ViewModel
  │   │   ├── ViewController
  │   │   └── Coordinator
  │   ...
  │   └── Scene #n
  │       ├── View
  │       ├── ViewModel
  │       ├── ViewController
  │       └── Coordinator
  │
  ├── Data
  │   ├── UserDefaults
  │   ├── Keychain
  │   ├── Network
  │   │   └── DataMapping
  │   ├── Socket
  │   │   └── DataMapping
  │   ├── Realm
  │   │   └── DataMapping
  │   └── Repository
  │
  ├── Network
  │   ├── NetworkManager
  │   └── NetworkRouter
  │
  ├── Socket
  │   ├── SocketManager
  │   └── SocketRouter
  │
  ├── Realm
  │   └── RealmManager
  │
  └── Application
      ├── AppDelegate
      ├── SceneDelegate
      └── AppCoordinator</code></pre>
<h3 id="1-중점적으로-생각한-부분">1. 중점적으로 생각한 부분</h3>
<h4 id="인스턴스-별-역할-분리">인스턴스 별 역할 분리</h4>
<ul>
<li>아키텍처를 도입한 이유이기 때문에 가장 중점적으로 고려하였다. 기존 VM의 역할을 최대한 분배하고, UseCase와 Repository도 적절한 역한 분리가 이루어지도록 했다.</li>
</ul>
<h4 id="의존성-방향">의존성 방향</h4>
<ul>
<li>레이어 별 의존성의 방향을 유지시켜서 외부 레이어에 수정이 발생하더라도 내부 레이어에 영향이 없도록 했다.</li>
</ul>
<br>

<h3 id="2-레이어-별-기능">2. 레이어 별 기능</h3>
<h4 id="1-presentation-layer">1. Presentation Layer</h4>
<ul>
<li>실제 화면에 가장 가까운(?) 영역이다. MVVM-C 패턴으로 구현하였다.</li>
<li>View는 뷰 객체, VC는 사용자 interaction, Coordinator는 화면 전환 로직을 수행한다</li>
<li>다른 레이어와 연결되는 부분은 VM이고, 최대한 불필요한 가공 없이 VC에게 필요한 데이터와 로직을 제공한다.</li>
</ul>
<h4 id="2-domain-layer">2. Domain Layer</h4>
<ul>
<li>프로젝트에 가장 필수적인 요소가 있는 영역이다.</li>
<li>가장 내부에 위치한 계층으로, 본인들이 어떻게 활용되는지 알 수 없다.</li>
</ul>
<ul>
<li>Entity는 <strong>프로젝트에서 사용하는 모델</strong> 이다.
모든 곳에서 접근이 가능한 구조체들을 모아두었다.<ul>
<li>Presentation Layer에서는 이 모델을 바로 뷰 객체에 보여주는 용도로 사용</li>
<li>Data Layer에서는 Entity 변형시켜서 요청 모델을 생성, 외부에 데이터를 요청하고,
응답이 오면 응답 모델을 Entity로 변형시킨다 <strong>(DataMapping)</strong><blockquote>
<p>Enterprise wide business rules, &quot;가장 고수준의 규칙&quot;</p>
</blockquote>
</li>
</ul>
</li>
<li>UseCase는 <strong>비즈니스 로직</strong> 이다. 
Repository (Data Layer)에서 전달하는 로직을 조합하고, 구조를 변형시켜서 VM (Presentation Layer)로 전달한다.</li>
<li>Interface는 <strong>Repository를 UseCase에서 사용하기 위한 프로토콜</strong>이다.
실제 Repo들은 Interface를 이용한 의존성 주입을 통해 UseCase에서 활용된다 <strong>(DIP)</strong></li>
</ul>
<br>

<h4 id="3-data-layer">3. Data Layer</h4>
<ul>
<li>Repository에서 <strong>외부 통신(?)을 통해 날 것의 데이터(?)</strong>를 받는다</li>
<li>HTTP API, Socket, DB, Push Notification에서 제공하는 데이터를 받아서 <strong>DTO 타입</strong> 으로 저장한다.</li>
<li>결국 이 데이터를 UseCase에게 전달해주어야 하기 때문에 <strong>DataMapping</strong>을 통해 Entity 타입으로 변환시킨다.</li>
</ul>
<br>

<h3 id="3-트러블">3. 트러블</h3>
<h4 id="1-여전히-뚱뚱한-vm">1. 여전히 뚱뚱한 VM</h4>
<ul>
<li>역할에 대한 명확한 기준 없이 무작정 코드로 구현만 하다보니, 자연스럽게 기존 방법대로 구현하면서 여전히 뚱뚱한 VM을 볼 수 있었다. </li>
<li>사실상 Repository는 네트워크 통신 응답만 하고, UseCase는 추가 가공 없이 그걸 그대로 VM에게 전달만 하고 있었다. 결국 나머지 모든 일을 VM에서 해주어야 했다.</li>
<li>이 사태를 인지하고 <strong>인스턴스 별 명확한 기준</strong> 이 필요하다고 생각했다.</li>
</ul>
<br>


<h4 id="2-여전히-중복되는-코드">2. 여전히 중복되는 코드</h4>
<ul>
<li>클린 아키텍처를 통해 레이어를 분리하여 역할을 나누면 중복된 코드를 줄일 수 있다.</li>
<li>예를 들어 같은 API 통신 또는 realm CRUD 작업이 여러 화면에서 필요한 경우, 기존에는 모든 VM에서 같은 코드를 작성해야 했다. 
물론 Singleton Manager를 통해 어느 정도 중복된 코드를 줄일 수는 있지만, 추가 가공이 필요한 경우 결국 같은 코드를 작성해야 한다.</li>
<li>이러한 코드들(비즈니스 로직)을 UseCase와 Repository를 통해 캡슐화하고 재사용할 수 있도록 한다.</li>
<li>원래 이렇게 되어야 하는데, 이 역시 명확한 기준이 없다보니 또 여기저기 같은 코드를 남발하고 있었다.</li>
</ul>
<br>

<h4 id="3-나름대로-해결-방안">3. 나름대로 해결 방안</h4>
<ul>
<li>결국 내가 해야 할 건 <strong>인스턴스 별 명확한 기준</strong> 세우기 였다</li>
<li>분리 기준은 오로지 &quot;VM, UseCase, Repo의 역할 분리&quot;와 &quot;코드 중복 최소화&quot;에 집중하면서 세웠다</li>
<li>사실 test에 적합한 기준인지도 확인을 했어야 하는데, 이번 프로젝트에서 test를 진행하지 못했기 때문에 여기에 대해서는 확인하지 못했다.</li>
</ul>
<ol>
<li><strong>Repository</strong><ul>
<li>외부 데이터(날 것의 데이터) 를 DTO 타입으로 디코딩</li>
<li>DTO 타입의 데이터를 Entity 타입으로 Mapping해서 전달</li>
</ul>
</li>
</ol>
<ol start="2">
<li><strong>UseCase</strong><ul>
<li>Repo 데이터를 VM이 사용하기 편하도록 가공해서 전달</li>
<li><strong>최대한 VM이 바로 활용할 수 있도록</strong> 가공한다</li>
<li>여러 Repo 데이터를 조합, 타입 변환 등</li>
</ul>
</li>
</ol>
<ol start="3">
<li><strong>ViewModel</strong><ul>
<li>UseCase 데이터를 불필요한 가공 없이 활용</li>
<li>VC와의 interaction에 집중</li>
</ul>
</li>
</ol>
<br>


<h2 id="3-후기">3. 후기</h2>
<h3 id="1-장점">1. 장점</h3>
<h4 id="1-vm의-역할-분리">1. VM의 역할 분리</h4>
<ul>
<li>결과적으로는 VM이 확실히 날씬해졌다. Presentation Layer에 필요한 내용만 VM에서 확인할 수 있기 때문에 가독성도 좋아졌다</li>
</ul>
<br>

<h4 id="2-dto의-장점">2. DTO의 장점</h4>
<ul>
<li>DTO 사용을 통해 사용 위치에 따라 필요한 데이터만 가지고 있을 수 있게 되었다. </li>
<li>기존에는 네트워크 응답으로 받은 데이터와 DB에서 꺼내온 데이터를 그대로 View에서도 사용했기 때문에 불필요한 데이터를 가지고 있는 경우도 있었고, 분명 같은 뷰에 나타나야 하는 데이터인데 타입이 다른 경우 등 여기저기서 많이 꼬이기도 했다. </li>
<li>이제는 확실히 구조체 자체를 사용 위치에 따라 분리해두었기 때문에 사용하기도 편하고, 가독성 역시 좋아졌다. </li>
<li><code>init</code>과 <code>toDomain</code> 을 통해 Entity와 상호작용하기 때문에 사용하기에도 크게 어렵지 않았다.</li>
</ul>
<br>

<h3 id="2-단점">2. 단점</h3>
<h4 id="1-작업량-증가">1. 작업량 증가</h4>
<ul>
<li>어쩔 수 없이 작업량 자체는 많아진다. 단순한 뷰를 만들려고 해도 생성해야 하는 파일 개수가 몇 배로 늘어난다.</li>
<li>그래서 미리 구조를 잘 생각해두고 구현을 시작해야 한다. 중간에 빠진 내용을 추가하려면 여러 파일을 돌면서 연결된 모든 레이어를 수정해야 하기 때문에 쉽지 않다.</li>
</ul>
<br>


<h4 id="2-역할-분리에-대한-명확한-기준이-없다면-클린하지-않은-구조">2. 역할 분리에 대한 명확한 기준이 없다면,, 클린하지 않은 구조...</h4>
<ul>
<li>처음에 역할 분리에 대해 명확한 기준이 없이 무작정 구현만 했을 때, 오히려 파일만 많아지고 코드를 보기가 더 힘들었다. 이게 클린한게 맞을까..? 싶은 의문이 많이 들었던 시기다.</li>
</ul>
<br>

<h4 id="3-의존성-방향이-정답인지에-대한-의문">3. 의존성 방향이 정답인지에 대한 의문</h4>
<ul>
<li>클린 아키텍처의 장점은 의존성 방향이 고정되어 있기 때문에 외부 레이어에 수정이 생기더라도 내부 레이어에는 영향이 없다는 것이다. 추가적인 수정을 하지 않아도 되기 때문에 이는 확실한 장점이 된다</li>
<li>하지만 내부 레이어에 수정이 자주 생기는 프로젝트라면...? 아직 실무에 나가보지 않았기 때문에 경험하지는 못했지만, 만약 Entity를 자주 수정한다면 결국 모든 레이어를 또 수정해야 한다. 이런 경우, 클린 아키텍처가 과연 좋은 아키텍처가 될 수 있을까 하는 의문이 생겼다.</li>
</ul>
<br>

<h3 id="3-아쉬운-점">3. 아쉬운 점</h3>
<h4 id="1-테스트-부재">1. 테스트 부재</h4>
<ul>
<li>시간 관계상 테스트를 진행해보지 못한 점이 가장 아쉽다. 추후에 시간이 된다면 테스트를 진행해보고, 내가 세운 기준이 테스트에도 적합한지 확인해보려고 한다. </li>
</ul>
<br>

<h4 id="2-viewmodel-프로토콜-부재">2. ViewModel 프로토콜 부재</h4>
<ul>
<li>UseCase와 Repository는 인터페이스, 즉 프로토콜을 이용한 의존성 주입을 통해 활용된다.</li>
<li>ViewModel도 역시 이와 동일하게 구현하려 했으나, 기존에 사용하던 Input / Output 방식도 프로토콜로 구현하고 있어서 여기서 발생하는 충돌 때문에 직접 구현체로 사용할 수 밖에 없었다.</li>
<li>분명 같이 사용할 수 있는 방법은 있을 것이기 때문에, 추후 코드를 수정해볼 생각이다.</li>
</ul>
<br>


<p><em>추가할 내용이 있으면 계속해서 수정해 나가겠습니다. 감사합니다.</em></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[새싹 iOS] 23주차_Push Notification]]></title>
            <link>https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-23%EC%A3%BC%EC%B0%A8</link>
            <guid>https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-23%EC%A3%BC%EC%B0%A8</guid>
            <pubDate>Mon, 18 Dec 2023 00:39:55 GMT</pubDate>
            <description><![CDATA[<h2 id="1-remote-push-notification-in-apple">1. Remote Push Notification in Apple</h2>
<p><img src="https://velog.velcdn.com/images/s_sub/post/12c9cd6d-9670-4b83-946f-97dcab81c77a/image.jpeg" alt=""></p>
<ul>
<li>앱에서 원격 알림을 받기 위해서는 <strong>서버</strong> 와 <strong>device token</strong> 이 필요하다</li>
</ul>
<ul>
<li>동작 구조<ol>
<li><strong>APNs</strong> 에 앱을 등록한다</li>
<li><strong>device token</strong> 을 받는다</li>
<li><strong>device token</strong> 을 서버에 전달한다</li>
<li>푸시 알림이 필요한 시점에 서버는 APNs에게 <strong>메세지 데이터</strong> 와 <strong>device token</strong> 을 보낸다<ul>
<li>이 때, APNs와 서버는 TLS 통신을 하며, 서버에는 인증서가 준비되어 있어야 한다</li>
</ul>
</li>
<li>받은 <strong>device token</strong>을 통해 APNs는 기기를 식별하여, <strong>알림 데이터</strong>를 푸시 알림으로 보내준다</li>
</ol>
</li>
</ul>
<ul>
<li><strong>APNs (Apple Push Notification Service)</strong><ul>
<li>애플 기기로 푸시 알림을 전송하는 서비스</li>
<li>오직 APNs만 기기에 <strong>직접적으로 푸시 알림을 보낼 수 있다</strong></li>
<li>고유한 <strong>device token</strong>을 통해 특정 디바이스로 알림을 보낸다</li>
</ul>
</li>
</ul>
<ul>
<li><strong>Device Token</strong><ul>
<li>앱과 디바이스 모두에게 유일성을 갖는다</li>
<li>서로 다른 앱에서 같은 device token을 사용할 수 없고,
다른 디바이스에 설치된 같은 앱에서도 다른 device token을 사용한다</li>
<li><strong>APNs</strong>만 해독이 가능하다</li>
<li><strong>로컬 저장소에 device token을 저장해두지 않는다</strong>
<img src="https://velog.velcdn.com/images/s_sub/post/990f84ec-be85-4d3b-90b2-dbba4debce72/image.png" alt=""></li>
</ul>
</li>
</ul>
<br>


<h2 id="2-fcm-token">2. FCM Token</h2>
<ul>
<li><strong>FCM (Firebase Cloud Messaging)</strong><ul>
<li>무료로 메세지를 안정적으로 전송할 수 있는 <strong>교차 플랫폼 메시징 솔루션</strong></li>
<li>설정에서 <strong>APNs Auth Key</strong> 를 등록한다</li>
<li>푸시 알림을 대신 전송하는 대리자(delegate) 역할을 수행하고,
실질적으로는 APNs 서버에 푸시 알림에 대한 요청을 한다.</li>
</ul>
</li>
</ul>
<ul>
<li><p>공식 문서에서는 <strong>등록 토큰</strong> 이라고 하는 FCM Token은 앱 시작 시 생성된다</p>
<ul>
<li><p>등록 토큰을 수신하기 위한 delegate을 설정한다</p>
<pre><code class="language-swift">// 공식 문서에 있는 코드
Messaging.messaging().delegate = self</code></pre>
</li>
<li><p>최초 앱 시작 시, 토큰이 업데이트되거나 무효화될 때 신규 또는 기존 토큰을 가져온다 
어떠한 경우든, 유효한 토큰이 있는 <code>didReceiveRegistrationToken</code> 메서드를 호출한다
이 때, 서버에 해당 토큰을 전달하거나, <code>NotificationCenter</code>를 이용해 앱 전체에 알린다</p>
<pre><code class="language-swift">// 공식 문서에 있는 코드
func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
  print(&quot;Firebase registration token: \(String(describing: fcmToken))&quot;)

  let dataDict: [String: String] = [&quot;token&quot;: fcmToken ?? &quot;&quot;]
  NotificationCenter.default.post(
    name: Notification.Name(&quot;FCMToken&quot;),
    object: nil,
    userInfo: dataDict
  )
  // TODO: If necessary send token to application server.
  // Note: This callback is fired at each app startup and whenever a new token is generated.
}</code></pre>
</li>
<li><p><code>.token(completion: )</code> 메서드를 통해 원하는 시점에 직접 토큰을 가져올 수도 있다</p>
<pre><code class="language-swift">// 공식 문서에 있는 코드
Messaging.messaging().token { token, error in
  if let error = error {
    print(&quot;Error fetching FCM registration token: \(error)&quot;)
  } else if let token = token {
    print(&quot;FCM registration token: \(token)&quot;)
    self.fcmRegTokenMessage.text  = &quot;Remote FCM registration token: \(token)&quot;
  }
}</code></pre>
</li>
</ul>
</li>
</ul>
<br>


<h2 id="3-프로젝트-적용">3. 프로젝트 적용</h2>
<h4 id="sooda">Sooda</h4>
<ul>
<li>UIKit, RxSwift</li>
<li>FirebaseMessaging</li>
<li><a href="https://github.com/limsub/Sooda4">Sooda 깃허브 레포</a></li>
</ul>
<h3 id="1-세팅">1. 세팅</h3>
<ul>
<li>GoogleService-Info.plist 파일 추가</li>
<li>Signing &amp; Capabilities에 <strong>Push Notification</strong> 추가</li>
<li>인증서 등록 및 Profile 등록
<img src="https://velog.velcdn.com/images/s_sub/post/f5ec0fcc-f99d-43c7-820f-18245a374255/image.png" alt=""></li>
</ul>
<br>

<h4 id="appdelegate---didfinishlaunchingwithoptions"><code>AppDelegate - didFinishLaunchingWithOptions</code></h4>
<pre><code class="language-swift">func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -&gt; Bool {

    // GoogleSerice-info.plist에 File I/O 하는 기능
    FirebaseApp.configure()

    // 알림 delegate 설정
    UNUserNotificationCenter.current().delegate = self

    // 알림 허용 확인
    UNUserNotificationCenter.current().requestAuthorization(
        options: [.alert, .sound, .badge, .providesAppNotificationSettings]) { didAllow, error  in
        print(&quot;Notification Authorization : \(didAllow)&quot;)
    }

    // 원격 알림에 앱 등록
    application.registerForRemoteNotifications()


    // Messaging delegate 설정
    Messaging.messaging().delegate = self


    return true
}</code></pre>
<br>

<h4 id="appdelegate---didregisterforremotenotificationswithdevicetoken"><code>AppDelegate - didRegisterForRemoteNotificationsWithDeviceToken</code></h4>
<pre><code class="language-swift">func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    // deviceToken (APNs 토큰)을 가져와서 Messaging의 apnsToken 설정
    Messaging.messaging().apnsToken = deviceToken
}

func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
    print(error)
}</code></pre>
<ul>
<li><code>application.registerForRemoteNotifications()</code>이 성공적으로 실행되면 위 메서드를 통해 deviceToken을 받는다.</li>
<li>만약 실패 시 <code>didFailToRegisterForRemoteNotificationsWithError</code> 메서드가 실행된다.</li>
</ul>
<ul>
<li>무조건 이 둘 중에 하나가 실행되어야 하는데, 정말 가끔 둘 다 실행이 안되어서 deviceToken을 받지 못하는 경우가 꽤 있었다.... 이것저것 해도 실행이 계속 안되다가 또 갑자기 되고,,, 아직 이유는 모르겠는데, Xcode 껐켰 후 실행된 경험이 있다....</li>
</ul>
<br>

<h4 id="appdelegate---didreceiveregistrationtoken"><code>AppDelegate - didReceiveRegistrationToken</code></h4>
<pre><code class="language-swift">func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
      let firebaseToken = fcmToken ?? &quot;No Token&quot; 

      KeychainStorage.shared.fcmToken = firebaseToken
}</code></pre>
<ul>
<li>firebase에서 fcm token을 받고, 이를 키체인에 저장한다.</li>
<li>만약 여기 말고 앱 내의 다른 곳에서 fcm Token을 받고 싶다면, 아래 코드를 실행한다<pre><code class="language-swift">Messaging.messaging().token { token, error in
  if let error = error {
    print(&quot;Error fetching FCM registration token: \(error)&quot;)
  } else if let token = token {
    print(&quot;FCM registration token: \(token)&quot;)
  }
}</code></pre>
</li>
</ul>
<br>

<h3 id="2-서버-통신">2. 서버 통신</h3>
<ul>
<li>FirebaseMessaging에서 받은 fcm token을 서버에 전달해준다</li>
<li>서버 DB에는 서버에 전달한 계정과 fcm token이 함께 저장되고, 
해당 계정에 push notification이 필요한 경우, 저장된 fcm token의 기기로 알림을 보내준다.</li>
</ul>
<ul>
<li>현재 프로젝트에서 deviceToken을 서버에 전달하는 API<ul>
<li>회원가입 <code>/v1/users/join</code></li>
<li>로그인(이메일, 카카오, 애플)  <code>/v1/users/login</code></li>
<li>FCM deviceToken 저장 <code>/v1/users/deviceToken</code></li>
</ul>
</li>
</ul>
<br>

<h3 id="25-devicetoken-업데이트-시점에-대한-고민-지점">2.5 deviceToken 업데이트 시점에 대한 고민 지점</h3>
<ul>
<li>로그인 또는 회원가입 시 deviceToken 전송</li>
<li>token이 업데이트되었을 때 deviceToken 전송</li>
<li>로그아웃 시 deviceToken 정보 삭제</li>
</ul>
<ul>
<li><strong>만약 여러 기기에서 로그인을 시도한다면</strong>,<ul>
<li>서버 DB 테이블에 계정 당 deviceToken을 하나만 저장할 수 있다면
가장 최신에 로그인한 계정으로 push notification이 보내진다.</li>
<li>하지만 실질적으로 사용자가 자주 사용하는 기기는 
가장 최신에 로그인한 기기가 아닐 수 있다.</li>
<li>이런 경우, 앱 내의 특정 화면을 정해서 
그 화면에 사용자가 접속했을 때 해당 계정의 deviceToken을 업데이트 해주는 것도 좋은 방법인 것 같다.</li>
</ul>
</li>
</ul>
<br>


<h3 id="3-push-notification-설정">3. Push Notification 설정</h3>
<ul>
<li><p>이번 프로젝트에서는 채팅 알림을 받도록 구현이 되어있다.</p>
</li>
<li><p>push notification 수신 시 구현 내용</p>
<table>
<thead>
<tr>
<th align="center"><img src="https://velog.velcdn.com/images/s_sub/post/1b22711a-d44f-4f74-aa87-75f2f0357eae/image.gif" alt=""></th>
<th align="center"><img src="https://velog.velcdn.com/images/s_sub/post/125fa8aa-75b0-44c9-8ead-d94be1eca328/image.gif" alt=""></th>
</tr>
</thead>
<tbody><tr>
<td align="center">채팅에 해당하는 채팅방에 들어가면<br>푸시 알림이 오지 않는다</td>
<td align="center">푸시 알림을 클릭했을 때,<br>해당 채팅방으로 바로 화면 전환</td>
</tr>
</tbody></table>
</li>
</ul>
<br>


<h4 id="1-채팅에-해당하는-채팅방에-들어가면-푸시-알림이-오지-않는다">1. 채팅에 해당하는 채팅방에 들어가면 푸시 알림이 오지 않는다</h4>
<pre><code class="language-swift">// AppDelegate
// 포그라운드에서 알림 받기
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -&gt; Void) {

    guard let userInfo = notification.request.content.userInfo as? [String: Any] else { return }

    // 1. 채널 채팅인 경우
    if let channelChatInfo: PushChannelChattingDTO = self.decodingData(userInfo: userInfo) {
        // userInfo 디코딩해서 푸시 알림 내용 확인

        // 현재 보고 있는 채팅방이 아닌 경우만 푸시 알림
        // (UserDefaults 이용해서 현재 보고 있는 채팅방 여부 확인)
        if !self.checkCurrentChannel(chatInfo: channelChatInfo) {
            completionHandler([.list, .badge, .sound, .banner])
        }
    }

    // 2. 디엠 채팅인 경우 - 생략
}
</code></pre>
<br>

<h4 id="2-푸시-알림을-클릭했을-때-해당-채팅방으로-바로-화면-전환">2. 푸시 알림을 클릭했을 때, 해당 채팅방으로 바로 화면 전환</h4>
<h5 id="appdelegate">AppDelegate</h5>
<ul>
<li><p>푸시 알림 클릭 시, NotificationCenter를 통해 SceneDelegate에게 알림 내용을 보낸다.</p>
<pre><code class="language-swift">// 푸시 알림을 클릭
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -&gt; Void) {

    guard let userInfo = response.notification.request.content.userInfo as? [String: Any] else { return }

    // 1. 채널 채팅인 경우
    if let channelChatInfo: PushChannelChattingDTO = self.decodingData(userInfo: userInfo) {
        // userInfo 디코딩해서 푸시 알림 내용 확인

        if let channelId = Int(channelChatInfo.channel_id),
           let workspaceId = Int(channelChatInfo.workspace_id) {

            let userInfo: [String: Any] = [
                &quot;channelId&quot;: channelId,
                &quot;workspaceId&quot;: workspaceId
            ]

            // Notification Post -&gt; SceneDelegate에 observer
            NotificationCenter.default.post(
                name: Notification.Name(&quot;channelChattingPushNotification&quot;),
                object: nil,
                userInfo: userInfo
            )
        }
    }

    // 2. 디엠 채팅인 경우 - 생략

</code></pre>
</li>
</ul>
<pre><code>  completionHandler()</code></pre><p>  }</p>
<pre><code>
&lt;br&gt;

##### SceneDelegate
- NotificationCenter를 통해 푸시 알림 클릭에 대한 노티를 받으면, 
AppCoordinator 메서드 실행 
(모든 화면 초기화 후 해당되는 채팅방으로 화면 전환)
  ```swift
  class SceneDelegate: UIResponder, UIWindowSceneDelegate {

      var appCoordinator: AppCoordinator?

      func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {

          /* ... */

          setNotification()    // 노티 등록
      }
  }


  extension SceneDelegate {

      private func setNotification() {
          // 1. 채널 채팅
          NotificationCenter.default.addObserver(
              self,
              selector: #selector(channelChatPushClicked),
              name: Notification.Name(&quot;channelChattingPushNotification&quot;),
              object: nil
          )
      }    


      // 1. 채널 채팅
      @objc
      private func channelChatPushClicked(_ notification: Notification) {

          if let userInfo = notification.userInfo,
              let channelId = userInfo[&quot;channelId&quot;] as? Int ,
              let workspaceId = userInfo[&quot;workspaceId&quot;] as? Int {

                  appCoordinator?.showDirectChannelChattingView(
                      workSpaceId: workspaceId,
                      channelId: channelId,
                      channelName: nil
                  )
          }
      }</code></pre><br>

<h5 id="appcoordinator">AppCoordinator</h5>
<ul>
<li><p><code>showDirectChannelChattingView</code> : 현재 쌓여있는 뷰를 모두 초기화시키고, 곧바로 채팅 화면으로 전환한다.</p>
</li>
<li><p><a href="https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-25%EC%A3%BC%EC%B0%A8">코디네이터 구조</a></p>
<pre><code class="language-swift">class AppCoordinator: AppCoordinatorProtocol {

    /* ... */

    func showDirectChannelChattingView(
        workSpaceId: Int,
        channelId: Int,
        channelName: String?
    ) {
        /*
        1. AppCoordinator child removeAll

        2. AppCoordinator showTabbarFlow(workspaceId: Int)

        3. TabbarCoordinator prepareTabBarController(selectedItem = 0)

        // channel
        4 - 1. HomeDefaultCoordinator showChannelChatting

        //dm
        4 - 2. HomeDefaultCoordinator showDMChatting
        */

</code></pre>
</li>
</ul>
<pre><code>      // 1. child coordinator removeAll
      childCoordinators.removeAll()
      navigationController.viewControllers.removeAll()


      // 2. show tabBar flow
      let tabBarCoordinator = TabBarCoordinator(navigationController)
      tabBarCoordinator.finishDelegate = self
      tabBarCoordinator.workSpaceId = workSpaceId
      childCoordinators.append(tabBarCoordinator)
      tabBarCoordinator.start()
      // (-&gt; homeDefaultCoordinator start)


      // 3. HomeDefaultCoordinator show ChannelChatting
      for i in 0...3 {
          if let homeDefaultCoordinator =  tabBarCoordinator.childCoordinators[i] as? HomeDefaultSceneCoordinatorProtocol {

                  homeDefaultCoordinator.showChannelChattingView(
                  workSpaceId: workSpaceId,
                  channelId: channelId,
                  channelName: channelName
              )
          }
      }
}</code></pre><p>  ```</p>
<br>


<h4 id="레퍼런스">레퍼런스</h4>
<p><a href="https://developer.apple.com/documentation/usernotifications/registering_your_app_with_apns">Apple Developer - Registering your app with APNs</a>
<a href="https://firebase.google.com/docs/cloud-messaging/ios/client?hl=ko&amp;authuser=0">Firebase - Cloud Messaging</a>
<a href="https://babbab2.tistory.com/58">개발자 소들이 - APNs :: Push Notification 동작 방식</a>
<a href="https://seungwoolog.tistory.com/88">FCM을 도입할 때 고려할 것들</a>
<a href="https://jkim68888.tistory.com/13">Jihyun Kim - IOS에서 이미지가 있는 푸시알림 구현하기</a>
<a href="https://brunch.co.kr/@woongss/16">웅쓰 - iOS 앱 Push 알림 이해하기</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[새싹 iOS] 22주차_WebSocket]]></title>
            <link>https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-22%EC%A3%BC%EC%B0%A8</link>
            <guid>https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-22%EC%A3%BC%EC%B0%A8</guid>
            <pubDate>Sat, 16 Dec 2023 05:38:08 GMT</pubDate>
            <description><![CDATA[<h2 id="websocket">WebSocket</h2>
<ul>
<li>클라이언트와 서버 간 연결을 종료하기 전까지 연결된 통신 계속 유지</li>
<li><strong>연결 시작 단계</strong>, <strong>연결 유지 단계</strong>, <strong>연결 종료 단계</strong>로 상태 구분</li>
</ul>
<ul>
<li><strong>양방향 통신(full-duplex)</strong>, <strong>실시간(Real-Time) 네트워킹</strong><ul>
<li>HTTP와 달리 요청이 없더라도 서버 -&gt; 클라이언트 데이터 송신 가능</li>
<li>원하는 시점에 서로 데이터 주고받기 가능</li>
</ul>
</li>
</ul>
<br>


<h2 id="websocket-구현">WebSocket 구현</h2>
<h4 id="preview">Preview</h4>
<ul>
<li><code>WebSocketManager</code> 싱글톤 패턴</li>
<li><code>URLSessionWebSocketTask</code>, <code>URLSessionWebSocketDelegate</code></li>
<li><a href="https://docs.upbit.com/v1.4.0/reference/general-info">UPBit WebSocket API</a> 활용</li>
<li>SwiftUI, Combine</li>
</ul>
<br>


<h3 id="model">Model</h3>
<pre><code class="language-swift">// 응답 데이터 모델
struct OrderBookWS: Decodable {
  let timestamp: Int
  let totalAskSize, totalBidSize: Double
  let orderbookUnits: [OrderbookUnit]

  enum CodingKeys: String, CodingKey { /*...*/ }
}

struct OrderbookUnit: Codable {
  let askPrice, bidPrice, askSize, bidSize: Double

  enum CodingKeys: String, CodingKey { /*...*/ }
}


// 뷰 데이터 모델
struct OrderBookItem: Hashable, Identifiable {
  let id = UUID()
  let price: Double
  let size: Double
}

struct Market: Codable, Hashable {
  let id = UUID()
  let market: String
  let koreanName: String
  let englishName: String

  enum CodingKeys: String, CodingKey { /*...*/ }
}</code></pre>
<br>


<h3 id="class-websocketmanager-nsobject"><code>class WebSocketManager: NSObject</code></h3>
<h4 id="싱글톤--필요한-프로퍼티-선언">싱글톤 + 필요한 프로퍼티 선언</h4>
<pre><code class="language-swift">  static let shared = WebSocketManager()

  private override init() {
      super.init()
  }

  private var timer: Timer?    // 5초에 한 번씩 Ping 보내기 위한 타이머
  private var webSocket: URLSessionWebSocketTask?
  private var isOpen = false   // 소켓 연결 상태
  var orderBookSbj = PassthroughSubject&lt;OrderBookWS, Never&gt;()
  // RxSwift PublishSubject   -&gt; Combine PassthroughSubject
  // Rx는 데이터 타입만 설정       -&gt; Combine은 에러 타입도 설정</code></pre>
<br>


<h4 id="1-open"><code>1. open</code></h4>
<ul>
<li>소켓 연결 -&gt; delegate의 <code>didOpen</code>으로 연결 확인</li>
<li>URLSession - default - webSocketTask 활용</li>
<li>iOS 13부터 <strong>webSocketTaks</strong> 등장
  <img src="https://velog.velcdn.com/images/s_sub/post/80e94c98-e168-4481-b839-79664df1d1e4/image.png" alt=""><pre><code class="language-swift">func openWebSocket() {
    if let url = URL(string: &quot;wss://api.upbit.com/websocket/v1&quot;) {
        let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
        // -&gt; URLSessionWebSocketDelegate 프로토콜 채택
        webSocket = session.webSocketTask(with: url)
        webSocket?.resume()
        ping()
    }
}</code></pre>
</li>
</ul>
<br>


<h4 id="2-close"><code>2. close</code></h4>
<ul>
<li><p>소켓 해제 -&gt; delegate의 <code>didClose</code>로 해제 확인</p>
</li>
<li><p>다양한 CloseCode가 존재하는데 주로 <code>.goingAway</code>와 <code>messageTooBig</code> 사용</p>
</li>
<li><p>VM, VC와 따로 동작하는 timer 관리 중요 (Keyword : <strong>run loop</strong>)</p>
<pre><code class="language-swift">func closeWebSocket() {
    webSocket?.cancel(with: .goingAway, reason: nil)
    webSocket = nil

    timer?.invalidate()
    timer = nil

    isOpen = false  
}</code></pre>
</li>
</ul>
<br>


<h4 id="3-send"><code>3. send</code></h4>
<ul>
<li><p>소켓 통신을 통해 받고 싶은 데이터를 요청 포맷에 맞춰서 요청
<img src="https://velog.velcdn.com/images/s_sub/post/02772fad-0727-4e21-8789-d6ed9bb3e2a7/image.png" alt=""></p>
<pre><code class="language-swift">func send(_ codes: String) {
 let requestStr = &quot;&quot;&quot;
 [{&quot;ticket&quot;:&quot;test&quot;},{&quot;type&quot;:&quot;orderbook&quot;,&quot;codes&quot;:[&quot;\(codes)&quot;]}]
 &quot;&quot;&quot;

 webSocket?.send(.string(requestStr), completionHandler: { error  in
     if let error { print(&quot;send Error : \(error.localizedDescription)&quot;) }
 })
}</code></pre>
</li>
</ul>
<br>


<h4 id="4-receive"><code>4. receive</code></h4>
<ul>
<li><p>필요한 순간에 서버에서 데이터를 받는다</p>
<pre><code class="language-swift">func receive() {
    if isOpen { // 소켓이 열렸을 때만 데이터 수신이 가능하도록 한다
        webSocket?.receive(completionHandler: { [weak self] result  in
            switch result {
            case .success(let success):
                print(&quot;receive Success : \(success)&quot;)

                switch success {
                case .data(let data):
                    print(&quot;success - data : \(data)&quot;)

                    do {
                        let decodedData = try JSONDecoder().decode(OrderBookWS.self, from: data)
                        // RxSwift .onNext -&gt; Combine .send
                        self?.orderBookSbj.send(decodedData)
                    } catch {
                        print(&quot;decodingError : \(error.localizedDescription)&quot;)
                    }

                case .string(let string):
                    print(&quot;success - string : \(string)&quot;)

                @unknown default:
                    fatalError()
                }

            case .failure(let failure):
                print(&quot;receive Fail : \(failure.localizedDescription)&quot;)
                self?.closeWebSocket()  // 소켓 데이터가 제대로 오지 않기 때문에, 닫아준다
            }

            // recursive
            self?.receive()
        })

    }
}</code></pre>
</li>
</ul>
<br>


<h4 id="5-ping"><code>5. ping</code></h4>
<ul>
<li><p>서버에 의해 연결이 끊어지지 않도록 주기적으로 ping을 보낸다
(120초 Idle Timeout)
<img src="https://velog.velcdn.com/images/s_sub/post/dba3b743-fbbb-43bb-bbe3-e71fba257c1a/image.png" alt=""></p>
</li>
<li><p>선언해둔 timer 활용</p>
<pre><code class="language-swift">private func ping() {
    self.timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true, block: { [weak self] _ in

        self?.webSocket?.sendPing(pongReceiveHandler: { error  in
            if let error {
                print(&quot;Ping Error : \(error.localizedDescription)&quot;)
            } else {
                print(&quot;Ping Success&quot;) 
            }
        })
    })
}</code></pre>
</li>
</ul>
<br>


<h3 id="extension-websocketmanager-urlsessionwebsocketdelegate"><code>extension WebSocketManager: URLSessionWebSocketDelegate</code></h3>
<ul>
<li><code>URLSessionWebSocketDelegate</code>를 타고타고 올라가보면, 
최종적으로 <code>NSObjectProtocol</code> 을 채택하고 있다.</li>
<li>즉, 원래대로라면 <code>WebSocketManager</code> 에서 <code>NSObjectProtocol</code>의 필수 프로퍼티와 메서드를 구현해야 한다.</li>
<li>이러한 번거로움을 줄이기 위해 이미 해당 내용을 구현하고 있는 <code>NSObject</code>를 <code>WebSocketManger</code>가 상속받도록 한다
<img src="https://velog.velcdn.com/images/s_sub/post/b65175c9-d8ac-4f0c-8593-fcef80fdbf88/image.png" alt=""></li>
</ul>
<ul>
<li><p>소켓이 연결되었을 때와 해제되었을 때 실행되는 메서드</p>
<pre><code class="language-swift">extension WebSocketManager: URLSessionWebSocketDelegate {

    func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) {
        print(#function)
        print(&quot;WebSocket OPEN&quot;)

        isOpen = true

        receive()    // 데이터 수신 시작
    }

    func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
        print(#function)
        print(&quot;WebSocket CLOSE&quot;)

        isOpen = false
    }
}</code></pre>
</li>
</ul>
<br>


<h3 id="class-sockettestviewmodel-observableobject"><code>class SocketTestViewModel: ObservableObject</code></h3>
<h4 id="필요한-프로퍼티-선언">필요한 프로퍼티 선언</h4>
<pre><code class="language-swift">var marketData: Market    // 어떤 코인인지

@Published var askOrderBook: [OrderBookItem] = []    // 매도 정보
@Published var bidOrderBook: [OrderBookItem] = []    // 매수 정보

// RxSwift disposeBag -&gt; Combine cancellable
private var cancellable = Set&lt;AnyCancellable&gt;()</code></pre>
<br>


<h4 id="init"><code>init</code></h4>
<ul>
<li><p>소켓 연결. 즉, VM 인스턴스를 만들면 바로 소켓 통신이 시작된다</p>
<pre><code class="language-swift">init(market: Market) {
    self.marketData = market

    WebSocketManager.shared.openWebSocket()

    WebSocketManager.shared.send(marketData.market)

    // Rx subscribe        -&gt; Combine sink
    // Rx Scheduler(.main) -&gt; Combine receive
    // Rx Dispose          -&gt; Combine AnyCancellable
    WebSocketManager.shared.orderBookSbj
        .receive(on: DispatchQueue.main)
        .sink { [weak self] order in
            guard let self else { return }

            self.askOrderBook = order.orderbookUnits
                .map { .init(price: $0.askPrice, size: $0.askSize)}
                .sorted { $0.price &gt; $1.price }

            self.bidOrderBook = order.orderbookUnits
                .map { .init(price: $0.bidPrice, size: $0.bidSize)}
                .sorted { $0.price &gt; $1.price }
        }
        .store(in: &amp;cancellable)
}</code></pre>
</li>
</ul>
<br>


<h4 id="deinit"><code>deinit</code></h4>
<ul>
<li>VM 인스턴스가 해제되면 소켓 연결 중단</li>
<li>만약 메모리 누수가 발생해서 <code>deinit</code> 함수가 실행되지 않으면 소켓은 영원히 해제되지 않는다. 주의하기<pre><code class="language-swift">deinit {
    WebSocketManager.shared.closeWebSocket()
}    </code></pre>
</li>
</ul>
<br>


<h2 id="websocket-결과">WebSocket 결과</h2>
<p><img src="https://velog.velcdn.com/images/s_sub/post/e7238beb-329f-4938-b2b1-8f3297dbd50a/image.gif" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[새싹 iOS] 21주차_In-App Purchase]]></title>
            <link>https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-21%EC%A3%BC%EC%B0%A8</link>
            <guid>https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-21%EC%A3%BC%EC%B0%A8</guid>
            <pubDate>Tue, 05 Dec 2023 02:30:38 GMT</pubDate>
            <description><![CDATA[<h1 id="in-app-purchase-iap">In-App Purchase (IAP)</h1>
<h1 id="iap-특성--정책">IAP 특성 &amp; 정책</h1>
<hr>
<ol>
<li>개발자 계정의 모든 앱 기준, <strong>최대 10,000개</strong>의 앱 내 구입 제품 생성 가능</li>
</ol>
<ol start="2">
<li>사용자 경험을 위해, <strong>국가/지역 설정 불가능</strong>. 특정 국가에만 보여주거나 숨기기 불가능</li>
</ol>
<ol start="3">
<li>유니버셜의 경우에도 <strong>일부 플랫폼에 대해 인앱 상품 제거 불가능</strong><ul>
<li>유니버셜 : AppStoreConnect에서 두 개 이상의 플랫폼 유형 선택</li>
<li>동일한 앱 내 구입 상품을 여러 플랫폼에서 제공할 수 있어야 한다</li>
</ul>
</li>
</ol>
<ol start="4">
<li>자동 갱신 구독 및 비소모성 인앱 상품의 경우 가족 공유 설정이 가능하지만, <strong>가족 공유 한 번 하고나면 비활성화 불가능</strong></li>
</ol>
<ol start="5">
<li>앱 내 구입 삭제는 <strong>최소 31일 전 공지</strong>가 필요하며, 프로모션에 인앱 상품이 포함되어 있다면 미리 종료해야 한다</li>
</ol>
<ol start="6">
<li>인앱 상품 생성<ul>
<li>상품 종류 (소모성, 비소모성, 자동 갱신, 비자동 갱신) 선택 후 등록</li>
<li>Localization에 대한 추가 / 제거 가능</li>
<li>프로모션 이미지 추가 및 제거 (iOS 11 이상)<ul>
<li>앱 내 구입 상품을 App Store에서 홍보 가능<ul>
<li>한 번에 최대 20개의 프로모션 이미지 가능    </li>
</ul>
</li>
</ul>
</li>
<li>앱 심사 정보 추가<ul>
<li>추가 정보와 인앱 상품이 포함된 스크린샷으로 심사 진행</li>
<li>심사 시에만 반영되고, App Store에는 반영되지 않는다</li>
</ul>
</li>
</ul>
</li>
</ol>
<ol start="7">
<li>Sandbox 테스트 계정<ul>
<li>결제 테스트 가능 (iOS 14 이상)</li>
<li>결제 실패 테스트 가능</li>
<li>&quot;구독&quot; 같은 기능 테스트할 때 유용함 <strong>자동 갱신 구독 테스트</strong><ul>
<li>지속 기간 단축, 구독 해지 전까지 최대 5회 자동 갱신</li>
<li>(1주일: 3분, 1개월: 5분, ... 1년: 1시간)</li>
</ul>
</li>
<li>만약 Sandbox 계정이 아닌 실제 결제로 테스트를 할 경우, 구매자가 직접 애플에 환불 요청을 해야 한다<ul>
<li>개발자에게 환불 처리하는 것은 불가능</li>
</ul>
</li>
</ul>
</li>
</ol>
<ol start="8">
<li>AppStore 서버 알림 URL<ul>
<li>AppStoreConnect [일반 정보] -&gt; [앱 정보]</li>
<li>사용자의 인앱 상품에 대한 변경 알림 (구독 중지, 구입 환불, ...)</li>
</ul>
</li>
</ol>
<ol start="9">
<li>StoreKit 2 사용 가능<ul>
<li><code>async / await</code> 도입</li>
<li>StoreKit 1의 각종 제약 수정</li>
<li>실시간으로 변하는 상태 체크가 쉽게 가능해짐</li>
</ul>
</li>
</ol>
<br>
<br>


<h1 id="iap-구현">IAP 구현</h1>
<hr>
<ol>
<li>유료 개발자 계정 생성 및 유료 응용 프로그램 계약 서명</li>
<li>App Store Connect에서 앱 네 구입 설정</li>
<li>Xcode로 앱 내 구입 활성화</li>
<li>앱 내 구입 디자인 및 제작</li>
<li>앱 내 구입 테스트</li>
<li>App Store에 앱 및 앱 내 구입 출시</li>
</ol>
<br>

<h2 id="1-유료-개발자-계정-생성-및-유료-응용-프로그램-계약-서명">1. 유료 개발자 계정 생성 및 유료 응용 프로그램 계약 서명</h2>
<p>  <img src="https://velog.velcdn.com/images/s_sub/post/515012c1-007b-4ff0-b14d-e17a3b471c04/image.png" alt=""></p>
<h2 id="2-app-store-connect에서-앱-내-구입-설정">2. App Store Connect에서 앱 내 구입 설정</h2>
<p>  <img src="https://velog.velcdn.com/images/s_sub/post/5d7f776c-2435-40cb-8b4b-0f5452690438/image.png" alt=""></p>
<p>  <img src="https://velog.velcdn.com/images/s_sub/post/ae002011-0ecf-44a6-a204-387ae9e94970/image.png" alt=""></p>
<p>  <img src="https://velog.velcdn.com/images/s_sub/post/9a1b45dd-78f5-47cd-83f4-322bca6fc4a3/image.png" alt=""></p>
<p>  <img src="https://velog.velcdn.com/images/s_sub/post/67d00d4c-4d01-4a92-965a-5d45383e9cb4/image.png" alt=""></p>
<h2 id="3-xcode-앱-내-구입-활성화">3. Xcode 앱 내 구입 활성화</h2>
<p><img src="https://velog.velcdn.com/images/s_sub/post/92efa3d9-935d-43eb-8ba2-08d110c7e5e8/image.png" alt=""></p>
<h2 id="4-앱-내-구입-디자인-및-제작-코드-구현">4. 앱 내 구입 디자인 및 제작 (코드 구현)</h2>
<h3 id="iapserviceswift">IAPService.swift</h3>
<h4 id="import-storekit"><code>import StoreKit</code></h4>
<ul>
<li><code>SKProduct</code> : 상품 정보 (price, name, ...)</li>
<li><code>SKPayment</code> : 지불 정보 (product id, request data)</li>
<li><code>SKPaymentRequest</code> : 결제 요청 시 사용 타입 (delegate로 성공/실패 알려줌)</li>
<li><code>SKPaymentQueue.default()</code> : 데이터 가져오기에 사용</li>
</ul>
<br>


<h4 id="productrequestcompletionhandler"><code>ProductRequestCompletionHandler</code></h4>
<ul>
<li>내부 딜리게이트를 통해 IAP 성공/실패 알 수 있고, 외부에도 completion 제공하는 식으로 구현하기 위해 따로 completion 정의</li>
<li>completion은 외부에서 정의하고, 실행은 내부에서 불림 (delegate 패턴)<pre><code class="language-swift">// completion 정의. (외부에서 정의하고, 내부에서 실행)
typealias ProductRequestCompletionHandler = (_ success: Bool, _ products: [SKProduct]?) -&gt; Void</code></pre>
</li>
</ul>
<br>


<h4 id="protocol-iapservicetype"><code>protocol IAPServiceType</code></h4>
<ul>
<li><p>외부에서 필요한 메서드 명시</p>
<ul>
<li><code>getProducts</code> : products 항목 (상품 정보) 가져오기</li>
<li><code>buyProduct</code> : product 구입</li>
<li><code>isProductPurchased</code> : 구입했는지 확인</li>
<li><code>restorePurchased</code> : 구입한 목록 확인</li>
</ul>
<pre><code class="language-swift">// 프로토콜 -&gt; 외부에서 필요한 메서드 명시
protocol IAPServiceType {
    var canMakePayments: Bool { get }

    func getProducts(completion: @escaping ProductRequestCompletionHandler)
    func buyProduct(_ product: SKProduct)
    func isProductPurchased(_ productID: String) -&gt; Bool
    func restorePurchases()
}</code></pre>
</li>
</ul>
<br>


<h4 id="class-iapservice"><code>class IAPService</code></h4>
<ul>
<li><code>NSObject</code> 상속 (StoreKit 사용), <code>IAPServiceType</code> 채택<pre><code class="language-swift">class IAPService: NSObject, IAPServiceType {</code></pre>
</li>
</ul>
<br>


<ul>
<li><p>필요한 프로퍼티 선언 및 init</p>
<ul>
<li><code>productIDs</code> : 앱스토어 커넥트에 등록된 productID</li>
<li><code>purchaseProductIDs</code> : 구매한 productID</li>
<li><code>productRequest</code> : productID로 부가 정보 조회하기 위한 인스턴스</li>
<li><code>productsCompletionHandler</code> : <strong>사용하는 쪽</strong>에서 성공/실패 했을 때 completion을 통해 값을 넘겨줄 수 있다.<pre><code class="language-swift">// 필요한 프로퍼티
private let productIdentifiers: Set&lt;String&gt;
private var purchasedProducts: Set&lt;String&gt; = []
private var productRequest: SKProductsRequest?
private var productsCompletionHandler: ProductsRequestCompletionHandler?
</code></pre>
</li>
</ul>
<p>// 상품 정보 받아서 초기화 및 SKPaymentQueue 연결
init(productIdentifiers: Set<String>) {</p>
<pre><code>// &quot;com.blogPost.App.~~&quot;
self.productIdentifiers = productIdentifiers

self.purchasedProducts = productIdentifiers.filter {
    // UserDefaults에 저장해둔 구매 여부
    UserDefaults.standard.bool(forKey: $0) == true
}

super.init()

SKPaymentQueue.default().add(self)
// App Store와 지불정보를 동기화하기 위한 Observer
// -&gt; SKPaymentTransactionObserver 프로토콜 채택</code></pre><p>}</p>
<pre><code>
</code></pre></li>
</ul>
<br>


<ul>
<li><code>canMakePayment</code> : 사용자의 디바이스가 현재 결제가 가능한지 확인<pre><code class="language-swift">var canMakePayments: Bool {
    return SKPaymentQueue.canMakePayments()
}</code></pre>
</li>
</ul>
<br>


<ul>
<li><p>프로토콜 메서드 정의</p>
<pre><code class="language-swift">// 상품 정보 조회
func getProducts(completion: @escaping ProductsRequestCompletionHandler) {
    self.productRequest?.cancel()
    self.productsCompletionHandler = completion
    self.productRequest = SKProductsRequest(productIdentifiers: self.productIdentifiers)
    self.productRequest?.delegate = self
    // -&gt; SKProductsRequestDelegate 채택
    self.productRequest?.start()    // 인앱 상품 조회 시작
    // -&gt; delegate 함수 실행 (SKProductsRequestDelegate)
}

// 상품 구입
func buyProduct(_ product: SKProduct) {
    let payment = SKPayment(product: product)
    SKPaymentQueue.default().add(payment)
    // -&gt; paymentQueue() 함수 실행
}

// 구입 확인
func isProductPurchased(_ productID: String) -&gt; Bool {
    return self.purchasedProducts.contains(productID)
}

// 구입 내역 복원
func restorePurchases() {
    SKPaymentQueue.default().restoreCompletedTransactions()
}</code></pre>
</li>
</ul>
<br>


<h4 id="extension-iapservice-skproductsrequestdelegate"><code>extension IAPService: SKProductsRequestDelegate</code></h4>
<ul>
<li><p><code>getProducts</code>에서 completionHandler를 캡쳐해두고, 여기서 실행한다</p>
<pre><code class="language-swift">extension IAPService: SKProductsRequestDelegate {

    // App Store Connect에서 상품 정보 조회
    func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {

        let products = response.products

        self.productsCompletionHandler?(true, products)

        self.clearRequestAndHandler()

        // 출력
        products.forEach { item in
            print(&quot;Found Product : \(item.productIdentifier) \(item.localizedTitle)&quot;)
        }
    }

</code></pre>
</li>
</ul>
<pre><code>  // fail
  func request(_ request: SKRequest, didFailWithError error: Error) {

      self.productsCompletionHandler?(false, nil)

      self.clearRequestAndHandler()

      // 출력
      print(&quot;Error : \(error.localizedDescription)&quot;)
  }

  // 초기화
  private func clearRequestAndHandler() {
      self.productRequest = nil
      self.productsCompletionHandler = nil
  }</code></pre><p>  }</p>
<pre><code>

&lt;br&gt;


#### `extension IAPService: SKPaymentTransactionObserver`
- `SKPaymentQueue`에서 처리되는 일
  - `purchased`, `failed`, `restored`, `deferred`, `purchasing`
  - 구매 성공 or 구매 복원 시 UserDefaults 업데이트 및 noti 해주기
  ```swift
  extension IAPService: SKPaymentTransactionObserver {
      func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
          print(#function)

          for transaction in transactions {
              let state = transaction.transactionState

              switch state {
              case .purchasing:
                  print(&quot;구매하는 중&quot;)
              case .purchased:
                  print(&quot;구매 완료&quot;)
                  completePurchase(transaction: transaction)
              case .failed:
                  print(&quot;구매 실패&quot;)
                  failPurchase(transaction: transaction)
              case .restored:
                  print(&quot;구매 복원&quot;)
                  restorePurchase(transaction: transaction)
              case .deferred:
                  print(&quot;deferred&quot;)
              @unknown default:
                  fatalError()
              }
          }
      }

      // 구매 완료 (성공)
      private func completePurchase(transaction: SKPaymentTransaction) {
          print(#function)

          guard let id = transaction.original?.payment.productIdentifier else { return }
          deliverPurchaseNotificationFor(id: id)
          SKPaymentQueue.default().finishTransaction(transaction)
      }

      // 구매 실패
      private func failPurchase(transaction: SKPaymentTransaction) {
          print(#function)

          if let transactionError = transaction.error as NSError?,
             transactionError.code != SKError.paymentCancelled.rawValue {
              print(&quot;TransactionError : \(transactionError.localizedDescription)&quot;)
          }
          SKPaymentQueue.default().finishTransaction(transaction)
      }

      // 구매 복원 성공
      private func restorePurchase(transaction: SKPaymentTransaction) {
          print(#function)

          guard let id = transaction.original?.payment.productIdentifier else { return }
          deliverPurchaseNotificationFor(id: id)
          SKPaymentQueue.default().finishTransaction(transaction)
      }

      private func deliverPurchaseNotificationFor(id: String?) {
          guard let id else { return }

          self.purchasedProducts.insert(id)
          UserDefaults.standard.set(true, forKey: id)

          NotificationCenter.default.post(
              name: .iapServicePurchaseNotification,    // 곧 정의
              object: id
          )
      }

      func paymentQueue(_ queue: SKPaymentQueue, removedTransactions transactions: [SKPaymentTransaction]) {
          print(#function)
      }
  }</code></pre><br>


<h4 id="extension-notificationname"><code>extension Notification.Name</code></h4>
<ul>
<li>노티 관리를 위한 메세지 정의<pre><code class="language-swift">extension Notification.Name {
    static let iapServicePurchaseNotification = Notification.Name(&quot;IAPServicePurchaseNotification&quot;)
}</code></pre>
</li>
</ul>
<br>


<h3 id="myproductsswift">MyProducts.swift</h3>
<h4 id="enum-myproducts"><code>enum MyProducts</code></h4>
<ul>
<li><p>Product ID를 가지고 있는 Wrapping 타입. (IAPService를 wrapping한다)</p>
<pre><code class="language-swift">enum MyProducts {
    static let productID = &quot;com.blogPost.s_sub.heart100&quot;
    static let iapService: IAPServiceType = IAPService(productIdentifiers: Set&lt;String&gt;([productID]))

    static func getResourceProductName(_ id: String) -&gt; String? {
        id.components(separatedBy: &quot;.&quot;).last
    }
}</code></pre>
</li>
</ul>
<br>


<h3 id="iaptestviewcontrollerswift">IAPTestViewController.swift</h3>
<h4 id="class-iaptestviewcontroller"><code>class IAPTestViewController</code></h4>
<ul>
<li><p>구현한 내용 이용하기 </p>
<pre><code class="language-swift">class FinalIAPTestViewController: UIViewController {

  private let restoreButton = UIButton()
  private let buyButton = UIButton()
  private let productLabel = UILabel()

  private var products = [SKProduct]()

  func setting() {...}

</code></pre>
</li>
</ul>
<pre><code>override func viewDidLoad() {
    super.viewDidLoad()

    setting()
    getProducts()
}

// 상품 정보 조회
func getProducts() {
    MyProducts.iapService.getProducts { [weak self] success, products in
        if success,
           let products = products {
            DispatchQueue.main.async {
                self?.products = products
                self?.productLabel.text = &quot;\(products.first?.productIdentifier ?? &quot;&quot;)\n \(products.first?.localizedTitle ?? &quot;&quot;)&quot;
            }
        }
    }
}

// 구매 버튼 클릭
@objc func buyButtonClicked() {
    MyProducts.iapService.buyProduct(products.first!)
}

// 복구 버튼 클릭 (구입 목록 조회)
@objc func restoreButtonClicked() {
    MyProducts.iapService.restorePurchases()
}


// 노티 세팅
func setNotification() {
    NotificationCenter.default.addObserver(
        self,
        selector: #selector(handlePurchaseNoti(_:)),
        name: .iapServicePurchaseNotification,
        object: nil
    )
}

@objc private func handlePurchaseNoti(_ notification: Notification) {
    // 노티 핸들 (뷰 업데이트)
}</code></pre><p>}</p>
<pre><code>


## 6. 앱 내 구입 테스트

![](https://velog.velcdn.com/images/s_sub/post/d48fd8b4-9be6-4951-8e31-60025a949e78/image.png)

![](https://velog.velcdn.com/images/s_sub/post/9779011e-d36f-445e-8fa5-66a73e2e2dc2/image.png)

![](https://velog.velcdn.com/images/s_sub/post/96e98957-4f8d-446c-8663-f2bd732e73ba/image.png)

![](https://velog.velcdn.com/images/s_sub/post/6a3a050e-a7b3-41f2-aaea-cc1cac7aecf2/image.png)


&lt;br&gt;
&lt;br&gt;
&lt;br&gt;



# 영수증 유효성 검증
---
- 위 코드 상에서는 따로 영수증 검증을 하지 않았지만, 사용자가 상품을 구매하려고 할 때 영수증 유효성에 대한 검증을 해야 한다.
- 서버가 없는 경우 클라이언트 상에서 검증하고, 서버가 있을 때는 영수증 정보를 서버에 전달한다.
- **영수증 검증이 완료된 후에 트랜잭션을 종료한다**   
`SKPaymentQueue.default().finishTransaction(transaction)`


### 1. 영수증 정보
```swift
func getReceiptData(_ productIdentifier: String) -&gt; String? {
    if let receiptURL = Bundle.main.appStoreReceiptURL,
       FileManager.default.fileExists(atPath: receiptFileURL.path) {

       do {
               let receiptData = try Data(contensOf: receiptFileURL)
            let receiptString = receiptData.base64EncodedString(
                options: NSData.Base64EncodingOptions(rawValue: 0)
            )
            return receiptString

       } catch {
               print(&quot;구매 이력 영수증 데이터 변환 과정에서 에러 : \(error.localizedDescription)&quot;)
            return nil
       }

    }

    print(&quot;구매 이력 영수증 URL 에러&quot;)
    return nil
}</code></pre><br>

<h3 id="2-영수증-검증-서버-x">2. 영수증 검증 (서버 x)</h3>
<ul>
<li>Validation URL Endpoint<ul>
<li>Sandbox URL : <a href="https://sandbox.itunes.apple.com/verifyReceipt">https://sandbox.itunes.apple.com/verifyReceipt</a></li>
<li>Production URL : <a href="https://buy.itunes.apple.com/verifyReceipt">https://buy.itunes.apple.com/verifyReceipt</a></li>
</ul>
</li>
</ul>
<ol>
<li>클라이언트 -&gt; 애플 (production URL)<ul>
<li>애플 서버(production URL)로 영수증 정보를 POST로 보낸다</li>
</ul>
</li>
</ol>
<ol start="2">
<li>애플 -&gt; 클라이언트 <ul>
<li>애플 서버에서 응답값을 클라이언트에게 보낸다</li>
<li><strong>status</strong> 값이 0이면, 유효한 영수증으로 판단한다</li>
<li><strong>status</strong> 값이 21007이면, Sandbox 테스트용 영수증임을 의미한다
<img src="https://velog.velcdn.com/images/s_sub/post/09d17cbe-db33-4de0-9166-9a1c9b2fca98/image.png" alt=""></li>
</ul>
</li>
</ol>
<ol start="3">
<li>클라이언트 -&gt; 애플 (sandbox URL)<ul>
<li>2에서 21007을 받았으면, 해당 정보를 sandbox URL로 다시 보낸다</li>
</ul>
</li>
</ol>
<ol start="4">
<li>애플 -&gt; 클라이언트<ul>
<li><strong>status</strong> 값이 0이면, 유효한 영수증으로 판단한다.
<img src="https://velog.velcdn.com/images/s_sub/post/2f8d589f-5cc2-4ea3-8969-064210330089/image.png" alt=""></li>
</ul>
</li>
</ol>
<ul>
<li>이 과정에서 만약 <strong>cancellation_date_ms</strong> 라는 키가 추가되어서 응답이 왔다면, 환불 영수증으로 판단한다</li>
<li>유효한 영수증으로 판단될 경우, 구매한 인앱 상품에 대한 로직을 클라이언트에서 처리한다. (광고 제거, 테마 사용, ...)</li>
</ul>
<br>

<h3 id="3-영수증-검증-서버-o">3. 영수증 검증 (서버 o)</h3>
<ul>
<li>서버가 있다면, 영수증 유효성 검증은 서버에서 진행한다.</li>
<li>이 경우 클라이언트의 역할에 대해 보자<br>

</li>
</ul>
<h4 id="1-클라이언트---서버--인앱-구매-목록-요청--응답">1. 클라이언트 -&gt; 서버 : 인앱 구매 목록 요청 / 응답</h4>
<ul>
<li>가장 먼저, 클라이언트에서 <strong>사용자가 이미 상품을 구매했는지</strong> 에 대한 판단을 해주어야 한다</li>
<li>만약 비소모성 상품이라면, 더 이상 구매가 불가능하도록 처리해주어야 한다</li>
<li>즉, 서버에게 해당 사용자의 인앱 구매 목록에 대한 데이터를 요청하고, 이미 상품을 구매했는지 체크한다.</li>
</ul>
<ul>
<li>사용자가 해당 상품을 안드로이드 기기의 구글 플레이 스토어에서 다운받은 앱에서 구매했을 가능성도 생각해야 한다. 이 경우에도 역시 상품의 구매 상태를 유지시켜주어야 하기 때문에 항상 서버에게 사용자의 구매 목록에 대한 요청을 해야 한다.</li>
</ul>
<ul>
<li>응답을 받으면 인앱 상품 표시 / 구매 상태에 반영한다</li>
</ul>
<br>

<h4 id="2-클라이언트---서버--구매-가능-여부-요청--응답">2. 클라이언트 -&gt; 서버 : 구매 가능 여부 요청 / 응답</h4>
<ul>
<li>진짜 구매가 가능한지에 대한 요청을 서버에게 보낸다. 구매 목록에 없는 걸 확인했더라도, 이 이후부터 구매 요청 사이에 사용자가 다른 기기에서 구매할 가능성을 고려한다. 이 찰나의 순간에 다른 디바이스에서 결제가 이루어질 수도 있기 때문이다</li>
</ul>
<br>

<h4 id="3-클라이언트---애플--인앱-결제-요청--응답">3. 클라이언트 -&gt; 애플 : 인앱 결제 요청 / 응답</h4>
<ul>
<li>애플 서버에 실제 결제에 대한 요청을 진행한다</li>
</ul>
<br>

<h4 id="4-클라이언트---서버--영수증-검증-요청--응답">4. 클라이언트 -&gt; 서버 : 영수증 검증 요청 / 응답</h4>
<ul>
<li>결제 정보에 대한 영수증 검증을 서버에 요청한다. 이 경우에는 서버에서 영수증 검증을 하고, 결과를 응답으로 보내준다.</li>
</ul>
<br>

<h4 id="5-클라이언트---서버--인앱-결제-완료-요청--응답">5. 클라이언트 -&gt; 서버 : 인앱 결제 완료 요청 / 응답</h4>
<ul>
<li>최종적으로 결제에 대한 완료 요청을 진행한다</li>
</ul>
<br>
<br>
<br>

<h1 id="구매-복원-restore">구매 복원 (restore)</h1>
<hr>
<ul>
<li>만약 내가 아이폰을 바꿨다고 하자. 이전 기기에서 사용하던 앱을 새롭게 다운받았는데, 이전 기기에서 해당 앱의 &quot;광고 제거&quot; 상품을 구매했다.</li>
<li>이러한 경우에, 반드시 <strong>구매 복원</strong>을 해주어야 한다.</li>
</ul>
<ul>
<li>구매 복원은 비소모성 상품에 대해 가능하고, 소모성 상품에 대해서는 불가능하다</li>
</ul>
<ul>
<li>구매 복원에 대한 기능은 <strong>필수로 구현</strong>해야 하고, 없다면 리젝 사유가 될 수 있다.</li>
</ul>
<ul>
<li>만약 서버가 존재해서 구매 복원에 대한 기능을 서버에서 자체적으로 해주고 있다면, 앱 내에서 구현하지 않아도 된다. (심사 제출 시 소명으로 언급해주면 된다)</li>
</ul>
<ul>
<li>즉, 서버가 없다면 애플 계정에 따라 클라이언트 상에서 체크하는 과정이 필요하지만, 서버에서 사용자의 구매 내역을 저장해주고 있다면 굳이 클라이언트 상에서 이 기능을 구현할 필요는 없다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[새싹 iOS] 20주차_WidgetKit]]></title>
            <link>https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-20%EC%A3%BC%EC%B0%A8</link>
            <guid>https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-20%EC%A3%BC%EC%B0%A8</guid>
            <pubDate>Tue, 28 Nov 2023 01:38:04 GMT</pubDate>
            <description><![CDATA[<h2 id="widgetkit">WidgetKit</h2>
<ul>
<li>iOS 14부터 도입</li>
<li>SwiftUI로만 구현 가능 (UIViewRepresentable 불가능)</li>
</ul>
<ul>
<li><strong>위젯은 미니앱이 아니다</strong><ul>
<li>사용자에게 정보를 보여주기 위한 도구에 불과</li>
<li>위젯 자체가 하나의 앱 기능을 할 수는 없다</li>
<li>메모리 30MB 제약</li>
</ul>
</li>
</ul>
<ul>
<li>버전<ul>
<li>iOS 14 : 홈 화면, 오늘 보기</li>
<li>iOS 16 : + 잠금 화면</li>
<li>iOS 17 : + Mac Desktop, iPad Lock Screen, StandBy, Watch Smart Stack</li>
</ul>
</li>
</ul>
<ul>
<li><p><strong>Widget Configuration</strong> (속성 편집에 대한 기능)</p>
<ul>
<li><p>Static Configuration
: 위젯 편집 항목이 나타나지 않으며, 사용자가 설정을 변경할 수 있는 옵션이 없다</p>
</li>
<li><p>Intent Configuration
: 위젯 편집 기능을 통해 사용자가 여러 Intent값을 수정할 수 있도록 위젯을 구성할 수 있다
: iOS 17부터 &#39;AppIntentConfigutation&#39; 으로 변경</p>
</li>
<li><p>Activity Configuration
: Live Activity</p>
</li>
</ul>
</li>
</ul>
<h3 id="widget-extension">Widget Extension</h3>
<ul>
<li><p>새로운 타겟으로 &#39;Widget Extension&#39;을 추가한다
<img src="https://velog.velcdn.com/images/s_sub/post/eb65e57e-f56d-4eb2-8d93-31ccdacf2dc1/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/s_sub/post/37b6fca6-e65f-44b7-aadd-157ae3acd2ea/image.png" alt=""></p>
</li>
</ul>
<br>


<ul>
<li>기본적으로 폴더 단위로 나눠지게 되고, 다른 타겟이기 때문에 <strong>접근 제어자</strong>에 따라 기존 프로젝트에 대한 접근 여부가 결정된다.
<img src="https://velog.velcdn.com/images/s_sub/post/27e23e3f-0f09-48c3-aa24-99452e61c958/image.png" alt=""></li>
<li>또는 인스펙터 영역의 <strong>Target Membership</strong>에서 사용 범위를 정할 수 있다.
<img src="https://velog.velcdn.com/images/s_sub/post/c895a682-95ee-4be3-90af-3ab740398d13/image.png" alt=""></li>
</ul>
<br>

<h2 id="코드">코드</h2>
<ul>
<li>위젯 파일은 크게 4가지의 struct로 구성되어 있다.
<img src="https://velog.velcdn.com/images/s_sub/post/de86bfe3-78b0-4807-968a-d227acd62f0b/image.png" alt=""><ul>
<li><strong>Provider</strong>에서 사용자가 설정한 시간에 맞춰 위젯을 업데이트할 수 있게 한다</li>
<li><strong>Entry</strong>에서 위젯에 필요한 데이터를 제공한다</li>
<li><strong>EntryView</strong>는 Entry를 통해 구성하며, UI를 담당하는 역할과 유사하다</li>
<li><strong>Widget</strong>에서는 static, intent, activity인지에 따라 최종적인 위젯을 구성한다</li>
</ul>
</li>
</ul>
<h3 id="1-provider">1. Provider</h3>
<ul>
<li>&quot;어떤 시간대에 어떻게 업데이트할지&quot;</li>
<li><code>typealias</code>를 통해 사용할 구조체를 정한다<pre><code class="language-swift">struct Provider: TimelineProvider {
  typealias Entry = SimpleEntry
</code></pre>
</li>
</ul>
<pre><code>- 사용자는 짧은 시간 내에 위젯을 보아야 하기 때문에, 위젯에 로딩이 길면 좋지 않다.
- 그래서 미리 위젯 뷰를 그리고 있다가 시간에 맞춰 뷰를 업데이트하고, TimelineEntry 배열을 통해 특정 시간에 위젯을 업데이트 할 수 있도록 한다.
- 즉, Provider는 위젯의 디스플레이를 업데이트할 시기를 WidgetKit에게 알려주는 역할을 수행한다
![](https://velog.velcdn.com/images/s_sub/post/9843c8c3-90a4-4dec-973f-a48121bdb225/image.png)


#### 1. placeholder
- 위젯을 최초로 렌더링할 때 사용한다 (스켈레톤 뷰 역할)
- 데이터를 보여줄 때까지 걸리는 시간동안 보여줄 뷰
- 사용자가 잠금 화면에서 민감한 정보를 숨기도록 선택한 경우 잠금 해제 전까지 placeholder로 위젯을 숨길 수 있다
- AOD 화면에서 잠금 전까지 보이지 않도록 구성할 수 있다
  ```swift
  func placeholder(in context: Context) -&gt; SimpleEntry {
      SimpleEntry(
          date: Date(),
          emoji: &quot;😀&quot;,
          title: &quot;플레이스 홀더 타이틀&quot;,
          price: 1200000
      )
  }</code></pre><h4 id="2-getsnapshot">2. getSnapshot</h4>
<ul>
<li>위젯 추가할 때 미리보기 화면 (위젯 갤러리)</li>
<li>위젯을 구성하는 데이터가 네트워크 통신을 통해서 가져오거나, 계산하는 데 몇 초 이상 걸릴 경우를 대비해 mock 데이터를 이용해 빠르게 위젯을 그릴 수 있도록 설정할 때도 이용
<img src="https://velog.velcdn.com/images/s_sub/post/d0f4d10f-7f54-4b48-b3dc-b1de8f16d2aa/image.png" alt=""><pre><code class="language-swift">func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -&gt; ()) {
    let entry = SimpleEntry(
        date: Date(),
        emoji: &quot;😎&quot;,
        title: &quot;미리보기 타이틀&quot;,
        price: 16000000
    )
    completion(entry)
}</code></pre>
</li>
</ul>
<h4 id="3-gettimeline">3. getTimeLine</h4>
<ul>
<li><p>위젯 상태 변경 시점 (시간에 대한 핸들링)</p>
</li>
<li><p>뷰를 미리 렌더링하고 올린다. (위젯의 작동 방식 - <a href="https://developer.apple.com/videos/play/wwdc2020/10028/">Meet WidgetKit</a>)</p>
</li>
<li><p>Widget 상태가 변경될 미래 시간이 포함된 <code>timelineEntry</code> 배열과 timeline 정책을 포함하고 있는 <code>Timeline</code>을 반환한다</p>
</li>
<li><p><code>.atEnd</code>는 <code>TimelineReloadPolicy</code> 구조체에서 설정되어 있는 타입 프로퍼티로, <strong>타임의 마지막 날짜가 지난 후, WidgetKit이 새로운 타임라인을 요청할 수 있도록 지정하는 정책</strong> 에 해당한다.</p>
<pre><code class="language-swift">func getTimeline(in context: Context, completion: @escaping (Timeline&lt;Entry&gt;) -&gt; ()) {
    var entries: [SimpleEntry] = []

    let currentDate = Date()

    // 타임라인 배열
    for hourOffset in 0 ..&lt; 30 {
        let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!

        let entry = SimpleEntry(
            date: entryDate,
            emoji: &quot;😇&quot;,
            title: &quot;타임라인 타이틀&quot;,
            price: 2000000
        )

        entries.append(entry)
    }

    let timeline = Timeline(entries: entries, policy: .atEnd)
    // .never : 요청 x
    // .after : 특정 시간 이후

    completion(timeline)
}</code></pre>
</li>
</ul>
<br>

<h3 id="2-entry">2. Entry</h3>
<ul>
<li>위젯을 구성하는 데 필요한 데이터를 갖는다</li>
<li><code>TimelineEntry</code> 프로토콜을 채택한다<ul>
<li><code>date</code> : 필수로 가져야 하며, 위젯이 다시 그려질 시간에 대한 정보를 갖는다</li>
<li><code>relevance</code> : 스마트 스택을 가진 위젯에서 위젯의 우선순위를 결정한다. (Score가 높은 위젯이 스택의 최상단으로 올라오도록 설정되어 있다)<pre><code class="language-swift">struct SimpleEntry: TimelineEntry {    
  let date: Date
  let emoji: String
  let title: String 
  let price: Int
}</code></pre>
</li>
</ul>
</li>
</ul>
<br>


<h3 id="3-entryview">3. EntryView</h3>
<ul>
<li><p>Provider를 통해 Entry를 제공받으면, Entry를 이용해서 위젯의 뷰를 그려준다</p>
</li>
<li><p><code>Entry</code>를 매개변수로 가지는 SwiftUI View이기 때문에 원하는 UI를 자유롭게 구성할 수 있다</p>
<pre><code class="language-swift">struct MyCoinOrderBookWidgetEntryView : View {
    var entry: Provider.Entry   // 프로퍼티로 Entry에 대한 정보를 넣어준다

    var body: some View {
        VStack {
            Text(entry.date, style: .time)
            Text(entry.emoji)
            Text(entry.title)
            Text(entry.price.formatted())
        }
    }
}</code></pre>
</li>
</ul>
<h3 id="4-widget">4. Widget</h3>
<ul>
<li><p>최종적으로 <code>WidgetConfiguration</code>을 구성한다</p>
</li>
<li><p>동일한 크기의 여러 위젯을 만들 수 있는데, <code>kind</code>는 위젯의 고유한 문자열이다.</p>
</li>
<li><p><code>.configurationDisplayName</code>, <code>description</code> 을 통해 위젯 갤러리에서 보일 위젯의 이름과 설명을 설정할 수 있다</p>
</li>
<li><p><code>.supportedFamilies</code> 를 통해 제공할 위젯의 크기를 설정할 수 있다</p>
<pre><code class="language-swift">// 위젯의 정보
struct MyCoinOrderBookWidget: Widget {
    let kind: String = &quot;MyCoinOrderBookWidget&quot;  

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            if #available(iOS 17.0, *) {
                MyCoinOrderBookWidgetEntryView(entry: entry)
                    .containerBackground(.fill.tertiary, for: .widget)
            } else {
                MyCoinOrderBookWidgetEntryView(entry: entry)
                    .padding()
                    .background()
            }
        }
        .configurationDisplayName(&quot;보유 코인&quot;)  
        .description(&quot;실시간 시세를 확인하세요&quot;)
        .supportedFamilies([.systemSmall, .systemLarge, .systemMedium])
    }
}</code></pre>
</li>
</ul>
<br>

<h2 id="app-group">App Group</h2>
<ul>
<li>위젯과 앱은 엄연히 다른 앱이기 때문에, 데이터를 share하고 싶으면 <strong>AppGroup</strong>을 만들어 주어야 한다.<ul>
<li><strong>AppGroup을 통해 App과 App Extension 간 데이터를 공유할 수 있다</strong></li>
<li>Extension의 Bundle은 Container App 번들에 포함되어 있지만, 두 가지는 각각 Container를 가지고 있기 때문에 둘 사이에는 데이터가 share되지 않는다.
<img src="https://velog.velcdn.com/images/s_sub/post/0df4dcf9-4fc5-4118-be39-e11332aeabc2/image.png" alt=""></li>
</ul>
</li>
</ul>
<ul>
<li>&quot;Singing &amp; Capabilities&quot; 에서 App Group을 추가해준다
<img src="https://velog.velcdn.com/images/s_sub/post/9f25aa1b-31b8-4162-a907-32ee0a9772be/image.png" alt="">
<img src="https://velog.velcdn.com/images/s_sub/post/d2b5c945-536e-4796-b80a-7d48122eed55/image.png" alt=""></li>
</ul>
<h3 id="share-userdefaults">Share UserDefaults</h3>
<ul>
<li>UserDefaults 역시 App과 App Extension에서 저장되는 위치가 다르기 때문에 <strong>shared container</strong>를 만들어서 처리하는 과정이 필요하다</li>
</ul>
<h4 id="1-userdefaultsgroupshared">1. UserDefaults.groupShared</h4>
<ul>
<li>해당 아이디를 가진 그룹 내에 있는 UserDefaults에 저장할 수 있도록 한다<pre><code class="language-swift">extension UserDefaults {
  static var groupShared: UserDefaults {
      let appGroupID = &quot;group.widgetTest.myCoinOrderBook&quot;
      return UserDefaults(suiteName: appGroupID)!
  }
}</code></pre>
</li>
</ul>
<h4 id="2-entry-view">2. Entry View</h4>
<pre><code class="language-swift">struct MyCoinOrderBookWidgetEntryView : View {
    var entry: Provider.Entry   // 프로퍼티로 Entry에 대한 정보를 넣어준다

    var body: some View {
        VStack {
            Text(entry.date, style: .time)
            Text(entry.emoji)
            Text(UserDefaults.groupShared.string(forKey: &quot;Market&quot;) ?? &quot;기본값&quot; )
            Text(entry.title)
            Text(entry.price.formatted())
        }
    }
}</code></pre>
<h4 id="3-setting">3. Setting</h4>
<pre><code class="language-swift">.onAppear {
  UserDefaults.groupShared.set(viewModel.market.koreanName, forKey: &quot;Market&quot;)
}</code></pre>
<br>


<h2 id="widgetcenter">WidgetCenter</h2>
<h4 id="1-getcurrentconfiguration">1. getCurrentConfiguration</h4>
<ul>
<li>현재 활성화되어 있는 위젯 정보를 확인할 수 있다<pre><code class="language-swift">WidgetCenter.shared.getCurrentConfigurations { response in
    switch response {
    case .success(let info):
        print(info)
    case .failure(let error):
        print(error)
    }
}</code></pre>
<img src="https://velog.velcdn.com/images/s_sub/post/0ad14774-228f-4a0c-863b-b5103950c6ae/image.png" alt=""></li>
</ul>
<h4 id="2-reloadtimelines">2. reloadTimeLines</h4>
<ul>
<li><p>필요한 시점에 위젯을 업데이트할 수 있다</p>
<pre><code class="language-swift">.onAppear {
    viewModel.fetchOrderBook()

    print(&quot;----- 현재 활성화 되어 있는 위젯 -----&quot;)
    WidgetCenter.shared.getCurrentConfigurations { response in
        switch response {
        case .success(let info):
            print(info)
        case .failure(let error):
            print(error)
        }
    }

    print(&quot;이전 : &quot;, UserDefaults.groupShared.string(forKey: &quot;Market&quot;))
    UserDefaults.groupShared.set(viewModel.marketData.koreanName, forKey: &quot;Market&quot;)
    print(&quot;이후 : &quot;, UserDefaults.groupShared.string(forKey: &quot;Market&quot;))

    WidgetCenter.shared.reloadTimelines(ofKind: &quot;MyCoinOrderBookWidget&quot;)
}</code></pre>
<p><img src="https://velog.velcdn.com/images/s_sub/post/cca8bac4-31f7-48fa-81bb-dbfb9709d793/image.gif" alt=""></p>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[새싹 iOS] 19주차_SwiftUI Source of Truth]]></title>
            <link>https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-19%EC%A3%BC%EC%B0%A8</link>
            <guid>https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-19%EC%A3%BC%EC%B0%A8</guid>
            <pubDate>Fri, 24 Nov 2023 00:19:38 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<ul>
<li>앱에서 UI는 <strong>Data에 의존</strong> 해서 자신의 상태를 결정한다
(Data 변경 -&gt; View 갱신)</li>
</ul>
<ul>
<li>UIKit에서는 Data의 변경 시점마다 UI 업데이트에 대한 모든 코드를 작성해야 한다.
따라서 Data 변경에 따른 View 관리가 쉽지 않았다.</li>
</ul>
<ul>
<li>이러한 불편함을 해소하기 위해 SwiftUI에서는 <strong>Source of Truth</strong> 를 통해 <strong>Data와 UI 사이의 의존 관계</strong> 를 만들고,</li>
<li><em>Data의 상태가 변하면 body를 rendering*</em> 하라는 하나의 의존 관계로 정의하고 있다.</li>
</ul>
<br>

<h2 id="source-of-truth">Source of Truth</h2>
<ul>
<li><p>View에서 읽는 모든 Data에는 <strong>Source of Truth</strong> 가 존재한다 -&gt; <code>@State</code></p>
</li>
<li><p>메인 화면과 상세 화면의 Data가 동일해야 한다면, 동일한 Data 요소는 한 군데에서 다뤄져야 한다 -&gt; <code>@Binding</code></p>
<blockquote>
<p>Source of Truth : Derived Value = @State : @Binding</p>
</blockquote>
</li>
<li><p>SoT는 <strong>단일(Single Source of Truth)</strong>로 존재해야 하며, 이를 복제하는 것은 상태의 불일치를 일으킬 수 있다.
<img src="https://velog.velcdn.com/images/s_sub/post/7093a536-0546-4a70-915d-3864e44d8d52/image.png" alt=""></p>
</li>
<li><p>즉, Data의 변경에 자동으로 UI가 반응하는(의존 관계) SwiftUI에서는 Data에 대한 관리가 중요한데, <strong>단 하나의 데이터</strong> 를 다른 뷰에게 전달함으로써 데이터를 여러 곳에서 복사해서 사용하는 사이드이펙트까지 방지할 수 있다.</p>
</li>
</ul>
<br>


<h2 id="property-wrapper">Property Wrapper</h2>
<ul>
<li>데이터의 일관성을 유지하기 위해, SwiftUI에서는 다음과 같은 Property Wrapper를 사용한다.</li>
</ul>
<h3 id="1-state">1. @State<img src="https://velog.velcdn.com/images/s_sub/post/257b7ee4-6683-4b97-a6c3-11dd5aec8589/image.png" alt=""></h3>
<ul>
<li><p>View에서 가지는 Source of Truth로, Data에 대한 상태를 저장하고 관찰한다.</p>
</li>
<li><p>해당 View가 Data를 소유하고 관리한다는 개념을 명시적으로 나타내기 위해 <code>private</code> 접근 제어자를 사용하는 것이 좋다.</p>
</li>
<li><p><strong>&quot;상태 프로퍼티&quot;</strong> <code>@State</code> 는
　1. 이 Data는 변할 수 있고, 2. View가 Data에 의존성을 가진다는 것을 의미한다.</p>
</li>
<li><p>즉, Data가 변경이 되면 View도 변경되어야 한다는 의미로, 변경 사항이 감지될 때마다 매번 View를 새로 그려준다.</p>
</li>
<li><p><strong>@State를 통해 하나의 Single Source of Truth가 생성된다</strong></p>
</li>
</ul>
<br>


<h3 id="2-binding">2. @Binding<img src="https://velog.velcdn.com/images/s_sub/post/013eaaf8-9425-48c2-a78b-94b1b233d7ff/image.png" alt=""></h3>
<ul>
<li><p>외부에서 Source of Truth를 주입받고 참조받는다.</p>
</li>
<li><p>상위 뷰가 가진 상태를 하위 뷰에서 사용하고 수정할 수 있도록 하는 Derived Value 이다.</p>
</li>
<li><p>값을 읽고 수정하여 다른 뷰에 갱신된 데이터를 전달한다.</p>
</li>
<li><p><code>$</code> 접두어를 사용하며, 내부적으로 PropertyWrapper의 projectedValue를 이용한다는 것을 의미한다</p>
</li>
<li><p>같은 뷰에서 값을 읽거나 쓰는 경우에는 <code>$</code> 없이 일반 변수처럼 사용이 가능하다.</p>
</li>
</ul>
<br>

<h3 id="3-published">3. @Published<img src="https://velog.velcdn.com/images/s_sub/post/86206393-8373-41eb-a9d0-8696f6bd3063/image.png" alt=""></h3>
<ul>
<li><p><code>ObservableObject</code> 프로토콜에서 프로퍼티를 선언할 때 사용되는 property wrapper</p>
</li>
<li><p><code>@Published</code> 로 선언된 프로퍼티의 값이 변경될 때마다 뷰 업데이트</p>
<blockquote>
<p><code>ObservableObject</code> 프로토콜</p>
</blockquote>
<ul>
<li>클래스에 <code>ObservableObject</code> 프로토콜을 채택하면, 이 프로토콜은 해당 클래스의 인스턴스를 관찰하고 있다가 내부의 <code>@Published</code> 로 선언된 데이터가 변경이 될 때마다 신호를 보내주고, 신호를 받은 뷰는 업데이트된다.</li>
</ul>
</li>
</ul>
<br>

<h3 id="4-stateobject">4. @StateObject<img src="https://velog.velcdn.com/images/s_sub/post/91ffd3cf-d54b-48f8-9de9-8ad12a806ba4/image.png" alt=""></h3>
<ul>
<li><p><code>ObservvableObject</code> 프로토콜을 구독하고 값이 업데이트될 때마다 뷰를 갱신하는 propertywrapper</p>
</li>
<li><p><code>@Published</code> 로 선언된 데이터가 변경될 때의 신호를 받는다</p>
</li>
<li><p><code>@StateObject</code> 로 선언된 인스턴스는 생성 시점에 한 번 초기화되고, 이후 뷰의 렌더링 여부와 상관 없이 <code>@StateObject</code> 로 선언된 변수는 뷰 내에서 재사용된다.</p>
</li>
</ul>
<br>

<h3 id="5-observedobject">5. @ObservedObject<img src="https://velog.velcdn.com/images/s_sub/post/17110488-bdfb-4fc1-8ba7-ded9bca9a9d7/image.png" alt=""></h3>
<ul>
<li><p><code>ObservvableObject</code> 프로토콜을 구독하고 값이 업데이트될 때마다 뷰를 갱신하는 propertywrapper</p>
</li>
<li><p><code>@State</code>와 <code>@Binding</code>의 관계처럼 <code>@StateObject</code>로 선언한 인스턴스를 함께 공유하고 싶을 때 사용한다
즉, 상위 뷰에서 <code>@StateObject</code> 로 만든 데이터를 하위 뷰에게 전달하고 싶을 때, 데이터를 주입해주면 하위 뷰에서는 인스턴스를 직접 생성하지 않고 전달받아 사용할 수 있다.</p>
</li>
<li><p><code>@ObservedObject</code> 로 선언된 인스턴스는 뷰가 렌더링될 때 인스턴스가 새롭게 생성되기 때문에 기존에 <code>@StateObject</code>로 주입받은 데이터가 휘발될 수 있다.</p>
</li>
</ul>
<br>

<h3 id="참고">참고</h3>
<p><a href="https://developer.apple.com/videos/play/wwdc2019/226/">WWDC : Data Flow Through SwiftUI</a>
<a href="https://80000coding.oopy.io/0930a96d-aaa5-4831-9974-03615ae1d6b6">SwiftUI) Source of Truth란?</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[새싹 iOS] 18주차_UserDefaults 추상화]]></title>
            <link>https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-18%EC%A3%BC%EC%B0%A8</link>
            <guid>https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-18%EC%A3%BC%EC%B0%A8</guid>
            <pubDate>Sat, 18 Nov 2023 05:54:51 GMT</pubDate>
            <description><![CDATA[<h2 id="0-basic">0. Basic</h2>
<p>일반적인 <code>UserDefualts</code> 사용 방법</p>
<pre><code class="language-swift">enum Key: String {
    case email, nickname, phone
}

// set
UserDefaults.standard.set(&quot;0@naver.com&quot;, forKey: Key.email.rawValue)
UserDefaults.standard.set(&quot;보리밥&quot;, forKey: Key.nickname.rawValue)
UserDefaults.standard.set(&quot;010-1234-1234&quot;, forKey: Key.phone.rawValue)

// get
let email = UserDefaults.standart.string(forKey: Key.email.rawValue)
let nickname = UserDefaults.standart.string(forKey: Key.nickname.rawValue)
let phone = UserDefaults.standart.string(forKey: Key.phone.rawValue)
</code></pre>
<br>

<h2 id="1-enum-userdefaultsmanager">1. enum UserDefaultsManager</h2>
<p>UserDefault에 직접 접근하지 않고,
열거형 <code>UserDefaultsManager</code>를 통해 접근한다</p>
<pre><code class="language-swift">enum UserDefaultsManager {

    // 컴파일 최적화 측면 - enum 내부에 선언한다
    enum Key: String {
        case email, nickname, phone
    }

    // 1. email 
    static var email: String {
        get {
            UserDefaults.standard.string(forKey: Key.email.rawValue) ?? &quot;No Email&quot;
        }
        set {
            UserDefaults.standard.set(newValue, forKey: Key.email.rawValue)
        }
    }

    // 2. nickname
    static var nickname: String {
        get {
            UserDefaults.standard.string(forKey: Key.nickname.rawValue) ?? &quot;No Nickname&quot;
        }
        set {
            UserDefaults.standard.set(newValue, forKey: Key.nickname.rawValue)
        }
    }

    // 3. phone
    static var phone: String {
        get {
            UserDefaults.standard.string(forKey: Key.phone.rawValue) ?? &quot;No Phone&quot;
        }
        set {
            UserDefaults.standard.set(newValue, forKey: Key.phone.rawValue)
        }
    }
}

/* ===== 사용 ===== */
UserDefaultsManager.email = &quot;1@naver.com&quot;
UserDefaultsManager.nickname = &quot;보리보리밥&quot;
UserDefaultsManager.phone = &quot;010-1234-1234&quot;

let email = UserDefaultsManager.email
let nickname = UserDefaultsManager.nickname
let phone = UserDefaultsManager.phone</code></pre>
<br>

<h2 id="2-struct-mydefaults">2. struct MyDefaults</h2>
<p>구조체 <code>MyDefaults</code> 로 한 번 더 감싼다</p>
<pre><code class="language-swift">struct MyDefaults {
    let key: String 
    let defaultValue: String 

    var myValue: String {
        get {
            UserDefaults.standard.string(forKey: key) ?? defaultValue
        }
        set {
            UserDefaults.standard.set(newValue, forKey: key)
        }
    }
}


enum UserDefaultsManager {

    enum Key: String {
        case email, nickname, phone
    }

    static var email = MyDefaults(key: Key.email.rawValue, defaultValue: &quot;No Email&quot;)
    static var nickname = MyDefaults(key: Key.nickname.rawValue, defaultValue: &quot;No Nickname&quot;)
    static var phone = MyDefaults(key: Key.phone.rawValue, defaultValue: &quot;No Phone&quot;)
}

/* ===== 사용 ===== */
UserDefaultsManager.email.myValue = &quot;2@naver.com&quot;
let email = UserDefaultsManager.email.myValue
</code></pre>
<br>

<h2 id="25-struct-mydefaults--generic">2.5 struct MyDefaults + Generic</h2>
<p>위 구조체 형식으로 만들면, 결국 <code>myValue</code> 타입이 달라질 때마다 또 다시 연산 프로퍼티를 여러 개 만들어야 한다.
이를 해결하기 위해 Generic 을 활용한다.</p>
<pre><code class="language-swift">struct MyDefaults&lt;T&gt; {
    let key: String 
    let defaultValue: T

    let myValue: T {
        get {
            UserDefaults.standard.object(forKey: key) as? T ?? defaultValue
        }
        set {
            UserDefaults.standard.set(newValue, forKey: key)
        }
    }
}

</code></pre>
<br>

<h2 id="3-property-wrapper">3. Property Wrapper</h2>
<p>구조체 <code>MyDefaults</code> 에 대해 propertyWrapper의 역할을 하도록 한다.
기존에 <code>myValue</code>로 선언한 프로퍼티를 <code>wrappedValue</code> 로 교체함으로써 사용 시 코드를 더 줄일 수 있게 된다</p>
<pre><code class="language-swift">@propertyWrapper
struct MyDefaults&lt;T&gt; {
    let key: String 
    let defaultValue: T

    var wrappedValue: T {
        get {
            UserDefaults.standard.object(forKey: key) as? T ?? defaultValue
        }
        set {
            UserDefaults.standard.set(newValue, forKey: key)
        }
    }
}

enum UserDefaultsManager {

    enum Key: String {
        case email, nickname, phone
    }

    @MyDefaults(key: Key.email.rawValue, defaultValue: &quot;No Email&quot;)
    static var email

    @MyDefaults(key: Key.nickname.rawValue, defaultValue: &quot;No Nickname&quot;)
    static var nickname

    @MyDefaults(key: Key.phone.rawValue, defaultValue: &quot;No Phone&quot;)
    static var phone
}


/* ===== 사용 ===== */
UserDefaultsManager.email = &quot;3@naver.com&quot;
let email = UserDefaultsManager.email.myValue


</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[새싹 iOS] 17주차_FSCalendar 날짜 범위(기간) 선택]]></title>
            <link>https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-17%EC%A3%BC%EC%B0%A8</link>
            <guid>https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-17%EC%A3%BC%EC%B0%A8</guid>
            <pubDate>Sun, 12 Nov 2023 13:32:41 GMT</pubDate>
            <description><![CDATA[<h3 id="완성본">완성본</h3>
<p><img src="https://velog.velcdn.com/images/s_sub/post/65d900f3-f645-4794-9f89-f1608a88f369/image.png" alt=""></p>
<br>

<h2 id="1-소개">1. 소개</h2>
<ul>
<li>사용 라이브러리 : <a href="https://github.com/WenchaoD/FSCalendar">FSCalendar</a></li>
</ul>
<ul>
<li>구현 기능<ul>
<li>하루 선택 시 원 모양 배경으로 선택 날짜 표시</li>
<li>시작/마지막 날짜 선택 시 두 날짜를 포함한 기간을 이어진 배경으로 표시</li>
</ul>
</li>
</ul>
<ul>
<li>아이디어<ul>
<li><a href="https://stackoverflow.com/questions/49856370/how-to-select-range-fscalendar-in-swift">Stack Overflow - How to select range fscalendar</a></li>
<li><a href="https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-11%EC%A3%BC%EC%B0%A8">FSCalendar Custom Cell 적용</a></li>
<li>기본적으로 FSCalendar에서 제공하는 selection 효과를 사용하지 않고, 
커스텀 셀의 배경을 바꿔가면서 UI를 구현한다</li>
</ul>
</li>
</ul>
<ul>
<li>단점<ul>
<li>셀 클릭 시마다 <code>calendar.reloadData()</code>를 실행해야 하기 때문에 리소스 낭비가 심하다.</li>
</ul>
</li>
</ul>
<br>


<h2 id="2-구현">2. 구현</h2>
<h3 id="1-calendar-setting">(1). Calendar Setting</h3>
<ul>
<li><p>사용할 calendar에 대해 기본적인 세팅을 한다</p>
<pre><code class="language-swift">var calendar = FSCalendar()

calendar.scrollDirection = .vertical    // 스크롤 방향
calendar.allowsMultipleSelection = true // 여러 날짜 선택 가능

calendar.register(SelectDatesCustomCalendarCell.self, forCellReuseIdentifier: SelectDatesCustomCalendarCell.description())  // 커스텀 셀 등록

calendar.appearance.titleFont = .boldSystemFont(ofSize: 18)    // 날짜 표시 레이블 폰트 설정
calendar.appearance.headerTitleFont = .boldSystemFont(ofSize: 20)    // 월 표시 레이블 폰트 설정

calendar.today = nil    // 기본 오늘 선택 해제
calendar.appearance.selectionColor = .clear    // 기본 선택 배경 투명 -&gt; 커스텀 셀 배경으로 표시
calendar.appearance.caseOptions = .weekdayUsesSingleUpperCase  // 요일 텍스트를 영어 한글자로 표시

// 기본 색상 선택
calendar.appearance.titleDefaultColor = .black
calendar.appearance.headerTitleColor = .black
calendar.appearance.weekdayTextColor = .black
calendar.appearance.titleSelectionColor = UIColor.appColor(.main1)</code></pre>
</li>
</ul>
<h3 id="2-enum">(2). enum</h3>
<ul>
<li>편의를 위해 날짜 타입을 enum으로 정의해준다.<pre><code class="language-swift">enum SelectedDateType {
    case singleDate    // 날짜 하나만 선택된 경우 (원 모양 배경)
    case firstDate    // 여러 날짜 선택 시 맨 처음 날짜
    case middleDate // 여러 날짜 선택 시 맨 처음, 마지막을 제외한 중간 날짜
    case lastDate   // 여러 날짜 선택시 맨 마지막 날짜
    case notSelectd // 선택되지 않은 날짜
}</code></pre>
</li>
</ul>
<h3 id="3-custom-cell">(3). Custom Cell</h3>
<ul>
<li>3개의 인스턴스가 필요하다<ul>
<li><strong>leftRectangle</strong>, <strong>rightRectangle</strong>, <strong>circle</strong></li>
<li>선택된 날짜의 타입(firstDate, middleDate, endDate)에 따라, 어떤 인스턴스를 Hidden 처리할지 결정한다.<br><img src="https://velog.velcdn.com/images/s_sub/post/3910d6fc-9f59-43eb-b6af-8ad8bda18e98/image.jpeg" alt=""></li>
</ul>
</li>
</ul>
<br>

<h4 id="레이아웃-및-기본-설정">레이아웃 및 기본 설정</h4>
<pre><code class="language-swift">
  class SelectDatesCustomCalendarCell: FSCalendarCell {

    var circleBackImageView = UIImageView()
    var leftRectBackImageView = UIImageView()
    var rightRectBackImageView = UIImageView()

    func setConfigure() {

        contentView.insertSubview(circleBackImageView, at: 0)
        contentView.insertSubview(leftRectBackImageView, at: 0)
        contentView.insertSubview(rightRectBackImageView, at: 0)
    }

    func setConstraints() {

        // 날짜 텍스트의 레이아웃을 센터로 잡아준다 (기본적으로 약간 위에 있다)
        self.titleLabel.snp.makeConstraints { make in
            make.center.equalTo(contentView)
        }

        leftRectBackImageView.snp.makeConstraints { make in
            make.leading.equalTo(contentView)
            make.trailing.equalTo(contentView.snp.centerX)
            make.height.equalTo(46)
            make.centerY.equalTo(contentView)
        }

        circleBackImageView.snp.makeConstraints { make in
            make.center.equalTo(contentView)
            make.size.equalTo(46)
        }

        rightRectBackImageView.snp.makeConstraints { make in
            make.leading.equalTo(contentView.snp.centerX)
            make.trailing.equalTo(contentView)
            make.height.equalTo(46)
            make.centerY.equalTo(contentView)
        }

    }

    func settingImageView() {
        circleBackImageView.clipsToBounds = true
        circleBackImageView.layer.cornerRadius = 23

        // 선택 날짜의 배경 색상을 여기서 정한다.
        [circleBackImageView, leftRectBackImageView, rightRectBackImageView].forEach { item  in
            item.backgroundColor = UIColor.appColor(.main3)
        }
    }
  }</code></pre>
<br>

<h4 id="날짜의-타입에-따라-셀의-배경-정해주기">날짜의 타입에 따라 셀의 배경 정해주기</h4>
<pre><code class="language-swift">  class SelectDatesCustomCalendarCell: FSCalendarCell {
      func updateBackImage(_ dateType: SelectedDateType) {
          switch dateType {
          case .singleDate:
              // left right hidden true
              // circle hidden false
              leftRectBackImageView.isHidden = true
              rightRectBackImageView.isHidden = true
              circleBackImageView.isHidden = false

          case .firstDate:
              // leftRect hidden true
              // circle, right hidden false
              leftRectBackImageView.isHidden = true
              circleBackImageView.isHidden = false
              rightRectBackImageView.isHidden = false

          case .middleDate:
              // circle hidden true
              // left, right hidden false
              circleBackImageView.isHidden = true
              leftRectBackImageView.isHidden = false
              rightRectBackImageView.isHidden = false

          case .lastDate:
              // rightRect hidden true
              // circle, left hidden false
              rightRectBackImageView.isHidden = true
              circleBackImageView.isHidden = false
              leftRectBackImageView.isHidden = false
          case .notSelectd:
              // all hidden
              circleBackImageView.isHidden = true
              leftRectBackImageView.isHidden = true
              rightRectBackImageView.isHidden = true
          }

      }

  }</code></pre>
<br>


<h3 id="4-viewcontroller">(4). ViewController</h3>
<h4 id="프로토콜-연결">프로토콜 연결</h4>
<pre><code class="language-swift">  class SelectedDateViewController: UIViewController {
      func settingCalendar() {
          mainView.calendar.delegate = self
          mainView.calendar.dataSource = self
      }
  }</code></pre>
<h4 id="필요한-변수">필요한 변수</h4>
<pre><code class="language-swift">class SelectedDateViewController: UIViewController {
    private var firstDate: Date?    // 배열 중 첫번째 날짜
    private var lastDate: Date?        // 배열 중 마지막 날짜
    private var datesRange: [Date] = []    // 선택된 날짜 배열
}</code></pre>
<h4 id="cellfor-date">cellFor date</h4>
<pre><code class="language-swift">extension SelectedDateViewController: FSCalendarDataSource {

    // 매개변수로 들어온 date의 타입을 반환한다
    func typeOfDate(_ date: Date) -&gt; SelectedDateType {

        let arr = datesRange

        if !arr.contains(date) {
            return .notSelectd    // 배열이 비어있으면 무조건 notSelected
        }

        else {
            // 배열의 count가 1이고, firstDate라면 singleDate
            if arr.count == 1 &amp;&amp; date == firstDate { return .singleDate }    

            // 배열의 count가 2 이상일 때, 각각 타입 반환
            if date == firstDate { return .firstDate }
            if date == lastDate { return .lastDate }

            else { return .middleDate }
        }
    }

    func calendar(_ calendar: FSCalendar, cellFor date: Date, at position: FSCalendarMonthPosition) -&gt; FSCalendarCell {

        guard let cell = calendar.dequeueReusableCell(withIdentifier: SelectDatesCustomCalendarCell.description(), for: date, at: position) as? SelectDatesCustomCalendarCell else { return FSCalendarCell() }

        // 현재 그리는 셀의 date의 타입에 기반해서 셀 디자인
        cell.updateBackImage(typeOfDate(date))

        return cell
    }
}</code></pre>
<h4 id="didselect">didSelect</h4>
<pre><code class="language-swift">extension SelectedDateViewController: FSCalendarDelegate {
    func calendar(_ calendar: FSCalendar, didSelect date: Date, at monthPosition: FSCalendarMonthPosition) {

        // case 1. 현재 아무것도 선택되지 않은 경우 
            // 선택 date -&gt; firstDate 설정
        if firstDate == nil {
            firstDate = date
            datesRange = [firstDate!]

            mainView.calendar.reloadData() // (매번 reload)
            return
        }

        // case 2. 현재 firstDate 하나만 선택된 경우
        if firstDate != nil &amp;&amp; lastDate == nil {
            // case 2 - 1. firstDate 이전 날짜 선택 -&gt; firstDate 변경
            if date &lt; firstDate! {
                calendar.deselect(firstDate!)
                firstDate = date
                datesRange = [firstDate!]

                mainView.calendar.reloadData()    // (매번 reload)
                return
            }

            // case 2 - 2. firstDate 이후 날짜 선택 -&gt; 범위 선택
            else {
                var range: [Date] = []

                var currentDate = firstDate!
                while currentDate &lt;= date {
                    range.append(currentDate)
                    currentDate = Calendar.current.date(byAdding: .day, value: 1, to: currentDate)!
                }

                for day in range {
                    calendar.select(day)
                }

                lastDate = range.last
                datesRange = range

                mainView.calendar.reloadData()    // (매번 reload)
                return
            }
        }

        // case 3. 두 개가 모두 선택되어 있는 상태 -&gt; 현재 선택된 날짜 모두 해제 후 선택 날짜를 firstDate로 설정
        if firstDate != nil &amp;&amp; lastDate != nil {

            for day in calendar.selectedDates {
                calendar.deselect(day)
            }

            lastDate = nil
            firstDate = date
            calendar.select(date)
            datesRange = [firstDate!]

            mainView.calendar.reloadData()    // (매번 reload)
            return
        }


    }
}</code></pre>
<h4 id="diddeselect">didDeselect</h4>
<pre><code class="language-swift">extension SelectDateViewController: FSCalendarDelegate {
    // 이미 선택된 날짜들 중 하나를 선택 -&gt; 선택된 날짜 모두 초기화
    func calendar(_ calendar: FSCalendar, didDeselect date: Date, at monthPosition: FSCalendarMonthPosition) {

        let arr = datesRange    
        if !arr.isEmpty {
            for day in arr {
                calendar.deselect(day)
            }
        }
        firstDate = nil
        lastDate = nil
        datesRange = []

        mainView.calendar.reloadData()    // (매번 reload)
    }</code></pre>
<br>

<h2 id="3-완성">3. 완성</h2>
<p><img src="https://velog.velcdn.com/images/s_sub/post/120afe8b-6aed-44c8-8af9-023f84198003/image.gif" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[새싹 iOS] 16주차_RxSwift Subject / CombineLatest]]></title>
            <link>https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-16%EC%A3%BC%EC%B0%A8Subject-CombineLatest</link>
            <guid>https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-16%EC%A3%BC%EC%B0%A8Subject-CombineLatest</guid>
            <pubDate>Sat, 04 Nov 2023 08:07:25 GMT</pubDate>
            <description><![CDATA[<h1 id="subject-종류">Subject 종류</h1>
<hr>
<h2 id="1-publishsubject">1. PublishSubject</h2>
<ul>
<li><p>초기값이 없다</p>
</li>
<li><p>구독 이후 전달받은 이벤트부터 반영된다</p>
</li>
<li><p>complete 이후의 이벤트는 반영되지 않는다</p>
<pre><code class="language-swift">func aboutPublishSubject() {
    let publish = PublishSubject&lt;Int&gt;()

    publish.onNext(20)
    publish.onNext(30)

    publish
        .subscribe(with: self) { owner , value in
            print(&quot;PublishSubject onNext - \(value)&quot;)
        } onError: { owner , error in
            print(&quot;PublishSubject onError - \(error)&quot;)
        } onCompleted: { owner in
            print(&quot;PublishSubject onCompleted&quot;)
        } onDisposed: { owner in
            print(&quot;PublishSubject onDisposed&quot;)
        }
        .disposed(by: disposeBag)

    publish.onNext(3)   // 요기부터 반영
    publish.onNext(45)
    publish.on(.next(9))

    publish.onCompleted()

    publish.onNext(200)
    publish.onNext(500)
}</code></pre>
<p><img src="https://velog.velcdn.com/images/s_sub/post/48fefd20-7302-4df1-9b7b-2ff4c5149a24/image.png" alt=""></p>
</li>
</ul>
<br>

<h2 id="2-behaviorsubject">2. BehaviorSubject</h2>
<ul>
<li><p>초기값이 있다 (선언 시 값을 넣어준다)</p>
</li>
<li><p>구독하기 전 가장 마지막 이벤트를 반영한다 (버퍼처럼 가지고 있는다)</p>
</li>
<li><p>complete 이후의 이벤트는 반영되지 않는다</p>
<pre><code class="language-swift">func aboutBehaviorSubject() {
    let behavior = BehaviorSubject(value: 100)

    behavior.onNext(20)
    behavior.onNext(30) // 얘는 요게 찍히네!!

    behavior
        .subscribe(with: self) { owner , value in
            print(&quot;BehaviorSubject onNext - \(value)&quot;)
        } onError: { owner , error in
            print(&quot;BehaviorSubject onError - \(error)&quot;)
        } onCompleted: { owner in
            print(&quot;BehaviorSubject onCompleted&quot;)
        } onDisposed: { owner in
            print(&quot;BehaviorSubject onDisposed&quot;)
        }
        .disposed(by: disposeBag)

    behavior.onNext(3)
    behavior.onNext(45)
    behavior.on(.next(9))

    behavior.onCompleted()

    behavior.onNext(200)
    behavior.onNext(500)
}</code></pre>
<p><img src="https://velog.velcdn.com/images/s_sub/post/4e4975cf-5a43-4b92-9987-f0b6243c2e16/image.png" alt=""></p>
</li>
</ul>
<br>

<h2 id="3-replaysubject">3. ReplaySubject</h2>
<ul>
<li><p>초기값이 없다</p>
</li>
<li><p>선언 시 버퍼 사이즈를 넣어준다</p>
</li>
<li><p>구독 시점 기준 버퍼 사이즈만큼 이전 이벤트부터 반영된다</p>
</li>
<li><p>complete 이후의 이벤트는 반영되지 않는다</p>
<pre><code class="language-swift">func aboutReplaySubject() {
    let replay = ReplaySubject&lt;Int&gt;.create(bufferSize: 3)

    replay.onNext(1)
    replay.onNext(2)
    replay.onNext(3)
    replay.onNext(4)
    replay.onNext(5)    // 요기부터 찍힘!! &lt;- 구독한 시점 기준으로 버퍼 사이즈만큼

    replay.onNext(20)
    replay.onNext(30)
    replay
        .subscribe(with: self) { owner , value in
            print(&quot;ReplaySubject onNext - \(value)&quot;)
        } onError: { owner , error in
            print(&quot;ReplaySubject onError - \(error)&quot;)
        } onCompleted: { owner in
            print(&quot;ReplaySubject onCompleted&quot;)
        } onDisposed: { owner in
            print(&quot;ReplaySubject onDisposed&quot;)
        }
        .disposed(by: disposeBag)

    replay.onNext(3)
    replay.onNext(45)
    replay.on(.next(9))

    replay.onCompleted()

    replay.onNext(200)
    replay.onNext(500)
}</code></pre>
<p><img src="https://velog.velcdn.com/images/s_sub/post/5af988cb-5bae-4298-96fd-4a1f47e919b7/image.png" alt=""></p>
</li>
</ul>
<br>

<h2 id="4-asyncsubject">4. AsyncSubject</h2>
<ul>
<li><p>초기값이 없다</p>
</li>
<li><p>구독 전 이벤트는 반영되지 않는다</p>
</li>
<li><p>complete 직전 이벤트 <strong>하나</strong>만 반영된다</p>
</li>
<li><p>complete 이후의 이벤트는 반영되지 않는다</p>
<pre><code class="language-swift">func aboutAsyncSubject() {
    let async = AsyncSubject&lt;Int&gt;()

    async.onNext(1)
    async.onNext(2)
    async.onNext(3)
    async.onNext(4)
    async.onNext(5)

    async.onNext(20)
    async.onNext(30)
    async
        .subscribe(with: self) { owner , value in
            print(&quot;AsyncSubject onNext - \(value)&quot;)
        } onError: { owner , error in
            print(&quot;AsyncSubject onError - \(error)&quot;)
        } onCompleted: { owner in
            print(&quot;AsyncSubject onCompleted&quot;)
        } onDisposed: { owner in
            print(&quot;AsyncSubject onDisposed&quot;)
        }
        .disposed(by: disposeBag)

    async.onNext(3)
    async.onNext(45)
    async.on(.next(9)) // 요기만 찍힘!

    async.onCompleted()

    async.onNext(200)
    async.onNext(500)
}</code></pre>
<p><img src="https://velog.velcdn.com/images/s_sub/post/ee8150aa-0630-4b97-8374-38ae5f953105/image.png" alt=""></p>
</li>
</ul>
<br>
<br>

<h1 id="combinelatest">CombineLatest</h1>
<hr>
<ul>
<li>두 개의 Sequence를 하나의 Sequence로 엮는다</li>
<li>서로 다른 타입의 Sequence를 받을 수 있고, 다른 타입의 Sequence를 만드는 것도 가능하다</li>
</ul>
<h4 id="예시">예시</h4>
<pre><code class="language-swift">let email = emailTextField.rx.text.orEmpty
let password = passwordTextField.rx.text.orEmpty

let validation = Observable.combineLatest(email, password) { first , second in
    return first.count &gt; 8 &amp;&amp; second.count &gt; 6
}

validation
    .bind(to: signInButton.rx.isEnabled)
    .disposed(by: disposeBag)

validation
    .subscribe(with: self) { owner , value in
        owner.signInButton.backgroundColor = value ? UIColor.blue : UIColor.red
        owner.emailTextField.layer.borderColor = value ? UIColor.blue.cgColor : UIColor.red.cgColor
        owner.passwordTextField.layer.borderColor = value ? UIColor.blue.cgColor : UIColor.red.cgColor
    }
    .disposed(by: disposeBag)</code></pre>
<br>


<h2 id="behaviorsubject-vs-publishsubject">BehaviorSubject vs. PublishSubject</h2>
<ul>
<li><code>CombineLatest</code> 는 엮어주는 두 Sequence에 모두 최초 이벤트가 있어야 
합쳐진 Sequence에서 이벤트가 발생한다. 
즉, 초기값이 없는 경우 이벤트가 발생하지 않는다.</li>
</ul>
<h3 id="1-behaviorsubject-사용">1. BehaviorSubject 사용</h3>
<ul>
<li><p>선언할 때 넣어준 초기값부터, <code>onNext</code>로 전달하는 이벤트들도 잘 반영된다</p>
<pre><code class="language-swift">func testCombineLatest() {
    let a = BehaviorSubject(value: 2)
    let b = BehaviorSubject(value: &quot;가&quot;)

    Observable.combineLatest(a, b) { first , second in
        return &quot;결과 : \(first) &amp; \(second)&quot;
    }
    .subscribe(with: self) { owner , value in
        print(value)
    }
    .disposed(by: disposeBag)

    a.onNext(100)
    a.onNext(200)
    a.onNext(300)

    b.onNext(&quot;감&quot;)
    b.onNext(&quot;남&quot;)
    b.onNext(&quot;담&quot;)
}</code></pre>
<p><img src="https://velog.velcdn.com/images/s_sub/post/6b18e0fb-4c33-441f-9da6-262c25daea38/image.png" alt=""></p>
</li>
</ul>
<h3 id="2-publishsubject-사용">2. PublishSubject 사용</h3>
<ul>
<li><p>두 subject 객체에 모두 값이 지정되기 전까지 subscribe가 일어나지 않는다</p>
<pre><code class="language-swift">func testCombineLatest() {
    let a = PublishSubject&lt;Int&gt;()
    let b = PublishSubject&lt;String&gt;() 

    Observable.combineLatest(a, b) { first , second in
        return &quot;결과 : \(first) &amp; \(second)&quot;
    }
    .subscribe(with: self) { owner , value in
        print(value)
    }
    .disposed(by: disposeBag)

    a.onNext(100)
    a.onNext(200)
    a.onNext(300)

    b.onNext(&quot;감&quot;)
    b.onNext(&quot;남&quot;)
    b.onNext(&quot;담&quot;)
}</code></pre>
<p><img src="https://velog.velcdn.com/images/s_sub/post/9c889c3d-c2f6-4513-87be-2eea7445667d/image.png" alt=""></p>
</li>
</ul>
<br>

<h3 id="rxcocoa">RxCocoa</h3>
<ul>
<li>최초 이벤트가 없을 때는 <code>CombineLatest</code> 이벤트가 발생하지 않는다는 건 알겠다.</li>
</ul>
<ul>
<li>Q. 그럼 맨 처음에 예시로 든 <code>TextField.rx.text.orEmpty</code> 는 초기값을 넣지 않았는데, 왜 이벤트가 잘 실행되었을까?</li>
<li>A. <code>TextField.rx.text.orEmpty</code> 는 초기값이 없는 것이 아니고, <strong>빈 문자열</strong>을 이벤트로 전달하기 때문에 정상적으로 <code>combineLatest</code> 이벤트가 실행된다</li>
</ul>
<ul>
<li><p>Q. 그럼 RxCocoa에서 제공하는 위와 같은 친구들은 모두 초기값이 있는 거라고 봐도 될까?</p>
</li>
<li><p>A. 그렇지 않더라. </p>
<ul>
<li>궁금해서 <code>Button.rx.tap</code> 을 두 개 만들어서 똑같이 테스트했는데,
하나의 버튼만 클릭했을 때는 이벤트가 발생하지 않고 나머지 버튼을 클릭한 순간
정상적으로 <code>combineLatest</code> 이벤트가 발생함을 확인할 수 있었다.</li>
</ul>
<pre><code class="language-swift">
let a = signInButton.rx.tap
let b = signUpButton.rx.tap

let emit = Observable.combineLatest(a, b) { first , second in
    print(&quot;탭탭 컴바인 레이티스트&quot;)
}
emit.subscribe(with: self) { owner , _ in
    print(&quot;탭탭 섭스크라이브&quot;)
}
.disposed(by: disposeBag)</code></pre>
<p><img src="https://velog.velcdn.com/images/s_sub/post/7a97ddb6-6c3e-4ece-b7e8-4807033e5df3/image.gif" alt="">
<img src="https://velog.velcdn.com/images/s_sub/post/0a9eaff7-e0f1-485d-8227-d890507dd44d/image.gif" alt=""></p>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[새싹 iOS] 16주차_RxSwift dispose]]></title>
            <link>https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-16%EC%A3%BC%EC%B0%A8RxSwift-Dispose</link>
            <guid>https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-16%EC%A3%BC%EC%B0%A8RxSwift-Dispose</guid>
            <pubDate>Fri, 03 Nov 2023 12:04:49 GMT</pubDate>
            <description><![CDATA[<ul>
<li><p>Subscribe 중인 Stream을 메모리에서 해제(리소스 정리)하기 위해서는 해당 Stream을 <strong>dispose</strong>하는 과정이 필요하다.</p>
</li>
<li><p>어느 상황에서 dispose가 실행될 수 있는지 정리해 보았다</p>
</li>
</ul>
<br>

<h2 id="1-반환값-타입--disposable">1. 반환값 타입 : Disposable</h2>
<hr>
<ul>
<li><p>일반적으로 사용하는 <code>subscribe</code> 함수는 반환값이 존재한다</p>
<pre><code class="language-swift">let textArray = BehaviorSubject(value: [&quot;Hue&quot;, &quot;Jack&quot;, &quot;Koko&quot;, &quot;Bran&quot;])

// 구독하기 -&gt; 반환값의 타입은? : Disposable
textArray.subscribe(with: self) { owner , value in
    print(&quot;onNext - \(value)&quot;)
} onError: { owner , error in
    print(&quot;onError - \(error)&quot;)
} onCompleted: { owner in
    print(&quot;onCompleted&quot;)
} onDisposed: { owner in
    print(&quot;onDisposed&quot;)
}</code></pre>
</li>
</ul>
<ul>
<li>그래서 이렇게만 쓰면 반환값을 사용하지 않았다는 노란색 warning이 뜬다
<img src="https://velog.velcdn.com/images/s_sub/post/4a1c9634-a6f0-4fb4-b51e-b69e904f43cd/image.png" alt=""></li>
</ul>
<ul>
<li>반환값의 타입은 <code>Disposable</code> 이라는 프로토콜인 걸 확인할 수 있다
<img src="https://velog.velcdn.com/images/s_sub/post/969420ba-6c15-4cff-8e3f-92d2c8f3001f/image.png" alt=""></li>
</ul>
<ul>
<li><p>어쨌든 이 반환값을 사용해야 하고, 그래서 일반적으로 <code>disposeBag</code>에 담는 코드를 뒤에 붙여준다</p>
<pre><code class="language-swift">let textArray = BehaviorSubject(value: [&quot;Hue&quot;, &quot;Jack&quot;, &quot;Koko&quot;, &quot;Bran&quot;])

// 구독하기 -&gt; 반환값의 타입은? : Disposable
textArray.subscribe(with: self) { owner , value in
    print(&quot;onNext - \(value)&quot;)
} onError: { owner , error in
    print(&quot;onError - \(error)&quot;)
} onCompleted: { owner in
    print(&quot;onCompleted&quot;)
} onDisposed: { owner in
    print(&quot;onDisposed&quot;)
}
.disposed(by: disposeBag)</code></pre>
</li>
</ul>
<br>

<h2 id="2-observable과-subject-차이-dispose-관점">2. Observable과 Subject 차이 (dispose 관점)</h2>
<hr>
<ul>
<li>Subject와 Observable의 정의를 배울 때 
Subject는 Observable과 Observer의 역할을 모두 수행한다 라고 배웠다</li>
</ul>
<ul>
<li><p>그럼 <strong>dispose되는 시점</strong>에 어느 차이가 있는지 확인해보자</p>
<pre><code class="language-swift">
let textArray = BehaviorSubject(value: [&quot;Hue&quot;, &quot;Jack&quot;, &quot;Koko&quot;, &quot;Bran&quot;])
let textArray = Observable.from([&quot;Hue&quot;, &quot;Jack&quot;, &quot;Koko&quot;, &quot;Bran&quot;])

/* subscribe 코드는 위와 동일하다 */</code></pre>
<p><img src="https://velog.velcdn.com/images/s_sub/post/55be0f3a-24f1-44f4-8358-3ee16b81f44f/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/s_sub/post/f4eee0cb-3f54-47ea-90ad-f60167906cd0/image.png" alt=""></p>
</li>
</ul>
<ul>
<li><strong><code>Observable</code></strong>로 선언한 경우, <code>onNext</code>로 방출이 끝나면 그 즉시 <code>onCompleted</code>가 실행되고, <code>onDisposed</code>까지 실행되면서 <code>dispose</code>가 완료되었음을 확인할 수 있다</li>
<li>하지만 <strong><code>Subject</code></strong>로 선언한 경우, <code>onNext</code> 실행 이후 별다른 코드가 보이지 않는다</li>
</ul>
<ul>
<li>단순하게 정의에서 차이점을 생각하면, </li>
<li><em><code>Observable</code>*</em>은 데이터를 방출한 순간, 역할을 다 했다. 하지만 <strong><code>Subject</code></strong>는 <code>Observer</code>의 역할, 즉 데이터를 전달받을 수도 있기 때문에 아직 역할이 남았다고 표현할 수 있다.</li>
<li>따라서 <strong><code>Subject</code></strong> 는 <code>onCompleted</code>가 실행되지 않고, <code>dispose</code>도 역시 실행되지 않는다</li>
</ul>
<br>

<h2 id="3-dispose-실행-onerror-oncompletd">3. dispose 실행 (onError, onCompletd)</h2>
<hr>
<ul>
<li><code>dispose</code> 는 리소스가 정리됨을 의미하고, 메모리가 해제됨을 의미한다. 
즉, <strong>더 이상 할 일이 없을 때</strong> <code>dispose</code>가 실행된다</li>
</ul>
<ul>
<li>2번에서 유추할 수 있는 점은, 아마 <code>onError</code> 또는<code>onCompleted</code>가 실행된다면 그 즉시 <code>dispose</code>가 실행될 것이다</li>
</ul>
<ul>
<li><code>onCompleted</code> 에 대해서는 2번에서 확인했으므로, <code>onError</code>를 실행시켜보자</li>
</ul>
<br>


<ul>
<li><p><code>onError</code> 실행시키는 것도 생각보다 간단하진 않다</p>
<pre><code class="language-swift">enum JackError: Error {    // Error 프로토콜의 열거형
    case invalid
}

textArray.onNext([&quot;hihi&quot;])
textArray.onNext([&quot;ho&quot;])

textArray.onError(JackError.invalid)    // Error 이벤트 전달

textArray.onNext([&quot;a&quot;, &quot;b&quot;, &quot;c&quot;])        // 여긴 전달이 되지 않을 것이다
textArray.onNext([&quot;d&quot;, &quot;e&quot;, &quot;f&quot;])</code></pre>
<p><img src="https://velog.velcdn.com/images/s_sub/post/067091d6-4e44-462e-85c4-a568287b13f6/image.png" alt=""></p>
</li>
<li><p><code>onError</code> 이벤트가 실행된 순간, <code>dispose</code>가 실행되는 것을 확인할 수 있다</p>
</li>
<li><p>당연히 <code>dispose</code> 된 이후에 <code>onNext</code>로 전달한 이벤트는 받을 수 없다</p>
</li>
</ul>
<br>


<h2 id="4-직접-dispose-disposable-프로토콜">4. 직접 dispose (Disposable 프로토콜)</h2>
<hr>
<ul>
<li>여태까지 정리한 내용은<ol>
<li><code>subscribe</code> 이후에는 <code>disposeBag</code> 이라는 곳에 Stream을 담아둔다</li>
<li><code>onError</code> 또는 <code>onCompleted</code>가 실행되면 알아서 <code>dispose</code>가 실행된다</li>
</ol>
</li>
</ul>
<ul>
<li>근데 <code>onError</code> 나 <code>onCompleted</code>가 실행되지 않았더라도 내 맘대로 <code>dispose</code>시키고 싶을 수도 있다. 즉, <strong>내가 원하는 시점</strong>에 <code>dispose</code>를 실행시키고 싶다</li>
</ul>
<ul>
<li>코드를 살짝 변형시켜서, <code>subscribe</code> 한 Stream 자체를 변수에 담는다<pre><code class="language-swift">let textArrayValue = textArray
                        .subscribe(with: self) { owner , value in
                            print(&quot;next - \(value)&quot;)
                        } onError: { owner , error in
                            print(&quot;error - \(error)&quot;)
                        } onCompleted: { owner in
                            print(&quot;completed&quot;)
                        } onDisposed: { owner in
                            print(&quot;disposed&quot;)
                        }</code></pre>
</li>
<li>요렇게 코드를 작성하면, 맨 처음 1번에서 목격한 노란색 워닝이 뜨지 않는다
(반환값을 상수에 받았으니까)</li>
<li>1번에서 확인했듯이, 상수의 타입은 <code>Disposable</code> 이다
<img src="https://velog.velcdn.com/images/s_sub/post/e5ccb366-5ca7-4f66-aa89-1b30239426ba/image.png" alt=""><ul>
<li><code>Disposable</code> 프로토콜의 정의를 타고 들어가면 <code>dispose</code> 메서드가 있고, 이게 내가 해야 실행시켜야 하는 메서드이다.</li>
</ul>
</li>
</ul>
<ul>
<li>즉, 이제 원하는 시점에 메서드를 실행시키기만 하면 된다<pre><code class="language-swift">DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
    textArrayValue.dispose()    // &quot;dispose&quot; : 직접적으로 리소스를 정리함
}</code></pre>
</li>
</ul>
<br>

<h2 id="5-disposebag의-메커니즘">5. disposeBag의 메커니즘</h2>
<hr>
<ul>
<li>여태까지 정리한 내용은, Stream을 <code>dispose</code> 하기 위해서<ol>
<li><code>onError</code> 또는 <code>onCompleted</code> 가 실행되거나</li>
<li>Stream을 상수에 저장해서 직접 메서드를 실행해야 한다</li>
</ol>
</li>
</ul>
<ul>
<li>그럼 여태까지 Rx를 배우면서 항상 당연하게 적었던 <code>.disposed(by: disposeBag)</code> 은 뭐냐</li>
<li>위의 두 케이스에 있지 않기 때문에 여태까지 작성한 Stream들은 모두 메모리에 남아있고, 꾸준히 메모리 누수를 발생시키고 있었던 것일까?</li>
</ul>
<ul>
<li>는 당연히 아니고, <code>disposBag</code>을 통해 <code>dispose</code> 메서드가 실행되고 있었다</li>
</ul>
<br>


<ul>
<li><p><code>DisposeBag</code> 클래스의 정의를 타고 들어가보자 
(설명에 필요하지 않은 내용은 지웠다)</p>
<pre><code class="language-swift">public final class DisposeBag: DisposeBase {

    // state
    private var disposables = [Disposable]()

    /// This is internal on purpose, take a look at `CompositeDisposable` instead.
        private func dispose() {
        let oldDisposables = self._dispose()

        for disposable in oldDisposables {
            disposable.dispose()
        }
    }

    private func _dispose() -&gt; [Disposable] {
        self.lock.performLocked {
            let disposables = self.disposables

            self.disposables.removeAll(keepingCapacity: false)
            self.isDisposed = true

            return disposables
    }

    deinit {
        self.dispose()
    }
} </code></pre>
</li>
</ul>
<ul>
<li>간단하게 코드를 훑어보면,<ol>
<li>내가 <code>disposeBag</code>에 담아둔 Stream들이 <code>disposables</code> 라는 <strong>배열에 저장</strong>된다</li>
<li><code>dispose</code> 메서드가 실행되면, 그 배열을 반복문으로 돌면서 각 Stream에 대해 <strong><code>dispose()</code> 를 실행</strong>한다. 위에서 내가 직접 <code>dispose()</code> 를 실행시킨 부분과 동일하다<ul>
<li>두 <code>dispose</code>가 다르다는 점을 주의하자</li>
</ul>
</li>
<li><code>dispose</code> 메서드가 실행되는 시점은, <strong>인스턴스가 <code>deinit</code>될 때</strong>이다</li>
</ol>
</li>
</ul>
<br>

<ul>
<li>정리하면, VC 또는 VM 클래스에서 계속 생성하고 다녔던
<code>let disposeBag = DisposeBag()</code> 인스턴스가 <code>deinit</code>될 때,
해당 <code>disposeBag</code>이 물고 있던(?) 모든 Stream에 대해 <code>dispose</code>가 실행된다.</li>
</ul>
<ul>
<li>따라서, 해당 VC 또는 VM 클래스가 <code>deinit</code>되는 순간 메모리 정리가 싹 되기 때문에 <strong>메모리 누수가 발생하지 않는다</strong></li>
</ul>
<br>


<h2 id="6-rootvc의-dispose">6. rootVC의 dispose</h2>
<hr>
<ul>
<li>5번을 정리하면, 어차피 VC가 정상적으로 <code>deinit</code> 되면, Stream들의 메모리 누수 걱정을 할 필요가 없다</li>
<li>하지만 <strong>rootVC</strong>는 <code>deinit</code>이 실행되지 않을 것이다. 만약 rootVC의 Stream에 대해 <code>dispose</code>를 실행해야 한다면 어떻게 해야 할까</li>
</ul>
<h4 id="1-직접-dispose">1. 직접 dispose</h4>
<ul>
<li><p>첫 번째 방법은 4번에서 했던 것처럼 모든 Stream에 대해 직접 <code>dispose</code> 메서드를 실행하는 것이다</p>
</li>
<li><p>4번과 동일하게, <code>disposeBag</code>에 담지 않고 모든 Stream을 상수에 담아주었다</p>
<pre><code class="language-swift">let increment = Observable&lt;Int&gt;.interval(.seconds(1), scheduler: MainScheduler.instance)

let incrementValue = increment
    .subscribe(with: self) { owner , value in
        print(&quot;next - \(value)&quot;)
    } onError: { owner , error in
        print(&quot;error - \(error)&quot;)
    } onCompleted: { owner in
        print(&quot;completed&quot;)
    } onDisposed: { owner in
        print(&quot;disposed&quot;)
    }

let incrementValue2 = increment
    .subscribe(with: self) { owner , value in
        print(&quot;next - \(value)&quot;)
    } onError: { owner , error in
        print(&quot;error - \(error)&quot;)
    } onCompleted: { owner in
        print(&quot;completed&quot;)
    } onDisposed: { owner in
        print(&quot;disposed&quot;)
    }

let incrementValue3 =  increment
    .subscribe(with: self) { owner , value in
        print(&quot;next - \(value)&quot;)
    } onError: { owner , error in
        print(&quot;error - \(error)&quot;)
    } onCompleted: { owner in
        print(&quot;completed&quot;)
    } onDisposed: { owner in
        print(&quot;disposed&quot;)
    }

</code></pre>
</li>
</ul>
<p>  // 필요한 시점에, 내가 직접 dispose 한다!
  DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
              incrementValue.dispose()
              incrementValue2.dispose()
              incrementValue3.dispose()
  }</p>
<pre><code>

#### 2. disposeBag 교체
- 근데 위 코드처럼 저러고 있으면, Stream의 양이 많아질수록 부담스럽다. 점점 편리한 `disposeBag` 이 생각나게 된다. 
- `disposeBag` 이 해줬던 것처럼, 물고 있는 모든 Stream에 대해 한 번에 `dispose`를 실행시킬 수는 없을까?


- 편리한 `disposeBag`의 기능을 사용하기 위해, 실행되어야 하는 것은 **disposeBag 인스턴스의 deinit 메서드**이다.
- 5번에서는 VC가 `deinit`될 때, 당연히 `disposeBag`도 `deinit`된다고 소개했다.
- 하지만, 이 경우가 아니더라도 충분히 `disposeBag`의 `deinit`을 실행시킬 수 있다

&lt;br&gt;

- **인스턴스를 교체한다**
  ```swift
  let increment = Observable&lt;Int&gt;.interval(.seconds(1), scheduler: MainScheduler.instance)

  increment
      .subscribe(with: self) { owner , value in
          print(&quot;next - \(value)&quot;)
      } onError: { owner , error in
          print(&quot;error - \(error)&quot;)
      } onCompleted: { owner in
          print(&quot;completed&quot;)
      } onDisposed: { owner in
          print(&quot;disposed&quot;)
      }
      .disposed(by: disposeBag)

  increment
      .subscribe(with: self) { owner , value in
          print(&quot;next - \(value)&quot;)
      } onError: { owner , error in
          print(&quot;error - \(error)&quot;)
      } onCompleted: { owner in
          print(&quot;completed&quot;)
      } onDisposed: { owner in
          print(&quot;disposed&quot;)
      }
      .disposed(by: disposeBag)

  increment
      .subscribe(with: self) { owner , value in
          print(&quot;next - \(value)&quot;)
      } onError: { owner , error in
          print(&quot;error - \(error)&quot;)
      } onCompleted: { owner in
          print(&quot;completed&quot;)
      } onDisposed: { owner in
          print(&quot;disposed&quot;)
      }
      .disposed(by: disposeBag)


  // 필요한 시점에, 내가 직접 dispose 한다!
  DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
      self.disposeBag = DisposeBag()
  }
</code></pre><br>

<h1 id="summary">Summary</h1>
<hr>
<ul>
<li><code>dispose</code> : 메모리에서 해제. 리소스 정리</li>
</ul>
<ul>
<li>실행 시점<ol>
<li><code>onError</code>, <code>onCompleted</code> 실행</li>
<li><code>Disposable</code> 프로토콜의 <code>dispose</code> 메서드 직접 실행<ol>
<li>직접 인스턴스에 접근해서 실행</li>
<li><code>DisposeBag</code> 클래스 이용<ul>
<li>일반적으로 VC 또는 VM이 <code>deinit</code>되면 
<code>disposeBag</code> 인스턴스도 <code>deinit</code>되고
물고 있던 Stream들 <code>dispose</code></li>
<li>rootVC라서 <code>deinit</code> 될 일이 없다면
원하는 시점에 <code>disposeBag</code> 인스턴스 교체</li>
<li><blockquote>
<p>기존 <code>disposeBag</code>의 <code>deinit</code> 실행</p>
</blockquote>
</li>
</ul>
</li>
</ol>
</li>
</ol>
</li>
</ul>
<br>

<h1 id="추가--tableviewcell의-재사용">추가 : TableViewCell의 재사용</h1>
<hr>
<ul>
<li>테이블 뷰 셀 위에 버튼이 있고, 해당 버튼을 누르면 화면 전환이 일어나는 코드를 작성한다</li>
<li><code>셀 위의 버튼.rx.tap</code> 과 화면 전환 코드(<code>navigation push</code>)를
<code>subscribe</code>로 연결한다. (VC의 cellForRowAt 부분에서 작성한다)</li>
</ul>
<pre><code class="language-swift">/* SearchTableViewCell 의 인스턴스 */
let appNameLabel: UILabel
let appIconImageView: UIImageView
let downloadButton: UIButton    // 화면 전환을 연결할 버튼
var disposeBag: DisposeBag</code></pre>
<pre><code class="language-swift">/* SearchTableViewController */
var data = [&quot;a&quot;, &quot;b&quot;, &quot;ab&quot;, &quot;abcde&quot;, &quot;de&quot;, &quot;db&quot;, &quot;abcd&quot;]  // 데이터 변경 시 편의를 위해 따로 배열을 관리한다
lazy var items = BehaviorSubject(value: data)    
let disposeBag = DisposeBag()

func bind() {
    // cellForRowAt
    items.bind(to: tableView.rx.items(
                    cellIdentifier: SearchTableViewCell.identifier,
                    cellType: SearchTableViewCell.self
    )) { (row, element, cell) in
        cell.appNameLabel.text = element
        cell.appIconImageView.backgroundColor = .green

        // 버튼과 화면 전환 구독
        cell.downloadButton.rx.tap
            .subscribe(with: self) { owner , value in
                owner.navigationController?.pushViewController(SampleViewController(), animated: true)
            }
            .disposed(by: cell.disposeBag)    // cell 인스턴스의 disposeBag을 사용한다
    }
    .disposed(by: disposeBag)
}</code></pre>
<ul>
<li>요기서 문제는, <strong>tableView의 cell은 재사용된다</strong>
즉, 재사용되는 동일한 cell에 중복해서 <code>subscribe</code>를 해주고 있는 꼴이 된다</li>
<li><code>subscribe</code>로 연결한 액션은 화면 전환이기 때문에, 버튼을 한 번 누르면 연속해서 화면 전환이 일어난다
<img src="https://velog.velcdn.com/images/s_sub/post/15b2b43f-a860-4b5c-8fbf-95b96cd397bb/image.gif" alt=""></li>
</ul>
<ul>
<li>셀의 재사용 때문에 발생한 문제는 대부분 <code>prepareForReuse</code> 에서 해결할 수 있는 것 같다</li>
<li>위 코드도 마찬가지로 <code>prepareForReuse</code>에 코드를 작성해주면 되는데,
문제 원인이 <strong>중복된 subscribe</strong>이기 때문에,
매번 셀을 만들 때, <strong>subscribe를 끊어주면 된다</strong></li>
</ul>
<ul>
<li><p><strong>disposeBag을 교체해준다</strong></p>
<pre><code class="language-swift">override func prepareForReuse() {
    super.prepareForReuse()

    disposeBag = DisposeBag()
}</code></pre>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[새싹 iOS] 14주차_TestFlight / 앱스토어 심사]]></title>
            <link>https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-14%EC%A3%BC%EC%B0%A8</link>
            <guid>https://velog.io/@s_sub/%EC%83%88%EC%8B%B9-iOS-14%EC%A3%BC%EC%B0%A8</guid>
            <pubDate>Thu, 19 Oct 2023 11:03:01 GMT</pubDate>
            <description><![CDATA[<h1 id="testflight---1">TestFlight - 1</h1>
<hr>
<ul>
<li>앱스토어에 심사를 올리기 전, 테스터들을 모집해서 앱 성능에 대한 테스트를 받는다</li>
</ul>
<ul>
<li><strong>내부 테스트</strong><ul>
<li>100명 제한이 있다</li>
<li>애플에게 심사를 따로 받지 않는다</li>
<li>사용자 및 액세스 추가 과정이 필요하다</li>
</ul>
</li>
</ul>
<ul>
<li><strong>외부 테스트</strong><ul>
<li>10,000명 제한이 있다</li>
<li>애플에게 심사를 받아야 테스트가 가능하다</li>
<li>사용자 및 액세스 추가 과정이 필요하지 않다</li>
</ul>
</li>
</ul>
<ul>
<li>나는 내부 테스트로, 같이 공부하는 팀원들에게 앱 테스트를 부탁했다</li>
</ul>
<br>

<h3 id="1-앱-등록">1. 앱 등록</h3>
<ul>
<li><a href="https://appstoreconnect.apple.com/apps">App Store Connects Apps</a>에 들어가서 신규 앱을 등록한다
<img src="https://velog.velcdn.com/images/s_sub/post/e113646e-6e5c-451e-bb57-6f9741ba9c0d/image.png" alt=""><ul>
<li>번들 ID는 <a href="https://developer.apple.com/account/resources/identifiers/list">Certificates, Identifiers &amp; Profiles</a>에서 등록할 수 있다</li>
</ul>
</li>
</ul>
<br>

<h3 id="2-신규-사용자-등록">2. 신규 사용자 등록</h3>
<ul>
<li><a href="https://appstoreconnect.apple.com/access/users">사용자 및 액세스</a>에 들어가서 신규 사용자를 등록한다
<img src="https://velog.velcdn.com/images/s_sub/post/8cfb7a74-dc0c-4bc5-b36d-bfa702f54abb/image.png" alt=""><ul>
<li>내부 테스터에게 적절한 역할을 줄 수 있다. (<strong>사용자 지원</strong>이 권한이 가장 적다)</li>
<li>추가 리소스는 따로 선택하지 않고, 앱은 내가 테스트를 부탁할 앱을 선택한다</li>
</ul>
</li>
</ul>
<ul>
<li>초대 버튼을 누르면 해당 이메일의 사용자에게 초대 메일이 발송된다.
<img src="https://velog.velcdn.com/images/s_sub/post/328c4b01-6adc-4eb2-afa1-9569f1e2a860/image.png" alt=""></li>
<li>수신자가 수락을 누르면 <strong>사용자 및 액세스</strong> 화면에서 테스터들을 확인할 수 있다
<img src="https://velog.velcdn.com/images/s_sub/post/ad7ca29b-85a0-4617-9df8-d165b2a22f8e/image.png" alt=""></li>
</ul>
<br>

<h3 id="3-내부-테스팅-그룹-생성">3. 내부 테스팅 그룹 생성</h3>
<ul>
<li>테스트해주는 인원이 많으면 그룹을 만들어서 따로 관리하는 것도 좋다
<img src="https://velog.velcdn.com/images/s_sub/post/2d5f1d61-f7e7-43b7-bfe3-68f4352212f7/image.png" alt=""></li>
<li>+ 버튼을 누르면 기존에 <strong>사용자 및 액세스</strong> 에서 초대한 리스트가 뜬다
<img src="https://velog.velcdn.com/images/s_sub/post/40ebc1e9-34dd-427f-afe1-d939a8f232d9/image.png" alt=""></li>
</ul>
<br>

<h1 id="앱-빌드-추가">앱 빌드 추가</h1>
<ul>
<li>이걸 제일 먼저 했어야 했는데, 까먹었다</li>
</ul>
<h3 id="앱-archive">앱 Archive</h3>
<ul>
<li>Xcode로 열심히 만든 앱을 애플 서버에 올리기 위한 작업이다</li>
</ul>
<ul>
<li><strong>버전 정보</strong>와 <strong>빌드</strong>를 확인한다
<img src="https://velog.velcdn.com/images/s_sub/post/95032d0c-973d-4382-aaf9-a02ba16e9023/image.png" alt=""><ul>
<li>1.0 (1) 로 올라갈 예정</li>
</ul>
</li>
</ul>
<ul>
<li>빌드 기기를 <strong>Any iOS Device (arm64)</strong>로 하고,
Product - Archive 클릭한다
<img src="https://velog.velcdn.com/images/s_sub/post/9de586c0-8ea8-449f-a7bb-ef18dabf10f5/image.png" alt=""></li>
</ul>
<ul>
<li>시간이 좀 지나면 이런 창이 뜨는데,</li>
<li><em>Validate App*</em> 누르고, <strong>Distribute App</strong> 누르면 된다
<img src="https://velog.velcdn.com/images/s_sub/post/f0424c65-72a4-41d5-b523-eeef141bfab4/image.png" alt=""><ul>
<li>빌드하는게 목적이기 때문에, 세부적인 내용은 생략한다.. 웬만하면 next 눌렀더니 성공했다</li>
</ul>
</li>
</ul>
<ul>
<li>Distribute App까지 마치면 아래 화면을 확인할 수 있다
<img src="https://velog.velcdn.com/images/s_sub/post/75f18999-5834-4de9-913a-f667cf945fd1/image.png" alt=""></li>
</ul>
<br>

<h1 id="testflight---2">TestFlight - 2</h1>
<hr>
<h3 id="4-내부-테스트-시작">4. 내부 테스트 시작</h3>
<ul>
<li>다시 TestFlight 화면으로 돌아오면 버전이 올라와있는 걸 확인할 수 있다
(아마 아카이빙하고 시간이 꽤 있어야 올라오는 것으로 알고 있다)
<img src="https://velog.velcdn.com/images/s_sub/post/aa3622c4-e3f4-4639-b89b-c842f24d7452/image.png" alt=""></li>
</ul>
<ul>
<li>따로 암호화하는 건 없기 때문에 아니요 체크하고 내부 테스트 시작한다
<img src="https://velog.velcdn.com/images/s_sub/post/b3767839-59c7-49f6-be86-5bfcf08bff93/image.png" alt=""></li>
</ul>
<ul>
<li>테스트를 시작하면, 메일로 테스트플라이트 초대 메일이 발송된다
<img src="https://velog.velcdn.com/images/s_sub/post/18adffa1-8c24-4255-a014-0026a43c5377/image.png" alt=""></li>
</ul>
<br>

<h3 id="5-테스트플라이트-앱-다운로드">5. 테스트플라이트 앱 다운로드</h3>
<ul>
<li><strong>View in TestFlight</strong> 클릭 후 순서대로 다운로드하면 실제 내 기기에서 앱을 테스트할 수 있다!
<img src="https://velog.velcdn.com/images/s_sub/post/7e377fcf-4d21-4d9a-8438-c8a98a98bc2b/image.png" alt=""></li>
</ul>
<br>
<br>

<h1 id="앱스토어-심사-제출">앱스토어 심사 제출</h1>
<hr>
<ul>
<li>앱 제작의 최종 목적은 결국 내 앱을 앱스토어에 출시하는 것이다</li>
<li>출시하기 위해서는 애플의 심사를 받아야 하고, 승인을 받아야 앱스토어에서 앱을 다운로드할 수 있다</li>
<li>TestFlight에 앱을 올리는 과정을 마쳤다면, 사실 심사를 제출하는 과정은 크게 어렵지 않다. 심사를 위한 추가 정보만 준비가 되었다면, 금방 할 수 있다</li>
</ul>
<br>

<h3 id="1-ios-미리보기-및-스크린샷">1. iOS 미리보기 및 스크린샷</h3>
<h4 id="스크린샷">스크린샷</h4>
<ul>
<li>앱 목업 스크린샷을 제출해야 한다.</li>
<li><a href="https://app-mockup.com/">목업 스크린샷 사이트</a> 를 이용했다</li>
<li>아이폰 용 앱이라면 <strong>6.5 디스플레이</strong>와 <strong>5.5 디스플레이</strong>에 대한 스크린샷을 필수로 제출해야 하고, <strong>6.7 디스플레이</strong>는 선택이다<ul>
<li>처음에 이거 몰라서 세개 다 만들었다... 
(빌드 추가를 하기 전에는 필수 글자가 뜨지 않았다...)
<img src="https://velog.velcdn.com/images/s_sub/post/e810a149-456b-4d77-9a3a-36b46a93fdf3/image.png" alt=""></li>
</ul>
</li>
</ul>
<h4 id="프로모션-텍스트">프로모션 텍스트</h4>
<ul>
<li>앱 설명 위에 3줄 정도 미리 표시되는 글이다. 나는 앱 설명으로 충분하다고 생각해서, 따로 적지 않았다</li>
</ul>
<h4 id="설명">설명</h4>
<ul>
<li>앱에 대한 설명을 적어준다</li>
<li>앱 소개, 주요 기능, 서비스 접근 권한 안내, 문의 등 정보를 적어주었다
<img src="https://velog.velcdn.com/images/s_sub/post/a6c5efd1-0297-4f2b-8a09-b2ce25803a72/image.png" alt=""></li>
</ul>
<h4 id="버전-업그레이드-사항">(버전 업그레이드 사항)</h4>
<ul>
<li>출시 이후, 업데이트 할 때 아마 뜨는 것 같다</li>
<li>업데이트한 내용을 적어준다
<img src="https://velog.velcdn.com/images/s_sub/post/a16debf7-591c-4f5a-9398-6547c83884ce/image.png" alt="">  </li>
</ul>
<h4 id="키워드-지원-url-마케팅-url-버전-저작권">키워드, 지원 URL, 마케팅 URL, 버전, 저작권</h4>
<ul>
<li><strong>키워드</strong> : 앱스토어 검색 시 내 앱이 뜨게 하고싶은 키워드를 적어준다. 
(독특한 키워드를 적어주면, 해당 키워드로 검색 시 내 앱만 뜨게 할 수 있다)</li>
<li><strong>지원 URL</strong> : 개발자에게 연락할 수 있는 사이트 링크를 추가한다<ul>
<li>나는 여기서 큰 문제가 되지 않았는데, 해당 링크로 들어갔을 때 이메일이나 전화번호 등 직접적으로 연락할 수 있는 수단이 없으면 리젝당한 경우도 있었다</li>
</ul>
</li>
<li><strong>버전</strong> : 출시하고 싶은 버전</li>
<li><strong>저작권</strong> : 이건 정확하게 모르겠는데, 개인 개발자라면 대부분 연도 + 이름 을 적는 것 같다
<img src="https://velog.velcdn.com/images/s_sub/post/6ffb4ba6-94f6-4cad-a5a5-bef1b1e296d7/image.png" alt=""></li>
</ul>
<h4 id="빌드-추가">빌드 추가</h4>
<ul>
<li>테스트플라이트 올릴 때와 동일하게 빌드 올려주면 된다</li>
</ul>
<br>

<h3 id="2-앱-심사-정보">2. 앱 심사 정보</h3>
<ul>
<li><strong>로그인 정보</strong> : 로그인이 필요한 앱이라면, 리뷰어가 테스트하기 위한 샘플 계정을 추가해준다.</li>
<li><strong>연락처 정보</strong> : 개발자 연락처를 적어준다 </li>
<li><strong>메모 + 첨부 파일</strong> : 리뷰어가 꼭 알아야 하는 내용을 메모에 적어준다. 
예를 들어, 한국에서만 사용 가능한 api를 활용한 앱이라면 메모에 해당 내용을 명시해주고, 첨부 파일에 그 기능을 녹화 또는 캡처해서 올려준다. 
<img src="https://velog.velcdn.com/images/s_sub/post/53822fb1-4416-4202-bc64-b5a7cbb31eda/image.png" alt=""></li>
</ul>
<br>

<h3 id="3-심사-제출">3. 심사 제출</h3>
<ul>
<li>이 외에도 앱 정보, 가격, 개인정보 등 저장해야 하는 정보가 많은데, 그건 따로 설명할 내용이 없어서 생략한다</li>
<li>마지막으로 주의해야 하는 부분은, <strong>심사에 추가</strong> 버튼만 누르고 끝내면 안된다</li>
<li><strong>심사에 추가</strong> 버튼을 눌렀다면, 그 다음 <strong>앱 심사에 제출</strong> 이었나..? 버튼이 하나 더 있다. 이 버튼을 눌러야 제대로 심사가 올라간다!</li>
</ul>
<br>

<h3 id="4-기다림">4. 기다림</h3>
<ul>
<li>심사를 제출했으면, 이제 결과를 기다리면 된다.</li>
<li>보통 짧으면 몇 시간, 길면 며칠이 걸린다고 하는데 내 주변 사람들은 대부분 하루 정도 걸린 것 같다</li>
<li>나는 제출하고 2시간 만에 승인 결과를 받았다
<img src="https://velog.velcdn.com/images/s_sub/post/1a900ec3-3108-4eea-b8d2-ed960ddb5a97/image.png" alt=""></li>
<li>짧은 시간이었지만, 그래도 기다리는 동안 그 떨림은 정말 쉽지 않았다..ㅎ</li>
<li><a href="https://apps.apple.com/kr/app/mulog-%EB%AE%A4%EB%A1%9C%EA%B7%B8-%EB%82%98%EB%A7%8C%EC%9D%98-%EC%9D%8C%EC%95%85-%EC%9D%BC%EA%B8%B0-%EC%95%B1/id6469449605">출시 앱 링크</a></li>
</ul>
<p><br><br></p>
<p><em>나중에 참고하기 위해 출시 경험을 간단하게 기록해보았습니다. 
혹시 잘못된 부분이 있다면 알려주시면 감사하겠습니다</em></p>
]]></description>
        </item>
    </channel>
</rss>