알람 서비스 LongPolling으로 구현하며

sally·2023년 3월 1일
2

알림 서비스

목록 보기
1/3

알림 서비스를 알아보면 모바일 별 FCM 등 이용방법이 있었고, 각 디바이스 정보를 보관해서 보내는 거 같았다.
예전에는 어떤 방법들이 있었을까?
효율면에서는 시작할 거 같지 않지만, 구현과정은 달랐었고, 3가지 정도 구현해보자 싶었다.

  • Polling, LongPolling
    • Polling : 단순하게 클라이언트 쪽에서 요청할 때 응답 통한 전달
    • LongPolling : 클라이언트에서 요청 후 일정 시간 동안 데이터가 발생하면 응답 통한 전달
      • 기존 보다는 실시간 어필
  • SSE
    • 클라이언트가 구독 신청하면, HTTP 커넥션 유지되어 스트림 통해 실시간으로 여러차례 응답
    • 기존 보다, 더 실시간 어필
  • Redis Pub/Sub
    • 당연 실시간, 조사 중... 현재 Redis 연동 되있기 때문에

웹 소켓의 양방향성과 다르다고 봤다. 채팅등에 이용되는데, 알람 서비스는 일단 구독 요청에 대해서 추후 알람을 서버에서 클라이언트로 보내주는 점이 다르다.
그리고, 이런 단방향 성 서버 전송 방식이 구현하며 차이를 볼 때 흥미로운 점이 있었다.


학습 하면서 구현 실습 해본 경험이라 잘 못된 부분이 있을 수 있습니다. 알려주시면 감사하겠습니다.

Long Polling 은 서버 측에서 응답을 일정시간 동안 대기 상태에 둬야 한다고 봤다.
어떻게 대기 하지?

  • 스프링은 요청 별로 스레드 할당 한다.
  • 그 스레드가 주어진 일정 시간 동안 할 일은?
    • 알람 전송 할 데이터 확인하기
    • 데이터 있으면 전송

앞에서 시간 ➡️ 할 일 순서로 봤는데,
🔙 할 일을 시간 동안 하는 순서로 보면 ?

  • 주어진 시간 동안 알람 전송 할 데이터를 여러 번 조회해서 확인 하기
  • 그 시간 동안 컨트롤러는 응답 지연 중
  • 데이터 있으면 전송, 없으면 응답

좀 더 세분화 해서 정리해보면 😢
✅ 주어진 시간 동안 알람 전송 할 데이터를 여러 번 조회 어떻게?

  • 실시간 조회 ➜ Crontab = 스프링 스케줄러

✅ 그 시간 동안 컨트롤러는 응답 지연 중

  • 다른 클라이언트가 알람 조회 요청 ➜ 스프링에서 관리되는 스레드 사용
  • 그런데 이 스레드 사용이 다른 컨트롤러 요청과 성격이 다르다.
    • 바로 응답하여 확인이 아닌, 데이터가 없을 수도 있다. 서버 측에서 데이터가 있다면 전달하는게 더 큰 역할 느낌
    • 새로고침이나 사용자가 직접 요청이 아니다.
  • 그렇게 좀 다른 의미로 의미가 적은 요소에 스프링의 웹 스레드를 회원 수 만큼 계속 처리한다면 ? (이미 불가능 ㅎㅎㅎ)
    • 트래픽이 너무 많지 않고, 변경이 잦지 않은 환경이란 가정하에
    • 5초 동안 10명의 요청을 가정했다. 😶

✅ 데이터 있으면 전송, 없으면 그냥 응답

  • 그렇구나

가장 중요한 걸 미루고 있었다. (모르는데? 모르는데? ...)

주어진 시간 동안 컨트롤러가 대기 후 응답

  • 시간 지연 + 동작

