실시간 통신의 필요성이 날로 증가하는 현대 웹 개발 환경에서 WebSocket은 더 이상 선택이 아닌 필수 기술이 되었습니다.
이 글에서는 Spring Boot와 STOMP 프로토콜을 활용하여 확장 가능하고
보안성이 뛰어난 실시간 채팅 애플리케이션을 구축하는 방법을 심층적으로 다룹니다.
왜 WebSocket인가? HTTP의 한계를 넘어서
전통적인 HTTP 통신은 요청-응답 모델의 단방향 통신으로, 서버가 클라이언트에게 먼저 데이터를 전송할 수 없다는 근본적인 한계가 있습니다. 실시간 데이터 전송이 필요한 다양한 애플리케이션에서 WebSocket이 적합하다고 설명하고 있습니다.
HTTP vs WebSocket 비교
HTTP 통신의 한계점:
- 클라이언트에서만 요청을 시작할 수 있는 단방향 통신
- 매 요청마다 연결 설정/해제로 인한 오버헤드
- 실시간성 보장을 위한 Polling 방식의 비효율성
WebSocket의 혁신적 장점:
- 전이중(Full-Duplex) 통신: 서버와 클라이언트 양방향 실시간 데이터 전송
- 지속적 연결: 초기 핸드셰이크 후 연결 유지로 오버헤드 최소화
- 낮은 지연시간: 데이터가 '패킷(packet)' 형태로 전달되며, 전송은 커넥션 중단과 추가 HTTP 요청 없이 양방향으로 이뤄집니다
STOMP 프로토콜: WebSocket의 완벽한 파트너
순수 WebSocket만으로는 메시지 포맷, 세션 관리, 라우팅 등을 모두 직접 구현해야 합니다.
STOMP(Simple Text Oriented Messaging Protocol)는 이러한 복잡성을 해결하는 핵심 솔루션입니다.
STOMP의 핵심 이점
- 표준화된 메시지 포맷: 구조화된 프레임 기반 메시징
- Pub/Sub 패턴 지원: 채팅방별 구독/발행 메커니즘
- Spring Framework 완벽 통합: @MessageMapping 어노테이션은 @RequestMapping과 비슷한 역할을 한다
- 외부 메시지 브로커 연동: Redis, RabbitMQ, Kafka 등과 원활한 통합
프로젝트 설정 및 의존성 구성
1. Gradle 의존성 설정
dependencies {
// WebSocket & STOMP 핵심 의존성
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.springframework.boot:spring-boot-starter-web'
// 보안 강화를 위한 Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.security:spring-security-messaging'
// JWT 토큰 인증
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
// Redis 연동 (선택사항)
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// JSON 처리
implementation 'com.fasterxml.jackson.core:jackson-databind'
// 테스트
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
2. 고급 WebSocket 설정
@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final WebSocketAuthInterceptor authInterceptor;
private final WebSocketHandshakeInterceptor handshakeInterceptor;
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
// 구독용 prefix 설정 (클라이언트가 구독할 주제)
config.enableSimpleBroker("/topic", "/queue", "/user");
// 메시지 발송용 prefix 설정 (클라이언트에서 서버로 메시지 전송)
config.setApplicationDestinationPrefixes("/app");
// 사용자별 메시지 전송을 위한 prefix
config.setUserDestinationPrefix("/user");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*") // 프로덕션에서는 구체적인 도메인 지정 필요
.addInterceptors(handshakeInterceptor)
.withSockJS(); // SockJS 폴백 지원
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
// 메시지 전송 시 인증/권한 검증
registration.interceptors(authInterceptor);
// 성능 최적화: 스레드 풀 설정
registration.taskExecutor()
.corePoolSize(10)
.maxPoolSize(20)
.queueCapacity(200);
}
@Override
public void configureWebSocketTransport(WebSocketTransportRegistration registry) {
// 성능 및 보안 최적화 설정
registry.setMessageSizeLimit(128 * 1024) // 128KB 메시지 크기 제한
.setSendTimeLimit(10 * 1000) // 10초 전송 타임아웃
.setSendBufferSizeLimit(512 * 1024); // 512KB 전송 버퍼 크기
}
}
보안 강화: JWT 인증 통합
WebSocket 보안은 일반적인 HTTP 보안과 다른 접근이 필요합니다.
WebSocket에서 JWT 인증을 구현하고 보안 프로토콜(WSS) 사용, 인증 및 권한 부여를 구현해야 합니다.
WebSocket 인증 인터셉터
@Component
@RequiredArgsConstructor
@Slf4j
public class WebSocketAuthInterceptor implements ChannelInterceptor {
private final JwtTokenProvider jwtTokenProvider;
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor
.getAccessor(message, StompHeaderAccessor.class);
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
return handleConnect(accessor, message);
} else if (StompCommand.SEND.equals(accessor.getCommand())) {
return handleSend(accessor, message);
}
return message;
}
private Message<?> handleConnect(StompHeaderAccessor accessor, Message<?> message) {
try {
String authToken = accessor.getFirstNativeHeader("Authorization");
if (authToken == null || !authToken.startsWith("Bearer ")) {
throw new SecurityException("인증 토큰이 필요합니다.");
}
String token = authToken.substring(7);
if (!jwtTokenProvider.validateToken(token)) {
throw new SecurityException("유효하지 않은 토큰입니다.");
}
// 인증된 사용자 정보를 세션에 저장
String userId = jwtTokenProvider.getUserIdFromToken(token);
accessor.getSessionAttributes().put("userId", userId);
accessor.getSessionAttributes().put("token", token);
// Spring Security Principal 설정
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userId, null,
Collections.emptyList());
accessor.setUser(authentication);
log.info("WebSocket 연결 성공: userId={}", userId);
} catch (Exception e) {
log.error("WebSocket 인증 실패: {}", e.getMessage());
throw new SecurityException("인증에 실패했습니다: " + e.getMessage());
}
return message;
}
private Message<?> handleSend(StompHeaderAccessor accessor, Message<?> message) {
// 모든 메시지 전송 시 세션 검증
String userId = (String) accessor.getSessionAttributes().get("userId");
if (userId == null) {
throw new SecurityException("인증되지 않은 사용자입니다.");
}
// Rate Limiting 구현 (선택사항)
if (!checkRateLimit(userId)) {
throw new SecurityException("메시지 전송 한도를 초과했습니다.");
}
return message;
}
private boolean checkRateLimit(String userId) {
// Redis 또는 메모리 캐시를 활용한 Rate Limiting 로직
// 사용자당 분당 메시지 전송 수 제한
return true; // 예시용
}
}
핸드셰이크 인터셉터
@Component
@Slf4j
public class WebSocketHandshakeInterceptor implements HandshakeInterceptor {
@Override
public boolean beforeHandshake(ServerHttpRequest request,
ServerHttpResponse response,
WebSocketHandler wsHandler,
Map<String, Object> attributes) throws Exception {
// IP 주소 기반 접근 제한 (선택사항)
String clientIp = getClientIpAddress(request);
log.info("WebSocket 핸드셰이크 시도: IP={}", clientIp);
// CORS 헤더 설정
if (request instanceof ServletServerHttpRequest) {
ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request;
String origin = servletRequest.getHeaders().getFirst("Origin");
if (isAllowedOrigin(origin)) {
attributes.put("origin", origin);
return true;
}
}
return true;
}
@Override
public void afterHandshake(ServerHttpRequest request,
ServerHttpResponse response,
WebSocketHandler wsHandler,
Exception exception) {
if (exception != null) {
log.error("WebSocket 핸드셰이크 실패", exception);
}
}
private String getClientIpAddress(ServerHttpRequest request) {
if (request instanceof ServletServerHttpRequest) {
HttpServletRequest servletRequest =
((ServletServerHttpRequest) request).getServletRequest();
String xForwardedFor = servletRequest.getHeader("X-Forwarded-For");
if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
return xForwardedFor.split(",")[0].trim();
}
return servletRequest.getRemoteAddr();
}
return "unknown";
}
private boolean isAllowedOrigin(String origin) {
// 허용된 도메인 검증 로직
List<String> allowedOrigins = Arrays.asList(
"http://localhost:3000",
"https://yourdomain.com"
);
return allowedOrigins.contains(origin);
}
}
메시지 처리 및 라우팅
채팅 메시지 데이터 모델
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChatMessage {
@NotBlank(message = "메시지 내용은 필수입니다")
@Size(max = 1000, message = "메시지는 1000자를 초과할 수 없습니다")
private String content;
@NotBlank(message = "발신자 정보는 필수입니다")
private String sender;
@NotBlank(message = "채팅방 ID는 필수입니다")
private String roomId;
@NotNull(message = "메시지 타입은 필수입니다")
private MessageType type;
private LocalDateTime timestamp;
// 메시지 암호화를 위한 필드 (선택사항)
private String encryptedContent;
private boolean isEncrypted;
public enum MessageType {
CHAT,
JOIN,
LEAVE,
TYPING,
SYSTEM
}
@PrePersist
protected void onCreate() {
timestamp = LocalDateTime.now();
}
// 민감한 정보 마스킹
public void sanitize() {
if (content != null) {
// XSS 방지를 위한 HTML 태그 제거
content = content.replaceAll("<[^>]*>", "");
// 욕설 필터링 (실제 구현 시 욕설 사전 활용)
content = filterProfanity(content);
}
}
private String filterProfanity(String text) {
// 욕설 필터링 로직 구현
return text;
}
}
고급 채팅 컨트롤러
@RestController
@RequiredArgsConstructor
@Slf4j
@Validated
public class ChatController {
private final SimpMessagingTemplate messagingTemplate;
private final ChatMessageService chatMessageService;
private final ChatRoomService chatRoomService;
private final UserService userService;
@MessageMapping("/chat.sendMessage")
public void sendMessage(@Valid ChatMessage chatMessage,
SimpMessageHeaderAccessor headerAccessor) {
try {
// 발신자 인증 정보 검증
String userId = (String) headerAccessor.getSessionAttributes().get("userId");
if (!userId.equals(chatMessage.getSender())) {
throw new SecurityException("발신자 정보가 일치하지 않습니다.");
}
// 채팅방 참여 권한 검증
if (!chatRoomService.isUserInRoom(userId, chatMessage.getRoomId())) {
throw new SecurityException("채팅방에 대한 권한이 없습니다.");
}
// 메시지 검증 및 정제
chatMessage.sanitize();
chatMessage.setTimestamp(LocalDateTime.now());
// 메시지 저장 (비동기 처리)
CompletableFuture.runAsync(() -> {
try {
chatMessageService.saveMessage(chatMessage);
} catch (Exception e) {
log.error("메시지 저장 실패: roomId={}, sender={}",
chatMessage.getRoomId(), chatMessage.getSender(), e);
}
});
// 실시간 메시지 브로드캐스트
messagingTemplate.convertAndSend(
"/topic/room/" + chatMessage.getRoomId(),
ChatMessageResponse.from(chatMessage)
);
// 사용자별 읽지 않은 메시지 수 업데이트
updateUnreadCount(chatMessage.getRoomId(), userId);
log.debug("메시지 전송 완료: roomId={}, sender={}",
chatMessage.getRoomId(), chatMessage.getSender());
} catch (SecurityException e) {
sendErrorToUser(headerAccessor, "SECURITY_ERROR", e.getMessage());
} catch (Exception e) {
log.error("메시지 처리 중 오류 발생", e);
sendErrorToUser(headerAccessor, "MESSAGE_ERROR", "메시지 전송에 실패했습니다.");
}
}
@MessageMapping("/chat.addUser")
public void addUser(ChatMessage chatMessage,
SimpMessageHeaderAccessor headerAccessor) {
try {
String userId = (String) headerAccessor.getSessionAttributes().get("userId");
// 채팅방 입장 권한 검증
if (!chatRoomService.canUserJoinRoom(userId, chatMessage.getRoomId())) {
throw new SecurityException("채팅방 입장 권한이 없습니다.");
}
// 사용자 정보 조회
User user = userService.findById(userId);
// 입장 메시지 생성
ChatMessage joinMessage = ChatMessage.builder()
.type(ChatMessage.MessageType.JOIN)
.content(user.getNickname() + "님이 입장하셨습니다.")
.sender("SYSTEM")
.roomId(chatMessage.getRoomId())
.timestamp(LocalDateTime.now())
.build();
// 채팅방에 사용자 추가
chatRoomService.addUserToRoom(userId, chatMessage.getRoomId());
// 입장 알림 브로드캐스트
messagingTemplate.convertAndSend(
"/topic/room/" + chatMessage.getRoomId(),
ChatMessageResponse.from(joinMessage)
);
// 채팅방 참여자 목록 업데이트
sendRoomParticipants(chatMessage.getRoomId());
} catch (SecurityException e) {
sendErrorToUser(headerAccessor, "SECURITY_ERROR", e.getMessage());
} catch (Exception e) {
log.error("사용자 입장 처리 중 오류 발생", e);
sendErrorToUser(headerAccessor, "JOIN_ERROR", "채팅방 입장에 실패했습니다.");
}
}
@MessageMapping("/chat.leaveUser")
public void leaveUser(ChatMessage chatMessage,
SimpMessageHeaderAccessor headerAccessor) {
handleUserLeave(chatMessage, headerAccessor);
}
@MessageMapping("/chat.typing")
public void handleTyping(@Valid ChatMessage chatMessage,
SimpMessageHeaderAccessor headerAccessor) {
String userId = (String) headerAccessor.getSessionAttributes().get("userId");
if (chatRoomService.isUserInRoom(userId, chatMessage.getRoomId())) {
// 타이핑 상태를 같은 방의 다른 사용자에게만 전송
messagingTemplate.convertAndSendToUser(
chatMessage.getRoomId(),
"/queue/typing",
Map.of("sender", chatMessage.getSender(), "isTyping", true)
);
}
}
private void handleUserLeave(ChatMessage chatMessage,
SimpMessageHeaderAccessor headerAccessor) {
try {
String userId = (String) headerAccessor.getSessionAttributes().get("userId");
User user = userService.findById(userId);
// 퇴장 메시지 생성
ChatMessage leaveMessage = ChatMessage.builder()
.type(ChatMessage.MessageType.LEAVE)
.content(user.getNickname() + "님이 퇴장하셨습니다.")
.sender("SYSTEM")
.roomId(chatMessage.getRoomId())
.timestamp(LocalDateTime.now())
.build();
// 채팅방에서 사용자 제거
chatRoomService.removeUserFromRoom(userId, chatMessage.getRoomId());
// 퇴장 알림 브로드캐스트
messagingTemplate.convertAndSend(
"/topic/room/" + chatMessage.getRoomId(),
ChatMessageResponse.from(leaveMessage)
);
// 채팅방 참여자 목록 업데이트
sendRoomParticipants(chatMessage.getRoomId());
} catch (Exception e) {
log.error("사용자 퇴장 처리 중 오류 발생", e);
}
}
private void sendErrorToUser(SimpMessageHeaderAccessor headerAccessor,
String errorType, String errorMessage) {
String sessionId = headerAccessor.getSessionId();
messagingTemplate.convertAndSendToUser(
sessionId,
"/queue/errors",
Map.of(
"type", errorType,
"message", errorMessage,
"timestamp", LocalDateTime.now()
)
);
}
private void updateUnreadCount(String roomId, String senderId) {
// 비동기로 읽지 않은 메시지 수 업데이트
CompletableFuture.runAsync(() -> {
try {
List<String> roomParticipants = chatRoomService.getRoomParticipants(roomId);
roomParticipants.stream()
.filter(userId -> !userId.equals(senderId))
.forEach(userId -> {
int unreadCount = chatMessageService.getUnreadCount(userId, roomId);
messagingTemplate.convertAndSendToUser(
userId,
"/queue/unread",
Map.of("roomId", roomId, "count", unreadCount)
);
});
} catch (Exception e) {
log.error("읽지 않은 메시지 수 업데이트 실패: roomId={}", roomId, e);
}
});
}
private void sendRoomParticipants(String roomId) {
try {
List<String> participants = chatRoomService.getRoomParticipants(roomId);
List<User> participantDetails = participants.stream()
.map(userService::findById)
.collect(Collectors.toList());
messagingTemplate.convertAndSend(
"/topic/room/" + roomId + "/participants",
participantDetails.stream()
.map(UserResponse::from)
.collect(Collectors.toList())
);
} catch (Exception e) {
log.error("참여자 목록 전송 실패: roomId={}", roomId, e);
}
}
}
Redis를 활용한 다중 서버 환경 지원
다수의 서버일 경우 서버간 채팅방을 공유할 수 없게 되면서 다른 서버간에 있는 사용자와의 채팅이 불가능 해진다.
이러한 문제들을 해결하기 위해서는 Message Broker가 여러 서버에서 접근할 수 있도록 개선이 필요하다.
Redis Pub/Sub 설정
@Configuration
@RequiredArgsConstructor
@EnableConfigurationProperties(RedisProperties.class)
public class RedisConfig {
private final RedisProperties redisProperties;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
LettuceConnectionFactory factory = new LettuceConnectionFactory(
redisProperties.getHost(),
redisProperties.getPort()
);
// Connection Pool 설정
factory.setValidateConnection(true);
factory.setShareNativeConnection(false);
return factory;
}
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory());
// JSON 직렬화 설정
GenericJackson2JsonRedisSerializer serializer =
new GenericJackson2JsonRedisSerializer();
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(serializer);
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(serializer);
return template;
}
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer() {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(redisConnectionFactory());
// 메시지 처리를 위한 스레드 풀 설정
container.setTaskExecutor(Executors.newFixedThreadPool(4));
return container;
}
@Bean
public ChannelTopic chatTopic() {
return new ChannelTopic("chat.messages");
}
}
Redis 메시지 발행자
@Service
@RequiredArgsConstructor
@Slf4j
public class RedisMessagePublisher {
private final RedisTemplate<String, Object> redisTemplate;
private final ChannelTopic chatTopic;
public void publishChatMessage(ChatMessage message) {
try {
RedisMessage redisMessage = RedisMessage.builder()
.type("CHAT_MESSAGE")
.roomId(message.getRoomId())
.payload(message)
.timestamp(LocalDateTime.now())
.serverId(getServerId())
.build();
redisTemplate.convertAndSend(chatTopic.getTopic(), redisMessage);
log.debug("Redis로 메시지 발행: roomId={}", message.getRoomId());
} catch (Exception e) {
log.error("Redis 메시지 발행 실패", e);
throw new MessagePublishException("메시지 발행에 실패했습니다.", e);
}
}
public void publishUserJoin(String roomId, String userId) {
try {
RedisMessage redisMessage = RedisMessage.builder()
.type("USER_JOIN")
.roomId(roomId)
.payload(Map.of("userId", userId))
.timestamp(LocalDateTime.now())
.serverId(getServerId())
.build();
redisTemplate.convertAndSend(chatTopic.getTopic(), redisMessage);
} catch (Exception e) {
log.error("사용자 입장 이벤트 발행 실패", e);
}
}
public void publishUserLeave(String roomId, String userId) {
try {
RedisMessage redisMessage = RedisMessage.builder()
.type("USER_LEAVE")
.roomId(roomId)
.payload(Map.of("userId", userId))
.timestamp(LocalDateTime.now())
.serverId(getServerId())
.build();
redisTemplate.convertAndSend(chatTopic.getTopic(), redisMessage);
} catch (Exception e) {
log.error("사용자 퇴장 이벤트 발행 실패", e);
}
}
private String getServerId() {
// 서버 식별자 반환 (예: hostname + port)
return System.getProperty("server.id", "default-server");
}
}
Redis 메시지 구독자
@Service
@RequiredArgsConstructor
@Slf4j
public class RedisMessageSubscriber implements MessageListener {
private final SimpMessagingTemplate messagingTemplate;
private final ObjectMapper objectMapper;
private final String currentServerId = getCurrentServerId();
@Override
public void onMessage(Message message, byte[] pattern) {
try {
String messageBody = new String(message.getBody(), StandardCharsets.UTF_8);
RedisMessage redisMessage = objectMapper.readValue(messageBody, RedisMessage.class);
// 자신이 발행한 메시지는 무시 (무한 루프 방지)
if (currentServerId.equals(redisMessage.getServerId())) {
return;
}
handleRedisMessage(redisMessage);
} catch (Exception e) {
log.error("Redis 메시지 처리 실패", e);
}
}
private void handleRedisMessage(RedisMessage redisMessage) {
switch (redisMessage.getType()) {
case "CHAT_MESSAGE":
handleChatMessage(redisMessage);
break;
case "USER_JOIN":
handleUserJoin(redisMessage);
break;
case "USER_LEAVE":
handleUserLeave(redisMessage);
break;
default:
log.warn("알 수 없는 메시지 타입: {}", redisMessage.getType());
}
}
private void handleChatMessage(RedisMessage redisMessage) {
try {
ChatMessage chatMessage = objectMapper.convertValue(
redisMessage.getPayload(), ChatMessage.class);
messagingTemplate.convertAndSend(
"/topic/room/" + redisMessage.getRoomId(),
ChatMessageResponse.from(chatMessage)
);
log.debug("다른 서버의 채팅 메시지 전달: roomId={}", redisMessage.getRoomId());
} catch (Exception e) {
log.error("채팅 메시지 처리 실패", e);
}
}
private void handleUserJoin(RedisMessage redisMessage) {
try {
Map<String, Object> payload = (Map<String, Object>) redisMessage.getPayload();
String userId = (String) payload.get("userId");
messagingTemplate.convertAndSend(
"/topic/room/" + redisMessage.getRoomId() + "/participants",
Map.of("type", "JOIN", "userId", userId)
);
} catch (Exception e) {
log.error("사용자 입장 이벤트 처리 실패", e);
}
}
private void handleUserLeave(RedisMessage redisMessage) {
try {
Map<String, Object> payload = (Map<String, Object>) redisMessage.getPayload();
String userId = (String) payload.get("userId");
messagingTemplate.convertAndSend(
"/topic/room/" + redisMessage.getRoomId() + "/participants",
Map.of("type", "LEAVE", "userId", userId)
);
} catch (Exception e) {
log.error("사용자 퇴장 이벤트 처리 실패", e);
}
}
private String getCurrentServerId() {
return System.getProperty("server.id", "default-server");
}
}
고급 클라이언트 구현
WebSocket 연결 관리 및 자동 재연결
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>실시간 채팅 애플리케이션</title>
<script src="https://cdn.jsdelivr.net/npm/sockjs-client/dist/sockjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/stompjs/lib/stomp.min.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.chat-container {
width: 400px;
height: 600px;
background: white;
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
overflow: hidden;
}
.chat-header {
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
padding: 20px;
text-align: center;
position: relative;
}
.connection-status {
position: absolute;
top: 10px;
right: 15px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #ff4757;
transition: background 0.3s ease;
}
.connection-status.connected {
background: #2ed573;
}
.login-form {
padding: 30px;
text-align: center;
}
.login-form input {
width: 100%;
padding: 15px;
margin: 10px 0;
border: 2px solid #e1e8ed;
border-radius: 10px;
font-size: 16px;
transition: border-color 0.3s ease;
}
.login-form input:focus {
outline: none;
border-color: #667eea;
}
.btn {
width: 100%;
padding: 15px;
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
border: none;
border-radius: 10px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s ease;
}
.btn:hover {
transform: translateY(-2px);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.chat-messages {
flex: 1;
padding: 20px;
overflow-y: auto;
background: #f8f9fa;
}
.message {
margin: 10px 0;
padding: 12px 16px;
border-radius: 18px;
max-width: 80%;
word-wrap: break-word;
animation: messageSlide 0.3s ease-out;
}
@keyframes messageSlide {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.message.own {
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
margin-left: auto;
text-align: right;
}
.message.other {
background: white;
border: 1px solid #e1e8ed;
}
.message.system {
background: #ffeaa7;
color: #636e72;
text-align: center;
font-style: italic;
margin: 15px auto;
}
.message-input-container {
padding: 20px;
background: white;
border-top: 1px solid #e1e8ed;
}
.message-input-form {
display: flex;
gap: 10px;
}
.message-input {
flex: 1;
padding: 12px 16px;
border: 2px solid #e1e8ed;
border-radius: 25px;
font-size: 14px;
transition: border-color 0.3s ease;
}
.message-input:focus {
outline: none;
border-color: #667eea;
}
.send-btn {
width: 50px;
height: 50px;
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
border: none;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease;
}
.send-btn:hover {
transform: scale(1.1);
}
.typing-indicator {
padding: 10px 20px;
color: #74b9ff;
font-style: italic;
font-size: 14px;
}
.hidden {
display: none;
}
.error-message {
background: #ff6b6b;
color: white;
padding: 10px;
border-radius: 5px;
margin: 10px 0;
text-align: center;
}
</style>
</head>
<body>
<div class="chat-container">
<div class="chat-header">
<h2><i class="fas fa-comments"></i> 실시간 채팅</h2>
<div class="connection-status" id="connectionStatus"></div>
</div>
<!-- 로그인 폼 -->
<div id="loginForm" class="login-form">
<input type="text" id="username" placeholder="사용자명을 입력하세요" maxlength="20" required>
<input type="text" id="roomId" placeholder="채팅방 ID를 입력하세요" maxlength="50" required>
<button class="btn" onclick="connect()">
<i class="fas fa-sign-in-alt"></i> 접속하기
</button>
</div>
<!-- 채팅 영역 -->
<div id="chatArea" class="hidden">
<div class="chat-messages" id="messages"></div>
<div class="typing-indicator hidden" id="typingIndicator"></div>
<div class="message-input-container">
<form class="message-input-form" onsubmit="sendMessage(event)">
<input type="text" id="messageInput" class="message-input"
placeholder="메시지를 입력하세요..." maxlength="1000" required>
<button type="submit" class="send-btn">
<i class="fas fa-paper-plane"></i>
</button>
</form>
</div>
</div>
</div>
<script>
class ChatClient {
constructor() {
this.stompClient = null;
this.currentUser = null;
this.currentRoom = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.reconnectDelay = 1000;
this.typingTimer = null;
this.typingTimeout = 3000;
this.isTyping = false;
this.initializeEventListeners();
}
initializeEventListeners() {
// 메시지 입력 시 타이핑 이벤트
document.getElementById('messageInput').addEventListener('input', () => {
this.handleTyping();
});
// 페이지 언로드 시 연결 정리
window.addEventListener('beforeunload', () => {
this.disconnect();
});
// 네트워크 연결 상태 감지
window.addEventListener('online', () => {
console.log('네트워크 연결됨');
if (!this.isConnected()) {
this.reconnect();
}
});
window.addEventListener('offline', () => {
console.log('네트워크 연결 끊김');
this.updateConnectionStatus(false);
});
}
connect(username, roomId) {
if (this.isConnected()) {
console.log('이미 연결되어 있습니다.');
return;
}
this.currentUser = username;
this.currentRoom = roomId;
const socket = new SockJS('/ws');
this.stompClient = Stomp.over(socket);
// JWT 토큰을 헤더에 포함 (실제 구현 시 로그인 시스템과 연동)
const headers = {
'Authorization': 'Bearer ' + this.getAuthToken()
};
this.stompClient.connect(headers,
(frame) => this.onConnected(frame),
(error) => this.onError(error)
);
// 연결 상태 업데이트
this.updateConnectionStatus(false);
}
onConnected(frame) {
console.log('WebSocket 연결 성공:', frame);
this.reconnectAttempts = 0;
this.updateConnectionStatus(true);
// 채팅방 구독
this.stompClient.subscribe(`/topic/room/${this.currentRoom}`,
(message) => this.onMessageReceived(message));
// 에러 메시지 구독
this.stompClient.subscribe('/user/queue/errors',
(message) => this.onErrorReceived(message));
// 타이핑 상태 구독
this.stompClient.subscribe('/user/queue/typing',
(message) => this.onTypingReceived(message));
// 입장 메시지 전송
this.stompClient.send('/app/chat.addUser', {}, JSON.stringify({
sender: this.currentUser,
roomId: this.currentRoom,
type: 'JOIN'
}));
this.showChatArea();
}
onError(error) {
console.error('WebSocket 연결 오류:', error);
this.updateConnectionStatus(false);
if (this.reconnectAttempts < this.maxReconnectAttempts) {
console.log(`재연결 시도 ${this.reconnectAttempts + 1}/${this.maxReconnectAttempts}`);
setTimeout(() => {
this.reconnectAttempts++;
this.reconnect();
}, this.reconnectDelay * Math.pow(2, this.reconnectAttempts));
} else {
this.showError('연결에 실패했습니다. 페이지를 새로고침해주세요.');
}
}
reconnect() {
if (this.currentUser && this.currentRoom) {
this.connect(this.currentUser, this.currentRoom);
}
}
disconnect() {
if (this.isConnected()) {
// 퇴장 메시지 전송
this.stompClient.send('/app/chat.leaveUser', {}, JSON.stringify({
sender: this.currentUser,
roomId: this.currentRoom,
type: 'LEAVE'
}));
this.stompClient.disconnect();
this.updateConnectionStatus(false);
}
}
sendMessage(content) {
if (!this.isConnected()) {
this.showError('연결이 끊어졌습니다. 재연결 중...');
this.reconnect();
return;
}
if (!content.trim()) {
return;
}
const message = {
sender: this.currentUser,
content: content.trim(),
roomId: this.currentRoom,
type: 'CHAT',
timestamp: new Date().toISOString()
};
try {
this.stompClient.send('/app/chat.sendMessage', {}, JSON.stringify(message));
this.stopTyping();
} catch (error) {
console.error('메시지 전송 실패:', error);
this.showError('메시지 전송에 실패했습니다.');
}
}
handleTyping() {
if (!this.isConnected()) return;
if (!this.isTyping) {
this.isTyping = true;
this.stompClient.send('/app/chat.typing', {}, JSON.stringify({
sender: this.currentUser,
roomId: this.currentRoom,
type: 'TYPING'
}));
}
clearTimeout(this.typingTimer);
this.typingTimer = setTimeout(() => {
this.stopTyping();
}, this.typingTimeout);
}
stopTyping() {
if (this.isTyping) {
this.isTyping = false;
clearTimeout(this.typingTimer);
}
}
onMessageReceived(message) {
try {
const chatMessage = JSON.parse(message.body);
this.displayMessage(chatMessage);
} catch (error) {
console.error('메시지 파싱 오류:', error);
}
}
onErrorReceived(message) {
try {
const errorData = JSON.parse(message.body);
this.showError(errorData.message);
} catch (error) {
console.error('에러 메시지 파싱 오류:', error);
}
}
onTypingReceived(message) {
try {
const typingData = JSON.parse(message.body);
if (typingData.sender !== this.currentUser) {
this.showTypingIndicator(typingData.sender);
}
} catch (error) {
console.error('타이핑 상태 파싱 오류:', error);
}
}
displayMessage(message) {
const messagesContainer = document.getElementById('messages');
const messageElement = document.createElement('div');
const isOwnMessage = message.sender === this.currentUser;
const isSystemMessage = message.type === 'JOIN' || message.type === 'LEAVE' || message.sender === 'SYSTEM';
if (isSystemMessage) {
messageElement.className = 'message system';
messageElement.textContent = message.content;
} else {
messageElement.className = `message ${isOwnMessage ? 'own' : 'other'}`;
if (!isOwnMessage) {
const senderSpan = document.createElement('span');
senderSpan.style.fontWeight = 'bold';
senderSpan.style.display = 'block';
senderSpan.style.marginBottom = '4px';
senderSpan.textContent = message.sender;
messageElement.appendChild(senderSpan);
}
const contentDiv = document.createElement('div');
contentDiv.textContent = message.content;
messageElement.appendChild(contentDiv);
if (message.timestamp) {
const timeSpan = document.createElement('span');
timeSpan.style.fontSize = '12px';
timeSpan.style.opacity = '0.7';
timeSpan.style.display = 'block';
timeSpan.style.marginTop = '4px';
timeSpan.textContent = this.formatTime(message.timestamp);
messageElement.appendChild(timeSpan);
}
}
messagesContainer.appendChild(messageElement);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
// 메시지 애니메이션
messageElement.style.opacity = '0';
messageElement.style.transform = 'translateY(20px)';
requestAnimationFrame(() => {
messageElement.style.transition = 'all 0.3s ease-out';
messageElement.style.opacity = '1';
messageElement.style.transform = 'translateY(0)';
});
}
showTypingIndicator(sender) {
const indicator = document.getElementById('typingIndicator');
indicator.textContent = `${sender}님이 입력 중...`;
indicator.classList.remove('hidden');
setTimeout(() => {
indicator.classList.add('hidden');
}, this.typingTimeout);
}
updateConnectionStatus(connected) {
const statusElement = document.getElementById('connectionStatus');
if (connected) {
statusElement.classList.add('connected');
statusElement.title = '연결됨';
} else {
statusElement.classList.remove('connected');
statusElement.title = '연결 끊김';
}
}
showChatArea() {
document.getElementById('loginForm').classList.add('hidden');
document.getElementById('chatArea').classList.remove('hidden');
document.getElementById('messageInput').focus();
}
showError(message) {
const errorDiv = document.createElement('div');
errorDiv.className = 'error-message';
errorDiv.textContent = message;
const container = document.querySelector('.chat-container');
container.insertBefore(errorDiv, container.firstChild);
setTimeout(() => {
if (errorDiv.parentNode) {
errorDiv.parentNode.removeChild(errorDiv);
}
}, 5000);
}
isConnected() {
return this.stompClient && this.stompClient.connected;
}
getAuthToken() {
// 실제 구현에서는 로그인 시스템에서 받은 JWT 토큰을 반환
// 현재는 임시 토큰 사용
return localStorage.getItem('authToken') || 'temporary-token';
}
formatTime(timestamp) {
const date = new Date(timestamp);
return date.toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit'
});
}
}
// 전역 객체 생성
const chatClient = new ChatClient();
// 전역 함수들
function connect() {
const username = document.getElementById('username').value.trim();
const roomId = document.getElementById('roomId').value.trim();
if (!username || !roomId) {
alert('사용자명과 채팅방 ID를 모두 입력해주세요.');
return;
}
// 입력값 검증
if (username.length < 2 || username.length > 20) {
alert('사용자명은 2-20자 사이여야 합니다.');
return;
}
if (!/^[a-zA-Z0-9가-힣_-]+$/.test(username)) {
alert('사용자명에는 특수문자를 사용할 수 없습니다.');
return;
}
chatClient.connect(username, roomId);
}
function sendMessage(event) {
event.preventDefault();
const messageInput = document.getElementById('messageInput');
const content = messageInput.value.trim();
if (content) {
chatClient.sendMessage(content);
messageInput.value = '';
}
}
// Enter 키로 로그인
document.addEventListener('DOMContentLoaded', function() {
const usernameInput = document.getElementById('username');
const roomIdInput = document.getElementById('roomId');
[usernameInput, roomIdInput].forEach(input => {
input.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
connect();
}
});
});
});
</script>
</body>
</html>
성능 최적화 및 모니터링
성능 최적화 전략
1. 연결 풀링 및 리소스 관리
# application.yml
spring:
websocket:
max-sessions: 1000
max-frame-size: 65536
max-text-message-buffer-size: 8192
max-binary-message-buffer-size: 8192
task:
execution:
pool:
core-size: 8
max-size: 16
queue-capacity: 100
keep-alive: 60s
2. 메시지 압축 및 배치 처리
@Configuration
public class WebSocketOptimizationConfig {
@Bean
public WebSocketTransportRegistration webSocketTransportRegistration() {
return new WebSocketTransportRegistration() {
@Override
public WebSocketTransportRegistration setMessageSizeLimit(int messageSizeLimit) {
return super.setMessageSizeLimit(64 * 1024); // 64KB
}
@Override
public WebSocketTransportRegistration setSendBufferSizeLimit(int sendBufferSizeLimit) {
return super.setSendBufferSizeLimit(512 * 1024); // 512KB
}
};
}
}
3. Rate Limiting 구현
@Component
public class MessageRateLimiter {
private final Map<String, RateLimiter> rateLimiters = new ConcurrentHashMap<>();
private final int messagesPerMinute = 60;
public boolean allowMessage(String userId) {
RateLimiter limiter = rateLimiters.computeIfAbsent(userId,
k -> RateLimiter.create(messagesPerMinute / 60.0));
return limiter.tryAcquire();
}
@Scheduled(fixedRate = 300000) // 5분마다 정리
public void cleanupRateLimiters() {
rateLimiters.entrySet().removeIf(entry ->
entry.getValue().tryAcquire(messagesPerMinute));
}
}
보안 강화 및 모범 사례
1. WSS(WebSocket Secure) 적용
# application.yml
server:
ssl:
enabled: true
key-store: classpath:keystore.p12
key-store-password: ${SSL_KEYSTORE_PASSWORD}
key-store-type: PKCS12
key-alias: websocket-server
protocol: TLS
enabled-protocols: TLSv1.2,TLSv1.3
2. CORS 정책 강화
@Configuration
public class WebSecurityConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(Arrays.asList(
"https://*.yourdomain.com",
"https://localhost:*"
));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
3. 입력 데이터 검증 및 정제
@Component
public class MessageSanitizer {
private final Pattern HTML_PATTERN = Pattern.compile("<[^>]*>");
private final Pattern SCRIPT_PATTERN = Pattern.compile("(?i)<script[^>]*>.*?</script>");
private final Set<String> FORBIDDEN_WORDS = Set.of("금지어1", "금지어2");
public String sanitizeMessage(String message) {
if (message == null || message.trim().isEmpty()) {
throw new IllegalArgumentException("메시지는 비어있을 수 없습니다.");
}
// HTML 태그 제거
message = HTML_PATTERN.matcher(message).replaceAll("");
// 스크립트 태그 제거
message = SCRIPT_PATTERN.matcher(message).replaceAll("");
// 욕설 필터링
message = filterProfanity(message);
// 길이 제한
if (message.length() > 1000) {
throw new IllegalArgumentException("메시지는 1000자를 초과할 수 없습니다.");
}
return message.trim();
}
private String filterProfanity(String message) {
String result = message;
for (String word : FORBIDDEN_WORDS) {
result = result.replaceAll("(?i)" + Pattern.quote(word), "*".repeat(word.length()));
}
return result;
}
}
모니터링 및 로깅
1. WebSocket 메트릭 수집
@Component
@RequiredArgsConstructor
public class WebSocketMetrics {
private final MeterRegistry meterRegistry;
private final AtomicInteger activeConnections = new AtomicInteger(0);
private final Counter messagesSent;
private final Counter messagesReceived;
private final Timer messageProcessingTime;
@PostConstruct
public void initMetrics() {
Gauge.builder("websocket.connections.active")
.register(meterRegistry, activeConnections, AtomicInteger::get);
messagesSent = Counter.builder("websocket.messages.sent")
.register(meterRegistry);
messagesReceived = Counter.builder("websocket.messages.received")
.register(meterRegistry);
messageProcessingTime = Timer.builder("websocket.message.processing.time")
.register(meterRegistry);
}
public void incrementActiveConnections() {
activeConnections.incrementAndGet();
}
public void decrementActiveConnections() {
activeConnections.decrementAndGet();
}
public void recordMessageSent() {
messagesSent.increment();
}
public void recordMessageReceived() {
messagesReceived.increment();
}
public Timer.Sample startMessageProcessingTimer() {
return Timer.start(meterRegistry);
}
}
2. 구조화된 로깅
@Component
@Slf4j
public class WebSocketEventLogger {
private final ObjectMapper objectMapper;
public WebSocketEventLogger() {
this.objectMapper = new ObjectMapper();
this.objectMapper.registerModule(new JavaTimeModule());
}
public void logConnection(String sessionId, String userId, String roomId) {
try {
Map<String, Object> logData = Map.of(
"event", "websocket_connect",
"sessionId", sessionId,
"userId", userId,
"roomId", roomId,
"timestamp", Instant.now()
);
log.info("WebSocket Event: {}", objectMapper.writeValueAsString(logData));
} catch (Exception e) {
log.error("로그 작성 실패", e);
}
}
public void logDisconnection(String sessionId, String userId, String reason) {
try {
Map<String, Object> logData = Map.of(
"event", "websocket_disconnect",
"sessionId", sessionId,
"userId", userId,
"reason", reason,
"timestamp", Instant.now()
);
log.info("WebSocket Event: {}", objectMapper.writeValueAsString(logData));
} catch (Exception e) {
log.error("로그 작성 실패", e);
}
}
public void logMessage(String sessionId, String userId, String roomId,
String messageType, long processingTime) {
try {
Map<String, Object> logData = Map.of(
"event", "websocket_message",
"sessionId", sessionId,
"userId", userId,
"roomId", roomId,
"messageType", messageType,
"processingTimeMs", processingTime,
"timestamp", Instant.now()
);
log.info("WebSocket Event: {}", objectMapper.writeValueAsString(logData));
} catch (Exception e) {
log.error("로그 작성 실패", e);
}
}
public void logError(String sessionId, String userId, String errorType,
String errorMessage, Exception exception) {
try {
Map<String, Object> logData = Map.of(
"event", "websocket_error",
"sessionId", sessionId,
"userId", userId,
"errorType", errorType,
"errorMessage", errorMessage,
"stackTrace", exception != null ?
Arrays.toString(exception.getStackTrace()) : "N/A",
"timestamp", Instant.now()
);
log.error("WebSocket Error: {}", objectMapper.writeValueAsString(logData));
} catch (Exception e) {
log.error("로그 작성 실패", e);
}
}
}
트러블슈팅 및 디버깅 가이드
일반적인 문제와 해결책
1. 연결 끊김 문제
// 하트비트 구현
@Scheduled(fixedRate = 30000) // 30초마다
public void sendHeartbeat() {
if (stompClient && stompClient.connected) {
stompClient.send('/app/heartbeat', {}, JSON.stringify({
type: 'HEARTBEAT',
timestamp: new Date().toISOString()
}));
}
}
2. 메모리 누수 방지
@EventListener
public void handleSessionDisconnect(SessionDisconnectEvent event) {
String sessionId = event.getSessionId();
// 세션 관련 데이터 정리
sessionManager.removeSession(sessionId);
// Rate limiter 정리
rateLimiters.remove(sessionId);
// 메트릭 업데이트
webSocketMetrics.decrementActiveConnections();
log.info("세션 정리 완료: {}", sessionId);
}
3. 대용량 트래픽 처리
@Configuration
public class WebSocketScalingConfig {
@Bean
@Primary
public TaskExecutor webSocketTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("websocket-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}
배포 및 운영 고려사항
1. 로드 밸런싱 설정
# nginx.conf
upstream websocket_backend {
ip_hash; # 세션 유지를 위한 sticky session
server backend1.example.com:8080;
server backend2.example.com:8080;
server backend3.example.com:8080;
}
server {
listen 443 ssl http2;
server_name chat.example.com;
ssl_certificate /path/to/certificate.crt;
ssl_certificate_key /path/to/private.key;
location /ws {
proxy_pass http://websocket_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket 타임아웃 설정
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
}
2. 도커 배포 설정
# Dockerfile
FROM openjdk:17-jre-slim
WORKDIR /app
COPY target/websocket-chat-app.jar app.jar
# JVM 튜닝
ENV JAVA_OPTS="-Xms512m -Xmx2g -XX:+UseG1GC -XX:G1HeapRegionSize=16m"
# WebSocket 최적화
ENV WEBSOCKET_OPTS="-Dspring.websocket.max-sessions=1000"
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD curl -f http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "$JAVA_OPTS", "$WEBSOCKET_OPTS", "-jar", "app.jar"]
3. 쿠버네티스 배포
# k8s-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: websocket-chat-app
spec:
replicas: 3
selector:
matchLabels:
app: websocket-chat-app
template:
metadata:
labels:
app: websocket-chat-app
spec:
containers:
- name: websocket-chat-app
image: websocket-chat-app:latest
ports:
- containerPort: 8080
env:
- name: REDIS_HOST
value: "redis-service"
- name: REDIS_PORT
value: "6379"
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "2Gi"
cpu: "1000m"
livenessProbe:
httpGet:
path: /actuator/health
port: 8080
initialDelaySeconds: 30
periodSeconds: 30
readinessProbe:
httpGet:
path: /actuator/health
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: websocket-chat-service
spec:
selector:
app: websocket-chat-app
ports:
- port: 80
targetPort: 8080
type: LoadBalancer
sessionAffinity: ClientIP # Sticky session
성능 테스트 및 최적화
JMeter를 활용한 WebSocket 부하 테스트
// WebSocket 부하 테스트 시나리오
@Component
public class WebSocketLoadTester {
public void runLoadTest(int concurrentUsers, int messagesPerUser) {
ExecutorService executor = Executors.newFixedThreadPool(concurrentUsers);
CountDownLatch latch = new CountDownLatch(concurrentUsers);
for (int i = 0; i < concurrentUsers; i++) {
final int userId = i;
executor.submit(() -> {
try {
simulateUser(userId, messagesPerUser);
} finally {
latch.countDown();
}
});
}
try {
latch.await(5, TimeUnit.MINUTES);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
executor.shutdown();
}
private void simulateUser(int userId, int messageCount) {
// 사용자 시뮬레이션 로직
String username = "user" + userId;
String roomId = "room" + (userId % 10); // 10개 방에 분산
// WebSocket 연결 및 메시지 전송 시뮬레이션
// 실제 테스트에서는 WebSocket 클라이언트 라이브러리 사용
}
}
확장 기능 및 고급 패턴
1. 메시지 암호화
@Service
public class MessageEncryptionService {
private final AESUtil aesUtil;
public ChatMessage encryptMessage(ChatMessage message) {
if (message.getContent() != null) {
String encryptedContent = aesUtil.encrypt(message.getContent());
message.setEncryptedContent(encryptedContent);
message.setEncrypted(true);
message.setContent(null); // 원본 내용 제거
}
return message;
}
public ChatMessage decryptMessage(ChatMessage message) {
if (message.isEncrypted() && message.getEncryptedContent() != null) {
String decryptedContent = aesUtil.decrypt(message.getEncryptedContent());
message.setContent(decryptedContent);
message.setEncrypted(false);
message.setEncryptedContent(null);
}
return message;
}
}
2. 파일 전송 기능
@MessageMapping("/chat.sendFile")
public void sendFile(@Valid FileMessage fileMessage,
SimpMessageHeaderAccessor headerAccessor) {
try {
// 파일 유효성 검증
validateFile(fileMessage);
// 파일 저장 (클라우드 스토리지)
String fileUrl = fileStorageService.saveFile(fileMessage.getFileData());
// 채팅 메시지로 변환
ChatMessage chatMessage = ChatMessage.builder()
.type(ChatMessage.MessageType.FILE)
.content("파일: " + fileMessage.getFileName())
.sender(fileMessage.getSender())
.roomId(fileMessage.getRoomId())
.fileUrl(fileUrl)
.fileName(fileMessage.getFileName())
.fileSize(fileMessage.getFileSize())
.build();
// 브로드캐스트
messagingTemplate.convertAndSend(
"/topic/room/" + fileMessage.getRoomId(),
ChatMessageResponse.from(chatMessage)
);
} catch (Exception e) {
sendErrorToUser(headerAccessor, "FILE_ERROR", "파일 전송에 실패했습니다.");
}
}
3. 채팅 히스토리 및 검색
@RestController
@RequestMapping("/api/chat")
@RequiredArgsConstructor
public class ChatHistoryController {
private final ChatMessageService chatMessageService;
@GetMapping("/rooms/{roomId}/messages")
public ResponseEntity<Page<ChatMessageResponse>> getChatHistory(
@PathVariable String roomId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size,
@RequestParam(required = false) String searchKeyword,
Authentication authentication) {
String userId = authentication.getName();
// 권한 검증
if (!chatRoomService.isUserInRoom(userId, roomId)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
Pageable pageable = PageRequest.of(page, size, Sort.by("timestamp").descending());
Page<ChatMessage> messages;
if (searchKeyword != null && !searchKeyword.trim().isEmpty()) {
messages = chatMessageService.searchMessages(roomId, searchKeyword, pageable);
} else {
messages = chatMessageService.getMessagesByRoomId(roomId, pageable);
}
Page<ChatMessageResponse> response = messages.map(ChatMessageResponse::from);
return ResponseEntity.ok(response);
}
@GetMapping("/rooms/{roomId}/unread-count")
public ResponseEntity<Map<String, Object>> getUnreadCount(
@PathVariable String roomId,
Authentication authentication) {
String userId = authentication.getName();
int unreadCount = chatMessageService.getUnreadCount(userId, roomId);
return ResponseEntity.ok(Map.of("unreadCount", unreadCount));
}
}
마무리 및 추가 개선 사항
이 글에서 다룬 WebSocket 실시간 채팅 애플리케이션은 현대적인 웹 애플리케이션의 핵심 요구사항을 모두 충족합니다:
핵심 성과
- 확장 가능한 아키텍처: Redis Pub/Sub을 통한 다중 서버 환경 지원
- 강화된 보안: JWT 인증, WSS 프로토콜, Rate Limiting
- 실시간 성능 최적화: 연결 풀링, 메시지 압축, 효율적인 리소스 관리
- 운영 친화적: 구조화된 로깅, 메트릭 수집, 헬스체크
추가 개선 가능 영역
- AI 기반 기능: 실시간 번역, 욕설 감지, 스팸 필터링
- 고급 알림: 푸시 알림, 이메일 알림, 슬랙 연동
- 분석 및 인사이트: 사용자 행동 분석, 채팅방 활성도 측정
- 접근성: 화면 리더 지원, 키보드 내비게이션
- 오프라인 지원: PWA 구현, 오프라인 메시지 동기화
참고 자료
- Spring WebSocket Documentation
- STOMP Protocol Specification
- Redis Pub/Sub Documentation
- WebSocket Security Best Practices
- Spring Security WebSocket Configuration
이러한 종합적인 접근을 통해 확장 가능하고, 보안이 강화되며, 성능이 최적화된 실시간 채팅 애플리케이션을 구축할 수 있습니다.
실제 프로덕션 환경에서는 각 조직의 요구사항에 맞게 추가적인 커스터마이징이 필요할 수 있습니다.
'Spring & Spring Boot 실무 가이드' 카테고리의 다른 글
[Spring] Spring Boot API 성능 최적화: 실무 환경에서 검증된 5가지 핵심 전략 (2) | 2025.01.21 |
---|---|
Spring Batch로 대용량 사용자 활동 로그를 효율적으로 집계하여 실시간 보고서 자동화 시스템 구축하기 (0) | 2025.01.20 |
[Spring]Spring 개발자를 위한 Annotation 원리와 커스텀 Annotation 실습 (0) | 2025.01.18 |
Spring Boot Form 데이터 처리 완벽 가이드: x-www-form-urlencoded 파싱부터 성능 최적화까지 (2) | 2024.02.17 |
Spring AOP 완전 정복: 실전 성능 최적화와 엔터프라이즈 활용 가이드 (1) | 2024.01.21 |