[SwiftUI] 침하하 앱을 만들어보자 Step 4, 4.5

팔랑이·2026년 3월 3일

iOS/Swift

목록 보기
87/89

SwiftUI 침하하 앱 개발기


오늘의 결과물

Step 4. Home Feed (Post List)

처음으로 실제 네트워크 요청이 TCA 흐름에 붙는 단계다.
Reducer에서 비동기 Effect를 다루는 패턴, 부모-자식 Reducer 합성, SwiftUI 리스트 레이아웃을 한 번에 다뤘다.

일단 본격적인 시작 전에, UIKit에서는 없었던 (정확히는 필요가 없었던) some에 대해서 정리하고 넘어가야 할 것 같다.


some

: 불투명 반환 타입(Opaque Return Type)으로, "특정 타입이긴 하지만 구체적인 타입은 감춘다" 라고 표시할 때 사용한다.

왜 필요한가?

SwiftUI의 View는 클래스가 아니라 프로토콜이다.
UIKit의 UIView는 클래스라서 반환 타입으로 바로 사용할 수 있었지만, 프로토콜은 그대로 반환 타입으로 사용할 수 없다.

// UIKit — UIView는 클래스라서 OK
func makeView() -> UIView {
    return UILabel()
}

// SwiftUI — View는 프로토콜이라 이렇게 못 씀
var body: View { ... }       // 컴파일 에러
var body: any View { ... }   // 가능하지만 타입 정보가 런타임에 지워짐
var body: some View { ... }  // OK

some의 의미

예를 들어, some View는
“View를 준수하는 구체적인 타입 중 하나인데, 구체 타입은 감추고 컴파일러만 알고 있다”는 뜻이다.

var body: some View {
    Text("Hello")   // 실제 타입은 Text
}

여기서 반환 타입은 매번 동일한 하나의 구체 타입이어야 하고, 조건문에서 서로 다른 타입을 직접 반환하면 컴파일 에러가 발생한다

ex) Text, Image 둘 다 View를 준수하지만 컴파일 에러가 난다.

var body: some View {
    if isLoggedIn {
        Text("환영합니다")
    } else {
        Image(systemName: "person")
    }
}

단, VStack, HStack 등으로 감싸면 반환 타입이 VStack, HStack 그 자체가 되므로 가능함.

var body: some View {
    VStack {
        if isLoggedIn {
            Text("환영합니다")
        } else {
            Image(systemName: "person")
        }
    }
}

만약 any를 사용한다면...

let view1: any View = Text("Hello")
let view2: any View = Image(systemName: "star")

이렇게 any를 사용하면, 컴파일 시점에는 둘 다 any View가 되어 이게 텍스트인지 이미지인지 알 수가 없다. 런타임에서 내부 타입을 확인하고 동적으로 처리해야 하는데, 이렇게 되면 성능 저하가 발생함

따라서 some을 사용해서 컴파일 시점에 타입 정보를 적극적으로 활용하여 최적화 하는것!


@Dependency — 의존성 주입

TCA에서는 외부 의존성(네트워크, DB 등)을 DependencyValues에 등록하고 @Dependency로 꺼내 쓴다.
Swinject 같은 별도 DI 컨테이너 없이 TCA 자체 시스템으로 DI를 해결한다.

// 1. DependencyKey 등록
private enum PostRepositoryKey: DependencyKey {
    static let liveValue: any PostRepository = PostRepositoryImpl()
}

extension DependencyValues {
    var postRepository: any PostRepository {
        get { self[PostRepositoryKey.self] }
        set { self[PostRepositoryKey.self] = newValue }
    }
}

// 2. Reducer에서 사용
@Dependency(\.postRepository) var postRepository

테스트할 때 withDependencies { $0.postRepository = MockRepo() } 한 줄로 mock 교체가 가능해서 깔끔하다.

Sendable — Swift Concurrency의 타입 안전성

Sendable은 "이 타입은 여러 스레드에서 동시에 접근해도 안전하다"는 계약이다.
Swift Concurrency에서 actor나 async context를 넘나드는 타입에 요구된다.

PostRepository 프로토콜에 : Sendable을 추가하면,
TCA의 @Dependency가 protocol existential(any PostRepository)을
Concurrency-safe하게 다룰 수 있다.

Equatable — State 비교를 통한 렌더링 최적화

Equatable을 채택하면 == 비교가 가능해진다.
TCA가 State에 이걸 요구하는 이유는 이전 State와 새 State가 같으면 View를 다시 그리지 않기 위해서다.


Effect.run — Reducer에서 비동기 작업

Reducer는 순수 함수여야 한다.
비동기 작업(네트워크 등)은 직접 실행할 수 없고, 반드시 Effect로 감싸서 반환해야 한다.

case .onAppear:
    state.isLoading = true
    return .run { send in  // async 클로저 시작
        do {
            let posts = try await postRepository.fetchPosts()
            await send(.postsResponse(.success(posts)))  // 결과를 Action으로 돌려보냄
        } catch {
            await send(.postsResponse(.failure(error)))
        }
    }

