티스토리 뷰

지난 동시성 이슈 해결 1탄 에서는 코드 레벨에서 발생할 수 있는 동시성 이슈에 대해 알아봤습니다. 이번 편에서는 데이터베이스 레벨의 동시성 이슈와 해결방법에 대해 알아보겠습니다. 인터넷의 많은 글에서 동시성 해결방법에 관한 키워드(분산락, 낙관적락, 비관적락, Named Lock 등)를 볼 수 있는데요. 해당 용어들도 정리해보겠습니다.

 

데이터베이스 레벨의 동시성 이슈 - 트랜잭션 충돌


그림1. 트랜잭션 충돌

데이터베이스의 재고 데이터를 감소하는 명령이 3번 호출됐을 때를 가정해보겠습니다.

각 명령이 트랜잭션으로 처리된다고 생각해보면 위 그림과 같이 3개의 트랜잭션이 실행될 것입니다. 초기 재고량이 10이므로 기대하는 결과 값은 7입니다. 하지만 위 그림에서 확인되는 결과값은 9입니다. 이는 기대와는 다른 결과 값입니다. 즉, 다수의 트랜잭션이 동시에 실행되면, 트랜잭션들은 서로 간섭하게 되고, 기대와 다르게 동작하게 됩니다.

 

다음은 재고를 감소시키는 기능을 간단히 구현한 코드입니다.

@Entity
@NoArgsConstructor
public class Stock {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private int quantity;

    public synchronized void minusQuantity() {
        this.quantity--;
    }
}

@Service
@RequiredConstructor
public class stockService {

    private final StockRepository stockRepository;

    @Transactional
    public synchronized void minusQuantity(Long id) {
        Stock stock = stockRepository.findById(id).orElseThrow(NoSuchElementException::new);    
        stock.minusQuantity();
    }
}

minusQuantity() 메서드는 데이터베이스와 상호작용하는 메서드로써 트랜잭션을 생성합니다. synchronized 키워드를 통해 해당 메서드를 임계구역으로 설정했기 때문에 트랜잭션이 여러 개 생성되진 않을 것입니다. 하지만, 애플리케이션 서버가 여러 대인 상황은 어떨까요?

 

그림2. 분산 환경

한 대의 서버로 운영되던 재고 관리 서비스가 스케일 아웃을 통해 서버를 한 대 늘렸다고 가정해보겠습니다. 두 대의 서버가 한 대의 데이터베이스 서버와 통신합니다. 두 대의 서버에서 동시에 minusQuantity()가 호출되면, 그림1과 같이 데이터베이스 내에서 두 개의 트랜잭션이 공존하게 됩니다. 그림2 상황을 코드로 구현하고 동시성 이슈를 재현해보겠습니다.

 

동시성 이슈 재현 - 데이터베이스 레벨의 동시성


// gradle.build
plugins {  
    id 'java'  
    id 'org.springframework.boot' version '3.1.0'  
    id 'io.spring.dependency-management' version '1.1.0'  
    id 'io.freefair.lombok' version '8.0.1'  
}  

group 'org.example'  
version '1.0-SNAPSHOT'  

repositories {  
    mavenCentral()  
}  

dependencies {  
    implementation 'mysql:mysql-connector-java:8.0.28'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'  
    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1'  
    testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.1'  
}  

test {  
    useJUnitPlatform()  
}
@Entity  
@NoArgsConstructor  
public class Stock {  
   @Id  
   @GeneratedValue(strategy = GenerationType.AUTO)  
   private Long id;  

   private int quantity;  

   public Stock(int quantity) {  
      this.quantity = quantity;  
   }  

   public void minusQuantity() {  
      this.quantity--;  
   }  

   public int getQuantity() {  
      return this.quantity;  
   }  
}
@Repository  
public interface StockRepository extends JpaRepository<Stock, Long> {  
}
@Service  
@Transactional  
@RequiredArgsConstructor  
public class StockManageService {  

   private final StockRepository stockRepository;  

   public void saveQuantity(int quantity) {  
      Stock stock = new Stock(quantity);  
      stockRepository.save(stock);  
   }  

