본문 바로가기
Study/Server 심화

6주차: 동시성 처리(2)

by jisu-jeong0 2024. 5. 28.

지난주에 이어 이번주에는 동시성 문제 해결방안에 대해 알아보겠다.

 

5주차: 동시성 처리(1)

1️⃣ 동시성하나의 시스템이 여러 작업을 동시에 처리하는 것처럼 보이게 하는 것 동시성은 독립적인 작업을 작은 단위의 연산으로 나누어 시간 분할 형태로 연산하고, 동시에 실행하는 것처

fluttering-girdle-e7f.tistory.com

 

5주차의 동시성 문제

- Race Condition, Deadlock, Starvation

 

동시성 문제는 어떻게 해결할까?

🚨 동시에 들어오는 요청들을 동시에 처리하지 않으면 되는 것!

 

1️⃣ Synchronized

Java는 기본적으로 멀티스레드 언어이기 때문에 동시성 문제가 발생하기 좋은 환경이다. 그래서 Java는 언어 레벨에서 동시성을 제어할 수 있는 기능을 제공하는데, 그것이 바로 synchronized이다. 메소드에 synchronized 키워드를 추가하거나 코드 내부에 synchronized 블럭을 추가하는 식으로 구현한다.

public class TicketService {
    public synchronized void ticketing(Long ticketId, Long quantity) {
        // ... Logic ...
    }

}

 

위와 같이 순서를 보장하고 싶은 메서드에 synchronized를 붙여주면 된다.
이렇게 되면 ticketing이라는 메소드는 한번에 하나의 스레드에서만 실행되면서 동시성 문제를 해결할 수 있게 된다.

 

하나의 JVM, 즉 하나의 서버에서는 synchronized만으로도 충분히 동시성 제어가 가능하다. 하지만, 서비스 규모가 약간만 커져도 하나의 서버로 서비스를 운영하는 것은 불가능하고, N대의 서버가 구동되게 된다. 이런 상황에서는 synchronized를 사용해도 여러 서버들에서 동시에 요청이 쏟아지므로 동시성 문제를 해결할 수 없게 된다.

💡 여러 대의 서버가 구동중인 환경에서도 동시성 문제를 막으려면 어떻게 해야할까?

 

2️⃣ Lock

 

비관적 락(pessimistic lock)

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select t from Ticket t where t.id = :id")
Ticket findByIdWithPLock(Long id);

// 이 옵션은 특정 데이터에 Lock이 걸리면 해당 데이터에 대해서 조회, 수정, 삭제가 모두 불가능한 
// 강한 격리성을 보장하는 모드이다. MySQL의 REPEATABLE-READ 수준의 격리성을 갖게 된다.
@Transactional
@Service
@RequiredArgsConstructor
public class TicketService {

    private final TicketRepository ticketRepository;

    public void ticketing(Long ticketId, Long quantity, Boolean lock) {
        Ticket ticket = lock
                ? ticketRepository.findByIdWithPLock(ticketId)
                : ticketRepository.findById(ticketId).orElseThrow();
        ticket.decrease(quantity);
        ticketRepository.saveAndFlush(ticket);
    }

}
  • 모든 트랜잭션은 충돌이 발생한다고 가정하고 우선 Lock을 거는 방법이다.
  • 낙관적 락(Optimistic Lock)과는 달리 DB의 Lock 기능을 이용한다. 주로 select for update 구문을 사용하고, 버전 정보는 사용하지 않는다.
  • 엔티티가 아닌 스칼라 타입(int, String 같은 타입)을 조회할 때도 사용할 수 있다.
  • 트랜잭션을 커밋하기 전에, 데이터를 수정하는 시점에 미리 트랜잭션 충돌을 감지할 수 있다.
  • Lock을 획득할 때까지 트랜잭션은 대기하므로, Timeout을 설정할 수 있다.

한계점

동시성 문제 측면에서는 안정성이 매우 높지만, 하나하나씩 순차적으로 처리해서 처리 속도가 비교적 매우 늦어지고, 특정 데이터의 조회까지도 막아버려서 또 다른 부작용이 발생할 가능성도 높아진다는 단점이 있다.

 

낙관적 락(optimistic lock)

@Lock(LockModeType.OPTIMISTIC)
@Query("select t from Ticket t where t.id = :id")
Ticket findByIdWithOLock(Long id);
@Slf4j
@Transactional
@Service
@RequiredArgsConstructor
public class TicketService {

    private final TicketRepository ticketRepository;

