이제부터 본격적으로 Database 수준에서 사용되는 Lock을 공부해보자.
Mysql 상에서 사용될 수 있는 Lock들을 공부 & 테스트
- 테스트 환경
- 모든 테스트는 10번의 테스트 코드를 실행시켜서 제일 긴 시간과 제일 짧은 시간을 제외한 8개의 시간의 평균 값을 기록
- 테스트 시간이 2분이 넘어갈 시에는 기록하지 않고 더 이상 테스트 하지 않는다.
- 쓰레드 풀 (newFixedThreadPool) 에 쓰레드는 10개로 고정
-> 쓰레드 풀에 있는 갯수를 20, 30, 100으로 했을 때에 시간 차이가 별로 나지 않고 오히려 시간이 더 걸리는 경우가 발생
-> 따라서 굳이 쓰레드풀에 있는 쓰레드 갯수를 늘리는 것은 오히려 놀고 있는 쓰레드를 만드는 것
Database Lock
1. Optimistic Lock (낙관적 락)
이론
-
트랜잭션 충돌이 발생하지 않는다고 가정
-
충돌이 나는 것을 미리 방지하지 않고, 충돌을 감지하면 처리
-
따라서 엄밀히 말하자면 DB에서 해결하는 것이 아닌 어플리케이션에서 처리
-
Entity에 version을 도입
-
예시
A가 table의 Id 2번을 읽음
B가 table의 Id 2번을 읽음
B가 table의 Id 2번, version 1인 row의 값 갱신 -> 성공
A가 table의 Id 2번, version 1인 row의 값 갱신 -> 실패
Id 2번은 이미 version이 2로 업데이트 되었기 때문에 A는 해당 row를 갱신하지 못함
같은 row에 대해서 각기 다른 2개의 수정 요청이 있었지만 1개가 업데이트 됨에 따라 version이 변경되었기 때문에 뒤의 수정 요청은 반영되지 않음
-
장점
- 충돌이 나지 않는 상황이라면 동시 요청에 대한 처리 성능이 좋음
-
단점
- 충돌이 많이 발생하는 경우, 롤백처리에 대한 비용이 많이 들어 오히려 성능에서 손해
- 롤백 처리를 구현을 수동으로 해줘야 한다.
- Foreign key 제약 조건이 있는 테이블에는 Optimistic Lock을 활용할 수 없다.
-> InnoDB 자체가 foreign key를 참조할 때에 S-Lock을 걸고, 데이터 수정시에 항상 X-Lock을 검
-> Lock이 없으면 데이터의 일관성을 지키기 어렵기 때문
-> DB에서 Lock을 걸지 않도록 만들 방법이 없어서 Foreign Key가 포함된 Entity에서는 낙관적 락을 사용하는 것은 좋지 않다.
테스트
- 코드는 나중에 github나 다른 페이지에 올릴 예정이므로, Lock을 거는 Repository 클래스 코드만 적음
@Lock(value = LockModeType.OPTIMISTIC)
@Query("SELECT s FROM Stock s WHERE s.id = :id")
Stock findByIdWithOptimisticLock(@Param("id") Long id);
- LockModeType
- NONE
락 옵션을 적용하지 않아도 엔티티에 @Version이 적용된 필드가 있다면 낙관적 락이 적용
암시적 잠금
@Version이 붙은 필드가 존재하거나 @OptimisticLocking 어노테이션이 설정되어 있을 경우 자동적으로 잠금이 실행 (JPA에서 자동 실행)
추가로 삭제 쿼리가 발생할 시 암시적으로 해당 row에 대한 Exclusive Lock을 건다.
- OPTIMISTIC
읽기시에도 낙관적 락이 걸린다.
버전을 체크하고 트랜잭션이 종료될 때까지 다른 트랜잭션에서 변경하지 않음을 보장
이를 통해 dirty read와 non-repeatable read를 방지
- OPTIMISTIC_FORCE_INCREMENT
낙관적 락을 사용하면서 버전 정보를 강제로 증가
논리적인 단위의 엔티티 묶음을 관리
예를 들어, 양방향 연관관계에서 주인인 엔티티만 변경했을 때 주인이 아닌 엔티티는 변경되지 않았지만 논리적으로 변경되었으므로 버전을 증가
- 테스트 실행
- 쓰레드가 decrease를 얻지 못하는 경우 : 100ms sleep
- 재고 100개, 100번 실행 시
- 걸린 시간 : 7.294s

