메인 프로젝트 (10) 실시간 알림 SSE

InSeok·2022년 12월 12일
0

프로젝트

목록 보기
9/13
post-thumbnail

실시간 알림

  • 챌린지 기능 개발중 아래와 같은 상황에알림기능이 필요했습니다.

챌린지 신청자

  • 챌린지 요청이 수락되었을때
  • 챌린지 요청이 거절되었을때
  • 챌린지가 중단 되었을때

챌린지 상대방

  • 챌린지 신청을 받았을때

  • 챌린지가 중단 되었을때

  • 로그인한 상태가 아니라면 로그인 했을 때 받은 알림을 모두 보여주면 되지만, 로그인한 상태라면 실시간으로 알림을 받길 원했습니다.

  • 실시간 웹애플리케이션을 개발 하는 방법(Polling, Websocket, SSE)중에서 polling은 지속적인 요청을 보내야하므로 리소스 낭비가 심할 것 같았고, 실시간 알림같은 경우는 서버에서 클라이언트 방향으로만 데이터를 보내면 되기 때문에 websocket처럼 양방향 통신은 필요없었습니다. 따라서 웹 소켓에 비해 가볍고 서버 -> 클라이언트 방향을 지원하는 SSE를 선택했습니다.

SSE(server push)

  • [서버 -> 클라이언트] 방향으로만 흐르는 단방향 통신 채널,
  • polling과 같이 주기적으로 http 요청을 보낼 필요없이 http 연결을 통해 서버에서 클라이언트로 데이터를 보낼 수 있다.

특징

  • SSE는 서버의 데이터를 실시간, 지속적으로 클라이언트에 보내는 기술이다. 위의 그림처럼 클라이언트에서 처음 HTTP 연결을 맺고 나면 서버는 클라이언트로 계속하여 데이터를 전송할 수 있다.
  • websocket과 달리 별도의 프로토콜을 사용하지 않고 HTTP 프로토콜만으로 사용이 가능하며 훨씬 가볍다.
  • 접속에 문제가 있으면 자동으로 재연결을 시도한다.
  • 최대 동시 접속 수는 HTTP/1.1의 경우 브라우저 당 6개이며, HTTP/2는 100개까지 가능하다.

Event Stream

  • sse 연결을 통해 도착하는 데이터의 형태
// 고유 id 같이 보내기.
// id 설정 시 브라우저가 마지막 이벤트를 추적하여 서버 연결이 끊어지면
// 특수한 HTTP 헤더(Last-Event-ID)가 새 요청으로 설정됨.
// 브라우저가 어떤 이벤트를 실행하기에 적합한 지 스스로 결정할 수 있게 됨.

id: 12345\n
data: first line\n
data: second line\n\n

클라이언트 구현

  • 처음에는 클라이언트에서 서버로 연결이 필요하다. 클라이언트에서 서버로 sse 연결 요청을 보내기 위해서 자바스크립트는 EventSource를 제공한다.
  • memberidsse연결을 맺고 후에 서버에서 해당 유저와의 sse연결을 통해 데이터가 날라오면 브라우저가 알림을 띄운다.

서버 구현

Controller

/**
 *로그인 한 유저sse연결
*@parammemberDetails로그인 세션정보
*@paramlastEventId클라이언트가 마지막으로 수신한 데이터의id값
*@return
*/
@GetMapping(value = "/connect", produces = "text/event-stream")
public SseEmitter connect(@AuthMember MemberDetails memberDetails,
@RequestHeader(value = "Last-Event-ID",required = false,defaultValue = "") String lastEventId) {

    SseEmitter emitter = notificationService.subscribe(memberDetails.getMemberId(), lastEventId);

    return emitter;
}
  • EventSource를 통해 날아오는 요청을 처리할 컨트롤러가 필요
  • spring framework 4.2부터 SSE 통신을 지원하는 SseEmitterAPI를 제공합니다. 이를 이용해 SSE 구독 요청에 대한 응답을 할 수 있습니다.
  • sse 통신을 하기 위해서는 MIME 타입을 text/event-stream 로 설정해야한다.
  • Last-Event-ID라는 헤더를 받고 있는데, 클라이언트가 마지막으로 수신한데이터 id를의미한다.항상 담겨있는것은 아니고, sse 연결이 시간 만료 등의 이유로 끊어졌을 경우 알림이 발생하면 그 시간 동안 발생한 알림은 클라이언트에 도달하지 못하는 상황을 방지하기위해, Last Event Id로 유실된 데이터를 다시 보내줄 수 있다

