초오오오오오짜개발자의낙서장

SSE란? 본문

Back-end

SSE란?

코딩하는곰팅이 2025. 10. 20. 17:40

SSE - Server Sent Event

- 클라이언트는 서버로 부터 데이터를 받을수만 있다.

- 웹소켓 과는 달리 서버로부터 오는 단방향 데이터 통신이다.(mono-directional)

- 서버에서 클라이언트로 실시간 이벤트를 전달하는 웹 기술

 

배경

- HTTP 특징인 비연결성은 연결한 적이 있어도 연결을 끊어버린다는 것이다.

- 이를 해결하기 위한 웹 기술은 Polling, Long Polling, WebSocket, SSE가 있다.

- 여기서 SSE는 단방향 통신이며 클라이언트의 별도 추사요청 없이 서버에서 업데이트를 스트리밍 할 수 없다는 특징을 가진다.

 

장점

- HTTP를 통해 통신하므로 다른 프로토콜은 필요가 없고, 구현이 굉장히 쉽다.

- 네트워크 연결이 끊겼을 때 자동으로 재연결을 시도.

- 실시간으로 서버에서 클라이언트로 데이터를 전송, 폴링 같은 경우는 실시간으로 보기 어려운데 이러한 한계를 극복.

단점

- GET 메소드만 지원하고, 파라미터를 보내는데 한계가 있다.

- 단방향 통신이며, 한 번 보내면 취소가 불가능하다.

- 클라이언트가 페이지를 닫아도 서버에서 감지하기가 어렵다.

- SSE는 지속적인 연결을 유지해야 하므로 많은 클라이언트가 동시에 연결을 유지할 경우 서버 부담이 커질수 있다.

 

타임아웃

- 클라이언트 측에서 일정 시간동아 서버로부터 데이터를 받지 못할 경우에 발생하는 상황.

- 타임아웃을 무제한으로 설정하면 Connection을 계속 유지할수 있지만 서버 리소스를 계속 사용하며 클라이언트 수가 증가할수록 서버 부하도 증가할 가능성이 있다.

- 무한한 연결 시간 설정은 보안 문제와 무단 접근 가능성을 높일수 있다.

- 이를 방지하기위해 타임아웃 시간을 설정하고 브라우저에서 자동으로 서버에 재연결 요청을 보내는 것이다.

 

실핻 과정

- 클라이언트가 서버의 이벤트로 구독하기 위한 요청을 보낸다.

- 서버에서는 클라이언트와 매핑되는 sse 객체를 만든다.

- 서버는 이벤트 스트림을 생성하고 클라이언트에게 비동기적으로 데이터를 전송한다.

 

1. Service에 SSE Emitter를 생성하고, 타임아웃을 설정 해 준다.

public class NotificationService {
        
        //타임아웃 설정
    private static final Long DEFAULT_TIMEOUT = 60L * 1000 * 60;
   	private final NotificationRepository notificationRepository;

    
        //SSE Emitter를 생성하는 메소드
    private SseEmitter createEmitter(Long id) {
        SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT);
        //생성된 SSE Emitter를 저장소에 저장
        notificationRepository.save(id, emitter);

        // Emitter가 완료될 때(모든 데이터가 성공적으로 전송된 상태) Emitter를 삭제한다.
        emitter.onCompletion(() -> notificationRepository.deleteById(id));
        // Emitter가 타임아웃 되었을 때(지정된 시간동안 어떠한 이벤트도 전송되지 않았을 때) Emitter를 삭제한다.
        emitter.onTimeout(() -> notificationRepository.deleteById(id));

        return emitter;
    }
    
    }

- 타임 아웃을 1시간으로 설정

- SSE Emitter를 생성하는 메소드를 작성하였고, Repo에 저장까지 하였다.

- 한번 전송한 SSE Emitter는 단방향 통신을 위한 일회성 객체이기 때문에 한번 사용하고 나면 폐기되고 다시 생성해야한다.

- 전송 중 오류가 발생하거나 타임 아웃이 발생했을 경우에도 마찬가지이다.

 

