웹소켓과 새로고침 문제

pitseleh·2025년 3월 5일
post-thumbnail

기존에는 시간에 쫓기다보니 새로고침과 브라우저 종료와 같은 예외 상황을 고려하지 않고 개발을 했다. 따라서 새로고침으로 인해 일시적으로 웹소켓 연결이 끊기는 순간 대기방에서 퇴장 처리되는 문제가 있었다. 프로젝트가 끝났으니 내내 마음에 걸렸던 부분을 개선하기로 했다. 우선 다음과 같이 설계해보았다.

  1. 유저가 WebSocket에서 연결 해제되었을 때(SessionDisconnectEvent 발생), 즉시 강제 퇴장 처리하는 것이 아니라 일정 시간(5초) 동안 기다렸다가 퇴장 여부를 결정
  2. 이를 위해 Spring의 TaskScheduler를 사용하여 비동기 스케줄링을 수행
  3. 5초 후(GRACE_PERIOD_SECONDS * 1000 밀리초 후) 다시 확인
    • 유저가 재연결되지 않았으면 → handleUserExit()을 호출하여 퇴장 처리
    • 유저가 재연결되었으면 → 아무런 작업도 하지 않음 (재연결 시 pendingKey가 삭제되었기 때문)

구현하면 아래와 같다.


@Component
@RequiredArgsConstructor
@Slf4j
public class WebSocketEventListener {
	private final UserStatusService userStatusService;
	private final SingleRoomRedisRepository singleRoomRedisRepository;
	private final TeamRoomRedisRepository teamRoomRedisRepository;
	private final SingleRoomService singleRoomService;
	private final TeamRoomService teamRoomService;
	private final GameLeaveService gameLeaveService;
	private final FriendRedisService friendRedisService;
	private final RedisTemplate<String, String> redisTemplate;
	private final TaskScheduler taskScheduler;

	private static final long GRACE_PERIOD_SECONDS = 5;   // 재연결 대기 시간

	@EventListener
	public void handleWebSocketConnectListener(SessionConnectEvent event) {
		StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage());
		Map<String, Object> sessionAttributes = accessor.getSessionAttributes();
		if (sessionAttributes != null && sessionAttributes.containsKey("userId")) {
			try {
				Long userId = (Long)sessionAttributes.get("userId");
				userStatusService.setUserOnline(userId);
				String pendingKey = RedisKeys.getPendingDisconnectKey(userId);

				// 재연결 시 pending 상태 확인
				if (Boolean.TRUE.equals(redisTemplate.hasKey(pendingKey))) {
					redisTemplate.delete(pendingKey); // pending 상태 해제
					log.info("유저가 재연결되었습니다 - userId: {}", userId);
				} else {
					userStatusService.setUserOnline(userId);
					log.info("유저가 연결되었습니다 - userId: {}", userId);
				}
			} catch (Exception e) {
				log.error("WebSocket 연결 처리 실패: {}", e.getMessage());
			}
		} else {
			log.info("WebSocket 연결 처리 실패: session attributes에서 userId를 찾을 수 없습니다.");
			throw new NotFoundUserException();
		}
	}

	@EventListener
	public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
		StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage());
		Map<String, Object> sessionAttributes = accessor.getSessionAttributes();

		log.info(accessor.toString());

		if (sessionAttributes != null && sessionAttributes.containsKey("userId")) {
			try {
				Long userId = (Long)sessionAttributes.get("userId");
				Long singleRoomId = singleRoomRedisRepository.getRoomIdByUser(userId);
				Long teamRoomId = teamRoomRedisRepository.getRoomIdByUser(userId);
				String pendingKey = RedisKeys.getPendingDisconnectKey(userId);     // pendingKey 생성

				// Redis에 Grace Period 동안 pending 상태로 설정
				redisTemplate.opsForValue().set(pendingKey, "true", GRACE_PERIOD_SECONDS, TimeUnit.SECONDS);

				// Grace Period 후 퇴장 처리 스케줄링
				taskScheduler.schedule(() -> {
					log.info("Grace Period 후 퇴장 스케줄링 실행됨 - userId: {}", userId);
					if (Boolean.TRUE.equals(redisTemplate.hasKey(pendingKey))) {
						log.info("유저 {}가 퇴장 처리됨.", userId);
						handleUserExit(userId, singleRoomId, teamRoomId);
						redisTemplate.delete(pendingKey);
					} else {
						log.info("유저 {}는 이미 재연결됨.", userId);
					}
				}, new Date(System.currentTimeMillis() + GRACE_PERIOD_SECONDS * 1000));

				log.info("유저가 연결 해제 감지됨 - userId: {}, Grace Period 대기 중", userId);
			} catch (Exception e) {
				log.error("WebSocket 연결 해제 처리 실패: {}", e.getMessage());
			}
		} else {
			log.info("WebSocket 연결 해제 처리 실패: session attributes에서 userId를 찾을 수 없습니다.");
			throw new NotFoundUserException();
		}
	}
    
// 생략

브라우저 종료와 같이 비정상적인 방법으로 대기방에서 퇴장할 경우 UI에 즉시 반영하고, 새로고침할 경우에는 그대로 대기방에 남기고 싶었는데 아무리 방법을 생각해봐도 두 가지 상황 (비정상적 퇴장, 새로고침)을 구분하기 위해서는 몇 초간의 딜레이가 발생할 수밖에 없었다. 아마 내가 못 찾은 거겠지만...🥹

새로고침 시 1~2초 내에 웹소켓이 재연결되기 때문에 최대 5초로 설정했다. 그리고 문제의 요구사항은 이벤트 발생 시 일회성으로 스케줄링되는 작업이기 때문에 별도의 스케줄러를 만들어 주기적으로 상태를 확인하기보단 TaskScheduler.schedule() 을 통해 정확히 5초 후 한 번만 실행되도록 보장했다.
구현 자체는 금방 끝났지만 완전히 마음에 드는 방법은 아니라 아쉬움이 남는다. 실력자들은 이런 상황에서 어떻게 처리하는지 너무 궁금하다......물어볼 사람이 있으면 좋을텐데.

0개의 댓글