동시성이슈 2편

HYK·2022년 12월 12일
0
post-thumbnail

동시성 이슈 1편

개요

1편에 이어서 이번에는 갱신 손실과 쓰기 스큐에 관련해서 조금 더 자세하게 테스트해 보고 알아보자

갱신 손실(Lost Update)

갱신 손실은 여러 트랜잭션이 한 개의 객체(row)에 동시에 접근해서 가장 마지막에 진행된 갱신만 적용되고 이전 트랜잭션들이 갱신한 값들은 모두 무효 처리가 되는 것을 의미한다.
예를 들어 쉽게 알아보자 이런 데이터가 있다고 치자

userIdcount
user10
  • 트랜잭션 1이 user1의 count를 읽어와 0+1를 계산했다.
  • 트랜잭션 2가 user1의 count를 읽어와 0+1를 계산했다.
  • 트랜잭션 1이 user1의 count에 계산한 값을 write 했다.
  • 트랜잭션 2가 user1의 count에 계산한 값을 write 했다.

당연히 결괏값은 아래와 같이 되어야 하지만 갱신 손실에 대한 방지를 하지 않았더라면

userIdcount
user12

다음과 같은 결괏값이 나온다.

userIdcount
user11

위에서 본 것처럼 트랜잭션 1의 갱신이 무효가 된 것이다. 그 이유를 단계적으로 풀어서 알아보자.

  1. 트랜잭션 1이 count의 값을 불러와 연산한다. 0+1=1
  2. 그 사이에 트랜잭션 2도 count의 값을 불러와 계산한다.
  3. 현재 시점으로 트랜잭션 1은 아직 연산된 count 값을 갱신하지 않았으므로 2번 단계에서 트랜잭션 2가 불러온 count 값은 0이다
  4. 마찬가지로 트랜잭션 2도 연산을 시작한다. 0+1=1
  5. 트랜잭션 1이 1번에서 계산한 값을 데이터베이스에 저장한다. (1) 저장
  6. 트랜잭션 2도 4번에서 계산한 값을 데이터베이스에 저장한다. (1) 저장
    즉 2번 트랜잭션 이 count를 검색한 시점에 count가 0이었기 때문에 이러한 문제가 발생하는 것이다.

테스트

각 테스트의 시나리오는 어떻게 결정할까?

  • 갱신 손실
    • 여러 개의 트랜잭션이 하나의 row에 쓸 때 일어난다.
    • 갱신 손실이 일어나면 크리티컬한 경우인 입금 시나리오를 테스트해보면 좋을 것 같다.
    • 입금 같은 경우 하나의 객체에 동시에 여러 번 update 되었을 때 한 번의 업데이트만 적용되면 데이터가 일치하지 않는 문제가 생길 수 있다.
  • 쓰기 스큐
    • 트랜잭션이 어떤 값을 select하고 select 한 값을 기반으로 어떤 결정을 내린 후 그 결정을 데이터베이스에 쓸 때 일어난다.
    • 출금/입금이 동시에 이루어지고 출금할 때 해당 계좌의 금액을 전제로 출금 후 계좌의 금액이 마이너스일 때 입금 프로세스가 일어나지 않고 마이너스가 아닌 경우에만 입금을 하므로 쓰기 스큐의 테스트 시나리오에 적당하다.

데이터 세팅

아래의 모든 테이블은 다음과 같은 데이터를 사용했고 계좌번호는 secondary index로 지정했다.

#                         pk money account(secondary index)
insert into account values(1,1000,'123-156-7890');
insert into account values(2,2000,'123-223-5523');
insert into account values(3,3000,'123-358-4566');
insert into account values(4,4000,'123-448-5809');
insert into account values(5,5000,'123-558-4567');
insert into account values(6,6000,'123-668-7655');
insert into account values(7,7000,'123-778-4576');
insert into account values(8,8000,'123-888-6668');
insert into account values(9,9000,'123-998-3433');
insert into account values(10,10000,'123-999-1245');

갱신 손실 테스트

갱신 손실 테스트를 하기 위해서는 read-modify-write 과정이 필요하므로 한 번에 원자적 연산을 사용하지 않고 select로 값을 가져와서 계산한 뒤에 그 값을 바로 update 방식으로 진행한다.

갱신 손실 테스트 시나리오를 간략하게 설명하면 이렇다.

  1. pk로 입금할 user의 현재 잔고를 select 한다.
  2. 해당 user의 잔고와 입금할 액수를 더해준다.
  3. 해당 2번에서 더한 금액을 update 해준다.

