[13주차] I/O

janjanee·2022년 8월 1일
0
post-thumbnail

2021.02.19 작성글 이전

13. I/O

학습 목표 : 자바의 Input과 Ontput에 대해 학습하세요.

13-1. 스트림 (Stream) / 버퍼 (Buffer) / 채널 (Channel) 기반의 I/O

IO

  • 자바에서 데이터의 입력(Input)과 출력(Oupput)을 다룬다.
  • 자바의 데이터는 스트림(Stream)을 통해 입출력되므로 스트림의 특징을 잘 알아야 한다.

NIO

  • 자바 4부터 새로운 입출력(NIO: New Input/Output) java.nio 패키지가 포함됐다.
  • 자바 7로 버전업하면서 기존의 IO와 NIO 사이의 일관성 없는 클래스 설계를 잡고, 비동기 채널 등의 네트워크 지원을 대폭 강화한 NIO.2 API가 추가됐다. (java.nio 하위 패키지에 통합됨. 구별없이 NIO)

IO vs NIO

구분IONIO
입출력 방식스트림채널
버퍼넌버퍼(non-buffer)버퍼(buffer)
비동기 방식지원 X지원 O
블로킹/넌블로킹블로킹둘 다 지원

스트림 (Stream)

스트림이란 데이터를 운반하는데 사용되는 연결통로

  • 스트림은 단방향통신만 가능하기 때문에 하나의 스트림으로 입려과 출력을 동시에 처리할 수 없다.
  • 입력과 출력을 동시에 수행하려면 입력스트림(input stream)과 출력스트림(output stream) 2개가 필요하다.
  • 스트림은 먼저 보낸 데이터를 먼저 받게 되어있으며 중간에 건너뜀 없이 연속적으로 데이터를 주고받는다. (queue와 같은 FIFO 구조)

버퍼 (Buffer)

버퍼(Buffer: 메모리 저장소) 읽고 쓰기가 가능한 메모리 배열

  • 복수 개의 바이트를 한번에 입력받고 한번에 출력할 수 있다.(빠른 성능)
  • IO(non-buffer)는 보조 스트림은 BufferedInputStream, BufferedOutputStream을 연결해서 사용하기도 함.
  • NIO는 기본적으로 버퍼를 사용하므로 입출력 시 IO 보다 입출력 성능이 좋다.
  • 채널은 버퍼에 저장된 데이터를 출력하고, 입력된 데이터를 버퍼에 저장한다.
  • NIO는 읽은 데이터를 무조건 버퍼에 저장하므로 버퍼 내에서 데이터의 위치를 이동해 가면서 필요한 부분만 읽고 쓸 수 있다.

채널 (Channel)

양방향으로 입력과 출력이 가능

  • 입력과 출력을 위한 별도의 채널을 만들 필요가 없다.
  • 채널은 Buffer 클래스를 사용하므로 데이터형에 맞는 전용 메모리 공간을 갖고있다.

13-2. InputStream과 OutputStream / Byte와 Character 스트림

바이트기반 스트림 - InputStream, OutputStream

스트림은 바이트단위로 데이터를 전송하며 입출력 대상에 따라 다음과 같은 입출력스트림이 있다.

입력스트림출력스트림입출력 대상의 종류
FileInputStreamFileOutputStream파일
ByteArrayInputStreamByteArrayOutputStream메모리(byte배열)
PipedInputStreamPipedOutputStream프로세스(프로세스간의 통신)
AudioInputStreamAudioOutputStream오디오장치

예를 들어, 어떤 파일의 내용을 읽고 싶으면 FileInputStream을 사용하면 된다.
모두 InputStream과 OutputStream의 자손들이며, 각각 읽고 쓰는데 필요한 추상메소드를 자신에 맞게 구현했다.

InputStreamOutputStream
abstract int read()abstract void write(int b)
int read(byte[] b)void write(byte[] b)
int read(byte[] b, int off, int len)void write(byte[] b, int off, int len)
  • read()와 write(int b)는 입출력의 대상에 따라 읽고 쓰는 방법이 다를 것이므로 각 상황에 맞게 구현할 수

    있도록 추상메소드로 정의되어있다.

  • 나머지 메소드들은 추상메소드인 read()와 write(int b)를 이용해서 구현한 것이므로 read()와 write(int b)

    가 구현되어 있지 않으면 아무런 의미가 없다.

ByteArrayInputStream과 ByteArrayOutputStream

메모리 즉, 바이트배열에 데이터를 입출력 하는데 사용되는 스트림

  • 주로 다른 곳에 입출력하기 전에 데이터를 임시로 바이트배열에 담아서 변환 등의 작업하는데 사용
  • 자주 사용되지는 않는다.

다음은 ByteArrayInputStream/ByteArrayOutputStream을 이용해서 바이트배열 inSrc의 데이터를 outSrc로 복사하는 예제이다.
read() 와 write()를 사용하는 가장 기본적인 방법을 보여준다.

public class IOEx1 {
    public static void main(String[] args) {
        byte[] inSrc = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
        byte[] outSrc = null;

        ByteArrayInputStream input = null;
        ByteArrayOutputStream output = null;

        input = new ByteArrayInputStream(inSrc);
        output = new ByteArrayOutputStream();

        int data = 0;

        while((data = input.read()) != -1) {
            output.write(data);
        }

        outSrc = output.toByteArray();  //  스트림의 내용을 byte 배열로 변환

        System.out.println("Input Source : " + Arrays.toString(inSrc));
        System.out.println("Output Source :" + Arrays.toString(outSrc));
    }
}
// 결과

Input Source : [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
Output Source :[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

while문의 조건식은 다음의 순서로 처리된다.

(data = input.read()) != -1

1. data = input.read()  // read()를 호출한 반환값을 변수 data에 저장
2. data != -1           // data에 저장된 값이 -1 아닌지 비교
  • 바이트배열은 사용하는 자원이 메모리 밖에 없으므로 가비지컬렉터에 의해 자동적으로 자원을 반환하므로 close()를

    이용해서 스트림을 닫지 않아도 된다.

  • 한 번에 1 byte만 읽고 쓰므로 작업 효율이 좋지 않다.

다음 예제는 배열을 사용해서 입출력 작업이 더 효율적이게 만들었다.

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

        byte[] inSrc = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
        byte[] outSrc = null;
        byte[] temp = new byte[10];

        ByteArrayInputStream input = null;
        ByteArrayOutputStream output = null;

        input = new ByteArrayInputStream(inSrc);
        output = new ByteArrayOutputStream();

        input.read(temp, 0, temp.length);
        output.write(temp, 5, 5);

        outSrc = output.toByteArray();

        System.out.println("Input Source : " + Arrays.toString(inSrc));
        System.out.println("temp : " + Arrays.toString(temp));
        System.out.println("Output Source : " + Arrays.toString(outSrc));
    }
}
// 결과

