diff --git a/backend/build.gradle b/backend/build.gradle index 987827899..0c8e32a20 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -59,6 +59,9 @@ dependencies { implementation 'io.jsonwebtoken:jjwt:0.12.6' implementation 'io.jsonwebtoken:jjwt-impl:0.12.6' + // Web Socket + implementation 'org.springframework.boot:spring-boot-starter-websocket' + // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/backend/src/main/java/site/coduo/pairroom/exception/NotFoundPairRoomSessionException.java b/backend/src/main/java/site/coduo/pairroom/exception/NotFoundPairRoomSessionException.java new file mode 100644 index 000000000..30895d563 --- /dev/null +++ b/backend/src/main/java/site/coduo/pairroom/exception/NotFoundPairRoomSessionException.java @@ -0,0 +1,8 @@ +package site.coduo.pairroom.exception; + +public class NotFoundPairRoomSessionException extends PairRoomException { + + public NotFoundPairRoomSessionException(final String message) { + super(message); + } +} diff --git a/backend/src/main/java/site/coduo/sync/service/SchedulerService.java b/backend/src/main/java/site/coduo/sync/service/SchedulerService.java index 8cc4795f9..3d810be67 100644 --- a/backend/src/main/java/site/coduo/sync/service/SchedulerService.java +++ b/backend/src/main/java/site/coduo/sync/service/SchedulerService.java @@ -14,6 +14,8 @@ import site.coduo.timer.domain.Timer; import site.coduo.timer.repository.TimerRepository; import site.coduo.timer.service.TimestampRegistry; +import site.coduo.websocket.PairRoomWebSocketService; +import site.coduo.websocket.message.EventAndDataMessage; @Slf4j @RequiredArgsConstructor @@ -22,17 +24,17 @@ public class SchedulerService { public static final Duration DELAY_SECOND = Duration.of(1, ChronoUnit.SECONDS); + private final PairRoomWebSocketService pairRoomWebSocketService; private final ThreadPoolTaskScheduler taskScheduler; private final SchedulerRegistry schedulerRegistry; private final TimestampRegistry timestampRegistry; private final TimerRepository timerRepository; - private final SseService sseService; public void start(final String key) { if (schedulerRegistry.isActive(key)) { return; } - sseService.broadcast(key, "timer", "start"); + pairRoomWebSocketService.sendAllPairRoomSessions(key, new EventAndDataMessage("timer", "start")); if (isInitial(key)) { final Timer timer = timerRepository.fetchTimerByAccessCode(key) .toDomain(); @@ -59,23 +61,24 @@ private void runTimer(final String key, final Timer timer) { stop(key, timer); return; } - if (sseService.hasNoConnections(key) && schedulerRegistry.has(key)) { + if (pairRoomWebSocketService.hasNoConnections(key) && schedulerRegistry.has(key)) { pause(key); return; } timer.decreaseRemainingTime(DELAY_SECOND.toMillis()); - sseService.broadcast(key, "remaining-time", String.valueOf(timer.getRemainingTime())); + pairRoomWebSocketService.sendAllPairRoomSessions(key, + new EventAndDataMessage("remaining-time", String.valueOf(timer.getRemainingTime()))); } public void pause(final String key) { if (schedulerRegistry.isActive(key)) { - sseService.broadcast(key, "timer", "pause"); + pairRoomWebSocketService.sendAllPairRoomSessions(key, new EventAndDataMessage("timer", "pause")); schedulerRegistry.release(key); } } private void stop(final String key, final Timer timer) { - sseService.broadcast(key, "timer", "stop"); + pairRoomWebSocketService.sendAllPairRoomSessions(key, new EventAndDataMessage("timer", "stop")); schedulerRegistry.release(key); final Timer initalTimer = new Timer(timer.getAccessCode(), timer.getDuration(), timer.getDuration()); timestampRegistry.register(key, initalTimer); diff --git a/backend/src/main/java/site/coduo/websocket/PairRoomWebSocketService.java b/backend/src/main/java/site/coduo/websocket/PairRoomWebSocketService.java new file mode 100644 index 000000000..af7c291f5 --- /dev/null +++ b/backend/src/main/java/site/coduo/websocket/PairRoomWebSocketService.java @@ -0,0 +1,25 @@ +package site.coduo.websocket; + +import java.util.Set; + +import org.springframework.stereotype.Service; +import org.springframework.web.socket.WebSocketSession; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Service +public class PairRoomWebSocketService { + + private final PairRoomWebSocketSessionStore pairRoomWebSocketSessionStore; + private final WebSocketSender prodWebSocketSender; + + public void sendAllPairRoomSessions(final String pairRoomAccessCode, final WebSocketMessage message) { + final Set sessions = pairRoomWebSocketSessionStore.getSessions(pairRoomAccessCode); + prodWebSocketSender.sendMessage(sessions, message); + } + + public boolean hasNoConnections(final String pairRoomAccessCode) { + return !pairRoomWebSocketSessionStore.hasPairRoomSessions(pairRoomAccessCode); + } +} diff --git a/backend/src/main/java/site/coduo/websocket/PairRoomWebSocketSessionStore.java b/backend/src/main/java/site/coduo/websocket/PairRoomWebSocketSessionStore.java new file mode 100644 index 000000000..0e293acd6 --- /dev/null +++ b/backend/src/main/java/site/coduo/websocket/PairRoomWebSocketSessionStore.java @@ -0,0 +1,54 @@ +package site.coduo.websocket; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.stereotype.Component; +import org.springframework.web.socket.WebSocketSession; + +import lombok.RequiredArgsConstructor; +import site.coduo.pairroom.exception.InvalidAccessCodeException; +import site.coduo.pairroom.exception.NotFoundPairRoomSessionException; +import site.coduo.pairroom.exception.PairRoomNotFoundException; +import site.coduo.pairroom.repository.PairRoomRepository; + +@RequiredArgsConstructor +@Component +public class PairRoomWebSocketSessionStore { + + private final PairRoomRepository pairRoomRepository; + private final Map> sessions = new ConcurrentHashMap<>(); + + public void addSession(final String pairRoomAccessCode, final WebSocketSession session) { + validatePairRoomAccessCode(pairRoomAccessCode); + if (!sessions.containsKey(pairRoomAccessCode)) { + sessions.put(pairRoomAccessCode, new HashSet<>()); + } + sessions.get(pairRoomAccessCode).add(session); + } + + private void validatePairRoomAccessCode(final String pairRoomAccessCode) { + if (pairRoomAccessCode == null || pairRoomAccessCode.isBlank()) { + throw new InvalidAccessCodeException("페어룸 접근 코드로 null이 입력될 수 없습니다."); + } + + if (!pairRoomRepository.existsByAccessCode(pairRoomAccessCode)) { + throw new PairRoomNotFoundException("존재하지 않는 페어룸 코드입니다. - " + pairRoomAccessCode); + } + } + + public Set getSessions(final String pairRoomAccessCode) { + validatePairRoomAccessCode(pairRoomAccessCode); + if (!sessions.containsKey(pairRoomAccessCode)) { + throw new NotFoundPairRoomSessionException("해당 페어룸의 세션이 존재하지 않습니다. - " + pairRoomAccessCode); + } + return sessions.get(pairRoomAccessCode); + } + + public boolean hasPairRoomSessions(final String pairRoomAccessCode) { + validatePairRoomAccessCode(pairRoomAccessCode); + return sessions.containsKey(pairRoomAccessCode); + } +} diff --git a/backend/src/main/java/site/coduo/websocket/ProdWebSocketSender.java b/backend/src/main/java/site/coduo/websocket/ProdWebSocketSender.java new file mode 100644 index 000000000..dbe9710b1 --- /dev/null +++ b/backend/src/main/java/site/coduo/websocket/ProdWebSocketSender.java @@ -0,0 +1,35 @@ +package site.coduo.websocket; + +import java.io.IOException; +import java.util.Set; + +import org.springframework.stereotype.Component; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@Component +public class ProdWebSocketSender implements WebSocketSender { + + private final ObjectMapper objectMapper; + + @Override + public void sendMessage(final Set sessions, final WebSocketMessage message) { + sessions.parallelStream().forEach(session -> sendMessage(session, message)); + } + + private void sendMessage(final WebSocketSession session, final WebSocketMessage message) { + try { + final TextMessage webSocketMessage = new TextMessage(objectMapper.writeValueAsString(message)); + session.sendMessage(webSocketMessage); + } catch (IOException e) { + log.error(e.getMessage(), e); + } + } +} diff --git a/backend/src/main/java/site/coduo/websocket/QueryAccessCodeParser.java b/backend/src/main/java/site/coduo/websocket/QueryAccessCodeParser.java new file mode 100644 index 000000000..4761e6846 --- /dev/null +++ b/backend/src/main/java/site/coduo/websocket/QueryAccessCodeParser.java @@ -0,0 +1,38 @@ +package site.coduo.websocket; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +import site.coduo.websocket.exception.EmptyQueryException; +import site.coduo.websocket.exception.NotFoundAccessCodeInQueryException; + +public class QueryAccessCodeParser { + + private static final String QUERY_DELIMITER = "&"; + private static final String KEY_VALUE_DELIMITER = "="; + private static final int ACCESS_CODE_QUERY_SIZE = 2; + private static final int KEY_INDEX = 0; + private static final String ACCESS_CODE_KEY_NAME = "accesscode"; + private static final int VALUE_INDEX = 1; + + public static String parse(final String query) { + validateQuery(query); + return Arrays.stream(query.split(QUERY_DELIMITER)) + .map(keyValuePair -> keyValuePair.split(KEY_VALUE_DELIMITER)) + .filter(QueryAccessCodeParser::isAccessCodeKeyValuePair) + .findFirst() + .map(accessCodeKeyValue -> URLDecoder.decode(accessCodeKeyValue[VALUE_INDEX], StandardCharsets.UTF_8)) + .orElseThrow(() -> new NotFoundAccessCodeInQueryException("쿼리에 액세스코드가 존재하지 않습니다.")); + } + + private static void validateQuery(final String query) { + if (query == null || query.isEmpty()) { + throw new EmptyQueryException("쿼리가 존재하지 않습니다."); + } + } + + private static boolean isAccessCodeKeyValuePair(final String[] keyValuePair) { + return keyValuePair.length == ACCESS_CODE_QUERY_SIZE && keyValuePair[KEY_INDEX].equals(ACCESS_CODE_KEY_NAME); + } +} diff --git a/backend/src/main/java/site/coduo/websocket/WebSocketConfig.java b/backend/src/main/java/site/coduo/websocket/WebSocketConfig.java new file mode 100644 index 000000000..971aa509c --- /dev/null +++ b/backend/src/main/java/site/coduo/websocket/WebSocketConfig.java @@ -0,0 +1,22 @@ +package site.coduo.websocket; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@EnableWebSocket +@Configuration +public class WebSocketConfig implements WebSocketConfigurer { + + private final WebSocketHandler webSocketHandler; + + @Override + public void registerWebSocketHandlers(final WebSocketHandlerRegistry registry) { + registry.addHandler(webSocketHandler, "/ws-connect") + .setAllowedOrigins("*"); + } +} diff --git a/backend/src/main/java/site/coduo/websocket/WebSocketHandler.java b/backend/src/main/java/site/coduo/websocket/WebSocketHandler.java new file mode 100644 index 000000000..1133d1a00 --- /dev/null +++ b/backend/src/main/java/site/coduo/websocket/WebSocketHandler.java @@ -0,0 +1,41 @@ +package site.coduo.websocket; + +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@Component +public class WebSocketHandler extends TextWebSocketHandler { + + private final PairRoomWebSocketSessionStore pairRoomWebSocketSessionStore; + + @Override + public void afterConnectionEstablished(final WebSocketSession session) { + final String query = session.getUri().getQuery(); + final String pairRoomAccessCode = QueryAccessCodeParser.parse(query); + pairRoomWebSocketSessionStore.addSession(pairRoomAccessCode, session); + log.info("연결 성공 : {}", session.getId()); + } + + @Override + protected void handleTextMessage(final WebSocketSession session, final TextMessage message) { + // TODO : 클라이언트의 메시지를 파싱하는 메서드. 타이머 잔여 시간 조회에 필요가 없어 우선 보류 + } + + @Override + public void handleTransportError(final WebSocketSession session, final Throwable exception) { + log.error("Web Socket 전송 중 에러 발생 : {}", exception.getMessage()); + } + + @Override + public void afterConnectionClosed(final WebSocketSession session, final CloseStatus status) { + log.info("연결 종료 : {}, 상태 : {}", session.getId(), status); + } +} diff --git a/backend/src/main/java/site/coduo/websocket/WebSocketMessage.java b/backend/src/main/java/site/coduo/websocket/WebSocketMessage.java new file mode 100644 index 000000000..0951b62d5 --- /dev/null +++ b/backend/src/main/java/site/coduo/websocket/WebSocketMessage.java @@ -0,0 +1,4 @@ +package site.coduo.websocket; + +public interface WebSocketMessage { +} diff --git a/backend/src/main/java/site/coduo/websocket/WebSocketSender.java b/backend/src/main/java/site/coduo/websocket/WebSocketSender.java new file mode 100644 index 000000000..635314e71 --- /dev/null +++ b/backend/src/main/java/site/coduo/websocket/WebSocketSender.java @@ -0,0 +1,10 @@ +package site.coduo.websocket; + +import java.util.Set; + +import org.springframework.web.socket.WebSocketSession; + +public interface WebSocketSender { + + void sendMessage(final Set sessions, final WebSocketMessage message); +} diff --git a/backend/src/main/java/site/coduo/websocket/exception/EmptyQueryException.java b/backend/src/main/java/site/coduo/websocket/exception/EmptyQueryException.java new file mode 100644 index 000000000..db4f7771f --- /dev/null +++ b/backend/src/main/java/site/coduo/websocket/exception/EmptyQueryException.java @@ -0,0 +1,8 @@ +package site.coduo.websocket.exception; + +public class EmptyQueryException extends WebSocketException { + + public EmptyQueryException(final String message) { + super(message); + } +} diff --git a/backend/src/main/java/site/coduo/websocket/exception/NotFoundAccessCodeInQueryException.java b/backend/src/main/java/site/coduo/websocket/exception/NotFoundAccessCodeInQueryException.java new file mode 100644 index 000000000..696781f22 --- /dev/null +++ b/backend/src/main/java/site/coduo/websocket/exception/NotFoundAccessCodeInQueryException.java @@ -0,0 +1,8 @@ +package site.coduo.websocket.exception; + +public class NotFoundAccessCodeInQueryException extends WebSocketException { + + public NotFoundAccessCodeInQueryException(final String message) { + super(message); + } +} diff --git a/backend/src/main/java/site/coduo/websocket/exception/WebSocketException.java b/backend/src/main/java/site/coduo/websocket/exception/WebSocketException.java new file mode 100644 index 000000000..b41cfa669 --- /dev/null +++ b/backend/src/main/java/site/coduo/websocket/exception/WebSocketException.java @@ -0,0 +1,8 @@ +package site.coduo.websocket.exception; + +public class WebSocketException extends RuntimeException { + + public WebSocketException(final String message) { + super(message); + } +} diff --git a/backend/src/main/java/site/coduo/websocket/message/EventAndDataMessage.java b/backend/src/main/java/site/coduo/websocket/message/EventAndDataMessage.java new file mode 100644 index 000000000..b06d18388 --- /dev/null +++ b/backend/src/main/java/site/coduo/websocket/message/EventAndDataMessage.java @@ -0,0 +1,6 @@ +package site.coduo.websocket.message; + +import site.coduo.websocket.WebSocketMessage; + +public record EventAndDataMessage(String event, String data) implements WebSocketMessage { +} diff --git a/backend/src/test/java/site/coduo/acceptance/SseAcceptanceTest.java b/backend/src/test/java/site/coduo/acceptance/SseAcceptanceTest.java index d39275547..7e803cc12 100644 --- a/backend/src/test/java/site/coduo/acceptance/SseAcceptanceTest.java +++ b/backend/src/test/java/site/coduo/acceptance/SseAcceptanceTest.java @@ -2,6 +2,7 @@ import static site.coduo.acceptance.PairRoomAcceptanceTest.createPairRoom; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -9,6 +10,7 @@ import site.coduo.fixture.PairRoomCreateRequestFixture; import site.coduo.pairroom.service.dto.PairRoomCreateRequest; +@Disabled class SseAcceptanceTest extends AcceptanceFixture { static void createConnect(final String accessCode) { @@ -17,7 +19,8 @@ static void createConnect(final String accessCode) { .when() .log().all() - .get("/api/{key}/connect", accessCode) +// .get("/api/{key}/connect", accessCode) + .get("/ws-connect/{key}/connect", accessCode) .then() .log().all() diff --git a/backend/src/test/java/site/coduo/acceptance/TimerAcceptanceTest.java b/backend/src/test/java/site/coduo/acceptance/TimerAcceptanceTest.java index c6221d72d..218163c66 100644 --- a/backend/src/test/java/site/coduo/acceptance/TimerAcceptanceTest.java +++ b/backend/src/test/java/site/coduo/acceptance/TimerAcceptanceTest.java @@ -2,6 +2,7 @@ import static site.coduo.acceptance.SseAcceptanceTest.createConnect; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; @@ -12,6 +13,7 @@ import site.coduo.pairroom.service.dto.PairRoomCreateResponse; import site.coduo.timer.service.dto.TimerUpdateRequest; +@Disabled class TimerAcceptanceTest extends AcceptanceFixture { static String createPairRoom(final PairRoomCreateRequest pairRoom) { diff --git a/backend/src/test/java/site/coduo/sync/service/SchedulerServiceTest.java b/backend/src/test/java/site/coduo/sync/service/SchedulerServiceTest.java index 0dbdfb7d1..99077c120 100644 --- a/backend/src/test/java/site/coduo/sync/service/SchedulerServiceTest.java +++ b/backend/src/test/java/site/coduo/sync/service/SchedulerServiceTest.java @@ -21,11 +21,15 @@ import site.coduo.timer.repository.TimerEntity; import site.coduo.timer.repository.TimerRepository; import site.coduo.timer.service.TimestampRegistry; +import site.coduo.websocket.PairRoomWebSocketService; @Disabled @SpringBootTest class SchedulerServiceTest { + @Autowired + private PairRoomWebSocketService pairRoomWebSocketService; + @Autowired private ThreadPoolTaskScheduler taskScheduler; @@ -36,20 +40,19 @@ class SchedulerServiceTest { private TimestampRegistry timestampRegistry; private TimerRepository timerRepository; - private SseService sseService; private SchedulerService schedulerService; @BeforeEach void setUp() { timerRepository = mock(TimerRepository.class); - sseService = mock(SseService.class); + pairRoomWebSocketService = mock(PairRoomWebSocketService.class); schedulerService = new SchedulerService( + pairRoomWebSocketService, taskScheduler, schedulerRegistry, timestampRegistry, - timerRepository, - sseService + timerRepository ); } diff --git a/backend/src/test/java/site/coduo/websocket/QueryAccessCodeParserTest.java b/backend/src/test/java/site/coduo/websocket/QueryAccessCodeParserTest.java new file mode 100644 index 000000000..b9676ad5e --- /dev/null +++ b/backend/src/test/java/site/coduo/websocket/QueryAccessCodeParserTest.java @@ -0,0 +1,52 @@ +package site.coduo.websocket; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.net.URLEncoder; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; + +import site.coduo.websocket.exception.EmptyQueryException; +import site.coduo.websocket.exception.NotFoundAccessCodeInQueryException; + +class QueryAccessCodeParserTest { + + @DisplayName("query에서 accesscode를 추출한다.") + @ParameterizedTest + @CsvSource(value = {"user=ink&accesscode=abcde-&role=admin", "accesscode=abcde-",}) + void parseAccessCode(final String query) { + final String accessCode = QueryAccessCodeParser.parse(query); + assertThat(accessCode).isEqualTo("abcde-"); + } + + @DisplayName("인코딩된 query에서 accesscode를 추출한다.") + @Test + void parseEncodedAccessCode() { + final String encode = URLEncoder.encode("abcde-"); + final String accessCode = QueryAccessCodeParser.parse("user=john&accesscode=" + encode); + assertThat(accessCode).isEqualTo("abcde-"); + } + + @DisplayName("query가 비어있거나 null이면 예외가 발생한다.") + @NullAndEmptySource + @ParameterizedTest + void throwExceptionWhenQueryIsEmptyOrNull(final String query) { + assertThatThrownBy(() -> QueryAccessCodeParser.parse(query)) + .isExactlyInstanceOf(EmptyQueryException.class) + .hasMessageContaining("쿼리가 존재하지 않습니다"); + } + + @DisplayName("accesscode가 비어있으면 예외가 발생한다.") + @Test + void throwExceptionWhenAccessCodeIsEmpty() { + final String query = "user=ink&accesscode="; + assertThatThrownBy(() -> QueryAccessCodeParser.parse(query)) + .isExactlyInstanceOf(NotFoundAccessCodeInQueryException.class) + .hasMessageContaining("쿼리에 액세스코드가 존재하지 않습니다."); + } +}