좋아요 구현 시 동시성 문제를 해결하기 위해 비관적 락과 낙관적 락을 테스트하던 도중 문제가 생겼다.
기존 SpringBoot3.3 (Spring Framework 6.0.x) 버전에서는 정상 작동 되는 코드가 SpringBoot 3.4 (Spring Framework 6.1.x) 버전에서는 정상 작동하지 않고, 추가적인 설정이 필요해졌기 때문이다.
상황
낙관적 락에서 좋아요 요청 시 처리에 대한 테스트를 RestClient를 사용하여 진행했다.
테스트 상황은 ThreadPool에 100개의 Thread를 만들어놓고, 해당 쓰레드를 사용하여 비동기 처리로 총 3000번의 좋아요 요청을 보내도록 했다.
// 테스트
@Test
void likePerformanceTest() throws InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(100);
likePerformanceTest(executorService, 3333L, "optimistic-lock");
}
// RestClient를 사용하여 좋아요 요청
void like(Long articleId, Long userId, String lockType) {
restClient.post()
.uri("/v1/article-likes/articles/{articleId}/users/{userId}/" + lockType, articleId, userId)
.retrieve()
}
// 스레드를 사용해서 요청 실행 및 실행 시간 측정
void likePerformanceTest(ExecutorService executorService, Long articleId, String lockType) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(3000);
System.out.println(lockType + " start");
like(articleId, 1L, lockType);
long start = System.nanoTime();
for(int i=0; i < 3000; i++) {
long userId = i + 2;
executorService.submit(() -> {
like(articleId, userId, lockType);
latch.countDown();
});
}
latch.await();
long end = System.nanoTime();
System.out.println("lockType = " + lockType + ", time = " + (end - start) / 1000000 + "ms");
System.out.println(lockType + " end");
Long count = restClient.get()
.uri("/v1/article-likes/articles/{articleId}/count", articleId)
.retrieve()
.body(Long.class);
System.out.println("count = " + count);
}
문제 1
이때 테스트가 완료되고 좋아요 수를 확인했을 때 count=0 으로 나오는 문제가 발생했다. 실제 Server에 로그를 확인해 본 결과 서버에 요청이 정상적으로 전달되지 않은 것을 알게되었다.
왜지??? 분명 나는 다음과 같이 RestClient를 사용해서 요청을 제대로 보냈는데???
void like(Long articleId, Long userId, String lockType) {
restClient.post()
.uri("/v1/article-likes/articles/{articleId}/users/{userId}/" + lockType, articleId, userId)
.retrieve();
}
해결 과정
일단 여기서 생각한 부분은 이 메서드 호출에서 실제 요청이 날아가도록 하는 지점은 .retrieve() 체이닝 부분에서 요청이 전송될 것이라고 생각했다. 따라서 해당 메서드 체이닝 시 요청이 전송되지 않는 사례를 구글링을 통해 찾아보려고 했지만, 찾기 쉽지 않았다.
따라서 공식 문서에 들어가서 retrieve() 메서드에 대해 자세한 설명을 읽어보았다.
REST Clients :: Spring Framework
WebClient is a non-blocking, reactive client to perform HTTP requests. It was introduced in 5.0 and offers an alternative to the RestTemplate, with support for synchronous, asynchronous, and streaming scenarios. WebClient supports the following: Non-blocki
docs.spring.io
공식 문제를 읽으면서 다음과 같은 부분을 확인했다.
NOTE 부분을 읽으면 retreive() 자체 호출만으로는 아무것도 하지 않기에, 만약 반환 값이 없는 호출을 하고 싶다면 retrieve().toBodilessEntity()를 사용하라는 것이다.

