Skip to content

Conversation

@dungbik
Copy link
Contributor

@dungbik dungbik commented Aug 14, 2025

📝 변경 내용


✅ 체크리스트

  • 코드가 정상적으로 동작함
  • 테스트 코드 통과함
  • 문서(README 등)를 최신화함
  • 코드 스타일 가이드 준수

💬 기타 참고 사항

Summary by CodeRabbit

  • 신기능
    • 알림 시스템 도입: 그룹 초대 시 알림 생성, DB 저장 및 FCM 푸시 발송, 이벤트 발행 및 비동기/재시도 리스너 추가
    • 알림 API 추가: 알림 목록(커서 기반), FCM 토큰 등록, 여러 알림 읽음 처리
  • 개선/리팩터링
    • 공용 모델 패키지 정리 및 커서 기반 페이지/매핑 유틸 추가
    • Map↔JSON 변환 자동화 지원 (영속화 변환기)
  • 문서
    • 알림 API 스웨거 문서 추가
  • 설정/작업
    • Firebase Admin, Apache Commons Text 의존성 추가 및 firebase.config-path 설정
    • 다국어 리소스에 알림 메시지 키 추가

@dungbik dungbik requested a review from stoneTiger0912 August 14, 2025 17:38
@dungbik dungbik self-assigned this Aug 14, 2025
@dungbik dungbik added the enhancement New feature or request label Aug 14, 2025
@coderabbitai
Copy link

coderabbitai bot commented Aug 14, 2025

Walkthrough

공통 모델 패키지 재배치 및 import 갱신, 그룹 초대 시 이벤트 발행과 트랜잭션 후(After-commit) 비동기 리스너로 알림 발송 도입, FCM 연동(FirebaseService 및 FcmErrorCode)과 Notification 도메인(엔티티/레포지토리/서비스/컨트롤러/DTO) 추가, Map↔JSON JPA 컨버터 및 커서 기반 DTO/응답 추가, Gradle 의존성 추가가 포함된 변경입니다.

Changes

