서버와 동시 실행
- 요청당 0.1초가 걸린다고 했을때 100개의 요청을 처리하는 경우
- 단순히 하나씩 처리
- 100개 처리하는데 10초가 소요됨
- 동시에 10개씩 처리
- 100개 처리하는데 1초가 걸림
- 즉 동시에 처리하면 성능이 좋아짐
- 서버 관점에서는 처리량이 늘어나고 응답시간이 줄어듦
- 단순히 하나씩 처리
- 서버가 동시에 여러 요청을 처리하는 방식은 2가지 방법이 존재
- 요청마다 스레드를 할당해서 처리 → 여러 스레드가 동시에 코드를 실행
- 비동기 IO(논블로킹 IO)를 사용해서 처리 → 이 방식도 단일 스레드로 사용하는 경우는 드묾
- 즉 어떤 방식이든 서버는 동시실행이 기본!
- 이때 여러 스레드가 같은 데이터를 조회하고 수정하면 문제가 생길 수 있음
public class Increaser {
private int count = 0;
public void inc() {
count = count + 1;
}
public int getCount() {
return count;
}
}
Increaser increaser = new Increaser();
Thread[] threads = new Thread[100];
for (int i = 0; i < 100; i++) {
Thread t = new Thread(() -> {
for (int j = 0; j < 100; j++) {
increaser.inc();
}
});
threads[i] = t;
t.start();
}
for (Thread t : threads) {
t.join();
}
System.out.println(increaser.getCount());- 여러 스레드에서 하나의 Increaser 인스턴스에 접근해서 inc() 를 호출하면 문제가 발생함
- count를 읽는 부분과 읽은 count에 +1 후 대입 하는 부분이 나눠져 있기 때문임
- count, +1, = 으로 3 가지 아닌가?
- 동시성 문제는 문제가 바로 드러나지 않을때가 존재,
- 숨어있다가 예상치 못한 순간에 나타남, 미묘해서 재현이 잘 안되는 경우도 존재
- 이때문에 처음부터 동시성 문제를 염두에 두고 개발하는것이 중요
- 이처럼 여러 스레드가 공유 자원에 접근해서 결과가 달라지는 상황을 경쟁 상태(race condition)라고함
잘못된 데이터 공유로 인한 문제 예시
- PayService는 싱글톤이라고 가정
- PayService는 싱글톤인데 여러 요청을 동시 처리하는경우 1. this.payId 할당 이후 결과를 예측할 수 없음
public class PayService {
private Long payId;
public PayResp pay(PayRequest req) {
...
this.payId = getPayid(); // 1. 아이디 생성, 클래스 멤버에 할당
saveTemp(this.payId, req); // 2. 임시 데이터 저장
PayResp resp = sendPayData(this.payId, ..); // 3. 결제 요청후 결과를 받음
applyResponse(resp); // 4. 결재 결과 처리
return resp;
}
public void applyResponse(PayResp resp) {
PayData payData = createPayDataFromResp(resp); // 4-1. 결제 응답을 이용해 PayData 생성
updatePayData(this.payId, payData); // 4-2. 결과를 업데이트
...
}
}- DB도 동시성 문제에서 자유롭지 않음!
- 관리자와 고객이 같은 주문정보를 동시에 변경하는 경우
- 즉 같은 row를 동시에 조작하는경우
- 관리자와 고객이 같은 주문정보를 동시에 변경하는 경우
프로세스 수준에서의 동시 접근 제어
- 동시성 문제는 프로세스 수준과 DB 수준 모두를 고려해야함
- 먼저 단일 프로세스 내에서
잠금
- 동작방식
- 잠금획득
- 공유 자원 접근
- 임계 영역(Critical Section): 둘 이상의 스레드가 접근하면 안되는 공유 자원
- 잠금 해제
- 책에서는 ReentrantLock() 사용
- synchronized 를 사용하면 더 간단하게 처리가능!
- ReentrantLock은 잠금 획득 대기 시간 지정 기능 지원
세마포어
- 일반적으로 잠금은 한 스레드만 접근가능
- 동시에 접근가능한 스레드 수를 제한하고 싶을때는 세마포어
- 동작방식은
- 세마포어에서 퍼밋 획득(허용 가능 숫자 1 감소)
- 공유 자원 접근
- 세마포어에 퍼밋 반환(허용 가능 숫자 1 증가)
읽기 쓰기 잠금
- 읽기와 쓰기가 동일한 잠금을 사용하면 동시에 한 스레드만 읽거나 쓸수 있음
- 읽고 있을때 쓰는건 문제가 되지만 쓰기가 없을때는 여러 스레드에서 읽어도 상관없음
- ReentrantReadWriteLock
- 쓰기는 한번에 한 스레드만
- 읽기는 여러 스레드가 가능
- 쓰기 잠금이 걸리면 해제될때까지 읽기 잠금 획득 불가능
- 읽기 잠금 하나라도 걸려있으면 쓰기 잠금 획득 불가능
원자적 타입
- 잠금은 쉽게 동시성 문제를 해결가능
- 하지만 잠금을 획득하지 못한 스레드는 대기하기 때문에 CPU 효율이 떨어짐
- 잠금을 사용하지 않으면서 동시성 문제없이 카운터를 구현하는방법, Atomic* 의 원자적 타입 사용
- Atomic은 내부적으로 CAS(Compare and swap)을 사용
- 성공할때까지 시도
동시성 지원 컬렉션
- HashMap, HashSet등은 여러 스레드가 공유하면 동시성 문제가 발생
- Collections에서 제공하는 동기화된 컬렉션을 사용하면 데이터를 변경하는 모든 연산에 잠금을 적용해서 한 스레드만 접근할 수 있도록 제한함
Map<String, String> map = new HashMap<>();
Map<String, String> syncMap = Collections.synchronizedMap(map);
syncMap.put("key1", "value1"); // 내부적으로 synchronized 로 처리됨주의
자바 23 또는 이전 버전 기준으로 가상 스레드를 사용하면 동기화 컬렉션으로 변환 메서드 사용 금지
내부적으로 synchronized를 사용하는데 해당 버전에서 가상 스레드는 synchronized를 지원하지 않기 때문에 성능에 문제가 발생할 수 있음
또 다른 방법은 ConcurrentHashMap을 사용하는것
DB와 동시성
- DB는 다양한 동시성 문제를 해결해주고 있음
- DB의 트랜잭션
- 원자성 제공 → 모두 적용(커밋)되거나 모두 취소(롤백)
- 비관적 잠금과 낙관적 잠금
- 명시적 잠금 기법을 선점 잠금, 또는 비관적 잠금이라고 함
- SELECT * FOR UPDATE
- 조회한 트랜잭션에서 잠금, 다른 트랜잭션에서는 동일 레코드에 대해 잠금이 끝날때(트랜잭션 커밋 또는 롤백)까지 대기
- 값을 비교해서 수정 (CAS), 비선점 잠금, 낙관적 잠금
- UPDATE * SET * , version = version + 1 WHERE .., version = [이전 조회시 version]
- 명시적 잠금 기법을 선점 잠금, 또는 비관적 잠금이라고 함
- 분산잠금
- DB 또는 레디스
- 외부 연동과 잠금
- 트랜잭션 범위 내에서 외부 시스템과 연동해야한다면 비선점 보다는 선점 잠금을 고려하는것이 좋음
- PG 결제 취소는 완료했는데 동시성 문제로 내부 DB 결제 취소가 안될 수 있음
- 비선점 잠금을 사용하고 싶다면 트랜잭션 아웃박스 패턴 적용을 적용해서 외부 연동을 처리하는 방법이 존재, 필자가 자주 애용하는 방식이라고 함
- 트랜잭션 범위 내에서 외부 시스템과 연동해야한다면 비선점 보다는 선점 잠금을 고려하는것이 좋음
- 증분 쿼리
- row의 컬럼으로 카운터를 구현한다고 했을때
- 선점 잠금은 대기시간 만큼 응답 시간이 길어짐, 비선점은 에러가 자주 발생할 수 있음
- UPDATE 쿼리에서 즉시 + 1
- DB는 a = a + 1 을 원자적으로 처리
잠금 사용 시 주의 사항
- 잠금을 획득한 후에는 반드시 해제해야함!
lock.lock()
try {
// 코드
} finally {
lock.unlock(); // finally 안에서!
}- 동시접근이 많아지면 대기 시간이 길어지는 문제가 발생함
- 이때는 대기 시간을 지정해야함!
- 또는 대기시간 없이 처리 → 대기시간이 길어지면 사용자에게 불안감을 줄수있음, 긴 대기시간보다는 빠른 실패가 사용자를 안심시키는 데 도움을 줌
- 교착 상태(deadlock) 피하기
- 두 스레드가 같은 자원에 접근
- 스레드1 | 스레드2 A | B B | A
- 다음과 같은 방법으로 교착 상태를 해소할 수 있음
- 잠금 대기시간
- 잠금시 정렬후 잠금 → 교착상태를 회피
- 라이브락(livelock)
- 두 사람이 길에서 마추쳤을때 같은 방향의 좌우로 회피하면 두 사람 모두 앞으로 갈 수 없음
- 이렇게 활동하는것같지만 아무것도 하지 않는 상태를 라이브락이라고 함
- 철학자의 만찬 문제
- 교착상태와는 다르게 뭔가를 하고는 있음
- 해결
- 우선순위두기 → 위 문제에서 사람마다 우선순위 두기
- 중재자 두기
단일 스레드로 처리하기
- 동시성이 발생하는 주된 원인은 여러 스레드가 동시에 동일한 자원에 접근하기 때문임
- 잠금을 사용하면 해결되지만 대드락등 과 같은 상황이 발생 할수도 있음!
- 이를 피하는 방법은? 한 스레드만 자원에 접근하면됨
- 큐에 넣고 한 스레드로 하나씩 꺼내서 실행
- 두 스레드가 데이터 공유가 필요하면 콜백 또는 큐 같은 수단을 이용해서 복제본 또는 불변값을 공유
- 고루틴에서는 고루틴끼리 통신을 위해 채널이 존재!
- 단일 스레드를 사용하면 동시성 문제는 발생하지 않음, 그러나 성능은?
- 순차적으로 수행하니깐 성능이 나빠지지 않을까?
- 임계영역의 실행시간이 짧고 동시에 접근하는 스레드수가 적을수록 잠금을 사용하는 방식이 성능이 좋을 가능성이 높음
- 임계영역의 실행시간이 길고 동시접근 스레드수가 많을 수록 큐 or 채널 방식이 더 나은 성능을 낼 가능성이 있음