diff --git a/.coderabbit.yaml b/.coderabbit.yaml
index fc3d7d8..a38d5a4 100644
--- a/.coderabbit.yaml
+++ b/.coderabbit.yaml
@@ -5,68 +5,58 @@ tone_instructions: "1. 리뷰 시에는 변경 사항의 문제점이나 한계
# ─────────── 리뷰(Reviews) 전반 ───────────
reviews:
profile: chill
- high_level_summary: true
- high_level_summary_placeholder: "🤖 Code Rabbit PR 요약"
- review_status: true
- commit_status: true
# 워크스루/자동화/부가 기능
- collapse_walkthrough: false
changed_files_summary: false
sequence_diagrams: false
assess_linked_issues: true
- related_issues: false
- related_prs: false
suggested_labels: false
- auto_apply_labels: false
suggested_reviewers: false
- auto_assign_reviewers: false
poem: false
- # 경로별 리뷰 지침 및 제외 폴더
+ # 리뷰 지침
path_instructions:
- - path: android/**
+ - path: src/**
instructions: |
- - 1. 코틀린 공식 스타일 가이드 및 팀 컨벤션을 우선적으로 반영하여, 가독성, 안전성(Null/예외처리), 테스트/유지보수 용이성, 안드로이드 특화 사항(라이프사이클, 리소스, 권한 등)에 대해 리뷰해주세요.
- - 2. 최신 코틀린/안드로이드 트렌드, 주석 및 문서화, 팀 스타일 통일성도 함께 확인해 주세요.
- - 3. 각 리뷰 포인트별로 문제점과 대안, 장단점을 논리적으로 제시하고, 필요한 경우 예시 코드도 추가해 주세요.
- - 4. 리뷰가 너무 많아서 피로감을 줄 수 있으니, 꼭 필요한 부분에 집중해주고, 나머지는 캡션으로 설명해주세요.
- - 5. 리뷰 남겨주는 부분은 해당 라인 범위의 코멘트에 작성해주세요.
- - path: backend/**
- instructions: |
- - 1. 팀 및 공식 컨벤션, 가독성, 예외처리, 테스트/확장/유지보수성, 모듈화, API/DB/보안 설계 기준을 기반으로 리뷰해주세요.
- - 2. 최신 트렌드, 불필요한 로직, 클린코드, 리팩토링, 서비스/도메인 설계, 공통 예외 처리, 확장성도 함께 확인해주세요.
- - 3. 각 피드백은 문제점·대안·장단점을 짧고 논리적으로, 예시 코드가 있다면 간결히 포함해 주세요.
- - 4. 팀 내 스타일 통일성도 확인해주세요.
- - 5. 미작성한 테스트 코드 케이스가 있다면, 어떤 테스트가 필요한지 제안해주세요. (예: 컨트롤러는 인수 테스트, 나머지는 단위 테스트)
- - 6. 리뷰 남겨주는 부분은 해당 라인 범위의 코멘트에 작성해주세요.
- - path: frontend/**
- instructions: |
- - 우리는 백엔드 개발자 팀으로, 관리자 페이지 프론트엔드를 Vibe 코딩 방식으로 빠르게 구현했습니다.
- - React에 대한 전문적인 이해도가 부족한 상태이므로, 다음과 같은 기준으로 리뷰해 주세요:
- - 1. 코드 스타일이나 컴포넌트 구조 등 전반적인 구조에 대한 일반적인 피드백은 생략해 주세요.
- - 2. 보안상 취약점이 될 수 있는 부분 (예: XSS, CSRF, 사용자 입력 검증 부족 등) 은 반드시 알려주세요.
- - 3. 화면 상 명백하게 어색하거나 비정상적으로 동작할 수 있는 UI/UX 요소만 지적해 주세요.
- - 4. 빠른 배포를 목적으로 하기 때문에, 논리상 큰 이상이 없는 부분은 코멘트하지 않으셔도 됩니다.
- - 5. 실제 사용자에게 혼동을 줄 수 있는 부분(버튼 비노출, 접근 불가능 등)이 있다면 꼭 알려주세요.
- - 6. 해당 PR에는 테스트 코드가 포함되지 않았으며, 테스트 커버리지나 테스트 방식에 대한 피드백은 생략해 주세요.
- - 위 기준을 바탕으로 꼭 필요한 피드백 위주로 리뷰 부탁드립니다.
-
- # 리뷰 진행/캐시/자동화
- abort_on_close: true
- disable_cache: false
+ 당신은 팀의 기술적 의사결정을 돕는 '프린시펄 엔지니어(Principal Engineer)'입니다.
+ 단순한 코드 교정이 아닌, '시스템의 안정성'과 '설계의 방향성'을 제시하여 개발자가 스스로 최적의 판단(Trade-off)을 내릴 수 있도록 돕는 것이 목표입니다.
+
+ 1. [Critical: 시스템 안정성 및 성능 심화] (OOM, 동시성, 리소스)
+ - 메모리 및 리소스 누수:
+ - `Stream.collect()`로 대량의 데이터를 힙에 로드하거나, `static` 컬렉션에 데이터가 무한히 쌓이는 패턴(OOM 위험)을 찾아내세요.
+ - `try-with-resources` 미사용, `ThreadLocal` 미정리 등 리소스 누수 가능성을 차단하세요.
+ - 동시성(Concurrency) 및 락:
+ - 공유 자원에 대한 'Check-Then-Act' 레이스 컨디션을 찾아내고, `Atomic` 변수나 `ConcurrentHashMap` 활용을 제안하세요.
+ - Java 21 Virtual Thread 환경에서 장시간 블로킹되는 I/O 작업이 `synchronized` 블록 내에 있을 경우 스레드 피닝(Pinning)이 발생할 수 있으니, 이러한 경우에는 `ReentrantLock` 사용을 고려하도록 안내하세요.
+ - 실패 격리 및 트랜잭션:
+ - 외부 API 호출(Network I/O)이 `@Transactional` 범위 내에 있어 DB 커넥션을 오래 점유하는지 확인하고, 분리를 제안하세요.
+ - 무분별한 재시도(Retry)로 인한 트래픽 폭주(Retry Storm) 가능성을 경고하세요.
+
+ 2. [Architecture: 최신 설계 트렌드 및 방향성 고민]
+ - Modern Java & 가독성:
+ - 단순히 최신 문법(Record, Pattern Matching, Switch Expression)을 쓰는 것을 넘어, 그것이 '불변성(Immutability)'과 '명확한 의도 전달'에 기여하는지 확인하세요.
+ - 복잡한 상속보다는 조합(Composition)을, 명령형 로직보다는 선언형(Stream/Functional) 스타일을 권장하되, 과도한 체이닝으로 디버깅이 어려워질 경우엔 가독성을 우선하세요.
+ - 설계적 사고(Design Thinking):
+ - 도메인 로직이 서비스 계층에만 비대하게 몰리는 'Transaction Script' 패턴을 경계하고, 도메인 객체 자체가 로직을 수행하는 '풍부한 도메인 모델(Rich Domain Model)' 방향을 제시해 주세요.
+ - "이 로직이 과연 이 클래스의 책임인가?"를 질문하여 SRP(단일 책임 원칙)와 응집도에 대해 고민하게 만드세요.
+
+ 3. [Test Strategy: 견고함 검증]
+ - Controller: `RestAssured` + `RANDOM_PORT`를 사용한 인수 테스트(E2E)여야 하며, `MockMvc` 사용은 지양합니다.
+ - Service/Domain: 외부 의존성을 배제한 순수 단위 테스트를 지향하세요.
+ - 단순 커버리지보다는 동시성 테스트, 엣지 케이스(Null, 경계값), 예외 발생 시나리오가 포함되었는지 확인하세요.
+
+ 4. [Feedback Style: 트레이드 오프와 의사결정 유도]
+ - 지적보다는 제안: "이건 틀렸습니다" 대신 "이 방식은 A라는 장점이 있지만 B라는 리스크가 있습니다"라고 설명해 주세요.
+ - 선택지 제공:
+ - 옵션 A: 성능이 최우선일 때의 접근법 (예: 캐싱 도입, 복잡도 증가)
+ - 옵션 B: 유지보수성과 가독성이 최우선일 때의 접근법 (예: 구조 단순화)
+ - 위와 같이 선택지를 주고 개발자가 상황에 맞춰 결정하도록 유도하세요.
+
+ - 리뷰 피로도를 낮추기 위해 핵심적인 문제(Critical/Major)를 선별해서 코멘트 달아주세요.
+ - 모든 피드백은 정중한 한국어로 작성해 주세요.
auto_review:
- enabled: true
- auto_incremental_review: true
- base_branches: [ "android", "backend", "frontend" ]
-
- finishing_touches:
- docstrings:
- enabled: true
- unit_tests:
- enabled: true
-
+ base_branches: [ ".*" ]
# ─────────── 채팅(Chat) 설정 ───────────
chat:
auto_reply: true
@@ -75,53 +65,38 @@ chat:
knowledge_base:
opt_out: false
- web_search:
- enabled: true
-
code_guidelines:
enabled: true
filePatterns:
- - backend/code-style.md
- - android/code-style.md
-
- learnings:
- scope: auto
- issues:
- scope: local
- pull_requests:
- scope: local
+ - code-style.md
# ─────────── 코드 생성(Code generation) ───────────
code_generation:
docstrings:
language: ko-KR
path_instructions:
- - path: backend/**
- instructions: |
- - JavaDoc 공식 형식으로, 한글로 Docstring을 작성해주세요.
- - 메서드 목적, 파라미터, 반환값, 예외 정보를 명확하게 기술해 주세요.
- - 외부 API 등 공개 메서드는 상세히, 내부용은 핵심만 요약해 주세요.
-
- - path: android/**
+ - path: src/main/**
instructions: |
- - 모든 public 함수에 대해 KDoc 양식을 따라 한글로 간결하게 Docstring을 작성해주세요.
- - 함수 목적, 파라미터, 반환값, 예외를 명확하게 기술해 주세요.
- - 샘플 코드/사용 예시는 필요한 경우에만 포함해 주세요.
+ - 기본 원칙: JavaDoc 표준 형식을 따르며, 설명은 '한글'을 사용합니다.
+ - 제외 대상: 단순 Getter/Setter, 생성자, 그리고 `@Override` 메서드에는 Docstring을 달지 마세요.
+ - 문체: '...함', '...임' 등의 명사형 종결어미 대신, 서술형 문장('...을 반환합니다.')을 사용하되, 주어('이 메서드는')는 생략하고 동사로 바로 시작하세요. (예: "사용자 ID를 기반으로 정보를 조회합니다.")
+ - 내용의 깊이:
+ - 단순히 메서드 이름을 번역하지 말고, '비즈니스 로직의 의도'와 '왜(Why)'를 설명하는 데 집중하세요.
+ - 복잡한 로직이 포함된 경우 `
` 태그로 문단을 나누고, `
`을 사용하여 처리 과정을 나열하세요.
unit_tests:
path_instructions:
- - path: backend/**
+ - path: src/**
instructions: |
- - Controller는 인수테스트(API 엔드포인트 통합 테스트) 나머지 영역은 함수/클래스 단위의 단위 테스트
- - Given-When-Then 패턴을 적용
-
-# ─────────── 코드 분석 도구(Tools) ───────────
-tools:
- hadolint:
- enabled: true
- gitleaks:
- enabled: true
- sqlfluff:
- enabled: true
- oxc:
- enabled: true
+ - 기본 프레임워크: JUnit 5와 AssertJ를 사용하세요.
+ - 명명 규칙:
+ - @DisplayName은 절대 사용하지 마세요.
+ - 메서드 이름은 반드시 한글로 작성하세요.
+ - 메서드 이름 맨 앞에는 테스트 성격을 나타내는 접두어(성공, 예외, 동시성 등)를 반드시 붙이세요. (예: void 성공_회원가입(), void 예외_입력값_누락())
+ - 코드 구조:
+ - // given, // when, // then 주석 섹션을 사용하여 BDD 패턴을 유지하세요.
+ - 예외 검증이나 간단한 로직은 // when & then 으로 합쳐서 작성해도 됩니다.
+ - Controller 계층 전략:
+ - MockMvc는 사용하지 마세요. 실제 포트를 열고 API를 호출하는 RestAssured 테스트 방식으로 작성해 주세요.
+ - Service/Domain 계층 전략:
+ - @ExtendWith(MockitoExtension.class)를 사용한 단위 테스트로 작성하세요.
diff --git a/infra/buildspec.yml b/infra/buildspec.yml
index b9c88f8..3bcb5ab 100644
--- a/infra/buildspec.yml
+++ b/infra/buildspec.yml
@@ -15,6 +15,7 @@ phases:
- echo "⚙️ [Pre Build Phase] 환경파일 생성 시작"
- mkdir -p src/main/resources/firebase
- echo "$SECRET_YML" | base64 -d > src/main/resources/application-secret.yml
+ - echo "$SERVER_YML" | base64 -d > src/main/resources/application-server.yml
- echo "$FIREBASE_ADMINSDK_ACCOUNT_KEY" > src/main/resources/firebase/firebase-adminsdk-account.json
- echo "✅ [Pre Build Phase] 완료"
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index ae88145..f20f2fb 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -18,7 +18,7 @@ spring:
hibernate:
ddl-auto: none
profiles:
- include: secret, monitoring
+ include: secret, monitoring, server
lifecycle:
# WAS 종료 대기 시간 15초로 설정
timeout-per-shutdown-phase: 15s
diff --git a/src/test/java/com/daedan/festabook/announcement/controller/AnnouncementControllerTest.java b/src/test/java/com/daedan/festabook/announcement/controller/AnnouncementControllerTest.java
index 071e0a9..dd409db 100644
--- a/src/test/java/com/daedan/festabook/announcement/controller/AnnouncementControllerTest.java
+++ b/src/test/java/com/daedan/festabook/announcement/controller/AnnouncementControllerTest.java
@@ -23,14 +23,15 @@
import com.daedan.festabook.festival.domain.Festival;
import com.daedan.festabook.festival.domain.FestivalFixture;
import com.daedan.festabook.festival.infrastructure.FestivalJpaRepository;
-import com.daedan.festabook.global.lock.ConcurrencyTestHelper;
import com.daedan.festabook.global.security.JwtTestHelper;
import com.daedan.festabook.global.security.role.RoleType;
import com.daedan.festabook.support.AcceptanceTestSupport;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import io.restassured.http.Header;
+import io.restassured.response.Response;
import java.util.List;
+import java.util.concurrent.Callable;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
@@ -453,21 +454,18 @@ class ConcurrentTest {
AnnouncementRequest request = AnnouncementRequestFixture.create(true);
- int requestCount = 100;
- Runnable httpRequest = () -> {
- RestAssured
- .given()
- .header(authorizationHeader)
- .contentType(ContentType.JSON)
- .body(request)
- .when()
- .post("/announcements");
- };
+ Callable httpRequest = () -> RestAssured
+ .given()
+ .header(authorizationHeader)
+ .contentType(ContentType.JSON)
+ .body(request)
+ .when()
+ .post("/announcements");
int expectedPinnedAnnouncementCount = 3;
// when
- ConcurrencyTestHelper.test(requestCount, httpRequest);
+ concurrencyTestHelper.execute(httpRequest);
// then
Long result = announcementJpaRepository.countByFestivalIdAndIsPinnedTrue(festival.getId());
@@ -488,31 +486,26 @@ class ConcurrentTest {
AnnouncementRequest createAnnouncement = AnnouncementRequestFixture.create(true);
AnnouncementPinUpdateRequest updateAnnouncement = AnnouncementPinUpdateRequestFixture.create(true);
- int requestCount = 100;
- Runnable createAnnouncementRequest = () -> {
- RestAssured
- .given()
- .header(authorizationHeader)
- .contentType(ContentType.JSON)
- .body(createAnnouncement)
- .when()
- .post("/announcements");
- };
-
- Runnable updateAnnouncementRequest = () -> {
- RestAssured
- .given()
- .header(authorizationHeader)
- .contentType(ContentType.JSON)
- .body(updateAnnouncement)
- .when()
- .post("announcement/{announcementId}/pin", initAnnouncement.getId());
- };
+ Callable createAnnouncementRequest = () -> RestAssured
+ .given()
+ .header(authorizationHeader)
+ .contentType(ContentType.JSON)
+ .body(createAnnouncement)
+ .when()
+ .post("/announcements");
+
+ Callable updateAnnouncementRequest = () -> RestAssured
+ .given()
+ .header(authorizationHeader)
+ .contentType(ContentType.JSON)
+ .body(updateAnnouncement)
+ .when()
+ .patch("/announcements/{announcementId}/pin", initAnnouncement.getId());
int expectedPinnedAnnouncementCount = 3;
// when
- ConcurrencyTestHelper.test(requestCount, createAnnouncementRequest, updateAnnouncementRequest);
+ concurrencyTestHelper.execute(createAnnouncementRequest, updateAnnouncementRequest);
// then
Long result = announcementJpaRepository.countByFestivalIdAndIsPinnedTrue(festival.getId());
diff --git a/src/test/java/com/daedan/festabook/festival/service/FestivalNotificationConcurrencyTest.java b/src/test/java/com/daedan/festabook/festival/service/FestivalNotificationConcurrencyTest.java
index 668a1d7..d72d94c 100644
--- a/src/test/java/com/daedan/festabook/festival/service/FestivalNotificationConcurrencyTest.java
+++ b/src/test/java/com/daedan/festabook/festival/service/FestivalNotificationConcurrencyTest.java
@@ -1,6 +1,6 @@
package com.daedan.festabook.festival.service;
-import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.SoftAssertions.assertSoftly;
import com.daedan.festabook.device.domain.Device;
import com.daedan.festabook.device.domain.DeviceFixture;
@@ -11,12 +11,12 @@
import com.daedan.festabook.festival.dto.FestivalNotificationRequestFixture;
import com.daedan.festabook.festival.infrastructure.FestivalJpaRepository;
import com.daedan.festabook.festival.infrastructure.FestivalNotificationJpaRepository;
-import com.daedan.festabook.global.lock.ConcurrencyTestHelper;
import com.daedan.festabook.support.AcceptanceTestSupport;
+import com.daedan.festabook.support.ConcurrencyTestResult;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import io.restassured.response.Response;
-import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.Callable;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -47,34 +47,28 @@ class subscribeFestivalNotification {
FestivalNotificationRequest request = FestivalNotificationRequestFixture.create(device.getId());
- int requestCount = 100;
- AtomicInteger duplicateErrorCount = new AtomicInteger(0);
-
- Runnable httpRequest = () -> {
- Response response = RestAssured
- .given()
- .contentType(ContentType.JSON)
- .body(request)
- .when()
- .post("/festivals/{festivalId}/notifications", festival.getId());
-
- if (response.getStatusCode() == HttpStatus.CONFLICT.value()) {
- String responseBody = response.getBody().asString();
- if (responseBody.contains("FestivalNotification 데이터베이스에 이미 존재합니다.")) {
- duplicateErrorCount.incrementAndGet();
- }
- }
- };
+ Callable httpRequest = () -> RestAssured
+ .given()
+ .contentType(ContentType.JSON)
+ .body(request)
+ .when()
+ .post("/festivals/{festivalId}/notifications", festival.getId());
// when
- ConcurrencyTestHelper.test(requestCount, httpRequest);
+ ConcurrencyTestResult result = concurrencyTestHelper.execute(httpRequest);
// then
- Long result = festivalNotificationJpaRepository.countByFestivalIdAndDeviceId(
+ Long notificationCount = festivalNotificationJpaRepository.countByFestivalIdAndDeviceId(
festival.getId(), device.getId());
- assertThat(result).isEqualTo(1);
- assertThat(duplicateErrorCount.get()).isEqualTo(99);
+ int successCount = result.getStatusCodeCount(HttpStatus.CREATED);
+
+ assertSoftly(s -> {
+ s.assertThat(notificationCount).isEqualTo(1);
+ s.assertThat(successCount).isEqualTo(1);
+ s.assertThat(result.getStatusCodeCount(HttpStatus.CONFLICT))
+ .isEqualTo(result.getRequestCount() - successCount);
+ });
}
}
}
diff --git a/src/test/java/com/daedan/festabook/global/lock/ConcurrencyTestHelper.java b/src/test/java/com/daedan/festabook/global/lock/ConcurrencyTestHelper.java
deleted file mode 100644
index bb752ee..0000000
--- a/src/test/java/com/daedan/festabook/global/lock/ConcurrencyTestHelper.java
+++ /dev/null
@@ -1,46 +0,0 @@
-package com.daedan.festabook.global.lock;
-
-import java.util.concurrent.CountDownLatch;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-
-public class ConcurrencyTestHelper {
-
- private ConcurrencyTestHelper() {
- }
-
- public static void test(int requestCount, Runnable... requests) {
- validateRequests(requests);
-
- try (ExecutorService threadPool = Executors.newFixedThreadPool(requestCount)) {
- CountDownLatch startLatch = new CountDownLatch(1);
- CountDownLatch endLatch = new CountDownLatch(requestCount);
-
- for (int i = 0; i < requestCount; i++) {
- int currentCount = i % requests.length;
- threadPool.submit(() -> {
- try {
- startLatch.await();
- requests[currentCount].run();
- } catch (InterruptedException ignore) {
- } finally {
- endLatch.countDown();
- }
- });
- }
-
- startLatch.countDown();
-
- try {
- endLatch.await();
- } catch (InterruptedException ignore) {
- }
- }
- }
-
- private static void validateRequests(Runnable[] requests) {
- if (requests.length == 0) {
- throw new IllegalArgumentException("실행할 api 인자는 최소 1개 이상이어야 합니다.");
- }
- }
-}
diff --git a/src/test/java/com/daedan/festabook/support/AcceptanceTestSupport.java b/src/test/java/com/daedan/festabook/support/AcceptanceTestSupport.java
index 6a7fc8c..3ec7456 100644
--- a/src/test/java/com/daedan/festabook/support/AcceptanceTestSupport.java
+++ b/src/test/java/com/daedan/festabook/support/AcceptanceTestSupport.java
@@ -8,17 +8,21 @@
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayNameGeneration;
import org.junit.jupiter.api.DisplayNameGenerator;
+import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
-@Import(TestSecurityConfig.class)
+@Import({TestSecurityConfig.class, ConcurrencyTestHelper.class})
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
public abstract class AcceptanceTestSupport {
+ @Autowired
+ protected ConcurrencyTestHelper concurrencyTestHelper;
+
@MockitoBean
protected Clock clock;
diff --git a/src/test/java/com/daedan/festabook/support/ConcurrencyTestHelper.java b/src/test/java/com/daedan/festabook/support/ConcurrencyTestHelper.java
new file mode 100644
index 0000000..4a71da3
--- /dev/null
+++ b/src/test/java/com/daedan/festabook/support/ConcurrencyTestHelper.java
@@ -0,0 +1,58 @@
+package com.daedan.festabook.support;
+
+import io.restassured.response.Response;
+import java.util.concurrent.Callable;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Component;
+
+@Slf4j
+@Component
+public class ConcurrencyTestHelper {
+
+ @Value("${server.tomcat.threads.max}")
+ private int tomcatThreadCount;
+
+ @SafeVarargs
+ public final ConcurrencyTestResult execute(Callable... requests) {
+ validateRequests(requests);
+ ConcurrencyTestResult result = new ConcurrencyTestResult();
+
+ try (ExecutorService executorService = Executors.newFixedThreadPool(tomcatThreadCount)) {
+ CountDownLatch startLatch = new CountDownLatch(1);
+ CountDownLatch endLatch = new CountDownLatch(tomcatThreadCount);
+
+ for (int i = 0; i < tomcatThreadCount; i++) {
+ int currentCount = i % requests.length;
+ executorService.submit(() -> {
+ try {
+ startLatch.await();
+ Response response = requests[currentCount].call();
+
+ HttpStatus httpStatus = HttpStatus.valueOf(response.getStatusCode());
+ result.recordStatusCode(httpStatus);
+ } catch (Exception ignore) {
+ } finally {
+ endLatch.countDown();
+ }
+ });
+ }
+
+ startLatch.countDown();
+ endLatch.await();
+ } catch (InterruptedException ignore) {
+ }
+ return result;
+ }
+
+ @SafeVarargs
+ private void validateRequests(Callable... requests) {
+ if (requests == null || requests.length == 0) {
+ throw new IllegalArgumentException("실행할 API 인자는 최소 1개 이상이어야 합니다.");
+ }
+ }
+}
diff --git a/src/test/java/com/daedan/festabook/support/ConcurrencyTestResult.java b/src/test/java/com/daedan/festabook/support/ConcurrencyTestResult.java
new file mode 100644
index 0000000..a39c008
--- /dev/null
+++ b/src/test/java/com/daedan/festabook/support/ConcurrencyTestResult.java
@@ -0,0 +1,25 @@
+package com.daedan.festabook.support;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicLong;
+import org.springframework.http.HttpStatus;
+
+public class ConcurrencyTestResult {
+
+ private final Map statusCodeCounts = new ConcurrentHashMap<>();
+ private final AtomicLong requestCount = new AtomicLong();
+
+ public void recordStatusCode(HttpStatus status) {
+ statusCodeCounts.merge(status, 1, Integer::sum);
+ requestCount.incrementAndGet();
+ }
+
+ public int getStatusCodeCount(HttpStatus status) {
+ return statusCodeCounts.getOrDefault(status, 0);
+ }
+
+ public long getRequestCount() {
+ return requestCount.get();
+ }
+}
diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml
index 14d8a13..fc3a580 100644
--- a/src/test/resources/application.yml
+++ b/src/test/resources/application.yml
@@ -31,3 +31,8 @@ secret:
fcm:
topic:
festival-prefix: example-festival
+
+server:
+ tomcat:
+ threads:
+ max: 15