단방향 통신 SSE 알림 제어
단방향 통신 SSE 알림 제어

단방향 통신 SSE 알림 제어

작성자
이태용이태용
카테고리
browser
태그
frontend
nextjs
SSE
service worker

SSE(Server-Sent-Event)란 무엇인가?

SSE는 서버의 데이터를 실시간으로 스트리밍 하는 단방향 통신 기술이다.
변경된 데이터를 가져오기 위해 지속적으로 API를 호출하여 동기화하는 작업을 없앨 수 있는 것이다.
  • 클라이언트와 서버가 최초 한번 HTTP 연결을 맺으면 그 뒤로 서버가 클라이언트에게 지속적으로 데이터를 보낼 수 있다.
 

서비스워커 조합

서비스워커의 조합은 백그라운드 영역을 효과적으로 제어할 수 있기 때문에 오프라인 상태에서도 알림을 전송할 수 있게 해준다. 사이드 프로젝트 기획상 누군가 채팅을 신청하거나, 채팅을 보내거나, 거래 신청을 했을 경우에 오프라인 상태에서 알림을 받아야 하기 때문에 서비스워커를 이용했다.
 

브라우저 알림 제어하기

우선, 사용자가 로그인했을 때를 기준으로 상위 계층에 선언해 줌으로써 전역 페이지에서 관리할 수 있게 된다. 로그인 성공 시 리다이렉트로 마운트 했을 시에 최초 서비스워커 1회 등록이 되고, 성공 시에 콜백으로 알림을 구독하는 함수가 실행이 된다.
// 서비스 워커 등록 useEffect(() => { if (isLoggedIn) { const serviceWorkerInit = async () => { const permission = await Notification.requestPermission() if (permission !== NOTIFICATION_PERMISSION.허용) return if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js').then(async () => { try { await subscribe(true, accessToken) } catch (error: unknown) { if (error instanceof AxiosError) { return Promise.reject(error) } } }) } } serviceWorkerInit() } }, [])
 
알림 구독 함수는 어떻게 되어 있을까? 알아보자.
파라미터로 firstConnection(첫 연결 여부), token(액세스 토큰)을 받는다. 아래 코드는 액세스 토큰이 유효한 토큰인지 해독하는 코드이다.
const decodedToken = jwtDecode(token || '') as { exp: number } const expTime = decodedToken.exp * 1000 const currentTime = Date.now()
 
해독한 토큰으로 조건을 걸어 만료 일 경우와 아닐 경우를 검증한다. 만료된 토큰이면 리프레시 토큰이 존재하는 한, refresh API을 서버로 쏴서 받아온 응답 값으로 전역 상태에 저장한 액세스 토큰 value 값을 바꾸고, 변수 options의 값을 바꿔주었다.
let options: EventSourceOption = { headers: { Authorization: `Bearer ${token}` }, heartbeatTimeout: 1000 * 60 * 60, withCredentials: true, }; // 토큰 만료별 조건 if (currentTime < expTime - 1000 * 60) { options = { ...options, }; } else { const response = await refresh(); setNewAccessToken(response.data.result); options = { ...options, headers: { Authorization: `Bearer ${response.data.result}`, }, }; }
 
아래 코드는 EventSource 객체이다. EventSourcePolyfill인 이유는 함수명 그대로 폴리필, 다양한 브라우저 환경에서 지원하기 위해 사용된다. 바뀐 변수 options 객체 정보들을 rest 파라미터로 모두 다 가져와 선언해 주었다.
const SSE = new EventSourcePolyfill(`${process.env.NEXT_PUBLIC_API_URL}/api/subscribe`, { ...options })
 
알림을 보낼 콜백 함수이다. 브라우저 내장 API인 Notification을 사용하였다.
// 알림 발송 const showNotification = (title: string, content?: string) => { if (Notification.permission === NOTIFICATION_PERMISSION.허용) { const notification = new Notification(title, { body: content, }); return notification; } };
 
