-
Notifications
You must be signed in to change notification settings - Fork 1
[FEAT] 웹소켓을 활용한 정보 공유 파이프라인 구현 #221
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7e7a07a
03a0529
30150ec
151afbb
15d9c2d
6cfc51c
c86904f
b1af2ee
5481b31
efbecad
93ac5af
cebf6f5
3a429cd
0dc1af8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| package com.debatetimer.config; | ||
|
|
||
| import com.debatetimer.exception.custom.DTInitializationException; | ||
| import com.debatetimer.exception.errorcode.InitializationErrorCode; | ||
| import lombok.Getter; | ||
| import org.springframework.boot.context.properties.ConfigurationProperties; | ||
|
|
||
|
|
||
| @Getter | ||
| @ConfigurationProperties(prefix = "cors.origin") | ||
| public class CorsProperties { | ||
|
|
||
| private final String[] corsOrigin; | ||
|
|
||
| //TODO 머지될 때 dev, prod secret 갱신 필요 | ||
| public CorsProperties(String[] corsOrigin) { | ||
| validate(corsOrigin); | ||
| this.corsOrigin = corsOrigin; | ||
| } | ||
|
|
||
| private void validate(String[] corsOrigin) { | ||
| if (corsOrigin == null || corsOrigin.length == 0) { | ||
| throw new DTInitializationException(InitializationErrorCode.CORS_ORIGIN_EMPTY); | ||
| } | ||
| for (String origin : corsOrigin) { | ||
| if (origin == null || origin.isBlank()) { | ||
| throw new DTInitializationException(InitializationErrorCode.CORS_ORIGIN_STRING_BLANK); | ||
| } | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| package com.debatetimer.config.sharing; | ||
|
|
||
| import com.debatetimer.controller.auth.AuthMember; | ||
| import com.debatetimer.controller.tool.jwt.AuthManager; | ||
| import com.debatetimer.exception.custom.DTClientErrorException; | ||
| import com.debatetimer.exception.errorcode.ClientErrorCode; | ||
| import com.debatetimer.service.auth.AuthService; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.core.MethodParameter; | ||
| import org.springframework.http.HttpHeaders; | ||
| import org.springframework.messaging.Message; | ||
| import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; | ||
| import org.springframework.messaging.simp.stomp.StompHeaderAccessor; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class WebSocketAuthMemberResolver implements HandlerMethodArgumentResolver { | ||
|
|
||
| private final AuthManager authManager; | ||
| private final AuthService authService; | ||
|
|
||
| @Override | ||
| public boolean supportsParameter(MethodParameter parameter) { | ||
| return parameter.hasParameterAnnotation(AuthMember.class); | ||
| } | ||
|
|
||
| @Override | ||
| public Object resolveArgument(MethodParameter parameter, Message<?> message) { | ||
| StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); | ||
| String token = accessor.getFirstNativeHeader(HttpHeaders.AUTHORIZATION); | ||
|
|
||
| if (token == null) { | ||
| throw new DTClientErrorException(ClientErrorCode.UNAUTHORIZED_MEMBER); | ||
| } | ||
|
|
||
| String email = authManager.resolveAccessToken(token); | ||
| return authService.getMember(email); | ||
|
Comment on lines
+37
to
+38
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 단순 궁금증입니다.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 음... 재발급 과정은 비토가 말한대로 꽤 크리티컬 할 것 같아요. 1초 이상이 지연이 생기면 잘 연동되는 느낌이 아니라서요. 뭔가 프론트랑 규약할 때 웹소켓용으로 토큰을 재발급해주거나, 웹소켓 시에는 인증을 약하게 유지하는 방식도 있을 것 같아요. 확실한 건 기존 어플리케이션 인증용 토큰을 그대로 사용하면 안될 것 같습니다. WebScoket Security 도 있어서 Spring 에서 제공해주는 웹소켓 보안 관련 스펙 한번 확인해보고 적절한 대안을 생각해볼게요. 이건 추후 따로 이슈파서 해결하겠습니다. |
||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| package com.debatetimer.config.sharing; | ||
|
|
||
| import com.debatetimer.config.CorsProperties; | ||
| import java.util.List; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.context.annotation.Configuration; | ||
| import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; | ||
| import org.springframework.messaging.simp.config.MessageBrokerRegistry; | ||
| import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; | ||
| import org.springframework.web.socket.config.annotation.StompEndpointRegistry; | ||
| import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; | ||
|
|
||
| @Configuration | ||
| @RequiredArgsConstructor | ||
| @EnableWebSocketMessageBroker | ||
| public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { | ||
|
|
||
| private final CorsProperties corsProperties; | ||
| private final WebSocketAuthMemberResolver webSocketAuthMemberResolver; | ||
|
|
||
| @Override | ||
| public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) { | ||
| resolvers.add(webSocketAuthMemberResolver); | ||
| } | ||
|
|
||
| @Override | ||
| public void configureMessageBroker(MessageBrokerRegistry registry) { | ||
| registry.enableSimpleBroker("/room", "/chairman"); | ||
| registry.setApplicationDestinationPrefixes("/app"); | ||
| } | ||
|
Comment on lines
+26
to
+30
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 나중에 웹소켓 관련 로직이 추가되면, 해당 부분도 추가되야하는거죠?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 네 맞습니다. -> 메모리에 내장된 Spring이 제공해주는 SimpleBroker에 위임할 prefix는 enableSimpleBroker에 추가 |
||
|
|
||
| @Override | ||
| public void registerStompEndpoints(StompEndpointRegistry registry) { | ||
| registry.addEndpoint("/ws") | ||
| .setAllowedOriginPatterns(corsProperties.getCorsOrigin()) | ||
| .withSockJS(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| package com.debatetimer.controller.sharing; | ||
|
|
||
| import com.debatetimer.controller.auth.AuthMember; | ||
| import com.debatetimer.domain.member.Member; | ||
| import com.debatetimer.dto.sharing.request.SharingRequest; | ||
| import com.debatetimer.dto.sharing.response.SharingResponse; | ||
| import org.springframework.messaging.handler.annotation.DestinationVariable; | ||
| import org.springframework.messaging.handler.annotation.MessageMapping; | ||
| import org.springframework.messaging.handler.annotation.Payload; | ||
| import org.springframework.messaging.handler.annotation.SendTo; | ||
| import org.springframework.stereotype.Controller; | ||
|
|
||
| @Controller | ||
| public class SharingController { | ||
|
|
||
| @MessageMapping("/event/{roomId}") | ||
| @SendTo("/room/{roomId}") | ||
| public SharingResponse share( | ||
| @AuthMember Member member, | ||
| @DestinationVariable(value = "roomId") long roomId, | ||
| @Payload SharingRequest request | ||
| ) { | ||
| return new SharingResponse(request.time()); | ||
| } | ||
|
Comment on lines
+16
to
+24
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 혹시, 발행자-구독자가 모두 연결되어 있는 상황에서, 발행자가 갑자기 연결이 끊길 경우에 대한 처리가 있나요?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 없습니다 그런데 이걸 백에서 해야할지도 조금 더 알아봐야할 것 같아요. 최근에 테코톡에서 웹소켓 끊김 대응전략을 보아서 오히려 프론트 단의 처리가 더 좋을 수도 있을 것 같아서요. 어떻게 클라이언트가 비정상종료되었는지를 서버가 감지하고 대응할지는 저도 공부가 더 필요할 것 같습니다. |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| package com.debatetimer.dto.sharing.request; | ||
|
|
||
| public record ChairmanSharingRequest( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Chairman이 뭔지 궁금해서 검색해보니 기업의 회장, 이사회의 의장 이런 개념이던데 맞을까요?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 사회자, 의장 으로 의도했습니다.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (선택) 체어맨하면 한 눈에 이해하기 어려우니까, Announcer 나 구독자/발행자로 해보는 것도... (근데 애매하네요;;) |
||
| long roomId | ||
| ) { | ||
|
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| package com.debatetimer.dto.sharing.request; | ||
|
|
||
| import java.time.LocalDateTime; | ||
|
|
||
| public record SharingRequest( | ||
| LocalDateTime time | ||
| ) { | ||
|
|
||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| package com.debatetimer.dto.sharing.response; | ||
|
|
||
| import java.time.LocalDateTime; | ||
|
|
||
| public record SharingResponse( | ||
| LocalDateTime time | ||
| ) { | ||
|
|
||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| package com.debatetimer.event.sharing; | ||
|
|
||
| import com.debatetimer.dto.sharing.request.ChairmanSharingRequest; | ||
| import com.debatetimer.exception.custom.DTClientErrorException; | ||
| import com.debatetimer.exception.errorcode.ClientErrorCode; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.context.event.EventListener; | ||
| import org.springframework.messaging.simp.SimpMessagingTemplate; | ||
| import org.springframework.messaging.simp.stomp.StompHeaderAccessor; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.web.socket.messaging.SessionSubscribeEvent; | ||
|
|
||
| @Slf4j | ||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class RoomSubscribeListener { | ||
|
|
||
| private static final String AUDIENCE_SUBSCRIBE_PREFIX = "/room/"; | ||
| private static final String CHAIRMAN_CHANNEL_PREFIX = "/chairman/"; | ||
|
|
||
| private final SimpMessagingTemplate messagingTemplate; | ||
|
|
||
| @EventListener | ||
| public void handleSubscribeEvent(SessionSubscribeEvent event) { | ||
| StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); | ||
| String destination = accessor.getDestination(); | ||
| if (destination == null) { | ||
| return; | ||
| } | ||
|
|
||
| if (destination.startsWith(AUDIENCE_SUBSCRIBE_PREFIX)) { | ||
| long roomId = parseRoomId(destination); | ||
| messagingTemplate.convertAndSend(CHAIRMAN_CHANNEL_PREFIX + roomId, new ChairmanSharingRequest(roomId)); | ||
| } | ||
|
Comment on lines
24
to
35
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 구독 destination 파싱 시 잘못된 roomId에 대한 방어 코드 제안 현재 예외를 로그로 남기고 무시하도록 방어 코드를 두면, 잘못된 구독 요청이 와도 다른 세션 처리에는 영향을 주지 않을 것 같습니다. @EventListener
public void handleSubscribeEvent(SessionSubscribeEvent event) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage());
String destination = accessor.getDestination();
if (destination == null) {
return;
}
if (destination.startsWith(AUDIENCE_SUBSCRIBE_PREFIX)) {
- long roomId = Long.parseLong(destination.replace(AUDIENCE_SUBSCRIBE_PREFIX, ""));
- messagingTemplate.convertAndSend(CHAIRMAN_CHANNEL_PREFIX + roomId, new ChairmanSharingRequest(roomId));
+ String roomIdPart = destination.substring(AUDIENCE_SUBSCRIBE_PREFIX.length());
+ try {
+ long roomId = Long.parseLong(roomIdPart);
+ messagingTemplate.convertAndSend(
+ CHAIRMAN_CHANNEL_PREFIX + roomId,
+ new ChairmanSharingRequest(roomId)
+ );
+ } catch (NumberFormatException e) {
+ log.warn("Invalid room subscription destination: {}", destination, e);
+ }
}
}
🤖 Prompt for AI Agents
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @coli-geonwoo 이 내용 한번 확인해주세요 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 반영해주었습니다... 값 객체로 감싸고 싶긴 했는데 너무 API 규약에 의존하는 느낌이라 순수한 VO 생성이 안될 것 같아 일단 private method로 처리해주었어요. |
||
| } | ||
|
|
||
| private long parseRoomId(String destination) { | ||
| try { | ||
| String parsedRoomId = destination.substring(AUDIENCE_SUBSCRIBE_PREFIX.length()); | ||
| return Long.parseLong(parsedRoomId); | ||
| } catch (NumberFormatException exception) { | ||
| throw new DTClientErrorException(ClientErrorCode.INVALID_ROOM_ID); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| package com.debatetimer; | ||
|
|
||
| import com.debatetimer.fixture.HeaderGenerator; | ||
| import com.debatetimer.fixture.entity.MemberGenerator; | ||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; | ||
| import java.util.List; | ||
| import java.util.concurrent.ExecutionException; | ||
| import java.util.concurrent.TimeUnit; | ||
| import java.util.concurrent.TimeoutException; | ||
| import org.junit.jupiter.api.AfterEach; | ||
| import org.junit.jupiter.api.BeforeEach; | ||
| import org.springframework.beans.factory.annotation.Autowired; | ||
| import org.springframework.boot.test.context.SpringBootTest; | ||
| import org.springframework.boot.test.web.server.LocalServerPort; | ||
| import org.springframework.messaging.converter.MappingJackson2MessageConverter; | ||
| import org.springframework.messaging.converter.MessageConverter; | ||
| import org.springframework.messaging.simp.stomp.StompSession; | ||
| import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter; | ||
| import org.springframework.web.socket.client.standard.StandardWebSocketClient; | ||
| import org.springframework.web.socket.messaging.WebSocketStompClient; | ||
| import org.springframework.web.socket.sockjs.client.SockJsClient; | ||
| import org.springframework.web.socket.sockjs.client.Transport; | ||
| import org.springframework.web.socket.sockjs.client.WebSocketTransport; | ||
|
|
||
| @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) | ||
| public abstract class BaseStompTest { | ||
|
|
||
| private static final String SOCKET_ENDPOINT = "/ws"; | ||
|
|
||
| protected StompSession stompSession; | ||
|
|
||
| @LocalServerPort | ||
| private int port; | ||
|
|
||
| private final String url; | ||
|
|
||
| private final WebSocketStompClient websocketClient; | ||
|
|
||
| @Autowired | ||
| protected MemberGenerator memberGenerator; | ||
|
|
||
| @Autowired | ||
| protected HeaderGenerator headerGenerator; | ||
|
|
||
| public BaseStompTest() { | ||
| List<Transport> transports = List.of(new WebSocketTransport(new StandardWebSocketClient())); | ||
| this.websocketClient = new WebSocketStompClient(new SockJsClient(transports)); | ||
| this.websocketClient.setMessageConverter(buildMessageConverter()); | ||
| this.url = "ws://localhost:"; | ||
| } | ||
|
|
||
| private MessageConverter buildMessageConverter() { | ||
| MappingJackson2MessageConverter converter = new MappingJackson2MessageConverter(); | ||
| ObjectMapper objectMapper = new ObjectMapper(); | ||
| objectMapper.registerModule(new JavaTimeModule()); | ||
| objectMapper.findAndRegisterModules(); | ||
| converter.setObjectMapper(objectMapper); | ||
| return converter; | ||
| } | ||
|
|
||
| @BeforeEach | ||
| public void connect() throws ExecutionException, InterruptedException, TimeoutException { | ||
| this.stompSession = this.websocketClient | ||
| .connectAsync(url + port + SOCKET_ENDPOINT, new StompSessionHandlerAdapter() { | ||
| }) | ||
| .get(3, TimeUnit.SECONDS); | ||
| } | ||
|
|
||
| @AfterEach | ||
| public void disconnect() { | ||
| if (this.stompSession.isConnected()) { | ||
| this.stompSession.disconnect(); | ||
| } | ||
| } | ||
|
Comment on lines
+62
to
+75
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. connect 실패 시 disconnect에서 NPE 발생 가능성
if (this.stompSession.isConnected()) {에서 @AfterEach
public void disconnect() {
- if (this.stompSession.isConnected()) {
- this.stompSession.disconnect();
- }
+ if (this.stompSession != null && this.stompSession.isConnected()) {
+ this.stompSession.disconnect();
+ }
}🤖 Prompt for AI Agents |
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
이렇게 하면 웹소켓을 사용하지 않는
@AuthMember가 붙은 곳에도 해당 ArgumentResolver가 적용되는 건가요?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
웹소켓 로직에만 적용됩니다.
웹소켓 MessageMapping 을 통해 애플리케이션에 위임된 메시지 핸들링 로직에서
@AuthMember가 붙은 엔드포인트에서 해당 로직을 인터셉트하여 실행해주어요.package org.springframework.messaging.handler.invocation;
여기서 messaging이 Spring STOMP 소켓 관련 핸들링 로직이 있는 패키지입니다.