웹소켓 메시지 미전송 문제

pitseleh·2025년 2월 26일
post-thumbnail

로컬 환경에서는 정상 작동한 코드가 배포 환경에서는 제대로 돌아가지 않는 문제가 발견되었다....

public void afterTeamMatch(String roomId1, String roomId2) {

	// 방 상태 변경
	redisTemplate.opsForValue().set(RedisKeys.TeamRoomStatus(Long.valueOf(roomId1)), "matched");
	redisTemplate.opsForValue().set(RedisKeys.TeamRoomStatus(Long.valueOf(roomId2)), "matched");

	// Redis에서 두 팀을 매칭 중 상태에서 제거
	redisTemplate.opsForZSet().remove(RedisKeys.TEAM_MATCHING_ROOMS, roomId1);
	redisTemplate.opsForZSet().remove(RedisKeys.TEAM_MATCHING_ROOMS, roomId2);

	String matchId = generateMatchId(roomId1, roomId2);
	Map<String, String> matchIdPayload = Map.of("matchId", matchId);

	// 매칭된 방의 matchId를 Redis에 저장
	redisTemplate.opsForValue().set(RedisKeys.TeamMatchId(Long.valueOf(roomId1)), matchId);
	redisTemplate.opsForValue().set(RedisKeys.TeamMatchId(Long.valueOf(roomId2)), matchId);

	roomWebSocketService.sendWebSocketMessage(roomId1, "MATCHED", matchIdPayload, BattleType.T);
	roomWebSocketService.sendWebSocketMessage(roomId2, "MATCHED", matchIdPayload, BattleType.T);
	log.info("matchId = {}", matchId);

	// 문제 및 테스트케이스 조회
	List<Long> problemIds = problemRepository.findAllProblemIds();
	if (problemIds.isEmpty()) {
		throw new ProblemNotFoundException();
	}
	Long randomProblemId = problemIds.get(new Random().nextInt(problemIds.size()));
	Problem problemEntity = problemRepository.findById(randomProblemId)
			.orElseThrow(ProblemNotFoundException::new);

	List<Testcase> testcaseEntities = testcaseRepository.findTestcasesByProblemId(problemEntity.getId());

	GameStartForProblemDto problem = GameStartForProblemDto.from(problemEntity);
	List<GameStartForTestcaseDto> testcases = testcaseEntities.stream()
			.map(GameStartForTestcaseDto::from)
			.collect(Collectors.toList());

	// 이후 DB에 저장하기 위해 문제 pk를 redis에 저장
	redisTemplate.opsForValue().set(RedisKeys.TeamRoomProblem(matchId), String.valueOf(randomProblemId));

	// 각 팀의 유저 정보를 가져오는 메소드 호출
	TeamMatchResponseDto teamMatchDto = getTeamMatchResponseDto(Long.parseLong(roomId1), Long.parseLong(roomId2),
													problem, testcases);

	// 두 팀의 정보를 websocket으로 전송 후 카운팅 시작
	roomWebSocketService.sendWebSocketMessage(matchId, "COUNTING_READY", teamMatchDto, BattleType.T);
	startCountingTeam(Long.parseLong(roomId1), Long.parseLong(roomId2), matchId);
}

위의 코드를 배포 환경에서 실행해보니 COUNTING_READY가 전송되지 않았다. 추측할 수 있는 원인으로는 세 가지가 있다.

1. 트랜잭션 종료 전에 WebSocket 메시지가 전송되는 문제

배포 환경에서는 JPA 트랜잭션이 완전히 끝나기 전에 WebSocket 메시지가 전송될 경우, 해당 메시지가 정상적으로 전송되지 않을 수 있다.

즉, 아래 코드에서 COUNTING_READY 이벤트가 전송되기 전에 문제(Problem)와 테스트케이스(Testcase)를 조회하는 로직이 실행되고 있다.

List<Long> problemIds = problemRepository.findAllProblemIds();
if (problemIds.isEmpty()) {
	throw new ProblemNotFoundException();
}
Long randomProblemId = problemIds.get(new Random().nextInt(problemIds.size()));
Problem problemEntity = problemRepository.findById(randomProblemId)
	.orElseThrow(ProblemNotFoundException::new);

List<Testcase> testcaseEntities = testcaseRepository.findTestcasesByProblemId(problemEntity.getId());

트랜잭션이 비동기적으로 실행될 경우

  • JPA에서 데이터 조회 및 트랜잭션이 완료되기 전에 WebSocket 메시지를 보내려고 하면, 해당 메시지가 무시되거나 전송되지 않을 수 있음
  • 특히 배포 환경에서는 로컬과 다르게 트랜잭션 커밋이 지연될 수 있음.
  • WebSocket 서비스가 DB 트랜잭션과 별개로 실행되기 때문에, COUNTING_READY 메시지가 전송될 시점에 필요한 데이터가 존재하지 않으면, 메시지가 유실될 가능성이 있음

2. Redis 동기화 지연 문제

afterTeamMatch()에서는 Redis에 값을 저장하고, 그 값을 기반으로 WebSocket 메시지를 보낸다.
그런데 배포 환경에서는 Redis 연산이 비동기적으로 처리될 가능성이 크다.

redisTemplate.opsForValue().set(RedisKeys.TeamRoomProblem(matchId), String.valueOf(randomProblemId));

즉, 위의 코드가 실행된 직후 COUNTING_READY 메시지를 전송하는데, Redis에 값이 저장되기 전에 WebSocket 메시지가 먼저 전송될 경우, 클라이언트가 필요한 데이터를 읽지 못해서 메시지가 무시될 수 있는 것이다.


3. 네트워크 지연 문제로 인해 구독 전 메시지 전송

클라이언트는 MATCHED 이벤트를 받으면 /sub/team/room/{matchId} 채널을 구독해야 한다. 그 후 서버는 /sub/team/room/{matchId} 채널로 COUNTING_READY 이벤트를 전송한다.
로컬 환경에서는 네트워크 지연이 거의 없어서 클라이언트가 MATCHED 이벤트를 받고 새 채널을 구독하는 데 충분한 시간이 있었을 수 있다. 하지만 배포 환경에서는 네트워크 지연이나 서버 부하 등으로 인해 클라이언트가 MATCHED 이벤트를 처리하고 새 채널을 구독하기 전, 서버가 이미 COUNTING_READY 이벤트를 해당 채널로 전송했을 가능성이 존재한다.


솔직히 아직도 정확한 원인이 무엇인지 확신하지 못했다. 프로젝트 마감 기한이 코앞이라 프론트엔드 담당 팀원을 위해 빨리 문제를 해결해야 했기에 계속 붙잡고 있을 수 없었다....🥹 임시 방편으로 아래와 같이 약간의 지연을 두었더니 의도한 대로 동작하긴 했다.

// 2초 후 COUNTING_READY 이벤트 전송
CompletableFuture.delayedExecutor(2, TimeUnit.SECONDS).execute(() -> {
	roomWebSocketService.sendWebSocketMessage(matchId, "COUNTING_READY", teamMatchDto, BattleType.T);
	startCountingTeam(Long.parseLong(roomId1), Long.parseLong(roomId2), matchId);
});

3번이 원인일 경우 클라이언트가 새 채널을 구독한 후 서버에 확인 메시지를 보내도록 하고, 서버는 이 확인을 받은 후에 COUNTING_READY 이벤트를 전송하는 방식을 사용할 수 있다.
시간에 쫓겨 얼렁뚱땅 처리한 느낌이라 아쉬움이 아주 많이 남는 부분이다. 프로젝트가 끝난 지금 테스트하면서 원인을 찾기도 힘들고....

0개의 댓글