Skip to content

알람 기능구현

이민수 edited this page Oct 26, 2024 · 2 revisions

👀 알람 기능구현

📘 개요

현 프로젝트는 채팅을 통해 판매자와 구매자가 소통하며 거래를 진행하는데

알람기능이 없다면 상대방이 채팅을 보내도 사용자는 채팅방에 들어가야지 확인가능하므로

불편하다 생각들어서 알람기능을 개발했습니다.

추가적으로 채팅뿐만 아니라 내가 작성한 게시글에 댓글이나 좋아요를 누를시 알람이 올수있게 추가적으로 설계했습니다.

📗 개발과정

현 채팅기능은 WebSocketSTOMP를 활용해 개발하였고,

알람기능또한 STOMP를 통해

@MessageMapping("/user/{userId}")
public void alarm(@DestinationVariable Long userId){}

사용자는 인증이 될시 "/queue/user/userId" 경로로 구독을 하고

상대방이 채팅을 보낼떄 SimpleMessageTemplate 클래스를 이용해

 @MessageMapping("/{roomId}")
    @SendTo("/queue/{roomId}")
    public ChatMessageDTO chat(@DestinationVariable Long roomId, ChatMessageDTO dto){
        messagingTemplate.convertAndSend("/queue/user/" + dto.getReceiver(),new AlarmMessage(dto.getRoomId()
        ));
        ChatAlarmDTO chatAlarmDTO = ChatAlarmDTO.builder().roomId(roomId).senderId(dto.getSender()).senderTime(dto.getSendTime()).build();
        alarmService.customAlarm(dto.getReceiver(),chatAlarmDTO,"새로운 채팅이 도착했습니다","chat");
        return chatService.createChat(roomId,dto);
    }

자기자신의 id의 경로로 구독한 사용자에게 알람이 올수있도록 설계했습니다.

테스트를 진행했을떄

화면 캡처 2024-09-25 230132

정상적으로 알람을 수신할수 있엇습니다.

📚 의문점

그런데 이떄 의문점이 생겼습니다.

STOMP 프로토콜은 Socket 기반으로 동작하므로 양방향 연결을 해야하지만,

알람 서버스는 서버에서 -> 클라이언트 에게 보내는 단방향 연결으로도 충분합니다,

특히 알람을 받을려면 어떤 페이지에서든 로그아웃 전까진 계속 구독을 해야하는데

아무래도 양방향 연결보단 단방향 연결을 사용하면 오버헤드도 적고 리소스 사용도 적어지므로

단방향 연결인 SSE 프로토콜을 사용해 구현을 한다면 조금더 성능 최적화가 가능하지 않을까 고민했습니다.

STOMP

  • 장점: 채팅서비스를 개발하는데 사용했기에 이미 설정이 완료된 인프라를 그대로 사용할 수 있어 초기 구현 비용이 적음

  • 단점: 양방향 연결(불필요한 오버헤드 발생)

SSE

  • 장점: 단방향 연결
  • 단점: STOMP보다 초기 구현 비용이 높음

그리고 특히 SSE같은 경우 클라이언트의 연결이 끊어질 경우 자동으로 재연결하는 특성을 가지므로

사용자가 로그아웃 하지 않는 이상 알람이 지속적으로 전달 될 수 있다는점 때문에 SSE를 사용하기로 결정했습니다.

🏃 구현

 @GetMapping(value = "/subscribe/{accessToken}",produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter subscribe(@PathVariable(name = "accessToken") String accessToken){ 
        Long userId = tokenProvider.getUserId(accessToken);
        return alarmService.subscribe(userId);
    }

SSE는 HTTP 상에서 동작하므로 GetMapping produces = ~ 부분으로 서버는 응답을 스트림으로 실시간 스트리밍 할수있도록

설정해두고

현 프로젝트는 SPA(Single Page Application) 프로젝트가 아닌 SSR(Server Side Rendering) 프로젝트이므로

매 페이지마다 새롭게 구독을 해야 하는데, user 정보를 랜더링 해주는 페이지가 없을 경우도 있으므로

accessToken을 사용하고 TokenProvidergetUserId메서드를 재활용해

"/subscribe/{accessToken}" 경로로 API를요청하면 구독을 할수있도록 설계했습니다.

그리고 Spring Framework 4.2 부터 SseEmitter를 제공하는데 이 구현체를 사용할시

Content-Type, 데이터 포맷을 자동으로 맞춰주기때문에 SseEmitter를 사용했습니다.

@Repository
@RequiredArgsConstructor
public class EmitterRepository {
    private final Map<Long, SseEmitter> emitters = new ConcurrentHashMap<>();

    public void save(Long userId, SseEmitter emitter){
        emitters.put(userId,emitter);
    }
    public void deleteByUserId(Long userId){
        emitters.remove(userId);
    }
    public SseEmitter get(Long userId){
        return emitters.get(userId);
    }
}

현재는 메모리에 key:userId , value:SseEmitter 를 저장하게 한뒤

추후 Redis or DB에 저장하는 방식을 바꿔야 할 때

확장성을 고려해 EmitterRepository를 별도에 클래스로 분리하였고

그리고 동시성 문제를 위해 ConcurrentHashMap을 사용했습니다.

Service 계층

@Service
@RequiredArgsConstructor
public class AlarmService {

    private final EmitterRepository emitterRepository;
    private static final Long TIMEOUT = 600L * 1000 * 60;

    public SseEmitter subscribe(Long userId){
        SseEmitter emitter = createEmitter(userId);
        sendToClient(userId,"userId :"+ userId ,"SSE 연결완료");

        return emitter;
    }

    public SseEmitter createEmitter(Long userId) {
        SseEmitter emitter = new SseEmitter(TIMEOUT);
        emitterRepository.save(userId, emitter);

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

        return emitter;
    }

    public 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.deleteByUserId(userId);
                emitter.completeWithError(e);
            }
        }
    }

    public <T> void customAlarm(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.deleteByUserId(userId);
                emitter.completeWithError(e);
            }
        }
    }
}

sendToClient 메서드에서 type을 매개변수로 지정한 이유는 SSE 이벤트의 식별자 역할을 부여하기 위해서 입니다,

type을 지정해서 클라이언트에서 좋아요,댓글,채팅 알람을 유형별로 다르게 처리할수 있엇습니다.

그리고 채팅을 보낼떄, 좋아요를 누를떄, 댓글을 작성할때 customNotify메서드를 호출해 알람을 보낼수있엇습니다.

결과

알림기능