열심히 끝까지

Java2 day15 본문

Java2(주말)

Java2 day15

노유림 2022. 5. 28. 16:24

JAVA2 day07,08
[15, 16일차 수업내용]
variable, if, for, array, class, interface, collection, thread
==> 이 부분들은 다시 공부할 것
Thread
=========================================================================
1. 프로세스와 쓰레드
            - 프로세스(process)란 간단히 말해서 '실행 중인 프로그램(program)' 이다.
            - 프로그램을 실행하면, O/S로부터 실행에 필요한 자원(메모리)를 할당받아 프로세스가 된다.
            - 프로세스는 프로그램을 수행하는데 필요한 데이터와 메모리 등의 자원 그리고 쓰레드로 구성되어 있으며,
              프로세스의 자원을 이용해서 실제로 작업을 수행하는 것이 바로 쓰레드이다.
            - 즉, 모든 프로세스에는 최소 하나 이상의 쓰레드가 존재하며,
              둘 이상의 쓰레드를 가진 프로세스를 '멀티 쓰레드 프로세스(Multi-Thread Process)'라고 한다.
            - 하나의 프로세스가 가질 수 있는 쓰레드의 개수는 제한되어 있지 않으나,
              쓰레드가 작업을 수행하는데 개발하는 메모리 공간(Call Stack)을 필요로 하기 때문에
              프로세스의 메모리 한계에 따라 생성할 수 있는 쓰레드의 수가 결정된다.
            
            1-1 ) 멀티태스킹과 멀티쓰레드
                        - 현재 우리가 사용하고 있는 O/S(Window)는 멀티태스킹(Multi-Tasking, 다중작업)을 

                          지원하기 때문에 여러 개의 프로세스가 동시에 실행될 수 있다.
                        - 이와 마찬가지로 멀티쓰레딩은 하나의 프로세스 내에서 여러 쓰레드가 동시에 작업을 

                          수행하는 것이다.
                        - 사실 동시에 실행된다는 개념이 아닌 매우 짧은 시간 간격으로 두 작업을 왔다갔다 하는 것이다.

            1-2 ) 멀티쓰레딩의 장단점
                                    - CPU의 사용률을 향상시킨다.
                                    - 자원을 보다 효율적으로 사용할 수 있다.
                                    - 사용자에 대한 응답성이 향상된다.
                                    - 작업이 분리되어 코드가 간결해진다.

                        - 메신저로 채팅하면서 파일을 다운로드 받거나 음성대화를 나눌 수 있는 이유가 바로 멀티쓰레드로
                          작성되어 있기 때문이다.
                        - 만일, 싱글쓰레드로 작성되어 있다면 파일을 다운로드 받는 동안에는 

                          다른 일을 전혀 할 수 없을 것이다.
                        - 여러 사용자에게 서비스를 제공해 주는 서버 프로그램의 경우 멀티쓰레드로 작성하는 것이 

                          필수적이어서 하나의 서버 프로세스가 여러 개의 쓰레드를 생성해서 쓰레드와 사용자의 요청이 

                          일대일로 처리되도록 프로그래밍을 해야한다.
                        - 만일 싱글쓰레드로 서버 프로그램을 작성한다면, 사용자의 요청마다 새로운 프로세스를 

                          생성해야 하는데 프로세스를 생성하는 것은 쓰레드를 생성하는 것에 비해 

                          더 많은 시간과 메모리 공간을 필요로 하기 때문에 많은 수의 사용자 요청을 서비스하기 어렵다.
                        - 멀티쓰레딩에는 장점만 있는 것이 아니다.
                        - 멀티쓰레드 프로세스는 여러 쓰레드가 같은 프로세스 내에서 자원을 공유하면서
                          작업하기 때문에 발생할 수 있는 동기화(Synchronization)과 교착상태(deallock)과 같은 문제들을
                          고려해서 신중히 프로그래밍을 해야한다.

