블로그로 돌아가기
Engineering

STOMP WebSocket으로 1:1 채팅 만들기 — Spring Boot + Next.js 실전 가이드

raw WebSocket, STOMP, Socket.IO 중 무엇을 선택할지부터 Spring Boot 서버 설정, JWT 인증, 타이핑 인디케이터, 읽음 처리, 낙관적 업데이트, REST 폴백까지 — 썸블링 채팅 구현 코드 중심 가이드

Team Somebling ·
#WebSocket #STOMP #Spring Boot #실시간 채팅

시작 전에: 프로토콜 선택

채팅을 처음 설계할 때 세 가지 선택지를 놓고 비교했습니다.

Raw WebSocket: 브라우저 네이티브, 가장 가볍습니다. 하지만 메시지 라우팅, 구독 관리, 재연결 로직을 모두 직접 구현해야 합니다. 방이 여러 개이고, 타이핑/읽음 같은 다양한 이벤트 타입이 있을 때 금방 복잡해집니다.

Socket.IO: DX(개발자 경험)가 좋고 폴백 처리도 내장되어 있습니다. 문제는 Spring Boot에서 네이티브 지원이 없다는 점입니다. 별도 Node.js 서버를 두거나, 지원이 부족한 Java 라이브러리를 써야 합니다.

STOMP over SockJS: Spring Boot가 공식 지원합니다. 목적지(destination) 기반 라우팅, 구독 패턴이 기본 내장되어 있어 “어떤 메시지를 누구에게 보낼지”를 프레임워크가 처리해줍니다. SockJS가 WebSocket을 지원하지 않는 환경(일부 기업 방화벽 등)에서 HTTP long-polling으로 자동 폴백합니다.

썸블링은 Spring Boot 멀티모듈 구조이고, 이미 Spring Security로 JWT 인증을 처리하고 있어서 STOMP를 선택했습니다.

flowchart TD
    subgraph Client["클라이언트 (Next.js)"]
        SC["@stomp/stompjs<br/>+ SockJS 폴백"]
    end
    subgraph Server["서버 (Spring Boot)"]
        WS["WebSocket Endpoint<br/>/ws"]
        MB["Message Broker<br/>/user, /topic"]
        MH["@MessageMapping<br/>메시지 핸들러"]
    end
    subgraph Infra["인프라"]
        DB[(PostgreSQL<br/>메시지 영구 저장)]
        RD[(Redis<br/>타이핑 · 읽지않음 · 온라인)]
    end

    SC <-->|STOMP over SockJS| WS
    WS --> MB
    WS --> MH
    MH --> DB
    MH --> RD
    MB -->|/user/{id}/queue/messages| SC

Spring Boot: WebSocket 서버 설정

1. 의존성 추가

// aro-chat/build.gradle.kts
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-websocket")
    implementation("org.springframework.boot:spring-boot-starter-data-redis")
}

2. STOMP 엔드포인트 등록

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws")
                .setAllowedOriginPatterns("*")
                .withSockJS();  // SockJS 폴백 활성화
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // 클라이언트가 메시지를 발행할 prefix
        registry.setApplicationDestinationPrefixes("/app");

        // 서버가 메시지를 구독자에게 전달할 prefix
        // /user/{userId}/queue/... 형태로 특정 사용자에게만 전달
        registry.enableSimpleBroker("/user", "/topic");
        registry.setUserDestinationPrefix("/user");
    }
}

3. JWT 인증 연결

STOMP CONNECT 프레임의 헤더에서 토큰을 꺼내 인증합니다.

@Component
public class WebSocketAuthInterceptor implements ChannelInterceptor {

    private final JwtProvider jwtProvider;

    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor accessor =
            MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);

        if (StompCommand.CONNECT.equals(accessor.getCommand())) {
            String authHeader = accessor.getFirstNativeHeader("Authorization");

            if (authHeader != null && authHeader.startsWith("Bearer ")) {
                String token = authHeader.substring(7);
                Long memberId = jwtProvider.getMemberIdFromToken(token);

                // Principal로 등록 → /user/{userId} 라우팅에 사용
                accessor.setUser(new MemberPrincipal(memberId));
            }
        }

        return message;
    }
}
@Configuration
public class WebSocketSecurityConfig extends AbstractSecurityWebSocketMessageBrokerConfigurer {

