포스트

Final (Fin)

프로젝트 막바지에는 기능 구현에 쫓겨 블로그 정리를 제대로 하지 못했다. 그래서 이 글은 당시 GitHub 커밋 내역과, 프로젝트 기간에 직접 적어 둔 업무 내역 목록을 AI(Claude Code)에게 보여주고 그걸 바탕으로 내용을 다시 정리했다. 코드는 실제 커밋에 있던 것을 가져왔다.

마지막 작업은 추천 탭의 로딩 속도 개선이다. 이 코드는 계정 정지로 그때 올리지 못했고, 10월 4일에 올린 upload 커밋에 들어 있다.


추천페이지 성능 개선

앱을 켜면 처음 보이는 추천 탭이 뜨는 데 오래 걸린다는 피드백이 있었다. 추천 탭은 진문님이 만든 화면이라, 진문님의 동의를 받고 코드를 보던 중에 문제점을 발견했다.


기존 방식

추천 탭은 카드를 넘기며 떡볶이집을 소개하고, 카드를 누르면 상세 페이지에서 긴 설명과 사진 4장을 보여준다. 기존에는 앱을 시작할 때 이 모든 데이터를 한꺼번에 불러왔다.

  1. Firebase 의 추천 collection 에서 document 를 전부 가져온다
  2. document 마다 카드에 필요한 제목, 설명, 메인 이미지뿐 아니라 상세 페이지에 쓸 긴 설명 2개, 주소, 사진 4장까지 모두 읽어서 메모리에 저장한다
  3. 사진은 Storage 의 gs:// 주소로 저장되어 있어서, 화면에 띄우려면 실제로 받을 수 있는 https:// 주소로 바꿔야 한다. 이걸 document 마다 5번(메인 1장, 상세 4장) 한다
  4. 모든 document 가 끝나야 카드 목록을 화면에 넘긴다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public func fetchData() async {
    let cardRef = db.collection("recommendMain")
    do {
        let querySnapshot = try await cardRef.getDocuments()
        cards.removeAll()
        var fetchedCards: [Card] = []
        let cards = try await withThrowingTaskGroup(of: Card?.self) { taskGroup in
            for document in querySnapshot.documents {
                taskGroup.addTask {
                    let data = document.data()
                    // ... title, description, longDescription1, 2, shopAddress 등
                    // gs:// URL을 HTTP(S) URL로 변환
                    let imageURL = try await self.convertGSURLToHTTPURL(gsURL: imageURLString)
                    let collectionImageURL1 = try await self.convertGSURLToHTTPURL(gsURL: collectionImageURL1String)
                    let collectionImageURL2 = try await self.convertGSURLToHTTPURL(gsURL: collectionImageURL2String)
                    let collectionImageURL3 = try await self.convertGSURLToHTTPURL(gsURL: collectionImageURL3String)
                    let collectionImageURL4 = try await self.convertGSURLToHTTPURL(gsURL: collectionImageURL4String)

                    await self.fetchBookmarkStatus(title: title)

                    return Card(/* 모든 필드 */)
                }
            }
            for try await card in taskGroup {
                if let card = card {
                    fetchedCards.append(card)
                }
            }
            return fetchedCards
        }

        DispatchQueue.main.async {
            self.cards = fetchedCards.sorted(by: { $0.order < $1.order })
        }
    } catch {
        print("Error getting documents: \(error)")
    }
}

이 방식은 상세 페이지에 들어갈 때 따로 로딩이 필요 없다는 장점이 있다. 앱을 시작하면서 이미 다 받아 두었기 때문이다. 대신 화면 전환 때가 아니라 첫 화면에서 모든 페이지의 내용을 다 받으니, 처음 뜨는 데 오래 걸렸다. 사용자는 카드 몇 장만 보고 넘길 수도 있는데, 보지도 않을 상세 페이지의 사진 주소까지 미리 바꾸고 있었다.


Async await 와 TaskGroup

기존 코드도 async/await 와 withThrowingTaskGroup 을 써서 document 들을 동시에 처리하고 있었다. TaskGroup 은 여러 작업을 한꺼번에 시작하고, 결과를 모아서 돌려준다. 여러 명에게 동시에 심부름을 보내고, 모두 돌아오면 한 번에 정리하는 것과 같다.

문제는 “모두 돌아오면” 이다. 제일 늦게 돌아오는 심부름 하나 때문에 나머지가 다 기다린다. 그리고 심부름 하나하나가 무거웠다. 그래서 동시에 처리해도 첫 화면이 늦었다.


