Skip to content

version 2.17.1#349

Merged
rlagkswn00 merged 3 commits intomainfrom
develop
Feb 26, 2026
Merged

version 2.17.1#349
rlagkswn00 merged 3 commits intomainfrom
develop

Conversation

@rlagkswn00
Copy link
Member

@rlagkswn00 rlagkswn00 commented Feb 26, 2026

Summary by CodeRabbit

Release Notes

  • New Features

    • 동아리 구독 기능 추가: 사용자가 관심 있는 동아리를 구독/구독 취소할 수 있습니다.
    • 동아리 모집 마감 알림: 매일 18:00에 마감 예정인 동아리에 대한 알림을 자동으로 발송합니다.
    • 동아리 정보 관리: 다양한 카테고리, 소속 정보와 함께 동아리 데이터를 관리할 수 있습니다.
  • Database

    • 동아리, 동아리 구독, 동아리 SNS 정보 저장을 위한 데이터베이스 테이블 추가.

jiyun921 and others added 3 commits February 15, 2026 07:21
* [feat]: Club, ClubBookmark 엔티티 구현

* [feat]: club 및 club_bookmark 테이블 Flyway 마이그레이션 추가

* [feat]: ClubCategory enum 구현

* [feat]: ClubDivision enum 구현

* [test]: Club 도메인 테스트 구현

* [fix]: Club category/division 컬럼 NOT NULL 제약 반영

* [refactor]: Club 위치 필드명을 의미 명확화를 위해 lat, lon으로 수정

* [refactor]: description, qualifications 필드 @lob@column(TEXT)로 변경

* [feat]: ClubSns 엔티티 및 ClubSnsType enum 추가, Club에 SNS 1:N 구조 적용

* [refactor]: 즐겨찾기 -> 구독으로 명칭 변경

* [refactor]: db club 테이블 변경사항 수정

* [refactor]: club 에러코드 네이밍 수정

---------

Co-authored-by: Han Ju Kim <[email protected]>
* feat : Club Equals & HashCode 오버라이딩

* feat : KuringFeatures 추가

* feat : Club MessageType 추가

* feat : 사용자 동아리 즐겨찾기 추가/삭제 API Adapter추가

* feat : 동아리 즐겨찾기 Request & Command 추가

* feat : 동아리 즐겨찾기 Usecase & Service 추가

* feat : Club & ClubSubscribe Query & CommandPort 추가

* feat : Club & ClubSubscribe Repository추가

* feat : ClubQueryRepository & Impl 추가

* feat : ClubPersistenceAdapter 추가

* feat : ClubSubscribe RootUser Mapping 수정

* feat : ClubNotificationUseCase & Service 추가

* feat : ClubNotificationScheduler 추가

* feat : Club ErrorCode & ResponseCodeAndMessages 추가

* feat : Club 구독 응답 DTO 추가

* feat : ClubSubscribe user -> rootUser 매핑 변경

* feat : 사용자 구독 추가/취소 인수테스트 추가

* feat : Club 도메인 DependencyRuleTests 추가

* feat : DatabaseConfigurator 동아리 init 추가

* feat : ClubPersistenceAdapterTest 추가

* feat : ClubNotificationServiceTest 추가

* feat : ClubNotificationSchedulerTest 추가

* feat : ClubCommandServiceTest 추가

* test : ClubPersistenceAdapterTest 테스트 간소화

* test : ClubServiceTest 리팩토링

* test : Club Service & Adapter Test assertAll 처리

* test: 미사용 메서드 제거

* remove: 미사용 Enum 제거

* fix : 동아리 즐겨찾기 삭제 API Request형식 수정

* fix : 동아리 즐겨찾기 추가/삭제 시 dev suffix추가하도록 수정

* test : ServerProperties Mock 추가

* remove : 미사용 메서드 제거(equals, hashcode)

* fix : JWT static final String 으로 분리

* fix : club_subscribe 테이블 root_user_id로 컬럼 변경 시 기존 데이터 무시
@rlagkswn00 rlagkswn00 self-assigned this Feb 26, 2026
@rlagkswn00 rlagkswn00 added the 🚀 Deploy Production deploy production server label Feb 26, 2026
@coderabbitai
Copy link

coderabbitai bot commented Feb 26, 2026

Walkthrough

동아리 구독 및 알림 기능을 추가하는 변경사항입니다. 동아리 도메인 엔티티, 포트 인터페이스, 퍼시스턴스 어댑터, 애플리케이션 서비스, REST API, 스케줄러, 데이터베이스 마이그레이션, 그리고 통합 테스트를 포함합니다.

Changes

Cohort / File(s) Summary
동아리 도메인 엔티티
src/main/java/com/kustacks/kuring/club/domain/Club.java, ClubSns.java, ClubSubscribe.java, ClubCategory.java, ClubDivision.java, ClubSnsType.java
JPA 엔티티 및 열거형 추가: Club(동아리 정보), ClubSns(소셜 미디어 링크), ClubSubscribe(구독 관계), 그리고 카테고리/분류/SNS 타입 열거형 정의.
포트 인터페이스
src/main/java/com/kustacks/kuring/club/application/port/in/ClubSubscriptionUseCase.java, ClubNotificationUseCase.java, dto/ClubSubscriptionCommand.java, out/ClubQueryPort.java, ClubSubscriptionCommandPort.java, ClubSubscriptionQueryPort.java
인바운드/아웃바운드 포트 정의: 구독 관리, 알림 발송, 동아리 조회 작업 인터페이스.
퍼시스턴스 계층
src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubPersistenceAdapter.java, ClubRepository.java, ClubSubscribeRepository.java, ClubQueryRepository.java, ClubQueryRepositoryImpl.java
ClubPersistenceAdapter가 3개 포트 구현; QueryDSL 기반 ClubQueryRepositoryImpl; Spring Data JPA 저장소.
애플리케이션 서비스
src/main/java/com/kustacks/kuring/club/application/service/ClubCommandService.java, ClubNotificationService.java
ClubCommandService: 구독 추가/제거 로직, 검증, 장치 주제 구독 처리. ClubNotificationService: Firebase 메시지 발송 및 헬퍼 메서드.
REST API 계층
src/main/java/com/kustacks/kuring/user/adapter/in/web/UserClubSubscriptionApiV2.java, dto/UserClubSubscriptionRequest.java, dto/UserClubSubscriptionCountResponse.java
POST/DELETE 엔드포인트: /api/v2/users/subscriptions/clubs, JWT 검증, 구독 관리 위임.
스케줄러 및 공통 변경
src/main/java/com/kustacks/kuring/worker/notification/ClubNotificationScheduler.java, src/main/java/com/kustacks/kuring/common/dto/ResponseCodeAndMessages.java, src/main/java/com/kustacks/kuring/common/exception/code/ErrorCode.java, src/main/java/com/kustacks/kuring/common/featureflag/KuringFeatures.java, src/main/java/com/kustacks/kuring/message/domain/MessageType.java, src/main/java/com/kustacks/kuring/message/application/port/out/dto/NoticeMessageDto.java
스케줄러: 매일 18:00에 동아리 알림 발송(기능 플래그 제어). 공통: 오류 코드, 응답 메시지, 메시지 타입(CLUB 추가), 기능 플래그, NoticeMessageDto id 필드를 Long에서 String으로 변경.
데이터베이스 마이그레이션
src/main/resources/db/migration/V260206__Create_club_table.sql, V260217__Alter_club_subscribe_to_root_user.sql
테이블 생성: club, club_subscribe, club_sns. 마이그레이션: club_subscribe를 user_id에서 root_user_id로 변경.
테스트
src/test/java/com/kustacks/kuring/acceptance/UserAcceptanceTest.java, UserStep.java, src/test/java/com/kustacks/kuring/club/..., src/test/java/com/kustacks/kuring/archunit/DependencyRuleTests.java, src/test/java/com/kustacks/kuring/worker/notification/ClubNotificationSchedulerTest.java, src/test/java/com/kustacks/kuring/support/DatabaseConfigurator.java
통합/단위 테스트: 구독 성공/실패 케이스, 알림 발송, 도메인 enum 매핑, 아키텍처 규칙 검증, 스케줄러 기능 플래그 테스트. 테스트 지원: 동아리 테이블 초기화.

