본문 바로가기
MSA

동시성 처리의 중요성과 방법

by notcherry 2024. 1. 26.

 

부트캠프에서 강의를 들으며 추가적으로 공부한 내용을 정리한 글입니다! 부족한 부분이 있다면 댓글 달아주시면 감사하겠습니다. 언제든 환영!

 

동시성 처리의 중요성


동시에 여러 요청이 들어오면 결과값이 정확하게 반영되지 않을 수 있습니다.

예를들어 100개의 재고량에 A가 5개, B가 3개, C 가 1개를 요청했다면, 우리가 생각하기에는 100-5-3-1 을 해서 요청 후 재고량은 91이라고 생각할 수 있습니다. 하지만 만약 요청이 동시처럼 보일만큼 비슷한 시점에 들어왔다면, 커밋 포인트가 95 일수도, 98일수도, 99가 될 수도 있습니다. (락으로 제한하는 방법이 있다)

또한 선착순 이벤트와 같이 순서가 중요한 상황에서는, 동시성 문제를 해결하지 못한 경우 요청이 유실되고 제한수 이상으로 쿠폰이 발급되는 문제가 생길 수 있고, 이벤트가 발생시키는 트래픽으로 인한 다른 서비스에도 영향을 줄 수 있습니다. (레디스를 사용하는 방법이 있다)

 

직접 테스트 코드를 통해 동시성 처리의 중요성을 알려드리겠습니다!

 

 

방법1. synchronized : 자바에서 순서 보장해주기

 

java에서 synchronized는 다중 스레드 환경에서 동기화를 위해 사용되는 키워드입니다. 이는 한 번에 하나의 스레드만이 동기화된 메서드에 접근할 수 있도록 보장해 데이터 무결성을 유지해줍니다. 여러 스레드가 공유 데이터(애그리거트)에 접근하고 수정하는 경우, 하나의 스레드가 해당 데이터에 접근하지 못하도록 하여 데이터 일관성을 유지합니다. 

예를들어, 상품이 배송된 경우에는 배송지 수정을 할 수 없어야 합니다. 이때 판매자가 배송을 시작한 경우, 구매자가 배송지 수정(애그리거트)을 하지 못하도록 막아줘야하는 상황이라고 볼 수 있습니다. 

 

예를들어, 들어오는 요청 수만큼 재고를 감소하는 서비스를 구현하고 아무런 동시성 처리를 해주지 않았을 경우를 테스트코드를 작성하여 실험해봤습니다.

//서비스 코드
@Transactional
public void decrease(Long id, Long count) { //count 수만큼 감소
   Inventory inventory = repository.findById(id).orElseThrow();
   inventory.decrease(count);//감소시킨후
   repository.saveAndFlush(inventory); // 남은 재고 디비에 반영
}

//테스트코드
@Test
    @DisplayName("멀티스레드를 활용해서동시에 100명이 1개를 주문한 상황/ 예상 : 0")
    public void 동시에100개주문() throws InterruptedException {
        int threadCount = 100;  //100개 동시에 넣기
        ExecutorService executorService = Executors.newFixedThreadPool(32); //동시 요청을 도와주는 자바 유틸리티
        CountDownLatch countDownLatch = new CountDownLatch(threadCount); //먼저 끝난 스래드가 대기하도록 교통 정리
        for (int i =0 ;i<100; i++ ){ //반복문으로 100개 요청
            executorService.submit(() -> { //개별 스레드가 호출할 요청
                try{
                    service.decrease(1L,1L); // 동시에 1번 아이템 1개 감소
                } finally {
                    countDownLatch.countDown(); //요청 들어간 스레드는 대기
                }
            });
        }
        countDownLatch.await(); //모든 스레드가 동작을 마치면 병렬처리 종료

        //재고는 0이 남아야 함
        Inventory inventory = repository.findById(1L).orElseThrow();

        assertEquals(0,inventory.getCount());
    }

 

100개의 재고가 있을 때, 100개의 주문이 들어오면 남은 수는 0이어야 합니다. 하지만 실제 결과값을 보면 아래와 같이 100개의 요청 중 11개의 요청만 들어온 것을 확인할 수 있었습니다.

 

 

이때 synchronized를 적용해보겠습니다.

//synchronized를 적용한 서비스 코드
@Transactional
public synchronized void decrease(Long id, Long count) { //count 수만큼 감소
   Inventory inventory = repository.findById(id).orElseThrow();
   inventory.decrease(count);//감소시킨후
   repository.saveAndFlush(inventory); // 남은 재고 디비에 반영
}

 

적용 후 테스트 코드를 돌려보면 이번에는 요청 100개 중 85개의 요청이 들어온 것을 확인할 수 있었습니다. 적용 전보다 유실된 요청의 수가 74개나 줄일 수 있었습니다.

 

