스레드 Pool

병렬 작업 처리가 많아지면 스레드 갯수가 증가하고 그에 따른 스레드 생성과 스케줄링으로 인해 메모리 사용량이 늘어납니다.
갑작스런 병렬 작업의 폭증으로 인한 스레드의 폭증을 막으려면 스레드풀을 사용해야 합니다.

스레드풀은 작업 처리에 사용되는 스레드를 제한된 갯수만큼 정해 놓고 큐에 들어오는 작업들을 하나씩 스레드가 맡아 처리합니다. 작업 처리가 끝난 스레드는 다시 작업 큐에서 새로운 작업을 가져와 처리합니다. 그렇기 때문에 작업 처리 요청이 폭증 되어도 스레드의 전체 개수가 늘어나지 않으므로 애플리케이션의 성능이 급격히 저하되지 않습니다

자바는 스레드풀을 생성하고 사용할 수 있도록 ExecutorService 인터페이스와 Executors 클래스를 제공하고 있습니다


스레드풀 생성 및 종료

스레드풀 생성

스레드풀을 생성하는 방법은 크게 2가지가 있습니다.

  1. newCachedThreadPool()
    • 초기스레드0, 코어스레드0, 최대스레드 Integer.MAX_VALUE
  2. newFixThreadPool(int nThread)
    • 초기스레드0, 코어스레드0, 최대스레드 nThreads
  • 초기 스레드 수 : ExecutorService 객체가 생성될 때 기본적으로 생성되는 스레드 수
  • 코어 스레드 수 : 스레드가 늘어난 후 사용되지 않은 스레드를 스레드 풀에서 제거할 때 최소한으로 유지해야할 수
  • 최대 스레드 수 : 스레드풀에서 관리하는 최대 스레드 수
newCachedThreadPool()

스레드 개수보다 작업 개수가 많아지면 새로운 스레드를 생성하여 작업을 처리합니다
만약 스레드가 60초동안 아무일을 하지않으면 스레드를 종료시키고 스레드풀에서 제거합니다

ExecutorService executorService = Executors.newCachedThreadPool();
newFixedThreadPool(int nThreads)

스레드 개수보다 작업 개수가 많으면 스레드를 새로 생성하여 작업을 처리합니다
newCachedThreadPool과 다른 점은 일을 하지 않아도 스레드를 제거하지 않습니다

ExecutorService executorService = Executors. newFixThreadPool();

newCachedThreadPool(),newFixedThreadPool() 메서드를 사용하지 않고
직접 스레드 개수들을 설정하고 싶다면 직접 ThreadPoolExecutor 객체를 생성하면 됩니다.

ExecutorService threadPool = new ThreadPoolExecutor(
    3,                                     // 코어 스레드 개수
    100,                                // 최대 스레드 개수
    120L,                                // 놀고 있는 시간
    TimeUnit.SECONDS,                     // 놀고 있는 시간 단위
    new SynchronousQueue<Runnable>()     // 작업큐
);

 


스레드풀 종료

스레드풀의 스레드는 기본적으로 데몬 스레드가 아니기 때문에 main 스레드가 종료되더라도 작업을 처리하기 위해 계속 실행 상태로 남아 있습니다. 중요한 부분인데 main() 메서드가 실행이 끝나도 애플리케이션 프로세는 종료되지 않습니다. 따라서 애플리케이션 (ex, Spring Batch)을 종료하려면 스레드풀을 종료시켜 스레드들이 종료상태가 되도록 처리해야 합니다

executorService.shutDown();  
// 또는
executorService.shutDownNow();

shutdown()
작업큐에 남아있는 작업까지 모두 마무리 후 종료
shoutdownNow()
작업큐 작업 잔량 상관없이 강제 종료


스레드 작업 생성과 처리 요청

작업 생성

스레드 하나의 작업은 Runnable 또는 Callable 구현 클래스로 표현합니다.
Runnable과 Callable의 차이는 작업 처리 완료후 리턴값이 있느냐 없느냐 입니다.

Ruunable
Runnable task = new Runnable() {
    @Override
    public void run() {
        // 스레드가 처리할 작업 내용
    }
}
Callable
Callalbe<T> task = new Callable<T>() {
    @Override
    public T call throws Exception() {
        // 스레드가 처리할 작업 내용
        return T;
    }
}

작업 처리 요청

public class ExecuteExample {

    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(2);

        for(int i = 0; i < 10; i++){
            Runnable runnable = new Runnable() {
                @Override
                public void run() {
                    ThreadPoolExecutor threadPoolExecutor = (ThreadPoolExecutor) executorService;
                    int poolSize = threadPoolExecutor.getPoolSize();
                    String threadName = Thread.currentThread().getName();

                    System.out.println("[총 스레드 개수:" + poolSize + "] 작업 스레드 이름: "+threadName);
                    int value = Integer.parseInt("윤호");
                }
            };

            //스레드풀에게 작업 처리 요청
            executorService.execute(runnable);
            //executorService.submit(runnable);

            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                 e.printStackTrace();
            }        
        }     
        executorService.shutdown();
    }
}
execute()로 실행했을 경우


execute로 실행했을 때에는 작업 처리 도중 예외가 발생하면 해당 스레드는 제거 되고 새 스레드가 계속 생성됩니다.

submit()으로 실행 했을 경우


submit의 경우 예외가 발생하더라도 스레드가 종료되지 않고 계속 재사용이 되어 다른 작업을 처리하는 것을 볼 수 있습니다