- 재고 1000개, 100번 실행 시
- 걸린 시간 : 5.452s
- 오히려 시간이 더 적게 걸린 것으로 보아, 충돌이 나는 경우가 줄어들면 성능이 좋아지는 것으로 보임

- 재고 1000개, 1000번 실행 시
- 걸린 시간 : 29s

- 이 이상의 실험 (재고 갯수를 늘리거나, 실행 횟수 증가 시) 2분이 넘어가는 사례들이 발생하였기 때문에, 더 이상 테스트는 진행하지 않았음
- profiler에서 CPU 사용량과 Thread 갯수를 보면, CPU 사용량 퍼센트가 증가하면 Thread의 숫자를 늘림 -> Thread 숫자가 늘어나면 CPU 사용량 퍼센트가 줄어든다.
-> Thread가 CPU 사용을 분배
- 결론 : 충돌이 많은 경우 낙관적 락의 성능은 좋지 않음. 이벤트 상황을 가정하고 충돌이 많은 상황을 대비하기 위해서는 다른 락을 사용하는 것이 좋을 것으로 보임
2. Pessimistic Lock (비관적 락)
이론
- 트랜잭션 충돌이 무조건 발생한다는 가정
- 하나의 트랜잭션이 자원에 접근시 락을 걸고, 다른 트랜잭션은 접근하지 못하도록 함
- 장점
- 충돌이 자주 발생하는 환경에 대해서는 롤백의 횟수를 줄일 수 있어 성능에서 유리
- 데이터 무결성을 보장하는 수준이 높음
- 단점
- 데이터 자체에 락을 걸어버리기 때문에 동시성이 떨어져 성능 손해
- 서로 자원이 필요한 경우에, 락으로 인해 데드락이 일어날 가능성
테스트
@Lock(value = LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT s FROM Stock s WHERE s.id = :id")
Stock findByIdWithPessimisticLock(@Param("id") Long id);
-
LockModeType
- PESSIMISTIC_READ
Shared Lock을 획득하고 데이터가 update, delete 되는 것을 방지
- PESSIMISTIC_WRITE
Exclusive Lock을 획득하고 데이터를 다른 트랜잭션에서 read,update,delete하는 것을 방지
- PESSIMISTIC_FORCE_INCREMENT
PESSIMISTIC_WRITE와 유사하지만 @Version이 지정된 Entity와 협력하기 위해 도입되어 PESSIMISTIC_FORCE_INCREMENT락을 획득할 시 버전이 업데이트
-
테스트 실행
- 재고 100개, 100번 실행 시
- 걸린 시간 : 3.117s

- 재고 1000개, 100번 실행 시
- 걸린 시간 : 3.162s

- 재고 1000개, 1000번 실행 시
- 걸린 시간 : 15s

- 재고 100개, 10000번 실행 시
- 걸린 시간 : 36s

- 재고 1000개, 10000번 실행 시
- 걸린 시간 : 45s

-
충돌이 발생하는 상황이 많이 일어나는 것을 가정하였기 때문인지 낙관적 락보다 성능 상에서 현저히 우위를 보임
-
1만번 실행되는 상황에서도 2분을 넘기지 않음
- 낙관적 락은 실행 횟수나, 재고가 증가하면 Connection pool이 누수되는 상황이 발생
-
Profiler를 보면 낙관적 락과 같이 필요한 상황 (CPU 사용량이 증가하는 상황) 에서 Thread의 갯수를 늘리는 것을 볼 수 있음
-
결론
- 잦은 충돌이 일어난다면 낙관적 락보다 성능이 우수하며 데이터 무결성을 보장
3. Named Lock
이론
- 고유한 이름으로 식별되는 잠금
- 잠금을 획득해 공유 자원 접근을 동기화
- 다른 서비스를 거치지 않고 MySQL 서버의 메모리에 직접 동작해 낮은 오버헤드로 잠금 획득 및 해제가 가능
- 모든 시스템이 동일한 물리 메모리에 접근할 수 있다고 가정하는 공유 메모리 모델에 의존하기 때문에 분산 시스템에 적합하지 않음
- 트랜잭션이 종료될 때 해당 락이 자동으로 해제되지 않기 때문에 수동으로 해제를 하거나 선점시간이 끝나야 해제 가능
- 장점
- 간단함 : MySQL 기본 제공 기능이므로 추가 소프트웨어나 라이브러리가 필요없음
- 속도 : 다른 서비스를 거치지 않고 MySQL 서버 메모리에서 동작하기 때문에 빠름
- Lock 관리 편이 : 쿼리를 이용해 잠금을 획득하고 해제할 수 있는 등 Named Lock 관리 쉬움
- 단점
- Mysql에서만 사용 가능
-> 다른 데이터베이스를 사용하는 경우, 해당 데이터베이스 만의 잠금 기능을 이용 혹은 애플리케이션 레벨에서 잠금을 제공해야 한다.
- MySQL 자체가 독립적으로 실행하는 RDBMS이기 때문에 분산 잠금 기능이 없어서, 분산 시스템에서는 적합하지 않다.
테스트
public interface LockRepository extends JpaRepository<Stock, Long> {
@Query(value = "SELECT GET_LOCK(:key, 1000)", nativeQuery = true)
void getLock(@Param("key") String key);
@Query(value = "SELECT RELEASE_LOCK(:key)", nativeQuery = true)
void releaseLock(@Param("key") String key);
}
Connection 누수

-
테스트를 진행하던 도중에 발생한 Connection 누수로 인해 테스트가 Fail이 되는 경우가 몇 번 발생
-
해당 에러 (Apparent connection leak detected)는 Hikari에서 Connection을 점유(getConnection()) 할 때 사용 가능한 커넥션이 없을 경우 얻을 때까지 대기하는 시간이 hikari.connection-timeout 보다 길어질 경우 발생하는 예외라고 한다.
-
Connection Pool
- WAS가 실행될 때 DB연결을 위해 미리 일정수의 connection 객체를 만들어 Pool에 담아 뒀다가 클라이언트의 요청이 발생하면 Pool에서 생성되어 있는 Connection 객체를 넘겨주고 처리가 끝나면 Connection 객체를 다시 Pool에 보관하는 방식
-> 쓰레드 풀이랑 비슷한 것으로 보임
-
Hikari CP
- JDBC의 커넥션 풀 오픈소스 라이브러리인 JDBC DataSource의 구현체
- 다른 오픈소스보다 성능 상 이점이 있기 때문에 Spring Boot 2.0 이상부터는 Hikari CP 사용
-
즉, 락을 획득하는데 걸리는 시간이 Hikari CP 에서 제공하는 Connection Pool의 연결 가능 시간보다 길어서 Connection Pool에 존재하는 연결들이 끊겼다는 의미인 것으로 보인다.
-
application.yml 에서 hikari의 max-lifetime을 늘리고 maximum-pool-size를 늘리자 어느정도 해결이 되는 것으로 보임 (전부 해결은 아직 안됨)
hikari:
leak-detection-threshold: 2000 # 누수 감지 (2초 넘어가면 누수 된 것으로 가정)
maximum-pool-size: 40 (default 값으 10)
connection-timeout: 31000
# 31초 (락에서 wait time이나 thread sleep 시간을 고려해서 해당 시간보다 길게 설정)
참고
낙관적 락에서의 Dead Lock 문제
Database Lock
재고 시스템으로 알아보는 동시성 이슈 해결 방법
낙관적락 비관적 락
네임드락으로 동시성 제어
커넥션 풀
HikariCP 장애 회고