Java Stream I/O 완전 정리 — 파일 읽고 쓰는 법
Java를 막 배우기 시작했을 때 I/O 파트가 특히 헷갈렸습니다. 클래스 이름이 너무 많은데다 비슷해 보이는 것들이 계층별로 쌓여 있어서, 뭘 언제 써야 하는지 감이 잘 안 왔습니다. FileInputStream이냐 FileReader냐, BufferedInputStream이냐 PrintStream이냐… 이름만 봐서는 차이를 알기 어렵더라고요.
이 글은 그 혼란을 정리한 기록입니다. File 클래스 기초부터 시작해서 바이트 스트림, 문자 스트림, try-with-resources 자원 관리, 그리고 객체 직렬화까지 실무에서 필요한 흐름을 한 번에 잡을 수 있도록 구성했습니다.
Stream의 기본 개념 — 왜 필요한가
Stream은 파일, 네트워크, 입출력 장치와 통신하기 위한 중간 매개체입니다. Java 코드로 파일을 읽거나 쓰는 명령을 작성해도, 실제 동작은 운영체제를 직접 호출하는 방식(native method)으로 처리됩니다. 그래서 Stream은 Java 코드와 운영체제 사이의 통로 역할을 합니다.
이 구조를 이해해두면 close()를 왜 반드시 호출해야 하는지, flush()가 왜 필요한지 자연스럽게 납득이 됩니다. 파일이나 네트워크 자원은 Java의 자원이 아니라 운영체제의 자원이고, 사용이 끝나면 운영체제에 반납해야 합니다.
File 클래스 vs Path 클래스 — 뭘 쓸까
File 클래스 기초
java.io.File은 파일을 생성·삭제하고 파일 정보를 조회하는 클래스입니다. 생성자는 세 가지가 있습니다.
File(String 파일경로)
File(String parent, String child)
File(File parent, String child)
파일 경로는 절대 경로와 상대 경로 두 가지 방식으로 지정할 수 있습니다.
- 절대 경로: 루트부터 시작하는 전체 경로. Windows는
\, 나머지 운영체제는/ - 상대 경로: 현재 디렉토리 기준.
./는 현재,../는 상위
절대 경로는 소스 코드에 직접 쓰지 않는 것이 좋습니다. 개발 환경과 운영 환경의 경로가 다를 경우 소스를 수정해야 하는 상황이 생기기 때문입니다. 절대 경로를 써야 한다면 별도 파일이나 데이터베이스에 저장해두고 불러서 쓰는 방식을 권장합니다.
파일 정보를 확인하는 기본 코드는 이렇습니다.
public static void main(String[] args) {
// 상대 경로로 파일 인스턴스 생성
File f = new File("./src/0.png");
// 파일 존재 여부 확인
boolean isExists = f.exists();
System.out.println("존재여부:" + isExists);
// 마지막 수정 시간 확인
long modify = f.lastModified();
Date date = new Date(modify);
System.out.println("마지막 수정시간:" + date);
// 파일 크기 확인
long size = f.length();
System.out.println("파일크기:" + size);
}
Path 클래스가 나온 이유
기존 File 클래스에는 두 가지 제약이 있었습니다.
- 파일 메타데이터와 심볼릭 링크(Windows 바로가기와 유사한 개념)를 취급할 수 없음
- 디렉토리 안에서 파일이 생성·수정·삭제되는 것을 감시하지 못함
이 문제를 해결하기 위해 java.nio.file.Path가 등장했습니다. 다만 기존 API들이 File 클래스 기반으로 많이 만들어져 있다는 게 Path의 단점이기도 합니다. 상호 변환은 toFile(), toURI() 메소드로 할 수 있습니다.
파일 복사를 Path로 처리하는 예제입니다.
public static void main(String[] args) {
Path path = Paths.get("./src/0.png");
Path to = Paths.get("./src/zero.png");
try {
Files.copy(path, to);
} catch (IOException e) {
e.printStackTrace();
}
}
Stream 분류 체계 — 방향과 데이터 종류로 나뉜다
Stream은 파일, 네트워크, 입출력 장치와 통신하기 위한 중간 매개체입니다. 분류 기준은 두 가지입니다.
| 분류 기준 | 종류 |
|---|---|
| 방향 | 입력 스트림 / 출력 스트림 |
| 데이터 | 바이트 스트림 (byte) / 문자 스트림 (char) |
중요한 포인트가 하나 있습니다. 문자 스트림 대신 바이트 스트림을 쓰는 것은 가능하지만 반대(바이트 스트림으로 문자 스트림 대체)는 안 됩니다. 바이트 스트림은 인코딩 변환 기능이 없어 한글 같은 멀티바이트 문자 처리 시 깨질 수 있기 때문입니다.
try-with-resources — 자원 관리의 정석
코드 예제를 보기 전에 자원 관리 방식을 먼저 짚어두겠습니다. 이후 모든 예제에서 이 패턴이 반복되기 때문입니다.
파일이나 네트워크 자원은 Java의 자원이 아니라 운영체제의 자원입니다. 사용이 끝나면 반드시 반납해야 합니다. 반납하지 않으면 운영체제가 그만큼의 자원을 사용할 수 없는 상태가 됩니다.
Java 1.7부터 도입된 try-with-resources 구문을 쓰면 close()를 명시적으로 호출하지 않아도 자동으로 자원을 반납합니다. AutoCloseable 인터페이스가 구현된 클래스라면 모두 이 방식을 쓸 수 있습니다.
try(
FileOutputStream fos = new FileOutputStream("./file.dat");
FileInputStream fis = new FileInputStream("./file.dat");
){
// 자원 사용
} catch(Exception e) {
// 예외 처리
}
// try 블록을 벗어나면 fos, fis가 자동으로 close됩니다
try 안에 여러 자원을 나열할 수 있고, 블록이 끝나면 선언 역순으로 close()가 호출됩니다.
바이트 스트림 — InputStream / OutputStream
InputStream 주요 메소드
InputStream은 바이트 단위로 읽어올 때 쓰는 추상 클래스입니다. 실제로 인스턴스를 만들어 쓰는 건 아니고, 공통 메소드를 정의해놓은 최상위 클래스입니다.
int available() // 읽을 수 있는 바이트 수 리턴
void close() // 연결 해제
int read() // 한 바이트 읽어서 리턴, 없으면 -1
int read(byte []) // 배열만큼 읽고 읽은 바이트 수 리턴, 없으면 -1
int read(byte [], int start, int len) // start부터 len만큼 읽기
OutputStream 주요 메소드
void close() // 연결 해제
void write(int n) // 1바이트 기록
void write(byte [] b) // 배열 전체 기록
void write(byte [] b, int start, int len) // start부터 len만큼 기록
void flush() // 버퍼에 남은 내용을 강제로 기록
출력 시 데이터는 버퍼에 먼저 쌓였다가 버퍼가 차면 실제로 기록됩니다. 마지막에 버퍼에 내용이 남아있는데 기록이 안 되는 현상이 생길 수 있습니다. flush()를 호출하면 남아있는 내용을 모두 기록합니다.
FileInputStream / FileOutputStream 실전 예제
파일에 문자열을 기록하고 분할해서 읽어오는 코드입니다.
public static void main(String[] args) {
try(
// append 모드: 두 번째 매개변수 true로 설정하면 이어쓰기
FileOutputStream fos = new FileOutputStream("./file.dat", true);
FileInputStream fis = new FileInputStream("./file.dat");
){
String msg = "안녕하세요 반갑습니다.";
byte [] b = msg.getBytes();
fos.write(b);
fos.flush();
// 크기가 큰 경우 한 번에 읽으면 메모리 부족이 생길 수 있습니다
// 나눠서 읽는 것이 효율적입니다. 크기는 8의 배수로 생성하는 것이 일반적
byte [] split = new byte[8];
while(true) {
int r = fis.read(split);
if(r <= 0) {
break;
}
// 배열 전체가 아니라 읽은 개수만큼만 사용해야 합니다
String str = new String(split, 0, r);
System.out.println(str);
}
} catch(Exception e) {
System.out.println("예외내용:" + e.getMessage());
}
}
FileOutputStream을 생성할 때 두 번째 매개변수를 true로 주면 파일이 이미 있을 경우 이어쓰기를 합니다. 생략하거나 false면 파일을 새로 생성합니다.
버퍼 스트림 — BufferedInputStream / PrintStream
입출력 명령은 내부적으로 운영체제의 native method를 호출합니다(입출력 명령은 운영체제를 직접 호출하는 방식으로 처리됩니다). 너무 잦은 호출은 애플리케이션 성능을 떨어뜨릴 수 있어서, 명령을 버퍼에 모아서 한꺼번에 처리하는 방식이 효율적인 경우가 있습니다.
BufferedInputStream, PrintStream은 다른 스트림을 매개변수로 받아서 버퍼 기능을 추가하는 구조입니다. PrintStream은 println() 같은 편의 메소드를 제공한다는 장점도 있습니다.
public class BufferInputOutput {
public static void main(String[] args) {
try(PrintStream ps = new PrintStream(new FileOutputStream("./buf.dat"));
BufferedInputStream bis = new BufferedInputStream(
new FileInputStream("./buf.dat"));
){
ps.println("Hello Buffered Stream");
ps.flush();
int len = bis.available();
byte [] b = new byte[len];
bis.read(b);
System.out.println(new String(b));
} catch(Exception e) {
System.out.println("예외:" + e.getMessage());
}
}
}
문자 스트림 — Reader / Writer
텍스트 파일을 다루거나 네트워크로 문자열을 주고받을 때는 문자 스트림을 씁니다. 바이트 스트림과 구조는 동일하지만 단위가 char입니다.
| 역할 | 클래스 |
|---|---|
| 문자 읽기 (추상) | Reader |
| 문자 쓰기 (추상) | Writer |
| 파일에서 문자 읽기 | FileReader |
| 파일에 문자 쓰기 | FileWriter |
| 버퍼로 문자 읽기 | BufferedReader |
| 버퍼로 문자 쓰기 | PrintWriter |
BufferedReader는 readLine() 메소드를 제공합니다. 줄 단위로 읽어서 String으로 리턴하고, 더 읽을 내용이 없으면 null을 리턴합니다.
실전 파일 읽기/쓰기 예제입니다.
public class CharacterStream {
public static void main(String[] args) {
try(
PrintWriter pw = new PrintWriter("./data.txt");
BufferedReader br = new BufferedReader(
new FileReader("./data.txt"));
){
pw.println("안녕하세요 반갑습니다.");
pw.println("내일 모레면 설날입니다.");
while(true) {
String line = br.readLine();
if(line == null) {
break;
}
System.out.println(line);
}
} catch(Exception e) {
System.out.println("예외:" + e.getMessage());
}
}
}
BufferedReader는 키보드 입력이나 소켓 읽기에도 활용됩니다.
// 키보드에서 읽기
new BufferedReader(new InputStreamReader(System.in));
// 소켓에서 읽기
new BufferedReader(new InputStreamReader(socket.getInputStream()));
Serializable — 객체를 파일에 저장하는 법
파일 입출력의 기본 단위는 byte나 char입니다. 그런데 실제 프로그램에서 다루는 데이터는 int, double 같은 기본형이나 직접 만든 클래스의 인스턴스인 경우가 많습니다.
이런 데이터를 파일에 저장하려면 byte나 char로 변환해야 하는데, 이 과정을 자동으로 처리해주는 것이 직렬화(Serialization)입니다.
직렬화를 사용하려면:
- 저장할 클래스에
Serializable인터페이스를implements ObjectOutputStream.writeObject()로 기록ObjectInputStream.readObject()로 읽기
메소드를 직접 구현할 필요 없이 implements Serializable만 선언하면 됩니다.
public class Unit implements Serializable {
private static final long serialVersionUID = 1L;
private int num;
private String name;
private int offence;
private int deffence;
private int level;
public Unit() { super(); }
public Unit(int num, String name, int offence, int deffence, int level) {
super();
this.num = num;
this.name = name;
this.offence = offence;
this.deffence = deffence;
this.level = level;
}
// getter/setter 생략
public void setLevel(int level) {
this.level = level;
offence = offence + level * 10;
}
@Override
public String toString() {
return "Unit [num=" + num + ", name=" + name + ", offence=" + offence
+ ", deffence=" + deffence + ", level=" + level + "]";
}
}
serialVersionUID = 1L은 직렬화 버전 ID입니다. Serializable을 구현한 클래스를 상속받을 때 이 ID를 만들라는 경고가 발생하는 경우가 있는데, 명시적으로 선언해두면 해결됩니다.
ArrayList 같은 자료구조 클래스는 이미 Serializable이 구현되어 있어서, 리스트 단위로 저장하고 읽어오는 것도 가능합니다.
public class SerializableMain {
public static void main(String [] args) {
try(ObjectOutputStream oos = new ObjectOutputStream(
new FileOutputStream("./star.dat"));
ObjectInputStream ois = new ObjectInputStream(
new FileInputStream("./star.dat"));
){
Unit unit = new Unit(1, "탱크", 10, 10, 0);
Unit unit1 = new Unit(2, "마린", 5, 5, 0);
ArrayList<Unit> list = new ArrayList<Unit>();
list.add(unit);
list.add(unit1);
oos.writeObject(list);
// List 단위로 읽어오기
ArrayList<Unit> read = (ArrayList<Unit>)ois.readObject();
for(Unit u : read) {
System.out.println(u);
}
} catch(Exception e) {
System.out.println("예외:" + e.getMessage());
}
}
}
한 가지 주의할 점이 있습니다. 직렬화로 저장한 데이터는 저장할 때 사용한 클래스가 없으면 읽어낼 수 없습니다. 응용프로그램 간 파일을 주고받거나 데이터 통신을 할 때 직렬화를 고려해야 하는 이유입니다.
RandomAccessFile — 읽고 쓰기가 모두 필요할 때
일반 스트림은 파일을 한 방향으로만 읽거나 씁니다. 한 번 읽은 데이터를 다시 읽으려면 스트림을 다시 생성해야 합니다.
RandomAccessFile은 읽기와 쓰기가 모두 가능하고, seek() 메소드로 파일 포인터를 원하는 위치로 이동해서 재읽기도 할 수 있습니다.
모드는 네 가지입니다.
| 모드 | 설명 |
|---|---|
r | 읽기 전용 |
rw | 읽기 + 쓰기 |
rws | 쓰기 데이터 즉시 반영, 파일 정보도 즉시 갱신 |
rwd | 쓰기 데이터 즉시 반영, 파일 정보는 나중에 갱신 |
앞에서 5바이트를 두 번 읽는 예제입니다.
public class RandomFileMain {
public static void main(String[] args) {
try(
RandomAccessFile f = new RandomAccessFile("./random.txt", "rw");
){
String msg = "Hello Random Access File";
f.write(msg.getBytes());
// 파일 포인터를 처음으로 이동
f.seek(0);
byte [] b = new byte[5];
f.read(b);
System.out.println(new String(b)); // "Hello"
// 파일 포인터를 다시 처음으로 이동해서 재읽기
f.seek(0);
b = new byte[5];
f.read(b);
System.out.println(new String(b)); // "Hello"
} catch(Exception e) {
System.out.println("예외:" + e.getMessage());
}
}
}
실무에서 어떤 Stream을 쓸지 판단 기준
학습하다 보면 클래스가 너무 많아서 선택이 막막하게 느껴지는 순간이 있습니다. 아래 기준으로 정리해두면 조금 편합니다.
| 상황 | 추천 |
|---|---|
| 바이너리 파일 (이미지, PDF 등) | FileInputStream / FileOutputStream |
| 텍스트 파일 읽기 | BufferedReader(new FileReader(...)) |
| 텍스트 파일 쓰기 | PrintWriter |
| 네트워크 문자열 통신 | BufferedReader(new InputStreamReader(socket.getInputStream())) |
| 객체를 파일에 저장 | ObjectOutputStream + Serializable |
| 파일 포인터 이동 필요 | RandomAccessFile |
| 성능이 중요한 대용량 처리 | BufferedInputStream / BufferedOutputStream 래핑 |
어떤 스트림이든 공통적으로 중요한 것은 두 가지입니다. 사용 후 반드시 close()(또는 try-with-resources)로 자원을 반납할 것, 그리고 OutputStream계열은 마지막에 flush()를 호출해서 버퍼를 비워줄 것.
Stream I/O는 처음 접할 때 클래스 계층이 복잡해 보이지만, 실제로 쓰다 보면 반복되는 패턴이 있습니다. 직접 코드를 짜고 디버깅해보는 것이 가장 빠른 방법이었습니다.
이 글은 2020년 12월 Java 학습 과정에서 정리한 내용을 기반으로 작성되었습니다.