앱의 알림함 기능 및 파트너가 유저를 선택하여 예약 메시지를 보내는 기능에 대한 회고입니다.
먼저, 요구사항은 다음과 같았습니다.
요구사항
알림함
- 유저는 앱푸시를 받은 메시지들을 알림함에서 확인할 수 있다.
- 유저는 알림들의 읽음 여부를 확인할 수 있다.
- 앱푸시를 클릭하면 알림함의 해당 메시지로 연결된다.
예약 메시지
- 병원은 유저에게 보낼 앱푸시 메시지를 지정된 시간에 등록할 수 있다.
고민한 것들
알림함 모델의 위치
알림함 모델을 유저와 파트너 중 어디에 위치를 시켜야 할지를 결정하기 위해 모델의 책임을 생각해습니다. 요구사항에 따르면 알림함은 앱푸시 가 될 메시지의 저장, 읽음 여부 체크를 위한 수정을 담당합니다. 이어서 두 컨텍스트를 보면, 파트너는 알림의 작성, 발송 등 알림의 생성과 관리에 대한 주요 행동의 주체이고, 유저는 이를 읽기만 하며 소비할 뿐이죠. 따라서 알림함 모델은 파트너 컨텍스트에 위치시키고, 유저 컨텍스트와 협력은 aws cognito를 통해 유저의 권한을 설정한 api를 제공하여 구현했습니다.
메시지 예약 발송 기능 구현 방법 (AWS EventBridge 선택 이유)
처음에는 배치로직으로 메시지를 발송하는 것을 생각했습니다. 유저들이 메시지 가장 많이 보는 시간대인 출퇴근, 저녁시간, 자기전 등을 선택지로 부여하고, 그 시간대에 배치를 돌며 발송을 하는 것이죠. 그런데 이렇게 되면 시간에 대한 자유도가 떨어지고, 추후 발송해야 할 메시지가 많아졌을 때 페이징처리를 해야 하며, 실패한 지점을 기록하고 다시 실행하는 처리를 해줘야 합니다. 그래서 AWS EventBridge의 Scheduler 서비스를 활용하여 파트너가 원하는 시간대에 에약 메시지를 등록하고, 해당 시간에 메시지를 발송하는 람다 함수를 실행하는 것으로 구현했습니다.
더불어, Scheduler 서비스의 쿼터를 Service Quatas > AWS 서비스 > Amazon EventBridge Scheduler 에서 확인해보니 기본 할당량이 1천만 건으로 현재 수준을 감당하기 충분하다고 판단했습니다.
스케줄러 상태값 관리 (발송 완료 상태 변경)
예약 완료를 처리하는 방법에는 2가지가 있습니다. 1) AWS SNS, SQS 로 비동기로 처리를 하는 방법. 2) schedulerRepository 종속성 주입을 통해 직접 변경하는 방법입니다. 비동기로 처리를 할 경우, 요청 속도와 장애 처리 측면에서 장점을 가지고 있지만 같은 컨텍스트에 있는 두 모델이 종속성 주입을 통해 협력을 할 수 있는 상황에서는 괜히 운영 복잡성과 비용을 높인다고 판단했습니다. 그래서 빠르게 처리할 수 있고, 추후 트랙잭션을 처리하기도 수월한 두 번째 방법을 선택했습니다.
기술스택
- Typescript
- AWS SNS
- AWS EventBridge
- AWS Lambda
도메인
Inbox
인박스 모델의 구조는 단순합니다. 어떤 유저가 어떤 병원의 알림을 받았는지 저장합니다. Sender와 Message는 값객체로 구성하여 발신자와 메시지라는 개념을 반영했습니다.
typeORM과 타입스크립트로는 다음과 같이 구성할 수 있습니다.
// inbox.ts
@Entity({ name: "tb_inbox" })
export class Inbox {
//다른 속성들
@Column(() => InboxSender)
sender: InboxSender;
}
// inbox.sender.ts
export class InboxSender {
@Column({ type: "varchar" })
type: SenderType;
@Column({ type: "json" })
info: SenderInfo;
}
구현하고자 했던 형태는 Sender라는 개념을 표현하되, 발신자의 타입과 정보를 테이블의 컬럼으로 구현하는 것이었습니다. 위처럼 @Column 데코레이터를 활용하면, sender_type, sender_info 라는 이름의 컬럼 테이블을 구성할 수 있습니다.
추가적으로 클래스에 대한 유효성 검증을 하고 싶다면, class-validator 와 class-transformer 라이브러리를 활용할 수 있습니다. 위의 코드에 적용을 해보면 class-transformer에서 @Type 데코레이터를 사용하여 객체를 클래스로 변환하고, class-validator에서 @ValidateNested 데코레이터를 사용하여 클래스 내부에 validation 까지 확인할 수 있습니다. 다양한 옵션들이 있으니 참고해보시면 좋을 것 같고, SenderType의 경우는 enum이었기 때문에 @IsEnum을 사용했습니다.
// inbox.ts
@Entity({ name: "tb_inbox" })
export class Inbox {
//다른 속성들
@Column(() => InboxSender)
@Type(() => InboxSender)
@ValidateNested()
sender: InboxSender;
}
// inbox.sender.ts
export class InboxSender {
@Column({ type: "varchar" })
@IsEnum(SenderType)
type: SenderType;
}
스케줄러
스케줄러 모델도 구성은 간단합니다. 어떤 병원이 어떤 메시지를 언제, 누구에게 보낼지에 대한 정보를 담았습니다. 상태값을 관리해야 하기 때문에, 변경을 위한 public 메서드(complete, cancel)를 구비했습니다.
기능 시퀀스
스케줄러 등록 후 메시지 발송 및 스케줄러 완료 처리
Inbox
알림함에 메시지를 추가하는 기능을 살펴보겠습니다. addInbox 함수가 호출되면 수신자들의 알림함에 저장하고, 푸시 메시지를 발송합니다. 이때, 인박스에 메시지가 저장되는 것이 우선이기 때문에, 푸시 메시지 발송이 실패하더라도 에러를 던지지 않게 처리를 하여 Promise.all을 통한 병렬적인 로직을 처리했습니다.
async addInbox(
inboxDto: InboxCreateDto,
): Promise<void> {
const { userIdList, isFromScheduler } = inboxDto;
await Promise.all(
userIdList.map(async (userId) => {
// inbox 저장
// 푸시 메시지 발송
await this.sendPushNotification(userId, message).catch(error);
}),
);
if (isFromScheduler) {
await this.completeScheduler().catch(error);
}
}
Scheduler
등록한 예약 메시지가 AWS EventBridge에 제대로 등록이 되었는지를 확인해야 하기 때문에 typeorm manager를 활용하여 스케줄러 저장과 EventBridge등록을 트랜잭션으로 묶어줬습니다.
@aws-sdk/client-scheduler 으로 스케줄 등록
등록은 CreateScheduleCommand 커멘드를 사용합니다. 자세한 설명은 공식문서를 참고하시기 바랍니다.
SchedulerService 파일에 이벤트 스케줄 등록과 삭제하는 로직을 구현하고, 이를 app.service 파일에서 사용했습니다.
필수값은 이름, 스케줄 표현(동작 시간), 실행할 타겟의 Arn, 역할 Arn 입니다.
- 만약, 그룹명을 입력할 경우 미리 생성해줘야 합니다. 없는 그룹명을 입력하면 에러가 발생합니다.
- 스케줄 표현은 그 시간에 실행하는 거라면 at(), 일정 주기로 반복적으로 실행한다면 rate()로 표현하면 됩니다. at()에 들어가는 날짜는 ISO 8601 형식을 입력해야 하므로, 날짜를 입력하기 전에 Date.prototype.toISOString() 메서드를 사용해서 변환해주세요.
- RoleArn 은 따로 생성해서 반드시 "역할"의 Arn을 입력해주세요. 혹시 되려나 싶어서 역할에 추가한 AWSLambdaRole 정책의 ARN을 연결해보니 오류가 나더군요.
- Input은 헤더, 바디, 쿼리파람, 패스파람 뭐든 필요한 값을 string으로 입력하시면 됩니다.
export class SchedulerService {
private schedulerClient: SchedulerClient;
constructor() {
this.schedulerClient = new SchedulerClient({ region: process.env.REGION });
}
async sendCreateSchedule(scheduleDto): Promise<void> {
await this.schedulerClient.send(
new CreateScheduleCommand({
Name: "your-name", // 필수값
GroupName: "your-group",
ScheduleExpression: `at(${scheduleDto.date})`, // 필수값
FlexibleTimeWindow: {
Mode: "OFF", // 정확히 지정된 시간에 실행하는 설정
},
Target: {
Arn: your-arn, // 필수값
RoleArn: your-role-arn, // 필수값
Input: JSON.stringify({}), // 문자열로 입력
},
ActionAfterCompletion: "DELETE", // 실행 후 등록된 스케줄 삭제하는 설정
}),
);
}
}
sam template 사용하는 경우
잊지말고, AmazonEventBridgeSchedulerFullAccess 정책을 추가해주세요.
CreateSchedulerFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: create-scheduler
CodeUri: handlers/scheduler/create-scheduler
Policies:
- AWSLambdaRole
- AmazonEventBridgeSchedulerFullAccess
# 기타 설정들
'Insight > 회고' 카테고리의 다른 글
플랫폼 서비스에서 AWS EventBridge를 이용한 자동메시지 기능 구현하기 (0) | 2025.02.11 |
---|---|
[업무회고] RIZZ 2.0 상담 모델 설계 (0) | 2025.01.18 |
[업무회고] RIZZ 3.0 유저의 병원별 추천 코드를 통한 맴버십 관리 (0) | 2025.01.12 |
2년차 끝자락에서의 반성과 다짐 (0) | 2024.10.01 |
12월 개발 스프린트 기술 중점 회고 (0) | 2023.12.18 |