Java Category/Java

[Java] 멀티 스레드

ReBugs 2023. 8. 1.

이 게시글은 이것이 자바다(저자 : 신용권, 임경균)의 책과 동영상 강의를 참고하여 개인적으로 정리하는 글임을 알립니다. 


2023.06.27 - [컴퓨터 구조 & 운영체제/운영체제] - [운영체제] 스레드(Thread), 멀티 프로세스와 멀티 스레드

 

[운영체제] 스레드(Thread), 멀티 프로세스와 멀티 스레드

이 글은 혼자 공부하는 컴퓨터 구조 + 운영체제 (저자 : 강민철)의 책과 유튜브 영상을 참고하여 개인적으로 정리하는 글임을 알립니다. 스레드 이 글에서 다루는 내용은 소프트웨어적 스레드이

rebugs.tistory.com

 

 

  • 하나의 프로세스가 두 가지 이상의 작업을 처리할 수 있는 이유는 멀티 스레드가 있기 때문이다.
  • 프로세스의 모든 스레드가 종료가 되어야 프로세스가 종료된다.
  • 하나의 스레드에서 예외가 발생하면 모든 스레드가 종료되어 결국 프로세스도 종료되므로 예외 처리에 만전을 기해야 한다.
메인 스레드
모든 자바 프로그램은 메인 스레드가 main() 메소드를 실행하면서 시작된다.
메인 스레드는 main() 메소드의 첫 코드부터 순차적으로 실행하고, main() 메소드의 마지막 코드를 실행하거나 return문을 만나면 실행을 종료한다.

메인 스레드는 필요에 따라 추가 작업 스레드들을 만들어서 실행시킬 수 있다.
멀티 스레드에서는 실행 중인 스레드가 하나라도 있다면 프로세스는 종료되지 않는다.
즉, 메인 스레드가 작업 스레드보다 먼저 종료되더라도 작업 스레드가 하나라도 실행 중이라면 프로세스는 종료되지 않는다.
한 프로세스에 하나의 JVM이 실행된다.
여러 자바 프로세스가 실행중이라면 여러 JVM이 있는 것이다.

 

스레드 생성

중요한 점은 아래의 방법 중에 어떤 방법을 사용해서 생성을 하든 상관이 없지만, 작업 스레드를 실행하려면 스레드 객체의 start() 메소드를 호출해야 한다.

 

Runnable 구현 객체를 만들어서 생성

Runnable 구현 클래스 작성

작업 스레드 객체를 직접 생성하려면, java.lang 패키지에 있는 Thread 클래스로부터 작업 스레드 객체를 생성하면 된다.

객체를 생성할 때에는 Runnable 구현 객체를 매개값으로 갖는 생성자를 호출해야 한다.

Runnable 인터페이스
스레드가 작업을 실행할 때 사용하는 인터페이스이다.
Runnable에는 run() 메소드가 정의되어 있는데, 구현 클래스는 run()을 오버라이딩해서 스레드가 실행할 코드를 가지고 있어야 한다.

Runnable 구현 클래스는 작업 내용을 정의한 것이므로, 스레드에게 전달해야 한다.

Runnable 구현 객체를 생성한 후 Thread 생성자 매개값으로 Runnable 객체를 전달하면 된다.

 

Task.java

public class Task implements Runnable{
	@Override
	public void run() {
		//실행할 코드 작성
	}
}

 

Main.java

public class Main {
	public static void main(String[] args) {
		Runnable task = new Task();
		Task task2 = new Task();
		
		Thread thread = new Thread(task);
		Thread thread2 = new Thread(task2);
		
		thread.start();
		thread2.start();
	}
}

 

Runnable 익명 구현 객체로 생성

아래와 같이 익명 구현 객체를 생성하여 매개값으로 사용할 수도 있다.

public class Main {
	public static void main(String[] args) {
		Thread thread = new Thread(new Runnable() { //익명 구현 객체
			@Override
			public void run() {
				//실행할 코드 작성
			}
		});
		
		thread.start();
	}
}

 

Thread 자식 클래스로 생성

명시적인 자식 클래스 정의 후 생성

작업 스레드 객체를 생성하는 또 다른 방법은 Thread의 자식 객체로 만드는 것이다.

Thread 클래스를 상속한 다음 run() 메소드를 오버라이딩해서 스레드가 실행할 코드를 작성하고 객체를 생성하면 된다.

 

