hwarrk / hwarrk-back

0 stars 1 forks source link

채팅 기능 구현을 위한 내용 교환 #31

Closed suzhanlee closed 2 weeks ago

suzhanlee commented 1 month ago

우선 맨 처음에는 websocket을 생으로 사용하는게 아니라 spring with stomp를 사용해 message가 동일 규격으로 오도록 만들기 위해 stomp를 사용했습니다.

+--------------+ +-------------------+ +----------------+ 클라이언트 <-----> WebSocket 서버 <-----> Spring Security +--------------+ +-------------------+ +----------------+
                                                         +---------+
                                                         | 메시지  |
                                                         | 핸들러  |
                                                         +---------+
                                                            |
                                                            |
                                                        +--------+
                                                        | RabbitMQ |
                                                        +--------+
                                                            |
                                                            |
                                                    +----------------+
                                                    |   구독자(사용자) |
                                                    +----------------+

우선 위와 같은 형식으로 만들어봤습니다.

websocket server 생성하기

 @Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/topic"); 
        config.setApplicationDestinationPrefixes("/app"); 
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws").withSockJS(); // STOMP 엔드포인트 등록
    }
}
image

topic 으로 보내면 simpleBroker가 내용을 처리하게 되는데, 이를 rabbitMQ로 바꿔서 실행할 수 있습니다. app 의 경우 simpAnnotationMessageBroke 에 보내 채팅의 전송과 받기를 분리할 수 있으나 추후 채팅 내용에 대한 데이터 분석이나 채팅 내용에 대한 검열이 필요 없다면 사용하지 않아도 될 것 같네요

@Controller
public class WebSocketController {

    @MessageMapping("/send")
    @SendTo("/topic/messages")
    public String sendMessage(String message) throws Exception {
        // 메시지를 처리하는 로직 (예: 데이터베이스에 저장 등)
        return message; // 수신자에게 메시지 전송
    }
}

여기서 MessageMapping 를 사용해 messagehandler가 메시지를 받아서 topic path를 기반으로 보내게 해 메시지를 바로 topic의 브로커(rabbitMQ)에게 보내 sub에게 보내게 만들었습니다.

@Document(collection = "user_rooms")
public class UserRoom {
    @Id
    private String id;
    private String userId;
    private String roomId;

    // 생성자, Getter, Setter
}

room 의 경우 위와 같이 만들어 사용자가 어떤 방에 참여했는지 알 수 있도록 했습니다.

public class Message {
    private String roomId;
    private String sender;
    private String content;
    private LocalDateTime timestamp;

    // 생성자, Getter, Setter
}

message도 nosql에 저장하도록 하면 될 것 같네요

@RestController
@RequestMapping("/chat")
public class MessageHistoryController {

    private final MongoTemplate mongoTemplate;

    public MessageHistoryController(MongoTemplate mongoTemplate) {
        this.mongoTemplate = mongoTemplate;
    }

    @GetMapping("/history/{roomId}")
    public List<Message> getChatHistory(@PathVariable String roomId) {
        Query query = new Query();
        query.addCriteria(Criteria.where("roomId").is(roomId));
        query.with(Sort.by(Sort.Direction.ASC, "timestamp"));  
        return mongoTemplate.find(query, Message.class, "messages");
    }
}

이런 식을 하면 메시지 내역도 가져올 수 있을 것 같아요

@RestController
@RequestMapping("/chat")
public class UserRoomController {

    private final UserRoomRepository userRoomRepository;

    public UserRoomController(UserRoomRepository userRoomRepository) {
        this.userRoomRepository = userRoomRepository;
    }

    @PostMapping("/room/{roomId}/join")
    public ResponseEntity<?> joinRoom(@PathVariable String roomId, @RequestBody String userId) {
        if (!userRoomRepository.findByUserIdAndRoomId(userId, roomId).isPresent()) {
            UserRoom userRoom = new UserRoom();
            userRoom.setRoomId(roomId);
            userRoom.setUserId(userId);
            userRoomRepository.save(userRoom);
        }
        return ResponseEntity.ok("User joined the room");
    }

    @GetMapping("/room/{roomId}/participants")
    public List<String> getParticipants(@PathVariable String roomId) {
        List<UserRoom> userRooms = userRoomRepository.findByRoomId(roomId);
        return userRooms.stream().map(UserRoom::getUserId).collect(Collectors.toList());
    }
}

이러면 방에 사용자가 있는지도 볼 수 있어 보이네요

@PostMapping("/room/{roomId}/leave")
public ResponseEntity<?> leaveRoom(@PathVariable String roomId, @RequestBody String userId) {
    userRoomRepository.findByUserIdAndRoomId(userId, roomId)
        .ifPresent(userRoom -> userRoomRepository.delete(userRoom));
    return ResponseEntity.ok("User left the room");
}

