최근 사이드 프로젝트로 "5초 뒷담화 앱"을 개발하면서 Socket.IO를 사용할 기회가 생겼습니다. 실시간으로 익명의 뒷담화가 5초씩 표시되고 사라지는 앱을 만들면서, Swift에서 Socket.IO를 다루는 다양한 노하우를 얻을 수 있었습니다.
웹소켓을 사용한 실시간 통신은 현대 앱 개발에서 필수적인 기술이 되었습니다. 채팅, 실시간 알림, 라이브 업데이트 등 다양한 기능을 구현할 때 Socket.IO는 강력하고 안정적인 선택지입니다.
이번 기회에 Socket.IO의 기본 원리부터 Swift에서의 실제 구현 방법, 그리고 개발하면서 마주친 주요 주의사항들을 정리해보려고 합니다. 실제 프로젝트 예제와 함께 Swift 환경에서 Socket.IO를 효과적으로 활용하는 방법을 알아보겠습니다.
웹소켓과 Socket.IO의 차이점에 대한 자세한 설명은 m5rep5wer님의 블로그 포스트를 참고했습니다.
웹소켓(WebSocket)은 클라이언트와 서버가 양방향 실시간 통신할 수 있게 하는 프로토콜(규약)입니다. HTTP와 달리 연결을 유지하며 실시간으로 데이터를 주고받을 수 있습니다.
Socket.IO는 웹소켓을 포함한 라이브러리로, 웹소켓에서 직접 구현해야 할 추가기능들을 포함하고 있습니다.
웹소켓의 특징:
Socket.IO의 특징:
Socket.IO의 추가 기능들:
Socket.IO의 통신 과정은 웹소켓을 기반으로 하면서도 추가적인 안정성과 기능을 제공합니다.
먼저 클라이언트가 서버에 연결을 시도하면 핸드셰이크 과정이 진행됩니다. 이 단계에서는 HTTP 롱폴링을 통해 초기 연결이 이루어지며, 클라이언트와 서버가 Socket.IO 프로토콜로 통신할 수 있는 상태를 만듭니다.
핸드셰이크가 완료되면 Socket.IO는 가능한 경우 웹소켓으로 업그레이드를 시도합니다. 웹소켓이 지원되는 환경에서는 더 효율적인 실시간 통신을 위해 자동으로 웹소켓 연결로 전환됩니다. 만약 웹소켓을 사용할 수 없는 환경이라면 HTTP 롱폴링을 계속 사용합니다.
연결이 완료된 후에는 이벤트 기반 통신이 시작됩니다. 클라이언트는 emit()
메서드를 사용해 서버에 메시지를 전송하고, 서버는 해당 이벤트를 받아 처리한 후 필요에 따라 다시 클라이언트에 응답을 보냅니다. 이러한 통신은 단순한 문자열부터 복잡한 객체까지 다양한 형태의 데이터를 주고받을 수 있습니다.
Socket.IO의 가장 큰 장점 중 하나는 자동 관리 기능입니다. 네트워크 연결이 끊어지면 자동으로 재연결을 시도하며, 연결 상태를 지속적으로 모니터링합니다. 또한 웹소켓을 사용할 수 없는 상황에서는 자동으로 HTTP 롱폴링으로 폴백하여 통신을 유지합니다. 이러한 모든 과정이 개발자가 별도로 구현하지 않아도 라이브러리 차원에서 자동으로 처리됩니다.
Swift Package Manager 사용 시:
// Package.swift 또는 Xcode > File > Add Package Dependencies
.package(url: "https://github.com/socketio/socket.io-client-swift", from: "16.0.0")
Tuist 프로젝트에서는:
// Tuist/Package.swift
let package = Package(
dependencies: [
.package(url: "https://github.com/socketio/socket.io-client-swift", from: "16.0.0")
]
)
import SocketIO
class SocketManager: ObservableObject {
private var manager: SocketManager?
private var socket: SocketIOClient?
@Published var isConnected = false
init() {
setupSocket()
}
private func setupSocket() {
// Socket.IO 매니저 생성
manager = SocketManager(
socketURL: URL(string: "http://localhost:3000")!,
config: [
.log(false), // 로그 비활성화
.compress, // 데이터 압축
.connectParams(["auth": "token"]) // 인증 파라미터
]
)
socket = manager?.defaultSocket
setupEventHandlers()
}
}
private func setupEventHandlers() {
// 연결 성공
socket?.on(clientEvent: .connect) { [weak self] _, _ in
print("🔗 서버 연결됨")
DispatchQueue.main.async {
self?.isConnected = true
}
}
// 연결 해제
socket?.on(clientEvent: .disconnect) { [weak self] _, _ in
print("🔌 서버 연결 해제")
DispatchQueue.main.async {
self?.isConnected = false
}
}
// 커스텀 이벤트 수신
socket?.on("message") { [weak self] data, _ in
guard let messageData = data[0] as? [String: Any],
let content = messageData["content"] as? String else { return }
DispatchQueue.main.async {
self?.handleReceivedMessage(content)
}
}
// 에러 처리
socket?.on(clientEvent: .error) { data, _ in
print("❌ Socket 에러: \(data)")
}
}
// 이벤트 전송
func sendMessage(_ content: String) {
let messageData: [String: Any] = [
"content": content,
"timestamp": Date().timeIntervalSince1970,
"deviceId": deviceId
]
socket?.emit("new-message", messageData)
}
// ACK(응답 확인)가 필요한 경우
func sendMessageWithAck(_ content: String) {
socket?.emitWithAck("new-message", content).timingOut(after: 5) { data in
if data.isEmpty {
print("⏰ 타임아웃 발생")
} else {
print("✅ 서버 응답: \(data)")
}
}
}
func connect() {
socket?.connect()
}
func disconnect() {
socket?.disconnect()
}
// 자동 재연결 설정
func enableAutoReconnect() {
socket?.on(clientEvent: .reconnect) { _, _ in
print("🔄 자동 재연결됨")
}
}
@MainActor
class GossipManager: ObservableObject {
@Published var currentMessage: String?
@Published var isConnected: Bool = false
@Published var connectionStatus: ConnectionState = .disconnected
private var socket: SocketIOClient?
func connect() {
socket?.connect()
}
// SwiftUI에서 안전한 상태 업데이트
private func updateUI(_ block: @escaping () -> Void) {
DispatchQueue.main.async {
block()
}
}
}
struct ContentView: View {
@StateObject private var socketManager = GossipManager()
var body: some View {
VStack {
// 연결 상태 표시
Circle()
.fill(socketManager.isConnected ? .green : .red)
.frame(width: 12, height: 12)
// 실시간 메시지 표시
if let message = socketManager.currentMessage {
Text(message)
.transition(.slide)
}
}
.onAppear {
socketManager.connect()
}
.onDisappear {
socketManager.disconnect()
}
}
}
// ❌ 잘못된 예: 강한 참조로 인한 메모리 누수
socket?.on("message") { data, _ in
self.handleMessage(data) // retain cycle 위험
}
// ✅ 올바른 예: weak self 사용
socket?.on("message") { [weak self] data, _ in
self?.handleMessage(data)
}
// UI 업데이트는 반드시 메인 스레드에서
socket?.on("update") { [weak self] data, _ in
DispatchQueue.main.async {
self?.updateUI(with: data)
}
}
// 또는 @MainActor 사용
@MainActor
class SocketManager: ObservableObject {
// 모든 메서드가 메인 스레드에서 실행됨
}
enum ConnectionState {
case disconnected
case connecting
case connected
case reconnecting
case error(String)
}
// 연결 상태에 따른 UI 처리
private func handleConnectionState(_ state: ConnectionState) {
switch state {
case .disconnected:
showRetryButton()
case .connecting:
showLoadingIndicator()
case .connected:
hideConnectionIndicators()
case .reconnecting:
showReconnectingMessage()
case .error(let message):
showErrorAlert(message)
}
}
// 수신 데이터 안전하게 파싱
socket?.on("gossip-display") { [weak self] data, _ in
guard let responseData = data[0] as? [String: Any] else {
print("⚠️ 잘못된 데이터 형식")
return
}
// 옵셔널 바인딩으로 안전하게 추출
if let gossipData = responseData["gossip"] as? [String: Any],
let content = gossipData["content"] as? String,
!content.isEmpty {
self?.updateGossip(content)
}
}
// 포괄적인 에러 처리
func handleSocketError(_ error: Error) {
switch error {
case let socketError as SocketIOClientError:
switch socketError {
case .connectionTimeout:
attemptReconnection()
case .disconnectedBeforeSecure:
showSecurityError()
default:
showGenericError()
}
default:
print("예상치 못한 에러: \(error)")
}
}
// 불필요한 이벤트 리스너 제거
deinit {
socket?.off("message") // 특정 이벤트 제거
socket?.removeAllHandlers() // 모든 핸들러 제거
socket?.disconnect()
}
// 배터리 효율성을 위한 적절한 연결 관리
func handleAppStateChanges() {
NotificationCenter.default.addObserver(
forName: UIApplication.willResignActiveNotification,
object: nil,
queue: .main
) { _ in
socket?.disconnect()
}
NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { _ in
socket?.connect()
}
}
5초 뒷담화 앱에서의 구현 예제:
@MainActor
class GossipManager: ObservableObject {
@Published var currentGossip: String?
@Published var timeLeft: Int = 0
@Published var isConnected: Bool = false
private var socket: SocketIOClient?
private func setupSocket() {
// 카운트다운 이벤트
socket?.on("countdown") <{ [weak self] data, _ in
guard let responseData = data[0] as? [String: Any],
let timeLeft = responseData["timeLeft"] as? Int else { return }
self?.timeLeft = timeLeft
if timeLeft <= 0 {
self?.currentGossip = nil
}
}
// 새 뒷담화 표시
socket?.on("gossip-display") { [weak self] data, _ in
guard let responseData = data[0] as? [String: Any] else { return }
if let gossipData = responseData["gossip"] as? [String: Any],
let content = gossipData["content"] as? String {
self?.currentGossip = content
self?.timeLeft = 5
} else {
self?.currentGossip = nil
self?.timeLeft = 0
}
}
}
}
Socket.IO는 Swift 앱에서 실시간 통신을 구현할 때 매우 강력한 도구입니다. 올바른 메모리 관리, 스레드 안전성, 그리고 적절한 에러 처리를 통해 안정적이고 효율적인 실시간 앱을 만들 수 있습니다.
핵심은 상태 관리의 명확성과 사용자 경험의 일관성입니다. 연결 상태를 명확히 표시하고, 예상치 못한 상황에 대비한 적절한 폴백 메커니즘을 구현하는 것이 중요합니다.
실시간 통신을 활용한 앱 개발에 도전해보세요! 🚀