I/O 스트림이란?
자바가 가진 데이터를 외부의 파일로 보내거나, 외부의 파일을 자바 내부 안으로 가져올 때, 모두 byte 단위를 기본으로 사용하며, 자바 프로그램은 혼자서 아무것도 읽거나 쓸 수 없기 때문에, 항상 I/O 스트림을 이용해야 합니다. 그렇다면 I/O 스트림이란 무엇일까요? I/O 스트림이란 InputStream, OutputStream 즉 입출력 스트림을 의미하며, 각 스트림은 단방향으로 흐릅니다.
즉 I/O스트림은 프로그램이 외부와 데이터를 주고받기 위한 통로이며, Input은 외부-> 프로그램, Output은 프로그램 -> 외부로 생각하시면 됩니다. 여기서 외부는 파일, 네트워크, 메모리 등을 뜻합니다.
자바 프로세스가 가지고 있는 데이터를 밖으로 보내려면 출력 스트림을, 외부 데이터를 자바 프로세스 안으로 가져오려면 입력 스트림으로 사용하면 됩니다.

Byte를 다루는 스트림 클래스 표
| 클래스 | 읽기/쓰기 | 처리 단위 | 대상 |
| InputStream | 읽기 | 바이트 | 파일, 키보드 |
| OutputStream | 쓰기 | 바이트 | 파일 |
| FileinputStream | 읽기 | 바이트 | 파일 |
| FileOutputStream | 쓰기 | 바이트 | 파일 |
| Reader | 읽기 | 문자 | 텍스트 |
| Writer | 쓰기 | 문자 | 텍스트 |
| FileReader | 읽기 | 문자 | 파일 |
| FileWriter | 쓰기 | 문자 | 파일 |
| InputStreamReader | 입력 변환기 | 바이트 -> 문자 | |
| OutputStreamWriter | 출력 변환기 | 문자 -> 바이트 |
바이트 스트림(ByteStream)
자바에서 스트림은 데이터를 어떤 단위로 다루느냐에 따라 두 가지로 나뉩니다. 먼저 바이트 스트림은 1바이트(8비트) 단위로 데이터를 처리하는 스트림입니다. 문자뿐 아니라 이미지, 영상 등 다양한 데이터를 바이트 단위로 다뤄 손상 없이 처리할 수 있습니다. 또한 문자 인코딩을 고려하지 않으므로, 문자 데이터를 다룰 때는 별도의 인코딩 변환이 필요합니다.
클래스 이름이 대부분 Stream으로 끝나며, 대표적으로 InputStream과 OutputStream이 있습니다.
*인코딩 : 문자 -> 숫자 / 디코딩 : 숫자 -> 문자
| 클래스 | 읽기/쓰기 | 처리 단위 | 대상 |
| InputStream | 읽기 | 바이트 | 파일, 키보드 |
| OutputStream | 쓰기 | 바이트 | 파일 |
| FileinputStream | 읽기 | 바이트 | 파일 |
| FileOutputStream | 쓰기 | 바이트 | 파일 |
OutputStream
OutputStream은 바이트 단위로 데이터를 출력하는 스트림의 최상위 추상 클래스입니다. 파일, 네트워크, 메모리 등 외부 대상에 데이터를 내보낼 때 사용 합니다. 1바이트씩 데이터를 출력하며 문자, 숫자, 이미지 등 모든 종류의 데이터 처리가 가능합니다. 추상 클래스 이기에 반드시 구현 클래스를 사용해야 합니다.