    @Override
    protected boolean sameOriginDisabled() {
        return true;  // CORS 허용 (SockJS 필요)
    }
}

메시지 발행/구독 패턴

채팅방은 UUID로 식별합니다. DB의 chat_rooms.uuid 컬럼에 저장된 값을 사용해 실제 id를 노출하지 않습니다.

메시지 전송 흐름

클라이언트: /app/chat/{roomUuid}/send 으로 발행

@MessageMapping("/chat/{roomUuid}/send") 메서드 실행

메시지 저장 (DB)

채팅방 참여자에게 개별 전달
/user/{memberId}/queue/messages
sequenceDiagram
    participant C as Client
    participant H as @MessageMapping
    participant DB as PostgreSQL
    participant Q as /user/{id}/queue

    C->>H: STOMP SEND<br/>/app/chat/{roomUuid}/send
    H->>H: JWT 검증 · 권한 확인
    H->>DB: 메시지 저장
    H->>Q: 채팅방 참여자별 전송
    Q-->>C: MESSAGE (발신자)
    Q-->>C: MESSAGE (수신자)
@Controller
@RequiredArgsConstructor
public class ChatMessageController {

    private final ChatMessageService chatMessageService;
    private final SimpMessagingTemplate messagingTemplate;

    @MessageMapping("/chat/{roomUuid}/send")
    public void sendMessage(
            @DestinationVariable String roomUuid,
            @Payload SendMessageRequest request,
            Principal principal) {

        Long senderId = ((MemberPrincipal) principal).getMemberId();

        // DB 저장 + 채팅방 참여자 조회
        ChatMessageResult result = chatMessageService.saveAndBroadcast(
            roomUuid, senderId, request.getContent(), request.getType()
        );

        // 채팅방의 모든 참여자에게 전송
        for (Long receiverId : result.getParticipantIds()) {
            messagingTemplate.convertAndSendToUser(
                receiverId.toString(),
                "/queue/messages",
                result.getMessage()
            );
        }
    }
}

타이핑 인디케이터: Redis TTL 5초

“입력 중…” 표시는 Redis를 활용하면 깔끔하게 구현할 수 있습니다. 키에 5초 TTL을 걸면 별도 “입력 중 종료” 이벤트 없이 자동으로 만료됩니다.

@MessageMapping("/chat/{roomUuid}/typing")
public void handleTyping(
        @DestinationVariable String roomUuid,
        @Payload TypingRequest request,
        Principal principal) {

    Long memberId = ((MemberPrincipal) principal).getMemberId();
    Long chatRoomId = chatRoomService.findIdByUuid(roomUuid);

    if (request.isTyping()) {
        // chat:typing:{roomId}:{memberId} → TTL 5초
        String key = String.format("chat:typing:%d:%d", chatRoomId, memberId);
        redisTemplate.opsForValue().set(key, "1", Duration.ofSeconds(5));
    } else {
        String key = String.format("chat:typing:%d:%d", chatRoomId, memberId);
        redisTemplate.delete(key);
    }

    // 상대방에게 타이핑 이벤트 전달
    Long partnerId = chatRoomService.getPartnerId(chatRoomId, memberId);
    messagingTemplate.convertAndSendToUser(
        partnerId.toString(),
        "/queue/typing",
        new TypingEvent(roomUuid, memberId, request.isTyping())
    );
}

프론트에서는 입력 이벤트에 디바운스를 걸어 과도한 전송을 방지합니다.

// 입력 시작 → 타이핑 true 전송
// 1.5초 동안 입력 없으면 → 타이핑 false 전송
const debouncedStopTyping = useMemo(
    () => debounce(() => stompClient.sendTyping(roomId, false), 1500),
    [roomId]
);

const handleInput = () => {
    if (!isTyping) {
        stompClient.sendTyping(roomId, true);
        setIsTyping(true);
    }
    debouncedStopTyping();
};

읽음 처리: Redis 카운터 + 이벤트

읽지 않은 메시지 수는 Redis에 저장하고, 채팅방 입장 시 초기화합니다.

// 메시지 수신 시 카운터 증가
public void incrementUnread(Long chatRoomId, Long memberId) {
    String key = String.format("chat:unread:%d:%d", chatRoomId, memberId);
    redisTemplate.opsForValue().increment(key);
}

