<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>꿈돌이랜드</title>
    <link>https://glassgow.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Sun, 23 Aug 2026 06:29:56 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>loinsir</managingEditor>
    <image>
      <title>꿈돌이랜드</title>
      <url>https://tistory1.daumcdn.net/tistory/5597052/attach/d72b67baa3a84ac69b1a09465139009b</url>
      <link>https://glassgow.tistory.com</link>
    </image>
    <item>
      <title>테스터블한 코드를 작성하는 법</title>
      <link>https://glassgow.tistory.com/57</link>
      <description>&lt;p&gt;최근 테스트 코드에 대해 공부하고 있는데, 결국 테스트 코드를 작성하기 위해선, 테스트 하려는 코드 자체가 테스트가능한 구조여야 가능하다. 그러면 어떻게 해야 처음부터 테스트 가능한 구조로 코드를 작성하는 습관을 들일 수 있을까? 에 초점을 맞춰 이 글을 작성했다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;1. 의존성을 직접 생성하지 말고, 외부에서 주입받아라&lt;/h2&gt;
&lt;p&gt;테스트 불가능한 코드의 가장 흔한 원인이다. 객체 내부에서 의존성을 직접 생성하면, 테스트 시 그 의존성을 교체할 수 없다.&lt;/p&gt;
&lt;h3&gt;AS-IS&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;class ProfileViewModel {
    private let repository = UserRepository()
    private let analytics = AnalyticsManager.shared

    func loadProfile(userId: String) async -&amp;gt; UserProfile? {
        analytics.track(&amp;quot;profile_viewed&amp;quot;)
        return await repository.fetchUser(id: userId)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;UserRepository()&lt;/code&gt;를 직접 생성하고, &lt;code&gt;AnalyticsManager.shared&lt;/code&gt;라는 싱글턴에 직접 접근한다. 테스트에서 네트워크 호출을 막을 수도, 분석 이벤트를 끌 수도 없다.&lt;/p&gt;
&lt;h3&gt;TO-BE&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;class ProfileViewModel {
    private let repository: UserRepositoryProtocol
    private let analytics: AnalyticsProtocol

    init(
        repository: UserRepositoryProtocol,
        analytics: AnalyticsProtocol
    ) {
        self.repository = repository
        self.analytics = analytics
    }

    func loadProfile(userId: String) async -&amp;gt; UserProfile? {
        analytics.track(&amp;quot;profile_viewed&amp;quot;)
        return await repository.fetchUser(id: userId)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;생성자에서 프로토콜 타입으로 주입받는다. 프로덕션에서는 실제 구현체를, 테스트에서는 스텁을 넣으면 된다. &lt;strong&gt;코드를 한 줄도 바꾸지 않고 의존성을 교체할 수 있다.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;2. 순수한 비즈니스 로직을 사이드 이펙트로부터 분리하라&lt;/h2&gt;
&lt;p&gt;비즈니스 로직 안에서 네트워크 호출, DB 저장, 알림 발송 등이 섞여 있으면, 로직만 따로 테스트할 방법이 없다. &lt;strong&gt;결정을 내리는 코드&lt;/strong&gt;와 &lt;strong&gt;결정을 실행하는 코드&lt;/strong&gt;를 물리적으로 나눠라.&lt;/p&gt;
&lt;h3&gt;AS-IS&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;class CouponService {
    private let repository: CouponRepository
    private let pushManager: PushNotificationManager

    func applyCoupon(code: String, to order: Order) async throws -&amp;gt; Order {
        let coupon = try await repository.findByCode(code)

        guard coupon.expiresAt &amp;gt; Date() else {
            throw CouponError.expired
        }

        guard order.totalPrice &amp;gt;= coupon.minimumAmount else {
            throw CouponError.minimumNotMet
        }

        let discounted: Int
        switch coupon.type {
        case .fixedAmount(let amount):
            discounted = max(order.totalPrice - amount, 0)
        case .percentage(let rate):
            discounted = Int(Double(order.totalPrice) * (1.0 - rate))
        }

        var updated = order
        updated.totalPrice = discounted
        updated.appliedCoupon = coupon

        try await repository.markAsUsed(coupon)
        await pushManager.send(&amp;quot;쿠폰이 적용되었습니다!&amp;quot;)

        return updated
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;할인 계산 로직만 테스트하고 싶어도 &lt;code&gt;repository&lt;/code&gt;와 &lt;code&gt;pushManager&lt;/code&gt;를 반드시 목으로 만들어야 한다. 비즈니스 규칙이 인프라에 갇혀 있다.&lt;/p&gt;
&lt;h3&gt;TO-BE&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;// 순수한 비즈니스 로직 — 사이드 이펙트가 전혀 없다
struct CouponCalculator {
    static func apply(
        coupon: Coupon,
        to order: Order,
        currentDate: Date
    ) -&amp;gt; Result&amp;lt;Order, CouponError&amp;gt; {
        guard coupon.expiresAt &amp;gt; currentDate else {
            return .failure(.expired)
        }

        guard order.totalPrice &amp;gt;= coupon.minimumAmount else {
            return .failure(.minimumNotMet)
        }

        let discounted: Int
        switch coupon.type {
        case .fixedAmount(let amount):
            discounted = max(order.totalPrice - amount, 0)
        case .percentage(let rate):
            discounted = Int(Double(order.totalPrice) * (1.0 - rate))
        }

        var updated = order
        updated.totalPrice = discounted
        updated.appliedCoupon = coupon
        return .success(updated)
    }
}

// 사이드 이펙트를 실행하는 셸
class CouponService {
    private let repository: CouponRepository
    private let pushManager: PushNotificationManager

    func applyCoupon(code: String, to order: Order) async throws -&amp;gt; Order {
        let coupon = try await repository.findByCode(code)

        let result = CouponCalculator.apply(
            coupon: coupon,
            to: order,
            currentDate: Date()
        )

        switch result {
        case .success(let updated):
            try await repository.markAsUsed(coupon)
            await pushManager.send(&amp;quot;쿠폰이 적용되었습니다!&amp;quot;)
            return updated
        case .failure(let error):
            throw error
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;CouponCalculator&lt;/code&gt;는 순수한 값 계산이다. 네트워크도, DB도, 시간 의존성도 없다. &lt;strong&gt;입력을 넣으면 출력이 나온다.&lt;/strong&gt; 비즈니스 규칙이 복잡해질수록 이 분리의 가치는 커진다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;3. 숨은 입력을 제거하라 — 전역 상태와 싱글턴&lt;/h2&gt;
&lt;p&gt;메서드 시그니처에 드러나지 않는 입력이 있으면 테스트가 어렵다. 대표적인 것이 싱글턴 접근, &lt;code&gt;Date()&lt;/code&gt; 직접 생성, &lt;code&gt;UserDefaults&lt;/code&gt; 직접 읽기 등이다.&lt;/p&gt;
&lt;h3&gt;AS-IS&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;class FeedViewModel {
    func loadFeed() async -&amp;gt; [Post] {
        // 숨은 입력 1: 싱글턴
        let userId = AuthManager.shared.currentUserId

        // 숨은 입력 2: 직접 생성한 시간
        let cutoff = Calendar.current.date(
            byAdding: .day, value: -7, to: Date()
        )!

        // 숨은 입력 3: UserDefaults 직접 접근
        let showAds = UserDefaults.standard.bool(forKey: &amp;quot;showAds&amp;quot;)

        let posts = await APIClient.shared.fetchPosts(
            userId: userId, since: cutoff
        )

        if showAds {
            return insertAds(into: posts)
        }
        return posts
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 메서드는 파라미터가 없지만, 실제로는 3개의 숨은 입력에 의존한다. 테스트에서 이들을 제어하려면 싱글턴 상태를 조작해야 하고, 이는 테스트 간 공유 상태를 만들어 테스트끼리 영향을 주게 된다.&lt;/p&gt;
&lt;h3&gt;TO-BE&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;class FeedViewModel {
    private let apiClient: APIClientProtocol
    private let preferences: PreferencesProtocol

    init(
        apiClient: APIClientProtocol,
        preferences: PreferencesProtocol
    ) {
        self.apiClient = apiClient
        self.preferences = preferences
    }

    func loadFeed(
        userId: String,
        currentDate: Date = Date()
    ) async -&amp;gt; [Post] {
        let cutoff = Calendar.current.date(
            byAdding: .day, value: -7, to: currentDate
        )!

        let posts = await apiClient.fetchPosts(
            userId: userId, since: cutoff
        )

        if preferences.showAds {
            return insertAds(into: posts)
        }
        return posts
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;모든 입력이 명시적이다. &lt;code&gt;userId&lt;/code&gt;는 파라미터로, &lt;code&gt;currentDate&lt;/code&gt;는 기본값이 있는 파라미터로, &lt;code&gt;apiClient&lt;/code&gt;와 &lt;code&gt;preferences&lt;/code&gt;는 생성자로 주입받는다. &lt;strong&gt;시그니처만 보면 이 메서드가 무엇에 의존하는지 바로 알 수 있다.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;4. 하나의 공개 메서드로 하나의 유스케이스를 완결하라&lt;/h2&gt;
&lt;p&gt;단일한 목표를 달성하기 위해 여러 메서드를 순서대로 호출해야 한다면, 캡슐화에 문제가 있다. 호출 순서를 잘못 지키면 시스템이 모순된 상태에 빠진다(불변 위반).&lt;/p&gt;
&lt;h3&gt;AS-IS&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;class OrderProcessor {
    private(set) var order: Order?
    private(set) var isValidated: Bool = false

    func createOrder(items: [Item]) {
        order = Order(items: items)
    }

    func validateStock() throws {
        guard let order else { fatalError() }
        for item in order.items {
            guard item.stock &amp;gt; 0 else {
                throw OrderError.outOfStock(item.name)
            }
        }
        isValidated = true
    }

    func calculateTotal() -&amp;gt; Int {
        guard let order, isValidated else { fatalError() }
        return order.items.reduce(0) { $0 + $1.price }
    }

    func confirm() throws -&amp;gt; Order {
        guard var order, isValidated else { fatalError() }
        order.status = .confirmed
        order.totalPrice = calculateTotal()
        self.order = order
        return order
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;사용하는 쪽에서 &lt;code&gt;createOrder&lt;/code&gt; → &lt;code&gt;validateStock&lt;/code&gt; → &lt;code&gt;calculateTotal&lt;/code&gt; → &lt;code&gt;confirm&lt;/code&gt; 순서를 정확히 지켜야 한다. 순서가 틀리면 런타임 크래시가 발생하고, 테스트도 이 순서를 매번 재현해야 한다.&lt;/p&gt;
&lt;h3&gt;TO-BE&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;struct OrderProcessor {
    static func process(items: [Item]) -&amp;gt; Result&amp;lt;Order, OrderError&amp;gt; {
        // 재고 검증
        for item in items {
            guard item.stock &amp;gt; 0 else {
                return .failure(.outOfStock(item.name))
            }
        }

        // 총액 계산 + 주문 확정을 하나의 연산으로
        let total = items.reduce(0) { $0 + $1.price }
        let order = Order(
            items: items,
            totalPrice: total,
            status: .confirmed
        )

        return .success(order)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;하나의 메서드 호출로 유스케이스가 완결&lt;/strong&gt;된다. 호출 순서를 신경 쓸 필요가 없고, 중간에 모순된 상태에 빠질 수 없다. 불변 위반이 구조적으로 불가능하다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;5. 프레임워크 의존성을 비즈니스 로직에서 밀어내라&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;UIKit&lt;/code&gt;, &lt;code&gt;CoreLocation&lt;/code&gt;, &lt;code&gt;UserNotifications&lt;/code&gt; 같은 프레임워크 타입이 비즈니스 로직에 침투하면, 테스트를 위해 프레임워크 환경을 세팅해야 한다.&lt;/p&gt;
&lt;h3&gt;AS-IS&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;class NearbyStoreViewModel {
    private let locationManager = CLLocationManager()

    func findStores() -&amp;gt; [Store] {
        // CLLocation에 직접 의존
        guard let location = locationManager.location else {
            return []
        }

        return allStores.filter { store in
            let storeLocation = CLLocation(
                latitude: store.latitude,
                longitude: store.longitude
            )
            // CLLocation의 메서드에 직접 의존
            return location.distance(from: storeLocation) &amp;lt;= 1000
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;CLLocationManager&lt;/code&gt;는 시뮬레이터에서 위치를 주지 않을 수도 있고, 권한 설정이 필요하다. 비즈니스 로직(&amp;quot;1km 이내의 매장 필터링&amp;quot;)을 테스트하고 싶을 뿐인데 프레임워크가 발목을 잡는다.&lt;/p&gt;
&lt;h3&gt;TO-BE&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;// 비즈니스 로직에 필요한 최소한의 값 타입 정의
struct Coordinate {
    let latitude: Double
    let longitude: Double

    func distance(to other: Coordinate) -&amp;gt; Double {
        // 하버사인 공식 등으로 거리 계산
        let earthRadius = 6371000.0
        let dLat = (other.latitude - latitude) * .pi / 180
        let dLon = (other.longitude - longitude) * .pi / 180
        let a = sin(dLat/2) * sin(dLat/2) +
                cos(latitude * .pi / 180) * cos(other.latitude * .pi / 180) *
                sin(dLon/2) * sin(dLon/2)
        return earthRadius * 2 * atan2(sqrt(a), sqrt(1-a))
    }
}

// 순수한 비즈니스 로직 — 프레임워크 의존 없음
struct NearbyStoreFilter {
    static func filter(
        stores: [Store],
        from origin: Coordinate,
        maxDistance: Double = 1000
    ) -&amp;gt; [Store] {
        stores.filter { store in
            let storeCoord = Coordinate(
                latitude: store.latitude,
                longitude: store.longitude
            )
            return origin.distance(to: storeCoord) &amp;lt;= maxDistance
        }
    }
}

// 프레임워크 의존은 가장 바깥 계층에서만
class NearbyStoreViewModel {
    private let locationProvider: LocationProviding
    private let storeRepository: StoreRepositoryProtocol

    func findStores() async -&amp;gt; [Store] {
        guard let location = await locationProvider.currentLocation() else {
            return []
        }

        let origin = Coordinate(
            latitude: location.latitude,
            longitude: location.longitude
        )
        let allStores = await storeRepository.fetchAll()

        return NearbyStoreFilter.filter(stores: allStores, from: origin)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;NearbyStoreFilter&lt;/code&gt;는 &lt;code&gt;CoreLocation&lt;/code&gt;을 전혀 모른다. 좌표 두 개와 거리 기준만 있으면 동작한다. &lt;strong&gt;프레임워크는 가장 바깥 껍데기에서만 다루고, 안쪽은 순수한 값과 로직으로 채워라.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;6. 구체 타입 대신 프로토콜로 경계를 만들어라&lt;/h2&gt;
&lt;p&gt;교체 가능한 경계(seam)가 없으면 의존성을 바꿀 수 없다.&lt;/p&gt;
&lt;h3&gt;AS-IS&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;class PaymentViewModel {
    // 구체 타입에 직접 의존
    private let paymentGateway: PortOnePaymentGateway

    init() {
        self.paymentGateway = PortOnePaymentGateway(apiKey: &amp;quot;live-key&amp;quot;)
    }

    func processPayment(amount: Int) async -&amp;gt; Bool {
        let result = await paymentGateway.charge(amount: amount)
        return result.isSuccess
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;테스트할 때마다 실제 결제가 일어나거나, &lt;code&gt;PortOnePaymentGateway&lt;/code&gt;의 구현 변경에 직접적으로 영향받는다.&lt;/p&gt;
&lt;h3&gt;TO-BE&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;protocol PaymentGateway {
    func charge(amount: Int) async -&amp;gt; PaymentResult
}

// 프로덕션 구현
struct PortOneGateway: PaymentGateway {
    private let apiKey: String

    init(apiKey: String) {
        self.apiKey = apiKey
    }

    func charge(amount: Int) async -&amp;gt; PaymentResult {
        // 실제 PG 연동
    }
}

class PaymentViewModel {
    private let gateway: PaymentGateway

    init(gateway: PaymentGateway) {
        self.gateway = gateway
    }

    func processPayment(amount: Int) async -&amp;gt; Bool {
        let result = await gateway.charge(amount: amount)
        return result.isSuccess
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;PaymentGateway&lt;/code&gt; 프로토콜이 경계 역할을 한다. PG사가 바뀌어도, 테스트 환경이 달라도, &lt;code&gt;PaymentViewModel&lt;/code&gt;은 수정할 필요가 없다. &lt;strong&gt;변경될 수 있는 외부 의존성 앞에는 항상 프로토콜 경계를 두어라.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;마무리: 테스터블한 프로덕션 코드를 위한 체크리스트&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;원칙&lt;/th&gt;
&lt;th&gt;핵심 질문&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;의존성 주입&lt;/td&gt;
&lt;td&gt;이 객체가 의존성을 직접 생성하고 있지 않은가?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;로직과 사이드 이펙트 분리&lt;/td&gt;
&lt;td&gt;이 메서드에서 순수한 계산과 외부 호출이 섞여 있지 않은가?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;숨은 입력 제거&lt;/td&gt;
&lt;td&gt;싱글턴, &lt;code&gt;Date()&lt;/code&gt;, &lt;code&gt;UserDefaults&lt;/code&gt;에 직접 접근하고 있지 않은가?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;유스케이스 완결&lt;/td&gt;
&lt;td&gt;하나의 목표를 위해 여러 메서드를 순서대로 호출해야 하지 않은가?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;프레임워크 격리&lt;/td&gt;
&lt;td&gt;비즈니스 로직에 &lt;code&gt;UIKit&lt;/code&gt;, &lt;code&gt;CoreLocation&lt;/code&gt; 등이 침투해 있지 않은가?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;프로토콜 경계&lt;/td&gt;
&lt;td&gt;변경될 수 있는 외부 의존성 앞에 교체 가능한 경계가 있는가?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;이 원칙들은 테스트를 위한 것이 아니다. &lt;strong&gt;좋은 설계 그 자체&lt;/strong&gt;다. 의존성이 명확하고, 로직이 분리되고, 경계가 있는 코드는 테스트 가능성이 자연스럽게 따라온다.&lt;/p&gt;</description>
      <category>Programming/Swift</category>
      <author>loinsir</author>
      <guid isPermaLink="true">https://glassgow.tistory.com/57</guid>
      <comments>https://glassgow.tistory.com/57#entry57comment</comments>
      <pubDate>Mon, 16 Mar 2026 21:57:49 +0900</pubDate>
    </item>
    <item>
      <title>앰비언트 컨텍스트(Ambient Context)는 왜 테스트 코드에서 안티 패턴일까</title>
      <link>https://glassgow.tistory.com/56</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;코드를 작성하다 보면 종종 이런 형태를 보게 된다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;Date()
Locale.current
UserSession.shared.currentUser
AppEnvironment.apiClient&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;함수의 파라미터에는 아무것도 없지만,&lt;br /&gt;코드 어딘가에는 &lt;b&gt;당연히 존재한다고 가정되는 값&lt;/b&gt;들이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에서는 이런 패턴을 &lt;b&gt;앰비언트 컨텍스트(Ambient Context)&lt;/b&gt; 라고 부르고,&lt;br /&gt;왜 이것이 테스트 코드를 작성할 때 안티 패턴으로 여겨지는지 정리해본다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;앰비언트 컨텍스트란 무엇인가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앰비언트 컨텍스트는&lt;br /&gt;&lt;b&gt;명시적으로 전달되지 않지만, 전역적으로 접근 가능한 실행 문맥 정보&lt;/b&gt;를 의미한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주로 다음과 같은 형태로 나타난다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;전역 변수&lt;/li&gt;
&lt;li&gt;싱글톤 객체&lt;/li&gt;
&lt;li&gt;&lt;code&gt;static&lt;/code&gt; 프로퍼티&lt;/li&gt;
&lt;li&gt;Thread-local / Task-local 값&lt;/li&gt;
&lt;li&gt;플랫폼이 암묵적으로 제공하는 상태&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;예시&lt;/h3&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;enum AppEnvironment {
    static var currentUser: User?
    static var apiClient: APIClient = .live
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;func loadProfile() {
    guard let user = AppEnvironment.currentUser else { return }
    AppEnvironment.apiClient.fetchProfile(user.id)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;loadProfile()&lt;/code&gt;는 아무런 인자를 받지 않지만,&lt;br /&gt;실제로는 다음 값들에 의존하고 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;현재 로그인된 사용자&lt;/li&gt;
&lt;li&gt;APIClient의 구현체&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 의존성들은 코드 표면에는 드러나지 않는다.&lt;br /&gt;이 점이 앰비언트 컨텍스트의 가장 큰 특징이다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 이런 패턴이 사용될까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앰비언트 컨텍스트는 분명 장점이 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;파라미터 전달이 줄어든다&lt;/li&gt;
&lt;li&gt;어디서든 접근 가능해 사용이 편하다&lt;/li&gt;
&lt;li&gt;레거시 코드와 잘 맞는다&lt;/li&gt;
&lt;li&gt;플랫폼 자체가 이런 방식을 제공하기도 한다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 이 편의성이 &lt;b&gt;테스트 코드에서 그대로 비용으로 돌아온다&lt;/b&gt;는 점이다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;테스트 코드에서 문제가 되는 이유&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 숨겨진 의존성&lt;/h3&gt;
&lt;pre class=&quot;autoit&quot;&gt;&lt;code&gt;func testLoadProfile() {
    loadProfile()
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 테스트만 보고는 다음을 알 수 없다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;어떤 사용자 상태를 전제로 하는지&lt;/li&gt;
&lt;li&gt;실제 API 호출이 일어나는지&lt;/li&gt;
&lt;li&gt;어떤 환경에서 동작하는지&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 테스트는 이렇게 작성된다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;func testLoadProfile() {
    AppEnvironment.currentUser = User(id: 1)
    AppEnvironment.apiClient = .mock

    loadProfile()
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;함수 시그니처만 보고는&lt;br /&gt;어떤 준비가 필요한지 전혀 알 수 없다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 테스트 간 결합&lt;/h3&gt;
&lt;pre class=&quot;go&quot;&gt;&lt;code&gt;func testA() {
    AppEnvironment.apiClient = .mockA
}

func testB() {
    // 기본 apiClient를 기대
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;테스트 실행 순서에 따라 결과가 달라질 수 있다&lt;/li&gt;
&lt;li&gt;하나의 테스트가 전역 상태를 변경하면 다른 테스트에 영향을 준다&lt;/li&gt;
&lt;li&gt;병렬 테스트 실행이 어려워진다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트는 독립적으로 실행될 수 있어야 하지만,&lt;br /&gt;앰비언트 컨텍스트는 이를 깨뜨린다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 테스트 의도를 읽기 어렵다&lt;/h3&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;loadProfile()&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 한 줄의 테스트 코드로는 다음을 파악할 수 없다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;어떤 APIClient를 사용했는지&lt;/li&gt;
&lt;li&gt;어떤 사용자 상태인지&lt;/li&gt;
&lt;li&gt;어떤 환경을 가정하는지&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 코드는 동작에 대한 사양 역할을 해야 하지만,&lt;br /&gt;앰비언트 컨텍스트는 테스트를 설명하기 어렵게 만든다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. Mock과 상태 복구 비용 증가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전역 상태를 사용하는 테스트는 항상 복구 코드가 필요하다.&lt;/p&gt;
&lt;pre class=&quot;swift&quot;&gt;&lt;code&gt;override func tearDown() {
    AppEnvironment.apiClient = .live
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;복구를 빠뜨리면 테스트가 불안정해진다&lt;/li&gt;
&lt;li&gt;테스트 수가 늘어날수록 관리 비용이 커진다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Mocking 자체보다&lt;br /&gt;전역 상태 관리가 더 큰 문제가 된다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 결정론적이지 않은 코드&lt;/h3&gt;
&lt;pre class=&quot;autoit&quot;&gt;&lt;code&gt;func isAdult() -&amp;gt; Bool {
    AppEnvironment.currentUser?.age ?? 0 &amp;gt;= 20
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입력이 없는데 결과가 달라질 수 있다.&lt;br /&gt;이런 함수는 테스트하기 어렵고, 동작을 예측하기도 힘들다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;대안&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 명시적인 의존성 주입&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;struct ProfileService {
    let apiClient: APIClient
    let currentUser: User
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;let service = ProfileService(
    apiClient: .mock,
    currentUser: User(id: 1)
)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;의존성이 코드에 그대로 드러나고,&lt;br /&gt;테스트 의도도 명확해진다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 파라미터로 컨텍스트 전달&lt;/h3&gt;
&lt;pre class=&quot;swift&quot;&gt;&lt;code&gt;func loadProfile(user: User, apiClient: APIClient)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 단순하면서도 테스트 친화적인 방식이다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 스코프가 제한된 컨텍스트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;완전한 전역 대신,&lt;br /&gt;명시적으로 범위를 제한한 컨텍스트를 사용할 수도 있다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;withDependencies {
    $0.apiClient = .mock
} operation: {
    // 이 블록 안에서만 유효
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전역처럼 보이지만,&lt;br /&gt;테스트에서는 비교적 안전하게 사용할 수 있다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앰비언트 컨텍스트는 코드를 작성하기는 편하게 만든다.&lt;br /&gt;하지만 그 편의성은 테스트 코드의 복잡도로 이어진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트를 중요하게 생각한다면,&lt;br /&gt;의존성은 숨기기보다 드러내는 쪽이 장기적으로 훨씬 낫다.&lt;/p&gt;</description>
      <category>Programming/Swift</category>
      <author>loinsir</author>
      <guid isPermaLink="true">https://glassgow.tistory.com/56</guid>
      <comments>https://glassgow.tistory.com/56#entry56comment</comments>
      <pubDate>Thu, 8 Jan 2026 23:48:03 +0900</pubDate>
    </item>
    <item>
      <title>[Swift] 모듈화 시 리소스 접근을 해결하는 #bundle매크로</title>
      <link>https://glassgow.tistory.com/55</link>
      <description>&lt;p&gt;최근 프로젝트의 규모가 커짐에 따라 &lt;strong&gt;모듈화(Modularization)&lt;/strong&gt; 작업을 진행했습니다. 공통으로 사용되는 UI 컴포넌트와 디자인 리소스(Color, Image 등)를 &lt;strong&gt;Shared 모듈(Dynamic Framework)&lt;/strong&gt;로 분리하는 것이 목표였습니다.&lt;/p&gt;
&lt;p&gt;하지만, 앱 타겟(App Target)에 있던 리소스를 별도의 프레임워크로 옮긴 후 실행해보니 &lt;strong&gt;색상이나 이미지를 불러오지 못하는 문제&lt;/strong&gt;가 발생했습니다.&lt;/p&gt;
&lt;p&gt;이 과정에서 겪은 번들(Bundle) 문제와, Xcode 16 (Swift 5.9+) 환경에서 이를 우아하게 해결해 준 &lt;code&gt;#bundle&lt;/code&gt; 매크로에 대해 공유합니다.&lt;/p&gt;
&lt;h2&gt;1. 문제 상황: 리소스가 왜 nil일까?&lt;/h2&gt;
&lt;p&gt;보통 우리는 Asset Catalog에 있는 색상을 사용할 때 다음과 같이 호출합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;// 일반적인 호출
let myColor = UIColor(named: &amp;quot;MyColor&amp;quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;앱 타겟 내부에 리소스가 있을 때는 문제없이 잘 동작하던 코드입니다. 하지만 리소스를 리소스 모듈로 옮기자마자 nil을 반환하기 시작했습니다. 원인은 UIColor(named:)의 기본 동작 방식에 있습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;// UIColor(named:)의 실제 동작
UIColor(named: &amp;quot;MyColor&amp;quot;, in: .main, compatibleWith: nil)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Bundle.main은 현재 실행 중인 App의 번들을 의미합니다. 하지만 제가 분리한 리소스는 Shared 프레임워크의 번들 내부에 존재하기 때문에, 앱 번들에서 아무리 찾아도 리소스를 발견할 수 없었던 것입니다.&lt;/p&gt;
&lt;h2&gt;2. 기존의 해결 방식 (The Old Way)&lt;/h2&gt;
&lt;p&gt;이 문제를 해결하려면 리소스가 위치한 정확한 Bundle을 명시해 주어야 합니다. 하지만 기존 방식은 환경에 따라 코드가 달라지는 불편함이 있었습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Framework: Bundle(for: MyClass.self)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Swift Package Manager (SPM): Bundle.module&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;만약 코드를 SPM과 프레임워크 환경 양쪽에서 공유해야 한다면, 전처리기를 사용하거나 별도의 BundleAccessor를 만들어야 했습니다.&lt;/p&gt;
&lt;h2&gt;3. 해결책: #bundle 매크로&lt;/h2&gt;
&lt;p&gt;Xcode 16 환경에서 이 문제를 가장 깔끔하게 해결할 수 있는 방법은 Swift의 공식 표현식 매크로인 &lt;strong&gt;#bundle&lt;/strong&gt;을 사용하는 것입니다.&lt;/p&gt;
&lt;p&gt;#bundle은 컴파일 타임에 현재 코드가 속한 타겟에 가장 적합한 번들을 자동으로 반환해 줍니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;// 리소스 모듈 내부

// [Before] 번들 위치를 명시하기 위해 고민이 필요함
let color = UIColor(named: &amp;quot;MyColor&amp;quot;, in: Bundle(for: CurrentClass.self), compatibleWith: nil)

// [After] #bundle 하나로 해결
let color = UIColor(named: &amp;quot;MyColor&amp;quot;, in: #bundle, compatibleWith: nil)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;#bundle의 동작 원리&lt;br&gt;이 매크로는 코드가 컴파일되는 위치에 따라 알맞은 번들 접근자로 변환됩니다.&lt;/p&gt;
&lt;p&gt;App Target    Bundle.main&lt;br&gt;Framework    Bundle(for: $self_type.self)&lt;br&gt;Swift Package    Bundle.module&lt;br&gt;App Extension    Extension 번들&lt;/p&gt;
&lt;h2&gt;4. 활용 범위&lt;/h2&gt;
&lt;p&gt;#bundle은 단순히 UIColor뿐만 아니라 번들을 인자로 받는 모든 API에서 활용 가능합니다. 특히 로컬라이징(String Catalog) 처리에 유용합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;label.text = String(
    localized: &amp;quot;Game Over.&amp;quot;,
    bundle: #bundle, // 고민 없이 #bundle 사용
    comment: &amp;quot;Text for game over banner.&amp;quot;
)&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;5. 도입 시 장점&lt;/h2&gt;
&lt;p&gt;환경 독립적 코드: App, Framework, SPM 어떤 환경으로 코드를 이동해도 수정할 필요가 없습니다.&lt;/p&gt;
&lt;p&gt;안전성: 다이나믹 프레임워크나 Static 라이브러리 등 복잡한 링크 구조에서도 리소스 위치를 안전하게 찾습니다.&lt;/p&gt;
&lt;p&gt;Tuist/SPM 설정 간소화:&lt;/p&gt;
&lt;p&gt;Tuist 사용 시 리소스 접근을 위해 disableBundleAccessor 옵션을 끄거나 별도의 BundleAccessor를 생성하곤 했습니다.&lt;/p&gt;
&lt;p&gt;이제는 네이티브 매크로인 #bundle이 그 역할을 완벽히 대체하므로 불필요한 보일러플레이트 코드를 줄일 수 있습니다.&lt;/p&gt;
&lt;p&gt;백포트 지원: 최신 문법이지만 iOS 15, macOS 12 이상 타겟이라면 문제없이 동작합니다.&lt;/p&gt;
&lt;h2&gt;마무리&lt;/h2&gt;
&lt;p&gt;모듈화를 진행하다 보면 &amp;quot;리소스 번들 위치&amp;quot; 문제는 필연적으로 마주치게 됩니다. 이제 복잡한 번들 분기 처리나 커스텀 헬퍼 클래스 대신, 표준 매크로인 #bundle을 사용하여 더 간결하고 명확한 코드를 작성해 보시길 바랍니다.&lt;/p&gt;
&lt;p&gt;| &lt;a href=&quot;https://developer.apple.com/documentation/foundation/bundle()&quot;&gt;https://developer.apple.com/documentation/foundation/bundle()&lt;/a&gt;&lt;/p&gt;</description>
      <category>Programming/iOS</category>
      <author>loinsir</author>
      <guid isPermaLink="true">https://glassgow.tistory.com/55</guid>
      <comments>https://glassgow.tistory.com/55#entry55comment</comments>
      <pubDate>Thu, 4 Dec 2025 12:25:09 +0900</pubDate>
    </item>
    <item>
      <title>LetSwift 2025 - Live Activity 개발기</title>
      <link>https://glassgow.tistory.com/54</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LetSwift 2025에 오거나이저로 참여하며 앱에 Live Activity를 넣기로 했다, 막상 시작하니... 생각보다 훨씬 복잡했다. Push-to-Start 토큰이 뭔지도 몰랐고, FCM으로 Live Activity를 어떻게 시작하는지도 감이 안 잡혔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과적으로는 성공했지만, 그 과정에서 정말 많은 시행착오가 있었다. 날짜 파싱이 안 돼서 머리를 쥐어뜯기도 했고, 토큰 관리 때문에 메모리 누수를 만들기도 했다. 이 글에서는 그런 삽질들을 가감 없이 공유해보려고 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;일단 설계부터&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Let'Swift는 A트랙이랑 B트랙이 동시에 진행된다. 그래서 처음부터 &quot;트랙별로 독립적인 Live Activity를 띄워야겠다&quot;고 생각했다. 문제는 Live Activity의 구조를 이해하는 게 쉽지 않았다는 거다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Live Activity는 &lt;code&gt;ActivityAttributes&lt;/code&gt;랑 &lt;code&gt;ContentState&lt;/code&gt; 두 부분으로 나뉜다. &lt;code&gt;ActivityAttributes&lt;/code&gt;는 한번 정하면 못 바꾸는 고정값이고, &lt;code&gt;ContentState&lt;/code&gt;는 계속 업데이트할 수 있는 동적인 값이다. 그래서 트랙 정보는 Attributes에 넣고, 세션 제목이나 진행 상태는 ContentState에 넣었다.&lt;/p&gt;
&lt;pre class=&quot;dart&quot;&gt;&lt;code&gt;struct PresentationAttributes: ActivityAttributes {
    struct ContentState: Codable, Hashable {
        var presentationId: Int
        var title: String
        var speakers: [Speaker]
        var location: String?
        var startTime: String
        var endTime: String
        var track: String
        var currentStatus: Status

        enum Status: String, Codable, Hashable {
            case upcoming, ongoing, ended, unknown
        }
    }

    var track: String  // &quot;A&quot; 또는 &quot;B&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하니까 A트랙이랑 B트랙 Live Activity를 동시에 띄워도 각각 따로 관리할 수 있었다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;토큰 지옥&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;솔직히 토큰 관리가 제일 헷갈렸다. FCM Token, Push-to-Start Token, Live Activity Token... 도대체 뭐가 이렇게 많은 건지. 각각 용도가 다르다는 건 알겠는데, 언제 어떤 토큰을 써야 하는지 감이 안 잡혔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 Live Activity Token은 더 골치 아팠다. 이 토큰은 Live Activity가 시작된 &quot;후에&quot; 발급된다. 그런데 트랙별로 따로 관리해야 하니까, A트랙 Activity 시작하면 A트랙 토큰이 나오고, B트랙 Activity 시작하면 B트랙 토큰이 또 나온다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 이걸 그냥 배열로 관리하려다가 완전 꼬였다. 어떤 토큰이 어떤 트랙 거인지 구분이 안 됐다. 결국 딕셔너리로 바꿨다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;actor LiveActivityTokenManager {
    static let shared = LiveActivityTokenManager()

    private var pushToStartTokenTask: Task&amp;lt;Void, Never&amp;gt;?
    private var activityMonitorTask: Task&amp;lt;Void, Never&amp;gt;?
    private var activityTokenTasks: [String: Task&amp;lt;Void, Never&amp;gt;] = [:]

    func observePushToStartToken() {
        pushToStartTokenTask?.cancel()

        pushToStartTokenTask = Task {
            for await data in Activity&amp;lt;PresentationAttributes&amp;gt;.pushToStartTokenUpdates {
                let token = data.map { String(format: &quot;%02x&quot;, $0) }.joined()
                UserDefaults.standard.set(token, forKey: &quot;pushToStartToken&quot;)
                await registerTokensWithServer()
            }
        }

        monitorActiveActivities()
    }

    func observeActivityToken(_ activity: Activity&amp;lt;PresentationAttributes&amp;gt;) {
        let track = activity.attributes.track
        activityTokenTasks[track]?.cancel()

        activityTokenTasks[track] = Task {
            for await data in activity.pushTokenUpdates {
                let token = data.map { String(format: &quot;%02x&quot;, $0) }.joined()
                var tokens = getLiveActivityTokens()
                tokens[track] = token
                saveLiveActivityTokens(tokens)
                await registerTokensWithServer()
            }
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Actor로 만든 건 스레드 안전성 때문이다. 여러 곳에서 동시에 토큰 관리를 해도 문제없게 하려고. 그리고 트랙별로 Task를 관리하는 게 핵심인데, 새 Activity가 시작되면 기존 Task를 취소하고 새로 만든다. 안 그러면 메모리 누수가 생긴다. (실제로 이거 때문에 한번 당했다...)&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Push-to-Start 구현&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Push-to-Start는 진짜 신기한 기능이다. 앱이 완전히 꺼져 있어도 서버에서 푸시를 보내면 Live Activity가 뜬다. 컨퍼런스 앱에는 완벽한 기능이었다. 세션 시작 5분 전에 자동으로 Live Activity를 띄울 수 있으니까.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;iOS에서는 푸시 알림을 받으면 페이로드를 확인해서 Live Activity를 시작한다. 처음에는 이 페이로드 파싱이 계속 실패했다. 알고 보니 JSON 구조를 잘못 이해하고 있었다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;func startLiveActivityIfNeeded(from userInfo: [AnyHashable: Any]) async {
    guard let aps = userInfo[&quot;aps&quot;] as? [String: Any],
          let event = aps[&quot;event&quot;] as? String,
          event == &quot;start&quot;,
          let contentStateDict = aps[&quot;content-state&quot;] as? [String: Any],
          let attributesDict = aps[&quot;attributes&quot;] as? [String: Any] else {
        return
    }

    do {
        let contentStateData = try JSONSerialization.data(withJSONObject: contentStateDict)
        let contentState = try JSONDecoder().decode(
            PresentationAttributes.ContentState.self,
            from: contentStateData
        )

        let attributesData = try JSONSerialization.data(withJSONObject: attributesDict)
        let attributes = try JSONDecoder().decode(
            PresentationAttributes.self,
            from: attributesData
        )

        // 중복 체크
        let existingActivities = Activity&amp;lt;PresentationAttributes&amp;gt;.activities
        guard !existingActivities.contains(where: { $0.attributes.track == attributes.track }) else {
            return
        }

        let content = ActivityContent(state: contentState, staleDate: nil)
        let activity = try Activity.request(
            attributes: attributes,
            content: content,
            pushType: .token
        )

        await LiveActivityTokenManager.shared.observeActivityToken(activity)
    } catch {}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중복 체크 부분이 중요한데, 처음에는 이거 안 넣어서 같은 트랙 Live Activity가 두세 개씩 뜨는 참사가 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버는 FastAPI로 짰다. Python이 익숙해서 선택했는데, Firebase Admin SDK 쓰는 게 생각보다 간단했다.&lt;/p&gt;
&lt;pre class=&quot;rust&quot;&gt;&lt;code&gt;async def start_live_activity(
    self,
    fcm_token: str,
    push_to_start_token: str,
    content_state: ActivityContentState,
    track: str
) -&amp;gt; str:
    content_state_dict = content_state.model_dump(mode='json')

    message = {
        &quot;token&quot;: fcm_token,
        &quot;apns&quot;: {
            &quot;live_activity_token&quot;: push_to_start_token,
            &quot;headers&quot;: {
                &quot;apns-priority&quot;: &quot;10&quot;,
                &quot;apns-push-type&quot;: &quot;liveactivity&quot;
            },
            &quot;payload&quot;: {
                &quot;aps&quot;: {
                    &quot;timestamp&quot;: int(datetime.now().timestamp()),
                    &quot;event&quot;: &quot;start&quot;,
                    &quot;content-state&quot;: content_state_dict,
                    &quot;attributes-type&quot;: &quot;PresentationAttributes&quot;,
                    &quot;attributes&quot;: {
                        &quot;track&quot;: track
                    },
                    &quot;alert&quot;: {
                        &quot;title&quot;: content_state.title,
                        &quot;body&quot;: f&quot;Starts at {content_state.startTime[11:16]}&quot;
                    }
                }
            }
        }
    }

    return await self._send_message(message)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;apns-push-type&lt;/code&gt;을 &lt;code&gt;liveactivity&lt;/code&gt;로 안 하면 안 된다. 이것도 몰라서 한참 헤맸다. Apple 문서를 제대로 안 읽은 내 잘못이지만...&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Dynamic Island는 진짜 멋있다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Dynamic Island 구현은 정말 재밌었다. Compact, Minimal, Expanded 세 가지 상태를 각각 디자인할 수 있는데, 특히 Expanded 상태는 꽤 넓은 공간을 쓸 수 있어서 좋았다.&lt;/p&gt;
&lt;pre class=&quot;roboconf&quot;&gt;&lt;code&gt;final class Volcano {
    static func createDynamicIsland(
        with context: ActivityViewContext&amp;lt;PresentationAttributes&amp;gt;
    ) -&amp;gt; DynamicIsland {
        DynamicIsland {
            DynamicIslandExpandedRegion(.leading) {
                Image(.logo2025200)
                    .resizable()
                    .frame(width: 32, height: 32)
                    .clipShape(Circle())
            }

            DynamicIslandExpandedRegion(.trailing) {
                Image(systemName: context.state.currentStatus == .upcoming
                    ? &quot;clock.badge&quot; : &quot;clock.fill&quot;)
                    .foregroundColor(context.state.currentStatus == .upcoming
                        ? Color(.upcoming) : Color(.themePrimary))
            }

            DynamicIslandExpandedRegion(.bottom) {
                VStack(spacing: 6) {
                    HStack {
                        Text(context.state.title)
                            .font(.system(size: 15, weight: .bold))
                        Spacer()
                    }

                    if context.state.currentStatus == .ongoing,
                       let startDate = parseDate(from: context.state.startTime),
                       let endDate = parseDate(from: context.state.endTime) {
                        ProgressView(
                            timerInterval: startDate...endDate,
                            countsDown: false
                        )
                        .progressViewStyle(.linear)
                        .tint(Color(.themePrimary))
                    }
                }
            }
        } compactLeading: {
            Image(.logo2025200)
                .resizable()
                .frame(width: 18, height: 18)
                .clipShape(Circle())
        } compactTrailing: {
            if context.state.currentStatus == .ongoing {
                ProgressView(timerInterval: startDate...endDate)
                    .progressViewStyle(.circular)
                    .frame(width: 20, height: 20)
            } else {
                Image(systemName: &quot;clock.badge&quot;)
            }
        } minimal: {
            ZStack {
                ProgressView(timerInterval: startDate...endDate)
                    .progressViewStyle(.circular)
                    .frame(width: 16, height: 16)
                Image(.logo2025200)
                    .resizable()
                    .frame(width: 10, height: 10)
            }
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Dynamic Island 디자인할 때 제일 어려운 건 공간이 진짜 작다는 거다. 특히 Compact랑 Minimal에서는 거의 아이콘 하나 넣으면 끝이다. 그래서 세션 진행 중일 때는 원형 진행률 표시기를, 대기 중일 때는 시계 아이콘을 보여주는 걸로 타협했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;iOS 18 Smart Stack은 보너스&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;iOS 18에서는 Live Activity가 홈 화면 위젯으로도 뜬다는 걸 나중에 알았다. 그게 바로 Smart Stack인데, 문제는 공간이 더 작다는 거다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;@available(iOS 18.0, *)
private struct LiveActivityContentView: View {
    let context: ActivityViewContext&amp;lt;PresentationAttributes&amp;gt;
    @Environment(\.activityFamily) private var activityFamily

    private var isSmartStack: Bool {
        activityFamily == .small
    }

    private var iconSize: CGFloat {
        isSmartStack ? 28 : 52
    }

    var body: some View {
        LiveActivityBaseView(
            context: context,
            iconSize: iconSize,
            badgeSize: isSmartStack ? 12 : 18,
            clockIconSize: isSmartStack ? 8 : 13,
            isCompact: isSmartStack
        )
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;@Environment(\.activityFamily)&lt;/code&gt;로 현재 크기를 알 수 있다. Small이면 Smart Stack이니까 모든 크기를 줄인다. 처음에 이거 몰라서 Smart Stack에서 텍스트가 다 짤렸다... 실기기가 없어 테스트를 제대로 못 한 내 잘못이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;날짜 파싱 지옥&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발하면서 제일 화났던 부분이다. &lt;code&gt;ProgressView(timerInterval:)&lt;/code&gt;을 쓰려면 Date 객체가 필요한데, 서버에서 받은 문자열을 파싱하는 게 계속 실패했다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;private func parseDate(from dateString: String) -&amp;gt; Date? {
    let formatter = ISO8601DateFormatter()
    formatter.formatOptions = [
        .withInternetDateTime,
        .withDashSeparatorInDate,
        .withColonSeparatorInTime
    ]

    // 1. 타임존 포함된 형식 시도
    if let date = formatter.date(from: dateString) {
        return date
    }

    // 2. 타임존 없으면 Z 붙여서 재시도
    if let date = formatter.date(from: dateString + &quot;Z&quot;) {
        return date
    }

    // 3. 안 되면 커스텀 포맷터
    let fallbackFormatter = DateFormatter()
    fallbackFormatter.dateFormat = &quot;yyyy-MM-dd'T'HH:mm:ss&quot;
    fallbackFormatter.timeZone = TimeZone.current
    return fallbackFormatter.date(from: dateString)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3단계 폴백 로직을 만들고 나서야 안정화됐다. 그런데 근본적인 해결은 서버를 고치는 거였다. 항상 타임존을 명시하도록 바꿨다.&lt;/p&gt;
&lt;pre class=&quot;perl&quot;&gt;&lt;code&gt;start_time_str = presentation.start_time.strftime(&quot;%Y-%m-%dT%H:%M:%S+09:00&quot;)
end_time_str = presentation.end_time.strftime(&quot;%Y-%m-%dT%H:%M:%S+09:00&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하니까 문제가 싹 사라졌다. 교훈: 날짜는 항상 타임존을 명시하자.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;서버 자동화는 필수&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 &quot;내가 눌러서 시작하면 되지&quot;라고 생각했다. 완전 안일한 생각이었다. 컨퍼런스 당일에 세션 시작할 때마다 버튼 누르고 있을 거야? 말이 안 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 서버가 알아서 현재 시간에 맞는 세션을 찾아서 Live Activity를 시작하도록 만들었다. 우선순위는 이렇다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;지금 진행 중인 세션 있으면 그거 ongoing으로&lt;/li&gt;
&lt;li&gt;30분 안에 시작하는 세션 있으면 그거 upcoming으로&lt;/li&gt;
&lt;li&gt;10분 전에 끝난 세션 있으면 그거 ongoing으로 (늦게 온 사람 배려)&lt;/li&gt;
&lt;li&gt;2시간 안에 시작하는 세션 있으면 그거 upcoming으로&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;@router.post(&quot;/start-current&quot;)
async def start_current_live_activity(
    request: StartCurrentActivityRequest,
    db: AsyncSession = Depends(get_db),
    storage: RedisTokenStorage = Depends(get_storage),
    fcm: FCMService = Depends(get_fcm_service)
) -&amp;gt; ActivityResponse:
    now = datetime.now()
    tracks = [&quot;A&quot;, &quot;B&quot;]

    for idx, track in enumerate(tracks):
        query = select(PresentationDB).where(
            PresentationDB.end_time &amp;gt; now,
            PresentationDB.track == track
        ).order_by(PresentationDB.start_time)

        presentations = (await db.execute(query)).scalars().all()

        selected_presentation = None
        selected_status = None

        for presentation in presentations:
            if presentation.start_time &amp;lt;= now &amp;lt;= presentation.end_time:
                selected_presentation = presentation
                selected_status = PresentationStatus.ongoing
                break
            elif presentation.start_time &amp;gt; now:
                time_until_start = (presentation.start_time - now).total_seconds() / 60
                if time_until_start &amp;lt;= 30:
                    selected_presentation = presentation
                    selected_status = PresentationStatus.upcoming
                    break

        # ... 나머지 로직 생략

        if idx &amp;lt; len(tracks) - 1:
            await asyncio.sleep(1.0)  # FCM rate limiting 방지&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 API를 크론으로 5분마다 호출하도록 설정했다. 이제 아무것도 안 해도 알아서 Live Activity가 뜬다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;FCM Rate Limiting에 당하다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 명한테 동시에 푸시를 보내니까 FCM이 &quot;야 너무 빠른데?&quot;라면서 거부하기 시작했다. 일부 메시지가 실패하는 거다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해결책은 간단했다. 좀 천천히 보내면 된다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;async def send_to_multiple_devices(
    self,
    fcm_tokens: List[str],
    live_activity_tokens: List[str],
    payload_builder,
    *args, **kwargs
) -&amp;gt; tuple[int, int]:
    success_count = 0
    failure_count = 0

    for fcm_token, la_token in zip(fcm_tokens, live_activity_tokens):
        try:
            await payload_builder(fcm_token, la_token, *args, **kwargs)
            success_count += 1
        except Exception as e:
            failure_count += 1

        await asyncio.sleep(0.1)  # 0.1초 쉬기

    return success_count, failure_count&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 메시지 사이에 0.1초씩 쉬고, 트랙 사이에는 1초 쉬도록 했다. 좀 느려지긴 했지만 안정성이 훨씬 좋아졌다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Redis로 토큰 저장&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디바이스 토큰을 어디에 저장할까 고민하다가 Redis를 선택했다. 이유는 간단하다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;빠르다&lt;/li&gt;
&lt;li&gt;TTL 설정하면 알아서 정리된다&lt;/li&gt;
&lt;li&gt;JSON 저장이 편하다&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;class RedisTokenStorage:
    async def update(self, device_id: str, request: DeviceTokenRequest):
        device_token = DeviceToken(
            deviceId=device_id,
            fcmToken=request.fcmToken,
            pushToStartToken=request.pushToStartToken,
            liveActivityTokens=request.liveActivityTokens,
            appVersion=request.appVersion,
            platform=request.platform,
            registeredAt=datetime.now()
        )

        device_data = device_token.model_dump_json()
        await self.redis.set(
            f&quot;device_token:{device_id}&quot;,
            device_data,
            ex=60 * 60 * 24 * 30  # 30일 후 자동 삭제
        )

        return device_token&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;30일 지나면 자동으로 삭제되니까 관리가 편했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;테스트는 Preview로&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Live Activity 테스트는 진짜 귀찮다. 실제 디바이스 필요하고, 서버도 켜야 하고... 그래도 Xcode Preview 기능이 있어서 다행이었다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;#Preview(&quot;Lock Screen&quot;, as: .content, using: PresentationAttributes.preview) {
   LetSwift_iOS_WidgetLiveActivity()
} contentStates: {
    PresentationAttributes.ContentState.ongoing
    PresentationAttributes.ContentState.upcoming
    PresentationAttributes.ContentState.upcomingSoon
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Preview 덕분에 UI는 빠르게 만들 수 있었다. Dynamic Island 여러 상태를 한눈에 보면서 디자인 결정할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 푸시 테스트는 Postman으로 API 직접 호출하는 식으로 했다. 개발 디바이스 등록하고 테스트 메시지 날려보고.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;배운 것들&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 프로젝트하면서 정말 많이 배웠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일단 Actor가 이렇게 편한지 몰랐다. 예전 같았으면 DispatchQueue랑 세마포어로 머리 싸맸을 텐데, Actor 쓰니까 훨씬 간단했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;날짜/시간은 진짜 조심해야 한다. 타임존 안 붙이면 나중에 분명히 문제 생긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 자동화는 필수다. 실제 운영 환경에서는 사람이 일일이 관리할 수 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Push-to-Start는 정말 강력한 기능이다. 앱 꺼져 있어도 Live Activity 띄울 수 있다니.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Dynamic Island는 멋있긴 한데... 모든 사용자가 Pro 모델 쓰는 건 아니니까 Lock Screen UI가 더 중요하다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;아쉬운 점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 완벽하진 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에러 처리가 좀 약하다. 지금은 에러 나면 그냥 로그만 남기는데, 재시도 로직이나 사용자 알림이 있으면 좋겠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Live Activity 토큰은 Activity 끝나면 무효화되는데, 서버에서 이걸 감지해서 자동으로 정리하는 게 없다. Redis 메모리가 좀 아깝다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;분석 데이터도 수집하면 좋을 것 같다. 얼마나 많은 사람이 Live Activity 쓰는지, 어떤 상태에서 제일 많이 보는지 알면 좋을 듯 싶었다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Live Activity 구현하면서 정말 많이 삽질했다. 토큰 관리 때문에 머리 아팠고, 날짜 파싱 때문에 화났고, FCM rate limiting 때문에 당황했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래도 재밌었다. Push-to-Start부터 Dynamic Island, Smart Stack까지 최신 기능을 다 써볼 수 있었고, 실제 운영 환경에서 필요한 것들을 고민해볼 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LetSwift 2025에서 참가자들이 Live Activity로 세션 정보 보면서 &quot;오 이거 편한데?&quot;라고 생각했다면 좋겠다. 그럼 이 모든 삽질이 보람 있을 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글이 Live Activity 구현하려는 다른 개발자들한테 조금이라도 도움이 됐으면 좋겠다. 더불어 함께 해준 오거나이저분들께 진심으로 감사의 말씀을 전합니다.&lt;/p&gt;</description>
      <category>Programming/개발 후기</category>
      <author>loinsir</author>
      <guid isPermaLink="true">https://glassgow.tistory.com/54</guid>
      <comments>https://glassgow.tistory.com/54#entry54comment</comments>
      <pubDate>Wed, 26 Nov 2025 23:09:19 +0900</pubDate>
    </item>
    <item>
      <title>Swift Concurrency의 함정사항 정리</title>
      <link>https://glassgow.tistory.com/53</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://engineering.linecorp.com/ko/blog/about-swift-concurrency-performance&quot;&gt;Swift Concurrency 성능 조사&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고한 글!&lt;/p&gt;
&lt;h1&gt;Core 수만큼 정확하게 스레드를 생성하는가?&lt;/h1&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;코어 수 만큼 아주 정확하게 스레드를 생성하지는 않음.&lt;/li&gt;
&lt;li&gt;필요에 따라 더 스레드를 만들어서 Priority가 High인 작업들 속에서 상대적으로 낮은 작업의 실행이 영원히 기다리지 않도록 방지한다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;그 반대의 경우도 마찬가지 (먼저 실행중인 task가 low이고 오래동안 점유 중 일때, high인 task가 들어올 경우)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;우선순위가 다른 여러 작업이 있을 때 Suspension points에서 높은 확률로 우선순위가 높은 작업이 스레드를 할당받는다.&lt;/li&gt;
&lt;li&gt;현재 실행해야 하는 작업 중 우선순위가 가장 높은 작업이 스레드를 코어 수만큼 차지하고 있다면, 우선순위가 그보다 낮은 작업을 위해 별도 스레드가 추가된다. 따라서 스레드는 코어 수로 고정되는 것이 아니라 상황에 따라 그보다 많을 수 있으며, 이렇게 추가된 스레드는 &lt;b&gt;&lt;i&gt;우선순위가 낮은 작업만을 실행해서 우선순위가 높은 작업이 종료되기를 무한정 기다리는 것을 방지한다.&lt;/i&gt;&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;await 키워드를 만나기만 하면 해당 지점은 suspension point로 작동하는가?&lt;/h1&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Suspension points가 없는 함수에서 특정 조건을 만족할 때 &lt;code&gt;await&lt;/code&gt; 키워드와 함께 &lt;code&gt;async&lt;/code&gt; 함수를 호출하는 테스트를 진행했고, 결과는 Suspension points가 될 수 없다고 나왔다. &lt;code&gt;async&lt;/code&gt; 함수를 내부에서 &lt;code&gt;await&lt;/code&gt; 키워드와 함께 실행했지만 스레드 점유권을 양도하거나 양도받는 상황은 나타나지 않음!&lt;/li&gt;
&lt;li&gt;따라서 명확하게 suspension point로 작동하려면&lt;/li&gt;
&lt;li&gt;Task.yield()를 호출하거나&lt;/li&gt;
&lt;li&gt;자식 작업을 생성한다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;async let binding이나&lt;/li&gt;
&lt;li&gt;withTaskGroup, withThrowingTaskGroup 등&lt;/li&gt;
&lt;li&gt;Task.sleep도...&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Programming/iOS</category>
      <author>loinsir</author>
      <guid isPermaLink="true">https://glassgow.tistory.com/53</guid>
      <comments>https://glassgow.tistory.com/53#entry53comment</comments>
      <pubDate>Wed, 2 Apr 2025 23:52:36 +0900</pubDate>
    </item>
    <item>
      <title>TCA 아키텍처에서 IdentifiedArray를 사용해야 하는 이유</title>
      <link>https://glassgow.tistory.com/52</link>
      <description>&lt;p&gt;&lt;a href=&quot;https://www.pointfree.co/blog/posts/95-modern-swiftui-identified-arrays&quot;&gt;Modern SwiftUI: Identified arrays&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;이 포스팅은 위 링크를 기반으로 작성했습니다.&lt;/p&gt;
&lt;h1&gt;IdentifiedArray가 필요한 이유&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;보통 SwiftUI에서 리스트 뷰를 구현할 때 ForEach 구문으로 리스트 데이터를 순차적으로 표시하는 케이스가 많습니다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;그런 경우 보통 다음과 같이 구현하게 됩니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;  struct StandupsList: View {
    @State var standups: [Standup] = […]

    var body: some View {
      List {
        ForEach(standups) { standup in
          StandupRow(standup: standup)
        }
      }
    }
  }&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;그런데 보통 뷰 레이어에서 다시 데이터를 조작해서 상태를 반영시켜주어야 하는 경우가 많은데, 그런 경우 다음과 같은 경우가 필연적으로 발생하게 됩니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;  func deleteStandup(id: Standup.ID) {
    guard let index = standups.firstIndex(where: { $0.id == id })
    else { return }

    standups.remove(at: index)
  }&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;TCA의 경우, Reducer 쪽에 다음처럼 같이 작성될 수 있습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;  struct StandupsList: Reducer {
      struct State: Equatable {
          var standups: [Standup] = []
      }

      enum Action {
          case deleteStandup(id: Standup.ID)
      }

      func reduce(into state: inout State, action: Action) -&amp;gt; Effect&amp;lt;Action&amp;gt; {
          switch action {
          case let .deleteStandup(id):
              if let index = state.standups.firstIndex(where: { $0.id == id }) {
                  state.standups.remove(at: index)
              }
              return .none
          }
      }
  }&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;문제는 firstIndex에 있습니다, firstIndex는 해당하는 요소를 찾기 위해 배열을 처음부터 스캔해야 하기 때문에(시간복잡도 O(n)) 잠재적인 성능 문제가 발생하는 지점이 되게 됩니다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;문서에는 다음과 같은 예시도 들어서, api 클라이언트를 통해 배열을 수정하게 되면 크래시가 나는 경우도 보여줍니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;  func deleteStandup(id: Standup.ID) async throws {
    guard let index = standups.firstIndex(where: { $0.id == id })
    else { return }

    try await apiClient.delete(id: id)
    standups.remove(at: index)
  }&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;IdentifiedArray의 특징과 사용법&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;IdentifiedArray는 기존의 원시 배열을 대체하는 컬렉션 타입입니다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;ID로 요소를 안전하고 효율적으로 읽고 수정할 수 있는 메서드를 제공합니다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;IdentifiedArray에 들어갈 요소 타입의 id는 마치 identifiable 프로토콜 처럼 hashable을 만족해야합니다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;다음과 같이 선언하여 사용할 수 있습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;  import IdentifiedCollections

  var standups: IdentifiedArrayOf&amp;lt;Standup&amp;gt; = []&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;가장 큰 장점은 특정 요소를 찾기 위해 firstIndex가 아닌 id를 사용해 시간복잡도 O(1)에 접근이 가능합니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;  standups[id: standup.id] = standup&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Reducer도 다음과 같이 수정될 수 있습니다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;  struct StandupsList: Reducer {
      struct State: Equatable {
          var standups: IdentifiedArrayOf&amp;lt;Standup&amp;gt; = []
      }

      enum Action {
          case deleteStandup(id: Standup.ID)
      }

      func reduce(into state: inout State, action: Action) -&amp;gt; Effect&amp;lt;Action&amp;gt; {
          switch action {
          case let .deleteStandup(id):
              state.standups.remove(id: id)
              return .none
          }
      }
  }&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;성능?&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;Github README에서는 성능이 Swift의 OrderedDictionary 성능과 일치한다고 주장합니다 &lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bnMy5R/btsMQa3y3pb/tY48lFb8ZBi7sCKu2FlM21/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bnMy5R/btsMQa3y3pb/tY48lFb8ZBi7sCKu2FlM21/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bnMy5R/btsMQa3y3pb/tY48lFb8ZBi7sCKu2FlM21/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbnMy5R%2FbtsMQa3y3pb%2FtY48lFb8ZBi7sCKu2FlM21%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;어떻게 구현되어 있나?&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;내부적으로 Swift의 OrderedDictionary를 사용하여 래핑한 것을 살펴볼수 있습니다.&lt;ul&gt;
&lt;li&gt;OrderedDictionary는 순서를 보장하는 dictionary를 구현한 것으로 swift 오픈소스인 OrderedCollections에 내장되어 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Github&lt;a href=&quot;https://github.com/pointfreeco/swift-identified-collections/blob/main/Sources/IdentifiedCollections/IdentifiedArray/IdentifiedArray.swift&quot;&gt;(Link)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;public struct IdentifiedArray&amp;lt;ID: Hashable, Element&amp;gt; {
  public let id: KeyPath&amp;lt;Element, ID&amp;gt;

  // NB: Captures identity access. Direct access to `Identifiable`&amp;#39;s `.id` property is faster than
  //     key path access.
  @usableFromInline
  var _id: (Element) -&amp;gt; ID

  @usableFromInline
  var _dictionary: OrderedDictionary&amp;lt;ID, Element&amp;gt;

  /// A read-only collection view for the elements contained in this array, as an `Array`.
  ///
  /// - Complexity: O(1)
  @inlinable
  @inline(__always)
  public var elements: [Element] { self._dictionary.values.elements }

 ...
&lt;/code&gt;&lt;/pre&gt;</description>
      <category>Programming/iOS</category>
      <author>loinsir</author>
      <guid isPermaLink="true">https://glassgow.tistory.com/52</guid>
      <comments>https://glassgow.tistory.com/52#entry52comment</comments>
      <pubDate>Fri, 21 Mar 2025 01:52:06 +0900</pubDate>
    </item>
    <item>
      <title>Swift Concurrency를 공부하며 느낀점</title>
      <link>https://glassgow.tistory.com/51</link>
      <description>&lt;h1&gt;Swift Concurrency&lt;/h1&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;사내에서 Swift Concurrency 스터디를 진행했습니다.&lt;/li&gt;
&lt;li&gt;Swift Concurrency는 Swift5.5 버전 부터 도입된 Swift 언어 차원의 비동기 처리 방식입니다&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/#Unstructured-Concurrency&quot;&gt;Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;이 글에서는 개인적으로 Swift Concurrency를 공부하면서 중요하게 깨달은 내용에 대해 서술합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;1. 어떤 actor에 해당 변수, 함수가 &amp;ldquo;선언&amp;rdquo;되어있는지가 중요하다.&lt;/h1&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;GCD의 경우 선언이 중요한게 아니라 런타임에 어떤 dispatchqueue에 해당 작업이 들어가서 실행되는지가 중요했습니다.&lt;/li&gt;
&lt;li&gt;Swift Concurrency의 경우는 해당 변수와 함수가 &amp;ldquo;선언된 위치&amp;rdquo;가 곧 어떤 스레드(엄밀히 말하면 actor)에서 동작할지를 결정합니다.&lt;/li&gt;
&lt;li&gt;이를 흔히 다양한 교육자료에서 격리(isolated)라고 표현이 되는데, 직역으로는 당연히 맞는 말이지만, 처음 공부하는 입장에서는 어렵게 느껴지는 요인이었습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;swift&quot;&gt;&lt;code&gt;class GCDExample {
    private let queue = DispatchQueue(label: &quot;com.example.gcd&quot;, attributes: .concurrent)
    private var counter = 0

    func increment() {
        queue.async {
            self.counter += 1
            print(&quot;GCD Counter: \(self.counter)&quot;)
        }
    }
}

let gcdExample = GCDExample()
gcdExample.increment()

// Swift Concurrency
actor CounterActor {
    private var counter = 0

    func increment() {
        counter += 1
        print(&quot;Actor Counter: \(counter)&quot;)
    }
}

let counterActor = CounterActor()
Task {
    await counterActor.increment()
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;2. 어떤 스레드에서 실행되는지 보다는 어떤 &amp;ldquo;actor&amp;rdquo;에서 실행되는지를 생각하자&lt;/h1&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Swift Concurrency는 미리 스레드를 대략 CPU 코어 수 만큼 여럿 만들어 놓고(협력적 스레드풀, Cooperative Thread pool) 이 중 쉬고 있는 스레드를 골라서 실행되게 하는 방법입니다.&lt;/li&gt;
&lt;li&gt;actor는 내부에 unownedExecutor라는 큐를 통해 본인의 변수나 함수에 접근하는 task를 순차적으로 실행되게 하여 data race를 방지합니다.&lt;/li&gt;
&lt;li&gt;await 키워드는 액터를 전환하면서, 해당 액터로부터의 작업을 기다리는 키워드로 이해하면 되겠습니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;실행되는 스레드는 시스템이 알아서 지정해줍니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;단 MainActor는 반드시 메인 스레드에서 작동함을 보장합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;3. 암시적인(숨겨진) 주변 문맥을 숙지해야한다.&lt;/h1&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;UIKit, SwiftUI 에서 제공하는 다양한 클래스들은 MainActor로 마킹되어 있어, 메인스레드에서 실행되는 것을 보장합니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;UIViewController 공식문서 선언부&lt;/li&gt;
&lt;li&gt;@MainActor class UIViewController&lt;/li&gt;
&lt;li&gt;SwiftUI View 프로토콜역시 MainActor로 마킹되어있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;따라서 UI LifeCycle 메서드내에 선언된 Task 들은 MainActor에서 실행되는 Task입니다.&lt;/li&gt;
&lt;li&gt;Task는 주변 actor 컨텍스트를 상속받습니다.&lt;/li&gt;
&lt;li&gt;단, Task.detached로 이를 끊을 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;swift&quot;&gt;&lt;code&gt;import UIKit

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // 이 Task는 MainActor를 상속받음 (메인 스레드에서 실행됨)
        Task {
            print(&quot;Executing on Main Thread: \\(Thread.isMainThread)&quot;) // true
        }

        // Task.detached를 사용하면 MainActor 컨텍스트에서 벗어남 (백그라운드에서 실행될 수 있음)
        Task.detached {
            print(&quot;Executing on Background Thread: \\(Thread.isMainThread)&quot;) // false (일반적으로)
        }
    }
}

&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;만약 아무런 컨텍스트없는 Task의 경우 명시적으로 @MainActor를 지정하지 않는 한 백그라운드에서 실행될 수 있습니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;어떤 actor에도 속하지 않는 글로벌 컨텍스트에서 실행됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Programming/Swift</category>
      <author>loinsir</author>
      <guid isPermaLink="true">https://glassgow.tistory.com/51</guid>
      <comments>https://glassgow.tistory.com/51#entry51comment</comments>
      <pubDate>Mon, 17 Mar 2025 22:14:17 +0900</pubDate>
    </item>
    <item>
      <title>메서드 스위즐링을 적용하여 실수로부터 벗어나기</title>
      <link>https://glassgow.tistory.com/50</link>
      <description>&lt;h1&gt;메서드 스위즐링이란?&lt;/h1&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;메서드 스위즐링은 런타임에 함수의 구현부를 뒤섞는 방법을 말합니다.&lt;/li&gt;
&lt;li&gt;구현 방법은 보통 다음과 같이 이뤄집니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;보통 &lt;code&gt;class_getInstanceMethod&lt;/code&gt; 나 &lt;code&gt;class_getClassMethod&lt;/code&gt; 와 같은 방법을 사용해서 각 메서드의 셀렉터를 가져오고&lt;/li&gt;
&lt;li&gt;각각을 &lt;code&gt;method_exchangeImplementation&lt;/code&gt;으로 뒤바꾸어 구현을 바꾸어서 구현, appdelegate 같은 곳에서 swizzle을 한번 실행시켜줍니다.&lt;/li&gt;
&lt;li&gt;단, 각 메서드는 @objc 런타임에 노출되어야 하고, dynamic으로 마킹되어있어야 함
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;extension에 정의된 경우는 자동으로 dynamic처리가 된 것으로 칩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;import UIKit

fileprivate var swizzleEnabled = false

extension UIViewController {
    static let methodSwizzled: Void = {
        guard !swizzleEnabled else { return }
        swizzleEnabled = true

        // #selector: swift의 메소드나 함수를 참조하는 방법 (Selector 타입)
        let originalSelector = #selector(viewWillAppear(_:))
        // class_getInstanceMethod: Objecitve-C 런타임 라이브러리에서 제공하며, 메소드를 검색하는데 사용
        let originalMethod = class_getInstanceMethod(UIViewController.self, originalSelector)

        let swizzledSelector = #selector(swizzledViewWillAppear(_:))
        let swizzledMethod = class_getInstanceMethod(UIViewController.self, swizzledSelector)

        guard let originalMethod, let swizzledMethod else { return }
        method_exchangeImplementations(originalMethod, swizzledMethod)
    }()

    @objc private func swizzledViewWillAppear(_ animated: Bool) {
        // Swizzling된 메서드에서 추가적인 동작 수행
        print(&quot;Swizzled viewWillAppear called for \(self)&quot;)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;메서드 스위즐링의 단점
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;다른 프레임워크나 라이브러리를 가져와 사용할때 이미 스위즐링 되어있는 메서드를 또 스위즐링 하고 있지는 않은지 확인해야 합니다.&lt;/li&gt;
&lt;li&gt;새로이 iOS 버전업 되면 스위즐링이 실패할 가능성이 있습니다.&lt;/li&gt;
&lt;li&gt;이에 예상치 못한 버그를 발생시킬 수 있습니다.&lt;/li&gt;
&lt;li&gt;이미 짜여진 standard 메서드가 아닌 본인이 작성한 메서드가 호출되는지 확인해야합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;적용 사례&lt;/h1&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;현재 사내 앱은 아이패드를 지원하고 있는데, 아이패드에서 UIAlertController나, UIActivityController 사용시, 반드시 popoverPresentationController를 적용해줘야 하는 문제가 있습니다.&lt;/li&gt;
&lt;li&gt;Apple 개발자 포럼에서 이 문제가 여러 번 언급되었습니다. 특히 UIAlertController를 iPad에서 사용할 때 popoverPresentationController 구성은 필수적이며, 구성하지 않으면 다음과 같은 크래시 메시지가 발생할 수 있습니다&lt;/li&gt;
&lt;li&gt;이러한 크래시를 방지하기 위해 iPad에서 액션 시트를 표시할 때는 항상 popoverPresentationController의 sourceView 및 sourceRect 또는 barButtonItem 속성을 설정해야 합니다.&lt;/li&gt;
&lt;li&gt;그렇지 않으면 다음과 같은 오류가 발생합니다.
&lt;pre class=&quot;vbnet&quot;&gt;&lt;code&gt;Terminating app due to uncaught exception 'NSGenericException', reason: 'Your application has presented a UIAlertController (&amp;lt;UIAlertController: ...&amp;gt;) of style UIAlertControllerStyleActionSheet. The modalPresentationStyle of a UIAlertController with this style is UIModalPresentationPopover. You must provide location information for this popover through the alert controller's popoverPresentationController. You must provide either a sourceView and sourceRect or a barButtonItem. If this information is not known when you present the alert controller, you may provide it in the UIPopoverPresentationControllerDelegate method -prepareForPopoverPresentation.'&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;그래서 초기에 다음과 같이 UIAlertController 사용부 마다 ensureActionSheet 라는 메서드를 만들어 적용시켜주어야 했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;extension UIAlertController {
    func ensureActionSheet(_ viewController: UIViewController) -&amp;gt; UIAlertController { 
      if UIDevice.current.userInterfaceIdiom != .pad { // 디바이스 타입이 iPad가 아닐때 
        return self 
      }
      guard let popover = self.popoverPresentationController else { 
        return self 
      }

      popover.sourceView = viewController.view 
      popover.sourceRect = CGRect(x: viewController.view.bounds.midX, y: viewController.view.bounds.midY, width: 0, height: 0) 
      popover.permittedArrowDirections = [] 

      return self
    }
}

// 사용법

let alert = UIAlertController(title: &quot;알림&quot;, message: &quot;알림창입니다.&quot;, preferredStyle: .alert).ensureActionSheet(self)&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;하지만 실수로 &lt;code&gt;.ensureActionSheet(self)&lt;/code&gt;호출을 누락하기라도 하면 크래시가 나는 단점이 있었습니다.&lt;/li&gt;
&lt;li&gt;그래서 이 부분에 메서드 스위즐링을 적용하면 좋겠다는 생각이 났고 다음과 같이 적용하게 되었습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;swift&quot;&gt;&lt;code&gt;import UIKit

extension UIAlertController {
    // 앱 시작 시 한 번만 호출되는 메서드
    static let swizzleAlertController: Void = {
        let originalSelector = #selector(UIAlertController.init(title:message:preferredStyle:))
        let swizzledSelector = #selector(UIAlertController.swizzled_init(title:message:preferredStyle:))

        guard let originalMethod = class_getInstanceMethod(UIAlertController.self, originalSelector),
              let swizzledMethod = class_getInstanceMethod(UIAlertController.self, swizzledSelector) else {
            return
        }

        method_exchangeImplementations(originalMethod, swizzledMethod)
    }()

    // UIAlertController 초기화 시 자동으로 iPadOS에서 액션 시트 설정을 보장하는 메서드
    @objc private func swizzled_init(title: String?, message: String?, preferredStyle: UIAlertController.Style) -&amp;gt; UIAlertController {
        // 원래 초기화 메서드 호출 (swizzle로 인해 실제로는 원래 구현을 호출)
        let controller = self.swizzled_init(title: title, message: message, preferredStyle: preferredStyle)

        // preferredStyle이 actionSheet일 경우에만 적용
        if preferredStyle == .actionSheet &amp;amp;&amp;amp; UIDevice.current.userInterfaceIdiom == .pad {
            if let popover = controller.popoverPresentationController {
                // 현재 키 윈도우에서 최상위 뷰 컨트롤러 가져오기
                if let viewController = UIApplication.shared.keyWindow?.rootViewController?.topMostViewController() {
                    popover.sourceView = viewController.view
                    popover.sourceRect = CGRect(x: viewController.view.bounds.midX, 
                                              y: viewController.view.bounds.midY, 
                                              width: 0, 
                                              height: 0)
                    popover.permittedArrowDirections = []
                }
            }
        }

        return controller
    }
}

// 최상위 뷰 컨트롤러를 가져오기 위한 확장
extension UIViewController {
    func topMostViewController() -&amp;gt; UIViewController {
        if let presented = self.presentedViewController {
            return presented.topMostViewController()
        }

        if let tabBarController = self as? UITabBarController, 
           let selected = tabBarController.selectedViewController {
            return selected.topMostViewController()
        }

        if let navigationController = self as? UINavigationController, 
           let visibleViewController = navigationController.visibleViewController {
            return visibleViewController.topMostViewController()
        }

        return self
    }
}

// 앱 시작 시 호출되어 swizzling 수행
class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -&amp;gt; Bool {
        // Method swizzling 실행
        _ = UIAlertController.swizzleAlertController

        return true
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이를 통해, 사용처에서 특별한 처리 없이 메서드 스위즐링을 통해 크래시를 방지할 수 있게 되었습니다.  &lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Programming/iOS</category>
      <category>ios #swift #uialertcontroller #메서드 스위즐링</category>
      <author>loinsir</author>
      <guid isPermaLink="true">https://glassgow.tistory.com/50</guid>
      <comments>https://glassgow.tistory.com/50#entry50comment</comments>
      <pubDate>Mon, 17 Mar 2025 20:53:38 +0900</pubDate>
    </item>
    <item>
      <title>swift-dependencies: Dependency lifetimes</title>
      <link>https://glassgow.tistory.com/49</link>
      <description>&lt;h1 id=&quot;0f72b389-d1fe-4ec6-b18f-76c9812b12f7&quot; style=&quot;color: #37352f; text-align: start;&quot;&gt;Dependency lifetimes&lt;/h1&gt;
&lt;h2 id=&quot;85fd19cc-2f09-4093-bbfb-b81c5d0db286&quot; style=&quot;color: #37352f; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;How task locals work&lt;/h2&gt;
&lt;ul id=&quot;6f6c820e-7309-46b1-96e1-454eaa6874dc&quot; style=&quot;list-style-type: disc; color: #37352f; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;Dependency 프로퍼티 래퍼가 초기화되면, 그 순간 dependency의 현재 상태를 캡처합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;ul id=&quot;e2f332ee-fff5-4c5a-9095-4881543a4cbc&quot; style=&quot;list-style-type: disc; color: #37352f; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;@TaskLocal 변수가 새로운 비동기 task들로부터 상속되는 것과 비슷합니다.
&lt;ul id=&quot;50ba955b-15aa-4b60-abae-2c1cabcf6c56&quot; style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: circle;&quot;&gt;TaskLocal 변수는 &lt;a style=&quot;color: #000000;&quot; href=&quot;https://developer.apple.com/documentation/swift/tasklocal/withvalue(_:operation:file:line:)-79atg&quot;&gt;withValue&lt;/a&gt; 메서드 Scope 내에서만 값을 변경 가능합니다.
&lt;ul id=&quot;ee8cba08-d610-45a5-9b5c-73dfa1cf1924&quot; style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: square;&quot;&gt;이는 TaskLocal 변수가 동시성 환경에서 Thread-safe하게 만듦니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;ul id=&quot;0e3e44a2-9e2c-4769-ae05-090b0ed1f595&quot; style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: circle;&quot;&gt;단, 상속된 Task의 Scope 내에서는 부모 Task의 TaskLocal 값을 상속받습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;ul id=&quot;0180ba76-8fd6-4530-b338-58623e716711&quot; style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: circle;&quot;&gt;하지만, 일반적으로 task local은 escaping closure 범위를 넘어설 때마다 오버라이드를 잃습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;ul id=&quot;562b6659-8680-4722-8edc-dd4f6a1a1e59&quot; style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: circle;&quot;&gt;아래 예시 코드 처럼 withValue로 오버라이드한 값이 asyncAfter 클로저 내에서 다시 1로 돌아가는 것을 볼 수 있습니다.
&lt;pre id=&quot;88c0195a-6ca0-4250-a633-0f481a043f66&quot; class=&quot;reasonml&quot; style=&quot;background-color: #f5f2f0; color: #000000; text-align: left;&quot;&gt;&lt;code&gt;print(Locals.value) // 1
Locals.$value.withValue(42) {
	print(Locals.value) // 42
	DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
	  print(Locals.value) // 1
	}
	print(Locals.value) // 42
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;ul id=&quot;183a4ddb-9236-4e48-9d91-3dd71a8ba479&quot; style=&quot;list-style-type: disc; color: #37352f; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;결론적으로, Swift는 보편적이진 않지만, task local 변수들을 특정 escaping, 비구조적 컨텍스트로 전파시키기 위해서는 추가적인 작업을 해야합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;89cbad0e-64c2-40d7-b4d9-30e0e71ae098&quot; style=&quot;color: #37352f; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;How @Dependency lifetimes work&lt;/h2&gt;
&lt;ul id=&quot;cec6ce7a-c67d-4c8f-a8aa-de5b85b1aa10&quot; style=&quot;list-style-type: disc; color: #37352f; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;이제 task local 변수들이 어떻게 작동하는지 알았으니, @Dependency의 생명주기를 이해할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;ul id=&quot;146dd284-1e21-4ed0-94a4-6ec651d7172f&quot; style=&quot;list-style-type: disc; color: #37352f; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;dependencies는 @TaskLocal로서 유지되고, 많은 task locals의 규칙 또한 dependencies에 적용됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;ul id=&quot;68938055-e8e2-4066-bc44-9076b517712b&quot; style=&quot;list-style-type: disc; color: #37352f; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;예를 들어 dependencies들은 tasks에서 상속되지만, 일반적으로 escaping 경계를 넘지는 않습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;ul id=&quot;1c5a48d5-8e54-48b2-92d7-d0a9c37bdce3&quot; style=&quot;list-style-type: disc; color: #37352f; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;하지만 몇가지 주의할 사항이 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;ul id=&quot;a93f0e09-26df-4649-bd8c-d43eb2382b95&quot; style=&quot;list-style-type: disc; color: #37352f; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;task local과 마찬가지로, dependency의 값은 withDependencies의 trailing, non-escaping 클로저내에서 변경될 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;ul id=&quot;6ae829b7-8b1c-43f1-90fd-4467a77d56dc&quot; style=&quot;list-style-type: disc; color: #37352f; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;하지만 라이브러리는 잘 정의된 방식으로 변경을 연장할 수 있는 몇가지 방법을 제공합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;ul id=&quot;db08b6ac-19fa-4c1d-9688-fe6614f64fe4&quot; style=&quot;list-style-type: disc; color: #37352f; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;예를 들어, 사용자 정보를 가져오기 위한 API 클라이언트에 액세스한다고 가정해봅시다.
&lt;pre id=&quot;416662b1-d17f-4fae-8567-b23b90bfd2bc&quot; class=&quot;swift&quot; style=&quot;background-color: #f5f2f0; color: #000000; text-align: left;&quot;&gt;&lt;code&gt;class FeatureModel: ObservableObject {
  @Dependency(\.apiClient) var apiClient


  func onAppear() async {
    do {
      self.user = try await self.apiClient.fetchUser()
    } catch {}
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;ul id=&quot;9212e662-80e1-4581-be4b-c8bf3123c2bf&quot; style=&quot;list-style-type: disc; color: #37352f; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;때로는 apiClient의 다른 구현을 사용하는 통제된 환경에서 이 모델을 구성하고 싶을 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;ul id=&quot;caf5f2b5-4e26-4209-ab57-9945fd4ed08b&quot; style=&quot;list-style-type: disc; color: #37352f; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;아마 테스트가 대부분 이러한 예시일 것입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;ul id=&quot;a3c1dbba-d8d8-41d3-ad5b-2e77ad1451a8&quot; style=&quot;list-style-type: disc; color: #37352f; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;테스트에서, 우리는 외부 세계의 모호한 상황에 노출되기 때문에 라이브 네트워크 요청을 만들고 싶지 않습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;ul id=&quot;c77786ab-700b-44a6-9677-3db590c065f3&quot; style=&quot;list-style-type: disc; color: #37352f; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;대신 우리는 데이터가 어떻게 우리의 기능 로직에 흐르는지 테스트하고 싶기에, 동기적이고, 즉각적으로 데이터를 리턴시키도록 구현을 제공하고 싶습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;ul id=&quot;80cdbeef-ae0c-44b2-be97-3832302e5b7b&quot; style=&quot;list-style-type: disc; color: #37352f; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;라이브러리에서 이를 수행하기 위한 helper가 제공되며 이를WithDependencies(_:operation:) 라 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;ul id=&quot;6d30664f-3b94-4408-b1ba-22ffe553dad8&quot; style=&quot;list-style-type: disc; color: #37352f; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;이는 두가지 클로저를 취하는데, 첫번째는 원하는 dependencies를 오버라이드 가능하게 하는 것이고, 두번째는 그 변환된 dependencies가 적용된 스코프에서 기능 로직이 실행되도록 하는 것입니다.
&lt;pre id=&quot;6a64b7dc-ff00-4b28-b2be-84a22aa68ca2&quot; class=&quot;reasonml&quot; style=&quot;background-color: #f5f2f0; color: #000000; text-align: left;&quot;&gt;&lt;code&gt;func testOnAppear() async {
  await withDependencies {
    $0.apiClient.fetchUser = { _ in User(id: 42, name: &quot;Blob&quot;) }
  } operation: {
    let model = FeatureModel()
    XCTAssertEqual(model.user, nil)
    await model.onAppear()
    XCTAssertEqual(model.user, User(id: 42, name: &quot;Blob&quot;))
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;ul id=&quot;306b3831-f82c-426b-80b4-a286d164d6ba&quot; style=&quot;list-style-type: disc; color: #37352f; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;그래서 위의 예시에서 모든 operation 클로저는 실제 네트워크 요청 없이 기능 코드 실행이 가능케 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;ul id=&quot;2689c512-2bc6-491a-8384-2914177ccee8&quot; style=&quot;list-style-type: disc; color: #37352f; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;한단계 더 나아가, operation 후행 클로저 범위에서 전체 테스트를 실행할 필요가 없습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;ul id=&quot;c89dbf84-1504-44f0-8dfe-b26611626d5f&quot; style=&quot;list-style-type: disc; color: #37352f; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;해당 스코프에서 모델을 구성하기만 하면 되고, 모든 dependencies들이 FeatureModel 내의 인스턴스 변수로 선언되어있는 한, 모델과의 모든 상호 작용은 클로저 외부에서도 제어된 종속성을 사용합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p id=&quot;822f802b-dc39-4233-b838-050fe982f2ba&quot; style=&quot;color: #37352f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p id=&quot;0fc79916-ba00-49f2-8b74-7b4c693e0009&quot; style=&quot;color: #37352f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;중략&amp;hellip;&lt;/p&gt;
&lt;p id=&quot;abfe1db7-708c-433c-a665-5a73ff34fe5c&quot; style=&quot;color: #37352f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul id=&quot;15aa8235-91ad-4fee-9fd7-a8ccc0e2ccef&quot; style=&quot;list-style-type: disc; color: #37352f; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;그러나, 자식 모델을 부모 모델로부터 생성할 때는 주의해야 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;ul id=&quot;aa6ff9d9-3cf0-44c8-ae4f-ea266be9cbec&quot; style=&quot;list-style-type: disc; color: #37352f; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;부모의 의존성으로부터 자식의 의존성이 상속되기 때문에, 자식 모델을 생성할 때 반드시 withDependencies(from:operation:file:line:) 을 사용해야 합니다.
&lt;pre id=&quot;847053eb-acdc-4579-9bb1-a934259b3118&quot; class=&quot;reasonml&quot; style=&quot;background-color: #f5f2f0; color: #000000; text-align: left;&quot;&gt;&lt;code&gt;let onboardingModel = withDependencies(from: self) {
  $0.apiClient = .mock
} operation: {
  FeatureModel()
}&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p id=&quot;b0f19d9b-7711-4f6f-b57b-866c29099d10&quot; style=&quot;color: #37352f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul id=&quot;edc911f6-751f-45b4-9e67-0d11cee98a3f&quot; style=&quot;list-style-type: disc; color: #37352f; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;일반적으로, 만약 앱의 매 기능 계층마다 적절하게 의존성들이 상속되는 것을 원한다면, 어떠한 ObservableObject 모델들은 withDependencies(from:operation:file:line) 내에서 생성해야 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;ul id=&quot;d00a266f-6b8b-4fb1-ab95-79016e5b0453&quot; style=&quot;list-style-type: disc; color: #37352f; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;Dependencies는 이미 previewValue 라는 개념을 지원하고 있기에, 이렇게 된다면 또한 매우 특정한 환경에서 프리뷰를 실행할 수 있게됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p id=&quot;fd74afcf-8184-4bf1-b313-b726be5ba705&quot; style=&quot;color: #37352f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;hellip; 중략&amp;hellip;&lt;/p&gt;
&lt;ul id=&quot;fd684566-5c87-4ea6-8088-ff55ed8bf1c6&quot; style=&quot;list-style-type: disc; color: #37352f; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;때떄로 매우 특정한 state에서 기능이 어떻게 동작하는지 보기 위해 의존성을 커스터마이징하고 싶을 수 있습니다. 예를 들어 만약, fetchUser 엔드포인트가 에러를 내뱉을 때, 어떻게되는지 보기 위해 프리뷰를 다음과 같이 업데이트 할 수 있습니다:
&lt;pre id=&quot;58873209-8667-4e55-bb0f-5a216d1bcac2&quot; class=&quot;swift&quot; style=&quot;background-color: #f5f2f0; color: #000000; text-align: left;&quot;&gt;&lt;code&gt;struct Feature_Previews: PreviewProvider {
  static var previews: some View {
    FeatureView(
      model: withDependencies {
        $0.apiClient.fetchUser = { _ in
          struct SomeError: Error {}
          throw SomeError()
        }
      } operation: {
        FeatureModel()
      }
    )
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h1 id=&quot;53395509-1f36-4ff9-aa4b-103024acaa76&quot; style=&quot;color: #37352f; text-align: start;&quot;&gt;Accessing a @Dependency from pre-structured concurrency&lt;/h1&gt;
&lt;ul id=&quot;a7da9d68-3245-45d8-b6f6-6c2efa44c1a0&quot; style=&quot;list-style-type: disc; color: #37352f; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;의존성들은 task local 내에서 점유되기에, 오직 자동적으로 structured concurrency와 Task 내에 자동적으로 전파됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;ul id=&quot;ef07e275-ab91-4735-9f66-d32e25d2e3c2&quot; style=&quot;list-style-type: disc; color: #37352f; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;escaping 클로저 너머 의존성들에 접근하기 위해서 (예를 들어 콜백이나 Combine 연산자) closure 안으로 전파될 수 있도록 추가적인 escape 작업을 의존성들에 해주어야 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;ul id=&quot;f4687fad-6224-4b75-bcf1-1590c41f0360&quot; style=&quot;list-style-type: disc; color: #37352f; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;예를 들어 특정 작업을 딜레이 시키기 위해 DispatchQueue.main.asyncAfter 를 사용한다고 가정하고, 해당 로직이 의존성을 사용한다고 했을 때. 해당 의존성이 의존성이 escaping 클로저내에서 올바른 값으로 반영될 수 있도록, withEscapedDependencies(_:) 를 사용해야 합니다.
&lt;pre id=&quot;bab0aa2a-e0fc-4fb9-a9a6-738e660407f3&quot; class=&quot;reasonml&quot; style=&quot;background-color: #f5f2f0; color: #000000; text-align: left;&quot;&gt;&lt;code&gt;withEscapedDependencies { dependencies in
  DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
    dependencies.yield {
      // All code in here will use dependencies at the time of calling withEscapedDependencies.
    }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p id=&quot;056e7de8-cf0e-4794-bb5a-9dbab8381202&quot; style=&quot;color: #37352f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote id=&quot;05d5f768-14ab-45a5-b563-3f87e6e07b58&quot; style=&quot;color: #37352f; text-align: start;&quot; data-ke-style=&quot;style1&quot;&gt;출처: &lt;a href=&quot;https://pointfreeco.github.io/swift-dependencies/main/documentation/dependencies/lifetimes/&quot;&gt;https://pointfreeco.github.io/swift-dependencies/main/documentation/dependencies/lifetimes/&lt;/a&gt;&lt;/blockquote&gt;</description>
      <category>Programming/SwiftUI</category>
      <author>loinsir</author>
      <guid isPermaLink="true">https://glassgow.tistory.com/49</guid>
      <comments>https://glassgow.tistory.com/49#entry49comment</comments>
      <pubDate>Thu, 13 Jun 2024 01:31:25 +0900</pubDate>
    </item>
    <item>
      <title>[후기] 네이버 부스트캠프 웹・모바일 8기 멤버십 수료 후기</title>
      <link>https://glassgow.tistory.com/48</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;804&quot; data-origin-height=&quot;408&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cwWAf7/btsGkuFD5L4/cGU1rUp0ztSPEGrwuMULXK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cwWAf7/btsGkuFD5L4/cGU1rUp0ztSPEGrwuMULXK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cwWAf7/btsGkuFD5L4/cGU1rUp0ztSPEGrwuMULXK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcwWAf7%2FbtsGkuFD5L4%2FcGU1rUp0ztSPEGrwuMULXK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;804&quot; height=&quot;408&quot; data-origin-width=&quot;804&quot; data-origin-height=&quot;408&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수료는 12월에 했지만 여러 바쁜 개인 사정과 취준으로 인해 이제야 후기를 작성한다.&lt;/p&gt;
&lt;h1&gt;멤버십 일정&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멤버십 초반 8주간은 미션 기반 프로젝트를 수행하고, 6주간은 팀 프로젝트로 이루어진다.&lt;/p&gt;
&lt;h1&gt;미션 주간&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;미션은 2주에 1개 미션씩 공개되며, 미션이 공개된 차주는 전주에 공개된 미션의 추가적인 구현 요구사항이 공개된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 초반 8주간의 미션 기반 프로젝트는 정말 재밌었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 기억에 남는 미션은 아이패드 드로잉 앱을 만드는 미션이었는데, 뷰 계층과 좌표 시스템 개념과 객체지향 개념 등을 함께 동원하여 미션을 완성해 나갔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 설계가 중요했던 미션이었는데, 추가적으로 미션이 진행되며, 확장성있는 구조를 짜는 방법을 계속해서 고민해야했다. 그리고 ViewController의 비대함을 줄이기 위한 여러가지 방법도 구상했던 미션이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 매 아침에 구현 진행상황을 그룹과 줌으로 공유하면서 피드백을 주고받으면서 많이 배웠던 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 미션의 마지막엔 동료와 페어프로그래밍을 진행하게 되는데, 특이한 경험이었다. 손발이 묶인채 코딩을 하면서 동료 캠퍼와 밤늦게까지 고민하며 미션을 수행했다. 현업에서도 간혹 이러한 방식으로 페어프로그래밍을 진행한다고 하는데, 미리 어떤 느낌인지 체험할 수 있어서 재밌었었다.&lt;/p&gt;
&lt;h1&gt;멤버십의 꽃, 팀 프로젝트&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;8주간의 미션 주간이 끝나고 1주간의 인터미션(휴식?) 기간이 주어진 후, 6주동안 팀 프로젝트를 수행하게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아마 미션주간 마지막 2주 전 즈음에 슬랙을 통해 프로젝트 팀빌딩 관련 공지가 나갔었던 걸로 기억하는데, 과열 아닌 과열? 적인 분위기로 인해 슬랙에 팀원 모집 글이 많이 올라왔던 것으로 기억한다. 아마 이번 부캠 8기가 처음으로 서버와 클라이언트가 함께 팀 프로젝트를 수행하는 것으로 인해 많은 사람들이 조급함으로 인해 그러지 않았나 싶다는 얘기가 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 난 팀원 구하는 데 딱히 조급함은 없었지만, 대부분의 사람들이 팀원을 구했다고 한 시점에 먼저 팀빌딩 여부를 물어봐주신 분을 통해 팀에 합류를 하게 되었다.(팀원 제안해주셔서 정말 감사합니다ㅠ)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;팀원분들이 다 좋으신 분들이어서 정말 재밌게 팀 프로젝트를 진행할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;팀프로젝트 일정은 다음과 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1주차: 이 때 프로젝트 주제 선정과, 디자인, 개발 사항 명세 작성 등을 끝내야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2~5주차: 기능 구현&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;6주차: 리팩토링, 산출물 정리 등&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 팀은 동영상 관련 주제를 하기로 하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위치 기반으로, 특정 위치 주변에 영상을 유튜브 숏츠나 인스타 릴스 처럼 보여주는 방식의 앱을 개발하기로 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇게 탄생한 앱이 Layover다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;1170&quot; data-origin-height=&quot;2532&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/StloR/btsGkCjd0gR/9PKZKfckg8WiQI1mQrQy30/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/StloR/btsGkCjd0gR/9PKZKfckg8WiQI1mQrQy30/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/StloR/btsGkCjd0gR/9PKZKfckg8WiQI1mQrQy30/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FStloR%2FbtsGkCjd0gR%2F9PKZKfckg8WiQI1mQrQy30%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;210&quot; height=&quot;454&quot; data-filename=&quot;1.png&quot; data-origin-width=&quot;1170&quot; data-origin-height=&quot;2532&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/boostcampwm2023/iOS09-Layover&quot;&gt;https://github.com/boostcampwm2023/iOS09-Layover&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1712065079978&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - boostcampwm2023/iOS09-Layover: 내 여정의 경유지들을 기록하다 &amp;zwj; &amp;zwj; &amp;zwj;  - 위치기&quot; data-og-description=&quot;내 여정의 경유지들을 기록하다 &amp;zwj; &amp;zwj; &amp;zwj;  - 위치기반 숏폼 플랫폼. Contribute to boostcampwm2023/iOS09-Layover development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/boostcampwm2023/iOS09-Layover&quot; data-og-url=&quot;https://github.com/boostcampwm2023/iOS09-Layover&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cJaDNv/hyVJRyzwg6/eEy9RedZm1UGjYAFEZDLb0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/boostcampwm2023/iOS09-Layover&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/boostcampwm2023/iOS09-Layover&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cJaDNv/hyVJRyzwg6/eEy9RedZm1UGjYAFEZDLb0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - boostcampwm2023/iOS09-Layover: 내 여정의 경유지들을 기록하다 &amp;zwj; &amp;zwj; &amp;zwj;  - 위치기&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;내 여정의 경유지들을 기록하다 &amp;zwj; &amp;zwj; &amp;zwj;  - 위치기반 숏폼 플랫폼. Contribute to boostcampwm2023/iOS09-Layover development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 팀에 디자인 능력자 분이 계셔서 멋진 앱 디자인을 낼 수 있었다.(정말 감사합니다 ㅠㅠ)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1주차에는 팀원들이 모두 대전에서 모여서 기획 회의를 했다. 에어비앤비를 하루 빌려서 어느정도 와이어 프레임과 디자인을 잡는 방향으로 진행헀다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;2.png&quot; data-origin-width=&quot;4032&quot; data-origin-height=&quot;3024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bRMmlX/btsGjQ3vqnQ/9WfyTmfyRsINuzKh4Cl30k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bRMmlX/btsGjQ3vqnQ/9WfyTmfyRsINuzKh4Cl30k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bRMmlX/btsGjQ3vqnQ/9WfyTmfyRsINuzKh4Cl30k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbRMmlX%2FbtsGjQ3vqnQ%2F9WfyTmfyRsINuzKh4Cl30k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;450&quot; data-filename=&quot;2.png&quot; data-origin-width=&quot;4032&quot; data-origin-height=&quot;3024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구현 주간에는 여러 일이 있었다. 앱 구현을 어떤 방식으로 나눠서 개발할 지, 협업의 방식에 대한 고민을 했다. 처음엔 단순히 화면 별로 나눠서 구현합시다! 라고 했지만, 그렇게 되면 서로의 개발 도메인을 이해하지 못하는 부분이 생길 수 있다는 마스터의 말씀이 있었다. 그래서 우리 팀은 유저의 유스케이스를 기준으로 나눠 구현하도록 정했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어, 유저가 동영상을 업로드 하는 부분이 있다면 업로드 하는 과정의 로직을 맡아 개발하는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;깃 충돌도 꽤 발생하는 부분이 있었지만, 각자의 코드 오너십을 줄일 수 있다는 장점이 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중간에 팀원들과 부산 광안리에 모여, 숙소에서 바다를 보며 작업도 했었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;3.png&quot; data-origin-width=&quot;4032&quot; data-origin-height=&quot;3024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b9YokK/btsGhVStXN6/cGBjYhdn1MQv1mCtGHUAYk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b9YokK/btsGhVStXN6/cGBjYhdn1MQv1mCtGHUAYk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b9YokK/btsGhVStXN6/cGBjYhdn1MQv1mCtGHUAYk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb9YokK%2FbtsGhVStXN6%2FcGBjYhdn1MQv1mCtGHUAYk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;700&quot; height=&quot;525&quot; data-filename=&quot;3.png&quot; data-origin-width=&quot;4032&quot; data-origin-height=&quot;3024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 막바지 까지 꽤나 잠을 줄여 가면서 열심히 했었다. 금요일과 토요일을 제외하면 거의 하루에 2~3시간 정도만 자면서 구현을 해 나갔다. 이렇게 했던 이유는 나중에 후회가 없이 프로젝트를 하고 싶어서였다. 유튜버 장사의 신 님이 말하길 &amp;ldquo;쉬지마, 20대 때는 안쉬는 거야&amp;rdquo;. 하지만, 이는 나중에 큰 독이 되었다. 프로젝트가 끝난 후 갑작스레 몸이 안좋아져 1, 2월 간 병원 신세를 졌다 ㅠㅠ. 생각해보니 장신님도 지금 잔병치레 하시지 않나&amp;hellip;?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여차저차 구현 주간 막바지에는 테스트 코드를 작성했다. 테스트하기 좋은 아키텍처를 택한 만큼 테스트 코드를 작성해야 의미있다는 생각이었기 때문이다. 또, 부스트캠프에서 배운 큰 것 중 하나가 테스트코드이기도 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;부스트캠프 마지막에는 최종 발표가 기다리고 있었다. 최종 발표에는 모든 멘토님들이 참석하셔서 꽤나 긴장되었다&amp;hellip;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;6주 동안의 결과물이 평가아닌 평가를 받게 된다고 생각하니 새가슴이 되었다.&lt;/p&gt;
&lt;h1&gt;공식 일정 이후&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 팀은 남은 작업들을 하기로 했다. UX적으로 미흡했던 부분을 보완하거나, 미처 작성하지 못한 테스트 코드 작성, 성능 개선, 출시를 남은 목표로 잡았다. 나는 주로 시청했던 동영상 캐싱 작업을 맡았다. 여러가지 캐싱 방식을 고려하다가 Styleshare사에서 개발했던, 앱 내부에 캐시용 프록시 서버를 띄워 캐싱하는 방식의 라이브러리를 알았고, 나도 이를 발전시켜 오픈소스 라이브러리로 배포하면 좋을 것 같다 생각하여 라이브러리로 만들었고, 앱에 적용했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/loinsir/HLSCachingServer&quot;&gt;https://github.com/loinsir/HLSCachingServer&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1712065349499&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - loinsir/HLSCachingServer: A simple, lightweight HLS caching server written in Swift.&quot; data-og-description=&quot;A simple, lightweight HLS caching server written in Swift. - loinsir/HLSCachingServer&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/loinsir/HLSCachingServer&quot; data-og-url=&quot;https://github.com/loinsir/HLSCachingServer&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/1VPvq/hyVGPJbPEv/KaX0vtnB3w5bKoIKHLMFb1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/loinsir/HLSCachingServer&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/loinsir/HLSCachingServer&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/1VPvq/hyVGPJbPEv/KaX0vtnB3w5bKoIKHLMFb1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - loinsir/HLSCachingServer: A simple, lightweight HLS caching server written in Swift.&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;A simple, lightweight HLS caching server written in Swift. - loinsir/HLSCachingServer&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;h1&gt;진짜 끝&amp;hellip;?&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트가 마무리 되고 돌이켜 보면, 다른 팀원들에게 너무 부족한 팀원이었던 것 같다. 욕심이 너무 앞서서 팀원들에게 부담을 준 것 같기도 하고, 여러모로 팀원들에게 미안한 감정이 크다... 그래도 팀원들이 너무 재밌고 좋으신 분들이어서 큰 복이었다고 생각한다. 좋은 추억으로 즐겁게 부캠을 수료할 수 있게 해준 팀원 여러분 정말 여러모로 감사합니다..!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;궁금하신 점은 댓글 달아주시면 빠르게 답변 해드리겠습니다:)&lt;/p&gt;</description>
      <category>후기/지원후기</category>
      <author>loinsir</author>
      <guid isPermaLink="true">https://glassgow.tistory.com/48</guid>
      <comments>https://glassgow.tistory.com/48#entry48comment</comments>
      <pubDate>Tue, 2 Apr 2024 22:33:24 +0900</pubDate>
    </item>
  </channel>
</rss>