Spring Boot로 SSE를 통한 알람 구현하기!

no.oneho·2023년 10월 29일
0

사이드 프로젝트를 하다 확장기능으로 내가 작성한 게시글에 좋아요가 달리면 알람 기능을 구현하자는 이야기가 나왔다.

소켓과 SSE중 하나를 선택하기로 하였고, 소켓에 비해 부하가 적고 단순 알람은 서버측에서 일방적인 데이터 전송을 통해 구현 가능하므로 SSE를 선택하였다.

실시간 통신에는 여러 구현 방법이 존재하는데
소켓과 SSE의 차이점을 나타내면

소켓은 양방향 실시간 통신
SSE는 단방향 실시간 통신이라고 정리가 가능하다.

SSE의 경우 지정된 엔드포인트로 구독을 시작하면 연결이 끊기기 전까지 구독한 해당 네트워크를 통해 서버측에서 이벤트 스트림을 전송 가능하다.

이를 통해 내가 작성한 리뷰에 좋아요가 달리면 구독된 SSE로 데이터를 전송하여 실시간으로 데이터를 받는게 가능하다!

컨트롤러부터 보자

@RestController
@RequiredArgsConstructor
public class NotificationController {

    private final NotificationService notificationService;

    @GetMapping(value = "/subscribe/{user_id}", produces = "text/event-stream;charset=UTF-8")
    public SseEmitter subscribe(@PathVariable(value = "user_id") Long userId) {
        return notificationService.subscribe(userId);
    }

}

특이점은 produces로 Content-Type을 지정해준것, 저렇게 메시지 형식 말고 스프링에서 지원해주는

MediaType.TEXT_EVENT_STREAM_VALUE

를 사용하는 것이 더 좋다. 저거 하나 고치고 새로 커밋 푸시 배포하는게 좀 귀찮아서.. 미루고 있는 과정이다.

text/event-stream이란 쉽게 말해 text형식의 이벤트 스트림을 응답으로 보내겠다는것이다. 클라이언트측에서 GET /subscribe/{user_id} 로 API를 요청해 구독을 하면 연결 종료, 혹은 타임 아웃이 될 때까지 응답을 기다리고 그 응답은 text형식의 이벤트 스트림인 것이다.

다음은 서비스 코드를 보자.

@Service
@RequiredArgsConstructor
public class NotificationService {

    private final UserRepository userRepository;
    private final EmitterRepository emitterRepository;


    private static final Long DEFAULT_TIMEOUT = 600L * 1000 * 60;

    public SseEmitter subscribe(Long userId) {
        SseEmitter emitter = createEmitter(userId);

        sendToClient(userId, "EventStream Created. [userId="+ userId + "]", "sse 접속 성공");
        return emitter;
    }

    public <T> void customNotify(Long userId, T data, String comment, String type) {
        sendToClient(userId, data, comment, type);
    }
    public void notify(Long userId, Object data, String comment) {
        sendToClient(userId, data, comment);
    }

    private void sendToClient(Long userId, Object data, String comment) {
        SseEmitter emitter = emitterRepository.get(userId);
        if (emitter != null) {
            try {
                emitter.send(SseEmitter.event()
                        .id(String.valueOf(userId))
                        .name("sse")
                        .data(data)
                        .comment(comment));
            } catch (IOException e) {
                emitterRepository.deleteById(userId);
                emitter.completeWithError(e);
            }
        }
    }

    private <T> void sendToClient(Long userId, T data, String comment, String type) {
        SseEmitter emitter = emitterRepository.get(userId);
        if (emitter != null) {
            try {
                emitter.send(SseEmitter.event()
                        .id(String.valueOf(userId))
                        .name(type)
                        .data(data)
                        .comment(comment));
            } catch (IOException e) {
                emitterRepository.deleteById(userId);
                emitter.completeWithError(e);
            }
        }
    }

    private SseEmitter createEmitter(Long userId) {
        SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT);
        emitterRepository.save(userId, emitter);

        emitter.onCompletion(() -> emitterRepository.deleteById(userId));
        emitter.onTimeout(() -> emitterRepository.deleteById(userId));

        return emitter;
    }

    private User validUser(Long userId) {
        return userRepository.findById(userId).orElseThrow(() -> new CustomException(UserErrorCode.NOT_FOUND_USER));
    }
}

서비스 코드에서 중요한점은 sendToClient 메서드이다.
특히 많은 사람들이 sse를 서버 - 클라이언트로 구현 할 때 삽질 하는 부분인데,
.name 을 통해 이벤트 타입을 지정해주면 클라이언트 코드에서는

eventSource.onmessge(()=>{})

로 받을 수가 없다.
이 코드는 단순 name이 지정되지않은 이벤트 스트림만 제어가 가능한 함수기에 name을 지정 할 시 다른 방법으로 받아야한다.

