> For the complete documentation index, see [llms.txt](https://commerce.gitbook.io/think-about/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://commerce.gitbook.io/think-about/undefined.md).

# 동시성 처리하기

## 💬  개요

e-commerce 프로젝트를 진행 시 한정 구매 상품인 만큼 다수의 사용자가 동시다발적으로 구매 요청을 하기 때문에 데이터베이스에 동시에 접근하는 경우가 빈번하게 발생한다.

위 같은 이유로 동시에 여러 사용자의 요청이 들어오면 Race Condition이 발생하여 재고 감소가 누락되는 현상이 발생할 수 있는데, 위와 같은 상황에서 동시성 처리를 위해 어떠한 고민을 하고 최종적으로 왜 이러한 기술을 적용했는지에 대하여 작성해 보고자 한다.

## 🤔  문제를 해결하기 위한 고민

### 1. Synchronized

동시성 문제 해결을 위한 개념 자체는 간단한데, 동시에 들어오는 요청들을 동시에 처리하지 않으면 되는 것이다.

실생활에 비유하자면, 어떤 상점이 문을 열자마자 사람들이 무분별하게 들이닥쳐 물건을 구매하는 것이 아닌, 사람들을 줄 세워서 순서대로 구매를 할 수 있도록 하면 되는 것이다.

synchronized는 특정 블록이나 메서드가 한 번에 하나의 스레드에 의해서만 접근되도록 보장할 수 있어, 이를 통해 데이터의 일관성을 유지하고, 스레드 간의 경합 조건(Race Condition)을 방지할 수 있다.

#### 구현방법

```java
public synchronized void decreaseStock(Long productId) {
	log.info("상품 재고 감소");

    Product product = this.findProductByProductId(productId);
    product.decreaseStock();
}
```

#### 장점

* Java 내장 키워드로 쉽게 사용이 가능하기 때문에, 메서드 또는 코드 블록에 간단히 적용할 수 있다.
* 객체나 클래스 단위로 락을 걸어, 특정 코드 블록을 단일 스레드만 실행하도록 보장이 가능하다.
* 코드에서 동기화가 명확히 보이므로, 유지보수 시 코드의 동작을 이해하기 쉽다.

#### 단점

* 락을 획득하고 해제하는 데 걸리는 오버헤드로 인해 성능이 저하될 수 있어, 특히 많은 스레드가 동시 접근할 때 경쟁이 발생하여 대기 시간이 길어질 수 있다.
* 잘못된 락 사용으로 인해 데드락이 발생할 수 있다.
* Java 애플리케이션 내부에서만 작동하며, 분산 시스템에는 적용할 수 없다.

### 2. 비관적 락(Pessimistic Lock)

트랜잭션의 충돌이 발생한다고 가정하고, 데이터에 접근할 때마다 우선적으로 락을 설정하여 데이터 정합성을 보장하는 방식으로, 데이터를 수정하는 동안 다른 트랜잭션이 해당 데이터에 접근하지 못하도록 강력하게 차단한다.

#### 동작방식

![](https://velog.velcdn.com/images/jinu0729/post/7194e6e3-0861-4c24-9855-90aa6fb7bc39/image.png)

* Transection 1(T1)이 데이터를 조회할 때, 배타락을 건다.
* Transection 2(T2)가 데이터를 조회하려고 했으나, 배타락이 걸려 있어 조회가 불가능하다.
* T2이 데이터의 락이 해제될때까지 대기한다.
* T1이 데이터를 수정하고, 커밋한다.
* T2가 데이터를 조회할 때, 배타락을 건다.
* T2가 데이터를 수정하고, 커밋한다.

#### 구현방법

JpaRepository를 사용하여 Pessimistic Lock을 적용하려면, 먼저 @Lock 어노테이션과 LockModeType을 사용해야 한다.

```java
public interface ProductRepository extends JpaRepository<Product, Long> {
	@Lock(LockModeType.PESSIMISTIC_WRITE)
    Optional<Product> findById(Long productId);
}
```

비관적 락을 사용하면 DB 단에 락을 설정하는 쿼리를 수행하는데, 이때 S-Lock을 설정하는 SELECT FOR SHARE 쿼리를 수행할지, X-Lock을 설정하는 SELECT FOR UPDATE 쿼리를 수행할지를 @Lock의 속성인 LockModeType을 통해 지정할 수 있다.

* LockModeType.PESSIMISTIC\_WRITE : X-LOCK 쿼리 수행
* LockModeType.PESSIMISTIC\_READ : S-LOCK 쿼리 수행

#### 장점

* 데이터에 대한 락을 통해 경합 조건과 데이터 불일치 방지가 가능하다.
* 데이터가 수정되는 동안 다른 트랜잭션이 접근하지 못하게 하여 안정성을 보장한다.

#### 단점

* 락을 획득하고 해제하는 오버헤드가 있으며, 대기 시간이 길어질 수 있다.
* 여러 트랜잭션이 서로 락을 기다리면서 데드락에 빠질 수 있다.
* 많은 트랜잭션이 동시에 발생하는 환경에서는 성능이 저하될 수 있다.

### 3. 낙관적 락(Optimistic Lock)

트랜잭션 대부분이 충돌이 발생하지 않는다고 낙관적으로 가정하는 방법으로, 데이터베이스의 Lock을 활용하지 않고, 데이터의 버전을 기록할 수 있는 컬럼을 추가해서 버전을 기준으로 동시 접근 제어를 하게 된다.

#### 동작방식

![](https://velog.velcdn.com/images/jinu0729/post/0b6acfd3-e251-469b-9775-3f5c66a02a1c/image.png)

* Transection 1(T1)이 데이터를 읽는다.
* Transection 2(T2)가 데이터를 읽는다.
* T1이 데이터를 수정할 때, version을 1만큼 증가 시킨다.
  * **데이터의 version은 1에서 2로 변경**된다.
* T2가 데이터를 수정하려고 한다.
  * T2가 읽어들인 데이터의 version은 1이므로, WHERE절의 조건 역시 version = 1이다.
  * 이미 T1에 의해 version이 2로 변경되었으므로, 데이터 수정에 실패하고 예외가 발생한다.

T1이 데이터 수정을 할 때 버전을 증가시키므로, 트랜잭션 T2가 데이터를 수정할 때는 버전이 이미 달라진 상태이다.

따라서 트랜잭션 T2 커밋 시 버전이 다르므로 데이터 수정에 실패하고 예외를 발생시킴으로써 T1의 갱신 내역을 지키게 되는 것이다.

#### 구현방법

비관적 락과 마찬가지로 낙관적 락도 @Lock 어노테이션과 LockModeType을 사용해야 한다.

```java
public interface ProductRepository extends JpaRepository<Product, Long> {
    @Lock(LockModeType.OPTIMISTIC)
    Optional<Product> findById(Long productId);
}
```

낙관적 락은 버전을 비교하여 동시성을 처리하기 때문에, 해당 엔티티에 Version 컬럼을 수동으로 엔티티에 추가 후 @Version 어노테이션을 선언해야 한다.

```java
@Entity
@Getter
@NoArgsConstructor
public class Product extends Timestamped {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long productId;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Type type;

    @Column(nullable = false)
    private String title;

    @Column(nullable = false)
    private Long price;

    @Column(nullable = false)
    private Long stock;

    @Column(nullable = false)
    private Boolean isActive;

	@Version
	private Long version;
    
    ...
}
```

로직을 실행시키면, 동시에 요청이 와서 버전이 맞지 않는 데이터가 존재하기 때문에 OptimisticLockException이 발생한다.

위에서 설명한 내용과 같이 낙관적 락은 버전 정보를 사용하기 때문에, 트랜잭션 커밋 시 버전 정보가 달라 발생하는 예외에 대해 예외 처리를 해주고, 예외가 발생했던 로직을 재시도해야 한다.

```java
public void decreaseStock(Long id, Long quantity) throws InterruptedException {
	while (true) {
		try {
			productService.decreaseStock(id, quantity);
			break;
		} catch (Exception e) {
			Thread.sleep(50);
		}
	}
}
```

#### 장점

* 락을 사용하지 않고 버전 관리를 통해 동시성을 제어하므로, 일반적으로 성능이 뛰어나다.
* 락을 사용하지 않으므로 데드락이 발생하지 않는다.
* 많은 트랜잭션이 동시에 발생해도 성능 저하가 적어 확장성이 뛰어나다.

#### 단점

* 데이터 충돌이 발생했을 때 이를 처리하기 위한 로직이 필요하다.
* 충돌이 빈번하게 발생하는 환경에서는 낙관적 락이 오히려 성능 저하를 초래할 수 있다.

### 4. Lettuce

Redis는 인메모리 데이터베이스로서 매우 빠른 속도로 데이터를 읽고 쓸 수 있기 때문에 주로 캐시, 세션 저장소, 실시간 데이터 분석 등에 사용되지만, 단순히 데이터 저장소로서의 역할뿐만 아니라, 동시성 제어를 위한 강력한 메커니즘도 제공한다.

Lettuce는 Netty기반의 Redis 라이브러리이며, 요청을 논블로킹으로 처리하여 높은 성능을 가지고 SETNX 명령어를 통해서 분산 락(Distributed Lock)을 구현할 수 있다.

> 분산 락(Distributed Lock) 여러 컴퓨터 또는 프로세스 간에 공유된 자원에 대한 접근을 제어하기 위한 메커니즘으로 분산 시스템에서는 여러 서버나 인스턴스가 동시에 실행되며, 이러한 환경에서 공유된 자원에 대한 동시 접근을 제어하는 것이 중요하기 때문에 동시성 문제를 해결하기 위해 사용된다.

#### 동작방식

SETNX메서드를 이용해 사용자가 직접 스핀 락(Spin Lock)형태로 구성하여, 락이 점유 시도를 실패했을 경우 계속 락 점유 시도를 하게 되어 Redis는 계속 부하를 받게 되고, 응답시간이 지연된다.

만료시간을 제공하고 있지 않아서 락을 점유한 서버가 장애가 생기면 다른 서버들도 해당 락을 점유할 수 없는 상황이 발생한다.

> 스핀 락(Spin Lock) 멀티스레딩 환경에서 공유 자원에 대한 동시 접근을 방지하기 위한 락(Lock) 중 하나로, 다른 락과는 다르게, 락을 획득할때까지 계속해서 락 획득을 시도하고 조건을 확인하면서 대기하기 때문에, 짧은 시간 내에 락을 얻을 수 있는 경우에 유리하지만, 장시간 대기 시에는 CPU 자원을 낭비할 수 있다.

#### 장점

* Redis 의존성을 추가하는 경우 기본 Redis 라이브러리로 제공되므로, 별도의 설정 없이 간단히 구현할 수 있다.

#### 단점

* 구현 방식에서 스핀락을 사용하기 때문에 Redis에 부하를 줄 수 있다.
* 사용자가 직접 구현을 해야 한다.

### 5. Redisson

Lettuce와 마찬가지로 Redis 라이브러리 중 하나로, Lettuce와 비슷하게 Netty를 사용하여 non-blocking I/O를 사용하지만, 직접 레디스의 명령어를 제공하지 않고, Bucket이나 Map같은 자료구조나 Lock 같은 특정한 구현체의 형태로 제공한다.

#### 동작방식

Redis의 Pub/sub 기능을 사용해서 Lock획득에 실패한 경우, 특정 채널을 구독하고 Lock이 다시 획득할 수 있는 상태가 됐다는 이벤트를 받았을 때, 다시 Lock획득을 시도한다.

#### 장점

* Redis의 다양한 기능을 높은 수준의 추상화된 API로 제공합니다. 이를 통해 분산 락을 쉽게 구현하고 사용할 수 있다.
* 분산 락 외에도 분산 데이터 구조, 큐, 토픽 등 다양한 분산 시스템 기능을 제공한다.
* 다양한 데이터 구조를 지원하며, 다양한 방식으로 락을 구현할 수 있습니다.

#### 단점

* 별도의 의존성을 추가해야 하기 때문에, 애플리케이션의 크기가 커질 수 있습니다.
* 고수준의 추상화된 API를 제공하기 때문에, 이에 따른 추가적인 오버헤드가 발생할 수 있다.

## 🤨  왜 Redisson 분산 락을 적용하였나?

synchronized 키워드는 단일 JVM에서만 동작하므로 여러 서버 간의 분산 환경에서는 사용할 수 없으며, 락을 획득하는 과정에서 데드락이 발생할 가능성이 있고, 락이 세밀하게 조절되지 않아 성능이 저하될 수 있다.

비관적 락은 매우 안전한 동시 접근 제어를 보여주지만, 과하게 Lock이 적용된다는 단점이 존재하고, 낙관적 락은 DB Lock을 적용하지는 않지만 트랜잭션 충돌이 발생할 수 있으며, 이를 처리하기 위해 추가적인 로직이 필요하다.

Lettuce는 분산 락의 구현은 직접 수행해야 하는데, 구현하는 과정에서 스핀 락 방식을 사용하여 Redis의 부담이 커지게 되므로 Redisson 분산락을 적용하였다.<br>

## 💻  어떻게 구현하였나?

***build.gradle*** redisson 라이브러리를 사용하기 위해 의존성을 추가한다.

```
dependencies {
    // redisso
    implementation 'org.redisson:redisson-spring-boot-starter:3.18.0'
}
```

***RedissonConfig.java*** Redis서버는 로컬에서 실행되고 있으므로 RedisProperties를 통해 getHost(), getPort()로 주소를 설정하고, Config 설정을 Bean으로 등록한다.

```java
@RequiredArgsConstructor
@Configuration
public class RedissonConfig {
    private final RedisProperties redisProperties;

    @Bean
    public RedissonClient redisson() {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://" + redisProperties.getHost() + ":" + redisProperties.getPort());

        return Redisson.create(config);
    }
}
```

***DistributedLock.java*** 분산락 어노테이션으로 비즈니스 로직과 분리하여 분산락을 관리하기 위해 작성하였으며, 락 획득을 시도하는 최대시간 및 락 획득 후 최대 점유시간을 설정 할 수 있도록 작성하였다.

```java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
    // Lock의 이름 (고유값)
    String value();

    // Lock획득을 시도하는 최대 시간 (ms)
    long waitTime() default 5000L;

    // 락을 획득한 후, 점유하는 최대 시간 (ms)
    long leaseTime() default 2000L;
}
```

***DistributedLockAop.java*** @DistributedLock 어노테이션 선언 시 수행되는 aop 클래스로, 파라미터 값을 가져와 분산락 획득 시도 그리고 어노테이션이 선언된 메서드를 실행한다.

```java
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class DistributedLockAop {
    private final RedissonClient redissonClient;
    private final AopForTransaction aopForTransaction;

    @Around("@annotation(com.jinu.commerceproductservice.global.redis.DistributedLock)")
    public Object lock(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);

        String key = method.getName() + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributedLock.value());
        // 락의 이름으로 RLock 인스턴스를 가져온다.
        RLock rLock = redissonClient.getLock(key);


        try {
        	// 정의된 waitTime까지 획득을 시도한다, 정의된 leaseTime이 지나면 잠금을 해제한다.
            boolean available = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), TimeUnit.MILLISECONDS);
            if (!available) {
                return false;
            }

			// DistributedLock 어노테이션이 선언된 메서드를 별도의 트랜잭션으로 실행한다.
            return aopForTransaction.proceed(joinPoint);
        } catch (InterruptedException e) {
            throw new InterruptedException();
        } finally {
            try {
            	// 종료 시 무조건 락을 해제한다.
                rLock.unlock();
            } catch (IllegalMonitorStateException e) {
                log.info("Redisson Lock Already UnLock serviceName: {}, key: {}", method.getName(), key);
            }
        }
    }
}
```

***CustomSpringELParser.java*** Spring 표현식을 좀 더 디테일하게 사용할 수 있도록 CustomSpringELParser 는 전달받은 Lock의 이름을 Spring Expression Language 로 파싱하여 읽어온다.

```java
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;

public class 
{

    public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) {
        ExpressionParser parser = new SpelExpressionParser();
        StandardEvaluationContext context = new StandardEvaluationContext();

        for (int i = 0; i < parameterNames.length; i++) {
            context.setVariable(parameterNames[i], args[i]);
        }

        return parser.parseExpression(key).getValue(context, Object.class);
    }
}
```

***AopForTransaction.java*** Propagation.REQUIRES\_NEW 옵션을 지정해 부모 트랜잭션의 유무에 관계없이 별도의 트랜잭션으로 동작하게끔 설정하였다.

```java
// AOP에서 트랜잭션 분리를 위한 클래스
@Component
public class AopForTransaction {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
        return joinPoint.proceed();
    }
}
```

***ProductServiceImpl.java*** 두 개의 비즈니스 로직을 만들어 하나는 분산락을 적용하지 않았고, 하나는 분산락을 적용하였다.

```java
@Slf4j(topic = "ProductServiceImpl")
@Service
@RequiredArgsConstructor
public class ProductServiceImpl implements ProductService {
    private final ProductRepository productRepository;

    @Override
    @Transactional
    public void decreaseStock(Long productId) {
        log.info("상품 재고 감소");

        Product product = this.findProductByProductId(productId);
        product.decreaseStock();
    }

    @Override
    @DistributedLock(value = "#productId")
    public void decreaseStockRedisson(Long productId) {
        log.info("상품 재고 감소");

        Product product = this.findProductByProductId(productId);
        product.decreaseStock();
    }
}
```

## 🏃‍♂️‍➡️  Test

***ProductServiceTest.java*** 동시에 100개의 재고감소가 일어났을 때, 정상 동작하는지 확인하기 위해 테스트 코드를 작성하였다.

```java
@Slf4j
@SpringBootTest
class ProductServiceTest {
    @Autowired
    ProductService productService;

    @Autowired
    ProductRepository productRepository;

    private final Integer CONCURRENT_COUNT = 100;
    private Long productId = 1L;


    private void reducingTest(Consumer<Void> action) throws InterruptedException {
        Long stock = productRepository.findById(productId).orElseThrow().getStock();

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

        for (int i = 0; i < CONCURRENT_COUNT; i++) {
            executorService.submit(() -> {
                try {
                    action.accept(null);
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();

        Product product = productRepository.findById(productId).orElseThrow();
        assertEquals(stock - CONCURRENT_COUNT, product.getStock());
    }

    @Test
    @DisplayName("동시에 재고변화 100건 : 분산락 X")
    public void badReducingTest() throws Exception {
        reducingTest((_no) -> productService.decreaseStock(productId));
    }

    @Test
    @DisplayName("동시에 재고변화 100건 : 분산락 O")
    public void redissonReducingTest() throws Exception {
        reducingTest((_no) -> productService.decreaseStockRedisson(productId));
    }
}
```

분산락을 적용하지 않은 로직에서는 100개의 재고가 있는 상품에 대해 100명이 동시에 구매를 요청한 경우, 동시성 문제로 인하여 원하는 결과값을 얻을 수 없었다.

<figure><img src="https://3880362233-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F73VtSrAp6WqEg9Vgy3n5%2Fuploads%2FcCjma2GKFWv7Gz2lPcec%2Fimage.png?alt=media&amp;token=0abee830-53ba-4efa-bc1f-45689a4f1ae6" alt=""><figcaption></figcaption></figure>

분산락을 적용한 로직에서는 100개의 재고가 있는 상품에 대해 100명이 동시에 구매를 요청한 경우, 정확하게 쿠폰이 100명 모두에게 발급된 것을 확인할 수 있다.<br>

<figure><img src="https://3880362233-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F73VtSrAp6WqEg9Vgy3n5%2Fuploads%2FWxCuwYm94p0WC3GNMl7n%2Fimage.png?alt=media&amp;token=a3abf3da-a269-43d5-8082-5c8a3f467389" alt=""><figcaption></figcaption></figure>

## 🏁  마치며

Redis의 Redisson 라이브러리의 분산락을 적용하여 동시성 처리를 구현하였지만, 분산락 이외의 다른 방식들을 좀 더 심도있게 공부하고 이번 프로젝트 혹은 다른 프로젝트에 적용하여 비교해보는 시간을 같는것도 좋을 것 같다.<br>

## 📑  참고

자바의 정석 - 13.9 쓰레드 동기화(Synchronized)

<https://github.com/redisson/redisson>

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