Skip to content

Conversation

@ki-met-hoon
Copy link
Collaborator

@ki-met-hoon ki-met-hoon commented Feb 17, 2025

Pull request

Related issue

Motivation and context

  • Redis 저장 시 데이터 타입 불일치로 인한 removeAll 연산 실패
    • userActive: Object 타입으로 저장됨 (Redis hash value)
    • onlineSession: String 타입으로 저장됨 (Redis set value)
    • 두 값의 형식 차이로 인해 Set.removeAll() 연산이 정상 동작하지 않음
  • findActiveSessions()에서 반환하는 세션 ID에 따옴표를 추가하여 onlineSession과 동일한 형식으로 통일
  • 이를 통해 Set.removeAll() 연산이 정상적으로 수행됨
  • KafkaConsumer class 내 FCM 발송 로직 추가 필요

Solution

How has this been tested

  • user 1 channel 1 구독 / user 2 channel 2 구독
  • user 2가 메세지를 보냄
image
  • 아래는 클라이언트 코드
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>WebSocket Connection Test</title>
    <style>
        .log-box {
            margin-top: 20px;
            border: 1px solid #ccc;
            padding: 10px;
            width: 500px;
            height: 300px;
            overflow-y: scroll;
            font-family: monospace;
        }
        .success { color: green; }
        .error { color: red; }
        .info { color: blue; }
        .chat-message {
            margin: 5px 0;
            padding: 5px;
            border-bottom: 1px solid #eee;
        }
        .message-nickname {
            font-weight: bold;
            margin-right: 10px;
        }
    </style>
