JPA

스프링과 JPA 환경에서 동시성 문제 해결하기

동시성 문제

동시성 문제는 공유 자원을 여러 스레드가 수정할 때 공유 자원의 일관성이 깨지는 상황을 말한다. 예를 들어 내 계좌에 1000원을 두 스레드가 동시에 입금하려고 한다고 가정해보자.

병렬로 스레드가 작업을 수행하고 위에서 부터 시간순으로 진행됨을 나타낸다.
스레드 A가 남은 금액 조회 : 0원
스레드 B가 남은 금액 조회 : 0원
스레드 A가 남은 금액에 1000원을 추가 : 0원 (아직 트랜잭션이 종료되지 않아 반영되지 않았다.)
스레드 B가 남은 금액에 1000원을 추가 : 0원 (아직 트랜잭션이 종료되지 않아 반영되지 않았다.)
스레드 A가 커밋하면서 금액을 1000원으로 업데이트 : 1000원
스레드 B가 커밋하면서 금액을 1000원으로 업데이트 : 1000원(어라? 1000원을 두 스레드가 입금하면 2000원이 되어야 한다!!!!)

위 예시를 보면 알 수 있듯이 공유 자원드 아무 제약 없이 조회(획득)해서 각자 작업을 하고 업데이트 하는 경우 예상하지 못한 결과가 나올 수 있다.

예시 코드

먼저 도메인 엔티티는 간략하게 포인트 하나만 둔다. 이 포인트 객체는 사용자의 포인트 누적액과 포인트 적립과 사용에 쓰이는 바코드를 포함한다.

1
2
3
4
5
6
7
8
9
10
@Entity
class Point(
@Column(nullable = false)
var amount: Long,
@Column(nullable = false, unique = true)
val barcode: String,
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long = 0
)

그리고 바코드에 해당하는 포인트 객체를 조회하는 메서드를 포함한 레포지토리 코드이다.

1
2
3
interface PointRepository: JpaRepository<Point, String> {
fun findByBarcode(barcode: String): Optional<Point>
}

마지막으로 서비스 코드이다. (주입받는 부분은 생략하고 메서드만 표기했다.)
이해를 위해 매우 간략하게 구현된 점을 이해하자.

1
2
3
4
5
6
7
@Transactional
fun saveUp(shopId: Long, barcode: String, amount: Long): Point {
val point = pointRepository.findByBarcode(barcode)
.orElseGet { pointRepository.save(Point(0, barcode)) } // 기존의 바코드가 존재하지 않으면 새 객체를 만들어서 영속화한다.
point.amount += amount
return point
}

이제 이를 테스트 하기 위해 테스트 메서드를 만들어보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Test
@DisplayName("포인트 적립 동시성 문제 검증")
fun saveUp_concurrency() {
val threadCount = 20
val executorService = Executors.newFixedThreadPool(10)
val countDownLatch = CountDownLatch(threadCount)
val barcodeValue = "1234567890"
callSaveUp(1, barcodeValue) //적립할 양과 바코드를 받아 적립하는 메서드이다.

for (i in 1..threadCount) {
executorService.submit {
try {
callSaveUpAPI( 1, barcodeValue)
} finally {
countDownLatch.countDown()
}
}
}
countDownLatch.await()
val point = pointRepository.findByBarcode(barcodeValue)
.orElseThrow()
assertThat(point.amount).isEqualTo((threadCount + 1).toLong())
}

CountDownRatch?
CountDownRatch는 어떤 스레드가 다른 스레드에서 작업이 완료될 때까지 기다릴 수 있도록 해주는 클래스다. 위 예시에서는 작업을 하는 스레드들이 완료될 때마다 countDown을 해서 모든 스레드들이 작업을 마칠 때까지 await하도록 구현했다.

synchronized 키워드

먼저 자바 및 코틀린에서 쉽게 생각할 수 있는 synchronized 키워드를 사용할 수 있다. 미리 말하자면, 이 방법은 확장성에 좋지않고 스프링에서는 고려해야할 부분이 있다.

  1. 확정성에 좋지 않은 이유.
    synchronized는 해당 어플리케이션 안에서만 유효하다. 즉 여러 서버를 운영하게 되면 여러 어플리케이션이 요청을 처리하게 되는데, 자신이 아닌 다른 어플리케이션의 요청은 제어할 수 없게된다.
  2. 스프링에서 고려해야 하는 부분. @Transactional
    일반적으로 서비스 계층에서 @Transactional을 사용해서 각 요청에 트랜잭션을 적용한다. 하지만 스프링은 트랜잭션을 프록시로 처리하게 된다. 즉 원래 메서드를 감싼 외부 메서드가 존재하게 된다. 업데이트는 트랜잭션 종료 시 실행되는데 스프링에서는 원래 메서드가 종료되면 외부 메서드에서 트랜잭션 종료 처리릃 하게 된다. 즉 값의 반영이 되지 않은 시점에서 다른 스레드가 원래 메서드를 통해 반영되지 않은 값을 조회할 수 있다.

Database에서 해결하기

데이터베이스의 Lock을 활용해서 해결할 수 있다.

낙관적 락

낙관적 락은 서비스 특성 상 동시성 문제가 발생할 경우가 적은 상황에서 사용한다. 실제로는 락을 사용한다기 보다는 수정 시점을 기록하는 칼럼(버전)을 통해 동시성 문제를 일으키는 쿼리를 감지하는 방식이다.

엔티티에 버전을 기록하는 칼럼을 추가하고, 조회했을 때 버전과 값을 변경해서 반영할 때의 버전이 같아야 반영하고 버전을 +1하는 방식이다.

