Skip to content
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

feat: 웹소켓 에러 핸들러 구현 #696

Open
wants to merge 4 commits into
base: feature/536
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ public abstract class OdyException extends RuntimeException {

private final HttpStatus httpStatus;

public OdyException(String message, HttpStatus httpStatus) {
protected OdyException(String message, HttpStatus httpStatus) {
super(message);
this.httpStatus = httpStatus;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.ody.common.exception;

public class OdyWebSocketException extends RuntimeException {

public OdyWebSocketException(String message) {
super(message);
}
}
14 changes: 13 additions & 1 deletion backend/src/main/java/com/ody/eta/config/WebSocketConfig.java
Original file line number Diff line number Diff line change
@@ -1,24 +1,36 @@
package com.ody.eta.config;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.ChannelRegistration;
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 WebSocketPreHandler webSocketPreHandler;
private final WebSocketErrorHandler webSocketErrorHandler;

@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/connect")
.setAllowedOrigins("*");
registry.setErrorHandler(webSocketErrorHandler);
}

@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic");
registry.enableSimpleBroker("/topic", "/queue");
registry.setApplicationDestinationPrefixes("/publish");
}

@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(webSocketPreHandler);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.ody.eta.config;

import com.ody.common.exception.OdyWebSocketException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.Message;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.web.socket.messaging.StompSubProtocolErrorHandler;

@Slf4j
@Configuration
public class WebSocketErrorHandler extends StompSubProtocolErrorHandler {

@Override
public Message<byte[]> handleClientMessageProcessingError(Message<byte[]> clientMessage, Throwable ex) {
Throwable cause = ex.getCause();
String message = cause.getMessage();

if (cause instanceof OdyWebSocketException) {
log.warn("message: {}", message);
return createErrorMessage(message);
}
return super.handleClientMessageProcessingError(clientMessage, ex);
}

private Message<byte[]> createErrorMessage(String exceptionMessage) {
StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.ERROR);
accessor.setMessage(exceptionMessage);
accessor.setLeaveMutable(true);

return MessageBuilder.createMessage(exceptionMessage.getBytes(), accessor.getMessageHeaders());
}
}
56 changes: 56 additions & 0 deletions backend/src/main/java/com/ody/eta/config/WebSocketPreHandler.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.ody.eta.config;

import com.ody.common.exception.OdyWebSocketException;
import com.ody.eta.domain.WebSocketEndpoint;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;

@Slf4j
@Configuration
public class WebSocketPreHandler implements ChannelInterceptor {

@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
StompCommand command = accessor.getCommand();
String destination = accessor.getDestination();

if (command == null) {
throw new OdyWebSocketException("stomp command는 null 일 수 없습니다.");
}
if (StompCommand.SUBSCRIBE.equals(command)) {
validateSubEndpoint(destination);
}
if (StompCommand.SEND.equals(command)) {
validateSendEndpoint(destination);
}
return message;
}

private void validateSubEndpoint(String destination) {
if (invalidSubEndpoint(destination)) {
throw new OdyWebSocketException(destination + "은 유효하지 않은 subscribe endpoint 입니다.");
}
}

private boolean invalidSubEndpoint(String destination) {
return destination == null || WebSocketEndpoint.getSubscribeEndpoints().stream()
.noneMatch(destination::startsWith);
}

private void validateSendEndpoint(String destination) {
if (invalidSendEndpoint(destination)) {
throw new OdyWebSocketException(destination + "은 유효하지 않은 send endpoint 입니다.");
}
}

private boolean invalidSendEndpoint(String destination) {
return destination == null || WebSocketEndpoint.getSendEndpoints().stream()
.noneMatch(destination::startsWith);
}
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
package com.ody.eta.controller;

import com.ody.common.exception.OdyException;
import com.ody.eta.annotation.WebSocketAuthMember;
import com.ody.eta.dto.request.MateEtaRequest;
import com.ody.eta.service.EtaSocketService;
import com.ody.meeting.dto.response.MateEtaResponsesV2;
import com.ody.member.domain.Member;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.messaging.handler.annotation.DestinationVariable;
import org.springframework.messaging.handler.annotation.MessageExceptionHandler;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.messaging.simp.annotation.SendToUser;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
Expand All @@ -36,4 +41,18 @@ public MateEtaResponsesV2 etaUpdate(
log.info("--- etaUpdate 호출 ! - {}, {}, {}", meetingId, member, etaRequest);
return etaSocketService.etaUpdate(meetingId, member, etaRequest);
}

@MessageExceptionHandler
@SendToUser("/queue/errors")
public ProblemDetail handleOdyException(OdyException exception) {
log.warn("exception: ", exception);
return ProblemDetail.forStatusAndDetail(exception.getHttpStatus(), exception.getMessage());
}

@MessageExceptionHandler
@SendToUser("/queue/errors")
public ProblemDetail handleException(Exception exception) {
log.error("exception: ", exception);
return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "서버 에러");
}
}
33 changes: 33 additions & 0 deletions backend/src/main/java/com/ody/eta/domain/WebSocketEndpoint.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.ody.eta.domain;