Sequence Diagram(s)

sequenceDiagram
    actor Client
    participant API as UserClubSubscriptionApiV2
    participant UseCase as ClubCommandService
    participant QueryPort as ClubQueryPort
    participant CommandPort as ClubSubscriptionCommandPort
    participant QueryPort2 as ClubSubscriptionQueryPort
    participant Device as DeviceSubscriptionPort

    Client->>API: POST /api/v2/users/subscriptions/clubs<br/>(email, clubId)
    activate API
    API->>API: validateJwtAndGetEmail()
    
    API->>UseCase: addSubscription(command)
    activate UseCase
    
    UseCase->>QueryPort: findClubById(clubId)
    activate QueryPort
    QueryPort-->>UseCase: Optional<Club>
    deactivate QueryPort
    
    alt Club not found
        UseCase-->>API: InvalidStateException(CLUB_NOT_FOUND)
    else
        UseCase->>QueryPort2: existsSubscription(userId, clubId)
        activate QueryPort2
        QueryPort2-->>UseCase: boolean
        deactivate QueryPort2
        
        alt Already subscribed
            UseCase-->>API: InvalidStateException(CLUB_ALREADY_SUBSCRIBED)
        else
            UseCase->>CommandPort: saveSubscription(rootUser, club)
            activate CommandPort
            CommandPort-->>UseCase: void
            deactivate CommandPort
            
            UseCase->>Device: subscribeToTopic(club.{clubId})
            activate Device
            Device-->>UseCase: void
            deactivate Device
            
            UseCase->>QueryPort2: countSubscriptions(userId)
            activate QueryPort2
            QueryPort2-->>UseCase: long
            deactivate QueryPort2
            
            UseCase-->>API: count
        end
    end
    
    deactivate UseCase
    API-->>Client: 200 OK<br/>{subscriptionCount, message}
    deactivate API
Loading
sequenceDiagram
    participant Scheduler as ClubNotificationScheduler
    participant UseCase as ClubNotificationService
    participant QueryPort as ClubQueryPort
    participant Firebase as FirebaseMessagingPort
    participant Config as ServerProperties

    Scheduler->>Scheduler: Feature Flag Check<br/>(NOTIFY_CLUB_DEADLINE)
    
    alt Feature Flag OFF
        Scheduler->>Scheduler: Return early
    else Feature Flag ON
        Scheduler->>UseCase: sendDeadlineNotifications()
        activate UseCase
        
        UseCase->>QueryPort: findNextDayRecruitEndClubs(now)
        activate QueryPort
        QueryPort-->>UseCase: List<Club>
        deactivate QueryPort
        
        loop For each Club
            UseCase->>Config: getEnvironmentSuffix()
            activate Config
            Config-->>UseCase: suffix
            deactivate Config
            
            UseCase->>UseCase: buildMessage()<br/>(club, topic, payload)
            
            UseCase->>Firebase: send(message)
            activate Firebase
            alt Send succeeds
                Firebase-->>UseCase: messageId
            else Send fails
                Firebase-->>UseCase: Exception
                UseCase->>UseCase: Log error<br/>(continue)
            end
            deactivate Firebase
        end
        
        UseCase-->>Scheduler: void
        deactivate UseCase
    end
Loading

Possibly related PRs

Suggested labels

⭐ Feat

Poem

🐰 동아리 구독 기능, 활짝 피어나고
알림도 스케줄마다 쏟아져 내려
포트와 서비스 정갈히 설계하여
테스트는 촘촘이 짜여 있으니
우리의 플랫폼은 더욱 풍요로워! 🌟

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Title check ⚠️ Warning PR 제목 'version 2.17.1'은 버전 번호만 나타내며, 실제 변경 사항(동아리 기능 추가, 구독 기능, 알림 스케줄러 등)을 전혀 반영하지 않아 변경 내용을 명확히 전달하지 못합니다. PR 제목을 '동아리 구독 및 마감 알림 기능 추가' 등으로 변경하여 주요 변경 사항을 구체적으로 설명해주세요.
Docstring Coverage ⚠️ Warning Docstring coverage is 1.12% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch develop

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

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link

Unit Test Results

  81 files  +  6    81 suites  +6   1m 35s ⏱️ +8s
570 tests +25  563 ✔️ +25  7 💤 ±0  0 ±0 
573 runs  +25  566 ✔️ +25  7 💤 ±0  0 ±0 

Results for commit e5e2d49. ± Comparison against base commit 5bc43a7.

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: 5

🧹 Nitpick comments (13)
src/test/java/com/kustacks/kuring/archunit/DependencyRuleTests.java (1)

176-178: 메서드명 및 DisplayName 네이밍 패턴 일관성

기존 테스트들은 validateXxxArchitecture 패턴과 "Xxx 아키텍처 검증" DisplayName을 사용합니다. 일관성을 위해 수정을 고려해 주세요.

♻️ 네이밍 일관성 개선 제안
-	`@DisplayName`("Club Domain 의존성 검증")
+	`@DisplayName`("Club 아키텍처 검증")
 	`@Test`
