댓글/좋아요 등의 알림은 유저의 요청(request)가 없더라도 실시간으로 서버의 변경 사항을 웹 브라우저에 갱신해줘야 하는 서비스다.
그러나 HTTP 통신(POST, GET 요청 등...) 은 클라이언트의 요청(request)가 있어야만, 서버가 응답(response)가 가능하다
(실시간 통신이 아니다)
HTTP 통신으로도 실시간 통신이 가능한 방법이 2가지 있다.

일정 주기를 가지고 서버의 API를 호출하는 방법
예시
장점
단점

업데이트 발생시에만 응답을 보내는 방식
장점
단점

서버와 웹브라우저 사이 양방향 통신이 가능한 방법
예시
장점
단점

사용법
장점
단점
결론
- Polling : 실시간 통신을 위해서는 업데이트 주기를 짧게 해야 하지만, 빈번한 HTTP 요청에 의해 트래픽이 많아질 경우 서버에 부하 大
- Long-Polling : (Polling 와 동일하게) 빈번한 HTTP 요청에 의해 트래픽이 많아질 경우 서버에 부하 大
- WebSocket : 서버와 클라이언트의 양방향 통신
- SSE : 서버에서 클라이언트로의 단방향 통신
알림 기능의 경우, 채팅이 아니므로 양방향 통신일 필요가 없다. 그래서 SSE 가 가장 적절하다.
@RestController
@RequiredArgsConstructor
public class NotificationController {
private final NotificationService notificationService;
public static Map<Long, SseEmitter> sseEmitters = new ConcurrentHashMap<>(); // 1. 모든 Emitters를 저장하는 ConcurrentHashMap
// 메시지 알림
@GetMapping("/api/notification/subscribe")
public SseEmitter subscribe(@AuthenticationPrincipal UserDetailsImpl userDetails) {
Long userId = userDetails.getUser().getId();
SseEmitter sseEmitter = notificationService.subscribe(userId);
return sseEmitter;
}
}
1.
@Service
@RequiredArgsConstructor
public class NotificationService {
private final PostRepository postRepository;
private final UserRepository userRepository;
// 메시지 알림
public SseEmitter subscribe(Long userId) {
// 1. 현재 클라이언트를 위한 sseEmitter 객체 생성
SseEmitter sseEmitter = new SseEmitter(Long.MAX_VALUE);
// 2. 연결
try {
sseEmitter.send(SseEmitter.event().name("connect"));
} catch (IOException e) {
e.printStackTrace();
}
// 3. 저장
NotificationController.sseEmitters.put(userId, sseEmitter);
// 4. 연결 종료 처리
sseEmitter.onCompletion(() -> NotificationController.sseEmitters.remove(userId)); // sseEmitter 연결이 완료될 경우
sseEmitter.onTimeout(() -> NotificationController.sseEmitters.remove(userId)); // sseEmitter 연결에 타임아웃이 발생할 경우
sseEmitter.onError((e) -> NotificationController.sseEmitters.remove(userId)); // sseEmitter 연결에 오류가 발생할 경우
return sseEmitter;
}
// 채팅 수신 알림 - receiver 에게
public void notifyMessage(String receiver) {
// 5. 수신자 정보 조회
User user = userRepository.findByNickname(receiver);
// 6. 수신자 정보로부터 id 값 추출
Long userId = user.getId();
// 7. Map 에서 userId 로 사용자 검색
if (NotificationController.sseEmitters.containsKey(userId)) {
SseEmitter sseEmitterReceiver = NotificationController.sseEmitters.get(userId);
// 8. 알림 메시지 전송 및 해체
try {
sseEmitterReceiver.send(SseEmitter.event().name("addMessage").data("메시지가 왔습니다."));
} catch (Exception e) {
NotificationController.sseEmitters.remove(userId);
}
}
}
// 댓글 알림 - 게시글 작성자 에게
public void notifyComment(Long postId) {
Post post = postRepository.findById(postId).orElseThrow(
() -> new IllegalArgumentException("게시글을 찾을 수 없습니다.")
);
Long userId = post.getUser().getId();
if (NotificationController.sseEmitters.containsKey(userId)) {
SseEmitter sseEmitter = NotificationController.sseEmitters.get(userId);
try {
sseEmitter.send(SseEmitter.event().name("addComment").data("댓글이 달렸습니다."));
} catch (Exception e) {
NotificationController.sseEmitters.remove(userId);
}
}
}
}
1.
2.

