Java Thread 4부 - wait와 notify로 Thread 깨우기

청록비
2 read

3부에서는 synchronized를 알아봤다.

핵심은 간단했다.

여러 Thread가 같은 데이터를 동시에 건드리면 위험하다.

그래서 synchronized로 한 번에 하나의 Thread만 접근하게 만들었다.

이번 글에서는 Thread끼리 서로 기다리고 깨우는 방법을 다룬다.

다음은 wait(), notify(), notifyAll()


왜 wait와 notify가 필요할까?

Thread는 혼자 일하지 않는다.

어떤 Thread는 데이터를 만들고,
어떤 Thread는 그 데이터를 사용한다.

예를 들어보자.

Producer Thread → 데이터 생성
Consumer Thread → 데이터 소비

Producer가 데이터를 만들기 전에 Consumer가 먼저 데이터를 가져가려고 하면 어떻게 될까?

가져갈 데이터가 없다.

이때 Consumer는 무작정 계속 확인하면 안 된다.

데이터 있나?
데이터 있나?
데이터 있나?
데이터 있나?

이런 방식은 CPU를 낭비한다.

더 좋은 방식은 이렇다.

Consumer: 데이터 없네. 기다릴게.
Producer: 데이터 만들었어. 이제 일어나.

이걸 가능하게 해주는 것이 wait()notify()다.


wait() - 기다리기

wait()는 현재 Thread를 기다리게 만든다.

단, 아무 곳에서나 사용할 수 없다.

반드시 synchronized 영역 안에서 호출해야 한다.

synchronized (lock) {
    lock.wait();
}

wait()를 호출한 Thread는 기다리는 상태가 된다.

그리고 중요한 일이 하나 더 일어난다.

wait() 호출
→ 현재 Thread가 기다림
→ 가지고 있던 락을 반납함

락을 반납한다는 점이 중요하다.

락을 반납하지 않으면 다른 Thread가 synchronized 영역에 들어올 수 없고, 결국 깨워줄 수도 없기 때문이다.


notify() - 하나 깨우기

notify()wait() 중인 Thread 하나를 깨운다.

synchronized (lock) {
    lock.notify();
}

마찬가지로 notify()도 반드시 synchronized 영역 안에서 호출해야 한다.

notify()는 같은 객체의 락에서 기다리는 Thread 중 하나를 깨운다.

lock.wait()로 기다리는 Thread
→ lock.notify()로 깨울 수 있음

즉, wait()notify()는 같은 객체를 기준으로 동작한다.


notifyAll() - 전부 깨우기

notifyAll()은 기다리는 Thread를 모두 깨운다.

synchronized (lock) {
    lock.notifyAll();
}

차이는 간단하다.

메서드

의미

notify()

기다리는 Thread 중 하나를 깨움

notifyAll()

기다리는 Thread 전부를 깨움

실무에서는 보통 notify()보다 notifyAll()이 더 안전한 경우가 많다.

왜냐하면 어떤 Thread가 깨어날지 개발자가 정확히 지정할 수 없기 때문이다.


간단한 예제

먼저 공유할 데이터를 담는 클래스를 만들어보자.

class SharedData {
    private String data;

    public synchronized void getData() {
        if (data == null) {
            try {
                wait();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }

        System.out.println("소비한 데이터: " + data);
    }

    public synchronized void setData(String data) {
        this.data = data;
        notify();
    }
}

그리고 Producer와 Consumer Thread를 만든다.

public class Main {
    public static void main(String[] args) {
        SharedData sharedData = new SharedData();

        Thread consumer = new Thread(() -> {
            sharedData.getData();
        });

        Thread producer = new Thread(() -> {
            sharedData.setData("Hello Thread");
        });

        consumer.start();
        producer.start();
    }
}

실행 결과는 다음과 같다.

소비한 데이터: Hello Thread

Consumer는 데이터가 없으면 기다린다.

Producer가 데이터를 넣고 notify()를 호출하면 Consumer가 깨어난다.


wait()는 락을 반납한다

여기서 가장 중요한 포인트는 이것이다.

wait()는 기다리면서 락을 반납한다.

만약 Consumer가 락을 계속 가지고 기다린다면 Producer는 setData()에 들어올 수 없다.

그러면 데이터를 넣을 수도 없고, notify()도 호출할 수 없다.

즉, 프로그램이 멈춘다.

그래서 wait()는 기다릴 때 락을 반납한다.

그리고 다시 깨어난 뒤에는 락을 다시 얻고 나서 다음 코드를 실행한다.

wait()
→ 락 반납
→ 대기
→ notify로 깨어남
→ 락 다시 획득
→ 이후 코드 실행

wait()는 if보다 while이 안전하다

앞의 예제에서는 if를 사용했다.

if (data == null) {
    wait();
}

하지만 실제로는 while을 사용하는 것이 안전하다.

while (data == null) {
    wait();
}

왜냐하면 Thread가 깨어났다고 해서 조건이 반드시 만족된 것은 아니기 때문이다.

다음 같은 일이 생길 수 있다.

1. Consumer가 기다림
2. notify로 깨어남
3. 그런데 다른 Consumer가 먼저 데이터를 가져감
4. 현재 Consumer가 실행될 때는 데이터가 없음

또는 특별한 이유 없이 Thread가 깨어나는 경우도 있다.

이를 Spurious Wakeup이라고 한다.

그래서 wait()는 항상 조건문을 다시 확인해야 한다.

정석은 이거다.

synchronized (lock) {
    while (조건이 만족되지 않으면) {
        lock.wait();
    }

    // 조건이 만족되었을 때 실행
}

개선된 예제

이제 whilenotifyAll()을 사용해서 조금 더 안전하게 바꿔보자.

class SharedData {
    private String data;

