| 일 | 월 | 화 | 수 | 목 | 금 | 토 | 
|---|---|---|---|---|---|---|
| 1 | ||||||
| 2 | 3 | 4 | 5 | 6 | 7 | 8 | 
| 9 | 10 | 11 | 12 | 13 | 14 | 15 | 
| 16 | 17 | 18 | 19 | 20 | 21 | 22 | 
| 23 | 24 | 25 | 26 | 27 | 28 | 29 | 
| 30 | 
                            Tags
                            
                        
                          
                          - 티스토리챌린지
 - 분류
 - PCA
 - 딥러닝
 - 오블완
 - regression
 - LG Aimers 4th
 - 회귀
 - ChatGPT
 - Classification
 - LLM
 - 해커톤
 - Machine Learning
 - deep learning
 - OpenAI
 - gpt
 - LG
 - AI
 - supervised learning
 - GPT-4
 - LG Aimers
 - 지도학습
 - 머신러닝
 
                            Archives
                            
                        
                          
                          - Today
 
- Total
 
SYDev
[이것이 자바다] Chapter 14. 멀티 스레드 본문
- 데이터 분할 병렬 처리할 때
 - 안드로이드 앱에서 네트워크 통신할 때
 - 다수의 클라이언트 요청을 처리하는 서버를 개발할 때
 
1. 메인 스레드
- 모든 자바 프로그램은 main thread가 main() method를 실행하면서 시작
 - main thread는 main() method의 첫 코드부터 순차적으로 실행
 - main() method의 마지막 코드를 실행하거나, return 문을 만나면 실행 종료
 

- main thread는 필요에 따라 추가 작업 스레드들을 만들어 실행
 

- 실행중인 thread가 하나라도 있다면 process는 종료되지 않음
 
2. 작업 스레드 생성과 실행
2.1. Thread 클래스로 직접 생성
Thread thread = new Thread(Runnable target);
- Runnable: threaed가 task를 실행할 때 사용하는 interface
- run() method가 정의돼있음 -> 구현 클래스는 thread가 실행할 코드를 포함
 
 
class Task implements Runnable {
    @Override
    public void run() {
        // thread가 실행할 코드
    }
}
- 명시적인 Runnable 구현 클래스를 작성하지 않고, Thread 생성자를 호출할 때 Runnable 익명 구현 객체를 매개값으로 사용하는 방법을 더 많이 사용
 
Thread thread = new Thread( new Runnable() {
    @Override
    public void run() {
        // thread가 실행할 코드
    }
} );
- 작업 스레드 객체를 생성하고, 작업 스레드를 실행하려면 thread 객체의 start() method를 호출
 
thread.start();

- 예제
package ch14.sec3;
import java.awt.Toolkit;
public class BeepPrintExample {
	public static void main(String[] args) {
		Toolkit toolkit = Toolkit.getDefaultToolkit();
		for(int i = 0; i < 5; i++) {
			toolkit.beep();
			try { Thread.sleep(500); } catch(Exception e) {}
		}
		
		for(int i = 0; i < 5; i++) {
			System.out.println("띵");
			try { Thread.sleep(500); } catch(Exception e) {}
		}
	}
}
-> beep음을 모두 발생시키고 나서, "띵" 5번 출력
package ch14.sec3;
import java.awt.Toolkit;
public class BeepPrintExample {
	public static void main(String[] args) {
		Thread thread = new Thread(new Runnable() {
			@Override
			public void run() {
				Toolkit toolkit = Toolkit.getDefaultToolkit();
				for(int i = 0; i < 5; i++) {
					toolkit.beep();
					try { Thread.sleep(500); } catch(Exception e) {}
				}
			}
		});
		
		// 작업 thread 실행
		thread.start();
		
		for(int i = 0; i < 5; i++) {
			System.out.println("띵");
			try { Thread.sleep(500); } catch(Exception e) {}
		}
	}
}
-> main thread는 출력을 담당, 작업 thread에서는 동시에 beep음 처리
2.2. Thread 자식 클래스로 생성
- 작업 thread 객체를 생성하는 또 다른 방법 -> Thread의 자식 객체로 만드는 방법
 - Thread 클래스를 상속한 다음 run() method를 재정의 -> thread가 실행할 코드를 작성하고 객체 생성
 
