개발/항해99

[Spring, Redisson] e-커머스 동시성 이슈 분석 및 제어 방법

IamBD 2024. 10. 30. 23:06

동시성 이슈

일반적으로 생각할 수 있는 동시성 이슈는 여러 프로세스나 스레드가 동시에 실행될 때 이를 비즈니스 로직이나 프로그램에서 처리하지 못해 의도하지 않는 결과가 나오는 상황이라고 생각합니다.

 

간단한 예시로 들어보자면 콘서트장에서 자리를 예약할 때 여러명이 같은 자리 예매에 성공했다면 그 자리는 누가 앉아야할까요?

 

e-커머스 동시성 이슈 발생 시나리오

그렇다면 일반적인 e-커머스에서는 어떤 동시성 이슈들이 발생할 수 있는지 살펴볼까요?

  • 잔액이 5,000원인 상황에서 5,000원짜리 물품에 대해 구매하기 버튼을 실수로 두 번 눌러버렸다.
  • 재고가 하나 남은 상품을 여러명이 동시에 구매했다.

물론 실제 e-커머스 시스템들이 겪는 동시성 이슈는 더 많이 있겠지만 제일 중요한 "돈"과 "상품"에 집중해서 위 두 가지의 사례만 살펴보려고 합니다. 

 

먼저 중복 구매시 잔액 동시 조회로 인해 발생하는 문제입니다.

 

사용자1에서 보면 이미 결제가 완료되어 잔액이 차감되었음에도 결제처리 도중 잔액 조회가 일어나 잔액이 있는 것처럼 조회했고 결국엔 잔액부족으로 인해 결제 처리에 실패했습니다.

 

 

두 번째로 재고가 하나 남은 상황에서의 여러 사용자 동시 제고조회 접근 문제입니다.

 

위와 같이 결제처리가 시작되었음에도 별도의 동시성 처리가 되어있지 않아 사용자2가 중복으로 마치 재고가 있는 것처럼 조회하였고 이후 재고가 한 개 남아있는 것으로 인식한 사용자 2는 최종 상품 구매시 재고 부족으로 인해 결제 처리에 실패하게 됩니다.

 

다행히 두 케이스 모두 잔액 부족과 재고 부족으로 인해 실제 피해는 발생하지 않았겠지만 음수 등 유효성 검사가 없다면 재고나 잔액이 마이너스가 되어 실제 피해가 발생할 수도 있었습니다.

 

동시성 이슈를 해결할 수 있는 다양한 방법들

낙관적 락

이름에서부터 알 수 있듯 같은 리소스에 대한 충돌이 거의 발생하지 않는다고 가정한 "낙관"적 잠금입니다. 이 방법은 테이블 내 version이나 timestamp를 통해 관리를 하게되며 조회시 version과 데이터 수정시의 version이 다르다면 Exception을 발생시키게 됩니다.

 

이 때, 자동적으로 재시도나 롤백 처리는 해주지 않기 때문에 반드시 재수행을 하는 등 별도의 로직을 준비해야 효율적인 요청 처리를 할 수 있게 됩니다.

 

장점

  • 락을 사용하지 않기 때문에 성능상 이점을 가집니다.
  • 트랜잭션을 사용하지 않기 때문에 성능상 이점을 가집니다.

단점

  • "거의" 일어나지 않는 충돌이 발생한다면 이는 개발자가 한땀한땀 수동으로 롤백을 진행해야 합니다.
  • 재시도 처리 등 Exception에 대한 적절한 처리를 하지 못한다면 좋지 않은 UX를 경험하게 됩니다.

위의 장단점을 봤을 때 충돌이 예상되지 않는 로직에 대해서는 "혹시나"를 대비하여 최소한의 낙관적 락을 사용하는 것이 최대한의 성능상 이점을 가져갈 수 있습니다.

 

 

비관적 락

아까와 같이 락 이름부터가 "비관"입니다. 낙관은 "거의" 충돌이 일어나지 않는다고 가정했다면 비관은 잦은 충돌이 예상되는 로직에 사용됩니다. 이 방법은 해당 트랜잭션이 시작될 때 공유 락 혹은 베타 락을 사용하여 다른 데이터의 접근을 막음으로서 데이터의 정합성을 보장합니다.

 

장점

  • 다른 데이터의 접근 자체를 막음으로서 데이터의 일관성을 손쉽게 보장할 수 있다.
  • 데이터 접근 전 락을 사용함으로서 작업 중 다른 트랜잭션과의 충돌 가능성을 낮출 수 있다.

단점

  • 매번 락을 사용하기 때문에 많은 트래픽이 발생할 경우 성능에 악영향을 끼칠 수 있다.
  • 데드락이 발생할 수 있다.

낙관적 락과는 다르게 충돌을 방지하는 목적이 크기 때문에 "공유되는" 자원에 대해서는 비관적 락을 사용하여 데이터의 일관성을 지키는 목적으로 사용합니다.

 

분산 락

