포스트

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 에 커뮤니티 탭을 다시 넣었다.

이 방식은 다음 날 튜터님 피드백을 받고 바꾸게 된다.

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