완벽하게는 동시성 제어를 할 수 없지만 어느정도의 효과는 볼 수 있었습니다. 제가 이 개념을 배울 때 강사님께서는 synchronized 사용을 추천하지 않으셨습니다. DB쪽에서 통신을 해결해주면 되는데 java단에서부터 통신을 해결하는 방법이라 성능이 떨어지고 프록시 객체를 사용하는 경우, 문제 발생 여지가 있기 때문입니다.

 

그렇다면 백에서 DB로 향할 때 동시성을 보장해주는 방법은 무엇이 있을까요? 바로 LOCK(잠금)이 있습니다. 

 

 

 

비관적 락(선점 잠금) : 충돌이 무조건 생기겠지! 하고 거는 잠금

 

출처: 도메인 주도 개발하기 책

 

사실 트랜잭션은 유저가 1명이 아닌 이상 충분히 일어날 수 있는 이슈기 때문에 비관적락을 거는 것도 좋은 방법이라고 생각합니다. 요청이 내 생각처럼 사이좋게 하나씩 들어오는 것이 아니란 걸 위에서 확인했으니..! 비관적 락을 사용하는 방법은 쉽습니다. 잠금을 선점할 때 lock어노테이션만 붙이면 됩니다.

//레포지터리에서 쿼리문을 날릴때 선점 잠금 
public interface InventoryRepository extends JpaRepository<Inventory, Long> {
 //이 자원은 내가 사용하고 있으니 쓰지도 읽지도 말라는 Lock
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT d FROM Inventory d "
            + "WHERE d.id =:id")
    Inventory findByPessimistic(Long id);
}

 

먼저 선점한 잠금은 메서드가 실행이 끝나면 해제됩니다. 

이렇게 요청이 시작할 때 잠그고, 끝나면 해제하고, 선점된 잠금이 없다면 다음 요청이 잠그고, 해제하고를 반복하며 요청한 100개를 모두 받았습니다. 

 

 

낙관적 락 : 비관적 락보다 더 빡빡한 방법 !

 

선점 잠금으로 동시 접근을 막았다면 비선점 잠금은 데이터를 실제 DBMS에 반영하는 시점에 변경 가능 여부를 확인하여 트랜잭션을 막는 방법입니다. 즉, 테이블에 버전값을 할당하여 먼저 애그리거트를 구한 스레드가 사용이 끝날 때까지 다른 스레드가 해당 애그리거트를 수정하지 못하게 막는 방법입니다.

 

출처: 도메인 주도 개발하기 책

 

낙관적 락은 버전관리를 하기 때문에 필드값에 version을 추가해주어야 합니다. 이 버전은 선점의 해제할 때마다 값이 바뀝니다.

 

비관적 락보다 코드 구현이 좀 깁니다. OptimisticInventoryService는 명칭만 다를 뿐, 위의 서비스 코드와 동일합니다! 서비스 코드에는 변함이 없습니다.

//엔티티에 버전 필드값을 추가합니다.
@Version //낙관적 락에서 정합성을 맞추기 위한 필드
private Long version;
    
//레포지에서 잠금 타입 변경
@Lock(LockModeType.OPTIMISTIC)
@Query("SELECT d FROM Inventory d "
        + "WHERE d.id =:id")
Inventory findByOptimistic(Long id);
    
//잠금을 호출하도록 파사드 서비스 생성
@Service
@RequiredArgsConstructor
public class OptimisticInventoryFacade {
    //파사드 클래스의 역할은 낙관적 락 서비스의 decrease를 반영될때까지 지속적으로 
    //재시도 하는 로직을 service 객체에 래핑
    private final OptimisticInventoryService optimisticInventoryService;

    public void decrease(Long id, Long count) throws InterruptedException {
        while (true) //성공할때까지 호출 -> 추가적인 비용이 발생
        {
            try {
                optimisticInventoryService.decrease(id,count);
                break;
            } catch (Exception e) {
                //락에 의해서 버저닝 정합성이 맞지않아 예외 발생시
                Thread.sleep(100); //0.1초 대기 후 재시도
            }

        }
    }
}

 

잠금이 해제될 때 버전을 올려줘야 하므로 아래와 같이 작업해주었습니다.

 

@Repository
public class OptimisticRepository {
    @PersistenceContext
    private EntityManager manager;

    public Delivery findByIdOptimisticLockMode(Long id) {
        return manager.find(         //버전을 하나씩 증가해줌
                Inventory.class, id, LockModeType.OPTIMISTIC_FORCE_INCREMENT);
    }
}

 

아래와 같이 100개의 요청은 모두 받았지만 비관적 락보다 약 9초 더 오래 걸리는 것을 확인할 수 있었습니다. 이는 낙관적 락이 걸릴 때마다 잠금이 성공할 때까지 호출하는데 서버 비용이 더 많이 들기 때문입니다. 이러한 단점으로 낙관적 락은 정말 필요한 부분에만 적용해야겠다는 생각이 들었습니다.