위의 다양한 락을 사용하며 동시성 이슈를 해결할 수 있지만 각 어플리케이션은 락을 "독립적"으로 사용하기 때문에 MSA와 같은 분산 환경에서는 각기 다른 락에 대해 알지 못하니 동시성 처리에는 한계가 있습니다. 분산 락이란 이럴 때 어플리케이션이나 DB에서의 직접적인 락이 아닌 기타 응용 소프트웨어를 통해 제어하는 방법입니다.

 

직접적인 락을 사용하는게 아니기 때문에 DB나 어플리케이션의 부하를 줄일 수 있다는 큰 장점을 가지고 있으며 대부분의 대용량을 다루는 서비스에서는 분산락을 채택하여 사용하고 있습니다.

 

 

채택 기술

위의 마지막을 이유로 pub/sub 패턴을 사용하는 Redisson을 이용한 분산락을 채택하였습니다. 다행히도 관련 적용 방법이 마켓컬리 기술 블로그에 작성되어 있어 적용 자체는 어렵지 않게 할 수 있었습니다.

 

Pub/Sub 패턴이란?

더보기

Pub/Sub(Publish/Subscibe)은 MSA 환경에서 비동기적으로 통신하기 위한 소프트웨어 메시징 패턴입니다. 해당 패턴을 사용하려면 중요한 네 가지 개념을 알아야 합니다.

  • 메시지 : 서로 다른 당사자 간에 교환되도록 인코딩된 개별 통신 단위
  • 발행자 : 메시지 전송을 담당하는 어플리케이션
  • 구독자 : 하나 이상의 게시자로부터 메시지를 수신하는 어플리케이션
  • 주제 : 특정 주제에 대한 메시지가 포함된 채널

여기서 중요한건 발신자와 수신자는 서로의 존재를 알 필요 없이 독립적으로 작동되는 것입니다. 즉, 발행자는 어떤 구독자에게 가는지 알 필요가 없으며 구독자 역시 누가 메시지를 보냈는지 알 필요가 없으며 그저 필요한 주제에 대해 구독하기만 하면 즉시 메시지를 수신할 수 있습니다.

 

이러한 Pub/Sub 패턴의 이점은 아래와 같습니다.

  • 효율성 : 구독자는 새 메시지를 받기 위해 반복적인 폴링을 할 필요가 없습니다.
  • 확장성 : 발행자와 구독자는 서로 독립적으로 동작하니 시스템의 확장이나 변경에 유리합니다.
  • 단순성 : 확장성과 마찬가지로 독립적으로 동작하니 각각의 어플리케이션은 언제나 연결과 제거가 쉬워 복잡성을 크게 낮춥니다.

 

 

먼저 redis를 사용하기 위해 관련 의존성과 docker-compose.yml을 작성합니다.

 

[docker-compose.yml]

version: '3'
services:
  redis:
    image: redis:latest
    ports:
      - "6379:6379"

 

[build.gradle]

// redisson
implementation 'org.redisson:redisson-spring-boot-starter:3.38.0'

 

[application.yml]

spring:
    data:
      redis:
        host: localhost
        port: 6379

 

현재 아래와 같은 패키지 구조를 가져가고 있는데 어느 레이어에 들어가야하나? 라는 고민을 잠깐 했었으나 data와 연관이 있는 기술이니 infra 내 aop 패키지를 만들었습니다.

 

 

 

Redisson 사용하기 위해 Config 설정을 진행합니다.

@Configuration
public class RedissonConfig {

    @Value("${spring.data.redis.host}")
    private String redisHost;

    @Value("${spring.data.redis.port}")
    private int redisPort;

    private static final String REDISSON_HOST_PREFIX = "redis://";

    @Bean
    public RedissonClient redissonClient() {
       RedissonClient redisson;
       Config config = new Config();
       config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort);
       redisson = Redisson.create(config);
       return redisson;
    }

}

 

 

분산락 적용시 어노테이션을 통해 유연한 사용을 위해 커스텀 어노테이션 클래스를 정의합니다.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {

    String key();
    TimeUnit timeUnit() default TimeUnit.SECONDS;
    long waitTime() default 5L;
    long leaseTime() default 3L;

}

 

위 분산락 적용시 수행을 담당하는 Aop 클래스 입니다.

@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class DistributedLockAop {
	private static final String REDISSON_LOCK_PREFIX = "LOCK:";

	private final RedissonClient redissonClient;
	private final AopForTransaction aopForTransaction;

	@Around("@annotation(com.hanghae.ecommerce.infra.aop.DistributedLock)")
	public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
		MethodSignature signature = (MethodSignature) joinPoint.getSignature();
		Method method = signature.getMethod();
		DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);

		String key = REDISSON_LOCK_PREFIX +
			CustomSpringELParser.getDynamicValue(
				signature.getParameterNames(), joinPoint.getArgs(), distributedLock.key()
			);
		RLock rLock = redissonClient.getLock(key); // Lock을 획득한다.

		try {
			boolean available = rLock.tryLock( // 정해진 시간동안 Lock 획득을 위해 대기한다.
				distributedLock.waitTime(),
				distributedLock.leaseTime(),
				distributedLock.timeUnit()
			);
			if (!available) {
				return false;
			}

			return aopForTransaction.proceed(joinPoint); // 락을 획득한 후에 새 트랜잭션을 시작한다.
		} catch (InterruptedException e) {
			throw new InterruptedException();
		} finally {
			try {
				rLock.unlock(); // 종료시 락을 해제한다.
			} catch (IllegalMonitorStateException e) {
				log.info("Redisson Lock Already UnLock {} {}",
					"serviceName" + method.getName(),
					"key" + key
				);
			}
		}
	}

}

 