1번에서 입금할 user의 row에 lock을 걸고 / 걸지 않고에 대한 결과의 차이를 확인해 보자

dao

    /**
     * 명시적 lock o
     */
    public UserDto selectLock(Long userId) {
        RowMapper<UserDto> rowMapper = (rs, rowNum) -> new UserDto(rs.getLong(1), rs.getInt(2), rs.getString(3));
        return jdbcTemplate.queryForObject("select * from account where id=? for update", rowMapper, userId);
    }
    /**
     * 명시적 lock x
     */
    public UserDto selectNonLock(Long userId) {
        RowMapper<UserDto> rowMapper = (rs, rowNum) -> new UserDto(rs.getLong(1), rs.getInt(2), rs.getString(3));
        return jdbcTemplate.queryForObject("select * from account where id=? ", rowMapper, userId);
    }
    /**
     * 원자성 연산 x
     */
    public void plusMoneyNonAtomic(Long userId, int money) {
        jdbcTemplate.update("update account set money = ? where id=?", money, userId);
    }

service

    /**
     * pk
     * 갱신 손실 테스트
     * 명시적 lock o
     */
    @Transactional
    public void depositByLock(Long sendId, int sendMoney) {
        UserDto userDto = userdao.selectLock(sendId);  
        userdao.plusMoneyNonAtomic(sendId, userDto.getMoney() + sendMoney);
    }
    /**
     * pk
     * 갱신 손실 테스트
     * 명시적 lock x
     */
    @Transactional
    public void depositByNonLock(Long sendId, int sendMoney) {
        UserDto userDto = userdao.selectNonLock(sendId);
        userdao.plusMoneyNonAtomic(sendId, userDto.getMoney() + sendMoney);
    }

test

    // 1번 테스트 nonlock
    @Test
    @DisplayName("입금 NonLock 테스트 갱신 손실 유발")
    void depositByNonLock() throws InterruptedException {
        Thread t1 = new Thread(() -> updateService.depositByNonLock(1L, 1000));
        Thread t2 = new Thread(() -> updateService.depositByNonLock(1L, 1000));
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        assertThat(userDao.selectNonLock(1L).getMoney()).isEqualTo(2000);  //true
    }
    // 2번 테스트 nonlock
    @Test
    @DisplayName("입금 Lock 테스트 갱신 손실 방지")
    void depositByLock() throws InterruptedException {
        Thread t1 = new Thread(() -> updateService.depositByLock(1L, 1000));
        Thread t2 = new Thread(() -> updateService.depositByLock(1L, 1000));
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        assertThat(userDao.selectNonLock(1L).getMoney()).isEqualTo(3000);  //true
    }

결과

1번 테스트 nonlock

분명 id가 1인 유저는 기본 데이터에 1,000원이 있었고 1,000원을 두 번 입금했으니 정확하게 계산이 된다면 3,000원이 되어야 할 것이다.

그런데 왜 결과는 2,000원이라고 나올까?

그 이유는 id가 1인 유저의 row에 lock을 걸지 않았고 그로 인해 여러 트랜잭션이 접근할 수 있었기 때문이다.
거의 동시에 두 트랜잭션이 id가 1인 user의 money를 select 했다.
그 값이 1,000원이였고 그 값을 기준으로 각각 1,000원을 더했기 때문에 두 개의 트랜잭션의 결과는 모두 2000이다.
즉 첫 번째 트랜잭션의 더한 값이 2번 트랜잭션에 반영이 되지 않았기 때문에 갱신 손실이 났다고 볼 수 있다.

2번 테스트 lock

2번의 테스트 결과는 lock을 걸고 트랜잭션을 진행했기 때문에 순차적으로 트랜잭션이 진행된다.
1번 트랜잭션이 lock을 걸고 2번 트랜잭션은 1번 트랜잭션의 작업이 끝날 때 까지 대기하기 때문에
1번 트랜잭션이 1,000 -> 2,000원으로 업데이트 후에 2번 트랜잭션이 2,000-> 3,000원으로 업데이트한다.
따라서 정상적으로 3,000원이 들어가게 된다.

갱신 손실 어떻게 막을까?

우리가 자주 사용하는 update 문을 이용하면 갱신 손실을 방지할 수 있다.

예를 들면 해당 예시에서는
update account set money = money+1000 where user_id=user1`` 과 같이 사용하면 갱신 손실을 방지할 수 있다. 이처럼 read-modify-write 주기를 제거해서 방지하는 방법을
원자적 쓰기 연산 이라고 한다.
혹은 비관적 lock을 사용해서 해당 row에 lock을 걸고 사용할 수도 있다(deadlock 발생 가능성 증가).
이외에도 갱신 손실 자동감지 등의 방법이 있으나 대부분은 위의 두 가지 경우로 해결하면 된다.