   public int minusQuantity(Long id) {  
      Stock stock = stockRepository.findById(id).orElseThrow(NoSuchElementException::new);  
      stock.minusQuantity();  
      return stock.getQuantity();  
   }  
}
@RestController  
@RequiredArgsConstructor  
public class StockManageController {  

   private final StockManageService service;  

   @GetMapping("/stock/save/{quantity}")  
   public int saveQuantity(@PathVariable int quantity) {  
      service.saveQuantity(quantity);  
      return quantity;  
   }  

   @GetMapping("/stock/minus/{id}")  
   public int minusQuantity(@PathVariable Long id) {  
      return service.minusQuantity(id);  
   }  
}
server:  
  port: 8081  # 프로젝트를 복사하고 8081로 수정 후 실행

spring:  
  datasource:  
    url: jdbc:mysql://localhost:3306/lock-test?useSSL=false&characterEncoding=UTF-8&serverTimezone=UTC  
    username: root  
    password: 1234  
    driver-class-name: com.mysql.cj.jdbc.Driver  
  jpa:  
    hibernate:  
      ddl-auto: none

로컬 환경에서 MySQL서버와 2대의 웹 서버(port:8080, 8081)를 실행시켰습니다. 앞으로 두 대의 웹 서버를 서버A, 서버B라고 칭하겠습니다. 그리고 다음 순서대로 API 를 호출했습니다.

1. 웹 브라우저에 localhost:8080/stock/save/1000 을 입력합니다.

  • 재고를 1000개로 초기화하는 API
  • 데이터베이스에 { id: 1, quantuty: 1000 } 레코드가 저장

 

2. JMeter 툴을 이용해 재고 감소 API를 500번 반복 호출합니다.

  • localhost:8080/stock/minus/1 x 500번
  • localhost:8081/stock/minus/1 x 500번

그림3. Jmeter 를 이용한 재고감소 API 호출

위 그림은 8080 포트 서버로 재고 감소 API를 요청하는 JMeter 설정입니다.
같은 방법으로 8081 포트 서버에도 재고 감소 API가 호출되도록 설정해줍니다. 다만, HTTP Request 설정의 Port Number 를 8081 로 해야합니다. 설정을 마치고 JMeter를 실행시킨 후, 로그를 확인해봤습니다.

위 그림은 서버A, 서버B의 로그입니다. "재고 감소 [767 → 766]" 로그가 총 두 번 출력된 것이 확인됩니다. 이것은 두 번의 재고 감소 요청에도 불구하고 한 번의 재고 감소만 이뤄졌다는 의미입니다. 두 서버가 데이터베이스에 동시에 레코드를 조회했기 때문입니다.

그림4. 재고 감소 1000번의 실행 결과

데이터베이스 테이블을 확인해봤습니다.

stock 테이블의 레코드 { id=1, quantity=1000 }에 -1씩 감소하는 동작을 1000번 반복했지만, 결과는 { id=1, quantity=458 } 이었습니다. 이로써 동시성 이슈가 발생하는 모습을 확인하였습니다. 이제 이 문제를 어떻게 해결할 수 있는지 알아보겠습니다.

 

기술 조사 - Database(MySQL) Lock


데이터베이스는 동시성 처리를 위해 락(Lock) 을 제공합니다. 일반적으로 모든 제품들이 락 매커니즘을 제공하고 조금씩 차이점이 있습니다. 이 글에서는 MySQL의 락을 알아보겠습니다.

 

락(Lcok) 이란?

트랜잭션 처리의 순차성을 보장하기 위한 장치입니다. 트랜잭션이 종료될 때 까지 다른 트랜잭션의 접근을 막아주는 기능을 합니다.

 

Lock의 종류

  • 락의 제한범위에 따른 분류
    • Shared Lock
    • Exclusive Lock
  • 락의 적용 대상에 따른 분류
    • Record Lock
    • Gap Lock
    • Next-key Lock
    • Auto Increment Lock

 

락의 제한 범위에 따른 분류

