Skip to content
Merged
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ dependencies {
//HTTP 클라이언트
implementation 'com.oracle.oci.sdk:oci-java-sdk-common-httpclient-jersey3'

// Firebase Admin SDK
implementation 'com.google.firebase:firebase-admin:9.7.0'

compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.mysql:mysql-connector-j'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Comment on lines +13 to +15
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Find StudentCouncil entity file
fd -t f "StudentCouncil.java" --type f

Repository: our-campUS/our-campUS-BE

Length of output: 148


🏁 Script executed:

# Find and examine CouncilType.java to get full context
fd -t f "CouncilType.java" --type f

Repository: our-campUS/our-campUS-BE

Length of output: 145


🏁 Script executed:

# Search for StudentCouncil class definition and its fields
rg -nP --type=java "class\s+StudentCouncil" -A 30

Repository: our-campUS/our-campUS-BE

Length of output: 50380


🏁 Script executed:

cat -n src/main/java/com/campus/campus/domain/council/domain/entity/CouncilType.java

Repository: our-campUS/our-campUS-BE

Length of output: 1615


🏁 Script executed:

# Search for hasAccess method to see how it handles null checks
rg -nP "hasAccess" --type=java -B2 -A5

Repository: our-campUS/our-campUS-BE

Length of output: 4230


🏁 Script executed:

# Check if @ManyToOne on school field has explicit optional parameter
rg -nP "@ManyToOne|@JoinColumn.*school" src/main/java/com/campus/campus/domain/council/domain/entity/StudentCouncil.java -A1

Repository: our-campUS/our-campUS-BE

Length of output: 266


🏁 Script executed:

# Find where topic() method is called
rg -nP "\.topic\(" --type=java -B2 -A2

Repository: our-campUS/our-campUS-BE

Length of output: 1682


🏁 Script executed:

# Check StudentCouncil entity to see if college and major have nullable constraints
rg -nP "@JoinColumn.*college|@JoinColumn.*major" src/main/java/com/campus/campus/domain/council/domain/entity/StudentCouncil.java -B1 -A1

Repository: our-campUS/our-campUS-BE

Length of output: 273


필드의 null 안전성 검증 필요 (college, major의 경우)

StudentCouncil의 school 필드는 데이터베이스 레벨에서 nullable = false 제약이 있어 SCHOOL_COUNCIL의 topic() 메서드는 즉시적인 NPE 위험이 낮습니다. 하지만 collegemajor 필드는 nullable이 기본값이므로 COLLEGE_COUNCIL과 MAJOR_COUNCIL의 topic() 메서드는 null 체크 없이 이들 필드에 접근하고 있습니다.

또한 hasAccess() 메서드는 user의 필드만 null 검증하고 writer의 필드는 검증하지 않으므로, 방어적 프로그래밍 관점에서 topic() 메서드에서도 일관되게 null 체크를 수행하는 것이 좋습니다. 특히 lazy-loading 환경에서 예상치 못한 상황이 발생할 수 있습니다.

모든 council type의 topic() 메서드에 명시적인 null 체크를 추가하여 일관성과 안정성을 확보하기를 권장합니다.

🤖 Prompt for AI Agents
In
@src/main/java/com/campus/campus/domain/council/domain/entity/CouncilType.java
around lines 13 - 15, The topic() implementations for COLLEGE_COUNCIL and
MAJOR_COUNCIL access StudentCouncil.getCollege() and getMajor() without null
checks; add explicit null-safety in each CouncilType.topic(StudentCouncil
writer) (and mirror the defensive style of hasAccess()) by validating writer and
the specific field (college/major) before using them — if null, either throw a
clear IllegalStateException/IllegalArgumentException with context (e.g.,
"College is null for council topic") or return a sensible fallback topic string;
ensure SCHOOL_COUNCIL/topic still assumes non-null school but optionally add an
assertion for consistency.

},
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
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Null 안전성 검증 필요

writer.getCollege()이 null일 경우 NPE가 발생할 수 있습니다. SCHOOL_COUNCIL과 동일한 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