가령 여러 스레드가 동시에 하나의 데이터를 조회해서 값을 변경하고 반영하려고 한다면, 모두 다 버전이 1인 상황에서 값을 반영하려고 할 것이고, 먼저 하나의 스레드가 반영이 되어서 버전이 2가 되어버리면 그 외 버전 1인 업데이트 쿼리가 모두 반영되지 않도록 막는다.

코드로 살펴보기

먼저 도메인 엔티티에 버전을 추가해주자.

1
2
3
4
5
6
7
8
9
10
@Entity
class Point(
@Column(nullable = false)
var amount: Long,
@Column(nullable = false, unique = true)
val barcode: String,
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long = 0
)

그리고 레포지토리 메서드에서 @Lock 어노테이션만 추가해주면 된다.

1
2
3
4
interface PointRepository: JpaRepository<Point, String> {
@Lock(LockModeType.OPTIMISTIC)
fun findByBarcodeAndShopSector(barcode: String, shopSector: ShopSector): Optional<Point>
}

여기서 추가로 낙관적 락은 동시성이 우려된 경우 예외를 발생한다는 사실을 알아두자. 즉 서비스 단에서 예외가 발생한 경우 재시도하는 코드를 추가해야 테스트 코드가 통과 될 것이다!

비관적 락

비관적 락은 모든 시도가 동시성 문제를 일으킬 수 있을 경우 사용한다. DB에서 락을 획득해서 다른 스레드가 자원을 접근하지 못하거나 수정을 막는 방식이다. 구현하는 방법은 매우 간단하다. 레포지토리에서 @Lock의 인자를 다르게 해주면 된다.

1
2
3
4
interface PointRepository: JpaRepository<Point, String> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
fun findByBarcode(barcode: String): Optional<Point>
}

락과 트랜잭션
락은 반드시 트랜잭션이 존재할 때만 가능하다. 만약 레포지토리 메서드를 트랜잭션이 아닌 환경에서 실행하면 예외가 발생한다.

추가로 더 알아보기

네임드락, 레디스를 활용해서 비슷한 문제를 해결할 수 있다. 코드는 강의 자바 코드이다.

네임드락

네임드락은 이름을 가진 메타데이터 락이다. 네임드락은 글로벌 락이라서 이미 한 세션이 A라는 이름의 네임드락을 가지고 있다면 다른 세션들은 같은 이름의 네임드락을 얻지 못한다. 여러 서버들의 동기화를 구현할 때 사용된다.
하지만 네임드락은 정해진 시간이 다 지나거나 락을 해제하는 명령어를 실행해야 락이 풀리고 다른 세션에서 접근할 수 있게되므로 사용에 각별한 주의를 해야 한다.
그리고 실무에서는 네임드락은 데이터소스를 따로 분리해서 적용하는 걸 추천한다. 커넥션이 부족해질 수 있기 때문이다.

레포지토리

일단 이번 예제에서는 하나의 데이터소스로 해보자. MySQL 네이티브 쿼리로 get_lockrelease_lock 함수로 네임드락을 획득하고 해제할 수 있다.

1
2
3
4
5
6
7
8
public interface LockRepository extends JpaRepository<Stock, Long> {

@Query(value = "select get_lock(:key, 3000)", nativeQuery = true)
void getLock(String key);

@Query(value = "select release_lock(:key)", nativeQuery = true)
void releaseLock(String key);
}

레포지토리를 주입받아서 서비스 로직이 실행되기 전에 락을 얻고 서비스 로직이 끝나면 락을 반납하는 방식으로 구현하면 된다.
네임드락은 데이터소스와 락 직접 해지, 그리고 트랜잭션 주기가 달라지는 부분이 있으므로 주의해서 사용하자.

레디스를 활용하기

레디스는 lettuce를 활용하는 방법과 redisson을 활용하는 방법 두가지가 있다.

lettuce 사용

lettuce는 네임드락과 매우 비슷하다. 키와 밸류를 가지고 특정 세션이 키와 밸류를 레디스에 저장하고 나면 다른 세션은 그 키가 존재하는 한 접근하지 못하는 방식이다.

레포지토리 구현

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Component
public class RedisRepository {

private final RedisTemplate<String, String> redisTemplate;


public RedisRepository(final RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}

public Boolean lock(Long key) {
return redisTemplate
.opsForValue()
.setIfAbsent(generateKey(key), "lock", Duration.ofMillis(3_000));
}

public Boolean unlock(Long key) {
return redisTemplate.delete(generateKey(key));
}

private String generateKey(final Long key) {
if (key == null) {
return "0";
}
return key.toString();
}
}

그리고 lettuce 방식은 락 획득이 실패하면 스핀락으로 재시도를 하기 때문에 만약 락 획득이 실패하는 경우 잠깐 기다리는 로직을 추가로 구현해주자.

redisson

레디슨은 다른 세션들이 계속 락을 얻기 위해 시도하는게 아니라 락이 해제가 되면 채널을 통해 기다리던 세션들에게 알리는 방식이다.(pub-sub) 그래서 비용이 적게 든다. 하지만 별도의 라이브러리를 추가해야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Component
public class RedissonStockFacade {

private final RedissonClient redissonClient;
private final StockService stockService;

public RedissonStockFacade(final RedissonClient redissonClient, final StockService stockService) {
this.redissonClient = redissonClient;
this.stockService = stockService;
}

public void decrease(final Long id, final Long quantity) {
RLock rLock = redissonClient.getLock(id.toString());

try {
boolean available = rLock.tryLock(5, 1, TimeUnit.SECONDS);

if (!available) {
System.out.println("Lock 획득 실패");
return;
}
stockService.decrease(id, quantity);
} catch (InterruptedException e) {
throw new RuntimeException();
} finally {
rLock.unlock();
}
}
}
Share