자바 I/O 스트림: 파일 데이터 처리의 기초

자바 애플리케이션에서 파일이나 네트워크를 통해 데이터를 교환하는 것은 매우 흔한 작업입니다. 이러한 데이터 교환은 '입력(Input)'과 '출력(Output)'이라는 두 가지 주요 흐름을 통해 이루어지며, 자바에서는 이를 I/O(Input/Output) 스트림이라는 개념으로 추상화합니다. I/O 스트림은 데이터의 원천(Source)과 목적지(Destination)를 연결하는 파이프라인과 같다고 이해할 수 있습니다. 데이터를 읽고 쓰는 기본적인 방법을 제공하며, java.io 패키지에 관련 클래스들이 모여 있습니다.

I/O 스트림의 분류

자바의 I/O 스트림은 크게 두 가지 기준에 따라 분류할 수 있습니다.

1. 데이터 흐름 방향에 따른 분류

  • 입력 스트림 (Input Stream): 외부 소스(파일, 네트워크 등)로부터 데이터를 읽어 애플리케이션으로 가져오는 데 사용됩니다. 데이터를 '소비'하는 역할을 합니다.
  • 출력 스트림 (Output Stream): 애플리케이션의 데이터를 외부 목적지(파일, 네트워크 등)로 내보내는 데 사용됩니다. 데이터를 '생산'하는 역할을 합니다.

2. 처리하는 데이터 단위에 따른 분류

  • 바이트 스트림 (Byte Stream): 8비트 바이트 단위로 데이터를 처리합니다. 모든 종류의 데이터(이미지, 오디오, 동영상, 텍스트 파일 등)를 처리할 수 있는 가장 기본적인 형태의 스트림입니다. 주로 InputStreamOutputStream 추상 클래스를 상속하는 클래스들이 이에 해당합니다.
  • 문자 스트림 (Character Stream): 16비트 유니코드 문자 단위로 데이터를 처리합니다. 주로 텍스트 파일과 같이 문자 데이터를 다룰 때 사용되며, 내부적으로 문자 인코딩을 처리해줍니다. ReaderWriter 추상 클래스를 상속하는 클래스들이 이에 해당합니다.

바이트 스트림 (Byte Stream)

바이트 스트림은 모든 종류의 바이너리 데이터를 처리할 수 있는 범용적인 스트림입니다. 파일에서 바이트 데이터를 읽거나 쓰는 방법을 살펴보겠습니다.

1. 바이트 출력 스트림: FileOutputStream

FileOutputStream은 파일에 바이트 단위로 데이터를 쓰는 데 사용되는 스트림입니다. 파일을 생성하거나 기존 파일에 데이터를 덮어쓰거나 추가할 수 있습니다.

import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets; // 문자열을 바이트로 변환하기 위해 추가

public class SimpleByteWriter {
    public static void main(String[] args) {
        // try-with-resources 문을 사용하여 스트림을 자동으로 닫습니다.
        try (FileOutputStream fileOut = new FileOutputStream("output.bin")) {
            // 단일 바이트 쓰기 (ASCII 'A')
            fileOut.write(65); // 65는 'A'의 ASCII 값

            // 바이트 배열 쓰기
            byte[] dataBytes = {70, 71, 72}; // 'F', 'G', 'H'
            fileOut.write(dataBytes);

            // 문자열을 바이트로 변환하여 쓰기 (UTF-8 인코딩)
            String message = "Hello, Java I/O!";
            byte[] messageBytes = message.getBytes(StandardCharsets.UTF_8);
            fileOut.write(messageBytes);

            System.out.println("데이터가 output.bin 파일에 성공적으로 기록되었습니다.");

        } catch (IOException e) {
            System.err.println("파일 쓰기 중 오류 발생: " + e.getMessage());
            e.printStackTrace();
        }
    }
}

FileOutputStream 사용 시 고려사항:

  • 파일 생성 및 덮어쓰기: FileOutputStream("파일경로") 생성자는 지정된 경로에 파일이 없으면 새로 생성하고, 파일이 이미 존재하면 그 내용을 모두 지우고 새로 씁니다.
  • 이어쓰기 (Append): 기존 파일의 내용을 유지한 채 데이터를 추가하려면 FileOutputStream("파일경로", true)와 같이 두 번째 인자로 true를 전달합니다.
  • 줄바꿈: 운영체제마다 줄바꿈 문자가 다릅니다. Windows는 \r\n, Unix/Linux는 \n, macOS는 \r을 사용합니다. 자바에서 플랫폼 독립적인 줄바꿈을 원한다면 System.lineSeparator()를 사용하는 것이 좋습니다.
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

