728x90
반응형
SMALL

참고자료:

 

김영한의 실전 자바 - 중급 1편 | 김영한 - 인프런

김영한 | 실무에 필요한 자바의 다양한 중급 기능을 예제 코드로 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을

www.inflearn.com

 

예외에 대한 내용을 정리한 게 있긴 한데 한번 더 정리해서 더 자세하게 예외 처리가 왜 필요한지?, 예외를 어떻게 처리하는 게 좋은 방법인지?를 알아보자.

 

다음과 같은 프로그램 구성도가 있다고 가정해보자.

 

프로그램의 흐름은 다음과 같다.

  1. 사용자가 데이터를 입력한다.
  2. 입력한 데이터를 NetworkService가 받아서 외부 서버에 전송한다.

여기서 외부 서버에 전송을 하려면, 외부 서버와 연결을 해주는 중간다리가 필요하다. 그 중간다리 역할을 NetworkClient가 해준다.

 

그래서 NetworkClient가 하는 역할은 다음과 같다.

  • 외부 서버와 연결한다.
  • 데이터를 외부 서버에 전송한다.
  • 외부 서버와 연결을 해제한다.

 

위 내용을 토대로 코드를 작성해보자.

NetworkClientV0

package exception.ex0;

public class NetworkClientV0 {

    private final String address;

    public NetworkClientV0(String address) {
        this.address = address;
    }

    public String connect() {
        System.out.println(address + " 서버 연결 성공");
        return "Success";
    }

    public String send(String data) {
        System.out.println(address + " 서버에 데이터 전송: " + data);
        return "Success";
    }

    public void disconnect() {
        System.out.println(address + " 서버 연결 해제");
    }
}
  • connect(): 외부 서버와 연결을 담당하는 메서드이다. 물론 실제로 연결하지 않지만 연결한다고 생각하자.
  • send(): 외부 서버에 데이터를 전송하는 메서드이다.
  • disconnect(): 외부 서버와의 연결을 해제하는 메서드이다.

NetworkServiceV0

package exception.ex0;

public class NetworkServiceV0 {

    public void sendMessage(String data) {
        String address = "https://example.com";
        NetworkClientV0 client = new NetworkClientV0(address);

        client.connect();
        client.send(data);
        client.disconnect();
    }
}

서비스 클래스에선 NetworkClient를 사용한다. 그래서 접속할 외부 서버의 주소를 전달하고, 연결하고 데이터를 전송하고 연결을 해제하는 메서드를 순차적으로 호출한다.

 

Main

package exception.ex0;

import java.util.Scanner;

public class MainV0 {
    public static void main(String[] args) {
        NetworkServiceV0 networkService = new NetworkServiceV0();

        Scanner scanner = new Scanner(System.in);
        while (true) {
            System.out.println("전송할 문자: ");
            String input = scanner.nextLine();
            if (input.equals("exit")) {
                break;
            }

            networkService.sendMessage(input);
            System.out.println();
        }
        System.out.println("프로그램을 정상 종료합니다.");

    }
}

 

이제 사용자와 상호작용하는 부분이다. "exit" 문자열이 들어오면 루프를 빠져나와 프로그램을 종료한다. 

사용자가 입력한 문자열이 데이터가 되어 외부 서버에 전송이 될 것이다.

 

실행결과:

전송할 문자: 
Hello
https://example.com 서버 연결 성공
https://example.com 서버에 데이터 전송: Hello
https://example.com 서버 연결 해제

전송할 문자: 
exit
프로그램을 정상 종료합니다.

 

나의 코드가 원하는 대로 동작을 한다. 근데 원하는 대로 동작 안한다면? 세상에 아름다운 이야기만 있지는 않다.

예를 들어, 외부 서버와의 연결이 실패한다면? 연결은 성공했지만 데이터를 전송하는 과정에 어떤 문제로 인해 데이터를 전송하는데 실패한다면? 그런 상황을 생각해보기 위해 위 코드를 한번 수정해보자.

 

NetworkClientV1

package exception.ex1;

public class NetworkClientV1 {

    private final String address;
    private boolean connectError;
    private boolean sendError;

    public NetworkClientV1(String address) {
        this.address = address;
    }

    public String connect() {
        if (connectError) {
            System.out.println(address + " 서버 연결 실패");
            return "connectError";
        }

        System.out.println(address + " 서버 연결 성공");
        return "Success";
    }

    public String send(String data) {
        if (sendError) {
            System.out.println(address + " 서버에 데이터 전송 실패: " + data);
            return "sendError";
        }

        System.out.println(address + " 서버에 데이터 전송: " + data);
        return "Success";
    }

    public void disconnect() {
        System.out.println(address + " 서버 연결 해제");
    }

    public void initError(String data) {
        if (data.contains("error1")) {
            connectError = true;
        }
        if (data.contains("error2")) {
            sendError = true;
        }
    }
}

 

이제 이 NetworkClientV1 클래스에는 connectError, sendError 두 개의 필드가 있다. 그리고 각 필드값이 true라면, connect() 메서드나 send() 메서드에서 에러 상황이 일어났다고 가정한다.

 

그리고 boolean 타입의 필드 기본값은 false이니까, 들어오는 데이터에 따라 그 값을 true로 변경하는 initError() 메서드를 만든다.

메서드 내용은 전달받은 파라미터 data(사용자 입력값)의 값이 "error1"을 포함하면 connectErrortrue로, "error2"를 포함하면 sendErrortrue로 변경한다.

 

NetworkServiceV1_1

package exception.ex1;


public class NetworkServiceV1_1 {

    public void sendMessage(String data) {
        String address = "https://example.com";
        NetworkClientV1 client = new NetworkClientV1(address);
        client.initError(data);

        String connectResult = client.connect();
        if (isError(connectResult)) {
            System.out.println("[네트워크 오류 발생] 오류 코드: " + connectResult);
            return;
        }

        String sendResult = client.send(data);
        if (isError(sendResult)) {
            System.out.println("[네트워크 오류 발생] 오류 코드: " + sendResult);
            return;
        }

        client.disconnect();
    }

    private static boolean isError(String connectResult) {
        return !connectResult.equals("Success");
    }
}

이제 NetworkClientV1을 가져다가 사용하는 서비스를 보자.

우선 사용자로부터 입력받은 data에 대한 initError() 메서드를 먼저 실행한 후, connect(), send() 메서드를 각각 실행한다.

 

근데 connect() 또는 send() 메서드가 에러가 있는 경우 더이상 "Success"를 반환하지 않고 "connectError" 또는 "sendError"를 반환하기 때문에 그럴 경우 에러가 발생했다는 것을 알려주고 다음 단계를 중지해야 한다. 그도 그럴것이 앞 단계에서 에러가 났는데 다음 단계가 잘 될리 없다.

 

실행결과:

전송할 문자: 
hello
https://example.com 서버 연결 성공
https://example.com 서버에 데이터 전송: hello
https://example.com 서버 연결 해제

전송할 문자: 
error1
https://example.com 서버 연결 실패
[네트워크 오류 발생] 오류 코드: connectError

전송할 문자: 
error2
https://example.com 서버 연결 성공
https://example.com 서버에 데이터 전송 실패: error2
[네트워크 오류 발생] 오류 코드: sendError

전송할 문자: 
exit
프로그램을 정상 종료합니다.

 

이제 만약 에러가 발생하면 에러가 발생했다는 사실을 사용자에게 출력해주고, 다음 단계를 진행하지 않는다. 좋다. 근데 한가지 문제가 있다. 에러가 발생하면 자원을 반납하는 disconnect() 메서드가 호출되지 않고 있다. 

참고: 자바의 경우 GC가 있기 때문에 JVM 메모리에 있는 인스턴스는 자동으로 해제할 수 있다. 하지만 외부 연결과 같은 자바 외부의 자원은 자동으로 해제가 되지 않는다. 따라서 외부 자원을 사용한 후에는 연결을 해제해서 외부 자원을 반드시 반납해야 한다.

 

그럼, 이제 사용자에게 에러가 발생했다는 것도 알려주고, 에러가 나면 다음 단계를 진행하지 않는것 까지는 좋은데 에러가 발생하든 발생하지 않든 disconnect() 메서드는 호출되어야 하기 때문에 이 부분을 해결해야 한다.

 

NetworkServiceV1_3

package exception.ex1;


public class NetworkServiceV1_3 {

    public void sendMessage(String data) {
        String address = "https://example.com";
        NetworkClientV1 client = new NetworkClientV1(address);
        client.initError(data);

        String connectResult = client.connect();
        if (isError(connectResult)) {
            System.out.println("[네트워크 오류 발생] 오류 코드: " + connectResult);
        } else {
            String sendResult = client.send(data);
            if (isError(sendResult)) {
                System.out.println("[네트워크 오류 발생] 오류 코드: " + sendResult);
            }
        }
        client.disconnect();
    }

    private static boolean isError(String connectResult) {
        return !connectResult.equals("Success");
    }
}

해결하는 방법은 간단하다. if-else로 조건을 분기하고 에러가 발생해도 바로 return을 하지 않으면 된다.

이렇게 코드를 수정하면 연결에 실패하든 데이터 전송에 실패하든 disconnect() 메서드를 호출할 수 있다.

 

실행결과:

전송할 문자: 
hello
https://example.com 서버 연결 성공
https://example.com 서버에 데이터 전송: hello
https://example.com 서버 연결 해제

전송할 문자: 
error1
https://example.com 서버 연결 실패
[네트워크 오류 발생] 오류 코드: connectError
https://example.com 서버 연결 해제

전송할 문자: 
error2
https://example.com 서버 연결 성공
https://example.com 서버에 데이터 전송 실패: error2
[네트워크 오류 발생] 오류 코드: sendError
https://example.com 서버 연결 해제

전송할 문자: 
exit
프로그램을 정상 종료합니다.

 

이제 정상흐름일 땐 정상흐름답게, 예외 흐름일 땐 문제 없이 사용자에게 예외 발생을 알리고 disconnect() 메서드도 잘 호출한다. 

 

근데, 아직 해결되지 않은 문제가 있다. 

정상 흐름과 예외 흐름이 섞여있어서 코드 해석이 어려워졌고 예외 처리 흐름이 심지어 더 많은 코드 분량을 차지하고 있기 때문에 어디가 정상 흐름이고 어디가 예외 흐름인지 분석하기가 어렵다. 

그래서 이렇게 connect(), send() 메서드의 반환값을 통해서 예외 상황을 처리하는 방식은 해결할 수 없는 문제를 가지고 있다.

 

이런 문제를 해결하기 위해 바로 예외 처리 메커니즘이 존재한다. 예외 처리를 사용하면 정상 흐름과 예외 흐름을 명확하게 분리할 수 있다.

 

결론

자바에서 제공하는 예외 처리 메커니즘을 사용해야 하는 이유는 다음과 같다.

  • 세상엔 정상 흐름만 존재하지 않는다.
  • 예외 흐름을 반환값 같은 코드적으로 해결하려 해도 예외 흐름과 정상 흐름이 섞여 있어 코드를 분석하기 어렵다.
  • 코드적으로 해결하다보니 예외 흐름이 정상 흐름보다 코드 양이 더 많아진다.

 

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

예외 처리 3 (예외 처리 도입)  (0) 2024.04.23
예외 처리 2 (예외 계층)  (0) 2024.04.23
try-with-resources  (0) 2024.04.23
익명 클래스  (0) 2024.04.21
지역 클래스  (0) 2024.04.21
728x90
반응형
SMALL

참고자료:

 

김영한의 실전 자바 - 중급 1편 | 김영한 - 인프런

김영한 | 실무에 필요한 자바의 다양한 중급 기능을 예제 코드로 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을

www.inflearn.com

 

try-with-resources 구문을 사용해서 사용한 자원을 반납하는데 효율적으로 반납해보자.

애플리케이션에서 외부 자원을 사용하는 경우 반드시 외부 자원을 해제해야 한다. (예: Database connection)

그래서 항상 try - finally 구문을 사용했다. 

try {
   //정상 흐름
} catch() {
   //예외 흐름
} finally {
   //반드시 호출해야 하는 마무리 흐름
}

이런 반복적인 구문을 사용하면서 편의 기능을 자바 7에서 도입했는데 그게 try-with-resources다.

이 구문을 사용하면 try가 끝나는 순간 자원을 알아서 반납해준다.

 

이 기능을 사용하려면 먼저 AutoClosable 인터페이스를 구현해야 한다.

public interface AutoClosable {
    void close() throws Exception;
}

 

이 인터페이스를 구현한 클래스의 인스턴스를 사용하면 try가 끝나는 시점에 close() 메서드가 자동으로 호출된다.

 

그리고 다음과 같이 try-with-resources 구문을 사용하면 된다.

try (Resource resource = new Resource()) {
    resource.xxx
    ...
}

 

직접 이 AutoClosable 인터페이스를 구현해서 try-with-resources를 사용해보자.

package exception.ex4;

import exception.ex4.exception.ConnectExceptionV4;
import exception.ex4.exception.SendExceptionV4;

public class NetworkClientV5 implements AutoCloseable {

    private final String address;
    private boolean connectError;
    private boolean sendError;

    public NetworkClientV5(String address) {
        this.address = address;
    }

    public void connect() {
        if (connectError) {
            throw new ConnectExceptionV4(address, "서버 연결 실패");
        }

        System.out.println(address + " 서버 연결 성공");
    }

    public void send(String data) {
        if (sendError) {
            // throw new RuntimeException("ex");
            throw new SendExceptionV4(data, address + " 서버에 데이터 전송 실패: " + data);
        }

        System.out.println(address + " 서버에 데이터 전송: " + data);
    }

    public void disconnect() {
        System.out.println(address + " 서버 연결 해제");
    }

    public void initError(String data) {
        if (data.contains("error1")) {
            connectError = true;
        }
        if (data.contains("error2")) {
            sendError = true;
        }
    }

    @Override
    public void close() {
        System.out.println("NetworkClientV5.close");
        disconnect();
    }
}

어떤 클래스던 AutoClosable 인터페이스를 구현하면, 반드시 구현해야 하는 메서드 close()가 있다. 이 close() 메서드에서 원하는 작업(리소스 정리)을 해주면 된다. 

 

NetworkClientV5 클래스를 사용하는 코드는 이렇게 생겼다.

package exception.ex4;

public class NetworkServiceV5 {

    public void sendMessage(String data) {
        String address = "https://example.com";

        try (NetworkClientV5 client = new NetworkClientV5(address)) {
            client.initError(data);
            client.connect();
            client.send(data);
        } catch (Exception e) {
            System.out.println("[에러 발생]: " + e.getMessage());
        }
    }
}

이렇게 try-with-resources 구문으로 인스턴스를 생성하고 try 구문 안에서 원하는 작업을 다 수행하면 try가 끝나는 시점에 바로 구현한 close() 메서드가 실행된다.

 

Main

package exception.ex4;

import exception.ex4.exception.SendExceptionV4;

import java.util.Scanner;

public class MainV4 {
    public static void main(String[] args) {
        NetworkServiceV4 networkService = new NetworkServiceV4();

        Scanner scanner = new Scanner(System.in);

        while (true) {
            System.out.println("전송할 문자: ");
            String input = scanner.nextLine();
            if (input.equals("exit")) {
                break;
            }

            try {
                networkService.sendMessage(input);
            } catch (Exception e) {
                exceptionHandler(e);
            }

            System.out.println();
        }
        System.out.println("프로그램을 정상 종료합니다.");
    }

    // 공통 예외 처리
    private static void exceptionHandler(Exception e) {
        System.out.println("사용자 메시지: 죄송합니다. 알 수 없는 문제가 발생했습니다.");

        System.out.println("==개발자용 디버깅 메시지==");
        e.printStackTrace(System.out);
        // e.printStackTrace();

        //필요하면 예외 별로 별도의 추가 처리 가능
        if (e instanceof SendExceptionV4 sendExceptionV4) {
            System.out.println("[전송 오류] 전송 데이터: " + sendExceptionV4.getData());
        }
    }
}

실행결과:

전송할 문자: 
hello
https://example.com 서버 연결 성공
https://example.com 서버에 데이터 전송: hello
https://example.com 서버 연결 해제

