지난 번 글에 이어 동시성 문제를 해결해보려고 한다.
java 동시성 제어 [1]java 처리
분산된 여러 서버에서 서버마다 락을 처리해놔도 여러 서버가 존재한다면 동시성은 해결될 수 없다. 그래서 여러 서버가 DB 한곳으로 요청할때 DB에서 락을 걸어 동시성 이슈를 해결하는 방법에 대해 알아보자.
데이터를 저장하고 있다면 당연히 DB를 사용하고 있을것이다. RDB를 사용하고 있는 경우 query를 통해 Lock을 걸어줄 수 있다.
여기서 비관적 락이란 트랜잭션이 발생하면 DB에서 락을 걸어서 대기시키는 것이다.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class DbLockService {
private final MemberRepository memberRepository;
@Transactional
public void post(RequestDto requestDto) {
Optional<MemberEntity> findMember = memberRepository.findByMemberIdForLock();
// 선착순 30명
if (findMember.isPresent()) {
return ;
}
memberRepository.save(requestDto.toModel());
}
}
이전에 소스와는 좀 다르다. 30번째가 되었을때 DB에 lock을 걸어주기 위해 쿼리를 수정해야되기 때문이다.
아래가 수정된 쿼리다.
public interface MemberRepository extends JpaRepository<MemberEntity, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query(value = "select m from MemberEntity m where m.memberId >= 30")
Optional<MemberEntity> findByMemberIdForLock();
}
실행되는 쿼리는
Hibernate: select me1_0.member_id,me1_0.number from member me1_0 where me1_0.member_id>=30 for update
for update
가 추가되어 member_id 값이 30이상인 경우 db lock이 걸려 insert시 대기하게 된다.
JPA의 LockModeType 에서 사용되는 락의 종류에는 대표적으로
LockModeType.NONE: 어떠한 락도 설정하지 않습니다. 즉, 해당 엔티티에 대한 락을 설정하지 않습니다. 이는 기본적으로 JPA가 사용하는 모드이며, 다른 트랜잭션과 충돌 없이 엔티티를 읽거나 수정할 수 있습니다.
LockModeType.OPTIMISTIC: 낙관적 락을 설정합니다. 이 락 모드에서는 엔티티를 읽을 때만 락을 설정하고, 수정 시에는 락을 설정하지 않습니다. 따라서 다수의 트랜잭션이 동시에 엔티티를 읽을 수 있으며, 업데이트 시에만 충돌을 감지하여 처리합니다.
LockModeType.PESSIMISTIC_READ: 비관적 읽기 락을 설정합니다. 이 락 모드에서는 읽기 작업을 수행할 때 다른 트랜잭션이 해당 레코드를 수정하지 못하도록 읽기 락을 설정합니다. 따라서 읽기 작업을 수행하는 트랜잭션이 완료될 때까지 다른 트랜잭션이 해당 레코드를 수정할 수 없습니다.
LockModeType.PESSIMISTIC_WRITE: 비관적 쓰기 락을 설정합니다. 이 락 모드에서는 읽기 작업과 쓰기 작업을 수행할 때 해당 레코드에 대한 쓰기 락을 설정합니다. 따라서 해당 레코드에 대한 수정이 완료될 때까지 다른 트랜잭션이 해당 레코드에 대한 읽기 및 쓰기 작업을 수행할 수 없습니다.
Lock 종류가 존재한다. 각 상황에 맞게 사용하면 된다.
@SpringBootTest
class DbLockServiceTest {
@Autowired
private DbLockService dbLockService;
// 동시성 제어 테스트 코드
@Test
public void testConcurrentReservation() throws InterruptedException {
int totalThreads = 100;
int perThread = 1;
CountDownLatch latch = new CountDownLatch(totalThreads);
ExecutorService executorService = Executors.newFixedThreadPool(totalThreads);
for (int i = 0; i < totalThreads; i++) {
final int finalI = i;
executorService.submit(() -> {
try {
for (int j = 0; j < perThread; j++) {
RequestDto request = RequestDto.builder()
.number(finalI)
.build();
dbLockService.post(request);
}
} finally {
latch.countDown();
}
});
}
latch.await();
executorService.shutdown();
}
}
DB에서 동시성 이슈가 발생했다고 판단되는 경우에 락을 걸어준다.
@Table(name = "O_MEMBER")
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Getter
@Setter
@Entity
public class OptimisticMemberEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;
private int number;
@Version
private int version;
}
동시성 이슈 처리를 위해 version 데이터를 가지고 있을 OptimisticMemberEntity 하나를 추가해준다.
public interface OptimisticMemberRepository extends JpaRepository<OptimisticMemberEntity, Long> {
}
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class DbOptimisticLockService {
private final OptimisticMemberRepository optimisticMemberRepository;
private final MemberRepository memberRepository;
@Transactional
public void post(RequestDto requestDto) {
Optional<OptimisticMemberEntity> findOptimistic = optimisticMemberRepository.findById(1L);
if (findOptimistic.isEmpty()) {
throw new RuntimeException("no data");
}
OptimisticMemberEntity optimisticMemberEntity = findOptimistic.get();
int version = optimisticMemberEntity.getVersion();
if (version > 30) {
throw new RuntimeException("30명 초과");
}
optimisticMemberEntity.setNumber(version);
optimisticMemberEntity.setVersion(++version);
optimisticMemberRepository.save(optimisticMemberEntity);
memberRepository.save(requestDto.toModel());
}
}
@SpringBootTest
class DbOptimisticLockServiceTest {
@Autowired
private DbOptimisticLockService dbOptimisticLockService;
@Autowired
private OptimisticMemberRepository optimisticMemberRepository;
// 동시성 제어 테스트 코드
@Test
public void testConcurrentReservation() throws InterruptedException {
optimisticMemberRepository.save(OptimisticMemberEntity.builder().number(1).version(1).build());
int totalThreads = 1000;
int perThread = 1;
CountDownLatch latch = new CountDownLatch(totalThreads);
ExecutorService executorService = Executors.newFixedThreadPool(totalThreads);
for (int i = 0; i < totalThreads; i++) {
final int finalI = i;
executorService.submit(() -> {
try {
for (int j = 0; j < perThread; j++) {
RequestDto request = RequestDto.builder()
.number(finalI)
.build();
dbOptimisticLockService.post(request);
}
} finally {
latch.countDown();
}
});
}
latch.await();
executorService.shutdown();
}
}
테스트 코드에서 OptimisticMemberEntity 값을 미리 1개 세팅해준다. 이와 같은 예시로는 쿠폰 발급 이벤트를 다음과 같이 처리해줄 수 있을 것이다.
DB락의 경우 잘못 설계했을 때 데드락에 빠질 수도 있고 DB 자체가 빠른 시스템은 아니기 때문에 성능면에서도 이득을 볼수 없다.
하지만 분산 시스템을 사용하면서 Redis나 다른 DB 시스템이나 또 다른 방법을 사용하여 동시성을 처리하지 않고 DB만 가지고도 동시성을 처리할 수 있으니 러닝 커브면에서는 매우 낮다고 할수 있다.