// 채팅방 입장 (읽음 처리)
@MessageMapping("/chat/{roomUuid}/read")
public void markAsRead(@DestinationVariable String roomUuid, Principal principal) {
    Long memberId = ((MemberPrincipal) principal).getMemberId();
    Long chatRoomId = chatRoomService.findIdByUuid(roomUuid);

    // Redis 카운터 초기화
    String key = String.format("chat:unread:%d:%d", chatRoomId, memberId);
    redisTemplate.delete(key);

    // 상대방에게 읽음 이벤트 전달
    Long partnerId = chatRoomService.getPartnerId(chatRoomId, memberId);
    messagingTemplate.convertAndSendToUser(
        partnerId.toString(),
        "/queue/read-receipts",
        new ReadReceiptEvent(roomUuid, "READ")
    );
}

프론트엔드: @stomp/stompjs 설정

// lib/websocket.ts
import { Client, StompSubscription } from '@stomp/stompjs';
import SockJS from 'sockjs-client';

let stompClient: Client | null = null;

export function connectStomp(
    token: string,
    onMessage: (msg: ChatMessage) => void,
    onTyping: (event: TypingEvent) => void,
    onReadReceipt: (event: ReadReceiptEvent) => void
) {
    stompClient = new Client({
        // SockJS 팩토리 함수
        webSocketFactory: () => new SockJS(`${process.env.NEXT_PUBLIC_WS_URL}`),

        // CONNECT 프레임에 JWT 포함
        connectHeaders: {
            Authorization: `Bearer ${token}`,
        },

        // 하트비트: 10초
        heartbeatIncoming: 10000,
        heartbeatOutgoing: 10000,

        // 재연결: 5초 간격
        reconnectDelay: 5000,

        // 재연결 시 최신 토큰 사용
        beforeConnect: () => {
            const latestToken = localStorage.getItem('accessToken');
            stompClient!.connectHeaders = {
                Authorization: `Bearer ${latestToken}`,
            };
        },

        onConnect: () => {
            stompClient!.subscribe('/user/queue/messages', (frame) => {
                onMessage(JSON.parse(frame.body));
            });
            stompClient!.subscribe('/user/queue/typing', (frame) => {
                onTyping(JSON.parse(frame.body));
            });
            stompClient!.subscribe('/user/queue/read-receipts', (frame) => {
                onReadReceipt(JSON.parse(frame.body));
            });
        },
    });

    stompClient.activate();
}

export function sendMessage(roomId: string, content: string, type = 'TEXT') {
    if (!stompClient?.connected) return false;

    stompClient.publish({
        destination: `/app/chat/${roomId}/send`,
        body: JSON.stringify({ content, type }),
    });
    return true;
}

낙관적 업데이트

메시지를 먼저 UI에 표시하고, 서버에서 실제 ID가 담긴 응답이 오면 교체합니다. 사용자 입장에서는 메시지가 즉시 보입니다.

// Zustand store
const sendMessage = (roomId: string, content: string) => {
    const tempId = `temp-${Date.now()}`;

    // 1. 임시 메시지 즉시 추가 (optimistic)
    const optimisticMsg: ChatMessage = {
        messageId: tempId as any,
        content,
        type: 'TEXT',
        status: 'SENDING',
        senderId: currentUserId,
        createdAt: new Date().toISOString(),
    };

    set((state) => ({
        messages: {
            ...state.messages,
            [roomId]: [...(state.messages[roomId] ?? []), optimisticMsg],
        },
    }));

    // 2. STOMP로 전송 → /user/queue/messages 로 실제 메시지 수신
    const sent = stompClient.sendMessage(roomId, content);

    // 3. WebSocket 실패 시 REST 폴백
    if (!sent) {
        chatApi.sendMessage(roomId, content).then((realMsg) => {
            set((state) => ({
                messages: {
                    ...state.messages,
                    [roomId]: state.messages[roomId].map((m) =>
                        m.messageId === (tempId as any) ? realMsg : m
                    ),
                },
            }));
        });
    }
};

서버에서 실제 메시지가 /user/queue/messages로 오면 tempId를 찾아 교체합니다.

