WebSocket이란?
https://harmony-raccoon.tistory.com/145
[Network] WebSocket이란?
WebSocket이란?WebSocket은 단일 TCP(Transmission Control Protocol) 연결을 통해 양방향 통신을 제공하는 컴퓨터 통신 프로토콜을 의미합니다.HTTP 통신과는 다르지만 WebSocket은 "HTTP 포트 443, 80을 통해 작동하
harmony-raccoon.tistory.com
STOMP이란?
https://harmony-raccoon.tistory.com/144
[Network] WebSocket (STOMP)
WebSocket이란?https://harmony-raccoon.tistory.com/145 [Network] WebSocket이란?WebSocket이란?WebSocket은 단일 TCP(Transmission Control Protocol) 연결을 통해 양방향 통신을 제공하는 컴퓨터 통신 프로토콜을 의미합니다.HTTP
harmony-raccoon.tistory.com
Vue.js로 WebSocket 구현하기
https://harmony-raccoon.tistory.com/152
[Vue.js] WebSocket 구현하기
WebSocket 이란?https://harmony-raccoon.tistory.com/145 [Network] WebSocket이란?WebSocket이란?WebSocket은 단일 TCP(Transmission Control Protocol) 연결을 통해 양방향 통신을 제공하는 컴퓨터 통신 프로토콜을 의미합니다.HTT
harmony-raccoon.tistory.com
Spring을 이용해서 WebSocket 구현하기
WebSocket 서버를 열기 위해서는 WebSocket Config 파일이 존재해야 합니다.
WebSocketConfig
@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final JwtHandShakeInterceptor jwtHandShakeInterceptor;
private final AuthChannelInterceptor authChannelInterceptor;
@Value("${websocket.allowed-origin}")
private String frontServer; // front URI
@Bean(name = "customMessageBrokerScheduler")
public TaskScheduler messageBrokerTaskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(10);
scheduler.setThreadNamePrefix("ws-heartbeat-");
scheduler.setWaitForTasksToCompleteOnShutdown(true);
scheduler.initialize();
return scheduler;
} // WebSocket을 처리할 Thread 설정
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic", "/queue") // Topic, Queue Prefix 설정
.setTaskScheduler(messageBrokerTaskScheduler())
.setHeartbeatValue(new long[]{10000, 10000});
// 하트비트 연결 및 10초 주기로 연결 확인
registry.setApplicationDestinationPrefixes("/app"); // Client가 데이터를 보낼 Prefix 설정
registry.setUserDestinationPrefix("/user");
// 사용자별 메세지 라우팅
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/chat") // WebSocket HandShake를 할 URI
.addInterceptors(jwtHandShakeInterceptor) // jwt를 통해서 인가 담당 기능
.setAllowedOriginPatterns(frontServer) // CORS 해제 설정
.withSockJS(); // SockJS를 통한 WebSocket 설정
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(authChannelInterceptor); // WebSocket에서 인증 정보를 처리할 수 있도록 저장하는 기능을 수행하는 객체
}
@Override
public void configureWebSocketTransport(WebSocketTransportRegistration registration) {
registration.setMessageSizeLimit(128 * 1024); // 128KB
registration.setSendBufferSizeLimit(512 * 1024); // 512KB
registration.setSendTimeLimit(1000); // 전송시간 제한 1초
}
}
여기서 추가적인 객체가 2개가 존재합니다.
JwtHandShakeInterceptor, AuthChannelInterceptor인데, 각각 하는 역할이 다릅니다.
JwtHandShakeInterceptor : HTTP HandShake가 일어날 때, 인가 검증
AuthChannelInterceptor : STOMP에서 연결을 하기 위한 CONNECT Frame 통신이 올 때, 인가 검증
특히, AuthChannelInterceptor는 인가를 확인한 후에 사용자 데이터를 기능에 적용할 수 있도록 제공해주는 중요한 객체입니다.
JwtHandShakeInterceptor
@Component
public class JwtHandShakeInterceptor implements HandshakeInterceptor {
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
if (request instanceof ServletServerHttpRequest servletServerHttpRequest) {
HttpServletRequest httpReq = servletServerHttpRequest.getServletRequest();
Cookie[] cookies = httpReq.getCookies();
String jwt = findDabomJWT(cookies);
exceptionHandler(jwt, attributes);
}
return true;
}
private void exceptionHandler(String jwt, Map<String, Object> attributes) {
try {
haveTokenLogic(jwt, attributes);
} catch (ExpiredJwtException e) {
// 만료 토큰 302 Redirect로 RefreshToken
} catch (SecurityException | MalformedJwtException e) {
// 잘못된 토큰 403 Error 내려주기
} catch (UnsupportedJwtException e) {
// 지원되지 않는 토큰 403 Error 내려주기
} catch (IllegalArgumentException e) {
// 빈 토큰 403 Error 내려주기
}
}
private String findDabomJWT(Cookie[] cookies) {
String jwt = null;
if (cookies != null) {
for (Cookie cookie : cookies) {
if (Objects.equals(cookie.getName(), ACCESS_TOKEN)) {
jwt = cookie.getValue();
}
}
}
return jwt;
}
private void haveTokenLogic(String jwt, Map<String, Object> attributes) {
if (jwt != null) {
Claims claims = JwtUtils.getClaims(jwt);
haveDabomTokenLogic(claims, attributes, jwt);
}
}
private void haveDabomTokenLogic(Claims claims, Map<String, Object> attributes, String jwt) {
if (claims != null) {
MemberDetailsDto dto = createDetailsFromToken(claims, jwt);
Authentication authentication = new UsernamePasswordAuthenticationToken(
dto,
null,
List.of(new SimpleGrantedAuthority(dto.getMemberRole().name()))) {
@Override
public String getName() {
return dto.getIdx().toString(); // getName대신 getIdx를 사용하기 위해서 설정
}
};
SecurityContextHolder.getContext().setAuthentication(authentication);
attributes.put("auth", authentication);
}
}
private MemberDetailsDto createDetailsFromToken(Claims claims, String jwt) {
Integer idx = Integer.parseInt(JwtUtils.getValue(claims, TOKEN_IDX));
String name = JwtUtils.getValue(claims, TOKEN_NAME);
String role = JwtUtils.getValue(claims, TOKEN_USER_ROLE);
return MemberDetailsDto.createFromToken(idx, name, role);
}
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
}
}
위 코드는 Spring Security에서 JwtAuthFilter와 매우 흡사한 코드입니다.
실제로 동작 과정도 매우 흡사합니다. (단, 2개의 코드가 완전히 같게 움직이게 할 수 없습니다. / 프로토콜이 미세하게 다르기 때문에)
AuthChannelInterceptor
@Component
public class AuthChannelInterceptor implements ChannelInterceptor {
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if(StompCommand.CONNECT.equals(accessor.getCommand())) { // STOMP에서 CONNECT형태로 데이터가 오면
Map<String, Object> attributes = accessor.getSessionAttributes();
if(attributes!=null) {
Authentication authentication = (Authentication) attributes.get("auth");
if(authentication!=null) {
accessor.setUser(authentication); // 인가 정보를 뽑아서 StompHeaderAccessor에 저장한다.
}
}
}
return message;
}
}
STOMP에서 CONNECT로 Frame의 Command가 날라오면, 처음 웹소캣에 연결하는 클라이언트이므로 인가 과정을 통해 처리해야 합니다.
이렇게 인가 정보는 다음 기능을 수행할 때 사용하게 됩니다.
ChatController
@RestController
@RequiredArgsConstructor
public class TogetherChatController {
private final SimpMessagingTemplate messagingTemplate;
private final MemberService memberService;
private final TogetherService togetherService;
private final Map<String, Set<Integer>> topicSessions = new ConcurrentHashMap<>();
@MessageMapping("/together/{togetherIdx}")
public void sendMessage(Principal principal,
@DestinationVariable Integer togetherIdx, @Payload String message) {
MemberDetailsDto memberDetailsDto = getMemberDetailsDto((Authentication) principal);
MemberInfoResponseDto memberInfo = memberService.getMemberInfo(memberDetailsDto);
String destination = "/topic/together/" + togetherIdx; // 토픽 키
int userCount = topicSessions.getOrDefault(destination, Collections.emptySet()).size();
TogetherChatResponseDto res = TogetherChatResponseDto.toDtoBySend(memberInfo.name(), message, userCount, memberInfo.id());
messagingTemplate.convertAndSend("/topic/together/" + togetherIdx, res);
}
@EventListener
public void handleSubscribeEvent(SessionSubscribeEvent event) {
SimpMessageHeaderAccessor headerAccessor = SimpMessageHeaderAccessor.wrap(event.getMessage());
Principal userPrincipal = headerAccessor.getUser();
String destination = headerAccessor.getDestination();
if (destination != null && destination.startsWith("/topic/together/")) {
// 환영 메시지 생성
MemberDetailsDto memberDetailsDto = getMemberDetailsDto((Authentication) userPrincipal);
MemberInfoResponseDto memberInfo = memberService.getMemberInfo(memberDetailsDto);
// 토픽별 접속자 세션 관리
topicSessions.computeIfAbsent(destination, k -> ConcurrentHashMap.newKeySet()).add(memberInfo.id());
Integer userCount = topicSessions.get(destination).size();
TogetherChatResponseDto welcome = TogetherChatResponseDto.toDtoByJoin(memberInfo.name(), userCount);
messagingTemplate.convertAndSend(destination, welcome);
}
}
@EventListener
public void handleDisconnectEvent(SessionDisconnectEvent event) {
SimpMessageHeaderAccessor headerAccessor = SimpMessageHeaderAccessor.wrap(event.getMessage());
Principal userPrincipal = headerAccessor.getUser();
if (userPrincipal == null) {
return; // 인증정보 없으면 종료
}
MemberDetailsDto memberDetailsDto = getMemberDetailsDto((Authentication) userPrincipal);
Integer memberId = memberDetailsDto.getIdx();
// 연결된 모든 토픽에 대해 해당 멤버 아이디 제거
topicSessions.forEach((topic, memberSet) -> {
boolean removed = memberSet.remove(memberId);
if (removed) {
messagingTemplate.convertAndSend(topic, "");
}
});
}
private MemberDetailsDto getMemberDetailsDto(Authentication principal) {
Authentication authentication = principal;
MemberDetailsDto memberDetailsDto = (MemberDetailsDto)authentication.getPrincipal();
return memberDetailsDto;
}
}
위 코드는 실제 채팅방 기능을 개발한 코드에서 발췌한 내용입니다.
제가 저 코드를 작성할 때, 인가 정보를 가지고 채팅방의 인원을 확인하고, 없다면 새로 추가하여 인원방을 정확하게 처리하기 위해 인가 정보를 활용했었습니다.
이렇게 AuthChannelInterceptor를 활용해서 추가적인 기능 개발을 진행할 수 있습니다.
'Spring' 카테고리의 다른 글
| Spring에서 Mockito를 활용해 Test Code 작성하기 (2) | 2025.01.03 |
|---|---|
| Spring에 예외처리 적용하기 With @RestControllerAdvice (0) | 2024.12.12 |
| Spring에 Swagger 적용하기 (0) | 2024.12.12 |
| Spring에 DTO 적용하기 (0) | 2024.12.09 |