동시성 문제 해결(2) - 낙관락 & 비관락 [낙관락 연습 - SpringBoot]
이번에는 동시성 제어 방법 중 하나인 낙관락과 비관락에 대해 알아보고 게시물 좋아요 기능에 낙관락을 적용해 보도록 하자.
예제 코드 : https://github.com/menuhwang/concurrency-issue
낙관락 비관락
낙관락
동시성 문제가 거의 발생하지 않는다고 낙관적으로 접근하는 방식. (동시성 문제 안나유~)
- 애플리케이션 레벨에서 락을 거는 방법.
- 데이터를 읽을 때는 락을 걸지 않는다.
- 여러 사용자가 동시에 읽기 작업을 수행하더라도 락이 걸리지 않아 빠른 속도로 작업이 수행된다.
- 데이터를 변경할 때는 이전에 읽은 데이터와 비교하여 충돌이 발생하는지 확인하고 충돌이 발생한 경우 롤백하거나, 다시 시도한다.
비관락
동시성 문제가 발생할 것이라고 비관적으로 접근하는 방식.
- 데이터를 읽을 때 락을 걸어 다른 사용자가 접근하지 못하도록 한다.
- 충돌이 발생하지 않아 일관성을 보장할 수 있지만, 락을 걸고 있어 성능이 떨어진다.
낙관락 적용
게시물 좋아요 기능 구현
게시물 좋아요 기능에 낙관락을 적용하여 구현해 보자.
좋아요 버튼을 누르면 기존 좋아요 값에 1을 증가시키고 저장한다. 두 사용자가 좋아요를 동시에 누르면 2가 증가하는 것이 아닌 1만 증가하는 문제가 발생할 수도 있다. 기본적인 기능을 구현하고 동시성 테스트를 통과하도록 개선해 보자.
예시 코드
Board
@Entity
@Getter
@ToString
public class Board {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
private String title;
private int likes;
protected Board() {
}
@Builder
public Board(int id, String title, int likes) {
this.id = id;
this.title = title;
this.likes = likes;
}
public void like() {
this.likes++;
}
public void resetLikes() {
this.likes = 0;
}
}
BoardRepository
public interface BoardRepository extends JpaRepository<Board, Integer> {
}
BoardService
@Service
@RequiredArgsConstructor
public class BoardService {
private final BoardRepository boardRepository;
public Board get(int id) {
return boardRepository.findById(id)
.orElseThrow(() -> new RuntimeException("게시물 없음"));
}
public Board like(int id) {
Board board = boardRepository.findById(id)
.orElseThrow(() -> new RuntimeException("게시물 없음"));
board.like();
boardRepository.saveAndFlush(board);
return board;
}
}
BoardController
@RestController
@RequiredArgsConstructor
@RequestMapping("/boards")
public class BoardController {
private final BoardService boardService;
@GetMapping("/{id}")
public Board get(@PathVariable int id) {
return boardService.get(id);
}
@PutMapping("/{id}/like")
public Board like(@PathVariable int id) {
return boardService.like(id);
}
}
TransactionStudyApplicationTest
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
class TransactionStudyApplicationTests {
@Autowired
private BoardRepository boardRepository;
@Autowired
MockMvc mockMvc;
@BeforeEach
void setUp() {
resetLikes();
}
@AfterEach
void tearDown() {
resetLikes();
}
@Test
@DisplayName("좋아요")
void like() throws Exception {
mockMvc.perform(put("/boards/1/like"))
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.likes").value(1));
}
@Test
@DisplayName("좋아요 10회 순차 요청")
void sequence_like_10_times() throws Exception {
for (int i = 1; i <= 10; i++) {
mockMvc.perform(put("/boards/1/like"))
.andExpect(jsonPath("$.likes").value(i));
}
}
@Test
@DisplayName("좋아요 동시 요청")
void concurrency_like_with_timeout() throws Exception {
int numberOfThreads = 200;
ExecutorService service = Executors.newFixedThreadPool(200); // 스프링 스레드풀 기본 값인 200으로 설정
CountDownLatch latch = new CountDownLatch(numberOfThreads);
for (int i = 0; i < numberOfThreads; i++) {
service.execute(() -> {
try {
mockMvc.perform(put("/boards/1/like"));
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
latch.countDown();
}
});
}
latch.await(); // 스레드가 모두 실행될때까지 대기
mockMvc.perform(get("/boards/1"))
.andExpect(jsonPath("$.likes").value(numberOfThreads));
}
void resetLikes() {
boardRepository.findAll().forEach(board -> {
board.resetLikes();
boardRepository.saveAndFlush(board);
});
}
}
테스트 실행 시 '좋아요 동시 요청' 테스트가 실패하는 것을 볼 수 있다. (좋아요 값은 테스트 실행 시마다 다른 값이 나온다.)
동시성 문제가 발생하고 있는 것이다. 먼저 낙관락을 적용시켜 해결해 보자.
@Version
JPA를 사용할 때 @Version 어노테이션과 버전 필드를 추가하여 간단하게 낙관락을 적용할 수 있다.
@Version 어노테이션을 사용한 낙관락 동작 순서는 아래와 같다.
1. 엔티티를 읽어 온다.
2. 엔티티의 버전정보를 읽어 온다.
2. 엔티티를 수정한다.
3. 엔티티의 버전 정보를 수정한다.
4. 데이터베이스에 업데이트를 시도한다.
4-1. 기존 버전 정보를 가지고 있는 레코드를 업데이트한다.
4-2. 기존 버전 정보와 같은 레코드가 없다면 예외 발생.
@Version 어노테이션을 적용할 수 있는 타입
- int, Integer
- long, Long
- short, Short
- Timestamp
- LocalDatetime
적용 코드
Board
@Entity
@Getter
@ToString
public class Board {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
private String title;
private int likes;
@Version // 추가
private LocalDateTime version; // 추가
// 생략...
}
BoardService
@Service
@RequiredArgsConstructor
public class BoardService {
// 생략...
public Board like(int id) {
int numberOfTries = 10; // 재시도 횟수 제한
while (numberOfTries-- > 0) { // 재시도
try {
Board board = boardRepository.findById(id)
.orElseThrow(() -> new RuntimeException("게시물 없음"));
board.like();
boardRepository.saveAndFlush(board); // 충돌 발생 가능 지점.
return board;
} catch (ObjectOptimisticLockingFailureException e) { // 낙관락 충돌 시
System.out.printf("재시도 %d\n", numberOfTries);
try {
Thread.sleep(ThreadLocalRandom.current().nextInt(100, 200)); // 100~200ms 대기 후 재시도.
} catch (InterruptedException ex) {
throw new RuntimeException(ex);
}
}
}
throw new RuntimeException("좋아요 처리 실패"); // 재시도 횟수 안에 완료하지 못한 경우 예외 발생.
}
}
낙관락은 충돌 발생 시 롤백 또는 재시도를 직접 처리해줘야 한다.
위 코드는 ObjectOptimisticLockingFaulureException 발생 시 랜덤 한 시간을 두고 최대 10회 재시도하는 코드이다.
낙관락 충돌 발생 시 ObjectOptimisticLockingFaulureException이 발생한다.
왜 무작위 간격을 두고 재시도할까?
동시성 문제라는 것이 이전 작업이 완료되기 전에 같은 데이터를 접근하여 생기는 문제인데, 같은 시간을 대기하고 재시도하면 동시에 데이터를 읽어올 확률이 높기 때문이다.
누군가는 100ms를 대기하고 누군가는 200ms를 대기하도록 하여 다른 스레드와 가능한 겹치지 않도록 해주는 것이다.
재시도 횟수 제한이 있으면, 작업 수행에 실패할 수도 있는데 완료될 때까지 반복하면 안 되나?
재귀 호출을 하거나, 정상처리 될 때까지 반복문을 탈출하지 않도록 하여 성공을 보장할 수 도 있겠다. 하지만, 성공하지 못한다면 무한히 반복될 가능성이 있고, 재귀 호출 시 Stack Overflow 에러 발생 가능성이 있다.
코드를 수정한 후 테스트 코드를 돌려보면 '좋아요 동시 요청' 테스트에 통과한 것을 확인할 수 있다.
마무리
아쉽게도 이 테스트 코드는 동시 요청 개수, service에서의 대기 시간, 재시도 횟수에 따라 성공할 수 도, 실패할 수 도 있는 코드이다.
이 부분을 어떻게 개선해야 할지는 아직 과제로 남았다...
또한, 약 200개의 좋아요 처리를 하면서 2초 정도의 시간이 걸렸다. 동시에 좋아요 버튼을 누른 200명 중 누군가는 2초 동안 기다렸다는 말이다.
다음에는 비관락을 적용해 보고 평균 성능도 비교해 봐야겠다.