본문 바로가기

Java/Java Language

[Java] Byte Stream의 사용법

프로그램이 종료될때, 사용하고 닫지 않는 스트림을 JVM이 자동적으로 닫아 주기는 하지만, 스트림을 사용해서 모든 작업을 마치고 난 후에는 close()을 호출해서 스트림을 반드시 닫아서 사용하던 I/O 자원을 반환해야한다. Java 7 이후로는 try-with-resources 문법이 도입되어, 스트림을 명시적으로 닫지 않아도 자동으로 자원이 해제되도록 할 수 있다. 이 문법은 AutoCloseable 인터페이스를 구현하는 클래스(예: 모든 스트림 클래스)에서 지원된다. 자세한 내용은 예외 처리 (Exception Handling)에 설명되어있다.


InputStream과 OutputStream의 메서드

mark()와 reset() 사용법

스트림의 종류에 따라서 mark()와 reset()를 사용해서 이미 읽은 데이터를 되돌려서 다시 읽을수 있다. 이 기능을 지원하는 스트림인지 확인하는 markSupported()를 통해서 알수 있다.

 

다음은 예제 코드이다. 입력 스트림의 read() 메서드를 호출할때마다, 아직 읽지 않은 다음 데이터로 넘어간다.

public class StreamMarkResetExample {  
    public static void main(String[] args) {  
        // 테스트할 데이터  
        String data = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";  
        byte[] byteData = data.getBytes();  

        try (ByteArrayInputStream inputStream = new ByteArrayInputStream(byteData)) {  
            // markSupported() 메서드로 mark와 reset 기능 지원 여부 확인  
            if (inputStream.markSupported()) {  
                System.out.println("Mark and reset are supported");  

                // 처음 3개의 바이트 읽기  
                for (int i = 0; i < 2; i++) {  
                    System.out.print((char) inputStream.read()); // ABC  
                }  
                System.out.println(); // 새 줄  

                // 현재 위치에 마크 설정 (읽기 제한 5바이트)  
                inputStream.mark(5); // C  

                // 다음 5개의 바이트 읽기  
                for (int i = 0; i < 10; i++) {  
                    System.out.print((char) inputStream.read()); // CDEFGHIJKL  
                }  
                System.out.println();  

                // 마크한 위치로 되돌리기  
                inputStream.reset(); // C  

                // 마크 위치부터 다시 읽기  
                int c;  
                while ((c = inputStream.read()) != -1) {  
                    System.out.print((char) c); // CDEFGHIJKLMNOPQRSTUVWXYZ  
                }  
            } else {  
                System.out.println("Mark and reset are not supported");  
            }  
        } catch (IOException e) {  
            e.printStackTrace();  
        }  
    }  
}

 

처음에 2개의 문자(AB)를 읽은 후에 mark()을 호출하여 마크 표시를 한다. 이후 위에올 10개의 문자(CDEFGHIJKL)를 더 읽은 후에, reset() 호출하여 마크한 위치로 스트림의 지점으로 되돌린다. 이제 스트림의 나머지 모든 문자들을 출력하면, 이전에 마크해뒀던 문자부터 CDEFGHIJKLMNOPQRSTUVWXYZ가 출력되는것을 알 수 있다.

Mark and reset are supported
AB
CDEFGHIJKL
CDEFGHIJKLMNOPQRSTUVWXYZ

바이트 보조 스트림의 메서드

명시적으로 flush() 호출

flush()는 스트림의 버퍼에 있는 모든 데이터를 한번에 출력(output) 대상으로 보낸다. 따라서 버퍼가 있는 출력스트림의 경우에만 동작해서 OutputStream에 정의된 flush() 메서드는 작업을 수행하지 못한다.

public class FlushExample {  
    private static final String ROOT_DIR = "io-test/";  
    private static final String[] FILE_NAMES = {"unbuffered.txt", "buffered.txt", "buffered_writer.txt"};  

    static public String getFileName(int i) {  
        return ROOT_DIR + FILE_NAMES[i];  
    }  

