DB

[JPA] 동시성 이슈 해결 - 트랜잭션 격리 수준과 낙관적 락

날아 2024. 4. 30. 23:27
더보기

토이 프로젝트 게시글 참가를 하는 과정에서 동시성 이슈가 발생할 수 있다는 점을 깨달았다.

인원 제한이 있을 경우, 예외를 발생시키고 참가를 할 수 없도록 로직을 작성하기는 했으나 만약 다수의 인원이 동시에 참가버튼을 누른다면 동시성 이슈가 발생할 수 있다고 생각했다. (이걸 왜 이제서야.... 그래도 알게되서 다행이다.)

이를 해결하기 위해 방안을 찾아보던 중, 낙관적 락에 대해서 알게되었고 점차 리팩토링 해갈 예정이다. 

그리고 공부하며 DB 내의 트랜잭션 격리 수준과 JPA 에서 지원하는 낙관적 락, 비관적 락에 대해서 알게되었는데 이를 한 번 정리해보고자 한다. 

 

1. 트랜잭션이란?

데이터베이스의 상태를 변화시키기 위해 수행하는 작업의 단위 

 

즉, 데이터베이스 질의어를 통해 데이터를 변화시는 것을 말한다.

DML(insert, update, delete, select)가 해당한다.

 

예를 들어, project 엔티티 내의 아이디 1의 게시글을 읽어오고 (select) 참가모집 인원수를 변경(update)하는 일련의 작업 단위가 한 트랜잭션에 속한다고 볼 수 있다. 

 

트랜잭션의 특징은 크게 4가지로 구분된다. (ACID)

  • 원자성 (Atomicity)
    • 트랜잭션이 데이터베이스에 모두 반영이 되거나, 모두 반영이 되지 않아야 한다. 
    • 만약, 인원수를 변경(update)하고 최대 인원수를 만족했을 때 게시글의 상태가 변경되어야 할 때(update), 첫번째 update 문만 실행되고 두번째 update 문이 실행되지 않는다면 큰 오류가 발생할 것이다. 
  • 일관성 (Consistency)
    • 트랜잭션의 작업 처리의 결과가 항상 일관성이 있어야 한다.
    • 트랜잭션 처리 이후에도 데이터나 시스템이 가지고 있는 고정요소나 상태가 일관성이 있어야 한다는 것이다. DB 내의 기본 키, 외래 키 제약과 같은 무결성 제약 조건 등을 어기면 안된다. 
  • 독립성 (Isolation)
    • 동시에 실행되는 트랜잭션들이 서로에 영향을 미치면 안된다. 
  • 지속성 (Durability)
    • 트랜잭션이 성공적으로 완료되었을 경우, 영구적으로 데이터베이스 내에 저장되어야 한다. 

 

이 네 가지 특성 중 독립성을 완벽히 지키기 위해서는, 사실상 다수의 트랜잭션이 동시에 처리되지 않고 순차적으로 처리되어야 할 것이다. 하지만 순차적 처리는 애플리케이션 처리가 월등히 떨어질 수 있다는 문제가 있다.

 

따라서 실무에서는 적절한 트랜잭션 격리 수준을 설정하여 어느정도 동시성을 보장하고, 어느정도의 부정합성을 교환하는 트레이드 오프를 수행한다. 

 

 


2. 트랜잭션 격리수준

여러 트랜잭션이 동시에 수행될 때, 특정 트랜잭션이 다른 트랜잭션에서 변경하거나 조회하는 데이터를 볼 수 있게 허용할지 말지 정도를 설정한 기준 

 

예를 들어, 트랜잭션 A 내에서는 project 엔티티 내의 아이디 1의 게시글을 단순히 읽기만 하고, 트랜잭션 B에서는 해당 게시글을 일부 수정하려고 한다.

 

아래 4가지의 격리 수준은 "트랜잭션 A에서 조회한 게시글 데이터는 어떻게 보일까?"에 대한 답이 다르다. 

 

 

