챌린지 신청을 받았을때
챌린지가 중단 되었을때
로그인한 상태가 아니라면 로그인 했을 때 받은 알림을 모두 보여주면 되지만, 로그인한 상태라면 실시간으로 알림을 받길 원했습니다.
실시간 웹애플리케이션을 개발 하는 방법(Polling, Websocket, SSE
)중에서 polling
은 지속적인 요청을 보내야하므로 리소스 낭비가 심할 것 같았고, 실시간 알림같은 경우는 서버에서 클라이언트 방향으로만 데이터를 보내면 되기 때문에 websocket
처럼 양방향 통신은 필요없었습니다. 따라서 웹 소켓에 비해 가볍고 서버 -> 클라이언트 방향을 지원하는 SSE
를 선택했습니다.
// 고유 id 같이 보내기.
// id 설정 시 브라우저가 마지막 이벤트를 추적하여 서버 연결이 끊어지면
// 특수한 HTTP 헤더(Last-Event-ID)가 새 요청으로 설정됨.
// 브라우저가 어떤 이벤트를 실행하기에 적합한 지 스스로 결정할 수 있게 됨.
id: 12345\n
data: first line\n
data: second line\n\n
EventSource
를 제공한다.memberid
로 sse
연결을 맺고 후에 서버에서 해당 유저와의 sse
연결을 통해 데이터가 날라오면 브라우저가 알림을 띄운다./**
*로그인 한 유저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
를 통해 날아오는 요청을 처리할 컨트롤러가 필요SseEmitter
API를 제공합니다. 이를 이용해 SSE 구독 요청에 대한 응답을 할 수 있습니다.text/event-stream 로 설정해야한다.
Last-Event-ID
라는 헤더를 받고 있는데, 클라이언트가 마지막으로 수신한데이터 id를의미한다.항상 담겨있는것은 아니고, sse 연결이 시간 만료 등의 이유로 끊어졌을 경우 알림이 발생하면 그 시간 동안 발생한 알림은 클라이언트에 도달하지 못하는 상황을 방지하기위해, Last Event Id로 유실된 데이터를 다시 보내줄 수 있다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();
}