public class WorkerThread extends Thread {
    @Override 
    public void run() {
        // thread가 실행할 코드
    }
}
// thread 객체 생성
Thread thread = new WorkerThread();

- Thread 익명 자식 객체를 사용하는 방법
 
Thread thread = new Threaed() {
    @Override
    public void run() {
        // thread가 실행할 코드
    }
};
thread.start();
- 예제
package ch14.sec3_1;
import java.awt.Toolkit;
public class BeepPrintExample {
	public static void main(String[] args) {
		Thread thread = new Thread() {
			@Override
			public void run() {
				Toolkit toolkit = Toolkit.getDefaultToolkit();
				for(int i = 0; i < 5; i++) {
					toolkit.beep();
					try { Thread.sleep(500); } catch(Exception e) {}
				}
			}
		};
		
		thread.start();
		
		for(int i = 0; i < 5; i++) {
			System.out.println("띵");
			try { Thread.sleep(500); } catch(Exception e) {}
		}
	}
}
3. 스레드 이름
- thread는 자신의 이름을 가짐
- main thread는 'main'이라는 이름을 가짐
 - worker thread는 자동적으로 'Thread-n'이라는 이름을 가짐
 
 - worker thread 이름을 다른 이름으로 설정하고 싶다면, Thread 클래스의 setName() method 사용
 
thread.setName("스레드 이름");
- thread name은 주로 디버깅할 때, 어떤 thread가 작업을 하는지 조사할 목적으로 주로 사용
 - 현재 코드를 어떤 thread가 실행하고 있는지 확인하려면 -> static method인 currentThread()로 thread 객체의 참조를 얻은 다음, getName() method name을 출력
 
Thread thread = Thread.currentThreaed();
System.out.println(threaed.getName());
4. 스레드 상태
- thread object를 생성(NEW)하고, start() method를 호출하면 바로 스레드가 실행되는 것이 아니라 실행 대기 상태(RUNNABLE)가 됨
 - 실행(RUNNING) 상태: CPU scheduling에 따라 CPU를 점유하고 run() method를 실행
- running thread는 run() method를 모두 실행하기 전에 scheduling에 의해, 다시 실행 대기 상태로 돌아갈 수 있음
 
 - 실행 상태에서 run() method 종료 -> 더이상 실행할 코드가 없기 때문에 threaed의 실행이 멈춤 -> 종료(TERMINATED) 상태
 - 실행 상태에서 일시 정지 상태로 가기도 함 -> 일시 정지 상태는 thread가 실행할 수 없는 상태를 의미
- 스레드가 다시 실행 상태로 가기 위해서는 일시 정지 상태에서 실행 대기 상태로 가야함
 
 



4.1. 주어진 시간 동안 일시 정지
try {
    Thread.sleep(1000);
} catch(InterruptedException e) {
    // interrupt() method가 호출되면 실행
}
- 예제
package ch14.sec5.exam1;
import java.awt.Toolkit;
public class SleepExample {
	public static void main(String[] args) {
		Toolkit toolkit = Toolkit.getDefaultToolkit();
		for(int i = 0; i < 10; i++) {
			toolkit.beep();
			try {
				Thread.sleep(3000);
			} catch(InterruptedException e) {
			}
		}
	}
}
-> 3초 주기로 beep음을 10번 발생
4.2. 다른 스레드의 종료를 기다림
- 다른 thread가 종료될 때까지 기다렸다가 실행해야 하는 경우
 

