본문 바로가기
Woowa Techcourse/Missions

React에서 Browser Notification (푸시 알림) 구현하기

by mingule 2021. 9. 10.

들어가기

Babble 프로젝트에서 WebSocket을 활용해 채팅 기능을 구현한 바 있다.

그런데, 유저 입장에서는 내가 들어간 방에 채팅이 새로 올라온지, 올라오지 않은지 확인할 길이 없었다.

무조건 다시 채팅이 있는 Tab으로 들어가 확인하는 수밖에 없었는데.. 

그래서 이런 불편함을 해결하기 위해 브라우저 푸시 알림 기능을 활용하기로 했다. 

채팅이 오면 브라우저 알림이 뜬다.

그런데 사실 나는 이런 브라우저 푸시 알림 기능을 귀찮아한다.

쓸데없는 알림인 경우가 대다수고, 너무 알림이 자주 오면 브라우저를 이용하는 데 방해되기 때문이다.

게다가 우리가 구현하는 기능은 채팅 알림인만큼 어떤 누군가가 채팅을 많이 치게되면 알림이 끊임없이 올 것이고,

이는 사용자 경험을 매우 떨어뜨릴 것이 분명했다.

팀원의 대다수가 이런 알림에 대해 부정적인(?) 경험에 대해 공감했고, 그래서 우리는 대안책을 선택하기로 했다. 

 

우리가 선택한 대안책은, 푸시 알림이 오되, 너무 많은 알림이 오지 않도록 일정 시간동안은 새로운 알림이 오지 않게 하는 것이었다.

아래 캡쳐본을 보면 '하이하이~'라는 알림은 오지만

그 이후에 바로 오는 '안녕하세요~~'와 '게임하실래요?'는 알림이 오지 않는 것을 볼 수 있다.

만약 알림 창이 꺼진 후에 새로운 메시지가 온다면 다시 알림이 온다.

알림이 마구 쏟아지지 않고 적절히(?) 온다. 하하하

 

 

브라우저에서 제공하는 Notification API를 사용해 Push 알림을 구현하는 것 자체는 크게 어렵지 않았다. 

그럼 어떻게 구현했는지 확인하러 꼬우꼬우~~

 

Push Notification Custom Hook 만들기

(아직 계획은 없지만) Push Notification을 여기저기 재활용 할 수 있을 것 같아 Custom Hook으로 만들기로 했다.

아래와 같이 만들 수 있다.

import { useRef } from 'react';

