동시성이슈란,
여러 쓰레드가 동시에 공유 자원에 접근하면서 발생하는 문제.
→
멀티쓰레드 환경에서 여러 쓰레드가 동시에 같은 변수나 객체에 접근해서 값을 읽거나 쓸 때,
의도하지 않은 결과(값 꼬임, 덮어쓰기, 데이터 손실 등)가 발생하는 문제이다.
간단한 예제로 알아보자.
두 개의 쓰레드는 각각 count를 1씩 10번 더해 최종 count = 20을 기대하고 있다.
public class ConcurrencyExample {
static int count = 0;
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
String threadName = Thread.currentThread().getName();
for (int i = 0; i < 10; i++) {
int before = count;
count++;
int after = count;
System.out.println(threadName + " - count: " + before + " → " + after);
try {
Thread.sleep(10); // 동시성 이슈 유도
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
Thread t1 = new Thread(task, "쓰레드-1");
Thread t2 = new Thread(task, "쓰레드-2");
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("최종 count 값: " + count);
}
}
결과를 보자면 다음과 같다.
쓰레드-2 - count: 1 → 2
쓰레드-1 - count: 0 → 1
쓰레드-1 - count: 2 → 3
쓰레드-2 - count: 3 → 4
쓰레드-1 - count: 4 → 5
쓰레드-2 - count: 5 → 6
쓰레드-2 - count: 6 → 7
쓰레드-1 - count: 6 → 7
쓰레드-2 - count: 7 → 8
쓰레드-1 - count: 8 → 9
쓰레드-2 - count: 9 → 10
쓰레드-1 - count: 9 → 10
쓰레드-2 - count: 10 → 11
쓰레드-1 - count: 11 → 12
쓰레드-1 - count: 12 → 13
쓰레드-2 - count: 13 → 14
쓰레드-1 - count: 14 → 15
쓰레드-2 - count: 15 → 16
쓰레드-1 - count: 16 → 17
쓰레드-2 - count: 17 → 18
최종 count 값: 18
두 개의 쓰레드는 공유자원 count에 접근하여 count 값을 +1 하는 작업을 한다.
결과가 위와 같이 나온 이유는
하나의 쓰레드가 작업 중인 결과가 반영되기 이전에 공유자원 count에 접근해 작업을 실행하기 때문이다.
쓰레드-2 count: 6 → 7
쓰레드-2의 작업 결과인 count = 7로 반영되기 이전에 count = 6을 가져와서 작업을 함 →
쓰레드-1 count: 6 → 7
최종 count 값을 20으로 예상했지만 동시성문제로 값이 꼬였다.
동시성 이슈를 다루는 방법
동시성 문제를 해결하기 위한 방법은 여러 가지 존재한다.
각각의 특징에 맞게 알맞게 해결하자.
| 해결 방법 | 설명 | 특징 |
| synchronized | 메서드나 블록 단위에서 락을 걸어 한 번에 하나의 쓰레드만 접근 가능하게 함 | 사용 간단, 성능은 낮을 수 있음 |
| ReentrantLock | java.util.concurrent.locks 패키지의 명시적 락 | 더 정교한 락 제어 (tryLock, interruptible 등), 락 해제 필수 |
| Atomic 클래스 | AtomicInteger, AtomicLong 등 원자성 보장 클래스 | 가벼운 동기화, CAS(compare-and-swap) 기반 |
| Concurrent Collections | ConcurrentHashMap, CopyOnWriteArrayList 등 | 동시성에 안전한 컬렉션 사용 |
| Thread-safe 클래스 사용 | 예: StringBuffer (vs StringBuilder) | 내부적으로 synchronized 처리됨 |
| Executor & ThreadPool | 쓰레드 생성, 실행을 안정적으로 관리 | 동시성 제어 + 자원 낭비 방지 |
| Volatile 키워드 | 변수의 변경을 모든 쓰레드가 즉시 보게 함 | 원자성은 보장 X, 가시성만 보장 |
위에서 본 예제 코드를 Synchronized 키워드를 이용해 해결해 보자.
package concurrency;
public class SyncConcurrencyEx {
static int count = 0;
public static synchronized void increment() {
count++;
}
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
String threadName = Thread.currentThread().getName();
for (int i = 0; i < 10; i++) {
int before = count;
increment();
int after = count;
System.out.println(threadName + " - count: " + before + " → " + after);
try {
Thread.sleep(10); // 동시성 이슈 유도
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
Thread t1 = new Thread(task, "쓰레드-1");
Thread t2 = new Thread(task, "쓰레드-2");
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("최종 count 값: " + count);
}
}
결과는 아래와 같다.
쓰레드-1 - count: 0 → 1
쓰레드-2 - count: 1 → 2
쓰레드-2 - count: 2 → 3
쓰레드-1 - count: 3 → 4
쓰레드-2 - count: 4 → 5
쓰레드-1 - count: 5 → 6
쓰레드-2 - count: 6 → 7
쓰레드-1 - count: 7 → 8
쓰레드-2 - count: 8 → 9
쓰레드-1 - count: 9 → 10
쓰레드-2 - count: 10 → 11
쓰레드-1 - count: 11 → 12
쓰레드-1 - count: 12 → 13
쓰레드-2 - count: 13 → 14
쓰레드-1 - count: 14 → 15
쓰레드-2 - count: 15 → 16
쓰레드-2 - count: 16 → 17
쓰레드-1 - count: 17 → 18
쓰레드-1 - count: 18 → 19
쓰레드-2 - count: 18 → 20
최종 count 값: 20
count ++ 는 사실 세 가지 단계로 진행된다.
1. count 읽기 (load)
2. +1 계산
3. 결과를 count에 저장 (store)
synchronized를 쓰면 한 쓰레드가 increment()를 실행하는 동안
다른 쓰레드는 그 메서드에 진입 자체를 못 하게 락을 건다.
그래서 위 3단계 연산이 원자적(atomic)으로 실행되고,
모든 증가 작업이 정확히 누적된다.
조금 더 현실적인 예제로 보자면 계좌 인출 상황이 있다.
- A와 B가 동시에 계좌 잔액을 읽음 → 둘 다 100만 원이라고 봄
- A가 50만 원 인출 → 50만 원 남음
- B도 50만 원 인출 → 또 50만 원 남음으로 착각함
- 결과적으로 100만 원 있었던 계좌가 150만 원을 갖게 된 상황 → 문제 상황
package concurrency;
public class AccountConcurrency {
static int balance = 1000000; // 100만원
public static void withdraw(String user, int amount) {
int currentBalance = balance; // 잔액 조회
System.out.println(user + " 조회 잔액: " + currentBalance);
// 인출 가능 여부 확인
if (currentBalance >= amount) {
System.out.println(user + " 인출 시도: " + amount);
try {
Thread.sleep(100); // 시간 지연: 동시성 이슈 유도
} catch (InterruptedException e) {
e.printStackTrace();
}
balance = currentBalance - amount; // 잔액 차감
System.out.println(user + " 인출 성공 후 잔액: " + balance);
} else {
System.out.println(user + " 인출 실패 (잔액 부족)");
}
}
public static void main(String[] args) throws InterruptedException {
Runnable taskA = () -> withdraw("A", 500000);
Runnable taskB = () -> withdraw("B", 500000);
Thread t1 = new Thread(taskA);
Thread t2 = new Thread(taskB);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("최종 잔액: " + balance);
}
}
결과 →
B 조회 잔액: 1000000
B 인출 시도: 500000
A 조회 잔액: 1000000
A 인출 시도: 500000
A 인출 성공 후 잔액: 500000
B 인출 성공 후 잔액: 500000
최종 잔액: 500000
대표적인 동시성 이슈로는 →
- Race Condition(경쟁상태): 실행 순서에 따라 결과가 달라짐
- Dirty Read/Write: 아직 완료되지 않은 데이터를 다른 쓰레드가 읽거나 씀
- Deadlock(교착상태): 서로 자원을 기다리며 무한 대기 상태
알아본 예제는
Race Condition이고, 동시에 Dirty Read도 발생함.
- A와 B가 동시에 balance 값을 읽고 동시에 수정하기 때문에,
"누가 먼저 실행될지 예측할 수 없고", 결과가 비정상적이 될 수 있다.
이건 Classic Race Condition이고,
그 안에서 발생하는 "잘못된 중간 상태"를 Dirty Read라고 부름.
'정리' 카테고리의 다른 글
| 스프링 Bean의 scope (0) | 2025.04.30 |
|---|---|
| 싱글톤패턴 (0) | 2025.04.29 |
| 동기(Synchronous)와 비동기(Asynchronous), blocking과 nonBlocking (0) | 2025.04.27 |
| RDBMS vs NoSQL (0) | 2025.04.26 |
| Collection정리하기 List, Set, Map (0) | 2025.04.25 |