I/O – 문자 다루기

핵심 원칙

Java I/O를 이해하는 데 필요한 단 하나의 대원칙이 있다. 모든 데이터는 반드시 바이트(byte)단위로 저장된다. 문자를 직접 저장할 수 없다. 이 원칙 하나를 확실히 이해하려면 이후 나오는 모든 내용이 자연스럽게 풀린다

문자 다루기 1 – 직접 인코딩/디코딩

스트림은 바이트만 다룰 수 있기 때문에, String을 파일에 저장하려면 개발자가 직접 변환 과정을 처리해야 한다

public class ReaderWriterMainV1 {
    public static void main(String[] args) throws IOException {
        String writeString = "ABC";

        // String → byte (인코딩)
        byte[] writeBytes = writeString.getBytes(UTF_8);
        System.out.println("write String: " + writeString);
        System.out.println("write bytes: " + Arrays.toString(writeBytes));
        // 출력: write bytes: [65, 66, 67]

        // 파일에 쓰기
        FileOutputStream fos = new FileOutputStream(FILE_NAME);
        fos.write(writeBytes);
        fos.close();

        // 파일에서 읽기
        FileInputStream fis = new FileInputStream(FILE_NAME);
        byte[] readBytes = fis.readAllBytes();
        fis.close();

        // byte → String (디코딩)
        String readString = new String(readBytes, UTF_8);
        System.out.println("read bytes: " + Arrays.toString(readBytes));
        System.out.println("read String: " + readString);
    }
}

write String: ABC
write bytes: [65, 66, 67]
read bytes: [65, 66, 67]
read String: ABC

핵심 API

  • String.getBytes(Charset): 문자열을 지정한 문자 집합으로 인코딩하여 byte[]로 변환
  • new String(byte[], Charset): byte[]를 지정한 문자 집합으로 디코딩하여 String으로 복원

텍스트 에디터에서 hello.txt를 열면 “ABC”로 보이는데, 파일에는 실제로 65, 66, 67이라는 바이트가 저장된 것이다. 에디터가 이 바이트는 UTF-8 문자 집합으로 디코딩해서 보여주는 것일 뿐이다.

이 방식은 정확하지만 번거롭다. 매번 인코딩/디코딩을 개발자가 직접 처리해야 하기 때문이다

문자 다루기 2 – OutputStreamWriter / InputStreamReader

이 번거로운 변환을 자동으로 처리해주는 클래스가 있다. BufferedXxx가 버퍼링을 대신 처리해줬던 것처럼, 문자 인코딩/디코딩을 대신 처리해주는 보조 스트림이다

public class ReaderWriterMainV2 {
    public static void main(String[] args) throws IOException {
        String writeString = "ABC";

        // 파일에 쓰기
        FileOutputStream fos = new FileOutputStream(FILE_NAME);
        OutputStreamWriter osw = new OutputStreamWriter(fos, UTF_8);
        osw.write(writeString); // String을 직접 전달
        osw.close();

        // 파일에서 읽기
        FileInputStream fis = new FileInputStream(FILE_NAME);
        InputStreamReader isr = new InputStreamReader(fis, UTF_8);

        StringBuilder content = new StringBuilder();
        int ch;
        while ((ch = isr.read()) != -1) {
            content.append((char) ch); // int → char 캐스팅 필요
        }
        isr.close();

        System.out.println("read String: " + content);
    }
}

write String: ABC
read String: ABC

동작 원리

  • OutputStreamWriter는 문자를 입력받아 지정한 문자 집합으로 인코딩한 뒤, 생성자에게 전달받은 OutputStream(여기서는 FileOutputStream)에 바이트를 전달한다. 개발자가 V1에서 직접 수행했던 인코딩 과정에서 대신 처리해주는 것이다
  • InputStreamReader는 반대로 동작한다. 내부에서 FileInputStream으로부터 바이트를 읽어 문자 집합으로 디코딩한 뒤 char로 반환한다
  • read()int를 반환하는 이유: 자바의 char 타입은 양수만 표현할 수 있어 EOF를 나타내는 -1을 표현할 수 없다. 그래서 int로 반환하고, 개발자가 (char)로 캐스팅해서 사용한다