전송할 문자: 
error1
https://example.com 서버 연결 해제
사용자 메시지: 죄송합니다. 알 수 없는 문제가 발생했습니다.
==개발자용 디버깅 메시지==
exception.ex4.exception.ConnectExceptionV4: 서버 연결 실패
	at exception.ex4.NetworkClientV4.connect(NetworkClientV4.java:18)
	at exception.ex4.NetworkServiceV4.sendMessage(NetworkServiceV4.java:11)
	at exception.ex4.MainV4.main(MainV4.java:21)

 

정상 흐름이나, 예외 흐름이나 모두 서버 연결 해제가 잘 호출된다. 그리고 예외 흐름일 때 호출 시점이 중요한데 보면 catch에 걸렸을 때 catch로 가기전 close()메서드가 호출됐음을 확인할 수 있다. 이렇듯 정말 try가 끝나는 즉시 close()메서드가 호출된다. 

 

try-with-resources 장점

  • 리소스 누수 방지: 모든 리소스가 제대로 닫히도록 보장한다. 실수로 finally 블록을 적지 않거나 finally 블럭 안에서 자원 해제 코드를 누락하는 문제들을 예방한다.
  • 코드 간결성 및 가독성 향상: 명시적인 close() 메서드 호출이 필요 없어 코드가 더 간결하고 읽기 쉬워진다.
  • 스코프 범위 한정: 예를 들어 리소스로 사용되는 client 변수의 스코프가 try 블록 안으로 한정된다. 따라서 코드 유지보수가 더 쉬워진다. 
  • 조금 더 빠른 자원 해제: 기존에는 try - catch - finally 순으로 catch 이후에 자원을 반납하는데 try-with-resources 구문은 try 블록이 끝나면 바로 반납한다.
728x90
반응형
LIST
728x90
반응형
SMALL

참고자료:

 

김영한의 실전 자바 - 중급 1편 | 김영한 - 인프런

김영한 | 실무에 필요한 자바의 다양한 중급 기능을 예제 코드로 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을

www.inflearn.com

 

익명 클래스는 마찬가지로 중첩 클래스의 일종이다.

익명 클래스는 이름 그대로 이름이 없는 클래스를 의미하는데, 저번 지역 클래스에서 사용했던 코드를 가져와서 익명 클래스로 변경해보자.

 

package nested.local;

import java.lang.reflect.Field;

public class OuterClass {

    private int outerInstanceVar = 10;
    private static int outerClassVar = 100;

    public LocalClazzInterface process(int paramVar) {
        int localVal = 2;

        class LocalClazz implements LocalClazzInterface {
            private int localInstanceVal = 5;

            @Override
            public void print() {
                System.out.println("localInstanceVal = " + localInstanceVal);
                System.out.println("localVal = " + localVal);
                System.out.println("paramVar = " + paramVar);
                System.out.println("outerInstanceVar = " + outerInstanceVar);
                System.out.println("outerClassVar = " + outerClassVar);
            }
        }

        return new LocalClazz();
    }

    public static void main(String[] args) {
        OuterClass outerClass = new OuterClass();
        LocalClazzInterface localClazz = outerClass.process(1);

        localClazz.print();

        Field[] declaredFields = localClazz.getClass().getDeclaredFields();
        for (Field declaredField : declaredFields) {
            System.out.println("declaredField = " + declaredField);
        }
    }
}

 

이 소스에서 LocalClazzInterface를 구현한 지역 클래스 LocalClazz를 익명 클래스로 변경할 수 있다.

 

AnonymousClass

package nested.local;

import java.lang.reflect.Field;

public class AnonymousClass {

    private int outerInstanceVar = 10;
    private static int outerClassVar = 100;

    public LocalClazzInterface process(int paramVar) {
        int localVal = 2;

        LocalClazzInterface localClazz = new LocalClazzInterface() {
            private int localInstanceVal = 5;
            @Override
            public void print() {
                System.out.println("localInstanceVal = " + localInstanceVal);
                System.out.println("localVal = " + localVal);
                System.out.println("paramVar = " + paramVar);
                System.out.println("outerInstanceVar = " + outerInstanceVar);
                System.out.println("outerClassVar = " + outerClassVar);
            }
        };

        System.out.println("localClazz.getClass() = " + localClazz.getClass());

        return localClazz;
    }

    public static void main(String[] args) {
        AnonymousClass outerClass = new AnonymousClass();
        LocalClazzInterface localClazz = outerClass.process(1);

        localClazz.print();

        Field[] declaredFields = localClazz.getClass().getDeclaredFields();
        for (Field declaredField : declaredFields) {
            System.out.println("declaredField = " + declaredField);
        }
    }
}

실행결과:

localClazz.getClass() = class nested.local.AnonymousClass$1
localInstanceVal = 5
localVal = 2
paramVar = 1
outerInstanceVar = 10
outerClassVar = 100
declaredField = private int nested.local.AnonymousClass$1.localInstanceVal
declaredField = final int nested.local.AnonymousClass$1.val$localVal
declaredField = final int nested.local.AnonymousClass$1.val$paramVar
declaredField = final nested.local.AnonymousClass nested.local.AnonymousClass$1.this$0

 

process() 메서드 내부를 보면, LocalClazzInterface 라는 인터페이스에 new라고 새로 생성하는 것처럼 보이는데 인터페이스를 인스턴스로 만드는 게 아니라 인터페이스를 구현하는 구현체를 만들어내는 것이다. 근데 따로 클래스를 정의해서 구현하는게 아니고 바로 생성해내는 방식을 익명클래스라고 한다.

 

그러니까 쉽게 말해 지역클래스에 대한 선언과 생성을 한번에 한다고 보면 된다.

 

익명 클래스 특징

  • 익명 클래스는 이름 없는 지역 클래스를 선언하면서 동시에 생성한다.
  • 익명 클래스는 부모 클래스를 상속 받거나, 또는 인터페이스를 구현해야 한다. 익명 클래스를 사용할 때는 상위 클래스나 인터페이스가 필요하다.
  • 익명 클래스는 말 그대로 이름이 없다. 이름을 가지지 않으므로 생성자를 가질 수 없다. (기본 생성자만 사용됨)
  • 익명 클래스는 위 실행결과의 AnonymousClass$1 과 같이 자바 내부에서 바깥 클래스 이름 + $ + 숫자로 정의된다. 익명 클래스가 여러개면 $1, $2, $3 으로 숫자가 증가하면서 구분된다.

익명 클래스의 장점

익명 클래스를 사용하면 클래스를 별도로 정의하지 않아도 인터페이스나 추상 클래스를 즉석에서 구현할 수 있어 코드가 더 간결해진다. 하지만 복잡하거나 재사용이 필요한 경우에는 별도의 클래스를 정의하는 것이 좋다.

 

익명 클래스를 사용할 수 없을 때

익명 클래스는 단 한 번만 인스턴스를 생성할 수 있다. 다음과 같이 여러 번 생성이 필요하다면 익명 클래스를 사용할 수 없다. 대신에 지역 클래스를 선언하고 사용하면 된다. 

LocalClazz localClazz1 = new LocalClazz();
localClazz1.print();

LocalClazz localClazz2 = new LocalClazz();
localClazz2.print();

 

익명 클래스 정리

  • 익명 클래스는 이름이 없는 지역 클래스이다.
  • 특정 부모 클래스(인터페이스)를 상속 받고 바로 생성하는 경우 사용한다.
  • 지역 클래스가 일회성으로 사용되는 경우나 간단한 구현을 제공할 때 사용한다.

 

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료:

 

김영한의 실전 자바 - 중급 1편 | 김영한 - 인프런

김영한 | 실무에 필요한 자바의 다양한 중급 기능을 예제 코드로 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을

www.inflearn.com

 

 

지역 클래스는 내부 클래스의 특별한 종류 중 하나이다. 그렇기에 내부 클래스의 특징을 그대로 가진다.

예를 들어, 지역 클래스도 내부 클래스이므로 바깥 클래스의 인스턴스 멤버에 접근할 수 있다.

 

지역 클래스는 지역 변수와 같이 코드 블럭 안에서 정의된다.

지역 클래스 예시

class Outer {
    
    public void process() { 
        int localVar = 0; //지역 변수
        
        class Local {...} //지역 클래스
        
        Local local = new Local();
    }
}

 

 

그래서 지역 클래스가 접근 가능한 것에는 다음과 같은 것들이 있다.

  • 지역 변수
  • 바깥 클래스의 인스턴스 변수
  • 바깥 클래스의 클래스 변수 (사실 이건 당연한 것. 클래스 변수는 어디서나 접근이 가능하기 때문에. 근데! private으로 선언해도 접근이 가능)

 

지역 클래스를 직접 만들어 보면 다음과 같다.

package nested.local;

public class OuterClass {

    private int outerInstanceVar = 10;
    private static int outerClassVar = 100;

    public void process(int paramVar) {
        int localVal = 2;

        class LocalClazz {
            private int localInstanceVal = 5;

            public void localClazzMethod() {
                System.out.println("localInstanceVal = " + localInstanceVal);
                System.out.println("localVal = " + localVal);
                System.out.println("paramVar = " + paramVar);
                System.out.println("outerInstanceVar = " + outerInstanceVar);
                System.out.println("outerClassVar = " + outerClassVar);
            }
        }

        LocalClazz localClazz = new LocalClazz();
        localClazz.localClazzMethod();
    }

    public static void main(String[] args) {
        OuterClass outerClass = new OuterClass();
        outerClass.process(1);
    }
}
  • 지역 클래스는 선언한 블록 안에서만 접근이 가능하다. 
    • 그렇기 때문에 process() 메서드 안에서 지역 클래스를 생성하고 지역 클래스의 인스턴스로부터 메서드를 호출할 수 있다.
  • 지역 클래스는 접근 제어자는 붙일 수 없다.
    • 지역 변수에 접근 제어자를 못 붙이는거랑 같은 맥락으로 생각하면 된다.

 

모든 중첩 클래스들은 일반 클래스처럼 인터페이스를 구현하거나 부모 클래스를 상속할 수 있다.

여기서 모든 중첩 클래스는(정적 중첩 클래스, 내부 클래스, 지역 클래스, 익명 클래스)를 말한다.

 

LocalClazzInterface

package nested.local;

public interface LocalClazzInterface {
    void print();
}

인터페이스를 상속받는 지역 클래스 예시

package nested.local;

public class OuterClass {

    ...

    public void process(int paramVar) {
        int localVal = 2;

        class LocalClazz implements LocalClazzInterface {
            private int localInstanceVal = 5;

            public void localClazzMethod() {
                System.out.println("localInstanceVal = " + localInstanceVal);
                System.out.println("localVal = " + localVal);
                System.out.println("paramVar = " + paramVar);
                System.out.println("outerInstanceVar = " + outerInstanceVar);
                System.out.println("outerClassVar = " + outerClassVar);
            }

            @Override
            public void print() {
                System.out.println("Hi");
            }
        }
		...
    }

    ...
}

 

지역 클래스 - 지역 변수 캡처

지역 변수 캡처라는 내용을 이해하기 전 변수의 생명 주기에 대해서 먼저 짚고 넘어가보자.

변수는 크게 세가지가 있다.

  • 클래스 변수
  • 인스턴스 변수
  • 지역 변수

변수의 생명 주기

  • 클래스 변수: 생명 주기가 가장 길다. 왜냐하면 이 클래스 변수는 프로그램 종료까지 살아 있다. 메모리 구조 내 메서드 영역에 있는 변수이기 때문에 이 프로그램에 종료되기까지 살아 있게 된다. 
  • 인스턴스 변수: 생명 주기는 인스턴스가 살아 있는 동안까지이다. 인스턴스는 힙 영역에 자리하고 있다. 인스턴스 변수는 본인이 소속된 인스턴스가 GC되기 전까지 존재한다. 생존 주기가 긴 편이다.
  • 지역 변수: 메서드 호출이 끝나면 사라진다. 지역 변수는 스택 영역에서 스택 프레임 안에 존재한다. 따라서 메서드가 호출될 때 생성되고 메서드 호출이 종료되면 스택 프레임이 제거되면서 그 안에 있는 지역 변수도 모두 제거된다. 생존 주기가 제일 짧다. 매개변수도 지역변수다. 

이 내용을 머리속에 넣은 상태에서 다음 코드를 보자.

package nested.local;

public class OuterClass {

    private int outerInstanceVar = 10;
    private static int outerClassVar = 100;

    public LocalClazzInterface process(int paramVar) {
        int localVal = 2;

        class LocalClazz implements LocalClazzInterface {
            private int localInstanceVal = 5;

            @Override
            public void print() {
                System.out.println("localInstanceVal = " + localInstanceVal);
                System.out.println("localVal = " + localVal);
                System.out.println("paramVar = " + paramVar);
                System.out.println("outerInstanceVar = " + outerInstanceVar);
                System.out.println("outerClassVar = " + outerClassVar);
            }
        }

        return new LocalClazz();
    }

    public static void main(String[] args) {
        OuterClass outerClass = new OuterClass();
        LocalClazzInterface localClazz = outerClass.process(1);

        localClazz.print();
    }
}

 

이전에 사용한 코드를 조금 수정한 내용이다.

우선 process() 메서드는 LocalClazzInterface 타입을 반환한다. 그래서 LocalClazzInterface를 구현한 구현체인 LocalClazzprocess() 메서드가 반환한다. 

 

main() 메서드에서 반환받은 LocalClazzInterface 타입의 변수 localClazzprint() 메서드를 실행한다.

실행 결과는 다음과 같다.

localInstanceVal = 5
localVal = 2
paramVar = 1
outerInstanceVar = 10
outerClassVar = 100

 

결론은 잘 찍힌다. 근데 의아한 부분이 하나 있다. 우선 하나씩 생각해보자.

내부 클래스로 만든 인스턴스도 인스턴스다. 그렇기 때문에 힙 영역에 존재하게 된다. 그리고 내부 클래스를 생성하면 내부 클래스는 바깥 클래스를 참조하고 있다. 

 

그럼 main() 메서드에서 process() 메서드를 호출해서 받은 내부 클래스의 인스턴스는 main() 메서드가 종료될 때 까지 살아 있는게 당연하다. 어디선가(main()) 이 인스턴스를 참조하고 있기 떄문에 GC에 해당하지 않는다. 그 말은 내부 클래스가 살아있으면 바깥 클래스도 여전히 살아 있게 된다는 말이다(내부 클래스가 바깥 클래스의 참조값을 가지고 있으니까).

 

그럼 내부 클래스의 변수인 localInstanceVal, 바깥 클래스의 인스턴스 변수인 outerInstanceVar, 바깥 클래스의 클래스 변수인 outerClassVar는 저 시점에 접근이 가능한게 맞다. 그러니까 아직 저 변수 세 개는 살아 있는게 맞다.

 

근데? process() 메서드의 지역 변수(매개변수 포함)인 localVal, paramVar는? 얘네는 process()가 호출될 때 스택 영역에 스택 프레임에 쌓이는 순간 생성되고 process() 메서드가 종료될 때 스택 프레임에서 제거되므로 저 변수 두 개도 삭제가 된다.

 

변수 두 개가 삭제가 되는데 여전히 실행을 하면 저 두개의 값이 잘 찍히고 있다. 어떻게 이게 가능할까?

여기서 나오는 해결책이 바로 지역 변수 캡처이다.

 

사실, 저렇게 실행결과를 찍을 때 지역 변수를 찾아서 가져오는 게 아니다. 지역 클래스에서 필요한 지역 변수가 있을 때 그 값을 지역 클래스의 필드로 몰래 만들어 낸다.

참고로, 모든 지역 변수를 캡처하는 게 아니다. 접근이 필요한 지역 변수만 캡쳐한다.

 

그러니까 코드로 보면 다음과 같다.

package nested.local;

public class OuterClass {

	...
    
    public LocalClazzInterface process(int paramVar) {
        int localVal = 2;

        class LocalClazz implements LocalClazzInterface {
            private int localInstanceVal = 5;

            // 이렇게 클래스 내부에 필드로 선언을 몰래 해버린다.
            int localVal = 2;
            int paramVar = ...;
        }

        return new LocalClazz();
    }
	...
}

 

