티스토리 뷰
1. 개요
사이드프로젝트에서 채팅을 구현했었다. 양방향 통신, 즉 웹소켓은 처음 사용해봐서 구현하기 전에 설렛던 기능이다. 어려울줄 알았는데, STOMP 프로토콜을 사용해서 어렵지 않게 구현할 수 있었다.
2. STOMP 란?
WebSocket 이 양방향 통신을 열어주고, 각 프레임이 전송된다고 했을 때, STOMP 는 그 프레임에 대한 프로토콜이다. 마치 Http 통신과 그 위에 있는 REST 의 개념이랄까.
WebSocket 만을 사용한다면, 어떤 메시지를 누구에게 보내고 어떻게 구독할지? 서버-클라이언트간의 프로토콜을 직접 만들어 소통해야 되는데, STOMP 는 메시징을 위해 약속을 미리 정해주었고, 그 약속에 맞춰 서버와 클라이언트가 통신을 하도록 되어있다.
특별히 Connect, Subscribe, Message, Send 등으로 메시징 기능에 특화된 프로토콜이다.
3. iOS 에서 적용하기
3-1. SwiftStomp Client 객체 생성
SwiftStomp 라이브러리를 사용해 기능을 구현했다.
예제를 보면 상당히 쉽게 연결할 수 있는걸로 보이는데, SwiftStomp 객체를 만들어 사용하는걸 볼 수 있다.
let url = URL(string: "ws://192.168.88.252:8081/socket")!
self.swiftStomp = SwiftStomp(host: url) //< Create instance
self.swiftStomp.delegate = self //< Set delegate
self.swiftStomp.autoReconnect = true //< Auto reconnect on error or cancel
self.swiftStomp.connect() //< Connect
나는 SwiftStomp 기능들을 한번 감싼 StompClient 객체를 만들어 사용했다.
현재 개발 요구사항에서는 객체가 두개 이상일 필요는 없다고 판단해 싱글톤 객체로 만들었다.
public class StompClient {
// MARK: - Instance
public static let shared = StompClient()
// MARK: - Properties
private var client: SwiftStomp!
private var accessToken: String? {
return TokenManager.accessToken
}
public var socketConnectionStatus = CurrentValueSubject<SocketConnectionStatus, Never>(.disconnected)
public var onMessageReceived = PassthroughSubject<Message, Never>()
// MARK: - Lifecycle
private init() {
let url = URL(string: "\(ServerType.current.socketBaseUrl)")!
self.client = SwiftStomp(
host: url,
httpConnectionHeaders: [
"Authorization": "Bearer \(accessToken ?? "")"
]
)
self.client.autoReconnect = true
self.client.enableAutoPing()
subscribeStomp()
}
}
라이브러리 예제와 동일해서 특별한 코드는 없는데, httpConnectionHeader 를 넣어주는 것을 볼 수 있다. WebSocket 은 초기 연결할 때 handshake 를 하게 되는데, 그 때 유저 검증을 위한 토큰을 넣어줄 수 있다.
SwiftStomp 라이브러리는 delegate 또는 combine 방식으로 이벤트를 수신할 수 있다. 나는 combine 방식으로 이벤트를 수신했다.
func subscribeStomp() {
client.eventsUpstream
.receive(on: RunLoop.main)
.sink { [weak self] event in
guard let self else { return }
switch event {
case .connected(_):
socketConnectionStatus.send(.connected)
case .disconnected(_):
socketConnectionStatus.send(.disconnected)
case let .error(error):
print(error)
socketConnectionStatus.send(.disconnected)
}
}
.store(in: &subscriptions)
client.messagesUpstream
.receive(on: RunLoop.main)
.sink { [weak self] message in
guard let self else { return }
if case let .text(message, messageId, destination, _) = message {
print("💬 [Received]", message)
guard let response = decodeMessageToDto(message) else { return }
let messageModel = Message(from: response)
onMessageReceived.send(messageModel)
}
}
.store(in: &subscriptions)
client.receiptUpstream
.sink { receiptId in
print("SwiftStop: Receipt received: \(receiptId)")
}
.store(in: &subscriptions)
}
func decodeMessageToDto(_ rawMessage: String) -> ChatSocketResponse? {
guard let jsonData = rawMessage.data(using: .utf8) else {
return nil
}
let decoder = JSONDecoder()
do {
let response = try decoder.decode(ChatSocketResponse.self, from: jsonData)
return response
} catch {
print(error)
return nil
}
}
eventsUpStraem
에서는 connect 상태를 받을 수 있다. 내가 만들어둔 socketConnectionStatus
로 바인딩하여 사용했다. 에러처리는 아직 안해서 일단 .disconnect
로 보내버렸다.messagesUpstream
에서는 STOMP 의 핵심인 메시지를 수신할 수 있다. 여기서는 서버와의 규약에 따라 if case let .text 로 풀어주었다. 다른 type 은 .data 가 있는데, 이건 서버와의 규약에 따라 맞는것을 사용하면 되겠다.
이렇게 메시지를 받으면 우리같은 경우는 JSON 형태의 String 을 받는다. 이 String 을 dto 로 만들어 PassthroughSubject 인 onMessageReceived 로 send 해주었다.
(RxSwift 쓰다가 Combine 은 처음인데 이렇게 쓰는게 맞나 .. )
public struct ChatSocketResponse: Codable {
let id: String
let channelId: String
let senderUserId: String?
let content: Content
let createdAt: String
public struct Content: Codable {
let type: String
let title: String?
let text: String
let cardColor: String?
}
}
3-2. Connect / Subscribe / Event 수신
현재는 외부로 4개의 메소드를 공개했다. 각각 connect, disconnect, subscribe, sendMessage
public func connect() {
if !client.isConnected {
socketConnectionStatus.send(.connecting)
client.connect()
}
}
public func disconnect() {
if client.isConnected {
client.disconnect()
socketConnectionStatus.send(.disconnected)
}
}
public func subscribe(channelId: String) {
let destination = "/channel/\(channelId)"
client.subscribe(
to: destination,
mode: .client
)
}
public func sendMessage(
request: ChatSocketMessageRequest,
channelId: String
) {
client.send(body: request, to: "/app/channel/\(channelId)")
}
STOMP 는 Connect 이후 메시지를 수신하려면 구독을 먼저 해야한다. 연결만 한다고 수신이 되는건 아니다. 어떤 채팅방, 즉 destination 을 구독해야 한다. 그걸 우리 팀에서는 channel 로 정의 했으며 위는 그 예시이다.
sendMessage 기능은 메시지를 destination 으로 보내는 기능이다. STOMP 와 서버의 프로토콜에 맞추어, body 와 어떤 destination 으로 보낼지 정할 수 있다.
3-3. 외부에서 사용하기
외부에서 직접 사용하는 부분도 살펴보자
func onAppear() {
subscribeStomp()
stompClient.connect()
requestMessageList()
}
func subscribeStomp() {
stompClient.socketConnectionStatus
.receive(on: RunLoop.main)
.sink { [weak self] event in
guard let self else { return }
switch event {
case .connected:
stompClient.subscribe(channelId: channelId)
case .disconnected:
print("disconnected")
default:
return
}
}
.store(in: &subscriptions)
stompClient.onMessageReceived
.receive(on: RunLoop.main)
.sink { [weak self] message in
guard let self else { return }
model?.socketReceivedNewMessage(message: message)
}
.store(in: &subscriptions)
}
func sendMessage(_ message: String) {
let requestBody = ChatSocketMessageRequest(
senderUserId: tempUserId,
messageContent: message,
messageType: "TEXT"
)
stompClient.sendMessage(
request: requestBody,
channelId: channelId
)
}
onAppear 에서 connect 를 시도하는 것을 볼 수 있다. 그리고 동시에 StompClient 에서 방출하는 이벤트를 .sink 하는데, connect 가 완료됨과 동시에 특정 channel 을 subscribe 하는 것을 볼 수 있다.
subscribe 까지 성공한다면, 메시지를 수신할 준비는 다 되었다. 그렇다면 아까 만들었던 StompClient 에서는 STOMP 에서 raw message 를 수신하여 dto 로 변환해 새로운 메시지 모델로 줄 것이다. 그걸 그냥 받으면 된다!
3-4. REST 와 함께 사용하기
실시간으로 수신하는 채팅은 웹소켓에서 오지만, 이미 이전에 보냈었던 채팅도 웹소켓으로 받을까?
-> 그렇지 않다. 과거의 메시지는 일반적으로 REST API 로 페이지네이션 처리까지 하여 받게 된다.
REST 와 웹소켓, 두가지 통신에서 들어오는 데이터를 하나의 모델로 잘 합쳐주면 문제는 없어보인다. 어떤 방법이 좋을까? 프로토콜로 만들까?
-> 정답은 없겠지만, 도메인 레이어에서 initializer 를 생성하는 것으로 풀어보았다.
우선, REST 에서 들어오는 dto 가 있고, 웹소켓에서 들어오는 dto 가 있다. 이 두 종류의 dto 를 도메인 레이어에서 동일한 엔티티로 변환할 수 있도록 각각 대응하는 initializer 를 정의했다.
public init(from dto: Components.Schemas.Message) {
self.id = dto.id
self.senderUserId = dto.senderUserId
self.createdAt = DateConverter.stringToDate(
string: dto.createdAt,
format: "yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'"
)
self.type = senderUserId == TokenManager.userId ? .my : .other(.init(id: senderUserId ?? ""))
self.content = MessageContent(from: dto.content)
}
public init(from dto: ChatSocketResponse) {
self.id = dto.id
self.senderUserId = dto.senderUserId
self.createdAt = DateConverter.stringToDate(string: dto.createdAt)
self.type = senderUserId == TokenManager.userId ? .my : .other(.init(id: senderUserId ?? ""))
self.content = MessageContent(from: dto.content)
}
이렇게 해두면, 메시지 데이터의 출처(REST / WebSocket)에 상관없이 결과적으로는 동일한 도메인 엔티티가 생성된다. 따라서 요렇게 구현하면 유즈케이스 계층이나 상위 모듈에서는 데이터의 출처를 신경쓰지 않고 일관된 방식으로 처리할 수 있을 것이라고 판단했다.
4. 결론
WebSocket 과 STOMP 자체는 어렵지 않았다. STOMP 프로토콜 자체가 어렵지 않기도 했고, 라이브러리도 잘 되어 있어서 채팅 기능을 구현하는 것 자체는 어렵지 않았는데, 어떻게 메시지들을 관리할지 고민하는 시간이 좀 들었다.
단순 텍스트만 왔다갔다 하면 개발자들은 행복하겠지만, 사진과 동영상, 요즘에는 채팅으로 이것저것 다양한걸 보낼 수 있는데, 그런 확장 기능들을 처리하는것도 머리가 아팠다. 특히 해당 프로젝트 기획에서는 인터랙션 카드가 있어서 어떻게 대응할지 고민을 많이 했었다.
그래두 처음 채팅을 구현하게 되면서 이런저런 고민도 해보고 부딪히는 즐거운 시간..(!) 이였다 .
'iOS' 카테고리의 다른 글
Github Action self-hosted-runner 도입기 (0) | 2025.05.22 |
---|---|
[iOS] MVI 패턴과 테스트코드 적용하기 (1) | 2025.05.13 |
[iOS] String Catalog 로 Localization, 다국어 지원하기 (0) | 2025.04.19 |
[iOS] SwiftUI 에서 TapGesture 가 동작하지 않을 때 (HighPriorityGesture) (1) | 2025.03.23 |
[iOS] Swift로 오디오 다루기 (AudioKit) (0) | 2025.03.16 |
- Total
- Today
- Yesterday
- flo
- ios 다국어
- 맥북에어 m4
- openapi-generator
- swiftui 탭
- watch connectivity
- Swift
- Github action
- demical
- Xcode15
- open-api-generator
- avplayer
- audio kit
- 애플워치 데이터 전송
- swift audio
- swift날짜
- easy cue
- ios웹소켓
- keyboardtype
- DateFormatter
- SwiftUI
- audiokit
- self-hosted-runner
- ios채팅
- string catalog
- IOS
- swiftui 제스처
- onTapGesture
- AVFoundation
- highprioritygesture
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 | 28 | 29 | 30 |
31 |