- 예제
package ch14.sec5.exam2;
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;
		}
	}
}
package ch14.sec5.exam2;
public class JoinExample {
	public static void main(String[] args) {
		SumThread sumThread = new SumThread();
		sumThread.start();
		try {
			sumThread.join();	//sumThread 대기
		} catch (InterruptedException e) {
		}
		System.out.println("1~100 합: " + sumThread.getSum());
	}
}

- join으로 기다리지 않는 경우, 그냥 끝내버림
 
package ch14.sec5.exam2;
public class JoinExample {
	public static void main(String[] args) {
		SumThread sumThread = new SumThread();
		sumThread.start();
//		try {
//			sumThread.join();	//sumThread 대기
//		} catch (InterruptedException e) {
//		}
		System.out.println("1~100 합: " + sumThread.getSum());
	}
}

4.3. 다른 스레드에게 실행 양보
- thread가 처리하는 작업은 무의미안 반복문을 실행하는 경우가 존재 -> 이때 다른 thread에게 실행을 양보하고 자신은 실행 대기 상태로 가는 것이 프로그램 성능에 도움
 
public void run() {
    while(true) {
        if(work) {
            System.out.println("ThreadA 작업 내용");
        }
    }
}
-> 무의미한 반복
public void run() {
    while(true) {
        if(work) {
            System.out.println("ThreadA 작업 내용");
        } else {
            Thread.yield();
        }
    }
}
- 예제
package ch14.sec5.exam3;
public class WorkThread extends Thread {
	// field
	public boolean work = true;
	
	// constructor
	public WorkThread(String name) {
		setName(name);
	}
	
	// method
	@Override
	public void run() {
		while(true) {
			if(work) {
				System.out.println(getName() + ": 작업처리");
			} else {
				Thread.yield();
			}
		}
	}
}
package ch14.sec5.exam3;
public class YieldExample {
	public static void main(String[] args) {
		WorkThread workThreadA = new WorkThread("workThreadA");
		WorkThread workThreadB = new WorkThread("workThreadB");
		workThreadA.start();
		workThreadB.start();
		
		try { Thread.sleep(5000); } catch (InterruptedException e) {}
		workThreadA.work = false;
		
		try { Thread.sleep(10000); } catch (InterruptedException e) {}
		workThreadA.work = true;
	}
}

5. 스레드 동기화
- 멀티 스레드는 하나의 객체를 공유해서 작업할 수도 있음 -> 다른 thread에 의해 의도했던 것과 다른 결과 초래 가능성
 - 이를 위해 자바는 synchronized method와 block을 제공
 - 객체 내부에 synchronized method와 block이 여러 개 있으면, thread가 이들 중 하나를 실행할 때 다른 thread는 해당 method는 물론이고 다른 synchronized method 및 block도 실행 불가
 - 일반 method는 실행 가능
 

5.1. 동기화 메소드 및 블록 선언
public synchronized void method() {
    // 단 하나의 thread만 실행하는 영역
}
- 메소드 전체가 아닌 일부 영역을 실행할 때만 객체 잠금을 걸고 싶은 경우
 
public void method() {
    // multiple threads가 실행 가능한 영역
    
    synchronized(공유객체) {
        // 단 하나의 thread만 실행 가능한 영역
    }
    
    // multiple threads가 실행 가능한 영역
}
- 예제
package ch14.sec6.exam1;
public class Calculator {
	private int memory;
	
	public int getMemory() {
		return memory;
	}
	
	// synchronized method
	public synchronized void setMemory1(int memory) {
		this.memory = memory;
		try {
			Thread.sleep(2000);
		} catch(InterruptedException e) {}
		System.out.println(Thread.currentThread().getName() + ": " + this.memory);
	}
	
	// synchronized block
	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);
		}
	}
}
package ch14.sec6.exam1;
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);
	}
}
package ch14.sec6.exam1;
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);
	}
}
package ch14.sec6.exam1;
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();
	}
}

-> 동기화 블록 혹은 메소드 선언을 했기 때문에, 매개값을 입력한대로 예상한 결과 출력

