youngseo's TECH blog

[Java|Spring] 동시성 문제 본문

BackEnd

[Java|Spring] 동시성 문제

jeonyoungseo 2024. 7. 21. 14:18

Lost Update 문제

Tomcat(Java/SpringBoot)와 같은 멀티쓰레드 환경에서 트랜잭션 격리 수준으로 온전히 해결되지 않는 동시성 문제가 있다. 바로 Lost Update 현상이다. 아래 예시를 살펴보자. A 트랜잭션이 data를 0으로 읽었고, B 트랜잭션이 끼어들어 data를 0으로 읽었다. (잘못 읽었음에도) A는 +1 연산을 수행하고 B 또한 +1 연산을 수행한다. 최종적으로 원한 값은 2였으나 Update 하나를 잃어버려 +1의 결과값을 갖게 되었다.

결론부터 말하면, 이 문제를 해결하기 위해 DB Lock 을 사용할 수 있다.


문제가 되는 코드

아래는 Member가 WishList를 추가하는 과정에서 Portfolio에 wishListCount 필드를 +1 해주는 과정이다.

우선 요청이 차례대로 한 개씩 들어오는 테스트를 작성해보았다.

역시 예상대로 요청이 차근차근 한 개씩 들어와 100개의 wishcount가 쌓였음을 볼 수 있다.

그러면 이번에는 java의 동시성  관련 클래스(ExecutorService, CountDownLatch)를 이용하여 멀티쓰레드 환경을 구축하고 동시에 100개의 요청이 한 번에 들어오도록 테스트해보자.

@SpringBootTest
public class WishListConcurrencyTest {

    @Autowired
    private PortfolioRepository portfolioRepository;

    private Portfolio portfolio;

    @BeforeEach
    public void before() {

        portfolio = portfolioRepository.findById(1L).orElseThrow();
    }

    @Test
    public void 동시에_100개_요청() throws InterruptedException {
        int threadCount = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(32);
        CountDownLatch countDownLatch = new CountDownLatch(threadCount);
        for (int i = 0; i < threadCount; i++) {
            executorService.submit(() -> {
                try {
                    portfolio.addWishListCount();
                    portfolioRepository.saveAndFlush(portfolio);
                } finally {
                    countDownLatch.countDown();
                }
            });
        }
        countDownLatch.await();

        Portfolio updatedPortfolio = portfolioRepository.findById(portfolio.getId()).orElseThrow();
        //assertEquals(updatedPortfolio.getWishListCount(), 100); -> 100이 아닌 경우가 발생
    }
}

그러면 아래와 같이 100개의 +1 Update 연산이 온전히 실행되지 못해 12개의 wishcount만 조회됨을 볼 수 있다.


해결 방법

1. synchronized 로 해결하기
portfolio.addWishListCount(); 메소드에서 Race Condition이 일어난다. 따라서 이 프로세스를 synchronized 시켜 스레드를 동기화 시킬 수 있다. 이를 통해 멀티스레드 환경에서 여러 스레드가 하나의 공유자원에 동시에 접근하지 못하도록 막을 수 있다.

하지만 synchronized는 @Transactional 어노테이션과 같이 쓸 수 없다는 한계가 있다.  간단히 설명하면 @Transactional의 경우에 Spring AOP의 프록시 개념으로 만들어지는데 synchronized 의 경우에는 메서드 시그니처에 포함되지 않아 AOP에 적용되지 않는다.

2. 낙관적 락
낙관적 락의 경우 말 그대로 낙관적으로 상황을 바라볼 때 사용된다. (동시성 문제가 생길 수도 있겠지~ 하지만 안 생길 수도 있잖아??) 락을 실제로 이용하지 않고 Version을 사용하여 정합성을 맞추는 방법이다. 어플리케이션 레벨(Entity)에서 지원하는 @Version을 통해 현재 내가 읽은 버전이 맞는지 확인한 후에 업데이트하고, 이를 통해 최초 커밋만 인정하게 된다.

UPDATE BOARD
SET
  title = ?,
  version = ? # 버전 + 1 증가
WHERE
  id = ?,
  and version = ? # 버전 비교

하지만 치명적인 단점 중 하나는 업데이트가 실패했을 때 어떻게 대처할지에 대한 로직을 개발자가 직접 적어주어야 한다는 것이다. 버전이 안 맞아서 update를 못하면 재시도하는 로직을 짜야 한다. 한 번의 재시도는 구현하기 쉬울 수도 있지만 만약,, 재시도의 실패로 인한 재시도의 실패로 인한 재시도..를 해야 한다면..??

추가적으로 좀 더 딥하게 들어가면 JPA가 엔티티를 수정하고 트랜잭션을 커밋하는 시점에 영속성 컨텍스트를 flush하면서 UPDATE문을 실행한다. 근데 이미 엔티티가 수정되었다면 where문을 찾을 수가 없게 된다. 결국 수행 불가다..

3. 비관적 락
비관적 락도 말 그대로 동시성 상황을 비관적으로 바라보고, 넌 어차피 동시성 문제가 생길 수 밖에 없을 거야 ! 하며 락을 걸어 보장하는 것을 말한다. 비관적락 W을 A 트랜잭션에서 걸 경우 다른 B 트랜잭션에서는 W를 걸지 못한다. 이 때 Select ... For Update 문을 사용하게 된다.

하지만 실제로 락을 건다는 측면에서 성능 이슈가 발생 가능하고, 동시에 접근하는 트랜잭션이 많아지면 많아질수록 API 콜의 대기 시간은 늘어나게 된다.즉, 실행 중인 트랜잭션이 끝날 때까지 기다려야 한다.


결론

1차 배포

해결 방법 → 비관적 락으로 해결하였다. 낙관적 락의 경우 fail할 경우 fail에 대한 화면단(또는 서버단)의 수정이 필요하다는 단점이 있어, 시간이 걸리더라도 비관적 락으로 해결하는 것이 이후 배포 계획에도 일관성 있게 해결할 수 있을 것이라고 기대하였다.

결론적으로 동시에 100개의 요청이 들어와도 아래와 같이 Thread-safe하게 100개의 요청을 Lost Update 없이 처리할 수 있다. 



이후 계획

비관적 락의 경우 분산환경(EC2의 AutoScaling)의 경우에 동시성 문제를 해결하지 못한다. 해당 프로젝트의 경우 Elastic Beanstalk에서 autoscaling할 시 서버가 확장될 수 있기 때문에 이후 분산락을 활용하여 이 위험도를 줄일 필요가 있다. Named Lock(사용자 지정 String으로 락을 건다는 이점을 가짐) 이나 Redis(싱글쓰레드의 이점을 가짐)를 통해 해결할 계획이다.