public class AdvancedByteWriter {
    public static void main(String[] args) {
        String filePath = "append_output.txt";
        
        try (FileOutputStream fileOut = new FileOutputStream(filePath, true)) { // true로 이어쓰기 모드 활성화
            String firstLine = "첫 번째 라인.\n"; // Unix/Linux 스타일 줄바꿈
            fileOut.write(firstLine.getBytes(StandardCharsets.UTF_8));

            String secondLine = "두 번째 라인입니다.";
            fileOut.write(secondLine.getBytes(StandardCharsets.UTF_8));
            fileOut.write(System.lineSeparator().getBytes(StandardCharsets.UTF_8)); // 플랫폼 독립적인 줄바꿈 추가

            String thirdLine = "세 번째 라인 (이어쓰기).";
            fileOut.write(thirdLine.getBytes(StandardCharsets.UTF_8));
            fileOut.write(System.lineSeparator().getBytes(StandardCharsets.UTF_8)); // 줄바꿈 추가

            System.out.println("데이터가 " + filePath + " 파일에 성공적으로 이어쓰기 되었습니다.");

        } catch (IOException e) {
            System.err.println("파일 이어쓰기 중 오류 발생: " + e.getMessage());
            e.printStackTrace();
        }
    }
}

2. 바이트 입력 스트림: FileInputStream

FileInputStream은 파일에서 바이트 단위로 데이터를 읽는 데 사용됩니다. 존재하지 않는 파일을 읽으려고 하면 FileNotFoundException이 발생합니다.

import java.io.FileInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

public class SimpleByteReader {
    public static void main(String[] args) {
        String filePath = "output.bin"; // SimpleByteWriter가 생성한 파일을 읽음
        // try-with-resources 문을 사용하여 스트림을 자동으로 닫습니다.
        try (FileInputStream fileIn = new FileInputStream(filePath)) {
            int byteRead;
            System.out.print("읽어온 바이트 데이터: ");
            while ((byteRead = fileIn.read()) != -1) { // 파일 끝(-1)까지 한 바이트씩 읽음
                System.out.print((char) byteRead); // 바이트를 문자로 변환하여 출력 (ASCII 가정)
            }
            System.out.println("\n파일 읽기 완료.");

        } catch (IOException e) {
            System.err.println("파일 읽기 중 오류 발생: " + e.getMessage());
            e.printStackTrace();
        }
    }
}

효율적인 바이트 읽기: 버퍼 사용

위 예시처럼 한 바이트씩 읽는 것은 큰 파일을 처리할 때 비효율적입니다. 성능을 향상시키기 위해 여러 바이트를 한 번에 읽을 수 있도록 바이트 배열(버퍼)을 사용하는 것이 일반적입니다.

import java.io.FileInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

public class BufferedByteReader {
    public static void main(String[] args) {
        String filePath = "append_output.txt"; // AdvancedByteWriter가 생성한 파일을 읽음
        // try-with-resources 문을 사용하여 스트림을 자동으로 닫습니다.
        try (FileInputStream fileIn = new FileInputStream(filePath)) {
            byte[] buffer = new byte[1024]; // 1KB 버퍼 생성
            int bytesRead; // 실제로 읽은 바이트 수

            System.out.println("버퍼로 읽어온 데이터:");
            while ((bytesRead = fileIn.read(buffer)) != -1) {
                // 읽어온 바이트 배열의 특정 범위만 사용하여 문자열 생성
                // 이렇게 해야 버퍼에 남은 이전 데이터가 출력되는 것을 방지합니다.
                System.out.print(new String(buffer, 0, bytesRead, StandardCharsets.UTF_8));
            }
            System.out.println("\n버퍼 파일 읽기 완료.");

        } catch (IOException e) {
            System.err.println("버퍼 파일 읽기 중 오류 발생: " + e.getMessage());
            e.printStackTrace();
        }
    }
}

new String(buffer, 0, bytesRead, StandardCharsets.UTF_8)를 사용하는 이유는 read(byte[] buffer) 메서드가 항상 버퍼를 완전히 채우지 않을 수 있기 때문입니다. 특히 파일의 마지막 부분에서 남은 데이터를 모두 읽더라도 버퍼의 크기보다 작을 수 있으며, 이 경우 bytesRead만큼만 유효한 데이터이므로 정확한 처리를 위해 이 오버로딩된 생성자를 사용합니다.

문자 인코딩과 디코딩

텍스트 데이터를 처리할 때 가장 중요한 개념 중 하나는 문자 인코딩(Encoding)과 디코딩(Decoding)입니다. 컴퓨터는 모든 데이터를 숫자로 처리하므로, 문자를 저장하고 전송하기 위해서는 특정 규칙에 따라 숫자로 변환하는 과정(인코딩)이 필요합니다. 반대로 숫자를 다시 문자로 변환하는 과정은 디코딩입니다.

  • ASCII: 7비트로 영문 알파벳, 숫자, 특수 문자를 표현합니다.
  • GBK/EUC-KR: 주로 아시아 언어(중국어, 한국어 등)를 표현하기 위해 사용됩니다. 한글 한 글자는 2바이트로 표현되는 경우가 많습니다.
  • Unicode (UTF-8, UTF-16): 전 세계의 모든 문자를 표현할 수 있도록 설계된 표준입니다. UTF-8은 가변 길이 인코딩으로, 영문은 1바이트, 한글은 3바이트로 표현되는 것이 일반적입니다.

