
지난 글에서 경쟁 상태를 해결하기 위한 여러 방법들을 알아보고 실습을 해보았다. 여러 방식으로 락을 적용해 동시성을 지켜냈지만 그러다 보면 의문이 하나 생긴다.
애초에 데이터를 관리하는 데이터베이스란 놈이 왜 여러 요청이 동시에 데이터를 수정하는 것을 허용해서 우리를 피곤하게 했는가..
바로 성능(동시성)과 정합성 사이의 트레이드오프 때문이다. 그리고 이 둘 사이의 균형을 맞추기 위해 데이터베이스는 트랜잭션 격리 수준이라는 옵션을 제공한다.
요걸 자세히 알아보자.
Level 1: READ UNCOMMITTED
가장 낮은 격리 수준이다. 한 트랜잭션이 아직 커밋하지 않은 변경사항조차 다른 트랜잭션에서 읽을 수 있다. 뭔가 이름부터 사용하면 문제가 발생할 것 같은 느낌이 든다.
실제로 더티 리드라는 문제가 발생한다.
트랜잭션 A가 재고를 100개에서 99개로 변경했지만 아직 커밋하지 않았다고 하자. 이때 트랜잭션 B가 재고를 조회하면 '99개'라는 아직 확정되지 않은 값을 읽게 된다. 그런데 만약 트랜잭션 A가 롤백된다면 트랜잭션 B는 존재하지도 않았던 유령 데이터를 기반으로 명령을 수행하여 잘못된 판단을 내릴 수 있다.
즉 데이터 정합성이 깨지기 쉽기 때문에 실제 서비스에서는 거의 사용하지 않는다.
Level 2: READ COMMITTED : 커밋된 것만 믿기
대부분의 데이터베이스는 기본 격리 수준으로 READ_COMMITED를 사용한다.
‘다른 트랜잭션이 커밋한 데이터만 읽는다’ 라는 규칙이다.
더티 리드는 발생하지 않지만 이 규칙 때문에 한 트랜젝션이 진행되는 동안에도 다른 트랜젝션이 커밋을 하면 데이터가 계속 바뀌어 보이는 ‘반복 불가능한 조회 현상’이 발생한다. 그리고 이는 업데이트 유실이라는 결과를 만든다.
트랜잭션 A가 재고를 조회한다고 하자. 이 때 재고는 100개이다.
트랜잭션 B가 재고를 1개 차감하고 커밋합니다. DB 실제 재고는 99개가 된다.
트랜잭션 A가 같은 트랜잭션 내에서 다시 재고를 조회하면 커밋한 데이터를 읽기 때문에 이번에는 99개가 보인다.
하지만 트랜잭션 A는 이미 첫 조회(100개)를 기준으로 로직을 수행 중이었다. 그래서 트랜잭션 A는 결국 B의 변경사항을 덮어쓰게 된다.
이를 해결하기 위해 격리 수준을 높일 수 있다. REPEATABLE_READ 를 사용해보자. MySQL에서 기본 격리 수준으로 사용되는 수준이다.
Level 3: REPEATABLE READ - 스냅샷
REPEATABLE_READ 는 트랜잭션이 시작될 때의 데이터 상태를 스냅샷으로 저장해두고 트랜잭션이 끝날 때까지는 다른 트랜잭션이 커밋한 변경사항이 보이더라도 스냅샷을 기준으로 진행하는 방식이다.
구현 원리는 MVCC 라고 하는 Multi-Version Concurrency Control 이다. 데이터를 업데이트 할 때마다 원본을 덮어쓰는 대신 변경된 내용을 새로운 버전으로 만들고 이전 버전도 함께 보관한다. REPEATABLE READ 트랜잭션은 자신이 시작된 지점의 버전만 계속 읽는 방식으로 동작한다.
이렇게 하면 같은 트랜잭션 내에서는 항상 동일한 스냅샷을 읽으므로 ‘반복 불가능한 조회’ 문제가 발생하지 않는다.
아까와 같이 트랜잭션 A가 재고를 조회 재고 100개의 상태일 때 이를 조회하면 스냅샷을 생성한다.
그리고 트랜잭션 B가 재고를 1개 차감하고 커밋합니다. (DB 실제 재고: 99개)
트랜잭션 A는 여전히 자신의 스냅샷(재고: 100개)을 기준으로 로직을 수행합니다.
이후 마지막에 트랜잭션 A가 변경사항을 커밋하려고 할 때 데이터베이스는 "어? 내가 가진 실제 값(99개)과 수정하려는데 기반이 되었던 값(100개)이 다르네?"라는 것을 감지하고 둘 중 하나의 트랜잭션을 롤백시키거나 대기시켜 데이터의 정합성을 보장한다.
하지만 남아있는 문제가 있다. 팬텀 리드라는 문제로 한 트랜잭션 내에서 동일한 쿼리를 보냈을 때 해당 조회 결과가 다른 경우를 의미한다.
트랜잭션 A 가 ‘WHERE age > 10’ 의 조건으로 회원 목록을 조회하고 결과로 5명의 회원을 조회할 수 있었다고 하자. 이후 트랜잭션 B가 age = 15 인 새로운 회원을 추가하고 커밋했다. 트랜잭션 A가 동일한 조건으로 다시 회원을 조회하면 이전에는 없었던 새로운 회원이 생겨 결과가 6명이 될 수 있다.
여기까지 공부하고 나니 궁금증이 생겼다.
REPEATABLE_READ이 MySQL의 기본 설정이라 했는데 우리는 이 전 실습에서 H2 를 사용했었다.(이전 글 참고) 그러다보니 혹시 격리 수준을 높이면 동시성 문제가 해결이 될지 궁금해졌다!!
@Transactional(isolation = Isolation.REPEATABLE_READ)을 넣어서 실습을 진행해보자.
성공을 예상했으나 결과는 실패였다.

