개발 기록
> [Spring] Multi Thread - 선착순 티켓 예매로 알아보는 동시성 이슈 2. Pessimistic Lock, Optimisitc Lock, Named Lock 본문
> [Spring] Multi Thread - 선착순 티켓 예매로 알아보는 동시성 이슈 2. Pessimistic Lock, Optimisitc Lock, Named Lock
1z 2023. 12. 15. 20:26
■
0. 동시성 문제 - Reace Condition
여러 개의 프로세스(혹은 스레드들이)가 공유된 자원에(객체,필드,데이터 등) 동시 접근 및 수정할 때 발생하는 문제이다.
예를 들면 여러사람이 계산기를 함께 나눠 쓰는 것과 같다.
1. 사람 A가 계산기로 작업중 결과를 메모리을 저장한 뒤 잠시 자리를 비움
2. 사람 B가 계산기를 만져 저장한 값을 다른 값으로 변경 해버림
3. 사람 A가 작업을 마저 처리할려고 했을 때, 사람 A는 본인이 저장한 값이 아닌 엉터리 값을 이용하게 된다.
즉 둘 이상의 입력 또는 조작의 타이밍이나 순서 등이 결과값에 영향을 준 상태(=Race Condition) 가 발생한다.
■
1. 동시성 문제 (데이터 정합성) 해결 예제
선착순으로 10장의 티켓을 30명의 사용자가 예매 하기
= 초기 자원값은 10 이고, 이를 30개의 쓰레드가 각각 개별로 1씩 감소하여 0으로 만드는 테스트를 작성하고, 총 자원 결과값과 작업 성공/실패 한 스레드 수를 알아보자
★ 아래 4가지 방법을 이용하여 동시성 문제를 해결해본다.
1. Application Level 에서 해결 : Synchronzied 키워드 사용
2. Database Level 에서 해결 : DataBase Lock (원문)
- 비관전 락 (Pessimistic Lock)
- 낙관적 락 (Optimistic Lock)
- 네임드 락 (Named Lock)
■
2. JPA 락 사용 (비관적락, 낙관적 락)
1) 비관적 락(Pessimistic Lock) 사용
트랜잭션의 충돌이 발생한다고 가정하고 우선 락을 걸고 보는 방법이다. 이것은 데이터베이스가 제공하는 락 기능을 사용한다. 주로 SQL 쿼리에 select for update 구문을 사용한다.
1. LockModeType 속성
- PESSIMISTIC_READ : 비관적 락, 읽기 락을 사용 (데이터를 반복 읽기만 하고 수정하지 않는 용도)
- PESSIMISTIC_WRITE : 비관적 락, 쓰기 락을 사용 (일)
- PESSIMISTIC_FORCE_INCREMENT : 비관적 락 + 버전 정보를 강제로 증가한다.
2. 특징
ⓛ 데이터를 수정하는 즉시 트랜잭션 충돌 감지
② DB의 Shared Lock, Exclusive Lock을 이용하여 트랜잭션이 시작할 때 실제데이터에 Lock을 걸어서 정합성을 맞춘다.
③ 다른 트랜잭션에서는 Lock이 해제되기 전까지 데이터를 가져갈 수 없다.
(1-1) 적용
▶ 1. Method Level 에 @Lock(LockModeType.PESSIMISTIC_WRITE) 을 적용한다. JPA 환경에서는 다음과 같이 선언해주면 MySQL에서 기본 옵션으로 제공하는 Pessimistic Lock을 사용할 수 있게 된다.
※ PESSIMISTIC_WRITE : NON-REPEATABLE READ 방지, 락이 걸린 로우는 다른 트랜잭션이 수정할 수 없다.
public interface PessimisticTicketRepository extends JpaRepository<Ticket, Long> {
// 트랜잭션 시작시 Shared&Exclusive Lock (배타적 잠금)적용
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Ticket> findById(Long id);
}
▶ 2. PessimisticTicketRepository 객체로 데이터를 조회해서 수정한다.

(1-3) 결과

티켓이 10장만 발급 된것을 확인 할 수 있다.

(1-4) 비관적 락과 타임아웃
비관적 락을 사용하면 락을 휙득할 때까지 트래잭션이 대기한다. 무한정 기다릴 수 없으므로 타임아웃 시간을 줄 수 있다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value ="5000")})
Optional<Ticket> findById(Long id)
(1-5) 단점
ⓛ 한 트랜잭션이 완료되기 전까지 다른 트랜잭션들은 대기상태에 빠지기 때문에 동시에 많은 요청이 들어 왔을 때 대기시간이 길어진다. 데드락 발생 가능성이 있다.
② 비관적 잠금은 단일 DB 환경에만 적용 가능하다. 분산 DB 환경에서는 동시성 제어의 효력을 잃는다.
2) 낙관적 락(Optimisitc Lock) 사용
트랜잭션 대부분은 충돌이 발생하지 않는다고 낙관적으로 가정하는 방법
데이터베이스가 제공하는 락 기능을 사용하는 것이 아니라 JPA 가 제공하는 버전관리 기능을 사용하여 데이터 정합성을 지킨다. 낙관적 락은 트랜잭션을 커밋하기 전까지는 트랜잭션의 충돌을 알 수 없다는 특징이 있다.

1. 속성
-1-1. NONE
ⓛ 데이터를 커밋하는 시점에 충돌을 알 수 있다.
② 락 옵션을 적용하지 않아도 엔티티에 @version 이 적용된 필드만 있으면 낙관적 락이 적용된다.
③ 용도: 조회한 엔티티를 수정할 때 다른 트랜잭션에 의해 변경(삭제)되지 않아야 한다. 조회 시점부터 수정 시점까지 보장한다.
④ 동작: 엔티티를 수정할 때 버전을 체크하면서 버전을 증가한다. => update 쿼리 사용
1-2. OPTIMISTIC
ⓛ 엔티티를 조회만 해도 버전을 체크한다.
③ 용도: 조회한 엔티티는 트랜잭션을 종료할 때 까지 다른 트랜잭션에서 변경불가를 보장.
④ 동작: 엔티티를 커밋할 때 버전을 조회해서 현재 엔티티의 버전과 같은지 검증한다. => select 쿼리 사용
1-3. OPTIMISTIC_FORCE_INCREMENT: 낙관적락 + 버전정보 강제
(2-1) 적용
▶ 1. version column 추가
도메인 객체에 Version 필드를 추가하여 현재 도메인 객체의 최신 Version 값을 추적할 수 있도록 한다.

▶ 2. 버전 불일치 시 처리 로직 추가 (AOP, Helper)
낙관적 잠금은 버전 불일치 처리를 서비스 단에서 담당해야 한다.
★2-1 방법 [1] AOP 로 처리
1. @Retry 어노테이션 정의
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Retry { }
2. @Aspect => 부가기능 작성 Class 생성
@Retry 어노테이션을 가진 메서드를 대상으로 실행한다. 예외 발생시 최대 1000번까지 0.1초 간격으로 재시도하도록 한다.
@Order(Ordered.LOWEST_PRECEDENCE - 1)
@Aspect
@Component
public class OptimisticLockRetryAspect {
private static final int MAX_RETRIES = 1000;
private static final int RETRY_DELAY_MS = 100;
@Pointcut("@annotation(Retry)")
public void retry() {
}
// 대상 객채의 샐행 전, 실행 후 또는 예외 발생 시점에 공통 기능 실행
@Around("retry()")
public Object retryOptimisticLock(ProceedingJoinPoint joinPoint) throws Throwable {
Exception exceptionHolder = null;
for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
// 대상 객체 메서드 실행
return joinPoint.proceed();
} catch (OptimisticLockException | ObjectOptimisticLockingFailureException | StaleObjectStateException e) {
exceptionHolder = e;
Thread.sleep(RETRY_DELAY_MS);
}
}
throw exceptionHolder;
}
}
3. @Retry 어노테이션 적용

★2-2. 방법 [2] Hepler 클래스에서 처리
현재 스레드가 이전 Version을 가지고 있어 티켓 예매 요청에 실패 할 경우 1ms 동안 대기 후 재시도 한다.
@RequiredArgsConstructor
@Service
public class OptimisticLockFacade {
private final OptimisticLockTicketService optimisticLockTicketService;
public void ticketing(final Long id) throws InterruptedException {
while (true) {
try {
optimisticLockTicketService.ticketing(id);
break;
} catch (Exception e) {
// retry
System.out.println("버전이 맞지 않습니다. !!!");
Thread.sleep(1);
}
}
}
}
(2-2) Test 결과
Version 충돌이 잦아, 수행 시간이 오래걸려 티켓 개수와 스레드 개수를 3개로 줄였다.

Version 에서 수정사항이 생겨서 Version의 값이 증가한 것을 확인 할 수 있다.

(2-3) 단점
ⓛ 버전 충돌로 인해 실패할 경우, 직접 예외를 처리하여 재시도하는 로직을 구현해야 한다.
② Version 충돌이 많기에 성능이 좋지 않다.
③ DB 기본 트랜잭션을 활용하지 않기 때문에 롤백을 직접 구현해야 한다.
④ version column 을 추가하기 위해 테이블 마이그레이션 필수적이다.
(2-4) 주의 해야 할점
ⓛ DB 커넥션 풀이 말라버릴 수 있기 때문에 별도의 Data Source를 사용하는 것을 권장한다.
② 요청이 들어온 순서대로 처리되는 것이 아니라, 재시도의 타이밍에 따라 결정되기에 요청 순서예 따른 서비스에는 부적절하다.
■
3. Named Lock 사용 - MySQL Native Named Lock
Metadata 단위 Lock 으로 이름을 가진 Lock을 획득하여 해제할 때까지 다른 세션은 해당 Lock을 획득할 수 없다

