07_RDBMS_락을활용한_동시성제어보고서 - loveAlakazam/hh-08-concert GitHub Wiki
여기서 공유자원은 '좌석' 이며 2명의 유저의 예약신청은 트랜잭션이다.
지난주 과제에서는 동시성제어없이 동시성테스트를 하다보니 RaceCondition으로 실패가 나왔다.
테스트 실행시간: 1s 5ms
관련 커밋
-
낙관적락적용: 1a60c3c0
-
좌석예약 업데이트쿼리가 1번만 실행됨
Hibernate:
update concert_seats
set
concert_id=?,
concert_date_id=?,
created_at=?,
deleted=?,
is_available=?,
number=?,
price=?,
reservation_id=?,
updated_at=?,
version=? // 👈 자동으로 save에서 version이 업데이트됨
where id=? and version=?
테스트 실행시간: 1s 132ms
관련 커밋
- 공유락(s-lock) 적용: b74126b
s-lock
(공유락)은 다른 트랜잭션은 읽기가 가능하다, 하지만 좌석상태의 변경이 이뤄지지 않는다.
그렇다면 왜 비관적락 s-lock
은 이 테스트케이스에 적합하지 않은 락인지 그에 대한 이유를 찾아보자.
s-lock
은 읽기락이며 다른트랜잭션들도 읽을 수 있다. 그래서 예약하려는 좌석정보조회에 락을 걸었고 이를 실제 SQL로 표현하면 다음과 같다.
- 실제 SQL 쿼리문
START TRANSACTION;
SELECT cs.*
FROM concert_seats as cs
WHERE cs.id = :id LOCK IN SHARE MODE;
통합테스트 결과는 예상과 다르게 성공이 나왔다. 콘솔로그에서 SQL쿼리문의 로그를보면 2명의 사용자모두 공연좌석의 정보를 조회를 한다.
# 유저1 이 콘서트좌석정보를 조회함
Hibernate:
select
cs1_0.id,cs1_0.concert_id,
cs1_0.concert_date_id,
cs1_0.created_at,
cs1_0.deleted,
cs1_0.is_available,
cs1_0.number,
cs1_0.price,
cs1_0.reservation_id,
cs1_0.updated_at
from concert_seats cs1_0
where cs1_0.id=? for share
# 유저2가 콘서트좌석정보를 조회함
Hibernate:
select
cs1_0.id,
cs1_0.concert_id,
cs1_0.concert_date_id,
cs1_0.created_at,
cs1_0.deleted,
cs1_0.is_available,
cs1_0.number,
cs1_0.price,
cs1_0.reservation_id,
cs1_0.updated_at
from concert_seats cs1_0
where cs1_0.id=? for share
내부로직을 일부를 수정했다. 좌석의 reserve() 에서 이미 예약된좌석은 예외를 발생하는 로직이 있는데 이 로직을 잠시 주석처리 후 테스트를 해봤다.
public ConcertSeat extends BaseEntity {
public void reserve() {
// 이미 예약된 좌석
// if( !isAvailable ) throw new BusinessException(ALREADY_RESERVED_SEAT);
this.isAvailable = false;
}
}
실패한 다른 트랜잭션에서 발생한 예외를 보면 DataIntegrityViolationException
이며 이 예외의 내용은 다음과 같다
could not execute statement
[Duplicate entry '1' for key 'reservations.UKgkvtcnho475cr4f4xmqmv7t4d']
[
insert into reservations (
concert_id,
concert_date_id,
concert_seat_id,
created_at,
deleted,
payment_id,
reserved_at,
status,
temp_reservation_expired_at,
updated_at,
user_id
)
values (?,?,?,?,?,?,?,?,?,?,?)
]
Duplicate entry 라고 나왔는데 결국은 concertSeatId = 1인 콘서트좌석 정보를 변경쿼리를 호출하려고하는 것이다. 즉 insert문의 실행중 쓰기 충돌이 발생한것이다. 여러 트랜잭션이 동시에 insert를 시도한 것이므로 데이터베이스에서는 실패가 나올 수 밖에 없다. 따라서 s-lock
은 쓰기충돌을 막아줄 수 없다는걸 실습코드를 통해 알게됐다.
s-lock
의 특성상 다른트랜잭션도 s-lock
획득을 허용하며, 여러개의 트랜잭션들이 예약하려는 좌석을 읽는 것을 허용한다.
우연히 1개의 트랜잭션만이 먼저 update를 시도하게되어 테스트의 성공이 나왔지만, 실제로는 락을 가진 여러개의 트랜잭션들은 이 좌석이 비어있다고 판단하여 좌석의 정보를 변경하려고 쓰기접근을 수행하는 것이다.
s-lock
은 해당 데이터로우에 대해 동시에 읽기접근은 가능하지만, 쓰기접근을 할 때 충돌이 발생한다. 쓰기 충돌을 막는다는건 비관적락의 공유락과 배타락의 공통점이기도하다.
테스트 실행시간: 1s 28ms
관련커밋
- 배타락(x-lock) 적용: 267cdcd
예약을 하게되면 좌석의 상태값 변경이 필요해야되며 락을 가진 트랜잭션 외의 다른트랜잭션이 데이터변경을 막아야하기때문이다. 이 특징으로 인해 데이터의 정합성(데이터값들이 일치하는 상태)을 보장할 수 있다. x-lock
(배타락)에서는 락을 획득한 트랜잭션은 해당데이터로우에 대해서 읽기/쓰기가 모두 가능하다. 한편 다른트랜잭션들은 락을 얻을때까지 기다린다. 심지어 다른트랜잭션들은 해당 데이터로우에 대해서 쓰기뿐만 제한되는게 아니라 읽기접근마저 제한된다. 비관락을 적용했을때도 동시성테스트는 성공을 했다.
비관적락은 데이터베이스의 물리적인 락이기때문에 데이터베이스 성능에 영향을 줄 수 있다. 특히 쓰기접근을 할때 대기를 할 수 있기 때문에 x-lock의 경우에는 모든 접근을 차단하기때문에 데드락과 락 대기, 커넥션 풀 고갈과 같은 사이드이펙트가 발생할 수 있다.
하지만 낙관적락은 실제락이 존재하지 않은 '논리적락'이다. version의 필드정보의 비교를 하여 version이 다르다면 저장시 예외가 발생하면 바로 First Fail로 빠른 실패예외처리해준다. 즉 논리레벨로 동시성을 제어할 수 있다는 것이다. 그래서 대용량트래픽에 적합하다.
세개의 락을 적용해서 동시 예약요청 인원수를 늘려서 테스트를 해본결과 인원수가 커질수록 낙관적락의 속도가 다른락보다 처리속도가 빠르다는걸 아래 코드를 통해서 확인했다.
테스트코드
@Test
void 병렬요청_시간_응답을_기록한다() throws Exception {
int threadCount = 20;
ExecutorService executorService= Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
List<Future<Long>> results = new ArrayList<>();
for (int i = 0; i < threadCount; i++) {
final int index = i;
results.add(executorService.submit(() -> {
latch.countDown();
latch.await();
long start = System.currentTimeMillis();
try {
User user = userRepository.save(User.of("유저 " + index));
reservationService.temporaryReserve(
ReservationCommand.TemporaryReserve.of(user, sampleConcertSeat)
);
} catch (Exception ignored) {}
return System.currentTimeMillis() - start;
}));
}
executorService.shutdown();
executorService.awaitTermination(10, TimeUnit.SECONDS);
long avg = (long) results.stream().mapToLong(r -> {
try { return r.get(); } catch (Exception e) { return 0; }
}).average().orElse(0);
log.info("평균 응답시간: {}ms", avg);
assertTrue(avg > 0 , "체크완료");
}
동일한 테스트코드를 실행했을때 실행시간 락종류별로 결과 공유
이 테스트는 동시성이라기보다는 동일한테스트를 실행할때 락의 성능테스트이다. 동시요청자수는 threadCount 와 동일하며 threadCount를 증가시켰을 때 락의 실제실행시간을 비교하여 나타냈다.
- 동시요청자수 20명
- 낙관적락: 146ms
- 비관적락/공유락(s-lock): 202ms
- 비관적락/배타락(x-lock): 212ms
- 동시요청자수 50명
- 낙관적락: 324ms
- 비관적락/공유락(s-lock): 339ms
- 비관적락/배타락(x-lock): 389ms
- 동시요청자수 100명
- 낙관적락: 591ms
- 비관적락/공유락(s-lock): 636ms
- 비관적락/배타락(x-lock): 761ms
성능상 비교해보면 낙관적락
> s-lock
> x-lock
순으로 결과를 나타낼 수 있다. 부가적으로 락을 설정하는 로직도 version
을 추가하기만하면 되서 편리하다.
포인트 충전/ 포인트 사용 기능은 모두 "유저포인트"를 공유자원으로 한다. 여기서는 서로다른 유저들이 접근하는게 아니라, 같은 유저가 보유한 포인트에 대한 동시성제어이다. 포인트와 같은 돈 개념은 데이터의 정합성이 깨지지 않아야한다.
아래는 동시에 포인트충전/사용 하는 케이스이다. 두개의 트랜잭션이 동시에 실행을 하게되므로 실패가 나오는게 당연하다. 이 테스트의 목적도 동시에 충전과 사용을 하면 안되지만, 데이터의 정합성을 지켜야한다. 만일 이런 동시요청 상황이 나온다하더라도 먼저 락을 잡은 트랜잭션이 수행하고나서 다음 트랜잭션이 진행할 수 있어야한다.
@Test
@Order(1)
void 포인트_충전과_사용은_동시에_실행되면_안된다() throws Exception {
// given
long userId = sampleUser.getId();
// 먼저 10,000원 충전
userService.chargePoint(UserPointCommand.ChargePoint.of(userId, 10_000L));
// 두 작업이 동시에 실행되도록 조율하는 CyclicBarrier
CyclicBarrier barrier = new CyclicBarrier(2);
ExecutorService executor = Executors.newFixedThreadPool(2);
List<Future<Void>> results = new ArrayList<>();
// 충전 쓰레드
results.add(executor.submit(() -> {
barrier.await(); // 🔥 다른 스레드가 도달할 때까지 대기
userService.chargePoint(UserPointCommand.ChargePoint.of(userId, 5_000L));
return null;
}));
// 사용 쓰레드
results.add(executor.submit(() -> {
barrier.await(); // 🔥 두 스레드가 동시에 실행되도록 조율
userService.usePoint(UserPointCommand.UsePoint.of(userId, 5_000L));
return null;
}));
// when: 두 작업이 완료될 때까지 기다림
for (Future<Void> result : results) {
result.get(); // 예외 발생 시 여기서 잡힘
}
// then: 최종 포인트는 10,000 + 5,000 - 5,000 = 10,000
UserInfo.GetCurrentPoint info = userService.getCurrentPoint(UserPointCommand.GetCurrentPoint.of(userId));
assertEquals(10_000L, info.point());
}
실패메시지는 다음과 같다. 그 이유를 살펴보자. 만일 코드라인대로 순차적으로 진행한다면 10000원이 나와야한다. 충전쓰레드만 실행되어 15000원이 나왔다. 데이터의 정합성이 깨진 RaceCondition의 대표적인 사례다. 하지만 테스트의 목표는 동시에 충전과 실행을 하면 안된다.
org.opentest4j.AssertionFailedError:
필요: 10000
실제 :15000
이번에도 3개의 락을 사용해서 나타내보기로 했다.
관련커밋
- 낙관적락 적용: 40c1335
테스트 실행결과: 실패
유저포인트가 공유자원이므로 UserPoint에 version필드를 추가만 하면된다.
@Entity
@Table(name = "user_points")
public class UserPoint{
/**
* 유저 포인트 ID
*/
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name ="id", nullable = false, updatable = false)
private long id;
@Column(name ="point", nullable = false)
private long point = 0; // 잔액
@Version
private int version; // 👈 버젼 필드 추가
...
}
하지만 통합테스트케이스를 실행해보면 아래와같은 에러로 실패가 나온다.
java.util.concurrent.ExecutionException
: org.springframework.orm.ObjectOptimisticLockingFailureException
: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect)
: [io.hhplus.concert.domain.user.UserPoint#1]
OptimisticLockingFailureException
이 발생했다. 왜 발생했는지 살펴보자.
동시에 실행했다해도, 콘솔로그를 보면 '충전' 이 먼저 요청했고, '사용'이 그다음으로 요청했다.
먼저 '충전' 요청을 실행하여 row가 업데이트되었는데 '사용'에서의 version과 충전이후에서의 version이 서로 맞지 않아서 실패한거다.
동시 충전/사용 요청시 실행순서가 충전 -> 사용 일때
그림으로 보면 먼저들어온 트랜잭션의 변경으로 인해서 version이 변경됐으므로 다른트랜잭션은 version이 다르므로 변경을 할 수 없다.
동시요청은 못하지만 실행순서가 포인트 충전만되고 포인트 사용에서는 OptimisticLockingFailureException
이 발생한거다.
반대로 실행하다가 포인트 사용이 먼저 실행되고 나중에 충전 으로 될 수 있다.
동시 충전/사용 요청시 실행순서가 사용 -> 충전 일때
아래 테스트코드는 포인트 충전과 사용이 동시에 요청했을때 낙관적락으로는 데이터정합성을 지키기 어려움을 증명했으며, 실행할때마다 순서가 달라져서 데이터의 정합성이 깨짐을 보여준다.
@Test
void 포인트_충전과_사용이_동시에_진행할때_둘중하나는_OptimisticLockException예외를_발생시킨다() throws Exception {
// given
long userId = sampleUser.getId();
// 먼저 10,000원 충전
userService.chargePoint(UserPointCommand.ChargePoint.of(userId, 10_000L));
// 두 작업이 동시에 실행되도록 조율하는 CyclicBarrier
CyclicBarrier barrier = new CyclicBarrier(2);
ExecutorService executor = Executors.newFixedThreadPool(2);
List<Future<String>> results = new ArrayList<>();
// 충전 쓰레드
results.add(executor.submit(() -> {
try{
log.info("::: 포인트 충전 스레드 실행");
barrier.await(); // 🔥 다른 스레드가 도달할 때까지 대기
userService.chargePoint(UserPointCommand.ChargePoint.of(userId, 5_000L));
return "충전 성공";
} catch(ObjectOptimisticLockingFailureException e){
return "충전 충돌";
}
}));
// 사용 쓰레드
results.add(executor.submit(() -> {
try {
log.info("::: 포인트 사용 스레드 실행");
barrier.await(); // 🔥 두 스레드가 동시에 실행되도록 조율
userService.usePoint(UserPointCommand.UsePoint.of(userId, 5_000L));
return "사용 성공";
} catch (ObjectOptimisticLockingFailureException e) {
return "사용 충돌";
}
}));
// when: 두 작업이 완료될 때까지 기다림
List<String> messages = new ArrayList<>();
for (Future<String> result : results) {
messages.add(result.get()); // 예외 발생 시 여기서 잡힘
}
// 충전/사용 중 하나는 반드시 성공하고 하나는 충돌이어야 한다
log.info("결과메시지: {}", messages);
assertTrue(messages.contains("충전 성공") || messages.contains("사용 성공"));
assertTrue(messages.contains("충전 충돌") || messages.contains("사용 충돌"));
// then
UserInfo.GetCurrentPoint info = userService.getCurrentPoint(UserPointCommand.GetCurrentPoint.of(userId));
log.info("보유포인트 : {}", info.point());
assertTrue(info.point() == 15000 || info.point() == 5000);
}
# 1번째 통합테스트 실행 결과
INFO 99601 --- [concert] [main] .d.u.UserPointConcurrencyIntegrationTest : 결과메시지: [충전 충돌, 사용 성공]
INFO 99601 --- [concert] [main] .d.u.UserPointConcurrencyIntegrationTest : 보유포인트 : 5000
# 2번째 통합테스트 실행 결과
INFO 24054 --- [concert] [main] .d.u.UserPointConcurrencyIntegrationTest : 결과메시지: [충전 성공, 사용 충돌]
INFO 24054 --- [concert] [main] .d.u.UserPointConcurrencyIntegrationTest : 보유포인트 : 15000
포인트 충전과 사용이 동시에 들어왔을 때 먼저들어온 트랜잭션만 처리하고 나머지 트랜잭션은 수정하지 못하도록 막아준다. 하지만 동시 요청될때, 충전이 먼저될지, 사용이 먼저될지는 알 수 없으며 보유포인트에 대한 정합성에 깨진다.
테스트 실행결과: 실패
관련커밋
- s-lock 적용: 334b48d
@Service
@RequiredArgsConstructor
public class UserService {
...
@Transactional
public UserInfo.ChargePoint chargePoint(UserPointCommand.ChargePoint command) {
// 유저 포인트정보 조회 (공유락 사용)
UserPoint userPoint = userPointRepository.findUserPointWithSharedLock(command.userId());
if(userPoint == null)
throw new BusinessException(UserErrorCode.NOT_EXIST_USER);
...
}
@Transactional
public UserInfo.UsePoint usePoint(UserPointCommand.UsePoint command) {
// 유저 정보 조회 (공유락 사용)
UserPoint userPoint = userPointRepository.findUserPointWithSharedLock(command.userId());
if(userPoint == null) throw new BusinessException(UserErrorCode.NOT_EXIST_USER);
....
}
}
포인트사용, 포인트충전에서 포인트조회하는 로직에 공유락을 걸어봤다. 걸어보고 수행해봤더니 아래와 같은 에러가 나왔다. 즉, 둘다 포인트를 업데이트하려고했으나 쓰기경합이 발생하여 DeadLock이 발생했다.
java.util.concurrent.ExecutionException
: org.springframework.dao.CannotAcquireLockException
: could not execute statement
[Deadlock found when trying to get lock; try restarting transaction]
[update user_points set point=?,user_id=? where id=?];
SQL [update user_points set point=?,user_id=? where id=?]
Caused by: org.hibernate.exception.LockAcquisitionException
: could not execute statement
[Deadlock found when trying to get lock; try restarting transaction]
[update user_points set point=?,user_id=? where id=?]
낙관적락처럼 버젼이 다르면 빠른 실패를 나타내지않고, 쓰기 락을 얻어서 연산이 수행될 때까지 기다리다가 아무도 쓰기락을 얻지 못해서 발생하는 에러이다. 데드락이 발생하게되면 데이터베이스 뿐만 아니라 애플리케이션 성능에도 영향을 줄수 있다.
즉 동시에 포인트 충전/사용 을 하면 안된다는 조건에는 부합하지만, 읽기락은 두 트랜잭션 모두 갖고있고 서로 읽기가 가능하다. 하지만 쓰기접근했을때 아무도 락이 없다고 생각하여 동시에 쓰기 연산을 수행하려고했지만 데드락이 발생했고 성능저하이슈로 s-lock은 적합하지 않다.
테스트 실행결과: 성공
관련커밋
- x-lock 적용: e7f8de4
배타락의 경우에는 락을 얻은 트랜잭션은 읽기/쓰기 권한을 갖고 다른 트랜잭션은 읽기조차 안되기때문에 끝날때까지 무조건 기다려야한다. 여기서도 포인트충전이 먼저 실행되고 그다음에 포인트 사용이 실행된다. 포인트 충전을 보면 락을 얻은 트랜잭션은 읽기와 쓰기를 모두 실행하고있다.
Hibernate: select up1_0.id,up1_0.point,up1_0.user_id from user_points up1_0 join users u1_0 on u1_0.id=up1_0.user_id where u1_0.id=? for update
Hibernate: insert into user_point_histories (amount,created_at,deleted,status,updated_at,user_point_id) values (?,?,?,?,?,?)
Hibernate: update user_points set point=?,user_id=? where id=?
INFO 98807 --- [concert] [pool-2-thread-1] .d.u.UserPointConcurrencyIntegrationTest : ::: 포인트 충전 스레드 실행
INFO 98807 --- [concert] [pool-2-thread-2] .d.u.UserPointConcurrencyIntegrationTest : ::: 포인트 사용 스레드 실행
Hibernate: select up1_0.id,up1_0.point,up1_0.user_id from user_points up1_0 join users u1_0 on u1_0.id=up1_0.user_id where u1_0.id=? for update
Hibernate: select up1_0.id,up1_0.point,up1_0.user_id from user_points up1_0 join users u1_0 on u1_0.id=up1_0.user_id where u1_0.id=? for update
Hibernate: insert into user_point_histories (amount,created_at,deleted,status,updated_at,user_point_id) values (?,?,?,?,?,?)
Hibernate: update user_points set point=?,user_id=? where id=?
Hibernate: insert into user_point_histories (amount,created_at,deleted,status,updated_at,user_point_id) values (?,?,?,?,?,?)
INFO 98807 --- [concert] [main] .d.u.UserPointConcurrencyIntegrationTest : 소요시간: 16ms
Hibernate: update user_points set point=?,user_id=? where id=?
INFO 98807 --- [concert] [main] .d.u.UserPointConcurrencyIntegrationTest : 소요시간: 59ms
배타적락은 동시요청이 들어왔을 때, 먼저 쓰기선점을 하게되어 다른 트랜잭션의 접근을 막아서라도 데이터정합성을 깨지지않고 동시성을 사전에 막을 수 있다. 쓰기 선점이 있기에 유일하게 성공을 했다.
[동시성테스트1] 에서는 낙관적락을 사용하기로 했다. 왜냐하면 세개의 락모두 성공한다했을때 빠른 성능이며, 물리적인 실제 락이 아닌 논리락이기 때문에 많은 트래픽에서 처리가 가능하기때문이다. 좌석 예약을 성공한 사람 1명을 제외한 나머지사람들은 빠른 실패처리를 해줄수 있기때문이다.
[동시성테스트2] 에서는 여기서는 헷갈렸다 데이터의 정합성을 포기하고 낙관락을 사용할까, 아니면 동시요청을 막음으로써 데이터의 정합성을 희생시킬건지 어느락을 써야될지 많이 고민했다. 결국은 최종적으로 데이터의 정합성을 지키는 비관적락 배타락(x-lock) 을 사용하기로 했다. 동시에 충전/사용 요청하는 상황이 오면 안되겠지만, 동시에 요청을 해야되는 상황이 왔을 때에도 순서를 지켜서 데이터의 정합성을 지키는게 중요하다고 판단된다. '돈' 은 정합성이 깨지면 안된다. 따라서 다른락들도 적용해보면서 비교를 해봤는데, 배타락(x-lock)만이 포인트를 동시에 충전/사용 요청이 와도 유일하게 정합성을 지켜서 성공을 한 케이스이기 때문이다.