본문 바로가기
카테고리 없음

WebSocket이 무엇이고, 코드엘에서는 어떻게 채팅을 구현했을까

by hong7 2026. 4. 20.

코드엘 프로젝트를 진행하면서 사용자 간 실시간 소통이 필요한 기능이 있었고, 자연스럽게 채팅 기능을 구현하게 되었어요.

처음 채팅 기능을 생각했을 때 가장 먼저 든 질문은 이것이었어요.

  • 채팅방 목록 조회
  • 이전 대화 내역 조회
  • 읽음 처리
  • 새 메시지 수신

이 기능들을 전부 같은 방식으로 처리해야 할까?

결론부터 말하면 코드엘은 이 역할을 둘로 나눠서 구현하고 있었어요.

  • 초기 조회와 상태 동기화는 HTTP
  • 실시간 메시지 수신은 WebSocket + STOMP

 

이번 글에서는

  • WebSocket이 무엇인지
  • HTTP와 무엇이 다른지
  • 왜 채팅에는 WebSocket이 필요한지
  • 코드엘 iOS 코드에서는 이를 어떻게 구성했는지

흐름 중심으로 정리해보려고 해요.

 

WebSocket이란 무엇일까

WebSocket은 클라이언트와 서버가 한 번 연결을 맺은 뒤, 그 연결을 유지한 채 계속 데이터를 주고받을 수 있는 통신 방식이에요.

HTTP는 보통 이렇게 동작해요.

  1. 클라이언트가 요청한다
  2. 서버가 응답한다
  3. 연결 흐름이 종료된다

 

반면 WebSocket은 이렇게 이해하면 쉬워요.

  1. 처음에 연결을 맺는다
  2. 연결을 유지한다
  3. 필요할 때마다 양쪽이 메시지를 보낸다

 

즉 WebSocket의 핵심은 두 가지예요.

  • 연결이 계속 유지된다
  • 서버도 먼저 클라이언트에게 메시지를 보낼 수 있다

채팅처럼 "상대방이 보낸 순간 바로 받아야 하는 기능"에는 이 특성이 아주 잘 맞아요.

 

HTTP와 WebSocket의 차이

둘의 차이는 "누가 먼저 말할 수 있느냐"와 "연결을 계속 유지하느냐"로 정리할 수 있어요.

 

HTTP

  • 요청-응답 기반
  • 클라이언트가 먼저 요청해야 서버가 응답할 수 있음
  • 한 번 응답이 끝나면 흐름도 끝남

 

WebSocket

  • 연결 유지 기반
  • 클라이언트와 서버가 양방향으로 메시지 전달 가능
  • 서버가 먼저 이벤트를 밀어줄 수 있음

 

그림으로 보면 더 직관적이에요.

HTTP
WebSocket

 

왜 채팅에는 WebSocket이 필요할까

채팅에서 중요한 건 "내가 요청할 때만 데이터를 받는 것"이 아니라 "상대방이 보내는 순간 바로 받는 것"이에요.

만약 채팅을 HTTP만으로 구현한다면, 새 메시지가 왔는지 확인하려고 계속 서버에 물어봐야 해요.

  • 1초마다 새 메시지가 있는지 조회
  • 채팅방 목록이 바뀌었는지 반복 조회
  • 안 읽은 메시지 개수를 주기적으로 다시 조회

 

이 방식은 구현은 쉬울 수 있지만 채팅과는 잘 맞지 않아요.

  • 요청이 너무 자주 발생할 수 있어요
  • 실시간성이 polling 주기에 의존해요
  • 변경이 없어도 계속 요청을 보내야 해요

 

그래서 코드엘은 채팅 기능에서 HTTP만 사용하지 않았어요.

채팅방 목록 초기 조회나 이전 메시지 조회는 HTTP로 처리하고, 새 메시지나 채팅방 상태 변경처럼 실시간성이 필요한 부분은 WebSocket 기반으로 처리하고 있었어요.

 

코드엘에서는 HTTP와 WebSocket을 어떻게 나눴을까

코드엘 코드를 보면 역할 분리가 꽤 분명해요.

ChatAPI에는 일반 HTTP 요청이 들어 있어요.

