1. DataInputStream과 DataOutputStream이 필요한 이유?
파일을 다룰 때는 단순한 텍스트가 아니라 구조화된 데이터(숫자, 불리언 값, 기본형 배열 등)를 저장해야 할 때가 있습니다. 예를 들어 간단한 게임을 만들고 사용자의 진행 상태를 저장한다고 해 봅시다. 점수(int), 현재 레벨(int), 플레이 시간(double), 승리 여부(boolean) 같은 것들입니다. 물론 이것을 텍스트로도 기록할 수 있습니다:
12345
5
67.5
true
하지만 이는 불편하고 안전하지 않습니다. 문자열을 파싱해야 하고, 형식이 달라질 수 있으며, 숫자는 더 많은 공간을 차지합니다.
핵심 아이디어는 데이터를 텍스트로 변환하지 않고 ‘원시’(바이너리) 형태로 파일에 기록하는 것입니다. 이를 위해 Java에는 두 가지 훌륭한 클래스가 있습니다:
- DataOutputStream — 기본형 타입을 스트림에 기록할 수 있습니다.
- DataInputStream — 스트림에서 기본형 타입을 읽을 수 있습니다.
이들은 일반 바이트 스트림(OutputStream/InputStream) 위에서 동작합니다. 즉, 단순히 바이트만 쓰는 것이 아니라, 그 바이트들로부터 int, double, boolean, 심지어 String까지 어떻게 구성하는지 ‘알고 있는’ 레이어입니다.
어떻게 동작할까?
일반적인 FileOutputStream은 컨베이어에 바이트를 하나씩 수동으로 올리는 것과 같습니다. 정수나 문자열을 기록하려면 각 요소가 몇 바이트를 차지하는지 직접 관리해야 합니다.
DataOutputStream은 이를 훨씬 편하게 해 줍니다. 컨베이어 위의 로봇처럼 동작하죠. “숫자를 기록해” 또는 “문자열을 기록해”라고 지시하면, 필요한 바이트 수로 데이터를 스스로 포장해 디스크로 보냅니다. 반대편 컨베이어에는 DataInputStream이라는 동일한 로봇이 있어 그 바이트들을 다시 원래 객체로 조립합니다.
왜 편리할까요? int, double, boolean의 바이트 수를 신경 쓸 필요가 없기 때문입니다. 데이터는 공간을 덜 차지하며, 빠르게 읽고 쓸 수 있고, 파싱 오류나 형식 문제 위험도 없습니다.
2. 예제: 기본형 쓰기와 읽기
가상의 게임 결과를 저장한다고 해 봅시다. 플레이어 이름(String), 점수(int), 최고 기록 시간(double), 승리 여부(boolean)입니다.
파일에 데이터 쓰기
import java.io.*;
public class SaveGameData {
public static void main(String[] args) {
String fileName = "savegame.bin";
String playerName = "Alice";
int score = 12345;
double recordTime = 67.5;
boolean isWinner = true;
try (DataOutputStream dos = new DataOutputStream(
new FileOutputStream(fileName))) {
dos.writeUTF(playerName); // 문자열 기록(UTF-8)
dos.writeInt(score); // int 기록(4바이트)
dos.writeDouble(recordTime); // double 기록(8바이트)
dos.writeBoolean(isWinner); // boolean 기록(1바이트)
System.out.println("데이터가 파일에 성공적으로 기록되었습니다!");
} catch (IOException e) {
System.out.println("쓰기 오류: " + e.getMessage());
}
}
}
- writeUTF(String) — 문자열을 UTF-8 형식으로 기록합니다(앞부분에 길이를 포함).
- writeInt(int) — 4바이트를 씁니다.
- writeDouble(double) — 8바이트를 씁니다.
- writeBoolean(boolean) — 1바이트를 씁니다(1 또는 0).
- 모든 메서드는 데이터를 필요한 형식으로 자동으로 ‘포장’합니다.
파일에서 데이터 읽기
import java.io.*;
public class LoadGameData {
public static void main(String[] args) {
String fileName = "savegame.bin";
try (DataInputStream dis = new DataInputStream(
new FileInputStream(fileName))) {
String playerName = dis.readUTF(); // 문자열 읽기
int score = dis.readInt(); // int 읽기
double recordTime = dis.readDouble(); // double 읽기
boolean isWinner = dis.readBoolean(); // boolean 읽기
System.out.println("플레이어 이름: " + playerName);
System.out.println("점수: " + score);
System.out.println("시간: " + recordTime);
System.out.println("승리 여부: " + isWinner);
} catch (IOException e) {
System.out.println("읽기 오류: " + e.getMessage());
}
}
}
중요:
읽기 순서는 쓰기 순서와 반드시 일치해야 합니다! 먼저 문자열, 그다음 int, 그다음 double을 썼다면 읽기도 같은 순서여야 합니다. 그렇지 않으면 오류가 나거나 데이터가 “엉망”이 됩니다.
3. 지원되는 타입은 무엇인가요?
DataOutputStream과 DataInputStream은 Java의 주요 기본형 타입을 모두 지원합니다:
| 쓰기 메서드 | 읽기 메서드 | 데이터 타입 | 크기(바이트) |
|---|---|---|---|
|
|
boolean | 1 |
|
|
byte | 1 |
|
|
short | 2 |
|
|
char | 2 |
|
|
int | 4 |
|
|
long | 8 |
|
|
float | 4 |
|
|
double | 8 |
|
|
String (UTF) | 가변 |
참고:
- 문자열에는 보통 writeUTF/readUTF를 사용합니다(문자열 길이와 UTF-8 바이트가 기록됨).
- 배열을 기록하려면 먼저 길이를 쓰고, 그다음 요소를 하나씩 쓰세요.
4. 고급 예제: 기본형 배열 저장하기
배열 쓰기
int[] scores = {100, 200, 300, 400, 500};
try (DataOutputStream dos = new DataOutputStream(
new FileOutputStream("scores.bin"))) {
dos.writeInt(scores.length); // 먼저 배열 길이를 기록
for (int score : scores) {
dos.writeInt(score); // 그런 다음 각 요소
}
}
배열 읽기
try (DataInputStream dis = new DataInputStream(
new FileInputStream("scores.bin"))) {
int length = dis.readInt(); // 길이 읽기
int[] scores = new int[length];
for (int i = 0; i < length; i++) {
scores[i] = dis.readInt(); // 요소 읽기
}
// 배열 출력
for (int score : scores) {
System.out.println(score);
}
}
왜 먼저 길이를 쓰나요?
읽을 때 몇 개의 숫자가 저장되어 있는지 알 수 없기 때문입니다. 앞부분에 길이를 기록해 두면 파일 형식 자체가 자기 설명적이 됩니다.
5. 중요한 뉘앙스와 특징
DataInputStream/DataOutputStream을 언제 사용하면 좋을까요?
- 기본형으로 구성된 구조화된 데이터를 저장/로드해야 할 때.
- Java 프로그램 사이(또는 형식을 알고 있다면 다른 언어와도)의 바이너리 데이터 교환이 필요할 때.
- 공간 절약과 속도가 중요한 경우(예: 로그, 계산 결과, 큰 크기의 숫자 배열).
사용하지 말아야 할 때:
- 사람이 읽기 쉬운 형식(CSV, JSON, XML)이 필요하다면 텍스트 형식을 사용하세요.
- 중첩된 복잡한 객체에는 ObjectOutputStream/ObjectInputStream을 통한 직렬화를 사용하는 것이 더 좋습니다(별도의 주제).
버퍼링
DataOutputStream과 DataInputStream 자체는 데이터를 버퍼링하지 않습니다. 큰 파일을 다룰 때 성능을 높이고 싶다면 BufferedOutputStream/BufferedInputStream으로 감싸세요:
try (DataOutputStream dos = new DataOutputStream(
new BufferedOutputStream(new FileOutputStream("data.bin")))) {
// ...
}
문자열 인코딩
writeUTF/readUTF 메서드는 특수 형식을 사용합니다. 먼저 문자열 길이(바이트)가 기록되고, 이어서 UTF-8로 인코딩된 내용이 저장됩니다. 단순히 바이트 배열을 쓰는 것과 혼동하지 마세요!
예외
읽기/쓰기 작업은 파일에 접근할 수 없거나, 손상되었거나, 예상보다 일찍 끝난 경우 IOException을 던질 수 있습니다. 기록된 양보다 더 읽으려고 하면 흔히 EOFException이 발생합니다. 항상 try-with-resources를 사용하거나 try-catch로 예외를 처리하세요.
읽기와 쓰기의 순서
가장 흔한 실수는 읽기와 쓰기의 순서가 일치하지 않는 것입니다. int, double, boolean 순서로 기록해 놓고, double, int, boolean 순서로 읽으면 잘못된 데이터가 나오거나 예외가 발생합니다.
6. 자주 발생하는 오류
오류 №1: 쓰기 및 읽기 순서 위반. 메서드의 순서를 바꾸면 데이터가 잘못 읽히거나 예외가 발생합니다. 예를 들어 먼저 문자열을 기록하고 그다음 숫자를 기록했는데, 읽을 때 숫자부터 읽으려 하면 형식 오류가 납니다.
오류 №2: 배열 길이를 기록하지 않음. 기본형 배열을 기록하면서 길이를 쓰지 않으면, 읽을 때 몇 개의 요소를 읽어야 하는지 알 수 없습니다. 이는 오류로 이어지거나 끝부분에 ‘불필요한’ 데이터가 남게 됩니다.
오류 №3: 파일 끝을 지나서 읽으려는 시도. 기록된 양보다 더 많이 읽으려 하면 EOFException(end of file)이 발생합니다.
오류 №4: 텍스트 파일에 DataInputStream/DataOutputStream을 사용함. 이 클래스들은 예를 들어 Notepad에서 만든 일반 텍스트 파일을 읽도록 설계되지 않았습니다. 그런 파일을 readInt()로 읽으려고 하면 무의미한 데이터나 오류가 발생합니다.
오류 №5: 스트림을 닫지 않음. try-with-resources를 사용하지 않거나 스트림을 수동으로 닫지 않으면, 파일이 다른 프로그램에서 사용할 수 없게 되거나 끝까지 저장되지 않을 수 있습니다.
GO TO FULL VERSION