-> setMemory에서 동기화 선언을 제거하고 실행한 경우
5.2. wait()과 notify()를 이용한 스레드 제어
- 두 개의 threads를 교대로 번갈하가며, 교대 작업이 필요할 경우
 - 공유 객체 -> 두 threads가 작업할 내용을 각각 동기화 method로 설정
 - 한 thread가 작업을 완료하면, notify() method 호출
- 일시 정지 상태의 thread -> 실행 대기 상태
 
 - 자신은 두 번 작업을 하지 않도록 wait() method 호출
- 실행 -> 일시 정지 상태
 
 - wait() & notify()는 synchronized method or synchronized block 내에서만 사용 가능
 

- 예제
package ch14.sec6.exam2;
public class WorkObject {
	// synchronized method
	public synchronized void methodA() {
		Thread thread = Thread.currentThread();
		System.out.println(thread.getName() + ": methodA 작업 실행");
		notify();	// 다른 thread를 실행 대기 상태로 만
		try {
			wait();	// 자신의 thread를 일시 정지 상태로 만
		} catch (InterruptedException e) {
		}
	}
	
	public synchronized void methodB() {
		Thread thread = Thread.currentThread();
		System.out.println(thread.getName() + ": methodB 작업 실행");
		notify();
		try {
			wait();
		} catch (InterruptedException e) {
		}
	}
}
package ch14.sec6.exam2;
public class ThreadA extends Thread {
	private WorkObject workObject;
	
	// parameter로 공유 작업 객체를 받음
	public ThreadA(WorkObject workObject) {
		setName("ThreadA");
		this.workObject = workObject;
	}
	
	@Override
	public void run() {
		for(int i = 0; i < 10; i++) {
			workObject.methodA();	// synchronized method 호
		}
	}
}
package ch14.sec6.exam2;
public class ThreadB extends Thread {
	private WorkObject workObject;
	
	// parameter로 공유 작업 객체를 받음
	public ThreadB(WorkObject workObject) {
		setName("ThreadB");
		this.workObject = workObject;
	}
	
	@Override
	public void run() {
		for(int i = 0; i < 10; i++) {
			workObject.methodB();	// synchronized method 호
		}
	}
}
package ch14.sec6.exam2;
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();
	}
}

6. 스레드 안전 종료
- thread는 run() method가 모두 실행되면 자동적으로 종료되지만, 경우에 따라 실행중인 thread를 즉시 종료할 필요가 있음
- ex) 동영상을 끝까지 보지 않고, 사용자가 멈춤을 요구하는 경우
 
 - thread를 강제 종료시키기 위해 Thread는 stop() method를 제공 -> deprecated(더 이상 사용되지 않음)
- resources(file, network, ...)가 불안정한 상태로 남겨지기 때문
 
 - thread를 안전하게 종료하는 방법: 사용하던 resources를 정리하고, run() method를 빨리 종료하는 것
- 조건 이용
 - interrupt() method
 
 
6.1. 조건 이용
public class XXXThread extends Thread {
    private boolean stop;
    
    public void run() {
    	// stop이 true가 되면 반복문 탈출
        while( !stop ) {
        	// thread가 반복 실행하는 코드;
        }
    	// thread가 사용한 resource 정리
    }
}
- 예제
package ch14.sec7.exam1;
public class PrintThread 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("실행 종료");
	}
}
package ch14.sec7.exam1;
public class SafeStopExample {
	public static void main(String[] args) {
		PrintThread printThread = new PrintThread();
		printThread.start();
		
		try {
			Thread.sleep(3000);
		} catch (InterruptedException e) {
		}
		
		printThread.setStop(true);
	}
}
-> main thread 3초 재웠다가, stop을 true로 설정

6.2. interrupt() 메소드 이용
- interrupt() method: 일시 정지 상태에 있을 때, InterruptedException 예외를 발생시키는 역할
 - 예외 처리를 통해 run() method를 정상 종료 가능
 

