Spring

[Spring Boot] WebSocket 사용법 | 웹소켓 사용법 | 웹소켓 실시간 알림 | 웹소켓 프론트 | 웹소켓 테스트하는 법 | 웹소켓 html

kimslab01 2024. 10. 29. 02:37

 

 

 

** 실시간 알림 구현의 6가지 방법 **

 

1. WebSocket

  • 설명: WebSocket은 클라이언트와 서버 간의 양방향 통신을 제공하는 프로토콜입니다. 한 번 연결이 맺어지면 서버에서 클라이언트로 실시간으로 데이터를 푸시할 수 있습니다.
  • 장점:
    • 양방향 통신이 가능해 실시간 상호작용에 적합.
    • 서버에서 클라이언트로 즉시 알림 전송이 가능.
  • 단점:
    • 서버와 클라이언트 간의 지속적인 연결을 유지해야 하므로 서버 자원을 많이 소모할 수 있음.
  • 사용 예시: 채팅 애플리케이션, 실시간 알림 시스템.

2. Server-Sent Events (SSE)

  • 설명: SSE는 서버에서 클라이언트로 일방향 통신을 위한 기술입니다. HTTP 연결을 유지한 상태에서 서버가 클라이언트에게 데이터를 전송할 수 있습니다.
  • 장점:
    • HTTP 기반이므로 방화벽이나 프록시를 쉽게 통과할 수 있음.
    • 상대적으로 구현이 간단하고 서버 리소스 소모가 적음.
  • 단점:
    • 양방향 통신이 불가능하며, 클라이언트가 서버로 데이터를 보내려면 별도의 요청이 필요.
  • 사용 예시: 주식 가격 변동, 뉴스 피드 업데이트.

3. Polling (Long Polling 포함)

  • 설명: 클라이언트가 주기적으로 서버에 요청을 보내 데이터를 가져오는 방식입니다. Long Polling은 클라이언트가 요청을 보내고 서버에서 새로운 데이터가 생길 때까지 응답을 기다리는 방식입니다.
  • 장점:
    • 클라이언트와 서버의 연결이 즉각 끊기지 않아 빠르게 데이터를 수신할 수 있음.
    • WebSocket이나 SSE와 달리 모든 브라우저에서 지원됨.
  • 단점:
    • 반복적인 요청으로 인해 서버에 부하를 줄 수 있음.
  • 사용 예시: 간단한 실시간 알림이 필요할 때.

4. Redis Pub/Sub

  • 설명: Redis의 Pub/Sub 기능을 이용해 여러 서버 간에 실시간 알림을 주고받을 수 있습니다. 서버가 메시지를 발행하면 구독한 클라이언트들이 해당 메시지를 실시간으로 수신합니다.
  • 장점:
    • 여러 서버 간의 메시지 교환이 필요할 때 유용함.
    • Redis가 고성능이므로 대규모 시스템에서도 확장 가능.
  • 단점:
    • 클라이언트가 직접 Redis Pub/Sub를 사용하는 경우는 드물고, 주로 서버 간 메시지 전달에 사용됨.
  • 사용 예시: 분산 서버 환경에서 실시간 데이터 동기화 및 알림 전송.

5. Apache Kafka

  • 설명: Kafka는 대규모 분산 시스템에서 실시간 스트리밍 데이터를 처리하는 데 사용되는 메시지 브로커입니다. 서버에서 이벤트를 발행하면 구독 중인 서비스들이 이를 받아 처리할 수 있습니다.
  • 장점:
    • 대용량 데이터를 처리하는 데 최적화됨.
    • 실시간 스트리밍 데이터를 처리하는 데 탁월.
  • 단점:
    • 시스템 구성이 복잡할 수 있으며, 학습 곡선이 가파를 수 있음.
  • 사용 예시: 대규모 데이터 파이프라인, 실시간 이벤트 스트리밍.

6. Firebase Cloud Messaging (FCM)

  • 설명: FCM은 Google에서 제공하는 푸시 알림 서비스로, 모바일 기기 또는 웹 브라우저로 푸시 알림을 보낼 수 있습니다.
  • 장점:
    • 모바일 앱 또는 웹 애플리케이션에서 쉽게 구현 가능.
    • 서버에서 푸시 알림을 즉각적으로 보낼 수 있음.
  • 단점:
    • Google의 클라우드 서비스에 종속적이며, 제한된 커스터마이징.
  • 사용 예시: 모바일 앱 또는 웹에서의 실시간 푸시 알림.

 

