Search

iOS 퍼블릭 채팅 기능 구현

What I Learned

1. 왜 Message 모델에 isFromCurrentUser를 계산 프로퍼티로 만들었는가?

struct Message: Identifiable, Equatable { let senderNumber: Int // ... var isFromCurrentUser: Bool { senderNumber == UserSession.shared.userNumber } }
Swift
복사
메시지를 생성할 때 isFromCurrentUser: true를 직접 넣으면 두 가지 문제가 생긴다.
1.
데이터 불일치: P2P로 받은 메시지를 변환할 때 실수로 true를 넣으면 남의 메시지가 내 메시지로 보임
2.
중복 로직: 메시지 생성하는 모든 곳에서 senderNumber == myNumber 비교를 해야 함
계산 프로퍼티로 만들면 senderNumber만 정확하면 항상 올바른 값이 나온다. 단일 진실 공급원(Single Source of Truth) 원칙

2. 왜 P2PPayload에서 Message로 변환하는 생성자를 만들었는가?

init(from payload: P2PPayload) { self.id = payload.messageId self.senderNumber = payload.senderNumber self.senderStage = payload.senderStage self.senderNickname = payload.senderNickname self.text = payload.content self.sentAt = payload.timestamp }
Swift
복사
P2P로 데이터를 주고받을 때는 P2PPayload를 사용하고 UI에서는 Message를 사용한다. 두 모델의 역할이 다르기 때문
P2PPayload: 네트워크 전송용 (type, targetNumber 등 포함)
Message: UI 표시용 (displayName, isFromCurrentUser 등 포함)
변환 로직을 Message 내부에 두면 변환이 필요한 곳에서 Message(from: payload) 한 줄로 끝난다.

3. 왜 메시지 라우터를 별도로 분리했는가?

final class MessageRouter { static let shared = MessageRouter() private func routeMessage(_ payload: P2PPayload) { switch payload.type { case .publicMessage: PublicChat.shared.receiveMessage(payload) case .directMessage, .dmRequest, ...: // DirectChat.shared.receiveMessage(payload) break default: break } } }
Swift
복사
처음에는 PublicChat에서 직접 P2P 리스너를 등록했다.
// 나쁜 예 P2PService.shared.onMessageReceived = { payload in self.handleMessage(payload) }
Swift
복사
문제는 onMessageReceived가 하나뿐이라는 것. DM 기능을 추가하면 PublicChat의 리스너를 덮어쓰게 된다.
MessageRouter를 두면
P2P 리스너는 하나만 등록
메시지 타입에 따라 적절한 Model로 분배
새로운 메시지 타입 추가 시 라우터만 수정하면 됨

4. 왜 240자 제한을 두었는가?

private let maxMessageLength = 240 func validateMessage(_ text: String) -> Bool { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) return !trimmed.isEmpty && trimmed.count <= maxMessageLength }
Swift
복사
익명 근거리 채팅의 특성상 짧고 가벼운 대화가 적합하다. 긴 글을 쓰면
스크롤이 길어져서 다른 메시지가 묻힘
익명성과 어울리지 않는 깊은 대화 유도
P2P 전송 시 데이터 크기 증가
240자는 트위터(280자)보다 약간 짧은 수준으로, 한두 문장 정도의 메시지에 적합하다.

5. 왜 100개 메시지 제한을 두었는가?

private let maxMessages = 100 private func addMessage(_ message: Message) { messages.append(message) if messages.count > maxMessages { messages.removeFirst(messages.count - maxMessages) } }
Swift
복사
메모리 관리 목적이다. 채팅이 활발하면 메시지가 계속 쌓이는데 제한이 없으면
메모리 사용량 증가
UI 렌더링 성능 저하 (긴 리스트)
오래된 메시지는 어차피 의미 없음 (실시간 대화니까)
100개면 스크롤해서 과거 대화를 볼 수 있으면서도 메모리 부담이 적다.

6. 왜 isActive 플래그로 unreadCount를 관리하는가?

var isActive: Bool = false { didSet { if isActive { unreadCount = 0 } } } func receiveMessage(_ payload: P2PPayload) { // ... if !isActive { unreadCount += 1 } }
Swift
복사
사용자가 퍼블릭 채팅 화면을 보고 있으면 새 메시지가 와도 "읽지 않음" 표시를 할 필요가 없다. 이미 보고 있으니까.
isActive = true: 채팅 화면 진입 시 View에서 설정, unreadCount 초기화
isActive = false: 채팅 화면 이탈 시 View에서 설정
메시지 수신 시 isActive가 false면 unreadCount 증가
이렇게 하면 탭 바에 뱃지를 표시할 때 PublicChat.shared.unreadCount만 참조하면 된다.

Key Takeaways

1.
계산 프로퍼티: 파생 데이터는 저장하지 말고 계산하자
2.
변환 생성자: 모델 간 변환 로직은 모델 내부에
3.
라우터 패턴: 여러 목적지로 분배할 때 중간 계층 추가
4.
제한값 설정: 메모리와 UX를 고려한 합리적인 제한
5.
상태 플래그: View 상태를 Model이 알아야 할 때 간단한 플래그 사용

References