동시성 문제 - JPA 비관적 락과 DB 설정(PostgreSQL)

Jang990·2023년 11월 3일
0

문제

서비스 로직

현재 서비스에서 Client를 묶는 Group이라는 테이블이 있다.
해당 테이블에는 Client_Count라는 필드로 다음과 같이 Group내의 Client 수를 관리하고 있다.

Client를 제거할 때는 Client가 들어있는 Group에 Client_Count 필드를 -1을 한다.
Client를 추가할 때는 Client가 들어있는 Group에 Client_Count 필드를 +1을 한다.

회원의 등급에 따라 허용되는 Client_Count의 수가 달라지기 때문에 중요한 부분이다.

동시성 문제 발생

다음 사진과 같이 엑셀 파일을 통해 하나의 Group에 여러 Cleient을 한 번에 등록하는 기능이 있다.
팀원 중 한 명이 해당 기능을 한 순간에 여러 번 시도했다.

여기서 Client_Count의 불일치 문제가 발생했다.
27명을 4번 등록했기 때문에 Client_Count108(27*4)명이 되어야 정상이다.
하지만 Group을 DB에서 직접 조회해본 결과 Client_Count81(27*3)명으로 조회되었다.

처음에는 "한 번의 요청이 누락되었겠구나"라고 생각하고 점검을 해봤지만 모든 요청과 로직은 정상적으로 동작했다.
그래서 여러 쓰레드에서 GroupClient_Count필드에 접근해서 데이터를 수정하기 때문에 동시성 문제가 발생했음을 인지했다.

해결 방법

  1. Redis를 통해서 설정한 제한 시간 사이에 중복 요청을 제한
  2. SQL의 For Update를 통해 해당 그룹에 Lock을 걸기

처음 생각한 방법은 Redis를 사용하여 요청을 제한하는 것이였다.
하지만 이 방법은 근본적인 해결방법이 되지 못한다.
만약 사용자가 증가했다고 생각해보자.

  1. A 사용자 요청
  2. 사용자가 많아져 우리가 설정한 제한 시간이 지나도 A 사용자의 요청을 처리하지 못하고 있음
  3. 제한 시간이 지났기 때문에 A 사용자는 다시 요청
  4. 다시 동시성 문제가 발생

그렇다고 제한 시간을 쫙 늘려버리면 사용자가 오랜 시간 다시 요청을 보낼 수 없다는 문제가 있다.

두 번째 해결 방법은 Select로 조회 시에 For Update를 이용하여
다른 트랜잭션에서 현재 트랜잭션이 사용하는 행에 접근하지 못하도록 막는 것이였다.
물론 이 방법도 For Update를 사용하여 행 자체에 락을 걸기 때문에 사용자가 많은 서비스에서는 최적화를 고려해야 한다.

이외에도 Redis를 사용하여 락을 거는 방식이 있다.(분산락)
분산락은 DB 다중화를 한 경우 하나의 DB에서 락이 걸리면 다른 DB에서는 알 수 없기 때문에 사용한다고 한다.

현재 우리 서비스는 DB가 다중화가 된 것도 아니고 사용자가 많지도 않기 때문에 For Update를 사용하기로 했다.

JPA를 이용한 락

JPA를 사용하여 락을 거는 방식은 2가지 방식이 있다.
'낙관적 락'과 '비관적 락'이다.
For Update 등의 구문을 통해 실제 Database에 락을 사용하는 '비관적 락' 방식이고,
'낙관적 락'은 Database가 아닌 JPA내에서 동시성 문제를 해결하려는 방식이다.

낙관적 락은 이해 위주로 설명하고, 비관적 락은 사용 위주로 설명하겠다.

@Version

낙관적 락을 알아보기 전에 JPA에서 제공하는 @Version을 알아보자.
@Version을 통해서 두 번 갱신 분실 문제(Second Lost Upadte Problem)를 해결할 수 있다.

두 번 갱신 분실 문제

예를 들어 사용자 A, 사용자 B가 현재 20살인 고객1의 나이를 변경하는 상황을 생각해보자.

사용자 A 트랜잭션사용자 B 트랜잭션
고객1 조회
고객1 조회
Update : 고객1.나이=30
Commit(트랜잭션 종료)
Update : 고객1.나이=10
Commit(트랜잭션 종료)

위와 같은 흐름이라면 고객1의 나이는 10살이 됐을 것이다.
사용자 B의 트랜잭션에서 수정한 내용은
사용자 A의 트랜잭션에서 변경한 내용으로 사라져버렸다.

이렇게 트랜잭션의 범위를 넘어서는 문제가 있을 때 이때 JPA의 @Version을 통해 해결할 수 있다.

@Version 사용법

엔티티에 version 필드를 추가해서 사용하는 것이다.

@Entity
public class Client {
	...
    private String name;
    
    // 
    @Version
    private Integer version; // Long, Short, Integer, Timestamp 타입 사용가능
}

이제 Version이 끼어들었을 때 두 번 갱신 분실 문제가 발생한 상황은 다음과 같이 바뀐다.

사용자 A 트랜잭션사용자 B 트랜잭션DB상의 고객1 version 정보
고객1 조회(version = 1)version = 1
고객1 조회(version = 1)version = 1
고객1 나이 = 30(version = 1)version = 1
Commit(트랜잭션 종료)version = 2 (+1)
고객1 나이 = 10(version = 1)version = 2
Commit시도(version = 1)version = 2

