좋아요! 🔥 이제 Thread/동기화 트릭을 하나 준비해 드릴게요.
이 부분은 정보처리기사 실기보다는 심화된 자바 동기화 트릭에 가깝지만, 자바의 동작 원리를 깊이 이해하는 데 도움이 돼요.
이 트릭 문제를 풀면서 synchronized 블록, 정적 메서드 동기화, 인스턴스 동기화의 차이, race condition을 모두 잡을 수 있습니다. 😄
class Counter {
static int count = 0;
synchronized void increment() {
count++;
System.out.println(Thread.currentThread().getName() + " increment: " + count);
}
static synchronized void staticIncrement() {
count++;
System.out.println(Thread.currentThread().getName() + " staticIncrement: " + count);
}
}
public class Test {
public static void main(String[] args) throws InterruptedException {
Counter c1 = new Counter();
Counter c2 = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 3; i++) {
c1.increment(); // 트릭 1: c1 인스턴스에 동기화
c2.staticIncrement(); // 트릭 2: 클래스 레벨 동기화
}
}, "T1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 3; i++) {
c2.increment(); // 트릭 3: c2 인스턴스에 동기화
c1.staticIncrement(); // 트릭 4: 클래스 레벨 동기화
}
}, "T2");
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("최종 count = " + Counter.count);
}
}
✅ synchronized가 인스턴스 메서드에 있으면 인스턴스별로 락
✅ static synchronized가 있으면 클래스 단위 락
✅ 서로 다른 인스턴스에서 synchronized 호출해도 충돌 없음
✅ static synchronized 호출은 전역적으로 하나만 호출 가능
✅ 출력값의 순서가 실행 환경마다 달라질 수 있음 → 하지만 마지막 count 값 계산 트릭
✅ count를 증가시키는 메서드가 6번 호출되므로 증가량 계산 트릭
각 스레드가 3번씩 아래를 호출:
increment() → 인스턴스 락 (c1과 c2가 달라 병렬 가능)staticIncrement() → 클래스 락 (동시에 호출 불가, 반드시 교대로 호출)최종적으로 count++가 호출되는 횟수:
12번 호출됨.T1 increment: 1
T1 staticIncrement: 2
T2 increment: 3
T2 staticIncrement: 4
T1 increment: 5
T1 staticIncrement: 6
T2 increment: 7
T2 staticIncrement: 8
T1 increment: 9
T1 staticIncrement: 10
T2 increment: 11
T2 staticIncrement: 12
최종 count = 12
⚠️ 출력 순서 자체는 스케줄러 상황에 따라 바뀜
⚠️ 하지만 최종 count 값은 반드시12
✅ 인스턴스 동기화(synchronized 메서드): 같은 객체에서만 락. 다른 인스턴스는 동시 호출 가능.
✅ 정적 동기화(static synchronized): 클래스 전체를 한 스레드만 호출 가능.
✅ 호출 순서가 섞여도, 동기화 때문에 데이터 정합성 보장 → race condition 없음
✅ 출력값 순서는 동기화 범위마다 다르므로 예측 불가하지만, count의 최종 값은 확정적
좋아요. 😄 방금 코드를 완전히 디버깅하듯 한 줄 한 줄 상황 보고 형식으로, 처음 보는 초보자도 이해할 수 있는 수준으로 꼼꼼히 설명해 드릴게요!
class Counter {
static int count = 0;
synchronized void increment() {
count++;
System.out.println(Thread.currentThread().getName() + " increment: " + count);
}
static synchronized void staticIncrement() {
count++;
System.out.println(Thread.currentThread().getName() + " staticIncrement: " + count);
}
}
public class Test {
public static void main(String[] args) throws InterruptedException {
Counter c1 = new Counter();
Counter c2 = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 3; i++) {
c1.increment();
c2.staticIncrement();
}
}, "T1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 3; i++) {
c2.increment();
c1.staticIncrement();
}
}, "T2");
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("최종 count = " + Counter.count);
}
}
Counterstatic int count = 0;
→ 공유되는 정적 변수: 클래스가 하나이므로 어디서든 이 하나의 count를 공유.
synchronized void increment()this)에 락을 건다.c1.increment() 호출 중이면 c1 객체를 쓰는 다른 스레드가 increment 호출할 수 없음.static synchronized void staticIncrement()Counter.class) 자체에 락을 건다.Counter c1 = new Counter();
Counter c2 = new Counter();
→ 이제 c1, c2 두 개의 다른 인스턴스가 있음.
Thread t1 = new Thread(() -> {
for (int i = 0; i < 3; i++) {
c1.increment(); // c1 객체 락
c2.staticIncrement(); // 클래스 락
}
}, "T1");
t1이 할 일:
c1.increment() → c1 인스턴스 락을 잡고 count++ 출력c2.staticIncrement() → 클래스 락을 잡고 count++ 출력Thread t2 = new Thread(() -> {
for (int i = 0; i < 3; i++) {
c2.increment(); // c2 객체 락
c1.staticIncrement(); // 클래스 락
}
}, "T2");
t2가 할 일:
c2.increment() → c2 락 잡고 count++ 출력c1.staticIncrement() → 클래스 락 잡고 count++ 출력c1.increment()와 c2.increment()는?increment() 부분에서 동시에 돌아가는 것 가능.staticIncrement() 부분은?스레드마다 루프가 3번 돌아요. 각 루프당:
스레드 2개 → 12번 호출 → count가 12 증가.
순서가 섞이는 이유:
increment() 호출 부분은 c1과 c2 락이 달라 동시 호출 가능staticIncrement() 부분은 반드시 한 번에 한 스레드만 호출 → 교대로 출력count의 최종값 = 12T1 increment: X, T2 increment: Y, T1 staticIncrement: Z, ... 이런 형태로 교차 출력됨.increment() → 인스턴스 락 → c1, c2 동시에 호출 가능staticIncrement() → 클래스 락 → 반드시 한 번에 하나좋아요! 🎯 이번에는 t1.start(), t2.start(), t1.join(), t2.join() 부분을 정말 초보자 입장에서 디버깅하면서 자세히 설명해 드릴게요.
t1.start()와 t2.start()가 하는 일t1.start()t1이라는 쓰레드를 "실행 준비 상태(Runnable)"로 만듦.t1의 run() 코드를 실행해줌.t2.start()t2 쓰레드를 실행 준비 상태로 만듦.t1.start()와 t2.start()를 호출하면 main 쓰레드와 t1, t2가 동시에 돌아가기 시작.
즉, main()에서 start 호출한 이후:
t1.join()과 t2.join()이 하는 일이 부분이 조금 트릭인데,
**"main 쓰레드가 t1, t2가 끝날 때까지 기다리는 것"**을 말해요.
t1.join()t2.join()t1.start() → t1 시작t2.start() → t2 시작t1.join() → main 쓰레드가 t1 끝날 때까지 대기t2.join() 호출 → t2 끝날 때까지 대기System.out.println()을 호출만약 join()을 안 쓰면?
→ main 쓰레드가 기다리지 않고 바로 끝나 버릴 수 있음.
→ 즉, count를 출력하기 전에 t1이나 t2가 아직 안 끝났을 수 있음.
→ 결과가 엉망이 되겠지!
t1.start() = 일꾼1에게 일 시작하라고 함.t2.start() = 일꾼2에게 일 시작하라고 함.t1.join() = 대장(메인) 입장에서 "일꾼1 일 끝나면 알려줘"라고 말하고 기다림.t2.join() = 대장(메인) 입장에서 "일꾼2 일 끝나면 알려줘"라고 말하고 기다림.→ 두 일꾼 일 끝나야 대장(메인)이 "최종 결과!"라고 말함.
좋아요! 🔥 이번엔 wait/notify 트릭과 deadlock 트릭 두 가지를 준비하고, 한 줄 한 줄 디버깅하면서 상황을 묘사해 드릴게요.
이 문제는 wait()와 notify()의 타이밍 트릭을 보여줍니다.
class Shared {
synchronized void doWait() {
System.out.println(Thread.currentThread().getName() + " waiting...");
try {
wait(); // 트릭: synchronized 블록 안에서 호출됨
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " resumed!");
}
synchronized void doNotify() {
System.out.println(Thread.currentThread().getName() + " notifying...");
notify(); // 트릭: 대기 중인 하나의 쓰레드를 깨움
}
}
public class Test {
public static void main(String[] args) throws InterruptedException {
Shared shared = new Shared();
Thread waiter = new Thread(() -> shared.doWait(), "Waiter");
Thread notifier = new Thread(() -> {
try {
Thread.sleep(1000); // 트릭: Waiter가 대기할 시간을 줌
shared.doNotify();
} catch (InterruptedException e) { }
}, "Notifier");
waiter.start();
notifier.start();
waiter.join();
notifier.join();
}
}
waiter.start()doWait() 호출Waiter waiting... 출력wait() 호출 → 현재 쓰레드가 대기 상태로 전환 + lock 반납!notifier.start()sleep)doNotify() 호출Notifier notifying... 출력notify() 호출 → 대기중인 Waiter를 깨움wait() 이후의 코드를 계속 실행Waiter resumed! 출력Waiter waiting...
Notifier notifying...
Waiter resumed!
✅ wait()는 반드시 synchronized 블록 안에서 호출
✅ wait() 호출하면 lock 반납
✅ notify() 호출해도 wait한 쓰레드가 바로 실행되는 건 아님. notify 종료 후 lock 반환해야 wait한 쓰레드가 깨어나 진행 가능
✅ 반드시 join()해서 main이 끝나지 않도록 대기
이번엔 두 쓰레드가 서로 다른 lock을 잡고 교착 상태에 빠지는 트릭!
class DeadlockDemo {
final Object lockA = new Object();
final Object lockB = new Object();
public void thread1() {
synchronized (lockA) {
System.out.println("Thread-1 잡은 lockA, lockB 대기중...");
try { Thread.sleep(500); } catch (InterruptedException e) {}
synchronized (lockB) {
System.out.println("Thread-1 잡은 lockB");
}
}
}
public void thread2() {
synchronized (lockB) {
System.out.println("Thread-2 잡은 lockB, lockA 대기중...");
try { Thread.sleep(500); } catch (InterruptedException e) {}
synchronized (lockA) {
System.out.println("Thread-2 잡은 lockA");
}
}
}
}
public class Test {
public static void main(String[] args) throws InterruptedException {
DeadlockDemo demo = new DeadlockDemo();
Thread t1 = new Thread(() -> demo.thread1(), "Thread-1");
Thread t2 = new Thread(() -> demo.thread2(), "Thread-2");
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Main 종료");
}
}
t1.start()Thread-1 → thread1() 호출synchronized (lockA) 잡음t2.start()Thread-2 → thread2() 호출synchronized (lockB) 잡음join()에서 대기중✅ 두 쓰레드가 lock 순서를 반대로 잡으면 deadlock 발생
✅ 이런 문제를 예방하기 위해선 반드시 lock 잡는 순서 고정하거나, tryLock()등을 쓰거나, 동시성 유틸 사용
✅ wait() / notify() 트릭
좋아요! 🎯 지금 완전 초보자 입장에서, "synchronized(락), 대기중(wait), 깨어난다(notify)" 라는 단어 자체를 현실 비유를 섞어 하나도 안다고 가정하지 않고, 천천히 차근차근 쉽게 설명해 드릴게요. 🧡
우리가 **동기화(synchronized)**나 **락(lock)**을 배울 때, 가장 쉬운 비유가 화장실 하나에 열쇠 하나 있는 상황이에요.
→ 프로그래밍에서 synchronized를 쓴다는 건,
이 화장실 열쇠를 잡고 있는 거랑 같아요.
여러 쓰레드(사람들)가 한 코드를 동시에 못 쓰도록 하는 거죠.
Thread-1, Thread-2 = 화장실 쓰려는 사람들이제 코드 한 줄:
synchronized (lockA) {
// 중요한 작업
}
lockA라는 열쇠를 잡고 화장실 들어가는 것!자, 화장실 안에 있는데 갑자기:
wait();
라고 하면?
→ 안에 있던 사람이 문 열고 나와서 열쇠 걸어두고 복도로 나가 대기하는 거예요.
(wait() 호출 = 화장실 열쇠 놓고 복도로 나가 기다리는 상태)
이 상태에서 사람(쓰레드)은 대기중(기다리는 중) 상태가 돼요.
화장실 바깥 다른 사람이:
notify();
라고 호출해주면?
→ "대기중인 사람들 중 하나에게 화장실 쓰라고 알려주기"
(notify() = "야 이제 화장실 열쇠 잡고 들어가서 계속해!" 신호)
wait() 상태였다가 notify() 호출받으면:
→ 기다리던 사람이 화장실 열쇠 잡고 다시 작업 계속!
→ 이 상태를 깨어난다(resumed) 라고 해요.
| 동작 | 비유 | 프로그래밍 동작 |
|---|---|---|
| synchronized | 화장실 열쇠 잡고 안에 들어감 | 공유 자원 lock 잡고 작업 |
| wait() | 화장실 안에서 열쇠 놓고 나가 복도로 나가 기다림 | lock 놓고 대기 상태 |
| notify() | 화장실 밖에서 대기중인 사람에게 "이제 써!"라고 알려줌 | 대기중인 쓰레드를 깨움 |
| resumed | 대기중이던 사람이 화장실 열쇠 잡고 다시 작업 시작 | wait() 끝나고 이후 코드 실행 |
짧은 코드로:
synchronized (shared) {
System.out.println("Thread-1: 작업중");
shared.wait(); // 열쇠 놓고 대기
System.out.println("Thread-1: 깨어나 작업 이어가기");
}
다른 쓰레드:
synchronized (shared) {
System.out.println("Thread-2: 대기중인 Thread-1 깨우기");
shared.notify(); // 대기중인 애 하나 깨우기
}
✅ synchronized = 공유 자원(화장실) 하나를 한 번에 한 쓰레드만 쓸 수 있게 함
✅ wait() = 일 하다가 lock 놓고 쉬기(대기 상태)
✅ notify() = 대기중인 쓰레드에게 "이제 계속해"라고 알리기
✅ 깨어나면(wait가 끝나면) → 다시 lock 잡고 작업 재개
좋아요! 🔥 아주 천천히 하나씩 설명해 드릴게요.
지금 보여주신 이 코드 부분을 한 줄 한 줄, 초보자 입장에서 완전히 뜯어서 설명해 드릴게요. 😄
class Shared {
synchronized void doWait() {
System.out.println(Thread.currentThread().getName() + " waiting...");
try {
wait(); // 트릭: synchronized 블록 안에서 호출됨
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " resumed!");
}
synchronized void doNotify() {
System.out.println(Thread.currentThread().getName() + " notifying...");
notify(); // 트릭: 대기 중인 하나의 쓰레드를 깨움
}
}
이 코드에서 처음 보는 부분들 하나씩 알려드릴게요.
Thread.currentThread().getName()Thread.currentThread() 란?Thread.currentThread()라고 쓰면 **"지금 나(이 코드를 실행 중인 쓰레드) 자신"**을 나타냄.getName()이란?Thread-0, Thread-1처럼 이름을 붙여줘요.Thread.currentThread().getName()은 지금 이 코드를 실행 중인 쓰레드의 이름을 반환.만약 메인에서 쓰레드를 이렇게 만들었으면:
Thread t1 = new Thread(() -> shared.doWait(), "Worker-1");
→ 이 쓰레드 안에서 Thread.currentThread().getName() 하면 "Worker-1" 반환!
→ 출력 예:
Worker-1 waiting...
try { ... } catch (InterruptedException e) { ... }try-catch란?try 블록 안의 코드에서 **예외(exception)**가 발생할 수 있는 경우, 프로그램이 터지지 않도록 잡아서(catch) 처리해주는 구조예요.InterruptedException이란?wait()나 sleep() 같은 메서드는 "중간에 다른 쓰레드가 너를 깨울 수 있다"는 걸 컴파일러에게 알려주려고 반드시 InterruptedException을 throw 하도록 되어 있어요.wait()를 호출할 때 무조건 try-catch나 throws로 감싸야 함.e.printStackTrace()란?→ try { wait(); } catch (InterruptedException e) { e.printStackTrace(); }
→ wait() 호출할 때 혹시 중간에 끊기면(인터럽트되면) 프로그램 안 죽고 에러 메시지만 출력.
Thread.currentThread().getName()
→ 현재 일하는 쓰레드 이름 출력 (예: "Thread-1")
try { wait(); } catch (InterruptedException e) { ... }
→ wait() 하려면 반드시 try-catch로 감싸야 함
→ 대기중에 다른 쓰레드가 "그만 기다려!"라고 인터럽트 날릴 수 있는데, 그때 InterruptedException 발생
e.printStackTrace()
→ 인터럽트 예외가 터졌을 때 무슨 일이 있었는지 콘솔에 보여주기
좋아요! 🎯 아주 기초부터 **synchronized(동기화)**의 역할을 천천히 쉽게 설명해 줄게요. 😄
synchronized란 무엇인가?synchronized 키워드는 한마디로 말하면:
"한 번에 한 쓰레드만 이 코드(또는 이 블록)에 들어올 수 있게 해 주는 열쇠"
라고 생각하면 돼요. 🔑
여러 쓰레드가 같은 데이터를 동시에 바꾸려고 하면 문제가 생길 수 있어요.
예를 들어:
int count = 0;
Thread t1 = new Thread(() -> count++);
Thread t2 = new Thread(() -> count++);
→ 이렇게 하면 두 쓰레드가 count++를 거의 동시에 호출하면서 count를 꼬이게 바꿀 수 있어요.
(이걸 레이스 컨디션(Race Condition) 이라고 함)
이 문제를 막기 위해 synchronized를 쓰면:
생각해 보자:
synchronized)락)쓰레드가 블록 안에 들어감)락 잡음)다른 쓰레드 대기)락 반환)다른 쓰레드 진행)synchronized 사용하는 방법 2가지synchronized void increment() {
count++;
}
→ 이 경우 this 객체를 락으로 사용.
→ 한 쓰레드가 increment() 호출 중이면 다른 쓰레드는 기다려야 함.
void increment() {
synchronized(this) {
count++;
}
}
→ 이렇게 하면 synchronized(this) 블록 안쪽만 lock 걸고,
→ 다른 부분은 동기화 없이 자유롭게 동작.
✅ synchronized = 동시 접속 금지 도구
✅ 여러 쓰레드가 한꺼번에 공유 데이터를 만지면 안 될 때 씀
✅ 한 쓰레드만 들어가고 다른 쓰레드는 기다리도록 함