[TIL | 내일배움캠프] SSE(Server-Sent Events)

변채주·2026년 1월 21일

Spring

목록 보기
17/17

(부제) SSE (Server-Sent Events)와 결제 시스템 알림 처리

SSE 통신 방식과 현재 진행 중인 프로젝트에서 어떻게 결제 시스템 처리 결과에 관한 알림을 다른 기능에서 받을 수 있을지 Claude AI를 통해 학습한 내용을 정리해봤습니다.

SSE란?

: Server-Sent Events(SSE)는 서버에서 클라이언트(브라우저)로 실시간 데이터를 전송하는 단방향 통신 기술이다.(HTTP 프로토콜을 기반)
서버가 클라이언트에게 지속적으로 이벤트를 푸시(sent)할 수 있다.

특징

  • 단방향 통신: 서버 → 클라이언트 방향으로만 데이터 전송
    웹소켓Websocket과 차이점 - Websocket은 양방향 통신
  • HTTP 기반: 별도의 프로토콜 없이 표준 HTTP 사용
  • 자동 재연결: 연결이 끊어지면 자동으로 재연결 시도
  • 텍스트 기반: 텍스트/JSON 데이터 전송에 최적화

WebSocket과의 차이

구분SSEWebSocket
통신 방향단방향 (서버→클라이언트)양방향
프로토콜HTTPWebSocket 프로토콜
복잡도간단상대적으로 복잡
적합한 사용 사례알림, 실시간 피드, 진행 상태채팅, 게임, 협업 도구
재연결자동수동 구현 필요

Spring WebFlux의 Flux와 Sink

Flux란?

Flux는 Spring WebFlux에서 제공하는 비동기 데이터 스트림을 표현하는 타입이다. Java의 Stream API와 표현법이 유사하지만, 데이터가 주기적으로 생성되고 전달된다는 점이 다르다.

// Stream API (동기): 데이터가 이미 존재
List<String> list = Arrays.asList("a", "b", "c");
Stream<String> stream = list.stream();

// Flux (비동기): 데이터가 시간에 걸쳐 생성됨
Flux<String> flux = Flux.just("a", "b", "c");

➕ 프로젝트에서 자주 쓰던 ResponseEntity와의 비교:
ResponseEntity<String>: 하나의 값을 담는 상자 (택배)
ResponseEntity<Flux<String>>: 시간에 걸쳐 흐르는 데이터 스트림 (컨베이어 벨트)

Sink의 역할

: Sink는 데이터를 발행(emit)할 수 있는 중앙 통로다. 여러 구독자가 하나의 Sink를 통해 데이터를 받을 수 있다.

비유하자면,
Sink = 중앙 컨베이어 벨트

  • 데이터가 발행되면 모든 구독자가 받을 수 있음
  • Filter를 통해 각 구독자는 자신에게 필요한 데이터만 선택
// Sink 1개를 여러 사용자가 공유
private final Sinks.Many<PaymentStatus> paymentSink = 
    Sinks.many().multicast().onBackpressureBuffer();

// 사용자별로 필터링
public Flux<PaymentStatus> getPaymentStream(Long orderId) {
    return paymentSink.asFlux()
        .filter(status -> status.getOrderId().equals(orderId));
}


[ ※ 본문과 관계없는 이미지입니다 ]

프로젝트 적용: 결제 완료 시 알림 처리

(with Portone V2)

프로젝트 요구사항

  • 결제 기능 구현 (Portone V2 연동) Portone에선 최신 기능을 사용하길 권장하고 있다.
  • 결제 상태를 구매자에게 실시간으로 전달 결제창 진행상황이 보여야 되니까!
  • 결제 완료 시 trade 서비스에 알림 (Redis Pub/Sub)

고민했던 내용

