웹소켓(WebSocket)

김나우·2023년 5월 6일
0

WebSocket


WebSoeckt이란 HTTP 환경을 기반으로 하여 TCP/IP 연결을 통해 전이중 송신(양방향 송수신) 채널을 제공하는 컴퓨터 통신 프로토콜이다.

WebSocket의 접속 과정은 TCP/IP 접속, 웹소켓을 열기 위한 HandShake 과정으로 나뉜다.

WebSocket의 최초 연결 과정을 나타내면 아래와 같다.

  1. 서버와 클라이언트 간 TCP/IP 연결을 수립
  2. HTTP 요청 기반의 HandShake 과정을 거친다.
    -> HTTP Request 헤더에 Upgrade, Connection을 추가하여 WebSocket 요청임을 표시
  3. HandShake 연결이 끝나면, HTTP 프로토콜을 WebSocket 프로토콜로 변환하여 웹 소켓 통신 시작.

웹 소켓의 최초 접속은 HTTP Request를 통해 HandShake 과정을 거치므로 기존 HTTP 규격이나 인증을 그대로 가져올 수 있고, 기존 80, 443 포트로 접속을 하므로 추가 방화벽에 대한 설정이 필요 없다는 장점이 있다.

작동원리

서버와 클라이언트간의 웹소켓 연결을 HTTP프로토콜을 통해 이루어 진다.

연결이 정상적으로 이루어진다면 서버와 클라이언트 간에 웹소켓 연결이 이루어지고
일정 시간이 지나면 HTTP연결은 자동으로 끊어진다.

웹소켓 API는 아주 간단한 기능만을 제공하므로, 대부분의 경우 SockJs나 Socket.IO같은 오픈소스 라이브러리를 많이 사용하고 있으며, 메세지 포맷 또한 STOMP같은 프로토콜을 같이 이용한다.

문제점

  1. 프로그램 구현에 보다 많은 복잡성 초래
    -> 웹 소켓은 HTTP와 달리 Stateful protocol이기 때문에 서버와 클라이언트 간의 연결을 항상 유지해야하며, 비정상적으로 연결이 귾어졌을 때 적절하게 대응해야 한다.
  1. 서버와 클라이언트 간의 Socket 연결을 유지하는 것 자체가 비용이 든다
    -> 트래픽이 많은 서버의 경우 CPU에 큰 부담이 될 수 있다.

STOMP


메시지 송수신을 효율적으로 하기 위해 나온 프로토콜

WebSocket 프로토콜 위에서 동작한다

기본적으로 pub/sub 구조로 되어있어 메시지 송신이나 수신 처리하는 부분이 확실히 정의되어 있기 때문에 개발자 입장에선 메시징 처리할 때 STOMP 스펙의 규칙만 잘 지키면 된다는 이점이 있다.

STOMP Frame

아래의 Frame 구조로 Client-Server 간 메세지 송수신이 이루어진다.

COMMAND
header1:value1
header2:value2

Body^@

COMMAND:
메시지의 첫 줄에는 COMMAND가 위치합니다. 이 부분은 메시지의 목적을 나타내며, 예를 들어 HTTP 프로토콜에서는 GET, POST, PUT, DELETE 등의 메서드가 이에 해당한다.

header:
두 번째 줄부터 메시지의 헤더가 위치합니다. header는 key-value 쌍으로 이루어져 있으며, 메시지에 대한 추가 정보를 제공합니다. 위 예시에서는 header1과 header2가 각각의 key이고, value1과 value2가 각각의 value다.

body:
메시지의 실제 내용이 담겨있는 부분으로, header 다음 줄부터 메시지의 body가 위치합니다. 메시지의 실제 내용을 담고 있습니다. 위 예시에서는 Body가 body에 해당한다.

^@:
마지막으로, 위 메시지에서는 ^@가 존재합니다. 이는 NULL 문자를 나타내며, 메시지의 끝을 알리는 구분자로 사용된다.


Pub/Sub 방식과 Message Broker

pub/sub은 메시지를 공급하는 객체(Publisher)와 소비하는 객체(Subscriber)를 분리해
제공하는 비동기식 메시징 방법이다.

publisher가 특정 topic에 메세지를 보내면 해당 topic을 구독해 놓은 모든 subscriber에게 메세지가 전송되는 방식이다

pub/sub 패턴은 비동기식 메세징 패턴이기 때문에 publisher가 연산해야 할 다른 topic(task)을 publish 하면 topic(task)를 가져갈 subscriber가 받아서 받은 task를 처리하고, 처리하는 시간 동안 publisher는 다른 작업을 수행할 수 있다는 장점이 있다.

publisher(게시자) :
publisher는 message를 생성한 뒤에, topic에 담아두도록 전달해주는 서버다.

