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

refactor: ETA 목록 조회 API web socket으로 수정 #531

Open
wants to merge 11 commits into
base: develop
Choose a base branch
from
1 change: 1 addition & 0 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation "io.jsonwebtoken:jjwt:0.9.1"
implementation 'javax.xml.bind:jaxb-api:2.3.1'

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.ody.eta.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface WebSocketAuthMember {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.ody.eta.argumentresolver;

import com.ody.auth.service.AuthService;
import com.ody.common.exception.OdyException;
import com.ody.common.exception.OdyUnauthorizedException;
import com.ody.eta.annotation.WebSocketAuthMember;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.MethodParameter;
import org.springframework.http.HttpHeaders;
import org.springframework.messaging.Message;
import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;

@Slf4j
@RequiredArgsConstructor
public class WebSocketArgumentResolver implements HandlerMethodArgumentResolver {

private final AuthService authService;

public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(WebSocketAuthMember.class);
}

@Override
public Object resolveArgument(
MethodParameter parameter,
Message<?> message
) {
Copy link
Contributor

Choose a reason for hiding this comment

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

[필수]

Suggested change
public Object resolveArgument(
MethodParameter parameter,
Message<?> message
) {
public Object resolveArgument(MethodParameter parameter, Message<?> message) {

개행하지 않아도 될 것 같아요 😃

Copy link
Contributor

Choose a reason for hiding this comment

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

반영했습니다!

StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
String accessToken = accessor.getFirstNativeHeader(HttpHeaders.AUTHORIZATION);

try {
return authService.parseAccessToken(accessToken);
} catch (OdyException exception) {
log.warn(exception.getMessage());
throw new OdyUnauthorizedException("액세스 토큰이 유효하지 않습니다.");
}
}
}
24 changes: 24 additions & 0 deletions backend/src/main/java/com/ody/eta/config/WebSocketConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.ody.eta.config;

import org.springframework.context.annotation.Configuration;
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;

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/connect")
.setAllowedOrigins("*");
Copy link
Contributor

Choose a reason for hiding this comment

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

[질문]
cors 방지를 위해 추가해주신 것 같은데
안드로이드 쪽에서 요청을 보내는 주소의 origin이 계속 바뀌나요 ??

Copy link
Contributor

Choose a reason for hiding this comment

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

우선 모든 요청지로부터의 요청을 허용한다는 설정으로만 알고 있는데 CORS와 관련된 부분인지 조금 더 공부해볼게요! socket통신이 처음이다보니 저도 익숙치 않은 설정이 많네요 😢

Copy link
Contributor Author

Choose a reason for hiding this comment

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

안드와 이야기해보고 와일드카드 사용하지 않는 방향으로 수정할게요!

}

@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic");
registry.setApplicationDestinationPrefixes("/publish");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.ody.eta.controller;

import com.ody.eta.annotation.WebSocketAuthMember;
import com.ody.eta.dto.request.MateEtaRequest;
import com.ody.eta.service.EtaSocketService;
import com.ody.meeting.dto.response.MateEtaResponsesV2;
import com.ody.member.domain.Member;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.handler.annotation.DestinationVariable;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
@RequiredArgsConstructor
public class EtaSocketController {

private final EtaSocketService etaSocketService;

@MessageMapping("/open/{meetingId}")
public void open(@DestinationVariable Long meetingId) {
log.info("--- websocket open ! - {}", meetingId);
etaSocketService.open(meetingId);
}

@MessageMapping("/etas/{meetingId}")
@SendTo("/topic/etas/{meetingId}")
public MateEtaResponsesV2 etaUpdate(
@DestinationVariable Long meetingId,
@WebSocketAuthMember Member member,
@Payload MateEtaRequest etaRequest
) {
log.info("--- etaUpdate 호출 ! - {}, {}, {}", meetingId, member, etaRequest);
return etaSocketService.etaUpdate(meetingId, member, etaRequest);
}
}
74 changes: 74 additions & 0 deletions backend/src/main/java/com/ody/eta/service/EtaSocketService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.ody.eta.service;

import com.ody.eta.dto.request.MateEtaRequest;
import com.ody.mate.service.MateService;
import com.ody.meeting.domain.Meeting;
import com.ody.meeting.dto.response.MateEtaResponsesV2;
import com.ody.meeting.service.MeetingService;
import com.ody.member.domain.Member;
import com.ody.util.TimeUtil;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@RequiredArgsConstructor
public class EtaSocketService {

private static final ZoneOffset KST_OFFSET = ZoneOffset.ofHours(9);
private static final Map<Long, LocalDateTime> LATEST_TRIGGER_TIME_CACHE = new ConcurrentHashMap<>();
private static final Map<Long, LocalDateTime> MEETING_TIME_CACHE = new ConcurrentHashMap<>();

private final MeetingService meetingService;
private final MateService mateService;
private final TaskScheduler taskScheduler;
private final SimpMessagingTemplate template;

public void open(Long meetingId) {
if (!MEETING_TIME_CACHE.containsKey(meetingId)) {
Meeting meeting = meetingService.findById(meetingId);
MEETING_TIME_CACHE.put(meetingId, meeting.getMeetingTime());
}
scheduleTrigger(meetingId, LocalDateTime.now().plusSeconds(1));
}

public MateEtaResponsesV2 etaUpdate(Long meetingId, Member member, MateEtaRequest etaRequest) {
if (isOverMeetingTime(meetingId)) {
log.info("--- websocket disconnect ! - {}", meetingId);
template.convertAndSend("/topic/disconnect/" + meetingId, "");
} else if (isTimeToSchedule(meetingId)) {
scheduleTrigger(meetingId, LocalDateTime.now().plusSeconds(10));
}
return mateService.findAllMateEtas(etaRequest, meetingId, member);
}

private boolean isOverMeetingTime(Long meetingId) {
LocalDateTime meetingTime = MEETING_TIME_CACHE.get(meetingId);
LocalDateTime lastTriggerTime = meetingTime.plusMinutes(1L);
return TimeUtil.nowWithTrim().isAfter(lastTriggerTime);
}
Comment on lines +54 to +58
Copy link
Contributor

Choose a reason for hiding this comment

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

[질문] 약속 시간이 지나면 더 이상 웹소켓 연결을 유지하지 않기 위해 검증하는 것 같은데 meetingTime.plusMinutes(1L)을 왜 lastTriggerTime이라는 변수명으로 받나요? 의미가 이해가 잘 안 가요

Copy link
Contributor

Choose a reason for hiding this comment

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

변수명에 대해서는 다시 생각해보는 게 맞을 것 같에요. lastCallTime, endCallTime 등의 변수명이 더 적절하다고 느껴지네요.

약속 시간 +1 분을 하는 이유는 약속 시간 이후에도 1분간 상태 업데이트가 되어야 지각 위기/ 도착 예정 -> 지각 / 도착 여부가 판별되기 때문입니다. 따라서 약속 시간 1분후 까지 eta 상태를 계속해서 업데이트하고 요청 시각이 1분후를 지났다면 disconnect 요청을 보내는 방식입니다.
+


private boolean isTimeToSchedule(Long meetingId) {
LocalDateTime lastTriggerTime = LATEST_TRIGGER_TIME_CACHE.get(meetingId);
Duration duration = Duration.between(lastTriggerTime, LocalDateTime.now());
return duration.toSeconds() >= 10;
Copy link
Contributor

Choose a reason for hiding this comment

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

[제안] 상수로 둬도 좋을 것 같네요

}

private void scheduleTrigger(Long meetingId, LocalDateTime triggerTime) {
Instant startTime = triggerTime.toInstant(KST_OFFSET);
taskScheduler.schedule(
() -> template.convertAndSend("topic/coordinates/" + meetingId, ""), startTime
);
LATEST_TRIGGER_TIME_CACHE.put(meetingId, LocalDateTime.now());
Comment on lines +68 to +71
Copy link
Contributor

Choose a reason for hiding this comment

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

[질문] 스케줄링 성공과 무관하게 latest trigger time을 업데이트해줘도 되나요?
startTime이 아니라 now로 업데이트하는 이유는 무엇인가요?

Copy link
Contributor

Choose a reason for hiding this comment

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

  • 스케쥴링에 실패하면 lastest trigger time cache에 시간을 넣기 이전에 에러가 반환되므로 업데이트가 되지 않습니다.
  • lastest trigger time은 상태를 업데이트 한 가장 최근 시각을 의미합니다. 즉, trigger 작업 [eta 상태 업데이트 -> 10초 뒤 스케쥴링 예약 ]-> 최근 업데이트 시간인 현재 시간으로 cache 저장의 순서를 따르고 있어요.

log.info("--- schedule 예약 완료 ! - {}, {}", meetingId, triggerTime);
}
}
87 changes: 87 additions & 0 deletions backend/src/test/java/com/ody/common/websocket/BaseStompTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package com.ody.common.websocket;

import com.ody.common.DatabaseCleaner;
import com.ody.common.TestAuthConfig;
import com.ody.common.TestRouteConfig;
import com.ody.common.config.JpaAuditingConfig;
import com.ody.eta.config.WebSocketConfig;
import com.ody.notification.config.FcmConfig;
import com.ody.notification.service.FcmPushSender;
import com.ody.notification.service.FcmSubscriber;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.context.annotation.Import;
import org.springframework.messaging.converter.MappingJackson2MessageConverter;
import org.springframework.messaging.simp.stomp.StompSession;
import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.annotation.DirtiesContext.ClassMode;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
import org.springframework.web.socket.messaging.WebSocketStompClient;

@Import({JpaAuditingConfig.class, TestRouteConfig.class, TestAuthConfig.class, WebSocketConfig.class})
@ExtendWith(SpringExtension.class)
@ActiveProfiles("test")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@DirtiesContext(classMode = ClassMode.BEFORE_EACH_TEST_METHOD)
public class BaseStompTest {

private static final String ENDPOINT = "/connect";

@MockBean
private FcmConfig fcmConfig;

@MockBean
protected FcmSubscriber fcmSubscriber;

@MockBean
protected FcmPushSender fcmPushSender;

@Autowired
private DatabaseCleaner databaseCleaner;

protected StompSession stompSession;

@LocalServerPort
private int port;

private final String url;

private final WebSocketStompClient websocketClient;

public BaseStompTest() {
this.websocketClient = new WebSocketStompClient(new StandardWebSocketClient());
this.websocketClient.setMessageConverter(new MappingJackson2MessageConverter());
this.url = "ws://localhost:";
}

@BeforeEach
public void connect() throws ExecutionException, InterruptedException, TimeoutException {
this.stompSession = this.websocketClient
.connect(url + port + ENDPOINT, new StompSessionHandlerAdapter() {
})
.get(3, TimeUnit.SECONDS);
}

@BeforeEach
void databaseCleanUp() {
databaseCleaner.cleanUp();
}

@AfterEach
public void disconnect() {
if (this.stompSession.isConnected()) {
this.stompSession.disconnect();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.ody.common.websocket;

import java.lang.reflect.Type;
import java.util.concurrent.CompletableFuture;
import org.springframework.messaging.simp.stomp.StompFrameHandler;
import org.springframework.messaging.simp.stomp.StompHeaders;

public class MessageFrameHandler<T> implements StompFrameHandler {

private final CompletableFuture<T> completableFuture = new CompletableFuture<>();

private final Class<T> tClass;

public MessageFrameHandler(Class<T> tClass) {
this.tClass = tClass;
}

@Override
public Type getPayloadType(StompHeaders headers) {
return this.tClass;
}

@Override
public void handleFrame(StompHeaders headers, Object payload) {
if(completableFuture.complete((T)payload)){
System.out.println("끝남");
}
}

public CompletableFuture<T> getCompletableFuture() {
return completableFuture;
}
}
Loading
Loading