Final (26)
프로젝트 막바지에는 기능 구현에 쫓겨 블로그 정리를 제대로 하지 못했다. 그래서 이 글은 당시 GitHub 커밋 내역과, 프로젝트 기간에 직접 적어 둔 업무 내역 목록을 AI(Claude Code)에게 보여주고 그걸 바탕으로 내용을 다시 정리했다. 코드는 실제 커밋에 있던 것을 가져왔다.
오늘은 프로필 수정 페이지에 닉네임 중복 확인을 붙이고, 어제 정한 대로 지도 데이터를 JSON 으로 바꿨다.
프로필 수정 닉네임 중복 확인
커뮤니티에서는 닉네임으로 사람을 구분하는데, 프로필 수정에서 아무 닉네임이나 저장할 수 있어서 같은 닉네임이 여러 명 생길 수 있었다. 그래서 닉네임 옆에 “중복확인” 버튼을 두고, 확인을 통과해야만 저장되게 했다.
중복확인 버튼과 결과 Label
닉네임 입력칸 오른쪽에 버튼을, 아래에 결과를 보여줄 Label 을 추가했다. isValidate 는 중복 확인을 통과했는지 기억하는 값이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var validateButton: UIButton = {
let button = UIButton()
button.setTitle("중복확인", for: .normal)
button.addTarget(self, action: #selector(validateName), for: .touchUpInside)
button.titleLabel?.font = ThemeFont.fontBold(size: 14)
button.titleLabel?.textColor = .white
button.backgroundColor = ThemeColor.mainOrange
button.layer.cornerRadius = 10
return button
}()
lazy var validateLabel: UILabel = {
let label = UILabel()
label.font = ThemeFont.fontBold(size: 18)
label.textColor = ThemeColor.mainBlack
label.text = "닉네임 변경 전 중복확인 검사를 해주세요."
return label
}()
var isValidate = false
var viewModel: ManageViewModel!
private var cancellables = Set<AnyCancellable>()
전체 유저 닉네임과 비교하기
전체 유저 목록은 관리자 페이지를 만들 때 쓰던 ManageViewModel.getUsers() 를 그대로 썼다. Realtime Database 의 users 를 전부 읽어서 userArray 에 UserModel 로 담는다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Published var userArray = [UserModel]()
func getUsers() {
manageManager.fetchUsers { [weak self] error, dataSnapshot in
self?.userArray.removeAll()
if let error = error {
self?.managePublisher.send(completion: .failure(error))
}
guard let dictionary = dataSnapshot?.value as? [String: [String: Any]] else { return }
for (uid, userDict) in dictionary {
let email = userDict[db_email] as? String ?? ""
let nickName = userDict[db_nickName] as? String ?? ""
let profileImageUrl = userDict[db_profileImageUrl] as? String ?? ""
let isBlockInt = userDict[db_isBlock] as? Int ?? 0
let isBlock = isBlockInt != 0
let model = UserModel(uid: uid, email: email, isBlock: isBlock, nickName: nickName, profileImageUrl: profileImageUrl)
self?.userArray.append(model)
}
self?.managePublisher.send(())
}
}
userArray 에는 @Published 가 붙어 있어서, 값이 바뀔 때마다 $userArray 를 구독(sink)한 쪽에 새 배열이 전달된다. 중복확인 버튼은 이걸 구독해서 입력한 닉네임이 배열에 있는지 본다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@objc func validateName() {
let manageManager = ManageManager()
viewModel = ManageViewModel(manageManager: manageManager)
viewModel.getUsers()
guard let nickName = userNameTextField.text else { return }
viewModel.$userArray.sink { [weak self] modelArray in
if modelArray.contains(where: { $0.nickName == nickName }) {
self?.isValidate = false
self?.validateLabel.textColor = .red
self?.validateLabel.text = "이미 닉네임이 존재합니다."
} else {
self?.isValidate = true
self?.validateLabel.textColor = .blue
self?.validateLabel.text = "입력하신 닉네임은 사용 가능합니다."
}
}.store(in: &cancellables)
}
확인을 통과해야 저장하기
저장 버튼은 isValidate 가 true 일 때만 프로필을 업데이트하고, 아니면 중복확인부터 하라고 알려준다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@objc func saveChanges() {
if isValidate {
ProgressHUD.animate()
guard let uid = Auth.auth().currentUser?.uid else { return }
var selectedImage = profileImage
var userName = userNameTextField.text
if userName == "" {
userName = profileName
}
if selectedImage == nil {
KingfisherManager.shared.retrieveImage(with: URL(string: gotProfileImage!)!) { [weak self] result in
// 기존 프로필 이미지 가져오기
}
}
userManager.updateProfile(uid: uid, nickName: userName!, profile: selectedImage!) { [weak self] result in
// 완료, 실패 Alert
}
} else {
showMessage(title: "중복확인을 해주세요", message: "닉네임 중복확인을 먼저 해주세요.")
}
}
지금 보면 이 구현에는 구멍이 있었다. @Published 는 구독하는 순간 지금 들고 있는 값을 먼저 한 번 보내준다. getUsers() 는 서버에서 읽어 오는 데 시간이 걸리니까, 처음 전달되는 건 아직 비어 있는 배열이고 그 순간 “사용 가능” 이 잠깐 뜬다. 버튼을 누를 때마다 구독이 하나씩 더 쌓이는 것도 문제다. 이 부분은 다음 날 커뮤니티 닉네임 중복처리를 하면서 getUsers 에 completion 을 붙이는 방식으로 바꾼다. 또 사진을 바꾸지 않았을 때 기존 이미지를 받아오는 retrieveImage 도 끝나기 전에 updateProfile 이 먼저 불리는데, 이건 7월 2일에 다시 손보게 된다.
같이 고친 것
프로필 사진을 Storage 에 올릴 때 PNG 대신 JPEG 로 압축(compressionQuality: 0.4)해서 올리도록 바꾸고, 내려받을 때의 최대 크기도 50MB 에서 2MB 로 줄였다. 프로필 사진은 작게 보이니까 원본 화질이 필요 없다.
1
2
let data = image.jpegData(compressionQuality: 0.4)
metaData.contentType = "image/jpg"
지도에 JSON 데이터 사용
어제 정한 대로 전처리한 분식집 데이터를 JSON 파일로 앱에 넣고, Local 에서 읽어 지도에 핀을 찍었다. 지역마다 파일을 나눠서, 내 위치의 지역 파일만 읽는다.
1
2
3
4
5
6
struct JsonModel: Codable {
let storeName: String
let address: String
let x: Double
let y: Double
}
x 는 경도, y 는 위도다. 파일을 읽고 거리로 거르는 건 JsonService 가 한다.
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
class JsonService {
private var stores: [JsonModel] = []
init(fileName: String) {
loadJSON(fileName: fileName)
}
private func loadJSON(fileName: String) {
guard let url = Bundle.main.url(forResource: fileName, withExtension: "json") else {
print("Error: File not found")
return
}
do {
let data = try Data(contentsOf: url)
stores = try JSONDecoder().decode([JsonModel].self, from: data)
} catch {
print("Error loading JSON: \(error)")
}
}
func filterStores(within distance: Double, from currentLocation: CLLocation) -> [JsonModel] {
return stores.filter { store in
let storeLocation = CLLocation(latitude: store.y, longitude: store.x)
let distanceFromCurrentLocation = currentLocation.distance(from: storeLocation)
return distanceFromCurrentLocation <= distance
}
}
}
JsonViewModel 은 기본 250m 안의 가게만 돌려준다.
1
2
3
func getNearbyStores(currentLocation: CLLocation, within distance: Double = 250) -> [JsonModel] {
return jsonService.filterStores(within: distance, from: currentLocation)
}
MapViewController 는 reverse geocoding 으로 얻은 지역 이름(administrativeArea)을 파일 이름으로 바꾸고, 그 파일을 읽어 주변 가게에 핀을 찍는다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private func getFileName(address: String) -> String {
switch address {
case "서울특별시":
return "seoul"
case "경기도":
return "gyeonggi"
// ... 다른 지역 생략
default:
return "default"
}
}
private func loadJson(file name: String) {
let jsonService = JsonService(fileName: name)
jsonViewModel = JsonViewModel(jsonService: jsonService)
let nearStores = jsonViewModel.getNearbyStores(currentLocation: userLocation)
viewModel.loadJsonStores(nearStores)
addNearbyStorePins(for: nearStores)
}
오후 커밋에서는 JSON 가게도 기존 검색 가게처럼 Firebase 의 별점, 리뷰 수, 스크랩 여부와 연결했다. MapViewModel 에 jsonStores 를 따로 두고, 가게 이름으로 못 찾으면 JSON 쪽에서 찾는다. 현재 scrap 을 제외한 나머지 기능은 연결을 마쳤다.
공공데이터 특성상 분식 카테고리로 가져왔는데도 카페가 섞여 나오는 등 문제가 있지만, 데이터가 많아서 불가피하다고 판단했다. 다른 지역 데이터도 카카오 API 의 지역 호출 방식에 맞춰 전처리를 이어갔다.