2. 쓰레드의 구현과 실행
            - 쓰레드를 구현하는 방법은 Thread클래스를 상속받는 방법과, Runnable 인터페이스를 구현하는 방법 

              2가지가 있다.
            - 두 방법 어느 것을 선택해도 상관없지만, 클래스를 상속받는 경우에는 다른 클래스를 상속받지 못한다는 

              제한이 있으며, 인터페이스를 구현하는 방법은 재사용성이 높고 코드의 일관성을 유지할 수 있기 때문에 

              보다 더 객체지향적인 방법이라고 할 수 있다.
            
                        ① Thread 클래스를 상속
                                    class MyThread extends Thread{
                                                @override
                                                public void run(){ /* 작업 내용 */ }
                                                // Thread클래스의 run() 메소드를 오버라이딩
                                    }

                        ② Runnable 인터페이스를 구현
                                    class MyThread implements Runnable{
                                                @override
                                                public void run(){ /* 작업 내용 */ }
                                                // Runnable 인터페이스의 run()을 구현
                                    }

            - 쓰레드를 구현한다는 것은 위의 두 방법 중 어떤 것을 선택하든지 그저 쓰레드를 통해 

              작업하고자 하는 내용으로 run()의 몸통{}을 채워주는 것이다.
            
            - 쓰레드의 실행 : start()
                        - 쓰레드를 생성했다고 해서 자동으로 실행되는 것은 아니다.
                        - start()를 호출해야만 쓰레드가 실행된다.

                        쓰레드 참조변수 t1, t2가 있다고 가정할 때
                        t1.run()(X), t2.run()(X)

                        t1.start();                        // 쓰레드 t1을 실행
                        t2.start();                        // 쓰레드 t2를 실행

            - 사실 start()메소드가 호출되었다고 해서 바로 실행되는 것이 아니라, 
              일단 실행대기 상태에 있다가 자신의 차례가 되어야 실행한다.
            - 물론 실행대기중인 쓰레드가 하나도 없다면 바로 실행상태가 된다.
            - 쓰레드의 실행순서는 O/S의 스케쥴러가 작성한 스케쥴러에 의해 결정된다.
            - 한번 실행이 종료된 쓰레드는 다시 실행할 수 없다.
                                    ==> 즉 하나의 쓰레드에 대해 start()가 단 한번만 호출될 수 있다.
            - 그래서 만일 쓰레드의 작업을 한번 더 수행해야 한다면 새로운 객체를 만들어서 start()를 호출해야 한다.

3. start() run()
            - 우리는 run()에 작업내용을 작성했는데, 실제로 실행되는 메소드는 start()라는 점에서 의문이 생길 것이다.
            - main()에서 run()를 호출한다는 것은 생성된 쓰레드를 실행시키는 것이 아니라 

              단지 클래스에 선언된 메소드를 호출하는 것이다.
            - 반면에 start()메소드는 쓰레드가 작업을 실행나는데 필요한 호출스택(Call Stack)을 생성한 다음에,
              run()을 호출해서 생성된 호출스택(Call Stack)에 run()이 첫번째로 올라가게 된다.
            - 모든 쓰레드는 독립적인 작업을 수행하기 위해 자신만의 호출스택을 필요로 하기 때문에,
              새로운 쓰레드를 생성하고 실행시킬 때마다 새로운 호출스택을 필요로 하기 때문에,
              새로운 쓰레드를 생성하고 실행시킬 때마다 새로운 호출스택이 생성되고 쓰레드가 종료되는 작업에
              사용된 호출스택은 소멸한다.
            - 호출스택을 배울 때 가장 위에 있는 메소드가 현재 실행중인 메소드이고 나머지 메소드는 실행중인 메소드가
              끝날 때 까지 대기상태라는 걸 배웠다.
            - 허나, 쓰레드가 둘 이상일 때는 호출스택의 최상위에 있는 메소드일지라도 대기상태에 있을 수 있다.
            - 스케줄러는 대기중인 쓰레드들의 우선순위를 고려하여 실행순서와 실행시간을 결정하고,
              각 쓰레드들은 작성된 스케쥴러에 따라 자신의 순서가 되면 지정된 시간동안 작업을 수행한다.
            - 이 때 주어진 시간동안 작업을 마치지 못한 쓰레드는 다시 차례가 돌아올 때까지 대기상태로 있게되며,
              작업을 마친 쓰레드, 즉 run() 수행이 종료된 쓰레드는 호출스택이 비워지면서 이 쓰레드가 사용하던 

              호출스택은 소멸한다.

            * main 쓰레드
                        - main()메소드의 작업을 수행하는 것 쓰레드이며 이를 main쓰레드라고 한다.
                        - 우리는 지금까지 이미 쓰레드를 사용하고 있던 것이다.
                        - 앞서 쓰레드가 일꾼이라 하였는데, 프로그램이 실행되기 위해서는 작업을 수행하는 일꾼이 

                          최소한 하나는 필요하다.
                        - 그래서 프로그램을 실행하면 기본적으로 하나의 쓰레드를 생성하고 그 쓰레드가 main을 호출해서
                          작업이 수행되도록 하는 것이다. 
                        - 지금까지는 main()메소드가 수행을 마치면 프로그램이 종료되었으나, main()메소드가 

                          수행을 마쳤다 하더라도 다른 쓰레드가 아직 작업을 마치지 않은 상태라면 

                          프로그램이 종료되지 않는다.
                                    ==> 바꿔말해, 실행중인 사용자 쓰레드가 하나도 없을 때 프로그램이 종료된다.
                        - 쓰레드는 '사용자 쓰레드(user thread)' '데몬 쓰레드(daemon thread)' 두 종류가 있다.

