성능과 데이터 일관성의 트레이드오프: READ COMMITTED로 전환한 이유
LABS
Vigfoot
Keyword
#Database#backend
115.95.*.*
# [RESEARCH] 인덱스는 죄가 없었다: 범위 조건 쿼리와 InnoDB 갭 락(Gap Lock) 보틀넥 해결기
## 0. 개요
본 포스팅에서는 MariaDB(InnoDB) 환경에서 특정 시간 범위 조회 시 발생한 락 경합 문제를 다룬다. 인덱스가 정상적으로 작동함에도 시스템 처리량이 급감했던 원인을 분석하고, 트랜잭션 격리 수준 조정을 통해 동시성을 확보한 과정을 기록한다.
<br>
## 1. 서론
이 게시글이 첫 글인 만큼 나에게 정말 의미 있었던 이야기를 써볼까 한다.
RDBMS에서 `REPEATABLE READ`는 데이터의 일관성을 보장하는 강력한 격리수준이지만, 대량의 쓰기 작업이 동반되는 서비스에서는 예상치 못한 성능 저하의 원인이 되기도 한다.
특히 MariaDB(InnoDB)가 Phantom Read를 방지하기 위해 사용하는 **갭 락(Gap Lock)** 은 범위 검색 쿼리와 만났을 때 시스템 전체의 동시성을 저해하는 보틀넥이 된다. 본 포스팅에서는 시간 범위 조건 쿼리에서 발생한 락 대기 현상을 분석하고, 트랜잭션 격리 수준을 `READ COMMITTED`로 하향 조정함으로써 정합성과 성능 사이의 트레이드오프를 최적화한 사례를 다룬다.
<br>
## 2. 본론: 인덱스는 죄가 없었다
### 2.1. 평화롭던 서비스에 찾아온 불청객
데이터가 적을 땐 참 예쁜 서비스였다. 하지만 운영 기간이 길어지고 데이터가 수백만 건 단위로 쌓이면서 문제가 터지기 시작했다. 초당 60회, 피크 타임엔 100회까지 쓰기와 읽기 요청이 몰리자 서버가 비명을 지르기 시작했다.

> query plan
처음에는 가볍게 생각했다. **"아, 인덱스가 없나 보네?"** `created_at` 컬럼에 인덱스를 추가하고 실행계획을 분석해보았다.
분명 `range` 스캔도 잘 타고 속도도 빨랐다.
이걸로 상황은 종료인 줄 알았다. 하지만 그건 착각이었다.
<br>
### 2.2. 또 다른 불청객: 인덱스를 탔는데 왜 아직도 느린 걸까?
이상했다. 인덱스를 추가하고 시간이 얼마 지나지 않았음에도 병목 현상은 요지부동이었다.
```sql
INSERT INTO data (created_at, something, is_deleted)
VALUES ('2026-01-15 10:00:00', 'Lock Test', 0);
```
> 당시 문제가 되었던 쿼리 형태
새벽까지 야근하며 모니터링을 해보니, 쿼리 자체는 빠른데 저런 형태의 쿼리를 실행하기까지의 대기 시간이 비정상적으로 길었다.

> Lock으로 인한 병목현상
분명 조회하는 쿼리와 데이터를 입력하는 쿼리가 서로 다른 레코드를 건드리고 있는데, 왜 서로를 밀어내며 대기하고 있는 걸까?
단순한 슬로우 쿼리 로그만으로는 답을 찾을 수 없었다.
결국 시스템의 실시간 상태를 확인하기 위해
문제가 발생한 시나리오들의 쿼리 로그를 전부 찾아 대조해보았었다.
<br>
### 2.3. 범인은 '보이지 않는 락', 갭 락(Gap Lock)
며칠을 꼬박 관찰하여 겨우 찾아냈다.
원인은 **갭 락(Gap Lock)**이었다.
내가 사용했던 MariaDB(InnoDB)는 기본 격리 수준인 REPEATABLE READ로 데이터 일관성에 대해 결벽에 가까운 기준을 가지고 있었다.
```sql
SELECT * FROM data
WHERE created_at BETWEEN '2026-01-01' AND '2026-01-31'
```
> 조회 쿼리
```sql
INSERT INTO data (created_at, something, is_deleted)
VALUES ('2026-01-15 10:00:00', 'Lock Test', 0);
```
> 다른 싸이클의 Thread에서 수행하는 Insert 쿼리
(해당 삽입 구문이 조회 쿼리의 조건과 겹쳐서 대기 상태)
내가 특정 기간(예: 1월 1일 ~ 1월 31일)의 데이터를 조회하고 있으면,
그 사이에 새로운 데이터가 끼어드는 현상(Phantom Read)을 막기 위해
데이터가 존재하지 않는 빈 공간(Gap)까지 락을 걸어 잠그고 있었다.
이것이 **병목의 실체**였다.
조회를 수행하는 그 찰나에 다른 트랜잭션이 해당 범위에 INSERT를 시도하면,
이 갭 락에 막혀 조회가 끝날 때까지 하염없이 대기해야 했다.
쿼리는 인덱스를 타고 순식간에 끝났지만,
그 뒤에 줄 서 있던 쓰기 요청들은 **지옥의 대기열**에 갇혀 시스템 전체를 마비시키고 있었다.
<br>
### 2.4. 당연한 게 안 될 때는 **기본 환경**부터 의심해보자
원인을 알았으니 해결책은 명확했다.
하지만 고민이 깊었다.

> 출처: https://mariadb.com/docs/server/server-management/install-and-upgrade-mariadb/migrating-to-mariadb/migrating-to-mariadb-from-sql-server/mariadb-transactions-and-isolation-levels-for-sql-server-users
DB의 기본 격리 수준을 건드려도 괜찮은 걸까?
결론은 **READ COMMITTED**로의 하향 조정이었다.
<br>
### 2.5. 트레이드오프(Trade-off)의 결정
REPEATABLE READ: 완벽한 정합성 보장, 그러나 Gap Lock으로 인한 동시성 저하.
READ COMMITTED: 정합성 수준 하향(Phantom Read 허용), 그러나 Gap Lock 제거로 동시성 확보.

> AS_IS

> TO_BE
당시 우리 서비스에서는 완벽한 정합성보다 대량의 요청을 지연 없이 처리하는 **동시성**이 훨씬 더 중요한 가치였다.
(변경은 서비스 레이어 한 줄만 수정하면 되는 간단한 작업이었지만, 전체 영향도를 파악하기 위해 긴 승인 절차를 거쳐야 했다...)
결과적으로 READ COMMITTED 수준에서는 갭 락이 발생하지 않으므로 이 보틀넥을 원천적으로 제거할 수 있었다.
<br>
## 3. 마치며
격리 수준을 조정하고 다시 피크 타임을 맞이했다.
결과는 드라마틱했다.
락 대기 시간 지표는 0에 가깝게 떨어졌고, 초당 100회의 요청도 막힘없이 처리되었다.
인덱스만 쳐다보고 있었다면 절대 해결하지 못했을 문제였다.
엔지니어에게 중요한 것은 기술의 기본 설정을 맹신하는 것이 아니라,
현재 서비스 상황에 맞는 최적의 '트레이드오프'를 결정하는 능력임을 뼈저리게 느낀 순간이었다.