티스토리 뷰

iOS

[iOS] STOMP 로 채팅 구현하기

jisuuuu 2025. 5. 11. 03:40

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 프로토콜 자체가 어렵지 않기도 했고, 라이브러리도 잘 되어 있어서 채팅 기능을 구현하는 것 자체는 어렵지 않았는데, 어떻게 메시지들을 관리할지 고민하는 시간이 좀 들었다.

단순 텍스트만 왔다갔다 하면 개발자들은 행복하겠지만, 사진과 동영상, 요즘에는 채팅으로 이것저것 다양한걸 보낼 수 있는데, 그런 확장 기능들을 처리하는것도 머리가 아팠다. 특히 해당 프로젝트 기획에서는 인터랙션 카드가 있어서 어떻게 대응할지 고민을 많이 했었다.

그래두 처음 채팅을 구현하게 되면서 이런저런 고민도 해보고 부딪히는 즐거운 시간..(!) 이였다 .

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/08   »
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
글 보관함