execute와 submit의 차이는 두가지가 있습니다
하나는 execute()는 작첩 처리 결과를 받지 못하고, submit()((은 작업 처리 결과를 받을 수 있도록 Future를 리턴합니다.
두번째로는 위의 예에서 알아본 예외 발생의 경우입니다. execute()는 스레드를 종료하고 스레드풀에서 제거됩니다. submit()의 경우에는 예외가 발생하더라도 스레드는 종료되지 않고 다음 작업을 위해 재사용됩니다

그렇기 때문에 가급적 스레드 생성의 오버헤드를 줄이기 위해서 submit()을 사용하는 것이 좋습니다


참조

  • ‘이것이 자바다’ - 신용권님 저
블로그 이미지

사용자 yhmane

댓글을 달아 주세요

이전 포스팅에선 다음과 같이 알아 봤는데요

  • Thread 생성
  • Thread 동기화 블록

이번에는 Thread가 제공하는 몇가지 메서드를 확인해보고
Thread 상태를 제어하는 방법에 대해 알아보도록 하겠습니다


Thread 상태

스레드를 생성하고 start() 메서드를 호출하면 곧바로 스레드가 실행되는 것처럼 보이지만, 사실은 '실행대기' 상태가 됩니다.
실행대기 상태란 아직 스케줄링이 되지 않아 실행을 기다리고 있는 상태를 말합니다.
실행대기 상태에 있는 스레드 중에서 스케줄링으로 선택된 스레드가 비로서 CPU를 점유하고 run() 메서드를 실행하게 됩니다.
이때를 실행 상태라고 합니다.

아래의 사진으로 스레드의 대략적인 상태를 살펴 보겠습니다

실행 상태의 스레드는 다시 실행대기 상태로 돌아갈 수 있고
실행, 실행대기 상태를 번갈아가며 자신의 run() 메서드를 수행합니다.
run() 메서드가 종료되면 스레드의 실행은 멈추게 됩니다.
이 상태를 종료 상태라고 합니다

경우에 따라서 ‘실행상태’에서 ‘일시정지’ 상태로 가기도 하는데,
일시정지 상태에서는 스레드가 실행할 수 없는 상태입니다.
일시정지 상태로는 WAITING, TIMED_WAITING, BLOCKED가 있습니다.
스레드가 다시 실행 상태로 가기 위해서는 일시정지 상태에서 실행대기 상태가 되어야 합니다.

상태 Enum 상수 설명
객체 생성 NEW 레드 객체 생성, 아직 start() 메서드 호출되지 전의 상태
실행대기 RUNNABLE 실행 상태로 언제든지 갈 수 있는 상태
일시정지 WAITING 다른 스레드가 통지(notify) 할 때까지 기다리는 상태
일시정지 TIMED_WAITING 주어진 시간 동안 기다리는 상태
일시정지 BLOCKED 사용하고자 하는 객체의 락이 풀릴 때까지 기다리는 상태
종료 TERMINATED 실행을 마친 상태

스레드의 상태 제어

사용자는 미디어 플레이어에서 동영상을 보다가 일시 정지 시킬수도 있고, 종료시킬수도 있습니다
정지는 다시 동영상을 보겠다는 의미로 스레드를 일시정지 상태로 만들어야 합니다
종료는 더 이상 동영상을 보지 않겠다는 의미이므르 미디어 플레이어는 스레드를 종료 상태로 만들어야 합니다
이와 같이 스레드의 상태를 변경하는 것은 스레드 상태 제어라고 합니다

아래는 스레드 상태를 제어하는 메서드입니다. 하나씩 메서드를 알아보도록 하겠습니다

일시정지 (sleep)

sleep() - 주어진 시간동안 일시 정지 상태로 만들고, 주어진 시간이 지나면 자동적으로 실행대기 상태가 됩니다
try {
    Thread.sleep(3000);
} catch (InterruptedException e) {
    e.printStackTrace();
} 

Thread.sleep(3000) 메서드를 통해 3초간 ‘일시정지’ 상태가 되고 3초가 지나면 다시 ‘실행대기’ 상태로 돌아오게 됩니다.

실행 양보 (yield)

yield() - 실행중에 우선순위가 높은 또는 동일한 다른 스레드에게 실행을 양보하고 실행 대기 상태가 됩니다
if (work) {
    System.out.println(getName() + " 스레드 작업 내용");
} else {
    Thread.yield();
}

yield() 메서드 호출시 해당 Thread는 우선 순위가 높거나 동일한 Thread에게 실행을 양보하고 실행 대기 상태로 전환됩니다

다른 스레드의 종료를 기다림 (join)

join() -메서드를 호출한 스레드는 일시 정지 상태가 됩니다. 실행 대기 상태로 가려면 join() 메서드를 멤버로 가지는 스레드가 종료되거나, 매개값으로 주어진 시간이 지나야 합니다. 이부분은 예제를 보도록 하겠습니다
SumThread
public class SumThread extends Thread {
    private long sum;
    public long getSum() {
        return sum;
    }

    public void run() {
        for (int i = 1; i <= 100; i++) {
            sum += i;
        }
    }
}
JoinExample
public class JoinExample {
    public static void main(String[] args) {
        SumThread sumThread = new SumThread();
        sumThread.start();

        try {
            sumThread.join();
        } catch (Exception e) {
        }

        System.out.println("합 : " + sumThread.getSum());
    }
}

sumthread.join()을 걸어 두었기 때문에 메인 Thread는 합연산이 끝날때까지 기다리게 됩니다. 따라서, 결과값은 5050이 나오게 됩니다

여기서 의문이 들 수 있습니다. Thread.sleep(3000)으로도 충분히 가능한데 무엇이 차이인가? sleep은 일정 시간동안만 실행대기 상태로 만들어줍니다. join의 경우 thread의 작업을 처리할때까지 묶어 둘 수 있기 때문에 멀티스레드 환경에서 동기화를 맞춰야 할 경우 유용하게 사용할 수 있습니다.

스레드간 협업 (wait, notify, notifyAll)

notify, notifyAll - 동기화 블록 내에서 wat 메서드에 의해 일시 정지 상태에 있는 스레드를 실행 대기 상태로 만들어 줍니다
wait - 동기화 블록 내에서 스리드를 일시 정지 상태로 만듭니다. 매개값으로 시간이 주어지면 일정 시간이 지난후 자동으로 실행대기 상태가 되고, 시간이 주어지지 않으면 notify, notifyAll 메서드에 의해 실행 대기 상태로 갈 수 있습니다

스레드 종료 (stop, interrupt)

Thread는 자신의 run() 메서드가 모두 실행되면 자동으로 종료됩니다. 경우에 따라서 Thread를 즉시 종료할 필요가 있습니다

stop - 갑자기 종료하면 스레드가 사용중이던 자원들이 불안전한 상태로 남겨지기 때문에 deprecated 되었습니다
interrupt - 일시 정지 상태의 스레드에서 InterruptedException 예외를 발생시켜, 예외 처리 코드(catch)에서 실행 대기 상태로 가거나 종료 상태로 갈 수 있도록 합니다

참조

  • '이것이 자바다' - 신용권님 저
블로그 이미지

사용자 yhmane

댓글을 달아 주세요

이전 포스팅 에선 n개의 스레드를 생성하여 실행하여 보았습니다.


작업의 동시성을 위해 thread는 매우 유용하게 사용되는데요,
하지만 공유되는 자원에 대해서 동기화 처리가 되지 않는다면 문제를 일으킬 수 있습니다.
이번 포스팅에선 thread를 동기화하여 안전하게 공유 자원을 사용하는 방법에 대해서 알아 보도록 하겠습니다.

 


비동기화

먼저, 동기화 처리가 되지 않는 thread의 결과값을 확인해보겠습니다
'이것이 자바다'의 나오는 예제입니다.

Calculator

public class Calculator {

    private int memory;

    public void setMemory(int memory) {
        this.memory = memory;

        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + ": " + this.memory);
    }
}

User1

public class User1 extends Thread {

    private Calculator calculator;

    public void setCalculator(Calculator calculator) {
        this.setName("User1"); // set thread name
        this.calculator = calculator;
    }

    public void run() {
        calculator.setMemory(100);
    }
}

User2

public class User2 extends Thread {

    private Calculator calculator;

    public void setCalculator(Calculator calculator) {
        this.setName("User2"); // set thread name
        this.calculator = calculator;
    }

    public void run() {
        calculator.setMemory(50);
    }
}

Main

public class MainThreadExample {

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

        User1 user1 = new User1();
        user1.setCalculator(calculator);
        user1.start();

        User2 user2 = new User2();
        user2.setCalculator(calculator);
        user2.start();
    }
}