</head>
<body>
    <h1>WebSocket Connection Test</h1>
    
    <div>
        <h3>Connection Test</h3>
        <input id="userId" type="text" placeholder="Enter user ID" />
        <button onclick="connect()">Connect</button>
        <button onclick="disconnect()">Disconnect</button>
        <button onclick="connectWithoutUserId()">Connect Without UserId</button>
    </div>

    <div>
        <h3>Channel Test</h3>
        <input id="channelId" type="text" placeholder="Enter channel ID" />
        <button onclick="subscribe()">Subscribe</button>
        <button onclick="unsubscribe()">Unsubscribe</button>
    </div>

    <div>
        <h3>Send Message</h3>
        <input id="messageInput" type="text" placeholder="Enter your message" />
        <button onclick="sendMessage()">Send Message</button>
    </div>

    <div>
        <h3>Connection Log:</h3>
        <div id="connection-log" class="log-box"></div>
    </div>

    <div>
        <h3>Chat Messages:</h3>
        <div id="chat-box" class="log-box"></div>
    </div>

    <script src="https://cdn.jsdelivr.net/npm/sockjs-client/dist/sockjs.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/stompjs/lib/stomp.min.js"></script>
    <script>
        let stompClient = null;
        let subscription = null;

        function log(message, type = 'info') {
            const logBox = document.getElementById('connection-log');
            const logEntry = document.createElement('div');
            logEntry.classList.add(type);
            logEntry.textContent = `${new Date().toLocaleTimeString()} - ${message}`;
            logBox.appendChild(logEntry);
            logBox.scrollTop = logBox.scrollHeight;
        }

        function connect() {
            const userId = document.getElementById("userId").value;
            
            if (!userId) {
                alert("User ID is required!");
                return;
            }

            const socket = new SockJS("http://localhost:8084/ws-connect");
            stompClient = Stomp.over(socket);

            const headers = {
                'X-User-ID': userId
            };

            stompClient.connect(headers, function (frame) {
                log("Connected: " + frame, 'success');
            }, function (error) {
                log("Connection error: " + error, 'error');
            });
        }

        function connectWithoutUserId() {
            const socket = new SockJS("http://localhost:8084/ws-connect");
            stompClient = Stomp.over(socket);
            
            stompClient.debug = function(str) {
                log(str, 'info');
            };

            log('Attempting to connect without userId');
            
            stompClient.connect({}, 
                frame => {
                    log(`Connected successfully: ${frame}`, 'success');
                }, 
                error => {
                    log(`Connection error: ${error}`, 'error');
                }
            );
        }

        function disconnect() {
            if (subscription) {
                unsubscribe();
            }
            
            if (stompClient) {
                stompClient.disconnect(() => {
                    log('Disconnected from WebSocket', 'info');
                });
                stompClient = null;
            }
        }

        function subscribe() {
            log(`Connection state check - stompClient: ${stompClient !== null}, connected: ${stompClient?.connected}`);
    
            if (!stompClient) {
                log('StompClient is null', 'error');
                return;
            }
            
            if (!stompClient.connected) {
                log('WebSocket is not connected', 'error');
                return;
            }

            const channelId = document.getElementById("channelId").value;

            if (!channelId) {
                log('Channel ID is required!', 'error');
                return;
            }

            if (subscription) {
                log('Already subscribed to a channel', 'error');
                return;
            }

            log(`Attempting to subscribe to channel: ${channelId}`);

            subscription = stompClient.subscribe(
                `/subscribe/chat.${channelId}`,
                message => {
                    log(`Received message: ${message.body}`, 'info');
                    displayChatMessage(message.body);
                }
            );

            log(`Subscribed to channel: ${channelId}`, 'success');
        }

        function unsubscribe() {
            if (!subscription) {
                log('No active subscription', 'error');
                return;
            }

            subscription.unsubscribe();
            log('Unsubscribed from channel', 'info');
            subscription = null;
        }

        function sendMessage() {
            if (!stompClient || !stompClient.connected) {
                log('WebSocket is not connected', 'error');
                return;
            }

            if (!subscription) {
                log('Not subscribed to any channel', 'error');
                return;
            }

            const message = document.getElementById("messageInput").value;
            const userId = document.getElementById("userId").value;
            const channelId = document.getElementById("channelId").value;

            if (!message) {
                log('Message cannot be empty', 'error');
                return;
            }

            const chatMessage = {
                userId: parseInt(userId, 10),
                content: message,
                attachmentList: []
            };

            try {
                stompClient.send(`/publish/chat.${channelId}`, {}, JSON.stringify(chatMessage));
                document.getElementById("messageInput").value = '';
                log('Message sent successfully', 'success');
            } catch (error) {
                log(`Failed to send message: ${error}`, 'error');
            }
        }

        function displayChatMessage(messageData) {
            try {
                const chatBox = document.getElementById('chat-box');
                const data = JSON.parse(messageData);
                const messages = data.chatMessage || [];

                messages.forEach(msg => {
                    const messageDiv = document.createElement('div');
                    messageDiv.className = 'chat-message';

                    if (msg.userNickname) {
                        const nicknameSpan = document.createElement('span');
                        nicknameSpan.className = 'message-nickname';
                        nicknameSpan.textContent = msg.userNickname;
                        messageDiv.appendChild(nicknameSpan);
                    }

                    if (msg.content) {
                        const contentSpan = document.createElement('span');
                        contentSpan.textContent = msg.content;
                        messageDiv.appendChild(contentSpan);
                    }

                    if (msg.url && !msg.urlThumbnail) {
                        const img = document.createElement('img');
                        img.src = msg.url;
                        img.style.maxWidth = '200px';
                        img.style.display = 'block';
                        img.style.marginTop = '5px';
                        messageDiv.appendChild(img);
                    }

                    if (msg.urlThumbnail && msg.url) {
                        const link = document.createElement('a');
                        link.href = msg.url;
                        link.target = '_blank';
                        
                        const thumbnail = document.createElement('img');
                        thumbnail.src = msg.urlThumbnail;
                        thumbnail.style.maxWidth = '200px';
                        thumbnail.style.display = 'block';
                        thumbnail.style.marginTop = '5px';
                        
                        link.appendChild(thumbnail);
                        messageDiv.appendChild(link);
                    }

                    chatBox.appendChild(messageDiv);
                });

                chatBox.scrollTop = chatBox.scrollHeight;
            } catch (error) {
                log(`Error displaying message: ${error}`, 'error');
            }
        }

        // Cleanup on page unload
        window.onbeforeunload = function() {
            if (stompClient) {
                disconnect();
            }
        };
    </script>
</body>
</html>

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

Checklist

  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have read the docs/CONTRIBUTING.md document.
  • I have added tests to cover my changes.
  • All new and existing tests passed.

