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..8c596cf 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,7 @@ package com.daedan.festabook.festival.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; import com.daedan.festabook.device.domain.Device; import com.daedan.festabook.device.domain.DeviceFixture; @@ -11,12 +12,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 +48,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); + + assertAll( + () -> assertThat(notificationCount).isEqualTo(1), + () -> assertThat(successCount).isEqualTo(1), + () -> 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..020a854 --- /dev/null +++ b/src/test/java/com/daedan/festabook/support/ConcurrencyTestHelper.java @@ -0,0 +1,60 @@ +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 e) { + log.error("동시성 테스트 실행 중 예외 발생", e); + result.recordError(e); + } 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..3dd5be7 --- /dev/null +++ b/src/test/java/com/daedan/festabook/support/ConcurrencyTestResult.java @@ -0,0 +1,34 @@ +package com.daedan.festabook.support; + +import java.util.Map; +import java.util.Queue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +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(); + private final AtomicLong errorCount = new AtomicLong(); + private final Queue errorCauses = new ConcurrentLinkedQueue<>(); + + public void recordStatusCode(HttpStatus status) { + statusCodeCounts.merge(status, 1, Integer::sum); + requestCount.incrementAndGet(); + } + + public void recordError(Throwable e) { + errorCount.incrementAndGet(); + errorCauses.add(e); + } + + 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