2. 클라이언트에게 데이터를 전송하는 메소드를 설정한다.

    	//데이터를 클라이언트에게 보내는 메소드
    private void sendEvent(Long sendId, Object data) {
        // 먼저 클라이언트의 SseEmitter를 가져온다
        SseEmitter emitter = notificationRepository.get(sendId);
        if (emitter != null) {
            try {
                // 데이터를 클라이언트에게 실어보낸다.
                emitter.send(SseEmitter.event().id(String.valueOf(sendId)).name("업무수정").data(data));
            } catch (IOException exception) {
                // 데이터 전송 중 오류가 발생하면 Emitter를 삭제하고 에러를 완료 상태로 처리
                notificationRepository.deleteById(sendId);
                emitter.completeWithError(exception);
            }
        }
    }

 

- Repo에서 해당 Id에 할당된 SseEmitter를 가져온다.

- Emitter가 존재하면 업무수정 이라는 데이터를 실어서 전송되게 끔 만들었다.

 

3. 클라이언트가 구독을 호출하는 메소드를 만든다.

- 클라이언트  Copntroller에서 구독 페이지 엔드 포인트를 생성하는데 사용된다.

public SseEmitter subscribe(Long userId,final HttpServletResponse response) {
        SseEmitter emitter = createEmitter(userId);
        response.setContentType("text/event-stream");
        response.setCharacterEncoding("UTF-8");
        sendEvent(userId, "더미데이터" + userId + "]");
        return emitter;
    }

- userID를 이용하여 클라이언트와 매핑 되는 Emitter를 생성해준다.

- sendEvent를 이용하여 데이터를 보내주는 이유는 클라이언트와의 초기 연결에서 아무 이벤트도 전송하지 않으면, 재연결 요청이나 연결 자체에서 오류가 발생할 수 있다.

- 첫 SSE 응답을 보낼시 더미 데이터를 넣어 이러한 오류를 방지한다.

 

4. 구독 페이지를 만든다.

   private final NotificationService notificationService;
    
    //구독 페이지
    @GetMapping(value = "/subscribe/{id}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter subscribe(@PathVariable Long id) {
        return notificationService.subscribe(id);
    }

    @PostMapping("/send-data/{id}")
    public void sendData(@PathVariable Long id) {
        notificationService.notify(id, "data");
    }
}

 

- /notifications/subscribe/{id} 경로로 get 요청이 오면 notification Service를 사용하여 해당 ID에 대한 SSEEmitter를 생성하고 반환한다.

- 해당 경로로 POST 요청이 오면 notificationService를 사용하여 해당 ID에 대한 데이터를 클라이언트에게 전송한다.

 

5. Repository를 생성해준다.

@Repository
@RequiredArgsConstructor
public class NotificationRepository {
    // 모든 Emitters를 저장하는 ConcurrentHashMap -> 여기서 이걸 쓴 이유 .
    private final Map<Long, SseEmitter> emitters = new ConcurrentHashMap<>();

    //Emitters 저장
    public void save(Long id, SseEmitter emitter) {
        emitters.put(id, emitter);
    }
    //Emitter 제거
    public void deleteById(Long id) {
        emitters.remove(id);
    }
    //Emitter 가져오기
    public SseEmitter get(Long id) {
        return emitters.get(id);
    }
}

 

- Emitter 저장 - 클라이언트에게 보낼 id와 데이터를 저장한다.

- Emitter 가져오기 - 정보를 담기 위해서 클라이언트(id)의 Emitter를 가져와야한다.

- Emitter 제거 - Emitter가 완료될 때, 타임 아웃 되었을 때, 오류가 발생했을 때 사용된다.

 

SSE 사용시 주의할 점

 

- Emitter를 생성한 후 만료 시간까지 아무 데이터도 보내지 않으면, 재연결 요청 시 503 service unable 에러가 발생할수 있다.

- 이를 방지하기 위해 초기 SSE 연결시 더미 데이터를 전송하여 안전한 연결을 유지한다.

 

- Thread-safe한 구조를 사용하지 않으면 ConcurrnetModificationException이 발생할수 있다

- 타임아웃 발생 시 실행할 콜백이 SseEmitter를 관리하는 다른 스레드에서 실행되기 때문이다.