    public static void main(String[] args) {  
        initializeFiles();  

        // 버퍼가 없는 출력 스트림 예제  
        try (OutputStream os = new FileOutputStream(getFileName(0))) {  
            System.out.println("Writing to unbuffered stream");  
            os.write("Hello, World!".getBytes()); // write() 메서드 호출하면 즉시 데이터가 파일에 쓰인다.  
            System.out.println("Before flush");  
            readFileWithBufferedInputStream(getFileName(0));  
            os.flush(); // 버퍼가 없는 파일 출력 스트림에서 flush()는 실제로 아무 효과가 없다. 
            System.out.println("After flush");  
            readFileWithBufferedInputStream(getFileName(0));  
            System.out.println();  
        } catch (IOException e) {  
            e.printStackTrace();  
        }  

        // 버퍼가 있는 출력 스트림 예제  
        try (BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(getFileName(1)))) {  
            System.out.println("Writing to buffered stream");  
            bos.write("Hello, World!".getBytes()); // 데이터를 버퍼에 씀  
            System.out.println("Before flush");  
            readFileWithBufferedInputStream(getFileName(1));  
            System.out.println("After flush");  
            bos.flush(); // 버퍼가 있는 출력 스트림에서 flush()는 버퍼의 모든 데이터를 파일에 쓴다.  
            readFileWithBufferedInputStream(getFileName(1));  
            System.out.println();  
        } catch (IOException e) {  
            e.printStackTrace();  
        }  

        // 버퍼가 있는 Writer 예제  
        try (BufferedWriter writer = new BufferedWriter(new FileWriter(getFileName(2)))) {  
            System.out.println("Writing to BufferedWriter");  
            writer.write("Hello, World!");  
            System.out.println("Before flush");  
            readFileWithBufferedInputStream(getFileName(2));  
            writer.flush(); // 버퍼가 있는 출력 스트림에서 flush()는 버퍼의 모든 데이터를 파일에 쓴다.  
            System.out.println("After flush");  
            readFileWithBufferedInputStream(getFileName(2));  
            System.out.println();  
        } catch (IOException e) {  
            e.printStackTrace();  
        }  
    }  

    // 기존의 생성한 파일들을 삭제하여 초기화
    private static void initializeFiles() {  
        for (int i = 0; i < FILE_NAMES.length; i++) {  
            String fileName = getFileName(i);  
            try {  
                Files.deleteIfExists(Paths.get(fileName));  
                System.out.println("Deleted existing file: " + fileName);  
            } catch (IOException e) {  
                System.err.println("Error deleting file " + fileName + ": " + e.getMessage());  
            }  
        }  
        System.out.println();  
    }  

    // 출력 스트림으로 파일의 콘텐츠를 읽어서, 쓰기 작업이 수행되었는지 확인
    private static void readFileWithBufferedInputStream(String filePath) {  
        try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(filePath))) {  
            byte[] buffer = new byte[1024];  
            int bytesRead;  
            StringBuilder content = new StringBuilder();  
            while ((bytesRead = bis.read(buffer)) != -1) {  
                content.append(new String(buffer, 0, bytesRead));  
            }  
            if (content.length() > 0) {  
                System.out.println("[Content] " + content.toString());  
            } else {  
                System.out.println("[No content]");  
            }  
        } catch (IOException e) {  
            System.err.println("Error reading file " + filePath + ": " + e.getMessage());  
        }  
    }  
}

 

다음은 위의 코드 실행 결과이다. FileOutputStream는 버퍼가 없는 파일 출력 스트림이므로, flush()가 실행되기전에 이미 파일에 쓰기 작업을 마쳤다. 즉, 버퍼가 없는 파일 출력 스트림에서 flush()는 실제로 아무 효과가 없는 것이다.

 

반면에, 버퍼를 쓰는 입출력 보조 스트림(BufferedOutputStream, BufferedWriter)은 메모리에 저장해뒀다가 한번에 전송하여 디스크 접근 횟수를 최소화하여 입출력이 빠르게 수행된다. 정확하게는 버퍼가 가득 차거나, flush() 메서드가 호출되면 입출력 대상으로 한 번에 쓰거나 읽는다.

 

위의 코드를 실행시킨 결과이다. 버퍼를 쓰지 않는 파일 출력 스트림 FileOutputStream만을 사용했을 때와, 버퍼를 쓰는 출력 보조 스트림(BufferedOutputStream, BufferedWriter)을 같이 사용했을 때의 출력값의 차이를 알수있다.

Deleted existing file: io-test/unbuffered.txt
Deleted existing file: io-test/buffered.txt
Deleted existing file: io-test/buffered_writer.txt

Writing to unbuffered stream
Before flush
[Content] Hello, World!
After flush
[Content] Hello, World!

Writing to buffered stream
Before flush
[No content]
After flush
[Content] Hello, World!

Writing to BufferedWriter
Before flush
[No content]
After flush
[Content] Hello, World!

 