Service

로그인 유저sse연결

  1. memerId에 현재시간을 더해emitterId,eventId생성
  2. SseEmitter유효시간 설정하여 생성(시간이 지나면 자동으로 클라이언트에게 재연결 요청)
    3.유효시간동안 어느 데이터도 전송되지 않을시503에러 발생 하므로 맨처음 연결시 더미데이터 전송
    4.클라이언트가 미수신한Event목록이 존재할 경우 전송
public SseEmitter subscribe(Long memberId, String lastEventId) {

    String emitterId = makeTimeIncludeId(memberId);

		log.info("emitterId = {}", emitterId);
		(2)
    SseEmitter emitter = emitterRepository.save(emitterId, new SseEmitter(60 * 1000 * 60L));

    //시간 초과된경우 자동으로 레포지토리에서 삭제 처리하는 콜백 등록
    emitter.onCompletion(() -> emitterRepository.deleteById(emitterId));
    emitter.onTimeout(() -> emitterRepository.deleteById(emitterId));

    (3)//503 에러 방지 위한 더미 이벤트 전송
    String eventId = makeTimeIncludeId(memberId);
    sendNotification(emitter, eventId, emitterId, "EventStream Created. [userId=" + memberId + "]");

    (4)// 클라이언트가 미수신한 Event 목록이 존재할 경우 전송하여 Event 유실 예방
    if (!lastEventId.isEmpty()) {
        sendLostData(lastEventId, memberId, emitterId, emitter);
    }
    return emitter;
}

(1)
// 데이터가 유실된시점을 파악하기 위해 memberId에 현재시간을 더한다.
    private String makeTimeIncludeId(Long memberId) {
        return memberId + "_" + System.currentTimeMillis();
    }
(4)
// 로그인후 sse연결요청시 헤더에 lastEventId가 있다면, 저장된 데이터 캐시에서 id값을 통해 유실된 데이터만 다시 전송
    private void sendLostData(String lastEventId, Long memberId, String emitterId, SseEmitter emitter) {
        Map<String, Object> eventCashes = emitterRepository.findAllEventCacheStartWithByMemberId(String.valueOf(memberId));
        eventCashes.entrySet().stream()
                .filter(entry -> lastEventId.compareTo(entry.getKey()) < 0)
                .forEach(entry -> sendNotification(emitter, entry.getKey(), emitterId, entry.getValue()));
    }

알림 송신

1.알림생성&저장
2.수신자의emitter들을 모두찾아 해당emitter로 송신


@Transactional
public void send(Member receiver,  Challenge challenge,String content) {
		(1)
    Notification notification = createNotification(receiver, content, challenge);
    notificationRepository.save(notification);
    String receiverId = String.valueOf(receiver.getId());
    String eventId = receiverId + "_" + System.currentTimeMillis();
    (2)//수신자의 SseEmitter 가져오기
    Map<String, SseEmitter> emitters = emitterRepository.findAllEmitterStartWithByMemberId(receiverId);

    emitters.forEach(
            (key, emitter) -> {
                //데이터 캐시 저장(유실된 데이터처리하기위함)
                emitterRepository.saveEventCache(key, notification);
                //데이터 전송
                sendNotification(emitter, eventId, key, NotificationResponse.of(notification));
log.info("notification= {}", NotificationResponse.of(notification).getContent());
            });
}

실제로 알림을 보내고 싶은 로직에서 send 메서드를 호출

public Long suggest(Long memberId, Long counterpartId) {

    Challenge challenge = challengeRepository.save(Challenge.toEntity(memberId,counterpartId));

    Member applicant = memberService.findMemberById(memberId);

    Member counterpart = memberService.findMemberById(counterpartId);

    // 상대방에게 알림 전송
    notificationService.send(counterpart, challenge, applicant.getUsername()+"님이 챌린지를 신청하셨습니다.");

    return challenge.getId();
}
profile
백엔드 개발자

0개의 댓글