자바(15) - 입출력

|

자바 입출력과 스트림

입출력은 프로그램의 가장 기본 기능이지만, 외부 저장 장치나 네트워크와 연동해야 하기 때문에 장치에 따라 다르게 구현해야 한다. 자바는 장치에 따라 독립적이고 효율적인 입출력 기능을 제공한다. 자바에서 모든 입출력은 스트림(stream)을 통해 이루어진다. 스트림이란 네트워크에서 유래된 용어이다. 자료 흐름이 물의 흐름과 같다는 의미에서 사용한다. 입출력 장치는 매우 다양하기 때문에 장치에 따라 입출력 부분을 일일이 다르게 구현을 하면 프로그램 호환성이 떨어질 수 밖에 없다. 이런 문제를 해결하기 위해 자바는 입출력 장치와 무관하고 일관성 있게 프로그램을 구현할 수 있도록 일종의 가상 통로인 스트림을 제공하는 것이다. 자료를 읽어 들이려는 소스(source)와 자료를 쓰려는 대상(target)에 따라 각각 다른 스트림 클래스를 제공한다.

자바에서 입출력 기능을 사용하는 곳은 파일 디스크, 키보드, 모니터, 메모리 입출력, 네트워크 등이 있다. 이런 곳에서 일어나는 모든 입출력 기능을 스트림 클래스로 제공한다. 따라서 자바에서 자료를 입출력하려면 여러 스트림 클래스에 대해 알아야 하지만, 구현 방식이 서로 비슷하니 크게 걱정할 필요 없다. 스크림은 크게 세 가지 기준에 따라 분류될 수 있다.

13장에서 배운 스트림과 다른 용도이니 헷갈리지 말자.

  1. 입력 스트림과 출력 스트림

어떤 대상으로부터 자료를 읽어 들일 때 사용하는 스트림이 입력 스트림이다. 예를 들어 입력 스트림은 어떤 동영상을 재생하기 위해 동영상 파일에서 자료를 읽을 때 사용한다. 편집 화면에 사용자가 쓴 글을 파일에 저장할 때는 출력 스트림을 사용한다. 스트림은 단방향으로 자료가 이동하기 때문에 입력과 출력을 동시에 할 수 없다. 입력 자료의 이동이 출력 자료의 이동과 한 스트림에서 동시에 일어날 수 없기 때문이다. 일방 통행 외길에 차가 양방향으로 다닐 수 없는 것에 비유할 수 있다. 따라서 어떤 스트림이 있다고 하면 그 스트림은 입력 스트림이거나 출력 스트림이다. 스트림의 이름을 보면 입력용인지 출력용인지 알 수 있다. InputStream이나 Reader로 끝나는 이름의 클래스는 입력 스트림이다. 반면 OutputStream이나 Wrtier로 끝나는 이름의 클래스는 출력 스트림이다.

  • 입력 스트림: FileInputStream, FileReader, BufferedInputStream, BufferedReader 등
  • 출력 스트림: FileOutputStream, FileWriter, BufferedOutputStream, BufferedWriter 등
  1. 바이트 단위 스트림과 문자 단위 스트림

원래 자바의 스트림은 바이트(byte) 단위로 자료의 입출력이 이루어진다. 그러므로 그림, 동영상, 음악 파일 등 대부분 파일은 바이트 단위로 읽거나 쓰면 된다. 그런데 자바에서의 문자를 나타내는 char형은 2바이트이기 때문에 1바이트만 읽으면 한글 같은 문자는 깨진다. 따라서 입출력 중 가장 많이 사용하는 자료인 문자를 위해 문자 스트림을 별도로 제공한다. 즉 읽어 들이는 자료형에 따라 바이트용과 문자용 스트림이 있다. 스트림 클래스의 이름이 Stream으로 끝나는 경우는 바이트 단위를 처리하는 스트림이다. Reader나 Writer로 끝나는 이름은 문자를 위한 스트림 클래스이다.

  • 바이트 스트림: FileInputStream, FileOutputStream, BufferedInputStream, BufferedOutPutStream 등
  • 문자 스트림: FileReader, FileWriter, BufferedReader, BufferedWriter 등
  1. 기반 스트림과 보조 스트림

어떤 스트림이 자료를 직접 읽거나 쓰는 기능을 제공하는 스트림인가, 아니면 자료를 직접 읽거나 쓰는 기능은 없이 다른 스트림에 부가 기능을 제공하는가에 따라 기반 스트림과 보조 스트림으로 구분할 수 있다. 기반 스트림은 읽어 들일 곳(소스)이나 써야 할 곳(대상)에서 직접 읽고 쓸 수 있으며 입출력 대상에 직접 연결되어 생성되는 스트림이다. 반면에 보조 스트림은 직접 읽고 쓰는 기능은 없다. 따라서 항상 다른 스트림을 포함하여 생성된다. 즉 기반 스트림에 보조 스트림을 더하여 기능을 추가한다.

기반 스트림인가 보조 스트림인가 여부는 이름으로 판단하기 어려울 수 있다. 대부분 기반 스트림이 소스나 대상의 이름을 가지고 있지만, 보조 스트림 중에도 이름만 봐서 알 수없는 경우도 있기에 많이 사용하는 클래스 위주로 기억해 두자.

  • 기반 스트림: FileInputStream, FileOutputStream, FileReader, FileWriter 등
  • 보조 스트림: InputStreamReader, OutputStreamWriter, BufferedInputStream, BufferedOutputStream 등

표준 입출력

자바에서는 화면에 출력하고 입력받는 표준 입출력 클래스를 미리 정의해 두었다. 이 클래스는 프로그램이 시작될 때 생성되므로 따로 만들 필요가 없다. 우리가 지금까지 화면 출력을 위해 사용한 System.out은 표준 출력을 위한 객체이다. 표준 입출력은 콘솔 화면에 입출력된다고 해서 콘솔 입출력이라고도 한다. 표준 입출력을 위한 System 클래스는 다음과 같이 세 개의 변수를 가지고 있다.

자료형 변수 이름 설명
static PrintStream out 표준 출력 스트림
static InputStream in 표준 입력 스트림
static OutputStream err 표준 오류 출력 스트림

System.out은 표준 출력용, System.in은 표준 입력용 스트림이다. 빨간색으로 오류 메시지를 출력할 때는 System.err를 사용한다. out, in, err 모두 정적 변수이다. 지금까지 우리가 System 클래스를 생성하지 않고도 System.out을 사용할 수 있었던 이유는 out 변수가 System 클래스의 정적 변수이기 때문이다.

System.in으로 화면에서 문자 입력 받기

입출력에 관련한 코드를 구현하면 예외 처리를 해주어야 한다.

import java.io.IOException;