결과

User1: 50
User2: 50

User1은 setMemory에서는 100을, User2 setMemory에서는 50을 설정하였습니다.
그러나 User1의 결과는 100이 아닌 50이 설정되었습니다.

그이유는 즉슨, User1과 User2 Calculator를 공유하여 사용하고 있습니다
다만, memory set후 바로 출력하는것이 아닌 2초간의 sleep time이 있기 때문입니다
따라서 원하는 값을 얻기 위해서는 공유 자원의 대한 제어가 필요합니다


동기화 블록 설정

스레드가 사용중인 객체를 다른 스레드가 변경할 수 없도록 하려면 스레드 작업이 끝날떄 까지
객체에 잠금을 걸어 다른 스레드가 사용할 수 없도록 해야합니다.


이처럼 멀티스레드 프로그램에서 단 하나의 스레드만 실행할 수 있는 코드 영역을 임계 영역(critical section)이라고 합니다.
자바는 임계 영역을 지정하기 위해 동기화 메서드블록을 제공합니다

동기화 메서드

public synchronized void setMemory(int memory) {
    this.memory = memory;

    try {
        Thread.sleep(2000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    System.out.println(Thread.currentThread().getName() + ": " + this.memory);
}

동기화 블록

public void setMemory(int memory) {
    synchronized (this) {
        this.memory = memory;

        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + ": " + this.memory);
    }
}

이외에도 'volatile' 이라는 키워드가 있습니다.
'volatile'은 변수에 적용하여 Cache memory가 아닌 main memory에서 동작하게 설정됩니다.
즉, 멀티스레드 환경에서 임계영역이 아닌 main memory에서 동작하기 때문에
여러 스레드가 공유 자원에 대해 동시에 '읽기-쓰기' 작업을 진행한다면 원자성을 보장해주지 않습니다.

따라서, 위와 같은 공유자원에 여러 스레드가 '읽기-쓰기' 작업을 수행한다면,
임계영역을 보장해주는 synchronized 키워드를 사용해야 합니다

참조

이것이 자바다 - 신용권님 저

블로그 이미지

사용자 yhmane

댓글을 달아 주세요

들어가며

우리는 하나의 프로그램, 프로세스로 두 가지 이상의 작업을 처리할 수 있다는 것을 알고 있습니다.
스레드를 이용하면 두가지 이상의 일을 동시에 할 수 있는데요,
이번 포스팅에선 Java를 이용해서 스레드를 생성하는 방법을 알아보도록 하겠습니다.


프로세스와 스레드

스레드를 다루기 전에 필수적인 용어만 짚고 넘어가도록 하겠습니다

  • 프로그램은 파일 시스템에 존재하는 실행파일 (*.exe)
  • 프로세스는 운영체제에서 실행중인 애플리케이션
  • 스레드는 프로세스 내에서 실행되는 여러 흐름의 단위

간단히 정리하면, 프로그램은 실행파일입니다.
이 실행파일은 메모리에 올린 것은 프로세스라고 하는데요
프로세스는 n개의 thread로 구성되어 있습니다.

운영체제의 대한 추가적인 설명은 생략하도록 하고, 자세히 설명되어 있는 링크를 달아두도록 하겠습니다.
스레드, 프로세스의 대한 개념 정리가 필요하시다면 한번씩 읽어보세요 😄
Process와 Thread 이야기. 프로세스(Process) | by Charlezz | Medium


Java Thread 생성

Java에서 Thread를 생성하는 방법은 두가지가 존재합니다

  1. Thread 클래스를 상속 받아 run 메서드를 오버라이딩 하는것
  2. Runnable 인터페이스를 Implements 하여 run 메서드를 정의하는것

먼저, Thread 상속

public class MyThread extends Thread {
    private int index;
    public MyThread(int index) {
        this.index = index;
    }

    @Override
    public void run() {
        System.out.println(this.index + " thread start");
        try {
            Thread.sleep(1000);
        } catch (Exception e) {
            e.printStackTrace();
        }
        System.out.println(this.index + " thread end");
    }
}

다음으로, Runnable 인터페이스 implements

public class MyRunnable implements Runnable {

    private int index;
    public MyRunnable(int index) {
        this.index = index;
    }

    @Override
    public void run() {
        System.out.println(this.index + " thread start");
        try {
            Thread.sleep(1000);
        } catch (Exception e) {
            e.printStackTrace();
        }
        System.out.println(this.index + " thread end");
    }
}

실행

public class MyThreadMain {
    public static void main(String[] args) throws  {
          // thread
        for (int i = 0; i < 10; i++) {
            Thread myThread = new MyThread(i);
            myThread.start();
        }

          // runnable
        for (int i = 10; i < 20; i++) {
            Runnable runnable = new MyRunnable(i);
            Thread runnableThread = new Thread(runnable);
            runnableThread.start();
        }
    }
}

이외에도 단순한 Thread의 처리라면 익명클래스나 람다를 이용하는 방법도 있습니다.
Java 8버전 이상을 사용하신다면 익명클래스 보단 람다 사용을 권장합니다!!

new Thread() {
    @Override
    public void run() {
        System.out.println("anonymous class thread");
    }
}.start();

new Thread(() -> System.out.println("lambda thread")).start();

 


start vs run

우리는 run() 메서드를 재정의하였지만, 정작 thread 사용시에는 start() 메서드를 이용하였습니다.
그 이유는 jvm의 메모리 구조때문인데요, 아래의 사진을 잠시 살펴보도록 하겠습니다.

jvm은 ‘data, code, stack, heap’ 영역으로 구성되어 있습니다.
여기서 프로세스 내부의 thread들은 data, code, heap 영역을 공유하게 됩니다.

다만, run() 메서드는 메인쓰레드의 call-stack을 공유하여 작업을 하지만
start() 메서드는 각 쓰레드마다 call-stack을 새로 만들어 작업을 합니다.

run() 메서드는 thread가 순번을 기다리며 대기하고 있기에 하드웨어의 성능을 적절히 사용할 수 없습니다.
따라서 쓰레드의 작업을 적적히 분배하기 위해서는 start() 메서드를 이용해야 합니다


출처

블로그 이미지

사용자 yhmane

댓글을 달아 주세요

들어가며

객체지향을 추구하던 Java는 8버전 부터 큰 변화를 시도하였습니다.
Java8 버전에 함수형 패러다임이 추가되었는데, 그 부분에서 가장 큰 역할을 하는 것이 람다와 함수형 인터페이스가 아닐까라고 생각합니다.
이번 포스팅에선 함수형 인터페이스의 선언과 자주 사용하는 인터페이스 몇가지를 소개드릴까 합니다.

다만, 이번 포스팅을 읽기전에 람다와 익명클래스에 관해 간단히 읽어 보시면 좋을거 같습니다!!
JAVACOFFEE :: Java Java8 - 람다 표현식 사용하기 | Lambda | 익명클래스


함수형 인터페이스 선언

함수형 인터페이스란 한개의 추상 메서드로 이루어진 인터페이스를 말합니다.
간단한 예제를 통해 알아보겠습니다.

@FunctionalInterface
public interface FunctionalInterfaceExample {
    void printMsg(String msg);
    //void printName(String name);

    default void defaultMethod() {
        System.out.println("defalut Method");
    }

    static void staticMethod() {
        System.out.println("static Method");
    }
}
  • @ FunctionalInterface를 선언하여 줍니다
  • 추상메서드는 하나만 선언할 수 있습니다
  • 두 개 이상시 컴파일 에러가 발생합니다
  • default, static 메서드는 사용 가능합니다

함수형 인터페이스 사용

생성한 함수형 인터페이스를 사용해보도록 하겠습니다.
먼저 Yunho.class

public class Yunho implements FunctionalInterfaceExample {
    @Override
    public void printMsg(String msg) {
        System.out.println(msg);
    }
}
public class FunctionMain {
    public static void main(String[] args) {
        FunctionalInterfaceExample yunhoInterface = new Yunho();
        yunhoInterface.printMsg("안녕하세요");
        yunhoInterface.defaultMethod();
        FunctionalInterfaceExample.staticMethod();
    }
}

위의 main()을 실행하게 되면
추상메서드, default 메서드, static 메서드가 아래와 같이 실행됩니다.

안녕하세요
defalut Method
static Method

자주 사용하는 함수형 인터페이스

자바에서 기본적으로 제공하는 함수형 인터페이스는 다음과 같은 것들이 있습니다.

Function<T,R> : <T> -> <R>
Consumer<T> : <T> -> Void
Predicate<T> : <T> -> Boolean
Supplier<T> : lazy evaluation

이외에도 다양한 인터페이스를 제공합니다. java.util.function (Java Platform SE 8 )

Predicate

T타입 인자를 받고 결과로 Boolean을 리턴합니다.

@FunctionalInterface
public interface Predicate<T> {
    boolean test(T t);
    default Predicate<T> and(Predicate<? super T> other) {
        Objects.requireNonNull(other);
        return (t) -> test(t) && other.test(t);
    }
    default Predicate<T> negate() {
        return (t) -> !test(t);
    }
    default Predicate<T> or(Predicate<? super T> other) {
        Objects.requireNonNull(other);
        return (t) -> test(t) || other.test(t);
    }
    static <T> Predicate<T> isEqual(Object targetRef) {
        return (null == targetRef)
                ? Objects::isNull
                : object -> targetRef.equals(object);
    }
    @SuppressWarnings("unchecked")
    static <T> Predicate<T> not(Predicate<? super T> target) {
        Objects.requireNonNull(target);
        return (Predicate<T>)target.negate();
    }
}

주요 메서드로 test가 있고, and, or 등의 메서드를 제공합니다

public class PredicateExample {

    public static void main(String[] args) {
        Predicate<Integer> integerPredicate = num ->  num > 10;
        Predicate<Integer> integerPredicate1 = num -> num < 20;

        System.out.println(integerPredicate.test(5));
        System.out.println(integerPredicate.and(integerPredicate1).test(15));
    }
}

and는 Predicate를 파라미터로 받기 때문에 위와 같은 연산을 수행할 수 있습니다.

Consumer

T 타입의 객체를 인자를 받고 내부적으로 값을 연산합니다. 리턴값은 없습니다.

@FunctionalInterface
public interface Consumer<T> {
    void accept(T t);
    default Consumer<T> andThen(Consumer<? super T> after) {
        Objects.requireNonNull(after);
        return (T t) -> { accept(t); after.accept(t); };
    }
}

Accept, andThen 메서드를 제공합니다

public class ConsumerExample {

    public static void main(String[] args) {
        Consumer<List<Integer>> numberConsumer = list -> {
            for (int i = 0; i < list.size(); i++) {
                list.set(i, list.get(i) * list.get(i));
            }
        };
        Consumer<List<Integer>> printConsumer = list -> {
            for (Integer num : list) {
                System.out.println(num);
            }
        };
        List<Integer> numbers = Arrays.asList(10, 20, 30, 40, 50);
        numberConsumer.andThen(printConsumer).accept(numbers);
    }
}

andThen은 Consumer를 Parameter로 받기 때문에 복합 연산을 수행할 수 있습니다.

Supplier

인자를 받지 않고 T 타입을 리턴합니다.

@FunctionalInterface
public interface Supplier<T> {
    T get();
}

get 메서드를 제공합니다

public class Student {

    private String name;
    private int age;

    public Student(String name, int age) {
        this.name = name;
        this.age = age;
    }

    @Override
    public String toString() {
        return "Student{" +
            "name='" + name + '\'' +
            ", age=" + age +
            '}';
    }
}

public class SupplierExmaple {

    public static void main(String[] args) {
        Supplier<String> supplier= () -> "hello world";
        System.out.println(supplier.get());

        Supplier<Student> studentSupplier = () -> new Student("황윤호", 20);
        System.out.println(studentSupplier.get());
    }
}

Function

T타입의 인자를 받고, R타입의 객체를 리턴합니다.

@FunctionalInterface
public interface Function<T, R> {

    R apply(T t);
    default <V> Function<V, R> compose(Function<? super V, ? extends T> before) {
        Objects.requireNonNull(before);
        return (V v) -> apply(before.apply(v));
    }
    default <V> Function<T, V> andThen(Function<? super R, ? extends V> after) {
        Objects.requireNonNull(after);
        return (T t) -> after.apply(apply(t));
    }
    static <T> Function<T, T> identity() {
        return t -> t;
    }
}

apply 이외에 compose, andThen 등의 메서드를 제공합니다

public class FunctionExample {

    public static void main(String[] args) {
        Function<Integer, Integer> multiplyFunction = number -> number * number;
        System.out.println(multiplyFunction.apply(10));

        Function<Integer, Integer> minusFunction = number -> number - 10;
        Integer result = multiplyFunction.andThen(minusFunction).apply(10);
        System.out.println(result);
    }
}

마찬가지로 andThen을 이용하면 복합 연산을 이용할 수 있습니다


마치며

이번 포스팅에서는 함수형 인터페이스에 대하여 알아보았는데요, 개인적으로는 Java8에서 꽃은 람다, 스트림, 함수형인터페이스 라고 생각합니다. 그만큼 중요하고, 많이 쓰이고 이해가 어려운 부분이라, 이번 포스팅에서 간단한 이해를 하시고 실제로 많은 연습을 통해 익히시는 걸 권해드립니다 @.@


참고

모던 자바 인 액션 - YES24
Java8 - 함수형 인터페이스(Functional Interface) 이해하기
java-self-study/src/main/java/fi at master · yhmane/java-self-study · GitHub

블로그 이미지

사용자 yhmane

댓글을 달아 주세요

Generic

들어가며

제네릭(Generic)은 Java5부터 새로 추가된 내용으로,
제네릭 타입을 이용하여 잘못된 타입이 사용될 수 있는 문제를 컴파일 과정에서 제거할 수 있게 되었습니다.
컬렉션, 람다식, 스트림, NIO에서 널리 사용되고 많은 API 도큐먼트에서 제네릭 표현이 사용되므로 정확한 학습이 필요합니다.

개인적으로, Java를 학습하며 Generic은 가장 이해가 어려웠던 파트중 하나로 기억합니다. 이번 포스팅에서는 신용권님의 ‘이것이 자바다’ 13장 Generic을 기반으로 Generic 클래스, 메서드, 형변환 등에 대해서 정리하도록 하겠습니다.


제네릭의 장점

컴파일시 강한 타입 체크를 할 수 있다

자바 컴파일러는 잘못 사용된 타입을 미리 체크하여 주는데, 제니릭 코드에 대해 강한 타입 체크를 한다.
따라서 실행 이전에 컴파일 단계에서 에러를 체크해 준다

타입 변환(casting)을 제거한다

generic을 사용하지 않을 경우

List strList = new ArrayList();
strList.add("hi");
Object strObject = strList.get(0); 
String str = (String) strList.get(0);

generic을 사용할 경우

List<String> strList = new ArrayList<String>();
strList.add("hi");
String str = strList.get(0);

<> 안에 Type을 지정하여 줌을로써, 요소를 가져올때 형변환 과정이 필요 없어진다


제네릭 타입(class, interface)

제네릭 타입은 타입을 파라미터로 가지는 클래스와 인터페이스를 말합니다.

public class 클래스명<T> {...}
public interface 인터페으스명<T> {...}

Generic 표기 방법


일반적으로 Java 진영에서는 위와 같은 컨벤션을 지키려고 한다. 다만, 경우에 따라서 적절한 Name을 지정해 주는 것도 좋다.

다음은 예제를 통해서 간단히 Generic class와 interface를 만들어 보자

Generic class

public class GenericClass<T> {
    private T value;

    public T getValue() {
        return value;
    }

    public void setValue(T value) {
        this.value = value;
    }
}

사용

GenericClass<String> strObject = new GenericClass<String>();
strObject.setValue("Hello, Yhmane!");
System.out.println(strObject.getValue());

GenericClass<Integer> intObject = new GenericClass<Integer>();
intObject.setValue(31);
System.out.println(intObject.getValue());

Generic Interface

public interface QueryResult<ID> {
    ID id();
}

Store.java

public class Store implements QueryResult<Long> {
    private Long storeId;
    private String name;
    private String address;

    public Store(Long storeId, String name, String address) {
        this.storeId = storeId;
        this.name = name;
        this.address = address;
    }

    @Override
    public Long id() {
        return storeId;
    }

    @Override
    public String toString() {
        return "Store{" +
            "storeId=" + storeId +
            ", name='" + name + '\'' +
            ", address='" + address + '\'' +
            '}';
    }
}

User.java

public class User implements QueryResult<Long> {
    private Long userId;
    private String name;
    private String email;

    public User(Long userId, String name, String email) {
        this.userId = userId;
        this.name = name;
        this.email = email;
    }

    @Override
    public Long id() {
        return userId;
    }

    @Override
    public String toString() {
        return "User{" +
            "userId=" + userId +
            ", name='" + name + '\'' +
            ", email='" + email + '\'' +
            '}';
    }
}

사용

User user = new User(1L, "윤호", "test1234@gmail.com");
Store store = new Store(1L, "윤호가게", "강남구 봉은사로");
System.out.println(user);
System.out.println(store);

generic 메서드

제네릭 메서드는 매개 타입과 리턴 탕비으로 타입 파라미터를 갖는 메서드를 말한다.

public <타입파라미터...> 리턴타입 메서드명(매개변수, ...) {...}

Util.java

public class Util {
    public static <T> Box<T> boxing(T t) {
        Box<T> box = new Box<T>();
        box.set(t);
        return box;
    }
}

Box.java

public class Box<T> {
    private T t;

    public T get() {
        return t;
    }

    public void set(T t) {
        this.t = t;
    }
}

사용
은 명시적으로 사용해도 되고 사용하지 않아도 된다

Box<Integer> box1 = Util.<Integer>boxing(100);
Box<String> box2 = Util.boxing("홍길동");

제네릭 타입의 제한과 와일드카드

제네릭도 마찬가지로 extends와 super를 이용할 수 있다

<T extends 상위타입>
<T super 하위타윕>

<?> // 와일드카드
<? extends 상위타입>
<? super 하위타입>

이해를 돕기위해 책에 있는 간단한 예제를 가져와 봤습니다.

Course<?> // Person, Worker, Student, HightStudent가 올수 있습니다
Course<? extends Student> // Student, HighStudent만 올 수 있습니다
Course<? super Worker> // Worker와 Person만 올 수 있습니다

참조

블로그 이미지

사용자 yhmane

댓글을 달아 주세요

들어가며

인터페이스는 직접 객체화할 수 없기 때문에 구현 클래스를 이용합니다. 다만, 일회성으로 사용하는 구현 객체를 계속 선언하는 것은 옳지 않기에 익명 클래스람다를 이용합니다.


익명 클래스

익명클래스는 이름이 없는 클래스이며 new 와 동시에 부모클래스를 상속받아 내부 메서드를 오버라이딩 하여 사용합니다. 익명 클래스는 코드가 길고 가독성이 떨어져 함수형 프로그래밍 방식에 적합하지 않다는 단점이 있습니다.


람다

람다가 기술적으로 자바8 이전의 자바로 할 수 없었던 일은 제공하는 것은 아닙니다. 다만, 동적 파라미터를 이용할 때 익명 클래스 등 판에 박힌 코드를 구현할 필요가 없다는 장점이 있습니다.

어떠한 장점이 있어 람다를 사용하는지 코드를 통해 알아보겠습니다. 먼저, 람다를 사용하지 않은 코드입니다.

1
2
3
4
5
Comparator<Apple> byWeight = new Comparator<Apple>() {
    public int compare(Apple a1, Apple a2) {
        return a1.getWeight().compareTo(a2.getWeight));
    }
}
cs