삭제도 가능해보입니다.

이를 통해 방 만들기, 방 삭제, 내 방 목록 조회... 모두 가능해보이고 실제 채팅을 보내면 방에 포함된 사람들이 pub-sub 패턴으로 해당 방으로 알람이 갈테니 이를 event로 react에서 받아서 숫자(빨간 숫자-> 메시지 갯수)를 표기 해주는게 가능해 보이네요! -> React는 RabbitMQ를 통해 전송된 알림을 이벤트로 받아 UI에 채팅 알림 숫자를 표시!

kafka 대신 rabbitMQ를 사용한 이유는 rabbitMQ가 낮은 레이턴시를 가지는데 비해 kafka의 경우, 대규모 스트리밍 작업에 특화되어 있지만, 로깅이나 추가적인 채팅에 대한 분석이 필요한게 아니라면 사용자에게는 낮은 레이턴시가 더 좋아 보여 이렇게 적용해봤습니다.

lsh2613 commented 1 month ago

엄청 빨리 정리하셨군요... simpleBroker 대신 rabbitMQ를 사용하여 외부 메시지 브로커를 사용한다는 말씀이신 거죠? 추가로 message에 isRead 컬럼을 통해 읽음 표시(0, 1), 안 읽은 대화 갯수 표시 등을 구할 수 있을 것 같아요

suzhanlee commented 1 month ago

오 isRead 방식 되게 좋은거 같네요 넵 외부 브로커로 생각하고 있었습니다.

lsh2613 commented 1 month ago

생각보다 너무 자세하게 적어주셔서 저는 핵심적인 부분과 장단점 및 선택 이유에 대해서만 정리해봤습니다

채팅방 - RDB

채팅내역 - NoSQL

외부 메시지 브로커

kafka

ActiveMQ

=> 즉, 자바기반의 오픈소스로 효율적이고 쉽지만, ActiveMQ(JMS)를 사용하지 않는 다른 시스템과 통신 불가능

RabbitMQ

결론

kafka에 경우 대용량 처리가 좋다고 하나 ActiveMQ, RabbitMQ에 비해 비교적 복잡한 구성으로, '대용량 처리를 위해 kafka를 꼭 경험해보고 싶다'가 아니라면 굳이라는 느낌.. ActiveMQ도 RabbitMQ에 비해 딱히 좋은 점을 찾을 수 없음. 오히려 rabbitMQ가 자료도 더 많고 간단해 보임

참고

suzhanlee commented 1 month ago

오 그럼 브로커는 rabbitMQ를 사용하는 걸로 하고, room의 경우 rdb에 저장하는 걸로 할까요?

lsh2613 commented 1 month ago

네 좋습니다 그러면 역할은 어떻게 나눌까요?

suzhanlee commented 1 month ago

혹시 푸시 알람 같은 경우는 어느 정도 걸릴까요? 제가 알림을 만들어 본 적이 없어서..., EventListener 이런거 사용해서 끝나면 날라가게 하는걸까요? 제 생각에는 양이 그것도 좀 많은거 같아서, 우선 제가 MQ 사용없이 일단 간단하게만 만들어보고 이야기 해볼까요?

lsh2613 commented 1 month ago

푸시 알람이 카카오 푸시 알림 말씀하시는 거면 이 부분도 아직 자료 조사가 필요하긴 해요. 의도한 기능은 숨고, 크몽처럼 매칭이 성사되거나 서비스 내의 채팅이 오면 이를 카카오톡으로 알림을 받을 수 있도록 하려고 했습니다. 원래 카카오톡 푸시 알림이 api를 사용하려 했는데 설명드리려고 다시 확인해봤는데 제가 원하던 api가 아닌 것 같네요... 이 api를 사용하려면 APNs나 FCM 토큰을 발급받아야 하는데 이는 모바일 기기에서 발급받을 수 있는 걸로 알고 있어요. 저희는 웹 카카오 소셜 로그인 이후에 채팅이 오면 카카오톡으로 알림을 주려고 했었습니다. 모바일 기기 없이 APNs나 FCM 토큰을 발급이 불가능한 걸로 알고 있어요 (사실 이게 가능하면 위 api를 쓸 수 있는데, 제가 조사한 바로는 불가능)

다시 찾아보니 제가 원하던 기능은 https://kakaobusiness.gitbook.io/main/ad/bizmessage/notice-friend#id-1-1에 나와있고 따로 협업사를 통해서 건당 결제로 이루어지네요. 이 부분은 톡방에서 다시 이야기 나눠보겠습니다