public class SystemInTest1 {
    public static void main(String[] args) throws IOException {
        System.out.println("알파벳 입력: ");

        int i;
        try {
            i = System.in.read(); // read() 메서드로 한 바이트 읽음
            System.out.println(i);
            System.out.println((char)i); // 문자로 변환하여 출력
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

A라고 알파벳을 쓰고 엔터를 누르면 입력한 값이 변수 i에 들어간다. i는 4바이트지만 System.in은 바이트 단위로 읽어 들이는 InputStream이므로 1바이트만 읽는다. 읽어 들인 1바이트를 출력하면 문자에 대한 숫자 값, 즉 아스키 값을 출력한다.

다음은 알파벳 여러개 쓰고 엔터 누르는 예제이다.

import java.io.IOException;

public class SystemInTest2 {
    public static void main(String[] args) throws IOException {
        System.out.println("알파벳 여러 개 입력: ");

        int i;
        try {
            while((i = System.in.read()) != -1) {
                System.out.println((char)i);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

while 문에서 read() 메서드를 이용해 한 바이트씩 읽는다. \n값이 입력될 때까지 반복 수행한다.

Scanner

Scanner 클래스는 java.util 패키지에 있는 입력 클래스이다. Scanner 클래스는 문자뿐 아니라 정수, 실수 등 다른 자료형도 읽을 수 있다. 또한 콘솔 화면뿐 아니라 파일이나 문자열을 생성자의 매개변수로 받아 자료를 읽어 올 수 있다.

생성자 설명
Scanner(File source) 파일을 매개변수로 받아 Scanner를 생성
Scanner(InputStream source) 바이트 스트림을 매개변수로 받아 Scanner를 생성
Scanner(String source) String을 매개변수로 받아 Scanner를 생성

우리가 이전까지 사용한 Scanner scanner = new Scanner(System.in)이것이 표준 입력으로부터 자료를 읽어 들이는 기능이 되는 것이다.

Console 클래스

System.in을 사용하지 않고 간단히 콘솔 내용을 읽을 수 있는 Console 클래스도 있다. 직접 콘솔 창에서 자료를 입력받을 때 이 클래스를 사용한다.

메서드 설명
String readLine() 문자열을 읽는다.
char[] readPassword() 사용자에게 문자열을 보여 주지 않고 읽는다.
Reader reader() Reader 클래스를 반환한다.
PrintWriter writer() PrintWriter 클래스를 반환한다.
import java.io.Console;

public class ConsoleTest {
    public static void main(String[] args) {
        Console console = System.console();

        System.out.print("이름:");
        String name = console.readLine();
        System.out.print("직업:");
        String name = console.readLine();
        System.out.print("비밀번호:");
        char[] pass = console.readPassword();
        String strPass = new String(pass);
    }
}

Console 클래스는 연동되지 않는 경우도 있어서 Scanner를 더 많이 사용한다.

바이트 단위 스트림

InputStream

바이트 단위로 읽는 스트림 중 최상위 스트림이다. InputStream은 추상 메서드를 포함한 추상 클래스로서 하위 스트림 클래스가 상속받아 각 클래스 역할에 맞게 추상 메서드 기능을 구현한다. 주로 사용하는 하위 클래스는 다음과 같다.

  • FileInputStream: 파일에서 바이트 단위로 자료를 읽는다.
  • ByteArrayInputStream: Byte 배열 메모리에서 바이트 단위로 자료를 읽는다.
  • FilterInputStream: 기반 스트림에에서 자료를 읽을 때 추가 기능을 제공하는 보조 스트림의 상위 클래스

InputStream은 바이트 자료를 읽기 위해 다음 메서드를 제공한다.

  • int read(): 입력 스트림으로부터 한 바이트의 자료를 읽는다. 읽은 자료의 바이트 수를 반환한다.
  • int read(byte b[]): 입력 스트림으로부터 b[] 크기의 자료를 b[]에 읽는다. 읽은 자료의 바이트 수를 반환한다.
  • int read(byte b[], int off, int len): 입력 스트림으로부터 b[] 크기의 자료를 b[]의 off 변수 위치부터 저장하여 len 만큼 읽는다. 읽은 자료의 바이트 수를 반환한다.
  • void close(): 입력 스트림과 연결된 대상 리소스를 닫는다. (FileInputStream인 경우 파일 닫음)

read() 메서드의 반환형은 int이다. 한 바이트를 읽어서 int에 저장한다. 한 바이트만 읽는 데 반환형이 int인 이유는 더 이상 읽어 들일 자료가 없는 경우에 정수 -1이 반환되기 때문이다. 파일에서 자료를 읽는 경우 파일의 끝에 도달하면 -1이 반환된다.

FileInputStream

FileInputStream은 파일에서 바이트 단위로 자료를 읽어 들일 때 사용하는 스트림 클래스이다. 스트림을 사용하기 위해서는 먼저 스트림 클래스를 생성해야 한다. 생성자는 아래와 같다.

  • FileInputStream(String name): 파일 이름 name(경로 포함)을 매개변수로 받아 입력 스트림을 생성한다.
  • FileInputStream(File f): File 클래스 정보를 매개변수로 받아 입력 스트림을 생성한다.
// input.txt 만들고 ABC 입력하고 테스트

import java.io.FileInputStream;
import java.io.IOException;

public class FileInputStreamTest1 {
    public static void main(String[] args) {
        FileInputStream fis = null;

        try {
            fis = new FileInputStream("input.txt"); // input.txt 파일 입력 스트림 생성
            System.out.println(fis.read());
            System.out.println(fis.read());
            System.out.println(fis.read());
        } catch (IOException e) {
            System.out.println(e);
        } finally {
            try {
                fis.close();
            } catch (IOException e) {
                System.out.println(e);
            } catch (NullPointerException e) {
                System.out.println(e);
            }
        }
    }
}

파일 끝까지 읽으려면 반복문을 써서 읽어 보자.

import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;

public class FileInputStreamTest2 {
    public static void main(String[] args) {
        try(FileInputStream fis = new FileInputStream("input.txt")) {
            int i;
            while((i = fis.read()) != -1) {
                System.out.println((char)i);
            }
            System.out.println("end");
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

read() 메서드로 파일을 읽는 경우 파일의 끝에 도달하면 -1을 반환한다. 그것을 이용해 끝까지 읽을 수 있다.

int read(byte[] b) 메서드로 읽기

자료를 read() 메서드로 한 바이트씩 읽는 것보다 배열을 사용하여 한꺼번에 많이 읽으면 처리 속도가 훨씬 빠르다.

// input2.txt는 A부터 Z까지 입력했다.
import java.io.FileInputStream;
import java.io.IOException;

public class FileInputStreamTest3 {
    public static void main(String[] args) {
        try(FileInputStream fis = new FileInputStream("input2.txt")) {
            byte[] bs = new byte[10];
            int i;
            while((i = fis.read(bs)) != -1) {
                for(byte b: bs) {
                    System.out.print((char)b);
                }
                System.out.print(": " + i + "바이트 읽음");
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        System.out.println("end");
    }
}

ABCDEFGHIJ: 10 바이트 읽음 KLMNOPQRST: 10 바이트 읽음 UVWXYZQRST: 6 바이트 읽음

위처럼 하면 최대 10바이트 씩 읽고 처리해준다. 그런데 마지막에 QRST가 남아있다. 기존에 남아 있던 자료가 출력된 것이다. 그러면 어떻게 해야할까 for문을 수정해주면 된다.

for(int k=0; k<i; k++) {
    System.out.print((char)bs[k])
}

OutputStream

바이트 단위로 쓰는 스트림 중 최상위 스트림이다. 자료의 출력 대상에 따라 다른 스트림을 제공한다.

  • FileOutputStream: 바이트 단위로 파일에 자료를 쓴다.
  • ByteArrayOutputStream: Byte 배열에 바이트 단위로 자료를 쓴다.
  • FilterOutputStream: 기반 스트림에서 자료를 쓸 때 추가 기능을 제공하는 보조 스트림의 상위 클래스

제공하는 메서드는 다음과 같다.

  • void write(int b): 한 바이트를 출력한다.
  • void write(byte[] b): b[] 배열에 있는 자료를 출력한다.
  • void write(byte b[], int off, int len): b[] 배열에 있는 자료의 off 위치부터 len 개수만큼 자료를 출력한다.
  • void flush(): 출력을 위해 잠시 자료가 머무르는 출력 버퍼를 강제로 비워 자료를 출력한다.
  • void close(): 출력 스트림과 연결된 대상 리소스를 닫는다. 출력 버퍼가 비워진다.

FileOutputStream

파일에 바이트 단위 자료를 출력하기 위해 사용하는 스트림이다. 생성자는 다음과 같다.

  • FileOutputStream(String name): 파일 이름 name(경로 포함)을 매개 변수로 받아 출력 스트림을 생성
  • FileOutputStream(String name, boolean append): append 값이 true이면 파일 스트림을 닫고 다시 생성할 때 파일의 끝에 이어서 쓴다. 디폴트 값은 false 이다.
  • FileOutputStream(File f): File 클래스 정보를 매개변수로 받아 출력 스트림을 생성
  • FileOutputStream(File f, boolean append): append 값이 true이면 파일 스트림을 닫고 다시 생성할 때 파일의 끝에 이어서 쓴다. 디폴트 값은 false 이다.

다음은 write() 메서드 사용하는 예제이다.

import java.io.FileOutputStream;
import java.io.IOException;

public class FileOutputStreamTest1 {
    public static void main(String[] args) {
        try(FileOutputStream fos = new FileOutputStream("output.txt")) {
            fos.write(65);  // FieOutStream은 파일에 숫자를 쓰면 해당하는 아스키 코드 값으로 반환됨.
            fos.write(66);
            fos.write(67);
        } catch(IOException e) {
            e.printStackTrace();
        }
    }
}

생성자에 매개변수로 true를 추가하고 다시 실행하면 이어서 써진다.

문자 단위 스트림

Reader클래스는 문자 단위로 읽는 스트림 중 최상위 스트림으로 다음 하위 클래스를 주로 사용한다.

  • FileReader: 파일에서 문자 단위로 읽는 스트림 클래스이다.
  • InputStreamReader: 바이트 단위로 읽은 자료를 문자로 변환해 주는 보조 스트림 클래스이다.
  • BufferedReader: 문자로 읽을 때 배열을 제공하여 한꺼번에 읽을 수 있는 기능을 제공해주는 보조 스트림이다.

메서드는 다음과 같다.

  • int read(): 파일로부터 한 문자를 읽는다. 읽은 값을 반환한다.
  • int read(char[] buf): 파일로부터 buf 배열에 문자를 읽는다.
  • int read(char[] buf, int off, int len): 파일로부터 buf 배열의 off 위치에서부터 len 개수만큼 문자를 읽는다.
  • void close(): 스트림과 연결된 파일 리소스를 다는다.

FileReader

FileReader의 생성자는 아래와 같고 FileInputStream과 마찬가지로 읽을 파일이 안보이면 FileNotFoundException이 발생한다.

  • FileReader(String name): 파일 이름 name(경로 포함)을 매개변수로 받아 입력 스트림을 생성한다.
  • FileReader(File f): File 클래스 정보를 매개변수로 받아 입력 스트림을 생성한다.

FileReader대신 FileInputStream을 쓰게 되면 바이트 단위로 갖고오기 때문에 한글이 깨질 수도 있다. 따라서 FileReader로 읽는 것이 좋다.

Writer

Writer는 문자 단위로 출력하는 스트림 중 최상위 스트림으로 다음 하위 클래스를 주로 사용한다.

  • FileWriter: 파일에 문자 단위로 출력하는 스트림 클래스
  • OutputStreamWriter: 파일에 바이트 단위로 출력한 자료를 문자로 변환해 주는 보조 스트림
  • BufferedWriter: 문자로 쓸 때 배열을 제공하여 한꺼번에 쓸 수 있는 기능을 제공해 주는 보조 스트림

메서드는 다음과 같이 제공한다.

  • void write(int c): 한 문자를 파일에 출력.
  • void write(char[] buf): 문자 배열 buf의 내용을 파일에 출력.
  • void write(char[] buf, int off, int len): 문자 배열 buf의 off 위치에서부터 len 개수의 문자를 파일에 출력
  • void write(String str): 문자열 str를 파일에 출력
  • void write(String str, int off, int len): 문자열 str의 off번째 문자부터 len 개수만큼 파일에 출력
  • void flush(): 파일에 출력하기 전에 자료가 있는 공간(출력 버퍼)을 비워 출력
  • void close(): 파일과 연결된 스트림을 닫는다. 출력 버퍼도 지워짐.

FileWriter

다른 스트림 클래스와 마찬가지로 생성자를 사용해서 스트림을 생성한다. FileOutputStream과 마찬가지로 출력 파일이 존재하지 않으면 파일을 생성한다.

  • FileWriter(String name): 파일 이름을 매개변수로 받아 출력 스트림 생성
  • FileWriter(String name, boolean append): true이면 파일 스트림을 닫고 다시 생성할 때 파일 끝에 이어서 씀
  • FileWriter(File f): File 클래스 정보를 매개변수로 받아 출력 스트림을 생성
  • FileWriter(File f, boolean append): true이면 파일 스트림을 닫고 다시 생성할 때 파일 끝에 이어서 씀

보조 스트림

보조 스트림은 입출력 대상이 되는 파일이나 네트워크에 직접 쓰거나 읽는 기능은 없다. 말 그대로 보조 기능을 추가하는 스트림이다. 이 보조 기능은 여러 스트림에 적용할 수 있다. 우리가 흔히 먹는 커피를 봐보자. 우유를 넣으면 라떼가 되고 모카 시럽을 넣으면 모카 커피가 된다. 커피라는 메인에 서브로 올리면 맛이 변하는 것이다. 보조 스트림도 이렇게 기반 스트림을 보조해주며 기능을 추가해준다. 보조 스트림은 감싸고 있다는 의미로 해서 Wrapper 스트림이라고도 한다. 스스로는 입출력 기능이 없기 때문에 생성자의 매개변수로 다른 스트림을 받게 되면 자신이 감싸고 있는 스트림이 읽거나 쓰는 기능을 수행할 때 보조 기능을 추가한다.

보조 스트림처럼 다양한 기능을 제공하는 클래스를 디자인 페턴에서 ‘데코레이터’라고 한다.

직렬화

인스턴스를 네트워크를 통해 전송해야 할 일이 있다. 이때 필요한 것이 직렬화이다. 그리고 저장된 내용이나 전송받은 내용을 다시 복원하는 것을 역직렬화라고 한다. 다시 말해 직렬화란 인스턴스 내용을 연속 스트림으로 만드는 것이다. 스트림으로 만들어야 파일에 쓸 수도 있고 네트워크로 전송할 수도 있다. 따라서 직렬화 과정에서 하는 일은 인스턴스 변수 값을 스트림으로 만드는 것이다. 이를 보조 스트림인 ObjectInputStream과 ObjectOutputStream을 사용하여 구현한다.

자바14 예외 처리

|

오류란

프로그램에서 오류가 발생하는 상황은 두 가지이다. 하나는 프로그램 코드 작성 중 실수로 발생하는 컴파일 오류(compile error)이고, 다른 하나는 실행 중인 프로그램이 의도하지 않은 동작을 하거나 프로그램이 중지되는 실행 오류(runtime error)이다. 실행 오류 중 프로그램을 잘못 구현하여 의도한 바와 다르게 실행되어 생기는 오류를 버그라고 한다. 컴파일 오류는 개발 환경에서 대부분 원인을 알 수 있다. 발생한 컴파일 오류를 모두 수정해야 프로그램이 정상적으로 실행되므로, 문법적으로 오류가 있음을 알 수 있다. 하지만 버그는 예측하기 어렵고 프로그램이 비정상 종료되면서 갑자기 멈춰 버린다. 실제 서비스가 이러하면 서비스가 중지되는 것이므로 문제가 심각해진다. 또한 실행 중에 오류가 발생하면 그 상황을 재현하여 테스트해야 하는데, 실제 시스템이나 서비스가 운영 중인 경우에는 쉬비 않다. 따라서 로그 분석을 통해 원인을 찾을 수 있도록 프로그램을 개발할 때 로그를 남기는것이 중요하다. 자바는 이런 비정상 종료를 최대한 줄이기 위해 다양한 예외에 대한 처리 방법을 가지고 있다. 예외 처리를 하는 목적은 일단 프로그램이 비정상 종료되는 것을 방지하기 위한 것이다. 그리고 예외가 발생했을 때 로그를 남겨두면 예외 상황을 파악하고 버그를 수정하는데 도움이 된다.

오류와 예외

실행 오류는 크게 두 가지가 있는데, 하나는 자바 가상 머신에서 발생하는 시스템 오류이고 다른 하나는 예외(exception)이다. 시스템 오류의 예로는 사용 가능한 동적 메모리가 없는 경우나 스택 메모리의 오버플로가 발생한 경우이다. 이러한 시스템 오류는 프로그램에서 제어할 수 없는 반면 예외는 프로그램에서 제어할 수 있다. 가령, 프로그램에서 파일을 읽어 사용하려는데 파일이 없는 경우, 네트워크로 데이터를 전송하려는데 연결이 안 된 경우, 배열 값을 출력하려는데 배열 요소가 없는 경우 등이다.

자바에서 제공한은 오류에 대한 전체 클래스는 이러하다.

Throwable <- Error
          <- Exception

오류 클래스는 모두 Throwable 클래스에서 상속받는다. Error 클래스의 하위 클래스는 시스템에서 발생하는 오류를 다루며 프로그램에서 제어하지 않는다. 프로그램에서 제어하는 부분은 Exception 클래스와 그 하위에 있는 예외 클래스이다.

예외 클래스의 종류

프로그램에서 처리하는 예외 클래스의 최상위 클래스는 Exception 클래스이다. 그리고 이를 상속받는 구성은 대략 이러하다.

Exception <- IOException(입출력 예외 처리) <- FileNotFoundException, SocketException
          <- RuntimeException(실행 오류 예외 처리) <- ArithmeticException, IndexOutofBoundsException

예외는 다음과 같은 방식으로 처리한다.

try-catch

try {
    예외가 발생할  있는 코드 부분  
} catch(처리할 예외 타입 e) {
    try 블록 안에서 예외가 발생했을  예외를 처리하는 부분
}

// 실사용
public class ArrayExceptionHandling {
    public static void main(String[] args) {
        int[] arr = new int[5];
        
        try {
            for(int i=0; i<=5; i++) {
                arr[i] = i;
                System.out.println(arr[i]);
            }
        } catch(ArrayIndexOutOfBoundsException e) {
            System.out.println(e);
            System.out.println("예외 처리 부분");
        }
    }
}

위 예제에서는 배열을 5개 만들었는데, 인덱스를 그 이상으로 접근하여 예외가 발생하는 것이다.

try-catch-finally

프로그램에서 사용한 리소스는 프로그램이 종료되면 자동으로 해제된다. 예를 들어 네트워크가 연결되었을 경우에 채팅 프로그램이 종료될 때 연결도 닫힌다. 하지만 끝나지 않고 계속 수행되는 서비스 같은 경웨 리소스를 여러 번 반복해서 열기만 하고 닫지 않는다면 문제가 발생한다. 시스템에서 허용하는 자원은 한계가 있기 때문이다. 따라서 앞에서 사용한 시스템 리소스는 사용 후 반드시 close() 메서드로 닫아 주어야 한다. try인 경우나 비정상 종료된 경우에도 닫아주어야 하는데 이럴 때 사용하는게 finally 문이다. finally는 이름 그대로 최종적으로 실행해주는 것이다. 즉 try나 catch 이후에 무조건 실행되는 것이다.

public static void main(String[] args) {
    FileInputStream fis = null;
    
    try {
        fis = new FileInputStream("a.txt");
    } catch(FileNotFoundException e) {
        System.out.println(e);
        return;
    } finally {
        if(fis != null) {
            try {
                fis.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

try-with-resources

위처럼 하면 시스템 리소스를 사용하고 해제하는 코드는 다소 복잡해보인다. 자바7부터는 try-with-resources문을 제공하여 close() 메서드를 명시적으로 호출하지 않아도 try블록 내에서 열린 리소스를 자동으로 닫도록 만들 수 있다. try-with-resources문법을 사용하려면 해당 리소스가 AutoCloseable인터페이스를 구현해야 한다. FileInputStream은 Closeable과 AutoCloseable인터페이스를 구현했기에 이 문법을 사용할 수 있다.

public class AutoCloseObj implements AutoCloseable {
    @Override
    public void close() throws Exception {
          System.out.println("close()");
    }
}

public class AutoCloseTest {
    public static void main(String[] args) {
        try(AutoCloseObj obj = new AutoCloseObj()) {
            throw new Exception()
        } catch(Exception e) {
            System.out.println("예외");
        }
    }
}

// 자바9
AutoCloseObj obj = new AutoCloseObj();
try(obj) {
    throw new Exception();
} catch(Exception e) {
    System.outn.print("exception");
}

try-with-resources문을 사용할 때 try문의 괄호() 안에 리소스를 선언한다. 그러면 자동으로 close()를 불러준다. 자바 7에서는 괄호안에서 다른 참조 변수로 선언해주어야 하는데, 자바 9부터는 위에서 선언한 참조변수를 넣을 수 있다.

예외 처리 미루기 (throws)

FileInputStream을 생성했을 때 예외 처리 방법은 두 가지가 있었다. 하나는 try/catch이고 다른 하나는 throws 선언이다. 이것은 예외를 해당 메서드에 처리하지 않고 미룬 후 메서드를 호출하여 사용하는 부분에서 예외를 처리하는 방법이다.

public class ThrowsException {
    public Class loadClass(String fileName, String className) throws FileNotFoundException, ClassNotFoundException {
        FileInputStream fis = new FileInputStream(fileName);
        Class c = Class.forName(className);
        return c;
    }
    
    public static void main(String[] args) {
        ThrowsException test = new ThrowsException();
        test.loadClass("a.txt", "java.lang.String"); // 메서드를 호출할 때 예외를 처리함
    }
}

이렇게 하면 loadClass에서 생기는 예외는 main()에서 처리를 해야하는데, main()에서도 throws를 하게되면 자바 가상 머신으로 보내지는데, 이러면 비정상 종료가 된다. 다른 방법으론 main()에 try/catch문을 써주면 된다.

ThrowsException test = new ThrowsException();
try {
    test.loadClass("a.txt", "java.lang.String");
} catch (FileNotFoundException | ClassNotFoundException e) {} // 하나의 문장으로 처리

// 또는
try {
    test.loadClass("a.txt", "java.lang.String");
} catch (FileNotFoundException e) {
} catch (ClassNotFoundException e) {}

다중 예외 처리

여러 catch문을 한꺼번에 사용하는 경우에 각 catch 블록은 각각의 예외를 담당한다. 그런데 문법적으로 반드시 예외 처리를 해야 하는 경우 이외에도 예외 처리를 해야 할 때가 있다. 그럴땐 마지막 부분에 Exception 클래스를 활용하여 처리해버린다.

try {
    test.loadClass("a.txt", "java.lang.String");
} catch (FileNotFoundException e) {
} catch (ClassNotFoundException e) {
} catch (Exception e) {}

이때 주의해야할게 catch문은 선언한 순서대로 검사한다. 따라서 맨 위에 Exception 문장을 쓰면 발생하는 모든 예외 클래스는 Exception 상위 클래스로 자동 형 변환되어 오류가 발생한다.

사용자 정의 예외

자바에서 제공하는 예외 처리 클래스 이외에 개발하는 프로그램에 따라 다양한 예외 상황이 발생할 수 있다. 예를 들어 어떤 사이트에 회원 가입을 할 때 입력하는 아이디 값이 null이어서는 안 되고 8자 이상 20자 이하로 만들어야 하는 조건이 필요할 수 있다. 이런 조건을 체크하라는 작업을 자바 프로그램에서 한다면 예외 클래스를 직접 만들어 예외를 발생시키고 예외 처리 코드를 구현할 수 있다. 사용자 정의 예외 클래스를 구현할 때 JDK에서 제공하는 가장 유사한 클래스를 상속받는 것이 좋다. 만약 모르겠다면 Exception클래스를 상속받자.

public class IDFormatException extends Exception {
    public IDFormatException(String message) {
        super(message);
    }
}

위와 같이 Exception을 상속받고 생성자를 만들고 super(message)로 예외 메시지를 설정한다. 나중에 getMessage()메서드를 호출하면 메시지 내용을 볼 수 있다.

public lcass IDFormatTest {
    private String userID;
    
    public String getUserID() {
        return userID;
    }
    
    public void setUserID(String userID) throws IDFormatException {
        if(userID == null) {
            throw new IDFormatException("ID can't be null");
        } else if (userID.length() < 8 || userID.length() > 20) {
            throw new IDFormatException("use ID length from 8 to 20");
        }
        this.userID = userID;
    }
    
    public static void main(String[] args) {
        IDFormatTest test = new IDFormatTest();
        
        String userID = null;
        try {
            test.setUserID(userID);
        } catch (IDFormatException e) {
            System.out.println(e.getMessage());
        }
        
        userID = "1234567";
        try {
            test.setUserID(userID);
        } catch (IDFormatException e) {
            System.out.println(e.getMessage());
        }
    }
}

자바(13) - 내부 클래스, 람다식, 스트림

|

내부 클래스

내부 클래스(inner class)는 말 그대로 ‘클래스 내부에 선언한 클래스’이다. 내부에 선언하는 이유는 대개 이 클래스와 외부 클래스가 밀접한 관련이 있어서이다. 또한 그 밖의 다른 클래스와 협력할 일이 없는 경우에 내부 클래스로 선언해서 사용한다.

class Out {
    class In {
    }
}

내부 클래스의 선언은 위와 같이 클래스 안에 클래스를 선언하는 것이다. 내부 클래스는 선언하는 위치나 예약어에 따라 네 가지 유형이 있다.

  • 인스턴스 내부 클래스
  • 정적 내부 클래스
  • 지역 내부 클래스
  • 익명 클래스
class ABC {                   // 외부 클래스
    class In {                // 인스턴스 내부 클래스
        static class SIn      // 정적 내부 클래스
    }
    
    public void abc() {
        class Local {}        // 지역 내부 클래스
    }
}

인스턴스 내부 클래스

인스턴스 내부 클래스(instance inner class)는 인스턴스 변수를 선언할 때와 같은 위치에 선언하며, 외부 클래스 내부에서만 생성하여 사용하는 객체를 선언할 때 쓴다. 예를 들어 어떤 클래스 내에 여러 변수가 있고 이들 변수 중 일부를 모아 클래스로 표현하는 것이다. 이 클래스를 다른 외부 클래스에서 사용할 일이 없는 경우 내부 인스턴스 클래스로 정의하낟. 인스턴스 내부 클래스는 외부 클래스 생성 후 생성된다. 따라서 외부 클래스를 먼저 생성하지 않고 인스턴스 내부 클래스를 사용할 수 없다.

class OutClass {
    private int num = 10;
    private static int sNum = 20;
    
    private InClass inClass;
    
    public OutClass() {
        inClass = new InClass();
    }
    
    class InClass {
        int inNum = 100;
        // static int sInNum = 200;
        
        void inTest() {
            System.out.prinln(num);
            System.out.prinln(sNum);
        }
        
        // static void sTest() // 정적 메서드 정의 불가
        
        public void usingClass() {
            inClass.inTest();
        }
    }
}

public class InnerTest {
    public static void main(String[] args) {
        OutClass outClass = new OutClass();
        outClass.usingClass(); // 외부 클래스에서 내부 클래스 기능 호출
    }
}

외부 클래스에서 선언한 변수들은 private지만 내부 클래스도 다 접근 가능하다. 내부 클래스에서 정적 변수와 메서드는 선언할 수 없다. 즉 인스턴스 내부 클래스는 외부 클래스가 먼저 생성되어야 사용할 수 있고 인스턴스 내부 클래스의 메서드는 외부 클래스의 메서드가 호출될 때 사용할 수 있다. 내부 클래스를 생성하는 이유는 그 클래스를 감싸고 있는 외부 클래스에서만 사용하기 위해서이다. 그렇지만 다른 클래스에서 private이 아닌 내부 클래스를 생성하는 것이 가능은 하다.

OutClass outClass = new OutClass();
OutClass.InClass inClass = outClass.new InClass();

정적 내부 클래스

인스턴스 내부 클래스는 외부 클래스가 먼저 생성되어야 생성할 수 있기에 정적 변수와 메서드는 사용할 수 없다. 그런데 내부 클래스가 외부 클래스 생성과 무관하게 사용할 수 있어야 하고 정적 변수도 사용할 수 있어야 한다면 정적 내부 클래스(static inner class)를 사용하면 된다. 정적 내부 클래스는 인스턴스 내부 클래스처럼 외부 클래스의 멤버 변수와 같은 위치에 정의하며 static 예약어를 함께 사용한다.

class OutClass {
    private int num = 10;
    private static int sNum = 20;
    
    static class InStaticClass {
        int inNum = 100;
        static int sInNum = 200;
    }
    
    void inTest() {
        // num += 10; // 외부 클래스의 인스턴스 변수는 사용할 수 없음.
        System.out.println(inNum);
        System.out.println(sInNum);
        System.out.println(sNum);
    }
    
    static void sTest() {
        // num += 10;
        // inNum += 10; // 외부 클래스와 내부 클래스의 인스턴스 변수는 사용할 수 없음
        System.out.println(sNum);
        System.out.println(sInNum);
    }
}

public class InnerTest {
    public static void main(String[] argS) {
        OutClass.InStaticClass sInClass = new OutClass.InStaticClass();
        sInClass.inTest();
        OutClass.InStaticClass.sTest();
    }
}

지역 내부 클래스

지역 내부 클래스는 지역 변수처럼 메서드 내부에 클래스를 정의하여 사용하는 것을 말한다. 그렇기에 이 클래스는 메서드 안에서만 사용할 수 있다.

class Outer {
    int outNum = 100;
    static int sNum = 200;
    
    Runnable getRunnable(int i) {
        int num = 100;
        
        class MyRunnable implements Runnable {
            int localNum = 10;
            
            @Override
            public void run() {
                // num = 200; // 지역 변수는 상수로 바뀌므로 값을 변경할 수 없어 오류 발생
                // i = 100; // 매개변수도 지역 변수처럼 상수로 바뀌어 값을 변경할 수 없어 오류 발생
                System.out.println(i);
                System.out.println(num);
                System.out.println(localNum);
                System.out.println(outNum);
                System.out.println(Outter.sNu);
            }
        }
        return new MyRunnable();
    }
}

public class LocalInnerTest {
    public static void main(String[] args) {
        Outter out = new Outter();
        Runnable runner = out.getRunnable(10);
        runner.run();
    }
}

지역 내부 클래스에서 지역 변수의 유효성을 봐보자. 지역 변수는 메서드가 호출될 때 스택 메모리에 생성되고 메서드의 수행이 끝나면 메모리에서 사라진다. 그런데 지역 내부 클래스에 포함된 getRunnable() 메서드의 매개변수 i와 메서드 내부에 선언한 변수 num은 지역 변수이다. 그런데도 run()메서드가 정상적으로 호출된다. 이는 getRunnable() 메서드 호출이 끝나고 스택 메모리에서 지워진 변수를 이후에 또 참조할 수 있다는 것이다. 즉 지역 내부 클래스에서 사용하는 지역 변수는 상수로 처리된다. 이는 컴파일에서 final이 추가되어서 처리된다.

익명 내부 클래스

익명 내부 클래스는 이름을 사용하지 않는 클래스이다.

class Outter2 {
    Runnable getRunnable(int i) {
        int num = 100;
        
        return new Runnable() {
            @Override
            public void run() {
                System.out.println(i)
                System.out.println(num)
            }
        }; // 클래스 끝에 ; 사용
    }
    
    Runnable runner = new Runnable() {
        @Override
        public void run() {
            System.out.println("Runnable이 구현된 익명 클래스 변수")
        }
    }; // 마찬가지로 ; 사용
}

public class AnonymousInnerTest {
    public static void main(String[] args) {
        Outter2 out = new Outter2();
        Runnable runnable = out.getRunnable(10);
        runnerable.run();
        out.runner.run();
    }
}
종류 구현위치 사용할 수 있는 외부 클래스 변수 생성 방법
인스턴스 내부 클래스 외부 클래스 멤버 변수와 동일 외부 인스턴스 변수 외부 전역 변수 외부 클래스를 먼저 만든 후 내부 클래스 생성
정적 내부 클래스 외부 클래스 멤버 변수와 동일 외부 전역 변수 외부 클래스와 무관하게 생성
지역 내부 클래스 메서드 내부에 구현 외부 인스턴스 변수 외부 전역 변수 메서드를 호출할 때 생성
익명 내부 클래스 메서드 내부에 구현 변수에 대입하여 직접 구현 외부 인스턴스 변수 외부 전역 변수 메서드를 호출할 때 생성되거나, 인터페이스 타입 변수에 대입할 때 new 예약어를 사용하여 생성

람다식

자바는 객체를 기반으로 프로그램을 만든다. 만약 어떤 기능이 필요하다면 클래스를 먼저 만들고, 클래스 안에 기능을 구현한 메서드를 만든 후 그 메서드를 호출해야 한다. 다시 말해 클래스가 없다면 메서드를 사용할 수 없다. 그런데 프로그래밍 언어 중에 함수의 구현과 호출만으로 프로그램을 만들 수 있는 프로그램 방식이 있다. 이를 함수형 프로그래밍(Functional Programming; FP)라고 한다. 자바는 8버전부터 이를 지원하고 있다. 자바에서 제공하는 함수형 프로그래밍 방식을 람다식(Lambda expression)이라고 한다. 람다식은 함수 이름이 없는 익명 함수를 만드는 것이다. 람다식의 문법은 다음과 같다.

(매개변수) -> {실행문;} ```java // 기존 int add(int x, int y) { return x + y; }

// 람다식 (int x, int y) -> {return x + y};


이 외에도 여러 가지로 표현할 수 있다.

```java
// 매개변수 자료형 생략. 매개변수가 하나인 경우네는 괄호도 생략가능하다
str -> {System.out.println(str);}

// 이런식으로는 안된다.
x, y -> {System.out.println(x+y);

// 중괄호 안의 구현 부분이 한 문장인 경우 중괄호 생략
str -> System.out.println(str);

// 중괄호 안의 구현 부분이 한 문장이더라도 return문은 중괄호를 생략할 수 없다.
str -> return str.length();

// return 생략하기, 중괄호 안의 구현 부분이 return문 하나라면 중괄호와 return을 모두 생략하고 식만 쓴다.
(x, y) -> x + y
str -> str.length()

람다식 사용

두 수 중 큰 수를 찾는 함수를 람다식으로 구현해보겠다. 람다식을 구현하기 위해서는 먼저 인터페이스를 만들고, 인터페이스에 람다식으로 구현할 메서드를 선언한다. 이를 함수형 인터페이스라고 한다.

public interface MyNumber {
    int getMax(int num1, int num2); // 추상 메서드 선언
}

public class TestMyNumber {
    public static void main(String[] args) {
        MyNumber max = (x, y) -> (x >= y) ? x : y; // 람다식으로 표현
        System.out.println(max.getMax(10, 20));
    }
}

함수형 프로그래밍은 순수 함수(pure function)을 구현하고 호출함으로써 외부 자료에 부수적인 영향(side effect)를 주지 않도록 구현하는 방식이다. 순수 함수란 매개변수만을 사용하여 만드는 함수이다. 즉 함수 내부에서 함수 외부에 있는 변수를 사용하지 않아 함수가 수행되더라도 외부에 영향을 주지 않는다. 객체 지향 언어가 객체를 기반으로 구현하는 방식이라면 함수형 프로그램은 함수를 기반으로 하고, 자료를 입력받아 구현하는 방식이다. 함수가 입력받은 자료 이외에 외부 자료에 영향을 미치지 않기 때문에 여러 자료 동시에 처리하는 병렬 처리에 적합하며, 안정되고 확장성 있는 프로그램을 개발할 수 있는 장점이 있다. 또 순수 함수로 구현된 함수형 프로그램은 함수 기능이 자료에 독립적일 수 있도록 보장해준다. 즉 동일한 입력에 대해서는 동일한 출력을 보장하고, 다양한 자료에 같은 기능을 수행할 수 있다.

함수형 인터페이스

람다식은 메서드 이름이 없고 메서드를 실행하는 데 필요한 매개변수와 매개변수를 활용한 실행 코드를 구현하는 것이다. 그렇다면 메서드는 어디에 선언하고 구현해야 할까. 함수형 언어에서는 함수만 따로 호출할 수 있지만, 자바에서는 참조 변수 없이 메서드를 호출할 수 없다. 그렇기에 람다식을 구현하기 위해 함수형 인터페이스를 만들고, 인터페이스에 람다식으로 구현할 메서드를 선언하는 것이다. 람다식은 하나의 메서드를 구현하여 인터페이스형 변수에 대입하므로 인터페이스가 두 개 이상의 메서드를 가지면 안된다.

public interface MyNumber {
    int getMax(int num1, int num2);
    int add(int num1, int num2);
}

이런식으로 인터페이스에 여러 메서드가 있다면 어떤 메서드를 구현할 것인지 모호해진다. 그렇기에 람다식은 오직 하나의 메서드만 선언한 인터페이스를 구현할 수 있다. 위처럼 실수로 다른 메서드를 추가함을 방지하기 위해 @FunctionalInterface라는 어노테이션을 제공한다. 클래스 위에 위 어노테이션을 달면 메서드가 두 개 이상 발생시 오류가 발생한다. 필수는 아니지만 명시적으로 표시해주면 좋다.

@FunctionalInterface
public interface MyNumber {
    int getMax(int num1, int num2);
    int add(int num1, int num2); // 오류 발생
}

객체 지향 프로그래밍 방식과 람다식 비교

문자열 두 개를 연결해서 출력하는 예제를 기존 객체 지향 방식과 람다식으로 구현해 보겠다. 람다식을 사용하면 기존 방식보다 간결한 코드를 구현할 수 있다. 메서드의 구현부를 클래스에 만들고, 이를 다시 인스턴스로 생성하고 호출하는 코드가 줄어들기 때문이다.

public interface StringConcat {
    public void makeString(String s1, String s2);
}

이 인터페이스는 문자열 두 개를 매개변수로 입력받아 두 문자열을 연결하여 출력하는 메서드를 가지고 있다. Hello, World 이런식으로 합쳐질 것이다.

// 기존 객체지향
public class StringConCatImpl implements StringConcat {
    @Override
    public void makeString(String s1, String s2) {
        System.out.println(s1 + ", " + s2);
    }
}

// 람다식
public class TestStringConcat {
    public static void main(String[] args) {
        String s1 = "Hello";
        String s2 = "World";
        StringConCatImpl concat1 = new StringConCatImpl();
        concat1.makeString(s1, s2);
        StringConcat concat2 = (s, v) -> System.out.println(s + ", " + v);
        concat2.makeString(s1, s2);
    }
}

익명 객체를 생성하는 람다식

자바는 객체 지향 언어이다. 그런데 람다식은 객체 없이 인터페이스의 구현만으로 메서드를 호출할 수 있었다. 어떻게 생성없이 메서드 호출이 가능했을까. 우리는 앞에서 익명 내부 클래스에 대해 배웠다. 익명 내부 클래스는 클래스 이름 없이 인터페이스 자료형 변수에 바로 메서드 구현부를 생성하여 대입할 수 있다. 즉 람다식으로 메서드를 구현해서 호출하면 컴퓨터 내부에서는 다음처럼 익명 클래스가 생성되고 이를 통해 익명 객체가 생성되는 것이다.

StringConcat concat3 = new StringConcat() {
    @Override
    public void makeString(String s1, String s2) {
        System.out.println(s1 + ", " + s2);
    }
};

람다식에서 사용하는 지역 변수 두 문자열을 연결하는 람다식 코드에서 외부 메서드의 지역 변수인 i를 수정하면 어떻게 될까

public class TestStringConcat {
    public static void main(String[] args) {
        int i = 100;
        
        StringConcat concat2 = (s, v) -> {
            // i = 200; // 람다식 내부에서 변경하면 오류 발생         
        };
    }
}

위 주석 부분이 에러가 나타날 것인데, 이는 지역 내부 클래스에서 다루었던 내용과 같다. 지역 변수는 메서드 호출이 끝나면 메모리에서 사라지기 때문에 익명 내부 클래스에서 사용하는 경우에는 지역 변수가 상수로 변한다. 람다식 역시 익명 내부 클래스가 생성되므로 외부 메서드의 지역 변수를 사용하면 변수는 final 변수, 즉 상수가 되기에 이 변수를 변경하면 오류가 발생한다.

함수를 변수처럼 사용하는 람다식

람다식을 이용하면 구현된 함수를 변수처럼 사용할 수 있다. 우리가 프로그램에서 변수를 사용하는 경우는 크게 세 가지이다.

  1. 특정 자료형으로 변수 선언 후 값 대입하여 사용하기. int a = 10;
  2. 매개변수로 전달하기. int add(int x, int y);
  3. 메서드의 반환 값으로 반환하기. return num;

람다식으로 구현된 메서드도 변수에 대입하여 사용할 수 있고, 매개변수로 전달하고 반환할 수 있다.

  • 인터페이스형 변수에 람다식 대입하기

인터페이스형 변수에 람다식을 대입하는 방법은 위에서 해보았다. 다음과 같이 함수형 인터페이스 PrintString이 있고, 여기에 메서드를 하나 선언한다. 이 메서드를 구현하면 다음과 같다.

interface PrintString {
    void showString(String str);
}

PrintString lambdaStr = s -> System.out.println(s)
lambdaStr.showString("hello lambda");
  • 매개변수로 전달하는 람다식

람다식을 변수에 대입하면 이를 매개변수로 전달할 수 있다. 이때 전달되는 매개변수의 자료형은 인터페이스형이다.

interface PrintString {
    void showString(String str);
}

public class TestLambda {
    public static void main(String[] args) {
        PrintString lambdaStr = s -> System.out.println(s);
        lambdaStr.showString("hello lambda");
        showMyString(lambdaStr);
    }
    
    public static void showMyString(PrintString p) {
        p.showString("hello lambda2");
    }
}
  • 반환 값으로 쓰이는 람다식

다음과 같이 메서드의 반환형을 람다식의 인터페이스형으로 선언하면 구현한 람다식을 반환할 수 있다.

public static PrintString returnString() {
    PrintString str = s -> System.out.println(s + "world");
    return str;
}

// 또는

public static PrintString returnString() {
    return s -> System.out.println(s + "world");;
}

// 테스트
interface PrintString {
    void showString(String str);
}

public class TestLambda {
    public static void main(String[] args) {
        PrintString reStr = returnString();
        reStr.showString("hello ");
    }
    
    public static void showMyString(PrintString p) {
        p.showString("hello lambda2");
    }
    
    public static PrintString returnString() {
        return s -> System.out.println(s + "world");
    }
}

스트림

자료가 모여 있는 배열이나 컬렉션 또는 특정 범위 안에 있는 일련의 숫자를 처리하는 기능이 미리 구현되어 있다면 프로그램의 코드가 훨씬 간결해지고 일관성 있게 다룰 수 있을 것이다. 예를 들어 배열 요소를 특정 기준에 따라 정렬하거나, 요소 중 특정 값을 제외하고 출력하는 기능같은 것이다. 이렇게 여러 자료의 처리에 대한 기능을 구현해 놓은 클래스가 스트림이다. 스트림을 활용하면 배열, 컬렉션 등의 자료를 일관성 있게 처리할 수 있다. 자료에 따라 기능을 각각 새로 구현하는 것이 아니라 처리해야 하는 자료가 무엇인지와 상관없이 같은 방식으로 메서드를 호출할 수 있기 때문이다. 다른 말로는 자료를 추상화했다고 표현한다.

배열을 예로 들어보자.

// 기존 방식
int[] arr = {1, 2, 3, 4, 5};
for(int i=0; i<arr.length; i++) {
    System.out.println(arr[i]);
}

// 스트림 사용
int[] arr = {1, 2, 3, 4, 5};
Arrays.stream(arr).forEach(n -> System.out.println(n));

Array.strean()이 스트림 생성 부분이고, .forEach() 메서드(최종 연산)를 사용하여 배열의 요소를 하나씩 꺼내 출력했다.

스트림 연산

스트림 연산의 종류에는 크게 중간 연산과 최종 연산 두 가지가 있다. 중간 연산은 자료를 거르거나 변경하여 또 다른 자료를 내부적으로 생성한다. 최종 연산은 생성된 내부 자료를 소모해 가면서 연산을 수행한다. 따라서 최종 연산은 마지막에 한 번만 호출된다. 그리고 최종 연산이 호출되어야 중간 연산의 결과가 만들어 진다.

중간 연산 filter(), map()

filter()는 조건을 넣고 그 조건에 맞는 참인 경우만 추출하는 경우에 사용한다. 문자열 배열이 있을 때 문자열의 길이가 5 이상인 경우만 추출해보겠다.

sList.stream().filter(s -> s.length() >= 5).forEach(s -> System.out.println(s));

map()은 클래스가 가진 자료 중 이름만 출력하는 경우에 사용한다. 예를 들어 고객 클래스가 있다면 고객 이름만 갖고와서 출력할 수 있다. map()은 요소들을 순회하여 다른 형식으로 변환하기도 한다.

customerList.stream().map(c -> c.getName()).forEach(s -> System.out.println(s));

최종 연산 forEach(), count(), sum(), reduce()

최종 연산은 스트림의 자료를 소모하면서 연산을 수행하기 때문에 최종 연산이 수행되고 나면 해당 스트림은 더 이상 사용할 수 없다. 최종 연산은 결과를 만드는데 주로 사용한다. forEach()는 요소를 하나씩 꺼낼 때 사용되며, 통계용으로 사용되는 sum(), count()는 배열 요소의 합계를 구한다든가 개수를 출력하는 등의 연산을 수행한다.

public class IntArrayTest {
    public static void main(String[] args) {
        int[] arr = {1, 2, 3, 4, 5};
        
        int sumVal = Arrays.stream(arr).sum();
        int count = (int)Arrays.stream(arr).count(); // long 으로 반환해주어 형 변환
    }
}

다음은 Collection 인터페이스를 구현한 클래스 중 가장 많이 사용하는 ArrayList에 스트림을 생성하고 활용해 보겠다.

List<String> sList = new ArrayList<String>();
sList.add("Tomas");
sList.add("Edward");
sList.add("Jack");

Stream<String> stream = sList.stream();
stream.forEach(s -> System.out.println(s));

Stream<String> stream2 = sList.stream();
stream2.sorted().forEach(s -> System.out.println(s));

stream2에서 중간 연산자로 sorted() 메서드를 호출했는데, 이는 정렬 방식에 대한 정의가 필요하다. 따라서 사용하는 자료 클래스가 Comparable 인터페이스를 구현해야 한다. 만약 구현되어있지 않았다면, sorted()메서드의 매개 변수로 Comparator 인터페이스를 구현한 클래스를 지정할 수 있다.

스트림의 특징

  • 자료의 대상과 관계없이 동일한 연산을 수행한다.

배열이나 컬렉션에 저장된 자료를 가지고 수행할 수 있는 연산은 여러 가지가 있다. 배열에 저장된 요소 값을 출력한다든지, 조건에 따라 자료를 추출한거나, 자료가 숫자일 때 합계, 평균 등을 구할 수도 있다. 스트림은 컬렉션의 여러 자료 구조에 대해 이러한 작업을 일관성 있게 처리할 수 있는 메서드를 제공한다.

  • 한 번 생성하고 사용한 스트림은 재사용할 수 없다.

어떤 자료에 대한 스트림을 생성하고 이 스트림에 메서드를 호출하여 연산을 수행했다면 해당 스트림을 다시 다른 연산에 사용할 수 없다. 예를 들어 스트림을 생성하여 배열에 있는 요소를 출력하기 위해 각 요소들을 하나씩 순회하면서 출력에 사용하는데, 이때 요소들이 ‘소모된다’고 한다. 소모된 요소는 재사용할 수 없다. 만약 다른 기능을 호출하려면 스트림을 다시 생성해야 한다.

  • 스트림의 연산은 기존 자료를 변경하지 않는다.

스트림을 생성하여 정렬한다거나 합을 구하는 등의 여러 연산을 수행한다고 해서 기존 배열이나 컬렉션이 변경되지는 않는다. 스트림 연산을 위해 사용하는 메모리 공간이 별도로 존재하므로, 스트림의 여러 메서드를 호출하더라도 기존 자료에는 영향을 미치지 않는다.

  • 스트림의 연산은 중간 연산과 최종 연산이 있다.

스트림에서 사용하는 메서드는 크게 중간 연산과 최종 연산 두 가지로 나뉜다. 스트림에 중간 연산은 여러 개가 적용될 수 있고, 최종 연산은 맨 마지막에 한 번 적용된다. 만약 중간 연산이 여러 개 호출되었더라도 최종 연산이 호출되어야 스트림의 중간 연산이 모두 적용된다. 예를 들어 자료를 정렬하거나 검색하는 중간 연산이 호출되어도 최종연산 호출되지 않으면 정렬이나 검색한 결과를 가져올 수 없다. 이를 지연 연산(lazy evaluation)이라고 한다.

프로그래머가 기능을 지정하는 reduce() 연산

위의 연산들은 기능이 이미 정해져 있다. reduce() 연산은 내부적으로 스트림의 요소를 하나씩 소모하면서 프로그래머가 직접 기능을 지정한 기능을 수행한다. reduce()는 다음의 형태이다. T reduce(T identify, BinaryOperator<T> accumlator) 첫 번째 매겨변수 T identify는 초깃값을 의미하고 두 번째 매개변수 BinaryOperator<t> accumlator는 수행해야 할 기능이다. BinaryOperator 인터페이스는 두 매개변수로 람다식을 구현하며 이 람다식이 각 요소가 수행해야 할 기능이 된다. 이때 BinaryOperator인터페이스를 구현한 람다식을 직접 써도 되고, 람다식이 길면 인터페이스를 구현한 클래스를 생성하여 대입해도 된다. 또한 BinaryOperator는 함수형 인터페이스로 apply()메서드를 반드시 구현해야 한다. apply()메서드는 두 개의 매개변수와 한 개의 반환 값을 가지는데, 세 개 모두가 같은 자료형이다. reduce() 메서드가 호출될 때 BinaryOperator의 apply() 메서드가 호출된다. 아래는 모든 요소를 합치는 reduce() 메서드이다.

Arrays.stream(arr).reduce(0, (a, b) -> a + b);    

내부적으로 반복문이 호출되면서 람다식에 해당하는 부분이 리스트 요소만큼 호출된다. 따라서 reduce() 메서드에 어떤 람다식이 전달되느냐에 따라 다양한 연산을 수행할 수 있다. reduce()는 처음부터 마지막까지 모든 요소를 소모하면서 람다식을 반복해서 수행하므로 최종 연산이다.

다음은 배열에 여러 문자열이 있을 때 그 중 길이가 가장 긴 문자열을 찾는 예제를 따라 하면서 reduce() 메서드 사용법을 봐보자. 두 번째 매개변수에 람다식을 직접 쓰는 경우와 BinaryOperator 인터페이스를 구현한 클래스를 사요하는 두 가지 경우이다.

import java.util.function.BinaryOperator;
    
class CompareString implements BinaryOperator<String> {
    @Override
    public String apply(String s1, String s2) {
        if(s1.getBytes().length >= s2.getBytes().length) return s1;
        else return s2;
    }
}

public class ReduceTest {
    public static void main(String[] args) {
        String[] greetings = {"asdfdsaf", "bb", "aaaaa"};
        System.out.println(Arrays.stream(greetings).reduce("", (s1, s2) -> {
                            if(s1.getBytes().length >= s2.getBytes().length)
                                return s1;
                            else return s2;}));
        
        String str = Arrays.stream(greetings).reduce(new CompareString()).get();
        System.out.println(str);
    }
}

자바(12) - 컬렉션프레임워크

|

우리가 사용하는 프로그램은 대부분 데이터를 사용하여 구현한다. 메일 시스템은 메일을, 채팅 앱은 친구 목록과 채팅 내용 등을 관리한다. 프로그램을 실행할 때 데이터를 효율적으로 관리하기 위해 자료 구조를 사용한다. 자바에서는 이런 자료구조들을 제공하는데 이를 컬렉션 프레임워크라고 한다.

제네릭

프로그램에서 변수를 선언할 때 모든 변수는 자료형이 있다. 메서드에서 매개변수를 사용할 때도 자료형을 가지고 있다. 대부분 하나의 자료형으로 구현하지만, 변수나 메서드의 자료형을 필요에 따라 여러 자료형으로 바꿀 수 있으면 프로그램은 훨씬 유연해질 것이다. 이처럼 어떤 값이 하나의 참조 자료형이 아닌 여러 참조 자료형을 사용할 수 있도록 프로그래밍하는 것을 제네릭(Generic) 프로그래밍이라고 한다. 제네렉 프로그램은 참조 자료형이 변환될 때 이에 대한 검증을 컴파일러가 하므로 안정적이다. 우리가 다룰 컬렉션 프레임워크도 제네릭을 저극 활용한다.

public class ThreeDPrinter {
    private Powder material;

    public void setMaterial(Powder material) {
        this.material = material;
    }

    public Powder getMaterial() {
        return material;
    }
}

public class ThreeDPrinter {
    private Plastic material;

    public void setMaterial(Plastic material) {
        this.material = material;
    }

    public Plastic getMaterial() {
        return material;
    }
}

위의 예제는 3D 프린터의 재료에 대한 클래싀이다. 3D 프린터의 재료로는 파우더가 될 수도 플라스틱이 될 수도 있다. 그런데 위와 같이 선언하면 우리는 재료가 생길 때마다 다른 클래스들을 만들어 주어야 하는 번거로움이 있다. 이런 문제점을 제네릭을 사용하면 바꿀 수 있다.

public class GenericPrinter<T> {
    private T material;

    public void setMaterial(T material) {
        this.material = material;
    }

    public T getMaterial {
        return material;
    }
}

GenericPrinter<Powder> powderPrinter = new GenericPrinter();
powderPrinter.setMaterial(new Powder())

클래스의 옆에 <T>가 보일텐데 이것이 제네릭 선언으로 자료형 매개변수라고 부른다. 자료형을 선택해서 하면 T 부분에 우리가 선택한 자료형들로 인식이 될 것이다. static 변수나 메서드는 인스턴스를 생성하지 않아도 클래스 이름으로 호출할 수 있다. static 변수는 인스턴스 변수가 생성되기 이전에 생성되며 static 메서드는 인스턴스 변수를 사용할 수 없다. 그런데 T의 자료형이 정해지는 순간은 제네릭 클래스의 인스턴스가 생성되는 순간이다. static들은 이 시점보다 빠르기 때문에 static 변수의 자료형이나 static 메서드 내부 변수의 자료형으로 사용할 수 없다.

T외에도 원하는 문자를 써도 된다. 보통 E(elment), K(key), V(value)등을 자주 쓴다.

T 자료형에 사용할 자료형을 제한하는

제네릭 클래스에서 T 자료형에 사용할 자료형에 제한을 둘 수 있다. 예를 들어 우리가 구현한 GenericPrint<T> 클래스는 사용할 수 있는 재료가 한정되어 있다. 만약 아무 제한이 없으면 아래 처럼 물을 재료로 쓰게할 수도 있기 때문이다.

GenericPrinter<Water> printer = new GenericPrinter<Water>();

이것은 논리상 말이 안되기에(언젠가는 될려나? ㅎㅎ) 자료형에 제한을 두는 방식으로 extends 예약어를 사용할 수 있다.

public abstract class Material {
    public abstract void doPrinting();
}

public class Powder extends Material {
    public void doPrinting() {
        System.out.println("Powder")
    }
}

public class Plastic extends Material {
    public void doPrinting() {
        System.out.println("Plastic")
    }
}

// extends 예약어로 사용할 수 있는 자료형에 제한을 둔다.
public class GenericPrinter<T extends Material> {
    private T material;
}

<T extends Material>로 선언하면 제네릭 클래스를 사용할 때 상위 클래스 Material에서 선언한 메서드를 사용할 수 있다. 그냥 <T>로 하면 컴파일 시 Object 클래스로 변환된다. 그러면 Object의 기본 메서드만 사용할 수 있다. 왜냐하면 자료형을 알 수 없기 때문이다. 근데 전자처럼 선언하면 Material 추상 클래스의 doPrinting()메서드를 사용할 수 있게 된다.

제네릭 메서드 활용하기

메서드의 매개변수를 자료형 매개변수로 사용하는 경우를 봐보자. 또 자료형 매개 변수가 하나 이상인 경우도 봐보겠다. 일단적인 제네릭 메서드의 형식은 아래와 같다.

public <자료형 매개변수> 반환형 메서드 이름(자료형 매개변수 ~~) {}

반환형 앞에 사용하는 <자료형 매개변수>는 여러 개일 수 있으며, 이는 메서드 내에서만 유효하다.

public class Point<T, V> {
    T x;
    V y;

    Point(T x, V y) {
        this.x = x;
        this.y = y;
    }

    // 제네릭 메서드
    public T getX() {
        return x;
    }

    public V getY() {
        return y;
    }
}

한 점을 나태는 Point 클래스의 두 좌표 x, y는 정수일 수도 있고 실수일 수도 있고 그렇기에 T와 V라는 자료형 매개변수로 표현했다. 그럼 다음과 같이 쓸 수 있다.

Point<Integer, Double> p1 = new Point<>(0, 0.0);
Point<Integer, Double> p2 = new Point<>(10, 10.0);

다음은 테스트 코드이다.

public class GenericMethod {
    public static <T, V> double makeRectangle(Point(T, V) p1, Point(T, V) p2) {
        double left = ((Number)p1.getX()).doubleValue();
        double right = ((Number)p2.getX()).doubleValue();
        double top = ((Number)p1.getY()).doubleValue();
        double bottom = ((Number)p2.getY()).doubleValue();
        double width = right - left;
        double height = bottom - top;

        return width * height;
    }

    public static void main(String[] args) {
        Point<Integer, Double> p1 = new Point<>(0, 0.0);
        Point<Integer, Double> p2 = new Point(10, 10.0);

        double rect = GenericMethod.<Integer, Doubble>makeRectangle(p1, p2);
    }
}

GenericMethod 클래스는 제네릭 클래스가 아니다. 제네릭 클래스가 아니라도 내부에 제네릭 메서드를 구현할 수 있다. 제네릭 메서드인 makeRectangle()메서드는 static으로 구현했다. makeRectangle() 메서드에서 사용하는 T와 V는 메서드 내부에서만 유효하게 사용할 수 있다.

class Shape<T> {
    public static <T, V> double makeRectangle(Point<T, V> p1, Point<T, V> p2) {}
}

위 코드에서 Shape<T>에 사용한 T와 makeRectangle()에서 사용한 T는 전혀 다른 의미이다. 즉 makeRectangle() 메서드에서 사용한 T는 메서드 내에서만 유효하다.

제네릭은 아직까지 헷갈리는 부분이 많은데 계속 써보면서 익숙해지는 수밖에 없을거 같다. 이 제네릭을 알아야 컬렉션 프레임에서도 잘 활용할 수 있기 때문이다. ArrayLists의 정의를 보면 다음과 같이 생겼다.

public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable {}

배열은 요소를 가지므로 T보다는 Element를 의미하는 E를 많이 사용한다. 그리고 다음과 같이 자료형을 넣어 선언한다.

ArrayList<String> list = new ArrayList<String>();

// get() 메서드의 모습이다.
public E get(int index) {
    rangeCheck(index);
    return elementData(index);
}

ArrayList<String> list = new ArrayList<>();
String str = new String("abc");
list.add(str);
String s = list.get(0);

컬렉션 프레임워크(Collection Framework)

흔히 프로그램 개발을 건물 짓는 일에 비유한다. 원하는 건물을 지으려면 구조를 잘 잡아야 하듯이 프로그램을 개발할 때도 사용하는 자료를 어떤 구조로 관리할 것인지가 중요하다. 그래야 프로그램의 기능을 효과적으로 구현할 수 있기 때문이다. 이때 사용하는 것이 자료 구조(data structure)이다. 자료 구조는 프로그램 실행 중 메모리에 자료를 유지, 관리하기 위해 사용한다. 자바에서는 필요한 자료 구조를 미리 구현하여 java.util패키지에 제공하고 있는데, 이를 컬렉션 프레임워크라고 한다. 물론 자료 구조를 직접 만들 수도 있긴 하지만 컬렉션 프레임워크를 사용하면 직접 개발하는 수고도 덜고 이미 잘 만들어진 자료 구조 클래스들이기에 안정성이 좋다.

자바 컬렉션 프레임워크에는 여러 인터페이스가 정의되어 있고, 그 인터페이스를 구현한 클래스가 있다. 구조는 아래와 같다.

Collection <- List <- ArrayList, Vector, LinkedList
           <- Set  <- HashSet, TreeSet

Map <- HashTable <- Properties
    <- HashMap
    <- TreeMap

Collection 인터페이스

Collection 인터페이스 하위에 List 인터페이스와 Set 인터페이스가 있다. List를 구현한 클래스는 순차적인 자료를 관리하는 데 사용하는 클래스이고, Set 인터페이스는 우리가 수학 시간에 배운 집합을 생각하면 된다. 집합은 순서와 상관없이 중복을 허용하지 않는다. 따라서 Set 계열의 클래스는 아이디처럼 중복되지 않는 객체를 다루는 데 사용한다.

  • List 인터페이스: 순서가 있는 자료 관리, 중복 허용. ArrayList, Vector, LinkedList, Stack, Queue 등이 있다.
  • Set 인터페이스: 순서가 정해져 있지 않음. 중복을 허용하지 않음. HashSet, TreeSet 등이 있다.

Collection 인터페이스에 선언된 메서드 중 자주 사용하는 메서드는 아래와 같다.

  • boolean add(E e): Collection에 객체를 추가
  • void clear(): 모든 객체를 제거
  • Iterator<E> iterator: 순환할 반복자(Iterator)를 반환
  • boolean remove(Object o): 매개변수에 해당하는 인스턴스가 존재하면 제거
  • int size(): 요소의 개수를 반환

Map 인터페이스

Map 인터페이스는 하나가 아닌 쌍(Pair)으로 되어있는 자료를 관리하는 메서드들이 선언되어 있다. key-value 쌍이라고 표현하는데 이때 키 값은 중복될 수 없다. 학번과 학생 이름처럼 쌍으로 되어 있는 자료를 관리할 때 사용하면 편리하다. Map은 기본적으로 검색용 자료 구조이다. 즉 어떤 key값을 알고 있을 때 value를 찾기 위한 구조이다. 자주 사용하는 메서드는 다음과 같다.

  • V put(K key, V value): key에 해당하는 value값을 map에 넣는다.
  • V get(K key): key에 해당하는 value값을 반환
  • boolean isEmpty(): Map이 비었는지 여부 반환
  • boolean containsKey(Object key): Map에 해당 key가 있는지 여부를 반환
  • boolean containsValue(Object key): Map에 해당 value가 있는지 여부를 반환
  • Set keyset(): key 집합을 Set로 반환(중복 안 되므로 Set)
  • Collection values(): value를 Collection으로 반환(중복 무관)
  • V remove(key): key가 있는 경우 삭제
  • boolean remove(Object key, Object value): key가 있는 경우 key에 해당하는 value가 매개변수와 일치할 때

자바(11) - 기본클래스

|

Object 클래스

java.lang

우리가 지금까지 자바로 프로그래밍하면서 String, Integer와 같은 클래스를 사용했다. 위 클래스들은 우리가 불러오지도 않았는데 계속 쓰고있던게 이상하지 않은가. 이들은 java.lang 패키지에 속해 있다. String 클래스의 전체 이름은 java.lang.String이고, Integer 클래스의 전체 이름은 java.lang.Integer이다. 이처럼 java.lang 패키지에는 기본적으로 많이 사용하는 클래스들이 포함되어 있다. 근데 외부 패키지에 있는데 왜 import를 안할까. 이유는 컴파일할 때 java.lang 패키지를 import java.lang.*;해서 자동으로 불러오기 때문이다.

최상위 클래스 Object

Object 클래스는 모든 자바 클래스의 최상위 클래스이다. 다시 말해 모든 클래스들은 Object 클래스로부터 상속을 받는다. 근데 얘또한 java.lang 패키지처럼 우리가 불러오지 않았다. 마찬가지로 컴파일러가 변환하면서 extends Object를 자동으로 써주기 때문이다. Object를 상속받으면 다음과 같은 메서드를 사용할 수 있다.

  1. String toString(): 객체를 문자열로 표현하여 반환한다. 재정의하여 객체에 대한 설명이나 특정 멤버 변수 값을 반환한다.
  2. boolean equals(Object object): 두 인스턴스가 동일한지 여부를 반환한다. 재정의하여 논리적으로 동일한 인스턴스임을 정의할 수 있다.
  3. int hashCode(): 객체의 해시 코드 값을 반환
  4. Object clone(): 객체를 복제하여 동일한 멤버 변수 값을 가진 새로운 인스턴스 생성
  5. Class getClass(): 객체의 Class 클래스를 반환
  6. void finalize(): 인스턴스가 힙 메모리에서 제거될 때 가비지 컬렉터(GC)에 의해 호출되는 메서드이다. 네트워크 연결 해제, 열려 있는 파일 스트림 해제 등을 구현한다.
  7. void wait(): 멀티스레드 프로그램에서 사용하는 메서드이다. 스레드를 ‘기다리는 상태(non runnalbe)’로 만든다.
  8. void notify(): wait() 메서드에 의해 기다리고 있는 스레드(non runnalbe)상태를 실행 가능한(runnable)상태로 가져온다.

toString() 메서드

해당 메서드는 객체 정보를 문자열로 바꾸어준다. String이나 Integer처럼 미리 재정의가 된 것이 있다. 기본적으로 객체를 프린트해보면 클래스 이름@해시 코드 값 형태로 보일 것이다. 기본적으로 getClass().getName() + '@' + Integer.toHexString(hashCode())가 실행되기 때문이다.

equals() 메서드

equlas 메서드는 두 인스턴스의 주소 값을 비교하여 boolean 값을 반환해준다. 주소 값이 같으면 당연히 같은 인스턴스이난. 서로 다른 주소값이어도 같은 인스턴스라고 정의하는 경우도 있다. 즉 물리적 동일성뿐 아니라 논리적 동일성을 구현할 때 사용된다.

// 인자는 stduentID, 학번이라 생각하자
Student student = new Student(100);
Student student2 = student
Student student3 = new Student(100);

위의 코드를 보면 인스턴스를 만들어서 student에 참조값을 저장하였고, student2도 같은 주소값을 가리키도록 지정했다. 이런 경우 두 개의 물리적 주소가 같기에 같은 인스턴스인 것이다. 그런데 student3같은 경우는 새로 new 해서 만든 인스턴스이기에 물리적 주소가 다르다. 그런데 학번은 같으니 같은 인스턴스라고 생각해야 한다. 이럴 때 equals를 활용해서 같은 학생으로 처리해주는 것이다.

class Student {
    @Override
    public boolean equals(Object obj) {
        if(obj instanceof Student) {
            Student std = (Student)obj;
            if(this.studentID == std.studentID) {
                return true;
            } else {
                return false;
            }
        }
        return false;
    }

    public static void test() {
        System.out.print(student == student3)       // 이것은 주소값을 비교하는 거기에 false
        System.out.print(student.equals(student3))  // 위의 오버라이드로 인해 true 반환
    }
}

hashCode() 메서드

해시(hash)는 정보를 저장하거나 검색할 때 사용하는 자료 구조이다. 정보를 어디에 저장할 것인지, 어디서 가져올 것인지 해시 함수를 사용하여 구현한다. 해시 함수는 객체의 특정 정보(키 값)를 매개변수 값으로 넣으면 그 객체가 저장되어야 할 위치나 저장된 해시 테이블 주소(위치)를 반환하다. 따라서 객체 정보를 알면 해당 객체의 위치를 빠르게 검색할 수 있다. hashCode()메서드는 자바 가상 머신이 힙 메모리에 저장한 인스턴스의 주소 값을 알려준다. 인스턴스가 같다면 hashCode()의 값이 같아야 한다. 그렇기에 논리적으로 같은 인스턴스의 해쉬값도 동일하게 나오게 재정의 해주어야 한다.

class Studnent {
    @Override
    public int hashCode() {
        return studentId;
    }
}

위처럼 hashCode를 갖고오면 학번을 리턴하게 만들었다. 그러면 논리적으로 같은 인스턴스는 같은 해쉬값을 반환할 것이다.

clone() 메서드

객체 원본을 유지해 놓고 복사본을 사용한다거나, 기본 틀(prototype)의 복사본을 사용해 동일한 인스턴스를 만들어 복잡한 생성 과정을 간단히 하려는 경우에 clone() 메서드를 사용하면 된다.

class Point {
    int x;
    int y;

    Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public String toString() {
        return "x = " + x + "," + "y= " + y;
    }
}

// Clonable 인터페이스를 함께 선언하여 객체를 복제해도 된다 지정한다.
class Circle implements Clonable {
    Point point;
    int radius;

    Circle(int x, int y, int radius) {
        this.radius = radius;
        point = new Point(x, y);
    }

    public String toString() {
        return "원점은 " + point + "이고," + "반지름은 " + radius + "이다.";
    }

    @Override
    public Object clone() throws CloneNotSupportedException {
        return super.clone();
    }
}

String 클래스

String을 선언하는 방법은 두 가지가 있다.

  1. 생성자의 매개변수로 문자열 생성 String str1 = new String("abc");
  2. 문자열 상수를 가리키는 방식 String str2 = "test";

이 둘의 차이로는 new 예약어를 사용하면 힙 메모리에 객체를 만드는 것이고, 2번째 방법은 문자열 상수를 가리키는 것인데, 상수플에 있는 “test”라는 문자열 상수의 메모리 주소를 가리키게 된다. 그래서 위 두개를 == 연산자를 사용해서 비교해보면 다른 주소에 위치하기에 false가 나오고 eqauls메서드로 했을 시에 true가 나온다. String 문자열은 public final char value[]라는 곳에 저장이 된다. final로 선언되어있기에 한 번 생성된 문자열은 변경되지 않는다. 이런 문자열의 특징을 immutable(불변)하다고 한다. 만약 두 개의 문자열을 연결한다면 어떻게 될까. 문자열이 변경되는게 아니라 새로운 문자열이 생성된다.

String javaStr = new String("java");
String androidStr = new String("android");
System.out.println(System.identityHashCode(javaStr));

javaStr = javaStr.concat(androidStr);
System.out.println(System.identityHashCode(javaStr));

아래처럼 concat의 사용해보고 해쉬값을 봐보면 달라지는 것을 볼 수 있다. 이런식으로 합치게 된다면 분명 메모리 낭비가 생김은 자명할 것이다. 이런 문제를 해결하기 위해 사용하는 클래스로는 StringBufferStringBuilder클래스가 있다. 이 둘은 final로 지정하지 않은 char[]변수를 활요하여 사용한다. 두 클래스의 차이로는 여러 작업(스레드)이 동시에 문자열을 변경하려 할 때 문자열이 안전하게 변경되도록 보장해 주는가 그렇지 않은가의 차이이다. StringBuffer는 문자열이 안전하게 변경되도록 보장하지만 StringBuilder는 보장되지 않는다. 멀티 스레드 환경이 아니면 StringBuilder가 좀 더 빠르다.

public class StringBuilderTest {
    public static void main(String[] args) {
        String javaStr = new String("java");
        StringBuilder buffer = new StringBuklder(javaStr);
        System.out.println(System.identityHashCode(buffer));

        buffer.append(" and");
        buffer.append(" android");
        buffer.append(" programming is fun");
        System.out.println(System.identityHashCode(buffer));

        javaStr = buffer.toString();
        System.out.println(System.identityHashCode(javaStr));
    }
}

buffer의 주소값은 변경이 되지 않음을 확인할 수 있을 것이다. 반면 buffer.toString()한 부분은 또 새로이 메모리가 할당됨을 볼 수 있다.

Wrapper 클래스

우리는 정수를 사용할 때 primitive(기본)형인 int를 사용했다. 근데 정수를 객체형으로 사용해야 하는 경우가 있다.

public void setValue(Integer i) {}  // 객체를 매개변수로 받는 경우
public Integer returnValue() {}     // 반환 값이 객체형인 경우

자바는 기본 자료형처럼 사용할 수 있는 클래스들을 제공한다. 이러한 클래스를 기본 자료형을 감쌌다는 의미로 Wrapper 클래스라고 한다.

  • Boolean: boolean
  • Byte: byte
  • Character: char
  • Short: short
  • Integer: int
  • Long: long
  • Float: float
  • Double: double

Interger 클래스

Interger 클래스의 경우 int 자료형을 감싼 클래스인데, 다음과 같은 매개변수를 받을 수 있다.

Integer(int value) {}   // 특정 정수를 매개변수로 받기
Integer(String s) {}    // 특정 문자열을 매개변수로 받기

Integer는 클래스다 보니 여러 기능을 제공하는데 int형의 최댓값, 최솟값을 상수로 정의하고 여러 메서드들도 있다.

Integer iValue = new Integer(100);
int myValue = iValue.intValue();    // int값 가져오기

// valueOf 정적 메서드를 사용하면 생성자 없이 바로 Integer 클래스로 반환받는다.
Integer number1 = Integer.valueOf("100");   // 숫하형 문자열도 가능하다.
Integer number2 = Integer.valueOf(100);

int num = Integer.pareInt("100");   // 문자열이 어떤 숫자를 나타낼 때 int로 변환 가능

오토박싱, 언박싱

정수를 int로 선언하는 것과 Integer로 선언하는 경우는 전혀 다르다. int는 기본 4바이트 자료형이지만, Integer는 클래스이기에 생성하려면 생성자를 호출하고 정수 값을 인수로 넣어야 한다. 이처럼 기본 자료형과 Wrapper 클래스는 같은 값을 나타내지만, 쓰임과 특성이 전혀 다르다. 그래서 자바 5이전에는 이 둘을 연산하려면 하나의 형으로 일치시켜 줘야 했다. 자바 5부터는 다음처럼 해도 연산이 잘 된다.

Integer num1 = new Integer(100);
int num2 = 200;
int sum = num1 + num2;  // num.intValue()로 변환(언박싱)
Integer num3 = num2;    // Integer.valueOf(num2)로 변환(오토박싱)

기본형을 객체형으로 바꾸는 것은 오토박싱(autoboxing), 객체형을 기본형으로 꺼내는 것을 언박싱(unboxing)이라고 한다. 컴파일러가 알아서 잘 변경해주니 아주 좋다.

Class 클래스

자바의 모든 클래스와 인터페이스는 컴파일이 되고 나면 class 파일로 생성된다. 예를 들어 a.java 파일이 컴파일되면 a.class 파일이 생성된다. 이 class 파일에는 클래스나 인터페이스에 대한 변수, 메서드, 생성자 등의 정보가 들어있다. Class 클래스는 컴파일 된 class 파일에 저장된 클래스나 인터페이스 정보를 가져오는데 사용한다.

Class 클래스

지금까지 변수를 선언할 때 자료형을 미리 파악하고 그 자료형에 따라 변수를 선언했다. 그리고 클래스를 사용할 때도 이미 그 클래스 정보(변수, 메서드)를 알고 있는 상황에서 프로그램을 만들었다. 그런데 어떤 경우에는 여러 클래스 중에 상황에 따라 다른 클래스를 사용해야 할 때도 있고, 반환받는 클래스가 정확히 어떤 자료형인지 모를 때도 있다. 이렇게 모르는 클래스의 정보를 사용할 경우에 우리가 클래스 정보를 직접 찾아야 한다. 이때 Class 클래스를 활용한다.

Class 클래스를 선언하고 클래스 정보를 가져오는 방법은 다음과 같이 세 가지가 있다.

// 1. Object 클래스의 getClass() 메서드 사용하기
String s = new String();
Class c = s.getClass(); // getClass() 메서드의 반환형은 Class

// 2. 클래스 파일 이름을 Class 변수에 직접 대입하기
Class c = String.Class;

// 3. Class.forName("클래스 이름") 메서드 사용하기
Class c = Class.forName("java.lang.String");
public class Person {
    private String name;
    private int age;

    public Person() {}

    public Person(String name) {
        this.name = name;
    }

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

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age)
}

public class ClassTest {
    public void main(String[] args) throws ClassNotFoundException {
        Person person = new Person();
        Class pClass1 = person.getClass();
        System.out.println(pClass1.getName());

        Class pClass2 = Person.class;
        System.out.println(pClass2.getName());

        Class pClass3 = Class.forName("classex.Person") // 패키지 풀 네임 써줘야한다.
    }
}

classex.Person classex.Person classex.Person

위에는 클래스 가져오는 3가지 방식으로 해본 것이다. 세 번째 같은 경우에 문자열로 입력받았는데 만약에 해당 문자열에 해당하는 클래스가 존재하지 않으면 ClassNotFoundException이 발생한다.

Class 클래스를 활용해 클래스 정보 알아보기

프로그래밍을 하다 보면 내가 사용할 클래스의 자료형을 모르는 경우가 있을 수 있다. 예로 들어 내 컴퓨터에 저장되어 있지 않은 객체를 메모리에 로드하고 생성하는 경우 그 객체의 정보를 알 수 없다. 이때 Class 클래스를 가져올 수 있다면 해당 클래스 정보, 즉 생성자, 메서드, 멤버 변수 정보를 찾을 수 있다. 이렇게 사용하려는 클래스의 자료형을 모르는 상태에서 Class 클래스를 활용하여 그 클래스의 정보를 가져오고, 이 정보를 활용하여 인스턴스를 생성하거나 메서드를 호출하는 방식을 리플렉션(reflection)이라고 한다. Class 클래스의 몇 가지 메서드를 활용해보자. Constructor, Method, Field등을 활용해볼건데 이것들은 java.lang.reflect 패키지에 정의되어 있다.

import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;

public class StringClassTest {
    public static void main(String[] args) {
        Class strClass = Class.forName("java.lang.String");

        // 모든 생성자 가져오기
        Constructor[] cons = strClass.getConstructor();
        for(Constructor c: cons) {
            System.out.println(c);
        }

        // 모든 멤버 변수(필드) 가져오기
        Field[] fields = strClass.getFields();
        for(Field f : fields) {
            System.out.println(f);
        }

        // 모든 메서드 가져오기  
        Method[] methods = strClass.getMethods();
        for(Method m : methods) {
            System.out.println(m)
        }
    }
}

newInstance()로 클래스 생성하기

Class 클래스 메서드 중에 newInstance() 메서드를 활용하면 인스턴스를 만들 수 있다. 이 메서드는 Object를 반혼하므로 생성된 객체형으로 형 변환 해주어야 한다.

public class NewInstanceTest {
    public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException {
        Person person1 = new Person();
        Class pClass = Class.forName("Person");
        Person person2 = (Person)pClass.newInstance();
    } 
}

Class 클래스를 사용하는 방법은 클래스의 자료형을 직접 사용하여 프로그래밍하는 것보다 더 복잡하고, 예외 처리도 해야한다. 이미 알고 있는 클래스인 경우와 컴파일할 때 직접 참조할 수 있는 클래스는 Class 클래스를 활용할 필요가 없다. 이런 리플렉션 프로그래밍은 컴파일 시점에 알 수 없는 클래스, 즉 프로그램 실행 중에 클래스를 메모리에 로딩하거나 객체가 다른 곳에 위치해서 원격으로 로딩하고 생성할 때 사용하는 것이 좋다.

Class.forName()으로 동적 로딩하기

대부분의 클래스 정보는 프로그램이 로딩될 때 이미 메모리에 있다. 그런데 이런 경우가 있다. 어떤 회사에서 개발한 시스템이 있는데, 그 시스템은 여러 종류의 데이터 베이스를 지원한다. 오라클, MySQL, MS-SQL 등이 있다. 그렇다고 이 시스템을 컴파일할 때 모든 데이터베이스 라이브러리를 같이 컴파일할 필요가 없다. 어떤 데이터베이스와 연결할지만 결정하면 해당 드라이버만 로딩하면 된다. 회사가 사용하는 데이터베이스 정보는 환경 파일에서 읽어 올 수도 있고 다른 변수 값으로 받을 수도 있다. 즉 프로그램 실행 이후 클래스의 로딩이 필요한 경우 클래스의 동적 로딩(dynamic loading 방식을 사용한다. 이를 Class.forName() 메서드로 동적 로딩할 수 있다.

Class pClass = Class.forName("classex.Person");

String className = "classex.Person";
Class pClass = Class.forName(className);

위의 두 방식으로 로딩할 수가 있다. 대신 유의할 점이 있다. 클래스 이름이 문자열 값이므로, 문자열에 오류가 있어도 컴파일할 때에는 그 오류를 알 수 없다. 결국 프로그램이 실행되고 메서드가 호출될 때 클래스 이름에 해당하는 클래스가 없다면 ClassNotFoundException이 발생한다. 즉 동적 로딩 방식은 컴파일할 때 오류를 알 수 없다. 하지만 여러 클래스 중 하나를 선택한다거나, 시스템 연동 중 매개변수로 넘어온 값에 해당하는 클래스가 로딩되고 실행되는 경우에는 동적 로딩 방식을 유연하게 사용할 수 있다.