InnoDB에서 기본적으로 실행되는 행 수준(Row level)의 잠금입니다. 종류는 공유 락(Shared Lock), 베타 락(Exclusive Lock)이 있습니다. 트랜잭션 T1이 행 R에 대해 락을 걸었을 때, 트랜잭션 T2의 작업이 제한됩니다.

 

  공유 락(Shared Lock) 베타 락(Exclusive Lock)
읽기 O X
O(트랜잭션 격리 수준이 REPEATABLE_READ 이상인 경우)
쓰기 X X

 

예를 들어, 트랜잭션 T1이 행 R에 대한 공유락을 가지고 있다고 해보겠습니다.

  • 트랜잭션 T2가 공유 락을 요청한 경우, 즉시 새로운 락을 획득합니다. → 같은 행에 대해 T1, T2의 중복된 공유락이 허용됩니다.
  • T2가 베타 락을 요청한 경우 즉시 거절됩니다.

 

예를 들어, 트랜잭션 T1이 행 R에 대한 베타락을 가지고 있다고 해보겠습니다.

  • 트랜잭션 T2가 어떠한 락을 요청하더라도 즉시 승인될 수 없습니다.
  • 트랜잭션 T2는 행 R에 대한 락을 얻기 위해 트랜잭션 T1이 행 R에 대한 락을 해제할 때 까지 기다려야 합니다.

 

락의 적용 대상에 따른 분류

레코드 락(Record Locks)

MySQL에서는 레코드에 락을 걸지 않고, 인덱스 레코드(index record)에 락을 겁니다. 예를 들어, SELECT c1 FROM t WHERE c1 = 10 FOR UPDATE 와 같은 쿼리는 t.c1의 값이 10인 레코드의 인덱스에 락이 걸립니다. 따라서 다른 트잭색션은 t.c1의 값이 10인 행에 대해 삽입, 수정, 삭제하지 못합니다. 심지어 테이블에 대한 인덱스를 정의하지 않았더라도 InnoDB는 숨겨진 클러스터 인덱스를 생성하고 해당 인덱스에 락을 적용합니다.

 

갭 락(Gap Locks)

갭 락(Gap Lokc)은 인덱스 레코드 사이의 갭에 걸리는 락입니다. 예를 들어, SELECT c1 FROM t WHERE c1 BETWEEN 10 AND 20 FOR UPDATE 와 같은 쿼리는 범위 내의 모든 레코드를 갭 락으로 잠급니다. 다른 트랜잭션에서 t.c1에 값 15를 삽입할 경우, 갭 락에 의해 해당 명령은 보류됩니다.

 

넥스트 키 락 (Next-key Locks)

넥스트키 락(Next-key Locks)은 레코드락(record lock)과 해당 인덱스 레코드 이전의 갭(gap)에 대한 갭 락(gap lock)의 조합입니다. 예를 들어, 인덱스에 { 10, 11, 13, 20 }의 값이 있을 때, 가능한 넥스트키 락의 구간은 다음과 같습니다. (라운드 괄호는 해당 구간의 끝점을 제외하고, 네모 괄호는 해당 구간의 끝점을 포함합니다.)

  • [10, 11): 10과 11 사이의 갭
  • [11, 13): 11과 13 사이의 갭
  • [13, 20): 13과 20 사이의 갭
  • [20, +∞): 20 이후의 갭

일반적으로 트랜잭션 격리 수준이 "REPEATABLE_READ" 일 때, Phantom Read 현상이 나타납니다. 하지만, MySQL에서는 트랜잭션 격리 수준이 "REPEATABLE_READ" 임에도 이와 같은 현상은 발생하지 않습니다. 넥스트키 락을 활용하기 때문입니다. 자세한 내용은 관련 자료를 읽어주시길 바랍니다.

 

자동 증가 락(Auto Increment Lock)

AUTO_INCREMENT 칼럼이 사용된 테이블에 동시에 여러 레코드가 INSERT되는 경우, 저장되는 각 레코드가 중복되지 않고 저장된 순서대로 증가하는 일련번호 값을 가져와야 합니다. 이 때 사용되는 테이블 수준의 잠금입니다.