const usePushNotification = () => {
  const notificationRef = useRef(null);

  // Notification이 지원되지 않는 브라우저가 있을 수 있기 때문에, 이를 대비해 Early return 문을 걸어준다.
  if (!Notification) {
    return;
  }
  
  // 만약 이미 유저가 푸시 알림을 허용해놓지 않았다면,
  if (Notification.permission !== 'granted') {  	
    // Chrome - 유저에게 푸시 알림을 허용하겠냐고 물어보고, 허용하지 않으면 return!
    try {
      Notification.requestPermission().then((permission) => {
        if (permission !== 'granted') return;  
      }
    } catch (error) {
      // Safari - 유저에게 푸시 알림을 허용하겠냐고 물어보고, 허용하지 않으면 return!
      if (error instanceof TypeError) {
        Notification.requestPermission().then((permission) => {
          if (permission !== 'granted') return;
        }); 
      } else {
      	console.error(error)
      }
    }
  }
  
  // 유저가 푸시 알림을 클릭하면, 푸시 알림이 일어난 화면으로 이동하기
  const setNotificationClickEvent = () => {
    notificationRef.current.onclick = (event) => {
      event.preventDefault();
      window.focus();
      notificationRef.current.close();
    };
  };
  
  const fireNotification = () => {
    const newOption = {
      badge: '',
      icon: '',
      ...options
    }
    
    // notificationRef에 Notification을 넣어준다. 이 친구는 이렇게 할당만해도 바로 실행된다.
    notificationRef.current = new Notification(title, newOption)

    // 위에서 만든 클릭 이벤트 걸어주기
    setNotificationClickEvent(); 
  }
  
  return { fireNotification }
}

export default usePushNotification;

 

원래 Chrome에서만 계속 확인해보다가, 가볍게 Safari로 테스트를 해봤는데, 웬열 문제가 생겼다. 

아래와 같은 TypeError가 떴는데, Safari에서의 Notification API와 Chrome에서의 Notification API가 달라서 생긴 문제였다.

그래서 Try-Catch로 에러를 잡아주었다. 

Safari는 Notification API 자체는 있기 때문에 위의 Early Return에서 잡히지 않는다. 

Notification.requestPermission 메소드의 사용법이 Chrome과 달라서 그러니, 다르게 써주어야 한다. (통일 좀 하지!!!!!!!)

모든 브라우저를 테스트해본게 아니라서.. 아직 Cross Browsing을 모두 세세히 살피지 못해 괜히 찝찝하기는 했다. 

(그래도 Chrome Safari 대응 했으니까 ^^! ! ㅎㅎㅎㅎ!! ㅎㅎㅎㅎ 정신승리)

 

자, 이제 다 만들기는 했는데, 푸시 알림이 마구 오는 그런 어뷰징(?)을 막아야 한다. (비장)

다음으로 넘어가보자!

 

 

다다다다 올 때 하나만 캐치하기

위에 언급했다시피 알림이 계속 다다다다 오게되면 유저의 사용자 경험이 나빠질 것 같아, 

타이머를 설정해 그 시간 안에 알림이 오면 묶어서 알림을 하나만 보내줄 수 있도록 만들었다.

 

이렇게 어떤 이벤트를 묶어서 실행하게 될 때, 생각나는 건.. Throttle과 Debounce다.

두 개념은 비슷해서 자꾸 헷갈리는 개념들인데, 잠깐 짚고 넘어가겠다. 여기에서 5초는 내가 임의로 정해준 delay 시간이다.

 

Throttle과 Debounce, 두 기술 다 잦은 이벤트 발생을 줄여 성능을 높여주는 데 공통점이 있다. 

 

먼저, Debounce를 알아보자.

Debounce는 이벤트를 그룹화해 특정한 시간이 지난 후, 하나의 이벤트만 발생하도록 한다.

아래 그림을 보면 조금 더 쉽게 이해할 수 있다. 

첫 번째 줄을 보면 발생한 이벤트들을 확인할 수 있는데, (색깔이 들어가있는 애들이 이벤트가 발생한 곳이다)

이벤트가 굉장히 자주 일어나고 있는 것을 확인할 수 있다.

이 이벤트에 Debounce를 걸어보자.

이벤트가 쭉 발생하다가 일정 시간(여기서는 400ms)동안 이벤트가 발생하지 않으면 마지막 이벤트를 실행한다.

출처) https://codepen.io/jaehee/pen/XoKeRW

Debounce 이벤트는 브라우저 크기를 리사이징하는 이벤트, API를 요청하는 이벤트 등에서 자주 사용된다.

브라우저 크기를 리사이징하는 대로 이벤트를 모두 발생시키면 너무 큰 성능 낭비일테니,

일정 시간동안의 리사이징 이벤트를 묶어 마지막 리사이징 이벤트만 발생하도록 하는 것이다. 

Input에 어떤 값을 입력할 때, API 요청을 보낸다고 하자.

값을 입력할 때마다 API요청을 보내면 이 또한 성능 문제가 생길 가능성이 높다.

그렇기 때문에 일정 시간동안의 유저 입력을 묶어 마지막 입력된 값으로 API요청을 보낼 수 있게 하는 것이다.

 

살짝 다시 이모티콘으로 설명해보자면 

아래의 동그라미들(하얀 동그라미, 까만 동그라미)이 이벤트들이라고 할 때,

