π€ μλ‘
μ΄μ κΈμμ μμλ³Έ synchronizedλ λ¨μΌ μλ² νκ²½μμλ§ λμμ± λ¬Έμ λ₯Ό ν΄κ²°ν μ μμμ΅λλ€.
μ΄λ² κΈμμλ λ€μ€ μλ² νκ²½μμ λμμ± λ¬Έμ λ₯Ό ν΄κ²°ν μ μλ λ°©λ²μ λν΄ μμ보λλ‘ νκ² μ΅λλ€.
μ¬λ¬ λμ μλ²μμ λμΌ μμμ μ κ·Όνλ κ²½μ°, λμμ ν κ°μ νλ‘μΈμ€(νΉμ μ°λ λ)λ§ μ κ·Ό κ°λ₯νλλ‘ νκΈ° μν΄ μ¬μ©νλ Lockμ λΆμ° λ½(Distributed Lock)μ΄λΌ λΆλ¦ λλ€.
μ΄μ λΆν° λΆμ° λ½μ ꡬννλ μ¬λ¬ λ°©λ²λ€μ λν΄ μμ보λλ‘ νκ² μ΅λλ€.
π‘ λΆμ° λ½μ λν΄μ
λΆμ° λ½μ λν΄μ μ΄λ λ€ ν μμ ν μ μλ₯Ό μ°Ύμ§λ λͺ»ν΄μ λ€μ λ μν©μ΄ νΌλλ μ μμ κ² κ°μ΅λλ€.
1. μΉ μ ν리μΌμ΄μ μλ²κ° μ¬λ¬λμΈ κ²½μ°, μ΄λ€κ°μ λμμ± λ¬Έμ λ₯Ό ν΄κ²°νκΈ° μν΄ μ¬μ©λλ Lock
2. μ€μΌμΌ μμλ DB νκ²½μμ λμμ± λ¬Έμ λ₯Ό ν΄κ²°νκΈ° μν΄ μ¬μ©λλ Lock
μ΄λ°μ λ° μλ£λ€μ μ°Ύμλ³Έ κ²°κ³Ό, 1, 2λ² λͺ¨λλ₯Ό λΆμ°λ½μ 보λ κ²½μ°λ μμκ³ , 2λ²λ§ λΆμ°λ½μΌλ‘ 보λ κ²½μ°λ μμμ΅λλ€.
μμΌλ‘μ κΈμμλ 1, 2 λ² λͺ¨λλ₯Ό λΆμ°λ½μΌλ‘ μκ°νκ³ μ€λͺ μ μ΄μ΄κ°λλ‘ νκ² μ΅λλ€.
μ΄λ₯Ό μΈκΈνκ² λ κ³κΈ°λ λ€μκ³Ό κ°μ΅λλ€.
1λ²μ κ²½μ° μ¬λ¬ λΆμ° μλ²μμ 1κ°μ DBλ₯Ό μ¬μ©νλ€λ©΄ λκ΄μ λ½ νΉμ λΉκ΄μ λ½ λ§μΌλλ‘ λμμ± λ¬Έμ λ₯Ό μ²λ¦¬ν μ μμ΅λλ€.
μ΄λ 1λ²μ κ°λ μ λΆμ°λ½μΌλ‘ λ³Έλ€λ©΄ λκ΄μ λ½κ³Ό λΉκ΄μ λ½μ ν΅ν΄μ λΆμ°λ½μ ꡬνν μ μμ§λ§, 2λ² μν©μ ν΄κ²°νμ§ λͺ»νλ μν©μ΄ λ°μν©λλ€.
μ΄λ‘ μΈν΄ 1λ²μ λΆμ°λ½μ΄ μλκ° νλ μκ°μ΄ λ€μλλ°, μ°Ύμ보λ λΆμ°λ½μ λν λλΆλΆμ μ€λͺ μμ 1λ² μν©μ κ°μ νκ³ μμμ΅λλ€. κ·Έλμ μ΅μ’ μ μΌλ‘ νΌλμ μ€μ΄κΈ° μν΄, μ λ μν© λͺ¨λ λΆμ°λ½μ ν΄λΉνλ€κ³ κ°μ νκ² μ΅λλ€.
π€ λΆμ°λ½μ ꡬννλ μ¬λ¬ λ°©λ²
- λΉκ΄μ λ½ (Pessimistic Lock)
- λκ΄μ λ½ (Optimistic Lock)
- USER-LEVEL Lock(Named Lock) (MySQL νμ )
- Redis
- Zookeeper
- λ±λ±..
μ΄μ€μμ μ΄λ² κΈμμλ λκ΄μ λ½κ³Ό λΉκ΄μ λ½μ λν΄μ μμλ³Έ λ€, λλ¨Έμ§λ λ€μ κΈμμ μ΄μ΄μ μμ보λλ‘ νκ² μ΅λλ€.
π³ λΉκ΄μ λ½(Pessimistic Lock)
λΉκ΄μ λ½μ DBμ X-Lock νΉμ S-Lockμ μ΄μ©νμ¬ λ°μ΄ν°μ μ ν©μ±μ λ§μΆλ λ°©λ²μ λλ€.
λΉκ΄μ λ½μ μ¬μ©νκ² λλ©΄ λμμ± λ¬Έμ λ₯Ό λ°©μ§ν μ μμΌλ, λ°λλ½μ΄ 걸릴 μ μκΈ° λλ¬Έμ μ£Όμν΄μ μ¬μ©ν΄μΌ ν©λλ€.
π³ λκ΄μ λ½(Optimistic Lock)
λκ΄μ λ½μ μ€μ λ‘ Lockμ μ¬μ©νμ§ μκ³ , λ²μ μ ν΅ν΄ λ°μ΄ν°μ μ ν©μ±μ λ§μΆλ λ°©λ²μ λλ€.
λ¨Όμ λ°μ΄ν°λ₯Ό μ½μ νμ updateλ₯Ό μνν λ λ΄κ° νμ¬ μ½μ λ²μ μ΄ λ³κ²½λμ§ μμλμ§ νμΈνλ©° μ λ°μ΄νΈλ₯Ό μ§νν©λλ€.
λ΄κ° μ½μ λ²μ μμ μμ μ¬νμ΄ μκ²Όμ κ²½μ°μλ applicationμμ λ€μ μ½μνμ μμ μ μνν΄μΌ ν©λλ€.
λλ¨Έμ§μ λν΄μλ λ€μ κΈμμ μ΄μ΄μ μμ보λλ‘ νκ² μ΅λλ€.
π€ λΉκ΄μ λ½μ ν΅ν λμμ± λ¬Έμ ν΄κ²°
StockRepositoryμ λ€μκ³Ό κ°μ λ©μλλ₯Ό μΆκ°ν©λλ€.
public interface StockRepository extends JpaRepository<Stock, Long> {
default Stock getById(Long id) {
return findById(id).orElseThrow(NoSuchElementException::new);
}
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithPessimisticLock(Long id);
}
μ΄λ₯Ό μ¬μ©νλ μλΉμ€ μ½λλ₯Ό μμ±ν©λλ€.
@RequiredArgsConstructor
@Service
public class PessimisticLockStockService {
private final StockRepository stockRepository;
@Transactional
public void decrease(Long id) {
Stock stock = stockRepository.findByIdWithPessimisticLock(id);
stock.decrease();
stockRepository.saveAndFlush(stock);
}
}
μ΄μ ν μ€νΈ μ½λλ₯Ό μμ±ν΄ λ³΄κ² μ΅λλ€.
@SpringBootTest
class PessimisticLockStockServiceTest {
@Autowired
private PessimisticLockStockService pessimisticLockStockService;
@Autowired
private StockRepository stockRepository;
private Long stockId;
@BeforeEach
void setUp() {
stockId = stockRepository.saveAndFlush(new Stock(1L, 100))
.getId();
}
@AfterEach
void tearDown() {
stockRepository.deleteAll();
}
@Test
void decrease_with_100_request() throws InterruptedException {
// given
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
// when
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
pessimisticLockStockService.decrease(stockId);
} finally {
latch.countDown();
}
});
}
latch.await();
// then
Stock stock = stockRepository.getById(stockId);
assertThat(stock.getQuantity()).isEqualTo(0);
}
}
μμ κ°μ΄ μ λμνλ κ²μ λ³Ό μ μμ΅λλ€.
findByIdWithPessimisticLock νΈμΆ μ λ°μνλ 쿼리λ₯Ό μ΄ν΄λ³΄λ©΄ λ€μκ³Ό κ°μ΅λλ€.
select for updateλ₯Ό ν΅ν΄μ X-lockμ νλνλ κ²μ μ μ μμ΅λλ€.
π³ λ€μ€ μλ² νκ²½μμ ν μ€νΈ
μ΄μ κΈμμ μ§νν κ²κ³Ό κ°μ΄, λ€μ€ μλ²μμμ λμμ νμΈν΄ 보λλ‘ νκ² μ΅λλ€.
컨νΈλ‘€λ¬ μ½λλ₯Ό λ€μκ³Ό κ°μ΄ λ³κ²½ν©λλ€.
@RequiredArgsConstructor
@RestController
public class StockController {
private final PessimisticLockStockService stockService;
private final StockRepository stockRepository;
@GetMapping("/stocks")
public void createStock() {
stockRepository.save(new Stock(1L, 100));
}
@GetMapping("/stocks/{id}/decrease")
public void decreaseStock(
@PathVariable Long id
) {
stockService.decrease(id);
}
}
μ΄ν http://localhost:8080/stocksλ‘ get μμ²μ 보λ λλ€.
μμ κ°μ΄ μνμ΄ μ μμ±λμλ€λ©΄, 100κ°μ μμ²μ λμμ 보λ΄λ³΄λλ‘ νκ² μ΅λλ€.
μ΄μ κΈκ³Ό μ½λλ λμΌνλ©°, μλμ κ°μ΅λλ€.
class MultiServerStockServiceTest {
@Test
void decrease_with_100_request_to_multi_server() throws InterruptedException {
// given
int threadCount = 100;
RestTemplate restTemplate = new RestTemplate();
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
// when
for (int i = 0; i < threadCount; i++) {
final int ii = i;
executorService.submit(() -> {
try {
int port = (ii % 2 == 0) ? 8080 : 8081;
ResponseEntity<Void> forEntity = restTemplate.getForEntity(
"http://localhost:" + port + "/stocks/1/decrease",
Void.class);
} finally {
latch.countDown();
}
});
}
latch.await();
}
}
λμμ± λ¬Έμ μμ΄ μ νν 100κ°μ μ¬κ³ κ° μ€μ΄λ κ²μ νμΈν μ μμ΅λλ€.
π€ λκ΄μ λ½μ ν΅ν λμμ± λ¬Έμ ν΄κ²°
λκ΄μ λ½μ μ€μ Lockμ μ΄μ©νλ κ²μ΄ μλ λ²μ μ μ΄μ©νλ κ²μ΄λ―λ‘, λ²μ μ λν νλκ° μΆκ°λμ΄μΌ ν©λλ€.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Stock {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long productId;
private int quantity;
@Version
private Long version;
public Stock(Long productId, int quantity) {
this.productId = productId;
this.quantity = quantity;
}
public void decrease() {
if (this.quantity == 0) {
throw new RuntimeException("μ¬κ³ λ 0κ° λ―Έλ§μ΄ λ μ μμ΅λλ€.");
}
this.quantity--;
}
}
(jakarta.persistence ν¨ν€μ§μ Versionμ μ¬μ©ν΄μΌ ν©λλ€.)
StockRepositoryμ λ€μκ³Ό κ°μ λ©μλλ₯Ό μΆκ°ν©λλ€.
public interface StockRepository extends JpaRepository<Stock, Long> {
default Stock getById(Long id) {
return findById(id).orElseThrow(NoSuchElementException::new);
}
@Lock(LockModeType.OPTIMISTIC)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithOptimisticLock(Long id);
}
μ΄λ₯Ό μ¬μ©νλ μλΉμ€ μ½λλ₯Ό μμ±ν©λλ€.
@RequiredArgsConstructor
@Service
public class OptimisticLockStockService {
private final StockRepository stockRepository;
@Transactional
public void decrease(Long id) {
Stock stock = stockRepository.findByIdWithOptimisticLock(id);
stock.decrease();
stockRepository.saveAndFlush(stock);
}
}
λκ΄μ λ½μ μ€ν¨ μ μ¬μλλ₯Ό ν΄μ£Όμ΄μΌ ν©λλ€.
μ΄λ₯Ό μ²λ¦¬νλ OptimisticLockStockFacadeλ₯Ό μμ±ν΄ μ£Όλλ‘ νκ² μ΅λλ€.
@RequiredArgsConstructor
@Service
public class OptimisticLockStockFacade {
private final OptimisticLockStockService optimisticLockStockService;
public void decrease(Long id) {
while (true) {
try {
optimisticLockStockService.decrease(id);
break;
} catch (Exception e) {
try {
Thread.sleep(50);
} catch (InterruptedException ex) {
throw new RuntimeException(ex);
}
}
}
}
}
μ΄μ ν μ€νΈ μ½λλ₯Ό μμ±ν΄ λ³΄κ² μ΅λλ€.
@SpringBootTest
class OptimisticLockStockFacadeTest {
@Autowired
private OptimisticLockStockFacade optimisticLockStockFacade;
@Autowired
private StockRepository stockRepository;
private Long stockId;
@BeforeEach
void setUp() {
stockId = stockRepository.saveAndFlush(new Stock(1L, 100))
.getId();
}
@AfterEach
void tearDown() {
stockRepository.deleteAll();
}
@Test
void decrease_with_100_request() throws InterruptedException {
// given
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
// when
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
optimisticLockStockFacade.decrease(stockId);
} finally {
latch.countDown();
}
});
}
latch.await();
// then
Stock stock = stockRepository.getById(stockId);
assertThat(stock.getQuantity()).isEqualTo(0);
}
}
μμ κ°μ΄ μ λμνλ κ²μ λ³Ό μ μμ΅λλ€.
π³ λ€μ€ μλ² νκ²½μμ ν μ€νΈ
컨νΈλ‘€λ¬ μ½λλ₯Ό λ€μκ³Ό κ°μ΄ λ³κ²½ν©λλ€.
@RequiredArgsConstructor
@RestController
public class StockController {
private final OptimisticLockStockFacade stockService;
private final StockRepository stockRepository;
@GetMapping("/stocks")
public void createStock() {
stockRepository.save(new Stock(1L, 100));
}
@GetMapping("/stocks/{id}/decrease")
public void decreaseStock(
@PathVariable Long id
) {
stockService.decrease(id);
}
}
λ§μ°¬κ°μ§λ‘ 2κ°μ μλ²λ₯Ό λμ΄ ν, λμμ 100κ°μ μμ²μ 보λ΄λ©΄ κ²°κ³Όλ λ€μκ³Ό κ°μ΅λλ€.
μλμ μ νν 100κ°κ° μ€μμΌλ©°, μλμ΄ μ λ°μ΄νΈ λ νμλ§νΌ version 컬λΌμ κ°μ΄ μ¦κ°λ κ²μ νμΈν μ μμ΅λλ€.
π€ λκ΄μ λ½κ³Ό λΉκ΄μ λ½ μ¬μ© μ λ¬Έμ
λκ΄μ λ½ νΉμ λΉκ΄μ λ½μ μ¬μ©νλ κ²½μ°, λ¨μΌ μΉ μλ² νκ²½ λΏλ§ μλλΌ λ€μ€ μΉ μλ² νκ²½μμλ λμμ± λ¬Έμ λ₯Ό λ°©μ§ν μ μμμ΅λλ€.
κ·Έλ¬λ μ€μΌμΌμμλ DB νκ²½μμλ μ΄λ€μ ν΅ν΄μ λμμ± μ μ΄λ₯Ό ν μ μλ€λ λ¬Έμ μ μ΄ μμ΅λλ€.
DBκ° μ€μΌμΌμμ λ μν©μ΄λΌλ©΄ Redis νΉμ ZooKeeperλ±μ ν΅ν΄ λΆμ°λ½μ ꡬνν΄μΌ ν©λλ€.
μ΄λ€μ μ¬μ©νλ λ°©λ²μ μμ보기 μ μ, λ€μ κΈμμλ MySQLμμ μ¬μ©ν μ μλ Named Lockμ ν΅ν΄ λΆμ°λ½μ ꡬννλ λ°©λ²μ λν΄ μμ보λλ‘ νκ² μ΅λλ€.
π‘ Named Lock μμλ μ€μΌμΌμμλ DB νκ²½μμλ λμμ± μ μ΄λ₯Ό ν μ μμ΅λλ€.
π€ λ§λ¬΄λ¦¬
μ΄λ² κΈμμλ λ°μ΄ν°λ² μ΄μ€μ λκ΄μ λ½κ³Ό λΉκ΄μ λ½μ μ¬μ©νμ¬ λμμ± λ¬Έμ λ₯Ό ν΄κ²°νλ λ°©λ²μ λν΄ μμ보μμ΅λλ€.
μ λ λ°©λ²μ μ¬μ©νλ©΄ λ¨μΌ μλ² λΏλ§ μλλΌ λ€μ€ μλ² νκ²½μμλ λμμ± λ¬Έμ λ₯Ό λ°©μ§ν μ μλ€λ κ²μ νμΈνμ΅λλ€.
λ€μ κΈμμλ MySQLμμ μ 곡νλ USER-LEVEL Lock(Named Lock)μ ν΅ν λΆμ°λ½μ ꡬνλ°©λ²μ λν΄ μμ보λλ‘ νκ² μ΅λλ€.
π Reference
[μ¬κ³ μμ€ν μΌλ‘ μμ보λ λμμ±μ΄μ ν΄κ²°λ°©λ²]
https://channel.io/ko/blog/distributedlock_2022_backend