턴업 앱 푸시 개발기 Part1. 예약 및 자동화 푸시

유원근·2023년 12월 4일
1
post-thumbnail

턴업 앱을 코어 기능만 포함하여 배포한뒤 끝 없는 업데이트중에 유저들의 재방문률이 생각보다 높지 않게 나오는 것을 발견하고 우리팀은 유지율을 높이기 위한 스프린트들을 연말까지 하나씩 해결해 보기로 결정하였습니다.

각종 일정과 다른 진행상황들을 고려해 본 결과 이번에는 푸시기능을 추가하기로 결정하게 되었고, 기간은 혼자 백오피스, 앱, 서버 개발을 동시에 진행했기 때문에 3주로 잡고 진행하게 되었습니다. (물론 개발은 1.5주 정도걸려 마무리 했습니다)

스프린트 기록

리액트 네이티브 앱에서 푸시기능을 처음 구현하는 것은 아니지만, 사실 저번 프로덕트에서 푸시를 구현했을 때에는 시간도 그렇고, 엄청난 기능적 완성도가 필요했던 것은 아니었기에, 잘 짜여진 구조를 통해 운영을 해 왔던 것은 아니었습니다.

그래서 이번기회에 제대로 만들어 보자! 라고 다짐했고, 그 개발이 어떻게 진행되었는지 간단하게 정리해 보도록 하겠습니다.

이번 글에서는 서버측에서의 구현을 위주로 다루게 됩니다.

먼저 이번 프로젝트에서 푸시 전송에 대한 요구사항은 다음과 같았습니다.

  • 백오피스에서 푸시 직접 전송.
  • 자동화 푸시
  • 예약 전송
  • 푸시알림 기록 추적

이중 Part.1 에서는 푸시를 전송하는 케이스별로 대응하기 위해 어떻게 효과적인 구조를 만들고, 전송까지 보낼지에 대한 고민의 내용을 다뤄보겠습니다.


1. 푸시 직접 전송

관리자가 푸시를 직접 전송하는 것은 필수적인 기능이었습니다.
예약전송이 없다면 가장 쉬운 작업기도 하기도 합니다.