Task.java

public class Task extends Thread{
	@Override
	public void run() {
		//실행할 코드 작성
	}
}

 

Main.java

public class Main {
	public static void main(String[] args) {
		//방법1
		Thread thread = new Thread(new Task());
		thread.start();
		
		//방법2
		Task task = new Task();
		Thread thread2 = new Thread(task);
		thread2.start();
		
		//방법3
		Task task2 = new Task();
		task2.start();
	}
}

 

 

Thread 익명 자식 객체로 생성

명시적인 자식 클래스를 정의하지 않고, 아래와 같이 Thread 익명 자식 객체를 사용할 수도 있다.

public class Main {
	public static void main(String[] args) {
		Thread thread = new Thread() {
			@Override
			public void run() {
				//실행할 코드 작성
			}
		};
		thread.start();
	}
}

 


 

스레드 이름

  • 메인 스레드는 main이라는 이름을 가진다.
  • 작업 스레드는 자동적으로 Thread-n이라는 이름을 가진다.
  • 다른 이름으로 설정하고 싶다면 Thread 클래스의 setName() 메소드를 사용하면 된다.
  • 당연히 스레드를 실행하기 전에 스레드 이름을 바꿔야 한다.
thread.setName("스레드 이름");

 

어떤 스레드가 실행하고 있는지 확인하려면 정적 메소드인 CurrentThread()로 스레드 객체의 참조를 얻은 다음 getName() 메소드로 스레드의 이름을 얻고 출력해 보면 된다.

Thread thread = Thread.currentThread();
System.out.println(thread.getName());

 

 

ThreadNameExample.java

public class ThreadNameExample {
	public static void main(String[] args) {
		Thread mainThread = Thread.currentThread();
		System.out.println(mainThread.getName() + " 실행");

		for(int i=0; i<3; i++) {
			Thread threadA = new Thread() {
				@Override
				public void run() {
					System.out.println(getName() + " 실행");
				}
			};
			threadA.start();
		}

		Thread chatThread = new Thread() {
			@Override
			public void run() {
				System.out.println(getName() + " 실행");
			}
		};
		chatThread.setName("chat-thread");
		chatThread.start();
	}
}
/*
main 실행
Thread-0 실행
Thread-1 실행
Thread-2 실행
chat-thread 실행
*/

 

위 예제에서 ThreadA.start()를 시작한 순서대로 출력이 되지 않을 수 있다.

Thread-0 실행 -> Thread-1 실행 -> Thread-2 실행 -> chat-thread 실행

이 순서가 아니라 무작위 순서가 될 수 있다는 것이다.

이유는, 바로 스레드가 시작되는 것이 아니라 실행 대기 상태로 있다가 CPU 스케줄링에 의해서 스케줄링 큐에 있는 순서대로 시작되기 때문이다.

2023.06.30 - [컴퓨터 구조 & 운영체제/운영체제] - [운영체제] CPU 스케줄링의 개념

 

[운영체제] CPU 스케줄링의 개념

이 글은 혼자 공부하는 컴퓨터 구조 + 운영체제 (저자 : 강민철)의 책과 유튜브 영상을 참고하여 개인적으로 정리하는 글임을 알립니다. 모든 프로세스들은 CPU를 필요로 하고, 모든 프로세스들은

rebugs.tistory.com

 


 

스레드 상태

  • 스레드 객체를 생성(NEW)하고, start() 메소드를 호출하면 곧바로 스레드가 실행되는 것이 아니라 실행 대기 상태(RUNNABLE)가 된다.
  • 실행 대기하는 스레드는 CPU 스케쥴링에 따라 CPU를 점유하고 run() 메소드를 실행한다. 이때를 실행(RUNNING) 상태라고 한다.
  • 실행 스레드는 run() 메소드를 모두 실행하기 전에 스케줄링에 의해 다시 실행 대기 상태로 돌아갈 수 있다. 그리고 다른 스레드가 실행 상태가 된다.
  • 이렇게 스레드는 실행 대기 상태와 실행 상태를 번갈아 가면서 자신의 run() 메소드를 조금씩 실행한다.
  • 실행 상태에서 run() 메소드가 종료되면 더 이상 실행할 코드가 없기 때문에 스레드의 실행은 멈추게 된다. 이 상태를 종료 상태(TERMINATED)라고 한다.

