Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .github/workflows/pr-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ jobs:
continue-on-error: true

- name: Build with Gradle
run: ./gradlew build -x test
run: ./gradlew build -x test --refresh-dependencies

- name: Run tests
run: ./gradlew test
Expand Down
19 changes: 11 additions & 8 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,18 @@ repositories {
}

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-mail'
// Spring Boot starters
implementation 'org.springframework.boot:spring-boot-starter'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-amqp'
implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'

Expand Down Expand Up @@ -85,11 +87,12 @@ dependencies {
implementation 'org.springframework.retry:spring-retry'
implementation 'org.springframework:spring-aspects'

// Spring Mail
implementation 'org.springframework.boot:spring-boot-starter-mail'

// Firebase Admin SDK (FCM 푸시 알림)
implementation 'com.google.firebase:firebase-admin:9.2.0'

// RabbitMQ
implementation 'com.rabbitmq:amqp-client'
testImplementation 'org.springframework.amqp:spring-rabbit-test'
Copy link
Contributor

Choose a reason for hiding this comment

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

high

RabbitMQ 테스트 의존성을 추가해주신 것은 좋지만, 이번 PR에서 리팩토링된 중요 로직에 대한 테스트 코드가 보이지 않습니다. 특히 NotificationDeliveryConsumer의 오류 처리, NotificationOutboxPublisher의 발행 로직, NotificationRecoveryScheduler의 복구 시나리오 등은 시스템의 안정성과 직결되므로 테스트 커버리지를 확보하는 것이 매우 중요합니다.

spring-rabbit-test@RabbitListenerTest 등을 활용하면 RabbitMQ 컴포넌트를 효과적으로 테스트할 수 있습니다. 새로운 아키텍처의 안정성을 보장하기 위해 테스트 코드를 추가해주시길 바랍니다.

}