저는 이중에서 클라이언트와 서버 간의 양방향 통신이 가능하고,

실시간으로 데이터의 변화를 감지하여 알림을 보낼 수 있는 웹소켓 형식을 선택하여 실시간 알림을 구현하였습니다.

 

프로젝트는 당근마켓을 클론한 새싹마켓입니다.

 

여기서 알림을 보내는 부분은 관심 목록에 있는 물품의 가격 변동 알림인데요!

 

제 코드를 참고해서 본인에게 필요한 코드에 맞게 바꿔서 사용하시면 좋을 것 같습니다.

 

 

 

 


 

 

 

 

 

1. Config 파일 설정

 

처음으로 필요한 것은 WebSocket을 사용할 수 있게 해주는 Config 파일들입니다.

WebSocketConfig, SecurityConfig+Jwt 관련 로직, WebConfig입니다.

Jwt 관련 로직들은 제 깃허브를 참고하셔도 좋지만 아마 본인이 사용하시는 것과 크게 차이나지 않는다고 생각하여

본인의 코드를 사용하셔도 좋을 것 같습니다.

 

1. WebSocketConfig

package com.sprarta.sproutmarket.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry; // 메시지 브로커 구성 정의
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry; // STOMP 프로토콜의 엔드포인트 등록
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; // 웹소켓 메시지 브로커 구성을 위한 인터페이스

@Configuration
@EnableWebSocketMessageBroker // 웹소켓 메시징 활성화
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    // 노출할 endpoint 설정
    @Override
    public void registerStompEndpoints(StompEndpointRegistry stompEndpointRegistry) {
        // 웹소켓이 연결하는 endpoint
        stompEndpointRegistry.addEndpoint("/ws") // 연결 url 설정
                .setAllowedOriginPatterns("*") // 모든 출처에서의 요청을 허용 이후 보안상 변경할 것
                .withSockJS();

    }

    //메세지 브로커 설정
    @Override
    public void configureMessageBroker(MessageBrokerRegistry messageBrokerRegistry) {
        // 서버 -> 클라이언트로 발행하는 메세지에 대한 endpoint 설정 : 구독
        messageBrokerRegistry.enableSimpleBroker("/sub"); // 메시지 브로커 활성화

        // 클라이언트->서버로 발행하는 메세지에 대한 endpoint 설정 : 구독에 대한 메세지
        messageBrokerRegistry.setApplicationDestinationPrefixes("/pub"); // 메시지를 발행할 때 사용할 접두사
    }
}

 

 

2. SecurityConfig

package com.sprarta.sproutmarket.config;

import lombok.RequiredArgsConstructor;
import org.apache.catalina.filters.CorsFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@EnableMethodSecurity(prePostEnabled = true) // 최신 방식의 메서드 수준 권한 제어
public class SecurityConfig {
    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(); // BCryptPasswordEncoder 를 사용하여 비밀번호를 인코딩
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .cors(cors -> cors.configurationSource(corsConfigurationSource())) // CORS 설정 추가
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                .addFilterBefore(jwtAuthenticationFilter, SecurityContextHolderAwareRequestFilter.class)
                .formLogin(AbstractHttpConfigurer::disable)
                .anonymous(AbstractHttpConfigurer::disable)
                .httpBasic(AbstractHttpConfigurer::disable)
                .logout(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/auth/**",
                                "/test/**",
                                "/error/**",
                                "/notifications/**").permitAll()
                        .requestMatchers("/ws/**").permitAll() // WebSocket 접근 허용
                        //Swagger 관련 오픈
                        .requestMatchers("/docs/**",
                                "/v3/api-docs/swagger-config").permitAll()
                        .requestMatchers("/admin/**").hasRole("ADMIN")
                        .requestMatchers("/","/signup", "/signin").permitAll()
                        .anyRequest().authenticated()
                )
                .build();
    }

    @Bean
    public CorsFilter corsFilter() {
        return new CorsFilter();
    }

    @Bean
    public UrlBasedCorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true);
        config.addAllowedOriginPattern("*");
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);

        return source;
    }
}

requestMatcher 부분은 실제로 사용하는 부분을 그대로 옮긴 것입니다. 참고해주세요!

 

 

3. WebConfig

package com.sprarta.sproutmarket.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")  // 모든 경로에 대해 CORS 설정
                .allowedOrigins("http://localhost:8080")  // 허용할 Origin 설정
                .allowedMethods("*")  // 모든 HTTP 메서드 허용
                .allowedHeaders("*")  // 모든 헤더 허용
                .allowCredentials(true);
    }

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/favicon.ico")
                .addResourceLocations("classpath:/static/");
    }
}

SecurityConfig와 WebConfig에 거쳐서 CORS를 열어주었습니다.

웹소켓 사용할 때 가장 어려웠던 부분이 프론트와의 연걸인데,

이 CORS를 해야 프론트와 연결이 됩니다. 꼭 참고해주세요!

 

그리고 favicon.ico도 src/main/resoures/static 밑에다가 만들어주세요!

 

 

 

 

 


 

 

 

 

 

2. 서비스에 알림 보내는 로직 추가

 

이건 개인적으로 알림을 보내려는 용도에 따라 코드가 완전히 달라질 것 같은데요.

따로 notification CRUD를 작성하지는 않았고, 알림이 필요한 부분의 service 코드에 추가해주는 방향으로 했습니다.

최소한으로 코드를 수정하여 알림 로직을 구현할 수 있어 좋았습니다.

 

 

1. ItemService

package com.sprarta.sproutmarket.domain.item.service;

import com.sprarta.sproutmarket.domain.common.enums.ErrorStatus;
import com.sprarta.sproutmarket.domain.common.exception.ApiException;
import com.sprarta.sproutmarket.domain.interestedItem.service.InterestedItemService;
import com.sprarta.sproutmarket.domain.item.dto.request.ItemContentsUpdateRequest;
import com.sprarta.sproutmarket.domain.item.dto.response.ItemResponse;
import com.sprarta.sproutmarket.domain.item.entity.Item;
import com.sprarta.sproutmarket.domain.item.repository.ItemRepository;
import com.sprarta.sproutmarket.domain.user.entity.CustomUserDetails;
import com.sprarta.sproutmarket.domain.user.entity.User;
import com.sprarta.sproutmarket.domain.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ItemService {
    private final ItemRepository itemRepository;
    private final UserRepository userRepository;
    private final SimpMessagingTemplate simpMessagingTemplate;
    private final InterestedItemService interestedItemService;

    /**
     * 매물의 내용(제목, 설명, 가격)을 수정하는 로직
     * @param itemId Item's ID
     * @param itemContentsUpdateRequest 매물 수정 정보를 포함한 요청 객체(제목, 내용, 가격, 이미지URL)
     * @param authUser 매물 내용 수정을 요청한 사용자
     * @return ItemResponse - 등록된 매물의 제목, 설명, 가격, 등록한 사용자의 닉네임을 포함한 응답 객체
     */
    @Transactional
    public ItemResponse updateContents(Long itemId, ItemContentsUpdateRequest itemContentsUpdateRequest, CustomUserDetails authUser){
        // response(user.email)를 위해 AuthUser에서 사용자 정보 가져오기
        User user = userRepository.findById(authUser.getId())
            .orElseThrow(() ->  new ApiException(ErrorStatus.NOT_FOUND_USER));

        // 매물 존재하는지, 해당 유저의 매물이 맞는지 확인
        Item item = itemRepository.findByIdAndSellerIdOrElseThrow(itemId, user);

        // 기존 가격 저장
        int oldPrice = item.getPrice();

        item.changeContents(
            itemContentsUpdateRequest.getTitle(),
            itemContentsUpdateRequest.getDescription(),
            itemContentsUpdateRequest.getPrice()
        );

        // 가격이 변경되었는지 확인
        boolean isPriceChanged = oldPrice != itemContentsUpdateRequest.getPrice();

        // 가격이 변경되었으면 알림 발송
        if (isPriceChanged) {
            notifyUsersAboutPriceChange(item.getId(), itemContentsUpdateRequest.getPrice());
        }

        return new ItemResponse(
            item.getTitle(),
            item.getDescription(),
            item.getPrice(),
            user.getNickname()
        );
    }

    /**
     * 관심 상품으로 등록한 사용자들에게 가격 변경 알림을 보내는 메서드
     */
    private void notifyUsersAboutPriceChange(Long itemId, int newPrice) {
        // 관심 상품 사용자 조회
        List<User> interestedUsers = interestedItemService.findUsersByInterestedItem(itemId);

        // 관심 사용자들에게 알림 전송
        for (User user : interestedUsers) {
            simpMessagingTemplate.convertAndSend("/sub/user/" + user.getId() + "/notifications",
                    "관심 상품의 가격이 변경되었습니다. 새로운 가격: " + newPrice);
        }
    }
}

 

더 자세한 흐름이나 코드는 굳이 적지 않고 깃허브 주소를 참고해주시면 좋을 것 같습니다.

중요한 점은 SimpMessagingTemplate를 import해주는 것입니다.

 

 

 

 

 


 

 

 

 

 

3. 프론트 구현

 

사실 웹소켓에서 가장 힘든 부분이 아닐까 싶습니다.

중요한 점은 일단 DB와 잘 연결되어 데이터가 전달되는지 확인하는 것,

그리고 웹소켓이 연결되는지 확인하는 것입니다.

 

아직 프론트를 구현하는 단계가 아니기 때문에

로그인 화면에서 알림이 뜨는지 테스트만 하려고 짠 코드임을 명심해주세요!

 

정교한 프론트를 구현하는 것보다 테스트에 초점을 맞추었습니다.

 

 

 

먼저 어떻게 되는지 화면부터 보여드릴게요!

 

이렇게 로그인 화면에 해당 알림이 가는지 확인하기 위한 테스트용 코드임을 알 수 있습니다.

 

개발자 화면으로 자세히 보면, 

bearerToken을 받아와서 payload에 있는 내용을 바탕으로 디코딩합니다. (연결된 DB에 저장된 회원이라는 가정하에)

저는 userId가 직접 jwt.io에서 디코딩해보니 userId가 아니라 sub이라는 이름으로 들어가는 것을 확인하였습니다.

 

그 뒤에 웹소켓에 연결하는데, 연결이 잘 되는 것을 볼 수 있어요.

그리고 포스트맨에서 updateContents를 했을 때,

1. 해당 관심 상품을 담은 회원일 때,

2. 해당 관심 상품의 가격 변경이 감지되었을 때

알림 메시지와 새 가격을 출력하도록 했습니다.

 

 

1. HomeController

@Controller
public class HomeController {
    @GetMapping("/signin")
    public String signin() {
        // templates/signin.html 파일을 렌더링
        return "signin";  // "signin"은 templates 폴더 아래의 signin.html을 가리킵니다.
    }
}

이 컨트롤러가 있어야 8080 환경에서 테스트가 가능합니다.

 

 

2. singin.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Signin</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            background-color: #f4f4f4;
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
        }

        .container {
            width: 300px;
            padding: 20px;
            background-color: white;
            box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
            border-radius: 8px;
        }

        h2 {
            text-align: center;
        }

        input {
            width: 100%;
            padding: 10px;
            margin: 10px 0;
            border: 1px solid #ccc;
            border-radius: 4px;
        }

        button {
            width: 100%;
            padding: 10px;
            background-color: #007bff;
            border: none;
            color: white;
            border-radius: 4px;
            cursor: pointer;
        }

        button:hover {
            background-color: #0056b3;
        }

        .link {
            text-align: center;
            margin-top: 10px;
        }

        .link a {
            color: #007bff;
            text-decoration: none;
        }

        .link a:hover {
            text-decoration: underline;
        }

        /* 메시지 표시를 위한 스타일 */
        #messages {
            margin-top: 20px;
            background-color: #f8f9fa;
            padding: 10px;
            border-radius: 5px;
            max-height: 200px;
            overflow-y: auto;
            font-size: 0.9em;
        }

    </style>
</head>
<body>
<div class="container">
    <h2>Sign In</h2>
    <form id="signinForm">
        <input type="email" id="email" placeholder="Email" required>
        <input type="password" id="password" placeholder="Password" required>
        <button type="submit">Sign In</button>
    </form>

    <!-- 여기서 메시지가 표시됨 -->
    <div id="messages"></div>

</div>

<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1.5.1/dist/sockjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>

<script>
    function parseJwt(token) {
        try {
            const base64Url = token.split('.')[1];
            if (!base64Url) {
                throw new Error('Invalid token');
            }
            const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
            const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
                return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
            }).join(''));

            const parsedPayload = JSON.parse(jsonPayload);
            console.log('Parsed JWT payload:', parsedPayload);  // 페이로드 출력

            return parsedPayload;
        } catch (error) {
            console.error('Error parsing JWT:', error.message);
            throw new Error('Failed to parse token');
        }
    }

    // JWT 토큰에서 userId 추출
    document.getElementById('signinForm').addEventListener('submit', function(event) {
        event.preventDefault();

        const data = {
            email: document.getElementById('email').value,
            password: document.getElementById('password').value
        };

        fetch('/auth/signin', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify(data)
        })
            .then(response => response.json())
            .then(data => {
                console.log('Received bearerToken:', data.bearerToken);

                const token = data.bearerToken;
                if (!token) {
                    alert('Token is not available');
                    return;
                }

                // 'Bearer' 접두어가 있는지 확인 후 제거
                const pureToken = token.startsWith('Bearer ') ? token.slice(7) : token;

                console.log('JWT Token:', pureToken);
                localStorage.setItem('token', pureToken);

                // JWT 토큰에서 userId 추출 (sub 사용)
                const decodedToken = parseJwt(pureToken);
                const userId = decodedToken.sub;  // 'sub'을 userId로 사용
                console.log('Decoded userId (sub):', userId);

                if (!userId) {
                    alert('User ID not found in token');
                    return;
                }

                connectWebSocket(userId);
            })
            .catch(error => {
                console.error('Error:', error);
                alert('Sign In failed');
            });
    });

    // WebSocket 연결 함수
    function connectWebSocket(userId) {
        const socket = new SockJS('http://localhost:8080/ws');  // WebSocket 엔드포인트
        const stompClient = Stomp.over(socket);

        stompClient.connect({}, function (frame) {
            console.log('Connected: ' + frame);

            // 해당 사용자(userId)에 대한 구독 경로
            stompClient.subscribe('/sub/user/' + userId + '/notifications', function (message) {
                const messageContainer = document.getElementById('messages');
                if (messageContainer) {
                    messageContainer.innerHTML += '<p>' + message.body + '</p>';
                } else {
                    console.error('Message container not found!');
                }
            });
        });
    }