출처 : 이것은 자바다 유튜브 동영상 강의

위 개념은 운영체제 CPU 스케줄링의 개념이다.

 

아래는 일시 정지로 가기 위한 메소드와 다시 실행 대기 상태로 가기 위한 메소드를 보여준다.

구분 메소드 설명
일시 정지로 보냄 sleep(long millis) 주어진 시간 동안 스레드를 일시 정지 상태로 만든다. 주어진 시간이 지나면 자동적으로 실행 대기 상태가 된다.
join() join() 메소드를 호출한 스레드는 일시 정지 상태가 된다. 실행 대기 상태가 되려면, join() 메소드를 가진 스레드가 종료되어야 한다.
wait() 동기화 블록 내에서 스레드를 일시 정지 상태로 만든다
일시 정지에서 벗어남 interrupt() 일시 정지 상태일 경우, interruptedException을 발생시켜 실행 대기 상태 또는 종료 상태로 만든다.
notify()
notifyAll()
wait() 메소드로 인해 일시 정지 상태인 스레드를 실행 대기 상태로 만든다.
실행 대기로 보냄 yield() 실행 상태에서 다른 스레드에게 실행을 양보하고 실행 대기 상태가 된다.

위 표에서 wait()과 notify(), notifyAll()은 Object 클래스의 메소드이고 그 외는 Thread 클래스의 메소드이다.

Object 클래스의 메소드들은 스레드 동기화에서 다룬다.

 

주어진 시간 동안 일시 정지

실행 중인 스레드를 일정 시간 멈추게 하고 싶다면 Thread 클래스의 정적 메소드인 sleep()을 이용하면 된다.

매개값에는 얼마 동안 일시 정지 상태로 있을 것인지 밀리세컨드(1/1000초) 단위로 시간을 주면 된다.

일시 정지 상태에서는 InterruptedException이 발생할 수 있기 때문에 sleep() 메소드는 예외 처리가 필요하다.

try {
        Thread.sleep(3000); //3초간 일시 정지
    } catch(InterruptedException e) {}

 

 

다른 스레드의 종료를 기다림

스레드는 다른 스레드와 독립적으로 실행하지만 다른 스레드가 종료될 때까지 기다렸다가 실행을 해야 하는 경우도 있다.

이를 위해 스레드는 join() 메소드를 제공한다.

  • 아래의 그림에서 ThreadA가 ThreadB의 join() 메소드를 호출하면 ThreadA는 ThreadB가 종료할 때까지 일시 정지 상태가 된다.
  • ThreadB의 run() 메소드가 종료되고 나서야 비로소 ThreadA는 일시 정지에서 풀려 다음 코드를 실행한다.
  • 일시 정지 상태에서는 InterruptedException이 발생할 수 있기 때문에 join() 메소드는 예외 처리가 필요하다.

 

SumThread.java

public class SumThread extends Thread {
	private long sum;
	
	public long getSum() {
		return sum;
	}

	public void setSum(long sum) {
		this.sum = sum;
	}

	@Override
	public void run() {
		for(int i=1; i<=100; i++) {
			sum+=i;
		}
	}
}

 

JoinExample.java

public class JoinExample {
	public static void main(String[] args) {
		SumThread sumThread = new SumThread();
		sumThread.start();
		try {
			sumThread.join();
		} catch (InterruptedException e) {
		}
		System.out.println("1~100 합: " + sumThread.getSum());
	}
}

 

join() 메소드를 호출하지 않으면 1~100의 합인 5050이 아니라 더 작은 값이 나올 수도 있다.

 

다른 스레드에게 실행 양보

스레드가 원하는 상태가 아니라면 다른 스레드에게 실행을 양보하고 자신은 실행 대기 상태로 가게 할 수도 있다.

이런 기능을 위해 Thread는 yield() 메소드를 제공한다.

yield()를 호출한 스레드는 실행 대기 상태로 돌아가고, 다른 스레드가 실행 상태가 된다.

 

  • 아래 예제에서는 처음 5초 동안은 ThreadA의 work 필드의 값이 false이기 때문에 실행 대기상태가 된다.
  • 5초 이후에는 work 필드의 값이 true로 바뀌어서 출력문이 계속 실행된다.
  • 다시 5초 이후에는 work 필드의 값이 false로 바뀌어서 다시 실행 대기상태가 된다.

 

