<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>sangjin.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Thu, 02 Apr 2026 16:56:19 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>sangjin.log</title>
            <url>https://velog.velcdn.com/images/sangjin-hash/profile/b61be0a9-f390-454d-b2c5-e58143c1073a/image.JPG</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. sangjin.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/sangjin-hash" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[[iOS] Rx 라이브러리들 활용해보기]]></title>
            <link>https://velog.io/@sangjin-hash/Rx-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC%EB%93%A4-%ED%99%9C%EC%9A%A9%ED%95%B4%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@sangjin-hash/Rx-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC%EB%93%A4-%ED%99%9C%EC%9A%A9%ED%95%B4%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Thu, 02 Apr 2026 16:56:19 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<p>현재 프로젝트에서 사용하고 있는 Rx 관련 라이브러리들과 Util 로 같이 사용하기 좋은 라이브러리들은 다음과 같습니다.</p>
<ul>
<li>RxSwift / RxCocoa / ReactorKit</li>
<li>RxRelay</li>
<li>RxDataSources / ReusableKit (with CompositionalLayout)</li>
</ul>
<p>각 라이브러리 별 개념 설명보다는 “어떻게” 활용하고 있는지에 대해 회고해보고자 합니다.</p>
<hr>
<h2 id="rxswift--rxcocoa--reactorkit">RxSwift + RxCocoa + ReactorKit</h2>
<h3 id="1-reactorkit-의-단방향-흐름">1. ReactorKit 의 단방향 흐름</h3>
<p>이 세 라이브러리는 하나의 흐름으로 묶어서 사용합니다. <code>ReactorKit</code>이 아키텍처 골격을 잡고, <code>RxSwift</code>가 비동기 흐름을, <code>RxCocoa</code>가 UIKit과의 연결을 담당합니다.</p>
<p>ReactorKit의 장점으로는 데이터를 단방향 흐름으로 구성할 수 있는 것인데요, <code>Action</code> → <code>mutate()</code> → <code>Mutation</code> → <code>reduce()</code> → <code>State</code> 사이클을 따릅니다. 이 프로젝트에서 HomeReactor를 예시로 보면, 각 역할이 명확하게 분리됩니다.</p>
<ul>
<li>HomeReactor 의 <code>Action</code> , <code>Mutation</code> , <code>State</code></li>
</ul>
<pre><code class="language-swift">  enum Action {
      case fetchPostList
      case loadNextPage
      case toggleLike(String, Bool)
      case searchKeyword(String)
      ...
  }

  enum Mutation {
      case setLoading(Bool)
      case setFetchCompleted(Page&lt;Post&gt;)
      case setLikeStatus(String, Bool)
      case setNeedsLogin
      ...
  }

  struct State {
      var posts: [Post] = []
      var isLoading: Bool = false
      @Pulse var error: AppError?
      @Pulse var needsLogin: Bool = false
      ...
  }</code></pre>
<p>mutate()는 사이드이펙트(API 호출 등)를 담당하고, reduce()는 순수하게 상태만 갱신합니다. 덕분에 버그가 생겼을 때 어느 레이어 문제인지 바로 좁혀집니다.</p>
<ul>
<li>HomeReactor 의 <code>mutate()</code> , <code>reduce()</code></li>
</ul>
<pre><code class="language-swift">extension HomeReactor {
    func mutate(action: Action) -&gt; Observable&lt;Mutation&gt; {
        switch action {
        case .fetchPostList:
            return fetchPosts(page: self.currentState.currentPage)

        case .loadNextPage:
            guard !self.currentState.isLoading else {
                return .empty()
            }

            guard self.currentState.hasNextPage else {
                return .empty()
            }

            return fetchPosts(page: self.currentState.currentPage + 1)
            ...


    func reduce(state: State, mutation: Mutation) -&gt; State {
        var newState = state
        switch mutation {
        case .setLoading(let isLoading):
            newState.isLoading = isLoading
            newState.sections = self.buildSections(
                selectedCategory: newState.selectedCategory,
                posts: newState.posts,
                isLoading: newState.isLoading
            )

        case .setFetchCompleted(let page):
            if page.page == 1 {
                newState.posts = page.data
            } else {
                newState.posts += page.data
            }

            newState.currentPage = page.page
            newState.hasNextPage = newState.posts.count &lt; page.total
            newState.sections = self.buildSections(
                selectedCategory: newState.selectedCategory,
                posts: newState.posts,
                isLoading: newState.isLoading
            )</code></pre>
<h3 id="2---asyncawait-↔-rxswift-브릿지">2.   async/await ↔ RxSwift 브릿지</h3>
<p>Domain/Data 레이어는 <code>async/await</code> 기반으로 설계했습니다.</p>
<ul>
<li>Data/PostRemoteDataSource</li>
</ul>
<pre><code class="language-swift">public final class AuthRemoteDataSourceImpl: AuthRemoteDataSource {
        ...
    public func signInWithIdToken(
        provider: AuthProvider,
        idToken: String,
        nonce: String?
    ) async throws -&gt; AuthTokenResponse {
        guard let oidcProvider = OpenIDConnectCredentials.Provider(rawValue: provider.rawValue) else {
            throw AppError.auth(.providerFailed)
        }

        let session = try await performAuth {
            try await self.client.auth.signInWithIdToken(
                credentials: .init(
                    provider: oidcProvider,
                    idToken: idToken,
                    nonce: nonce
                )
            )
        }

        return AuthTokenResponse(
            accessToken: session.accessToken,
            refreshToken: session.refreshToken
        )
    }

    ...</code></pre>
<ul>
<li>Domain/Implement/CheckAuthOnLaunchUseCaseImpl</li>
</ul>
<pre><code class="language-swift">final class CheckAuthOnLaunchUseCaseImpl: CheckAuthOnLaunchUseCase {
    private let authRepository: AuthRepository
    private let userRepository: UserRepository
    private let userStore: UserStore

    init(
        authRepository: AuthRepository,
        userRepository: UserRepository,
        userStore: UserStore
    ) {
        self.authRepository = authRepository
        self.userRepository = userRepository
        self.userStore = userStore
    }

    func execute() async throws -&gt; AuthState {
        // 1. 토큰 없음 → 익명 로그인
        if case .noToken = self.authRepository.currentTokenStatus() {
            _ = try await self.authRepository.signInAnonymously()
            self.userStore.clear()
            return .anonymous
        }

        // 2. 토큰 있음 → GET /me (인터셉터가 토큰 주입 + 401 리프레시)
        do {
            if let user = try await self.userRepository.fetchMe() {
                self.userStore.setUser(user)
                return .authenticated(user)
            } else {
                self.userStore.clear()
                return .anonymous
            }
        } catch let error as AppError where error.isRequireReAuth {
            // 3. 401 (리프레시도 실패)
            self.userStore.clear()
            switch self.authRepository.currentTokenStatus() {
            case .valid(let isAnonymous), .expired(let isAnonymous):
                if isAnonymous {
                    _ = try await self.authRepository.signInAnonymously()
                    return .anonymous
                }

                try self.authRepository.clearToken()
                return .unauthenticated

            case .noToken:
                _ = try await self.authRepository.signInAnonymously()
                return .anonymous
            }
        }
    }
}
</code></pre>
<p>Data 와 Domain 모듈에서 <code>async/await</code> 형태로 구성한 이유는 우선 Data 모듈의 경우 “데이터를 어떻게 가져올 것인지” 에 대해 초점을 맞춘 모듈이기에, 주로 Remote Server DB 혹은 Local(Device) DB 에서 데이터를 가져오는 로직들로 구성이 되어 있고, Domain 의 경우 Data 모듈에서 가져온 데이터를 기반으로 “어떻게 데이터를 가공할지” 목적이므로 비즈니스 로직을 적용하는 부분입니다.</p>
<p>따라서 Feature 모듈의 경우 Domain 모듈의 <code>UseCase</code> 를 이용하여 가공된 데이터를 받아와 이를 화면에 구성해야 하는데요, 이전 Data/Domain 의 경우 데이터를 받아오고 가공하는 역할을 하므로 Reactor 에서 사용하는 <code>Observable</code> 을 반환하지 않고 <code>async/await</code> 로 구성하였습니다.</p>
<p>Feature 모듈에서는 주로 <code>Reactor</code>를 사용하고 이는 MVVM 구조에서 <code>ViewModel</code> 역할을 합니다. <code>Reactor</code>는 <code>Observable</code>을 요구하므로 이를 위해 Observable.task extension 을 만들어 사용하면 편리하게 사용할 수 있습니다.</p>
<ul>
<li>Shared_ReactiveX/Rx/Observable+Task.swift</li>
</ul>
<pre><code class="language-swift">import RxSwift

extension Observable {
    /// async throws 함수를 Observable로 변환
    /// - Parameter work: async throws 클로저
    /// - Returns: 성공 시 onNext, 실패 시 onError를 방출하는 Observable
    public static func task(_ work: @escaping () async throws -&gt; Element) -&gt; Observable&lt;Element&gt; {
        Observable.create { observer in
            let task = Task {
                do {
                    let result = try await work()
                    observer.onNext(result)
                    observer.onCompleted()
                } catch {
                    observer.onError(error)
                }
            }
            return Disposables.create { task.cancel() }
        }
    }
}</code></pre>
<p>위 extension 을 사용 시에는 이렇게 사용할 수 있습니다.</p>
<pre><code class="language-swift">extension AuthReactor {
    func mutate(action: Action) -&gt; Observable&lt;Mutation&gt; {
        switch action {
        case .checkAuth:
                // Observable.task extension 사용
            return Observable.task { try await self.dependency.checkAuthOnLaunchUseCase.execute() }
                .map { Mutation.setAuthState($0) }
                .catch { error in
                    let appError = (error as? AppError) ?? .unknown(message: error.localizedDescription)
                    return .just(.setError(appError))
                }
        }
    }</code></pre>
<p>즉 해당 task 안에 <code>UseCase</code> 의 함수를 호출하는 클로저를 사용할 수 있습니다.</p>
<h3 id="3-pulse---일회성-이벤트">3. @Pulse - 일회성 이벤트</h3>
<p>에러나 로그인 유도 같은 &quot;한 번만 발생해야 하는 이벤트&quot;를 State에 담으면 문제가 생깁니다. 화면 전환 후 다시 구독할 때 이전값이 replay되기 때문입니다. 다시 말해 “이전 상태에 영향을 받지 않고, 한 번만 처리되어야 하는 이벤트”일 때 ReactorKit의 <code>@Pulse</code>가 이를 해결합니다.</p>
<pre><code class="language-swift">  // Reactor
  @Pulse var error: AppError?
  @Pulse var needsLogin: Bool = false

  // ViewController
  reactor.pulse(\.$needsLogin)
      .filter { $0 }
      .subscribe(onNext: { [weak self] _ in
          ConfirmDialog.show(on: self, ...)
      })
      .disposed(by: self.disposeBag)</code></pre>
<hr>
<h2 id="rxrelay">RxRelay</h2>
<p><code>BehaviorRelay</code>는 앱 전역에서 공유되는 상태를 안전하게 관리하기 위해 사용했습니다. <code>BehaviorSubject</code>와 달리 onError / onCompleted를 방출할 수 없기 때문에, 스트림이 예상치 못하게 종료되는 사고를 막을 수 있습니다.</p>
<p>해당 프로젝트에서는 <code>UserStore</code> 라고 네이밍을 지어 로그인 이후 앱 내 사용되는 사용자의 정보들을 전역적으로 관리하였습니다.</p>
<ul>
<li>AppCore/UserStore.swift</li>
</ul>
<pre><code class="language-swift">  public final class UserStore {
      public let currentUser = BehaviorRelay&lt;User?&gt;(value: nil)

      public var isLoggedIn: Bool {
          return self.currentUser.value != nil
      }

      public func setUser(_ user: User) {
          self.currentUser.accept(user)
      }

      public func clear() {
          self.currentUser.accept(nil)
      }
  }</code></pre>
<p><code>.value</code> 로 현재 값을 동기적으로 읽을 수 있어서, Reactor 내부에서 로그인 여부 체크처럼 스트림을 구독하지 않아도 되는 경우에 유용합니다.</p>
<p>ex)</p>
<pre><code class="language-swift">  case .toggleLike(let postId, let isLiked):
      guard self.dependency.userStore.isLoggedIn else {
          return .just(.setNeedsLogin)
      }</code></pre>
<p>다른 Reactor에서 로그인 상태 변화를 구독할 때는 <code>transform(mutation:)</code>에서 merge합니다.</p>
<pre><code class="language-swift">  func transform(mutation: Observable&lt;Mutation&gt;) -&gt; Observable&lt;Mutation&gt; {
      let userMutation = self.dependency.userStore.currentUser
          .map { Mutation.setUser($0) }
          .asObservable()
      return .merge(mutation, userMutation)
  }</code></pre>
<hr>
<h2 id="compositionallayout--rxdatasources--reusablekit">CompositionalLayout + RxDataSources + ReusableKit</h2>
<p>이 세개는 <code>CollectionView</code> 를 구성할 때 함께 사용됩니다.</p>
<ul>
<li>CompositionalLayout → 섹션별 레이아웃 정의</li>
<li>RxDataSources → <code>UITableView</code> / <code>UICollectionView</code> 여러 섹션의 데이터를 RxSwift 방식으로 바인딩하기 위한 라이브러리</li>
<li>ReusableKit → <code>register</code> → <code>deque</code> 과정에서 <code>Identifier</code> 하드코딩을 피하기 위함</li>
</ul>
<p>다음 홈 화면을 기준으로 설명해보겠습니다. </p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/b2fd0993-8f21-449e-ba34-f54d15358f7a/image.png" width="400">
  <p style="font-size: 14px; color: gray;">▲ 그림 1. 홈 화면 구성</p>
</div>



<p>홈 화면에서는 <code>CollectionView</code> 를 사용하고 있는데 우선은 크게 3가지로 나뉘어져 있습니다.</p>
<ol>
<li>카테고리 배너(전체, 뷰티, 푸드, 패션,…)</li>
<li>이번 주 핫딜 Top 10 버튼</li>
<li>공동구매 피드들</li>
</ol>
<p>우선 CollectionView 에서 위 세가지로 섹션을 나누었다면, 이제 <code>CollectionViewLayout</code> 을 구성하여 각 Section 마다 레이아웃을 정의해야 하는데요, <code>UICollectionViewCompositionalLayout</code>을 섹션 인덱스 기반으로 3가지 레이아웃을 반환하는 구조입니다. 클로저 기반 초기화를 사용해서 섹션마다 완전히 다른 레이아웃을 정의하고, 하나의 CollectionView 에서 이를 구성하게 됩니다.</p>
<ul>
<li>HomeCollectionViewLayout.swift</li>
</ul>
<pre><code class="language-swift">enum HomeCollectionViewLayout {
    static func create() -&gt; UICollectionViewCompositionalLayout {
        return UICollectionViewCompositionalLayout { sectionIndex, _ in
            switch sectionIndex {
            case 0:
                return createCategorySection()

            case 1:
                return createBannerSection()

            default:
                return createPostListSection()
            }
        }
    }

    // MARK: - Section 0: 카테고리 칩

    private static func createCategorySection() -&gt; NSCollectionLayoutSection {
        let itemSize = NSCollectionLayoutSize(
            widthDimension: .estimated(80),
            heightDimension: .absolute(36)
        )
        let item = NSCollectionLayoutItem(layoutSize: itemSize)

        let groupSize = NSCollectionLayoutSize(
            widthDimension: .estimated(80),
            heightDimension: .absolute(36)
        )
        let group = NSCollectionLayoutGroup.horizontal(
            layoutSize: groupSize,
            subitems: [item]
        )

        let section = NSCollectionLayoutSection(group: group)
        section.orthogonalScrollingBehavior = .continuous
        section.interGroupSpacing = 8
        section.contentInsets = NSDirectionalEdgeInsets(
            top: 12, leading: 16, bottom: 12, trailing: 16
        )
        return section
    }

    // MARK: - Section 1: TOP 10 배너

    private static func createBannerSection() -&gt; NSCollectionLayoutSection {
        ...
    }

    // MARK: - Section 2: 공동구매 리스트

    private static func createPostListSection() -&gt; NSCollectionLayoutSection {
        ...
    }
}
</code></pre>
<ul>
<li>HomeViewController.swift</li>
</ul>
<pre><code class="language-swift">    private lazy var collectionView: UICollectionView = {
        let cv = UICollectionView(
            frame: .zero,
            collectionViewLayout: HomeCollectionViewLayout.create()
        )
        cv.backgroundColor = .systemBackground
        cv.register(Self.categoryCell)
        cv.register(Self.bannerCell)
        cv.register(Self.postCell)
        cv.register(Self.skeletonCell) // Shimmer 기능 -&gt; 게시물 로드 전 스켈레톤 UI 제공
        return cv
    }()</code></pre>
<p>그럼 여기까지해서 우선 <code>CollectionViewLayout</code> 을 통해 각 섹션 별로 레이아웃 정의하고, <code>CollectionView</code> 까지 <code>ViewController</code> 에서 생성했다면 이제는 <code>RxSwift</code> 로 바인딩할 차례입니다. 이 때 사용되는 라이브러리가 바로 <code>RxDataSources</code> 입니다.</p>
<p>바인딩하기 전에 먼저 현재 홈 화면 특징을 살펴보면, 카테고리 Cell 과 공동구매 피드 Cell 이 두개에서 관리하고 있는 상태가 있습니다.</p>
<ol>
<li>카테고리 Cell → 카테고리 필터링 기능이라 사용자가 선택한 카테고리에 따라 UI 업데이트</li>
<li>공동구매 Cell<ol>
<li>좋아요 버튼</li>
<li>좋아요 수: 좋아요 버튼 사용자가 클릭 시 좋아요 수 + 1</li>
</ol>
</li>
</ol>
<p>즉 해당 Cell 들은 상태 변수를 가지고 있고 이에 따라 UI 를 업데이트해야 하는데, 이 때 필요한 것은 <code>SectionModel</code> 입니다. <code>SectionModel</code> 에서 각 Cell 마다 상태 변경을 감지해야 하는 경우 <code>IdentifiableType, Equtable</code> 을 이용해서 감지할 수 있습니다. </p>
<ul>
<li>HomeSectionItem  ⇒ 무엇이 변경되었는가</li>
</ul>
<pre><code class="language-swift">enum HomeSectionItem: IdentifiableType, Equatable {
    case category(GroupBuyingCategory?, Bool)
    case top10Banner
    case post(Post)
    case skeleton(Int)

    var identity: String {
        switch self {
        case .category(let category, _):
            return &quot;category_\(category?.rawValue ?? &quot;all&quot;)&quot;

        case .top10Banner:
            return &quot;top10Banner&quot;

        case .post(let post):
            return &quot;post_\(post.id)&quot;

        case .skeleton(let index):
            return &quot;skeleton_\(index)&quot;
        }
    }

    static func == (lhs: HomeSectionItem, rhs: HomeSectionItem) -&gt; Bool {
        switch (lhs, rhs) {
        case (.category(let lCat, let lSel), .category(let rCat, let rSel)):
            return lCat == rCat &amp;&amp; lSel == rSel

        case (.top10Banner, .top10Banner):
            return true

        case (.post(let lPost), .post(let rPost)):
            return lPost.id == rPost.id
                &amp;&amp; lPost.likesCount == rPost.likesCount
                &amp;&amp; lPost.isLiked == rPost.isLiked

        case (.skeleton(let l), .skeleton(let r)):
            return l == r

        default:
            return false
        }
    }
}
...</code></pre>
<ul>
<li>HomeSectionModel ⇒ 어느 섹션에 속하는가</li>
</ul>
<pre><code class="language-swift">// MARK: - Section Model

enum HomeSectionModel {
    case category(items: [HomeSectionItem])
    case top10Banner(items: [HomeSectionItem])
    case postList(items: [HomeSectionItem])
}

extension HomeSectionModel: AnimatableSectionModelType {
    typealias Item = HomeSectionItem

    var identity: String {
        switch self {
        case .category:
            return &quot;category&quot;

        case .top10Banner:
            return &quot;top10Banner&quot;

        case .postList:
            return &quot;postList&quot;
        }
    }

    var items: [HomeSectionItem] {
        switch self {
        case .category(let items):
            return items

        case .top10Banner(let items):
            return items

        case .postList(let items):
            return items
        }
    }

    init(original: HomeSectionModel, items: [HomeSectionItem]) {
        switch original {
        case .category:
            self = .category(items: items)

        case .top10Banner:
            self = .top10Banner(items: items)

        case .postList:
            self = .postList(items: items)
        }
    }
}</code></pre>
<p>결국 <code>SectionModel</code> 은 “CollectionView 의 데이터 구조를 Rx 스트림과 연결하기 위한 컨테이너” 이고, diff 로직의 입력값 역할을 합니다.</p>
<p>이제 그럼 바인딩할 준비는 모두 되었습니다. 위에 언급한 컨테이너까지 만들었으니 바인딩을 해봅시다.</p>
<pre><code class="language-swift">extension HomeViewController: View {
    func bind(reactor: HomeReactor) {

        // MARK: - DataSource

        let dataSource = RxCollectionViewSectionedAnimatedDataSource&lt;HomeSectionModel&gt;(
            animationConfiguration: .init(insertAnimation: .none, reloadAnimation: .none, deleteAnimation: .none),
            configureCell: { [weak reactor] _, collectionView, indexPath, item in
                switch item {
                case .category(let category, let isSelected):
                    let cell = collectionView.dequeue(HomeViewController.categoryCell, for: indexPath)
                    cell.configure(
                        dependency: .init(),
                        payload: .init(
                            category: category,
                            isSelected: isSelected
                        )
                    )
                    return cell

                case .top10Banner:
                    let cell = collectionView.dequeue(HomeViewController.bannerCell, for: indexPath)
                    cell.configure(dependency: .init(), payload: .init())
                    return cell

                case .post(let post):
                    let cell = collectionView.dequeue(HomeViewController.postCell, for: indexPath)
                    cell.configure(dependency: .init(), payload: .init(post: post))

                    cell.likeButton.rx.tap
                        .map { HomeReactor.Action.toggleLike(post.id, post.isLiked) }
                        .bind(to: reactor!.action)
                        .disposed(by: cell.disposeBag)

                    return cell

                case .skeleton(_):
                    let cell = collectionView.dequeue(HomeViewController.skeletonCell, for: indexPath)
                    return cell
                }
            }
        )</code></pre>
<p>위와 같이 DataSource 를 만들었다면 이제 <code>Reactor</code> 의 State 와 연결할 차례입니다.</p>
<pre><code class="language-swift">  // MARK: - State

  reactor.state.map(\.sections)
      .observe(on: MainScheduler.asyncInstance)
      .bind(to: self.collectionView.rx.items(dataSource: dataSource))
      .disposed(by: self.disposeBag)</code></pre>
<p><code>reactor.state</code>에서 <code>sections</code> 프로퍼티만 추출해서 <code>CollectionView</code>에 바인딩합니다. 이제 <code>Reactor</code>에서 <code>sections</code> 배열이 바뀔 때마다 <code>RxDataSources</code>가 diff를 계산해서 변경된 셀만 업데이트합니다. 이게 전부입니다.</p>
<p>DataSource 를 생성할 때 <code>configureCell</code> 에서 셀을 반환하는 부분을 다시 보면</p>
<pre><code class="language-swift">let cell = collectionView.dequeue(HomeViewController.postCell, for: indexPath)</code></pre>
<p>원래 UIKit 에서는 이렇게 써야 합니다.</p>
<pre><code class="language-swift">let cell = collectionView.dequeueReusableCell(
      withReuseIdentifier: &quot;HomePostCardCell&quot;,
      for: indexPath
  ) as! HomePostCardCell</code></pre>
<p>여기서 문제점은 <code>as!</code>강제 캐스팅, 다른 하나는 문자열 <code>identifier</code>입니다. <code>identifier</code> 오타가 나거나 등록하지 않은 셀을 dequeue하면 런타임 크래시가 납니다. 그러므로 이 때 활용되는 라이브러리인 <code>ReusableKit</code> 이 있는데요,</p>
<p><code>ReusableKit</code>은 이 두 문제를 제네릭으로 해결합니다. <code>ReusableCell&lt;Cell&gt;</code>이 셀 타입과 <code>identifier</code>를 함께 들고 있어서, <code>register</code>와 <code>dequeue</code>가 항상 같은 타입·같은 <code>identifier</code>를 보장합니다.</p>
<pre><code class="language-swift">  // 타입과 identifier를 한 곳에서 정의
  static let postCell = ReusableCell&lt;HomePostCardCell&gt;()

  // 등록 — Cell 타입을 이미 알고 있으므로 identifier 별도 관리 불필요
  cv.register(Self.postCell)

  // 재사용 — 반환 타입이 HomePostCardCell로 이미 결정됨
  let cell = collectionView.dequeue(HomeViewController.postCell, for: indexPath)</code></pre>
<p>내부 구현을 보면 단순합니다.</p>
<pre><code class="language-swift">// ReusableKit 내부
public func dequeue&lt;Cell&gt;(_ cell: ReusableCell&lt;Cell&gt;, for indexPath: IndexPath) -&gt; Cell {
    return self.dequeueReusableCell(withReuseIdentifier: cell.identifier, for: indexPath) as! Cell
}</code></pre>
<p><code>as!</code>는 라이브러리 내부에서 한 번만 쓰고, <code>register</code>와 <code>dequeue</code>가 항상 같은 <code>ReusableCell</code> 인스턴스를 참조하기 때문에 타입 불일치가 구조적으로 불가능합니다. 결과적으로 <code>CollectionView</code> 셀 관련 코드에서 문자열과 강제 캐스팅이 완전히 사라집니다.</p>
<hr>
<h2 id="결론">결론</h2>
<p>지금까지 사이드프로젝트를 진행하면서 활용중인 <code>Rx</code> 라이브러리들에 대해 알아보았는데요, 짧게 다시 요약해보자면</p>
<ul>
<li>RxSwift / RxCocoa / ReactorKit ⇒ 데이터의 단방향 흐름 관리</li>
<li>RxRelay ⇒ 전역적으로 공유하는 데이터 관리</li>
<li>RxDataSources / ReusableKit (with CompositionalLayout) ⇒ CollectionView 의 데이터 바인딩</li>
</ul>
<p>각 라이브러리가 담당하는 역할이 명확하게 분리되어 있어서, 어디서 문제가 생겼는지 추적하기 쉽다는 게 가장 큰 장점이었습니다. 상태 변경은 <code>Reactor</code>에서, UI 업데이트는 <code>RxDataSources</code>의 diff가, 전역 상태는 <code>RxRelay</code>가 각자 책임지는 구조 덕분에 코드를 읽는 것만으로도 데이터 흐름이 눈에 들어옵니다.</p>
<p>다만 초기 진입 장벽은 분명히 있습니다. <code>SectionModel</code> 설계나 <code>ReactorKit</code>의 <code>Action-Mutation-State</code> 사이클에 익숙해지기까지 시간이 필요했고, 간단한 화면에서도 보일러플레이트가 적지 않습니다. 그럼에도 화면이 복잡해질수록 이 구조가 빛을 발한다는 걸 직접 느꼈습니다. 앞으로 기능이 늘어나면서 이 구조가 어떻게 유지되는지도 계속 기록해보려 합니다.</p>
<hr>
<h2 id="github">GitHub</h2>
<p>해당 사이드프로젝트에 대한 코드는 다음 링크에서 확인하실 수 있습니다.</p>
<p><a href="https://github.com/sangjin-hash/09Market">https://github.com/sangjin-hash/09Market</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[iOS] 의존성 주입 그리고 Swinject + Pure 활용에 대하여]]></title>
            <link>https://velog.io/@sangjin-hash/iOS-%EC%9D%98%EC%A1%B4%EC%84%B1-%EC%A3%BC%EC%9E%85-%EA%B7%B8%EB%A6%AC%EA%B3%A0-Swinject-Pure-%ED%99%9C%EC%9A%A9%EC%97%90-%EB%8C%80%ED%95%98%EC%97%AC</link>
            <guid>https://velog.io/@sangjin-hash/iOS-%EC%9D%98%EC%A1%B4%EC%84%B1-%EC%A3%BC%EC%9E%85-%EA%B7%B8%EB%A6%AC%EA%B3%A0-Swinject-Pure-%ED%99%9C%EC%9A%A9%EC%97%90-%EB%8C%80%ED%95%98%EC%97%AC</guid>
            <pubDate>Wed, 01 Apr 2026 16:18:54 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<blockquote>
<p>의존성 주입(Dependency Injection, DI)은 객체 간의 결합도를 낮추고, 테스트 용이성과 코드의 확장성을 높이기 위해 널리 사용되는 설계 패턴입니다. 특히 규모가 커지는 애플리케이션에서는 객체 생성과 의존성 관리가 복잡해지기 때문에 이를 체계적으로 관리할 수 있는 도구나 라이브러리의 도움을 받는 경우가 많습니다.</p>
</blockquote>
<p>→ 여기까지 Chat GPT 가 생성해준 말인데, 물론 너무나 맞는 말이지만 저도 처음 실무에 적용할 때 테스트 용이성? 확장성 높이기 위한 설계 패턴? 이런 것들이 다소 와닿지 않더라구요. “의존성 주입을 왜 해야해?” 라는 질문에 대한 제 생각을 먼저 말씀드리자면 “생성하고자 하는 인스턴스의 의존성을 몰라도 됨”이 가장 큰 것 같습니다. 일반적으로 객체가 필요한 의존성을 직접 생성한다면, 해당 객체는 의존 객체의 생성 방식이나 구체적인 구현에 대해 알고 있어야 합니다. 하지만 DI를 사용하면 객체는 단순히 필요한 의존성을 외부로부터 전달받기만 하면 됩니다. 실제로 어떤 객체가 생성되고 어떻게 구성되는지는 외부(예: DI 컨테이너)가 담당하게 됩니다.</p>
<p>‘의존성을 몰라도 된다’ 를 다시 풀어서 설명하자면</p>
<p>(1) DI 를 통해 외부(컨테이너)에서 의존성 주입</p>
<p>(2) 해당 인스턴스가 변경이 되더라도 영향을 받지 않음</p>
<p>그러니 객체 생성 책임이 외부로 분리되었으니 결합도가 낮아지고, 변경에 유연하며 확장에도 용이한 구조인 것이죠. 테스트 용이성은 객체를 직접 생성하는 구조에서는 테스트 시에도 실제 의존 객체가 함께 생성되기 때문에 테스트 환경을 제어하기 어렵습니다. 반면 DI 구조에서는 테스트 시점에 원하는 의존 객체를 직접 주입할 수 있습니다. 예를 들어 실제 네트워크 요청을 수행하는 객체 대신에 미리 정의된 결과를 반환하는 Mock 객체로 주입한다면 실제 요청보내지 않더라도 테스트 수행이 가능한 것이니 테스트 용이성을 확보할 수 있는 것입니다.</p>
<p>Swift 생태계에서도 DI를 지원하기 위한 여러 라이브러리가 존재하며, 그중 대표적으로 <code>Swinject</code>와 <code>Pure</code>를 들 수 있는데요, 두 라이브러리는 모두 의존성 주입을 보다 구조적으로 관리할 수 있도록 도와주지만, 접근 방식과 설계 철학에는 차이가 있습니다.</p>
<p>이 글에서는 제 사이드 프로젝트에 적용했던 사례를 기반으로 <code>Swinject</code>와 <code>Pure</code> 에 대한 특징과 구조, 그리고 이 둘을 복합적으로 어떻게 활용했는지에 대해 알아보겠습니다.</p>
<hr>
<h2 id="swinject-를-사용하는-이유">Swinject 를 사용하는 이유</h2>
<h3 id="1-register-과-resolve">1. register() 과 resolve()</h3>
<p>Swinject 에는 보통 <code>register()</code> 과 <code>resolve()</code> 의 개념이 있습니다. 쉽게 말해 </p>
<ul>
<li>register: 등록 → 인스턴스는 ~ 이렇게 만들어라 라는 팩토리 명시 → ex) 요리할 때 레시피</li>
<li>resolve: 사용 → 등록된 팩토리를 가지고 인스턴스를 만듬 → ex) 레시피보고 요리하는 것</li>
</ul>
<p>여기서 조금 헷갈리는 것은 <code>register</code> 는 인스턴스 만드는 방법을 등록하는거지, 생성하는 것이 아닙니다. 그럼 여기서 왜 인스턴스 만드는 방법을 등록하는지? 등록하면 장점이 뭔지? 의문이 들 수 있는데요,</p>
<p>이렇게 생성 방법을 등록해 두는 이유는 객체 생성 책임을 한 곳으로 모으기 위해서입니다. 만약 각 객체가 필요한 의존성을 직접 생성한다면, 애플리케이션 곳곳에서 객체 생성 코드가 반복적으로 등장하게 됩니다. 예를 들어 </p>
<ul>
<li><code>ViewModel</code>→ <code>UseCase</code> 생성</li>
<li><code>UseCase</code>→ <code>Repository</code> 생성</li>
<li><code>Repository</code>→ <code>DataSource</code> 생성</li>
</ul>
<p>이런식으로 의존성이 코드 전반에 흩어지게 됩니다. 이런 구조에서는 특정 의존 객체의 생성 방식이 변경될 때 영향을 받는 코드 범위가 넓어질 수 있습니다. 반면 <code>register()</code>를 통해 객체 생성 규칙을 DI 컨테이너에 미리 정의해 두면, 실제 객체를 사용하는 쪽에서는 단순히 <code>resolve()</code>로 필요한 의존성을 요청하기만 하면 됩니다. 객체를 어떻게 생성해야 하는지에 대한 로직은 모두 컨테이너 내부에 모이게 되기 때문에, 생성 방식이 변경되더라도 해당 규칙만 수정하면 됩니다.</p>
<p>그래서 다시 <code>register()</code> 를 통해 인스턴스 생성 방법을 미리 등록하는 것의 장점을 요약하자면,</p>
<ul>
<li>객체 생성 책임을 한 곳으로 모을 수 있다.</li>
<li>의존 객체의 생성 방식이 변경되더라도 영향을 최소화할 수 있다.</li>
<li>애플리케이션의 의존성 구조를 중앙에서 관리할 수 있다.</li>
<li>실제 객체를 사용하는 코드에서는 생성 방식에 대해 신경 쓰지 않아도 된다.</li>
</ul>
<h3 id="2-dicontainer">2. DIContainer</h3>
<p>앞서 설명하면서 “외부”, “등록하는 곳”, “중앙에서 관리한다”와 같은 표현을 사용했는데, 이러한 역할을 담당하는 것이 바로 의존성 컨테이너(DI Container) 입니다. DI Container는 <strong>객체를 어떻게 생성할지에 대한 규칙을 보관하고, 필요한 시점에 해당 규칙을 이용해 인스턴스를 생성하고 주입하는 역할</strong>을 합니다. 즉 애플리케이션에서 필요한 객체들의 생성 방법을 한 곳에서 관리하고, 실제로 객체가 필요할 때 이를 생성해 전달하는 중앙 관리 지점이라고 볼 수 있습니다. <code>Swinject</code> 에서는 <code>Container</code> 타입을 제공하고 있으며 해당 컨테이너를 통해 <code>register()</code> 와 <code>resolve()</code> 를 이용할 수 있습니다.</p>
<h3 id="3-assembly">3. Assembly</h3>
<p>앞서 Container를 통해 객체의 생성 규칙을 <code>register()</code>로 등록할 수 있다고 설명했습니다. 하지만 애플리케이션의 규모가 커질수록 등록해야 하는 의존성의 수 역시 빠르게 늘어나게 됩니다. 예를 들어 클린 아키텍처 구조에서 하나의 Container 만 사용한다고 가정해보겠습니다. 그러면 이 때 <code>Usecase, Repostiory, DataSource</code> 등 다양한 객체들의 생성 규칙을 하나의 Container 에 등록하게 될텐데 이러면 해당 Container 가 매우 방대해지고 추후에는 관리가 더 어렵겠죠. 따라서 이를 해결하기 위해 Swinject 에서는 <code>Assembly</code> 라는 것을 사용합니다.</p>
<p>Assembly는 간단히 말해 의존성 등록 코드를 기능 단위로 분리하기 위한 구조입니다. 각 모듈이나 레이어 단위로 의존성 등록을 나누어 작성하고, 이를 Container에 조립(assemble)하는 방식으로 사용할 수 있습니다.</p>
<p>현재 진행중인 사이드 프로젝트에서는 클린 아키텍처 + Modular Architecture 를 따르고 있는데, 이 구조에서 레이어 모듈을 예시로 들어보겠습니다.</p>
<ul>
<li>(하위) DataAssembly</li>
</ul>
<pre><code class="language-swift">public final class DataAssembly: Assembly {
    public init() {}

    public func assemble(container: Container) {

        // MARK: - KeychainClient

        container.register(KeychainClient.self) { _ in
            KeychainClientImpl()
        }.inObjectScope(.container)


        // MARK: - Auth

        container.register(AuthRemoteDataSource.self) { _ in
            ...
            return AuthRemoteDataSourceImpl(...)
        }.inObjectScope(.container)

        container.register(AuthLocalDataSource.self) { r in
            AuthLocalDataSourceImpl(keychainClient: r.resolve())
        }.inObjectScope(.container)

        container.register(AuthRepository.self) { r in
            AuthRepositoryImpl(
                remoteDataSource: r.resolve(),
                localDataSource: r.resolve()
            )
        }.inObjectScope(.container)

        ...</code></pre>
<ul>
<li>(하위) DomainAssembly</li>
</ul>
<pre><code class="language-swift">public final class DomainAssembly: Assembly {
    public init() {}

    public func assemble(container: Container) {

        // MARK: - UserStore

        container.register(UserStore.self) { _ in
            UserStore()
        }.inObjectScope(.container)

        // MARK: - Auth

        container.register(SignInWithIdTokenUseCase.self) { r in
            SignInWithIdTokenUseCaseImpl(
                authRepository: r.resolve(),
                userRepository: r.resolve(),
                userStore: r.resolve()
            )
        }.inObjectScope(.container)

        container.register(SignOutUseCase.self) { r in
            SignOutUseCaseImpl(
                authRepository: r.resolve(),
                userStore: r.resolve()
            )
        }.inObjectScope(.container)

        ...</code></pre>
<p>이런식으로 각 레이어 모듈별로 <code>Assembly</code> 를 통해 각 모듈별로 필요한 의존성들을 조립했고, 해당 <code>Assembly</code> 들은 최상위 모듈에서 <code>Assembler</code> 를 통해 관리하게 됩니다.</p>
<ul>
<li>(상위) AppDIContainer</li>
</ul>
<pre><code class="language-swift">final class AppDIContainer {
    static let shared = AppDIContainer()

    private let assembler: Assembler

    var resolver: Resolver {
        return self.assembler.resolver
    }

    private init() {
        self.assembler = Assembler([
            DataAssembly(),
            DomainAssembly(),
            ...
        ])
    }
}</code></pre>
<p>위와 같은 구조를 사용하면 의존성 등록 코드가 하나의 Container에 모두 모이지 않고, 레이어 또는 모듈 단위로 자연스럽게 분리됩니다. 그 결과 각 모듈은 자신이 필요로 하는 의존성만 정의하게 되고, 전체 애플리케이션의 의존성 구조 역시 보다 명확하게 드러나게 됩니다.</p>
<p>특히 클린 아키텍처와 모듈화된 구조에서는 이러한 방식이 더욱 유용합니다. Data, Domain, Presentation과 같은 레이어별로 Assembly를 구성하면, 각 레이어가 어떤 의존성을 가지고 있는지 한눈에 파악할 수 있고, 의존성 변경이 발생하더라도 해당 모듈의 Assembly만 수정하면 되기 때문에 유지보수 역시 수월해집니다.</p>
<p>또한 최상위 모듈에서는 Assembler를 통해 각 Assembly를 한 번에 조립(assemble)함으로써, 애플리케이션 전체의 의존성 구성을 중앙에서 관리할 수 있습니다. 이후 실제 객체가 필요한 시점에는 Resolver를 통해 필요한 의존성을 요청하기만 하면 되고, 인스턴스 생성과 의존성 주입은 컨테이너가 담당하게 됩니다.</p>
<p>정리하자면 Assembly를 활용한 구조는 다음과 같은 장점을 가집니다.</p>
<ul>
<li>의존성 등록 코드를 레이어 및 모듈 단위로 분리할 수 있다.</li>
<li>애플리케이션의 의존성 구조를 보다 명확하게 파악할 수 있다.</li>
<li>특정 의존성 변경 시 영향 범위를 최소화할 수 있다.</li>
<li>최상위 모듈에서 Assembler를 통해 전체 의존성을 일관되게 관리할 수 있다.</li>
</ul>
<p>이처럼 Assembly는 단순히 DI 설정 코드를 나누기 위한 도구라기보다, 애플리케이션의 의존성 구조를 모듈 단위로 구성하고 관리할 수 있도록 도와주는 중요한 역할을 합니다.</p>
<h3 id="4-resolve-extension">4. resolve extension</h3>
<p>약간의 디테일을 추가하자면 <code>resolve()</code> extension 을 사용하면 코드의 가독성을 올릴 수 있습니다. 단, <code>resolve()</code> 에서 Forced Unwrapping 을 사용해 리턴하고 있기 때문에 등록 누락 시 런타임 크래시가 발생할 수 있으므로 주의가 필요합니다.</p>
<pre><code class="language-swift">import Swinject

extension Resolver {
    public func resolve&lt;Service&gt;() -&gt; Service! {
        return self.resolve(Service.self)
    }

    public func resolve&lt;Service, Arg&gt;(argument: Arg) -&gt; Service! {
        return self.resolve(Service.self, argument: argument)
    }
}</code></pre>
<ul>
<li>Resolver extension 을 적용하지 않은 경우</li>
</ul>
<pre><code class="language-swift">container.register(SignInWithIdTokenUseCase.self) { r in
    SignInWithIdTokenUseCaseImpl(
        authRepository: r.resolve(AuthRepository.self)!,
        userRepository: r.resolve(UserRepository.self)!,
        userStore: r.resolve(UserStore.self)!
    )
}.inObjectScope(.container)</code></pre>
<ul>
<li>Resolver extension 을 적용한 경우</li>
</ul>
<pre><code class="language-swift">container.register(SignInWithIdTokenUseCase.self) { r in
    SignInWithIdTokenUseCaseImpl(
        authRepository: r.resolve(),
        userRepository: r.resolve(),
        userStore: r.resolve()
    )
}.inObjectScope(.container)</code></pre>
<p>즉 extension을 사용하면</p>
<ul>
<li>Service.self 타입 명시 제거</li>
<li>코드 길이 감소</li>
<li>DI 등록 코드 가독성 개선</li>
</ul>
<hr>
<h2 id="pure-를-사용하는-이유">Pure 를 사용하는 이유</h2>
<p><code>Pure</code> 도 <code>Swinject</code> 처럼 의존성 주입 라이브러리입니다. Pure 를 채택한 프로젝트에서는 보통 <code>CompositionRoot</code> 패턴을 적용해 상위 계층에서 의존성을 조립하고, 하위 모듈에서는 필요한 의존성을 주입받아 사용하는 구조로 구성하는데요,</p>
<p>이 두 라이브러리의 가장 큰 차이점은 다음과 같습니다.</p>
<ul>
<li>Swinject</li>
</ul>
<pre><code class="language-swift">container.register(UserRemoteDataSource.self) { r in
    UserRemoteDataSourceImpl(apiClient: r.resolve())
}.inObjectScope(.container)</code></pre>
<ul>
<li>Pure</li>
</ul>
<pre><code class="language-swift">final class UserRemoteDataSourceImpl: FactoryModule {
    struct Dependency {
        let apiClient: APIClient
    }

    struct Payload {
        ...
    }

    private let dependency: Dependency

    required init(dependency: Dependency) {
        self.dependency = dependency
    }
    ...
}

let dataSource = UserRemoteDataSourceImpl(dependency: .init(
    apiClient: apiClient
))</code></pre>
<p>위 코드를 보면 Swinject 에서는 <code>DIContainer</code> , <code>resolve()</code> , <code>inObjectScope()</code> 개념을 활용하지만 Pure 의 경우 우선 Container 를 제공하지 않습니다. 대신 <code>FactoryModule</code> 프로토콜을 채택하여 각 클래스가 자신이 필요로 하는 의존성을 <code>Dependency</code> 구조체로 명시적으로 선언하고, 생성 시점에 이니셜라이저를 통해 직접 주입받는 방식으로 의존성을 관리합니다. 또한 <code>Payload</code> 라는 필요 시 명시해야 하는데, 이는 “런타임” 단계에서 생성되는 데이터들을 의미합니다. 예를 들어 어떤 객체에서 <code>userId</code> 가 필요하다면 이는 런타임 단계에서 전달되는 값이기 때문에 이는 <code>Dependency</code> 가 아닌 <code>Payload</code> 에 실어야 하는 것이죠.</p>
<p>Pure 에서는 그럼 왜 <code>FactoryModule</code> 을 채택하여 <code>Dependency</code> 와 <code>Payload</code> 를 사용하는지 이 철학에 대해서 알아야 하는데요, 이는 <code>Swinject</code> 와 다르게 “컴파일 단계에서 의존성 누락을 발견하기 위함” 입니다. 예를 들어 설명하겠습니다.</p>
<ul>
<li>Swinject</li>
</ul>
<pre><code class="language-swift">// ✅ UserRemoteDataSource는 등록
container.register(UserRemoteDataSource.self) { r in
    UserRemoteDataSourceImpl(apiClient: r.resolve()!)
}

// ❌ APIClient는 등록을 깜빡함
// container.register(APIClient.self) { ... }  ← 이게 빠진 상태

// 사용 시점
let dataSource = container.resolve(UserRemoteDataSource.self)!
// → r.resolve()!에서 APIClient를 찾지 못해 런타임 크래시</code></pre>
<p><code>r.resolve()</code>는 결국 컨테이너에 <code>APIClient.self</code>로 등록된 게 있는지 <strong>런타임에 딕셔너리 조회</strong>를 하는 것이기 때문에, 등록이 빠져 있어도 컴파일러는 이를 알 수 없고 실행해봐야 발견됩니다. 따라서 개발자의 실수로 컨테이너에 의존성 등록을 깜빡하고 생략한 뒤 넘어갔다면, <code>forced Unwrapping</code> 으로 인해 의존성 부재로 앱이 크래시가 발생하는 것입니다.</p>
<p>따라서 위의 문제를 보완하기 위해 Pure에서는 <code>Dependency</code>를 명시함으로써 의존성 누락을 컴파일 타임에 잡아냅니다.</p>
<ul>
<li>Pure</li>
</ul>
<pre><code class="language-swift">final class UserRemoteDataSourceImpl: FactoryModule {
    struct Dependency {
        let apiClient: APIClient  // 필수 의존성으로 선언
    }

    required init(dependency: Dependency, payload: Payload) { ... }
}

// apiClient를 빠뜨리면?
let dataSource = UserRemoteDataSourceImpl(
    dependency: .init(),  // ❌ 컴파일 에러: &#39;apiClient&#39; 인자 누락
    payload: .init(userId: &quot;123&quot;)
)</code></pre>
<hr>
<h2 id="swinject-와-pure-혼합하여-사용했을-때">Swinject 와 Pure 혼합하여 사용했을 때</h2>
<p><code>Swinject</code> 의 장점으로는 <code>Container</code> 를 통해 의존성들을 중앙에서 관리할 수 있고, 스코프 관리를 통해 생명주기 관리를 개발자가 신경쓰지 않아도 된다는 점, 그리고 의존성 등록 시 프로토콜 기반으로 등록하고 <code>resolve()</code> 하기 때문에 사용하는 쪽에서는 구현체를 전혀 몰라도 된다는 장점이 있습니다.</p>
<p>다시 정리하면, 컴파일 타임 안정성을 우선시하면 <code>Pure</code>가, 런타임 유연성과 의존성 중앙 집중 관리를 우선시하면  <code>Swinject</code> 가 유리한데요,</p>
<p>이 둘을 혼합해서 의존성을 중앙에 집중하여 관리하되, 컴파일 타임 안정성을 유지할 수 있다면 더 좋겠죠. 그래서 저는 해당 사이드 프로젝트를 진행하면서 다음과 같이 혼합하여 사용했습니다.</p>
<pre><code>Swinject
├── DIContainer
│   ├── DataAssembly
│   ├── DomainAssembly
│   └── 각 Feature 모듈들의 Assembly

Pure
├── ~Reactor
└── ~ViewController</code></pre><p><code>DIContainer</code>를 통해 의존성을 중앙에서 집중 관리하되, 해당 프로젝트는 Clean Architecture를 준수하여 모듈을 구성했기 때문에 <code>Data</code>, <code>Domain</code> 모듈 그리고 각 <code>Feature</code> 모듈의 Assembly에는 Swinject를 적용했습니다.</p>
<p>반면 각 Feature 모듈 내부의 <code>Reactor</code>와 <code>ViewController</code>에는 Pure를 적용했습니다. 화면 간 데이터 전달이나 화면 구성에 필요한 런타임 파라미터가 많고, 의존성 역시 해당 화면을 띄우기 위해 반드시 필요한 경우가 대부분이기 때문에, 이를 누락할 경우 곧바로 크래시로 이어집니다. 따라서 런타임이 아닌 컴파일 단계에서 누락을 잡아낼 수 있는 Pure가 더 적합하다고 판단했습니다.</p>
<ul>
<li>Pure 를 적용한 HomeReactor</li>
</ul>
<pre><code class="language-swift">final class HomeReactor: Reactor, FactoryModule {

    struct Dependency {
        let fetchPostsListUseCase: FetchPostsListUseCase
        let fetchTop10PostsUseCase: FetchTop10PostsUseCase
        ...
    }

    ...

    required init(dependency: Dependency, payload: Void) {
        self.dependency = dependency
        self.payload = payload
    }
</code></pre>
<ul>
<li>Pure 를 적용한 HomeViewController</li>
</ul>
<pre><code class="language-swift">final class HomeViewController: UIViewController, FactoryModule {

    // MARK: - Init

    struct Dependency {
        let reactor: HomeReactor
    }

    required init(dependency: Dependency, payload: Void) {
        super.init(nibName: nil, bundle: nil)
        defer { self.reactor = dependency.reactor }
    }
    ...</code></pre>
<p>위 코드처럼 <code>HomeReactor</code>는 UseCase들을, <code>HomeViewController</code>는 Reactor를 <code>Dependency</code>로 선언하고 있어, 이 중 하나라도 빠뜨리면 컴파일 단계에서 즉시 에러가 발생합니다. 이를 통해 화면을 띄우는 데 필요한 의존성이 런타임 크래시 없이 안전하게 보장됩니다.</p>
<p>위 Reactor 와 ViewController 의 의존성을 조립하는 주체는 <code>Assembly</code> 입니다. 위에서 언급한대로 각 <code>Feature</code> 모듈 별 Assembly 는 Container 에서 관리하기 때문에 Assembly 내부에서 <code>r.resolve()</code>로 필요한 의존성을 가져온 뒤 Pure의 <code>Dependency</code> 구조체에 담아 <code>Factory</code>를 생성하는 방식으로 Swinject와 Pure를 함께 활용하고 있습니다.</p>
<pre><code class="language-swift">public final class HomeAssembly: Assembly {
    public init() {}

    public func assemble(container: Container) {
        container.register(HomeReactor.Factory.self) { r in
            HomeReactor.Factory(dependency: .init(
                fetchPostsListUseCase: r.resolve(),
                fetchTop10PostsUseCase: r.resolve(),
                likePostUseCase: r.resolve(),
                cancelLikePostUseCase: r.resolve(),
                userStore: r.resolve()
            ))
        }
        .inObjectScope(.graph)

        container.register(HomeViewController.Factory.self) { r in
            HomeViewController.Factory(dependency: .init(
                reactor: r.resolve(HomeReactor.Factory.self)!.create()
            ))
        }
        .inObjectScope(.graph)

        container.register(HomeCoordinator.self) { (r, navigationController: UINavigationController) in
            HomeCoordinatorImpl(
                navigationController: navigationController,
                viewController: r.resolve(HomeViewController.Factory.self)!.create()
            )
        }
        .inObjectScope(.graph)
    }
}
</code></pre>
<p>위 코드에서 볼 수 있듯이, Assembly 내부에서 <code>r.resolve()</code>로 가져온 의존성들을 Pure의 <code>Dependency</code> 구조체에 담아 <code>Factory</code>를 생성하고 있습니다. 이를 통해 모듈 간 의존성 관리는 Swinject의 <code>Container</code>가, 화면 단위의 의존성 안전성은 Pure의 컴파일 타임 검증이 각각 담당하는 구조가 됩니다. </p>
<p>Data/Domain 레이어의 Assembly와 달리 각 Feature 모듈의 Assembly에서는 <code>ObjectScope</code>를 <code>.graph</code>로 설정했습니다. <code>.graph</code> 스코프는 하나의 resolve 체인 내에서만 동일 인스턴스를 공유하고, 이후에는 새로운 인스턴스를 생성합니다. 따라서 Coordinator가 <code>removeChild()</code>를 통해 해제되면, 해당 resolve 체인에서 생성된 Reactor, ViewController 등의 객체들도 강한 참조가 사라지며 자연스럽게 메모리에서 해제됩니다. 또한 <code>.container</code>로 설정할 경우 화면을 다시 로드하더라도 기존 상태값이 그대로 유지되는 문제가 있는데, <code>.graph</code>를 사용하면 화면 진입 시마다 의존성이 새로 생성되므로 이전 상태가 남아있는 문제를 방지할 수 있습니다.</p>
<p>이처럼 <code>Swinject</code> 와 <code>Pure</code> 를 같이 활용함으로써 모듈 간 의존성처럼 유연한 중앙 관리가 필요한 레이어에 대해서는 <code>Swinject</code> 를, 화면을 구성할 때 의존성 누락처럼 휴먼 에러가 발생하기 쉬운 곳에서는 <code>Pure</code> 를 적용함으로써 런타임 유연성과 컴파일 타임 안정성을 동시에 확보할 수 있습니다. 물론 해당 구조가 모든 프로젝트의 정답으로 사용할 수 있는 Silver Bullet 은 아니겠지만, DI 전략을 고민하고 계신 분들께 하나의 선택지로 참고가 되었으면 합니다.</p>
<hr>
<h2 id="github">Github</h2>
<p>해당 사이드프로젝트에 대한 코드는 다음 링크에서 확인하실 수 있습니다.
<a href="https://github.com/sangjin-hash/09Market">https://github.com/sangjin-hash/09Market</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[iOS] 멀티 모듈 구조에서 Coordinator + Delegate 패턴으로 화면 흐름 관리하기]]></title>
            <link>https://velog.io/@sangjin-hash/iOS-%EB%A9%80%ED%8B%B0-%EB%AA%A8%EB%93%88-%EA%B5%AC%EC%A1%B0%EC%97%90%EC%84%9C-Coordinator-Delegate-%ED%8C%A8%ED%84%B4%EC%9C%BC%EB%A1%9C-%ED%99%94%EB%A9%B4-%ED%9D%90%EB%A6%84-%EA%B4%80%EB%A6%AC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@sangjin-hash/iOS-%EB%A9%80%ED%8B%B0-%EB%AA%A8%EB%93%88-%EA%B5%AC%EC%A1%B0%EC%97%90%EC%84%9C-Coordinator-Delegate-%ED%8C%A8%ED%84%B4%EC%9C%BC%EB%A1%9C-%ED%99%94%EB%A9%B4-%ED%9D%90%EB%A6%84-%EA%B4%80%EB%A6%AC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 01 Apr 2026 16:12:58 GMT</pubDate>
            <description><![CDATA[<h2 id="개요">개요</h2>
<pre><code class="language-swift">// LoginViewController.swift
func loginButtonTapped() {
    let homeVC = HomeViewController()
    self.navigationController?.pushViewController(homeVC, animated: true)
}</code></pre>
<p>간단한 iOS 앱을 개발할 때 화면 이동을 구현한다면 보통 이런식으로 구현할 수 있습니다. 그러나 만약 기능이 늘어난다고 하면 어떨까요?</p>
<ul>
<li>문제 1: ViewController 가 너무 많은 것을 알고 있다</li>
</ul>
<p>위 예시의 <code>LoginViewController</code> 를 기준으로, Login → Home 화면으로 이동할 때 <code>HomeViewController</code> 를 알아야 하는 문제가 있습니다. 즉 해당 <code>ViewController</code> 는 어느 화면으로 이동해야하는지 스스로 결정한다는 것입니다.</p>
<ul>
<li>문제 2: 재사용이 불가능하다</li>
</ul>
<p><code>LoginViewController</code>가 <code>HomeViewController</code>를 직접 생성하는 순간, Login 모듈은 Home 모듈에 의존하게 됩니다. 반대로 Home 모듈에서 Login을 띄워야 한다면 순환 참조 문제가 발생합니다. 멀티 모듈 프로젝트에서는 이러한 순환 참조 문제가 결국 빌드 오류로 직결되는 문제가 있습니다.</p>
<ul>
<li>문제 3: 테스트하기 어렵다</li>
</ul>
<p>화면 전환 로직이 <code>ViewController</code>에 있으면, 로직 검증을 위해 UI 환경 전체를 셋업해야 합니다.</p>
<p>구체적인 예시로 살펴보겠습니다. 다음과 같은 코드가 있다고 가정합니다.</p>
<pre><code class="language-swift">// LoginViewController.swift
func loginButtonTapped() {
    authService.login(id: id, password: password) { [weak self] result in
        switch result {
        case .success:
            let homeVC = HomeViewController()
            self?.navigationController?.pushViewController(homeVC, animated: true)
        case .failure:
            self?.showErrorAlert()
        }
    }
}</code></pre>
<p>→ &quot;로그인 성공 시 홈 화면으로 이동한다&quot;는 로직을 테스트하려면 다음을 모두 준비해야 합니다.</p>
<pre><code class="language-swift">// LoginViewControllerTests.swift
func test_로그인_성공_시_홈으로_이동() {
    // 1. UIWindow를 만들고 계층에 붙여야 navigationController가 동작함
    let window = UIWindow()
    let navController = UINavigationController()
    window.rootViewController = navController
    window.makeKeyAndVisible()

    // 2. ViewController를 직접 생성 및 로드
    let loginVC = LoginViewController()
    navController.pushViewController(loginVC, animated: false)

    // 3. 의존성 주입이 안 돼 있으면 실제 네트워크 호출 발생
    loginVC.loginButtonTapped()

    // 4. 비동기 완료를 기다려야 함
    RunLoop.main.run(until: Date(timeIntervalSinceNow: 1.0))

    // 5. 화면이 실제로 바뀌었는지 확인
    XCTAssertTrue(navController.topViewController is HomeViewController)
}</code></pre>
<ul>
<li>UIWindow 없이는 동작하지 않는다.<ul>
<li>navigationController?.pushViewController는 뷰 계층에 실제로 붙어 있어야 제대로 동작합니다.</li>
<li>UIWindow를 만들고 makeKeyAndVisible()을 호출해야 하는데, 이는 단위 테스트가 아니라 UI 테스트에 가까운 셋업입니다.</li>
</ul>
</li>
<li>실제 의존성을 차단하기 어렵다.<ul>
<li>authService를 Mock으로 교체하려면 ViewController 내부에 의존성 주입 포인트가 있어야 함.</li>
<li>코드가 내부에서 직접 생성한다면 테스트에서 제어할 수 없음.</li>
</ul>
</li>
</ul>
<p>따라서 <code>Coordinator</code> 패턴은 위 문제를 해결하기 위해 화면 전환 책임을 ViewController 에서 분리합니다. ViewController 는 오로지 화면 내에서 발생한 이벤트만 처리하고, 어느 화면으로 이동할지는 Coordinator 가 결정합니다.</p>
<hr>
<h2 id="recap-현재-프로젝트의-모듈-구조">Recap: 현재 프로젝트의 모듈 구조</h2>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/2c46aeef-ca21-460e-8879-9342b3a10218/image.png" height="400">
  <p style="font-size: 14px; color: gray;">▲ 그림 1. 모듈 구조</p>
</div>


<p>이전 게시글에서 설계했던 모듈 구조는 다음과 같습니다. App 모듈을 최상위로 의존성 방향은 App → 각 Feature 모듈임을 참고 바랍니다.</p>
<hr>
<h2 id="coordinator-패턴-기초">Coordinator 패턴 기초</h2>
<h3 id="1-coordinator-protocol">1. Coordinator Protocol</h3>
<pre><code class="language-swift">// AppCore/Sources/Coordinator/Coordinator.swift

    public protocol Coordinator: AnyObject {
    var childCoordinators: [Coordinator] { get set }
    var navigationController: UINavigationController { get }
    func start()
}

public extension Coordinator {
    func addChild(_ coordinator: Coordinator) {
        self.childCoordinators.append(coordinator)
    }

    func removeChild(_ coordinator: Coordinator) {
        self.childCoordinators.removeAll { $0 === coordinator }
    }
}
</code></pre>
<ul>
<li><code>childCoordinators</code> : 하위 Coordinator를 strong reference로 유지하여, 흐름이 끝나기 전까지 메모리에서 해제되지 않도록 관리</li>
<li><code>navigatioController</code> : 실제 화면 전환에 사용할 Navigation Controller</li>
<li><code>start()</code> : 해당 Coordinator가 담당하는 flow를 시작하는 진입점</li>
<li><code>AnyObject</code>를 채택하는 이유는 Coordinator를 class 전용으로 제한하여 참조 비교(<code>===</code>)와 weak 참조를 가능하게 하기 위해</li>
</ul>
<h3 id="2-feature-모듈의-coordinator-프로토콜">2. Feature 모듈의 Coordinator 프로토콜</h3>
<pre><code class="language-swift">// Login/Sources/Interface/LoginCoordinator.swift
public protocol LoginCoordinator: Coordinator {
    var delegate: LoginCoordinatorDelegate? { get set }
}</code></pre>
<p>각 Feature 모듈은 공통 Coordinator를 기반으로, Feature 단위의 역할과 인터페이스를 명확히 하기 위해 별도의 Coordinator 프로토콜을 정의합니다.</p>
<p>Coordinator 프로토콜을 그대로 사용하지 않고 Feature별 프로토콜로 한 번 더 감싸는 이유는 두 가지입니다.</p>
<ol>
<li>Delegate 속성 선언 — 각 Feature가 상위 Coordinator(<code>AppCoordiantor</code>) 와의 통신을 위해 <code>delegate</code> 를 정의하여, 화면 흐름의 결과(ex. 완료, 취소 등)를 외부로 전달할 수 있도록 합니다.</li>
<li>Interface / Implement 분리 — 이는 지난번 게시글에서 언급한대로, Interface와 Implement를 분리하여, 상위 레이어(App)는 구체 구현이 아닌 프로토콜에만 의존하도록 하고, 실제 구현체는 DI 컨테이너에서 주입받아 결합도를 낮춥니다.</li>
</ol>
<p>이 구조 덕분에 Feature 모듈끼리는 서로의 존재를 모른 채 오직 프로토콜을 통해서만 소통함으로써 Feature 모듈 간 결합도를 낮추고, 독립적인 개발 및 테스트가 가능해집니다.</p>
<h3 id="3-appcoordinator---최상위-coordinator">3. AppCoordinator - 최상위 Coordinator</h3>
<pre><code class="language-swift">final class AppCoordinator: Coordinator {

    // MARK: - Coordinator Protocol

    var childCoordinators: [Coordinator] = []
    let navigationController: UINavigationController

    private let window: UIWindow
    private let diContainer: AppDIContainer

    // MARK: - Init

    init(window: UIWindow, diContainer: AppDIContainer) {
        self.window = window
        self.diContainer = diContainer
        self.navigationController = UINavigationController()
    }


    // MARK: - Start

    func start() {
        self.window.rootViewController = self.navigationController
        self.window.makeKeyAndVisible()
        self.startAuth()
    }
}</code></pre>
<p><code>AppCoordinator</code>는 애플리케이션의 최상위 Coordinator로서 전체 화면 흐름을 관리하는 역할을 담당합니다. 앱 실행 시 가장 먼저 생성되어 start()를 통해 초기 화면(예: 로그인 또는 메인 화면)을 결정하고, 이후 각 Feature Coordinator를 생성 및 연결하여 전체 navigation 흐름을 제어합니다. 또한 하위 Coordinator들을 childCoordinators로 관리하며, 각 흐름이 종료되면 적절히 해제하여 메모리를 관리합니다. 이를 통해 앱 전반의 흐름을 한 곳에서 통합적으로 제어하고, Feature 간 결합도를 낮추는 구조를 유지할 수 있습니다.</p>
<h3 id="4-tabbarcoordaintor">4. TabBarCoordaintor</h3>
<pre><code class="language-swift">  final class TabBarCoordinator: Coordinator {

      var childCoordinators: [Coordinator] = []
      let navigationController: UINavigationController

      private let tabBarController: UITabBarController
      private let diContainer: AppDIContainer
      private weak var homeDelegate: HomeCoordinatorDelegate?
      private weak var profileDelegate: ProfileCoordinatorDelegate?

      func start() {
          let homeNav = UINavigationController()
          let profileNav = UINavigationController()

          setupHomeTab(homeNav)
          setupProfileTab(profileNav)

          self.tabBarController.viewControllers = [homeNav, profileNav]
          self.navigationController.setViewControllers([self.tabBarController], animated: false)
      }
  }</code></pre>
<p>각 탭마다 독립적인 Coordinator와 독립적인 <code>UINavigationController</code>를 가지는 것이 일반적입니다. AppCoordinator가 TabBarCoordinator를 자식으로 생성하고, TabBarCoordinator가 각 탭별 Coordinator(HomeCoordinator, LoginCoordinator 등)를 자식으로 생성합니다.</p>
<p>각 탭 Coordinator는 자신만의 <code>NavigationController</code>를 보유하며, TabBarController의 <code>viewControllers</code> 배열에 각 탭의 <code>NavigationController</code>를 할당합니다. 일반 화면 전환과 달리 탭 전환은 <code>UITabBarController</code>가 자체 처리하므로, 모든 탭의 Coordinator를 앱 시작 시 미리 생성해놓아야 합니다. 각 탭의 네비게이션 스택이 독립적으로 관리되어 탭 간 간섭이 없고, 각 탭 내부의 push/pop만 해당 탭의 Coordinator가 독립적으로 관리합니다.</p>
<hr>
<h2 id="delegate-패턴과-coordinator-의-계층-구조">Delegate 패턴과 Coordinator 의 계층 구조</h2>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/bde25e16-68c7-4917-b176-1181e7005af1/image.png" width="400">
  <p style="font-size: 14px; color: gray;">▲ 그림 2. 앱 실행 -> 인증/인가 플로우</p>
</div>


<p>앱 실행 → 인증/인가 → 로그인 or 홈 화면 이동 하는 시나리오를 기준으로 알아보겠습니다. 우선 진행하고 있는 사이드 프로젝트의 경우 앱 실행 시 인증/인가 작업을 하는데, 인증/인가 작업의 경우 <code>Supabase</code> 의 <code>Authenticate</code> 기능을 활용했습니다. 이는 SNS 소셜 로그인(ex. Google, Apple)을 하면 해당 Auth Provider(Google, Apple)에서 사용자 신원을 증명하는 ID Token(JWT) 을 발급합니다. 이 때 Supabase 는 이 ID Token 을 Provider 의 공개키로 검증한 후, 자체적으로 <code>Access Token</code> 과 <code>Refresh Token</code> 을 발급하게 됩니다. 이는 곧 <code>Supabase 에서 제공하는 토큰 == 앱 내 서비스를 이용하는 사용자 신원 확인</code> 이 가능한 것인데요,</p>
<p>위 플로우차트에서 언급된 상태들에 따라 화면을 분기처리하게 됩니다.</p>
<ul>
<li><code>.anonymous</code> : 익명 사용자(로그인 이력이 없는 사용자) → 쉽게 말해 비회원 → 탭바 화면</li>
<li><code>.authenticated</code> : 서비스를 이용하는 사용자(로그인 이력이 있는 사용자) → 탭바 화면</li>
<li><code>.unauthenticated</code> : 로그인 이력이 있으나 로그인한지 오래되어 토큰이 만료된 사용자 → 로그인 화면</li>
</ul>
<p>다시 본론으로 들어와서 위 상태에 따라 어디로 분기처리 해야하는지 알아보았으니 이제 Coordinator 와 Delegate 패턴을 어떻게 활용했는지 알아보겠습니다.</p>
<h3 id="1--app-실행">1.  App 실행</h3>
<ul>
<li>SceneDelegate.swift</li>
</ul>
<pre><code class="language-swift">final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?
    private var appCoordinator: AppCoordinator?

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

        let appCoordinator = AppCoordinator(
            window: window,
            diContainer: AppDIContainer.shared
        )
        self.appCoordinator = appCoordinator
        appCoordinator.start()
    }

    ...</code></pre>
<p>App 실행 시 SceneDelegate 에서 AppCoordinator 를 생성하여 <code>start()</code> 를 호출합니다. <code>AppCoordinator</code> 는 최상위 모듈인 <code>App</code> 모듈에 있는 Coordinator 이자, 최상위 Coordinator 를 의미합니다.</p>
<h3 id="2-appcoordinator-의-start">2. AppCoordinator 의 start()</h3>
<p>Coordinator Protocol 과 AppCoordinator 는 위에서 다루었습니다. 그래서 해당 <code>start()</code> 에서는 어떤 로직을 처리하는지 알아보겠습니다.</p>
<pre><code class="language-swift">...
// MARK: - Start

    func start() {
        self.window.rootViewController = self.navigationController
        self.window.makeKeyAndVisible()
        self.startAuth() // 인증/인가 작업 수행
    }
}

// MARK: - Flow

private extension AppCoordinator {
    /// 앱 실행 시 인증/인가 작업 처리
    func startAuth() {
        let authCoordinator: AuthCoordinator = self.diContainer.resolver.resolve(argument: self.navigationController)

        authCoordinator.delegate = self
        self.addChild(authCoordinator)
        authCoordinator.start()
    }

    /// 홈 화면으로 이동
    func showTabBar() {
        let tabBarCoordinator = TabBarCoordinator(
            navigationController: self.navigationController,
            diContainer: self.diContainer,
            homeDelegate: self,
            profileDelegate: self
        )

        self.addChild(tabBarCoordinator)
        tabBarCoordinator.start()
    }

    /// 로그인 화면으로 이동
    func showLogin() {
        let loginCoordinator: LoginCoordinator = self.diContainer.resolver.resolve(argument: self.navigationController)

        loginCoordinator.delegate = self
        self.addChild(loginCoordinator)
        loginCoordinator.start()
    }
}</code></pre>
<p>우선 해당 <code>AppCoordinator</code> 에서 담당하는 Coordinator 는 다음과 같습니다.</p>
<ul>
<li><code>AuthCoordinator</code> → 인증/인가 작업 담당, Auth 화면은 없음</li>
<li><code>TabBarCoordinator</code> → 익명 사용자 or 로그인 이력이 있는 사용자</li>
<li><code>LoginCoordinator</code> → 토큰 만료된 사용자</li>
</ul>
<p>위 [Recap: 현재 프로젝트의 모듈 구조] 의 의존성 그래프를 보시면 <code>Authenticate</code> , <code>Login</code> , <code>Home</code> 등 각 기능을 담당하는 Feature 모듈들로 나뉘어져 있기 때문에 각 모듈에서 담당하고 있는 Coordinator 입니다.</p>
<p>따라서 <code>AppCoordinator</code> 에서 담당하고 있는 하위 Coordinator 들에 대하여 별도 extension 으로 Flow 를 관리했습니다. 즉, 해당 Coordinator 에서는 자식 Coordinator 를 생성하고 <code>start()</code> 를 호출는 역할을 합니다. </p>
<h3 id="3-authcoordinator">3. AuthCoordinator</h3>
<ul>
<li>Features/Authenticate/Implement/AuthCoordinatorImpl.swift</li>
</ul>
<pre><code class="language-swift">    // MARK: - Delegate

    public weak var delegate: AuthCoordinatorDelegate?
    ...

    public func start() {
        // 1. Splash 표시
        let viewController = AuthViewController()
        self.navigationController.setViewControllers([viewController], animated: false)

        // 2. authState 확정 시 delegate 호출
        self.authReactor.state.map(\.authState)
            .compactMap { $0 }
            .take(1)
            .observe(on: MainScheduler.instance)
            .subscribe(onNext: { [weak self] state in
                self?.delegate?.authDidCheckOnLaunch(state: state)
            })
            .disposed(by: self.disposeBag)

        // 3. 에러 시 ErrorDialog 표시 (authVC 위에 표시)
        self.authReactor.state.map(\.error)
            .compactMap { $0 }
            .observe(on: MainScheduler.instance)
            .subscribe(onNext: { [weak self] error in
                guard let self else { return }
                ErrorDialog.show(
                    on: viewController,
                    error: error,
                    retryAction: { self.authReactor.action.onNext(.checkAuth) }
                )
            })
            .disposed(by: self.disposeBag)

        // 4. checkAuth 실행
        self.authReactor.action.onNext(.checkAuth)
    }</code></pre>
<p>최상위 <code>App</code> 모듈에서 하위 모듈인 <code>Authenticate</code> 모듈로 넘어왔고, 여기서 <code>Reactor</code> 를 통해 인증/인가 작업을 실행하게 됩니다. <code>self.authReactor.action.onNext(.checkAuth)</code> 를 실행하게 되면 인증/인가 UseCase 가 수행되면서 그에 따른 상태를 <code>// 2. authState 확정 시 delegate 호출</code> 에서 관찰하게 되는데요, 이 때 주목하실 부분은 <code>self?.delegate?.authDidCheckOnLaunch(state: state)</code> 입니다.</p>
<p>즉 모든 인증/인가 작업이 끝난 경우 <code>AuthCoordinatorDelegate</code> 의 <code>authDidCheckOnLaunch</code> 를 호출하게 되는데요, 이는 다음 단계에서 알아보겠습니다.</p>
<h3 id="4-authcoordinatordelegate">4. AuthCoordinatorDelegate</h3>
<ul>
<li>Features/Authenticate/Interface/AuthCoordinatorDelegate.swift</li>
</ul>
<pre><code class="language-swift">public protocol AuthCoordinatorDelegate: AnyObject {
    /// Splash 인증 확인 완료
    func authDidCheckOnLaunch(state: AuthState)
}</code></pre>
<p>이 Delegate 의 <code>authDidCheckOnLaunch</code> 는 인증/인가 작업이 완료 된 이후 처리해야 할 로직을 위임해야 합니다. 보통 Delegate 패턴의 경우 &quot;이 일이 끝났다&quot;는 사실만 알리고, 그 이후 어떻게 처리할지는 채택한 쪽에 맡기는 것이 핵심입니다. 즉 Authenticate 의 모듈에서 담당하고 있는 “인증/인가” 작업이 끝났으니 더 이상 해당 모듈이 관여할 게 없다는 뜻이죠. 그러므로 이는 최상위인 <code>App</code> 모듈에 알려야 합니다.</p>
<h3 id="5-appcoordinator-가-authcoordinatordelegate-채택">5. AppCoordinator 가 AuthCoordinatorDelegate 채택</h3>
<pre><code class="language-swift">// MARK: - AuthCoordinatorDelegate

extension AppCoordinator: AuthCoordinatorDelegate {
    /// 런치 시 인증 상태 확인 완료 후 분기 처리
    func authDidCheckOnLaunch(state: AuthState) {
        switch state {
        case .anonymous:
            showTabBar()

        case .authenticated:
            showTabBar()

        case .unauthenticated:
            self.loginContext = .launch
            showLogin()
        }
    }
}</code></pre>
<p>다시 <code>AppCoordiantor</code> 로 돌아와서, 인증/인가 작업 이후의 로직 처리는 App 모듈이 담당해서 라우팅 해야 합니다. 따라서 위임 주체는 <code>AppCoordinator</code>  이므로 이와 같이 해당 Delegate 를 채택하여 그 다음 어느 화면으로 라우팅할지 결정하게 됩니다. </p>
<hr>
<h2 id="결론">결론</h2>
<p>이로써 다시 정리하자면 최상위 <code>AppCoordinator</code> 는 화면 라우팅을 담당하고, 각 Feature 모듈에서는 자신이 책임지고 있는 기능들을 수행합니다. Feature 모듈은 화면 전환이 필요한 시점에 직접 처리하는 것이 아니라, Delegate를 통해 이벤트를 상위로 위임합니다.</p>
<p>이 구조가 주는 이점은 명확합니다.</p>
<ul>
<li>Feature 모듈 간 의존성 없음 — Authenticate는 Login을, Home은 Login을 import하지 않습니다. 각 모듈은 오직 자신의 역할에만 집중합니다. 따라서 “순환 참조” 문제가 발생하지 않는 것입니다.</li>
<li>화면 전환 로직의 단일 책임 — 어떤 화면으로 이동할지는 AppCoordinator 한 곳에서만 결정합니다. 흐름 파악이 쉽고 수정 시 영향 범위가 명확합니다.</li>
<li>테스트 용이성 — Feature 모듈은 Delegate만 교체하면 독립적으로 테스트할 수 있습니다.</li>
</ul>
<p>결국 Coordinator 계층 구조와 Delegate 패턴의 조합은 단순히 &quot;화면 이동 코드를 분리하는 것&quot;이 아닙니다. 모듈이 서로를 모르는 상태에서도 앱 전체의 흐름을 일관되게 유지할 수 있게 해주는 설계 원칙입니다.</p>
<p>그럼 다음 게시글에서는 중간에 설명을 생략한 “DIContainer”가 있는데, 이것은 무엇이고 어떻게 활용하고 있는지에 대해 알아보겠습니다.</p>
<hr>
<h2 id="github">GitHub</h2>
<p>해당 사이드프로젝트에 대한 코드는 다음 링크에서 확인하실 수 있습니다.
<a href="https://github.com/sangjin-hash/09Market">https://github.com/sangjin-hash/09Market</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[iOS] 멀티 모듈 아키텍처 적용해보기(Tuist 를 곁들인)]]></title>
            <link>https://velog.io/@sangjin-hash/iOS-%EB%A9%80%ED%8B%B0-%EB%AA%A8%EB%93%88-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EC%A0%81%EC%9A%A9%ED%95%B4%EB%B3%B4%EA%B8%B0Tuist-%EB%A5%BC-%EA%B3%81%EB%93%A4%EC%9D%B8</link>
            <guid>https://velog.io/@sangjin-hash/iOS-%EB%A9%80%ED%8B%B0-%EB%AA%A8%EB%93%88-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EC%A0%81%EC%9A%A9%ED%95%B4%EB%B3%B4%EA%B8%B0Tuist-%EB%A5%BC-%EA%B3%81%EB%93%A4%EC%9D%B8</guid>
            <pubDate>Sat, 21 Mar 2026 15:19:27 GMT</pubDate>
            <description><![CDATA[<h2 id="왜-tuist-를-도입했는가">왜 Tuist 를 도입했는가</h2>
<p>Tuist 의 주요 장점을 짧게 요약하자면</p>
<ol>
<li>프로젝트 일관성 보장과 .pbxproj 충돌 해소</li>
<li>멀티모듈 구조를 쉽게 구성하고 관리</li>
<li>의존성 관리 통합</li>
</ol>
<p>이 세가지로 들 수 있습니다. 개인적인 생각으로는 위 세가지 중 특히 두번째가 가장 큰 장점이지 않나 싶은데요, 멀티모듈 구조에서는 변경된 모듈만 재빌드하기 때문에 빌드 시간을 크게 줄일 수 있습니다. 하지만 Xcode 만으로 멀티모듈을 구성하려면, 새 모듈을 추가할 때마다 프레임워크 타겟 생성 → 빌드 설정 → 의존성 연결을 GUI 를 통해 일일이 작업해야 합니다. 이 과정에서 <code>deployment target</code>, <code>product type</code>, <code>bundle ID</code> 같은 값들이 모듈마다 제각각이 되기 쉽고, 모듈이 늘어날수록 동일한 설정 작업을 반복하게 됩니다.</p>
<p>Tuist는 이 과정을 Swift 코드로 선언함으로써 만들 수 있게 하는데, 공통 설정을 헬퍼 함수(<code>ProjectDescriptionHelpers</code> 추후 언급)로 한 번 정의해두면, 새 모듈을 추가할 때는 모듈 이름과 의존성만 지정하면 나머지는 헬퍼가 알아서 채워줍니다. 덕분에 모듈이 몇 개든 일관된 설정이 보장되고, 추가 작업도 간단해집니다. 이처럼 Tuist는 멀티모듈 도입의 진입 장벽을 낮춰주는 역할을 합니다.</p>
<p>이번 사이드 프로젝트의 목표로는 “멀티 모듈의 이해”를 목적으로 수행하는 것으로, 왜 이 기술을 선택하고 어떤 고민이 있었는지 등 의사결정 기반으로 회고를 해보며 복습 차원에서 글을 남겨볼 예정입니다.  </p>
<hr>
<h2 id="프로젝트-구조-설계">프로젝트 구조 설계</h2>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/4157a433-f236-4302-b2dc-dc4f23d0e9a4/image.png">
  <p style="font-size: 14px; color: gray;">▲ 그림 1. 모듈 그래프</p>
</div>


<p>아직 프로젝트 작업이 전부 완료되진 않았지만 현재 기준으로 모듈 구조는 다음과 같습니다. 해당 프로젝트는 ‘MVVM + Clean Architecture + 멀티모듈&#39; 구조로 앱을 만들고 있고, 아직은 기능이 많지 않아 별도 <code>Domain</code> 과 <code>Data</code> 모듈이 통합으로 관리되는 점 참고 바랍니다.</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/3a8574ca-827f-4464-8786-a284c219ea41/image.png" width="400">
  <p style="font-size: 14px; color: gray;">▲ 그림 2. 모듈 구성</p>
</div>

<ul>
<li>App</li>
</ul>
<p>앱의 진입점으로 모든 모듈을 조립하는 역할을 합니다. <code>DIContainer</code> 를 통해 각 모듈의 구현체를 등록하고, 앱의 런치 플로우를 결정합니다. 직접적인 비즈니스 로직은 담지 않고, 모듈들을 연결하는 역할을 합니다.</p>
<ul>
<li>Feature (Home, Authenticate, Profile, Login, …)</li>
</ul>
<p>사용자가 직접 마주하는 화면 단위의 모듈입니다. 각 Feature 는 <code>Interface</code> 와 <code>Implement</code> 두 타겟으로 모듈이 분리되어 있는데 이는 추후에 다시 설명하겠습니다. 간단하게만 언급하고 넘어가자면 이렇게 분리를 통해 <code>Feature</code> 간 구현체인 <code>Implement</code> 를 직접 참조하지 않고 <code>Interface</code> 에만 의존하게 되어 모듈 간 결합도를 낮추는 장점이 있습니다.</p>
<ul>
<li>Domain</li>
</ul>
<p>앱의 비즈니스 로직을 담당합니다. <code>UseCase</code> 와 <code>Repository</code> 프로토콜, <code>Entity</code> 등이 여기에 위치합니다. 특히 <code>Domain</code> 의 경우 특정 프레임워크나 외부 라이브러리에 의존하지 않는 순수한 비즈니스 규칙만을 유지해야 합니다.</p>
<ul>
<li>Data</li>
</ul>
<p>외부 데이터 소스와의 통신을 담당합니다. 데이터 소스는 크게 Local 과 Remote 로 나누었고, Local 은 <code>KeyChain</code> 이나 <code>SwiftData</code> 와 같이 디바이스 내 DB 를 이용하는 경우와 Remote 는 자체 서버의 API 호출, 3rd Party 등으로 나누었습니다. <code>Repository</code> 패턴을 사용중인데 이는 Local/Remote 의 여러 데이터 소스를 활용하는 경우 오케스트레이션 역할을 합니다.</p>
<ul>
<li>AppCore</li>
</ul>
<p>앱 수준의 핵심 타입, 로직들을 모아둔 모듈입니다. <code>Domain</code> 모듈은 비즈니스 로직을 담당하는 반면 <code>AppCore</code> 모듈은 앱 전역에서 활용될 수 있는 핵심 로직들을 모아둔 것으로 예를 들어 인증 상태 관리, 사용자 정보 관리, 에러 Fallback 등 이러한 로직들로 구성되어 있습니다.</p>
<ul>
<li>DesignSystem</li>
</ul>
<p>앱의 시각적 일관성을 담당하는 모듈입니다. 공통 컬러, 폰트, 문자열 리소스 등 UI 관련 에셋과 상수를 중앙에서 관리합니다.</p>
<ul>
<li>Util</li>
</ul>
<p>프레임워크에 의존하지 않는 순수 유틸리티 모듈입니다. 앱 내 핵심 로직이나 비즈니스 로직과는 거리가 멀고 앱 내 유틸리티를 담당하는 로직들이 해당 모듈에 위치합니다. ex) JWTParser</p>
<ul>
<li>Shared(DI, ReactiveX, UI)</li>
</ul>
<p>특정 외부 라이브러리를 감싸서 프로젝트 전체에 제공하는 래퍼 모듈입니다. 우선은 총 3가지 Shared 모듈을 이용하고 있는데,</p>
<ul>
<li>DI<ul>
<li>Swinject</li>
<li>Pure</li>
</ul>
</li>
<li>ReactiveX<ul>
<li>ReactorKit</li>
<li>RxSwift</li>
<li>RxCocoa</li>
<li>RxDataSource</li>
</ul>
</li>
<li>UI<ul>
<li>SnapKit</li>
<li>FlexLayout</li>
<li>PinLayout</li>
</ul>
</li>
</ul>
<p>각 Shared 모듈은 <code>@_exported import</code>를 통해 외부 라이브러리를 re-export하므로, Feature나 Domain에서 외부 라이브러리를 직접 import할 필요 없이 Shared 모듈 하나만 import하면 됩니다. 또한 각 모듈에는 프로젝트에서 공용으로 자주 사용되는 편의 <code>extension</code>도 함께 포함하고 있습니다. </p>
<hr>
<h2 id="tuist-기본-설정">Tuist 기본 설정</h2>
<h3 id="1-tuistpackageswift로-외부-의존성-관리">1. Tuist/Package.swift로 외부 의존성 관리</h3>
<p>Tuist에서는 외부 라이브러리 의존성을 <code>Tuist/Package.swift</code> 파일에서 SPM(Swift Package Manager) 형식으로 선언합니다. 이 파일은 프로젝트 루트의 <code>Tuist/</code> 디렉토리 아래에 위치하며, Tuist가 프로젝트를 생성할 때 이 선언을 읽어 외부 패키지를 연동해줍니다.</p>
<pre><code class="language-swift">// Tuist/Package.swift
...

let package = Package(
    name: &quot;Market09&quot;,
    dependencies: [
        .package(url: &quot;https://github.com/Alamofire/Alamofire&quot;, from: &quot;5.0.0&quot;),
        .package(url: &quot;https://github.com/onevcat/Kingfisher&quot;, from: &quot;8.0.0&quot;),
        .package(url: &quot;https://github.com/Swinject/Swinject&quot;, from: &quot;2.10.0&quot;),
        .package(url: &quot;https://github.com/supabase/supabase-swift&quot;, from: &quot;2.0.0&quot;),
        .package(url: &quot;https://github.com/ReactorKit/ReactorKit&quot;, from: &quot;3.2.0&quot;),
        .package(url: &quot;https://github.com/ReactiveX/RxSwift&quot;, from: &quot;6.0.0&quot;),
        .package(url: &quot;https://github.com/RxSwiftCommunity/RxDataSources&quot;, from: &quot;5.0.0&quot;),
        .package(url: &quot;https://github.com/google/GoogleSignIn-iOS&quot;, from: &quot;8.0.0&quot;),
        .package(url: &quot;https://github.com/devxoul/Pure&quot;, from: &quot;1.1.0&quot;),
        .package(url: &quot;https://github.com/devxoul/ReusableKit&quot;, from: &quot;4.0.0&quot;),
        .package(url: &quot;https://github.com/layoutBox/FlexLayout&quot;, from: &quot;2.0.0&quot;),
        .package(url: &quot;https://github.com/layoutBox/PinLayout&quot;, from: &quot;1.10.0&quot;),
        .package(url: &quot;https://github.com/SnapKit/SnapKit&quot;, from: &quot;5.7.0&quot;),
    ]
)

// Data/Project.swift
let project = Project(
    name: &quot;Data&quot;,
    targets: [
        .target(
            name: &quot;Data&quot;,
            destinations: [.iPhone],
            product: .staticFramework,
            bundleId: &quot;...&quot;,
            deploymentTargets: .iOS(&quot;17.0&quot;),
            infoPlist: .default,
            sources: [&quot;Sources/**&quot;],
            dependencies: [
                .module(.domain),
                .module(.core),
                .external(name: &quot;Alamofire&quot;),
                .external(name: &quot;GoogleSignIn&quot;),
                .external(name: &quot;Kingfisher&quot;),
                .external(name: &quot;Supabase&quot;),
                .module(.sharedDI),
            ]
        ),
        ...
    ]
)
</code></pre>
<p>여기서 선언한 패키지들은 <code>Project.swift</code>의 각 타겟에서 <code>.external(name:)</code> 형태로 참조하여 사용할 수 있습니다. 예를 들어 Data 모듈 타겟에서 Alamofire가 필요하다면 <code>dependencies</code>에 <code>.external(name: &quot;Alamofire&quot;)</code>를 추가하는 식입니다.</p>
<p>이 방식의 장점은 외부 의존성 선언이 <code>Tuist/Package.swift</code> 한 곳에 모이기 때문에, 어떤 라이브러리를 사용하는지 한눈에 파악할 수 있고, 버전 관리도 중앙에서 일관되게 할 수 있다는 점입니다.</p>
<h3 id="2-루트-projectswift-구성-app-타겟-infoplist-확장-xcconfig-연동">2. 루트 Project.swift 구성 (App 타겟, Info.plist 확장, xcconfig 연동)</h3>
<p><code>Project.swift</code>는 Tuist 프로젝트의 핵심 매니페스트 파일로, 앱 타겟, 빌드 설정, Info.plist 구성 등을 정의하는 곳입니다.</p>
<ul>
<li>App Target 및 xcconfig 연동</li>
</ul>
<pre><code class="language-swift">let project = Project(
    name: &quot;Market09&quot;,
    settings: .settings(
        configurations: [
            .debug(name: &quot;Debug&quot;, xcconfig: &quot;./Secrets.xcconfig&quot;),
            .release(name: &quot;Release&quot;, xcconfig: &quot;./Secrets.xcconfig&quot;),
        ]
    ),
    targets: [
        // MARK: - App
        .target(
            name: &quot;App&quot;,
            destinations: .iOS,
            product: .app,
            bundleId: &quot;...&quot;,
            ...</code></pre>
<p>여기서 주목할 부분은 <code>xcconfig</code> 연동입니다. API 키나 클라이언트 ID 같은 민감한 값들은 코드에 직접 하드코딩하지 않고, <code>.xcconfig</code> 파일에 분리하여 관리합니다. 이 파일을 <code>.gitignore</code>에 추가하면 Git에 민감 정보가 노출되는 것을 방지할 수 있습니다. Tuist에서는 <code>configurations</code> 파라미터에 xcconfig 경로를 지정하면 빌드 시 해당 설정값들이 자동으로 주입됩니다.</p>
<ul>
<li>Info.plist 확장</li>
</ul>
<p>Tuist에서는 <code>infoPlist</code> 파라미터에 <code>.extendingDefault(with:)</code>를 사용하면 기본 Info.plist 위에 필요한 항목을 추가할 수 있습니다. 별도의 Info.plist 파일을 관리할 필요 없이 Swift 코드 안에서 선언적으로 설정할 수 있다는 점이 편리합니다.</p>
<pre><code class="language-swift">infoPlist: .extendingDefault(
                with: [
                    &quot;UIApplicationSceneManifest&quot;: [
                        &quot;UIApplicationSupportsMultipleScenes&quot;: false,
                        &quot;UISceneConfigurations&quot;: [
                            &quot;UIWindowSceneSessionRoleApplication&quot;: [
                                [
                                    &quot;UISceneConfigurationName&quot;: &quot;Default Configuration&quot;,
                                    &quot;UISceneDelegateClassName&quot;:
                                        &quot;$(PRODUCT_MODULE_NAME).SceneDelegate&quot;,
                                ]
                            ]
                        ],
                    ],
                    &quot;UILaunchScreen&quot;: [
                        &quot;UIColorName&quot;: &quot;&quot;,
                        &quot;UIImageName&quot;: &quot;&quot;,
                    ],
                    // Supabase
                    &quot;SUPABASE_URL&quot;: &quot;$(SUPABASE_URL)&quot;,
                    &quot;SUPABASE_ANON_KEY&quot;: &quot;$(SUPABASE_ANON_KEY)&quot;,

                    // Goole
                    &quot;GIDClientID&quot;: &quot;$(GOOGLE_CLIENT_ID)&quot;,

                    // URL Scheme for Google Sign-In callback
                    &quot;CFBundleURLTypes&quot;: [
                        [
                            &quot;CFBundleURLSchemes&quot;: [
                                &quot;$(GOOGLE_URL_SCHEME)&quot;  // reversed client ID
                            ]
                        ]
                    ],

                    // EndPoint
                    &quot;API_ME&quot;: &quot;$(API_ME)&quot;,
                    &quot;API_POST&quot;: &quot;$(API_POST)&quot;,
                    &quot;API_LIKE&quot;: &quot;$(API_LIKE)&quot;,
                ]
            ),
            ...</code></pre>
<p>위 설정에서 <code>$(SUPABASE_URL)</code>, <code>$(GOOGLE_CLIENT_ID)</code> 등의 값은 앞서 연동한 <code>Secrets.xcconfig</code>에 정의된 빌드 설정 변수를 참조합니다. 이렇게 하면 Info.plist에는 변수 참조만 남고, 실제 값은 xcconfig에만 존재하게 되어 민감 정보를 코드와 분리할 수 있습니다.</p>
<p>Scene 관련 설정의 경우, <code>UISceneDelegateClassName</code>에 <code>$(PRODUCT_MODULE_NAME).SceneDelegate</code>를 지정함으로써 <code>SceneDelegate</code> 기반의 앱 생명주기를 사용하도록 구성하고 있습니다.</p>
<ul>
<li>#if TUIST + PackageSettings 활용법</li>
</ul>
<p><code>Tuist/Package.swift</code> 파일을 보면 파일 상단에 <code>#if TUIST</code> 컴파일 플래그를 사용하는 경우가 있습니다. 이는 Tuist 환경에서만 실행되는 설정 블록을 정의하기 위한 것으로, 대표적으로 <code>PackageSettings</code>를 구성할 때 활용됩니다.</p>
<pre><code class="language-swift">#if TUIST
import ProjectDescription
import ProjectDescriptionHelpers

let packageSettings = PackageSettings(
    productTypes: [
        &quot;Alamofire&quot;: .framework,
        &quot;RxSwift&quot;: .framework,
        &quot;Kingfisher&quot;: .framework,
    ]
)
#endif

import PackageDescription

let package = Package(
    name: &quot;Market09&quot;,
    dependencies: [
        ...
    ]
)</code></pre>
<p><code>PackageSettings</code>는 외부 패키지가 Xcode 프로젝트로 변환될 때의 동작을 제어하는 역할을 합니다. 예를 들어 <code>productTypes</code>를 통해 특정 패키지를 static library가 아닌 dynamic framework로 빌드하도록 지정할 수 있습니다. 이는 앱 바이너리 크기, 빌드 시간, 그리고 모듈 간 링킹 방식에 영향을 주기 때문에, 프로젝트 구조에 따라 적절히 설정할 필요가 있습니다.</p>
<hr>
<h2 id="projectdescriptionhelpers">ProjectDescriptionHelpers</h2>
<p>Tuist에서는 <code>Tuist/ProjectDescriptionHelpers</code> 디렉토리에 Swift 파일을 두면, 모든 Project.swift에서 import하여 사용할 수 있습니다. 저는 우선 해당 프로젝트에 두 가지 헬퍼를 활용하고 있습니다.</p>
<ul>
<li>FeatureTarget.swift: Interface / Implement 타겟 팩토리 메서드</li>
</ul>
<pre><code class="language-swift">import ProjectDescription

extension Project {

    public static func interfaceTargets(
        name: String,
        deploymentTargets: DeploymentTargets = .iOS(&quot;17.0&quot;),
        infoPlist: InfoPlist = .default,
        sources: SourceFilesList? = nil,
        dependencies: [TargetDependency] = []
    ) -&gt; [Target] {
        let target = Target.target(
            name: name,
            destinations: [.iPhone],
            product: .staticFramework,
            bundleId: &quot;...\(name.lowercased())&quot;,
            deploymentTargets: deploymentTargets,
            infoPlist: infoPlist,
            sources: sources ?? [&quot;Sources/Interface/**&quot;],
            resources: nil,
            dependencies: dependencies
        )
        return [target]
    }

    public static func implementTargets(
        name: String,
        deploymentTargets: DeploymentTargets = .iOS(&quot;17.0&quot;),
        infoPlist: InfoPlist = .default,
        sources: SourceFilesList? = nil,
        dependencies: [TargetDependency] = []
    ) -&gt; [Target] {
        let target = Target.target(
            name: &quot;\(name)Impl&quot;,
            destinations: [.iPhone],
            product: .staticFramework,
            bundleId: &quot;...\(name.lowercased()).impl&quot;,
            deploymentTargets: deploymentTargets,
            infoPlist: infoPlist,
            sources: sources ?? [&quot;Sources/Implement/**&quot;],
            resources: nil,
            dependencies: [.target(name: name)] + dependencies
        )
        return [target]
    }
}
</code></pre>
<p>Feature 모듈은 모두 <code>Interface</code>와 <code>Implement</code> 두 타겟으로 구성되는데, 매번 타겟을 정의할 때마다 destinations, product type, deployment target, 소스 경로 등을 반복 작성해야 합니다. 이를 Project의 static 메서드로 추출하여, <code>interfaceTargets(name:dependencies:)</code>와 <code>implementTargets(name:dependencies:)</code>만 호출하면 공통 설정이 자동으로 채워지도록 했습니다. Implement 타겟은 같은 이름의 Interface 타겟을 자동으로 의존하도록 되어 있어, 의존성 누락을 방지할 수 있습니다. 덕분에 새 Feature 모듈의 Project.swift는 이름과 의존성만 지정하면 되고, Feature 모듈 모두 동일한 패턴으로 작성됩니다.</p>
<ul>
<li>TargetDependency+.swift: Feature enum + Module enum으로 타입 세이프한 의존성 선언</li>
</ul>
<p>Tuist에서 다른 프로젝트의 타겟을 의존하려면 <code>.project(target:path:)</code>를 사용해야 하는데, target 이름과 path를 문자열로 직접 입력하면 오타가 나기 쉽습니다. 이를 해결하기 위해 Feature enum과 Module enum을 정의하고, TargetDependency에 <code>.feature(.home, type: .interface)</code>나 <code>.module(.domain)</code> 같은 extension을 추가했습니다.</p>
<pre><code class="language-swift">import ProjectDescription

// MARK: - Feature

public enum Feature: String {
    case home = &quot;Home&quot;
    case auth = &quot;Authenticate&quot;
    case profile = &quot;Profile&quot;
    case login = &quot;Login&quot;
}

public enum FeatureType {
    case interface
    case implement
}

extension TargetDependency {

    public static func feature(_ feature: Feature, type: FeatureType) -&gt; TargetDependency {
        switch type {
        case .interface:
            return .project(
                target: feature.rawValue,
                path: .relativeToRoot(&quot;Features/\(feature.rawValue)&quot;)
            )
        case .implement:
            return .project(
                target: &quot;\(feature.rawValue)Impl&quot;,
                path: .relativeToRoot(&quot;Features/\(feature.rawValue)&quot;)
            )
        }
    }
}

// MARK: - Module

public enum Module: String {
    case domain = &quot;Domain&quot;
    case domainImpl = &quot;DomainImpl&quot;
    case core = &quot;AppCore&quot;
    case data = &quot;Data&quot;
    case sharedReactiveX = &quot;Shared_ReactiveX&quot;
    case sharedDI = &quot;Shared_DI&quot;
    case sharedUI = &quot;Shared_UI&quot;
    case util = &quot;Util&quot;
    case designSystem = &quot;DesignSystem&quot;

    var projectPath: String {
        switch self {
        case .domainImpl: return &quot;Domain&quot;
        case .sharedReactiveX, .sharedDI, .sharedUI: return &quot;Shared/\(rawValue)&quot;
        default: return rawValue
        }
    }
}

extension TargetDependency {

    public static func module(_ module: Module) -&gt; TargetDependency {
        .project(
            target: module.rawValue,
            path: .relativeToRoot(module.projectPath)
        )
    }
}
</code></pre>
<p>이를 통해 의존성 선언 시 문자열 대신 <code>enum case</code>를 사용하므로, 존재하지 않는 모듈 이름을 쓰면 컴파일 타임에 잡아낼 수 있습니다. 또한 Feature의 경우 type 파라미터로 <code>Interface</code>와 <code>Implement</code>를 구분하여, 타겟 이름에 Impl 접미사를 붙이는 규칙도 자동으로 처리됩니다.</p>
<hr>
<h3 id="feature-에-새로운-모듈-추가-시">Feature 에 새로운 모듈 추가 시</h3>
<p>위에선 Tuist 기본 설정과 Feature 모듈에 대한 Tuist Helper 적용(Implement/Interface 분리, 타입 세이프) 까지 다뤘고 그럼 이제 Feature 모듈에 새로운 모듈을 추가하는 방법에 대해 간단하게 언급만 하고 넘어가겠습니다.</p>
<ol>
<li><code>TargetDependency+.swift</code>의 Feature enum에 새 case를 추가</li>
<li>Features/ 디렉토리 아래에 해당 이름으로 폴더를 생성하고, Sources/Interface/, Sources/Implement/ 디렉토리 구조를 만듭니다.</li>
<li><code>Project.swift</code>를 작성합니다. 기존 Feature와 동일한 패턴으로 이름과 의존성만 지정하면 됩니다.</li>
</ol>
<pre><code class="language-swift">  let project = Project(
      name: &quot;NewFeature&quot;,
      targets: Project.interfaceTargets(
          name: &quot;NewFeature&quot;,
          dependencies: [
              .module(.core),
          ]
      ) + Project.implementTargets(
          name: &quot;NewFeature&quot;,
          dependencies: [
              .module(.core),
              .module(.domain),
              // ... 필요한 의존성
          ]
      )
  )</code></pre>
<ol>
<li>루트 <code>Project.swift</code>의 App 타겟에 <code>.feature(.newFeature, type: .interface)</code>와 <code>.feature(.newFeature, type: .implement)</code>를 추가</li>
<li><code>tuist generate</code> 실행</li>
</ol>
<hr>
<h2 id="feature-모듈의-interface--implement-분리">Feature 모듈의 Interface / Implement 분리</h2>
<h3 id="1-왜-분리하는가-의존성-역전-빌드-시간-최적화">1. 왜 분리하는가 (의존성 역전, 빌드 시간 최적화)</h3>
<p>Feature 모듈을 단일 타겟으로 구성하면 해당 모듈을 참조할 때 이는 두 가지 문제가 발생할 수 있습니다.</p>
<p>첫째, 의존성이 불필요하게 넓어집니다. 예를 들어 App 모듈의 <code>Coordinator</code>가 Home 화면으로 전환할 때, 실제로 필요한 건 <code>HomeCoordinator</code> 프로토콜뿐인데 Home의 <code>ViewController</code>, <code>Reactor</code>, <code>Cell</code>까지 모두 의존하게 됩니다.</p>
<p>둘째, 빌드 시간에 영향을 줍니다. <code>Implement</code> 쪽 코드가 변경될 때마다 이 모듈을 참조하는 모든 곳이 재빌드 대상이 됩니다. <code>Interface</code>와 <code>Implement</code>가 분리되어 있으면, <code>Implement</code> 내부에서 아무리 변경이 생겨도 <code>Interface</code>가 바뀌지 않는 한 다른 모듈은 재빌드되지 않습니다.</p>
<p>이렇게 분리하면 Feature 모듈 간 화면 이동에도 이점이 생깁니다. 각 Feature는 서로의 구현체를 알 필요 없이 <code>Delegate</code> 로 이벤트만 외부로 전달하고, <code>AppCoordinator</code>가 해당 Delegate 를 채택하여 화면 이동과 데이터 전달을 중재할 수 있습니다. 이로써 Feature 모듈 간 직접 참조가 없으므로 순환 참조 없이 자유로운 화면 전환이 가능해집니다.</p>
<h3 id="2-interface-coordinator--delegate">2. Interface: Coordinator + Delegate</h3>
<p>Interface 타겟에는 다른 모듈이 알아야 할 <code>Coordinator</code> 와 <code>delegate</code>만 정의합니다. 주로 해당 Feature 모듈에서 다른 Feature 모듈로 이동해야 하는 시나리오가 있을 때 이에 대한 함수를 <code>Delegate</code> 에 작성하고, 해당 <code>Delegate</code> 는 App 모듈의 <code>AppCoordinator</code> 에서 해당 <code>Delegate</code> 를 채택하여 화면 이동을 담당합니다.</p>
<pre><code class="language-swift">// HomeCoordinator
public protocol HomeCoordinator: Coordinator {
    var delegate: HomeCoordinatorDelegate? { get set }
}

// Delegate
public protocol HomeCoordinatorDelegate: AnyObject {
    func homeDidRequestLogin()
}</code></pre>
<h3 id="3-implement-실제-구현--interface-의존">3. Implement: 실제 구현 + Interface 의존</h3>
<p><code>Implement</code> 타겟에는 화면을 구성하는 모든 구현 코드가 들어갑니다.</p>
<ul>
<li>HomeCoordinatorImpl — Interface에서 정의한 HomeCoordinator의 실제 구현</li>
<li>HomeViewController, HomeReactor — 화면 로직</li>
<li>HomePostCardCell, HomeCategoryChipCell 등 — UI 컴포넌트</li>
<li>HomeAssembly — DI 등록</li>
</ul>
<hr>
<p>지금까지 Tuist 기본 설정과 ProjectDescriptionHelpers 를 통한 Scaffolding 그리고 Feature 모듈의 Interface/Implement 분리와 각 타겟의 역할에 대해 알아보았습니다. 해당 게시글 후반부에 잠깐 언급했던 <code>Coordinator</code> 와 <code>Delegate</code> 패턴에 대한 내용이 있는데 이는 다음 게시글에서 다뤄보며 어떻게 Feature 모듈간 의존하지 않고 화면 이동이나 Data passing 등을 가능하게 했는지 알아보겠습니다.</p>
<hr>
<h2 id="github">GitHub</h2>
<p>해당 사이드프로젝트에 대한 코드는 다음 링크에서 확인하실 수 있습니다.
<a href="https://github.com/sangjin-hash/09Market">https://github.com/sangjin-hash/09Market</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[iOS] DASH 에 대하여]]></title>
            <link>https://velog.io/@sangjin-hash/iOS-DASH-%EC%97%90-%EB%8C%80%ED%95%98%EC%97%AC</link>
            <guid>https://velog.io/@sangjin-hash/iOS-DASH-%EC%97%90-%EB%8C%80%ED%95%98%EC%97%AC</guid>
            <pubDate>Wed, 17 Dec 2025 07:31:11 GMT</pubDate>
            <description><![CDATA[<h3 id="개요">개요</h3>
<p>지난 포스트까진 HLS 에 대해 알아보았고, 이번에는 MPEG-DASH 에 대해 학습하고자 한다. DASH 는 HLS 와 같이 비디오 스트리밍 프로토콜의 양대산맥(?) 으로 대표할 수 있는 프로토콜인데 HLS 학습과 동일하게 DASH 가 무엇인지, 사용하는 파일의 규격은 어떻게 되는지, <code>AVPlayer</code> 를 이용해서 플레이어 만들어보기 등 학습 과정을 기록해보겠다.</p>
<hr>
<h3 id="dash-란">DASH 란?</h3>
<blockquote>
<p><strong>MPEG-DASH</strong>
<em>Dynamic Adaptive Streaming over HTTP (DASH), also known as MPEG-DASH, is an adaptive bitrate streaming technique that enables high quality streaming of media content over the Internet delivered from conventional HTTP web servers.</em></p>
</blockquote>
<p>DASH는 HTTP 기반의 적응형 비트레이트 스트리밍 프로토콜로 HLS와 함께 가장 널리 사용되는 스트리밍 프로토콜 중 하나이다. 아무래도 DASH 는 국제 표준 프로토콜이고 HLS은 Apple 에서 직접 만든 프로토콜이다보니 <code>AVPlayer</code> 와 호환되지 않는다. 그래서 iOS 기기에서 DASH 프로토콜을 따르는 비디오를 재생하기 위해선 다음과 같은 방법을 활용할 수 있다.</p>
<h4 id="1-webkit-을-이용한-방법">1. <code>WebKit</code> 을 이용한 방법</h4>
<p>가장 간단한 접근 방식은 <code>WKWebView</code>를 활용하여 웹 기반 DASH 플레이어를 임베드하는 것이다. <code>dash.js</code> 같은 <code>JavaScript</code> 라이브러리를 사용하면 DASH 스트림을 브라우저에서 재생할 수 있다. 장점으로는 구현이 매우 간단하고 빠른 것이지만 단점으로는 <code>AVPlayer</code>의 <code>Picture-in-Picture</code>, <code>AirPlay</code> 같은 네이티브 기능 통합이 제한적이라는 점이다. 네이티브 수준의 성능과 최적화를 기대하기 어렵다.</p>
<pre><code class="language-swift">import WebKit

class DashPlayerViewController: UIViewController {
    var webView: WKWebView!

    override func loadView() {
        let config = WKWebViewConfiguration()
        config.allowsInlineMediaPlayback = true
        config.mediaTypesRequiringUserActionForPlayback = []

        webView = WKWebView(frame: .zero, configuration: config)
        view = webView
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        loadDashPlayer()
    }

    func loadDashPlayer() {
        let html = &quot;&quot;&quot;
        &lt;!DOCTYPE html&gt;
        &lt;html&gt;
        &lt;head&gt;
            &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1&quot;&gt;
            &lt;script src=&quot;https://cdn.dashjs.org/latest/dash.all.min.js&quot;&gt;&lt;/script&gt;
        &lt;/head&gt;
        &lt;body style=&quot;margin:0;padding:0;&quot;&gt;
            &lt;video id=&quot;videoPlayer&quot; controls style=&quot;width:100%;height:100vh;&quot;&gt;&lt;/video&gt;
            &lt;script&gt;
                var url = &quot;\(dashManifestURL)&quot;;
                var player = dashjs.MediaPlayer().create();
                player.initialize(document.querySelector(&quot;#videoPlayer&quot;), url, true);
            &lt;/script&gt;
        &lt;/body&gt;
        &lt;/html&gt;
        &quot;&quot;&quot;

        webView.loadHTMLString(html, baseURL: nil)
    }
}</code></pre>
<h4 id="2-avassetresourceloader를-이용한-가상-hls-playlist-제공">2. AVAssetResourceLoader를 이용한 가상 HLS Playlist 제공</h4>
<p><code>AVAssetResourceLoader</code>를 활용하여 MPD 파일을 파싱한 후, <code>AVPlayer</code>가 이해할 수 있는 가상의 HLS playlist로 변환하는 방법이다. 실제로는 DASH 세그먼트를 다운로드하지만, <code>AVPlayer</code> 입장에서는 HLS를 재생하는 것처럼 동작한다. 핵심 아이디어는 커스텀 URL scheme(예: dash://)을 사용하여 <code>AVPlayer</code>의 리소스 요청을 가로채고, DASH 세그먼트를 매핑해서 제공하는 것이다. 이렇게 되면 <code>AVPlayer</code>의 모든 네이티브 기능을 그대로 사용 가능 (PiP, AirPlay, 자막 등)하고 최적화된 버퍼링, 디코딩 파이프라인 활용할 수 있다는 점이 있다. 다만 단점으로는 MPD의 복잡한 구조(SegmentTemplate, SegmentTimeline 등)를 모두 파싱해야 한다는 점이 있다.</p>
<h4 id="3-커스텀-플레이어-직접-개발">3. 커스텀 플레이어 직접 개발</h4>
<p><code>AVPlayer</code>를 전혀 사용하지 않고, <code>VideoToolbox</code>로 디코딩하고 <code>Metal/AVSampleBufferDisplayLayer</code>로 렌더링하는 완전한 커스텀 플레이어를 만드는 방법이다. 이렇게 되면 플레이어의 모든 측면을 완벽하게 제어 가능하고 DRM, 워터마크, 커스텀 자막 등 고급 기능 자유롭게 추가 가능하겠지만, 아무래도 iOS 버전별 호환성 유지보수 부담해야하고 <code>AVPlayer</code> 에서 제공하는 기능들을 별도로 구현이 필요하므로 개발 관련 리소스가 크다는 점이 있다.</p>
<p>위의 3가지 방법 중 이번 DASH 학습을 위해 <strong>2. AVAssetResourceLoader를 이용한 가상 HLS Playlist 제공</strong> 방법으로 직접 구현해보면서 해당 프로토콜에 대해서 다뤄보도록 하겠다.</p>
<hr>
<h3 id="mpd-톺아보기">.mpd 톺아보기</h3>
<p>DASH 기반 비디오를 제공하는 URL 은 다음과 같다.</p>
<pre><code>https://dash.akamaized.net/dash264/TestCasesUHD/2b/11/MultiRate.mpd</code></pre><p>해당 URL 에 파일을 실제 다운로드 받아보면 다음과 같이 구성되어 있다. MPD(Media Presentation Description) 파일은 DASH 스트리밍에서 사용되는 파일 확장자로, 클라이언트가 재생에 필요한 모든 정보를 담고 있는 XML 매니페스트 파일이다. 실제 mpd 파일을 하나씩 뜯어보면서 각 태그의 의미를 살펴보자.</p>
<pre><code class="language-xml">&lt;?xml version=&quot;1.0&quot;?&gt;
&lt;!-- MPD file Generated with GPAC version 0.5.2-DEV-rev1067-g9cfa0d1-master  at 2016-10-11T16:34:50.559Z--&gt;
&lt;MPD xmlns=&quot;urn:mpeg:dash:schema:mpd:2011&quot; minBufferTime=&quot;PT1.500S&quot; type=&quot;static&quot; mediaPresentationDuration=&quot;PT0H11M58.998S&quot; maxSegmentDuration=&quot;PT0H0M2.005S&quot; profiles=&quot;urn:mpeg:dash:profile:isoff-live:2011,http://dashif.org/guidelines/dash264&quot;&gt;
 &lt;ProgramInformation moreInformationURL=&quot;http://gpac.sourceforge.net&quot;&gt;
  &lt;Title&gt;../DASHed/2b/11/MultiRate.mpd generated by GPAC&lt;/Title&gt;
 &lt;/ProgramInformation&gt;

 &lt;Period duration=&quot;PT0H11M58.998S&quot;&gt;
  &lt;AdaptationSet segmentAlignment=&quot;true&quot; maxWidth=&quot;3840&quot; maxHeight=&quot;2160&quot; maxFrameRate=&quot;60000/1001&quot; par=&quot;16:9&quot; lang=&quot;und&quot;&gt;
   &lt;Representation id=&quot;1&quot; mimeType=&quot;video/mp4&quot; codecs=&quot;hev1.2.4.L153.90&quot; width=&quot;3840&quot; height=&quot;2160&quot; frameRate=&quot;60000/1001&quot; sar=&quot;1:1&quot; startWithSAP=&quot;1&quot; bandwidth=&quot;5678742&quot;&gt;
    &lt;SegmentTemplate timescale=&quot;60000&quot; media=&quot;video_8000k_$Number$.mp4&quot; startNumber=&quot;1&quot; duration=&quot;119952&quot; initialization=&quot;video_8000k_init.mp4&quot;/&gt;
   &lt;/Representation&gt;
   &lt;Representation id=&quot;2&quot; mimeType=&quot;video/mp4&quot; codecs=&quot;hev1.2.4.L153.90&quot; width=&quot;3840&quot; height=&quot;2160&quot; frameRate=&quot;60000/1001&quot; sar=&quot;1:1&quot; startWithSAP=&quot;1&quot; bandwidth=&quot;8308466&quot;&gt;
    &lt;SegmentTemplate timescale=&quot;60000&quot; media=&quot;video_10400k_$Number$.mp4&quot; startNumber=&quot;1&quot; duration=&quot;119952&quot; initialization=&quot;video_10400k_init.mp4&quot;/&gt;
   &lt;/Representation&gt;
   &lt;Representation id=&quot;3&quot; mimeType=&quot;video/mp4&quot; codecs=&quot;hev1.2.4.L153.90&quot; width=&quot;3840&quot; height=&quot;2160&quot; frameRate=&quot;60000/1001&quot; sar=&quot;1:1&quot; startWithSAP=&quot;1&quot; bandwidth=&quot;10870369&quot;&gt;
    &lt;SegmentTemplate timescale=&quot;60000&quot; media=&quot;video_13520k_$Number$.mp4&quot; startNumber=&quot;1&quot; duration=&quot;119952&quot; initialization=&quot;video_13520k_init.mp4&quot;/&gt;
   &lt;/Representation&gt;
  &lt;/AdaptationSet&gt;
  &lt;AdaptationSet segmentAlignment=&quot;true&quot; lang=&quot;eng&quot;&gt;
   &lt;Representation id=&quot;4&quot; mimeType=&quot;audio/mp4&quot; codecs=&quot;mp4a.40.2&quot; audioSamplingRate=&quot;48000&quot; startWithSAP=&quot;1&quot; bandwidth=&quot;319525&quot;&gt;
    &lt;AudioChannelConfiguration schemeIdUri=&quot;urn:mpeg:dash:23003:3:audio_channel_configuration:2011&quot; value=&quot;2&quot;/&gt;
    &lt;SegmentTemplate timescale=&quot;48000&quot; media=&quot;audio_64k_$Number$.mp4&quot; startNumber=&quot;1&quot; duration=&quot;95999&quot; initialization=&quot;audio_64k_init.mp4&quot;/&gt;
   &lt;/Representation&gt;
  &lt;/AdaptationSet&gt;
 &lt;/Period&gt;
&lt;/MPD&gt;</code></pre>
<h4 id="mpdmedia-presentation-description">MPD(Media Presentation Description)</h4>
<pre><code class="language-xml">&lt;?xml version=&quot;1.0&quot;?&gt;
&lt;MPD xmlns=&quot;urn:mpeg:dash:schema:mpd:2011&quot; 
     minBufferTime=&quot;PT1.500S&quot; 
     type=&quot;static&quot; 
     mediaPresentationDuration=&quot;PT0H11M58.998S&quot; 
     maxSegmentDuration=&quot;PT0H0M2.005S&quot; 
     profiles=&quot;urn:mpeg:dash:profile:isoff-live:2011,http://dashif.org/guidelines/dash264&quot;&gt;</code></pre>
<ul>
<li>전체 매니페스트의 루트 엘리먼트</li>
<li>type<ul>
<li>static: VOD, 전체 콘텐츠가 이미 준비되어 있음</li>
<li>dynamic: 라이브 스트리밍. 새로운 세그먼트가 계속 생성됨</li>
</ul>
</li>
<li>mediaPresentationDuration<ul>
<li>전체 영상의 재생 시간. ISO 8601 기간 형식으로 표현되며, 여기서는 11분 58.998초를 의미한다</li>
</ul>
</li>
<li>maxSegmentDuration=&quot;PT0H0M2.005S&quot;<ul>
<li>세그먼트의 최대 길이. 약 2초를 의미</li>
</ul>
</li>
<li>profiles<ul>
<li>이 MPD가 따르는 DASH 프로파일. 여기서는 ISOFF(ISO Base Media File Format) 라이브 프로파일과 DASH264 가이드라인을 따른다.</li>
</ul>
</li>
</ul>
<h4 id="period">Period</h4>
<pre><code class="language-xml">&lt;Period duration=&quot;PT0H11M58.998S&quot;&gt;</code></pre>
<p>Period는 콘텐츠의 시간적 구간을 나타낸다. 하나의 MPD는 여러 Period를 가질 수 있으며, 각 Period는 서로 다른 콘텐츠를 담을 수 있다.</p>
<h4 id="adaptationset">AdaptationSet</h4>
<pre><code class="language-xml">&lt;AdaptationSet segmentAlignment=&quot;true&quot; 
               maxWidth=&quot;3840&quot; 
               maxHeight=&quot;2160&quot; 
               maxFrameRate=&quot;60000/1001&quot; 
               par=&quot;16:9&quot; 
               lang=&quot;und&quot;&gt;</code></pre>
<p>AdaptationSet은 동일한 콘텐츠의 여러 버전(다른 화질, 다른 언어 등)을 그룹화한다. 일반적으로 비디오와 오디오는 별도의 AdaptationSet으로 분리된다.</p>
<ul>
<li><p>segmentAlignment</p>
<ul>
<li>모든 Representation의 세그먼트가 시간적으로 정렬되어 있다는 의미</li>
<li><code>true</code> 이면 재생 중 화질 전환 시 동일한 세그먼트 번호의 타임스탬프가 일치하므로 끊김없이 전환 가능하다.</li>
</ul>
</li>
<li><p>maxWidth=&quot;3840&quot;, maxHeight=&quot;2160&quot;</p>
<ul>
<li>이 AdaptationSet에 포함된 모든 화질 중 최대 해상도. 4K UHD</li>
</ul>
</li>
<li><p>maxFrameRate</p>
<ul>
<li>최대 프레임레이트</li>
<li><code>60000/1001 ≈ 59.94fps</code> 로 NTSC 방식의 60fps를 나타냄</li>
</ul>
</li>
<li><p>par</p>
<ul>
<li>Pixel Aspect Ratio. 픽셀의 가로세로 비율</li>
</ul>
</li>
</ul>
<h4 id="representation">Representation</h4>
<pre><code class="language-xml">&lt;Representation id=&quot;1&quot; 
                mimeType=&quot;video/mp4&quot; 
                codecs=&quot;hev1.2.4.L153.90&quot; 
                width=&quot;3840&quot; 
                height=&quot;2160&quot; 
                frameRate=&quot;60000/1001&quot; 
                sar=&quot;1:1&quot; 
                startWithSAP=&quot;1&quot; 
                bandwidth=&quot;5678742&quot;&gt;</code></pre>
<p>Representation은 특정 비트레이트, 해상도의 실제 미디어 스트림을 나타낸다. 클라이언트는 현재 네트워크 상황에 맞는 Representation을 선택하여 재생한다. 이 MPD에는 동일한 4K 해상도에 세 가지 비트레이트가 제공된다.</p>
<ul>
<li><p>mimeType</p>
<ul>
<li>MIME 타입. 여기서는 MP4 컨테이너를 사용</li>
</ul>
</li>
<li><p>codecs</p>
<ul>
<li>사용된 비디오 코덱</li>
<li>hev1 = HEVC (H.265) 코덱</li>
<li>2.4.L153.90 = HEVC의 프로파일, 레벨 등 상세 정보</li>
</ul>
</li>
<li><p>startWithSAP</p>
<ul>
<li>Stream Access Point</li>
<li>각 세그먼트가 IDR(Instantaneous Decoder Refresh) 프레임으로 시작</li>
<li>임의의 세그먼트부터 디코딩을 시작해도 문제없다는 의미</li>
</ul>
</li>
<li><p>bandwidth</p>
<ul>
<li>이 Representation의 비트레이트</li>
<li>클라이언트는 이 값을 보고 현재 네트워크에서 재생 가능한지 판단</li>
</ul>
</li>
</ul>
<h4 id="segmenttemplate">SegmentTemplate</h4>
<p>SegmentTemplate은 세그먼트 파일의 URL 패턴과 타이밍 정보를 정의한다. 템플릿 방식을 사용하면 수백 개의 세그먼트를 일일이 나열하지 않아도 된다.</p>
<pre><code class="language-xml">&lt;SegmentTemplate timescale=&quot;60000&quot; 
                 media=&quot;video_8000k_$Number$.mp4&quot; 
                 startNumber=&quot;1&quot; 
                 duration=&quot;119952&quot; 
                 initialization=&quot;video_8000k_init.mp4&quot;/&gt;</code></pre>
<ul>
<li><p>timescale</p>
<ul>
<li>시간 단위의 기준</li>
<li>여기서는 1초에 60000 단위로 표현</li>
</ul>
</li>
<li><p>media</p>
<ul>
<li>세그먼트 파일명 템플릿</li>
<li><code>$Number$</code>는 세그먼트 번호로 치환된다</li>
<li>ex: <code>video_8000k_1.mp4</code>, <code>video_8000k_2.mp4</code>, ...</li>
</ul>
</li>
<li><p>startNumber</p>
<ul>
<li>시작 세그먼트 번호</li>
</ul>
</li>
<li><p>duration</p>
<ul>
<li>각 세그먼트의 재생 시간 (timescale 단위)</li>
</ul>
</li>
<li><p>initialization</p>
<ul>
<li>초기화 세그먼트</li>
<li>재생 전 먼저 다운로드해야 하는 파일</li>
<li>코덱 정보, 메타데이터 등이 포함됨</li>
</ul>
</li>
</ul>
<h4 id="오디오">오디오</h4>
<p>오디오를 위한 별도의 AdaptationSet이다. 비디오와 오디오를 분리하는 이유는 </p>
<ol>
<li>독립적인 ABR (화질은 낮추되 오디오 품질은 유지)</li>
<li>다국어 지원 (영어, 한국어, 일본어 오디오 선택 가능)</li>
<li>접근성 (음성 해설 트랙 추가 가능)</li>
</ol>
<p>해당 MPD 파일에 하단부분에 위치한 것을 확인할 수 있다.</p>
<h4 id="audiochannelconfiguration">AudioChannelConfiguration</h4>
<pre><code class="language-xml">&lt;AudioChannelConfiguration schemeIdUri=&quot;urn:mpeg:dash:23003:3:audio_channel_configuration:2011&quot; 
                            value=&quot;2&quot;/&gt;</code></pre>
<ul>
<li>value: 채널<ul>
<li>1 = 모노</li>
<li>2 = 스테레오</li>
<li>6 = 5.1 서라운드</li>
<li>8: 7.1 서라운드</li>
</ul>
</li>
</ul>
<h4 id="정리">정리</h4>
<p>이 MPD 파일은 다음을 제공한다:</p>
<ul>
<li>비디오: 4K 60fps HEVC, 3가지 비트레이트 (5.68/8.31/10.87Mbps)</li>
<li>오디오: 스테레오 AAC 48kHz, 320kbps</li>
<li>세그먼트: 약 2초 단위, 총 359개</li>
<li>재생 시간: 11분 58초</li>
<li>ABR 지원: 네트워크 상황에 따라 자동 화질 전환</li>
<li>탐색 지원: 각 세그먼트가 독립적으로 디코딩 가능</li>
</ul>
<p>이후 내용은 해당 MPD 파일을 기준으로 <code>AVAssetResourceLoader</code> 를 이용해 가상의 HLS Playlist 로 변환하여 <code>AVPlayer</code> 가 해당 비디오 세그먼트가 있는 URL 로 리다이렉트해서 직접 받아오는 식으로 재생하게끔 구성할 것이다.</p>
<hr>
<h3 id="model-정의">Model 정의</h3>
<p>DASH MPD 파일은 XML 형식에 계층적 구조를 가지고 있다보니 이를 그대로 반영하기 위해 모델 정의도 Nested Struct로 구성하였다. 해당 모델 파일은 <code>Player/Models/DASH/DASHMPD.swift</code> 에서 확인할 수 있다.</p>
<ul>
<li>DASHMPD 최상위 구조체
전체 비디오 프레젠테이션을 나타낸다.</li>
</ul>
<pre><code class="language-swift">struct DASHMPD {
    let baseURL: URL              // MPD 파일의 URL (세그먼트 URL 계산에 사용)
    let type: PresentationType    // static(VOD) or dynamic(Live)
    let mediaPresentationDuration: TimeInterval?  // 전체 영상 길이
    let minBufferTime: TimeInterval  // 최소 버퍼 시간
    let periods: [Period]         // Period 배열

    enum PresentationType: String {
        case static_ = &quot;static&quot;
        case dynamic = &quot;dynamic&quot;
    }

}</code></pre>
<ul>
<li>Period(구간)
영상의 특정 시간 구간 역할을 한다. 보통 광고 삽입 등에 사용된다. 대부분의 경우 VOD는 Period가 1개만 있으나
Live나 광고 있는 경우 여러 Period로 나뉠 수 있다.</li>
</ul>
<pre><code class="language-swift">struct DASHMPD {
    ...
    struct Period {
        let id: String?
        let duration: TimeInterval?
        let adaptationSets: [AdaptationSet] // 비디오, 오디오, 자막 등
    }
}</code></pre>
<ul>
<li>AdaptationSet
같은 타입의 미디어를 그룹화한다. 이는 비디오/오디오/자막을 분리하여 독립적으로 품질 선택 가능하다.</li>
</ul>
<pre><code class="language-swift">struct DASHMPD {
    ...
    struct AdaptationSet {
        let id: String?
        let contentType: String?  // &quot;video&quot;, &quot;audio&quot;, &quot;text&quot;
        let mimeType: String?    // &quot;video/mp4&quot;, &quot;audio/mp4&quot;
        let codecs: String?      // &quot;avc1.640028&quot;, &quot;mp4a.40.2&quot;
        let representations: [Representation]
    }
}</code></pre>
<ul>
<li>Representation
특정 화질/음질의 스트림이다. ABR은 네트워크 상황에 따라 Representation을 전환한다.</li>
</ul>
<pre><code class="language-swift">class DASHMPD {
    ...
    struct Representation {
        let id: String              
        let bandwidth: Int          // 800000 (800kbps), 3000000 (3Mbps)
        let width: Int?             // 1920 (비디오만 해당)
        let height: Int?            // 1080 (비디오만 해당)
        let frameRate: String?      // &quot;30&quot;, &quot;60&quot; 등
        let codecs: String?
        let mimeType: String?
        let segmentTemplate: SegmentTemplate?  // 세그먼트 URL 패턴
        let segmentList: SegmentList?          // 또는 세그먼트 목록
        let baseURL: String?
    }
}</code></pre>
<ul>
<li>SegmentTemplate
세그먼트 URL 생성 규칙이다.</li>
</ul>
<pre><code class="language-swift">class DASHMPD {
    ...
    struct SegmentTemplate {
        let initialization: String?  // &quot;video_8000k_init.mp4&quot;
        let media: String?           // &quot;video_8000k_$Number$.mp4&quot;
        let timescale: Int?          // 90000 (시간 단위)
        let duration: Int?           // 180000 (timescale 단위)
        let startNumber: Int?        // 1 (시작 번호)
    }
}</code></pre>
<ul>
<li>SegmentList
<code>SegmentTemplate</code> 대신 직접 URL 목록을 제공한다. <code>SegmentTemplate</code> 는 규칙적인 패턴으로 URL 생성하지만 <code>SegmentList</code> 는 각 세그먼트 URL을 직접 나열하기 때문에 불규칙적이라는 점에서 다르다.</li>
</ul>
<pre><code class="language-swift">class DASHMPD {
    ...
    struct SegmentList {
       let initialization: Initialization?
       let segments: [SegmentURL]   // 세그먼트 URL 배열
       let timescale: Int?
       let duration: Int?
    }
}</code></pre>
<hr>
<h3 id="dashparser">DASHParser</h3>
<ul>
<li>Utilities/DASHParser.swift
서버에서 받은 XML 문자열(<code>&lt;MPD&gt;...&lt;/MPD&gt;</code>)을 읽어서 DASHMPD 객체로 변환한다.</li>
</ul>
<pre><code class="language-swift">class DASHParser {
    ...
    func parse(_ xmlString: String, baseURL: URL) throws -&gt; DASHMPD {
        self.baseURL = baseURL
        reset()

        guard let data = xmlString.data(using: .utf8) else {
            throw ParsingError.invalidXML
        }

        let parser = XMLParser(data: data)
        parser.delegate = self

        guard parser.parse() else {
            throw ParsingError.invalidXML
        }

        guard !periods.isEmpty else {
            throw ParsingError.unsupportedFormat
        }

        return DASHMPD(
            baseURL: baseURL,
            type: mpdType,
            mediaPresentationDuration: mediaPresentationDuration,
            minBufferTime: minBufferTime,
            periods: periods
        )
    }
}</code></pre>
<hr>
<h3 id="dashresourceloaderdelegate">DASHResourceLoaderDelegate</h3>
<p><code>AVAssetResourceLoaderDelegate</code> 의 역할에 대해서는 이미 <a href="https://velog.io/@sangjin-hash/iOS-AVPlayer-%EC%9D%98-Custom-ResourceLoader">지난 포스트</a>에서 다루었다. </p>
<p><code>AVPlayer</code>는 기본적으로 DASH를 직접 재생할 수 없기 때문에, DASH MPD를 HLS Playlist 형태로 변환한 가상 Playlist를 제공해야 한다. 따라서 해당 Delegate는 custom scheme을 사용해 <code>AVPlayer</code>의 요청을 가로채고, HLS Playlist 요청에는 가상의 <code>.m3u8</code>을 반환하며 세그먼트 요청은 실제 DASH 세그먼트 URL로 리다이렉트한다. 이를 통해 <code>AVPlayer</code> 위에서 DASH 스트리밍을 우회적으로 재생할 수 있다.</p>
<h4 id="property">Property</h4>
<pre><code class="language-swift">class DASHResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate {

    // MARK: - Properties

    private let customScheme = &quot;custom-dash&quot;
    private let mpd: DASHMPD
    private let representation: DASHMPD.Representation
    private let virtualPlaylist: String</code></pre>
<h4 id="avassetresourceloaderdelegate">AVAssetResourceLoaderDelegate</h4>
<pre><code class="language-swift">// MARK: - AVAssetResourceLoaderDelegate

func resourceLoader(
    _ resourceLoader: AVAssetResourceLoader,
    shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest
) -&gt; Bool {
    guard let url = loadingRequest.request.url else {
        loadingRequest.finishLoading(with: NSError(domain: &quot;DASHResourceLoader&quot;, code: -1))
        return false
    }

    handleLoadingRequest(loadingRequest, url: url)

    return true
}</code></pre>
<h4 id="private-methods">Private Methods</h4>
<ul>
<li><code>handleLoadingRequest(_ loadingRequest: AVAssetResourceLoadingRequest, url: URL)</code>
가상 플레이리스트 -&gt; init 세그먼트 -&gt; 나머지 비디오 세그먼트 이 순서로 처리되기 때문에 if-else 분기 순서대로 처리가 된다.</li>
</ul>
<pre><code class="language-swift">private func handleLoadingRequest(_ loadingRequest: AVAssetResourceLoadingRequest, url: URL) {
    do {
        let urlString = url.absoluteString

        if urlString.hasSuffix(&quot;manifest.m3u8&quot;) {
            // 가상 HLS 플레이리스트
            loadManifest(loadingRequest)
        } else if urlString.contains(&quot;init&quot;) {
            // 초기화 세그먼트
            try loadInitializationSegment(loadingRequest, url: url)
        } else {
            // 미디어 세그먼트
            try loadMediaSegment(loadingRequest, url: url)
        }
    } catch {
        loadingRequest.finishLoading(with: error as NSError)
    }
}</code></pre>
<ul>
<li><code>loadManifest(_ loadingRequest: AVAssetResourceLoadingRequest)</code>
<code>AVPlayer</code> 에 가상의 HLS 플레이리스트를 가지고 응답하는 메소드이다.</li>
</ul>
<pre><code class="language-swift">private func loadManifest(_ loadingRequest: AVAssetResourceLoadingRequest) {
    guard let data = virtualPlaylist.data(using: .utf8) else {
        loadingRequest.finishLoading(with: NSError(domain: &quot;DASHResourceLoader&quot;, code: -7))
        return
    }

    if let contentRequest = loadingRequest.contentInformationRequest {
        contentRequest.contentType = &quot;application/x-mpegURL&quot;
        contentRequest.isByteRangeAccessSupported = false
        contentRequest.contentLength = Int64(data.count)
    }

    if let dataRequest = loadingRequest.dataRequest {
        dataRequest.respond(with: data)
    }

    loadingRequest.finishLoading()
}
</code></pre>
<ul>
<li><code>loadInitializationSegment(_ loadingRequest: AVAssetResourceLoadingRequest, url: URL)</code>
초기화 세그먼트(fMP4의 헤더 정보)를 실제 HTTPS URL로 리다이렉트한다. <code>AVPlayer</code> 는 <code>http/https</code> 스킴만 처리가 가능하고 리다이렉트함으로써 <code>AVPlayer</code> 가 <code>URLSession</code> 을 통해서 직접 비디오 세그먼트를 받아오게 처리를 해야 재생이 되기 때문이다.</li>
</ul>
<pre><code class="language-swift">    private func loadInitializationSegment(_ loadingRequest: AVAssetResourceLoadingRequest, url: URL) throws {
        // URL 경로 추출 - manifest.m3u8가 경로에 포함되어 있으면 제거
        var path = url.path
        if path.hasPrefix(&quot;/manifest.m3u8/&quot;) {
            path = String(path.dropFirst(&quot;/manifest.m3u8/&quot;.count))
        } else if path.hasPrefix(&quot;/&quot;) {
            path = String(path.dropFirst())
        } else if path.isEmpty {
            path = url.host ?? &quot;&quot;
        }

        // MPD baseURL과 결합하여 실제 URL 생성
        let initURL = URL(string: path, relativeTo: mpd.baseURL.deletingLastPathComponent())!

        // 302 리다이렉트 응답 반환 (데이터 직접 제공하지 않음)
        loadingRequest.redirect = URLRequest(url: initURL)
        loadingRequest.response = HTTPURLResponse(
            url: initURL,
            statusCode: 302,
            httpVersion: &quot;HTTP/1.1&quot;,
            headerFields: [&quot;Location&quot;: initURL.absoluteString]
        )

        loadingRequest.finishLoading()
    }</code></pre>
<ul>
<li><code>loadMediaSegment(_ loadingRequest: AVAssetResourceLoadingRequest, url: URL)</code>
미디어 세그먼트도 위의 초기화 세그먼트와 동일하게 실제 HTTPS URL로 리다이렉트 해야한다.</li>
</ul>
<pre><code class="language-swift">    private func loadMediaSegment(_ loadingRequest: AVAssetResourceLoadingRequest, url: URL) throws {
        // URL 경로 추출 - manifest.m3u8가 경로에 포함되어 있으면 제거
        var path = url.path
        if path.hasPrefix(&quot;/manifest.m3u8/&quot;) {
            path = String(path.dropFirst(&quot;/manifest.m3u8/&quot;.count))
        } else if path.hasPrefix(&quot;/&quot;) {
            path = String(path.dropFirst())
        } else if path.isEmpty {
            path = url.host ?? &quot;&quot;
        }

        // MPD baseURL과 결합하여 실제 URL 생성
        let mediaURL = URL(string: path, relativeTo: mpd.baseURL.deletingLastPathComponent())!

        // 302 리다이렉트 응답 반환
        loadingRequest.redirect = URLRequest(url: mediaURL)
        loadingRequest.response = HTTPURLResponse(
            url: mediaURL,
            statusCode: 302,
            httpVersion: &quot;HTTP/1.1&quot;,
            headerFields: [&quot;Location&quot;: mediaURL.absoluteString]
        )

        loadingRequest.finishLoading()
    }</code></pre>
<hr>
<h3 id="streamplayermanager">StreamPlayerManager</h3>
<ul>
<li><code>generateVirtualHLSPlaylist()</code>
DASH의 SegmentTemplate 정보를 HLS 플레이리스트 형식의 텍스트로 변환한다. 각 태그별 의미는 <a href="https://velog.io/@sangjin-hash/iOS-HLS-%EC%97%90-%EB%8C%80%ED%95%98%EC%97%AC">이전 게시글</a> 에서 다루었으니 참고하길 바란다.</li>
</ul>
<pre><code class="language-swift">    /// DASH를 HLS 형식의 가상 플레이리스트로 변환
    private func generateVirtualHLSPlaylist(
        mpd: DASHMPD,
        representation: DASHMPD.Representation,
        segmentCount: Int
    ) -&gt; String {
        guard let template = representation.segmentTemplate,
              let duration = template.duration,
              let timescale = template.timescale else {
            return &quot;&quot;
        }

        let segmentDuration = Double(duration) / Double(timescale)
        let startNumber = template.startNumber ?? 1

        var playlist = &quot;#EXTM3U\n&quot;
        playlist += &quot;#EXT-X-VERSION:6\n&quot;
        playlist += &quot;#EXT-X-TARGETDURATION:\(Int(ceil(segmentDuration)))\n&quot;
        playlist += &quot;#EXT-X-MEDIA-SEQUENCE:0\n&quot;
        playlist += &quot;#EXT-X-PLAYLIST-TYPE:VOD\n&quot;
        playlist += &quot;#EXT-X-INDEPENDENT-SEGMENTS\n&quot;

        // 초기화 세그먼트
        if let initPattern = template.initialization {
            playlist += &quot;#EXT-X-MAP:URI=\&quot;\(initPattern)\&quot;\n&quot;
        }

        // 미디어 세그먼트
        if let mediaPattern = template.media {
            for i in 0..&lt;segmentCount {
                let segmentNumber = startNumber + i
                // $Number$를 실제 숫자로 교체
                let segmentURL = mediaPattern.replacingOccurrences(of: &quot;$Number$&quot;, with: &quot;\(segmentNumber)&quot;)
                playlist += &quot;#EXTINF:\(segmentDuration),\n&quot;
                playlist += &quot;\(segmentURL)\n&quot;
            }
        }

        playlist += &quot;#EXT-X-ENDLIST\n&quot;

        return playlist
    }</code></pre>
<ul>
<li><code>loadDASH(mpdURL: URL, bandwidth: Int = 3_000_000)</code>
다음과 같이 크게 4가지로 구성할 수 있다. MPD 파일을 받아와서 대역폭에 따른 <code>Representation</code> 을 로드하고, <code>AVPlayer</code> 에서 호환 가능한 형태의 가상의 HLS Playlist 로 변환한 뒤 <code>Delegate</code> 에 넘길 수 있도록 커스텀 스킴을 적용한다.</li>
</ul>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/180f15d9-27ab-4c99-b92a-fc7ce7551b7f/image.png" width="300">
  <p style="font-size: 14px; color: gray;">▲ 그림 1. loadDash 플로우</p>
</div>



<pre><code class="language-swift">/// DASH 스트림 로드 (AVAssetResourceLoader 방식)
func loadDASH(mpdURL: URL, bandwidth: Int = 3_000_000) async throws {
    cleanup()
    currentState = .loading

    // 1. MPD 파일 다운로드
    let (data, _) = try await URLSession.shared.data(from: mpdURL)

    guard let xmlString = String(data: data, encoding: .utf8) else {
        throw NSError(domain: &quot;StreamPlayerManager&quot;, code: -1, userInfo: [
            NSLocalizedDescriptionKey: &quot;Failed to decode MPD file&quot;
        ])
    }

    // 2. MPD 파싱
    let parser = DASHParser()
    let mpd = try parser.parse(xmlString, baseURL: mpdURL)

    // 3. Representation 선택
    guard let representation = mpd.selectRepresentation(bandwidth: bandwidth) else {
        throw NSError(domain: &quot;StreamPlayerManager&quot;, code: -2, userInfo: [
            NSLocalizedDescriptionKey: &quot;Failed to select representation&quot;
        ])
    }

    // 4. 세그먼트 개수 계산
    guard let segmentCount = mpd.totalSegmentCount(for: representation),
          segmentCount &gt; 0 else {
        throw NSError(domain: &quot;StreamPlayerManager&quot;, code: -3, userInfo: [
            NSLocalizedDescriptionKey: &quot;Failed to calculate segment count&quot;
        ])
    }

    // 5. HLS 형식의 가상 플레이리스트 생성
    let virtualPlaylist = generateVirtualHLSPlaylist(
        mpd: mpd,
        representation: representation,
        segmentCount: segmentCount
    )

    // 6. ResourceLoaderDelegate 생성 및 설정
    let delegate = DASHResourceLoaderDelegate(
        mpd: mpd,
        representation: representation,
        virtualPlaylist: virtualPlaylist
    )
    self.dashResourceLoaderDelegate = delegate

    // 7. Custom scheme URL 생성
    let customURL = URL(string: &quot;custom-dash://manifest.m3u8&quot;)!

    // 8. AVURLAsset 생성 및 ResourceLoader 설정
    let asset = AVURLAsset(url: customURL)
    asset.resourceLoader.setDelegate(
        delegate,
        queue: DispatchQueue(label: &quot;com.dashplayer.resourceloader&quot;)
    )

    // 9. AVPlayerItem 및 AVPlayer 생성
    playerItem = AVPlayerItem(asset: asset)
    player = AVPlayer(playerItem: playerItem)

    if let player = player {
        player.automaticallyWaitsToMinimizeStalling = true
    }

    setupObservers()
}</code></pre>
<hr>
<h3 id="총정리">총정리</h3>
<p>DASH 재생 전체 흐름은 다음과 같다. 이미 <code>StreamPlayerManager</code> 에서 해당 DASH URL은 커스텀 스킴이 적용된 URL 형태로 변환되었기 때문에, (1) <code>AVPlayer</code> 에서 해당 URL 을 재생 요청을 보내면 <code>DASHResourceLoaderDelegate</code> 로 넘어가게 된다. 이 때 (2) Delegate 의 <code>handleLoadingRequest()</code> -&gt; <code>loadManifest()</code> 를 호출하면서 가상 HLS 플레이리스트 텍스트를 <code>data</code> 로 응답하게 된다. (3) <code>AVPlayer</code> 가 플레이리스트로 파싱하면서 플레이리스트 안에 상대주소로 명시된 초기화 세그먼트 URL 을 다시 요청하게 된다. 해당 URL은 마찬가지로 <code>custom scheme</code>이 적용되어 있어 마찬가지로 (4) Delegate 의 <code>resourceLoader()</code> 를 호출하게 되는데, 해당 경로를 다시 <code>https://</code> 스킴의 실제 초기화 세그먼트가 있는 URL 로 변환하게 되고, (5) <code>AVPlayer</code> 에서 직접 302 리다이렉트를 통해 직접 다운로드해서 받아오게 된다. 나머지 비디오 세그먼트들도 동일하다. (6) <code>AVPlayer</code> 가 커스텀 스킴의 비디오 세그먼트 URL 들을 처리를 못해 Delegate에 넘기게 되고 (7) Delegate 에서는 마찬가지로 실제 해당 비디오 세그먼트들이 저장된 URL로 변환하고 <code>AVPlayer</code> 가 리다이렉트함으로써 순차적으로 받아와 재생하게 된다.</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/0803c102-d3c4-47af-b52c-6b462dccd47f/image.png" width="500">
  <p style="font-size: 14px; color: gray;">▲ 그림 2. AVPlayer와 Delegate 플로우</p>
</div>

<hr>
<h3 id="결과">결과</h3>
<p>해당 프로젝트의 코드는 <a href="https://github.com/sangjin-hash/VideoStreamingPlayer.git">다음 링크</a> 에서 모두 확인할 수 있다.</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/6562254b-e583-4710-b52d-2b18d7292ae8/image.png" width="300">
  <p style="font-size: 14px; color: gray;">▲ 그림 3. DASH 플레이어 실행 결과</p>
</div>

<hr>
<h3 id="reference">Reference</h3>
<ul>
<li><a href="https://en.wikipedia.org/wiki/Dynamic_Adaptive_Streaming_over_HTTP">https://en.wikipedia.org/wiki/Dynamic_Adaptive_Streaming_over_HTTP</a></li>
<li><a href="https://www.cloudflare.com/ko-kr/learning/video/what-is-mpeg-dash/">https://www.cloudflare.com/ko-kr/learning/video/what-is-mpeg-dash/</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[iOS] AVPlayer 의 Custom ResourceLoader]]></title>
            <link>https://velog.io/@sangjin-hash/iOS-AVPlayer-%EC%9D%98-Custom-ResourceLoader</link>
            <guid>https://velog.io/@sangjin-hash/iOS-AVPlayer-%EC%9D%98-Custom-ResourceLoader</guid>
            <pubDate>Sun, 14 Dec 2025 16:59:27 GMT</pubDate>
            <description><![CDATA[<h3 id="개요">개요</h3>
<p><code>AVPlayer</code> 에서 자동으로 처리하는 ABR 기능을 직접 구현해보고 싶었다. 그러면 사용자의 네트워크 환경을 계속 관찰하고 있다가 기존에 사용하던 환경에서 변경이 되면, 그 당시 네트워크의 대역폭을 고려한 스트림을 다시 가져와서 Media Playlist 를 가져오고, 해당 파일 내에 그 다음으로 받아야 하는 비디오 세그먼트를 가져와야 된다고 생각했다.</p>
<p>그래서 지금까지의 현황은 모든 비디오 세그먼트들을 다운로드하고 하나의 파일로 만들어서 AVPlayer가 재생하는 것까지 구현되어 있으니, 그 다음으로는 비디오 세그먼트들을 하나씩 AVPlayer 에 넣고 실행시켜주는 방식으로 구현하는 것을 목표로 설정했다. (첫문단에서 언급한 ABR 을 사용하기 위해 비디오 세그먼트 하나씩 AVPlayer에 넣는 것이 전제 조건이라 생각했음)</p>
<p>하나의 <code>main.mp4</code> 를 바이트 범위에 따라 받아오는 로직까지는 모두 구현을 완료했으나 결국 <code>AVPlayer</code> 에서 실행이 되지 않고 <code>CoreMediaErrorDomain</code> 이 발생하여 재생이 되지 않는 문제에 직면하였다. 그래서 이번 포스트에서는 문제를 직면하기까지 일련의 과정들을 작성하고 그에 따른 시행착오와 회고를 남기려고 한다.</p>
<hr>
<h3 id="recap">Recap</h3>
<p>지난 게시글에서 언급한대로 <a href="https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/master.m3u8">Apple 에서 제공한 스트리밍 URL</a> 의 미디어 플레이리스트는 다음과 같다.</p>
<pre><code>#EXTM3U
#EXT-X-TARGETDURATION:6
#EXT-X-VERSION:7
#EXT-X-MEDIA-SEQUENCE:1
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-MAP:URI=&quot;main.mp4&quot;,BYTERANGE=&quot;719@0&quot;
#EXTINF:6.00000,
#EXT-X-BYTERANGE:1508000@719
main.mp4
#EXTINF:6.00000,
#EXT-X-BYTERANGE:1510244@1508719
main.mp4
...
(생략)
#EXTINF:6.00000,
#EXT-X-BYTERANGE:1504803@148977509
main.mp4
#EXT-X-ENDLIST</code></pre><p>해당 비디오의 메타데이터를 포함한 <code>init_segment</code> 와 6초씩 실행되는 100개의 <code>segment</code> 들이 존재한다. 즉, 1 개의 <code>.mp4</code> 파일을 바이트 범위에 따라 100 개의 세그먼트들을 받아와야 한다. 이러한 특징은 뒤에서 다시 서술할 예정이라 우선 비디오의 인풋이 어떤 형태인지 간단하게 언급만 하고 넘어가겠다.</p>
<hr>
<h3 id="avassetresourceloader">AVAssetResourceLoader?</h3>
<p>이전 포스트에서 다루었던 <code>AVPlayer</code> 에서 <code>AVAssetResourceLoader</code>를 사용하는 기본적인 흐름은 다음 사진과 같다. 이는 <code>AVURLAsset</code> 내부의 &quot;리소스 로딩 시스템&quot; 으로, URL 요청을 처리하는 내부 시스템이다. <code>AVPlayer</code> 에서 스트리밍이 담겨져 있는 URL 에 데이터를 받아올 때, <code>AVAssetResourceLoader</code> 가 <code>URLSession</code> 을 통하여 데이터를 받아오게 된다.</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/e4a6a955-730e-4c7f-a076-c45b6f301b35/image.png" width="600">
  <p style="font-size: 14px; color: gray;">▲ 그림 1. 일반적인 AVPlayer 재생</p>
</div>

<p>출처: <a href="https://deview.kr/data/deview/session/attach/4_AVPlayer%E2%80%99s%20Custom%20ResourceLoader,%20%EC%96%B4%EB%96%BB%EA%B2%8C%20%EC%82%AC%EC%9A%A9%ED%95%A0%EA%B9%8C%EC%9A%94%20(AVAssetResourceLoaderDelegate%20%EB%85%B8%ED%95%98%EC%9A%B0%20%EA%B3%B5%EC%9C%A0).pdf">네이버 DEVIEW 2021</a></p>
<p>이 때 다음과 같이 <code>AVAssetResourceLoaderDelegate</code> 를 추가하게 되면 URL이 <code>http</code> 나 <code>https</code> 로 시작하는 경우 <code>URLSession</code> 으로 처리가 되는데, 커스텀 스킴일 경우(ex. <code>custom-hls://</code>, <code>myapp://</code>) 해당 Delegate 가 요청을 가로채서 처리하게 된다. </p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/58db0bf8-b3e6-44a2-ba00-0725d7b2b9f3/image.png" width="600">
  <p style="font-size: 14px; color: gray;">▲ 그림 2. 커스텀 로더를 이용한 AVPlayer 재생 </p>
</div>

<p>출처: <a href="https://deview.kr/data/deview/session/attach/4_AVPlayer%E2%80%99s%20Custom%20ResourceLoader,%20%EC%96%B4%EB%96%BB%EA%B2%8C%20%EC%82%AC%EC%9A%A9%ED%95%A0%EA%B9%8C%EC%9A%94%20(AVAssetResourceLoaderDelegate%20%EB%85%B8%ED%95%98%EC%9A%B0%20%EA%B3%B5%EC%9C%A0).pdf">네이버 DEVIEW 2021</a></p>
<p>그럼 여기서 왜 커스텀 스킴을 사용하는지 의문이 드는데, 사례를 들자면 다음과 같다.</p>
<pre><code>사례 1: 커스텀 암호화

// ex. &quot;secure://encrypted-video-456&quot;
// 서버에서 암호화된 데이터 다운로드
→ 복호화 키 가져오기 → 복호화 → AVPlayer에 평문 데이터 전달

사례 2: P2P 스트리밍

// ex. &quot;p2p://torrent-hash-789&quot;
여러 피어에서 청크 다운로드 → 조합 → AVPlayer에 전달

사례 3: 오프라인 재생

// ex. &quot;offline://downloaded-video-012&quot;
→ 로컬 파일 시스템에서 읽기 → AVPlayer에 전달

사례 4: 분석 &amp; 통계

모든 요청을 가로채서 → 다운로드 속도 측정 → 얼마나 봤는지 추적 → 서버에 통계 전송
→ 데이터 그대로 AVPlayer에 전달</code></pre><p>즉 <code>Delegate</code> 는 <code>AVPlayer</code> 와 실제 데이터 소스 사이의 중개자 역할로, <code>http/https</code> 스킴을 사용하지 않은 커스텀 스킴일 경우 Delegate 를 호출하기 위해 <code>AVAssetResourceLoader</code> 를 사용해야 한다. 본 포스트에서는 위에서 언급한 미디어 플레이리스트의 비디오 세그먼트를 읽어올 때 </p>
<pre><code>(기존)
https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/v5/main.mp4

(변경)
custom-hls://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/v5/main.mp4</code></pre><p>기존 URL 의 스킴을 <code>https://</code> -&gt; <code>custom-hls://</code> 로 변경하여 해당 URL은 Delegate 에서 처리하게 한 다음, 해당 Delegate 에서는 100 개의 비디오 세그먼트를 <code>AVPlayer</code> 가 자동으로 받아오는 것이 아닌, 수동으로 받아오게 하려고 한다.</p>
<p>이러한 이유는 기존 프로젝트에서는 <code>AVPlayer</code> 에 마스터 플레이리스트 URL 만 넣어주면 자동으로 모두 처리되었던 거를 직접 수동으로 구현해보면서 <code>AVPlayer</code> 의 역할에 대해 학습하기 위함이다.</p>
<hr>
<h2 id="시행착오">시행착오</h2>
<h3 id="1-hlsdownloadmanager">1. HLSDownloadManager</h3>
<p>기존의 <code>HLSDownloadManager</code> 에서 두 함수가 추가되었다. 미디어 플레이리스트 파일 안에 있는 <code>init segment</code> 를 다운로드 하는 로직과 나머지 <code>segment</code> 들을 받아오는 로직 두 개를 추가했다.</p>
<pre><code class="language-swift">/// 초기화 세그먼트 다운로드 (fMP4 헤더)
func downloadInitializationSegment() async throws -&gt; Data? {
    guard let mediaPlaylist = currentMediaPlaylist,
          let initSegment = mediaPlaylist.initializationSegment else {
        return nil
    }

    guard let segmentURL = mediaPlaylist.absoluteURL(for: initSegment.uri) else {
        throw DownloadError.invalidURL
    }

    if let byteRange = initSegment.byteRange {
        return try await downloadSegment(url: segmentURL, byteRange: byteRange)
    } else {
        return try await downloadSegment(url: segmentURL)
    }
}

/// 특정 세그먼트 다운로드
func downloadSegment(at index: Int) async throws -&gt; Data {
    guard let mediaPlaylist = currentMediaPlaylist else {
        throw DownloadError.noAvailableStream
    }

    guard let segment = mediaPlaylist.segment(at: index) else {
        throw DownloadError.invalidURL
    }

    guard let segmentURL = mediaPlaylist.absoluteURL(for: segment.uri) else {
        throw DownloadError.invalidURL
    }

    if let byteRange = segment.byteRange {
        return try await downloadSegment(url: segmentURL, byteRange: byteRange)
    } else {
        return try await downloadSegment(url: segmentURL)
    }
}</code></pre>
<h3 id="2-hlsresourceloaderdelegate">2. HLSResourceLoaderDelegate</h3>
<h4 id="property">Property</h4>
<pre><code class="language-swift">class HLSResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate {

    // MARK: - Properties

    private let downloadManager: HLSDownloadManager
    private let customScheme = &quot;custom-hls&quot;

    // 세그먼트 캐시 (이미 다운로드한 세그먼트 저장)
    private var segmentCache: [Int: Data] = [:]
    private var initSegmentData: Data?

    // 현재 Media Playlist
    private var mediaPlaylist: HLSMediaPlaylist?
    private var mediaPlaylistContent: String?</code></pre>
<h4 id="private-method">Private Method</h4>
<ul>
<li><code>handleLoadingRequest(_ loadingRequest: AVAssetResourceLoadingRequest, url: URL)</code></li>
</ul>
<p>AVPlayer 의 모든 리소스 요청을 받는 진입점이다. AVPlayer 가 <code>custom-hls://</code> URL 을 요청하면 <code>http/https</code> 스키마가 아니므로 Delegate 에서 처리하도록 넘어오면서 해당 함수가 가장 먼저 호출된다. ContentInfo 와 Data 요청을 각각의 핸들러로 분기 처리하였다.</p>
<pre><code class="language-swift">private func handleLoadingRequest(_ loadingRequest: AVAssetResourceLoadingRequest, url: URL) async {
    do {
        // 1. Content Information Request 처리
        if let contentRequest = loadingRequest.contentInformationRequest {
            try await handleContentInfoRequest(contentRequest, url: url)
        }

        // 2. Data Request 처리
        if let dataRequest = loadingRequest.dataRequest {
            try await handleDataRequest(dataRequest, url: url)
        }

        loadingRequest.finishLoading()

    } catch {
        print(&quot;Request failed: \(error.localizedDescription)&quot;)
        loadingRequest.finishLoading(with: error)
    }
}</code></pre>
<ul>
<li>`handleContentInfoRequest(<pre><code>  _ contentRequest: AVAssetResourceLoadingContentInformationRequest,
  url: URL</code></pre>  )<code>파일의 메타데이터 정보를 제공한다.</code>Content-Type<code>과</code>Content-Length<code>(파일 전체 크기),</code>Byte-Range` 지원 여부를 설정한다.</li>
</ul>
<pre><code class="language-swift">private func handleContentInfoRequest(
    _ contentRequest: AVAssetResourceLoadingContentInformationRequest,
    url: URL
) async throws {
    guard let mediaPlaylist = mediaPlaylist else {
        throw NSError(domain: &quot;HLSResourceLoader&quot;, code: -1)
    }

    let fileName = url.lastPathComponent

    // 플레이리스트 파일인 경우
    if fileName.hasSuffix(&quot;.m3u8&quot;) {
        contentRequest.contentType = &quot;application/x-mpegURL&quot;
        contentRequest.isByteRangeAccessSupported = false
        if let playlistContent = mediaPlaylistContent {
            contentRequest.contentLength = Int64(playlistContent.utf8.count)
        }
    } else {
        // fMP4 세그먼트인 경우 - 전체 파일 크기 계산
        contentRequest.contentType = &quot;video/mp4&quot;
        contentRequest.isByteRangeAccessSupported = true

        // 전체 파일 크기 = 초기화 세그먼트 + 모든 미디어 세그먼트
        var totalLength: Int64 = 0

        if let initSegment = mediaPlaylist.initializationSegment,
           let byteRange = initSegment.byteRange {
            totalLength += Int64(byteRange.offset + byteRange.length)
        }

        for segment in mediaPlaylist.segments {
            if let byteRange = segment.byteRange {
                let segmentEnd = byteRange.offset + byteRange.length
                totalLength = max(totalLength, Int64(segmentEnd))
            }
        }

        contentRequest.contentLength = totalLength
    }
}</code></pre>
<ul>
<li>`handleDataRequest(<pre><code>  _ dataRequest: AVAssetResourceLoadingDataRequest,
  url: URL</code></pre>  )<code>실제 데이터를 제공하는 부분이다.</code>AVPlayer<code>가 요청한 바이트 범위(offset, length) 의 데이터를 반환하고,</code>custom-hls://<code>커스텀 스킴을</code>https://` 로 복원 후 다운로드하여 데이터를 제공한다.</li>
</ul>
<pre><code class="language-swift">private func handleDataRequest(
    _ dataRequest: AVAssetResourceLoadingDataRequest,
    url: URL
) async throws {
    // Custom scheme을 원래 scheme으로 복원
    let originalURL = restoreOriginalURL(url)

    // 요청된 바이트 범위로 세그먼트 데이터 가져오기
    let requestedOffset = Int(dataRequest.requestedOffset)
    let requestedLength = dataRequest.requestedLength
    let currentOffset = Int(dataRequest.currentOffset)

    // 바이트 범위로 어떤 세그먼트를 요청하는지 파악하고 세그먼트의 ByteRange offset 가져오기
    let (data, segmentByteRangeOffset) = try await fetchSegmentDataByRange(for: originalURL, offset: requestedOffset, length: requestedLength)

    // 세그먼트 데이터에서의 시작 위치 계산
    // data[0]은 파일의 segmentByteRangeOffset 위치에 해당
    // currentOffset부터 제공해야 하므로: currentOffset - segmentByteRangeOffset
    let dataStartOffset = currentOffset - segmentByteRangeOffset

    guard dataStartOffset &gt;= 0 &amp;&amp; dataStartOffset &lt; data.count else {
        throw NSError(domain: &quot;HLSResourceLoader&quot;, code: -4, userInfo: [
            NSLocalizedDescriptionKey: &quot;Data offset out of bounds&quot;
        ])
    }

    // 제공해야 할 데이터 길이 계산
    // requestedOffset부터 requestedLength만큼 요청했지만, currentOffset부터 제공
    let requestedEndOffset = requestedOffset + requestedLength
    let remainingLength = requestedEndOffset - currentOffset
    let endOffset = min(dataStartOffset + remainingLength, data.count)

    let subdata = data.subdata(in: dataStartOffset..&lt;endOffset)
    dataRequest.respond(with: subdata)
}</code></pre>
<ul>
<li><code>fetchSegmentDataByRange(for url: URL, offset: Int, length: Int)</code></li>
</ul>
<pre><code class="language-swift">/// 바이트 범위로 세그먼트 데이터 가져오기
/// - Returns: (세그먼트 데이터, 세그먼트의 ByteRange offset)
private func fetchSegmentDataByRange(for url: URL, offset: Int, length: Int) async throws -&gt; (Data, Int) {
    guard let mediaPlaylist = mediaPlaylist else {
        throw NSError(domain: &quot;HLSResourceLoader&quot;, code: -1, userInfo: [
            NSLocalizedDescriptionKey: &quot;Media playlist not set&quot;
        ])
    }

    let fileName = url.lastPathComponent

    // 플레이리스트 파일 자체 요청 (.m3u8)
    if fileName.hasSuffix(&quot;.m3u8&quot;) {
        guard let playlistContent = mediaPlaylistContent else {
            throw NSError(domain: &quot;HLSResourceLoader&quot;, code: -1, userInfo: [
                NSLocalizedDescriptionKey: &quot;Media playlist content not set&quot;
            ])
        }
        return (playlistContent.data(using: .utf8) ?? Data(), 0)
    }

    // 초기화 세그먼트 확인 (offset 0부터 시작)
    if let initSegment = mediaPlaylist.initializationSegment,
       fileName == initSegment.uri,
       let byteRange = initSegment.byteRange,
       offset &gt;= byteRange.offset &amp;&amp; offset &lt; byteRange.offset + byteRange.length {

        if let cachedData = initSegmentData {
            return (cachedData, byteRange.offset)
        }

        let data = try await downloadManager.downloadInitializationSegment()
        guard let data = data else {
            throw NSError(domain: &quot;HLSResourceLoader&quot;, code: -2)
        }
        initSegmentData = data
        return (data, byteRange.offset)
    }

    // 미디어 세그먼트 찾기 (바이트 범위로 매칭)
    for segment in mediaPlaylist.segments {
        if segment.uri == fileName,
           let byteRange = segment.byteRange,
           offset &gt;= byteRange.offset &amp;&amp; offset &lt; byteRange.offset + byteRange.length {

            // 캐시 확인
            if let cachedData = segmentCache[segment.index] {
                return (cachedData, byteRange.offset)
            }

            // 다운로드
            let data = try await downloadManager.downloadSegment(at: segment.index)
            segmentCache[segment.index] = data
            return (data, byteRange.offset)
        }
    }

    throw NSError(domain: &quot;HLSResourceLoader&quot;, code: -3, userInfo: [
        NSLocalizedDescriptionKey: &quot;Segment not found for offset: \(offset)&quot;
    ])
}</code></pre>
<p>바이트 범위로 어떤 세그먼트인지 파악하고 다운로드하는 로직이다. Media Playlist의 Byte-Range 정보를 보고 해당 세그먼트를 찾는다. <code>HLSDownloadManager</code> 를 통해 실제 HTTP Range 요청으로 다운로드 한다.</p>
<ul>
<li><code>restoreOriginalURL(_ url: URL)</code>
<code>custom-hls://</code> -&gt; <code>https://</code> 로 복원</li>
</ul>
<pre><code class="language-swift">private func restoreOriginalURL(_ url: URL) -&gt; URL {
    var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
    components?.scheme = &quot;https&quot;
    return components?.url ?? url
}</code></pre>
<ul>
<li><code>setMediaPlaylist(_ playlist: HLSMediaPlaylist, content: String)</code></li>
</ul>
<p>Media Playlist 안에 있는 모든 비디오 세그먼트들의 <code>main.mp4</code> 파일 상대주소를 커스텀 스킴인 <code>custom-hls://</code> 가 적용된 파일의 절대 주소로 모두 변경한다. 즉 <code>custom-hls://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/v5/main.mp4</code> 이런식으로 Media Playlist 파일 내 모든 상대 주소를 이렇게 변경한다.</p>
<pre><code class="language-swift">/// Media Playlist 안 Video Segment URL 상대 주소를
/// 커스텀 스킴으로 적용된 절대 주소로 변경
func setMediaPlaylist(_ playlist: HLSMediaPlaylist, content: String) {
    self.mediaPlaylist = playlist

    // main.mp4를 custom-hls URL로 변경하여 우리가 직접 처리하도록 함
    var modifiedContent = content

    // baseURL에서 디렉토리 경로 추출
    let baseURL = playlist.baseURL.absoluteString.components(separatedBy: &quot;/&quot;).dropLast().joined(separator: &quot;/&quot;)

    if !baseURL.isEmpty {
        // https://...v5/main.mp4 -&gt; custom-hls://...v5/main.mp4
        let httpsURL = &quot;\(baseURL)/main.mp4&quot;
        let customURL = httpsURL.replacingOccurrences(of: &quot;https://&quot;, with: &quot;custom-hls://&quot;)

        // 1. #EXT-X-MAP의 URI=&quot;main.mp4&quot; -&gt; URI=&quot;custom-hls://...&quot;
        modifiedContent = modifiedContent.replacingOccurrences(
            of: &quot;URI=\&quot;main.mp4\&quot;&quot;,
            with: &quot;URI=\&quot;\(customURL)\&quot;&quot;
        )

        // 2. 세그먼트 URI main.mp4 -&gt; custom-hls://...
        let lines = modifiedContent.components(separatedBy: .newlines)
        var modifiedLines: [String] = []

        for (index, line) in lines.enumerated() {
            if line == &quot;main.mp4&quot; {
                // 이전 줄이 #EXTINF: 또는 #EXT-X-BYTERANGE인 경우
                if index &gt; 0 &amp;&amp; (lines[index - 1].hasPrefix(&quot;#EXTINF:&quot;) || lines[index - 1].hasPrefix(&quot;#EXT-X-BYTERANGE:&quot;)) {
                    modifiedLines.append(customURL)
                } else {
                    modifiedLines.append(line)
                }
            } else {
                modifiedLines.append(line)
            }
        }

        modifiedContent = modifiedLines.joined(separator: &quot;\n&quot;)
    }

    self.mediaPlaylistContent = modifiedContent
}</code></pre>
<ul>
<li><code>resourceLoader()</code>
AVPlayer의 리소스 요청 감지한다. <code>AVPlayer</code> 가 <code>custom-hls://</code>을 요청할 때 자동 호출되고 모든 리소스 로딩의 진입점이다. <code>true</code> 를 반환 시 해당 Delegate 가 담당하여 처리하게 된다.</li>
</ul>
<pre><code class="language-swift">func resourceLoader(
    _ resourceLoader: AVAssetResourceLoader,
    shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest
) -&gt; Bool {
    guard let url = loadingRequest.request.url else {
        loadingRequest.finishLoading(with: NSError(domain: &quot;HLSResourceLoader&quot;, code: -1))
        return false
    }

    // 비동기 처리
    Task {
        await handleLoadingRequest(loadingRequest, url: url)
    }

    return true
}</code></pre>
<h3 id="3-streamplayermanager">3. StreamPlayerManager</h3>
<p>HLS 스트림을 수동으로 로드하고 <code>AVPlayer</code> 에 <code>Custom ResourceLoader</code> 를 설정하였다. 우선 처음에 Master Playlist 를 <code>https://</code> 로 직접 다운로드 및 파싱을 한 뒤, 사용할 스트림으로는 매직 넘버를 넣어 <code>3Mbps</code> 로 설정했고, Media Playlist 역시 위에서 스트림 대역폭을 선언한 것을 기반으로 로드하였다. <code>setMediaPlaylist()</code> 를 호출하면서 playlist content 의 세그먼트 URI 를 모두 커스텀 스킴으로 변경한 뒤 미디어 플레이리스트의 URL 마찬가지로 커스텀 스킴으로 변경한다. 이후 <code>AVURLAsset</code> 및 <code>ResourceLoader</code> 를 연결하여 플레이어를 설정하면 된다.</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/6ea314ca-34ac-4ddf-9cb5-b615a8935af0/image.png" width="400">
  <p style="font-size: 14px; color: gray;">▲ 그림 3. loadCustomHLS() Flow </p>
</div>

<pre><code class="language-swift">/// Custom HLS 스트림 로드 (AVAssetResourceLoader 방식)
func loadCustomHLS(masterURL: URL, bandwidth: Int = 3_000_000) async throws {
    cleanup()
    currentState = .loading

    // 1. HLSDownloadManager 생성
    let manager = HLSDownloadManager()
    self.downloadManager = manager

    // 2. Master Playlist 로드
    let masterPlaylist = try await manager.loadMasterPlaylist(from: masterURL)

    // 3. 스트림 선택
    let stream = try await manager.selectStream(bandwidth: bandwidth)

    // 4. Media Playlist 로드
    let (mediaPlaylist, playlistContent) = try await manager.loadMediaPlaylist(for: stream)

    // 5. ResourceLoaderDelegate 생성 및 설정
    let delegate = HLSResourceLoaderDelegate(downloadManager: manager)
    delegate.setMediaPlaylist(mediaPlaylist, content: playlistContent)
    self.resourceLoaderDelegate = delegate

    // 6. Media Playlist URL을 custom scheme으로 변환
    guard let mediaPlaylistURL = masterPlaylist.absoluteURL(for: stream.uri) else {
        throw NSError(domain: &quot;StreamPlayerManager&quot;, code: -1, userInfo: [
            NSLocalizedDescriptionKey: &quot;Failed to get media playlist URL&quot;
        ])
    }

    var urlComponents = URLComponents(url: mediaPlaylistURL, resolvingAgainstBaseURL: false)
    urlComponents?.scheme = &quot;custom-hls&quot;

    guard let customURL = urlComponents?.url else {
        throw NSError(domain: &quot;StreamPlayerManager&quot;, code: -2, userInfo: [
            NSLocalizedDescriptionKey: &quot;Failed to convert media playlist URL to custom scheme&quot;
        ])
    }

    // 7. AVURLAsset 생성 및 ResourceLoader 설정
    let asset = AVURLAsset(url: customURL)
    asset.resourceLoader.setDelegate(
        delegate,
        queue: DispatchQueue(label: &quot;com.hlsplayer.resourceloader&quot;)
    )

    // 8. AVPlayerItem 및 AVPlayer 생성
    playerItem = AVPlayerItem(asset: asset)
    player = AVPlayer(playerItem: playerItem)

    // ABR 최적화 설정
    if let player = player {
        player.automaticallyWaitsToMinimizeStalling = true
    }

    setupObservers()
}</code></pre>
<h3 id="4-총정리">4. 총정리</h3>
<p>위의 프로세스들을 정리하면 다음과 같다. Media Playlist URL -&gt; Init Segment -&gt; Video Segment 순으로 커스텀 스킴을 사용하는 URL일 경우 모두 Delegate 를 거치게 되고, <code>AVPlayer</code> 는 <code>http/https</code> 만 처리가 가능하므로 커스텀 스킴을 다시 <code>https://</code> 로 복원하여 <code>AVPlayerItem</code> 을 설정하였다.</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/0a31162a-e9c0-4061-94b1-bc5770d2a14e/image.png" width="600">
  <p style="font-size: 14px; color: gray;">▲ 그림 4. Delegate 호출 프로세스 </p>
</div>

<hr>
<h3 id="에러-발생">에러 발생</h3>
<p>비디오 플레이러를 실행한 결과 <code>stateObserver</code> 에서 <code>.failed</code> 로 처리되어 <code>CoreMediaErrorDomain</code> 의 에러가 발생하였다.</p>
<pre><code>ErrorComment: custom url not redirect
ErrorDomain: CoreMediaErrorDomain
ErrorCode: -12881
URI: custom-hls://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/v5/main.mp4</code></pre><p>에러가 발생한 원인은 다음과 같다.</p>
<pre><code>1. Media Playlist 파싱
             ↓
2. &quot;custom-hls://.../main.mp4&quot; 발견
             ↓
3. AVPlayer 내부 검증:
   - 파일 확장자: .mp4 → 미디어 파일임
   - URL scheme: custom-hls://
             ↓
4. AVPlayer의 정책:
   - mp4 같은 미디어 파일의 scheme은 http/https
   - 또는 redirect로 http/https로 전환되어야 함
   - Range 요청이 HTTP 표준을 따라야 함
             ↓
5. AVPlayer에서 custom scheme으로 된 mp4를 처리 불가
             ↓
6. Error: &quot;custom url not redirect&quot;</code></pre><p>즉 <code>custom-hls://</code> 로 접근한 리소스를 AVPlayer가 “MP4 파일”로 인식했고 Delegate로 넘어가면서 <code>https://</code> 형태로 변경해서 데이터를 정상 제공했지만, 이미 AVPlayer에서 Delegate로 넘기기 전에 URL을 보고 MP4임을 인지했으므로 이는 HTTP 기반 redirect 가능한 URL이어야 하는데 custom scheme이라서 즉시 실패한 것이다.</p>
<hr>
<h3 id="약간의-꼼수-그리고-역시나-실패">약간의 꼼수 그리고 역시나 실패</h3>
<p><code>setMediaPlaylist()</code> 호출 후 수정된 미디어 플레이리스트를 통해 <code>AVPlayer</code>가 각 세그먼트를 독립적인 <code>HLS media segment(.m4s)</code>로 인식하도록 유도하고, 실제 데이터는 <code>AVAssetResourceLoaderDelegate</code>에서 byte-range 기반으로 직접 공급하는 구조를 기대했다. 즉, 플레이리스트 수준에서는 다중 세그먼트 HLS처럼 보이게 만들고, 내부 구현에서는 하나의 main.mp4 파일을 분할해 제공하는 방식으로 우회하려 했다.</p>
<pre><code class="language-swift">func setMediaPlaylist(_ playlist: HLSMediaPlaylist, content: String) {
    var lines = content.components(separatedBy: .newlines)
    var output: [String] = []
    var segmentIndex = 0

    for line in lines {
        if line.contains(&quot;#EXT-X-MAP&quot;) {
            output.append(line)
            continue
        } else if line == &quot;main.mp4&quot; {
            output.append(&quot;custom-hls://seg-\(segmentIndex).m4s&quot;)
            segmentIndex += 1
        } else {
            output.append(line)
        }
    }

    self.mediaPlaylistContent = output.joined(separator: &quot;\n&quot;)
}

func handleContentInfoRequest(...) {
    // 수정 필요
    ...
}


func handleDataRequest(...) {
    // 수정 필요
    ...
}</code></pre>
<p>그러나 이 접근은 근본적으로 동작하지 않았다. 해당 스트림은 하나의 <code>main.mp4</code> 파일을 HTTP Range 요청으로 분할해 사용하는 <code>single-file fMP4 HLS</code>이다. 이 유형의 HLS 스트림은 미디어 플레이리스트를 파싱하는 초기 단계에서 이미 AVPlayer 내부적으로 <code>single-file fMP4 HLS</code>로 분류되며, 이 판단은 <code>AVAssetResourceLoaderDelegate</code>가 개입하기 이전에 완료된다. 따라서 미디어 플레이리스트의 URI를 수정하거나 확장자를 <code>.m4s</code>로 변경하더라도, AVPlayer는 이를 논리적인 HLS 세그먼트로 재해석하지 않는다. 결국 커스텀 스킴을 통한 리소스 로딩이나 세그먼트 단위 제어는 구조적으로 허용되지 않았고, 재생 과정은 <code>CoreMediaErrorDomain -3</code> 오류로 종료되었다.</p>
<hr>
<h3 id="그럼-다른-방법은">그럼 다른 방법은?</h3>
<ol>
<li><code>AVPlayer</code> 가 리다이렉트를 통해 원본 서버에서 직접 비디오 세그먼트를 받아오도록 처리<ul>
<li>기존 비디오 세그먼트를 바이트 범위로 받아오는 로직 필요 없음 -&gt; <code>AVPlayer</code> 가 알아서 처리하기 때문</li>
</ul>
</li>
<li>커스텀 플레이어 제작<ul>
<li>기존 비디오 세그먼트를 바이트 범위로 받아오는 로직 그대로 사용 가능</li>
<li>단 <code>AVPlayer</code> 와 호환이 되지 않기 때문에, 비디오 프레임을 화면에 렌더링 하기 위한 레이어부터 비디오와 오디오 동기화, 개별 비디오/오디오 데이터 파싱 등 여러 컴포넌트들을 직접 구현해야 한다.</li>
</ul>
</li>
</ol>
<p>다음 두 가지 방법 중에서 1번 방법에 대한 코드는 다음과 같다. 크게 변경된 부분은 <code>handleLoadingRequest()</code> 이 부분에 리다이렉트 하는 로직을 넣은 것인데,</p>
<ul>
<li><p><code>handleLoadingRequest(_ loadingRequest: AVAssetResourceLoadingRequest, url: URL)</code></p>
<pre><code class="language-swift">/// 로딩 요청 처리 - 302 리다이렉트로 AVPlayer가 직접 처리하도록 함
  private func handleLoadingRequest(_ loadingRequest: AVAssetResourceLoadingRequest, url: URL) {
      let fileName = url.lastPathComponent

      // 플레이리스트 파일인 경우 직접 제공
      if fileName.hasSuffix(&quot;.m3u8&quot;) {
          if let playlistContent = mediaPlaylistContent,
             let data = playlistContent.data(using: .utf8) {

              if let contentRequest = loadingRequest.contentInformationRequest {
                  contentRequest.contentType = &quot;application/x-mpegURL&quot;
                  contentRequest.isByteRangeAccessSupported = false
                  contentRequest.contentLength = Int64(data.count)
              }

              if let dataRequest = loadingRequest.dataRequest {
                  dataRequest.respond(with: data)
              }

              loadingRequest.finishLoading()
          } else {
              loadingRequest.finishLoading(with: NSError(domain: &quot;HLSResourceLoader&quot;, code: -1))
          }
      } else {
          // 비디오 세그먼트인 경우 원본 URL로 302 리다이렉트
          let originalURL = restoreOriginalURL(url)

          loadingRequest.redirect = URLRequest(url: originalURL)
          loadingRequest.response = HTTPURLResponse(
              url: originalURL,
              statusCode: 302,
              httpVersion: &quot;HTTP/1.1&quot;,
              headerFields: [&quot;Location&quot;: originalURL.absoluteString]
          )

          loadingRequest.finishLoading()
      }
  }</code></pre>
</li>
</ul>
<p>이를 실행시켜보면 재생이 잘 되는 것을 확인할 수 있다. 다만 이 경우 기존의 바이트 범위로 직접 세그먼트를 받아와서 AVPlayer 에 응답을 했었던 모든 로직들은 사용할 수 없기 때문에, 이렇게 되면 결국 어차피 AVPlayer 가 미디어 플레이리스트를 알아서 처리할텐데 굳이 <code>Custom scheme</code> 을 적용할 필요가 있나? 싶다.(<del>다시 <a href="https://velog.io/@sangjin-hash/iOS-AVFoundation-AVKit-%EC%9D%B4%ED%95%B4-%EA%B7%B8%EB%A6%AC%EA%B3%A0-AVPlayer-%EB%8B%A4%EB%A4%84%EB%B3%B4%EA%B8%B0">[iOS] AVFoundation, AVKit 이해 그리고 AVPlayer 다뤄보기</a> 태초마을로 돌아가는게 아닌가..</del>)</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/27761c43-8cb6-43bd-92c5-a67b948c4cfc/image.png" width="300">
  <p style="font-size: 14px; color: gray;">▲ 그림 4. 리다이렉트 방법 적용</p>
</div>

<p>지금까지 <code>Custom scheme</code>을 적용하고 <code>CoreMediaErrorDomain</code> 오류가 발생했던 코드는 <a href="https://github.com/sangjin-hash/VideoStreamingPlayer/tree/fdfc97ee69167427f55934e6174fd7891a0475ad">다음 링크</a>에서 확인할 수 있다.</p>
<p>그래도 이번 포스트를 통해 확실하게 배운 것은 </p>
<ol>
<li>AVPlayer 는 <code>http / https</code> 스킴만 처리 가능</li>
<li><code>Custom scheme</code> 을 이용한 <code>AVAssetResourceLoaderDelegate</code> 처리</li>
</ol>
<p>이 두가지를 알게 되었다!</p>
<hr>
<h3 id="reference">Reference</h3>
<p>[Apple Docs]</p>
<ul>
<li><a href="https://developer.apple.com/documentation/avfoundation/avassetresourceloaderdelegate">https://developer.apple.com/documentation/avfoundation/avassetresourceloaderdelegate</a></li>
<li><a href="https://developer.apple.com/documentation/avfoundation/avassetresourceloader">https://developer.apple.com/documentation/avfoundation/avassetresourceloader</a></li>
</ul>
<p>[Naver Deview]</p>
<ul>
<li><a href="https://tv.naver.com/v/23652319">https://tv.naver.com/v/23652319</a></li>
<li><a href="https://deview.kr/data/deview/session/attach/4_AVPlayer%E2%80%99s%20Custom%20ResourceLoader,%20%EC%96%B4%EB%96%BB%EA%B2%8C%20%EC%82%AC%EC%9A%A9%ED%95%A0%EA%B9%8C%EC%9A%94%20(AVAssetResourceLoaderDelegate%20%EB%85%B8%ED%95%98%EC%9A%B0%20%EA%B3%B5%EC%9C%A0).pdf">https://deview.kr/data/deview/session/attach/4_AVPlayer%E2%80%99s%20Custom%20ResourceLoader,%20%EC%96%B4%EB%96%BB%EA%B2%8C%20%EC%82%AC%EC%9A%A9%ED%95%A0%EA%B9%8C%EC%9A%94%20(AVAssetResourceLoaderDelegate%20%EB%85%B8%ED%95%98%EC%9A%B0%20%EA%B3%B5%EC%9C%A0).pdf</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[iOS] HLS 에 대하여]]></title>
            <link>https://velog.io/@sangjin-hash/iOS-HLS-%EC%97%90-%EB%8C%80%ED%95%98%EC%97%AC</link>
            <guid>https://velog.io/@sangjin-hash/iOS-HLS-%EC%97%90-%EB%8C%80%ED%95%98%EC%97%AC</guid>
            <pubDate>Fri, 12 Dec 2025 09:12:15 GMT</pubDate>
            <description><![CDATA[<h3 id="개요">개요</h3>
<p>VOD(Video On Demand) 의 핵심은 다양한 네트워크 환경에서도 끊김 없이 고품질의 영상을 제공하는 기술이다. 특히 사용자의 네트워크 상태는 시간에 따라 계속 변화하기 때문에, 고정된 품질의 스트리밍은 사용자 경험을 저하시킬 수 있다. 이를 해결하기 위한 대표적인 방법으로 Adaptive Bitrate Streaming(ABR)이며, HTTP Live Streaming(HLS)은 이러한 기술을 실제 서비스에 적용하기 위한 핵심적인 프로토콜이다.</p>
<hr>
<h3 id="hls-란">HLS 란?</h3>
<blockquote>
<p><strong>HLS</strong>
<em>HLS is designed for reliability and dynamically adapts to network conditions by optimizing playback for the available speed of wired and wireless connections.</em></p>
</blockquote>
<p>HLS는 Apple이 개발한 적응형 비트레이트 스트리밍 프로토콜이다. 사용자가 실시간으로 스트리밍을 보고 있을 때 계속해서 동일한 네트워크 속도로 유지가 되면 좋겠지만, 실제 사용자의 네트워크 속도는 자주 변경된다. 이에 따라 다양한 환경에서 끊임없는 재생을 지원하기 위해 고안된 것이 ABR 인데, 이는 사용자의 bandwidth, CPU capacity 를 실시간으로 감지하고 이에 따라 미디어 스트림 품질을 조정함으로써 동작하게 된다.</p>
<p>HLS는 세그먼트 기반 전송으로, 전체 비디오를 작은 세그먼트(일반적으로 6~10초 길이)로 나누어 전송한다. 각 세그먼트는 <code>.ts (MPEG-2 Transport Stream)</code> 또는 <code>.fmp4 (Fragmented MP4)</code> 형식으로 인코딩되며, 독립적으로 디코딩 가능한 완전한 미디어 파일이다.</p>
<hr>
<h3 id="m3u8-톺아보기">.m3u8 톺아보기</h3>
<p><code>.m3u8</code> 은 M3U(Playlist) + UTF-8 인코딩으로, 비디오 스트리밍용 플레이리스트로 만든 파일 확장자이다. 이는 두 가지 타입이 있다.</p>
<pre><code>- Master Playlist (Multivariant Playlist)
    - HLS 스트리밍의 진입점(Entry Point)
    - 사용 가능한 모든 옵션의 인덱스/카탈로그
    - 실제 미디어 데이터 없음, 단지 선택지만 제공
    - 파일명 예시: master.m3u8, playlist.m3u8

- Media Playlist
    - 실제 비디오/오디오 세그먼트 파일들의 목록
    - 재생에 필요한 구체적인 정보 포함
    - Master Playlist에서 선택된 특정 옵션의 상세 정보
    - 파일명 예시: v5/prog_index.m3u8, a1/prog_index.m3u8</code></pre><p>글로만 보면 이해가 잘 되지 않아서 다음 파일을 직접 까보도록 하자. 아래 파일은 Master Playlist로, <code>fMP4</code> 방식의 파일이다.</p>
<ul>
<li><a href="https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/master.m3u8">img_bipbop_adv_example_fmp4</a><pre><code>#EXTM3U
#EXT-X-VERSION:6
#EXT-X-INDEPENDENT-SEGMENTS

</code></pre></li>
</ul>
<p>#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=2168183,BANDWIDTH=2177116,CODECS=&quot;avc1.640020,mp4a.40.2&quot;,RESOLUTION=960x540,FRAME-RATE=60.000,CLOSED-CAPTIONS=&quot;cc1&quot;,AUDIO=&quot;aud1&quot;,SUBTITLES=&quot;sub1&quot;
v5/prog_index.m3u8
#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=7968416,BANDWIDTH=8001098,CODECS=&quot;avc1.64002a,mp4a.40.2&quot;,RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS=&quot;cc1&quot;,AUDIO=&quot;aud1&quot;,SUBTITLES=&quot;sub1&quot;
v9/prog_index.m3u8
#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=6170000,BANDWIDTH=6312875,CODECS=&quot;avc1.64002a,mp4a.40.2&quot;,RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS=&quot;cc1&quot;,AUDIO=&quot;aud1&quot;,SUBTITLES=&quot;sub1&quot;
v8/prog_index.m3u8
#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=4670769,BANDWIDTH=4943747,CODECS=&quot;avc1.64002a,mp4a.40.2&quot;,RESOLUTION=1920x1080,FRAME-RATE=60.000,CLOSED-CAPTIONS=&quot;cc1&quot;,AUDIO=&quot;aud1&quot;,SUBTITLES=&quot;sub1&quot;
v7/prog_index.m3u8
#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=3168702,BANDWIDTH=3216424,CODECS=&quot;avc1.640020,mp4a.40.2&quot;,RESOLUTION=1280x720,FRAME-RATE=60.000,CLOSED-CAPTIONS=&quot;cc1&quot;,AUDIO=&quot;aud1&quot;,SUBTITLES=&quot;sub1&quot;
v6/prog_index.m3u8
...
(생략)</p>
<pre><code>
해당 파일안에는 `v5/prog_index.m3u8`, `v9/prog_index.m3u8` 이런식으로 상대 주소가 적혀 있는데, 바로 이것들이 Media Playlist 이다.
</code></pre><p>Master Playlist URL
-&gt; <a href="https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/master.m3u8">https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/master.m3u8</a></p>
<p>Media Playlist URL
-&gt; <a href="https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/v5/prog_index.m3u8">https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/v5/prog_index.m3u8</a>
-&gt; <a href="https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/v9/prog_index.m3u8">https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/v9/prog_index.m3u8</a></p>
<pre><code>
이미 Master Playlist 파일은 직접 다운로드해서 확인을 했으니, 해당 파일 안에 Media Playlist 파일을 까보기 위해 터미널에서 요청 날린 결과는 다음과 같았다.
```bash
(Terminal)
curl https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/v5/prog_index.m3u8

#EXTM3U
#EXT-X-TARGETDURATION:6
#EXT-X-VERSION:7
#EXT-X-MEDIA-SEQUENCE:1
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-MAP:URI=&quot;main.mp4&quot;,BYTERANGE=&quot;719@0&quot;
#EXTINF:6.00000,
#EXT-X-BYTERANGE:1508000@719
main.mp4
#EXTINF:6.00000,
#EXT-X-BYTERANGE:1510244@1508719
main.mp4
...
#EXTINF:6.00000,
#EXT-X-BYTERANGE:1504803@148977509
main.mp4
#EXT-X-ENDLIST</code></pre><p>총 100 개의 <code>main.mp4</code> 를 확인할 수 있는데, 이는 해당 Media Playlist 는 100개의 비디오 세그먼트로 이루어져 있는 것을 확인할 수 있다. 다시 말해 하나의 main.mp4 파일을 100 개의 세그먼트들로 쪼개서 A 부터 B까지 바이트 범위를 설정해서 받아오는 것이다. 위와 같은 방식이 바로 &quot;BYTERANGE 방식&quot;이고 해당 방식에서 사용하는 태그에 대한 설명은 다음과 같다.</p>
<ul>
<li><p><code>#EXTM3U</code></p>
<ul>
<li>모든 M3U8 파일의 필수 시작 태그, 해당 파일은 M3U 플레이리스트임을 선언</li>
</ul>
</li>
<li><p><code>#EXT-X-TARGETDURATION:6</code></p>
<ul>
<li>모든 세그먼트의 최대 길이 (초 단위) -&gt; 여기서는 6초라는 뜻</li>
<li>플레이어가 버퍼 크기를 결정하는 데 사용</li>
</ul>
</li>
<li><p><code>#EXT-X-VERSION</code></p>
<ul>
<li>v3: 기본 HLS</li>
<li>v4: #EXT-X-BYTERANGE, I-Frame 플레이리스트</li>
<li>v6: #EXT-X-MAP (fMP4 초기화 세그먼트)</li>
<li>v7: fMP4 완전 지원, 부동소수점 EXTINF 값</li>
</ul>
</li>
<li><p><code>#EXT-X-MEDIA-SEQUENCE</code></p>
<ul>
<li>첫 번째 세그먼트의 시퀀스 번호를 뜻함</li>
<li>VOD에서는 보통 0 또는 1 이지만 라이브 스트리밍에서는 계속 증가하기 때문에 &quot;어떤 세그먼트부터 시작하는지&quot; 를 해당 시퀀스 번호를 통해 알 수 있다</li>
</ul>
</li>
<li><p><code>#EXT-X-PLAYLIST-TYPE</code></p>
<ul>
<li>VOD(Video On Demand), EVENT(라이브 이벤트), LIVE or 없음(일반 라이브) 3가지로 구성</li>
<li>VOD 의 경우 플레이리스트가 변경되지 않고 모든 세그먼트가 처음부터 존재하지만, EVENT의 경우 라이브가 진행될수록 세그먼트가 계속 뒤에 추가된다. 즉 라이브가 끝나면 사실상 VOD 가 된다.(더 이상 추가되지 않기 때문에) LIVE는 세그먼트가 “윈도우” 기반으로 관리되는데 예를 들면 마지막 3분만 유지하고 이전 건 제거하는 식으로 플레이리스트가 계속 밀려난다.</li>
</ul>
</li>
<li><p><code>#EXT-X-INDEPENDENT-SEGMENTS</code></p>
<ul>
<li>모든 세그먼트가 독립적으로 디코딩 가능함을 명시</li>
<li>각 세그먼트는 다른 세그먼트에 의존하지 않음을 뜻함 -&gt; 언제든지 다른 화질로 변환이 가능</li>
</ul>
</li>
<li><p><code>#EXT-X-MAP:URI=&quot;main.mp4&quot;,BYTERANGE=&quot;719@0&quot;</code></p>
<ul>
<li>fMP4의 초기화 세그먼트 정의 (MPEG-TS에는 없음)</li>
<li>비디오 메타데이터 포함 (실제 미디어 데이터는 없음)</li>
</ul>
</li>
<li><p>초기화 세그먼트의 내용</p>
<ul>
<li>파일 타입 (ftyp box)</li>
<li>무비 헤더 (moov box)</li>
<li>비디오 코덱: H.264</li>
<li>오디오 코덱: AAC</li>
<li>해상도: 960x540</li>
<li>프레임레이트: 60fps</li>
<li>샘플레이트: 48kHz</li>
<li>기타 메타데이터</li>
</ul>
</li>
</ul>
<ul>
<li><p><code>BYTERANGE=&quot;719@0&quot;</code></p>
<ul>
<li>형식: <code>길이@시작위치</code> </li>
<li>719: 초기화 세그먼트 크기 (719 바이트)</li>
<li>0: 파일의 시작 위치 (0 바이트부터)</li>
</ul>
</li>
<li><p><code>#EXTINF:6.00000</code></p>
<ul>
<li>다음 세그먼트의 재생 시간 (초 단위)</li>
</ul>
</li>
<li><p><code>#EXT-X-BYTERANGE:1508000@719</code></p>
<ul>
<li>다음 세그먼트의 바이트 범위 지정</li>
</ul>
</li>
</ul>
<p>여기까지가 하나의 파일을 여러 범위로 나누어 받아오는 &quot;BYTERANGE&quot; 방식이고, 여러 파일을 받아오는 방식도 있다.
<a href="https://developer.apple.com/documentation/http-live-streaming/example-playlists-for-http-live-streaming">Apple 공식문서의 예제</a>에 따르면</p>
<ul>
<li>개별 파일 방식</li>
</ul>
<pre><code>#EXTM3U
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-TARGETDURATION:10
#EXT-X-VERSION:4
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:10.0,
fileSequenceA.ts
#EXTINF:10.0,
fileSequenceB.ts
#EXTINF:10.0,
fileSequenceC.ts
#EXTINF:9.0,
fileSequenceD.ts
#EXT-X-ENDLIST</code></pre><p>이런식으로 각 세그먼트가 개별 파일들로 구성되어 있는 것을 확인할 수 있다. 보통 <code>.ts</code> 확장자는 MPEG 표준을 따르고, 각 TS 파일에 메타데이터를 포함하고 있기 때문에 초기화 세그먼트가 불필요하다. </p>
<p><code>.ts</code> 확장자 이외에도 <code>.m4s</code> 또는 <code>.mp4</code> 확장자를 이용하기도 한다. 이는 TS 파일과 다르게 메타데이터를 포함하고 있지 않아서 초기화 세그먼트(<code>init.mp4</code>)가 별도로 필요한 것 이외에 동일하게 동작한다.</p>
<hr>
<h3 id="master-playlist">Master Playlist</h3>
<p>지금까지 Media Playlist 를 알아보았으니 다시 돌아와서 Master Playlist를 구성하는 태그를 알아보자.</p>
<ul>
<li><code>#EXT-X-STREAM-INF</code><pre><code>#EXT-X-STREAM-INF:AVERAGE-BANDWIDTH=2168183,BANDWIDTH=2177116,CODECS=&quot;avc1.640020,mp4a.40.2&quot;,
RESOLUTION=960x540,FRAME-RATE=60.000,CLOSED-CAPTIONS=&quot;cc1&quot;,AUDIO=&quot;aud1&quot;,SUBTITLES=&quot;sub1&quot;</code></pre></li>
</ul>
<ol>
<li>BANDWIDTH</li>
</ol>
<ul>
<li>최대 비트레이트</li>
<li>클라이언트가 스트림 선택 시 사용되는 기준</li>
</ul>
<ol start="2">
<li>AVERAGE-BANDWIDTH</li>
</ol>
<ul>
<li>평균 비트레이트</li>
</ul>
<ol start="3">
<li>CODECS</li>
</ol>
<ul>
<li>비디오와 오디오 코덱 명시</li>
<li>보통 쉼표로 구분(ex. avc1.640020,mp4a.40.2)</li>
</ul>
<ol start="4">
<li>RESOLUTION</li>
</ol>
<ul>
<li>비디오 해상도</li>
<li>클라이언트가 화면 크기에 맞는 스트림 선택</li>
</ul>
<ol start="5">
<li>FRAME-RATE</li>
</ol>
<ul>
<li>초당 프레임 수</li>
<li>부드러운 움직임 vs 데이터 절약 선택에 사용</li>
</ul>
<ol start="6">
<li>CLOSED-CAPTIONS</li>
</ol>
<ul>
<li>자막 그룹 ID 참조</li>
<li>비디오 스트림 내부에 인코딩된 자막</li>
<li>나중에 #EXT-X-MEDIA:TYPE=CLOSED-CAPTIONS 태그에서 정의됨</li>
<li>비디오 스트림 내부에 인코딩</li>
</ul>
<ol start="7">
<li>AUDIO</li>
</ol>
<ul>
<li>오디오 그룹 ID 참조</li>
<li>이 비디오 스트림과 함께 재생할 오디오 트랙 지정</li>
<li>나중에 #EXT-X-MEDIA:TYPE=AUDIO 태그에서 정의됨</li>
</ul>
<ol start="8">
<li>SUBTITLES</li>
</ol>
<ul>
<li>자막 그룹 ID 참조</li>
<li>외부 자막 파일</li>
<li>나중에 #EXT-X-MEDIA:TYPE=SUBTITLES 태그에서 정의됨</li>
<li>자막이 담긴 별도의 파일임(CLOSED-CAPTIONS 와 차이점)<pre><code></code></pre></li>
</ul>
<p>해당 파일을 보면 같은 Media Playlist 가 여러개 있는 것을 확인할 수 있다. 다음 사진에서는 예를 들어 <code>v5/prog_index.m3u8</code> 이라는 상대 주소가 3개 있는 것을 볼 수 있는데, 이는 뒤에 <code>CODECS</code> 태그에서 오디오 코덱과 <code>AUDIO</code> 태그가 다르다. 이러한 이유로는 사용자 환경에 따라 최적 오디오 선택하기 위함이다. 예를 들면 스테레오 헤드폰에서는 <code>aud1</code> (AAC), 홈시어터 시스템에서는 <code>aud2</code> 또는 <code>aud3</code> (서라운드) 이런식으로 플레이어가 자동으로 디바이스에 따라 오디오를 선택함으로써 최적의 영상을 보고 들을 수 있는 것이다. 결국 해당 파일에서는 총 8개의 비디오 화질과 각 비디오 화질마다 3개의 오디오 옵션이 있어 총 24개의 스트림 조합을 확인할 수 있다.</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/6bf4d377-2e1d-4072-9b04-af52b84b9243/image.png" width="800">
  <p style="font-size: 14px; color: gray;">▲ 그림 1. 동일 비디오, 다른 오디오 조합</p>
</div>

<ul>
<li><code>#EXT-X-I-FRAME-STREAM-INF</code><pre><code>#EXT-X-I-FRAME-STREAM-INF:AVERAGE-BANDWIDTH=183689,BANDWIDTH=187492,CODECS=&quot;avc1.64002a&quot;,RESOLUTION=1920x1080,URI=&quot;v7/iframe_index.m3u8&quot;
#EXT-X-I-FRAME-STREAM-INF:AVERAGE-BANDWIDTH=132672,BANDWIDTH=136398,CODECS=&quot;avc1.640020&quot;,RESOLUTION=1280x720,URI=&quot;v6/iframe_index.m3u8&quot;
#EXT-X-I-FRAME-STREAM-INF:AVERAGE-BANDWIDTH=97767,BANDWIDTH=101378,CODECS=&quot;avc1.640020&quot;,RESOLUTION=960x540,URI=&quot;v5/iframe_index.m3u8&quot;
#EXT-X-I-FRAME-STREAM-INF:AVERAGE-BANDWIDTH=75722,BANDWIDTH=77818,CODECS=&quot;avc1.64001e&quot;,RESOLUTION=768x432,URI=&quot;v4/iframe_index.m3u8&quot;
#EXT-X-I-FRAME-STREAM-INF:AVERAGE-BANDWIDTH=63522,BANDWIDTH=65091,CODECS=&quot;avc1.64001e&quot;,RESOLUTION=640x360,URI=&quot;v3/iframe_index.m3u8&quot;
#EXT-X-I-FRAME-STREAM-INF:AVERAGE-BANDWIDTH=39678,BANDWIDTH=40282,CODECS=&quot;avc1.640015&quot;,RESOLUTION=480x270,URI=&quot;v2/iframe_index.m3u8&quot;</code></pre></li>
</ul>
<p>해당 태그는 키프레임(I-Frame)만 포함하는 특수 플레이리스트이다. 여기서는 <code>CODECS</code> 태그에 비디오 코덱만 포함하고, 일반 스트림과는 다르게 속성에 <code>URI</code> 라는 해당 I-Frame 을 받아올 수 있는 상대주소가 적혀있다.</p>
<ul>
<li><code>#EXT-X-MEDIA:TYPE=AUDIO</code><pre><code>#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=&quot;aud1&quot;,LANGUAGE=&quot;en&quot;,NAME=&quot;English&quot;,AUTOSELECT=YES,DEFAULT=YES,CHANNELS=&quot;2&quot;,URI=&quot;a1/prog_index.m3u8&quot;
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=&quot;aud2&quot;,LANGUAGE=&quot;en&quot;,NAME=&quot;English&quot;,AUTOSELECT=YES,DEFAULT=YES,CHANNELS=&quot;6&quot;,URI=&quot;a2/prog_index.m3u8&quot;
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=&quot;aud3&quot;,LANGUAGE=&quot;en&quot;,NAME=&quot;English&quot;,AUTOSELECT=YES,DEFAULT=YES,CHANNELS=&quot;6&quot;,URI=&quot;a3/prog_index.m3u8&quot;

</code></pre></li>
</ul>
<ul>
<li><p>GROUP-ID=&quot;aud1&quot;
이 오디오 트랙의 그룹 식별자로, <code>#EXT-X-STREAM-INF</code>의 <code>AUDIO=&quot;aud1&quot;</code>과 연결됨</p>
</li>
<li><p>LANGUAGE=&quot;en&quot;
ISO 639 언어 코드, 다국어 지원 시: <code>ko</code>, <code>ja</code>, <code>zh</code> 등</p>
</li>
<li><p>AUTOSELECT=YES
플레이어가 자동으로 선택 가능, 사용자 언어 설정에 맞춰 자동으로 선택된다</p>
</li>
<li><p>CHANNELS=&quot;2&quot;
오디오 채널 수를 뜻한다. &quot;2&quot;는 스테레오, &quot;6&quot;는 5.1 서라운드</p>
<pre><code></code></pre></li>
<li><p><code>#EXT-X-MEDIA:TYPE=CLOSED-CAPTIONS</code></p>
<pre><code>#EXT-X-MEDIA:TYPE=CLOSED-CAPTIONS,GROUP-ID=&quot;cc1&quot;,LANGUAGE=&quot;en&quot;,NAME=&quot;English&quot;,AUTOSELECT=YES,DEFAULT=YES,INSTREAM-ID=&quot;CC1&quot;</code></pre></li>
</ul>
<p>이는 비디오 내부에 인코딩된 자막을 뜻한다. </p>
<p>따라서 마스터 플레이리스트의 전체 구조는 다음과 같다.</p>
<pre><code>Master Playlist
│
├─ 비디오 스트림 (24개)
│   ├─ v2 (270p) × 3개 오디오
│   ├─ v3 (360p) × 3개 오디오
│   ├─ v4 (432p) × 3개 오디오
│   ├─ v5 (540p) × 3개 오디오
│   ├─ v6 (720p) × 3개 오디오
│   ├─ v7 (1080p) × 3개 오디오
│   ├─ v8 (1080p mid) × 3개 오디오
│   └─ v9 (1080p high) × 3개 오디오
│
├─ I-Frame 스트림 (6개)
│   └─ 빠른 탐색용
│
├─ 오디오 트랙 (3개)
│   ├─ aud1: AAC 2채널
│   ├─ aud2: AC-3 5.1채널
│   └─ aud3: E-AC-3 5.1채널
│
└─ 자막 (2개)
    ├─ cc1: Closed Captions (인스트림)
    └─ sub1: Subtitles (외부 파일)</code></pre><hr>
<h3 id="model-정의">Model 정의</h3>
<p>그럼 이제 위에서 계속 사용했던 마스터 플레이리스트 파일을 기준으로 &quot;BYTERANGE&quot; 기법을 활용해 AVPlayer 에서 해당 비디오를 재생하는 것을 만들어보려고 한다. 모델은 총 4개로 정의했다.</p>
<pre><code>Player/Models/HLS/Master
- HLSMasterPlaylist.swift
- HLSMediaInfo
- HLSStreamInfo

Player/Models/HLS/Media
- HLSMediaPlaylist.swift</code></pre><p>위에서 언급한대로 HLS 은 Master Playlist 와 Media Playlist 두 가지 타입을 사용하기 때문에 다음과 같이 선언했고, <code>HLSMediaInfo</code> 와 <code>HLSStreamInfo</code> 는 <code>HLSMasterPlaylist</code>의 프로퍼티이다.</p>
<hr>
<h3 id="hlsparser">HLSParser</h3>
<p>해당 파서는 문자열로 들어오는 응답을 Master Playlist 혹은 Media Playlist로 파싱하기 위한 유틸 객체이다. 
public 함수로는 두 플레이리스톨 파싱하는 함수를 기준으로 private 함수로는 다음과 같이 구성하였다.</p>
<pre><code>Utilities/HLSParser.swift
1. #EXT-X-VERSION 파싱
2. #EXT-X-STREAM-INF 파싱
3. #EXT-X-MEDIA 파싱
4. #EXT-X-MAP 파싱 (초기화 세그먼트)
5. 속성 파싱 (KEY=VALUE 또는 KEY=&quot;VALUE&quot; 형식)</code></pre><hr>
<h3 id="hlsdownloadmanager">HLSDownloadManager</h3>
<ul>
<li><code>loadMasterPlaylist</code>
Master Playlist(<code>.m3u8</code>)는 HLS 스트리밍의 진입점이다. 클라이언트는 먼저 이 Master Playlist를 다운로드해서 사용 가능한 스트림 옵션들을 파악한다.</li>
</ul>
<pre><code class="language-swift">/// Master Playlist 로드 및 파싱
func loadMasterPlaylist(from url: URL) async throws -&gt; HLSMasterPlaylist {
    let content = try await downloadPlaylist(from: url)
    let playlist = try parser.parseMasterPlaylist(content, baseURL: url)
    self.masterPlaylist = playlist
    return playlist
}</code></pre>
<ul>
<li><p><code>selectStream</code>
사용자의 현재 네트워크 대역폭을 측정한 값을 기준으로 적절한 화질을 선택한다. 해당 기능이 ABR 에서도 사용되는 기능이다. <code>bandwidth &lt;= 현재대역폭</code> 조건으로 재생 가능한 최고 화질을 선택하는 로직이다.</p>
<pre><code class="language-swift">/// 특정 대역폭에 맞는 스트림 선택
func selectStream(bandwidth: Int) throws -&gt; HLSStreamInfo {
  guard let masterPlaylist = masterPlaylist else {
      throw DownloadError.noAvailableStream
  }

  // 대역폭 이하의 가장 높은 품질 선택
  let sortedStreams = masterPlaylist.sortedStreams
  let selectedStream = sortedStreams.last { $0.bandwidth &lt;= bandwidth }
      ?? sortedStreams.first // 최소 품질이라도 선택

  guard let stream = selectedStream else {
      throw DownloadError.noAvailableStream
  }

  self.currentStreamInfo = stream
  return stream
}</code></pre>
</li>
<li><p><code>loadMediaPlaylist</code>
Media Playlist는 실제 비디오 세그먼트 파일들의 목록이므로 각 세그먼트의 URL, 재생 시간, 순서 등의 정보 포함하고 있다.</p>
</li>
</ul>
<pre><code class="language-swift">/// Media Playlist 로드 및 파싱
func loadMediaPlaylist(for stream: HLSStreamInfo) async throws -&gt; HLSMediaPlaylist {
    guard let masterPlaylist = masterPlaylist else {
        throw DownloadError.noAvailableStream
    }

    guard let mediaPlaylistURL = masterPlaylist.absoluteURL(for: stream.uri) else {
        throw DownloadError.invalidURL
    }

    let content = try await downloadPlaylist(from: mediaPlaylistURL)
    let playlist = try parser.parseMediaPlaylist(content, baseURL: mediaPlaylistURL)
    self.currentMediaPlaylist = playlist
    return playlist
}</code></pre>
<ul>
<li><code>downloadAndCombine</code>
해당 함수는 플레이리스트를 받아와서 재생까지 검증을 하기 위해 만든 임시 함수이다. 원래는 세그먼트를 받아오는대로 실시간으로 영상에 재생하지만, 이는 다음 포스트에서 다룰 것이므로 이번 포스트에서는 간단하게 받아온 세그먼트들을 결합하여 임시로 저장했다가 해당 파일을 AVPlayer 를 이용해 재생해보려고 한다. </li>
</ul>
<pre><code class="language-swift">/// [Phase 1 검증용] 모든 세그먼트를 다운로드하고 결합해서 임시 파일로 저장
/// - Returns: 결합된 비디오 파일의 임시 URL
func downloadAndCombine() async throws -&gt; URL {
    guard let mediaPlaylist = currentMediaPlaylist else {
        throw DownloadError.noAvailableStream
    }

    var combinedData = Data()

    // 1. 초기화 세그먼트 추가
    if let initData = try await downloadInitializationSegment() {
        combinedData.append(initData)
    }

    // 2. 모든 미디어 세그먼트 다운로드 &amp; 결합
    for segment in mediaPlaylist.segments {
        let data = try await downloadSegment(at: segment.index)
        combinedData.append(data)
    }

    // 3. 임시 파일로 저장
    let tempURL = FileManager.default.temporaryDirectory
        .appendingPathComponent(UUID().uuidString + &quot;.mp4&quot;)

    do {
        try combinedData.write(to: tempURL)
        return tempURL
    } catch {
        throw DownloadError.networkError(error)
    }
}</code></pre>
<hr>
<h3 id="hls-다운로드-테스트">HLS 다운로드 테스트</h3>
<ul>
<li><code>VideoPlayerViewController.swift</code>
<code>loadTestVideo()</code> 함수에서 기존의 AVPlayer 방식을 주석처리 하고, 다음과 같이 위에서 받아온 segment들을 가지고 <code>AVPlayer</code> 에 재생을 테스트해볼 수 있다.</li>
</ul>
<pre><code class="language-swift">    private func loadTestVideo() {
        // Phase 1: HLS 다운로드 &amp; 결합 테스트
        testHLSDownload()

        // 기존 AVPlayer 방식
        // guard let url = URL(string: testStreamURL) else {
        //     return
        // }
        // streamPlayerManager.loadStream(url: url)
        // playerView.player = streamPlayerManager.player
    }

    /// [Phase 1 검증] HLS 다운로드 및 재생 테스트
    private func testHLSDownload() {
        Task {
            do {
                guard let masterURL = URL(string: testStreamURL) else { return }

                let downloadManager = HLSDownloadManager()

                // 1. Master Playlist 로드
                let masterPlaylist = try await downloadManager.loadMasterPlaylist(from: masterURL)

                // 2. 스트림 선택 (3Mbps로 가정)
                let stream = try await downloadManager.selectStream(bandwidth: 3_000_000)

                // 3. Media Playlist 로드
                let mediaPlaylist = try await downloadManager.loadMediaPlaylist(for: stream)

                // 4. 모든 세그먼트 다운로드 &amp; 결합
                let videoURL = try await downloadManager.downloadAndCombine()

                // 5. AVPlayer로 재생
                await MainActor.run {
                    streamPlayerManager.loadStream(url: videoURL)
                    playerView.player = streamPlayerManager.player
                }
            } catch {
                print(&quot;Error: \(error.localizedDescription)&quot;)
            }
        }
    }
</code></pre>
<p>실행 결과는 다음과 같이 재생이 잘 되는 것을 확인할 수 있다. 다만 위 코드는 모든 segment들의 sequnce를 받아온 뒤 비디오를 재생하기 때문에 재생되기까지 시간이 걸리고, 메모리에 해당 비디오의 크기만큼 차지하는 문제가 있다. 우선 지금까지 단계의 목적은 단순히 다운로드한 비디오 Segment들이 (1) 파싱이 잘 되는지, (2) Sequence로 쭉 연결했을 때 재생이 잘 되는지 확인이 목적이었기 때문에 이 다음으로는 실시간으로 비디오 segment들을 재생하고 버퍼링을 최소화하며 ABR 을 적용하고자 한다.</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/58c230d8-2fb2-49bd-83c1-fbbff173ece4/image.png" width="200">
  <p style="font-size: 14px; color: gray;">▲ 그림 . Segment 다운로드 후 실행 결과</p>
</div>

<hr>
<h3 id="reference">Reference</h3>
<p>[Apple Docs]</p>
<ul>
<li><a href="https://developer.apple.com/streaming/Whats-new-HLS.pdf">https://developer.apple.com/streaming/Whats-new-HLS.pdf</a></li>
<li><a href="https://developer.apple.com/documentation/http-live-streaming">https://developer.apple.com/documentation/http-live-streaming</a></li>
<li><a href="https://developer.apple.com/documentation/http-live-streaming/example-playlists-for-http-live-streaming">https://developer.apple.com/documentation/http-live-streaming/example-playlists-for-http-live-streaming</a></li>
</ul>
<p>[Blog]</p>
<ul>
<li><a href="https://medium.com/@hongseongho/introduction-to-hls-e7186f411a02">https://medium.com/@hongseongho/introduction-to-hls-e7186f411a02</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[iOS] AVFoundation, AVKit 이해 그리고 AVPlayer 다뤄보기]]></title>
            <link>https://velog.io/@sangjin-hash/iOS-AVFoundation-AVKit-%EC%9D%B4%ED%95%B4-%EA%B7%B8%EB%A6%AC%EA%B3%A0-AVPlayer-%EB%8B%A4%EB%A4%84%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@sangjin-hash/iOS-AVFoundation-AVKit-%EC%9D%B4%ED%95%B4-%EA%B7%B8%EB%A6%AC%EA%B3%A0-AVPlayer-%EB%8B%A4%EB%A4%84%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Tue, 09 Dec 2025 12:41:44 GMT</pubDate>
            <description><![CDATA[<h3 id="개요">개요</h3>
<p>이번 포스트는 AVFoundation 과 AVKit을 이용한 동영상 플레이어에 대해 알아보고자 한다. AVFoundation 과 AVkit은 iOS에서 미디어 처리를 다루는 두 가지 핵심 프레임워크인데,</p>
<blockquote>
<p><strong>AVFoundation</strong>
<em>Work with audiovisual assets, control device cameras, process audio, and configure system audio interactions.</em></p>
</blockquote>
<p>AVFoundation은 <strong>오디오와 비디오를 재생, 녹화, 편집</strong>하는 프레임워크이다. 미디어 에셋 관리, 재생 타이밍 제어, 오디오 세션 설정 등 세밀한 커스터마이징이 필요할 때 사용한다.</p>
<blockquote>
<p><strong>AVKit</strong>
<em>Create user interfaces for media playback, complete with transport controls, chapter navigation, picture-in-picture support, and display of subtitles and closed captions.</em></p>
</blockquote>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/7a58af65-8d33-4265-ab11-c21d549b8858/image.png" width="400">
  <p style="font-size: 14px; color: gray;">▲ 그림 1. AVFoundation stack on iOS</p>
</div>

<p>AVKit은 AVFoundation 위에서 동작하는 프레임워크이다. <code>AVPlayerViewController</code> 같은 즉시 사용 가능한 플레이어 인터페이스를 제공해서, 빠르게 표준 미디어 재생 화면을 구현할 수 있다. 간단히 말하면 AVFoundation이 엔진이라면, AVKit은 그 위에 올라가는 UI 레이어라고 볼 수 있다.</p>
<h3 id="동영상-플레이어">동영상 플레이어</h3>
<p>AVFoundation 과 AVkit을 이용해 동영상 플레이어를 만든 프로젝트를 기준으로 설명하고자 한다. 해당 프로젝트에서 제공하는 기능은 다음과 같다.</p>
<p><a href="https://github.com/sangjin-hash/VideoStreamingPlayer">다음 링크</a>의 <code>AVPlayer</code> 브랜치에서 확인할 수 있다.</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/1b6d349c-70ef-4639-8d63-870939bf0718/image.png" width="600">
  <p style="font-size: 14px; color: gray;">▲ 그림 2. AVFoundation & AVKit 이용한 동영상 플레이어</p>
</div>

<pre><code>1. HLS 비디오 플레이어
2. 기본적인 재생 제어(재생/멈춤, Seek 기능, 5초 앞으로/뒤로)
3. SeekBar(전체 트랙, 버퍼링된 영역, 현재 재생 위치)
4. 화면 전환 시 전체화면 변경</code></pre><p>기본적인 동영상 플레이어의 기능을 구현했고, 해당 프로젝트 내에서 AVFoundation 과 AVkit 에서 사용한 기능들에 대해 리뷰하겠다.</p>
<h3 id="미디어-로딩avurlasset-avplayeritem">미디어 로딩(AVURLAsset, AVPlayerItem)</h3>
<blockquote>
<p><strong>class AVURLAsset : AVAsset</strong>
<em>An asset that represents media at a local or remote URL.</em></p>
</blockquote>
<p><code>AVURLAsset</code>은 URL을 통해 미디어 파일에 접근하는 클래스이다. 로컬 파일이나 원격 스트리밍 URL로부터 미디어의 메타데이터, 트랙 정보, 재생 시간 등을 비동기적으로 로드한다.</p>
<blockquote>
<p><strong>class AVPlayerItem : NSObject</strong>
<em>An object that models the timing and presentation state of an asset during playback.</em></p>
</blockquote>
<p><code>AVPlayerItem</code>은 <code>AVAsset</code>을 재생 가능한 형태로 래핑한 객체이다. 재생 상태(buffering, ready, failed), 현재 재생 시간, 로드된 시간 범위 등 실시간 재생 정보를 제공한다. <code>AVPlayer</code>와 연결되어 실제 미디어 재생을 관리하며, 여러 <code>AVPlayerItem</code>을 순차적으로 재생하는 큐잉도 지원한다.</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/f4c7c4a2-f655-4261-84b5-8fa8ba6b0242/image.png" width="400">
  <p style="font-size: 14px; color: gray;">▲ 그림 3. AVAsset 구성</p>
</div>

<p><code>AVAsset</code>은 비디오, 오디오, 자막과 같은 서로 다른 미디어 타입의 트랙들을 포함하고 있고, 위에서 설명한 <code>AVURLAsset</code> 혹은 <code>AVComposition</code> 등과 같이 <code>AVAsset</code>을 상속받아 구체적인 미디어 소스를 구현한다.</p>
<pre><code class="language-swift">class StreamPlayerManager {
    ...

    func loadStream(url: URL) {
        cleanup()
        currentState = .loading

        let asset = AVURLAsset(url: url)
        playerItem = AVPlayerItem(asset: asset)
        player = AVPlayer(playerItem: playerItem)

        // ABR 최적화 설정
        if let player = player {
            // 자동으로 stalling 최소화 (버퍼링 개선)
            player.automaticallyWaitsToMinimizeStalling = true
        }

        setupObservers()
    }
    ...
}</code></pre>
<p>먼저 <code>AVURLAsset</code>으로 URL의 미디어 리소스에 접근하고, 이를 <code>AVPlayerItem</code>으로 래핑하여 재생 가능한 상태로 만든다. 마지막으로 <code>AVPlayer</code>에 <code>playerItem</code>을 연결하면 실제 재생 준비가 완료된다. 이 과정은 <code>Asset → PlayerItem → Player</code> 순서로 진행되며, 각 객체가 상위 레이어를 감싸는 구조이다.</p>
<h3 id="avplayerlayer-설정">AVPlayerLayer 설정</h3>
<blockquote>
<p><strong>class AVPlayerLayer : CALayer</strong>
<em>An object that presents the visual contents of a player object.</em></p>
</blockquote>
<p><code>AVPlayerLayer</code> 는 AVPlayer가 재생하는 비디오를 화면에 렌더링하는 역할을 담당한다. 미디어 재생 자체는 <code>AVPlayer</code>가 담당하지만 <code>AVPlayerLayer</code>는 이 재생 중인 비디오를 렌더링하는 데 사용된다고 이해하면 된다. 해당 프로젝트 내에 Layer 를 설정하는 코드는 <code>PlayerView.swift</code> 에서 확인할 수 있다.</p>
<ul>
<li><p>UIView의 backing layer를 AVPlayerLayer로 변경</p>
<pre><code class="language-swift">override class var layerClass: AnyClass {
  AVPlayerLayer.self
}</code></pre>
<p><code>UIView</code>는 기본적으로 <code>CALayer</code>를 <code>backing layer</code>로 사용하기 때문에, <code>layerClass</code>를 override하면 다른 타입의 layer 사용 가능하다. 이렇게 하면 <code>self.layer</code>가 자동으로 <code>AVPlayerLayer</code> 인스턴스가 된다.</p>
</li>
<li><p>playerLayer Property</p>
<pre><code class="language-swift">// self.layer를 AVPlayerLayer로 타입 캐스팅 -&gt; 옵셔널로 반환하여 안전성 확보
var playerLayer: AVPlayerLayer? {
  self.layer as? AVPlayerLayer
}
</code></pre>
</li>
</ul>
<p>var player: AVPlayer? {
    get { self.playerLayer?.player }
    set { self.playerLayer?.player = newValue }
}</p>
<pre><code>
`AVPlayerLayer`의 player 프로퍼티에 대한 편리한 접근하고자 getter 를 세팅했고, `ViewController` 에서 `player` 를 편하게 초기화하기 위해 setter 를 설정했다.

- 초기화
```swift
// MARK: - Initialization

override init(frame: CGRect) {
    super.init(frame: frame)
    setupLayer()
}

required init?(coder: NSCoder) {
    super.init(coder: coder)
    setupLayer()
}

// MARK: - Setup

private func setupLayer() {
    backgroundColor = .black
    playerLayer?.videoGravity = .resizeAspect
}</code></pre><p>Storyboard/XIB 사용 시 <code>init(coder:)</code>, 코드로 생성 시 <code>init(frame:)</code> 를 호출한다. 둘 다 <code>setupLayer()</code> 호출하여 플레이어에 대해 초기 설정을 적용할 수 있다.</p>
<h3 id="플레이어-및-재생-제어avplayer">플레이어 및 재생 제어(AVPlayer)</h3>
<blockquote>
<p><strong>class AVPlayer: NSObject</strong>
<em>An object that provides the interface to control the player’s transport behavior.</em></p>
</blockquote>
<p><code>AVPlayer</code> 는 미디어 재생을 제어하는 핵심 컨트롤러 객체이다. <code>play()</code>, <code>pause()</code>, <code>seek()</code> 같은 재생 제어 메서드와 <code>currentTime</code>, <code>rate(재생 속도)</code> 등의 속성을 제공하며, <code>AVPlayerItem</code>의 상태를 모니터링하고 실제 재생을 관리한다. </p>
<ul>
<li>재생 제어
<code>AVPlayer</code> 는 위의 <code>loadStream(url)</code> 에서 초기화했기 때문에 생략했고, 재생 제어 관련된 메소드들은 다음과 같이 설정했다. <code>StreamPlayerManager</code> 의 명시된 함수들을 통해 <code>ViewController</code> 에서 UI의 Action이 일어나는 부분에 해당 메소드들을 호출하게 되면 재생을 제어할 수 있다.</li>
</ul>
<pre><code class="language-swift">class StreamPlayerManager {
    ...

    // MARK: - Properties

    private(set) var player: AVPlayer?
    private var playerItem: AVPlayerItem?

    ...

    // MARK: - Public Methods

    ...

    func play() {
        guard let playerItem = playerItem else { return }

        player?.play()

        if playerItem.isPlaybackLikelyToKeepUp {
            currentState = .playing
        } else {
            currentState = .buffering
        }
    }

    func pause() {
        player?.pause()
        currentState = .paused
    }

    func seek(to time: Double) {
        let cmTime = CMTime(seconds: time, preferredTimescale: 600)
        player?.seek(to: cmTime)
    }

    func seek(toPercent percent: Double) {
        let time = duration * percent
        seek(to: time)
    }

    func forward(seconds: Double = 10) {
        guard let currentItem = player?.currentItem else { return }
        let currentTime = currentItem.currentTime().seconds
        let newTime = min(currentTime + seconds, duration)
        seek(to: newTime)
    }

    func rewind(seconds: Double = 10) {
        guard let currentItem = player?.currentItem else { return }
        let currentTime = currentItem.currentTime().seconds
        let newTime = max(currentTime - seconds, 0)
        seek(to: newTime)
    }

    func setRate(_ rate: Float) {
        player?.rate = rate
    }

    ...</code></pre>
<ul>
<li>상태 관리
플레이어의 상태는 다음과 같이 정의할 수 있다.<pre><code class="language-swift">import Foundation
</code></pre>
</li>
</ul>
<p>enum PlaybackState: Equatable {
    case idle
    case loading
    case readyToPlay
    case playing
    case paused
    case buffering
    case ended
    case failed(Error)</p>
<pre><code>static func == (lhs: PlaybackState, rhs: PlaybackState) -&gt; Bool {
    switch (lhs, rhs) {
    case (.idle, .idle),
         (.loading, .loading),
         (.readyToPlay, .readyToPlay),
         (.playing, .playing),
         (.paused, .paused),
         (.buffering, .buffering),
         (.ended, .ended):
        return true
    case (.failed(let lhsError), .failed(let rhsError)):
        return lhsError.localizedDescription == rhsError.localizedDescription
    default:
        return false
    }
}</code></pre><p>}</p>
<pre><code>
8 개의 상태를 정의해서 각 상태 별 표시해야 할 UI 는 다음과 같다.

&lt;div align=&quot;center&quot;&gt;
  &lt;img src=&quot;https://velog.velcdn.com/images/sangjin-hash/post/f4d5a90a-2510-4989-9b49-5576da39fdd0/image.png&quot; width=&quot;600&quot;&gt;
  &lt;p style=&quot;font-size: 14px; color: gray;&quot;&gt;▲ 그림 4. 상태 별 UI 표시&lt;/p&gt;
&lt;/div&gt;

```swift
class StreamPlayerManager {

    // MARK: - Properties
    ...

    private(set) var currentState: PlaybackState = .idle {
        didSet {
            delegate?.playerStateDidChange(state: currentState)
        }
    }
    ...

    // MARK: - Initialization

    init() {
        setupNotifications()
    }

    deinit {
        cleanup()
        NotificationCenter.default.removeObserver(self)
    }

    // MARK: - Private Methods

    private func setupObservers() {
        guard let player = player, let playerItem = playerItem else { return }

        // Time Observer
        timeObserverToken = player.addPeriodicTimeObserver(
            forInterval: CMTime(seconds: 0.5, preferredTimescale: 600),
            queue: .main
        ) { [weak self] time in
            guard let self = self else { return }
            self.delegate?.playerDidUpdateTime(
                currentTime: time.seconds,
                duration: self.duration
            )
        }

        // Status Observer
        statusObservation = playerItem.observe(\.status, options: [.new]) { [weak self] item, _ in
            guard let self = self else { return }

            switch item.status {
            case .readyToPlay:
                self.currentState = .readyToPlay

            case .failed:
                if let error = item.error {
                    self.currentState = .failed(error)
                    print(&quot;Failed to play: \(error.localizedDescription)&quot;)
                }

            case .unknown:
                print(&quot;Loading...&quot;)

            @unknown default:
                break
            }
        }

        // Buffer Observer - 버퍼 상태 및 UI 업데이트
        bufferObservation = playerItem.observe(\.loadedTimeRanges, options: [.new]) { [weak self] item, _ in
            guard let self = self else { return }

            // 1. UI 업데이트 - 버퍼링 seekbar 표시
            if let timeRange = item.loadedTimeRanges.first?.timeRangeValue {
                let bufferedTime = timeRange.start.seconds + timeRange.duration.seconds
                self.delegate?.playerDidUpdateBuffer(bufferedTime: bufferedTime)
            }

            // 2. 상태 관리 - 버퍼링 상태 체크
            let isPlayingOrBuffering = self.currentState == .playing || self.currentState == .buffering

            if !item.isPlaybackLikelyToKeepUp &amp;&amp; isPlayingOrBuffering {
                // 버퍼가 부족하면 buffering 상태로 전환
                self.currentState = .buffering
            } else if item.isPlaybackLikelyToKeepUp &amp;&amp; self.currentState == .buffering {
                // 버퍼링이 완료되면 playing 상태로 복귀 (재생 중이었다면)
                if self.player?.rate != 0 {
                    self.currentState = .playing
                }
            }
        }
    }

    private func setupNotifications() {
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(playerDidFinishPlaying),
            name: .AVPlayerItemDidPlayToEndTime,
            object: nil
        )
    }

    @objc private func playerDidFinishPlaying() {
        currentState = .ended
    }

    private func cleanup() {
        if let token = timeObserverToken {
            player?.removeTimeObserver(token)
            timeObserverToken = nil
        }

        statusObservation?.invalidate()
        statusObservation = nil

        bufferObservation?.invalidate()
        bufferObservation = nil

        player?.pause()
        player = nil
        playerItem = nil

        currentState = .idle
    }
}</code></pre><p><code>setupObservers()</code> 에는 3 가지의 옵저버를 생성했는데, 각 옵저버의 역할은 다음과 같다.</p>
<pre><code>1. Time Observer (재생 시간 관찰)
  - 역할:
      - 0.5초마다 현재 재생 시간을 delegate에 전달
      - SeekBar의 progressBar 업데이트에 사용

2. Status Observer (재생 준비 상태 관찰)
  - 역할:
      - AVPlayerItem의 status 프로퍼티 변화 감지
      - 재생 가능 여부, 에러 발생 등을 파악

  - 처리하는 상태:
      - .readyToPlay: 재생 준비 완료 → PlaybackState.readyToPlay
      - .failed: 재생 실패 → PlaybackState.failed(error), 에러 로그 출력
      - .unknown: 로딩 중 → 로그만 출력

3. Buffer Observer (버퍼링 상태 관찰)
  - 역할:
      - loadedTimeRanges 변화 감지
      - 버퍼링된 시간 계산 및 UI 업데이트
      - 버퍼 충분 여부에 따라 자동으로 상태 전환

  - 처리 로직:
      - UI 업데이트 (bufferedBar 표시)
        - loadedTimeRanges에서 첫 번째 timeRange 추출
        - start + duration으로 총 버퍼링된 시간 계산
        - delegate로 전달하여 SeekBar의 bufferedBar 업데이트
      - 상태 전환 로직
        - 버퍼 부족 (!isPlaybackLikelyToKeepUp) + 재생 중 → buffering
        - 버퍼 충분 (isPlaybackLikelyToKeepUp) + buffering 상태 + rate != 0 → playing</code></pre><h3 id="회고">회고</h3>
<p>지금까지 <code>AVPlayer</code> 설정 및 재생 제어 그리고 상태 관리까지 알아보았다. 해당 프로젝트를 수행하면서 <code>.m3u8</code> 마스터 플레이리스트 URL을 사용했는데, 이는 <code>HLS(HTTP Live Streaming)</code> 프로토콜을 따르는 비디오 세그먼트들을 저장하고 있는 파일로, 이와 관련된 내용은 다음 포스트에서 자세하게 다루도록 하겠다. </p>
<p><code>AVPlayer</code>는 Apple의 HLS 프로토콜을 네이티브로 지원하는데, 개발자는 세그먼트 다운로드, 플레이리스트 파싱, 디코딩 등의 복잡한 처리를 전혀 신경 쓸 필요가 없다. 또한 <code>AVPlayer</code> 는 자동으로 <code>ABR(Adaptive Bitrate Streaming)</code> 를 제공하는데, 이는 <code>AVPlayer</code>의 가장 강력한 기능 중 하나이다. ABR 은 간단하게 정의하자면 사용자의 네트워크 대역폭을 실시간으로 측정하고, 동적으로 그에 맞는 화질의 세그먼트를 자동으로 선택하여 끊김 없는 비디오 재생을 보장하는 기술이다.</p>
<p>AVPlayer가 자동으로 처리하는 것들은 다음과 같다.</p>
<pre><code>1. 네트워크 대역폭 측정
  - 실시간으로 다운로드 속도 모니터링
  - 사용 가능한 대역폭 자동 계산

2. 화질 자동 선택
  - HLS 마스터 플레이리스트의 여러 variant 중 최적 화질 선택
  - 네트워크 상태에 따라 동적으로 화질 전환
  - 고화질 ↔ 저화질 seamless 전환

3. 버퍼 관리
  - 끊김 없는 재생을 위한 버퍼 사이즈 자동 조정
  - isPlaybackLikelyToKeepUp 프로퍼티로 버퍼 상태 제공

4. 네트워크 변화 대응
  - WiFi ↔ 셀룰러 전환 시 자동 적응
  - 네트워크가 느려지면 즉시 낮은 화질로 전환
  - 네트워크가 개선되면 점진적으로 화질 향상</code></pre><p>위를 확인하기 위해 Simulator 에 해당 프로젝트를 실행시키고, 별도의 <code>Network Link Conditioner</code> 를 설치하여 <code>Wifi -&gt; LTE -&gt; 3G</code> 순으로 확인해본 결과 해당 네트워크의 대역폭에 따라 화질이 낮아지는 것을 확인했다.</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/f18915ce-9570-4bfb-bc61-0793c47ac740/image.png" width="600">
  <p style="font-size: 14px; color: gray;">▲ 그림 5. Network Link Conditioner</p>
</div>

<p>다음 게시글에서는 <code>HLS</code> 와 <code>ABR</code> 에 대해 학습한 내용들을 다루도록 하겠다.</p>
<h3 id="reference">Reference</h3>
<p>[Apple Docs]</p>
<ul>
<li><a href="https://developer.apple.com/documentation/avfoundation">https://developer.apple.com/documentation/avfoundation</a></li>
<li><a href="https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/AVFoundationPG/Articles/00_Introduction.html#//apple_ref/doc/uid/TP40010188-CH1-SW3">https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/AVFoundationPG/Articles/00_Introduction.html#//apple_ref/doc/uid/TP40010188-CH1-SW3</a></li>
<li><a href="https://developer.apple.com/documentation/avfoundation/avasset">https://developer.apple.com/documentation/avfoundation/avasset</a></li>
<li><a href="https://developer.apple.com/documentation/avfoundation/avplayer">https://developer.apple.com/documentation/avfoundation/avplayer</a></li>
<li><a href="https://developer.apple.com/documentation/avfoundation/avplayerlayer">https://developer.apple.com/documentation/avfoundation/avplayerlayer</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[iOS] 배포 파이프라인 개선(2)]]></title>
            <link>https://velog.io/@sangjin-hash/Flutter-%EB%B0%B0%ED%8F%AC-%ED%8C%8C%EC%9D%B4%ED%94%84%EB%9D%BC%EC%9D%B8-%EA%B0%9C%EC%84%A02-iOS-%ED%8E%B8</link>
            <guid>https://velog.io/@sangjin-hash/Flutter-%EB%B0%B0%ED%8F%AC-%ED%8C%8C%EC%9D%B4%ED%94%84%EB%9D%BC%EC%9D%B8-%EA%B0%9C%EC%84%A02-iOS-%ED%8E%B8</guid>
            <pubDate>Fri, 06 Jun 2025 11:32:14 GMT</pubDate>
            <description><![CDATA[<h3 id="개요">개요</h3>
<p><a href="https://velog.io/@sangjin-hash/Flutter-%EB%B0%B0%ED%8F%AC-%ED%8C%8C%EC%9D%B4%ED%94%84%EB%9D%BC%EC%9D%B8-%EA%B0%9C%EC%84%A01-Android-%ED%8E%B8">지난 게시글</a>에서는 Android에서 Firebase App Distribution 의 도입을 통해 배포 파이프라인을 알파 테스트를 추가함으로써 배포 채널의 분리(알파 테스트-Firebase, 베타 테스트-내부 테스트, 프로덕션 출시)하였고, 이번엔 iOS 에 대해 적용해보겠다.</p>
<hr>
<h3 id="build-scheme-수정">Build Scheme 수정</h3>
<h4 id="--build-configuration-설정">- Build Configuration 설정</h4>
<p>기존의 Build Scheme 은 다음과 같다.</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/058ca021-007e-4ed8-b5a9-1b86612221ec/image.png" width="300">
  <p style="font-size: 14px; color: gray;">▲ 그림 1. 기존의 Build Scheme</p>
</div>

<p>이는 Android Studio에서 Flutter 앱을 디버그 혹은 실행할 때 선언한 <code>Build Configuration</code> 과 동일한데, Android 배포 파이프라인을 개선하면서 <code>(기존) dev, prod</code> -&gt; <code>(변경) development, hotfix, sandbox, production</code> 으로 변경되었기 때문에, 마찬가지로 Xcode 내에서 해당 Build Scheme 들을 변경해줘야 한다.</p>
<p>Build Scheme 을 추가하기 전에, Build Scheme에 대응하는 Build Configuration 이 실행되도록 하기 위해 Configuration들을 추가해주었다.[Xcode &gt; Project &gt; Runner &gt; Info &gt; Configurations]</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/3604d105-41d0-4d2a-b32d-480d9df0dc0d/image.png" width="600">
  <p style="font-size: 14px; color: gray;">▲ 그림 2. Build Configuration 추가</p>
</div>

<p>Flutter에서 --flavor와 --debug|--release 옵션을 통해 실행되는 iOS 빌드는 내부적으로 Build Configuration을 [BuildType]-[Flavor] 형식으로 구성하여 Xcode 프로젝트를 참조한다. 따라서 Xcode 내에는 해당 이름의 Build Configuration과 이를 참조하는 Scheme이 구성돼 있어야 Flutter 빌드가 정상적으로 수행된다.</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/c697fd09-d0a6-4241-b9fe-bd79e27f69f9/image.png" width="600">
  <p style="font-size: 14px; color: gray;">▲ 그림 3. Android Studio 에서 Build Configuration</p>
</div>

<p>다시 말해, 위 이미지처럼 Android Studio에서 <strong>&quot;production&quot;</strong> 이라는 Run Configuration을 만들고 <code>--dart-define=FLAVOR=prod --release</code> 옵션과 <code>--flavor=production</code>을 지정하면, Flutter는 내부적으로 Release-production이라는 Build Configuration 이름을 Xcode에 전달한다.</p>
<p>이때 Xcode 프로젝트 내에는 <strong>&quot;Release-production&quot;</strong> 이라는 이름의 Build Configuration이 반드시 존재해야 하며, 동시에 이를 참조하는 Xcode Scheme (production) 도 구성돼 있어야 한다.</p>
<p>즉, Flutter는 Android Studio에서 해당 Run Configuration을 실행할 때 자동으로 <code>xcodebuild -scheme production -configuration Release-production</code> 과 같은 명령을 실행하게 되며, 이때 대응되는 Xcode 설정이 누락되어 있다면 빌드가 실패한다.</p>
<p>따라서 Android Studio와 Xcode 양쪽 모두에서 Flavor 이름과 Build Configuration 네이밍이 일치하도록 관리해야 Flutter의 iOS 빌드가 원활하게 동작한다.</p>
<h4 id="--build-scheme-설정">- Build Scheme 설정</h4>
<p>따라서 4가지의  Build Scheme 을 설정하면 다음과 같다.</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/346ad07f-d71a-4c51-87a8-a4e5835eedf4/image.png" width="300">
  <p style="font-size: 14px; color: gray;">▲ 그림 4. 변경된 Build Scheme</p>
</div>

<p>각 Build Scheme 에 따라 위에서 설정했던 Build Configuration 을 1:1 매핑 시켜야 한다.</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/b17527be-c9cb-48a1-ac0a-b81aa23e20c9/image.png" width="600">
  <p style="font-size: 14px; color: gray;">▲ 그림 5. 각 Build Scheme 별 Configuration 매핑</p>
</div>

<hr>
<h3 id="additional-run-args-전달-필요">Additional run args 전달 필요</h3>
<p>Android 에서 Fastfile 에서 앱을 빌드할 때 사용하는 함수는 다음과 같다.</p>
<pre><code class="language-ruby">def build_app(flavor)
    if flavor == &quot;sandbox&quot;
      sh &quot;flutter build apk --flavor sandbox --release --dart-define=FLAVOR=dev&quot;
      return &quot;../build/app/outputs/flutter-apk/app-sandbox-release.apk&quot;

    elsif flavor == &quot;production-firebase&quot;
        sh &quot;flutter build apk --flavor production --release --dart-define=FLAVOR=prod&quot;
        return &quot;../build/app/outputs/flutter-apk/app-production-release.apk&quot;

    elsif flavor == &quot;production&quot;
      sh &quot;flutter build appbundle --flavor production --release --dart-define=FLAVOR=prod&quot;
      return &quot;../build/app/outputs/bundle/productionRelease/app-production-release.aab&quot;

    else
      UI.user_error!(&quot;Unknown flavor: #{flavor}&quot;)
    end
  end</code></pre>
<p>여기서 빌드 시 <code>--dart-define=FLAVOR=[dev or prod]</code> 이를 추가주었는데, 이러한 이유는 <code>FLAVOR</code> 변수를 <code>main.dart</code> 에 전달하여 해당 값에 따라 <code>Environment</code> 를 설정하기 때문이다. 해당 이유는 <a href="https://velog.io/@sangjin-hash/Flavor-%EA%B0%9C%EB%B0%9C-%EC%9A%B4%EC%98%81-%ED%99%98%EA%B2%BD-%EA%B5%90%EC%B2%B4-%EC%9E%90%EB%8F%99%ED%99%94">다음 게시글</a> 에서 확인할 수 있다.</p>
<p>따라서 CI 단계에서도 해당 변수를 전달하기 위해 Android 의 경우 위의 방법으로 해결하였고, iOS 에서는 </p>
<blockquote>
<ol>
<li>xcconfig 를 통한 전달</li>
<li>명령어에 포함시켜 직접 빌드</li>
</ol>
</blockquote>
<p>다음 두가지를 시도해보았고, <strong>첫번째 방법에서 빌드 오류</strong> 로 인해 이러한 문제를 해결하지 못해 두번째 방법으로 해결하였다. <strong>만약 첫번째 방법을 시도했는데 오류가 나지 않는다면 첫번째 방법으로 적용하는 것을 적극 권장한다.</strong> 이러한 이유는 아래에서 다루도록 하겠다.</p>
<h4 id="--1-xcconfig-를-통한-전달">- 1. xcconfig 를 통한 전달</h4>
<p>xcconfig 파일은 Xcode 프로젝트 내에서 빌드 설정을 외부 파일로 분리하여 구성할 수 있게 해주는 구성 파일이다.
환경별 설정(예: 디버그/릴리즈, 개발/운영 등)을 명확하게 구분하여 관리할 수 있고, 같은 프로젝트 내에서도 다양한 빌드 구성을 손쉽게 제어할 수 있다는 장점이 있다.</p>
<p>xcconfig 파일을 활용하면 DART_DEFINES와 같은 변수를 설정하여 Dart 레이어에 값을 넘길 수 있다. 이를 통해 런타임 시점에서 Dart 코드 내에서 정의된 환경 값(const String.fromEnvironment(&#39;FLAVOR&#39;))을 사용할 수 있게 된다.</p>
<ul>
<li><p>Debug-development.xcconfig</p>
<pre><code>#include &quot;Debug.xcconfig&quot;
DART_DEFINES=FLAVOR%3Ddev</code></pre></li>
<li><p>Debug-hotfix.xcconfig</p>
<pre><code>#include &quot;Debug.xcconfig&quot;
DART_DEFINES=FLAVOR%3Dprod</code></pre></li>
<li><p>Release-sandbox.xcconfig</p>
<pre><code>#include &quot;Release.xcconfig&quot;
DART_DEFINES=FLAVOR%3Ddev</code></pre></li>
<li><p>Release-production.xcconfig</p>
<pre><code>#include &quot;Release.xcconfig&quot;
DART_DEFINES=FLAVOR%3Dprod</code></pre></li>
</ul>
<p>위의 4개 <code>.xcconfig</code> 를 만들었다면 이는 ios/Flutter 내부에 넣어주면 되고,</p>
<p>ios &gt; Flutter &gt; Add Files to &quot;Runner&quot; &gt; 파일 선택 &gt; Action: Reference files in place 선택한다.</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/38a3f616-2d83-4d36-a829-af86240bb709/image.png" width="300">
  <p style="font-size: 14px; color: gray;">▲ 그림 6. Xcode에 File 추가</p>
</div>

<p>이 때 .xcconfig 파일은 빌드 설정용이므로 앱 번들에 포함되면 안 되므로 Targets 에는 모두 해제해주었다.</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/9b56c75d-10aa-40d9-ab78-037db7746f99/image.png" width="300">
  <p style="font-size: 14px; color: gray;">▲ 그림 7. Action 설정</p>
</div>


<p>각 Configuration 마다 위에서 생성한 xcconfig 를 매핑시켜줘야 한다.</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/b7aa49b2-7f39-4900-bf3f-f330e9b0aad1/image.png" width="600">
  <p style="font-size: 14px; color: gray;">▲ 그림 8. 각 Build Configuration 마다 .xcconfig 매핑</p>
</div>

<p>그러나 다음과 같은 빌드 실패 에러가 발생하였고, 빌드 캐시 삭제, pod 초기화 등 시도해보았으나 해당 에러를 해결하지 못해 결국 2번 방법으로 우회하였다.</p>
<pre><code>Failed to build iOS app
Error (Xcode): Error parsing assemble command: your generated configuration may be out of date. Try re-running &#39;flutter build ios&#39; or the appropriate build command.


Encountered error while building for device.</code></pre><h4 id="--2-명령어에-포함시켜-직접-빌드">- 2. 명령어에 포함시켜 직접 빌드</h4>
<p><code>flutter</code> 명령어를 이용하여 <code>--dart-define=FLAVOR=</code> 를 포함시켜서 빌드하는 방법이다.</p>
<pre><code class="language-bash">flutter build ios --flavor sandbox --dart-define=FLAVOR=dev --release</code></pre>
<p>이 방법으로 진행할 경우 <code>Fastfile</code> 에서 flavor 에 따라 다르게 명령어를 실행해주면 된다. 해당 작업은 추후 Fastfile 작성 파트에서 상세 내용을 다루도록 하겠다.</p>
<hr>
<h3 id="run-scripts-수정">Run Scripts 수정</h3>
<p>Build Configuration 이 추가되었기 때문에 실행 시 Configuration 에 따라(운영 환경인지, 개발 환경인지) Firebase 연동에 필요한 <code>Info.plist</code> 를 런타임 단계에서 복사하는 스크립트도 수정해야 한다.[Xcode &gt; Targets &gt; Runner &gt; Build Phase] </p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/985b281f-7c48-4610-b7f1-0dff18fe7ee4/image.png" width="600">
  <p style="font-size: 14px; color: gray;">▲ 그림 9. 기존 Run Script</p>
</div>

<p>기존에는 dev 와 prod 만 이용했었기 때문에, 이제는 4가지 환경에 맞게 다시 설정해줘야 한다.</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/37fee7c9-7d88-4935-a2b7-acd50a81345b/image.png" width="600">
  <p style="font-size: 14px; color: gray;">▲ 그림 10. 변경된 Run Script</p>
</div>


<hr>
<h3 id="firebase_app_distribution-설정">Firebase_app_distribution 설정</h3>
<p>ios 디렉토리에서 해당 명령어를 실행 시 ios/Gemfile 이 수정되며 ios/fastlane/Pluginfile 이 생긴다.</p>
<pre><code>fastlane add_plugin firebase_app_distribution</code></pre><p>이 후 <code>GOOGLE_APPLICATION_CREDENTIALS</code> 환경 변수를 설정해야 한다.</p>
<pre><code>export GOOGLE_APPLICATION_CREDENTIALS=/absolute/path/to/credentials/file.json</code></pre><p>이는 Android 에서 수행했던 작업과 동일하다.</p>
<hr>
<h3 id="match-를-통한-ad-hoc-인증서-발급">Match 를 통한 Ad-hoc 인증서 발급</h3>
<p>firebase_app_distribution으로 iOS 앱을 배포하려면, 테스터 기기에서 앱을 설치할 수 있어야 한다. 이를 위해서는 해당 기기들의 <strong>UDID가 포함된 Ad-Hoc 프로비저닝 프로필과
앱 서명을 위한 Ad-Hoc 배포 인증서</strong>가 필요하다.</p>
<p>추후 Fastfile 에서 <code>export_method: &quot;ad-hoc&quot;</code>으로 .ipa를 생성할 때 이 프로파일과 인증서가 포함되어야 Firebase를 통해 배포된 앱이 실제 기기에서 설치 가능하다. </p>
<p>지금은 appstore 용 인증서만 발급한 상태기 때문에 match를 통해 App Store 용 인증서(App Store Distribution)와 프로비저닝 프로필만 있는 경우, <code>export_method: &quot;app-store&quot;</code>(따로 설정 안하면 default로 설정됨)로 빌드된 .ipa는 Firebase App Distribution에서는 설치 불가하다. 이 프로파일은 실제 App Store 제출 전용으로, <strong>테스터 기기 UDID가 포함되지 않기 때문</strong>이다.</p>
<p>따라서 Firebase를 통한 테스트 배포에는 Ad-Hoc 인증서 및 프로비저닝 프로필이 필수이다.</p>
<h4 id="--test-device-의-uuid-추가">- Test Device 의 UUID 추가</h4>
<p>Apple Developer Portal &gt; Certificates, Identifiers &amp; Profiles &gt; Devices</p>
<ul>
<li><a href="https://developer.apple.com/account/resources/devices/list">https://developer.apple.com/account/resources/devices/list</a></li>
</ul>
<h4 id="ad-hoc-용-match-인증서-발급">Ad-hoc 용 Match 인증서 발급</h4>
<pre><code>fastlane match adhoc --git_branch [브랜치명]</code></pre><p>해당 명령어를 수행한 뒤 git_url 로 들어가보면 <code>profiles/adhoc</code> 에 프로비저닝 프로필이 생성된 것을 확인할 수 있다.</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/6a19a690-0622-4e0e-8145-df228f1fc810/image.png" width="600">
  <p style="font-size: 14px; color: gray;">▲ 그림 11. Ad-Hoc 프로비저닝 프로필</p>
</div>


<blockquote>
<p>이미 Ad-hoc 인증서 발급했는데, UUID 를 추가해야 되는 경우 ?</p>
</blockquote>
<p>인증서 발급 이후 테스트 기기의 UUID 를 추가해야 하는 경우 인증서를 새로 업데이트 해줘야 한다. 따라서 위에서 언급한 <a href="https://developer.apple.com/account/resources/devices/list">Device List</a> 에서 UUID 추가한 후 match 인증서 발급을 덮어쓰기 하면 된다. </p>
<pre><code>fastlane match adhoc --force --git_branch [브랜치명]</code></pre><hr>
<h3 id="fastfile-작성">Fastfile 작성</h3>
<h4 id="--build_appscheme-configuration">- build_app(scheme, configuration)</h4>
<p>iOS 의 Build Scheme 과 Configuration 은 다음과 같다.</p>
<blockquote>
<ul>
<li>Build Scheme<ul>
<li>development</li>
<li>hotfix</li>
<li>sandbox</li>
<li>production
<br></br></li>
</ul>
</li>
<li>Build Configuration<ul>
<li>Debug-development</li>
<li>Debug-hotfix</li>
<li>Release-sandbox<ul>
<li>Release-production</li>
</ul>
</li>
</ul>
</li>
</ul>
</blockquote>
<p>lane 2, 3, 4의 경우 <code>Build Scheme: production &amp;&amp; Build Configuration: Release-production</code> 을 공통으로 사용하기 때문에 scheme 과 configuration 은 해당 값대로 할당해주었고, parameter 로 받는 <code>export_method</code> 의 경우 default 는 <code>&quot;app-store&quot;</code> 이고 Firebase App Distribution 에 프로덕션 앱을 올리는 경우를 고려하여 이 경우는 <code>export_method = &quot;ad-hoc&quot;</code> 을 명시하도록 했다.</p>
<pre><code class="language-ruby">## 공통 빌드 함수 - lane 2, lane 3, lane 4
def build_ios_app(export_method = &quot;app-store&quot;)
    build_app(
      clean: true,
      workspace: &quot;Runner.xcworkspace&quot;,
      output_directory: &quot;./ipa&quot;,
      scheme: &quot;production&quot;,
      configuration: &quot;Release-production&quot;,
      export_method: export_method
    )
    return &quot;./ipa/Runner.ipa&quot;
  end</code></pre>
<h4 id="--lane-추가">- lane 추가</h4>
<p>lane 구성은 Android 와 동일하게 4개로 다시 언급하면,</p>
<blockquote>
<p>Lane 1: 알파 테스트 -&gt; Firebase 에 sandbox ipa 업로드
Lane 2: 알파 테스트 -&gt; Firebase 에 production ipa 업로드
Lane 3: 베타 테스트 -&gt; TestFlight에 production ipa 업로드
Lane 4: 제품 출시 및 버전 업데이트 &gt; App Store 에 production ipa 업로드</p>
</blockquote>
<p>Lane 1과 Lane 2 가 추가 되었으므로 이에 맞는 lane 을 추가해주고, 나머지 두 lane 은 <a href="https://velog.io/@sangjin-hash/iOS-Fastlane-%EA%B3%BC-Github-Action%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%98%EC%97%AC-%EC%9A%B4%EC%98%81-%EC%A4%91%EC%9D%B8-%EC%84%9C%EB%B9%84%EC%8A%A4%EC%97%90-%EB%B0%B0%ED%8F%AC-%EC%9E%90%EB%8F%99%ED%99%94-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0-iOS1">기존 게시글</a>에서 <code>build_app</code> 함수만 별도로 빼고 나머지는 크게 변경된 점은 없다.</p>
<h4 id="--lane-1-알파-테스트---firebase-에-sandbox-ipa-업로드">- Lane 1: 알파 테스트 -&gt; Firebase 에 sandbox ipa 업로드</h4>
<p>위에서 <code>--dart-define=FLAVOR=</code> 를 <code>main.dart</code> 에 전달하기 위한 방법으로 2가지를 소개했고, 결국 방법 2(명령어에 포함시켜 직접 빌드)를 이용하여 Fastfile 을 작성한다고 언급했었다.</p>
<p>주석처리가 된 부분은 방법 1(xcconfig 를 통한 전달)으로 빌드 오류가 발생하지 않으면 해당 스크립트를 사용하면 된다. 다음은 방법 2로 구현했을 경우 다음 스크립트를 수행하는데, </p>
<p>sandbox 환경의 iOS 앱을 Flutter CLI와 Xcode를 통해 빌드하고 .xcarchive 파일을 생성한 후, 이를 기반으로 <code>.ipa</code> 파일을 export하여 Firebase App Distribution에 배포한다. <code>.xcarchive</code>는 Xcode에서 .ipa를 만들기 위한 중간 산출물로, 서명 및 export 설정에 필요한 메타데이터를 포함하고 있다. 이 과정을 통해 <code>Ad-Hoc</code> 방식의 <code>.ipa</code>를 생성하고, QA 그룹에 내부 테스트용으로 안정적으로 배포할 수 있다.</p>
<pre><code class="language-ruby">...

# Lane 1: [sandbox] 환경 - 알파 테스트용 ipa -&gt; Firebase 배포
#   desc &quot;Deploy iOS [sandbox] to Firebase App Distribution&quot;
#   lane :deploy_sandbox_to_firebase do |options|
#     do_signing(
#       key_id: options[:key_id],
#       issuer_id: options[:issuer_id],
#       key_content: options[:key_content],
#       app_identifier: options[:app_identifier],
#       signing_type: &quot;adhoc&quot;
#     )
#
#     ipa_path = build_ios_app(&quot;sandbox&quot;, &quot;Release-sandbox&quot;, &quot;ad-hoc&quot;)
#     version, build_number = get_app_version()
#
#     firebase_app_distribution(
#       app: ENV[&#39;FIREBASE_IOS_APP_ID_DEV&#39;],
#       ipa_path: ipa_path,
#       groups: &quot;qa&quot;,
#       release_notes: &quot;[sandbox] Version: #{version}+#{build_number} 내부 테스트)&quot;
#     )
#   end

  # Lane 1: [sandbox] 환경 - 알파 테스트용 ipa -&gt; Firebase 배포
  desc &quot;Deploy iOS [sandbox] to Firebase App Distribution&quot;
  lane :deploy_sandbox_to_firebase do |options|
    do_signing(
      key_id: options[:key_id],
      issuer_id: options[:issuer_id],
      key_content: options[:key_content],
      app_identifier: options[:app_identifier],
      signing_type: &quot;adhoc&quot;
    )

    # 1. Flutter CLI로 빌드 (FLAVOR=dev 반영)
    sh(&quot;flutter build ios --flavor sandbox --dart-define=FLAVOR=dev --release&quot;)

    # 2. 생성된 .ipa 경로 지정 (기본 생성 위치는 `build/ios/iphoneos/Runner.app`)
    # Fastlane에서 ipa 파일로 export 하기 위해 .xcarchive 경로 지정
    archive_path = &quot;../../build/ios/archive/Runner.xcarchive&quot;
    ipa_output_path = &quot;./ipa&quot;

    # 3. .xcarchive가 존재하지 않으면, 생성 (선택)
    sh(&quot;xcodebuild -workspace ../Runner.xcworkspace -scheme sandbox -configuration Release-sandbox -archivePath #{archive_path} archive&quot;)

    # 4. .ipa export (Ad-Hoc 방식으로)
    ipa_path = gym(
      export_method: &quot;ad-hoc&quot;,
      archive_path: archive_path,
      output_directory: ipa_output_path,
      output_name: &quot;Runner.ipa&quot;,
      scheme: &quot;sandbox&quot;
    )

    # 5. Version info 추출
    version, build_number = get_app_version()

    # 6. Firebase에 업로드
    firebase_app_distribution(
      app: ENV[&#39;FIREBASE_IOS_APP_ID_DEV&#39;],
      ipa_path: ipa_path,
      groups: &quot;qa&quot;,
      release_notes: &quot;[sandbox] Version: #{version}+#{build_number} 내부 테스트)&quot;
    )
  end</code></pre>
<ul>
<li>최종 Fastfile <pre><code class="language-ruby">default_platform(:ios)
</code></pre>
</li>
</ul>
<p>platform :ios do</p>
<h2 id="ios-signing-설정-api-key--match">iOS Signing 설정 (API Key &amp; Match)</h2>
<p>  desc &quot;Do Signing for Deploy iOS APP&quot;
  lane :do_signing do |options|
    api_key = app_store_connect_api_key(
      key_id: options[:key_id],
      issuer_id: options[:issuer_id],
      key_content: options[:key_content],
      is_key_content_base64: true
    )</p>
<pre><code>match(
  type: options[:signing_type] || &quot;appstore&quot;,   # default: appstore
  app_identifier: options[:app_identifier],
  api_key: api_key,
  git_branch: &#39;release&#39;,
  readonly: true
)</code></pre><p>  end</p>
<h2 id="version-정보-추출">Version 정보 추출</h2>
<p>  def get_app_version
    pubspec = File.read(&quot;../../pubspec.yaml&quot;)
    version_line = pubspec.lines.find { |line| line.start_with?(&quot;version:&quot;) }
    version, build_number = version_line.split(&quot;:&quot;).last.strip.split(&quot;+&quot;)
    return version, build_number
  end</p>
<p>  def build_ios_app(export_method = &quot;app-store&quot;)
    build_app(
      clean: true,
      workspace: &quot;Runner.xcworkspace&quot;,
      output_directory: &quot;./ipa&quot;,
      scheme: &quot;production&quot;,
      configuration: &quot;Release-production&quot;,
      export_method: export_method
    )
    return &quot;./ipa/Runner.ipa&quot;
  end</p>
<h1 id="lane-1-sandbox-환경---알파-테스트용-ipa---firebase-배포">Lane 1: [sandbox] 환경 - 알파 테스트용 ipa -&gt; Firebase 배포</h1>
<h1 id="desc-deploy-ios-sandbox-to-firebase-app-distribution">desc &quot;Deploy iOS [sandbox] to Firebase App Distribution&quot;</h1>
<h1 id="lane-deploy_sandbox_to_firebase-do-options">lane :deploy_sandbox_to_firebase do |options|</h1>
<h1 id="do_signing">do_signing(</h1>
<h1 id="key_id-optionskey_id">key_id: options[:key_id],</h1>
<h1 id="issuer_id-optionsissuer_id">issuer_id: options[:issuer_id],</h1>
<h1 id="key_content-optionskey_content">key_content: options[:key_content],</h1>
<h1 id="app_identifier-optionsapp_identifier">app_identifier: options[:app_identifier],</h1>
<h1 id="signing_type-adhoc">signing_type: &quot;adhoc&quot;</h1>
<h1 id="">)</h1>
<p>#</p>
<h1 id="ipa_path--build_ios_appsandbox-release-sandbox-ad-hoc">ipa_path = build_ios_app(&quot;sandbox&quot;, &quot;Release-sandbox&quot;, &quot;ad-hoc&quot;)</h1>
<h1 id="version-build_number--get_app_version">version, build_number = get_app_version()</h1>
<p>#</p>
<h1 id="firebase_app_distribution">firebase_app_distribution(</h1>
<h1 id="app-envfirebase_ios_app_id_dev">app: ENV[&#39;FIREBASE_IOS_APP_ID_DEV&#39;],</h1>
<h1 id="ipa_path-ipa_path">ipa_path: ipa_path,</h1>
<h1 id="groups-qa">groups: &quot;qa&quot;,</h1>
<h1 id="release_notes-sandbox-version-versionbuild_number-내부-테스트">release_notes: &quot;[sandbox] Version: #{version}+#{build_number} 내부 테스트)&quot;</h1>
<h1 id="-1">)</h1>
<h1 id="end">end</h1>
<h1 id="lane-1-sandbox-환경---알파-테스트용-ipa---firebase-배포-1">Lane 1: [sandbox] 환경 - 알파 테스트용 ipa -&gt; Firebase 배포</h1>
<p>  desc &quot;Deploy iOS [sandbox] to Firebase App Distribution&quot;
  lane :deploy_sandbox_to_firebase do |options|
    do_signing(
      key_id: options[:key_id],
      issuer_id: options[:issuer_id],
      key_content: options[:key_content],
      app_identifier: options[:app_identifier],
      signing_type: &quot;adhoc&quot;
    )</p>
<pre><code># 1. Flutter CLI로 빌드 (FLAVOR=dev 반영)
sh(&quot;flutter build ios --flavor sandbox --dart-define=FLAVOR=dev --release&quot;)

# 2. 생성된 .ipa 경로 지정 (기본 생성 위치는 `build/ios/iphoneos/Runner.app`)
# Fastlane에서 ipa 파일로 export 하기 위해 .xcarchive 경로 지정
archive_path = &quot;../../build/ios/archive/Runner.xcarchive&quot;
ipa_output_path = &quot;./ipa&quot;

# 3. .xcarchive가 존재하지 않으면, 생성 (선택)
sh(&quot;xcodebuild -workspace ../Runner.xcworkspace -scheme sandbox -configuration Release-sandbox -archivePath #{archive_path} archive&quot;)

# 4. .ipa export (Ad-Hoc 방식으로)
ipa_path = gym(
  export_method: &quot;ad-hoc&quot;,
  archive_path: archive_path,
  output_directory: ipa_output_path,
  output_name: &quot;Runner.ipa&quot;,
  scheme: &quot;sandbox&quot;
)

# 5. Version info 추출
version, build_number = get_app_version()

# 6. Firebase에 업로드
firebase_app_distribution(
  app: ENV[&#39;FIREBASE_IOS_APP_ID_DEV&#39;],
  ipa_path: ipa_path,
  groups: &quot;qa&quot;,
  release_notes: &quot;[sandbox] Version: #{version}+#{build_number} 내부 테스트)&quot;
)</code></pre><p>  end</p>
<h1 id="lane-2-production-환경---알파-테스트용-ipa---firebase-배포">Lane 2: [production] 환경 - 알파 테스트용 ipa -&gt; Firebase 배포</h1>
<p>  desc &quot;Deploy iOS [production] to Firebase App Distribution&quot;
  lane :deploy_prod_to_firebase do |options|
    do_signing(
      key_id: options[:key_id],
      issuer_id: options[:issuer_id],
      key_content: options[:key_content],
      app_identifier: options[:app_identifier],
      signing_type: &quot;adhoc&quot;
    )</p>
<pre><code>ipa_path = build_ios_app(&quot;ad-hoc&quot;)
version, build_number = get_app_version()

firebase_app_distribution(
  app: ENV[&#39;FIREBASE_IOS_APP_ID_PRODUCTION&#39;],
  ipa_path: ipa_path,
  groups: &quot;qa&quot;,
  release_notes: &quot;[production] Version: #{version}+#{build_number} 배포 전 내부 테스트)&quot;
)</code></pre><p>  end</p>
<h1 id="lane-3-production-환경---베타-테스트용-ipa---testflight-배포">Lane 3: [production] 환경 - 베타 테스트용 ipa -&gt; TestFlight 배포</h1>
<p>  desc &quot;Deploy iOS [production] to TestFlight&quot;
  lane :deploy_to_testflight do |options|
    do_signing(
      key_id: options[:key_id],
      issuer_id: options[:issuer_id],
      key_content: options[:key_content],
      app_identifier: options[:app_identifier],
      signing_type: &quot;appstore&quot;
    )</p>
<pre><code>build_ios_app()

upload_to_testflight(
  skip_waiting_for_build_processing: true
)</code></pre><p>  end</p>
<h1 id="lane-4-production-환경---app-store-배포">Lane 4: [production] 환경 - App Store 배포</h1>
<p>  desc &quot;Deploy iOS [production] to App Store&quot;
  lane :deploy_to_app_store do |options|
    do_signing(
      key_id: options[:key_id],
      issuer_id: options[:issuer_id],
      key_content: options[:key_content],
      app_identifier: options[:app_identifier],
      signing_type: &quot;appstore&quot;
    )</p>
<pre><code>build_ios_app()

upload_to_app_store(
  automatic_release: false,  # 자동 출시 비활성화
  submit_for_review: false,  # 자동 심사 제출 비활성화
  skip_screenshots: true,    # 스크린샷 업로드 건너뛰기
  skip_metadata: true,       # 메타데이터 업데이트 건너뛰기
  skip_app_version_update: true, # App Store의 버전 자동 업데이트 비활성화
  force: true,                    # Preview 확인 건너뛰기
  precheck_include_in_app_purchases: false # In-App Purchase 검사 건너뛰기
)</code></pre><p>  end</p>
<p>end</p>
<pre><code>---
### Github Actions Workflow 수정
#### - GoogleService-Info.plist 문제
Run script 에서 Build Configuration 에 따라서 연동되어 있는 Firebase 프로젝트와 관련 Info.plist 를 복사하는 스크립트를 추가했었고, runner 안에 프로젝트에서 복사가 잘 되었음을 확인하였다.

&lt;div align=&quot;center&quot;&gt;
  &lt;img src=&quot;https://velog.velcdn.com/images/sangjin-hash/post/4472fb1d-1dcd-4d7b-b57e-db57350a96bf/image.png&quot; width=&quot;600&quot;&gt;
  &lt;p style=&quot;font-size: 14px; color: gray;&quot;&gt;▲ 그림 12. GoogleService-Info.plist 찾을 수 없음&lt;/p&gt;
&lt;/div&gt;

그러나 위의 문제가 발생했었고 이러한 원인으로는 Xcode는 GoogleService-Info.plist를 빌드 시작 전부터 존재해야 하는 정적 파일로 간주하는데, 기존에는 빌드 도중에 Run Script에서 복사하고 있으니, Xcode는 이를 빌드 입력 파일로 인식하지 못하는 것이다.

따라서 옵션에 따른 배포 실행할 때 정적으로 해당 파일을 생성해주는 로직이 필요하여 다음과 같이 수정하였다.

나머지 lane 을 실행하는 것은 Android 에서 옵션에 따른 배포 실행과 동일하다.

```yaml
      - name: 옵션에 따른 배포 실행
        working-directory: ios
        env:
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ secrets.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }}
          KEY_ID: ${{ secrets.ASC_KEY_ID }}
          ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
          KEY_CONTENT: ${{ secrets.ASC_KEY_P8 }}
          APP_IDENTIFIER: ${{ secrets.APP_IDENTIFIER }}
          FIREBASE_APP_ID_SANDBOX: ${{ secrets.FIREBASE_APP_ID_SANDBOX }}
          FIREBASE_APP_ID_PRODUCTION: ${{ secrets.FIREBASE_APP_ID_PRODUCTION }}
        run: |
          COMMIT_MSG=$(git log -1 --pretty=%B)
          if [[ &quot;$COMMIT_MSG&quot; =~ deploy:[1-4] ]]; then
            DEPLOY_OPTION=$(echo &quot;$COMMIT_MSG&quot; | grep -o &#39;deploy:[1-4]&#39; | cut -d&#39;:&#39; -f2)
          else
            echo &quot;배포 옵션이 지정되지 않았습니다. 기본 옵션(1) 사용&quot;
            DEPLOY_OPTION=&quot;1&quot;
          fi
          echo &quot;🔍 선택된 배포 옵션: $DEPLOY_OPTION&quot;

          case &quot;$DEPLOY_OPTION&quot; in
            &quot;1&quot;)
              # 개발 환경의 Firebase 연동 관련 Info.plist 생성
              echo &quot;${{ secrets.IOS_GOOGLE_SERVICE_DEV_PLIST }}&quot; &gt; Runner/GoogleService-Info.plist
              echo &quot;[sandbox] Firebase App Distribution 배포 시작&quot;
              fastlane deploy_sandbox_to_firebase key_id:$KEY_ID issuer_id:$ISSUER_ID key_content:$KEY_CONTENT app_identifier:$APP_IDENTIFIER
              ;;
            &quot;2&quot;)
              # 운영 환경의 Firebase 연동 관련 Info.plist 생성
              echo &quot;${{ secrets.IOS_GOOGLE_SERVICE_PROD_PLIST }}&quot; &gt; Runner/GoogleService-Info.plist
              echo &quot;[production] Firebase App Distribution 배포 시작&quot;
              fastlane deploy_prod_to_firebase key_id:$KEY_ID issuer_id:$ISSUER_ID key_content:$KEY_CONTENT app_identifier:$APP_IDENTIFIER
              ;;
            &quot;3&quot;)
              # 운영 환경의 Firebase 연동 관련 Info.plist 생성
              echo &quot;${{ secrets.IOS_GOOGLE_SERVICE_PROD_PLIST }}&quot; &gt; Runner/GoogleService-Info.plist
              echo &quot;[production] TestFlight 배포 시작&quot;
              fastlane deploy_to_testflight key_id:$KEY_ID issuer_id:$ISSUER_ID key_content:$KEY_CONTENT app_identifier:$APP_IDENTIFIER
              ;;
            &quot;4&quot;)
              # 운영 환경의 Firebase 연동 관련 Info.plist 생성
              echo &quot;${{ secrets.IOS_GOOGLE_SERVICE_PROD_PLIST }}&quot; &gt; Runner/GoogleService-Info.plist
              echo &quot;[production] App Store 배포 시작&quot;
              fastlane deploy_to_app_store key_id:$KEY_ID issuer_id:$ISSUER_ID key_content:$KEY_CONTENT app_identifier:$APP_IDENTIFIER
              ;;
            *)
              echo &quot;❌ Invalid deployment option selected: $DEPLOY_OPTION&quot;
              exit 1
              ;;
          esac</code></pre><hr>
<h3 id="결과">결과</h3>
<h4 id="--option-1-sandbox-환경-firebase-배포">- Option 1: Sandbox 환경 Firebase 배포</h4>
<p>아무래도 <code>.ipa</code> 와 <code>.xcarchive</code> 를 Flutter CLI 를 이용하여 만들다보니 다른 Lane 에 비해 로그 출력도 2배 이상 많고, 시간 또한 더 오래걸렸다(15분). <code>.xcconfig</code> 로 <code>--dart-define=FLAVOR</code> 를 전달하는 첫번째 방법을 이용하고 Fastfile에서 fastlane core 에서 지원하는 <code>build_app</code> 을 사용했다면 시간이 더 단축되었을 것이다.</p>
<p>따라서 이러한 이유로 인해 방법 2(명령어에 포함시켜 직접 빌드) 보다 방법 1(xcconfig 를 통한 전달)을 권장했던 것이다.</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/df04fe2d-968b-4e7e-aff5-4d15ee4819b6/image.png" width="400">
  <p style="font-size: 14px; color: gray;">▲ 그림 13. [Option 1] 실행 결과</p>
</div>

<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/039e9145-11c1-4bd1-9231-6479b25c852a/image.png" width="600">
  <p style="font-size: 14px; color: gray;">▲ 그림 14. 'sandbox' 용 Firebase 업로드 결과
</div>

<h4 id="--option-2-production-환경-firebase-배포">- Option 2: production 환경 Firebase 배포</h4>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/4a721709-31e4-4879-b05b-4ff4ae55f940/image.png" width="400">
  <p style="font-size: 14px; color: gray;">▲ 그림 15. [Option 2] 실행 결과
</div>

<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/a2c60dd5-ca4e-4c03-92cb-ba1e1fdeb0aa/image.png" width="600">
  <p style="font-size: 14px; color: gray;">▲ 그림 16. 'sandbox' 용 Firebase 업로드 결과
</div>



<hr>
<h3 id="결론">결론</h3>
<p>다음은 기존의 배포 파이프라인에서 <code>Firebase App Distribution</code> 도입을 통한 알파 테스트 구축 및 개선한 결과이다.</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/8f4b6a9a-37f5-4c83-92e4-7dc414511c95/image.png" width="500">
  <p style="font-size: 14px; color: gray;">▲ 그림 17. 배포 파이프라인 개선</p>
</div>

<p>이를 통해 도입된 Firebase App Distribution 기반의 테스트 배포는 다음과 같은 실질적 이점을 제공한다:</p>
<blockquote>
<ol>
<li>배포 속도 향상 및 테스트 피드백 루프 단축<ul>
<li>Play Console의 내부 테스트 채널은 빌드 반영까지 평균 30분~1시간 가량의 지연이 발생하지만, Firebase App Distribution을 활용할 경우 업로드 즉시 배포가 가능하여 알파 테스터의 피드백을 더 빠르게 수집할 수 있다.</li>
<li>결과적으로 QA 반복 주기를 단축하고, 기능 안정화 기간을 앞당길 수 있다.
<br></br></li>
</ul>
</li>
<li>테스트 대상에 따른 배포 채널의 전략적 분리<ul>
<li>내부 검증(Firebase): 개발자, QA 인력 등 조직 내부 인원이 주도하는 테스트는 Firebase를 통해 즉각적인 배포와 회수, 버전 전환이 가능하며, Slack 등 협업 도구와도 쉽게 연동 가능하다.</li>
<li>외부 검증(Play Console): 실 사용자 기반의 베타 테스트나 외부 파트너 대상 검증은 Play Console을 통해 안정적으로 운영함으로써 테스트 안정성과 정책 준수, 사용자 경험 측면에서 유리하다.
<br></br></li>
</ul>
</li>
<li>테스트 단계별 품질 게이트 확보<ul>
<li>알파(Firebase) → 베타(Play Console) → 프로덕션 배포로 이어지는 명확한 릴리즈 파이프라인을 구축함으로써, 각 단계마다 품질 검증 기준을 수립하고 이탈 없는 배포 전략을 운용할 수 있다.</li>
</ul>
</li>
</ol>
</blockquote>
<hr>
<h3 id="reference">Reference</h3>
<ul>
<li><a href="https://firebase.google.com/docs/app-distribution/ios/distribute-fastlane?hl=ko">https://firebase.google.com/docs/app-distribution/ios/distribute-fastlane?hl=ko</a></li>
<li><a href="https://firebase.google.com/codelabs/appdistribution-udid-collection?hl=ko#2">https://firebase.google.com/codelabs/appdistribution-udid-collection?hl=ko#2</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] 배포 파이프라인 개선(1)]]></title>
            <link>https://velog.io/@sangjin-hash/Flutter-%EB%B0%B0%ED%8F%AC-%ED%8C%8C%EC%9D%B4%ED%94%84%EB%9D%BC%EC%9D%B8-%EA%B0%9C%EC%84%A01-Android-%ED%8E%B8</link>
            <guid>https://velog.io/@sangjin-hash/Flutter-%EB%B0%B0%ED%8F%AC-%ED%8C%8C%EC%9D%B4%ED%94%84%EB%9D%BC%EC%9D%B8-%EA%B0%9C%EC%84%A01-Android-%ED%8E%B8</guid>
            <pubDate>Sun, 01 Jun 2025 08:21:22 GMT</pubDate>
            <description><![CDATA[<h3 id="개요">개요</h3>
<p>기존의 배포 파이프라인은 다음과 같다.</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/e747a086-607e-45c8-95e8-a56ac3d2478a/image.png" width="700">
  <p style="font-size: 14px; color: gray;">▲ 그림 1. 기존의 CI/CD 파이프라인</p>
</div>

<p>Flavor 를 이용하여 운영(prod) 과 개발(dev) 환경을 운영하였고, Android 에서는 <a href="https://velog.io/@sangjin-hash/%EB%8B%A4%EC%A4%91-Firebase-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%97%B0%EB%8F%99-%EC%8B%9C-%EC%A4%91%EB%B3%B5-SHA-1-%EB%A1%9C-%EC%9D%B8%ED%95%9C-%EA%B5%AC%EA%B8%80-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EB%AC%B8%EC%A0%9C">개발 환경으로 된 앱을 내부 테스트에 업로드가 불가</a>한 문제로 <a href="https://velog.io/@sangjin-hash/Android-%EB%B0%B0%ED%8F%AC-%ED%99%98%EA%B2%BD%EC%97%90-%EB%94%B0%EB%9D%BC-%EB%82%B4%EB%B6%80-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EA%B5%AC%EC%84%B1%ED%95%98%EA%B8%B0-Firebase-App-Distribution">Firebase App Distribution</a> 을 도입했고,이 과정에서 4가지의 환경을 구성하였다.</p>
<blockquote>
<p>development: Flavor - dev &amp;&amp; Build Type: Debug
hotfix: Flavor - prod &amp;&amp; Build Type: Debug
sandbox: Flavor - dev &amp;&amp; Build Type: Release
production: Flavor - prod &amp;&amp; Build Type: Release</p>
</blockquote>
<p>이러한 네이밍을 지은 이유는 &#39;Firebase App Distribution&#39; 게시글에서 확인할 수 있다.</p>
<p>따라서 본 게시글과 다음 포스트할 예정인 iOS 편에서 목표는 기존 배포 파이프라인에서 공통적으로 <code>Firebase App Distribution</code> 을 적용하고, iOS 의 경우 Build Scheme 을 위 4가지 빌드 환경으로 구성하는 방법론에 대해 알아볼 예정이다.</p>
<hr>
<h3 id="fastlane-설정">Fastlane 설정</h3>
<p>우선 다음 명령어를 통해 fastlane 에 firebase_app_distribution 플러그인을 추가해줘야 한다. </p>
<pre><code class="language-bash">fastlane add_plugin firebase_app_distribution</code></pre>
<p>이전 게시글에서 이미 GCP 에서 fastlane 전용 서비스 계정을 이미 만들었으니 이에 대한 내용은 생략하겠다. </p>
<p>Firebase App Distribution이나 Google API를 사용할 때는 인증이 필요한데, 이 때 Google은 기본 인증 방식인 <code>ADC(Application Default Credentials)</code>를 제공한다. ADC는 <code>GOOGLE_APPLICATION_CREDENTIALS</code> 환경 변수에 서비스 계정 키(JSON 파일)의 경로를 설정하면, 해당 파일을 자동으로 참조해 인증 토큰을 생성하고 CLI나 Fastlane 같은 도구들이 이를 통해 안전하게 API 요청을 수행할 수 있도록 해준다. 따라서 인증을 자동화하고 수동 입력 없이 배포나 빌드를 진행하려면 이 환경 변수 설정이 필수적이다.</p>
<pre><code> export GOOGLE_APPLICATION_CREDENTIALS=/absolute/path/to/credentials/file.json</code></pre><hr>
<h3 id="fastfile-수정">Fastfile 수정</h3>
<h4 id="--build_appflavor">- build_app(flavor)</h4>
<p>기존의 <code>build_app(flavor)</code> 는 다음과 같다.</p>
<ul>
<li><p>기존</p>
<pre><code class="language-ruby">platform :android do
def build_app(flavor)
  if flavor == &quot;dev&quot;
    sh &quot;flutter build appbundle --flavor dev --release --dart-define=FLAVOR=dev&quot;
    return &quot;../build/app/outputs/bundle/devRelease/app-dev-release.aab&quot;

  elsif flavor == &quot;prod&quot;
    sh &quot;flutter build appbundle --flavor prod --release --dart-define=FLAVOR=prod&quot;
    return &quot;../build/app/outputs/bundle/prodRelease/app-prod-release.aab&quot;

  else
    UI.user_error!(&quot;Unknown flavor: #{flavor}&quot;)
  end
end</code></pre>
</li>
</ul>
<p>2개의 환경에서 4개로 변경되었기 때문에 네이밍 변경뿐 만 아니라 2개의 환경도 추가해줘야 한다.</p>
<ul>
<li><p>변경</p>
<pre><code class="language-ruby">def build_app(flavor)
  if flavor == &quot;sandbox&quot;
    sh &quot;flutter build apk --flavor sandbox --release --dart-define=FLAVOR=dev&quot;
    return &quot;../build/app/outputs/flutter-apk/app-sandbox-release.apk&quot;

  elsif flavor == &quot;production-firebase&quot;
      sh &quot;flutter build apk --flavor production --release --dart-define=FLAVOR=prod&quot;
      return &quot;../build/app/outputs/flutter-apk/app-production-release.apk&quot;

  elsif flavor == &quot;production&quot;
    sh &quot;flutter build appbundle --flavor production --release --dart-define=FLAVOR=prod&quot;
    return &quot;../build/app/outputs/bundle/productionRelease/app-production-release.aab&quot;

  else
    UI.user_error!(&quot;Unknown flavor: #{flavor}&quot;)
  end
end</code></pre>
</li>
</ul>
<p>우선 <code>flavor == &quot;production-firebase&quot;</code> 와 <code>flavor == &quot;production&quot;</code> 이 분기 처리된 이유 먼저 봐야하는데, &#39;production&#39; 환경은 운영 환경에서의 실제 제품이기 때문에 이는 개발자가 만든 업로드용 키와 Play Console 에서 부여한 인증키 2개로 구성되어 있다. 이를 가지고 <code>.apk</code> 가 아닌 <code>.aab</code> 앱 번들 파일을 만들어 업로드가 가능한 것이다. </p>
<p>Firebase App Distribution 에서는 apk 와 aab 업로드 둘 다 지원하고 있었고, 제품 출시 때 앱 번들을 사용하니 Firebase 에도 aab 를 업로드 하려고 했으나, </p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/b8ca76fe-7ae3-4465-ae66-cdb5905c4023/image.png" width="500">
  <p style="font-size: 14px; color: gray;">▲ 그림 2. Link 에러</p>
</div>

<p>테스트 과정에서 계속 연결 에러가 발생했고, 이러한 원인으로는</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/30b6bdd9-c62c-4f34-8d8e-e756992fa0e2/image.png" width="400">
  <p style="font-size: 14px; color: gray;">▲ 그림 3. Firebase 설정 오류</p>
</div>

<p>Firebase 콘솔에서 앱을 찾을 수 없다는 에러가 발생해서 연결 자체가 되지 않아 발생하였다. 이는 Firebase Console &gt; 프로젝트 설정 &gt; 통합 에서 확인할 수 있다. 해결 방법으로는 Firebase 프로젝트 설정에 Play Console 에서 부여한 서명 키의 SHA-1, SHA-256 을 넣어주면 된다곤 하지만, 이미 이전부터 추가가 된 상태였었고 연결 오류에 대한 추가적인 원인을 찾기 어려워 Firebase 에는 apk 를 업로드 하는 방식으로 우회하였다.(만약에 해당 문제가 발생하지 않은 경우라면 운영 환경의 앱일 경우 Firebase 에 aab 파일을 업로드 하는 것을 권장한다)</p>
<p>따라서 분기처리의 의미로는 다음과 같다.</p>
<blockquote>
<p>production-firebase: Firebase -&gt; APK 업로드
production: Console 의 내부테스트, 프로덕션 -&gt; AAB 업로드</p>
</blockquote>
<h4 id="lane-추가">lane 추가</h4>
<p>lane 의 구성은 총 4개로, </p>
<blockquote>
<p>Lane 1: 알파 테스트 -&gt; Firebase 에 sandbox APK 업로드
Lane 2: 알파 테스트 -&gt; Firebase 에 production APK 업로드
Lane 3: 베타 테스트 -&gt; Play Console 의 내부테스트에 production aab 업로드
Lane 4: 제품 출시 및 버전 업데이트 &gt; Play Console 의 프로덕션에 production aab 업로드</p>
</blockquote>
<p>추가가 된 함수는 <code>get_app_version</code> 인데, <code>pubspec.yaml</code> 을 읽어 버전 정보를 가져오는 함수이다. 이는 단순히 Firebase App Distribution 에서 출시 노트에 해당 버전을 기재하기 위한 용도이다.</p>
<p>Lane 3,4는 기존의 Fastfile 에 있었던 내용과 동일하고, Lane 1,2가 추가 되었다.</p>
<pre><code class="language-ruby">  def get_app_version
      pubspec = File.read(&quot;../../pubspec.yaml&quot;)
      version_line = pubspec.lines.find { |line| line.start_with?(&quot;version:&quot;) }
      version, build_number = version_line.split(&quot;:&quot;).last.strip.split(&quot;+&quot;)
      return version, build_number
    end

  ... (build_app 생략)

# Lane 1: [sandbox] 환경 - 알파 테스트용 apk -&gt; Firebase 배포
  desc &quot;Deploy sandbox .apk to Firebase App Distribution&quot;
  lane :deploy_sandbox_to_firebase do
    apk_path = build_app(&quot;sandbox&quot;)
    version, build_number = get_app_version()

    firebase_app_distribution(
      app: ENV[&#39;FIREBASE_APP_ID_SANDBOX&#39;],
      apk_path: apk_path,
      groups: &quot;qa&quot;,
      release_notes: &quot;[sandbox] Version: #{version}+#{build_number} 내부 테스트)&quot;
    )
  end

  # Lane 2: [production] 환경 - 알파 테스트용 aab -&gt; Firebase 배포
  desc &quot;Deploy production .aab to Firebase App Distribution&quot;
  lane :deploy_prod_to_firebase do
    apk_path = build_app(&quot;production-firebase&quot;)
    version, build_number = get_app_version()

    firebase_app_distribution(
      app: ENV[&#39;FIREBASE_APP_ID_PRODUCTION&#39;],
      apk_path: apk_path,
      groups: &quot;qa&quot;,
      release_notes: &quot;[production] Version: #{version}+#{build_number} 배포 전 내부 테스트)&quot;,
    )
  end

# [Lane 2] aab 업로드 로직
#   desc &quot;Deploy production .aab to Firebase App Distribution&quot;
#     lane :deploy_prod_to_firebase do
#       aab_path = build_app(&quot;production&quot;)
#       version, build_number = get_app_version()
#
#       firebase_app_distribution(
#         app: ENV[&#39;FIREBASE_APP_ID_PRODUCTION&#39;],
#         android_artifact_path: aab_path,
#         android_artifact_type: &quot;AAB&quot;,
#         groups: &quot;qa&quot;,
#         release_notes: &quot;[production] Version: #{version}+#{build_number} 배포 전 내부 테스트)&quot;,
#         service_credentials_file: &quot;../gachiga-serviceAccount.json&quot;,
#       )
#     end

  # Lane 3: [production] 환경 - 베타 테스트용 aab -&gt; Play Console 내부 테스트 배포
  desc &quot;Deploy production .aab to Play Console Internal Test&quot;
  lane :deploy_prod_to_internal_test do
    aab_path = build_app(&quot;production&quot;)

    upload_to_play_store(
      track: &quot;internal&quot;,
      aab: aab_path
    )
  end

  # Lane 4: [production] 환경 - 프로덕션 배포
  desc &quot;Deploy production .aab to Play Console Production&quot;
  lane :deploy_prod_to_production do
    aab_path = build_app(&quot;production&quot;)

    upload_to_play_store(
      track: &quot;production&quot;,
      aab: aab_path,
      skip_upload_metadata: true,
      skip_upload_images: true,
      skip_upload_screenshots: true,
      skip_upload_changelogs: true,
    )
  end</code></pre>
<hr>
<h3 id="github-actions-workflow-수정">Github Actions Workflow 수정</h3>
<h4 id="--github-actions-secrets-추가">- Github Actions Secrets 추가</h4>
<p>sandbox 환경이 추가되었기 때문에 로컬과 동일한 환경 구성을 위해 sandbox 용 앱 서명 키에 대한 keystore 를 secrets 에 추가해줘야 한다. </p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/7f8b0051-9712-458e-b12d-79852f844d8c/image.png" width="500">
  <p style="font-size: 14px; color: gray;">▲ 그림 4. 'sandbox' 용 앱 서명 키</p>
</div>

<p>마찬가지로 sandbox 환경의 Firebase 프로젝트 연동을 위해서 App Id 도 추가해주었다.</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/3cd7c3a2-e8cf-411e-ae93-dab16e27f31a/image.png" width="500">
  <p style="font-size: 14px; color: gray;">▲ 그림 5. 'sandbox' 용 Firebase 연동 관련 App Id</p>
</div>

<h4 id="--ci-단계에서-sandbox-환경-고려">- CI 단계에서 &#39;sandbox&#39; 환경 고려</h4>
<p>기존의 <code>deploy_android</code> 작업에서 <code>production</code> 환경만 고려했던 것을 <code>sandbox</code> 도 추가하여 <code>google-services.json</code>, <code>key.properties</code> 등을 추가하였다.</p>
<pre><code class="language-yaml">  deploy_android:
    name: Android Deployment
    runs-on: self-hosted
    needs: setup_environment
    steps:
      - name: Google-services.json 생성
        working-directory: android
        run: |
          mkdir -p app/src/production
          mkdir -p app/src/sandbox

          cat &lt;&lt;EOF &gt; app/src/sandbox/google-services.json
          ${{ secrets.ANDROID_GOOGLE_SERVICE_DEV_JSON }}
          EOF

          cat &lt;&lt;EOF &gt; app/src/production/google-services.json
          ${{ secrets.ANDROID_GOOGLE_SERVICE_PROD_JSON }}
          EOF

      - name: local.properties 생성
        working-directory: android
        run: |
          cat &lt;&lt;EOF &gt; local.properties
          ${{ secrets.ANDROID_LOCAL_PROPERTIES }}
          EOF

      - name: Signing Key 복호화 및 key.properties 생성
        working-directory: android
        run: |          
          # keystore 전용 디렉토리 생성
          mkdir -p keystore/release/dev
          mkdir -p keystore/release/prod

          # .jks 파일 복호화
          echo &quot;${{ secrets.ANDROID_PRODUCTION_KEY_BASE_64 }}&quot; | base64 -d &gt; keystore/release/prod/production-key.jks
          echo &quot;${{ secrets.ANDROID_SANDBOX_KEY_BASE_64 }}&quot; | base64 -d &gt; keystore/release/dev/sandbox-key.jks

          # 권한 설정
          chmod 600 keystore/release/prod/production-key.jks
          chmod 600 keystore/release/dev/sandbox-key.jks

          # production-key.properties 생성
          cat &lt;&lt;EOF &gt; keystore/release/prod/production-key.properties
          ${{ secrets.ANDROID_PRODUCTION_KEY_PROPERTIES }}
          EOF

          # sandbox-key.properties 생성
          cat &lt;&lt;EOF &gt; keystore/release/dev/sandbox-key.properties
          ${{ secrets.ANDROID_SANDBOX_KEY_PROPERTIES }}
          EOF</code></pre>
<p>이후에 커밋 메시지의 옵션에 따라 배포를 실행하기 위해서 4가지 옵션으로 구성하였다.</p>
<blockquote>
<p>Option 1: [sandbox] 환경의 알파테스트 진행 -&gt; Firebase 업로드
Option 2: [production] 환경의 알파테스트 진행 -&gt; Firebase 업로드
Option 3: [production] 환경의 베타테스트 진행 -&gt; Play Console 내부 테스트 업로드
Option 4: [production] 환경의 제품 출시 및 버전 업데이트 -&gt; Play Console 프로덕션 배포</p>
</blockquote>
<pre><code class="language-yaml">      - name: 옵션에 따른 배포 실행
        working-directory: android
        env:
          FIREBASE_APP_ID_SANDBOX: ${{ secrets.FIREBASE_APP_ID_SANDBOX }}
          FIREBASE_APP_ID_PRODUCTION: ${{ secrets.FIREBASE_APP_ID_PRODUCTION }}
        run: |
          COMMIT_MSG=$(git log -1 --pretty=%B)
          if [[ &quot;$COMMIT_MSG&quot; =~ deploy:[1-9] ]]; then
            DEPLOY_OPTION=$(echo &quot;$COMMIT_MSG&quot; | grep -o &#39;deploy:[1-9]&#39; | cut -d&#39;:&#39; -f2)
          else
            echo &quot;배포 옵션이 지정되지 않았습니다. 기본 옵션(1) 사용&quot;
            DEPLOY_OPTION=&quot;1&quot;
          fi
          echo &quot;선택된 배포 옵션: $DEPLOY_OPTION&quot;

          case &quot;$DEPLOY_OPTION&quot; in
            &quot;1&quot;)
              echo &quot;[sandbox] Firebase App Distribution 배포 시작&quot;
              fastlane deploy_sandbox_to_firebase
              ;;
            &quot;2&quot;)
              echo &quot;[production] Firebase App Distribution 배포 시작&quot;
              fastlane deploy_prod_to_firebase
              ;;
            &quot;3&quot;)
              echo &quot;[production] Play Console Internal Test 배포 시작&quot;
              fastlane deploy_prod_to_internal_test
              ;;
            &quot;4&quot;)
              echo &quot;[production] Play Console Production 배포 시작&quot;
              fastlane deploy_prod_to_production
              ;;
            *)
              echo &quot;Invalid deployment option selected: $DEPLOY_OPTION&quot;
              exit 1
              ;;
          esac</code></pre>
<hr>
<h3 id="결과">결과</h3>
<h4 id="option-1-sandbox-환경-firebase-배포">Option 1: Sandbox 환경 Firebase 배포</h4>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/ab963fce-2e84-465a-95eb-7e871f315fc7/image.png" width="500">
  <p style="font-size: 14px; color: gray;">▲ 그림 6. [Option 1] 실행 결과</p>
</div>

<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/beccf528-6eea-40ba-8eac-9e441058ee1e/image.png" width="500">
  <p style="font-size: 14px; color: gray;">▲ 그림 7. 'sandbox' 용 Workflow 실행 결과</p>
</div>

<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/79d70203-f89e-4755-b715-e5a22d235c5c/image.png" width="500">
  <p style="font-size: 14px; color: gray;">▲ 그림 8. 'sandbox' 용 Firebase 업로드 결과</p>
</div>

<h4 id="option-2-production-환경-firebase-배포">Option 2: Production 환경 Firebase 배포</h4>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/87af2549-9152-4d89-bdae-07651aa3fc6c/image.png" width="500">
  <p style="font-size: 14px; color: gray;">▲ 그림 9. [Option 2] 실행 결과</p>
</div>

<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/f5ec807e-79e5-443b-9b29-8b95dc6d2c39/image.png" width="500">
  <p style="font-size: 14px; color: gray;">▲ 그림 10. 'production' 용 Firebase 업로드 결과</p>
</div>

<h4 id="option-3-production-환경-내부테스트-배포">Option 3: Production 환경 내부테스트 배포</h4>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/a249afe9-31f6-4fc6-80b5-1e48308a9d5e/image.png" width="500">
  <p style="font-size: 14px; color: gray;">▲ 그림 11. [Option 3] 실행 결과</p>
</div>

<h4 id="option-4-production-환경-프로덕션-배포">Option 4: Production 환경 프로덕션 배포</h4>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/b12858a9-3ee3-48d7-94a8-932eeb3552af/image.png" width="500">
  <p style="font-size: 14px; color: gray;">▲ 그림 13. [Option 4] 실행 결과</p>
</div>

<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/8c976eb5-0f1a-4061-9539-1558b9b9ea8d/image.png" width="500">
  <p style="font-size: 14px; color: gray;">▲ 그림 14. 'production' 용 프로덕션 배포</p>
</div>

<hr>
<h3 id="reference">Reference</h3>
<p><a href="https://firebase.google.com/docs/app-distribution/android/distribute-fastlane?hl=ko">https://firebase.google.com/docs/app-distribution/android/distribute-fastlane?hl=ko</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] Fastlane 과 Github Action을 사용하여 운영 중인 서비스에 배포 자동화 시스템 구축하기(2)]]></title>
            <link>https://velog.io/@sangjin-hash/Flutter-Fastlane-%EA%B3%BC-Github-Action%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%98%EC%97%AC-%EC%9A%B4%EC%98%81-%EC%A4%91%EC%9D%B8-%EC%84%9C%EB%B9%84%EC%8A%A4%EC%97%90-%EB%B0%B0%ED%8F%AC-%EC%9E%90%EB%8F%99%ED%99%94-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B02-Android-%ED%8E%B8</link>
            <guid>https://velog.io/@sangjin-hash/Flutter-Fastlane-%EA%B3%BC-Github-Action%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%98%EC%97%AC-%EC%9A%B4%EC%98%81-%EC%A4%91%EC%9D%B8-%EC%84%9C%EB%B9%84%EC%8A%A4%EC%97%90-%EB%B0%B0%ED%8F%AC-%EC%9E%90%EB%8F%99%ED%99%94-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B02-Android-%ED%8E%B8</guid>
            <pubDate>Sat, 31 May 2025 12:40:01 GMT</pubDate>
            <description><![CDATA[<h3 id="개요">개요</h3>
<p>지난 게시글에 이어 이번엔 Android 에 대해 적용을 할 차례이다. Android 는 비교적 iOS 보다 적용하는 과정이 수월한데,
Android 배포 자동화 시스템 구축에서 사용하는 툴이나 서비스들은 다음과 같다.</p>
<pre><code>- Android
    - Gradle or Terminal Command (APK/AAB 빌드)
    - Play Console API (빌드 업로드 및 배포)
    - Fastlane Supply (Google Play 메타데이터 관리)</code></pre><hr>
<h3 id="fastlane-setup">Fastlane Setup</h3>
<pre><code>cd android/
fastlane init</code></pre><p>init 명령어를 입력한 뒤 <code>json secret file</code> 의 경로를 입력해야 하는데, 이는 <code>Google credentials</code> 를 의미한다.
GCP 에서 서비스 계정을 생성한 뒤 여기서 발급받은 json 을 프로젝트에 넣어서 그 경로를 입력해줘야 하는데 이에 대한 방법은</p>
<p><a href="https://docs.fastlane.tools/getting-started/android/setup/#getting-started-with-fastlane-for-android">Fastlane-Android 공식문서</a>의 <code>Collect your Google credentials</code> 섹션에 자세하게 나와있다.</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/5e4575d8-d7fb-4de6-a15b-56ec2a235a23/image.png" width="700">
  <p style="font-size: 14px; color: gray;">▲ 그림 1. 추가한 Fastlane 용 서비스 계정</p>
</div>

<p>이 밖에도 꼭 해줘야 하는 작업으로는 <code>Google Play Developer API</code> 가 활성화 되어 있는지 확인해야 한다. 서비스 계정을 넣어줘도 해당 API 가 활성화되어 있지 않으면 Play Console 에 앱 번들 자동 업로드가 안되기 때문에 활성화가 필요하다.</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/a5d16e30-4deb-4dff-8250-79ef55b55810/image.png" width="700">
  <p style="font-size: 14px; color: gray;">▲ 그림 2. Google Play Android Developer API 활성화</p>
</div>

<hr>
<h3 id="fastfile-작성">Fastfile 작성</h3>
<h4 id="--build_appflavor">- build_app(flavor)</h4>
<p>우선 <code>build_app(flavor)</code> 이라는 함수를 정의하였다.</p>
<p>Fastfile에서 Android 앱을 빌드하는 방법은 크게 두 가지가 있다:</p>
<blockquote>
<ol>
<li>Gradle 명령어를 직접 사용하는 방식</li>
<li>flutter 명령어를 사용하는 방식</li>
</ol>
</blockquote>
<p>이 프로젝트에서는 두 번째 방법인 flutter 명령어를 통한 빌드 방식을 선택하였다. 그 이유는, <code>--dart-define=FLAVOR=dev</code> 또는 <code>--dart-define=FLAVOR=prod</code>와 같은 환경 변수를 명시적으로 main.dart에 전달할 수 있기 때문이다.</p>
<p>반면, Gradle 명령어만을 사용할 경우에는 Flutter 엔트리포인트(main.dart)에 전달할 <code>--dart-define</code> 인자를 자연스럽게 포함시키기 어렵다. 따라서 각 빌드 flavor 환경에 맞는 정의를 명확히 전달하고자, flutter build 명령어를 직접 사용하여 빌드를 수행하도록 구성하였다.</p>
<pre><code>platform :android do
  def build_app(flavor)
    if flavor == &quot;dev&quot;
      sh &quot;flutter build appbundle --flavor dev --release --dart-define=FLAVOR=dev&quot;
      return &quot;../build/app/outputs/bundle/devRelease/app-dev-release.aab&quot;

    elsif flavor == &quot;prod&quot;
      sh &quot;flutter build appbundle --flavor prod --release --dart-define=FLAVOR=prod&quot;
      return &quot;../build/app/outputs/bundle/prodRelease/app-prod-release.aab&quot;

    else
      UI.user_error!(&quot;Unknown flavor: #{flavor}&quot;)
    end
  end</code></pre><h4 id="--lane-설정">- Lane 설정</h4>
<p>Android 의 경우 <a href="https://velog.io/@sangjin-hash/iOS-Fastlane-%EA%B3%BC-Github-Action%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%98%EC%97%AC-%EC%9A%B4%EC%98%81-%EC%A4%91%EC%9D%B8-%EC%84%9C%EB%B9%84%EC%8A%A4%EC%97%90-%EB%B0%B0%ED%8F%AC-%EC%9E%90%EB%8F%99%ED%99%94-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0-iOS1">이전 게시글(iOS)</a>과 다르게, Android 에서는 내부 테스트용 앱 번들을 올릴 때, <a href="https://velog.io/@sangjin-hash/%EB%8B%A4%EC%A4%91-Firebase-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%97%B0%EB%8F%99-%EC%8B%9C-%EC%A4%91%EB%B3%B5-SHA-1-%EB%A1%9C-%EC%9D%B8%ED%95%9C-%EA%B5%AC%EA%B8%80-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EB%AC%B8%EC%A0%9C">개발 환경의 앱 번들은 올릴 수가 없어서</a>, 우선 운영 환경에서의 내부 테스트 배포(Lane 1), 운영 환경에서의 Play Console 배포(Lane 2) 로 2개의 Lane 을 구성하였다.</p>
<p>이러한 문제점에 착안하여 <a href="https://velog.io/@sangjin-hash/Android-%EB%B0%B0%ED%8F%AC-%ED%99%98%EA%B2%BD%EC%97%90-%EB%94%B0%EB%9D%BC-%EB%82%B4%EB%B6%80-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EA%B5%AC%EC%84%B1%ED%95%98%EA%B8%B0-Firebase-App-Distribution"><code>Firebase App Distribution</code>을 적용</a>하였는데, 추후에 <code>Firebase App Distribution</code> 까지 CI 단계에 적용한 내용에 대해 포스트할 예정이다.</p>
<pre><code>  [build_app(flavor) 생략]
  ...
  # Lane 1: [prod] 환경 - Play Console 내부 테스트 배포
  desc &quot;Deploy production .aab to Play Console Internal Test&quot;
  lane :deploy_prod_to_internal_test do
    aab_path = build_app(&quot;prod&quot;)

    upload_to_play_store(
      track: &quot;internal&quot;,
      aab: aab_path
    )
  end

  # Lane 2: [prod] 환경 - 프로덕션 배포
  desc &quot;Deploy production .aab to Play Console Production&quot;
  lane :deploy_prod_to_production do
    aab_path = build_app(&quot;prod&quot;)

    upload_to_play_store(
      track: &quot;production&quot;,
      aab: aab_path,
      skip_upload_metadata: true,
      skip_upload_images: true,
      skip_upload_screenshots: true,
      skip_upload_changelogs: true,
    )
  end
end</code></pre><p>iOS 와 마찬가지로 Play Console 에 자동으로 앱 번들을 올리고, 이 후 출시 노트나 메타 데이터 변경, 버전 업데이트 작업은 수동으로 하기 위해 다음과 같이 설정하였다.</p>
<hr>
<h3 id="workflow-작성">Workflow 작성</h3>
<p><a href="https://velog.io/@sangjin-hash/iOS-Fastlane-%EA%B3%BC-Github-Action%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%98%EC%97%AC-%EC%9A%B4%EC%98%81-%EC%A4%91%EC%9D%B8-%EC%84%9C%EB%B9%84%EC%8A%A4%EC%97%90-%EB%B0%B0%ED%8F%AC-%EC%9E%90%EB%8F%99%ED%99%94-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0-iOS1">이전 게시글</a> 에서 공통적인 <code>setup_environment</code> 을 수행한 뒤에 <code>deploy_android</code> 작업을 수행한다. 우선 앱을 빌드 하기 전에 환경 설정해줘야 하는 작업으로는 다음과 같다.</p>
<blockquote>
<ol>
<li>Google-services.json 생성</li>
<li>local.properties 생성</li>
<li>앱 서명 관련 key.properties 생성</li>
</ol>
</blockquote>
<p>위의 3가지들은 모두 <code>.gitignore</code> 에 명시되어 있는 파일들로, Runner 에서 실행했을 때 해당 파일이 물리적으로 없기 때문에 이를 <code>Actions secrets</code> 에 값을 저장하고 불러와 생성하는 식으로 진행해야 한다.</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/2d8dd730-538b-493c-a08f-d76b9bb4911c/image.png" width="500">
  <p style="font-size: 14px; color: gray;">▲ 그림 3. Android 관련 Github Actions secrets 저장된 변수들</p>
</div>

<pre><code>  deploy_android:
    name: Android Deployment
    runs-on: self-hosted
    needs: setup_environment
    steps:
      - name: Google-services.json 생성
        working-directory: android
        run: |
          # 운영 환경(prod)만 고려하여 배포(내부테스트, 프로덕션)
          mkdir -p app/src/prod

          cat &lt;&lt;EOF &gt; app/src/prod/google-services.json
          ${{ secrets.ANDROID_GOOGLE_SERVICE_PROD_JSON }}
          EOF

      - name: local.properties 생성
        working-directory: android
        run: |
          cat &lt;&lt;EOF &gt; local.properties
          ${{ secrets.ANDROID_LOCAL_PROPERTIES }}
          EOF

      - name: Signing Key 복호화 및 key.properties 생성
        working-directory: android
        run: |          
          # keystore 전용 디렉토리 생성
          mkdir -p keystore/release/prod

          # .jks 파일 복호화
          echo &quot;${{ secrets.ANDROID_PRODUCTION_KEY_BASE_64 }}&quot; | base64 -d &gt; keystore/release/prod/production-key.jks

          # 권한 설정
          chmod 600 keystore/release/prod/production-key.jks

          # production-key.properties 생성
          cat &lt;&lt;EOF &gt; keystore/release/prod/production-key.properties
          ${{ secrets.ANDROID_PRODUCTION_KEY_PROPERTIES }}
          EOF</code></pre><p>환경설정이 모두 완료되었다면 그 다음으로 커밋 메시지에서 배포 옵션에 따라 배포를 실행해야 한다. 마찬가지로 <code>[커밋 메시지] - deploy:1</code> 이런 식으로 배포 옵션을 메시지를 통해 입력을 받아 실행하였고, 위에서 2개의 Lane(내부 테스트 업로드, 프로덕션 업로드)를 설정했기 때문에 이에 따라 분기 처리하여 실행하도록 설정했다.</p>
<pre><code>      - name: 옵션에 따른 배포 실행
        working-directory: android
        run: |
          COMMIT_MSG=$(git log -1 --pretty=%B)
          if [[ &quot;$COMMIT_MSG&quot; =~ deploy:[1-9] ]]; then
            DEPLOY_OPTION=$(echo &quot;$COMMIT_MSG&quot; | grep -o &#39;deploy:[1-9]&#39; | cut -d&#39;:&#39; -f2)
          else
            echo &quot;배포 옵션이 지정되지 않았습니다. 기본 옵션(1) 사용&quot;
            DEPLOY_OPTION=&quot;1&quot;
          fi
          echo &quot;선택된 배포 옵션: $DEPLOY_OPTION&quot;

          case &quot;$DEPLOY_OPTION&quot; in
            &quot;1&quot;)
              echo &quot;[production] Play Console Internal Test 배포 시작&quot;
              fastlane deploy_prod_to_internal_test
              ;;
            &quot;2&quot;)
              echo &quot;[production] Play Console Production 배포 시작&quot;
              fastlane deploy_prod_to_production
              ;;
            *)
              echo &quot;Invalid deployment option selected: $DEPLOY_OPTION&quot;
              exit 1
              ;;
          esac</code></pre><hr>
<h3 id="향후-계획">향후 계획</h3>
<p>지금까지 Android 와 iOS 에서 배포 옵션에 따라 배포 자동화 시스템을 구축하는 방식에 대해서 알아보았다. iOS 에서는 각 환경마다 TestFlight 에 올릴 수 있지만 Android 에서는 개발 환경 전용 GoogleService.json 과 업로드 키 문제로 인해 <strong>&quot;운영&quot; 환경만 내부테스트에 업로드가 가능</strong>한 한계점이 있는데, 이를 보완하기 위해서 Firebase 의 App Distribution 서비스를 도입했었고 이를 가지고 배포 자동화 워크 플로우에 추가할 계획이다.</p>
<p>또한 지금은 Build Configuration 이 환경에 따라서 <code>dev</code> 와 <code>prod</code> 로 구성되어 있는데, 추후에 포스트할 게시글의 내용으로는 환경을 더 나눠 이에 따른 배포 파이프라인 개선을 할 예정이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[iOS] Fastlane 과 Github Action을 사용하여 운영 중인 서비스에 배포 자동화 시스템 구축하기(1)]]></title>
            <link>https://velog.io/@sangjin-hash/iOS-Fastlane-%EA%B3%BC-Github-Action%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%98%EC%97%AC-%EC%9A%B4%EC%98%81-%EC%A4%91%EC%9D%B8-%EC%84%9C%EB%B9%84%EC%8A%A4%EC%97%90-%EB%B0%B0%ED%8F%AC-%EC%9E%90%EB%8F%99%ED%99%94-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0-iOS1</link>
            <guid>https://velog.io/@sangjin-hash/iOS-Fastlane-%EA%B3%BC-Github-Action%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%98%EC%97%AC-%EC%9A%B4%EC%98%81-%EC%A4%91%EC%9D%B8-%EC%84%9C%EB%B9%84%EC%8A%A4%EC%97%90-%EB%B0%B0%ED%8F%AC-%EC%9E%90%EB%8F%99%ED%99%94-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0-iOS1</guid>
            <pubDate>Fri, 30 May 2025 02:00:25 GMT</pubDate>
            <description><![CDATA[<h3 id="지금까지-회고-미리-구축할걸-지난날의-후회-배포-자동화-니즈를-느끼게-된-계기">지금까지 회고, &#39;미리 구축할걸&#39; 지난날의 후회, 배포 자동화 니즈를 느끼게 된 계기</h3>
<p><del>(뻘소리 주의!! 시간을 아끼고 싶은 분들은 아래 &#39;개요&#39;부터 읽으시면 됩니다.)</del></p>
<p>서비스를 스토어에 출시한 지 벌써 1년 2개월이 지났다(<strong>25년 1월에 작성한 초안 글</strong>). 중간에 회사에서 또 다른 서비스를 기획하고 출시까지 해야되는 프로젝트가 있었어서 약 6개월 정도는 기존 서비스에 새로운 기능을 추가하거나 유지보수를 하진 못했었고, 단순 에러만 대응하고 버전 업데이트만 할 뿐이었다. 프로젝트가 끝난 뒤 2024년 9월부터 회사에서 기존 제품의 경쟁력을 갖추기 위해 신규 인력도 채용하고 추가적인 기획 및 개발을 해서 제품의 완성도를 올리려는 목표를 설정했다. 매주마다 지속적인 QA를 하며 사용자로부터 애로 및 건의사항을 통해 지속적으로 앱을 수정하며 기능을 추가하는 작업을 했었다. 이 과정에서 앱 버전을 업데이트하는 경우가 많았는데, 지금까지 모두 수동으로 작업을 했었다.</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/06e0f24b-a77f-438f-bdb6-c69434bca5fc/image.png" width="500">
  <p style="font-size: 14px; color: gray;">▲ 그림 1. 앱 버전 업데이트 기록</p>
</div>

<p>데스크톱 메모장에 일명 &quot;매크로&quot;라고 칭하여 <strong>매번 업데이트마다 아래 메모장을 참고하여 수동으로 배포</strong>했었다. Play Console 과 App Store Connect 에 해당 배포 작업이 약 20분정도 소요되었고, 해당 작업을 하는 동안은 그래도 계속 대기하며 붙들어매진 않았지만 중간중간에 버튼 몇번 딸깍 하는 작업이 필요했어서 번거로웠다.</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/fad7000d-feed-4ece-978b-65b18574aeea/image.png" width="300">
  <p style="font-size: 14px; color: gray;">▲ 그림 2. 수동으로 앱 버전 업데이트할 때</p>
</div>

<blockquote>
<p>그럼 &#39;여태까지 배포 자동화를 도입하지 않았던 이유가 무엇이냐&#39;라고 한다면,</p>
</blockquote>
<p><strong>작업의 우선순위가 그리 높진 않았다</strong>. 우리 회사는 스타트업이기도 했었고, 개발인력이 많이 없음에도 불구하고 빠르게 기능을 개발해서 시장 평가를 받고 유의미하지 않으면 바로 교체해서 다른 기능을 선보여야 했기 때문에 <strong>기능 개발이 최우선</strong>이었다. 배포 자동화를 구축하면 업데이트 시 편리하긴 하겠지만 수동으로 해도 문제가 없었으니, 배포 자동화 시스템 구축을 Optional 하게 생각했었던 것이다.</p>
<blockquote>
<p>&#39;그럼 이제 와서 구축하는 이유는?&#39; 라고 한다면,</p>
</blockquote>
<p><strong>일정에 여유가 생겨서이다</strong>. 조금의 프로젝트 복기할 시간이 생겨서 프로젝트 리팩토링도 할 겸, 겸사겸사 배포 자동화 시스템도 구축하기로 했다. 앱을 처음 출시할 당시 사내 백엔드 인력이 없어서 백엔드 업무를 봤었는데 그 때 <code>Github Actions</code>, <code>S3</code>, <code>CodeDeploy</code> 를 이용해 배포 자동화를 구축한 적이 있었다. 이 때는 Github repository 에 푸시만 하면 자동으로 테스트, 빌드, 배포까지 모두 진행이 되었던 터라, 모바일쪽에도 백엔드처럼 repository 에 푸시하면 자동으로 스토어에 앱 빌드 파일이 올라가면 편리하겠다는 니즈가 생겨 백엔드와 비슷한 flow로 구축하고자 한다. </p>
<p>지금까지 서론이 너무 길었는데, 이미 운영되고 있는 서비스에 <code>Fastlane</code> 과 <code>Github Actions</code> 를 이용하여 모바일 운영체제(iOS, Android) 에 따른 배포 자동화 시스템 구축에 대해 알아보도록 하자.</p>
<hr>
<h3 id="개요">개요</h3>
<p>배포 자동화 시스템 구축에서 사용하는 툴이나 서비스들은 다음과 같다.</p>
<pre><code>- CI/CD 시스템
    - Github Actions
    - Self-hosted Runner
    - Secrets 관리(Github Actions Secrets)

- 자동 빌드 오픈소스(iOS &amp; Android)
    - Fastlane

- iOS
    - Match (프로비저닝 프로필 및 인증서 관리)
    - App Store Connect API (빌드 업로드 및 배포)
    - Gym (IPA 빌드)
    - Pilot (TestFlight 배포)
    - Deliver (App Store 메타데이터 및 스크린샷 업로드)

- Android
    - Gradle (APK/AAB 빌드)
    - Play Console API (빌드 업로드 및 배포)
    - Fastlane Supply (Google Play 메타데이터 관리)</code></pre><hr>
<h3 id="fastlane-setup---자동-빌드-오픈소스ios--android">Fastlane Setup - 자동 빌드 오픈소스(iOS &amp; Android)</h3>
<pre><code>sudo gem install fastlane -NV
cd ios
fastlane init</code></pre><div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/c675dd15-a6e8-4a59-a146-7b5f8ac1ffe8/image.png" width="800">
  <p style="font-size: 14px; color: gray;">▲ 그림 3. iOS Fastlane init</p>
</div>

<p>여기서 목적에 맞게 선택하면 된다. TestFlight 에 업로드할 용도로&#39;만&#39; 쓴다면 2번, TestFlight 업로드를 제외하고 App Store Distribution 용으로&#39;만&#39; 쓴다면 3번을 선택하면 된다. 필자처럼 둘 다 고려해야 하는 상황이라면(TestFlight &amp;&amp; App Store Distribution) <strong>2번 혹은 3번 아무거나 선택해도 무방하다.</strong> 추후에 &#39;<code>Fastfile</code>&#39;에 추가로 설정해주면 되기 때문이다.  </p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/28032886-3ec4-49c5-a9f3-92d49f0d82de/image.png" width="800">
  <p style="font-size: 14px; color: gray;">▲ 그림 4. flavor Option</p>
</div>

<p>이 경우, 우리 프로젝트는 <a href="https://velog.io/@sangjin-hash/Flavor-%EA%B0%9C%EB%B0%9C-%EC%9A%B4%EC%98%81-%ED%99%98%EA%B2%BD-%EA%B5%90%EC%B2%B4-%EC%9E%90%EB%8F%99%ED%99%94">Flavor를 이용하여 개발용 환경인 &#39;dev&#39; 와 서비스 운영용 &#39;prod&#39; 이 둘을 관리하기 때문에</a> 다음 사진처럼 두 가지 옵션이 나왔는데, 이 때 아무거나 선택해도 된다. 어차피 이후에 마찬가지로 Fastfile을 직접 수정해서 <strong>dev</strong>와 prod 모두 배포할 수 있도록 설정할 것이다.</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/44e665b3-1d59-456f-9809-2b5132d0e6d7/image.png" width="500">
  <p style="font-size: 14px; color: gray;">▲ 그림 5. 자동 생성된 Fastfile 관련 파일</p>
</div>

<hr>
<h3 id="match-setup---프로비저닝-프로필-및-인증서-관리">Match Setup - 프로비저닝 프로필 및 인증서 관리</h3>
<p>iOS 앱을 배포하기 위해서는 인증서(certificate)와 프로비저닝 프로필(provisioning profile)이 반드시 필요하다. 이 파일들은 팀원 간 공유가 어렵고, 수동으로 관리하면 빌드 오류나 인증서 충돌이 자주 발생한다.</p>
<p>match는 이러한 문제를 해결하기 위해 Fastlane에서 제공하는 프로비저닝 프로필 및 인증서 버전 관리 도구다. 팀 단위 개발에서 인증서를 안전하게 공유하고, CI/CD 환경에서도 동일한 인증서를 재사용할 수 있도록 해준다.</p>
<h4 id="주요-장점">주요 장점</h4>
<ul>
<li><p>Git 기반 중앙 관리</p>
<ul>
<li>인증서와 프로비저닝 프로필을 Git repository에 저장하여 팀원과 안전하게 공유 가능</li>
</ul>
</li>
<li><p>자동 생성 및 설치</p>
<ul>
<li>필요한 인증서를 자동으로 생성하고 설치하므로 수동 작업 최소화</li>
</ul>
</li>
<li><p>충돌 없는 인증서 관리</p>
<ul>
<li>기존 인증서와의 충돌 방지를 위한 match nuke 기능 제공</li>
</ul>
</li>
<li><p>CI/CD 연동 최적화</p>
<ul>
<li>GitHub Actions, Bitrise, Jenkins 등 자동화 도구와 쉽게 연동 가능</li>
</ul>
</li>
</ul>
<p><br></br></p>
<h4 id="--match-init">- Match init</h4>
<pre><code class="language-bash">fastlane match init</code></pre>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/030e6998-4e48-47fb-baaf-50866ea5dd45/image.png" width="700">
  <p style="font-size: 14px; color: gray;">▲ 그림 6. 인증서 저장 위치 선택</p>
</div>

<p>git의 repository 에 profile 과 certificate를 저장할 것이기 때문에 1번을 선택.</p>
<h4 id="--matchfile-작성">- Matchfile 작성</h4>
<pre><code>git_url(&quot;[github_project_url]&quot;)

storage_mode(&quot;git&quot;)

type(&quot;appstore&quot;) # The default type, can be: appstore, adhoc, enterprise or development

# app_identifier([&quot;tools.fastlane.app&quot;, &quot;tools.fastlane.app2&quot;])
# username(&quot;user@fastlane.tools&quot;) # Your Apple Developer Portal username

# For all available options run `fastlane match --help`
# Remove the # in the beginning of the line to enable the other options

# The docs are available on https://docs.fastlane.tools/actions/match</code></pre><p>이후 기존에 로컬에서 사용하고 있는 인증서가 있다면 추후에 match 를 통해 생성할 인증서와 충돌이 생기기 때문에 모두 삭제를 해야 한다.</p>
<pre><code class="language-bash">fastlane match nuke development # 개발인증서 삭제
fastlane match nuke distribution # 배포인증서 삭제</code></pre>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/9e8cba5c-e6bc-47eb-822a-7001426e77ab/image.png" width="800">
  <p style="font-size: 14px; color: gray;">▲ 그림 7. 인증서 삭제</p>
</div>

<p>위의 커맨드를 통해 기존의 인증서들을 모두 삭제했다면 match 인증서를 어느 브랜치에서 관리할 것인지 다음과 같이 명시해주면 된다.</p>
<pre><code class="language-bash">fastlane match development --git_branch &quot;[브랜치 이름]&quot; # 개발용 인증서
fastlane match appstore --git_branch &quot;[브랜치 이름]&quot; # 앱스토어 배포용 인증서</code></pre>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/485e5109-6edc-4e46-8ac5-b30a41afa406/image.png" width="800">
  <p style="font-size: 14px; color: gray;">▲ 그림 8. Github Repository 에 자동 생성된 인증서</p>
</div>

<p>이제 로컬에서 사용했던 인증서(Xcode 에서 앱 빌드 시 사용되었던 인증서)를 모두 삭제했으니, match 를 통해 생성된 인증서를 연결해주면 된다.</p>
<ul>
<li>Xcode &gt; Signing &amp; Capabilities &gt; match Development [package_name]<div align="center">
<img src="https://velog.velcdn.com/images/sangjin-hash/post/8a0e8a62-6388-47a1-aa55-173ac6a74388/image.png" width="800">
<p style="font-size: 14px; color: gray;">▲ 그림 9. Provisioning Profile 변경 -> Match </p>
</div>

</li>
</ul>
<hr>
<h3 id="app-store-connect-api">App Store Connect API</h3>
<p>iOS 앱을 TestFlight 또는 App Store에 배포하려면 App Store Connect에 접근해야 한다. Fastlane을 사용하는 경우, 이 인증을 처리하는 방식은 아래 세 가지 중 하나를 선택할 수 있다.</p>
<blockquote>
<ol>
<li>App Store Connect Api Key</li>
<li>이중 인증(2FA)</li>
<li>Application-specific passwords</li>
</ol>
</blockquote>
<p>이 중에서 Fastlane 공식 문서와 실무 환경에서는 1번 방식인 App Store Connect API Key 사용을 가장 권장한다. 이러한 이유로는 다음과 같다.</p>
<h4 id="--기존-방식의-문제점">- 기존 방식의 문제점</h4>
<ul>
<li>2FA는 세션이 만료될 때마다 인증 코드를 다시 입력해야 하므로, 완전한 자동화가 어렵다.</li>
<li>앱 암호는 종종 인증 오류가 발생하며, CI/CD 환경에서 예측이 어려운 문제가 있다.</li>
<li>로그인 기반 인증은 CI 환경에서는 매우 불안정하고 관리가 어렵다.</li>
</ul>
<p>이러한 문제점을 해결하기 위해 Apple은 JWT 기반 인증 방식인 App Store Connect API Key를 도입했다. 이 방식은 로그인 없이도 인증이 가능하며, 인증 정보를 GitHub Secrets 등과 함께 안전하게 관리할 수 있어 완전한 무인 자동화가 가능하다.</p>
<h4 id="app-store-connect-api-key-발급">App Store Connect API Key 발급</h4>
<ul>
<li>App Store Connect 로그인</li>
<li>사용자 및 액세스 &gt; 통합 &gt; 키 &gt; App Store Connect API &gt; 액세스 요청 &gt; API 키 생성<div align="center">
<img src="https://velog.velcdn.com/images/sangjin-hash/post/bd31bbbb-11c7-4905-b477-6acb10f5eff9/image.png" width="800">
<p style="font-size: 14px; color: gray;">▲ 그림 10. App Store Connect API Key 발급 </p>
</div>

</li>
</ul>
<hr>
<h3 id="fastfile-작성">Fastfile 작성</h3>
<p>Fastfile 구성은 다음과 같다. <code>do_signing</code> lane은 App Store, TestFlight에 앱을 배포할 때 공통적으로 호출되며, App Store Connect Api 를 통해 발급받은 Api Key 정보를 기반으로 Apple Developer 계정에 인증한 후, match를 통해 git에 저장된 서명 정보(인증서, provisioning profile 등)를 안전하게 가져와 Xcode 빌드를 위한 사전 준비를 수행한다.</p>
<pre><code class="language-ruby">## iOS Signing 설정 (API Key &amp; Match)
desc &quot;Do Signing for Deploy iOS APP&quot;
lane :do_signing do |options|
  api_key = app_store_connect_api_key(
    key_id: options[:key_id],
    issuer_id: options[:issuer_id],
    key_content: options[:key_content],
    is_key_content_base64: true
  )

  match(
    type: &#39;appstore&#39;,
    app_identifier: options[:app_identifier],
    api_key: api_key,
    git_branch: &#39;release&#39;,
    readonly: true
  )
end</code></pre>
<p><code>deploy_to_testflight</code> lane은 <code>do_signing</code>을 거친 후, 동일하게 scheme에 맞춰 앱을 빌드하고, <code>upload_to_testflight</code> 액션을 통해 TestFlight로 업로드한다. 이 때 scheme은 flavor 의 빌드 환경을 말한다. <code>skip_waiting_for_build_processing</code> 옵션이 설정되어 있어 업로드 후 빌드가 준비되기를 기다리지 않고 즉시 작업이 종료되며, 이후의 Tester 초대 및 배포는 TestFlight 웹 콘솔에서 수동으로 설정할 수 있게 하였다.</p>
<pre><code class="language-ruby">## TestFlight 배포 Lane
desc &quot;Deploy to TestFlight&quot;
lane :deploy_to_testflight do |options|
  flavor = options[:flavor] || &quot;prod&quot;

  do_signing(
    key_id: options[:key_id],
    issuer_id: options[:issuer_id],
    key_content: options[:key_content],
    app_identifier: options[:app_identifier]
  )

  build_app(
    clean: true,
    workspace: &quot;Runner.xcworkspace&quot;,
    scheme: flavor,
    configuration: &quot;Release&quot;
  )

  upload_to_testflight(skip_waiting_for_build_processing: true)
end</code></pre>
<p><code>deploy_to_app_store</code> lane 또한 <code>do_signing</code>을 통해 서명 과정을 마친 뒤, 지정된 scheme(기본값은 &quot;prod&quot;)으로 앱을 빌드하고 <code>upload_to_app_store</code> 액션을 통해 App Store Connect에 앱 번들을 업로드한다. 이때 자동 출시(automatic_release)나 자동 심사 제출(submit_for_review)은 비활성화했는데, 이는 빌드 파일만 자동으로 올린 뒤 App Store Connect에서 수동으로 출시 및 심사를 진행하기 위함이다. 또한, 앱 버전, 메타데이터, 스크린샷은 모두 건너뛰기 설정했으며, 기존에 설정된 정보를 그대로 유지하게 설정하였다.</p>
<pre><code class="language-ruby">## App Store 배포 Lane
desc &quot;Deploy to App Store Connect&quot;
lane :deploy_to_app_store do |options|
  flavor = options[:flavor] || &quot;prod&quot;

  do_signing(
    key_id: options[:key_id],
    issuer_id: options[:issuer_id],
    key_content: options[:key_content],
    app_identifier: options[:app_identifier]
  )

  build_app(
    clean: true,
    workspace: &quot;Runner.xcworkspace&quot;,
    scheme: flavor
  )

  upload_to_app_store(
    automatic_release: false,  # 자동 출시 비활성화
    submit_for_review: false,  # 자동 심사 제출 비활성화
    skip_screenshots: true,    # 스크린샷 업로드 건너뛰기
    skip_metadata: true,       # 메타데이터 업데이트 건너뛰기
    skip_app_version_update: true, # App Store의 버전 자동 업데이트 비활성화
    force: true,                    # Preview 확인 건너뛰기
    precheck_include_in_app_purchases: false # In-App Purchase 검사 건너뛰기
  )

end</code></pre>
<p>이처럼 App Store 및 TestFlight 배포 작업은 각각의 lane을 통해 자동화되어 있으며, 사전 준비된 인증 정보를 바탕으로 반복 가능한 안정적인 배포 파이프라인을 구성할 수 있다.</p>
<ul>
<li>ios/fastlane/Fastfile<pre><code class="language-ruby">default_platform(:ios)
</code></pre>
</li>
</ul>
<p>platform :ios do</p>
<h2 id="ios-signing-설정-api-key--match">iOS Signing 설정 (API Key &amp; Match)</h2>
<p>  desc &quot;Do Signing for Deploy iOS APP&quot;
  lane :do_signing do |options|
    api_key = app_store_connect_api_key(
      key_id: options[:key_id],
      issuer_id: options[:issuer_id],
      key_content: options[:key_content],
      is_key_content_base64: true
    )</p>
<pre><code>match(
  type: &#39;appstore&#39;,
  app_identifier: options[:app_identifier],
  api_key: api_key,
  git_branch: &#39;release&#39;,
  readonly: true
)</code></pre><p>  end</p>
<h2 id="testflight-배포-lane">TestFlight 배포 Lane</h2>
<p>  desc &quot;Deploy to TestFlight&quot;
  lane :deploy_to_testflight do |options|
    flavor = options[:flavor] || &quot;prod&quot;</p>
<pre><code>do_signing(
  key_id: options[:key_id],
  issuer_id: options[:issuer_id],
  key_content: options[:key_content],
  app_identifier: options[:app_identifier]
)

build_app(
  clean: true,
  workspace: &quot;Runner.xcworkspace&quot;,
  scheme: flavor,
  configuration: &quot;Release&quot;
)

upload_to_testflight(skip_waiting_for_build_processing: true)</code></pre><p>  end</p>
<h2 id="app-store-배포-lane">App Store 배포 Lane</h2>
<p>  desc &quot;Deploy to App Store Connect&quot;
  lane :deploy_to_app_store do |options|
    flavor = options[:flavor] || &quot;prod&quot;</p>
<pre><code>do_signing(
  key_id: options[:key_id],
  issuer_id: options[:issuer_id],
  key_content: options[:key_content],
  app_identifier: options[:app_identifier]
)

build_app(
  clean: true,
  workspace: &quot;Runner.xcworkspace&quot;,
  scheme: flavor
)

upload_to_app_store(
  automatic_release: false,  # 자동 출시 비활성화
  submit_for_review: false,  # 자동 심사 제출 비활성화
  skip_screenshots: true,    # 스크린샷 업로드 건너뛰기
  skip_metadata: true,       # 메타데이터 업데이트 건너뛰기
  skip_app_version_update: true, # App Store의 버전 자동 업데이트 비활성화
  force: true,                    # Preview 확인 건너뛰기
  precheck_include_in_app_purchases: false # In-App Purchase 검사 건너뛰기
)</code></pre><p>  end
end</p>
<pre><code>---

### Github Actions - CI/CD
GitHub Actions는 GitHub 저장소에서 특정 이벤트(push, pull_request, release 등)가 발생했을 때 자동으로 정해진 작업을 수행할 수 있도록 해주는 워크플로 자동화 도구이다. 이는 코드 빌드, 테스트, 배포까지 이어지는 전체 CI/CD 흐름을 손쉽게 구성할 수 있게 해준다.

GitHub Actions에서 워크플로는 Runner라는 실행 환경에서 동작하며, 대표적으로 GitHub-hosted Runner와 Self-hosted Runner 두 가지 유형이 있다.

- **GitHub-hosted Runner**는 GitHub에서 제공하는 가상 머신으로, 워크플로가 실행될 때마다 새로운 환경이 생성된다. 사용이 간편하고 관리가 필요 없다는 장점이 있지만, 무료로 제공되는 시간에는 제한이 있고, 초과 시 비용이 발생한다. 예를 들어, 퍼블릭 리포지토리는 무료지만, 프라이빗 리포지토리는 사용량이 제한되고, 초과 시 추가 요금이 청구된다.

- **Self-hosted Runner**는 사용자가 직접 관리하는 서버에서 워크플로를 실행하는 방식이다. 실행 환경을 커스터마이징할 수 있고, 자체 인프라를 이용하므로 과금 부담을 줄일 수 있다는 점에서 유리하다. 특히, 반복적인 테스트나 빌드 작업이 많은 경우 비용 최적화 측면에서 효과적이다.

iOS 앱의 경우, macOS 환경에서만 빌드가 가능하기 때문에 GitHub-hosted Runner를 사용할 경우 반드시 macOS Runner를 사용해야 한다. 문제는 이 macOS Runner의 비용이 매우 높다는 점이다. 

GitHub 공식 요금 정책에 따르면 minute multiplier 기준으로 macOS는 **Linux 대비 10배의 소모량**을 기록하므로, 같은 작업을 실행하더라도 과금 속도가 훨씬 빠르다. 예를 들어, macOS에서 10분짜리 작업을 실행하면 100분이 사용된 것처럼 계산된다. 이러한 비용 구조는 iOS 앱을 자주 빌드하거나 테스트하는 팀에게 상당한 과금 부담으로 이어질 수 있다.

그래서 Self-hosted Runner를 사용하여 macOS 환경을 자체 구축하고, 이를 통해 비용을 크게 절감하면서도 안정적으로 iOS 앱을 빌드할 수 있는 CI/CD 환경을 구성하게 되었다.

&lt;div align=&quot;center&quot;&gt;
  &lt;img src=&quot;https://velog.velcdn.com/images/sangjin-hash/post/b7bfb576-9601-4880-b213-6d2cf062846c/image.png&quot; width=&quot;800&quot;&gt;
  &lt;p style=&quot;font-size: 14px; color: gray;&quot;&gt;▲ 그림 11. Github-hosted Runner Pricing &lt;/p&gt;
&lt;/div&gt;


#### - Runner 생성
https://devs0n.tistory.com/137 참고

#### - Github Actions Secrets 생성
GitHub Actions에서는 민감한 정보(API 키, 인증 토큰, 비밀번호 등)를 코드에 직접 작성하지 않고 안전하게 관리하기 위해 Secrets 기능을 제공한다.

이 값들은 저장소 설정에서 `Settings &gt; Secrets and variables &gt; Actions &gt; New repository secret` 경로를 통해 등록할 수 있다.

Actions Secrets 에 추가한 목록들은 다음과 같다.</code></pre><ul>
<li><p>Match 관련</p>
<ul>
<li>MATCH_PASSWORD : Fastlane match 명령어 실행 시 사용하는 저장소 비밀번호</li>
</ul>
</li>
<li><p>App Store Connect 관련</p>
<ul>
<li>ASC_KEY_ID: App Store Connect API Key의 Key ID</li>
<li>ASC_ISSUER_ID: App Store Connect API Key의 Issuer ID</li>
<li>ASC_KEY_P8: App Store Connect API Key 파일(.p8)의 내용을 base64 인코딩</li>
</ul>
</li>
<li><p>Fastlane 관련</p>
<ul>
<li>FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: Apple 계정용 App-Specific Password</li>
</ul>
</li>
<li><p>앱 관련</p>
<ul>
<li>APP_IDENTIFIER: 앱 패키지<pre><code></code></pre></li>
</ul>
</li>
</ul>
<p>이는 iOS 배포 자동화 때 필요한 데이터들이고, <code>.gitignore</code> 에 추가했던 키 값, 써드 파티 연동 관련 파일(ex. Firebase 연동 시 필요한 파일-GoogleService-Info.plist) 들도 마찬가지로 추가해줘야 한다. </p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/4880f716-2da7-44f1-873c-1f9c7d00ea42/image.png" width="500">
  <p style="font-size: 14px; color: gray;">▲ 그림 12. Actions secrets 추가</p>
</div>

<p>이 밖에도 <code>.gitignore</code> 에는 추가되어 있지만 변경이 잦은 파일의 경우 <code>Variables</code> 에 추가해주면 좋다. Github Secrets 에 등록된 것과 차이점은 Secrets 의 경우 값을 수정할 경우 기존의 값이 보이지 않는데, Variables 에는 수정 시 기존 값을 열람할 수 있다는 장점이 있다.</p>
<p>따라서 Variables 에는 환경 관련된 변수들, 자체 서버 Api Endpoint 들이 명시되어 있는 파일을 관리하였다.</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/9ecd5f50-593f-49a8-b8c6-a4956fe54d31/image.png" width="800">
  <p style="font-size: 14px; color: gray;">▲ 그림 13. Actions variables 추가</p>
</div>

<h4 id="--workflow-파일-작성">- Workflow 파일 작성</h4>
<p>Github Actions 이 어떤 이벤트를 트리거함과 동시에 Runner 가 실행되기 위한 전제조건으로는 <code>release</code> 브랜치에 푸시 이벤트가 발생할 시로 설정했다. 해당 브랜치로 설정한 이유로는 우선 형상 관리 전략에 대해 먼저 이야기 해야하는데,</p>
<p>우리 모바일 개발팀의 경우 Git-flow 전략을 이용하고 있다. 웹 애플리케이션과 다르게 크로스플랫폼으로 개발한 앱의 경우 업데이트 시 App Store와 Play Console 의 심사 작업이 필요하고 여기서 리젝될 경우 추가 수정 작업이 요구된다. 실제로 앱 초기 출시 당시에 심사 리젝이 6번 연속으로 되어 한동안 수정 작업을 했던 경험이 있어서, 이를 고려하여 <code>release</code> 브랜치에서 추가 작업을 한 뒤 심사가 통과될 때 스토어에 올라온 버전으로는 <code>main</code> 브랜치에 병합 뒤 버전에 대한 Tag 를 남기는 방식으로 진행했었다.</p>
<p>따라서 해당 워크플로우는 배포를 하려고 할 때, <code>release</code> 브랜치에서 푸시 이벤트가 발생 시 워크플로우가 실행되는 식으로 구성했다.</p>
<pre><code class="language-yaml">name: Deploy pipeline

on:
  push:
    branches:
      - release</code></pre>
<p>iOS 와 Android 빌드 전에 우선 환경 설정부터 해야 하는데, Fastlane 이 수행되는데 사용되는 <code>Ruby</code> 와 <code>Flutter</code> 버전 명시 후 환경 설정, 빌드 초기화/패키지 다운로드 등 빌드 시 필요한 작업들을 수행해야 한다. </p>
<p>이러한 작업을 하도록 <code>setup_environment</code> 라 명명하였고, 해당 작업에서는 iOS 와 Android 공통으로 사용하는 모듈들을 설정하도록 하고, &#39;iOS 의 Pod 설치&#39; 이러한 OS에 종속적인 작업들은 각 OS에서만 수행되면 되니까 분리시켜 놓았다.</p>
<p>또한 Actions Secrets &amp; Variables 에 저장한 파일들(<code>.gitignore</code> 에 명시된)을 가져와 파일을 생성해주는 로직도 포함하고 있다. 이러한 이유는 <code>Check Repository</code> 에서 사용자가 해당 브랜치에 푸시한 코드들을 그대로 받아오는데, 이 때 <code>.gitignore</code> 를 제외한 파일들을 가져오므로 로컬과 동일한 환경을 구성하기 위해선 파일 복사 과정이 필요하다.</p>
<ul>
<li><p>setup_environment</p>
<pre><code class="language-yaml">jobs:
setup_environment:
  name: Setup Common Environment
  runs-on: self-hosted
  steps:
    - name: Checkout Repository
      uses: actions/checkout@v3

    - name: Ruby 환경 설정
      uses: ruby/setup-ruby@v1
      with:
        ruby-version: 3.0.6

    - name: Flutter 환경 설정
      uses: subosito/flutter-action@v2
      with:
        flutter-version: &#39;3.24.1&#39;

    - name: 캐시된 빌드 파일 초기화
      run: flutter clean

    - name: 패키지 다운로드
      run: flutter pub get

    - name: Gitignore 된 중요 파일 복사
      run: |
        # Flavor 환경 변수 파일
        cat &lt;&lt;EOF &gt; lib/environment.dart
        ${{ vars.ENVIRONMENT_CONFIG }}
        EOF

        # Server Endpoint 파일
        cat &lt;&lt;EOF &gt; lib/core/util/network/dio/paths.dart
        ${{ vars.SERVER_PATH }}
        EOF

        # [Common] Fastlane Service Account json 생성
        cat &lt;&lt;EOF &gt; ./gachiga-serviceAccount.json
        ${{ secrets.FASTLANE_SERVICE_ACCOUNT_JSON }}
        EOF

        # Firebase Config 복구
        cat &lt;&lt;EOF &gt; lib/core/util/firebase/firebase_options_dev.dart
        ${{ secrets.FIREBASE_OPTIONS_DEV }}
        EOF

        # Firebase 연동 시 필요한 json(운영 서버)
        cat &lt;&lt;EOF &gt; lib/core/util/firebase/firebase_options_prod.dart
        ${{ secrets.FIREBASE_OPTIONS_PROD }}
        EOF

        cat &lt;&lt;EOF &gt; assets/json/firebase-service-key-prod.json
        ${{ secrets.FIREBASE_SERVICE_KEY_JSON }}
        EOF</code></pre>
</li>
</ul>
<p>이번엔 iOS 빌드를 위한 환경설정 때 해야 하는 작업으로 Pod 설치, Gemfile 관련 설치 그리고 Firebase 프로젝트 연동때 사용되는 Info.plist 생성 등이 있다. 이를 수행한 작업은 다음과 같다.</p>
<pre><code class="language-yaml">  deploy_ios:
    name: iOS Deployment
    runs-on: self-hosted
    steps:
      - name: Pod 설치
        working-directory: ios
        run: |
          pod install --no-repo-update

      - name: Gemfile 관련 설치
        run: |
          if ! gem list bundler -i &gt; /dev/null; then
            gem install bundler
          fi
          bundle install

      - name: GoogleService-Info.plist 생성
        run: |
          ## iOS
          mkdir -p Runner/Firebase/dev
          mkdir -p Runner/Firebase/prod

          cat &lt;&lt;EOF &gt; Runner/Firebase/dev/GoogleService-Info.plist
          ${{ secrets.IOS_GOOGLE_SERVICE_DEV_PLIST }}
          EOF

          cat &lt;&lt;EOF &gt; Runner/Firebase/prod/GoogleService-Info.plist
          ${{ secrets.IOS_GOOGLE_SERVICE_PROD_PLIST }}
          EOF

          ...</code></pre>
<p>지금까지 빌드를 위한 설정이 모두 끝났고, Fastfile 에서 선언한 lane 을 호출해야 하는데, 우선 배포 옵션을 고려해야 한다.
Flavor 를 통해 dev(개발 환경), prod(운영 환경) 을 나누었기 때문에 개발 환경으로 내부 테스트용 TestFlight 에 배포할지, 운영 환경으로 TestFlight 에 배포할지, 운영 환경으로 스토어에 배포할지 등 옵션을 고려해야 해야 했으므로 이는 커밋 메시지에서 <code>deploy:[배포 옵션]</code> 를 기준으로 실행되도록 설정하였다.</p>
<blockquote>
<p>커밋 메시지 예: &quot;[커밋 메시지 내용] - deploy:1&quot;</p>
</blockquote>
<pre><code class="language-yaml">      - name: 옵션에 따른 배포 실행
        env:
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ secrets.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }}
          KEY_ID: ${{ secrets.ASC_KEY_ID }}
          ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
          KEY_CONTENT: ${{ secrets.ASC_KEY_P8 }}
          APP_IDENTIFIER: ${{ secrets.APP_IDENTIFIER }}
        run: |
          COMMIT_MSG=$(git log -1 --pretty=%B)
          if [[ &quot;$COMMIT_MSG&quot; =~ deploy:[1-3] ]]; then
            DEPLOY_OPTION=$(echo &quot;$COMMIT_MSG&quot; | grep -o &#39;deploy:[1-3]&#39; | cut -d&#39;:&#39; -f2)
          else
            echo &quot;배포 옵션이 지정되지 않았습니다. 기본 옵션(1) 사용&quot;
            DEPLOY_OPTION=&quot;1&quot;
          fi
          echo &quot;선택된 배포 옵션: $DEPLOY_OPTION&quot;

          case &quot;$DEPLOY_OPTION&quot; in
            &quot;1&quot;)
              echo &quot;(prod) App Store Connect 배포 시작&quot;
              fastlane deploy_to_app_store flavor:prod key_id:$KEY_ID issuer_id:$ISSUER_ID key_content:$KEY_CONTENT app_identifier:$APP_IDENTIFIER
              ;;
            &quot;2&quot;)
              echo &quot;(prod) TestFlight 배포 시작&quot;
              fastlane deploy_to_testflight flavor:prod key_id:$KEY_ID issuer_id:$ISSUER_ID key_content:$KEY_CONTENT app_identifier:$APP_IDENTIFIER
              ;;
            &quot;3&quot;)
              echo &quot;(dev) TestFlight 배포 시작&quot;
              fastlane deploy_to_testflight flavor:dev key_id:$KEY_ID issuer_id:$ISSUER_ID key_content:$KEY_CONTENT app_identifier:$APP_IDENTIFIER
              ;;
            *)
              echo &quot;Invalid deployment option selected: $DEPLOY_OPTION&quot;
              exit 1
              ;;
          esac</code></pre>
<hr>
<h3 id="ios-배포-자동화-결과">iOS 배포 자동화 결과</h3>
<ul>
<li><p>TestFlight upload</p>
<div align="center">
<img src="https://velog.velcdn.com/images/sangjin-hash/post/2486e624-3021-4e85-b505-3121cccf5929/image.png" width="600">
<p style="font-size: 14px; color: gray;">▲ 그림 14. TestFlight 업로드 결과</p>
</div>
</li>
<li><p>AppStore Connect upload</p>
<div align="center">
<img src="https://velog.velcdn.com/images/sangjin-hash/post/07cecd11-fe42-4b72-8694-184813c6a53e/image.png" width="600">
<p style="font-size: 14px; color: gray;">▲ 그림 15. AppStore 업로드 결과</p>
</div>

</li>
</ul>
<hr>
<h3 id="reference">Reference</h3>
<p>[Match]</p>
<ul>
<li><a href="https://docs.fastlane.tools/actions/match/">https://docs.fastlane.tools/actions/match/</a></li>
<li><a href="https://millo-l.github.io/ReactNative-fastlane-match/">https://millo-l.github.io/ReactNative-fastlane-match/</a></li>
<li><a href="https://velog.io/@dvhuni/fastlane-match">https://velog.io/@dvhuni/fastlane-match</a></li>
<li><a href="https://velog.io/@parkgyurim/iOS-fastlane-match">https://velog.io/@parkgyurim/iOS-fastlane-match</a></li>
</ul>
<p>[Github Actions Self-hosted Runner]</p>
<ul>
<li><a href="https://danawalab.github.io/common/2022/08/24/Self-Hosted-Runner.html">https://danawalab.github.io/common/2022/08/24/Self-Hosted-Runner.html</a></li>
<li><a href="https://devs0n.tistory.com/137">https://devs0n.tistory.com/137</a></li>
<li><a href="https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners">https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners</a></li>
</ul>
<p>[Fastlane]</p>
<ul>
<li><a href="https://docs.fastlane.tools/getting-started/cross-platform/flutter/">https://docs.fastlane.tools/getting-started/cross-platform/flutter/</a></li>
<li><a href="https://docs.flutter.dev/deployment/cd#fastlane">https://docs.flutter.dev/deployment/cd#fastlane</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] 배포 환경에 따라 내부 테스트 구성하기 - Firebase App Distribution]]></title>
            <link>https://velog.io/@sangjin-hash/Android-%EB%B0%B0%ED%8F%AC-%ED%99%98%EA%B2%BD%EC%97%90-%EB%94%B0%EB%9D%BC-%EB%82%B4%EB%B6%80-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EA%B5%AC%EC%84%B1%ED%95%98%EA%B8%B0-Firebase-App-Distribution</link>
            <guid>https://velog.io/@sangjin-hash/Android-%EB%B0%B0%ED%8F%AC-%ED%99%98%EA%B2%BD%EC%97%90-%EB%94%B0%EB%9D%BC-%EB%82%B4%EB%B6%80-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EA%B5%AC%EC%84%B1%ED%95%98%EA%B8%B0-Firebase-App-Distribution</guid>
            <pubDate>Tue, 13 May 2025 14:12:39 GMT</pubDate>
            <description><![CDATA[<h3 id="문제-정의">문제 정의</h3>
<p>이전 게시글에서 <code>flavor</code> 를 통해 개발 환경(<code>dev</code>) 와 운영 환경(<code>prod</code>)을 나누어 앱 내 사용되는 환경 변수들을 교체하고, <code>firebase</code> 사용 시 각 환경에 맞는 프로젝트와 연동되도록 하는 방법에 대해 알아보았다. 이 때 Android 의 경우 개발 환경(<code>dev</code>)으로 빌드한 앱을 Play Console 의 &#39;내부 테스트&#39; 에 올릴 때 앱을 서명할 키가 없어 <strong>Firebase 프로젝트 연동</strong> 뿐 만 아니라 <strong>구글 로그인</strong> 까지 되지 않는 문제가 있었다.</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/be4b54af-ffbb-4ebf-8179-1104455eff3e/image.png" width=70%>
  <p style="font-size: 14px; color: gray;">▲ 그림 1. (기존) Flavor 환경에 따라 사용되는 Signing key</p>
</div>

<p>이러한 문제가 발생하게 된 이유는 다음과 같다.</p>
<blockquote>
<ol>
<li>Play Console 에 앱 배포 시 개발자가 업로드 한 키(Upload key), Google Play 가 서명하는 앱 서명 키(App Signing key) 에는 alias 가 1개만 등록이 가능하다 =&gt; 운영 환경에만 사용 가능</li>
<li>키의 SHA-1, SHA-256 을 여러 Firebase 프로젝트에 등록이 불가능하다</li>
</ol>
</blockquote>
<p>개발자가 IDE 에서 Release mode로 빌드하고 테스트를 한다고 가정했을 때, 개발 환경 전용 앱 서명 키를 하나 생성한 뒤 운영 환경과 별도로 관리한다면 Firebase 연동/구글 로그인 문제는 발생하지 않는다. </p>
<p>그러나 다른 팀원이 앱을 테스트 해야 하는 경우(ex. QA 팀에게 앱 번들 전달) 보통 Play Console의 &#39;내부 테스트&#39; 를 이용할텐데, 내부 테스트에 aab 파일을 업로드 할 때 Play Console 에 등록된 앱 서명 키가 사용되기 때문에 운영 환경이 아닌 개발 환경 전용 앱은 해당 키로 서명을 할 수 없어 위에서 다루었던 문제를 겪게 된다.</p>
<p>또 다른 문제로는 Play Console 의 내부 테스트에 앱 번들을 업로드하면 업데이트 되는데 걸리는 시간이 평균 30분~1시간이다. 앱 번들을 업로드하는데 시간이 오래 걸리진 않지만, 내부 테스터들이 새로운 버전의 앱을 다운로드 받을 수 있도록 업데이트 되는데 시간이 iOS 에 비해 오래 소요되었다(TestFlight 업로드 &amp; 업데이트 시 평균 15분)</p>
<p>따라서 본 게시글에서는 <strong>(1)내부 테스트에 개발 환경 앱 배포 시 서명 키 문제</strong>와 <strong>(2)내부 테스트 앱 업데이트가 오래 걸리는 문제</strong> 를 해결하기 위해 <code>Firebase App Distribution</code>을 대안으로 선택하였으며, 이를 통한 문제 해결 방안을 설명하고자 한다.</p>
<hr>
<h3 id="새로운-sandbox-환경-추가-및-signingconfigs-네이밍-변경">새로운 Sandbox 환경 추가 및 SigningConfigs 네이밍 변경</h3>
<p>기존에 다루었던 빌드 환경은 다음과 같다.</p>
<ul>
<li>Flavor: dev, BuildType: Debug =&gt; 개발 환경</li>
<li>Flavor: prod, BuildType: Debug =&gt; 운영 환경(debug)</li>
<li>Flavor: prod, BuildType: Release =&gt; 운영 환경(release)<div align="center">
<img src="https://velog.velcdn.com/images/sangjin-hash/post/e3b794d6-4dc0-41dc-9def-1bad11f2387f/image.png" width=70%>
<p style="font-size: 14px; color: gray;">▲ 그림 2. (기존) BuildType 과 Flavor 에 따른 SigningConfigs</p>
</div>

</li>
</ul>
<p><br></br>
여기서 Flavor: dev, BuildType: Release 가 추가 되므로 해당 환경 전용 서명 키를 생성했고, SigningConfigs 의 네이밍을 좀 더 직관적으로 변경하였다.</p>
<ul>
<li><p>서명키 생성</p>
<pre><code class="language-bash">keytool -genkeypair \
  -alias dev-release-key \
  -keyalg RSA \
  -keysize 2048 \
  -validity 3650 \
  -keystore android/keystore/release/dev/dev-release-key.jks</code></pre>
<p><br></br></p>
</li>
<li><p>네이밍 변경</p>
<blockquote>
<ul>
<li>devDebug -&gt; development</li>
<li><blockquote>
<p>새로운 기능 개발 및 유지보수를 위한 기본 디버그 환경</p>
</blockquote>
</li>
<li>prodDebug -&gt; hotfix</li>
<li><blockquote>
<p>운영 중인 서비스의 긴급 오류 수정 시 빠른 로그 확인을 위한 환경</p>
</blockquote>
</li>
<li>release -&gt; production</li>
<li><blockquote>
<p>최종 배포 및 실제 사용자에게 제공되는 운영 환경</p>
</blockquote>
</li>
<li>sandbox 추가(Flavor: dev &amp;&amp; BuildType: release)</li>
<li><blockquote>
<p>팀원 및 QA 팀에게 테스트 목적으로 앱 번들을 제공하기 위한 환경</p>
</blockquote>
</li>
</ul>
</blockquote>
</li>
</ul>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/2b4b980f-6e7d-4ef1-a9cf-c25d055772a0/image.png" width=70%>
  <p style="font-size: 14px; color: gray;">▲ 그림 3. (변경) sandbox 환경 추가 및 네이밍 변경</p>
</div>

<ul>
<li><code>key</code> 관련 디렉토리 구조<pre><code class="language-text">android/
├── keystore/
│   ├── debug/
│   │   ├── debug.keystore
│   │   └── debug-key.properties
│   │
│   └── release/
│       ├── dev/
│       │   ├── dev-release-key.jks
│       │   └── dev-release-key.properties
│       │
│       └── prod/
│           ├── key.jks
│           └── key.properties</code></pre>
</li>
</ul>
<hr>
<h3 id="배포-환경-나누기">배포 환경 나누기</h3>
<ul>
<li>android/app/build.gradle 에서 properties 변수 설정<pre><code class="language-gradle">// [development &amp; hotfix]
// flavor: dev &amp; prod / Build : Debug
def debugKeystoreProperties = new Properties()
def debugKeystorePropertiesFile = rootProject.file(&#39;keystore/debug/debug-key.properties&#39;)
if (debugKeystorePropertiesFile.exists()) {
  debugKeystoreProperties.load(new FileInputStream(debugKeystorePropertiesFile))
}
</code></pre>
</li>
</ul>
<p>// [sandbox]
// flavor: dev / Build: Release
def sandboxKeystoreProperties = new Properties()
def sandboxKeystorePropertiesFile = rootProject.file(&#39;keystore/release/dev/dev-release-key.properties&#39;)
if (sandboxKeystorePropertiesFile.exists()) {
    sandboxKeystoreProperties.load(new FileInputStream(sandboxKeystorePropertiesFile))
}</p>
<p>// [production]
// flavor: Prod / Build : Release
def productionKeystoreProperties = new Properties()
def productionKeystorePropertiesFile = rootProject.file(&#39;keystore/release/prod/key.properties&#39;)
if (productionKeystorePropertiesFile.exists()) {
    productionKeystoreProperties.load(new FileInputStream(productionKeystorePropertiesFile))
}</p>
<pre><code>
- signingConfigs 설정
```gradle
android {
    ...
    signingConfigs {
        // Flavor: dev / Build: Debug
        development {
            storeFile file(debugKeystoreProperties[&#39;storeFile&#39;])
            storePassword debugKeystoreProperties[&#39;storePassword&#39;]
            keyAlias debugKeystoreProperties[&#39;devKeyAlias&#39;]
            keyPassword debugKeystoreProperties[&#39;keyPassword&#39;]
        }

        // Flavor: prod / Build: Debug
        hotfix {
            storeFile file(debugKeystoreProperties[&#39;storeFile&#39;])
            storePassword debugKeystoreProperties[&#39;storePassword&#39;]
            keyAlias debugKeystoreProperties[&#39;prodKeyAlias&#39;]
            keyPassword debugKeystoreProperties[&#39;keyPassword&#39;]
        }

        // Flavor: dev / Build: Release
        sandbox {
            keyAlias sandboxKeystoreProperties[&#39;keyAlias&#39;]
            keyPassword sandboxKeystoreProperties[&#39;keyPassword&#39;]
            storeFile sandboxKeystoreProperties[&#39;storeFile&#39;] ? file(sandboxKeystoreProperties[&#39;storeFile&#39;]) : null
            storePassword sandboxKeystoreProperties[&#39;storePassword&#39;]
        }

        // Flavor: prod / Build: Release
        production {
            keyAlias productionKeystoreProperties[&#39;keyAlias&#39;]
            keyPassword productionKeystoreProperties[&#39;keyPassword&#39;]
            storeFile productionKeystoreProperties[&#39;storeFile&#39;] ? file(productionKeystoreProperties[&#39;storeFile&#39;]) : null
            storePassword productionKeystoreProperties[&#39;storePassword&#39;]
        }
    }</code></pre><ul>
<li><p>buildTypes 설정</p>
<pre><code class="language-gradle">buildTypes {
      debug {
          minifyEnabled false
          shrinkResources false
          signingConfig null
      }

      release {
          minifyEnabled true
          proguardFiles getDefaultProguardFile(&#39;proguard-android.txt&#39;), &#39;proguard-rules.pro&#39;
          signingConfig null
      }
  }
</code></pre>
</li>
</ul>
<pre><code>
- productFlavors 설정
```gradle
    flavorDimensions &quot;build-type&quot;

    productFlavors {
        development {
            dimension &quot;build-type&quot;
            signingConfig signingConfigs.development
        }

        hotfix {
            dimension &quot;build-type&quot;
            signingConfig signingConfigs.hotfix
        }

        sandbox {
            dimension &quot;build-type&quot;
            signingConfig signingConfigs.sandbox
        }

        production {
            dimension &quot;build-type&quot;
            signingConfig signingConfigs.production
        }
    }</code></pre><hr>
<h3 id="firebase-프로젝트-연동">Firebase 프로젝트 연동</h3>
<p>Firebase 프로젝트 연동 시 프로젝트 내에 <code>google-services.json</code> 을 포함시켜야 한다. 위에서 나눈 <code>productFlavors</code> 에 따라 연동해야 하는 Firebase 프로젝트의 <code>google-services.json</code> 을 복사해주기만 하면 된다. Firebase 프로젝트는 개발 환경 프로젝트와(flavor: <code>dev</code>) 운영 환경 프로젝트(flavor: <code>prod</code>) 로 나눠놓았기 때문에, 각 <code>productFlavors</code> 의 flavor 에 맞춰 <code>google-services.json</code> 을 복사한다.</p>
<pre><code class="language-text">android/
└── app/
    └── src/
        ├── debug/
        ├── development/
        │   └── google-services.json(dev 용)
        ├── hotfix/
        │   └── google-services.json(prod 용)
        ├── main/
        ├── production/
        │   └── google-services.json(prod 용)
        ├── profile/
        └── sandbox/
            └── google-services.json(dev 용)</code></pre>
<p><code>sandbox</code> 환경을 서명할 키를 생성했으니, 해당 키의 SHA-1 과 SHA-256 을 프로젝트 설정에 추가해줘야 연동이 가능하다.</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/5f804ba6-b8e8-465b-b753-a8c4ce6c4bc5/image.png" width=70%>
  <p style="font-size: 14px; color: gray;">▲ 그림 4. SHA-1, SHA-256 추가</p>
</div>



<hr>
<h3 id="firebase-app-distribution에-apk-업로드">Firebase App Distribution에 APK 업로드</h3>
<p><code>sandbox</code> 환경의 <code>.aab</code> 파일을 업로드를 하였으나, &#39;app bundle 설정이 불완전합니다. 단계를 따라 app bundle 업로드를 위해 프로젝트를 설정하세요.&#39; 라는 에러가 나오면서 업로드가 되지 않았다. Firebase FAQ 에서 aab 업로드 관련 FAQ를 찾아본 결과 해당 앱 번들은 자체적으로 생성한 서명 키이고, Google Play에서 테스트 앱 서명 키 인증서를 발급받지 않았으므로 서명에서 문제가 생겨 업로드가 되지 않은 것 같다.</p>
<ul>
<li><a href="https://firebase.google.com/docs/app-distribution/troubleshooting?hl=ko&amp;platform=android">https://firebase.google.com/docs/app-distribution/troubleshooting?hl=ko&amp;platform=android</a><div align="center">
<img src="https://velog.velcdn.com/images/sangjin-hash/post/97377f59-764b-42ad-b93a-be8a45893797/image.png" width=60%>
<p style="font-size: 14px; color: gray;">▲ 그림 5. Firebase 앱 배포 문제 해결 및 FAQ</p>
</div>

</li>
</ul>
<p><br></br>
따라서 <code>.aab</code> 가 아닌 <code>.apk</code> 를 업로드를 하니 성공적으로 업로드가 되었다.</p>
<ul>
<li>apk 생성 <pre><code class="language-bash">flutter build apk --flavor sandbox --release --dart-define=FLAVOR=dev</code></pre>
</li>
</ul>
<p>해당 커맨드에서 뒤 <code>--dart-define=FLAVOR=[Flavor 환경]</code> 의 의미는 앱이 실행될 때 [Flavor 환경]을 전달받아 환경 변수들(ex. baseUrl, 을 세팅하는데 사용된다.</p>
<pre><code class="language-dart">// Initializes environment settings based on the value of the &quot;FLAVOR&quot; environment variable.
// The FLAVOR value is set during compilation, such as `--dart-define=FLAVOR=debug`.
String flavor = const String.fromEnvironment(&#39;FLAVOR&#39;);
Environment.initialize(flavor);</code></pre>
<p><br></br></p>
<ul>
<li>Firebase App Distribution 에 apk 업로드
apk 생성 커맨드를 입력한 뒤 apk 파일을 수동으로 업로드 시 등록된 테스터에게 모두 메일이 가며 메일의 다운로드 링크를 통해 업로드한 sandbox 용 앱을 다운로드 받을 수 있다.</li>
</ul>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/c79743cd-2fad-4822-9152-e6a5f7e7f037/image.png" width=70%>
  <p style="font-size: 14px; color: gray;">▲ 그림 6. App Distribution 에 apk 업로드</p>
</div>

<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/9a04f0d4-4b40-4ff5-954a-6305e321ff1d/image.PNG" width=20%>
  <p style="font-size: 14px; color: gray;">▲ 그림 7. apk 설치 메일</p>
</div>

<hr>
<h3 id="결론">결론</h3>
<p>이번 게시글에서는 sandbox 환경 추가와 이를 위한 Firebase App Distribution에 업로드 방법을 다루었다. 이를 통해 다음과 같은 문제를 효과적으로 해결할 수 있었다:</p>
<ul>
<li>sandbox는 α-test (팀 내부 테스트)<ul>
<li>Flavor: dev, Build Type: Release</li>
<li>팀원 및 QA 팀에게 테스트 목적으로 앱 번들을 제공하는 환경</li>
<li>내부 테스트 업로드 시 발생하는 Firebase 연동과 구글 로그인 문제를 해결하기 위해 =&gt; Firebase App Distribution 에 배포</li>
</ul>
</li>
</ul>
<ul>
<li>Firebase App Distribution은 sandbox 뿐만 아니라 production에서도 사용할 수 있음<ul>
<li>sandbox는 초기 기능 검증을 위한 내부 테스트</li>
<li>production은 실사용자 피드백 수집을 위한 베타 테스트</li>
<li>두 환경 모두 빠른 배포와 피드백 수집에 최적화된 배포 채널</li>
<li><code>.apk</code> 나 <code>.aab</code> 업로드 시 바로 테스터에게 메일 발송 =&gt; 다운로드 가능</li>
</ul>
</li>
</ul>
<p><code>sandbox</code> 환경에서 빌드된 앱 번들은 여전히 Play Console의 내부 테스트에 직접 업로드할 수 없다 (<code>production</code> 환경의 앱만 가능). 그러나 Firebase App Distribution은 내부 테스트에서 제공하는 기능들을 대부분 지원하므로, 빠른 피드백 수집과 테스터 관리가 필요한 경우 훌륭한 대안이 될 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Flutter] 클린 아키텍처 회고록(4)]]></title>
            <link>https://velog.io/@sangjin-hash/Flutter-%ED%81%B4%EB%A6%B0-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%ED%9A%8C%EA%B3%A0%EB%A1%9D4</link>
            <guid>https://velog.io/@sangjin-hash/Flutter-%ED%81%B4%EB%A6%B0-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%ED%9A%8C%EA%B3%A0%EB%A1%9D4</guid>
            <pubDate>Mon, 21 Apr 2025 13:55:54 GMT</pubDate>
            <description><![CDATA[<p>지금까지 회고록의 시행착오 과정은 다음과 같다.</p>
<blockquote>
<ol>
<li>Entity &lt;-&gt; Request/Response DTO 1:1 대응</li>
<li>도메인 중심의 가변적인 Entity 설계</li>
<li>Param 도입</li>
</ol>
</blockquote>
<p>이러한 일련의 과정을 거치며, 설계와 아키텍처에 대한 고민은 점차 실전 운영 관점으로 확장되었다. 실제로 1년 넘게 서비스를 운영하면서 기능 추가, 정책 변경, 사업 아이템 확장, 버그 대응과 같은 다양한 변경 요구사항이 반복되었고, 이에 따라 변화에 유연하게 대응할 수 있는 구조의 중요성이 명확해졌다. 유지보수성과 확장성을 고려한 설계는 단순한 기술 선택의 문제가 아니라, 서비스 생명주기 전반에 영향을 미치는 핵심 요소로 작용하게 된다.</p>
<p>이러한 문제의식에서 출발해, <strong>클라이언트와 서버 각각의 상태 관리 책임을 명확히 분리</strong>하고 상태의 성격에 따라 관리 단위를 나누는 아키텍처를 도입하게 되었다. <strong>클라이언트는 UI 단의 로컬 상태만을 관리</strong>하고, <strong>서버 상태는 API 응답 또는 내부 DB와 같은 외부 데이터 소스로부터 발생하는 비동기 상태</strong>를 전담하는 구조다. 이전에는 이 두 상태를 하나의 Bloc 혹은 Cubit에서 통합 관리했지만, 현재는 역할에 따라 상태를 분리하고 독립적으로 관리하는 방식으로 변경하여 사용하고 있고 지속적으로 검증 중이다. 단순히 이론적인 접근이 아니라, 실전에서 발생하는 문제들을 해결하면서 점점 더 구체화되고 있는 구조라고 보면 좋을 것 같다.</p>
<h3 id="keyword-및-중점-내용">Keyword 및 중점 내용</h3>
<ul>
<li>Client-Side State / Client Side Cubit</li>
<li>Server-Side State / Server Side Bloc</li>
</ul>
<h3 id="개요">개요</h3>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/bf04c503-2e86-4c99-8fe9-2114c2f0a846/image.png" width=100%>
  <p style="font-size: 14px; color: gray;">▲ 그림 1. 이전 게시글의 Param 도입 컴포넌트 다이어그램</p>
</div>


<p>다음은 이전 게시글에서 언급했던 Param을 도입한 컴포넌트 다이어그램이다. Param을 도입함으로써 다양한 입력 양식이나 화면 단계가 존재하는 복잡한 폼 구조에서 필요한 데이터만 수집하고 이를 관리한다는 점에서 용이하였으나, Presentation 전용 <code>Mapper(Param -&gt; Entity)</code> 가 필요하다보니 Mapper 클래스가 하나 더 추가되면서 구조가 불필요하게 복잡해지는 측면이 있었다.</p>
<p>또한, 데이터 수집 및 상태 관리를 전적으로 Param과 Bloc에 위임하는 구조에서는, <strong>UI 단에서만 필요한 상태 로직(예: 입력값 유효성 검사, UI 조건 분기 등)과 도메인 로직을 수행하기 위한 상태 로직(예: 서버 통신, API 호출 등)이 하나의 Bloc 내부에 혼재되어 관리되는 문제가 있었다.</strong> 유효성 검사와 같은 UI 상태에 대해 별도의 상태를 정의하고 이를 BlocBuilder에서 처리하려 할 경우, 도메인 상태와 UI 상태를 모두 구분 렌더링해야 하는 비효율적인 구조로 이어질 수밖에 없다. 이런 설계는 단순 UI 상의 상태 변화까지도 BlocBuilder가 리렌더링을 관장하게 되어, 불필요한 렌더링을 유발하고 컴포넌트 간 책임 구분을 모호하게 만든다. 이처럼 UI 로직과 도메인 로직이 동일한 Bloc 내에서 함께 처리되다 보면, Bloc이 지나치게 많은 책임을 지게 되고, 관심사 분리가 어렵고 구조가 복잡해지는 단점이 발생한다.</p>
<p><a name="그림2"></a></p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/ba291fe2-2aa6-49ac-9e5e-c5c81e0567e3/image.png" width=100%>
  <p style="font-size: 14px; color: gray;">▲ 그림 2. Client-side, Server-side 상태 관리 분리</p>
</div>


<p>이러한 문제를 해결하고자, <strong>UI 상태를 전담하는 Client Side Cubit/State와 서버 통신 상태를 처리하는 Server Side Bloc/State 구조로 분리</strong>하였다. 이를 통해 각 계층의 역할을 명확히 하고, 상태 관리 기준을 UI 로직과 도메인 로직 단위로 나누는 설계 전환을 시도하게 되었다.</p>
<p>이번 포스트도 마찬가지로, 클린 아키텍처 회고록 시리즈에서 다뤄왔던 &#39;프로필 생성 시나리오&#39;를 예시로 들겠다. </p>
<hr>
<h3 id="client-side-vs-server-side-상태-분리-기준">Client-side vs Server-side 상태 분리 기준</h3>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/349a5b9a-381f-4d04-9e43-6e1e679e0eb6/image.png" width=100%>
  <p style="font-size: 14px; color: gray;">▲ 그림 3. 프로필 생성 시나리오</p>
</div>

<p>해당 시나리오를 기준으로 필요한 &#39;UI 로직&#39;과 &#39;도메인 로직&#39;은 다음과 같다.</p>
<blockquote>
<ul>
<li>UI 로직<ul>
<li>국가 선택, 거주 연수, 성별, 생년월일 등 폼 입력값 선택/유지</li>
<li>닉네임 텍스트 입력 상태</li>
<li>유효성 검사 (ex. 필수 항목 선택 및 기입 여부)</li>
<li>모든 조건이 충족되었을 때만 “가입완료하고 시작하기” 버튼 활성화</li>
</ul>
</li>
</ul>
</blockquote>
<blockquote>
<ul>
<li>도메인 로직<ul>
<li>닉네임 중복확인(Server 에 API 요청)</li>
<li>프로필 생성(Server 에 API 요청)</li>
</ul>
</li>
</ul>
</blockquote>
<p>UI 상태는 사용자의 입력에 즉각적으로 반응하며, 클라이언트 컴포넌트 내부에서만 유효한 정보이므로 Client-side에서 관리하는 것이 적절하다. 반면, 서버와의 통신이 수반되는 도메인 로직은 Server-side Bloc을 통해 비동기적으로 처리하여 상태 흐름을 관리한다.</p>
<hr>
<h3 id="client-side-cubitstate">Client-side Cubit/State</h3>
<h4 id="--client-side-state--cubit-핵심-역할">- Client-side State &amp; Cubit 핵심 역할</h4>
<blockquote>
<ul>
<li>Client-side State</li>
<li><blockquote>
<p>UI에서 입력된 데이터를 일시적으로 저장하고, 입력값의 유효성(Validation) 여부까지 포함하여 사용자 상호작용에 따른 화면 상태를 관리</p>
</blockquote>
</li>
<li>Client-side cubit</li>
<li><blockquote>
<p>상태를 조작하고 업데이트하는 역할로, 사용자의 입력이나 액션에 따라 state를 변경하고, 폼 완성 여부 등을 판단하는 로직을 포함</p>
</blockquote>
</li>
</ul>
</blockquote>
<ul>
<li>personal_profile_create_client_state.dart</li>
</ul>
<p>이전 게시글에서는 Param 에서 수집해야 하는 데이터들을 모두 가지고 있었는데, 이와 비슷한 논리로 Client-side State 에서 수집한 데이터들을 가지면 된다. 기존 Param 과 다른 점은, <code>validation</code> 관련 변수들이 추가된 점이다. 해당 변수들은 UI 단에서 수집된 데이터들이 유효한 값을 가지는지에 대해 나타내는 변수로 컴포넌트 렌더링에 직접적인 영향을 주는 변수이기 때문에 <strong>Client-side State 에서 관리</strong>하도록 했다.</p>
<pre><code class="language-dart">class PersonalProfileCreateClientState extends Equatable {
  final String profilePhoto;
  final String nickName;
  final String introduction;
  final String residenceCountryCode;
  final String residenceStateCode;
  final String residenceCityCode;
  final String birth;
  final bool male;
  final bool traveler;
  final double residenceYear;
  final List&lt;InterestExpertiseType&gt; interests;
  final List&lt;InterestExpertiseType&gt; expertises;

  /** 
   * Validation 관련 변수들
   */

  /// 닉네임 중복 검사
  final bool isNicknameUnique;
  /// 필수입력 값들 모두 받았는지 =&gt; 프로필 생성 버튼 활성화
  final bool isCreated;


  /// 초기화
  const PersonalProfileCreateClientState(
      {this.profilePhoto = &#39;&#39;,
      this.backgroundProfilePhoto = &#39;&#39;,
      this.nickName = &#39;&#39;,
      this.introduction = &#39;&#39;,
      this.residenceCountryCode = &#39;&#39;,
      this.residenceStateCode = &#39;&#39;,
      this.residenceCityCode = &#39;&#39;,
      this.birth = &#39;&#39;,
      this.male = true,
      this.traveler = true,
      this.residenceYear = 1,
      this.interests = const [],
      this.expertises = const [],
      this.isCreated = false,
      this.isLoadedProfilePhoto = false,
      this.isLoadedBackgroundPhoto = false,
      this.nicknameUniqueInfoMessage = &#39;&#39;,
      this.isNicknameUnique = false});

  /// 객체 복사
  PersonalProfileCreateClientState copyWith({
    String? profilePhoto,
    String? backgroundProfilePhoto,
    String? nickName,
    String? introduction,
    String? residenceCountryCode,
    String? residenceStateCode,
    String? residenceCityCode,
    String? birth,
    bool? male,
    bool? traveler,
    double? residenceYear,
    List&lt;InterestExpertiseType&gt;? interests,
    List&lt;InterestExpertiseType&gt;? expertises,
    bool? isCreated,
    bool? isNicknameUnique,
  }) {
    return PersonalProfileCreateClientState(
      profilePhoto: profilePhoto ?? this.profilePhoto,
      backgroundProfilePhoto:
          backgroundProfilePhoto ?? this.backgroundProfilePhoto,
      nickName: nickName ?? this.nickName,
      introduction: introduction ?? this.introduction,
      residenceCountryCode: residenceCountryCode ?? this.residenceCountryCode,
      residenceStateCode: residenceStateCode ?? this.residenceStateCode,
      residenceCityCode: residenceCityCode ?? this.residenceCityCode,
      birth: birth ?? this.birth,
      male: male ?? this.male,
      traveler: traveler ?? this.traveler,
      residenceYear: residenceYear ?? this.residenceYear,
      interests: interests ?? this.interests,
      expertises: expertises ?? this.expertises,
      isCreated: isCreated ?? this.isCreated,
      isNicknameUnique: isNicknameUnique ?? this.isNicknameUnique,
    );
  }

  @override
  List&lt;Object?&gt; get props =&gt; [
        profilePhoto,
        backgroundProfilePhoto,
        nickName,
        expertises,
        introduction,
        residenceCountryCode,
        residenceStateCode,
        residenceCityCode,
        birth,
        male,
        residenceYear,
        traveler,
        interests,
        isCreated,
        isNicknameUnique,
      ];
}</code></pre>
<ul>
<li><p>personal_profile_create_client_cubit.dart
해당 Cubit 에서는 State 에서 관리하는 변수들을 초기화 하는 로직들과 마지막에 <code>_isValidForm()</code> 와 같은 필수입력 값들을 모두 받았는지 확인하는 로직을 가지고 있다. </p>
<pre><code class="language-dart">class PersonalProfileCreateClientCubit
  extends Cubit&lt;PersonalProfileCreateClientState&gt; {
PersonalProfileCreateClientCubit()
    : super(PersonalProfileCreateClientState());

void setProfilePhoto(String profilePhoto) {
  emit(state.copyWith(profilePhoto: profilePhoto));
  emit(state.copyWith(isCreated: _isValidForm()));
}

void setExpertises(List&lt;InterestExpertiseType&gt; expertises) {
  emit(state.copyWith(expertises: expertises));
  emit(state.copyWith(isCreated: _isValidForm()));
}

void setInterests(List&lt;InterestExpertiseType&gt; interests) {
  emit(state.copyWith(interests: interests));
  emit(state.copyWith(isCreated: _isValidForm()));
}

void setIntroduction(String introduction) {
  emit(state.copyWith(introduction: introduction));
  emit(state.copyWith(isCreated: _isValidForm()));
}

...
(생략)

bool _isValidForm() {
  return state.profilePhoto.isNotEmpty &amp;&amp;
      state.nickName.isNotEmpty &amp;&amp;
      state.introduction.isNotEmpty &amp;&amp;
      state.residenceCountryCode.isNotEmpty &amp;&amp;
      state.birth.isNotEmpty &amp;&amp;
      ...
      state.interests.isNotEmpty &amp;&amp;
      state.expertises.isNotEmpty &amp;&amp;
      state.isNicknameUnique;  // 닉네임 중복검사 여부 확인
}
}</code></pre>
</li>
</ul>
<h4 id="--프로필-수정-update-유스케이스-적용-방식">- 프로필 수정 (Update) 유스케이스 적용 방식</h4>
<p>위의 예시는 &#39;프로필 생성&#39; 에 관련된 유스케이스를 다루지만, 서버로부터 데이터(ex. 로그인한 사용자 프로필)를 받아와 수정하는 화면에서는 어떻게 해야할까? &#39;생성&#39; 유스케이스에서는 사용자로부터 직접 데이터 입력을 받아 생성자를 초기화할 때 Default 값을 넣어줬지만, &#39;수정&#39; 유스케이스에서는 사용자가 이전에 입력했었던 정보들을 서버로부터 받아와 초기화해줘야 하므로 <strong>생성자</strong> 부분에 초기화하는 로직만 다를 뿐, 데이터 저장 &amp; 데이터 조작 &amp; 유효성 검증의 책임은 모두 동일하다.</p>
<ul>
<li>personal_profile_update_client_state.dart<pre><code class="language-dart">part of &#39;personal_profile_update_client_cubit.dart&#39;;
</code></pre>
</li>
</ul>
<p>class PersonalProfileUpdateClientState extends Equatable {
  (create_client_state 멤버 변수들과 동일)
  ...</p>
<p>  const PersonalProfileUpdateClientState(
      {required this.profilePhoto,
      required this.backgroundProfilePhoto,
      required this.nickName,
      required this.introduction,
      required this.residenceCountryCode,
      required this.residenceStateCode,
      required this.residenceCityCode,
      required this.residenceYear,
      required this.traveler,
      required this.interests,
      required this.expertises,
      this.isUpdated = false,
      this.isLoadedProfilePhoto = false,
      this.isLoadedBackgroundPhoto = false,
      this.nicknameUniqueInfoMessage = &#39;&#39;,
      this.isNicknameUnique = false});</p>
<pre><code>
- personal_profile_update_client_cubit.dart
```dart
class PersonalProfileUpdateClientCubit
    extends Cubit&lt;PersonalProfileUpdateClientState&gt; {
  final ProfileEntity entity; // 서버로부터 받은 데이터

  PersonalProfileUpdateClientCubit(this.entity)
      : super(PersonalProfileUpdateClientState(
          profilePhoto: entity.profilePreviewEntity.profilePhotoUrl,
          backgroundProfilePhoto: entity.backgroundPhotoUrl,
          nickName: entity.profilePreviewEntity.nickname,
          introduction: entity.profilePreviewEntity.introduction,
          residenceCountryCode: entity.residenceCountryCode,
          residenceStateCode: entity.residenceStateCode ?? &#39;&#39;,
          residenceCityCode: entity.residenceCityCode ?? &#39;&#39;,
          residenceYear: entity.residenceYear,
          traveler: entity.profilePreviewEntity.traveler,
          interests: entity.interests,
          expertises: entity.expertises,
        ));

  (데이터 조작하는 로직 &amp; 유효성 검증 함수 동일 -&gt; 생략)
  ...
</code></pre><hr>
<h3 id="server-side-blocstate">Server-side Bloc/State</h3>
<h4 id="--server-side-state--bloc-핵심-역할">- Server-side State &amp; Bloc 핵심 역할</h4>
<blockquote>
<ul>
<li>Server-side State</li>
<li><blockquote>
<p>서버에서 받아온 데이터를 저장하고 UI에 전달하는 역할</p>
</blockquote>
</li>
<li>Server-side Bloc</li>
<li><blockquote>
<p>서버와의 비즈니스 로직(예: API 호출, 데이터 파싱 등)을 수행하고 그 결과를 State에 반영하는 역할</p>
</blockquote>
</li>
</ul>
</blockquote>
<ul>
<li>personal_profile_create_server_event.dart
프로필 생성 시나리오에서는 서버에 호출해야 하는 API 가 2개 있다. 바로 &#39;닉네임 중복검사&#39; 와 &#39;프로필 생성&#39; 인데, Server-side Event는 사용자의 요청에 따라 서버와 통신이 필요한 작업들을 구체적으로 정의하며, 각각의 이벤트는 해당 목적에 맞는 데이터를 포함하고 있다. </li>
</ul>
<p>CreatePersonalProfile 이벤트에서 param 객체를 사용하는 이유는, 서버에 전달해야 할 필드가 10개 이상으로 많기 때문에 이를 하나의 객체로 래핑하여 전달하는 것이 가독성과 유지보수 측면에서 유리하기 때문이다. 이는 위 <a href="#%EA%B7%B8%EB%A6%BC2">그림 2. Client-side, Server-side 상태 관리 분리</a> 에서 Server-side Param 사용은 Optional로 표현된 이유와도 연결된다. 해당 Param은 별도의 도메인 로직을 포함하지 않고, 단순히 데이터 전달을 위한 구조체 역할에 집중하도록 설계되었기 때문이다.</p>
<pre><code class="language-dart">part of &#39;personal_profile_create_server_bloc.dart&#39;;

sealed class PersonalProfileCreateServerEvent extends Equatable {
  const PersonalProfileCreateServerEvent();
}

/// 프로필 생성 요청
final class CreatePersonalProfile extends PersonalProfileCreateServerEvent {
  const CreatePersonalProfile({required this.userId, required this.param});

  final ProfileUpdateParam param;
  final int userId;

  @override
  List&lt;Object?&gt; get props =&gt; [userId, param];
}

/// 닉네임 중복검사 요청
final class CheckNickname extends PersonalProfileCreateServerEvent {
  const CheckNickname({required this.nickname});

  final String nickname;

  @override
  List&lt;Object?&gt; get props =&gt; [nickname];
}</code></pre>
<ul>
<li>personal_profile_create_server_state.dart</li>
</ul>
<p>Server-side State는 서버와의 통신 상태를 표현하는 역할을 하며, API 호출의 진행 상태(초기/로딩/성공/실패)를 기반으로 클라이언트 UI가 어떻게 반응할지 결정하는 데 사용된다. 이 State는 로딩 프로그래스바 표시, 성공 시 라우팅 혹은 데이터 기반 렌더링, 실패 시 에러 메시지 노출 등 프레젠테이션 레벨의 로직 제어에 핵심적인 역할을 한다.</p>
<pre><code class="language-dart">part of &#39;personal_profile_create_server_bloc.dart&#39;;

sealed class PersonalProfileCreateServerState extends Equatable {
  const PersonalProfileCreateServerState();
  @override
  List&lt;Object&gt; get props =&gt; [];
}

final class PersonalProfileCreateServerInitial
    extends PersonalProfileCreateServerState {}

final class PersonalProfileCreateServerLoading
    extends PersonalProfileCreateServerState {}

final class PersonalProfileCreateServerLoaded
    extends PersonalProfileCreateServerState {
  final ProfileEntity createdData;

  const PersonalProfileCreateServerLoaded(this.createdData);

  @override
  List&lt;Object&gt; get props =&gt; [createdData];
}

final class PersonalProfileCreateServerFailure
    extends PersonalProfileCreateServerState {
  final String errorMessage;

  const PersonalProfileCreateServerFailure(this.errorMessage);

  @override
  List&lt;Object&gt; get props =&gt; [errorMessage];
}

final class IsUnique extends PersonalProfileCreateServerState {
  const IsUnique(this.data);

  final String data;

  @override
  List&lt;Object&gt; get props =&gt; [data];
}</code></pre>
<ul>
<li>personal_profile_create_server_bloc.dart</li>
</ul>
<p>Server-side Bloc 에서는 주로 서버와 통신하는 모든 비즈니스 로직을 담당한다. 프로필 생성 시나리오에서는 닉네임 중복 검사 및 프로필 생성 요청 처리, 이미지 S3 업로드 등 서버에 의존적인 작업을 수행하게 된다. 위에서 언급한 Server-side State 들을 API 호출 과정에서 emit 하여 UI 가 로딩, 성공, 실패 등의 상태에 따라 동작하도록 프레젠테이션 로직을 유기적으로 연결하기도 한다.</p>
<p>-&gt; Bloc 에서 다루는 멤버 변수들 : Client-side cubit, Usecase, View 전용 Mapper</p>
<pre><code class="language-dart">part &#39;personal_profile_create_server_event.dart&#39;;

part &#39;personal_profile_create_server_state.dart&#39;;

class PersonalProfileCreateServerBloc extends Bloc&lt;
    PersonalProfileCreateServerEvent, PersonalProfileCreateServerState&gt; {
  /// UI 단에서 수집한 데이터
  final PersonalProfileCreateClientCubit personalProfileCreateClientCubit;

  /// Usecase -&gt; 프로필 생성 &amp; 이미지 S3 업로드
  final CreateMyProfileUseCase createMyProfileUseCase =
      serviceLocator&lt;CreateMyProfileUseCase&gt;();
  final AwsUploadMediaUseCase awsUploadMediaUseCase =
      serviceLocator&lt;AwsUploadMediaUseCase&gt;();

  /// View 전용 Mapper(Param -&gt; Entity 변환)
  ...
  final profileMapper = ViewProfileMapper();
  ...</code></pre>
<p>-&gt; Bloc 생성자 및 Action</p>
<pre><code class="language-dart">PersonalProfileCreateServerBloc(
      {required this.personalProfileCreateClientCubit})
      : super(PersonalProfileCreateServerInitial()) {
    /// 프로필 생성
    on&lt;CreatePersonalProfile&gt;(_createPersonalProfile);

    /// 닉네임 중복 확인
    on&lt;CheckNickname&gt;(_checkNickname);
  }

  Future&lt;void&gt; _createPersonalProfile(CreatePersonalProfile event,
      Emitter&lt;PersonalProfileCreateServerState&gt; emit) async {
    emit(PersonalProfileCreateServerLoading());

    String? profilePhotoUrl;

    // 프로필 사진과 배경 사진에 대한 UploadEntity 생성
    ...(일부 생략)

    // 프로필 사진 업로드
    final profileUploadTask =
        await awsUploadMediaUseCase.execute(profilePhotoUrlUploadEntity);
    profileUploadTask.when(
      success: (urls) {
        if (urls.isNotEmpty) {
          profilePhotoUrl = urls.first; // 프로필 사진 URL 저장
        }
      },
      failure: (error) {
        emit(PersonalProfileCreateServerFailure(error));
      },
    );

    /// View Mapper 를 통해 Param -&gt; Entity로 변환 
    final profileCreateEntity = profileMapper.mapProfileCreateParamToEntity(
      param: event.param,
      profilePhoto: profilePhotoUrl ?? defaultImage,
    );

    /// 프로필 생성 Usecase 실행
    final result =
        await createMyProfileUseCase.execute(event.userId, profileCreateEntity);

    result.when(
      success: (data) {
        emit(PersonalProfileCreateServerLoaded(data));
      },
      failure: (error) {
        emit(PersonalProfileCreateServerFailure(error));
      },
    );
  }

  Future&lt;void&gt; _checkNickname(CheckNickname event,
      Emitter&lt;PersonalProfileCreateServerState&gt; emit) async {
    emit(PersonalProfileCreateServerInitial());
    final result = await createMyProfileUseCase.checkNickname(event.nickname);
    result.when(
      success: (data) {
        emit(IsUnique(data));
      },
      failure: (error) {
        emit(PersonalProfileCreateServerFailure(error));
      },
    );
  }</code></pre>
<hr>
<h3 id="view">View</h3>
<p>다음은 프로필 생성 두번째 화면에 대한 코드 전문을 다루도록 하겠다. 해당 단락에서 핵심 내용은 </p>
<p>(1) Client-side Cubit 과 BlocBuilder 를 활용하여 프레젠테이션 로직을 유기적으로 연결시켰는지
(2) <code>buildWhen</code> 기법을 어떻게 활용해서 컴포넌트별 재렌더링을 줄일 수 있는지
(3) Server-side Bloc 을 활용해서는 API 호출 과정 중 상태별로 어떤 로직들을 처리했는지</p>
<p>에 초점을 맞추어 설명하고자 한다.</p>
<ul>
<li><p>멤버 변수, initState, dispose</p>
<pre><code class="language-dart">class ProfileCreateSecondView extends StatefulWidget {
ProfileCreateSecondView({super.key});

static const String id = &quot;...&quot;;

@override
State&lt;ProfileCreateSecondView&gt; createState() =&gt; _ProfileCreateSecondViewState();
}
</code></pre>
</li>
</ul>
<p>class _ProfileCreateSecondViewState extends State<ProfileCreateSecondView> {
  late PersonalProfileCreateClientCubit personalProfileCreateClientCubit;
  late PersonalProfileCreateServerBloc personalProfileCreateServerBloc;
  ...</p>
<p>  @override
  void initState() {
    /// context 로부터 등록된 cubit 과 bloc 을 받아 온다.
    personalProfileCreateClientCubit =
        BlocProvider.of<PersonalProfileCreateClientCubit>(context);
    personalProfileCreateServerBloc =
        BlocProvider.of<PersonalProfileCreateServerBloc>(context);
    ...</p>
<pre><code>super.initState();</code></pre><p>  }</p>
<p>  @override
  void dispose() {
    /// 메모리 명시적 해제
    personalProfileCreateClientCubit.close();
    PersonalProfileCreateServerBloc.close();
    ...
    super.dispose();
  }</p>
<pre><code>
- build()
    - Server-side Event 에 대해 State 관리(프로필 생성 요청 담당)
    - Loading -&gt; Loading Widget 리턴
    - Error -&gt; Error Dialog 와 에러 위젯 리턴
    - Success -&gt; Navigator 를 통한 화면 라우팅
    - buildWhen: 해당 BlocBuilder 는 &#39;프로필 생성&#39; 유스케이스를 책임지므로 이와 관련없는 &#39;닉네임 중복확인 - `isUnique`&#39;는 처리하지 않으므로 buildWhen 을 통해 re-rendering 을 막음

```dart
@override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        FocusManager.instance.primaryFocus?.unfocus();
      },
      child: Scaffold(
        backgroundColor: Palette.valueBackground,
        appBar: getAppbarWidget(),
        extendBodyBehindAppBar: false,
        body: BlocConsumer&lt;PersonalProfileCreateServerBloc,
            PersonalProfileCreateServerState&gt;(
          /// 화면 라우트 시 listener 이용
          listener: (context, state) {
            if (state is PersonalProfileCreateServerLoaded) {
              userEntityCubit.updateProfile(state.createdData);
              Navigator.of(context).popUntil((route) =&gt; route.isFirst);
            }
          },
          /// 닉네임 중복확인 책임 x
          buildWhen: (previous, current) {
            if (current is IsUnique) {
              return false;
            }
            return true;
          },
          /// Server-side State 별 화면 렌더링
          builder: (context, state) {
            if (state is PersonalProfileCreateServerLoading) {
              return LoadingWidget();
            } else if (state is PersonalProfileCreateServerFailure) {
              WidgetsBinding.instance.addPostFrameCallback(
                (_) =&gt; showDialog(
                  context: context,
                  builder: (BuildContext context) {
                    return AppWidgets.getErrorMessage(
                        context, state.errorMessage);
                  },
                ),
              );
              return ErrorWidget();
            }
            return getBodyWidget();
          },
        ),
      ),
    );
  }</code></pre><ul>
<li>getBodyWidget()<ul>
<li>각 컴포넌트 별로 <code>BlocBuilder</code> 를 통해 렌더링 관리</li>
<li><code>buildWhen</code> 을 통해 이전값과 현재값 비교해서 변경되지 않은 경우 re-rendering x</li>
<li>&#39;프로필 설정 완료&#39; 버튼의 경우, Client-side Cubit &amp; State 에서 필수입력값들에 대한 유효성 검사 이후, 통과가 되면 활성화</li>
<li>즉 Client-side Cubit -&gt; UI 렌더링 / Server-side Bloc -&gt; 서버와의 비즈니스 로직 수행 후 결과 반영</li>
</ul>
</li>
</ul>
<pre><code class="language-dart">Widget getBodyWidget() {
    return SingleChildScrollView(
      child: Padding(
        padding: EdgeInsets.symmetric(horizontal: 15.w),
        child: GestureDetector(
          onTap: () {
            FocusManager.instance.primaryFocus?.unfocus();
          },
          child: Column(
            children: [
              (생략)
              ...

              /// 프로필 사진
              BlocBuilder&lt;PersonalProfileCreateClientCubit,
                  PersonalProfileCreateClientState&gt;(
                buildWhen: (previous, current) {
                  return previous.profilePhoto != current.profilePhoto;
                },
                builder: (context, state) {
                  return ProfilePhotoWidget(
                      setProfileFunction:
                          personalProfileCreateClientCubit.setProfilePhoto,
                      setIsLoadedProfilePhoto: personalProfileCreateClientCubit
                          .setIsLoadedProfilePhoto,
                      profilePhoto: state.profilePhoto);
                },
              ),
              SizedBox(
                height: 20.h,
              ),

              /// 닉네임 설정
              BlocBuilder&lt;PersonalProfileCreateServerBloc,
                  PersonalProfileCreateServerState&gt;(
                /// 프로필 생성과 관련된 상태는 관여 x
                buildWhen: (previous, current) {
                  return current is IsUnique;
                },
                builder: (context, state) {
                  if (state is IsNotUnique) {
                    personalProfileCreateClientCubit.setNicknameUnique(false);
                  } 
                  ...
                  return BlocBuilder&lt;PersonalProfileCreateClientCubit,
                          PersonalProfileCreateClientState&gt;(
                      buildWhen: (previous, current) {
                    return previous.nickName != current.nickName ||
                        previous.nicknameUniqueInfoMessage !=
                            current.nicknameUniqueInfoMessage ||
                        previous.isNicknameUnique != current.isNicknameUnique;
                  }, builder: (context, state) {
                    return NicknameWidget(
                        checkNickName: checkNickName,
                        setNickname:
                            personalProfileCreateClientCubit.setNickName,
                        clearNickname:
                            personalProfileCreateClientCubit.clearNickname,
                        nickname: state.nickName,
                        message: state.nicknameUniqueInfoMessage);
                  });
                },
              ),
              SizedBox(
                height: 30.h,
              ),

              /// 자기소개
              BlocBuilder&lt;PersonalProfileCreateClientCubit,
                  PersonalProfileCreateClientState&gt;(
                buildWhen: (previous, current) {
                  return previous.introduction != current.introduction;
                },
                builder: (context, state) {
                  return IntroductionWidget(
                    setIntroduction:
                        personalProfileCreateClientCubit.setIntroduction,
                    introduction: state.introduction,
                  );
                },
              ),
              SizedBox(
                height: 30.h,
              ),

              (생략)
              ...

              /// 프로필 설정 완료 버튼
              BlocBuilder&lt;PersonalProfileCreateClientCubit,
                  PersonalProfileCreateClientState&gt;(
                buildWhen: (previous, current) {
                  return current.isCreated != previous.isCreated;
                },
                builder: (context, state) {
                  return TextButton(
                    style: TextButton.styleFrom(
                        fixedSize: Size(345.w, 60.h),
                        shape: RoundedRectangleBorder(
                            borderRadius: BorderRadius.circular(10.r)),
                        backgroundColor: state.isCreated
                            ? Palette.valuePink
                            : Palette.valueGrey),
                    onPressed: () {
                      if (state.isCreated) {
                        createProfile();
                      }
                    },
                    child: Text(&#39;프로필 설정 완료&#39;,
                        style: TTextTheme.textTheme.bodyLarge!
                            .apply(color: Palette.valueWhite)),
                  );
                },
              ),
</code></pre>
<ul>
<li>그 이외 코드<ul>
<li>Server-side Bloc 에 Event 를 dispatch 하는 코<ul>
<li>[Optional] Context 관련 UI 생성 코드(ex. showDialog() 등)</li>
</ul>
</li>
</ul>
</li>
</ul>
<pre><code class="language-dart">void checkNickName(String nickname) {
    personalProfileCreateServerBloc.add(CheckNickname(nickname: nickname));
  }

void createProfile() {
    final param = personalProfileCreateServerBloc.toProfileCreateParam();
    personalProfileCreateServerBloc.add(CreatePersonalProfile(
        userId: userEntityCubit.state!.userId, param: param));
  }</code></pre>
<hr>
<h3 id="결론">결론</h3>
<p>Client-side 와 Server-side 로 나누어 프레젠테이션 로직을 관리했을 때, 본 회고록의 프로젝트 계층 구조는 다음과 같다. </p>
<pre><code class="language-text">lib/
├── **Presentation Layer**
│   ├── bloc/
│   │   ├── [feature]_client_cubit.dart
│   │   ├── [feature]_client_state.dart
│   │   ├── [feature]_server_bloc.dart
│   │   ├── [feature]_server_event.dart
│   │   └── [feature]_server_state.dart
│   ├── mapper/ (optional)
│   │   └── [feature]_view_mapper.dart
│   ├── param/ (optional)
│   │   └── [feature]_param.dart
│   └── screen/
│       └── [feature]_view.dart
│
├── **Domain Layer**
│   ├── entity/
│   │   └── [feature]_entity.dart
│   ├── repository/
│   │   └── [feature]_repository.dart
│   └── usecase/
│       └── [usecase].dart
│
└── **Data Layer**
    ├── data_source/
    │   ├── local/
    │   │   └── [feature]_local_api.dart
    │   └── remote/
    │       └── [feature]_remote_api.dart
    ├── mapper/
    │   └── [feature]_mapper.dart
    ├── model/
    │   ├── request/
    │   │   └── [feature]_request_model.dart
    │   └── response/
    │       └── [feature]_response_model.dart
    └── repository/
        └── [feature]_repository_impl.dart
</code></pre>
<p>Presentation Layer는 단순히 화면을 구성하는 계층을 넘어, 유지보수성과 확장성을 고려하여 명확한 관심사 분리가 필요하다. 이를 위해 아래와 같이 각 컴포넌트의 책임을 분리해 설계한다.</p>
<table>
<thead>
<tr>
<th>구성요소</th>
<th>책임 및 관심사</th>
</tr>
</thead>
<tbody><tr>
<td><strong>View</strong></td>
<td>UI 컴포넌트 렌더링, Context 기반 작업, Cubit/Bloc dispatch</td>
</tr>
<tr>
<td><strong>ClientCubit</strong></td>
<td>UI 내부 상태 관리 및 유효성 로직</td>
</tr>
<tr>
<td><strong>ServerBloc</strong></td>
<td>도메인 로직 호출 및 그 결과에 따른 UI 상태 처리</td>
</tr>
</tbody></table>
<p>*<em>1. View *</em></p>
<ul>
<li>역할<ul>
<li>UI 요소의 렌더링과 사용자 인터랙션 처리<br></li>
</ul>
</li>
<li>책임<ul>
<li>Stateless한 위젯 구성 및 화면 표현</li>
<li>Bloc/Cubit에 이벤트를 전달 (dispatch, emit)</li>
<li>showDialog, SnackBar, Navigator 등 BuildContext를 요구하는 작업 수행<br></li>
</ul>
</li>
<li>제한<ul>
<li>비즈니스 로직을 직접 수행하거나 상태를 가공하지 않음</li>
<li>도메인 로직 호출은 Bloc에 위임<br>

</li>
</ul>
</li>
</ul>
<p>*<em>2. ClientCubit / ClientState *</em></p>
<ul>
<li>역할<ul>
<li>UI 내에서 발생하는 상태 변화를 관리<br></li>
</ul>
</li>
<li>ClientState<ul>
<li>사용자의 입력값, 유효성 여부 등 View가 렌더링에 필요한 상태를 보유<br></li>
</ul>
</li>
<li>ClientCubit<ul>
<li>단순한 상태 변경을 처리 (setNickname, setBirth 등)</li>
<li>내부적으로 <code>_isValidForm()</code> 같은 UI validation logic을 포함</li>
<li><code>emit()</code>을 통해 View에 변경 사항을 전달<br>

</li>
</ul>
</li>
</ul>
<p>*<em>3. ServerBloc / ServerState / ServerEvent *</em></p>
<ul>
<li>역할<ul>
<li>도메인 레이어와의 인터페이스, 비즈니스 로직 호출 및 결과 상태 관리<br></li>
</ul>
</li>
<li>ServerBloc<ul>
<li>도메인 레이어에 정의된 UseCase를 호출 </li>
<li>서버 통신 또는 비즈니스 로직 수행 결과에 따라 View에 알맞은 상태를 전달 (emit)<br></li>
</ul>
</li>
<li>ServerEvent<ul>
<li>View로부터 전달받은 이벤트<br></li>
</ul>
</li>
<li>ServerState<ul>
<li>서버 처리의 결과 (Success, Failure, Loading 등)를 구체화하여 View에 전달<br>

</li>
</ul>
</li>
</ul>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/bfd6f305-67f4-4ed6-83c8-fbcbb33f74db/image.png" width=100%>
  <p style="font-size: 14px; color: gray;">▲ 그림 4. Presentation Layer Class Diagram</p>
</div>

<hr>
<h3 id="끝으로">끝으로</h3>
<p>클린 아키텍처는 <strong>“관심사의 분리를 통해 도메인 중심의 견고한 설계를 추구하는 것”</strong> 을 주요 아이디어로 삼는다. 이러한 구조는 특히 백엔드 혹은 복잡한 도메인 모델링이 필요한 시스템에서 강력한 유지보수성과 확장성을 보장해준다.</p>
<p>하지만 프론트엔드나 모바일 애플리케이션 영역에서는 이와는 또 다른 고민이 병행되어야 한다. 위에서 다루었던 &quot;프로필 생성 유스케이스&quot;를 예시로 들자면, 서버와 통신하기 위해 UseCase를 호출하고, 이미지 업로드 및 도메인 로직 흐름을 설계하는 것도 중요하다. 마찬가지로 이에 못지않게, 사용자의 입력값을 UI 상태로 관리하고, 이를 바탕으로 화면을 구성하거나 유효성 검증을 반영하는 등 Presentation Layer 내에서의 데이터 수집 및 상태 관리하는 것도 중요하다. 1년간 서비스를 운영하며 총 24회의 버전 업데이트를 거치는 과정에서 다음과 같은 인사이트를 얻었다.</p>
<blockquote>
<ol>
<li>도메인 설계의 중요성: 변경의 전파를 최소화하기 위한 중심축</li>
</ol>
</blockquote>
<p>서비스 기획은 반복적으로 바뀌며, 그에 따라 입력 항목이 추가되거나 제거되는 일이 빈번하다. 이는 단순히 화면 단에서 끝나지 않고, 도메인 모델 자체의 변경으로 이어지며 애플리케이션 전반에 영향을 미친다. 이런 변경 흐름을 여러 차례 경험하면서 탄탄한 도메인 설계는 유지보수의 난이도를 줄이고 전체 개발 속도를 빠르게 만든다는 점을 실감하게 되었다. 도메인 계층이 잘 정리되어 있을수록, 변경이 생겨도 영향 범위를 좁게 만들 수 있으며, 기능 단위로 안전하게 수정·배포할 수 있다.</p>
<blockquote>
<ol start="2">
<li>그러나 Presentation Layer의 변화도 결코 작지 않다</li>
</ol>
</blockquote>
<p>클린 아키텍처는 보통 도메인 중심 설계에 초점을 맞추지만, 실제 운영 경험에서 느낀 바로는 Presentation Layer 또한 기획 변경의 영향을 크게 받는다. 단순히 도메인의 엔티티만 수정해서 끝나는 게 아니라,</p>
<ul>
<li>입력 필드 구성의 변경 (필드 추가/삭제에 따른 UI 구조 변화)</li>
<li>화면 동작 흐름의 변경 (데이터에 따라 달라지는 화면 상태 또는 인터랙션 처리)</li>
<li>입력값에 대한 전처리 및 유효성 검사 로직의 추가/변경</li>
</ul>
<p>이 모든 것들이 화면 단에서 병행하여 수정되어야 한다. 도메인을 변경할 때, 그만큼 View의 논리도 재정비가 필요했던 것이다.</p>
<blockquote>
<ol start="3">
<li>Presentation Layer 역시 관심사 분리가 필요하다</li>
</ol>
</blockquote>
<p>선언형 UI 패러다임에서는 자연스럽게 View 코드의 양이 많아진다. 하지만 이 View 안에 유효성 검사, 조건 처리, 상태 변화 로직이 모두 뒤섞이기 시작하면 컴포넌트의 재사용성은 떨어지고 유지보수는 급격히 어려워진다. 따라서 다음과 같은 분리가 반드시 필요하다:</p>
<ul>
<li>View는 UI 렌더링과 Context 기반의 작업에만 책임 분리</li>
<li>ClientCubit은 사용자 입력에 대한 상태 변화 및 유효성 검증 책임 분리</li>
<li>ServerBloc은 도메인 호출과 결과 상태 전달 책임 분리</li>
</ul>
<p>이러한 구조는 단순히 “Clean Code”를 위한 것이 아니라, 실제 운영과 유지보수 상황에서 실질적인 시간과 비용을 줄여주는 설계 전략임을 체감하게 되었다.</p>
<p>끝으로 아키텍처에 대해선 정답이 없다. 해당 포스트에서 정리한 구조와 방향성이 어떤 프로젝트에서는 효과적일 수 있지만, 다른 맥락에서는 오히려 적절하지 않을 수도 있다. 소프트웨어 설계에 “Silver Bullet” 은 존재하지 않으며, 나 역시 아직 모든 경우를 경험해본 것이 아니기에 논리가 부족한 부분도 있고, 미처 보지 못한 단점들도 분명 존재할 것이다.</p>
<p>하지만 중요한 건, 더 나은 구조를 향한 지속적인 고민과 실천이라고 생각한다. 내가 생각하는 <strong>&quot;클린 코드&quot;</strong> 란 단지 기술적인 규칙을 따르는 것이 아니라 변경이 쉽고 유지보수에 용이하며 확장성이 높은 코드라 생각하는데, </p>
<p>내가 지향하는 <strong>&quot;클린 코드&quot;</strong>에 다다르기까지 이 회고록 시리즈 역시 여기서 멈추지 않고, 앞으로의 운영과 경험을 통해 계속해서 다듬고 개선해 나갈 예정이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Flutter] 비동기 작업 취소, CancelableOperation]]></title>
            <link>https://velog.io/@sangjin-hash/%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%9E%91%EC%97%85-%EC%B7%A8%EC%86%8C-CancelableOperation</link>
            <guid>https://velog.io/@sangjin-hash/%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%9E%91%EC%97%85-%EC%B7%A8%EC%86%8C-CancelableOperation</guid>
            <pubDate>Wed, 12 Mar 2025 02:42:59 GMT</pubDate>
            <description><![CDATA[<h3 id="개요">개요</h3>
<p>서비스를 운영하는 도중, <code>닉네임</code> UX 개선이 필요하여 디자인 변경이 필요한 부분이 생겼다. 기존&amp;변경 닉네임 UI 에 대한 Usecase는 다음과 같다.</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/c6c280d3-48c0-415b-9567-69edf92fb332/image.png" width="800">
  <p style="font-size: 14px; color: gray;">▲ 그림 1. 닉네임 기획 변경 시안</p>
</div>

<pre><code class="language-text">- [기존] 닉네임 Usecase
  - 닉네임을 입력한다.
  - 입력한 닉네임을 &#39;중복확인&#39; 버튼을 클릭한다.
  - 닉네임을 요청보낸 뒤 서버로부터 사용가능(닉네임 중복 x) 혹은 사용불가(닉네임 중복 o)에 대한 응답을 받는다.

- [변경] 닉네임 Usecase
  - 닉네임을 입력한다.
  - 입력이 주어지면 1초 딜레이를 준 뒤 suffix 에 Loading Progress bar 가 돌아간다.
  - 추가적인 입력이 없는 경우 사용가능 혹은 사용불가에 대한 응답을 받아 해당 상태에 따른 UI 를 렌더링한다.
  - 1초 딜레이 동안 혹은 Loading Progress bar가 돌아가는 중 추가적인 입력이 주어지면 기존 입력에 대한 작업을 취소하고, 추가적인 입력에 대한 중복검사를 실시한다.
    (ex. &#39;ㅁㅁ&#39; 입력 -&gt; 1초 딜레이 -&gt; Loading Progress bar(&#39;ㅁㅁ&#39;에 대한 중복검사 수행) -&gt; &#39;ㅁㅁㅁ&#39; 입력 
    -&gt; &#39;ㅁㅁ&#39; 입력에 대한 중복검사 작업 취소 -&gt; 1초 딜레이 -&gt; Loading Progress bar(&#39;ㅁㅁㅁ&#39;에 대한 중복검사 수행))  </code></pre>
<p>기존 Usecase 와 변경 Usecase 의 가장 큰 차이점으로는 &#39;중복확인&#39; 버튼이 사라지면서, 입력이 주어졌을 때 <strong>&#39;마지막 입력에 대해 자동으로 중복검사&#39;</strong> 를 한다는 점이다. 기존 입력에 대한 중복검사를 취소하고 마지막 입력에 대한 중복검사만 한 뒤 상태에 따른 UI 렌더링까지를 목표로, 이를 구현하면서 겪었던 시행착오와 기능 구현하면서 얻게 된 지식인 <code>CancelableOperation</code> 에 대한 설명하고자 한다.</p>
<h3 id="keyword-및-중점-내용">Keyword 및 중점 내용</h3>
<ul>
<li>Debounce</li>
<li>EventTransformer</li>
<li>CancelableOperation</li>
</ul>
<hr>
<h3 id="debounce-의-한계">Debounce 의 한계</h3>
<p>우리는 상태 관리 라이브러리를 <code>bloc</code> 을 채택하여 사용하고 있다. <code>bloc</code> 에서 제공하는 수많은 기능들 중, <code>debounce</code> 와 <code>EventTransformer</code> 가 있다. 간단하게 설명하자면,</p>
<blockquote>
<p>EventTransformer 는 Bloc에서 이벤트가 처리되는 방식을 변환할 수 있도록 도와주는 함수
✔ 기본적으로 Bloc은 이벤트가 발생하면 순차적으로 처리하지만, eventTransformer를 사용하면 이벤트 흐름을 제어할 수 있음
✔ 여러 이벤트가 연속해서 발생할 때, 기존 이벤트를 취소하거나 지연시키거나, 특정 로직을 추가할 수 있음</p>
</blockquote>
<p>보통 transformer 를 설정할 때 다음 4가지 옵션을 고려할 수 있는데,</p>
<ul>
<li><p><code>debounce</code>: 일정 시간 후 마지막 이벤트만 실행</p>
<pre><code class="language-dart">EventTransformer&lt;T&gt; debounce&lt;T&gt;(Duration duration) {
return (events, mapper) =&gt; 
    events.debounceTime(duration).switchMap(mapper);
}</code></pre>
</li>
<li><p><code>restartable</code>: 새 이벤트가 발생하면 이전 이벤트를 취소</p>
<pre><code class="language-dart">on&lt;CheckNickname&gt;(
_checkNickname,
transformer: restartable(),
);</code></pre>
</li>
<li><p><code>droppable</code>: 새로운 이벤트가 들어와도 기존 이벤트가 끝날 때까지 실행하지 않음</p>
<pre><code class="language-dart">on&lt;CheckNickname&gt;(
_checkNickname,
transformer: droppable(),
);</code></pre>
</li>
<li><p><code>sequential</code>: 이벤트를 순차적으로 실행 (Queue 방식)</p>
<pre><code class="language-dart">on&lt;CheckNickname&gt;(
_checkNickname,
transformer: sequential(),
);</code></pre>
</li>
</ul>
<p>변경된 닉네임 Usecase 에서는 사용자의 입력이 짧은 시간 내에 많이 들어올 수 있기 때문에, 매번 들어올 때마다 중복검사를 수행하는 것이 아닌 마지막 입력에 대해서만 중복검사를 수행하면 되기 때문에, <code>debounce</code> 옵션을 채택했다. 다시 <code>debounce</code> 에 대해 좀 더 자세히 서술해보자면,</p>
<blockquote>
<p>Debounce는 짧은 시간 동안 여러 번 발생하는 이벤트 중에서 마지막 이벤트만 실행하도록 하는 기술</p>
<p>✔ 사용 목적
   •    사용자가 입력할 때마다 API 요청을 보내는 것을 방지
   •    불필요한 이벤트 호출을 줄여 성능 최적화
   •    사용자가 입력을 마친 후 일정 시간이 지나면 이벤트 실행</p>
<p>✔ 작동 방식
   •    특정 시간이 지나기 전에 새로운 이벤트가 발생하면 이전 이벤트를 취소
   •    가장 마지막 이벤트만 실행</p>
</blockquote>
<p>처음에는 변경된 닉네임 Usecase를 구현하기 위해 입력이 여러 번 주어지는 경우 debounce를 활용하여 특정 시간 동안 마지막으로 호출된 이벤트만 처리하면 된다고 생각했다. 아래 그림에서는 기존에는 1~10까지 요청이 들어왔다면, debounce를 적용하면 300ms 간격을 두고 해당 시간 내 마지막 요청인 ‘2번’, ‘6번’, ‘10번’만 처리되는 예시를 볼 수 있다. </p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/2f983524-5261-43cd-9103-d44056a28dfe/image.png" width="500">
  <p style="font-size: 14px; color: gray;">▲ 그림 2. debounce 작동 방식</p>
</div>

<p>이를 참고하여 <strong>“사용자의 입력은 보통 1~2초 이내로 끝난다”</strong> 는 전제 하에, debounce의 시간 간격을 적절히 조정하면 그 시간 동안 발생한 요청들 중 마지막 요청만 처리할 수 있을 것이라 예상했다. 따라서 debounce 의 시간을 2초 정도 여유롭게 걸어놓았을 때 2초동안 마지막 이벤트만 처리되게끔 구현했다. </p>
<ul>
<li><code>debounce</code> 를 적용한 코드</li>
</ul>
<pre><code class="language-dart">import &#39;package:equatable/equatable.dart&#39;;
import &#39;package:flutter_bloc/flutter_bloc.dart&#39;;
import &#39;package:rxdart/rxdart.dart&#39;;
import &#39;package:value_up/core/di/locator.dart&#39;;
import &#39;package:value_up/features/profile/domain/usecase/check_nickname.dart&#39;;

part &#39;profile_nickname_event.dart&#39;;
part &#39;profile_nickname_state.dart&#39;;

class ProfileNicknameBloc
    extends Bloc&lt;ProfileNicknameEvent, ProfileNicknameState&gt; {
  final CheckNicknameUseCase checkNicknameUseCase =
      serviceLocator&lt;CheckNicknameUseCase&gt;();

  ProfileNicknameBloc() : super(ProfileNicknameInitial()) {
    on&lt;Initialize&gt;(_initialize);
    on&lt;CheckNickname&gt;(_checkNickname,
        transformer: debounce(const Duration(seconds: 2)));
  }

  Future&lt;void&gt; _initialize(
      Initialize event, Emitter&lt;ProfileNicknameState&gt; emit) async {
    emit(ProfileNicknameInitial());
  }

  Future&lt;void&gt; _checkNickname(
      CheckNickname event, Emitter&lt;ProfileNicknameState&gt; emit) async {
    print(&#39;${event.nickname} 검사 시작 ${DateTime.now()}&#39;);
    emit(ProfileNicknameLoading());
    await Future.delayed(Duration(seconds: 1));
    final result = await checkNicknameUseCase.execute(event.nickname);
    result.when(
      success: (data) {
        emit(data ? IsUnique(event.nickname) : IsNotUnique());
        print(&#39;${event.nickname} 검사 종료 ${DateTime.now()}&#39;);
      },
      failure: (error) {
        return;
      },
    );
  }

  EventTransformer&lt;T&gt; debounce&lt;T&gt;(Duration duration) {
    return (events, mapper) =&gt; events.debounceTime(duration).switchMap(mapper);
  }
}</code></pre>
<ul>
<li>로그 출력<pre><code>flutter: ㅇㅇ 검사 시작 2025-03-11 09:52:02.948724
flutter: ㅇㅇ 검사 종료 2025-03-11 09:52:04.032297
flutter: ㅇㅇㅇ 검사 시작 2025-03-11 09:52:05.818071
flutter: ㅇㅇㅇ 검사 종료 2025-03-11 09:52:06.865051
flutter: ㅇㅇㅇㅇ 검사 시작 2025-03-11 09:52:10.088473
flutter: ㅇㅇㅇㅇ 검사 종료 2025-03-11 09:52:11.176691
flutter: ㅇㅇㅇㅇㅇ 검사 시작 2025-03-11 09:52:12.769056
flutter: ㅇㅇㅇㅇㅇ 검사 종료 2025-03-11 09:52:13.812183</code></pre></li>
</ul>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/eb28d905-6586-4dc5-ad9a-e060461252c4/image.gif" width="300" height="500">
  <p style="font-size: 14px; color: gray;">▲ 그림 3. Debounce 적용 결과</p>
</div>

<p>위에서 한 가지 간과했던 사실은 <strong>&#39;debounce 시간 이 후에 들어온 이벤트에 대한 처리&#39;</strong> 이다. 위의 영상처럼 &#39;ㅇㅇ&#39; 를 입력한 뒤 debounce 시간인 2초가 지나 loading progressBar가 돌아갈 때 입력이 주어진다면, 이미 <code>_checkNickname</code> 이 수행되었고 이 때 추가적인 이벤트인 &#39;ㅇㅇㅇ&#39; 이 들어왔을 때 기존의 <code>_checkNickname</code> 을 수행했던 작업이 emit 을 한 순간 UI 렌더링이 되었고, 병렬적으로 &#39;ㅇㅇㅇ&#39; 에 대한 중복검사를 수행하여 이에 따른 state도 emit 되어 UI 렌더링이 된 것이다. 따라서 위의 로그를 보면 debounce 시간 이 후 들어온 이벤트들에 대해서 모두 작업을 수행한 것을 확인할 수 있다.</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/7aa4531d-4786-4ac6-8267-93c3d4c1ae2d/image.png" width="900">
  <p style="font-size: 14px; color: gray;">▲ 그림 4. Debounce flow</p>
</div>


<p>따라서 debounce 의 한계는 <strong>&#39;진행 중인 요청을 강제로 취소할 수 없다&#39;</strong> 는 점이고, 결국 debounce 설정한 시간 내 이벤트 요청을 줄이는 데 유용하다. 또한 <code>EventTransformer</code> 부분에 설정했던 <code>switchMap</code> 을 활용하면 최신 이벤트만 실행할 순 있지만, 이미 실행된 요청은 취소하지 못한다는 점에 결국 해당 구현 방법은 기각되었다.</p>
<hr>
<h3 id="cancelableoperation">CancelableOperation</h3>
<p>CancelableOperation은 Dart의 <code>async</code> 패키지에서 제공하는 기능으로, <strong>실행 중인 비동기 작업을 강제로 취소할 수 있도록 도와주는 도구</strong>이다. 핵심 기능으로는 다음과 같다.</p>
<blockquote>
<p>✔ 진행 중인 비동기 작업을 강제로 취소 가능
✔ 취소된 작업은 더 이상 실행되지 않음
✔ 새로운 요청이 들어오면 기존 요청을 즉시 중단하고 최신 요청만 유지
✔ 사용자가 빠르게 입력할 경우, 마지막 입력만 처리하도록 최적화 가능</p>
</blockquote>
<ul>
<li><p>새로운 요청이 들어오면 기존 요청을 중단하고 최신 요청만 실행하는 핸들러</p>
<pre><code class="language-dart">Future&lt;void&gt; _handleCheckNicknameRequest(
    CheckNickname event, Emitter&lt;ProfileNicknameState&gt; emit) async {
  // 이전 요청이 있으면 취소
  _activeOperation?.cancel();
  _cancelCompleter?.complete(); // 이전 요청이 있을 경우 강제 종료

  emit(ProfileNicknameLoading());

  // 새로운 Completer 생성
  _cancelCompleter = Completer&lt;void&gt;();

  // CancelableOperation으로 새로운 작업 실행
  _activeOperation = CancelableOperation.fromFuture(
    _executeNicknameValidation(event, emit, _cancelCompleter!),
    onCancel: () {
      // 검사 요청 취소됨
      print(&quot;닉네임 검사 요청 취소됨: ${event.nickname}&quot;);
    },
  );

  await _activeOperation?.value;
}</code></pre>
</li>
<li><p>cancelCompleter.isCompleted 를 이용하여 작업 취소</p>
<pre><code class="language-dart">Future&lt;void&gt; _executeNicknameValidation(
    CheckNickname event,
    Emitter&lt;ProfileNicknameState&gt; emit,
    Completer&lt;void&gt; cancelCompleter) async {
  if (event.nickname.isEmpty) {
    emit(ProfileNicknameInitial());
    return;
  }

  try {
    print(&quot;닉네임 검사 시작: ${event.nickname}&quot;);

    await Future.any([
      Future.delayed(Duration(seconds: 1)), // 1초 딜레이 후 API 호출
      cancelCompleter.future, // 취소 요청이 들어오면 즉시 종료
    ]);

    if (cancelCompleter.isCompleted) {
      print(&quot;닉네임 검사 중단됨 (딜레이 중): ${event.nickname}&quot;);
      return;
    }

    final result = await checkNicknameUseCase.execute(event.nickname);

    if (cancelCompleter.isCompleted) {
      print(&quot;닉네임 검사 중단됨 (응답 후): ${event.nickname}&quot;);
      return;
    }

    result.when(
      success: (data) {
        emit(data ? IsUnique(event.nickname) : IsNotUnique());
        print(&quot;닉네임 검사 완료: ${event.nickname}&quot;);
      },
      failure: (error) {
        return;
      },
    );
  } catch (e) {
    return;
  }
}</code></pre>
</li>
<li><p>로그 출력</p>
<pre><code class="language-text">flutter: 닉네임 검사 시작: ㅇ
flutter: 닉네임 검사 요청 취소됨: ㅇ
flutter: 닉네임 검사 시작: ㅇㅇ
flutter: 닉네임 검사 중단됨 (딜레이 중): ㅇ
flutter: 닉네임 검사 완료: ㅇㅇ
flutter: 닉네임 검사 시작: ㅇㅇㅇ
flutter: 닉네임 검사 요청 취소됨: ㅇㅇㅇ
flutter: 닉네임 검사 시작: ㅇㅇㅇㄷ
flutter: 닉네임 검사 중단됨 (딜레이 중): ㅇㅇㅇ
flutter: 닉네임 검사 완료: ㅇㅇㅇㄷ
flutter: 닉네임 검사 시작: ㅇㅇㅇㄷㄷ
flutter: 닉네임 검사 요청 취소됨: ㅇㅇㅇㄷㄷ
flutter: 닉네임 검사 시작: ㅇㅇㅇㄷㄷㄷ
flutter: 닉네임 검사 중단됨 (딜레이 중): ㅇㅇㅇㄷㄷ
flutter: 닉네임 검사 요청 취소됨: ㅇㅇㅇㄷㄷㄷ
flutter: 닉네임 검사 시작: ㅇㅇㅇㄷㄷㄷㄷ
flutter: 닉네임 검사 중단됨 (딜레이 중): ㅇㅇㅇㄷㄷㄷ
flutter: 닉네임 검사 완료: ㅇㅇㅇㄷㄷㄷㄷ</code></pre>
</li>
</ul>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/a21a413e-a178-4e6a-84ac-aff685466a48/image.gif" width="300" height="500">
  <p style="font-size: 14px; color: gray;">▲ 그림 5. CancelableOperation 적용 결과</p>
</div>

<ul>
<li>전체 코드<pre><code class="language-dart">import &#39;dart:async&#39;;
</code></pre>
</li>
</ul>
<p>import &#39;package:async/async.dart&#39;;
import &#39;package:bloc/bloc.dart&#39;;
import &#39;package:equatable/equatable.dart&#39;;
import &#39;package:value_up/core/di/locator.dart&#39;;
import &#39;package:value_up/features/profile/domain/usecase/check_nickname.dart&#39;;</p>
<p>part &#39;profile_nickname_event.dart&#39;;
part &#39;profile_nickname_state.dart&#39;;</p>
<p>class ProfileNicknameBloc
    extends Bloc&lt;ProfileNicknameEvent, ProfileNicknameState&gt; {
  final CheckNicknameUseCase checkNicknameUseCase =
      serviceLocator<CheckNicknameUseCase>();</p>
<p>  CancelableOperation<void>? _activeOperation;
  Completer<void>? _cancelCompleter;</p>
<p>  ProfileNicknameBloc() : super(ProfileNicknameInitial()) {
    on<Initialize>(_initialize);
    on<CheckNickname>(_handleCheckNicknameRequest);
  }</p>
<p>  /// Initialize 발생 시 초기화
  Future<void> _initialize(
      Initialize event, Emitter<ProfileNicknameState> emit) async {
    emit(ProfileNicknameInitial());
  }</p>
<p>  /// 실행 중인 CheckNickname이 있으면 취소 후 새로운 요청 처리하는 핸들러
  Future<void> _handleCheckNicknameRequest(
      CheckNickname event, Emitter<ProfileNicknameState> emit) async {
    // 이전 요청이 있으면 취소
    _activeOperation?.cancel();
    _cancelCompleter?.complete(); // 이전 요청이 있을 경우 강제 종료</p>
<pre><code>emit(ProfileNicknameLoading());

// 새로운 Completer 생성
_cancelCompleter = Completer&lt;void&gt;();

// CancelableOperation으로 새로운 작업 실행
_activeOperation = CancelableOperation.fromFuture(
  _executeNicknameValidation(event, emit, _cancelCompleter!),
  onCancel: () {
    // 검사 요청 취소됨
  },
);

await _activeOperation?.value;</code></pre><p>  }</p>
<p>  /// 닉네임 검사 API 호출
  Future<void> _executeNicknameValidation(
      CheckNickname event,
      Emitter<ProfileNicknameState> emit,
      Completer<void> cancelCompleter) async {
    if (event.nickname.isEmpty) {
      emit(ProfileNicknameInitial());
      return;
    }</p>
<pre><code>try {
  await Future.any([
    Future.delayed(Duration(seconds: 1)), // 1초 딜레이 후 API 호출
    cancelCompleter.future, // 취소 요청이 들어오면 즉시 종료
  ]);

  if (cancelCompleter.isCompleted) return;

  final result = await checkNicknameUseCase.execute(event.nickname);

  if (cancelCompleter.isCompleted) return;

  result.when(
    success: (data) {
      emit(data ? IsUnique(event.nickname) : IsNotUnique());
    },
    failure: (error) {
      return;
    },
  );
} catch (e) {
  return;
}</code></pre><p>  }
}</p>
<p>```</p>
<hr>
<h3 id="reference">Reference</h3>
<p>[Debounce &amp; EventTransformer]</p>
<ul>
<li><a href="https://bloclibrary.dev/bloc-concepts/">https://bloclibrary.dev/bloc-concepts/</a></li>
<li><a href="https://www.iainsmith.me/blog/flutter-how-to-use-debounce-increment-control">https://www.iainsmith.me/blog/flutter-how-to-use-debounce-increment-control</a></li>
<li><a href="https://medium.com/@dev.h.majid/debouncing-and-throttling-in-flutter-using-bloc-state-management-3cfda9b09c52">https://medium.com/@dev.h.majid/debouncing-and-throttling-in-flutter-using-bloc-state-management-3cfda9b09c52</a></li>
</ul>
<p>[CancelableOperation]</p>
<ul>
<li><a href="https://pub.dev/documentation/async/latest/async/CancelableOperation-class.html">https://pub.dev/documentation/async/latest/async/CancelableOperation-class.html</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Flutter] 클린 아키텍처 회고록(3)]]></title>
            <link>https://velog.io/@sangjin-hash/%ED%81%B4%EB%A6%B0-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%ED%9A%8C%EA%B3%A0%EB%A1%9D3</link>
            <guid>https://velog.io/@sangjin-hash/%ED%81%B4%EB%A6%B0-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%ED%9A%8C%EA%B3%A0%EB%A1%9D3</guid>
            <pubDate>Mon, 24 Feb 2025 15:17:41 GMT</pubDate>
            <description><![CDATA[<p>지난 게시글에서 도메인 기반 Entity 설계를 했고 이에 대한 Trade-off를 분석해보며 <strong>Presentation Layer 에서 도메인의 Entity 에 의존적</strong> 이라는 문제점을 보완하기 위해 <code>Param</code> 에 대한 개요 정도 언급을 하였다. 간단하게 Param의 정의를 다시 리마인드하자면 <strong>비즈니스 로직은 없고 UI 로부터 수집한 데이터를 저장 용도로 만든 객체</strong>인데, 이번 게시글에서는 해당 Param 을 이용하여 위의 문제점을 어떻게 보완했는지, 이번 아키텍처에서 Trade-off는 무엇인지, 추가적으로 보완해야 할 점이 무엇인지 등 다뤄보도록 하겠다.</p>
<h2 id="keyword-및-중점-내용">Keyword 및 중점 내용</h2>
<ul>
<li>Param</li>
<li>View Mapper, Data Mapper</li>
</ul>
<h2 id="개요">개요</h2>
<p><img src="https://velog.velcdn.com/images/sangjin-hash/post/bf04c503-2e86-4c99-8fe9-2114c2f0a846/image.png" alt=""></p>
<p>Param을 도입한 클린 아키텍처에 대한 컴포넌트 다이어그램은 다음과 같다. &#39;프로필 생성 과정&#39;과 같이 화면으로부터 입력을 받는 예시에서는 Param 을 통해 내가 필요한 데이터들만 종합을 해서 수집을 하고 이를 Presentation 전용 Mapper 를 통해 Entity 로 변환한 뒤, Bloc 에서 Domain Layer에 있는 usecase 함수 파라미터에 해당 Entity 를 사용할 수 있다. </p>
<p>이전 게시글에서 Domain 기반 Entity 설계의 단점으로</p>
<blockquote>
<ol>
<li>Thread safe 하지 않다. -&gt; Entity가 가변객체기 때문에</li>
<li>해당 Entity 를 사용할 경우 매번 필드마다 null 체크가 필요하다. -&gt; Entity가 가변객체기 때문에</li>
<li>API Request 에 사용되는 데이터 필드 수가 Entity 가 가지고 있는 필드 수보다 적기 때문에 화면에서 입력받지 않아도 되는 데이터들에 대해서는 메모리 낭비이다.</li>
<li>Presentation Layer 가 Domain Layer의 Entity 에 너무 의존적이다.</li>
</ol>
</blockquote>
<p>다음 4가지를 언급했는데 Param을 도입함으로써 Entity 를 불변 객체로 사용할 수 있고, 이전 설계보다 Presentation Layer 에서 Domain Layer의 Entity 의 의존성을 줄일 수 있는 효과가 있다. </p>
<p>백문이 불여일견. 우선 코드로 알아보자.</p>
<hr>
<h2 id="param-설계">Param 설계</h2>
<pre><code class="language-dart">class ProfileCreateParam {
  /// 입력 받아야 하는 13개 필드들
  final bool traveler;
  final bool male;
  final int residenceYear;
  final String birth;
  final String nickname;
  final String residenceCountryCode;
  final String residenceStateCode;
  final String residenceCityCode;
  final String introduction;
  final String profilePhotoUrl;
  final String backgroundPhotoUrl;
  final List&lt;String&gt; interests;
  final List&lt;String&gt; expertises;

  /// 불변 객체로 설계 (기본값 설정)
  const ProfileCreateParam({
    this.traveler = false,
    this.male = false,
    this.residenceYear = 0,
    this.birth = &quot;&quot;,
    this.nickname = &quot;&quot;,
    this.residenceCountryCode = &quot;&quot;,
    this.residenceStateCode = &quot;&quot;,
    this.residenceCityCode = &quot;&quot;,
    this.introduction = &quot;&quot;,
    this.profilePhotoUrl = &quot;&quot;,
    this.backgroundPhotoUrl = &quot;&quot;,
    this.interests = const [],
    this.expertises = const [],
  });

  /// `copyWith()`을 활용한 값 변경 (기존 값 유지)
  ProfileCreateParam copyWith({
    bool? traveler,
    bool? male,
    int? residenceYear,
    String? birth,
    String? nickname,
    String? residenceCountryCode,
    String? residenceStateCode,
    String? residenceCityCode,
    String? introduction,
    String? profilePhotoUrl,
    String? backgroundPhotoUrl,
    List&lt;String&gt;? interests,
    List&lt;String&gt;? expertises,
  }) {
    return ProfileCreateParam(
      traveler: traveler ?? this.traveler,
      male: male ?? this.male,
      residenceYear: residenceYear ?? this.residenceYear,
      birth: birth ?? this.birth,
      nickname: nickname ?? this.nickname,
      residenceCountryCode: residenceCountryCode ?? this.residenceCountryCode,
      residenceStateCode: residenceStateCode ?? this.residenceStateCode,
      residenceCityCode: residenceCityCode ?? this.residenceCityCode,
      introduction: introduction ?? this.introduction,
      profilePhotoUrl: profilePhotoUrl ?? this.profilePhotoUrl,
      backgroundPhotoUrl: backgroundPhotoUrl ?? this.backgroundPhotoUrl,
      interests: interests ?? List.from(this.interests),
      expertises: expertises ?? List.from(this.expertises),
    );
  }
}</code></pre>
<p>Param 은 다음과 같이 불변 객체로 설계할 수 있다. Entity 는 불변 객체를 사용하기 때문에 Param 은 가변 객체로 설정해도 되긴 하지만, 이전 게시글에서 &#39;가변 객체 설계 방식의 문제점&#39;에 대해 언급한 바가 있기 때문에 마찬가지로 불변 객체로 설계하였다. 또한 해당 코드에서 생성자 부분을 보면 타입 별 기본 값으로 초기화를 했는데, 이는 개발자 취향 차이인 것 같다. 필자처럼 <strong>(1) 생성자에 기본값으로 초기화 하거나, (2)<code>required this.필드</code> 를 통해 생성단계에서 초기화 하는 방법</strong>이 있는데, 개인적으로 전자의 방법이 좀 더 편해서 이를 채택했다. 후자의 방법일 경우 프로필 생성 과정을 예시로 총 4개의 화면에서 13개의 데이터 입력을 받아야 하는데, <strong>각 UI 별로 입력값을 저장할 변수가 필요하고, 화면이 넘어갈 때마다 수집했던 데이터들을 모두 넘겨줘야 한다.</strong> 이러한 방법보다는 UI에서 수집하면 바로 해당 param 의 필드를 변경하고 화면마다 해당 객체를 넘겨주는 방식이 더 편리하게 다가왔다. </p>
<p>그러나 Update 인 경우는 다르다. 보통 Update 화면에서는 서버에서 데이터를 불러오거나 이전 화면에서 기존 데이터를 저장하고 있는 객체를 넘겨주기 때문에 후자의 방법을 통해 param 을 초기에 생성해주고 생성자에 해당 객체의 필드들로 초기화를 하는 방법이 더 간편하다.</p>
<hr>
<h2 id="세번째-시행착오-param-도입">세번째 시행착오: Param 도입</h2>
<h3 id="--presentation-layer---view-screen">- Presentation Layer - View, Screen</h3>
<pre><code class="language-dart">/// class ProfileCreateView 생략 
...

class _ProfileCreateViewState extends State&lt;ProfileCreateView&gt; {
  /// 다른 변수들 생략
  ...

  /// Data Field -&gt; Param
  ProfileCreateParam param = ProfileCreateParam();

  /// 해당 화면에서 사용할 bloc
  late ProfileCreateBloc profileCreateBloc;

  @override
  void initState() {
    profileCreateBloc = BlocProvider.of&lt;ProfileCreateBloc&gt;(context);
    ...
  }

  @override
  void dispose() {
    profileCreateBloc.close();
    ...
  }

  @override
  Widget build(BuildContext context) {
    ...
    return getBodyWidget();
  }

  /// Scaffold 의 body 에 호출될 메소드
  Widget getBodyWidget() {
    ...

    /// 자기소개
    IntroductionTextField(
        ...
        onChanged: (value) {
          /// introduction 값 업데이트
          setState(() {
            param = param.copyWith(introduction: value);
          });
        },
    ),

    ...

    /// 프로필 설정 완료 버튼 =&gt; 버튼 클릭 시 API 호출
    TextButton(
      onPressed: () =&gt; requestProfileCreate(),
      child: ...
    )
  }

  /// 다른 함수들 생략
  ...

  /// Bloc에 이벤트 Dispatch
  void requestProfileCreate() {
    profileCreateBloc.add(CreateProfile(param: param);
  }
}</code></pre>
<p>다음은 프로필 생성 화면을 이루는 코드이다. 여기서 <code>IntroductionTextField</code> 라는 위젯을 통해 입력값을 받게 되는데 이 때 Param 의 <code>copyWith()</code> 를 통해 값을 업데이트할 수 있고, 만일 필수 입력값들이 모두 수집된 상태에서 &#39;프로필 설정 완료&#39; 버튼을 눌렀을 때 <code>requestProfileCreate()</code>를 호출함으로써 해당 param을 Bloc에 전달할 수 있다. 이와 같이 사용했을 때 별도의 Request 용 Entity 를 생성하거나 Domain 기반 Entity 를 활용할 필요 없이, 필요한 데이터들만 수집할 수 있는 객체인 Param을 이용하면 이전 게시글들에서 발생했던 문제를 해결할 수 있다.</p>
<h3 id="--presentation-layer---presenter-bloc">- Presentation Layer - Presenter, Bloc</h3>
<pre><code class="language-dart">class ProfileCreateBloc extends Bloc&lt;ProfileCreateEvent, ProfileCreateState&gt; {
  /// UseCase 의존성 주입
  final createProfileUseCase = serviceLocator&lt;CreateProfileUseCase&gt;();
  ...

  /// View 전용 Mapper
  final mapper = ViewProfileMapper();

  ProfileCreateBloc() : super(ProfileCreateInitial()) {
    on&lt;CreateProfile&gt;(_createProfileRequested);
    ...
  }

  Future&lt;void&gt; _createProfileRequested(
    CreateProfile event, Emitter&lt;ProfileCreateState&gt; emit) async {
      emit(ProfileCreateLoading());
      ...

      /// Mapper: Param -&gt; Entity 로 변환 이후 use case 함수 호출
      final result 
        = await createProfileUseCase.execute(mapper.mapCreateParamToEntity(event.param));

      result.when(success: (data) {
        return emit(ProfileCreateSuccess(data));
      }, failure: (error) {
        return emit(ProfileCreateFailure(error));
      });
  }

  ...
}</code></pre>
<p>해당 Bloc 에서는 <code>CreateProfileUseCase</code> 의존성 주입을 받아 해당 함수를 호출하는데, 위의 화면에서 수집했던 Param 을 <code>Event</code> 를 통해 전달받고 <code>Mapper</code> 를 통해 Entity 로 변환하여 함수를 호출하는 형식이다. 여기서 등장하는 Mapper 는 &#39;화면 전용 Mapper&#39;로, Param 을 Entity 로 변환하는 역할을 한다. </p>
<p>단, 이 때 <code>int userId</code> 와 같이 <code>ProfileCreateParam</code> 에 없는 필드의 경우 default 값을 넣어주면 된다. 해당 데이터의 경우 서버로부터 받아와야 하는 데이터기 때문에 생성 요청을 보내는 유스케이스의 경우 해당 데이터에 default 값을 넣어도 비즈니스 로직에는 전혀 지장을 주지 않는다.</p>
<ul>
<li>profile_mapper.dart<pre><code class="language-dart">class ViewProfileMapper {
/// [Profile] 도메인 기반 Entity
Profile mapCreateParamToEntity(ProfileCreateParam param)
  =&gt; Profile(
       residenceCountryCode: param.residenceCountryCode,
       residenceStateCode: param.residenceStateCode,
       residenceCityCode: param.residenceCityCode,
       ...
       /// Default 값 할당하기
       userId: -1,
       hostValue: 0.0,
       guestValue: 0.0,
       ...
     );
...
} </code></pre>
</li>
</ul>
<p>위의 과정을 통해 Presentation Layer 에서 Param 이 다음과 같이 사용 될 수 있음을 알아보았다. 이로써 Param 은 <strong>비즈니스 로직은 없고 UI 로부터 수집한 데이터를 저장 용도로 만든 객체</strong>이며 지난 회고록(클린 아키텍처 회고록(2))에서 언급했던 Domain 기반의 Entity 를 Presentation Layer 에서 데이터 수집용으로 이용하는 것보다 더 효율적이고 명확한 설계 방식임을 알 수 있다.</p>
<p>이는 몇 가지 중요한 이점을 제공한다:</p>
<blockquote>
<ol>
<li>의존성 감소
: Param 객체를 도입함으로써 Presentation Layer가 Domain Layer의 Entity에 직접적으로 의존하지 않게 된다. 이는 아키텍처의 계층 간 결합도를 낮추어 유지보수성과 확장성을 높여준다.</li>
<li>유연성 향상
: 화면에서 필요한 데이터만을 수집하고 관리할 수 있기 때문에, UI 요구사항이 변경되더라도 Domain Entity에 불필요한 영향을 주지 않는다. 이는 특히 다양한 입력 양식이나 화면 단계가 존재하는 복잡한 폼 구조에서 유리하다.</li>
<li>메모리 효율성
: Entity가 가지고 있는 모든 필드를 메모리에 유지할 필요 없이, 화면에 필요한 데이터만을 수집하기 때문에 메모리 낭비를 줄일 수 있다. 특히, 대규모 데이터를 다루거나 다양한 입력 필드가 있는 경우 이점이 더욱 크다.</li>
<li>불변성 유지 및 안전성 확보
: Param 객체도 불변 객체로 설계함으로써 데이터의 일관성과 안정성을 보장할 수 있다. 이는 특히 상태 관리(예: Bloc)에서 예기치 않은 값 변경으로 인한 버그를 방지하는 데 도움이 된다.</li>
</ol>
</blockquote>
<hr>
<h2 id="trade-off-및-한계점">Trade-off 및 한계점</h2>
<p>그러나 이러한 설계 방식에도 몇 가지 단점이나 고려해야 할 점이 존재한다.</p>
<blockquote>
<ol>
<li>Mapper 관리의 복잡성
: Param → Entity 변환을 위한 별도의 Mapper 클래스를 작성해야 하므로 코드의 양이 증가하고 관리 포인트가 늘어난다. 특히 Entity의 필드가 복잡해질수록 Mapper 코드도 점차 복잡해질 수 있다.
: Param -&gt; Entity 를 변환하는 View 전용 Mapper, Model &lt;-&gt; Entity 변환하는 Data layer 에서 사용하는 Mapper 총 두 개가 필요하다.</li>
<li>중복 코드 가능성
: 비슷한 형태의 Param과 Mapper가 여러 개 만들어질 경우, 코드의 중복이 발생할 수 있다. 이를 해결하기 위해 공통된 로직을 추출하거나, 제너릭 기반의 변환 유틸리티를 만들어 관리하는 것이 필요하다.</li>
<li>테스트 범위 증가
: 새로운 계층(Param, Mapper)이 도입되면서 테스트해야 할 범위가 넓어진다. 각각의 Mapper 변환 로직에 대한 단위 테스트를 작성해야 하며, 이는 개발 리소스를 추가로 요구한다.</li>
</ol>
</blockquote>
<p>결론적으로, Param 객체의 도입은 클린 아키텍처에서 Presentation Layer와 Domain Layer의 의존성을 줄이는 효과적인 방법이며, 복잡한 입력 처리나 데이터 수집에 있어 명확하고 직관적인 흐름을 제공한다. 앞으로의 개발에서는 이 구조를 기반으로 확장성과 유지보수성을 더욱 강화할 수 있는 방법들을 지속적으로 고민해야 할 것이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Flutter] 클린 아키텍처 회고록(2)]]></title>
            <link>https://velog.io/@sangjin-hash/%ED%81%B4%EB%A6%B0-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%ED%9A%8C%EA%B3%A0%EB%A1%9D2</link>
            <guid>https://velog.io/@sangjin-hash/%ED%81%B4%EB%A6%B0-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%ED%9A%8C%EA%B3%A0%EB%A1%9D2</guid>
            <pubDate>Sat, 15 Feb 2025 17:27:35 GMT</pubDate>
            <description><![CDATA[<p>지난 게시글에 이어 이번에는 두번째로 겪었던 시행착오에 대해 다뤄보고자 한다. Entity와 Model을 1:1 대응하여 설계했던 방법론에서 되려 유지보수가 어렵고 새로운 기능이 추가되거나 기존의 기능을 변경할 때 생산성이 더 저해되는 한계점을 착안하여 이번에는 Entity 와 Model을 1:1 대응하지 않고 좀 더 명확한 역할 분리를 하였다. 이번 게시글에서는 어떻게 Entity를 설계하였고 이에 대한 장단점이 무엇이고 어떻게 보완했는지에 다뤄보겠다.</p>
<h2 id="keyword-및-중점-내용">Keyword 및 중점 내용</h2>
<ul>
<li>도메인 중심의 Entity 설계</li>
<li>가변 객체 &amp; 불변 객체</li>
<li>Param</li>
</ul>
<br>

<h2 id="두번째-시행착오-도메인-중심의-entity-설계">두번째 시행착오: 도메인 중심의 Entity 설계</h2>
<p><img src="https://velog.velcdn.com/images/sangjin-hash/post/9850355a-612d-4f9d-9855-9a343b94034c/image.png" alt=""></p>
<p>위의 사진은 프로필 화면이다. 오른쪽의 사진을 주목해보면 빨간색 칠한 박스부분이 <strong>프로필을 생성했을 당시에 수집했던 데이터</strong>들이고, 나머지 데이터들을 포함하여 &#39;프로필&#39; 도메인에서 사용하는 데이터 전부를 담은 내용이다. 즉, Model 과 Entity 를 1:1 대응하여 사용했던 설계방식을 <strong>&#39;도메인&#39; 별로 나눠 Entity 를 나누고, 해당 Entity 에는 해당 도메인에서 사용하는 데이터들을 모두 담고 있는 방식</strong>으로 설계하였다. 이렇게 설계했을 때 첫번째 시행착오에서 보완할 수 있는 점은 다음과 같다.</p>
<blockquote>
<ol>
<li>API 별로 Entity 를 생성하지 않아도 되므로 생성해야 하는 Entity 의 수가 현저히 낮아짐.</li>
<li>도메인별로 역할이 분리되어 유지보수가 용이함</li>
<li>비즈니스 로직에서 데이터의 일관성을 유지할 수 있음</li>
<li>각 Entity가 독립적으로 동작할 수 있음</li>
</ol>
</blockquote>
<p>기존 방식에서는 API 요청마다 새로운 Entity를 생성해야 했기 때문에, API가 추가되거나 변경될 때마다 관련된 Entity들도 같이 수정해야 하는 문제가 있었다. 하지만 도메인 단위로 Entity를 나누면, 해당 도메인에서 다루는 데이터가 명확해지고, <strong>새로운 기능이 추가되거나 기존 기능을 변경할 때에도 관련된 Entity를 수정할 필요가 줄어들어 유지보수가 훨씬 용이</strong>해지는 장점이 있다. 또한 도메인 중심으로 Entity를 나누면, <strong>해당 도메인에서 사용하는 데이터만 포함하게 되므로 데이터 일관성을 유지하면서도 불필요한 의존성을 줄일 수 있다.</strong> </p>
<p>마지막으로 해당 이유가 도메인 중심 설계로 변경하게 된 계기였는데, 아무래도 API별로 Entity를 만들게 되면 서로 다른 API에서 유사한 데이터를 반환할 경우 비슷한 구조의 Entity가 여러 개 생기게 되고, 이로 인해 중복된 코드가 많아지는 문제가 발생하였다. 반면 도메인 기반으로 Entity를 정의하면, <strong>각 Entity가 독립적인 역할을 가지면서도 필요한 경우 공통 속성을 공유할 수 있어 중복을 줄일 수 있는 효과</strong>가 있다.</p>
<hr>
<h2 id="가변-객체-설계-방식의-문제점">가변 객체 설계 방식의 문제점</h2>
<p>위의 Entity 를 보면 우선 모든 필드가 nullable 이다. 이렇게 설계한 이유는 첫번째 시행착오 게시글에서 다뤘던 프로필 생성 예시로 설명해보겠다.</p>
<p><img src="https://velog.velcdn.com/images/sangjin-hash/post/8f939737-e965-401e-8bcb-4c1a755f3dd0/image.png" alt=""></p>
<p>프로필 생성 과정에서 입력 받아야 하는 데이터는 총 13개로, 여러가지 형태의 UI를 통해 입력을 받는다. UI 에서 데이터를 수집해 해당 Entity 에 수집한 데이터를 할당해주게 되는데, 이 때 불변 객체를 만들어 사용하게 되면 UI 입력의 수정이 생길 때마다 매번 Entity 의 객체를 복사하여 값을 업데이트 시켜줘야 하는 부담이 있다. 이에 반해 가변 객체를 사용했을 때는 화면에 해당 Entity 를 import 하기만 하면 UI 업데이트마다 해당 객체를 불러와 값만 바꿔주기만 하면 손쉽게 사용이 가능하다. 예시를 들어보자면 <code>TextField</code>만 보더라도 입력의 변경이 엄청 잦은편인데 이에 따라 <strong>객체를 매번 복사하여 업데이트를 해주는 컴퓨팅 비용이 가변 객체를 만들어 사용했을 때보다 부담이 있다</strong>고 판단해서 가변 객체를 사용하였다.</p>
<p>그러나 Entity 를 가변 객체로 사용했을 때 문제점은 다소 치명적이었다.</p>
<blockquote>
<ol>
<li>Thread safe 하지 않다.</li>
<li>해당 Entity 를 사용할 경우 매번 필드마다 null 체크가 필요하다.</li>
<li>API Request 에 사용되는 데이터 필드 수가 Entity 가 가지고 있는 필드 수보다 적기 때문에 화면에서 입력받지 않아도 되는 데이터들에 대해서는 메모리 낭비이다.</li>
</ol>
</blockquote>
<p>가변 객체는 여러 스레드에서 동시에 접근하여 값을 변경할 수 있기 때문에, 멀티스레드 환경에서 데이터 정합성이 깨질 위험이 크다.</p>
<pre><code class="language-dart">void updateProfile(Profile profile) {
  // API 응답이 도착하여 프로필 변경
  Future.delayed(Duration(milliseconds: 500), () {
    profile.nickname = &quot;Sangjin&quot;;
    print(&quot;API 응답에서 닉네임 변경: ${profile.nickname}&quot;);
  });

  // UI에서 사용자가 닉네임을 입력하는 도중
  Future.delayed(Duration(seconds: 1), () {
    profile.nickname = &quot;Jay&quot;;
    print(&quot;UI에서 닉네임 변경: ${profile.nickname}&quot;);
  });
}

void main() {
  Profile profile = Profile();
  updateProfile(profile);
}</code></pre>
<p>다음 코드를 예시로 프로그램 시작과 동시에 API 요청을 하고 응답에 따라 UI를 변경하는 첫번째 옵션과 사용자의 입력을 받아 UI를 변경하는 두 번째 옵션이 있다고 가정하자. 첫번째 옵션은 시작한지 500ms 내로 응답이 와서 UI를 변경하고, 두 번째 옵션은 1초 후에 사용자가 업데이트 한다고 할 때, 결과는 다음과 같이 기대할 수 있다.</p>
<pre><code class="language-text">API 응답에서 닉네임 변경: Sangjin
UI에서 닉네임 변경: Jay</code></pre>
<p>그러나 네트워크 특성 상 환경에 따라 API 응답이 언제 도착할지 보장할 수 없기 때문에, 위의 예시 코드처럼 500ms 내로 올 수도 있고, 1000ms 보다 더 오래 걸릴 수 있기 때문에 만일 최종적으로 <code>Jay</code> 로 변경되길 원했지만 <code>Sangjin</code> 으로 변경될 수도 있다. 즉 <strong>Race Condition</strong> 문제가 발생하여 데이터 정합성이 깨질 가능성이 있다.</p>
<p>두 번째 이유로 null 체크를 매번 할 시 코드의 가독성을 저해하고, 필드 사용 시 <code>field ?? default 값</code> 혹은 <code>if(field != null)</code> 과 같은 처리를 해줘야 해서 번거로운 문제가 있다.</p>
<p>마지막으로는 위의 프로필 생성 과정을 참고하면 해당 화면들에서 입력값을 받아야 하는 필드의 개수는 총 13개지만, Entity에는 불필요한 데이터인 나머지 5개의 필드에 대해서도 동일하게 메모리에 올려야 하니 낭비가 발생하는 문제가 있다.</p>
<hr>
<h2 id="param-의-도입">Param 의 도입</h2>
<p>첫번째 시행착오인 Entity &lt;-&gt; Model 1:1 설계 방식을 보완하기 위해 도메인 중심 Entity 설계 방식이 나왔으나, 아직까지 두 방식에서 해결하지 못한 문제점이 있다.</p>
<blockquote>
<p>Presentation Layer 가 Domain Layer의 Entity 에 너무 의존적이다.</p>
</blockquote>
<p>아직까지 Entity 를 이용하여 UI로부터 수집한 데이터를 저장하고 있는 점에서 Presentation Layer에서 너무 의존적이라는 문제점을 해결하지 못하였다. 따라서 이를 보완하기 위해 <code>Param</code> 을 도입하였다. Param은 쉽게 말하자면 <code>화면 전용 데이터 껍데기</code> 라고 정의하고 싶다. 다시 풀어서 설명하자면, 비즈니스 로직은 없고 UI 로부터 수집한 데이터를 저장 용도로 만든 객체인 것이다. 첫번째 시행착오 게시글에서 설명했었던 비즈니스 로직이 빠져있는 <code>ProfileCreateRequestEntity</code> 라고 생각하면 된다. 화면으로부터는 Param 을 통해 데이터를 저장해서 Domain의 <code>Usecase</code> 함수를 호출할 땐 Entity로 변환하여 사용하면 되므로 더이상 화면이 Entity 에 의존하지 않아도 되는 것이다. </p>
<p>여기까지가 Param 의 개요에 대한 내용이고, 다음 게시글에서 이를 이용해 클린 아키텍처를 어떻게 변형하였는지 설명하도록 하겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Flutter] 클린 아키텍처 회고록(1)]]></title>
            <link>https://velog.io/@sangjin-hash/%ED%81%B4%EB%A6%B0-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%ED%9A%8C%EA%B3%A0%EB%A1%9D1</link>
            <guid>https://velog.io/@sangjin-hash/%ED%81%B4%EB%A6%B0-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%ED%9A%8C%EA%B3%A0%EB%A1%9D1</guid>
            <pubDate>Thu, 13 Feb 2025 18:33:02 GMT</pubDate>
            <description><![CDATA[<p>소프트웨어 개발에서 아키텍처는 프로젝트의 뼈대를 구성하는 중요한 요소 중 하나다. 그중에서도 <strong>클린 아키텍처(Clean Architecture)</strong>는 소프트웨어의 유지보수성과 확장성을 극대화하기 위해 제안된 설계 원칙이다.</p>
<p>이번 글에서는 필자가 실제 운영하고 있는 서비스에서 클린 아키텍처를 적용하면서 경험한 시행착오를 돌아보고, 이를 통해 얻은 인사이트를 공유하려 한다.</p>
<h2 id="keyword-및-중점-내용">Keyword 및 중점 내용</h2>
<ul>
<li>클린 아키텍처</li>
<li>Entity 와 Model의 분리</li>
<li>Model 과 1:1 대응한 Entity 설계 방식<br>
## 개요

</li>
</ul>
<p>클린 아키텍처의 핵심 개념은 의존성 역전(Dependency Inversion) 원칙을 기반으로 하여, 비즈니스 로직과 프레젠테이션, 데이터 접근 등의 관심사를 분리하는 것이다. 이를 통해 도메인 로직을 프레임워크나 UI로부터 독립적으로 설계할 수 있으며, 테스트가 용이하고 변경에 강한 구조를 만들 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/sangjin-hash/post/1a888614-a097-45dc-b4eb-93faefc4b8e9/image.png" alt=""></p>
<p>필자는 클린 아키텍처의 레이어는 다음과 같이 구성할 수 있다.</p>
<ul>
<li><strong>Data Layer</strong><ul>
<li>DB, 웹서비스, 디바이스 내부 DB 등 외부 Data Source(Local, Remote)로부터 데이터를 핸들링하는 레이어</li>
<li>Data Sources : REST API, 로컬 DB, Shared Preferences 등에서 데이터를 가져오는 클래스</li>
<li>Models : 데이터를 표현하는 모델 클래스(JSON 직렬화 / 역직렬화 포함)</li>
<li>Repository (구현체) : N개의 Data Source 를 다루며 Domain Layer의 Repository Interface 를 구현한 클래스</li>
</ul>
</li>
</ul>
<ul>
<li><strong>Domain Layer</strong><ul>
<li>앱의 핵심 비즈니스 로직을 처리하는 레이어</li>
<li>Entities : 애플리케이션의 핵심 데이터를 표현하는 객체</li>
<li>Use Cases : 특정 기능을 수행하는 서비스 로직</li>
<li>Repository interfaces : Data Layer와의 의존성을 줄이기 위한 추상화 계층</li>
</ul>
</li>
</ul>
<ul>
<li><strong>Presentation Layer</strong><ul>
<li>UI 및 상태 관리를 담당하는 레이어</li>
<li>bloc : 상태 관리 및 UI 로직 처리</li>
<li>Pages : 화면</li>
<li>Widgets : 재사용 가능한 UI 컴포넌트</li>
</ul>
</li>
</ul>
<hr>
<h2 id="문제-상황-정의">문제 상황 정의</h2>
<p><img src="https://velog.velcdn.com/images/sangjin-hash/post/d292c39d-2b54-44b0-b1ec-7fff93bbf2e9/image.png" alt="">
회고록의 이해를 돕기 위해 실제 운영하고 있는 서비스의 일부 기능 중 하나를 가져왔다. 해당 화면은 사용자가 프로필을 만드는 과정을 나타낸 것으로, 4개의 페이지를 통해 수집된 <strong>13개의 데이터를 서버로 전송해야</strong> 하는데 이 때 필요한 데이터는 다음과 같다.</p>
<div align="center">
  <img src="https://velog.velcdn.com/images/sangjin-hash/post/9c7d76a5-039d-466a-bd33-ddb2ee785607/image.png" width="700">
</div>

<p>여기서 주목해야 할 점은 <code>String profilePhotoUrl</code> 과 <code>String backgroundPhotoUrl</code> 인데, <strong>프로필 생성 요청을 보내기 이전에 우선 S3에 업로드를 한 뒤, 해당 이미지가 저장되어 있는 다운로드 URL 을 받아와야 한다.</strong> 그 이후 해당 URL 을 위에서 언급한 <code>profilePhotoUrl</code>, <code>backgroundPhotoUrl</code> 에 값을 할당한 뒤 프로필 생성 요청을 보내야 한다. 즉 다음 사진과 같은 로직을 수행해야 한다.
<img src="https://velog.velcdn.com/images/sangjin-hash/post/5545d14a-0905-4564-a8e9-70481347e8a9/image.png" alt=""></p>
<p>자체 서버(해당 서비스 운영 서버)로 우선 PreSigned URL을 요청해서 받아온 뒤에 해당 URL 로 이미지를 업로드를 하는 로직은 <code>AwsUploadMediaUseCase</code> 라는 <code>Usecase</code> 에 구현을 해놓았다. 1~2번을 수행한 결과로 해당 이미지가 업로드가 된 URL을 받게 되는데 이 형태는 cdn이 붙어 있는 URL 형태이며 이를 가지고 <code>String profilePhotoUrl</code> 과 <code>String backgroundPhotoUrl</code>에 알맞게 할당해 준 뒤 나머지 입력 데이터들(11개)를 가지고 프로필 생성 요청을 보내는 예시이다. 해당 과정을 클린 아키텍처 적용을 했을 때 겪었던 시행착오들을 가지고 <strong>Domain의 Entity를 어떻게 설정했고, Presentation-Domain-Data Layer 의 Call Flow</strong> 에 따라 설명하겠다.</p>
<hr>
<h2 id="첫번째-시행착오-entity---requestresponse-11-대응">첫번째 시행착오: Entity &lt;-&gt; Request/Response 1:1 대응</h2>
<h3 id="--profilecreaterequestentity-설계">- ProfileCreateRequestEntity 설계</h3>
<pre><code class="language-dart">class ProfileCreateRequestEntity {
    bool? traveler;
    bool? male;
    int? residenceYear;
    String? birth;
    String? nickname;
    String? residenceCountryCode;
    String? residenceStateCode;
    String? residenceCityCode;
    String? introduction;
    String? profilePhotoUrl;
    String? backgroundPhotoUrl;
    List&lt;String&gt;? interests;
    List&lt;String&gt;? expertises;
}</code></pre>
<p>우선 해당 Entity 를 Request DTO 와 1:1 대응하여 구성한 이유는 다음과 같다.</p>
<blockquote>
<ol>
<li>프로필 생성 API 를 호출할 때 4개의 화면에서 총 13 개의 데이터를 수집해야 하는데, 이를 저장할 데이터 객체가 필요하며 유지보수가 용이하다.</li>
<li>데이터 변환을 최소화할 수 있다. (Entity &lt;-&gt; Model)</li>
</ol>
</blockquote>
<p>위의 두 가지 이유로 Data Layer 의 Model 과 1:1 대응하는 Entity 를 만들어 해당 Entity 를 Presentation Layer 에서 사용하였고, UI 의 입력값들을 상응하는 Entity 의 필드에 값을 할당해주었다. 이 때까지만 해도 클린 아키텍처에서 핵심적인 원칙 중 하나인 의존성 방향이 항상 바깥쪽 원에서 안쪽 원으로 향해야 하기 때문에 UI(가장 바깥쪽)에서 Entity(가장 안쪽)에 의존해도 된다고 생각해서, 해당 Entity 를 Presentation Layer 에서도 사용했었다.</p>
<p>또한 해당 Entity 를 <strong>가변 객체</strong>로 만든 이유는 UI의 입력값에 변경이 잦기 때문에 해당 객체의 값을 적은 비용으로 수정하기 위함이다. 해당 Entity 를 불변 객체로 만들어서 사용할 경우 사용자가 UI의 입력값을 변경할 때마다 해당 Entity의 객체를 복사하여 교체하거나 <code>copyWith()</code> 함수를 추가로 만들어 되는 점이 번거롭다고 판단하여 가변 객체로 만들어 사용하였다.</p>
<h3 id="--presentation-layer">- Presentation Layer</h3>
<ul>
<li>profile_create_event.dart<pre><code class="language-dart">sealed class ProfileCreateEvent extends Equatable {
  const ProfileCreateEvent();
}
</code></pre>
</li>
</ul>
<p>class CreatePersonalProfile extends ProfileCreateEvent {
    const CreatePersonalProfile({
        required this.userId,
        requiired this.entity
    });</p>
<pre><code>final int userId;
final ProfileCreateRequestEntity entity;

@override
List&lt;Object?&gt; get props =&gt; [userId, entity];</code></pre><p>}</p>
<pre><code>
- profile_create_bloc.dart
```dart
class ProfileCreateBloc extends Bloc&lt;ProfileCreateEvent, ProfileCreateState&gt; {
    final AwsUploadMediaUseCase awsUploadMediaUseCase =
      serviceLocator&lt;AwsUploadMediaUseCase&gt;();
    final CreateMyProfileUseCase createMyProfileUseCase =
      serviceLocator&lt;CreateMyProfileUseCase&gt;();

    ProfileCreateBloc() : super(ProfileCreateInitial()) {
        on&lt;CreatePersonalProfile&gt;(_createPersonalProfile);
        ...
    }

    Future&lt;void&gt; _createPersonalProfile(CreatePersonalProfile event, 
        Emitter&lt;ProfileCreateState&gt; emit) async {
            emit(ProfileCreateLoading());

            /// 이미지 업로드 이후 다운로드 URL 저장할 변수들
            late String profilePhotoUrl;
            late String backgroundPhotoUrl;

            // 이미지 S3 업로드 로직 생략(위의 두 변수에 URL 할당하는 작업)
            ...

            /// Entity 의 profilePhotoUrl, backgroundPhotoUrl 교체
            /// (기존) 이미지가 저장되어 있는 경로(앨범에서 가져오므로 해당 파일의 경로가 들어가 있음)
            /// (변경) Download URL
            final profileCreateEntity = event.entity;
            profileCreateEntity.profilePhotoUrl = profilePhotoUrl;
            profileCreateEntity.backgroundPhotoUrl = backgroundPhotoUrl;

            /// 프로필 생성 요청
            final result = await createMyProfileUseCase.execute(event.userId, profileCreateEntity);

            result.when(
                success : (data) {
                    emit(ProfileCreateSuccess(data));
                },
                failure: (error) {
                    emit(ProfileCreateFailure(error));
                }
            );
    }
}</code></pre><h3 id="--domain-layer">- Domain Layer</h3>
<ul>
<li><p>profile_create_response_entity.dart</p>
<pre><code class="language-dart">class ProfileCreateResponseEntity {
  final int userId;
  final String nickname;
  final bool traveler;
  /// 일부 필드 생략
  ...

  const ProfileCreateResponse({
      required this.userId,
      required this.nickname,
      required this.travler,
      ...
  });

  /// 일부 비즈니스 로직 생략
  ...
}</code></pre>
</li>
<li><p>create_my_profile.dart</p>
<pre><code class="language-dart">class CreateMyProfileUseCase {
  final ProfileRepository profileRepository;

    CreateMyProfileUseCase({required this.profileRepository});

    Future&lt;Result&lt;ProfileCreateResponseEntity&gt;&gt; execute(int userId, ProfileCreateEntity entity) {
      return profileRepository.createMyProfile(userId, entity);
    }
}</code></pre>
</li>
<li><p>profile_repository.dart</p>
<pre><code class="language-dart">abstract class ProfileRepository {
  Future&lt;Result&lt;ProfileCreateResponseEntity&gt;&gt; createMyProfile(int userId, ProfileCreateEntity entity);

  /// 다른 함수들 생략
  ...
}</code></pre>
</li>
</ul>
<h3 id="--data-layer">- Data Layer</h3>
<ul>
<li><p>profile_create_request_model.dart</p>
<pre><code class="language-dart">@freezed
class ProfileCreateRequestModel with _$ProfileCreateRequestModel {
factory ProfileCreateRequestModel({
  required bool traveler,
  required bool male,
  required int residenceYear,
  required String birth,
  required String nickname,
  required String residenceCountryCode,
  required String residenceStateCode,
  required String residenceCityCode,
  required String introduction,
  required List&lt;String&gt; interests,
  required List&lt;String&gt; expertises,
  required String profilePhotoUrl,
  required String backgroundPhotoUrl,
}) = _ProfileCreateRequestModel;

factory ProfileCreateRequestModel.fromJson(Map&lt;String, dynamic&gt; json) =&gt;
    _$ProfileCreateRequestModelFromJson(json);
}</code></pre>
</li>
<li><p>profile_create_response_model.dart</p>
<pre><code class="language-dart">@freezed
class ProfileCreateResponseModel with _$ProfileCreateResponseModel {
factory ProfileCreateResponseModel({
  required String nickname,
  required bool traveler,
  ...
}) = _ProfileCreateResponseModel;

factory ProfileCreateResponseModel.fromJson(Map&lt;String, dynamic&gt; json) =&gt;
    _$ProfileCreateResponseModelFromJson(json);
}</code></pre>
</li>
</ul>
<p>위의 두 Model 클래스를 보면 <code>@freezed</code> 라이브러리를 이용하여 단순히 <strong>JSON 직렬화/역직렬화</strong> 하는 기능만 담고 있다. 이렇게 되면 Entity 와 Model 의 차이를 혼동할 수 있을 텐데, 필자는 Model 과 Entity 를 다음과 같이 정의하였다.</p>
<blockquote>
<ul>
<li>Entity : 애플리케이션의 핵심 비즈니스 로직과 도메인 데이터를 가지고 있는 객체.</li>
<li>Model : JSON 직렬화 / 역직렬화만 담당하며, 비즈니스 로직이 없는 데이터 전송 객체.</li>
</ul>
</blockquote>
<p>다음과 같이 정의한 이유로는, 서버로부터 받은 데이터를 애플리케이션 내부에서 바로 사용하기보다는, <strong>도메인 로직에 맞게 변환하여 관리하는 것이 더 유연하고 확장성이 높기 때문이다.</strong></p>
<p>예를 들어, 서버에서 반환된 JSON 데이터는 API 설계에 따라 구성되며, 이는 애플리케이션이 필요로 하는 도메인 모델(Entity)과 정확히 일치하지 않을 수 있다. API 응답 데이터는 종종 RESTful 또는 GraphQL의 설계 원칙에 따라 최적화되어 있으며, UI 또는 비즈니스 로직을 수행하는 도메인 계층에서 그대로 사용하기에 적절하지 않을 수 있다. 따라서, Model은 서버에서 받은 원본 데이터를 표현하는 역할을 하고, Entity는 해당 데이터를 애플리케이션의 도메인 요구사항에 맞게 변환하여 사용하도록 한다.</p>
<p>이러한 분리를 통해 얻을 수 있는 주요 장점은 다음과 같다.</p>
<blockquote>
<ol>
<li>데이터 구조 변경에 대한 유연성
•    서버의 API 응답 형식이 변경되더라도 Model을 조정하면 되며, Entity를 사용하는 비즈니스 로직은 영향을 받지 않는다.
•    Entity는 애플리케이션 내부의 도메인 로직을 반영하기 때문에, API 변경에 독립적으로 유지될 수 있다.<br></li>
<li>비즈니스 로직과 데이터 구조의 명확한 분리
•    Model은 데이터 전송 객체(DTO)로서 직렬화/역직렬화만 담당하고, 어떠한 비즈니스 로직도 포함하지 않는다.
•    Entity는 도메인 규칙을 반영하며, Model과 다르게 비즈니스 로직을 직접 포함할 수 있다.<br></li>
<li>데이터 가공 및 변환 과정의 명확화
•    서버 응답을 받은 후, 애플리케이션이 필요로 하는 형태로 변환하여 Entity에 저장할 수 있다.
•    예를 들어, 서버에서 날짜 데이터를 timestamp 형식으로 반환하는 경우, Model에서는 int 값으로 저장하고, Entity에서는 이를 DateTime 객체로 변환하여 사용할 수 있다.</li>
</ol>
</blockquote>
<p>따라서, Model과 Entity를 구분하는 것은 단순한 코드 구조의 차이가 아니라, 데이터 처리 방식과 애플리케이션의 유지보수성을 고려한 설계 원칙의 일환이다. Model은 네트워크 레이어에서 원본 데이터를 표현하는 역할을 수행하며, Entity는 애플리케이션 내부에서 비즈니스 로직을 반영한 최적의 데이터 구조로 활용되는 것이 이상적인 분리 방식이다.</p>
<ul>
<li><p>profile_repository_impl.dart</p>
<pre><code class="language-dart">class ProfileRepositoryImpl implements ProfileRepository {
final ProfileApi profileApi;
/// Entity &lt;-&gt; Model Mapper
final profileMapper = ProfileMapper();

ProfileRepositoryImpl({required this.profileApi});

@override
Future&lt;Result&lt;ProfileCreateResponseEntity&gt;&gt; createMyProfile(
    int userId, ProfileCreateRequestEntity entity) async {
        try {
        /// ProfileCreateResponseEntity -&gt; ProfileCreateRequestModel 로 변환 후 생성 요청
          final result = await profileApi.createMyProfile(
              profileMapper.mapProfileEntityToRequestModel(entity));

        /// userId, ProfileCreateResponseModel -&gt; ProfileCreateResponseEntity 로 변환 후 리턴
          return Result.success(
              profileMapper.personalProfileResponseToEntity(userId, result));
        } catch (error) {
          return Result.failure(NetworkExceptions.getErrorMessage(
              NetworkExceptions.getDioException(error)));
        }
}</code></pre>
</li>
<li><p>profile_api.dart</p>
<pre><code class="language-dart">class ProfileApi {
  final DioClient dioClient;
  final DioClientV2 dioClientV2;

  ProfileApi({required this.dioClient, required this.dioClientV2});

  /// 사용자 프로필 생성
  Future&lt;PersonalProfileResponseModel&gt; createMyProfile(
    PersonalProfileCreateRequestModel model) async {
  try {
    final response = await dioClientV2.post(profileUrl, data: model.toJson());
    return PersonalProfileResponseModel.fromJson(response);
  } catch (error) {
    CustomLogger.logger.e(&#39;$TAG createProfileRequest() =&gt; $error&#39;);
    throw NetworkExceptions.getDioException(error);
  }
}
}</code></pre>
</li>
</ul>
<hr>
<h2 id="trade-off">Trade-off</h2>
<p>Entity를 Request / Response 와 1:1 대응으로 설계한 뒤 서비스를 운영을 했고 몸소 체감한 장단점은 다음과 같았다.</p>
<h3 id="--pros">- Pros</h3>
<blockquote>
<ol>
<li>API 기능 별로 필요한 Data Structure를 만듦.</li>
<li>유지보수에 용이하다</li>
</ol>
</blockquote>
<h3 id="--cons">- Cons</h3>
<blockquote>
<ol>
<li>API 기능 별로 필요한 Data Structure를 만듦.</li>
<li>유지보수가 불편하다(서비스 규모가 클수록(API 개수가 많아질수록) 그에 따른 Entity 도 증가)</li>
<li>Entity 별로 중복된 데이터 필드가 발생한다.</li>
<li>Presentation Layer 가 Domain Layer의 Entity 에 너무 의존적이다.</li>
</ol>
</blockquote>
<p>우선 장점을 먼저 설명하자면, API에 따라 필요한 Request, Response에 대응하는 Entity를 만들기 때문에, <strong>각 기능에서 필요한 데이터 구조를 명확하게 정의할 수 있다는 점에서 직관적</strong>이었다. 위의 프로필 생성 과정에서 보여줬던 4개의 화면을 예로 든다면, 각 화면에서 요구하는 데이터를 별도의 Entity를 통해 적절히 수집할 수 있다. 이를 통해 각 API의 역할이 명확해지고, 특정 기능에 맞춘 최적화된 데이터 구조를 설계할 수 있다.</p>
<p>또한, 각 API의 요청과 응답을 엄격하게 Entity에 매핑함으로써 타입 안정성이 강화된다. API 스펙이 변경될 경우, 변경된 부분을 Entity 레벨에서 바로 감지할 수 있으며, 이를 통해 코드 내에서 데이터 구조의 일관성을 유지할 수 있다. 이는 유지보수의 용이성을 높이는 요소로 작용하며, 특히 개발자가 API의 요청/응답 구조를 빠르게 파악할 수 있도록 도와준다.</p>
<p>추가적으로, Entity가 특정 API 기능과 1:1로 매칭되기 때문에, <strong>서비스 개발 초기 단계에서는 구조를 빠르게 잡고 확장하기 용이하다고 판단하였다.</strong> API의 응답 구조와 동일한 형태로 Entity를 구성하면, 변환 과정 없이 바로 데이터를 사용할 수 있어 직관적이며, DTO 또는 별도의 변환 과정 없이도 효율적인 데이터 매핑이 가능하다.</p>
<p>결론적으로, <strong>API에 맞춰 Entity를 설계하는 방식은 개별 기능에 필요한 데이터를 정확하게 정의하고, 코드의 가독성과 유지보수성을 높이며, 초기 개발 속도를 높이는 측면에서 유리한 접근 방식</strong>이라고 할 수 있다.</p>
<br>

<p>그러나 역설적으로 위에서 언급했던 장점이 그대로 단점으로 적용이 되었다.</p>
<blockquote>
<ol>
<li>API 기능 별로 필요한 Data Structure를 만듦</li>
<li>유지보수가 불편하다(서비스 규모가 클수록(API 개수가 많아질수록) 그에 따른 Entity 도 증가)</li>
</ol>
</blockquote>
<p>API가 증가함에 따라 이에 대응하는 Entity도 함께 증가하면서 유지보수의 어려움이 발생했다. 새로운 API가 추가될 때마다 새로운 Entity를 만들어야 하며, 기존 API가 변경되면 관련된 모든 Entity를 수정해야 하는 경우도 많아졌다. 또한, API 간의 데이터 흐름을 추적하기 어려워지면서, 특정 데이터를 어디서 어떻게 변환하고 있는지 파악하는 데 시간이 많이 소요되었다. 이로 인해 생산성이 저하되었으며, Entity 변경 사항이 여러 API에 걸쳐 있을 경우, 하나의 수정이 예상치 못한 버그를 유발하는 경우도 발생했다.</p>
<blockquote>
<ol start="3">
<li>Entity 별로 중복된 데이터 필드가 발생한다.</li>
</ol>
</blockquote>
<p>각 API 기능에 맞춰 Entity를 구성하다 보니, 특정 데이터 필드가 여러 Entity에서 반복적으로 사용되는 경우가 많았다. 예를 들어 <code>ProfileCreateRequestEntity</code>, <code>ProfileUpdateRequestEntity</code> 등 유사한 객체들이 존재할 경우, 이들 사이에서 id, nickname 등의 필드가 중복될 수밖에 없었다. 이러한 중복 필드를 이용한 비즈니스 로직을 Entity 에 만들었을 때, <code>ProfileCreateRequestEntity</code>, <code>ProfileUpdateRequestEntity</code> 두 Entity 에 마찬가지로 동일한 로직을 구현해야 한다는 점이 불편하고, 유지보수 시 일관성을 유지하기 어렵게 만들었으며 한 필드를 변경할 경우 모든 관련 Entity에서 해당 변경을 반영해야 하는 번거로움이 발생했다. 결과적으로, 데이터의 중복이 코드의 복잡성을 증가시키고, 유지보수 비용을 상승시키는 요인이 되었다.</p>
<blockquote>
<ol start="4">
<li>Presentation Layer 가 Domain Layer의 Entity 에 너무 의존적이다.</li>
</ol>
</blockquote>
<p>API의 요청 및 응답에 따라 Entity를 설계하다 보니, Presentation Layer에서 직접 Domain Layer의 Entity를 참조하는 경우가 많아졌다. 이는 Presentation Layer가 도메인 로직에 대한 강한 결합도를 가지게 만들었으며, UI의 변경이 Domain Layer의 Entity에도 영향을 미치는 문제를 초래했다. 예를 들어, UI에서 특정 필드가 필요 없거나 다르게 가공되어야 하는 경우에도, 기존 Entity를 직접 사용하면 변경이 어려워지고, 필요 이상으로 많은 데이터를 포함하는 객체를 전달해야 하는 비효율이 발생했다.</p>
<p>특히, API 응답을 기준으로 Entity를 설계하다 보니, UI가 API의 구조에 의존하는 형태가 되어 Presentation Layer와 Domain Layer 간의 분리가 어려워졌다. 결과적으로, API 스펙이 변경될 경우 UI까지 영향을 받는 구조가 형성되었으며, 도메인 로직의 변경이 UI 로직에도 불필요한 영향을 미치는 경우가 발생했다. 이는 클린 아키텍처의 원칙(레이어 간의 분리)에도 위배되는 설계 방식이 되어버렸다.</p>
<hr>
<h2 id="결론">결론</h2>
<p>Entity를 API와 1:1로 대응하여 설계하는 방식은 초기 개발 속도와 API별 최적화된 데이터 구조를 보장하는 측면에서 장점이 있지만, 서비스 규모가 커질수록 유지보수성이 떨어지고 코드 중복이 증가하는 문제가 발생했다. 또한, Presentation Layer와 Domain Layer의 결합도가 높아지는 현상이 나타나면서, 클린 아키텍처의 원칙을 유지하기 어려워지는 부작용도 있었다. 우선 해당 설계 방식을 적용할 시기에 운영하고 있는 서비스의 규모가 꽤 컸었고(화면이 약 80개, API 는 약 100개 정도) 이에 따라 Entity 도 약 150개가 넘어가는 상황이었어서 추가 기획이 들어와 기존의 기능을 변경해야 할 때 유지보수가 매우 힘들었었다. 따라서 새로운 기능 개발에 초점을 맞추기 보다는 <strong>기존의 서비스를 잘 운영하면서 유지보수에 용이한 아키텍처를 설계해보자</strong>는 생각으로, 위의 언급했던 단점을 보완할 수 있는 방안을 모색해보았고 두 번째 겪었던 시행착오에 대한 얘기는 다음 게시글에서 서술해보려고 한다.</p>
<hr>
<h2 id="reference">Reference</h2>
<ul>
<li><a href="https://medium.com/@DrunknCode/clean-architecture-simplified-and-in-depth-guide-026333c54454">https://medium.com/@DrunknCode/clean-architecture-simplified-and-in-depth-guide-026333c54454</a></li>
<li><a href="https://medium.com/@unaware_harry/a-deep-dive-into-clean-architecture-and-solid-principles-dcdcec5db48a">https://medium.com/@unaware_harry/a-deep-dive-into-clean-architecture-and-solid-principles-dcdcec5db48a</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Android] 다중 Firebase 프로젝트 연동 시 중복 SHA-1 로 인한 구글 로그인 문제]]></title>
            <link>https://velog.io/@sangjin-hash/%EB%8B%A4%EC%A4%91-Firebase-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%97%B0%EB%8F%99-%EC%8B%9C-%EC%A4%91%EB%B3%B5-SHA-1-%EB%A1%9C-%EC%9D%B8%ED%95%9C-%EA%B5%AC%EA%B8%80-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EB%AC%B8%EC%A0%9C</link>
            <guid>https://velog.io/@sangjin-hash/%EB%8B%A4%EC%A4%91-Firebase-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%97%B0%EB%8F%99-%EC%8B%9C-%EC%A4%91%EB%B3%B5-SHA-1-%EB%A1%9C-%EC%9D%B8%ED%95%9C-%EA%B5%AC%EA%B8%80-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EB%AC%B8%EC%A0%9C</guid>
            <pubDate>Tue, 31 Dec 2024 10:30:40 GMT</pubDate>
            <description><![CDATA[<h3 id="문제-정의">문제 정의</h3>
<p><code>flavor</code> 를 통해 환경(<code>dev</code>, <code>prod</code>)을 나누고, <code>prod</code>는 이미 android sdk 내 default debug key가 등록된 환경에서 <code>dev</code> 에 Firebase 프로젝트를 연동해야 할 때 다음과 같은 문제가 발생한다.</p>
<p>기존 프로젝트에서는 <code>android/app/build.gradle</code> 에는 debug 모드에 관련된 설정이 없었고, Android sdk 에 내장된 <code>.android/debug.keystore</code> 를 사용하였다. 이 때 default 로 등록된 <code>androiddebugkey</code> 라는 <code>alias</code>를 사용할 경우 구글 로그인, 카카오 로그인과 같은 소셜 로그인을 이용하면 SHA-1, SHA-256 혹은 키 해시를 각 플랫폼 별 콘솔에 입력해야 하는데, 이 때 하나의 키값 혹은 SHA-1 이 여러 프로젝트에 중복되어 사용되면 오류가 발생한다. </p>
<p>다음은 위에서 언급한 오류에 대한 내용이다. 해당 프로젝트를 <code>prod</code> 환경으로 디버그했을 경우 로그인은 성공적으로 잘 되지만, <code>dev</code> 환경으로 변경하여 디버그할 경우 <code>dev</code> 와 연결된 Firebase 프로젝트에 디버그 키에 대한 SHA-1, SHA-256이 등록되지 않아 다음과 같은 에러가 발생한 것이다.
<img src="https://velog.velcdn.com/images/sangjin-hash/post/309026eb-5b6a-456b-b254-03765b5ba586/image.JPG" alt=""></p>
<pre><code class="language-text">PlatformException(sign_in_failed, com.google.android.gms.common.api.ApiException: 10: , null, null)</code></pre>
<p>따라서 해당 문제를 해결하기 위해 <code>flavor</code> 의 환경에 맞춰 디버그, 릴리즈용 키를 생성하여 각 키를 연동된 Firebase 에 연동하고자 한다.</p>
<hr>
<h3 id="디버그-키-생성">디버그 키 생성</h3>
<ul>
<li>Android sdk 에 내장된 <code>.android/debug.keystore</code> 를 사용하지 않는 이유?
디버그 시 Default 로 적용되는 <code>.android/debug.keystore</code> 를 사용하지 않고 프로젝트 내에 새로 생성한 <code>debug.keystore</code> 를 사용하는 이유는 다음과 같다.</li>
</ul>
<blockquote>
<ol>
<li>개발자 마다 Android sdk 가 위치한 경로가 다르다.</li>
<li>조직 내 팀원이 많을 경우, 각 팀원마다 Debug 키에 대한 SHA-1 를 외부 플랫폼 콘솔에 추가해줘야 한다.</li>
</ol>
</blockquote>
<p>1번의 경우 추후 설명할 <code>android/app/build.gradle</code> 에서 <code>signingConfigs</code> 부분에 <code>storeFile</code> 을 가져올 때, 팀원들의 데스크톱에Android sdk 가 저장된 경로가 모두 일치해야 해당 파일에 접근할 수 있는 점과, 2번의 이유로 인해 공통 관리를 위해 프로젝트 내에 새로운 <code>debug.keystore</code> 를 생성해주었다.</p>
<ul>
<li>디렉토리 생성</li>
</ul>
<pre><code class="language-bash">mkdir -p android/keystore</code></pre>
<ul>
<li><code>debug.keystore</code> -&gt; <code>dev-key</code>, <code>prod-key</code> 생성
```bash
keytool -genkey -v -keystore android/keystore/debug.keystore -alias dev-key \</li>
<li>keyalg RSA -keysize 2048 -validity 10000 \</li>
<li>storepass [PASSWORD] -keypass [PASSWORD]</li>
</ul>
<p>keytool -genkey -v -keystore android/keystore/debug.keystore -alias prod-key <br>-keyalg RSA -keysize 2048 -validity 10000 <br>-storepass [PASSWORD] -keypass [PASSWORD]</p>
<pre><code>![](https://velog.velcdn.com/images/sangjin-hash/post/e8bc2925-dc5b-44bb-accf-1478c4fb57a6/image.png)


- alias 확인
```bash
keytool -list -keystore android/keystore/debug.keystore -storepass [PASSWORD]</code></pre><p><img src="https://velog.velcdn.com/images/sangjin-hash/post/29400f30-ee55-4829-964d-4482835d3376/image.png" alt=""></p>
<hr>
<h3 id="firebase-sha-1-sha-256-등록">Firebase SHA-1, SHA-256 등록</h3>
<ul>
<li>SHA-1, SHA-256 추출<pre><code class="language-bash">keytool -list -v -keystore android/keystore/debug.keystore -alias dev-key -storepass [PASSWORD]
keytool -list -v -keystore android/keystore/debug.keystore -alias prod-key -storepass [PASSWORD]</code></pre>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/sangjin-hash/post/ddc1b67b-9a73-4ef9-a852-943543ed4d8a/image.png" alt=""></p>
<ul>
<li>각 Firebase 프로젝트에 해당 SHA-1, SHA-256 등록<ul>
<li><code>dev</code> flavor -&gt; &quot;gachiga-dev&quot; Firebase 프로젝트 연동</li>
<li><code>prod</code> flavor -&gt; &quot;gachiga&quot; Firebase 프로젝트 연동
<img src="https://velog.velcdn.com/images/sangjin-hash/post/9d979b58-ead6-4031-adb3-4c63fc79301e/image.png" alt=""></li>
</ul>
</li>
</ul>
<ul>
<li>업데이트 된 <code>google-services.json</code> 으로 변경
<img src="https://velog.velcdn.com/images/sangjin-hash/post/2915ba8c-6f41-409f-84bd-17be80563651/image.png" alt=""></li>
</ul>
<hr>
<h3 id="properties-추가-및-buildgradle-setting">properties 추가 및 build.gradle Setting</h3>
<ul>
<li><code>android/debug-key.properties</code> 추가
<img src="https://velog.velcdn.com/images/sangjin-hash/post/fec73b6a-4229-467a-a6ba-890e09957eab/image.png" alt=""></li>
</ul>
<ul>
<li><code>android/app/build.gradle</code> 설정<pre><code class="language-Groovy">def debugKeystoreProperties = new Properties()
def debugKeystorePropertiesFile = rootProject.file(&#39;debug-key.properties&#39;)
if (debugKeystorePropertiesFile.exists()) {
  debugKeystoreProperties.load(new FileInputStream(debugKeystorePropertiesFile))
}
</code></pre>
</li>
</ul>
<p>android {
    ... 생략</p>
<pre><code>signingConfigs {
    devDebug {
        storeFile file(debugKeystoreProperties[&#39;storeFile&#39;])
        storePassword debugKeystoreProperties[&#39;storePassword&#39;]
        keyAlias debugKeystoreProperties[&#39;keyAlias&#39;]
        keyPassword debugKeystoreProperties[&#39;keyPassword&#39;]
    }

    prodDebug {
        storeFile file(debugKeystoreProperties[&#39;storeFile&#39;])
        storePassword debugKeystoreProperties[&#39;storePassword&#39;]
        keyAlias debugKeystoreProperties[&#39;prodKeyAlias&#39;]
        keyPassword debugKeystoreProperties[&#39;keyPassword&#39;]
    }

    ...
}

buildTypes {
    debug {
        minifyEnabled false
        shrinkResources false
        signingConfig null // Allows productFlavors to override
    }

    ...
}

flavorDimensions &quot;build-type&quot;

productFlavors {
    dev {
        dimension &quot;build-type&quot;
        signingConfig signingConfigs.devDebug
    }

    prod {
        dimension &quot;build-type&quot;
        signingConfig signingConfigs.prodDebug
    }
}</code></pre><p>}</p>
<pre><code>
### 디버그 모드에서 Result
- Build Configuration: `dev`
![](https://velog.velcdn.com/images/sangjin-hash/post/5778fc78-fe7d-44ad-9f43-2bc18bf805cd/image.png)

- Build Configuration: `prod`
![](https://velog.velcdn.com/images/sangjin-hash/post/5931eb42-95fd-46cc-9b21-ea8a5d2e501a/image.png)

위의 결과처럼 각 Configuration 에 따라 성공적으로 구글 로그인이 되는 것을 확인하였다.

---

### Release 모드의 한계
지금까지 각 환경에 따른 Debug 모드로 빌드했을 경우에 대해 다루었고, 이번엔 위의 내용과 동일한 순서로 Release 모드일 때도 적용하려고 했으나...

현재 &#39;가치가&#39; 앱은 이미 출시가 되어 서비스가 운영되고 있다. 해당 프로젝트 내 &#39;업로드 전용 키&#39; 를 이미 사용하고 있고, 해당 키 또한 Play Console 에 업로드를 한 상태이며 Google 에서 제공한 &#39;앱 서명 키&#39; 를 프로덕션용 Firebase 프로젝트에 이미 연동이 완료된 상태이다. 즉 해당 키들은 `release` 로 배포할 때 사용이 되며 이 때 사용된 키들의 SHA-1, SHA-256 디지털 지문들은 모두 프로덕션용 Firebase 프로젝트에 연동이 되어 있어 개발용 Firebase 프로젝트에 연동이 불가능하다. 

![](https://velog.velcdn.com/images/sangjin-hash/post/5fa544ef-72ae-4db5-8047-98794bf0d37f/image.png)
(출처: https://developer.android.com/studio/publish/app-signing?hl=ko)

&gt; 프로젝트 내 새로 생성했던 `debug.keystore` 와 똑같이 `release.keystore` 를 추가해주면 안되는지?
&gt; -&gt; 결론을 먼저 말하자면 불가능하다. 왜냐하면 내부 테스트용 or 프로덕션용으로 `.aab` 를 Play Console 에 업로드 할 때 &#39;업로드 전용 키&#39;가 사용되기 때문이다.

---

### 결론
Flavor 환경(dev, prod) 둘 다 내부 테스트용으로 `.aab` 를 업로드해서 테스트를 하는 경우가 있는데, 이 때 업로드 전용 키는 하나만 사용이 가능하며 이 안에 있는 alias 도 단일 alias만 허용을 한다. 결과적으로는 release 로 빌드할 경우는 release 전용 키 하나만 사용해야 한다. 이 말은 결국 하나의 키만 사용이 가능하다 보니, 개발용 Firebase 프로젝트에는 해당 키의 SHA-1, SHA-256 디지털 지문 등록이 되지 않아 구글 로그인 시 `PlatformException` 이 발생하여 로그인이 불가능한 한계가 있다.
![](https://velog.velcdn.com/images/sangjin-hash/post/be4b54af-ffbb-4ebf-8179-1104455eff3e/image.png)


### Reference
https://support.google.com/firebase/answer/6401008?hl=ko</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[[Flutter] Flavor - 개발, 운영 환경에 따른 설정 교체 자동화]]></title>
            <link>https://velog.io/@sangjin-hash/Flavor-%EA%B0%9C%EB%B0%9C-%EC%9A%B4%EC%98%81-%ED%99%98%EA%B2%BD-%EA%B5%90%EC%B2%B4-%EC%9E%90%EB%8F%99%ED%99%94</link>
            <guid>https://velog.io/@sangjin-hash/Flavor-%EA%B0%9C%EB%B0%9C-%EC%9A%B4%EC%98%81-%ED%99%98%EA%B2%BD-%EA%B5%90%EC%B2%B4-%EC%9E%90%EB%8F%99%ED%99%94</guid>
            <pubDate>Sat, 28 Dec 2024 14:31:30 GMT</pubDate>
            <description><![CDATA[<h3 id="도입-배경">도입 배경</h3>
<p>  기존의 개발, 운영 환경은 다음과 같았다. 백엔드 단에서는 개발, 운영 환경에 맞게 AWS-Firebase 1:1 대응하여 운영되고 있었으나, 프론트 단에서는 Firebase 는 하나만 연동하여 사용을 하고 있었다. 이러한 이유는 한 개의 프로젝트는 한 개의 Firebase 프로젝트와 연동이 되어야 하고, 만일 Gachiga-Dev ↔ Gachiga 변경할 때 수동으로 <code>GoogleService-Info.plist</code> , <code>google-services.json</code> 을 개발/운영 환경에 따라 변경해줘야 하므로 매우 번거로웠다.</p>
<p><img src="https://velog.velcdn.com/images/sangjin-hash/post/e1fc4bf5-708a-47e8-97dc-d3b9fecd8201/image.png" alt=""></p>
<p>위의 환경의 문제점에 대한 예시를 들면 다음과 같다. <code>test@gmail.com</code> 이라는 이메일을 통해 소셜 로그인한 회원이 있고, 해당 회원은  운영 서버에서 DB의 PK 값인 회원 ID 가 3번, 개발 서버에서 1번이라고 가정해보자. 이 때, Firebase 에서 어떤 정보를 가져올 때 해당 회원 ID 를 기준으로 값을 가져오기 때문에 Firebase 는 하나만 연결되어 있으므로 충돌이 일어나는 문제가 발생한다. 따라서 개발용 Firebase 연동이 필요한 상황이고, Build Configuration(Dev, Prod) 에 따라 Firebase, API Endpoint 의 Base URL 등을 자동으로 변경해주는 시스템인 <code>Flavor</code> 을 도입하고자 한다.
<img src="https://velog.velcdn.com/images/sangjin-hash/post/8a04623d-8312-4d21-a913-e476b6d8c3a0/image.png" alt=""></p>
<hr>
<h3 id="multi-app-vs-single-app">Multi App VS Single App</h3>
<p>  Build Configuration 에 따라서 다음 사진처럼 <code>ApplicationIdSuffix</code> 를 변경하면 두 앱의 패키지 주소가 다르기 때문에 Multi App 으로 구성할 수 있다. 그러나 필자는 단일 앱으로 구성하였는데, 다중 앱과 trade-off 는 다음과 같다.</p>
<p><img src="https://velog.velcdn.com/images/sangjin-hash/post/4ed62068-bc7a-4d61-af9b-81e8d8655058/image.png" alt=""></p>
<ul>
<li><p>Multi App</p>
<ul>
<li>Pros<ul>
<li>독립적인 설치 가능: 개발/운영 환경의 앱을 동시에 설치하여 테스트할 수 있음.</li>
<li>환경 간 간섭 최소화: 개발 환경에서 운영 환경의 데이터에 접근하거나 잘못된 설정이 혼합될 가능성이 적음.</li>
</ul>
</li>
<li>Cons<ul>
<li>관리 복잡도 증가: 여러 패키지를 관리해야 하며, 각 환경별로 Firebase, 소셜 로그인 등의 추가 설정 필요.</li>
</ul>
</li>
</ul>
</li>
<li><p>Single App</p>
<ul>
<li>Pros<ul>
<li>유지보수 효율성: Firebase, 소셜 로그인, 푸시 알림 등 외부 서비스 설정을 단일 패키지 기준으로 관리 가능.</li>
</ul>
</li>
<li>Cons<ul>
<li>데이터 분리 어려움: 로컬 저장소(SharedPreferences, SQLite 등) 데이터가 동일한 패키지 기준으로 저장되어 개발/운영 데이터 충돌 가능성.</li>
<li>동시 설치 불가: 개발/운영 환경을 동시에 설치하여 테스트할 수 없음.</li>
</ul>
</li>
</ul>
</li>
</ul>
<p>  다중 앱으로 구성하면 설치된 앱이 모두 독립적으로 운영되기 때문에 개발/운영 환경 별 로컬 저장소의 데이터 분리의 용이함이 있으나 해당 앱이 사용하고 있는 서드 파티 API(소셜 로그인, 푸시 알림, 결제 API 등)의 설정을 추가적으로 구성해야 하는 단점이 매우 치명적이었다. 또한 단일 앱으로 구성했을 때는 해당 앱에는 내부 저장소에 JWT 토큰과 알림 Flag 와 같은 여러 시스템에 관련된 데이터들을 저장하고 있기 때문에 환경이 변경될 때 앱을 재설치 해야 하는 번거로움이 있다. 다중 앱과 단일 앱의 각 단점을 비교했을 때 단일 앱의 단점에 대한 공수가 다중 앱의 공수보다 더 적다고 판단해서 번거롭더라도 환경에 따라 앱 재설치 및 재로그인을 하는 방향으로 설정했고, 이를 개선하기 위해 코드 상에서 해결하고자 하였다.</p>
<hr>
<h3 id="android-setting">Android Setting</h3>
<ul>
<li>android/app/build.gradle</li>
</ul>
<pre><code class="language-groovy">android {

...

flavorDimensions &quot;build-type&quot;

    productFlavors {
        dev {
            dimension &quot;build-type&quot;
        }

        prod {
            dimension &quot;build-type&quot;
        }
    }
}</code></pre>
<ul>
<li><p><code>flavorDimensions</code> : Product Flavor를 분류하기 위한 기준(카테고리)을 정의</p>
</li>
<li><p><code>dimension</code> <strong>:</strong> 해당 Flavor가 어떤 Dimension(카테고리)에 속하는지 정의</p>
</li>
<li><p>추가로 설정할 수 있는 옵션들</p>
<ul>
<li><code>applicationIdSuffix</code> : 동일한 코드베이스에서 여러 Flavor를 빌드하여 설치할 때 각 앱의 패키지를 구분</li>
<li><code>resValue</code> : 리소스 값을 동적으로 정의</li>
</ul>
</li>
</ul>
<p><code>applicationIdSuffix</code> 와 <code>resValue</code> 등 이밖에도 여러가지가 있지만 이들을 사용하지 않은 이유는 위에서 언급한대로, 이들은 다중앱을 구성했을 때 패키지 주소 변경, 각 앱의 이름 설정 등 다중앱을 구성하기 위한 옵션들이기 때문이다. </p>
<p>Firebase 프로젝트 연동을 위해서는 프로젝트 별 <code>google-service.json</code> 이 필요한데, <code>FlutterFire CLI</code> 를 통해 연동을 한 뒤, 환경(운영/개발)에 따라 디렉토리를 구분하여 다음과 같은 위치에 저장해야 한다.</p>
<ul>
<li>android/app/src/[Build Configuration]/google-services.json
<img src="https://velog.velcdn.com/images/sangjin-hash/post/b600b680-b04b-40a5-bbe6-3cadbfc64feb/image.png" alt=""></li>
</ul>
<hr>
<h3 id="ios-setting">iOS Setting</h3>
<ul>
<li>Project → Runner → Info → Configurations → +<ul>
<li>Debug-prod, Debug-dev : Duplicate “Debug” Configuration</li>
<li>Release-prod, Release-dev: Duplicate “Release” Configuration</li>
<li>Profile-prod, Profile-dev : Duplicate “Profile” Configuration</li>
</ul>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/sangjin-hash/post/c2506fdb-bae7-44d1-87f1-61aed4487c20/image.png" alt=""></p>
<ul>
<li>Edit Scheme<ul>
<li>기존의 ‘Runner’ Scheme 을 복제하여 이름을 변경해준 뒤 각 환경에 따라 Build Configuration 을 다르게 설정해준다.
<img src="https://velog.velcdn.com/images/sangjin-hash/post/5a4593a5-d3e1-4a82-b540-01a140c333a7/image.png" alt=""></li>
</ul>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/sangjin-hash/post/b12e7c81-0dda-4771-9e16-20d98ff44c83/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/sangjin-hash/post/9028dd39-75ec-4bb7-91f3-38fa874b494c/image.png" alt=""></p>
<ul>
<li>GoogleService-Info 추가<ul>
<li>ios/Runner/Firebase/[Configuration]/GoogleService-Info</li>
<li>Add Files to “Runner” 에 Firebase 디렉토리를 추가해준다.</li>
</ul>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/sangjin-hash/post/9f95c935-3fa4-4b2b-8ac6-b4983ebd378e/image.png" alt=""></p>
<ul>
<li><p>GoogleService-Info.plist 를 런타임때 추가해주는 스크립트 추가</p>
<ul>
<li><p>TARGETS → Runner → Build Phases → <code>Copy GoogleService-Info.plist</code> 추가</p>
</li>
<li><p>해당 Phase 는 <code>Compile Sources</code> 위에 위치시키기.</p>
<pre><code class="language-groovy">#!/bin/sh
if [[ &quot;${CONFIGURATION}&quot; == &quot;Debug-dev&quot; || &quot;${CONFIGURATION}&quot; == &quot;Release-dev&quot; ]]; then
cp &quot;${PROJECT_DIR}/Runner/Firebase/dev/GoogleService-Info.plist&quot; &quot;${PROJECT_DIR}/Runner/GoogleService-Info.plist&quot;
elif [[ &quot;${CONFIGURATION}&quot; == &quot;Debug-prod&quot; || &quot;${CONFIGURATION}&quot; == &quot;Release-prod&quot; ]]; then
cp &quot;${PROJECT_DIR}/Runner/Firebase/prod/GoogleService-Info.plist&quot; &quot;${PROJECT_DIR}/Runner/GoogleService-Info.plist&quot;
fi
</code></pre>
</li>
</ul>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/sangjin-hash/post/14e7e3e2-89f6-4b08-8098-5bfa6f42b7f9/image.png" alt=""></p>
<ul>
<li>빌드 후 Copy 된 GoogleService-Info.plist 를 추가해주기</li>
</ul>
<p><img src="https://velog.velcdn.com/images/sangjin-hash/post/d5462b89-359b-400a-8bb6-aa93a7bea5b4/image.png" alt=""></p>
<hr>
<h3 id="환경에-따른-구성설정을-처리하는-dart-파일-생성">환경에 따른 구성(설정)을 처리하는 Dart 파일 생성</h3>
<ul>
<li>environment.dart<ul>
<li>해당 Environment 파일을 Singleton 으로 만들고, Configuration (dev or prod) 에 따라 관리가 달라져야 하는 base URL 의 경우 getter 로 구현해주었다.</li>
<li>baseURL 뿐만 아니라 Configuration 에 따른 내부 저장소 관련 PK 값들 혹은 저장소 경로 등을 <code>baseUrl</code> 처럼 getter 로 구현해서 사용하면 환경 별로 관리할 수 있다.</li>
</ul>
</li>
</ul>
<pre><code class="language-dart">enum BuildType { dev, prod }

class Environment {
  static Environment? _instance;

  static Environment get instance {
    assert(_instance != null,
        &#39;Environment instance has not been initialized yet.&#39;);
    return _instance!;
  }

  final BuildType _buildType;

  static BuildType get buildType =&gt; instance._buildType;

  static String get baseUrl {
    switch (buildType) {
      case BuildType.dev:
        return &quot;$DEV_BASE_URL&quot;;
      case BuildType.prod:
        return &quot;$PROD_BASE_URL&quot;;
      default:
        return &quot;$PROD_BASE_URL&quot;;
    }
  }

  Environment._internal(this._buildType);

  factory Environment.newInstance(BuildType buildType) {
    _instance ??= Environment._internal(buildType);
    return _instance!;
  }

  static void initialize(String flavor) {
    BuildType buildType;
    switch (flavor) {
      case &#39;dev&#39;:
        buildType = BuildType.dev;
        break;
      case &#39;prod&#39;:
        buildType = BuildType.prod;
        break;
      default:
        buildType = BuildType.prod;
    }
    Environment.newInstance(buildType);
  }
}
</code></pre>
<ul>
<li>main.dart</li>
</ul>
<pre><code class="language-dart">void main() async {
  WidgetsBinding widgetsBinding = WidgetsFlutterBinding.ensureInitialized();
  FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);

  // Initializes environment settings based on the value of the &quot;FLAVOR&quot; environment variable.
  // The FLAVOR value is set during compilation, such as `--dart-define=FLAVOR=debug`.
  String flavor = const String.fromEnvironment(&#39;FLAVOR&#39;);
  Environment.initialize(flavor);</code></pre>
<hr>
<h3 id="firebase-option-설정">Firebase option 설정</h3>
<ul>
<li>firebase_options_dev.dart</li>
</ul>
<pre><code class="language-dart">// File generated by FlutterFire CLI.
// ignore_for_file: lines_longer_than_80_chars, avoid_classes_with_only_static_members
import &#39;package:firebase_core/firebase_core.dart&#39; show FirebaseOptions;

const FirebaseOptions android = FirebaseOptions(
  apiKey: &#39;$apiKey&#39;,
  appId: &#39;$appId&#39;,
  messagingSenderId: &#39;$messagingSenderId&#39;,
  projectId: &#39;gachiga-dev&#39;,
  storageBucket: &#39;$storageBucket&#39;,
);

const FirebaseOptions ios = FirebaseOptions(
  apiKey: &#39;$apiKey&#39;,
  appId: &#39;$appId&#39;,
  messagingSenderId: &#39;$messagingSenderId&#39;,
  projectId: &#39;gachiga-dev&#39;,
  storageBucket: &#39;$storageBucket&#39;,
  iosClientId: &#39;$iosClientId&#39;,
  iosBundleId: &#39;$iosBundleId&#39;,
);
</code></pre>
<ul>
<li>firebase_options_prod.dart</li>
</ul>
<pre><code class="language-dart">// File generated by FlutterFire CLI.
// ignore_for_file: lines_longer_than_80_chars, avoid_classes_with_only_static_members
import &#39;package:firebase_core/firebase_core.dart&#39; show FirebaseOptions;

const FirebaseOptions android = FirebaseOptions(
  apiKey: &#39;$apiKey&#39;,
  appId: &#39;$appId&#39;,
  messagingSenderId: &#39;$messagingSenderId&#39;,
  projectId: &#39;gachiga&#39;,
  storageBucket: &#39;$storageBucket&#39;,
);

const FirebaseOptions ios = FirebaseOptions(
  apiKey: &#39;$apiKey&#39;,
  appId: &#39;$appId&#39;,
  messagingSenderId: &#39;$messagingSenderId&#39;,
  projectId: &#39;gachiga&#39;,
  storageBucket: &#39;$storageBucket&#39;,
  iosClientId: &#39;$iosClientId&#39;,
  iosBundleId: &#39;$iosBundleId&#39;,
);
</code></pre>
<ul>
<li>firebase_options.dart</li>
</ul>
<pre><code class="language-dart">import &#39;package:firebase_core/firebase_core.dart&#39; show FirebaseOptions;
import &#39;package:flutter/foundation.dart&#39;
    show defaultTargetPlatform, TargetPlatform;

import &#39;./firebase_options_dev.dart&#39; as dev;
import &#39;./firebase_options_prod.dart&#39; as prod;

/// Default [FirebaseOptions] for use with your Firebase apps.
///
/// Example:
/// ```dart
/// import &#39;firebase_options_prod.dart&#39;;
/// // ...
/// await Firebase.initializeApp(
///   options: DefaultFirebaseOptions.currentPlatform,
/// );
/// ```
class DefaultFirebaseOptions {
  static FirebaseOptions get currentPlatform {
    const flavor = String.fromEnvironment(&#39;FLAVOR&#39;);

    switch (defaultTargetPlatform) {
      case TargetPlatform.android:
        return flavor == &#39;dev&#39; ? dev.android : prod.android;
      case TargetPlatform.iOS:
        return flavor == &#39;dev&#39; ? dev.ios : prod.ios;
      case TargetPlatform.macOS:
        throw UnsupportedError(
          &#39;DefaultFirebaseOptions have not been configured for macos - &#39;
          &#39;you can reconfigure this by running the FlutterFire CLI again.&#39;,
        );
      case TargetPlatform.windows:
        throw UnsupportedError(
          &#39;DefaultFirebaseOptions have not been configured for windows - &#39;
          &#39;you can reconfigure this by running the FlutterFire CLI again.&#39;,
        );
      case TargetPlatform.linux:
        throw UnsupportedError(
          &#39;DefaultFirebaseOptions have not been configured for linux - &#39;
          &#39;you can reconfigure this by running the FlutterFire CLI again.&#39;,
        );
      default:
        throw UnsupportedError(
          &#39;DefaultFirebaseOptions are not supported for this platform.&#39;,
        );
    }
  }
}
</code></pre>
<ul>
<li>main.dart</li>
</ul>
<pre><code class="language-dart">void main() async {
...
await Firebase.initializeApp(
    name: Environment.buildType == BuildType.dev ? &quot;gachiga-dev&quot; : &quot;gachiga&quot;,
    options: DefaultFirebaseOptions.currentPlatform,
  );
}</code></pre>
<hr>
<h3 id="android-studio-에서-edit-configurations">Android Studio 에서 Edit Configurations</h3>
<p><img src="https://velog.velcdn.com/images/sangjin-hash/post/79f13bf2-8566-49b0-b2fb-9877b8bc43ef/image.png" alt=""></p>
<ul>
<li>Additional run args : <code>-t lib/main.dart --dart-define=FLAVOR=&#39;$환경&#39;</code> 추가</li>
<li>Build flavor : 환경 추가
<img src="https://velog.velcdn.com/images/sangjin-hash/post/b7904c00-b38e-4c87-b4c8-f6481c1af33e/image.png" alt="">
<img src="https://velog.velcdn.com/images/sangjin-hash/post/1c56cba2-bcf9-4ca1-a77b-8af4a3526439/image.png" alt=""></li>
</ul>
<hr>
<h3 id="references">References</h3>
<ul>
<li><a href="https://docs.flutter.dev/deployment/flavors">https://docs.flutter.dev/deployment/flavors</a></li>
<li><a href="https://dokit.tistory.com/61">https://dokit.tistory.com/61</a></li>
<li><a href="https://ahmedyusuf.medium.com/setup-flavors-in-ios-flutter-with-different-firebase-config-43c4c4823e6b">https://ahmedyusuf.medium.com/setup-flavors-in-ios-flutter-with-different-firebase-config-43c4c4823e6b</a></li>
</ul>
]]></description>
        </item>
    </channel>
</rss>