1. 목적
SpringBoot 기반 프로젝트의 실시간 통신 시스템 구현.
순수 WebSocket과 STOMP, Redis Pub/Sub을 활용해 확장 가능하고 안정적인 실시간 통신을 목표로 한다.
예시로 구현한 사용자별 알림 기능뿐만 아니라, LLM + RAG를 통한 사용자 정보 분석 등 엔드 유저의 대기가 발생하는 모든 기능에 대응할 수 있도록 한다.
2. 주요 기능
1) 실시간 WebSocket 통신
- 순수 WebSocket 기반 연결 (SockJS 미사용)
- STOMP 프로토콜 지원으로 메시지 라우팅
- JWT 쿠키 기반 인증 연동
2) 분산 환경 지원
- Redis Pub/Sub 기반 메시지 브로커
- 서버 간 메시지 전파 지원
- 수평 확장 가능한 아키텍처
3) 실시간 알림 시스템
- 개인별 알림 큐 구독
- 자동 브라우저 알림 연동
- 읽음 상태 관리 지원
3. 기술적 선택과 이점
1) WebSocket
장점
- 네이티브 브라우저 지원으로 높은 성능
- 프로토콜 오버헤드 최소화
- 실시간성 극대화
- 최신 브라우저 환경에서 안정적 동작
고려사항
- 구형 브라우저 지원 제한
- 프록시/방화벽 환경에서 일부 제약
2) STOMP 프로토콜 활용
장점
- 메시지 라우팅 표준화
- 구독/발행 패턴 지원
- Spring Framework 네이티브 지원
- 클라이언트 라이브러리 풍부
주요 기능
- 목적지 기반 메시지 라우팅
- 사용자별 개인 큐 지원
- 메시지 헤더 및 바디 구조화
- 연결/구독 상태 관리
3) Redis Pub/Sub 분산 처리
아키텍처 이점:
- 서버 인스턴스 간 메시지 공유
- 로드밸런서 환경에서 안정적 동작
- 세션 afinity 불필요
- 수평적 확장 지원
4. 시스템 아키텍처
1) 전체 구조도
graph TB
subgraph "Client Layer"
C1[Client 1<br/>WebSocket]
C2[Client 2<br/>WebSocket]
C3[Client N<br/>WebSocket]
end
subgraph "Load Balancer"
LB[Load Balancer<br/>Sticky Session 불필요]
end
subgraph "Application Layer"
A1[API Server 1<br/>WebSocket Handler]
A2[API Server 2<br/>WebSocket Handler]
A3[API Server N<br/>WebSocket Handler]
end
subgraph "Message Layer"
R[Redis Pub/Sub<br/>Message Broker]
end
subgraph "Database Layer"
DB[(MySQL<br/>Notification Data)]
end
C1 -.-> LB
C2 -.-> LB
C3 -.-> LB
LB --> A1
LB --> A2
LB --> A3
A1 <--> R
A2 <--> R
A3 <--> R
A1 --> DB
A2 --> DB
A3 --> DB
2) 계층별 컴포넌트
Layer | Component | Description |
---|
Presentation | NotificationController | 알림 전송 REST API |
WebSocket | WebSocketConfig | WebSocket 설정 및 STOMP 구성 |
Interceptor | JwtHandshakeInterceptor | WebSocket 핸드셰이크 시 JWT 인증 |
Interceptor | AuthChannelInterceptor | STOMP 채널 인바운드 인증 처리 |
Domain | NotificationPublisher | Redis Pub/Sub 메시지 발행 |
Infrastructure | NotificationListener | Redis Pub/Sub 메시지 수신 |
Application | NotificationService | 알림 비즈니스 로직 |
5. WebSocket 연결 플로우
1) 초기 연결 과정
sequenceDiagram
participant Client
participant LB as Load Balancer
participant API as API Server
participant Redis
participant Auth as JWT Resolver
Client->>LB: 1. WebSocket 연결 요청<br/>/ws (with cookies)
LB->>API: 2. 연결 라우팅
API->>Auth: 3. JWT 쿠키 검증
Auth-->>API: 4. UserPrincipal 반환
API->>API: 5. WebSocket 핸드셰이크 완료
API->>Client: 6. WebSocket 연결 성공
Client->>API: 7. STOMP CONNECT
API->>API: 8. 사용자 인증 정보 설정
API->>Client: 9. STOMP CONNECTED
Client->>API: 10. 개인 큐 구독<br/>SUBSCRIBE /member/queue/notification
API->>API: 11. 구독 등록 완료
2) 메시지 전송 플로우
sequenceDiagram
participant Client1
participant API1 as API Server 1
participant Redis
participant API2 as API Server 2
participant Client2
Client1->>API1: 1. 알림 전송 요청<br/>POST /notification/send
API1->>API1: 2. 알림 데이터 DB 저장
API1->>Redis: 3. Pub/Sub 메시지 발행<br/>member:{memberId}:notification
Redis->>API1: 4. 메시지 브로드캐스트
Redis->>API2: 5. 메시지 브로드캐스트
API1->>Client1: 6. STOMP 메시지 전송<br/>/member/queue/notification
API2->>Client2: 7. STOMP 메시지 전송<br/>/member/queue/notification
Client1->>Client1: 8. 브라우저 알림 표시
Client2->>Client2: 8. 브라우저 알림 표시
6. 상세 구현
1) WebSocket 설정
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| @Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
// 단순 메시지 브로커 활성화
config.enableSimpleBroker("/topic", "/queue");
// 애플리케이션 목적지 prefix
config.setApplicationDestinationPrefixes("/publish");
// 사용자별 목적지 prefix
config.setUserDestinationPrefix("/member");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// 순수 WebSocket 엔드포인트 (SockJS 미사용)
registry.addEndpoint("/ws")
.setAllowedOriginPatterns(corsProperties.getAllowedOrigins())
.addInterceptors(handshakeInterceptor);
}
}
|
2) JWT 기반 인증
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| @Component
public class JwtHandshakeInterceptor implements HandshakeInterceptor {
@Override
public boolean beforeHandshake(ServerHttpRequest request,
ServerHttpResponse response,
WebSocketHandler wsHandler,
Map<String, Object> attributes) {
// JWT 쿠키에서 토큰 추출
String accessToken = extractTokenFromCookies(request);
// JWT 토큰 검증 및 사용자 정보 추출
UserPrincipal userPrincipal = jwtResolver.getUserPrincipalFromAccessToken(accessToken);
// WebSocket 세션에 사용자 정보 저장
attributes.put("USER_PRINCIPAL", userPrincipal);
return true;
}
}
|
3) Redis Pub/Sub 메시지 처리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| @Component
public class NotificationListener implements MessageListener {
@Override
public void onMessage(Message message, byte[] pattern) {
// Redis 메시지를 NotificationMessage로 역직렬화
NotificationMessage notificationMessage =
objectMapper.readValue(new String(message.getBody()), NotificationMessage.class);
// STOMP를 통해 해당 사용자에게 메시지 전송
simpMessagingTemplate.convertAndSendToUser(
String.valueOf(notificationMessage.getMemberId()),
"/queue/notification",
notificationMessage
);
}
}
|
7. 클라이언트 구현 가이드
1) JavaScript WebSocket 연결
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| // 순수 WebSocket 연결 (SockJS 미사용)
const websocket = new WebSocket('ws://localhost:8080/ws');
// STOMP over WebSocket
const stompClient = Stomp.over(websocket);
// 연결 설정
stompClient.connect({}, function(frame) {
console.log('STOMP 연결 성공:', frame);
// 개인 알림 큐 구독
stompClient.subscribe('/member/queue/notification', function(message) {
const notification = JSON.parse(message.body);
handleNotification(notification);
});
});
|
2) 알림 처리 함수
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| function handleNotification(notification) {
console.log('새로운 알림:', notification);
// 브라우저 알림 표시
if ('Notification' in window && Notification.permission === 'granted') {
new Notification('새로운 알림', {
body: notification.message,
icon: '/favicon.ico'
});
}
// UI 업데이트
updateNotificationUI(notification);
}
// 브라우저 알림 권한 요청
function requestNotificationPermission() {
if ('Notification' in window && Notification.permission !== 'granted') {
Notification.requestPermission();
}
}
|
3) 연결 상태 관리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| // 연결 상태 모니터링
websocket.onopen = function(event) {
console.log('WebSocket 연결 성공');
updateConnectionStatus('connected');
};
websocket.onerror = function(error) {
console.error('WebSocket 오류:', error);
updateConnectionStatus('error');
};
websocket.onclose = function(event) {
console.log('WebSocket 연결 종료:', event.code, event.reason);
updateConnectionStatus('disconnected');
// 자동 재연결 시도
setTimeout(reconnectWebSocket, 3000);
};
// 재연결 로직
function reconnectWebSocket() {
if (websocket.readyState === WebSocket.CLOSED) {
connectWebSocket();
}
}
|
8. 데이터 모델
1) NotificationMessage 구조
1
2
3
4
5
6
| {
"memberId": 1,
"notificationId": 12345,
"message": "새로운 알림이 도착했습니다.",
"redirectPath": "/dashboard"
}
|
2) Redis Pub/Sub 채널 구조
채널 패턴 | 설명 | 예시 |
---|
member:{memberId}:notification | 개인 알림 채널 | member:1:notification |
broadcast:all | 전체 브로드캐스트 | broadcast:all |
3) WebSocket 세션 속성
1
2
3
4
5
6
| Map<String, Object> sessionAttributes = {
"USER_PRINCIPAL": UserPrincipal, // 인증된 사용자 정보
"MEMBER_ID": Long, // 회원 ID
"CONNECTED_AT": LocalDateTime, // 연결 시간
"LAST_ACTIVITY": LocalDateTime // 마지막 활동 시간
}
|
9. 보안 및 인증
1) JWT 쿠키 기반 인증
- HttpOnly 쿠키로 XSS 공격 방지
- Secure 플래그로 HTTPS 강제
- SameSite=None으로 CORS 환경 지원
- 토큰 만료 시 자동 연결 해제
2) CORS 설정
1
2
3
4
5
6
7
8
| @Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOriginPatterns(
// Allowed Origins
)
.addInterceptors(jwtHandshakeInterceptor);
}
|
8. 개발 가이드라인
1) 연결 전 확인사항:
- 브라우저 WebSocket 지원 확인
- HTTPS 환경에서 WSS 프로토콜 사용
- 인증 쿠키 설정 확인
- CORS 정책 준수
2) 구현 시 주의사항:
- 자동 재연결 로직 구현
- 연결 상태 UI 표시
- 메시지 중복 처리 방지
- 브라우저 알림 권한 요청
16. 참고 자료