하지만 변화는 있었다.
이 전에는 그냥 실패였다면 이번엔 로그에서 실패 원인을 확인할 수 있었다.
2025-10-17T17:03:14.188+09:00 WARN 4484 --- [concurrency-stock] [pool-3-thread-2] o.h.engine.jdbc.spi.SqlExceptionHelper : SQL Error: 40001, SQLState: 40001 2025-10-17T17:03:14.188+09:00 WARN 4484 --- [concurrency-stock] [pool-3-thread-1] o.h.engine.jdbc.spi.SqlExceptionHelper : SQL Error: 40001, SQLState: 40001 2025-10-17T17:03:14.188+09:00 WARN 4484 --- [concurrency-stock] [ool-3-thread-10] o.h.engine.jdbc.spi.SqlExceptionHelper : SQL Error: 40001, SQLState: 40001 2025-10-17T17:03:14.188+09:00 WARN 4484 --- [concurrency-stock] [pool-3-thread-6] o.h.engine.jdbc.spi.SqlExceptionHelper : SQL Error: 40001, SQLState: 40001 2025-10-17T17:03:14.189+09:00 ERROR 4484 --- [concurrency-stock] [ool-3-thread-10] o.h.engine.jdbc.spi.SqlExceptionHelper : Deadlock detected. The current transaction was rolled back. Details: "STOCK"; SQL statement: update stock set product_id=?,quantity=? where id=? [40001-232] 2025-10-17T17:03:14.189+09:00 ERROR 4484 --- [concurrency-stock] [pool-3-thread-1] o.h.engine.jdbc.spi.SqlExceptionHelper : Deadlock detected. The current transaction was rolled back. Details: "STOCK"; SQL statement: update stock set product_id=?,quantity=? where id=? [40001-232]
어떤 차이가 있었길래 데드락이 발생했나.
격리 수준이 높아지자 데이터 일관성을 위해 락을 거는 시간이 늘어났기 때문이다. 그렇기 때문이 스레드끼리 충돌이 발생했고 데드락 로그를 확인할 수 있었던 것이다.
READ_COMMITTED를 기본값으로 가지는 @Transactional 을 사용할 땐 락이 걸리지 않는다. 다른 트랙잭션이 읽기를 자유롭게 할 수 있고 수정하는 순간에만 짧은 락을 건다. 때문에 거의 동시에 락 없이 데이터를 읽고 각자 수정을 하고 덮어쓰기를 시도한다. 이 과정에서 다른 트랜잭션의 변경사항이 무시되는 업데이트 유실이 발생하여 잘못된 결과가 나오는 것이다.
격리 수준을 높이면 스냅샷을 찍어 관리가 이루어진다. 때문에 수정이 발생할 때 그 행만 락이 걸리는 것이 아니라 데이터 정합성을 위해 더 넓은 범위에 트랜잭션이 끝날 때 까지 락이 유지된다.
100개의 트랜잭션이 동시에 같은 행을 수정하려고 하면서 각자 락을 획득하려고 경쟁하고 이 과정에서 서로가 서로의 락이 해제되기를 기다리는 순환 대기 상태(데드락)이 발생할 확률이 높아지는 것이다. 데이터베이스는 교착 상태를 감지하고 일부 트랜잭션을 강제로 롤백시키게 된다.
즉 처음엔 정합성이 깨진 오류, 격리 수준 변경 후에는 일부 트랜잭션이 실행되지 못하고 롤백된 가용성이 떨어진 오류이다.
아무래도 쓰기가 많이 발생하는 상황을 테스트 하다보니 격리 수준 변경만으로는 해결되지 않는 것 같다. 아마 읽기 위주의 작업이 발생하는 예시였다면 해결이 됐을 것이라 생각된다. 격리 수준을 높이는 것만으로는 동시성 문제가 해결되지 않는다는 사실을 알게되었다.
혹시나 스레드 대기 시간을 설정해주면 락 없이 동시성 제어가 가능한지 궁금해서 찾아보았지만 @Transactional(isolation = Isolation.REPEATABLE_READ) 설정 자체에 스레드 대기 시간을 직접 추가하는 표준적인 방법은 없다고 한다.
SERIALIZABLE : 가장 높은 격리 수준
최후의 방법이 있다. 데이터베이스 전체에 락을 걸고 모든 트랜잭션을 한 줄로 세우는 것!
동시성 문제 해결 측면에서는 성능이 더 좋지만 여러 스레드가 동시에 작업할 수 없게 되기 때문에 전체적인 성능이 저하된다. 때문에 잘 사용하지 않는다.
실습에서도 역시나 데드락이 발생했다.
처음 시작할 때 말했던 바로 성능(동시성)과 정합성 사이의 트레이드오프가 바로 이런 것이다. 격리 수준으로 동시성을 제어하니 데드락이라는 새로운 문제가 발생한 것이다.
REPEATABLE_READ 와 같은 격리 수준처럼 데이터베이스가 알아서 처리해주는 것을 암시적 락이라고 한다. 그리고 우리가 직접 SELECT FOR UPDATE 를 적어주는 것이나 Redis 를 활용하는 방식은 개발자가 직접 제어하는 명시적 락이라고 한다.
즉 우리는 필요한 순간에 적절한 격리 수준을 선택하여 사용할 수 있다.
암시적 락은 아무래도 특정 기능뿐만 아니라 서비스 전반의 데이터 일관성을 높이고 싶을 때 고려할 수 있을 것 같다. 그리고 READ_COMMITED라는 기본 격리 수준으로 빠른 속도를 유지하면서 충돌이 자주 생길 것으로 예상되는 특정 기능에 서는 명시적 락을 사용할 수 있다. 또한 읽기와 쓰기 중 많이 발생하는 것을 분석하고 적절한 방식을 사용해 트랜잭션 관련 문제를 해결할 수 있을 것 같다.
이제 동시성 제어와 트랜잭션에 대한 이해도가 높아졌으니 DB 락, Redis 등을 활용하여 트레이드오프를 직접 관리하면서 해결하고 개발해보자!!
'CS 먹고 레벨업~' 카테고리의 다른 글
| 귀하게 자란 내가 N+1 문제 같은 걸 봐도 될까? (4) | 2025.11.04 |
|---|---|
| JOIN이 느린건 내 골반이 멈추지 않는 탓일까? ㅜ.ㅜ (2) | 2025.10.28 |
| 자OO스가 모르는 것, 못하는 것, 내가 전부 가르쳐줄게. (0) | 2025.10.15 |
| 내 기술은 모두 한 단계 진화한다. (2) | 2025.10.01 |
| 서버 하나 추가해봐 (0) | 2025.09.24 |