기존에 버퍼에 데이터가 append 된 후에 파일의 텍스트를 읽어왔을 때는 텍스트가 없어서[No content]을 출력하지만, flush()를 실행 후에 파일에 저장된 텍스트 읽으면 [Content] Hello, World!가 출력되었다. 이렇게 명시적으로 flush()을 호출하면 버퍼에 저장된 데이터가 디스크로 한 번에 쓰여진것을 알수 있다.

 

버퍼가 가득찼을때 자동 입출력

이번엔 명시적으로 flush()를 호출하지 않고, 버퍼가 가득찼을때 자동으로 입출력 대상으로 한 번에 쓰기를 수행하는 지 확인하기 위한 코드이다.

import java.io.*;  

public class FullBufferedOutputExample {  
    private static final String ROOT_DIR = "io-test/";  
    private static final String filePath = "full_buffer_output.txt";  

    public static void main(String[] args) {  

        // 작은 버퍼를 가지는 FileOutputStream을 생성  
        try (FileOutputStream fileOutputStream = new FileOutputStream(filePath);  
             BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(fileOutputStream, 10)) { // 버퍼 크기 10바이트  

            String data = "Hello, this is an example of full buffered output.";  
            byte[] bytes = data.getBytes();  

            // 데이터를 버퍼에 쓰기  
            for (byte b : bytes) {  
                bufferedOutputStream.write(b);  
                // 버퍼 크기 10바이트이므로, 10바이트 쓰면 디스크에 기록  
            }  

            readFileWithBufferedInputStream(filePath); // [Content] Hello, this is an example of full buffer  

            // 이 시점까지 버퍼에 10바이트가 가득 차면 자동으로 디스크에 기록됨  
            // 'flush()'를 호출하면 버퍼에 남아있는 데이터를 강제로 디스크에 기록함  
            bufferedOutputStream.flush(); // 강제로 버퍼의 데이터 디스크에 기록  

            readFileWithBufferedInputStream(filePath); // [Content] Hello, this is an example of full buffered output.  
        } catch (IOException e) {  
            e.printStackTrace();  
        }  
    }  

    private static void readFileWithBufferedInputStream(String filePath) {  
        try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(filePath))) {  
            byte[] buffer = new byte[1024];  
            int bytesRead;  
            StringBuilder content = new StringBuilder();  
            while ((bytesRead = bis.read(buffer)) != -1) {  
                content.append(new String(buffer, 0, bytesRead));  
            }  
            if (content.length() > 0) {  
                System.out.println("[Content] " + content.toString());  
            } else {  
                System.out.println("[No content]");  
            }  
        } catch (IOException e) {  
            System.err.println("Error reading file " + filePath + ": " + e.getMessage());  
        }  
    }  
}

 

위의 코드에서 버퍼 크기 10바이트로 지정해준뒤에, 버퍼에 10바이트를 초과하도록 저장하였다. 그리고 나서 readFileWithBufferedInputStream 메서드를 호출하여 입력 스트림을 통해 파일의 데이터를 읽어드려서 자동으로 쓰기를 수행했는지 확인하였다. 그리고나서 버퍼의 크기를 초과한 데이터를 파일에 저장하기 위해서 flush 메서드를 호출하였다. 이후, readFileWithBufferedInputStream 메서드를 호출해서 초과된 데이터가 파일에 저장되었는지 확인하였다.

 

다음은 코드 실행 결과이다. 다음 출력문은 readFileWithBufferedInputStream 메서드를 통해 출력된 문자열이다.

[Content] Hello, this is an example of full buffer
[Content] Hello, this is an example of full buffered output.

 

버퍼 크기가 꽉차서 데이터가 자동으로 파일에 저장된것을 알수 있다. 그리고 나서 나머지 버퍼 크기에 초과된 데이터도 파일에 저장된 것을 확인되었다.


ByteArrayInputStream과 ByteArrayOutputStream

ByteArrayInputStream와 ByteArrayOutputStream은 메모리에 바이트 단위로 입출력 하는데 사용하는 스트림이다. 주로 다른 곳에 입출력하기 전에 데이터를 임시로 바이트 배ㅔ열에 담아서 변환 등의 작업을 하는데 사용한다. 자주 사용되는 스트림은 아니지만, 스트림을 이용한 입출력 방법을 보여주는 예제를 작성하기에는 적합하다. 스트림 종류가 달라도 읽고 쓰는 방법은 동일하기 떄문이다.

read()와 write() 사용법

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

import array.Array;  

import java.io.ByteArrayInputStream;  
import java.io.ByteArrayOutputStream;  
import java.util.Arrays;  

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

        ByteArrayInputStream in = new ByteArrayInputStream(inSrc);  
        ByteArrayOutputStream out = new ByteArrayOutputStream();  

        int data = 0;  

        while ((data = in.read()) != -1) {  
            out.write(data);  
        }  

        outSrc = out.toByteArray();  
        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]

 

