Skip to content

Commit d171e79

Browse files
authored
feat: 알림 기능을 적용한다 (#41)
* chore: Firebase 의존성 등록 * chore: Firebase 설정 파일 등록 - Firebase 설정 파일 환경 등록 * feat: 알림 도메인 기초 설계 - 알림 도메인 기초 설계 작성 - 관련 예외 핸들러 등록 * feat: 알림 전송 기능 구현 - 알림 전송 기능에 대한 서비스, 구현체 작성 * chore: Firebase key gitignore 등록 - 보안을 위해 Firebase key gitignore에 등록 * refactor: body NotEmpty 제약사항 제거 * feat: 알림에 AlertGroup 정보 추가 - 알림에 AlertGroup 정보 추가 - AlertManager 패키지 위치 수정 * feat: 알림에 발신자 정보 추가 - 알림에 발신자 정보 추가 (닉네임) - 보낸 발신자가 없을 경우 시스템이 보낸 것으로 간주 * feat: 알림 이벤트 핸들러 추가 - 알림 이벤트 핸들러 추가 - CreateAlertRequest 삭제 대신 이벤트 인자로 변경 * feat: 알림 데이터베이스 저장 추가 - 보낸 알림과 데이터베이스 연결 작성 * refactor: Soft delete를 적용하기 위해 상속자 변경 - Soft delete를 적용하기 위해 SoftDeleteBaseEntity를 상속받도록 수정 * feat: 60일 초과된 알림은 soft delete되도록 처리 * feat: 알림 생성 시각 추가 - 알림 생성 시각 추가 * refactor: Alert 속성 변경 - 알림에 쓰인 토큰을 관리하지 않도록 제거 - 알림의 대상이 되는 회원의 ID가 연결되도록 수정 * feat: 레디스를 이용한 FCM 토큰 관리 기능 구현 - 로그인 시 토큰을 저장하도록 설정 - 토큰 관리 리포지터리 정의 * refactor: 알림 필드 수정 - senderId 대신 receiverId를 관리하도록 수정 - 토큰을 직접 받기보다 서비스 내부에서 가져오도록 수정 * refactor: 토큰 조회 시 유효성 검사 진행 - 토큰 조회 시 유효성 검사 진행 * feat: 알림 조회 기능 개발 - 알림 조회 기능 개발 (페이징, 단일) - 알림 단일 조회 시 read 작업 수행되도록 설정 * test: 알림 도메인 테스트 작성 - 알림 도메인 단위테스트 작성 * test: AlertJdbcRepository 테스트 작성 - AlertJdbcRepository 테스트 작성 - 관련 Fixture 생성, 값을 작성하기 위해 빌더 패턴 추가 * test: AlertJpaRepository 테스트 작성 - AlertJpaRepository 테스트 작성 - 관련 Fixture 생성 * test: AlertQueryRepository 테스트 작성 - AlertQueryRepository 테스트 작성 - 페이징 조회 타입을 QueryResults로 변경 - AlertSearchResponse 반환 타입 변경 - 관련 Fixture 등록 * refactor: 파일 이름 변경 - 레디스 의존성을 드러내기 위해 파일 이름 변경 * test: FakeAlertTokenRepository 작성 - FakeAlertTokenRepository 작성 * test: FakeAlertRepository 작성 - FakeAlertRepository 작성 * test: AlertService 테스트 작성 - AlertService 테스트 작성 - 관련 Fixture 등록 - FakeAlertRepository 문제 수정 * fix: 알림 정렬 기준 문제 수정 - 알림 정렬 조건에 id 추가되도록 수정 * test: AlertQueryService 테스트 작성 - AlertQueryService 테스트 작성 - 관련 Fixture 등록 * fix: FirebaseApp 중복 문제 해결 - FirebaseApp 중복 문제 해결 * test: 단일 알림 조회 테스트 수정 - 단일 알림 조회 테스트 수정 - API 문서 작성 * test: AlertControllerAcceptance 테스트 작성 - AlertControllerAcceptance 테스트 작성 - 관련 Fixture 등록 * refactor: 회원 로그인 시 이벤트 발생 검증 추가 - 회원 로그인 시 FCM 토큰 저장 이벤트 발생 검증 추가 - 회원 로그인 문서에 FCM 토큰 정보 추가 * refactor: 알림 단일 조회 반환값 변경 - 알림 단일 조회 반환값 (읽음 여부) 변경 * refactor: 알림 조회 테스트 정렬 기준 교정 - 알림 조회 테스트 정렬 기준 교정 * refactor: 필요없는 인자 제거 - 필요없는 인자 제거 * refactor: 파이어베이스 키 조회 방식 수정 - 직접적인 파일을 미리 만들지 않고 암호화된 값을 통해 파일을 만드는 방식으로 조회하도록 수정 * fix: 파이어베이스 키 조회 방식 수정 - 테스트 환경에서의 문제 해결하기 위해 테스트 application.yml에 파이어베이스 관련 정보 등록 - 파일 생성 중단, 데이터만 이용하도록 변경 - 빈 생성 중 예외 발생 (테스트 환경)일 시, 기본 GoogleCredentials 반환하도록 수정 * refactor: Fake 객체 이름 변경 - Fake 객체 이름 규칙에 맞게 변경 * docs: 의미없는 gitignore 삭제 - 의미없는 gitignore 코드 삭제 * refactor: FirebaseFileNotFoundException 삭제 - FirebaseFileNotFoundException 파일 삭제 * refactor: final 추가 - AlertGroup final 추가 * refactor: AllArgsConstructor 추가 - AlertMessage AllArgsConstructor 추가 * refactor: 어노테이션 순서 통일 - 어노테이션 순서 통일되도록 수정 * refactor: AlertManager 패키지 위치 수정 - AlertManager 인터페이스의 위치를 도메인에서 애플리케이션으로 변경 * refactor: Alert 생성 시 빌더 패턴 적용 - Alert 생성 시 빌더 패턴 적용되도록 수정 * refactor: AlertService orElseThrow 축약 - AlertService orElseThrow 축약 적용 * chore: Firebase 라이브러리 버전 업그레이드 - 파이어베이스 이후 버전의 특징 (sendEach, 트래픽 처리 개선 등)을 적용하기 위해 버전 업그레이드 * refactor: 알림의 특성을 고려하여 트랜잭션 분리 적용 - 외부 트랜잭션이 커밋된 이후에만 이벤트를 발송할 수 있도록 & 추가적인 데이터베이스 작업이 필요하므로 TransactionalEventListener로 변경 - 커밋 후 트랜잭션 작업을 하기 위해 REQUIRES_NEW 적용 * fix: AlertMessage Equals&HashCode 추가 - 테스트 정상화를 위해 AlertMessage Equals&HashCode 추가 * fix: AuditingHandler 추가 방식 이용 - AlertQueryRepositoryTest에 AuditingHandler 추가 * fix: LocalDateTime 비교 제외 진행 - 값 비교 시 LocalDateTime 제외되도록 수정 * refactor: AuditingHandler 제거 - AuditingHandler 제거 * refactor: AlertGroup Enumerated 수정 - AlertGroup Enumerated value 수정 * fix: import 교체 - 바뀐 경로 import 교체 - assertSoftly 문제 수정 * feat: 알림 삭제 스케줄러 분산 락 적용 - 레디스 분산 락을 적용하기 위해 redisson 등록 - AlertScheduler 인터페이스 분리 - 기존 AlertService 스케줄러 메서드 삭제 및 이동 - RedissonAlertSchedulerTest 테스트 작성 * refactor: Alert 인덱싱 적용 * refactor: 비동기 적용 방식 수정 - Async 어노테이션으로 알림 전송 스레드 분리 - Async 전용 설정 파일 생성 - Firebase 전용 스레드 매니저 생성 - 트랜잭션 전파 수준 수정 * refactor: QueryService abstract class 생성 - 페이지 조회 공통 메서드를 축약하기 위해 BaseQueryService 생성 * refactor: RedissonLock AOP 생성 - Redisson Lock 재사용을 위해 AOP 생성 및 적용 * refactor: 비동기 예외 핸들러 등록 - AsyncUncaughtExceptionHandler 등록 * refactor: Alert 생성 과정 수정 - DB에 저장된 createdAt 속성 들어가도록 수정 - 메시지 바디 null 문제로 인해 data에 들어가도록 수정 * refactor: instanceof 표현 삭제 - instanceof 대신 ClassCastException 탐지되도록 수정
1 parent 0a8d8cb commit d171e79

File tree

67 files changed

+2427
-33
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

67 files changed

+2427
-33
lines changed

build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ dependencies {
4343
runtimeOnly 'com.h2database:h2'
4444
runtimeOnly 'com.mysql:mysql-connector-j'
4545
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
46+
implementation 'org.redisson:redisson-spring-boot-starter:3.33.0'
4647

4748
// flyway
4849
implementation 'org.flywaydb:flyway-core'
@@ -78,6 +79,9 @@ dependencies {
7879
// actuator, prometheus
7980
runtimeOnly 'io.micrometer:micrometer-registry-prometheus'
8081
implementation 'org.springframework.boot:spring-boot-starter-actuator'
82+
83+
// Firebase
84+
implementation 'com.google.firebase:firebase-admin:9.3.0'
8185
}
8286

8387
def generated = 'src/main/generated'

src/docs/asciidoc/alert.adoc

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
:toc: left
2+
:source-highlighter: highlightjs
3+
:sectlinks:
4+
:toclevels: 2
5+
:sectlinks:
6+
:sectnums:
7+
8+
== Alert
9+
10+
=== 알림 페이징 조회 (GET api/members/me/alerts)
11+
==== 요청
12+
include::{snippets}/alert-controller-web-mvc-test/받은_알림_페이징조회/request-headers.adoc[]
13+
include::{snippets}/alert-controller-web-mvc-test/받은_알림_페이징조회/request-parts.adoc[]
14+
include::{snippets}/alert-controller-web-mvc-test/받은_알림_페이징조회/http-request.adoc[]
15+
==== 응답
16+
include::{snippets}/alert-controller-web-mvc-test/받은_알림_페이징조회/response-fields.adoc[]
17+
include::{snippets}/alert-controller-web-mvc-test/받은_알림_페이징조회/http-response.adoc[]
18+
19+
=== 알림 단일 조회 (GET api/members/me/alerts/{alertId})
20+
읽지 않았던 알림일 경우 읽음 처리됨
21+
22+
==== 요청
23+
include::{snippets}/alert-controller-web-mvc-test/단일_알림_조회/request-headers.adoc[]
24+
include::{snippets}/alert-controller-web-mvc-test/단일_알림_조회/path-parameters.adoc[]
25+
include::{snippets}/alert-controller-web-mvc-test/단일_알림_조회/http-request.adoc[]
26+
==== 응답
27+
include::{snippets}/alert-controller-web-mvc-test/단일_알림_조회/response-fields.adoc[]
28+
include::{snippets}/alert-controller-web-mvc-test/단일_알림_조회/http-response.adoc[]

src/docs/asciidoc/index.adoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@
1313
* link:membersurveys.html[회원 연애고사 API]
1414
* link:report.html[회원 신고 API]
1515
* link:likes.html[호감 API]
16+
* link:alert.html[알림 API]

src/main/java/com/atwoz/AtwozApplication.java

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,8 @@
44
import org.springframework.boot.autoconfigure.SpringBootApplication;
55
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
66
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
7-
import org.springframework.scheduling.annotation.EnableAsync;
87

98
@EnableJpaAuditing
10-
@EnableAsync
119
@SpringBootApplication
1210
@ConfigurationPropertiesScan
1311
public class AtwozApplication {
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package com.atwoz.alert.application;
2+
3+
import com.atwoz.alert.application.event.AlertCreatedEvent;
4+
import com.atwoz.alert.application.event.AlertTokenCreatedEvent;
5+
import lombok.RequiredArgsConstructor;
6+
import org.springframework.scheduling.annotation.Async;
7+
import org.springframework.stereotype.Component;
8+
import org.springframework.transaction.event.TransactionalEventListener;
9+
10+
@RequiredArgsConstructor
11+
@Component
12+
public class AlertEventHandler {
13+
14+
private static final String ASYNC_EXECUTOR = "asyncExecutor";
15+
16+
private final AlertService alertService;
17+
18+
@Async(value = ASYNC_EXECUTOR)
19+
@TransactionalEventListener
20+
public void sendAlertCreatedEvent(final AlertCreatedEvent event) {
21+
alertService.sendAlert(event.group(), event.title(), event.body(), event.sender(), event.receiverId());
22+
}
23+
24+
@TransactionalEventListener
25+
public void sendAlertTokenCreatedEvent(final AlertTokenCreatedEvent event) {
26+
alertService.saveToken(event.id(), event.token());
27+
}
28+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.atwoz.alert.application;
2+
3+
import com.atwoz.alert.domain.Alert;
4+
5+
public interface AlertManager {
6+
7+
void send(Alert alert, String sender, String token);
8+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.atwoz.alert.application;
2+
3+
import com.atwoz.alert.domain.AlertRepository;
4+
import com.atwoz.alert.infrastructure.dto.AlertPagingResponse;
5+
import com.atwoz.alert.infrastructure.dto.AlertSearchResponse;
6+
import com.atwoz.global.application.BaseQueryService;
7+
import lombok.RequiredArgsConstructor;
8+
import org.springframework.data.domain.Page;
9+
import org.springframework.data.domain.Pageable;
10+
import org.springframework.stereotype.Service;
11+
import org.springframework.transaction.annotation.Transactional;
12+
13+
@RequiredArgsConstructor
14+
@Transactional(readOnly = true)
15+
@Service
16+
public class AlertQueryService extends BaseQueryService<AlertSearchResponse> {
17+
18+
private final AlertRepository alertRepository;
19+
20+
public AlertPagingResponse findMemberAlertsWithPaging(final Long memberId, final Pageable pageable) {
21+
Page<AlertSearchResponse> response = alertRepository.findMemberAlertsWithPaging(memberId, pageable);
22+
int nextPage = getNextPage(pageable.getPageNumber(), response);
23+
return AlertPagingResponse.of(response, nextPage);
24+
}
25+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package com.atwoz.alert.application;
2+
3+
public interface AlertScheduler {
4+
5+
void deleteExpiredAlerts();
6+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.atwoz.alert.application;
2+
3+
import com.atwoz.alert.domain.Alert;
4+
import com.atwoz.alert.domain.AlertRepository;
5+
import com.atwoz.alert.domain.AlertTokenRepository;
6+
import com.atwoz.alert.domain.vo.AlertGroup;
7+
import com.atwoz.alert.exception.exceptions.AlertNotFoundException;
8+
import lombok.RequiredArgsConstructor;
9+
import org.springframework.stereotype.Service;
10+
import org.springframework.transaction.annotation.Transactional;
11+
12+
@RequiredArgsConstructor
13+
@Transactional
14+
@Service
15+
public class AlertService {
16+
17+
private final AlertRepository alertRepository;
18+
private final AlertTokenRepository tokenRepository;
19+
private final AlertManager alertManager;
20+
21+
public void saveToken(final Long id, final String token) {
22+
tokenRepository.saveToken(id, token);
23+
}
24+
25+
public void sendAlert(final AlertGroup group, final String title, final String body, final String sender, final Long receiverId) {
26+
String token = tokenRepository.getToken(receiverId);
27+
Alert alert = Alert.createWith(group, title, body, receiverId);
28+
Alert savedAlert = alertRepository.save(alert);
29+
alertManager.send(savedAlert, sender, token);
30+
}
31+
32+
public Alert readAlert(final Long memberId, final Long id) {
33+
Alert alert = alertRepository.findByMemberIdAndId(memberId, id)
34+
.orElseThrow(AlertNotFoundException::new);
35+
alert.read();
36+
return alert;
37+
}
38+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.atwoz.alert.application.event;
2+
3+
import com.atwoz.alert.domain.vo.AlertGroup;
4+
5+
public record AlertCreatedEvent(
6+
AlertGroup group,
7+
String title,
8+
String body,
9+
String sender,
10+
Long receiverId
11+
) {
12+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.atwoz.alert.application.event;
2+
3+
public record AlertTokenCreatedEvent(
4+
Long id,
5+
String token
6+
) {
7+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package com.atwoz.alert.config;
2+
3+
import com.fasterxml.jackson.core.JsonProcessingException;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import com.google.auth.oauth2.GoogleCredentials;
6+
import com.google.firebase.FirebaseApp;
7+
import com.google.firebase.FirebaseOptions;
8+
import com.google.firebase.ThreadManager;
9+
import org.springframework.beans.factory.annotation.Value;
10+
import org.springframework.context.annotation.Bean;
11+
import org.springframework.context.annotation.Configuration;
12+
13+
import java.io.ByteArrayInputStream;
14+
import java.io.IOException;
15+
import java.io.InputStream;
16+
import java.util.HashMap;
17+
import java.util.Map;
18+
19+
@Configuration
20+
public class FirebaseConfig {
21+
22+
private static final String TYPE = "type";
23+
private static final String PROJECT_ID = "project_id";
24+
private static final String PRIVATE_KEY_ID = "private_key_id";
25+
private static final String PRIVATE_KEY = "private_key";
26+
private static final String CLIENT_EMAIL = "client_email";
27+
private static final String CLIENT_ID = "client_id";
28+
private static final String AUTH_URI = "auth_uri";
29+
private static final String TOKEN_URI = "token_uri";
30+
private static final String AUTH_CERT = "auth_provider_x509_cert_url";
31+
private static final String CLIENT_CERT = "client_x509_cert_url";
32+
private static final String UNIVERSE_DOMAIN = "universe_domain";
33+
private static final String REPLACE_TARGET_MARK = "\\\\n";
34+
private static final String REPLACE_VALUE_MARK = "\n";
35+
36+
@Value("${firebase.type}")
37+
private String type;
38+
39+
@Value("${firebase.project_id}")
40+
private String projectId;
41+
42+
@Value("${firebase.private_key_id}")
43+
private String privateKeyId;
44+
45+
@Value("${firebase.private_key}")
46+
private String privateKey;
47+
48+
@Value("${firebase.client_email}")
49+
private String clientEmail;
50+
51+
@Value("${firebase.client_id}")
52+
private String clientId;
53+
54+
@Value("${firebase.auth_uri}")
55+
private String authUri;
56+
57+
@Value("${firebase.token_uri}")
58+
private String tokenUri;
59+
60+
@Value("${firebase.auth_provider_x509_cert_url}")
61+
private String authCert;
62+
63+
@Value("${firebase.client_x509_cert_url}")
64+
private String clientCert;
65+
66+
@Value("${firebase.universe_domain}")
67+
private String universeDomain;
68+
69+
@Bean
70+
public FirebaseApp firebaseApp() {
71+
if (!FirebaseApp.getApps().isEmpty()) {
72+
return FirebaseApp.getInstance();
73+
}
74+
ThreadManager threadManager = new FirebaseThreadManager();
75+
FirebaseOptions options = new FirebaseOptions.Builder()
76+
.setThreadManager(threadManager)
77+
.setCredentials(getCredentials())
78+
.build();
79+
return FirebaseApp.initializeApp(options);
80+
}
81+
82+
private GoogleCredentials getCredentials() {
83+
try {
84+
String jsonContent = generateJsonContent();
85+
InputStream inputStream = new ByteArrayInputStream(jsonContent.getBytes());
86+
return GoogleCredentials.fromStream(inputStream);
87+
} catch (IOException e) {
88+
return GoogleCredentials.newBuilder()
89+
.build();
90+
}
91+
}
92+
93+
private String generateJsonContent() throws JsonProcessingException {
94+
ObjectMapper mapper = new ObjectMapper();
95+
Map<String, String> firebaseConfig = generateFirebaseConfig();
96+
firebaseConfig.put(PRIVATE_KEY, replaceNewLines(privateKey));
97+
98+
return mapper.writeValueAsString(firebaseConfig);
99+
}
100+
101+
private Map<String, String> generateFirebaseConfig() {
102+
Map<String, String> firebaseConfig = new HashMap<>();
103+
firebaseConfig.put(TYPE, type);
104+
firebaseConfig.put(PROJECT_ID, projectId);
105+
firebaseConfig.put(PRIVATE_KEY_ID, privateKeyId);
106+
firebaseConfig.put(CLIENT_EMAIL, clientEmail);
107+
firebaseConfig.put(CLIENT_ID, clientId);
108+
firebaseConfig.put(AUTH_URI, authUri);
109+
firebaseConfig.put(TOKEN_URI, tokenUri);
110+
firebaseConfig.put(AUTH_CERT, authCert);
111+
firebaseConfig.put(CLIENT_CERT, clientCert);
112+
firebaseConfig.put(UNIVERSE_DOMAIN, universeDomain);
113+
114+
return firebaseConfig;
115+
}
116+
117+
private String replaceNewLines(String value) {
118+
return value.replaceAll(REPLACE_TARGET_MARK, REPLACE_VALUE_MARK);
119+
}
120+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.atwoz.alert.config;
2+
3+
import com.google.firebase.FirebaseApp;
4+
import com.google.firebase.ThreadManager;
5+
import org.springframework.stereotype.Component;
6+
7+
import java.util.concurrent.ExecutorService;
8+
import java.util.concurrent.Executors;
9+
import java.util.concurrent.ThreadFactory;
10+
11+
@Component
12+
public class FirebaseThreadManager extends ThreadManager {
13+
14+
private static final int FIREBASE_THREADS_SIZE = 40;
15+
16+
@Override
17+
protected ExecutorService getExecutor(final FirebaseApp firebaseApp) {
18+
return Executors.newFixedThreadPool(FIREBASE_THREADS_SIZE);
19+
}
20+
21+
@Override
22+
protected void releaseExecutor(final FirebaseApp firebaseApp, final ExecutorService executorService) {
23+
executorService.shutdownNow();
24+
}
25+
26+
@Override
27+
protected ThreadFactory getThreadFactory() {
28+
return Executors.defaultThreadFactory();
29+
}
30+
}

0 commit comments

Comments
 (0)