enum ChatAPI: APIConfigurable {
    case updateLastRead(roomId: Int, lastChatId: Int)
    case randomQuestion(roomId: Int, category: QuestionCategory)
    case leave(roomId: Int)
    case close(roomId: Int)
    case chatRooms(PaginationRequestDTO)
    case chats(roomId: Int, lastReadChatId: Int? = 0, pageable: PaginationRequestDTO)
    case previousChats(roomId: Int, lastReadChatId: Int? = 0, pageable: PaginationRequestDTO)
}

이걸 보면 HTTP는 이런 역할을 맡고 있다는 걸 알 수 있어요.

  • 채팅방 목록 최초 조회
  • 현재 읽지 않은 메시지 조회
  • 이전 메시지 페이지네이션 조회
  • 마지막으로 읽은 메시지 ID 업데이트
  • 대화 종료, 나가기 같은 일반 API 액션

 

반대로 소켓 계층은 STOMPEvent로 따로 분리되어 있어요.

enum STOMPEvent: STOMPConfigurable, Hashable, Equatable {
    case subscribeRooms(memberId: Int)
    case subscribeChat(roomId: Int)
    case unsubscribeChat(roomId: Int)
    case sendChat(roomId: Int, chat: ChatRequestDTO)
}

즉 코드엘은 이렇게 역할을 나눈 셈이에요.

  • 과거 데이터 조회와 상태 보정은 HTTP
  • 새 이벤트 수신과 메시지 전송은 WebSocket/STOMP

이 분리가 채팅 구현을 훨씬 이해하기 쉽게 만들어줘요.

 

그런데 왜 WebSocket만 쓰지 않고 STOMP까지 썼을까

여기서 한 단계 더 생각해보면, WebSocket은 "연결을 유지하는 통로"에 가까워요.

하지만 채팅에서는 통로만 있다고 끝나지 않아요.

우리는 이런 규칙이 필요해요.

  • 어느 채널을 구독할 것인지
  • 어느 경로로 메시지를 보낼 것인지
  • 채팅방 목록 업데이트와 채팅 메시지를 어떻게 구분할 것인지
  • 채팅방을 나갈 때 어떤 구독을 해제할 것인지

 

이런 규칙을 정리해주는 프로토콜이 STOMP예요.

쉽게 말하면:

  • WebSocket은 연결 통로
  • STOMP는 그 통로 위에서 메시지를 주고받는 규칙

 

STOMP를 쓰면 흐름이 훨씬 명확해져요.

  • CONNECT: 연결
  • SUBSCRIBE: 특정 destination 구독
  • SEND: 특정 destination으로 메시지 전송
  • UNSUBSCRIBE: 구독 해제

채팅은 본질적으로 "어디를 구독하고 어디로 보내는가"가 중요한 기능이라서, STOMP가 잘 어울려요.

 

코드엘에서는 STOMP를 어떻게 표현했을까

코드엘에서는 STOMP 동작을 문자열로 흩뿌리지 않고 STOMPEvent라는 enum으로 묶어두었어요.

enum STOMPEvent: STOMPConfigurable, Hashable, Equatable {
    case subscribeRooms(memberId: Int)
    case subscribeChat(roomId: Int)
    case unsubscribeChat(roomId: Int)
    case sendChat(roomId: Int, chat: ChatRequestDTO)

    var destination: String {
        switch self {
        case .subscribeRooms(let memberId):
            return "/sub/v1/chatroom/member/\\\\(memberId)"
        case .unsubscribeChat(let roomId),
             .subscribeChat(let roomId):
            return "/sub/v1/chatroom/\\\\(roomId)"
        case .sendChat(let roomId, _):
            return "/pub/v1/chatroom/\\\\(roomId)/chat"
        }
    }

    var command: STOMPCommand {
        switch self {
        case .subscribeRooms, .subscribeChat:
            return .subscribe
        case .unsubscribeChat:
            return .unsubscribe
        case .sendChat:
            return .publish
        }
    }
}

이 구조가 좋은 이유는 명확해요.

  • destination이 한 곳에 모여 있어요
  • subscribe / publish / unsubscribe 의도가 코드에 그대로 드러나요
  • 화면 계층은 "무슨 이벤트를 보낼지"만 알면 돼요

 

