주니어 백엔드 개발자가 반드시 알아야 할 실무 지식 ‐ 동시성, 데이터가 꼬이기 전에 잡아야 한다 - dnwls16071/Backend_Summary GitHub Wiki
서버와 동시 실행
- 서버는 클라이언트 요청을 처리하기 위해 DB에 쿼리를 보내고 그 결과를 받는다.
- 동시에 여러 클라이언트가 서버에 연결하는 만큼, 서버도 동시애 여러 쿼리를 DB에 보낸다.
- 동시에 여러 클라이언트가 요청을 보내기 때문에 서버는 각 요청을 동시에 처리해야 한다.
- 만약 동시에 처리하지 못하고 각 요청을 순차적으로 처리하게 된다면 서버 전체적인 성능, 즉 처리량과 응답 시간이 저하된다.
경쟁 상태(Race Condition)
- 여러 쓰레드가 동시에 공유 자원에 접근할 때, 접근 순서에 따라 결과가 달라지는 상황을 경쟁 상태라고 한다.
- 경쟁 상태가 발생하면 예상하지 못한 결과가 나오거나 오류가 발생할 수 있다.
프로세스 수준에서의 동시 접근 제어
잠금(Lock)을 이용한 접근 제어
- 프로세스 수준에서 데이터를 동시에 수정하는 것을 막기 위한 일반적인 방법은 잠금(Lock)을 사용하는 것이다.
- 잠금을 사용하면 공유 자원에 접근하는 쓰레드를 한 번에 하나로 제한할 수 있다.
1. 잠금을 획득한다.
2. 공유 자원에 접근한다.
3. 잠금을 해제한다.
임계 영역(Critical Section)
- 임계 영역이란 동시에 둘 이상의 쓰레드나 프로세스가 접근하면 안되는 공유 자원에 접근하는 코드 영역을 말한다.
- 공유 자원의 예시로는 메모리나 파일이 있다.
뮤텍스(Mutex)
- mutual exclusion의 줄임말로 잠금이라고 한다.
- 자바 언어는 이름이 Lock인 타입을 사용한다.
- 잠금은 한 번에 하나의 쓰레드만 획득할 수 있다.
- 여러 쓰레드가 동시에 잠금 획득을 시도하면 그 중 하나만 획득하고 나머지 쓰레드는 잠금이 해제될 때까지 대기해야 한다.
- 잠금을 획득한 쓰레드는 공유 자원에 접근한 뒤, 사용을 마치면 잠금을 해제한다.
- 잠금이 해제되면 대기 중이던 쓰레드 중 하나가 잠금을 획득해 자원에 접근한다.
public class UserSessions {
private Lock lock = new ReentrantLock();
private Map<String, UserSession> sessions = new HashMap();
public void addSession(UserSession session) {
lock.lock(); // 잠금을 획득할 때까지 대기
try {
sessions.put(session.getSessionId(), session); // 공유 자원(Map)에 접근
} finally {
lock.unlock(); // 반드시 잠금 해제(잠금을 계속 점유하게 된다면 무한 대기 상태에 빠지게 되기 때문에)
}
}
}
- ReentrantLock을 사용해서 sessions 필드에 대한 동시 접근을 제한한다.
synchronized키워드를 사용하면 간단하게 쓰레드 동시 접근을 제한할 수 있다.- 그러나 ReentrantLock을 다소 선호하는 이유는 잠금 획득 대기 시간을 지정하는 기능이 추가로 있기 때문이다.
- 원하는 기능을 구현하는 데 적당한 잠금 구현을 사용하면 된다. 단, 두 방식을 섞어서 사용하는 것을 권장하지 않는다.
세마포어(Semaphore)
- 동시에 실행할 수 있는 쓰레드 수를 제한한다.
- 자원에 대한 접근을 일정 수준 이상으로 제한하고 싶을 때 세마포어를 사용할 수 있다.
1. 세마포어에서 퍼밋을 획득(허용 가능 숫자 1만큼 감소)
2. 코드 실행
3. 세마포어에 퍼밋을 반환(허용 가능 숫자 1만큼 증가)
public class MyClient {
private Semaphore semaphore = new Semaphore(5); // 동시에 최대 5개 쓰레드까지만 실행됨
public String getData() {
try {
semaphore.acquire(); // 퍼밋 획득 시도
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
try {
String data = "...";
return data;
} finally {
semaphore.release(); // 퍼밋 반환
}
}
}
읽기/쓰기 잠금
- 잠금을 사용하면 데이터를 변경하지 않더라도 동시에 읽기가 안 된다.
- 한 번에 하나의 쓰레드만 읽기가 가능하기 때문이다.
- 한 번에 하나의 쓰레드만 읽기가 가능하므로 쓰기 빈도 대비 읽기 빈도가 높은 경우 읽기 성능이 저하되는 문제가 발생할 수 있다.
- 읽기/쓰기 잠금을 사용하면 성능상 단점을 없애면서 잠금을 통해 데이터 동시 접근 문제를 없앨 수 있다.
- 다음과 같이 4가지 읽기/쓰기 잠금 특징을 잘 알아두면 좋다.
1. 쓰기 잠금은 한 번에 하나의 쓰레드만 구할 수 있다.
2. 읽기 잠금은 한 번에 여러 쓰레드가 구할 수 있다.
3. 한 쓰레드가 쓰기 잠금을 획득했다면 쓰기 잠금이 해제될 때까지 읽기 잠금을 구할 수 없다.
4. 읽기 잠금을 획득한 모든 쓰레드가 읽기 잠금을 해제할 때까지 쓰기 잠금을 구할 수 없다.
public class RefactorUserSessions {
private ReadWriteLock lock = new ReentrantReaderWriteLock();
private Lock writeLock = lock.writeLock();
private Lock readLock = lock.readLock();
private Map<String, UserSession> sessions = new HashMap();
public void addSession(UserSession session) {
writeLock.lock(); // 쓰기 잠금 획득 시도
try {
sessions.put(session.getSessionId(), session);
} finally {
writeLock.unlock(); // 쓰기 잠금 반환
}
}
public UserSession getSession(String sessionId) {
readLock.lock(); // 읽기 잠금 획득 시도
try {
return sessions.get(sessionId);
} finally {
readLock.unlock(); // 읽기 잠금 반환
}
}
}
원자적 타입(Atomic Type)
- 잠금을 사용하면 동시성 문제를 해결할 수 있다.
- 하지만 이 방식을 사용하면 CPU 효율이 떨어진다는 단점이 있다.
- 여러 쓰레드가 동시에 실행할 때, 잠금을 확보한 쓰레드를 제외한 나머지 쓰레드가 대기하기 때문이다.
- 잠금을 사용하지 않으면서 동시성 문제없이 구현하는 방법은 원자적 타입을 적용하는 것이다.
AtomicInteger는 내부적으로 CAS 연산을 사용한다. 이를 통해 쓰레드를 멈추지 않고도 다중 쓰레드 환경에서 안전하게 값을 변경할 수 있다.- 여러 쓰레드가 동시에
count,incrementAndGet()을 호출해도 모든 쓰레드가 멈추지 않고 실행되기에 CPU 효율을 높일 수 있다.
동시성 지원 컬렉션 - 동기화된 컬렉션을 사용하기
- 쓰레드 안전하지 않은 컬렉션을 여러 쓰레드가 공유하면 동시성 문제가 발생할 수 있다.
- 이런 문제를 해결하는 방법 중 하나가 동기화된 컬렉션을 사용하는 것이다.
- 즉, 데이터를 변경하는 모든 연산에 잠금을 적용해서 한 번에 하나의 쓰레드만 접근할 수 있도록 제한하는 것이다.
- 자바의
Collection클래스는 동기화된 컬렉션을 생성하는 메서드를 제공한다. 이 메서드를 사용하면 기존 컬렉션 객체를 쉽게 동기화된 컬렉션 객체로 변환할 수 있다. - 동기화된 컬렉션 객체는 변경이나 조회와 관련된 메서드가 모두 동기화된 블럭 내부에서 실행되기 때문에 동시성 문제가 해결된다.
- 허나 이 방법도 synchronized 키워드를 사용한다는 점에서 쓰레드 대기는 발생할 수 밖에 없기 때문에 성능 저하는 감안을 해야한다.
동시성 지원 컬렉션 - 동시성 자체를 지원하는 컬렉션 타입을 사용하기
- 자바의
ConcurrentHashMap은 데이터를 변경할 때, 잠금 범위를 최소화한다.
그 외 - 불변값(Immutable Value)
- 동시성 문제를 피하기 위한 방법 중 하나는 불변 값을 사용하는 것이다.
- 불변 값은 말 그대로 값이 바뀌지 않는 값을 말한다.
- 값이 바뀌지 않기 때문에 동시에 여러 쓰레드가 접근해도 문제가 발생하지 않는다.
- 불변 값은 데이터 변경이 필요한 경우, 기존 값을 수정하는 대신 새로운 값을 생성해서 사용한다.
- 예를 들어,
CopyOnWriteArrayList는 요소를 추가하거나 삭제할 때, 매번 새로운 리스트를 생성해서 반환한다.
DB와 동시성
- DB 트랜잭션은 여러 개의 조회나 쓰기를 논리적으로 하나의 연산으로 묶는다.
- 하나의 트랜잭션에 포함된 모든 쓰기는 모두 적용되거나 모두 취소된다.
- 트랜잭션 안의 쿼리 중 하나라도 실패하면 전체 트랜잭션을 롤백함으로써 데이터가 깨지는 것을 방지할 수 있다.
- 하지만 DB 트랜잭션만으로는 동시성 문제를 해결할 수 없다.
- 대부분의 DB는 명시적인 잠금 기법을 제공한다. 이런 방식을 선점 잠금 또는 비관적 잠금이라고 부른다.
- 선점 잠금을 사용하면 동일한 레코드에 대해 한 번에 하나의 트랜잭션만 접근할 수 있도록 제어할 수 있다.
- 반면, 값을 비교해서 수정하는 방식은 비선점 잠금, 또는 낙관적 잠금이라고 하며, 쿼리 실행 자체는 막지 않으면서 데이터가 잘못 변경되는 것을 막을 수 있다.
비관적 락 & 낙관적 락
- 비관적이라고 명시하는 이유는 실패할 가능성이 높아서인 것이고, 낙관적이라고 명시하는 이유는 성공할 가능성이 높아서이다.
선점(비관적) 잠금
- 선점 잠금은 데이터에 먼저 접근한 트랜잭션이 잠금을 획득하는 방식이다.
- 선점 잠금을 획득하기 위한 쿼리는 다음 형식을 가진다.
select * from 테이블 where 조건 for update
- 이 쿼리는 조건에 해당하는 레코드를 조회하면서 동시에 잠금을 획득한다.
- 하나의 트랜잭션이 특정 레코드에 대한 잠금을 획득한 경우 잠금을 해제할 때까지 다른 트랜잭션은 동일 레코드에 대한 잠금을 획득하지 못하고 대기해야 한다.
- 레코드에 대한 잠금은 트랜잭션이 종료될 때, 반환된다.
분산 잠금
- 여러 프로세스가 동일한 자원에 접근하지 못하도록 하는 방법이다.
- 분산 잠금은 여러 프로세스 간에 잡금 처리를 한다는 점에서 차이가 있다.
- 트래픽이 많다면 레디스를 이용해 분산 잠금을 구현하는 것을 고려한다.
- 레디스를 기반으로 하는 분산 잠금 도구들이 잘 만들어져 있기 때문이다.
- 레디스를 추가로 도입해야 한다는 점이 단점이나, 이미 레디스를 사용하고 있다면 빠르게 적용할 수 있다.
비선점(낙관적) 잠금
- 비선점 잠금은 명시적으로 잠금을 사용하지 않는다.
- 대신 데이터를 조회한 시점의 값과 수정하는 시점의 값이 같은지를 비교하는 방식으로 동시성 문제를 처리한다.
- 보통 비선점 잠금을 구현할 때는 정수 타입의 버전 컬럼을 사용한다.
증분 쿼리
- 선점 잠금을 사용하면 잠금 대기 시간만큼 응답 시간이 길어지고 비선점 잠금을 사용하면 대기 시간은 없지만 변경 실패 에러가 자주 발생할 수 있다.
- 잠금을 사용하지 않으면서 해결하려면 바로 증분 쿼리를 사용한다.
update SUBJECT set joinCount = joinCount + 1 where id = ?
- DB는
joinCount = joinCount + 1을 원자적 연산으로 처리한다. - DB는 동일 데이터에 대한 원자적 연산이 동시에 실행될 경우 이를 순차적으로 처리한다.
- 따라서, 데이터가 누락되는 문제가 발생하지 않는다.
잠금 사용 시 주의사항
잠금 해제하기
- 잠금을 획득한 뒤에는 반드시 잠금을 해제해야 한다.
- 그렇지 않으면 잠금을 시도하는 쓰레드들이 무한정 대기하게 된다.
- 세마포어도 마찬가지다. 퍼밋을 획득했다면 반드시 퍼밋을 반환해야 한다.
- 만약 퍼밋을 반환하지 않게 된다면 퍼밋을 얻으려는 쓰레드는 끝없이 대기해야 한다.
lock.lock();
try {
// 코드
} finally {
lock.unlock();
}
- 잠금을 사용할 떄는 습관적으로 finally 블럭에서 잠금을 해제하는 코드를 작성하도록 하자.
대기 시간 지정하기
- 잠금 획득을 시도하는 코드는 잠금을 구할 수 있을 떄까지 계속 대기하는데 동시 접근이 많아지면 대기 시간이 길어지는 문제가 발생할 수 있다.
- 대기 시간이 길어지는 문제를 막기 위한 방법 중 하나는 대기 시간을 지정하는 것이다.
boolean acquired = lock.tryLock(5, TimeUnit.SECONDS);
if (!acquired) {
// 잠금 획득에 실패했다면?
throw new RuntimeException("Failed to acquire lock");
}
try {
// 자원 접근 코드
} finally {
lock.unlock();
}
lock.tryLock(5, TimeUnit.SECONDS)메서드는 5초 동안 잠금 획득을 시도한다.- 만약 5초 이내에 잠금을 획득했다면 true를 리턴하고 실패하면 false를 리턴한다.
- 잠금을 획득하지 못하면 자원 접근 코드를 실행하지 않고 실패 시 처리를 수행한다.
교착 상태(DeadLock) 피하기
- 교착 상태는 2개 이상의 쓰레드가 서로가 획득한 잠금을 대기하면서 무한정 기다리는 상황을 말한다.
- 교착 상태가 발생하지 안혿록 신경을 써야겠지만, 복잡한 코드 구조에서 잠금을 사용하면 개발자 자신도 모르게 교착 상태가 발생할 수 있다.
기아(starvation) 상태
- 우선순위가 높은 작업이 많아 우선순위가 낮은 작업이 실행되지 않을 수 있다.
- 또는 특정 자원을 한 프로세스가 긴 시간 동안 독점하고 있어 다른 프로세스가 자원에 접근하지 못해 이후 작업을 실행하지 못할 수도 있다.
- 이렇게 프로세스나 쓰레드가 자원을 할당받지 못해 실행되지 못하는 상태를 기아 상태라고 한다.
- 기아 상태에 빠지지 않도록 하려면 실행이 안 되고 있는 작업의 우선순위를 높이거나 여러 프로세스나 쓰레드가 공유하는 자원을 독점하는 시간에 제한을 두어 가능한 작업을 실행할 수 있도록 해야 한다.
단일 쓰레드로 처리하기
- 동시성 문제가 발생하는 주된 이유는 여러 쓰레드가 동시에 동일 자원에 접근하기 때문이다. 이를 방지하기 위해 잠금과 같은 수단을 사용한다.
- 여러 쓰레드가 동시에 접근하지 않고 하나의 쓰레드만 접근하면 애초에 동시성 문제가 발생하지 않게 된다.
- 단일 쓰레드를 사용하면 교착 상태와 같은 동시성 문제가 발생하지 않는다는 이점이 있지만 다중 쓰레드가 동시에 처리하던 것을 단일 쓰레드로 처리하게 되면 성능은 나빠지지 않을지 고민이 된다.
- 성능은 동시에 실행할 작업 개수와 임계 영역의 실행 시간에 따라 달라진다.
- 임계 영역의 실행 시간이 짧고 동시 접근하는 쓰레드 수가 적을수록 잠금을 사용하는 구현 성능이 좋을 가능성이 높다.
- 반면에 동시에 실행되는 작업이 많고 임계 영역의 실행 시간이 길어진다면 큐나 채널을 이용한 방식이 비슷하거나 더 나은 성능을 낼 가능성도 있다.