간단한 예시코드와 함께 보겠습니다.
public static void main(String[] args) throws IOException { //예외 처리
OutputStream os = new FileOutputStream(FILE_NAME);
os.write(65); // 'A'
os.write(66); // 'B'
os.write(67); // 'C'
os.close();
}
ABC
65,66,67을 출력한 후 파일을 열어 보면 파일에는 65~67이 아닌 문자 A~C이 보이게 됩니다. 왜 이런 현상이 발생할까요?
(FILE_NAME은 txt파일입니다)
먼저 OutputStream을 이용해 파일에 저장된 데이터는 65~67이라는 숫자, 즉 '바이트' 값입니다. 하지만 저희가 사용하는 메모장이나 VS Code같은 텍스트 편집기는 이 바이트들을 ASCII코드 또는 UTF-8 인코딩 기준으로 해석하여 문자로 표시합니다.
즉 텍스트 파일의 텍스트 에디터가 저장된 바이트를 문자로 자동으로 해석해 보여주었으며, 이러한 과정은 변환이 아닌 해석이기에 실제 파일에 저장된 내용은 변경되지 않습니다.
InputStream
InputStream은 외부에 저장된 바이트 데이터를 프로그램 내부로 읽어오는 역할을 합니다.

public static void main(String[] args) throws IOException {
InputStream is = new FileInputStream(FILE_NAME);
int data;
while ((data = is.read()) != -1) {
System.out.println(data);
}
is.close();
}
65
66
67
위의 코드에서는 A,B,C가 아닌 65,66,67이 콘솔창에 출력 됩니다. 파일에 저장된 65 ~ 67이 그대로 숫자로 출력된 이유는 InputStream.read() 메서드가 바이트를 문자로 변환하지 않고 해당 바이트 값을 정수(int)로 반환하기 때문입니다.
여기서 read()는 항상 0~255 범위의 값을 반환하며, 더 읽을 데이터가 없으면 -1(EOF)을 반환합니다.
*EOF는 End Of File이라는 의미로, 더 이상 읽을 데이터가 없다는 신호 입니다.
그런데 여기서 궁금한 점이 몇 가지 생깁니다. FileInputStream으로 객체를 생성하고, read()의 int형을 반환받는 변수를 삭제 하고, 바로 출력하면 안되는 걸까요?
public static void main(String[] args) throws IOException {
FileInputStream fis = new FileInputStream("output.txt");
while (fis.read() != -1) {
System.out.println(fis.read());
}
fis.close();
}
66
-1
위의 코드처럼 입력을 했을 때, 원하는 데이터를 제대로 입력받지 못했습니다. 그 이유는 뭘까요?
먼저 InputStream으로 선언한 이유는 다형성 때문이며, 이는 데이터 손실과는 무관 합니다.
FileInputStream은 파일만 읽을 수 있지만, InputStream은 파일, 네트워크 ,메모리 ,버퍼 등 입력 대상에 의존하지 않습니다.
또한 추후 성능 향상을 위해 BufferedStream 같은 보조 스트림을 추가할 때 선언부를 변경하지 않고 생성자만 수정하면 되므로 편리합니다. 그래서 객체는 구체적으로 생성하고, 변수는 추상적으로 선언하는 것이 좋습니다.
InputStream is =
new BufferedInputStream(new FileInputStream("output.txt"));
그렇다면 read() 메서드의 반환값을 변수에 저장하지 않아서 데이터 손실 문제가 발생했다는 뜻인데, 그 이유는 뭘까요?
그 이유는 read()는 호출될 때마다 데이터를 한 바이트씩 읽고 소비하기 때문입니다.
즉 InputStream.read()는 단순히 값을 확인하는 메서드가 아니라, 호출하는 순간 데이터를 읽고 다음 위치로 이동하기에,
그 결과를 반드시 변수에 저장해 재사용 해야 합니다.
while (fis.read() != -1) { //65 조건 확인
System.out.println(fis.read()); // 66 출력
}
데이터 읽기 방식
바이트스트림(Byte Stream)은 1바이트씩 데이터의 입출력을 처리합니다. 하지만 이렇게 1바이트씩 처리하면 속도가 매우 느려질 수 있습니다. 그래서 효율적인 데이터 처리를 위해 '부분 입/출력'과 '전체 입/출력' 방식을 지원합니다.
부분 읽기 : read(byte [], offset, length)
- 스트림의 데이터를 일정 크기씩 나누어 읽음
- 개발자가 지정한 buffer 배열에 데이터를 채우고, offset위치부터 최대 length만큼 읽을 수 있음
- 메모리 사용량을 조절할 수 있어 대용량 파일을 한 번에 모두 읽지 않고 조금씩 나누어 처리 가능
- 읽은 데이터를 처리하면서 계속 스트림을 이어 읽을 수 있음
- 대용량 파일을 한 번에 로드하면 OutOfMemoryError가 발생할 수 있지만, 지정된 메모리만 사용하기에 정상적인 로드 가능
byte[] buffer = new byte[1024];
int bytesRead;
while ((bytesRead = inputStream.read(buffer, 0, buffer.length)) != -1) {
System.out.println(bytesRead);
}
전체 읽기 : readAllBytes()
- 스트림의 모든 데이터를 한 번의 호출로 읽을 수 있음
- 작은 파일, 메모리에 모든 내용을 한 번에 처리하는 경우 적합함
- 메모리 사용량을 제어할 수 없어 대용량 파일을 로드할 때는 위험
InputStream is = new FileInputStream(FILE_NAME);
byte[] allBytes = is.readAllBytes(); //스트림의 모든 데이터 한 번에 읽기
System.out.println(new String(allBytes)); //읽은 바이트 배열을 문자열로 변환
write는 기본적으로 write(byte[])가 내부에서 write(byte[], offset, length)를 호출해서 동작을 합니다. 즉 전체 쓰기나 부분 쓰기나 모두 같은 원리로 동작 하기에 크게 신경을 쓰지 않아도 됩니다.
문자 스트림(Character Stream)
위에서 공부한 바이트 스트림은 1바이트 단위로 데이터를 처리합니다. 그래서 숫자, 이미지, 영상 같은 모든 데이터를 다룰 수 있지만, 문자열을 처리하기에는 다소 불편한 점이 있습니다. 예를 들어 문자열을 파일에 저장하려면 문자열을 byte[]로 변환하고, 문자 인코딩을 해야 하는 과정을 거쳐야 합니다.
이러한 불편함을 해결하기 위해 등장한 것이 문자 스트림이며, 문자 스트림은 데이터를 문자 단위(char)로 처리하며, 문자 인코딩을 자동으로 처리해줍니다. 내부적으로는 바이트를 읽고 쓰지만, 개발자는 문자(char, String) 단위로 데이터를 다룰 수 있습니다.
클래스 이름은 Reader/Writer로 끝나며, 텍스트 데이터 처리에 적합합니다.

