포스트

동시성 이슈와 트랜잭션 격리수준과 락2

락을 활용한 동시성 이슈를 해결해보려고 합니다.

락을 활용해서 멀티 쓰레드 환경에서 발생할 수 있는 동시성 이슈를 해결해보고자 합니다.
실험 환경은 다음과 같습니다.

환경버전
MySQL8.2.0
Spring Boot3.2.3
Java17


한 개의 레코드에 대해서, 동시에 10 개의 쓰레드가 접근해서 업데이트하는 시나리오를 생각해보겠습니다.

낙관락을 활용하는 경우

낙관락은 말 그대로 트랜잭션이 충돌하지 않을 것이라 가정하는 방법입니다. Jpa 를 사용하는 경우, @Version 어노테이션을 활용하여 엔티티의 버전을 관리합니다.

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
30
31
32
33
34
@Entity
public class Pocket {

  @Id
  @GeneratedValue
  private Long pocketId;

  @ManyToOne(fetch = FetchType.LAZY)
  @JoinColumn(name = "user_id")
  private User user;

  private Long point;

  @Version
  private Integer version;

  public Long addPoint(Long inputPoint) {
    this.point = this.point + inputPoint;
    return this.point;
  }

  protected Pocket() {
  }

  @Builder
  public Pocket(Long pocketId, User user, Long point) {
    this.pocketId = pocketId;
    this.user = user;
    this.point = point;
    this.version = 1;
  }
}

Entity 클래스를 위와 같이 구현하면 낙관락을 사용할 수 있습니다. queryDSL 을 사용한다면 낙관락의 사용을 명시적으로 지정할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// repository Layer
@Override
public Optional<Pocket> findUserPocketWithOptimisticLock(Long pocketId){
  QPocket pocket=QPocket.pocket;
  return Optional.ofNullable(queryFactory.selectFrom(pocket)
  .where(pocket.pocketId.eq(pocketId))
  .setLockMode(LockModeType.OPTIMISTIC)
  .fetchFirst()
  );
  }

// service Layer
@Transactional
public void addPointWithOptimisticLock(Long pocketId,Long point){
  Pocket pocket=pocketRepository.findUserPocketWithOptimisticLock(pocketId).orElseThrow();
  pocket.addPoint(point);
  }

위의 메서드는 어떤 쿼리로 실행될까요? 아마 이러한 쿼리로 업데이트가 발생할 것입니다.

1
2
3
4
5
6
7
8
UPDATE pocket
set point   = ?,
    version = ? # 버전 + 1 증가
where
  id = ?
  and version = ? # 기존 버전과 비교

레코드의 업데이트를 수행하며 버전을 확인할 것입니다. 그렇다면 낙관락으로 1 개의 레코드를 동시에 10개의 쓰레드가 업데이트하고자 한다면 어떻게 될까요?
ObjectOptimisticLockingFailureException 이 발생하면서 최초 1개의 트랜잭션을 제외한 나머지 트랜잭션은 롤백처리될 것입니다.

비관락을 활용하는 경우

비관락은 데이터베이스의 락을 사용하여 동시성을 제어합니다. SELECT … FOR UPDATE 구문을 사용합니다. QueryDSL 을 활용한다면 다음과 같은 코드로 작성될 수 있습니다.

1
2
3
4
5
6
7
8
9
10
@Override
public Optional<Pocket> findUserPocketWithPessimisticLock(Long pocketId){
  QPocket pocket=QPocket.pocket;
  return Optional.ofNullable(queryFactory.selectFrom(pocket)
  .where(pocket.pocketId.eq(pocketId))
  .setLockMode(LockModeType.PESSIMISTIC_WRITE)
  .fetchFirst());
  }

그런데, LockModeType 이 PESSIMISTIC 이 아니라, PESSIMISTIC_WRITE 입니다. LockModeType enum 을 확인해보면 다음과 같습니다.

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
public enum LockModeType {
  /**
   * 생략..
   */

  /**
   *
   * Pessimistic read lock.
   *
   * @since 2.0
   */
  PESSIMISTIC_READ,

  /**
   * Pessimistic write lock.
   *
   * @since 2.0
   */
  PESSIMISTIC_WRITE,

  /**
   * Pessimistic write lock, with version update.
   *
   * @since 2.0
   */
  PESSIMISTIC_FORCE_INCREMENT,
}

무슨 차이일까요?

  • PESSIMISTIC_WRITE 는 일반적인 비관적 락을 의미합니다. 데이터베이스에 SELECT FOR UPDATE 를 사용하여 배타락을 겁니다. NON-REPEATABLE READ 를 방지합니다. 다른 트랜잭션에서 읽기, 쓰기가 모두 불가능합니다.
  • PESSIMISTIC_READ 는 데이터를 읽기만 하고 수정하지 않을 때 사용합니다. 다른 트랜잭션에서 읽기는 가능합니다.
  • PESSIMISTIC_FORCE_INCREMENT 는 비관적 락이지만 버전 정보를 증가시킵니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@DisplayName("비관락을 활용하는 경우, 순차적으로 연산이 수행된다.")
@Test
public void testAddPointWithPessimisticLock()throws Exception{
final int threadNumber=10;
final long inputPoint=100L;
  ExecutorService executorService=Executors.newFixedThreadPool(10);
  CountDownLatch latch=new CountDownLatch(threadNumber);
  for(int i=0;i<threadNumber; i++){
  executorService.execute(()->{
  pocketService.addPointWithPessimisticLock(1L,inputPoint);
  var point=pocketService.findTotalPocketPointByUserId(1L);
  logger.info("point: {}",point);
  latch.countDown();
  });
  }
  executorService.shutdown();
  latch.await();
  assertEquals(1000,point);
  }

비관적락을 활용하여 10개의 쓰레드가 동시에 1개의 레코드에 업데이트를 수행하겠습니다. 그 결과는 순차적인 실행으로 총 결과는 1000원이 됩니다.

결론

동시성 문제를 해결하기 위한 Lock 을 정리해봤습니다.
어떤 레코드에 대해서 동시성 이슈가 발생할 가능성이 높다면 비관적 락을 활용해야 하지만, 다른 트랜잭션을 대기시키기에 데드락 문제를 발생시킬 수 있습니다.
낙관적 락은 데드락은 발생시키지 않지만 버전이 다른 트랜잭션을 어떻게 처리할지를 고려해야 합니다.
2008 년 글이지만 스택오버플로우에서 두 락의 용처에 대한 응답 이 있습니다. 이 글에 따르면, 낙관적 락은 가용성을 중시하며 atomic 한 트랜잭션을 위해서 사용하며, 비관적 락은 엄중하게 일관성 있는 트랜잭션의 수행을 위해서 사용한다고 합니다.
읽어보시기를 권해드립니다.

예제 코드는 여기서 확인하실 수 있습니다. https://github.com/seonb2n/transaction-lock-test

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.