3.
4.
6., 7.
8.
(SSE 를 2.에서처럼 연결해두고, 메시지가 전송되거나 게시글에 댓글이 달리면 다음처럼 연결해둔 SSE로 알림이 온다)

@RestController
@RequestMapping("/api/post/{category}/{postId}")
@RequiredArgsConstructor
public class CommentController {
private final CommentService commentService;
private final NotificationService notificationService;
@PostMapping
public ResponseEntity<CommentResponseDto> createComment(@PathVariable Long category, @PathVariable Long postId, @RequestBody CommentRequestDto requestDto, @AuthenticationPrincipal UserDetailsImpl userDetails) {
CommentResponseDto response = commentService.createComment(category, postId, requestDto, userDetails.getUser());
notificationService.notifyComment(postId); // 댓글 알림 - 게시글 작성자 에게
return new ResponseEntity<>(response, HttpStatus.OK);
}
...
}
댓글 작성 시 알림이 오도록 하고 싶다면, 다음처럼 notificationService 에서 구현 후 이를 호출하는 코드 한 줄만 추가해줘도 가능하다.
알림 메시지만 전송할 경우, 간단하게 SSE 를 통한 알림 기능을 구현해볼 수 있다.
그러나 알림에는 알림 내용, 누구에게서 온 알림인지, 알림이 온 시간 등... 이 함께 온다면 사용자에게 더욱 편리해질 것이라 생각했다.
@RestController
@RequiredArgsConstructor
public class NotificationController {
private final NotificationService notificationService;
public static Map<Long, SseEmitter> sseEmitters = new ConcurrentHashMap<>();
// 메시지 알림
@GetMapping("/api/notification/subscribe")
public SseEmitter subscribe(@AuthenticationPrincipal UserDetailsImpl userDetails) {
Long userId = userDetails.getUser().getId();
SseEmitter sseEmitter = notificationService.subscribe(userId);
return sseEmitter;
}
// 알림 삭제
@DeleteMapping("/api/notification/delete/{id}")
public MsgResponseDto deleteNotification(@PathVariable Long id) throws IOException {
return notificationService.deleteNotification(id);
}
}
@Entity
@Setter
@Getter
@Table(name = "notification")
@NoArgsConstructor
public class Notification {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String sender;
private LocalDateTime createdAt;
private String contents; // 채팅 메시지 내용 또는 댓글 내용
private String roomId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id")
private Post post;
}
public interface NotificationRepository extends JpaRepository<Notification, Long> {
Optional<Notification> findById(Long id);
}
@Service
@RequiredArgsConstructor
public class NotificationService {
private final PostRepository postRepository;
private final UserRepository userRepository;
private final NotificationRepository notificationRepository;
private final CommentRepository commentRepository;
private final MessageRepository messageRepository;
private final MessageRoomRepository messageRoomRepository;
private static Map<Long, Integer> notificationCounts = new HashMap<>(); // 알림 개수 저장
// 메시지 알림
public SseEmitter subscribe(Long userId) {
// 현재 클라이언트를 위한 sseEmitter 생성
SseEmitter sseEmitter = new SseEmitter(Long.MAX_VALUE);
try {
// 연결
sseEmitter.send(SseEmitter.event().name("connect"));
} catch (IOException e) {
e.printStackTrace();
}
// user 의 pk 값을 key 값으로 해서 sseEmitter 를 저장
NotificationController.sseEmitters.put(userId, sseEmitter);
sseEmitter.onCompletion(() -> NotificationController.sseEmitters.remove(userId));
sseEmitter.onTimeout(() -> NotificationController.sseEmitters.remove(userId));
sseEmitter.onError((e) -> NotificationController.sseEmitters.remove(userId));
return sseEmitter;
}
// 메시지 알림 - receiver 에게
public void notifyMessage(String roomId, String receiver, String sender) {
MessageRoom messageRoom = messageRoomRepository.findByRoomId(roomId);
Post post = postRepository.findById(messageRoom.getPost().getId()).orElseThrow(
() -> new IllegalArgumentException("게시글을 찾을 수 없습니다.")
);
User user = userRepository.findByNickname(receiver);
User userSender = userRepository.findByNickname(sender);
Message receiveMessage = messageRepository.findFirstBySenderOrderByCreatedAtDesc(userSender.getNickname()).orElseThrow(
() -> new IllegalArgumentException("메시지를 찾을 수 없습니다.")
);
Long userId = user.getId();
if (NotificationController.sseEmitters.containsKey(userId)) {
SseEmitter sseEmitter = NotificationController.sseEmitters.get(userId);
try {
Map<String, String> eventData = new HashMap<>();
eventData.put("message", "메시지가 왔습니다.");
eventData.put("sender", receiveMessage.getSender()); // 메시지 보낸자
eventData.put("createdAt", receiveMessage.getCreatedAt().toString()); // 메시지를 보낸 시간
eventData.put("contents", receiveMessage.getMessage()); // 메시지 내용
sseEmitter.send(SseEmitter.event().name("addMessage").data(eventData));
// DB 저장
Notification notification = new Notification();
notification.setSender(receiveMessage.getSender());
notification.setCreatedAt(receiveMessage.getCreatedAt());
notification.setContents(receiveMessage.getMessage());
notification.setRoomId(messageRoom.getRoomId());
notification.setPost(post); // post 필드 설정
notificationRepository.save(notification);
// 알림 개수 증가
notificationCounts.put(userId, notificationCounts.getOrDefault(userId, 0) + 1);
// 현재 알림 개수 전송
sseEmitter.send(SseEmitter.event().name("notificationCount").data(notificationCounts.get(userId)));
} catch (Exception e) {
NotificationController.sseEmitters.remove(userId);
}
}
}
// 댓글 알림 - 게시글 작성자 에게
public void notifyComment(Long postId) {
Post post = postRepository.findById(postId).orElseThrow(
() -> new IllegalArgumentException("게시글을 찾을 수 없습니다.")
);
Comment receiveComment = commentRepository.findFirstByPostIdOrderByCreatedAtDesc(post.getId()).orElseThrow(
() -> new IllegalArgumentException("댓글을 찾을 수 없습니다.")
);
Long userId = post.getUser().getId();
if (NotificationController.sseEmitters.containsKey(userId)) {
SseEmitter sseEmitter = NotificationController.sseEmitters.get(userId);
try {
Map<String, String> eventData = new HashMap<>();
eventData.put("message", "댓글이 달렸습니다.");
eventData.put("sender", receiveComment.getUser().getNickname()); // 댓글 작성자
eventData.put("createdAt", receiveComment.getCreatedAt().toString()); // 댓글이 달린 시간
eventData.put("contents", receiveComment.getComment()); // 댓글 내용
sseEmitter.send(SseEmitter.event().name("addComment").data(eventData));
// DB 저장
Notification notification = new Notification();
notification.setSender(receiveComment.getUser().getNickname());
notification.setCreatedAt(receiveComment.getCreatedAt());
notification.setContents(receiveComment.getComment());
notification.setPost(post); // post 필드 설정
notificationRepository.save(notification);
// 알림 개수 증가
notificationCounts.put(userId, notificationCounts.getOrDefault(userId, 0) + 1);
// 현재 알림 개수 전송
sseEmitter.send(SseEmitter.event().name("notificationCount").data(notificationCounts.get(userId)));
} catch (IOException e) {
NotificationController.sseEmitters.remove(userId);
}
}
}
// 알림 삭제
public MsgResponseDto deleteNotification(Long id) throws IOException {
Notification notification = notificationRepository.findById(id).orElseThrow(
() -> new IllegalArgumentException("알림을 찾을 수 없습니다.")
);
Long userId = notification.getPost().getUser().getId();
notificationRepository.delete(notification);
// 알림 개수 감소
if (notificationCounts.containsKey(userId)) {
int currentCount = notificationCounts.get(userId);
if (currentCount > 0) {
notificationCounts.put(userId, currentCount - 1);
}
}
// 현재 알림 개수 전송
SseEmitter sseEmitter = NotificationController.sseEmitters.get(userId);
sseEmitter.send(SseEmitter.event().name("notificationCount").data(notificationCounts.get(userId)));
return new MsgResponseDto("알림이 삭제되었습니다.", HttpStatus.OK.value());
}
}
알림 메시지 + α
알림 전송 시간
알림 전송 시간을 받아올 때 첫 채팅 메시지or댓글을 전송한 시간이 고정되어 전송되는 문제가 있었다.
이는 findFirstBySenderOrderByCreatedAtDesc 를 통해 해결할 수 있었다.
즉, 특정 Sender의 메시지 중에서 최신 메시지를 가져올 수 있는 것이다.
알림 개수 저장
알림 개수 증가 및 현재 알림 개수 전송 을 구현참고: [백엔드|스프링부트] 알림 기능은 어떻게 구현하는게 좋을까?
참고: 🔥 TIL - Day 64 SSE를 이용한 실시간 알림
참고: [Photogram] 실시간 알림
참고: [Spring + SSE] Server-Sent Events를 이용한 실시간 알림