Skip to content

feat: 알림 기능을 적용한다 #41

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 66 commits into from
Jul 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
29740e9
chore: Firebase 의존성 등록
devholic22 Jul 6, 2024
128e3a6
chore: Firebase 설정 파일 등록
devholic22 Jul 6, 2024
734f7f9
feat: 알림 도메인 기초 설계
devholic22 Jul 6, 2024
40b31de
feat: 알림 전송 기능 구현
devholic22 Jul 6, 2024
cec714f
chore: Firebase key gitignore 등록
devholic22 Jul 6, 2024
47f863a
refactor: body NotEmpty 제약사항 제거
devholic22 Jul 11, 2024
830072b
feat: 알림에 AlertGroup 정보 추가
devholic22 Jul 11, 2024
de9e5e5
feat: 알림에 발신자 정보 추가
devholic22 Jul 11, 2024
d469b7b
feat: 알림 이벤트 핸들러 추가
devholic22 Jul 11, 2024
4e64cc9
feat: 알림 데이터베이스 저장 추가
devholic22 Jul 11, 2024
6b507f1
refactor: Soft delete를 적용하기 위해 상속자 변경
devholic22 Jul 11, 2024
52911c4
feat: 60일 초과된 알림은 soft delete되도록 처리
devholic22 Jul 11, 2024
ad4ac4c
feat: 알림 생성 시각 추가
devholic22 Jul 11, 2024
7c37d58
refactor: Alert 속성 변경
devholic22 Jul 11, 2024
c94d9b2
Merge remote-tracking branch 'origin/develop' into feat/34
devholic22 Jul 11, 2024
1aa3be9
feat: 레디스를 이용한 FCM 토큰 관리 기능 구현
devholic22 Jul 11, 2024
b6a4bf2
refactor: 알림 필드 수정
devholic22 Jul 11, 2024
0dce06c
refactor: 토큰 조회 시 유효성 검사 진행
devholic22 Jul 11, 2024
d8c1329
feat: 알림 조회 기능 개발
devholic22 Jul 12, 2024
53f104c
test: 알림 도메인 테스트 작성
devholic22 Jul 12, 2024
432ab79
test: AlertJdbcRepository 테스트 작성
devholic22 Jul 12, 2024
5219631
test: AlertJpaRepository 테스트 작성
devholic22 Jul 12, 2024
2de8813
test: AlertQueryRepository 테스트 작성
devholic22 Jul 12, 2024
9a8cc59
refactor: 파일 이름 변경
devholic22 Jul 12, 2024
dd82456
test: FakeAlertTokenRepository 작성
devholic22 Jul 12, 2024
56b3ad5
test: FakeAlertRepository 작성
devholic22 Jul 12, 2024
3a21613
test: AlertService 테스트 작성
devholic22 Jul 12, 2024
5fa61fd
fix: 알림 정렬 기준 문제 수정
devholic22 Jul 12, 2024
02d6b83
test: AlertQueryService 테스트 작성
devholic22 Jul 12, 2024
7aa4f70
fix: FirebaseApp 중복 문제 해결
devholic22 Jul 12, 2024
7afc271
test: 단일 알림 조회 테스트 수정
devholic22 Jul 12, 2024
88e5e52
test: AlertControllerAcceptance 테스트 작성
devholic22 Jul 12, 2024
550e70b
refactor: 회원 로그인 시 이벤트 발생 검증 추가
devholic22 Jul 12, 2024
196049b
refactor: 알림 단일 조회 반환값 변경
devholic22 Jul 12, 2024
ac16c6d
refactor: 알림 조회 테스트 정렬 기준 교정
devholic22 Jul 12, 2024
5090251
refactor: 필요없는 인자 제거
devholic22 Jul 12, 2024
796239e
refactor: 파이어베이스 키 조회 방식 수정
devholic22 Jul 12, 2024
0040234
fix: 파이어베이스 키 조회 방식 수정
devholic22 Jul 12, 2024
f183748
refactor: Fake 객체 이름 변경
devholic22 Jul 12, 2024
f92ecbc
docs: 의미없는 gitignore 삭제
devholic22 Jul 12, 2024
f103ac6
refactor: FirebaseFileNotFoundException 삭제
devholic22 Jul 12, 2024
833df6b
refactor: final 추가
devholic22 Jul 13, 2024
3ed2562
refactor: AllArgsConstructor 추가
devholic22 Jul 13, 2024
5d1910f
refactor: 어노테이션 순서 통일
devholic22 Jul 13, 2024
fc577e8
refactor: AlertManager 패키지 위치 수정
devholic22 Jul 13, 2024
4c8cca2
refactor: Alert 생성 시 빌더 패턴 적용
devholic22 Jul 13, 2024
64040f3
refactor: AlertService orElseThrow 축약
devholic22 Jul 13, 2024
22f27bf
chore: Firebase 라이브러리 버전 업그레이드
devholic22 Jul 13, 2024
976ea50
refactor: 알림의 특성을 고려하여 트랜잭션 분리 적용
devholic22 Jul 13, 2024
734c23c
fix: AlertMessage Equals&HashCode 추가
devholic22 Jul 13, 2024
4ea7046
fix: AuditingHandler 추가 방식 이용
devholic22 Jul 13, 2024
24d5187
fix: LocalDateTime 비교 제외 진행
devholic22 Jul 13, 2024
808f8c3
refactor: AuditingHandler 제거
devholic22 Jul 13, 2024
0f59e98
refactor: AlertGroup Enumerated 수정
devholic22 Jul 14, 2024
e045ed1
Merge branch 'develop' into feat/34
devholic22 Jul 18, 2024
5dec65f
Merge branch 'develop' into feat/34
devholic22 Jul 18, 2024
dab7f33
fix: import 교체
devholic22 Jul 18, 2024
72806c7
feat: 알림 삭제 스케줄러 분산 락 적용
devholic22 Jul 18, 2024
6f1feb3
Merge branch 'feat/34' of https://github.com/sosow0212/atwoz into fea…
devholic22 Jul 18, 2024
41335c0
refactor: Alert 인덱싱 적용
devholic22 Jul 20, 2024
69697dd
refactor: 비동기 적용 방식 수정
devholic22 Jul 22, 2024
a6bcbcc
refactor: QueryService abstract class 생성
devholic22 Jul 24, 2024
fd105a0
refactor: RedissonLock AOP 생성
devholic22 Jul 24, 2024
cdd5116
refactor: 비동기 예외 핸들러 등록
devholic22 Jul 25, 2024
042b521
refactor: Alert 생성 과정 수정
devholic22 Jul 28, 2024
4f7a9ac
refactor: instanceof 표현 삭제
devholic22 Jul 29, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ dependencies {
runtimeOnly 'com.h2database:h2'
runtimeOnly 'com.mysql:mysql-connector-j'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.redisson:redisson-spring-boot-starter:3.33.0'

// flyway
implementation 'org.flywaydb:flyway-core'
Expand Down Expand Up @@ -78,6 +79,9 @@ dependencies {
// actuator, prometheus
runtimeOnly 'io.micrometer:micrometer-registry-prometheus'
implementation 'org.springframework.boot:spring-boot-starter-actuator'

// Firebase
implementation 'com.google.firebase:firebase-admin:9.3.0'
}

def generated = 'src/main/generated'
Expand Down
28 changes: 28 additions & 0 deletions src/docs/asciidoc/alert.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
:toc: left
:source-highlighter: highlightjs
:sectlinks:
:toclevels: 2
:sectlinks:
:sectnums:

== Alert

=== 알림 페이징 조회 (GET api/members/me/alerts)
==== 요청
include::{snippets}/alert-controller-web-mvc-test/받은_알림_페이징조회/request-headers.adoc[]
include::{snippets}/alert-controller-web-mvc-test/받은_알림_페이징조회/request-parts.adoc[]
include::{snippets}/alert-controller-web-mvc-test/받은_알림_페이징조회/http-request.adoc[]
==== 응답
include::{snippets}/alert-controller-web-mvc-test/받은_알림_페이징조회/response-fields.adoc[]
include::{snippets}/alert-controller-web-mvc-test/받은_알림_페이징조회/http-response.adoc[]

=== 알림 단일 조회 (GET api/members/me/alerts/{alertId})
읽지 않았던 알림일 경우 읽음 처리됨

==== 요청
include::{snippets}/alert-controller-web-mvc-test/단일_알림_조회/request-headers.adoc[]
include::{snippets}/alert-controller-web-mvc-test/단일_알림_조회/path-parameters.adoc[]
include::{snippets}/alert-controller-web-mvc-test/단일_알림_조회/http-request.adoc[]
==== 응답
include::{snippets}/alert-controller-web-mvc-test/단일_알림_조회/response-fields.adoc[]
include::{snippets}/alert-controller-web-mvc-test/단일_알림_조회/http-response.adoc[]
1 change: 1 addition & 0 deletions src/docs/asciidoc/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@
* link:membersurveys.html[회원 연애고사 API]
* link:report.html[회원 신고 API]
* link:likes.html[호감 API]
* link:alert.html[알림 API]
2 changes: 0 additions & 2 deletions src/main/java/com/atwoz/AtwozApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,8 @@
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.scheduling.annotation.EnableAsync;

@EnableJpaAuditing
@EnableAsync
@SpringBootApplication
@ConfigurationPropertiesScan
public class AtwozApplication {
Expand Down
28 changes: 28 additions & 0 deletions src/main/java/com/atwoz/alert/application/AlertEventHandler.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.atwoz.alert.application;

import com.atwoz.alert.application.event.AlertCreatedEvent;
import com.atwoz.alert.application.event.AlertTokenCreatedEvent;
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionalEventListener;

@RequiredArgsConstructor
@Component
public class AlertEventHandler {

private static final String ASYNC_EXECUTOR = "asyncExecutor";

private final AlertService alertService;

@Async(value = ASYNC_EXECUTOR)
@TransactionalEventListener
public void sendAlertCreatedEvent(final AlertCreatedEvent event) {
alertService.sendAlert(event.group(), event.title(), event.body(), event.sender(), event.receiverId());
}

@TransactionalEventListener
public void sendAlertTokenCreatedEvent(final AlertTokenCreatedEvent event) {
alertService.saveToken(event.id(), event.token());
}
}
8 changes: 8 additions & 0 deletions src/main/java/com/atwoz/alert/application/AlertManager.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.atwoz.alert.application;

import com.atwoz.alert.domain.Alert;

public interface AlertManager {

void send(Alert alert, String sender, String token);
}
25 changes: 25 additions & 0 deletions src/main/java/com/atwoz/alert/application/AlertQueryService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.atwoz.alert.application;

import com.atwoz.alert.domain.AlertRepository;
import com.atwoz.alert.infrastructure.dto.AlertPagingResponse;
import com.atwoz.alert.infrastructure.dto.AlertSearchResponse;
import com.atwoz.global.application.BaseQueryService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class AlertQueryService extends BaseQueryService<AlertSearchResponse> {

private final AlertRepository alertRepository;

public AlertPagingResponse findMemberAlertsWithPaging(final Long memberId, final Pageable pageable) {
Page<AlertSearchResponse> response = alertRepository.findMemberAlertsWithPaging(memberId, pageable);
int nextPage = getNextPage(pageable.getPageNumber(), response);
return AlertPagingResponse.of(response, nextPage);
}
}
6 changes: 6 additions & 0 deletions src/main/java/com/atwoz/alert/application/AlertScheduler.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.atwoz.alert.application;

public interface AlertScheduler {

void deleteExpiredAlerts();
}
38 changes: 38 additions & 0 deletions src/main/java/com/atwoz/alert/application/AlertService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.atwoz.alert.application;

import com.atwoz.alert.domain.Alert;
import com.atwoz.alert.domain.AlertRepository;
import com.atwoz.alert.domain.AlertTokenRepository;
import com.atwoz.alert.domain.vo.AlertGroup;
import com.atwoz.alert.exception.exceptions.AlertNotFoundException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@RequiredArgsConstructor
@Transactional
@Service
public class AlertService {

private final AlertRepository alertRepository;
private final AlertTokenRepository tokenRepository;
private final AlertManager alertManager;

public void saveToken(final Long id, final String token) {
tokenRepository.saveToken(id, token);
}

public void sendAlert(final AlertGroup group, final String title, final String body, final String sender, final Long receiverId) {
String token = tokenRepository.getToken(receiverId);
Alert alert = Alert.createWith(group, title, body, receiverId);
Alert savedAlert = alertRepository.save(alert);
alertManager.send(savedAlert, sender, token);
}

public Alert readAlert(final Long memberId, final Long id) {
Alert alert = alertRepository.findByMemberIdAndId(memberId, id)
.orElseThrow(AlertNotFoundException::new);
alert.read();
return alert;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.atwoz.alert.application.event;

import com.atwoz.alert.domain.vo.AlertGroup;

public record AlertCreatedEvent(
AlertGroup group,
String title,
String body,
String sender,
Long receiverId
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.atwoz.alert.application.event;

public record AlertTokenCreatedEvent(
Long id,
String token
) {
}
120 changes: 120 additions & 0 deletions src/main/java/com/atwoz/alert/config/FirebaseConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package com.atwoz.alert.config;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.auth.oauth2.GoogleCredentials;
import com.google.firebase.FirebaseApp;
import com.google.firebase.FirebaseOptions;
import com.google.firebase.ThreadManager;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;

@Configuration
public class FirebaseConfig {

private static final String TYPE = "type";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오우 상수화랑 밸류 담느라 고생하셨네요 ㅠ

private static final String PROJECT_ID = "project_id";
private static final String PRIVATE_KEY_ID = "private_key_id";
private static final String PRIVATE_KEY = "private_key";
private static final String CLIENT_EMAIL = "client_email";
private static final String CLIENT_ID = "client_id";
private static final String AUTH_URI = "auth_uri";
private static final String TOKEN_URI = "token_uri";
private static final String AUTH_CERT = "auth_provider_x509_cert_url";
private static final String CLIENT_CERT = "client_x509_cert_url";
private static final String UNIVERSE_DOMAIN = "universe_domain";
private static final String REPLACE_TARGET_MARK = "\\\\n";
private static final String REPLACE_VALUE_MARK = "\n";

@Value("${firebase.type}")
private String type;

@Value("${firebase.project_id}")
private String projectId;

@Value("${firebase.private_key_id}")
private String privateKeyId;

@Value("${firebase.private_key}")
private String privateKey;

@Value("${firebase.client_email}")
private String clientEmail;

@Value("${firebase.client_id}")
private String clientId;

@Value("${firebase.auth_uri}")
private String authUri;

@Value("${firebase.token_uri}")
private String tokenUri;

@Value("${firebase.auth_provider_x509_cert_url}")
private String authCert;

@Value("${firebase.client_x509_cert_url}")
private String clientCert;

@Value("${firebase.universe_domain}")
private String universeDomain;

@Bean
public FirebaseApp firebaseApp() {
if (!FirebaseApp.getApps().isEmpty()) {
return FirebaseApp.getInstance();
}
ThreadManager threadManager = new FirebaseThreadManager();
FirebaseOptions options = new FirebaseOptions.Builder()
.setThreadManager(threadManager)
.setCredentials(getCredentials())
.build();
return FirebaseApp.initializeApp(options);
}

private GoogleCredentials getCredentials() {
try {
String jsonContent = generateJsonContent();
InputStream inputStream = new ByteArrayInputStream(jsonContent.getBytes());
return GoogleCredentials.fromStream(inputStream);
} catch (IOException e) {
return GoogleCredentials.newBuilder()
.build();
}
}

private String generateJsonContent() throws JsonProcessingException {
ObjectMapper mapper = new ObjectMapper();
Map<String, String> firebaseConfig = generateFirebaseConfig();
firebaseConfig.put(PRIVATE_KEY, replaceNewLines(privateKey));

return mapper.writeValueAsString(firebaseConfig);
}

private Map<String, String> generateFirebaseConfig() {
Map<String, String> firebaseConfig = new HashMap<>();
firebaseConfig.put(TYPE, type);
firebaseConfig.put(PROJECT_ID, projectId);
firebaseConfig.put(PRIVATE_KEY_ID, privateKeyId);
firebaseConfig.put(CLIENT_EMAIL, clientEmail);
firebaseConfig.put(CLIENT_ID, clientId);
firebaseConfig.put(AUTH_URI, authUri);
firebaseConfig.put(TOKEN_URI, tokenUri);
firebaseConfig.put(AUTH_CERT, authCert);
firebaseConfig.put(CLIENT_CERT, clientCert);
firebaseConfig.put(UNIVERSE_DOMAIN, universeDomain);

return firebaseConfig;
}

private String replaceNewLines(String value) {
return value.replaceAll(REPLACE_TARGET_MARK, REPLACE_VALUE_MARK);
}
}
30 changes: 30 additions & 0 deletions src/main/java/com/atwoz/alert/config/FirebaseThreadManager.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.atwoz.alert.config;

import com.google.firebase.FirebaseApp;
import com.google.firebase.ThreadManager;
import org.springframework.stereotype.Component;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;

@Component
public class FirebaseThreadManager extends ThreadManager {

private static final int FIREBASE_THREADS_SIZE = 40;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

쓰레드 40개로 선정하신 이유가 궁금합니다!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

사실 이 부분은 스레드를 얼마까지 정해뒀을 때 수요를 버틸 수 있을지 정확히 예측하기 힘들다고 판단하여 다른 글들을 참고해서 보수적으로 적용했었습니다. 실제 배포 후 모니터링한 뒤 더 늘릴 수 있다면 이후에 늘려보고 싶습니다.


@Override
protected ExecutorService getExecutor(final FirebaseApp firebaseApp) {
return Executors.newFixedThreadPool(FIREBASE_THREADS_SIZE);
}
Comment on lines +16 to +19
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

newFixedThreadPool로 쓰레드를 설정해주셨는데요.
만약에 40개의 코어 쓰레드가 모두 사용중이라면 그 이후에 오는 요청은 어떻게 되고 최대 몇 개까지 기다릴 수 있을까요?
그리고 만약 초과가 됐다면 이 경우 대기중인 요청은 어떻게 되나요?

Copy link
Collaborator Author

@devholic22 devholic22 Jul 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • 우선 알림을 보낼 때 영향이 끼쳐지는 스레드는 FirebaseThreadManager에서 관리하는 스레드가 아닌, AsyncConfig에서 관리하는 스레드입니다. (AsyncConfig의 getAsyncExecutor 메서드, 스레드 이름을 로그로 남겨보면서 확인하였습니다.)

스크린샷 2024-07-28 오후 5 17 01

  • 지정한 MAX_THREAD_SIZE + QUEUE_SIZE개 만큼의 수요 (지금은 MAX_THREAD_SIZE 40, QUEUE_SIZE 100이므로 140개 가능)를 감당한 뒤 나머지 처리에 대해서는 ThreadPoolExecutor에서 전략을 설정할 수 있는데, 우선은 이벤트를 호출한 스레드 (즉, 스프링 메인 스레드)에서 나머지를 보내도록 설정하였습니다. (ThreadPoolExecutor.CallerRunsPolicy 전략)
    • AbortPolicy: TaskRejectedException이 발생되고 요청이 무시됩니다.
    • CallerRunsPolicy: 처리되지 못한 요청을 ThreadPool을 호출한 Thread에서 처리합니다.
    • DiscardPolicy: 처리되지 못한 요청을 무시합니다.
    • DiscardOldestPolicy: 가장 오래된 요청을 삭제하고 다시 execute를 실행합니다.
    • 이에 따라, 알림을 보내야 하는 상황에서 무시하거나 예외를 발생시키기보다는 메인 스레드를 이용해서라도 진행시켜야 하는 게 맞겠다는 생각이 들어 CallerRunsPolicy를 선택하였습니다.
  • 그럼에도 FirebaseThreadManager에서 스레드를 따로 지정한 이유는 기본적으로 파이어베이스가 요청이 n개 들어온다면 n개만큼의 스레드를 만들기 때문에, 극단적인 예로 10,000개의 알림을 보내야 한다면 10,000개의 스레드를 만들게 되고 이로 인해 OutOfMemory 현상이 발생할 수 있음을 확인하였어서 지정했었습니다.


@Override
protected void releaseExecutor(final FirebaseApp firebaseApp, final ExecutorService executorService) {
executorService.shutdownNow();
}

@Override
protected ThreadFactory getThreadFactory() {
return Executors.defaultThreadFactory();
}
}
Loading
Loading