웹 소켓
웹소켓은 클라이언트와 서버 간의 양방향 통신을 위한 프로토콜이다. HTTP의 요청-응답 패턴과는 다르게, 하나의 연결을 유지하며 실시간 데이터를 주고받을 수 있는 기술이다. 주로 실시간 채팅, 주식 거래, 게임 등 실시간으로 진행되는 통신이 필요한 곳에서 사용된다.
웹 소켓 개념
웹 소켓은 TCP 기반의 프로토콜로, HTML5 표준의 일부로 등장했다.
특징은 다음과 같다.
- 단일 TCP 연결 : HTTP 연결은 요청-응답 후 연결이 끊기지만, 웹 소켓은 연결을 유지한다.
- 양방향 통신 : 클라이언트와 서버가 자유롭게 데이터를 실시간으로 주고받을 수 있다.
- 낮은 오버헤드 : HTTP에서 헤더 정보가 매번 전송되지만, 웹 소켓은 핸드셰이크 이후에는 최소한의 오버헤드로 데이터만 전송한다.
웹 소켓의 장점
1. 실시간 양방향 통신
클라이언트와 서버 간 실시간 데이터 흐름을 제공한다.
2. 효율적인 리소스 사용
HTTP 는 요청-응답 마다 새 연결을 만들지만, 웹 소켓은 하나의 연결을 유지하므로 네트워크 리스소 소모가 적다.
3. 낮은 오버헤드
웹 소켓의 데이터 프레임은 작고 간결하여 전송 속도가 빠르고 효율적이다.
4. 표준 프로토콜
모든 주요 브라우저에서 웹 소켓을 지원한다.
STOMP(Simple Text Oriented Messaging Protocol)
STOMP는 메세지 브로커와 클라이언트 간의 통신을 위해 설계된 텍스트 기반의 프로토콜이다.
웹 소켓 위에서 작동하는 상위 프로토콜로, 메시징 구조를 명확하게 정의하고, 다양한 메시징 시스템에서 쉽게 사용할 수 있도록 만들어졌다.
STOMP란?
- 메세지 지향 프로토콜로, 메시지 큐 시스템을 통해 퍼블리시/구독(Pub/Sub) 방식을 제공한다.
- 웹 소켓이 양방향 통신을 제공하지만, 구체적인 메시징 형식이나 구독 기능이 없기 때문에, STOMP를 사용하면 이러한 기능을 구현하기 더 쉽다.
- 텍스트 기반이기 때문에 구현이 간단하며 JSON, XML 등 다양한 데이터 포맷을 사용할 수 있다.
- 다양한 메시지 브로커와 함께 사용할 수 있다.
STOMP 서버 예제(Spring-Boot)
1. WebSocketConfig 설정
@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private static final Logger LOGGER = LoggerFactory.getLogger(WebSocketConfig.class);
final StompHandler stompHandler; // 커스텀 인터셉터
// WebSocket 엔드포인트 설정
// 클라이언트가 연결할 수 있는 WebSocket의 URL은 "/ws" 로 설정
// CORS 문제를 회피하기 위해 모든 origin을 허용
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*");
}
// 메세지 브로커 설정
// "/sub"로 시작하는 구독 경로와 "/pub"로 시작하는 발행 경로 설정
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/sub"); // 구독 경로
registry.setApplicationDestinationPrefixes("/pub"); // 발행 경로
}
// 클라이언트로부터 들어오는 메시지를 인터셉트 하기 위해 stompHandler를 등록
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(stompHandler);
}
}
2. StompHandler 설정
@Component
public class StompHandler extends ChannelInterceptorAdapter {
private static final Logger LOGGER = LoggerFactory.getLogger(StompHandler.class);
// 메시지가 브로커로 보내진 후의 이벤트를 처리한다.
@Override
public void postSend(Message message, MessageChannel channel, boolean sent) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
String sessionId = accessor.getSessionId();
switch ((accessor.getCommand())) {
case CONNECT:
LOGGER.info("세션 들어옴 => {}", sessionId); // 클라이언트가 연결한 경우
break;
case DISCONNECT:
LOGGER.info("세션 끊음 => {}", sessionId); // 클라이언트가 연결을 해제한 경우
break;
default:
break;
}
}
}
3. SocketController 구성
@RestController
@RequiredArgsConstructor
public class SocketController {
private static final Logger LOGGER = LoggerFactory.getLogger(SocketController.class);
private final SimpMessageSendingOperations simpMessageSendingOperations;
// WebSocket 연결 이벤트를 처리한다.
@EventListener
public void handleWebSocketConnectListener(SessionConnectEvent event) {
LOGGER.info("Received a new web socket connection");
}
// WebSocket 연결 해제 이벤트를 처리한다.
@EventListener
public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
String sessionId = headerAccessor.getSessionId();
LOGGER.info("sessionId Disconnected : " + sessionId);
}
// 클라이언트에서 "/pub/chat" 경로로 메시지를 발행하면,
// 구독자에세 "/sub/chat/{channelId}" 경로로 메시지를 전송한다.
@MessageMapping("/chat") // 메시지 발행 경로
@SendTo("/sub/chat")
public void sendMessage(Map<String, Object> params) {
LOGGER.info(params.get("channelId") + " --> " + params.get("id") + " : " + params.get("message"));
simpMessageSendingOperations.convertAndSend("/sub/chat/" + params.get("channelId"), params);
}
}
STOMP 클라이언트 예제(Swift)
import UIKit
import SnapKit
import StompClientLib
class ViewController: UIViewController {
init(userId: String) {
self.userId = userId
super.init(nibName: nil, bundle: nil) // 부모 이니셜라이저 호출
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
var stompClient = StompClientLib()
let socketURL = URL(string: "ws://localhost:8080/ws")! // Spring 서버의 WebSocket URL
let destinationSubscribe = "/sub/chat/{채널ID}" // 서버의 구독 경로
let destinationSend = "/pub/chat" // 서버의 메시지 전송 경로
let userId: String
var messages: [String] = []
let textView: UITableView = {
let tableView = UITableView()
tableView.backgroundColor = .black
return tableView
}()
let textField: UITextField = {
let textField = UITextField()
textField.placeholder = "내용을 입력하세요"
return textField
}()
let button: UIButton = {
let btn = UIButton()
btn.setTitle("전송", for: .normal)
btn.setTitleColor(.black, for: .normal)
btn.addTarget(self, action: #selector(sendMessage), for: .touchUpInside)
return btn
}()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
config()
setupTableView()
connectToStomp()
}
private func setupTableView() {
textView.dataSource = self
textView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
}
func config() {
view.addSubview(textView)
view.addSubview(textField)
view.addSubview(button)
textView.snp.makeConstraints {
$0.top.equalTo(view.safeAreaLayoutGuide.snp.top).inset(30)
$0.leading.trailing.equalToSuperview().inset(30)
$0.bottom.equalTo(view.safeAreaLayoutGuide.snp.bottom).inset(100)
}
button.snp.makeConstraints {
$0.top.equalTo(textView.snp.bottom)
$0.bottom.equalTo(view.safeAreaLayoutGuide.snp.bottom)
$0.trailing.equalToSuperview().inset(30)
$0.width.equalTo(100)
}
textField.snp.makeConstraints {
$0.top.equalTo(textView.snp.bottom)
$0.bottom.equalTo(view.safeAreaLayoutGuide.snp.bottom)
$0.leading.equalToSuperview().inset(30)
$0.trailing.equalTo(button.snp.leading)
}
}
// MARK: - STOMP Connection
private func connectToStomp() {
stompClient.openSocketWithURLRequest(
request: URLRequest(url: socketURL) as NSURLRequest,
delegate: self,
connectionHeaders: nil // 필요하면 인증 헤더 추가 가능
)
}
// MARK: - Send Message
@objc private func sendMessage() {
guard let message = textField.text, !message.isEmpty else { return }
let payload: [String: Any] = ["message": message, "channelId": {채널ID}, "id": userId]
// 메시지 전송
stompClient.sendJSONForDict(dict: payload as AnyObject, toDestination: destinationSend)
// 로컬에 메시지 추가 및 업데이트
messages.append("나: \(message)")
textView.reloadData()
// 텍스트 필드 초기화
textField.text = nil
}
}
extension ViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return messages.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
cell.textLabel?.text = messages[indexPath.row]
cell.textLabel?.numberOfLines = 0
return cell
}
}
extension ViewController: StompClientLibDelegate {
func stompClientDidDisconnect(client: StompClientLib!) {
print("STOMP 연결 종료")
}
func stompClientDidConnect(client: StompClientLib!) {
print("STOMP 연결 성공")
stompClient.subscribe(destination: destinationSubscribe)
}
func stompClient(client: StompClientLib!, didReceiveMessageWithJSONBody jsonBody: AnyObject?, akaStringBody stringBody: String?, withHeader header: [String: String]?, withDestination destination: String) {
// JSON 형식의 메시지가 있을 경우 처리
if let jsonBody = jsonBody as? [String: Any], let message = jsonBody["message"] as? String, let id = jsonBody["id"] as? String {
print("JSON 메시지 수신: \(message)")
if userId != id {
messages.append("\(id): \(message)")
}
} else if let stringBody = stringBody {
// 문자열 형식의 메시지가 있을 경우 처리
print("문자열 메시지 수신: \(stringBody)")
messages.append("서버: \(stringBody)")
}
// TableView 업데이트
DispatchQueue.main.async {
self.textView.reloadData()
}
}
func stompClientLibDidEncounterError(client: StompClientLib!, error: Error!) {
print("STOMP 오류 발생: \(error.localizedDescription)")
}
func serverDidSendReceipt(client: StompClientLib!, withReceiptId receiptId: String) {
print("서버에서 메시지 수신 확인: \(receiptId)")
}
func serverDidSendError(client: StompClientLib!, withErrorMessage description: String, detailedErrorMessage message: String?) {
print("서버 오류: \(description), 상세: \(String(describing: message))")
}
func serverDidSendPing() {
print("서버에서 핑 수신")
}
}
실행 결과

서버에서는 User1과 User2가 소켓에 접근한 것과 각각의 유저들이 보낸 메세지가 오는 것을 알 수 있다.

어플리케이션으로 테스트를 했을 때 실제로 채팅이 구현되는 것을 확인할 수 있다.
'TIL(Today I Learned)' 카테고리의 다른 글
| 2024.12.23 Today I Learned (0) | 2024.12.23 |
|---|---|
| 2024.12.19 Today I Learned (2) | 2024.12.19 |
| 2024.10.13 Today I Learned (1) | 2024.10.13 |
| 2024.09.25 Today I Learned (0) | 2024.09.25 |
| Today I Learned 2024.09.03 (2) | 2024.09.03 |