다음으로 람다를 사용한 코드입니다.

1
Comparator<Apple> byWeight = (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight));
cs

람다는 새로운 기능이 추가되었다기 보다는 기존의 코드를 더 의미있고 간결하게 사용하도록 도와주고 있습니다.


람다의 특징

  • 익명 : 보통의 메서드와 달리 이름이 없으므로 익명이라 표현합니다
  • 함수 : 람다는 메서드처럼 특정 클래스에 종속되지 않으므로 함수라고 부릅니다. 하지만 메서드처럼 파라미터 리스트, 바디, 반환형식, 가능한 예외 리스트들을 포함합니다
  • 전달 : 람다 표현식을 메서드 인수로 전달하거나 변수로 저장할 수 있습니다.
  • 간결성 : 익명 클래스처럼 많은 자질구레한 코드를 구현할 필요가 없습니다.

람다 표현식

람다 표현식은 파라미터, 화살표, 바디로 이루어 집니다

1
2
3
                   -화살표- 
(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight));
---- 파라미터 ----                    --- 바디 ---
cs
  • 기본 구조
    • 파라미터 리스트 : 사과 2개
    • 화살표 : 람다 파라미터와 람다 바디를 구분한다
    • 람다 바디 : 람다의 반환값에 해당하는 표현식이다
  • 람다는 파라미터와 화살표(->) 바디로 구분합니다
  • 파라미터가 하나일 경우 매개변수를 생략 할 수 있습니다.
  • 바디가 단일 실행문이면 괄호{}를 생략 할 수 있습니다.
  • 바디가 return문으로만 구성되어 있는 경우 괄호{}를 생략 할 수 없습니다.

