Published on

MySQL Lock과 동시성 및 정합성

Authors
  • avatar
    Name
    ywj9811
    Twitter

Lock에 대하여

사용자가 많아지게 된다면 당연하게 동시 요청이 많아질 것이다.

만약 Spring Boot와 Mysql을 사용하고 있다면 많은 요청이 들어올 경우 Spring Boot의 각각 스레드가 Mysql에 요청을 보내게 될 것이다.

즉, 한 DB에 동시에 여러 요청을 보내게 될 것이라는 것이다.

그러한 상황에서 데이터 정합성을 보장하기 위해 Lock을 알아보도록 하자.

Lock 은 여러 트랜잭션이 동시에 처리될 때 데이터의 무결성을 보장하기 위한 수단이다.

한 트랜잭션이 처리되고 있을 때, 다음 트랜잭션이 잠시 기다렸다가 처리되도록 하여 순차적으로 처리하게 하는 것이다.

Lock의 설정 범위

하지만 Lock을 사용하면 다른 트랜잭션이 접근할 수 없다. 따라서 불필요하게 많은 락을 설정한다면 안정적일지 몰라도 성능은 저하되게 될 것이다.

이를 잠금 비용이라고 하며, 잠금 비용이 올라갈수록 동시성을 처리하는데 성능 손실이 발생하게 된다.

따라서, 락의 설정 범위를 지정할 수 있고, 한 행(Row)부터 데이터베이스 전체까지 걸어둘 수 있다.

  • 행(Row)

    1개의 행을 기준으로 Lock을 설정하는 것으로, Update, Insert와 같은 DML에 대한 일반적인 범위이다.

  • 열(Column)

    1개의 열을 기준으로 Lock을 설정하는 것으로, 컬럼에 대한 접근을 막는 것이라 설정과 해제에 대한 리소스가 많이 들어가 신중하게 사용해야 한다.

  • 페이지, 블럭

    일반적으로 데이터베이스는 파일의 형태로 저장되는데, 해당 파일의 일부인 페이지나 블럭 단위로 설정할 수 있다.

    하지만 이는 DMBS에 의해 논리적인 저장 구조가 관리되는 만큼, 이는 많은 비용을 초래한다.

  • 테이블

    한 테이블에 대한 Lock을 설정할 수 있으며 이는 전체 테이블을 수정하는 등의 작업을 할 때 유용하게 활용할 수 있다.

    혹은, DDL구문을 사용할 때 함께 사용할 수 있어서 DDL LOCK 이라고도 불린다.

  • 그 외

    그 외에도 파일 혹은 데이터 베이스 전체에 락을 거는 방식 또한 존재한다.

Lock의 종류

기능과 용도에 따라서 다양한 종류로 분류된다.

우선 데이터 접근에 대해는 공유 락(Shared Lock, Lock-S), 배타 락(Exclusive Lock, Lock-X) 이렇게 구분할 수 있다.

공유 락(Shared Lock, Lock-S)

공유 락은 다른 트랜잭션이 잠긴 객체를 읽고 새로운 공유 락을 얻을 수 있지만, 쓰기는 불가능하다.

예를 들면, A가 1행에 대해 공유 락을 걸었다면, B는 1행을 읽기는 가능하지만, 수정은 불가능하다.

이때, B또한 새로운 공유 락을 걸 수 있다.

이는 읽는 도중에 데이터의 변경이 있는 경우를 막는 것이다.

→ 여러 명이 동시에 데이터를 읽기만 한다면 문제가 발생하지 않기에 공유 락은 동시에 접근이 가능하다.

배타 락(Exclusive Lock, Lock-X)

배타 락은 다른 트랜잭션이 접근하는 것을 막는 락이다.

데이터를 쓰는 경우(Insert, Update)에 사용한다.

이는 다른 트랜잭션이 접근해서 배타 락을 생성할 수 없기 때문에, 다른 트랜잭션의 데이터 쓰기가 불가능하다.

그리고 배타 락은 공유 락과 다르게 다른 락과 동시에 사용할 수 없다. (다만, 배타 락이 걸려있다고 무조건 데이터를 읽을 수 없는 것은 아니고 락을 사용하지 않는 조회는 가능하다.)