WorkThread.java

public class WorkThread extends Thread {
	//필드
	public boolean work = false;
	
	//생성자
	public WorkThread(String name) {
		setName(name);
	}
	
	//메소드
	@Override
	public void run() {
		while(true) {
			if(work) {
				System.out.println(getName() + ": 작업처리");
			} else {
				Thread.yield();
			}
		}
	}
}

 

YieldExample.java

public class YieldExample {
	public static void main(String[] args) {
		WorkThread workThreadA = new WorkThread("workThreadA");
		workThreadA.start();
		
		try { Thread.sleep(5000); } catch (InterruptedException e) {}
		workThreadA.work = true;

		try { Thread.sleep(10000); } catch (InterruptedException e) {}
		workThreadA.work = false;
	}
}

 


 

스레드 동기화

동기화 메소드와 블록

스레드 A가 공유 자원(객체)을 사용 중인데 다른 스레드인 스레드 B가 접근하려고 하면 스레드 A가 사용 중인 객체를 다른 스레드가 변경할 수 없도록 하려면 스레드 작업이 끝날 때까지 객체에 잠금을 걸면 된다.

이를 위해 자바는 동기화 메소드와 블록을 제공한다.

  • 공유 객체의 동기화 메소드는 하나의 스레드만 사용할 수 있다.
  • 공유 객체의 동기화 블록은 하나의 스레드만 사용할 수 있다.
  • 공유 객체의 일반 메소드는 모든 스레드가 사용할 수 있다.

출처 : 이것은 자바다 유튜브 동영상 강의

 

동기화 메소드는 다음과 같이 synchronized 키워드를 붙이면 된다.

synchronized 키워드는 인스턴스와 정적 메소드 어디든 붙일 수 있다.

public synchronized void method(){
	//단 하나의 스레드만 실행하는 영역
}

스레드가 동기화 메소드를 실행하는 즉시 객체는 잠금이 일어나고, 메소드 실행이 끝나면 잠금이 풀린다.

 

메소드 전체가 아닌 일부 영역을 실행할 때만 객체 잠금을 걸고 싶다면 다음과 같이 동기화 블록을 만들면 된다.

public void method(){
	//여러 스레드가 실행할 수 있는 영역
    synchronized(공유 객체){
    	//단 하나의 스레드만 실행하는 영역
    }
    //여러 스레드가 실행할 수 있는 영역
}

 

  • 아래의 예제에서 공유 객체는 Calculator이다.
  • Calculator의 setMemory1()는 동기화 메소드이다.
  • Calculator의 setMemory2()는 동기화 블록을 포함하는 메소드이다.
  • setMemory1()과 setMemory2()는 하나의 스레드만 실행이 가능한 메소드가 된다.
  • 두 메소드는 동일하게 매개값을 memory 필드에 값을 저장을 하고 2초간 일시 정지 후 memory 필드의 값을 출력한다.
  • 두 개의 스레드가 공유 객체의 memory 값을 바꾼다.

 

Calculator.java

public class Calculator {
	private int memory;

	public int getMemory() {
		return memory;
	}

	public synchronized void setMemory1(int memory) {
		this.memory = memory;
		try {
			Thread.sleep(2000);
		} catch(InterruptedException e) {}
		System.out.println(Thread.currentThread().getName() + ": " + this.memory);
	}

	public void setMemory2(int memory) {
		synchronized(this) {
			this.memory = memory;
			try {
				Thread.sleep(2000);
			} catch(InterruptedException e) {}
			System.out.println(Thread.currentThread().getName() + ": " + this.memory);
		}
	}
}

 

User1Thread.java

public class User1Thread extends Thread {
	private Calculator calculator;

	public User1Thread() {
		setName("User1Thread");
	}

	public void setCalculator(Calculator calculator) {
		this.calculator = calculator;
	}

	@Override
	public void run() {
		calculator.setMemory1(100);
	}
}

 

User2Thread.java

public class User2Thread extends Thread {
	private Calculator calculator;
	
	public User2Thread() {
		setName("User2Thread");
	}

	public void setCalculator(Calculator calculator) {
		this.calculator = calculator;
	}

	@Override
	public void run() {
		calculator.setMemory2(50);
	}
}

 

SynchronizedExample.java

