Final (21)
프로젝트 막바지에는 기능 구현에 쫓겨 블로그 정리를 제대로 하지 못했다. 그래서 이 글은 당시 GitHub 커밋 내역과, 프로젝트 기간에 직접 적어 둔 업무 내역 목록을 AI(Claude Code)에게 보여주고 그걸 바탕으로 내용을 다시 정리했다. 코드는 실제 커밋에 있던 것을 가져왔다.
내용이 많아 최대한 심플하게 작성한다. 이번 글은 게스트 모드를 커뮤니티까지 넓힌 작업과, 추천페이지 생명주기 수정이다.
심사 Reject 와 게스트 로그인
앱 심사를 넣었는데 Reject 이 되었다. 사유는 다음과 같다.
- 앱은 사용자가 계정 기반이 아닌 기능에 엑세스 하기 위해 등록하거나 로그인해야 합니다.
- 앱은 앱의 핵심 기능과 직접 관련이 있거나 법에 의해 요구되는 경우를 제외하고는 사용자가 기능하기 위해 개인 정보를 입력할 것을 요구하지 않을 수 있습니다.
처음에 우리는 무조건 로그인을 해야 앱을 사용할 수 있도록 했는데, 그것이 Reject 사유가 된 것이다. 추천, 지도처럼 계정이 없어도 볼 수 있는 기능까지 로그인을 요구했기 때문이다.
그래서 19일에 로그인 화면에 게스트 로그인 버튼을 먼저 추가했다. 버튼을 누르면 로그인 없이 바로 메인 TabBar 로 넘어간다.
1
2
3
4
5
6
7
8
9
10
11
private lazy var guestLoginButton: UIButton = {
let button = UIButton()
button.setImage(UIImage(systemName: "person.crop.circle"), for: .normal)
button.tintColor = .white
button.setTitle(" 게스트로 로그인", for: .normal)
button.titleLabel?.font = ThemeFont.fontBold()
button.backgroundColor = ThemeColor.mainOrange
button.layer.cornerRadius = 10
button.addTarget(self, action: #selector(guestButtonDidTapped), for: .touchUpInside)
return button
}()
1
2
3
4
// SceneDelegate
}, guestTapped: {
self.switchToMainTabBarController()
},
문제는 커뮤니티였다. 채팅 화면은 Auth.auth().currentUser! 처럼 로그인한 유저가 반드시 있다고 가정하고 만들어져 있어서, 게스트로 들어가면 바로 crash 가 난다. 22일에는 이 부분을 정리했다.
커뮤니티 게스트 처리
Firebase 의 User 는 Firebase 가 로그인한 사람에게만 만들어 주는 타입이라 게스트용으로 직접 만들 수가 없다. 그래서 게스트를 표현할 타입을 따로 만들었다.
1
2
3
4
5
6
7
8
9
10
11
12
13
import FirebaseAuth
struct CustomUser {
let uid: String
let email: String?
let isGuest: Bool
init(guestUID: String) {
self.uid = guestUID
self.email = nil
self.isGuest = true
}
}
Firebase 의 User 와 비슷하게 쓰려고 uid, email 을 두고, 게스트인지 여부를 위해 isGuest 를 넣었다.
Message 에 게스트용 init 추가
채팅 메시지 모델에도 CustomUser 를 받는 init 을 만들었다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
init(customUser: CustomUser, content: String, displayName: String) {
sender = Sender(senderId: customUser.uid, displayName: displayName)
self.content = content
sentDate = Date()
id = nil
}
init(customUser: CustomUser, image: UIImage, displayName: String) {
sender = Sender(senderId: customUser.uid, displayName: displayName)
self.image = image
sentDate = Date()
content = ""
id = nil
}
SceneDelegate 에서 유저 구분
switchToMainTabBarController 는 첫 줄에서 guard let user = Auth.auth().currentUser else { return } 를 하고 있었다. 게스트는 여기서 막혀서 메인 화면으로 갈 수가 없었다. 이 guard 를 지우고, 유저가 없으면 CustomUser 를 만들도록 바꿨다.
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
var currentUser: User?
func switchToMainTabBarController() {
let user: User? = Auth.auth().currentUser
let customUser: CustomUser?
if let user = user {
currentUser = user
customUser = nil
} else {
customUser = CustomUser(guestUID: "guest")
}
// ... 생략
let communityVC: UINavigationController
if let user = currentUser {
communityVC = UINavigationController(rootViewController: ChannelVC(currentUser: user))
} else if let guestUser = customUser {
communityVC = UINavigationController(rootViewController: ChannelVC(customUser: guestUser))
} else {
fatalError("No valid user found.")
}
// ... 생략
}
ChannelVC 에서 유저 구분
ChannelVC 도 User 하나만 받던 것을 User?, CustomUser? 두 개로 나누고 init 을 각각 만들었다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
let uid: String
private var currentUser: User?
private var customUser: CustomUser?
init(currentUser: User) {
self.currentUser = currentUser
self.customUser = nil
self.uid = currentUser.uid
super.init(nibName: nil, bundle: nil)
title = "Channels"
}
init(customUser: CustomUser) {
self.currentUser = nil
self.customUser = customUser
self.uid = customUser.uid
super.init(nibName: nil, bundle: nil)
title = "Channels"
}
uid 는 원래 Auth.auth().currentUser?.uid 라서 옵셔널이었는데, init 에서 값을 넣어주도록 바꿔서 uid! 같은 강제 언래핑을 없앴다.
닉네임이 비어 있으면 입력 Alert 를 띄우는 checkNickname 은 로그인 유저일 때만 돌게 했다. 게스트는 닉네임이 없기 때문이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private func checkNickname() {
// 유저 모드일 때만 닉네임 확인
if let user = currentUser {
userManager.fetchUserData(uid: user.uid) { [self] error, snapshot in
if let error = error {
print(error)
}
guard let dictionary = snapshot?.value as? [String: Any] else { return }
currentName = (dictionary[db_nickName] as? String) ?? "Unknown"
if currentName == "" {
showNameAlert(uid: user.uid)
}
}
}
}
채팅방에 들어갈 때도 유저 종류에 따라 다른 init 으로 ChatVC 를 만든다.
1
2
3
4
5
6
7
8
9
10
11
12
13
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let channel = channels[indexPath.row]
let viewController: ChatVC
if let user = currentUser {
viewController = ChatVC(user: user, channel: channel)
} else if let customUser = customUser {
viewController = ChatVC(customUser: customUser, channel: channel)
} else {
fatalError("No valid user found.")
}
navigationController?.pushViewController(viewController, animated: true)
tableView.deselectRow(at: indexPath, animated: true)
}
ChatVC 에서 게스트 제한
ChatVC 도 같은 방식으로 user, customUser 를 나누고 게스트용 init 을 추가했다. 그 다음 게스트일 때 달라져야 하는 곳을 하나씩 고쳤다.
먼저 입력창이다. 로그인 유저는 주황색에 “채팅을 입력해주세요!”, 게스트는 회색에 “채팅 입력을 위해 로그인해주세요!” 를 보여준다. 이 설정은 viewDidLoad 에서 viewWillAppear 로 옮겨서 화면에 들어올 때마다 다시 반영되게 했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
setupMessageInputBar()
}
private func setupMessageInputBar() {
if let user = user {
messageInputBar.inputTextView.tintColor = ThemeColor.mainOrange
messageInputBar.sendButton.setTitleColor(ThemeColor.mainOrange, for: .normal)
messageInputBar.inputTextView.placeholder = "채팅을 입력해주세요!"
} else if customUser != nil {
messageInputBar.inputTextView.tintColor = .systemGray
messageInputBar.sendButton.setTitleColor(.systemGray, for: .normal)
messageInputBar.inputTextView.placeholder = "채팅 입력을 위해 로그인해주세요!"
}
}
MessageKit 은 화면에 메시지를 그릴 때 “지금 보는 사람이 누구인지”(currentSender)를 알아야 내 메시지와 남의 메시지를 좌우로 나눈다. 게스트도 값을 돌려주도록 분기했다.
1
2
3
4
5
6
7
8
9
var currentSender: SenderType {
if let user = user {
return Sender(senderId: user.uid, displayName: currentDisplayName)
} else if let customUser = customUser {
return Sender(senderId: customUser.uid, displayName: currentDisplayName)
} else {
fatalError("No valid user found.")
}
}
표시 이름은 게스트면 “Guest” 로 고정했다.
1
2
3
4
5
6
7
8
9
10
11
12
private func fetchDisplayName(completion: @escaping (String?) -> Void) {
let userManager = UserManager()
if let user = user {
userManager.fetchUserData(uid: user.uid) { error, snapshot in
// ... 닉네임 가져오기
}
} else if let customUser = customUser {
completion(customUser.isGuest ? "Guest" : "Unknown")
} else {
completion(nil)
}
}
마지막으로 전송이다. 보내기 전에 닉네임을 가져오는데, 게스트는 닉네임이 없어서 여기서 nil 이 나온다. 기존에는 print 만 하고 끝났는데, 이제 Alert 를 띄워서 게스트는 보낼 수 없다고 알려준다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func inputBar(_ inputBar: InputBarAccessoryView, didPressSendButtonWith text: String) {
Message.fetchDisplayName(userManager: UserManager()) { [weak self] displayName in
guard let displayName = displayName, let self = self else {
self?.showMessage(title: "로그인이 필요한 기능입니다.", message: "게스트는 메세지를 보낼 수 없습니다.")
return
}
var message: Message
if let user = self.user {
message = Message(user: user, content: text, displayName: displayName)
} else if let customUser = self.customUser {
message = Message(customUser: customUser, content: text, displayName: displayName)
} else {
print("No valid user found")
return
}
// ... 저장
}
}
사진 전송(sendPhoto)도 같은 방식으로 Message 를 만들 때 분기했다.
게스트 예외처리 보완
같은 날 오후에 몇 가지를 더 보완했다.
user, customUser 를 let 에서 var 로 바꾸고, viewWillAppear 에서 현재 로그인 상태를 다시 확인해서 넣어주도록 했다. init 때 받은 값만 믿지 않고, 화면에 들어올 때마다 지금 상태를 기준으로 하게 한 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if let currentUser = Auth.auth().currentUser {
self.user = currentUser
self.customUser = nil
} else {
self.user = nil
self.customUser = CustomUser(guestUID: "guest")
}
setupMessageInputBar()
// ... 생략
}
화면의 빈 곳을 탭하면 키보드가 내려가도록 제스처도 추가했다.
1
2
3
4
5
6
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap))
messagesCollectionView.addGestureRecognizer(tapGesture)
@objc private func handleTap() {
view.endEditing(true)
}
deinit 에서는 user, customUser 를 nil 로 비웠다. ChannelVC 의 deinit 에도 똑같이 넣었다.
1
2
3
4
5
6
deinit {
chatFirestoreStream.removeListener()
navigationController?.navigationBar.prefersLargeTitles = true
user = nil
customUser = nil
}
커뮤니티 UI 수정
커뮤니티 화면에도 색이 지정되지 않은 곳이 있어서 정리했다. ChatVC 에는 색을 모아서 설정하는 함수를 만들고 viewDidLoad 에서 부른다. 기존 configure() 에 있던 title 설정도 이쪽으로 옮겼다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private func configureColor() {
view.backgroundColor = .white
messagesCollectionView.backgroundColor = .white
messageInputBar.backgroundColor = .white
messageInputBar.backgroundView.backgroundColor = .white
messageInputBar.inputTextView.backgroundColor = .white
messageInputBar.inputTextView.textColor = .black
navigationController?.navigationBar.tintColor = ThemeColor.mainOrange
navigationController?.navigationBar.barTintColor = .white
navigationController?.navigationBar.titleTextAttributes = [
NSAttributedString.Key.foregroundColor: ThemeColor.mainOrange
]
title = channel.name
navigationController?.navigationBar.prefersLargeTitles = false
}
채팅방 안에서는 TabBar 가 입력창을 가리지 않도록 숨기고, 나올 때 다시 보이게 했다.
1
2
3
4
5
6
7
8
9
10
11
12
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
setupMessageInputBar()
tabBarController?.tabBar.isHidden = true
navigationController?.setToolbarHidden(true, animated: false)
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
tabBarController?.tabBar.isHidden = false
navigationController?.setToolbarHidden(false, animated: false)
}
ChannelVC 도 배경, NavigationBar, cell 색을 흰색과 주황색으로 맞췄다.
추천페이지 생명주기 수정
추천 탭을 다시 누를 때마다 빈 화면에서 로딩이 시작되는 문제가 있었다. 원인은 데이터를 불러오는 시점이었다.
ViewController 생명주기란?
ViewController 는 화면이 만들어지고, 보이고, 사라질 때마다 정해진 메서드가 불린다.
viewDidLoad: 화면이 처음 메모리에 올라올 때 한 번만 불린다viewWillAppear: 화면이 보이기 직전에, 보일 때마다 불린다viewDidDisappear: 화면이 완전히 사라진 뒤에, 사라질 때마다 불린다
탭을 옮겨 다니면 viewDidLoad 는 다시 불리지 않지만, viewWillAppear 와 viewDidDisappear 는 매번 불린다.
fetch 위치 옮기기
기존에는 viewWillAppear 에서 fetch 를 했다. 그래서 탭에 들어올 때마다 그제서야 데이터를 받기 시작했고, 받는 동안은 빈 화면이 보였다.
그래서 처음 한 번은 viewDidLoad 에서 fetch 하고, 그 다음부터는 화면이 사라질 때(viewDidDisappear) 미리 fetch 하도록 바꿨다. 추천페이지가 비활성화되어 있는 동안 미리 받아 두니까, 다시 들어왔을 때는 이미 데이터가 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
setupCardSwiper()
Task {
await viewModel.fetchData()
}
bind()
}
public override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
cancellables.removeAll()
Task {
await viewModel.fetchData()
}
bind()
}
public override func viewWillAppear(_ animated: Bool) {
navigationController?.navigationBar.isHidden = true
}
viewDidDisappear 에서 구독(cancellables)을 비운 뒤 bind() 를 다시 해서, 구독이 중복으로 쌓이지 않게 했다.