import java.util.Arrays;
import java.util.List;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum WebSocketEndpoint {

OPEN("/publish/open/"),
ETA_UPDATE("/publish/etas/"),
ETAS("/topic/etas/"),
LOCATION("/topic/coordinates/"),
DISCONNECT("/topic/disconnect/"),
ERROR("/user/queue/errors"),
;

private final String endpoint;

public static List<String> getSubscribeEndpoints() {
return Arrays.stream(values()).map(value -> value.endpoint)
.filter(endpoint -> endpoint.startsWith("/topic/") || endpoint.contains("/queue/"))
.toList();
}

public static List<String> getSendEndpoints() {
return Arrays.stream(values()).map(value -> value.endpoint)
.filter(endpoint -> endpoint.startsWith("/publish/"))
.toList();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,7 @@ public BaseStompTest() {
@BeforeEach
public void connect() throws ExecutionException, InterruptedException, TimeoutException {
this.stompSession = this.websocketClient
.connect(url + port + ENDPOINT, new StompSessionHandlerAdapter() {
})
.connect(url + port + ENDPOINT, new StompSessionHandlerAdapter() {})
.get(3, TimeUnit.SECONDS);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,7 @@ public Type getPayloadType(StompHeaders headers) {

@Override
public void handleFrame(StompHeaders headers, Object payload) {
if (completableFuture.complete((T) payload)) {
}
completableFuture.complete((T) payload);
}

public CompletableFuture<T> getCompletableFuture() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@

import com.ody.auth.service.AuthService;
import com.ody.common.Fixture;
import com.ody.common.exception.OdyNotFoundException;
import com.ody.common.websocket.BaseStompTest;
import com.ody.common.websocket.MessageFrameHandler;
import com.ody.eta.dto.request.MateEtaRequest;
import com.ody.eta.service.SocketMessageSender;
import com.ody.mate.service.MateService;
import com.ody.meeting.dto.response.MateEtaResponsesV2;
import com.ody.meeting.service.MeetingService;
Expand All @@ -25,13 +25,19 @@
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.stream.Stream;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.Mockito;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;

class EtaSocketControllerTest extends BaseStompTest {

Expand Down Expand Up @@ -147,6 +153,42 @@ void subscribe() throws ExecutionException, InterruptedException, TimeoutExcepti
assertThat(mateEtaResponsesV2.requesterMateId()).isEqualTo(response.requesterMateId());
}

@DisplayName("controller 진입 후 에러 발생 시 /user/queue/errors 로 에러 메시지를 받는다")
@ParameterizedTest
@MethodSource("provideExceptionAndExpectedProblemDetail")
void exceptionHandling(Throwable exception, ProblemDetail expectedErrorMessage)
throws ExecutionException, InterruptedException, TimeoutException {
MessageFrameHandler<ProblemDetail> handler = new MessageFrameHandler<>(ProblemDetail.class);

Mockito.doThrow(exception).when(mateService).findAllMateEtas(any(), any(), any());

stompSession.subscribe("/user/queue/errors", handler); // 에러 수신용 구독
Thread.sleep(3000);

Mockito.when(timeCache.get(anyLong()))
.thenReturn(LocalDateTime.now().plusMinutes(10L)) // meeting 시간 (10분 뒤)
.thenReturn(LocalDateTime.now()); //trigger 당긴지 0초 > 새로 예약 x

sendEtaRequest();

ProblemDetail actualErrorMessage = handler.getCompletableFuture().get(10, TimeUnit.SECONDS);

assertThat(actualErrorMessage).isEqualTo(expectedErrorMessage);
}

public static Stream<Arguments> provideExceptionAndExpectedProblemDetail() {
return Stream.of(
Arguments.of(
new OdyNotFoundException("not found"),
ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, "not found")
),
Arguments.of(
new RuntimeException(),
ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "서버 에러")
)
);
}

private void sendEtaRequest() throws InterruptedException {
MateEtaRequest request = new MateEtaRequest(false, "37.515298", "127.103113");
stompSession.send("/publish/etas/1", request);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.ody.eta.domain;

import static org.assertj.core.api.Assertions.assertThat;

import java.util.List;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

class WebSocketEndpointTest {

@DisplayName("subscribe endpoint 리스트를 반환한다.")
@Test
void getSubscribeEndpoints() {
List<String> actual = WebSocketEndpoint.getSubscribeEndpoints();
List<String> expected = List.of(
WebSocketEndpoint.ETAS.getEndpoint(),
WebSocketEndpoint.LOCATION.getEndpoint(),
WebSocketEndpoint.DISCONNECT.getEndpoint(),
WebSocketEndpoint.ERROR.getEndpoint()
);

assertThat(actual).containsExactlyInAnyOrderElementsOf(expected);
}

@DisplayName("send endpoint 리스트를 반환한다.")
@Test
void getSendEndpoint() {
List<String> actual = WebSocketEndpoint.getSendEndpoints();
List<String> expected = List.of(
WebSocketEndpoint.OPEN.getEndpoint(),
WebSocketEndpoint.ETA_UPDATE.getEndpoint()
);

assertThat(actual).containsExactlyInAnyOrderElementsOf(expected);
}
}