Final (24)
프로젝트 막바지에는 기능 구현에 쫓겨 블로그 정리를 제대로 하지 못했다. 그래서 이 글은 당시 GitHub 커밋 내역과, 프로젝트 기간에 직접 적어 둔 업무 내역 목록을 AI(Claude Code)에게 보여주고 그걸 바탕으로 내용을 다시 정리했다. 코드는 실제 커밋에 있던 것을 가져왔다.
커뮤니티에 두 가지를 추가했다. 채팅에 지도를 첨부해서 위치를 공유하는 기능과, 내가 지금 있는 지역의 채팅방에만 들어갈 수 있게 하는 입장 제한이다.
커뮤니티 지도 첨부
채팅방에서 “여기 떡볶이 맛있어요” 하고 위치를 같이 보낼 수 있으면 좋겠다고 생각했다. MessageKit 은 메시지 종류로 텍스트, 사진 말고도 위치(.location)를 지원해서, 메시지 모델만 확장하면 지도 말풍선을 그려준다.
다만 지도 말풍선은 이미지처럼 눌러서 크게 보거나 이동하는 건 안 되고, 마커된 지역만 표시할 수 있다.
위치 메시지 모델
MessageKit 의 LocationItem 프로토콜을 따르는 모델을 먼저 만들었다. 위치와 말풍선 크기만 있으면 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
import Foundation
import MessageKit
import CoreLocation
struct Location: LocationItem {
var location: CLLocation
var size: CGSize
init(location: CLLocation) {
self.location = location
self.size = CGSize(width: 240, height: 240) // 원하는 지도 크기
}
}
Message 에는 location property 를 추가하고, kind 에서 위치가 있으면 .location 을 돌려주게 했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
var location: CLLocation?
var kind: MessageKind {
if let image = image {
let mediaItem = ImageMediaItem(image: image)
return .photo(mediaItem)
} else if let location = location {
let locationItem = Location(location: location)
return .location(locationItem)
} else {
return .text(content)
}
}
위치 메시지를 만드는 init 도 유저용과 게스트용 두 개를 추가했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
init(user: User, location: CLLocation, displayName: String) {
sender = Sender(senderId: user.uid, displayName: displayName)
self.location = location
sentDate = Date()
content = ""
id = nil
}
init(customUser: CustomUser, location: CLLocation, displayName: String) {
sender = Sender(senderId: customUser.uid, displayName: displayName)
self.location = location
sentDate = Date()
content = ""
id = nil
}
Firestore 에 위치 저장하고 읽기
Firestore 에는 CLLocation 을 그대로 넣을 수 없어서 위도(latitude), 경도(longitude) 두 숫자로 나눠 저장한다.
1
2
3
4
5
// DatabaseRepresentation (저장할 때)
} else if let location = location {
representation["latitude"] = location.coordinate.latitude
representation["longitude"] = location.coordinate.longitude
}
읽어올 때는 반대로 두 숫자가 있으면 CLLocation 을 다시 만든다.
1
2
3
4
5
6
// init?(document:) (읽을 때)
} else if let latitude = data["latitude"] as? CLLocationDegrees, let longitude = data["longitude"] as? CLLocationDegrees {
location = CLLocation(latitude: latitude, longitude: longitude)
content = ""
downloadURL = nil
}
사진과 지도를 고르는 버튼
입력창 왼쪽의 카메라 버튼을 클립(paperclip) 버튼으로 바꾸고, 누르면 사진과 지도 중에 고르는 action sheet 를 띄운다. 게스트는 여기서 막는다.
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
lazy var addBarButtonItem: InputBarButtonItem = {
// ...
button.image = UIImage(systemName: "paperclip")
button.addTarget(self, action: #selector(presentInputActionSheet), for: .touchUpInside)
return button
}()
@objc private func presentInputActionSheet() {
if user != nil {
let actionSheet = UIAlertController(title: "유형을 선택해주세요", message: "아래에서 선택해주세요", preferredStyle: .actionSheet)
actionSheet.addAction(UIAlertAction(title: "사진", style: .default, handler: { [weak self] _ in
self?.didTapCameraButton()
}))
actionSheet.addAction(UIAlertAction(title: "지도", style: .default, handler: { [weak self] _ in
self?.presentLocationPicker()
}))
actionSheet.addAction(UIAlertAction(title: "취소", style: .cancel, handler: nil))
present(actionSheet, animated: true)
} else if customUser != nil {
showMessage(title: "로그인이 필요한 기능입니다.", message: "사용 할 수 없습니다.")
}
}
지도를 고르면 지도 탭에서 쓰던 MapViewController 를 위치 선택 모드(isLocationPicker)로 띄운다. 지도 화면을 새로 만들지 않고 재활용했다.
1
2
3
4
5
6
7
private func presentLocationPicker() {
let mapVC = MapViewController()
mapVC.delegate = self
mapVC.isLocationPicker = true
let navController = UINavigationController(rootViewController: mapVC)
present(navController, animated: true)
}
Delegate 패턴이란?
지도 화면에서 고른 위치를 채팅 화면으로 돌려줘야 한다. 이럴 때 delegate 패턴을 쓴다.
심부름을 보내면서 “다 하면 나한테 이 번호로 전화해”라고 연락처를 쥐여 주는 것과 같다. ChatVC 가 지도 화면을 띄우면서 mapVC.delegate = self 로 자기를 연락처로 넘기고, 지도 화면은 위치가 정해지면 그 연락처(delegate)의 메서드를 부른다. 지도 화면은 상대가 ChatVC 인지 몰라도 되고, 약속한 메서드(protocol)만 알면 된다.
1
2
3
protocol MapViewControllerDelegate: AnyObject {
func didSelectLocation(_ location: CLLocationCoordinate2D)
}
delegate 는 weak 로 잡는다. ChatVC 가 지도 화면을 들고 있고 지도 화면도 ChatVC 를 강하게 들고 있으면 서로 놓아주지 않아서 메모리에서 해제되지 않기 때문이다.
지도에서 위치 고르기
MapViewController 에 delegate 와 선택한 위치, 선택 모드 여부를 추가했다.
1
2
3
weak var delegate: MapViewControllerDelegate?
var selectedLocation: CLLocationCoordinate2D?
var isLocationPicker: Bool = false
선택 모드일 때만 하단에 Cancel, Send 버튼을 붙인다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if isLocationPicker {
setupButtons()
}
private func setupButtons() {
view.addSubview(buttonStackView)
buttonStackView.addArrangedSubview(cancelButton)
buttonStackView.addArrangedSubview(sendButton)
buttonStackView.snp.makeConstraints { make in
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(20)
make.leading.trailing.equalTo(view.safeAreaLayoutGuide).inset(20)
make.height.equalTo(50)
}
}
위치는 지도를 길게 눌러서 고른다. 누른 화면 좌표를 지도 좌표(위도, 경도)로 바꾸고, 기존 핀을 지운 뒤 그 자리에 새 핀을 꽂는다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(gestureRecognizer:)))
mapView.map.addGestureRecognizer(longPressGesture)
@objc func handleLongPress(gestureRecognizer: UILongPressGestureRecognizer) {
if gestureRecognizer.state == .began {
let touchPoint = gestureRecognizer.location(in: mapView.map)
let coordinate = mapView.map.convert(touchPoint, toCoordinateFrom: mapView.map)
selectedLocation = coordinate
// 기존 핀 제거
let allAnnotations = mapView.map.annotations
mapView.map.removeAnnotations(allAnnotations)
// 새로운 핀 추가
addPin(at: CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude), title: "선택된 위치", isMainLocation: true)
}
}
길게 누르는 gesture 는 누르고 있는 동안 계속 호출되기 때문에, .began 일 때 한 번만 처리했다.
Send 를 누르면 delegate 로 위치를 넘기고 화면을 닫는다.
1
2
3
4
5
6
7
8
9
10
@objc func sendButtonTapped() {
if let location = selectedLocation {
delegate?.didSelectLocation(location)
}
dismiss(animated: true, completion: nil)
}
@objc func cancelButtonTapped() {
dismiss(animated: true, completion: nil)
}
받은 위치를 메시지로 보내기
ChatVC 는 받은 좌표로 위치 메시지를 만들어 Firestore 에 저장만 한다. 화면에 메시지를 추가하는 건 이미 붙어 있는 snapshot listener 가 새 메시지를 감지해서 처리한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
extension ChatVC: MapViewControllerDelegate {
func didSelectLocation(_ location: CLLocationCoordinate2D) {
guard let user = self.user else {
print("No valid user found")
return
}
let displayName = currentDisplayName
let locationMessage = Message(user: user, location: CLLocation(latitude: location.latitude, longitude: location.longitude), displayName: displayName)
// Firestore에 저장
chatFirestoreStream.save(locationMessage) { error in
if let error = error {
print(error)
return
}
// 메시지를 추가하지 않음. Firestore의 스냅샷 리스너가 이를 처리함.
}
}
}
지역 채팅방 입장 제한
커뮤니티는 지역별 채팅방으로 되어 있다. 서울에 있는 사람이 부산 채팅방에서 떠들면 지역 커뮤니티의 의미가 없어서, 지금 있는 지역의 채팅방에만 들어갈 수 있게 했다. 채팅방 이름이 “서울특별시” 같은 지역 이름이라, 내 위치의 지역 이름과 비교하면 된다.
오후에는 현재 유저 위치를 console 에 띄우는 것까지 하고, 저녁에 입장 제한을 붙였다.
Reverse Geocoding이란?
GPS 로 받는 위치는 위도, 경도 숫자다. 이걸 “서울특별시” 같은 주소로 바꾸는 걸 reverse geocoding 이라고 한다. 반대로 주소를 좌표로 바꾸는 게 geocoding 이다.
iOS 에서는 CLGeocoder 의 reverseGeocodeLocation 이 이 일을 한다. 결과로 CLPlacemark 가 오는데, 여기서 administrativeArea 가 시/도 단위 이름이다.
현재 위치로 지역 이름 구하기
ChannelVC 에 CLLocationManager 를 붙여서 위치 권한을 요청하고 위치를 받는다.
1
2
3
4
5
6
7
8
9
10
11
import CoreLocation
let locationManager: CLLocationManager = CLLocationManager()
var userLocation: CLLocation = CLLocation()
private var currentAddress = ""
private func checkUserLocation() {
locationManager.delegate = self
locationManager.requestWhenInUseAuthorization()
locationManager.startUpdatingLocation()
}
viewDidLoad 와 viewWillAppear 에서 checkUserLocation() 을 부른다. 위치를 한 번 받으면 업데이트를 멈추고 지역 이름을 구한다. 지역만 알면 되니까 계속 받을 필요가 없다.
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
extension ChannelVC: CLLocationManagerDelegate {
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
if let location = locations.last {
userLocation = location
getAddress(coordinate: userLocation)
locationManager.stopUpdatingLocation() // 위치 업데이트 멈추기
}
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
print("Failed to find user's location: \(error.localizedDescription)")
}
func getAddress(coordinate: CLLocation) {
let address = CLGeocoder.init()
address.reverseGeocodeLocation(coordinate) { [weak self] (placeMarks, error) in
var placeMark: CLPlacemark!
placeMark = placeMarks?[0]
guard let address = placeMark else { return }
self?.currentAddress = address.administrativeArea!
}
}
}
처음에는 address.locality 를 print 해 봤는데, 우리 채팅방은 시/도 단위라서 administrativeArea 로 바꿨다.
채팅방 입장 막기
채팅방을 누르면 내 지역 이름과 채팅방 이름이 같을 때만 들어가고, 다르면 Alert 로 지금 내 지역을 알려준다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let channel = channels[indexPath.row]
if currentAddress == channel.name {
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)
} else {
showMessage(title: "지역 오류", message: "현재 속한 지역의 대화방만 입장가능합니다.\n현재 유저님의 속한 지역은 \(currentAddress)입니다.")
}
}
채널 목록 하단의 툴바(채널 생성 버튼 등)는 일반 유저가 쓸 일이 없어서 addToolBarItems() 호출을 주석 처리했다. 그리고 TabBar 에 커뮤니티 탭을 다시 넣었다.
이 방식은 다음 날 튜터님 피드백을 받고 바꾸게 된다.