아래의 예제를 보겠습니다.

1
2
3
4
5
() -> {}
() -> "Raoul"
() -> { return "Mario"; }
(Integer i) -> return "Alan" + i; // {return "Alan" + i;} 가 와야 합니다
(String s) -> {"Iron Man";} // (String s) -> "Iron Man" 또는 (String s) -> { return "Iron Man";}이 와야합니다
cs

다음 포스팅에서는 함수형 인터페이스에 대하여 알아보도록 하겠습니다

블로그 이미지

사용자 yhmane

댓글을 달아 주세요

들어가며


 이전 포스팅에서 자바의 클래스에 대하여 알아보았습니다. 이번 포스팅에선 자바의 클래스에서 한단계 더 나아간 인터페이스와 추상클래스에 대하여 알아보도록 하겠습니다.


추상클래스와 인터페이스

 

인터페이스와 추상 클래스는 객체지향적 개념을 개발에 적용하며 설계를 확장하거나 향후 변경하기 쉬운 구조를 지원하는 요소입니다기본적인 속성과 필요한 메서드의 형태(프로토타입만) 기술하고세부적인 내용은 실제 구현 클래스에서 담당합니다.” 


 자바 실무에서 가장 많이 쓰이는 내용이 아닐까 라고 생각합니다. 공통적인 내용을 추상적으로 만들어 설계하는 곳에서 많이 사용됩니다. 실무에서는 보통 선임개발자가 추상클래스/인터페이스를 설계하여 공통된 로직을 적용하고 후임 개발자들이 내용들을 구현하는 용도로 많이 사용됩니다. 아래는 추상클래스와 인터페이스에 대한 내용을 간략히 표로 정리한 것입니다.


 

 추상 클래스

 인터페이스 

 상속 

 단일상속 

 다중상속 

 구현 

 extends 

 implements 

 추상메서드 

 0개 이상 

 모든 메서드 

 객체생성 

 생성불가 

 생성 불가 


  • 추상클래스

 추상클래스는 일반적인 클래스의 추상화 버전입니다. 추상클래스는 개념이나 사물에서 공통되는 특징이나 속성을 추출하여 설계하는 것입니다.

  • 인터페이스
 인터페이스는 추상클래스와 개념상 거의 동일하지만 아래와 같은 차이가 있습니다.

    • 인터페이스는 일반 메서드를 포함할 수 없으며, 모두 추상 메서드로만 구성합니다.
    • 일반 멤퍼 필드는 없고, public, static, final로 선언한 상수만 있습니다. (java8 부터는 생략 가능)
    • 다중 상속이 가능합니다.

