동시성 문제를 해결하는 4가지 방법
안녕하세요, 오늘은 멀티스레드 환경에서 발생하는 동시성 문제를 해결하는 네 가지 방법에 대해 이야기해보려고 합니다. 동시성 문제는 여러 스레드가 동시에 같은 자원에 접근하면서 데이터 무결성이 깨질 수 있는 상황을 말하는데요. 이를 해결하기 위해 자바와 Spring Boot에서 사용할 수 있는 다양한 접근법을 예시와 함께 알아보겠습니다.
1. 자바 synchronized 키워드
가장 기본적인 동시성 제어 방법 중 하나는 자바의 synchronized 키워드를 사용하는 것입니다. 이 방식은 특정 코드 블록이나 메서드에 대해 한 번에 하나의 스레드만 접근할 수 있도록 잠금을 걸어줍니다.
언제 사용하나요?
- 간단한 동기화가 필요한 경우
- 객체 단위로 락을 걸어 데이터 무결성을 지키고 싶을 때
Spring Boot 예시
재고 관리 시스템에서 동시에 재고를 감소시키는 상황을 생각해봅시다.
@Service
public class StockService {
private int stock = 100;
public synchronized void decreaseStock(int quantity) {
if (stock >= quantity) {
stock -= quantity;
System.out.println("재고 감소! 남은 재고: " + stock);
} else {
throw new RuntimeException("재고 부족!");
}
}
}
위 코드에서 synchronized를 사용하면 여러 스레드가 동시에 decreaseStock 메서드에 접근하더라도 순차적으로 실행됩니다. 단점은 성능이 중요한 대규모 시스템에서는 병목 현상이 발생할 수 있다는 점입니다.
2. 낙관적 락 (Optimistic Lock)
낙관적 락은 "충돌이 별로 없을 거야!"라는 낙관적인 가정을 기반으로 동시성을 제어합니다. 데이터를 수정할 때 충돌 여부를 버전 번호나 타임스탬프를 통해 체크하고, 충돌이 발생하면 예외를 던져서 처리합니다.
언제 사용하나요?
- 충돌 가능성이 낮은 환경
- 성능 최적화가 중요한 경우
Spring Boot 예시
JPA를 사용해 낙관적 락을 구현해보겠습니다. @Version 어노테이션을 활용하면 됩니다.
@Entity
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private int stock;
@Version
private int version; // 버전 필드 추가
}
@Service
public class ProductService {
@Autowired
private ProductRepository productRepository;
@Transactional
public void decreaseStock(Long productId, int quantity) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new RuntimeException("상품 없음"));
if (product.getStock() >= quantity) {
product.setStock(product.getStock() - quantity);
productRepository.save(product);
} else {
throw new RuntimeException("재고 부족!");
}
}
}
위 코드에서 트랜잭션이 끝날 때 JPA가 버전을 체크합니다. 다른 스레드가 먼저 수정해서 버전이 달라지면 OptimisticLockException이 발생하고, 이를 통해 충돌을 감지합니다. 성능이 비관적 락보다 좋지만, 충돌이 자주 발생하면 롤백 처리가 늘어날 수 있어요.
3. 비관적 락 (Pessimistic Lock)
비관적 락은 "충돌이 자주 발생할 거야!"라는 비관적인 가정을 하고, 아예 트랜잭션 시작 시점에 데이터베이스에 락을 겁니다. 다른 스레드는 락이 해제될 때까지 기다려야 합니다.
언제 사용하나요?
- 충돌 가능성이 높은 환경
- 데이터 무결성이 매우 중요한 경우 (예: 금융 시스템)
Spring Boot 예시
JPA에서 @Lock 어노테이션을 사용해 비관적 락을 설정할 수 있습니다.
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Product p WHERE p.id = :id")
Optional<Product> findByIdWithPessimisticLock(@Param("id") Long id);
}
@Service
public class ProductService {
@Autowired
private ProductRepository productRepository;
@Transactional
public void decreaseStock(Long productId, int quantity) {
Product product = productRepository.findByIdWithPessimisticLock(productId)
.orElseThrow(() -> new RuntimeException("상품 없음"));
if (product.getStock() >= quantity) {
product.setStock(product.getStock() - quantity);
productRepository.save(product);
} else {
throw new RuntimeException("재고 부족!");
}
}
}
PESSIMISTIC_WRITE 락을 걸면 해당 레코드는 트랜잭션이 끝날 때까지 다른 스레드가 수정할 수 없습니다. 성능은 낙관적 락보다 떨어지지만, 중요한 데이터의 정확성을 보장할 수 있어요.
4. 캐시 서버 도입
마지막으로, 캐시 서버(예: Redis)를 활용하는 방법입니다. 이건 돈이 좀 들 수 있는 솔루션이지만, 동시성 문제를 근본적으로 줄이고 성능을 크게 향상시킬 수 있습니다. 캐시는 자주 조회되는 데이터를 메모리에 저장해 DB 부하를 줄여줍니다.
언제 사용하나요?
- 읽기 요청이 많은 시스템
- DB 부하를 줄이고 응답 속도를 높이고 싶을 때
Spring Boot 예시
Redis를 사용해 재고를 캐싱하고 동시성을 제어해보겠습니다.
@Service
public class StockService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
private static final String STOCK_KEY = "stock:product:1";
public void decreaseStock(int quantity) {
String stockStr = redisTemplate.opsForValue().get(STOCK_KEY);
int stock = stockStr != null ? Integer.parseInt(stockStr) : 100;
if (stock >= quantity) {
redisTemplate.opsForValue().set(STOCK_KEY, String.valueOf(stock - quantity));
System.out.println("재고 감소! 남은 재고: " + (stock - quantity));
} else {
throw new RuntimeException("재고 부족!");
}
}
}
여기서는 단순히 Redis에 재고를 저장했지만, 실제로는 Redis의 SETNX(Set if Not Exists) 같은 명령어나 분산 락(Distributed Lock)을 활용해 동시성을 더 철저히 제어할 수 있습니다. 비용이 들지만 확장성과 성능 면에서 큰 이점이 있어요.
정리: 어떤 방법을 선택할까?
- synchronized: 간단하고 비용 없음, 소규모 시스템에 적합
- 낙관적 락: 충돌이 적을 때 성능 좋음, 무료
- 비관적 락: 충돌이 많고 데이터 무결성이 중요할 때, 무료지만 성능 저하 주의
- 캐시 서버: 돈은 들지만 성능과 확장성 극대화
프로젝트의 요구사항과 예산에 따라 적절한 방법을 선택하면 됩니다. 예를 들어, 간단한 앱이라면 synchronized나 낙관적 락으로 충분할 수 있고, 대규모 트래픽을 감당해야 한다면 Redis 같은 캐시 서버를 고려해볼 만해요.
이 글이 동시성 문제 해결에 도움이 되셨길 바랍니다.
'개발 부트캠프 > 백엔드' 카테고리의 다른 글
[Kafka] Kafka 이용하여 Spring Boot로 메시지 보내고 받기 (0) | 2025.02.27 |
---|---|
[MSA] OpenFeign (0) | 2025.02.27 |
[Trace] Jaeger (0) | 2025.02.21 |
[Log] Log 중앙화 (0) | 2025.02.21 |
[Trace] 핀포인트(Pinpoint) (0) | 2025.02.21 |