1. 서론 (Introduction)
1.1 프로젝트 배경
기존의 오래된 메시징 서비스 구조는 변화하는 웹 환경에 대응하기 어렵고, 유지보수 측면에서 많은 아쉬움이 있었습니다. 본 프로젝트 'Loopin'은 이러한 Smalltalk 시스템을 걷어내고, 최신 Vizend 플랫폼으로 재구축(Refactoring)하는 것을 목표로 시작되었습니다.
메신저 서비스의 핵심 가치는 '사용자 간 단절 없는 실시간 소통 경험'에 있습니다. 이를 달성하기 위해 당면한 기술적 과제는 크게 두 가지였습니다. 첫째, 기존 HTTP 프로토콜의 단방향 요청-응답 모델에서 벗어나 클라이언트와 서버가 영속적인 커넥션을 맺고 지연 없이 메시지를 송수신할 수 있는 메시징 채널을 구축하는 것. 둘째, 협업과 소통의 효율성을 극대화하기 위해 사용자의 현재 접속 여부를 실시간으로 판별하여 화면에 뿌려주는 접속 상태 관리(Presence) 엔진을 구현하는 것이었습니다.
1.2 프로젝트 목표
기술을 선택할 때 가장 경계해야 할 부분은 바로 '오버엔지니어링(Overengineering)'입니다. 대규모 글로벌 트래픽을 견뎌야 하는 아키텍처가 아님에도 불구하고, 유행에 이끌려 Redis, Kafka 등 다수의 복잡한 분산 솔루션을 무조건 인프라에 추가하는 것은 운영 부담과 배포 난이도, 그리고 클라우드 비용의 불필요한 상승을 초래합니다. Loopin 프로젝트는 '단순함과 효율성(KISS - Keep It Simple, Stupid)'을 핵심 가치로 삼았습니다. 외부 인프라 요소를 최대한 배제하고, 서비스의 메인 저장소인 관계형 데이터베이스(RDB)만을 다각도로 활용하여 실시간 Presence와 메시지 수명 주기를 완전하게 통제하는 것을 최우선 목표로 정의했습니다.
2. 시스템 아키텍처 개요 (System Architecture)
Loopin 메신저의 데이터 흐름은 불필요한 레이어를 생략하고 명확한 책임 분할을 기반으로 합니다. 실시간성을 보장하는 통신 계층과, 데이터의 무결성을 담보하는 영속성 계층이 완벽히 동기화되어 움직입니다.
[그림 1] Loopin 시스템 아키텍처 및 웹소켓 기반 데이터 흐름도
-
React Frontend: STOMP 프로토콜을 사용해 서버와의 커넥션을 단 한 번 맺은 후, 브라우저가 백그라운드에서 상시 대기합니다. 사용자의 입력이 이루어지면 비동기로 메시지를 전송하고, 수신된 이벤트를 컴포넌트 단위로 리렌더링하여 사용자에게 즉각 피드백을 제공합니다.
-
Spring Boot Backend: 웹소켓 엔드포인트 수용, JWT 토큰을 통한 연결 시점의 보안 검증(Interceptor), STOMP 주소 체계를 활용한 라우팅 제어, 내부 수명 주기 이벤트(Event Listener) 처리를 담당합니다.
-
Relational Database: 영속적인 채팅 메시지뿐만 아니라, 실시간 접속 제어 스키마를 내부에 수용하여 세션 상태와 세션 이력 정보를 안정적으로 보존합니다.
3. 웹소켓(WebSocket)과 STOMP를 이용한 실시간 메시징
3.1 STOMP 서브 프로토콜의 도입 배경
HTML5 표준 스펙인 Raw WebSocket은 TCP 소켓과 유사하게 양방향 통신 터널만 열어줄 뿐, 데이터의 구체적인 형식이나 목적지를 지정하는 규격이 존재하지 않습니다. 즉, 클라이언트와 서버가 주고받는 문자열이 채팅 데이터인지, 방에 입장하겠다는 선언인지, 에러 메시지인지를 구분하기 위해 개발자가 고유의 텍스트 파싱 규칙을 정의하고 커스텀 핸들러를 가공해야 합니다. 이는 결함이 발생하기 쉽고 아키텍처를 복잡하게 만듭니다.
Loopin은 이러한 낭비를 줄이기 위해 웹소켓 프레임 위에서 동작하는 STOMP(Simple Text Oriented Messaging Protocol) 서브 프로토콜을 전면 도입했습니다. STOMP는 커맨드(COMMAND), 헤더(Headers), 바디(Body) 형태로 정형화된 프레임 구조를 지니고 있어 메시지 기반 시스템의 라우팅 매핑을 획기적으로 직관화해 줍니다.
3.2 Spring Boot 웹소켓 인프라 구성 소스코드
Spring Boot에서 STOMP 기반의 내장 메시지 브로커를 활성화하고 엔드포인트를 매핑하기 위한 실제 구현 코드는 다음과 같습니다.
@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. 데이터베이스(DB) 기반의 Presence(접속 상태) 기능 설계
4.1 추가 인프라(Redis) 배제의 타당성과 RDB 선택의 아키텍처적 의도
많은 개발 가이드에서 실시간 세션 데이터는 소멸성이 강하다는 이유로 Redis 같은 인메모리 Key-Value NoSQL 데이터베이스에 저장할 것을 권장하곤 합니다. 하지만 Loopin 프로젝트의 규모와 도메인 특성을 면밀히 파악했을 때 이는 명백한 자원 낭비였습니다. 관계형 데이터베이스(RDB)만을 활용해 구조를 설계한 구체적인 이유는 다음과 같습니다.
첫째, 단순한 배포 아키텍처의 유지보수 편의성입니다. 단일 인스턴스 또는 주-부(Primary-Replica) 구조의 기존 DB 구성을 그대로 활용하면 모니터링 경보 체계, 백업 정책 등을 일원화할 수 있습니다. 둘째, 강한 데이터 무결성 및 통계 이력 추적성입니다. 사용자가 언제 로그인하고 로그아웃했는지에 대한 히스토리는 단순 상태값 이상의 의미를 지닙니다. 시스템 장애나 보안 감사 시 세션 로그 이력이 필수적인데, RDB는 관계적 스키마와 인덱스를 활용해 정밀한 시계열 로그 분석 쿼리를 수용할 수 있습니다. 셋째, 도메인 데이터와의 결합 편리성입니다. '내 친구 목록 중 현재 접속 중인 회원만 상단에 정렬'과 같은 요구사항을 구현할 때, Redis와 RDB에 데이터가 찢어져 있다면 애플리케이션 레이어에서 두 번의 IO를 발생시키고 수동 메모리 조인을 해야 하지만, RDB 단일 환경에서는 복잡한 구조 없이 간단한 JOIN 또는 IN 서브쿼리 연산으로 밀리초 이내에 처리가 가능합니다.
4.2 데이터베이스 스키마 모델링
-- 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 이벤트 핸들러 구현
Loopin Presence 테이블의 활성 카운트를 실시간 검증하는 방식입니다.
@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 & Retrospective)
기존 Smalltalk 메신저 시스템을 최신 Vizend 플랫폼의 Loopin 메신저 서비스 재구축 작업은 비대하고 값비싼 외부 분산 캐시 레이어 없이도, 복합 인덱스 전략과 영리하게 짜인 상호 검증용 세션 로그 테이블 스키마 디자인만으로 안정적이고 데이터 가시성이 뛰어난 Presence 엔진을 훌륭히 완성해 낼 수 있었습니다.
이번 프로젝트는 WebSocket과 STOMP를 활용해 실시간 메시징의 기본적인 흐름을 직접 구현해 보며, 프레임워크 뒤에서 세션이 어떻게 제어되는지, WebSocket의 세션 단절 예외를 직접 핸들링해 보며 실시간 통신의 흐름 등을 차근차근 이해할 수 있는 좋은 기회였습니다. 화려한 최신 기술을 쫓기보다 현재 요구사항과 비용에 맞춰 데이터베이스만으로 문제를 해결해 보면서, 단순한 아키텍처가 주는 프로젝트 유지보수의 이점을 다시 한번 배울 수 있었습니다.
BigJumbo