READ UNCOMITTED

START TRANSACTION;

INSERT intom member values (1, '날아');
 
  SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED ;

SELECT * from member WHERE id = 1;

//조회결과 있음
  • 각 트랜잭션에서의 변경된 내용은 커밋과 롤백에 상관 없이 다른 트랜잭션에서 볼 수 있다
  • 만약 트랜잭션 A에서 member 엔티티에 새로운 데이터를 insert 할 경우(ex. 날아 라는 이름을 가진 회원 데이터 저장), 이를 커밋하지 않은 상태에서도 트랜잭션 B는 날아 라는 데이터를 select 하여 읽을 수 있다.
  • 이처럼 어떤 트랜잭션에서 작업이 완료되지 않았음에도 , 타 트랜잭션에서 조회할 수 있는 부정합 문제를 Dirty Read 라고 한다.
  • 트랜잭션 A에서 데이터를 저장하지 않고 롤백할 경우, 트랜잭션 B에서는 해당 데이터가 보였다가 보이지 않는 심각한 데이터 오류가 일어날 수 있기 때문에 실무에서는 해당 격리 수준을 적극 지양한다.

 

 

READ COMMITTED

START TRANSACTION;

INSERT intom member values (1, '날아');
 
  SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED ;

SELECT * from member WHERE id = 1;

//조회결과 없음
COMMIT;  
  SELECT * from member WHERE id = 1;
//조회결과 있음

 

  • 각 트랜잭션에서의 변경된 내용은 커밋이 되어야만 다른 트랜잭션에서 볼 수 있다. 
  • Dirty Read는 발생하지 않지만 Non-Repetable Read(반복 읽기 불가능) 문제가 발생한다. 
  • 트랜잭션 A에서 날아 라는 이름을 가진 회원 데이터를 아직 커밋하지 않았을 때, 트랜잭션 B는 해당 데이터를 읽을 수 없다. 하지만 만약 A에서 데이터를 커밋한 뒤, B에서 해당 데이터를 다시 읽는다면 이때에는 데이터를 읽을 수 있다. 
  • 만약 금전적인 처리가 일어나는 트랜잭션에서는 큰 문제가 될 수 있다. 트랜잭션 A에서는 오늘 입금된 총 합을 계산하고, 트랜잭션 B에서는 계속해서 입금 내역을 커밋한다면 A 내에서는 총 합을 계산할 때마다 결과가 달라지는 문제가 생긴다.
  • 만약 매 조회마다 트랜잭션을 닫아버리면 상관이 없겠지만, 실무에서 한 트랜잭션 내에 select가 여러번 일어날 수 있는 상황은 충분히 가능한 상황이다. 한 트랜잭션 내에서 실행되는 조회 쿼리를 예측할 수 없다는 점은 큰 버그로 이어질 수 있다. 
  • postgresQL에서 기본적으로 채택하고있는 격리 수준이다. 

 

 