message(메시지) :
message는 publisher로부터 subscriber에게 최종적으로 전달되는 데이터와 property의 조합이다. 쉽게 말하자면 서로 다른 API끼리 통신을 할 데이터라고 할 수 있다.

topic(토픽), channel :
topic은 task, 즉 업무이다, publisher가 message를 전달하는 리소스다.

subscription(구독) :
subscription은 message 스트림이 subscriber들에게 전달되는 과정을 나타내는 이름을 가지고 있는 리소스다.

subscriber(구독자) :
subscriber는 message를 수신하려는 서버이다.

publisher와 subscriber는 서로에 대해 알 필요가 없다, 그러면 어떻게 message를 주고 받을까?

중간다리 역할을 해주는 브로커(broker), 혹은 버스(bus) 라고 불리는 존재가 있다.
브로커나 버스는 publisher와 subscriber 모두가 서로 알고 있는 존재로, 브로커나 버스 역할을 해줄 redis 혹은 kafka에서 지원하는 서비스를 사용한다.


Redis Pub/Sub


Redis는 STOMP 프로토콜을 지원하지 않지만, Redis가 제공하는 pub/sub 기능을 통해
메세지 브로커로 사용할 수 있다.

STOMP 프로토콜을 지원하는 RabbitMQ와 같은 전용 메시지 브로커를 사용하면
더 고도화된 기능(메세지 전달 보장, SSL 지원)을 사용할 수 있다.

1. build.gradle

implementation 'it.ozimov:embedded-redis:0.7.3'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

build.gradle에 redis 관련 의존성을 추가한다
-> 여기서는 내장(embedded)레디스를 사용한다.

2. EmbeddedRedis.config

@Configuration
@Profile("default")
@Slf4j
public class EmbededRedisConfig {

    @Value("${spring.redis.port}")
    private int redisPort;
    private RedisServer redisServer;

    @PostConstruct
    public void startRedis() throws IOException {
        redisServer = new RedisServerBuilder()
                .port(redisPort)
                .setting("maxheap 512M") // 힙 크기 조절
                .build();
        redisServer.start();
    }

    @PreDestroy
    public void stopRedis() {
        redisServer.stop();
    }
}

@Configuration
스프링 설정 클래스임을 나타낸다.
@Profile("default")
application.yml에서 spring.profiles.active를 설정하지 않으면
default 프로파일의 설정값이 사용된다.
@PostConstruct
해당 메서드가 객체 생성 이후에 실행되어야 함을 나타낸다.
@PreDestroy
어노테이션은 해당 메서드가 객체가 소멸하기 전에 실행되어야 함을 나타낸다.

3. Redis.config

@Configuration
@RequiredArgsConstructor
public class RedisConfig {

    @Value("${spring.redis.host}")
    private String host;


    @Value("${spring.redis.port}")
    private int port;

    @Bean
    public LettuceConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(host, port);
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate() {

        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();

        redisTemplate.setKeySerializer(new StringRedisSerializer());

        Jackson2JsonRedisSerializer<Object> serializer = new Jackson2JsonRedisSerializer<>(Object.class);

        ObjectMapper objectMapper = new ObjectMapper();

        objectMapper.registerModule(new JavaTimeModule());

        serializer.setObjectMapper(objectMapper);

        redisTemplate.setValueSerializer(serializer);

        redisTemplate.setHashValueSerializer(serializer);

        redisTemplate.setHashKeySerializer(serializer);

        redisTemplate.setConnectionFactory(redisConnectionFactory());

        return redisTemplate;
    }

    @Bean
    public RedisMessageListenerContainer redisMessageListenerContainer() {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(redisConnectionFactory());
        return container;
    }

}

LettuceConnectionFactory는 Redis와 연결하기 위한 Connection Factory이며, Redis 호스트 주소와 포트를 지정하여 Redis와 연결할 수 있다.

따라서 위 코드에서는 host와 port 값을 인자로 받아 LettuceConnectionFactory 객체를 생성하고, 해당 객체를 Bean으로 등록하여 Spring에서 활용할 수 있게 한다.

RedisMessageListenerContainer는 Redis에서 발생하는 이벤트를 처리하기 위한 리스너를 등록하고, 해당 이벤트를 수신하여 이벤트 핸들러에 전달합니다. 즉, Redis의 메시지를 구독하고, 이벤트가 발생하면 해당 이벤트를 처리하는 기능을 수행한다.

redisConnectionFactory() 메소드를 통해 Redis 연결을 위한 LettuceConnectionFactory 객체를 생성하고, 이를 RedisMessageListenerContainer 객체에 설정하여 Bean으로 등록합니다. 이렇게 Bean으로 등록된 RedisMessageListenerContainer는 다른 Bean에서 DI(Dependency Injection)을 받아 사용할 수 있다.

//추후 추가예정..

profile
안녕하세요

0개의 댓글