문자 다루기 3 – Reader / Writer 계층 구조

자바 I/O는 처음부터 두 개의 독립적인 계층으로 설계되어 있다

바이트를 다루는 계층 (OutputStream / InputStream)

OutputStream                    InputStream
├── FileOutputStream             ├── FileInputStream
├── ByteArrayOutputStream        ├── ByteArrayInputStream
└── BufferedOutputStream         └── BufferedInputStream

문자를 다루는 계층 (Writer / Reader)

Writer                          Reader
├── OutputStreamWriter           ├── InputStreamReader
│   └── FileWriter               │   └── FileReader
└── BufferedWriter               └── BufferedReader

클래스 이름 끝에 Stream이 붙으면 바이트, Writer / Reader가 붙으면 문자를 다루는 클래스이다

Writer 계층은 write(String), write(char[]) 같이 문자를 직접 다루는 메서드를 제공한다. 하지만 내부적으로는 결국 문자를 바이트로 인코딩한 뒤 OutputStream을 통해 저장한다. 모든 저장은 바이트로 이루어진다

FileWriter / FileReader

OutputStreamWriter를 더 편리하게 사용할 수 있도록 감싼 클래스가 FileWriterFileReader이다

public class ReaderWriterMainV3 {
    public static void main(String[] args) throws IOException {
        String writeString = "ABC";

        // 파일에 쓰기
        FileWriter fw = new FileWriter(FILE_NAME, UTF_8);
        fw.write(writeString);
        fw.close();

        // 파일에서 읽기
        StringBuilder content = new StringBuilder();
        FileReader fr = new FileReader(FILE_NAME, UTF_8);
        int ch;
        while ((ch = fr.read()) != -1) {
            content.append((char) ch);
        }
        fr.close();

        System.out.println("read String: " + content);
    }
}

FileWriter의 실제 구현은 단순하다

public FileWriter(String fileName, Charset charset) throws IOException {
    super(new FileOutputStream(fileName), charset); // OutputStreamWriter 생성자 호출
}

FileWriterOutputStreamWriter를 상속하며, 생성자에서 FileOutputStream을 직접 생성해주는 것 외에 다른 기능은 없다. V2에서 개발자가 직접 작성했던 두 줄짜리 생성 코드를 한 줄로 줄여주는 것이다

// V2 방식 (두 줄)
FileOutputStream fos = new FileOutputStream(FILE_NAME);
OutputStreamWriter osw = new OutputStreamWriter(fos, UTF_8);

// V3 방식 (한 줄) - 내부 동작은 동일
FileWriter fw = new FileWriter(FILE_NAME, UTF_8);

겉보기에는 한 줄 차이지만, 복잡한 내부 구조를 숨기고 사용자에게 단순한 인터페이스를 제공한다는 점에서 좋은 설계의 좋은 예이다.

주의: 문자 집합을 생략하면 시스템의 기본 인코딩이 사용된다. 이식성 있는 코드를 위해 항상 명시적으로 저장하는 것을 권장한다

문자 다루기 4 – BufferedReader / BufferedWriter

Reader / Writer 계층에도 버퍼 보조 기능이 있다. BufferedReader는 버퍼링 기능에 대해 줄 단위 읽기라는 유용한 기능을 추가로 제공한다

public class ReaderWriterMainV4 {
    private static final int BUFFER_SIZE = 8192;

    public static void main(String[] args) throws IOException {
        String writeString = "ABC\n가나다";

        // 파일에 쓰기
        FileWriter fw = new FileWriter(FILE_NAME, UTF_8);
        BufferedWriter bw = new BufferedWriter(fw, BUFFER_SIZE);
        bw.write(writeString);
        bw.close();

        // 파일에서 읽기
        StringBuilder content = new StringBuilder();
        FileReader fr = new FileReader(FILE_NAME, UTF_8);
        BufferedReader br = new BufferedReader(fr, BUFFER_SIZE);

        String line;
        while ((line = br.readLine()) != null) { // null이 EOF
            content.append(line).append("\n");
        }
        br.close();

        System.out.println(content);
    }
}