onMessage: (msg) => {
    set((state) => {
        const roomMessages = state.messages[msg.roomUuid] ?? [];

        // optimistic 메시지가 있으면 교체, 없으면 추가
        const hasTemp = roomMessages.some((m) => m.status === 'SENDING');
        const updated = hasTemp
            ? roomMessages.map((m) => (m.status === 'SENDING' ? msg : m))
            : [...roomMessages, msg];

        return { messages: { ...state.messages, [msg.roomUuid]: updated } };
    });
}

커서 기반 메시지 히스토리

무한 스크롤로 이전 메시지를 로드할 때 오프셋 페이지네이션은 부적합합니다. 메시지가 실시간으로 추가되기 때문에 페이지가 밀립니다. 커서(마지막 메시지 ID) 기반을 사용합니다.

// GET /api/v1/chat/rooms/{roomId}/messages?cursor={lastMessageId}&size=20
public CursorPage<ChatMessageResponse> getMessages(
        String roomUuid, Long cursor, int size, Long requesterId) {

    List<ChatMessage> messages = queryFactory
        .selectFrom(chatMessage)
        .where(
            chatMessage.chatRoom.uuid.eq(roomUuid),
            cursor != null ? chatMessage.id.lt(cursor) : null  // cursor 미만 ID
        )
        .orderBy(chatMessage.id.desc())
        .limit(size + 1)  // 다음 페이지 존재 여부 확인용으로 1개 더
        .fetch();

    boolean hasNext = messages.size() > size;
    if (hasNext) messages.remove(messages.size() - 1);

    Long nextCursor = hasNext ? messages.get(messages.size() - 1).getId() : null;

    return new CursorPage<>(
        messages.stream().map(ChatMessageResponse::from).toList(),
        nextCursor,
        hasNext
    );
}

프론트에서는 스크롤이 상단에 도달하면 다음 페이지를 요청합니다.

const fetchMessages = async (roomId: string, cursor?: number) => {
    const res = await chatApi.getMessages(roomId, cursor, 20);

    set((state) => ({
        messages: {
            ...state.messages,
            // 새로 로드한 메시지를 기존 메시지 앞에 추가
            [roomId]: [...res.content, ...(state.messages[roomId] ?? [])],
        },
    }));

    return res.hasNext;  // 더 로드할 메시지가 있는지 반환
};

REST API 폴백

WebSocket이 끊어진 상황에서도 메시지 전송이 동작해야 합니다. POST /api/v1/chat/rooms/{roomId}/messages로 HTTP 요청을 보내면 서버에서 DB 저장 후, 온라인 사용자에게는 STOMP로 브로드캐스트합니다.

@PostMapping("/api/v1/chat/rooms/{roomId}/messages")
public ApiResponse<ChatMessageResponse> sendMessageRest(
        @PathVariable String roomId,
        @RequestBody SendMessageRequest request,
        @AuthenticationPrincipal MemberPrincipal principal) {

    ChatMessageResult result = chatMessageService.saveAndBroadcast(
        roomId, principal.getMemberId(), request.getContent(), request.getType()
    );

    // 온라인 사용자에게 STOMP로도 전달
    for (Long receiverId : result.getParticipantIds()) {
        messagingTemplate.convertAndSendToUser(
            receiverId.toString(), "/queue/messages", result.getMessage()
        );
    }

    return ApiResponse.success(result.getMessage());
}

마무리

STOMP를 선택한 덕분에 메시지 라우팅과 구독 관리는 프레임워크에게 맡기고, 실제 비즈니스 로직(인증, 읽음 처리, 타이핑 상태)에 집중할 수 있었습니다. Redis TTL을 타이핑 인디케이터에 활용하는 패턴이나 커서 기반 히스토리 로드는 실제 서비스에서 바로 쓸 수 있는 접근법입니다.

낙관적 업데이트와 REST 폴백을 함께 구현하면 네트워크가 불안정한 환경에서도 사용자는 메시지 전송 실패를 거의 체감하지 못합니다. 채팅은 신뢰가 핵심입니다.

댓글

GitHub 계정으로 로그인하여 댓글을 남길 수 있습니다. 허위사실, 욕설, 사칭 등의 댓글은 통보 없이 삭제될 수 있습니다.

블로그로 돌아가기