
맨 처음 멀티스레드를 학습했을 때 들었던 의문이 있습니다. "싱글 코어 CPU는 한 번에 하나의 명령만 실행하는데, 왜 락이 필요하지?" 병렬 실행이 배제된다면 충돌도 없을 테니까요.
결론부터 말하면 실제로 문제가 생기는 포인트는 "병렬로 실행되느냐"가 아닌 "작업이 중간에 중단될 수 있느냐" 입니다. 이 글에서 예시는 자바 기준으로 설명하겠지만 자바에만 한정된 내용은 아닙니다. 스레드가 번갈아 실행되고, 그 과정에서 공유 자원의 정합성이 깨지는 문제는 언어 레벨보다 더 로우 레벨의 현상입니다. 후에 소개될 자바의 synchronized, AtomicInteger, volatile을 예로 들었을 뿐, 유사한 해결법은 다른 언어에서도 존재합니다.
count++는 한 번의 동작이 아니다.
질문을 조금 바꿔보면 이렇습니다. "CPU가 명령어를 순서대로만 처리한다면, 결국 스레드 A가 끝난 뒤 스레드 B가 도는 것 아닌가? 그러면 같은 변수를 읽고 써도 괜찮은 것 아닌가?"
문제는 우리가 한 줄로 쓰는 코드가 실제로는 여러 단계라는 점입니다. 예를 들어 count++는 보통 아래 순서로 진행됩니다.
- 메모리에서 count를 읽습니다.
- 읽어온 값을 1 증가시킵니다.
- 다시 메모리에 씁니다.
겉으로는 한 줄이지만, 실제로는 읽기와 계산과 쓰기가 나뉘어 있습니다. 그리고 이 사이에 스레드 전환이 들어올 수 있습니다.
문맥 전환과 경쟁 상태
예를 들어 count가 0에서 시작한다고 해보겠습니다.
- 스레드 A가 count를 읽습니다.
- 증가 전에 스레드 A가 멈춥니다.
- 스레드 B가 실행되어 0을 읽고 1 증가시킵니다.(쓰기 포함)
- 다시 스레드 A가 실행됩니다.
- 스레드 A는 자기가 들고 있던 0을 기준으로 1을 증가시킵니다.
증가 연산은 두 번 일어났는데 결과는 2가 아니라 1입니다. 두 스레드가 물리적으로 동시에 실행된 적은 없어도 정합성은 충분히 깨질 수 있습니다.
여기서 "스레드 A, B가 들고 있던 0은 어디에 있었나?"가 한 번 더 궁금해질 수 있습니다. 보통 이런 값은 CPU 레지스터나 스레드가 이어서 사용할 실행 문맥 안에 잠시 잡혀 있습니다. 스레드 A가 멈출 때 운영체제는 "어디까지 실행했는지", "다음에 무엇을 이어서 해야 하는지", "지금 어떤 레지스터 값을 들고 있는지" 같은 상태를 저장해 둡니다. 그리고 나중에 스레드 A를 다시 실행할 때 그 문맥을 복원합니다.
그래서 스레드 A 입장에서는 "아까 읽어둔 0을 가지고 다시 이어서 계산하는" 일이 가능합니다. 하지만 그 사이에 메모리에 있는 count 값은 이미 스레드 B로 인해 1로 바꿔버렸을 수 있습니다. A는 예전 값을 기준으로 계속 실행하고, 메모리의 실제 값은 이미 달라져 있는 상태가 되는 것입니다.
싱글 코어와 스케줄링
싱글 코어에서도 운영체제는 스레드를 번갈아 실행합니다. 인터럽트가 걸릴 수도 있고, I/O 대기로 현재 작업이 멈출 수도 있고, 우선순위 때문에 다른 스레드가 먼저 실행될 수도 있습니다.
그래서 싱글 코어일지라도 문제가 되는 것은 병렬 실행 자체가 아니라 문맥 전환입니다. 읽고, 계산하고, 쓰는 작업이 끝나기 전에 다른 스레드가 같은 값에 손을 대면 앞에서 정합성에 문제가 발생합니다.
흔히 사용되는 락은 이 구간을 보호하려고 사용합니다. 어떤 코드 조각이 끝날 때까지 다른 스레드가 그 구간에 들어오지 못하게 막는 것입니다.
atomic
락을 알아보기 전에 이런 의문이 먼저 들 수 있습니다. "그럼 count++만 atomic 하게 만들면 되는 것 아닌가?"
이 질문에 대해서는 "그 연산 하나만 보면 그럴 수 있다" 정도로 답하는 편이 맞을 것 같습니다. 단일 변수에 대한 짧은 갱신은 atomic 연산으로 처리할 수 있습니다. Java의 AtomicInteger가 대표적인 예입니다.
AtomicInteger count = new AtomicInteger();
public void hit() {
count.incrementAndGet();
}
위 코드는 카운터 하나를 안전하게 올리는 데는 잘 맞습니다. 락을 잡지 않고도 증가 연산 자체를 원자적으로 처리할 수 있기 때문입니다.
복합 연산의 문제
일반적인 코드는 보통 이렇게 간단하지는 않죠. 값을 읽고, 조건을 보고, 다른 값도 함께 바꾸는 식으로 여러 단계가 묶이는 경우가 많습니다. 예를 들면 다음과 같겠네요.
if (balance >= price) { // 1. balance를 읽습니다. 2. 결제 가능한지 판단합니다.
balance -= price; // 3. 잔액을 줄입니다.
purchaseCount++; // 4.구매 횟수를 늘립니다.
}
각각의 읽기나 쓰기 연산이 원자적으로 보장되더라도, 이 코드 블록 전체가 원자적으로 실행되는 것은 아닙니다. 잔액을 확인한 뒤 실제 차감하기 전까지 다른 스레드가 끼어들 수 있기 때문입니다.
경쟁 상태 대응 방법
단일 변수의 짧은 갱신이라면 atomic 연산으로 충분한 경우가 많습니다. 반대로 여러 값을 함께 바꾸거나, 조건 확인과 상태 변경을 하나의 흐름으로 묶어야 한다면 락이 더 맞는 선택이 될 수 있습니다.
경쟁 상태를 막는 방법은 상황에 따라 여러 가지가 있습니다. 이 글에서는 비교적 자주 언급되는 몇 가지 방식만 가볍게 정리해 보겠습니다.
1. synchronized
같은 프로세스 안에서만 경쟁을 막아도 되는 상황이라면 가장 단순하게 떠올릴 수 있는 방법입니다. 다만 실무에서는 synchronized를 직접 감싸기보다, 목적에 맞는 동시성 도구나 다른 제어 수단을 선택하는 경우가 더 많습니다. 병목이 생기기 쉽고, 락을 기다리거나 포기하는 흐름을 세밀하게 제어하기 어렵기 때문입니다.
private final Object lock = new Object();
private int balance = 10000;
public void pay(int price) {
synchronized (lock) {
if (balance >= price) {
balance -= price;
}
}
2. AtomicInteger, compare-and-swap 같은 atomic 연산을 사용합니다.
단일 변수의 짧은 갱신에는 비용이 적고 효과적입니다. 카운터, 플래그, 간단한 상태 전환처럼 범위가 작은 경우에 주로 씁니다.
private final AtomicInteger hitCount = new AtomicInteger();
public void hit() {
hitCount.incrementAndGet();
}
3. 동시성 컬렉션이나 고수준 도구
ConcurrentHashMap, BlockingQueue, 세마포어 같은 도구는 경쟁 상태 제어를 직접 처리하지 않아도 되게 해 줍니다. 명시적인 락으로 제어하기보단, 이미 잘 만들어진 도구를 가져다 쓰는 편이 보통 더 단순하고 안전합니다.
private final ConcurrentHashMap<Long, Integer> scores = new ConcurrentHashMap<>();
public void addScore(Long userId, int score) {
scores.merge(userId, score, Integer::sum);
}
정리
싱글 코어라고 해서 멀티스레드 동시성 문제가 사라지지는 않습니다. 스레드는 번갈아 실행되고, 그 사이에 공유 자원에 대한 접근은 얼마든지 발생할 수 있습니다. 그래서 동시성 문제를 볼 때는 병렬 실행 여부보다, 아직 끝나지 않은 작업 사이에 다른 스레드가 들어올 수 있는지를 고려해야 합니다. 락은 그 임계 영역을 보호하기 위해 사용됩니다.
'Java > Java' 카테고리의 다른 글
| [Java] 코드 흐름을 조립하고 실패를 복구하는 방법 (0) | 2026.04.24 |
|---|---|
| [Java] 공변성으로 알아보는 제네릭과 와일드카드 (1) | 2023.12.06 |
| [Java] JWT (JSON Web Token) 개념 및 예제 코드 (2) | 2022.10.04 |
| [Java] 쓰레드 로컬 (ThreadLocal) (0) | 2022.08.26 |
| [Java] 어노테이션(Annotation), @ (0) | 2022.08.23 |