-
Notifications
You must be signed in to change notification settings - Fork 0
feat/#45: 학생회 게시글 작성 시, 학생에게 푸시알림 기능 구현 #51
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
ba45b2d
bf3ff03
4eeee56
62ede9c
f7c875e
4cd891a
604495c
cf57384
541c15a
c8f0c6c
812256c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -9,21 +9,32 @@ public boolean hasAccess(User user, StudentCouncil writer) { | |||||||||||||||||||
| return user.getSchool() != null && | ||||||||||||||||||||
| user.getSchool().getSchoolId().equals(writer.getSchool().getSchoolId()); | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| @Override public String topic(StudentCouncil writer) { | ||||||||||||||||||||
| return "school_" + writer.getSchool().getSchoolId(); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| }, | ||||||||||||||||||||
| COLLEGE_COUNCIL { | ||||||||||||||||||||
| @Override | ||||||||||||||||||||
| public boolean hasAccess(User user, StudentCouncil writer) { | ||||||||||||||||||||
| return user.getCollege() != null && | ||||||||||||||||||||
| user.getCollege().getCollegeId().equals(writer.getCollege().getCollegeId()); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| @Override public String topic(StudentCouncil writer) { | ||||||||||||||||||||
| return "college_" + writer.getCollege().getCollegeId(); | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
Comment on lines
+23
to
+25
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Null 안전성 검증 필요
🛡️ Null 안전성을 위한 수정안 @Override public String topic(StudentCouncil writer) {
+ if (writer.getCollege() == null) {
+ throw new IllegalStateException("COLLEGE_COUNCIL writer must have a college");
+ }
return "college_" + writer.getCollege().getCollegeId();
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||
| }, | ||||||||||||||||||||
| MAJOR_COUNCIL { | ||||||||||||||||||||
| @Override | ||||||||||||||||||||
| public boolean hasAccess(User user, StudentCouncil writer) { | ||||||||||||||||||||
| return user.getMajor() != null && | ||||||||||||||||||||
| user.getMajor().getMajorId().equals(writer.getMajor().getMajorId()); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| @Override public String topic(StudentCouncil writer) { | ||||||||||||||||||||
| return "major_" + writer.getMajor().getMajorId(); | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
Comment on lines
+33
to
+35
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Null 안전성 검증 필요
🛡️ Null 안전성을 위한 수정안 @Override public String topic(StudentCouncil writer) {
+ if (writer.getMajor() == null) {
+ throw new IllegalStateException("MAJOR_COUNCIL writer must have a major");
+ }
return "major_" + writer.getMajor().getMajorId();
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||
| }; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| public abstract boolean hasAccess(User user, StudentCouncil writer); | ||||||||||||||||||||
| public abstract String topic(StudentCouncil writer); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| package com.campus.campus.domain.councilpost.application; | ||
|
|
||
| import java.util.Map; | ||
|
|
||
| import org.springframework.scheduling.annotation.Async; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.transaction.event.TransactionPhase; | ||
| import org.springframework.transaction.event.TransactionalEventListener; | ||
|
|
||
| import com.campus.campus.domain.councilpost.application.dto.request.CouncilPostCreatedEvent; | ||
| import com.campus.campus.global.firebase.application.service.FirebaseCloudMessageService; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
|
|
||
| @Slf4j | ||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class CouncilPostPushListener { | ||
|
|
||
| private static final String DATA_KEY_TYPE = "type"; | ||
| private static final String DATA_TYPE_COUNCIL_POST_CREATED = "COUNCIL_POST_CREATED"; | ||
| private static final String DATA_KEY_POST_ID = "postId"; | ||
| private static final String DATA_KEY_CATEGORY = "category"; | ||
|
|
||
| private final FirebaseCloudMessageService firebaseCloudMessageService; | ||
|
|
||
| @Async | ||
| @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) | ||
| public void handleCouncilPostCreatedEvent(CouncilPostCreatedEvent event) { | ||
1winhyun marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| String title = event.councilName(); | ||
| String body = resolveBody(event.category()); | ||
|
|
||
| log.info("[PUSH] after_commit event received. topic={}, postId={}, category={}", | ||
| event.topic(), event.postId(), event.category()); | ||
|
|
||
| firebaseCloudMessageService.sendToTopic( | ||
| event.topic(), | ||
| title, | ||
| body, | ||
| Map.of( | ||
| DATA_KEY_TYPE, DATA_TYPE_COUNCIL_POST_CREATED, | ||
| DATA_KEY_POST_ID, String.valueOf(event.postId()), | ||
| DATA_KEY_CATEGORY, event.category() | ||
| ) | ||
| ); | ||
| } | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| private String resolveBody(String category) { | ||
| return switch (category) { | ||
| case "PARTNERSHIP" -> "새 제휴 게시글이 등록되었습니다."; | ||
| case "EVENT" -> "새 행사글이 등록되었습니다."; | ||
| default -> "새 게시글이 등록되었습니다."; | ||
| }; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| package com.campus.campus.domain.councilpost.application.dto.request; | ||
|
|
||
| public record CouncilPostCreatedEvent( | ||
| Long postId, | ||
| String councilName, | ||
| String category, | ||
| String topic | ||
| ) {} |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,9 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| package com.campus.campus.global.config; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.context.annotation.Configuration; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.scheduling.annotation.EnableAsync; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Configuration | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @EnableAsync | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public class AsyncConfig { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
10
to
30
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: Short summary
What @EnableAsync looks up
Key Boot behavior notes
Recommended best practices
Quick config examples
References
If you want, I can: (a) paste a recommended ThreadPoolTaskExecutor @bean + AsyncConfigurer example, or (b) produce tuned sizing guidance based on your app’s CPU vs blocking characteristics — tell me your expected concurrency and type of tasks. 🌐 Web query:
💡 Result: Short answer: yes — by default Spring's ThreadPoolTaskExecutor uses an effectively unbounded queue (queueCapacity = Integer.MAX_VALUE), and Spring Boot's auto-configured @async executor uses that queue with a core size (default 8). That combination means the queue can grow without bound (risking OOM) and prevents the pool from expanding beyond the core (max-size is ignored while the queue is unbounded). [1][2][3] What to do (brief):
Sources:
🌐 Web query:
💡 Result: Short recommended production settings & rules-of-thumb for Spring Boot AsyncConfigurer / ThreadPoolTaskExecutor:
If you want, tell me: (1) your host #CPU cores, (2) whether tasks are CPU- or IO-bound, and (3) an expected concurrency/throughput—I’ll propose concrete numbers and example config/YAML. Citations:
@EnableAsync 단독 구성 시 기본 Executor의 무제한 큐로 인한 운영 리스크 해결 필요. Spring Boot 3.5에서는 @EnableAsync 시 자동으로 ThreadPoolTaskExecutor를 구성하지만, 기본값으로 큐 용량이 Integer.MAX_VALUE(사실상 무제한)로 설정됩니다. 이로 인해 트래픽 급증 시 메모리 무제한 증가와 스레드 풀 확장 불능이 발생할 수 있어, 운영 환경에서는 다음과 같이 명시적으로 유계 TaskExecutor를 빈으로 등록하거나 설정 프로퍼티를 통해 큐를 제한해야 합니다. 푸시 발송 같은 백그라운드 작업이 몰릴 경우 메모리 부족(OOM)으로 이어질 수 있으므로, 적절한 corePoolSize, maxPoolSize, queueCapacity를 지정한 전용 Executor 구성을 권장하며, 함께 AsyncUncaughtExceptionHandler를 정의하여 void @async 메서드 예외도 처리하면 좋습니다. 권장 설정 예시 (유계 executor + 예외 처리) package com.campus.campus.global.config;
+import java.util.concurrent.Executor;
+
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.annotation.AsyncConfigurer;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
+import org.springframework.context.annotation.Bean;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
@Configuration
@EnableAsync
-public class AsyncConfig {
+public class AsyncConfig implements AsyncConfigurer {
+
+ private static final Logger log = LoggerFactory.getLogger(AsyncConfig.class);
+
+ @Override
+ @Bean(name = "taskExecutor")
+ public Executor getAsyncExecutor() {
+ ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+ executor.setCorePoolSize(4);
+ executor.setMaxPoolSize(16);
+ executor.setQueueCapacity(100);
+ executor.setThreadNamePrefix("async-");
+ executor.setWaitForTasksToCompleteOnShutdown(true);
+ executor.setAwaitTerminationSeconds(30);
+ executor.initialize();
+ return executor;
+ }
+
+ @Override
+ public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
+ return (ex, method, params) ->
+ log.error("Uncaught async exception in {}.{}()",
+ method.getDeclaringClass().getSimpleName(), method.getName(), ex);
+ }
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| package com.campus.campus.global.firebase.application.dto; | ||
|
|
||
| import io.swagger.v3.oas.annotations.media.Schema; | ||
|
|
||
|
|
||
| public record FcmMessageRequestDto( | ||
|
|
||
| @Schema(description = "유저ID", example = "1") | ||
| Long userId, | ||
|
|
||
| @Schema(description = "메시지 발송인", example = "시스템") | ||
| String title, | ||
|
|
||
| @Schema(description = "메시지 내용", example = "이제 서연님에게 화이팅을 할 수 있어요.") | ||
| String body | ||
| ) { | ||
|
Comment on lines
+6
to
+16
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 요청 DTO에 Validation(@NotNull/@notblank) 추가 + Schema 설명 정합성 맞추기 권장. Proposed diff package com.campus.campus.global.firebase.application.dto;
import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
public record FcmMessageRequestDto(
@Schema(description = "유저ID", example = "1")
+ @NotNull
Long userId,
- @Schema(description = "메시지 발송인", example = "시스템")
+ @Schema(description = "메시지 제목", example = "시스템")
+ @NotBlank
String title,
@Schema(description = "메시지 내용", example = "이제 서연님에게 화이팅을 할 수 있어요.")
+ @NotBlank
String body
) {
}🤖 Prompt for AI Agents |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| package com.campus.campus.global.firebase.application.service; | ||
|
|
||
| import java.util.Map; | ||
|
|
||
| import org.springframework.stereotype.Service; | ||
|
|
||
| import com.campus.campus.global.firebase.exception.FcmTopicSendFailedException; | ||
| import com.google.firebase.messaging.FirebaseMessaging; | ||
| import com.google.firebase.messaging.FirebaseMessagingException; | ||
| import com.google.firebase.messaging.Message; | ||
| import com.google.firebase.messaging.Notification; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
|
|
||
| @Slf4j | ||
| @Service | ||
| @RequiredArgsConstructor | ||
| public class FirebaseCloudMessageService { | ||
|
|
||
| public void sendToTopic(String topic, String title, String body, Map<String, String> data) { | ||
| Message.Builder builder = Message.builder() | ||
| .setTopic(topic) | ||
| .setNotification(Notification.builder() | ||
| .setTitle(title) | ||
| .setBody(body) | ||
| .build()); | ||
|
|
||
| if (data != null) { | ||
| data.forEach(builder::putData); | ||
| } | ||
|
|
||
| try { | ||
| String messageId = FirebaseMessaging.getInstance().send(builder.build()); | ||
| log.info("[FCM] sent. topic={}, messageId={}, title={}, body={}, dataKeys={}", | ||
| topic, | ||
| messageId, | ||
| title, | ||
| body, | ||
| (data == null ? "[]" : data.keySet().toString()) | ||
| ); | ||
|
|
||
| } catch (FirebaseMessagingException e) { | ||
| log.error("[FCM] send failed. topic={}, errorCode={}, message={}", | ||
| topic, | ||
| e.getErrorCode(), | ||
| e.getMessage(), | ||
| e | ||
| ); | ||
| throw new FcmTopicSendFailedException(e); | ||
| } | ||
|
Comment on lines
50
to
59
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # Find usages of FirebaseCloudMessageService and sendToTopic
rg -n "FirebaseCloudMessageService|sendToTopic" --type java -A 3 -B 3Repository: our-campUS/our-campUS-BE Length of output: 7147 🏁 Script executed: # Check for @Async annotations in the codebase
rg -n "@Async" --type java -A 2 -B 2Repository: our-campUS/our-campUS-BE Length of output: 767 🏁 Script executed: # Look for CouncilPostPushListener specifically
fd "CouncilPostPushListener" --type fRepository: our-campUS/our-campUS-BE Length of output: 159 🏁 Script executed: # Check for retry mechanisms (Spring Retry, Resilience4j, etc.)
rg -n "Retry|@Retryable|RetryTemplate" --type java -A 2Repository: our-campUS/our-campUS-BE Length of output: 50 일시적 Firebase 오류에 대한 재시도 메커니즘을 추가하세요. 현재 상태:
권장 사항:
|
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| package com.campus.campus.global.firebase.application.service; | ||
|
|
||
| import java.io.IOException; | ||
| import java.io.InputStream; | ||
|
|
||
| import javax.annotation.PostConstruct; | ||
|
|
||
| import org.springframework.core.io.ClassPathResource; | ||
| import org.springframework.stereotype.Service; | ||
|
|
||
| import com.campus.campus.global.firebase.exception.FirebaseInitializationFailedException; | ||
| import com.google.auth.oauth2.GoogleCredentials; | ||
| import com.google.firebase.FirebaseApp; | ||
| import com.google.firebase.FirebaseOptions; | ||
|
|
||
| @Service | ||
| public class FirebaseInitializer { | ||
|
|
||
| @PostConstruct | ||
| public void initialize() { | ||
| if (!FirebaseApp.getApps().isEmpty()) { | ||
| return; | ||
| } | ||
|
|
||
| try (InputStream serviceAccount = new ClassPathResource("keys/campus-firebase.json").getInputStream()) { | ||
|
|
||
| FirebaseOptions options = FirebaseOptions.builder() | ||
| .setCredentials(GoogleCredentials.fromStream(serviceAccount)) | ||
| .build(); | ||
| FirebaseApp.initializeApp(options); | ||
coderabbitai[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| } catch (IOException e) { | ||
| throw new FirebaseInitializationFailedException(); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| package com.campus.campus.global.firebase.exception; | ||
|
|
||
| import org.springframework.http.HttpStatus; | ||
|
|
||
| import com.campus.campus.global.common.exception.ErrorCodeInterface; | ||
|
|
||
| import lombok.Getter; | ||
| import lombok.RequiredArgsConstructor; | ||
|
|
||
| @Getter | ||
| @RequiredArgsConstructor | ||
| public enum ErrorCode implements ErrorCodeInterface { | ||
|
|
||
| FIREBASE_INITIALIZATION_FAILED(6201, HttpStatus.INTERNAL_SERVER_ERROR, "Firebase 초기화에 실패했습니다."), | ||
| FCM_TOPIC_SEND_FAILED(6202, HttpStatus.INTERNAL_SERVER_ERROR, "푸시 알림 전송에 실패했습니다."); | ||
|
|
||
| private final int code; | ||
| private final HttpStatus status; | ||
| private final String message; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| package com.campus.campus.global.firebase.exception; | ||
|
|
||
| public class FcmTopicSendFailedException extends RuntimeException { | ||
| public FcmTopicSendFailedException(Throwable cause) { | ||
| super(ErrorCode.FCM_TOPIC_SEND_FAILED.getMessage(), cause); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| package com.campus.campus.global.firebase.exception; | ||
|
|
||
| public class FirebaseInitializationFailedException extends RuntimeException { | ||
| public FirebaseInitializationFailedException() { | ||
| super(ErrorCode.FIREBASE_INITIALIZATION_FAILED.getMessage()); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: our-campUS/our-campUS-BE
Length of output: 148
🏁 Script executed:
Repository: our-campUS/our-campUS-BE
Length of output: 145
🏁 Script executed:
Repository: our-campUS/our-campUS-BE
Length of output: 50380
🏁 Script executed:
Repository: our-campUS/our-campUS-BE
Length of output: 1615
🏁 Script executed:
Repository: our-campUS/our-campUS-BE
Length of output: 4230
🏁 Script executed:
Repository: our-campUS/our-campUS-BE
Length of output: 266
🏁 Script executed:
Repository: our-campUS/our-campUS-BE
Length of output: 1682
🏁 Script executed:
Repository: our-campUS/our-campUS-BE
Length of output: 273
필드의 null 안전성 검증 필요 (college, major의 경우)
StudentCouncil의
school필드는 데이터베이스 레벨에서nullable = false제약이 있어 SCHOOL_COUNCIL의 topic() 메서드는 즉시적인 NPE 위험이 낮습니다. 하지만college와major필드는 nullable이 기본값이므로 COLLEGE_COUNCIL과 MAJOR_COUNCIL의 topic() 메서드는 null 체크 없이 이들 필드에 접근하고 있습니다.또한 hasAccess() 메서드는
user의 필드만 null 검증하고writer의 필드는 검증하지 않으므로, 방어적 프로그래밍 관점에서 topic() 메서드에서도 일관되게 null 체크를 수행하는 것이 좋습니다. 특히 lazy-loading 환경에서 예상치 못한 상황이 발생할 수 있습니다.모든 council type의 topic() 메서드에 명시적인 null 체크를 추가하여 일관성과 안정성을 확보하기를 권장합니다.
🤖 Prompt for AI Agents