.run { send in } 내부는 async 클로저라서 await을 쓸 수 있다.
완료되면 send()로 Action을 다시 Reducer에 쏜다.
"비동기 작업 → 결과를 Action으로 돌려보내기" 패턴이 TCA 비동기의 핵심이다.


부모-자식 Reducer 합성

앱이 커지면 Reducer를 기능별로 나눈다. 부모 Reducer가 자식 Reducer를 품는 게 TCA의 합성 패턴이다.

// AppReducer에서 HomeReducer를 자식으로 연결
var body: some ReducerOf<Self> {
    Scope(state: \.home, action: \.home) {
        HomeReducer()
    }
    Reduce { state, action in ... }
}

Scope가 없으면 Action이 날아와도 HomeReducer가 실행되지 않는다.
View에서는 store.scope로 부모 Store를 자식 Store로 좁혀서 넘긴다.

HomeView(store: store.scope(state: \.home, action: \.home))

LazyVStack vs List

List는 시스템 스타일이 강해서 배경색, separator, 셀 padding 커스텀이 번거롭다.
커스텀 디자인이 필요할 때는 ScrollView + LazyVStack이 낫다고 한다.

ScrollView {
    LazyVStack(spacing: 0) {
        ForEach(posts) { post in
            PostRowView(post: post)
        }
    }
}

Lazy가 붙으면 화면에 보이는 셀만 생성한다.
일반 VStack은 모든 셀을 한 번에 만들어서 목록이 길면 성능이 나빠진다.


AsyncImage — URL 이미지 비동기 로딩

AsyncImage(url: post.imageURL) { image in
    image.resizable().scaledToFill()
} placeholder: {
    Color("chimSurface2")
}
.frame(width: 72, height: 72)
.clipped()

Kingfisher가 하던 역할을 SwiftUI 내장 뷰로 해결한다. 캐싱은 URLCache 기반이라 기본 캐시는 자동으로 된다고 한다.


TCA TestStore — 단위 테스트 패턴

@MainActor
struct HomeReducerTests {

    @Test
    func onAppear_성공시_포스트_로드() async {
        let store = TestStore(initialState: HomeReducer.State()) {
            HomeReducer()
        } withDependencies: {
            $0.postRepository = MockPostRepository(posts: [mockPost])  // mock 교체
        }

        await store.send(.onAppear) {
            $0.isLoading = true  // Action 이후 기대 State
        }

        await store.receive(\.postsResponse) {  // Effect가 보낸 Action 수신
            $0.isLoading = false
            $0.posts = [mockPost]
        }
    }
}
  • withDependencies — 의존성 주입
  • send 클로저 — Action 직후 State 변화 단언
  • receive — Effect가 쏜 Action을 캐치해서 검증
  • @MainActor — TCA TestStore는 메인 스레드에서 실행해야 함

Step 4.5. DummyJSON 마이그레이션

초기에 구현했던 JSONPlaceholder는 좋아요·조회수·작성자명 등이 없어서 DummyJSON으로 교체했다.

Nested JSON 디코딩

DummyJSON은 좋아요/싫어요를 reactions 객체 안에 담아서 보내고, 댓글 작성자를 user 객체 안에 담아서 보낸다.

{ "reactions": { "likes": 192, "dislikes": 25 } }
{ "user": { "id": 121, "fullName": "Terry Medhurst" } }

이런 nested object는 파싱용 private struct를 만들어서 한 번 받은 뒤 필요한 값만 꺼낸다.

private struct Reactions: Decodable {
    let likes: Int
    let dislikes: Int
}

let reactions = try c.decodeIfPresent(Reactions.self, forKey: .reactions)
likeCount = reactions?.likes

Reactions네트워크 응답 파싱에만 쓰이는 일회용 타입이라 private으로 숨긴다.
바깥 모델(Post)은 깔끔하게 유지된다.


API 래퍼 타입 처리

JSONPlaceholder는 [Post] 배열을 바로 반환했지만, DummyJSON은 페이지네이션 정보와 함께 래핑해서 반환한다.

{ "posts": [...], "total": 251, "skip": 0, "limit": 30 }

[Post]를 바로 디코딩하면 실패한다. Repository 함수 안에 private 래퍼 타입을 선언해서 해결한다.

func fetchPosts() async throws -> [Post] {
    struct Response: Decodable { let posts: [Post] }
    let response: Response = try await client.fetch(.posts)
    return response.posts
}

타입을 함수 안에 선언하면 외부에 노출되지 않는다.
이 패턴은 "매핑 로직을 Repository 레이어 안에서 흡수"하는 것으로,
Domain 모델과 네트워크 응답 구조를 분리하는 핵심이다.


decode vs decodeIfPresent

// 필드가 반드시 존재해야 할 때 — 없으면 throw
let title = try c.decode(String.self, forKey: .title)

// 필드가 없을 수도 있을 때 — 없으면 nil
let views = try c.decodeIfPresent(Int.self, forKey: .views)

API 응답에서 없을 수도 있는 필드는 decodeIfPresent를 써야 한다.
decode를 썼을 때 해당 키가 없으면 디코딩 에러가 난다.

profile
정체되지 않는 성장

0개의 댓글