2024. 12. 31. 18:01ㆍ🍏/Swift
들어가기 앞서
iOS의 소켓 통신에 대해서 궁금해서 찾아보다가 정보들이 부실하기도 하고, SocketIO에 대한 정보가 많아서 First-party Framework인 Network를 통해 직접 소켓 통신을 해보고 정리해 보기 위해서 이 글을 작성합니다.
사전 지식
이전에 c++98(POSIX) KQueue로 http 서버를 만들어 본 적이 있습니다. (대략 소켓 통신과 fd, tcp/ip, udp에 대해서 알고 있다는 뜻)
이번엔 이렇게 거창한 것은 아니고, 간단하게 macOS에서 echo 서버(Broadcasting Server) 를 만들고 iOS에서 소켓 통신을 해보도록 하겠습니다.
소켓이란?
소켓(Socket)은 컴퓨터 네트워크에서 데이터를 송수신하기 위한 통신의 끝점(Endpoint)을 의미합니다.
소켓은 네트워크 프로그래밍에서 중요한 개념으로, 응용 프로그램과 네트워크 프로토콜 사이의 인터페이스 역할을 합니다.
소켓을 사용하면 두 장치 간에 데이터를 교환할 수 있습니다.
소켓의 구성 요소
소켓은 다음과 같은 요소로 구성됩니다:
- IP 주소: 네트워크 상의 장치를 식별하는 주소입니다.
- 포트 번호: 한 장치에서 여러 프로그램을 구분하기 위한 번호입니다.
- 프로토콜: 데이터를 전송하는 방식(예: TCP, UDP)을 정의합니다.
소켓은 이 세 가지 정보를 결합하여 특정 네트워크 연결을 식별합니다.
소켓의 종류
소켓은 사용하는 프로토콜에 따라 두 가지 주요 유형으로 나뉩니다:
- TCP 소켓 (스트림 소켓)
- 신뢰성 있는 데이터 전송을 제공합니다.
- 연결 지향적이며, 데이터를 순서대로 보장합니다.
- 주로 파일 전송, 웹 브라우저 등에서 사용됩니다.
- UDP 소켓 (데이터그램 소켓)
- 비연결 지향적이며, 신뢰성이 낮습니다.
- 데이터의 순서 보장이나 전송 확인이 없습니다.
- 빠른 데이터 전송이 필요한 스트리밍, 게임 등에서 사용됩니다.
소켓의 주요 함수
소켓 프로그래밍에서 사용하는 주요 함수들은 다음과 같습니다:
- 소켓 생성
- socket() 함수: 소켓을 생성합니다.
- 주소 바인딩
- bind() 함수: 소켓에 IP 주소와 포트를 연결합니다.
- 연결 요청
- connect() 함수: 클라이언트가 서버에 연결 요청을 보냅니다.
- listen() 및 accept() 함수: 서버가 클라이언트의 요청을 기다리고 처리합니다.
- 데이터 송수신
- send()와 recv(): 데이터 전송 및 수신 (TCP).
- sendto()와 recvfrom(): 데이터 전송 및 수신 (UDP).
- 소켓 닫기
- close() 함수: 소켓을 닫아 연결을 종료합니다.
소켓의 동작 방식
- 서버가 소켓을 생성하고 바인딩(bind), 리스닝(listen) 상태로 대기합니다.
- 클라이언트가 소켓을 생성하고 연결(connect) 요청을 보냅니다.
- 서버가 클라이언트 요청을 수락(accept)하고 연결이 성립됩니다.
- 클라이언트와 서버는 데이터를 송수신합니다.
- 데이터 교환이 끝나면 소켓을 닫아 연결을 종료합니다.
MacOS 에서 Broadcast 서버 키기
Xcode에서 새 프로젝트를 만듭니다. macOS > command line tool
프로젝트를 생성한 뒤 아래의 코드를 활용해 브로드캐스팅 서버를 하나 간단하게 키고,
//
// main.swift
// BroadcastServer
//
// Created by Chan on 12/30/24.
//
import Foundation
func startServersConcurrently() {
let tcpQueue = DispatchQueue(label: "com.server.tcp", qos: .userInitiated)
let udpQueue = DispatchQueue(label: "com.server.udp", qos: .userInitiated)
tcpQueue.async {
startTCPServer()
}
udpQueue.async {
startUDPServer()
}
dispatchMain()
}
func startTCPServer() {
let port: UInt16 = 8080
var serverAddr = sockaddr_in()
let serverSocket = socket(AF_INET, SOCK_STREAM, 0)
if serverSocket == -1 {
perror("Socket creation failed")
return
}
serverAddr.sin_family = sa_family_t(AF_INET)
serverAddr.sin_addr.s_addr = inet_addr("0.0.0.0")
serverAddr.sin_port = CFSwapInt16HostToBig(port)
withUnsafePointer(to: &serverAddr) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
if bind(serverSocket, $0, socklen_t(MemoryLayout<sockaddr_in>.size)) == -1 {
perror("Socket bind failed")
Darwin.close(serverSocket)
return
}
}
}
if listen(serverSocket, 10) == -1 {
perror("Listening failed")
Darwin.close(serverSocket)
return
}
print("TCP Server is running on port \(port)")
var clientSockets: [Int32] = []
while true {
let clientSocket = acceptConnection(serverSocket: serverSocket)
if clientSocket != -1 {
clientSockets.append(clientSocket)
print("Client connected: \(clientSocket)")
broadcastMessage("Client \(clientSocket) has joined the chat.",
from: clientSocket,
to: clientSockets)
DispatchQueue.global().async {
handleClient(clientSocket: clientSocket, allClients: &clientSockets)
}
}
}
}
func acceptConnection(serverSocket: Int32) -> Int32 {
var clientAddr = sockaddr_in()
var addrLen = socklen_t(MemoryLayout<sockaddr_in>.stride)
let clientSocket = withUnsafeMutablePointer(to: &clientAddr) { pointer -> Int32 in
let addrPointer = UnsafeMutableRawPointer(pointer).assumingMemoryBound(to: sockaddr.self)
return accept(serverSocket, addrPointer, &addrLen)
}
if clientSocket == -1 {
perror("Accept failed")
}
return clientSocket
}
func handleClient(clientSocket: Int32, allClients: inout [Int32]) {
let bufferSize = 1024
var buffer = [CChar](repeating: 0, count: bufferSize)
while true {
buffer = [CChar](repeating: 0, count: bufferSize)
let bytesRead = recv(clientSocket, &buffer, bufferSize, 0)
if bytesRead <= 0 {
if bytesRead == 0 {
print("Client disconnected: \(clientSocket)")
} else {
perror("Receive failed")
}
Darwin.close(clientSocket)
allClients.removeAll { $0 == clientSocket }
broadcastMessage("Client \(clientSocket) has left the chat.",
from: clientSocket,
to: allClients)
break
}
if let message = String(cString: buffer, encoding: .utf8)?.trimmingCharacters(in: .newlines) {
print("Received message from \(clientSocket): \(message)")
broadcastMessage(message, from: clientSocket, to: allClients)
}
}
}
func broadcastMessage(_ message: String, from sender: Int32, to clients: [Int32]) {
let formattedMessage = "Client \(sender): \(message)\n"
for clientSocket in clients {
if clientSocket != sender {
_ = formattedMessage.withCString { cstr in
send(clientSocket, cstr, strlen(cstr), 0)
}
}
}
}
func startUDPServer() {
let port: UInt16 = 8081
var serverAddr = sockaddr_in()
let serverSocket = socket(AF_INET, SOCK_DGRAM, 0)
if serverSocket == -1 {
perror("Socket creation failed")
return
}
serverAddr.sin_family = sa_family_t(AF_INET)
serverAddr.sin_addr.s_addr = inet_addr("0.0.0.0")
serverAddr.sin_port = CFSwapInt16HostToBig(port)
withUnsafePointer(to: &serverAddr) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
if bind(serverSocket, $0, socklen_t(MemoryLayout<sockaddr_in>.size)) == -1 {
perror("Socket bind failed")
close(serverSocket)
return
}
}
}
print("UDP Server is running on port \(port)")
let bufferSize = 1024
var buffer = [CChar](repeating: 0, count: bufferSize)
var clientAddr = sockaddr_in()
var addrLen = socklen_t(MemoryLayout<sockaddr_in>.size)
while true {
buffer = [CChar](repeating: 0, count: bufferSize)
let bytesRead = withUnsafeMutablePointer(to: &clientAddr) { pointer -> Int in
let addrPointer = UnsafeMutableRawPointer(pointer).assumingMemoryBound(to: sockaddr.self)
return recvfrom(serverSocket, &buffer, bufferSize, 0, addrPointer, &addrLen)
}
if bytesRead == -1 {
perror("Receive failed")
continue
}
if let message = String(cString: buffer, encoding: .utf8)?.trimmingCharacters(in: .newlines) {
print("Received message: \(message)")
let response = "Server received: \(message)\n"
response.withCString { cstr in
withUnsafePointer(to: &clientAddr) { pointer in
let addrPointer = UnsafeRawPointer(pointer).assumingMemoryBound(to: sockaddr.self)
sendto(serverSocket, cstr, strlen(cstr), 0, addrPointer, addrLen)
}
}
}
}
}
startServersConcurrently()
TCP, UDP 서버 두 개를 같이 키는 코드입니다.
nc -u localhost 8081
명령어를 통해 UDP 서버와 통신할 수 있습니다.
nc localhost 8080
TCP는 위 명령어로 통신을 확인할 수 있습니다.
터미널을 사용해서 접속이 되는지 확인해 봅니다.
두 개의 터미널에서 입출력 테스트를 해봤을 때 정상 작동하는 것을 확인했습니다.
url로도 접근해서 확인해 봅니다.
크롬에서 접근했을 때, 위와 같이 크롬 기본 헤더들이 잘 나오는 것을 확인할 수 있었습니다.
iOS 앱으로 접속하기
간단하게 앱하나 만들어 주겠습니다.
1. 간단한 UI 작성
UI 작성 코드입니다. 아래 더 보기 클릭하시면 보입니다.
//
// ViewController.swift
// BroadcastClient
//
// Created by Chan on 12/30/24.
//
import UIKit
final class ViewController: UIViewController {
private let connectTCP: UIButton = {
let button = UIButton()
button.setTitle("Connect TCP", for: .normal)
button.backgroundColor = .systemBlue
button.setTitleColor(.white, for: .normal)
button.layer.cornerRadius = 8
return button
}()
private let connectUDP: UIButton = {
let button = UIButton()
button.setTitle("Connect UDP", for: .normal)
button.backgroundColor = .systemGreen
button.setTitleColor(.white, for: .normal)
button.layer.cornerRadius = 8
return button
}()
private let testLabel: UILabel = {
let label = UILabel()
label.text = "Broadcast Messages:"
label.font = .systemFont(ofSize: 18)
label.textAlignment = .left
label.numberOfLines = 0
return label
}()
private let messageInput: UITextField = {
let textField = UITextField()
textField.placeholder = "Enter message..."
textField.borderStyle = .roundedRect
textField.returnKeyType = .google
return textField
}()
private let sendButton: UIButton = {
let button = UIButton()
button.setTitle("Send", for: .normal)
button.backgroundColor = .systemOrange
button.setTitleColor(.white, for: .normal)
button.layer.cornerRadius = 8
return button
}()
private let messageView: UITextView = {
let textView = UITextView()
textView.isEditable = false
textView.layer.borderColor = UIColor.lightGray.cgColor
textView.layer.borderWidth = 1
textView.layer.cornerRadius = 8
textView.font = .systemFont(ofSize: 16)
return textView
}()
private let inputStack: UIStackView = {
let stackView = UIStackView()
stackView.axis = .horizontal
stackView.spacing = 10
stackView.distribution = .fill
return stackView
}()
private var messageViewHeightConstraint: NSLayoutConstraint?
private var inputStackBottomConstraint: NSLayoutConstraint?
override func viewDidLoad() {
super.viewDidLoad()
setUI()
setLayout()
addActions()
setupKeyboardNotifications()
}
private func setUI() {
view.addSubview(connectTCP)
view.addSubview(connectUDP)
view.addSubview(testLabel)
view.addSubview(messageView)
inputStack.addArrangedSubview(messageInput)
inputStack.addArrangedSubview(sendButton)
view.addSubview(inputStack)
view.backgroundColor = .systemBackground
}
private func setLayout() {
connectTCP.translatesAutoresizingMaskIntoConstraints = false
connectUDP.translatesAutoresizingMaskIntoConstraints = false
testLabel.translatesAutoresizingMaskIntoConstraints = false
messageView.translatesAutoresizingMaskIntoConstraints = false
inputStack.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
// TCP Button
connectTCP.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
connectTCP.leadingAnchor.constraint(equalTo: view.centerXAnchor, constant: -150),
connectTCP.widthAnchor.constraint(equalToConstant: 120),
connectTCP.heightAnchor.constraint(equalToConstant: 50),
// UDP Button
connectUDP.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
connectUDP.trailingAnchor.constraint(equalTo: view.centerXAnchor, constant: 150),
connectUDP.widthAnchor.constraint(equalToConstant: 120),
connectUDP.heightAnchor.constraint(equalToConstant: 50),
// Test Label
testLabel.topAnchor.constraint(equalTo: connectTCP.bottomAnchor, constant: 20),
testLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
testLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
// Message View
messageView.topAnchor.constraint(equalTo: testLabel.bottomAnchor, constant: 10),
messageView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
messageView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
messageView.bottomAnchor.constraint(equalTo: inputStack.topAnchor, constant: -10),
// Input Stack
inputStack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
inputStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
inputStack.heightAnchor.constraint(equalToConstant: 40)
])
inputStackBottomConstraint = inputStack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -10)
inputStackBottomConstraint?.isActive = true
}
private func addActions() {
connectTCP.addTarget(self, action: #selector(connectTCPServer), for: .touchUpInside)
connectUDP.addTarget(self, action: #selector(connectUDPServer), for: .touchUpInside)
sendButton.addTarget(self, action: #selector(sendMessage), for: .touchUpInside)
}
private func setupKeyboardNotifications() {
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil)
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
view.endEditing(true)
}
@objc private func keyboardWillShow(_ notification: Notification) {
if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
let keyboardHeight = keyboardFrame.height
inputStackBottomConstraint?.constant = -(keyboardHeight + 10)
view.layoutIfNeeded()
}
}
@objc private func keyboardWillHide(_ notification: Notification) {
messageViewHeightConstraint?.constant = -10
inputStackBottomConstraint?.constant = -10
view.layoutIfNeeded()
}
@objc private func connectTCPServer() {
// TCP 연결 로직 구현
print("Connecting to TCP server...")
}
@objc private func connectUDPServer() {
// UDP 연결 로직 구현
print("Connecting to UDP server...")
}
@objc private func sendMessage() {
guard let message = messageInput.text, !message.isEmpty else { return }
// 메시지 전송 로직 구현
print("Sending message: \(message)")
messageInput.text = ""
}
}
2. TCP, UDP 연결 로직 구현
맥북 상단의 와이파이 버튼을 option키를 누르면서 좌클릭을 하면 위와 같이 현재 접속한 network의 IP Address가 나옵니다.
보고 따라 치는 게 귀찮으시다면 terminal에 아래 코드로 쉽게 복사해 올 수 있습니다. 상단의 Interface Name에 따라 en0인지 en1인지 구분해서 사용하시면 됩니다. 인터페이스가 인식된 순서대로 en0, en1이 정해집니다. LAN어댑터 일수도 있으며 Wi-Fi 무선랜일 수도 있습니다.
ipconfig getifaddr en0 | pbcopy
구현 로직 코드
// 연결하려는 서버의 IP와 포트를 실제 환경에 맞춰 변경하세요.
var hostEndPoint = "192.168.30.88"
// MARK: - 1. TCP 서버 연결
@objc private func connectTCPServer() {
let host = NWEndpoint.Host(hostEndPoint)
let port = NWEndpoint.Port(integerLiteral: 8080)
let parameters = NWParameters.tcp
self.tcpConnection = NWConnection(host: host, port: port, using: parameters)
tcpConnection?.stateUpdateHandler = { [weak self] state in
switch state {
case .ready:
print("TCP Connection Ready")
DispatchQueue.main.async {
self?.messageView.text += "\n[TCP] 연결 성공\n"
self?.startReceiveTCP()
}
case .failed(let error):
print("TCP Connection Failed: \(error)")
DispatchQueue.main.async {
self?.messageView.text += "\n[TCP] 연결 실패: \(error.localizedDescription)\n"
}
default:
break
}
}
tcpConnection?.start(queue: .global())
}
// MARK: - 2. UDP 서버 연결
@objc private func connectUDPServer() {
let host = NWEndpoint.Host(hostEndPoint)
let port = NWEndpoint.Port(integerLiteral: 8081)
let parameters = NWParameters.udp
// 만약 UDP 브로드캐스트를 원한다면 아래처럼 파라미터를 조정할 수 있습니다.
// parameters.allowLocalEndpointReuse = true
// parameters.includePeerToPeer = true
self.udpConnection = NWConnection(host: host, port: port, using: parameters)
udpConnection?.stateUpdateHandler = { [weak self] state in
switch state {
case .ready:
print("UDP Connection Ready")
DispatchQueue.main.async {
self?.messageView.text += "\n[UDP] 연결 성공\n"
self?.startReceiveUDP()
}
case .failed(let error):
print("UDP Connection Failed: \(error)")
DispatchQueue.main.async {
self?.messageView.text += "\n[UDP] 연결 실패: \(error.localizedDescription)\n"
}
default:
break
}
}
udpConnection?.start(queue: .global())
}
현재 코드에서 TCP와 UDP의 차이점.
일반적으로 TCP(Transmission Control Protocol)와 UDP(User Datagram Protocol)는 전송 방식 자체가 다릅니다. iOS의 Network Framework에서도 두 가지 프로토콜을 선택해서 사용할 수 있는데, 간단히 비교하면 다음과 같은 차이점이 있습니다:
- 연결 방식
- TCP
- 연결 지향적(Connection-Oriented) 프로토콜입니다.
- 클라이언트와 서버가 서로 3-way handshake 과정을 통해 연결을 맺은 뒤, 데이터를 주고받습니다.
- 현재 코드에서 start(queue:)가 호출되면 connect 과정을 시작하는데, 이때 3-way handshake 과정을 거칩니다.
- 연결을 맺고 있으므로, 상대방이 제대로 받고 있는지 확인(ACK)하면서 통신합니다.
- UDP
- 비연결성(Connectionless) 프로토콜입니다.
- 서버와 미리 연결을 맺는 과정 없이, 데이터를 무조건 보내고(또는 받기만) 합니다.
- 별도의 세션을 맺지 않아서 연결 확립 과정이 없습니다.
- 현재 코드에서 start(queue:)가 호출되면 connect 과정을 시작하는데, 연결상태가 .ready 가 되면 실제의 '논리적' 연결이 된 것이 아니라 소켓(fd)이 준비되었다는 뜻입니다.
- TCP
- 신뢰성(에러체크 및 재전송 보장)
- TCP
- 데이터를 순서대로 재조립하고, 손실되었을 시 재전송을 요청하는 등 신뢰성이 보장됩니다.
- 상대방이 데이터를 잘 받았는지(ACK)를 TCP 계층에서 확인합니다.
- UDP
- 전송된 데이터가 도착했는지, 혹은 순서가 맞는지 등을 보장하지 않습니다.
- 별도의 순서 보장, 재전송 로직을 갖고 있지 않으므로 전송 속도가 빠른 대신 신뢰성은 사용자가 직접 구현해야 합니다(필요하다면).
- TCP
- 속도 및 오버헤드
- TCP
- 연결을 맺고, 패킷마다 ACK를 주고받으며, 패킷의 순서나 오류를 제어하므로 오버헤드가 큽니다.
- 보다 안정적이고, 데이터가 안전하게 도착해야 하는 경우(예: HTTP, 파일 전송 등)에 적합합니다.
- UDP
- TCP보다 빠릅니다.
- 연결 과정 없이 곧장 패킷을 전송하고, 재전송 과정도 없기 때문에 오버헤드가 적습니다.
- 빠른 응답이 필요한 스트리밍, 게임, VoIP 등에 활용할 수 있습니다.
- TCP
3. 메세지 송신
메세지를 보내는 부분은 간단합니다. 각 Connection에서 send를 통해 서버로 보낼 수 있습니다.
// MARK: - 3. 메시지 전송
@objc private func sendMessage() {
guard let message = messageInput.text, !message.isEmpty else { return }
guard let data = message.data(using: .utf8) else { return }
// TCP로 메시지 전송
if let tcpConnection = tcpConnection {
tcpConnection.send(content: data, completion: .contentProcessed { [weak self] error in
if let error = error {
print("TCP Send Error: \(error)")
DispatchQueue.main.async {
self?.messageView.text += "\n[TCP] 메시지 전송 실패: \(error.localizedDescription)\n"
}
} else {
print("TCP Message Sent")
DispatchQueue.main.async {
self?.messageView.text += "\n[TCP] 메시지 전송 완료: \(message)\n"
}
}
})
}
// UDP로 메시지 전송
if let udpConnection = udpConnection {
udpConnection.send(content: data, completion: .contentProcessed { [weak self] error in
if let error = error {
print("UDP Send Error: \(error)")
DispatchQueue.main.async {
self?.messageView.text += "\n[UDP] 메시지 전송 실패: \(error.localizedDescription)\n"
}
} else {
print("UDP Message Sent")
DispatchQueue.main.async {
self?.messageView.text += "\n[UDP] 메시지 전송 완료: \(message)\n"
}
}
})
}
messageInput.text = ""
}
4. 메세지 수신
메세지 수신부는 receive로 구현되며, connect 될 때 .ready 부분에서 호출하여 켜줍니다.
private func startReceiveTCP() {
guard let tcpConnection = self.tcpConnection else { return }
tcpConnection.receive(minimumIncompleteLength: 1, maximumLength: 65535) { [weak self] (data, context, isComplete, error) in
if let data = data, !data.isEmpty {
// 받은 데이터를 String으로 변환(UTF-8 가정)
let receivedMessage = String(data: data, encoding: .utf8) ?? ""
print("[TCP] 메시지 수신: \(receivedMessage)")
// UI 업데이트는 메인 스레드에서
DispatchQueue.main.async {
self?.messageView.text += "\n[TCP] 수신: \(receivedMessage)"
}
}
// 에러 처리
if let error = error {
print("TCP Receive Error: \(error)")
return
}
// 연결이 끝났는지 확인 (상대방이 소켓 닫았을 수도 있음)
if isComplete {
print("TCP Connection is complete (possibly closed by remote).")
return
}
// 계속해서 다음 메시지를 수신하기 위해 재귀적으로 다시 receive
self?.startReceiveTCP()
}
}
private func startReceiveUDP() {
guard let udpConnection = self.udpConnection else { return }
udpConnection.receiveMessage { [weak self] (data, context, isComplete, error) in
if let data = data, !data.isEmpty {
let receivedMessage = String(data: data, encoding: .utf8) ?? ""
print("[UDP] 메시지 수신: \(receivedMessage)")
DispatchQueue.main.async {
self?.messageView.text += "\n[UDP] 수신: \(receivedMessage)"
}
}
if let error = error {
print("UDP Receive Error: \(error)")
return
}
// UDP는 서버가 소켓을 닫았다고 해서 반드시 isComplete가 뜨는 건 아니지만,
// 네트워크 상황에 따라 complete가 될 수 있으니 처리 가능
if isComplete {
print("UDP Connection is complete (possibly closed?).")
return
}
// 다음 메시지를 계속 수신하기 위해 재호출
self?.startReceiveUDP()
}
}
5. 전체코드
더보기를 눌러서 전체 코드를 확인하세요.
//
// ViewController.swift
// BroadcastClient
//
// Created by Chan on 12/30/24.
//
import UIKit
import Network
final class ViewController: UIViewController {
private let connectTCP: UIButton = {
let button = UIButton()
button.setTitle("Connect TCP", for: .normal)
button.backgroundColor = .systemBlue
button.setTitleColor(.white, for: .normal)
button.layer.cornerRadius = 8
return button
}()
private let connectUDP: UIButton = {
let button = UIButton()
button.setTitle("Connect UDP", for: .normal)
button.backgroundColor = .systemGreen
button.setTitleColor(.white, for: .normal)
button.layer.cornerRadius = 8
return button
}()
private let testLabel: UILabel = {
let label = UILabel()
label.text = "Broadcast Messages:"
label.font = .systemFont(ofSize: 18)
label.textAlignment = .left
label.numberOfLines = 0
return label
}()
private let messageInput: UITextField = {
let textField = UITextField()
textField.placeholder = "Enter message..."
textField.borderStyle = .roundedRect
textField.returnKeyType = .google
return textField
}()
private let sendButton: UIButton = {
let button = UIButton()
button.setTitle("Send", for: .normal)
button.backgroundColor = .systemOrange
button.setTitleColor(.white, for: .normal)
button.layer.cornerRadius = 8
return button
}()
private let messageView: UITextView = {
let textView = UITextView()
textView.isEditable = false
textView.layer.borderColor = UIColor.lightGray.cgColor
textView.layer.borderWidth = 1
textView.layer.cornerRadius = 8
textView.font = .systemFont(ofSize: 16)
return textView
}()
private let inputStack: UIStackView = {
let stackView = UIStackView()
stackView.axis = .horizontal
stackView.spacing = 10
stackView.distribution = .fill
return stackView
}()
private var inputStackBottomConstraint: NSLayoutConstraint?
private var tcpConnection: NWConnection?
private var udpConnection: NWConnection?
override func viewDidLoad() {
super.viewDidLoad()
setUI()
setLayout()
addActions()
setupKeyboardNotifications()
}
private func setUI() {
view.addSubview(connectTCP)
view.addSubview(connectUDP)
view.addSubview(testLabel)
view.addSubview(messageView)
inputStack.addArrangedSubview(messageInput)
inputStack.addArrangedSubview(sendButton)
view.addSubview(inputStack)
view.backgroundColor = .systemBackground
}
private func setLayout() {
connectTCP.translatesAutoresizingMaskIntoConstraints = false
connectUDP.translatesAutoresizingMaskIntoConstraints = false
testLabel.translatesAutoresizingMaskIntoConstraints = false
messageView.translatesAutoresizingMaskIntoConstraints = false
inputStack.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
// TCP Button
connectTCP.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
connectTCP.leadingAnchor.constraint(equalTo: view.centerXAnchor, constant: -150),
connectTCP.widthAnchor.constraint(equalToConstant: 120),
connectTCP.heightAnchor.constraint(equalToConstant: 50),
// UDP Button
connectUDP.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
connectUDP.trailingAnchor.constraint(equalTo: view.centerXAnchor, constant: 150),
connectUDP.widthAnchor.constraint(equalToConstant: 120),
connectUDP.heightAnchor.constraint(equalToConstant: 50),
// Test Label
testLabel.topAnchor.constraint(equalTo: connectTCP.bottomAnchor, constant: 20),
testLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
testLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
// Message View
messageView.topAnchor.constraint(equalTo: testLabel.bottomAnchor, constant: 10),
messageView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
messageView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
messageView.bottomAnchor.constraint(equalTo: inputStack.topAnchor, constant: -10),
// Input Stack
inputStack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
inputStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
inputStack.heightAnchor.constraint(equalToConstant: 40)
])
inputStackBottomConstraint = inputStack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -10)
inputStackBottomConstraint?.isActive = true
}
private func addActions() {
connectTCP.addTarget(self, action: #selector(connectTCPServer), for: .touchUpInside)
connectUDP.addTarget(self, action: #selector(connectUDPServer), for: .touchUpInside)
sendButton.addTarget(self, action: #selector(sendMessage), for: .touchUpInside)
}
private func setupKeyboardNotifications() {
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil)
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
view.endEditing(true)
}
@objc private func keyboardWillShow(_ notification: Notification) {
if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
let keyboardHeight = keyboardFrame.height
inputStackBottomConstraint?.constant = -(keyboardHeight + 10)
view.layoutIfNeeded()
}
}
@objc private func keyboardWillHide(_ notification: Notification) {
inputStackBottomConstraint?.constant = -10
view.layoutIfNeeded()
}
// 연결하려는 서버의 IP와 포트를 실제 환경에 맞춰 변경하세요.
var hostEndPoint = "192.168.30.88"
// MARK: - 1. TCP 서버 연결
@objc private func connectTCPServer() {
let host = NWEndpoint.Host(hostEndPoint)
let port = NWEndpoint.Port(integerLiteral: 8080)
let parameters = NWParameters.tcp
self.tcpConnection = NWConnection(host: host, port: port, using: parameters)
tcpConnection?.stateUpdateHandler = { [weak self] state in
switch state {
case .ready:
print("TCP Connection Ready")
DispatchQueue.main.async {
self?.messageView.text += "\n[TCP] 연결 성공\n"
self?.startReceiveTCP()
}
case .failed(let error):
print("TCP Connection Failed: \(error)")
DispatchQueue.main.async {
self?.messageView.text += "\n[TCP] 연결 실패: \(error.localizedDescription)\n"
}
default:
break
}
}
tcpConnection?.start(queue: .global())
}
// MARK: - 2. UDP 서버 연결
@objc private func connectUDPServer() {
let host = NWEndpoint.Host(hostEndPoint)
let port = NWEndpoint.Port(integerLiteral: 8081)
let parameters = NWParameters.udp
// 만약 UDP 브로드캐스트를 원한다면 아래처럼 파라미터를 조정할 수 있습니다.
// parameters.allowLocalEndpointReuse = true
// parameters.includePeerToPeer = true
self.udpConnection = NWConnection(host: host, port: port, using: parameters)
udpConnection?.stateUpdateHandler = { [weak self] state in
switch state {
case .ready:
print("UDP Connection Ready")
DispatchQueue.main.async {
self?.messageView.text += "\n[UDP] 연결 성공\n"
self?.startReceiveUDP()
}
case .failed(let error):
print("UDP Connection Failed: \(error)")
DispatchQueue.main.async {
self?.messageView.text += "\n[UDP] 연결 실패: \(error.localizedDescription)\n"
}
default:
break
}
}
udpConnection?.start(queue: .global())
}
// MARK: - 3. 메시지 전송
@objc private func sendMessage() {
guard let message = messageInput.text, !message.isEmpty else { return }
guard let data = message.data(using: .utf8) else { return }
// TCP로 메시지 전송
if let tcpConnection = tcpConnection {
tcpConnection.send(content: data, completion: .contentProcessed { [weak self] error in
if let error = error {
print("TCP Send Error: \(error)")
DispatchQueue.main.async {
self?.messageView.text += "\n[TCP] 메시지 전송 실패: \(error.localizedDescription)\n"
}
} else {
print("TCP Message Sent")
DispatchQueue.main.async {
self?.messageView.text += "\n[TCP] 메시지 전송 완료: \(message)\n"
}
}
})
}
// UDP로 메시지 전송
if let udpConnection = udpConnection {
udpConnection.send(content: data, completion: .contentProcessed { [weak self] error in
if let error = error {
print("UDP Send Error: \(error)")
DispatchQueue.main.async {
self?.messageView.text += "\n[UDP] 메시지 전송 실패: \(error.localizedDescription)\n"
}
} else {
print("UDP Message Sent")
DispatchQueue.main.async {
self?.messageView.text += "\n[UDP] 메시지 전송 완료: \(message)\n"
}
}
})
}
// 전송 후 텍스트필드 초기화
messageInput.text = ""
}
private func startReceiveTCP() {
guard let tcpConnection = self.tcpConnection else { return }
tcpConnection.receive(minimumIncompleteLength: 1, maximumLength: 65535) { [weak self] (data, context, isComplete, error) in
if let data = data, !data.isEmpty {
// 받은 데이터를 String으로 변환(UTF-8 가정)
let receivedMessage = String(data: data, encoding: .utf8) ?? ""
print("[TCP] 메시지 수신: \(receivedMessage)")
// UI 업데이트는 메인 스레드에서
DispatchQueue.main.async {
self?.messageView.text += "\n[TCP] 수신: \(receivedMessage)"
}
}
// 에러 처리
if let error = error {
print("TCP Receive Error: \(error)")
return
}
// 연결이 끝났는지 확인 (상대방이 소켓 닫았을 수도 있음)
if isComplete {
print("TCP Connection is complete (possibly closed by remote).")
return
}
// 계속해서 다음 메시지를 수신하기 위해 재귀적으로 다시 receive
self?.startReceiveTCP()
}
}
private func startReceiveUDP() {
guard let udpConnection = self.udpConnection else { return }
udpConnection.receiveMessage { [weak self] (data, context, isComplete, error) in
if let data = data, !data.isEmpty {
let receivedMessage = String(data: data, encoding: .utf8) ?? ""
print("[UDP] 메시지 수신: \(receivedMessage)")
DispatchQueue.main.async {
self?.messageView.text += "\n[UDP] 수신: \(receivedMessage)"
}
}
if let error = error {
print("UDP Receive Error: \(error)")
return
}
// UDP는 서버가 소켓을 닫았다고 해서 반드시 isComplete가 뜨는 건 아니지만,
// 네트워크 상황에 따라 complete가 될 수 있으니 처리 가능
if isComplete {
print("UDP Connection is complete (possibly closed?).")
return
}
// 다음 메시지를 계속 수신하기 위해 재호출
self?.startReceiveUDP()
}
}
}
6. 테스트
시뮬레이터, 앱, terminal, 서버에서의 스크린샷입니다.
iOS에서의 저수준 API
소켓을 이용해 저수준 API로 연결하고 간단히 데이터를 주고받는 방법을 살펴보았습니다. 데이터가 들어올 때마다 특정 로직을 호출하는 구조를 직접 만들 수도 있지만, 실제 채팅이나 소켓 통신 환경에서는 이벤트 기반으로 추상화된 소켓 라이브러리를 사용하는 경우가 많습니다. 이벤트 기반 라이브러리를 활용하면 코드 유지보수나 확장성 측면에서 이점이 크므로, 실무에서는 이를 고려해 보는 것이 좋겠습니다.
확장 및 주의사항
- 실무에서의 연결 관리
연결이 실패할 경우 재시도 로직, 타임아웃 처리, 서버 연결이 끊겼을 때 자동 복구 로직 등이 필요할 수 있습니다. 이는 사용자 경험과 서비스 안정성에 직접적인 영향을 주므로, 실무에서 반드시 고려해야 할 사항입니다. - 메시지 프로토콜 정의
서버가 어떤 형식(예: JSON, 구분 문자, 바이트 스트림 등)으로 데이터를 주고받을지 미리 정해두고, 그에 맞춰 파싱해야 합니다. 여기서는 단순히 문자열로 처리하기 위해 String(cString:)을 사용했지만, 복잡한 프로토콜일수록 구조화된 데이터 파싱을 고려하는 것이 좋습니다. - 스레드 안전성
clientSocket을 여러 스레드에서 동시에 사용하는 경우, 동시성 이슈가 발생하지 않도록 주의해야 합니다. Swift에서는 GCD(DispatchQueue)나 Operation 등을 활용해 I/O 처리를 스레드 안전하게 관리할 수 있습니다. - UI 업데이트 최적화
서버에서 메시지가 매우 빈번하게 들어올 수 있는데, 이때마다 UI를 실시간으로 업데이트하면 성능 부담이 커질 수 있습니다. 특정 주기로 모아서 한 번에 갱신하는 방식을 쓰는 등, UI 업데이트 로직을 최적화하는 전략이 필요합니다.
'🍏 > Swift' 카테고리의 다른 글
[iOS] 데이터를 저장하는 방법들 / 간단 예제 (0) | 2024.12.31 |
---|---|
[iOS] 에러 처리 / Error Handling (0) | 2024.12.30 |
[Swift] 접근 제어자(Access Control Levels) (0) | 2024.12.29 |
[iOS] Protocol / 프로토콜 / 개념 의미 요구사항 확장 POP (0) | 2024.12.29 |
[Swift] Tail Call Optimization / TCO / 꼬리재귀 (0) | 2024.03.16 |