먼저 채팅 서비스를 구현해야한다고 했을때 다들 대부분 WebSocket을 사용해야하는 것은 알고있지만 어떤 이유로 웹소켓을 활용해야하는지 비교를 통해 개념을 잡아보고 iOS 에서는 어떤 방식을 사용해서 웹소켓을 구현할 수 있는지 구현 전략에 대해서 알아보고자 한다.
- HTTP 방식과 WebSocket의 차이
통신 방식 요청 → 응답 (단방향) 지속 연결 (양방향) 연결 유지 요청마다 새로 연결 한 번 연결 후 유지 데이터 전송 단위 바이트 기반 메시지 단위 (텍스트/바이너리) 지연시간 높음 (요청/응답 반복) 낮음 (이벤트 실시간 처리) - 메시지 지향 (Message-Oriented)
- HTTP는 바이트 데이터를 주고 받는 방식이라 데이터 경계를 직접 구분해야 한다.
- WebSocket은 프레임 → 메시지로 구조화 되어있어 데이터를 한건의 메시지 단위로 주고받을 수 있다.
- ex : 채팅에서 “안녕”이라는 메시지를 보냈을 때, 그 하나가 독립적인 단위가 되어야 UI에 표시가 가능하다.
- 낮은 지연 (Low Latency)
- HTTP기반의 폴링/ 롱폴링 요청-응답 방식이라 새로운 데이터가 있어도 주기적으로 요청해야 한다.
- WebSocket은 지속 연결로 서버가 즉시 클라이언트에게 Push할 수 있어 지연이 거의 없다.
- ex : 상대방이 메시지를 보낼 때 0.5초 내로 받으려면 WebSocket이 필요하다.
- 양방향 통신 (Full Duplex)
- HTTP는 클라이언트가 요청해야만 서버가 응답이 가능한 형태!
- WebSocket은 서버도 먼저 메시지를 보낼 수 있다.
- ex : 타이핑 표시, 읽은 확인, 시스템 알림 등 서버 → 클라이언트 방향으로 이뤄지는 통신이 가능함
- TCP 기반의 신뢰성과 순서 보장
- WebSocket은 TCP 위에 있다 → 손실되면 자동으로 재전송되고 순서가 유지 된다.
- 큰 용량을 차지하는 메시지가 늦게 오면 전체적인 흐름이 막힐 수 있기 때문에 파일 전송과 같은 서비스는 HTTP로, 메시지는 WebSocket으로 분리하여 구현하는 접근이 필요하다.
- 유휴 연결 관리 : Heartbeat
- 모바일에서는 네트워크가 바뀌거나, 프록시가 유휴 연결을 끊기도 한다.
- 그렇기 때문에 WebSocket은 주기적으로 Ping/Pong으로 살아있는지 확인한다.
- 메시지 지향 (Message-Oriented)
정리하자면 채팅 서비스를 구현해야할 때 WebSocket이 필요한 이유는 메시지를 즉시 받아야하며, 상대방이 입력 중인 상태를 확인할 수 있는 환경을 구축할 수 있다. 또한 읽음과 전송 확인이 실시간으로 이뤄질 수 있다는 점 등등 물론 HTTP를 활용해서 폴링/푸시를 사용해 구현은 가능하지만 WebSokect으로 더 효율적으로 구현할 수 있다는 점 때문!!
URLSessionWebSocketTask 사용
iOS는 WebSocket을 구현할 수 있는 API 를 제공해주고 있는데 이것이 바로바로 URLSessionWebSocketTask 이다!
구성 요소 설명
연결 (Connect) | 서버와 연결을 열고 유지 (resume) |
수신 (Receive) | 메시지 한 건씩 계속 기다리며 받는 루프 |
송신 (Send) | 메시지 보내기, 완료 시 핸들링 |
Heartbeat | 주기적인 Ping으로 연결 상태 확인 |
재연결 | 네트워크 오류/종료 시 자동 재연결 로직 |
멱등성 처리 | 중복 메시지 처리(예: clientMsgId 사용) |
메시지 파싱 | JSON 메시지를 Swift 모델로 변환해서 처리 |
대표적인 기능으로 이렇게 구현할 수 있도록 제공해준다.
final class ChatWebSocketClient {
private var webSocketTask: URLSessionWebSocketTask?
private let session = URLSession(configuration: .default)
// 연결 시작
func connect() {
let url = URL(string: "wss://yourserver.com/ws")!
webSocketTask = session.webSocketTask(with: url)
webSocketTask?.resume()
listen() // 수신 시작
sendPing() // 핑 시작
}
// 연결 종료
func disconnect() {
webSocketTask?.cancel(with: .goingAway, reason: nil)
}
}
기본적으로 사용하는 클라이언트를 구성하게 된다.
그렇다면 메시지 수신 루프를 만들어 보자
private func listen() {
webSocketTask?.receive { [weak self] result in
switch result {
case .success(let message):
switch message {
case .string(let text):
print("Received text: \\(text)")
// 여기에 JSON → 모델 변환 파싱 로직 추가
case .data(let data):
print("Received binary data: \\(data)")
@unknown default:
break
}
self?.listen() // 계속해서 수신 대기
case .failure(let error):
print("Receive failed: \\(error)")
self?.reconnect() // 실패 시 재연결
}
}
}
메시지를 수신했다면 송신은
func send(message: String) {
let wsMessage = URLSessionWebSocketTask.Message.string(message)
webSocketTask?.send(wsMessage) { error in
if let error = error {
print("Send failed: \\(error)")
} else {
print("Message sent")
}
}
}
이와 같이 구현하면 메시지 송수신할 수 있는 구조를 만들어 낼 수 있다.
그 다음
private func sendPing() {
webSocketTask?.sendPing { [weak self] error in
if let error = error {
print("❌ Ping failed: \\(error)")
self?.reconnect()
} else {
print("✅ Ping success")
DispatchQueue.global().asyncAfter(deadline: .now() + 15) {
self?.sendPing() // 반복
}
}
}
}
핑을 꾸준하게 일정 시간을 잡고 요청을 통해 유효한지 확인 해야하는데. 왜냐하면 WebSocket은 하나의 연결을 오랫동안 유지하는 방식이기 때문에 연결이 물리적으로 끊기게 됐을때 클라이언트 단에서는 그 즉시 알지 못할 수 있다.
예를 들어 서버가 죽었거나, 클라이언트가 Wi-Fi → LTE로 바뀌었거나, 라우터/NAT가 유휴 연결을 강제로 닫았거나 이런 상황에서는 실제로 끊겼지만 이상없이 유지하고 있다고 착각할 수 있기 때문.
그렇기 때문에 주기적으로 Ping을 보내고 Pong을 받아야 이 연결이 살아있음을 확인할 수 있다.
특히 백그라운드 진입, 절전, 네트워크 변경이 앱에서는 특히 자주 일어나기 때문에 해당 핑퐁 전략은 매우 필수적이므로 잊지 않도록 하자..!
오늘은 여기까지!
'◽️ Programming > iOS' 카테고리의 다른 글
Swift Concurrency 시리즈 (1) Task (4) | 2025.08.07 |
---|---|
Swinject이요? (1/2) (3) | 2025.07.18 |
AVAudioSession설정으로 인한 트러블 슈팅 과정 (0) | 2025.05.26 |
Tuist를 사용하면서 만난 Duplicate Symbol Error (0) | 2025.04.30 |
Tuist Scaffold로 모듈 생성 자동화 하기 + 외부 Dependencies 추가하기 (0) | 2025.04.22 |