쓰기 스큐 테스트 (송금)

쓰기 스큐 는 select 한 값을 기반해 결정한 후 insert update 할 때 발생할 수 있다.

쓰기 스큐 테스트 시나리오를 간략하게 설명하면 이렇다.

  1. pk로 송금자의 현재 잔액을 select 한다.
  2. 송금할 금액을 현재 계좌에서 뺏을 때 마이너스가 되면 exception을 발생시키고 송금 프로세스를 종료한다.
  3. 마이너스가 되지 않으면 출금 액수만큼 상대방 계좌에 입금(update)한다

1번에서 출금하는 user의 row에 lock을 걸고 / 걸지 않고에 대한 결과의 차이를 확인해보자

dao

 	//select는 로직은 갱신 손실 테스트에서 사용한 것과 동일
   /**
     * 원자성 연산 o
     */
    public void plusMoney(Long userId, int money) {
        jdbcTemplate.update("update account set money = money+? where id=?", money, userId);
    }
    /**
     * 원자성 연산 o
     */
    public void minusMoney(Long userId, int money) {
        jdbcTemplate.update("update account set money = money-? where id=?", money, userId);
    }

service

    /**
     * pk
     * 쓰기 스큐 테스트
     * 명시적 lock x
     */
    @Transactional
    public void remittanceByNonLock(Long sendId, Long receiveId, int sendMoney) {
        UserDto userDto = userdao.selectNonLock(sendId);
        if (userDto.getMoney() - sendMoney < 0) {
            throw new IllegalArgumentException("잔액부족");
        }
        userdao.minusMoney(sendId, sendMoney);
        userdao.plusMoney(receiveId, sendMoney);
    }

    /**
     * pk
     * 쓰기 스큐 테스트
     * 명시적 lock o
     */
    @Transactional
    public void remittanceByLock(Long sendId, Long receiveId, int sendMoney) {
        UserDto userDto = userdao.selectLock(sendId);
        if (userDto.getMoney() - sendMoney < 0) {
            throw new IllegalArgumentException("잔액부족");
        }
        userdao.minusMoney(sendId, sendMoney);
        userdao.plusMoney(receiveId, sendMoney);
    }

test

    @Test
    @DisplayName("송금 nonLock 쓰기 스큐 유발")
    void remittanceByNonLock() throws InterruptedException {
        userDao.deleteData();
        userDao.initData();
        Thread t1 = new Thread(() -> updateService.remittanceByNonLock(1L, 2L, 1000));
        Thread t2 = new Thread(() -> updateService.remittanceByNonLock(1L, 3L, 1000));
        Thread t3 = new Thread(() -> updateService.remittanceByNonLock(2L, 3L, 1000));
        t1.start();
        t2.start();
        t3.start();
        t1.join();
        t3.join();
        t2.join();
        assertThat(userDao.selectNonLock(1L).getMoney()).isEqualTo(-2000);
        assertThat(userDao.selectNonLock(2L).getMoney()).isEqualTo(3000);
        assertThat(userDao.selectNonLock(3L).getMoney()).isEqualTo(4000);
        assertThat(userDao.selectNonLock(4L).getMoney()).isEqualTo(5000);

    }

	//TreadChecker 는 스레드에서 발생한 예외를 잠깐 저장해놨다가 테스트가 끝나면 해당예외를 반환해주는 기능을 실행
    @Test
    @DisplayName("송금 Lock 쓰기 스큐 방지")
    void remittanceByLock() {
        userDao.deleteData();
        userDao.initData();
        assertThatThrownBy(() -> {
            TreadChecker t1 = new TreadChecker(() -> updateService.remittanceByLock(1L, 2L, 1000));
            TreadChecker t2 = new TreadChecker(() -> updateService.remittanceByLock(1L, 3L, 1000));
            TreadChecker t3 = new TreadChecker(() -> updateService.remittanceByLock(2L, 3L, 1000));
            t1.start();
            t2.start();
            t3.start();
            t1.test();
            t3.test();
            t2.test();
        }).isInstanceOf(IllegalArgumentException.class);
        assertThat(userDao.selectNonLock(1L).getMoney()).isEqualTo(0);
        assertThat(userDao.selectNonLock(2L).getMoney()).isEqualTo(3000);
        assertThat(userDao.selectNonLock(3L).getMoney()).isEqualTo(3000);
        assertThat(userDao.selectNonLock(4L).getMoney()).isEqualTo(4000);
    }

