Loopin Messenger Development Story

Loopin Messenger Development Story

1. Introduction

1.1 Project Background

The existing structure of old messaging services struggled to adapt to the changing web environment, leading to many maintenance-related shortcomings. This project, 'Loopin', was initiated with the goal of removing this Smalltalk system and rebuilding it on the latest Vizend platform (Refactoring).

The core value of the messenger service lies in the 'real-time communication experience without disruption between users'. The two major technical challenges faced in achieving this were: first, building a messaging channel that allows the client and server to establish a persistent connection and send/receive messages without delay, moving away from the one-way request-response model of the existing HTTP protocol. Second, implementing a presence engine that determines and displays the current online status of users in real-time to maximize collaboration and communication efficiency.

1.2 Project Objectives

The most critical aspect to be wary of when selecting technology is 'overengineering'. Even though it is not an architecture that needs to withstand large-scale global traffic, blindly adding numerous complex distributed solutions like Redis and Kafka to the infrastructure due to trends results in operational burdens, deployment difficulties, and unnecessary increases in cloud costs. The Loopin project has established 'simplicity and efficiency (KISS - Keep It Simple, Stupid)' as its core values. The goal was defined as prioritizing the complete control of real-time presence and message lifecycle by excluding external infrastructure elements as much as possible and multifariously utilizing the relational database (RDB), which serves as the main storage of the service.

2. System Architecture Overview

The data flow of the Loopin messenger is based on clear responsibility division, omitting unnecessary layers. The communication layer, which ensures real-time capability, and the persistence layer, which guarantees data integrity, operate in perfect synchronization.

image1.png

[Figure 1] Loopin System Architecture and WebSocket Based Data Flow

  • React Frontend: After establishing a connection with the server using the STOMP protocol only once, the browser stays in the background, constantly waiting. When user input occurs, it sends messages asynchronously and re-renders the received events at the component level to provide immediate feedback to the user.

  • Spring Boot Backend: Responsible for accepting WebSocket endpoints, security verification of the connection point through JWT tokens (Interceptor), routing control using the STOMP address scheme, and handling internal lifecycle events (Event Listener).

  • Relational Database: Not only preserves persistent chat messages but also houses a real-time access control schema to reliably store session status and session history information.

3. Real-time Messaging Using WebSocket and STOMP

3.1 Background of the Introduction of STOMP Subprotocol

The Raw WebSocket, which is an HTML5 standard specification, only opens a bidirectional communication tunnel similar to TCP sockets without specifying any concrete format or destination for the data. This means developers must define their own text parsing rules and craft custom handlers to distinguish whether the strings exchanged between the client and server are chat data, declarations of entering a room, or error messages. This can easily lead to defects and complicates the architecture.
Loopin has fully adopted the STOMP (Simple Text Oriented Messaging Protocol) subprotocol that operates over WebSocket frames to reduce such waste. STOMP features a structured frame structure in the form of Commands, Headers, and Body, which revolutionizes the intuition of routing mapping in message-based systems.

3.2 Spring Boot WebSocket Infrastructure Configuration Source Code

The actual implementation code for activating the built-in message broker based on STOMP in Spring Boot and mapping endpoints is as follows:

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // 프론트엔드가 핸드셰이크를 요청할 종단점 주소 매핑
        // SockJS 폴백을 적용하여 웹소켓이 차단된 프록시나 구형 브라우저 환경 지원
        registry.addEndpoint("/ws")
                .setAllowedOriginPatterns("*")
                .withSockJS()
                .setSessionCookieNeeded(false);
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/user");
        registry.setUserDestinationPrefix("/user");
        registry.setApplicationDestinationPrefixes("/app");
    }
}

4. Database (DB) Based Presence Function Design

4.1 Justification of Excluding Additional Infrastructure (Redis) and Architectural Intent of RDB Choice

Many development guides recommend storing real-time session data in in-memory Key-Value NoSQL databases like Redis due to their ephemeral nature. However, upon closely examining the scale and domain characteristics of the Loopin project, it was evident that this was a blatant waste of resources. The specific reasons for designing the structure solely using a relational database (RDB) are as follows.

First, the convenience of maintaining a simple deployment architecture. By utilizing the existing DB configuration of a single instance or primary-replica structure, monitoring alarm systems, backup policies, etc., can be centralized. Second, strong data integrity and historical tracking of statistics. The history of when users log in and log out has meanings beyond simple status values. Session log history is essential during system failures or security audits, and RDB can accommodate precise time-series log analysis queries by utilizing relational schemas and indexes. Third, the convenience of coupling with domain data. When implementing requirements like 'sort only currently online members at the top of my friends list,' if the data is split between Redis and RDB, two IOs would occur at the application layer, necessitating manual memory joins. However, in a single RDB environment, it can be processed within milliseconds with simple JOIN or IN subquery operations without complex structures.

4.2 Database Schema Modeling