Input Source : [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
temp : [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
Output Source : [5, 6, 7, 8, 9]

int read(byte[] b, int off, int len)와 void write(byte[] b, int off, int len)을 사용해서 입출력하는 방법이다.
이전 예제와 달리 byte배열을 사용해서 한 번에 배열의 크기만크 읽고 쓸 수 있다.

다음은 보조스트림의 종류이다.

입력출력설명
FilterInputStreamFilterOutputStream필터를 이용한 입출력 처리
BufferedInputStreamBufferedOutputStream버퍼를 이용한 입출력 성능향상
DataInputStreamDataOutputStreamint, float와 같은 기본형 단위(primitive type)로 데이터를 처리하는 기능
SequenceInputStream없음두 개의 스트림을 하나로 연결
LineNumberInputStream없음읽어 온 데이터의 라인 번호를 카운트 (JDK 1.1 부터 LineNumberReader로 대체)
ObjectInputStreamObjectOutputStream데이터를 객체단위로 읽고 쓰는데 사용. 주로 파일을 이용하며 객체 직렬화와 관련
없음PrintStream버퍼를 이용하며, 추가적인 print관련 기능(print, printf, println 메소드)
PushbackInputStream없음버퍼를 이용해서 읽어 온 데이터를 다시 되돌리는 기능(unread, push back to buffer)
  • 모든 보조스트림 역시 InputStream과 OutputStream의 자손들이므로 입출력 방법이 같다.

FilterInputStream과 FilterOutputStream

FilterInputStream/FilterOutputStream은 InputStream/OutputStream의 자손이면서 모든 보조스트림의 조상이다.

FilterInputStream/FilterOutputStream을 상속받아서 기반스트림에 보조기능을 추가한 보조스트림 클래스는 다음과 같다.

FilterInputStream의 자손 BufferedInputStream, DataInputStream, PushbackInputStream 등
FilterOutputStream의 자손 BufferedOutputStream, DataOutputStream, PrintStream 등

BufferedInputStream과 BufferedOutputStream

한 바이트씩 입출력하는 것 보다는 버퍼(바이트배열)를 이용해서 한 번에 여러 바이트를 입출력하는 것이 빠르기 때문에
대부분의 입출력 작업에 사용된다.

생성자설명
BufferedInputStream(InputStream in, int size)주어진 InputStream 인스턴스를 입력소스(input source)로 하며 지정된 크기(byte단위)의 버퍼를 갖는 BufferedInputStream 인스턴스를 생성한다.
BufferedInputStream(InputStream in)주어진 InputStream 인스턴스를 입력소스(input source)로 하며 버퍼의 크기를 지정해주지 않으므로 기본적으로 8192byte 크기의 버퍼를 갖게된다.
  • BufferedInputStream의 버퍼크기는 입력소스로부터 한 번에 가져올 수 있는 데이터의 크기로 지정하면 좋다.

  • 버퍼에 저장된 모든 데이터를 다 읽고 그 다음 데이터를 읽기위해 read 메소드가 호출되면 BufferedInputStream은

    입력소스로부터 다시 버퍼크기 만큼의 데이터를 읽어 버퍼에 저장한다. 이와 같은 작업이 계속해서 반복된다.

메소드/생성자설명
BufferedOutputStream(OutputStream out, int size)주어진 OutputStream인스턴스를 출력소스(output source)로하며 지정된 크기(byte단위)의 버퍼를 갖는 BufferedOutputStream의 인스턴스를 생성한다.
BufferedOutputStream(OutputStream out)주어진 OutputStream 인스턴스를 출력소스(output source)로 하며 버퍼의 크기를 지정해주지 않으므로 기본적으로 8192byte 크기의 버퍼를 갖게된다.
flush()버퍼의 모든 내용을 출력소스에 출력한 다음, 버퍼를 비운다.
close()flush()를 호출해서 버퍼의 모든 내용을 출력소스에 출력하고, BufferedOutputStream 인스턴스가 사용하던 모든 자원을 반환한다.
  • 프로그램에서 write 메소드를 이용한 출력이 BufferedOutputStream의 버퍼에 저장된다.
  • 버퍼가 가득차면, 그 때 버퍼의 모든 내용을 출력소스에 출력한다.
  • 버퍼를 비우고 다시 프로그램으로부터의 출력을 저장할 준비를 한다.

💡 버퍼가 가득 찼을 때만 출력소스에 출력을 하기 때문에, 마지막 출력부분이 출력소스에 쓰이지 못하고
BufferedOutputStream의 버퍼에 남아있는 채로 프로그램이 종료될 수 있음을 주의

public class BufferedOutputStreamEx1 {
    public static void main(String[] args) {
        try {
            FileOutputStream fos = new FileOutputStream("123.txt");

            // BufferedOutputStream의 버퍼 크기를 5로 한다.
            BufferedOutputStream bos = new BufferedOutputStream(fos, 5);

            // 파일 123.txt에 1 부터 9까지 출력한다.

            for (int i = '1'; i <= '9'; i++) {
                bos.write(i);
            }

            bos.close();

        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}
// 결과
12345

크기가 5인 BufferdOutputStream을 이용해서 파일 123.txt에 1~9 까지 출력하는 예제이다.
그런데, 123.txt 파일을 열어보면 5까지만 출력되어있다.

이유는 아래 그림과 같이 버퍼에 남아있는 데이터가 출력되지 못한 상태로 프로그램이 종료되었기 때문이다.

이 문제를 해결하기 위한 방법은 fos.close();가 아닌 bos.close(); 를 해주어야 한다.
BufferedOutputStream의 close()를 호출해 주어야 버퍼에 남아있던 모든 내용이 출력된다.

BufferedOutputStream의 close()는 기반 스트림인 FileOutputStream의 close()를 호출하기 때문에
FileOutputStream의 close()는 따로 호출해주지 않아도 된다.

즉, 보조스트림을 사용할 경우 기반스트림의 close()나 flush()를 호출할 필요가 없다.

DataInputStream과 DataOutputStream

데이터를 읽고 쓸 때 byte 단위가 아닌, 8가지 기본 자료형의 단위로 읽고 쓸 수 있는 장점이 있다.

  • 출력하는 형식은 각 기본 자료형의 값을 16진수로 표현하여 저장한다.
  • 각 자료형의 크기가 다르므로, 출력한 데이터를 다시 읽어 올 때 출력했을 순서를 고려해야함.
public class DataOutputStreamEx2 {
    public static void main(String[] args) {
        ByteArrayOutputStream bos;
        DataOutputStream dos;

        byte[] result;

        try {
            bos = new ByteArrayOutputStream();
            dos = new DataOutputStream(bos);

            dos.writeInt(10);
            dos.writeFloat(20.0f);
            dos.writeBoolean(true);

            result = bos.toByteArray();

            String[] hex = new String[result.length];

            for (int i = 0; i < result.length; i++) {
                if (result[i] < 0) {
                    hex[i] = String.format("%02x", result[i] + 256);
                } else {
                    hex[i] = String.format("%02x", result[i]);
                }
            }

            System.out.println("10 진수 : " + Arrays.toString(result));
            System.out.println("16 진수 : " + Arrays.toString(hex));

            dos.close();

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

    }
}
// 결과

10 진수 : [0, 0, 0, 10, 65, -96, 0, 0, 1]
16 진수 : [00, 00, 00, 0a, 41, a0, 00, 00, 01]
  • 결과를 보면 10 진수의 첫 번째 4 byte인 [0, 0, 0, 10]은 writeInt(10)에 의해 출력된 값이다.
  • 두 번째 4 byte인 [65, -96, 0, 0]은 writeFloat(20.0f)에 의해 출력된 값이다.
  • 마지막 1 byte인 [1]은 writeBoolean(true)에 의해 출력된 값이다.
public class DataInputStreamEx1 {
    public static void main(String[] args) {

        try {

            FileOutputStream fos = new FileOutputStream("sample.dat");
            DataOutputStream dos = new DataOutputStream(fos);

            dos.writeInt(10);
            dos.writeFloat(20.0f);
            dos.writeBoolean(true);

            dos.close();

            FileInputStream fis = new FileInputStream("sample.dat");
            DataInputStream dis = new DataInputStream(fis);

            System.out.println(dis.readInt());
            System.out.println(dis.readFloat());
            System.out.println(dis.readBoolean());

            dis.close();

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

    }
}
// 결과
10
20.0
true

sample.dat 파일을 생성 후 출력하는 예제이다.
sample.dat 파일로부터 데이터를 읽어 올 때, 아무런 변환이나 자릿수를 셀 필요없이 단순히 readInt()와 같이
읽어 올 데이터 타입에 맞는 메소드를 사용하면된다.

SequenceInputStream

여러 개의 입력스트림을 연속적으로 연결해서 하나의 스트림으로부터 데이터를 읽는 것과 같이 처리

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

        byte[] arr1 = {0, 1, 2};
        byte[] arr2 = {3, 4, 5};
        byte[] arr3 = {6, 7, 8};
        byte[] outSrc = null;

        Vector v = new Vector();

        v.add(new ByteArrayInputStream(arr1));
        v.add(new ByteArrayInputStream(arr2));
        v.add(new ByteArrayInputStream(arr3));

        SequenceInputStream input = new SequenceInputStream(v.elements());
        ByteArrayOutputStream output = new ByteArrayOutputStream();

        int data;

        try {
            while((data = input.read()) != -1) {
                output.write(data);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }

        outSrc = output.toByteArray();

        System.out.println("Input Source1 : " + Arrays.toString(arr1));
        System.out.println("Input Source2 : " + Arrays.toString(arr2));
        System.out.println("Input Source3 : " + Arrays.toString(arr3));
        System.out.println("Output Source : " + Arrays.toString(outSrc));


    }
}
  • 3개의 ByteArrayInputStream을 Vector와 SequenceInputStream을 이용해서 하나의 입력스트림처럼 다룰 수 있다.
  • Vector에 저장된 순서대로 입력되므로 순서에 주의

PrintStream

데이터를 기반스트림에 다양한 형태로 출력할 수 있는 print, println, printf와 같은 메소드를 오버로딩하여 제공

  • 데이터를 적절한 문자로 출력하므로 문자기반 스트림의 역할을 수행한다.
  • JDK 1.1 부터 PrintWriter가 추가되었으나 자주 사용하는 System.out이 PrintStream이다 보니 둘 다 사용하게됨
public class PrintStreamEx1 {
    public static void main(String[] args) {
        int i = 65;
        float f = 1234.56789f;

        Date d = new Date();

        System.out.printf("문자 %c의 코드는 %d%n", i, i);
        System.out.printf("%d는 8진수로 %o, 16진수로 %x%n", i, i, i);
        System.out.printf("%3d%3d%3d%n", 100, 90, 80);
        System.out.println();
        System.out.printf("%s%-5s%5s%n", "123", "123", "123");
        System.out.println();
        System.out.printf("%-8.1f%8.1f %e%n", f, f, f);
        System.out.println();
        System.out.printf("오늘은 %tY년 %tm월 %td일 입니다. %n", d, d, d);
        System.out.printf("지금은 %tH시 %tM분 %tS초 입니다. %n", d, d, d);
        System.out.printf("지금은 %1$tH시 %1$tM분 %1$tS초 입니다. %n", d);

    }
}

printf()를 사용한 예제이다.

문자기반 스트림 - Reader, Writer

Java에서는 한 문자를 의미하는 char형이 2 byte여서 바이트기반의 스트림으로 2 byte인 문자를 처리하는데 어렵다.

문자데이터를 입출력할 때는 바이트기반 스트림 대신 문자기반 스트림을 사용하자.

InputStream --> Reader
OutputStream --> Writer

바이트기반 스트림문자기반 스트림
FileInputStream FileOutputStreamFileReader FileWriter
ByteArrayInputStream ByteArrayOutputStreamCharArrayReader CharArrayWriter
PipedInputStream PipedOutputStreamPipedReader PipedWriter
StringBufferInputStream StringBufferOutputStreamStringReader StringWriter

💡 StringBufferInputStream, StringBufferOutputStream은 StringReader와 StringWriter로 대체되어 더이상 사용되지 않음.

다음은 바이트기반 스트림과 문자기반 스트름의 읽기, 쓰기 메소드를 비교한 것이다.

InputStreamReader
abstract int read()int read()
int read(byte[] b)int read(char[] cbuf)
int read(byte[] b, int off, int len)abstract int read(char[], int off, int len)
InputStreamReader
abstract void write(int b)void write(int c)
void write(byte[] b)void write(char[] cbuf)
void write(byte[] b, int off, int len)abstract void write(char[] cbuf, int off, int len) void write(String str) void write(String str, int off, int len)
  • Reader와 Writer도 추상메소드가 아닌 메소드들은 추상메소드를 이용해서 작성되었다.
  • 바이트기반 스트림과 문자기반 스트림은 이름만 조금 다를 뿐 활용방법은 거의 같다.
바이트기반 보조스트림문자기반 보조스트림
BufferedInputStream BufferedOutputStreamBufferedReader BufferedWriter
FilterInputStream FilterOutputStreamFilterReader FilterWriter
LineNumberInputStream(deprecated)LineNumberReader
PrintStreamPrintWriter
PushbackInputStreamPushbackReader

보조스트림 역시 다음과 같은 문자기반 보조스트림이 존재하며 사용목적과 방식은 바이트기반 보조스트림과 다르지않다.

PipedReader와 PipedWriter

쓰레드 간에 데이터를 주고받을 때 사용된다.
다른 스트림과 달리 입력과 출력 스트림을 하나의 스트림으로 연결해서 데이터를 주고받는다.

public class PipedReaderWriter {
    public static void main(String[] args) {
        InputThread inputThread = new InputThread("InputThread");
        OutputThread outputThread = new OutputThread("OutputThread");

        // PipedReader와 PipedWriter 연결
        inputThread.connect(outputThread.getOutput());

        inputThread.start();
        outputThread.start();

    }
}

class InputThread extends Thread {
    PipedReader input = new PipedReader();
    StringWriter sw = new StringWriter();

    InputThread (String name) {
        super(name);            // Thread(String name);
    }

    @Override
    public void run() {
        try {
            int data;

            while((data = input.read()) != -1) {
                sw.write(data);
            }

            System.out.println(getName() + " received : " + sw.toString());

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

    }

    public PipedReader getInput() {
        return input;
    }

    public void connect (PipedWriter output) {
        try {
            input.connect(output);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

class OutputThread extends Thread {
    PipedWriter output = new PipedWriter();

    OutputThread(String name){
        super(name);
    }

    @Override
    public void run() {
        try{
            String msg = "Hello";
            System.out.println(getName() + " sent : " + msg);
            output.write(msg);
            output.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public PipedWriter getOutput() {
        return output;
    }

    public void connect(PipedReader input) {
        try {
            output.connect(input);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

두 쓰레드가 PipedReader/PipedWriter를 이용해서 서로 메시지를 주고받는 예제이다.
쓰레드 시작전에 두 스트림을 연결해야하는 것을 주의하자!

StrinReader와 StringWriter

CharArrayReader/CharArrayWriter와 같이 입출력 대상이 메모리인 스트림이다.

  • StringWriter에 출력되는 데이터는 내부의 StringBuffer에 저장된다.
  • StringWriter는 다음과 같은 메소드를 이용해서 저장된 데이터를 얻을 수 있다.
    • StringBuffer getBuffer() StringWriter에 출력한 데이터가 저장된 StringBuffer를 반환
    • String toString() StringWriter에 출력된 문자열을 반환
public class StringReaderWriterEx {
    public static void main(String[] args) {

        String inputData = "ABCD";
        StringReader input = new StringReader(inputData);
        StringWriter output = new StringWriter();

        int data;

        try {
            while((data = input.read()) != -1) {
                output.write(data);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }

        System.out.println("Input Data : " + inputData);
        System.out.println("Output Data : " + output.toString());
    }
}
// 결과

Input Data : ABCD
Output Data : ABCD

BufferedReader와 BufferedWriter

버퍼를 이용해서 입출력의 효율을 높일 수 있도록 해주는 역할을 한다.

  • BufferedReader의 readLine()을 사용하면 데이터를 라인단위로 읽을 수 있다.
  • BufferedWriter는 newLine()이라는 줄바꿈 메소드를 갖고 있다.
public class BufferedReaderEx1 {
    public static void main(String[] args) {
        try(FileReader fr = new FileReader("src/main/java/com/jihan/javastudycode/week13/BufferedReaderEx1.java");
            BufferedReader br = new BufferedReader(fr)) {

            String line = "";

            for(int i = 1; (line = br.readLine()) != null; i++) {
                // ";"을 포함한 라인을 출력한다.
                if(line.indexOf(";") != -1)
                    System.out.println(i + ": " + line);
            }

        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}
// 결과
1: package com.jihan.javastudycode.week13;
3: import java.io.*;
7:         try(FileReader fr = new FileReader("src/main/java/com/jihan/javastudycode/week13/BufferedReaderEx1.java");
10:             String line = "";
12:             for(int i = 1; (line = br.readLine()) != null; i++) {
13:                 // ";"을 포함한 라인을 출력한다.
14:                 if(line.indexOf(";") != -1)
15:                     System.out.println(i + ": " + line);
19:             e.printStackTrace();

BufferedReader의 readLine()을 이용해서 파일을 라인단위로 읽은 다음 indexOf()를 이용해서
';'을 포함하고 있는 라인만 출력하는 예제이다.

InputStreamReader와 OutputStreamWriter

  • 바이트기반 스트림을 문자기반 스트림으로 연결시켜주는 역할을 한다.
  • 바이트기반 스트림의 데이터를 지정된 인코딩 문자데이터로 변환하는 작업을 한다.
public class InputStreamReaderEx {

    public static void main(String[] args) {

        String line = "";

        try(InputStreamReader isr = new InputStreamReader(System.in);
            BufferedReader br = new BufferedReader(isr)) {

            System.out.println("사용중인 OS의 인코딩 : " +  isr.getEncoding());

            do {
                System.out.print("문장을 이력하세요. 마치려면 q를 입력하세요.>");
                line = br.readLine();
                System.out.println("입력하신 문장 : " + line);
            } while (!line.equalsIgnoreCase("q"));

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

        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}
// 결과
사용중인 OS의 인코딩 : UTF8
문장을 이력하세요. 마치려면 q를 입력하세요.> hello my name is jihan
입력하신 문장 :  hello my name is jihan
문장을 이력하세요. 마치려면 q를 입력하세요.>jihan world~~
입력하신 문장 : jihan world~~
문장을 이력하세요. 마치려면 q를 입력하세요.>good bye
입력하신 문장 : good bye
문장을 이력하세요. 마치려면 q를 입력하세요.>q
입력하신 문장 : q
프로그램을 종료합니다.
  • BufferedReader의 readLine()을 이용해서 사용자의 화면입력을 받으면 편하다.
  • BufferedReader와 InputStream인 System.in을 연결하기 위해 InputStreamReader를 사용하였다.
  • JDK 1.5부터 Scanner가 추가되어 더 간단하게 처리가 가능하다.

13-3. 표준 스트림 (System.in, System.out, System.err)

표준입출력은 콘솔(console)을 통한 데이터 입력과 콘솔로의 데이터 출력을 의미한다.

자바에서는 표준 입출력을 위해 3가지 입출력 스트림을 자바 애플리케이션 실행과 동시에 사용할 수 있게 자동으로
생성해준다. 따라서, 개발자가 별도의 스트림 생성없이 사용할 수 있다.

System.in 콘솔로부터 데이터를 입력받는데 사용
System.out 콘솔로 데이터를 출력하는데 사용
System.err 콘솔로 데이터를 출력하는데 사용

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

        try {
            int input = 0;

            while((input=System.in.read()) != -1) {
                System.out.println("input : " + input + ", (char) input : " + (char)input );
            }

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

    }
}
// 결과
hello!!!
input : 104, (char) input : h
input : 101, (char) input : e
input : 108, (char) input : l
input : 108, (char) input : l
input : 111, (char) input : o
input : 33, (char) input : !
input : 33, (char) input : !
input : 33, (char) input : !
input : 10, (char) input :

System.in.read()를 이용하여 사용자의 입력을 받아 데이터를 읽어들이는 예제이다.

public class StandardIOEx2 {
    public static void main(String[] args) {
        System.out.println("out: Hello World!");
        System.err.println("err: Hello World!");
    }
}
// 결과
out: Hello World!
err: Hello World!
  • out과 err는 둘다 출력 대상 콘솔이므로 결과는 동일하다.

표준입출력의 대상변경 - setOut(), setErr(), setIn()

메소드설명
static void setOut(PrintStream out)System.out의 출력을 지정된 PrintStream으로 변경
static void setErr(PrintStream err)System.err의 출력을 지정된 PrintStream으로 변경
static void setIn(PrintStream in)System.in의 출력을 지정된 PrintStream으로 변경

그러나 JDK 1.5부터 등장한 Scanner 클래스로 인해 System.in으로부터 데이터를 입력받아 작업하는 것이 편리해짐

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

        PrintStream ps = null;
        FileOutputStream fos = null;

        try {
            fos = new FileOutputStream("test.txt");
            ps = new PrintStream(fos);
            System.setOut(ps);  // System.out의 출력대상을 test.txt 파일로 변경

        } catch (FileNotFoundException e) {
            e.printStackTrace();
        }

        System.out.println("Hello by System.out");
        System.err.println("Hello by System.err");

    }
}
// 결과
// console
Hello by System.err

// text.txt
Hello by System.out
  • System.out을 이용한 출력은 모두 test.txt 파일에 저장된다.
  • 실행결과 console에는 System.err와 관련된 출력만 나타난다.

13-4. 파일 읽고 쓰기

FileInputStream과 FileOutputStream

파일에 입출력을 하기 위한 스트림

생성자설명
FileInputStream(String name)지정된 파일명(name)을 가진 실제 파일과 연결된 FileInputStream을 생성
FileInputStream(File file)File 인스턴스로 지정해주어야 하는점을 제외하고 위와 같다.
FileInputStream(FileDescriptor fdObj)파일 디스크립터(fdObj)로 FileInputStream을 생성
FileOutputStream(String name)지정된 파일명(name)을 가진 실제 파일과 연결된 FileOutputStream을 생성
FileOutputStream(String name, boolean append)두번째 인자인 append를 true로 하면, 출력시 기존의 파일내용의 마지막에 덧붙인다. false면, 기존의 파일내용을 덮어쓴다.
FileOutputStream(File file)File 인스턴스로 지정해주어야 하는점을 제외하고 FileOutputStream(String name)과 같다.
FileOutputStream(File file, boolean append)File 인스턴스로 지정해주어야 하는점을 제외하고 FileOutputStream(String name, boolean append)과 같다.
FileOutputStream(FileDescriptor fdObj)파일 디스크립터(fdObj)로 FileOutputStream을 생성

첫 번째 예제를 작성하자.

public class FileViewer {
    public static void main(String[] args) throws IOException {
        FileInputStream fis = new FileInputStream(args[0]);
        int data = 0;

        while((data=fis.read()) != -1) {
            char c = (char)data;
            System.out.print(c);
        }
    }
}

커맨드 라인으로부터 입력받은 파일의 내용을 그대로 읽어서 출력하는 예제이다.
read()의 반환값이 int형(4 byte)이긴 하지만, 더 이상 입력값이 없음을 알리는 -1을 제외하고는 0~255(1 byte)범위의
정수값이기 때문에, char형(2 byte)로 변환해도 손실되는 값은 없다.

$ java com.jihan.javastudycode.week13.FileViewer com/jihan/javastudycode/week13/FileViewer.java 
package com.jihan.javastudycode.week13;

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

public class FileViewer {
    public static void main(String[] args) throws IOException {
        FileInputStream fis = new FileInputStream(args[0]);
        int data = 0;

        while((data=fis.read()) != -1) {
            char c = (char)data;
            System.out.print(c);
        }
    }
}

커맨드 라인에서 입력한 결과이다. 파일의 내용을 그대로 출력해준다.

또 다른 예제를 봐보자. 이번에는 파일의 내용을 복사하여 다른 파일에 옮기는 예제이다.

public class FileCopy {
    public static void main(String[] args) {
        try {
            FileInputStream fis = new FileInputStream(args[0]);
            FileOutputStream fos = new FileOutputStream(args[1]);

            int data = 0;

            while ((data = fis.read()) != -1) {
                fos.write(data);
            }

            fis.close();
            fos.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}
$ java com.jihan.javastudycode.week13.FileCopy com/jihan/javastudycode/week13/FileCopy.java backUp.txt

$ cat backUp.txt                                                                                      
package com.jihan.javastudycode.week13;

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

public class FileCopy {
    public static void main(String[] args) {
        try {
            FileInputStream fis = new FileInputStream(args[0]);
            FileOutputStream fos = new FileOutputStream(args[1]);

            int data = 0;

            while ((data = fis.read()) != -1) {
                fos.write(data);
            }

            fis.close();
            fos.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

FileCopy.java의 내용을 backUp.txt 파일에 그대로 옮겼다. 텍스트 파일을 다루는 경우에는
FileInputStream/FileOutputStream 보다 문자기반 스트림인 FileReader/FileWriter를 사용하는 것이 더 좋다.

보조 스트림

스트림의 기능을 보완하기 위한 보조스트림이 제공된다.
데이터 입출력의 기능은 없지만, 스트림의 기능을 향상시키거나 새로운 기능을 추가할 수 있다.

예를 들면, hello.txt 파일을 읽기위해 FileInputStream을 사용할 때, 입력 성능을 향상시키기 위해
BufferedInputStream을 사용하는 코드는 다음과 같다.

// 먼저 스트림을 생성한다.
FileInputStream fis = new FileInputStream("hello.txt");

// 스트림을 이용해서 보조스트림을 생성한다.
BufferedInputStream bis = new BufferedInputStream(fis);

bis.read(); // 보조스트림으로부터 데이터를 읽는다.
  • 실제 입력기능은 BufferedInputStream과 연결된 FileInputStream이 수행한다.
  • 보조스트림 BufferedInputStream은 버퍼만 제공한다.
  • 버퍼 사용유무의 입출력은 성능차이가 상당하기 때문에 보통 버퍼를 사용한다.

FileReader와 FileWriter

파일로부터 텍스트 데이터를 읽고, 파일에 쓰는데 사용

public class FileReaderEx1 {
    public static void main(String[] args) {
        String fileName = "test.txt";

        try(FileInputStream fis = new FileInputStream(fileName);
            FileReader fr = new FileReader(fileName)) {

            int data;

            while((data = fis.read()) != -1) {
                System.out.print((char)data);
            }

            System.out.println();

            while((data=fr.read()) != -1) {
                System.out.print((char)data);
            }

            System.out.println();
        } catch (IOException e) {
            e.printStackTrace();
        }

    }
}
// 결과
// FileInputStream
안녕! 안녕~ Hello$$

// FileReader
안녕! 안녕~ Hello$$

바이트 기반 스트림인 FileInputStream과 문자기반 FileReader를 사용하여 파일의 내용을 읽어 화면에 출력하는 예제이다.
결과를 보면 FileInputStream을 사용했을 경우 한글이 깨져서 출력된 것을 볼 수 있다.

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

        String fileName = "test.txt";

        try(FileReader fr = new FileReader("test.txt");
            FileWriter fw = new FileWriter("convert.txt")) {

            int data;

            while((data=fr.read()) != -1) {

                if (data != '\t' && data != '\n' && data != ' ' && data != '\r'){
                    fw.write(data);
                }
            }

        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

위에서 만든 "test.txt" 파일을 읽어서 "convert.txt" 파일로 출력하는 예제이다.
코드를 보면 공백을 제거해서 새로운 convert.txt 파일을 출력한다.

// 결과
// convert.txt
안녕!안녕~Hello$$

RandomAccessFile

RandomAccessFile은 하나의 클래스로 파일에 대한 입출력을 모두 할 수 있다.

  • DataInput과 DateOutput 인터페이스를 모두 구현했기 때문에 읽기/쓰기가 모두 가능하다.
  • 기본자료형 단위로 데이터를 읽고 쓸 수 있다.
  • 파일의 어느 위치에나 읽기/쓰기가 가능하다.
    • 내부적으로 파일 포인터를 사용. 입출력 시 작업이 수행되는 곳이 바로 파일 포인터가 위치한 곳이다.
    • 파일의 임의의 위치에 있는 곳에서 작업하고자 한다면, 파일 포인터를 원하는 위치로 이동시켜야 한다.
    • 현재 작업 중인 파일에서 파일 포인터를 알려면 getFilePointer()를 사용
    • 포인터 위치를 옮기기 위해서 seek(long pos)나 skipBytes(int n)을 사용
public class RandomAccessFileEx1 {
    public static void main(String[] args) {

        try {

            RandomAccessFile raf = new RandomAccessFile("test.dat", "rw");
            System.out.println("파일 포인터의 위치: " + raf.getFilePointer());

            raf.writeInt(100);
            System.out.println("파일 포인터의 위치: " + raf.getFilePointer());

            raf.writeLong(100L);
            System.out.println("파일 포인터의 위치: " + raf.getFilePointer());

        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}
// 결과

파일 포인터의 위치: 0
파일 포인터의 위치: 4
파일 포인터의 위치: 12

파일에 출력작업이 수행됐을 때 파일 포인터의 위치 변화를 출력하는 예제이다.
int 일 때 4 byte long 일 때 8 byte 이동하여 총, 12 byte 만큼 이동한 것을 알 수 있다.

💡 인스턴스 모드
r 파일로 부터 읽기만 수행
rw 파일에 읽기 쓰기 수행
rws, rwd rw와 같으나 출력내용이 파일에 지연 없이 바로 쓰이게 한다. rwd는 파일의 내용만, rws는 파일의 메타정보도 포함

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

        int[] score = {100, 85, 90, 60};

        try {

            RandomAccessFile raf = new RandomAccessFile("score2.dat", "rw");

            for (int i = 0; i < score.length; i++) {
                raf.writeInt(score[i]);
            }

            raf.seek(0);

            while (true) {
                System.out.println(raf.readInt());
            }

        } catch (EOFException eof) {
            // readInt()를 호출했을 때 더 이상 읽을 내용이 없으면 EOFException 발생
        } catch (IOException e) {
            e.printStackTrace();
        }

    }
}
// 결과
100
85
90
60
  • 주의할 점은 raf.seek(0) 을 호출하지 않으면 콘솔에 아무것도 출력되지 않는다.
  • 이유는, writeInt()를 수행하면서 포인터의 위치가 파일의 마지막으로 이동되었기 때문이다.
  • "rw" 읽기/쓰기 모드일 때는 이런 점을 주의해야 한다.

File

File 클래스를 통해서 파일과 디렉토리를 다룰 수 있다.

  • File 인스턴스는 파일 일 수도 있고 디렉토리 일 수도 있다.

  • 파일의 경로와 디렉토리나 파일의 이름을 구분하는데 사용되는 구분자가 OS 마다 다르기 때문에 OS 독립적으로

    프로그램을 작성하기 위해서는 반드시 특정 멤버변수들을 이용해야한다.

public class FileEx1 {
    public static void main(String[] args) throws IOException {

        File f = new File("src/main/java/com/jihan/javastudy/FileEx1.java");

        String fileName = f.getName();
        int pos = fileName.lastIndexOf(".");

        System.out.println("경로를 제외한 파일이름 - " + f.getName());
        System.out.println("확장자를 제외한 파일이름 - " + fileName.substring(0, pos));
        System.out.println("확장자 - " + fileName.substring(pos + 1));

        System.out.println();

        System.out.println("경로를 포함한 파일이름 - " + f.getPath());
        System.out.println("파일의 절대 경로 - " + f.getAbsolutePath());
        System.out.println("파일의 정규경로 - " + f.getCanonicalPath());
        System.out.println("파일이 속해 있는 디렉토리 - " + f.getParent());

        System.out.println();

        System.out.println("File.separator - " + File.separator);
        System.out.println("File.separatorChar - " + File.separatorChar);
        System.out.println("File.pathSeparator - " + File.pathSeparator);
        System.out.println("File.pathSeparatorChar - " + File.pathSeparatorChar);

        System.out.println();

        System.out.println("user.dir = " + System.getProperty("user.dir"));

    }
}
경로를 제외한 파일이름 - FileEx1.java
확장자를 제외한 파일이름 - FileEx1
확장자 - java

경로를 포함한 파일이름 - src/main/java/com/jihan/javastudy/FileEx1.java
파일의 절대 경로 - /Users/jihan/janjanee/workspace/java/java-study-code/src/main/java/com/jihan/javastudy/FileEx1.java
파일의 정규경로 - /Users/jihan/janjanee/workspace/java/java-study-code/src/main/java/com/jihan/javastudy/FileEx1.java
파일이 속해 있는 디렉토리 - src/main/java/com/jihan/javastudy

File.separator - /
File.separatorChar - /
File.pathSeparator - :
File.pathSeparatorChar - :

user.dir = /Users/jihan/janjanee/workspace/java/java-study-code
  • File 인스턴스를 생성하고 메소드를 이용해서 파일의 경로와 구분자 등의 정보를 출력하는 예제이다.
  • 정규경로란? 기호나 링크 등을 포함하지 않는 유일한 경로를 의미한다.
  • 시스템 속성 중 user.dir의 값을 확인하면 현재 프로그램이 실행중인 디렉토리를 알 수 있다.
  • File 인스턴스를 생성했다고 해서 파일이 생성되는 것은 아니다. 파일명이나 디렉토리명으로 지정된 문자열이 유효하지 않더라도 컴파일 에러나 예외를 발생시키지 않는다.
    • 새로운 파일을 생성하기 위해서는 File 인스턴스 생성 후 -> createNewFile()을 호출해야함
public class FileEx4 {
    public static void main(String[] args) {

        File dir = new File(args[0]);

        File[] files = dir.listFiles();

        for (int i = 0; i < files.length; i++) {
            File f = files[i];
            String name = f.getName();
            SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mma");
            String attribute = "";
            String size = "";

            if(files[i].isDirectory()) {
                attribute = "DIR";
            } else {
                size = f.length() + "";
                attribute = f.canRead() ? "R" : " ";
                attribute += f.canWrite() ? "W" : " ";
                attribute += f.isHidden() ? "H" : " ";
            }
            System.out.printf("%s %3s %6s %s\n", df.format(new Date(f.lastModified())), attribute, size, name);
        }
    }
}
$ java com.jihan.javastudycode.week13.FileEx4 경로

2021-01-14 23:58오후 RW   45003 dog.jpg
2021-02-17 15:23오후 RWH   6148 .DS_Store
2021-01-14 23:02오후 RW    3801 cup.jpeg
2021-02-17 15:23오후 DIR        example
2021-01-14 23:57오후 RW     889 mobilenet.html
2021-01-14 23:57오후 RW    1006 bodyfix.html
2021-01-14 23:17오후 RW  111443 person.jpg
2021-01-14 23:49오후 RW  389534 cat.jpg
2021-01-14 23:03오후 RW   61371 latte.jpg
  • 지정한 경로의 디렉토리에 속한 파일과 디렉토리 이름, 크기 등 상세정보를 보여주는 예제이다.
public class FileSplit {
    public static void main(String[] args) {

        final int VOLUME = Integer.parseInt(args[1]);

        String filename = args[0];

        try (FileInputStream fis = new FileInputStream(filename);
             BufferedInputStream bis = new BufferedInputStream(fis);
             ){
            FileOutputStream fos = null;
            BufferedOutputStream bos = null;

            int data = 0;
            int i = 0;
            int number = 0;

            while((data = bis.read()) != -1) {
                if (i % VOLUME == 0) {
                    if (i != 0) {
                        bos.close();
                    }

                    fos = new FileOutputStream(filename + "_." + ++number);
                    bos = new BufferedOutputStream(fos);
                }
                bos.write(data);
                i++;
            }

            bos.close();

        } catch (Exception e) {
            e.printStackTrace();
        }

    }
}
// 결과

-rw-r--r--  1287  2 17 20:51 test.txt
-rw-r--r--   200  2 17 20:51 test.txt_.1
-rw-r--r--   200  2 17 20:51 test.txt_.2
-rw-r--r--   200  2 17 20:51 test.txt_.3
-rw-r--r--   200  2 17 20:51 test.txt_.4
-rw-r--r--   200  2 17 20:51 test.txt_.5
-rw-r--r--   200  2 17 20:51 test.txt_.6
-rw-r--r--    87  2 17 20:51 test.txt_.7

지정한 파일을 지정한 크기로 잘라서 여러개의 파일로 만드는 예제이다.
다음예제는 바로 위에서 쪼개진 파일을 하나로 합치는 예제이다.

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

        String mergeFilename = args[0];

        try {
            File tempFile = File.createTempFile("~mergetemp", ".tmp");
            tempFile.deleteOnExit();

            FileOutputStream fos = new FileOutputStream(tempFile);
            BufferedOutputStream bos = new BufferedOutputStream(fos);

            BufferedInputStream bis = null;

            int number = 1;

            File f = new File(mergeFilename + "_." + number);

            while (f.exists()) {
                f.setReadOnly();

                bis = new BufferedInputStream(new FileInputStream(f));

                int data = 0;

                while((data = bis.read()) != -1) {
                    bos.write(data);
                }

                bis.close();

                f = new File(mergeFilename + "_." + ++number);
            }

            bos.close();

            File oldFile = new File(mergeFilename);

            if (oldFile.exists())
                oldFile.delete();

            tempFile.renameTo(oldFile);

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

    }
}

위에서 여러개로 쪼개진 파일들을 하나의 파일로 합친다. 이 때 임시파일을 새로 만들고 프로그램 종료시
자동으로 삭제되도록 한다.
이유는 프로그램 실행중 사용자에 의해 중단되거나, 합쳐지는 도중 불완전한 파일이 생기는 것을 방지하기 위함이다.
작업이 완료되면 기존 파일을 삭제하고 완성된 임시파일의 이름을 기존파일의 이름으로 변경하면 된다.

13-5. 직렬화(Serialization)

객체를 컴퓨터에 저장했다가 다음에 다시 꺼내쓸 수 없을까?
네트워크를 통해 컴퓨터 간에 서로 객체를 주고받을 수 없을까?
있다! 직렬화(Serialization)이 가능하게 해준다.

직렬화? 객체를 데이터 스트림으로 만드는 것

  • 객체에 저장된 데이터를 스트림에 쓰기위해 연속적인(serial) 데이터로 변환
  • 반대로 스트림으로부터 데이터를 읽어서 객체를 만드는 것을 역직렬화(deserialization)라고 한다.
  • 직렬화 시 변환되는 것은 필드들이고, 생성자 및 메소드는 직렬화에 포함되지 않음
  • 필드 선언에 static, transient가 붙은 경우 직렬화 되지 않는다.

ObjectInputStream, ObjectOutputStream

직렬화(스트림에 객체를 출력) -> ObjectOutputStream
역직렬화(스트림으로부터 객체를 입력) -> ObjectInputStream

ObjectInputStream(InputStream in)
ObjectOutputStream(OutputStream out)
  • 둘 다 보조스트림이므로 입출력(직렬화/역직렬화) 스트림을 지정해주어야 한다.
FileOutputStream fos = new FileOutputStream("objectfile.ser");
ObjectOutputStream out = new ObjectOutputStream(fos);

out.writeObject(new UserInfo());
  • 파일에 객체를 저장(직렬화)하고 싶다면 위와 같이 하면된다.
  • objectfile.ser이라는 파일에 UserInfo 객체를 직렬화하여 저장한다.
  • 출력할 스트림(FileOutputStream)을 생성해서 이를 기반스트림으로 하는 ObjectOutputStream을 생성한다.
  • writeObject(Object obj)를 사용해서 객체를 출력하면, 객체가 파일에 직렬화되어 저장된다.
FileInputStream fis = new FileInputStream("objectfile.ser");
ObjectInputStream in = new ObjectInputStream(fis);

UserInfo info = (UserInfo)in.readObject();
  • 역직렬화도 마찬가지이다. writeObject() 대신 readObject()를 사용하여 읽으면된다.
  • readObject()의 반환타입 -> Object 이므로 원래 타입으로 형변환이 필요하다.

Serializable, transient

직렬화가 가능한 클래스를 만드는 방법은 직렬화하고자 하는 클래스가
java.io.Serializable 인터페이스를 구현하도록 하면 된다.

public class UserInfo implements Serializable {
    ...
}

클래스를 직렬화 가능하도록 하려면 위와같이 Serializable 인터페이스를 구현하면 된다.

public interface Serializable {}

Serializable 인터페이스를 확인해보면 아무런 내용이 없는 빈 인터페이스인데 직렬화를 고려하여 작성한 클래스인지를 판단하는 기준이 된다.

public class SuperUserInfo implements Serializable {
    String name;
    String password;
}

public class UserInfo extends SuperUserInfo {
    int age;
}

Serializable을 구현한 클래스를 상속받으면, Serializable을 구현하지 않아도 된다.
위의 예제에서는 UserInfo 는 SuperUserInfo를 상속받았으므로 UserInfo도 직렬화가 가능하다.

  • 조상인 name, password 도 함께 직렬화가 된다.
  • 만약, SuperUserInfo에서 Serializable을 구현하지 않고 UserInfo에서만 구현했다면?
    • name과 password는 직렬화 대상에서 제외된다.
public class UserInfo implements Serializable {
    String name;
    String password;
    int age;

    Object obj = new Object();      // Object는 직렬화 할 수 없다!
}

위의 클래스를 직렬화하면 java.io.NotSerializableException이 발생한다.
왜? 그 이유는 직렬화 할 수 없는 Object 클래스를 인스턴스변수로 참조하고 있기 때문이다.

public class UserInfo implements Serializable {
    String name;
    String password;
    int age;

    Object obj = new String("hello");   // String은 직렬화될 수 있다.
}

위의 클래스를 직렬화하면 이번에는 성공한다. 인스턴스변수 obj의 타입이 직렬화가 안되는 Object 이더라도
실제로 저장된 객체는 직렬화가 가능한 String 인스턴스이기 때문에 가능한것이다.

💡 인스턴스변수의 타입이 아닌 실제로 연결된 객체의 종류에 의해서 결정된다는 것!

public class UserInfo implements Serializable {
    String name;
    transient String password;              // 직렬화 대상에서 제외
    int age;

    transient Object obj = new Object();    // 직렬화 대상에서 제외
}

직렬화하려는 객체의 클래스에 제어자 transient를 붙여서 직렬화 대상에서 제외시킬 수 있다.
그리고 transient가 붙은 인스턴스변수의 값은 그 타입의 기본값으로 직렬화된다.
-> UserInfo 객체를 역직렬화하면 참조변수인 obj와 password의 값은 null 이 된다.

이제 예제를 통해 직렬화를 해보자.

public class UserInfo implements Serializable {
    String name;
    String password;
    int age;

    public UserInfo() {
        this("Unknown", "1111", 0);
    }

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

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

직렬화 대상 테스트 클래스인 UserInfo를 만든다.

public class SerialEx1 {
    public static void main(String[] args) {
        String fileName = "UserInfo.ser";

        try(FileOutputStream fos = new FileOutputStream(fileName);
            BufferedOutputStream bos = new BufferedOutputStream(fos);
            ObjectOutputStream out = new ObjectOutputStream(bos)) {

            UserInfo u1 = new UserInfo("Kim", "12345", 30);
            UserInfo u2 = new UserInfo("Lee", "3333", 20);

            ArrayList<UserInfo> list = new ArrayList<>();
            list.add(u1);
            list.add(u2);

            out.writeObject(u1);
            out.writeObject(u2);
            out.writeObject(list);

            System.out.println("직렬화 끝.");

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

위에서 만든 UserInfo 객체를 직렬화하여 파일(UserInfo.ser)에 저장하는 예제이다.
FileOutputStream을 기반으로 한 ObjectOutputStream을 생성 후, writeObject()를 이용해서
객체를 출력하면 UserInfo.ser 파일에 객체가 직렬화되어 저장된다.

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

        String fileName = "UserInfo.ser";

        try(FileInputStream fis = new FileInputStream(fileName);
            BufferedInputStream bis = new BufferedInputStream(fis);
            ObjectInputStream in = new ObjectInputStream(bis)) {


            UserInfo u1 = (UserInfo) in.readObject();
            UserInfo u2 = (UserInfo) in.readObject();
            ArrayList<UserInfo> list = (ArrayList<UserInfo>) in.readObject();

            System.out.println(u1);
            System.out.println(u2);
            System.out.println(list);

        } catch (Exception e) {
            e.printStackTrace();
        }

    }
}

앞의 예제인 직렬화된 객체를 역직렬화하는 예제이다.

  • readObject()의 리턴타입이 Object이므로 원래의 타입으로 형변환을 해주어야 한다.
  • 객체를 역직렬화 할 때는 직렬화 할 때의 순서와 일치해야한다.

writeObject() , readObject() 메소드

부모클래스가 Serializable 인터페이스를 구현하면 자식 클래스도 직렬화가 가능하다고 했다.
그런데 부모 클래스는 Serializable을 구현하지 않고 자식 클래스만 구현했다면?
자식 클래스의 필드만 직렬화가된다.

만약, 이런 상황에서 부모 클래스의 필드도 직렬화하고 싶다면 어떻게 해야할까?

  • 부모 클래스가 Serializable 인터페이스를 구현
  • 자식 클래스에서 writeObject()와 readObject() 메소드를 선언해서 부모 객체의 필드를 직접 출력

두 방법이 있는데, 첫번째가 좋겠지만 그럴 수 없는 상황이라면 두 번째 방법을 사용해야한다.

  • writeObject() -> 직렬화할 때 자동 호출
  • readObject() -> 역직렬화할 때 자동 호출
private void writeObject(ObjectOutputStream out) throws IOEXception {
    // 부모 객체의 필드값을 출력
    out.writeXXX(부모필드);
    ...

    out.defaultWriteObject();       //  자식 객체의 필드값을 직렬화
}

private void readObject(ObjectInputStream in) throws IOEXception, ClassNotFoundException {
    // 부모 객체의 필드값을 입력
    부모필드 = in.readXXX();
    ...

    out.defaultWriteObject();       //  자식 객체의 필드값을 역직렬화
}

두 메소드의 선언 방법이다.
주의할 점은 접근 제한자가 private가 아니면 자동호출이 되지 않으므로 반드시 private으로 해야한다.

아래는 예제 코드이다.

public class Parent {

    String field1;

}
public class Child extends Parent implements Serializable {

    String filed2;

    private void writeObject(ObjectOutputStream out) throws IOException {
        out.writeUTF(field1);
        out.defaultWriteObject();
    }

    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        field1 = in.readUTF();
        in.defaultReadObject();
    }

}

직렬화 가능한 클래스의 버전관리

  • 직렬화된 객체를 역직렬화할 때는 직렬화 했을 때와 같은 클래스를 사용해야한다.
  • 클래스 이름이 같아도 클래스의 내용이 변경됐다면 역직렬화는 실패하고 에러가 발생한다.

위에서 만든 UserInfo 클래스에 인스턴스 변수를 하나 추가해보자.

public class UserInfo implements Serializable {

    double weight;

    ...
}

몸무게 weight 변수를 추가하였다.

위의 SerialEx2 예제인 역직렬화를 다시 실행시켜보자.

java.io.InvalidClassException: com.jihan.javastudycode.week13.UserInfo; 
local class incompatible: stream classdesc serialVersionUID = 6546280052364076434, local class serialVersionUID = -3670788073303903862
...

직렬화 할 때와 역직렬화 할 때의 클래스의 버전이 다르다는 에러가 발생한다!

객체가 직렬화될 때 클래스에 정의된 멤버들의 정보를 이용해서 serialVersionUID라는 클래스의 버전을
자동생성해서 직렬화 내용에 포함된다.
그래서 역직렬화 할 때 클래스의 버전을 비교하고 직렬화할 때의 클래스의 버전과 일치하는지 비교할 수 있었고 에러가 발생한 것이다.

public UserInfo implements Serializable {

    private static final long serialVersionUID = 1L;

    ...

}

이렇게 클래스 내에 serialVersionUID를 정의해주면, 클래스의 내용이 바뀌어도 클래스의 버전이 자동생성된 값으로 변경되지 않는다.

컴파일 후 다시 직렬화 -> 인스턴스변수 추가 -> 역직렬화를 진행하여도 에러없이 정상적으로 동작한다.

번외. 데코레이터 패턴 (Decorator Pattern)

자바 I/O에 대해 공부하다가 I/O 패키지의 많은 부분들이 데코레이터 패턴을 이용하여 만들어졌다는 것을 알았다.
그래서 데코레이터 패턴이 뭐지? 하는 궁금증에 데코레이터 패턴에 대해서도 조금 공부해봤다.

데코레이터 패턴
객체에 추가적인 요건을 동적으로 첨가한다. 데코레이터는 서브클래스를 만드는 것을 통해서 기능을
유연하게 확장할 수 있는 방법을 제공한다.

당연히 정의만 봐서 무슨 소리인지 이해를 못하겠다.
예제를 보며 차분히 이해해보자.

스타버즈라는 카페가 있다. 스타버즈는 엄청난 급속도로 성장해서 다양한 음료들을 포괄하는 주문시스템을
이제서야 겨우 갖추려고 준비중이다.

처음 사업 시작 시 클래스들은 다음과 같이 구성되어 있었다.

  • Beverage는 음료를 나타내는 추상 클래스이며, 모든 음료는 이 클래스의 서브 클래스가 된다.
  • description 인스턴스변수는 각 서브클래스에서 설정되고, "가장 훌륭한 다크 로스트 커피" 같은 음료 설명이 적힌다.
  • cost() 메소드는 추상메소드 이다. 따라서 모든 서브클래스에서 음료의 가격을 리턴하는 cost() 메소드를 구현해야한다.

커피를 주문할 때 스팀 우유, 두유, 모카(초코), 휘핑과 같은 토핑을 변경할 수 있는데 이런 경우
기존 구성을 어떻게 변경해야 할까?

처음 스타버즈는 이렇게 해보기로 했다.

  • Beverage라는 기본 클래스의 각 음료에 우유, 두유, 모카, 휘핑이 들어가는지 여부를 나타내는 인스턴스 변수를 추가

  • cost()를 추상클래스로 하지 않고, 구현해 놓기로 한다. 각 음료 인스턴스마다 추가 토핑에 해당하는

    추가 가격까지 포함시킬 수 있도록 말이다.

    • 이렇게 하더라도 서브클래스에서 cost()를 오버라이드 해야한다.
    • 오버라이드 할 때 super를 호출하여 추가 비용을 합친 총 가격을 리턴하는 방식으로.

와 뭔가 잘 될 것 같다! 라고 생각할 수 있지만 이 구조에는 몇 가지의 문제점이 있을 수 있다.

  1. 토핑 가격이 바뀔 때마다 기존 코드를 수정해야함
  2. 토핑 종류가 많아지면 새로운 메소드를 추가하고, 수퍼클래스의 cost() 메소드도 고쳐야 함
  3. 새로운 음료가 출시되었다! 루이보스 차! 루이보스 차에는 휘핑 같은 토핑이 들어가서는 안되는데 불필요한 hasWhip() 같은 메소드를 여전히 상속받게된다.
  4. 더블 모카를 주문한 경우는 어떻게 될까???

이런 문제점으로 인해 바로 위의 구조인 상속을 써서 음료 가격과 토핑 가격을 합한 총 가격을 계산한 방법은
그리 좋은 방법이 아니다.

스타버즈는 다음 대안으로 다음과 같이 생각해본다. 우선 특정 음료에서 시작해서, 토핑으로 그 음료를 장식(decorate) 할 것이다. 예를 들어 손님이 모카하고 휘핑을 추가한 에스프레소를 주문한다면 다음과 같다.

  • Espresso 객체를 가져온다.
  • Mocha 객체로 장식한다.
  • Whip 객체로 장식한다.
  • cost() 메소드를 호출한다. 이 때 토핑 가격을 계산하는 일은 해당 객체들에게 위임된다.

그러면 객체를 어떻게 "장식" 할 수 있을까?

1️⃣ Espresso 객체에서 시작한다.

  • Beverage를 상속받기 때문에 cost() 메소드를 가짐

2️⃣ 모카 토핑을 주문했으니 Mocha 객체를 만들고 그 객체로 Espresso를 감싼다.

  • Mocha 객체는 데코레이터이다. 이 객체의 형식은 이 객체가 장식하고 있는 객체(Beverage)를 반영한다.
    • 반영(mirror)한다는 것은 "같은 형식을 갖는다"는 뜻으로 이해
    • Mocha에도 cost() 메소드가 있고, 다형성을 통해 Mocha가 감싸고 있는 Espresso도 Beverage 객체로 간주할 수 있다.
    • Mocha도 Beverage의 서브클래스 형식이다.

3️⃣ 휘핑 크림도 같이 주문했기 때문에 Whip 데코레이터를 만들고 그 객체로 Mocha를 감싼다.

  • Whip도 데코레이터기 때문에 Espresso의 형식을 반영하고, 따라서 cost() 메소드를 가진다.

  • Mocha와 Whip으로 싸여 있는 Espresso는 여전히 Beverage 객체이기 때문에 cost() 메소드 호출을 비롯한

    그냥 Espresso일 때와 같이 모든 행동을 할 수 있다.

4️⃣ 마지막으로 가격을 구한다. 가격을 구할 때는 가장 바깥쪽에 있는 데코레이터인 Whip의 cost()를 호출로 시작한다.

이제 실제 코드를 구현하기 전해 조금 더 이해하기 편하도록 클래스 다이어그램을 살펴보자.

  • Beverage는 구성요소를 나타내는 Component 추상클래스와 같은 개념이다.
    • 각 구성요소는 직접 쓰일 수도 있고 데코레이터로 감싸져서 쓰일 수도 있다.
  • 왼쪽의 커피 종류마다 구성요소를 나타내는 구상 클래스를 하나씩 만든다.
  • ToppingDecorator는 자신이 장식할 구성요소와 같은 인터페이스 또는 추상클래스를 구현한다.
  • Milk, Mocha.. 와 같은 데코레이터에는 그 객체가 장식하고 있는 객체를 위한 인스턴스 변수가 있다.
    • Beverage beverage

이제 실제 코드를 작성하며 앞의 내용들을 더 명확하게 알아보자.

🥤 Beverage 클래스 🥤

public abstract class Beverage {

    private String description = "제목없음";

    public void setDescription(String description) {
        this.description = description;
    }

    public String getDescription() {
        return description;
    }

    public abstract int cost();

}
  • 추상클래스이며 cost()는 서브클래스에서 구현할 수 있도록 추상메소드로 작성되어있다.
  • description은 음료의 설명이 들어간다.

🥛 ToppingDecorator 클래스 🥛

public abstract class ToppingDecorator extends Beverage {
    public abstract String getDescription();
}
  • 토핑을 나타내는 추상클래스(데코레이터 클래스)이다.
  • Beverage 객체가 들어갈 수 있도록 Beverage 클래스를 상속받는다.
  • 모든 토핑 데코레이터(Milk, Mocha..)에서 getDescription() 메소드를 새로 구현하도록 추상 메소드로 선언해준다.

☕️ Espresso 클래스 (음료 클래스 구현) ☕️

public class Espresso extends Beverage {

    public Espresso () {
        setDescription("에스프레소");
    }

    @Override
    public int cost() {
        return 4000;
    }
}
  • Beverage 클래스를 상속받는다.
  • 생성자에서 description 값을 에스프레소로 지정
  • 에스프레소 가격을 리턴한다. 이 때 토핑과 관련된 계산은 걱정할 필요가없다. 그저 에스프로 가격만 리턴해두자.
  • 나머지 HouseBlend, Decaf, DarkRoast도 동일하게 만든다.

🍫 Mocha 클래스(토핑 데코레이터 클래스) 🍫

추상 구성요소 (Beverage), 구상 구성요소 (Esppreso), 추상 데코레이터(ToppingDecorator) 까지 만들었으니
마지막으로 구상 데코레이터를 구현하자.

public class Mocha extends ToppingDecorator {

    Beverage beverage;

    public Mocha(Beverage beverage) {
        this.beverage = beverage;
    }

    @Override
    public int cost() {
        return 1000 + beverage.cost();
    }

    @Override
    public String getDescription() {
        return beverage.getDescription() + ", 모카";
    }
}
  • Mocha는 데코레이터 이므로 추상 데ㄹ코레이터 ToppingDecorator를 상속받는다.

  • Mocha 인스턴스에는 Beverage에 대한 레퍼런스가 들어있다. 이래야 감싸고자 하는 음료를 저장할 수 있다.

  • 위에서 getDescription()을 추상메소드로 만든이유는 여기있다. "에스프레소" 만 들어있으면 어떤 첨가물이

    들어있는지 알 수 없으니 ", 모카"를 덧붙여준다.

  • cost()는 장식하고있는 객체의 가격을 구한 뒤 그 가격에 모카를 추가한 가격을 리턴한다.

  • Soy, SteamMilk, Whip 클래스도 위와 동일하게 작성한다.

이제 준비가 다 됐으니 커피를 주문해보자.

🛎 실행 🛎

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

        Beverage beverage = new Espresso();
        System.out.println(beverage.getDescription() + " " + beverage.cost() +"원");

        Beverage beverage2 =new DarkRoast();
        beverage2 = new Mocha(beverage2);
        beverage2 = new Mocha(beverage2);
        beverage2 = new Whip(beverage2);
        System.out.println(beverage2.getDescription() + " " + beverage2.cost() + "원");

        Beverage beverage3 = new HouseBlend();
        beverage3 = new Soy(beverage3);
        beverage3 = new Mocha(beverage3);
        beverage3 = new Whip(beverage3);
        System.out.println(beverage3.getDescription() + " " + beverage3.cost() + "원");

    }
}
// 결과
에스프레소 4000원
다크 로스트, 모카, 모카, 휘핑 7000원
하우스 블렌드, 두유, 모카, 휘핑 7200원

첫 번째 에스프레소는 아무것도 들어가지 않는 에스프레소를 주문하고,
두 번째, 세 번째 커피는 각각 토핑을 추가하여 토핑 데코레이터로 감싸서 최종 주문을 할 수 있다.

이 쯤 되니 조금 눈치를 채보면 토핑 데코레이터로 감싸는 부분을 어디서 많이 본 것도 같다?!?!

데코레이터가 적용된 I/O

BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(tempFile));

위에 공부한 자바 I/O에서 많이 본 코드이다. 몰랐지만 열심히 데코레이터를 쓰고있었다!

  • FileOutputStream이 데코레이터로 포장될 구성 요소(ex- Espresso)이다.
    • FileOutputStream은 InputStream을 상속받았는데 InputStream이 추상 구성요소(ex-Beverage)가 된다.
  • BufferedOutputStream은 구상 데코레이터(ex- Mocha)이다.
    • BufferedOutputStream은 FilterOutputStream을 상속받았는데 여기서 FilterOutputStream이 추상 데코레이터(ex- ToppingStream) 역할을 한다.

I/O 데코레이터 실습

데코레이터 패턴도 알았으니 직접 I/O 입력 데코레이터를 만들 수 있다.

👉 입력 스트림에 있는 대문자를 전부 소문자로 바꿔주는 데코레이터를 만들자!

public class LowerCaseInputStream extends FilterInputStream {

    protected LowerCaseInputStream(InputStream in) {
        super(in);
    }

    public int read() throws IOException {
        int c = super.read();
        return (c == -1 ? c : Character.toLowerCase((char)c));
    }

    public int read(byte[] b, int offset, int len) throws IOException {
        int result = super.read(b, offset, len);
        for (int i = offset; i < offset+result; i++) {

            b[i] = (byte)Character.toLowerCase((char)b[i]);

        }
        return result;
    }
}
  • 추상 데코레이터인 FilterInputStream을 상속받는다.

  • 두 개의 read() 메소드를 구현한다. 각각 byte 값 하나, byte[] 배열을 읽고 각 byte를 검사하여

    대문자이면 소문자로 변환한다.

public class InputTest {
    public static void main(String[] args) {
        int c;

        try (LowerCaseInputStream in =
                     new LowerCaseInputStream(
                        new BufferedInputStream(
                            new FileInputStream("test.txt")))) {

            while((c = in.read()) != -1) {
                System.out.print((char)c);
            }

        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}
  • 위에서 만든 LowerCaseInputStream을 테스트 해보자

I love pizza, because I just like Pizza.

test.txt 파일의 내용이 아래와 같이 대문자 -> 소문자로 변경된 것을 확인할 수 있다.

i love pizza, because i just like pizza.

데코레이터 패턴을 이용하면 일반적인 상속관계보다 유연하게 기능을 확장할 수 있지만 너무 많은 클래스가
생긴다거나 감싼 구조가 많아지다 보면 구조 때문에 디버깅이 어려워질 수 도 있다.
필요한 부분에 적절하게 사용해야하는데 항상 적절하게? 라는 말은 어렵다🥲

I/O를 학습하다가 어쩌다보니 데코레이터 패턴도 공부하게 됐는데 패턴 공부는 처음해봐서 재밌고 신기했다.

데코레이터 예제 전체 소스코드 링크

References

  • 남궁성, 『자바의 정석』, 도우출판(2016)
  • 신용권, 『이것이 자바다』, 한빛미디어(2018)
  • 에릭 프리먼 외 3, 『헤드퍼스트 디자인 패턴』, OREILLY(2005)
  • 출처가 없는 이미지는 직접 그림
profile
얍얍 개발 펀치

0개의 댓글