public class SynchronizedExample {
	public static void main(String[] args) {
		Calculator calculator = new Calculator();

		User1Thread user1Thread = new User1Thread();
		user1Thread.setCalculator(calculator);
		user1Thread.start();

		User2Thread user2Thread = new User2Thread();
		user2Thread.setCalculator(calculator);
		user2Thread.start();
	}
}
/*
User1Thread: 100
User2Thread: 50
*/

 

 

wait()과 notify()를 이용한 스레드 제어

경우에 따라서는 두 개의 스레드를 교대로 번갈아 가며 실행할 때도 있다.

정확한 교대 작업이 필요할 경우, 자신의 작업이 끝나면 상대방 스레드를 일시 정지 상태에서 풀어주고 자신은 일시 정지 상태로 만들면 된다.

 

이 방법의 핵심은 공유 객체에 있다.

  • 공유 객체는 두 스레드가 작업할 내용을 각각 동기화 메소드로 정해 놓는다.
  • 한 스레드가 작업을 완료하면 notify() 메소드를 호출해서 일시 정지 상태에 있는 다른 스레드를 실행 대기 상태로 만든다.
  • 자신은 두 번 작업을 하지 않도록 wait() 메소드를 호출하여 일시 정지 상태로 만든다.

출처 : 이것은 자바다 유튜브 동영상 강의

  • notify()는 wait() 에 의해 일시 정지된 스레드 중 한 개를 실행 대기 상태로 만들고, notifyAll()은 wait()에 의해 일시 정지된 모든 스레드를 실행 대기 상태로 만든다.
  • 주의할 점은 이 두 메소드는 동기화 메소드 또는 동기화 블록 내에서만 사용할 수 있다는 것이다.

 

아래의 예제는 공유 객체인 WorkObject에 동기화 메소드인 ThreadA와 ThreadB가 각 methodA와 methodB를 호출한다.

 

WorkObject.java

public class WorkObject {
	public synchronized void methodA() {
		Thread thread = Thread.currentThread();
		System.out.println(thread.getName() + ": methodA 작업 실행");
		notify();
		try {
			wait();
		} catch (InterruptedException e) {
		}
	}

	public synchronized void methodB() {
		Thread thread = Thread.currentThread();
		System.out.println(thread.getName() + ": methodB 작업 실행");
		notify();
		try {
			wait();
		} catch (InterruptedException e) {
		}
	}
}

 

ThreadA.java

public class ThreadA extends Thread {
	private WorkObject workObject;

	public ThreadA(WorkObject workObject) {
		setName("ThreadA");
		this.workObject = workObject;
	}

	@Override
	public void run() {
		for(int i=0; i<10; i++) {
			workObject.methodA();
		}
	}
}

 

ThreadB.java

public class ThreadB extends Thread {
	private WorkObject workObject;

	public ThreadB(WorkObject workObject) {
		setName("ThreadB");
		this.workObject = workObject;
	}

	@Override
	public void run() {
		for(int i=0; i<10; i++) {
			workObject.methodB();
		}
	}
}

 

WaitNotifyExample.java

public class WaitNotifyExample {
	public static void main(String[] args) {
		WorkObject workObject = new WorkObject();

		ThreadA threadA = new ThreadA(workObject);
		ThreadB threadB = new ThreadB(workObject);

		threadA.start();
		threadB.start();
	}
}
/*
ThreadA: methodA 작업 실행
ThreadB: methodB 작업 실행
ThreadA: methodA 작업 실행
ThreadB: methodB 작업 실행
ThreadA: methodA 작업 실행
ThreadB: methodB 작업 실행
ThreadA: methodA 작업 실행
ThreadB: methodB 작업 실행
ThreadA: methodA 작업 실행
ThreadB: methodB 작업 실행
ThreadA: methodA 작업 실행
ThreadB: methodB 작업 실행
ThreadA: methodA 작업 실행
ThreadB: methodB 작업 실행
ThreadA: methodA 작업 실행
ThreadB: methodB 작업 실행
ThreadA: methodA 작업 실행
ThreadB: methodB 작업 실행
ThreadA: methodA 작업 실행
ThreadB: methodB 작업 실행
*/

 


 

스레드 안전 종료

스레드는 자신의 run() 메소드가 모두 실행되면 자동적으로 종료되지만, 경우에 따라서는 실행 중인 스레드를 즉시 종료할 필요가 있다.