AI와 학습하며 오갔던 질문과 대답입니다.

  1. 왜 SSE를 선택했는가?
  • 질문: 결제 상태 업데이트에 WebSocket이 아닌 SSE를 사용하는 이유는?
  • 답변:
    • 결제 시스템에서 필요한 통신은 서버 → 클라이언트 단방향
    • 사용자가 서버로 보낼 데이터는 일반 HTTP POST로 충분
    • SSE가 더 간단하고 HTTP 기반이라 인프라 구성이 쉬움
  1. Redis Pub/Sub과 SSE의 관계
  • 질문: Redis Pub/Sub과 SSE는 어떻게 다른가?
  • 구조:
  [결제 서비스] ─(Redis Pub/Sub)→ [주문 서비스]  // 백엔드 간 통신
      ↓
   (SSE)
      ↓
  [브라우저]  // 서버-클라이언트 통신
  • 역할 구분:
    • Redis Pub/Sub: 여러 서버 간 메시지 전달 (수평 확장)
    • SSE: 서버와 브라우저 간 실시간 데이터 전송
  1. 결제 API를 prepare와 verify로 분리한 이유
  • 질문: 왜 결제를 하나의 트랜잭션으로 처리하지 않고 API를 분리했는가?
    Portone API 가이드에서 결제 정보를 먼저 생성한 뒤 요청을 보내게 하는 이유가 궁금했다.

  • 문제 상황:

    • Portone 결제창은 브라우저에서 팝업으로 표시됨
    • 사용자가 카드 정보를 입력하는 동안 (10초~1분) 백엔드가 대기할 수 없음
    • 사용자가 취소할 수도 있음
  • 해결:

    1. POST /payment/prepare 결제 준비 API

      • 주문 정보 DB 저장 (상태: READY)
      • merchantUid 생성 및 반환
      • 즉시 응답 (트랜잭션 짧음)
    2. [프론트엔드] 결제창 호출

      • 사용자가 결제 진행
      • 백엔드는 대기하지 않음
    3. POST /payment/verify 결제 검증 API

      • 결제 완료 후 검증
      • Portone API로 실제 결제 내역 확인
      • 상태 변경: READY → PENDING → COMPLETED
  1. 결제 검증의 필요성
  • 질문: 브라우저에서 "결제 성공" 응답을 받았는데, 왜 다시 Portone API로 확인해야 하는가?
  • 보안 이슈:
    • 브라우저(클라이언트)에서 오는 정보는 조작 가능
    • 악의적 사용자가 10,000원 결제를 100원으로 바꾼 뒤 "10,000원 결제 완료"라고 거짓말할 수 있음
  • 검증 프로세스:
// 1. DB에서 우리가 저장한 정보 조회
Payment payment = paymentRepository.findByPaymentId(paymentId);
// 2. Portone API로 실제 결제 내역 조회
PortonePayment portonePayment = portoneClient.getPayment(paymentId);
// 3. 검증
if (payment.getAmount() != portonePayment.getAmount()) {
    throw new PaymentVerificationException("금액 불일치");
}
if (!portonePayment.getStatus().equals("PAID")) {
    throw new PaymentVerificationException("결제 미완료");
}
  1. Sink를 어디에 선언해야 하는가?
  • 질문: Sink를 Controller에 선언해야 하는가, Service에 선언해야 하는가?
  • 결론: Service 계층에 선언
  • 이유:
    • Spring의 Controller는 싱글톤 (애플리케이션 시작 시 1번 생성)
    • Sink도 싱글톤으로 관리되어야 함 (하나의 중앙 통로)
    • 여러 사용자의 메시지를 하나의 Sink로 받고, filter로 구분
    • Service 계층이 비즈니스 로직과 상태 관리에 적합

전체 Flow

실제 프로젝트 Flow는 아니고, 통신 과정을 이해하기 위해 참고용으로 기록하려 합니다.

1. 브라우저 → 백엔드: POST /payment/prepare
   ← paymentId, amount

2. 브라우저: SSE 연결 시작 (/payment/stream/{paymentId})

3. 브라우저: PortOne.requestPayment() 호출
   → Portone 결제창 표시

4. 사용자 → Portone: 카드 정보 입력 및 결제
   → PG사 처리

5. Portone → 브라우저: 결제 완료 콜백

6. 브라우저 → 백엔드: POST /payment/verify

7. 백엔드 → Portone API: GET /v2/payments/{paymentId}
   (실제 결제 내역 조회 및 검증)

8. 백엔드: 
   - DB 상태 변경 (READY → COMPLETED)
   - Redis 발행 (주문 서비스에 알림)
   - SSE로 브라우저에 알림

9. 브라우저: SSE로 결제 완료 메시지 수신
   → 사용자에게 완료 알림 표시

메모 및 회고

  • Redis Pub/Sub과 SSE는 다른 계층에서 동작한다. Redis는 서버 간 통신, SSE는 서버-브라우저 간 통신이다.
  • 결제 검증은 보안상 필수다. Portone에서도 꼭 하길 권장하고 있음. 클라이언트에서 오는 정보만으로는 신뢰할 수 없으며, PG사 API로 실제 결제 내역을 확인해야 한다.

📚 참고 자료

Portone V2 공식 문서
Spring WebFlux Flux 문서
SSE (Server-Sent Events) MDN 문서

profile
우당탕탕얼레벌레 개발 일지

0개의 댓글