‼️ 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.

Suggested change
@Override public String topic(StudentCouncil writer) {
return "college_" + writer.getCollege().getCollegeId();
}
@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();
}
🤖 Prompt for AI Agents
In
@src/main/java/com/campus/campus/domain/council/domain/entity/CouncilType.java
around lines 23 - 25, CouncilType.topic 메서드에서 writer.getCollege()가 null일 경우 NPE가
발생하므로 writer.getCollege()와 관련된 null 체크를 추가해 SCHOOL_COUNCIL과 동일한 방식으로 처리하세요; 즉
CouncilType.topic(…)에서 writer.getCollege()가 null이면 적절한 예외(예:
IllegalStateException/IllegalArgumentException) 또는 기본 토픽 문자열을 반환하도록 방어 코드를 넣고
로그/메시지에 writer 식별자와 함께 상태를 명시하도록 수정하세요.

},
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
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Null 안전성 검증 필요

writer.getMajor()가 null일 경우 NPE가 발생할 수 있습니다. SCHOOL_COUNCIL, COLLEGE_COUNCIL과 동일한 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

‼️ 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.

Suggested change
@Override public String topic(StudentCouncil writer) {
return "major_" + writer.getMajor().getMajorId();
}
@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();
}
🤖 Prompt for AI Agents
In
@src/main/java/com/campus/campus/domain/council/domain/entity/CouncilType.java
around lines 33 - 35, The topic method in CouncilType (topic(StudentCouncil
writer)) calls writer.getMajor().getMajorId() without null checks which can NPE;
update topic to first check for null writer.getMajor() (and null majorId) and
return a safe fallback (e.g., "major_unknown" or an empty topic) when null, and
apply the same null-safety fixes to the SCHOOL_COUNCIL and COLLEGE_COUNCIL
branches so none of them dereference getMajor() or getMajorId() without guards.

};

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) {

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()
)
);
}

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
Expand Up @@ -4,7 +4,6 @@
import java.time.ZoneId;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;

import org.springframework.dao.DataIntegrityViolationException;
Expand All @@ -18,9 +17,9 @@
import com.campus.campus.domain.council.domain.entity.CouncilType;
import com.campus.campus.domain.councilpost.application.dto.response.GetActivePartnershipListForUserResponse;
import com.campus.campus.domain.councilpost.application.dto.response.GetLikedPostResponse;
import com.campus.campus.domain.councilpost.application.dto.response.GetPostForUserResponse;
import com.campus.campus.domain.councilpost.application.dto.response.LikePostResponse;
import com.campus.campus.domain.councilpost.application.dto.response.PostListItemResponse;
import com.campus.campus.domain.councilpost.application.dto.response.GetPostForUserResponse;
import com.campus.campus.domain.councilpost.application.exception.CollegeNotSetException;
import com.campus.campus.domain.councilpost.application.exception.MajorNotSetException;
import com.campus.campus.domain.councilpost.application.exception.PostNotFoundException;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import java.util.ArrayList;
import java.util.List;