5초동안 이벤트가 더이상 일어나지 않으면 마지막 이벤트(까만 동그라미)가 실행되도록 하는 것이 Debounce 이다.

⚪️ ⚪️ ⚪️ ⚪️ ⚪️ ⚫️ --- ( 5초 )--- ⚪️ ⚪️ ⚫️ --- ( 5초 ) --- ⚪️...

 

그러면 Throttle은 뭘까?

Throttle은 이벤트를 일정 주기마다 발생하도록 하는 기술이다.

마지막 함수가 호출된 이후, 일정 시간이 지나기 전에 다시 호출되지 않도록 하는 것이다. 즉 중간에 이벤트가 발생해도 무시된다.

만약 Scroll 이벤트가 있다고 하자. Scroll 이벤트를 걸게 되면 Scroll이 될 때마다 계속 이벤트가 발생한다.

그런데 만약 Throttle을 걸게되면, 일정 시간(예를 들면 5초)마다 Scroll 이벤트가 한 번씩만 일어난다.

아래와 같이 이벤트가 일정하게 일어나는 것이다.

 

Throttle도 살짝 다시 이모티콘으로 설명해보자면

아래의 동그라미들(하얀 동그라미, 까만 동그라미)이 이벤트들이라고 할 때,

마지막 함수가 호출된 이후, 일정 시간이 지나지 않았다면 그 기다리는 시간동안 발생된 이벤트는 무시된다.

⚪️ - ⚪️ - ⚫️ - ⚪️ - ⚪️ - ⚫️ - ⚪️ - ⚪️ - ⚫️ - ⚪️ - ...  ( ⚪️ - ⚪️ - ⚫️ : 5초)

 

여기에서 헷갈릴 수 있는 점을 잠깐 짚고 넘어가겠다. 

그러면 무조건 이벤트 시간에 딱 맞추어서 이게 이벤트가 실행되는가? 라고 했을 때,

이벤트의 시간이 딱 맞지 않아도 이벤트가 실행된다.

 

예를 들어, 어떤 이벤트에 3초마다 Throttling을 걸어놓았다고 했을 때, 아래와 같이 동작한다는 것이다.

예시) 1초(실행 안됨) - 3초(실행됨) - 5초(실행안됨) - 7초(실행됨)

3초 - 6초 - 9초 - .. 만약 계속 실행되는 이벤트라면 이렇게 딱 맞추어 실행되겠지만, 

위의 예시처럼 띄엄띄엄 실행되는 이벤트일 때에는 3초 직후의 이벤트, 6초 직후의 이벤트 .. 이렇게 이벤트가 실행된다.

그림으로 설명하면 아래와 같다.

                3초                 6초                 9초   

  ⚪️ - ⚪️ - ⚫️ - ⚪️ - ⚪️ - ⚫️ - ⚪️ - ⚪️ - ⚫️ - ⚪️ -  → 비교를 위한 일정한 간격의 이벤트

  ⚪️ - ⚪️     -     ⚫️ - ⚪️ - ⚫️ - ⚪️ - ⚪️     -     ⚫️ - . . .  → 일정한 간격이 아닌 이벤트

 

 

Debounce, Throttle과 같은 이벤트들은 성능 상의 이점이 있기는 하지만,

시간을 잘 생각해 선택하지 않으면 오히려 사용자 경험을 떨어뜨릴 수 있다.

너무 길게 잡으면 마치 화면이 멈추거나 렉이 걸린 것 같이 느껴질 수 있기 때문이다. 허헛.

뭐든 잘 사용하는 것이 중요하다!

 

어쨌든, 

아까 우리가 원했던 "푸시 알림이 오되, 너무 많은 알림이 오지 않도록 일정 시간동안은 새로운 알림이 오지 않게 하는 것"을 들었을 때,

우리는 Throttle이 가장 적합한 기술이라고 생각했고, 이를 적용해보고 싶었다.

 