추상클래스 예제

abstract class MyAbstractClass {
int num1, num2;
int result;

void calc() {
result = num1 + num2;
}

abstract int getResult();    // 추상메서드는 선언만
}

class MyClass extends MyAbstractClass  {    // extends로 상속 받음
int getResult() {
return result;
}
}

인터페이스 예제

Interface MyInterface {
public static final num = 1000;
public abstract int getResult();
}

class MyClass implements MyInterface {    // implements로 구현 받음
public int getResult() {
return num + 1000;
}
}



참조

- 자바의 정석

- Just 자바

블로그 이미지

사용자 yhmane

댓글을 달아 주세요

들어가며


  자바를 처음 배우게 되면 콘솔 화면을 통해 입력을 하고, 결과값을 출력하게 됩니다. 하지만, 콘솔 화면을 통한 입출력에는 데이터 처리에 대한 한계가 있습니다. 데이터가 휘발성이기 때문에 원하는 결과값을 컴퓨터에 저장하지 못하는데, 이러한 문제를 해결하여 주는 것이 '파일' 입니다. 이러한 문제를 해결 해주기 위해 자바에서는 스트림이라는 것을 통해 파일 입출력을 다룹니다. 이번 포스팅에선 스트림과 파일 관련 클래스에 대하여 알아보도록 하겠습니다.