여기서 중요하게 봐야할 부분은 "락을 획득한 후에 새 트랜잭션을 시작한다"입니다. 여러 비즈니스 메서드에서 트랜잭션이 걸려있을텐데 굳이 왜 또 락 획득 후 새로운 트랜잭션을 시작해야 할까요? 락이 아닌 트랜잭션 시작 후에 획득한 경우의 케이스를 살펴보겠습니다.

참조 : 항해99 발제자료

 

빨간색으로 표시된 부분을 살펴보면 Request 1이 트랜잭션 "이후에" 락을 획득하였기 때문에 그 사이에 Request 2는 Request 1이 처리하지 않은 데이터를 조회하면서 정상적인 비즈니스 로직을 수행할 수 없습니다.

 

위처럼 커스텀 어노테이션으로 구현된 분산락은 이제 필요한 부분에 선언함으로서 쉽게 사용 가능하게 됩니다.

[재고차감]

@Override
@DistributedLock(key = "#productId")
public void reduceStock(Long productId, Long quantity) {
    var product = productReader.getProduct(productId);
    product.reduceStock(quantity);
}

 

분산락을 적용한 이후 동시 주문시에 데이터 정합성을 보장할 수 있게 되었습니다.

 

@Test
void 재고_동시성_테스트() throws InterruptedException {
    // given
    Long productId = 1L;
    int threadCount = 5;

    ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
    CountDownLatch latch = new CountDownLatch(threadCount);

    for (int i = 0; i < threadCount; i++) {
       Long cartId = (long) (i + 1);
       Long userId = (long) (i + 1);

       CartCommand.AddToCartRequest addToCartRequest =
          CartCommand.AddToCartRequest.of(productId, 1L, cartId);
       cartService.addToCart(addToCartRequest);

       userService.chargeBalance(UserCommand.ChargeRequest.of(userId, 10000L));
    }

    // when
    for (int i = 0; i < threadCount; i++) {
       Long cartId = (long) (i + 1);
       executorService.submit(() -> {
          try {
             OrderCommand.CreateOrderRequest createOrderRequest =
                OrderCommand.CreateOrderRequest.from(cartId);
             orderFacade.createOrder(createOrderRequest);
          } catch (Exception e) {
             System.out.println("Order failed: " + e.getMessage());
          } finally {
             latch.countDown();
          }
       });
    }

    latch.await();

    // then
    Long remainingStock = productService.getProduct(productId).getStock();
    System.out.println("Remaining stock: " + remainingStock);
    assertEquals(0L, remainingStock);
}

 

 

개선 포인트

잘 작성된 분산락 적용 방법 덕분에 손쉽게 프로젝트에 적용할 수 있었습니다. 다만 테스트 코드를 보면 알 수 있듯 테스트 코드가 너무 많은 비즈니스 로직을 알고 있습니다. 또한 테스트에 트랜잭션을 적용하지 않아 각 테스트는 독립적이지 않습니다.

 

학습을 위해 분산락을 잠시 접어두고 낙관락과 비관락을 이용하여 똑같은 테스트를 작성하고 트랜잭션 전파 속성의 이해를 위해 Facade단이 아닌 Service단에 조금 더 세세한 트랜잭션을 적용하여 동시성 처리를 진행한 이후에 분산락을 적용하여 락과 트랜잭션에 대한 이해도를 높이는데 집중해야겠습니다.

 

 

 

참고링크

https://helloworld.kurly.com/blog/distributed-redisson-lock/

 

풀필먼트 입고 서비스팀에서 분산락을 사용하는 방법 - Spring Redisson

어노테이션 기반으로 분산락을 사용하는 방법에 대해 소개합니다.

helloworld.kurly.com

https://redisson.org/glossary/pubsub.html

 

What is pub/sub? | Redisson

The pub/sub pattern is a key concept in software architecture and design for performing asynchronous communication between parties. But what is pub/sub, and how does pub/sub work in practice?

redisson.org

https://github.com/redisson/redisson

 

GitHub - redisson/redisson: Redisson - Valkey & Redis Java client. Complete Real-Time Data Platform. Sync/Async/RxJava/Reactive

Redisson - Valkey & Redis Java client. Complete Real-Time Data Platform. Sync/Async/RxJava/Reactive API. Over 50 Redis or Valkey based Java objects and services: Set, Multimap, SortedSet, Map, ...

github.com