스레드를 강제 종료시키기 위해 Thread는 stop() 메소드를 제공하고 있으나 이 메소드는 사용하지 않는 것을 추천한다.

스레드를 갑자기 종료하게 되면 사용 중이던 리소스들이 불안전한 상태로 남겨지기 때문이다.

스레드를 안전하게 종료하는 방법은 리소스들을 정리하고 run() 메소드를 빨리 종료하는 것이다.

 

이를 위해 아래의 두 가지 방법을 사용한다.

  • 조건 이용
  • interrupt() 메소드 이용

 

조건 이용

  • 스레드가 while 문으로 반복 실행할 경우, 조건을 이용해서 run() 메소드의 종료를 유도한다.
public class XXXThread extends Thread {
	private boolean stop;

	public void setStop(boolean stop) {
		this.stop = stop;
	}

	@Override
	public void run() {
		while(!stop) {
			System.out.println("실행 중");
		}
		System.out.println("리소스 정리");
		System.out.println("실행 종료");
	}
}

 

 

interrupt() 메소드 이용

일시 정지를 이용한 방법

interrupt() 메소드는 스레드가 일시 정지 상태에 있을 때 InterruptedException 예외를 발생시키는 역할을 한다.

이것을 이용하면 예외 처리를 통해 run() 메소드를 정상 종료시킬 수 있다.

public void run() {
		try {
			while(true) {
				//...
				Thread.sleep(1); //일시 정지
			}
		} catch(InterruptedException e) {
		}
		//스레드가 사용한 리소스 정리
	}

즉, 이 방법은 스레드가 일시 정지 상태에 있을 때만 가능하다.

일시 정지 상태일 때 처리하는 InterruptedException 예외 처리를 이용한 방법이기 때문이다.

 

PrintThread.java

public class PrintThread extends Thread {
	public void run() {
		try {
			while(true) {
				System.out.println("실행 중");
				Thread.sleep(1);
			}
		} catch(InterruptedException e) {
		}
		System.out.println("리소스 정리");
		System.out.println("실행 종료");
	}
}

 

InterruptExample.java

public class InterruptExample {
	public static void main(String[] args) {
		Thread thread = new PrintThread();
		thread.start();

		try {
			Thread.sleep(100); //메인 스레드를 일시정지. 작업 스레드를 멈추는게 아님
		} catch (InterruptedException e) {
		}

		thread.interrupt(); //작업 스레드 인터럽트
	}
}
/*
실행 중
실행 중
실행 중
실행 중
실행 중
실행 중
실행 중
실행 중
실행 중
리소스 정리
실행 종료
*/

 

 

일시 정지를 이용하지 않는 방법

Thread의 interrupted()와 isInterrupt() 메소드는 interrupt() 메소드 호출 여부를 리턴한다.

  • interrupted() : 정적 메소드 -> 객체 생성 없이 사용 가능
  • isInterrupt() : 인스턴스 메소드 -> 객체를 생성해야 사용 가능

이 방법은 위 메소드를 이용하여 interrupt() 메소드 호출 여부를 확인하고 호출되었다면,  while 블록을 빠져나가 리소스 정리를 하고 스레드를 종료하게끔 유도한다.

 

PrintThread.java

public class PrintThread extends Thread{
	public void run() {
		while(true){
			System.out.println("실행 중");
			if(Thread.interrupted()) { //interrupt() 메소드가 호출되었는지 확인
				break; //interrupt() 메소드가 호출되었다면 while문 탈출
			}
		}
		System.out.println("리소스 정리");
		System.out.println("실행 종료");
	}
}

 

Main.java

public class Main {
	public static void main(String[] args) {
		Thread thread = new PrintThread();
		thread.start();
		try {
			Thread.sleep(1); //메인 스레드를 일시정지, 작업 스레드를 일시 정지 하는것이 아님
		}catch (InterruptedException e) {
		}
		thread.interrupt(); //작업 스레드 인터럽트
	}
}
/*
실행 중
실행 중
실행 중
실행 중
실행 중
실행 중
실행 중
실행 중
실행 중
실행 중
실행 중
실행 중
실행 중
실행 중
실행 중
실행 중
실행 중
실행 중
실행 중
실행 중
실행 중
실행 중
실행 중
실행 중
실행 중
실행 중
실행 중
실행 중
실행 중
실행 중
실행 중
실행 중
리소스 정리
실행 종료
*/

댓글