@ki-met-hoon ki-met-hoon added ✨ Feature 기능 추가 📦 Environment 개발 환경 세팅 🎮 BE 백엔드 무조건 스프린트내에 해야하는 것들 labels Feb 17, 2025
@ki-met-hoon ki-met-hoon self-assigned this Feb 17, 2025
@netlify
Copy link

netlify bot commented Feb 17, 2025

Deploy Preview for jootalkpia canceled.

Name Link
🔨 Latest commit d59a4aa
🔍 Latest deploy log https://app.netlify.com/sites/jootalkpia/deploys/67b36e4fa7c7d60008c7933a

Copy link
Member

@bo-ram-bo-ram bo-ram-bo-ram left a comment

Choose a reason for hiding this comment

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

고생하셨습니다!

Comment on lines +26 to +28
JsonNode rootNode = mapper.readTree(kafkaMessage);
JsonNode commonNode = rootNode.get("common");
JsonNode messagesNode = rootNode.get("message");
Copy link
Member

Choose a reason for hiding this comment

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

chatMessageToKafka.getCommon()
chatMessageToKafka.getMessage()

로는 안될까요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

이 부분에 대해선 FCM 발송 로직 구현 때 테스트 해보겠습니다!

if (userActiveSessions != null) {
Set<String> sessions = convertToSet(userActiveSessions);
sessions.forEach(session ->
activeSessions.add("\"" + session + "\""));
Copy link
Member

Choose a reason for hiding this comment

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

redis가 string인데 해당처럼 변환을 해줘야할까요?
activeSessions.add(session);
이렇게도 가능하지않을까요?

Copy link
Collaborator Author

@ki-met-hoon ki-met-hoon Feb 18, 2025

Choose a reason for hiding this comment

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

  • Redis 저장 시 데이터 타입 불일치로 인한 removeAll 연산 실패
    • userActive: Object 타입으로 저장됨 (Redis hash value)
    • onlineSession: String 타입으로 저장됨 (Redis set value)

따라서 두 값의 형식 차이로 인해 Set.removeAll() 연산이 정상 동작하지 않았기 때문에 변환을 했습니다!
보람님 방식으로 처음에 구현했으나 필터링이 되지 않아 변경했습니다!

Copy link
Member

Choose a reason for hiding this comment

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

아아 이해했습니다! 피알 내용이 이부분인지 이해를 못했었네요 고생하셨어요!

implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.kafka:spring-kafka'
implementation group: 'com.google.code.gson', name: 'gson', version: '2.8.6'
Copy link
Member

Choose a reason for hiding this comment

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

아래에서 mapper를 사용하시던데 그렇다면 gson을 사용하시는 부분 있으신가요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Kafka Producer에서 사용하고 있습니다!

Claude가 objectMapper와의 차이점을 다음과 같이 알려줬습니다!

ObjectMapper가 더 빠른 성능을 보이고 다양한 기능을 제공하는 반면, Gson은 더 간단하고 직관적인 사용법이 특징입니다.
Gson의 가장 큰 장점은 사용하기 쉽고 학습 곡선이 낮다는 점입니다. 단일 jar 파일로 제공되어 의존성 관리가 쉽고, null 값 처리도 자동으로 해주어 별도 설정이 필요 없습니다. 또한 라이브러리 크기가 작아 전체 애플리케이션 크기를 줄일 수 있습니다.
결국 선택은 프로젝트의 요구사항에 따라 달라집니다. 간단한 JSON 처리와 빠른 개발이 중요하다면 Gson을, 고성능과 세밀한 제어가 필요하다면 ObjectMapper를 사용하는 것이 좋습니다.

Kafka Produce 시에는 Json 데이터를 단순히 String으로 변환만 하기 때문에 gson도 괜찮아 보여서 계속 사용하고 있었습니다!

@ki-met-hoon ki-met-hoon merged commit bf33b4c into dev Feb 18, 2025
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

무조건 스프린트내에 해야하는 것들 🎮 BE 백엔드 📦 Environment 개발 환경 세팅 ✨ Feature 기능 추가

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FCM 메시지를 받을 유저 필터링 로직 구현

3 participants