-
조회수 변경 시 발생하는 레이스 컨디션과 해결 방법운영 중인 서비스/Coconut. 2024. 2. 2. 13:59
게시판에 조회수를 요청이 들어오는대로 1씩 올려주다보면 정합성 이슈를 만날 수 있습니다.
레이스 컨디션이 발생하는 이유와 어떻게 해결하는지 적어보겠습니다.
1. 레이스 컨디션 발생 확인
현재 로직은 게시판 상세 내용을 get 요청하는 경우 view count를 1씩 올리는 형태로 작성되어있습니다.
public PostResDto getPost(long postId) { Optional<Post> optPost = postRepository.findById(postId); Post post = optPost.orElseThrow(() -> new NotFoundException("[postId: " + postId + "] 게시글이 존재하지 않습니다")); post.setViewCount(post.getViewCount() + 1); postRepository.save(post); return postMapper.toPostResDto(post); }
http://localhost:8080/api/v1/post/29 주소로 한번씩 요청을 하는 경우 viewCount는 1씩 잘 올라가고 있습니다.
이번에는 script를 사용해서 한번 요청을 해보겠습니다.
2번째 요청이기 때문에 viewCount가 2로 알맞게 오고 있습니다.
$ curl -X GET "http://localhost:8080/api/v1/post/29"
curl을 사용한 요청은 동기적으로 실행되기 때문에 2번을 요청해서 카운트가 잘 올라가는 것을 확인할 수 있습니다.
그럼 이번에는 apache benchmark를 사용해서 병렬로 요청을 날려보겠습니다.
// 15000개의 요청을 100개의 동시 사용자가 요청하는 시나리오입니다. $ ab -n 15000 -c 100 -l http://<서버ip>/api/v1/post/29
벤치마크 결과를 직접 보니 신기합니다
Time taken for test를 통해서 전체 소요시간을 확인할 수 있습니다.
Benchmarking localhost (be patient) Completed 1500 requests Completed 3000 requests Completed 4500 requests Completed 6000 requests Completed 7500 requests Completed 9000 requests Completed 10500 requests Completed 12000 requests Completed 13500 requests Completed 15000 requests Finished 15000 requests Server Software: Server Hostname: localhost Server Port: 8080 Document Path: /api/v1/post/29 Document Length: Variable Concurrency Level: 100 Time taken for tests: 4.385 seconds Complete requests: 15000 Failed requests: 0 Total transferred: 4369244 bytes HTML transferred: 2794244 bytes Requests per second: 3420.54 [#/sec] (mean) Time per request: 29.235 [ms] (mean) Time per request: 0.292 [ms] (mean, across all concurrent requests) Transfer rate: 972.99 [Kbytes/sec] received Connection Times (ms) min mean[+/-sd] median max Connect: 0 0 0.4 0 38 Processing: 2 29 12.8 26 152 Waiting: 2 29 12.8 26 152 Total: 2 29 12.8 26 152 Percentage of the requests served within a certain time (ms) 50% 26 66% 27 75% 29 80% 30 90% 40 95% 52 98% 72 99% 82 100% 152 (longest request)
처음에 20000건을 요청했는데 제 컴퓨터 사양이 부족해서 요청이 계속 실패합니다.
15000건의 요청으로 조절하고 cpu 사용양을 보니 요청이 진행되는 동안 그래프의 움직임이 확실하게 보입니다.
15000개의 요청이 들어갔는데 view count는 1540입니다.
100명의 유저가 사용하는 상황에서도 이러한 결과가 나올수가 있습니다.
2. 레이스 컨디션을 해결을 위해 고려해 볼 수 있는 방법들
기본적으로 읽기 + 더하기 + 저장하기의 3단계 과정 중에 쓰레드가 바뀌면서 발생하는 문제이기 때문에 이 것을 방지하면 race condition을 막을 수 있다고 이해를 하고 시작해보겠습니다.
방법1. 자바의 synchronized 키워드 사용 ( 분산 환경에서는 사용 X )
오랜만에 synchronized 키워드를 만나니 아찔해집니다.
다시 한번 synchronized에 대해서 간단하게 살펴본다면, 이 키워드는 Object Locking(인스턴스 단위의 락)을 얻어서 1개의 스레드만 임계영역에 접근가능하도록 뮤텍스를 사용해서 락 메커니즘을 구현합니다.
@Override public synchronized PostResDto getPost(long postId) { Optional<Post> optPost = postRepository.findById(postId); Post post = optPost.orElseThrow(() -> new NotFoundException("[postId: " + postId + "] 게시글이 존재하지 않습니다")); post.setViewCount(post.getViewCount() + 1); postRepository.save(post); }
구현은 아주 간단합니다. 단순하게 synchronized 키워드만 추가해주면 다른 곳은 변경할 필요가 없습니다.
동일한 방법으로 요청을 날려보겠습니다. 모든 요청을 처리하는데 사용된 시간이 증가하였습니다. (4초 => 14초)
Benchmarking localhost (be patient) Completed 1500 requests Completed 3000 requests Completed 4500 requests Completed 6000 requests Completed 7500 requests Completed 9000 requests Completed 10500 requests Completed 12000 requests Completed 13500 requests Completed 15000 requests Finished 15000 requests Server Software: Server Hostname: localhost Server Port: 8080 Document Path: /api/v1/post/29 Document Length: Variable Concurrency Level: 100 Time taken for tests: 14.300 seconds Complete requests: 15000 Failed requests: 0 Total transferred: 4383894 bytes HTML transferred: 2808894 bytes Requests per second: 1048.95 [#/sec] (mean) Time per request: 95.333 [ms] (mean) Time per request: 0.953 [ms] (mean, across all concurrent requests) Transfer rate: 299.38 [Kbytes/sec] received Connection Times (ms) min mean[+/-sd] median max Connect: 0 0 0.3 0 4 Processing: 1 94 61.8 91 551 Waiting: 1 94 61.8 91 551 Total: 1 94 61.8 91 555 Percentage of the requests served within a certain time (ms) 50% 91 66% 120 75% 136 80% 145 90% 163 95% 173 98% 212 99% 284 100% 555 (longest request)
하지만 정합성은 완벽하게 유지되었습니다.
synchronized 키워드를 사용하면 속도를 조금 양보하고 정합성을 유지할 수 있는 것을 확인할 수 있습니다.
하지만 synchronized 키워드를 사용하는 경우 조심해야하는 함정이 있습니다.
synchronized는 같은 JVM에서 실행되는 쓰레드들에 대해서만 동기화를 보장합니다. 따라서 여러 개의 서버를 가지고 있는 시스템 환경에서는 레이스 컨디션 해소의 방법으로 사용될 수 없습니다. 예를 들어 서버A와 서버B가 있다고 해봅시다. synchronized를 사용한다면 서버 A에 있는 쓰레드에서 발생하는 레이스 컨디션은 해소할 수 있지만 서버A의 쓰레드와 서버B의 쓰레드 사이에서는 동기화를 보장할 수 없습니다. 따라서 많은 경우에 synchronized는 적절한 대응방법이라고 볼 수 없습니다.
방법2. 비관적 락(Pessimistic Lock) 사용하기
비관적 락은 한 트랜잭션이 데이터를 읽고 수정하는 동안 다른 트랜잭션은 해당 데이터에 접근하지 못하도록 락을 거는 방식입니다.
em.find를 통해서 게시글을 가져오고 현재 트랜잭션이 진행되는 동안은 다른 트랜잭션에서 해당 row에 접근할 수 없습니다.
- 비관적 읽기 락 (Pessimistic Read Lock, LockModeType.PESSIMISTIC_READ)
조회는 가능하지만 변경은 불가능한 락 - 비관적 쓰기 락 (Pessimistic Write Lock, LockModeType.PESSIMISTIC_WRITE)
조회와 변경 모두 불가능한 락
// 의존성 주입 @PersistenceContext private EntityManager em; // 서비스 메소드 @Transactional @Override public PostResDto getPost(long postId) { Post post = em.find(Post.class, postId, LockModeType.PESSIMISTIC_WRITE); if (post == null) { throw new NotFoundException("[postId: " + postId + "] 게시글이 존재하지 않습니다"); } post.setViewCount(post.getViewCount() + 1); return postMapper.toPostResDto(post); }
아파치 벤치를 통해서 성능을 확인해보면, 기존 4초에서 10초 이상 걸리는 것을 확인할 수 있습니다.
성능을 양보하고 정합성을 얻은 것을 확인할 수 있습니다.
JPA에서 @Lock 어노테이션을 사용하면 레포지토리 인터페이스에서 간단하게 비관적락을 적용할 수도 있습니다.
이 경우에는 findById가 사용되는 모든 곳에서 비관적 락이 적용될 것이기 때문에 주의가 필요합니다.
public interface PostRepository extends JpaRepository<Post, Long> { @Lock(LockModeType.PESSIMISTIC_WRITE) Optional<Post> findById(Long postId); }
방법3. 낙관적 락(Optimistic Lock) 사용
이 방법은 레이스 컨디션은 발생하지 않지만 실제로 조회된 만큼 조회수를 올리지는 못하는 상황이 발생합니다.
낙관적 락은 디비에 version 이라는 컬럼을 추가하고, 데이터를 업데이트를 할 때 업데이트를 시도하는 데이터의 버전과 디비에 있는 버전을 비교하여 버전이 같은 경우에만 업데이트를 하는 방식입니다.
낙관적 락 매커니즘
JPA가 update 쿼리를 작성할 때 where ... and version = 현재 버전 조건을 추가합니다.
버전이 일치하지 않는다면 적용된 row는 0이 반환될 것입니다.
이런 경우에는 JPA가 'OptimisticLockException' 예외를 발생시켜서 사용자에게 실패를 알립니다.
엔드 유저에게는 아래와 같은 메세지를 전달할 수 있습니다.
- "다른 사용자가 이미 해당 게시글을 수정했습니다. 최신 내용을 확인하시고 다시 시도해 주세요."
- "요청하신 작업을 수행하는 동안 데이터가 변경되었습니다. 변경된 내용을 불러온 후, 원하시는 작업을 다시 진행해 주세요."
엔티티에 버전 필드를 추가해 줍니다.
@Version private Long version;
버전 컬럼이 생성됩니다. 버전을 중간에 생성해서 버전 컬럼에 값이 없는 경우에는 문제가 발생할 수 있습니다.
0 혹은 1의 값이 기본값으로 들어있어야합니다.
위와 동일하게 요청을 날리면 락을 걸지 않은 것과 비슷한 성능을 보여줍니다.
위에서 이야기 한 것 처럼 15,000번의 요청을 했지만 카운트는 1579이고 버전도 1579입니다.
레이스 컨디션은 발생하지 않았지만 그래도 원하는 비즈니스 요구사항을 만족시키는 방법은 아닌 것 같습니다.
3. 비관적 락을 사용하기
고려할 수 있는 여러가지 옵션을 살펴보았습니다. 이 외에도 메세지 큐를 사용하는 방법도 있지만 현재 유저 트래픽과 시스템 사이즈를 고려했을 때 아직 도입을 위한 적절한 시기는 아니라고 판단이 됩니다. 따라서 우선 비관적 락을 사용해서 조회수 증가에서 발생하는 이슈를 해결하도록 하겠습니다.
'운영 중인 서비스 > Coconut.' 카테고리의 다른 글
CloudWatch를 사용해서 Discord에서 EC2 상태 알림 받기 (0) 2024.02.16 내 서버는 얼마나 버틸 수 있는가 (0) 2024.02.15 String으로 사용되던 데이터 enum으로 상수처리 (0) 2024.01.30 테스트를 쓰면서 만난 좋지 않은 Util 클래스와 의존성관리 (0) 2024.01.25 NumberFormatException (0) 2024.01.23 - 비관적 읽기 락 (Pessimistic Read Lock, LockModeType.PESSIMISTIC_READ)