프로젝트를 하던중에 동시성 문제에 대한 이해가 부족한것 같아서 정리해 보았다.
1. 트랜잭션 격리 수준
Isolation
- 트랜잭션의 ACID 원칙중 I에 해당하는 Isolation은 여러 트랜잭션이 동시에 수행될때 다른 트랜잭션이 참조하지 못하는 성질을 말한다.
Isolation Level
트랜잭션의 격리 수준은 트랜잭션이 어느정도 까지 비정합적인 참조를 허용하는지에 대한 것이다.
4가지 단계가 존재한다.
Dirty Read
- 트랜잭션 1이 데이터 수정 중 커밋하지 않아도 트랜잭션 2가 수정중인 데이터 조회 가능
- 트랜잭션 1이 롤백되면 데이터 정합성 문제 발생
Non-Repeatable Read
- 한 트랜잭션 내에서 같은 데이터를 여러번 조회시 각 데이터 상태가 다른것
(중간에 다른 트랜잭션이 데이터 수정해서)
Phantom Read
한 트랜잭션 내에서 where 문 통해 데이터 여러번 조회시 없던 데이터가 나타나는것
(중간에 다른 트랜잭션이 데이터 추가 해서)
격리 수준의 채택
격리 수준이 높을수록
- 동시성 문제를 잘 예방해준다
- 성능 저하 발생한다.
성능이 중요하고 정합성이 안중요하면 낮은 격리수준을 채택하고,
반대면 높은 격리수준을 채택한다.
InnoDB 사용하는 MySQL은 기본적인 격리수준이 Repeatable Read이다.
JPA는 1차 캐시를 동해 Repeatable Read 등급의 격리수준을 애플리케이션 차원에서 제공한다.
2. 락의 종류
트랜잭션 처리중 동시성 문제를 예방하기 위해 락을 걸어준다.
낙관적 락
- Race condition이 빈번히 발생하지 않을것이라고 낙관적으로 생각하는것
- 실제로 락 걸지는 않음
- 애플리케이션 차원에서 버전을 이용해 정합성 맞춤.
- 락 걸지 않아서 성능상 이점
- 충돌 빈번하면 더 느림. 이때는 비관적 락 사용해야함
- 재시도 로직 작성해야함
- JPA @Version 이용함
- @Version + LockModeType.NONE
○ 조회후 엔티티 수정시 버전 증가 (Update 쿼리 사용). 버전 다르면 예외
○ 두번의 갱신 분실 문제 예방
- @Version + LockModeType.OPTIMISTIC
○ 조회하면서도 버전 확인. 버전 다르면 예외
○ Dirty Read, Non-Reapeatable Read 도 방지함
- @Version + LockModeType.OPTIMISTIC_FORCE_INCREMENT
○ 연관관계 설정된 엔티티에서, 수정안된 엔티티의 버전을 강제 증가
○ 논리적인 연관관계 있는 엔티티 버전관리 가능
비관적 락
- Race condition이 빈번히 발생할것이라고 비관적으로 생각하는것
- 실제로 DB에 락을 거는것. select for update 구문이 발생
- 데드락 발생 가능
- 충돌이 빈번하면 낙관락보다 성능 좋음.
- 별도 락을 잡아서 성능 안좋음
- LockModeType.PESSIMISTIC_WRITE
○ 쓰기 락(exclusive lock) 사용
○ Non-Reapeatable Read 방지, 로우락 걸어서 수정 방지
- LockModeType.PESSIMISTIC_READ
○ MySQL - lock in share mode 사용
○ 반복읽기만 하고 수정없을때 사용
○ Non-Reapeatable Read 방지
○ 잘 안씀. 방언 때문에 PESSIMISTIC_WIRTE로 동작 할수도있음
○ 타임아웃 구현 어려움
분산 락
- 분산 환경에서 동시성 문제 예방을 위해 DB 락을 거는것에 관한것.
- MySQL 네임드 락, Redis 이용 등의 방법이 있다.
- 비관락 중에서 select for update와 다르게 get_lock은 타임아웃 구현 쉬움
3. 두번의 갱신 분실 문제
일반적인 데이터베이스 트랜잭션 범위를 넘어서는 문제이다.
ex) A와 B가 동시에 같은 게시물을 수정할 때, A가 먼저 수정완료를 누른 후 B가 수정을 한다면 A의 수정사항은 사라지고 B가 수정한 것만 남는 현상.
트랜잭션은 정상적으로 진행되었다. 게시물을 수정하는 동안 다른 트랜잭션이 게시물을 변경할수 없기 때문. 하지만 논리적으로는 첫번째 트랜잭션이 무시되는 문제가 생긴 상황이다.
3가지 대응 방법이 있다.
1. 마지막 커밋만 인정
A의 내용은 무시하고 마지막 B의 내용만 인정
2. 최초 커밋만 인정
A가 이미 수정을 완료했으므로 B가 수정완료 할땐 오류 발생
JPA @Version 이용하면 최초 커밋만 인정하기 구현 가능.
3. 충돌하는 갱신 내용 병합
사용자 A와 사용자B의 수정사항을 병합
4. 동시성 문제 예시와 해결방법
모든 예시는 JPA, Spring Data, MySQL 사용함.
a. 동시성 고려하지 않기
예시 상황
이커머스 시스템. 주문 취소하는 상황
해결방법
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
// @Transactional 트랜잭션 없음.
public void cancelOrder(Long orderId){
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new EntityNotFoundException("엔티티 없음"));
order.setStatus("주문 취소");
orderRepository.saveAndFlush(order);
}
}
@Test
public void 동시에_100명이_주문취소() throws InterruptedException {
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
orderService.cancelOrder(1L);
} finally {
latch.countDown();
}
});
}
latch.await();
Order order = orderRepository.findById(1L).orElseThrow();
Assertions.assertThat("주문 취소").isEqualTo(order.getStatus());
}
서비스 코드에 트랜잭션을 걸지 않았다.
1. 동시성 문제 고려 안해도 됨
주문 상태를 "주문 취소"로 바꾸기만 하면됨. 이때 먼저 실행된 트랜잭션이건 나중에 실행된 트랜잭션이건 상관이 없음.
마치 멱등성과 비슷함.
2. 성능 측면 이점 있음
- 스프링 데이터를 사용하니까, 어차피 repository 에서 트랜잭션이 걸림
- 트랜잭션은 범위를 작게 잡아야 성능상 좋음.
- 커넥션을 오랜시간 붙잡고 있으면 안됨. 모든 메소드를 트랜잭션으로 묶으면 커넥션을 오래 잡고 있음.
- 위 예시에서 find(), saveAndFlush() 각각 트랜잭션이 걸린다.
- 변경감지 동작 안하니까 saveAndFlush() 명시적으로 호출해줌.
b. sychronized 사용
예시 상황
이커머스 시스템. 주문 수량을 감소시킨다
해결방법
// @Transactional 트랜잭션 없음.
public synchronized void decreaseAmount(Long orderId, Long amount){
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new EntityNotFoundException("엔티티 없음"));
order.decrease(amount);
orderRepository.saveAndFlush(order);
}
@Test
public void 동시에_100명이_수량감소() throws InterruptedException {
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
orderService.decreaseAmount(1L, 1L);
} finally {
latch.countDown();
}
});
}
latch.await();
Order order = orderRepository.findById(1L).orElseThrow();
Assertions.assertThat(order.getAmount()).isEqualTo(0);
}
synchronized를 통해 동기화 해주었다.
1. 동작
synchronized 없으면 동시성 문제 발생함. 갱신 분실 현상이 나타남.
synchronized 이용해서 하나의 쓰레드만 메소드 사용 가능하게 하여 해결.
2. @Transactional 사용해선 안된다.
Target 대신 사용되는 트랜잭션 AOP에 의해 DI된 Proxy 객체 구조상 트랜잭션을 커밋하기 전에 다른 쓰레드가 로직을 실행시킬수 있기 때문이다.
예시)
//ProxyOrderService
startTx();
decrease(); // 이 메소드 끝나고 바로 커밋되는게 아니라 그 뒤에 endTx(); 호출해야 커밋되니까 동시성 문제 발생
endTx();
3. 단일 서버에서만 사용 가능.
서버가 스케일 아웃된 상황이라면, 각 서버는 단일 쓰레드를 가진거처럼 동작하더라도 결국 DB 접근은 여러 쓰레드(프로세스)가 접근하게 되기 때문.
c. UDDATE 문 사용
예시 상황
b 상황과 같음. 이커머스 시스템. 주문 수량을 감소시킨다.
해결방법
//UPDATE 이용
// @Transactional
public void decreaseAmountUpdate(Long orderId){
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new EntityNotFoundException("엔티티 없음"));
orderRepository.decreaseAmount(order.getId());
}
public interface OrderRepository extends JpaRepository<Order, Long> {
@Transactional
@Modifying
@Query("UPDATE Order o SET o.amount = o.amount - 1 WHERE o.id = :id")
void decreaseAmount(@Param("id") Long id);
}
//update 이용
@Test
public void 동시에_100명이_수량감소_update() throws InterruptedException {
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
orderService.decreaseAmountUpdate(1L);
} finally {
latch.countDown();
}
});
}
latch.await();
Order order = orderRepository.findById(1L).orElseThrow();
Assertions.assertThat(order.getAmount()).isEqualTo(0);
}
Update 쿼리 이용함.
Update 문을 이용하면 조회와 변경이 따로 일어나는것이 아니라 한번에 일어나니까 동시성 문제 (lose update 갱신 분실)가 발생하지 않는다.
위와같이 간단한 예시에서는 사용가능하다. 하지만 조회와 변경이 복잡하게 일어나는 비즈니스 로직에는 적용할수 없다.
d. 낙관적 락
예시 상황
b 상황과 같음. 이커머스 시스템. 주문 수량을 감소시킨다.
해결방법
//낙관적 락 이용
@Transactional
public void decreaseAmountOptimistic(Long orderId, Long amount){
Order order = orderRepository.findByIdWithOptimisticLock(orderId)
.orElseThrow(() -> new EntityNotFoundException("엔티티 없음"));
order.decrease(amount);
}
}
@Lock(value = LockModeType.OPTIMISTIC)
@Query("select o from Order o where o.id = :id")
Optional<Order> findByIdWithOptimisticLock(@Param("id") Long id);
@Component
@RequiredArgsConstructor
public class OrderFacade {
private final OrderService orderService;
public void decrease(Long id, Long amount) throws InterruptedException {
while (true) {
try {
orderService.decreaseAmountOptimistic(id, amount);
break;
} catch (Exception e) {
Thread.sleep(50);
}
}
}
}
//낙관적 락 이용
@Test
public void 동시에_100명이_수량감소_낙관락() throws InterruptedException {
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
orderFacade.decrease(1L,1L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
latch.countDown();
}
});
}
latch.await();
Order order = orderRepository.findById(1L).orElseThrow();
Assertions.assertThat(order.getAmount()).isEqualTo(0);
}
낙관적 락을 이용해 해결.
엔티티에 @Version Long version; 추가,
OrderFacade 추가해서 재시도 로직 구현.
엔티티를 조회하면서 버전을 확인하고, 만약 버전이 다르면 예외 발생시킨다.
갱신 분실 문제가 예방된다.
e. 비관적 락 - select for update
예시 상황
b 상황과 같음. 이커머스 시스템. 주문 수량을 감소시킨다.
해결방법
@Lock(value = LockModeType.PESSIMISTIC_WRITE)
@Query("select o from Order o where o.id = :id")
Optional<Order> findByIdWithPessimisticLock(@Param("id") Long id);
비관적 락을 걸어준다. select for update 쿼리가 발생한다.
실제로 DB 데이터에 락이 걸린다.
f. 분산 락 - MySQL 네임드 락
예시 상황
여러 서버가 존재하는 이커머스 시스템. 주문 수량을 감소시킨다
해결방법
public interface LockRepository extends JpaRepository<Order, 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);
}
@Transactional
public void decrease(Long id, Long amount) {
try {
lockRepository.getLock(id.toString());
orderService.decrease(id, amount);
} finally {
lockRepository.releaseLock(id.toString());
}
}
네임드 락을 획득하고 반납하는 기능을 레포지토리에 추가한다.
파사드 계층을 두어 공통 로직을 처리한다.
네임드 락은 select for update 보다 타임아웃 구현이 쉽다
g. 분산 락 - Redis & Lettuce
예시 상황
여러 서버와 여러 데이터베이스가 존재하는 이커머스 시스템. 주문 수량을 감소시킨다
해결방법
public void decrease(Long key, Long quantity) throws InterruptedException {
while (!redisLockRepository.lock(key)) {
Thread.sleep(100);
}
try {
orderService.decrease(key, quantity);
} finally {
redisLockRepository.unlock(key);
}
}
@Component
public class RedisLockRepository {
private RedisTemplate<String, String> redisTemplate;
public RedisLockRepository(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public Boolean lock(Long key) {
return redisTemplate
.opsForValue()
.setIfAbsent(generateKey(key), "lock", Duration.ofMillis(3_000));
}
public Boolean unlock(Long key) {
return redisTemplate.delete(generateKey(key));
}
private String generateKey(Long key) {
return key.toString();
}
}
- setnx (set if not exist) 명령어 이용해 분산락 구현. 기존 값 없을때만 데이터 삽입
- spin lock 방식임. 재시도 로직 직접 작성해야함
- mysql named lock 과 비슷함. 레디스를 활용하고 세션관리 신경 안써도 됨.
- 구현이 간단함
- 스핀락 방식이라 레디스 부하 심함
- 기본 라이브러리임. 별도로 사용할 필요없음
h. 분산 락- Redis & Redisson
예시 상황
여러 서버와 여러 데이터베이스가 존재하는 이커머스 시스템. 주문 수량을 감소시킨다.
해결방법
public void decrease(Long key, Long amount) {
RLock lock = redissonClient.getLock(key.toString());
try {
boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS);
if (!available) {
System.out.println("lock 획득 실패");
return;
}
orderServiec.decrease(key, amount);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
- pub - sub 기반으로 Lock 구현함. 해제 이벤트 발생하면 락 획득
- 메시지 와야 락 획득 시도하니까 레디스 부하 적음
- 구현이 복잡함
- 별도 라이브러리 사용해야함
- 락을 라이브러리 차원에서 공해줌.
- 재시도 필요한 경우 Redisson, 필요없으면 lettuce 사용하기
락 선택에 대한 내 생각
1. 동시성 문제 자주 발생 안하는 환경
- 낙관적 락 사용하기
○ 동시성 문제 자주 발생 안하면 속도가 빠르기 때문
2. 동시성 문제 자주 발생하는 환경
- select for update 보다는 분산 락 사용하기
○ select for update는 타임아웃 설정이 힘들어서 무한대기 할수 있음
- 레디스를 사용하거나 사용할 예정이라면 Redis + Lettuce/Redisson 사용하기
○ 레디스는 인메모리 DB라서 속도가 빠름
- 레디스 안쓰면 mysql named lock 사용하기
○ 오직 락때문에 레디스 쓰면 관리비용이 클수 있음.
○ select for update 는 타임아웃 설정이 힘듦
참고
https://milenote.tistory.com/156#------%--JPA%--%EB%B-%--%EA%B-%--%EC%A-%--%--%EB%-D%BD
https://techblog.woowahan.com/2631/
https://dkswnkk.tistory.com/681
inflearn: 재고시스템으로 알아보는 동시성이슈 해결방법
'Dev > JPA' 카테고리의 다른 글
API 설계시 DTO를 주고받아야 한다. 엔티티 말고 (0) | 2021.06.29 |
---|---|
[강의정리] N+1 문제와 JPQL fetch join을 통한 해결 (0) | 2021.05.21 |
[강의정리] 값 타입 (0) | 2021.05.21 |
[강의정리] 영속성 전이, 고아객체, 생명주기 (0) | 2021.05.18 |
[강의정리] 프록시와 연관관계 관리 (0) | 2021.05.18 |