현재 사용자 A 트랜잭션에 version 필드는 1인데
실제 DB의 저장된 version 필드는 2이다.
불일치가 발생한 것이다.

Q. JPA에서는 이렇게 version 필드 불일치가 발생한걸 어떻게 알 수 있나요?
A. JPA는 Update 쿼리에 Where문을 걸어서 알아낸다.

UPDATE Client 
SET name=?, version=? (버전 + 1 증가)
WHERE id=?, version=? (현재 버전과 DB 버전 비교)

여기서 이런 충돌을 해결하는 방법은 3가지 방법이 있다.

  1. 최초 커밋 인정(사용자 B가 수정한 정보를 인정하는 방법)
  2. 마지막 커밋 인정(사용자 A가 수정한 정보를 인정하는 방법)
  3. 두 내용 병합 처리(개발자가 두 수정 내용을 병합하는 방법을 제공해야 한다.)

이 옵션을 적용하는 부분은 이 글에서 다루지 않으니, 알아보고 싶다면 낙관적 락 사용법에 대해 찾아보자.

낙관적 락

낙관적 락이란 말 그대로 낙관적으로 생각하는 락을 말한다.
"대부분의 트랜잭션에서는 충돌이 발생하지 않을거야" 라는 생각으로 JPA에서 처리를 하는 것이다.
@Lock에 대한 설정없이 @Version만 사용해도 낙관적 락을 사용할 수 있다.
여러 옵션들이 있다.

비관적 락

비관적 락은 "대부분의 트랜잭션에서는 충돌이 발생할거야" 라는 생각으로
일단 DB에서 조회하는 행에 LOCK을 거는 방식이다.
여러 옵션들이 있다.

뭘 써야 할까?

충돌이 잦고 문제가 발생하면 크리티컬하다.(ex- 돈) -> 비관적 락
충돌이 어쩌다 한 번이고 문제가 발생해도 널널하다. -> 낙관적 락

비관적 락 적용

이제 비관적 락을 통해 동시성 문제를 본격적으로 해결해보자.

스프링 코드

GroupClient_Count 필드에 접근할 때 발생하는 문제이기 때문에 Group을 조회할 때 Lock을 걸것이다.

public interface GroupRepository extends JpaRepository<Group, Long> {
	@Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT g FROM Group g WHERE g.id = :groupId")
    Optional<Group> findGroupByIdForUpdate(Long groupId);
}

여러 옵션들이 있지만 비관적 락이라 하면 일반적으로 LockModeType.PESSIMISTIC_WRITE이 옵션을 의미한다.
해당 옵션을 통해 DB에 쓰기 락을 걸 수 있다.

이제 특정 Group을 수정하고 있을 때 해당 Group에 다른 트랜잭션이 끼어들 수 없다.

데드락

앞서 동시성 문제를 비관적 락을 통해 해결했다.
하지만 비관적 락으로 인해 데드락이 발생할 수 있다.

고객A그룹1에서 그룹2로 옮기는 상황과
고객B그룹2에서 그룹1로 옮기는 상황이 동시에 발생했고
처리 중에 컨텍스트 스위칭이 이뤄졌다고 가정해보자.

고객A를 그룹1에서 그룹2로고객B를 그룹1에서 그룹2로
그룹1 조회(락 획득)
그룹2 조회(락 획득)
그룹1 조회(💢 그룹1 락 내놔)
그룹2 조회(💢 그룹2 락 내놔)

서로가 서로의 락을 가지기 위해 대기하는 상황이 발생했다.

Lock 타임아웃 확인을 위한 DB 수정 (Windows)

현재 DB는 PostgreSQL이다.
PostgreSQL은 락을 가지기 위해 대기하는 시간인 lock_timeout은 기본적으로 disable이다.
하지만 데드락이 발생했는지를 확인하고 차단하는 deadlock_timeout 시간은 기본적으로 1s로 설정되어있다.
그렇기 때문에 DB의 별다른 설정없이 데드락을 해결할 수 있다.

하지만 Thread.sleep을 통해 타임아웃이 발생하는지 확인하고 싶다면 lock_timeout을 다음과 같이 수정해주자.


C:\Program Files\PostgreSQL\15\data 폴더에 들어가서 postgresql.conf를 수정해주자. 지금은 테스트 용으로 1초(1000ms)로 수정해주었다.

#lock_timeout = 0 이렇게 되어 있는 것을 아래와 같이 바꾸자.
lock_timeout = 1000

그리고 서비스에 들어가서 postgresql을 재시작해주면 된다.


설정이 변경되었는지는 show lock_timeout;을 통해 확인할 수 있다.

결과

비관적 락을 건 쿼리를 2개의 스레드에서 같은 ID의 Group을 조회하도록 만들고
앞서 설정한 lock_timeout을 초과하는 시간을 대기하도록 만들었다.
그러면PessimisticLockingFailureException 예외가 발생하는 것을 확인할 수 있다.

참고

자바 ORM 표준 JPA 프로그래밍 - 김영한 지음
dba.stackexchange - what-is-the-difference-between-lock-timeout-and-deadlock-timeout
https://repost.aws/questions/QUqGNTjsYwQPubF8wrHDrVxA/lock-timeout-parameter

profile
공부한 내용을 적지 말고 이해한 내용을 설명하자

0개의 댓글