업데이트 락(Update Lock)

업데이트 락은 배타 락을 걸 때, 데드락을 방지하기 위해 사용하는 락이다.

Untitled

이러한 상황이 있다고 가정하자.

파랑과 초록은 각각 트랜잭션을 의미하며 각 트랜잭션은 두 테이블에 대한 수정 쿼리를 포함하고 있다.

파란색은 A테이블을 수정하고 B테이블을 수정한다면, 초록색은 반대로 B테이블을 수정하고 A테이블을 수정한다.

그리고 두 트랜잭션은 자신이 수정하는 각 테이블에 배타 락을 건다.

그렇다면 둘 다 두번째 테이블에 대한 작업을 완료할 수 없기에 배타 락이 풀리지 않는다.

즉 데드락이 생기게 되는 것이다.

이런 경우에 어떤 방법이 있을까.

  • 다음 트랜잭션이 들어오기 전에 빠르게 작업을 마치고 락을 푼다.
  • 해당 트랜잭션이 수정해야 되는 테이블에 모두 락을 걸어 놓는다.

첫번째가 물론 좋겠지만, 불가능한 상황 또한 존재할 것이다.

두번째의 경우는 공유 락 또한 막아버리기 때문에 성능 저하가 우려된다.

이러한 경우에 사용할 수 있는 것이 업데이트 락이다.

만약 수정할 테이블에 업데이트 락을 걸게 되면 배타 락을 얻는 것을 방지한다.

→ 좀 있다가 수정할 것이라고 미리 알림을 주는 것과 같다.

물론 위와 같이 단순한 예제에서는 이를 통해 해결할 수 있지만 여러개의 트랜잭션이 공유 락을 가지고 접근한 이후에 배타 락을 얻으려 한다면 다른 공유 락에 의해 얻지 못할 것이고, 데드 락에 빠지게 될 것이다.


Lock을 활용한 동시성 및 정합성 문제 해결

  • 팔로우
    • 팔로우 대상 회원 조회 -> 이미 팔로우했는지 조회 -> 팔로잉 정보 추가 -> 회원의 팔로워 수 업데이트

이러한 프로세스로 로직이 진행될 때 여러 동접자가 발생한다면 어떤 동시성 문제가 발생하게 될까.

팔로워 수 업데이트 하는 과정의 로직은 JPA의 더티 체킹 기능을 통해 이루어진다.

더티 체킹 혹은 변경 감지란 트랜잭션 커밋 종료 시점 혹은 영속성 컨텍스트의 flush가 일어나는 시점에서 엔티티의 스냅샷을 비교하여 변경된 컬럼이 있는지 확인 후 반영하는 것이다.

하지만 만약 엔티티를 조회하여 가져온 이후에 트랜잭션이 커밋되어 업데이트 쿼리를 실행하는 그 사이에 다른 트랜잭션이 값을 변경한다면 어떻게 될 것일까.

Untitled

a와 b가 각각 로그인하여 c에 대한 팔로우 메소드를 실행한다면, 트랜잭션에 진입한 이후 c를 조회하는데, 이때 팔로워는 0이다.

그리고 트랜잭션 A가 업데이트를 진행하여 팔로워를 1로 업데이트한다.

하지만 트랜잭션 B가 업데이트를 진행하는데, B가 처음에 조회한 팔로워 수는 0이었기 때문에 1로 업데이트 하게 된다.

하지만 실제로 생성된 팔로워는 2개지만 결과는 1이 나오게 된다.

즉, 이러한 정합성에 대한 동시성 문제가 발생하는 것이다.

해결 방안

정합성 문제를 어떻게 해결할 수 있을까.

트랜잭션 격리 레벨 조정?

가장 높은 트랜잭션 격리 레벨인 Serializable 을 사용하면 문제를 해결할 수 있을까?

💡 트랜잭션 격리 레벨에 대해 💡
높은 순으로 있다.
- Serializable
	가장 엄격한 격리 수준으로, 이름 그대로 트랜잭션을 순차적으로 진행시킨다.
	하지만 트랜잭션이 순차적으로 처리되어야 하니, 동시 처리 문제가 매우 떨어진다.
	순수한 SELECT 작업에서도 대상 레코드에 공유 락을 설정한다.