스트림 (Stream)

 

 Stream은 컴퓨터와 네트워크, 컴퓨터와 주변장치(키보드, 마우스, 모니터, 프린터, 스마트폰 등)간 데이터 통신을 하는 통로입니다. 스트림은 아래와 같은 특성이 있습니다.

  • 스트림은 데이터 송수신의 통로로, Input/Output의 기본이 됨

  • 단방향 통신을 제공 하기 때문에 입력, 출력 두개의 스트림이 필요

  • 스트림은 연속된 데이터 흐름으로 입출력 처리시 다른 작업을 할 수 없는 블로킹 상태

  • 스트림은 문자스트림과 바이트스트림으로 구분


문자스트림

Reader <- BuffredReader, InputStreamReader <- FileReader

Writer <- BufferedWriter, OutputStreamWriter <- FileWriter


바이트스트림

InputStream <- ObjectInputStream, FileInputStream <- DataInputStream, BuffredInputStream

OutputStream <- ObjectOutputSream, FileOutputStream <- DataOutputStream, BufferedOutputStream




파일 개요


 컴퓨터에서 파일은 매우 중요한 입출력 대상입니다. 컴퓨터의 램(RAM) 메모리는 휘발성으로 컴퓨터의 전원이 꺼지면 메모리 상의 모든 데이터는 사라집니다. 하지만, 컴퓨터를 이용하다 보면 게임의 세이브 데이터, 보고서 작성 데이터, 채팅이나 문자 메시지 등 컴퓨터를 종료 하더라도 지워지면 안되는 데이터들이 있습니다. 이처럼 '파일'은 컴퓨터의 메모리 한계를 벗어나 데이터를 저장하고 공유하는 중요한 수단입니다.


 파일 관련 자바 코드를 보기전에, 간단히 용어에 대하여 알아보도록 하겠습니다.


File

- 파일은 컴퓨터 디스크에 텍스스타 바이너리 형태로 저장하기 위해 고안됨

- 텍스트 파일은 프로그램 소스나 메모장에서 작성한 단순 정보를 기록하는 것이 목적

- 바이너리 파일은 이진 형태로 저장 되며 프로그램 실행 파일이나 프로그램에서 저장하는 데이터 파일


Directory

- 대용량 파일을 관리하기 위해 고안됨, '폴더' 라고도 지칭

- 디스크 시스템의 최상위를 루트(ROOT)라 하며, 파일은 루트를 비롯한 하위 디렉토리에 들어 있음


Path

- 경로(Path)는 디스크 시스템에서 파일의 위치를 관리한느 체계

- 파일을 처리 하기 위해서는 파일의 위치 정보가 필요하고, 파일과 디렉토리는 위치를 표현하기 위해 사용됨

- 유닉스 계열은 '/' 윈도우 계열은 '₩'를 구분자로 사용




파일 입출력


 컴퓨터에서 파일은 매우 중요한 입출력 대상입니다. 컴퓨터의 램(RAM) 메모리는 휘발성으로 컴퓨터의 전원이 꺼지면 메모리 상의 모든 데이터는 사라집니다. 하지만, 컴퓨터를 이용하다 보면 게임의 세이브 데이터, 보고서 작성 데이터, 채팅이나 문자 메시지 등 컴퓨터를 종료 하더라도 지워지면 안되는 데이터들이 있습니다. 이처럼 '파일'은 컴퓨터의 메모리 한계를 벗어나 데이터를 저장하고 공유하는 중요한 수단입니다.


  • File 관련 주요 클래스

- File : 경로 정보를 바탕으로 해당 파일 객체를 생성
- FileReader : 파일에서 문자 스트림을 기반으로 한 입력을 처리하는 클래스
- FileWriter : 파일에서 문자 스트림을 기반으로 한 출력을 처리하는 클래스
- FileInputStream : 파일에서 바이트 스트림을 기반으로 한 입력을 처리하는 클래스
FileIOutputStream : 파일에서 바이트 스트림을 기반으로 한 출력을 처리하는 클래스

  • 파일 객체 성성
File file = new File("/Users/hwang-yunho/Documents/temp.txt");

  • 파일 입출력 스트림 생성
FileReader fr = new FileReader(file);
FileReader fr = new FileReader("/Users/hwang-yunho/Documents/temp.txt");
FileInputStream fis = new FileInputStream(file);
FileInputStream fis = new FileInputStream("/Users/hwang-yunho/Documents/temp.txt");