접근이 필요한 지역 변수 localVal, paramVar를 클래스 내부에 필드로 몰래 선언을 한다. 이렇게 해놓으니까 process() 메서드가 종료되면서 날라가는 스택 프레임에 같이 있던 지역 변수가 삭제되더라도 지역 클래스에서 저 변수를 출력해낼 수 있는 것.

 

실제로 그런지 코드로도 확인해보자. 어떻게 확인할 수 있냐면 getClass() 메서드를 통해 가지고 있는 필드를 조회해보자.

public static void main(String[] args) {
    OuterClass outerClass = new OuterClass();
    LocalClazzInterface localClazz = outerClass.process(1);

    localClazz.print();

    Field[] declaredFields = localClazz.getClass().getDeclaredFields();
    for (Field declaredField : declaredFields) {
        System.out.println("declaredField = " + declaredField);
    }
}

실행 결과:

localInstanceVal = 5
localVal = 2
paramVar = 1
outerInstanceVar = 10
outerClassVar = 100
declaredField = private int nested.local.OuterClass$1LocalClazz.localInstanceVal
declaredField = final int nested.local.OuterClass$1LocalClazz.val$localVal
declaredField = final int nested.local.OuterClass$1LocalClazz.val$paramVar
declaredField = final nested.local.OuterClass nested.local.OuterClass$1LocalClazz.this$0

 

지역 클래스가 가지고 있는 localInstanceVal 외에 localVal, paramVar가 보인다. 정말로 우리 몰래 필드를 클래스에 넣어버렸다.

그리고 또 하나, 마지막에 출력된 이거.

declaredField = final nested.local.OuterClass nested.local.OuterClass$1LocalClazz.this$0

이게 바로 바깥 클래스의 참조값이다. 이렇게 지역 클래스는 바깥 클래스의 참조값도 보관하고 있음을 알 수 있다. 

 

결론은,

이러한 지역 변수 캡쳐를 통해서 스택 영역에 쌓인 스택 프레임이 삭제되면서 지역 변수가 같이 삭제되더라도 힙 영역에 존재하는 지역 클래스는 문제 없이 지역 변수에 접근이 가능하다. 사실 지역 변수에 접근이 가능하단 말도 정확한게 아니고 본인의 캡쳐된 변수에 접근한다.

 

근데.. 결론일까? 그럼 여기서 의문이 하나 더 생긴다.

그럼 지역 변수를 캡처해서 가지고 있는것 까지 알았는데 이 지역 변수의 값을 바꾸면 어떻게 되는거지?!

지역 클래스가 접근하는 지역 변수는 절대로 중간에 값이 변하면 안된다.

이건 자바 규칙이고 문법이다. 그래서 final로 선언하거나, 사실상 final이어야 한다.

사실상 finaleffectively final을 번역한 것이고 final로 선언하진 않았지만 선언 이후 값을 변경하지 않는 지역 변수를 의미한다.

 

그럼 왜 final 또는 사실상 final이어야 하고? 왜 중간에 값이 변하면 안될까?

지역 클래스가 접근하는 지역 변수는 지역 변수 캡쳐를 하기 때문이다.

 

중간에 변경하려고 하면 다음과 같이 컴파일 에러가 발생한다.

 

지역 클래스가 사용하는 지역 변수는 지역 변수 캡처가 일어나는데 중간에 지역 변수 값을 바꿔버리면 동기화 문제가 발생한다. 그런 이유로 인해 자바에서 아예 지역 변수를 변경하는 것 자체를 막아둔 것.

 

그래서 진짜 결론은 다음과 같다.

 

결론

지역 클래스는 접근하는 지역 변수를 지역 변수 캡처를 통해 가지고 있게 된다. 그래서 스택 영역에 쌓인 스택 프레임이 삭제되면서 같이 삭제되는 지역 변수에 접근하지 않아도 지역 변수 캡처값을 가지고 접근이 가능해진다. 그리고 이 지역 변수 캡처를 하기 때문에 지역 클래스로부터 접근이 되는 지역 변수는 중간에 값이 변경될 수 없다. 

 

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

try-with-resources  (0) 2024.04.23
익명 클래스  (0) 2024.04.21
중첩 클래스(정적 중첩 클래스, 내부 클래스)  (0) 2024.04.14
Stream API  (2) 2024.04.07
날짜와 시간  (0) 2024.04.04
728x90
반응형
SMALL

참고자료:

 

김영한의 실전 자바 - 중급 1편 | 김영한 - 인프런

김영한 | 실무에 필요한 자바의 다양한 중급 기능을 예제 코드로 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을

www.inflearn.com

 

클래스 안에 클래스를 중첩해서 정의할 수 있는데, 이것을 중첩 클래스(Nested Class)라고 한다.

중첩 클래스 예시

class Outer {
	...
    class Nested {
    	...
    }
}

 

근데 중첩 클래스는 클래스를 정의하는 위치에 따라 다음과 같이 분류한다.

 

이 네가지가 모두 중첩 클래스라고 불리는데, 여기서 2가지로 분류가 가능하다.

  • 정적 중첩 클래스 (static)
  • 내부 클래스 종류
    • 내부 클래스
    • 지역 클래스
    • 익명 클래스

 

중첩 클래스는 변수를 선언하는 위치와 같다.

 

변수의 선언 위치

  • 클래스 변수 (static)
  • 인스턴스 변수
  • 지역 변수

중첩 클래스 선언 위치

  • 정적 중첩 클래스 -> 정적 변수(클래스 변수)와 같은 위치
  • 내부 클래스 -> 인스턴스 변수와 같은 위치
  • 지역 클래스, 익명 클래스 -> 지역 변수와 같은 위치
class Outer {
	...
    // 정적 중첩 클래스
    static class StaticNested {
    	...
    }
    
    // 내부 클래스
    class Inner {
    	...
    }
    
    public void method() {
    	...
        // 지역 클래스
        class Local() {...}
        
        Local local = new Local();
        ...
    }
}

 

익명 내부 클래스는 저렇게 클래스를 선언하고 사용하는 게 아니라, 추상 클래스나 인터페이스 같은것을 상속받은 클래스가 필요한데 이걸 굳이 클래스 파일로 만들어서 클래스라는 키워드를 사용해서 만드는게 아니라 한 번 사용하고 말거 같을 때 코드 내에서 선언할 수가 있다.

 

익명 내부 클래스 예시

context.execute(new Strategy() {
    @Override
    public void call() {
        log.info("비즈니스 로직 1 실행");
    }
});

 

그럼 정적 중첩 클래스와 내부 클래스를 분리하는 이유는 뭘까?

 

정적 중첩 클래스와 내부 클래스의 차이

정적 중첩 클래스

  • static이 붙는다.
  • 바깥 클래스의 인스턴스에 소속되지 않는다.

내부 클래스

  • static이 붙지 않는다.
  • 바깥 클래스의 인스턴스에 소속된다.
그러니까 엄밀히 말하면 정적 중첩 클래스내부 클래스는 완전히 다른 거고 내부 클래스정적 중첩 클래스라고 말하거나 정적 중첩 클래스내부 클래스라고 말하면 안된다. 근데 말하면서 그냥 섞어 쓰니까 상황과 문맥에 따라 잘 이해해서 받아들이면 된다.

 

 

중첩 클래스는 언제 사용할까?

내부 클래스를 포함한 모든 중첩 클래스는 특정 클래스가 다른 하나의 클래스 안에서만 사용되거나, 둘이 아주 긴밀하게 연결되어 있는 경우에만 사용해야 한다. 외부의 여러 클래스가 특정 중첩 클래스를 사용한다면 중첩 클래스로 만들면 안된다.

 

중첩 클래스를 사용하는 이유

  • 논리적 그룹화: 특정 클래스가 다른 하나의 클래스 안에서만 사용되는 경우 해당 클래스 안에 포함하는 것이 논리적으로 더 그룹화된다. 패키지를 열었을 때 다른 곳에서 사용될 필요가 없는 중첩 클래스가 외부에 노출되지 않는 장점도 있다.
  • 캡슐화: 중첩 클래스는 바깥 클래스의 private 멤버에 접근할 수 있다. 이렇게 해서 둘을 긴밀하게 연결하고 불 필요한 public 메서드를 제거할 수 있다.

 

정적 중첩 클래스

정적 중첩 클래스의 사용 예시를 보자.

public class StaticNestedClass {

    private static int outerStaticValue = 1;
    private int outerInstanceValue = 2;

    static class InStaticNestedClass {
        private int inStaticNestedInstanceValue = 10;
        public void print() {
            // 본인의 인스턴스 변수에 당연히 접근 가능
            System.out.println("inStaticNestedInstanceValue = " + inStaticNestedInstanceValue);

            // 외부 클래스의 클래스 변수에 접근이 가능 (근데, private 이어도 가능한게 유일한 차이)
            System.out.println("outerStaticValue = " + outerStaticValue);

            // 외부 클래스의 인스턴스 변수에는 접근이 불가능, 왜냐하면 static을 생각해보면 됨 static이니까 같은 static만 접근이 가능하다고 보면 됨
            // 인스턴스 영역(힙 영역)에 저 변수에 접근할 수 있는 방법이 없음
            // System.out.println("outerStaticValue = " + outerInstanceValue);
        }
    }
}

 

정적 중첩 클래스는 외부의 클래스와 그냥 다른 클래스라고 보면 된다.

그러니까 다음 코드랑 동일하다고 보면 된다. 

class StaticNestedClass {

}

class InStaticNestedClass {

}

 

근데 한가지 다른 하나, 정적 중첩 클래스는 외부 클래스의 private 클래스 변수에 접근이 가능하다.

private에 접근이 가능한 이유는 내부에 있기 때문. private은 자기 자신에서는 접근이 가능한데 정적 중첩 클래스는 결국 {} 안에 존재하기 때문에. 

 

호출은 이렇게 하면 된다.

Main

public class StaticNestedMain {
    public static void main(String[] args) {
        StaticNestedClass.InStaticNestedClass inStaticNestedClass = new StaticNestedClass.InStaticNestedClass();

        inStaticNestedClass.print();
    }
}

 

 

그럼 정적 중첩 클래스는 어느 순간에 써야하는지 예제를 통해 알아보자.

 

정적 중첩 클래스의 사용 예시

 

NetworkMessage

public class NetworkMessage {
    private String content;

    public NetworkMessage(String content) {
        this.content = content;
    }

    public void print() {
        System.out.println(content);
    }
}

 

Network

public class Network {
    public void sendMessage(String text) {
        NetworkMessage networkMessage = new NetworkMessage(text);
        networkMessage.print();
    }
}

 

이렇게 두 개의 클래스가 있다. 이때 NetworkMessage 클래스는 외부 어디에서도 사용하지 않고 Network 클래스에서만 사용하고 있다고 가정해보자.

 

이걸 만든 사람과 이걸 가져다가 사용하는 사람이 서로 다른 사람이라고 가정할 때 가져다가 사용하는 사람은 이렇게 두 개의 클래스를 보면 이런 생각이 든다. 어? 뭘 사용해야하지? 가 첫번째. 두번째는 어떻게 잘 파악해서 "아 Network 클래스에서 NetworkMessage를 가져다가 사용하니까 Network 클래스를 사용하면 되겠구나." 라고 생각을 어찌저찌 하긴 할 것이다.

 

그런데 이렇게 짠 코드가 아니라 다음 코드를 보자.

Refactoring된 Network

public class Network {

    public void sendMessage(String text) {
        NetworkMessage networkMessage = new NetworkMessage(text);
        networkMessage.print();
    }

    private static class NetworkMessage {
        private String content;

        public NetworkMessage(String content) {
            this.content = content;
        }

        public void print() {
            System.out.println(content);
        }
    }
}

 

이렇게 정적 중첩 클래스로 안에서 만들고 심지어 이 클래스의 접근제어자가 private이면 외부에서는 저 클래스에 아예 접근 자체를 하지 못하게 하겠다는 강한 의지이기 때문에 이걸 가져다가 사용하는 개발자는 아무런 고민할 필요가 없다. 애시당초에 클래스 파일자체가 이것 하나만 있으니 다른 클래스를 더 볼 필요도 없고 이렇게 Network에서만 사용되는 클래스라면 내부에 정적 중첩 클래스로 선언해서 사용하면 파일 개수도 줄고 가시성도 좋아진다.

 

정적 중첩 클래스는 이런 장점과 사용성이 있다. 내부 클래스 말고 정적 중첩 클래스(static)로 선언한 이유는 외부 클래스의 인스턴스 변수에 접근할 필요가 없으면 이게 더 확실한 의도를 보여줄 수 있기 때문.

 

내부 클래스

정적 중첩 클래스는 외부 클래스와 아무런 관련이 없다.

서로 다른 클래스다. 근데 한가지! 외부 클래스의 private 클래스 변수에도 접근이 가능하다는 점.

원래 클래스 변수면 어디서나 접근이 가능한데, private이면 그 클래스 내부에서만 접근이 가능한데 정적 중첩 클래스도 그 클래스 내부 {}에 존재하기 때문에 가능한 원리.

 

근데 이제 내부 클래스는 외부 클래스와 밀접한 관련이 있다. 그냥 외부 클래스의 인스턴스를 이루는 요소가 된다.

 

정적 중첩 클래스

  • static이 붙는다.
  • 바깥 클래스의 인스턴스에 소속되지 않는다.

내부 클래스

  • static이 붙지 않는다.
  • 바깥 클래스의 인스턴스에 소속된다.

예제 코드를 통해 내부 클래스를 알아보자.

public class InnerOuter {
    private static int outClassValue = 3;
    private int outInstanceValue = 2;

    class Inner {
        private int innerInstanceValue = 1;

        public void print() {
            // 자기 자신에 접근
            System.out.println("innerInstanceValue = " + innerInstanceValue);

            //외부 클래스의 인스턴스 변수
            System.out.println("outInstanceValue = " + outInstanceValue);

            //외부 클래스의 클래스 변수
            System.out.println("outClassValue = " + outClassValue);
        }
    }
}

내부 클래스는 앞에 static이 붙지 않고 인스턴스 멤버가 된다.

그래서 내부 클래스는 자신의 멤버에도 접근이 당연히 가능하고, 바깥 클래스의 인스턴스 멤버에도 접근이 가능하고, 바깥 클래스의 클래스 멤버에는 당연히 접근이 가능하다. 근데 여기서 private도 접근이 가능하다는 것만 좀 주의하면 된다.

 

그래서 이 내부 클래스는 어떻게 호출하고 사용할까?

public class InnerOuterMain {
    public static void main(String[] args) {
        InnerOuter outer = new InnerOuter();
        InnerOuter.Inner inner = outer.new Inner();

        inner.print();
    }
}

내부 클래스는 다시 한번 말하지만 외부 클래스의 인스턴스에 속한다. 그래서 외부 클래스 없이는 내부 클래스 자체적으로 인스턴스를 생성할 수 없다. 그래서 아래처럼 외부 클래스의 인스턴스를 생성하고 그 외부 클래스 인스턴스의 내부 클래스를 만들어야 한다.

InnerOuter outer = new InnerOuter();
InnerOuter.Inner inner = outer.new Inner();

 

그래서 외부 클래스와 내부 클래스를 개념적으로 살펴보면 다음 그림처럼 생각하면 된다.

외부 클래스의 인스턴스가 내부 클래스의 인스턴스를 포함하고 있고 이런 구조이기 때문에 내부 클래스의 인스턴스는 바깥 인스턴스를 알기 때문에 바깥 인스턴스의 멤버에 접근이 가능하다.

 

이렇게만 알아둬도 충분한데 실제로 구조는 조금 다르다. 근데 저렇게 알아두면 된다.

실제 구조는 다음과 같다.

내부 인스턴스가 외부 인스턴스의 참조값을 가지고 있기 때문에 바깥 인스턴스의 멤버에 접근이 가능한 게 실제 구조다.

 

정리를 하자면

정적 중첩 클래스는 외부 클래스와 아무런 관련이 없다. 서로 다른 클래스이지만 외부 클래스가 관리하는 영역 {} 안에 있기 때문에 private으로 선언해도 접근이 가능할 뿐이다.

 

