Server-Sent Events (SSE)를 활용한 채팅방 개수 실시간으로 나타내기

코딩을 합시다·2023년 2월 21일
0

프로젝트를 진행하는 과정에서 실시간으로 채팅방 개수를 나타내야 하는 기능이 추가가 되었는데 처음에는 단순히 Socket으로 하면 되지 않을까 생각했는데 다른 방식을 시도해보고 싶었다.
그래서 인터넷을 뒤진 결과 SSE 방식을 알게되었다.


SSE 방식이란?

  • SSE는 웹 브라우저와 서버 간 단방향 통신 방식입니다.
  • 서버에서 클라이언트로 정보를 전송할 수 있습니다.
  • 새로운 채팅방이 생성될 때, 서버에서 클라이언트로 정보를 전송하여 실시간으로 업데이트합니다.
  • 서버에 부하가 웹소켓에 비해 적은 편입니다.

서버에 부하가 웹소켓에 비해 적다는게 정말 맘에 들었다.
아래부분은 내가 사용한 코드다.


SseController.java

package shop.dodotalk.dorundorun.sse.Controller;

import java.io.IOException;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import shop.dodotalk.dorundorun.chatroom.repository.ChatRoomRepository;
import shop.dodotalk.dorundorun.sse.Entity.SseEmitters;

@Slf4j
@Controller
@RequiredArgsConstructor
public class SseController {
    private final SseEmitters sseEmitters;
    @GetMapping("/ssehtml")
    public String ssehtml() {
        return "ssechatroom";
    }

    @ResponseBody
    @GetMapping(value = "/connect", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public ResponseEntity<SseEmitter> connect() {
        SseEmitter emitter = new SseEmitter();
        sseEmitters.add(emitter);
        try {
            emitter.send(SseEmitter.event()
                    .name("connect")
                    .data("connected!"));
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
        return ResponseEntity.ok(emitter);
    }

    @ResponseBody
    @GetMapping("/count")
    public ResponseEntity<Void> count() {
        System.out.println("count 실행중");
        sseEmitters.count();
        return ResponseEntity.ok().build();
    }
}

SseEmitters.java

package shop.dodotalk.dorundorun.sse.Entity;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import shop.dodotalk.dorundorun.chatroom.entity.ChatRoom;
import shop.dodotalk.dorundorun.chatroom.repository.ChatRoomRepository;

import java.io.IOException;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicLong;

@Slf4j
@Component
@RequiredArgsConstructor
public class SseEmitters {
    private static final AtomicLong counter = new AtomicLong();
    private final List<SseEmitter> emitters = new CopyOnWriteArrayList<>();

    public SseEmitter add(SseEmitter emitter) {
        this.emitters.add(emitter);
        System.out.println("emitter : " + emitter);
        System.out.println("emitter list size: " + emitters.size());
        emitter.onCompletion(() -> {
            System.out.println("만료됨");
            this.emitters.remove(emitter);    // 만료되면 리스트에서 삭제
        });
        emitter.onTimeout(() -> {
            System.out.println("타임아웃");
            emitter.complete();
        });

        return emitter;
    }

    public void count() {
        long count = counter.incrementAndGet();
        emitters.forEach(emitter -> {
            try {
                emitter.send(SseEmitter.event()
                        .name("count")
                        .data(chatRooms.size()));
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        });
    }
}

  1. SseEmitter를 사용해서 구현했으며 SseEmitter의 send 함수를 활용해 이름과 데이터 값을 보내주고 있다.
  2. produces = MediaType.TEXT_EVENT_STREAM_VALUE를 사용하여 Server-Sent Events 프로토콜을 사용하고 있다는 것을 나타내서 클라이언트와 서버 간의 데이터 통신을 정확하게 하기 위해 작성했다.
  3. AtomicLong 클래스는 Java에서 제공하는 long 타입의 값을 동시성 환경에서 안전하게 읽고 쓸 수 있도록 하는 클래스로 AtomicLong은 내부적으로 CAS(Compare-and-Swap) 연산을 사용하여 값을 변경하기 때문에 다중 스레드 환경에서 안전하게 값을 변경할 수 있습니다.
  4. CopyOnWriteArrayList는 스레드 세이프한 리스트로, 동시성 환경에서 사용하기 적합한 자료구조입니다. ArrayList와 비슷하지만 CopyOnWriteArrayList는 내부적으로 복사본을 만들어 수정 작업을 수행하므로, 동시에 여러 스레드가 리스트를 수정하더라도 원본 리스트에는 영향을 주지 않습니다.
    여러 스레드에서 동시에 리스트에 접근하거나 수정 작업을 수행해야 할 때 CopyOnWriteArrayList를 사용하면, 스레드 간의 동시성 문제를 해결하면서 안전하게 작업을 수행할 수 있습니다. 그러나 CopyOnWriteArrayList는 동시성 작업이 많을 경우에도 복사본을 만들어야 하기 때문에, 성능상의 이슈가 발생할 수 있습니다. 따라서 작은 크기의 리스트나 적은 동시성 작업이 필요한 경우에 사용하는 것이 좋습니다.
  5. incrementAndGet() 메서드는 AtomicLong 객체의 값을 1 증가시키고, 그 값을 반환시켜준다.
  6. CAS 연산은 변수의 예상값과 현재값이 일치할 때만 값을 변경하도록 보장하는 원자적 연산이다.

ssehtml.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>SSE Example</title>
</head>
<body>
<div id="count"></div>
<button onclick="handleCountEvent()">클릭</button>

<script>
    const sse = new EventSource("http://localhost:8080/connect");

    sse.addEventListener('connect', (e) => {
        const { data: receivedConnectData } = e;
        console.log('connect event data:', receivedConnectData);  // "connected!"
    });

    sse.addEventListener('count', e => {
        const { data: receivedCount } = e;
        console.log("count event data:", receivedCount);
        handleCountEvent(receivedCount);
    });

    function handleCountEvent(count) {
        console.log("count event data:", count);
        const countElement = document.getElementById('count');
        countElement.innerText = `현재 채팅방 수: ${count}`;
    }
</script>
</body>
</html>

1. EventSource를 사용해서 Sse 연결을 수행했으며 EventSource는 연결이 끊어져도 다시 재연결해주는 기능이 있어서 클라이언트에서 Sse의 연결이 끊길때마다 다시 재연결을 할 수 있다.

--- 서버 배포시 ---
만약 NGINX를 사용하고 있다면 HTTP1.1로 통신 버전을 업그레이드 시켜줘야한다.

  • Nginx는 기본적으로 Upstream으로 요청을 보낼때 HTTP/1.0 버전을 사용하기 때문에 직접 수동으로 1.1 버전을 지정해줘야한다.
  • HTTP/1.0을 사용하면 SSE에서 사용되는 지속 연결(persistent connection)이 자동으로 끊어지게 된다. 이는 HTTP/1.0의 프로토콜 스펙상 연결 유지를 위한 헤더가 제공되지 않기 때문입니다.
  • $service_url의 경우 Nginx에서 사용중인 백엔드의 서버 주소를 적어주면된다 나는 127.0.0.1:8081로 service_url 변수가 만들어져 있다.
  • proxy_set_header Connection ''; --> Connection 의 값을 빈 값 즉 ''로 만들어 준다. 이 설정을 사용하면 Nginx가 서버로부터 받은 Connection 헤더를 무시하고, 클라이언트와의 연결을 계속 유지할 수 있습니다.

참고 : https://tecoble.techcourse.co.kr/post/2022-10-11-server-sent-events/

0개의 댓글