OutputStreamWriter
OutputStreamWriter는 문자 단위로 데이터를 출력하는 문자 스트림의 기본 클래스입니다. 개발자가 문자(char, String)를 입력하면 내부적으로 이를 바이트로 인코딩하여 지정된 출력 스트림(OutputStream)으로 내보냅니다. 즉, 문자 스트림과 바이트 스트림을 연결해주는 다리 역할을 합니다.
추상 클래스가 아니기에 객체 생성이 가능하며, 항상 OutputStream으로 기반으로 동작합니다.
Writer writer = new OutputStreamWriter(new FileOutputStream(FILE_NAME));
writer.write("ABC");
writer.write("\n문자 스트림 예제입니다.");
writer.close();
ABC
문자 스트림 예제입니다.
위 코드는 "ABC"라는 문자 데이터를 OutputStreamWriter가 내부적으로 바이트로 변환하고, FileOutputStream을 통해 파일에 저장합니다. 즉 개발자는 문자 단위로 다루지만, 실제 파일에는 바이트 데이터가 저장되는 것입니다.
만약 65 ~ 67의 숫자를 넣어도 파일에는 문자 A,B,C로 저장되어 보입니다.
[문자] → OutputStreamWriter → [바이트] → FileOutputStream → 파일
InputStreamReader
InputStreamReader는 바이트 데이터를 문자로 읽어오는 문자 스트림의 기본 클래스입니다. 외부(InputStream)으로부터 바이트를 읽은 뒤, 이를 내부적으로 문자로 해석(Decoding)하여 프로그램 내부로 전달합니다. 항상 InputStream을 기반으로 동작합니다.
Reader reader =
new InputStreamReader(new FileInputStream(FILE_NAME));
int data;
while ((data = reader.read()) != -1) {
System.out.print((char) data);
}
reader.close();
위 코드는 파일에서 바이트를 읽어 문자로 변환한 뒤 한 글자씩 출력하는 예시 코드입니다.
버퍼 스트림(Buffered Stream)
버퍼 스트림은 바이트나 문자 스트림이 1바이트, 1문자씩 데이터를 입출력 할때 발생하는 성능 문제를 개선하기 위해 사용 됩니다. 파일이나 네트워크 같은 외부 자원에 매번 접근하면 시간이 오래 걸리기 때문에 데이터를 일정 크기(보통 8KB)씩 메모리 버퍼에 모아 한 번에 처리하는 방식입니다.
버퍼란?
버퍼(Buffer)는 데이터를 임시로 모아두는 메모리 공간입니다. 데이터를 하나씩 바로 처리하지 않고, 한 번에 모아 처리함으로써 외부 자원 접근 횟수를 줄이는 역할을 합니다.
개발자가 입출력을 위해 writer()나 read()를 호출할때마다 OS의 시스템 콜을 통해 명령어를 전달하는데, 이러한 시스템콜은 상대적으로 무거운 작업입니다. 만약 1000바이트의 파일을 1바이트씩 처리 한다고 가정한다면 '1바이트 읽기 -> 디스크 접근'을 총 1000번 반복해 매우 비효율적입니다. 이렇게 시스템 콜의 횟수가 증가하고 지속된다면, 상당한 오버헤드를 유발할 수 있기 때문에 read(), writer()의 호출 횟수를 줄여 시스템 콜 횟수를 줄이기 위해 버퍼를 사용합니다.
버퍼를 사용하면, 디스크(파일)에서 한 번에 지정된 메모리(8KB, 4KB 등)단위로 읽어와 내부 버퍼(메모리)에 저장합니다. 이후 read() 호출은 디스크가 아닌 메모리 버퍼에서 데이터를 가져오기에 디스크의 접근 횟수를 낮추고 메모리의 접근성을 높여 시스템 콜의 횟수를 줄여 결과적으로 전체적인 속도 향상이 이루어집니다.
*버퍼의 기본 크기는 8KB이며 이는 OS와 파일 시스템 성능에 맞춘 경험적 최적값입니다. 너무 작으면 디스크 접근이 잦고, 너무 크면 메모리 낭비이기 때문입니다. 8KB의 메모리를 다 읽으면 다시 디스크에 접근해서 8KB를 읽어와 버퍼에 저장합니다.
문자 데이터 버퍼
문자 데이터 버퍼는 문자 스트림(Reader/Writer)을 감싸서 사용하는 보조 스트림입니다. 내부에 일정 크기의 버퍼를 두고 데이터를 임시 저장하며, 문자 인코딩을 자동 처리하므로 개발자가 인코딩을 신경쓰지 않아도 됩니다. 또한 BufferedReader는 readLine() 메서드를 제공해 한 줄 단위로 쉽게 읽을 수 있습니다.
BufferedReader
BufferedReader br =
new BufferedReader(
new InputStreamReader(
new FileInputStream(FILE_NAME)
),
8 * 1024 // 8KB, 버퍼크기 지정
);
String line;
while((line = br.readLine()) != null) { //줄 단위 읽기
System.out.println(line);
}
BufferedWriter
BufferedReader br = new BufferedReader(new FileReader(FILE_NAME), 8 * 1024);
BufferedWriter bw = new BufferedWriter(new FileWriter(FILE_NAME), 8 * 1024); //8KB 내부 버퍼
String line;
while((line = br.readLine()) != null) { //줄 단위 읽기
bw.write(line);
bw.newLine(); //줄바꿈
}
br.close();
bw.close();
바이트 데이터 버퍼
바이트 데이터 버퍼는 바이트 스트림(InputStream / OutputStream)을 감싸서 사용하는 보조 스트림입니다. 내부에 8KB 크기의 버퍼를 가지고 있으며 개발자가 직접 바이트 배열(byte[])을 만들어 읽거나 쓸 데이터를 담아 처리하는 방식이 일반적입니다. 이미지, 영상, 오디오 등 텍스트가 아닌 모든 종류의 대용량 데이터를 처리하는데 적합합니다.
BufferedInputStream
예시 코드처럼 바이트 배열(byte[])을 만들어야 하는 이유는 내부 버퍼에서 읽어온 데이터를 저장할 수 있는, 개발자가 직접 접근이 가능한 버퍼가 필요하기 때문입니다.
BufferedInputStream bis =
new BufferedInputStream(
new FileInputStream(FILE_NAME),
8 * 1024 // 8KB 내부 버퍼(디스크에서 읽어오는 용도, 개발자가 직접 접근 불가)
);
byte[] buffer = new byte[8 * 1024]; //개발자가 직접 접근(읽은 데이터를 내 코드에서 사용)
int bytesRead;
while ((bytesRead = bis.read(buffer)) != -1) {
System.out.println(bytesRead);
}
bis.close();
BufferedOutputStream
BufferedInputStream bis = new BufferedInputStream(new FileInputStream(FILE_NAME));
BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(FILE_NAME));
byte[] buffer = new byte[8 * 1024];
int bytesRead;
while((bytesRead = bis.read(buffer)) != -1) { //줄 단위 읽기
bos.write(buffer, 0, bytesRead);
//버퍼에서 0부터 읽은 곳 까지 출력
}
bis.close();
bos.close();
자원 정리
스트림을 사용할 때 꼭 기억해야 할 중요한 메서드로 flush()와 close()가 있습니다. 이 두 메서드는 바이트 스트림, 문자 스트림 모두에 해당하는 공통 개념입니다.
flush()
버퍼를 사용하는 출력 스트림에서는 데이터를 바로 내보내지 않고 내부 버퍼에 임시 저장합니다. 이 상태에서 버퍼에 남아 있는 데이터를 강제로 모두 내보내는 역할을 합니다. 출력 도중 중간에 데이터를 확실히 보내고 싶거나 내부 버퍼의 메모리가 가득 채워지지 않았을 때 출력하기 위해 사용합니다. 예를 들어 로그를 실시간으로 저장하거나 네트워크로 데이터를 전송할 때 유용합니다.
close()
close()는 스트림 작업이 끝났을 때 반드시 호출해야 하는 메서드로, 스트림을 닫고 관련된 외부 자원을 해제합니다.
close()를 호출하면 내부 버퍼에 남아있는 데이터를 자동으로 flush() 해주므로, 보통 close()만 호출해도 안전하게 데이터가 모두 출력이 됩니다.
다만 작업 중간에 데이터를 출력하고 싶으면 flush()를 별도로 호출해야하고, 자원을 제대로 해제하지 않으면 파일이 손상되거나 메모리 누수 등의 문제가 발생할 수 있으므로 반드시 close()를 호출해주어야 합니다,
'Java' 카테고리의 다른 글
| [Java] 컬렉션 프레임워크 - Set(HashSet, TreeSet)의 이해 (1) | 2026.03.03 |
|---|---|
| [Java] 컬렉션 프레임워크 - List(ArrayList, LinkedList)의 이해 (0) | 2026.02.23 |
| [Java] 시간복잡도와 빅 오(Big-O) 표기의 이해 (1) | 2026.02.18 |
| [Java] 멀티스레드의 이해 (스레드의 상태와 동기화 중요성 ) (1) | 2026.01.30 |
| [Java] 멀티스레드의 기초 이해 (0) | 2026.01.22 |
