주니어 백엔드 개발자가 반드시 알아야 할 실무 지식 ‐ 동시성, 데이터가 꼬이기 전에 잡아야 한다. - 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) 상태

  • 우선순위가 높은 작업이 많아 우선순위가 낮은 작업이 실행이 안 될 수 있다.
  • 또는 특정 자원을 한 프로세스가 긴 시간동안 독점하고 있어 다른 프로세스가 자원에 접근하지 못해 이후 작업을 실행하지 못할 수 있다.
  • 이렇게 프로세스나 쓰레드가 자원을 할당받지 못한 채 실행되지 못하는 상태를 기아 상태라고 부른다.
  • 기아 상태에 빠지지 않도록 하려면 실행이 안되고 있는 작업의 우선순위를 높이거나 여러 쓰레드나 프로세스가 공유하는 자원을 독점하는 시간에 제한을 두어 우선 가능한 작업을 실행할 수 있도록 해야 한다.