서버가 동시에 여러 클라이언트의 요청을 처리하는 방식에는 크게 두가지가 있다.
→ 어떤 방식이던 서버는 동시 실행이 기본이므로 동시성 문제를 염두해두고 설계하자
경쟁상태
경쟁 상태는 여러 스레드가 동시에 공유 자원에 접근 할 때 접근 순서에 따라 결과가 달라지는 상황을 말한다.
→ 동시성 문제는 프로세스 수준, DB 수준에서 동시에 발생한다. 두 가지의 경우 어떻게 접근해야하는지 알아보자.
Lock 을 사용하면 공유 자원에 접근하는 스레드를 하나로 제한할 수 있다.
로직은 다음과 같다.
잠금을 획득
공유 자원에 접근 ( 임계 영역 )
임계영역이란?
임계영역은 동시에 둘 이상의 프로세스나 스레드가 접근하면 안되는 공유자원에 접근하는 코드 영역
잠금을 해제함.
JAVA 의 HashMap 은 다중 스레드 환경에서 안전하지 않다. ( 동시에 여러 스레드가 put() 과 같은 메서드를 호출하면 데이터의 유실이 일어날 수 있으므로)
이런 문제를 방지하려면 ReentrantLock 을 사용해서 임계 영역에 한번에 한 스레드만 접근할 수 있도록 제한하여야 한다.
synchronized 또한 고려할 수 있다. @synchronized 하면 해당 어노테이션이 있는 코드 블록을 제어하고 코드 블록이 끝나면 자동으로 잠금을 풀어주기 때문이다.
하지만 ReentrantLock 은 잠금 획득 대기 시간을 지정하는 기능 등의 기능이 있따.
또한 JAVA 21 버전에 가상 스레드가 ReentrantLock 만 지원하고 synchronized는 자바 24 버전부터 지원한다.
다만 두 방식을 섞어 쓰지는 말자. 가능하면 하나만 사용하자
읽기 쓰기 잠금 (Read-Write Lock)이란?
일반적인 잠금(Lock이나 synchronized)은 공유 자원에 접근하는 스레드를 한 번에 단 하나로 제한합니다. 이 방식은 데이터의 무결성(integrity)을 보장하지만, 데이터를 변경하지 않는 단순한 읽기 작업마저도 동시에 수행될 수 없게 합니다.
문제점: 만약 데이터에 대한 쓰기(변경) 빈도보다 읽기(조회) 빈도가 훨씬 높다면, 한 번에 하나의 스레드만 읽기를 수행할 수 있으므로 전체적인 읽기 성능이 떨어지는 문제가 발생합니다.
해결책: 읽기 쓰기 잠금은 이러한 성능상의 단점을 없애기 위해 고안된 동시 접근 제어 방식입니다. 이 잠금은 쓰기(데이터 변경)와 읽기(데이터 조회) 작업을 분리하여 동시성을 관리합니다.
읽기 쓰기 잠금의 핵심 특징 (성능 개선 원리)
읽기 쓰기 잠금은 다음과 같은 규칙을 가집니다:
◦ 배타적 접근: 한 번에 하나의 스레드만 쓰기 잠금을 획득할 수 있습니다. (데이터를 변경할 때는 오직 혼자서만 작업해야 데이터가 꼬이지 않기 때문입니다.)
◦ 공유 접근: 한 번에 여러 스레드가 읽기 잠금을 획득할 수 있습니다. (데이터를 조회만 하는 경우 여러 명이 동시에 봐도 문제가 되지 않습니다.)
◦ 어떤 스레드가 쓰기 잠금을 획득한 상태라면, 이 잠금이 해제될 때까지 다른 스레드는 읽기 잠금을 구할 수 없습니다. (쓰는 동안에는 읽을 수 없음).
◦ 어떤 스레드라도 읽기 잠금을 획득한 상태라면, 모든 읽기 잠금이 해제될 때까지 쓰기 잠금을 구할 수 없습니다. (읽는 동안에는 쓸 수 없음).
이러한 특징 덕분에 읽기 쓰기 잠금을 사용하면 동시에 여러 스레드가 데이터를 읽을 수 있게 되어, 일반 잠금을 사용했을 때 발생하는 읽기 성능 저하 문제를 완화할 수 있습니다
스레드에 안전한지 않은 컬렉션을 여러 스레드에 공유하면 동시성 문제가 발생한다.
자바에서는 Collections.synchronized 와 같이 동기화된 컬렉션을 생성하는 메서드를 제공한다.
단, 자바 23 또는 이전 버전 기준으로 가상 스레드를 사용한다면 해당 동기화 컬렉션 변환 메서드를 사용하면 안된다. 내부적으로 synchronized 를 사용해서 동시 접근을 동기화 하기 때문이다. → 성능에 문제가 생길 수 있다.
다른 방법으로는 동시성 자체를 지원하는 컬렉션 타입을 사용하는 것이다.
ConcurrentHashMap 과 같은 타입은 잠금 범위를 최소화하므로 키의 해시 분포가 다르고 동시 수정이 많으면 더 나은 성느을 제공한다.
잠금을 해제 하지 않으면 잠금을 시도하는 스레드가 무한정 대기하게 되므로 try-finally 형태의 코드를 사용하여 작므을 해제하는 코드를 넣는 습관을 들이자
잠금 해제 시간을 정하면 사용자에게 잠금 획득 실패 에러를 빠르게 전달할 수 있다.
대기 시간은 교착 상태를 피하는데도 효과를 볼 수 있다.
또한 지정한 순서대로 잠금을 획득하게 할 수 도 있다. 모든 스레드가 트랜잭션에서 같은 순서로 잠금을 획득한다는 규칙을 만들면 교착상태를 피하는데 도움을 준다.
동시성이 발생하는 이유는 여러 스레드가 동시에 동일 자원에 접근하기 때문이므로 차라리 한 스레드만 상태를 관리하게 하고 그 스레드를 사용할 수 있는 큐를 만드는 것이 더 나을 수 있음
두 스레드의 데이터 공유가 필요하면 콜백이나 큐와 같은 수단을 이용해서 데이터 복제본을 공유하게 할 수 있음
→ 성능에 문제가 될 수 있으나 임계 영역의 실행 시간이 짧고 동시 접근 스레드 수가 적을수록 잠ㄱ믕르 사용하는 구현 성능이 좋을 가능성이 높음
왜냐하면 큐나 채널을 사용하는데 드는 시간보다 잠금 획득과 해제에 드는 시간이 더 짧기 때문에
하지만 동시에 실행되는 작업이 많고 임계 영역의 실행 시간이 길어지면 큐나 채널을 이용한 방식이 더 비슷하거나 나은 성능을 낼 가능성이 있음