개선 방식

그래서 처음에는 카드에 보이는 내용만 받고, 상세 내용은 카드를 눌렀을 때 그 페이지 것만 받도록 나눴다.

카드에 필요한 것만 담는 모델을 따로 만들었다.

1
2
3
4
5
6
struct CardShell {
    var title: String
    var description: String
    var imageURL: String
    var order: Int
}

fetchData 는 이제 제목, 설명, 메인 이미지, 순서만 읽고, 이미지 주소 변환도 메인 이미지 한 번만 한다. 그리고 다 모아서 한 번에 넘기지 않고, 카드 하나가 준비될 때마다 바로 목록에 추가한다. 먼저 로딩을 마친 카드부터 화면에 보이는 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public func fetchData() async {
    do {
        let querySnapshot = try await recommendCollection.getDocuments()
        cards.removeAll()
        for document in querySnapshot.documents {
            let data = document.data()
            let title = data["title"] as? String ?? "No Title"
            let description = data["description"] as? String ?? "No Description"
            let imageURLString = data["imageURL"] as? String ?? ""

            let order = data["order"] as? Int ?? 0
            // gs:// URL을 HTTP(S) URL로 변환
            let imageURL = try await self.convertGSURLToHTTPURL(gsURL: imageURLString)

            await self.fetchBookmarkStatus(title: title)

            let card = CardShell(title: title, description: description, imageURL: imageURL, order: order)

            DispatchQueue.main.async {
                self.cards.append(card)
                self.cards.sort(by: { $0.order < $1.order })
            }
        }
    } catch {
        print("Error getting documents: \(error)")
    }
}

cards 는 @Published 라서, 하나가 추가될 때마다 추천 화면이 그걸 받아서 카드를 그린다.

상세 내용은 카드를 눌러서 상세 페이지가 열릴 때, 그 카드의 제목으로 document 하나만 찾아서 받는다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func getSpecificRecommendation(title: String) async {
    do {
        let querySnapshot = try await recommendCollection.whereField(db_title, isEqualTo: title).getDocuments()

        self.card.removeAll()
        for document in querySnapshot.documents {
            let data = document.data()
            // ... title, description, longDescription1, 2, shopAddress, collectionImageURL1~4
            await self.fetchBookmarkStatus(title: title)

            let card = Card(
                // ...
                imageURL: imageURLString, // URL을 그대로 사용
                collectionImageURL1: collectionImageURL1String, // URL을 그대로 사용
                // ...
                order: order
            )

            DispatchQueue.main.async {
                self.card.append(card)
            }
        }
    } catch {
        print("Error getting documents: \(error)")
    }
}
1
2
3
4
// DetailViewController
Task {
    await viewModel.getSpecificRecommendation(title: cardTitle!)
}

상세 페이지는 $card 를 구독하다가 값이 오면 화면을 채우고, 사진 주소 변환은 화면을 채우면서 한다. 받는 동안은 ProgressHUD 로 로딩을 보여준다.

1
2
3
4
5
6
7
8
9
10
11
12
private func bind() {
    ProgressHUD.animate()
    viewModel.$card
        .receive(on: DispatchQueue.main)
        .sink { [weak self] cards in
            guard let card = cards.first else { return }
            self?.configureView(with: card)
            ProgressHUD.dismiss()
        }
        .store(in: &cancellables)
    // ...
}

이렇게 하면 상세 페이지에 들어갈 때 로딩 시간이 생긴다. 하지만 처음에 모든 걸 받느라 오래 걸리는 것보다는 낫다고 판단했다. 그리고 상세 페이지는 원래도 사진을 모두 받아야 화면이 완성되는 구조라서, 들어가는 순간 받는 방식과 잘 맞았다.


결과

 기존개선
앱 시작 때 받는 것모든 카드의 모든 내용카드에 보이는 제목, 설명, 메인 이미지
이미지 주소 변환카드마다 5번카드마다 1번
화면에 보이는 시점모든 카드가 끝난 뒤카드 하나가 끝날 때마다
상세 페이지바로 열림그 카드 것만 받아서 열림

첫 화면이 뜨는 시간이 약 1.5 ~ 1.8초 줄었다.


마무리

5월 말부터 한 달 조금 넘게 이어진 최종 프로젝트가 끝났다. 막바지에는 계정 정지까지 겹쳐서 정리를 제대로 못 했는데, 그때 적어 둔 메모와 팀원들이 대신 올려준 커밋 덕분에 이렇게 다시 정리할 수 있었다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.