[Java] 쓰레드(Thread) 란? 특징과 사용법

2021. 5. 4. 15:05Java

프로세스와 쓰레드

- 프로세스

  : 실행 중인 프로그램으로 자원(CPU와 Memory 같은 Resource)과 쓰레드로 구성된다.

- 쓰레드

  : 프로세스 내에서 실제 작업을 수행

  : 모든 프로세스는 최소한 하나의 쓰레드를 가지고 있다.

  : 싱글 쓰레드 프로세스와 멀티 쓰레드 프로세스로 구분된다.

 

※ 1개의 new 프로세스를 생성하는 것보다 1개의 new 쓰레드를 생성하는 것이 더 적은 비용이 든다.
※ 과거 CGI 같은 경우 멀티 쓰레드를 지원하지 않아, 요청이 올 때마다 새로운 프로세스가 생성되었는데 

   이는 Web 수요가 늘어나는 시기에 멀티 쓰레드가 지원되는 Java에 밀려 사라지는 이유 중 하나가 되었다.

 

 

멀티쓰레드의 장점 및 단점

- 장점

  : 시스템 자원을 보다 효율적으로 사용 가능

  : 사용자에 대한 응답성이 향상된다.

  : 작업이 분리되어 코드가 간결해진다.

- 단점

  : 동기화(Synchronization)에 주의해야 한다.

  : 교착상태(Dead-Lock)가 발생하지 않도록 주의해야 한다.

  : 각 쓰레드가 효율적으로 고르게 실행될 수 있게 해야 한다.

  : 즉 프로그래밍할 때 고려해야 할 사항들이 많다.

 

 

쓰레드를 구현하는 방법

1. Thread 클래스를 상속

public class Example {

    public static void main(String[] args) {
        MyTread myTread = new MyTread();	// 쓰레드 객체 생성
        myTread.start();			// 쓰레드 시작
    }
}

class MyTread extends Thread {
    @Override
    public void run() {
        // 작업 내용
    }
}

 

2. Runnable 인터페이스를 구현

public class Example {

    public static void main(String[] args) {
        Runnable runnable = new MyTread();
        Thread myTread = new Thread(runnable);	// 쓰레드 객체 생성
        myTread.start();			// 쓰레드 시작
    }
}

class MyTread implements Runnable {
    @Override
    public void run() {
        // 작업 내용
    }
}

 

 

※ 쓰레드를 구현 하는 방법 [연습]  ---  더보기 클릭

더보기
public class Example {

    public static void main(String[] args) {

        MyTread1 myTread1 = new MyTread1();             // 쓰레드1 객체 생성
        Thread myTread2 = new Thread(new MyTread2());   // 쓰레드2 객체 생성

        myTread1.start();   // 쓰레드1 실행
        myTread2.start();   // 쓰레드2 실행
    }
}

class MyTread1 extends Thread {
    @Override
    public void run() {
        for(int i=0; i<5; i++) {
            System.out.println("쓰레드 네임: " +this.getName());
        }
    }
}

class MyTread2 implements Runnable {
    @Override
    public void run() {
        for(int i=0; i<5; i++) {
            System.out.println("쓰레드 네임: " +Thread.currentThread().getName());
        }
    }
}

 

// 출력 결과
쓰레드 네임: Thread-0
쓰레드 네임: Thread-0
쓰레드 네임: Thread-1
쓰레드 네임: Thread-1
쓰레드 네임: Thread-1
쓰레드 네임: Thread-1
쓰레드 네임: Thread-1
쓰레드 네임: Thread-0
쓰레드 네임: Thread-0
쓰레드 네임: Thread-0

 

 

위 출력 결과를 보면 myTread1 을 먼저 start 하였지만, 순서가 뒤죽박죽이다. 이는 OS 스케줄러가 가지고 있는 스케줄링 로직에 의해 결정되는 부분이므로 OS에 의존적이다. 그렇기에 쓰레드는 예측할 수 없는 불확실성 한 특징을 가지고 있다.

 


쓰레드의 종류

1. main Thread

- 사용자 스레드

 

2. Daemon Thread

- 보조 스레드

- GC, 자동 저장, 화면 자동갱신 등에 사용된다.

// ADT
boolean isDaemon()		// daemon 인지 확인 (true: 데몬)
void setDaemon(boolean on);	// daemon 또는 main 으로 변경 (true: Daemon)

- setDaemon() 여부는 start() 전에 결정해야 한다. 그렇지 않으면 IllegalThreadStateException 발생

 