SwiftStomp는 왜 필요했을까

여기서 자주 헷갈리는 게 있어요.

  • STOMP는 프로토콜
  • SwiftStomp는 그 프로토콜을 Swift에서 쓰게 도와주는 라이브러리

 

즉, 코드엘이 채택한 건 "SwiftStomp라는 기술"이 아니라

"STOMP 프로토콜"이고, iOS 구현 도구로 SwiftStomp를 사용한 거예요.

코드 구조는 대략 이렇게 되어 있어요.

즉 화면은 직접 SwiftStomp를 다루지 않고, StompClient와 StompManager가 중간 계층 역할을 해요.

 

코드엘에서 SwiftStomp를 어떻게 사용했을까

1. StompClient가 외부 진입점 역할을 한다

StompClient는 feature 레이어에서 사용하는 인터페이스예요.

final class StompClient: @unchecked Sendable {
    private let manager: StompManager

    func connect() async
    func disconnect() async
    func sendChat(roomId: Int, message: String, memberId: Int) async
    func subscribeRooms(memberId: Int) async
    func subscribeChat(roomId: Int) async
    func unsubscribeChat(roomId: Int) async
    func chatPublisher() -> AnyPublisher<ChatModel, Never>
    func chatRoomsPublisher() -> AnyPublisher<ChatRoomModel, Never>
}

즉 feature 입장에서는 내부 구현을 몰라도 돼요.

  • 연결하고 싶다
  • 채팅방을 구독하고 싶다
  • 메시지를 보내고 싶다
  • 실시간 메시지를 받고 싶다

이 의도만 전달하면 실제 처리는 StompManager가 맡아요.

 

2. 실제 연결은 StompManager가 담당한다

실제 SwiftStomp 객체를 들고 있는 건 StompManager예요.

func connect(url: URL, accessToken: String) {
    queue.async { [weak self] in
        guard let self = self else { return }

        if self.swiftStomp.isConnected {
            return
        }

        self.swiftStomp = SwiftStomp(host: url, headers: [
            "Authorization": "Bearer \\\\(accessToken)",
            "content-type": "application/json"
        ])

        self.swiftStomp.delegate = self
        self.swiftStomp.enableLogging = true
        self.swiftStomp.enableAutoPing(pingInterval: 10)
        self.swiftStomp.connect(autoReconnect: false)
    }
}

여기서 볼 수 있는 포인트는 이래요.

  • 소켓 URL은 Bundle.main.socketURL에서 가져온다
  • 연결 시 Authorization 헤더에 Bearer 토큰을 붙인다
  • SwiftStompDelegate를 등록해 이벤트를 받는다
  • enableAutoPing으로 연결 유지에 도움을 준다

즉 코드엘의 소켓 연결은 "로그인 후 인증된 사용자 기준으로 여는 연결"이에요.

 

코드엘에서 소켓 연결은 언제 시작될까

이 부분도 코드 기준으로 보면 더 명확해요.

스플래시에서 로그인 성공 후, 회원가입이 완료된 사용자라면 소켓 연결을 시도해요.

if model.memberStatus == .done {
    await stompClient.connect()

    for await connected in stompClient.connectionPublisher().values {
        if connected {
            break
        }
    }
}

 

 AppCoordinatorFeature에서도 연결 성공을 기다렸다가 채팅방 목록을 구독하는 흐름이 있어요.

await stompClient.connect()

for await isConnected in stompClient.connectionPublisher().values {
    if isConnected {
        await stompClient.subscribeRooms(memberId: memberId)
        break
    }
}

 

즉 순서는 보통 이렇게 이해하면 돼요.

  1. 로그인 완료
  2. WebSocket 연결
  3. 연결 성공 확인
  4. 내 채팅방 목록 구독

 

subscribe / publish / receive 흐름은 어떻게 이어질까

이제 가장 중요한 흐름을 코드엘 기준으로 정리해볼게요.

 

1. 채팅 목록 화면에서는 채팅방 목록을 subscribe 한다

ChatFeature의 bodyTask를 보면, 화면 진입 시 채팅방 목록을 구독하고 HTTP 초기 조회도 함께 실행해요.