import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
Expand All @@ -14,11 +15,12 @@
import com.campus.campus.domain.council.application.exception.StudentCouncilNotFoundException;
import com.campus.campus.domain.council.domain.entity.StudentCouncil;
import com.campus.campus.domain.council.domain.repository.StudentCouncilRepository;
import com.campus.campus.domain.councilpost.application.dto.request.CouncilPostCreatedEvent;
import com.campus.campus.domain.councilpost.application.dto.request.PostRequest;
import com.campus.campus.domain.councilpost.application.dto.response.GetPostListForCouncilResponse;
import com.campus.campus.domain.councilpost.application.dto.response.GetPostResponse;
import com.campus.campus.domain.councilpost.application.dto.response.GetUpcomingEventListForCouncilResponse;
import com.campus.campus.domain.councilpost.application.dto.response.NormalizedDateTime;
import com.campus.campus.domain.councilpost.application.dto.request.PostRequest;
import com.campus.campus.domain.councilpost.application.exception.NotPostWriterException;
import com.campus.campus.domain.councilpost.application.exception.PostImageLimitExceededException;
import com.campus.campus.domain.councilpost.application.exception.PostNotFoundException;
Expand All @@ -45,6 +47,7 @@ public class StudentCouncilPostService {
private final PostImageRepository postImageRepository;
private final PresignedUrlService presignedUrlService;
private final StudentCouncilPostMapper studentCouncilPostMapper;
private final ApplicationEventPublisher eventPublisher;

private static final int MAX_IMAGE_COUNT = 10;
private static final long UPCOMING_EVENT_WINDOW_HOURS = 72L;
Expand All @@ -69,14 +72,24 @@ public GetPostResponse create(Long councilId, PostRequest dto) {
writer, dto, normalized.startDateTime(), normalized.endDateTime()
);

postRepository.save(post);
StudentCouncilPost saved = postRepository.save(post);

if (dto.imageUrls() != null) {
for (String imageUrl : dto.imageUrls()) {
postImageRepository.save(studentCouncilPostMapper.createPostImage(post, imageUrl));
}
}

String topic = writer.getCouncilType().topic(writer);
String writerName = writer.getCouncilName();

eventPublisher.publishEvent(new CouncilPostCreatedEvent(
saved.getId(),
writerName,
saved.getCategory().name(),
topic
));

List<String> imageUrls = postImageRepository
.findAllByPostOrderByIdAsc(post)
.stream()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.campus.campus.domain.councilpost.domain.repository;

import java.util.List;
import java.util.Optional;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.campus.campus.domain.councilpost.presentation;

import java.util.Map;

import org.springframework.data.domain.Page;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
Expand All @@ -20,6 +22,7 @@
import com.campus.campus.domain.councilpost.domain.entity.PostCategory;
import com.campus.campus.global.annotation.CurrentCouncilId;
import com.campus.campus.global.common.response.CommonResponse;
import com.campus.campus.global.firebase.application.service.FirebaseCloudMessageService;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
Expand All @@ -37,7 +40,7 @@
public class StudentCouncilPostController {

private final StudentCouncilPostService postService;

private final FirebaseCloudMessageService firebaseCloudMessageService;
@PostMapping
@Operation(
summary = "학생회 제휴/행사 게시글 생성",
Expand Down Expand Up @@ -164,4 +167,15 @@ public CommonResponse<Page<GetUpcomingEventListForCouncilResponse>> getUpcomingE

return CommonResponse.success(StudentCouncilPostResponseCode.POST_LIST_READ_SUCCESS, response);
}


@PostMapping("/topic/{topic}")
public void sendToTopic(@PathVariable String topic) {
firebaseCloudMessageService.sendToTopic(
topic,
"푸시 테스트",
"서버에서 보낸 테스트 알림입니다.",
Map.of("type", "TEST")
);
}
}
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
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Spring Boot 3.5 Spring Framework 6 @EnableAsync default executor behavior and best practices

💡 Result:

Short summary

  • Default in Spring Boot 3.5 / Spring Framework 6: Boot auto-configures an AsyncTaskExecutor for @EnableAsync. If virtual threads are enabled (Java 21+ with spring.threads.virtual.enabled=true) the auto-configured executor is a SimpleAsyncTaskExecutor that creates virtual threads; otherwise Boot configures a ThreadPoolTaskExecutor with “sensible defaults.” The auto-configured executor is used for @EnableAsync unless you register your own Executor/AsyncTaskExecutor or AsyncConfigurer. [1][2][3]

What @EnableAsync looks up

  • Spring’s @EnableAsync will use, in order of precedence:
    1. an AsyncConfigurer (if you implement it and provide getAsyncExecutor()), or
    2. a single TaskExecutor/AsyncTaskExecutor bean in the context (or an Executor bean when Boot’s auto-config is active), or
    3. fall back to a SimpleAsyncTaskExecutor (framework default). [4][5]

Key Boot behavior notes

  • Boot’s TaskExecutionAutoConfiguration will back off if you define custom Executor beans. Boot also documents property-based configuration via spring.task.execution.* to tune the auto-configured ThreadPoolTaskExecutor (core/max/queue/keep-alive, name prefix). Web integrations (MVC/WebFlux/GraphQL/WebSocket, JPA bootstrap, etc.) may require an AsyncTaskExecutor bean named applicationTaskExecutor for all pieces to use it. [1][2][6]

Recommended best practices

  • Always explicitly configure an Executor for production instead of relying on the silent fallback:
    • Use a ThreadPoolTaskExecutor (or a java.util.concurrent.Executor adapted) with explicit corePoolSize, maxPoolSize, queueCapacity, keepAlive and thread-name-prefix. Bound the queue to avoid unbounded memory growth. [1][6]
    • Choose rejection policy appropriate for your app (CallerRunsPolicy, abort, etc.) and consider back-pressure.
    • Provide an AsyncUncaughtExceptionHandler (via AsyncConfigurer) to handle exceptions from void @async methods.
    • Name the bean appropriately (consider applicationTaskExecutor when you need MVC/WebFlux integration) and/or expose a single TaskExecutor bean so @EnableAsync picks it up.
    • Monitor threads, queue length, latency and adapt sizes; avoid very small pools for blocking workloads.
    • If running on Java 21+ and your workload is heavily blocking IO, evaluate virtual threads (spring.threads.virtual.enabled=true) — they can simplify sizing but test for your workload. [1][4][7][8]
    • Prefer non-blocking/reactive designs for highly concurrent services where appropriate.

Quick config examples

  • application.properties (use Boot’s properties to tune auto executor):
    • spring.task.execution.pool.max-size=16
    • spring.task.execution.pool.queue-capacity=100
    • spring.task.execution.pool.keep-alive=10s
    • spring.threads.virtual.enabled=true (only on Java 21+ to opt into virtual-thread SimpleAsyncTaskExecutor). [1][6][7]

References

  1. Spring Boot — Task Execution and Scheduling (auto-config behavior, properties, virtual threads). [Doc]. [4]
  2. Spring Boot Reference — TaskExecutionAutoConfiguration / defaults discussion. [Doc]. [3]
  3. Spring Boot docs (3.2/3.1 pages confirming ThreadPoolTaskExecutor vs SimpleAsyncTaskExecutor with virtual threads). [7][2]
  4. Spring Framework — @EnableAsync / AsyncConfigurer / SimpleAsyncTaskExecutor behavior. [9][8]
  5. Spring Framework scheduling & @async docs (executor selection and @async("beanName")). [10]

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:

Spring @Async default ThreadPoolExecutor configuration risks unbounded queue

💡 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):

  • Set a finite queue (spring.task.execution.pool.queue-capacity) and a sensible max-size (spring.task.execution.pool.max-size).
  • Or provide your own Executor bean/ThreadPoolTaskExecutor configured with bounded queue and rejection policy. [2][3]