※ 메인 쓰레드가 종료되면, 데몬 쓰레드는 자동으로 종료된다.

 

 

쓰레드의 I/O Blocking 

- 입출력 작업 시, 쓰레드로 인한 블록킹 현상

 

ex) 사용자에게 입력 값을 받는다. 또 1~100을 출력한다. 아래 그림을 보면 싱글 쓰레드 구성 시, 사용자의 입력을 기다리는 시간동안 정지 상태가 이어지며, 1~100 을 출력 명령 실행이 늦어지는 걸 확인할 수 있다. 즉 I/O Blocking 이 지속 된다는 말이다. 반대로 멀티 쓰레드 구상 시, 사용자의 입력을 기다리는 정지 상태가 지속되더라도 1~100 을 출력하라는 명령은 서브 스레드가 대신하기 때문에 실행 시간이 지체되지 않는 걸 확인할 수 있다.

 

쓰레드의 우선순위 (Priority of Thread)

- 작업의 중요도에 따라 쓰레드의 우선순위를 다르게 하여 특정 쓰레드가 더 많은 작업시간을 갖게 할 수 있다.

- 1 ~ 10 까지의 우선순위를 부여할 수 있으며, 숫자가 클수록 우선순위가 높다. (default 값은 5 이다)

// ADT
void setPriority(int newPriority);	// 쓰레드의 우선순위 지정
int getPriority();			// 쓰레드의 우선순위 반환

- 하지만 실제로는 우선순위에 따라 더 많은 작업시간을 가지고 정확히 돌아가지 않는다.

- 개발자가 지정한 1 ~ 10 까지의 우선순위는 JVM 에 적용된 희망사항이다.

- 이러한 희망사항을 OS 스케줄러에게 전달하는 것뿐이라 정확히 적용되지는 않는다.

- OS 에서 돌아가는 수많은 프로세스와 쓰레드를 제치고 우리의 프로그램에 특혜를 줄 수 없기 때문이다.

 

 

쓰레드의 그룹

- 서로 관련된 쓰레드를 그룹으로 묶어서 다루기 위한 것- 모든 쓰레드는 반드시 하나의 쓰레드 그룹에 속해있다. (default 값은 main 쓰레드)

// ADT
ThreadGroup getThreadGroup();			// 쓰레드의 그룹 반환
void uncaughtException(Thread t, Throwable e);	// 그룹별 쓰레드 예외처리 재정의

 

 

쓰레드의 상태

 

 

쓰레드의 실행 제어

// ADT
static void sleep();	// 일시정지
void join();		// 다른 쓰레드를 기다린다
void interrupt();	// sleep() 이나 join() 된 쓰레드를 깨운다
void stop();		// 쓰레드 즉시 종료
void suspend();		// 일시정지
void resume();		// suspend() 된 쓰레드를 실행대기상태(Runnable)로 만듬
static void yield();	// 자신에게 주어진 실행 시간을 다른 쓰레드에게 양보(yield)

- static 이 붙은 sleep 과 yield 는 다른 쓰레드에게 적용이 불가능하다. 오직 자기 자신에게만 적용 가능하다.

 

1. sleep

- 현재 쓰레드를 지정된 시간 동안 멈추게 한다.

- Time out 또는 Interrupt() 가 발생했을 때 sleep() 은 깨진다.

- Interrupt() 경우, InterruptedException 이 발생하기에 예외처리를 필수이다.

 

※ sleep [연습]  ---  더보기 클릭

더보기
public class Example {

    public static void main(String[] args) {

        MyTread1 myTread1 = new MyTread1();             // 쓰레드1 객체 생성
        Thread myTread2 = new Thread(new MyTread2());   // 쓰레드2 객체 생성

        myTread1.start();   // 쓰레드1 실행
        myTread2.start();   // 쓰레드2 실행


        /**
         * 이 부분에서 !
         * myTread1.sleep() 해도 sleep() 이 적용 되는건 Main 쓰레드 이다.
         * 이유는 sleep 은 static 메소드라 자기 자신만이 적용 되기 때문이다.
         */
        delay(2000);

        System.out.println("Main 쓰레드 종료");
    }

