본문 바로가기
Spring & Spring Boot 실무 가이드

WebSocket으로 실시간 채팅 애플리케이션 완벽 구현 가이드 - Spring Boot & STOMP

by devcomet 2025. 1. 19.
728x90
반응형

WebSocket으로 실시간 채팅 애플리케이션 완벽 구현 가이드 - Spring Boot & STOMP - 썸네일
WebSocket으로 실시간 채팅 애플리케이션 완벽 구현 가이드 - Spring Boot & STOMP

 

실시간 통신의 필요성이 날로 증가하는 현대 웹 개발 환경에서 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의 핵심 이점

  1. 표준화된 메시지 포맷: 구조화된 프레임 기반 메시징
  2. Pub/Sub 패턴 지원: 채팅방별 구독/발행 메커니즘
  3. Spring Framework 완벽 통합: @MessageMapping 어노테이션은 @RequestMapping과 비슷한 역할을 한다
  4. 외부 메시지 브로커 연동: 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
  • 실시간 성능 최적화: 연결 풀링, 메시지 압축, 효율적인 리소스 관리
  • 운영 친화적: 구조화된 로깅, 메트릭 수집, 헬스체크

추가 개선 가능 영역

  1. AI 기반 기능: 실시간 번역, 욕설 감지, 스팸 필터링
  2. 고급 알림: 푸시 알림, 이메일 알림, 슬랙 연동
  3. 분석 및 인사이트: 사용자 행동 분석, 채팅방 활성도 측정
  4. 접근성: 화면 리더 지원, 키보드 내비게이션
  5. 오프라인 지원: PWA 구현, 오프라인 메시지 동기화

참고 자료

이러한 종합적인 접근을 통해 확장 가능하고, 보안이 강화되며, 성능이 최적화된 실시간 채팅 애플리케이션을 구축할 수 있습니다.

실제 프로덕션 환경에서는 각 조직의 요구사항에 맞게 추가적인 커스터마이징이 필요할 수 있습니다.

728x90
반응형