신비한 개발사전
Spring에서의 동시성 문제 해결 방법 본문
문제 의식
데이터 수정 작업을 수행할 때, 한 트랜잭션 단위 안에서 작업이 이루어지도록 메서드에 @Transactional 애노테이션을 붙인다. 중간에 문제가 발생한 경우 아예 작업 자체가 실행되지 않았던 것처럼 롤백을 해야 하기 때문이다.
여기에서 추가로 고려해볼 점은, @Transactional을 사용한다고 해서 동시성을 지킬 수 있는 것은 아니라는 것이다.
한 DB 칼럼의 값을 변경하려고 할 때, 엔티티를 조회해온 후 내부 값을 변경하고 다시 영속화하는 흐름을 따른다고 가정하자.
@Service
public class ItemService {
private final ItemRepository itemRepository;
public ItemService(ItemRepository itemRepository) {
this.itemRepository = itemRepository;
}
// 동시성이 보장되지 않음
@Transactional
public void decrease(Long id, Long quantity) {
Item item = itemRepository.findById(id).orElseThrow();
item.decrease(quantity);
itemRepository.saveAndFlush(item);
}
}
여러 스레드에서 예시 코드의 decrease 메서드를 동시에 호출하면 race condition이 발생하게 된다. Race condition을 방지하기 위해서는 스레드들이 순서를 지키면서 공유자원에 접근하도록 해야 한다.
synchronized로 해결
자바의 synchronized 키워드를 사용하면 해당 키워드가 붙은 메서드는 한번에 한 스레드만 사용할 수 있게 된다.
public synchronized void decrease(Long id, Long quantity) {
Item item = itemRepository.findById(id).orElseThrow();
item.decrease(quantity);
itemRepository.saveAndFlush(item);
}
* synchronized를 사용할 경우 @Transactional 애노테이션은 제외해야 한다. @Transactional은 프록시 객체를 생성해 실제 서비스 객체의 메서드를 대신 호출해주는데, 프록시의 행동 자체는 synchronized하지 않다. 프록시의 액션 내에서 서비스 메서드의 실행이 끝나면 해당 메서드는 다시 다른 스레드가 접근할 수 있는 상태가 되기 때문에 synchronized의 효과를 볼 수 없다.
만약 서버가 한 대만 돌아가고 있는 상황이라면 synchronized를 통한 해결책은 의도한 대로 동작한다.
하지만 synchronized도 한계가 있다. 이 방식은 한 프로세스 내에서만 유효한데, 애플리케이션이 여러 컴퓨터(서버)에서 가동되는 경우 각각 다른 프로세스에서 애플리케이션이 동작하기 때문에 서로의 스레드 접근을 제어할 수 없다.
비관적 락(pessimistic lock)으로 해결
데이터베이스 자체에 lock을 걸어서 한번에 한 트랜잭션만 데이터에 접근할 수 있도록 제어할 수도 있다.
비관적 락은 두 종류가 있다:
- Shared lock(또는 read lock): 락이 걸려있는 동안에 다른 트랜잭션은 읽기만 가능
- Exclusive lock: 락이 걸려있는 동안에 다른 트랜잭션은 읽기도 쓰기도 불가
락을 활용하면 서버가 여러 대인 환경에서도 동시성 문제를 해결할 수 있다.
Repository에 Spring Data JPA의 @Lock 애노테이션을 활용해 비관적 락을 건 쿼리 메서드를 정의한다.
// ItemRepository.java
public interface ItemRepository extends JpaRepository<Item, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT i FROM Item i WHERE i.id = :id")
Item findByIdWithPessimisticLock(Long id);
}
// ItemService.java
@Service
public class ItemService {
private final ItemRepository itemRepository;
public ItemService(ItemRepository itemRepository) {
this.itemRepository = itemRepository;
}
@Transactional
public void decrease(Long id, Long quantity) {
Item item = itemRepository.findByIdWithPessimisticLock(id);
item.decrease(quantity);
itemRepository.save(item);
}
}
비관적 락은 락을 관리하는 비용이 발생하지만, 트랜잭션 충돌이 잦은 상황에서는 낙관적 락에 비해 좋은 성능을 보인다.
낙관적 락(optimistic lock)으로 해결
낙관적 락은 미리 락을 걸어서 접근을 제어하기 보단 데이터를 실제로 update하려는 시점에 확인하는 방식이다. 충돌이 없을 것이라고 가정한다는 뜻에서 낙관적 락이라고 부른다.
낙관적 락은 버전(version)으로 데이터를 수정할 수 있는지 아닌지를 판단한다. 엔티티에 version 필드를 따로 둬서 update 시점에 version 값을 비교하는데, 이때 version 값이 일치하면 update를 적용한 뒤 version 값을 증가시키고, 일치하지 않으면 update가 실패해 트랜잭션을 처음부터 다시 실행하게 한다.
비관적 락과는 달리 update가 실패하면 트랜잭션을 재시도하는 로직이 필요하기 때문에 facade 패턴을 활용해 구현한다.
// Item.java
@Entity
public class Item {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private Long quantity;
// 엔티티에 version 필드 추가--@Version 애노테이션은 jakarta.persistence 패키지에서 import
@Version
private Long version;
// ...중략...
}
// ItemRepository.java
public interface ItemRepository extends JpaRepository<Item, Long> {
@Lock(LockModeType.OPTIMISTIC)
@Query("SELECT i FROM Item i WHERE i.id = :id")
Item findByIdWithOptimisticLock(Long id);
}
// ItemService.java
@Service
public class ItemService {
//...중략...
@Transactional
public void decrease(Long id, Long quantity) {
Item item = itemRepository.findByIdWithOptimisticLock(id);
item.decrease(quantity);
itemRepository.save(item);
}
}
// OptimisticLockItemFacade.java
@Component
public class OptimisticLockItemFacade {
private final ItemService itemService;
public OptimisticLockItemFacade(ItemService itemService) {
this.itemService = itemService;
}
public void decrease(Long id, Long quantity) throws InterruptedException {
// 버전 차이로 인해 update가 실패하면 계속 재시도해야 하기 때문에 while(true)문 사용
while (true) {
try {
itemService.decrease(id, quantity);
break; // itemService.decrease가 성공할 경우 break해줌
} catch (Exception e) {
// itemService.decrease가 실패해서 예외가 발생하면 스레드를 잠시 기다리게 하고 재시도
Thread.sleep(50);
}
}
}
}
여러 트랜잭션이 초기에 같은 version 값을 조회해 와도, 가장 빠른 스레드가 update를 수행해 version 값이 달라지면 나머지 스레드는 유효하지 않은 version 값을 들고 있게 되는 것이다.
낙관적 락은 트랜잭션을 처음부터 다시 실행하는 특징 때문에 충돌이 발생할 때마다 처리 속도가 떨어질 수 있다.
동시성 문제는 synchronized 키워드나 데이터베이스 락을 활용해 데이터 접근을 제어함으로써 해소할 수 있다. 이외에도 Redis로 분산 락을 구현하는 등 다양한 해결 방안이 존재하기 때문에, 상황에 따라 적절한 선택지를 고려해보면 좋을 것 같다.
'Backend' 카테고리의 다른 글
Spring REST Docs로 생성한 API 문서의 배포 문제를 해결한 과정 (0) | 2025.06.09 |
---|---|
Spring REST Docs 적용기 (0) | 2025.06.07 |
@Param 및 @PathVariable의 파라미터 관련 에러 원인 및 해결방안 (0) | 2025.05.28 |
@Embedded, @Embeddable로 객체지향적인 JPA 엔티티 설계하기 (0) | 2025.05.25 |
@Transactional 유무에 의한 로직 동작 차이 (0) | 2025.05.22 |