Sources:

  • Spring Framework (ThreadPoolTaskExecutor defaults and queue behavior). [1]
  • Spring Framework task-execution docs (warning about unbounded queues/ OOM and interaction with maxPoolSize). [2]
  • Spring Boot task-execution auto-config (defaults: core-size=8 and how to configure queue-capacity/max-size). [3]

🌐 Web query:

Spring Boot AsyncConfigurer TaskExecutor recommended settings production

💡 Result:

Short recommended production settings & rules-of-thumb for Spring Boot AsyncConfigurer / ThreadPoolTaskExecutor:

  • Use a bounded queue (not Integer.MAX_VALUE). Configure spring.task.execution.pool.queue-capacity to a finite value (e.g. 50–500) so maxPoolSize is honored.
  • Size threads by workload type using the usual formula: threads ≈ CPU_cores * (1 + wait_time / service_time). For CPU-bound tasks set ≈ #cores; for IO-bound allow many more (example: cores * (1 + blockingCoefficient)). Start with theory then load-test/monitor. (engineering.zalando.com)
  • Example starter values (tune to your app & host):
    • corePoolSize: = #cores (or slightly higher for light IO).
    • maxPoolSize: = corePoolSize * 2–10 (or set to cores*(1+W/S) if known).
    • queueCapacity: 50–200 for web backends; smaller (0–10) for latency-sensitive pipelines to avoid large queuing.
    • keepAlive: 10–60s; consider allowCoreThreadTimeOut=true for elastic usage.
    • rejectedExecutionHandler: CallerRunsPolicy (simple back-pressure) or a custom handler that logs/metrics.
    • waitForTasksToCompleteOnShutdown=true and set an awaitTerminationPeriod. (docs.spring.io)
  • Observe these metrics in production and adjust: CPU, GC, thread counts, queue length, task latency and rejection rate. Use load tests to find sweet spot. (foojay.io)

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

