프로젝트를 진행하다보면 빈번하게 동시성 문제를 만나게 된다. 이에 동시성 문제를 해결하는 여러 방법들을 기록하려한다.
동시성 문제란?
동시성 문제는 2개 이상의 스레드 혹은 세션이 공유 자원에 동시에 접근하여 데이터를 읽고 쓰는 과정에서 충돌이 발생하는 문제로, 하나의 트랜잭션에서 데이터를 수정 중일 때, 다른 트랜잭션에서 수정 전의 데이터를 조회하고 로직을 처리함으로서 데이터의 정합성이 깨지는 문제를 말한다. 주변에서 쉽게 접할 수 있는 문제 발생 사례로는 재고 관리시 재고 이력과 수량이 일치하지 않는 문제가 있다.
동시성 문제를 해결하기 위해 대표적으로 사용되는 방법으로는 낙관적 락과 비관적 락을 사용해 어플리케이션 및 데이터베이스에서 동시성을 제어하는 방법이 있다.
낙관적 락이란?
낙관적 락은 트랜잭션간 충돌이 자주 발생하지 않을 것이라고 가정하고, 충돌을 감지하여 후처리하는 방식으로 동작한다
낙관적 락의 동작 방식
- 트랜잭션이 시작되면 락을 걸지 않고 데이터를 조회한다.
- 데이터를 수정할 때, 처음 조회한 데이터와 수정하려는 데이터의 버전 번호(version)나 타임스탬프(timestamp) 등을 비교하여, 다른 트랜잭션이 데이터를 수정했는지 확인한다.
- 데이터가 다른 트랜잭션에 의해 수정되지 않았다면, 데이터를 수정하고 버전 번호를 증가시키면서 트랜잭션을 종료한다.
- 데이터가 다른 트랜잭션에 의해 수정되었다면, 수정 작업을 중단하며 트랜잭션을 롤백한다.
낙관적 락의 장단점
- 장점:
- 락을 걸지 않기 때문에 성능이 좋다.
- 단점:
- 충돌이 발생했을 때 개발자가 직접 롤백 처리를 해줘야 하여 관리 포인트가 늘어난다.
- 충돌이 자주 발생하면 트랜잭션 재시도 횟수가 늘어나기 때문에 비관적 락에 비해 성능이 나빠질 수 있다.
- 재시도가 최초 요청 순서대로 처리되는 것이 아니기 때문에 요청 순서가 보장되어야 하는 경우 사용할 수 없다.
낙관적 락 사용 예제 (with JPA)
스프링에서는 Spring Retry 라이브러리를 통해 낙관적 락을 반복적인 try-catch 문 없이 구현할 수 있다.
// build.gradle
implementation 'org.springframework.retry:spring-retry'
implementation 'org.springframework:spring-aspects'
@SpringBootApplication
@EnableFeignClients
// Spring Retry 라이브러리 사용을 위해 적용
@EnableRetry
public class WaitingSpotServiceApplication {
public static void main(String[] args) {
SpringApplication.run(WaitingSpotServiceApplication.class, args);
}
}
위와 같이 build.gradle 파일 내 Spring Retry 라이브러리 의존성을 추가한 후, SpringBootApplication.class에 @EnalbeRetry 어노테이션을 추가한다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "spots")
public class Spot {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 낙관적 락 사용을 위한 version 컬럼
@Version
private Long version;
@Column(name="name", nullable = false)
private String name;
@Column(name = "max_capacity", nullable = false)
private Integer maxCapacity;
@Column(name = "remaining_capacity", nullable = false)
private Integer remainingCapacity;
...
}
낙관적 락을 사용할 Entity에 @Version 어노테이션을 활용하여 version 필드를 추가한다.
@Repository
public interface SpotRepository extends JpaRepository<Spot, Long> {
@Lock(LockModeType.OPTIMISTIC)
@Query("SELECT s FROM Spot s WHERE s.id = :spotId")
Optional<Spot> findByIdForOptimistic(Long spotId);
...
}
repository에서 낙관적 락을 사용할 메서드에 위와 같이 @Lock 어노테이션과 LockModeType.OPTIMISTIC 옵션을 설정한다.
@Service
@RequiredArgsConstructor
public class SpotService {
private final SpotRepository spotRepository;
private final SpotReader spotReader;
...
@Transactional
// ObjectOptimisticLockingFailureException 발생시 1초 대기후 최대 5번까지 재시도
@Retryable(
retryFor = {
ObjectOptimisticLockingFailureException.class
},
maxAttempts = 5,
backoff = @Backoff(delay = 1000)
)
public void decreaseRemainingCapacityWithOptimisticLock(Long spotId, SpotRemainingCapacityRequest request) {
Spot spot = spotReader.findByIdForOptimistic(spotId);
spot.decreaseRemainingCapacity(request.getHeadCount());
spotRepository.save(spot);
}
...
}
service에서 낙관적 락을 적용한 메서드에 @Retryable 어노테이션을 적용한다. 위와 같이 특정 예외가 발생할 시 재처리 대기시간 및 최대 재시도 횟수 등을 설정할 수 있다.
@Slf4j
@SpringBootTest
class SpotServiceTest {
@Autowired
private SpotService spotService;
@Autowired
private SpotReader spotReader;
@Autowired
private SpotRepository spotRepository;
@AfterEach
void tearDown() {
spotRepository.deleteAll();
}
@Test
void 낙관적_락을_사용하여_잔여_수용량을_감소시킨다() throws InterruptedException {
Integer remainingCapacity = 1000;
Spot spot1 = new Spot("점포 1호", 1000, remainingCapacity, "서울특별시 영등포구", 1L);
Long spotId = spotRepository.save(spot1).getId();
int threadCount = 3;
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.execute(() -> {
try {
spotService.decreaseRemainingCapacityWithOptimisticLock(
spotId, new SpotRemainingCapacityRequest(1)
);
} finally {
latch.countDown();
}
});
}
latch.await();
Spot spot = spotReader.findById(spotId);
assertEquals(remainingCapacity - threadCount, spot.getRemainingCapacity());
}
}
위와 같이 테스트 코드를 작성하여 실행해보자.
Hibernate:
update
spots
set
address=?,
max_capacity=?,
name=?,
remaining_capacity=?,
status=?,
user_id=?,
version=?
where
id=?
and version=?
위와 같은 업데이트 쿼리가 발생하며 테스트가 통과하는 것을 볼 수 있다. 해당 업데이트 쿼리를 살펴보면 where 절에서 version이 일치하는지 조건에 추가하여, 최초 조회한 데이터와 수정하려는 데이터가 같은지 비교 확인하는 것을 볼 수 있다.
비관적 락이란?
비관적 락(Pessimistic Lock)은 데이터에 공유락(Shared Lock) 또는 배타락(Exclusive Lock)을 걸어 다른 트랜잭션이 동시에 접근하지 못하도록 막는 방식이다.
비관적 락의 동작 방식
- 트랜잭션이 시작되면 배타락을 걸고 데이터를 조회한다.
- 락이 걸린 데이터는 현재 트랜잭션이 데이터를 읽고 수정할동안 다른 트랜잭션의 접근 제한한다.
- 트랜잭션이 종료되면 락이 해제되고, 다른 대기 중인 트랜잭션들이 순차적으로 락을 획득하고 작업을 수행한다.
비관적 락의 장단점
- 장점:
- 데이터의 정합성을 보장할 수 있다.
- 낙관적 락과 달리 데이터 충돌을 사전에 방지할 수 있다.
- 단점:
- 비관적 락이 걸려있는 동안 다른 트랜잭션이 대기(block) 상태로 있어 성능이 저하될 가능성이 높다.
- 동시에 많은 사용자가 요청할 시 DeadLock이 발생할 가능성이 높다.
비관적 락 사용 예제 (with JPA)
@Repository
public interface SpotRepository extends JpaRepository<Spot, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "1000")})
@Query("SELECT s FROM Spot s WHERE s.id = :spotId")
Optional<Spot> findByIdForUpdate(Long spotId);
...
}
repository에서 비관적 락을 사용할 메서드에 위와 같이 @Lock 어노테이션과 LockModeType.PESSIMISTIC_WRITE옵션을 설정한다. 또한 비관적 락의 경우 데드락이 발생할 가능성이 있기 때문에 @QueryHints 어노테이션을 사용하여 락 최대 대기 시간을 설정해준다.
@Service
@RequiredArgsConstructor
public class SpotService {
private final SpotRepository spotRepository;
private final SpotReader spotReader;
...
@Transactional
public void decreaseRemainingCapacityWithPessimisticLock(Long spotId, SpotRemainingCapacityRequest request) {
Spot spot = spotReader.findByIdForUpdate(spotId);
spot.decreaseRemainingCapacity(request.getHeadCount());
spotRepository.save(spot);
}
...
}
@Slf4j
@SpringBootTest
class SpotServiceTest {
@Autowired
private SpotService spotService;
@Autowired
private SpotReader spotReader;
@Autowired
private SpotRepository spotRepository;
@AfterEach
void tearDown() {
spotRepository.deleteAll();
}
...
@Test
void 비관적_락을_사용하여_잔여_수용량을_감소시킨다() throws InterruptedException {
Integer remainingCapacity = 1000;
Spot spot1 = new Spot("점포 1호", 1000, remainingCapacity, "서울특별시 영등포구", 1L);
Long spotId = spotRepository.save(spot1).getId();
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.execute(() -> {
try {
spotService.decreaseRemainingCapacityWithPessimisticLock(
spotId, new SpotRemainingCapacityRequest(1)
);
} finally {
latch.countDown();
}
});
}
latch.await();
Spot spot = spotReader.findById(spotId);
assertEquals(remainingCapacity - threadCount, spot.getRemainingCapacity());
}
...
}
위와 같이 service layer와 테스트 코드를 작성하고 실행해보자.
Hibernate:
select
s1_0.id,
s1_0.address,
s1_0.max_capacity,
s1_0.name,
s1_0.remaining_capacity,
s1_0.status,
s1_0.user_id,
s1_0.version
from
spots s1_0
where
s1_0.id=? for update
테스트 코드 실행시, 테스트가 통과하며 위와 같이 조회 쿼리 마지막에 for update 구문이 추가되어 비관적 락이 잘 적용된 것을 확인할 수 있다.
그래서 낙관적 락과 비관적 락중 어떤 것을 사용해야 할까?
진행하고 있는 '웨이팅 프렌즈' 프로젝트의 잔여 수용량 수정 기능에서는 낙관적 락을 사용할 수 없었다. 이유는 해당 기능이 '손님이 예약하는 인원 수 만큼 해당 점포의 잔여 수용량이 감소하며 예약이 진행'되는 로직이어서 최초 요청 순서가 항상 보장되어야 했기 때문이다. 따라서 해당 기능에 우선적으로 비관적 락을 설정해두고 더 좋은 방법을 찾아 보기로 했다.
낙관적 락과 비관적 락은 각각 장단점이 명확하기 때문에 필요한 상황에 맞춰 적절하게 사용해야 성능 저하를 최소화시킬 수 있다.
'JPA' 카테고리의 다른 글
| JPA에서 soft delete 처리하기 (@Where, @Filter) (0) | 2023.02.01 |
|---|---|
| @Column(nullable = false) 와 @NotNull 중 무엇을 사용해야 할까? (0) | 2022.07.18 |