    private static void delay(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

class MyTread1 extends Thread {
    @Override
    public void run() {
        for(int i=0; i<5; i++) {
            System.out.println("쓰레드 네임: " +this.getName());
        }
    }
}

class MyTread2 implements Runnable {
    @Override
    public void run() {
        for(int i=0; i<5; i++) {
            System.out.println("쓰레드 네임: " +Thread.currentThread().getName());
        }
    }
}




/*****************
 * 출력 결과
 * ---------------
 * 쓰레드 네임: Thread-0
 * 쓰레드 네임: Thread-1
 * 쓰레드 네임: Thread-1
 * 쓰레드 네임: Thread-1
 * 쓰레드 네임: Thread-1
 * 쓰레드 네임: Thread-1
 * 쓰레드 네임: Thread-0
 * 쓰레드 네임: Thread-0
 * 쓰레드 네임: Thread-0
 * 쓰레드 네임: Thread-0
 * Main 쓰레드 종료
 * ---------------
 *****************/

 

 

2. interrupt()

- 대기상태(Waiting)인 쓰레드를 실행 대기상태(Runnable)로 만든다.

// ADT
void interrupt();		// 쓰레드의 interrupted 상태를 true 로 변경
boolean isInterrupted();	// 쓰레드의 interrupted 상태 반환
static boolean interrupted();	// 쓰레드의 interrupted 상태 반환, false 로 초기화

 

3. suspend()  <<Deprecated 됨. Dead-Lock 의 원인이 될 수 있음>>

- 쓰레드를 일시정지시킨다.

 

4. resume()  <<Deprecated 됨. Dead-Lock 의 원인이 될 수 있음>>

- suspend() 에 의해 일시 정지된 쓰레드를 실행 대기상태(Runnable)로 만든다.

 

5. stop()  <<Deprecated 됨. Dead-Lock 의 원인이 될 수 있음>>

- 쓰레드를 즉시 종료시킨다.

 

6. join()

- 지정된 시간 동안 특정 쓰레드가 작업하는 것을 기다린다.

 

7. yield()

- 남은 시간을 다음 쓰레드에게 양보하고, 자신은 실행 대기한다.

- yield() 와 interrupt() 를 적절히 사용하면, 응답성과 효율을 높일 수 있다.

- yield 는 OS 스케줄러에게 통보하는 것이므로 정확히 실행된다는 보장은 없다.

 

 

쓰레드의 동기화 (Synchronization)

- 멀티 쓰레드 프로세스에서는 다른 쓰레드의 작업에 영향을 미칠 수 있다.

- 진행 중인 작업이 다른 쓰레드에게 간섭받지 않게 하려면 "동기화"가 필요

- 동기화하려면 간섭받지 않아야 하는 문장들을 "임계 영역"으로 설정한다.

- 임계 영역은 Lock 을 얻은 단 하나의 쓰레드만 출입이 가능하다. (객체 1개에 락 1개)

 

 

synchronized 를 이용한 동기화

- 키워드 synchronized 를 사용해 임계 영역(Critical Section) 을 설정하는 방법 2가지

 

1. 메서드 전체를 임계 영역 지정

public synchronized void calcSum() {
	...
}

 

2. 특정한 영역을 임계 영역 지정

synchronized(객체의 참조변수) {
	...
}

 

※ synchronized 를 이용한 동기화 [연습]  ---  더보기 클릭

더보기
public class Example {

    public static void main(String[] args) {

        // 쓰레드 시작
        for(int i=0; i<20; i++) {
           new Thread(new ticketingTread()).start();
        }
    }
}


class Ticket {
    
    // 총 티켓 수
    private int totalTicket = 10;

    public synchronized void ticketing() {

        try {
        // 출력을 위해 딜레이 추가
            Thread.sleep(100);

            // 티켓 감소
            if (totalTicket >= 0) {
                totalTicket --;
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public int getTotalTicket() {
        return totalTicket;
    }

}

class  ticketingTread implements Runnable {

    static Ticket ac = new Ticket();

    @Override
    public void run() {

        synchronized(this) {
            System.out.println("Thread ID -> " + Thread.currentThread().getName());

            // 티켓팅
            ac.ticketing();

            int remainTicket = ac.getTotalTicket();
            if (remainTicket < 0)
                System.out.println("티켓팅 실패 ㅠㅠ - 남은 티켓: " + remainTicket);
            else
                System.out.println("티켓팅 성공 !!! - 남은 티켓: " + remainTicket);

        }

    }
}

 

// 출력 결과
Thread ID -> Thread-0
Thread ID -> Thread-3
Thread ID -> Thread-4
Thread ID -> Thread-5
Thread ID -> Thread-1
Thread ID -> Thread-2
Thread ID -> Thread-6
Thread ID -> Thread-8
Thread ID -> Thread-7
Thread ID -> Thread-9
Thread ID -> Thread-10
Thread ID -> Thread-11
Thread ID -> Thread-12
Thread ID -> Thread-13
Thread ID -> Thread-14
Thread ID -> Thread-15
Thread ID -> Thread-16
Thread ID -> Thread-17
Thread ID -> Thread-18
Thread ID -> Thread-19
티켓팅 성공 !!! - 남은 티켓: 9
티켓팅 성공 !!! - 남은 티켓: 8
티켓팅 성공 !!! - 남은 티켓: 7
티켓팅 성공 !!! - 남은 티켓: 6
티켓팅 성공 !!! - 남은 티켓: 5
티켓팅 성공 !!! - 남은 티켓: 4
티켓팅 성공 !!! - 남은 티켓: 3
티켓팅 성공 !!! - 남은 티켓: 2
티켓팅 성공 !!! - 남은 티켓: 1
티켓팅 성공 !!! - 남은 티켓: 0
티켓팅 실패 ㅠㅠ - 남은 티켓: -1
티켓팅 실패 ㅠㅠ - 남은 티켓: -1
티켓팅 실패 ㅠㅠ - 남은 티켓: -1
티켓팅 실패 ㅠㅠ - 남은 티켓: -1
티켓팅 실패 ㅠㅠ - 남은 티켓: -1
티켓팅 실패 ㅠㅠ - 남은 티켓: -1
티켓팅 실패 ㅠㅠ - 남은 티켓: -1
티켓팅 실패 ㅠㅠ - 남은 티켓: -1
티켓팅 실패 ㅠㅠ - 남은 티켓: -1
티켓팅 실패 ㅠㅠ - 남은 티켓: -1

 

 

synchronized 를 이용한 동기화 추가 내용

- 동기화를 하면 데이터는 보호되지만, 1번에 1 Thread 만 임계 영역에 들어갈 수 있기에 비효율적이다. 프로그램 효율이 떨어지는 이유이다.

- 동기화의 효율을 높이기 위해 wait(), notify() 를 사용하다.

- Object 클래스에 정의되어 있으며, 동기화 블록 내에서만 사용할 수 있다.

 

1. wait()

- 객체의 Lock 을 풀고 쓰레드를 해당 객체의 waiting pool 에 넣는다.

 

2. notify()

- waiting pool 에서 대기중인 쓰레드 중의 하나를 깨운다. (랜덤)

 

3. nofityAll()

- waiting pool 에서 대기중인 모든 쓰레드를 깨운다.

 

※ wait 와 notify 를 이용한 동기화 [연습]  ---  더보기 클릭

더보기
import java.util.ArrayList;

public class Example {

    public static void main(String[] args) throws InterruptedException {
        Table table = new Table();

        // 요리사 쓰레드 시작
        new Thread(new Chef(table), "CHEF").start();

        // 손님 쓰레드 시작
        boolean flag = true;
        for(int i=0; i<20; i++) {
            if (flag) {
                new Thread(new Customer(table, "donut"), "CUST-" + i).start();
                flag = false;
            } else {
                new Thread(new Customer(table, "burger"), "CUST-" + i).start();
                flag = true;
            }

            Thread.sleep(500);
        }


        // 시스템 종료 조건
        Thread.sleep(30000);
        System.exit(0);
    }
}


// 손님
class Customer implements Runnable {

    private Table table;
    private String food;

    public Customer(Table table, String food) {
        this.table = table;
        this.food = food;
    }

    @Override
    public void run() {
        String curThreadNm = Thread.currentThread().getName();
        System.out.println("손님: " + curThreadNm + " 등장");

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
        }
        
        // 음식 먹기
        table.remove(food);
        System.out.println("손님: " + curThreadNm + " 은 음식( " + food + " ) 를 먹었습니다.");
    }
}


// 요리사
class Chef implements  Runnable {
    private Table table;

    public Chef(Table table) {
        this.table = table;
    }

    @Override
    public void run() {
        while(true) {
            int idx = (int)(Math.random() * table.getDishNum());
            table.add(table.dishNames[idx]);

            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {}
        }
    }
}


// 테이블
class Table {
    String[] dishNames = { "donut", "donut", "burger" };
    final int MAX_FOOD = 6;

    private ArrayList<String> dishes = new ArrayList<>();

    public synchronized void add(String dish)  {
        String curThreadNm = Thread.currentThread().getName();

        // 테이블에 음식이 가득 찼을 경우
        while (dishes.size() >= MAX_FOOD) {
            System.out.println("┏━테이블에 음식이 가득 찼습니다.");
            System.out.println("┖━요리사: " + curThreadNm + " is waiting");

            // 요리사를 기다리게 하기
            waitAndDelay(1000);
        }

        // 음식 추가
        dishes.add(dish);
        notify();   // 손님 깨우기
        System.out.println("요리사: " + curThreadNm + " 가 음식( " + dish + " ) 를 만들었습니다. " + dishes.toString());
    }

    public void remove(String dishName) {
        synchronized (this) {
            String curThreadNm = Thread.currentThread().getName();

            // 테이블에 음식이 존재하지 않을 경우
            while (dishes.size() == 0) {
                System.out.println("┏━테이블에 음식이 없습니다.");
                System.out.println("┖━손님: " + curThreadNm + " is waiting");
                waitAndDelay(1000);
            }

            // 음식 먹기
           while (true) {
               // 원하는 음식 탐색
               for(int i=0; i<dishes.size(); i++) {
                   if (dishName.equals(dishes.get(i))) {
                       dishes.remove(i);
                       notify();   // 잠자고 있는 요리사를 꺠우기 위함
                       return;
                   }
               }

               // 고객이 원하는 음식이 없을 경우, 기다리게 한다.
               System.out.println("┏━테이블에 원하는 음식( " + dishName + " )이 없습니다.");
               System.out.println("┖━손님: " + curThreadNm + " is waiting");
               waitAndDelay(1000);
            }
        }
    }

    public int getDishNum() {
        return dishNames.length;
    }

    private void waitAndDelay(int millis) {
        try {
            wait();
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

 

// 출력결과
요리사: CHEF 가 음식( donut ) 를 만들었습니다. [donut]
손님: CUST-0 등장
손님: CUST-1 등장
요리사: CHEF 가 음식( donut ) 를 만들었습니다. [donut, donut]
손님: CUST-0 은 음식( donut ) 를 먹었습니다.
손님: CUST-2 등장
┏━테이블에 원하는 음식( burger )이 없습니다.
┖━손님: CUST-1 is waiting
손님: CUST-3 등장
요리사: CHEF 가 음식( donut ) 를 만들었습니다. [donut, donut]
손님: CUST-4 등장
손님: CUST-5 등장
┏━테이블에 원하는 음식( burger )이 없습니다.
┖━손님: CUST-1 is waiting
요리사: CHEF 가 음식( burger ) 를 만들었습니다. [donut, donut, burger]
손님: CUST-3 은 음식( burger ) 를 먹었습니다.
손님: CUST-2 은 음식( donut ) 를 먹었습니다.
손님: CUST-6 등장
손님: CUST-7 등장
┏━테이블에 원하는 음식( burger )이 없습니다.
┖━손님: CUST-1 is waiting
요리사: CHEF 가 음식( donut ) 를 만들었습니다. [donut, donut]
┏━테이블에 원하는 음식( burger )이 없습니다.
┖━손님: CUST-5 is waiting
손님: CUST-4 은 음식( donut ) 를 먹었습니다.
손님: CUST-8 등장
손님: CUST-9 등장
┏━테이블에 원하는 음식( burger )이 없습니다.
┖━손님: CUST-5 is waiting
요리사: CHEF 가 음식( burger ) 를 만들었습니다. [donut, burger]
손님: CUST-7 은 음식( burger ) 를 먹었습니다.
손님: CUST-6 은 음식( donut ) 를 먹었습니다.
손님: CUST-10 등장
손님: CUST-11 등장
┏━테이블에 원하는 음식( burger )이 없습니다.
┖━손님: CUST-1 is waiting
요리사: CHEF 가 음식( donut ) 를 만들었습니다. [donut]
┏━테이블에 원하는 음식( burger )이 없습니다.
┖━손님: CUST-9 is waiting
손님: CUST-8 은 음식( donut ) 를 먹었습니다.
손님: CUST-12 등장
손님: CUST-13 등장
┏━테이블에 원하는 음식( burger )이 없습니다.
┖━손님: CUST-5 is waiting
요리사: CHEF 가 음식( burger ) 를 만들었습니다. [burger]
손님: CUST-11 은 음식( burger ) 를 먹었습니다.
┏━테이블에 음식이 없습니다.
┖━손님: CUST-10 is waiting
손님: CUST-14 등장
손님: CUST-15 등장
┏━테이블에 원하는 음식( burger )이 없습니다.
┖━손님: CUST-9 is waiting
손님: CUST-16 등장
손님: CUST-17 등장
┏━테이블에 원하는 음식( burger )이 없습니다.
┖━손님: CUST-1 is waiting
┏━테이블에 음식이 없습니다.
┖━손님: CUST-15 is waiting
┏━테이블에 음식이 없습니다.
┖━손님: CUST-14 is waiting
요리사: CHEF 가 음식( burger ) 를 만들었습니다. [burger]
손님: CUST-13 은 음식( burger ) 를 먹었습니다.
┏━테이블에 음식이 없습니다.
┖━손님: CUST-12 is waiting
손님: CUST-18 등장
손님: CUST-19 등장
┏━테이블에 원하는 음식( burger )이 없습니다.
┖━손님: CUST-5 is waiting
요리사: CHEF 가 음식( donut ) 를 만들었습니다. [donut]
┏━테이블에 원하는 음식( burger )이 없습니다.
┖━손님: CUST-17 is waiting
손님: CUST-16 은 음식( donut ) 를 먹었습니다.
┏━테이블에 원하는 음식( burger )이 없습니다.
┖━손님: CUST-9 is waiting
┏━테이블에 음식이 없습니다.
┖━손님: CUST-10 is waiting
요리사: CHEF 가 음식( donut ) 를 만들었습니다. [donut]
┏━테이블에 원하는 음식( burger )이 없습니다.
┖━손님: CUST-19 is waiting
손님: CUST-18 은 음식( donut ) 를 먹었습니다.
┏━테이블에 음식이 없습니다.
┖━손님: CUST-15 is waiting
┏━테이블에 원하는 음식( burger )이 없습니다.
┖━손님: CUST-1 is waiting
요리사: CHEF 가 음식( donut ) 를 만들었습니다. [donut]
요리사: CHEF 가 음식( donut ) 를 만들었습니다. [donut]
손님: CUST-12 은 음식( donut ) 를 먹었습니다.
손님: CUST-14 은 음식( donut ) 를 먹었습니다.
요리사: CHEF 가 음식( burger ) 를 만들었습니다. [burger]
┏━테이블에 원하는 음식( donut )이 없습니다.
┖━손님: CUST-10 is waiting
요리사: CHEF 가 음식( burger ) 를 만들었습니다. [burger, burger]
손님: CUST-19 은 음식( burger ) 를 먹었습니다.
요리사: CHEF 가 음식( donut ) 를 만들었습니다. [burger, donut]
손님: CUST-9 은 음식( burger ) 를 먹었습니다.
요리사: CHEF 가 음식( donut ) 를 만들었습니다. [donut, donut]
┏━테이블에 원하는 음식( burger )이 없습니다.
┖━손님: CUST-17 is waiting
┏━테이블에 원하는 음식( burger )이 없습니다.
┖━손님: CUST-5 is waiting
요리사: CHEF 가 음식( donut ) 를 만들었습니다. [donut, donut, donut]
손님: CUST-10 은 음식( donut ) 를 먹었습니다.
요리사: CHEF 가 음식( burger ) 를 만들었습니다. [donut, donut, burger]
손님: CUST-1 은 음식( burger ) 를 먹었습니다.
요리사: CHEF 가 음식( burger ) 를 만들었습니다. [donut, donut, burger]
손님: CUST-15 은 음식( burger ) 를 먹었습니다.
요리사: CHEF 가 음식( donut ) 를 만들었습니다. [donut, donut, donut]
┏━테이블에 원하는 음식( burger )이 없습니다.
┖━손님: CUST-5 is waiting
┏━테이블에 원하는 음식( burger )이 없습니다.
┖━손님: CUST-17 is waiting
요리사: CHEF 가 음식( donut ) 를 만들었습니다. [donut, donut, donut, donut]
┏━테이블에 원하는 음식( burger )이 없습니다.
┖━손님: CUST-5 is waiting
요리사: CHEF 가 음식( donut ) 를 만들었습니다. [donut, donut, donut, donut, donut]
┏━테이블에 원하는 음식( burger )이 없습니다.
┖━손님: CUST-17 is waiting
요리사: CHEF 가 음식( donut ) 를 만들었습니다. [donut, donut, donut, donut, donut, donut]
┏━테이블에 원하는 음식( burger )이 없습니다.
┖━손님: CUST-5 is waiting
┏━테이블에 음식이 가득 찼습니다.
┖━요리사: CHEF is waiting