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.
성능: 특정 대화방 메시지 조회 시
단점도 있다.
•
대화방 전체를 수정해야 메시지 하나 추가 가능
•
대화방이 많으면 메모리 부담
P2P 채팅 특성상 동시 대화방 수가 적으므로 중첩 구조가 적합했다.
왜 DM 프로토콜을 요청/수락/거절로 설계했는가?
case dmRequest // DM 요청
case dmRequestAccept // 수락
case dmRequestDecline // 거절
case directMessage // 실제 메시지
case dmReadReceipt // 읽음 확인
case dmLeave // 나가기
Swift
복사
왜 바로 메시지를 보내지 않고 요청 단계를 두었을까?
1.
스팸 방지: 상대가 수락해야만 메시지 전송 가능
2.
사용자 경험: 원치 않는 DM을 사전에 거절 가능
3.
상태 동기화: 양쪽 모두 대화방 상태를 알 수 있음
A → B: dmRequest (메시지와 함께)
B → A: dmRequestAccept
A ↔ B: directMessage (이제 양방향 가능)
A → B: dmReadReceipt
B → A: 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.
프로토콜 설계: 양방향 통신은 명확한 메시지 타입으로 상태 동기화
