Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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()
.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());
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,61 @@
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);
result.recordStatusCode(HttpStatus.INTERNAL_SERVER_ERROR);
} 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