INSERT나 REPLACE 문장에서 AUTO_INCREMENT 값을 가져오는 순간 락이 걸렸다가 즉시 해제됩니다. 예를 들어, 두 개의 INSERT 쿼리가 동시에 실행되는 경우, 하나의 쿼리가 AUTO_INCREMENT 락을 걸고 나머지 쿼리는 AUTO_INCREMENT 락을 기다립니다.

AUTO_INCREMENT 락은 INSERT와 REPLACE 쿼리 문장과 같이 새로운 레코드를 저장하는 쿼리에서만 필요하며, UPDATE나 DELETE 등의 쿼리에서는 걸리지 않습니다.

 

해결 방법과 실습 


낙관적 락(Optimistic Lock)

  • 낙관적 락은 트랜잭션 대부분은 충돌하지 않을 것이라고 낙관적으로 가정하는 방법입니다.
  • 트랜잭션 충돌을 감지하는 것에 목적이 있습니다. (비관적 락의 목적은 트랜잭션 충돌을 방지)
  • 낙관적락은 데이터베이스의 락 매커니즘을 사용하지 않으며 애플리케이션 레벨에서 구현됩니다.
  • 낙관적 락은 트랜잭션을 커밋하기 전까지는 트랜잭션의 충돌을 알 수 없다는 특징이 있습니다.
  • 데이터베이스의 락을 사용하지 않기 때문에 락으로 인한 성능 저하가 적습니다.
  • JPA에서는 낙관적락을 쉽게 구현할 수 있도록 @Version 애노테이션을 제공합니다.

그림5. 낙관적 락 활용 예시 (출처: 자바 ORM 표준 JPA 프로그래밍, 김영한)

예를 들어, 두 트랜잭션이 게시물의 제목을 동시에 변경하려고 할 때, 낙관적 락은 다음과 같이 동작합니다.

  1. 트랜잭션1, 트랜잭션2가 동시에 게시물 조회
  2. 트랜잭션2가 게시물의 제목을 C로 변경 (version 값 1 증가)
  3. 트랜잭션1이 게시물의 제목을 B로 변경 시도 → 실패 (이유: 본래 알던 version ≠ DB version)

예시의 3번 과정에서 사용되는 쿼리는 다음과 같습니다.

UPDATE board SET title = 'B' WHERE version = 1;

 

낙관적 락(Optimistic Lock) 실습

JPA의 @Version 애노테이션을 이용해 낙관적락을 적용해봤습니다.

@Entity
@NoArgsConstructor
public class Stock {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Version
    private Integer version;

    private int quantity;

    public Stock(int quantity) {
        this.quantity = quantity;
    }

    public synchronized void minusQuantity() {
        this.quantity--;
    }
}

 

실행 결과, JPA에 의해 version 컬럼이 자동으로 만들어집니다.

그림6. JPA에 의해 생성된 version 컬럼

 

애플리케이션 서버의 로그를 확인해보면, 트랜잭션 충돌 시, ObjectOptimisticLockingFailureException 예외가 발생하는 것을 확인할 수 있습니다. 해당 예외가 발생했을 때, AOP 등을 활용하여 재시도를 하거나 롤백 처리를 구현해주면 됩니다. 만약 트랜잭션 충돌이 많이 발생하는 상황이라면, 재시도, 롤백 비용이 많아지므로 비관적 락 또는 분산락을 사용해야 합니다.

그림7. 낙관적락 사용시 트랜잭션 충돌에 의한 ObjectOptimisticLockingFailureException 발생

 

비관적 락(Pessimistic Lock)

  • 비관적 락은 트랜잭션 충돌이 발생할 것이라고 비관적으로 가정하는 방법입니다.
  • 트랜잭션 충돌을 방지하는데 목적이 있습니다. (낙관적락의 목적은 트랜잭션 감지)
  • 비관적 락은 앞서 살펴본 MySQL의 Lock과 같은 DB 락 매커니즘에 의존하는 방법입니다.
  • 데이터베이스 락 매터니즘으로 인한 성능 저하와 교착 상태(Deadlock) 발생 가능성이 있습니다.
  • JPA에서는 애노테이션을 통해 기존의 조회 쿼리에 락 매커니즘을 적용할 수 있습니다.

 

비관적 락(Pessimistic Lock) 실습