인코딩과 디코딩 방식이 일치하지 않으면 '깨진 문자(Garbled Text)' 또는 '외계어' 현상이 발생합니다. 예를 들어, UTF-8로 인코딩된 파일을 EUC-KR로 디코딩하려고 하면 문자가 올바르게 표시되지 않습니다. 이러한 문제를 해결하기 위해 자바에서는 텍스트 파일 처리에 문자 스트림을 제공합니다.

문자 스트림 (Character Stream)

문자 스트림은 텍스트 데이터를 효율적이고 안전하게 처리하기 위해 설계되었습니다. 내부적으로 인코딩/디코딩 과정을 처리해주므로 개발자가 직접 바이트-문자 변환에 신경 쓸 필요가 줄어듭니다.

1. 문자 입력 스트림: FileReader

FileReader는 파일에서 문자 단위로 데이터를 읽는 데 사용됩니다. 시스템의 기본 문자 인코딩을 사용하여 파일을 읽습니다. (따라서 특정 인코딩을 지정하려면 InputStreamReader를 사용하는 것이 더 강력합니다.)

import java.io.FileReader;
import java.io.IOException;

public class SimpleCharReader {
    public static void main(String[] args) {
        String filePath = "korean_text.txt";
        // 이 파일은 미리 UTF-8로 저장된 한글 텍스트라고 가정합니다.
        // 예를 들어: "안녕하세요, 자바 I/O 스트림입니다."

        try (FileReader charReader = new FileReader(filePath)) {
            int charCode;
            System.out.println("읽어온 문자 데이터:");
            while ((charCode = charReader.read()) != -1) {
                System.out.print((char) charCode); // charCode를 문자로 변환하여 출력
            }
            System.out.println("\n문자 파일 읽기 완료.");

        } catch (IOException e) {
            System.err.println("문자 파일 읽기 중 오류 발생: " + e.getMessage());
            e.printStackTrace();
        }
    }
}

효율적인 문자 읽기: 문자 배열 버퍼 사용

바이트 스트림과 마찬가지로, 문자 스트림에서도 문자 배열(버퍼)을 사용하여 한 번에 여러 문자를 읽는 것이 효율적입니다.

import java.io.FileReader;
import java.io.IOException;

public class BufferedCharReader {
    public static void main(String[] args) {
        String filePath = "korean_text.txt";

        try (FileReader charReader = new FileReader(filePath)) {
            char[] charBuffer = new char[512]; // 512 문자 크기의 버퍼
            int charsRead; // 실제로 읽은 문자 수

            System.out.println("버퍼로 읽어온 문자 데이터:");
            while ((charsRead = charReader.read(charBuffer)) != -1) {
                // 읽어온 문자 배열의 특정 범위만 사용하여 문자열 생성
                System.out.print(new String(charBuffer, 0, charsRead));
            }
            System.out.println("\n버퍼 문자 파일 읽기 완료.");

        } catch (IOException e) {
            System.err.println("버퍼 문자 파일 읽기 중 오류 발생: " + e.getMessage());
            e.printStackTrace();
        }
    }
}

2. 문자 출력 스트림: FileWriter

FileWriter는 파일에 문자 단위로 데이터를 쓰는 데 사용됩니다. FileOutputStream과 유사하게, 파일 생성, 덮어쓰기, 이어쓰기 기능을 제공합니다.

import java.io.FileWriter;
import java.io.IOException;

public class SimpleCharWriter {
    public static void main(String[] args) {
        String filePath = "written_text.txt";

        try (FileWriter charWriter = new FileWriter(filePath, true)) { // 이어쓰기 모드
            charWriter.write("한글 텍스트를 작성합니다.\n");
            charWriter.write("두 번째 줄입니다.\n");
            charWriter.write("Java 문자 스트림 좋아요.");
            charWriter.flush(); // 버퍼에 있는 데이터를 즉시 파일에 씁니다.

            System.out.println("문자 데이터가 " + filePath + " 파일에 성공적으로 기록되었습니다.");

        } catch (IOException e) {
            System.err.println("문자 파일 쓰기 중 오류 발생: " + e.getMessage());
            e.printStackTrace();
        }
    }
}

flush() 메서드는 버퍼에 저장된 모든 데이터를 대상 스트림으로 강제로 내보내는 역할을 합니다. close() 메서드를 호출하면 자동으로 flush()가 호출되지만, 데이터의 즉각적인 기록이 필요할 때 명시적으로 호출할 수 있습니다.

태그: java IO Stream FileInputStream FileOutputStream FileReader

7월 23일 04:55에 게시됨