- Replateble READ
	자신의 트랜잭션이 생성되기 이전의 트랜잭션에서 COMMIT이 된 데이터만 읽는다.
	Mysql과 MariaDB가 디폴트로 사용하는 격리 수준이다.
- Read Commited
	COMMIT이 된 데이터만 읽는다.
	RDB에서 대부분 기본적으로 사용되고 있는 격리 수준이다.
- Read UnCommited
	각 트랜잭션에서의 변경 내용이 COMMIT이나 ROLLBACK여부에 상관 없이
	다른 트랜잭션에서 값을 읽을 수 있다.

Serializable 를 사용하면 select 쿼리에 공유 락이 걸리게 된다.

그리고 업데이트 쿼리가 실행될 때 Mysql이 배타 락을 걸게 된다.

하지만 이 부분에서 데드락이 걸린다는 문제가 발생한다.

Untitled

즉, 단순히 격리 레벨을 조정한다고 해결할 수 없다.

비관적 락 활용?

비관적 락은 트랜잭션이 시작될 때 공유 락 혹은 배타 락을 걸고 시작하는 방법이다.

비관적 락을 사용한다면 해결할 수 있을까

이를 사용하면 조회하는 시점에서 배타 락을 얻게 된다.

Untitled

따라서 이와 같이 트랜잭션 A에서 c회원을 조회하면 트랜잭션 A가 끝날때 까지 다른 트랜잭션들은 배타 락, 공유 락 아무것도 얻어올 수 없다.

이를 통해 정합성 문제도, 데드락 문제도 해결할 수 있지만 이는 대기 시간이 발생하게 된다.

더욱이 동시에 접근하는 사용자가 많아진다면 대기 시간은 더욱 길어질 것입니다.

따라서 현업에서는 최대한 기피한다고 한다.

낙관적 락 활용?

낙관적 락은 트랜잭션 끝에서 충돌 검사를 하는데, 데드락 문제도 발생하지 않고 비관적 락 방식처럼 레코드에 락을 걸어버려 대기를 길게 하지도 않으니 좋지 않을까.

JPA를 사용하면 낙관적 락을 구현하기 또한 쉽다고 한다.

하지만 정합성을 지키기 위해서 비즈니스 로직을 희생해야 한다.

Untitled

이와 같이 낙관적 락을 활용하게 되면 버전 정보를 활용해서 버전 정보가 일치하는 경우에만 커밋을하고, 일치하지 않는 경우 롤백 처리를 한다.

이렇게 된다면 실제로 생성되는 팔로잉은 a → c이기 때문에 팔로워 카운트가 1만 증가되어도 정합성에 문제가 생기지는 않는다.

하지만 b → c가 실패한다는 문제가 있다.

더티 체킹 대신 쿼리 직접 실행

더티 체킹을 포기하고 레코드 자체의 값을 통해 통계를 계산해 주는 방법이다.

도메인 값을 변경하지 않고 Service 레이어에서 직접 Repository의 메소드를 호출해줘야 하기 때문에 도메인에서 최대한 모든 로직을 처리하는 것은 불가능해지지만, 정합성을 맞출수는 있다.

Mysql은 update 쿼리가 실행될 때 배타 락을 설정한다.

Untitled
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(value = "update Member m set m.followerCount = m.followerCount + 1 where m.id = :followingMemberId")
void increaseFollowerCount(Long followingMemberId);

이렇게 하게 되면 저장된 회원의 팔로워에 +1을 하는 것이다.

이렇게 하면 count 쿼리를 사용하는 것보다 훨씬 빠르고 배타 락 덕분에 정합성 또한 보장할 수 있다.

물론 이는 덜 객체 지향적인 코드가 되고, 도메인의 로직이 바깥으로 이동된다는 단점이 있다.

적당한 방법을 잘 파악해서 하는 것이 중요할 듯 하다.

또한, 캐시메모리를 활용하는 방법 또한 고려할 수 있을 것이다.