-----------------------------------------------------------------

package example01;

public class ThreadEx01 {
            public static void main(String[] args) {
                        // 1. Thread 클래스를 상속받는 Thread의 객체 생성
                        ThreadEx1_1 t1 = new ThreadEx1_1();

                        // 2. Runnable 인터페이스를 구현한 Thread의 객체 생성
                        Runnable r = new ThreadEx1_2();
                        Thread t2 = new Thread(r);
                        // Thread t2 = new Thread(new ThreadEx1_2()); // 위와 같음

                        // 실행
                        // run()으로 하게되면 ThreadEx1_2는 죽었다 깨어나도 먼저 위로 올라오거나 하지 않음
                        t1.start();
                        t2.start();
            }
}

// 1. Thread 클래스를 상속받은 Thread 구현
class ThreadEx1_1 extends Thread{
            @Override
            public void run() {
                        for(int i = 0; i < 5; i++) {
                                    System.out.println("ThreadEx1_1");
                        }
            }
}

//2. Runnable 인터페이스를 구현한 Thread 구현
class ThreadEx1_2 implements Runnable{
            @Override
            public void run() {
                        for(int i = 0; i < 5; i++) {
                                    System.out.println("ThreadEx1_2");
                        }
            }
}

--------------------------------------------------
4. 싱글쓰레드와 멀티쓰레드
            - 두 개의 작업을 하나의 쓰레드(th1)로 처리하는 경우와 
              두 개의 쓰레드(th1, th2)로 처리하는 경우를 가정해보자.
            - 하나의 쓰레드로 두 작업을 처리하는 경우는 한 작업을 마친 후에 다른 작업을 시작하지만,
              두 개의 쓰레드로 작업을 하는 경우에는 짧은 시간동안 2개의 쓰레드가 번갈아 가면서
              작업을 동시에 처리하는 것과 같이 느끼게 한다.
            - 하나의 쓰레드로 두 개의 작업을 수행한 시간과 
              두 개의 쓰레드로 두 개의 작업을 수행한 시간은 거의 같다.
            - 오히려 두 개의 쓰레드 작업한 시간이 싱글쓰레드로 작업한 시간보다 더 걸리게 되는데
              그 이유가 쓰레드간의 작업전환(context switching)에 시간이 걸리기 때문이다.
            - 작업전환을 할 때는 현재 진행 중인 작업의 상태, 
              예를 들면 다음에 실행해야 할 위치 등의 정보를 저장하고 읽어오는 시간이 소요된다.
            - 우리가 실습하는 예제는 실행할 때마다 다른 결과를 얻을 수 있는데 그 이유는 실행중인 예제가 
              O/S의 프로세스 스케쥴러의 영향을 받기 때문이다.  

------------------------------------
package example01;

public class ThreadEx02 {
            public static void main(String[] args) {
                        // 싱글쓰레드 소요시간 확인
                        long startTime = System.currentTimeMillis();

                        for(int i = 0; i < 300; i++) {
                                    System.out.printf("%s", new String("-"));
                        }

                        for(int i = 0; i < 300; i++) {
                                    System.out.printf("%s", new String("|"));
                        }

                        System.out.println("소요시간 : " + (System.currentTimeMillis() - startTime));
            }
}

--------------------------------
package example01;