낙관적 락 실습 때, Stock 클래스에 추가했던 version 필드를 주석처리 합니다.

@Entity
@NoArgsConstructor
public class Stock {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // @Version
    // private Integer version;

    private int quantity;

    public Stock(int quantity) {
        this.quantity = quantity;
    }


    public synchronized void minusQuantity() {
        this.quantity--;
    }
}

 

JPA에서는 Lock 애노테이션의 여러 옵션을 제공한다.

  • PESSIMISTIC_WRIRE
    • 용도: 데이터베이스에 베타락(Exclusive Lock)을 건다.
    • 원리: 데이터베이스에 select for update를 사용해서 락을 건다.
    • 동작: 락이 걸린 로우는 다른 트랜잭션의 select for share, select for update, 수정 작업이 제한된다.
  • PESSIMISTIC_READ
    • 용도: 데이터베이스에 공유락(Share Lock)을 건다.
    • 원리: 데이터베이스에 select for share를 사용해서 락을 건다.
    • 동작: 락이 걸린 로우는 다른 트랜잭션의 수정 작업이 제한된다.
  • PESSIMISTIC_FORCE_INCREMENT:
    • 용도: 비관적 락중 유일하게 버전 정보를 사용한다.
    • 원리: nowait를 지원하는 데이터베이스에 대해서 fore update nowait 옵션을 적용한다.

 

비관적 락이라 하면 일반적으로 PESSIMISTIC_WRITE 옵션을 사용함을 뜻합니다. 또한 @QueryHints 를 통해 락 타임아웃 시간을 지정해줄 수 있습니다. JpaRepository 클래스의 코드를 다음과 같이 수정하였습니다.

@Repository
public interface StockRepository extends JpaRepository<Stock, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")})
    public Optional<Stock> findById(Long id);
}
  • @Lock(LockModeType.PESSIMISTIC_WRITE)
    • 조회 쿼리는 select ... for update 로 변경되어 레코드에 베타락을 적용합니다.
    • 트랜잭션이 해당 레코드의 락을 획득하고, 이후 다른 트랜잭션이 해당 레코드의 락을 획득하려면 기존 락이 해제될 때 까지 기다려야합니다.
  • @QueryHint(name="...timeout", value = "3000")
    • 기존 락이 해제될 때 까지 기다리는 최대 시간입니다.
    • 해당 시간이 지날 때 까지 락을 획득하지 못하면 LockTimeoutException 예외가 발생합니다.

 

다시 한번 동시성 이슈 테스트를 수행했습니다. 테스트 내용을 다시 한번 말씀드리면, 초기 재고 데이터가 1000개로 설정된 상태에서 2대의 서버에게 500번의 재고감소 명령을 내립니다. 실행 결과, 기대값 { quantity = 0 } 이 확인됩니다. 이로써 비관적락을 통해 동시성 이슈를 해결하는 과정을 알아봤습니다.

그림8. 비관적락을 적용한 후 테스트 실행 결과

 

애플리케케이션 서버에서 쿼리 로그를 볼 수 있게 설정해보면, 조회 쿼리가 select ... for update 로 변경되어 나가는 것을 확인할 수 있다.

그림9. 비관적락 적용시 조회 쿼리가 for update로 변경

 

분산 락(Distributed Lock)