    public void optimisticTicketing(Long ticketId, Long quantity) {
        try {
            Ticket ticket = ticketRepository.findByIdWithOLock(ticketId);
            ticket.decrease(quantity);
            ticketRepository.saveAndFlush(ticket);
        } catch (ObjectOptimisticLockingFailureException | OptimisticLockException e) {
            log.info("Version 충돌. 롤백 또는 재시도");
        }
    }

}
  • 낙관적 락(Optimisstic Lock)은 DB의 Lock을 사용하지 않고 Version 관리를 통해 애플리케이션 레벨에서 처리한다.
  • 대부분의 트랜잭션이 충돌하지 않는다고 가정하는 방법이다.
  • DB의 Lock 기능을 이용하지 않고, JPA가 제공하는 버전 관리 기능을 사용한다.
  • 트랜잭션 커밋 전에는 트랜잭션 충돌을 알 수 없다.
  • JPA의 낙관적 락(Optimisstic Lock)을 사용하기 위해서는 @Version을 사용해서 버전 관리 기능을 추가해야 한다.
  • @Version을 적용할 수 있는 데이터 타입은 아래와 같다.
    1. Long, long
    2. Integer, int
    3. Short, short
    4. Timestamp

 

  • 엔티티에 @Version을 위한 필드를 추가하면, 엔티티를 수정할 때 마다 버전이 하나씩 자동으로 증가한다.
  • 그리고 엔티티를 수정할 때 조회 시점의 버전과, 수정 시점의 버전이 다르면 예외가 발생한다.
    1. 트랜잭션1이 엔티티를 조회 → version 1
    2. 동시에 트랜잭션2가 같은 엔티티를 조회 후 수정 → version 2
    3. 트랜잭션1이 커밋 → version이 1이여야 하는데 2이다 → 예외 발생
  • 이런 매커니즘 때문에 최초 커밋만 인정되는 방식을 구현할 수 있으므로, 두 번의 갱신 분실 문제를 방지할 수 있다.

한계점

Optimistic lock의 경우 강한 수준의 DB Lock을 걸지 않아서 Pessimistic Lock에 비해서 자유롭게 데이터 조회가 가능하다는 장점이 있지만, 롤백 및 재시도 로직을 직접 구현해줘야한다는 단점 또한 존재한다.

 

💡 분산 락?
분산 환경에서 동시성 문제를 다루기 위해 등장한 방법이 바로 분산 락(distributed lock)이다.

 

분산 락을 구현하기 위해 락에 대한 정보를 ‘어딘가’에 공통적으로 보관하고 있어야 한다. 그리고 분산 환경에서 여러대의 서버들은 공통된 ‘어딘가’를 바라보며, 자신이 임계 영역(critical section)에 접근할 수 있는지 확인한다. 이렇게 분산 환경에서 원자성(atomic)을 보장할 수 있게 된다. 

 

Redis를 통해 분산 락을 구현할 수 있는데, 이에 접근할 수 있는 다양한 클라이언트들이 존재한다. 대표적으로 Jedis, Lettuce, Redisson등이 있다. 

 

Redisson은 비교적 합리적인 방식으로 Lock 획득 재시도 기능이 구현되어 있다.
Lettuce는 '스핀락'이라고 불리는 일종의 폴링 기법을 활용해서 Lock 획득을 재시도하나, Redisson은 Redis의 Pub/sub 기능을 사용해서 Lock 획득을 재시도한다.


즉, Lock획득에 실패하면, Redisson은 특정 채널을 구독하고, Lock이 다시 획득할 수 있는 상태가 됐다는 이벤트를 받았을 때, 다시 Lock획득을 시도하는 것이다. 이는 Lock이 획득될 때까지 계속 Lock 획득을 요청하는 Lettuce보다 효율적이고, Redis서버에도 부하를 덜 주는 방법이라고 볼 수 있다.

 


 

비동기 처리?

작업을 수행하는 두 주체 A, B가 있다고 가정하자.
동기 (sync) 비동기 (async)
A가 작업을 끝내는 시간에 맞춰 B가 작업을 시작한다. 다른 말로 하면, B는 A가 수행한 작업의 결과(리턴 값)에 관심이 있다고도 할 수 있다. A가 작업을 끝내든 말든 상관 없이, B가 자신의 작업을 시작한다. B는 A가 수행한 작업의 결과에 관심이 없다고 표현할 수도 있다.

 

비동기 방식에서는 두 주체가 서로의 작업 시작 및 종료 시간에 영향을 받지 않고, 별도의 작업 시작/종료 시간을 가진다.

 

 

 

References

 

[Spring] Optimistic Lock, Pessimistic Lock을 활용한 동시성 문제 해결

Lock으로 동시성 문제를 해결해보자

velog.io

 

동시성 이슈를 해결하는 다양한 방법

트래픽이 몰려 짧은 시간에 요청이 거의 동시에 발생하거나 서버가 불안정해져 요청을 처리하는데 시간이 오래 걸리는 경우 동시성 이슈로 데이터 정합성에 문제가 생길 수 있다.위와 같이 상

velog.io

 

 

 

 

[Spring] 스프링 동시성 처리 방법(feat. 비관적 락, 낙관적 락, 네임드 락)

0. 들어가기 전 이전에는 DB 단의 동시성 처리 방법인 Lock에 대해서 알아봤습니다. https://ksh-coding.tistory.com/121 [DB] DB Lock이란? (feat. Lock 종류, 블로킹, 데드락) 0. 락(Lock)이란? 여러 커넥션에서 동시

ksh-coding.tistory.com

 

'Study > Server 심화' 카테고리의 다른 글

8주차: CI/CD (2)  (0) 2024.06.25
7주차: CI/CD (1)  (0) 2024.06.18
5주차: 동시성 처리(1)  (1) 2024.05.21
4주차: 스프링 시큐리티 + JWT(2)  (1) 2024.05.14
3주차: 스프링 시큐리티 + JWT(1)  (0) 2024.05.07