- CopyOnWriteArrayList를 사용할 수도 있다.

 

- Jpa를 사용하는동안 open-in-view 속성을 true 로 설정하면 DB connection POOL에서 동시에 많은 클라이언트가 SSE 연결을 시도할 경우 DB 커넥션 고갈이 발생할 수 있다.

- 이를 방지하기 위해 SSE 연결 동안에는 open-in-view 속성을 false로 설정하여 http connection이 닫힐 때마다 DB connection도 해제되도록 한다.

 

* open-in-view란?

 - 웹 애플리케이션에서 데이터베이스와 상호작용할 때 트랜잭션을 요청 응답 주기와 일치시키는 방법을 할한다.

  Socket Server Sent Event
브라우저 대부분의 브라우저 가능 대부분 모던 브라우저 가능 (polyfills 가능)
통신 방향 양방향 단방향(서버 -> 클라)
리얼 타임 yes yes
Data Format Binary, UTF-8 UTF-8
자동 재접속 No Yes(3초)
최대 동시 접속 수 브라우저 연결 한도는 없지만,
서버 세팅에 따라 다름
HTTP 는 브라우저당 6개/
HTTP 는 100개
프로토콜 websocket HTTP
배터리 소모량 작음
Firewall 친화적 Yes Yes

 

- 브라우저가 SSE를 지원하는지 안하는지 알아보는 방법이 JS로 존재한다.

if ('EventSource' in window) {
  // use polyfills
}
var source = new EventSource('URL');

 

- SSE를 사용하려면 헤더를 다음과 같이 보내야 한다.

 

// 먼저 해더를 아래와 같이 보내야 합니다.
const headers = {
    'Content-Type': 'text/event-stream',
    'Connection': 'keep-alive',
    'Cache-Control': 'no-cache',
}

- content-type : 브라우저에게 우리가 text 형태의 이벤트 스트림을 보낼 꺼다 라고 말해주는 부분.

- Connection : 브라우저에게 커낵션을 닫지 말고 계속 열어두고 기다리고 있어라.

- Cach-Control : 계속 새로운 데이터가 올 것이기 때문에 캐시는 필요 없음으로 no-chace

 

// 이제 서버에서 어떤 이벤트가 발생 했을 때 데이터 클라이언트로 보내기

// 예시 서버 최초 9+ 강화 성공
const notifyUser = (req, res) => {
  const payload = {...}
  clients.forEach(client =>
  	client.response.write(`data: ${JSON.stringify(payload)}\n\n`)
}

- 위와 같이 데이터의 맨 끝에 \n\n을 써줘야 스트림이 끝이라고 인식한다.

 

- 버퍼가 있으면 클라이언트에게 바로 안 보내고 잠시 기다리게 된다.

 

- HTTP header - X-Accel-Buffering : no

- nginx config - proxy_buffering off

- 노드로는 10000커넥션 까지 가능.

 

 

 

 

 

출처

https://surviveasdev.tistory.com/entry/%EC%9B%B9%EC%86%8C%EC%BC%93-%EA%B3%BC-SSEServer-Sent-Event-%EC%B0%A8%EC%9D%B4%EC%A0%90-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B3%A0-%EC%82%AC%EC%9A%A9%ED%95%B4%EB%B3%B4%EA%B8%B0

 

웹소켓 과 SSE(Server-Sent-Event) 차이점 알아보고 사용해보기

최근에 어떤 이벤트가 생겼을 때 client side에 ui를 업데이트해야 되는 기능을 구현해야 됐었습니다. 처음에는 이런 경우에 사용할 수 있는 것이 socket 밖에 몰라서 socket.io를 사용해서 socket으로 만

surviveasdev.tistory.com

 

https://velog.io/@black_han26/SSE-Server-Sent-Events

 

SSE (Server-Sent-Events) 란?

SSE란 무엇인지 알아보고, Spring을 통해 구현을 해보았다.

velog.io

 

'Back-end' 카테고리의 다른 글

Proxy 서버  (0) 2025.10.13
CORS 에러  (3) 2025.08.17