public class ThreadEx03 {
            public static void main(String[] args) throws Exception {
                        // 멀티쓰레드 소요시간 확인, 작업전환(context switching)
                        // throws Exception + t1.join 을 붙이면 중간에 뜨는 소요시간을
                        // 모든 작업이 끝나고 뜨도록 만들 수 있다.
                        ThreadEx3_1 t1 = new ThreadEx3_1();
                        t1.start();

                        long startTime = System.currentTimeMillis();
                        for(int i = 0; i < 300; i++) {
                                    System.out.printf("%s" , new String("|"));
                        }
                        t1.join();
                        System.out.println("소요시간 : " + (System.currentTimeMillis() - startTime));
            }
}

class ThreadEx3_1 extends Thread{
@Override
            public void run() {
                        for(int i = 0; i < 300; i++) {
                                   System.out.printf("%s" , new String("-"));
                        }
            }
}
---------------------------------
package example01;

import javax.swing.JOptionPane;

public class ThreadEx04 {
            public static void main(String[] args) {
                        // 싱글쓰레드 : 외부로부터 데이터 입력 받기
                        String input = JOptionPane.showInputDialog("아무값이나 입력하세요");

                        for(int i = 0; i < 10; i++) {
                                    System.out.println(i);
                                    try {
                                                Thread.sleep(1000);     // 1초동안 재움
                                    } catch (Exception e) {}
                        }
            }
}

--------------------------------
package example01;

import javax.swing.JOptionPane;

public class ThreadEx05 {
            public static void main(String[] args) {
                        // 멀티쓰레드 : 외부로부터 데이터 입력받기
                        ThreadEx5_1 th1 = new ThreadEx5_1();
                        th1.start();

                        String input = JOptionPane.showInputDialog("아무값이나 입력하세요");
                        System.out.println("input : " + input);
            }
}

class ThreadEx5_1 extends Thread{
            @Override
            public void run() {
                        for(int i = 0; i < 10; i++) {
                                    System.out.println(i);
                                    try {
                                                Thread.sleep(1000);
                                    } catch(Exception e) {}
                        }
            }
}

-----------------------------------------------------------------
5. 쓰레드의 우선순위
            - 쓰레드는 우선순위(Priority) 라는 속성(멤버변수)을 가지고 있는데, 
              이 우선순위의 값에 따라 쓰레드가 얻는 실행시간이 달라진다.
            - 쓰레드가 수행하는 작업의 중요도에 따라 쓰레드의 우선순위를 서로 다르게 지정하여
              특정 쓰레드가 더 많은 작업시간을 갖도록 할 수 있다.
                        kakaotalk 파일전송 vs 채팅 ==> 우선순위가 무엇이 더 높아야 할까?
            - 예를 들어 파일전송기능이 있는 메신저의 경우, 파일다운로드를 처리하는 쓰레드보다
              채팅내용을 전송하는 쓰레드의 우선순위가 더 높아야 사용자가  채팅하는데 불편함이 없을 것이다.
                        ( 대신, 파일 다운로드 하는 시간이 좀 더 길어질 것이다. )
            - 이처럼 시각적인 부분이나 사용자에게 빠르게 반응해야 하는 작업을 하는 쓰레드의 우선순위를
              다른 작업을 수행하는 쓰레드에 비해 높아야 한다.

            * 쓰레드의 우선순위 지정하기
                        void setPriority(int newPriority) : 쓰레드의 우선순위를 지정한 값으로 변경한다.
              
                        int getPriority() : 쓰레드의 우선순위를 반환한다.

                        public static final int MAX_PRIORITY = 10;                // 최대 우선순위
                        public static final int MIN_PRIORITY = 1;                  // 최소 우선순위
                        public static final int MORM_PRIORITY = 5;               // 보통 우선순위(default)

                        - 쓰레드가 가질 수 있는 우선순위 범위는 1~10이며 숫자가 높을수록 우선순위가 높다.
                        - 쓰레드의 우선순위는 쓰레드를 생성한 쓰레드로부터 상속받는다.
                                    ==> main()을 수행하는 우선순위가 5이므로,
                                           main()에서 생성하는 쓰레드의 우선순위는 자동적으로 5가 된다.

