티스토리 뷰
웹 소켓이란
내가 친구에게 “뭐해” 톡을 보냈습니다. 친구에게 바로 보내진다고 생각하지만 실제로는 서버에 내가 보낸 데이터가 저장되고 친구는 그걸 읽어옵니다.
- 나 → 서버 “뭐해”전달
- 서버 → 친구 “뭐해” 전달
만약 HTTP를 사용한다고 생각해보겠습니다.
1번의 경우 POST 메소드를 사용해 서버에 저장이 가능합니다.
2번의 경우 서버는 친구에게 어떻게 전달할까요???
네트워크 연결이 있기 전 서버가 직접적으로 전달이 불가능하기에 친구가 지속적으로 요청을 해야합니다. 그래서 실시간 채팅을 위해서 1초마다 요청을 날리면 됩니다!
근데 내가 1시간에 한 번만 메시지를 보내는데 친구가 1초마다 계속 요청을 하게 된다면 너무 비효율적이지 않나요?? 그렇다고 1분마다 요청을 보내면 그건 실시간 채팅이 아니게 됩니다.
조금 비효율적인 이 방법은 Polling이라는 방법으로 웹 소켓 이전에 사용하던 방식 중 하나입니다.
이런 비효율적인 자원의 낭비를 막고자 실시간 통신을 위한 통신 프로토콜이 바로 웹 소켓입니다!
웹 소켓(WebSocket)은 웹 애플리케이션과 웹 서버 간에 실시간 양방향 통신을 가능하게 하는 프로토콜 및 기술입니다. 웹 소켓은 기존의 HTTP 통신과는 다르게 지속적인 연결을 제공하여 실시간 데이터를 주고받을 수 있도록 합니다. 채팅, 주식, 게임과 같은 곳에서 사용됩니다.
웹 소켓은 HTTP와 비교하여 다음과 같은 장점이 있습니다.
- 효율성: HTTP는 단방향 통신이기 때문에 데이터를 전송할 때마다 새로운 연결을 설정해야 합니다. 웹 소켓은 연결을 유지하기 때문에 데이터 전송에 더 효율적입니다.
- 신뢰성: HTTP는 연결이 끊어지면 데이터 전송이 중단될 수 있습니다. 웹 소켓은 연결을 유지하기 때문에 데이터 전송이 더 신뢰적입니다.
URLSessionWebSocketTask
Swift에서는 웹 소켓 프로토콜 RFC 6455을 준수하는 URLSessionWebSocketTask을 지원해 쉽고 간단하게 웹 소켓 통신이 가능합니다.
URLSessionWebSocketTask는 URLSessionTask의 구체적인 하위 클래스로서 WebSocket 프레임 형식의 TCP 및 TLS를 통한 메시지 기반 전송 프로토콜을 제공합니다.
URLSessionWebSocketTask를 생성할 때는 ws: 또는 wss: URL 중 하나를 사용합니다.
send(_:completionHandler:)와 receive(completionHandler:)를 사용해 비동기적으로 데이터를 주고 받습니다. 바이너리 프레임과 UTF-8로 인코딩된 텍스트 프레임을 포함하는 메시지를 보내고 받을 수 있습니다.
URLSessionWebSocketTask는 다른 종류의 작업과 마찬가지로 리다이렉션 및 인증을 지원하며, URLSessionTaskDelegate의 메서드를 사용하여 이를 처리합니다.
서버
Node.js의 웹 소켓 통신 모듈인 ws 모듈을 사용해 서버를 구현해주었습니다.
이번 글은 Swift 웹 소켓 구현에 집중할 것이기 때문에 서버 코드는 Github에서 확인할 수 있습니다.
GitHub - never-better/SwiftUI-Websocket-Demo: SwiftUI와 Node.js를 사용한 웹 소켓 예시 앱
SwiftUI와 Node.js를 사용한 웹 소켓 예시 앱. Contribute to never-better/SwiftUI-Websocket-Demo development by creating an account on GitHub.
github.com
아래 링크들을 참고했습니다.
- https://hudi.blog/websocket-with-nodejs/
- https://curryyou.tistory.com/348
- https://developer.mozilla.org/ko/docs/Web/API/WebSockets_API/Writing_WebSocket_client_applications
SwiftUI View & Model 구성
예시 앱을 만들거기 때문에 View와 Model을 먼저 구현해주겠습니다.
작업 환경은 아래와 같습니다.
- macOS 14.0 Sonoma
- XCode 15.0
- iOS Simluator 17.0
Chat Bubble
import Foundation
struct ChatBubbleModel: Hashable {
let nickname: String // 사용자 닉네임
let text: String // 채팅 텍스트
let isMe: Bool // 나인지 여부
}
struct ChatBubbleEntity: Codable {
let id: String
let nickname: String
let text: String
func toDomain(_ myID: String) -> ChatBubbleModel {
ChatBubbleModel(
nickname: self.nickname,
text: self.text,
isMe: self.id == myID
)
}
}
채팅 내용을 위한 모델 ChatBubbleModel을 만들어주었습니다. View에서 isMe 프로퍼티를 통해 채팅창의 좌우 위치를 설정해줄 겁니다.
ChatBubbleEntity의 경우 서버에서 json데이터를 받아오기 위한 모델입니다. 앱에 저장된 클라이언트(내) id와 채팅 데이터의 id를 비교해 “나인지”를 판별합니다.
import SwiftUI
struct ChatBubble: View {
let model: ChatBubbleModel
var body: some View {
HStack {
if model.isMe {
Spacer()
}
VStack(alignment: model.isMe ? .trailing : .leading, spacing: 10) {
Text(model.nickname)
.font(.title3)
Text(model.text)
.padding(10)
.foregroundStyle(.white)
.background(model.isMe ? .green : .blue)
.clipShape(.rect(cornerRadius: 5))
}
.padding(.horizontal)
if !model.isMe {
Spacer()
}
}
}
}
채팅 내용 View를 만들어주었습니다. 아래 그림 확인하면 쉽게 이해가 가능합니다.
import SwiftUI
struct ContentView: View {
@ObservedObject var networkService: NetworkServiceTwo = NetworkServiceTwo()
@State var textfield: String = ""
var body: some View {
VStack {
List(networkService.chatBubbleModel, id: \\.self) { chatBubbleModel in
ChatBubble(model: chatBubbleModel)
.listRowSeparator(.hidden)
}
.listStyle(.plain)
HStack {
TextField("", text: $textfield)
Button(
action: {
sendChat(self.textfield)
self.textfield = ""
},
label: {
Image(systemName: "arrow.up.circle.fill")
.resizable()
.scaledToFit()
.frame(width: 24, height: 24, alignment: .center)
}
)
}
.padding()
.background(.gray.opacity(0.4))
.clipShape(.rect(cornerRadius: 5))
.padding(.horizontal)
}
}
}
List와 Textfield를 사용해 채팅창을 만들어주었습니다.
이렇게 View는 완성입니다!
Completion Handler
final class NetworkService: ObservableObject {
@Published var chatBubbleModel = [ChatBubbleModel]()
static let shared = NetworkService()
private var webSocketTask: URLSessionWebSocketTask?
private var timer: Timer?
init() {
connect()
}
private func connect() {
// 포트 번호는 서버 설정에 따라 다름
guard let url = URL(string: "ws://localhost:8001/") else { return }
let request = URLRequest(url: url)
webSocketTask = URLSession.shared.webSocketTask(with: request)
webSocketTask?.resume()
self.startPing()
receiveMessage()
}
private func receiveMessage() {
webSocketTask?.receive(completionHandler: { [weak self] result in
switch result {
case .failure(let error):
print(error.localizedDescription)
case .success(let message):
switch message {
case .string(let stringMessage):
self?.handleMessage(stringMessage)
case .data:
break
@unknown default:
break
}
}
self?.receiveMessage()
})
}
private func handleMessage(_ message: String) {
let jsonData = message.data(using: .utf8)
guard let entity = try? JSONDecoder().decode(ChatBubbleEntity.self, from: jsonData!) else {
return
}
let model = entity.toDomain(User.id)
DispatchQueue.main.async {
self.chatBubbleModel.append(model)
}
}
//...
}
초기화시 지정된 URL로 연결을 시도합니다. 그리고 receiveMessage()를 통해 서버에서 보내주는 데이터를 수신합니다. 이 때 receiveMessage() 재귀적으로 호출해서 계속해서 데이터를 수신합니다. (재귀 호출 안 하면 처음 데이터만 수신함)
handleMessage()함수는 json 데이터를 모델로 바꿔주고 실제 모델에 추가해줍니다. 이 때 @Published의 데이터는 View를 바꾸기 때문에 메인 스레드에서 변경해주지 않으면 에러가 발생함으로 DispatchQueue.main.async를 사용합니다.
func sendMessage(_ text: String) {
// 모델 -> JSON 변환
let model = ChatBubbleEntity(id: User.id, nickname: User.nickname, text: text)
guard let jsonData = try? JSONEncoder().encode(model) else {
return
}
let message = String(data: jsonData, encoding: .utf8)!
// 전송
webSocketTask?.send(.string(message), completionHandler: { error in
if let error = error {
print(error.localizedDescription)
}
})
}
메시지를 보내는 sendMessage() 입니다. 도메인 모델을 JSON으로 바꿔줍니다. 앞서 공식문서에서 살펴봤듯 Swift Websocket은 utf8 포맷을 지원하기에 utf8로 인코딩해줍니다.
private func startPing() {
self.timer?.invalidate()
self.timer = Timer.scheduledTimer(
withTimeInterval: 10,
repeats: true,
block: { [weak self] _ in self?.ping() }
)
}
private func ping() {
webSocketTask?.sendPing { [weak self](error) in
if let error = error {
print("Ping failed: \(error)")
}
self?.startPing()
}
}
마지막으로 핑을 보내는 함수입니다.
서버도 무한정 통신을 기다릴 수 없기에 주기적으로 핑을 보내서 "나 살아있다"를 알려줘야 합니다. webSocketTask의 sendPing() 함수의 클로저에서 타이머를 사용해 주기적으로 신호를 보내줍니다.
Async/Await
다른 URLSession 기능과 마찬가지로 async/await을 지원합니다. receiveMessage()와 sendMessage()를 async/await으로 수정합니다. sendPing()은 async/await 함수가 없습니다.
private func receiveMessage() {
guard let webSocketTask = webSocketTask else { return }
Task {
let message = try await webSocketTask.receive()
switch message {
case .string(let stringMessage):
self.handleMessage(stringMessage)
case .data:
break
@unknown default:
break
}
receiveMessage()
}
}
func sendMessage(_ text: String) {
let model = ChatBubbleEntity(id: User.id, nickname: User.nickname, text: text)
guard let jsonData = try? JSONEncoder().encode(model) else {
return
}
let message = String(data: jsonData, encoding: .utf8)!
guard let webSocketTask = webSocketTask else {
print("언래핑 에러")
return
}
Task {
try await webSocketTask.send(.string(message))
}
}
실행 영상
크롬과 시뮬레이터로 localhost에 접속했습니다. 웹 소켓이 잘 동작하네요 😁
웹 소켓에 대해 공부하면서 간단한 앱까지 만들어봤습니다. Node 사용하면서 오랜만에 Javascript 쓰니까 재밌었네요 ㅎㅎ
끝 끝 끝 끝 끝
참고 링크
- https://ios-development.tistory.com/1109
- Google Bard
- Total
- Today
- Yesterday
- swiftUI 기초
- SwiftUI
- TODO
- 부스트캠프iOS
- 디자인 패턴
- 애플
- 코딩 테스트
- Swift 서버
- 책
- vapor
- Swift공식문서
- Swift
- Swift 디자인 패턴
- 앱개발
- ios
- 책후기
- 날씨어플
- 책리뷰
- 부스트캠프7기
- 필독서
- todo앱
- 코딩
- 프로그래머스
- 부스트캠프
- 개발
- UX
- Swift문법
- Swift DocC
- 코딩테스트
- Combine
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |