동시성 문제로 정합성이 깨지는 문제 해결
문제 상황
스테디 프로젝트 에서는 게시물에 눌린 좋아요
를 기준으로 인기 게시물을 보여주는 기능을 제공하고 있습니다. 그런데 이 좋아요 기능에는 현재 문제가 있는데 같은 게시물의 대해서 동시에 좋아요 요청
이 들어오거나 따닥 문제가 발생
했을 때 정합성이 깨지는 문제
가 발생하고 있습니다.
아래 테스트 시나리오는 ExecutorService
와 CountDownLatch
를 사용하여 동일한 스터디의 대해서 유저가 좋아요 요청을 동시에 보내는 상황을 가정했습니다.
@Test
void updateSteadyLikeTest() throws InterruptedException {
int threadCount = 50;
ExecutorService executorService = Executors.newFixedThreadPool(50);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
Long userId = (long) (Math.random() * 10);
long steadyId = 1L;
steadyLikeService.updateSteadyLike(steadyId, new UserInfo(userId));
} finally {
latch.countDown();
}
});
}
latch.await();
}
발생하는 문제
테스트의 로그를 분석하면, 데이터베이스 작업 중 두 가지 주요 문제가 발생하고 있음을 알 수 있습니다.
- 중복 키 문제
- 로그:
Duplicate entry '1-1' for key 'steady_likes.steady_likes_unique'
- 설명:
steady_likes
테이블에steady_likes_unique
라는 유니크 제약 조건이 있는데, '1-1'이라는 값으로 새로운 레코드를 삽입하려고 하면서 유니크 제약 조건을 위반했습니다. 이는 같은 'steadyId'와 'userId' 조합을 가진 레코드가 이미 존재하지만, 이를 중복해서 삽입하려고 한 것으로 예상됩니다.
- 로그:
- 데드락 (Deadlock)
- 로그:
SQL Error: 1213, SQLState: 40001
및Deadlock found when trying to get lock; try restarting transaction
- 설명: 이 로그는 데이터베이스 트랜잭션 중
데드락
이 발생했음을 나타냅니다. 데드락은 두 개 이상의 트랜잭션이서로의 자원을 기다리면서 영원히 대기 상태에 빠지는 상황
을 말합니다.
- 로그:
중복 키 문제는 이해가 되지만 데드락은 왜 발생하는지 모르겠어서 데이터베이스에 접근해서 로그를 확인해보겠습니다.
중복 키 문제 원인과 분석
현재 데이터베이스는 MySQL을 사용하며, 트랜잭션 격리 수준은 기본값인 Repeatable Read
로 설정되어 있습니다. 이 수준에서는 트랜잭션이 시작되고 첫 번째 select 쿼리가 발생할 때 데이터의 스냅샷이 생성
됩니다.
이러한 설정에서 두 명의 사용자가 동시에 같은 게시물에 좋아요
를 누르게 되면, 각각의 트랜잭션이 '좋아요'가 아직 없다는 판단을 하게 됩니다. 따라서 두 트랜잭션 모두 '좋아요'를 삽입하려고 시도
합니다.
유니크 제약 조건과 충돌
하지만 데이터베이스에 유니크 제약 조건
이 설정되어 있고, 이 제약 조건은 동일한 데이터 조합을 가진 레코드의 중복 삽입을 방지합니다. 따라서, 두 트랜잭션이 동일한 좋아요 정보를 동시에 삽입
하려 할 때, 데이터베이스는 중복 키 에러
를 반환합니다.
즉, 동일한 회원과 스테디 조합을 중복으로 가질 수 없고 동시에 요청이 왔을 때 한 번만 처리되고 다른 하나는 처리되지 않는 것은 정상적인 흐름으로 간주할 수 있습니다.
데드락 발생 원인과 분석
------------------------
LATEST DETECTED DEADLOCK
------------------------
2023-12-11 15:18:10 140111275714304
*** (1) TRANSACTION:
TRANSACTION 3757572, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 10 lock struct(s), heap size 1128, 7 row lock(s), undo log entries 1
MySQL thread id 32, OS thread handle 140111230580480, query id 1840 61.101.34.110 root updating
update steadies set bio='Bio1',contact=null,content='Content1',deadline='2023-12-02',finished_at='2023-12-09',like_count=14,name='Steady Name1',number_of_participants=3,participant_limit=3,promoted_at='2023-11-24 10:12:33',promotion_count=3,scheduled_period='ONE_WEEK',status='FINISHED',steady_mode='ONLINE',title='Title1',type='STUDY',updated_at='2023-12-12 00:18:10.048351',view_count=503 where id=1
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 60 page no 5 n bits 184 index PRIMARY of table `dev_steady`.`steadies` trx id 3757572 lock mode S locks rec but not gap
Record lock, heap no 118 PHYSICAL RECORD: n_fields 23; compact format; info bits 64
...
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 60 page no 5 n bits 184 index PRIMARY of table `dev_steady`.`steadies` trx id 3757572 lock_mode X locks rec but not gap waiting
Record lock, heap no 118 PHYSICAL RECORD: n_fields 23; compact format; info bits 64
...
*** (2) TRANSACTION:
TRANSACTION 3757571, ACTIVE 1 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 9 lock struct(s), heap size 1128, 6 row lock(s), undo log entries 1
MySQL thread id 31, OS thread handle 140111231637248, query id 1841 61.101.34.110 root updating
update steadies set bio='Bio1',contact=null,content='Content1',deadline='2023-12-02',finished_at='2023-12-09',like_count=14,name='Steady Name1',number_of_participants=3,participant_limit=3,promoted_at='2023-11-24 10:12:33',promotion_count=3,scheduled_period='ONE_WEEK',status='FINISHED',steady_mode='ONLINE',title='Title1',type='STUDY',updated_at='2023-12-12 00:18:10.04817',view_count=503 where id=1
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 60 page no 5 n bits 184 index PRIMARY of table `dev_steady`.`steadies` trx id 3757571 lock mode S locks rec but not gap
Record lock, heap no 118 PHYSICAL RECORD: n_fields 23; compact format; info bits 64
...
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 60 page no 5 n bits 184 index PRIMARY of table `dev_steady`.`steadies` trx id 3757571 lock_mode X locks rec but not gap waiting
Record lock, heap no 118 PHYSICAL RECORD: n_fields 23; compact format; info bits 64
...
*** WE ROLL BACK TRANSACTION (2)
...
데드락의 이유를 확인 하기 위해 SHOW ENGINE INNODB STATUS
명령어를 사용했고 로그 먼저 살펴보겠습니다.
(1) 트랜잭션과 (2) 트랜잭션 모두 S-Lock을 보유
하고 있고 레코드를 업데이트 하기 위해 X-Lock 을 요청
하면서 양쪽 모두 레코드를 수정할 수 없는 데드락 상태
에 빠졌습니다.
그런데 코드를 살펴보면 트랜잭션에서 S-Lock이 사용될 곳이 예상되지 않았습니다. 어디에서 S-Lock이 사용되어 데드락에 빠진것일까요?
공식문서 에서 위에 내용을 찾을 수 있었습니다.
테이블에 외래 키 제약 조건이 정의되어 있는 경우, 제약 조건을 확인해야 하는
모든 삽입, 업데이트 또는 삭제는 제약 조건을 확인하기 위해 살펴보는 레코드에 공유 레코드 수준 잠금을 설정
합니다.
따라서, 아래와 같은 시나리오로 데드락이 발생한 것으로 보입니다:
- 1번과 2번 트랜잭션은 삽입 쿼리를 통해
S-Lock
을 획득합니다.(s-lock은 여러 트랜잭션에서 공유 가능) - 1 트랜잭션이 x-lock을 획득하려고 시도하지만, 2 트랜잭션에서 사용 중인 자원(S-Lock)으로 인해 대기 상태가 됩니다.
- 그 후, 2번 트랜잭션 도 x-lock을 획득하려고 시도하지만, 1번 트랜잭션에서 사용 중인 자원 때문에 대기 상태가 됩니다.
결과적으로 두 트랜잭션은 서로의 자원을 기다리면서 데드락 상태
에 빠집니다.
해결 방법: 외래키 제거
- 문제 상황을 개선하기 위한 접근 방식 중 하나로,
외래키를 제거
하고간접 참조
를 사용하여steady_id
를 관리하는 방안을 고려했습니다. 이 방법은 S-Lock을 피할 수 있어 데드락 발생 위험을 줄일 수 있습니다. 외래키를 제거하면 데이터 무결성 문제가 발생할 수 있지만, 코드 상에서Steady
를 먼저 조회한 후 ID 값을 삽입하기 때문에 무결성 문제는 발생하지 않을 것으로 예상했습니다.
결과로 데드락이 발생하는 문제는 해결
됐으나 정합성의 문제는 해결되지 않았습니다.
테스트 결과 Steady_Like 테이블에는 1개의 레코드가 존재하나 Steady의 집계 컬럼에서는 -1 이라는 값이 나오게 됩니다.. 왜 이런 결과가 나오는걸까요?
결과적으로는 삽입 쿼리에 유니크 제약 조건이 설정되어 있어 동일한 값을 삽입하려 할 땐 트랜잭션이 롤백
되지만, 이미 값이 존재하는 상황
에서 두 트랜잭션이 동시에 좋아요 취소
를 시도할 경우, 삭제된 레코드의 유무와 관계없이 집계 컬럼의 값이 감소하는 현상이 발생합니다.
트랜잭션 격리 레벨을 변경하는 것도 고려해 봤으나 격리 레벨을 변경한다 해도 같은 상태를 읽고 이후 변경을 시도하게 되면 똑같은 현상이 발생합니다.
해결 방법: 낙관적 락
낙관적 락(Optimistic Locking)은 데이터베이스의 동시성 제어 방식 중 하나입니다. 이 방식은 이름에서도 알 수 있듯이 '낙관적'으로 충돌이 일어나지 않을 것이라 가정하고 작업을 수행합니다. 즉, 데이터에 대한 변경을 시작할 때는 락을 걸지 않고, 실제 변경을 커밋할 때 충돌 여부를 검사하게 됩니다. 이러한 방식은 동시에 여러 트랜잭션이 같은 데이터를 읽는 경우가 많고,
실제로 데이터를 변경하는 경우는 드물 때 효과적
입니다.
그러면 애플리케이션에서 낙관적 락을 어떻게 적용할 수 있을까요?
동작 방식
- 데이터 읽기: 데이터를 읽을 때는 다른 트랜잭션과의 충돌 없이 자유롭게 데이터를 읽습니다. 이때 데이터의
버전 정보와 같은 메타데이터를 함께 읽어옵니다.
- 데이터 수정: 트랜잭션이 필요한 계산이나
변경을 수행
합니다.(커밋X) - 데이터 커밋: 트랜잭션이 변경 사항을 데이터베이스에 커밋하려 할 때, 원래
읽어왔던 메타데이터(버전 정보)의 값이 여전히 유효한지 확인
합니다. 만약 그 값이 변경되었다면, 다른 트랜잭션에 의해 해당 데이터가 수정되었을 가능성이 있습니다. 이 경우 롤백이 발생하고 예외가 반환됩니다. - 재시도 또는 오류 처리: 충돌이 발생한 경우
변경을 재시도
하거나 예외를 반환해 사용자에게 알릴 수 있습니다.
적용 방법
- 스프링 부트와 Hibernate JPA를 사용할 때 낙관적 락(Optimistic Locking)을 적용하는 방법은 간단합니다.
@Version
어노테이션을 사용하여 엔터티의 특정 필드에 버전 관리를 적용할 수 있습니다.
- 엔터티 클래스에 @Version 어노테이션 적용
@Entity
public class Steady {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@Version
private int version;
//
}
version
필드는 Steady Entity의 메타데이터(버전 정보)를 위한 필드입니다. JPA는 이 필드를 사용하여 데이터베이스와 엔터티간의 충돌이 발생하지 않는지 체크합니다.
- 테스트 코드 결과
낙관적락을 적용하니 아래 테스트코드가 성공하는것을 확인할 수 있었고 DB에서 정합성이 잘 맞는것도 확인되었습니다.
@Test
void updateSteadyLikeTest() throws InterruptedException {
int threadCount = 50;
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
AtomicLong userIdCounter = new AtomicLong(1);
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
Long userId = userIdCounter.getAndIncrement();
long steadyId = 1L;
steadyLikeService.updateSteadyLike(steadyId, new UserInfo(userId));
} finally {
latch.countDown();
}
});
}
latch.await();
Steady steady = steadyRepository.getSteady(1L);
List<SteadyLike> allBySteady = steadyLikeRepository.findAllBySteady(steady);
assertThat(steady.getLikeCount()).isEqualTo(allBySteady.size());
}
하지만 애플리케이션을 실행 후 성능테스트를 진행해 보니 아래 처럼 4개의 성공과 26개가 실패하는것을 확인할 수 있었습니다.
- @Version을 이용한 업데이트 과정
org.springframework.orm.ObjectOptimisticLockingFailureException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect) : [dev.steady.steady.domain.Steady#1]
at org.springframework.orm.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:304) ~[spring-orm-6.0.12.jar:6.0.12]
at org.springframework.orm.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:232) ~[spring-orm-6.0.12.jar:6.0.12]
at org.springframework.orm.jpa.JpaTransactionManager.doCommit(JpaTransactionManager.java:565) ~[spring-orm-6.0.12.jar:6.0.12]
- 해결 방법
낙관적 락(Optimistic Lock)을 사용할 때 발생할 수 있는 예외를 처리하고 반복 로직을 클래스를 분리
해서 처리할 수 있습니다. 클래스를 분리하는 이유는 SteadyService에서 재시도를 하게되며 MySQL의 기본 격리 레벨(Repeatable Read)을 사용하게 되면 트랜잭션의 첫 쿼리에 읽은 게시물 데이터를 유지하므로 실패되는 상황이 반복되기 때문입니다.
문제점
만약 N개의 트랜잭션이 동시에 시작했을때 낙관적 락에서는 첫번째로 수정 쿼리를 날린 트랜잭션만 성공하게 됩니다. 그러면 나머지 N-1개의 트랜잭션은 재시도를 하게되고 첫번째 쿼리를 날린 트랜잭션만 성공하며 나머지는 실패하고 재시도를 반복하게되어 애플리케이션의 부하가 많고 순서보장이 되지않을 확률이 높습니다.
해결 방법: 비관적 락
왜 비관적 락인가?
steadyRepository.getById(steadyId);
를 사용해 게시물을 조회할 때 x-lock
을 설정하면, 이 게시물 레코드에 다른 스레드가 접근하는 것을 차단합니다. 다시 말해, 한번에 하나의 트랜잭션만 해당 레코드에 접근할 수 있게 됩니다.
이런 방식을 선택하면 같은 스터디 레코드에 접근할 때 X-Lock 을 사용하게 되고 동시성 문제가 해결될 것으로 예상했습니다. 왜냐하면, 첫 번째 트랜잭션이 해당 레코드에 대한 작업을 완료하기 전까지는 다른 트랜잭션이 락 대기 상태에 머무르게 되기 때문입니다.
예상 효과:
- 데드락 문제 해결: X-lock의 도입으로 두 트랜잭션이 동시에 같은 레코드를 수정하려는 상황이 사라집니다.
- 중복 좋아요 방지: 트랜잭션이 순차적으로 진행되면서, 한 사용자가 동시에 여러 번 좋아요를 누르는 문제도 예방됩니다.
이렇게 비관적 락을 적용함으로써 동시성 문제를 근본적으로 해결하려는 시도를 했습니다.
현재 저희는 Hibernate JPA를 활용하고 있습니다. 그래서 비관적 락(Pessimistic Locking
)을 적용하는 것은 @Lock(LockModeType.PESSIMISTIC_WRITE)
어노테이션을 사용함으로써 굉장히 간단했습니다.
테스트 결과
성능 테스트 결과 비관적 락을 사용했음에도 실패가 발생하는 것을 확인할 수 있었습니다. 이유를 찾아보니 사용자 정보 조회
이후에 스테디 정보 조회
에 X-Lock을 사용하고 있습니다. 비관적락을 사용했지만 실패한 이유를 눈치채셨나요?
위에서 사용자 조회로 같은 상태의 스냅샷이 생성되고 그 이후에 비관적 락을 사용하지만 스테디 상태를 두 트랜잭션 모두 조회하지 못하므로 삽입을 시도합니다.(스냅샷에는 두 트랜잭션 모두 좋아요 상태 X) 하지만 실제론 이미 좋아요가 추가된 상태로, 중복 예외가 발생합니다. 이 부분은 사용자 정보 조회보다 스터디 정보를 먼저 조회하면서 같은 게시물에 대해 좋아요를 누르게 되면 첫 조회 쿼리부터 락 대기 상태에 들어가게 함으로 해결했습니다.
결론
결론적으로는 낙관적락을 사용하기로 했습니다. 낙관적 락은 데이터에 대한 충돌이 드물다고 가정하고, 일반적인 작업 중에 락을 걸지 않습니다. 이로 인해 데이터베이스에 대한 불필요한 락 요청이 감소하여 성능이 향상시킬 수 있으므로 선택했습니다. 좋아요 기능의 충돌은 자주 발생하지 않을 것으로 생각되고 발생된다고 하더라도 정합성의 문제가 생기지 않으므로 결정했습니다.