-	void validateClubDomainDependencies() {
+	void validateClubArchitecture() {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/test/java/com/kustacks/kuring/archunit/DependencyRuleTests.java` around
lines 176 - 178, The test method validateClubDomainDependencies() and its
`@DisplayName` currently break the project's naming pattern; rename the method to
validateClubDomainArchitecture() and update the `@DisplayName` to match the
convention (e.g., "Club 아키텍처 검증") so it follows the existing
validateXxxArchitecture / "Xxx 아키텍처 검증" pattern, and update any
references/imports/usages accordingly (search for validateClubDomainDependencies
and the old display name) to keep tests compiling and consistent.
src/test/java/com/kustacks/kuring/support/DatabaseConfigurator.java (1)

134-143: initClub()의 하드코딩된 enum 문자열을 상수화하여 타입 안전성을 높이세요.

Line 139, Line 140의 리터럴("ACADEMIC", "CENTRAL")을 enum의 .name() 메서드로 대체하면 리팩토링 시 자동으로 반영되어 테스트의 안정성이 향상됩니다. 현재 값은 JPA @Enumerated(EnumType.STRING)의 저장 형식과 일치하지만, enum 상수에서 직접 값을 가져오는 방식이 더 유지보수하기 좋습니다.

♻️ 제안 diff
+import com.kustacks.kuring.club.domain.ClubCategory;
+import com.kustacks.kuring.club.domain.ClubDivision;
+
     private void initClub() {
         jdbcTemplate.update(
                 "INSERT INTO club (name, summary, category, division, is_always) VALUES (?, ?, ?, ?, ?)",
                 "테스트동아리1",
                 "테스트 요약",
-                "ACADEMIC",
-                "CENTRAL",
+                ClubCategory.ACADEMIC.name(),
+                ClubDivision.CENTRAL.name(),
                 false
         );
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/test/java/com/kustacks/kuring/support/DatabaseConfigurator.java` around
lines 134 - 143, Replace the hardcoded enum string literals in initClub() with
the corresponding enum .name() values to improve type-safety and
maintainability: locate the jdbcTemplate.update call inside initClub() and
change the category and division arguments from "ACADEMIC" and "CENTRAL" to the
enum references (e.g., ClubCategory.ACADEMIC.name() and
ClubDivision.CENTRAL.name()) so the persisted strings come directly from the
enum constants.
src/main/java/com/kustacks/kuring/message/application/port/out/dto/NoticeMessageDto.java (1)

53-53: String.valueOf()의 null 처리 동작 검토

notice.getId()null을 반환하는 경우, String.valueOf(null)은 문자열 "null"을 반환합니다. 이후 Line 33의 Assert.notNull(id, ...) 검증을 통과하게 되어 의도치 않은 "null" 문자열이 저장될 수 있습니다.

Notice 엔티티의 id가 항상 non-null임이 보장된다면 문제없지만, 방어적 코딩을 원한다면 명시적인 null 체크를 고려해 주세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/kustacks/kuring/message/application/port/out/dto/NoticeMessageDto.java`
at line 53, The current .id(String.valueOf(notice.getId())) call in
NoticeMessageDto will convert a null id to the literal "null" and bypass the
later Assert.notNull(id,...); fix by performing an explicit null check on
notice.getId() before converting: either assert/throw early with
Assert.notNull(notice.getId(), "...") (preferred if id must never be null) or
set id with a null-aware conversion like notice.getId() != null ?
String.valueOf(notice.getId()) : null so the subsequent Assert.notNull(id, ...)
behaves correctly; update the builder call in NoticeMessageDto accordingly.
src/main/java/com/kustacks/kuring/club/application/port/in/dto/ClubSubscriptionCommand.java (1)

3-7: 입력 검증 추가 고려

Command 객체에 기본적인 입력 검증을 추가하면 잘못된 데이터가 서비스 레이어까지 전달되는 것을 조기에 방지할 수 있습니다.

♻️ 검증 추가 제안
 package com.kustacks.kuring.club.application.port.in.dto;

+import org.springframework.util.Assert;
+
 public record ClubSubscriptionCommand(
         String email,
         Long clubId
 ) {
+    public ClubSubscriptionCommand {
+        Assert.hasText(email, "email must not be empty");
+        Assert.notNull(clubId, "clubId must not be null");
+    }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/kustacks/kuring/club/application/port/in/dto/ClubSubscriptionCommand.java`
around lines 3 - 7, The ClubSubscriptionCommand record lacks input validation;
add a compact canonical constructor in ClubSubscriptionCommand to validate its
components (email and clubId): ensure email is non-null, non-blank and matches a
simple email pattern, and ensure clubId is non-null and > 0, throwing
IllegalArgumentException (or NullPointerException for null) with clear messages
on failure so invalid data is rejected before reaching the service layer.
src/main/java/com/kustacks/kuring/user/adapter/in/web/dto/UserClubSubscriptionRequest.java (1)

5-7: 필드 이름을 clubId로 명확하게 변경하는 것을 권장합니다.

현재 id 필드명은 모호합니다. 이 DTO가 동아리 구독 요청에 사용되므로 clubId로 명명하면 API 문서화와 코드 가독성이 향상됩니다.

♻️ 제안된 수정
 public record UserClubSubscriptionRequest(
-        `@NotNull` Long id
+        `@NotNull` Long clubId
 ) {
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/kustacks/kuring/user/adapter/in/web/dto/UserClubSubscriptionRequest.java`
around lines 5 - 7, Rename the ambiguous record field id to clubId in
UserClubSubscriptionRequest and update all call sites, tests, mappers,
controllers and API docs to use the new name; specifically change the record
declaration in UserClubSubscriptionRequest (and any constructor/parameter
references) to use clubId (keeping `@NotNull`) and update any JSON
serialization/deserialization annotations or consumers (e.g., controllers,
service methods, DTO mappers, request binding) so they expect "clubId" instead
of "id" to maintain compatibility with API documentation and improve
readability.
src/main/java/com/kustacks/kuring/club/domain/ClubDivision.java (1)

36-56: ClubCategory와 동일한 패턴 - 공통 추상화 고려

ClubCategory와 동일한 NAME_MAP 패턴 및 fromName() 구현을 사용하고 있습니다. 현재 구조도 동작하지만, 향후 유사한 enum이 추가될 경우 공통 인터페이스나 유틸리티 메서드로 추출하는 것을 고려해볼 수 있습니다.

ClubCategory에서 제안한 것과 동일하게 Map<String, ClubDivision>으로 직접 enum을 저장하면 코드가 간결해집니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/com/kustacks/kuring/club/domain/ClubDivision.java` around lines
36 - 56, The NAME_MAP currently maps names to enum name strings and
fromName(String) does an extra lookup with ClubDivision.valueOf; change NAME_MAP
to Map<String, ClubDivision> (store the enum instance) by building it with
Arrays.stream(values()).collect(Collectors.toMap(ClubDivision::getName,
Function.identity())) and make it unmodifiable, then simplify fromName(String)
to return Optional.ofNullable(NAME_MAP.get(name)).orElseThrow(...); also
consider extracting this pattern into a shared utility or interface (as
suggested for ClubCategory) to avoid duplication across enums like ClubCategory
and ClubDivision.
src/main/java/com/kustacks/kuring/club/application/service/ClubNotificationService.java (2)

25-25: CLUB_TOPIC_PREFIX 상수가 중복 정의되어 있습니다

ClubCommandService에도 동일한 상수(CLUB_TOPIC_PREFIX = "club.")가 정의되어 있습니다. 중복을 제거하고 공통 상수 클래스나 도메인 객체로 추출하는 것을 권장합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/kustacks/kuring/club/application/service/ClubNotificationService.java`
at line 25, CLUB_TOPIC_PREFIX is duplicated in ClubNotificationService and
ClubCommandService; extract it into a single shared constant (e.g., a new
ClubConstants or TopicConstants) and replace the local definitions with
references to that shared constant. Specifically, remove the private static
final String CLUB_TOPIC_PREFIX from ClubNotificationService and
ClubCommandService, add the new shared constant, and update all usages in
methods/classes that referenced CLUB_TOPIC_PREFIX to import/reference the new
constant (ensuring visibility is public/static/final as needed). Ensure tests
and imports are updated accordingly.

49-51: LocalDateTime.now() 직접 호출은 테스트 용이성을 저하시킵니다

findDeadlineClubs() 메서드 내에서 LocalDateTime.now()를 직접 호출하면 단위 테스트에서 시간을 제어하기 어렵습니다. Clock을 주입받거나, 현재 시간을 파라미터로 받는 방식을 고려해주세요.

♻️ Clock 주입 방식 제안
+import java.time.Clock;
+
 `@Slf4j`
 `@UseCase`
 `@RequiredArgsConstructor`
 public class ClubNotificationService implements ClubNotificationUseCase {
 
+    private final Clock clock;
     private final ClubQueryPort clubQueryPort;
     // ...
 
     private List<Club> findDeadlineClubs() {
-        return clubQueryPort.findNextDayRecruitEndClubs(LocalDateTime.now());
+        return clubQueryPort.findNextDayRecruitEndClubs(LocalDateTime.now(clock));
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/kustacks/kuring/club/application/service/ClubNotificationService.java`
around lines 49 - 51, The findDeadlineClubs() method calls LocalDateTime.now()
directly which makes tests time-dependent; refactor to obtain current time from
an injectable Clock or by passing the current LocalDateTime as a parameter.
Specifically, change findDeadlineClubs() to use a Clock field (inject via
constructor) or add a parameter (e.g., LocalDateTime now) and call
clubQueryPort.findNextDayRecruitEndClubs(now) instead of LocalDateTime.now();
update callers and unit tests to supply a fixed Clock or fixed time when
testing.
src/main/java/com/kustacks/kuring/club/domain/Club.java (1)

59-61: 위도/경도 필드의 유효성 검증 고려

latlon 필드가 Double 타입으로 선언되어 있습니다. 도메인 레벨에서 유효 범위(-9090, -180180) 검증이 필요한지 검토해주세요. 현재는 데이터 무결성이 외부(API 또는 DB 제약)에 의존합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/com/kustacks/kuring/club/domain/Club.java` around lines 59 -
61, Add domain-level range validation for the Club class's lat and lon fields:
implement checks for lat in [-90, 90] and lon in [-180, 180] inside the Club
class (e.g., in the constructor that sets coordinates, in setLat and setLon
methods, or in a `@PrePersist/`@PreUpdate validateCoordinates method) and throw a
clear IllegalArgumentException (or a javax.validation.ConstraintViolation) when
out of range; alternatively add javax.validation annotations
(`@DecimalMin/`@DecimalMax or a custom `@Latitude/`@Longitude constraint) on the lat
and lon fields to enforce the same ranges so validation happens at the domain
level rather than relying on external API/DB constraints.
src/main/java/com/kustacks/kuring/club/domain/ClubCategory.java (1)

22-42: NAME_MAP 구조 간소화 가능

현재 NAME_MAPname -> enum.name() 매핑 후 valueOf()를 호출하는 2단계 조회를 수행합니다. Map<String, ClubCategory>로 직접 enum을 저장하면 코드가 더 간결해집니다.

♻️ 간소화 제안
-    private static final Map<String, String> NAME_MAP;
+    private static final Map<String, ClubCategory> NAME_MAP;
 
     static {
         NAME_MAP = Collections.unmodifiableMap(Arrays.stream(ClubCategory.values())
-                .collect(Collectors.toMap(ClubCategory::getName, ClubCategory::name))
+                .collect(Collectors.toMap(ClubCategory::getName, c -> c))
         );
     }
 
     // ...
 
     public static ClubCategory fromName(String name) {
-        String findName = Optional.ofNullable(NAME_MAP.get(name))
+        return Optional.ofNullable(NAME_MAP.get(name))
                 .orElseThrow(() -> new NotFoundException(CLUB_CATEGORY_NOT_SUPPORTED));
-        return ClubCategory.valueOf(findName);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/com/kustacks/kuring/club/domain/ClubCategory.java` around lines
22 - 42, Replace the current NAME_MAP and two-step lookup with a Map<String,
ClubCategory> to store the enum directly: change NAME_MAP to Map<String,
ClubCategory>, initialize it by mapping ClubCategory::getName to
Function.identity() (or v -> v) inside the static block, and update
fromName(String name) to retrieve the ClubCategory directly from NAME_MAP (using
Optional.ofNullable(...).orElseThrow(...)) instead of fetching a String and
calling ClubCategory.valueOf; this simplifies the lookup and removes the extra
valueOf call while keeping NotFoundException(CLUB_CATEGORY_NOT_SUPPORTED)
behavior.
src/test/java/com/kustacks/kuring/club/application/service/ClubNotificationServiceTest.java (2)

75-75: Unchecked cast 경고에 대한 억제 어노테이션을 고려하세요.

ReflectionTestUtils.getField는 Object를 반환하므로 Map<String, String>으로의 캐스팅 시 unchecked cast 경고가 발생합니다. 테스트 코드이므로 심각하지 않지만, @SuppressWarnings("unchecked")를 메소드 레벨에 추가하면 경고를 명시적으로 처리할 수 있습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/test/java/com/kustacks/kuring/club/application/service/ClubNotificationServiceTest.java`
at line 75, Add a `@SuppressWarnings`("unchecked") annotation at the test method
level in ClubNotificationServiceTest where ReflectionTestUtils.getField is cast
to Map<String, String> (the line assigning to variable data) to explicitly
suppress the unchecked cast warning from casting the Object returned by
ReflectionTestUtils.getField to Map<String, String>.

72-80: 두 번째 메시지(club2)에 대한 검증이 누락되었습니다.

첫 번째 메시지(club.1.dev)만 검증하고 있습니다. 두 번째 메시지(club.2.dev)도 올바른 topic과 data를 가지는지 검증하면 테스트 커버리지가 더 완전해집니다.

♻️ 두 번째 메시지 검증 추가 제안
         Map<String, String> data = (Map<String, String>) ReflectionTestUtils.getField(sent, "data");
+        Message sent2 = captor.getAllValues().get(1); // 2번 ID에 대한 Club 메시지
+        String topic2 = (String) ReflectionTestUtils.getField(sent2, "topic");
+        Map<String, String> data2 = (Map<String, String>) ReflectionTestUtils.getField(sent2, "data");
         assertAll(
                 () -> assertThat(topic).isEqualTo("club.1.dev"),
                 () -> assertThat(data).containsEntry("clubId", "1"),
-                () -> assertThat(data).containsEntry("messageType", "club")
+                () -> assertThat(data).containsEntry("messageType", "club"),
+                () -> assertThat(topic2).isEqualTo("club.2.dev"),
+                () -> assertThat(data2).containsEntry("clubId", "2"),
+                () -> assertThat(data2).containsEntry("messageType", "club")
         );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/test/java/com/kustacks/kuring/club/application/service/ClubNotificationServiceTest.java`
around lines 72 - 80, The test only asserts the first captured Message; add
assertions for the second captured Message by retrieving
captor.getAllValues().get(1) (or equivalent) into a second variable (e.g.,
sent2), extract its "topic" and "data" with ReflectionTestUtils (like topic2 and
data2) and assert topic2 equals "club.2.dev" and data2 contains entries "clubId"
-> "2" and "messageType" -> "club" to fully verify both messages.
src/test/java/com/kustacks/kuring/club/application/service/ClubCommandServiceTest.java (1)

165-181: root user not found 케이스에 대한 테스트 추가를 고려하세요.

존재하지 않는 동아리(CLUB_NOT_FOUND)에 대한 테스트는 있지만, 존재하지 않는 root user(ROOT_USER_NOT_FOUND)에 대한 테스트가 없습니다. 서비스에서 이 케이스를 처리한다면 해당 테스트도 추가하는 것이 좋습니다.

♻️ 제안하는 테스트 케이스
`@DisplayName`("존재하지 않는 사용자는 동아리를 구독할 수 없다")
`@Test`
void add_subscription_fail_when_root_user_not_found() {
    //given
    when(rootUserQueryPort.findRootUserByEmail("[email protected]"))
            .thenReturn(Optional.empty());

    //when & then
    assertAll(
            () -> assertThatThrownBy(() -> service.addSubscription(new ClubSubscriptionCommand("[email protected]", 1L)))
                    .isInstanceOf(InvalidStateException.class)
                    .extracting(ex -> ((InvalidStateException) ex).getErrorCode())
                    .isEqualTo(ErrorCode.ROOT_USER_NOT_FOUND),
            () -> verify(userEventPort, never()).subscribeEvent(anyString(), anyString())
    );
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/test/java/com/kustacks/kuring/club/application/service/ClubCommandServiceTest.java`
around lines 165 - 181, Add a test that verifies addSubscription rejects
requests when the root user is missing: mock
rootUserQueryPort.findRootUserByEmail("[email protected]") to return
Optional.empty(), call service.addSubscription(new
ClubSubscriptionCommand("[email protected]", 1L)), assert that it throws
InvalidStateException with ErrorCode.ROOT_USER_NOT_FOUND, and verify that
userEventPort.subscribeEvent(...) is never invoked; place this alongside the
existing add_subscription_fail_when_club_not_found test to cover the
ROOT_USER_NOT_FOUND branch.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@src/main/java/com/kustacks/kuring/club/application/service/ClubCommandService.java`:
- Around line 46-49: The methods currently call external subscription operations
(subscribeAllLoggedInDevices / unsubscribeAllLoggedInDevices) inside the DB
transaction alongside clubSubscriptionCommandPort.saveSubscription /
deleteSubscription and countSubscriptionsQueryPort.countSubscriptions, which
risks data vs external-state inconsistency if the transaction rolls back; move
these external calls out of the transactional boundary by either publishing a
domain event or registering an after-commit callback (e.g.,
TransactionSynchronizationManager.registerSynchronization) from the
transactional methods (those invoking
clubSubscriptionCommandPort.saveSubscription and deleteSubscription and
countSubscriptionsQueryPort.countSubscriptions) and perform
subscribeAllLoggedInDevices/unsubscribeAllLoggedInDevices with makeTopic(...)
only in the after-commit handler so external subscriptions run only after
successful commit.
- Around line 42-47: The pre-check using isAlreadySubscription before calling
clubSubscriptionCommandPort.saveSubscription (and the similar delete flow)
causes race conditions; instead make save/delete operations rely on a DB
uniqueness/constraint and map database exceptions to domain errors: remove the
existence check in the subscribe path, call
clubSubscriptionCommandPort.saveSubscription(rootUser, club) inside a
transaction, catch the persistence exception (e.g.
DataIntegrityViolationException or the specific repository exception) and throw
InvalidStateException(ErrorCode.CLUB_ALREADY_SUBSCRIBED) on duplicate-key; for
unsubscribe, perform the delete via
clubSubscriptionCommandPort.deleteSubscription(...) and if the delete reports
zero rows affected throw InvalidStateException(ErrorCode.CLUB_NOT_SUBSCRIBED);
keep subscribeAllLoggedInDevices(rootUser.getId(), makeTopic(club)) only after a
confirmed successful save and ensure transactional boundaries around these
operations.

In `@src/main/java/com/kustacks/kuring/club/domain/Club.java`:
- Around line 47-48: The Club entity's homepageUrls List<ClubSns> (field name:
homepageUrls in class Club) lacks cascade/orphan removal settings, so deleting a
Club can leave ClubSns rows with a non-null club_id and cause FK violations;
update the `@OneToMany` on homepageUrls to manage ClubSns lifecycle by adding
cascade = CascadeType.REMOVE (or CascadeType.ALL as appropriate) and
orphanRemoval = true so child ClubSns are deleted when their parent Club is
removed.

In `@src/main/resources/db/migration/V260206__Create_club_table.sql`:
- Around line 36-39: 중복 인덱스가 존재합니다: CONSTRAINT uk_club_user 생성 시 (club_id,
user_id) 복합 인덱스가 생성되어 왼쪽 접두사로 club_id 단독 조회를 지원하므로 기존의 INDEX
idx_club_subscribe_club는 불필요합니다; SQL 마이그레이션(V260206__Create_club_table.sql)에서
INDEX idx_club_subscribe_club 항목을 제거하고 INDEX idx_club_subscribe_user 및
CONSTRAINT uk_club_user는 그대로 유지해 중복 인덱스를 제거하세요.

In
`@src/main/resources/db/migration/V260217__Alter_club_subscribe_to_root_user.sql`:
- Around line 1-3: The migration V260217__Alter_club_subscribe_to_root_user.sql
currently contains a DELETE FROM club_subscribe which will irreversibly delete
production data; instead, remove the unconditional DELETE, and either add a safe
data-migration step that maps existing user_id to root_user_id (e.g., an INSERT
... SELECT or UPDATE using a reliable user→root_user mapping) or add a guard
that aborts/backs up when club_subscribe contains rows (check COUNT(*) and fail
with clear message), ensuring the migration only deletes rows in dev where
confirmed empty; locate and update the SQL in
V260217__Alter_club_subscribe_to_root_user.sql and any helper scripts that
perform the user→root_user mapping to implement the safe path.

---

Nitpick comments:
In
`@src/main/java/com/kustacks/kuring/club/application/port/in/dto/ClubSubscriptionCommand.java`:
- Around line 3-7: The ClubSubscriptionCommand record lacks input validation;
add a compact canonical constructor in ClubSubscriptionCommand to validate its
components (email and clubId): ensure email is non-null, non-blank and matches a
simple email pattern, and ensure clubId is non-null and > 0, throwing
IllegalArgumentException (or NullPointerException for null) with clear messages
on failure so invalid data is rejected before reaching the service layer.

In
`@src/main/java/com/kustacks/kuring/club/application/service/ClubNotificationService.java`:
- Line 25: CLUB_TOPIC_PREFIX is duplicated in ClubNotificationService and
ClubCommandService; extract it into a single shared constant (e.g., a new
ClubConstants or TopicConstants) and replace the local definitions with
references to that shared constant. Specifically, remove the private static
final String CLUB_TOPIC_PREFIX from ClubNotificationService and
ClubCommandService, add the new shared constant, and update all usages in
methods/classes that referenced CLUB_TOPIC_PREFIX to import/reference the new
constant (ensuring visibility is public/static/final as needed). Ensure tests
and imports are updated accordingly.
- Around line 49-51: The findDeadlineClubs() method calls LocalDateTime.now()
directly which makes tests time-dependent; refactor to obtain current time from
an injectable Clock or by passing the current LocalDateTime as a parameter.
Specifically, change findDeadlineClubs() to use a Clock field (inject via
constructor) or add a parameter (e.g., LocalDateTime now) and call
clubQueryPort.findNextDayRecruitEndClubs(now) instead of LocalDateTime.now();
update callers and unit tests to supply a fixed Clock or fixed time when
testing.

In `@src/main/java/com/kustacks/kuring/club/domain/Club.java`:
- Around line 59-61: Add domain-level range validation for the Club class's lat
and lon fields: implement checks for lat in [-90, 90] and lon in [-180, 180]
inside the Club class (e.g., in the constructor that sets coordinates, in setLat
and setLon methods, or in a `@PrePersist/`@PreUpdate validateCoordinates method)
and throw a clear IllegalArgumentException (or a
javax.validation.ConstraintViolation) when out of range; alternatively add
javax.validation annotations (`@DecimalMin/`@DecimalMax or a custom
`@Latitude/`@Longitude constraint) on the lat and lon fields to enforce the same
ranges so validation happens at the domain level rather than relying on external
API/DB constraints.

In `@src/main/java/com/kustacks/kuring/club/domain/ClubCategory.java`:
- Around line 22-42: Replace the current NAME_MAP and two-step lookup with a
Map<String, ClubCategory> to store the enum directly: change NAME_MAP to
Map<String, ClubCategory>, initialize it by mapping ClubCategory::getName to
Function.identity() (or v -> v) inside the static block, and update
fromName(String name) to retrieve the ClubCategory directly from NAME_MAP (using
Optional.ofNullable(...).orElseThrow(...)) instead of fetching a String and
calling ClubCategory.valueOf; this simplifies the lookup and removes the extra
valueOf call while keeping NotFoundException(CLUB_CATEGORY_NOT_SUPPORTED)
behavior.

In `@src/main/java/com/kustacks/kuring/club/domain/ClubDivision.java`:
- Around line 36-56: The NAME_MAP currently maps names to enum name strings and
fromName(String) does an extra lookup with ClubDivision.valueOf; change NAME_MAP
to Map<String, ClubDivision> (store the enum instance) by building it with
Arrays.stream(values()).collect(Collectors.toMap(ClubDivision::getName,
Function.identity())) and make it unmodifiable, then simplify fromName(String)
to return Optional.ofNullable(NAME_MAP.get(name)).orElseThrow(...); also
consider extracting this pattern into a shared utility or interface (as
suggested for ClubCategory) to avoid duplication across enums like ClubCategory
and ClubDivision.

In
`@src/main/java/com/kustacks/kuring/message/application/port/out/dto/NoticeMessageDto.java`:
- Line 53: The current .id(String.valueOf(notice.getId())) call in
NoticeMessageDto will convert a null id to the literal "null" and bypass the
later Assert.notNull(id,...); fix by performing an explicit null check on
notice.getId() before converting: either assert/throw early with
Assert.notNull(notice.getId(), "...") (preferred if id must never be null) or
set id with a null-aware conversion like notice.getId() != null ?
String.valueOf(notice.getId()) : null so the subsequent Assert.notNull(id, ...)
behaves correctly; update the builder call in NoticeMessageDto accordingly.

In
`@src/main/java/com/kustacks/kuring/user/adapter/in/web/dto/UserClubSubscriptionRequest.java`:
- Around line 5-7: Rename the ambiguous record field id to clubId in
UserClubSubscriptionRequest and update all call sites, tests, mappers,
controllers and API docs to use the new name; specifically change the record
declaration in UserClubSubscriptionRequest (and any constructor/parameter
references) to use clubId (keeping `@NotNull`) and update any JSON
serialization/deserialization annotations or consumers (e.g., controllers,
service methods, DTO mappers, request binding) so they expect "clubId" instead
of "id" to maintain compatibility with API documentation and improve
readability.

In `@src/test/java/com/kustacks/kuring/archunit/DependencyRuleTests.java`:
- Around line 176-178: The test method validateClubDomainDependencies() and its
`@DisplayName` currently break the project's naming pattern; rename the method to
validateClubDomainArchitecture() and update the `@DisplayName` to match the
convention (e.g., "Club 아키텍처 검증") so it follows the existing
validateXxxArchitecture / "Xxx 아키텍처 검증" pattern, and update any
references/imports/usages accordingly (search for validateClubDomainDependencies
and the old display name) to keep tests compiling and consistent.

In
`@src/test/java/com/kustacks/kuring/club/application/service/ClubCommandServiceTest.java`:
- Around line 165-181: Add a test that verifies addSubscription rejects requests
when the root user is missing: mock
rootUserQueryPort.findRootUserByEmail("[email protected]") to return
Optional.empty(), call service.addSubscription(new
ClubSubscriptionCommand("[email protected]", 1L)), assert that it throws
InvalidStateException with ErrorCode.ROOT_USER_NOT_FOUND, and verify that
userEventPort.subscribeEvent(...) is never invoked; place this alongside the
existing add_subscription_fail_when_club_not_found test to cover the
ROOT_USER_NOT_FOUND branch.

In
`@src/test/java/com/kustacks/kuring/club/application/service/ClubNotificationServiceTest.java`:
- Line 75: Add a `@SuppressWarnings`("unchecked") annotation at the test method
level in ClubNotificationServiceTest where ReflectionTestUtils.getField is cast
to Map<String, String> (the line assigning to variable data) to explicitly
suppress the unchecked cast warning from casting the Object returned by
ReflectionTestUtils.getField to Map<String, String>.
- Around line 72-80: The test only asserts the first captured Message; add
assertions for the second captured Message by retrieving
captor.getAllValues().get(1) (or equivalent) into a second variable (e.g.,
sent2), extract its "topic" and "data" with ReflectionTestUtils (like topic2 and
data2) and assert topic2 equals "club.2.dev" and data2 contains entries "clubId"
-> "2" and "messageType" -> "club" to fully verify both messages.

In `@src/test/java/com/kustacks/kuring/support/DatabaseConfigurator.java`:
- Around line 134-143: Replace the hardcoded enum string literals in initClub()
with the corresponding enum .name() values to improve type-safety and
maintainability: locate the jdbcTemplate.update call inside initClub() and
change the category and division arguments from "ACADEMIC" and "CENTRAL" to the
enum references (e.g., ClubCategory.ACADEMIC.name() and
ClubDivision.CENTRAL.name()) so the persisted strings come directly from the
enum constants.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5bc43a7 and e5e2d49.

📒 Files selected for processing (40)
  • src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubPersistenceAdapter.java
  • src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubQueryRepository.java
  • src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubQueryRepositoryImpl.java
  • src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubRepository.java
  • src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubSubscribeRepository.java
  • src/main/java/com/kustacks/kuring/club/application/port/in/ClubNotificationUseCase.java
  • src/main/java/com/kustacks/kuring/club/application/port/in/ClubSubscriptionUseCase.java
  • src/main/java/com/kustacks/kuring/club/application/port/in/dto/ClubSubscriptionCommand.java
  • src/main/java/com/kustacks/kuring/club/application/port/out/ClubQueryPort.java
  • src/main/java/com/kustacks/kuring/club/application/port/out/ClubSubscriptionCommandPort.java
  • src/main/java/com/kustacks/kuring/club/application/port/out/ClubSubscriptionQueryPort.java
  • src/main/java/com/kustacks/kuring/club/application/service/ClubCommandService.java
  • src/main/java/com/kustacks/kuring/club/application/service/ClubNotificationService.java
  • src/main/java/com/kustacks/kuring/club/domain/Club.java
  • src/main/java/com/kustacks/kuring/club/domain/ClubCategory.java
  • src/main/java/com/kustacks/kuring/club/domain/ClubDivision.java
  • src/main/java/com/kustacks/kuring/club/domain/ClubSns.java
  • src/main/java/com/kustacks/kuring/club/domain/ClubSnsType.java
  • src/main/java/com/kustacks/kuring/club/domain/ClubSubscribe.java
  • src/main/java/com/kustacks/kuring/common/dto/ResponseCodeAndMessages.java
  • src/main/java/com/kustacks/kuring/common/exception/code/ErrorCode.java
  • src/main/java/com/kustacks/kuring/common/featureflag/KuringFeatures.java
  • src/main/java/com/kustacks/kuring/message/application/port/out/dto/NoticeMessageDto.java
  • src/main/java/com/kustacks/kuring/message/domain/MessageType.java
  • src/main/java/com/kustacks/kuring/user/adapter/in/web/UserClubSubscriptionApiV2.java
  • src/main/java/com/kustacks/kuring/user/adapter/in/web/dto/UserClubSubscriptionCountResponse.java
  • src/main/java/com/kustacks/kuring/user/adapter/in/web/dto/UserClubSubscriptionRequest.java
  • src/main/java/com/kustacks/kuring/worker/notification/ClubNotificationScheduler.java
  • src/main/resources/db/migration/V260206__Create_club_table.sql
  • src/main/resources/db/migration/V260217__Alter_club_subscribe_to_root_user.sql
  • src/test/java/com/kustacks/kuring/acceptance/UserAcceptanceTest.java
  • src/test/java/com/kustacks/kuring/acceptance/UserStep.java
  • src/test/java/com/kustacks/kuring/archunit/DependencyRuleTests.java
  • src/test/java/com/kustacks/kuring/club/adapter/out/persistence/ClubPersistenceAdapterTest.java
  • src/test/java/com/kustacks/kuring/club/application/service/ClubCommandServiceTest.java
  • src/test/java/com/kustacks/kuring/club/application/service/ClubNotificationServiceTest.java
  • src/test/java/com/kustacks/kuring/club/domain/ClubCategoryTest.java
  • src/test/java/com/kustacks/kuring/club/domain/ClubDivisionTest.java
  • src/test/java/com/kustacks/kuring/support/DatabaseConfigurator.java
  • src/test/java/com/kustacks/kuring/worker/notification/ClubNotificationSchedulerTest.java

Comment on lines +42 to +47
if (isAlreadySubscription(rootUser, club)) {
throw new InvalidStateException(ErrorCode.CLUB_ALREADY_SUBSCRIBED);
}

clubSubscriptionCommandPort.saveSubscription(rootUser, club);
subscribeAllLoggedInDevices(rootUser.getId(), makeTopic(club));
Copy link

@coderabbitai coderabbitai bot Feb 26, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

구독 상태 체크 후 저장/삭제하는 패턴이 동시성 경쟁 상태를 만듭니다.

existssave/delete가 원자적으로 보장되지 않아 동시 요청에서 중복 저장 또는 의도와 다른 성공/실패 결과가 발생할 수 있습니다. 저장/삭제는 DB 제약 기반으로 처리하고 충돌 예외를 도메인 예외로 매핑하는 방식이 필요합니다.

Also applies to: 57-61

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/kustacks/kuring/club/application/service/ClubCommandService.java`
around lines 42 - 47, The pre-check using isAlreadySubscription before calling
clubSubscriptionCommandPort.saveSubscription (and the similar delete flow)
causes race conditions; instead make save/delete operations rely on a DB
uniqueness/constraint and map database exceptions to domain errors: remove the
existence check in the subscribe path, call
clubSubscriptionCommandPort.saveSubscription(rootUser, club) inside a
transaction, catch the persistence exception (e.g.
DataIntegrityViolationException or the specific repository exception) and throw
InvalidStateException(ErrorCode.CLUB_ALREADY_SUBSCRIBED) on duplicate-key; for
unsubscribe, perform the delete via
clubSubscriptionCommandPort.deleteSubscription(...) and if the delete reports
zero rows affected throw InvalidStateException(ErrorCode.CLUB_NOT_SUBSCRIBED);
keep subscribeAllLoggedInDevices(rootUser.getId(), makeTopic(club)) only after a
confirmed successful save and ensure transactional boundaries around these
operations.

Copy link
Member Author

Choose a reason for hiding this comment

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

이 부분은 낙관적으로 처리하는거에 대한 고민을 해봐야겠네
이 부분을 요약해서 이슈로 만들어줘

Copy link

Choose a reason for hiding this comment

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

🧩 Analysis chain

@rlagkswn00, 동시성 이슈를 정리해서 GitHub 이슈로 생성하겠습니다.


🏁 Script executed:

#!/bin/bash

# Create GitHub issue for the concurrency race condition problem
gh issue create \
  --title "동아리 구독 기능의 동시성 경쟁 상태 개선" \
  --body "## 문제 설명

동아리 구독/구독 취소 기능에서 구독 상태를 확인한 후 저장/삭제하는 패턴이 동시성 경쟁 상태(race condition)를 발생시킬 수 있습니다.

## 발생 위치

- 파일: \`src/main/java/com/kustacks/kuring/club/application/service/ClubCommandService.java\`
- 메서드: \`addSubscription()\` 및 \`removeSubscription()\`
- 라인: 42-47, 57-61

## 현재 문제점

\`existsSubscription()\` 체크와 \`save()/delete()\` 작업이 원자적으로 보장되지 않아 다음과 같은 문제가 발생할 수 있습니다:

1. **구독 추가 시**: 동시에 여러 요청이 들어올 경우, 모두 존재하지 않는다고 판단하여 중복 저장이 시도될 수 있습니다.
2. **구독 취소 시**: 동시에 여러 삭제 요청이 들어올 경우, 모두 존재한다고 판단하여 의도와 다른 결과가 발생할 수 있습니다.

## 개선 방향 고려사항

낙관적 처리(optimistic) 방식을 포함한 다양한 동시성 제어 방안에 대한 검토가 필요합니다.

## 참고

- PR: https://github.com/ku-ring/ku-ring-backend-web/pull/349
- 코멘트: https://github.com/ku-ring/ku-ring-backend-web/pull/349#discussion_r2856267052
- 보고자: `@rlagkswn00`" \
  --assignee rlagkswn00

Length of output: 57


@rlagkswn00, 동시성 경쟁 상태 이슈를 생성했습니다!

이슈 #351: 동아리 구독 기능의 동시성 경쟁 상태 개선

낙관적 처리 방식을 포함한 다양한 동시성 제어 방안을 검토하실 때 참고하시면 됩니다.

Comment on lines +46 to +49
clubSubscriptionCommandPort.saveSubscription(rootUser, club);
subscribeAllLoggedInDevices(rootUser.getId(), makeTopic(club));

return countSubscriptionsQueryPort.countSubscriptions(rootUser.getId());
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

외부 토픽 구독/해제를 트랜잭션 내부에서 호출하면 데이터-외부상태 불일치가 발생할 수 있습니다.

DB 트랜잭션이 롤백되더라도 외부 구독 상태는 이미 변경될 수 있어 정합성이 깨질 수 있습니다. 외부 호출은 커밋 이후로 분리하는 게 안전합니다.

🔧 제안 수정안
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
@@
-        clubSubscriptionCommandPort.saveSubscription(rootUser, club);
-        subscribeAllLoggedInDevices(rootUser.getId(), makeTopic(club));
+        clubSubscriptionCommandPort.saveSubscription(rootUser, club);
+        runAfterCommit(() -> subscribeAllLoggedInDevices(rootUser.getId(), makeTopic(club)));
@@
-        clubSubscriptionCommandPort.deleteSubscription(rootUser, club);
-        unsubscribeAllLoggedInDevices(rootUser.getId(), makeTopic(club));
+        clubSubscriptionCommandPort.deleteSubscription(rootUser, club);
+        runAfterCommit(() -> unsubscribeAllLoggedInDevices(rootUser.getId(), makeTopic(club)));
@@
+    private void runAfterCommit(Runnable action) {
+        if (!TransactionSynchronizationManager.isActualTransactionActive()) {
+            action.run();
+            return;
+        }
+        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+            `@Override`
+            public void afterCommit() {
+                action.run();
+            }
+        });
+    }

Also applies to: 60-63, 80-92

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/com/kustacks/kuring/club/application/service/ClubCommandService.java`
around lines 46 - 49, The methods currently call external subscription
operations (subscribeAllLoggedInDevices / unsubscribeAllLoggedInDevices) inside
the DB transaction alongside clubSubscriptionCommandPort.saveSubscription /
deleteSubscription and countSubscriptionsQueryPort.countSubscriptions, which
risks data vs external-state inconsistency if the transaction rolls back; move
these external calls out of the transactional boundary by either publishing a
domain event or registering an after-commit callback (e.g.,
TransactionSynchronizationManager.registerSynchronization) from the
transactional methods (those invoking
clubSubscriptionCommandPort.saveSubscription and deleteSubscription and
countSubscriptionsQueryPort.countSubscriptions) and perform
subscribeAllLoggedInDevices/unsubscribeAllLoggedInDevices with makeTopic(...)
only in the after-commit handler so external subscriptions run only after
successful commit.

Comment on lines +47 to +48
@OneToMany(mappedBy = "club")
private List<ClubSns> homepageUrls = new ArrayList<>();
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find the ClubSns entity
find . -name "*.java" -type f | xargs grep -l "class ClubSns" | head -5

Repository: ku-ring/ku-ring-backend-web

Length of output: 131


🏁 Script executed:

# Check for ClubSnsRepository
find . -name "*.java" -type f | xargs grep -l "ClubSnsRepository" | head -10

Repository: ku-ring/ku-ring-backend-web

Length of output: 53


🏁 Script executed:

# Search for how ClubSns is saved/deleted
rg -n "clubSnsList|homepageUrls" -t java --context 3

Repository: ku-ring/ku-ring-backend-web

Length of output: 704


🏁 Script executed:

# Check the Club entity relationships
rg -n "class Club|@OneToMany|@ManyToOne" src/main/java/com/kustacks/kuring/club/domain/Club.java

Repository: ku-ring/ku-ring-backend-web

Length of output: 130


🏁 Script executed:

cat -n ./src/main/java/com/kustacks/kuring/club/domain/ClubSns.java

Repository: ku-ring/ku-ring-backend-web

Length of output: 1355


🏁 Script executed:

# Search for ClubSnsRepository more broadly
rg "ClubSns" -t java --type-list | head -20
fd -e java -type f | xargs grep -l "ClubSns" | grep -i repository

Repository: ku-ring/ku-ring-backend-web

Length of output: 631


🏁 Script executed:

# Check service layer for how ClubSns is managed
fd -e java -path "*/service/*" -type f | xargs grep -l "ClubSns" 2>/dev/null

Repository: ku-ring/ku-ring-backend-web

Length of output: 241


🏁 Script executed:

# Look for any usage patterns of homepageUrls or ClubSns operations
rg "homepageUrls|new ClubSns|\.setClubSns|\.addClubSns" -t java --context 2

Repository: ku-ring/ku-ring-backend-web

Length of output: 500


🏁 Script executed:

# Find ClubRepository and Club service
find . -name "*ClubRepository.java" -o -name "*ClubService.java" | head -10

Repository: ku-ring/ku-ring-backend-web

Length of output: 155


🏁 Script executed:

# Check for repository/service files
fd -e java -type f | xargs grep -l "repository\|service" | grep -i club | head -20

Repository: ku-ring/ku-ring-backend-web

Length of output: 241


🏁 Script executed:

# Search for delete operations on Club entity
rg "delete|remove" -t java --context 2 | grep -i club | head -30

Repository: ku-ring/ku-ring-backend-web

Length of output: 3978


🏁 Script executed:

# Check for any cascade type definitions in Club.java
cat src/main/java/com/kustacks/kuring/club/domain/Club.java | head -80

Repository: ku-ring/ku-ring-backend-web

Length of output: 1965


🏁 Script executed:

# Check ClubRepository for delete operations
cat ./src/main/java/com/kustacks/kuring/club/adapter/out/persistence/ClubRepository.java

Repository: ku-ring/ku-ring-backend-web

Length of output: 321


🏁 Script executed:

# Search for ClubSnsRepository
find . -name "*ClubSnsRepository.java" -o -name "*ClubSns*Repository.java"

Repository: ku-ring/ku-ring-backend-web

Length of output: 53


🏁 Script executed:

# Check service layer for Club deletion logic
find . -name "ClubCommandService.java" -o -name "ClubService.java" | xargs cat 2>/dev/null | head -100

Repository: ku-ring/ku-ring-backend-web

Length of output: 4277


🏁 Script executed:

# Search for any explicit ClubSns deletion before Club deletion
rg "delete.*ClubSns|remove.*ClubSns|ClubSns.*delete" -A 3 -B 3

Repository: ku-ring/ku-ring-backend-web

Length of output: 53


🏁 Script executed:

# Comprehensive search for Club deletion operations
rg "\.delete|\.remove|repository\.delete" -A 2 -B 2 | grep -i "club\|homepageurl" | head -50

Repository: ku-ring/ku-ring-backend-web

Length of output: 3625


🏁 Script executed:

# Check if there are any other service classes handling Club lifecycle
find . -name "*.java" -path "*/service/*" -o -name "*.java" -path "*/adapter/*" | xargs grep -l "Club" | grep -i "command\|delete\|remove" | head -10

Repository: ku-ring/ku-ring-backend-web

Length of output: 244


🏁 Script executed:

# Final check: look for all usages of homepageUrls in the codebase
rg "homepageUrls|ClubSns" -B 3 -A 3

Repository: ku-ring/ku-ring-backend-web

Length of output: 2336


@OneToMany 관계에 CascadeType 설정 필요 검토

homepageUrls 필드의 club_id 외래키는 NOT NULL 제약이 있습니다. Club 엔티티 삭제 시 적절한 cascade 옵션이 없으면 ClubSns 레코드가 고아 상태가 되어 외래키 제약 조건 위반이 발생합니다. ClubClubSns의 생명주기를 관리한다면 @OneToMany(mappedBy = "club", cascade = CascadeType.REMOVE, orphanRemoval = true) 추가를 고려하세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/com/kustacks/kuring/club/domain/Club.java` around lines 47 -
48, The Club entity's homepageUrls List<ClubSns> (field name: homepageUrls in
class Club) lacks cascade/orphan removal settings, so deleting a Club can leave
ClubSns rows with a non-null club_id and cause FK violations; update the
`@OneToMany` on homepageUrls to manage ClubSns lifecycle by adding cascade =
CascadeType.REMOVE (or CascadeType.ALL as appropriate) and orphanRemoval = true
so child ClubSns are deleted when their parent Club is removed.

Comment on lines +1 to +3
-- root_user로 바꾸면 데이터 이관을 해야하나, 아직 데이터가 존재하지 않는 개발 단계이므로 데이터 삭제하고 진행.
-- 1) 기존 device(user) 기준 구독 데이터는 유지하지 않는다.
DELETE FROM club_subscribe;
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

프로덕션 환경에서 데이터 손실 위험이 있습니다.

주석에서 개발 단계이므로 데이터 삭제를 진행한다고 설명하고 있지만, Flyway 마이그레이션은 한 번 적용되면 프로덕션 환경에서도 동일하게 실행됩니다. 만약 프로덕션에 이미 club_subscribe 데이터가 존재한다면 모든 구독 데이터가 영구 삭제됩니다.

다음 사항을 확인해 주세요:

  1. 프로덕션 환경에 이 마이그레이션이 적용되기 전에 club_subscribe 테이블에 데이터가 없음을 확인
  2. 또는 데이터 이관 로직 추가 고려 (예: user_id → root_user_id 매핑이 가능한 경우)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/resources/db/migration/V260217__Alter_club_subscribe_to_root_user.sql`
around lines 1 - 3, The migration V260217__Alter_club_subscribe_to_root_user.sql
currently contains a DELETE FROM club_subscribe which will irreversibly delete
production data; instead, remove the unconditional DELETE, and either add a safe
data-migration step that maps existing user_id to root_user_id (e.g., an INSERT
... SELECT or UPDATE using a reliable user→root_user mapping) or add a guard
that aborts/backs up when club_subscribe contains rows (check COUNT(*) and fail
with clear message), ensuring the migration only deletes rows in dev where
confirmed empty; locate and update the SQL in
V260217__Alter_club_subscribe_to_root_user.sql and any helper scripts that
perform the user→root_user mapping to implement the safe path.

@rlagkswn00 rlagkswn00 merged commit 8e0914a into main Feb 26, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🚀 Deploy Production deploy production server

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants