Skip to content

Commit

Permalink
Merge pull request #966 from woowacourse-teams/BE/feature/#961
Browse files Browse the repository at this point in the history
[BE] SSE 기반 타이머 잔여 시간 조회 기능을 Web Socket 기반으로 변경
  • Loading branch information
kelly6bf authored Nov 23, 2024
2 parents 4a6bcd9 + 676da02 commit c5ad88b
Show file tree
Hide file tree
Showing 19 changed files with 344 additions and 11 deletions.
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 {
}
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

0 comments on commit c5ad88b

Please sign in to comment.