간단한 채팅(Spring, WebSocket, Redis)

박찬섭·2025년 3월 29일

스프링

목록 보기
14/14

목적

최대한 다른 기능들을 제외하고 단순한 채팅 프로그램구현으로
전체적인 뼈대를 구성해본다.

흐름

  1. 클라이언트는 서버와 웹소켓 연결을 시도한다.
  2. 서버는 메세지를 수신하여 Redis에 저장하고 해당 토픽을 구독한 구독자들에게 메세지를 전달한다.

WebSocket 설정

@Configuraion
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
	@Override
    public void configurMessageBroker(MessageBrokerRegistry registry) {
    	registry.enableSimpleBroker("/topic");
        registry.setAppliactionDestinationPrefixes("/app");
    }
    
    @Override
    public void registerStompEndPoints(StompEndPointRegistry registry) {
    	registry.addEndPoint("/ws")
        		.setAllowedOrigins("http://127.0.0.1:5500")
                .withSockJs();
    }
}

메세지 전달 위주의 웹소켓 사용으로 @EnableWebSocketMessageBroker 어노테이션을 사용해서 SimpMessagingTemplate가 등록되어 사용할 수 있다.

configureMessageBroker

  • enableSimpleBroker("/topic")으로 클라이언트가 구독할때 기본 경로를 선언한다.
  • setApplicationDestinationPreFix("/app")으로 클라이언트가 메세지를 보낼때 기본 경로를 선언한다.

RegisterStompEndPoints

  • addEndPoints()로 클라이언트가 웹소켓 연결을 하기 위한 경로를 선언한다.
  • setAllowedOrigins()로 서버에서 허용할 소스를 정한다. 이외의 경로는 CORS에러 발생
  • withSockJS() WebSocket을 사용하지 못하는 클라이언트를 위해 webSocket통신을 못하면 sockJs방식으로 대응

메세지 수신

@Getter
public class MessageDto {
	private String sender;
    private String content;
    
    public MessageDto() {}
}

//-----------------------------------------------------------------------------------------------

private final SimpMessagingTemplate simpMessagingTemplate;
private final RedisTemplate<String, Object> redisTemplate;

@MessageMapping("/{roomId}")
public void takeAndSendMessage(
	@DestinationVariable Long roomId,
    @PayLoad MessageDto message
) {
	String key = "roomId" + roomId;
	long ago = Duration.ofDays(1).toMillis();
	redisTemplate.opsForZset().add(key, message, ago);
    
    simpMessagingTemplate.convertAndSend("/topic/" + roomId, message);
}

레디스가 MessageDto를 직렬화 하기 위해서 getter, 기본 생성자가 필요하다.

메세지의 저장 기한 + 시간순으로 정렬 을 위해 Zset 자료구조를 사용한다.

opsForZset().add(key, value, score)


simpMessagingTemplate.convertAndSend(sub, payLoad);
sub의 경로를 구독한 클라이언트들에게 payload 전달


Redis

@ConfigurationProperties(prefix = "spring.redis")
@Setter
@Getter
public class RedisProperties {
	private String host;
    private int port;
    private String password;
}


@Configuration
public class RedisConfig {
	private final RedisProperties redisProperties;
    
    public RedisConfig(RedisProperties redisProperties) {
    	this.redisProperties = redisProperties;
    }
    
	@Bean
    public RedisConnectionFactory redisConnectionFactory() {
    	RedisStandAloneConfiguration configuration = new RedisStandAloneConfiguration();
        configuration.setHost(redisProperties.getHost());
        configuration.setPort(redisProperties.getPort());
        configuration.setPassword(redisProperties.getPassword());
        
        return new LettusConnectionFactory(configuration);
    }
    
    @Bean
    public class RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
    	RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(connectionFactory);
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        
        return redisTemplate;
    }
}

레디스의 연결설정을 위해 RedisConnectionFactory를 설정한다.

단일 레디스로 설정하기 때문에 RedisStandAloneConfiguration로 설정하여
host, port, password등을 기입한다.


이후 설정한 connectionFactory를 활용하기 위한 Template도 key, value의 직렬화 방법을 직접 선언한다.

key의 경우 문자열로 직렬화 하는 방법을 선택하고 value는 GenericJackson2JsonRedisSerializer로 선택한다.

value를 json형식으로 직렬화 시키지 않는다면 오류가 발생한다.


ChatController

@RestController
@RequestMapping("/chat")
@RequiredArgsConstructor
public class ChatController {

    private final RedisTemplate<String, Object> redisTemplate;
    private final SimpMessagingTemplate messagingTemplate;

    // In-memory WebSocket session management
    private final Map<String, List<WebSocketSession>> chatRooms = new ConcurrentHashMap<>();

    @MessageMapping("/sendMessage/{roomId}")
    public void sendMessage(@DestinationVariable String roomId, MessageDto message) {
        String redisKey = "chatroom:" + roomId;
        // Redis에 메시지 저장 (24시간 TTL 설정)
        redisTemplate.opsForList().rightPush(redisKey, message);
        redisTemplate.expire(redisKey, Duration.ofHours(24));

        // WebSocket으로 메시지 즉시 전달
        messagingTemplate.convertAndSend("/topic/" + roomId, message);
    }

    @GetMapping("/messages/{roomId}")
    public List<Object> getMessages(@PathVariable String roomId) {
        String redisKey = "chatroom:" + roomId;

        // Redis에서 메시지 조회
        return redisTemplate.opsForList().range(redisKey, 0, -1);
    }
}

ChatDto

public class ChatDto {
    @Getter
    @Setter
    public static class MessageDto {
        private String sender;
        private String content;
        private String timestamp;


        public MessageDto(String sender, String content, String timestamp) {
            this.sender = sender;
            this.content = content;
            this.timestamp = timestamp;
        }
    }
}

RedisConfig

@Configuration
public class RedisConfig {
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        template.setKeySerializer(new StringRedwisSerializer());
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        return template;
    }

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration config = new RedisStandaloneConfiguration();
        config.setHostName("localhost");
        config.setPort(6379);
        config.setPassword("qwer1234");
        return new LettuceConnectionFactory(config);
    }
}

WebSocketConfig

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/topic"); //클라이언트 입장에서 해당 주소로 구독
        registry.setApplicationDestinationPrefixes("/app");     //클라이언트 입장에서 해당 주소로 메세지 전송
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws")                             //클라이언트에서는 해당 주소로 웹소켓 연결 시도
                .setAllowedOriginPatterns("http://172.25.224.1:5500") // 서버에서 허용하는 클라이언트 주소
                .withSockJS();                                          //ws를 사용하지 않는 클라이언트는 sockjs 지원함
    }
}

WebCorsConfig

@Configuration
public class WebCorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedMethods("POST", "GET")
                .allowedOrigins("http://172.25.224.1:5500")
                .allowCredentials(true);
    }
}
profile
백엔드 개발자를 희망하는

0개의 댓글