여태 프로젝트 진행하면서 동시성 문제에 대해 생각하지 않았다.
사실 그럴 힘이 없었다. 근데 자바에서는 멀티 스레드를 지원하는데 문제는 여기서 발생한다.
멀티 스레드 -> 즉 하나의 프로세스에서 여러 개의 스레드가 동시에 작업을 진행한다.
씈크럼 프로젝트에서 스크럼의 참여 인원을 +1 시키는 메서드가 존재한다.
public void plusCurrentMember(){
if(this.maxMember <= this.currentMember){
throw new RuntimeException("더 이상 참여가 불가능합니다.");
}
this.currentMember = currentMember + 1;
}
현재 멤버랑 최대 멤버 카운트 비교해서 자리가 있으면 +1로 참여를 진행시킨다.
싱글 스레드에서는 문제가 없다. 스레드 하나만 요청이 들어오기에 단순 조건을 판단하고 +1을 진행하면 된다.
그러나 멀티 스레드인 경우 문제가 이제 발생한다.
(그림은 양해 부탁드립니다. 그림판으로 그렸습니다)
위 그림을 설명하면 2명의 유저가 하나의 스크럼에 접근했다.
스크럼 -> 최대 멤버 : 20명, 현재 멤버 : 19명
만약에 두 유저가 해당 스크럼 정보를 통해 참여를 진행한다면?
둘 다 현재 참여가 가능하다고 판단해서 Update Query를 날리게 되고, 결국 데이터 정합성에 문제가 발생한다.
나는 여태 이런 경우를 생각하지 않고 진행했었는데 이제는 멀티 스레드에 대해 생각할 시간이 됐다.
간단하게 테스트 코드를 만들어서 진행해보았다.
@Test
public void 스레드_15개_스크럼_참여() throws InterruptedException {
Long scrumId = 7L;
int threadCount = 15;
ExecutorService executorService = Executors.newFixedThreadPool(threadCo
CountDownLatch latch = new CountDownLatch(threadCount);
for(int i=0; i<threadCount; i++){
executorService.submit(() -> {
try{
scrumService.enterTestScrum(scrumId);
}catch (Exception e){
e.printStackTrace();
}finally {
latch.countDown();
}
});
}
latch.await();
executorService.shutdown();
Scrum scrum = scrumRepository.findById(scrumId).orElseThrow();
Assertions.assertEquals(15, scrum.getCurrentMember());
}
15명의 유저를 가상으로 만들어서 동시 요청을 보내게 했고, 현재 스크럼의 유저가 15가 돼야 정상적으로 전부 수행이 가능해진다.
그러나 멀티 스레드로 동작했더니 race condition이 발생해서 2명만 참가한 것으로 나타났다.(실제 서비스에서 이런 동기화 문제를 꼭 생각해야 한다)
이런 데이터 정합성을 맞추기 위해 해결하는 방법은 크게 4가지가 존재한다.
synchronized
자바에서 지원하는 동기화 기법 중 하나이다.
synchronized 키워드를 통해 현재 데이터를 점유하는 스레드 말고는 접근을 못하게 해주는 키워드이다.
@Override
@Transactional
public synchronized void enterTestScrum(Long scrumId) {
Scrum scrum = scrumRepository.findById(scrumId).orElseThrow();
scrum.plusCurrentMember();
}
그래서 Service 메서드에 synchronized 키워드를 달아주어서 다시 실행했는데 동기화를 보장하지 않았다. 왜지?
분명 synchronized 키워드를 통해 해당 service에 동시 접근을 막았으니, 정상적으로 15개의 스레드가 순차적으로 실행된다고 생각했는데 아니었다.
그 이유는 Spring AOP에서 찾을 수 있었다.
@Transactional을 사용하게 되면 AOP로 인해서 enterTestScrum의 프록시 객체가 만들어지게 된다.
public void enterTestScrum(Long scrumId){
1. 트랜잭션 시작
2. 실제 enterTestScrum 시작
3. 커밋
}
그러면 실제 메서드를 호출하기 전후로 트랜잭션을 걸고 커밋을 진행한다.
우리가 실제 Service에는 synchronized를 걸어두었지만, 프록시 객체에는 synchronized가 전달이 안된다고 한다.
결국 enterTestScrum은 우리가 예상했던 대로 thread-safe 하지 않고, 여러 스레드에서 동시에 사용하게 되는 것이다
그래서 @Transactional을 제거하고 동작시켜 보았다.
이번엔 아예 돌지를 않더라. 분명 더티체킹이 동작하지 않나? 싶어서 쿼리문을 보니 전부 select만 나가고 update가 나가지 않았다.
더티 체킹은 트랜잭션 내에서 변화를 감지하고 종료 시점에 자동으로 UPDATE 쿼리를 날려준다.
현재 나는 트랜잭션을 제거했으니 당연히 더티체킹이 돌지 않았던 것. 그래서 수동으로 save를 진행해 주었다.
@Override
// @Transactional
public synchronized void enterTestScrum(Long scrumId) {
Scrum scrum = scrumRepository.findById(scrumId).orElseThrow();
scrum.plusCurrentMember();
scrumRepository.save(scrum);
}
이렇게 동작하니 제대로 성공하는 것을 확인할 수 있다.
그러나 해당 방법의 단점이 존재한다.
-> 하나의 프로세스만 데이터 점유를 판단하고 막기에, 서버가 여러 대인 경우 문제가 발생한다.
위 그림처럼 서버 1, 서버 2에서는 각각 싱크로나이즈를 걸기에 여러 서버에서 요청을 보내는 경우 동기화 문제가 발생한다.
우리는 데이터베이스 레벨에서 데이터 동기화를 진행해주어야 한다.
Lock(락)
- 데이터에 접근하기 위해 얻어야 하는 것
- 데이터당 Lock을 하나 가지고 있어서 하나의 트랜잭션만 접근이 가능. 나머지는 대기 상태
pessimisticLock(비관적 락)
- 실제 데이터에 Lock을 걸어서 정합성을 맞추는 방법.
- exclusive lock을 걸면 다른 트랜잭션에서는 lock이 해제되기 전까지 데이터를 못 가져간다.
- 데드락 발생 가능성 있음
Repository 쿼리 위에 @Lock 어노테이션을 통해 비관적 락을 걸어주었다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Scrum s where s.id =:scrum_id")
Scrum findByIdPessimisticLock(@Param("scrum_id") Long scrumId);
그리고 기존 enterTestScrum은 트랜잭션을 활성화시켜 주었다.
@Override
@Transactional
public void enterTestScrum(Long scrumId) {
Scrum scrum = scrumRepository.findByIdPessimisticLock(scrumId);
scrum.plusCurrentMember();
}
정상적으로 동기화 작업을 성공한 것을 볼 수 있다.
optimisticLock(낙관적 락)
- Lock을 이용하지 않고 버전을 이용해서 관리한다.
- 데이터 읽은 후 update를 수행할 때 내가 읽은 버전과 일치하는지 확인 후 진행.
- 버전이 일치하지 않으면 다시 읽어서 작업 수행한다.
낙관적 락을 프로젝트에 적용은 추후에 진행하겠습니다.
'프로젝트 > 씈크럼 프로젝트' 카테고리의 다른 글
for문을 stream으로 변경하자.(근데 왜 더 느리지?) (0) | 2024.02.24 |
---|---|
내가 원하는 컬럼만 업데이트 쿼리가 나가고싶은데..(feat, DynamicUpdate) (0) | 2024.02.22 |
공통으로 가지는 생성시간, 수정시간을 상속으로 이용해보자. (0) | 2024.02.19 |
QueryDsl로 페이지네이션 도입(검색 API 구현) (1) | 2024.02.17 |