SwiftUI 침하하 앱 개발기
오늘의 결과물

처음으로 실제 네트워크 요청이 TCA 흐름에 붙는 단계다.
Reducer에서 비동기 Effect를 다루는 패턴, 부모-자식 Reducer 합성, SwiftUI 리스트 레이아웃을 한 번에 다뤘다.
일단 본격적인 시작 전에, UIKit에서는 없었던 (정확히는 필요가 없었던) 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 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")
}
}
}
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를 품는 게 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 ListList는 시스템 스타일이 강해서 배경색, 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 기반이라 기본 캐시는 자동으로 된다고 한다.
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는 메인 스레드에서 실행해야 함초기에 구현했던 JSONPlaceholder는 좋아요·조회수·작성자명 등이 없어서 DummyJSON으로 교체했다.
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)은 깔끔하게 유지된다.
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를 썼을 때 해당 키가 없으면 디코딩 에러가 난다.