위의 코드에서 read()와 write()는 모두 ByteArrayInputStream와 ByteArrayOutputStream의 메서드이다. 바이트 배열(ByteArrayInputStream, ByteArrayOutputStream)은 사용하는 자원이 메모리 밖에 없으므로 가비지 컬렉터에 의해 자동으로 자원을 반환하므로 close()를 사용해서 스트림을 닫지 않아도된다.

 

그런데 여기서 read()와 write()를 사용하기 때문에 한번에 1byte만 읽고쓰므로 작업효율이 떨어진다. 다음 예제에서는 배열을 사용해서 입출력 작업이 보다 효율적으로 이루어질수있도록 한다.

 

바이트 배열로 입출력 시간 단축

import java.io.ByteArrayInputStream;  
import java.io.ByteArrayOutputStream;  
import java.util.Arrays;  

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

        ByteArrayInputStream in = new ByteArrayInputStream(inSrc);  
        ByteArrayOutputStream out = new ByteArrayOutputStream();  

        in.read(tmp, 0, inSrc.length); // in에서 최대 inSrc.length개의 데이터를 읽어서 tmp 배열의 인덱스 0부터 모두 저장  
        out.write(tmp, 5, 5); // tmp 배열의 인덱스 5부터 5개의 데이터를 out에다가 write한다.  

        outSrc = out.toByteArray();  
        System.out.println("Input Source: " + Arrays.toString(inSrc));  
        System.out.println("tmp: " + Arrays.toString(tmp));  
        System.out.println("Output Source: " + Arrays.toString(outSrc));  
    }  
}
Input Source: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
tmp: [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 배열을 사용해서 한번에 배열의 크기만큼 읽고 쓸수 있다. 또는 배열의 특정 부분만을 일고 쓸수도 있다. 이렇게 배열을 이용한 입출력은 작업의 속도를 증가시키므로 입출력 대상에 따라 알맞은 크기의 배열을 사용하는 것이 좋다.

 

read(byte[] b)와 write(byte[] b) 사용법

다음은 int read(byte[] b)와 void write(byte[] b)를 사용할때 예상과 다른 결과가 나오는 것을 방지하기 위한 코드이다.

import java.io.ByteArrayInputStream;  
import java.io.ByteArrayOutputStream;  
import java.io.IOException;  
import java.util.Arrays;  

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

        ByteArrayInputStream in = new ByteArrayInputStream(inSrc);  
        ByteArrayOutputStream out = new ByteArrayOutputStream();  

        System.out.println("Input Source: " + Arrays.toString(inSrc));  
        try {  
            while (in.available() > 0) { // blocking 없이 읽어올수 있는 바이트의 수 반환)  
                in.read(tmp); // InputStream의 메서드  
                out.write(tmp); // OutputStream의 메서드  
                outSrc = out.toByteArray(); // 바이트 배열으로 변환  
                printArray(tmp, outSrc); // tmp, outSrc 출력  
            }  

        } catch (IOException e) {  

        }  
    }  

    static void printArray(byte[] tmp, byte[] outSrc) {  
        System.out.println("tmp: " + Arrays.toString(tmp));  
        System.out.println("outSrc: " + Arrays.toString(outSrc));  
    }  
}

 

위의 코드에서 int read(byte[] b)와 void write(byte[] b)는 ByteArrayInputStream와 ByteArrayOutputStream의 메서드가 아니라 InputStream과 OutputStream의 메서드이므로IOException 예외 처리가 필요하다.

 

available()은 현재 스트림에서 버퍼에 이미 존재하거나 버퍼링된 데이터 중에서 즉시 읽을 수 있는 바이트 수를 반환한다. 예를 들어, 소켓 연결 스트림(SocketInputStream)에서 available()은 소켓에서 읽을 수 있는 데이터 양을 나타내며, 데이터가 아직 네트워크에서 도착하지 않았다면 0을 반환할 수 있다. 이는 blocking 없이 읽어올 수 있는 바이트의 수이다. I/O 작업은 수행하는데 시간이 오래걸리기 때문에, I/O에 작업을 수행하는 스레드는 blocking되며, I/O 작업을 마친 버퍼의 데이터는 인터럽트에 의해 blocking 상태에서 벗어나게된다.

 

