동시성 이슈 문제를 해결했던 업무 회고를 작성했던 지난 포스팅에 이어서
동시성 이슈 문제의 해결 방법 중 하나인 Lock이라는 거에 대해서 좀 더 자세히 알아보고자 한다.
지난 포스팅에서 나는 동시성 이슈를 JPA의 Lock Annotation을 이용하여 대응을 하였다.
동시성 이슈에 대해서 공부 하다보면 대응을 위해서 Redis 데이터베이스를 썼다는 이야기가 많이 나온다.
대략적인 이유를 보면 '단일 스레드 처리 방식과 데이터 잠금의 차이'라고 나와있다.
나는 그래서 Lock을 사용하고자 했던 거고 이번 기회에 Lock에 대해 더 공부를 하게 되었다.
Lock?
Lock이란 트랜잭션 처리의 순차성을 보장하기 위한 방법으로,
같은 데이터에 동시에 접근하는 경우 데이터의 일관성과 무결성의 유지를 위해 사용하는 기술이다.
DBMS마다 Lock을 구현하는 방식이 다르기 때문에 원하는 DB별 Lock에 대한 이해가 필요하다.
또한 Application 레벨에서도 설정할 수 있다.
이처럼 때와 상황에 맞춰서 어느 레벨에서 관리하는지도 중요한 관리 포인트가 될 것이라 생각한다.
개인적으로는 접근하고자 하는 데이터나 비지니스 상황에 따라 Lock의 설정을
다르게 하고자 할 수도 있기 때문에 Application 레벨이 좀 더 유연하게 대처할 수 있지 않을까 라는 생각을 한다.
또한 데이터베이스 레벨에서 Lock을 설정하여 구현하게 되면 성능 저하를 야기할 수도 있지 않을까? 싶기도 하다.
Database (Postgresql)
기본적으로 DB의 Lock는 공유(Shared) Lock과 베타(Exclusive) Lock이 있으며
공유 Lock은 Read Lock 이라고도 불리고 배타 Lock은 Write Lock 이라고도 불린다.
- 공유(Shared) Lock
- 데이터를 읽을때 사용되는 Lock
- 같은 공유 Lock 끼리는 동시에 접근이 가능
- 즉, 하나의 데이터를 읽는 것은 여러 사용자가 동시에 할 수 있다는 것
- 베타(Exclusive) Lock
- 데이터를 변경하고자 할때 사용되는 Lock
- 트랜잭션이 완료될 때까지 유지
- Lock이 해제될 때까지 다른 트랜잭션(Read 포함)은 데이터에 접근할 수 없음
DB별로 Lock에 대한 세부설정이 다르기 때문에 일단 내가 동시성 이슈를 대응했던 Postgresql 기준으로 알아보았다.
Lock의 종류가 위의 두가지를 기준으로 좀 더 세부적으로 존재하는 듯했다.
- Read Lock - AccessSharedLock
- AccessSharedLock은 SEELCT 명령문으로 잡히는 Lock이다.
- 첫 번째 세션이 트랜잭션을 생성하여 계정 정보를 조회하고,
- 두 번째 세션이 다른 트랜잭션으로 동일한 계정 정보를 조회하더라고 이상 없이 동일한 정보를 조회한다.
session1> begin;
session1> select user_id, company from user_info where email = 'admin';
email | company
-----------------
admin | [NULL]
session2> begin;
session2> select email, company from user_info where email = 'admin';
email | company
-----------------
admin | [NULL]
select locktype, relation::regclass, mode, transactionid tid, pid, granted
from pg_catalog.pg_locks where not pid=pg_backend_pid();
relation user_info AccessShareLock 6322 true
- Write Lock - RowExclusiveLock
- RowExclusiveLock은 UPDATE, DELETE, INSERT 명령문으로 잡고 있는 Lock이다.
- 첫 번째 세션이 트랜잭션을 시작하고 데이터를 수정한 후, 수정된 데이터를 조회하면 수정된 데이터가 정상적으로 조회된다.
- 하지만 두 번째 세션이 트랜잭션을 새로 시작하고 첫 번째 세션이 트랜잭션을 종료하기 전에 동일 데이터를 조회하면
- 수정되기 전에 데이터로 조회되는것을 확인할 수 있다.
- 이는 데이터의 정합성과 무결성을 정하는 격리수준인 Isolation level이 대부분의 DB에서는 Read Committed로 되어있기 때문이다.
- 이것은 Commit을 한 정보만 다른 세션 또는 트랜잭션에서 확인할 수 있음을 의미한다.
- RowExclusiveLock은 ExclusiveLock 중에 하나로 데이터를 변경할 때 다른 사람이 동시에 바꿀수 없도록 쓰기 잠금을 걸고, 쓰기를 마치면 잠금을 해제한다.
- 물론 쓰기 잠금이 걸려있을 때는 다른 잠금을 걸 수 없다.
session1> begin;
session1> select email, company from user_info where email = 'admin';
email | company
-----------------
admin | [NULL]
session1> update user_info set company = 'company' where email = 'admin';
session1> select email, company from user_info where email = 'admin';
email | company
-----------------
admin | company
session2> begin;
session2> select email, company from user_info where email = 'admin';
email | company
-----------------
admin | [NULL]
select locktype, relation::regclass, mode, transactionid tid, pid, granted
from pg_catalog.pg_locks where not pid=pg_backend_pid();
virtualxid [NULL] ExclusiveLock 6417 true
- Race Condition - SharedLock
- 첫번째 세션이 트랜잭션을 생성하고 데이터를 수정한 후, 트랜잭션이 종료되지 않은 상황에서
- 두번째 세션이 트랜잭션을 생성하여 동일 데이터에 수정 작업을 진행하게 되면 쿼리가 정상적으로 작동하지 않고 대기를 한다.
- 그 후에 첫번째 세션의 트랜잭션이 정상적으로 종료된 후에야 두 번째 세션의 데이터 수정 작업이 완료된다.
- Lock 정보를 조회해보면 Shared Lock이 추가되었음을 확인할 수 있다.
- Shared Lock은 공유 잠금 또는 읽기 잠금이라는 뜻으로
- 동시에 데이터를 변경할 때 생기는 문제를 보호하기 위하여 먼저 Lock을 잡은 TransactionId에 공유를 요청하는 Lock으로
- 첫 번째 세션이 먼저 UPDATE 행위를 통해 ExclusiveLock을 잡고 있었기 때문에 SharedLock과 Conflict 되어 Lock이 Granted 되지 않았던 것이다.
session1> begin;
session1> update user_info set company = 'company' where email = 'admin';
UPDATE 1
session2> begin;
session2> update user_info set company = 'company2' where email = 'admin';
session1> commit;
select locktype, relation::regclass, mode, transactionid tid, pid, granted
from pg_catalog.pg_locks where not pid=pg_backend_pid();
transactionid [NULL] ShareLock 1129 8902 false
등등 그 외에도 굉장히 다양한 종류의 Lock이 있다.
Application
내가 실제로 사용한 방식인 JPA를 이용한 방식으로 Lock mode를 설정하는 방식을 알아보자.
JPA에서는 Optimistic Lock (낙관적 락)과 Pessimistic Lock (비관적 락)을 제공한다.
- Optimistic Lock
- 데이터 갱신 시 충돌이 발생하지 않을 것이라고 낙관적으로 보고 잠금을 거는 기법이다.
- 동시에 동일한 데이터에 대한 여러 업데이트가 서로 간섭하지 않도록 방지하는 version이라는 속성을 확인하여 Entity의 변경사항을 감지하는 메커니즘이다.
- JPA에서 낙관적 잠금을 사용하기 위해 Entity 내부에 @Version 어노테이션이 붙은 변수를 구현하여 줌으로써 간단하게 구현이 가능하다.
- @Version 명시에 주의사항
- 각 Entity 클래스에는 하나의 버전 속성만 있어야 한다.
- 여러 테이블에 매핑된 Entity의 경우 기본 테이블에 배치되어야 한다.
- 버전에 명시할 타입은 int, Integer, long, Long, short, Short, java.sql.Timestamp 중 하나여야 한다.
- JPA는 Select시에 트랜잭션 내부에 버전 속성의 값을 보유하고 트랜잭션이 업데이트를 하기 전에 버전 속성을 다시 확인한다.
- 그동안에 버전 정보가 변경이 되면 OptimisticLockException이 발생하고 변경되지 않으면 트랜잭션은 버전 속성을 증가하는 업데이트를 하게 된다.
- LockMode Type
- NONE : 별도의 옵션을 사용하지 않아도 Entity에 @Version이 적용된 필드만 있으면 낙관적 잠금이 적용된다.
- OPTIMISTIC (Read) : Entity 수정 시에만 발생하는 낙관적 잠금이 읽기 시에도 발생하도록 설정한다. 읽기 시에도 버전을 치크하고 트랜잭션이 종료될 때까지 다른 트랜잭션에서 변경하지 않음을 보장한다.
- OPTIMISTIC_FORCE_INCREMENT (Write) : 낙관적 잠금을 사용하면서 버전 정보를 강제로 증가시키는 옵션이다.
- READ, WRITE : READ는 OPTIMISTIC과 같은 역할, WRITE는 OPTIMISTIC_FORCE_INCREMENT와 같은 역할을 하며 JPA 1.0호 환성을 위한 옵션이다.
- OptimisticLockException
- 권장되는 예외처리 방법에서는 Entity를 다시 로드하거나 새로고침하여 업데이트를 재 시도하는 방법이다.
- Entity에서 낙관적 잠금 충돌을 감지하면 OptimisticLockException을 발생시키게 되고 트랜잭션은 롤백 처리를 한다.
@Entity
public class Student {
@Id
private Long id;
private String name;
private String lastName;
@Version
private Integer version;
}
- Pessimistic Lock
- 트랜잭션의 충돌이 발생한다고 가정하고 우선 락을 걸고 보는 방법으로 트랜잭션 안에서 서비스 로직이 진행되어야 한다.
- LockMode Type
- PESSIMISTIC_READ : dirty read가 발생하지 않을 때마다 공유 잠금을 획득하고 데이터가 UPDATE, DELETE 되는 것을 방지할 수 있다.
- 데이터베이스에 따라 PESSIMISTIC_READ를 지원하지 않는 경우엔 PESSIMISTIC_WRITE로 대체된다.
- PESSIMISTIC_WRITE : 배타적 잠금을 획득하고 데이터를 다른 트랜잭션에서 READ, UPDATE, DELETE 하는 것을 방지할 수 있다.
- PESSIMISTIC_FORCE_INCREMENT : 이 잠금은 PESSIMISTIC_WRITE와 유사하게 작동하지만 @Version이 지정된 Entity와 협력하기 위해 도입되어 PESSIMISTIC_FORCE_INCREMEMT 잠금을 획득할 시 버전이 업데이트된다.
- Exception
- PessimisticLockException : 잠금은 ShareLock 또는 Exclusive Lock 둘 중에 하나만 얻을 수 있으며 그 락을 얻는데 실패하면 발생하는 예외
- LockTimeoutException : 락을 대기하다 설정해놓은 wait time이 초과되었을 때 발생하는 예외
- PersistenceException
- Lock Scope
- NORMAL : 기본값으로 해당 entity 만 잠금이 설정되고, @Inheritance(strategy = InheritanceThype.JOINED)와 같이 조인 상속을 사용하면 부모도 함께 잠금이 설정된다.
- EXTENDED : @ElememtCollection, @OneToOne, @OneToMany 등 연관된 entitye들도 잠금이 설정된다.
결국 내가 동시 결제견에 대한 처리를 이렇게 했다고 정의할 수 있을 것 같다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
- 결제 건에 대해서는 항상 한 트랜잭션으로 관리를 한다.
- 즉, 결제 관련 데이터를 조회하고 결제를 요청하고 결제가 완료되면 데이터를 수정해서 다시 저장하는 일련의 과정을 하나의 트랜잭션으로 관리하는 것이다.
- 하나의 트랜잭션이 일정 결제 건을 조회한다면 Dirty Read를 방지하고자 PESSIMISTIC_WRITE의 LockMode로 데이터를 조회해온다.
- LockScope는 결제정보 해당 Entity에만 걸고 연관된 Entity에는 걸지 않는다.
추가로 더 해줘야 하는 것?
데이터에 Lock을 설정하다 보면 (특히 Exclusive Lock) 일정 트랜잭션이 일정 데이터를 점유하고 있는 동안 다른 트랜잭션에 접근이 거부되기 때문에 무한정 대기를 하게 되거나 예외를 내게 되는데 이런 경우의 적절한 처리가 필요할 것이다!
민감한 사용자의 정보일수록 이런 설정들에 더더욱 신경을 써줘야 할 것이다.
특히 결제는 돈이 걸려있는 문제다 보니 더 신중하게 여러 경우의 수를 생각하며 개발하는 것이 좋을 것 같다!

(오늘도 성장하는 나)
'Programing > Java' 카테고리의 다른 글
우아한 테크캠프 Pro 프리코스 회고록 - 객체지향 생활체조 (2) | 2022.11.02 |
---|---|
Java Stream 모르고(?) 쓰면 일어나는 일들 (0) | 2022.09.16 |
하나의 결제건이 다중 결제가 된다면? - Concurrency Programming (0) | 2022.08.01 |
Java ThreadLocal 파헤치기 (0) | 2022.07.16 |