Search

iOS DM 기능 구현 - 상태 머신과 불변 객체 패턴

What I Learned

왜 DM 대화방에 상태 머신 패턴을 적용했는가?

enum Status: Equatable { case pending // 내가 보낸 요청 대기 중 case received // 상대가 보낸 요청 받음 case active // 대화 중 case declined // 거절됨 case left // 나감 }
Swift
복사
DM은 단순히 “있다/없다”가 아니라 여러 상태를 거친다.
[요청 보냄] → pending ↓ [상대가 수락] → active ↓ [대화 중...][상대가 나감]left
Swift
복사
상태를 enum으로 정의하면 불가능한 상태 전이를 컴파일 타임에 방지할 수 있다.
func sendMessage(to peerNumber: Int, text: String) -> Bool { guard let index = conversationIndex(for: peerNumber), conversations[index].status == .active // active 상태에서만 메시지 전송 가능 else { return false } // ... }
Swift
복사
만약 상태를 Bool 여러 개로 관리했다면?
// 나쁜 예: 불가능한 조합이 생길 수 있음 var isPending: Bool var isActive: Bool var isDeclined: Bool // isPending = true, isActive = true 면 뭐지?
Swift
복사

왜 DirectMessage에 불변 객체 + 복사 패턴을 적용했는가?

struct DirectMessage: Identifiable, Equatable { let id: String let peerNumber: Int let senderNumber: Int let text: String let sentAt: Date let isRead: Bool // let으로 선언 func markAsRead() -> DirectMessage { DirectMessage( id: id, peerNumber: peerNumber, senderNumber: senderNumber, text: text, sentAt: sentAt, isRead: true // 이것만 바꿔서 새 객체 반환 ) } }
Swift
복사
왜 isRead를 var로 두고 직접 수정하지 않았을까?
// 나쁜 예 message.isRead = true // 어디서 바뀌었는지 추적 어려움
Swift
복사
불변 객체 패턴의 장점
1.
변경 추적 용이: 값이 바뀌면 반드시 새 객체가 생성됨
2.
스레드 안전: 원본이 변경되지 않으므로 동시 접근 문제 없음
3.
@Observable 호환: SwiftUI가 변경을 감지하려면 배열 자체가 바뀌어야 함

왜 인덱스 기반으로 배열을 수정하는가?

func acceptRequest(from peerNumber: Int) { guard let index = conversationIndex(for: peerNumber), conversations[index].status == .received else { return } conversations[index].status = .active conversations[index].updatedAt = Date() // ... } private func conversationIndex(for peerNumber: Int) -> Int? { conversations.firstIndex { $0.peerNumber == peerNumber } }
Swift
복사
왜 객체를 직접 찾아서 수정하지 않을까?
// 안 되는 예 if var conversation = conversations.first(where: { $0.peerNumber == peerNumber }) { conversation.status = .active // 복사본만 수정됨, 원본은 그대로! }
Swift
복사
Swift의 struct는 값 타입이다. first(where:)로 가져온 건 복사본이므로 수정해도 원본 배열에 영향 없음
인덱스를 찾아서 conversations[index]로 접근해야 원본 배열의 요소를 수정할 수 있다.

왜 대화방과 메시지를 중첩 구조로 설계했는가?

struct DMConversation: Identifiable, Equatable { let peerNumber: Int var messages: [DirectMessage] // 대화방 안에 메시지 배열 var status: Status // ... }
Swift
복사
메시지와 대화방을 분리
// 대안 var conversations: [DMConversation] var messages: [String: [DirectMessage]] // conversationID → messages
Swift
복사
중첩 구조를 선택한 이유
1.
단순함: 대화방 하나 가져오면 메시지도 함께 옴
2.
정합성: 대화방 삭제 시 메시지도 자동 삭제
3.
성능: 특정 대화방 메시지 조회 시 O(1)O(1)
단점도 있다.
대화방 전체를 수정해야 메시지 하나 추가 가능
대화방이 많으면 메모리 부담
P2P 채팅 특성상 동시 대화방 수가 적으므로 중첩 구조가 적합했다.

왜 DM 프로토콜을 요청/수락/거절로 설계했는가?

case dmRequest // DM 요청 case dmRequestAccept // 수락 case dmRequestDecline // 거절 case directMessage // 실제 메시지 case dmReadReceipt // 읽음 확인 case dmLeave // 나가기
Swift
복사
왜 바로 메시지를 보내지 않고 요청 단계를 두었을까?
1.
스팸 방지: 상대가 수락해야만 메시지 전송 가능
2.
사용자 경험: 원치 않는 DM을 사전에 거절 가능
3.
상태 동기화: 양쪽 모두 대화방 상태를 알 수 있음
AB: dmRequest (메시지와 함께) BA: dmRequestAccept AB: directMessage (이제 양방향 가능) AB: dmReadReceipt BA: dmLeave
Swift
복사

왜 읽음 처리를 map으로 구현했는가?

func markAsRead(peerNumber: Int) { guard let index = conversationIndex(for: peerNumber) else { return } conversations[index].messages = conversations[index].messages.map { message in if !message.isFromMe && !message.isRead { return message.markAsRead() } return message } // ... }
Swift
복사
왜 for문으로 직접 수정하지 않았을까?
// 나쁜 예 (struct라서 안 됨) for message in conversations[index].messages { if !message.isFromMe && !message.isRead { message.isRead = true // 컴파일 에러! } }
Swift
복사
map을 사용하면
1.
불변 객체 패턴 유지
2.
새 배열을 통째로 할당하므로 @Observable이 변경 감지
3.
함수형 스타일로 의도가 명확함

Key Takeaways

1.
상태 머신: 복잡한 상태 전이는 enum으로 명시적으로 관리
2.
불변 객체: 값 변경 시 새 객체 반환, 추적과 동기화에 유리
3.
인덱스 기반 수정: Swift struct 배열 수정 시 인덱스로 접근 필수
4.
중첩 vs 분리: 데이터 관계와 접근 패턴에 따라 선택
5.
프로토콜 설계: 양방향 통신은 명확한 메시지 타입으로 상태 동기화

References