6. 데몬쓰레드 ( daemon thread )
            - 데몬쓰레드는다른 일반쓰레드(데몬쓰레드가 아닌 쓰레드)의 작업을 돕는
              보조적인 역할을 수행하는 쓰레드이다.
            - 일반쓰레드가 모두 종료되면 데몬쓰레드는 강제적으로 자동 종료되는데 그 이유는
              데몬쓰레드는 일반쓰레드의 보조역할을 수행하므로 일반쓰레드가 종료되고 나면
              데몬쓰레드의 존재의 의미가 없기 때문이다.            
            - 이 점을 제외하고는 데몬쓰레드와 일반쓰레드는 다르지 않으며 데몬쓰레드의 대표적인 예로는
              GC(Garbage Collector), 자동저장 등이 있다.
            - 데몬쓰레드는 무한루프와 조건문을 이용해서 실행 후 대기하고 있다가 특정 조건이 만족되면
              작업을 수행하고 다시 대기하도록 작성한다.
            - 데몬쓰레드는 일반쓰레드의 작성방법과 실행방법이 같으며 다만 쓰레드를 생성한 다음 실행하기 전에
              setDaemon(true) 메소드를 호출하기만 하면 된다.

                        boolean isDaemon() : 쓰레드가 데몬쓰레드인지 확인한다. 데몬쓰레드이면 true 반환
                        void setDaemon(boolean on)
                                    : 쓰레드를 데몬쓰레드로 또는 사용자 쓰레드로 변환한다.
                                      매개변수 on의 값을 true로 지정하면 데몬쓰레드가 된다.

-----------------------------------------------------------
package example01;

public class ThreadEx06 {
            public static void main(String[] args) {
                        ThreadEx6_1 th1 = new ThreadEx6_1();
                        ThreadEx6_2 th2 = new ThreadEx6_2();

                        System.out.println("[우선순위 변경 전]");
                        System.out.println("Priority of th1 : " + th1.getPriority());
                        System.out.println("Priority of th2 : " + th2.getPriority());

                        // th1.start();
                        // th2.start(); 

                        th1.setPriority(1);
                        th2.setPriority(10);;

                        System.out.println("[우선순위 변경 후]");
                        System.out.println("Priority of th1 : " + th1.getPriority());
                        System.out.println("Priority of th2 : " + th2.getPriority());

                        th1.start();
                        th2.start();
            }
}

class ThreadEx6_1 extends Thread{
            @Override
            public void run() {
                        for(int i = 0; i < 300; i++) {
                                    System.out.print("-");
                                    for(int x = 0; x < 10000000; x++);  // 시간지연 목적
                        }
            }
}

class ThreadEx6_2 extends Thread{
            @Override
            public void run() {
                        for(int i = 0; i < 300; i++) {
                                    System.out.print("|");
                                    for(int x = 0; x < 10000000; x++);  // 시간지연 목적
                        }
            }
}

-----------------------------------------
package example01;

public class ThreadEx07 implements Runnable{
            static boolean autoSave = false;

            public void autoSave() {
                        System.out.println("작업파일이 저장되었습니다.");
            }

            @Override
            public void run() {
                        while(true) {
                                    try {
                                                Thread.sleep(3000);
                                    } catch(Exception e) {}

                                    if(autoSave) {
                                                autoSave();
                                    }
                        }
            }

            public static void main(String[] args) {
                        /*
                         *  Runnable r = new ThreadEx07();
                         *  Thread t = new Thread(r);
                         */
                        Thread t = new Thread(new ThreadEx07());
                        t.setDaemon(true);
                        t.start();

                        for(int i = 0; i <= 10; i++) {
                                    try {
                                               Thread.sleep(1000);
                                   }catch(InterruptedException e) {}
                                    System.out.println(i);
                                    if(i == 5) {
                                                autoSave = true;
                                    }
                        }

            }
}

