동시성 문제로부터 비롯된 트랜잭션 격리 수준과 데이터베이스 락의 이해 과정
Q. 어떤 게시글에 여러 사람이 동시에 댓글을 다는 일은 흔하다. 만약 게시글 테이블에서 칼럼으로 댓글 개수를 기록하고 있으면, 트랜잭션을 어떻게 활용해야 동시성 문제를 해결할 수 있을까?
A-1. 트랜잭션 격리 수준
트랜잭션의 격리 수준은 여러 트랜잭션이 동시에 같은 데이터에 접근할 때 발생할 수 있는 문제들을 어떻게 격리시킬지를 정하는 설정입니다. 격리 수준에는 여러 옵션이 있지만, 주로 다음 네 가지 수준을 사용합니다:
READ UNCOMMITTED - 가장 낮은 격리 수준으로, 다른 트랜잭션에서 커밋하지 않은 데이터도 읽을 수 있습니다.
READ COMMITTED - 커밋된 데이터만 읽을 수 있어, "dirty reads"는 방지하지만 "non-repeatable reads"는 발생할 수 있습니다.
REPEATABLE READ - 트랜잭션이 시작되면 그 순간의 데이터를 '스냅샷'으로 보고, 그 스냅샷을 통해 같은 쿼리를 반복해서 수행해도 같은 결과를 얻습니다. 이는 "phantom reads"를 제외한 대부분의 문제를 방지합니다.
SERIALIZABLE - 가장 높은 격리 수준으로, 트랜잭션이 순차적으로 실행되는 것처럼 보장하여 모든 읽기 문제와 쓰기 문제를 방지합니다.
위에 든 예시 상황에선, SERIALIZABLE 을 제외하곤 똑같은 문제가 계속 발생할 것이다. ex) REPEATABLE READ 수준이여도 두 트랜잭션이 동시에 댓글 수를 먼저 "2"를 읽었다면, 각자의 스냅샷에 기반하여 작업을 수행하고 모두 성공적으로 커밋되지만 댓글 수가 "3" 이 될 것이다. (4가 되어야 정상)
하지만, 트랜잭션의 SERIALIZABLE 을 사용하는 것에는 큰 비용이 든다.
처음에는, 위와 같은 격리 수준을 설정해주면 내가 작성한 쿼리에 FOR SHARE, FOR UPDATE 와 같이 공유락과 배타락이 알아서 붙는건 줄 알았다. 하지만 이는 매우 틀린 이해이고, DBMS 에서 내부적으로 락을 사용하여 해당 격리 수준을 달성하는 것이라고 이해하면 된다.
SERIALIZABLE 격리 수준은 트랜잭션의 독립성을 최대화하여 동시성 문제를 방지하며, 이를 위해 데이터베이스 시스템은 내부적으로 락을 적절히 사용하여 트랜잭션들이 마치 순차적으로 처리되는 것처럼 관리합니다. 그러나 이 모든 과정은 DBMS 내부에서 자동으로 이루어지며, SQL 쿼리에 명시적으로 FOR UPDATE가 추가되지는 않습니다. 따라서, 특정 행에 대한 명시적인 배타적 락을 요구하는 경우, 개발자는 쿼리에 직접 FOR UPDATE를 포함시켜야 합니다.
알아서 잘 관리해주기 때문에, Spring 에선 어노테이션만 붙이면 되어서 편하다. 하지만, 이는 개발자가 제어할 수 없다는 것을 의미하며, 심각한 성능 저하를 초래할 수 있고, 데드락의 가능성도 높아진다. 심지어 이를 파악하기도 힘들다.
결론적으로
동시성 문제 해결에 있어서 데이터베이스의 격리 수준 조정만으로는 한계가 있습니다.
그리고
SERIALIZABLE 격리 수준은 트랜잭션의 독립성을 최대화하여 동시성 문제를 방지하며, 이를 위해 데이터베이스 시스템은 내부적으로 락을 적절히 사용하여 트랜잭션들이 마치 순차적으로 처리되는 것처럼 관리합니다. 그러나 이 모든 과정은 DBMS 내부에서 자동으로 이루어지며, SQL 쿼리에 명시적으로 FOR UPDATE가 추가되지는 않습니다. 따라서, 특정 행에 대한 명시적인 배타적 락을 요구하는 경우, 개발자는 쿼리에 직접 FOR UPDATE를 포함시켜야 합니다.
A-2. 명시적 락을 통해 막는다
장점:
SERIALIZABLE보다 더 세밀한 제어가 가능합니다. 필요한 부분에서만 락을 적용할 수 있으므로, 전체적인 성능 저하를 줄일 수 있습니다.데이터 일관성을 보장할 수 있습니다.
단점:
구현이 복잡할 수 있습니다. 언제, 어디에, 어떤 락을 걸어야 할지 결정해야 합니다.여전히 성능 저하와 데드락 문제가 있을 수 있습니다, 특히 잘못 구현된 경우 더 심각할 수 있습니다.
필요한 쿼리에 FOR UPDATE 직접 붙여서 사용해 주면 된다. 혹은, Spring Data JPA 를 활용한다면 다음과 같이 활용할 수 있다.
public interface UserRepository extends JpaRepository<User, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
User findUserForUpdate(Long userId);
}
다만, 데드락 발생 가능성을 항상 염두에 두고 조심히 다뤄야 한다.
또한, 비관적 락과 낙관적 락에 대한 이해도 필요하다.
비관적 락과 낙관적 락 모두 명시적 락의 범주에 들어가지만, 사용하는 상황과 목적이 다르기 때문에 구현 방법과 시나리오에 따라 선택해야 합니다. 각각의 특성을 이해하고 게시글의 댓글 수 업데이트 문제에 어떤 방식이 더 적합한지 살펴보겠습니다.
비관적 락 (Pessimistic Locking)
비관적 락은 데이터가 다른 트랜잭션에 의해 변경될 것이라고 "비관적"으로 가정하고, 데이터를 사용하기 전에 락을 걸어 동시성 문제를 미리 방지하는 방법입니다.
적용 방법:
- SQL에서는 SELECT ... FOR UPDATE 구문을 사용하여 데이터를 조회할 때 해당 데이터에 락을 걸 수 있습니다.
- JPA에서는 @Lock(LockModeType.PESSIMISTIC_WRITE) 어노테이션을 사용하여 특정 데이터 접근 시 비관적 락을 적용할 수 있습니다.
장점:
동시 수정이 발생할 경우 데이터의 일관성과 무결성을 보장합니다.데드락을 방지할 수 있도록 세밀한 락 관리가 가능합니다.
단점:
락으로 인해 데이터베이스의 리소스가 장기간 점유될 수 있어 성능 저하가 발생할 수 있습니다.트랜잭션이 락을 기다리는 동안 대기해야 하므로 응답 시간이 길어질 수 있습니다.
낙관적 락 (Optimistic Locking)
낙관적 락은 데이터가 변경되지 않을 것이라고 "낙관적"으로 가정하고, 데이터를 업데이트할 때만 충돌이 발생했는지 확인하는 방법입니다.
적용 방법:
데이터에 버전 번호 (version) 또는 타임스탬프를 두어, 업데이트할 때 현재 데이터의 버전과 저장된 버전을 비교합니다.JPA에서는 @Version 어노테이션을 사용하여 엔티티의 버전 관리를 자동으로 처리할 수 있습니다.
장점:
읽기 작업에서 성능 저하가 거의 발생하지 않습니다.리소스 점유 시간이 짧아 다른 트랜잭션의 대기 시간이 줄어듭니다.
단점:
업데이트 충돌이 빈번하게 발생할 경우, 충돌 해결 로직이 복잡할 수 있습니다.충돌 발생 시, 사용자에게 다시 시도하라는 메시지를 보내야 하는 등의 추가적인 처리가 필요할 수 있습니다.
댓글 수 업데이트 문제에 대한 추천
게시글의 댓글 수 업데이트와 같은 경우, 비관적 락을 사용하는 것을 추천합니다 (FOR UPDATE). 이는 데이터의 정확성을 유지해야 하는 상황에서 보다 안정적입니다.
근데 딱 설명만 들어도, 낙관락은 FOR SHARE, FOR UPDATE 를 활용하지 않는 것 같고, 역시 그렇다고 한다.
어쨌든, 처음 예시로 든 상황에선 FOR UPDATE 로 게시글 댓글 수를 업데이트하면 동시성 문제가 해결될 것이다.
여기서 잠깐, 낙관락이 나왔으니 MVCC 알아보고 넘어가자. 왜냐하면, 둘이 좀 많이 헷갈린다.
낙관적 락과 MVCC (Multi-Version Concurrency Control)는 서로 관련이 있어 보일 수 있지만, 실제로는 다른 메커니즘과 원리를 기반으로 동작합니다. 이 두 개념을 정확히 이해하고 구분하는 것이 중요합니다.
MVCC (Multi-Version Concurrency Control)
MVCC는 데이터베이스가 동시성을 관리하는 방법 중 하나로, 각 트랜잭션을 위해 데이터의 여러 "버전"을 유지함으로써 구현됩니다. MVCC의 주요 목적은 읽기 작업이 쓰기 작업과 충돌하지 않도록 하여 동시성을 높이는 것입니다.
작동 원리:
- 데이터 읽기: 트랜잭션이 데이터를 읽을 때, 그 트랜잭션의 시작 시점 이전에 커밋된 데이터의 "버전"을 읽습니다. 이는 읽기 작업이 쓰기 작업에 의해 방해받지 않도록 보장합니다.
- 데이터 쓰기: 데이터를 업데이트할 때, 새로운 버전의 데이터가 생성됩니다. 다른 트랜잭션이 이 새로운 데이터를 볼 수 있는지 여부는 그 트랜잭션의 시작 시점과 새 데이터의 생성 시점을 비교하여 결정됩니다.
낙관적 락 (Optimistic Locking)
낙관적 락은 데이터베이스의 데이터를 업데이트할 때 발생할 수 있는 충돌을 관리하는 전략입니다. 낙관적 락은 데이터에 충돌이 일어날 것으로 가정하지 않고, 대신 업데이트를 시도할 때만 데이터의 버전을 확인하여 충돌이 발생했는지를 검사합니다.
구현 방법:
- 버전 관리: 데이터 모델에 version 필드를 추가합니다. 데이터가 업데이트될 때마다 이 필드의 값이 자동으로 증가합니다.
- 충돌 검사: 데이터를 업데이트할 때, 저장된 version 필드의 값과 업데이트를 시도하는 트랜잭션에서 읽은 version 값이 일치하는지 확인합니다. 일치하지 않으면 OptimisticLockException을 발생시키고, 업데이트를 거부합니다.
MVCC와 낙관적 락의 차이
- 용도의 차이: MVCC는 주로 읽기 작업의 속도와 동시성을 향상시키기 위해 사용됩니다. 반면, 낙관적 락은 데이터의 업데이트 시 충돌을 관리하기 위해 사용됩니다.
- 구현 메커니즘: MVCC는 데이터베이스 시스템이 자동으로 여러 버전의 데이터를 관리하며, 낙관적 락은 애플리케이션 또는 데이터베이스가 데이터 버전을 명시적으로 체크해야 합니다.
결론
두 메커니즘은 동시성을 제어하는 방법론적 차원에서 연관성이 있지만, 낙관적 락은 애플리케이션 레벨에서 명시적으로 데이터 버전을 관리하고 충돌을 검사하는 반면, MVCC는 데이터베이스 시스템 레벨에서 자동으로 데이터의 다양한 버전을 관리하여 읽기와 쓰기 작업의 충돌을 방지합니다. 따라서, 게시글 댓글 수 같은 경우 낙관적 락을 통한 동시성 제어가 가능하긴 하나, 비관적 락을 사용하는 것이 일반적으로 더 일반적이고 신뢰성 있는 접근 방법입니다.
Q. 그니까 MVCC 는 내가 신경안써도 알아서 적용되어 있는거고, 낙관적 락은 개발자로서 동시성 제어를 위해 직접 적용해주고 해야한다는 거지?
A. 맞습니다!
(뿌듯)
이제 다시, 본 주제로 돌아가서, 동시성 문제 해결 방법에 대하여 생각해 보자.
이 정도면 충분할까? 결국 비관락을 활용하는 방식이 될 것이다.
사실, 이 외에도 synchronized 를 활용하는 방식 (Java 에서 제공, 한 메소드에 한 스레드만 접근하게 하여 동시성 제어가 가능하지만, 여러대의 서버 환경에서 곧 바로 오동작할 위험 매우 높음), Redis 와 같은 인메모리 데이터스토어를 사용하는 방식이 있다.
데이터 일관성이 엄격히 요구되지 않는 경우 (즉각 즉각 바로 업데이트 되어야 하는건 아닌 경우) 작업을 비동기적으로 처리하는 방식도 존재한다. 예를들어, 인스타그램 연예인의 팔로워 수의 경우 즉각즉각 1의 단위가 정확히 표현되어야 하는 것은 아닐거다. 그러면 팔로워 수를 업데이트 하는 작업을 비동기 큐에 넣고 실제 데이터베이스 업데이트는 백그라운드에서 처리하면 된다.
즉, 이는 즉각적인 일관성(Immediate Consistency)보다는 최종 일관성(Eventual Consistency)을 목표로 하는 방식이다.
사실상 트래픽이 매우 많은 큰 회사들, 특히 소셜 미디어 플랫폼 같은 곳에서는 주로 비관락 보다는 비동기 처리 방식을 활용할 것이다. 비관락은 동시성 제어에는 좋지만 성능과 확장성 면에선 적합하지 않기 때문이다. 규모 동시 사용자와 거대한 데이터 규모 때문에 데이터베이스 락이 성능에 미치는 영향이 심각할 수 있다.
그럼에도, 은행 이체와 같이 즉각적인 일관성이 중요한 경우에는 성능을 어느정도 포기하더라도 즉각적인 일관성을 보장할 것으로 예상된다.
다음번에는 레디스를 활용하여 동시성 문제를 제어하는 방법을 공부해보자.