테스트에서 동시성 문제 상황 재현 (@SpringBootTest, ScheduledExecutorService)
개인 프로젝트 수행 중 동시성 문제가 발생했습니다.
이 문제를 해결하기 위한 과정으로 문제가 발생하는 상황을 테스트 코드로 재현하며 기록을 남깁니다.
개인 프로젝트에서 클라이언트 view
를 구성할 때 영속화된 데이터가 필요한 경우, 비동기 요청을 통해 서버에서 데이터를 가져옵니다.
서버 자원을 등록, 조회, 수정, 삭제 할 때 역시 비동기 요청을 사용하고 있습니다.
배경 및 문제 상황
문제를 인지한 상황은 댓글 좋아요 기능을 사용할 때입니다. 댓글 좋아요 기능은 다음의 과정으로 요청이 이뤄집니다.
- 브라우저에서 게시물 댓글에 좋아요, 좋아요 취소 버튼을 클릭 하면 좋아요 등록•삭제 대상을 자바스크립트 객체에 저장해두고,
- 페이지를 떠날 때, 이 객체에 저장된 좋아요 등록•삭제 대상의 식별자를
query string
에 담아HTTP POST • DELETE
요청을 서버에 보냅니다.
동시성 문제는 좋아요 등록•삭제 후, 브라우저 새로고침의 경우에 발생했습니다.
- 좋아요 기능 사용 후, 새로고침하면 좋아요 등록•삭제 요청이 먼저 서버로 향합니다.
- 이후 새로고침 된 페이지에서 곧바로 댓글 정보를 불러오는 비동기 요청을 서버에 보냅니다.
- 이때, 각 요청을 처리하는 스레드의 완료 시점에 따라 댓글의 좋아요 수를 제대로 반영하지 못하는 문제가 종종 발생했습니다.
문제가 발생하는 원인은 댓글 좋아요 등록•삭제 요청에 대한 처리가 완료되기 전에 댓글 조회 요청의 처리가 먼저 완료됐기 때문입니다.
문제를 직접적으로 해결하기 전, 문제가 발생하는 상황을 테스트 코드로 재현할 필요가 있다고 생각했습니다. 그 이유는 문제 해결 방안을 적용한 후 실제로 해결되었는지 확인하기 위해서입니다.
또 다른 이유는 해당 문제가 조회 요청 스레드의 완료 시점에 따라 간헐적으로 발생하기 때문에, 브라우저에서 수동적으로 테스트할 경우 상황을 재현하기가 어렵기 때문입니다.
문제 상황 재현 전 확인 사항
해당 문제를 재현하기 위해 알아야할 점은 다음과 같습니다.
-
스프링 부트에서는 기본적으로 10 개의 스레드를 생성해 스레드 풀에 보관합니다.
하나의 HTTP 요청이 하나의 스레드에 배정되어 요청을 처리합니다. -
클라이언트 측 자바스크립트의 비동기 처리는 좋아요 기능 사용 후,
페이지를 떠날 때 다수의 댓글 좋아요 등록•사제 요청이 아주 짧은 간격으로 서버로 전송됩니다.
(클라이언트는 싱글 스레드지만 컨텍스트 스위칭하며 마치 여러 스레드가 동시에 작업하듯 각 비동기 요청을 전송합니다.) -
게시물 조회 페이지에서 새로고침 시 최초 6 개의 댓글을 로드합니다.
이후 필요하다면 더 불러오기로 서버에 추가 댓글을 조회합니다.
-> 새로고침 후 불러올 최초 6 개의 댓글에 대해서만 좋아요 수가 제대로 반영되었는지 확인합니다.
문제 상황 재현
@SpringBootTest
환경에서 테스트 코드를 작성해 실행하면, 하나의 스레드가 코드를 실행합니다. 위에서 부터 아래로 코드를 읽으며, 실행중인 코드 실행이 완료되면 다음 코드를 실행하는 방식입니다. 따라서 브라우저에서 다수의 비동기 요청을 짧은 간격으로 보내는 상황을 연출할 수 없습니다.
테스트 코드가 브라우저 처럼 짧은 간격으로 다수 요청을 보내기 위해서는 코드를 실행하는 스레드를 요청 수 만큼 만들어야 합니다.
검색을 통해 Java
가 제공하는 java.util.concurrent
패키지 아래의 인터페이스를 사용하면 손쉽게 다수의 스레드를 사용할 수 있음을 알았습니다.
ExecutorService
인터페이스를 사용해 다수의 스레드를 생성, 스레드 풀에 보관하고, 스레드 풀에 있는 각 스레드에 작업을 할당해, 실행할 수 있습니다.
다만 문제 상황과는 달리 댓글 좋아요 등록•삭제 요청과 댓글 조회 요청 스레드 작업 순서가 뒤섞여 실행됩니다.
18:30:46.780 [http-nio-auto-1-exec-2] - "POST /api/v1/comment-like/3 " keep-alive Java/17.0.7
18:30:46.780 [http-nio-auto-1-exec-3] - "POST /api/v1/comment-like/2 " keep-alive Java/17.0.7
18:30:46.780 [http-nio-auto-1-exec-1] - "POST /api/v1/comment-like/5 " keep-alive Java/17.0.7
18:30:46.780 [http-nio-auto-1-exec-5] - "POST /api/v1/comment-like/4 " keep-alive Java/17.0.7
18:30:46.780 [http-nio-auto-1-exec-7] - "POST /api/v1/comment-like/1 " keep-alive Java/17.0.7
// 중간에 낀 GET 요청.
18:30:46.780 [http-nio-auto-1-exec-4] - "GET /api/v1/board/1/comments " keep-alive Java/17.0.7
18:30:46.780 [http-nio-auto-1-exec-6] - "POST /api/v1/comment-like/6 " keep-alive Java/17.0.7
댓글 조회 작업에 Thread.sleep(long millis)
를 통해 실행을 지연할 수 있지만, 깔끔하게 해당 기능이 구현된 Java
가 제공하는 ScheduledExecutorService
인터페이스를 사용하기로 했습니다.
ExecutorService.execute(Runnable runnable)
을 통해Runnable
작업을 스레드에 할당해 실행할 수 있습니다.- 스레드가 수행한 작업의 결과물을 돌려받기 위해서
Future<>
객체를 반환하는 메서드를 사용할 수 있습니다. schedule(Callable<V> callable, long delay, TimeUnit unit)
메서드를 사용하면 스레드 작업 결과를ScheduledFuture<V>
객체로 돌려 받으면서,delay
,unit
으로 설정한 시간 만큼 지연해 스레드 작업을 실행할 수 있습니다.
댓글 좋아요 등록•삭제의 경우, 돌려받아야할 결과는 없기 때문에 execute()
메서드를 사용하고,
댓글 조회의 경우, 조회한 댓글을 통해 테스트 성패를 판단해야되기 때문에 schedule()
메서드를 사용했습니다.
위에서 살펴본 API 를 이용해 목표한 문제 상황 재현을 아래와 같이 할 수 있었습니다.
@Test
void concurrency_test() throws InterruptedException, ExecutionException {
Long targetBoardId = boards.get(0).getId();
Long targetCommentIdFrom = comments.get(0).getId();
extraClaims = claimsPutMemberId(member);
final var token = tokenBuilder.buildToken(extraClaims, member);
final var headers = buildPostHeadersWithToken(token);
final int countToRegister = 6;
final int threadPoolSize = 10;
final List<Long> idsToRegisterCommentLike = new ArrayList<>();
IntStream.range(0, countToRegister)
.forEach((i) -> idsToRegisterCommentLike.add(targetCommentIdFrom + i));
ScheduledExecutorService service = Executors.newScheduledThreadPool(threadPoolSize);
idsToRegisterCommentLike
.forEach((i) -> service.execute( // 스레드에 작업(Runnable)을 할당하고, 실행
() -> {
final var registerCommentLikeUri = String.format(TARGET_URI_FORMAT, i);
try {
final var registerRequest = buildPostRequestEntity(headers, null, registerCommentLikeUri);
template.exchange(registerRequest, RegisterCommentLikeResponse.class);
} catch (JsonProcessingException e) {
throw new RuntimeException("JSON processing went wrong");
}
}
));
final var getCommentsUri = String.format("/api/v1/board/%d/comments?page-number=1", targetBoardId);
final var getCommentsRequest = buildGetRequestEntity(getCommentsUri);
// 해당 작업을 맡은 스레드를 지정한 만큼 지연하고, 할당한 작업 결과를 돌려받음.
final var future = service.schedule(() -> template.exchange(
getCommentsRequest
, new ParameterizedTypeReference<CustomSliceImpl<CommentGetResponse>>() {
}
), 100L, TimeUnit.MILLISECONDS);
Objects.requireNonNull(future.get().getBody())
.getContent().stream()
.filter((comment) -> idsToRegisterCommentLike.contains(comment.getId()))
.forEach((filtered) -> {
assertThat(filtered.getLikeCount()).isEqualTo(1L);
}
);
}
테스트 실행 후, 요청이 들어온 결과는 다음과 같습니다. 모든 POST 요청이 서버에 도착한 후, GET 요청을 접수합니다.
17:57:09.892 [http-nio-auto-1-exec-4] - "POST /api/v1/comment-like/4 " keep-alive Java/17.0.7
17:57:09.892 [http-nio-auto-1-exec-2] - "POST /api/v1/comment-like/6 " keep-alive Java/17.0.7
17:57:09.893 [http-nio-auto-1-exec-1] - "POST /api/v1/comment-like/3 " keep-alive Java/17.0.7
17:57:09.893 [http-nio-auto-1-exec-6] - "POST /api/v1/comment-like/2 " keep-alive Java/17.0.7
17:57:09.893 [http-nio-auto-1-exec-5] - "POST /api/v1/comment-like/1 " keep-alive Java/17.0.7
17:57:09.892 [http-nio-auto-1-exec-3] - "POST /api/v1/comment-like/5 " keep-alive Java/17.0.7
17:57:09.920 [http-nio-auto-1-exec-7] - "GET /api/v1/board/1/comments " keep-alive Java/17.0.7
테스트의 주장은 실패합니다. (assertion failed)
org.opentest4j.AssertionFailedError:
expected: 1L
but was: 0L
Expected :1L
Actual :0L
이제 이 테스트가 주장하는 바가 성공하도록 문제를 해결하면 되겠습니다.
보완이 필요한 점
-
테스트 코드에서
schedule(Callable<V> callable, long delay, TimeUnit unit)
로 지연시킨 값이 절대값이기 때문에 테스트 실행 환경(CPU, 메모리 성능)에 따라 스레드 실행 순서가 달라질 수도 있을 것으로 보입니다. 이 찝찝함을 해소할 수 있는 방법이 있는지도 찾아봐야겠습니다. -
댓글 좋아요 등록•삭제를 섞은 테스트 케이스 추가