‼️ 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.

Suggested change
@Configuration
@EnableAsync
public class AsyncConfig {
}
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 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);
}
}
🤖 Prompt for AI Agents
In @src/main/java/com/campus/campus/global/config/AsyncConfig.java around lines
6 - 9, The current AsyncConfig enabling @EnableAsync leaves Spring's default
executor with an effectively unbounded queue, risking OOM under load; replace
the empty config by defining a named ThreadPoolTaskExecutor bean with explicit
corePoolSize, maxPoolSize and queueCapacity (bounded queue) and register it as
the AsyncConfigurer/TaskExecutor for @Async, and also provide an
AsyncUncaughtExceptionHandler implementation to handle exceptions from void
@Async methods; update references to the executor bean name in any @Async
annotations if needed.

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
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

요청 DTO에 Validation(@NotNull/@notblank) 추가 + Schema 설명 정합성 맞추기 권장.
지금 상태면 API 계층에서 null/blank가 그대로 들어와 downstream(Firebase)에서 실패/예외가 날 수 있습니다. 또한 title 설명이 “메시지 발송인”으로 되어 있어 필드 의미와 어긋나 보입니다.

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
In
@src/main/java/com/campus/campus/global/firebase/application/dto/FcmMessageRequestDto.java
around lines 6 - 16, FcmMessageRequestDto currently lacks validation and has an
incorrect Schema for title; annotate the record components with validation
annotations (e.g., annotate userId with @NotNull, title and body with @NotBlank)
and update the @Schema descriptions to match each field's meaning (change
title's description from "메시지 발송인" to something like "메시지 제목", keep body as
message content, and document userId as required). Ensure you import the correct
validation package (jakarta.validation or javax.validation depending on project)
and confirm callers (controllers) validate the DTO (use @Valid) so invalid
requests are rejected before reaching Firebase.

}
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
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find usages of FirebaseCloudMessageService and sendToTopic
rg -n "FirebaseCloudMessageService|sendToTopic" --type java -A 3 -B 3

Repository: 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 2

Repository: our-campUS/our-campUS-BE

Length of output: 767


🏁 Script executed:

# Look for CouncilPostPushListener specifically
fd "CouncilPostPushListener" --type f

Repository: 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 2

Repository: our-campUS/our-campUS-BE

Length of output: 50


일시적 Firebase 오류에 대한 재시도 메커니즘을 추가하세요.

현재 상태:

  • CouncilPostPushListener는 이미 @Async로 비동기 처리됩니다.
  • 하지만 sendToTopic() 메서드 자체는 동기 메서드이며, 네트워크 오류나 Firebase 서비스 일시적 문제 시 즉시 실패합니다.
  • StudentCouncilPostController에서 직접 호출하는 경우 비동기 보호가 없습니다.

권장 사항:

  • Spring Retry 또는 유사한 재시도 메커니즘 적용
  • Firebase 오류 코드별 재시도 가능 여부 판단 (예: UNAVAILABLE, INTERNAL은 재시도 대상)
  • 최소 지수 백오프를 사용하여 일시적 장애에 대한 탄력성 향상

}
}
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);

} 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());
}
}
Loading