개발 낙서장

[TIL] 내일배움캠프 79일차 - SSE 본문

Java/Sparta

[TIL] 내일배움캠프 79일차 - SSE

권승준 2024. 4. 19. 20:52

오늘의 학습 키워드📚

SSE

SSE(Server Sent Event)는 서버에서 클라이언트로 이벤트를 보내주는 통신 방법이다.
클라이언트에서 서버로 HTTP 프로토콜을 통해 세션 연결 요청을 보내며 세션이 유지되는 동안 클라이언트는 서버로부터 이벤트 메세지를 받을 수 있다.

클라이언트에서 메세지를 보내지는 못하고 받을 수만 있어서 주로 알림 기능, 실시간 혹은 주기적으로 데이터 수신이 필요한 기능 등에 사용한다.

SSE를 사용하게 된 계기는 현재 거래 글에서 주문 요청을 하면 아임포트 결제 메소드가 실행되고 결제가 완료되면 클라이언트에서 서버로 구매 요청을 하는데 해당 요청은 SQS로 보내진다.
서버에서는 SQS에 있는 메세지를 하나씩 가져오며 주문 처리를 하는데 이때 예외를 발생시키거나 정상적으로 처리를 완료하는 방식이다.

문제가 클라이언트에서는 서버에 구매 요청을 보내고 해당 요청에 대한 응답을 받는 것을 마지막으로 구매가 정상적으로 처리되었는지 알 수 없다.

    // 결제 완료 시 주문 생성 요청 및 SQS 로 주문 정보 메세지 송신
    public IamportResponse<Payment> createOrder(User user, OrderRequest request, String imp_uid)
        throws IamportResponseException, IOException {
        IamportResponse<Payment> response = iamportClient.paymentByImpUid(imp_uid);

        if (response == null || !response.getResponse().getAmount()
            .equals(BigDecimal.valueOf(request.getAmount()))) {
            cancelPayment(imp_uid, "결제 정보가 일치하지 않음.");
            return null;
        }

        sqsTemplate.send(to -> to
            .queue(queueName)
            .messageGroupId(String.valueOf(user.getUserId()))
            .payload(new CreateOrderMessage(user, request, imp_uid,
                response.getResponse().getMerchantUid()))
        );

        return response;
    }

코드를 살펴보면 결제 과정에서 예외가 발생한 경우 아니면 다른 예외가 발생하지 않기 때문에 클라이언트 입장에서는 구매 및 주문 요청이 정상적으로 처리되었는지 알 수 없다.
구매 및 주문 요청은 SQS로 동시성 처리를 하였기 때문에 서버에서는 메세지 수신 콜백 메소드가 실행돼 처리가 됐는지 아닌지 알 수 있지만 해당 메소드의 응답을 클라이언트에서는 받을 수 없어서 SSE로 결과를 알 수 있도록 했다.

좀 설명을 잘 못했지만 어쨌든 클라이언트 입장에서 구매 및 주문 요청에 대한 결과를 SSE로 받을 수 있도록 했다.

SseEmitterService

@Slf4j
@Service
public class SseEmitterService {

    private final Map<String, SseEmitter> emitterMap = new ConcurrentHashMap<>();

    public SseEmitter subscribe(String key, Long timeout) {
        // timeout 설정
        SseEmitter emitter = new SseEmitter(timeout);

        // Timeout 시 연결 종료 이벤트
        emitter.onTimeout(() -> {
            log.info("[" + key + "] SSE 세션 타임 아웃");
            emitterMap.remove(key);
            emitter.complete();
        });

        // 에러 이벤트
        emitter.onError((e) -> {
            log.info("[" + key + "] SSE 세션 오류 발생");
            emitterMap.remove(key);
            emitter.complete();
        });

        // 연결 종료 이벤트
        emitter.onCompletion(() -> {
            if(emitterMap.remove(key) != null) {
                log.info("[" + key + "] SSE 세션 완료");
            }
        });

        emitterMap.put(key, emitter);

        return emitter;
    }

    public <T> void send(String key, T data, String name) throws IOException {
        SseEmitter emitter = emitterMap.get(key);

        if(emitter != null) {
            emitter.send(SseEmitter.event().name(name).data(data).build());
        }
    }

    public void complete(String key) {
        SseEmitter emitter = emitterMap.remove(key);

        if(emitter != null) {
            emitter.complete();
        }
    }
}

구독 요청이 들어오면  SseEmitter를 생성해 Map에 저장해 관리한다. TimeOut은 연결 만료 시간이며 연결 종료, 에러, 완료 이벤트 함수를 등록해 Emitter를 반환한다.

서버에서 클라이언트로 메세지를 보내는 send 메소드는 key 값으로 해당 SseEmitter를 찾고 이벤트 name으로 data를 보낸다. 클라이언트에서는 해당 name으로 들어온 data를 처리할 수 있다.

Subscribe(Controller에서 수행)

    @GetMapping("/{imp_uid}/sse")
    public SseEmitter subscribeCreateOrder(@PathVariable("imp_uid") String imp_uid) {
        return orderService.subscribeCreateOrder(imp_uid);
    }

SSE가 필요한 도메인의 Controller에서(어쩌면 SseController를 따로 만들어서 공통으로 구독 처리를 해도 괜찮을 것 같다) Key 값을 받아 구독 처리를 한다.

클라이언트 부분

        if(rsp.success) {
            try {
                const {data} = await createOrder({
                    salePostId: salePost.salePostId,
                    count: count,
                    amount: salePost.price * count
                }, rsp.imp_uid);

                const eventSource = await new EventSource(
                    `http://${window.location.hostname}:8080/api/v1/orders/${rsp.imp_uid}/sse`);

                eventSource.addEventListener("success", event => {
                    alert(event.data);
                    eventSource.close();
                    handleBackClick();
                })

                eventSource.addEventListener("failure", event => {
                    alert(event.data);
                    eventSource.close();
                })
            } catch (error) {
                alert('결제 실패');
            }
        }
        else {
            alert('결제 취소');
        }

클라이언트 부분에서는 EventSource를 구독 Url로 요청을 보내 생성하고 addEventListener를 통해 수신 이벤트를 등록한다.
success나 failure는 서버에서 보낸 name 값으로 해당 name으로 수신된 메세지로 처리 로직을 작성한다.


오늘의 회고💬

오늘은 집중이 많이 안 되는 날이었다. 주말 동안 화면 부분 열심히 해야지...

 

내일의 계획📜

유저 테스트를 진행해야돼서 화면 부분 에러랑 기능적으로 부족한 부분을 살짝 손봐야 한다.

Comments