case .bodyTask:
    let subscribe: Effect<Action> =
        .run { _ in
            guard let memberId = await keychain.read(.memberId).flatMap(Int.init) else {
                return
            }
            await stompClient.subscribeRooms(memberId: memberId)
        }

    let initialLoad: Effect<Action> =
        .merge(
            reduce(into: &state, action: .fetchUnlockedCodes(page: 0, size: 10)),
            reduce(into: &state, action: .fetchChatRooms(page: 0, size: 10))
        )

이건 의미가 분명해요.

  • 처음 화면을 열 때는 HTTP로 현재 목록을 가져오고
  • 그다음부터는 STOMP 구독으로 변경 사항을 실시간 반영한다

 

그리고 실시간 채팅방 업데이트는 chatRoomsPublisher()를 통해 받아요.

let realtimeSubscription: Effect<Action> = .publisher {
    stompClient.chatRoomsPublisher()
        .receive(on: DispatchQueue.main)
        .map { Action.receiveMessage($0) }
}

즉 채팅 목록 화면에서는 "내 채팅방 목록 destination"을 subscribe 하고,

서버가 보내주는 ChatRoomModel을 받아 목록 상태를 갱신하는 구조예요.

 

2. 채팅방 화면에서는 특정 room을 subscribe 한다

ChatRoomFeature의 bodyTask를 보면 채팅방 진입 시 다음 일을 동시에 수행해요.

return .merge(
    .send(.fetchChats(lastReadChatId: state.chatRoom.lastReadChatId)),

    .run { [roomId = state.roomId] _ in
        await stompClient.subscribeChat(roomId: roomId)
    },

    .publisher { [roomId = state.roomId] in
        stompClient.chatPublisher()
            .filter { $0.chatRoomId == roomId }
            .receive(on: DispatchQueue.main)
            .map { Action.receiveMessage($0) }
    }
)

여기서도 패턴이 같아요.

  • HTTP로 현재 채팅 내역을 먼저 가져오고
  • 동시에 해당 채팅방을 subscribe 하고
  • 이후부터는 실시간 메시지를 publisher로 받는다

 

특히 .filter { $0.chatRoomId == roomId }가 중요해요.

앱 전체로 들어오는 메시지 중 현재 화면이 보고 있는 채팅방 메시지만 반영하겠다는 뜻이거든요.

 

3. 사용자가 메시지를 보내면 publish 한다

메시지 전송 버튼을 누르면 stompClient.sendChat(...)이 호출돼요.

case .messageSendButtonTapped:
    guard let memberId = state.memberId else { return .none }
    return .run { [roomId = state.roomId, message = state.message] send in
        await stompClient.sendChat(roomId: roomId, message: message, memberId: memberId)
        await send(.state(.clearMessage))
    }

 

그리고 StompClient 내부에서는 이걸 sendChat 이벤트로 바꿔 StompManager에 넘겨요.

func sendChat(roomId: Int, message: String, memberId: Int) async {
    let model = ChatRequestDTO(message: message, memberId: memberId)
    manager.request(event: .sendChat(roomId: roomId, chat: model))
}

 

StompManager는 이벤트의 command가 .publish면 실제 SwiftStomp.send(...)를 호출해요.

case .publish:
    self.swiftStomp.send(
        body: event.parameters,
        to: event.destination,
        receiptId: nil
    )

즉 "전송 버튼 탭 → STOMPEvent.sendChat → publish → 서버 전달" 흐름이에요.

 

4. 서버가 새 메시지를 보내면 delegate에서 받는다

서버가 새 메시지나 채팅방 업데이트를 보내면 SwiftStompDelegate의 onMessageReceived가 호출돼요.

func onMessageReceived(
    swiftStomp: SwiftStomp,
    message: Any?,
    messageId: String,
    destination: String,
    headers: [String : String]
) {
    guard let dataString = message as? String else { return }
    let decoder = JSONDecoder()

    if let chat = try? decoder.decode(ChatResponseDTO.self, from: Data(dataString.utf8)) {
        chatSubject.send(chat.toModel())
    } else if let chatRoom = try? decoder.decode(ChatRoomResponseDTO.self, from: Data(dataString.utf8)) {
        chatRoomSubject.send(chatRoom.toModel())
    }
}