-------------------------------------------------------
7. 쓰레드의 실행제어
            - 쓰레드 프로그래밍이 어려운 이유는 동기화(synchronization) 스케쥴링(scheduling)때문이다.
            - 효율적인 멀티쓰레드 프로그램을 만들기 위해서는 보다 정교한 스케줄링을 통해 프로세스에게
              주어진 자원과 시간을 여러 쓰레드가 낭비없이 잘 사용하도록 프로그래밍 해야한다.
            - 쓰레드의 스케쥴링을 잘하기 위해서는 쓰레드의 상태와 관련된 메소드들을 잘 알아야 한다.

            * 쓰레드의 상태

            NEW                        쓰레드가 생성되고 아직 start()가 호출되지 않은 상태

            RUNNABLE                실행 중 또는 실행 가능한 상태

            BLOCKED                  동기화블록에 의해서 일시정지된 상태 ( lock이 풀릴 때까지 기다리는 상태)

            WAITING                  쓰레드의 작업이 종료되지는 않았지만 실행가능하지 않는 일시정지 상태
            TIMED_WAITING        TIMED_WAITING은 일시정지시간이 지정된 경우를 의미한다.

            TERMINATED             쓰레드의 작업이 종료된 상태

            * 쓰레드의 스케쥴링과 관련된 메소드

                        메소드                                                                        설명
            
            static void sleep(long millis)                          지정된 시간(천분의1초)동안 쓰레드를 일시정지 시킨다.
            static void sleep(long millis, int nanos)             지정한 시간이 지나고 나면, 자동으로 다시 

                                                                          실행대기상태가 된다.

            void join()
            void join(long millis)                                   쓰레드 자신이 하던 작업을 잠시 멈추고
            void join(long millis, int nanos)                      다른 쓰레드가 지정한 시간동안 작업을 수행하도록 하게 한다.

            void interrupted()                                       sleep()이나 join()에 의해 일시정지상태인 쓰레드를 깨워서
                                                                        실행대기상태로 만든다.
                                                                        해당 쓰레드에서는 InterruptedException이 발생함으로써
                                                                        일시정지상태를 벗어나게 한다.

            void stop()                                               쓰레드를 즉시 종료시킨다.

                                                                        (교착상태를 발생시키기 쉽다)(사용하지 않음)

            void suspend()                                          쓰레드를 즉시 일시정지 시킨다.
                                                                        resume()을 호출하면 다시 실행대기상태가 된다.

            void yield()                                              실행중에 자신에게 주어진 실행시간을 

                                                                        다른 쓰레드에게 양보하고 자신은 실행대기상태가 된다.

-------------------------------------------------
package example01;

public class ThreadEx08 {
            public static void main(String[] args) {
                        ThreadEx8_1 th1 = new ThreadEx8_1();
                        ThreadEx8_2 th2 = new ThreadEx8_2();

                        th1.start();
                        th2.start();

                        try {
                                    // th1.sleep(2000);
                                    Thread.sleep(2000);
                        } catch(InterruptedException e) {}

                        System.out.println("<<main 종료>>");
            }
}

class ThreadEx8_1 extends Thread{
            @Override
            public void run() {
                        for(int i = 0; i < 300; i++){
                                    System.out.print("-");
                        }
                        System.out.println("<<th1 종료>>");
            }
}

class ThreadEx8_2 extends Thread{
            @Override
            public void run() {
                        for(int i = 0; i < 300; i++) {
                                    System.out.print("|");
                        }
                        System.out.println("<<th2 종료>>");
            }
}

-------------------------------------------------
            7-1 ) sleep() - 일정시간동안 쓰레드를 멈추게 한다.
                        static void sleep(long millis)
                        static void sleep(long millis, int nanos)

                        - 밀리세컨드와 나노세컨드의 시간단위로 세밀하게 값을 지정할 순 있지만,
                          어느정도의 오차는 발생할 수 있다.
                        - sleep()에 의해 일시정지 상태가 된 쓰레드는 지정된 시간이 다 지나거나
                          interrupt()가 호출되면 잠에서 깨 실행대기 상태가 된다.
                          ( InterruptedException 예외 발생 )
                        - 그래서 sleep()을 호출할 때는 항상 예외처리를 해주어야 한다.
                        - sleep()은 항상 현재 실행중인 쓰레드에 의해 작동하기 때문에 
                          th1.sleep(2000);과 같이 호출하였어도 실제로 영향을 받는 것은
                          main()을 실행하는 main쓰레드이다.
                        - sleep()은 static으로 선언되어 있으므로 참조변수를 이용해서 호출하기보다는
                          Threa.sleep(2000);과 같이 static한 방법으로 사용해야 한다. 

'Java2(주말)' 카테고리의 다른 글

Java2 day16  (0) 2022.05.29
Java2 day14  (0) 2022.05.22
Java2 day13  (0) 2022.05.21
Java2 day12  (0) 2022.05.15
Java2 day11 보충  (0) 2022.05.15