인스턴스로 생성되어 만들어졌기 때문에 변수 SSE 내부에 EventSourcePolyfill 메서드 체이닝이 일어난다. 그래서 내장 객체를 선언할 수 있게 되는데, 아래 코드에서 보면 흐름이 파라미터로 받은 firstConnection값으로 첫 연결인지, 아닌지를 구별하여 각각 다른 조건이 실행되고 있다. 실패 시 SSE 알림 재연결이 일어난다. 실패했는데 재연결을 해주는 이유가 SSE 기능이 단방향 통신으로 연결이 완전히 끊어졌을 때 불필요한 신호를 내보내지 않기 위한 내부 메커니즘으로 주기적으로 끊어진 후 다시 재연결이 되도록 만들어졌다.
SSE.onopen = () => { if (Notification.permission === NOTIFICATION_PERMISSION.허용) { // 첫 알림 푸시 if (firstConnection) { showNotification("알림", "앱 알림을 구독하였습니다."); toast.success("앱 알림을 구독하였습니다."); } else { console.log("재연결 완료"); } } }; // 재연결 시도 SSE.onerror = () => { SSE.close(); setIsSseReconnect(true); };
 
아래 코드는 파라미터로 받은 문자열 데이터가 JSON 형식인지를 여부를 판별하는 함수다. 이 함수를 작성한 이유는 SSE 알림 통신 후 백엔드에서 무조건 1회성 알림을 보내야 하는데 첫 메시지의 데이터가 단순 문자열 형태이기 때문에 그것을 피해 주기 위해 예외 처리한 함수이다.
const isJson = (data: string) => { try { const json = JSON.parse(data) return json && typeof json === 'object' } catch (error: unknown) { return false } }
 
첫 알림 이후 두 번째 알림부터 아래 함수를 거쳐가도록 설계했다. isJson 함수로 문자열인지 검증 후 백엔드로부터 전달받은 이벤트 객체의 내용들을 showNotification 콜백 함수로 전달해 브라우저에 알림을 띄우고 있다. getAlarmStatus 함수는 알림 기능이 “거래”“채팅” 기능에도 구현되게 설계했는데, 프론트엔드로 알림 타입을 던져주는 데이터를 받아와 다른 value로 컨버팅을 해주어야 하기 때문에 만들었다.
// SSE 감지 후 브라우저 알림 푸시 SSE.addEventListener('sse', (event: any) => { if (isJson(event.data)) { const eventData = JSON.parse(event.data) if (eventData) { const alarmInfo: SSEType = { ...eventData, } // 수신 알림 타입 const getAlarmStatus = (value: E_NOTIFICATION_TYPE) => { return { TRANSACTION: '거래', CHAT: '채팅', }[value] } if (Notification.permission === NOTIFICATION_PERMISSION.허용) { showNotification(getAlarmStatus(alarmInfo.notificationType), alarmInfo.content) toast.success(`${alarmInfo.content}`) } } } })
 
전역 컴포넌트에서 선언되었기 때문에 컴포넌트가 언마운트 시 SSE를 끊어주었다. 왜냐하면 전역 컴포넌트가 언마운트 되었다는 뜻은 브라우저가 종료된 경우를 가리킨다. 추가로 리액트 내장 메모이제이션 함수인 useCallback을 사용함으로써 캐싱 기능과 의존성 배열에 accessTokensetNewAccessToken을 선언해 줌으로써 해당 변수가 바뀔 경우에만 실행이 되도록 조건을 걸어주었다.
return () => { SSE.close() } }, [accessToken, setNewAccessToken]
 
아래 코드는 useEffect에 의존성 배열을 걸어 변수나, 함수가 실행되었을 때에만 비동기적으로 발생한다. 재연결 신호가 오면 subscribe 함수에 전역 변수인 액세스 토큰을 파라미터로 담아 요청한다.
// SSE 알림 재연결 useEffect(() => { if (isSseReconnect) { subscribe(false, accessToken) setIsSseReconnect(false) } }, [accessToken, subscribe, isSseReconnect])
 

트러블 슈팅

SSE 옵션에서 heartbeat timeout에 시간을 걸어두면 연결을 끊고 재연결을 시도할 수 있게 된다. 백엔드와 프론트엔드에서 각각 heartbeat timeout을 동일하게 바라보도록 시간을 걸어두면 특정 시간이 지남에 따라 다시 재요청이 발생해야 하는데, 토큰이 만료가 된 후 재발급 받은 토큰으로 요청을 보냈을 경우에만 연결이 끊어지기만 하고 있었다. 이유는 재요청을 보낼 때 토큰이 발급되는 시간과 프론트엔드와 백엔드 서버에서 요청이 발생하는 시간이 서로 엇갈려 브라우저 자체에서 특정 시간이 지났다고 인식되면서 재요청이 발생하지 않은 것이다. 그래서 토큰이 만료기한이 되기까지 넉넉히 1분전으로 잡고 토큰을 해독한 후 유효한 토큰이면 그대로 보내고, 만료되기 1분 전으로부터 토큰이 유효하지 않다면 refresh API를 쏘도록 트러블 슈팅했다.
if (currentTime < expTime - 1000 * 60)
 

전체 코드

// 브라우저 알림 구독 const subscribe = useCallback( async (firstConnection: boolean, token: string | undefined) => { const decodedToken = jwtDecode(token || '') as { exp: number } const expTime = decodedToken.exp * 1000 const currentTime = Date.now() let options: EventSourceOption = { headers: { Authorization: `Bearer ${token}` }, heartbeatTimeout: 1000 * 60 * 60, withCredentials: true, } // 토큰 만료별 조건 if (currentTime < expTime - 1000 * 60) { options = { ...options, } } else { const response = await refresh() setNewAccessToken(response.data.result) options = { ...options, headers: { Authorization: `Bearer ${response.data.result}`, }, } } const SSE = new EventSourcePolyfill(`${process.env.NEXT_PUBLIC_API_URL}/api/subscribe`, { ...options }) // 알림 발송 const showNotification = (title: string, content?: string) => { if (Notification.permission === NOTIFICATION_PERMISSION.허용) { const notification = new Notification(title, { body: content, }) return notification } } SSE.onopen = () => { if (Notification.permission === NOTIFICATION_PERMISSION.허용) { // 첫 알림 푸시 if (firstConnection) { showNotification('알림', '앱 알림을 구독하였습니다.') toast.success('앱 알림을 구독하였습니다.') } else { console.log('재연결 완료') } } } // 재연결 시도 SSE.onerror = () => { SSE.close() setIsSseReconnect(true) } // SSE 감지 후 브라우저 알림 푸시 SSE.addEventListener('sse', (event: any) => { const isJson = (data: string) => { try { const json = JSON.parse(data) return json && typeof json === 'object' } catch (error: unknown) { return false } } if (isJson(event.data)) { const eventData = JSON.parse(event.data) if (eventData) { const alarmInfo: SSEType = { ...eventData, } // 수신 알림 타입 const getAlarmStatus = (value: E_NOTIFICATION_TYPE) => { return { TRANSACTION: '거래', CHAT: '채팅', }[value] } if (Notification.permission === NOTIFICATION_PERMISSION.허용) { showNotification(getAlarmStatus(alarmInfo.notificationType), alarmInfo.content) toast.success(`${alarmInfo.content}`) } } } }) return () => { SSE.close() } }, [accessToken, setNewAccessToken] ) // SSE 알림 재연결 useEffect(() => { if (isSseReconnect) { subscribe(false, accessToken) setIsSseReconnect(false) } }, [accessToken, subscribe, isSseReconnect]) // 서비스 워커 등록 useEffect(() => { if (isLoggedIn) { const serviceWorkerInit = async () => { const permission = await Notification.requestPermission() if (permission !== NOTIFICATION_PERMISSION.허용) return if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js').then(async () => { try { await subscribe(true, accessToken) } catch (error: unknown) { if (error instanceof AxiosError) { return Promise.reject(error) } } }) } } serviceWorkerInit() } }, [])
 

댓글

guest