Home [football] 웹소켓을 직접 활용한 채팅 서비스 구현
Post
Cancel

[football] 웹소켓을 직접 활용한 채팅 서비스 구현

# 문제점


Scale Out을 고려한 아키텍처 설계 를 위해 STOMP의 pub/sub 방식의 메세징 기법을 사용하지 않고 웹소켓을 직접 핸들링해 채팅 기능을 구현해야 했습니다.

# 해결방안


제가 구성한 채팅 기능 로직의 흐름은 다음과 같고 순서대로 구현 코드를 알아보겠습니다.

  1. API 서버에서 메세지 전송에 대한 요청을 받는다.

  2. 요청 정보에 담긴 채팅방 정보를 통해 수신자 목록을 조회한다.

  3. 조회된 수신자 목록별로 메세지 푸시 메소드를 실행한다.

  4. 웹소켓에 접속하지 않은 수신자일 경우, FCM에 푸시알림 전송 요청을 보낸다.

  5. 웹소켓에 접속한 수신자일 경우, API 서버가 채팅 서버로 메세지 전송 요청을 보내 메세지를 해당 수신자에게 전송한다.

추가로 웹소켓에 접속한지 여부를 판단하기 위해 redis에 웹소켓에 저장된 회원 정보와 웹소켓 서버 주소를 따로 저장해두도록 구성한 상태입니다.

1. API 서버에서 메세지 전송에 대한 요청을 받는다.

구현코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class ChatController {
  
  @PostMapping("/send/message")
  public ResponseDto sendMessage(
    @RequestBody SendMessageRequest request, 
    @AuthenticationPrincipal UserDetails user
  ) {
    
    // 메세지 전송에 대한 비즈니스 로직이 선언된 서비스 레이어 메소드 호출
    chatService.sendMessage(
      request.getChannelId(), 
      Integer.parseInt(user.getUsername()), 
      request.getContent()
    );

    return new ResponseDto<>(true, null, "메세지 전송 완료", null);

  }
  
}

Controller 레이어에서 사용자의 메세지 전송에 대한 요청을 REST API를 통해 받아 Service 레이어 메소드를 호출합니다.

2. 요청 정보에 담긴 채팅방 정보를 통해 수신자 목록을 조회한다.

구현코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
public class ChatServiceImpl implements ChatService {

  @Override
  @Transactional
  public void sendMessage(int channelId, int sendUserId, String content) {

    Channel channel = channelRepository.findById(channelId)
        .orElseThrow(() -> new RuntimeException("채팅방 정보가 존재하지 않습니다."));

    User user = userRepository.findById(sendUserId)
        .orElseThrow(() -> new RuntimeException("회원 정보가 존재하지 않습니다."));

    Message message = Message.builder()
        .type(Type.MESSAGE)
        .content(content)
        .createAt(LocalDateTime.now())
        .channel(channel)
        .user(user)
        .build();

    messageRepository.save(message);

    // 수신자 목록을 조회합니다.
    List<Integer> userIdList = findMessageReceivers(channelId);

    for (int receiveUserId : userIdList) {

      if (receiveUserId != sendUserId) {

        PushMessageCommand command = PushMessageCommand.builder()
            .channelId(channelId)
            .sendUserId(sendUserId)
            .receiveUserId(receiveUserId)
            .content(content)
            .build();

        chatPushService.pushMessage(command);

      }

    }

  }

  // 수신자 목록 조회 메소드
  @Override
  @Transactional(readOnly = true)
  public List<Integer> findMessageReceivers(int channelId) {

    // JPA로 구현되어 있습니다.
    return participantRepository.findAllUserIdByChannelId(channelId);

  }
  
}

ChatServiceImpl에서 채팅방 정보와 송신자 정보에 대한 검증을 거친 이후, 채팅방 정보를 이용해 수신자 정보를 리스트로 조회합니다.

3. 조회된 수신자 목록별로 메세지 푸시 메소드를 실행한다.

구현코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public class ChatServiceImpl implements ChatService {

  @Override
  @Transactional
  public void sendMessage(int channelId, int sendUserId, String content) {

    Channel channel = channelRepository.findById(channelId)
        .orElseThrow(() -> new RuntimeException("채팅방 정보가 존재하지 않습니다."));

    User user = userRepository.findById(sendUserId)
        .orElseThrow(() -> new RuntimeException("회원 정보가 존재하지 않습니다."));

    Message message = Message.builder()
        .type(Type.MESSAGE)
        .content(content)
        .createAt(LocalDateTime.now())
        .channel(channel)
        .user(user)
        .build();

    messageRepository.save(message);

    
    List<Integer> userIdList = findMessageReceivers(channelId);

    // 조회된 수신자 리스트에 포함된 사용자들에게 한명씩 푸시 message 메소드를 실행합니다.
    for (int receiveUserId : userIdList) {

      if (receiveUserId != sendUserId) {

        PushMessageCommand command = PushMessageCommand.builder()
            .channelId(channelId)
            .sendUserId(sendUserId)
            .receiveUserId(receiveUserId)
            .content(content)
            .build();

        // 푸시 message 메소드
        chatPushService.pushMessage(command);

      }

    }

  }
  
}

조회된 리스트에 저장된 회원 한명씩 반복문을 통해 메세지 푸시 로직을 수행합니다. 메세지 푸시 로직은 ChatPushService에 구분해 구현했습니다.

4. 웹소켓에 접속한 수신자가 아닐 경우

구현코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class ChatPushServiceImpl implements ChatPushService {

  private final RedisService redisService;

  private final RestTemplate restTemplate;
  
  @Override
  public void pushMessage(PushMessageCommand command) {

    // Redis에서 수신자 정보를 통해 웹소켓에 연결되어 있는지 여부를 파악합니다.
    String address = redisService.getWebSocketSession(
        PREFIX_KEY + command.getReceiveUserId()
    );

    if (address == null) {

      // 웹소켓에 접속중이지 않은 경우는 FCM을 통해 푸시 알림을 보냅니다.
      log.info(command.getReceiveUserId() + "님에게 푸시 알림을 보냅니다.");

    } else {

      String uri = "http://" + address + "/ws/send/message";

      restTemplate.postForObject(uri, command, ResponseDto.class);

      log.info(command.getReceiveUserId() + "님에게 메세지 전송이 완료되었습니다.");

    }

  }

}

만약 Redis에서 조회된 웹소켓 접속 정보가 없다면 FCM에 메세지 푸시알림 요청을 보냅니다.

현재 코드에서는 실제 FCM에 동작하도록 구현하지 않고 log만 남기도록 구현한 상태입니다.

5. 웹소켓에 접속한 수신자일 경우

구현코드 1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class ChatPushServiceImpl implements ChatPushService {

  private final RedisService redisService;

  private final RestTemplate restTemplate;
  
  
  @Async // 비동기식으로 처리
  @Override
  public void pushMessage(PushMessageCommand command) {

    // Redis에서 수신자 정보를 통해 웹소켓에 연결되어 있는지 여부를 파악합니다.
    String address = redisService.getWebSocketSession(
        PREFIX_KEY + command.getReceiveUserId()
    );

    if (address == null) {

      log.info(command.getReceiveUserId() + "님에게 푸시 알림을 보냅니다.");

    } else {

      // 웹소켓에 접속중인 경우 채팅 서버에 메세지 전송 요청을 보냅니다.
      String uri = "http://" + address + "/ws/send/message";

      restTemplate.postForObject(uri, command, ResponseDto.class);

      log.info(command.getReceiveUserId() + "님에게 메세지 전송이 완료되었습니다.");

    }

  }

}

Redis에서 조회된 웹소켓 연결 정보가 존재하면 채팅 서버에 메세지 전송 요청을 보냅니다.

구현코드 2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@RequestMapping("/ws")
@RequiredArgsConstructor
public class WebSocketController {

  private final HeartBeatService heartBeatService;

  private final SessionService sessionService;

  private final ObjectMapper objectMapper;
  

  @PostMapping("/send/message")
  public ResponseDto sendMessage(@RequestBody SendMessageRequest request) throws Exception {

    // 서버 내부 List에 저장된 수신자의 WebsocketSession 객체를 호출
    WebSocketSession session = sessionService.findSessionByUserId(request.getReceiveUserId());

    // 해당 session에 메세지 전송
    session.sendMessage(new TextMessage(objectMapper.writeValueAsString(request)));

    return new ResponseDto(true, null, "메세지 전송 완료", null);

  }

}

실제 메세지 전송을 위해 채팅 서버에 구현된 WebsocketController에서 해당 수신자에 해당하는 WebsocketSession 정보를 조회하고 메세지를 전송합니다.

# 마치며


이러한 설계 및 구현 과정에서 직접 웹소켓을 조작하기 위한 많은 고민과 시도를 해볼 수 있었고 확장성을 고민한 설계에 맞춰 실제 코드를 작성해보면서 규모의 확장에 안전한 구조로 채팅 기능을 구현할 수 있었습니다.

# 참고자료


  • https://dev-gorany.tistory.com/212
  • https://supawer0728.github.io/2018/03/30/spring-websocket/
This post is licensed under CC BY 4.0 by the author.

[football] 비동기 방식으로 처리되는 메시지 및 푸시알림 전송 로직 구현

[football] 지속적인 Health Check를 활용해 접속 가능 서버 확인이 가능한 설계 구현