

import Foundation
final class SocketManager {
// WebSocket 통신을 위한 URLSessionWebSocketTask 인스턴스 입니다.
private var webSocketTask: URLSessionWebSocketTask?
// URLSession 인스턴스 입니다.
private let urlSession = URLSession(configuration: .default)
// 1. WebSocket 연결을 시작하는 함수
func connect(to url: URL) {
// ...
}
// 2. 메시지를 수신하는 함수
func receiveMessage(completion: @escaping (Result<String, Error>) -> Void) {
// ...
}
// 3. 메시지를 송신하는 함수
func sendMessage(text: String, completion: @escaping (Result<String, Error>) -> Void) {
// ...
}
// 4. WebSocket 연결을 종료하는 힘수
func disConnect() {
// ...
}
}
func connect(to url: URL) {
webSocketTask = urlSession.webSocketTask(with: url)
webSocketTask?.resume()
}
WebSocketTask를 생성하고 연결을 시작합니다.websocket secure라는 의미를 갖고 있습니다.URLSessionWebSocketTask 클래스는 receive() 메서드를 활용해서 메시지를 수신할 수 있습니다. receive 메서드는 클로저를 매개변수로 받습니다. 이 클로저는 메시지를 수신할 때 호출되며, Result<URLSessionWebSocketTask.Message, Error> 타입의 결과를 전달합니다. 이는 메시지 수신의 성공과 실패를 나타내는 열거형 입니다. URLSessionWebSocketTask.Message는 .data(Data) 또는 .string(String)의 두가지 형태로 나타날 수 있습니다. func receiveMessage(completion: @escaping (Result<String, Error>) -> Void) {
webSocketTask?.receive { [weak self] result in
switch result {
case .success(let message):
switch message {
case .data(let data):
// 예제 API는 String값으로 socket을 다루기 때문에 data를 디코딩하지 않습니다.
print("Data received: \(data)")
case .string(let strData):
print("STR Message received: \(strData)")
completion(.success(strData))
default:
break
}
case .failure(let error):
completion(.failure(error))
}
self?.receiveMessage(completion: completion)
}
}
URLSessionWebSocketTask 클래스의 receive 메서드를 활용해서 데이터를 받고 해당 결과를 클로저 매개변수의 결과로 전달합니다. Receive 메서드가 한 번 호출되고나서 종료가 되는것이 아니라, 지속적으로 데이터를 받을 수 있는 상태를 유지해야 합니다. 이를 위해 receive 메서드가 종료되기 전에 receiveMessage 메서드를 재귀적으로 호출하여 지속적으로 메시지를 수신할 수 있도록 합니다. URLSessionWebSocketTask 클래스는 send() 메서드를 활용해서 메시지를 송신할 수 있습니다.send 메서드는 클로저를 매개변수로 받아, 메시지를 송신한 후의 결과를 처리합니다. send 메서드는 메시지를 성공적으로 송신했는지 또는 오류가 발생했는지 여부를 클로저를 통해 전달합니다.func sendMessage(text: String, completion: @escaping (Result<String, Error>) -> Void) {
let message = URLSessionWebSocketTask.Message.string(text)
webSocketTask?.send(message) { error in
if let error = error {
completion(.failure(error))
} else {
completion(.success(text))
}
}
}
URLSessionWebSocketTask 클래스의 send 메서드를 활용해서 데이터를 송신하고, 해당 결과를 클로저 매개변수의 결과로 전달합니다.send 메서드는 메시지를 송신한 후, 클로저를 호출하여 송신 성공 여부를 전달합니다. 성공적으로 송신된 경우 completion 핸들러를 통해 송신된 메시지를 전달하고, 오류가 발생한 경우에는 오류 정보를 전달합니다.URLSessionWebSocketTask 클래스의 cancel(with:reason:) 메서드를 사용합니다. cancel(with:reason:) 메서드는 연결을 종료하는 이유를 포함하여 WebSocket 연결을 닫습니다.func disConnect() {
webSocketTask?.cancel(with: .goingAway, reason: nil)
}
URLSessionWebSocketTask 클래스의 cancel(with:reason:) 메서드를 활용해서 WebSocket 연결을 종료합니다.cancel 메서드는 두 개의 매개변수를 받습니다:with: 종료 상태 코드를 나타냅니다. 여기서는 .goingAway를 사용하여 정상적인 종료를 나타냅니다.reason: 연결 종료의 이유를 설명하는 데이터입니다. 여기서는 nil로 설정하여 별도의 이유를 제공하지 않습니다.import UIKit
final class ViewController: UIViewController {
private var chatModel: [Chat] = []
private let chatTableView: UITableView = {
let tableView = UITableView()
tableView.register(
ChatCell.self,
forCellReuseIdentifier: ChatCell.identifier
)
tableView.estimatedRowHeight = 300
tableView.rowHeight = UITableView.automaticDimension
return tableView
}()
private let textField: UITextField = {
let textField = UITextField()
textField.borderStyle = .roundedRect
textField.textColor = .black
return textField
}()
private let sendButton: UIButton = {
let btn = UIButton()
btn.setTitle("전송", for: .normal)
btn.setTitleColor(.black, for: .normal)
btn.backgroundColor = .lightGray
btn.layer.cornerRadius = 6
return btn
}()
override func viewDidLoad() {
super.viewDidLoad()
setupLayout()
setupAttribute()
}
private func setupLayout() {
[
chatTableView,
textField,
sendButton
].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
self.view.addSubview($0)
}
NSLayoutConstraint.activate([
chatTableView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 26),
chatTableView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
chatTableView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
chatTableView.bottomAnchor.constraint(equalTo: textField.topAnchor),
textField.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor),
textField.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 16),
textField.trailingAnchor.constraint(equalTo: sendButton.leadingAnchor, constant: -6),
textField.heightAnchor.constraint(equalTo: sendButton.heightAnchor),
sendButton.bottomAnchor.constraint(equalTo: textField.bottomAnchor),
sendButton.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -16),
sendButton.widthAnchor.constraint(equalToConstant: 55),
sendButton.heightAnchor.constraint(equalToConstant: 35)
])
}
private func setupAttribute() {
self.view.backgroundColor = .white
chatTableView.dataSource = self
chatTableView.separatorStyle = .none
sendButton.addTarget(
self,
action: #selector(sendText(_:)),
for: .touchUpInside
)
}
@objc func sendText(_ sender: UIButton) {
if let text = textField.text {
textField.text = ""
textField.endEditing(true)
}
}
}
extension ViewController: UITableViewDataSource {
func tableView(
_ tableView: UITableView,
numberOfRowsInSection section: Int
) -> Int {
chatModel.count
}
func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(
withIdentifier: ChatCell.identifier,
for: indexPath
) as? ChatCell
cell?.bind(to: chatModel[indexPath.row])
return cell ?? UITableViewCell()
}
}
enum Sender {
case me
case other
}
struct Chat {
let text: String
let type: Sender
}
Senderme와 other 두 가지 케이스가 있습니다.Chattext는 메시지 내용, type은 발신자를 나타냅니다.final class ChatCell: UITableViewCell {
static let identifier = String(describing: ChatCell.self)
private lazy var chatView = ChatView()
private var chatLabelLeadingConstraint: NSLayoutConstraint?
private var chatLabelTrailingConstraint: NSLayoutConstraint?
identifierchatViewchatLabelLeadingConstraint 및 chatLabelTrailingConstraintSender 값이 me 일 경우 chatLabelTrailingConstraint를 활성화하고 other 일 경우 chatLabelLeadingConstraint 를 활성화 해서 메시지의 위치를 조정합니다. override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupLayout()
setupAttribute()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepareForReuse() {
chatView.clearTextField()
chatLabelLeadingConstraint?.isActive = false
chatLabelTrailingConstraint?.isActive = false
chatLabelLeadingConstraint = nil
chatLabelTrailingConstraint = nil
}
private func setupLayout() {
[
chatView
].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
self.contentView.addSubview($0)
}
NSLayoutConstraint.activate([
chatView.topAnchor.constraint(equalTo: self.contentView.topAnchor),
chatView.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor, constant: -6),
chatView.widthAnchor.constraint(lessThanOrEqualTo: self.contentView.widthAnchor, multiplier: 0.8)
])
}
private func setupAttribute() {
self.backgroundColor = .white
self.selectionStyle = .none
}
chatView의 너비는 콘텐츠 뷰 너비의 최대 80%로 제한합니다. func bind(to model: Chat) {
chatView.bind(to: model)
switch model.type {
case .me:
myTextLabel()
case .other:
otherTextLabel()
}
}
private func myTextLabel() {
chatLabelLeadingConstraint?.isActive = false
chatLabelLeadingConstraint = nil
chatLabelTrailingConstraint = chatView.trailingAnchor.constraint(equalTo: self.contentView.trailingAnchor, constant: -6)
chatLabelTrailingConstraint?.isActive = true
}
private func otherTextLabel() {
chatLabelTrailingConstraint?.isActive = false
chatLabelTrailingConstraint = nil
chatLabelLeadingConstraint = chatView.leadingAnchor.constraint(equalTo: self.contentView.leadingAnchor, constant: 6)
chatLabelLeadingConstraint?.isActive = true
}
}
myTextLabel()chatLabelLeadingConstraint를 비활성화하고, chatLabelTrailingConstraint를 활성화하여 chatView를 콘텐츠 뷰의 우측에 배치합니다.otherTextLabel()chatLabelTrailingConstraint를 비활성화하고, chatLabelLeadingConstraint를 활성화하여 chatView를 콘텐츠 뷰의 좌측에 배치합니다.ChatCell 클래스는 사용자의 메시지와 다른 사람의 메시지를 구분하여 좌우 정렬하는 기능을 제공합니다. chatView에 바인딩하고, 메시지 유형에 따라 레이아웃 제약 조건을 동적으로 설정합니다.import UIKit
final class ChatView: UIView {
private lazy var chatLabel: UILabel = {
let lbl = UILabel()
lbl.numberOfLines = 0
lbl.textColor = .black
lbl.font = .systemFont(ofSize: 16, weight: .regular)
lbl.backgroundColor = .clear
return lbl
}()
override init(frame: CGRect) {
super.init(frame: frame)
setupLayout()
setupAttribute()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupLayout() {
[
chatLabel
].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
self.addSubview($0)
}
NSLayoutConstraint.activate([
chatLabel.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 12),
chatLabel.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -12),
chatLabel.topAnchor.constraint(equalTo: self.topAnchor, constant: 12),
chatLabel.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -12)
])
}
private func setupAttribute() {
self.layer.cornerRadius = 8
self.clipsToBounds = true
}
func bind(to model: Chat) {
chatLabel.text = model.text
switch model.type {
case .me:
myTextLabel()
case .other:
otherTextLabel()
}
}
private func myTextLabel() {
chatLabel.textAlignment = .right
self.backgroundColor = .lightGray
}
private func otherTextLabel() {
chatLabel.textAlignment = .left
self.backgroundColor = .systemGray6
}
func clearTextField() {
chatLabel.text = ""
}
}
chatLabel을 ChatView에 추가하고 오토레이아웃 제약 조건을 설정해서 여백을 둡니다. myTextLabel() .lightGray)으로 설정합니다.otherTextLabel().systemGray6)으로 설정합니다.import UIKit
final class ViewController: UIViewController {
private let socketManager = SocketManager()
// ...
private func setupAttribute() {
// ...
connectWebSocket()
}
private func connectWebSocket() {
/// notify_self 삭제
/// notify_self 파라미터가 적용되어 있으면 성공적으로 데이터를 전송할 때 내가 보낸 데이터를 receive하게 됨
let url = URL(string: "발급 받은 URL")!
socketManager.connect(to: url)
socketManager.receiveMessage { [weak self] result in
DispatchQueue.main.async {
switch result {
case .success(let message):
let chat = Chat(text: message, type: .other)
self?.updateTableView(chat: chat)
case .failure(let error):
print(error.localizedDescription)
}
}
}
}
connectWebSocket() 메서드에서 WebSocket URL을 통해 서버에 연결합니다.socketManager.receiveMessage 메서드를 통해 서버로부터 수신된 메시지를 처리합니다. @objc func sendText(_ sender: UIButton) {
if let text = textField.text {
sendMessage(with: text)
}
}
private func sendMessage(with text: String) {
socketManager.sendMessage(text: text) { [weak self] result in
DispatchQueue.main.async {
switch result {
case .success(let message):
let chat = Chat(text: message, type: .me)
self?.updateTableView(chat: chat)
self?.textField.text = ""
self?.textField.endEditing(true)
case .failure(let error):
print(error.localizedDescription)
}
}
}
}
sendText(_:) 메서드는 사용자가 입력한 텍스트를 WebSocket 서버로 송신합니다. private func updateTableView(chat: Chat) {
chatModel.append(chat)
let indexPath = IndexPath(row: chatModel.count - 1, section: 0)
chatTableView.beginUpdates()
chatTableView.insertRows(
at: [indexPath],
with: chat.type == .me ? .right : .left
)
chatTableView.endUpdates()
chatTableView.scrollToRow(at: indexPath, at: .bottom, animated: true)
}
}
updateTableView(chat:) 메서드는 새로운 메시지가 수신될 때마다 테이블 뷰를 업데이트합니다.제가 학습한 내용을 요약하여 정리한 것입니다. 내용에 오류가 있을 수 있으며, 어떠한 피드백도 감사히 받겠습니다.
감사합니다.