본문 바로가기
TIL(Today I Learned)

2024.12.17 Today I Learned

by 승환파크 2024. 12. 17.

웹 소켓

웹소켓은 클라이언트와 서버 간의 양방향 통신을 위한 프로토콜이다. 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