분명 SpringBoot 3.3.x (Spring Framework 6.0.x) 에서는 retrieve만으로도 호출이 가능했으나, 이 부분이 새로 업데이트 된 것 같다.
따라서 기존 코드를 수정해주었다.
void like(Long articleId, Long userId, String lockType) {
restClient.post()
.uri("/v1/article-likes/articles/{articleId}/users/{userId}/" + lockType, articleId, userId)
.retrieve();
.toBodilessEntity(); // 추가
}
이제 해치웠나? 했을 때, 또 다른 문제가 발생했다.
문제 2
위와 같이 코드를 수정하고 테스트를 다시 실행했을 때, 테스트가 멈추지 않는 문제가 발생했다.
분명 latch.countDown()을 통해 카운트를 감소시키고 이후 작업을 수행하는데, 시작 로그 외에 아무 로그도 나타나지 않았다.
일단, 실제 응답을 받는 서버를 확인했을 때 서버에 요청이 정상적으로 도달하는 것은 확인 할 수 있었다.
따라서 latch.countDown()이 정상적으로 잘 작동하는지 확인하기 위해 latch.getCount() 메서드를 호출해서 카운트를 확인했다.
for(int i=0; i < 3000; i++) {
long userId = i + 2;
executorService.submit(() -> {
like(articleId, userId, lockType);
latch.countDown();
System.out.println("latch.getCount() = " + latch.getCount()); // count 감소 확인
});
}
그 결과 console창을 확인해보니 countDown이 좋아요 수가 db에 저장하는 만큼은 감소가 되는데, 그 이상의 수는 줄지 않았다.
즉 낙관적 락에서 3000번의 요청을 호출 했을 때, 350건의 좋아요 요청이 성공했다면 latch.countDown이 350번 호출되었지만, 그외에 실패한 요청에 대해서는 latch.countDown()이 작동하지 않는 것을 확인했다.
해결 과정
일단 이 문제의 특징을 살펴보면 다음과 같다.
- 요청을 정상 처리한 경우 문제 없이 countDown()이 작동한다.
- 낙관적 락에서 특정 데이터의 업데이트 충돌로 인해 변경이 취소되면 countdown()이 작동하지 않는다.
- 이때 latch.countDown()이 정상 호출되지 않음
따라서 낙과전 락에서 데이터의 업데이트 시 트랜잭션이 롤백되는 부분의 흐름을 잘 살펴보면 위 문제를 해결할 수 있겠다고 생각했다.
요청을 받는 서버의 로그를 보면 데이터 업데이트 시 충돌이 발생하면 StableObjectStateException이 발생하는 것을 확인했다.
이때 아까 해결했던 1번 문제와의 연관이 떠올랐다.
retrieve() 후 toBodilessEntity()로 요청에 대한 응답을 받아야하는데, 이때 서버에서 에러가 발생해버렸으니 응답을 받는 상황에서 문제가 생긴게 아닐까? 라고 생각했다.
이후 toBodilessEntity()의 구현체의 코드를 뜯어보며 흐름을 자세히 살펴보았다.

먼저 toBodilessEntity()의 구현을 보면 내부적으로 applyStatusHandlers를 호출하며 response 객체를 파라미터로 넘겨준다.
이때 applyStatusHandlers는 응답 statusCode를 확인하며, 해당 statusCode로 전달 받은 응답을 처리할 수 있는 handler가 있으면 해당 handler를 통해 처리하고, 그렇지 못하면 에러를 반환하는 메서드이다.
applyStatusHandler의 구현 코드를 자세히 살펴보면 다음과 같다.

여기서 보면, 등록된 handler를 하나씩 가져와서 response 객체를 처리할 수 있는지 확인하고, 처리할 수 있다면 처리한다, 만약 처리할 수 없다면 에러를 발생키기도록 되어있다.
따라서 내가 에러가 발생했을 때 처리 방법을 등록하지 않았기 때문에 applyStatusHandlers 에서 에러가 발생하여 올라왔고, 결국 해당 작업을 처리하던 스레드가 죽어버리게 된 것이다.
요약하면 다음과 같은 흐름이다.
- retrieve().toBodilessEntity()로 요청 시 서버 에러 발생 (statusCode 500)
- 500 번대 에러를 처리해줄 등록된 handler가 없음
- 에러 발생 -> 스레드 죽음
따라서 500번대 에러를 처리해줄 핸들러를 등록하여 해당 문제를 해결했다.
void like(Long articleId, Long userId, String lockType) {
restClient.post()
.uri("/v1/article-likes/articles/{articleId}/users/{userId}/" + lockType, articleId, userId)
.retrieve()
// 5xx handler 추가
.onStatus(HttpStatusCode::is5xxServerError, (request, response) -> {
return;
})
.toBodilessEntity();
}
위처럼 5xx에서 에러가 발생하면 단순하게 해당 메서드를 종료시키도록 하였고, 최종적으로 테스트가 정상적으로 작동하게 되었다.
결론
restClient 사용 시 응답 객체 매핑까지 메서드 체이닝을 해줘야 정상적으로 서버에 요청이 도달하며, 서버에서 에러 발생 시 restClient에 해당 에러를 처리할 Handler가 등록되어 있지 않다면 에러가 발생하게 된다!
'트러블 슈팅' 카테고리의 다른 글
| 스프링 제네릭 이벤트와 타입 소거: ResolvableType으로 해결하기 (0) | 2025.04.01 |
|---|---|
| Jpa 테스트 시 TransactionRequiredException 발생과 해결 (0) | 2025.03.16 |
| AWS CodeDeploy 에러 해결, 권한 문제 (0) | 2025.02.13 |
| QueryDSL 동적 조회 조건 생성 시 조인 문제 (3) | 2025.02.13 |
| AWS Fargate에서 Eureka 사용 시 Eureka Client의 IP 추적 문제 (0) | 2025.02.13 |