코드 실행 결과, 임시 바이트 배열인 tmp의 요소가 [4, 5, 6, 7] 출력 다음에 [8, 9]가 나와야하는데 [8, 9, 6, 7]가 나왔다. 이는 보다 나은 성능을 위해 tmp에 저장된 요소 위에 덮어쓰기 때문이다. in.read(tmp)는 이 2바이트만 tmp 배열의 앞부분에 읽어 넣고, 나머지 부분은 그대로 두는 것이다. 따라서 최종 결과물인 outSrc에 모든 tmp 요소가 복제 되었다.

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

 

이러한 문제를 해결하기 위해서, 입력 스트림에서 읽은 데이터의 길이 만큼 쓰면 된다. int read(byte[] b) 메서드는 바이트 배열에 읽어온 데이터의 길이를 반환한다. 그리고나서 임시 배열 tmp에 읽어온 길이 만큼만 저장하면 된다.

while (in.available() > 0) { // blocking 없이 읽어올수 있는 바이트의 수 반환)  
    int len = in.read(tmp); 
    out.write(tmp, 0, len); 
}

 

그러면 출력 스트림에 읽어온 데이터의 길이만큼 tmp 요소를 가져오기 때문에, 최종결과물인 outSrc에 올바르게 복제 된다.

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

        ByteArrayInputStream in = new ByteArrayInputStream(inSrc);  
        ByteArrayOutputStream out = new ByteArrayOutputStream();  

        System.out.println("Input Source: " + Arrays.toString(inSrc));  
        try {  
            while (in.available() > 0) { // blocking 없이 읽어올수 있는 바이트의 수 반환)  
                int len = in.read(tmp); // InputStream의 메서드  
                out.write(tmp, 0, len); // OutputStream의 메서드  
            }  

        } catch (IOException e) {  

        }  

        outSrc = out.toByteArray(); // 바이트 배열으로 변환  
        printArray(tmp, outSrc); // tmp, outSrc 출력  
    }  

    static void printArray(byte[] tmp, byte[] outSrc) {  
        System.out.println("tmp: " + Arrays.toString(tmp));  
        System.out.println("outSrc: " + Arrays.toString(outSrc));  
    }  
}
Input Source: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
tmp: [0, 1, 2, 3]
outSrc: [0, 1, 2, 3]
tmp: [4, 5, 6, 7]
outSrc: [0, 1, 2, 3, 4, 5, 6, 7]
tmp: [8, 9, 6, 7]
outSrc: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

FileInputStream과 FileOutputStream

FileInputStream과 FileOutputStream은 파일에 입출력을 하기 위한 스트림이다. 실제 프로그래밍에서 많이 사용되는 스트림 중의 하나이다.

 

아래의 코드는 FileInputStream을 사용해서 커맨드 라인으로부터 입력받은 파일의 내용을 읽어서 콘솔에 출력하는 코드이다. read()의 반환값은 int형(4byte)이라, 더 이상 입력값이 없음을 알리는 -1을 제외하고는 데이터의 범위가 0~255 범위의 정수값이다. read() 한번에 1byte씩 파일로부터 데이터를 읽어들인다. 이 메서드의 반환값은 정수값이어서 2byte인 char 형도 정수이기 때문에 변환한다해도 손실되는 값은 없다.

import java.io.*;  

public class FileViewer {  
    public static void main(String[] args) {  
        try (FileInputStream fis = new FileInputStream(args[0])) {  
            int data = 0;  
            while ((data = fis.read()) != -1) {  
                char c = (char) data;  
                System.out.print(c);  
            }  
        } catch (IOException e) {  
            e.printStackTrace();  
        }  
    }  
}

 

위의 코드 실행 결과이다.

java FileViewer FileViewer.java

import java.io.*;  

public class FileViewer {  
    public static void main(String[] args) {  
        try (FileInputStream fis = new FileInputStream(args[0])) {  
            int data = 0;  
            while ((data = fis.read()) != -1) {  
                char c = (char) data;  
                System.out.print(c);  
            }  
        } catch (IOException e) {  
            e.printStackTrace();  
        }  
    }  
}

 

다음 코드는 FileInputStream과 FileOutputStream를 사용해서 커맨드 라인으로부터 입력받은 파일 FileCopy.java의 내용을 읽어서 그대로 FileCopy.bak로 복사를 수행한다.

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);  
            }  
        } catch (IOException e) {  
            e.printStackTrace();  
        }  
    }  
}
# 명령어
java FileCopy FileCopy.java FileCopy.bak
type FileCopy.bak

 

위처럼 텍스트 파일을 다루는 경우에는 FileInputStream과 FileOutputStream 보다 FileReader와 FileWriter를 사용하는 것이 좋다.


챰고 자료

  • 자바의 정석 (남궁성 지음)