Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Response> 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());
Expand All @@ -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<Response> createAnnouncementRequest = () -> RestAssured
.given()
.header(authorizationHeader)
.contentType(ContentType.JSON)
.body(createAnnouncement)
.when()
.post("/announcements");

Callable<Response> updateAnnouncementRequest = () -> RestAssured
.given()
.header(authorizationHeader)
.contentType(ContentType.JSON)
.body(updateAnnouncement)
.when()
.post("announcement/{announcementId}/pin", initAnnouncement.getId());

int expectedPinnedAnnouncementCount = 3;

// when
ConcurrencyTestHelper.test(requestCount, createAnnouncementRequest, updateAnnouncementRequest);
concurrencyTestHelper.execute(createAnnouncementRequest, updateAnnouncementRequest);

// then
Long result = announcementJpaRepository.countByFestivalIdAndIsPinnedTrue(festival.getId());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -47,34 +47,24 @@ 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<Response> 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(notificationCount).isEqualTo(1);

assertThat(duplicateErrorCount.get()).isEqualTo(99);
assertThat(result.getSuccessCount()).isEqualTo(1);
assertThat(result.getStatusCodeCount(HttpStatus.CONFLICT))
.isEqualTo(result.getRequestCount() - result.getSuccessCount());
}
}
}
Original file line number Diff line number Diff line change
@@ -1,46 +1,60 @@
package com.daedan.festabook.global.lock;

import com.daedan.festabook.support.ConcurrencyTestResult;
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 {

private ConcurrencyTestHelper() {
}
@Value("${server.tomcat.threads.max}")
private int tomcatThreadCount;

public static void test(int requestCount, Runnable... requests) {
@SafeVarargs
public final ConcurrencyTestResult execute(Callable<Response>... requests) {
validateRequests(requests);
ConcurrencyTestResult result = new ConcurrencyTestResult();

try (ExecutorService threadPool = Executors.newFixedThreadPool(requestCount)) {
try (ExecutorService executorService = Executors.newFixedThreadPool(tomcatThreadCount)) {
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch endLatch = new CountDownLatch(requestCount);
CountDownLatch endLatch = new CountDownLatch(tomcatThreadCount);

for (int i = 0; i < requestCount; i++) {
for (int i = 0; i < tomcatThreadCount; i++) {
int currentCount = i % requests.length;
threadPool.submit(() -> {
executorService.submit(() -> {
try {
startLatch.await();
requests[currentCount].run();
} catch (InterruptedException ignore) {
Response response = requests[currentCount].call();

HttpStatus httpStatus = HttpStatus.valueOf(response.getStatusCode());
result.recordStatusCode(httpStatus);
} catch (Exception e) {
log.error("동시성 테스트 실행 중 예외 발생", e);
} finally {
endLatch.countDown();
}
});
}

startLatch.countDown();

try {
endLatch.await();
} catch (InterruptedException ignore) {
}
endLatch.await();
} catch (InterruptedException ignore) {
}
return result;
}

private static void validateRequests(Runnable[] requests) {
if (requests.length == 0) {
throw new IllegalArgumentException("실행할 api 인자는 최소 1개 이상이어야 합니다.");
@SafeVarargs
private void validateRequests(Callable<Response>... requests) {
if (requests == null || requests.length == 0) {
throw new IllegalArgumentException("실행할 API 인자는 최소 1개 이상이어야 합니다.");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,27 @@
import com.daedan.festabook.festival.domain.FestivalNotificationManager;
import com.daedan.festabook.global.config.TestSecurityConfig;
import com.daedan.festabook.global.infrastructure.ShuffleManager;
import com.daedan.festabook.global.lock.ConcurrencyTestHelper;
import io.restassured.RestAssured;
import java.time.Clock;
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;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
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<HttpStatus, Integer> 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 int getSuccessCount() {
return statusCodeCounts.entrySet().stream()
.filter(entry -> entry.getKey().is2xxSuccessful())
.mapToInt(Map.Entry::getValue)
.sum();
}
Copy link
Contributor

Choose a reason for hiding this comment

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

위에 StatusCodeCount를 강제하는게 어때요?

  • 성공하는 경우가, 200번대는 맞겠지만 실제로 200번대 응답이 뒤섞였을 가능성이 여기서는 알 수 없을 것 같아요

예를 들어 성공 예상은 200응답 11개지만, 200 응답 10개, 201응답 1개가 되었을 때 해당 메서드를 사용하면 모를 것 같아요!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

부기 말대로 200번대는 맞지만, 오해의 소지가 있을 수 있다고 생각이 들어요.

그렇다면, 1차적으로는 삭제했지만, 또 다른 생각 방향으로는 is2xxSuccessful 네이밍으로 변경하는 건 어떤가요?

현재는 해당 메서드 삭제했습니다.


public long getRequestCount() {
return requestCount.get();
}
}
5 changes: 5 additions & 0 deletions src/test/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,8 @@ secret:
fcm:
topic:
festival-prefix: example-festival

server:
tomcat:
threads:
max: 15