애플리케이션 안에서 동시성 이슈를 해결하는 방법

alsdl0629·2024년 5월 10일
0

동시성 이슈

목록 보기
4/4
post-thumbnail

이번 글에서는 애플리케이션 안에서 동시성을 해결하는 방법을 정리해 보려고 합니다.


동시성 이슈가 발생하는 경우

동시성 이슈여러 작업이 동시에 공유 자원에 접근했을 때 발생합니다.
ex) 재고 감소, 조회수 증가, 선착순 시스템 등


동시성 이슈로 다음과 같은 문제가 발생할 수 있습니다.

  • 경쟁 조건 (Race Condition)
    • 둘 이상의 작업이 공유된 자원을 변경해서 예상과 다른 결과가 발생
  • 교착 상태 (DeadLock)
    • 두 개 이상의 작업이 서로 끝나기를 기다리고 있는 상태로 다음 단계로 진행 불가
  • 병목 현상 (Bottleneck)
    • 시스템의 성능이나 용량이 제한을 받는 현상

쿠폰 발급 예제 코드

@Entity
public class Coupon {
    @Id
    private Long id;

    private String name;

    private int totalQuantity;

    private int issuedQuantity;

    public void issue() {
        if (issuedQuantity >= totalQuantity) {
            throw new RuntimeException("더 이상 쿠폰을 발급할 수 없습니다.");
        }
        issuedQuantity++;
    }
}
@Entity
public class UserCoupon {
    @Id
    private Long id;

    private Long couponId;

    private Long userId;
}
@RequiredArgsConstructor
@Service
public class CouponService {
    private final CouponRepository couponRepository;
    private final UserCouponRepository userCouponRepository;

	// 쿠폰 발급 로직
    @Transactional
    public void issue(Long couponId, Long userId) {
        Coupon coupon = couponRepository.findById(couponId)
            .orElseThrow();
        coupon.issue();

        userCouponRepository.save(
            new UserCoupon(couponId, userId)
        );
    }
}

예제 테스트 코드

	@DisplayName("동시에 여러명의 유저가 쿠폰을 발행한다")
	@Test
    void issueCouponByUsers() throws InterruptedException {
        // given
        long userId = 1L;
        long couponId = 1L;
        couponRepository.saveAndFlush(
            new Coupon(couponId, "치킨 할인 쿠폰", 100, 0)
        );

        int threadCount = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
        CountDownLatch countDownLatch = new CountDownLatch(threadCount);

        // when
        IntStream.range(0, 100)
            .forEach(n -> executorService.execute(() -> {
                    try {
                        couponService.issue(couponId, userId);
                    } finally {
                        countDownLatch.countDown();
                    }
                })
            );
        countDownLatch.await();

        // then
        Coupon coupon = couponRepository.findById(couponId).orElseThrow();
        assertThat(coupon.getIssuedQuantity()).isEqualTo(100);
    }

동시성 테스트와 테스트가 모두 끝날 때 까지 대기하기 위해 ExecutorService, CountDownLatch를 사용했습니다.

쿠폰 100개가 발급되길 기대했지만 경쟁 조건(Race Condition)이 발생해서 기대했던 것보다 적게 쿠폰이 발급되었습니다.

synchronized

	public void issue(Long couponId, Long userId) {
        synchronized (this) {
            Coupon coupon = couponRepository.findById(couponId)
                .orElseThrow();
            coupon.issue();
            couponRepository.save(coupon);
        }

        userCouponRepository.save(
            new UserCoupon(couponId, userId)
        );
    }

Java에서는 synchronized를 사용해서 동시성 이슈를 해결할 수 있습니다.

synchronized를 사용하면 데이터를 점유하고 있는 스레드를 제외하고, 나머지 스레드들은 데이터에 접근할 수 없습니다.

synchronized 문제점

@Transactional ❌

@Transactional은 트랜잭션 종료 시점에 데이터베이스 업데이트를 하게 됩니다.
실제 데이터베이스가 업데이트 되기 전에 다른 스레드가 쿠폰 발급을 호출하면,
갱신되기 전에 값을 가져가서 경쟁 조건(Race Condition)이 또 다시 발생하게 됩니다.

성능저하

위에서 언급한 것처럼 synchronized는 데이터를 점유하고 있는 스레드를 제외하고, 나머지 스레드들은 데이터에 접근할 수 없기 때문에 많은 요청이 들어오는 경우 빠르게 처리할 수 없습니다.

하나의 프로세스 안에서만 보장

synchronnized는 하나의 프로세스 안에서만 보장됩니다.
실사용 서비스는 대부분 다중 서버 환경이기 때문에 동시성 문제가 발생할 수 있어 synchronized를 사용하지 않습니다.


Lock

Java에서 제공하는 Lock을 사용하면 synchronized와 같이 동시성 문제를 해결할 수 있습니다.

@Configuration
public class LockConfig {
	// Lock을 공유하기 위해 빈 등록
    @Bean
    public Lock lock() {
        return new ReentrantLock();
    }
}
	public void issue(Long couponId, Long userId) {
        try {
            lock.lock();
            Coupon coupon = couponRepository.findById(couponId)
                .orElseThrow();
            coupon.issue();
            couponRepository.save(coupon);
        } finally {
            lock.unlock(); // 다른 스레드가 Lock을 걸 수 있도록 하기 위해
        }

        userCouponRepository.save(
            new UserCoupon(couponId, userId)
        );
    }

하지만 Lock도 마찬가지로 synchronized와 같은 문제를 가지고 있어 잘 사용하지 않습니다.


마무리

대부분의 운영 중인 서비스는 다중 서버 환경이므로 동시성 이슈를 해결하기 위해서는 위에서 알아본 방법 대신 DB Lock, Redis와 같은 외부 기술을 사용해야 될 것 같습니다.

추가로 전에 DB Lock, Redis를 이용한 동시성 이슈를 해결하는 방법을 정리한 글을 참고하시면 도움이 될 것 같습니다!

profile
인풋보다 아웃풋

0개의 댓글