▶ 1. 특징
ⓛ Named Lock에서는 Thread가 아니라 Session이라고 부른다.
② 복잡한 로직을 처리할 때 로직을 하나의 트랜잭션으로 묶을 수 있다.
③ 분산락을 구현할 수 있다.
* 분산락: 공통된 저장소를 사용해서 자원이 사용 중인지 체크/획득/반납하는 형태 (분산된 서버들 간의 동기화 처리를 지원하는 락킹)
▶2. MySQL Native Named Lock Method
※ GET_LOCK과 RELEASE_LOCK 으로 분산 락(distributed lock)을 구현할 수 있다
| GET_LOCK(str, timeout) | (str) 로 (timeout) 동안 Lock 휙득 시도 |
| IS_FREE_LOCK(str) | (str) 로 Lock 을 휙득 여부 |
| IS_USED_LOCK(str) | (str) Lock 사용 유무 |
| RELEASE_LOCK(str) | 락 해제 |
(3-1) 적용
▶ 1. 네이티브 쿼리를 사용하여 GET_LOCK() 메서드로 Lock 호출, RELESE_LOCK() 메서드로 Lock 해제 하는 메서드를 생성한다.

▶ 2. 파사드 패턴을 적용하여 티켓 예매를 시도하기전에 Lock 을 휙득하고, 예매 완료후 해당 락을 해제한다.
@RequiredArgsConstructor
@Component
public class NamedLockTicketFacade {
private final NamedLockTicketRepository namedLockTicketRepository;
private final NamedLockTicketService namedLockTicketService;
@Transactional
public void ticketing(Long id) {
try {
/*GET_LOCK(String, timeout)
* 입력받은 이름(String)으로 timeout(단위: 초) 동안 잠금 획득을 시도한다.
* timeout에 음수를 입력하면 잠금을 획득할 때 까지 무한대기하게 된다.
* 한 세션에서 잠금을 유지하고 있는 동안에는 다른 세션(=thread)에서 동일한 이름의 잠금을 획득할 수 없다.
* GET_LOCK()을 이용하여 획득한 잠금은 트랜잭션(Transaction)이 커밋(Commit)되거나 롤백(Rollback)되어도 해제되지 않는다.
* GET_LOCK()의 결괏값은 1(성공), 0(실패), null(에러발생)을 반환한다.*/
int lockResult = namedLockTicketRepository.getLock(Long.toString(id));
System.out.println("lock 결과 :" + lockResult);
namedLockTicketService.ticketing(id);
} finally {
/* RELEASE_LOCK(String)
* 입력받은 이름(String)의 잠금을 해제한다.
RELEASE_LOCK()의 결괏값은 1(성공), 0(실패), null(잠금이 존재하지 않을 때)을 반환.*/
int result = namedLockTicketRepository.releaseLock(Long.toString(id));
System.out.println("lock 해제 결과 :" + result);
}
}
}
(1-2) 단점
ⓛ 락을 획득하기 위해 Connection을 유지해야 하므로 애플리케이션에서 데이터베이스에 Connection Pool이 부족할 수 있다
② Transaction이 종료 시 Lock이 자동 해제되지 않으므로 수동 해제처리를 해야한다.
③ 커넥션을 생성한 세션에서만 Connection 을 닫을 수 있으므로 주의해야 한다.
④ 데이터 소스 분리시 세션 관리도 수동해야한다.
⑤ MySQL 서버의 부하가 큼
④ 커넥션을 생성한 세션에서만 Connection 을 닫을 수 있으므로 주의해야 한다.
(1-2) 분산락 구현 : Pessmistic Lock VS Named Lock
- Pessmistic Lock은 column/row 단계에서 Lock을 걸지만, Named Lock은 metadata 단위에 lock을 건다.
- Pessmistic Lock은 타임아웃을 설정이 까다로워서 무한 대기에 빨질 가능성이 있는 반면 Named Lock은 타임아웃을 설정이 쉽다.
- Named Lock 을 사용하면 복잡한 로직을 처리할 때 로직을 하나의 트랜잭션으로 묶을 수 있다.
참고
자바 ORM 표준 JPA 프로그래밍
https://tecoble.techcourse.co.kr/post/2023-08-16-concurrency-managing/
'Spring' 카테고리의 다른 글
| > [Spring Security]- 인증 정보는 어디에 저장되는 걸까? (0) | 2024.01.10 |
|---|---|
| >[cache] EHcache 3.x 개념을 알아보고 Spring Boot 에서 사용해보자! (0) | 2024.01.05 |
| > [Spring] Multi Thread - 선착순 티켓 예매로 알아보는 동시성 이슈 1. 동시성 이슈 발견, Synchronized 로 해결 (0) | 2023.12.15 |
| > [Spring] Request Value 조작 - 1. HandlerMethodArgumentResolver 를 직접 구현해보자! (0) | 2023.12.07 |
| > [Spring] Bean 생명 주기에 사용자 정의 작업 연결 - @PostConstruct, @PreDestory (0) | 2023.12.01 |