Conversation
…학기별로 분류하여 조회할 수 있다""
…-applicaton-semester-selection-MOA-231 Revert "Revert "[feature] 동아리 지원서 폼 제작 시에 학기를 선택할 수 있고 생성된 모든 지원서 폼을 …
active, published, unpublished
version 필드가 구현된 user로 변경
…-239 [feature] 해당 학기에 게시할 지원서 폼을 선택할 수 있다
- 지원서 id와 제목, description을 list로 반환
- TODO: status가 null인 경우(마이그레이션X), default로 할 status 결정해야 함
구버전 호환용 v1 api 제공
[Feature] 검색 결과에 총 개수를 숫자한다.
nosql에서 java.persistance.Id를 사용하면 save 메소드 사용시 중복 체크가 제대로 되지 않아 중복 저장시 upsert가 아닌 duplicate key error를 내게 된다고 한다
- save를 insert로 변경 : 신규 생성
…-content-MOA-301 [feature] 지원자의 지원서를 요약해서 메모에 추가한다.
…-diff [fix] 지원서 이전 모집상태와 비교하지않고 알림을 보내는 문제를 수정한다
…-MOA-318 refactor: server모드 삭제 및 에러코드 추가
…te-application-form-controller-MOA-317 [refactor] 동아리 지원서 양식을 부분적으로 수정가능하게만든다
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Warning
|
| 코호트 / 파일 | 변경 요약 |
|---|---|
CI/CD 워크플로우 .github/workflows/oracle_server_cd_develop.yml, .github/workflows/pr-test.yml, .github/workflows/server_cd_release.yml |
Firebase JSON 설정 자동화 추가, 워크플로우 트리거 개선, 다중 아키텍처(ARM64/AMD64) Docker 이미지 빌드 및 푸시 추가 |
의존성 및 설정 backend/build.gradle, backend/.gitignore, backend/perf/k6/.env.example, backend/perf/k6/script.js |
Spring AOP, AMQP, Retry, Firebase Admin SDK 의존성 추가; K6 성능 테스트 스크립트 및 환경 설정 추가 |
동아리 지원 도메인 엔티티 backend/src/main/java/moadong/club/entity/Club.java, backend/src/main/java/moadong/club/entity/ClubApplicant.java, backend/src/main/java/moadong/club/entity/ClubApplicationForm.java, backend/src/main/java/moadong/club/entity/ClubApplicationFormQuestion.java |
Firebase 메시징 추가, ClubQuestion→ClubApplicationForm 및 ClubApplication→ClubApplicant 이름 변경, 학기 정보(semesterYear, semesterTerm) 및 상태 필드 추가 |
동아리 지원 도메인 열거형 backend/src/main/java/moadong/club/enums/ApplicationStatus.java, backend/src/main/java/moadong/club/enums/ApplicationFormStatus.java, backend/src/main/java/moadong/club/enums/SemesterTerm.java |
ApplicationStatus→ApplicantStatus 이름 변경, ApplicationFormStatus 신규 추가(ACTIVE, PUBLISHED, UNPUBLISHED), SemesterTerm 신규 추가(FIRST, SECOND) |
동아리 지원 도메인 저장소 backend/src/main/java/moadong/club/repository/ClubApplicantsRepository.java, backend/src/main/java/moadong/club/repository/ClubApplicationFormsRepository.java, backend/src/main/java/moadong/club/repository/ClubApplicationFormsRepositoryCustom.java, backend/src/main/java/moadong/club/repository/ClubApplicationRepository.java, backend/src/main/java/moadong/club/repository/ClubQuestionRepository.java |
신규 저장소 인터페이스(ClubApplicantsRepository, ClubApplicationFormsRepository) 추가, MongoDB aggregation 파이프라인 구현, 기존 저장소 삭제 |
동아리 지원 서비스 backend/src/main/java/moadong/club/service/ClubApplyAdminService.java, backend/src/main/java/moadong/club/service/ClubApplyPublicService.java, backend/src/main/java/moadong/club/service/ClubApplyServiceV1.java, backend/src/main/java/moadong/club/service/ClubApplyService.java |
어드민 관리용(ClubApplyAdminService) 및 공개용(ClubApplyPublicService), V1 레거시 인터페이스(ClubApplyServiceV1) 신규 추가, 기존 ClubApplyService 삭제, SSE 및 비동기 이벤트 지원 |
동아리 지원 컨트롤러 backend/src/main/java/moadong/club/controller/ClubApplyAdminController.java, backend/src/main/java/moadong/club/controller/ClubApplyPublicController.java, backend/src/main/java/moadong/club/controller/ClubApplyControllerV1.java, backend/src/main/java/moadong/club/controller/ClubApplyController.java |
어드민(ClubApplyAdminController), 공개(ClubApplyPublicController), V1 호환성(ClubApplyControllerV1) 컨트롤러 신규 추가, 기존 ClubApplyController 삭제 |
동아리 지원 페이로드 backend/src/main/java/moadong/club/payload/dto/*, backend/src/main/java/moadong/club/payload/request/*, backend/src/main/java/moadong/club/payload/response/* |
DTO 및 요청/응답 타입 신규 추가(ClubApplicationFormCreateRequest, ClubApplicationFormEditRequest, ClubApplicationFormsResult 등), 학기 및 상태 관련 페이로드 통합 |
지원자 요약 및 메시징 backend/src/main/java/moadong/club/summary/ApplicantIdMessageConsumer.java, backend/src/main/java/moadong/club/summary/ApplicantIdMessagePublisher.java |
RabbitMQ 기반 지원자 요약 워크플로우 추가, AI(Gemma) 기반 요약 생성 및 저장 |
모집 상태 관리 backend/src/main/java/moadong/club/util/RecruitmentStateCalculator.java, backend/src/main/java/moadong/club/service/RecruitmentStateChecker.java |
모집 상태 계산 로직 분리, Firebase 메시지 기반 푸시 알림 구현 |
FCM 통합 backend/src/main/java/moadong/fcm/controller/FcmController.java, backend/src/main/java/moadong/fcm/entity/FcmToken.java, backend/src/main/java/moadong/fcm/service/*, backend/src/main/java/moadong/fcm/payload/*, backend/src/main/java/moadong/fcm/repository/FcmTokenRepository.java |
FCM 토큰 관리, 클럽 구독/구독 해제, 비동기 토픽 관리 기능 신규 추가 |
Gemma AI 통합 backend/src/main/java/moadong/gemma/dto/AIRequest.java, backend/src/main/java/moadong/gemma/dto/AIResponse.java, backend/src/main/java/moadong/gemma/service/GemmaService.java |
외부 Gemma API와 연동하여 지원자 답변 요약 기능 신규 추가 |
전역 설정 backend/src/main/java/moadong/global/config/AsyncConfig.java, backend/src/main/java/moadong/global/config/RabbitMQConfig.java, backend/src/main/java/moadong/global/config/RestTemplateConfig.java, backend/src/main/java/moadong/global/config/WebConfig.java, backend/src/main/java/moadong/global/util/FcmInitializer.java |
비동기 처리, RabbitMQ, RestTemplate, SSE 타임아웃, Firebase 초기화 설정 추가 |
예외 처리 backend/src/main/java/moadong/global/exception/ErrorCode.java, backend/src/main/java/moadong/global/exception/GlobalExceptionHandler.java |
신규 오류 코드 추가(ACTIVE_APPLICATION_NOT_FOUND, APPLICATION_SEMESTER_INVALID, FCMTOKEN_NOT_FOUND 등), 포맷팅 정규화 |
사용자 도메인 backend/src/main/java/moadong/user/entity/User.java, backend/src/main/java/moadong/user/payload/CustomUserDetails.java, backend/src/main/java/moadong/user/service/UserCommandService.java |
사용자 엔티티에 clubId 필드 추가, 사용자 등록 흐름 트랜잭션 개선 |
검색 및 기타 서비스 backend/src/main/java/moadong/club/service/ClubSearchService.java, backend/src/main/java/moadong/club/payload/response/ClubSearchResponse.java, backend/src/main/java/moadong/club/repository/ClubRepository.java |
ClubSearchResponse에 totalCount 필드 추가, ClubRepository에 count 쿼리 메서드 추가 |
테스트 backend/src/test/java/moadong/club/service/*Test.java, backend/src/test/java/moadong/fcm/service/*Test.java, backend/src/test/java/moadong/fixture/*Fixture.java |
테스트 클래스 이름 변경 및 업데이트, FCM 서비스 테스트 신규 추가, 픽스처 데이터 업데이트 |
Sequence Diagram(s)
sequenceDiagram
participant Client
participant ApplicantIdMessagePublisher
participant RabbitMQ as RabbitMQ<br/>(applicantId-queue)
participant ApplicantIdMessageConsumer
participant Gemma as Gemma API
participant MongoDB as MongoDB<br/>(ClubApplicant)
Client->>MongoDB: 지원자가 답변 제출
Client->>ApplicantIdMessagePublisher: addApplicantIdToQueue()
ApplicantIdMessagePublisher->>RabbitMQ: ApplicantSummaryMessage 발행
Note over RabbitMQ: 메시지 큐 대기
RabbitMQ->>ApplicantIdMessageConsumer: receiveMessage(ApplicantSummaryMessage)
ApplicantIdMessageConsumer->>MongoDB: ClubApplicant 로드
ApplicantIdMessageConsumer->>MongoDB: ClubApplicationForm 로드
ApplicantIdMessageConsumer->>ApplicantIdMessageConsumer: 답변 복호화<br/>(AES)
rect rgb(200, 220, 255)
Note over ApplicantIdMessageConsumer,Gemma: AI 요약 생성
ApplicantIdMessageConsumer->>Gemma: getSummarizeContent(prompt)
Gemma-->>ApplicantIdMessageConsumer: 요약 결과
end
ApplicantIdMessageConsumer->>MongoDB: 요약 내용을<br/>applicant.memo에 저장
MongoDB-->>ApplicantIdMessageConsumer: 저장 완료
sequenceDiagram
participant Admin
participant ClubApplyAdminController
participant ClubApplyAdminService
participant SseEmitter
participant MongoDB as MongoDB<br/>(ClubApplicationForm)
participant EventListener as SSE 클라이언트
Admin->>ClubApplyAdminController: createClubApplicationForm()
ClubApplyAdminController->>ClubApplyAdminService: 지원 양식 생성
ClubApplyAdminService->>MongoDB: 양식 저장
MongoDB-->>ClubApplyAdminService: 저장 완료
Admin->>ClubApplyAdminController: getApplicantStatusEvents()<br/>(applicationFormId)
ClubApplyAdminController->>ClubApplyAdminService: createSseConnection()
ClubApplyAdminService->>SseEmitter: SseEmitter 생성
SseEmitter-->>ClubApplyAdminController: emitter 반환
ClubApplyAdminController-->>Admin: SSE 스트림 시작
rect rgb(200, 255, 200)
Note over Admin,EventListener: SSE 연결 유지
Admin->>ClubApplyAdminController: editApplicantDetail()
ClubApplyAdminController->>ClubApplyAdminService: 지원자 상태 변경
ClubApplyAdminService->>MongoDB: 상태 저장
ClubApplyAdminService->>SseEmitter: 상태 변경 이벤트<br/>발송
SseEmitter-->>EventListener: applicant-status-changed
Note over EventListener: 실시간 업데이트
end
Estimated code review effort
🎯 4 (Complex) | ⏱️ ~60 minutes
주의 사항:
- ClubApplyAdminService: 복잡한 SSE 연결 관리, 트랜잭션 동기화, 비동기 이벤트 처리 로직 포함 — 상세 검토 필요
- ApplicantIdMessageConsumer: RabbitMQ 메시지 처리, AES 복호화, Gemma AI 통합 — 오류 처리 및 데이터 흐름 검증 필요
- RabbitMQConfig: 메시지 큐 및 Dead Letter 교환 설정, Jackson 변환기 구성 — 메시지 직렬화/역직렬화 검증 필요
- ClubApplyServiceV1 & ClubApplyPublicService: 새로운 서비스 계층 분리로 인한 데이터 흐름 변경 — 기존 동작과의 호환성 검증 필요
- ClubApplicationFormsRepositoryCustom: MongoDB aggregation 파이프라인 복잡도 — 쿼리 성능 및 정렬 로직 검증 필요
- FcmAsyncService: CompletableFuture 및 타임아웃 처리, 토픽 구독/구독 해제 — 동시성 문제 및 에러 핸들링 검증 필요
- MyadongApplication:
@EnableRetry추가로 인한 재시도 정책 동작 확인 필요
Possibly related PRs
- [feature] 지원자의 지원서를 요약해서 메모에 추가한다. #809: RabbitMQ 기반 지원자 요약 워크플로우(ApplicantSummaryMessage, ApplicantIdMessagePublisher/Consumer, GemmaService, RabbitMQConfig 등) 동일 코드 레벨 변경
- [feature] 해당 학기에 게시할 지원서 폼을 선택할 수 있다 #765: 동아리 지원 도메인 이름 변경(ClubQuestion→ClubApplicationForm, ApplicationStatus→ApplicantStatus, 새로운 저장소/컨트롤러/서비스) 동일 범위 수정
- [cicd] firebase.json 추가 #814: CI/CD 워크플로우 파일에서 Firebase JSON 설정 자동화 및 YAML 문법 정규화 직접 관련
Suggested labels
🚀 Major Feature, ♻️ Refactoring, 🔧 Infrastructure, 📬 API, 🤖 AI Integration
Suggested reviewers
- Zepelown
- alsdddk
- yw6938
Pre-merge checks and finishing touches
❌ Failed checks (1 warning)
| Check name | Status | Explanation | Resolution |
|---|---|---|---|
| Docstring Coverage | Docstring coverage is 4.26% which is insufficient. The required threshold is 80.00%. | You can run @coderabbitai generate docstrings to improve docstring coverage. |
✅ Passed checks (2 passed)
| Check name | Status | Explanation |
|---|---|---|
| Description Check | ✅ Passed | Check skipped - CodeRabbit’s high-level summary is enabled. |
| Title check | ✅ Passed | PR 제목이 실제 변경 사항과 완전히 일치합니다. 이 PR은 v1.0.9 배포를 위한 릴리스 버전으로, 대량의 club 신청 기능 개선, FCM 구독 시스템, Firebase 통합, 성능 테스트 스크립트 등 주요 기능들이 포함되어 있으며, 제목이 이를 명확하게 표현하고 있습니다. |
✨ Finishing touches
- 📝 Generate docstrings
🧪 Generate unit tests (beta)
- Create PR with unit tests
- Post copyable unit tests in a comment
- Commit unit tests in branch
develop/be
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.
Comment @coderabbitai help to get the list of available commands and usage tips.
|
수고하셨습니다!! |
There was a problem hiding this comment.
Actionable comments posted: 14
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
.github/workflows/oracle_server_cd_develop.yml (1)
60-76: 멀티 아키텍처 빌드를 위해 QEMU 등록이 필요합니다.GitHub Runner는 x86_64 환경이라
linux/arm64이미지를 빌드하려면 QEMU binfmt 등록이 선행되어야 합니다. 현재는docker/setup-buildx-action만 있어서 arm64 타겟 빌드가/bin/sh: qemu-aarch64: not found와 같은 오류로 바로 실패합니다. 배포 워크플로 실패를 막기 위해 QEMU 설정 단계를 추가해주세요.- name: Log in to Docker Hub uses: docker/login-action@v2 with: username: ${{ secrets.SERVER_DOCKER_USERNAME }} password: ${{ secrets.SERVER_DOCKER_PASSWORD }} + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3.github/workflows/server_cd_release.yml (1)
59-78: 릴리스 파이프라인도 QEMU 설정이 빠져 있습니다.multi-arch 이미지를 빌드하려면 develop 워크플로와 동일하게 QEMU binfmt 등록이 필요합니다. 현재 상태로는
linux/arm64타겟 빌드가 곧바로 실패하여 배포 파이프라인이 막힐 가능성이 큽니다.- name: Log in to Docker Hub uses: docker/login-action@v2 with: username: ${{ secrets.SERVER_DOCKER_USERNAME }} password: ${{ secrets.SERVER_DOCKER_PASSWORD }} + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3
🧹 Nitpick comments (37)
backend/src/main/java/moadong/user/service/UserCommandService.java (1)
167-172: 엔티티 생성 패턴 개선을 고려하세요.
createUser헬퍼 메서드가 엔티티 생성 후updateId와updateClubId를 호출하는 패턴은 다소 비전형적입니다. 일반적으로 엔티티의 ID는 생성자나 빌더 패턴을 통해 설정하는 것이 더 명확하고 불변성을 유지하는 데 도움이 됩니다.
UserRegisterRequest.toUserEntity()메서드가 userId와 clubId를 파라미터로 받도록 수정하는 것을 고려해보세요:private User createUser(UserRegisterRequest request, String userId, String clubId) { return request.toUserEntity(passwordEncoder, userId, clubId); }이를 위해
UserRegisterRequest.toUserEntity()메서드의 시그니처를 다음과 같이 변경해야 합니다:// UserRegisterRequest.java public User toUserEntity(PasswordEncoder passwordEncoder, String userId, String clubId) { // ID를 생성자에서 직접 설정 }backend/perf/k6/.env.example (1)
1-2: BASE_URL 값이 불완전합니다.
BASE_URL=https://는 불완전한 URL입니다. 실제 예시 값을 제공하거나 명확한 플레이스홀더를 사용하는 것을 권장합니다.다음과 같이 수정하는 것을 고려해보세요:
# 서버 베이스 URL -BASE_URL=https:// +BASE_URL=https://example.com또는:
# 서버 베이스 URL -BASE_URL=https:// +BASE_URL=<your-server-url>backend/perf/k6/script.js (4)
5-8: 환경 변수 유효성 검증을 추가하세요.환경 변수가 설정되지 않은 경우 명확한 에러 메시지 없이 테스트가 실패할 수 있습니다. 스크립트 시작 시 필수 환경 변수의 존재를 확인하는 것을 권장합니다.
다음과 같이 검증 로직을 추가할 수 있습니다:
const BASE = __ENV.BASE_URL; const PATH = '/api/club/application'; const LOGIN_ID = __ENV.LOGIN_ID; const LOGIN_PW = __ENV.LOGIN_PW; + +if (!BASE || !LOGIN_ID || !LOGIN_PW) { + throw new Error('필수 환경 변수가 설정되지 않았습니다: BASE_URL, LOGIN_ID, LOGIN_PW'); +}
32-45: 주석 처리된 코드를 정리하세요.주석 처리된 ramp 시나리오가 향후 사용될 예정이라면 그 의도를 명확히 문서화하고, 그렇지 않다면 제거하는 것을 권장합니다.
76-77: JSON 파싱 에러 처리를 추가하세요.
loginResponse.json()과json.data.accessToken접근 시 응답 구조가 예상과 다르거나 JSON 파싱에 실패할 경우 명확한 에러 없이 테스트가 실패할 수 있습니다.다음과 같이 에러 처리를 추가할 수 있습니다:
- const json = loginResponse.json(); - return json.data.accessToken; + let json; + try { + json = loginResponse.json(); + } catch (e) { + throw new Error(`로그인 응답 JSON 파싱 실패: ${e.message}`); + } + + if (!json.data?.accessToken) { + throw new Error(`액세스 토큰을 찾을 수 없습니다: ${JSON.stringify(json)}`); + } + + return json.data.accessToken;
88-111: 중복 코드를 리팩토링하세요.
mode=agg와mode=server테스트 로직이 반복되고 있습니다. 헬퍼 함수를 사용하여 코드 중복을 제거하고 가독성을 향상시킬 수 있습니다.다음과 같이 리팩토링할 수 있습니다:
+ function testMode(mode, requestHeader) { + group(`mode=${mode}`, () => { + const url = `${BASE}${PATH}?mode=${mode}`; + const res = http.get(url, { headers: requestHeader, tags: { mode } }); + check(res, { [`${mode}: status is 200`]: (r) => r.status == 200 }); + }); + } + //반복마다 호출 순서 교차 (캐시 편향 제거) if (__ITER % 2 === 0) { - group("mode=agg", () => { - const url = `${BASE}${PATH}?mode=agg`; - const res = http.get(url, { headers: requestHeader, tags: { mode: "agg" } }); - check(res, { "agg: status is 200": (r) => r.status == 200 }); - }); - group("mode=server", () => { - const url = `${BASE}${PATH}?mode=server`; - const res = http.get(url, { headers: requestHeader, tags: { mode: "server" } }); - check(res, { "server: status is 200": (r) => r.status == 200 }); - }); + testMode("agg", requestHeader); + testMode("server", requestHeader); } else { - group("mode=server", () => { - const url = `${BASE}${PATH}?mode=server`; - const res = http.get(url, { headers: requestHeader, tags: { mode: "server" } }); - check(res, { "server: status is 200": (r) => r.status == 200 }); - }); - group("mode=agg", () => { - const url = `${BASE}${PATH}?mode=agg`; - const res = http.get(url, { headers: requestHeader, tags: { mode: "agg" } }); - check(res, { "agg: status is 200": (r) => r.status == 200 }); - }); + testMode("server", requestHeader); + testMode("agg", requestHeader); }backend/build.gradle (1)
62-62: Firebase 의존성 배치를 재정렬해주세요.firebase-admin이 이미지 처리 라이브러리(thumbnailator, spring-test) 뒤에 배치되어 있어 논리적 그룹화가 깨져있습니다. Spring Boot 스타터 및 외부 라이브러리별로 의존성을 재정렬하면 유지보수성이 향상됩니다.
다음과 같이 의존성 순서를 개선해주시기 바랍니다:
implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-aop' implementation 'org.springframework.boot:spring-boot-starter-amqp' implementation 'org.springframework.retry:spring-retry' + implementation 'com.google.firebase:firebase-admin:9.7.0' implementation 'com.google.cloud:spring-cloud-gcp-storage:5.8.0' implementation 'io.jsonwebtoken:jjwt-api:0.11.5' implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' implementation 'org.springframework.boot:spring-boot-starter-actuator' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' testImplementation 'io.github.artsok:rerunner-jupiter:2.1.6' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testCompileOnly 'org.projectlombok:lombok' testAnnotationProcessor 'org.projectlombok:lombok' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' // S3 implementation 'software.amazon.awssdk:s3:2.26.0' implementation 'software.amazon.awssdk:auth:2.26.0' // resize tool implementation 'net.coobird:thumbnailator:0.4.14' implementation 'org.springframework:spring-test' - - implementation 'com.google.firebase:firebase-admin:9.7.0'backend/src/main/java/moadong/club/repository/ClubRepository.java (1)
18-18:countByIdIn시그니처는 적절해 보입니다
MongoRepository<Club, String>의id필드에 대해countByIdIn(List<String> id)로 카운트하는 파생 쿼리는 자연스럽고, 다중 clubId 유효성 검증에 쓰기 좋아 보입니다.
서비스 단에서 빈 리스트인 경우는 호출 전에 필터링해 두면 의미가 더 명확해질 것 같다는 정도의 소소한 의견입니다.backend/src/main/java/moadong/club/entity/Club.java (2)
72-80:Club(String id, String userId)생성자의 사용 시나리오를 한 번만 점검해 주세요수동으로
id를 지정하면서version은null상태라isNew()구현(version == null) 기준으로는 여전히 “신규 엔티티”로 처리됩니다.
MongoDB 쪽에서 이미 존재하는_id를 재사용하는 경우라면, 중복 키 에러가 날 수 있어서 이 생성자는 “정말 새 클럽을 만들면서 미리 ID를 정해두는” 용도로만 쓰는 것이 안전해 보입니다. 이 의도가 맞는지만 한 번 확인해 주세요.
3-12: 엔티티에서 직접 FCM을 호출하는 책임 분리가 살짝 아쉽습니다
Club.sendPushNotification(Message message)가FirebaseMessaging.getInstance().send(message)를 직접 호출하면서, 엔티티가 인프라(Firebase)에 강하게 의존하는 구조가 되었습니다. 기능적으로는 문제 없지만,
- 추후 테스트(특히 단위 테스트) 작성 시 목킹/스텁이 까다로워지고
- 다른 채널(SNS, 이메일 등)로의 확장이 필요할 때 엔티티 변경이 필요해지는 점
을 고려하면, 알림 전송은 별도의 도메인 서비스/애플리케이션 서비스로 빼고
Club은 메시지에 필요한 도메인 정보만 제공하도록 하는 쪽이 유지보수성 측면에서 더 깔끔할 수 있습니다.또한 예외 처리 시 현재는
e.getMessage()만 로그로 남기는데, 스택 트레이스까지 함께 남기면 장애 분석에 도움이 됩니다.log.error("FirebaseSendNotificationError for clubId={}", id, e);처럼 사용하는 것도 고려해 볼 만합니다.
Also applies to: 15-15, 28-28, 141-147
backend/src/main/java/moadong/club/util/RecruitmentStateCalculator.java (2)
18-28: 상태 변경 시에만 푸시를 보내도록 한 구조는 좋습니다기존
clubRecruitmentInformation의 상태를 읽어서calculateRecruitmentStatus결과와 비교한 뒤, 실제 변경이 있을 때만buildRecruitmentMessage+sendPushNotification을 호출하는 흐름은 중복 알림을 막는 데 적절해 보입니다.
calculate가 알림 생성 책임까지 같이 가지긴 하지만, 계산 로직과 메시지 생성 로직을 각각calculateRecruitmentStatus,buildRecruitmentMessage로 나눠둔 점은 이해하기 쉽고 유지보수에도 도움이 됩니다.
55-78:buildRecruitmentMessage의 공개 범위와 널 안전성을 조금 더 보강하면 좋겠습니다현재 구현은
RecruitmentStateCalculator.calculate내부에서만 호출되는 것으로 보이고, 그 경로에서는info.getRecruitmentStart()/getRecruitmentEnd()가 null이 아니어서 NPE는 발생하지 않습니다. 다만 메서드가public static으로 열려 있어,
- 다른 코드에서 잘못된 상태(예:
status == UPCOMING인데recruitmentStart가 null)로 호출하면format()에서 바로 NPE가 날 수 있고- 이 예외는
Club.sendPushNotification의FirebaseMessagingException캐치에서 잡히지 않아 스케줄러를 터뜨릴 수 있습니다.가능한 개선 방향은 예를 들어:
- 이 메서드를 패키지‑프라이빗 혹은
private로 내려서calculate안에서만 쓰이게 하거나,- 또는
status와info.getRecruitmentStart()/End()사이의 불일치에 대해 널 체크를 추가하고 방어적으로 CLOSED 메시지로 떨어지게 하거나하는 정도가 있을 것 같습니다.
또한 FCM topic으로
club.getId()를 사용하고 있으니, 반드시 영속화된 클럽(항상 id가 세팅된 엔티티)에 대해서만 호출된다는 전제가 코드 상/주석 상으로 드러나면 운영 시 오용을 줄이는 데 도움이 될 것 같습니다.// 예시: 널 방어 (선택사항) case OPEN -> { ZonedDateTime end = info.getRecruitmentEnd(); if (end == null) { yield "모집이 진행 중입니다."; } String formattedEndTime = end.format(formatter); yield formattedEndTime + "까지 모집 중이니 서둘러 지원하세요!"; }backend/src/main/java/moadong/club/service/RecruitmentStateChecker.java (1)
14-18: 스케줄 주기 및 흐름은 적절해 보입니다 (추후 로그만 약간 보강 제안)
fixedRate = 60 * 60 * 1000에 맞춰 “1시간마다 실행” 주석을 맞춰 둔 점 좋습니다.ClubRecruitmentStatus.ALWAYS는 바로 건너뛰고, 나머지만RecruitmentStateCalculator.calculate→clubRepository.save흐름을 타는 것도 합리적입니다.추후에는 상태 전환이 실제로 발생했을 때(알림을 보낸 경우 등)에만 간단한 로그를 남겨 두면 운영 시 모니터링과 장애 분석에 도움이 될 것 같습니다.
Also applies to: 25-37
backend/src/main/java/moadong/club/payload/dto/ClubActiveFormResult.java (1)
5-9: 레코드에 @builder 사용 검토레코드는 이미 모든 필드를 받는 기본 생성자를 제공하므로 @builder가 다소 중복될 수 있습니다. 빌더 패턴이 실제로 필요한 경우(예: 선택적 필드, 복잡한 생성 로직)가 아니라면 레코드의 기본 생성자만으로도 충분합니다.
필요시 다음과 같이 @builder를 제거할 수 있습니다:
-@Builder public record ClubActiveFormResult( String id, String title ) { }backend/src/main/java/moadong/club/payload/dto/ApplicantSummaryMessage.java (1)
3-7: 메시지 필드 검증 추가 고려RabbitMQ 메시지로 사용되는 DTO이므로 필드 검증을 추가하면 잘못된 메시지로 인한 런타임 오류를 방지할 수 있습니다.
다음과 같이 검증 애너테이션을 추가할 수 있습니다:
package moadong.club.payload.dto; +import jakarta.validation.constraints.NotBlank; + public record ApplicantSummaryMessage( + @NotBlank String applicationFormId, + @NotBlank String applicantId ) { }backend/src/main/java/moadong/gemma/dto/AIRequest.java (1)
5-11: 필드 검증 및 타입 안정성 개선 고려AI 요청 DTO에 다음과 같은 개선을 고려해볼 수 있습니다:
keepAlive는 int 타입이므로 음수가 될 수 있습니다. 유효성 검증이 필요할 수 있습니다.model,prompt등 필수 필드에 대한 검증이 없습니다.다음과 같이 검증을 추가할 수 있습니다:
package moadong.gemma.dto; import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; public record AIRequest( + @NotBlank String model, + @NotBlank String prompt, String format, boolean stream, - @JsonProperty("keep_alive") int keepAlive + @JsonProperty("keep_alive") @Positive int keepAlive ) { }backend/src/main/java/moadong/club/summary/ApplicantIdMessagePublisher.java (2)
14-18: 메시지 발행 실패에 대한 처리를 고려하세요.현재
convertAndSend가 실패하면 예외가 호출자에게 그대로 전파됩니다. 비즈니스 로직과 메시징 인프라의 결합도를 낮추기 위해 다음을 고려하세요:
- 예외를 catch하고 로깅한 후 사용자 친화적인 예외로 변환
- 발행 실패 시 재시도 로직 추가
- 비동기 발행을 고려하여 메인 플로우 블로킹 방지
+@Slf4j @Component @RequiredArgsConstructor public class ApplicantIdMessagePublisher { private final RabbitTemplate applicantIdTemplate; public void addApplicantIdToQueue(String applicationFormId, String applicantId) { - ApplicantSummaryMessage message = new ApplicantSummaryMessage(applicationFormId, applicantId); - applicantIdTemplate.convertAndSend(message); + try { + ApplicantSummaryMessage message = new ApplicantSummaryMessage(applicationFormId, applicantId); + applicantIdTemplate.convertAndSend(message); + } catch (AmqpException e) { + log.error("Failed to publish applicant summary message: formId={}, applicantId={}", + applicationFormId, applicantId, e); + throw new RestApiException(ErrorCode.MESSAGE_PUBLISH_FAILED); + } } }
12-12: 빈 주입 시 @qualifier 사용을 권장합니다.현재 필드명
applicantIdTemplate으로 빈을 매칭하고 있습니다. 명시적으로@Qualifier를 사용하면 리팩토링 시 안전성이 높아집니다.+import org.springframework.beans.factory.annotation.Qualifier; + @Component @RequiredArgsConstructor public class ApplicantIdMessagePublisher { + @Qualifier("applicantIdTemplate") private final RabbitTemplate applicantIdTemplate;backend/src/main/java/moadong/user/entity/User.java (1)
79-82:updateId노출 범위 점검Mongo 식별자에 대해
updateId(String id)를 열어 둔 것은 마이그레이션/동기화 용도라면 이해되지만, 일반 도메인 로직에서 임의로 ID를 바꾸는 것은 위험할 수 있습니다.
ID를 변경해야 하는 케이스가 특정 레이어(예: 리포지토리/서비스)로 제한된다면, 접근 제어자를public→ package-private로 줄이거나 해당 레이어로 헬퍼를 분리하는 것도 고려해 보시면 좋겠습니다.backend/src/main/java/moadong/club/payload/request/ClubApplicationFormEditRequest.java (1)
1-27: Edit 요청에서 필드 nullable 설계 의도 확인
ClubApplicationFormCreateRequest는@NotBlank,@NotNull로 필수값을 강제하는 반면, 이 Edit용 record는:
title,description,active,questions,semesterYear,semesterTerm모두 nullable 허용- Bean Validation 상 필수 제약이 없음 (
@Size,@Min/@Max만 존재)이라서 “부분 수정(Patch)” 용도로 null이면 기존 값을 유지하는 패턴으로 보입니다.
서비스 레이어에서 null 필드는 반드시 “변경 없음”으로 처리하고 있는지만 한 번만 더 확인해 주시면 좋겠습니다. 그렇지 않다면, 필요한 필드에@NotNull/@NotBlank를 다시 거는 게 안전합니다.backend/src/main/java/moadong/club/repository/ClubApplicantsRepository.java (1)
1-13: 메서드 파라미터 이름과 도메인 용어 정합성
findAllByFormId(String questionId)에서:
- 쿼리는
{ 'formId': ?0, ... }로?0을 사용하고 있어 기능상 문제는 없지만,- 파라미터 이름이
questionId라서 현재 도메인(이제는formId를 사용)과 혼동될 수 있습니다. (이전에questionId/clubId혼용 이력이 있었던 것도 감안하면 더 헷갈릴 여지가 있습니다. Based on learnings)가독성을 위해 아래처럼 이름만 정리해 두는 것을 추천드립니다.
@Query("{ 'formId': ?0, 'status': { $exists: true, $ne: 'DRAFT' } }") List<ClubApplicant> findAllByFormId(String formId);또한
status != 'DRAFT'필터링이 실제ApplicantStatus에서 기대하는 공개 범위와 맞는지도 한 번만 서비스 레벨에서 확인해 주세요.backend/src/test/java/moadong/fixture/ClubApplicationEditFixture.java (1)
3-25: Fixture의 학기 값 하드코딩 범용성테스트 픽스처가:
return new ClubApplicationFormEditRequest( "테스트123", "테스트 지원서입니다", false, new ArrayList<>(), 2025, SemesterTerm.SECOND );처럼
semesterYear = 2025,SemesterTerm.SECOND로 고정되어 있는데,
연도/학기 로직에 의존적인 테스트가 많아지면 현재 연도를 사용하도록 유틸을 두는 것도 한 가지 방법입니다.다만 지금처럼 단순한 정적 픽스처 수준이라면 이대로 두셔도 크게 문제는 없어 보입니다.
backend/src/main/java/moadong/global/config/RabbitMQConfig.java (1)
23-97: RabbitMQ 리소스 선언 전략 및 Bean 네이밍/설정값 분리 검토 제안
- 현재 Config에서 큐/익스체인지/바인딩을 Bean으로 선언하면, 기존에 인프라(운영) 차원에서 수동으로 생성·관리하던 RabbitMQ 리소스 전략과 충돌할 수 있습니다. 운영 환경에서 이미 동일 이름으로 선언된 큐/익스체인지가 다른 속성(인자, DLX 설정 등)을 갖고 있다면 부트 시점에 예외가 날 수도 있으니, 실제 운영 RabbitMQ 설정과 이름/옵션이 정확히 일치하는지 한 번 확인하는 게 좋겠습니다.
- DLX 이름/큐 이름을 코드 상수(
DEAD_LETTER_EXCHANGE_NAME,DEAD_LETTER_QUEUE_NAME,DEAD_LETTER_ROUTING_KEY)로 고정하기보다,rabbitmq.summary.*처럼application.yml의 설정값으로 분리하면 스테이징/프로덕션 간 분리와 추후 운영 변경에 더 유연합니다.Jackson2JsonMessageConverterBean 메서드명이 클래스명과 동일한 PascalCase라, 코드만 보면 생성자 호출과 혼동될 여지가 있습니다. 아래처럼 일반적인 lowerCamelCase로 이름을 바꾸면 가독성이 좀 더 좋아집니다.- @Bean - public MessageConverter Jackson2JsonMessageConverter() { - return new Jackson2JsonMessageConverter(); - } + @Bean + public MessageConverter jackson2JsonMessageConverter() { + return new Jackson2JsonMessageConverter(); + } @@ - public RabbitTemplate applicantIdTemplate(ConnectionFactory connectionFactory) { - RabbitTemplate template = new RabbitTemplate(connectionFactory); - template.setMessageConverter(Jackson2JsonMessageConverter()); + public RabbitTemplate applicantIdTemplate(ConnectionFactory connectionFactory) { + RabbitTemplate template = new RabbitTemplate(connectionFactory); + template.setMessageConverter(jackson2JsonMessageConverter());Based on learnings
backend/src/main/java/moadong/club/controller/ClubApplyControllerV1.java (1)
28-94: clubId → 최신 ACTIVE formId 매핑 전략이 요구사항과 잘 맞습니다
convertClubIdToFormId에서findTopByClubIdAndStatusOrderByEditedAtDesc(clubId, ApplicationFormStatus.ACTIVE)를 사용해, 동일 clubId에 ACTIVE 폼이 여러 개 존재할 수 있는 상황에서도 “가장 최근에 수정된 활성 폼” 하나만 선택하도록 한 부분이 V1 설명(“활성화된 최신 지원서 양식”)과 잘 맞습니다.- V1 엔드포인트(
/apply,/apply/info,/applicant, 삭제) 모두 이 헬퍼를 통해 동일한 formId 선택 로직을 공유하므로, 추후 ACTIVE 폼 정책을 변경해야 할 때도 한 곳만 수정하면 되어 유지보수성 측면에서도 좋아 보입니다.- 미세 최적화로는, 각 요청 내에서
convertClubIdToFormId(clubId)가 여러 번 호출되는 메서드(예: edit/remove)에서 로컬 변수로 한 번만 조회해 재사용하는 정도는 선택적으로 고려해볼 수 있겠습니다.Based on learnings
backend/src/test/java/moadong/club/service/ClubApplyAdminServiceTest.java (1)
49-55: setUp에서 중복 조회 제거 및 테스트 픽스처 초기화 개선 제안
clubApplicationFormsRepository.findByClubId(club.getId())를 두 번 호출하고 있으므로, 한 번만 조회해 로컬 변수에 저장한 뒤 비어 있음 체크와 첫 번째 요소 사용을 동시에 처리하면 성능/가독성 모두 조금 개선될 수 있습니다.- if (clubApplicationFormsRepository.findByClubId(club.getId()).isEmpty()){ - throw new NoSuchElementException("테스트를 위한 ClubApplicationForm 문서가 DB에 존재하지 않습니다. 먼저 문서를 생성해주세요."); - } - ClubApplicationForm clubApplicationForm = clubApplicationFormsRepository.findByClubId(club.getId()).get(0); + var forms = clubApplicationFormsRepository.findByClubId(club.getId()); + if (forms.isEmpty()) { + throw new NoSuchElementException("테스트를 위한 ClubApplicationForm 문서가 DB에 존재하지 않습니다. 먼저 문서를 생성해주세요."); + } + ClubApplicationForm clubApplicationForm = forms.get(0);
- 또한 현재 테스트는 “DB에 미리 ClubApplicationForm 문서가 존재한다”는 외부 전제에 강하게 의존하고 있으므로, 가능하다면 향후에는 테스트 내부(또는 픽스처 유틸)에서 폼을 생성하는 쪽으로 바꾸면 환경 의존성을 줄이고 재현성을 높일 수 있겠습니다.
backend/src/main/java/moadong/club/service/ClubApplyPublicService.java (2)
39-86: 폼 상태(status) 검증 및 DB 트랜잭션 vs MQ 퍼블리시 타이밍 확인 필요
getClubApplicationForm/applyToClub가 모두clubApplicationFormsRepository.findByClubIdAndId(clubId, applicationFormId)만 사용하고 있어, 레포지토리 구현에 별도 필터가 없다면 비활성/마감된 지원서에도 조회·지원이 가능해질 수 있습니다.
- 의도대로 “활성 폼에만 지원 가능”이어야 한다면,
status == ACTIVE조건을 명시적으로 체크하거나,findByClubIdAndIdAndStatus(...)같은 메서드를 사용하는 편이 안전합니다.applyToClub에서@Transactional범위 안에서clubApplicantsRepository.save(applicant)후 바로applicantIdMessagePublisher.addApplicantIdToQueue(...)를 호출하는 구조라, 이론상 DB 커밋이 나중에 실패할 경우 “DB에는 없는 applicantId를 담은 메시지”가 큐에 남을 수 있습니다.
- 심각한 문제까지는 아니더라도, 다운스트림에서 applicantId 기준으로 DB를 조회하는 경우를 상정하면
TransactionSynchronizationManager.registerSynchronization(...)등으로 트랜잭션 커밋 이후에 메시지를 퍼블리시하는 패턴을 고려해볼 만합니다.예시(개념적):
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() { @Override public void afterCommit() { applicantIdMessagePublisher.addApplicantIdToQueue(applicationFormId, applicant.getId()); } });
- 현재 구현으로도 대부분의 경우 정상 동작하겠지만, 폼 상태 조건과 퍼블리시 타이밍은 한 번 명확히 의도 확인해보시는 것을 추천드립니다.
Based on learnings
88-127: 답변 값 null 방어 및 검증 로직 공통화 여지
validateAnswerLength에서value.length()를 바로 호출하고 있어,ClubApplyRequest.Answer.value()가null로 들어오는 경우 NPE가 발생할 수 있습니다. DTO 쪽에서@NotBlank/@NotNull등으로 이미 방어하고 있다면 문제는 없지만, 그렇지 않다면 아래처럼 null 을 허용하지 않도록 방어 코드를 추가하는 편이 안전합니다.- private void validateAnswerLength(String value, ClubApplicationQuestionType type) { + private void validateAnswerLength(String value, ClubApplicationQuestionType type) { + if (value == null) { + throw new RestApiException(ErrorCode.REQUIRED_QUESTION_MISSING); + } switch (type) {
- 동일/유사한 검증 로직이
ClubApplyServiceV1에도 존재하는 것으로 보여, 향후에는 별도의 유틸/컴포넌트로 추출해 양쪽에서 재사용하면 검증 정책 변경 시 관리 포인트를 줄일 수 있을 것 같습니다.Based on learnings
backend/src/main/java/moadong/fcm/payload/request/ClubSubscribeRequest.java (1)
7-12: 구체적인 구현 타입 대신 인터페이스를 사용하세요.
ArrayList<String>대신List<String>를 사용하는 것이 유연성과 유지보수성 측면에서 더 좋습니다. Record는 불변 데이터 전달에 사용되므로 구현 타입에 의존할 필요가 없습니다.public record ClubSubscribeRequest( @NotNull String fcmToken, @NotNull - ArrayList<String> clubIds + List<String> clubIds ) { }import 문도 수정해야 합니다:
-import java.util.ArrayList; +import java.util.List;backend/src/main/java/moadong/global/config/AsyncConfig.java (1)
15-25: 스레드 풀 크기를 운영 부하에 맞게 모니터링하세요.현재 설정된 값들(core=4, max=8, queue=100)은 초기 설정으로 적절하지만, FCM 푸시 알림의 실제 부하에 따라 조정이 필요할 수 있습니다.
CallerRunsPolicy사용은 적절합니다. 큐가 가득 찰 경우 호출 스레드에서 작업을 실행하여 back-pressure를 제공합니다.운영 환경에서 다음 메트릭을 모니터링하세요:
- 큐 사용률 (
ThreadPoolTaskExecutor의 큐 크기)- 작업 거부 빈도
- 평균 작업 대기 시간
필요시 외부 설정(application.properties)으로 이동하여 런타임에 조정 가능하도록 개선할 수 있습니다.
backend/src/main/java/moadong/fcm/service/FcmTxService.java (1)
3-13: 스프링 @transactional로 통일하면 트랜잭션 설정이 명확해집니다.서비스 전반에서 스프링 트랜잭션 매니저(JPA, Mongo 등)를 사용하고 있으므로
jakarta.transaction.Transactional대신org.springframework.transaction.annotation.Transactional로 맞춰 주시면 설정 옵션(readOnly, propagation 등)과 프록시 조합(@retryable)에서 예측 가능한 동작을 확보할 수 있습니다.-import jakarta.transaction.Transactional; +import org.springframework.transaction.annotation.Transactional;backend/src/main/java/moadong/fcm/controller/FcmController.java (2)
29-34: 구독 API 설명과 서비스 시그니처를 조금 다듬으면 좋겠습니다.
@Operation의 description"변경되면저거 하면 알림"부분은 오타로 보이는데,"변경되면 알림을 받도록 구독합니다."정도로 정리해 두면 문서 가독성이 좋아질 것 같습니다.FcmService.subscribeClubs시그니처가ArrayList<String>를 직접 받도록 되어 있어서(backend/src/main/java/moadong/fcm/service/FcmService.java 참고), 컨트롤러/DTO에서 사용하는 구체 컬렉션 타입에 불필요하게 묶입니다.List<String>로 느슨하게 받고 내부에서 필요 시new ArrayList<>(...)로 변환하는 쪽이 향후 리팩터링과 재사용에 더 유리합니다.
36-41: FCM 토큰 쿼리 파라미터에 공백/빈 문자열까지 검증하면 더 안전합니다.현재 시그니처가
@RequestParam("fcmToken") @Validated @NotNull String fcmToken이라null만 막고, 빈 문자열("")이나 공백 토큰은 그대로 통과합니다. FCM 토큰이 비어 있으면 무효인 값일 가능성이 높으므로,@NotBlank로 변경해 두는 것을 고려해 볼 만합니다. 또한 클래스에 이미@Validated가 붙어 있다면 파라미터에 중복으로 붙은@Validated는 제거해도 동작에는 문제가 없습니다.backend/src/main/java/moadong/fcm/entity/FcmToken.java (2)
26-30:updateClubIds에서 null 인자에 대한 방어 로직을 추가하면 안전합니다.현재 구현은
clubIds인자가 null일 경우this.clubIds.addAll(clubIds)에서 NPE가 발생할 수 있습니다. 호출부에서 null을 넘기지 않는다는 가정이 있더라도, 엔티티의 변경 메서드는 방어적으로 작성해 두는 편이 향후 수정 시 안전합니다.예를 들면 다음과 같이 수정할 수 있습니다.
- public void updateClubIds(List<String> clubIds) { - this.clubIds.clear(); - this.clubIds.addAll(clubIds); - } + public void updateClubIds(List<String> clubIds) { + this.clubIds.clear(); + if (clubIds != null) { + this.clubIds.addAll(clubIds); + } + }Also applies to: 36-39
24-30:token필드에 인덱스를 추가하면 조회 성능과 유니크 보장이 좋아질 수 있습니다.
FcmTokenRepository.findFcmTokenByToken을 통해 토큰 단건 조회를 자주 수행하는 구조라면,token에 인덱스를 두지 않으면 토큰 수가 늘어날수록 조회 비용이 커질 수 있습니다. 비즈니스 상 토큰이 유니크해야 한다면, 아래처럼 인덱스를 추가하는 것도 고려해 볼 만합니다(기존 데이터/마이그레이션 영향은 별도 검토 필요).-import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; ... - private String token; + @Indexed(unique = true) + private String token;backend/src/test/java/moadong/fcm/service/FcmServiceTest.java (2)
97-111: 타임스탬프 갱신 테스트가 시계 해상도에 따라 드물게 실패할 수 있습니다.
existingToken생성 시와updateTimestamp()호출 시 모두LocalDateTime.now()를 사용하고 있어서, 시스템 시계 해상도가 충분히 낮으면 두 값이 동일하게 나와isAfter가 실패할 가능성이 있습니다(실제 업데이트는 되었더라도). 테스트를 더 견고하게 하려면 다음과 같은 방향을 고려해 볼 수 있습니다.
- 단순히
"업데이트가 실행됐다"만 보장하면 된다면isAfterOrEqualTo로 완화.- 또는
Clock을 주입받도록 리팩터링해서 테스트에서 고정된/제어 가능한 시간을 사용.간단히는 아래처럼 변경해도 플리키 리스크는 줄어듭니다.
- assertThat(updatedToken.getTimestamp()).isAfter(initialUpdatedAt); + assertThat(updatedToken.getTimestamp()).isAfterOrEqualTo(initialUpdatedAt);
141-161: 성공 케이스는FcmService.subscribeClubs경로를 통해서도 한 번 검증하면 좋겠습니다.
subscribeClubs_success테스트는 현재fcmAsyncService.updateSubscriptions를 직접 호출하고 있어, 실제 프로덕션 경로인FcmService.subscribeClubs(diff 계산 + 비동기 호출 부분)는 예외 케이스만 검증되고 성공 플로우는 통째로 커버되지 않습니다.테스트 설정에서
@Bean(name = "fcmAsync")로SyncTaskExecutor를 사용하고 있으므로,FcmService.subscribeClubs를 호출하더라도 비동기 호출이 동기적으로 처리되어 테스트가 안정적으로 동작할 수 있습니다. 예를 들면 다음과 같이 단순화할 수 있습니다.- // when - CompletableFuture<Void> future = fcmAsyncService.updateSubscriptions(token, Set.copyOf(newClubIds), Set.of(club3.getId()), Set.of(club1.getId())); - future.join(); + // when + fcmService.subscribeClubs(token, newClubIds);이렇게 하면 서비스의 diff 계산 로직과 비동기 호출 wiring까지 한 번에 검증할 수 있고,
FcmAsyncService직접 호출 의존성도 제거할 수 있습니다.backend/src/main/java/moadong/fcm/service/FcmAsyncService.java (1)
54-82: 예외 처리 시 스택트레이스를 함께 로그로 남기고, 가능하면 도메인 예외로 래핑하는 게 좋습니다.현재
ExecutionException/TimeoutException/InterruptedException을 처리할 때:
- 로그에
e.getMessage()만 남겨서, 실제 원인 파악 시 스택트레이스 정보가 부족할 수 있고,RuntimeException으로만 래핑해서 던지기 때문에 나중에 상위 레이어에서 에러코드 기반 처리를 하기 어렵습니다.운영 관점에서는 다음과 같은 방향이 조금 더 다루기 편할 것 같습니다.
- 로그는
log.error("FCM topic subscription error", e);처럼 스택트레이스를 함께 남기기.- 에러 코드가 허용된다면
RestApiException(ErrorCode.FCMTOKEN_SUBSCRIBE_ERROR, e)처럼 도메인 예외로 래핑해서 던지기(또는 별도 ErrorCode 추가).이렇게 하면 비동기 처리 중 발생한 문제도 원인 분석과 모니터링이 더 수월해집니다.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
Disabled knowledge base sources:
- Jira integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (79)
.github/workflows/oracle_server_cd_develop.yml(4 hunks).github/workflows/pr-test.yml(1 hunks).github/workflows/server_cd_release.yml(5 hunks)backend/.gitignore(1 hunks)backend/build.gradle(2 hunks)backend/perf/k6/.env.example(1 hunks)backend/perf/k6/script.js(1 hunks)backend/src/main/java/moadong/MoadongApplication.java(1 hunks)backend/src/main/java/moadong/club/controller/ClubApplyAdminController.java(1 hunks)backend/src/main/java/moadong/club/controller/ClubApplyController.java(0 hunks)backend/src/main/java/moadong/club/controller/ClubApplyControllerV1.java(1 hunks)backend/src/main/java/moadong/club/controller/ClubApplyPublicController.java(1 hunks)backend/src/main/java/moadong/club/entity/Club.java(4 hunks)backend/src/main/java/moadong/club/entity/ClubApplicant.java(2 hunks)backend/src/main/java/moadong/club/entity/ClubApplicationForm.java(4 hunks)backend/src/main/java/moadong/club/entity/ClubApplicationFormQuestion.java(1 hunks)backend/src/main/java/moadong/club/enums/ApplicantStatus.java(1 hunks)backend/src/main/java/moadong/club/enums/ApplicationFormStatus.java(1 hunks)backend/src/main/java/moadong/club/enums/SemesterTerm.java(1 hunks)backend/src/main/java/moadong/club/payload/dto/ApplicantStatusEvent.java(1 hunks)backend/src/main/java/moadong/club/payload/dto/ApplicantSummaryMessage.java(1 hunks)backend/src/main/java/moadong/club/payload/dto/ClubActiveFormResult.java(1 hunks)backend/src/main/java/moadong/club/payload/dto/ClubActiveFormSlim.java(1 hunks)backend/src/main/java/moadong/club/payload/dto/ClubApplicantsResult.java(2 hunks)backend/src/main/java/moadong/club/payload/dto/ClubApplicationFormsResult.java(1 hunks)backend/src/main/java/moadong/club/payload/dto/ClubApplicationFormsResultItem.java(1 hunks)backend/src/main/java/moadong/club/payload/request/ClubApplicantEditRequest.java(2 hunks)backend/src/main/java/moadong/club/payload/request/ClubApplicationEditRequest.java(0 hunks)backend/src/main/java/moadong/club/payload/request/ClubApplicationFormCreateRequest.java(2 hunks)backend/src/main/java/moadong/club/payload/request/ClubApplicationFormEditRequest.java(1 hunks)backend/src/main/java/moadong/club/payload/response/ClubActiveFormsResponse.java(1 hunks)backend/src/main/java/moadong/club/payload/response/ClubApplicationFormResponse.java(1 hunks)backend/src/main/java/moadong/club/payload/response/ClubApplicationFormsResponse.java(1 hunks)backend/src/main/java/moadong/club/payload/response/ClubApplicationResponse.java(0 hunks)backend/src/main/java/moadong/club/payload/response/ClubSearchResponse.java(1 hunks)backend/src/main/java/moadong/club/payload/response/SemesterOptionResponse.java(1 hunks)backend/src/main/java/moadong/club/repository/ClubApplicantsRepository.java(1 hunks)backend/src/main/java/moadong/club/repository/ClubApplicationFormsRepository.java(1 hunks)backend/src/main/java/moadong/club/repository/ClubApplicationFormsRepositoryCustom.java(1 hunks)backend/src/main/java/moadong/club/repository/ClubApplicationRepository.java(0 hunks)backend/src/main/java/moadong/club/repository/ClubQuestionRepository.java(0 hunks)backend/src/main/java/moadong/club/repository/ClubRepository.java(1 hunks)backend/src/main/java/moadong/club/service/ClubApplyAdminService.java(1 hunks)backend/src/main/java/moadong/club/service/ClubApplyPublicService.java(1 hunks)backend/src/main/java/moadong/club/service/ClubApplyService.java(0 hunks)backend/src/main/java/moadong/club/service/ClubApplyServiceV1.java(1 hunks)backend/src/main/java/moadong/club/service/ClubSearchService.java(1 hunks)backend/src/main/java/moadong/club/service/RecruitmentStateChecker.java(3 hunks)backend/src/main/java/moadong/club/summary/ApplicantIdMessageConsumer.java(1 hunks)backend/src/main/java/moadong/club/summary/ApplicantIdMessagePublisher.java(1 hunks)backend/src/main/java/moadong/club/util/RecruitmentStateCalculator.java(1 hunks)backend/src/main/java/moadong/fcm/controller/FcmController.java(1 hunks)backend/src/main/java/moadong/fcm/entity/FcmToken.java(1 hunks)backend/src/main/java/moadong/fcm/payload/request/ClubSubscribeRequest.java(1 hunks)backend/src/main/java/moadong/fcm/payload/request/FcmSaveRequest.java(1 hunks)backend/src/main/java/moadong/fcm/payload/response/ClubSubscribeListResponse.java(1 hunks)backend/src/main/java/moadong/fcm/repository/FcmTokenRepository.java(1 hunks)backend/src/main/java/moadong/fcm/service/FcmAsyncService.java(1 hunks)backend/src/main/java/moadong/fcm/service/FcmService.java(1 hunks)backend/src/main/java/moadong/fcm/service/FcmTxService.java(1 hunks)backend/src/main/java/moadong/gemma/dto/AIRequest.java(1 hunks)backend/src/main/java/moadong/gemma/dto/AIResponse.java(1 hunks)backend/src/main/java/moadong/gemma/service/GemmaService.java(1 hunks)backend/src/main/java/moadong/global/config/AsyncConfig.java(1 hunks)backend/src/main/java/moadong/global/config/RabbitMQConfig.java(1 hunks)backend/src/main/java/moadong/global/config/RestTemplateConfig.java(1 hunks)backend/src/main/java/moadong/global/config/WebConfig.java(2 hunks)backend/src/main/java/moadong/global/exception/ErrorCode.java(1 hunks)backend/src/main/java/moadong/global/exception/GlobalExceptionHandler.java(2 hunks)backend/src/main/java/moadong/global/util/FcmInitializer.java(1 hunks)backend/src/main/java/moadong/user/entity/User.java(3 hunks)backend/src/main/java/moadong/user/payload/CustomUserDetails.java(1 hunks)backend/src/main/java/moadong/user/service/UserCommandService.java(4 hunks)backend/src/test/java/moadong/club/service/ClubApplyAdminServiceTest.java(4 hunks)backend/src/test/java/moadong/club/service/RecruitmentStateCheckerTest.java(4 hunks)backend/src/test/java/moadong/fcm/service/FcmServiceTest.java(1 hunks)backend/src/test/java/moadong/fcm/service/FcmTxServiceTest.java(1 hunks)backend/src/test/java/moadong/fixture/ClubApplicationEditFixture.java(2 hunks)backend/src/test/java/moadong/fixture/UserFixture.java(1 hunks)
💤 Files with no reviewable changes (6)
- backend/src/main/java/moadong/club/controller/ClubApplyController.java
- backend/src/main/java/moadong/club/repository/ClubApplicationRepository.java
- backend/src/main/java/moadong/club/service/ClubApplyService.java
- backend/src/main/java/moadong/club/payload/request/ClubApplicationEditRequest.java
- backend/src/main/java/moadong/club/repository/ClubQuestionRepository.java
- backend/src/main/java/moadong/club/payload/response/ClubApplicationResponse.java
🧰 Additional context used
🧠 Learnings (5)
📚 Learning: 2025-08-25T14:43:52.320Z
Learnt from: lepitaaar
Repo: Moadong/moadong PR: 703
File: backend/src/main/java/moadong/club/controller/ClubApplyController.java:84-84
Timestamp: 2025-08-25T14:43:52.320Z
Learning: In the Moadong codebase, questionId and clubId are equivalent identifiers that represent the same entity. The ClubApplicationRepository.findAllByIdInAndQuestionId method correctly uses clubId as the questionId parameter for filtering club applications.
Applied to files:
backend/src/test/java/moadong/club/service/RecruitmentStateCheckerTest.javabackend/src/main/java/moadong/club/entity/ClubApplicationFormQuestion.javabackend/src/main/java/moadong/club/entity/Club.javabackend/src/main/java/moadong/club/payload/dto/ClubApplicantsResult.javabackend/src/main/java/moadong/club/controller/ClubApplyControllerV1.javabackend/src/main/java/moadong/club/repository/ClubApplicationFormsRepositoryCustom.javabackend/src/main/java/moadong/club/entity/ClubApplicant.javabackend/src/main/java/moadong/club/controller/ClubApplyPublicController.javabackend/src/main/java/moadong/club/repository/ClubApplicationFormsRepository.javabackend/src/test/java/moadong/club/service/ClubApplyAdminServiceTest.javabackend/src/main/java/moadong/club/service/ClubApplyAdminService.javabackend/src/main/java/moadong/club/service/ClubApplyPublicService.javabackend/src/main/java/moadong/club/repository/ClubApplicantsRepository.javabackend/src/main/java/moadong/club/service/ClubApplyServiceV1.javabackend/src/main/java/moadong/club/payload/request/ClubApplicationFormCreateRequest.javabackend/src/main/java/moadong/user/entity/User.javabackend/src/test/java/moadong/fixture/ClubApplicationEditFixture.javabackend/src/main/java/moadong/user/service/UserCommandService.javabackend/src/main/java/moadong/club/repository/ClubRepository.javabackend/src/main/java/moadong/club/entity/ClubApplicationForm.java
📚 Learning: 2025-05-19T05:45:52.957Z
Learnt from: lepitaaar
Repo: Moadong/moadong PR: 406
File: backend/src/main/java/moadong/club/service/ClubApplyService.java:34-38
Timestamp: 2025-05-19T05:45:52.957Z
Learning: The code duplication between createClubApplication and editClubApplication methods in ClubApplyService.java is acknowledged but will be addressed in a future refactoring, as per the developer's plan.
Applied to files:
backend/src/test/java/moadong/club/service/RecruitmentStateCheckerTest.javabackend/src/main/java/moadong/club/entity/ClubApplicationFormQuestion.javabackend/src/main/java/moadong/club/payload/dto/ClubApplicantsResult.javabackend/src/main/java/moadong/club/controller/ClubApplyControllerV1.javabackend/src/main/java/moadong/club/controller/ClubApplyAdminController.javabackend/src/main/java/moadong/club/entity/ClubApplicant.javabackend/src/main/java/moadong/club/controller/ClubApplyPublicController.javabackend/src/main/java/moadong/club/payload/request/ClubApplicantEditRequest.javabackend/src/test/java/moadong/club/service/ClubApplyAdminServiceTest.javabackend/src/main/java/moadong/club/service/ClubApplyAdminService.javabackend/src/main/java/moadong/club/service/ClubApplyPublicService.javabackend/src/main/java/moadong/club/service/ClubApplyServiceV1.javabackend/src/main/java/moadong/club/payload/request/ClubApplicationFormCreateRequest.javabackend/src/main/java/moadong/club/payload/request/ClubApplicationFormEditRequest.javabackend/src/test/java/moadong/fixture/ClubApplicationEditFixture.javabackend/src/main/java/moadong/club/entity/ClubApplicationForm.java
📚 Learning: 2025-09-30T05:26:41.788Z
Learnt from: alsdddk
Repo: Moadong/moadong PR: 765
File: backend/src/main/java/moadong/club/service/ClubApplyService.java:431-435
Timestamp: 2025-09-30T05:26:41.788Z
Learning: In the Moadong codebase's club application feature (backend/src/main/java/moadong/club/), multiple ClubApplicationForm entities can have ACTIVE status for the same clubId, semesterYear, and semesterTerm simultaneously. There is no uniqueness constraint requiring only one ACTIVE form per semester.
Applied to files:
backend/src/test/java/moadong/club/service/RecruitmentStateCheckerTest.javabackend/src/main/java/moadong/club/enums/ApplicantStatus.javabackend/src/main/java/moadong/club/payload/response/ClubActiveFormsResponse.javabackend/src/main/java/moadong/club/entity/ClubApplicationFormQuestion.javabackend/src/main/java/moadong/club/entity/Club.javabackend/src/main/java/moadong/club/payload/response/ClubApplicationFormResponse.javabackend/src/main/java/moadong/club/payload/dto/ClubApplicantsResult.javabackend/src/main/java/moadong/club/controller/ClubApplyControllerV1.javabackend/src/main/java/moadong/club/controller/ClubApplyAdminController.javabackend/src/main/java/moadong/club/repository/ClubApplicationFormsRepositoryCustom.javabackend/src/main/java/moadong/club/enums/ApplicationFormStatus.javabackend/src/main/java/moadong/club/entity/ClubApplicant.javabackend/src/main/java/moadong/club/controller/ClubApplyPublicController.javabackend/src/main/java/moadong/club/repository/ClubApplicationFormsRepository.javabackend/src/main/java/moadong/club/payload/request/ClubApplicantEditRequest.javabackend/src/test/java/moadong/club/service/ClubApplyAdminServiceTest.javabackend/src/main/java/moadong/club/service/ClubApplyAdminService.javabackend/src/main/java/moadong/club/service/RecruitmentStateChecker.javabackend/src/main/java/moadong/club/payload/dto/ClubActiveFormSlim.javabackend/src/main/java/moadong/club/service/ClubApplyPublicService.javabackend/src/main/java/moadong/club/payload/dto/ClubApplicationFormsResult.javabackend/src/main/java/moadong/club/repository/ClubApplicantsRepository.javabackend/src/main/java/moadong/club/service/ClubApplyServiceV1.javabackend/src/main/java/moadong/club/payload/dto/ClubActiveFormResult.javabackend/src/main/java/moadong/club/payload/dto/ApplicantStatusEvent.javabackend/src/main/java/moadong/club/payload/request/ClubApplicationFormCreateRequest.javabackend/src/main/java/moadong/club/payload/dto/ClubApplicationFormsResultItem.javabackend/src/main/java/moadong/user/entity/User.javabackend/src/main/java/moadong/club/payload/request/ClubApplicationFormEditRequest.javabackend/src/test/java/moadong/fixture/ClubApplicationEditFixture.javabackend/src/main/java/moadong/user/service/UserCommandService.javabackend/src/main/java/moadong/club/payload/response/ClubApplicationFormsResponse.javabackend/src/main/java/moadong/club/entity/ClubApplicationForm.java
📚 Learning: 2025-11-08T18:45:09.295Z
Learnt from: lepitaaar
Repo: Moadong/moadong PR: 809
File: backend/src/main/java/moadong/global/config/RabbitMQConfig.java:13-42
Timestamp: 2025-11-08T18:45:09.295Z
Learning: The Moadong project uses manually-configured RabbitMQ infrastructure (queues, exchanges, bindings) on the server side rather than declaring them as Spring beans in RabbitMQConfig. The configuration class only provides ConnectionFactory and RabbitTemplate beans.
Applied to files:
backend/src/main/java/moadong/MoadongApplication.javabackend/src/main/java/moadong/global/config/RestTemplateConfig.javabackend/src/main/java/moadong/global/config/AsyncConfig.javabackend/src/main/java/moadong/club/summary/ApplicantIdMessagePublisher.javabackend/src/main/java/moadong/global/config/RabbitMQConfig.java
📚 Learning: 2025-05-15T12:03:57.356Z
Learnt from: Zepelown
Repo: Moadong/moadong PR: 406
File: backend/src/main/java/moadong/club/entity/ClubApplicationQuestion.java:32-33
Timestamp: 2025-05-15T12:03:57.356Z
Learning: 엔티티 클래스는 요청/응답 객체(DTO)에 의존해서는 안 됩니다. 계층 간 의존성 문제를 방지하기 위해 엔티티와 DTO는 분리되어야 합니다. 예를 들어, `ClubApplicationQuestion` 엔티티가 `ClubApplicationRequest.Options`와 같은 요청 객체를 직접 참조하는 대신, 엔티티 패키지 내에 `QuestionOptions`와 같은 별도의 클래스를 정의하고 사용해야 합니다. 이렇게 하면 요청 객체 변경이 데이터베이스 스키마나 엔티티 계층에 영향을 미치지 않습니다.
Applied to files:
backend/src/main/java/moadong/club/payload/dto/ClubApplicantsResult.javabackend/src/main/java/moadong/club/service/ClubApplyServiceV1.java
🧬 Code graph analysis (21)
backend/src/main/java/moadong/club/entity/Club.java (2)
backend/src/main/java/moadong/club/entity/ClubRecruitmentInformation.java (1)
AllArgsConstructor(22-124)backend/src/main/java/moadong/club/service/RecruitmentStateChecker.java (1)
Slf4j(17-40)
backend/src/main/java/moadong/club/payload/response/ClubApplicationFormResponse.java (1)
backend/src/main/java/moadong/club/payload/response/ClubApplicationResponse.java (1)
Builder(8-14)
backend/src/main/java/moadong/fcm/service/FcmAsyncService.java (1)
backend/src/main/java/moadong/fcm/service/FcmService.java (1)
Slf4j(15-72)
backend/src/main/java/moadong/club/controller/ClubApplyControllerV1.java (2)
backend/src/main/java/moadong/club/controller/ClubApplyAdminController.java (1)
RestController(26-118)backend/src/main/java/moadong/club/controller/ClubApplyPublicController.java (1)
RestController(13-45)
backend/src/main/java/moadong/club/controller/ClubApplyAdminController.java (2)
backend/src/main/java/moadong/club/controller/ClubApplyControllerV1.java (1)
RestController(28-95)backend/src/main/java/moadong/club/controller/ClubApplyPublicController.java (1)
RestController(13-45)
backend/src/main/java/moadong/club/repository/ClubApplicationFormsRepositoryCustom.java (1)
backend/src/main/java/moadong/club/entity/ClubApplicationForm.java (1)
Document(20-96)
backend/src/main/java/moadong/club/entity/ClubApplicant.java (2)
backend/src/main/java/moadong/club/entity/ClubApplicationForm.java (1)
Document(20-96)backend/src/main/java/moadong/club/entity/ClubApplicationFormQuestion.java (1)
AllArgsConstructor(12-36)
backend/src/main/java/moadong/club/controller/ClubApplyPublicController.java (2)
backend/src/main/java/moadong/club/controller/ClubApplyAdminController.java (1)
RestController(26-118)backend/src/main/java/moadong/club/controller/ClubApplyControllerV1.java (1)
RestController(28-95)
backend/src/main/java/moadong/club/repository/ClubApplicationFormsRepository.java (1)
backend/src/main/java/moadong/club/repository/ClubApplicationFormsRepositoryCustom.java (1)
Repository(18-71)
backend/src/main/java/moadong/fcm/service/FcmService.java (1)
backend/src/main/java/moadong/fcm/service/FcmAsyncService.java (1)
Slf4j(24-86)
backend/src/test/java/moadong/club/service/ClubApplyAdminServiceTest.java (1)
backend/src/test/java/moadong/fixture/ClubApplicationEditFixture.java (1)
ClubApplicationEditFixture(7-27)
backend/src/test/java/moadong/fcm/service/FcmServiceTest.java (1)
backend/src/test/java/moadong/fcm/service/FcmTxServiceTest.java (1)
IntegrationTest(14-43)
backend/src/main/java/moadong/club/service/ClubApplyAdminService.java (2)
backend/src/main/java/moadong/club/service/ClubApplyPublicService.java (1)
Service(30-128)backend/src/main/java/moadong/club/service/ClubApplyServiceV1.java (1)
Service(31-202)
backend/src/main/java/moadong/club/service/RecruitmentStateChecker.java (1)
backend/src/main/java/moadong/club/entity/Club.java (1)
Slf4j(28-153)
backend/src/main/java/moadong/club/service/ClubApplyPublicService.java (2)
backend/src/main/java/moadong/club/service/ClubApplyAdminService.java (1)
Service(33-348)backend/src/main/java/moadong/club/service/ClubApplyServiceV1.java (1)
Service(31-202)
backend/src/main/java/moadong/club/summary/ApplicantIdMessagePublisher.java (1)
backend/src/main/java/moadong/club/summary/ApplicantIdMessageConsumer.java (1)
Component(24-63)
backend/src/main/java/moadong/club/service/ClubApplyServiceV1.java (2)
backend/src/main/java/moadong/club/service/ClubApplyAdminService.java (1)
Service(33-348)backend/src/main/java/moadong/club/service/ClubApplyPublicService.java (1)
Service(30-128)
backend/src/main/java/moadong/club/summary/ApplicantIdMessageConsumer.java (1)
backend/src/main/java/moadong/club/summary/ApplicantIdMessagePublisher.java (1)
Component(8-19)
backend/src/main/java/moadong/fcm/entity/FcmToken.java (2)
backend/src/main/java/moadong/user/entity/User.java (1)
Builder(21-99)backend/src/main/java/moadong/user/payload/CustomUserDetails.java (1)
Getter(9-42)
backend/src/main/java/moadong/club/payload/request/ClubApplicationFormEditRequest.java (1)
backend/src/main/java/moadong/club/payload/request/ClubApplicationEditRequest.java (1)
ClubApplicationEditRequest(9-22)
backend/src/main/java/moadong/club/entity/ClubApplicationForm.java (2)
backend/src/main/java/moadong/club/entity/ClubApplicant.java (1)
Document(17-53)backend/src/main/java/moadong/club/entity/ClubApplicationFormQuestion.java (1)
AllArgsConstructor(12-36)
| public record ClubApplicationFormResponse( | ||
| String title, | ||
| String description, | ||
| List<ClubApplicationFormQuestion> questions, | ||
| Integer semesterYear, | ||
| SemesterTerm semesterTerm, | ||
| ApplicationFormStatus status | ||
| ) { |
There was a problem hiding this comment.
엔티티를 Response DTO에 직접 노출하지 마세요.
questions 필드가 엔티티 타입인 ClubApplicationFormQuestion을 직접 사용하고 있습니다. 이는 다음 문제를 야기할 수 있습니다:
- JSON 직렬화 시 순환 참조 문제
- Lazy loading 관련 예외 발생 가능
- 엔티티 내부 구조가 API 응답에 그대로 노출
- 엔티티 변경 시 API 계약 변경
별도의 DTO 클래스(예: ClubApplicationFormQuestionDto)를 생성하고 변환 로직을 추가하세요.
// 예시: 별도 DTO 생성
public record ClubApplicationFormQuestionDto(
Long id,
String title,
String type,
boolean required
) {
public static ClubApplicationFormQuestionDto from(ClubApplicationFormQuestion entity) {
return new ClubApplicationFormQuestionDto(
entity.getId(),
entity.getTitle(),
entity.getType(),
entity.isRequired()
);
}
}그리고 Response에서는:
public record ClubApplicationFormResponse(
String title,
String description,
- List<ClubApplicationFormQuestion> questions,
+ List<ClubApplicationFormQuestionDto> questions,
Integer semesterYear,
SemesterTerm semesterTerm,
ApplicationFormStatus status
) {
}🤖 Prompt for AI Agents
In
backend/src/main/java/moadong/club/payload/response/ClubApplicationFormResponse.java
around lines 11 to 18, the record exposes the JPA entity
ClubApplicationFormQuestion directly in the questions field; create a separate
DTO (e.g., ClubApplicationFormQuestionDto) with only the fields needed, add a
static from(entity) mapper on that DTO, change the questions field type to
List<ClubApplicationFormQuestionDto>, and update the code that builds this
response (controller/service/mapper) to map each ClubApplicationFormQuestion
entity to ClubApplicationFormQuestionDto before constructing the response to
avoid serialization, lazy-loading and exposure issues; also update imports and
any tests/usages accordingly.
| public List<ClubApplicationFormsResult> findClubApplicationFormsByClubId(String clubId) { | ||
| List<AggregationOperation> operations = new ArrayList<>(); | ||
|
|
||
| operations.add(Aggregation.match(Criteria.where("clubId").is(clubId))); | ||
| operations.add(Aggregation.project() | ||
| .and("_id").as("_id") | ||
| .and("title").as("title") | ||
| .and("editedAt").as("editedAt") | ||
| .and("status").as("status") | ||
| .and("semesterYear").as("semesterYear") | ||
| .and("semesterTerm").as("semesterTerm")); | ||
|
|
||
| //1차 정렬 -> 그룹 내에서 최종수정날짜 순으로 정렬됨 | ||
| operations.add(Aggregation.sort(Sort.by( | ||
| Sort.Order.desc("editedAt"), | ||
| Sort.Order.desc("id") | ||
| ))); | ||
|
|
||
| //그룹화 | ||
| GroupOperation groupOperation = Aggregation.group("semesterYear","semesterTerm") | ||
| .push(new Document("_id", "$_id") | ||
| .append("title", "$title") | ||
| .append("editedAt", "$editedAt") | ||
| .append("status","$status")) | ||
| .as("forms"); | ||
| operations.add(groupOperation); | ||
|
|
||
| //그룹들 학기순 정렬 | ||
| operations.add(Aggregation.addFields() | ||
| .addFieldWithValue("termOrder", new Document("$indexOfArray", | ||
| Arrays.asList(Arrays.asList("FIRST", "SECOND"), "$_id.semesterTerm"))) | ||
| .build()); | ||
| operations.add(Aggregation.sort(Sort.by( | ||
| Sort.Order.desc("_id.semesterYear"), | ||
| Sort.Order.desc("termOrder")))); | ||
|
|
||
| operations.add(Aggregation.project("forms") | ||
| .and("_id.semesterYear").as("semesterYear") | ||
| .and("_id.semesterTerm").as("semesterTerm")); | ||
|
|
||
| Aggregation aggregation = Aggregation.newAggregation(operations); | ||
| return mongoTemplate | ||
| .aggregate(aggregation, "club_application_forms", ClubApplicationFormsResult.class) | ||
| .getMappedResults(); |
There was a problem hiding this comment.
🧩 Analysis chain
Aggregation 정렬 키 필드 오타 (id vs _id)
파이프라인에서:
operations.add(Aggregation.project()
.and("_id").as("_id")
...
);
...
operations.add(Aggregation.sort(Sort.by(
Sort.Order.desc("editedAt"),
Sort.Order.desc("id")
)));proj 단계에서 _id만 유지하고 id 필드는 만들지 않았는데, sort에서는 "id"를 사용하고 있습니다.
현재는 editedAt이 1차 정렬 키라서 실질적인 동작에는 큰 문제가 없겠지만, 동일한 editedAt 값들 사이에서 의도한 2차 정렬이 동작하지 않는 상태입니다.
아래처럼 "id" → "_id"로 수정하는 것이 안전해 보입니다.
operations.add(Aggregation.sort(Sort.by(
Sort.Order.desc("editedAt"),
Sort.Order.desc("_id")
)));또한 $indexOfArray에서 "FIRST", "SECOND" 문자열을 직접 쓰고 있어 SemesterTerm enum 이름 변경 시 취약하니, 여유가 되면 enum에서 name()을 사용해 배열을 구성하도록 개선해도 좋겠습니다.
---
🏁 Script executed:
```shell
#!/bin/bash
# Verify the repository structure and find the target file
find . -type f -name "ClubApplicationFormsRepositoryCustom.java" 2>/dev/null | head -20
Length of output: 89
🏁 Script executed:
#!/bin/bash
# Display the actual file content to verify against review snippet
fd -e java "ClubApplicationFormsRepositoryCustom" --exec cat {} \;Length of output: 2793
🏁 Script executed:
#!/bin/bash
# Search for SemesterTerm enum definition
rg -n "enum\s+SemesterTerm" --type java -A 10Length of output: 294
🏁 Script executed:
#!/bin/bash
# Check for any references to termOrder or indexOfArray in the codebase
rg -n "indexOfArray|termOrder|FIRST.*SECOND" --type java -B 2 -A 2Length of output: 2501
🏁 Script executed:
#!/bin/bash
# Search for other aggregation pipelines using similar patterns to understand context
rg -n "Aggregation\.sort.*editedAt|Sort\.Order\.desc" --type java -B 3 -A 3 | head -40Length of output: 2046
🏁 Script executed:
#!/bin/bash
# Look for ClubApplicationFormsResult class to understand the output structure
rg -n "class ClubApplicationFormsResult" --type java -A 15Length of output: 0
정렬 필드 오류: id 대신 _id 사용 필요
파이프라인 28~34줄에서 프로젝션 단계에서 _id 필드만 유지하고 id 필드는 생성하지 않았으나, 39줄의 정렬 단계에서 "id"를 참조하고 있습니다. 이로 인해 editedAt 값이 동일한 레코드들 간의 2차 정렬이 의도대로 작동하지 않습니다.
operations.add(Aggregation.sort(Sort.by(
Sort.Order.desc("editedAt"),
Sort.Order.desc("_id") // "id" → "_id" 수정 필요
)));또한 54줄에서 $indexOfArray 연산에 하드코딩된 "FIRST", "SECOND" 문자열은 SemesterTerm 열거형 값이 변경될 경우 유지보수가 어려우므로, 가능하면 SemesterTerm.values()를 활용하여 동적으로 배열을 구성하도록 개선하면 좋겠습니다.
🤖 Prompt for AI Agents
In
backend/src/main/java/moadong/club/repository/ClubApplicationFormsRepositoryCustom.java
around lines 28-34, 39 and 54, the secondary sort references "id" which is not
projected (use "_id" instead) causing incorrect tie-breaking; change the
Aggregation.sort call to use Sort.Order.desc("_id") for the secondary sort. Also
replace the hardcoded Arrays.asList(Arrays.asList("FIRST", "SECOND"), ...) used
in the $indexOfArray with a dynamic list built from SemesterTerm.values() (map
enum names or values into a List and pass that into the aggregation) so term
ordering follows the enum rather than fixed strings.
| @Transactional //test 사용 | ||
| public void editClubApplicationQuestion(String applicationFormId, CustomUserDetails user, ClubApplicationFormEditRequest request) { | ||
| ClubApplicationForm clubApplicationForm = clubApplicationFormsRepository.findById(applicationFormId) | ||
| .orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND)); | ||
|
|
||
| updateApplicationForm(clubApplicationForm, request); | ||
| clubApplicationForm.updateEditedAt(); | ||
|
|
||
| clubApplicationFormsRepository.save(clubApplicationForm); | ||
| } |
There was a problem hiding this comment.
편집 대상 폼의 소유 검증이 누락되었습니다
findById만 호출하면 존재 여부만 확인하고 현재 로그인한 클럽의 폼인지 확인하지 못합니다. 다른 클럽의 applicationFormId를 전달하면 해당 폼을 수정할 수 있어 권한 우회가 됩니다. clubId까지 함께 조회하도록 바꿔 주세요.
- ClubApplicationForm clubApplicationForm = clubApplicationFormsRepository.findById(applicationFormId)
- .orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND));
+ ClubApplicationForm clubApplicationForm = clubApplicationFormsRepository.findByClubIdAndId(user.getClubId(), applicationFormId)
+ .orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND));Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In backend/src/main/java/moadong/club/service/ClubApplyAdminService.java around
lines 105 to 114, the code only checks existence by findById and misses
verifying the form belongs to the caller's club; change the lookup to include
the clubId (e.g., repository.findByIdAndClubId(applicationFormId,
user.getClubId()) or equivalent), throw ErrorCode.APPLICATION_NOT_FOUND if not
found, then proceed with updateApplicationForm, updateEditedAt, and save; this
prevents cross-club authorization bypass.
| @Transactional | ||
| public void editApplicantDetail(String applicationFormId, List<ClubApplicantEditRequest> request, CustomUserDetails user) { | ||
| String clubId = user.getClubId(); | ||
|
|
||
| Map<String, ClubApplicantEditRequest> requestMap = request.stream() | ||
| .collect(Collectors.toMap(ClubApplicantEditRequest::applicantId, | ||
| Function.identity(), (prev, next) -> next)); | ||
|
|
||
| List<String> applicationIds = new ArrayList<>(requestMap.keySet()); | ||
| List<ClubApplicant> application = clubApplicantsRepository.findAllByIdInAndFormId(applicationIds, applicationFormId); | ||
|
|
||
| if (application.size() != applicationIds.size()) { | ||
| throw new RestApiException(ErrorCode.APPLICANT_NOT_FOUND); | ||
| } | ||
|
|
||
| application.forEach(app -> { | ||
| ClubApplicantEditRequest editRequest = requestMap.get(app.getId()); | ||
| app.updateMemo(editRequest.memo()); | ||
| app.updateStatus(editRequest.status()); | ||
|
|
||
| // SSE 이벤트 발송 | ||
| ApplicantStatusEvent event = new ApplicantStatusEvent( | ||
| app.getId(), | ||
| editRequest.status(), | ||
| editRequest.memo(), | ||
| ZonedDateTime.now(ZoneId.of("Asia/Seoul")).toLocalDateTime(), | ||
| clubId, | ||
| applicationFormId | ||
| ); | ||
|
|
||
| TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { | ||
| @Override | ||
| public void afterCommit() { | ||
| CompletableFuture.runAsync(() -> sendStatusChangeEvent(clubId, applicationFormId, event)); | ||
| } | ||
| }); | ||
| }); | ||
|
|
||
| clubApplicantsRepository.saveAll(application); | ||
| } |
There was a problem hiding this comment.
지원자 수정 시 폼 소유 검증 누락
현재 applicationFormId가 사용자 클럽 소속인지 확인하지 않아, 다른 클럽의 폼/지원자 ID를 전달하면 타 클럽 지원자의 메모·상태를 수정할 수 있습니다. 수정 전에 해당 폼이 로그인한 클럽에 속하는지 조회해 주세요.
- String clubId = user.getClubId();
+ ClubApplicationForm applicationForm = clubApplicationFormsRepository.findByClubIdAndId(user.getClubId(), applicationFormId)
+ .orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND));
+ String clubId = applicationForm.getClubId();
Map<String, ClubApplicantEditRequest> requestMap = request.stream()
.collect(Collectors.toMap(ClubApplicantEditRequest::applicantId,
Function.identity(), (prev, next) -> next));
List<String> applicationIds = new ArrayList<>(requestMap.keySet());
- List<ClubApplicant> application = clubApplicantsRepository.findAllByIdInAndFormId(applicationIds, applicationFormId);
+ List<ClubApplicant> application = clubApplicantsRepository.findAllByIdInAndFormId(applicationIds, applicationForm.getId());🤖 Prompt for AI Agents
In backend/src/main/java/moadong/club/service/ClubApplyAdminService.java around
lines 166 to 205, you must validate that the applicationFormId belongs to the
requesting user's club before applying edits: query the application form
repository (or repository method) for existence by applicationFormId and clubId
(user.getClubId()), and if not found throw a RestApiException (use the
appropriate ErrorCode such as FORM_NOT_FOUND or FORBIDDEN); only after this
ownership check pass should you load and modify applicants and register the
afterCommit SSE transaction. Ensure the check happens before fetching/updating
ClubApplicant entities.
| @Transactional | ||
| public void deleteApplicant(String applicationFormId, ClubApplicantDeleteRequest request, CustomUserDetails user) { | ||
| List<ClubApplicant> applicants = clubApplicantsRepository.findAllByIdInAndFormId(request.applicantIds(), applicationFormId); | ||
|
|
||
| if (applicants.size() != request.applicantIds().size()) { | ||
| throw new RestApiException(ErrorCode.APPLICANT_NOT_FOUND); | ||
| } | ||
|
|
||
| clubApplicantsRepository.deleteAll(applicants); |
There was a problem hiding this comment.
지원자 삭제 시 폼 소유 검증 누락
삭제 시에도 applicationFormId가 로그인한 클럽의 폼인지 확인하지 않아, 다른 클럽의 지원자를 삭제할 수 있습니다. 먼저 폼이 해당 클럽 소속인지 조회한 뒤 삭제를 진행해 주세요.
- List<ClubApplicant> applicants = clubApplicantsRepository.findAllByIdInAndFormId(request.applicantIds(), applicationFormId);
+ ClubApplicationForm applicationForm = clubApplicationFormsRepository.findByClubIdAndId(user.getClubId(), applicationFormId)
+ .orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND));
+
+ List<ClubApplicant> applicants = clubApplicantsRepository.findAllByIdInAndFormId(request.applicantIds(), applicationForm.getId());🤖 Prompt for AI Agents
In backend/src/main/java/moadong/club/service/ClubApplyAdminService.java around
lines 207 to 215, the deleteApplicant method fails to verify that the
applicationFormId belongs to the logged-in user's club; before deleting, load
the ApplicationForm (or ClubForm) by id and the club id from user (e.g.
repository.findByIdAndClubId(applicationFormId, user.getClubId()) or fetch by id
then compare form.getClub().getId() to user.getClubId()), throw an appropriate
exception (e.g. FORBIDDEN or FORM_NOT_FOUND) if not found/mismatched, and only
then proceed to delete the applicants.
| public AIResponse getSummarizeContent(String prompt) { | ||
| try { | ||
| String gemmaServerUrl = "http://" + gemmaServerHost + ":" + gemmaServerPort + "/api/generate"; | ||
| AIRequest request = new AIRequest("gemma3:4b", prompt, "json", false, -1); | ||
| AIResponse response = restTemplate.postForObject(gemmaServerUrl, request, AIResponse.class); | ||
| if (response != null) { | ||
| return objectMapper.readValue(response.response(), AIResponse.class); | ||
| } | ||
| } catch (Exception e) { | ||
| log.error("Json Serialize Error: ", e); | ||
| return null; | ||
| } | ||
| return null; | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
🧩 Analysis chain
외부 API 호출에 재시도 로직을 추가하세요.
Gemma API는 외부 서비스이므로 일시적인 네트워크 오류나 서비스 장애에 대비한 재시도 로직이 필요합니다. Spring Retry의 @Retryable 애노테이션을 사용하거나 RestTemplate에 인터셉터를 추가하세요.
외부 API 호출에 대한 재시도 정책이 다른 곳에 구현되어 있는지 확인하세요:
🏁 Script executed:
#!/bin/bash
# Spring Retry 설정 및 사용 확인
rg -n "@Retryable|@EnableRetry|RetryTemplate" --type=java
# RestTemplate 인터셉터 확인
rg -n "ClientHttpRequestInterceptor|setInterceptors" --type=javaLength of output: 144
🏁 Script executed:
#!/bin/bash
# Check GemmaService for @Retryable annotation
rg -n "class GemmaService|@Retryable" --type=java -A 5 -B 5 | head -100
# Check RestTemplate bean configuration
rg -n "RestTemplate|@Bean" --type=java -C 3 | grep -A 10 -B 10 "RestTemplate"Length of output: 7073
🏁 Script executed:
#!/bin/bash
# Specifically look for GemmaService file
fd -t f "GemmaService.java" --exec cat {}Length of output: 1349
🏁 Script executed:
#!/bin/bash
# Check for RestTemplate configuration classes
fd -t f -e java | xargs grep -l "RestTemplate\|HttpClient" | head -10Length of output: 129
외부 API 호출에 재시도 로직을 추가하세요.
검증 결과, GemmaService.getSummarizeContent() 메서드(26-39줄)에는 재시도 로직이 없습니다. Spring Retry 프레임워크(@EnableRetry)가 이미 활성화되어 있고, 유사한 패턴이 FcmTxService에서 사용 중입니다. RestTemplateConfig의 RestTemplate 빈도 현재는 타임아웃만 설정되어 있고 재시도 메커니즘이 없습니다.
다음 중 하나로 구현하세요:
@Retryable애노테이션을 메서드에 추가 (FcmTxService의 예시 참고: maxAttempts=2, backoff delay=100)- 또는
RestTemplateConfig에ClientHttpRequestInterceptor를 추가하여 전역 재시도 정책 구성
🧩 Analysis chain
응답 파싱 로직을 검증하세요.
라인 32의 파싱 로직에 의문이 있습니다:
AIResponse response = restTemplate.postForObject(gemmaServerUrl, request, AIResponse.class);
if (response != null) {
return objectMapper.readValue(response.response(), AIResponse.class);
}이미 AIResponse 타입으로 받은 response의 response() 필드(String)를 다시 AIResponse로 파싱하고 있습니다. 이는 논리적으로 맞지 않습니다.
다음 중 하나가 의도일 것으로 보입니다:
response.response()가 다른 DTO 타입(예:SummarizeResult)으로 파싱되어야 함- 단순히
response를 그대로 반환해야 함 - API 응답 구조가 중첩되어 있어서 두 번 파싱이 필요한 경우
API 응답 구조를 확인하고 올바른 파싱 로직으로 수정하세요.
---
🏁 Script executed:
```shell
#!/bin/bash
# Find AIResponse class definition
fd -t f "AIResponse\.java" --exec cat {}
Length of output: 154
🏁 Script executed:
#!/bin/bash
# Search for other DTO/Response classes that might be related
fd -t f "\.java" -path "*/dto/*" -o -path "*/model/*" -o -path "*/response/*" | head -20Length of output: 168
🏁 Script executed:
#!/bin/bash
# Check AIRequest structure and imports in GemmaService
fd -t f "GemmaService\.java" --exec cat {}Length of output: 1349
🏁 Script executed:
#!/bin/bash
# Search for other usages of getSummarizeContent or similar response parsing
rg -n "AIResponse|response\.response\(\)" --type=java -A 3 -B 3Length of output: 4367
라인 32의 이중 파싱 로직을 수정하세요.
restTemplate.postForObject()로 이미 AIResponse.class로 역직렬화된 response 객체를 다시 한 번 response.response()에서 추출한 String을 AIResponse.class로 파싱하려고 시도하고 있습니다. 이는 논리적 오류입니다.
AIResponse 레코드는 String response 필드만 포함하므로, response.response()는 단순 문자열입니다. 이를 다시 AIResponse로 파싱할 수 없습니다.
라인 32를 다음과 같이 수정하세요:
return response; // 이미 역직렬화된 AIResponse를 그대로 반환호출자(ApplicantIdMessageConsumer, 라인 59)도 반환된 AIResponse 객체에서 response() 메서드를 호출하고 있으므로, 수정된 로직이 올바른 의도입니다.
🤖 Prompt for AI Agents
In backend/src/main/java/moadong/gemma/service/GemmaService.java around lines 26
to 39, the method currently double-parses the AIResponse by calling
objectMapper.readValue on response.response() which is a plain String; replace
that double-parsing with returning the already-deserialized response directly.
Change the code so after the postForObject call you simply return response (and
keep the existing try/catch), ensuring callers that expect AIResponse can call
response() themselves.
| } catch (Exception e) { | ||
| log.error("Json Serialize Error: ", e); | ||
| return null; | ||
| } | ||
| return null; |
There was a problem hiding this comment.
에러 처리를 개선하세요.
현재 에러 처리에 여러 문제가 있습니다:
- null 반환: 호출하는 쪽에서 NPE가 발생할 수 있습니다.
Optional<AIResponse>반환이나 명시적 예외 발생을 고려하세요. - 로그 메시지 부정확: "Json Serialize Error"가 아니라 "Json Deserialization Error"가 정확합니다.
- catch-all Exception: 구체적인 예외 타입(예:
HttpClientErrorException,JsonProcessingException)을 분리하여 처리하면 디버깅과 모니터링에 유리합니다.
public AIResponse getSummarizeContent(String prompt) {
try {
String gemmaServerUrl = "http://" + gemmaServerHost + ":" + gemmaServerPort + "/api/generate";
AIRequest request = new AIRequest("gemma3:4b", prompt, "json", false, -1);
AIResponse response = restTemplate.postForObject(gemmaServerUrl, request, AIResponse.class);
if (response != null) {
return objectMapper.readValue(response.response(), AIResponse.class);
}
+ throw new RestApiException(ErrorCode.GEMMA_API_NO_RESPONSE);
- } catch (Exception e) {
- log.error("Json Serialize Error: ", e);
- return null;
+ } catch (HttpClientErrorException | HttpServerErrorException e) {
+ log.error("Gemma API HTTP Error: ", e);
+ throw new RestApiException(ErrorCode.GEMMA_API_ERROR);
+ } catch (JsonProcessingException e) {
+ log.error("Json Deserialization Error: ", e);
+ throw new RestApiException(ErrorCode.JSON_PROCESSING_ERROR);
}
- return null;
}Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In backend/src/main/java/moadong/gemma/service/GemmaService.java around lines
34-38, the catch block currently swallows all Exceptions, logs an inaccurate
message, and returns null; change the method to return Optional<AIResponse> (or
throw a checked runtime exception) instead of returning null, update the log
message to "Json Deserialization Error" and log the exception details, and
replace the single catch(Exception) with specific catches (e.g.,
JsonProcessingException for JSON issues and HttpClientErrorException or
RestClientException for HTTP errors), handling each by logging appropriately and
either returning Optional.empty() or rethrowing a meaningful exception so
callers won't get an NPE.
| @PostConstruct | ||
| public void init() throws IOException { | ||
| try { | ||
| ClassPathResource serviceAccount = | ||
| new ClassPathResource("firebase.json"); | ||
|
|
||
| if (!serviceAccount.exists()) { | ||
| throw new IOException("Firebase service account file not found"); | ||
| } | ||
|
|
||
| InputStream in = serviceAccount.getInputStream(); | ||
|
|
||
| FirebaseOptions.Builder options = FirebaseOptions.builder(); | ||
| options.setCredentials(GoogleCredentials.fromStream(in)); | ||
|
|
||
| if (FirebaseApp.getApps().isEmpty()) { | ||
| FirebaseApp.initializeApp(options.build()); | ||
| log.info("Firebase app has been initialized"); | ||
| } | ||
|
|
||
| in.close(); | ||
| } catch (Exception e) { | ||
| log.error("Firebase app initialization failed", e); | ||
| throw e; | ||
| } | ||
| } |
There was a problem hiding this comment.
리소스 누수 위험: try-with-resources를 사용해야 합니다.
InputStream이 수동으로 닫히고 있으며, 33-37번 라인에서 예외가 발생하면 40번 라인의 close()가 실행되지 않아 리소스 누수가 발생합니다.
다음과 같이 수정하세요:
@PostConstruct
public void init() throws IOException {
try {
ClassPathResource serviceAccount =
new ClassPathResource("firebase.json");
if (!serviceAccount.exists()) {
throw new IOException("Firebase service account file not found");
}
- InputStream in = serviceAccount.getInputStream();
-
- FirebaseOptions.Builder options = FirebaseOptions.builder();
- options.setCredentials(GoogleCredentials.fromStream(in));
-
- if (FirebaseApp.getApps().isEmpty()) {
- FirebaseApp.initializeApp(options.build());
- log.info("Firebase app has been initialized");
- }
-
- in.close();
+ try (InputStream in = serviceAccount.getInputStream()) {
+ FirebaseOptions options = FirebaseOptions.builder()
+ .setCredentials(GoogleCredentials.fromStream(in))
+ .build();
+
+ if (FirebaseApp.getApps().isEmpty()) {
+ FirebaseApp.initializeApp(options);
+ log.info("Firebase app has been initialized");
+ }
+ }
} catch (Exception e) {
log.error("Firebase app initialization failed", e);
throw e;
}
}🤖 Prompt for AI Agents
In backend/src/main/java/moadong/global/util/FcmInitializer.java around lines 20
to 45, the InputStream is closed manually which can leak if an exception is
thrown; change to use try-with-resources when obtaining the InputStream from
ClassPathResource (e.g., open the stream in a try (InputStream in =
serviceAccount.getInputStream()) block), perform the
GoogleCredentials.fromStream(in) and FirebaseOptions building inside that block,
remove the explicit in.close(), and keep the existing exception handling and
logging as-is.
| @NotNull | ||
| private String clubId; | ||
|
|
There was a problem hiding this comment.
🧩 Analysis chain
clubId 필드의 필수 여부와 기본값 확인 필요
clubId에 @NotNull만 있고 기본값이나 @Builder.Default가 없어서, User.builder()를 사용하는 기존 코드에서 clubId를 세팅하지 않으면 검증 단계에서 NPE/검증 오류가 날 수 있습니다.
실제로 모든 User가 항상 특정 동아리(clubId)에 소속된다는 전제가 아니라면:
@NotNull제거, 혹은@Builder.Default private String clubId = "";등 기본값 부여
중 하나를 고려해 볼 만합니다. 반대로 “항상 필수”가 맞다면, 빌더/생성 시점에서 모두 세팅되도록 사용처를 전수 점검해 두는 게 안전합니다.
🏁 Script executed:
#!/bin/bash
# 1. Examine User.java to understand clubId field and surrounding context
echo "=== User.java Full Content ==="
find . -name "User.java" -path "*/user/entity/*" | head -1 | xargs catLength of output: 2361
🏁 Script executed:
#!/bin/bash
# 2. Search for User.builder() usage patterns in the codebase
echo "=== User.builder() Usage Patterns ==="
rg -n "User\.builder\(\)" --type=java -B2 -A5 | head -100Length of output: 1795
🏁 Script executed:
#!/bin/bash
# 3. Search for clubId initialization patterns
echo "=== clubId Usage/Initialization Patterns ==="
rg -n "clubId" --type=java -B1 -A1 | head -80Length of output: 6842
🏁 Script executed:
#!/bin/bash
# 4. Look for User constructors or factory methods
echo "=== User Class Structure (constructors, fields) ==="
ast-grep --pattern 'class User {
$$$
}'Length of output: 51
clubId 필드에서 @NotNull과 기본값 불일치 확인됨
clubId 필드가 @NotNull로 선언되었으나 @Builder.Default 없이 선언되어 있어, 검증 제약과 실제 빌더 동작이 불일치합니다.
현황:
UserRegisterRequest.toUserEntity()에서 User를 빌드할 때 clubId를 설정하지 않음 (backend/src/main/java/moadong/user/payload/request/UserRegisterRequest.java:34-38)UserCommandService.createUser()에서 빌드 후updateClubId(clubId)로 나중에 설정 (backend/src/main/java/moadong/user/service/UserCommandService.java:167-171)- 테스트 픽스처도 clubId 없이 User 생성 (backend/src/test/java/moadong/fixture/UserFixture.java:25-29)
- 다른
@NotNull필드(emailVerified,createdAt,status)는@Builder.Default로 기본값 제공
권장사항:
다음 중 하나를 적용하세요:
clubId에@Builder.Default추가 후 적절한 기본값 설정, 또는@NotNull제거 후 선택 필드로 변경
| import jakarta.servlet.http.HttpServletResponse; | ||
| import java.util.Date; | ||
|
|
||
| import jakarta.transaction.Transactional; |
There was a problem hiding this comment.
잘못된 Transactional 어노테이션 임포트를 수정하세요.
jakarta.transaction.Transactional은 JTA(Java Transaction API) 트랜잭션 어노테이션으로, Spring 컨텍스트에서 제대로 작동하지 않을 수 있습니다. Spring에서는 org.springframework.transaction.annotation.Transactional을 사용해야 합니다.
다음 diff를 적용하여 올바른 임포트로 수정하세요:
-import jakarta.transaction.Transactional;
+import org.springframework.transaction.annotation.Transactional;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| import jakarta.transaction.Transactional; | |
| import org.springframework.transaction.annotation.Transactional; |
🤖 Prompt for AI Agents
In backend/src/main/java/moadong/user/service/UserCommandService.java around
line 7, the Transactional import is incorrect; replace the current import of
jakarta.transaction.Transactional with
org.springframework.transaction.annotation.Transactional and remove the jakarta
import so Spring's transaction management is used correctly.
Summary by CodeRabbit
릴리스 노트
New Features
Refactor