** 실시간 알림 구현의 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
'Spring' 카테고리의 다른 글
[Spring] JwtUtil 클래스란? | JwtUtil 역할 | JwtUtil 구현 (0) | 2024.11.22 |
---|---|
[트러블슈팅] localhost:63342 오류 | localhost:63342 웹소켓 연결 안됨 | localhost:63342 연결 안됨 (0) | 2024.11.05 |
[Spring] Spring Security 테스트 코드 401 에러 | Spring Security 테스트 코드 401 에러 해결법 (0) | 2024.10.06 |
[Spring] Spring Boot에서 JPA QueryDSL 사용 | QueryDSL 사용법 | QueryDSL 의존성 추가 (2) | 2024.10.03 |
[Spring] 스프링 카카오 소셜 로그인 구현 | 깃 카카오 소셜 로그인 코드 (5) | 2024.09.25 |