일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |
- 자료구조
- Proxy
- C
- IT
- c언어
- react
- 알고리즘
- design pattern
- Spring
- spring webflux
- Galera Cluster
- 컴퓨터구조
- 운영체제
- redis
- 네트워크
- OS
- MySQL
- 자바
- Data Structure
- JPA
- 백준
- Java
- MSA
- 파이썬
- JavaScript
- mongoDB
- Kafka
- Heap
- Algorithm
- 디자인 패턴
- Today
- Total
시냅스
Spring 동시성 문제 해결 (비관적 락, Redis 분산락을 적용한 Annotation AOP) 본문
동시성 문제
https://liltdevs.tistory.com/83
애플리케이션에서 동시성 문제는 빈번히 발생할 수 있습니다.
Spring에서 각 Request 는 스레드를 사용하므로 공유자원에 대한 접근이 가능하기 때문입니다.
while(i == BUFFER_SIZE)
{
count++;
}
while(i == BUFFER_SIZE)
{
count--;
}
2개의 스레드가 위와 아래를 병렬로 실행하고,
count 가 100이었다면 실행을 마쳤을 때엔
단순히 한 쪽에선 더하기만 하고, 한 쪽에선 빼기만 하기 때문에 기대 값은 100일 것입니다.
하지만 실제로 count는 어떤 값이 나올지 모릅니다.
register1 = count
register1 = register1 + 1 // count = 6, interrupt
count = register1 // resume, count = 6
register2 = count
register2 = register2 - 1
count = register2 // count = 4
기계어로 연산이 실행되며 스레드1에서 count 가 register1 에 할당되면
더하는 과정에서 스레드2가 count 를 register2에 할당하여 값을 변경하기 때문입니다.
위와 같은 상황을 경쟁 상황, Race Condition 이라고 합니다.
현재 프로젝트는 공연에 대한 예매 시스템을 갖고 있습니다.
어떤 공연(Show)이 있다면 해당 회차(ShowDetail)의 좌석(Seat)에 대한 예매가 이뤄질 수 있는 구조입니다.
회차에 대한 좌석 정보를 따로 떼내어 놓음으로써
ShowDetail 이 일종의 Bucket 이 되는 구조로 Lock Striping 의 효과를 고려하였습니다.
다만, 각각의 좌석은 수량을 가지고 있고 그 수량은 사용자가 예매하거나, 취소될 때에 변경되어야 합니다.
이 때 사용자 다수가 변경을 하는 것에 대해 동시성에 관한 고민이 필요하였습니다.
동시성 문제를 해결할 방법들
- 비관적 락
- 낙관적 락
- 분산락
비관적 락
https://liltdevs.tistory.com/190
비관적 락은 DB 수준에서 제공하는 Locking 기법입니다.
충돌이 무조건 발생할 것이라는 가정 하에 수정에 대해서는 X-Lock 을 설정해 접근 자체를 막게 됩니다.
낙관적 락
낙관적 락은 Application 수준에서 제공하는 Locking 기법입니다.
여러 트랜잭션이 데이터를 동시에 수정하지 않는다는 가정하에 트랜잭션 충돌을 방지하는 기법입니다.
JPA 엔티티 내부에 @Version 이라는 컬럼을 통해 각각의 버전을 확인하고
가장 나중의 Version 을 업데이트 하거나, 가장 첫 번째의 Version 을 업데이트 합니다.
다만, 이 방법은 성능이 좋은 대신 동시에 들어오는 요청에 대해 어떤 버전은 작업이 거부되는 단점이 있습니다.
동시에 1, 2 번 요청이 들어왔다면 하나의 요청만 커밋이 승인됩니다.
공연 예매는 동시에 요청이 들어왔더라도 좌석이 남아 있다면 예매에 성공해야 합니다.
따라서 낙관적 락은 현재 프로젝트에서 사용하기 어렵습니다.
분산 락
- Named Lock
- MySQL 에서 기본적으로 제공하는 기능.
- 특정 String 에 lock 을 걸어 취득하고 반납.
- 다만 MySQL 서버의 부하가 큼.
- lock 을 걸려고 시도 하면서 lock 이 걸려있는지 지속적으로 확인해야하고, session 에 대한 관리 cost 가 듬
- Lettuce
- Spin Lock (Livelock) 방식으로 retry 로직을 개발자가 작성해야 한다.
- 따라서 애플리케이션에서 지속적으로 Lock 에 대한 취득을 확인함으로써 부하가 발생한다.
- Redisson
- Lock Interface 지원으로 쉬운 구현
- Pub-Sub 기반으로 Lock 구현 제공
- 특정 Channel 에 해제 되었다는 이벤트를 발행하면 구독하는 consumer 들이 lock 을 획득하는 방식
- 라이브러리 차원의 사용법을 익혀야 하지만 위의 2 방식보다 부하가 가장 적다.
위와 같은 이유로 Redisson 을 사용하게 되었습니다.
또한 분산락을 사용한다면 레코드 단위의 Lock 을 거는 비관적 락 보다 보다 효율적으로 사용할 수 있습니다.
한 Row에 다수의 lock 을 각각의 컬럼에 걸어야 한다면 비관적 락은 불가능하지만 분산 락은 가능하기 때문입니다.
아래에서는 비관적 락을 먼저 적용해보고, 이후 분산 락을 적용하면서 Transactional 과의 혼용을 위한 AOP 를 설정해보겠습니다.
동시성 문제 해결 - 비관적 락 사용
위에서 설명했듯, 비관적 락(X-Lock)은 특정 레코드에 Lock 을 걸며 시작합니다.
쿼리로는 SELECT ... FOR UPDATE 가 되겠지만 JPA 는 단순한 방법을 제공합니다.
@Lock(value = LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Seat s where s.id = :id")
Optional<Seat> findBySeatWithPessimisticLock(@Param("id") Long id);
public Seat getSeatWithPessimisticLockOrElseThrow(Long seatId) {
return seatRepository
.findBySeatWithPessimisticLock(seatId)
.orElseThrow(() -> new ImheroApplicationException(ErrorCode.SEAT_NOT_FOUND));
}
위의 설정을 통해 업데이트 하고자 하는 레코드에 X-Lock 을 설정할 수 있습니다.
Lock 의 value 로 X-Lock / S-Lock 을 설정할 수 있고
각각 LockModeType.PESSIMISTIC_WRITE / LockModeType.PESSIMISTIC_READ 로 설정합니다.
프로젝트에서는 Seat 라는 Entity 에 총 좌석 수와 현재 좌석 수를 컬럼으로 갖고 있고,
현재 좌석 수를 빼거나 더하면서 0이 되면 (가능한 좌석이 없으면) 예약이 되지 않은 시스템입니다.
실제로 JMeter 를 통해 동시성 문제가 발생하지 않는지 확인해보겠습니다.
JMeter 에서 위와 같이 설정합니다.
Request 는 인증이 필요하므로 Cookie 를 설정할 수 있도록 하겠습니다.
50개의 요청을 보낼 예정이고, 개별 요청마다 2개의 좌석을 예매하겠습니다.
따라서 총 100개의 예매가 발생되어야 하고 100개보다 크거나 적을 수 없습니다.
DB 에서도 미리 100건의 좌석이 예매될 수 있도록 준비하였습니다.
모두 예상한 대로 잘 실행되었습니다.
X-Lock 을 설정함으로써 요청마다 해당 레코드에 X-Lock 을 걸어 배타적으로 수행됐음을 짐작할 수 있습니다.
단, 위에서 간단히 알아보았지만 하나의 row 에 여러 lock 을 걸어야 하는 상황이라거나
분산 DB 환경이라면 X-Lock 을 사용한 제어는 어려울 수 있습니다.
따라서 아래에서는 Redisson 을 적용하여 보겠습니다.
동시성 문제 해결 - Redisson
Redisson 은 위에서 살펴보았듯 redis 기반의 구독 모델을 통해 lock 을 제어합니다.
Live lock 을 사용하지 않아 애플리케이션 부하가 적고, timeout 관련 설정도 제공합니다.
단 @Transactional Annotation 과 혼용하기 어려운 점이 있는데 아래에서 살펴보겠습니다.
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.redisson:redisson-spring-boot-starter:3.17.7'
- build.gradle
@Configuration
public class RedisConfig {
@Value("${spring.redis.host}")
private String redisHost;
@Value("${spring.redis.port}")
private int redisPort;
private static final String REDISSON_HOST_PREFIX = "redis://";
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort);
return Redisson.create(config);
}
}
- RedisConfig.java
redis:
host: localhost
port: 6379
- application.yaml
위와 같이 설정합니다.
향후 RedissonClient 를 DI 받아 사용할 수 있습니다.
아래는 분산락을 적용하는 간단한 예제입니다.
@RequiredArgsConstructor
@Service
public class DistributedLockService {
private final RedissonClient redissonClient;
@Transactional
public void DistributedLock throws(String keyName) {
RLock lock = redissonClient.getLock(keyName);
try {
// 각각 waitTime, leaseTime, time unit
boolean isLocked = lock.tryLock(2, 3, TimeUnit.SECONDS);
if (!isLocked) {
// 락 획득에 실패시
throw new RuntimeException( ... );
}
// ...
} catch (InterruptedException e) {
// interrupted
} finally {
// 락 release
lock.unlock();
}
}
}
lock 을 keyName 을 통해 설정합니다.
was 가 몇개이든 redis 를 통해 락을 획득하므로 해당 key name으로 오직 1개만을 보장합니다.
lock 을 획득한 후 필요한 수행 이후 lock 을 풀어주면 일련의 처리는 끝나게 됩니다.
다만 위의 방식에는 치명적인 문제가 있습니다.
만약 DB 의 격리 수준이 Repeatable Read 이고, lock 을 Transaction 내부에서 걸게 됐을 때
undo 를 참조하면서 락을 걸기 이전의 데이터를 참조하게 됩니다.
100개의 좌석이 남아있는 상황에서 1000개의 요청이 동시에 들어왔다면
Lock 은 순서대로 얻으면서도 undo 를 참조하므로 1000개의 요청이 100개의 좌석이 남아있다고 확인할 가능성이 높습니다.
따라서 lock 을 걸고 transaction 을 시작하고 commit 이후 lock 을 풀어주어야 합니다.
이를 간편히 하기 위해 AOP 로 작동하는 Annotation 을 구현하겠습니다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
TimeUnit timeUnit() default TimeUnit.SECONDS;
long waitTime() default 5L;
long leaseTime() default 5L;
}
- DistributedLock.java
@Aspect
@Component
@RequiredArgsConstructor
public class DistributedLockAop {
private static final String REDIS_LOCK_PREFIX = "LOCK:";
private final RedissonClient redissonClient;
private final MyTransactionManager myTransactionManager;
@Around("@annotation(com.imhero.config.aop.DistributedLock)")
public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);
String key = REDIS_LOCK_PREFIX + getParameterizedReservationRequest(joinPoint).getSeatId();
RLock lock = redissonClient.getLock(key);
try {
boolean isLocked = lock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit());
if (!isLocked) {
return false;
}
return myTransactionManager.proceed(joinPoint);
} catch (InterruptedException e) {
throw new ImheroApplicationException(ErrorCode.INTERNAL_SERVER_ERROR);
} finally {
lock.unlock();
}
}
private ReservationRequest getParameterizedReservationRequest(ProceedingJoinPoint joinPoint) {
Object[] args = joinPoint.getArgs();
ReservationRequest reservationRequest = null;
for (Object arg : args) {
if (arg instanceof ReservationRequest) {
reservationRequest = (ReservationRequest) arg;
break;
}
}
return reservationRequest;
}
}
- DistributedLockAOP.java
@Component
public class MyTransactionManager {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
return joinPoint.proceed();
}
}
- MyTransactionManager.java
위에서부터 각각 Annotation, aop 설정, transaction manager 입니다.
(저희 프로젝트에 맞춰 개발한 것으로 세부적인 것은 직접 설정하시길 권장드립니다.)
@DistributedLock 이 annotation 으로 설정된 메소드라면 AOP로 lock 을 먼저 걸고,
내부에서 transaction 을 MyTransactionManager 를 통해서 진행합니다.
메소드 진행이 끝나면 lock 을 release 하며 마무리합니다.
따라서 @Transactional 대신 @DistributedLock 을 사용하여 lock 과 transaction 제어를 할 수 있게 됩니다.
코드의 복잡도를 높이지 않고 단순히 애노테이션을 통해 분산 락을 설정할 수 있게 됐습니다.
동시성 문제와 해결 방법에 대해 비관적 락과 분산 락을 구현하며 알아보았습니다.
Redisson 을 사용하는 것은 효과적이지만 추가적인 인프라 구축에 대한 비용이 있을 수 있고
Redisson 자체적인 사용방법을 익혀야 할 필요도 있어 보입니다.
여러 trade off 를 고려하여 선택하시길 바랍니다.
끝!
'Java, Spring' 카테고리의 다른 글
JVM, Spring 에서의 시스템 변수와 환경변수 이해 (0) | 2023.11.28 |
---|---|
Spring Boot 3++ 를 위한 Spring Batch migration 요약 (1) | 2023.11.18 |
JWT 대신 Session 을 쓰는 이유 (Redis Session Clustering, Spring Security) (2) | 2023.07.17 |
HikariCP 설정과 유의사항 (0) | 2023.05.03 |
구현하며 이해하는 Spring MVC (0) | 2023.04.16 |