    public synchronized void getData() {
        while (data == null) {
            try {
                wait();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            }
        }

        System.out.println("소비한 데이터: " + data);
        data = null;

        notifyAll();
    }

    public synchronized void setData(String data) {
        while (this.data != null) {
            try {
                wait();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            }
        }

        this.data = data;
        System.out.println("생성한 데이터: " + data);

        notifyAll();
    }
}

사용 코드는 다음과 같다.

public class Main {
    public static void main(String[] args) {
        SharedData sharedData = new SharedData();

        Thread consumer = new Thread(() -> {
            sharedData.getData();
        });

        Thread producer = new Thread(() -> {
            sharedData.setData("Hello Thread");
        });

        consumer.start();
        producer.start();
    }
}

실행 결과는 다음과 비슷하다.

생성한 데이터: Hello Thread
소비한 데이터: Hello Thread

이제 구조가 더 명확해졌다.

데이터가 없으면 Consumer 대기
데이터가 있으면 Producer 대기

Producer-Consumer 구조

이런 방식을 Producer-Consumer 패턴이라고 부른다.

Producer → 데이터를 만든다
Consumer → 데이터를 사용한다

대표적인 예시는 다음과 같다.

  1. 작업 큐

  2. 로그 처리

  3. 파일 다운로드 처리

  4. 메시지 처리

  5. 주문 처리 시스템

실무에서는 직접 wait()notify()를 사용하기보다 BlockingQueue 같은 클래스를 더 많이 사용한다.

하지만 내부 원리를 이해하려면 wait()notify()를 알아두는 것이 좋다.


wait(), notify()는 Object의 메서드다

재미있는 점이 있다.

wait(), notify(), notifyAll()Thread 클래스의 메서드가 아니다.

Object 클래스의 메서드다.

즉, 모든 객체가 가지고 있다.

Object lock = new Object();

synchronized (lock) {
    lock.wait();
    lock.notify();
}

왜 Object에 있을까?

이 메서드들은 Thread 자체보다 락 객체와 관련이 있기 때문이다.

Thread가 기다리는 대상은 Thread가 아니라 특정 객체의 락이다.

Thread가 lock 객체에서 기다림
다른 Thread가 lock 객체로 깨움

그래서 wait()notify()는 락으로 사용하는 객체를 기준으로 호출한다.


synchronized 밖에서 호출하면?

wait()notify()synchronized 밖에서 호출하면 예외가 발생한다.

Object lock = new Object();

lock.wait();

실행하면 다음 예외가 발생한다.

java.lang.IllegalMonitorStateException

이유는 간단하다.

락을 가지고 있지 않은 Thread가 그 락을 반납하거나, 그 락에서 기다리는 Thread를 깨우려고 했기 때문이다.

그래서 반드시 이렇게 사용해야 한다.

synchronized (lock) {
    lock.wait();
}

synchronized (lock) {
    lock.notifyAll();
}

sleep()과 wait()의 차이

sleep()wait()는 둘 다 Thread를 멈춘다.

하지만 완전히 다르다.

구분

sleep()

wait()

소속

Thread

Object

락 반납

반납하지 않음

반납함

사용 위치

어디서든 가능

synchronized 안에서만 가능

깨우는 방법

시간 종료 또는 interrupt

notify, notifyAll, interrupt

목적

잠깐 쉬기

조건이 만족될 때까지 기다리기

가장 중요한 차이는 이것이다.

sleep()은 락을 반납하지 않는다.
wait()는 락을 반납한다.

이 차이를 모르면 Thread 코드를 이해하기 어렵다.


notify()는 바로 실행시키는 게 아니다

notify()를 호출하면 기다리던 Thread가 바로 실행될 것 같지만 그렇지 않다.

notify()
→ 기다리던 Thread를 깨움
→ 하지만 락은 아직 notify를 호출한 Thread가 가지고 있음

깨어난 Thread는 바로 실행되는 것이 아니라, 락을 다시 얻을 때까지 기다린다.

즉, notify()를 호출한 Thread가 synchronized 영역을 빠져나가야 깨어난 Thread가 실행될 수 있다.

synchronized (lock) {
    lock.notify();

    // 아직 락을 가지고 있음
    System.out.println("notify 후 작업");
}

// synchronized 블록을 빠져나가야 락 반납

이 점도 중요하다.

notify()는 실행 명령이 아니라, 대기 상태에서 깨우는 신호에 가깝다.


핵심 정리

Thread 4부의 핵심은 이것만 기억하면 된다.

  1. wait()는 Thread를 기다리게 한다.

  2. notify()는 기다리는 Thread 하나를 깨운다.

  3. notifyAll()은 기다리는 Thread 전부를 깨운다.

  4. wait(), notify(), notifyAll()은 반드시 synchronized 안에서 사용해야 한다.

  5. wait()는 기다리면서 락을 반납한다.

  6. sleep()은 락을 반납하지 않는다.

  7. wait()if보다 while과 함께 쓰는 것이 안전하다.

  8. notify()는 깨우는 신호일 뿐, 바로 실행시키는 것은 아니다.

실무에서는 직접 쓰기보다 BlockingQueue 같은 도구를 더 많이 사용한다.

이번 글의 핵심은 하나다.

wait()는 기다리고,
notify()는 깨운다.

다음 글에서는 synchronized보다 더 유연한 동기화 도구를 다룬다.

바로 Lock, ReentrantLock, Atomic, volatile이다.

#Cloud#Infrastructure#Serverless#Tech2024