public class JavaEx {
public static void main(String[] args) {

String path = "/Users/hwang-yunho/Desktop/tmp.txt";
File file = new File(path);

try {
FileWriter fw = new FileWriter(file);

for (int i = 'A'; i <= 'z'; i++) {
fw.write(i);
}

fw.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}


  • Buffered 계열 스트림 연결
BuffredReader br = new BufferedReader(new FileReader("/Users/hwang-yunho/Documents/temp.txt"));
BuffredWriter bw = new BufferedWriter(new FileReader("/Users/hwang-yunho/Documents/temp2.txt"));
public class JavaEx {
public static void main(String[] args) {

String path = "/Users/hwang-yunho/Desktop/tmp.txt";
String newPath = "/Users/hwang-yunho/Desktop/tmp2.txt";
File rFile = new File(path);
File wFile = new File(newPath);

try {
BufferedReader reader = new BufferedReader(new FileReader(rFile));
BufferedWriter writer = new BufferedWriter(new FileWriter(wFile));

String s = "";

while ((s = reader.readLine()) != null) {
writer.write(s);
}

reader.close();
writer.close();
rFile.delete();
} catch (IOException e) {
e.printStackTrace();
}
}
}



정리

 

  • 파일은 컴퓨터에서 가장 기본이 되며 중요한 입출력 대상

  • File 클래스는 바이트 스트림과 문자 스트림을 모두 지원

  • File을 다루기 위해서는 File 객체를 생성하고, 보조 스트림이 필요

  • 서버마다 디렉터리 구분자가 다르니 유의하여 사용 (유닉스 '/' 윈도우 '₩')


출처


- 자바의 정석

- Just 자바

https://docs.oracle.com/javase/8/docs/api/java/nio/file/Files.html


블로그 이미지

사용자 yhmane

댓글을 달아 주세요

들어가며



 이전 글에서 [ https://yhmane.tistory.com/121, Java의 Date, Calendar의 단점을 얘기하며 Java8부터 새로 출시된 Time API에 대하여 포스팅하였습니다. 다만, 타임존이 제외된 날짜와 시간에 대해서만 LocalDate, LocalTime, LocalDateTime 언급하였기에, 이번 포스팅에선 ZonedDateTime 클래스에 대하여 포스팅 하도록 하겠습니다.



ZonedDateTime

 

* ZonedDateTime 클래스에 대하여


ZonedDateTime 클래스는 'ZonedDateTime = LocalDateTime + 시차/타임존' 이라고 보시면 쉽게 이해하실 수 있을겁니다. 날짜/시간 이외에 시차, 타임존이 필요할 경우 사용되어 집니다. Public 생성자를 지원하지 않기 때문에, now(), of() 라는 정적 메서드를 이용하여 객체를 생성합니다.


ZonedDateTime nowSeoul = ZonedDateTime.now();
System.out.println("Seoul is " + nowSeoul);

ZonedDateTime nowBerlin = ZonedDateTime.now(ZoneId.of("Europe/Berlin"));
System.out.println("Berlin is " + nowBerlin);

ZonedDateTime now = ZonedDateTime.of(2020, 10, 04, 20, 30, 0, 0, ZoneId.of("Asia/Seoul"));
System.out.println("now is " + now);
Seoul is 2020-10-04T20:49:36.509+09:00[Asia/Seoul]
Berlin is 2020-10-04T13:49:36.510+02:00[Europe/Berlin]
now is 2020-10-04T20:30+09:00[Asia/Seoul]

위와 같이 now(), of() 메서드를 이용하여 객체를 생성할 수 있습니다.


ZonedDateTime now = Year.of(2020).atMonth(10).atDay(04).atTime(20, 30).atZone(ZoneId.of("Asia/Seoul"));

또한, 좀더 명시적인 표현을 위해 위와 같이 표현할 수 있습니다.


타임존테이블 https://en.wikipedia.org/wiki/List_of_tz_database_time_zones


ZoneId, ZoneOffset 


 ZonedDateTime은 시차, 타임존으로 구성됩니다. ZoneId, ZoneOffset는 각각 타임존, 시차를 다룹니다. 타임존은 코드로 'Asia/Seoul', 'Europe/Berlin' 과 같이 표기하고 offset의 경우 UTC 타임을 기준으로 '+09:00', '+02:00' 의 +,- 값으로 표기하여 줍니다. 간단히 사용법을 알아보도록 하겠습니다.


ZoneOffset seoulZoneOffset = ZoneOffset.of("+09:00");
System.out.println("+0900 Time = " + ZonedDateTime.now(seoulZoneOffset));
ZoneId seoulZoneId = ZoneId.of("Asia/Seoul");
System.out.println("Seoul Time = " + ZonedDateTime.now(seoulZoneId));


ZoneOffset berlonZoneOffset = ZoneOffset.of("+02:00");
System.out.println("+0200 Time = " + ZonedDateTime.now(berlonZoneOffset));
ZoneId berlonZoneId = ZoneId.of("Europe/Berlin");
System.out.println("Berlin Time = " + ZonedDateTime.now(berlonZoneId));
+0900 Time = 2020-10-04T21:15:40.845+09:00
Seoul Time = 2020-10-04T21:15:40.948+09:00[Asia/Seoul]
+0200 Time = 2020-10-04T14:15:40.949+02:00
Berlin Time = 2020-10-04T14:15:40.952+02:00[Europe/Berlin]


 서울의 경우 Asia/Seoul의 타임존 코드나 +09:00 Offset 값을 사용해도 무방하지만, 많은 나라들이 매번 동일한 시차를 적용하는 것은 아닙니다. 써머타임의 경우도 있고, 특정년도에 다른 나라의 시차를 적용한 기간도 있기 때문입니다. 따라서  ZonedDateTime 객체를 만들때, ZoneId는 시차를 내부적으로 계산해 처리해주기 때문에, ZoneOffset보다 ZoneId를 이용하는 것이 오류를 방지하는 것에 유리합니다.



출처


https://docs.oracle.com/javase/8/docs/api/java/time/ZonedDateTime.html

https://en.wikipedia.org/wiki/List_of_tz_database_time_zones

https://www.daleseo.com/java8-zoned-date-time/




블로그 이미지

사용자 yhmane

댓글을 달아 주세요