-
[동시성 문제] 게시글 조회수 증가Spring Framework 2024. 8. 18. 01:06
팀 프로젝트 진행 중 팀원이 동시성 문제에 대해 언급했다. 조회수 증가 로직에서 동시성 문제가 발생될 수 있다고 했다. 동시성 문제에 대해 그동안 전혀 고민 없이 개발했는데, 팀원 덕분에 이번 기회에 동시성 문제를 인지하고 고민해 볼 수 있었다.
기존 조회수 증가 로직 (JPA의 변경 감지 기능을 통해 UPDATE 쿼리 실행)
// StudentQuestionBoardService public void incrementViewCount(Long questionBoardId) { QuestionBoard questionBoard = questionBoardRepository.findById(questionBoardId).orElseThrow(); questionBoard.setViewCount(questionBoard.getViewCount() + 1); }
이 코드를 읽고 동시성 문제가 있네! 라고 생각하신 분들은 트랜잭션과 멀티 쓰레드에 대해 잘 알고 계신 분이다. 나는 아직 멀었다.. 이번 기회에 확실히 개념을 잡을 수 있어서 참 다행이다.
기존 로직이 문제가 되는 이유는,,
바로 경쟁 상태(Race Condition) 때문이다. 경쟁 상태란 두 개 이상의 쓰레드가 동시에 같은 자원에 접근하여 데이터를 읽거나 수정하는 경우를 의미한다.
예시를 들어보자.
1. 쓰레드 A와 쓰레드 B가 동시에 incrementViewCount 메서드를 호출한다.
- 두 쓰레드가 거의 동시에 데이터베이스에서 동일한 QuestionBoard 엔티티를 조회했다.
- 조회한 QuestionBoard의 현재 조회수를 10이라고 가정한다.
2. 조회수 증가
- 쓰레드 A가 조회수를 1 증가시켰다. (➡️ 11)
- 이와 거의 동시에 쓰레드 B도 조회수를 1 증가시켰다. (➡️ 11)
3. 결과
- 쓰레드 A가 viewCount를 11로 업데이트하고 데이터베이스에 저장한다.
- 쓰레드 B가 viewCount를 11로 업데이트하고 데이터베이스에 저장한다.
- 실제 조회수는 12가 되어야 하지만 11로 저장되었다.
두 쓰레드가 거의 동시에 동일한 엔티티를 조회하고 값을 업데이트 했기 때문에, 최종적으로 조회수가 2번 증가하는 대신 한 번만 증가한 것처럼 보이는 것이 동시성 문제의 핵심이다.
조금만 더 깊이 생각해보자.
이 문제의 근본적인 원인은 데이터를 읽고 쓰는 작업이 분리되어 있다는 것이다. 쓰레드들은 서로의 작업 내용은 알지 못하기 때문에 결국 마지막으로 작업한 쓰레드의 결과만이 데이터베이스에 반영된다..
- 읽기 : 각 쓰레드가 현재 조회수를 읽어옴
- 쓰기 : 각 쓰레드가 조회수를 1 증가시킨 후 다시 저장
여기까지 이해했음에도 불구하고, 나는 직접 눈으로 봐야 믿는 성격이라 테스트 코드를 작성해 보았다.
- 100개의 쓰레드가 같은 게시글의 조회수 증가 메서드를 병렬로 호출한다.
- 기대하는 최종 조회수는 100이었지만 결과는 12였다. 동시성 문제가 발생한다는 뜻이다!
@SpringBootTest public class ViewCountTest { @Autowired private UserRepository userRepository; @Autowired private QuestionBoardRepository questionBoardRepository; @Autowired private StudentQuestionBoardService studentQuestionBoardService; @Test public void testViewCountConcurrency() throws InterruptedException { // given Long questionBoardId = createTestQuestionBoard(); int numberOfThreads = 100; ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads); CountDownLatch latch = new CountDownLatch(numberOfThreads); // when for (int i = 0; i < numberOfThreads; i++) { executorService.submit(() -> { try { studentQuestionBoardService.incrementViewCount(questionBoardId); } finally { latch.countDown(); } }); } latch.await(); executorService.shutdown(); // then QuestionBoard questionBoard = questionBoardRepository.findById(questionBoardId).orElseThrow(); Assertions.assertThat(questionBoard.getViewCount()).isEqualTo(numberOfThreads); } private Long createTestQuestionBoard() { QuestionBoard questionBoard = new QuestionBoard(); questionBoard.setTitle("Test Title"); questionBoard.setContent("Test Content"); questionBoardRepository.save(questionBoard); return questionBoard.getId(); } }
그래서 어떻게 해결하는데?
이 문제를 해결하는 가장 간단한 방법은 데이터베이스 레벨에서 조회수 증가를 직접 처리해 주는 것이다. 데이터를 조회하고 값을 세팅해주는 것이 아닌 UPDATE 쿼리 자체를 의미한다.
코드 수정 (데이터베이스 레벨에서 UPDATE 쿼리 실행)
// StudentQuestionBoardService public void incrementViewCount(Long questionBoardId) { questionBoardRepository.incrementViewCountById(questionBoardId); }
// QuestionBoardRepository @Modifying @Query("update QuestionBoard q set q.viewCount = q.viewCount + 1 where q.id = :id") void incrementViewCountById(@Param("id") Long id);
이렇게 코드를 수정하니 테스트 코드가 통과되었다. 동시성 문제를 해결했다는 뜻이다.
이게 왜 되는데?
코드를 별로 수정한 것도 없는데 도대체 왜 될까? 이것을 이해하기 위해 예전에 정리해둔 트랜잭션에 관한 글을 다시 읽었다.
데이터베이스의 트랜잭션이 보장해야 하는 특성 중 격리성(Isolation)이 있다. 격리성이란 트랜잭션끼리 서로 간섭하지 않아야 한다는 뜻이다. 격리성을 보장하기 위한 가장 확실한 방법은 모든 트랜잭션을 정확히 순서대로 실행하는 것이다. 하지만 이렇게 하면 성능이 저하되기 때문에 이미 4가지로 정의된 트랜잭션 격리 수준이 존재한다.
트랜잭션 격리 수준
- READ UNCOMMITED (커밋되지 않은 읽기)
- READ COMMITTED (커밋된 읽기)
- REPEATABLE READ (반복 가능한 읽기)
- SERIALIZABLE (직렬화 가능)
내가 사용하고 있는 MySQL은 격리 수준 단계로 REPEATABLE READ를 사용하고 있다. 이 격리 수준에서는 한 트랜잭션 내에서 동일한 데이터를 여러 번 읽어도 항상 일관된 결과를 보장한다는 특징이 있다. 또한 READ COMMITTED과 마찬가지로 트랜잭션이 완료되지 않은 데이터를 다른 트랜잭션이 읽지 못하도록 한다.
데이터베이스에서 직접 UPDATE를 수행하면 이처럼 데이터베이스 자체에서 트랜잭션의 격리성을 보장해주기 때문에 동시성 문제가 해결된 것이다!
'Spring Framework' 카테고리의 다른 글
[Spring Cloud] Netflix Eureka 찍어먹기 (2) 2025.01.03 Spring AI 맛보기 (0) 2024.08.13 [Spring Data JPA] open-in-view 설정에 대해서 (0) 2024.07.12 [Spring Data JPA] 벌크 연산 후 영속성 컨텍스트를 초기화 해야 하는 이유 (0) 2024.07.09 [Spring MVC] 파일 업로드 예제 (1) 2024.05.24