-- Presence 테이블
CREATE TABLE IF NOT EXISTS active_participant
(
    id             VARCHAR(255) NOT NULL,
    actor_id       VARCHAR(255),
    stage_id       VARCHAR(255),
    pavilion_id    VARCHAR(255),
    entity_version BIGINT       NOT NULL,
    registered_by  VARCHAR(255),
    registered_on  BIGINT       NOT NULL,
    modified_by    VARCHAR(255),
    modified_on    BIGINT       NOT NULL,
    session_id     VARCHAR(255),
    login_time     BIGINT       NOT NULL,
    CONSTRAINT pk_active_participant PRIMARY KEY (id)
);

4.3 Presence Event Handler Implementation

This is a method for verifying the active count of the Loopin Presence table in real time.

@Component
@RequiredArgsConstructor
public class PresenceEventListener {
    //
    private static final String SIMP_CONNECT_MESSAGE_HEADER = "simpConnectMessage";
    private static final String NATIVE_HEADERS = "nativeHeaders";
    private static final String SIMP_USER_HEADER = "simpUser";
    private final PresenceTask presenceTask;
    
    @EventListener
    public void handleSessionConnect(SessionConnectEvent event) {
        SimpMessageHeaderAccessor headers = SimpMessageHeaderAccessor.wrap(event.getMessage());
        log.info("### STOMP CONNECT Attempt ### SessionId: {}", headers.getSessionId());
    }

    @EventListener
    public void handleSessionConnected(SessionConnectedEvent event) {
        //
        SimpMessageHeaderAccessor headers = SimpMessageHeaderAccessor.wrap(event.getMessage());
        log.info("WebSocket Session Connected! SessionId: {}", headers.getSessionId());
        Map<String, ArrayList<String>> nativeHeaders = this.extractNativeHeaders(headers);
        
        if (nativeHeaders == null || nativeHeaders.isEmpty()) {
            throw new IllegalArgumentException("Native headers cannot be found in Socket header.");
        }
        List<String> actorId = nativeHeaders.get(SIMP_USER_HEADER);
        log.info("WebSocket Session Connected! SessionId: {}, ActorId: {}, Headers: {}", headers.getSessionId(), actorId, nativeHeaders);
        LoginEvent loginEvent = new LoginEvent(actorId.getFirst());
        presenceTask.addParticipant(headers.getSessionId(), loginEvent);
    }

    @EventListener
    public void handleSessionDisconnect(SessionDisconnectEvent event) {
        log.info("WebSocket Session Disconnect! sessionId : {}", event.getSessionId());
        presenceTask.removeParticipant(event.getSessionId());
    }

    @SuppressWarnings("unchecked")
    private Map<String, ArrayList<String>> extractNativeHeaders(SimpMessageHeaderAccessor headers) {
        //
        GenericMessage<?> gm = (GenericMessage<?>) headers.getMessageHeaders().get(SIMP_CONNECT_MESSAGE_HEADER);
        return (Map<String, ArrayList<String>>) gm.getHeaders().get(NATIVE_HEADERS);
    }
}

@Service
@Transactional
@RequiredArgsConstructor
public class PresenceTask {
    //
    private final ActiveParticipantLogic activeParticipantLogic;

    public void addParticipant(String sessionId, LoginEvent event) {
        // 1인 1세션 정책: 동일 사용자의 기존 세션 정보를 모두 정리한 후 새 세션 등록
        activeParticipantLogic.removeByActorId(event.getActorId());

        ActiveParticipantCdo cdo = new ActiveParticipantCdo();
        cdo.setActorId(event.getActorId());
        cdo.setSessionId(sessionId);
        cdo.setLoginTime(System.currentTimeMillis());
        activeParticipantLogic.registerActiveParticipant(cdo);
    }

    public LoginEvent getParticipant(String sessionId) {
        ActiveParticipant activeParticipant = activeParticipantLogic.findBySessionId(sessionId);
        if (activeParticipant == null) {
            return null;
        }
        return new LoginEvent(activeParticipant.getActorId());
    }

    public void removeParticipant(String sessionId) {
        ActiveParticipant activeParticipant = activeParticipantLogic.findBySessionId(sessionId);
        if (activeParticipant != null) {
            activeParticipantLogic.removeActiveParticipant(activeParticipant.getId());
        }
    }

    public Map<String, LoginEvent> getActiveSessions() {
        return activeParticipantLogic.findActiveParticipants(null).stream()
                .collect(Collectors.toMap(
                        ActiveParticipant::getSessionId,
                        ap -> new LoginEvent(ap.getActorId())
                ));
    }

    public boolean isCitizenOnline(String citizenId) {
        return activeParticipantLogic.existsByActorId(citizenId);
    }
}

5. Conclusion and Retrospective

The task of rebuilding the existing Smalltalk messenger system into the latest Vizend platform's Loopin messenger service has successfully completed a stable and highly visible Presence engine without a large and costly external distributed cache layer, relying solely on a compound index strategy and cleverly designed mutually verifying session log table schema.

This project was a good opportunity to directly implement the basic flow of real-time messaging using WebSocket and STOMP, understanding how sessions are managed behind the framework, handling WebSocket session disconnection exceptions directly, and gradually grasping the flow of real-time communication. Instead of chasing after flashy latest technologies, solving problems with just a database according to current requirements and costs allowed me to learn once again the benefits of simple architecture in project maintenance.

BigJumbo

Site footer