내부 클래스는 외부 클래스의 인스턴스에 속한다. 그렇기 때문에 내부 클래스는 생성할 때 외부 클래스의 인스턴스에 속한채로 생성되어야 한다. 그리고 내부 인스턴스는 외부 인스턴스를 알고 있기 때문에 외부 인스턴스 멤버에 접근이 가능하다.

 

 

내부 클래스의 용도

내부 클래스를 사용하지 않았을 때 코드를 먼저 보고 내부 클래스를 사용하면 어떻게 더 좋아지는지 보자.

 

내부 클래스를 사용하지 않았을 때

Car

package nested.inner.ex1;

public class Car {
    private String model;
    private int chargeLevel;
    private Engine engine;

    public Car(String model, int chargeLevel) {
        this.model = model;
        this.chargeLevel = chargeLevel;
        this.engine = new Engine(this);
    }


    // Engine에서만 사용하는 메서드
    public int getChargeLevel() {
        return chargeLevel;
    }

    // Engine에서만 사용하는 메서드
    public String getModel() {
        return model;
    }

    public void start() {
        engine.start();
        System.out.println(model + " 시작 완료");
    }
}

Engine

package nested.inner.ex1;

// Car Class 에서만 사용
public class Engine {

    private Car car;

    public Engine(Car car) {
        this.car = car;
    }

    public void start() {
        System.out.println("충전 레벨 확인: " + car.getChargeLevel());
        System.out.println(car.getModel() + "의 엔진을 구동합니다.");
    }
}

Main

package nested.inner.ex1;

public class CarMain {
    public static void main(String[] args) {
        Car car = new Car("Model Y", 100);
        car.start();
    }
}

 

지금 보면 어떤 문제가 있냐면 두가지 문제가 있는데,

1. Car 클래스에서만 사용하는 Engine 클래스를 두개의 클래스 파일로 나뉘어져 사용 중

2. Engine 클래스에서만 사용하는 Car 클래스에서 만든 메서드가 있음

 

이걸 어떻게 좋은 코드로 변경해볼까?

내부 클래스를 사용했을 때

Car

package nested.inner.ex2;

public class Car {
    private final String model;
    private final int chargeLevel;
    private final Engine engine;

    private class Engine {
        public void start() {
            System.out.println("충전 레벨 확인 = " + chargeLevel);
            System.out.println(model + " 의 엔진을 구동합니다.");
        }
    }

    public Car(String model, int chargeLevel) {
        this.model = model;
        this.chargeLevel = chargeLevel;
        this.engine = new Engine();
    }

    public void start() {
        engine.start();
        System.out.println(model + " 시작 완료");
    }
}

 

Car 클래스에서만 사용되는 Engine 클래스라면 그냥 Car 안에 private 내부 클래스로 선언하면 된다.

그리고 내부 클래스로 선언했기 때문에 Engine 클래스는 더이상 Car에 대한 인스턴스를 따로 받을 필요가 없다.

그리고 Engine 클래스는 내부 클래스이므로 외부 클래스의 인스턴스 멤버에 접근이 가능하기 때문에 다이렉트로 필드에 접근하면 된다. 그 말은 Car 클래스에서 불필요한 메서드를(불필요 하다기보단 오로지 Engine만을 위해 만든 메서드) 없앨 수 있다. 그리고 이 말은 캡슐화를 더 강력하게 해준다. 

 

Main

package nested.inner.ex2;

public class CarMain {
    public static void main(String[] args) {
        Car car = new Car("Model Y", 100);
        car.start();
    }
}

 

슈퍼 간단해졌다. 이게 내부 클래스를 사용하는 이유이다.

 

이제 다시 처음으로 돌아가서 왜 중첩 클래스를 사용하냐?!

중첩 클래스(정적 중첩 클래스, 내부 클래스)는 특정 클래스가 다른 하나의 클래스 안에서만 사용되거나, 둘이 아주 긴밀하게 연결되어 있는 특별한 경우에만 사용해야 한다. 외부 여러곳에서 특정 클래스를 사용한다면 중첩 클래스로 사용하면 안된다.

 

사용하는 이유는 크게 두가지이다.

  • 논리적 그룹화: 특정 클래스가 다른 하나의 클래스에서만 사용된다면 해당 클래스 안에 포함하는 게 더 논리적으로 그룹화가 된다. 패키지를 열었을 때 다른 곳에서 사용될 필요가 없는 중첩 클래스가 외부에 노출되지 않는다는 장점도 있다.
  • 캡슐화: 중첩 클래스는 바깥 클래스의 private 멤버에 접근이 가능하다. 그 말은 중첩클래스가 아니고 이 클래스만을 위해서 만든 메서드는 필요가 없다는 뜻이다. (위에 내부 클래스를 사용하지 않았을 때 Car 예시처럼 오로지 Engine 클래스만을 위해 public으로 메서드를 만들었다면 캡슐화가 약해지는 것이다.)

 

같은 이름의 바깥 변수 접근

아래 같은 경우를 말하는 것.

package nested.inner.ex2;

public class Shadowing {
    private int value = 3;

    private class InnerShadowing {
        private int value = 5;

        public void shadowing() {
            int value = 10;
            System.out.println("value = " + value); // 이 메서드의 지역 변수 value
            System.out.println("this.value = " + this.value); // 내부 클래스의 인스턴스 변수 value
            System.out.println("Shadowing.this = " + Shadowing.this.value); // 바깥 클래스의 value
        }
    }

    public static void main(String[] args) {
        Shadowing shadowing = new Shadowing();
        InnerShadowing innerShadowing = shadowing.new InnerShadowing();

        innerShadowing.shadowing();
    }
}

외부 클래스와 내부 클래스에서 같은 이름으로 변수를 만들었을 때 접근하는 방법이다.

근데, 이렇게 변수 이름을 모호하게 작성 안하는게 가장 좋은 방법이다.

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

익명 클래스  (0) 2024.04.21
지역 클래스  (0) 2024.04.21
Stream API  (2) 2024.04.07
날짜와 시간  (0) 2024.04.04
Enum  (0) 2024.04.03
728x90
반응형
SMALL

요즘 Stream API를 사용하면서 얻게 되는 코드 가시성 향상과 코드 라인 간소화가 너무 재밌어진다. 그래서 이것 또한 JAVA에서 기본이 되는 내용 같아서 정리를 하고자한다.

Stream API가 필요한 이유

만약, 다음과 같은 배열이 주어졌다면 그리고 그 배열에 어떤 정렬이 필요하다고 할 때 Stream을 사용하지 않고 처리해보자.

String[] names = {"choi", "kim", "park", "sung", "jang"};

이런 배열이 있을 때, 오름차순으로 정렬을 하고 싶다. 그러면 다음과 같은 메서드를 사용해볼 수 있다.

Arrays.sort(names);

 

그리고 이것을 사용해서 실제로 배열을 출력해보기 위해 다음과 같은 코드를 작성한다. 

String[] names = {"kim", "choi", "park", "sung", "jang"};
        
Arrays.sort(names);

for (String name : names) {
    System.out.println(name);
}

 

실행결과:

choi
jang
kim
park
sung

 

원하는대로 잘 정렬이 됐다. 이게 이 요구사항을 처리할 때 나쁜 코드라고 말할 순 없을 것이다. 

근데 이것은 2가지 단점이 있다.

  • 원본 데이터가 변경된다.
  • 더 복잡한 요구사항이 있다면 로직을 작성했을 때 코드의 간결성이 떨어질 수 있다.

실제로 기존에 names 배열은 이제 없고 정렬된 배열로 변경된다. 위 코드를 보면 반환값을 찍은게 아닌 그대로 "names"를 찍었다.

 

그래서 이를 더 간결화하기 위해 Stream API를 사용한 코드를 보자.

Stream API 사용

Arrays
    .stream(names)
    .sorted()
    .forEach(System.out::println);

 

실행결과:

choi
jang
kim
park
sung

 

저 한 줄로 원하는 결과를 출력할 수 있게 됐다. 그리고 가장 큰 장점은 기존의 배열에는 영향이 없다는 사실이다.

실제로 그런지 아래 코드를 실행해보자.

public static void main(String[] args) {
    String[] names = {"kim", "choi", "park", "sung", "jang"};

    Arrays.stream(names).sorted().forEach(System.out::println);

    System.out.println("names = " + Arrays.toString(names));
}

 

실행결과:

choi
jang
kim
park
sung
names = [kim, choi, park, sung, jang]

 

Stream의 특징

  • 원본의 데이터를 변경하지 않는다.
  • 일회성이므로 한 번 사용하면 다시 사용하지 못한다.

 

어? 원본의 데이터를 변경하지 않는다는 것은 알았는데, 일회성이란 말은 무슨말일까? 다음 코드를 보자.

public static void main(String[] args) {
    String[] names = {"kim", "choi", "park", "sung", "jang"};

    Stream<String> streamNames = Arrays.stream(names);

    streamNames.sorted().forEach(System.out::println);

    System.out.println("firstValue = " + streamNames.findFirst());
}

 

streamNames 라는 Stream을 만들고, 그 녀석으로 오름차순 정렬을 수행한 뒤 루프를 돌려 하나씩 출력하는 코드를 진행 후에 다시 그 스트림을 가지고 첫번째 값을 찍어내는 시도를 했다. 실행 결과는 다음과 같다.

 

보는것과 같이 에러가 발생했다. 에러 내용은 스트림이 이미 한 번 작동됐고 더 사용할 수 없게 닫혀진 상태다라는 내용이다. 이렇듯 스트림은 한번 사용한 후 다시 재사용할 수 없다. 이것을 반드시 알아두어야 한다.

 

 

Stream의 여러 기능 중 절대적으로 중요한 것들

많은 기능이 있는데 개인적으로 filter(), map(), sorted(), distinct() 정도는 알아야 할 것 같다.

 

filter()

public static void main(String[] args) {
    String[] names = {"kim", "choi", "park", "sung", "jang"};

    List<String> filteredList = 
            Arrays.stream(names)
                    .filter(s -> s.length() >= 4)
                    .toList();
    System.out.println("filteredList = " + filteredList);
}

 

말 그대로다. 어떤 솔팅과정을 해주는 메서드이고, 다음과 같이 길이가 4이상인 문자열만 뽑아내고 싶다는 요구사항에 맞게 코드를 작성해주면 다음과 같은 결과를 얻을 수 있다.

filteredList = [choi, park, sung, jang]

 

map()

public static void main(String[] args) {
    String[] names = {"kim", "choi", "park", "sung", "jang"};

    List<String> upperCases = Arrays.stream(names).map(String::toUpperCase).toList();
    System.out.println("upperCases = " + upperCases);
}

 

map()은 기존의 Stream 요소들을 변환하여 새로운 Stream을 만들어내는 메서드이다. 위 코드는 요소 하나하나를 모두 대문자로 변경하고 리스트로 변환하는 경우이다. 출력하면 다음과 같은 결과를 얻을 수 있다.

upperCases = [KIM, CHOI, PARK, SUNG, JANG]

 

 

sorted()

public static void main(String[] args) {
    String[] names = {"kim", "choi", "park", "sung", "jang"};

    List<String> sorted = Arrays.stream(names).sorted().toList();
    System.out.println("sorted = " + sorted);
}

정렬을 할 때 사용되는 sorted(). 실행 결과는 다음과 같다.

sorted = [choi, jang, kim, park, sung]

 

 

distinct()

public static void main(String[] args) {
    String[] names = {"kim", "choi", "park", "sung", "choi", "choi"};

    List<String> sorted = Arrays.stream(names).distinct().toList();
    System.out.println("sorted = " + sorted);
}

중복 제거를 위해 사용되는 distinct(). 실행 결과는 다음과 같다.

sorted = [kim, choi, park, sung]

 

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

지역 클래스  (0) 2024.04.21
중첩 클래스(정적 중첩 클래스, 내부 클래스)  (0) 2024.04.14
날짜와 시간  (0) 2024.04.04
Enum  (0) 2024.04.03
Class 클래스  (0) 2024.04.03
728x90
반응형
SMALL

참고 자료:

 

김영한의 실전 자바 - 중급 1편 | 김영한 - 인프런

김영한 | 실무에 필요한 자바의 다양한 중급 기능을 예제 코드로 깊이있게 학습합니다., [사진]국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바

www.inflearn.com

 

날짜와 시간에 대한 작업은 굉장히 아주 많이 어렵고 까다롭다. 왜냐하면 고려할 사항이 무진장 많기 때문인데 윤년, 각 달의 일수, 일광 절약 시간(DST), 타임존(GMT, UTC, ...) 등 고려할 사항이 너무 많다. 그래서 그냥 개발자가 직접 날짜와 시간을 계산하면 99.9%의 확률로 잘못된 계산이 될 것이다.

 

그래서 이러한 복잡성 때문에 대부분의 현대 개발 환경에서는 날짜와 시간을 처리하기 위해 잘 설계된 라이브러리를 사용해야 한다. 자바의 날짜와 시간 라이브러리는 java.time 패키지다.

이 표가 자바가 제공하는 날짜와 시간 라이브러리의 축약버전이다. 다양한 클래스들이 있고 그 클래스가 표현하는 단위와 방식이 어떻게 되는지가 자세히 나와있다. 하나씩 자세히 알아보자.

 

LocalDate, LocalTime, LocalDateTime

LocalDate: 날짜만 표현할 때 사용한다. 년월일을 다룬다. 예) 2024-04-04

LocalTime: 시간만을 표현할 때 사용한다. 시분초를 다룬다. 예) 09:20:30.213

LocalDateTime: LocalDate와 LocalTime을 합한 개념이다. 예) 2024-04-04T09:20:30.213

 

앞에 Local(현지의)이 붙는 이유는 세계 시간대를 고려하지 않아서 타임존이 적용되지 않기 때문이다. 특정 지역의 날짜와 시간만 고려할 때 사용한다. 예) 국내 서비스만 고려하는 애플리케이션

 

ZonedDateTime, OffsetDateTime

ZonedDateTime: 시간대를 고려한 날짜와 시간을 표현할 때 사용한다. 여기에는 시간대를 표현하는 타임존이 포함된다.

  • 예) 2024-04-04T09:20:30.213+9:00[Asia/Seoul]
  • +9:00은 UTC(협정 세계시)로 부터의 시간대 차이이다. 오프셋이라고 하고 +0900으로도 잘 표현된다. 한국은 UTC보다 +9:00 시간이다.
  • Asia/Seoul을 타임존이라고 한다. 이 타임존을 알면 오프셋과 일광 절약 시간제에 대한 정보를 알 수 있다.
  • 일광 절약 시간제가 적용된다.

OffsetDateTime: 시간대를 고려한 날짜와 시간을 표현할 때 사용한다. 여기에는 타임존은 없고, UTC로부터의 시간대 차이인 고정된 오프셋만 포함한다.

  • 예) 2024-04-04T09:20:30.213+9:00
  • 일광 절약 시간제가 적용되지 않는다.

 

Asia/Seoul 같은 타임존 안에는 일광 절약 시간제에 대한 정보와 UTC+9:00와 같은 UTC로부터 시간 차이인 오프셋 정보를 모두 포함하고 있다. 일광 절약 시간제(DST, 썸머타임)를 알려면 타임존을 알아야 한다. 따라서 ZonedDateTime은 일광 절약 시간제를 함께 처리한다. 반면에 타임존을 알 수 없는 OffsetDateTime은 일광 절약 시간제를 처리하지 못한다. 

 

Year, Month, YearMonth, MonthDay

년, 월, 년월, 달일을 각각 다룰 때 사용하나 자주 사용하지는 않는다. DayOfWeek와 같이 월, 화, 수, 목, 금, 토, 일을 나타내는 클래스도 있다. 

 

Instant

Instant는 UTC(협정 세계시)를 기준으로 하는, 시간의 한 지점을 나타낸다. Instant는 날짜와 시간을 나노초 정밀도로 표현하며, 1970년 1월 1일 0시 0분 0초(UTC)를 기준으로 경과한 시간으로 계산된다. 쉽게 이야기해서 Instant 내부에는 초 데이터만 들어있다.

 