그런데 우리가 원하는 이벤트는 첫 번째 이벤트가 실행되는 아래와 같은 이벤트였다.

🟠 - 첫 번째 이벤트(유저의 입장 메시지)의 알림을 주고,

그 이후의 메시지부터 Throttle을 걸어주려고 했다. 

 

즉, 아래와 같은 이벤트를 만드려고 했다.

🟠 --- ⚪️ ⚪️ ⚪️ ⚪️ (5초) ⚫️ --- ⚪️ ⚪️ (5초) ⚫️ --- ⚪️ ⚪️ ⚪️...

 

일단 아래와 같이 뚝닥뚝딱 만들어보았다.

import { useRef } from 'react';

const usePushNotification = () => {
  const notificationRef = useRef(null);
  const timerRef = useRef(null);

  if (Notification.permission !== 'granted') {
    try {
      Notification.requestPermission().then((permission) => {
        if (permission !== 'granted') return;
      });
    } catch (error) {
      if (error instanceof TypeError) {
        Notification.requestPermission((permission) => {
          if (permission !== 'granted') return;
        });
      } else {
        console.error(error);
      }
    }
  }
  
  const setNotificationClickEvent = () => {
    notificationRef.current.onclick = (event) => {
      event.preventDefault();
      window.focus();
      notificationRef.current.close();
    };
  };  
  /**************** 위에 작성한 코드까지는 위에 설명해놨음! ****************/

  // Notification을 위한 타이머를 설정하는 함수!
  const setNotificationTimer = (timeout) => {
    // clearTimeout을 하기 위해 timerRef에 저장해준다.
    timerRef.current = setTimeout(() => {
      // timeout에 넣어준 시간만큼 흐르면, 콜백이 실행되면서 timerRef.current에 null이 담기게 된다.
      timerRef.current = null;
		
      // 시간이 다 흘렀으면 푸시 알림이 꺼지도록 해준다.
      notificationRef.current.close();
      // 그리고 역할을 다한 notificationRef를 null로 초기화해준다.
      notificationRef.current = null;
    }, timeout);
  };

  // 어떤 알림을 보낼 것인지, 몇 초마다 알림을 보낼 것인지에 대한 함수
  const fireNotificationWithTimeout = (title, timeout, options = {}) => {
    // 만약 유저가 푸시 알림을 꺼놓았다면 함수가 실행되지 않게 미리 return을 해준다.
    // 그런데 가드를 세워놨음에도 불구하고, Safari에서 실행되지 않는 문제점이 있었다. 이 문제는 해결중이다 ㅜㅜ!
    if (Notification.permission !== 'granted') return;

    // Notification API는 두 번째 인자로 option 값을 받는데, 뱃지 이미지와 아이콘 등을 설정해 줄 수 있다.
    // 초기 badge, icon을 설정해주었다.
    const newOption = {
      badge: 'https://babble.gg/img/logos/babble-speech-bubble.png',
      icon: 'https://babble.gg/img/logos/babble-speech-bubble.png',
      ...options,
    };

    // 만약 notificationRef가 아직 초기화되지 않았다면, 타이머가 아직 동작하고 있는거니까 notificationRef가 없을 때에만 새로운 알림을 만든다.
    if (!notificationRef.current) {
      // 여기 들어왔다는건 타이머가 실행되지 않고 있다는 것이니까, 타이머를 만들어준다.
      setNotificationTimer(timeout);
      
      // 푸시 알림에서 보여줄 title과 위에서 우리가 만든 custom option을 넣어준다.
      // 이 Notification 함수는 할당해도 바로 실행되기 때문에 첫 이벤트는 무조건 실행되고, 그 이후부터 타이머가 작동한다.
      notificationRef.current = new Notification(title, newOption);
      
      // Notification의 Click Event를 새로 붙여준다.
      setNotificationClickEvent();
    }
  };

  return { fireNotificationWithTimeout };
};

 

