회원가입 기능 구현 중에 유저의 아이디를 중복 확인하는 로직이 있었는데 같은 아이디로 동시에 회원가입할 때 예측한 대로 작동하지 않는 버그가 있었다. (인덱스 지정 x)
public Long joinUser(JoinUserDto userDto) {
if (isDuplicateUserId(userDto.getUserId())) {
throw new UserIdDuplicateException();
}
User user = User.from(userDto);
user.setEncryptionPassword(passwordEncoder.encode(user.getUserPassword()));
return userMapper.insertUser(user);
}
isDuplicateUserId
에 여러 스레드가 접근 시에 제대로 동작하지 않고 쓰기 스큐와 팬텀 읽기가 발생한다.트랜잭션 내에 select(값을 읽고) 후 그 select 한 값으로 분기(결정)를 하고 그 결과를 insert or update (쓰기) 하려는 중에 select 한 값을 통해서 결정한 전제가 더 이상 참이 아닌 현상을 말한다.
현재 이슈인 회원가입을 예로 들면
트랜잭션이 어떤 검색 조건에 부합하는 객체를 읽는다.(select)
다른 클라이언트가 그 검색 결과에 주는 쓰기 (insert)를 했을 때 발생하는 현상을 말한다.
-- 1번 트랜잭션
select count(*) from users where user_id = 'test';
--결과는 0 중복되는 아이디가 없음
-- 2번 트랜잭션
insert into users(user_id) values('test');
-- 1번 트랜잭션
insert into users(user_id) values('test');
-- 2번 트랜잭션이 이미 test라는 아이디로 가입
-- 2번 트랜잭션이 1번 트랜잭션에 영향을 준다.
-- select count(*) from users where user_id = 'test';의 결과가 1로 변경
마찬가지로 회원가입 로직에 비유하자면 1번 트랜잭션에서 처음 중복 id 검사를 했을 땐 중복된 id가 없다고 나왔는데 이후에 보니(insert 바로전) 중복 id가 있는 나오는 현상을 말한다.
이 문제를 해결하기 위한 방안을 몇 가지 생각해 봤다.
1. 2단계 잠금 사용(2PL)
2. synchronized or serializable
3. unique index + DuplicateKeyException
2단계 잠금 사용은 트랜잭션을 시작하고 읽은 객체에 대한 변경이 필요하다면 읽을 때(select) 공유 모드 혹은 독점 모드로 사용해서 해당 객체에 락을 걸고 사용하는 것을 말한다.
즉 현재 회원가입 로직에서는 중복 아이디를 확인할 때 select for update (x-lock)
를 사용하면 된다.
개요
2단계 잠금 사용을 해서 구현을 할 때 (성능상) 문제가 없는지 테스트하기
확인할 사항
데드락 발생 빈도수, lock의 범위, search 성능 ...
현재 해당 unique 인덱스는
a, b, c
가 있다고 가정한다.
select ~ where user_id='a' for update
를 실행하는 경우에는 a가 존재하기 때문에 record lock을 건다
현재 a, b, c의 인덱스가 존재할 때
select ~ where user_id='aa' for update
를 실행 하게 되면
현재 포함 인덱스인 a부터 b 사이를 모두 잠그는 gap-lock이 실행되는 것을 볼 수 있다.
현재 a, b, c의 인덱스가 존재할 때
select ~ where user_id='zzz' for update
를 실행 하게 되면
a, b, c 인덱스 범위에 포함되지 않기 때문에 c ~ 허수 인덱스인 supremum pseudo-record까지 모두 잠그게 된다.
이 경우에는 레코드가 작을수록 테이블 전체에 lock의 범위가 넓어짐으로 주의해야 한다.
이외에 세컨더리 인덱스 같은 경우 쿼리의 결괏값이 1개인 것을 보장하지 않고 중복되기 때문에 항상 record-lock + gap-lock이 사용된다.
따라서 인덱스를 사용하고 있을 때 2PL 방식을 사용하면 deadlock이 매우 자주 발생한다.
deadlock이 자주 발생하는 이유는 gap-lock의 비밀에 있다.
(중복검사는 존재하지 않는 값을 검색하는 용도로 사용하기 때문에 위에처럼 record-lock이 잡히는 경우는 아이디가 존재하는 케이스 밖에 없다 따라서 중복검사에서 통과하고 update 할 때에는 모두 gap-lock으로 잡힌다.)
실제로 일반적인 x-lock은 s, x lock획득 자체를 못하게 방지하지만
gap x-lock 은 다음과 같이 동시에 락 획득이 가능하다.
gap x-lock은 실제 gap에 값이 삽입되는 것을 막기 위한 기능이고 같은 범위의 gap-lock 획득에는 제한을 두지 않는다.
따라서 동시에 트랜잭션이 select for update로 같은 범위의 gap x-lock을 획득할 수 있는 것이다.
(따라서 gap x-lock은 lock 획득에 대해서 gap s-lock과 차이가 없다.)
따라서 서로 다른 트랜잭션이 같은 gap x-lock을 획득하고 이후에 각각 insert를 시작하게 되면 서로 gap x-lock이 풀리길 기다리게 되어 데드락이 발생한다.
대기중인 트랜잭션
데드락 발생
https://dev.mysql.com/doc/refman/8.0/en/innodb-locking.html#innodb-gap-locks
사실 인덱스가 없는 경우는 select 성능 자체가 잘 나오지 않기 때문에 사용할 수 없다.
하지만 인덱스가 없는 경우에 비관적 락을 사용 시 잠기는 lock의 범위가 궁금해서 테스트해 보았다.
인덱스가 없는 경우에는 테이블 전체 레코드에 대해서 락이 걸리는 것을 확인했다.
이처럼 테이블 전체에 lock 이 걸리기 때문에 성능상으로 많은 손해를 보기 때문에 사용하기 적합하지 않다.
현재는 unique 인덱스를 기준으로 테스트했지만 기본 키 인덱스, 또는 다른 인덱스를 조건으로 검색하거나 해도 동일한 결과가 나온다.
인덱스가 있는 경우에는 deadlock이 지속적으로 발생해서 재시도 해야 하는 문제로 적합하지않음
인덱스가 없는 경우에는 select 성능 문제와 테이블 전체 lock 때문에 적합하지 않은 방법이다.
현재 로직에는 2PL 잠금은 deadlock 이슈와 성능 문제로 인해서 사용할 수 없을 것 같다고 판단했다.
synchronized 나 serializable 또한 직렬성을 보장해 주지만 동시성 처리 성능 면에서는 좋지 않기 때문에 적합하지 않다.
마지막 3번 같은 경우는 비관적 락을 이용하지 않는다
일단 동시 회원가입이 아닌 경우는 isDuplicateUserId가 한번 걸러낼 수 있고
자주 발생하진 않겠지만 동시에 같은 아이디로 회원가입이 발생한다면 그때는 DuplicateKeyException를 잡아서 customException으로 한번 감싼 후에 반환해 주면 된다.
결론적으로 3번이 락을 따로 이용하지 않기 때문에 동시성 처리에 대해서 가장 효율적이기 때문에 3번 형식을 이용해서 구현했다.
public Long joinUser(JoinUserDto userDto) {
if (isDuplicateUserId(userDto.getUserId())) {
throw new UserIdDuplicateException();
}
User user = User.from(userDto);
user.setEncryptionPassword(passwordEncoder.encode(user.getUserPassword()));
Long saveUserId = null;
try {
saveUserId = userMapper.insertUser(user);
} catch (DuplicateKeyException e) {
throw new UserIdDuplicateException("Duplicated userId", e);
}
return saveUserId;
}
2편은 이어서 동시성 처리에 관련된 갱신 손실과 쓰기 스큐에 대해서 테스트를 진행해 보고 리뷰할 예정이다.
안녕하세요 포스팅에서 잘 배우고 갑니다
혹시 락 범위 잡는 테스트는 어떤 툴로 진행하신걸까요?
테스트 방법이 궁금합니다