Period, Duration

시간의 개념은 크게 2가지로 표현될 수 있다.

  • 특정 시점의 시간(시각)
    • 이 프로젝트는 2024년 4월 20일까지 완료해야해
    • 다음 회의는 10시에 진행한다
  • 시간의 간격(기간)
    • 이 프로젝트는 3개월 남았어
    • 라면은 3분동안 끓어야 해

Period, Duration은 시간의 간격(기간)을 표현하는데 사용된다.

 

Period

두 날짜 사이의 간격을 년, 월, 일 단위로 나타낸다

 

Duration

두 시간 사이의 간격을 시, 분, 초(나노초) 단위로 나타낸다.

 

 

LocalDate, LocalTime, LocalDateTime 생성

모든 날짜 클래스는 불변이다. 따라서 변경이 발생하는 경우 새로운 객체를 생성해서 반환하므로 반환값을 꼭 받아야 한다.

LocalDate

생성

  • now(): 현재 시간을 기준으로 생성한다.
  • of(...): 특정 날짜를 기준으로 생성한다. 년, 월, 일을 입력할 수 있다.

계산

  • plusDays(): 특정 일을 더한다. 다양한 plusXxx() 메서드가 존재한다.

LocalTime

생성

  • now(): 현재 시간을 기준으로 생성한다.
  • of(...): 특정 날짜를 기준으로 생성한다. 시, 분, 초를 입력할 수 있다.

계산

  • plusSeconds(): 특정 초을 더한다. 다양한 plusXxx() 메서드가 존재한다.

LocalDateTime

생성

  • now(): 현재 시간을 기준으로 생성한다.
  • of(...): 특정 날짜와 시간을 기준으로 생성한다.

분리

  • 날짜(LocalDate)와 시간(LocalTime)을 toXxx() 메서드로 분리할 수 있다.

합체

  • LocalDateTime.of(localDate, localTime) 메서드로 날짜와 시간을 사용해서 LocalDateTime을 만든다.

계산

  • plusSeconds(): 특정 초을 더한다. 다양한 plusXxx() 메서드가 존재한다.

비교

  • isBefore(): 다른 날짜시간과 비교한다. 현재 날짜와 시간이 이전이라면 true를 반환한다.
  • isAfter(): 다른 날짜시간과 비교한다. 현재 날짜와 시간이 이후라면 true를 반환한다.
  • isEquals(): 다른 날짜시간과 날짜시간적으로 동일한지 비교한다. 시간이 같으면 true를 반환한다.

isEquals() vs equals()

  • isEquals()는 단순히 비교 대상이 날짜시간적으로 같으면 true를 반환한다. 객체가 다르고, 타임존이 달라도 시간적으로 같으면 true를 반환한다. 쉽게 이야기해서 날짜시간을 계산해서 날짜시간만으로 둘을 비교한다. 예) 서울의 9시와 UTC의 0시는 시간적으로 같다. 이 둘을 비교하면 true를 반환한다.
  • equals()는 객체의 타입, 타임존 등등 내부 데이터의 모든 구성요소가 같아야 true를 반환한다. 예) 서울의 9시와 UTC의 0시는 시간적으로 같다. 이 둘을 비교하면 타임존의 데이터가 다르기 때문에 false를 반환한다.

 

ZonedDateTime

"Asia/Seoul" 같은 타임존 안에는 일광 절약 시간제에 대한 정보와 UTC+9:00와 같은 UTC로부터 시간 차이인 오프셋 정보를 모두 포함하고 있다. 자바는 타임존을 ZondId 클래스로 제공한다.

public class ZonedDateTimeMain {
    public static void main(String[] args) {
        Set<String> availableZoneIds = ZoneId.getAvailableZoneIds();
        for (String availableZoneId : availableZoneIds) {
            System.out.println("availableZoneId = " + availableZoneId);
        }

        ZoneId zoneId = ZoneId.systemDefault();
        System.out.println("SystemDefaultZoneId = " + zoneId);
        System.out.println("zoneId.getRules() = " + zoneId.getRules());
    }
}

 

실행결과:

....
availableZoneId = America/Winnipeg
availableZoneId = Europe/Vatican
availableZoneId = Asia/Amman
availableZoneId = Etc/UTC
availableZoneId = SystemV/AST4ADT
availableZoneId = Asia/Tokyo
availableZoneId = America/Toronto
availableZoneId = Asia/Singapore
availableZoneId = Australia/Lindeman
availableZoneId = America/Los_Angeles
availableZoneId = SystemV/EST5EDT
availableZoneId = Pacific/Majuro
availableZoneId = America/Argentina/Buenos_Aires
availableZoneId = Europe/Nicosia
availableZoneId = Pacific/Guadalcanal
availableZoneId = Europe/Athens
availableZoneId = US/Pacific
availableZoneId = Europe/Monaco
SystemDefaultZoneId = Asia/Seoul
zoneId.getRules() = ZoneRules[currentStandardOffset=+09:00]

 

ZoneId.systemDefault() 메서드는 시스템이 사용하는 기본 ZoneId를 반환한다. 현재 내 PC의 ZoneId는 Asia/Seoul이다.

ZoneId는 내부에 일광 절약 시간 관련 정보, UTC와의 오프셋 정보를 포함하고 있다.

 

ZonedDateTime

ZonedDateTime은 LocalDateTime에 시간대 정보인 ZoneId가 합쳐진 것이다.

ZonedDateTime 클래스

public class ZonedDateTime {
     private final LocalDateTime dateTime;
     private final ZoneOffset offset;
     private final ZoneId zone;
}

 

시간대를 고려한 날짜와 시간을 표현할 때 사용한다. 여기에는 시간대를 표현하는 타임존이 포함된다.

예) 2013-11-21T18:20:30.213+9:00[Asia/Seoul]

+9:00는 UTC(협정 세계시)로 부터의 시간대 차이이다. 오프셋이라고 한다.

ZoneId를 통해 타임존을 알면 일광 절약 시간제에 대한 정보도 알 수 있다. 따라서 일광 절약 시간제가 적용된다.

 

public class ZonedDateTimeMain {
    public static void main(String[] args) {
        ZonedDateTime zdt = ZonedDateTime.now();
        System.out.println("zdt = " + zdt);

        LocalDateTime ldt = LocalDateTime.of(2030, 1, 1, 13, 30, 50);
        ZonedDateTime zdt2 = ZonedDateTime.of(ldt, ZoneId.of("Asia/Seoul"));
        System.out.println("zdt2 = " + zdt2);

        ZonedDateTime zdt3 = ZonedDateTime.of(2030, 1, 1, 13, 30, 50, 0, ZoneId.of("Asia/Seoul"));
        System.out.println("zdt3 = " + zdt3);

        ZonedDateTime utc = zdt3.withZoneSameInstant(ZoneId.of("UTC"));
        System.out.println("utc = " + utc);
    }
}

실행결과:

zdt = 2024-04-04T16:52:47.055743+09:00[Asia/Seoul]
zdt2 = 2030-01-01T13:30:50+09:00[Asia/Seoul]
zdt3 = 2030-01-01T13:30:50+09:00[Asia/Seoul]
utc = 2030-01-01T04:30:50Z[UTC]

 

위 코드에서 보면 생성하는 방법이 역시 2가지다. now(), of(...).

 

그리고 타임존을 변경할 수도 있다. withZoneSameInstant(ZoneId) 메서드를 사용하면 타임존을 변경한다. 타임존에 맞추어 시간도 함께 변경된다. 이 메서드를 통해 지금 서울 시간으로 다른 나라는 몇 시 인지 확인할 수 있다.

 

OffsetDateTime

OffsetDateTime은 LocalDateTime에 UTC 오프셋 정보인 ZoneOffset이 합쳐진 것이다.

 

OffsetDateTime 클래스

public class OffsetDateTime {
     private final LocalDateTime dateTime;
     private final ZoneOffset offset;
}

시간대를 고려한 날짜와 시간을 표현할 때 사용한다. 여기에는 타임존은 없고, UTC로 부터의 시간대 차이인 고정된 오프셋만 포함한다.

예) 2013-11-21T08:20:30.213+9:00

ZoneId가 없으므로 일광 절약 시간제가 적용되지 않는다.

public class OffsetDateTimeMain {
    public static void main(String[] args) {
        OffsetDateTime now = OffsetDateTime.now();
        System.out.println("now = " + now);

        LocalDateTime localDateTime = LocalDateTime.of(2023, 1, 1, 13, 30, 50);
        System.out.println("localDateTime = " + localDateTime);

        OffsetDateTime offsetDateTime = OffsetDateTime.of(localDateTime, ZoneOffset.of("+01:00"));
        System.out.println("offsetDateTime = " + offsetDateTime);
    }
}

 

실행 결과:

now = 2024-04-04T19:54:45.743425+09:00
localDateTime = 2023-01-01T13:30:50
offsetDateTime = 2023-01-01T13:30:50+01:00

 

ZoneOffset은 +01:00처럼 UTC와의 시간 차이인 오프셋 정보만 보관한다.

 

ZoneDateTime vs OffsetDateTime

- ZoneDateTime은 구체적인 지역 시간대를 다룰 때 사용하며, 일광 절약 시간을 자동으로 처리할 수 있다. 사용자 지정 시간대에 따른 시간 계산이 필요할 때 적합하다.

- OffsetDateTime은 UTC와의 시간 차이만을 나타낼 때 사용하며, 지역 시간대의 복잡성을 고려하지 않는다. 시간대 변환 없이 로그를 기록하고, 데이터를 저장하고 처리할 때 적합하다.

 

참고로, 이 두가지는 글로벌 서비스를 하지 않으면 잘 사용하지 않는다.

 

Instant (기계 중심의 시간)

Instant는 UTC(협정 세계시)를 기준으로 하는, 시간의 한 지점을 나타낸다. Instant는 날짜와 시간을 나노초 정밀도로 표현하며, 1970년 1월 1일 0시 0분 0초(UTC 기준)를 기준으로 경과한 시간으로 계산된다. 쉽게 이야기해서 Instant 내부에는 초 데이터만 들어있다. (나노초 포함). 따라서 날짜와 시간을 계산에 사용할 때는 적합하지 않다.

 

Instant 클래스

public class Instant {
     private final long seconds;
     private final int nanos;
     ...
}
  • UTC 기준 1970년 1월 1일 0시 0분 0초라면 seconds에 0이 들어간다.
  • UTC 기준 1970년 1월 1일 0시 0분 10초라면 seconds에 10이 들어간다.
  • UTC 기준 1970년 1월 1일 0시 1분 0초라면 seconds에 60이 들어간다. 

참고 - Epoch 시간

Epoch time 또는 Unix timestamp는 컴퓨터 시스템에서 시간을 나타내는 방법 중 하나이다. 이는 1970년 1월 1일 00:00:00 UTC부터 현재까지 경과된 시간을 초 단위로 표현한 것이다. 즉, Unix 시간은 1970년 1월 1일 이후로 경과한 전체 초의 수로, 시간대에 영향을 받지 않는 절대적인 시간 표현 방식이다. 참고로 Epoch라는 뜻은 어떤 중요한 사건이 발생한 시점을 기준으로 삼는 시작점을 뜻하는 용어다. Instant는 바로 이 Epoch 시간을 다루는 클래스이다.

 

Instant 특징

  • 장점
    • 시간대 독립성: Instant는 UTC를 기준으로 하므로, 시간대에 영향을 받지 않는다. 이는 전 세계 어디서나 동일한 시점을 가리키는데 유용하다.
    • 고정된 기준점: 모든 Instant는 1970년 1월 1일 0시 0분 0초 UTC를 기준으로 하기 때문에 시간 계산 및 비교가 명확하고 일관된다.
  • 단점
    • 사용자 친화적이지 않음: Instant는 기계적인 시간 처리에는 적합하지만, 사람이 읽고 이해하기에는 직관적이지 않다. 예를 들어, 날짜와 시간을 계산하고 사용하는데 필요한 기능이 부족하다.
    • 시간대 정보 부재: Instant에는 시간대 정보가 포함되어 있지 않아, 특정 지역의 날짜와 시간으로 변환하려면 추가적인 작업이 필요하다.

사용 예 (물론, 글로벌 서비스를 다룬다고 가정할 때 의미가 생긴다)

  • 전 세계적인 시간 기준 필요 시: Instant는 UTC를 기준으로 하므로, 전 세계적으로 일관된 시점을 표현할 때 사용하기 좋다. 예를 들어, 로그 기록이나, 트랜잭션 타임스탬프, 서버 간의 시간 동기화 등이 이에 해당한다.
  • 시간대 변환 없이 시간 계산 필요 시: 시간대의 변화 없이 순수하게 시간의 흐름(예: 지속 시간 계산)만을 다루고 싶을 때 Instant가 적합하다. 이는 시간대 변환의 복잡성 없이 시간 계산을 할 수 있게 해준다.
  • 데이터 저장 및 교환: 데이터베이스에 날짜와 시간 정보를 저장하거나, 다른 시스템과 날짜와 시간 정보를 교환할 때 Instant를 사용하면, 모든 시스템에서 동일한 기준점(UTC)을 사용하게 되므로 데이터의 일관성을 유지하기 쉽다.
public class InstantMain {
    public static void main(String[] args) {
        Instant now = Instant.now();
        System.out.println("now = " + now);

        ZonedDateTime zdt = ZonedDateTime.now();
        System.out.println("zdt = " + zdt);

        Instant from = Instant.from(zdt);
        System.out.println("from = " + from);

        // Epoch Time에서 0초 경과 후 시간
        Instant epochStart = Instant.ofEpochSecond(0);
        System.out.println("epochStart = " + epochStart);

        // Epoch Time에서 3600초(1시간) 경과 후 시간
        Instant later = epochStart.plusSeconds(3600);
        System.out.println("later = " + later);

        // Epoch Time 으로부터 얼마나 지났나요? 
        long laterEpochSecond = later.getEpochSecond();
        System.out.println("laterEpochSecond = " + laterEpochSecond);
    }
}

 

실행결과:

now = 2024-04-04T13:27:02.390809Z
zdt = 2024-04-04T22:27:02.413119+09:00[Asia/Seoul]
from = 2024-04-04T13:27:02.413119Z
epochStart = 1970-01-01T00:00:00Z
later = 1970-01-01T01:00:00Z
laterEpochSecond = 3600

 

생성

  • now(): UTC를 기준 현재 시간의 Instant를 생성한다.
  • from(): 다른 타입의 날짜와 시간을 기준으로 Instant를 생성한다. 참고로 Instant는 UTC를 기준으로 하기 때문에 시간대 정보가 필요하다. 따라서 LocalDateTime은 사용할 수 없다.
  • ofEpochSecond(): 에포크 시간을 기준으로 Instant를 생성한다. 0초를 선택하면 에포크 시간인 1970년 1월 1일 0시 0분 0초로 생성된다.

계산

  • plusSeconds(): 초를 더한다. 초, 밀리초, 나노초 정도만 더하는 간단한 메서드가 제공된다.

조회

  • getEpochSecond(): 에포크 시간인 UTC 1970년 1월 1일 0시 0분 0초를 기준으로 흐른 초를 반환한다.
  • 여기서는 앞서 에포크 시간에 3600초를 더했기 때문에 3600이 반환된다.

 

Instant 정리

  • 조금 특별한 시간, 기계 중심, UTC 기준
  • 에포크 시간으로부터 흐른 시간을 초 단위로 저장
  • 전세계 모든 서버 시간을 똑같이 맞출 수 있음. 항상 UTC 기준이므로 한국에 있는 Instant, 미국에 있는 Instant의 시간이 똑같음
  • 서버 로그, epoch 시간 기반 계산이 필요할 때, 간단히 두 시간의 차이를 구할 때
  • 단점: 초 단위의 간단한 연산 기능, 복잡한 연산 못함
  • 대안: 날짜 계산 필요하면 LocalDateTime 또는 ZonedDateTime 사용

 

기간, 시간의 간격 Duration, Period

위에서 Duration, Period의 차이를 얘기했었다. 둘 다 시간의 간격(기간)을 표현하는데 사용하는데 표현하는 방식이 다르다.

 

Period

