[Spring] 동시성(Concurrency) 이슈 - Database(2)
데이터베이스의 동시성 이슈
이제 데이터베이스의 동시성 이슈에 대해 얘기해보겠습니다.
데이터베이스의 특징(격리 레벨과 MVCC)
우선 흔히 사용하는 RDBMS에서 데이터베이스가 어느 정도까지 동시성을 허용하는지 알 필요가 있습니다.
데이터베이스를 사용한다고 모든 동시성 문제를 해결해줄 수 있는게 아니기 때문입니다.
일단 데이터베이스의 동시성 이슈를 이해하기 위해 선행 지식으로 격리 레벨을 알아야 하지만 주제는 동시성 이슈이기 때문에 간단하게 설명하고 넘어 가겠습니다.
- Read Uncommitted - 커밋되지 않은 업데이트 내역을 읽을 수 있다
- Read Committed - 커밋된 업데이트 내역을 읽을 수 있다. 다만 하나의 트랜잭션에서 조회를 2번할 때 다른 곳에서 커밋한 값이 조회될 수 있다(Non-Repeatable Read)
- Repeatable Read - 초기에 조회한 값은 다른 곳에서 변경하더라도 계속 같은 값이 조회된다. 데이터 변경 시 UNDO 영역에 돌려 놓을 수 있는 상태의 데이터를 백업하고 실제 레코드를 변경한다. 문제 발생시 UNDO LOG로 롤백한다. 하지만 팬텀 리드가 발생해서 하나의 트랜잭션이 동작하는 동안 insert된 것을 읽어올 수도 있다.
- Serializable - 트랜잭션이 특정 테이블을 읽으면 다른 트랜잭션은 그 테이블의 데이터를 추가/변경/삭제할 수 없다.
InnoDB의 기본 격리레벨은 Repeatable Read입니다.
격리레벨 중 Read Commited와 Repeatable Read는 MVCC(Multi Version Concurrency Content)라는 도구를 이용해서 위와 같은 특징을 가지는 것이라고 볼 수 있습니다.
MVCC가 가지고 있는 특징에은 쉽게 말해서 "미리 본떠놓는 기능"이라고 할 수 있습니다.
그것을 "스냅샷"이라고 하는데요.
Repeatable Read에서는 하나의 트랜잭션이 시작될 때 해당 테이블을 기준으로 스냅샷, 즉 미리 본떠놓고 시작합니다.
그리고 해당 스냅샷을 기반으로 읽기를 수행합니다. 그러면 실제 데이터베이스에 데이터가 변경되더라도 스냅샷을 이용해 데이터를 조회하니 일관성있게 읽는 것이 가능합니다.
Read Committed에서는 하나의 트랜잭션이 시작될 때가 아니라 트랜잭션 내에서 데이터를 조회할 때 스냅샷을 생성하는데요.
해당 스냅샷을 이용해서 다른 트랜잭션에서 커밋된 업데이트 내역을 읽을 수 있습니다.
MVCC에는 UNDO LOG라는 것도 존재하는데요.
어떤 트랜잭션이든 데이터가 실제로 변경이 일어나면 UNDO LOG를 저장해놓고 실패 시 이 로그를 통해 데이터를 롤백시키는 역할을 합니다.
이제 격리 레벨과 MVCC의 충분한 설명은 끝났고 Repeatable Read까지의 격리레벨은 어디까지 동시성 제어를 하는지 다시 설명해보겠습니다.
Repeatable Read는 결국에 A 트랜잭션 안에서 같은 조회 쿼리를 사용했을 때 일관성있는 쿼리 결과 값을 도출할 수 있습니다.
쉽게 말해 데이터의 일관성을 보장할 수 있습니다.
이 상태에서 어떤 동시성 문제가 남았는지 살펴보겠습니다.
동시에 같은 레코드를 수정하면?
이전 글에서 CAS(Compare-And-Swap)에 대해 알아봤습니다.
CAS 알고리즘을 사용하면 데이터가 올바르게 처리되는 것을 확인할 수 있었습니다.
하지만 DB에서 수많은 트랜잭션이 동시에 하나의 레코드를 동시에 수정하려고 하면 CAS 알고리즘처럼 올바르게 처리될까요?
아쉽게도 그렇지 않습니다.
Repeatable Read 이하의 격리 수준에서 두 트랜잭션 간 수정 충돌이 발생하면, 대부분의 데이터베이스는 충돌을 감지하여 해당 트랜잭션을 실패시키고 롤백하는 방식을 택합니다.
다시 말해 데이터베이스에서 갱신 분실 문제(Lost Updated) 문제를 해결해줍니다.
예를 들어서 조회수를 올리는 비즈니스 로직을 상상해보겠습니다.
@Transactional
public void incrementWithSleep(Long id) throws InterruptedException {
ViewCount viewCount = viewCountRepository.findById(id)
.orElseThrow(() -> new RuntimeException("ViewCount not found"));
Thread.sleep(30000);
viewCount.incrementCount();
}
@Transactional
public void incrementWithoutSleep(Long id) {
ViewCount viewCount = viewCountRepository.findById(id)
.orElseThrow(() -> new RuntimeException("ViewCount not found"));
viewCount.incrementCount();
}
우선 A 클라이언트의 요청으로 incrementWithSleep 메소드가 실행됩니다.
트랜잭션이 시작된 후 데이터를 읽어옵니다. 초기 조회수는 0입니다.
그리고 30초동안 Sleep 합니다.
Sleep하는 동안 B 클라이언트의 요청으로 같은 레코드의 데이터를 미리 0에서 1로 증감시킵니다다.
그리고 30초가 지나고나면 A 클라이언트의 요청은 이미 업데이트 되었다고 판단하기 때문에 롤백됩니다.
결국에 2번을 실행했지만 count는 1이 저장됩니다.
동시에 클라이언트 2명이 요청을 했다면 한명의 트랜잭션은 롤백되는 현상이 생기겠죠.
갱신 분실 문제라는 것은 데이터를 덮어 씌우는 것을 말하는데, 롤백되니까 갱신 분실 문제가 해결됐습니다.
하지만 덮어 씌우지 않는 문제는 해결됐지만 개발자가 원한건 2명의 요청이니까 count가 2가되어야겠죠.
이를 위해서 해결 방법은 아래와 같습니다.
해결 방법 1. 비관적 락(Pessimistic Lock)
위의 문제는 가장 간단하게 비관적 락을 사용해서 해결할 수 있습니다.
@Lock(LockModeType.PESSIMISTIC_WRITE) // 비관적 락을 설정
@Query("SELECT v FROM ViewCount v WHERE v.id = ?1")
ViewCount findWithLockById(Long id);
@Transactional
public void incrementWithSleepAndLock(Long id) throws InterruptedException {
ViewCount viewCount = viewCountRepository.findWithLockById(id); // 비관적 락으로 데이터 조회
Thread.sleep(30000);
viewCount.incrementCount(); // 조회수 증가
}
@Transactional
public void incrementWithoutSleepAndLock(Long id) {
ViewCount viewCount = viewCountRepository.findWithLockById(id); // 비관적 락으로 데이터 조회
viewCount.incrementCount(); // 조회수 증가
}
위와 같이 똑같은 순서로 실행했을 때 하나의 트랜잭션이 findWithLockById를 사용하고 있다면 락이 걸리므로 다른 트랜잭션은 락이 끝날 때까지 대기합니다. 그러면 올바른 조회수 로직이 완성됩니다.
해결 방법 2. Redis
public void incrementViewCountByRedis2(Long id) {
String key = VIEW_COUNT_KEY_PREFIX + id;
redisTemplate.opsForValue().increment(key);
}
Redis는 위의 방법보다 무겁지만 또 다른 해결책 중 하나입니다.
Redis는 위와 같이 간단한 INCR 연산은 원자적(Atomic) 연산으로 처리하기 때문에 기존 RDBMS의 ACID를 보장하는 트랜잭션 방식과 달리 데이터를 올바르게 처리합니다.
또한 Redis를 선택해야하는 이유는 하나 더 있습니다.
과부하인데요.
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
InnoDB에서 위의 명령어를 수행하면 timeout 시간을 확인할 수 있습니다.
만약에 비관적 락을 유지하더라도 락을 기다리는 시간이 timeout 시간을 초과하면 롤백 처리 시킵니다.
그럴 때는 Redis 인메모리 DB를 사용해서 빠르게 처리하면 좀 더 많은 사용자의 부하 처리가 가능하겠습니다.
또한 timeout 시간이 아니더라도 Spring의 기본 Thread 갯수(200개)가 모두 대기 중이라면 전체 부하가 걸리는 현상도 발생할 수 있습니다.
갱신 분실 문제
위에서 제가 설명한 것중에 갱신 분실 문제가 해결된다고 했는데, 다시 등장한게 좀 의아할 수 있습니다.
여기서 설명할 갱신 분실 문제는 프론트엔드의 갱신 분실 문제입니다.
만약에 두명의 사용자가 하나의 게시글을 동시에 수정하면 어떻게 될까요?
이런 경우에 한명이 미리 저장하고 그 다음 사용자가 다시 저장하려고 하면 덮어씌워지는 갱신 분실 문제 현상이 나타납니다.
해결 방법 1. 낙관적 락(Optimistic Lock)
코드에 Version 값을 추가하고 프론트엔드에 전달해서 해당 버전과 일치하지 않으면 예외 처리하고 사용자에게 이미 갱신된 게시글이 있다는 알림을 해주는 것으로 해결할 수 있습니다.
The last resort is to manually set an exclusive lock on all the necessary rows (SELECT FOR UPDATE) or even on the entire table (LOCK TABLE). This always works, but nullifies the benefits of multiversion concurrency
개발자가 수동으로 모든 행(SELECT FOR UPDATE) 또는 전체 테이블(LOCK TABLE)에 대해 수동으로 단독 잠금을 설정하는 경우 작동은 하지만 다중 버전 동시성(MVCC)의 이점을 무효화합니다.
cyclicBarrier를 이용해 테스트 가능) 이런 경우에는 분산락, 스핀락을 이용해 처리한다고 한다.