</script>
</body>
</html>

body 부분을 자세히 보겠습니다.

 

<div class="container">
    <h2>Sign In</h2>
    <form id="signinForm">
        <input type="email" id="email" placeholder="Email" required>
        <input type="password" id="password" placeholder="Password" required>
        <button type="submit">Sign In</button>
    </form>

    <!-- 여기서 메시지가 표시됨 -->
    <div id="messages"></div>

</div>

저희 팀은 email과 pw로 로그인하는데, 그 부분을 반영한 것입니다.

그리고 <div id="messages"></div>는

여기 사진처럼 발생하는 오류를 해결하기 위해 넣은 태그입니다.

 

<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1.5.1/dist/sockjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>

이 부분은 sockjs.min.js와 stomp.min.js를 사용하기 위한 부분입니다.

 

function parseJwt(token) {
        try {
            const base64Url = token.split('.')[1];
            if (!base64Url) {
                throw new Error('Invalid token');
            }
            const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
            const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
                return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
            }).join(''));

            const parsedPayload = JSON.parse(jsonPayload);
            console.log('Parsed JWT payload:', parsedPayload);  // 페이로드 출력

            return parsedPayload;
        } catch (error) {
            console.error('Error parsing JWT:', error.message);
            throw new Error('Failed to parse token');
        }
    }

이 부분은 bearerToken을 가져와서 parsing하는 부분입니다.

여기서 PAYLOAD처럼 파싱해주는 로직입니다.

 