이게 코드엘 실시간 흐름의 핵심이에요.

  • 메시지 payload를 받는다
  • ChatResponseDTO인지 ChatRoomResponseDTO인지 decode 해본다
  • 맞는 publisher로 흘려보낸다

 

즉 코드엘은 하나의 소켓 계층 안에서

  • 채팅 메시지 스트림
  • 채팅방 상태 변경 스트림

을 분리해서 다루고 있었어요.

 

5. Feature가 publisher를 구독해 상태를 갱신한다

실시간 데이터가 publisher로 흘러오면, 각 feature가 자기 책임 범위만 반영해요.

ChatFeature는 채팅방 목록을 업데이트하고,

case let .realtimeChatRoom(chatRoom):
    if chatRoom.eventType == .update {
        ...
    } else {
        state.chatRooms.data.removeAll(where: { $0.id == chatRoom.id })
    }

 

ChatRoomFeature는 채팅 메시지를 append 하고 읽음 위치를 갱신해요.

case let .realtimeChat(chat):
    if state.unreadChats.data.contains(where: { $0.id == chat.id }) {
        return .none
    }
    state.unreadChats.data.append(updatedChat)
    return .send(.state(.updateLastReadChatId(chat.id)))

 

결국 흐름은 이렇게 정리할 수 있어요.

 

unsubscribe는 언제 필요할까

구독만큼 중요한 게 구독 해제예요.

코드엘에서는 채팅방을 빠져나올 때 unsubscribeChat(roomId:)를 호출해요.

 

AppCoordinatorFeature에는 이런 코드가 있어요.

case let .chatDidFinish(roomId, lastReadChatId):
    return .run(priority: .high) { _ in
        await stompClient.unsubscribeChat(roomId: roomId)
        guard let lastReadChatId else { return }
        try? await updateLastRead(roomId, lastReadChatId)
    }

즉 채팅방에서 나갈 때는

  • 해당 room 구독을 해제하고
  • 마지막 읽은 메시지 ID를 HTTP로 업데이트해요

실시간 통신과 일반 REST 동기화를 함께 쓰는 코드엘의 방식이 여기서도 드러나요.

 

코드엘 채팅 구현을 한 문장으로 정리하면

코드엘의 채팅은 "초기 상태는 HTTP로 가져오고, 이후 변경 사항은 STOMP over WebSocket으로 실시간 반영하는 구조"예요.

조금 더 풀어 쓰면 이렇게 정리할 수 있어요.

  1. 로그인 후 소켓 연결을 맺는다
  2. 채팅 목록 화면에서 내 채팅방 목록을 subscribe 한다
  3. 채팅방 화면에서 특정 room을 subscribe 한다
  4. 메시지를 보낼 때는 publish 한다
  5. 서버는 새 메시지나 채팅방 변경 사항을 push 한다
  6. StompManager가 delegate에서 이를 decode 한다
  7. publisher를 통해 feature로 전달하고 화면 상태를 갱신한다

마치며

이번에 코드엘 채팅 코드를 따라가보면서 느낀 건,

WebSocket은 단순히 "실시간이라서 쓰는 기술"이 아니라 역할 분리가 꽤 중요한 구조라는 점이었어요.

 

코드엘은 이걸 비교적 깔끔하게 나눠두고 있었어요.

  • HTTP는 조회와 동기화
  • WebSocket은 연결 유지와 실시간 전달
  • STOMP는 subscribe / publish 규칙 정리
  • SwiftStomp는 iOS에서 그 규칙을 구현하는 도구

처음엔 저도 코드를 보면서 막연히 "소켓이네" 정도로만 이해했는데, 흐름을 따라가보니 생각보다 구조가 분명했어요.

 

특히 subscribe, publish, onMessageReceived, publisher, state update 순서로 보면 전체 그림이 훨씬 잘 보이더라고요.

 

혹시 저처럼 실시간 채팅 구현이 처음이라면, "WebSocket이 뭔가?"보다 먼저 "HTTP가 맡는 역할과 실시간 스트림이 맡는 역할이 어떻게 나뉘는가?"부터 보는 게 훨씬 이해가 쉬운 것 같아요.