- 예제
package ch14.sec7.exam2;
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("실행 종료");
	}
}
package ch14.sec7.exam2;
public class InterruptExample {
	public static void main(String[] args) {
		Thread thread = new PrintThread();
		thread.start();
		
		try {
			Thread.sleep(1000);
		} catch (InterruptedException e) {
		}
		
		thread.interrupt();
	}
}

7. 데몬 스레드
- 데몬(Daemon) 스레드: main thread의 작업을 돕는 보조적인 역할을 수행하는 thread
 - main thread가 종료되면, daemon thread도 따라서 자동으로 종료
 - ex) 워드프로세서의 자동 저장, 미디어 플레이어의 동영상 및 음악 재생, 가비지 컬렉터 -> main thread(워드프로세스, 미디어 플레이어, JVM)가 종료되면 자동 종료
 - thread를 daemon으로 만들기 위해, main thread가 daemon이 될 thread의 setDaemon(true)를 호출
 
public static void main(String[] args) {
    AutoSaveThread thread = new AutoSaveThread();
    thread.setDaemon(true);
    thread.start();
    ...
}
- 예제
package ch14.sec8;
public class AutoSaveThread extends Thread {
	public void save() {
		System.out.println("작업 내용을 저장함.");
	}
	
	@Override
	public void run() {
		while(true) {
			try {
				Thread.sleep(1000);
			} catch (InterruptedException e) {
				break;
			}
			save();
		}
	}
}
package ch14.sec8;
public class DaemonExample {
	public static void main(String[] args) {
		AutoSaveThread autoSaveThread = new AutoSaveThread();
		autoSaveThread.setDaemon(true);
		autoSaveThread.start();
		
		try {
			Thread.sleep(3000);
		} catch (InterruptedException e) {
		}
		
		System.out.println("메인 스레드 종료");
	}
}

-> main thread 종료 시에, 자동으로 종료
8. 스레드풀
- 병렬 작업 처리가 많아지면 threads의 개수가 폭증 -> CPU가 바빠지고, memory 사용량이 늘어남 -> application 성능 저하
 - 병렬 작업 증가로 인한 threads 폭증 방지 -> 스레드풀(ThreadPool) 사용
 - ThreadPool: 작업 처리에 사용되는 threads를 제한된 개수만큼 정해놓고, Job Queue에 들어오는 작업들을 thread가 하나씩 맡아 처리하는 방식
 

8.1. 스레드풀 생성
- 자바는 ThreadPool을 생성하고 사용할 수 있도록, java.util.concurrent 패키지에서 ExecutorService interface와 Executors class를 제공
 - 초기 수: 스레드풀이 생성될 때 기본적으로 생성되는 threads 수
 - 코어 수: threads가 증가된 후 사용되지 않는 threads를 제거할 때, 최소한 풀에서 유지하는 threads 수
 - 최대 수: 증가되는 threads의 한도 수
 
| 메소드명(매개변수) | 초기 수 | 코어 수 | 최대 수 | 
| newCachedThreadPool() | 0 | 0 | Integer.MAX_VALUE | 
| newFixedThreadPool(int nThreads) | 0 | 생성된 수 | nThreads | 
- newCachedThreadPool() method
- 초기 수와 코어 수가 0
 - 작업 개수가 많아지면 새 thread를 생성시켜 작업 처리
 - 60초 동안 thread가 아무 작업을 하지 않으면 thread를 pool에서 제거
 
 - newFixedThreadPool() method
- 초기 수 0
 - 작업 개수가 많아지면 최대 nThreads개까지 thread를 생성시켜 작업을 처리
 
 
ExecutorService executorService = Executors.newCachedThreadPool();
ExecutorService executorService = Executors.newFixedThreadPool(5);
- method 사용 없이, 직접 ThreadPoolExecutor로 threadpool 생성도 가능
 