// JWT 토큰에서 userId 추출
    document.getElementById('signinForm').addEventListener('submit', function(event) {
        event.preventDefault();

        const data = {
            email: document.getElementById('email').value,
            password: document.getElementById('password').value
        };

        fetch('/auth/signin', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify(data)
        })
            .then(response => response.json())
            .then(data => {
                console.log('Received bearerToken:', data.bearerToken);

                const token = data.bearerToken;
                if (!token) {
                    alert('Token is not available');
                    return;
                }

                // 'Bearer' 접두어가 있는지 확인 후 제거
                const pureToken = token.startsWith('Bearer ') ? token.slice(7) : token;

                console.log('JWT Token:', pureToken);
                localStorage.setItem('token', pureToken);

                // JWT 토큰에서 userId 추출 (sub 사용)
                const decodedToken = parseJwt(pureToken);
                const userId = decodedToken.sub;  // 'sub'을 userId로 사용
                console.log('Decoded userId (sub):', userId);

                if (!userId) {
                    alert('User ID not found in token');
                    return;
                }

                connectWebSocket(userId);
            })
            .catch(error => {
                console.error('Error:', error);
                alert('Sign In failed');
            });
    });

 

저는 auth 도메인 밑에 signin 엔드포인트로 로그인을 구현했는데,

저와 다르신 분은 fetch('auth/sigin') 이 부분울 수정해주세요. method나 headers, body도 다르면 그에 맞게 수정해주세요.

 

이 로직에서는 signin을 하고 나서 token이 넘어오는데, 그때 Bearer를 뺀 pureToken으로

위에서 파싱하는 과정을 거쳐 파싱하고 나면, sub(=userId)가 나오게 됩니다.

 

// WebSocket 연결 함수
    function connectWebSocket(userId) {
        const socket = new SockJS('http://localhost:8080/ws');  // WebSocket 엔드포인트
        const stompClient = Stomp.over(socket);

        stompClient.connect({}, function (frame) {
            console.log('Connected: ' + frame);

            // 해당 사용자(userId)에 대한 구독 경로
            stompClient.subscribe('/sub/user/' + userId + '/notifications', function (message) {
                const messageContainer = document.getElementById('messages');
                if (messageContainer) {
                    messageContainer.innerHTML += '<p>' + message.body + '</p>';
                } else {
                    console.error('Message container not found!');
                }
            });
        });

여기서는 userId를 사용하여 구독 경로를 파악하는데요. 위에 service 로직에서 짠 

simpMessagingTemplate.convertAndSend("/sub/user/" + user.getId() + "/notifications",
                    "관심 상품의 가격이 변경되었습니다. 새로운 가격: " + newPrice);

여기서 짠 코드와 엔드포인트가 같음을 알 수 있습니다.

즉, 여기서 /sub/user/{userId}/notifications라는 엔드포인트를 프론트에서 사용함으로써

service 로직의 알림을 프론트에서 보낼 수 있게 되는 것이죠!

 

그 밑부분은 구독 경로에 따른 알림 메시지를 받는 부분입니다.

위에 messages div에서 본 오류를 해결하기 위한 일부입니다.

알림 메시지는 service의 convertAndSend를 통해 js로 옮겨집니다.

 

 

 

먼저 http://localhost:8080/ws로 접속해보면

잘 연결되었다고 뜹니다.

밑의 개발자 환경에서 보이는 403 favicon.ico는 굳이 신경쓰지 않아도 되는 오류이니 무시해도 좋습니다.

 

혹시 이게 어려우신 분들은 웹소켓 포스트맨 테스트를 하는 방법도 있습니다.

그냥 connect 되었는지만 확인할 수 있습니다.

 

여기까지 잘 따라오셨다면 아마

이렇게 개발자 환경까지 잘 뜨실 것이라 믿습니다...!!

 

github 링크를 걸어둘테니 자세한 코드나 흐름은 깃헙을 참고하세요!

https://github.com/NBC-Spring6-Final-Team15/sprout-market

 

GitHub - NBC-Spring6-Final-Team15/sprout-market: 개인 간 중고거래 플랫폼 백엔드 개발

개인 간 중고거래 플랫폼 백엔드 개발. Contribute to NBC-Spring6-Final-Team15/sprout-market development by creating an account on GitHub.

github.com