동시성, 데이터가 꼬이기 전에 잡아야 한다.
서버와 동시 실행
- 요청당 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를 동시에 조작하는경우
- 관리자와 고객이 같은 주문정보를 동시에 변경하는 경우
https://www.mermaidchart.com/play?utm_source=mermaid_live_editor&utm_medium=share#pako:eNqdkzFPwkAUx7_KSycdMLFuHRhIPwbLpTSmgwWhuhgTCMggDJhoUkghEIxxwKQBjJroF-q9fgd7V1ronYTUDh3e3fvl9393d6MY1YqpaErDvLwybcPULXJeJxdlG6KPGE61DsG6SV8WOBnExRqpO5Zh1YjtJEs9D–fwzsPSGNTgrQmN-kluYbzb7r4xI5HvzpRHwPFJUhrGaXVNPDHZTuupYJQKBZFJw1ONaC-j90Z4OgRl28pyLomjinuF6BpNIbWSxHtJOIlbtMnuloDzvxw1BewSUq9BIU_tbIM6rvh0I1bKmaGsvHhkbmGMCwN1C3t4xW7ooqwPyGKM08Sqv9MKGnlTLjfh-XjwQDbrbDtAV01g-VPbp-YEYmg2wIcdui8n8eHHyM_BiZ1tpXiJDjC8QPtvR9LSGn-bP3QLVO3tzZHZvmW7TYfziz57EAnA-bFYi9d_sQPxN7zsJK_cvsLm7IrIQ
프로세스 수준에서의 동시 접근 제어
- 동시성 문제는 프로세스 수준과 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 채널 방식이 더 나은 성능을 낼 가능성이 있음