[Spring] 동시성(Concurrency) 이슈 - 변수(1)
동시성 이슈란?
동시성 이슈는 한 가지라고 단정할 수 없습니다.
동시성 이슈의 종류의 예는 아래와 같습니다.
- 여러 개의 스레드가 DB를 동시에 수정할 때 생기는 문제
- 여러 개의 스레드가 하나의 변수를 공유해서 생기는 문제(공유하지 않도록 설계하는 방법)
- 하나의 변수를 공유하도록 설계했지만 데이터의 정합성이 깨지는 문제
- 갱신 분실 문제
- 선착순, 조회수, 재고 관리 등 한번에 너무 많은 요청이 예상될 때 DB의 성능으로 인해 지연되다가 트랜잭션이 실패하는 문제
전부 동시성 문제로 간주할 수 있습니다.
이제 동시성 이슈에 대해 단계적으로 살펴보도록 하겠습니다.
변수에 대한 동시성 이슈 발생 조건
변수에서 동시성 이슈가 발견되는 것은 변수가 스레드마다 공유되는 상황에서 발생합니다.
주로 다음과 같은 경우에 발생합니다.
- 멀티스레드 환경에서의 공유 변수 상태 변경
- 싱글톤 객체에서의 공유 변수 상태 변경
public class SharedCounter {
static int count = 0; // 공유 변수
public void increment() {
count++; // 동시성 이슈 발생 가능
}
}
위와 같이 static 변수나 싱글톤 객체에서 생성된 전역 변수는 공유 상태에 놓이기 때문에 동시성 이슈가 발생합니다.
공유 변수를 여러 스레드가 한번에 증감을 하려고 하면 증감의 결과가 올바르게 처리되지 않기 때문입니다.
올바르게 처리되지 않는 자세한 이유는 아래 Volatile 부분에서 소개하겠습니다.
이러한 문제는 공유 변수를 위와 같이 변수에 담아 관리하는 것이 아니라 ACID가 보장되는 Database에 삽입해서 관리하면 대부분의 문제는 발생하지 않습니다.
하지만 가끔 설계상 굳이 DB가 필요없다고 생각할 때 위처럼 변수로 백엔드 소스 내에서 관리하는 경우도 드물게 사용할 수도 있습니다.
다만 그런 측면이 아니더라도 동시성에 대한 지식도 얻을 겸 알아보겠습니다.
해결 방법
변수에 대한 동시성을 제어하기 위한 3가지 방법으로는 Synchronized, Volatile, Concurrent.Atomic 패키지 클래스 사용 방식이 있습니다.
Synchronized 키워드
int count;
public synchronized void increment() {
count++;
}
- 장점: 구현이 간단함
- 단점: 성능 저하 (한 번에 하나의 스레드만 접근 가능)
synchronized가 사용된 메서드는 하나의 스레드가 사용하고 있으면 다른 스레드는 사용하지 못합니다.
다른 스레드는 그 동안 기다려야 하기 때문에 속도 문제라는 너무 뚜렷한 단점이 존재하는 방식입니다.
Volatile 키워드
public class Test {
volatile int a = 0;
}
Volatile 변수는 가시성을 지켜주는 변수입니다.
컴퓨터 용어로 쓰이는 메모리 가시성(Memory Visibility)은 한 스레드에서 변수의 값이 변경되면 다른 스레드가 그 변경된 값을 즉시 인식할 수 있는지를 의미합니다.
위에서 설명한 static 변수는 하나의 스레드에서 변수의 값을 변경하면 다른 스레드가 알지 못하는 문제가 발생합니다.
이게 무슨 말인지 자세하게 알아봅시다.
예를 들어 A, B 사용자가 같은 데이터를 수정하고 있다고 가정하겠습니다.
이때, A 사용자가 먼저 업데이트를 하더라도 B 사용자는 A 사용자로 인해 업데이트된 것을 알지 못합니다.
즉, 가시성이 없다는 것 입니다.
boolean running = true;
public static void main(String[] args) {
new VolatileTest().test();
}
public void test() {
new Thread(() -> {
int count = 0;
while (running) { // CPU Cache에서 꺼내므로 아래의 Thread가 값을 바꾸더라도 무한 반복에 빠진다.
count++;
}
System.out.println("Thread 1 finished. Counted up to " + count);
}
).start();
new Thread(() -> {
try {
Thread.sleep(100);
} catch (InterruptedException ignored) {
}
System.out.println("Thread 2 finishing");
running = false;
}
).start();
}
좋은 코드 예제가 있어서 한번 확인해보겠습니다.
1. 두 개의 스레드가 동시에 작동합니다.
2. 첫 번째 스레드는 while(true)로 인해 무한 루프가 작동합니다.
3. 두 번째 스레드는 running의 값을 false로 바꿔서 무한 루프 작동을 멈추려고 시도합니다.
4. 하지만 첫 번째 스레드는 두 번째 스레드에서 변화된 값을 인지하지 못합니다.
위에 그림은 Java가 변수를 어떻게 처리하는지에 대한 메모리 모델입니다.
Java는 기본적으로 어떠한 요청이 들어오면 메인 메모리(RAM)에서 실제 값인 데이터를 꺼내 Heap 혹은 Stack 자료 구조에 저장한 후에 CPU에서 캐시 해서 사용합니다.
CPU에서 캐시 되는 문제로 인해 위의 예제 코드에서는 캐시 된 데이터를 들고 오는 현상이 발생됩니다.
그래서 변화된 값을 인지하지 못하는 것이죠.
하지만 volatile 변수는 CPU에서 캐시값을 사용하지 않습니다.
volatile은 한글로 "휘발성"이라는 뜻을 가집니다.
volatile이라는 의미는 CPU를 거치지 않고 휘발성 메모리(RAM)를 이용해 변수를 처리하겠다는 의미입니다.
위의 코드에서 running 변수를 volatile로 변경하면 캐시를 이용하지 않고 바로 메모리에 있는 데이터를 접근하기 때문에 첫번째 스레드는 무한루프가 종료됩니다.
그렇다면 모든 변수를 volatile로 만들면 되지 않을까요? 그렇지 않습니다.
변수는 JVM이 최적화를 해서 CPU에서 캐쉬를 해야 접근도 빠르기 때문입니다.
volatile로 만든 변수는 JVM이 최적화 처리하지 않습니다.
volatile은 그럼 동시성 문제를 해결해 주는 키워드일까요? 그렇지 않습니다.
volatile은 가시성을 지켜주는 것이지만 원자성을 지켜주지는 않습니다.
이제 원자성이 지켜지지 않는 예제를 살펴보겠습니다.
private volatile int count = 0; // volatile로 선언
public static void main(String[] args) {
new VolatileTest2().test();
}
public void test() {
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
count++; // 원자성이 보장되지 않음
}
System.out.println("Thread 1 finished.");
});
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
count++; // 원자성이 보장되지 않음
}
System.out.println("Thread 2 finished.");
});
thread1.start();
thread2.start();
try {
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Final count: " + count);
}
이제 변수가 volatile로 설정되어 있고 각 스레드에서 count를 10000을 더 하는 로직입니다.
결과적으로 데이터는 메모리에 있는 데이터로 접근하지만(가시성) count는 20000이 보장되지 않을 때가 있습니다.
원자성은 특정 연산이 중간에 끊기지 않고 완전히 수행된다는 것을 의미합니다.
예를 들어, 스레드 A가 count 값을 읽고 5를 가져왔다고 가정합시다.
그 사이에 스레드 B가 count 값을 6으로 증가시키면, 스레드 A가 count에 6을 쓴다고 해도 결과적으로 count는 6이 됩니다.
즉, 스레드 A의 작업은 B의 작업으로 인해 무효화될 수 있습니다.
그러니 A의 작업은 연산이 수행되지 않았다고 보기 때문이 원자성이 없는 것 입니다.
하지만 위에서 설명한 synchronized는 원자성과 가시성을 보장합니다.
다만, 속도가 느린 것뿐입니다.
이제 원자성과 가시성이 전부 필요한 경우에 어떤 방식이 권장되는지 살펴보겠습니다.
Atomic 클래스(권장)
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // 원자적 연산
}
Atomic 클래스에서는 CAS(Compare-And-Swap) 알고리즘을 사용해서 원자성을 보장합니다.
그렇기 때문에 위의 예제에서 20000이라는 값은 보장될 수 있습니다.
CAS(Compare-And-Swap) 알고리즘의 특징
CAS 알고리즘은 Current Value와 Expected Value를 사용합니다.
- CAS 연산이 시작되기 전에 연산의 대상이 되는 값을 Expected Value에 저장합니다.(예를 들어 5)
- 마지막에 데이터를 쓰기(저장) 직전에 메인 메모리에서 데이터를 읽어와서 Current Value에 담습니다.(예를 들어 6)
- 두 개의 값을 비교해서 값이 같지 않은 경우에는 어디선가 한번 업데이트가 이루어졌다고 판단할 수 있기 때문에 이 경우 CAS 연산은 실패하고 아무런 변경도 이루어지지 않습니다.
- 실패한 스레드는 바쁜 대기(Busy Waiting) 상태가 되어 성공적인 수행이 가능할 때까지 무한 반복합니다.
원자성에 대한 상상
조금 말이 복잡한데요.
CAS 연산은 말 그대로 "비교와 쓰기 작업을 한 번에 수행"하는 특징이 있습니다.
이 말이 무슨 말인지 반대 사례를 통해 원자성이 무너지는 상상을 해보겠습니다.
만약 Expected Value가 5고 Current Value도 5라고 가정하겠습니다.
그럼 둘의 값은 같으니 업데이트를 할 수 있게 됩니다.
하지만 같음을 비교하고 바로 어디선가 실제 메모리에 담긴 데이터를 5를 6으로 증감시키는 상상도 충분히 할 수 있는데요.
그런 상황에서 5라는 데이터를 6으로 업데이트하려고 하면 이전처럼 당연히 원자성이 깨지게 됩니다.
하지만 Compare-And-Swap이라는 말은 이 2개의 작업을 단 하나의 동작으로 묶어서 동작한다는 의미입니다.
쉽게 말해 "같으면 업데이트를 한다"는 2가지 동작이지만 "같으면"까지 검사를 했는데 그 사이에 다른 스레드가 6으로 업데이트하는 경우가 발생하지 않는다는 의미입니다.
근데 두 개의 동작을 하나의 동작으로 불가분의 관계로 연산을 하고 다른 작업이 방해하지 않도록 한다는 의미는 마치 '락'을 의미하는 것 같지 않나요?
여기 오해를 할 수 있는 부분은 여기서는 '락'이라는 표현을 쓰지 않습니다.
락은 소프트웨어 개념에서 설명되는 것이고 CAS는 하드웨어 레벨에서 이루어지는 연산이기 때문에 락이라는 표현을 사용하지 않습니다.
CAS 명령어는 CPU 차원에서 제공되는 기능입니다.
CAS 연산을 수행할 때 위처럼 Compare-And-Swap의 과정을 진행하는 동안 방해를 받지 않는 원자적 특성이 있습니다.
멀티코어 CPU 환경에서는 MESI(Modified, Exclusive, Shared, Invalid) 같은 캐시 일관성 프로토콜을 통해, 하나의 CPU 코어가 특정 메모리 주소에 대한 CAS 작업을 진행 중일 때 다른 코어들이 이 메모리에 접근하지 못하도록 제한합니다.
또 메모리 장벽(Memory Barrier)을 이용해 CAS 연산이 중단 없이 완전히 끝날 때까지 다른 메모리 작업이 간섭하지 않도록 순서를 강제합니다.
이러한 특징들로 인해 '락'처럼 보이는 연산이 가능해집니다.
CPU가 모든 계산을 원자성을 보장하도록 연산하지는 않습니다.
Java에서 일반적인 변수도 원래 CPU의 ALU가 처리하지만 이건 원자성이 보장되지 않죠.
이건 CPU가 가진 명령어인 CAS만이 가진 장점이라고 볼 수 있습니다.
CAS도 단점은 존재합니다.
바쁜 대기 현상(실패 발생 시)에서는 무한루프를 돌기 때문에 자원 소모량이 크다는 단점도 있고 ABA 문제라고 해서 값이 A에서 B로 변경된 후 다시 A로 돌아오면 CAS는 성공으로 판단하는 현상으로 인해 예상하지 못한 상태가 발생할 수 있습니다.
이 문제를 해결하기 위해 AtomicStampedReference 클래스를 사용하여 "버전 번호"와 함께 사용하기도 합니다.
ConcurrentHashMap(권장)
만약에 변수가 HashMap으로 되어있다면 ConcurrentHashMap 클래스를 사용합니다.
ConcurrentHashMap은 자바 8 이전에는 락 스트라이핑 방식인 세그먼트 락을 이용했다면
지금은 CAS를 사용합니다.
Volatile은 왜 존재할까?
Atomic과 같은 좋은 기능이 있는데 왜 Volatile이 존재할까요?
volatile 키워드는 메모리 가시성만을 보장하며, 원자성은 보장하지 않습니다.
따라서 단순히 최신 상태를 다른 스레드에서 볼 수 있어야 하지만 원자적 연산이 필요하지 않은 경우에 사용합니다.
예를 들어, 플래그(예: boolean running = true)와 같이 단순한 상태 변경이 필요한 경우에 volatile을 사용하면 충분합니다.
굳이 원자성을 지킬 이유는 없을 때 사용합니다.
Synchronized는 왜 존재할까?
락을 통해 블록 내부의 모든 연산을 원자적으로 수행하고, 블록을 빠져나올 때 다른 스레드가 변경 사항을 볼 수 있도록 보장합니다.
이 때문에 synchronized는 Atomic 클래스와 달리 여러 연산이 묶인 복잡한 연산에서도 원자성을 보장하는 데 유용합니다.