java에서 단일 서버 환경의 동시성 문제를 해결하는 synchronized와 Lock에 대해 학습하였다.
Synchronized
자바의 synchronized
키워드는 한 개의 쓰레드만 접근이 가능하도록 해준다.
기본적인 이해를 위해 가장 간단한 코드를 보면
Thread-safe하지 않은 코드`
public class Counter {
private int count = 0;
public void increment() {
count++;
}
public int getCount() {
return count;
}
}
여러 스레드가 이 코드에 접근하면 문제가 발생할 수 있다.
예를 들어 두 스레드가 동시에 count++ 를 실행하면 두 스레드 모두 같은 값을 읽고 증가시키므로 실제로는 한 번만 증가하는 경우가 생긴다.
Thread-safe한 코드
public class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
synchronized 키워드를 이용하여 count 변수의 일관성을 유지한다.
이를 통해 여러 스레드가 동시에 접근하면 메소드가 하나의 스레드에 의해 실행중이면, 다른 스레드는 대기하도록 한다.
이제 Spring 코드를 통해 synchronized
키워드를 실제 상황에서 어떻게 사용하는지 보자.
이 예시는 여러 사용자가 동시에 출금 요청을 할 때 발생할 수 있는 동시성 문제를 다루고, synchronized
키워드를 사용하여 이를 해결하는 방법을 설명한다.
1. 은행 계좌 엔티티 (BankAccount)
- 여러 스레드가 동시에 계좌에서 출금하는 경우, 실제로 출금된 금액이 예상과 다르게 나올 수 있다.
- 예를 들어, 계좌 잔액이 1000원인데 동시에 500원씩 두 번 출금 요청이 들어오면, 최종 잔액이 0원이 되어야 한다.
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.*;
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class BankAccount {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String accountNumber;
private int balance;
public BankAccount(String accountNumber, int balance) {
this.accountNumber = accountNumber;
this.balance = balance;
}
public void withdraw(int amount) {
if (this.balance < amount) {
throw new RuntimeException("잔액이 부족합니다.");
}
this.balance -= amount;
}
}
2. 은행 계좌 레포지토리 (BankAccountRepository)
- 데이터베이스에서 계좌를 읽고 업데이트하는 과정에서 여러 스레드가 동시에 접근하면 문제가 발생한다.
import org.springframework.data.jpa.repository.JpaRepository;
public interface BankAccountRepository extends JpaRepository<BankAccount, Long> {
Optional<BankAccount> findByAccountNumber(String accountNumber);
}
3. 은행 계좌 서비스 (BankAccountService)
- 여러 스레드가 동시에
withdraw
메서드를 호출하면, 동시에 잔액을 감소시키는 문제가 발생한다.- 이로 인해 Race Condition이 발생하고, 최종 잔액 값이 정확하지 않게 된다.
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class BankAccountService {
private final BankAccountRepository bankAccountRepository;
@Transactional
public void withdraw(String accountNumber, int amount) {
BankAccount bankAccount = bankAccountRepository.findByAccountNumber(accountNumber)
.orElseThrow(() -> new RuntimeException("계좌를 찾을 수 없습니다."));
bankAccount.withdraw(amount);
bankAccountRepository.saveAndFlush(bankAccount);
}
}
4. 은행 계좌 컨트롤러 (BankAccountController)
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequiredArgsConstructor
public class BankAccountController {
private final BankAccountService bankAccountService;
@PostMapping("/withdraw")
public ResponseEntity<Void> withdraw(@RequestParam String accountNumber, @RequestParam int amount) {
bankAccountService.withdraw(accountNumber, amount);
return ResponseEntity.ok().build();
}
}
5. 테스트 코드 (BankAccountServiceTest)
- 동시에 100개의 출금 요청을 보냈을 때, 잔액이 예상과 다르게 나온다.
- 여러 스레드가 동시에 접근하여 잔액을 감소시키기 때문에, 실제 잔액 값이 0이 되지 않는 문제가 발생한다.
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
class BankAccountServiceTest {
@Autowired
private BankAccountService bankAccountService;
@Autowired
private BankAccountRepository bankAccountRepository;
private String accountNumber;
@BeforeEach
void setUp() {
accountNumber = bankAccountRepository.saveAndFlush(new BankAccount("12345", 1000)).getAccountNumber();
}
@Test
void withdraw() {
// given
bankAccountService.withdraw(accountNumber, 500);
// when
BankAccount bankAccount = bankAccountRepository.findByAccountNumber(accountNumber).orElseThrow();
// then
assertThat(bankAccount.getBalance()).isEqualTo(500);
}
@Test
void withdraw_with_100_request() throws InterruptedException {
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
bankAccountService.withdraw(accountNumber, 10);
} finally {
latch.countDown();
}
});
}
latch.await();
BankAccount bankAccount = bankAccountRepository.findByAccountNumber(accountNumber).orElseThrow();
assertThat(bankAccount.getBalance()).isEqualTo(0);
}
}
withdraw() 테스트 는 단일 스레드에 대한 출금 테스트이므로, synchronized
키워드와 상관없이 성공한다.
중요하게 봐야할 것은, withdraw_with_100_request() 이라는 동시 출금 테스트이다.
위 코드에서 잔액이 1000원인 계좌에 대해 100개의 스레드가 동시에 10원을 출금 요청하고 잔액이 0원인지 확인하는데, 이는 당연히 Race Condition이 발생하므로 잔액이 0이 아닌 값이 나올 수가 있다.
따라서 withdraw
메소드에 synchronized
키워드를 붙여서 한 번에 하나의 스레드만 접근 가능하도록 하였다.
- 수정된 withdraw 메소드
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class BankAccountService {
private final BankAccountRepository bankAccountRepository;
public synchronized void withdraw(String accountNumber, int amount) {
BankAccount bankAccount = bankAccountRepository.findByAccountNumber(accountNumber)
.orElseThrow(() -> new RuntimeException("계좌를 찾을 수 없습니다."));
bankAccount.withdraw(amount);
bankAccountRepository.saveAndFlush(bankAccount);
}
}
synchronized
키워드로 인해, 다중 출금 테스트에서 100개의 출금 요청이 들어와도 각 스레드가 순차적으로 잔액을 감소시키기 때문에, Race Condition이 방지되고 테스트를 통과할 수 있다.
synchronized
키워드의 문제점
- 트랜잭션이 보장되지 않는다.
@Transactional
을 사용할 수 없기 때문에, 하나의 트랜잭션으로 처리해야할 작업이 많아지면, 문제가 발생한다.@Transactional
을 사용하는 경우 트랜잭션이 끝나기 전에 다른 스레드가 접근할 수 있어 동시성 문제가 해결되지 않을 수 있기 때문이다.
- 하나의 프로세스 내에서만 보장된다. (다중 웹 서버 환경에서 적용할 수 없다)
- 여러 서버에서 데이터를 접근하는 경우 여전히 동시성 문제가 발생한다.
Lock
동일한 상황에서 lock
을 이용한 동시성 문제 해결을 알아보자.
- BankAccount 엔티티
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class BankAccount {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String accountNumber;
private int balance;
public BankAccount(String accountNumber, int balance) {
this.accountNumber = accountNumber;
this.balance = balance;
}
public void withdraw(int amount) {
if (this.balance < amount) {
throw new RuntimeException("Insufficient balance");
}
this.balance -= amount;
}
}
- BankAccountRepository
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface BankAccountRepository extends JpaRepository<BankAccount, Long> {
Optional<BankAccount> findByAccountNumber(String accountNumber);
default BankAccount getByAccountNumber(String accountNumber) {
return findByAccountNumber(accountNumber).orElseThrow(NoSuchElementException::new);
}
}
- BankAccountService
@RequiredArgsConstructor
@Service
public class BankAccountService {
private final BankAccountRepository bankAccountRepository;
@Transactional
public void withdraw(String accountNumber, int amount) {
BankAccount bankAccount = bankAccountRepository.getByAccountNumber(accountNumber);
bankAccount.withdraw(amount);
bankAccountRepository.saveAndFlush(bankAccount);
}
}
- LockBanckAccountFacade
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@RequiredArgsConstructor
@Service
public class LockBankAccountFacade {
private final ConcurrentHashMap<String, Lock> locks = new ConcurrentHashMap<>();
private final BankAccountService bankAccountService;
public void withdraw(String accountNumber, int amount) throws InterruptedException {
Lock lock = locks.computeIfAbsent(accountNumber, key -> new ReentrantLock());
boolean acquiredLock = lock.tryLock(3, TimeUnit.SECONDS);
if (!acquiredLock) {
throw new RuntimeException("Lock acquisition failed");
}
try {
bankAccountService.withdraw(accountNumber, amount);
} finally {
lock.unlock();
}
}
}
- 코드 흐름
accountNumber
에 해당하는Lock
객체를ConcurrentHashMap
에서 가져온다. 해당Lock
객체가 없으면 새로운ReentrantLock
객체를 생성하여 맵에 추가한다.tryLock
메서드를 사용하여Lock
을 획득하려고 시도합니다. 3초 동안Lock
을 획득하지 못하면 예외를 발생시킨다.Lock
을 성공적으로 획득하면,bankAccountService.withdraw
메서드를 호출하여 출금 작업을 수행한다.- 출금 작업이 완료되면
finally
블록에서Lock
을 해제한다.
- 테스트 클래스
- synchronized 방식과 다르게 lockBankAccountFacade 을 통한 withdraw 호출이 차이점.
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
class LockBankAccountFacadeTest {
@Autowired
private LockBankAccountFacade lockBankAccountFacade;
@Autowired
private BankAccountRepository bankAccountRepository;
private String accountNumber;
@BeforeEach
void setUp() {
accountNumber = bankAccountRepository.saveAndFlush(new BankAccount("12345", 1000)).getAccountNumber();
}
@Test
void withdraw() {
// given
lockBankAccountFacade.withdraw(accountNumber, 500);
// when
BankAccount bankAccount = bankAccountRepository.findByAccountNumber(accountNumber).orElseThrow();
// then
assertThat(bankAccount.getBalance()).isEqualTo(500);
}
@Test
void withdraw_with_100_request() throws InterruptedException {
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
lockBankAccountFacade.withdraw(accountNumber, 10);
} finally {
latch.countDown();
}
});
}
latch.await();
BankAccount bankAccount = bankAccountRepository.findByAccountNumber(accountNumber).orElseThrow();
assertThat(bankAccount.getBalance()).isEqualTo(0);
}
}
lockBankAccountFacade.withdraw(accountNumber, 10); 부분에서 해당 계좌번호에 대해lockBankAccountFacade
클래스의 tryLock
메소드를 실행하게 된다.
따라서 Lock을 소유하게 되어 withdraw가 실행되거나, 만약 다른 메소드가 Lock을 차지중이라면 예외를 발생시키게 되고, 이를 통해 동시성 문제 해결이 가능하다.
하지만 이 Lock을 이용한 방식 역시 다중 서버 환경에서 제대로 작동하지 않는다.
이 문제를 해결하기 위해서 여러 방법들을 통해 분산 락(Distributed Lock) 을 구현하여 이용한다.