ABC
가나다

readLine() 특징

  • 줄 단위 (\n 기준)으로 문자를 읽어 String으로 반환한다
  • 반환타입이 String이므로 EOF를 -1로 표현할 수 없다. 대신 null을 반환한다
  • 회원 정보, 로그 같이 줄 단위로 구성된 데이터를 처리할 때 매우 유용하다

기타 스트림

PrintStream

System.out의 실제 타입인 PrintStreamFileOutputStream과 조합하면 콘솔에 출력하던 코드를 그대로 파일에 출력할 수 있다

public class PrintStreamEtcMain {
    public static void main(String[] args) throws FileNotFoundException {
        FileOutputStream fos = new FileOutputStream("temp/print.txt");
        PrintStream printStream = new PrintStream(fos);

        printStream.println("hello java!");
        printStream.println(10);
        printStream.println(true);
        printStream.printf("hello %s", "world");
        printStream.close();
    }
}

hello java!
10
true
hello world

숫자, 불리언 등 모든 데이터 타입을 문자열로 변환한 후 인코딩하여 저장한다. 출력 대상만 FileOutputStream으로 바꿨을 뿐, 사용 방법은 System.out과 동일하다. 스트림 추상화의 위력을 잘 보여주는 예이다

DataOutputStream / DataInputStream

자바의 기본 데이터 타입을 그대로 저장하고 읽어야 할 때 사용한다

public class DataStreamEtcMain {
    public static void main(String[] args) throws IOException {
        // 쓰기
        FileOutputStream fos = new FileOutputStream("temp/data.dat");
        DataOutputStream dos = new DataOutputStream(fos);

        dos.writeUTF("회원A");      // UTF-8 문자열
        dos.writeInt(20);           // 4바이트 정수
        dos.writeDouble(10.5);      // 8바이트 실수
        dos.writeBoolean(true);     // 1바이트 불리언
        dos.close();

        // 읽기
        FileInputStream fis = new FileInputStream("temp/data.dat");
        DataInputStream dis = new DataInputStream(fis);

        System.out.println(dis.readUTF());      // 회원A
        System.out.println(dis.readInt());      // 20
        System.out.println(dis.readDouble());   // 10.5
        System.out.println(dis.readBoolean());  // true
        dis.close();
    }
}

회원A
20
10.5
true

반드시 저장한 순서대로 읽어야 한다. writeUTFwriteIntwriteDoublewriteBoolean 순으로 저장했다면, 읽을 때도 동일한 순서로 호출해댜 한다. int는 4바이트, double은 8바이트로 저장되는 등 각 타입별 바이트 크기가 다르기 때문에 순서가 어긋나면 잘못된 데이터를 읽게 된다

저장된 data.dat 파일을 텍스트 에디터로 열면 writeUTF()로 저장된 문자열 부분 외에는 알아볼 수 없는 문자료 표시된다. 나머지 타입들은 문자가 아닌 해당 타입의 바이트 표현 그대로 저장되기 때문이다

기본 스트립 vs 보조 스트림

구분특징예시
기본 스트림단독 사용 가능, 실제 I/O 수행FileInputStream, FileOutputStream, FileReader, FileWriter, ByteArrayInputStream, ByteArrayOutputStream
보조 스트림단독 사용 불가, 기본 스트림에 부가 기능 추가BufferedInputStream, BufferedOutputStream, InputStreamReader, OutputStreamWriter, DataOutputStream, DataInputStream, PrintStream

보조 스트림은 반드시 기본 스트림을 생성자에서 전달받아야 한다. 그리고 스트림을 연결해서 사용할 때는 항상 마지막에 연결한 보조 스트림을 닫아야 연쇄적으로 close()가 호출되어 모든 자원이 안전하게 정리된다

출처 – 김영한 님의 강의 중 김영한의 실전 자바 – 고급 2편, I/O, 네트워크, 리플렉션