두 날짜 사이의 간격을 년, 월, 일 단위로 나타낸다. (예: 이 프로젝트는 3개월 남았다)

 

Duration

두 시간 사이의 간격을 시, 분, 초(나노초)단위로 나타낸다 (예: 라면을 끓이는 시간은 3분이다)

 

Period

public class Period {
     private final int years;
     private final int months;
     private final int days;
}

다음과 같이 가지고 있는 필드가 년, 월, 일로 있다.

 

Period를 사용해서 원하는 기간만큼 날짜에 더하거나 뺄 수 있고, 두 날짜 사이의 기간도 구할 수 있다.

public static void main(String[] args) {

    Period period = Period.ofDays(10);
    System.out.println("period = " + period);

    LocalDate ld = LocalDate.of(2030, 1, 1);
    LocalDate plusDate = ld.plus(period);
    System.out.println("ld = " + ld);
    System.out.println("plusDate = " + plusDate);

    LocalDate startDate = LocalDate.of(2023, 1, 1);
    LocalDate endDate = LocalDate.of(2023, 4, 2);
    Period between = Period.between(startDate, endDate);
    System.out.println("기간 = " + between.getMonths() + "개월" + between.getDays() + "일");
}

실행결과:

period = P10D
ld = 2030-01-01
plusDate = 2030-01-11
기간 = 3개월1일

 

 

Duration

두 시간 사이의 간격을 시, 분, 초(나노초)로 나타내고 다음과 같이 클래스가 구성되어 있다.

public class Duration {
     private final long seconds;
     private final int nanos;
}

내부에서 초를 기반으로 시, 분, 초를 계산해서 사용한다.

 

Duration을 사용해서 특정 시간에 시분초값을 더하거나 뺄 수 있으며, 두 시간 사이의 간격도 시분초로 나타낼 수 있다.

public static void main(String[] args) {

    Duration duration = Duration.ofMinutes(10);
    System.out.println("duration = " + duration);

    LocalTime lt = LocalTime.of(1, 0);
    System.out.println("기준 시간 = " + lt);

    LocalTime plusLt = lt.plus(duration);
    System.out.println("더한 시간 = " + plusLt);

    LocalTime start = LocalTime.of(9, 0);
    LocalTime end = LocalTime.of(10, 0);
    Duration between = Duration.between(start, end);
    System.out.println("차이 = " + between.getSeconds() + "초");
    System.out.println("근무 시간 = " + between.toHours() + "시간 " + between.toMinutesPart() + "분");
}

실행결과:

duration = PT10M
기준 시간 = 01:00
더한 시간 = 01:10
차이 = 3600초
근무 시간 = 1시간 0분

 

 

날짜와 시간의 핵심 인터페이스

시간은 크게 두가지 분류를 할 수 있다.

  • 특정 시점의 시간
  • 시간의 간격

둘을 표현하기 위해서 사용한 클래스들은 다음과 같다.

  • 특정 시점의 시간: LocalDateTime, LocalDate, LocalTime, ZonedDateTime, OffsetDateTime
  • 시간의 간격: Duration, Period

벌써 느낌이 둘로 인터페이스가 나뉠 것 같고 인터페이스를 구현한 구현체가 저들일거란 생각이 든다. 그래서 명세를 제공하고 그 명세를 어떤방식으로 구현하던 사용하는 클라이언트는 변경을 할 필요가 없게끔 설계했을것만 같은 생각이 든다. 맞다.

 

 

TemporalAccessor

  • 날짜와 시간을 읽기 위한 기본 인터페이스
  • 이 인터페이스는 특정 시점의 날짜와 시간 정보를 읽을 수 있는 최소한의 기능을 제공한다.

Temporal

  • TemporalAccessor의 하위 인터페이스로, 날짜와 시간을 조작(추가, 빼기 등)하기 위한 기능을 제공한다. 이를 통해 날짜와 시간을 변경하거나 조정할 수 있다.

간단히 정리하자면, TemporalAccessor는 읽기 전용 접근을, Temporal은 읽기와 쓰기(조작) 모두를 지원한다.

 

 

여기에 추가적으로 알아야 할 인터페이스가 있다. 

 

시간의 단위시간 필드.

 

시간의 단위TemporalUnit이다.

근데 시간의 단위가 뭐야?

초, 분, 시간 단위를 말한다고 생각하면 된다. 코드로 보면 바로 이해가 될 것이다.

 

TemporalUnitMain

import java.time.temporal.ChronoUnit;

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

        ChronoUnit[] values = ChronoUnit.values();
        for (ChronoUnit value : values) {
            System.out.println("value = " + value);
        }
    }
}

코드를 보면, TemporalUnit을 구현한 ChronoUnit의 values() 메서드를 사용한다.

즉, ENUM 클래스란 이야기다. 찍어보면 다음과 같이 나온다.

실행결과:

value = Nanos
value = Micros
value = Millis
value = Seconds
value = Minutes
value = Hours
value = HalfDays
value = Days
value = Weeks
value = Months
value = Years
value = Decades
value = Centuries
value = Millennia
value = Eras
value = Forever

 

시간 단위(ChronoUnit)로 시간 계산하기

그래서 이 단위를 이용해서 원하는 시간 단위로 시간의 계산이 가능하다.

System.out.println("시간 단위 = " + ChronoUnit.HOURS);
System.out.println("1시간을 초 단위로 = " + ChronoUnit.HOURS.getDuration().getSeconds());

System.out.println("일 단위 = " + ChronoUnit.DAYS);
System.out.println("1일을 초 단위로 = " + ChronoUnit.DAYS.getDuration().getSeconds());

LocalTime lt1 = LocalTime.of(1, 10, 0);
LocalTime lt2 = LocalTime.of(1, 20, 0);

long secondsBetween = ChronoUnit.SECONDS.between(lt1, lt2);
System.out.println("두 시간의 차이를 초 단위로 = " + secondsBetween);

long millisBetween = ChronoUnit.MILLIS.between(lt1, lt2);
System.out.println("두 시간의 차이를 밀리초 단위로 = " + millisBetween);

long minBetween = ChronoUnit.MINUTES.between(lt1, lt2);
System.out.println("두 시간의 차이를 분 단위로 = " + minBetween);

실행결과:

시간 단위 = Hours
1시간을 초 단위로 = 3600
일 단위 = Days
1일을 초 단위로 = 86400
두 시간의 차이를 초 단위로 = 600
두 시간의 차이를 밀리초 단위로 = 600000
두 시간의 차이를 분 단위로 = 10

 

이런게 시간의 단위 TemporalUnit이란 인터페이스.

그리고 그 인터페이스를 구현한 ChronoUnit.

 

 

그럼 시간 필드는 진짜 뭐야?

이건 개념이 조금 헷갈리는데 코드를 보면 또 이해가 된다.

 

ChronoFieldMain

import java.time.temporal.ChronoField;

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

        ChronoField[] values = ChronoField.values();
        for (ChronoField value : values) {
            System.out.println("value = " + value);
        }
    }
}

이 또한, 열거형인 클래스 ChronoField.

찍어보면 다음과 같이 나온다.

실행 결과:

value = NanoOfSecond
value = NanoOfDay
value = MicroOfSecond
value = MicroOfDay
value = MilliOfSecond
value = MilliOfDay
value = SecondOfMinute
value = SecondOfDay
value = MinuteOfHour
value = MinuteOfDay
value = HourOfAmPm
value = ClockHourOfAmPm
value = HourOfDay
value = ClockHourOfDay
value = AmPmOfDay
value = DayOfWeek
value = AlignedDayOfWeekInMonth
value = AlignedDayOfWeekInYear
value = DayOfMonth
value = DayOfYear
value = EpochDay
value = AlignedWeekOfMonth
value = AlignedWeekOfYear
value = MonthOfYear
value = ProlepticMonth
value = YearOfEra
value = Year
value = Era
value = InstantSeconds
value = OffsetSeconds

 

그러니까 이게 필드로 제공이 되는데, DayOfMonth란 필드를 예시로 들면 그 달의 며칠을 말하는 것.

다음 코드를 보자.

System.out.println("일년의 개월 범위 " + ChronoField.MONTH_OF_YEAR.range());
System.out.println("한 달의 일 수 범위 " + ChronoField.DAY_OF_MONTH.range());

실행 결과:

일년의 개월 범위 1 - 12
한 달의 일 수 범위 1 - 28/31

이런게 필드라고 생각하면 된다. 이걸 어떻게 사용할 수 있을까?

 

ChangeTimeMain

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

        LocalDateTime localDateTime = LocalDateTime.of(2030, 1, 1, 13, 30, 59);
        System.out.println("시간 필드의 Year = " + localDateTime.get(ChronoField.YEAR));
        System.out.println("시간 필드의 연도를 기준으로 월 = " + localDateTime.get(ChronoField.MONTH_OF_YEAR));
        System.out.println("시간 필드의 달을 기준으로 일 = " + localDateTime.get(ChronoField.DAY_OF_MONTH));
        System.out.println("시간 필드의 하루를 기준으로 시간 = " + localDateTime.get(ChronoField.HOUR_OF_DAY));
        System.out.println("시간 필드의 시간을 기준으로 분 = " + localDateTime.get(ChronoField.MINUTE_OF_HOUR));
        System.out.println("시간 필드의 분을 기준으로 초 = " + localDateTime.get(ChronoField.SECOND_OF_MINUTE));

    }
}

예를 들면 초를 나타낼 때 100초, 1000초로도 표현이 가능하지만, 1시간에 정해진 초는 0초 ~ 59초 사이이다. 이걸 표현하고 싶을 때 사용한다.

실행결과:

시간 필드의 Year = 2030
시간 필드의 연도를 기준으로 월 = 1
시간 필드의 달을 기준으로 일 = 1
시간 필드의 하루를 기준으로 시간 = 13
시간 필드의 시간을 기준으로 분 = 30
시간 필드의 분을 기준으로 초 = 59

 

근데, 저렇게 잘 안쓰고 편의 메서드를 사용한다. 그니까 저것을 가지고 이런 편의 메서드를 제공한다고 보면 된다.

System.out.println("getYear = " + localDateTime.getYear());
System.out.println("getMonthValue = " + localDateTime.getMonthValue());
System.out.println("getDayOfMonth = " + localDateTime.getDayOfMonth());
System.out.println("getHour = " + localDateTime.getHour());
System.out.println("getMinute = " + localDateTime.getMinute());
System.out.println("getSecond = " + localDateTime.getSecond());

실행결과:

getYear = 2030
getMonthValue = 1
getDayOfMonth = 1
getHour = 13
getMinute = 30
getSecond = 59

 

 

물론, 없는 편의 메서드도 있다.

getMinuteOfDay, getSecondOfDay 이런건 없다.

System.out.println("일 단위를 기준으로 분 = " + localDateTime.get(ChronoField.MINUTE_OF_DAY));
System.out.println("일 단위를 기준으로 초 = " + localDateTime.get(ChronoField.SECOND_OF_DAY));
일 단위를 기준으로 분 = 810
일 단위를 기준으로 초 = 48659

 

그래서, 지금까지 시간의 기간(간격)을 더하고 빼는 여러 방법을 다 나열해보자.

LocalDateTime localDateTime = LocalDateTime.of(2030, 1, 1, 13, 30, 59);

LocalDateTime plusDt1 = localDateTime.plus(10, ChronoUnit.YEARS);
System.out.println("plusDt1 = " + plusDt1);

LocalDateTime plusDt2 = localDateTime.plusYears(10);
System.out.println("plusDt2 = " + plusDt2);

Period period = Period.ofYears(10);
LocalDateTime plusDt3 = localDateTime.plus(period);
System.out.println("plusDt3 = " + plusDt3);

실행결과:

plusDt1 = 2040-01-01T13:30:59
plusDt2 = 2040-01-01T13:30:59
plusDt3 = 2040-01-01T13:30:59

 

여기서 핵심은 이것이다.

TemporalAccessor.get(), Temporal.plus()와 같은 인터페이스를 통해 특정 구현 클래스와 무관하게 일관성있는 시간 조회, 조작 기능을 제공한다는 것. plus() 메서드를 보라. 10이라는 int, Period 타입, ChronoUnit까지 전부 다 받아준다.

 

물론, 모든게 다 되는건 아니다. 다음 코드는 에러가 난다.

LocalDate now = LocalDate.now();
int minute = now.get(ChronoField.SECOND_OF_MINUTE);
System.out.println("minute = " + minute);

실행결과:

Exception in thread "main" java.time.temporal.UnsupportedTemporalTypeException: Unsupported field: SecondOfMinute
	at java.base/java.time.LocalDate.get0(LocalDate.java:698)
	at java.base/java.time.LocalDate.get(LocalDate.java:641)
	at time.ChangeTimeMain.main(ChangeTimeMain.java:41)

당연히 LocalDate 타입은 시분초에 대한 정보는 없기 때문에 필드 SECOND_OF_MINUTE으로는 값을 가져올 수 없다.

그래서 이런 문제를 예방하기 위해 TemporalAccessor와 Temporal 인터페이스는 이 타입이 특정 시간 단위나 필드를 사용할 수 있는지 확인할 수 있는 메서드도 제공한다.

TemporalAccessor

boolean isSupported(TemporalField field);

Temporal

boolean isSupported(TemporalUnit unit);

 

그래서 아래 코드처럼 안전하게 확인 후 시간을 가져오거나 가져오지 않거나 할 수 있다.

LocalDate now = LocalDate.now();
boolean supported = now.isSupported(ChronoField.SECOND_OF_MINUTE);
if (supported) {
    int minute = now.get(ChronoField.SECOND_OF_MINUTE);
    System.out.println("minute = " + minute);
}

 

날짜와 시간 조회하고 조작하기 (중요⭐️)

날짜와 시간을 조작하고 조회하는 경우를 은근 많이 만난다. 그래서 중요하다.

우선 날짜와 시간을 조작하는 with() 메서드를 사용해보자.

 

ChangeTime2Main

import java.time.LocalDateTime;
import java.time.temporal.ChronoField;

public class ChangeTime2Main {
    public static void main(String[] args) {
        LocalDateTime ldt = LocalDateTime.of(2020, 1, 1, 13, 30, 59);
        System.out.println("ldt = " + ldt);

        LocalDateTime newLdt = ldt.with(ChronoField.YEAR, 2030);
        System.out.println("newLdt = " + newLdt);

        LocalDateTime newLdt2 = ldt.withYear(2030);
        System.out.println("newLdt2 = " + newLdt2);
    }
}

 

with() 메서드를 사용하면 원하는 필드에 대해 날짜와 시간을 조작할 수 있다.

실행결과:

ldt = 2020-01-01T13:30:59
newLdt = 2030-01-01T13:30:59
newLdt2 = 2030-01-01T13:30:59

근데 이제 with() 메서드를 사용하려면 필드를 사용하는게 귀찮으니까 편의 메서드를 제공한다. withYear()가 그 예시이다. 

그리고 불변객체에 대해서 공부할 때 새로운 객체를 만들어낼 때 메서드의 이름을 통상 withXxx() 라고 말했는데 여기가 딱 그 대표적인 예시이다.

 

근데 이제 이런 경우가 있다. 정해진 LocalDateTime을 기준으로 다음으로 오는 금요일이라던지, 해당 달의 마지막 일요일이라던지 이런값을 구하고 싶을 때가 있다. TemporalAdjuster 인터페이스를 사용을 하면 된다.

 

원래라면, 이 인터페이스를 직접 구현한 구현체가 필요하겠지만 자바는 이미 필요한 구현체들을 TemporalAdjusters 라는 구현클래스에 구현체를 다 모아두었다. 이걸 사용하면 된다.

 

ChangeTime2Main 일부

LocalDateTime now = LocalDateTime.now();

LocalDateTime nextFriday = now.with(TemporalAdjusters.next(DayOfWeek.FRIDAY));
System.out.println("nextFriday = " + nextFriday);

LocalDateTime lastSundayInMonth = now.with(TemporalAdjusters.lastInMonth(DayOfWeek.SUNDAY));
System.out.println("lastSundayInMonth = " + lastSundayInMonth);

실행결과:

nextFriday = 2024-04-19T10:51:35.605616
lastSundayInMonth = 2024-04-28T10:51:35.605616

 

기가 막힌다. 지금까지는 직접 구하고 그랬는데 아는게 힘인게 역시 맞다..

그리고 이 클래스가 제공하는 주요 메서드들은 다음과 같다.

 

TemporalAdjusters 클래스의 주요 메서드

아래표에서 파라미터는 설명을 위해 몇개만 써넣은거지 안 써넣은게 없다는 뜻은 아니다.

메서드 설명
dayOfWeekInMonth(int ordinal, DayofWeek dayOfWeek) 주어진 요일이 몇 번째인지에 따라 날짜를 조정한다. 예를 들어, 파라미터에 (2, DayOfWeek.MONDAY) 이렇게 들어가면 2번째 주 월요일을 반납한다.
firstDayOfMonth 해당 월의 첫번째 날로 조정한다.
firstDayOfNextMonth 해당 월의 다음달 첫번째 날로 조정한다.
firstDayOfNextYear 다음 년도 첫번째 날로 조정한다.
firstDayOfYear 이번 년도 첫번째 날로 조정한다.
firstInMonth(DayOfWeek dayOfWeek) 첫번째 주의 주어진 요일로 조정한다. 예를 들어, 파라미터에 (DayOfWeek.TUESDAY)이렇게 들어가면 첫번째 주 화요일을 반환한다.
lastDayOfMonth 해당 월의 마지막 날로 조정한다.
lastDayOfNextMonth 다음 달의 마지막 날로 조정한다.
lastDayOfNextYear 다음 년도 마지막 날로 조정한다.
lastDayOfYear 이번 년도 마지막 날로 조정한다.
lastInMonth(DayOfWeek dayOfWeek) 주어진 요일 중 해당 월의 마지막 요일로 조정한다.
next(DayOfWeek dayOfWeek) 주어진 요일 이후의 가장 가까운 요일로 조정한다.
nextOrSame 주어진 요일 이후의 가장 가까운 요일로 조정하되, 현재 날짜가 주어진 요일인 경우 현재 날짜를 반환한다.
previous 주어진 요일 이전의 가장 가까운 요일로 조정한다.
previousOrSame 주어진 요일 이전의 가장 가까운 요일로 조정하되, 현재 날짜가 주어진 요일인 경우 현재 날짜를 반환한다.

 

 

날짜와 시간 문자열 파싱과 포맷팅

이게 용어를 정리하면 뭔가 좀 확 익숙해진다.

 

  • 포맷팅: 날짜를 원하는 형태의 문자로 변경 (날짜 -> 문자)
  • 파싱: 문자를 날짜로 변경 (문자 -> 날짜)

FormatterAndParseMain

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

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

        LocalDateTime now = LocalDateTime.of(2024, 4, 14, 11, 16, 59);
        System.out.println("now = " + now);
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy년 MM월 dd일");

        String formattedDateTime = now.format(formatter);
        System.out.println("formattedDateTime = " + formattedDateTime);
    }
}

실행결과:

now = 2024-04-14T11:16:59
formattedDateTime = 2024년 04월 14일

 

이렇게 원하는 형태의 문자열로 날짜를 문자로 변경할 수 있다. 이것을 포맷팅이라고 한다.

 근데 그 때 형태가 중요하다. MM월을 mm월로 사용할 수 없다. 정해진 규칙이 있기 때문에.

mm이라고 소문자로 표현하면 분을 의미하게 된다.

https://docs.oracle.com/javase/8/docs/api/java/time/format/DateTimeFormatter.html#patterns

 

DateTimeFormatter (Java Platform SE 8 )

Parses the text using this formatter, without resolving the result, intended for advanced use cases. Parsing is implemented as a two-phase operation. First, the text is parsed using the layout defined by the formatter, producing a Map of field to value, a

docs.oracle.com

이번엔 문자열을 날짜와 시간 타입으로 변경해보자.

FormatterAndParseMain 일부

DateTimeFormatter formatter2 = DateTimeFormatter.ofPattern("yyyy년 MM월 dd일 HH시 mm분 ss초");
String dateTimeString = "2020년 11월 03일 11시 20분 15초";
LocalDateTime parsedDateTime = LocalDateTime.parse(dateTimeString, formatter2);

System.out.println("dateTimeString = " + dateTimeString);
System.out.println("parsedDateTime = " + parsedDateTime);

실행결과:

dateTimeString = 2020년 11월 03일 11시 20분 15초
parsedDateTime = 2020-11-03T11:20:15

 

문자열로 된 날짜와 시간을 날짜와 시간 타입으로 변경했다. 

포맷팅과 파싱을 보면 알겠지만 패턴은 형식이 그대로 일치해야 한다! 

그니까, 파싱할 때 패턴을 "yyyy년 MM월 dd일 HH:mm:ss" 이렇게 설정했는데 문자열이 "yyyy년 MM월 dd일 HH시 mm분 ss초" 이렇게 모양새가 다르면 파싱 못한다.

 

 

 

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

중첩 클래스(정적 중첩 클래스, 내부 클래스)  (0) 2024.04.14
Stream API  (2) 2024.04.07
Enum  (0) 2024.04.03
Class 클래스  (0) 2024.04.03
Wrapper Class  (0) 2024.04.02
728x90
반응형
SMALL

자바는 열거형(Enum Type)을 제공하는데, 이 열거형을 제대로 이해하기 위해서 열거형이 생겨난 이유를 알아보자.

다음과 같은 요구사항이 들어왔다고 생각해보자.

 

등급별 할인율 적용

  • BASIC 등급 - 10%
  • GOLD 등급 - 20%
  • DIAMOND 등급 - 30%

이 요구사항을 처리하기 위해 다음과 같은 코드를 작성했다.

 public class DiscountService {
     public int discount(String grade, int price) {
        int discountPercent = 0;
        if (grade.equals("BASIC")) {
            discountPercent = 10;
        } else if (grade.equals("GOLD")) {
            discountPercent = 20;
        } else if (grade.equals("DIAMOND")) {
            discountPercent = 30;
        } else {
        System.out.println(grade + ": 할인X");
        }
        return price * discountPercent / 100;
    }
}

 

그래서 파라미터로 등급과 금액을 받아서 등급으로 들어온 문자열이 BASIC, GOLD, DIAMOND 중 어느것이냐에 따라 할인율을 적용한 후 할인 금액을 반환한다.

 

그리고 이 메서드를 사용하는 코드를 다음과 같이 작성했다.

public class StringGradeEx0_1 {
    public static void main(String[] args) {
        int price = 10000;
        
        DiscountService discountService = new DiscountService();
        int basic = discountService.discount("BASIC", price);
        int gold = discountService.discount("GOLD", price);
        int diamond = discountService.discount("DIAMOND", price);
		
        System.out.println("BASIC 등급의 할인 금액: " + basic);
        System.out.println("GOLD 등급의 할인 금액: " + gold);
        System.out.println("DIAMOND 등급의 할인 금액: " + diamond);
	}
}

 

실행결과:

BASIC 등급의 할인 금액: 1000 
GOLD 등급의 할인 금액: 2000
DIAMOND 등급의 할인 금액: 3000

 

문제없이 잘 동작하는 것 같지만, 이 코드는 아주 많은 잠재적 버그를 내포하고 있다.

우선, 등급을 넣는 파라미터에 잘못된 값이 들어가도 문자열이기 때문에 어떠한 컴파일 에러도 발생하지 않는다.

 

예를 들어, 다음 코드같이 작성해도 컴파일 단계에서 에러가 발생하지 않는다.

int vip = discountService.discount("VIP", price);
System.out.println("VIP 등급의 할인 금액: " + vip);

 

VIP라는 등급은 현재 없다. 그럼에도 불구하고 등급 파라미터에 넣을 수 있다.

또는 오타가 발생할 수도 있다. "GOLD"를 "GOLDD"라고 작성해도 아무런 문제가 발생하지 않는다.

또는 소문자로 입력을 하면 등급을 찾지 못한다. 

 

즉, 문자열을 사용하는 이 방식은 다음과 같은 문제가 있다.

  • 타입 안정성 부족: 어떠한 문자열이 들어가도 입력이 가능하게 되어있다.
  • 데이터 일관성: GOLD를 gold라고 작성하면 골드 등급임에도 할인 적용을 받지 못한다.

 

그래서 이러한 문제를 해결해보기 위해 다음과 같이 나름의 고민끝에 코드가 작성됐다. 등급을 클래스 내 상수로 만들어 두고 이 상수를 사용해보는 것이다.

public class StringGrade {
     public static final String BASIC = "BASIC";
     public static final String GOLD = "GOLD";
     public static final String DIAMOND = "DIAMOND";
}

 

 public class DiscountService {
     public int discount(String grade, int price) {
         int discountPercent = 0;
         if (grade.equals(StringGrade.BASIC)) {
             discountPercent = 10;
         } else if (grade.equals(StringGrade.GOLD)) {
             discountPercent = 20;
         } else if (grade.equals(StringGrade.DIAMOND)) {
             discountPercent = 30;
        } else {
        System.out.println(grade + ": 할인X");
        }
         return price * discountPercent / 100;
     }
}

 

public class StringGradeEx1_1 {
     public static void main(String[] args) {
     	int price = 10000;
     	
        DiscountService discountService = new DiscountService();
     	int basic = discountService.discount(StringGrade.BASIC, price);
     	int gold = discountService.discount(StringGrade.GOLD, price);
     	int diamond = discountService.discount(StringGrade.DIAMOND, price);
        
        System.out.println("BASIC 등급의 할인 금액: " + basic);
        System.out.println("GOLD 등급의 할인 금액: " + gold);
        System.out.println("DIAMOND 등급의 할인 금액: " + diamond);
	} 
}

 

이렇게 사용을 하니, 오타를 방지할 수 있다. 예를 들면 StringGrade.GOLDD라는 값을 넣으면 컴파일 에러가 발생할 것이다. 왜냐하면 StringGrade 클래스에는 GOLDD라는 필드는 없기 때문이다. 근데 이 또한 문제가 있다. 어떤 문제냐면 여전히 받는 파라미터는 문자열 타입이라는 것이다. 그래서 다음 코드가 여전히 문제가 된다는 것.

int vip = discountService.discount("VIP", price); 
System.out.println("VIP 등급의 할인 금액: " + vip);

 

이러한 문제를 해결하기 위해 Enum이 탄생했다. 

 

열거형 - Enum Type

enum은 enumeration의 줄임말로 열거라는 뜻을 가지고 있다. 어떤 항목들을 나열한다는 의미이다. 다음 코드를 보자.

public enum Grade {
     BASIC, GOLD, DIAMOND
}

 

열거형을 정의할 땐 class 대신 enum이라는 키워드를 사용한다.

이 코드를 클래스로 표현하면 다음 코드와 거의 같다.

public class Grade extends Enum {
 	public static final Grade BASIC = new Grade();
 	public static final Grade GOLD = new Grade();
 	public static final Grade DIAMOND = new Grade();
    
    //private 생성자 추가
 	private Grade() {}
}
  • 열거형(Enum)도 클래스다.
  • 열거형은 자동으로 java.lang.Enum을 상속받는다.
  • 열거형은 외부에서 임의로 생성할 수 없다. (private constructor)

실제로 Enum으로 선언한 BASIC, GOLD, DIAMOND는 각 참조값이 따로 존재하고 모두 Grade라는 클래스의 인스턴스다.

코드로 확인해보자.

public class Main {
    public static void main(String[] args) {
        System.out.println(Grade.BASIC.getClass());
        System.out.println(Grade.GOLD.getClass());
        System.out.println(Grade.DIAMOND.getClass());

        System.out.println(Grade.BASIC);
        System.out.println(Grade.GOLD);
        System.out.println(Grade.DIAMOND);
    }
}

실행결과:

class enums.Grade
class enums.Grade
class enums.Grade
BASIC
GOLD
DIAMOND

 

실행결과를 봤더니 전부 Grade라는 클래스 소속이고, 참조값을 확인해보기 위해 찍은 자기 자신이 문자 그대로가 나왔다. 이 이유는 열거형에서는 toString()을 알아서 본인이 찍히도록 오버라이딩되어 있기 때문이다. 그래서 이 각 열거값들의 참조값을 알아보기 위해 다음과 같이 코드를 수정했다.

public class Main {
    public static void main(String[] args) {
        System.out.println(Grade.BASIC.getClass());
        System.out.println(Grade.GOLD.getClass());
        System.out.println(Grade.DIAMOND.getClass());


        System.out.println(Integer.toHexString(System.identityHashCode(Grade.BASIC)));
        System.out.println(Integer.toHexString(System.identityHashCode(Grade.GOLD)));
        System.out.println(Integer.toHexString(System.identityHashCode(Grade.DIAMOND)));
    }
}

실행결과:

class enums.Grade
class enums.Grade
class enums.Grade
30f39991
452b3a41
4a574795

 

결과를 확인했더니 서로 다른 참조값을 가지는 것을 알 수 있다. 그래서 이 열거형으로 위에서 겪었던 문제를 해결해보자. 

등급을 받는 파라미터의 타입을 Grade라는 열거형으로 변경하여 해당 열거형에 존재하지 않는 값 자체를 받지 못하게 설정한다.

public class DiscountService {
    public int discount(Grade grade, int price) {
        int discountPercent = 0;

        if (grade == Grade.BASIC) {
            discountPercent = 10;
        } else if (grade == Grade.GOLD) {
            discountPercent = 20;
        } else if (grade == Grade.DIAMOND) {
            discountPercent = 30;
        } else {
            System.out.println("할인X");
        }
        return price * discountPercent / 100;
    }
}

 

사용하는 코드에서는 이 Grade라는 타입을 받는 파라미터를 입력하는 부분에서 Grade에 속한 값 외에 어떤 값도 넣지 못한다. 

Grade라는 열거형 클래스의 인스턴스도 만들지 못한다. 생성자가 외부에서 접근하지 못하도록 private으로 선언되어 있기 때문이다.

public class Main {
    public static void main(String[] args) {
        int price = 10000;
        
        DiscountService discountService = new DiscountService();
        int basic = discountService.discount(Grade.BASIC, price);
        int gold = discountService.discount(Grade.GOLD, price);
        int diamond = discountService.discount(Grade.DIAMOND, price);
        
        System.out.println("BASIC 등급의 할인 금액: " + basic);
        System.out.println("GOLD 등급의 할인 금액: " + gold); 
        System.out.println("DIAMOND 등급의 할인 금액: " + diamond);
    }
}

 

열거형으로 인한 다음 두 가지 장점이 생겼다

  • 타입 안정성 향상: 열거형으로 사전에 정의된 상수들만으로 구성되어 유효하지 않은 값이 입력될 가능성은 없다. 이럴 경우 컴파일 에러가 발생한다.
  • 간결성 및 일관성: 열거형을 사용하면 코드가 더 간결해지고 명확해진다.
  • 확장성: 새로운 회원등급을 추가하고 싶을 때 열거형에 상수하나만 추가해주면 된다.

위 코드를 조금 더 간단하게 바꿀 수도 있다. static import를 하면 조금 더 간결해진다. 실무에서 많이 사용하는 방식이다. 

import static enums.Grade.*;

public class Main {
    public static void main(String[] args) {
        int price = 10000;

        DiscountService discountService = new DiscountService();
        int basic = discountService.discount(BASIC, price);
        int gold = discountService.discount(GOLD, price);
        int diamond = discountService.discount(DIAMOND, price);

        System.out.println("BASIC 등급의 할인 금액: " + basic);
        System.out.println("GOLD 등급의 할인 금액: " + gold);
        System.out.println("DIAMOND 등급의 할인 금액: " + diamond);
    }
}

 

열거형의 주요 메서드

열거형도 클래스다. 클래스라서 메서드가 있다. 대표적인 메서드들은 다음과 같다.

  • values(): 모든 ENUM 상수를 포함하는 배열을 반환한다.
  • valueOf(String name): 주어진 이름과 일치하는 ENUM 상수를 반환한다.
  • name(): ENUM 상수의 이름을 문자열로 반환한다.
  • ordinal(): ENUM 상수의 선언 순서(0부터 시작)를 반환한다. 
  • toString(): ENUM 상수의 이름을 문자열로 반환한다. name() 메서드와 유사하지만 toString()은 직접 오버라이딩할 수 있다.

