diff --git a/backend/ddang/src/main/java/com/ddang/ddang/chat/domain/WebSocketSessions.java b/backend/ddang/src/main/java/com/ddang/ddang/chat/domain/WebSocketSessions.java index 7619377a5..283e02419 100644 --- a/backend/ddang/src/main/java/com/ddang/ddang/chat/domain/WebSocketSessions.java +++ b/backend/ddang/src/main/java/com/ddang/ddang/chat/domain/WebSocketSessions.java @@ -7,11 +7,12 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import static com.ddang.ddang.websocket.handler.dto.WebSocketAttributeKey.USER_ID; + @Getter public class WebSocketSessions { protected static final String CHAT_ROOM_ID_KEY = "chatRoomId"; - private static final String USER_ID_KEY = "userId"; private final Set sessions = Collections.newSetFromMap(new ConcurrentHashMap<>()); @@ -24,7 +25,7 @@ public void putIfAbsent(final WebSocketSession session, final Long chatRoomId) { public boolean contains(final Long userId) { return sessions.stream() - .anyMatch(session -> session.getAttributes().get(USER_ID_KEY) == userId); + .anyMatch(session -> session.getAttributes().get(USER_ID.getName()) == userId); } public void remove(final WebSocketSession session) { diff --git a/backend/ddang/src/main/java/com/ddang/ddang/chat/handler/ChatWebSocketHandleTextMessageProvider.java b/backend/ddang/src/main/java/com/ddang/ddang/chat/handler/ChatWebSocketHandleTextMessageProvider.java index d9f69098d..d28c1e6af 100644 --- a/backend/ddang/src/main/java/com/ddang/ddang/chat/handler/ChatWebSocketHandleTextMessageProvider.java +++ b/backend/ddang/src/main/java/com/ddang/ddang/chat/handler/ChatWebSocketHandleTextMessageProvider.java @@ -16,16 +16,25 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import org.springframework.web.socket.PingMessage; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; +import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; +import static com.ddang.ddang.websocket.handler.dto.WebSocketAttributeKey.CONNECTED; +import static com.ddang.ddang.websocket.handler.dto.WebSocketAttributeKey.USER_ID; + +@Slf4j @Component @RequiredArgsConstructor public class ChatWebSocketHandleTextMessageProvider implements WebSocketHandleTextMessageProvider { @@ -115,7 +124,7 @@ private TextMessage createTextMessage( } private boolean isMyMessage(final WebSocketSession session, final Long writerId) { - final long userId = Long.parseLong(String.valueOf(session.getAttributes().get("userId"))); + final long userId = Long.parseLong(String.valueOf(session.getAttributes().get(USER_ID.getName()))); return writerId.equals(userId); } @@ -136,6 +145,39 @@ private void updateReadMessageLog( @Override public void remove(final WebSocketSession session) { + log.info("{} 연결 종료", session); sessions.remove(session); } + + @Scheduled(fixedDelay = 60000) + public void sendPingSessions() { + final Set webSocketSessions = getWebSocketSessions(); + + webSocketSessions.parallelStream() + .forEach(this::sendPingMessage); + } + + private Set getWebSocketSessions() { + return sessions.getChatRoomSessions() + .values() + .stream() + .flatMap(webSocketSessions -> webSocketSessions.getSessions().stream()) + .collect(Collectors.toSet()); + } + + private void sendPingMessage(final WebSocketSession session) { + final Map attributes = session.getAttributes(); + final boolean connected = (boolean) attributes.get(CONNECTED.getName()); + if (!connected) { + sessions.remove(session); + return; + } + + attributes.put(CONNECTED.getName(), false); + try { + session.sendMessage(new PingMessage()); + } catch (IOException e) { + log.error("ping 보내기 실패 : {} ", session); + } + } } diff --git a/backend/ddang/src/main/java/com/ddang/ddang/websocket/configuration/WebSocketInterceptor.java b/backend/ddang/src/main/java/com/ddang/ddang/websocket/configuration/WebSocketInterceptor.java index 59326ce22..11a9dc1d0 100644 --- a/backend/ddang/src/main/java/com/ddang/ddang/websocket/configuration/WebSocketInterceptor.java +++ b/backend/ddang/src/main/java/com/ddang/ddang/websocket/configuration/WebSocketInterceptor.java @@ -14,6 +14,10 @@ import java.util.Map; +import static com.ddang.ddang.websocket.handler.dto.WebSocketAttributeKey.BASE_URL; +import static com.ddang.ddang.websocket.handler.dto.WebSocketAttributeKey.CONNECTED; +import static com.ddang.ddang.websocket.handler.dto.WebSocketAttributeKey.USER_ID; + @Component @RequiredArgsConstructor public class WebSocketInterceptor extends HttpSessionHandshakeInterceptor { @@ -27,8 +31,9 @@ public boolean beforeHandshake( final WebSocketHandler wsHandler, final Map attributes ) throws Exception { - attributes.put("userId", findUserId(request)); - attributes.put("baseUrl", ImageRelativeUrl.USER.calculateAbsoluteUrl()); + attributes.put(USER_ID.getName(), findUserId(request)); + attributes.put(BASE_URL.getName(), ImageRelativeUrl.USER.calculateAbsoluteUrl()); + attributes.put(CONNECTED.getName(), true); return super.beforeHandshake(request, response, wsHandler, attributes); } diff --git a/backend/ddang/src/main/java/com/ddang/ddang/websocket/handler/WebSocketHandler.java b/backend/ddang/src/main/java/com/ddang/ddang/websocket/handler/WebSocketHandler.java index c72936775..efa1b6ee0 100644 --- a/backend/ddang/src/main/java/com/ddang/ddang/websocket/handler/WebSocketHandler.java +++ b/backend/ddang/src/main/java/com/ddang/ddang/websocket/handler/WebSocketHandler.java @@ -7,18 +7,21 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.PongMessage; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.handler.TextWebSocketHandler; import java.util.List; +import java.util.Map; + +import static com.ddang.ddang.websocket.handler.dto.WebSocketAttributeKey.CONNECTED; +import static com.ddang.ddang.websocket.handler.dto.WebSocketAttributeKey.TYPE; @Component @RequiredArgsConstructor public class WebSocketHandler extends TextWebSocketHandler { - private static final String TYPE_KEY = "type"; - private final WebSocketHandleTextMessageProviderComposite providerComposite; private final ObjectMapper objectMapper; @@ -26,7 +29,7 @@ public class WebSocketHandler extends TextWebSocketHandler { protected void handleTextMessage(final WebSocketSession session, final TextMessage message) throws Exception { final String payload = message.getPayload(); final TextMessageDto textMessageDto = objectMapper.readValue(payload, TextMessageDto.class); - session.getAttributes().put(TYPE_KEY, textMessageDto.type()); + session.getAttributes().put(TYPE.getName(), textMessageDto.type()); final WebSocketHandleTextMessageProvider provider = providerComposite.findProvider(textMessageDto.type()); final List sendMessageDtos = provider.handleCreateSendMessage(session, textMessageDto.data()); @@ -38,9 +41,15 @@ protected void handleTextMessage(final WebSocketSession session, final TextMessa @Override public void afterConnectionClosed(final WebSocketSession session, final CloseStatus status) { - final String type = String.valueOf(session.getAttributes().get(TYPE_KEY)); + final String type = String.valueOf(session.getAttributes().get(TYPE.getName())); final TextMessageType textMessageType = TextMessageType.valueOf(type); final WebSocketHandleTextMessageProvider provider = providerComposite.findProvider(textMessageType); provider.remove(session); } + + @Override + public void handlePongMessage(WebSocketSession session, PongMessage message) { + final Map attributes = session.getAttributes(); + attributes.put(CONNECTED.getName(), true); + } } diff --git a/backend/ddang/src/main/java/com/ddang/ddang/websocket/handler/dto/WebSocketAttributeKey.java b/backend/ddang/src/main/java/com/ddang/ddang/websocket/handler/dto/WebSocketAttributeKey.java new file mode 100644 index 000000000..dfb19a023 --- /dev/null +++ b/backend/ddang/src/main/java/com/ddang/ddang/websocket/handler/dto/WebSocketAttributeKey.java @@ -0,0 +1,18 @@ +package com.ddang.ddang.websocket.handler.dto; + +import lombok.Getter; + +@Getter +public enum WebSocketAttributeKey { + + USER_ID("userId"), + BASE_URL("baseUrl"), + CONNECTED("connected"), + TYPE("type"); + + private final String name; + + WebSocketAttributeKey(final String name) { + this.name = name; + } +} diff --git a/backend/ddang/src/test/java/com/ddang/ddang/chat/domain/fixture/WebSocketSessionsTestFixture.java b/backend/ddang/src/test/java/com/ddang/ddang/chat/domain/fixture/WebSocketSessionsTestFixture.java index 1e96befe9..49137563c 100644 --- a/backend/ddang/src/test/java/com/ddang/ddang/chat/domain/fixture/WebSocketSessionsTestFixture.java +++ b/backend/ddang/src/test/java/com/ddang/ddang/chat/domain/fixture/WebSocketSessionsTestFixture.java @@ -3,10 +3,16 @@ import java.util.HashMap; import java.util.Map; +import static com.ddang.ddang.websocket.handler.dto.WebSocketAttributeKey.BASE_URL; +import static com.ddang.ddang.websocket.handler.dto.WebSocketAttributeKey.CONNECTED; +import static com.ddang.ddang.websocket.handler.dto.WebSocketAttributeKey.USER_ID; + @SuppressWarnings("NonAsciiCharacters") public class WebSocketSessionsTestFixture { protected Long 사용자_아이디 = 1L; - protected Map 세션_attribute_정보 = new HashMap<>(Map.of("userId", 사용자_아이디, "baseUrl", "/images")); + protected Map 세션_attribute_정보 = new HashMap<>( + Map.of(USER_ID.getName(), 사용자_아이디, BASE_URL.getName(), "/images", CONNECTED.getName(), true) + ); protected Long 채팅방_아이디 = 1L; } diff --git a/backend/ddang/src/test/java/com/ddang/ddang/chat/handler/ChatWebSocketHandleTextMessageProviderTest.java b/backend/ddang/src/test/java/com/ddang/ddang/chat/handler/ChatWebSocketHandleTextMessageProviderTest.java index dd7c99cd9..a45d399cc 100644 --- a/backend/ddang/src/test/java/com/ddang/ddang/chat/handler/ChatWebSocketHandleTextMessageProviderTest.java +++ b/backend/ddang/src/test/java/com/ddang/ddang/chat/handler/ChatWebSocketHandleTextMessageProviderTest.java @@ -3,6 +3,7 @@ import com.ddang.ddang.chat.application.event.MessageNotificationEvent; import com.ddang.ddang.chat.application.event.UpdateReadMessageLogEvent; import com.ddang.ddang.chat.domain.WebSocketChatSessions; +import com.ddang.ddang.chat.domain.WebSocketSessions; import com.ddang.ddang.chat.domain.repository.ReadMessageLogRepository; import com.ddang.ddang.chat.handler.fixture.ChatWebSocketHandleTextMessageProviderTestFixture; import com.ddang.ddang.configuration.IsolateDatabase; @@ -22,9 +23,12 @@ import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.test.context.event.ApplicationEvents; import org.springframework.test.context.event.RecordApplicationEvents; +import org.springframework.web.socket.PingMessage; import org.springframework.web.socket.WebSocketSession; +import java.io.IOException; import java.util.List; +import java.util.Map; import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; @@ -32,6 +36,9 @@ import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.willDoNothing; import static org.mockito.BDDMockito.willReturn; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; @IsolateDatabase @RecordApplicationEvents @@ -48,6 +55,9 @@ class ChatWebSocketHandleTextMessageProviderTest extends ChatWebSocketHandleText @SpyBean WebSocketChatSessions sessions; + @SpyBean + WebSocketSessions webSocketSessions; + @Mock WebSocketSession writerSession; @@ -186,4 +196,35 @@ class ChatWebSocketHandleTextMessageProviderTest extends ChatWebSocketHandleText final boolean actual = sessions.containsByUserId(채팅방.getId(), 발신자.getId()); assertThat(actual).isFalse(); } + + @Test + void 저장되어_있는_세션에_ping_메시지를_보낸다() throws IOException { + // given + given(writerSession.getAttributes()).willReturn(발신자_세션_attribute_정보); + given(webSocketSessions.getSessions()).willReturn(Set.of(writerSession)); + given(sessions.getChatRoomSessions()).willReturn(Map.of(채팅방.getId(), webSocketSessions)); + + // when + provider.sendPingSessions(); + + // then + verify(sessions, never()).remove(writerSession); + verify(writerSession, times(1)).sendMessage(new PingMessage()); + } + + @Test + void 연결이_끊긴_세션은_삭제한다() throws IOException { + // given + given(writerSession.getAttributes()).willReturn(연결이_끊긴_세션_attribute_정보); + given(webSocketSessions.getSessions()).willReturn(Set.of(writerSession)); + given(sessions.getChatRoomSessions()).willReturn(Map.of(채팅방.getId(), webSocketSessions)); + willDoNothing().given(sessions).remove(writerSession); + + // when + provider.sendPingSessions(); + + // then + verify(sessions, times(1)).remove(writerSession); + verify(writerSession, never()).sendMessage(new PingMessage()); + } } diff --git a/backend/ddang/src/test/java/com/ddang/ddang/chat/handler/fixture/ChatWebSocketHandleTextMessageProviderTestFixture.java b/backend/ddang/src/test/java/com/ddang/ddang/chat/handler/fixture/ChatWebSocketHandleTextMessageProviderTestFixture.java index 3d9d288ac..54a71dd97 100644 --- a/backend/ddang/src/test/java/com/ddang/ddang/chat/handler/fixture/ChatWebSocketHandleTextMessageProviderTestFixture.java +++ b/backend/ddang/src/test/java/com/ddang/ddang/chat/handler/fixture/ChatWebSocketHandleTextMessageProviderTestFixture.java @@ -10,7 +10,6 @@ import com.ddang.ddang.chat.application.event.CreateReadMessageLogEvent; import com.ddang.ddang.chat.domain.ChatRoom; import com.ddang.ddang.chat.domain.repository.ChatRoomRepository; -import com.ddang.ddang.chat.domain.repository.ReadMessageLogRepository; import com.ddang.ddang.image.domain.ProfileImage; import com.ddang.ddang.user.domain.Reliability; import com.ddang.ddang.user.domain.User; @@ -22,6 +21,10 @@ import java.util.HashMap; import java.util.Map; +import static com.ddang.ddang.websocket.handler.dto.WebSocketAttributeKey.BASE_URL; +import static com.ddang.ddang.websocket.handler.dto.WebSocketAttributeKey.CONNECTED; +import static com.ddang.ddang.websocket.handler.dto.WebSocketAttributeKey.USER_ID; + @SuppressWarnings("NonAsciiCharacters") public class ChatWebSocketHandleTextMessageProviderTestFixture { @@ -46,6 +49,7 @@ public class ChatWebSocketHandleTextMessageProviderTestFixture { protected Map 발신자_세션_attribute_정보; protected Map 수신자_세션_attribute_정보; + protected Map 연결이_끊긴_세션_attribute_정보; protected Map 메시지_전송_데이터; protected CreateReadMessageLogEvent 메시지_로그_생성_이벤트; @@ -86,8 +90,21 @@ void setUpFixture() { chatRoomRepository.save(채팅방); - 발신자_세션_attribute_정보 = new HashMap<>(Map.of("userId", 발신자.getId(), "baseUrl", "/images")); - 수신자_세션_attribute_정보 = new HashMap<>(Map.of("userId", 수신자.getId(), "baseUrl", "/images")); + 발신자_세션_attribute_정보 = new HashMap<>(Map.of( + USER_ID.getName(), 발신자.getId(), + BASE_URL.getName(), "/images", + CONNECTED.getName(), true + )); + 수신자_세션_attribute_정보 = new HashMap<>(Map.of( + USER_ID.getName(), 수신자.getId(), + BASE_URL.getName(), "/images", + CONNECTED.getName(), true + )); + 연결이_끊긴_세션_attribute_정보 = new HashMap<>(Map.of( + USER_ID.getName(), 수신자.getId(), + BASE_URL.getName(), "/images", + CONNECTED.getName(), false + )); 메시지_전송_데이터 = Map.of( "chatRoomId", String.valueOf(채팅방.getId()), "receiverId", String.valueOf(수신자.getId()), diff --git a/backend/ddang/src/test/java/com/ddang/ddang/notification/application/fixture/NotificationEventListenerFixture.java b/backend/ddang/src/test/java/com/ddang/ddang/notification/application/fixture/NotificationEventListenerFixture.java index 847fbcb40..bd8fd01fe 100644 --- a/backend/ddang/src/test/java/com/ddang/ddang/notification/application/fixture/NotificationEventListenerFixture.java +++ b/backend/ddang/src/test/java/com/ddang/ddang/notification/application/fixture/NotificationEventListenerFixture.java @@ -34,6 +34,10 @@ import java.util.List; import java.util.Map; +import static com.ddang.ddang.websocket.handler.dto.WebSocketAttributeKey.BASE_URL; +import static com.ddang.ddang.websocket.handler.dto.WebSocketAttributeKey.CONNECTED; +import static com.ddang.ddang.websocket.handler.dto.WebSocketAttributeKey.USER_ID; + @SuppressWarnings("NonAsciiCharacters") public class NotificationEventListenerFixture { @@ -117,8 +121,9 @@ void setUpFixture() { bidRepository.save(bid); 세션_attribute_정보 = new HashMap<>(Map.of( - "userId", 발신자_겸_판매자.getId(), - "baseUrl", 이미지_절대_경로 + USER_ID.getName(), 발신자_겸_판매자.getId(), + BASE_URL.getName(), 이미지_절대_경로, + CONNECTED.getName(), true )); 메시지_전송_데이터 = Map.of( "chatRoomId", String.valueOf(채팅방.getId()), diff --git a/backend/ddang/src/test/java/com/ddang/ddang/websocket/handler/WebSocketHandlerTest.java b/backend/ddang/src/test/java/com/ddang/ddang/websocket/handler/WebSocketHandlerTest.java index 40b7cca3e..eb27fb26a 100644 --- a/backend/ddang/src/test/java/com/ddang/ddang/websocket/handler/WebSocketHandlerTest.java +++ b/backend/ddang/src/test/java/com/ddang/ddang/websocket/handler/WebSocketHandlerTest.java @@ -12,11 +12,14 @@ import org.mockito.Mock; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.web.socket.PongMessage; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; import java.util.List; +import static com.ddang.ddang.websocket.handler.dto.WebSocketAttributeKey.CONNECTED; +import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.BDDMockito.given; @@ -69,4 +72,16 @@ class WebSocketHandlerTest extends WebSocketHandlerTestFixture { // then verify(provider, times(1)).remove(any(WebSocketSession.class)); } + + @Test + void pong_메시지_수신시_연결_상태를_참으로_변환한다() { + // given + given(session.getAttributes()).willReturn(세션_attribute_정보); + + // when + webSocketHandler.handlePongMessage(session, new PongMessage()); + + // then + assertThat((boolean) 세션_attribute_정보.get(CONNECTED.getName())).isTrue(); + } } diff --git a/backend/ddang/src/test/java/com/ddang/ddang/websocket/handler/fixture/WebSocketHandlerTestFixture.java b/backend/ddang/src/test/java/com/ddang/ddang/websocket/handler/fixture/WebSocketHandlerTestFixture.java index 59c7d7b3d..168896f70 100644 --- a/backend/ddang/src/test/java/com/ddang/ddang/websocket/handler/fixture/WebSocketHandlerTestFixture.java +++ b/backend/ddang/src/test/java/com/ddang/ddang/websocket/handler/fixture/WebSocketHandlerTestFixture.java @@ -6,6 +6,10 @@ import java.util.HashMap; import java.util.Map; +import static com.ddang.ddang.websocket.handler.dto.WebSocketAttributeKey.BASE_URL; +import static com.ddang.ddang.websocket.handler.dto.WebSocketAttributeKey.CONNECTED; +import static com.ddang.ddang.websocket.handler.dto.WebSocketAttributeKey.USER_ID; + @SuppressWarnings("NonAsciiCharacters") public class WebSocketHandlerTestFixture { @@ -13,7 +17,7 @@ public class WebSocketHandlerTestFixture { protected Map 세션_attribute_정보 = new HashMap<>( Map.of( "type", TextMessageType.CHATTINGS.name(), - "data", Map.of("userId", 사용자_아이디, "baseUrl", "/images") + "data", Map.of(USER_ID.getName(), 사용자_아이디, BASE_URL.getName(), "/images", CONNECTED.getName(), false) ) ); protected TextMessage 전송할_메시지 = new TextMessage("메시지");