1편에 이어서 이번에는 갱신 손실과 쓰기 스큐에 관련해서 조금 더 자세하게 테스트해 보고 알아보자
갱신 손실은 여러 트랜잭션이 한 개의 객체(row)에 동시에 접근해서 가장 마지막에 진행된 갱신만 적용되고 이전 트랜잭션들이 갱신한 값들은 모두 무효 처리가 되는 것을 의미한다.
예를 들어 쉽게 알아보자 이런 데이터가 있다고 치자
userId | count |
---|---|
user1 | 0 |
당연히 결괏값은 아래와 같이 되어야 하지만 갱신 손실에 대한 방지를 하지 않았더라면
userId | count |
---|---|
user1 | 2 |
다음과 같은 결괏값이 나온다.
userId | count |
---|---|
user1 | 1 |
위에서 본 것처럼 트랜잭션 1의 갱신이 무효가 된 것이다. 그 이유를 단계적으로 풀어서 알아보자.
각 테스트의 시나리오는 어떻게 결정할까?
아래의 모든 테이블은 다음과 같은 데이터를 사용했고 계좌번호는 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번에서 입금할 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
}
분명 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을 걸고 트랜잭션을 진행했기 때문에 순차적으로 트랜잭션이 진행된다.
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번에서 출금하는 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);
}
분명 우리는 서비스 로직에 출금할 돈이 0보다 작으면 뒤에 송금 로직에서 Exception을 터뜨리고 나머지 로직인 송금 로직은 실행이 되면 안 된다.
그럼에도 불구하고 유저 1의 계좌 잔액은 -2,000원 이이다.
이렇게 된 이유를 단계별로 알아보자.
userdao.selectNonLock(sendId);
를 통해서 출금할 user1의 row에 lock을 걸지 않고 계좌 정보를 가져온다."update account set money = money-? where id=?"
이 경우에는 lock 을 걸고 실행했기 때문에 user1의 1,000원은 가장 먼저 접근한 트랜잭션의 1개의 1,000원 송금만 허용하고 나머지 2개의 트랜잭션은 Exception을 발생시키며 송금이 되지 않는다.
그 순서를 단계적으로 알아보면 이렇다.
위의 예처럼 쓰기 스큐는 트랜잭션이 순서대로 실행되게 하도록 해야 막을 수 있다.
즉 쓰기 스큐를 막기 위해서는 성능 손해를 보더라도 트랜잭션이 순차적(직렬)으로 진행돼야 한다.
따라서 트랜잭션의 직렬성을 보장하는 방법을 사용해야 하는데
트랜잭션 격리 수준을 SERIALIZABLE 로 올려서 사용하거나
위에서 사용한 것처럼 비관적 lock(2PL)을 사용해서 처리하면 쓰기 스큐를 막을 수 있다.
회원가입 로직 하나로 발생할 수 있는 여러 가지 db 이슈들을 살펴보았다.
갱신 손실에 비해서 쓰기 스큐 같은 경우는 비즈니스 로직에서 생각보다 자주 발생하는 이슈일 것 같다.
중복 확인, 송금 관련, 예약 관련 등등 이렇게 어떤 정보를 바탕으로 db에 쓸 때는 해당 이슈를 꼭 기억하고 주의해서 코드를 작성해야겠다.
추가적으로 현재 회원 테이블에서는 Unique index를 사용했지만 만약 데이터량이 많고 insert가 자주 발생하는 테이블에 unique를 보장해야 하는 값이 있다면 Unique index를 써도 될까??
Unique index는 기본적으로 중복 값을 체크할 때 읽기 잠금을 사용하고, 쓰기를 할 때는 쓰기 잠금을 사용하기 때문에 성능이 좋지 않다.
이는 테이블이 커질수록 문제가 될 수 있는데 이럴 때는 어떻게 해야 할지 생각해 봐야겠다.