eventSource.addEventListener("event-name", (e) => {})

이런 형식으로 받아야 이벤트 name 이 지정된 이벤트를 받을 수 있다. 주의해두자!

다음은 Repository 코드이다.

@Repository
@RequiredArgsConstructor
public class EmitterRepository {

    private final Map<Long, SseEmitter> emitters = new ConcurrentHashMap<>();

    public void save(Long id, SseEmitter emitter) {
        emitters.put(id, emitter);
    }

    public void deleteById(Long userId) {
        emitters.remove(userId);
    }

    public SseEmitter get(Long userId) {
        return emitters.get(userId);
    }
}

여기는 동시성 문제를 위해 ConcurrentHashMap을 사용한 것 말고는 일반적인 로직을 타기에 별로 설명할 것이 없다. SSE를 구현하려면 SseEmitter의 기본 개념정도를 숙지하고 이 글을 읽을텐데 그 개념을 알면 매우 쉬운 코드라고 생각한다.

잘 이해가 안가면 DB없이 메모리단에서 정보를 저장하던것을 기억해보자.

여기까지 구현을 했으면 나머진 본인의 비즈니스 로직에 잘 녹여주면 된다.
예시로 좋아요 알람을 주던 코드를 보자

    @Transactional
    public Void doLike(Long reviewId) {

        User user = validUser(AuthHolder.getUserId());
        Review review = validReview(reviewId);
        validSelfLike(user.getId(), review.getUser().getId());

        if (likeRepository.existsByUserAndReview(user, review)) {
            likeRepository.deleteByUserAndReview(user, review);
        } else {
            likeRepository.save(Like.builder()
                        .user(user)
                        .review(review)
                    	.build());
            LikeSseResponse likeSseResponse = LikeSseResponse.builder()
                    .nickname(user.getNickname())
                    .reviewId(reviewId)
                    .thumbnail(review.getBook().getThumbnailUrl())
                    .likedTime(DateUtils.formatLocalDateTime(LocalDateTime.now(ZoneId.of("Asia/Seoul"))))
                    .build();

            notificationService.customNotify(review.getUser().getId(), likeSseResponse, "작성하신 리뷰에 좋아요가 달렸습니다", "like");
        }
        return null;
    }

SSE 관련 코드는

LikeSseResponse likeSseResponse = LikeSseResponse.builder()
                    .nickname(user.getNickname())
                    .reviewId(reviewId)
                    .thumbnail(review.getBook().getThumbnailUrl())
                    .likedTime(DateUtils.formatLocalDateTime(LocalDateTime.now(ZoneId.of("Asia/Seoul"))))
                    .build();

            notificationService.customNotify(review.getUser().getId(), likeSseResponse, "작성하신 리뷰에 좋아요가 달렸습니다", "like");
        

이 부분인데 좋아요가 눌릴 때 마다 SSE로 보낼 객체를 만들어 customNotify 메서드에 인자로 담아 보내고

public <T> void customNotify(Long userId, T data, String comment, String type) {
        sendToClient(userId, data, comment, type);
    }

private <T> void sendToClient(Long userId, T data, String comment, String type) {
        SseEmitter emitter = emitterRepository.get(userId);
        if (emitter != null) {
            try {
                emitter.send(SseEmitter.event()
                        .id(String.valueOf(userId))
                        .name(type)
                        .data(data)
                        .comment(comment));
            } catch (IOException e) {
                emitterRepository.deleteById(userId);
                emitter.completeWithError(e);
            }
        }

해당 메서드에서 sendToClient 로 다시 보내 createEmitter로 구독한 userId를 리뷰 작성자로 찾아 보내주고 구독중이라면 메시지 이벤트를 보내는것이다.

이렇게 스프링 코드는 끝이났다.

클라이언트 코드는 내가 기술검증하며 사용한 코드로 보여주겠다.

 useEffect(()=> {
    const eventSource = new EventSource("http://localhost:8080/subscribe/1")

    eventSource.onopen = async () => {
      await console.log("sse opened!")
    }

    eventSource.addEventListener('like', (event) => {
      console.log("like")
      const data = JSON.parse(event.data);
      console.log(data)
    });

    eventSource.onerror = async (e) => {
      await console.log(e)
    }

    return () => {
      eventSource.close()
    }
  },[])

놀랍게도 이게 전부이다. 실제 서비스에도 이런식으로 이벤트 스트림을 담고 있으며
클라이언트 개발자가 UI 이벤트 처리까지 끝내놔서 알람이 정상적으로 동작한다.

profile
안녕하세요 백엔드 개발자를 지망하고있는 노원호라고합니다.

0개의 댓글