[Spring/DB] Spring에서 데이터 동시성 제어하기
서론
DB Lock 포스팅에서 데이터베이스에서 동시성 처리를 위한 Lock에 대해 공부했다. 뿐만 아니라 공유 락, 배타 락과 같이 블로킹과 데드락이 발생할 수 있는 조건을 알 수 있었다.
Spring Framework 개발 환경에서 어플리케이션 레벨에서의 Lock 기법과 데이터베이스 레벨에서의 Lock 기법, 그리고 Spring 내에서 지원하는 Lock 기법에 대해 공부해보고자 한다.
낙관적 락
- 낙관적 락은 데이터의 수정 충돌이 거의 발생하지 않을 것이라고 낙관적으로 가정하는 방법이다.
- 데이터의 일관성, 정합성 보다 성능 위주의 어플리케이션에서 사용된다.
- 여러 트랜잭션의 접근할 수 있도록 허용하고, DB의 Lock 기능을 사용하지 않고 어플리케이션 레벨에서 동시성을 제어한다.
- Spring Data JPA에서는
@Version
어노테이션을 이용해서 구현할 수 있다.
Entity
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import ...
@Entity
public class SampleEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long quantity;
@Version
private Long version;
public void decrease(final Long quantity) {
if (this.quantity - quantity < 0) {
throw new RuntimeException("재고는 0개 미만이 될 수 없습니다.");
}
this.quantity -= quantity;
}
}
- 우선 낙관적 락을 적용하려면, Entity에 멤버 변수로
@Version
어노테이션을 추가한version
을 선언해야한다.- 해당 레코드가 업데이트 될 때마다
version
값이 1씩 올라간다.
Repository
1
2
3
4
5
6
7
8
9
10
import ...
@Repository
public interface SampleEntityRepository extends JpaRepository<SampleEntity, Long> {
@Lock(LockModeType.OPTIMISTIC)
@Query("select s from SampleEntity s where s.id = :id")
@Transactional
SampleEntity findByIdWithOptimisticLock(@Param("id") Long id);
}
낙관적 락을 이용한 조회를 하도록 Repository에
@Lock(LockModeType.OPTIMISTIC)
어노테이션과 JPQL Query를 추가한 select 조회 함수를 선언해주었다.
Service
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import ...
@Service
@RequiredArgsConstructor
public class SampleEntityService {
private final SampleEntityRepository sampleEntityRepository;
@Transactional
public void updateOptimisticSampleEntity(Long id){
SampleEntity sampleEntity = sampleEntityRepository.findByIdWithOptimisticLock(id);
sampleEntity.decrease(1L);
sampleEntityRepository.saveAndFlush(sampleEntity);
}
}
updateOptimisticSampleEntity
메소드에서 수량을 감소하는 로직을 작성했다.
Facade
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import ...
@Component
@RequiredArgsConstructor
public class SampleEntityFacade {
private final SampleEntityService sampleEntityService;
public void updateEntityOptimistic(Long id) throws InterruptedException {
int retryCount = 0;
int maxRetry = 5; // 최대 재시도 횟수
while (retryCount < maxRetry) {
try {
sampleEntityService.updateOptimisticSampleEntity(id);
break; // 성공적으로 업데이트되면 반복 종료
} catch (Exception e) {
retryCount++;
System.out.println("낙관적 락 충돌 발생, 재시도: " + retryCount);
if (retryCount >= maxRetry) {
throw e;
}
Thread.sleep(50); // 잠시 대기 후 재시도
}
}
}
}
- 낙관적 락에서는 데이터베이스 레벨의 락이 아닌 어플리케이션 레벨 락이기 때문에 멀티 스레드 환경에서 Entity의
decrease()
를 호출하게 되면 version 충돌로 인해 원하는 만큼 수량의 감소가 일어나지 않을 수도 있다.- 충돌이 발생할 경우 아래와 같이
OptimisticLockException
이 발생하는데, 이를 방지하기 위해 재시도 로직을 Facade 클래스를 통해 작성했다.
Test
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
import ...
import static ...
@SpringBootTest
class SampleEntityServiceTest {
@Autowired
private SampleEntityRepository sampleEntityRepository;
@Autowired
private SampleEntityService sampleEntityService;
@Autowired
private SampleEntityFacade sampleEntityFacade;
@BeforeEach
void setUp(){
SampleEntity sampleEntity = new SampleEntity();
sampleEntity.setId(1L);
sampleEntity.setQuantity(100L);
sampleEntityRepository.save(sampleEntity);
}
@AfterEach
void tearDown() {
sampleEntityRepository.deleteAll();
}
@Test
@DisplayName("낙관적 락 테스트")
void optimisticLockTest() {
sampleEntityService.updateOptimisticSampleEntity(1L);
}
@Test
@DisplayName("낙관적 락 순차적 테스트")
void optimisticLockTestNonThread10() throws InterruptedException {
final int threadCount = 10;
for(int i=0;i<threadCount;i++){
sampleEntityFacade.updateEntityOptimistic(1L);
}
SampleEntity sampleEntity = sampleEntityRepository.findByIdWithOptimisticLock(1L);
assertEquals(90L, sampleEntity.getQuantity());
}
@Test
@DisplayName("낙관적 락 동시성 테스트")
void optimisticLockTest10() throws InterruptedException{
final int threadCount = 10;
final ExecutorService executorService = Executors.newFixedThreadPool(8);
final CountDownLatch countDownLatch = new CountDownLatch(threadCount);
for(int i=0;i<threadCount;i++){
executorService.submit(() -> {
try{
sampleEntityFacade.updateEntityOptimistic(1L);
} catch (InterruptedException | OptimisticLockException | StaleStateException e) {
System.out.println("Exception occurred: " + e.getMessage());
} finally{
countDownLatch.countDown();
}
});
}
// 모든 스레드 작업 완료 대기
countDownLatch.await();
executorService.shutdown();
SampleEntity sampleEntity = sampleEntityRepository.findByIdWithOptimisticLock(1L);
System.out.println(sampleEntity.getVersion());
assertEquals(90L, sampleEntity.getQuantity());
}
}
테스트 케이스를 decrease 로직 1번 실행, 반복문을 통한 10번 실행, 멀티 스레드 환경에서 10번 실행으로 정하고 실행해보았다.
테스트 결과
동시성 테스트의 결과는 아래와 같이 실패가 발생할 수도, 성공으로 끝날 수도 있다.
이유는 Race Condition(경합 상태)이 발생할 수 있기 때문이다. 충돌이 발생해서 원하는 version에 대한 데이터가 이미 업데이트가 됐을 수도 있다.
동시성 테스트 실패 사례
동시성 테스트 성공 사례
비관적 락
- 비관적 락은 데이터의 수정 충돌이 무조건 발생할 것이라고 비관적으로 가정하는 방법이다.
- 데이터의 일관성, 정합성이 중요한 어플리케이션에서 사용된다.
- DB의 Lock 기능을 사용해서 데이터베이스 레벨에서 동시성을 제어한다.
- Spring Data JPA에서는
@Lock의 속성인 LockModeType
을 통해 공유 락, 배타적 락을 구현할 수 있다.
💡 Entity는 위 낙관적 락에서 이용했던 SampleEntity
를 그대로 이용할 것이다.
Repository
1
2
3
4
5
6
7
8
9
import ...
@Repository
public interface SampleEntityRepository extends JpaRepository<SampleEntity, Long> {
@Transactional
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from SampleEntity s where s.id = :id")
SampleEntity findByIdWithPessimisticLock(@Param("id") Long id);
}
@Lock
어노테이션의LockModeType
옵션에 따라 아래와 같은 Lock이 적용된다.
LockModeType.PESSIMISTIC_WRITE
: 배타적 락(FOR UPDATE
) 쿼리 수행LockModeType.PESSIMISTIC_READ
: 공유 락(FOR SHARE
) 쿼리 수행LockModeType.PESSIMISTIC_FORCE_INCREMENT
: 배타적 락(FOR UPDATE
) 쿼리와, version 상승이 혼합된 Lock 방식- 테스트는
LockModeType.PESSIMISTIC_WRITE
방식으로 진행했다.
Service
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import ...
@Service
@RequiredArgsConstructor
public class SampleEntityService {
private final SampleEntityRepository sampleEntityRepository;
@Transactional
public void updatePessimisticSampleEntity(Long id) throws InterruptedException{
SampleEntity sampleEntity = sampleEntityRepository.findByIdWithPessimisticLock(id);
sampleEntity.decrease(1L);
sampleEntityRepository.saveAndFlush(sampleEntity);
}
}
Test
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import ...
@SpringBootTest
class SampleEntityServiceTest {
@Autowired
private SampleEntityRepository sampleEntityRepository;
@Autowired
private SampleEntityService sampleEntityService;
@Autowired
private SampleEntityFacade sampleEntityFacade;
@BeforeEach
void setUp(){
SampleEntity sampleEntity = new SampleEntity();
sampleEntity.setId(1L);
sampleEntity.setQuantity(100L);
sampleEntityRepository.save(sampleEntity);
}
@AfterEach
void tearDown() {
sampleEntityRepository.deleteAll();
}
@Test
@DisplayName("비관적 락 테스트")
void pessimisticLockTest() throws InterruptedException {
sampleEntityService.updatePessimisticSampleEntity(1L);
}
@Test
@DisplayName("비관적 락 동시성 테스트")
void pessimisticLockTest10() throws InterruptedException{
final int threadCount = 10;
final ExecutorService executorService = Executors.newFixedThreadPool(1);
final CountDownLatch countDownLatch = new CountDownLatch(threadCount);
for(int i=0;i<threadCount;i++){
executorService.submit(() -> {
try{
sampleEntityService.updatePessimisticSampleEntity(1L);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally{
countDownLatch.countDown();
}
});
}
// 모든 스레드 작업 완료 대기
countDownLatch.await();
executorService.shutdown();
SampleEntity sampleEntity = sampleEntityRepository.findByIdWithPessimisticLock(1L);
assertEquals(10L, sampleEntity.getVersion());
assertEquals(90L, sampleEntity.getQuantity());
}
}
- 비관적 락 옵션을 통해 레코드를 조회하게 되면 아래 처럼
SELECT ... FOR UPDATE
쿼리를 통해 조회하게 된다.- 비관적 락을 통해 동시성 처리를 하게 되면, 데이터베이스 레벨의 배타적 락을 사용하기 때문에 성능이 낮아질 수 있지만 데이터의 정합성을 지킬 수 있다.
비관적 락 테스트 성공 사례
비관적 락 동시성 테스트 성공 사례
네임드 락
네임드 락은 임의로 락의 이름을 설정하고, 해당 락을 사용해서 동시성을 처리하는 방식의 동시성 제어 방식이다.
네임드 락도 비관적 락과 마찬가지로 데이터베이스 레벨에서 제어되는 락이다.
차이점으로는,
- Lock을 설정하는 대상
- 비관적 락은
sample_entity
테이블의 인덱스에 락이 걸리게 된다. - 네임드 락은 MySQL의 별도 저장 공간에 Lock을 설정하게 된다.
- 비관적 락은
- Lock의 해제 시점
- 비관적 락은 Lock을 가진 트랜잭션이 종료되면 자동으로 Lock이 해제되어 대기중인 트랜잭션 작업이 수행될 수 있다.
- 네임드 락은 트랜잭션의 종료와 Lock의 해제 시점에 관련 없이, 정해진 timeout 시간이 지나면 Lock이 해제된다.
비관적 락은 분산 서버 환경에서 처리되어지기 어렵다는 단점이 있는데, 네임드 락은 분산 락의 기법으로 많이 사용된다.
우아한 기술 블로그 - 분산 락 적용기
Repository
1
2
3
4
5
6
7
8
9
10
11
import ...
@Repository
public interface LockRepository extends JpaRepository<SampleEntity, Long> {
@Query(value = "select get_lock(:key, 3000)", nativeQuery = true)
void getLock(String key);
@Query(value = "select release_lock(:key)", nativeQuery = true)
void releaseLock(String key);
}
- getLock 메소드 : get_lock DB 함수에 인자로 key와 timeout(ms)을 주어 NamedLock을 설정한다.
- releaseLock 메소드 : release_lock DB 함수를 통해 해당 key의 NamedLock을 해제한다.
Service
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import ...
@Service
@RequiredArgsConstructor
public class SampleEntityService {
private final SampleEntityRepository sampleEntityRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void decrease(Long id){
SampleEntity sampleEntity = sampleEntityRepository.findById(id).orElseThrow(() -> new RuntimeException("Entity not found"));
sampleEntity.decrease(1L);
sampleEntityRepository.saveAndFlush(sampleEntity);
}
}
- 낙관적 락, 비관적 락과 다르게 트랜잭션 전파 전략을
Propagation.REQUIRES_NEW
로 해서, 비즈니스 로직의 트랜잭션을 별도로 분리해야한다. - 네임드 락은 락의 해제와 트랜잭션의 커밋 시점이 관련이 없기 때문에 비즈니스 로직의 트랜잭션을 별도로 분리해서 비즈니스 로직이 커밋된 후에 네임드 락을 해제해야한다.
Facade
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import ...
@Component
@RequiredArgsConstructor
public class NamedLockFacade {
private final LockRepository lockRepository;
private final SampleEntityService sampleEntityService;
@Transactional
public void updateNamedSampleEntity(Long id){
try{
lockRepository.getLock(id.toString());
sampleEntityService.decrease(id);
}finally {
lockRepository.releaseLock(id.toString());
}
}
}
💡 만약 Lock 설정/해제와 비즈니스 로직이 하나의 트랜잭션으로 묶인다면?
Thread A와 B에서 한 레코드의 수량 감소 로직을 동시에 요청했다고 가정하면, Thread A에서 네임드 락을 설정하고, 수량 감소 로직을 실행한 뒤에 네임드 락을 해제한다.
여기서 해당 레코드의 수량 감소 로직을 실행한 트랜잭션이 커밋되는 시점이 중요하다. 하나의 트랜잭션에서 이뤄진다면 네임드 락 해제가 마지막 로직이기 때문에 네임드 락 해제 후 트랜잭션이 커밋될 것이다.
이 때 Thread B의 입장에서는 네임드 락에 의해 블로킹 상태로 대기하고 있다가, 네임드 락이 해제되는 순간 자신의 수량 감소 로직을 실행한다. 하지만 수량 감소 로직이 커밋되지 않았기 때문에 Thread A에서 수량 감소 로직이 실행되기 전의 데이터를 조회하게 되므로 낙관적 락처럼 누락이 발생할 것이다.
Test
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import ...
@SpringBootTest
class NamedLockFacadeTest {
@Autowired
private SampleEntityRepository sampleEntityRepository;
@Autowired
private NamedLockFacade namedLockFacade;
@BeforeEach
void setUp(){
SampleEntity sampleEntity = new SampleEntity();
sampleEntity.setId(1L);
sampleEntity.setQuantity(100L);
sampleEntityRepository.save(sampleEntity);
}
@AfterEach
void tearDown() {
sampleEntityRepository.deleteAll();
}
@Test
@DisplayName("네임드 락 동시성 테스트")
void namedLockTest10() throws InterruptedException {
final int threadCount = 10;
final ExecutorService executorService = Executors.newFixedThreadPool(8);
final CountDownLatch countDownLatch = new CountDownLatch(threadCount);
for(int i=0;i<threadCount;i++){
executorService.submit(() -> {
try{
namedLockFacade.updateNamedSampleEntity(1L);
} catch (Exception e) {
System.out.println("Exception occurred: " + e.getMessage());
} finally{
countDownLatch.countDown();
}
});
}
// 모든 스레드 작업 완료 대기
countDownLatch.await();
executorService.shutdown();
SampleEntity sampleEntity = sampleEntityRepository.findByIdWithOptimisticLock(1L);
System.out.println(sampleEntity.getVersion());
assertEquals(90L, sampleEntity.getQuantity());
}
}
트랜잭션을 분리해야하기 때문에 커넥션도 2개를 사용하게 된다.
네임드 락 동시성 테스트 성공 사례
3가지 Lock 장단점 비교
낙관적 락
- 장점
- 데이터베이스 레벨에서 락을 설정하지 않기 때문에 하나의 트랜잭션 작업이 길어질 때 다른 트랜잭션 작업이 영향을 받지 않아 성능에 이점이 있을 수 있다.
- 단점
OptimisticLockException
과 같은 예외가 발생할 때 재시도 로직을 구현해야한다.- 재시도 로직을 타게 되면 성능에 이슈가 있을 수 있다.(빈번한 동시 수정이 발생하면 성능에 좋지 않다.)
비관적 락
- 장점
- Race Condition(경합 상태)이 자주 일어난다면 낙관적 락보다 성능이 좋다.
- 데이터베이스 레벨의 Lock을 통해 동시성을 제어하기 때문에 데이터의 정합성이 보장된다.
- 단점
- 데이터베이스 레벨에서 Lock을 설정하기 때문에 한 트랜잭션 작업이 정상적으로 끝나지 않으면 다른 트랜잭션 작업들이 대기해야하므로 성능이 감소할 수 있다.
네임드 락
- 장점
- 여러 대의 서버가 있을 때 사용 가능하다.
- 분산 락을 구현할 수 있다.
- Lock의 대상이 별도로 Lock을 위한 공간에 Lock을 설정하기 때문에 같은 key의 네임드 락을 사용하는 작업 외의 작업은 영향을 받지 않는다.
- 데이터 UPDATE 작업이 아닌 INSERT 작업의 경우에는 기준을 잡을 레코드가 존재하지 않아 비관적 락을 사용할 수 없는데, 이 때 네임드 락을 사용할 수 있다.
- 여러 대의 서버가 있을 때 사용 가능하다.
- 단점
- 트랜잭션 종료 시에 Lock 해제, 세션 관리 등을 수동으로 직접 처리해야하기 때문에 구현이 복잡해질 수 있다.
결론
낙관적 락과 비관적 락은 데이터의 수정 충돌, Race Condition(경합 과정)의 빈도수에 맞게 사용하고, 서버가 1대가 아닌 여러 대와 같은 분산된 아키텍처를 가지고 있다면 분산 락을 네임드 락을 통해 구현해서 사용한다.
비관적 락과 낙관적 락을 더 자세히 말하면, 충돌이 자주 일어날 것이라고 예상되면 비관적 락을, 충돌이 빈번하지 않지만 동시성을 지켜야한다면 낙관적 락을 사용하는 것이 좋다.
이후에는 MySQL에서 락이 어떻게 구현되는 지 깊게 알아볼 것이다.