ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • SSE 연결이 다시 붙을 때 메시지가 중복되는 이유
    Programming 2026. 9. 21. 12:29
    728x90
    반응형

    나무 말뚝 사이를 지나며 한 번 되돌아가는 흰 실
    연결이 이어졌다 다시 붙는 경로를 표현한 AI 생성 개념 이미지입니다.

    SSE를 재연결할 때는 연결 성공과 이벤트 처리 완료를 구분하고 같은 이벤트를 식별할 기준을 정하세요.

    네트워크가 잠깐 끊겼다가 연결됐는데 같은 알림이 두 번 추가됩니다. 재연결은 성공했지만 화면에 반영한 이벤트의 범위를 제대로 구분하지 못했을 수 있습니다. 반대로 중복을 피하려다 아직 처리하지 않은 이벤트까지 건너뛰는 경우도 있습니다.

    SSE의 자동 재연결은 통신을 이어 가는 기능이지 업무 처리를 정확히 한 번 끝냈다는 보증이 아닙니다. 이 글은 2026년 9월 13일 EventSource와 HTML 명세를 기준으로, 공개 작업 진행 상태를 표시하는 가상 예시를 다룹니다. 실제 SSE 서버를 실행하거나 연결을 끊어 측정한 기록은 아닙니다.

    이벤트 ID는 연결을 이어 갈 단서입니다

    SSE 서버는 text/event-stream 형식으로 이벤트를 보냅니다. 각 이벤트는 빈 줄로 구분하며 data가 본문, event가 이벤트 이름, id가 이벤트 식별자에 해당합니다. 다음은 가상 작업 job-7의 두 진행 상태입니다. 각 블록 뒤에는 빈 줄이 필요합니다. SSE 메시지 형식

    id: job-7:41
    event: progress
    data: {"jobId":"job-7","seq":41,"completed":12,"total":100,"status":"running"}
    
    id: job-7:42
    event: progress
    data: {"jobId":"job-7","seq":42,"completed":18,"total":100,"status":"running"}
    
    

    이 숫자의 의미는 앱이 정한 계약입니다. 이 예시에서는 작업 안에서 seq가 증가하고 같은 seq의 데이터는 바뀌지 않는다고 정합니다. jobId는 다른 작업과의 충돌을 피하는 범위입니다. SSE 자체가 모든 id를 숫자 순서로 해석하거나 전역 유일성을 검사해 주는 것은 아닙니다.

    EventSource가 같은 연결 객체에서 재연결할 때는 마지막 이벤트 ID가 비어 있지 않으면 Last-Event-ID 헤더로 서버에 알릴 수 있습니다. 서버는 이 값을 보고 어디에서 이어 줄지 결정할 수 있습니다. Last-Event-ID의 역할

    하지만 서버가 지난 이벤트를 보관하고 재생하는 기능은 별도로 구현해야 합니다. 헤더를 받는 것만으로 사라진 이벤트가 복구되지는 않습니다. 해당 ID 다음부터 보낼지, 경계 이벤트를 다시 포함할지, 너무 오래된 ID는 어떻게 처리할지도 서버와 클라이언트가 합의해야 합니다.

    마지막으로 받은 ID와 마지막으로 처리한 ID는 다릅니다

    브라우저는 이벤트 스트림을 해석하면서 마지막 이벤트 ID를 갱신합니다. 앱의 데이터베이스 저장이나 비동기 처리 성공을 기다린 뒤에만 그 값을 전진시키는 구조가 아닙니다. 이벤트 디스패치와 ID 갱신 순서가 명세에 따로 정의돼 있습니다. 이벤트 ID와 디스패치 순서

    예를 들어 42번 이벤트를 받았지만 화면 반영 전에 JSON 해석이나 앱 로직이 실패했다고 하겠습니다. 재연결의 Last-Event-ID만 믿고 “42번까지 처리가 끝났다”고 보면 실제 처리 상태와 어긋날 수 있습니다. 수신 위치는 업무 완료 확인서가 아닙니다.

    반대 상황도 생각할 수 있습니다. 서버가 경계 확인을 위해 42번을 다시 보내면 이미 42번을 반영한 앱이 같은 알림을 한 번 더 추가할 수 있습니다. 연결 객체를 중복 생성해 서로 같은 이벤트를 받는 경우도 진단 후보입니다.

    따라서 중복이 보이면 서버가 보낸 id와 본문, 현재 활성 연결 수, 앱이 마지막으로 반영한 위치를 함께 봅니다. “재연결에서 중복이 생겼다”는 관찰만으로 네트워크나 서버 한쪽이 잘못했다고 단정하지 않습니다.

    진행 상태는 누적 증가보다 현재 값으로 표시할 수 있습니다

    이 예시의 이벤트는 “6개를 더 완료했다”가 아니라 “현재 18개를 완료했다”는 전체 진행 상태입니다. 같은 스냅샷이 다시 와도 18을 한 번 더 더하지 않고 현재 값을 대입할 수 있습니다. 이벤트 설계가 중복의 영향을 줄이는 데 도움이 됩니다.

    다음은 #progress와 #connection 요소가 준비된 모듈 스크립트의 예시입니다. 스트림은 같은 출처의 공개 테스트 작업에 대한 것으로 가정합니다. seq와 id는 서버가 앞서 정한 불변·증가 계약을 지켜야 합니다.

    const progress = document.querySelector("#progress");
    const connection = document.querySelector("#connection");
    if (!progress || !connection) {
      throw new Error("진행 상태 요소가 없습니다.");
    }
    
    const jobId = "job-7";
    let lastApplied = -1;
    const source = new EventSource("/events/job-7");
    
    source.addEventListener("progress", (event) => {
      try {
        const data = JSON.parse(event.data);
        if (
          data.jobId !== jobId ||
          !Number.isSafeInteger(data.seq) || data.seq < 0 ||
          event.lastEventId !== jobId + ":" + data.seq ||
          !Number.isSafeInteger(data.completed) || data.completed < 0 ||
          !Number.isSafeInteger(data.total) || data.total < data.completed ||
          !["running", "done", "failed"].includes(data.status)
        ) {
          throw new Error("이벤트 형식 또는 식별자가 올바르지 않습니다.");
        }
        if (data.seq <= lastApplied) return;
        progress.textContent =
          data.completed + " / " + data.total + " (" + data.status + ")";
        lastApplied = data.seq;
        if (data.status !== "running") {
          source.close();
          connection.textContent = "최종 작업 상태를 받아 구독을 종료했습니다.";
        }
      } catch (error) {
        source.close();
        connection.textContent = "이벤트 적용 실패: 현재 상태를 다시 조회해야 합니다.";
      }
    });
    
    source.onopen = () => {
      connection.textContent = "이벤트 연결됨";
    };
    source.onerror = () => {
      connection.textContent = source.readyState === EventSource.CLOSED
        ? "이벤트 연결 종료"
        : "연결이 끊겼습니다. 재연결을 기다립니다.";
    };
    
    export function stopWatching() {
      source.close();
      connection.textContent = "진행 상태 구독을 종료했습니다.";
    }
    

    event: progress를 보냈으므로 onmessage가 아니라 이름이 같은 이벤트 리스너를 등록했습니다. 이름이 없는 기본 이벤트와 구분해야 “연결은 됐는데 데이터가 안 온다”는 오해를 피할 수 있습니다. 이름 있는 이벤트 수신

    이 예시는 각 진행 이벤트에 고유한 id를 보내도록 요구합니다. id를 생략한 이벤트에는 앞서 받은 ID가 이어질 수 있으므로 lastEventId가 있다는 사실만으로 이번 이벤트의 고유 번호가 생겼다고 생각하면 안 됩니다. 이벤트 ID의 유지

    lastApplied는 화면 반영 뒤에 갱신합니다. 형식이 잘못된 데이터를 정상 처리한 번호로 기록하지 않습니다. 이벤트 적용 실패 때 연결을 닫은 것은 잘못된 상태를 계속 쌓지 않고 현재 스냅샷을 다시 조회하는 복구 흐름으로 넘기기 위한 선택입니다. 그 조회 API와 재구독 구현은 이 코드에 포함하지 않았습니다.

    중복 42번과 누락된 43번을 다르게 해석합니다

    41번을 반영한 뒤 42번을 받으면 화면은 12/100에서 18/100으로 바뀝니다. 42번이 다시 오면 seq가 lastApplied보다 크지 않으므로 추가 반영하지 않습니다. 같은 번호가 다른 내용으로 재사용되지 않는다는 서버 계약이 이 판단의 전제입니다.

    이후 43번 없이 44번을 받았다면 어떻게 해야 할까요. 이 글의 전체 진행 상태라면 유효한 44번으로 현재 화면을 대체할 수 있습니다. 하지만 이것이 43번의 작업 기록까지 복구했다는 뜻은 아닙니다.

    이벤트가 “목록에서 한 항목 삭제”나 “수량 1 증가”처럼 이전 상태에 누적 적용하는 변경분이라면 이야기가 달라집니다. 빠진 이벤트 하나가 최종 상태를 바꿀 수 있으므로 순서 누락을 감지하고 재생이나 전체 상태 조회로 복구해야 합니다. 이 글의 seq 이하 무시 코드를 모든 이벤트 처리에 그대로 적용하지 않습니다.

    또한 한 페이지의 숫자를 바꾸는 동기 처리와 외부 저장을 포함하는 비동기 처리는 다릅니다. 비동기 핸들러가 겹쳐 실행된다면 처리 순서와 성공 기록을 별도로 직렬화하거나 관리해야 합니다. 메모리의 lastApplied 하나로 영속적인 처리 보장을 얻지는 못합니다.

    새로고침 뒤에는 복구 지점을 다시 정해야 합니다

    이 예시의 lastApplied는 페이지 메모리에 있습니다. 새로고침하면 사라지고 새 EventSource도 만들어집니다. 기존 객체가 자동 재연결할 때의 Last-Event-ID와 새 객체에서 다시 시작하는 상황을 같은 것으로 취급하지 않습니다.

    현재 상태 조회와 SSE 구독을 연결할 때도 틈이 생길 수 있습니다. 조회가 끝난 순간과 구독이 시작된 순간 사이에 발생한 변경을 놓칠 수 있기 때문입니다. 서버가 스냅샷과 그 기준 커서를 함께 주고 그 이후를 재생하거나, 처음 스트림에서 일관된 스냅샷을 보내는 방식 같은 계약이 필요합니다.

    커서를 저장한다면 누구의 어떤 스트림에 대한 값인지, 해당 시점 이후 이벤트가 아직 서버에 남아 있는지 확인해야 합니다. 보관 기간이 지난 커서를 조용히 최신 위치로 바꾸면 복구가 아니라 누락 은폐가 될 수 있습니다.

    서버가 이어 줄 수 없을 때는 전체 상태 재조회가 필요한 상황을 명시하고, 앱이 이전 데이터와 현재 데이터의 차이를 다시 맞추도록 합니다. 구체적인 상태 코드나 재생 API는 서비스마다 정해야 하며 SSE가 하나로 정해 주지 않습니다.

    error마다 새 EventSource를 만들지 않습니다

    EventSource에는 CONNECTING, OPEN, CLOSED 상태가 있습니다. 일시적인 연결 단절 뒤에는 자동 재연결이 가능하므로 error 이벤트마다 새 객체를 만들면 기존 객체의 재연결과 겹쳐 연결 수가 늘 수 있습니다. 먼저 현재 객체의 상태와 수명을 확인합니다. EventSource의 연결 상태

    반면 모든 오류가 무조건 재시도된다고 가정해서도 안 됩니다. 명시적으로 close를 부르면 연결이 닫히며, 서버가 HTTP 204로 재연결 중단을 알리는 경우도 있습니다. 연결 종료와 작업 완료는 별개의 상태입니다. 연결 종료, 재연결 중단 조건

    컴포넌트를 떠날 때 stopWatching 같은 정리 함수를 호출하고, 돌아올 때 기존 구독이 남아 있지 않은지 확인합니다. 구독 종료는 진행 중인 서버 작업의 취소를 뜻하지 않습니다. 사용자에게도 “작업 취소”와 “진행 상태 보기 종료”를 다르게 안내해야 합니다.

    재연결 검증은 이벤트 적용 결과까지 봅니다

    테스트에서는 같은 ID를 다시 보내도 중복 반영되지 않는지, 잘못된 JSON을 받았을 때 성공 위치를 전진시키지 않는지, 번호가 건너뛰었을 때 스냅샷과 변경분을 각각 올바르게 처리하는지 확인합니다. 새로고침과 보관 기간 초과도 별도 시나리오입니다.

    연결됐다는 표시가 돌아왔다고 복구를 마친 것은 아닙니다. 마지막으로 적용한 데이터가 무엇인지, 그 이후를 서버가 실제로 제공했는지, 누락을 발견하면 어디에서 다시 맞출지까지 확인해야 합니다. SSE의 자동 재연결 위에 이 계약을 더해야 중복을 감추는 수준을 넘어 신뢰할 수 있는 실시간 화면을 만들 수 있습니다.

    728x90
    반응형
Designed by Tistory.