요청별 스레드 할당 해서 스케줄러로 5초 동안 1초 씩 돈다면
DB 트랜잭션까지 이용하며 조회 해와서 데이터 없으면 그냥 응답인데,
데이터 있으면 그 순간 데이터 담아서 전송해야 한다.

  • 이쯤부터, Redis 생각이 많이 들었다. 알람을 영속성 데이터로 저장 할 필요 있을까 생각해보니, 전송 후 없어도 될 거 같은게 알람 이었다. 하지만, 더 새로운 것보다는 집중해보기로 했다. (아직 생각 못 한 요구사항이 떠오를 지도)

일단 구현하기 ㅎㅎㅎ

요청 받고, 스케줄러 도는데, 계속 돌면서 계속 조회 결과 없고 응답은 없다. 나의 약한 노트북을 이렇게 과거 괴롭힌 적 없는데 ... 😬
하지만, 문제들을 보며, 이 때 부터 로직들을 잘 정리 할 수 있었고, 구현까지 갔다.


@Scheduled

여러 옵션 설정이 있다.

  • 외부 응답 보내기, DB 조회, 변경 으로 꽤 시간 소요되는 작업들이 많았다.
  • fixedRate : 이전 작업 시작 시간 부터 고정 시간 설정

@Scheduled 적용 메서드는 전달할 파라미터나 반환 값이 없어야 한다.

  • 클라이언트가 조회 요청하면, 그 정보를 별도 자료구조에 보관해야 한다.

  • 1명을 5초 동안 반복해서 확인 하는게 처음 생각한 LongPolling 개념 이이었다.

    • 자료구조에는 스케줄러에서 이용할 클라리언트 정보가 담긴다

      • 5초가 짧은 시간 이라면, 지연되더라도 다음 사용자 위한 처리로 1초씩 돌때 응답 대기 시간 5초 동안 5명의 순회는 보장은 어떨까 싶었다.🙃
    • 5초 동안 10명의 요청 이란 ?

      • 5초 내 : 1초 단위로 5명 확인
        • 1초 간격 마다 사용자 요청?
        • 1초에 5명 요청 후 4초 때 5명 요청?
      • 만일 더 길어진 요청 대기 시간 동안 1초 간격 순회?
        • 10 초에 스케줄러는 10명 체크 보장
  • 요청 별도로 받을 자료구조 : 시간순서대로 처리하자 FIFO
  • 5명의 요청을 어떻게 보관할까?
    • 스케줄러 1초 마다 별도 처리라 생각하면 비동기가 떠올랐다.

DeferredResult

비동기 처리 하면 무언가 많은데
LongPolling은 요즘 안 쓰여서 인지 설명이나 구현 예제들이 적어보였지만, DeferredResult 가 기본인 거 같았다.

번역 해서 간력히 써보면

Long polling 은 서버 애플리케이션이 정보를 이용할수 있을 때까지 클라이언트와의 연결을 유지하는 방식으로, 서버가 정보를 얻거나 결과를 기다리기 위해 다운스트림 서비스를 요청해야 할 때 사용된다.

DeferredResult

  • 에러와 타임아웃을 어떻게 다룰지와 테스트 방식 알기

Long Polling과 DeferredResult

  • Spring MVC 에서 인바운드 HTTP 요청을 비동기적으로 다루고자 할 때 사용
  • 들어오는 요청을 다루기 위한 HTTP 작업 스레드가 다른 작업 스레드로 오프로드 할 수 있게 하여 대기시간이나 긴 컴퓨팅 시간을 좀더 효율적으로 이용할 수 있다.
  • worker가 setResult 를 호출 할 때, 컨테이너 스레드는 요청한 클라이언트한테 응답을 허용한다
  • 다운 스트림 시스템으로부터 절대 응답 받지 못할 경우를 위한 타임아웃 메커니즘은 필수이다.
  DeferredResult<Response<List<Alarm>>> output = new DeferredResult<>(LONG_POLLING_TIMEOUT);
  output.onTimeout(() -> {
      output.setErrorResult(String.format("TimeOut: %dms", LONG_POLLING_TIMEOUT));
      alarmInMemory.remove();
  });
  • 타임아웃 임계치 도달시 컨테이너 스레드에 의한 입력을 Runnable 로 하여,

    • 타임아웃 되면 에러나 setErrorResult 로 다룬다.
  • 타입아웃 확인 위한 테스팅 예제

	private static void enableTimeout(MvcResult asyncListener) throws IOException {
		((MockAsyncContext)asyncListener
			.getRequest()
			.getAsyncContext())
			.getListeners()
			.get(0)
			.onTimeout(null);
	}

