주니어 백엔드 개발자가 반드시 알아야 할 실무 지식 ‐ 동시성, 데이터가 꼬이기 전에 잡아야 한다. - dnwls16071/Backend_Summary GitHub Wiki
📚 동시성 문제⭐
- 서버는 동시에 여러 클라이언트의 요청을 처리하며 DB도 동시에 여러 쿼리를 실행한다.
경쟁 상태(Race Condition)⭐
- 여러 쓰레드가 동시에 공유 자원에 접근할 때, 접근 순서에 따라 결과가 달라지는 현상을 경쟁 상태라 한다.
- 경쟁 상태가 발생하면 예상치 못한 결과가 나오거나 오류가 발생할 수 있다.
📚 잠금을 이용한 동시 접근 제어⭐
- 동시성 문제는 프로세스 수준과 DB 수준 모두에서 검토해야 한다.
- 프로세스 수준에서 데이터를 동시에 수정하는 것을 막기 위한 일반적인 방법은 잠금을 사용하는 것이다.
- 잠금을 사용하면 공유 자원에 접근하는 쓰레드를 한 번에 하나로 제한할 수 있다.
- 잠금은 한 번에 한 스레드만 획득할 수 있다.
- 여러 쓰레드가 동시에 잠금 획득을 시도하면 그 중 하나만 획득하고 나머지 쓰레드는 잠금이 해제될 때까지 대기하게 된다.
- 잠금을 획득한 쓰레드는 공유 자원에 접근한 뒤 사용을 마치면 잠금을 해제한다. 잠금이 해제되면 대기 중이던 쓰레드 중 하나가 잠금을 획득해 자원에 접근하게 된다.
임계 영역(Critical Section)
- 임계 영역이란, 둘 이상의 쓰레드나 프로세스가 접근하면 안 되는 공유 자원에 접근하는 코드 영역을 말한다.
synchronized와 ReentrantLock
- synchronized 키워드를 사용하면 더 간단하게 쓰레드 동시 접근을 제어할 수 있다.
- 반면 ReentrantLock은 synchronized에는 없는 잠금 획득 대기 시간 지정 기능이 있따.
- 둘 중 무엇을 사용해도 문제는 없지만 원하는 기능을 구현하는 데 적당한 잠금 구현을 사용하면 된다. 단, 혼용해서 사용하지는 않는다.
뮤텍스(Mutex)
📚 동시 접근 제어를 위한 구성 요소
- 자바에서 지원하는 ReentrantLock은 한 번에 1개의 쓰레드만 잠금을 구할 수 있다.
- 즉, 한 번에 1개의 쓰레드만 공유 자원에 접근할 수 있다. 나머지 쓰레드는 잠금이 해제될 때까지 대기해야 한다.
- 잠금 외에도 동시 접근을 제어하기 위한 구성 요소로 세마포어와 읽기/쓰기 잠금이 있다.
세마포어(Semaphore)
- 동시에 실행할 수 있는 쓰레드 수를 제한한다.
- 자원에 대한 접근을 일정 수준 이상으로 제한하고 싶을 때 세마포어를 사용할 수 있다.
- 세마포어는 허용 가능한 숫자를 이용해서 생성한다. 이런 숫자를 Java 세마포어 구현체는 퍼밋이라고 표현한다.
- 세마포어에는 이진 binary와 계수 counting 세마포어가 있다. 이진 binary는 동시에 접근할 수 있는 쓰레드가 1개인 반면, 계수 counting 세마포어는 지정한 수만큼 동시 접근이 가능하다.
- 세마포어에서 퍼밋을 구하고 반환하는 연산을 각각 P연산, V연산이라고 한다.
import java.util.concurrent.Semaphore
public class MyClient {
private Semaphore semaphore = new Semaphore(5);
public String getData() {
try {
semaphore.acquire(); // 퍼밋 획득 시도
catch (InterruptedException e) {
throw new RuntimeException(e);
}
try {
String data = ... // 외부 연동 코드
return data;
} finally {
semaphore.release(); // 퍼밋 반환
}
}
}
- 허용 개수를 5개로 지정했기 때문에 동시에 최대 5개의 쓰레드까지만 실행할 수 있다.
읽기/쓰기 잠금⭐
- 쓰기 잠금은 한 번에 한 쓰레드만 구할 수 있다.
- 읽기 잠금은 여러 쓰레드가 구할 수 있다.
- 한 쓰레드가 쓰기 잠금을 획득했다면 쓰기 잠금이 해제될 때까지 다른 쓰레드가 읽기 잠금을 구할 수 없다.
- 읽기 잠금을 획득한 모든 쓰레드가 읽기 잠금을 해제할 때까지 쓰기 잠금을 구할 수 없다.
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class UserSessionsRW {
private ReadWriteLock lock = new ReentrantReadWriteLock();
private Lock writeLock = lock.writeLock();
private Lock readLock = lock.readLock();
private Map<String, UserSession> sessions = new HashMap();
public void addUserSession(UserSession session) {
writeLock.lock();
try {
sessions.put(session.getSessionId(), session);
} finally {
writeLock.unlock();
}
}
public UserSession getUserSession(String sessionId) {
readLock.lock();
try {
return sessions.get(sessionId);
} finally {
readLock.unlock();
}
}
}
📚 원자적 타입과 동시성 지원 컬렉션⭐
- 잠금을 사용하면 카운터 증가에 대한 동시성 문제를 간단히 해결할 수 있다.
- 하지만 이 방식을 사용하면 CPU 효율이 떨어진다는 단점이 있다.
- 여러 쓰레드가 동시에 실행할 때 잠금을 확보한 쓰레드를 제외한 나머지 쓰레드는 대기하기 때문이다.
- Java에서는
Atomic~으로 시작하는 클래스가 원자적 타입에 해당한다.
- 이 타입을 사용하면 다중 쓰레드 환경에서 동시성 문제없이 여러 쓰레드가 공유하는 데이터를 변경할 수 있다.
- Java에서 지원하는 원자적 타입은 내부적으로 CAS 연산을 수행한다. 이를 통해 쓰레드를 멈추지 않고 다중 쓰레드 환경에서 안전하게 값을 변경할 수 있다.
- CAS 연산은 Compare And Swap의 약자로 이름 그대로 비교 후에 교체하는 연산을 말한다.
동시성 지원 컬렉션⭐
- 쓰레드에 안전하지 않은 컬렉션을 여러 쓰레드가 공유하면 동시성 문제가 발생할 수 있다.
- 문제를 해결하는 방법 중 하나는 동기화된 컬렉션을 사용하는 것이다. 즉, 데이터를 변경하는 모든 연산에 잠금을 적용해서 한 번에 한 쓰레드만 접근할 수 있도록 제한하는 것이다.
Map<String, String> map = new HashMap<>();
Map<String, String> syncMap = Collections.synchronizedMap(map);
syncMap.put("key1", "value1"); // put 메서드는 내부적으로 synchronized 처리됨
- 동기화된 컬렉션 객체는 변경이나 조회와 관련된 메서드가 모두 동기화된 블럭에서 진행되어 동시성 문제를 해결한다.
- 또 다른 방법은 동시성 자체를 지원하는 컬렉션 타입을 사용하는 것이다.
불변 값
- 동시성 문제를 피하기 위한 방법 중 하나는 불변 값을 사용하는 것이다.
- 값이 바뀌지 않기 때문에 동시에 여러 쓰레드가 접근해도 문제가 발생하지 않는다.
- 불변 값은 데이터 변경이 필요한 경우 기존 값을 수정하는 대신 새로운 값을 생성해서 사용한다.
- 예를 들어, Java의 CopyOnWriteArrayList는 요소를 추가하거나 삭제를 할 때마다 매번 새로운 리스트를 생성해서 반환한다.
📚 DB와 동시성: 선점 잠금(비관적)과 비선점 잠금(낙관적)⭐
- 비관적은 실패할 가능성이 높아서 비관적, 낙관적은 반대로 성공할 가능성이 높아서 낙관적.
선점 잠금(비관적 잠금)⭐
- 선점 잠금은 데이터에 먼저 접근한 트랜잭션이 잠금을 획득하는 방식이다.
- 선점 잠금을 획득하기 위한 쿼리는 다음 형식을 갖는다.
SELECT * FROM 테이블 WHERE 조건
FOR UPDATE
- 이 쿼리는 조건에 해당하는 레코드를 조회하면서 동시에 잠금을 획득한다.
- 한 트랜잭션이 특정 레코드에 대한 잠금을 획득한 경우 잠금을 해제할 때까지 다른 트랜잭션은 동일한 레코드에 대한 잠금을 획득하지 못하고 대기해야 한다.
- 레코드에 대한 잠금은 트랜잭션이 종료될 때 반환된다.
- 트랜잭션1이 먼저 데이터에 대한 잠금을 구했다.
- 트랜잭션2는 트랜잭션1이 트랜잭션을 완료해서 잠금을 반환할 때까지 대기하게 된다.
- 트랜잭션1이 끝난 뒤 트랜잭션2가 잠금을 구하게 되면 트랜잭션2는 트랜잭션1이 변경한 데이터를 조회하게 된다.
- 이는 두 트랜잭션이 동시에 같은 데이터를 수정하면서 데이터 일관성이 깨지는 문제를 방지해준다.
분산 잠금(Distributed Lock)⭐
- 분산 잠금은 여러 프로세스가 동시에 동일한 자원에 접근하지 못하도록 막는 방법이다.
- 간단한 분산 잠금이 필요한 경우 DB에서 제공하는 선점 잠금을 사용해 구현하는 편이다.
- 트래픽이 많다면 레디스를 이용해 분산 잠금을 구현하는 것을 고려해 볼 수 있다. 레디스를 기반으로 한 분산 잠금 도구들이 잘 만들어져 있기 때문이다.
- 레디스를 추가로 도입해야 하는 것이 단점이나 이미 레디스를 적용했다면 빠르게 사용이 가능하다.
비선점 잠금(낙관적 잠금)⭐
- 비선점 잠금은 명시적으로 잠금을 사용하지 않는다.
- 대신 데이터를 조회한 시점의 값과 수정하려는 시점의 값이 같은지 비교하는 방식으로 동시성 문제를 처리한다.
- 보통 비선점 잠금을 구현할 때는 정수 타입의 버전 컬럼을 사용한다.
- 버전 칼럼을 이용해서 비선점 잠금을 구현하는 방식은 다음과 같다.
📚 잠금 주의사항⭐
- 잠금을 획득한 뒤에는 반드시 잠금을 해제해야 한다.
- 그렇지 않으면 잠금을 시도하는 쓰레드가 무한정 대기하게 된다.
- 잠금을 사용할 떄에는 습관적으로 finally 블럭에서 잠금을 해제하는 코드를 작성한다.
lock.lock();
try {
// 코드
} finally {
lock.unlock();
}
대기 시간 지정⭐
- 잠금 획득을 시도하는 코드는 잠금을 구할 수 있을 때까지 계속 대기하는데, 동시 접근이 많아지면 대기 시간이 길어지는 문제가 발생할 수 있다.
- 아래 코드는
tryLock() 메서드로 5초 동안 잠금 획득을 시도하고 5초 이내에 잠금을 획득하면 true를 반환하고, 실패하면 false를 반환한다.
- 잠금을 획득하지 못하면 자원 접근 코드를 실행하지 않고 실패에 대한 처리를 수행하게 된다.
boolean acquired = lock.tryLock(5, TimeUnit.SECONDS);
if (!acquired) {
throw new RuntimeException("Failed to acqurie Lock!");
}
try {
// 자원 접근 코드
} finally {
lock.unlock();
}
교착 상태(DeadLock)⭐
- 교착 상태란, 2개 이상의 쓰레드가 서로가 획득한 잠금을 대기하면서 무한히 기다리는 상황을 말한다.
- 쓰레드1이 자원 A에 대한 잠금을 획득했고 쓰레드2는 자원 B에 대한 잠금을 획득했다.
- 현재 쓰레드1과 쓰레드2가 서로 상대방이 획득한 잠금을 무한히 대기하는 상황이 벌어지게 된다.
기아(Starvation) 상태
- 우선순위가 높은 작업이 많아 우선순위가 낮은 작업이 실행이 안 될 수 있다.
- 또는 특정 자원을 한 프로세스가 긴 시간동안 독점하고 있어 다른 프로세스가 자원에 접근하지 못해 이후 작업을 실행하지 못할 수 있다.
- 이렇게 프로세스나 쓰레드가 자원을 할당받지 못한 채 실행되지 못하는 상태를 기아 상태라고 부른다.
- 기아 상태에 빠지지 않도록 하려면 실행이 안되고 있는 작업의 우선순위를 높이거나 여러 쓰레드나 프로세스가 공유하는 자원을 독점하는 시간에 제한을 두어 우선 가능한 작업을 실행할 수 있도록 해야 한다.