tasks.named('test') {
Expand Down
89 changes: 89 additions & 0 deletions scripts/sql/migrate_notification_tables.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
-- ============================================
-- 알림 관련 테이블 마이그레이션 (전체)
-- ============================================
-- 실행 순서: notification → notification_delivery → notification_outbox, fcm_token
-- MySQL 8.0+ / MariaDB 10.2+ 권장 (DATETIME(6), utf8mb4)
-- 사용법: mysql -u 사용자 -p DB명 < migrate_notification_tables.sql
-- ============================================

-- ---------------------------------------------------------------------------
-- 1. Notification (알림 원장)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS notification (
id BINARY(16) NOT NULL PRIMARY KEY,
user_id BIGINT NOT NULL COMMENT '알림 수신자 사용자 ID',
kind VARCHAR(30) NOT NULL COMMENT '알림 종류 (PROPOSAL_RECEIVED, CAMPAIGN_MATCHED 등)',
title VARCHAR(255) NOT NULL COMMENT '알림 제목',
body VARCHAR(1000) NOT NULL COMMENT '알림 내용',
reference_type VARCHAR(30) COMMENT '참조 타입 (CAMPAIGN_PROPOSAL, CAMPAIGN_APPLY, CAMPAIGN)',
reference_id VARCHAR(36) COMMENT '참조 ID',
campaign_id BIGINT COMMENT '관련 캠페인 ID',
proposal_id BIGINT COMMENT '관련 제안 ID',
is_read BOOLEAN NOT NULL DEFAULT FALSE COMMENT '읽음 여부',
created_at DATETIME(6) NOT NULL COMMENT '생성 시간',
updated_at DATETIME(6) COMMENT '수정 시간',
deleted_at DATETIME(6) COMMENT '삭제 시간 (소프트 삭제)',
is_deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '삭제 여부 (소프트 삭제)',
INDEX idx_notification_user_read_created (user_id, is_read, created_at) COMMENT '미읽음 조회 최적화',
INDEX idx_notification_user_created (user_id, created_at) COMMENT '날짜별 조회 최적화',
INDEX idx_notification_user_kind (user_id, kind) COMMENT '필터 조회 최적화'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='알림 원장 테이블';

-- ---------------------------------------------------------------------------
-- 2. NotificationDelivery (채널별 발송 추적, 재시도)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS notification_delivery (
id BINARY(16) NOT NULL PRIMARY KEY,
notification_id BINARY(16) NOT NULL,
channel VARCHAR(30) NOT NULL COMMENT 'PUSH, EMAIL, SMS',
status VARCHAR(20) NOT NULL COMMENT 'PENDING, IN_PROGRESS, RETRY, SENT, FAILED',
fail_reason VARCHAR(500),
attempted_at DATETIME(6),
next_retry_at DATETIME(6) COMMENT '다음 재시도 예정 시간 (exponential backoff: 1m, 5m, 30m, 2h, 12h)',
sent_at DATETIME(6),
provider_message_id VARCHAR(255),
idempotency_key VARCHAR(100) UNIQUE COMMENT 'eventId:kind:receiverId:channel 조합, 멱등성 보장',
attempt_count INT NOT NULL DEFAULT 0 COMMENT '발송 시도 횟수 (최대 5회, 재시도 정책용)',
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6),
INDEX idx_delivery_notification (notification_id, channel),
INDEX idx_delivery_status_retry (status, next_retry_at, created_at) COMMENT '워커 배치 조회 최적화'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='푸시/이메일 발송 추적 (Outbox + RabbitMQ 발송용)';

-- ---------------------------------------------------------------------------
-- 3. NotificationOutbox (Outbox 패턴, MQ 발행 대기)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS notification_outbox (
id BINARY(16) NOT NULL PRIMARY KEY,
delivery_id BINARY(16) NOT NULL COMMENT 'FK → notification_delivery.id',
notification_id BINARY(16) NOT NULL COMMENT 'FK → notification.id',
channel VARCHAR(30) NOT NULL COMMENT 'PUSH, EMAIL',
status VARCHAR(20) NOT NULL COMMENT 'PENDING, SENDING, SENT, FAILED',
retry_count INT NOT NULL DEFAULT 0 COMMENT 'MQ 발행 재시도 횟수 (최대 10회)',
last_error VARCHAR(500) COMMENT '마지막 발행 실패 사유',
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6),
INDEX idx_outbox_status_created (status, created_at) COMMENT 'PENDING Outbox 조회 (OutboxPublisher)'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Outbox: Notification+Delivery와 동일 TX 저장, Publisher가 MQ 발행';

-- ---------------------------------------------------------------------------
-- 4. FCM Token (웹 푸시 디바이스 토큰)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS fcm_token (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL COMMENT '토큰 소유자 사용자 ID',
token VARCHAR(500) NOT NULL COMMENT 'FCM 디바이스 토큰 (웹 푸시용)',
device_info VARCHAR(255) COMMENT '디바이스 정보 (예: Chrome/120.0.0.0 Windows 10)',
created_at DATETIME(6) NOT NULL COMMENT '토큰 등록 시간',
updated_at DATETIME(6) COMMENT '토큰 업데이트 시간',
INDEX idx_fcm_token_user (user_id),
UNIQUE KEY uk_fcm_token_value (token) COMMENT '토큰 중복 방지 (멱등성 보장)'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='FCM 디바이스 토큰 저장소 (사용자당 여러 토큰 지원)';

-- ============================================
-- 완료
-- ============================================
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package com.example.RealMatch.notification.application.service;

import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.example.RealMatch.notification.domain.entity.NotificationDelivery;
import com.example.RealMatch.notification.domain.entity.enums.DeliveryStatus;
import com.example.RealMatch.notification.domain.repository.NotificationDeliveryRepository;

import lombok.RequiredArgsConstructor;

/**
* Delivery 상태 전이 서비스. Consumer에서 호출.
* 각 메서드가 독립 TX → 외부 API(FCM/SMTP)가 TX 밖에서 실행됨을 보장.
* 흐름: claimDelivery() → (외부 발송) → markSent() / recordFailure()
*/
@Service
@RequiredArgsConstructor
public class NotificationDeliveryClaimService {

private static final Logger LOG = LoggerFactory.getLogger(NotificationDeliveryClaimService.class);

private static final List<DeliveryStatus> CLAIMABLE_STATUSES =
List.of(DeliveryStatus.PENDING, DeliveryStatus.RETRY);

private final NotificationDeliveryRepository deliveryRepository;

/** PENDING/RETRY → IN_PROGRESS 조건절 UPDATE. 1 row면 성공. */
@Transactional
public boolean claimDelivery(UUID deliveryId) {
int updated = deliveryRepository.claimDelivery(
deliveryId,
DeliveryStatus.IN_PROGRESS,
LocalDateTime.now(),
CLAIMABLE_STATUSES);

if (updated == 0) {
LOG.debug("[DeliveryClaim] Claim failed (already processed). deliveryId={}", deliveryId);
}
return updated > 0;
}

/** IN_PROGRESS → SENT. */
@Transactional
public void markSent(UUID deliveryId, String providerMessageId) {
NotificationDelivery delivery = deliveryRepository.findById(deliveryId).orElse(null);
if (delivery == null) {
LOG.warn("[DeliveryClaim] Delivery not found for markSent. deliveryId={}", deliveryId);
return;
}
delivery.markAsSent(providerMessageId);
LOG.debug("[DeliveryClaim] Marked SENT. deliveryId={}, providerId={}", deliveryId, providerMessageId);
}

/** 일시적 실패. attemptCount 기준 RETRY(backoff) 또는 FAILED. */
@Transactional
public void recordFailure(UUID deliveryId, String reason) {
NotificationDelivery delivery = deliveryRepository.findById(deliveryId).orElse(null);
if (delivery == null) {
LOG.warn("[DeliveryClaim] Delivery not found for recordFailure. deliveryId={}", deliveryId);
return;
}
delivery.recordFailure(reason);
LOG.warn("[DeliveryClaim] Recorded failure. deliveryId={}, attempt={}, newStatus={}",
deliveryId, delivery.getAttemptCount(), delivery.getStatus());
}

/** 영구 실패(잘못된 토큰, 미존재 이메일 등). 재시도 불가. */
@Transactional
public void markPermanentlyFailed(UUID deliveryId, String reason) {
NotificationDelivery delivery = deliveryRepository.findById(deliveryId).orElse(null);
if (delivery == null) {
LOG.warn("[DeliveryClaim] Delivery not found for permanent failure. deliveryId={}", deliveryId);
return;
}
delivery.markAsPermanentlyFailed(reason);
LOG.warn("[DeliveryClaim] Marked PERMANENTLY FAILED. deliveryId={}, reason={}", deliveryId, reason);
}
}
Comment on lines +50 to +85
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

markSent, recordFailure, markPermanentlyFailed 세 메서드에서 deliveryRepository.findById(...)로 엔티티를 조회하고 null 체크를 하는 로직이 중복되고 있습니다. 코드 유지보수성을 높이기 위해 이 중복 로직을 별도의 private 헬퍼 메서드로 추출하는 것을 고려해볼 수 있습니다.

예를 들어, 다음과 같이 Consumer<NotificationDelivery>를 인자로 받는 헬퍼 메서드를 만들어 사용할 수 있습니다.

private void updateDelivery(UUID deliveryId, String action, Consumer<NotificationDelivery> updater) {
    NotificationDelivery delivery = deliveryRepository.findById(deliveryId).orElse(null);
    if (delivery == null) {
        LOG.warn("[DeliveryClaim] Delivery not found for {}. deliveryId={}", action, deliveryId);
        return;
    }
    updater.accept(delivery);
}

@Transactional
public void markSent(UUID deliveryId, String providerMessageId) {
    updateDelivery(deliveryId, "markSent", delivery -> {
        delivery.markAsSent(providerMessageId);
        LOG.debug("[DeliveryClaim] Marked SENT. deliveryId={}, providerId={}", deliveryId, providerMessageId);
    });
}

// recordFailure, markPermanentlyFailed도 유사하게 변경

This file was deleted.

Loading
Loading