코드엘 프로젝트를 진행하면서 사용자 간 실시간 소통이 필요한 기능이 있었고, 자연스럽게 채팅 기능을 구현하게 되었어요.
처음 채팅 기능을 생각했을 때 가장 먼저 든 질문은 이것이었어요.
- 채팅방 목록 조회
- 이전 대화 내역 조회
- 읽음 처리
- 새 메시지 수신
이 기능들을 전부 같은 방식으로 처리해야 할까?
결론부터 말하면 코드엘은 이 역할을 둘로 나눠서 구현하고 있었어요.
- 초기 조회와 상태 동기화는 HTTP
- 실시간 메시지 수신은 WebSocket + STOMP
이번 글에서는
- WebSocket이 무엇인지
- HTTP와 무엇이 다른지
- 왜 채팅에는 WebSocket이 필요한지
- 코드엘 iOS 코드에서는 이를 어떻게 구성했는지
흐름 중심으로 정리해보려고 해요.
WebSocket이란 무엇일까
WebSocket은 클라이언트와 서버가 한 번 연결을 맺은 뒤, 그 연결을 유지한 채 계속 데이터를 주고받을 수 있는 통신 방식이에요.
HTTP는 보통 이렇게 동작해요.
- 클라이언트가 요청한다
- 서버가 응답한다
- 연결 흐름이 종료된다
반면 WebSocket은 이렇게 이해하면 쉬워요.
- 처음에 연결을 맺는다
- 연결을 유지한다
- 필요할 때마다 양쪽이 메시지를 보낸다
즉 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
}
}
즉 순서는 보통 이렇게 이해하면 돼요.
- 로그인 완료
- WebSocket 연결
- 연결 성공 확인
- 내 채팅방 목록 구독
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으로 실시간 반영하는 구조"예요.
조금 더 풀어 쓰면 이렇게 정리할 수 있어요.
- 로그인 후 소켓 연결을 맺는다
- 채팅 목록 화면에서 내 채팅방 목록을 subscribe 한다
- 채팅방 화면에서 특정 room을 subscribe 한다
- 메시지를 보낼 때는 publish 한다
- 서버는 새 메시지나 채팅방 변경 사항을 push 한다
- StompManager가 delegate에서 이를 decode 한다
- publisher를 통해 feature로 전달하고 화면 상태를 갱신한다
마치며
이번에 코드엘 채팅 코드를 따라가보면서 느낀 건,
WebSocket은 단순히 "실시간이라서 쓰는 기술"이 아니라 역할 분리가 꽤 중요한 구조라는 점이었어요.
코드엘은 이걸 비교적 깔끔하게 나눠두고 있었어요.
- HTTP는 조회와 동기화
- WebSocket은 연결 유지와 실시간 전달
- STOMP는 subscribe / publish 규칙 정리
- SwiftStomp는 iOS에서 그 규칙을 구현하는 도구
처음엔 저도 코드를 보면서 막연히 "소켓이네" 정도로만 이해했는데, 흐름을 따라가보니 생각보다 구조가 분명했어요.
특히 subscribe, publish, onMessageReceived, publisher, state update 순서로 보면 전체 그림이 훨씬 잘 보이더라고요.
혹시 저처럼 실시간 채팅 구현이 처음이라면, "WebSocket이 뭔가?"보다 먼저 "HTTP가 맡는 역할과 실시간 스트림이 맡는 역할이 어떻게 나뉘는가?"부터 보는 게 훨씬 이해가 쉬운 것 같아요.