근데, ordinal()은 가급적 사용하면 안된다. 왜냐하면 ENUM을 만들고 추후에 추가적으로 새로운 상수를 중간에 추가하고 싶어질 때가 있을 수 있다. 다음과 같이 말이다.

public enum Grade {
    BASIC, SILVER, GOLD, DIAMOND
}

그럼 기존에는 BASIC(0), GOLD(1), DIAMOND(2)였던게 BASIC(0), SILVER(1), GOLD(2), DIAMOND(3)이 되어버린다. 기존에 ordinal()을 사용해서 코드를 작성했다면 끔찍한 일이 벌어질 것이다.

 

 

열거형 - 리팩토링

기존에 서비스 코드를 다시 한번 보자.

public class DiscountService {
    public int discount(Grade grade, int price) {
        int discountPercent = 0;

        if (grade == Grade.BASIC) {
            discountPercent = 10;
        } else if (grade == Grade.GOLD) {
            discountPercent = 20;
        } else if (grade == Grade.DIAMOND) {
            discountPercent = 30;
        } else {
            System.out.println("할인X");
        }
        return price * discountPercent / 100;
    }
}

이 코드를 보면 등급에 따라 할인율을 적용하고 있다. 그럼 결국 할인율이란 것은 등급에 극도로 의존하고 있다. 그럼 할인율을 계산하는 코드는 사실 ENUM 클래스 내부에 있어도 될 것 같다. 그게 바로 객체 지향이니까. 그리고 등급에 따라 할인율이 고정적으로 정해져 있기 때문에 아예 등급을 선언할 때부터 해당 등급의 할인율을 필드로 가지고 있으면 더 좋을 것 같다. 다시 한번 말하지만 ENUM도 클래스다.

 

그래서 이 코드를

public enum Grade {
    BASIC, GOLD, DIAMOND
}

 

다음과 같이 변경했다. (참고로 상수외에 다른게 있으면 저렇게 상수가 끝나는 지점에 세미콜론(;)이 있어야 한다.)

public enum Grade {
    BASIC(10),
    GOLD(20),
    DIAMOND(30);

    private final int discountPercent;

    Grade(int discountPercent) {
        this.discountPercent = discountPercent;
    }

    public int getDiscountPercent() {
        return discountPercent;
    }

    public int discount(int price) {
        return price * discountPercent / 100;
    }
}

 

각 등급별 discountPercent 필드에 10, 20, 30을 지정해준다. 그리고 이 클래스 안에서 할인가격을 구하는 기능(메서드)을 만들면 된다.

그럼 DiscountService가 이렇게 간단해진다.

public class DiscountService {
    public int discount(Grade grade, int price) {
        return grade.discount(price);
    }
}

 

그럼 결국 DiscountService가 하는것은 위임밖에 없다. 이 말은 이 DiscountService 자체가 없어도 된다는 뜻이다. 날려버리고 메인 메서드를 이렇게 만들어보자. 너무나 깔끔하다.

public class Main {
    public static void main(String[] args) {
        int price = 10000;

        int basic = BASIC.discount(price);
        int gold = GOLD.discount(price);
        int diamond = DIAMOND.discount(price);

        System.out.println("BASIC 등급의 할인 금액: " + basic);
        System.out.println("GOLD 등급의 할인 금액: " + gold);
        System.out.println("DIAMOND 등급의 할인 금액: " + diamond);
    }
}

 

 

정리

열거형을 사용해서 다음과 같은 문제를 해결할 수 있다.

  • 타입 안정성 문제
  • 데이터의 일관성

열거형의 특징은 다음과 같다.

  • 열거형도 클래스이다.
  • 클래스란 것은 필드기능이 존재할 수 있다는 뜻이다.
  • 외부에서 생성하지 못한다 (생성자가 private)

 

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

Stream API  (2) 2024.04.07
날짜와 시간  (0) 2024.04.04
Class 클래스  (0) 2024.04.03
Wrapper Class  (0) 2024.04.02
Method Chaining  (0) 2024.04.02
728x90
반응형
SMALL

자바에서는 Class 클래스가 있고 이 클래스는 특정 클래스의 정보(메타데이터)를 다루는데 사용된다. Class 클래스를 통해 개발자는 실행중인 자바 애플리케이션 내에서 필요한 클래스의 속성과 메서드에 대한 정보를 조회하고 조작할 수 있다.

 

Class 클래스의 주요 기능은 다음과 같다.

  • 타입 정보 얻기: 클래스의 이름, 슈퍼클래스, 인터페이스, 접근 제한자 등과 같은 정보를 조회할 수 있다.
  • 리플렉션: 클래스에 정의된 메서드, 필드, 생성자 등을 조회하고 이들을 통해 객체 인스턴스를 생성하거나 메서드를 호출하는 등의 작업을 할 수 있다.
  • 동적 로딩과 생성: Class.forName() 메서드를 사용하여 클래스를 동적으로 로드하고, newInstance() 메서드를 통해 새로운 인스턴스를 생성할 수 있다.
  • 애노테이션 처리: 클래스에 적용된 애노테이션을 조회하고 처리하는 기능을 제공한다.

 

특정 클래스 객체를 가져오는 방법은 크게 3가지가 있다.

Class<String> stringClass = String.class;
Class<? extends String> stringClass = new String().getClass();
Class<?> stringClass = Class.forName("java.lang.String");

 

이런 방법들 중 하나를 택해서 특정 클래스의 클래스 객체를 가져오면 다음과 같은 작업들을 해 볼 수 있다.

// 모든 필드 조회
Field[] declaredFields = stringClass.getDeclaredFields();
for (Field declaredField : declaredFields) {
    System.out.println("declaredField = " + declaredField);
}
// 모든 메서드 조회
Method[] declaredMethods = stringClass.getDeclaredMethods();
for (Method declaredMethod : declaredMethods) {
    System.out.println("declaredMethod = " + declaredMethod);
}
// 부모 클래스 조회
Class<? super String> superclass = stringClass.getSuperclass();
System.out.println("superclass = " + superclass);
// 모든 인터페이스 조회
Class<?>[] interfaces = stringClass.getInterfaces();
for (Class<?> anInterface : interfaces) {
    System.out.println("anInterface = " + anInterface);
}

 

실행해보면 다음과 같이 메타데이터들이 나온다.

declaredField = private final byte[] java.lang.String.value
declaredField = private final byte java.lang.String.coder
declaredField = private int java.lang.String.hash
declaredField = private boolean java.lang.String.hashIsZero
declaredField = private static final long java.lang.String.serialVersionUID
declaredField = static final boolean java.lang.String.COMPACT_STRINGS
declaredField = private static final java.io.ObjectStreamField[] java.lang.String.serialPersistentFields
declaredField = private static final char java.lang.String.REPL
declaredField = public static final java.util.Comparator java.lang.String.CASE_INSENSITIVE_ORDER
declaredField = static final byte java.lang.String.LATIN1
declaredField = static final byte java.lang.String.UTF16
declaredMethod = byte[] java.lang.String.value()
declaredMethod = public boolean java.lang.String.equals(java.lang.Object)
declaredMethod = public int java.lang.String.length()
declaredMethod = public java.lang.String java.lang.String.toString()
declaredMethod = static void java.lang.String.checkIndex(int,int)
declaredMethod = public int java.lang.String.hashCode()
declaredMethod = public void java.lang.String.getChars(int,int,char[],int)
declaredMethod = public int java.lang.String.compareTo(java.lang.Object)
declaredMethod = public int java.lang.String.compareTo(java.lang.String)
declaredMethod = public int java.lang.String.indexOf(java.lang.String,int,int)
declaredMethod = static int java.lang.String.indexOf(byte[],byte,int,java.lang.String,int)
declaredMethod = public int java.lang.String.indexOf(java.lang.String,int)
...
declaredMethod = public java.lang.Object java.lang.String.transform(java.util.function.Function)
declaredMethod = public java.lang.String java.lang.String.formatted(java.lang.Object[])
declaredMethod = public static java.lang.String java.lang.String.copyValueOf(char[],int,int)
declaredMethod = public static java.lang.String java.lang.String.copyValueOf(char[])
declaredMethod = public native java.lang.String java.lang.String.intern()
declaredMethod = static void java.lang.String.checkOffset(int,int)
declaredMethod = static java.lang.String java.lang.String.valueOfCodePoint(int)
declaredMethod = public java.util.Optional java.lang.String.describeConstable()
declaredMethod = private static java.lang.String java.lang.String.lambda$stripIndent$3(int,java.lang.String)
declaredMethod = private static java.lang.String java.lang.String.lambda$indent$2(int,java.lang.String)
declaredMethod = private static java.lang.String java.lang.String.lambda$indent$1(java.lang.String)
declaredMethod = private static java.lang.String java.lang.String.lambda$indent$0(java.lang.String,java.lang.String)
superclass = class java.lang.Object
anInterface = interface java.io.Serializable
anInterface = interface java.lang.Comparable
anInterface = interface java.lang.CharSequence
anInterface = interface java.lang.constant.Constable
anInterface = interface java.lang.constant.ConstantDesc

 

이번엔 직접 만든 클래스로 Class 클래스를 사용해보자.

Hello

public class Hello {
    private final String value;

    public Hello(String value) {
        this.value = value;
    }

    public void hello() {
        System.out.println(value);
    }
}

 

Main

public class Main {
    public static void main(String[] args) throws ClassNotFoundException {

        Class<Hello> helloClass = Hello.class;

        Field[] declaredFields = helloClass.getDeclaredFields();
        for (Field declaredField : declaredFields) {
            System.out.println("declaredField = " + declaredField);
        }
    }
}

 

실행결과:

declaredField = private final java.lang.String clazz.Hello.value

이렇게 기존 방법대로 하는것도 있고, 다음과 같이 문자열로 패키지명과 클래스이름을 통해 클래스를 가져와서 newInstance()로 새 인스턴스를 만드는 방법도 있다.

public class Main {
    public static void main(String[] args) throws Exception {

        Class<?> aClass = Class.forName("clazz.Hello");
        Hello o = (Hello) aClass.getDeclaredConstructor(String.class).newInstance("Hi");

        o.hello();
    }
}

 

실행결과:

Hi

 

newInstance()를 사용하면 반환 타입은 Object 타입이다. 그래서 내가 원하는 클래스로 다운캐스팅 해줘야 한다.

getDeclaredConstructor() 메서드를 사용해서 생성자 메서드를 가져오는데 Hello 클래스는 파라미터가 있는 생성자밖에 없기 때문에 파라미터로 파라미터 타입이 String인 생성자를 가져와서 newInstance() 메서드를 실행한다. 

 

그리고 이렇게 Class 클래스를 이용해서 클래스의 메타 정보를 기반으로 클래스에 정의된 메서드, 필드, 생성자 등을 조회하고, 이들을 통해 객체 인스턴스를 생성하거나 메서드를 호추랗는 작업을 할 수 있는데 이를 리플렉션이라고 한다.

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

날짜와 시간  (0) 2024.04.04
Enum  (0) 2024.04.03
Wrapper Class  (0) 2024.04.02
Method Chaining  (0) 2024.04.02
String 클래스  (0) 2024.04.01
728x90
반응형
SMALL

Wrapper Class는 특정 기본형을 감싸서 객체로 만들어 놓은 것을 말한다. Integer, Boolean, Long 등이 있다.

그럼 이 Wrapper 클래스는 왜 필요한걸까?

 

기본형의 한계

1. 객체가 아니다

우선 기본형은 객체가 아니다. 그 말은 객체가 가지는 장점을 활용할 수 없다는 얘기다. 가장 대표적인 것으로 본인의 속성을 사용하는 기능을 가질 수 없고 외부에서 만들어 사용해야 한다. 다음 코드를 보자.

public class Main {
    public static void main(String[] args) {
        int intValue = 10;

        int i1 = compareTo(intValue, 5);
        int i2 = compareTo(intValue, 10);
        int i3 = compareTo(intValue, 20);

        System.out.println("i1 = " + i1);
        System.out.println("i2 = " + i2);
        System.out.println("i3 = " + i3);
    }

    public static int compareTo(int value, int target) {
        if (value < target) {
            return -1;
        } else if (value > target) {
            return 1;
        } else {
            return 0;
        }
    }
}

 

기본형인 int 타입의 변수에 어떤 값이 들어있을 때 이 값과 다른 값을 비교해서 더 큰지 작은지,두개가 같은지 비교하는 메서드다.

본인의 값을 가지고 비교하고 있음에도 객체가 아니기 때문에 내부적인 메서드를 만들지 못하고 어디선가 만들어 놓은 외부 메서드를 가져다가 사용해야 한다. Wrapper Class를 사용하면 다음과 같이 깔끔하게 변경할 수 있다.

public class Main {
    public static void main(String[] args) {
        Integer intValue = 10;

        int i1 = intValue.compareTo(10);
        int i2 = intValue.compareTo(20);
        int i3 = intValue.compareTo(5);

        System.out.println("i1 = " + i1);
        System.out.println("i2 = " + i2);
        System.out.println("i3 = " + i3);
    }
}

자신의 속성을 가지고 비교하는 메서드를 객체 지향이라는 언어에 맞게 내부적으로 메서드를 만들어서 사용하는 것이다. 캡슐화가 잘 지켜진 코드라고도 볼 수 있다.

 

 

2. null을 사용할 수 없다.

가끔은 값이 비어있음을 표현해야 할 때가 있다. 그러나 기본형은 그럴 수 없다. 반드시 값이 들어있어야 한다.

그래서 이 경우에도 Wrapper 클래스의 도움을 받을 수 있다. 굳이 이 말 외에 예제 코드가 필요하진 않을듯하여 예제 코드는 생략하겠다.

 

 

자바가 제공하는 래퍼 클래스 특징

1. Integer, Long, Byte, Double, Boolean 등 자바가 제공하는 Wrapper 클래스는 다 불변객체이다. 

2. 클래스니까 equals()로 비교해야한다.

 

그리고, 클래스라고 했기 때문에 new로 생성할 수 있는데 new로 생성하면 안된다. 왜냐하면 자바9부터 Deprecated 됐다.

 

그 이유는 valueOf() 메서드를 사용하면 효율적으로 더 좋기 때문이다. 왜 그러냐면, 예를 들어 Integer인 경우 "-128 ~ 127"까지는 Integer 클래스를 미리 생성해준다. 그래서 새로 인스턴스를 new로 생성하기보다 더 메모리 사용도 효율적으로 할 수 있고 그에 따라 속도도 개선된다. 마치 String의 문자열 풀과 같은 것. "어? 그럼 그 범위 밖은요?" 그 범위 밖은 자바가 알아서 new로 새롭게 인스턴스를 만들어준다.

 

그래서 사용할 땐 아래처럼 그냥 valueOf()를 사용하면 된다.

Integer boxedValue = Integer.valueOf(10);

 

래퍼 클래스 생성 - 박싱(Boxing)

위 코드처럼 기본형을 래퍼 클래스로 변경하는 것을 마치 박스에 물건을 넣은 것 같다고 해서 박싱(Boxing)이라 한다.

근데 이거를 아주 간단하게 그냥 이렇게 쓰면 된다. 이것을 오토박싱이라고 한다.

(근데 사실 이런 명칭자체가 그렇게 크게 중요하진 않다고 생각한다.)

Integer intValue = 10;

 

intValue() - 언박싱(Unboxing)

Integer intValue = 10;
int i = intValue.intValue();

박스에 담은 기본형(래퍼 클래스)을 다시 기본형으로 돌려버리는 것을 언박싱이라고 한다.

근데 이거를 아주 간단하게 그냥 이렇게 쓰면 된다. 그리고 이것을 오토언박싱이라 한다.

Integer intValue = 10;
int unboxedValue = intValue;

 

 

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

Enum  (0) 2024.04.03
Class 클래스  (0) 2024.04.03
Method Chaining  (0) 2024.04.02
String 클래스  (0) 2024.04.01
불변객체 (Immutable Object)  (2) 2024.04.01

+ Recent posts