상황
공동구매 서비스 '총대마켓'에는 총대와 여러 참여자가 소통하는 채팅방이 있습니다. 하지만 서비스 초기에 알림 기능을 MVP로 보지 않았기 때문에, 채팅을 전송해도 알림을 수신하지 못하는 상황이었습니다. 직접 총대마켓의 사용자가 되어 거래를 진행해 본 결과, 아래 불편함이 크게 다가왔습니다.
- 즉각적인 소통이 어려워, 거래 날짜와 장소를 빠르게 확정할 수 없다.
- 거래의 진행 상황과 참여 사실을 앱에 접속하지 않는 이상 알기 어렵다.
- 채팅 확인을 위해 불필요하게 앱에 접속하는 상황이 많아지거나, 반대로 앱에 접속하지 않게 된다.
위 불편함을 이유로 푸시알림은 해당 시점에 꼭 필요한 기능이라고 생각했습니다. 하지만 다른 팀원들은 우선순위가 높은 기능이라고 생각하지 않거나, 많은 리소스 투여로 구현 시작을 어려워하는 것으로 보였습니다. 그렇게 혼자 도입을 결심했던 푸시알림 기능 구현 과정을 공유하겠습니다.
협업하는 클라이언트는 안드로이드이며, 백엔드 코드는 자바로 작성하였습니다.
알림 필요 시점
총대마켓 서비스에 알림이 필요한 시점은 아래로 정의하였습니다.
누구에게 | 무엇을 | |
공모 작성 시 * | 작성자를 제외한 모든 사용자 | 공모 제목, 공모 id * |
공모 참여 시 | 총대 | 공모 제목, 참여자 닉네임, 공모 id |
공모 참여 취소 시 | 총대 | 공모 제목, 참여자 닉네임, 공모 id |
채팅 전송 시 | 전송자를 제외한 모든 공모 참여자 | 공모 제목, 전송자, 채팅 내용, 공모 id |
거래 상태 변경 시 | 총대를 제외한 모든 공모 참여자 | 공모 제목, 상태 설명, 공모 id |
* 공모 id는 리다이렉트를 위한 정보로 활용됨
* 공모 작성 시 전송하는 알림의 경우, 앱 출시 초기에 한해 사용자 유입을 위해 전송하기로 결정함
사용 기술
FCM의 Firebase Admin SDK 방식
가장 러닝 커브가 낮고 익숙한 기술인 FCM(Firebase Cloud Messaging)을 채택했습니다. FCM 구현 방식에는 Firebase Admin SDK 방식과 FCM 서버 프로토콜 방식이 존재하며, 편리한 사용을 위해 Firebase Admin SDK 방식을 택했습니다.
Firebase Admin SDK | FCM 서버와 상호작용하여 작업할 수 있는 서버 라이브러리 집합 |
FCM 서버 프로토콜 | REST API로 직접 요청하는 방식 |
FCM 도입 전 알아두면 좋은 개념
FCM 아키텍처
- 메시지 요청 구현 도구: Firebase Admin SDK 혹은 FCM 서버 프로토콜을 지원하는 신뢰 가능한 서버 환경에서 메시지 요청을 구현한다. 총대마켓은 그중 자바를 사용하여 서버를 구현한다.
- FCM 백엔드: 메시지 요청 수락 등의 여러 기능을 수행한다.
- 플랫폼 수준 전송 레이어: 기기 토큰 혹은 주제로 타겟팅된 메시지를 라우팅하고, 메시지를 전송하고, 필요 시 플랫폼별 구성을 적용한다. FCM은 안드로이드, Apple, 웹 플랫폼을 제공한다.
- 사용자 기기의 FCM SDK: 알림을 표시하거나, 앱의 포그라운드/백그라운드 상태 및 관련 애플리케이션 로직에 따라 메시지를 처리한다.
메시지 유형
- Notification
- Data
Notification
- FCM SDK에서 자동으로 알림 처리
- title, body, image 필드로 화면에 실제 표시될 정보 전송 (아래 사진 참고)
> [참고] title, body, image 필드 설명
[참고] title, body, image 필드 설명
{
"message": {
"token": "bk3RNwTe3H0:CI2k_HHwgIpoDKCIZvvDMExUdFQ3P1...",
"notification": {
"title": "두근두근 새로운 공모를 확인해보세요!",
"body": "2천원 곰팡이 제거제 공동구매하실 분"
}
}
}
- [구현] FCM 라이브러리가 제공하는 Notification 타입의 객체를 생성해 Message 객체의 notification 필드 설정
Message.builder()
.setToken(/*token*/)
.setNotification(/*notification*/)
.build();
Data
- 클라이언트에서 알림 처리
- 키-값 형태의 데이터 커스텀하여 전송
{
"message": {
"token": "bk3RNwTe3H0:CI2k_HHwgIpoDKCIZvvDMExUdFQ3P1...",
"data": {
"nickname": "에버",
"address": "서울",
"age": "비밀"
}
}
}
- [구현] key, value 값으로 Message 객체의 Map 형태 필드 data에 엔트리 추가
Message.builder()
.setToken(/*token*/)
.putData(/*key, value*/)
.build();
총대마켓이 선택한 방법
Data 방식 + Notification 형태
{
"message": {
"token": "bk3RNwTe3H0:CI2k_HHwgIpoDKCIZvvDMExUdFQ3P1...",
"data": {
"title": "메시지 제목",
"body": "메시지 본문",
//...
}
}
}
Data 방식으로 메시지를 전송하도록 하되, Notification의 형태를 일부 따르기로 했습니다. Notification 형태인 title, body를 전송하도록 하고, 아래 예시와 같이 추가 데이터가 필요할 경우 data 필드 내부에 추가해 주었습니다.
이유 1: 알림 메시지 수정에 용이
안드로이드와의 협업이었기 때문에 클라이언트의 재배포 속도가 매우 낮습니다. 따라서 사용자에게 표시될 알림을 재배포 속도가 비교적 빠른 서버에서 지정하도록 하였습니다. 따라서 클라이언트가 서버로부터 전달받은 데이터로 메시지를 빌드하는 방식이 아닌, 완성된 메시지를 서버로부터 전달받도록 하였습니다.
이유 2: 클라이언트 측의 유연한 처리
FCM SDK가 자동으로 작업을 처리하는 notification 방식의 경우 편리하다는 장점이 있으나, 세부적인 작업을 조정하기 어려울 것이라는 판단에 클라이언트를 거쳐 처리하는 data 방식을 택했습니다.
전송 방법
- Token
- Tokens
- Topic
- Condition
Token
FCM이 발급한 기기 토큰에 메시지를 전송합니다.
- 기기 토큰은 사용자 기기 별로 발급됨
- 총대마켓의 경우 사용자당 하나의 기기 토큰을 갖도록 설계함
- [구현] setToken 메서드에 기기 토큰을 입력하여 Message 객체의 token 필드 설정
public Message createMessage(FcmToken token, FcmData data) {
return Message.builder()
.setToken(token.getValue())
.putAllData(data.getData())
.build();
}
* 위 코드에서 FcmToken, FcmData는 내가 생성한 클래스이다.
Tokens
FCM이 발급한 기기 토큰 여러 개에 메시지를 전송합니다.
- 최대 500개의 토큰까지 전송 가능
- [구현] addAllTokens 메서드에 기기 토큰의 리스트를 입력하여 MulticastMessage 객체의 tokens 필드에 데이터 추가
public MulticastMessage createMessages(FcmTokens tokens, FcmData data) {
return MulticastMessage.builder()
.addAllTokens(tokens.getTokenValues())
.putAllData(data.getData())
.build();
}
* 위 코드에서 FcmTokens, FcmData는 내가 생성한 클래스이다.
Topic
특정 주제를 구독한 기기 토큰들에게 메시지를 전송합니다.
- [구현] setTopic 메서드에 주제의 이름을 입력하여 Message 객체의 topic 필드 설정
public Message createMessage(FcmTopic topic, FcmData data) {
return Message.builder()
.setTopic(topic.getValue())
.putAllData(data.getData())
.build();
}
* 위 코드에서 FcmTopic, FcmData는 내가 생성한 클래스이다.
Condition
특정 조건을 만족하는 기기 토큰들에게 메시지를 전송합니다.
- [구현] setCondition 메서드에 조건을 입력하여 Message 객체의 condition 필드 설정
public Message createMessage(FcmCondition condition, FcmData data) {
return Message.builder()
.setCondition(condition.getValue())
.putAllData(data.getData())
.build();
}
* 위 코드에서 FcmCondition, FcmData는 내가 생성한 클래스이다.
총대마켓이 선택한 방법
누구에게 | 무엇을 | 어떻게 | |
공모 참여 시 | 총대 | 공모 제목, 참여자 닉네임, 공모 id | token |
공모 참여 취소 시 | 총대 | 공모 제목, 참여자 닉네임, 공모 id | token |
거래 상태 변경 시 | 총대를 제외한 모든 공모 참여자 | 공모 제목, 상태 설명, 공모 id | topic |
채팅 전송 시 | 전송자를 제외한 모든 공모 참여자 | 공모 제목, 전송자, 댓글 내용, 공모 id | tokens |
공모 작성 시 | 작성자를 제외한 모든 사용자 | 공모 제목, 공모 id | condition |
> tokens vs topic 결정 기준
tokens vs topic 결정 기준
- tokens 방식: 알림을 수신할 사용자들의 기기 토큰 리스트에 메시지를 전송하는 방식
- topic 방식: 사전 구독된 주제에 메시지를 전송하는 방식
두 방식을 언제 사용하는 것이 좋을까요? 데이터베이스에 접근할 필요가 없다는 특징 덕분에 topic 방식이 무조건 더 나은 방식이라 판단할 수 있습니다. 하지만 topic 방식은 tokens 방식에 비해 느립니다.
따라서 알림의 즉각적인 수신이 요구되는 '채팅 전송'의 경우는 tokens 방식을, '공모글 작성' 혹은 '공모 참여' 알림 등은 실시간성이 필수적이지 않고 매번 데이터베이스에 접근할 필요가 없는 topic 방식을 채택했습니다.
총대마켓 구현 과정
1. 프로젝트 초기 세팅
2. 테스트용 FCM 기기 토큰 발급
안드로이드 코틀린 코드 작성을 통해 테스트용 기기 토큰을 발급했습니다. 본 토큰은 서버 테스트에서 유용하게 사용했습니다.
import com.google.firebase.messaging.FirebaseMessaging
import android.util.Log
fun getFcmToken() {
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
if (!task.isSuccessful) {
Log.w("FCM", "Fetching FCM registration token failed", task.exception)
return@addOnCompleteListener
}
val token = task.result
Log.d("FCM", "FCM Token: $token")
}
}
FCM 토큰은 기기와 앱에 고유하게 발급되며, 기본적으로 유효기간 없이 유지됩니다.
3. 알림 도메인 설계: Notification
총대마켓 서비스는 데이터베이스에 알림 관련 정보를 저장하지 않습니다. 그 이유는 아래와 같습니다.
- 알림 기능은 중간에 급하게 도입된 기능으로, 빠른 구현이 목적이었다.
- 알림 저장 후 사용 용도가 없다.
- 로그를 확실하게 출력해둠으로써 로그를 통해 알림 전송의 정상 로직을 확인할 수 있다.
주저리: 그러나, 앞으로의 확장성을 위해 전송된 알림 정보를 저장해두는 것이 좋아보입니다. 알림 리스트 조회 화면을 구현하는 등 알림 정보가 필요할 수 있으니까요. 또한 로그는 수집 후 기간 만료시 사라지기 때문에 로그에 의존하는 것 또한 좋지 않아보입니다.
아래는 알림 도메인 설계도입니다. 일부 추상화한 부분이 있는 점 알립니다.
token, tokens, topic, condition의 전송 방법, 그리고 메시지 유형 중 data는 도메인으로 만들어 사용하였습니다. 각 계층은 하위 클래스부터 주요 코드를 기준으로 하나하나 설명하겠습니다.
3-1. FcmConfig
- 역할: FCM 서버 초기화 및 연결
import com.google.auth.oauth2.GoogleCredentials;
import com.google.firebase.FirebaseApp;
import com.google.firebase.FirebaseOptions;
import java.io.IOException;
import java.io.InputStream;
import javax.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
@Slf4j
@Configuration
public class FcmConfig {
@Value("${fcm.secret-key.path}")
private String secretKeyPath;
@PostConstruct
public void initialize() {
if (!FirebaseApp.getApps().isEmpty()) {
log.info("성공적으로 FCM 앱을 실행하였습니다.");
return;
}
try {
InputStream secretKey = this.getClass().getResourceAsStream(secretKeyPath);
FirebaseApp.initializeApp(fcmOptions(secretKey));
log.info("성공적으로 FCM 앱을 초기화하였습니다.");
} catch (IOException e) {
e.printStackTrace();
throw new RuntimeException(e);
}
}
private FirebaseOptions fcmOptions(InputStream secretKey) throws IOException {
return FirebaseOptions.builder()
.setCredentials(GoogleCredentials.fromStream(secretKey))
.build();
}
}
3-2. NotificationSender
- 역할: FCM 서버에 메시지 전송
- Fake 방식으로 test double 하기 위해 NotificationSender 클래스로 추상화 후 FcmNotificationSender 구현
import com.google.firebase.messaging.BatchResponse;
import com.google.firebase.messaging.FirebaseMessaging;
import com.google.firebase.messaging.FirebaseMessagingException;
import com.google.firebase.messaging.Message;
import com.google.firebase.messaging.MulticastMessage;
import com.zzang.chongdae.global.exception.MarketException;
import com.zzang.chongdae.notification.exception.NotificationErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class FcmNotificationSender implements NotificationSender {
@Override
public String send(Message message) {
try {
String response = FirebaseMessaging.getInstance().send(message);
log.info("알림 메시지 전송 성공: {}", response);
return response;
} catch (FirebaseMessagingException e) {
log.error("알림 메시지 전송 실패: {}", e.getMessage());
e.printStackTrace();
throw new MarketException(NotificationErrorCode.CANNOT_SEND_ALARM);
}
}
@Override
public BatchResponse send(MulticastMessage message) {
try {
BatchResponse response = FirebaseMessaging.getInstance().sendEachForMulticast(message);
log.info("알림 메시지 전송 성공 개수: {}", response.getSuccessCount());
log.info("알림 메시지 전송 실패 개수: {}", response.getFailureCount());
return response;
} catch (FirebaseMessagingException | IllegalArgumentException e) {
log.error("알림 메시지 전송 실패: {}", e.getMessage());
e.printStackTrace();
throw new MarketException(NotificationErrorCode.CANNOT_SEND_ALARM);
}
}
}
3-3. MessageSubscriber
- 역할: 주제(topic) 구독
- Fake 방식으로 test double 하기 위해 NotificationSubscriber 클래스로 추상화 후 FcmNotificationSubscriber 구현
import com.google.firebase.messaging.FirebaseMessaging;
import com.google.firebase.messaging.FirebaseMessagingException;
import com.google.firebase.messaging.TopicManagementResponse;
import com.zzang.chongdae.global.exception.MarketException;
import com.zzang.chongdae.member.repository.entity.MemberEntity;
import com.zzang.chongdae.notification.domain.FcmTopic;
import com.zzang.chongdae.notification.exception.NotificationErrorCode;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Slf4j
@Service
public class FcmNotificationSubscriber implements NotificationSubscriber {
@Override
public TopicManagementResponse subscribe(MemberEntity member, FcmTopic topic) {
try {
TopicManagementResponse response = FirebaseMessaging.getInstance()
.subscribeToTopic(List.of(member.getFcmToken()), topic.getValue());
log.info("구독 성공 개수: {}", response.getSuccessCount());
log.info("구독 실패 개수: {}", response.getFailureCount());
return response;
} catch (FirebaseMessagingException | IllegalArgumentException e) {
log.error("토픽 구독 실패: {}", e.getMessage());
e.printStackTrace();
throw new MarketException(NotificationErrorCode.CANNOT_SEND_ALARM);
}
}
@Override
public TopicManagementResponse unsubscribe(MemberEntity member, FcmTopic topic) {
try {
TopicManagementResponse response = FirebaseMessaging.getInstance()
.unsubscribeFromTopic(List.of(member.getFcmToken()), topic.getValue());
log.info("구독 취소 성공 개수: {}", response.getSuccessCount());
log.info("구독 취소 실패 개수: {}", response.getFailureCount());
return response;
} catch (FirebaseMessagingException | IllegalArgumentException e) {
log.error("토픽 구독 실패: {}", e.getMessage());
e.printStackTrace();
throw new MarketException(NotificationErrorCode.CANNOT_SEND_ALARM);
}
}
}
3-4. MessageCreator
- 역할: 메시지 생성
- 전송 방법(token, tokens, topic, condition)에 따라 오버로딩으로 메서드 정의
import com.google.firebase.messaging.Message;
import com.google.firebase.messaging.MulticastMessage;
import com.zzang.chongdae.notification.domain.FcmCondition;
import com.zzang.chongdae.notification.domain.FcmData;
import com.zzang.chongdae.notification.domain.FcmToken;
import com.zzang.chongdae.notification.domain.FcmTokens;
import com.zzang.chongdae.notification.domain.FcmTopic;
import org.springframework.stereotype.Component;
@Component
public class FcmMessageCreator {
public Message createMessage(FcmToken token, FcmData data) {
return Message.builder()
.setToken(token.getValue())
.putAllData(data.getData())
.build();
}
public Message createMessage(FcmCondition condition, FcmData data) {
return Message.builder()
.setCondition(condition.getValue())
.putAllData(data.getData())
.build();
}
public Message createMessage(FcmTopic topic, FcmData data) {
return Message.builder()
.setTopic(topic.getValue())
.putAllData(data.getData())
.build();
}
public MulticastMessage createMessages(FcmTokens tokens, FcmData data) {
return MulticastMessage.builder()
.addAllTokens(tokens.getTokenValues())
.putAllData(data.getData())
.build();
}
}
3-5. MessageManager
- 역할: 기능별 메시지 정보 관리
- 각 기능에 따라 어떤 데이터를 전송하고 어떻게 전송할지 등의 세부적인 정보 관리
- 아래 예시는 공모글 작성 시 전송되는 메시지를 관리하는 클래스
import com.google.firebase.messaging.Message;
import com.zzang.chongdae.notification.domain.FcmCondition;
import com.zzang.chongdae.notification.domain.FcmData;
import com.zzang.chongdae.offering.repository.entity.OfferingEntity;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@RequiredArgsConstructor
@Component
public class OfferingMessageManager {
private static final String MESSAGE_TITLE = "두근두근 새로운 공모를 확인해보세요!";
private static final String MESSAGE_TYPE = "offering_detail";
private final FcmMessageCreator messageCreator;
public Message messageWhenSaveOffering(OfferingEntity offering) {
FcmCondition condition = FcmCondition.offeringCondition(offering);
FcmData data = new FcmData();
data.addData("title", MESSAGE_TITLE);
data.addData("body", offering.getTitle());
data.addData("offering_id", offering.getId());
data.addData("type", MESSAGE_TYPE);
return messageCreator.createMessage(condition, data);
}
}
3-6. NotificationService
- 역할: 알림 관련 로직 처리
- 기존 로직과 알림 로직 분리를 위해, 기존 비즈니스 로직에서는 알림 서비스의 메서드만 호출하도록 함
import com.google.firebase.messaging.Message;
import com.zzang.chongdae.notification.domain.FcmTopic;
import com.zzang.chongdae.notification.service.message.CommentMessageManager;
import com.zzang.chongdae.notification.service.message.OfferingMessageManager;
import com.zzang.chongdae.notification.service.message.ParticipationMessageManager;
import com.zzang.chongdae.notification.service.message.RoomStatusMessageManager;
import com.zzang.chongdae.offering.repository.entity.OfferingEntity;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Slf4j
@RequiredArgsConstructor
@Service
public class FcmNotificationService {
private final NotificationSender notificationSender;
private final NotificationSubscriber notificationSubscriber;
private final OfferingMessageManager offeringMessageManager;
public void saveOffering(OfferingEntity offering) {
Message message = offeringMessageManager.messageWhenSaveOffering(offering);
FcmTopic topic = FcmTopic.proposerTopic(offering);
notificationSubscriber.subscribe(offering.getMember(), topic);
notificationSender.send(message);
}
public void deleteOffering(OfferingEntity offering) {
FcmTopic topic = FcmTopic.proposerTopic(offering);
notificationSubscriber.unsubscribe(offering.getMember(), topic);
}
// ...
}
@RequiredArgsConstructor
@Service
public class OfferingService { // 비즈니스 로직
private final FcmNotificationService notificationService;
private final OfferingRepository offeringRepository;
private final OfferingMemberRepository offeringMemberRepository;
public Long saveOffering(OfferingSaveRequest request, MemberEntity member) {
OfferingEntity offering = request.toEntity(member);
validateMeetingDate(offering.getMeetingDate());
OfferingEntity savedOffering = offeringRepository.save(offering);
OfferingMemberEntity offeringMember = new OfferingMemberEntity(member, offering, OfferingMemberRole.PROPOSER);
offeringMemberRepository.save(offeringMember);
notificationService.saveOffering(savedOffering); // 여기서 호출하면 되도록
return savedOffering.getId();
}
}
4. QA & 트러블슈팅
1) 배포 시 JSON 파일 인식하지 못하는 문제
[상황] dev 서버 CI/CD 스크립트 실행시, JSON 파일인 시크릿키를 인식하지 못함
echo "${{ secrets.FCM_SECRET_KEY }}" > src/main/resources/fcm/chongdaemarket-fcm-key.json
[원인] echo 명령어에서 사용하는 큰따옴표와 JSON 파일 내부에서 사용되는 큰따옴표의 혼돈
[해결] echo 명령어에서 사용되는 문자 큰따옴표를 작은따옴표로 변경
echo '${{ secrets.FCM_SECRET_KEY }}' > src/main/resources/fcm/chongdaemarket-fcm-key.json
2) 동일한 테스트 환경 구축하기
[상황] dev 서버 배포 시 테스트 실패
[원인] 테스트 코드에서 사용되는 시크릿키가 정의되지 않음
[해결] test 하위 resources 패키지에도 시크릿키 파일 삽입
3) 로컬 환경 작업 침투하지 않도록
[상황] 로컬 환경에서 작업한 FCM 기기 토큰, 토픽 등이 dev 환경에서도 유효하게 동작
[원인] 동일한 FCM 서버 사용
[해결] 세 환경의 FCM 서버 분리
결과
누구에게 | 무엇을 | |
1. 공모 작성 시 | 작성자를 제외한 모든 사용자 | 공모 제목, 공모 id |
2. 공모 참여 시 | 총대 | 공모 제목, 참여자 닉네임, 공모 id |
3. 공모 참여 취소 시 | 총대 | 공모 제목, 참여자 닉네임, 공모 id |
4. 채팅 전송 시 | 전송자를 제외한 모든 공모 참여자 | 공모 제목, 전송자, 채팅 내용, 공모 id |
5. 거래 상태 변경 시 | 총대를 제외한 모든 공모 참여자 | 공모 제목, 상태 설명, 공모 id |
위 시점에 대해 아래와 같이 알림이 정상적으로 오는 것을 확인할 수 있습니다 :)
1. 글 작성 시 모든 사용자에게 알림 전송
2. 공모 참여 시 총대에게 알림 전송
3. 공모 참여 취소 시 총대에게 알림 전송
4. 채팅 전송 시 알림 전송
5. 거래 상태 변경 시 참여자에게 알림 전송
참고
'⛳️ 공동구매 서비스 총대마켓' 카테고리의 다른 글
동시성 이슈 해결 과정: 트랜잭션 격리 수준, 낙관적 잠금, 비관적 잠금 (1) | 2024.12.18 |
---|---|
FCM 푸시알림 트러블슈팅: 비동기 처리로 동시성 이슈 방지하기 (0) | 2024.11.25 |
쿼리 최적화와 인덱스로 API Latency 30배 개선하기 (3) | 2024.11.15 |
개발 환경 CI/CD 파이프라인 구축기 | Github Actions, Self-hosted Runner, Docker 기술 선택 이유 (0) | 2024.08.31 |
OG 메타 태그 크롤링하여 이미지 추출하기 | Jsoup 구현 (1) | 2024.07.31 |