Cohort / File(s) Change Summary
의존성 변경
build.gradle
Apache Commons Text(org.apache.commons:commons-text:1.14.0) 및 Firebase Admin(com.google.firebase:firebase-admin:9.5.0) implementation 의존성 추가.
공통 모델 패키지 이동 및 import 갱신
project/flipnote/common/model/...
src/main/java/project/flipnote/common/model/event/*, .../model/request/*, .../model/response/*, 관련 참조 파일들 (auth/*, group/*, user/service/*, common/security/*, common/exception/*)
여러 이벤트/요청/응답 타입을 project.flipnote.common.model.* 하위로 이동 또는 신규 추가하고 전역 import 경로를 갱신 (예: UserRegisteredEvent, UserWithdrawnEvent, UserCreateCommand, ApiResponse 등).
JPA Map↔JSON 컨버터 추가
src/main/java/project/flipnote/common/entity/MapToJsonConverter.java
Jackson ObjectMapper 주입받아 AttributeConverter<Map<String,Object>, String> 구현한 MapToJsonConverter 추가 (@Converter 및 Spring @Component으로 빈 등록; diff에선 autoApply 미표시). null/빈 맵 처리 및 예외 변환 로직 포함.
그룹 초대 이벤트 발행 및 리스너 추가
src/main/java/project/flipnote/group/service/GroupInvitationService.java, src/main/java/project/flipnote/notification/listener/GroupInvitationCreateEventListener.java, src/main/java/project/flipnote/common/model/event/GroupInvitationCreatedEvent.java
초대 생성 시 ApplicationEventPublisherGroupInvitationCreatedEvent 발행; 트랜잭션 AFTER_COMMIT, 비동기 및 재시도 가능한 리스너에서 NotificationService로 알림 전송 트리거 추가.
그룹 조회 API 보완
src/main/java/project/flipnote/group/repository/GroupRepository.java, src/main/java/project/flipnote/group/service/GroupService.java
그룹명만 조회하는 Optional<String> findGroupNameById(Long id) 리포지토리 메서드 추가 및 이를 이용한 public String findGroupName(Long groupId) 서비스 메서드 추가(존재하지 않으면 BizException).
그룹 초대 상태 접근 방식 변경
src/main/java/project/flipnote/group/model/GroupInvitationStatus.java, .../IncomingGroupInvitationResponse.java, .../OutgoingGroupInvitationResponse.java
상태 파생 로직을 enum 값 전달 방식에서 GroupInvitation 엔티티 전달 방식으로 변경(관련 메서드 시그니처/호출부 업데이트).
Notification 도메인 추가
src/main/java/project/flipnote/notification/entity/*, .../repository/*, .../service/NotificationService.java, .../controller/*, .../model/*, .../docs/*
Notification 및 FcmToken 엔티티, 리포지토리(조회·bulk 업데이트·삭제 등), NotificationService(커서 페이지 조회, FCM 토큰 등록/정리, 전송/에러 매핑), 컨트롤러 및 OpenAPI 문서 인터페이스, DTO(TokenRegisterRequest, NotificationListRequest, NotificationResponse, MarkNotificationsAsReadRequest 등) 추가.
Firebase 연동 추가
src/main/java/project/flipnote/infra/firebase/FirebaseService.java, src/main/java/project/flipnote/infra/firebase/FcmErrorCode.java, src/main/resources/application.yml
Firebase 앱 초기화(@PostConstruct, config-path 프로퍼티 사용) 및 멀티캐스트 전송 메서드 추가, FCM 오류 코드 매핑 enum 추가, firebase.config-path 프로퍼티 추가.
보안/예외 처리 import 정리
src/main/java/project/flipnote/common/security/*, src/main/java/project/flipnote/common/exception/GlobalExceptionHandler.java
ApiResponse 등 패키지 경로 변경에 따른 import 수정.
국제화 메시지 추가
src/main/resources/messages.properties
notification.group.invite=${groupName} 그룹에 초대되셨습니다. 항목 추가.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor User
  participant GroupSvc as GroupInvitationService
  participant Repo as GroupInvitationRepository
  participant EVT as ApplicationEventPublisher
  participant Listener as GroupInvitationCreateEventListener
  participant NotiSvc as NotificationService
  participant GroupRepoSvc as GroupService
  participant Firebase as FirebaseService

  User->>GroupSvc: inviteUserToGroup(groupId, inviteeId)
  GroupSvc->>Repo: save(invitation)
  GroupSvc->>EVT: publish(GroupInvitationCreatedEvent)
  EVT-->>Listener: event (AFTER_COMMIT, async, retryable)
  Listener->>NotiSvc: sendGroupInvite(groupId, inviteeId)
  NotiSvc->>GroupRepoSvc: findGroupName(groupId)
  GroupRepoSvc-->>NotiSvc: groupName
  NotiSvc->>Firebase: sendEachForMulticast(tokens, title, body)
  Firebase-->>NotiSvc: BatchResponse
  NotiSvc->>NotiSvc: delete invalid tokens / update lastUsedAt
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • stoneTiger0912

Poem

폴짝, 초대 한 통 던졌네, 당근 소식 실려와!
이벤트는 펄쩍, 리스너는 폴짝폴짝,
그룹 이름 담아 메시지 톡, 토큰은 쓱쓱 정리—
FCM 구름길 타고 알림이 쓩쓩,
코드밭에 당근 한 줌, 오늘도 배포 축하해! 🥕✨

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/notification

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 26

🔭 Outside diff range comments (1)
src/main/java/project/flipnote/common/exception/GlobalExceptionHandler.java (1)

50-56: 예외 응답 포맷 일관성 깨짐: String 바디 대신 ApiResponse로 반환하세요

현재 MissingServletRequestParameterException 처리만 String 바디를 반환하고 있어, 다른 핸들러의 ApiResponse.error(...)와 응답 구조가 달라집니다. 또한 ApiResponseAdvice가 개입될 경우 400 상태임에도 success 형태로 감싸질 수 있어(상태만 400, code/message 비어있는 구조) 클라이언트 측 파싱 혼선을 야기합니다. 동일한 에러 응답 포맷으로 통일하는 것이 바람직합니다.

아래처럼 ApiResponse를 직접 빌드해 동적 메시지(누락 파라미터명 포함)를 유지하면서도 에러 포맷을 일관화하세요.

-	@ExceptionHandler(MissingServletRequestParameterException.class)
-	public ResponseEntity<String> handleMissingServletRequestParameter(
-		MissingServletRequestParameterException exception) {
-		String missingParam = exception.getParameterName();
-		String message = String.format("필수 파라미터 '%s'가 없습니다.", missingParam);
-		return ResponseEntity.badRequest().body(message);
-	}
+	@ExceptionHandler(MissingServletRequestParameterException.class)
+	public ResponseEntity<ApiResponse<Void>> handleMissingServletRequestParameter(
+		MissingServletRequestParameterException exception) {
+		String missingParam = exception.getParameterName();
+		String message = String.format("필수 파라미터 '%s'가 없습니다.", missingParam);
+		return ResponseEntity.badRequest().body(
+			ApiResponse.<Void>builder()
+				.status(CommonErrorCode.INVALID_INPUT_VALUE.getStatus()) // 통일된 에러 상태 사용
+				.code(CommonErrorCode.INVALID_INPUT_VALUE.getCode())
+				.message(message) // 동적 메시지 유지
+				.build()
+		);
+	}

또한 같은 범주의 바인딩/검증 오류로 분류될 수 있는 MethodArgumentTypeMismatchException, ConstraintViolationException 등에 대해서도 동일 포맷으로의 핸들러 추가를 권장합니다.

🧹 Nitpick comments (29)
src/main/java/project/flipnote/common/model/response/ApiResponse.java (1)

1-1: 패키지 이동은 적절합니다. 성공 응답의 null 필드 직렬화 제거를 권장합니다.
성공 응답에서 code/message가 null인 경우가 많으므로, Jackson 설정이 기본이면 불필요한 null 필드가 노출됩니다. 응답 페이로드를 깔끔히 유지하려면 null 필드 제외를 권장합니다.

아래와 같이 Jackson 애노테이션을 추가해 주세요.

import com.fasterxml.jackson.annotation.JsonInclude;

@JsonInclude(JsonInclude.Include.NON_NULL)
@Getter
@Builder
public class ApiResponse<T> {
    ...
}
build.gradle (1)

43-44: 검증 필요 — Firebase Admin / commons-text 전이(트랜지티브) 의존성 충돌 확인 요청

commons-text 1.14.0은 Text4Shell(CVE-2022-42889) 패치 버전으로 적절합니다. 다만 com.google.firebase:firebase-admin:9.5.0이 guava / protobuf / google-http-client 등 전이 의존성을 끌어오므로 Spring Boot BOM(예: 3.5.x)과 버전 충돌 여부를 런타임 의존성 그래프로 확인해야 합니다. 제가 ./gradlew로 검사 시도했으나 환경 문제로 실패했습니다:

ERROR: JAVA_HOME is set to an invalid directory: /usr/lib/jvm/java-17-openjdk-amd64

검증 요청(프로젝트 루트에서 실행 후 결과 붙여 주세요):

  • ./gradlew :dependencies --configuration runtimeClasspath
  • ./gradlew dependencyInsight --configuration runtimeClasspath --dependency com.google.guava:guava
  • ./gradlew dependencyInsight --configuration runtimeClasspath --dependency com.google.protobuf:protobuf-java
  • ./gradlew dependencyInsight --configuration runtimeClasspath --dependency com.google.http-client:google-http-client

추가 정보: 사용 중인 Spring Boot 버전(BOM)을 알려주시면 더 정확히 판단할 수 있습니다.

검토 대상 위치:

  • 파일: build.gradle — 줄 43-44
    implementation 'org.apache.commons:commons-text:1.14.0'
    implementation 'com.google.firebase:firebase-admin:9.5.0'
    

권장(선택): 설정 메타정보와 IDE 자동완성을 위해 configuration-processor 추가

dependencies {
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
}
src/main/java/project/flipnote/common/model/request/UserCreateCommand.java (1)

1-1: 네이밍/패키지 정합성 제안: request 패키지에 Command 타입 혼재

타입명이 UserCreateCommand인데 패키지가 model.request입니다. 팀 컨벤션에 따라 다음 중 하나로 통일을 제안합니다.

  • 패키지: project.flipnote.common.model.command로 이동
  • 또는 타입명: UserCreateRequest로 변경
src/main/java/project/flipnote/auth/service/AuthService.java (1)

37-39: 회원가입 이벤트 발행 타이밍/신뢰성 아키텍처 점검 제안

  • 트랜잭션 내 즉시 publish라 리스너가 동기면 커밋 전 실행될 수 있습니다. AFTER_COMMIT 리스너(@TransactionalEventListener) 사용 여부를 확인하세요.
  • 외부 알림(이메일/FCM)과 연계된다면 Outbox 패턴(트랜잭션 일관성 + 재시도/중복 허용) 도입을 고려해 보세요.
  • 리스너가 비동기/재시도 가능(백오프/데드레터)하게 구성되어 있는지 점검 필요.

Also applies to: 81-82

src/main/java/project/flipnote/common/model/event/GroupInvitationCreatedEvent.java (1)

5-5: 이름 일관성 제안: inviteeId → inviteeUserId로 통일

GroupInvitation 엔티티(파일: src/main/java/project/flipnote/group/entity/GroupInvitation.java)의 필드명은 inviteeUserId입니다. 이벤트 필드도 동일한 네이밍으로 맞추면 도메인 전반의 가독성과 추론성이 좋아집니다. 변경 시 퍼블리셔/리스너에서의 접근자(event.inviteeId())도 함께 업데이트가 필요합니다.

적용 예:

 public record GroupInvitationCreatedEvent(
 	Long groupId,
-	Long inviteeId
+	Long inviteeUserId
 ) {
 }
src/main/java/project/flipnote/common/model/request/CursorPageRequest.java (1)

3-9: Optional: import 보강 안내

@pattern 사용을 위해 다음 import가 필요합니다.

 import org.springframework.util.StringUtils;
 
 import jakarta.validation.constraints.Max;
 import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.Pattern;
 import lombok.Getter;
 import lombok.Setter;
src/main/java/project/flipnote/group/listener/UserRegisteredEventListener.java (1)

24-27: Retry 범위 축소 제안: 일시적 예외만 재시도

현재는 기본값(checked/unchecked Exception 전반)으로 재시도됩니다. 비즈니스 예외까지 재시도되지 않도록 일시적 예외(예: TransientDataAccessException, 외부 I/O 계열)로 범위를 좁히는 것을 권장합니다.

예시 수정:

 	@Retryable(
 		maxAttempts = 3,
-		backoff = @Backoff(delay = 2000, multiplier = 2)
+		retryFor = { org.springframework.dao.TransientDataAccessException.class },
+		backoff = @Backoff(delay = 2000, multiplier = 2)
 	)

필요 시 외부 연동 예외(예: FCM/이메일 전송)도 추가하세요.

src/main/java/project/flipnote/group/controller/GroupInvitationQueryController.java (1)

29-31: 페이지네이션 파라미터 유효성 검증 추가 제안

page/size에 대한 제약이 없어 음수/0 등의 값이 들어올 수 있습니다. 요청 파라미터 수준에서 즉시 400을 주는 편이 안전합니다.

아래와 같이 제약을 추가하는 것을 권장합니다.

-    @RequestParam(defaultValue = "0") int page,
-    @RequestParam(defaultValue = "20") int size,
+    @RequestParam(defaultValue = "0") @Min(0) int page,
+    @RequestParam(defaultValue = "20") @Min(1) int size,
-    @RequestParam(defaultValue = "0") int page,
-    @RequestParam(defaultValue = "20") int size,
+    @RequestParam(defaultValue = "0") @Min(0) int page,
+    @RequestParam(defaultValue = "20") @Min(1) int size,

추가로, @RequestParam 검증이 동작하려면 컨트롤러 클래스에 @validated가 필요합니다. 클래스 선언부에 아래 애노테이션을 추가해주세요.

// 필요한 import
import org.springframework.validation.annotation.Validated;

@Validated

필요한 import:

  • jakarta.validation.constraints.Min

Also applies to: 41-43

src/main/java/project/flipnote/common/security/filter/ExceptionHandlerFilter.java (2)

28-33: 보안 예외 처리 시 로깅 보강 제안

운영 관점에서 거부 사유 추적을 위해 최소한의 경고 로그를 남기는 것을 권장합니다. 민감정보 노출은 피하세요.

-        } catch (CustomSecurityException ex) {
-            setErrorResponse(response, ex);
-        }
+        } catch (CustomSecurityException ex) {
+            log.warn("Security exception handled: code={}, status={}, message={}",
+                ex.getErrorCode().getCode(), ex.getErrorCode().getStatus(), ex.getErrorCode().getMessage());
+            setErrorResponse(response, ex);
+        }

35-42: ObjectMapper 매번 생성 → 빈 주입으로 일관성과 성능 개선

메서드 내 new ObjectMapper()는 성능/설정 일관성 측면에서 비권장입니다. 스프링 빈(ObjectMapper)을 주입받아 사용하세요. 또한 이미 커밋된 응답에 쓰기를 시도하지 않도록 가드 추가를 권장합니다.

-    ApiResponse<Void> errorResponse = ApiResponse.error(ex.getErrorCode());
-
-    response.getWriter().write(new ObjectMapper().writeValueAsString(errorResponse));
+    if (response.isCommitted()) {
+        return;
+    }
+    ApiResponse<Void> errorResponse = ApiResponse.error(ex.getErrorCode());
+    response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
+    response.getWriter().flush();

클래스 필드/애노테이션 추가(선택):

// import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Component
public class ExceptionHandlerFilter extends OncePerRequestFilter {

    private final ObjectMapper objectMapper;
    ...
}
src/main/java/project/flipnote/group/model/OutgoingGroupInvitationResponse.java (1)

21-31: 응답 내 이메일(PII) 노출 재검토

inviteeEmail을 그대로 노출하고 있습니다. 초대한 사용자에게 노출이 허용된 데이터인지 기획/보안 정책 확인이 필요합니다. 필요 시 마스킹/제거를 고려하세요.

예) 간단 마스킹 전략

  • local-part 일부만 노출: ab****@domain.com
  • 도메인만 노출: ****@domain.com
src/main/java/project/flipnote/notification/entity/Notification.java (4)

36-38: Enum 문자열 컬럼 길이 20은 향후 확장 시 스키마 충돌 가능

현재 상수명은 짧지만, 새로운 타입 추가 시 이름 길이 제한(20)으로 마이그레이션 이슈가 생길 수 있습니다. 여유 있게 늘리거나 길이 제약을 제거하는 것을 권장합니다.

아래처럼 길이를 늘리는 변경을 제안합니다.

 	@Enumerated(EnumType.STRING)
-	@Column(nullable = false, length = 20)
+	@Column(nullable = false, length = 50)
 	private NotificationType type;

46-49: 필드 접근 제어자 보강

JPA 필드는 기본적으로 private로 두는 것이 캡슐화/일관성 측면에서 안전합니다.

 	@Column(name = "is_read", nullable = false)
-	boolean read;
+	private boolean read;

-	LocalDateTime readAt;
+	private LocalDateTime readAt;

25-27: 조회용 인덱스 추가 제안: (receiverId, id) 조합

Repository에서 receiverId 조건 + id DESC 정렬 + 커서(id < X)를 사용합니다. 다음 인덱스가 있으면 성능이 크게 개선됩니다.

다음과 같이 테이블 인덱스를 정의하는 것을 권장합니다.

-@Table(name = "notifications")
+@Table(
+	name = "notifications",
+	indexes = {
+		@Index(name = "idx_notifications_receiver_id", columnList = "receiver_id"),
+		@Index(name = "idx_notifications_receiver_id_id", columnList = "receiver_id, id")
+	}
+)

또한 receiverId 컬럼명을 명시해 인덱스와 일치시키세요:

-	@Column(nullable = false)
+	@Column(name = "receiver_id", nullable = false)
 	private Long receiverId;

추가로, 위 변경에는 import가 필요합니다:

  • jakarta.persistence.Index

62-67: 시간 주입(Clock) 사용 시 테스트 용이성과 일관성 향상

LocalDateTime.now() 대신 Clock을 주입받아 사용하면 테스트와 타임존 일관성이 좋아집니다. 우선순위는 낮지만 추후 서비스 레이어에서 시간을 주입하거나, 정적 Clock을 제공하는 유틸을 도입하는 것을 고려해 주세요.

src/main/java/project/flipnote/notification/repository/NotificationRepository.java (1)

14-19: 커서 기반 조회 쿼리 적절. 인덱스와 함께 사용 권장

쿼리 형태(id < :cursor, receiverId = :receiverId, ORDER BY id DESC)는 커서 페이지네이션에 적합합니다. Notification 엔티티 테이블에 (receiver_id), (receiver_id, id) 인덱스를 추가하면 대량 데이터에서도 안정적 성능을 기대할 수 있습니다. 또한 Pageable의 정렬은 쿼리의 ORDER BY와 충돌할 수 있으니, 서비스 단에서는 Sort.unsorted()를 사용하는 것을 추천합니다.

src/main/java/project/flipnote/notification/entity/FcmToken.java (1)

46-55: 시간 생성 시 시스템 로캘에 종속됩니다. UTC/테스트 용이성을 위해 Clock 주입을 고려하세요.

LocalDateTime.now()는 서버의 시스템 타임존에 의존합니다. 운영/개발 환경이 섞이면 시간 비교/정렬/만료 로직에서 미묘한 버그가 날 수 있습니다. UTC 기준으로 고정하고 테스트 가능성을 높이기 위해 Clock을 사용하세요.

다음과 같이 UTC Clock을 사용하도록 변경 제안드립니다:

+import java.time.Clock;
  import java.time.LocalDateTime;
@@
  @Builder
  public FcmToken(Long userId, String token) {
    this.userId = userId;
    this.token = token;
-   this.lastUsedAt = LocalDateTime.now();
+   this.lastUsedAt = LocalDateTime.now(Clock.systemUTC());
  }

  public void updateLastUsedAt() {
-   this.lastUsedAt = LocalDateTime.now();
+   this.lastUsedAt = LocalDateTime.now(Clock.systemUTC());
  }
src/main/java/project/flipnote/notification/listener/GroupInvitationCreateEventListener.java (1)

33-36: 실패 복구 시 내구성 있는 보관소로 이동(DLQ/아웃박스) 등을 고려하세요.

로그만 남기면 재처리 불가합니다. 실패 이벤트를 별도 테이블/큐(DLQ)에 적재하고 운영 도구로 재처리할 수 있게 설계하면 안정성이 크게 향상됩니다.

원한다면 간단한 아웃박스 엔티티/리포지토리/정기 배치 설계안을 제안드리겠습니다.

src/main/java/project/flipnote/notification/exception/NotificationErrorCode.java (1)

12-14: 에러 메시지 문장부호/톤을 일관화하세요.

다른 코드와 톤/문장부호(마침표) 일치가 필요해 보입니다.

-	FCM_INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "NOTIFICATION_001", "FCM 내부 오류가 발생했습니다"),
+	FCM_INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "NOTIFICATION_001", "FCM 내부 오류가 발생했습니다."),
 	FCM_SERVER_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "NOTIFICATION_002", "FCM 서버를 사용할 수 없습니다.");
src/main/java/project/flipnote/common/entity/MapToJsonConverter.java (1)

26-27: 예외 타입을 IllegalStateException으로 통일해 컨버터 내부 오류임을 명확히 하세요.

다른 컨버터(AES)와의 일관성 및 의미 측면에서 IllegalStateException이 더 적합합니다.

-		} catch (JsonProcessingException ex) {
-			throw new IllegalArgumentException("JSON 변환 실패", ex);
+		} catch (JsonProcessingException ex) {
+			throw new IllegalStateException("JSON 변환 실패", ex);
 		}
@@
-		} catch (IOException ex) {
-			throw new IllegalArgumentException("JSON 파싱 실패", ex);
+		} catch (IOException ex) {
+			throw new IllegalStateException("JSON 파싱 실패", ex);
 		}

Also applies to: 37-39

src/main/java/project/flipnote/notification/model/NotificationResponse.java (2)

23-33: 정적 팩토리 메서드 네이밍을 기존 컨벤션(from)으로 통일 제안

동일 모듈 내 Response DTO들의 정적 팩토리 네이밍이 from(...)으로 일관되어 있습니다(예: SocialLinkResponse.from, GroupCreateResponse.from 등). 본 레코드만 of(...)를 사용하고 있어 소소한 혼선을 줄 수 있습니다. 아래처럼 from(...)으로 맞추는 것을 권장합니다.

- public static NotificationResponse of(Notification notification, String message) {
+ public static NotificationResponse from(Notification notification, String message) {
     return new NotificationResponse(
       notification.getId(),
       message,
       notification.getAdditionalData(),
       notification.isRead(),
       notification.getReadAt(),
       notification.getCreatedAt()
     );
   }

추가로, 사용처(NotificationService)도 함께 변경이 필요합니다. 해당 수정 제안은 NotificationService 코멘트에 별도로 포함했습니다.


6-13: null 필드 직렬화 제외로 응답 깔끔화 제안

읽지 않은 알림의 경우 readAt가 null일 수 있습니다. null 필드를 응답에서 생략하면 클라이언트 처리 및 문서화가 더 깔끔해집니다.

 import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonInclude;

-import project.flipnote.notification.entity.Notification;
+import project.flipnote.notification.entity.Notification;
 
+@JsonInclude(JsonInclude.Include.NON_NULL)
 public record NotificationResponse(
src/main/java/project/flipnote/notification/controller/docs/NotificationControllerDocs.java (1)

3-12: 보안 스키마 문서화 누락: SecurityRequirement 추가 제안

다른 Docs 인터페이스들과 동일하게 액세스 토큰 보안 스키마를 명시해 주세요. 스웨거 문서 일관성과 소비자(클라이언트) 가이드를 위해 필요합니다.

 import org.springframework.http.ResponseEntity;
 
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
+import io.swagger.v3.oas.annotations.security.SecurityRequirement;
 import project.flipnote.common.model.response.CursorPageResponse;
 import project.flipnote.common.security.dto.AuthPrinciple;
 import project.flipnote.notification.model.NotificationListRequest;
 import project.flipnote.notification.model.NotificationResponse;
 import project.flipnote.notification.model.TokenRegisterRequest;
src/main/java/project/flipnote/notification/controller/NotificationController.java (1)

30-39: Locale 전달 고려(선택)

현재 서비스에서 Locale.KOREA를 고정 사용합니다. 사용자별/요청별 로케일 반영이 필요하다면 컨트롤러에서 LocaleContextHolder.getLocale()을 받아 서비스에 전달하도록 확장하는 것을 고려해 주세요.

Also applies to: 41-49

src/main/java/project/flipnote/notification/service/NotificationService.java (5)

61-66: 정적 팩토리 메서드 네이밍 일치 수정

앞서 제안한대로 NotificationResponse.ofNotificationResponse.from으로 통일 시, 본 사용처도 함께 변경되어야 합니다.

-			.map((notification -> {
-				String message = buildMessage(notification, Locale.KOREA);
-				return NotificationResponse.of(notification, message);
-			}))
+			.map(notification -> {
+				String message = buildMessage(notification, Locale.KOREA);
+				return NotificationResponse.from(notification, message);
+			})

71-86: 트랜잭션 내 외부 I/O(FCM 호출) 수행은 위험 — 비동기/아웃박스 고려

DB 트랜잭션 내에서 FCM 호출을 수행하면 지연/실패 시 트랜잭션 지연 또는 롤백 리스크가 있습니다. 아래 중 하나를 권장합니다:

  • Notification 저장까지 트랜잭션 처리 → AFTER_COMMIT 이벤트/큐 기반(예: outbox)으로 FCM 발송 수행
  • 또는 @Async 리스너에서 FCM 발송 처리

도입한 이벤트 흐름을 본 메서드에도 일관 적용하는 것을 추천합니다.


97-103: 토큰 없음 로그 레벨 완화 제안

토큰 미존재는 정상 시나리오일 수 있습니다. WARN 대신 INFO로 낮추는 것이 운영 노이즈를 줄일 수 있습니다.

-			log.warn("No FCM tokens for user {}", userId);
+			log.info("No FCM tokens for user {}", userId);

104-107: FCM 알림 타이틀 하드코딩 제거 및 i18n 적용 제안

현재 타이틀이 "알림"으로 하드코딩되어 있습니다. 메시지 키(예: notification.title)로 MessageSource에서 가져오도록 개선을 권장합니다.

-			BatchResponse response = firebaseService.sendEachForMulticast(tokens, "알림", body);
+			String title = messageSource.getMessage("notification.title", null, Locale.KOREA);
+			BatchResponse response = firebaseService.sendEachForMulticast(tokens, title, body);

추후 사용자 로케일을 반영하려면 Locale 인자를 상위 계층에서 전달받도록 확장해 주세요.


84-86: 로케일 고정(Locale.KOREA) 사용 — 사용자/요청 로케일 반영 고려

메시지 렌더링과 FCM 타이틀 모두 Locale.KOREA로 고정되어 있습니다. 다국어/해외 사용자 고려 시, LocaleContextHolder.getLocale() 또는 사용자 프로필 선호 로케일을 서비스 메서드 인자로 전달받아 반영하는 것을 권장합니다.

Also applies to: 139-144

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these settings in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 04e50bf and 6591c61.

📒 Files selected for processing (45)
  • build.gradle (1 hunks)
  • src/main/java/project/flipnote/auth/listener/UserWithdrawnEventListener.java (1 hunks)
  • src/main/java/project/flipnote/auth/model/UserRegisterRequest.java (1 hunks)
  • src/main/java/project/flipnote/auth/service/AuthService.java (1 hunks)
  • src/main/java/project/flipnote/common/entity/MapToJsonConverter.java (1 hunks)
  • src/main/java/project/flipnote/common/exception/GlobalExceptionHandler.java (1 hunks)
  • src/main/java/project/flipnote/common/model/event/GroupInvitationCreatedEvent.java (1 hunks)
  • src/main/java/project/flipnote/common/model/event/UserRegisteredEvent.java (1 hunks)
  • src/main/java/project/flipnote/common/model/event/UserWithdrawnEvent.java (1 hunks)
  • src/main/java/project/flipnote/common/model/request/CursorPageRequest.java (1 hunks)
  • src/main/java/project/flipnote/common/model/request/UserCreateCommand.java (1 hunks)
  • src/main/java/project/flipnote/common/model/response/ApiResponse.java (1 hunks)
  • src/main/java/project/flipnote/common/model/response/ApiResponseAdvice.java (1 hunks)
  • src/main/java/project/flipnote/common/model/response/CursorPageResponse.java (1 hunks)
  • src/main/java/project/flipnote/common/model/response/PageResponse.java (1 hunks)
  • src/main/java/project/flipnote/common/security/config/SecurityConfig.java (1 hunks)
  • src/main/java/project/flipnote/common/security/exception/CustomAuthenticationEntryPoint.java (1 hunks)
  • src/main/java/project/flipnote/common/security/filter/ExceptionHandlerFilter.java (1 hunks)
  • src/main/java/project/flipnote/group/controller/GroupInvitationQueryController.java (1 hunks)
  • src/main/java/project/flipnote/group/controller/docs/GroupInvitationQueryControllerDocs.java (1 hunks)
  • src/main/java/project/flipnote/group/listener/UserRegisteredEventListener.java (2 hunks)
  • src/main/java/project/flipnote/group/model/GroupInvitationStatus.java (1 hunks)
  • src/main/java/project/flipnote/group/model/IncomingGroupInvitationResponse.java (1 hunks)
  • src/main/java/project/flipnote/group/model/OutgoingGroupInvitationResponse.java (1 hunks)
  • src/main/java/project/flipnote/group/repository/GroupRepository.java (1 hunks)
  • src/main/java/project/flipnote/group/service/GroupInvitationService.java (3 hunks)
  • src/main/java/project/flipnote/group/service/GroupService.java (1 hunks)
  • src/main/java/project/flipnote/infra/firebase/FcmErrorCode.java (1 hunks)
  • src/main/java/project/flipnote/infra/firebase/FirebaseService.java (1 hunks)
  • src/main/java/project/flipnote/notification/controller/NotificationController.java (1 hunks)
  • src/main/java/project/flipnote/notification/controller/docs/NotificationControllerDocs.java (1 hunks)
  • src/main/java/project/flipnote/notification/entity/FcmToken.java (1 hunks)
  • src/main/java/project/flipnote/notification/entity/Notification.java (1 hunks)
  • src/main/java/project/flipnote/notification/entity/NotificationType.java (1 hunks)
  • src/main/java/project/flipnote/notification/exception/NotificationErrorCode.java (1 hunks)
  • src/main/java/project/flipnote/notification/listener/GroupInvitationCreateEventListener.java (1 hunks)
  • src/main/java/project/flipnote/notification/model/NotificationListRequest.java (1 hunks)
  • src/main/java/project/flipnote/notification/model/NotificationResponse.java (1 hunks)
  • src/main/java/project/flipnote/notification/model/TokenRegisterRequest.java (1 hunks)
  • src/main/java/project/flipnote/notification/repository/FcmTokenRepository.java (1 hunks)
  • src/main/java/project/flipnote/notification/repository/NotificationRepository.java (1 hunks)
  • src/main/java/project/flipnote/notification/service/NotificationService.java (1 hunks)
  • src/main/java/project/flipnote/user/service/UserService.java (1 hunks)
  • src/main/resources/application.yml (1 hunks)
  • src/main/resources/messages.properties (1 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (33)
src/main/java/project/flipnote/notification/model/TokenRegisterRequest.java (2)
src/main/java/project/flipnote/auth/model/PasswordResetRequest.java (1)
  • PasswordResetRequest (6-14)
src/main/java/project/flipnote/user/model/UserUpdateRequest.java (1)
  • UserUpdateRequest (8-25)
src/main/java/project/flipnote/group/controller/GroupInvitationQueryController.java (1)
src/main/java/project/flipnote/common/response/PageResponse.java (2)
  • PageResponse (7-32)
  • from (19-31)
src/main/java/project/flipnote/common/security/exception/CustomAuthenticationEntryPoint.java (1)
src/main/java/project/flipnote/common/response/ApiResponse.java (4)
  • ApiResponse (14-64)
  • FieldError (47-63)
  • error (38-45)
  • error (30-36)
src/main/java/project/flipnote/group/service/GroupService.java (2)
src/main/java/project/flipnote/groupjoin/service/GroupJoinService.java (1)
  • findGroup (50-54)
src/main/java/project/flipnote/cardset/service/CardSetService.java (1)
  • findGroup (46-50)
src/main/java/project/flipnote/common/model/response/PageResponse.java (1)
src/main/java/project/flipnote/common/response/PageResponse.java (2)
  • PageResponse (7-32)
  • from (19-31)
src/main/java/project/flipnote/common/security/filter/ExceptionHandlerFilter.java (2)
src/main/java/project/flipnote/common/response/ApiResponse.java (2)
  • ApiResponse (14-64)
  • FieldError (47-63)
src/main/java/project/flipnote/common/response/ApiResponseAdvice.java (1)
  • ApiResponseAdvice (12-40)
src/main/java/project/flipnote/common/model/response/ApiResponse.java (1)
src/main/java/project/flipnote/common/response/ApiResponseAdvice.java (2)
  • ApiResponseAdvice (12-40)
  • beforeBodyWrite (20-39)
src/main/java/project/flipnote/user/service/UserService.java (1)
src/main/java/project/flipnote/common/dto/UserCreateCommand.java (1)
  • UserCreateCommand (3-12)
src/main/java/project/flipnote/infra/firebase/FcmErrorCode.java (2)
src/main/java/project/flipnote/common/exception/ErrorCode.java (2)
  • ErrorCode (3-10)
  • getCode (7-7)
src/main/java/project/flipnote/group/exception/GroupErrorCode.java (1)
  • Getter (9-26)
src/main/java/project/flipnote/common/exception/GlobalExceptionHandler.java (2)
src/main/java/project/flipnote/common/response/ApiResponse.java (3)
  • ApiResponse (14-64)
  • FieldError (47-63)
  • success (23-28)
src/main/java/project/flipnote/common/response/ApiResponseAdvice.java (1)
  • ApiResponseAdvice (12-40)
src/main/java/project/flipnote/common/model/request/UserCreateCommand.java (1)
src/main/java/project/flipnote/common/dto/UserCreateCommand.java (1)
  • UserCreateCommand (3-12)
src/main/java/project/flipnote/group/model/GroupInvitationStatus.java (2)
src/main/java/project/flipnote/group/entity/GroupInvitation.java (1)
  • respond (78-80)
src/main/java/project/flipnote/group/entity/GroupInvitationStatus.java (1)
  • GroupInvitationStatus (3-5)
src/main/java/project/flipnote/common/entity/MapToJsonConverter.java (1)
src/main/java/project/flipnote/common/crypto/AesCryptoConverter.java (1)
  • AesCryptoConverter (18-80)
src/main/java/project/flipnote/notification/service/NotificationService.java (1)
src/main/java/project/flipnote/notification/model/NotificationListRequest.java (1)
  • NotificationListRequest (5-6)
src/main/java/project/flipnote/notification/repository/FcmTokenRepository.java (1)
src/main/java/project/flipnote/group/repository/GroupInvitationRepository.java (1)
  • GroupInvitationRepository (17-41)
src/main/java/project/flipnote/notification/model/NotificationResponse.java (8)
src/main/java/project/flipnote/group/model/GroupDetailResponse.java (1)
  • GroupDetailResponse (8-41)
src/main/java/project/flipnote/user/model/UserUpdateResponse.java (1)
  • UserUpdateResponse (5-18)
src/main/java/project/flipnote/group/model/GroupInvitationCreateResponse.java (1)
  • GroupInvitationCreateResponse (3-6)
src/main/java/project/flipnote/group/model/GroupInvitationRespondRequest.java (1)
  • GroupInvitationRespondRequest (6-17)
src/main/java/project/flipnote/user/model/SocialLinkResponse.java (1)
  • SocialLinkResponse (9-26)
src/main/java/project/flipnote/groupjoin/model/GroupJoinResponse.java (1)
  • GroupJoinResponse (5-11)
src/main/java/project/flipnote/group/model/GroupCreateResponse.java (1)
  • GroupCreateResponse (3-9)
src/main/java/project/flipnote/user/model/MyInfoResponse.java (1)
  • MyInfoResponse (9-38)
src/main/java/project/flipnote/common/model/event/UserRegisteredEvent.java (2)
src/main/java/project/flipnote/common/event/UserRegisteredEvent.java (1)
  • UserRegisteredEvent (3-6)
src/main/java/project/flipnote/common/event/UserWithdrawnEvent.java (1)
  • UserWithdrawnEvent (3-6)
src/main/java/project/flipnote/auth/service/AuthService.java (2)
src/main/java/project/flipnote/common/dto/UserCreateCommand.java (1)
  • UserCreateCommand (3-12)
src/main/java/project/flipnote/common/event/UserRegisteredEvent.java (1)
  • UserRegisteredEvent (3-6)
src/main/java/project/flipnote/notification/entity/Notification.java (1)
src/main/java/project/flipnote/common/entity/BaseEntity.java (1)
  • BaseEntity (14-26)
src/main/java/project/flipnote/notification/exception/NotificationErrorCode.java (10)
src/main/java/project/flipnote/common/model/response/ApiResponse.java (2)
  • Getter (14-64)
  • Getter (47-63)
src/main/java/project/flipnote/common/exception/ErrorCode.java (2)
  • ErrorCode (3-10)
  • getCode (7-7)
src/main/java/project/flipnote/common/exception/CommonErrorCode.java (1)
  • Getter (8-18)
src/main/java/project/flipnote/cardset/exception/CardSetErrorCode.java (1)
  • Getter (9-23)
src/main/java/project/flipnote/auth/exception/AuthErrorCode.java (1)
  • Getter (9-36)
src/main/java/project/flipnote/image/exception/ImageErrorCode.java (1)
  • Getter (9-23)
src/main/java/project/flipnote/user/exception/UserErrorCode.java (1)
  • Getter (9-24)
src/main/java/project/flipnote/group/exception/GroupInvitationErrorCode.java (1)
  • Getter (9-25)
src/main/java/project/flipnote/group/exception/GroupErrorCode.java (1)
  • Getter (9-26)
src/main/java/project/flipnote/common/security/exception/SecurityErrorCode.java (1)
  • Getter (9-20)
src/main/java/project/flipnote/common/model/response/CursorPageResponse.java (9)
src/main/java/project/flipnote/common/response/PageResponse.java (2)
  • PageResponse (7-32)
  • from (19-31)
src/main/java/project/flipnote/group/model/GroupDetailResponse.java (1)
  • GroupDetailResponse (8-41)
src/main/java/project/flipnote/user/model/UserUpdateResponse.java (1)
  • UserUpdateResponse (5-18)
src/main/java/project/flipnote/user/model/SocialLinksResponse.java (1)
  • SocialLinksResponse (7-18)
src/main/java/project/flipnote/groupjoin/model/GroupJoinListResponse.java (1)
  • GroupJoinListResponse (7-13)
src/main/java/project/flipnote/cardset/model/CreateCardSetResponse.java (1)
  • CreateCardSetResponse (4-10)
src/main/java/project/flipnote/groupjoin/model/FindGroupJoinListMeResponse.java (1)
  • FindGroupJoinListMeResponse (7-13)
src/main/java/project/flipnote/groupjoin/model/GroupJoinResponse.java (1)
  • GroupJoinResponse (5-11)
src/main/java/project/flipnote/groupjoin/model/GroupJoinRespondResponse.java (1)
  • GroupJoinRespondResponse (3-9)
src/main/java/project/flipnote/notification/controller/docs/NotificationControllerDocs.java (5)
src/main/java/project/flipnote/notification/model/NotificationListRequest.java (1)
  • NotificationListRequest (5-6)
src/main/java/project/flipnote/auth/controller/docs/AuthControllerDocs.java (5)
  • AuthControllerDocs (19-53)
  • Operation (45-46)
  • Operation (30-31)
  • Operation (39-40)
  • Operation (36-37)
src/main/java/project/flipnote/user/controller/docs/UserControllerDocs.java (2)
  • Tag (14-28)
  • Operation (20-21)
src/main/java/project/flipnote/group/controller/docs/GroupInvitationControllerDocs.java (1)
  • Tag (13-28)
src/main/java/project/flipnote/auth/controller/docs/OAuthControllerDocs.java (1)
  • Tag (10-28)
src/main/java/project/flipnote/notification/controller/NotificationController.java (1)
src/main/java/project/flipnote/notification/model/NotificationListRequest.java (1)
  • NotificationListRequest (5-6)
src/main/java/project/flipnote/infra/firebase/FirebaseService.java (2)
src/main/java/project/flipnote/notification/controller/NotificationController.java (1)
  • RequiredArgsConstructor (23-50)
src/test/java/project/flipnote/FlipnoteApplicationTests.java (1)
  • FlipnoteApplicationTests (9-20)
src/main/java/project/flipnote/notification/repository/NotificationRepository.java (6)
src/main/java/project/flipnote/group/repository/GroupInvitationRepository.java (1)
  • GroupInvitationRepository (17-41)
src/main/java/project/flipnote/user/repository/UserProfileRepository.java (1)
  • UserProfileRepository (12-23)
src/main/java/project/flipnote/cardset/repository/CardSetRepository.java (1)
  • Repository (8-10)
src/main/java/project/flipnote/auth/repository/UserAuthRepository.java (1)
  • UserAuthRepository (13-35)
src/main/java/project/flipnote/group/repository/GroupPermissionRepository.java (1)
  • Repository (9-12)
src/main/java/project/flipnote/group/repository/GroupMemberRepository.java (1)
  • Repository (13-23)
src/main/java/project/flipnote/common/model/event/GroupInvitationCreatedEvent.java (4)
src/main/java/project/flipnote/group/model/GroupInvitationCreateResponse.java (1)
  • GroupInvitationCreateResponse (3-6)
src/main/java/project/flipnote/group/model/GroupInvitationCreateRequest.java (1)
  • GroupInvitationCreateRequest (6-10)
src/main/java/project/flipnote/group/model/GroupInvitationRespondRequest.java (1)
  • GroupInvitationRespondRequest (6-17)
src/main/java/project/flipnote/group/model/GroupDetailResponse.java (1)
  • GroupDetailResponse (8-41)
src/main/java/project/flipnote/auth/model/UserRegisterRequest.java (1)
src/main/java/project/flipnote/common/dto/UserCreateCommand.java (1)
  • UserCreateCommand (3-12)
src/main/java/project/flipnote/common/model/event/UserWithdrawnEvent.java (2)
src/main/java/project/flipnote/common/event/UserWithdrawnEvent.java (1)
  • UserWithdrawnEvent (3-6)
src/main/java/project/flipnote/common/event/UserRegisteredEvent.java (1)
  • UserRegisteredEvent (3-6)
src/main/java/project/flipnote/group/model/OutgoingGroupInvitationResponse.java (2)
src/main/java/project/flipnote/group/entity/GroupInvitation.java (2)
  • respond (78-80)
  • getStatus (98-103)
src/main/java/project/flipnote/group/model/GroupInvitationRespondRequest.java (1)
  • GroupInvitationRespondRequest (6-17)
src/main/java/project/flipnote/common/model/response/ApiResponseAdvice.java (1)
src/main/java/project/flipnote/common/response/ApiResponse.java (3)
  • ApiResponse (14-64)
  • FieldError (47-63)
  • success (23-28)
src/main/java/project/flipnote/group/service/GroupInvitationService.java (1)
src/main/java/project/flipnote/user/service/UserService.java (1)
  • RequiredArgsConstructor (27-126)
src/main/java/project/flipnote/group/listener/UserRegisteredEventListener.java (2)
src/main/java/project/flipnote/common/event/UserRegisteredEvent.java (1)
  • UserRegisteredEvent (3-6)
src/main/java/project/flipnote/auth/listener/EmailVerificationEventListener.java (1)
  • EmailVerificationEventListener (17-39)
src/main/java/project/flipnote/group/model/IncomingGroupInvitationResponse.java (2)
src/main/java/project/flipnote/group/entity/GroupInvitation.java (1)
  • respond (78-80)
src/main/java/project/flipnote/group/model/GroupInvitationRespondRequest.java (1)
  • GroupInvitationRespondRequest (6-17)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (36)
src/main/java/project/flipnote/common/model/response/PageResponse.java (1)

1-1: 패키지 경로 변경 확인 완료 — 레거시 참조 없음

검색 결과 'project.flipnote.common.response' 참조는 발견되지 않았고, PageResponse/CursorPageResponse는 모두 'project.flipnote.common.model.response'로 import 되어 있습니다.

  • src/main/java/project/flipnote/common/model/response/PageResponse.java
  • src/main/java/project/flipnote/common/model/response/CursorPageResponse.java
  • src/main/java/project/flipnote/notification/service/NotificationService.java
  • src/main/java/project/flipnote/notification/controller/NotificationController.java
  • src/main/java/project/flipnote/notification/controller/docs/NotificationControllerDocs.java
  • src/main/java/project/flipnote/group/controller/GroupInvitationQueryController.java
  • src/main/java/project/flipnote/group/controller/docs/GroupInvitationQueryControllerDocs.java
  • src/main/java/project/flipnote/group/service/GroupInvitationService.java

문제 없음 — 반영 완료로 보입니다.

src/main/java/project/flipnote/common/model/request/UserCreateCommand.java (2)

1-1: 패키지 이동 정리 좋습니다 (LGTM).

dto → model.request로의 이전이 일관된 리팩터링으로 보이며, 레코드 스펙 변경 없이 안정적입니다.


1-1: 레거시 import 없음 — project.flipnote.common.dto.UserCreateCommand 참조 없음

검색 결과 project.flipnote.common.dto.UserCreateCommand에 대한 import/참조는 발견되지 않았습니다. UserCreateCommand 참조는 모두 새 패키지(project.flipnote.common.model.request.UserCreateCommand)를 사용하고 있습니다.

사용 위치(검출된 파일):

  • src/main/java/project/flipnote/auth/model/UserRegisterRequest.java — import, toCommand()에서 사용
  • src/main/java/project/flipnote/auth/service/AuthService.java — import 및 사용
  • src/main/java/project/flipnote/user/service/UserService.java — import 및 사용
src/main/java/project/flipnote/user/service/UserService.java (1)

14-15: import 경로 업데이트만 존재 — 기능 영향 없음 (LGTM)

모듈 재배치에 따른 import 경로 변경만 있으며 런타임 동작 변화는 없습니다.

src/main/java/project/flipnote/auth/model/UserRegisterRequest.java (2)

6-6: import 경로 교체 정상 (LGTM)

toCommand() 반환 타입과 사용처가 동일하므로 변경 리스크는 낮습니다.


6-6: 전화번호 정규화(null) 동작 확인 — null-safe(예외 없음)

PhoneUtil.normalize(null)는 내부에서 if (phone == null) return null; 처리를 하므로 NPE가 발생하지 않습니다. ValidPhone 어노테이션은 @notblank가 아니므로 null을 허용합니다(필요시 필드에 @NotBlank/@NotNull 추가 검토).

  • 확인 위치:
    • PhoneUtil.normalize(...) — PhoneUtil.java: null 체크 및 null 반환 확인
    • src/main/java/project/flipnote/common/validation/annotation/ValidPhone.java — @notblank 미적용(따라서 null 허용)
src/main/java/project/flipnote/common/model/event/UserRegisteredEvent.java (2)

1-1: 패키지 이동 정돈 (LGTM)

이벤트 타입을 common.model.event로 이관한 방향성에 동의합니다.


1-6: 패키지명 변경이 직렬화/아웃박스 스키마에 미치는 영향 — 결론: 없음(인프로세스 Spring 이벤트만 사용됨)

rg 검사 결과 Outbox/이벤트 저장소나 FQCN을 타입 키로 사용하는 코드가 발견되지 않았습니다. UserRegisteredEvent는 애플리케이션 이벤트로 발행/처리되고 있어 패키지명 변경이 현재 호환성 문제를 일으킬 가능성은 낮습니다.

점검한 위치:

  • src/main/java/project/flipnote/auth/service/AuthService.java — eventPublisher.publishEvent(new UserRegisteredEvent(email)); (발행 지점)
  • src/main/java/project/flipnote/common/model/event/UserRegisteredEvent.java — 이벤트 정의
  • src/main/java/project/flipnote/group/listener/UserRegisteredEventListener.java — @TransactionalEventListener로 처리(수신 지점)

권고: 향후 이벤트를 DB나 외부 메시지로 영속화/전송할 계획이 있다면 FQCN 기반 타입 키 대신 고유 타입 키/버전 정책을 도입해 주세요.

src/main/java/project/flipnote/auth/service/AuthService.java (1)

37-39: import 경로 변경만 포함 — 동작 영향 없음 (LGTM)

UserCreateCommand / UserRegisteredEvent의 새 경로 참조로 변경되었으며, 나머지 로직은 동일합니다.

src/main/java/project/flipnote/common/model/event/GroupInvitationCreatedEvent.java (1)

3-7: 도메인 이벤트 도입 적절 — 불변 DTO(record)로 명확합니다

단순 페이로드 전달에 record 사용이 적합합니다. 이벤트 명과 페이로드 구성도 의도가 분명합니다.

src/main/java/project/flipnote/common/model/event/UserWithdrawnEvent.java (1)

1-6: 패키지 리로케이션 정합성 확인 — OK

common.event → common.model.event로의 이동이 일관된 구조 재정리에 부합합니다. 레코드 시그니처 변경 없음도 적절합니다.

src/main/java/project/flipnote/auth/listener/UserWithdrawnEventListener.java (2)

14-14: import 경로 변경 적절

이벤트 패키지 이동에 따른 import 업데이트가 올바릅니다. 나머지 리스너 로직 영향 없음 확인했습니다.


14-14: 확인: 레거시 import(project.flipnote.common.event) 없음 — 조치 불필요

레거시 패키지(project.flipnote.common.event)에 대한 전역 검색에서 사용 흔적이 없습니다. 현재 참조는 project.flipnote.common.model.event를 사용하고 있습니다. 탐지된 참조 위치(참고):

  • src/main/java/project/flipnote/auth/listener/UserWithdrawnEventListener.java (import 및 핸들러/복구 메서드)
  • src/main/java/project/flipnote/user/service/UserService.java (import 및 publishEvent 호출)
  • src/main/java/project/flipnote/common/model/event/UserWithdrawnEvent.java (이벤트 정의)
src/main/java/project/flipnote/group/listener/UserRegisteredEventListener.java (2)

28-31: 메서드 네이밍 카멜케이스 정정 — 가독성 향상

핸들러 메서드명을 handleUserRegisteredEvent로 정리한 점 좋습니다. @TransactionalEventListener(AFTER_COMMIT) + @async 조합도 이벤트 후속처리 성격에 적합합니다.


23-31: Retry로 인한 중복 처리 방지(idempotency) 확인 필요

@retryable 적용으로 동일 이벤트가 재시도될 수 있습니다. groupInvitationService.acceptPendingInvitationsOnRegister(email)이 멱등하게 동작하는지(예: PENDING에만 조건부 업데이트, 유니크 제약으로 중복 수락 방지 등) 확인해 주세요. 멱등성 보장이 없다면 재시도 시 중복 처리/알림이 발생할 수 있습니다.

src/main/resources/messages.properties (1)

1-1: 확인: ${...} 플레이스홀더는 현재 구현에서 정상 치환되므로 변경 불필요합니다

NotificationService에서 MessageSource로 템플릿을 로드한 뒤 Apache Commons Text의 StringSubstitutor(notification.getVariables())로 ${...} 플레이스홀더를 치환하고 있어 현재 포맷 유지해도 동작합니다.

참고 파일 위치

  • src/main/resources/messages.properties
    • notification.group.invite=${groupName} 그룹에 초대되셨습니다.
  • src/main/java/project/flipnote/notification/service/NotificationService.java
    • template = messageSource.getMessage(key, null, locale);
    • StringSubstitutor substitutor = new StringSubstitutor(notification.getVariables());
    • return substitutor.replace(template);
  • src/main/java/project/flipnote/notification/entity/NotificationType.java
    • GROUP_INVITE("notification.group.invite");

추가 권고: 향후 MessageSource#getMessage(code, args, locale) 방식(번호 기반 포맷 {0})으로 교체할 계획이면 메시지 포맷을 {0} 형태로 바꾸는 것이 안전합니다.

src/main/java/project/flipnote/common/security/exception/CustomAuthenticationEntryPoint.java (1)

16-16: 패키지 경로 변경 반영 LGTM

ApiResponse의 패키지 이동을 정확히 반영했습니다. 동작 영향 없음으로 보입니다.

src/main/java/project/flipnote/group/controller/GroupInvitationQueryController.java (1)

12-12: PageResponse 패키지 경로 변경 반영 — 레거시 참조 없음 (LGTM)

rg 검사 결과 레거시 import 'project.flipnote.common.response.PageResponse'는 존재하지 않으며, 다음 파일들이 새 경로를 사용하고 있습니다:

  • src/main/java/project/flipnote/common/model/response/PageResponse.java (정의)
  • src/main/java/project/flipnote/group/service/GroupInvitationService.java
  • src/main/java/project/flipnote/group/controller/GroupInvitationQueryController.java
  • src/main/java/project/flipnote/group/controller/docs/GroupInvitationQueryControllerDocs.java

의존성/패키지 리팩토링 방향과 일치하며 컴파일 타임 의존성만 변경되어 런타임 영향 없음 — 승인합니다.

src/main/java/project/flipnote/common/security/filter/ExceptionHandlerFilter.java (1)

15-15: ApiResponse 패키지 경로 변경 반영 LGTM

패키지 리네이밍에 따른 import 변경만으로 동작 영향은 없습니다.

src/main/java/project/flipnote/common/security/config/SecurityConfig.java (1)

27-27: ApiResponse 패키지 경로 변경 반영 — 레거시 import 없음, LGTM

rg로 '^import\s+project.flipnote.common.response.ApiResponse;' 를 검색한 결과 Java 파일에서 해당 레거시 import는 검출되지 않았습니다. 핸들러의 에러 응답 직렬화와 동일한 타입 사용으로 일관성 유지에 좋습니다.

src/main/java/project/flipnote/group/model/OutgoingGroupInvitationResponse.java (1)

21-31: 확인: GroupInvitationStatus.from(invitation)는 getStatus()의 만료 로직을 그대로 사용합니다.

간단히 설명하면, from(...)이 invitation.getStatus().name()을 호출하므로 getStatus()의 isExpired() 검사(만료 시 EXPIRED 반환)가 동일하게 반영됩니다.

  • src/main/java/project/flipnote/group/model/GroupInvitationStatus.java
    • public static GroupInvitationStatus from(GroupInvitation invitation) → GroupInvitationStatus.valueOf(invitation.getStatus().name())
  • src/main/java/project/flipnote/group/entity/GroupInvitation.java
    • getStatus()는 isExpired()를 통해 expiredAt 기준으로 EXPIRED를 반환하도록 구현되어 있음

결론: 동작이 이전과 일치하므로 별도 수정 불필요합니다.

src/main/java/project/flipnote/common/exception/GlobalExceptionHandler.java (1)

12-12: ApiResponse 패키지 경로 변경 반영 확인 - 문제 없음

새로운 패키지(project.flipnote.common.model.response) 경로로의 변경이 일관되게 반영되었습니다.

src/main/java/project/flipnote/group/model/IncomingGroupInvitationResponse.java (1)

22-22: 만료 상태 반영 강건성 향상: from(invitation) 사용 적절

GroupInvitationStatus.from(invitation)로 변경하여 초대 만료/상태 파생 로직을 일관되게 적용하는 방향이 타당합니다. 응답 정확도가 개선됩니다.

src/main/java/project/flipnote/notification/entity/NotificationType.java (1)

6-12: 간결하고 목적에 맞는 enum 설계, LGTM

메시지 키를 enum에 응집시킨 점이 명확합니다. Notification 엔티티에서 EnumType.STRING으로 저장하므로 추후 상수명이 길어질 가능성만 염두에 두면 좋겠습니다.

src/main/java/project/flipnote/group/controller/docs/GroupInvitationQueryControllerDocs.java (1)

10-10: 패키지 경로 변경 반영, LGTM

PageResponse 패키지 이동을 정확히 반영했습니다. 컨트롤러 구현부에서도 동일 경로를 사용 중인지 최종 컴파일 확인만 부탁드립니다.

src/main/java/project/flipnote/notification/entity/FcmToken.java (1)

21-30: 테이블 인덱스/제약에 사용된 컬럼명과 매핑 컬럼명이 불일치할 수 있습니다. 명시적으로 컬럼명을 지정하세요.

@Table의 인덱스/유니크 제약은 "user_id", "token"을 사용하지만, 필드에는 @Column(name=...)이 없어 네이밍 전략에 의존합니다. 운영 환경에서 네이밍 전략이 바뀌거나 테스트에서 다른 전략을 쓰면 DDL/쿼리가 깨질 수 있습니다. 명시적으로 컬럼명을 지정해 안전하게 가는 것을 권장합니다.
[ suggest_essential_refactor ]
다음과 같이 필드에 컬럼명을 명시해주세요:

-	@Column(nullable = false)
-	private Long userId;
+	@Column(name = "user_id", nullable = false)
+	private Long userId;

-	@Column(nullable = false, length = 512)
-	private String token;
+	@Column(name = "token", nullable = false, length = 512)
+	private String token;

-	@Column(nullable = false)
-	private LocalDateTime lastUsedAt;
+	@Column(name = "last_used_at", nullable = false)
+	private LocalDateTime lastUsedAt;

해당 프로젝트에서 Hibernate PhysicalNamingStrategy가 snake_case로 설정되어 있는지 확인 부탁드립니다. 설정되어 있지 않다면 위 수정은 필수입니다.

src/main/java/project/flipnote/notification/repository/FcmTokenRepository.java (2)

21-22: 토큰의 lastUsedAt 일괄 업데이트가 사용자 범위를 무시합니다. 의도된 동작인지 확인 필요합니다.

현재 쿼리는 WHERE f.token IN :tokens만 조건으로 걸어 특정 userId에 한정되지 않습니다. 동일 토큰이 다른 사용자에 매핑될 가능성을 고려하면, 다른 사용자의 레코드까지 업데이트할 수 있습니다. 사용자 범위를 포함하는 것이 안전합니다.

의도한 범위가 “특정 사용자에 한정”이라면 다음과 같이 수정하세요:

-	@Query("UPDATE FcmToken f SET f.lastUsedAt = :now WHERE f.token IN :tokens")
-	int bulkUpdateLastUsedAt(@Param("tokens") List<String> tokens, @Param("now") LocalDateTime now);
+	@Query("UPDATE FcmToken f SET f.lastUsedAt = :now WHERE f.userId = :userId AND f.token IN :tokens")
+	int bulkUpdateLastUsedAt(@Param("userId") Long userId, @Param("tokens") List<String> tokens, @Param("now") LocalDateTime now);

서비스 계층의 호출부 시그니처 업데이트도 함께 필요합니다.


20-22: 트랜잭션 경계 확인 필요.

@Modifying JPQL 업데이트는 트랜잭션 내에서 수행되어야 합니다. 서비스 메서드에 @Transactional이 적용되어 있는지 확인 바랍니다.

src/main/java/project/flipnote/notification/listener/GroupInvitationCreateEventListener.java (1)

29-31: 멱등성 보장 확인 필요.

재시도/중복 이벤트 수신 시 동일 초대에 대해 중복 알림이 발송될 수 있습니다. sendGroupInvite 내부에서 멱등 키(예: groupId+inviteeId+createdAt)를 활용해 중복 발송을 방지하는지 확인 바랍니다.

src/main/java/project/flipnote/common/entity/MapToJsonConverter.java (1)

21-29: NULL 저장 정책 재검토: 빈 맵은 "{}"로, null은 null로 저장하는 것이 저장 공간/의미 측면에서 유리할 수 있습니다.

현재는 null/빈맵 모두 "{}"로 저장됩니다. DB 컬럼이 NOT NULL이 아니라면 null은 null로 저장하는 편이 의미적으로 명확합니다.

NOT NULL 제약 및 읽기 시 기본값 요구사항을 확인해 주세요. 필요시 다음과 같이 분기할 수 있습니다(참고):

-		if (attribute == null || attribute.isEmpty()) {
-			return "{}";
-		}
+		if (attribute == null) {
+			return null;
+		}
+		if (attribute.isEmpty()) {
+			return "{}";
+		}
src/main/java/project/flipnote/notification/model/NotificationResponse.java (1)

10-21: DTO 매핑 구조는 명확하며 일관적입니다

엔티티에서 응답 DTO로의 필드 매핑이 명확하고, 날짜 포맷 지정도 적절합니다. 컨트롤러/서비스에서 바로 사용하기에 충분히 간결합니다.

Also applies to: 23-32

src/main/java/project/flipnote/group/service/GroupInvitationService.java (2)

7-7: 이벤트 퍼블리셔 도입은 좋은 선택입니다

초대 생성 이후 도메인 이벤트 발행으로 알림 전송을 decouple한 점이 좋습니다. 모듈 간 결합도를 낮추고 확장성도 올라갑니다.

Also applies to: 15-17, 45-45


254-254: 이벤트 발행 타이밍 및 비동기 처리 확인 — 변경 불필요

확인 결과 GroupInvitationCreatedEvent 리스너가 이미 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 및 @async로 선언되어 있어 eventPublisher.publishEvent(...) 호출은 트랜잭션 커밋 이전 실행으로 인한 불일치 위험이 없습니다.

  • src/main/java/project/flipnote/notification/listener/GroupInvitationCreateEventListener.java
    • @async
    • @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
  • src/main/java/project/flipnote/group/listener/UserRegisteredEventListener.java
    • @async
    • @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
src/main/java/project/flipnote/notification/controller/NotificationController.java (1)

30-39: 알림 목록 조회 엔드포인트 구성은 적절합니다

  • ModelAttribute로 커서 페이징 파라미터를 받는 구성 적합
  • 서비스 호출 후 200 OK 반환 적절
src/main/java/project/flipnote/notification/service/NotificationService.java (2)

49-69: 확인: NotificationRepository가 ID DESC 정렬을 보장하므로 커서 정합성 문제 없음

요약: findNotificationsByReceiverIdAndCursor에 @query로 "ORDER BY n.id DESC"가 명시되어 있어 getNotifications의 hasNext/nextCursor 계산이 정렬 일관성 하에서 올바르게 동작합니다.

  • 확인된 위치:
    • src/main/java/project/flipnote/notification/repository/NotificationRepository.java — @query("SELECT n FROM Notification n WHERE (:cursor IS NULL OR n.id < :cursor) AND n.receiverId = :receiverId ORDER BY n.id DESC")
    • src/main/java/project/flipnote/notification/service/NotificationService.java — getNotifications(...) (라인 ~49-69)

선택적 권장: 대량 데이터에서 성능 저하가 우려되면 n.id에 대한 인덱스/쿼리 계획을 검토하세요.


71-86: 검증 완료 — 메시지 키와 템플릿 변수 일치

검증 결과: NotificationType.GROUP_INVITE의 messageKey("notification.group.invite")가 messages*.properties에 존재하며, 템플릿 변수는 ${groupName} 하나뿐이므로 치환 누락 위험이 없습니다.

점검 위치:

  • src/main/java/project/flipnote/notification/entity/NotificationType.java: GROUP_INVITE("notification.group.invite")
  • messages*.properties: notification.group.invite=${groupName} 그룹에 초대되셨습니다.
  • src/main/java/project/flipnote/notification/service/NotificationService.java (71–86, 139–144): variables Map.of("groupName", groupName)

결론: 추가 조치 불필요합니다.

Comment on lines +14 to +16
private String cursor;

@Min(1)
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

검증 강화: cursor 형식(숫자) 사전 검증으로 400 처리 유도

빈 값은 허용하되(커서 미지정), 공백 포함을 허용하고 비숫자는 차단하는 패턴을 적용하면 컨트롤러 레벨에서 명확히 400을 반환할 수 있습니다.

-	private String cursor;
+	@Pattern(regexp = "^\\s*\\d*\\s*$", message = "cursor는 숫자 문자열이어야 합니다.")
+	private String cursor;

상단 import 추가(파일 상단에 별도 반영 필요):

import jakarta.validation.constraints.Pattern;
🤖 Prompt for AI Agents
In src/main/java/project/flipnote/common/model/request/CursorPageRequest.java
around lines 14 to 16, the cursor field needs pre-validation to force a 400 for
non-numeric values while still allowing empty/unspecified cursors; add the
jakarta.validation.constraints.Pattern import at the top and annotate the cursor
String with a Pattern that permits either empty string or only digits (e.g.
regexp "^$|^\\d+$"), ensuring controller-level validation returns 400 for
non-numeric inputs.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (2)
src/main/java/project/flipnote/notification/service/NotificationService.java (2)

89-96: 동일 FCM 토큰의 사용자 재할당/충돌 처리 누락 — 토큰 단일성 보장 필요

현재 (userId, token) 기준 upsert만 수행하여 동일 토큰이 타 사용자에 등록된 상태를 처리하지 않습니다(앱 로그아웃/로그인 등 시나리오). 토큰 단독 조회로 기존 소유자를 확인하고 재할당/삭제 처리한 뒤 저장하세요. 또한 DB 레벨에서 token 유니크 제약 추가를 권장합니다.

적용 예시(저장소에 findByToken 추가 전제):

 	@Transactional
 	public void registerFcmToken(Long userId, TokenRegisterRequest req) {
-		fcmTokenRepository.findByUserIdAndToken(userId, req.token())
-			.ifPresentOrElse(
-				FcmToken::updateLastUsedAt,
-				() -> saveFcmToken(userId, req)
-			);
+		// 동일 토큰이 다른 사용자에게 등록된 경우 선 정리
+		fcmTokenRepository.findByToken(req.token())
+			.filter(existing -> !existing.getUserId().equals(userId))
+			.ifPresent(existing -> {
+				fcmTokenRepository.deleteByUserIdAndTokenIn(existing.getUserId(), List.of(req.token()));
+			});
+
+		// (userId, token) 기준 upsert
+		fcmTokenRepository.findByUserIdAndToken(userId, req.token())
+			.ifPresentOrElse(
+				FcmToken::updateLastUsedAt,
+				() -> saveFcmToken(userId, req)
+			);
 	}

저장소/엔티티 보강(파일 외 참고용):

// FcmTokenRepository
Optional<FcmToken> findByToken(String token);

// FcmToken 엔티티
// @Column(name = "token", unique = true) 또는 @Table(uniqueConstraints = @UniqueConstraint(columnNames = "token"))

검증 스크립트(현 상태 점검):

#!/bin/bash
set -euo pipefail

echo "FcmTokenRepository 메서드들:"
rg -n "interface FcmTokenRepository" -A 120 -B 5

echo
echo "findByToken 존재 여부:"
rg -n "findByToken\\s*\\(" || true

echo
echo "FcmToken 토큰 유니크 제약 확인:"
rg -n "class\\s+FcmToken" -A 120 -B 5 | rg -n "@Column\\(|@Table\\(" -A 3 -B 3

145-150: 메시지 키 누락 시 런타임 예외 방지(기본 메시지 제공)

키 미존재 시 NoSuchMessageException을 피하기 위해 기본 메시지를 함께 전달하세요.

 	private String buildMessage(Notification notification, Locale locale) {
 		String key = notification.getType().getMessageKey();
-		String template = messageSource.getMessage(key, null, locale);
+		String template = messageSource.getMessage(key, null, key, locale);
 		StringSubstitutor substitutor = new StringSubstitutor(notification.getVariables());
 		return substitutor.replace(template);
 	}
🧹 Nitpick comments (6)
src/main/java/project/flipnote/notification/model/MarkNotificationsAsReadRequest.java (1)

7-10: 입력 유효성 보강: ID 값 양수 보장 및 대량 요청 상한 추가 제안

음수/0 ID 방지와 IN 절 폭주를 예방하기 위해 요소 타입에 @positive, 컬렉션에 @SiZe 상한을 권장합니다.

적용 예시:

 import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.Positive;
+import jakarta.validation.constraints.Size;

 public record MarkNotificationsAsReadRequest(
-	@NotEmpty
-	List<Long> notificationIds
+	@NotEmpty
+	@Size(max = 1000)
+	List<@Positive Long> notificationIds
 ) {
 }
  • max 값은 운영/DB 제약에 맞춰 조정하세요(예: 500~1000).
src/main/java/project/flipnote/notification/service/NotificationService.java (5)

72-87: FCM 전송 실패 시 트랜잭션 롤백으로 알림 저장까지 취소될 위험

알림 저장과 푸시 전송이 동일 트랜잭션에서 수행되어 FCM 예외 시 BizException 재던지며 전체 롤백됩니다. 사용자가 알림 목록에서 해당 알림을 보지 못하는 문제가 될 수 있습니다. 알림 저장은 커밋하고, 푸시 전송은 커밋 이후 별도 흐름으로 분리하세요.

권장:

  • Notification 저장 후 도메인 이벤트 발행 → @TransactionalEventListener(phase = AFTER_COMMIT)에서 FCM 전송
  • 또는 전송 로직을 REQUIRES_NEW 트랜잭션으로 분리해 저장 트랜잭션과 분리

간단 예시:

  • sendGroupInvite: notificationRepository.save(...) 후 ApplicationEventPublisher.publishEvent(new NotificationCreatedEvent(...))
  • Listener: @TransactionalEventListener(phase = AFTER_COMMIT)에서 sendNotification(...)

98-101: 중복 ID 제거 및 타임스탬프 1회 계산으로 미세 최적화

불필요한 IN 항목 감소와 일관된 타임스탬프 사용을 위해 아래처럼 경량 리팩터를 권장합니다.

 	@Transactional
 	public void markNotificationsAsRead(Long userId, MarkNotificationsAsReadRequest req) {
-		notificationRepository.bulkMarkAsRead(userId, req.notificationIds(), LocalDateTime.now());
+		List<Long> ids = req.notificationIds().stream().distinct().toList();
+		LocalDateTime now = LocalDateTime.now();
+		notificationRepository.bulkMarkAsRead(userId, ids, now);
 	}

62-66: Locale 고정값(Locale.KOREA) 대신 요청 컨텍스트 Locale 사용 권장

국제화 대응을 위해 LocaleContextHolder에서 현재 요청 Locale을 사용하세요.

-				String message = buildMessage(notification, Locale.KOREA);
+				String message = buildMessage(notification, LocaleContextHolder.getLocale());

필요한 import 추가(아래 코멘트 참고).


9-15: LocaleContextHolder import 추가

위 변경을 위해 import를 추가하세요.

 import org.apache.commons.text.StringSubstitutor;
 import org.springframework.context.MessageSource;
+import org.springframework.context.i18n.LocaleContextHolder;
 import org.springframework.data.domain.PageRequest;
 import org.springframework.data.domain.Pageable;

85-86: sendGroupInvite에서도 요청 Locale 사용으로 일관성 유지

-		String message = buildMessage(notification, Locale.KOREA);
+		String message = buildMessage(notification, LocaleContextHolder.getLocale());
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these settings in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 6591c61 and 7286e0b.

📒 Files selected for processing (7)
  • src/main/java/project/flipnote/common/entity/MapToJsonConverter.java (1 hunks)
  • src/main/java/project/flipnote/infra/firebase/FcmErrorCode.java (1 hunks)
  • src/main/java/project/flipnote/notification/controller/NotificationController.java (1 hunks)
  • src/main/java/project/flipnote/notification/controller/docs/NotificationControllerDocs.java (1 hunks)
  • src/main/java/project/flipnote/notification/model/MarkNotificationsAsReadRequest.java (1 hunks)
  • src/main/java/project/flipnote/notification/repository/NotificationRepository.java (1 hunks)
  • src/main/java/project/flipnote/notification/service/NotificationService.java (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (5)
  • src/main/java/project/flipnote/common/entity/MapToJsonConverter.java
  • src/main/java/project/flipnote/infra/firebase/FcmErrorCode.java
  • src/main/java/project/flipnote/notification/controller/docs/NotificationControllerDocs.java
  • src/main/java/project/flipnote/notification/controller/NotificationController.java
  • src/main/java/project/flipnote/notification/repository/NotificationRepository.java
🧰 Additional context used
🧬 Code Graph Analysis (2)
src/main/java/project/flipnote/notification/model/MarkNotificationsAsReadRequest.java (7)
src/main/java/project/flipnote/auth/model/EmailVerificationRequest.java (1)
  • EmailVerificationRequest (6-11)
src/main/java/project/flipnote/cardset/model/CreateCardSetRequest.java (1)
  • CreateCardSetRequest (12-30)
src/main/java/project/flipnote/user/model/UserUpdateRequest.java (1)
  • UserUpdateRequest (8-25)
src/main/java/project/flipnote/auth/model/ChangePasswordRequest.java (1)
  • ChangePasswordRequest (5-13)
src/main/java/project/flipnote/auth/model/EmailVerifyRequest.java (1)
  • EmailVerifyRequest (8-17)
src/main/java/project/flipnote/auth/model/PasswordResetRequest.java (1)
  • PasswordResetRequest (6-14)
src/main/java/project/flipnote/auth/model/PasswordResetCreateRequest.java (1)
  • PasswordResetCreateRequest (6-10)
src/main/java/project/flipnote/notification/service/NotificationService.java (2)
src/main/java/project/flipnote/infra/firebase/FirebaseService.java (1)
  • RequiredArgsConstructor (24-61)
src/main/java/project/flipnote/notification/model/NotificationListRequest.java (1)
  • NotificationListRequest (5-6)
🔇 Additional comments (1)
src/main/java/project/flipnote/notification/service/NotificationService.java (1)

50-70: 확인 — 저장소 쿼리에 ORDER BY(n.id DESC) 존재하므로 추가 조치 불필요

NotificationRepository.findNotificationsByReceiverIdAndCursor JPQL에 ORDER BY n.id DESC가 명시되어 있어 서비스의 nextCursor 계산이 안정적입니다. Service 쪽에서 Pageable에 정렬을 추가로 명시할 필요는 없습니다.

참고 위치:

  • src/main/java/project/flipnote/notification/repository/NotificationRepository.java
    @query("SELECT n FROM Notification n WHERE (:cursor IS NULL OR n.id < :cursor) AND n.receiverId = :receiverId ORDER BY n.id DESC")
  • src/main/java/project/flipnote/notification/service/NotificationService.java (getNotifications, PageRequest.of(0, req.getSize() + 1))

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
src/main/java/project/flipnote/notification/repository/FcmTokenRepository.java (1)

18-22: 빈 리스트 IN 절 예외 가능 — 안전 래퍼 default 메서드 추가 제안 (이전 코멘트 재확인)

IN :tokens에 빈 리스트가 전달되면 JPA 구현체에 따라 예외가 발생할 수 있습니다. deleteByUserIdAndTokenInbulkUpdateLastUsedAt 모두 동일 리스크가 있으므로, 인터페이스 default 메서드로 안전 래퍼를 제공해 호출 측에서 실수로 빈 리스트를 넘겨도 no-op으로 처리되도록 하는 것을 권장합니다.

아래처럼 인터페이스 끝에 안전 래퍼를 추가해주세요:

@@
 	Optional<FcmToken> findByToken(String token);
+
+	// Safety wrappers to avoid empty IN-clause errors
+	default void deleteByUserIdAndTokenInSafe(Long userId, List<String> tokens) {
+		if (tokens == null || tokens.isEmpty()) return;
+		deleteByUserIdAndTokenIn(userId, tokens);
+	}
+
+	default int bulkUpdateLastUsedAtSafe(List<String> tokens, LocalDateTime now) {
+		if (tokens == null || tokens.isEmpty()) return 0;
+		return bulkUpdateLastUsedAt(tokens, now);
+	}
 }
🧹 Nitpick comments (2)
src/main/java/project/flipnote/notification/repository/FcmTokenRepository.java (2)

20-22: 대용량 IN 파라미터 한계/성능 고려 — 서비스 계층에서 청크 처리 권장

토큰 수가 커질 경우 DB마다 IN 파라미터 수 제한(예: Oracle 1000, PostgreSQL 32767 등)에 걸리거나 플랜 품질에 영향이 있을 수 있습니다. 서비스 계층에서 토큰을 청크로 나눠 여러 번 호출하는 방식으로 방어하는 것을 권장합니다.

예시(서비스 계층):

private static final int BATCH_SIZE = 900; // DB 제약에 맞게 조정

int updateLastUsedAtInBatches(List<String> tokens, LocalDateTime now) {
    int affected = 0;
    for (int i = 0; i < tokens.size(); i += BATCH_SIZE) {
        List<String> chunk = tokens.subList(i, Math.min(i + BATCH_SIZE, tokens.size()));
        affected += fcmTokenRepository.bulkUpdateLastUsedAt(chunk, now);
    }
    return affected;
}

DB 종류와 토큰 리스트의 최대 크기를 알려주시면 적정 배치 크기 산정에 도움 됩니다.


16-16: 대량 사용자 토큰 조회 패턴 대비 — 페이징/슬라이스 메서드 추가 고려

findByUserId(Long userId)는 사용자당 토큰이 많을 경우 메모리 사용량이 커질 수 있습니다. 필요 시 추가로 findAllByUserId(Long userId, Pageable pageable) 또는 Slice<FcmToken> findAllByUserId(Long userId, Pageable pageable)를 제공하는 것을 검토해 주세요.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 7286e0b and 33e955b.

📒 Files selected for processing (5)
  • src/main/java/project/flipnote/auth/listener/UserWithdrawnEventListener.java (2 hunks)
  • src/main/java/project/flipnote/common/entity/MapToJsonConverter.java (1 hunks)
  • src/main/java/project/flipnote/common/model/request/CursorPageRequest.java (1 hunks)
  • src/main/java/project/flipnote/notification/repository/FcmTokenRepository.java (1 hunks)
  • src/main/java/project/flipnote/notification/service/NotificationService.java (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/main/java/project/flipnote/common/model/request/CursorPageRequest.java
  • src/main/java/project/flipnote/common/entity/MapToJsonConverter.java
  • src/main/java/project/flipnote/auth/listener/UserWithdrawnEventListener.java
  • src/main/java/project/flipnote/notification/service/NotificationService.java
🧰 Additional context used
🧬 Code Graph Analysis (1)
src/main/java/project/flipnote/notification/repository/FcmTokenRepository.java (2)
src/main/java/project/flipnote/auth/repository/UserAuthRepository.java (2)
  • UserAuthRepository (13-35)
  • Modifying (32-34)
src/main/java/project/flipnote/group/repository/GroupInvitationRepository.java (2)
  • GroupInvitationRepository (17-41)
  • Modifying (35-40)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (1)
src/main/java/project/flipnote/notification/repository/FcmTokenRepository.java (1)

20-22: @Modifying 옵션(L1/flush) 설정 적절 — 영속성 컨텍스트 일관성 보장

clearAutomatically = true, flushAutomatically = true 설정으로 벌크 업데이트 이후 1차 캐시 불일치를 예방한 점 좋습니다.

Copy link
Member

@stoneTiger0912 stoneTiger0912 left a comment

Choose a reason for hiding this comment

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

P1: 확인했습니다.

@dungbik dungbik merged commit a9676b8 into develop Aug 17, 2025
3 checks passed
@dungbik dungbik deleted the feat/notification branch August 17, 2025 05:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants