동시성 문제 극복하기
동시성 문제와 극복
목차
동시성 제어 방안
비관적 락
자원 요청에 따른 동시성문제가 발생할것이라고 예상하고 락을 거는 방법
- 장점
- 충돌이 자주 발생하는 환경에서는 롤백 횟수를 줄여서 성능상 유리
- 데이터 무결성을 보장하는 수준이 높다
- 트랜잭션이 자원을 점유하는 동안 다른 트랜잭션의 접근을 차단하여 데이터의 일관성을 보장
- 단점
- 하나의 트랜잭션이 자원에 접근시 락을 걸고 다른트랜잭션이 접근하지 못하게하므로 읽기가 많은 경우 성능상 손해
- 서로 자원이 필요한 경우에, 락이 걸려있으므로 데드락이 일어날 가능성이 있음
- 높은 락 경쟁으로 인해 성능 저하가 발생할 수 있음
- 구현 복잡도
- ★☆☆☆☆
- JPA 에서 지원하는 어노테이션을 사용하기만 해도 구현 가능 기존 소스를 거의 수정 안해도 된다
구현 방법
JPA 에서 지원하는 @Lock어노테이션의 LockModeType로 사용할 수 있다 - PESSIMISTIC_WRITE (주로사용)
- 베타 락 (쓰기에 락) 사용. 다른 트랜잭션에서 읽기/쓰기 모두 불가
- 더티리드가 발생하지않음
PESSIMISTIC_READ- 공유 락 사용. 다른 트랜잭션에서 읽기는 가능하나 쓰기는 불가능
PESSIMISTIC_FORCE_INCREMENT- 베타 락 사용하지만 낙관적 락처럼 버전 정보를 사용.
- 락을 획득하면 버전이 업데이트된다
1
2
3
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT r FROM ReservationEntity r WHERE r.id = :reservationId")
Optional<ReservationEntity> pessimisticFindReservationById(@Param("reservationId") Long reservationId);
mysql 기준 select 문에 for update를 추가하여 특정 데이터에 대한 비관적 락을 설정
1
select r.id, r.reserved from reservation_entity r where r.id = ? for update
낙관적 락
자원에 락을 걸어서 선점하지않고 동시성 문제가 발생하면 처리하는 방법
- 장점
- 충돌이 발생하지 않는다는 가정하에 동시 요청에 대한 처리 성능이 유리
- 트랜잭션 충돌이 발생할 때만 재시도를 하므로, 낮은 충돌율을 가진 시스템에서 효율적
- 단점
- 잦은 충돌이 일어나는경우 롤백처리에 대한 비용이 많이 들어 오히려 성능상 손해
- 버전 관리와 재시도 로직 구현이 필요하며, 복잡성이 증가할 수 있음
- 구현 복잡도
- ★★☆☆☆
- 엔티티에 버전 컬럼을 추가해줘야 하고 버전 충돌에 대한 예외 처리를 별도로 해줘야한다. 재시도등 고려할 사항도 존재
구현 방법
1
2
3
4
5
6
7
8
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "reservation")
public class ReservationEntity extends BaseEntity { // 생략
@Version
private Long version = 0L;
}
조회 시점의 버전과 커밋 시점의 버전이 다르면 충돌이 발생한것으로 판단하고 예외를 발생 실제론 아래처럼 쿼리가 생성된다
1
2
3
4
5
update reservation
set
생략...,
version = 2where
id = ? and version = 1
이미 이전 요청에서 버전이 2로 증가된 상태 이므로 예외가 발생
Redis
메모리 기반의 데이터 저장소 키-밸류(key-value) 데이터 구조에 기반한 다양한 형태의 자료 구조를 제공하며, 데이터들을 저장할 수 있는 저장소
- 스핀락 (Lettuce)
- 락을 획득하기 위해 계속해서 Redis에 락 획득 요청을 보내야 하는 구조
- 지속적으로 락 요청을 시도하여 CPU 자원을 소모할 수 있음
- pub/sub (Redisson)
- 스핀락과 다르게 락점유할때 까지 대기하지 않음 > - 대기 없이 tryLock 메소드를 이용해서 lock 획득에 성공하면 true를 반환
- lock을 구독하는 클라이언트는 해제 신호를 받고 lock 획득을 시도
- 장점
- 메모리 기반이므로 빠르게 락을 획득 및 해제 가능
- 분산 환경에서 락을 관리할 수 있어 확장성이 높음
- 단점
- 레디스 서버가 다운되거나 문제가 발생하면 해당 레디스 서버에 접근하는 모든 클라이언트가 영향을 받을 수 있기에, 레디스 서버가 단일 장애 지점이 될 위험이 있음.
- 구현 복잡도
- ★★★☆☆
- 설정, 클라이언트 구현까지 작업이 필요하지만 AOP등으로 재사용 가능하도록 구현하면 실 사용시 기존 소스를 거의 수정 안해도 된다.
구현 방법
Pub/Sub방식 Redisson을 AOP 방식으로 구현 AOP 에서 메서드를 실행시키기전 트랜잭션을 강제로 생성시켜 Lock이 생성되기전엔 트랜잭션이 시작 되지않도록 구현
AOP 시작 -> Lock -> 트랜잭션 -> 로직 실행 -> 커밋 -> Lock 해제- 트랜잭션 중 Lock이 생성될 경우 조회시점이 달라질 수 있고 락 획득 대기시간동안 DB 커낵션이 유지되므로 부하가 발생
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
// lock 이 시작하고 트랜잭션을 생성시켜주기 위한 컴포넌트
@Component
public class AopTransaction {
@Transactional(propagation = Propagation.REQUIRES_NEW) // 신규 트랜잭션 생성
public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
return joinPoint.proceed(); // 로직 실행
}
}
// 커스텀 어노테이션에 정의한 파라미터 값을 가져오기위한 클래스 SpEL 사용
public class CustomSpringELParser {
public static List<String> getDynamicValue(String[] parameterNames, Object[] args, String key) {
ExpressionParser parser = new SpelExpressionParser();
StandardEvaluationContext context = new StandardEvaluationContext();
for (int i = 0; i < parameterNames.length; i++) {
context.setVariable(parameterNames[i], args[i]);
}
Object value = parser.parseExpression(key).getValue(context, Object.class);
if (value == null) {
return Collections.emptyList();
} else if (value instanceof List<?> list) {
List<String> stringList = new ArrayList<>(list.size());
for (Object obj : list) {
stringList.add(obj.toString());
}
return stringList;
} else {
return Collections.singletonList(value.toString());
}
}
}
// Lock 이 시작하고 트랜잭션을 생성해주는 구현부
public class RedissonLockAspect {
// 생략
@Around("@annotation(io.hhplus.server_construction.support.aop.annotation.RedissonLock)")
public Object redissonLock(ProceedingJoinPoint joinPoint) throws Throwable {
// 생략
// lock 키 : 메서드명 + 지정한 키값
List<String> dynamicValue = CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), annotation.value());
RLock lock;
// 키값이 여러개일 경우 다중 Lock 사용
if (dynamicValue.size() > 1) {
List<String> lockKeys = dynamicValue.stream()
.map(value -> method.getName() + LOCK_PREFIX + value)
.toList();
lock = redissonClient.getMultiLock(lockKeys.stream()
.map(redissonClient::getLock)
.toArray(RLock[]::new));
} else {
lock = redissonClient.getLock(dynamicValue.get(0));
}
try {
// 락 획득 여부 확인
boolean isLocked = lock.tryLock(annotation.waitTime(), annotation.leaseTime(), TimeUnit.MILLISECONDS);
if (!isLocked) {
throw new IllegalStateException("failed to acquire lock");
}
// 트랜잭션 생성 + 로직 실행
return aopTransaction.proceed(joinPoint);
} catch (InterruptedException e) {
log.error(e.getMessage(), e);
throw e;
} finally {
// 락 해제
lock.unlock();
}
}
}
@RedissonLock(value = "#reservationConcertCommand.concertSeatIdList")
public ReservationConcertResult setConcertReservation(ReservationConcertCommand reservationConcertCommand) {
// 사용자 조회
User user = userService.findUserById(reservationConcertCommand.userId());
// 콘서트 좌석 조회 - 임시 예약 처리
List<ConcertSeat> concertSeatList = concertService.setSeatReservation(reservationConcertCommand.concertSeatIdList());
// 콘서트 예약
Reservation reservation = reservationService.setConcertReservation(concertSeatList, user);
return ReservationConcertResult.from(reservation);
}
Kafka
파이프라인, 스트리밍 분석, 데이터 통합 및 미션 크리티컬 애플리케이션을 위해 설계된 고성능 분산 이벤트 스트리밍 플랫폼.
Pub-Sub 모델의 메시지 큐 형태로 동작하며 분산환경에 특화.
- 장점
- 다수의 브로커로 구성되어 있어, 하나의 브로커가 다운되더라도 다른 브로커가 대신 처리할 수 있어, 고가용성을 보장
- 단점
- 서버의 수와 스토리지의 용량에 따라 비용이 증가, 관리 복잡
- 구현 복잡도
- ★★★★☆
비즈니스 로직 중 발생 가능한 동시성 이슈 파악
동시성 이슈가 발생할 포인트
- 좌석 예약: 다수의 사용자가 동시에 같은 좌석을 예약하려는 경우 충돌 가능성이 높음
- 결제: 결제 처리 중 데이터 일관성 및 무결성을 유지해야 하므로 동시성 문제가 발생할 수 있음
- 잔액 충전: 사용자의 잔액을 충전하는 과정에서 데이터 불일치나 무결성 문제가 발생할 수 있음
기능별 동시성 고려 포인트
좌석 예약
- 비관적 락
- 낙관적 락
- 분산락 (Redis)
- 최종 선택
- 분산 락
- DB 자원을 무한대로 늘릴수 없으므로 결국 분산락을 사용해야 한다고 생각
- 한사람이 여러좌석을 예약할 수 있는 구조기 때문에 Redisson의 MultiLock을 이용해서 구현
- 트랜잭션 뿐만아니라 별도의 작업들도 락을 획득해야 처리하도록 해서 불필요한 부하를 사전에 차단할 수 있음
- 비관적 락의 단점인 병목현상을 보완할 수 있음
- 기존에 Redis를 사용중이라면 (대기열 등등..) 적용해볼만하다고 판단
그리고 한번 써보자는 이유..
- 분산 락
결제
- 비관적 락
- 낙관적 락
- 분산락 (Redis)
- 최종 선택
- 비관적 락
- 금액관련 중요한 데이터 처리, 본인 자원에 접근하기 때문에 병목현상도 발생하지 않기 때문에 적용
- 비관적 락
잔액 충전
- 비관적 락
- 낙관적 락
- 분산락 (Redis)
- 최종 선택
- 비관적 락
- 결제와 동일한 이유로 비관적 락이 적합하다고 판단
- 비관적 락
This post is licensed under CC BY 4.0 by the author.








