동시성이슈 1편 - 회원가입

HYK·2022년 11월 23일
3

project

목록 보기
2/8
post-custom-banner

개요

회원가입 기능 구현 중에 유저의 아이디를 중복 확인하는 로직이 있었는데 같은 아이디로 동시에 회원가입할 때 예측한 대로 작동하지 않는 버그가 있었다. (인덱스 지정 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 한 값을 통해서 결정한 전제가 더 이상 참이 아닌 현상을 말한다.

현재 이슈인 회원가입을 예로 들면

  1. 동일한 아이디로 1번 트랜잭션과 2번 트랜잭션이 함께 회원가입을 시도한다.
  2. 1번, 2번 트랜잭션이 동시에 isDuplicateUserId 로직을 통과한다.
  3. 2번 트랜잭션이 먼저 insert 문으로 해당 아이디로 가입을 했다고 치고 1번 트랜잭션도 2번 과정에서 이미 isDuplicateUserId를 참으로 통과했기 때문에 중복된 아이디가 없는 것으로 판단하고 insert를 시도 한다.
  4. 하지만 2번 트랜잭션이 먼저 가입한 시점에서 부터는 1번 트랜잭션의 insert 전제인 isDuplicateUserId의 결과가 참 -> 거짓이 되는 현상이 생기는데 이를 쓰기 스큐라 한다.(따라서 unique 인덱스가 없다면 같은 아이디로 2개가 가입이 된다.)

팬텀 읽기란?

트랜잭션이 어떤 검색 조건에 부합하는 객체를 읽는다.(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


1. 2단계 잠금 사용(2PL)

2단계 잠금 사용은 트랜잭션을 시작하고 읽은 객체에 대한 변경이 필요하다면 읽을 때(select) 공유 모드 혹은 독점 모드로 사용해서 해당 객체에 락을 걸고 사용하는 것을 말한다.

즉 현재 회원가입 로직에서는 중복 아이디를 확인할 때 select for update (x-lock) 를 사용하면 된다.

테스트

  • 개요
    2단계 잠금 사용을 해서 구현을 할 때 (성능상) 문제가 없는지 테스트하기

  • 확인할 사항
    데드락 발생 빈도수, lock의 범위, search 성능 ...

테스트 1 (unique) 인덱스가 있는 경우

현재 해당 unique 인덱스는 a, b, c 가 있다고 가정한다.

  • unique 인덱스나 pk로 쿼리(=) 하는 경우는 해당 값이 1개인 것을 보장한다.
    때문에 where 절에 해당하는 값이 있으면 gap-lock 을 걸지 않고 레코드 락을 건다.

    select ~ where user_id='a' for update를 실행하는 경우에는 a가 존재하기 때문에 record lock을 건다

  • 하지만 만약 where 절에 해당하는 값이 존재하지 않는다면 gap-lock 을 걸게 되는데
    현재 존재하는 인덱스 사이에 값이 없는 경우 검색한 값의 이전 인덱스 ~ 다음 인덱스 값까지 lock을 건다.

    현재 a, b, c의 인덱스가 존재할 때 select ~ where user_id='aa' for update를 실행 하게 되면
    현재 포함 인덱스인 a부터 b 사이를 모두 잠그는 gap-lock이 실행되는 것을 볼 수 있다.

  • 존재하는 인덱스의 범위를 벗어나는 경우에는
    해당 값을 기준으로 마지막 지점(supremum pseudo-record)까지 (next-key)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이 매우 자주 발생한다.

gap-lock의 비밀

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

테스트 2 인덱스가 없는경우

사실 인덱스가 없는 경우는 select 성능 자체가 잘 나오지 않기 때문에 사용할 수 없다.
하지만 인덱스가 없는 경우에 비관적 락을 사용 시 잠기는 lock의 범위가 궁금해서 테스트해 보았다.
인덱스가 없는 경우에는 테이블 전체 레코드에 대해서 락이 걸리는 것을 확인했다.

  • 전체 레코드에 lock 걸림

이처럼 테이블 전체에 lock 이 걸리기 때문에 성능상으로 많은 손해를 보기 때문에 사용하기 적합하지 않다.

현재는 unique 인덱스를 기준으로 테스트했지만 기본 키 인덱스, 또는 다른 인덱스를 조건으로 검색하거나 해도 동일한 결과가 나온다.

인덱스가 있는 경우에는 deadlock이 지속적으로 발생해서 재시도 해야 하는 문제로 적합하지않음
인덱스가 없는 경우에는 select 성능 문제와 테이블 전체 lock 때문에 적합하지 않은 방법이다.
현재 로직에는 2PL 잠금은 deadlock 이슈와 성능 문제로 인해서 사용할 수 없을 것 같다고 판단했다.


2. synchronized or serializable

synchronized 나 serializable 또한 직렬성을 보장해 주지만 동시성 처리 성능 면에서는 좋지 않기 때문에 적합하지 않다.


3. unique index + DuplicateKeyException

마지막 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편은 이어서 동시성 처리에 관련된 갱신 손실과 쓰기 스큐에 대해서 테스트를 진행해 보고 리뷰할 예정이다.

profile
Test로 학습 하기
post-custom-banner

2개의 댓글

comment-user-thumbnail
2024년 4월 18일

안녕하세요 포스팅에서 잘 배우고 갑니다
혹시 락 범위 잡는 테스트는 어떤 툴로 진행하신걸까요?
테스트 방법이 궁금합니다

1개의 답글