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

[BE] SSE 기반 타이머 잔여 시간 조회 기능을 Web Socket 기반으로 변경 #966

Merged
merged 9 commits into from
Nov 23, 2024
3 changes: 3 additions & 0 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package site.coduo.pairroom.exception;

public class NotFoundPairRoomSessionException extends PairRoomException {

public NotFoundPairRoomSessionException(final String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<WebSocketSession> sessions = pairRoomWebSocketSessionStore.getSessions(pairRoomAccessCode);
prodWebSocketSender.sendMessage(sessions, message);
}

public boolean hasNoConnections(final String pairRoomAccessCode) {
return !pairRoomWebSocketSessionStore.hasPairRoomSessions(pairRoomAccessCode);
}
}
Original file line number Diff line number Diff line change
@@ -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<String, Set<WebSocketSession>> 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<WebSocketSession> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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<WebSocketSession> 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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
22 changes: 22 additions & 0 deletions backend/src/main/java/site/coduo/websocket/WebSocketConfig.java
Original file line number Diff line number Diff line change
@@ -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("*");
}
}
41 changes: 41 additions & 0 deletions backend/src/main/java/site/coduo/websocket/WebSocketHandler.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package site.coduo.websocket;

public interface WebSocketMessage {
}
10 changes: 10 additions & 0 deletions backend/src/main/java/site/coduo/websocket/WebSocketSender.java
Original file line number Diff line number Diff line change
@@ -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<WebSocketSession> sessions, final WebSocketMessage message);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package site.coduo.websocket.exception;

public class EmptyQueryException extends WebSocketException {

public EmptyQueryException(final String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package site.coduo.websocket.exception;

public class NotFoundAccessCodeInQueryException extends WebSocketException {

public NotFoundAccessCodeInQueryException(final String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package site.coduo.websocket.exception;

public class WebSocketException extends RuntimeException {

public WebSocketException(final String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package site.coduo.websocket.message;

import site.coduo.websocket.WebSocketMessage;

public record EventAndDataMessage(String event, String data) implements WebSocketMessage {
}
Comment on lines +5 to +6
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WebSocketMessage 를 인터페이스로 두고 이 EventAndDataMessage를 레코드로 설계하신 이유가 궁금합니다!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WebSocketMessage는 타이머, 레퍼런스 링크, 투두 에서 사용될 예정입니다.
현재 타이머엔 event와 data 만 필요하지만 도메인이나 상황에 따라 다양한 필드를 가진 WebSocketMessage들이 필요할 수 있어� 추상화 했습니다.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아하 인터페이스를 레코드로 구현하는게 신기(?)해서 남긴 질문이였어요

지금 인터페이스는 뭔가 데이터 모음을 한번에 처리하기 위한 그런 역할이군요

Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@

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;

import io.restassured.RestAssured;
import site.coduo.fixture.PairRoomCreateRequestFixture;
import site.coduo.pairroom.service.dto.PairRoomCreateRequest;

@Disabled
class SseAcceptanceTest extends AcceptanceFixture {

static void createConnect(final String accessCode) {
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand Down
Loading
Loading