REPETABLE READ

  • MYSQL에서 기본적으로 채택하고 있는 격리 수준이다.
  • MYSQL의 InnoDB 엔진은 변경 전의 레코드를 undo 공간에 백업 해둔다. (롤백을 염두에 둔 것) 이렇게 변경 전/후의 데이터를 모두 존재하게 하고 변경하는 방식을 MVCC(Multi Version Concurrency Control)이라고 한다. 
  • REPETABLE READ는 MVXCC의 undo 영역에 있는 데이터를 이용해 한 트랜잭션 내에서 동일한 결과를 보장하도록 한다.
  • 각각의 트랜잭션은 순차 증가하는 고유한 트랜잭션 번호가 존재하며, 백업 레코드에는 어느 트랜잭션에 의해 백업되었는지 트랜잭션 번호를 함께 저장한다.
  • REPEATABLE READ 내에서는 해당 트랜잭션 번호 이후의 트랜잭션 변화는 무시하여 정합성을 보장할 수 있다. 
  • 다만, 다른 트랙잭션 의해 추가된 레코드가 조회되는 현상(Phantom Read)이 일어날 수 있는데 잠금(LOCK)을 사용하는 과정에서 일어날 수 있다. SELECT UPDATE FOR .... 구문의 경우 수정을 위해 LOCK을 발생시키는데, 잠금이 있는 읽기는 undo 로그가 아닌  실제 테이블에서 조회가 일어난다. (이는 undo 로그가 append only 형태로 잠금을 걸 수 없어서 라는데 이 부분은 조금 더 공부해봐야 할 것 같다.)
  • 다행히도, MYSQL 에서는 갭 락이 존재하기 때문에 위와 Phantom Read를 어느정도 방지할 수 있다.
  • 만약 트랜잭션 B에서 id = 100인 데이터를 select for update 할 경우, Mysql은 id가 100보다 큰 범위에는 갭 락인 넥스트 키 락을 건다. 이후 트랜잭션 A에서 id = 101인 데이터를 insert 하려고 할 경우, 트랜잭션 B가 종료될 때까지(커밋 or 롤백) 기다린다. 
    • 정리를 해보자면, 
    • select 이후 select : MVCC로 인해 팬텀리드 X
    • select 이후 select for update : 팬텀리드 O (실제 테이블을 바로 조회하므로)
    • select for update 이후 select : 갭락이 발생하여 팬텀리드 X
    • select for update 이후 select for update : 갭락이 발생하여 팬텀리드 X

 

 

SERIALIZABLE

  • select 쿼리에 대해 LOCK을 얻어야 한다.
  • 여러 트랜잭션이 레코드에 동시에 접속할 수 없고, 순차적으로 처리된다.
  • 어떠한 부정합성도 발생하지 않지만, 애플리케이션 성능이 떨어진다. 

 

 

정리

격리 수준 Dirty Read Non-Repetable Read Phantom Read
Uncommited Read O O O
Commited Read O O
Repetable Read X X O
(락으로 어느정도 막을 수 있음)
SERIALIZABLE X X X

 


3. JPA - 비관적 락 & 낙관적 락  

그렇다면 Spring 내의 트랜잭션은 어떨까? 먼저 Spring 내의 트랜잭션 수준은 DEFAULT 이다. 즉, RDBMS의 격리수준을 따라가는 것이다. 만약 PostgresQL이라면 UNCOMMITED READ, MySQL이라면 REPETABLE READ를 따라간다.

 

그런데 JPA 내에서는 영속성 컨텍스트(1차 캐시) 가 존재하기 때문에 애플리케이션 내에서 REPETBLE READ가 가능하다. 각각의 트랜잭션이 개별 영속성 컨텍스트 내의 데이터를 사용하기 때문에 가능한 것이다. 

 

하지만 결국 REPETABLE READ는 여러 트랜잭션이 있어도 한 트랜잭션 내에서 동일한 데이터를 읽을 수 있게 하지만, 특정 데이터에 대한 동시 변경에 따른 충돌이 발생할 때 해결책을 제공하는 것은 아니다. 

 

따라서 JPA 에서는 위와 같은 문제를 해결하기 위해 비관적 락 & 낙관적 락을 제공한다.  

 

 

비관적 락

  • 트랜잭션 사이의 충돌이 빈번할 것이라고 여겨, 트랜잭션이 시작될 때 공유 락(Shared Lock) 혹은 배타 락(Exclusive Lock)을 건다.
  • 공유 락 : 트랜잭션이 읽기를 할 때 사용하는 락이며, 데이터를 읽기만하기 때문에 같은 공유락끼리는 동시에 접근이 가능하지만, write 작업은 막는다.
  • 배타 락 : 데이터를 변경할 때 사용하는 락이다. 트랜잭션이 완료될 때까지 유지되며, 배타락이 끝나기 전까지 read/write를 모두 막는다.
  • REBMS 의 RESIALIZABLE과 유사한 효과를 지닌다.

 

 

