이번 게시글에선 동시성 요청에 대한 이슈처리를 하기 위한 방법으로 '락'(lock) 에 대해서 알아보겠습니다.
일반적으로 '락'을 분류할 때 큰 범주로 비관적 락과 낙관적 락으로 분류합니다.
각각의 락에 대해 정리해보겠습니다.
비관적 락
비관적 락은 충돌이 잦은 환경에서 주로 사용하는 락이라는 관점에서 '비관적'이라는 명칭이 붙었습니다.
- 공유 자원에 락을 걸고 진행
비관적 락은 데이터베이스 환경에서의 락입니다.
비관적 락은 충돌이 발생할 것을 예상하고 작업을 진행하기 때문에
시작부터 데이터베이스의 '공유락', '배타락'을 사용하여 작업을 순차진행 시킵니다.
공유락과 배타락에 대해선 저번 게시글에서 정리해보았었습니다.
비관적 락은 공유락과 배타락은 락을 걸고 진행하기 때문에
성능 오버헤드 이슈가 있고 또한 데드락의 위험성이 있기 때문에 주의하여 사용하는 것이 필요합니다.
낙관적 락
낙관적 락은 비교적 충돌이 적은 환경에서 사용하는 락이라는 관점에서 '낙관적'이라는 명칭이 붙었습니다.
- 공유 자원에 락을 걸지 않고 진행 (이미 락이 걸린 상태라면 풀릴 때까지 대기)
낙관적 락은 어플리케이션 환경에서의 락입니다.
낙관적 락은 충돌이 발생할 것을 미리 예상하지 않습니다.
따라서 낙관적 락은 데이터베이스의 락을 걸지 않고 작업을 진행시키다가 충돌이 발생 여부를 커밋 시점에 확인합니다.
버전 등의 칼럼으로 충돌 발생여부를 확인한 경우, 애플리케이션 단에서 에러나 롤백 처리를 해야합니다.
JPA의 낙관적락 설정을 이용하면 개발자가 일일히 잡아줘야할 복잡한 롤백 처리를 간단하게 처리할 수 있습니다.
락을 걸지 않고 작업을 진행하기 때문에 충돌이 발생하지 않는 상황에서는 성능 오버헤드가 없습니다.
그러나 충돌이 잦은 상황인 경우 에러 처리와 롤백 처리가 빈번해져 오히려 성능이 비관적 락보다 좋지 않아지게 됩니다.
충돌이 발생하면 기존 트랜잭션을 롤백 후 트랜잭션을 다시 시작하여 읽기, 쓰기 작업을 처음부터 진행해야하기 때문입니다.
- 낙관적 락은 실패시 에러처리 로직 필요
- 낙관적 락 사용 시에도 데드락 발생 가능성 존재
예시코드
아래의 표는 JPA에서 제공하는 락 모드에 대한 종류입니다.
가장 먼저 락을 설정하지 않고 실패하는 테스트를 실행시켜보겠습니다.
@Test
public void 포인트_차감_동시_요청_5() throws Exception { // 준비, 요청, 완료 카운트다운래치로 동시 요청
long startTime = System.currentTimeMillis(); // 코드 시작 시간
Point point = pointRepository.save(new Point(10000L)); // 포인트저장
int n = 1000; // 스레드 풀의 크기
ExecutorService executorService = Executors.newFixedThreadPool(n); // 고정된 크기의 스레드 풀 생성
CountDownLatch readyThreadCounter = new CountDownLatch(n); //준비 카운트
CountDownLatch callingThreadBlocker = new CountDownLatch(1); //요청 카운트
CountDownLatch completedThreadCounter = new CountDownLatch(n); //완료 카운트
// 동시에 실행될 작업 정의
Runnable task = new WaitingWorker(
point, readyThreadCounter, callingThreadBlocker, completedThreadCounter);
// n개의 작업을 스레드 풀에 제출 --> 작업과 실행의 분리!
System.out.println("n개의 작업을 스레드 풀에 제출");
for (int i = 0; i < n; i++) {
executorService.submit(task); // 실행
}
readyThreadCounter.await(); // 모든 작업 준비 될 때까지 대기
callingThreadBlocker.countDown(); // 준비 완료 시 요청 count 0으로 호출 -> 요청 동시에 n개 호출
completedThreadCounter.await(); // 모든 작업 완료 될 때까지 대기
System.out.println("========= 모든 작업 완료 =========");
Point after = pointRepository.findById(point.getId()).orElseThrow();
System.out.println(after);
long endTime = System.currentTimeMillis(); // 코드 끝난 시간
long durationTimeSec = endTime - startTime;
System.out.println("실행 시간 == " + durationTimeSec);
assertThat(after.getPoints()).isEqualTo(9000L);
}
10000포인트를 1포인트씩 차감하며 1000번 요청을 시도했습니다.
예상한대로 테스트는 실패했습니다. 1000번 요청중 124건의 요청만 실행되었습니다.
실행시간은 1477 밀리세건드입니다.
그럼, 비관적락 설정을 해보고 다시 테스트를 실행해보겠습니다!
이제 findById()로 조회를 해오면 해당 트랜잭션 내에서 비관적락(쓰기락)으로 작업이 진행됩니다.
테스트 결과는 어떻게 되었을까요?
두둔~!
비관적락을 설정하고 update 요청을 1000번 동시요청을 테스트한 결과, 정확히 1000번의 요청을 수행하였습니다!
다만, 실행시간이 이전의 1477 밀리세컨트보다 확실히 증가한 2656 밀리세컨드로 측정되었습니다!
해당 테스트에서 발생한 쿼리를 살펴보실까요?
로그를 확인해보시면 SELECT ~ FOR UPDATE라는 "배타락(쓰기락)" 쿼리가 flush된 것을 확인해볼 수 있습니다.
위처럼 비관적락은 충돌이 자주 발생할 곳에 설정을 하면 충돌을 방지해주지만,
특정 트랜잭션이 점유하고 있는 레코드에 락을 걸어두어 트랜잭션 종료 후 다른 작업이 가능하기 때문에
성능이 낮아진다는 것을 확인해볼 수 있었습니다.
또한, 비관적락은 데드락이 발생할 여지도 있다는 점에서 주의해서 사용해야합니다. (낙관적락도 발생 가능)
낙관적 락 테스트
이번엔 낙관적 락 테스트를 진행하여 버전이 다를 경우 ObjectOptimisticLockingFailureException 가 발생하는지 확인해보겠습니다.
JPA를 이용한 낙관적 락은 세 가지 설정을 할 수 있습니다.
1. 엔티티 필드에 @Version 설정 + (LockModeType.NONE - 명시 안해도 결과 같음)
- 트랜잭션 내 수정이 일어날 경우만 버전을 체크합니다. 수정이 발생한 경우 버전을 증가시킵니다.
2.엔티티 필드에 @Version 설정 + LockModeType.OPTIMISTIC
- 읽기 작업 중에서도 Version을 체크합니다. 수정이 발생한 경우 버전을 증가시킵니다.
- DirtyRead, NON-REPEATABLE READ를 방지할 수 있습니다.
3. 엔티티 필드에 @Version 설정 + LockModeType.OPTIMISTIC_FORCE_INCREMENT
- 커밋 시 항상 버전을 증가시킵니다. 수정이 일어난 경우 수정에 해당하는 버전 + 1, 커밋에 해당하는 버전 + 1 이 일어나 결과적으로 버전 + 2 가 발생합니다.
저는 LockModeType.OPTIMISTIC을 이용하여 동시 요청 테스트를 진행해보겠습니다.
@Entity
@NoArgsConstructor
@Getter
@ToString
public class Point {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Long id;
private Long points;
@Version
Long version;
public Point(Long points) {
this.points = points;
}
public void substractPoint(Long point){
points -= point;
}
}
public interface PointRepository extends JpaRepository<Point, Long> {
@Lock(LockModeType.OPTIMISTIC)
Optional<Point> findById(Long id);
}
예상대로 테스트는 실패했습니다.
해당 작업은 수정 발생 시 버전 체크를 하였고, 동시 요청의 문제로 버전이 다른 경우가 발생하여 ObjectOptimisticLockingFailureException가 발생하였습니다!
낙관적 락은 어플리케이션 레벨에서 락 체크이므로, 예외를 catch해서 별도의 작업을 처리해주어야합니다.
만약 낙관적 락을 이용하여 예외를 catch해서 다시 수정 요청을 하는 경우라면
동시 요청이 많은 상황에선 요청 작업을 처음부터 재시작하는 트랜잭션이 과다해져 비관적락의 경우보다 성능이 낮아질 가능성이 있습니다.
비관적 락 & 낙관적 락 한계
비관적 락, 낙관적 락 모두 DB에 의존적이므로 다중 서버 환경인 경우에도 동시 요청에 대한 처리가 가능한 것을 알 수 있습니다.
하지만, DB 의존적이므로 데이터가 분산되어 스케일 아웃된 DB 환경에서는 락이 불가능합니다.
또한 DB에 의존적인 만큼, DB I/O를 많이 발생시켜 성능이 중요한 동시성 요청 사항에서도 위의 설정만으로 서비스 운영이 어려울 수 있습니다.
다음 게시글에선 성능을 고려한 동시성 요청 작업 처리와 스케일 아웃된 DB 환경에서도 동시성 요청을 받아낼 수 있는 구조에 대해서도 알아보겠습니다..!!
참고)
https://ttl-blog.tistory.com/1568
https://systemdata.tistory.com/51
https://sabarada.tistory.com/175
https://isntyet.github.io/jpa/JPA-%EB%B9%84%EA%B4%80%EC%A0%81-%EC%9E%A0%EA%B8%88(Pessimistic-Lock)/
'백엔드 > Java' 카테고리의 다른 글
동시성 이슈 처리 및 테스트 방안 3 - 트랜잭션 격리수준 (2) | 2024.04.11 |
---|---|
동시성 이슈 처리 및 테스트 방안 - 2(CountDownLatch 활용) (6) | 2024.03.25 |
동시성 이슈 처리 및 테스트 방안 - 1 (6) | 2024.02.26 |
천사와 악마 사이 추상클래스... - 인터페이스 및 정적 유틸리티 클래스 비교 - 2 (5) | 2024.02.13 |
천사와 악마 사이 추상클래스... - 인터페이스 및 정적 유틸리티 클래스 비교 - 1 (0) | 2024.02.01 |