백고등어 개발 블로그
동시성 이슈 원인 및 해결 본문
오늘은 많은 개발자분들이 한 번쯤은 겪는 동시성 이슈에 대해 이야기해볼까 합니다.
"어? 갑자기 재고 수량이 맞지 않네?"라는 상황, 한 번쯤 들어보셨죠?
이 글에서는 동시성 이슈가 왜 발생하는지, 그리고 이를 어떻게 해결할 수 있는지, 현실적인 비유와 함께 스프링 부트 예제 코드를 곁들여 쉽게 풀어보겠습니다!
동시성 이슈란?
먼저, 동시성 이슈란 여러 사용자가 동시에 같은 데이터를 읽고 수정하면서 발생하는 문제를 말합니다.
현실 속 비유
친구와 피자가게에서 전화로 주문을 하고 있다고 가정해보겠습니다!
- 친구 A가 전화로 피자를 주문하며 남은 재고가 몇 개인지 물어봅니다. 가게 직원은 "5개 남았어요!"라고 대답합니다.
- 친구 B도 동시에 전화를 걸어 "재고 몇 개 남았나요?"라고 묻습니다. 직원은 "5개 남았어요!"라고 대답합니다.
- 친구 A와 B가 동시에 "한 판 주세요!"라고 말합니다.
- 직원은 각각 A와 B의 주문을 받고, 재고를 확인하고 처리합니다.
- A의 주문을 처리하면서 재고를 4로 업데이트합니다.
- 그러나 동시에 B의 주문도 처리하며 다시 재고를 4로 업데이트합니다.
그 결과? 재고는 두 번 줄어야 하지만, 잘못된 처리로 인해 4가 유지됩니다. 바로 이러한 상황에 대해서 동시성 이슈가 발생했다고 말할 수 있습니다!
왜 발생할까?
동시성 이슈는 주로 다음과 같은 상황에서 발생합니다!
- 공유 자원 접근: 여러 사용자가 같은 데이터를 동시에 읽고 쓰는 경우.
- 비원자적 연산: 데이터 읽기, 수정, 쓰기 연산이 하나의 연산으로 처리되지 않을 때.
스프링 애플리케이션에서도 이런 문제가 종종 발생하죠. 특히, 재고 관리 시스템처럼 데이터가 자주 업데이트되는 시스템을 예시로 들 수 있을 것 같습니다!
동시성 이슈 예시
여기 간단한 재고 관리 API가 있습니다.
고객이 재고를 하나씩 줄이는 요청을 보낼 때 동시성 이슈가 발생합니다!
StockController.java
@RestController
@RequestMapping("/api/stock")
public class StockController {
@Autowired
private StockService stockService;
@PostMapping("/decrease/{id}")
public ResponseEntity<String> decreaseStock(@PathVariable Long id) {
stockService.decreaseStock(id);
return ResponseEntity.ok("Stock decreased successfully!");
}
}
StockService.java
@Service
public class StockService {
@Autowired
private StockRepository stockRepository;
@Transactional
public void decreaseStock(Long id) {
Stock stock = stockRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Stock not found"));
if (stock.getQuantity() > 0) {
stock.setQuantity(stock.getQuantity() - 1);
stockRepository.save(stock);
} else {
throw new RuntimeException("Stock is empty");
}
}
}
Stock.java
@Entity
public class Stock {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private int quantity;
// Getters and setters
}
문제 발생
- 재고가 5인 상태에서 두 사용자가 동시에 API를 호출하면, 두 요청이 모두 재고를 5로 읽고, 각각 4로 저장하려고 합니다.
- 결과적으로 재고는 하나만 줄어들어 4가 됩니다. 동시성 이슈가 발생한 거죠!
동시성 이슈 해결 방법
이제 해결 방법을 살펴보겠습니다!
다양한 방법이 있지만, 대표적인 세 가지를 소개합니다!
1. Pessimistic Lock (비관적 락)
비관적 락은 "한 사람이 데이터를 수정하는 동안 다른 사람이 해당 데이터에 접근할 수 없게 잠가버릴 거야!"라고 생각하는 방식입니다.
은행 ATM을 사용하는 상황을 생각해보세요.
한 사람이 ATM을 사용하는 동안 다른 사람이 사용할 수 없게 잠그는 방식을 예로 들 수 있습니다.
아래의 예시를 통해 비관적 락을 구현하는 두 가지 방식에 대해 소개해드리겠습니다!
1.1 SELECT ... FOR UPDATE 쿼리
순수 SQL에선 SELECT ... FOR UPDATE 쿼리를 사용해 비관적 락을 구현할 수 있습니다.
SELECT ... FOR UPDATE 쿼리는 데이터베이스가 해당 데이터에 대해 락을 걸도록 지시합니다.
이 락은 다른 트랜잭션이 동일한 데이터를 읽거나 수정하지 못하도록 방지합니다.
예를 들어, 하나의 트랜잭션이 SELECT ... FOR UPDATE를 실행하면, 해당 트랜잭션이 완료되어 락이 해제될 때까지 다른 트랜잭션은 해당 데이터에 접근하려고 대기하게 됩니다.
이를 통해 데이터 충돌을 방지할 수 있습니다.
아래는 SELECT ... FOR UPDATE 쿼리를 통해 비관적 락을 구현하는 예시입니다.
-- 트랜잭션 시작
START TRANSACTION;
-- 특정 행에 대해 쓰기 락을 설정
SELECT quantity
FROM stock
WHERE id = 1
FOR UPDATE;
-- 재고 감소 처리
UPDATE stock
SET quantity = quantity - 1
WHERE id = 1;
-- 트랜잭션 커밋
COMMIT;
설명
- START TRANSACTION: 트랜잭션을 명시적으로 시작합니다.
- SELECT ... FOR UPDATE: ID가 1인 행에 쓰기 락을 설정하여 다른 트랜잭션이 수정하거나 읽지 못하도록 합니다.
- UPDATE 쿼리: 재고 수량을 감소시킵니다.
- COMMIT: 트랜잭션을 커밋하여 락을 해제하고 작업을 확정합니다.
이 방식은 순수 SQL 쿼리만으로도 데이터베이스 레벨에서 비관적 락을 구현하는 방법을 보여줍니다.
1.2 스프링 부트 @Lock 어노테이션
@Lock 어노테이션
스프링 데이터 JPA에서는 @Lock 어노테이션을 사용하여 비관적 락을 구현할 수 있습니다. 예를 들어, @Lock(LockModeType.PESSIMISTIC_WRITE)를 설정하면 특정 데이터를 수정하려는 다른 트랜잭션을 차단하고, 현재 트랜잭션이 완료될 때까지 기다리게 만듭니다.
코드 구현
@Repository
public interface StockRepository extends JpaRepository<Stock, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT s FROM Stock s WHERE s.id = :id")
Stock findByIdWithLock(@Param("id") Long id);
}
@Service
public class StockService {
@Transactional
public void decreaseStock(Long id) {
Stock stock = stockRepository.findByIdWithLock(id);
if (stock.getQuantity() > 0) {
stock.setQuantity(stock.getQuantity() - 1);
} else {
throw new RuntimeException("Stock is empty");
}
}
}
장단점
- 장점: 동시성 문제를 확실히 막을 수 있습니다.
- 단점: 데이터베이스 락으로 인해 성능이 저하될 수 있습니다.
2. Optimistic Lock (낙관적 락)
낙관적 락은 "충돌이 잘 안 나겠지?"라고 낙관적으로 생각하는 방식입니다.
데이터를 수정할 때 버전 정보를 활용해 충돌 여부를 확인합니다.
문서 편집 툴을 예로 들 수 있을 것 같습니다.
A와 B가 동시에 문서를 수정하더라도, 저장할 때 "누군가 이미 이 문서를 수정했어요!"라고 알려주는 것처럼 말입니다.뭔가 개인적으로 낙관적... 이라기보단 감시라는 느낌이 더 드네요...
코드 구현
@Entity
public class Stock {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private int quantity;
@Version
private Long version; // 버전 필드 추가
}
@Service
public class StockService {
@Transactional
public void decreaseStock(Long id) {
Stock stock = stockRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Stock not found"));
if (stock.getQuantity() > 0) {
stock.setQuantity(stock.getQuantity() - 1);
} else {
throw new RuntimeException("Stock is empty");
}
}
}
장단점
- 장점: 성능이 좋습니다. 락을 걸지 않기 때문에 병렬 처리에 유리합니다.
- 단점: 충돌이 자주 발생하면 성능이 저하될 수 있습니다.
3. Redis를 활용한 분산 락
분산 환경에서는 Redis와 같은 외부 도구를 사용해 락을 걸 수 있습니다.
예를 들어, Redisson 라이브러리를 사용할 수 있어요.
예를 들어, 여러 지점에서 운영하는 대여 시스템을 생각해보세요. 중앙 서버(Redis)를 통해 대여 가능 여부를 확인합니다.
코드 구현 (Redisson 사용)
@Service
public class StockService {
@Autowired
private RedissonClient redissonClient;
public void decreaseStock(Long id) {
RLock lock = redissonClient.getLock("stockLock:" + id);
try {
if (lock.tryLock(10, 1, TimeUnit.SECONDS)) {
// 재고 감소 로직
}
} catch (InterruptedException e) {
throw new RuntimeException("Failed to acquire lock");
} finally {
lock.unlock();
}
}
}
장단점
- 장점
- 분산 환경 지원: 여러 서버에서 동시에 락을 걸 수 있어 확장성이 뛰어납니다.
- 효율성: Redis의 높은 성능 덕분에 락 처리 속도가 빠릅니다.
- TTL 지원: 락이 과도하게 유지되는 것을 방지하기 위해 TTL(Time-to-Live)을 설정할 수 있습니다.
- 단점
- 복잡성 증가: Redis와 같은 외부 시스템에 의존해야 하며, 설정과 관리가 추가됩니다.
- 잠재적 실패 시 처리 필요: 네트워크 장애나 Redis 노드 장애 시 락이 예상대로 동작하지 않을 수 있어 별도의 장애 처리 로직이 필요합니다.
- 정확한 구현 요구: 락 해제 실패, 중복 락 등 문제를 방지하려면 구현이 정확해야 합니다.
결론
동시성 이슈는 데이터 일관성을 보장하기 위해 꼭 해결해야 할 문제입니다.
어떤 방법을 사용할지는 시스템의 요구사항과 성능 조건에 따라 달라집니다.
- Pessimistic Lock: 안정성 우선.
- Optimistic Lock: 성능 우선.
- Redis 분산 락: 분산 환경에서 사용.
제 글이 도움이 되셨으면 좋겠습니다!
감사합니다!
'기타 개발 지식' 카테고리의 다른 글
웹서버와 웹 어플리케이션 서버 (WAS) (0) | 2021.12.27 |
---|---|
7 Standard Actions (0) | 2021.02.03 |