diff --git a/build.gradle b/build.gradle index f490a7e2..206f5904 100644 --- a/build.gradle +++ b/build.gradle @@ -31,6 +31,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jdbc' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-websocket' implementation 'org.springframework.boot:spring-boot-starter-validation' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' diff --git a/src/main/java/com/example/RealMatch/chat/presentation/config/ChatWebSocketConfig.java b/src/main/java/com/example/RealMatch/chat/presentation/config/ChatWebSocketConfig.java new file mode 100644 index 00000000..862343a2 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/config/ChatWebSocketConfig.java @@ -0,0 +1,41 @@ +package com.example.RealMatch.chat.presentation.config; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.lang.NonNull; +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; +import org.springframework.web.socket.server.HandshakeHandler; + +@Configuration +@EnableWebSocketMessageBroker +public class ChatWebSocketConfig implements WebSocketMessageBrokerConfigurer { + + @Value("${cors.allowed-origin}") + private String allowedOrigin; + private final ObjectProvider handshakeHandlerProvider; + + public ChatWebSocketConfig(ObjectProvider handshakeHandlerProvider) { + this.handshakeHandlerProvider = handshakeHandlerProvider; + } + + @Override + public void registerStompEndpoints(@NonNull StompEndpointRegistry registry) { + var registration = registry.addEndpoint("/ws/chat") + .setAllowedOrigins(allowedOrigin); + HandshakeHandler handshakeHandler = handshakeHandlerProvider.getIfAvailable(); + if (handshakeHandler != null) { + registration.setHandshakeHandler(handshakeHandler); + } + } + + @Override + public void configureMessageBroker(@NonNull MessageBrokerRegistry registry) { + registry.setApplicationDestinationPrefixes("/app"); + registry.setUserDestinationPrefix("/user"); + registry.enableSimpleBroker("/topic", "/queue"); + } +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/controller/ChatSocketController.java b/src/main/java/com/example/RealMatch/chat/presentation/controller/ChatSocketController.java new file mode 100644 index 00000000..370b78c6 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/controller/ChatSocketController.java @@ -0,0 +1,34 @@ +package com.example.RealMatch.chat.presentation.controller; + +import java.util.Objects; + +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.messaging.simp.annotation.SendToUser; +import org.springframework.stereotype.Controller; + +import com.example.RealMatch.chat.presentation.controller.fixture.ChatSocketFixtureFactory; +import com.example.RealMatch.chat.presentation.dto.websocket.ChatMessageCreatedEvent; +import com.example.RealMatch.chat.presentation.dto.websocket.ChatSendMessageAck; +import com.example.RealMatch.chat.presentation.dto.websocket.ChatSendMessageCommand; + +import jakarta.validation.Valid; + +@Controller +public class ChatSocketController { + + private final SimpMessagingTemplate messagingTemplate; + + public ChatSocketController(SimpMessagingTemplate messagingTemplate) { + this.messagingTemplate = messagingTemplate; + } + + @MessageMapping("/chat.send") + @SendToUser("/queue/chat.ack") + public ChatSendMessageAck sendMessage(@Valid @Payload ChatSendMessageCommand command) { + ChatMessageCreatedEvent event = ChatSocketFixtureFactory.sampleMessageCreatedEvent(command); + messagingTemplate.convertAndSend("/topic/rooms/" + command.roomId(), Objects.requireNonNull(event)); + return ChatSocketFixtureFactory.sampleAck(command, event.message().messageId()); + } +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/controller/fixture/ChatSocketFixtureFactory.java b/src/main/java/com/example/RealMatch/chat/presentation/controller/fixture/ChatSocketFixtureFactory.java new file mode 100644 index 00000000..d83c753c --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/controller/fixture/ChatSocketFixtureFactory.java @@ -0,0 +1,66 @@ +package com.example.RealMatch.chat.presentation.controller.fixture; + +import java.time.LocalDateTime; + +import com.example.RealMatch.chat.presentation.dto.enums.ChatAttachmentStatus; +import com.example.RealMatch.chat.presentation.dto.enums.ChatAttachmentType; +import com.example.RealMatch.chat.presentation.dto.enums.ChatMessageType; +import com.example.RealMatch.chat.presentation.dto.enums.ChatSendMessageAckStatus; +import com.example.RealMatch.chat.presentation.dto.enums.ChatSenderType; +import com.example.RealMatch.chat.presentation.dto.response.ChatAttachmentInfoResponse; +import com.example.RealMatch.chat.presentation.dto.response.ChatMessageResponse; +import com.example.RealMatch.chat.presentation.dto.websocket.ChatMessageCreatedEvent; +import com.example.RealMatch.chat.presentation.dto.websocket.ChatSendMessageAck; +import com.example.RealMatch.chat.presentation.dto.websocket.ChatSendMessageCommand; + +public final class ChatSocketFixtureFactory { + + private ChatSocketFixtureFactory() { + } + + public static ChatMessageCreatedEvent sampleMessageCreatedEvent(ChatSendMessageCommand command) { + ChatAttachmentInfoResponse attachment = sampleAttachment(command); + String content = command.messageType() == ChatMessageType.TEXT ? command.content() : null; + + ChatMessageResponse message = new ChatMessageResponse( + 8001L, + command.roomId(), + 202L, + ChatSenderType.USER, + command.messageType(), + content, + attachment, + null, + LocalDateTime.of(2025, 1, 1, 10, 2), + command.clientMessageId() + ); + return new ChatMessageCreatedEvent(command.roomId(), message); + } + + public static ChatSendMessageAck sampleAck(ChatSendMessageCommand command, Long messageId) { + return new ChatSendMessageAck( + command.clientMessageId(), + messageId, + ChatSendMessageAckStatus.SUCCESS + ); + } + + private static ChatAttachmentInfoResponse sampleAttachment(ChatSendMessageCommand command) { + ChatMessageType messageType = command.messageType(); + if (messageType != ChatMessageType.IMAGE && messageType != ChatMessageType.FILE) { + return null; + } + ChatAttachmentType attachmentType = messageType == ChatMessageType.IMAGE + ? ChatAttachmentType.IMAGE + : ChatAttachmentType.FILE; + return new ChatAttachmentInfoResponse( + command.attachmentId(), + attachmentType, + messageType == ChatMessageType.IMAGE ? "image/png" : "application/octet-stream", + messageType == ChatMessageType.IMAGE ? "photo.png" : "file.bin", + 204800L, + "https://cdn.example.com/attachments/8001", + ChatAttachmentStatus.READY + ); + } +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatSendMessageAckStatus.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatSendMessageAckStatus.java new file mode 100644 index 00000000..4c6f9dd1 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/enums/ChatSendMessageAckStatus.java @@ -0,0 +1,6 @@ +package com.example.RealMatch.chat.presentation.dto.enums; + +public enum ChatSendMessageAckStatus { + SUCCESS, + FAILED +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/websocket/ChatMessageCreatedEvent.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/websocket/ChatMessageCreatedEvent.java new file mode 100644 index 00000000..c0a823b0 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/websocket/ChatMessageCreatedEvent.java @@ -0,0 +1,9 @@ +package com.example.RealMatch.chat.presentation.dto.websocket; + +import com.example.RealMatch.chat.presentation.dto.response.ChatMessageResponse; + +public record ChatMessageCreatedEvent( + Long roomId, + ChatMessageResponse message +) { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/websocket/ChatSendMessageAck.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/websocket/ChatSendMessageAck.java new file mode 100644 index 00000000..ac10f862 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/websocket/ChatSendMessageAck.java @@ -0,0 +1,10 @@ +package com.example.RealMatch.chat.presentation.dto.websocket; + +import com.example.RealMatch.chat.presentation.dto.enums.ChatSendMessageAckStatus; + +public record ChatSendMessageAck( + String clientMessageId, + Long messageId, + ChatSendMessageAckStatus status +) { +} diff --git a/src/main/java/com/example/RealMatch/chat/presentation/dto/websocket/ChatSendMessageCommand.java b/src/main/java/com/example/RealMatch/chat/presentation/dto/websocket/ChatSendMessageCommand.java new file mode 100644 index 00000000..6f757a82 --- /dev/null +++ b/src/main/java/com/example/RealMatch/chat/presentation/dto/websocket/ChatSendMessageCommand.java @@ -0,0 +1,14 @@ +package com.example.RealMatch.chat.presentation.dto.websocket; + +import com.example.RealMatch.chat.presentation.dto.enums.ChatMessageType; + +import jakarta.validation.constraints.NotNull; + +public record ChatSendMessageCommand( + @NotNull Long roomId, + @NotNull ChatMessageType messageType, + String content, + Long attachmentId, + @NotNull String clientMessageId +) { +} diff --git a/src/main/java/com/example/RealMatch/global/config/SecurityConfig.java b/src/main/java/com/example/RealMatch/global/config/SecurityConfig.java index 09c4535b..7aeff704 100644 --- a/src/main/java/com/example/RealMatch/global/config/SecurityConfig.java +++ b/src/main/java/com/example/RealMatch/global/config/SecurityConfig.java @@ -28,6 +28,7 @@ public class SecurityConfig { private static final String[] PERMIT_ALL_URL_ARRAY = { "/api/test", "/api/chat/**", + "/ws/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-resources/**", "/swagger-ui.html" }; @@ -64,7 +65,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, CustomAuthEntr public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOrigins(List.of(allowedOrigin, "http://localhost:8080", swaggerUrl)); + configuration.setAllowedOrigins(List.of(allowedOrigin, swaggerUrl)); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(List.of("*")); configuration.setAllowCredentials(true); // 쿠키/인증정보 포함 요청 diff --git a/src/test/java/com/example/RealMatch/chat/presentation/controller/ChatSocketControllerTest.java b/src/test/java/com/example/RealMatch/chat/presentation/controller/ChatSocketControllerTest.java new file mode 100644 index 00000000..9f4d944d --- /dev/null +++ b/src/test/java/com/example/RealMatch/chat/presentation/controller/ChatSocketControllerTest.java @@ -0,0 +1,225 @@ +package com.example.RealMatch.chat.presentation.controller; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Type; +import java.security.Principal; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Bean; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import org.springframework.messaging.converter.MappingJackson2MessageConverter; +import org.springframework.messaging.simp.stomp.StompFrameHandler; +import org.springframework.messaging.simp.stomp.StompHeaders; +import org.springframework.messaging.simp.stomp.StompSession; +import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.client.standard.StandardWebSocketClient; +import org.springframework.web.socket.messaging.WebSocketStompClient; +import org.springframework.web.socket.server.HandshakeHandler; +import org.springframework.web.socket.server.support.DefaultHandshakeHandler; + +import com.example.RealMatch.chat.presentation.dto.enums.ChatMessageType; +import com.example.RealMatch.chat.presentation.dto.enums.ChatSendMessageAckStatus; +import com.example.RealMatch.chat.presentation.dto.websocket.ChatMessageCreatedEvent; +import com.example.RealMatch.chat.presentation.dto.websocket.ChatSendMessageAck; +import com.example.RealMatch.chat.presentation.dto.websocket.ChatSendMessageCommand; +import com.fasterxml.jackson.databind.ObjectMapper; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class ChatSocketControllerTest { + private static final String WS_ENDPOINT = "/ws/chat"; + private static final String APP_SEND_DESTINATION = "/app/chat.send"; + private static final String ROOM_TOPIC_PREFIX = "/topic/rooms/"; + private static final String ACK_QUEUE = "/user/queue/chat.ack"; + private static final long ROOM_ID = 3001L; + private static final String CLIENT_MESSAGE_ID = "11111111-1111-1111-1111-111111111111"; + private static final String IMAGE_CLIENT_MESSAGE_ID = "22222222-2222-2222-2222-222222222222"; + private static final String FILE_CLIENT_MESSAGE_ID = "33333333-3333-3333-3333-333333333333"; + private static final long ATTACHMENT_ID = 9001L; + private static final int TIMEOUT_SECONDS = 3; + private static final long SUBSCRIBE_WAIT_MILLIS = 200L; + + @LocalServerPort + private int port; + + private WebSocketStompClient stompClient; + + @BeforeEach + void setUp() { + stompClient = new WebSocketStompClient(new StandardWebSocketClient()); + MappingJackson2MessageConverter converter = new MappingJackson2MessageConverter(); + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.findAndRegisterModules(); + converter.setObjectMapper(objectMapper); + stompClient.setMessageConverter(converter); + } + + @Test + @DisplayName("채팅 메시지 전송: ACK 수신") + void sendMessage_returnsAck() throws Exception { + StompSession session = connect(); + CompletableFuture ackFuture = new CompletableFuture<>(); + + session.subscribe(ACK_QUEUE, new StompFrameHandler() { + @Override + public @NonNull Type getPayloadType(@NonNull StompHeaders headers) { + return ChatSendMessageAck.class; + } + + @Override + public void handleFrame(@NonNull StompHeaders headers, @Nullable Object payload) { + ackFuture.complete((ChatSendMessageAck) payload); + } + }); + waitForSubscriptions(); + + ChatSendMessageCommand command = createCommand( + ChatMessageType.TEXT, + "안녕하세요!", + null, + CLIENT_MESSAGE_ID + ); + session.send(APP_SEND_DESTINATION, Objects.requireNonNull(command)); + + ChatSendMessageAck ack = ackFuture.get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertThat(ack.status()).isEqualTo(ChatSendMessageAckStatus.SUCCESS); + assertThat(ack.clientMessageId()).isEqualTo(command.clientMessageId()); + } + + @Test + @DisplayName("채팅 메시지 전송: 브로드캐스트 수신") + void sendMessage_broadcastsMessage() throws Exception { + StompSession session = connect(); + CompletableFuture messageFuture = new CompletableFuture<>(); + + session.subscribe(ROOM_TOPIC_PREFIX + ROOM_ID, new StompFrameHandler() { + @Override + public @NonNull Type getPayloadType(@NonNull StompHeaders headers) { + return ChatMessageCreatedEvent.class; + } + + @Override + public void handleFrame(@NonNull StompHeaders headers, @Nullable Object payload) { + messageFuture.complete((ChatMessageCreatedEvent) payload); + } + }); + waitForSubscriptions(); + + ChatSendMessageCommand command = createCommand(ChatMessageType.TEXT, "안녕하세요!", null, CLIENT_MESSAGE_ID); + session.send(APP_SEND_DESTINATION, Objects.requireNonNull(command)); + + ChatMessageCreatedEvent event = messageFuture.get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertThat(event.roomId()).isEqualTo(ROOM_ID); + assertThat(event.message().messageType()).isEqualTo(ChatMessageType.TEXT); + } + + @Test + @DisplayName("이미지 메시지 전송: 브로드캐스트 수신") + void sendImageMessage_broadcastsMessage() throws Exception { + StompSession session = connect(); + CompletableFuture messageFuture = new CompletableFuture<>(); + + session.subscribe(ROOM_TOPIC_PREFIX + ROOM_ID, new StompFrameHandler() { + @Override + public @NonNull Type getPayloadType(@NonNull StompHeaders headers) { + return ChatMessageCreatedEvent.class; + } + + @Override + public void handleFrame(@NonNull StompHeaders headers, @Nullable Object payload) { + messageFuture.complete((ChatMessageCreatedEvent) payload); + } + }); + waitForSubscriptions(); + + ChatSendMessageCommand command = createCommand(ChatMessageType.IMAGE, null, ATTACHMENT_ID, IMAGE_CLIENT_MESSAGE_ID); + session.send(APP_SEND_DESTINATION, Objects.requireNonNull(command)); + + ChatMessageCreatedEvent event = messageFuture.get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertThat(event.message().messageType()).isEqualTo(ChatMessageType.IMAGE); + assertThat(event.message().attachment()).isNotNull(); + } + + @Test + @DisplayName("파일 메시지 전송: 브로드캐스트 수신") + void sendFileMessage_broadcastsMessage() throws Exception { + StompSession session = connect(); + CompletableFuture messageFuture = new CompletableFuture<>(); + + session.subscribe(ROOM_TOPIC_PREFIX + ROOM_ID, new StompFrameHandler() { + @Override + public @NonNull Type getPayloadType(@NonNull StompHeaders headers) { + return ChatMessageCreatedEvent.class; + } + + @Override + public void handleFrame(@NonNull StompHeaders headers, @Nullable Object payload) { + messageFuture.complete((ChatMessageCreatedEvent) payload); + } + }); + waitForSubscriptions(); + + ChatSendMessageCommand command = createCommand(ChatMessageType.FILE, null, ATTACHMENT_ID, FILE_CLIENT_MESSAGE_ID); + session.send(APP_SEND_DESTINATION, Objects.requireNonNull(command)); + + ChatMessageCreatedEvent event = messageFuture.get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertThat(event.message().messageType()).isEqualTo(ChatMessageType.FILE); + assertThat(event.message().attachment()).isNotNull(); + } + + private StompSession connect() throws Exception { + return stompClient + .connectAsync("ws://localhost:" + port + WS_ENDPOINT, new StompSessionHandlerAdapter() { + }) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + + private @NonNull ChatSendMessageCommand createCommand( + ChatMessageType messageType, + String content, + Long attachmentId, + String clientMessageId + ) { + return new ChatSendMessageCommand( + ROOM_ID, + messageType, + content, + attachmentId, + clientMessageId + ); + } + + private void waitForSubscriptions() throws InterruptedException { + TimeUnit.MILLISECONDS.sleep(SUBSCRIBE_WAIT_MILLIS); + } + + @TestConfiguration + static class WebSocketTestConfig { + + @Bean + HandshakeHandler handshakeHandler() { + return new DefaultHandshakeHandler() { + @Override + protected Principal determineUser( + @NonNull ServerHttpRequest request, + @NonNull WebSocketHandler wsHandler, + @NonNull Map attributes + ) { + return () -> "test-user"; + } + }; + } + } +}