벨덩 DeferredResult Callbacks

  • 비동기 요청 완료시
	output.onCompletion(() -> log.info("The alarm publish is completed."));
  • 타임 아웃 발생시
output.onTimeout(() -> {
			output.setErrorResult(String.format("TimeOut: %dms", LONG_POLLING_TIMEOUT));
			alarmInMemory.remove();
		});
  • 에러 발생시
deferredResult.onError((Throwable t) -> {
    deferredResult.setErrorResult(
      ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
        .body("An error occurred."));
});

AlarmEntity

현재는 많은 기능 없고 댓글 등록시 알람 전송만 하지만, 좋아요나 팔로우 등 추가한다면 생각하고 구현 해봤습니다.
확실히 ... 기능적인 부분보다 복잡 하니 잼잼잼...

설계 과정으로 아주 주관적인 이야기

메시지? : 누가 -의 -에 -을 했습니다. (sender → recipient)

검색 ? : -의

  • 누가 : 댓글 작성자, 좋아요 클릭한 회원, 팔로우 신청자
  • -에 :
    • 포스트명? ➜ 포스트명이 길면?
      • 포스트에 (postId) (default : 내가 작성한 포스트) → 이 댓글을 달았습니다.
      • user flow 상 짧아도 되지 않을까? 댓글이 달렸습니다

user flow ?
click alarm's message → show 포스트 or 댓글
click the part of content → sender 조회

  • 댓글은 제목도 없다.

    • 짧게 메시지 전달하고 클릭시 해당 키워드 (포스트, 댓글, 팔로워) 를 보러 가게 하면 좋을 거 같다.
  • 키워드 ? AlarmType

    AlarmType메시지
    COMMENT댓글을 달았습니다.
    POST_LIKE포스트에 좋아요를 눌렀습니다.
    COMMENT_LIKE댓글에 좋아요를 눌렀습니다. (댓글 작성자가 recipient)
    FOLLOW팔로우/구독? 합니다.
    • 댓글
      • 댓글 보러가기 → 포스트 or 댓글
        • postId
        • commentId + 댓글 목록 ( /api/v2/sns/posts/{postId}/comments )
    • 좋아요
      • 포스트 보러가기
        • postId
      • 댓글 보러가기
        • commentId + 댓글 목록 ( /api/v2/sns/posts/{postId}/comments )
    • 팔로우
      • senderId (userId) 포스트 목록 보러가기

🚨 문제는 ...

각 키워드 별(AlarmType) 별 저장 할 데이터들이 달랐다.

처음에는 아래처럼 단순화 할 수 있을까 싶었는데, 요구사항이 위처럼 4가지라면 변경 발생시 적절하지도 못 했다.

💡 그래서 모든 값들을 담아보자 생각해본 방법 4가지

  • 테이블 내 컬럼 별 값을 그냥 null 로 대체한다.
  • 별도의 각 타입별 저장할 데이터 목록화한 테이블을 만들어 PK 를 FK로 주입
  • MySQL 의 JSON 타입
  • NoSQL

순서대로 보자면

  • 앞서 알람 테이블은 영속성 데이터 보관이 필요할까 란 고민이 잠시 있었다. 짧은 식견으로는 다른 테이블 보다는 보관되지 않고 삭제 작업이 주요할 거 같았다.
    그런 면에서 null 은 무시할 수도 있을 수 있지만, null 에 대해 다르게 설계해보자가 제 주관이라서 pass
  • 역시 위와 동일한 이유로 조인까지 하면서 조회하고 관리 해야 할까 싶었다. 게다가 스케줄러의 반복 조회되는 상황이라 배제해보자 생각
  • 마지막 NoSQL은 처음부터 미뤘으니까 ...

