HTTP, WebSocket

세팅과 연결 설정(CORS)을 끝낸 후, 진행할 다음 내용은 클라이언트의 로직과 서버의 로직을 작성하면서 로컬에서의 MVP 기능을 구현하는 것. 앱의 가장 기본적인 회원 권한을 위한 인증 기능을 구현하고, 바로 웹소켓을 기반으로 한 단체 공개 채팅 프레임을 구축하기로 했다.

1. Spring Security

백엔드를 처음 공부했을 때는 얘만큼 참 어려운 게 없었는데, 여러 번 사이드 프로젝트에서 보안 기능을 맡고 연습을 수행하면서 어느 정도의 감을 익힌 것 같다. 스프링 시큐리티에 익숙해지면서 클라이언트와 서버 간의 통신 흐름에 대해 2차적으로 공부할 수 있게 됐으니.

인증을 구현하는 방법은 DispatcherServelet을 지나고 컨트롤러 단계에서의 구현도 있을 수 있고, 이전의 Filter 단계에서 구현할 수도 있고 정답은 존재하지 않지만, 적어도 인증의 취지 상, 모든 API 요청에 있어 우선돼야 한다는 입장이라서 나는 Filter에서의 인증 로직 구현을 더 선호한다.

해서, 이번 사이드 프로젝트의 인증 로직은 스프링 시큐리티를 기반으로 Filter 단계에서 구현하였다.

1) JWT 인증 방식

스프링 시큐리티를 적용한 내 코드의 동작 방식은 위와 같다. 그리고 인증 방식을 JWT로 채택하면서 엑세스 토큰은 클라이언트에서 관리하고, 리프레시 토큰은 서버에서 관리하는 방식을 채택했다. 즉, 생명주기가 짧은 엑세스 토큰의 재발급 여부를 리프레시 토큰의 유효성을 통해 검증, 결정하는 방식인데 이를 위해서는 리프레시 토큰을 어딘가에 저장해야 한다.

기존의 RDBMS인 PostgreSQL에 저장하는 방식도 있겠지만, 연관관계 설정의 필요성이 엄청 높지 않고 보안 정책에 따라 생명주기가 변하기 쉬운 토큰의 특성 상, 입출력 및 수정이 잦을 것을 생각해서 Redis에 저장하는 방안을 채택했다.

2) Redis Config Class

스프링부트의 가장 큰 장점은, 수많은 의존성을 기반으로 다양한 외부 툴(데이터베이스, 메세지 큐, 엔진 등)과 쉽게 상호작용할 수 있는 것이다. Gradle(혹은 Maven)을 통해 다양한 툴과의 상호 의존 및 구성 처리를 수행하며 서버 사이드에서 쉽게 제어가 가능해진다. 이는 Redis도 마찬가지다.

스프링과 Redis 간의 통신을 제어하기 위한 Redis 클라이언트에는 LettuceRedisson으로 두 가지가 있다.

구분LettuceRedisson
비동기 지원비동기 및 리액티브 모델 지원동기, 비동기, 리액티브 등 다양한 모델 지원
API 수준저수준 API (Redis 명령어 직접 호출)고수준 API (분산 객체, 락, 큐 등 제공)
주요 기능단순한 Redis 명령어 실행분산 데이터 구조, 분산 락, 분산 캐시 등 지원
사용 사례Redis 명령어를 직접적으로 다루고자 할 때분산 시스템, 고급 데이터 구조 및 동기화가 필요할 때

보면 Redisson이 Lettuce에 비해 좀 더 고급 기능을 제공한다. 대표적인 것이 분산 락으로, 동시성 이슈를 해결할 수 있는 수단 중 하나다. 다만, Redisson은 별개의 외부 의존성을 추가로 설정해야 하며, 현 시점에서는 Lettuce의 제공 기능만으로도 충분히 MVP를 달성할 수 있다. 이에, Lettuce를 기반으로 MVP를 달성하고, 향후 MSA나 기타 고급 기능 구현에서 필요시, Redisson을 고려하는 것으로 결정했다.

// RedisConfig

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration redisConfiguration = new RedisStandaloneConfiguration();
        redisConfiguration.setHostName(host);
        redisConfiguration.setPort(port);
        redisConfiguration.setPassword(password);
        redisConfiguration.setDatabase(0);

        final SocketOptions socketoptions = SocketOptions.builder().connectTimeout(Duration.ofSeconds(10)).build();
        final ClientOptions clientoptions = ClientOptions.builder().socketOptions(socketoptions).build();

        LettuceClientConfiguration lettuceClientConfiguration = LettuceClientConfiguration.builder()
                .clientOptions(clientoptions)
                .commandTimeout(Duration.ofMinutes(1))
                .shutdownTimeout(Duration.ZERO)
                .build();

        return new LettuceConnectionFactory(redisConfiguration, lettuceClientConfiguration);
    }

    @Bean(name = "authTemplate")
    public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory);

        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(String.class));

        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashValueSerializer(new Jackson2JsonRedisSerializer<>(String.class));

        return redisTemplate;
    }

그래서 Redis 설정 클래스는 위와 같다. 리프레시 토큰은 사용자의 고유값 중 하나인 이메일을 기반으로 한 key를 바탕으로 저장시킨다.

2. WebSocket Stomp

