Final (Fin)
프로젝트 막바지에는 기능 구현에 쫓겨 블로그 정리를 제대로 하지 못했다. 그래서 이 글은 당시 GitHub 커밋 내역과, 프로젝트 기간에 직접 적어 둔 업무 내역 목록을 AI(Claude Code)에게 보여주고 그걸 바탕으로 내용을 다시 정리했다. 코드는 실제 커밋에 있던 것을 가져왔다.
마지막 작업은 추천 탭의 로딩 속도 개선이다. 이 코드는 계정 정지로 그때 올리지 못했고, 10월 4일에 올린 upload 커밋에 들어 있다.
추천페이지 성능 개선
앱을 켜면 처음 보이는 추천 탭이 뜨는 데 오래 걸린다는 피드백이 있었다. 추천 탭은 진문님이 만든 화면이라, 진문님의 동의를 받고 코드를 보던 중에 문제점을 발견했다.
기존 방식
추천 탭은 카드를 넘기며 떡볶이집을 소개하고, 카드를 누르면 상세 페이지에서 긴 설명과 사진 4장을 보여준다. 기존에는 앱을 시작할 때 이 모든 데이터를 한꺼번에 불러왔다.
- Firebase 의 추천 collection 에서 document 를 전부 가져온다
- document 마다 카드에 필요한 제목, 설명, 메인 이미지뿐 아니라 상세 페이지에 쓸 긴 설명 2개, 주소, 사진 4장까지 모두 읽어서 메모리에 저장한다
- 사진은 Storage 의
gs://주소로 저장되어 있어서, 화면에 띄우려면 실제로 받을 수 있는https://주소로 바꿔야 한다. 이걸 document 마다 5번(메인 1장, 상세 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월 말부터 한 달 조금 넘게 이어진 최종 프로젝트가 끝났다. 막바지에는 계정 정지까지 겹쳐서 정리를 제대로 못 했는데, 그때 적어 둔 메모와 팀원들이 대신 올려준 커밋 덕분에 이렇게 다시 정리할 수 있었다.