MySQL의 JSON 타입 사용 하기로 했다.

  • 의존성 추가
    implementation 'com.vladmihalcea:hibernate-types-52:2.20.0\'
  • 엔티티에 Json 애노테이션 추가
    @TypeDef(name = "json", typeClass = JsonStringType.class)
    @Type(type = "json")
    • typeClass가 여러가지 이니 추가로 알아보면 좋다.

LongPollingAlarmService

  • 요구사항 2가지를 생각 해 봤다.
    • 조회 시간 이후 발생된 알람만 전송
    • 클라이언트가 접속으로 요청시 미발송 알람들 모두 전송

첫 번째는 쿼리 조회 조건으로 시간만 추가하면 돼서 간단해 보였었다.
2번째로 구현하면 알람이 너무 많은 경우 모아서 전달 해 버린다.

  • 최근 10개 중 delete 안 된 것만 ?
    • 만일 30개가 쌓이면.. 나머진 ?
    • 모두 조회해와서 10개만 응답으로 보내고 나머진 delete 진행 ?
  • 페이징이라도 추가 할 수 도 있지만 50개 있는데 10개씩 5번 전송...
    • 잦은 변경이 아닌 상황 설정상 알람이 별로 없을 거라는 가정이었으니까....(🛫 물론 지금처럼 목록 보낼 경우 페이징 또는 개수 제한이 있는게 좋다)

알람 요구사항 정리

  • 알람 전송은 얼마나 필수 일까?
    • 필수라면, 반복되더라도 꼭 보내줘야 할까?
      • 필수라면, 반복 되는 중복 알람도 허용?
    • 필수가 아니라면 보완이나 대책은?
      • 없어도 되는가? .. 극악은 알람 없어도?
      • 다른 방식으로 확인 방법이 있을까?

결국은, 알람 목록 확인 페이지 등이 별도로 있다면, 몇 건 미스되더라도 현재 시간 이후의 실시간 알람이 나을 거 같다.

  • 서비스 마다 다르겠지만, SNS 피드 형식에서는 알람 끄기 기능이 제공되기도 하고, 너무 많은 알람을 보통 선호하진 않을...거 같은데, 중복은 더 큰 문제인 거 같았다.
  • 알람 미전송 보다 중복 알람 더 오류로 느껴질 거 같다고 생각 했습니다.

p.s.주관적 생각입니다.

... 생각과 다르게 구현은 일단 모두 조회해서 전송하고 변경 로직으로 했습니다. 그래서 이곳에 나름 자세히 정리해 봅니다. ㅎㅎㅎ

  • 현 상태로는 @Transactional 필수

그 외에도 수정한다면

  • 처음에는 응답 시간 동안 한 사용자의 5번 조회 였지만, 5초의 시간 동안 확인하는 조회되는 사용자의 확실성으로 변경 했고, 데이터 없는 경우 타임아웃 응답이 그대로였다.
    • 조회 데이터가 없는 경우 타임아웃 콜백패턴의 응답은 커널 통한 동작 처리라면 커널 영역으로 오버헤드가 있을 거 같다 생각이다.
    • 확인 시마다 데이터 유무 무관하게 바로 응답 하자는 생각이다.
    • 그럴 경우 클라이언트에서 일정 시간 간격으로 요청이 보장되야 한다.
      • 그렇지 않으면, 응답 후 바로 요청한다면 사용자가 늘수록 요청도 많아진다.
  • 실시간 조회 보다 댓글 등록 시점에 해당 포스트 정보 가진 조회로 응답 한다면, LongPollingAlarmInMemory 를 해시로 구현 한다면 어떨까?
    • 요구사항은 5초 동안 사용자 조회 였었다. 이는 댓글 등록되지 않는다면 타임아웃 발생으로 응답을 보낸다.
    • 이런 점이 SSE 의 여러 응답 처리나 이벤트 발생 기준 발행 방식과의 차이인 것 같다.
profile
sally의 법칙을 따르는 bug Duck

0개의 댓글