프로젝트

자프실 프로젝트-2

aerimi-code 2025. 11. 29. 14:15

stomp+websocket 방식

 

우리 프로젝트에서는 웹 소켓 위에 STOMP프로토콜을 사용하기로 했다.

 

역할

웹 소켓: 서버와 클라이언트 사이를 연결해준다

 

STOMP: 규칙을 제시한다. 데이터에 딱지를 붙여 준다. 

예: SEND-채팅방으로 갈거야. SUBSCRIBE- 나 이제부터 귓속말 들을거야(구독하기).

 

 

api명세서로 따라가보는 흐름

 

[1단계] 입장 수속 (REST API)

경로: POST /api/users/join (일반 HTTP)

 

카운터 접수

 

[2단계] 도로 개통 (웹소켓 연결)

경로: ws://localhost:8080/ws

 

서버와 실시간으로 대화할 전용 터널 생성.  -> 브라우저를 끄기 전까지 연결 유지

 

[3단계] 주파수 맞추기 (STOMP - SUBSCRIBE)

클라이언트가 STOMP규칙에 따라 구독 신청. 

명세서에 존재하는 구독 경로

  1. /topic/public 구독
  2. /user/queue/whisper 구독
  3. /topic/users 구독

💡 핵심: STOMP가 없다면, 서버는 메시지가 왔을 때 이걸 누구한테 뿌려야 할지 일일이 코드로 짜야 함. 하지만 STOMP를 쓰면 "이 주소(/topic/public) 구독한 애들한테 다 뿌려!" 라고 하면 끝.

 

 

[4단계] 말하기 (STOMP - SEND)

경로: /app/chat.send (메세지 전송)

클라이언트의 메세지가 STOMP 봉투에 담겨서 감.

COMMAND: SEND
DESTINATION: /app/chat.send

{ "senderId": 1, "content": "안녕하세요!" }

 

 

    • DESTINATION을 보고 스프링 서버(MessageBroker)가 "아, 이건 채팅 보내는 로직(chat.send)으로 보내야겠구나"라고 판단
  • 서버가 뿌릴 때 (응답):
    • 서버는 이 메시지를 처리한 뒤, 아까 /topic/public을 구독하고 있던 모든 사람(예림, 아연 등)에게 쏴줌.

 

[5단계] 퇴장 (연결 끊기)

 

  1. 클라이언트가 브라우저를 닫음. (웹소켓 터널 붕괴 💥)
  2. 스프링은 "연결된 선이 끊어졌네?"라고 감지. (SessionDisconnectEvent)
  3. 서버가 자동으로 "00님이 나갔습니다"라는 안내 방송을 만들어서, 아직 남아있는 사람들의 /topic/users 채널로 쏴줌

 


 

기존 웹 REST API랑 다른점은 Controller가 URL주소를 받는게 아니라 STOMP(메시지 주소)를 받는다. 

 

private final ObjectMapper objectMapper;

 

//접속한 유저 관리 (메모리저장소)

private static final Map<String , WebSocketSession> userSessions = new ConcurrentHashMap<>();

 

이게 필요 없다. 

 

구독개념으로 사용하기 떄문에, 스프링이 내부적으로 누가 들어와있는지(구독자)를 알아서 관리해준다.

 

비교

// 우리가 짠 코드: 수동으로 관리함
private Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>();

public void userEnter(String name, WebSocketSession session) {
    sessions.put(name, session); // 명부에 적기
    // ... for문 돌려서 전체 방송 ...
}

 

// STOMP 방식: 그냥 리턴하면 끝남
@MessageMapping("/enter") // 클라이언트가 /app/enter로 보내면
@SendTo("/topic/chat")    // 구독한 모두에게 리턴값(/topic/chat)을 쏨
public ChatMessage enter(ChatMessage msg) {
    msg.setMessage(msg.getSender() + "님이 입장하셨습니다.");
    return msg; // 리턴하면 스프링이 알아서 전체 방송 때림
}

 

3. 그럼 STOMP에서 입/퇴장 감지는 어떻게 해?

"Map이 없는데, 진짜 연결이 끊겼는지 어떻게 알고 '퇴장하셨습니다'를 띄우지?" 👉 스프링이 제공하는 **이벤트 리스너(Event Listener)**를 사용합니다.

 

@Component
@RequiredArgsConstructor
public class WebSocketEventListener {

    private final SimpMessageSendingOperations messagingTemplate;

    // 연결이 끊기면(Disconnect) 스프링이 이 함수를 자동으로 실행해줌
    @EventListener
    public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
        // 여기서 "OO님이 퇴장하셨습니다" 메시지를 만들어서
        // messagingTemplate.convertAndSend("/topic/chat", msg); 로 쏘면 끝.
    }
}

 

 

입장은 클라이언트가 버튼을 누르면 입장되니까 cotroller가 관리하고, 퇴장은 사용자가 창을 꺼서 끊기때문에, 감시자 (Listener)가 필요하다.

1. 전체 흐름도 (Flow)

  • [입장] 클라이언트(버튼 클릭) ➡ /app/chat.join 전송 ➡ Controller 실행
  • Controller:
    • DB에서 유저 찾음.
    • UserService: 명단에 이름 올림 (addActiveUser).
    • Session: 나갈 때를 대비해 이름표 붙임 (put("username")).
    • ChatService: "이제 방송해!"라고 명령 (userJoin).
  • ChatService:
    • 개인: 지난 대화 내용을 그 사람에게만 쏴줌 (loadHistory).
    • 전체: "OOO님 입장!" 알림 메시지 방송 (/topic/public).
    • 전체: [철수, 영희, OOO] 갱신된 명단 방송 (/topic/users).

 

  • [퇴장] 클라이언트(창 닫음) ➡ 연결 끊김 감지 ➡ Listener 실행
  • 3. WebSocketEventListener (Listener):
    • 이벤트에서 StompHeaderAccessor를 통해 세션 정보를 엽니다.
    • 아까 입장할 때(joinChat) 넣어뒀던 username("철수")을 꺼냅니다.
    • UserService에게 "철수 명단에서 지워!" 라고 시킵니다.
    • ChatService에게 "철수 나갔다고 방송해!" 라고 시킵니다.

 

 

 

 

stomp 이해

 

왜 userid가 아니라 string을 서비스에 보낼까? 

 

STOMP는 Username을 보고 배달하기 때문이다. (headAccesor가 각 유저의 소켓에 userName을 붙여 관리한다.)