시작 전에: 프로토콜 선택
채팅을 처음 설계할 때 세 가지 선택지를 놓고 비교했습니다.
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 계정으로 로그인하여 댓글을 남길 수 있습니다. 허위사실, 욕설, 사칭 등의 댓글은 통보 없이 삭제될 수 있습니다.