ExecutorService threadPool = new ThreadPoolExecutor(
    3, // 코어 스레드 개수
    100, // 최대 스레드 개수
    120L, // 놀고 있는 시간
    TimeUnit.SECONDS, // 놀고 있는 시간 단위
    new SynchronousQueue<Runnable>() // 작업 큐
);
-> 120초 작업하지 않을 시에, 해당 thread를 pool에서 제거
8.2. 스레드풀 종료
- threadPool의 thread는 기본적으로 Daemon thread가 아니기 때문에, 자동 종료 X
 - ExecutorService의 다음 두 메소드 중 하나를 실행해서 종료해야 함
 
| 리턴 타입 | 메소드명(매개변수) | 설명 | 
| void | shutdown() | 현재 처리 중인 작업뿐만 아니라 작업 큐에 대기하고 있던 모든 작업을 처리한 뒤에 threadPool을 종료시킴 | 
| List<Runnable> | shutdownNow() | 현재 작업 처리 중인 thread를 interrupt해서 작업을 중지시키고 threadPool을 종료시킴. 리턴값은 작업 큐에 있는 미처리된 작업(Runnable)의 목록 | 
- shutdown(): 남아있는 작업을 마무리하고, threadPool을 종료
 - shutdownNow(): 남아있는 작업과 상관없이 강제 종료
 
8.3. 작업 생성과 처리 요청
- 하나의 작업은 Runnable 또는 Callable 구현 객체로 표현됨
- 둘의 차이점은 작업 처리 완료 후 리턴값의 유무
 
 
new Runnable() {
    @Override
    public void run() {
        // 스레드가 처리할 작업 내용
    }
}
new Callable<T>() {
    @Override
    public T call() throws Exception {
        // 스레드가 처리할 작업 내용
        return T;
    }
}
- 작업 처리 요청: ExecutorService의 job Queue에 Runnable 또는 Callable 객체를 넣는 행위
 
| 리턴 타입 | 메소드명(매개변수) | 설명 | 
| void | execute(Runnable command) | - Runnable을 job Queue에 저장 - 작업 처리 결과를 리턴 X  | 
| Future<T> | submit(Callable<T> task) | - Callable을 job Queue에 저장 - 작업 처리 결과를 얻을 수 있도록 Future를 리턴  | 
- 예제
package ch14.sec9.exam2;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class RunnableExecuteExample {
	public static void main(String[] args) {
		// 1000개의 메일 생성
		String[][] mails = new String[1000][3];
		for(int i = 0; i < mails.length; i++) {
			mails[i][0] = "admin@my.com";
			mails[i][1] = "member" + i + "@my.com";
			mails[i][2] = "신상품 입고";
		}
		
		// ExecutorService 생성
		ExecutorService executorService = Executors.newFixedThreadPool(5);
		
		// 이메일을 보내는 작업 생성 및 처리 요청
		for(int i = 0; i < 1000; i++) {
			final int idx = i;
			executorService.execute(new Runnable() {
				@Override
				public void run() {
					Thread thread = Thread.currentThread();
					String from = mails[idx][0];
					String to = mails[idx][1];
					String content = mails[idx][2];
					System.out.println("[" + thread.getName() + "]" + from + " ==> " + to + ": " + content);
				}
			});
		}
		
		// ExecutorService 종료
		executorService.shutdown();
		
	}
}

-> 1000개의 Runnable 생성 후, execute() method로 job Queue에 입력
-> ExecutorService는 최대 5개의 threads로 job Queue에서 Runnable을 하나씩 꺼내어 run() method를 실행하며 작업 처리
참고자료
728x90
    
    
  반응형
    
    
    
  'Programming Lang > Java' 카테고리의 다른 글
| [이것이 자바다] Chapter 16. 람다식 (2) | 2025.02.02 | 
|---|---|
| [이것이 자바다] Chapter 15. 컬렉션 자료구조 (0) | 2025.02.02 | 
| [이것이 자바다] Chapter 13. 제네릭 (0) | 2025.01.31 | 
| [이것이 자바다] Chapter 12. java.base 모듈 (0) | 2025.01.31 | 
| [이것이 자바다] Chapter 11. 예외 처리 (0) | 2025.01.30 |