낙관적 락

  • 트랜잭션 대부분이 서로 충돌하지 않는다는 가정하는 방법으로, 자원에 LOCK을 걸지 않고 동시성 문제가 발생하면 그때 애플리케이션 내에서 해결한다. 
  • JPA가 제공하는 Version 관리 기능을 사용한다. 
  • JPA에서는 엔티티를 수정하고 트랜잭션을 커밋하면 영속성 컨텍스트를 플러시하는데, 이 때 버전을 사용하는 엔티티라면 UPDATE 쿼리에 버전 관련 정보를 추가한다. 

 

만약, recruitCount에 대한 변경의 트랜잭션이 일어난다면, version에 대한 정보가 쿼리에 추가적으로 포함된다. 

 

Version 속성은 하나의 엔티티 클래스에 하나만 존재하여 엔티티를 수정할 때 조회 시점의 버전과 수정 시점의 버전이 다르면 예외를 발생시킨다. 

 

만약, A 트랜잭션에서 프로젝트에 참가하여 recruitCount가 증가할 때(update), 트랜잭션 B에서 동일한 작업을 수행하며 update 쿼리 문이 실행되고 커밋할 경우 A 트랜잭션에서는 예외가 발생하게 된다. 

왜냐하면, A 트랜잭션에서 커밋할려고 할 경우, 이미 B 트랜잭션의 커밋으로 인해 version이 다름으로써 예외가 발생하기 때문이다. 

 

 

@Version + LockModeType.OPTIMISTIC

JPA가 제공하는 LOCK 옵션은 여러가지가 있다. 

그 중에서도 OPTIMISTIC 타입을 사용하면 엔티티를 조회하는 것만으로도 버전을 체크한다.

즉, 한 번 조회한 엔티티가 트랜잭션 동안 변경되지 않음을 보장한다. (Dirty Read, Non-Repetable Read 방지) 

 

단, 이는 "JPA 영속성 컨텍스트를 활용했을 때 REPEATABLE READ 수준의 트랜잭션 격리 수준이 제공될 수 있다"라는 것과는 별개의 이야기이다. @Version, LockModeType을 사용한다는 것은 락을 통해 조금 더 엄격하게 트랜잭션의 동작을 제한하여 데이터의 정합성을 유지한다는 뜻이다. 

 

 

@Version + LockModeType.OPTIMISTIC_FORCE_INCREMENT

해당 타입은 엔티티를 수정하지 않아도 트랜잭션을 커밋할 때마다 버전 정보를 강제로 변경시킨다. 

이는 대부분 연관관계에 있는 엔티티를 수정할 때 사용된다. 

 

(이 부분은 추후에 추가하려한다.)

 

 


 

결론적으로, 맨 처음 설명한 것처럼 참여인원에 대해 동시성 이슈가 있을 경우 낙관적 락(+을 사용하려 한다. (비관적 락은 DB 내에서 락을 걸어버리는 것처럼 동시성을 거의 막아버리기 때문에 성능 상 이슈가 있을 것 같기 때문이다.)

 

다만, 낙관적 락을 시도한다면

트랙잭션 A는 일반적으로 성공적으로 참여되고 트랜잭션 B는 롤백되어 사용자에게 다시 참여버튼을 누르라고 하거나 (실무에서는 쥐약일 것 같다...) 서버 내에서 다시 시도하게 로직을 구성해야 할 것이다. 

 

다시 참여하게 하도록 에러를 발생시키는 건 쉽지만, 일정 시간 뒤에 다시 시도하게 하는 로직으로 리팩토링 해야할텐데 해당 로직은 직접 리팩토링하며 다시 게시글을 작성하거나 수정해야 할 것 같다. (지금 노트북이 문제가 있는 상태라...빨리 해야지)

 

참고

더보기