정상적인 상황에서는 트랜잭션이 시작할때 락을 얻고, 트랜잭션이 커밋되면 락을 해제한다. 그 이후에 락 획득을 대기하고 있던 다른 트랜랙션이 락을 얻게 되고 자신의 로직을 실행한다. 하지만 비즈니스 로직 내에서 비정상적으로 동작이 지연되는 경우들이 발생할 수 있다.
락 메커니즘을 사용할때 꼭 설정해야 하는 것중 하나가 락 타임아웃이다. 만약 락을 획득한 트랜잭션이 무한정 락을 소유하고 있을 수 있다면 어떻게 될까. 해당 트랜잭션에서 지연이 발생하거나, 분산 서버 환경에서 락을 들고 있는 서버가 죽어버린다면 락의 소유권은 영영 돌려받을 수 없게 된다. 다른 트랜잭션들은 락 획득을 위해 무한정 대기하게 되고 결국 데드락이 발생할 것이다.
Client 1의 작업으로 5000원의 잔액이 예상되었지만 결과적으로는 7000원의 잔액이 남는다.
먼저 락을 획득한 클라이언트 1의 트랜잭션이 진행 중인 상황에서 락 타임아웃이 발생했기에 계좌 잔액에 대한 갱신 손실이 일어난 케이스라고 할 수 있다. 작은 규모의 서비스에서는 거의 발생할 일이 없는 예외일지라도, 서비스의 규모가 커지고 MSA 환경에서 서비스 간의 네트워크 통신이나 외부 서비스 연동이 많아진다면 발생 가능성이 충분히 높은 케이스라고 생각한다.
위에서살펴본예외시나리오를실제코드로재현해보고, 갱신손실을막을수있는방법을살펴보자.
문제 상황 재현
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Account {
@Id
@Column(name = "account_id")
private Long id;
@Column(name = "account_no")
private String accountNo;
private Long balance;
public void withdraw(Long money)
{
if(this.balance < money)
{
throw new IllegalArgumentException("잔고가 부족합니다.");
}
this.balance -= money;
}
public Account(Long id, String accountNo, Long balance) {
this.id = id;
this.accountNo = accountNo;
this.balance = balance;
}
}
계좌에서돈을인출하는시나리오를재현하기위해우선 Account라는엔티티를구성했다.
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
public class AccountService {
private final AccountRepository accountRepository;
private static boolean static_error = true; // ---- (1)
public void withdraw(Long id, Long money) throws InterruptedException {
Account account = accountRepository.findById(id).orElseThrow();
if (static_error) { // ----- (2)
static_error = false; // ------(3)
log.info("{} 쓰레드가 중간에 네트워크 에러가 발생해서 5초간 지연 발생", Thread.currentThread().getName());
Thread.sleep(5_000L); // ------ (4)
}
account.withdraw(money);
}
}
AccountService에서는 withdraw 메서드를 통해 계좌에서 돈을 인출한다.
(1) 처음 쓰레드만 지연을 발생시키기 위해서 설정한 변수이다.
(2) 예외를 발생시키는 상황인지 판별한다.
(3) 두번째 클라이언트는 정상적으로 작동해야 하기에 false로 값을 변경했다.
(4) 첫번째클라이언트는 5초간의지연시간을가진다.
@Service
@Transactional
@RequiredArgsConstructor
public class AccountFacade {
private final RedissonClient redissonClient;
private final AccountService accountService;
public void withdraw(Long id, Long money) throws InterruptedException {
final RLock lock = redissonClient.getLock(String.valueOf(id)); // ------- (1)
try {
boolean available = lock.tryLock(10, 3, TimeUnit.SECONDS); // ------- (2)
if (!available) { // -------- (3)
throw new RuntimeException("Lock을 획득하지 못했습니다.");
}
accountService.withdraw(money); // --------- (4)
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock(); // ---------- (5)
}
}
}
분산락을 적용하기 위한 Facade 계층을 추가했다.
(1) redissonClient를 통해 Lock 객체를 가져온다.
(2) 락 획득을 시도한다. 첫번째 파라미터는 락 대기 시간, 두번째 파라미터는 락 소유 타임아웃, 세번째는 시간 단위이다.
(3) 락 획득에 실패하면 로직을 실행하지 않고 대기한다.
(4) 락 획득에 성공했다면 비즈니스 로직을 실행한다.
(5) 비즈니스 로직을 성공적으로 실행했다면 락을 해제한다.
주의깊게봐야할부분은지연이지속되는시간과 redisson에서락을획득하려시도하는과정에설정한락소유타임아웃이다. 지연지속시간은 5초로설정한반면락소유타임아웃은 3초로설정했다. 즉, 첫번째로락을획득한클라이언트는트랜잭션이커밋되기전에락타임아웃으로인해락이해제될것이다. 위의도표에서살펴본문제가실제로발생하는지테스트를통해확인해보자.
@SpringBootTest
class LockTest {
@Autowired
private AccountFacade accountFacade;
@Autowired
private AccountRepository accountRepository;
// 테스트를 시작할때마다 계좌 엔티티를 새로 등록한다.
@BeforeEach
public void before()
{
Account account = new Account(1L,"842400-4324-3445",10_000L);
accountRepository.saveAndFlush(account);
}
// 테스트가 끝날때마다 계좌 테이블을 전체 삭제한다.
@AfterEach
public void after()
{
accountRepository.deleteAll();
}
@Test
public void 한쪽_트랜잭션중_지연이_발생하여_락이_먼저_풀리는_경우() throws InterruptedException {
int threadCount = 2; // 테스트하는 쓰레드의 개수는 2개이다.
CountDownLatch latch = new CountDownLatch(threadCount);
ExecutorService executorService = Executors.newFixedThreadPool(2);
for(int i = 0; i < threadCount; i++)
{
executorService.submit(() -> {
try {
accountFacade.withdraw(1L, 3_000L); // 클라이언트 1,2는 각각 3000원씩 인출을 시도한다.
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
latch.countDown();
}
});
}
latch.await();
Account account = accountRepository.findById(1L).orElseThrow();
Assertions.assertEquals(4_000L,account.getBalance()); // 3000원씩 2번 인출했으므로 잔액이 4000원이 남을 것으로 예상한다.
}
}
public class AccountFacade {
public void withdraw_with_optimistic_locking(Long id, Long money) throws InterruptedException {
// -------- (1)
while (true) {
try {
checkDistributeRedisLockAndExecute(id, money); // -------- (2)
log.info("{} 쓰레드가 계좌에서 인출 완료", Thread.currentThread().getName());
break; // ------- (3)
} catch (Exception e) {
log.info("{} 쓰레드가 인출 후, DB의 버전을 확인했을때 불일치가 발생해서 미반영 후 재시도", Thread.currentThread().getName());
Thread.sleep(2000);
}
}
}
private void checkDistributeRedisLockAndExecute(Long id, Long money) throws InterruptedException {
final RLock lock = redissonClient.getLock(String.valueOf(id));
try {
boolean available = lock.tryLock(10, 3, TimeUnit.SECONDS);
if (!available) {
throw new RuntimeException("Lock을 획득하지 못했습니다.");
}
accountService.withdraw_with_optimistic_locking(id, money);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
}
(1) 낙관적 락은 버전 충돌이 발생할 경우 ObjectOptimisticLockingFailureException 예외를 반환한 후 로직이 종료된다. 따라서 사용자가 직접 재시도 로직을 구현해야 한다. 여기서는 예외 발생 후 2초간 휴식 뒤 while문을 통해서 재시도한다.
(2) 기존의 분산락을 구현한 메서드이다. 구조가 복잡해지는 것을 방지하고자 별도의 메서드로 추출했다.
(3) 만약 낙관적 락 예외가 발생하지 않고 트랜잭션이 정상 처리됐다면 break문을 통해 반복문에서 빠져나온다.
분산락과낙관적락을모두적용한경우를테스트해보자.
@Test
public void 한쪽_트랜잭션중_지연이_발생하여_락이_먼저_풀리지만_낙관적락으로_방어한_경우() throws InterruptedException {
int threadCount = 2;
CountDownLatch latch = new CountDownLatch(threadCount);
ExecutorService executorService = Executors.newFixedThreadPool(2);
for(int i = 0; i < threadCount; i++)
{
executorService.submit(() -> {
try {
accountFacade.withdraw_with_optimistic_locking(1L,3_000L);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
latch.countDown();
}
});
}
latch.await();
Account account = accountRepository.findById(1L).orElseThrow();
Assertions.assertEquals(4_000L,account.getBalance());
}
}