Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
41 changes: 39 additions & 2 deletions docs/TESTING_GUIDELINES.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,14 +336,21 @@ void 책_평점_계산이_올바르게_동작한다() {
- **최소 데이터**: 테스트에 필요한 최소한의 속성만 설정

### 3. 테스트 독립성

테스트는 실행 순서에 의존하지 않아야 하며, 이전 테스트의 상태가 남아있지 않도록 격리해야 합니다.

```java
@BeforeEach
void setUp() {
// 각 테스트마다 깨끗한 상태로 시작
databaseCleaner.clear();

// 1. CircuitBreakerReset: 이전 테스트의 영향(Open/Half-Open) 제거
// 핵심: 이전 테스트의 영향도를 삭제하고 '깨끗한 상태'에서 시작
circuitBreakerRegistry.circuitBreaker("fcm").transitionToClosedState();
}
```

> **Note**: DB 초기화(`databaseCleaner.clear()`)는 `@IntegrationTest` 및 `@CustomRepositoryTest`에 등록된 `CleanDatabaseBeforeEach` 확장에 의해 **자동으로 수행**되므로 별도로 호출할 필요가 없습니다. 또한, 이때 `BookCategory` 테이블의 **ROOT, NULL** 엔티티와 같은 필수 기초 데이터도 자동으로 복구됩니다.

### 4. Fixture를 활용한 의미있는 테스트 데이터

Fixture는 4가지 메서드 패턴을 제공하며, 상황에 맞게 선택하여 사용합니다:
Expand Down Expand Up @@ -417,6 +424,36 @@ Member member = new Member("user123", "[email protected]");
- **도메인 규칙 보장**: 모든 메서드가 유효한 도메인 객체 생성
- **테스트 격리**: 각 테스트마다 독립적인 랜덤 데이터 사용
- **최소 속성 원칙**: builder 사용 시 테스트에 꼭 필요한 속성만 명시적으로 설정하고, 나머지는 Fixture 기본값 사용
427:
428: ### 5. 시간 의존성 테스트
429:
430: `LocalDateTime.now()` 등을 내부에서 직접 호출하면 미래/과거 시점 테스트가 어렵습니다. 시간을 파라미터로 받거나 `Clock` Bean을 사용하여 테스트 가능성을 높이세요.
431:
432: ```java
433: // ❌ 테스트하기 어려움 - 내부에서 현재 시간 고정
434: public void processExpired() {
435: LocalDateTime now = LocalDateTime.now(); // 테스트에서 제어 불가
436: // ...
437: }
438:
439: // ✅ 테스트하기 쉬움 - 시간을 파라미터로 주입
440: public void processExpired(LocalDateTime currentTime) {
441: // currentTime 기준으로 로직 수행
442: }
443:
444: // ✅ 테스트 코드 예시
445: @Test
446: void 만료된_항목을_처리한다() {
447: // given: 1시간 뒤 미래 시간을 주입하여 만료 조건 충족
448: LocalDateTime futureTime = LocalDateTime.now().plusHours(1);
449:
450: // when
451: service.processExpired(futureTime);
452:
453: // then
454: // ...
455: }
456: ```

## 🚫 안티패턴

Expand Down
5 changes: 3 additions & 2 deletions src/main/java/book/book/common/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,9 @@ public enum ErrorCode {
// N0xx: 알림 관련 예외
NOT_FOUND_FCMTOKEN("N000", "본 유저의 fcm토큰이 저장되어 있지 않습니다.", HttpStatus.NOT_FOUND),
NOTIFICATION_NOT_FOUND("N001", "알림을 찾을 수 없습니다.", HttpStatus.NOT_FOUND),
NOTIFICATION_METADATA_CONVERSION_ERROR("N002", "알림 메타데이터 JSON 변환에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR),
FCM_SEND_ERROR("N003", "FCM 알림 전송 중 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR),
NOTIFICATION_LOG_NOT_FOUND("N002", "알림 전송 로그를 찾을 수 없습니다.", HttpStatus.NOT_FOUND),
NOTIFICATION_METADATA_CONVERSION_ERROR("N003", "알림 메타데이터 JSON 변환에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR),
FCM_SEND_ERROR("N004", "FCM 알림 전송 중 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR),

// B10xx: follow 예외
FOLLOW_AREADY_EXIST("B1001", "이미 팔로우 한 유저입니다.", HttpStatus.CONFLICT),
Expand Down
53 changes: 53 additions & 0 deletions src/main/java/book/book/config/Resilience4jConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package book.book.config;

import book.book.notification.exception.FcmInvalidTokenException;
import book.book.notification.exception.FcmRetryableException;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import io.github.resilience4j.core.IntervalFunction;
import io.github.resilience4j.retry.RetryConfig;
import io.github.resilience4j.retry.RetryRegistry;
import java.io.IOException;
import java.time.Duration;
import java.util.Map;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class Resilience4jConfig {

@Bean
public RetryRegistry retryRegistry() {
RetryConfig fcmRetryConfig = RetryConfig.custom()
.maxAttempts(4) // 첫 시도 포함 총 4회 (재시도 3회)
.intervalFunction(IntervalFunction.ofExponentialBackoff(
Duration.ofSeconds(1), // 초기 1초
2.0 // 1s -> 2s -> 4s
))
.retryExceptions(FcmRetryableException.class, IOException.class)
.ignoreExceptions(FcmInvalidTokenException.class)
.build();

return RetryRegistry.of(Map.of("fcm", fcmRetryConfig));
}

@Bean
public CircuitBreakerRegistry circuitBreakerRegistry() {
CircuitBreakerConfig fcmCbConfig = CircuitBreakerConfig
.custom()
.failureRateThreshold(50) // 실패율 50% 이상 시 서킷 오픈
.slowCallRateThreshold(100)
.slowCallDurationThreshold(Duration.ofSeconds(2)) // 2초 이상 걸리면 느린 호출로 간주
.permittedNumberOfCallsInHalfOpenState(3)
.maxWaitDurationInHalfOpenState(Duration.ofSeconds(10))
.slidingWindowSize(10) // 최근 10개의 호출을 통계로 사용
.minimumNumberOfCalls(5) // 최소 5번은 호출된 후 통계 계산
.waitDurationInOpenState(Duration.ofSeconds(30)) // 서킷 오픈 후 30초 대기
// 서킷 브레이커가 전파받을 예외 설정 (Retry에서 걸러진 것들이 여기까지 옴)
.recordExceptions(FcmRetryableException.class, IOException.class)
.ignoreExceptions(FcmInvalidTokenException.class)
.build();

return CircuitBreakerRegistry.of(Map.of("fcm", fcmCbConfig));
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
package book.book.notification;
package book.book.notification.api;

import book.book.common.response.ResponseForm;
import book.book.notification.dto.NotificationMessage;
import book.book.notification.dto.NotificationDto;
import book.book.notification.service.FCMService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
Expand All @@ -22,9 +21,9 @@ public class NotificationTestApi {

@PostMapping("/notification/test")
@Operation(summary = "알림 테스트용도 API")
public ResponseForm<Void> sendNotification(@RequestBody NotificationMessage message) {
public ResponseForm<Void> sendNotification(@RequestBody NotificationDto message) {
log.info("알림 전달 메시지 : {}", message);
fcmService.send(message);
return ResponseForm.ok();
return ResponseForm.ok();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
*/
@RequiredArgsConstructor
public enum FCMSendStatus {
PENDING("전송 대기"), // FCM 전송 대기 중
SENT("전송 성공"), // FCM 전송 성공
FAILED("전송 실패"); // FCM 전송 실패
PENDING("전송 대기"), // FCM 전송 대기 중
SENT("전송 성공"), // FCM 전송 성공
FAILED("전송 실패"), // FCM 전송 실패 (재시도 가능)
FAILED_NO_RETRY("전송 실패(재시도 불가)"), // FCM 전송 실패 (재시도 불가, 예: Invalid Token)
RETRY_LIMIT_EXCEEDED("재시도 횟수 초과"); // 재시도 횟수 초과로 인한 실패

private final String description;
}
20 changes: 1 addition & 19 deletions src/main/java/book/book/notification/domain/Notification.java
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,6 @@ public class Notification extends BaseTimeEntity {
@Column(nullable = false)
private boolean isRead = false;

/**
* FCM 전송 상태
*/
@Enumerated(EnumType.STRING)
@Builder.Default
@Column(nullable = false)
private FCMSendStatus fcmSendStatus = FCMSendStatus.PENDING;

/**
* 관련 리소스 정보를 JSON 형식으로 저장
* 프론트엔드에서 라우팅에 필요한 모든 정보 포함
Expand All @@ -76,18 +68,8 @@ public class Notification extends BaseTimeEntity {
@Column(columnDefinition = "TEXT")
private String metadata;

/**
* 알림 읽음 처리
*/
public void markAsRead() {
this.isRead = true;
}

public void markAsSent() {
this.fcmSendStatus = FCMSendStatus.SENT;
}

public void markAsFailed() {
this.fcmSendStatus = FCMSendStatus.FAILED;
}
}

103 changes: 103 additions & 0 deletions src/main/java/book/book/notification/domain/NotificationDeviceLog.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package book.book.notification.domain;

import book.book.common.BaseTimeEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Table(name = "notification_device_log")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class NotificationDeviceLog extends BaseTimeEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "notification_id", nullable = false)
private Notification notification;

/**
* 알림 발송 대상 FCM 토큰의 ID (토큰 삭제 시 추적을 위해 저장, FK 제약조건은 없을 수 있음)
*/
@Column(nullable = false, unique = true)
private Long fcmTokenId;
Comment on lines +37 to +38
Copy link
Contributor

Choose a reason for hiding this comment

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

critical

fcmTokenId 필드에 설정된 unique = true 제약 조건은 잘못된 것으로 보입니다. 이 제약 조건은 각 FCM 토큰이 notification_device_log 테이블에 단 하나의 항목만 가질 수 있음을 의미합니다. 하지만, 단일 기기(즉, 단일 FCM 토큰)는 시간이 지남에 따라 여러 알림을 수신할 수 있습니다. 만약 사용자가 두 개의 다른 알림을 받게 되면, 동일한 기기에 대한 두 번째 알림 로그를 저장하려 할 때 DataIntegrityViolationException이 발생할 것입니다.

고유 제약 조건은 특정 알림 이벤트가 특정 기기에 한 번만 전송되도록 보장하기 위해 notification_idfcm_token_id의 조합에 대해 설정되어야 합니다. 다음과 같이 복합 고유 제약 조건으로 변경하는 것을 고려해 보세요.

@Table(name = "notification_device_log", uniqueConstraints = {
    @UniqueConstraint(columnNames = {"notification_id", "fcmTokenId"})
})

그리고 @Column(name = "fcmTokenId")에서 unique=true를 제거해야 합니다.


/**
* 기기 타입 (예: IOS, ANDROID, WEB)
* 토큰이 삭제되어도 어떤 기기로 보냈는지 알 수 있도록 저장
*/
@Column(nullable = false)
private String deviceType;

@Enumerated(EnumType.STRING)
@Column(nullable = false)
private FCMSendStatus status;

@Column(columnDefinition = "TEXT")
private String errorCode;

@Column(nullable = false)
private int retryCount = 0;

@Builder
public NotificationDeviceLog(Notification notification, Long fcmTokenId, String deviceType, FCMSendStatus status,
String errorCode) {
this.notification = notification;
this.fcmTokenId = fcmTokenId;
this.deviceType = deviceType;
this.status = status;
this.errorCode = errorCode;
}

public void updateStatus(FCMSendStatus status, String errorCode) {
this.status = status;
this.errorCode = errorCode;
}

public void incrementRetryCount() {
this.retryCount++;
}

public void succeed() {
this.status = FCMSendStatus.SENT;
this.errorCode = null;
}

public void fail(String errorCode) {
this.status = FCMSendStatus.FAILED;
this.errorCode = errorCode;
}

public void failNoRetry(String errorCode) {
this.status = FCMSendStatus.FAILED_NO_RETRY;
this.errorCode = errorCode;
}

public void failRetryLimitExceeded() {
this.status = FCMSendStatus.RETRY_LIMIT_EXCEEDED;
}

public static NotificationDeviceLog of(Notification notification, FCMToken token) {
return NotificationDeviceLog.builder()
.notification(notification)
.fcmTokenId(token.getId())
.deviceType(token.getDeviceType().name())
.status(FCMSendStatus.PENDING)
.build();
}
}
14 changes: 14 additions & 0 deletions src/main/java/book/book/notification/dto/FcmSendResult.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package book.book.notification.dto;

public record FcmSendResult(
int successCount,
int totalCount,
String errorLog) {
public boolean isAnySuccess() {
return successCount > 0;
}

public boolean isAllFailed() {
return totalCount > 0 && successCount == 0;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

새로 추가된 FcmSendResult 레코드가 코드베이스 전체에서 사용되지 않는 것으로 보입니다. 만약 가까운 미래에 사용할 계획이 없다면, 코드를 깔끔하게 유지하고 혼동을 피하기 위해 제거하는 것이 좋겠습니다.

46 changes: 46 additions & 0 deletions src/main/java/book/book/notification/dto/NotificationDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package book.book.notification.dto;

import book.book.notification.domain.NotificationType;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
* 가변적이지 않고 중간에 필드값이 추가됨
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder(toBuilder = true)
public class NotificationDto {
private Long notificationId; // DB에 저장된 알림 ID (FCM 전송용)
private Long receiverId;
private Long actorId; // 알림을 발생시킨 사용자 ID (시스템 알림의 경우 null)
private String title;
private String content;
private NotificationType notificationType;

/**
* 알림 관련 리소스 정보 (프론트엔드 라우팅용)
* 예시:
* - 댓글: {"commentId": 456, "diaryId": 123}
* - 좋아요: {"diaryId": 123}
* - 퀴즈 완성: {"quizId": 789, "bookId": 321}
*/
private String metadata; // JSON String

// 내부 로직용 필드 (DB 저장 및 전송 시 활용)
private Long notificationDeviceLogId; // 전송 로그 ID (상태 업데이트용)
private Long fcmTokenId; // FCM 토큰 ID (전송 대상 조회용)
private String fcmToken; // FCM 토큰 값 (실제 전송용)

public NotificationDto withDetails(Long notificationId, Long deviceLogId, Long tokenId, String tokenValue) {
return this.toBuilder()
.notificationId(notificationId)
.notificationDeviceLogId(deviceLogId)
.fcmTokenId(tokenId)
.fcmToken(tokenValue)
.build();
}
}
Loading
Loading