분산락 (출처: 컬리 기술 블로그 https://helloworld.kurly.com/blog/distributed-redisson-lock/)

말 그대로 분산 환경에서 동시성 이슈를 해결하는 방법론입니다. 비관적락과 다른 점은 데이터베이스 객체(테이블 또는 레코드)에 락을 설정하는 게 아니라는 점입니다. 분산 락에선 별도의 컴포넌트를 락 매커니즘에 이용합니다. MySQL을 사용한다면 네임드락(NamedLock)을 이용하여 분산락을 구현할 수 있습니다.

 

네임드락은 임의의 문자열에 대해 잠금을 설정하는 기능인데, 이 잠금의 특징은 대상이 테이블이나 레코드 같은 데이터베이스 객체가 아니라는 점입니다. 네임드 락은 사용자가 지정한 문자열에 대해 획득하고 반납하는 잠금입니다. 네임드 락을 획득하려면 GET_LOCK() 함수를, 반납하려면 RELEASE_LOCK() 함수를 이용하면 된다.

 

개인적인 생각으로 비관적락과 비교해봤을 때, 분산 락을 활용하면 데이터베이스의 짐을 덜어준다고 생각합니다. 이미 레코드에 많은 락을 활용하고 있는 상황이라면, 쿼리가 수행될 때마다 인덱스와 테이블 등에 잠금을 설정하거나, 데이터에 접근해도 되는지 계산할 것이고, 이는 어느정도 비용이 드는 작업일 것입니다. 이런 상황에서 비관적락을 사용한다면 데이터베이스에 객체에 추가적인 락이 사용되고, 이는 쿼리 수행의 비용을 증가시키기 때문입니다. 반면에, 네임드락은 데이터베이스 객체에 잠금을 걸지 않기 때문에 데이터베이스에 부담을 덜 줄 것입니다.

 

분산락을 코드로 확인해보습니다. 구현하며 우여곡절이 많았는데, 자세한 스토리는 네임드락을 이용한 분산락 구현에 남겨놓겠습니다.

 

분산 락(Distributed Lock)

먼저 비관적락 실습시 작성했던 코드를 주석으로 변경하겠습니다. 

@Repository
public interface StockRepository extends JpaRepository<Stock, Long> {
	// @Lock(LockModeType.PESSIMISTIC_WRITE)
	// @QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")})
	// Optional<Stock> findById(@Param("id") Long id);

	@Transactional
	@Modifying(clearAutomatically = true)
	@Query(value = "UPDATE Stock s SET s.quantity = :quantity WHERE s.id = :id")
	int update(@Param("id") Long id, @Param("quantity") long quantity);
}
  • int update(...)
    • 더티 체킹을 이용하면 영속성 컨텍스트의 변경 내용이 쓰기 지연 저장소에 저장 됐다가 나중에 데이터베이스에 동기화됩니다.
    • 반면에 JPQL 기반의 메소드가 실행되면, 영속성 컨텍스트는 곧바로 flush 됩니다.
    • [락 획득] → [DB 조회] → [엔티티 변경] → [영속성 컨텍스트 플러시] → [락 반납] 과정이 올바르게 동작할 수 있도록 업데이트 쿼리를 JPQL로 작성하였습니다. 만약 그렇지 않으면 [락 반납] → [영속성 컨텍스트 플러시] 순서로 동작할 수도 있으며, 다른 트랜잭션이 아직 업데이트되지 않은 레코드 값을 조회하는 일이 일어날 수도 있습니다.
  • @Modifying
    • 업데이트 쿼리가 실행되기 위해 필요한 애노테이션입니다.
    • clearAutomatically=true 옵션은 해당 쿼리가 실행된 후, 영속성 컨텍스트를 clear 하도록 합니다. 이에 따라 캐시에서 엔티티를 조회하지 않고, 항상 데이터베이스로부터 데이터를 조회합니다. 두 대의 서버가 운영되는 상황이기 때문에 서버에 캐시된 데이터는 정확한 데이터가 아닙니다. 따라서 항상 데이터베이스로부터 값을 조회해주도록 하였습니다.

 

락을 획득하고 반납해주는 클래스 UserLevelLockWithJdbcTemplate를 작성합니다.

@Slf4j
@Component
@RequiredArgsConstructor
public class UserLevelLockWithJdbcTemplate {

	private static final String GET_LOCK = "SELECT GET_LOCK(?, ?)";
	private static final String RELEASE_LOCK = "SELECT RELEASE_LOCK(?)";
	private static final String EXCEPTION_MESSAGE = "LOCK 을 수행하는 중에 오류가 발생하였습니다";

	private final DataSource dataSource;

	public <T> T executeWithLock(String userLockName, int timeoutSeconds, Supplier<T> supplier) {
		try(Connection connection = dataSource.getConnection()) {
			try {
				getLock(connection, userLockName, timeoutSeconds);
				return supplier.get();
			} finally {
				releaseLock(connection, userLockName);
			}
		} catch (SQLException e) {
			log.error("", e);
			throw new RuntimeException(e.getMessage(), e);
		}
	}

	private void getLock(Connection connection, String userLockName, int timeoutSeconds) throws SQLException {
		try (PreparedStatement ps = connection.prepareStatement(GET_LOCK)) {
			ps.setString(1, userLockName);
			ps.setInt(2, timeoutSeconds);
			checkResult(ps, userLockName, "GetLock");
		}
	}

	private void releaseLock(Connection connection, String userLockName) throws SQLException {
		try (PreparedStatement ps = connection.prepareStatement(RELEASE_LOCK)) {
			ps.setString(1, userLockName);
			checkResult(ps, userLockName, "ReleaseLock");
		}
	}

	private void checkResult(PreparedStatement ps, String userLockName, String type) throws SQLException {
		try (ResultSet resultSet = ps.executeQuery()) {
			if (!resultSet.next()) {
				log.error("USER LEVEL LOCK 쿼리 결과 값이 없습니다. type = {}, userLockName = {}", type, userLockName);
				throw new RuntimeException(EXCEPTION_MESSAGE);
			}
			int result = resultSet.getInt(1);
			if (result != 1) {
				log.error("USER LEVEL LOCK 쿼리 결과 값이 1이 아닙니다. type = {}, result = {}, userLockName = {}", type, result,
					userLockName);
				throw new RuntimeException(EXCEPTION_MESSAGE);
			}
		}
	}
}

 

StockManageService 클래스를 변경합니다.

@Slf4j
@Service
@RequiredArgsConstructor
public class StockManageService {

	private final StockRepository stockRepository;

	private final UserLevelLockWithJdbcTemplate namedLockTemplate;

	public synchronized void saveQuantity(int quantity) {
		Stock stock = new Stock(quantity);
		stockRepository.save(stock);
	}

	public void minusQuantityWithLock(Long id) {
		namedLockTemplate.executeWithLock("my-lock", 5, () -> minusQuantity(id));
	}

	@Transactional
	public boolean minusQuantity(Long id) {
		Stock stock = stockRepository.findById(id).orElseThrow(NoSuchElementException::new);
		stockRepository.update(id, stock.getQuantity() - 1);
		return true;
	}
}

 

StockManageController 클래스를 변경합니다.

@RestController
@RequiredArgsConstructor
public class StockManageController {

	private final StockManageService service;

	@GetMapping("/stock/save/{quantity}")
	public int saveQuantity(@PathVariable int quantity) {
		service.saveQuantity(quantity);
		return quantity;
	}

	@GetMapping("/stock/minus/{id}")
	public void minusQuantity(@PathVariable Long id) {
		service.minusQuantityWithLock(id);
	}
}

 

실행 결과, 기대한 대로 결과 동작합니다. 이로써 분산락을 활용해 동시성 이슈를 해결해봤습니다. 이와 같이 동시성을 해결하는 방법은 다양한데요. 다음으로 어떤 상황에서 무엇을 사용하면 좋을지 정리해보겠습니다.

그림9. 분산락을 적용한 후 테스트 실행 결과

 

정리


구분 낙관적락 비관적락 분산락
정의 트랜잭션 충돌이 없을 것으로 가정하여 락을 걸지 않음 트랜잭션 충돌을 예상하고 미리 데이터베이스 객체에 락을 검 데이터베이스 객체가 아닌 별도의 컴포넌트에 락을 검
목적 트랜잭션 충돌 감지 트랜잭션 충돌 방지 트랜잭션 충돌 방지
사용법 JPA 사용시 @Version JPA 사용시 @Lock, LockModeType.PESSIMISTIC_WRITE
또는 직접 쿼리를 작성 
MySQL 사용시, NamedLock
장점 데드락 가능성이 적음
성능의 이점
충돌에 대한 오버헤드가 없음 충돌에 대한 오버헤드가 없음
비관적락에 비해 성능의 이점이 존재 
단점 충돌이 발생하면 재시도/롤백 등에 의한 오버헤드 발생 데드락 가능성이 존재
데이터베이스 락 매커니즘에 의한 성능 감소
데드락 가능성이 존재

 

참고 자료


댓글