Skip to content

Commit 36dfa92

Browse files
authored
[FEAT] FCM 푸시 알림 및 이메일 알림 기본 설계 구현 (#340)
<!-- ## PR 제목 컨벤션 [TYPE] 설명 (#이슈번호) 예시: - [FEAT] 회원가입 API 구현 (#14) - [FIX] 이미지 업로드 시 NPE 수정 (#23) - [REFACTOR] 토큰 로직 분리 (#8) - [DOCS] ERD 스키마 업데이트 (#6) - [CHORE] CI/CD 파이프라인 추가 (#3) - [RELEASE] v1.0.0 배포 (#30) TYPE: FEAT, FIX, DOCS, REFACTOR, TEST, CHORE, RENAME, REMOVE, RELEASE --> ## Summary <!-- 변경 사항을 간단히 설명해주세요 --> 비즈니스 이벤트 기반 알림 시스템을 구현했습니다. 제안 수신, 캠페인 매칭 등 이벤트 발생 시 웹 푸시(FCM) 및 이메일 알림을 발송하고, 알림 페이지 API를 제공합니다. Outbox 패턴을 사용하여 비즈니스 트랜잭션과 완전히 분리되었으며, 재시도 정책과 장애 격리 메커니즘이 포함되어 있습니다. ## Changes <!-- 변경된 내용을 목록으로 작성해주세요 --> ### 도메인 레이어 - **Notification 엔티티**: 알림 원장 (읽음 처리, 소프트 삭제 지원) - **NotificationDelivery 엔티티**: 채널별 발송 추적 (PENDING/IN_PROGRESS/SENT/FAILED 상태, 재시도 관리) - **FcmToken 엔티티**: FCM 디바이스 토큰 저장소 (다중 기기 지원, 토큰 재할당) - **NotificationKind enum**: 알림 종류 정의 (PROPOSAL_RECEIVED, CAMPAIGN_MATCHED 등) - **DeliveryStatus enum**: 발송 상태 정의 (PENDING, IN_PROGRESS, SENT, FAILED) ### 애플리케이션 레이어 - **NotificationService**: 알림 생성 및 읽음 처리 - **NotificationQueryService**: 알림 목록 조회 (필터링, 날짜별 그룹핑, 페이징) - **NotificationDeliveryProcessor**: 개별 발송 처리 (장애 격리, REQUIRES_NEW 트랜잭션) - **FcmTokenService**: FCM 토큰 등록/삭제 관리 - **NotificationChannelResolver**: 알림 종류별 채널 결정 (PUSH/EMAIL) - **NotificationMessageTemplateService**: 알림 메시지 템플릿 생성 ### 인프라스트럭처 레이어 - **NotificationEventListener**: 비즈니스 이벤트 구독 및 알림 생성 (`@TransactionalEventListener(AFTER_COMMIT)`) - **NotificationDeliveryWorker**: 비동기 발송 워커 (`@Scheduled`, 30초마다 실행) - **FcmNotificationSender**: FCM 웹 푸시 발송 (Firebase Admin SDK) - **EmailNotificationSender**: 이메일 발송 (Spring Mail, HTML 템플릿) - **FirebaseConfig**: Firebase Admin SDK 초기화 (선택적, 파일 없어도 동작) ### 프레젠테이션 레이어 - **NotificationController**: 알림 목록 조회, 읽음 처리 API - **FcmTokenController**: FCM 토큰 등록/삭제 API - **NotificationSwagger**: API 문서화 인터페이스 - **FcmTokenSwagger**: FCM 토큰 API 문서화 인터페이스 ### 이벤트 정의 (데모데이 이후용) - **CampaignCompletedEvent**: 캠페인 완료 이벤트 (리스너만 정의, 발행은 추후) - **SettlementReadyEvent**: 정산 준비 이벤트 (리스너만 정의, 발행은 추후) - **AutoConfirmedEvent**: 자동 확정 이벤트 (리스너만 정의, 발행은 추후) ### 설정 파일 - **application.yml**: 로컬 개발용 기본값 설정 (FCM, SMTP) - **application-dev.yml**: dev 환경 설정 (환경변수 참조) - **application-prod.yml**: prod 환경 설정 (환경변수 참조) - **.gitignore**: Firebase 키 파일 제외 설정 ## Type of Change <!-- 해당하는 항목에 x 표시해주세요 --> - [ ] Bug fix (기존 기능에 영향을 주지 않는 버그 수정) - [x] New feature (기존 기능에 영향을 주지 않는 새로운 기능 추가) - [ ] Breaking change (기존 기능에 영향을 주는 수정) - [ ] Refactoring (기능 변경 없는 코드 개선) - [ ] Documentation (문서 수정) - [ ] Chore (빌드, 설정 등 기타 변경) - [ ] Release (develop → main 배포) ## Related Issues <!-- 관련 이슈 번호를 작성해주세요 (예: Closes #123, Fixes #456) --> Closes #218 Closes #219 ## 참고 사항 <!-- 리뷰어가 알아야 할 추가 정보가 있다면 작성해주세요 -->
1 parent c5a1abb commit 36dfa92

27 files changed

+1092
-11
lines changed

.github/workflows/pr-check.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,18 @@ jobs:
9393
AWS_ACCESS_KEY_ID: AWSACCESSKEYIDISSECRET
9494
AWS_SECRET_ACCESS_KEY: AWSSECRETACCESSKEYISSECRET
9595
# FRONT
96+
FRONT_DOMAIN_URL: http://localhost:3000
9697
FRONT_DOMAIN_URL_V2: http://localhost:3000
9798
FRONT_DOMAIN_URL_LOCAL: http://localhost:3000
99+
# FCM
100+
FCM_CREDENTIALS_PATH: ""
101+
FCM_PROJECT_ID: ""
102+
# SMTP
103+
MAIL_HOST: smtp.gmail.com
104+
MAIL_PORT: 587
105+
MAIL_USERNAME: test@example.com
106+
MAIL_PASSWORD: test_password
107+
MAIL_FROM: test@example.com
98108

99109
steps:
100110
- name: Checkout code

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ src/test/resources/application.yml
88
.env
99
.env.*
1010

11+
# Firebase 서비스 계정 키
12+
config/firebase-service-account.json
13+
**/firebase-service-account.json
14+
**/*firebase-adminsdk*.json
15+
1116
### Gradle ###
1217
.gradle/
1318
build/

build.gradle

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@ dependencies {
8383
// Spring Retry
8484
implementation 'org.springframework.retry:spring-retry'
8585
implementation 'org.springframework:spring-aspects'
86+
87+
// Spring Mail
88+
implementation 'org.springframework.boot:spring-boot-starter-mail'
89+
90+
// Firebase Admin SDK (FCM 푸시 알림)
91+
implementation 'com.google.firebase:firebase-admin:9.2.0'
8692
}
8793

8894
tasks.named('test') {

docker-compose.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ services:
5858
- rabbitmq
5959
env_file:
6060
- .env
61+
volumes:
62+
- ./config/firebase-service-account.json:/app/config/firebase-service-account.json:ro
6163
networks:
6264
- realmatch-network
6365

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.example.RealMatch.business.application.event;
2+
3+
/**
4+
* 캠페인이 자동으로 확정되었을 때 발행되는 이벤트
5+
*/
6+
public record AutoConfirmedEvent(
7+
Long campaignId,
8+
Long brandUserId,
9+
Long creatorUserId,
10+
String campaignName
11+
) {
12+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.example.RealMatch.business.application.event;
2+
3+
/**
4+
* 캠페인이 완료되었을 때 발행되는 이벤트
5+
*/
6+
public record CampaignCompletedEvent(
7+
Long campaignId,
8+
Long brandUserId,
9+
Long creatorUserId,
10+
String campaignName
11+
) {
12+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.example.RealMatch.business.application.event;
2+
3+
/**
4+
* 정산이 가능한 상태가 되었을 때 발행되는 이벤트
5+
*/
6+
public record SettlementReadyEvent(
7+
Long campaignId,
8+
Long creatorUserId,
9+
Long brandUserId,
10+
String campaignName,
11+
Long settlementAmount
12+
) {
13+
}

src/main/java/com/example/RealMatch/notification/application/event/NotificationEventListener.java

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@
88

99
import com.example.RealMatch.brand.domain.entity.Brand;
1010
import com.example.RealMatch.brand.domain.repository.BrandRepository;
11+
import com.example.RealMatch.business.application.event.AutoConfirmedEvent;
1112
import com.example.RealMatch.business.application.event.CampaignApplySentEvent;
13+
import com.example.RealMatch.business.application.event.CampaignCompletedEvent;
1214
import com.example.RealMatch.business.application.event.CampaignProposalSentEvent;
1315
import com.example.RealMatch.business.application.event.CampaignProposalStatusChangedEvent;
16+
import com.example.RealMatch.business.application.event.SettlementReadyEvent;
1417
import com.example.RealMatch.business.domain.enums.ProposalDirection;
1518
import com.example.RealMatch.business.domain.enums.ProposalStatus;
1619
import com.example.RealMatch.notification.application.dto.CreateNotificationCommand;
@@ -222,6 +225,129 @@ public void handleCampaignApplySent(CampaignApplySentEvent event) {
222225
}
223226
}
224227

228+
// ==================== 데모데이 이후용: CampaignCompletedEvent ====================
229+
230+
/**
231+
* CampaignCompletedEvent 구독.
232+
* 크리에이터에게 CAMPAIGN_COMPLETED 알림 생성.
233+
* (데모데이 이후 해당 플로우에서 이벤트 발행만 추가하면 동작)
234+
*/
235+
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
236+
public void handleCampaignCompleted(CampaignCompletedEvent event) {
237+
if (event == null) {
238+
LOG.warn("[Notification] Invalid CampaignCompletedEvent: event is null");
239+
return;
240+
}
241+
242+
try {
243+
String eventId = String.format("CAMPAIGN_COMPLETED:%d", event.campaignId());
244+
String brandName = findBrandNameByUserId(event.brandUserId());
245+
246+
MessageTemplate template = messageTemplateService.createCampaignCompletedMessage(brandName);
247+
248+
CreateNotificationCommand command = CreateNotificationCommand.builder()
249+
.eventId(eventId)
250+
.userId(event.creatorUserId())
251+
.kind(NotificationKind.CAMPAIGN_COMPLETED)
252+
.title(template.title())
253+
.body(template.body())
254+
.referenceType(ReferenceType.CAMPAIGN)
255+
.referenceId(String.valueOf(event.campaignId()))
256+
.campaignId(event.campaignId())
257+
.build();
258+
259+
notificationService.create(command);
260+
261+
LOG.info("[Notification] Created CAMPAIGN_COMPLETED. eventId={}, campaignId={}, userId={}",
262+
eventId, event.campaignId(), event.creatorUserId());
263+
} catch (Exception e) {
264+
LOG.error("[Notification] Failed to create CAMPAIGN_COMPLETED. campaignId={}, userId={}",
265+
event.campaignId(), event.creatorUserId(), e);
266+
}
267+
}
268+
269+
// ==================== 데모데이 이후용: SettlementReadyEvent ====================
270+
271+
/**
272+
* SettlementReadyEvent 구독.
273+
* 크리에이터에게 SETTLEMENT_READY 알림 생성.
274+
* (데모데이 이후 해당 플로우에서 이벤트 발행만 추가하면 동작)
275+
*/
276+
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
277+
public void handleSettlementReady(SettlementReadyEvent event) {
278+
if (event == null) {
279+
LOG.warn("[Notification] Invalid SettlementReadyEvent: event is null");
280+
return;
281+
}
282+
283+
try {
284+
String eventId = String.format("SETTLEMENT_READY:%d", event.campaignId());
285+
String brandName = findBrandNameByUserId(event.brandUserId());
286+
287+
MessageTemplate template = messageTemplateService.createSettlementReadyMessage(brandName);
288+
289+
CreateNotificationCommand command = CreateNotificationCommand.builder()
290+
.eventId(eventId)
291+
.userId(event.creatorUserId())
292+
.kind(NotificationKind.SETTLEMENT_READY)
293+
.title(template.title())
294+
.body(template.body())
295+
.referenceType(ReferenceType.CAMPAIGN)
296+
.referenceId(String.valueOf(event.campaignId()))
297+
.campaignId(event.campaignId())
298+
.build();
299+
300+
notificationService.create(command);
301+
302+
LOG.info("[Notification] Created SETTLEMENT_READY. eventId={}, campaignId={}, userId={}",
303+
eventId, event.campaignId(), event.creatorUserId());
304+
} catch (Exception e) {
305+
LOG.error("[Notification] Failed to create SETTLEMENT_READY. campaignId={}, userId={}",
306+
event.campaignId(), event.creatorUserId(), e);
307+
}
308+
}
309+
310+
// ==================== 데모데이 이후용: AutoConfirmedEvent ====================
311+
312+
/**
313+
* AutoConfirmedEvent 구독.
314+
* 브랜드에게 AUTO_CONFIRMED 알림 생성.
315+
* (데모데이 이후 해당 플로우에서 이벤트 발행만 추가하면 동작)
316+
*/
317+
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
318+
public void handleAutoConfirmed(AutoConfirmedEvent event) {
319+
if (event == null) {
320+
LOG.warn("[Notification] Invalid AutoConfirmedEvent: event is null");
321+
return;
322+
}
323+
324+
try {
325+
String eventId = String.format("AUTO_CONFIRMED:%d", event.campaignId());
326+
String brandName = findBrandNameByUserId(event.brandUserId());
327+
328+
MessageTemplate template = messageTemplateService.createAutoConfirmedMessage(brandName);
329+
330+
CreateNotificationCommand command = CreateNotificationCommand.builder()
331+
.eventId(eventId)
332+
.userId(event.brandUserId())
333+
.kind(NotificationKind.AUTO_CONFIRMED)
334+
.title(template.title())
335+
.body(template.body())
336+
.referenceType(ReferenceType.CAMPAIGN)
337+
.referenceId(String.valueOf(event.campaignId()))
338+
.campaignId(event.campaignId())
339+
.build();
340+
341+
notificationService.create(command);
342+
343+
LOG.info("[Notification] Created AUTO_CONFIRMED. eventId={}, campaignId={}, userId={}",
344+
eventId, event.campaignId(), event.brandUserId());
345+
} catch (Exception e) {
346+
LOG.error("[Notification] Failed to create AUTO_CONFIRMED. campaignId={}, userId={}",
347+
event.campaignId(), event.brandUserId(), e);
348+
}
349+
}
350+
225351
// ==================== 공통 헬퍼 ====================
226352

227353
/**
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package com.example.RealMatch.notification.application.service;
2+
3+
import java.util.Optional;
4+
5+
import org.slf4j.Logger;
6+
import org.slf4j.LoggerFactory;
7+
import org.springframework.stereotype.Service;
8+
import org.springframework.transaction.annotation.Transactional;
9+
10+
import com.example.RealMatch.notification.domain.entity.FcmToken;
11+
import com.example.RealMatch.notification.domain.repository.FcmTokenRepository;
12+
13+
import lombok.RequiredArgsConstructor;
14+
15+
@Service
16+
@RequiredArgsConstructor
17+
@Transactional
18+
public class FcmTokenService {
19+
20+
private static final Logger LOG = LoggerFactory.getLogger(FcmTokenService.class);
21+
22+
private final FcmTokenRepository fcmTokenRepository;
23+
24+
/**
25+
* FCM 토큰을 등록한다.
26+
* 동일한 토큰이 이미 존재하면 소유자를 현재 유저로 재할당한다 (디바이스 로그아웃→재로그인 대응).
27+
*/
28+
public void registerToken(Long userId, String token, String deviceInfo) {
29+
Optional<FcmToken> existing = fcmTokenRepository.findByToken(token);
30+
31+
if (existing.isPresent()) {
32+
FcmToken fcmToken = existing.get();
33+
if (!fcmToken.getUserId().equals(userId)) {
34+
fcmToken.reassignTo(userId);
35+
LOG.info("[FCM] Token reassigned. token={}..., newUserId={}", token.substring(0, Math.min(token.length(), 10)), userId);
36+
}
37+
return;
38+
}
39+
40+
FcmToken fcmToken = FcmToken.builder()
41+
.userId(userId)
42+
.token(token)
43+
.deviceInfo(deviceInfo)
44+
.build();
45+
fcmTokenRepository.save(fcmToken);
46+
LOG.info("[FCM] Token registered. userId={}, deviceInfo={}", userId, deviceInfo);
47+
}
48+
49+
/**
50+
* FCM 토큰을 삭제한다 (로그아웃 시 호출).
51+
*/
52+
public void removeToken(String token) {
53+
fcmTokenRepository.deleteByToken(token);
54+
LOG.info("[FCM] Token removed. token={}...", token.substring(0, Math.min(10, token.length())));
55+
}
56+
57+
/**
58+
* 유저의 모든 FCM 토큰을 삭제한다 (회원 탈퇴 시 호출).
59+
*/
60+
public void removeAllTokensByUserId(Long userId) {
61+
fcmTokenRepository.deleteByUserId(userId);
62+
LOG.info("[FCM] All tokens removed. userId={}", userId);
63+
}
64+
}

0 commit comments

Comments
 (0)