결과

1번 테스트 nonlock

분명 우리는 서비스 로직에 출금할 돈이 0보다 작으면 뒤에 송금 로직에서 Exception을 터뜨리고 나머지 로직인 송금 로직은 실행이 되면 안 된다.

그럼에도 불구하고 유저 1의 계좌 잔액은 -2,000원 이이다.

이렇게 된 이유를 단계별로 알아보자.

  1. 여러 트랜잭션이 userdao.selectNonLock(sendId);를 통해서 출금할 user1의 row에 lock을 걸지 않고 계좌 정보를 가져온다.
  2. 따라서 실행한 1->2, 1->3, 1->4 트랜잭션은 각각 동시에 현재 user의 계좌 잔액에 접근하게 되고 3개의 트랜잭션 모두 1,000원을 select해서 (userDto.getMoney() - sendMoney < 0)` 로직을 통과하게 된다.
  3. 잔액 부족을 확인하는 로직을 세 트랜잭션 모두 통과했기 때문에 그 뒤의 로직은 문제없이 현재 값을 기준으로 원자성 update를 하게 된다 "update account set money = money-? where id=?"
  4. 따라서 해당 계좌의 잔액과 관계없이 update가 모두 반영돼서 3번 출금하고 3번 입금된 결과가 -2,000원이다.

2번 테스트 lock

이 경우에는 lock 을 걸고 실행했기 때문에 user1의 1,000원은 가장 먼저 접근한 트랜잭션의 1개의 1,000원 송금만 허용하고 나머지 2개의 트랜잭션은 Exception을 발생시키며 송금이 되지 않는다.

그 순서를 단계적으로 알아보면 이렇다.

  1. 여러 트랜잭션이 동시에 접근하려 하지만 가장 먼저 접근한 첫 번째 트랜잭션이 출금 계좌에 lock을 걸고 나머지 트랜잭션들은 출금할 계좌의 lock이 풀릴 때까지 대기하게 된다.
  2. 1번 트랜잭션이 송금 처리를 완료한다.
  3. 다음으로 대기하던 트랜잭션이 select 해서 현재 user1의 계좌 잔액을 확인한다.
  4. 1번 트랜잭션이 이미 송금을 했기 때문에 남은 잔액은 0원이라고 나온다. 따라서 exception이 발생하고 그 뒤에 다른 트랜잭션들도 송금이 불가하다.
  5. 따라서 1번 트랜잭션만 정상적으로 송금 로직이 완료되고 나머지 트랜잭션들은 송금에 실패하게 된다.

쓰기 스큐 어떻게 막을까?

위의 예처럼 쓰기 스큐는 트랜잭션이 순서대로 실행되게 하도록 해야 막을 수 있다.

즉 쓰기 스큐를 막기 위해서는 성능 손해를 보더라도 트랜잭션이 순차적(직렬)으로 진행돼야 한다.
따라서 트랜잭션의 직렬성을 보장하는 방법을 사용해야 하는데
트랜잭션 격리 수준을 SERIALIZABLE 로 올려서 사용하거나
위에서 사용한 것처럼 비관적 lock(2PL)을 사용해서 처리하면 쓰기 스큐를 막을 수 있다.

느낀 점

회원가입 로직 하나로 발생할 수 있는 여러 가지 db 이슈들을 살펴보았다.
갱신 손실에 비해서 쓰기 스큐 같은 경우는 비즈니스 로직에서 생각보다 자주 발생하는 이슈일 것 같다.
중복 확인, 송금 관련, 예약 관련 등등 이렇게 어떤 정보를 바탕으로 db에 쓸 때는 해당 이슈를 꼭 기억하고 주의해서 코드를 작성해야겠다.

조금 더 생각해 볼 점

추가적으로 현재 회원 테이블에서는 Unique index를 사용했지만 만약 데이터량이 많고 insert가 자주 발생하는 테이블에 unique를 보장해야 하는 값이 있다면 Unique index를 써도 될까??
Unique index는 기본적으로 중복 값을 체크할 때 읽기 잠금을 사용하고, 쓰기를 할 때는 쓰기 잠금을 사용하기 때문에 성능이 좋지 않다.
이는 테이블이 커질수록 문제가 될 수 있는데 이럴 때는 어떻게 해야 할지 생각해 봐야겠다.

profile
Test로 학습 하기

0개의 댓글