동시성 처리
우리가 웹 서비스를 개발하다보면 수많은 종류의 동시성 문제를 만날 수 있다.
- 주문을 도메인으로 갖는 서비스에서 상품 재고 동시성 처리
- 선착순 쿠폰에 관한 동시성 처리
- 한정판 등등
동시성 문제는 공유 자원을 동시에 접근하는 과정에서 `Critical Section`에서 발생하는
`Race Condition`으로 인한 문제를 의미한다.
따라서, 이번 포스팅에서는 JVM 환경에서 제공하는 `synchronized` 키워드를 사용하여 동시성을 제어하고
동시에 한계에 관해 설명하려 한다.
설명에 앞서, 프로세스와 스레드에 대해서 간단하게 짚고 넘어가자.
Proccss와 Thread
프로세스는 무엇일까?
디스크에 파일 형태로 존재하던 프로그램이 주기억장치에 적재되면 그걸 프로세스라고 한다.
- 적재된 프로세스는 `Ready Queue`에서 대기(Ready Status)하게 되고 CPU 스케줄링에 의해
CPU를 할당받게 되면 `Running Status`가 된다.
프로그램 = 파일 시스템에 설치되어 있는 파일
프로세스 = 메모리에 적재된 프로그램 & OS로부터 자원을 할당받는 작업의 단위
쓰레드 = 프로세스가 할당받은 자원을 이용하는 실행 흐름 단위
하나의 프로세스는 최소 1개 이상의 쓰레드가 존재하며 쓰레드가 실제 작업 실행의 주체이다.
프로세스 내부의 쓰레드들은 프로세스의 Code 영역, Data 영역, Heap 영역을 공유하고 Stack 영역과 Register는 별도로 할당받는다.
각각의 쓰레드들이 공유 자원에 동시에 접근하게 되는 경우를 `Race Condition`이라고 하며
`Race Condition`에 대한 동기화 메커니즘으로는 Mutex, Semaphore, Monitor 등이 존재한다.
또한, 공유 자원에 접근하는 해당 영역을 `Critical Section`이라고 부른다.
synchronized 키워드
자바의 `synchronized` 키워드는 N개의 쓰레드가 동시에 공유 자원에 접근하는 것을 제어하여
`Race Condition`을 방지하는 동기화 메커니즘을 제공한다.
- `Critical Section`에 관한 Monitor Locking 메커니즘을 통해서 제어한다.
자바의 모든 객체는 모니터 락(Monitor Lock)을 가지고 있으며, 이를 통해 쓰레드 동기화를 수행할 수 있다.
synchronized 키워드는 객체의 모니터 락을 사용하여 상호 배제(Mutual Exclusion)을 보장한다.
따라서, 모니터를 획득한 하나의 쓰레드만이 `Critical Section`에 접근할 수 있다.
모니터 락에 관련해서는 추후 포스팅으로 다뤄보겠다.
동시성 제어
@Getter
@Setter
@Entity
public class Ticket {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private int stock;
public Ticket(int stock) {
this.stock = stock;
}
public Ticket() {
}
public void purchase(int amount){
if(stock == 0){
throw new RuntimeException("티켓이 매진되었습니다.");
}
if(stock < amount){
throw new RuntimeException("티켓 재고가 부족합니다.");
}
stock -= amount;
}
}
1. synchronized 적용 X
@Slf4j
@Service
@RequiredArgsConstructor
public class TicketV1Service {
private final TicketRepository ticketRepository;
@Transactional
public void purchase(Long ticketId, int amount){
Ticket ticket = ticketRepository.findById(ticketId).orElseGet(null);
log.info("{} -> [Ticket{} 현재 보유량={} & 구매 요청량={}", Thread.currentThread().getName(), ticket.getId(), ticket.getStock(), amount);
ticket.purchase(amount);
}
}
- 현재 티켓 구매 로직에는 어떠한 동시성 처리도 적용되지 않았다.
- 따라서, 결과로도 알 수 있듯이 20명의 사용자 X 5장의 티켓 = 100장의 티켓이 팔려야 정상인데 10장밖에 팔리지 않은 것으로 기록되었다.
2. synchronized 적용 O
@Slf4j
@Service
@RequiredArgsConstructor
public class TicketV2Service {
private final TicketRepository ticketRepository;
@Transactional
@Synchronized
public void purchase(Long ticketId, int amount){
Ticket ticket = ticketRepository.findById(ticketId).orElseGet(null);
log.info("{} -> [Ticket{} 현재 보유량={} & 구매 요청량={}", Thread.currentThread().getName(), ticket.getId(), ticket.getStock(), amount);
ticket.purchase(amount);
}
}
- synchronized 키워드를 적요했음에도 불구하고 여전히 동시성 처리가 되지 않음을 확인할 수 있다.
- 이유는 바로 @Transcational의 동작원리 때문이다.
@Transactional 동작원리
- @Transcation이 적용된 클래스는 `CGLIB`에 의해 런타임에 해당 클래스를 기반으로 한 프록시로 생성된다.
- 그에 따라 @Transactional 로직으로 진입하기 전/후에 Transcation Begin & Commit/Rollback이 진행되는 것이다.
- 이렇게 @Transactional이 걸려있는 비즈니스 로직에 `synchronized` 키워드를 붙이게 된다면 다음과 같이 동작한다.
- 해당 비즈니스 로직에 `synchronized`가 걸려있으니 해당 로직으로 진입할 때 `Monitor Lock`을 가지고 진입하게 되는 것이다.
- 그러면 Thread1을 제외한 나머지 쓰레드들은 비즈니스 로직에 접근하지 못하고 Lock을 얻기 위해서 대기한다.
- 여기서 Thread1이 비즈니스 로직을 끝내고 커밋/롤백 시점으로 돌입한다고 가정하자.
- 이 시점에 Thread2가 진입하게 되면 아직 Thread1의 로직이 commit되기 전이므로 DB에 존재하는 Ticket의 stock은 여전히 100이다.
- 그에 따라서 Thread2는 Ticket의 stock을 100으로 받게 되고 그에 따른 로직이 진행된다.
3. synchronized & @Transactional 분리
1) Facade Layer
@Component
@RequiredArgsConstructor
public class TicketFacade {
private final TicketV3Service target;
@Synchronized
public void invoke(Long ticketId, int amount){
target.purchase(ticketId, amount);
}
}
@Slf4j
@Service
@RequiredArgsConstructor
public class TicketV3Service {
private final TicketRepository ticketRepository;
@Transactional
public void purchase(Long ticketId, int amount){
Ticket ticket = ticketRepository.findById(ticketId).orElseGet(null);
log.info("{} -> [Ticket{} 현재 보유량={} & 구매 요청량={}", Thread.currentThread().getName(), ticket.getId(), ticket.getStock(), amount);
ticket.purchase(amount);
}
}
2) 명시적 savdAndFlush
@Slf4j
@Service
@RequiredArgsConstructor
public class TicketV4Service {
private final TicketRepository ticketRepository;
@Synchronized
public void purchase(Long ticketId, int amount){
Ticket ticket = ticketRepository.findById(ticketId).orElseGet(null);
log.info("{} -> [Ticket{} 현재 보유량={} & 구매 요청량={}", Thread.currentThread().getName(), ticket.getId(), ticket.getStock(), amount);
ticket.purchase(amount);
ticketRepository.saveAndFlush(ticket);
}
}
Facade의 경우 `Monitor Lock`이 `@Transactional` 적용된 범위 밖에 걸리기 때문에 문제가 발생하지 않는다.
`명시적 savdAndFlush`의 경우는 @Transcational 자체를 사용하지 않고 바로 flush를 해버리기 때문에 마찬가지로 괜찮다.
하지만, 분산 환경에서 `synchronized`를 사용하는 것에는 한계가 존재한다.
분산 환경에서의 synchronized 한계
`synchronized` 키워드는 단일 인스턴스 상에서 멀티 쓰레드가 `Critical Section`에 접근하는 경우 동시성 제어가 가능하다.
하지만, 우리는 SPOF(단일 장애점 실패), 트래픽 부하 분산, 등 여러가지 이유로 서버 자체를 분산시켜 구성한다.
그럼 이때도 `synchronized`는 우리가 원하는대로 동작할까?
예상할 수 있겠지만 원하는대로 동작하지 않을 것이다.
관련 내용은 다음 포스팅에서 다뤄보겠다.
참고
https://sjiwon-dev.tistory.com/20
'Spring > 동시성 & Lock' 카테고리의 다른 글
[캐시 & 동시성 & Lock] 선착순 티켓 시스템 고도화 - 1 (0) | 2024.11.19 |
---|---|
[Spring Data] Redis Lock을 적용해보자 - 2 (0) | 2024.10.17 |
[Spring Data] Redis Lock을 적용해보자 - 1 (0) | 2024.10.16 |
[Spring] synchronized 키워드를 활용한 동시성 문제 해결 및 한계 - 3 (0) | 2024.09.27 |
[Spring] synchronized 키워드를 활용한 동시성 문제 해결 및 한계 - 2 (0) | 2024.09.25 |