1. 공유 자원이란 무엇인가
공유 자원은 이미 일상에서 접해 보셨을 겁니다. 가족이 함께 사는 집은 그 가족의 공유 자원입니다. 사무실 냉장고는 모든 동료의 공유 자원입니다. 느낌이 오시죠.
프로그래밍에서 공유 자원이란 여러 스레드가 동시에 접근할 수 있는 변수, 객체, 또는 데이터 구조를 말합니다. 예를 들어:
- 처리된 주문 수를 세는 카운터 변수.
- 어떤 스레드들은 채우고 다른 스레드들은 처리하는 요청 목록.
- 여러 스레드가 동시에 쓰기 작업을 하는 열린 파일.
- 프로그램의 서로 다른 부분이 사용하는 데이터베이스 연결.
Java에서는 여러 스레드가 접근할 수 있는 모든 객체나 변수는 잠재적으로 “공유 자원”이 됩니다.
공유 자원 예: 전역 카운터
public class Counter {
public int value = 0;
}
여러 스레드가 이 카운터를 증가시킨다면, 모두 동일한 변수 value에 접근하게 됩니다 — 이것이 바로 공유 자원입니다.
2. 동시 접근의 문제
단일 스레드 프로그램에서는 간단합니다. 하나의 스레드 — 하나의 실행자 — 기차가 레일을 달리듯 차례대로 코드를 따라갑니다. 하지만 스레드가 여러 개가 되는 순간 진짜 “칼춤”이 시작됩니다. 스레드들이 가장 예상치 못한 지점에서 서로의 작업에 끼어들 수 있기 때문입니다.
Race condition(경쟁 상태)
Race condition은 프로그램의 결과가 스레드들의 동작이 “어떻게 뒤섞였는지”에 따라 달라지는 상황을 말합니다. 즉, 같은 프로그램을 여러 번 실행해도 결과가 매번 다를 수 있습니다 — 이것은 버그라기보다 멀티스레딩의 “특성”입니다.
고전적인 예: 두 스레드가 카운터를 증가
간단한 상황을 모델링해 봅시다. 공유 카운터가 있고, 두 스레드가 각각 천 번씩 그 값을 증가시킵니다.
public class Counter {
public int value = 0;
}
public class CounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable incrementTask = () -> {
for (int i = 0; i < 1000; i++) {
counter.value++; // 위험한 부분!
}
};
Thread t1 = new Thread(incrementTask);
Thread t2 = new Thread(incrementTask);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("기대값: 2000");
System.out.println("실제 값: " + counter.value);
}
}
화면에는 무엇이 표시될까요?
어쩔 때는 2000, 어쩔 때는 1985, 또 어떤 때는 1937... 왜 그럴까요? counter.value++ 연산은 원자적이지 않기 때문입니다! 이 연산은 세 단계로 이루어집니다:
- 현재 counter.value 값을 읽는다.
- 그 값을 1 증가시킨다.
- 다시 기록한다.
두 스레드가 동시에 같은 값을 읽고, 둘 다 증가시킨 뒤, 둘 다 그 결과를 기록하면 — 증가 중 하나가 “사라집니다”. 이것이 바로 lost update — 업데이트 손실입니다.
일관성 없는 객체 상태
여러 필드로 구성된 복잡한 객체에서 스레드들이 각기 다른 필드를 동시에 변경하면, 객체는 “이상한” 혹은 일관성 없는 상태가 될 수 있습니다. 예를 들어, 계좌 잔액은 줄었는데 거래 내역은 갱신되지 않았다면 — 고객은 당황하고, 회계 담당자는 충격을 받습니다.
3. 왜 동기화가 필요한가
동기화는 프로그램에 이렇게 말하는 방법입니다. “멈춰! 이 코드 조각은 동시에 오직 한 스레드만 실행해야 해! 나머지는 기다려!” 화장실 문에 “청소 중! 출입 금지!” 표지판이 붙어 있는 것과 같습니다. 한 사람 안에 있는 동안 다른 사람들은 밖에서 기다립니다(그리고 안에서 오래 머무는 사람을 마음속으로 원망하죠).
데이터 무결성 보장
카운터가 항상 올바르게 증가하도록 하려면, 여러 스레드가 그 값을 동시에 변경하지 못하도록 해야 합니다.
예: 카운터 증가 동기화
public class Counter {
public int value = 0;
public synchronized void increment() {
value++;
}
}
이제 두 스레드가 increment()를 호출하더라도, 같은 시점에는 오직 하나의 스레드만 이 메서드를 실행할 수 있습니다. 다른 하나는 첫 번째가 끝날 때까지 기다립니다.
도식: 동기화 시 무슨 일이 일어나는가
+-------------------+
| 스레드 1 | --\
+-------------------+ \
| \
V \
+-------------------+ > [ synchronized increment() ]
| 스레드 2 | --/ /
+-------------------+ / /
| / /
V / /
+-------------------+ / /
| 스레드 3 | --/ /
+-------------------+ /
| /
V /
+-------------------+ /
| 스레드 N |/
+-------------------+
모든 스레드는 보호된 코드 구간을 실행하기 위해 대기열에 섭니다. 한 번에 오직 하나의 스레드만 “임계 구역”( synchronized 블록) 안에 있을 수 있습니다.
4. 동기화 방식 간단 소개
Java의 동기화는 단 하나의 방법이 아니라, 공유 자원을 동시 접근으로부터 보호하기 위한 “도구 모음”입니다.
키워드 synchronized
Java의 기본 동기화 도구입니다. 두 가지 방식으로 사용할 수 있습니다:
동기화된 메서드
public synchronized void increment() {
value++;
}
동기화된 블록
public void increment() {
synchronized (this) {
value++;
}
}
여기서 this는 락이 걸리는 객체입니다. 한 스레드가 이 블록을 실행하는 동안, 같은 객체에 대해 동일한 블록에 들어오려는 다른 스레드는 기다리게 됩니다.
java.util.concurrent의 특화 클래스
- Lock, ReentrantLock — synchronized의 보다 유연한 대안.
- ReadWriteLock — 읽기 락과 쓰기 락을 분리.
- Semaphore — 동시에 코드를 실행할 수 있는 스레드 수를 제한.
- CountDownLatch, CyclicBarrier 등 — 스레드 작업을 조율.
중요: 오늘은 기초만 살펴봅니다 — 이 클래스들은 나중에 더 자세히 이야기하겠습니다.
5. 실습 예제: 멀티스레드 카운터 애플리케이션
어떤 서비스에 대한 사용자 호출 통계를 만든다고 가정해 봅시다. 각 스레드는 사용자 하나를 나타내며, 공용 카운터를 증가시킵니다.
동기화 없이
public class Counter {
public int value = 0;
}
public class MultiThreadCounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable user = () -> {
for (int i = 0; i < 10000; i++) {
counter.value++;
}
};
Thread t1 = new Thread(user);
Thread t2 = new Thread(user);
Thread t3 = new Thread(user);
t1.start();
t2.start();
t3.start();
t1.join();
t2.join();
t3.join();
System.out.println("기대값: 30000");
System.out.println("실제 값: " + counter.value);
}
}
결과: 거의 항상 30000보다 작습니다. 때로는 훨씬 작습니다! 왜냐하면 스레드들이 서로의 작업을 “끼어들어” 덮어쓰기 때문입니다.
동기화: 오류를 고치기
public class Counter {
public int value = 0;
public synchronized void increment() {
value++;
}
}
public class MultiThreadCounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable user = () -> {
for (int i = 0; i < 10000; i++) {
counter.increment();
}
};
Thread t1 = new Thread(user);
Thread t2 = new Thread(user);
Thread t3 = new Thread(user);
t1.start();
t2.start();
t3.start();
t1.join();
t2.join();
t3.join();
System.out.println("기대값: 30000");
System.out.println("실제 값: " + counter.value);
}
}
결과: 항상 30000입니다. 와, 동기화가 작동합니다!
6. 유용한 포인트
시각화: race condition은 어떻게 보이나
두 스레드가 어떻게 인크리먼트를 “잃어버리는지” 보여 주는 작은 표를 그려 봅시다:
| 단계 | 스레드 1 | 스레드 2 | value 값 |
|---|---|---|---|
| 1 | value=0 읽음 | 0 | |
| 2 | value=0 읽음 | 0 | |
| 3 | 1로 증가 | 0 | |
| 4 | 1로 증가 | 0 | |
| 5 | 1 기록 | 1 | |
| 6 | 1 기록 | 1 |
언제 동기화가 필요한가
동기화가 항상 필요한 것은 아닙니다. 변수가 자기만의 작은 세계에서 오직 하나의 스레드만 다룬다면 — 긴장 풀어도 됩니다. 하지만 그 변수를 다른 스레드와 공유하는 순간, 동기화 없이는 안 됩니다. 설령 “괜찮을 것 같다”고 느껴져도 — 믿지 마세요. 경쟁 상태는 교활합니다. 오랫동안 숨어 있다가 가장 부적절한 순간에 불쑥 터집니다.
앞으로 다룰 내용: 또 어떤 동기화 수단이 있나
오늘은 기본 도구인 synchronized만 살짝 맛봤습니다. 다음 강의에서는 다음을 다룹니다:
- 객체 모니터가 어떻게 동작하는지와 락의 유형.
- 정적 동기화 메서드(static + synchronized).
- volatile 키워드의 동작 방식과 필요성.
- 현대적인 동기화 클래스들(Lock, Semaphore 등).
7. 공유 자원 다룰 때 흔한 실수
실수 №1: 멀티스레딩을 무시함.
가장 흔한 실수 중 하나는 변수가 여러 스레드에서 접근될 수 있다는 사실을 고려하지 않는 것입니다. 지금은 프로그램이 단일 스레드더라도, 나중에 누군가 스레드를 추가하면 — 버그가 “어디선가” 나타납니다.
실수 №2: 부족하거나 과도한 동기화.
공유 자원 접근을 동기화하지 않으면 race condition과 일관성 없는 데이터가 생깁니다. 반대로 전부 다 동기화하면, 락으로 “숨이 막혀” 프로그램이 느려집니다. 항상 정말 필요한 부분만 신중하게 동기화하세요.
실수 №3: 잘못된 객체에 동기화.
서로 다른 객체에 대해 동기화(예: 지역 변수나 문자열 리터럴에 대해)하면 공유 자원을 보호할 수 없습니다. 모든 스레드는 동일한 객체에 대해 동기화해야 합니다.
실수 №4: 비원자 연산에서 원자성을 기대함.
i++는 원자적이지 않습니다! 변수에 volatile을 선언하더라도 인크리먼트가 원자적이 되지는 않습니다. 이러한 연산에는 동기화가 필요합니다.
실수 №5: “운 좋게도 내 환경에서는 잘 된다”.
Race condition은 내 컴퓨터에서는 드러나지 않을 수 있지만, 서버나 사용자 환경에서는 반드시 드러납니다. 멀티스레드 프로그램에서 “설마”에 기대지 마세요!
GO TO FULL VERSION