일단 제가 개발할 때에 목표로 삼았던 완성도는 브레이즈(https://www.braze.com)라는 서비스에서 사용자들에게 푸시를 전송할 때의 경험적 완성도와 편의성을 우리 팀원들도 느낄 수 있도록 구현하고 싶었습니다.
브레이즈

브레이즈에서는 위와 같은 UI를 통해 푸시 알림을 전송하게 되는데, 일단 백오피스를 구현할 때에 중점적으로 생각했던 부분들은 디바이스별 푸시 미리보기, OS별 데이터 통합이었습니다.

디바이스별 푸시 미리보기 같은 경우에는 글자수에 따라서 잘려보이는 내용이나, 이미지를 포함할때에 어떻게 유저들에게 푸시알림이 노출되는지 확인이 필요했기 때문에 구현한 기능인데, 요즘들어 프론트엔드 개발을 진행할 일이 계속 적어지다 보니, 직접 스타일링이라는 비효율 적인 것이 해보고 싶어지는 바람에..? 직접 재미있게 구현을 하게 되었고, 전송 데이터 같은 경우에는 각 OS별로 알림을 비교해가며 분석한 결과 아래와 같은 데이터를 이용하기로 하였습니다.

  • 제목
  • 본문
  • 이미지
  • 큰 아이콘 ( Android Only )

이전의 경험상 일단 데이터의 종류를 줄이는 것이 각 OS별 앱에서 푸시데이터를 전송 받았을 때에 처리를 해주기 쉽기 때문에 요구조건들을 충족시킬 수 있을 정도의 최소한의 데이터만을 이용하기로 하였습니다.
안드로이드의 큰 아이콘 같은 경우에는 필요에 따라 재미있게 활용이 가능한 부분들이 있었기 때문에 추가를 해 두었습니다.

위 데이터만을 이용해서 푸시를 앱에 전송할 수도 있지만, 서버에서 앱에 사전에 정의된 템플릿 데이터를 함께 넣어주는 작업을 진행하는 것이 가능합니다.

완성된 백오피스에서의 푸시센터 모습은 아래와 같습니다.
완성된 백오피스 UI

다만 완성후에 아쉬웠던 부분은 푸시알림 타겟 유저 그룹을 아직 구현하지 못한 것인데, 아직 서비스의 초창기이다 보니 아직 세그멘테이션을 할 때에 기준을 명확하게 생각이 나지 않았기 때문에 다음 업데이트로 미루기로 하였습니다.


2. 자동화 푸시, 예약푸시

자동화 푸시는 사실 원래 3주라는 스프린트 기간에 있어서 저는 생각을 하고 있지는 않았지만, 팀의 규모가 크지 않다보니, 효율적인 운영에 있어 그 필요성이 대두되어서, 빠르고 간단하게 구현하게 되었습니다.
예약 푸시 또한, 위에서 언급한 직접푸시를 보낼 때와 관련된 부분이지만, 초기 개발스펙에는 없었으나, 개발을 할 때에 자동화 푸시와 함께 고민하였던 부분이어서 함께 개발을 진행하게 되었습니다.

먼저 자동화 푸시같은 경우에는 저희 서비스의 다음과 같은 기능을 위해 활용될 예정이었습니다.

  • 유저들이 등록한 키워드 알림을 위한 푸시
  • AI 처리 결과를 안내하기 위한 푸시
  • 재방문률을 높이기 위해 유저별 특정 조건에 의해 보내지게 되는 푸시

위 사항들을 개발하기 위해 자동화 푸시에는 두종류의 트리거가 필요하였습니다.

  • 첫번째는 특정 로직의 시점에서 푸시를 전송하게 되는 트리거
  • 두번째는 특정 주기마다 특정 유저그룹에게 전송하게 되는 트리거

첫번째 특정 로직의 지점에서 직접 발동되는 트리거 같은 경우에는 특정 지점에서 직접 푸시시에 사용되는 로직을 이용해 쉽게 해결이 가능했지만, 두번째 특정 주기마다 실행되어야 하는 트리거는 다음과 같은 문제점들이 있었습니다.

  • 유저별로 발동되어야 하는 변수들이 다르다.
  • 특정 조건들이 주기적으로 체크해 주어야 하는 부분이 있고, 자동화 푸시에 따라 조건들이 너무 상이하다.
  • 조건별로 실행되는 시점들이 다르며, 변동적이다.

위와 같은 이유로 인해 기본적인 스케쥴링을 통해 주기적인 로직실행이 필요했고, 처음에는 node-scheduler을 고려하였지만, 고민끝에 agenda를 선택하게 되었습니다.

Agenda Github - https://github.com/agenda/agenda

선택에 대한 이유는 예약푸시 기능의 구현을 위해서 scheduler의 필요는 이미 결정되어 있었고,
node-scheduler같은 경우에는 이전의 개발시에 여러개의 서버에서 함께 실행되는 경우 한번의 실행을 보장하기가 까다로운 부분이 있으며, 서버가 중간에 중단되게 되는등 이슈에 있어서 신경써줘야 하는 부분들이 많았습니다.
무엇보다, 백오피스에서 자동화 푸시 및 예약을 걸어놓은 푸시에 대한 관리자의 컨트롤이 가능해야 했기 때문에 그 부분에 있어서 강점을 가진 agenda를 선택하게 되었습니다.

Agenda선택 그 이후

agenda는 몽고DB와 연결을 통해 스케쥴링 설정을 DB에 저장하고 실행되는 시점에 DB의 정보를 이용하는 Agenda 인스턴스와 함께 사용할 수 있습니다.
또한 DB를 통해 Sync를 맞추고, 저장된 데이터를 이용하기 때문에 스케쥴을 예약하는 시점에 parameter를 등록해 놓는다면, 실행시점에 그 parameter를 DB에서 꺼내서 로직을 실행시킬 수있습니다.

이 부분이 장점인 이유에 대한 예시를 저희 서비스로 들어보면,

먼저 몽고DB와 연결된 Agenda에 자동화 Job들의 로직을 등록하고, 실행시켜 주는 Agenda 워커용 서버를 하나 실행해 줍니다.
이제부터 그 워커는 몽고DB에 저장된 Job들을 DB에 저장된 시간에 맞춰 실행하여 줄 것입니다.

그 워커에 추가적으로 예약 푸시에 대한 로직을 등록해 줍니다.
예약 푸시 로직은 예약 등록시 함께 입력된 parameter를 이용해 푸시를 전송하는 작업만 진행하게 됩니다.

Agenda는 5초(기본값) 마다 DB에 저장되어 있는 정보를 불러와 조건에 맞는 로직을 실행시켜 줍니다.
그 조건에는 일반적으로 사용되는 CRON을 이용해 반복적으로 실행시킬 수 있으며, 특정 시점을 지정하여 그 시점에만 로직을 실행 할 수도 있습니다.

이렇게 되면 예약되거나 자동화가 걸린 작업들을 실행시켜 주는 서버는 준비가 됩니다.

하지만, 예약을 걸고, 자동화 작업을 켜고 끌 수 있는 서버는 위에서 만든 워커와는 별개로 따로 존재할 수 밖에 없습니다.

그렇기 때문에 백오피스 서버에 위 워커 Agenda와 동일한 몽고DB에 연결된 Agenda 인스턴스를 생성해 parameter와 함께 예약을 걸어두거나, 자동화 로직을 켜고, 끄게 된되는 로직을 실행하게 된다면 같은 DB를 이용하기 때문에, 같은 DB에 연결되어 있는 다른 서버의 Agenda 인스턴스들은 스케쥴링 작업을 동적으로 실행할 수 있으며, 쉽게 제어를 할 수 있게 됩니다.

코드 예시

job을 실행하는 워커서버에 미리 로직을 정의해두고 다른 서버에서 예약을 거는 케이스

// 예약을 거는 서버
  const agenda = getAgenda(); // agenda인스턴스를 불러오는 함수
  const { data, userIds } = payload; // 특정 시간에 로직을 실행할 때에 필요한 변수

  agenda.schedule(특정 시간, 'reservation', { data, userIds }); // 예약
// 같은 reservation이라는 key로 DB에 등록된 로직을 실행하는 서버
  const agenda = getAgenda(); // agenda인스턴스를 불러오는 함수

  await agenda.define('reservation', async (job, done) => { 
    const message = job.attrs.data; // 예약을 거는 시점에 등록한 변수 { data, userIds }
    if (!message) return done();
    // 실행할 로직
    await kafkaProducer.sendPushMessages([message]);
    //종료
    done();
  });

job을 scheduler에 정의된 조건에 따라 주기적으로 워커서버에서 실행하는 케이스

// 로직 정의 함수
const defineProcessor = async (agenda: AppAgenda) => {
  await agenda.define('pop_magazine_weekly', async (job, done) => {
    // 실행할 로직
    done();
  });
};

// 스케쥴 정의 함수
const schedule = async (agenda: AppAgenda) => {
  await agenda.every(
    '0 50 18 ? * 3', // 매주 수요일 18시 50분
    'pop_magazine_weekly',
    {},
    { timezone: 'Asia/Seoul' }
  );
};


(async()=>{
	const agenda = getAgenda();
	await defineProcessor(agenda); // 로직을 정의하고
	await schedule(agenda);        // agenda에 스케쥴 등록
})()

위와 같이 서로 다른 서버에서 정의한 Job에 대해서도 같은 DB에 입력된 데이터를 기반으로 로직을 실행하기 때문에, 다른 서버에서 Schedule을 수정하거나, 정지하는 작업을 진행할 수 있습니다.

이번 글에서는 푸시를 직접 전송하는 로직이 아닌, 전송할 데이터를 생성하는 과정까지만 다루었는데, 글이 너무 길어지는 것 같아서 다음글에서 위 과정에서 생성한 데이터를 어떻게 다루었는지, 설계와 로직에 대한 좀더 깊은 내용을 다뤄보겠습니다.

0개의 댓글