여기에서 setNotificationTimer의 throttle 로직을 추상화하면서

useThrottle을 만들어 재사용할 수 있게 만들어 보았다!

import { useRef } from 'react';

const useThrottle = () => {
  // timer를 저장하는 Ref를 하나 만든다. 
  const timerRef = useRef(null);

  const throttle = (callback, delay) => {
    // 만약 timer가 없다면,
    if (!timerRef.current) {
      // 새로운 timer를 설정해준다.
      timerRef.current = setTimeout(() => {
        // delay time이 끝나면 callback을 실행한다.
        callback();
        // 다 실행하고 나면 timer를 초기화한다.
        timerRef.current = null;
      }, delay);
    }
  };

  return { throttle };
};

export default useThrottle;

 

그러면 아래와 같이 만들 수 있다.

import { useRef } from 'react';

const usePushNotification = () => {
  /************** ... ****************/

  const setNotificationTimer = (timeout) => {
    throttle(() => {
      notificationRef.current.close();
      notificationRef.current = null;
    }, timeout);
  };

  /************** ... ****************/
  
  
  return { fireNotificationWithTimeout };
};

 

우와앙 이제 유저가 채팅을 많이 보내도, 적당히 푸시 알림이 오게 되었다.!! 꺅

 

Tips

Notification의 option에는 여러가지가 있으니, 다른 option을 설정하고 싶으신 분들은

아래 공식 문서를 참고하셔도 좋을 것 같습니다용!

 

https://notifications.spec.whatwg.org/#dictdef-notificationoptions

 

Notifications API Standard

Abstract This standard defines an API to display notifications to the end user, typically outside the top-level browsing context’s viewport. It is designed to be compatible with existing notification systems, while remaining platform-independent. Table o

notifications.spec.whatwg.org

 

이제 그러면 아래와 같이 사용하면 된다!

const { fireNotificationWithTimeout } = usePushNotification();

fireNotificationWithTimeout('Babble 채팅 메시지', 5000, {
  body: `${user.nickname}: ${content}`,
});

 

마치며..

Push Notification이 생각보다 쉽게 만들어졌다!

만드는 것은 (조금) 쉬웠지만, 이를 구현하면서 페어와 정말 많은 논의를 나눴다. (그래서 구현하는 시간은 엄청 오래걸렸따ㅋㅋㅋㅋㅋㅋ)

throttle을 사용할 것인지, debounce를 사용할 것인지,

throttle 부분을 추상화할 것인지 하지 않을 것인지,

timer부분을 어떻게 잘 사용할 것인지.. 등 

되게 재미있게 논의하고 또 구현했다. 구현하고나니 신기하기도 했다. 케케

 

아마 이제는 Cross Browsing 문제를 잘 해결하는 것이 중요할 듯 한데.. 일단 Safari와 Chrome에서 잘 돌아가는 것은 확인했다.

어떻게 나머지 브라우저들에 대응을 할 것인지는 조금 더 생각해봐야하는 문제일 것 같다.

 

그리고 이게 push notification이 채팅 모달에서만 사용되는 것이 아니라 전체 페이지에서도 사용이 되어야해서

우리는 결국 이렇게 만들어놓고 Context를 사용해 전역에서 사용할 수 있게 만들었다.

이거 구현하면서도 트러블슈팅을 했는데 생각보다 되게 짜릿했기 때문에..! 추후 포스팅에 살짝 작성해 보겠다. (요건 쫌 나중에)

 

(다음 포스팅 스포)

다음 포스팅은 채팅이 오면, Browser Title이 깜빡이는 내용에 대해 다루어 볼 예정이다. 

점점 기존 기능들이 탄탄해지고 있는 것 같지 않나효..? ^^;;ㅎ;;ㅎ; 

(자화자찬)

어쨌든!

이번에도! 재밌었다!

헤헤

 

 

 

 

 

댓글