채팅 앱의 핵심 기능을 구현하기 위해선 당연히 WebSocket이 필요하다. 다만, 이 WebSocket이 어떻게 동작하는지, 본래 HTTP는 클라이언트와 서버 간의 무상태성이 전제됨에도 어떻게 가능한 것인지 등에 대해서 한번 정리를 할 필요가 있을 듯하다.

1) HTTP ↔ WebSocket

위에서 언급했듯, HTTP는 서버와 클라이언트 간의 무상태성을 전제로 하기 때문에 별개의 요청이나 응답이 없을 시에는 서버와 클라이언트는 단절되어 있다. 만약 HTTP API 호출만을 기반으로 채팅을 만든다면, 실시간처럼 보이도록 상당히 잦은 주기의 요청과 응답이 반복될 것이다. 이 방식이 폴링(Polling) 방식이며 빗대자면, 집 주인이 택배가 배달될 때까지 계속 전화로 문의하는(...진상?) 것이다.

또한, 서버가 클라이언트로 일방향으로 실시간 데이터를 전송하는 기술인 SSE도 존재한다. 이것은 반대로 택배 기사가 계속 택배를 전달하면서 고객이 부재중이어도 추후에 다시 계속 찾아오는(...VIP?) 것이다.

WebSocket은 택배 기사와 고객이 서로 통화를 하는 상황이다. 이를 통해 실시간으로 택배(데이터, 정확히는 메세지)를 주고받을 수 있는 상황이 된다.

기본적으로 아래와 같은 단계를 거쳐 WebSocket으로 업그레이드가 이뤄진다.

(1) 클라이언트가 HTTP 요청으로 WebSocket 업그레이드를 요청.

GET /chat HTTP/1.1
Host: localhost
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

(2) 서버가 HTTP 101 Switching Protocols 응답을 보내 WebSocket 업그레이드를 승인.

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: # 어쩌고 저쩌고....

(3) 프로토콜이 WebSocket으로 전환되고, 클라이언트와 서버는 양방향 실시간 통신을 시작.

(4) WebSocket 프레임을 통해 데이터를 주고받으며 연결을 유지.

(5) Close 프레임을 전송하여 연결을 종료.

중요한 키워드로, WebSocket은 서버와 클라이언트 간의 연결과 관련돼서 영향을 끼친다. 서버 내에서의 로직이나 클라이언트 코드에서의 메세지 처리는 실시간 통신 기반의 전달 이후의 문제로 넘어간다. 이 부분부터는 WebSocket의 영역이 아닌데 이것을 미리 언급하는 이유는 향후 성능 튜닝에서 WebSocket의 실시간성과 맞춰 정합성을 유지시키기 위한 중요한 개념이다.

2) STOMP

단순 WebSocket은 그저 양방향 통신을 위한 프로토콜의 수준에 그친다. 그 이상의 고급 기능(메세지 프레임, 브로커와의 결합 활용)은 직접 애플리케이션 단계에서 구현해야 한다.

STOMP는 메시지 브로커와 통신하기 위한 텍스트 기반의 메시징 프로토콜로, 메시지의 전송, 구독, 라우팅 등을 정의할 수 있다. WebSocket을 기반으로 STOMP 프로토콜을 메시징 브로커(RabbitMQ 등)와 같이 활용할 수 있다.

특징WebSocketWebSocket STOMP
프로토콜 유형저수준의 양방향 통신 프로토콜STOMP 프로토콜을 사용하는 고수준 메시징 프로토콜
메시지 형식텍스트 또는 바이너리 데이터, 규격 없음명확한 메시지 형식(STOMP 명령어와 헤더)
메시징 기능없음 (애플리케이션에서 직접 처리)메시지 전송, 라우팅, 구독/발행 등의 기능을 제공
사용 사례실시간 채팅, 게임, 단순 데이터 스트리밍브로커 기반의 메시징 시스템 (예: 채팅, 알림 시스템)
메시지 브로커 연동없음메시지 브로커와 통신 가능 (RabbitMQ, ActiveMQ 등)
메시지 전송 방식클라이언트와 서버 간의 직접 데이터 전송메시지 브로커를 통한 라우팅 및 메시지 전송 관리
라우팅 및 구독 관리없음 (직접 구현해야 함)STOMP 명령을 통해 구독, 발행, 라우팅 등의 기능을 제공

3) WebSocket Config

@Configuration
@RequiredArgsConstructor
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.setApplicationDestinationPrefixes("/send");
        registry.enableSimpleBroker("/sub");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry
                .addEndpoint("/stomp/chat")
                .setAllowedOriginPatterns("*")
                .withSockJS();
    }
}

WebSocket 설정을 맡는 클래스는 WebSocketMessageBrokerConfigurer를 구현체로써 작성한다. 핸드쉐이킹을 위한 경로와 구독 경로 및 메세지 송수신 경로를 작성해준다. 사실 이게 끝이다. 이것만 작성하고 서버에서 메세지 매핑만 처리하면 단순한 채팅 앱이 완성된다.

물론 이게 다가 아니다. 결국 성능 튜닝까지 향하는 것이 내 목표이기 때문에 단순히 구현한 채팅 앱을 어떻게 성능을 측정하고 향상시킬지, 또한 채팅과 관련돼서 생각할 수 있는 고급 기능(참여자 정보, 채팅 기록 보관 등)의 구현까지 천천히 나아가볼 예정.

profile
scientia est potentia / 벨로그 이사 예정...

0개의 댓글