김영한의 실전 자바 - 고급 2편, I/O, 네트워크, 리플렉션| 김영한 - 인프런 강의
현재 평점 5.0점 수강생 5,647명인 강의를 만나보세요. I/O, 네트워크, 리플렉션, 애노테이션을 기초부터 실무 레벨까지 깊이있게 학습합니다. 웹 애플리케이션 서버(WAS)를 자바로 직접 만들어봅니
www.inflearn.com
이 링크를 통해 구매하시면 제가 수익을 받을 수 있어요.
스트림

자바 프로세스가 가지고 있는 데이터를 밖으로 보내려면 출력 스트림을 사용하고, 반대로 외부 데이터를 자바 프로세스 안으로 가져오려면 입력 스트림을 사용하면 된다.
참고로 각 스트림은 단방향으로 흐른다.
예제 1 - 파일에 데이터 쓰고 읽기
public class StreamStartMain1 {
public static void main(String[] args) throws IOException {
FileOutputStream fos = new FileOutputStream("temp/hello.dat");
fos.write(65);
fos.write(66);
fos.write(67);
fos.close();
FileInputStream fis = new FileInputStream("temp/hello.dat");
System.out.println(fis.read());
System.out.println(fis.read());
System.out.println(fis.read());
System.out.println(fis.read());
fis.close();
}
}
실행 결과
65
66
67
-1
`new FileOutputStream("temp/hello.dat")`
- 파일에 데이터를 출력하는 스트림이다.
- 파일이 없으면 파일을 자동으로 만들고, 데이터를 해당 파일에 저장한다.
- 폴더를 만들지는 않기 때문에 폴더는 미리 만들어두어야 한다.
`write()`
- byte 단위로 값을 출력한다. 여기서는 65, 66, 67을 출력했다.
- ASCII 코드 집합에서 65는 A, 66는 B, 67는 C이다.
`new FileInputStream("temp/hello.dat")`
- 파일에서 데이터를 읽어오는 스트림이다.
`read()`
- 파일에서 데이터를 byte 단위로 하나씩 읽어온다.
- 순서대로 65, 66, 67을 읽어온다.
- 파일의 끝에 도달해서 더는 읽을 내용이 없다면 -1을 반환한다.
- 파일의 끝(EOF, End Of File)
`close()`
- 파일에 접근하는 것은 자바 입장에서 외부 자원을 사용하는 것이다. 자바에서 내부 객체는 자동으로 GC가 되지만 외부 자원은 사용 후 반드시 닫아주어야 한다.
실행 결과 - temp/hello.dat
ABC
- `hello.dat`에 분명 65, 66, 67을 저장했다. 그런데 왜 개발툴이나 텍스트 편집기에서 열어보면 ABC라고 보일까?
- 우리가 사용하는 개발툴이나 텍스트 편집기는 UTF-8 또는 MS949 문자 집합을 사용해서 byte 단위의 데이터를 문자로 디코딩해서 보여준다. 따라서 65, 66, 67 byte를 ASCII 문자인 A, B, C로 인식해서 출력한 것이다.
참고 - 파일 append 옵션
`FileOutputStream`의 생성자에는 `append`라는 옵션이 있다.
new FileOutputStream("temp/hello.dat", true);
- `true`: 기존 파일의 끝에 이어서 쓴다.
- `false`: 기존 파일의 데이터를 지우고 처음부터 다시 쓴다. (기본값)
예제 2 - 파일 끝까지 읽기
public class StreamStartMain2 {
public static void main(String[] args) throws IOException {
FileOutputStream fos = new FileOutputStream("temp/hello.dat");
fos.write(65);
fos.write(66);
fos.write(67);
fos.close();
FileInputStream fis = new FileInputStream("temp/hello.dat");
int data;
while ((data = fis.read()) != -1) {
System.out.println(data);
}
fis.close();
}
}
- 입력 스트림의 `read()` 메서드는 파일의 끝에 도달하면 -1을 반환한다. 따라서 -1을 반환할 때까지 반복문을 사용하면 파일의 데이터를 모두 읽을 수 있다.
실행 결과
65
66
67
참고 - read()가 int를 반환하는 이유
- 부호 없는 바이트 표현:
- 자바에서 `byte`는 부호 있는 8비트 값(-128 ~ 127)이다.
- `int`로 반환함으로써 0에서 255까지의 모든 가능한 바이트 값을 부호 없이 표현할 수 있다.
- EOF(End Of File) 표시
- byte를 표현하려면 256가지 종류의 값을 모두 사용해야 한다.
- 자바의 `byte`는 -128에서 127까지 256종류의 값만 가질 수 있어서, EOF를 위한 특별한 값을 할당하기 어렵다.
- `int`는 0~255까지 모든 가능한 바이트 값을 표현하고, 여기에 추가로 -1을 반환하여 스트림의 끝(EOF)을 나타낼 수 있다.
- `write()`의 경우도 비슷한 이유로 `int` 타입을 반환한다.
예제3 - 원하는 크기만큼 쓰고 읽기
public class StreamStartMain3 {
public static void main(String[] args) throws IOException {
FileOutputStream fos = new FileOutputStream("temp/hello.dat");
byte[] input = {65, 66, 67};
fos.write(input);
fos.close();
FileInputStream fis = new FileInputStream("temp/hello.dat");
byte[] buffer = new byte[10];
int readCount = fis.read(buffer, 0, 10);
System.out.println("readCount = " + readCount);
System.out.println(Arrays.toString(buffer));
fis.close();
}
}
실행 결과
readCount = 3
[65, 66, 67, 0, 0, 0, 0, 0, 0, 0]
출력 스트림
- `write(byte[])`: `byte[]`에 원하는 데이터를 담고 `wirte()`에 전달하면 해당 데이터를 한 번에 출력할 수 있다.
입력 스트림
- `read(byte[], offset, length)`: `byte[]`를 미리 만들어 두고, 만들어둔 `byte[]`에 한 번에 데이터를 읽어올 수 있다.
- `byte[]`: 데이터가 읽혀지는 버퍼
- `offset`: 데이터가 기록되는 `byte[]`의 인덱스 시작 위치
- `length`: 읽어올 byte의 최대 길이
- 반환 값: 버퍼에 읽은 총 바이트 수를 반환한다. 스트림의 끝에 도달하여 더 이상 데이터가 없는 경우 -1을 반환
`read(byte[])`
- `offset`, `length`를 생략한 `read(byte[])` 메서드도 있다. 이 메서드는 다음 값을 가진다.
- `offset`: 0
- `length`: `byte[].length``
예제 4 - 모든 byte 한 번에 일기
public class StreamStartMain4 {
public static void main(String[] args) throws IOException {
FileOutputStream fos = new FileOutputStream("temp/hello.dat");
byte[] input = {65, 66, 67};
fos.write(input);
fos.close();
FileInputStream fis = new FileInputStream("temp/hello.dat");
byte[] readBytes = fis.readAllBytes();
System.out.println(Arrays.toString(readBytes));
fis.close();
}
}
- `readAllBytes()`를 사용하면 스트림이 끝날 때까지(파일의 끝에 도달할 때까지) 모든 데이터를 한 번에 읽어올 수 있다.
실행 결과
[65, 66, 67]
부분으로 나누어 읽기 vs 전체 읽기
- `read(byte[], offset, length)`
- 스트림의 내용을 부분적으로 읽거나, 읽은 내용을 처리하면서 스트림을 계속해서 읽어야 할 경우에 적합하다.
- 메모리 사용량을 제어할 수 있다.
- 예) 파일이나 스트림에서 일정한 크기의 데이터를 반복적으로 읽어야 할 때 유용하다. 예를 들어, 대용량 파일을 처리할 때, 한 번에 메모리에 로드하기보다는 이 메서드를 사용하여 파일을 조각조각 읽어 들일 수 있다.
- 100MB의 파일을 1M 단위로 나누어 읽고 처리하는 방식을 사용하면 한 번에 최대 1M의 메모리만 사용한다.
- `readAllBytes()`
- 한 번의 호출로 모든 데이터를 읽을 수 있어 편리하다.
- 작은 파일이나 메모리에 모든 내용을 올려서 처리해야 하는 경우에 적합하다.
- 메모리 사용량을 제어할 수 없다.
- 큰 파일의 경우 `OutOfMemoryError`가 발생할 수 있다.
InputStream, OutputStream
InputStream, OutputStream

현대의 컴퓨터는 대부분 byte 단위로 데이터를 주고받는다. (bit 단위는 너무 작기 때문에 byte 단위를 기본으로 사용한다.) 이렇게 데이터를 주고 받는 것을 Input/Outout(I/O)라 한다.
자바 내부에 있는 데이터를 외부에 있는 파일에 저장하거나, 네트워크를 통해 전송하거나 콘솔에 출력할 때 모두 byte 단위로 데이터를 주고 받는다. 만약 파일, 네트워크, 콘솔 각각 데이터를 주고 받는 방식이 다르다면 상당히 불편할 것이다. 또한 파일에 저장하던 내용을 네트워크에 전달하거나 콘솔에 출력하도록 변경할 때 너무 많은 코드를 변경해야 할 수 있다.
이런 문제를 해결하기 위해 자바는 `InputStream`, `OutputStream`이라는 기본 추상 클래스를 제공한다.

- `InputStream`과 상속 클래스
- `read()`, `read(byte[])`, `readAllBytes()` 제공

- `OutputStream`과 상속 클래스
- `write(int)`, `write(byte[])` 제공
스트림을 사용하면 파일을 사용하든, 소켓을 통해 네트워크를 사용하든 모두 일관된 방식으로 데이터를 주고받을 수 있다. 그리고 수많은 기본 구현 클래스들도 제공한다. 물론 각각의 구현 클래스들은 자신에게 맞는 추가 기능도 함께 제공한다.
메모리 스트림
public class ByteArrayStreamMain {
public static void main(String[] args) throws IOException {
byte[] input = {1, 2, 3};
// 메모리에 쓰기
ByteArrayOutputStream baos = new ByteArrayOutputStream();
baos.write(input);
// 메모리에서 읽기
ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
byte[] bytes = bais.readAllBytes();
System.out.println(Arrays.toString(bytes));
}
}
실행 결과
[1, 2, 3]
`ByteArrayOutputStream`, `ByteArrayInputStream`을 사용하면 메모리에 스트림을 쓰고 읽을 수 있다. 이 클래스들은 `OutputStream`, `InputStream`을 상속받았기 때문에 부모의 기능을 모두 사용할 수 있다. 코드를 보면 파일 입출력과 매우 비슷한 것을 확인할 수 있다.
참고로 메모리에 어떤 데이터를 저장하고 읽을 때는 컬렉션이나 배열을 사용하면 되기 때문에 이 기능은 잘 사용하지 않는다. 주로 스트림을 간단하게 테스트하거나 스트림의 데이터를 확인하는 용도로 사용한다.
콘솔 스트림
public class PrintStreamMain {
public static void main(String[] args) throws IOException {
PrintStream printStream = System.out;
byte[] bytes = "Hello!\n".getBytes(UTF_8);
printStream.write(bytes);
printStream.println("Print!");
}
}
실행 결과
Hello!
Print!
자주 사용했던 `System.out`이 사실은 `PrintStream`이다. 이 스트림은 `OutputStream`을 상속받는다. 이 스트림은 자바가 시작될 때 자동으로 만들어진다. 따라서 우리가 직접 생성하지 않는다.
- `write(byte[])`: `OuputStream` 부모 클래스가 제공하는 기능
- `println(String)`: `PrintStream`이 자체적으로 제공하는 추가 기능
정리
`InputStream`과 `OuputStream`이 다양한 스트림들을 추상화하고 기본 기능에 대한 표준을 잡아둔 덕분에 개발자는 편리하게 입출력 작업을 수행할 수 있다. 이러한 추상화의 장점은 다음과 같다.
- 일관성: 모든 종류의 입출력 작업에 대해 동일한 인터페이스(여기서는 부모의 메서드)를 사용할 수 있어, 코드의 일관성이 유지된다.
- 유연성: 실제 데이터 소스나 목적지가 무엇인지에 관계없이 동일한 방식으로 코드를 작성할 수 있다. 예를 들어, 파일, 네트워크, 메모리 등 다양한 소스에 대해 동일한 메서드를 사용할 수 있다.
- 확장성: 새로운 유형의 입출력 스트림을 쉽게 추가할 수 있다.
- 재사용성: 다양한 스트림 클래스들을 조합하여 복잡한 입출력 작업을 수행할 수 있다. 예를 들어, `BufferedInputStream`을 사용하여 성능을 향상하거나 `DataInputStream`을 사용하여 기본 데이터 타입을 쉽게 읽을 수 있다.
- 에러 처리: 표준화된 예외 처리 메커니즘을 통해 일관된 방식으로 오류를 처리할 수 있다.
`InputStream`, `OutputStream`은 추상 클래스이다. 자바 1.0부터 제공되고, 일부 작동하는 코드도 들어있기 때문에 인터페이스가 아니라 추상 클래스로 제공된다.
파일 입출력과 성능 최적화
공통으로 사용할 상수들 정의
public class BufferedConst {
public static final String FILE_NAME = "temp/buffered.dat";
public static final int FILE_SIZE = 10 * 1024 * 1024; // 10MB
public static final int BUFFER_SIZE = 8192; // 8KB
}
- `FILE_NAME`: `temp/buffered.dat`라는 파일을 만들 예정이다.
- `FILE_SIZE`: 파일의 크기는 10MB이다.
하나씩 쓰기
쓰기
public class CreateFileV1 {
public static void main(String[] args) throws IOException {
FileOutputStream fos = new FileOutputStream(FILE_NAME);
long startTime = System.currentTimeMillis();
for (int i = 0; i < FILE_SIZE; i++) {
fos.write(1);
}
fos.close();
long endTime = System.currentTimeMillis();
System.out.println("File created: " + FILE_NAME);
System.out.println("File size: " + FILE_SIZE / 1024 / 1024 + "MB");
System.out.println("Time taken: " + (endTime - startTime) + "ms");
}
}
- `fos.write(1)`: 파일의 내용은 중요하지 않기 때문에 단순히 1이라는 값을 반복하며 계속 저장한다.
- 한 번에 1byte가 만들어진다.
- 이 메서드를 약 1000만번(10*1024*1024) 호출하면 10MB의 파일이 만들어진다.
실행 결과
File created: temp/buffered.dat
File size: 10MB
Time taken: 13524ms
- 실행을 하면 결과를 보는데 상당히 오랜 시간이 걸린다.
읽기
public class ReadFileV1 {
public static void main(String[] args) throws IOException {
FileInputStream fis = new FileInputStream(FILE_NAME);
long startTime = System.currentTimeMillis();
int fileSize = 0;
int data;
while ((data = fis.read()) != -1) {
fileSize++;
}
fis.close();
long endTime = System.currentTimeMillis();
System.out.println("File name: " + FILE_NAME);
System.out.println("File size: " + fileSize / 1024 / 1024 + "MB");
System.out.println("Time taken: " + (endTime - startTime) + "ms");
}
}
- `fis.read()`를 사용해서 앞서 만든 파일에서 1byte씩 데이터를 읽는다.
- 파일의 크기가 10MB이므로 `fis.read()` 메서드를 약 1000번(10*1024*1024) 호출한다.
실행 결과
File name: temp/buffered.dat
File size: 10MB
Time taken: 4668ms
- 실행을 하면 결과를 보는데 상당히 오랜 시간 걸린다.
정리
10MB 파일 하나를 쓰는데 13초, 읽는데 5초라는 매우 오랜 시간이 걸렸다. 이렇게 오래 걸린 이유는 자바에서 1byte씩 디스크에 데이터를 전달하기 때문이다. 디스크는 1byte의 데이터를 받아서 1byte의 데이터를 쓴다. 이 과정을 무려 1000만 번 반복하는 것이다.
더 자세히 설명하면 다음 2가지 이류로 느려진다.
- `write()`나 `read()`를 호출할 때마다 OS의 시스템 콜을 통해 파일을 읽거나 쓰는 명령어를 전달한다. 이러한 시스템 콜은 상대적으로 무거운 작업이다.
- HDD, SDD 같은 장치들도 하나의 데이터를 읽고 쓸 때마다 필요한 시간이 있다. HDD의 경우 더욱 느린데, 물리적으로 디스크의 회전이 필요하다.
버퍼 활용
1byte씩 데이터를 하나씩 전달하는 것이 아니라 `byte[]`을 통해 배열에 담아서 한 번에 여러 byte를 전달해 보자.
쓰기
public class CreateFileV2 {
public static void main(String[] args) throws IOException {
FileOutputStream fos = new FileOutputStream(FILE_NAME);
long startTime = System.currentTimeMillis();
byte[] buffer = new byte[BUFFER_SIZE];
int bufferIndex = 0;
for (int i = 0; i < FILE_SIZE; i++) {
buffer[bufferIndex++] = 1;
// 버퍼가 가득 차면 쓰고, 버퍼를 비운다.
if (bufferIndex == BUFFER_SIZE) {
fos.write(buffer);
bufferIndex = 0;
}
}
// 끝 부분에 오면 버퍼가 가득차지 않고, 남아있을 수 있다. 버퍼에 남은 부분 쓰기
if (bufferIndex > 0) {
fos.write(buffer, 0, bufferIndex);
}
fos.close();
long endTime = System.currentTimeMillis();
System.out.println("File created: " + FILE_NAME);
System.out.println("File size: " + FILE_SIZE / 1024 / 1024 + "MB");
System.out.println("Time taken: " + (endTime - startTime) + "ms");
}
}
- 데이터를 먼저 `buffer`라는 `byte`에 담아둔다.
- 이렇게 데이터를 모아서 전달하거나 모아서 전달받는 용도로 사용하는 것을 버퍼라 한다.
- 여기서는 `BUFFER_SIZE` 만큼 데이터를 모아서 `write()`를 호출한다.
- 예를 들어서 `BUFFER_SIZE`가 10이라면 10만큼 모이면 `write()`를 호출해서 `10byte`를 한 번에 스트림에 전달한다.
실행 결과
File created: temp/buffered.dat
File size: 10MB
Time taken: 15ms
- 실행 결과의 `BUFFER_SIZE`는 8192(8KB)이다.
- 실행 결과를 보면 이전 예제의 쓰기 결과인 13초보다 약 1000배 정도 빠른 것을 확인할 수 있다.
버퍼의 크기에 따른 쓰기 성능
`BUFFER_SIZE`에 따른 쓰기 성능
- 1: 14368ms
- 2: 7474ms
- 3: 4829ms
- 10: 1692ms
- 100: 180ms
- 1000: 28ms
- 2000: 23ms
- 4000: 16ms
- 8000: 13ms
- 80000: 12ms
많은 데이터를 한 번에 전달하면 성능을 최적화할 수 있다. 이렇게 되면 시스템 콜도 줄어들고, HDD, SDD 같은 장치들의 작동 횟수도 줄어든다. 예를 들어 버퍼의 크기를 1 → 2로 변경하면 시스템 콜 횟수는 절반으로 줄어든다.
그런데 버퍼의 크기가 커진다고 해서 속도가 계속 줄어들지는 않는다. 왜냐하면 디스크나 파일 시스템에서 데이터를 읽고 쓰는 기본 단위가 보통 4KB 또는 8KB이기 때문이다.
- 4KB (4096 byte)
- 8KB (8192 byte)
결국 버퍼에 많은 데이터를 담아 보내도 디스크나 파일 시스템에서 해당 단위로 나누어 저장하기 때문에 효율에는 한계가 있다. 따라서 버퍼의 크기는 보통 4KB, 8KB 정도로 잡는 것이 효율적이다.
읽기
public class ReadFileV2 {
public static void main(String[] args) throws IOException {
FileInputStream fis = new FileInputStream(FILE_NAME);
long startTime = System.currentTimeMillis();
byte[] buffer = new byte[BUFFER_SIZE];
int fileSize = 0;
int size;
while ((size = fis.read(buffer)) != -1) {
fileSize += size;
}
fis.close();
long endTime = System.currentTimeMillis();
System.out.println("File name: " + FILE_NAME);
System.out.println("File size: " + fileSize / 1024 / 1024 + "MB");
System.out.println("Time taken: " + (endTime - startTime) + "ms");
}
}
실행 결과
File name: temp/buffered.dat
File size: 10MB
Time taken: 5ms
- 실행 결과의 `BUFFER_SIZE`는 8192(8KB)이다.
- 읽기의 경우에도 버퍼를 사용하면 약 1000배 정도의 성능 향상을 확인할 수 있다.
Buffered 스트림 쓰기
`BufferedOutputStream`은 버퍼 기능을 내부에서 대신 처리해 준다. 따라서 단순한 코드를 유지하면서 버퍼를 사용하는 이점도 함께 누릴 수 있다.
Buffered 스트림 쓰기
public class CreateFileV3 {
public static void main(String[] args) throws IOException {
FileOutputStream fos = new FileOutputStream(FILE_NAME);
BufferedOutputStream bos = new BufferedOutputStream(fos, BUFFER_SIZE);
long startTime = System.currentTimeMillis();
for (int i = 0; i < FILE_SIZE; i++) {
bos.write(1);
}
bos.close();
long endTime = System.currentTimeMillis();
System.out.println("File created: " + FILE_NAME);
System.out.println("File size: " + FILE_SIZE / 1024 / 1024 + "MB");
System.out.println("Time taken: " + (endTime - startTime) + "ms");
}
}
- `BufferedOutputStream`은 내부에서 단순히 버퍼 기능만 제공한다. 따라서 반드시 대상 `OutputStream`이 있어야 한다.
- 여기서는 `FileOutputStream` 객체를 생성자에 전달한다.
- 추가로 사용할 버퍼의 크기도 함께 전달할 수 있다.
실행 결과
File created: temp/buffered.dat
File size: 10MB
Time taken: 101ms
- 성능도 예제1의 13초보다 130배 빠른 0.1초에 처리되었다.
- 성능이 예제2보다는 다소 떨어진다.
실행 순서

- `BufferedOutputStream`은 내부에 `byte[] buf`라는 버퍼를 가지고 있다.
- 여기서 버퍼의 크기를 3으로 가정하겠다.
- `BufferedOutputStream`에 `write(byte)`를 통해 `byte` 하나를 전달하면 `byte[buf]`에 보관된다.
- 참고로 실제로는 `write(int)` 타입이다.


- `write(byte)`를 3번 호출하면 버퍼가 가득 찬다.
- 버퍼가 가득 차면 `FileOutputStream`에 있는 `write(byte[])` 메서드를 호출한다.
- 호출하면, 전달된 모든 `byte[]`을 시스템 콜로 OS에 전달한다.

- 버퍼의 데이터를 모두 전달했기 때문에 버퍼의 내용을 비운다.
- 이후에 `write(byte)`가 호출되면 다시 버퍼를 채우는 식으로 반복한다.
flush()
버퍼가 다 차지 않아도 버퍼에 남아있는 데이터를 전달하려면 `flush()`라는 메서드를 호출하면 된다.



- 버퍼에 2개의 데이터가 남아있음
- `flush()` 호출
- 버퍼에 남아있는 데이터를 전달한다.
- 데이터를 전달하고 버퍼를 비운다.
close()

- `BufferedOutputStream`을 `close()`로 닫으면 먼저 내부에서 `flush()`를 호출한다. 따라서 버퍼에 남아있는 데이터를 모두 전달하고 비운다.
- 따라서 `close()`를 호출해도 남은 데이터를 안전하게 저장할 수 있다

- 버퍼가 비워지고 나면 `close()`로 `BufferedOuputStream`의 자원을 정리한다.
- 그러고 나서 연결된 스트림의 `close()`를 호출한다. 여기서는 `FileOutputStream`의 자원이 정리된다.
- 핵심은 `close()`를 호출하면 `close()`가 연쇄 호출된다는 점이다. 따라서 마지막에 연결한 `BufferedOutputStream`만 닫아주면 된다.
기본 스트림, 보조 스트림
- `FileOutputStream`과 같이 단독으로 사용할 수 있는 스트림을 기본 스트림이라 한다.
- `BufferedOutputStream`과 같이 단독으로 사용할 수 없고, 보조 기능을 제공하는 스트림을 보조 스트림이라 한다.
`BufferedOutputStream`은 `FileOutputStream`에 버퍼라는 보조 기능을 제공한다. 생성자를 보면 알겠지만 반드시 `FileOutputStream`같은 대상 `OutputStream`이 있어야 한다.
public BufferedOutputStream(OutputStream out) { ... }
public BufferedOutputStream(OutputStream out, int size) { ... }
- `BufferedOutputStream`은 버퍼라는 보조 기능을 제공한다. 그렇다면 누구에게 보조 기능을 제공할지 대상을 반드시 전달해야 한다.
정리
- `BufferedOutputStream`은 버퍼 기능을 제공하는 보조 스트림이다.
- `BufferedOutputStream`도 `OutputStream`의 자식이기 때문에, `OutputStream`의 기능을 그대로 사용할 수 있다.
- 물론 대부분의 기능은 재정의된다. `write()`의 경우 먼저 버퍼에 쌓도록 재정의된다.
- 버퍼의 크기만큼 데이터를 모아서 전달하기 때문에 빠른 속도로 데이터를 처리할 수 있다.
Buffered 스트림 읽기
Buffered 스트림 읽기
public class ReadFileV3 {
public static void main(String[] args) throws IOException {
FileInputStream fis = new FileInputStream(FILE_NAME);
BufferedInputStream bis = new BufferedInputStream(fis, BUFFER_SIZE);
long startTime = System.currentTimeMillis();
int fileSize = 0;
int data;
while ((data = bis.read()) != -1) {
fileSize++;
}
bis.close();
long endTime = System.currentTimeMillis();
System.out.println("File name: " + FILE_NAME);
System.out.println("File size: " + fileSize / 1024 / 1024 + "MB");
System.out.println("Time taken: " + (endTime - startTime) + "ms");
}
}
실행 결과
File name: temp/buffered.dat
File size: 10MB
Time taken: 119ms
- 예제 1보다 약 5초 정도 걸렸는데, 약 50배 정도 빨라진 것을 확인할 수 있다.
- 예제 2보다는 조금 느리다.
실행 순서

- `read()`는 1byte만 조회한다.
- `BufferedInputStream`은 먼저 버퍼를 확인한다. 버퍼에 데이터가 없으므로 데이터를 불러온다.
- `BufferedInputStream`은 `FileInputStream`에서 `read(byte[])`을 사용해서 버퍼의 크기인 3byte의 데이터를 불러온다.
- 불러온 데이터를 버퍼에 보관한다.



- `read()`를 호출하여 버퍼에 있는 데이터 중 1byte를 반환한다.

- `read()`를 호출하는데, 이번에는 버퍼가 비어있다.
- `FileInputStream`에서 버퍼 크기만큼 조회하고 버퍼에 담아둔다.

- 버퍼에 있는 데이터를 하나 반환한다.
- 이런 방식을 반복한다.
정리
- `BufferedInputStream`은 버퍼의 크기만큼 데이터를 미리 읽어서 버퍼에 보관해 둔다. 따라서 `read()`를 통해 1byte씩 데이터를 조회해도, 성능이 최적화된다.
버퍼를 직접 다루는 것보다 BufferedXxx의 성능이 떨어지는 이유
- 예제 1 쓰기: 13000ms (13초)
- 예제 2 쓰기: 15ms (버퍼 직접 다룸)
- 예제 3 쓰기: 119ms (BufferedXxx)
예제 2는 버퍼를 직접 다루는 것이고, 예제 3은 `BufferedXxx`라는 클래스가 대신 버퍼를 처리해 준다. 버퍼를 사용하는 것은 같기 때문에 결과적으로 예제 2와 예제 3은 비슷한 성능이 나와야 한다. 그런데 예제 2가 더 빠른 이유는 바로 동기화 때문이다.
BufferedOutputStream.write()
@Override
public void write(int b) throws IOException {
if (lock != null) {
lock.lock();
try {
implWrite(b);
} finally {
lock.unlock();
}
} else {
synchronized (this) {
implWrite(b);
}
}
}
- `BufferedOutputStream`을 포함한 `BufferedXxx` 클래스는 모두 동기화 처리가 되어 있다.
- 락을 걸고 푸는 코드도 1000만 번 호출된다.
BufferedXxx 클래스의 특징
`BufferedXxx` 클래스는 자바 초창기에 만들어진 클래스인데, 처음부터 멀티 스레드를 고려해서 만든 클래스이다. 따라서 멀티 스레드에 안전하지만 락을 걸고 푸는 동기화 코드로 인해 성능이 약간 저하될 수 있다. 하지만 싱글 스레드 상황에서는 동기화 락이 필요하지 않기 대문에 직접 버퍼를 다룰 때와 비교해서 성능이 떨어진다.
일반적인 상황이라면 이 정도 성능은 크게 문제가 되지는 않기 때문에 싱글 스레드여도 `BufferedXxx`를 사용하면 충분하다. 물론 큰 데이터를 다루어야 하고, 성능이 최적화가 중요하다면 직접 버퍼를 다루는 방법을 고려해야 한다.
한 번에 쓰기
파일의 크기가 크지 않다면 간단하게 한 번에 쓰고 읽는 것도 좋은 방법이다. 이 방법은 성능은 가장 빠르지만, 결과적으로 메모리를 한 번에 많이 사용하기 때문에 파일의 크기가 작아야 한다.
쓰기
public class CreateFileV4 {
public static void main(String[] args) throws IOException {
FileOutputStream fos = new FileOutputStream(FILE_NAME);
long startTime = System.currentTimeMillis();
byte[] buffer = new byte[FILE_SIZE];
for (int i = 0; i < FILE_SIZE; i++) {
buffer[i] = 1;
}
fos.write(buffer);
fos.close();
long endTime = System.currentTimeMillis();
System.out.println("File created: " + FILE_NAME);
System.out.println("File size: " + FILE_SIZE / 1024 / 1024 + "MB");
System.out.println("Time taken: " + (endTime - startTime) + "ms");
}
}
실행 결과
File created: temp/buffered.dat
File size: 10MB
Time taken: 7ms
- 실행 시간은 8KB의 버퍼를 직접 사용한 예제2와 오차 범위 정도로 거의 비슷하다.
- 디스크나 파일 시스템에서 데이터를 읽고 쓰는 기본 단위가 보통 4KB 또는 8KB이기 때문에, 한 번에 쓴다고 해서 무작정 빠른 것은 아니다.
읽기
public class ReadFileV4 {
public static void main(String[] args) throws IOException {
FileInputStream fis = new FileInputStream(FILE_NAME);
long startTime = System.currentTimeMillis();
byte[] bytes = fis.readAllBytes();
fis.close();
long endTime = System.currentTimeMillis();
System.out.println("File name: " + FILE_NAME);
System.out.println("File size: " + bytes.length / 1024 / 1024 + "MB");
System.out.println("Time taken: " + (endTime - startTime) + "ms");
}
}
- `readAllBytes()`를 사용하면 한 번에 데이터를 다 읽을 수 있다.
실행 결과
File name: temp/buffered.dat
File size: 10MB
Time taken: 5ms
- 실행 시간은 8KB의 버퍼를 직접 사용한 예제2와 오차 범위 정도로 거의 비슷하다.
- `readAllBytes()`는 자바 구현에 따라 다르지만 보통 4KB, 8KB, 16KB 단위로 데이터를 읽어 들인다.
정리
- 파일의 크기가 크지 않아서, 메모리 사용에 큰 영향을 주지 않는다면 쉽고 빠르게 한 반에 처리하자.
- 성능이 중요하고 큰 파일을 나누어 처리해야 한다면, 버퍼를 직접 다루자.
- 성능이 크게 중요하지 않고, 버퍼 기능이 필요하면 `BufferedXxx`를 사용하자
- 동기화 코드가 들어있어서 스레드 안전하지만, 약간의 성능 저하가 있다.
문자 다루기
직접 구현
스트림의 모든 데이터는 `byte` 단위를 사용한다. 따라서 `byte`가 아닌 문자를 스트림에 직접 전달할 수는 없다. 예를 들어서 `String` 문자를 스트림을 통해 파일에 저장하려면 `String`을 `byte`로 변환한 다음에 저장해야 한다.
public class ReaderWriterMainV1 {
public static void main(String[] args) throws IOException {
String writeString = "ABC";
// 문자 - byte UTF-8 인코딩
byte[] writeBytes = writeString.getBytes(UTF_8);
System.out.println("write String: " + writeString);
System.out.println("write bytes: " + Arrays.toString(writeBytes));
// 파일에 쓰기
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 UTF-8 디코딩
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
`byte[] writeBytes = writeString.getBytes(UTF_9=8)`
- `String`을 `byte`로 변환할 때 `String.getBytes(Charset)`을 사용하면 된다.
- 이때 문자를 `byte` 숫자로 변경해야 하기 때문에 반드시 문자 집합(인코딩 셋)을 지정해야 한다.
- ABC를 인코딩하면 65, 66, 67이 된다.
이렇게 만든 `byte[]`를 `FileOutputStream`에 `write()`로 전달하면 65, 66, 67을 파일에 저장할 수 있다. 결과적으로 우리가 의도한 ABC 문자를 파일에 저장할 수 있다.
`String readString = new String(readBytes, UTF_8)`
- 반대의 경우도 비슷하다. `String` 객체를 생성할 때, 읽어 들인 `byte[]`와 디코딩할 문자 집합을 전달하면 된다.
- 그러면 `byte[]`를 `String` 문자로 다시 복원할 수 있다.
여기서 핵심은 스트림은 `byte`만 사용할 수 있으므로, `String` 같은 문자는 직접 전달할 수 없다는 점이다. 그래서 개발자가 번거롭게 다음과 같은 변환 과정을 직접 호출해주어야 한다.
- `String` + 문자 집합 → `byte[]`
- `byte[]` + 문자 집합 → `String`
스트림을 문자로
- OutputStreamWriter: 스트림에 byte 대신 문자를 저장할 수 있게 지원한다.
- InputStreamReader: 스트림에 byte 대신 문자를 읽을 수 있게 지원한다.
public class ReaderWriterMainV2 {
public static void main(String[] args) throws IOException {
String writeString = "abc";
System.out.println("write String: " + writeString);
// 파일에 쓰기
FileOutputStream fos = new FileOutputStream(FILE_NAME);
OutputStreamWriter osw = new OutputStreamWriter(fos, UTF_8);
osw.write(writeString);
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);
}
isr.close();
System.out.println("read String: " + content);
}
}
- 코드를 보면 앞에서 작성한 `BufferedXxx`와 비슷한 것을 확인할 수 있다.
실행 결과
write String: abc
read String: abc

- `OutputStreamWriter`는 문자를 입력받고, 받은 문자를 인코딩해서 `byte[]`로 변환한다.
- `OutputStreamWriter`는 변환한 `byte[]`를 전달할 `OutputStream`과 인코딩 문자 집합에 대한 정보가 필요하다. 따라서 두 정보를 생성자를 통해 전달해야 한다.
- `new OuputStreamWriter(fos, UTF_8)`
- 그림을 보면 `OutputStreamWriter`가 문자 인코딩을 통해 `byte[]`로 변환하고, 변환 결과를 `FileOutputStream`에 전달하는 것을 확인할 수 있다.

- 데이터를 읽을 때는 `int ch = read()`를 제공하는데, 여기서는 문자 하나인 `char` 형으로 데이터를 받게 된다. 그런데 실제 반호나 타입은 `int` 형이므로 `char` 형으로 캐스팅해서 사용하면 된다.
- 자바의 `char` 형은 파일의 끝인 `-1`을 표현할 수 없으므로 대신 `int`를 반환한다.
- 그림을 보면 데이터를 읽을 때 `FileInputStream`에서 `byte[]`를 읽은 것을 확인할 수 있다. `InputStreamReader`는 이렇게 읽은 `byte[]`을 문자인 `char`로 변경해서 반환한다. 물론 `byte`를 문자로 변경할 때도 문자 집합이 필요하다.
- `new InputStreamReader(fis, UTF_8)`
`OutputStreamWriter`, `InputStreamWriter` 덕분에 매우 편리하게 문자를 `byte[]`로 변경하고, 그 반대도 가능하다.
Reader, Writer
Reader, Writer

- byte를 다루는 클래스는 `OutputStream`, `InputStream`의 자식이다.
- 부모 클래스의 기본 기능도 `byte` 단위를 다룬다.
- 클래스 이름 마지막에 보통 `OutputStream`, `InputStream`이 붙어있다.

- 문자를 다루는 클래스는 `Writer`, `Reader`의 자식이다.
- 부모 클래스의 기본 기능은 `String`, `char` 같은 문자가 다룬다.
- 클래시 이름 마지막에 보통 `Writer`, `Reader`가 붙어있다.
방금 본 `OutputStreamWriter`는 바로 문자를 다루는 `Writer` 클래스의 자식이다. 그래서 `write(string)`이 가능한 것이다.
FileWriter, FileReader
public class ReaderWriterMainV3 {
public static void main(String[] args) throws IOException {
String writeString = "ABC";
System.out.println("write String: " + writeString);
// 파일에 쓰기
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);
}
}
실행 결과
write String: ABC
read String: ABC
`new FileWriter(FILE_NAME, UTF_8)`
- `FileWriter`에 파일명과 문자 집합(인코딩 셋)을 전달한다.
- `FileWriter`는 사실 내부에서 스스로 `FileOutputStream`을 하나 생성해서 사용한다.
public FileWriter(String fileName, Charset charset) throws IOException {
super(new FileOutputStream(fileName), charset);
}
`fw.write(writeString)`
- 문자를 파일에 직접 쓸 수 있다. (실제로 그런 것은 아니다.)
- 이렇게 문자를 쓰면 `FileWriter` 내부에서는 인코딩 셋을 사용해서 문자를 byte로 변경하고, `FileOutputStream`을 사용해서 파일에 저장한다.
`new FileReader(FILE_NAME, UTF_8)`
- 앞서 설명한 `FileWriter`와 같은 방식으로 작동한다.
- 내부에서 `FileInputStream`을 생성해서 사용한다.
public FileReader(String fileName, Charset charset) throws IOException {
super(new FileInputStream(fileName), charset);
}
`ch = fr.read()`
- 데이터를 읽을 때도 내부에서 `FileInputStream`을 사용해서 데이터를 byte 단위로 읽어 들인다. 그리고 문자 집합을 사용해서 `byte[]`를 `char`로 디코딩한다.
FileWriter와 OutputStream
`FileWriter` 코드와 앞서 작성한 `OuputStream`를 사용한 코드가 뭔가 비슷하다는 점을 알 수 있다. 딱 하나 차이점이 있다면 이전 코드에서는 `FileOutputStream`을 직접 생성했는데, `FileWriter`는 생성자 내부에서 대신 `FileOutputStream`를 생성해 준다.
사실 `FileWriter`는 `OutputStreamWriter`을 상속한다. 그리고 다른 추가 기능도 없다. 딱 하나, 생성자에서 개발자 대신에 `FilieOutputStream`을 생성해 주는 일만 대신 처리해 준다.
따라서 `FileWriter`은 `OutputStreamWriter`를 조금 편리하게 사용하도록 도와줄 뿐이다. `FileReader`도 마찬가지이다.
정리
`Writer`, `Reader` 클래스를 사용하면 바이트 변환 없이 문자를 직접 다룰 수 있어서 편리하다. 하지만 실제로는 내부에서 byte로 변환해서 저장한다. 모든 데이터는 바이트 단위로 다룬다. 문자를 직접 저장할 수는 없다.
BufferedReader
`BufferedOutputStream`, `BufferedInputStream`과 같이 `Reader`, `Writer`에도 버퍼 보조 기능을 제공하는 `BufferedReader`, `BufferedWriter` 클래스가 있다.
추가로 문자를 다룰 때는 한 줄(라인) 단위로 다룰 때가 많다. `BufferedReader`는 한 줄 단위로 문자를 읽는 기능도 추가로 제공한다.
public class ReaderWriterMainV4 {
private static final int BUFFER_SIZE = 8192;
public static void main(String[] args) throws IOException {
String writeString = "ABC\n가나다";
System.out.println("== Write String ==");
System.out.println(writeString);
// 파일에 쓰기
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) {
content.append(line).append("\n");
}
br.close();
System.out.println("== Read String ==");
System.out.println(content);
}
}
실행 결과
== Write String ==
ABC
가나다
== Read String ==
ABC
가나다
`br.readline()`
- 한 줄 단위로 문자를 읽고 `String`을 반환한다.
- 파일의 끝(EOF)에 도달하면 `null`을 반환한다.
- 반환 타입이 `String`이기 때문에 EOF를 -1로 표현할 수 없다. 대신에 `null`을 반환한다.
기타 스트림
PrintStream
`PrintStream`은 우리가 자주 사용해 왔던 `System.out`에서 사용되는 스트림이다.
`PrintStream`과 `FileOutputStream`을 조합하면 마치 콘솔에 출력하듯이 파일에 출력할 수 있다.
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();
}
}
실행 결과 - temp/print.txt
hello java!
10
true
hello world
`PrintStream`의 생성자에 `FileOutputStream`을 전달했다. 이제 이 스트림을 통해서 나가는 출력은 파일에 저장된다. 이 기능을 사용하면 마치 콘솔에 출력하는 것처럼 파일이나 다른 스트림에 문자를 출력할 수 있다.
DataOutputStream
`DataOutputStream`을 사용하면 자바의 `String`, `int`, `double`, `boolean` 같은 데이터 형을 편리하게 다룰 수 있다. 이 스트림과 `FilieOutputStream`을 조합하면 파일에 자바 데이터 형을 편리하게 저장할 수 있다.
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");
dos.writeInt(20);
dos.writeDouble(10.5);
dos.writeBoolean(true);
dos.close();
FileInputStream fis = new FileInputStream("temp/data.dat");
DataInputStream dis = new DataInputStream(fis);
System.out.println(dis.readUTF());
System.out.println(dis.readInt());
System.out.println(dis.readDouble());
System.out.println(dis.readBoolean());
dis.close();
}
}
실행 결과
회원A
20
10.5
true
예제를 보면 자바 데이터 타입을 사용하면서, 회원 데이터를 저장하고 불러오는 것을 확인할 수 있다. 이 스트림을 사용할 때 주의할 점으로는 저장한 순서대로 읽어야 한다는 것이다. 그렇지 않으면 잘못된 데이터가 조회될 수 있다.
저장한 `data.dat` 파일을 직접 열어보면 제대로 보이지 않는다. 왜냐하면 `writeUTF()`의 경우 UTF-8로 저장하지만, 나머지의 경우 문자가 아니라 각 타입에 맞는 byte 단위로 저장하기 때문이다. 예를 들어 , 자바에서 `int`는 4byte를 묶어서 사용한다. 해당 byte가 그대로 저장되는 것이다.
텍스트 편집기는 자신의 문자 집합을 사용해서 byte를 문자로 표현하려고 시도하지만 문자 집합에 없는 단어이거나 또는 전혀 예상하지 않는 문자로 디코딩될 것이다.
Stream 정리
기본(기반, 메인) 스트림
- File, 메모리, 콘솔 등에 직접 접근하는 스트림
- 단독으로 사용할 수 있음
- 예) `FileInputStream`, `FileOutputStream`, `FileReader`, `FileWriter`, `ByteArrayInputStream`, `ByteArrayOutputStream`
보조 스트림
- 기본 스트림을 도와주는 스트림
- 단독으로 사용할 수 없음, 반드시 대상 스트림이 있어야 함
- 예) `BufferedInputStream`, `BufferedOutputStream`, `InputStreamReader`, `OutputStreamWriter`, `DataOutputStream`, `DataInputStream`, `PrintStream`
회원관리 예제
I/O를 사용해서 회원 데이터를 관리하는 예제를 만들어 보자
요구사항
회원의 속성은 다음과 같다.
- ID
- Name
- Age
회원을 등록하고, 등록한 회원의 목록을 조회할 수 있어야 한다.
프로그램 작동 예시
1.회원 등록 | 2.회원 목록 조회 | 3.종료
선택: 1
ID 입력: id1
Name 입력: name1
Age 입력: 20
회원이 성공적으로 등록되었습니다.
1.회원 등록 | 2.회원 목록 조회 | 3.종료
선택: 1
ID 입력: id2
Name 입력: name2
Age 입력: 30
회원이 성공적으로 등록되었습니다.
1.회원 등록 | 2.회원 목록 조회 | 3.종료
선택: 2
회원 목록:
[ID: id1, Name: name1, Age: 20]
[ID: id2, Name: name2, Age: 30]
1.회원 등록 | 2.회원 목록 조회 | 3.종료
선택: 3
프로그램을 종료합니다.
기본 코드
회원 클래스
public class Member {
private String id;
private String name;
private Integer age;
public Member() {
}
public Member(String id, String name, Integer age) {
this.id = id;
this.name = name;
this.age = age;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
@Override
public String toString() {
return "Member{" +
"id='" + id + '\'' +
", name='" + name + '\'' +
", age=" + age +
'}';
}
}
회원을 저장하고 관리하는 인터페이스
public interface MemberRepository {
void add(Member member);
List<Member> findAll();
}
- `add()`: 회원 객체를 저장한다.
- `findAll()`: 저장한 회원 객체를 `List`로 모두 조회한다.
- Repository는 저장소라는 뜻이다.
프로그램 main
public class MemberConsoleMain {
private static final MemberRepository repository = new MemoryMemberRepository();
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
while (true) {
System.out.println("1.회원 등록 | 2.회원 목록 조회 | 3.종료");
System.out.print("선택: ");
int choice = scanner.nextInt();
scanner.nextLine(); // newline 제거
switch (choice) {
case 1:
registerMember(scanner);
break;
case 2:
// 회원 목록 조회
displayMembers();
break;
case 3:
System.out.println("프로그램을 종료합니다.");
return;
default:
System.out.println("잘못된 선택입니다. 다시 입력하세요.");
}
}
}
private static void registerMember(Scanner scanner) {
System.out.print("ID 입력: ");
String id = scanner.nextLine();
System.out.print("Name 입력: ");
String name = scanner.nextLine();
System.out.print("Age 입력: ");
int age = scanner.nextInt();
scanner.nextLine(); // newline 제거
Member newMember = new Member(id, name, age);
repository.add(newMember);
System.out.println("회원이 성공적으로 등록되었습니다.");
}
private static void displayMembers() {
List<Member> members = repository.findAll();
System.out.println("회원 목록:");
for (Member member : members) {
System.out.printf("[ID: %s, Name: %s, Age: %d]\n", member.getId(), member.getName(), member.getAge());
}
}
}
- 콘솔을 통해 회원 등록, 목록 조회 기능을 제공한다.
메모리
public class MemoryMemberRepository implements MemberRepository {
private final List<Member> members = new ArrayList<>();
@Override
public void add(Member member) {
members.add(member);
}
@Override
public List<Member> findAll() {
return members;
}
}
- 간단하게 메모리에 회원을 저장하고 관리한다.
- 회원을 저장하면 내부에 있는 `members` 리스트에 회원이 저장된다.
- 회원을 조회하면 `members` 리스트가 반환된다.
이 프로그램은 잘 작동하지만, 데이터를 메모리에 보관하기 때문에 자바를 종료하면 모든 회원 정보가 사라진다. 따라서 프로그램을 다시 실행하면 모든 회원 데이터가 사라진다.
프로그램을 종료하고 다시 실행해도 회원 데이터가 영구 보존되어야 한다.
파일에 보관
회원 데이터를 영구 보존하려면 파일에 저장하면 된다.
temp/members-txt.dat
id1,member1,20
id2,member2,30
- 문자를 파일에 저장한다. 문자를 다루므로 `Reader`, `Writer`를 사용하는 것이 편리하다.
- 한 줄 단위로 처리할 때는 `BufferedReader`가 유용하므로 `BufferedReader`, `BufferedWriter`을 사용하면 된다.
코드
public class FileMemberRepository implements MemberRepository {
private static final String FILE_PATH = "temp/members-txt.dat";
private static final String DELIMITER = ",";
@Override
public void add(Member member) {
try (BufferedWriter bw = new BufferedWriter(new FileWriter(FILE_PATH, UTF_8, true))) {
bw.write(member.getId() + DELIMITER + member.getName() + DELIMITER + member.getAge());
bw.newLine();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@Override
public List<Member> findAll() {
List<Member> members = new ArrayList<>();
try (BufferedReader br = new BufferedReader(new FileReader(FILE_PATH, UTF_8))) {
String line;
while ((line = br.readLine()) != null) {
String[] memberData = line.split(DELIMITER);
members.add(new Member(memberData[0], memberData[1], Integer.valueOf(memberData[2])));
}
return members;
} catch (FileNotFoundException e) {
return new ArrayList<>();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
- `DELIMITER`: 회원 데이터는 `id1`, `member1`, `20`와 같이 `,`(쉼표)로 구분한다.
참고 - 빈 컬렉션 반환
- 빈 컬렉션을 반환할 때는 `new ArrayList()` 보다는 `List.of()`를 사용하는 것이 좋다.
- 이번 예제에서도 `List.of()`를 사용하는 것이 좋지만, 뒤에 나오는 `ObjectStream` 부분과 내용을 맞추기 위해 빈 컬렉션에 `new ArrayList()`를 사용했다.
회원 저장
bw.write(member.getId() + DELIMITER + member.getName() + DELIMITER + member.getAge());
bw.newLine();
- 회원 객체의 데이터를 읽어서 `String` 문자로 변환한다. 여기서 `write()`는 `String`을 입력으로 받는다. 그리고 `DELIMITER`를 구분자로 사용한다.
- 각 회원을 구분하기 위해 `newLine()`을 통해 다음 줄로 이동한다.
회원 조회
- `line = br.readLine()`을 통해 각 회원 하나하나를 불러온다.
- `String[] memberData = line.split(DELIMITER)`
- 회원 데이터를 `DELIMITER` 구분자로 구분해서 배열에 담는다.
- `members.add(new Member(memberData[0], memberData[1], Integer.valueOf(memberData[2])`
- 파일에 읽은 데이터를 기반으로 회원 객체를 생성한다.
- `age`의 경우 문자(`String`)으로 조회했기 때문에 숫자인 `Integer`로 변경해야 한다.
- `FileNotFounedException e`
- 회원 데이터가 하나도 없을 때는 파일이 존재하지 않는다. 따라서 해당 예외가 발생한다. 이 경우 회원 데이터가 하나도 없는 것으로 보고 빈 리스트를 반환한다.
문제
모든 타입을 문자로 저장하는 문제
public class Member {
private String id;
private String name;
private Integer age;
}
- 회원 객체는 `String`, `Integer` 같은 자바의 다양한 타입을 사용한다.
- 그런데 이런 타입을 무시하고 모든 데이터를 문자로 변경해서 저장하는 부분이 아쉽다.
- `age`의 경우 문자를 숫자로 변경하기 위한 코드도 따로 작성해야 한다.
구분자(`DELIMITER`)를 사용하는 문제
- `id`, `name`, `age` 각 필드를 구분하기 위해 구분자를 넣어서 저장하고, 또 조회할 때도 구분자를 사용해서 각 필드를 구분해야 한다.
DataStream
코드
public class DataMemberRepository implements MemberRepository {
private static final String FILE_PATH = "temp/members-data.dat";
@Override
public void add(Member member) {
try (DataOutputStream dos = new DataOutputStream(new FileOutputStream(FILE_PATH, true))) {
dos.writeUTF(member.getId());
dos.writeUTF(member.getName());
dos.writeInt(member.getAge());
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@Override
public List<Member> findAll() {
List<Member> members = new ArrayList<>();
try (DataInputStream dis = new DataInputStream(new FileInputStream(FILE_PATH))) {
while (dis.available() > 0) {
Member member = new Member(dis.readUTF(), dis.readUTF(), dis.readInt());
members.add(member);
}
return members;
} catch (FileNotFoundException e) {
return new ArrayList<>();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
회원 저장
dos.writeUTF(member.getId());
dos.writeUTF(member.getName());
dos.writeInt(member.getAge());
- 회원을 저장할 때는 회원 필드의 타입에 맞는 메서드를 호출하면 된다.
- 이전 예제에서는 각 회원을 한 줄 단위로 구분했는데, 여기서는 그런 구분이 필요 없다.
회원 조회
new Member(dis.readUTF(), dis.readUTF(), dis.readInt()
- 회원 데이터를 조회할 때는 회원 필드의 각 타입에 맞는 메서드를 사용해서 조회하면 된다.
원리
`DataStream`은 어떤 원리로 구분자나 한 줄 라인 없이 데이터를 저장하고 조회할 수 있는 것일까?
String
dos.writeUTF("id1"); // 저장
dis.readUTF(); // 조회 id1
- `writeUTF()`는 UTF-8 형식으로 문자를 저장하는데, 저장할 때 2byte를 추가로 사용해서 앞에 글자의 길이를 저장해 둔다. (65535 길이까지만 사용 가능)
3id1(2byte(문자 길이) + 3byte(실제 문자 데이터))
- 따라서 `readUTF()`로 읽어 들일 때 먼저 앞의 2byte로 글자의 길이를 확인하고 해당 길이만큼 글자를 읽어 들인다.
- 이 경우 2byte를 사용해서 3이라는 문자의 길이를 숫자로 보관하고, 나머지 3byte로 실제 문자 데이터를 보관한다.
기타 타입
dos.writeInt(20);
dis.readInt()
- 자바의 `Int(Integer)`는 4byte를 사용하기 때문에 4byte를 사용해서 파일에 저장하고, 읽을 때도 4byte를 읽어서 복원한다.
저장 예시
dos.writeUTF("id1");
dos.writeUTF("name1");
dos.writeInt(20);
dos.writeUTF("id2");
dos.writeUTF("name2");
dos.writeInt(30);
저장된 파일 예시
3id1(2byte(문자 길이) + 3byte)
5name1(2byte(문자 길이) + 5byte)
20(4byte)
3id2(2byte(문자 길이) + 3byte)
5name2(2byte(문자 길이) + 5byte)
30(4byte)
- 실제로는 엔터 없이 한 줄로 연결되어 있다.
- 저장된 파일은 문자와 byte가 섞여있다.
정리
`DataStream` 덕분에 자바의 타입도 그대로 사용하고, 구분자도 제거할 수 있었다. 추가로 모든 데이터를 문자로 저장할 때보다 저장 용량도 더 최적화할 수 있다.
예를 들어서 숫자의 1,000,000,000(10억)을 문자로 저장하게 되면 총 10byte가 사용된다. 왜냐하면 숫자 1 0 0 0 0 0 0 0 0 0 각각 하나하나를 문자로 저장해야 하기 때문에 ASCII 인코딩을 해도 각각 1byte가 사용된다. 하지만 이것을 자바의 int와 같이 4byte를 사용해서 저장한다면 4byte만 사용하게 된다.
물론 이렇게 byte를 직접 저장하면, 문서 파일을 열어서 확인하고 수정하는 것이 어렵다는 단점도 있다.
문제
`DataStream` 덕분에 회원 데이터를 더 편리하게 저장할 수 있는 것은 맞지만, 회원 필드 하나하나를 다 조회해서 각 타입에 맞도록 따로따로 저장해야 한다. 이것은 회원 객체를 저장한다기보다는 회원 데이터를 하나하나 분류해서 따로 저장한 것이다.
ObjectStream
회원 인스턴스도 메모리 어딘가에 보관되어 있다. 이렇게 메모리에 보관되어 있는 객체를 읽어서 파일에 저장하기만 하면 아주 간단하게 회원 인스턴스를 저장할 수 있을 것 같다.
`ObjectStream`을 사용하면 이렇게 메모리에 보관되어 있는 회원 인스턴스를 파일에 편리하게 저장할 수 있다. 마치 자바 컬렉션에 회원 객체를 보관하듯이 말이다.
객체 직렬화
자바의 객체 직렬화(Serialization)는 메모리에 있는 객체 인스턴스를 바이트 스트림으로 변환하여 파일에 저장하거나 네트워크를 통해 전송할 수 있도록 하는 기능이다. 이 과정에서 객체의 상태를 유지하여 나중에 역직렬화(Deserialization)를 통해 원래의 객체로 복원할 수 있다.
객체 직렬화를 사용하려면 직렬화하려는 클래스는 반드시 `Serialization` 인터페이스를 구현해야 한다.
Serializable 인터페이스
package java.io;
public interface Serializable {
}
- 이 인터페이스에는 아무런 기능이 없다. 단지 직렬화 가능한 클래스라는 것을 표시하기 위한 인터페이스일 뿐이다.
- 메서드 없이 단지 표시가 목적인 인터페이스를 마커 인터페이스라 한다.
만약 해당 인터페이스가 없는 객체를 직렬화하면 다음과 같은 예외가 발생한다.
java.io.NotSerializableException: io.member.Member
코드
public class ObjectMemberRepository implements MemberRepository {
private static final String FILE_PATH = "temp/members-obj.dat";
@Override
public void add(Member member) {
List<Member> members = findAll();
members.add(member);
try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(FILE_PATH))) {
oos.writeObject(members);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@Override
public List<Member> findAll() {
try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(FILE_PATH))) {
Object findObject = ois.readObject();
return (List<Member>) findObject;
} catch (FileNotFoundException e) {
return new ArrayList<>();
} catch (IOException | ClassNotFoundException e) {
throw new RuntimeException(e);
}
}
}
실행 결과 - temp/members-obj.dat
파일이 정상 보관된다. 문자와 byte가 섞여있다.
직렬화
- `ObjectOutputStream`를 사용하면 객체 인스턴스를 직렬화하여 byte로 변경할 수 있다.
- 우리는 회원 객체 하나가 아니라 회원 목록 전체를 파일에 저장해야 하므로 `members` 컬렉션을 직렬화해야 한다.
- `oos.writeObject(members)`를 호출하면 `members` 컬렉션과 그 안에 포함된 `Member`를 모두 직렬화해서 byte로 변경한다. 그리고 `oos`와 연결되어 있는 `FileOutputStream`에 결과를 출력한다.
역직렬화
- `ObjectInputStream`을 사용하면 byte를 역직렬화해서 객체 인스턴스로 만들 수 있다.
- `Object findObject = ois.readObject()`를 사용하면 역직렬화가 된다. 이때 반환 타입이 `Object`이므로 캐스팅해서 사용해야 한다.
정리
객체 직렬화 덕분에 객체를 매우 편리하게 저장하고 불러올 수 있었다. 객체 직렬화를 사용하면 객체를 바이트로 변환할 수 있어, 모든 종류의 스트림에 전달할 수 있다. 이는 파일에 저장하는 것은 물론 네트워크를 통해 객체를 전송하는 것도 가능하게 한다. 이러한 특성 때문에 초기에는 분산 시스템에서 활용되었다.
1990년대에 등장한 기술로, 초창기에는 인기가 있었지만 시간이 지나면서 여러 단점이 드러났다. 또한 대안 기술이 등장하면서 점점 그 사용이 줄어들게 되었다. 현재는 객체 직렬화를 거의 사용하지 않는다.
- `serialVersionUID`: 객체 직렬화 버전을 관리
- `transient` 키워드: `transient`가 붙어있는 필드는 직렬화하지 않고 무시한다.
XML, JSON, 데이터베이스
객체 직렬화의 한계
- 버전 관리의 어려움
- 클래스 구조가 변경되면 이전에 직렬화된 객체와의 호환성 문제가 발생한다.
- serialVersoinUID 관리가 복잡하다
- 플랫폼 종속성
- 자바 직렬화는 자바 플랫폼에 종속적이어서 다른 언어나 시스템과의 상호 운용성이 떨어진다.
- 성능 이슈
- 직렬화/역직렬화 과정이 상대적으로 느리고 리소스를 많이 사용한다.
- 유연성 부족
- 직렬화된 형식을 커스터마이즈 하기 어렵다
- 크기 효율성
- 직렬화된 데이터의 크기가 상대적으로 크다
XML
<member>
<id>id1</id>
<name>name1</name>
<age>20</age>
</member>
플랫폼 종속성 문제를 해결하기 위해 2000년대 초반에 XML이라는 기술이 인기를 끌었다. XML은 매우 유연하고 강력했지만, 복잡성과 무거움이라는 문제가 있었다. 태그를 포함한 XML 문서의 크기가 커서 네트워크 전송 비용도 증가했다.
JSON
{ "member": { "id": "id1", "name": "name1", "age": 20 } }
JSON은 가볍고 간결하며, 자바 스크립트와의 자연스러운 호환성 덕분에 웹 개발자들 사이에서 빠르게 확산되었다. 2000년 대 후반, 웹 API와 RESTful 웹 서비스가 대중화되면서 JSON은 표준 데이터 교환 포맷으로 자리 잡았다.
XML은 데이터 구조의 복잡성과 엄격한 스키마 정의가 필요한 초기 웹 서비스와 엔터프라이즈 환경에서 중요한 역할을 했지만, 시간이 지나면서 JSON과 같은 가볍고 효율적인 데이터 형식이 더 많이 채택되었다.
XML은 특정 영역에서 여전히 사용되지만, JSON이 현대 소프트웨어 개발의 주류로 자리 잡았다. 지금은 웹 환경에서 데이터를 교환할 때 JSON이 사실상 표준 기술이다.
Protobut, Avro - 더 작은 용량, 더 빠른 성능
- JSON은 거의 모든 곳에서 호환이 가능하고, 사람이 읽고 쓰기 쉬운 텍스트 기반 포맷이어서 디버깅과 개발이 쉽다.
- 만약 매우 작은 용량으로 더 빠른 속도가 필요하다면 Protobuf, Avro 같은 대안 기술이 있다.
- 이런 기술은 호환성은 떨어지지만 byte 기반에, 용량과 성능 최적화가 되어있으므로 매우 빠르다.
- 다만 byte 기반이므로 JSON처럼 사람이 직접 읽기는 어렵다.
정리
- 자바 객체 직렬화는 대부분 사용하지 않는다.
- JSON이 사실상 표준이다. JSON을 먼저 고려하자
- 성능 최적화가 매우 중요하다면 Protobuf, Avro 같은 기술을 고려하자. (대부분 JSON만 사용해도 충분하다.)
데이터베이스
구조화된 데이터를 주고받을 때는 JSON 형식을 주로 사용한다. 하지만 어떤 형식이든 데이터를 저장할 때, 파일에 데이터를 직접 저장하는 방식은 몇 가지 큰 한계가 있다.
- 데이터의 무결성을 보장하기 어렵다. 여러 사용자가 동시에 파일을 수정하거나 접근하려고 할 때, 데이터의 충돌이나 손상 가능성이 높아진다. 이러한 경우, 데이터의 일관성을 유지하는 것이 매우 어렵다.
- 데이터 검색과 관리의 비효율성이다. 파일에 저장된 데이터는 특정 형식 없이 단순히 저장될 수 있기 때문에 필요한 데이터를 빠르게 찾는데 많은 시간이 소요될 수 있다. 특히, 데이터의 양이 방대해질수록 검색 속도는 급격히 저하된다.
- 보안 문제이다. 파일 기반 시스템에서는 민감한 데이터를 안전하게 보호하기 위한 접근 제어와 암호화 등이 충분히 구현되어 있지 않을 수 있다. 결과적으로, 데이터 유출이나 무단 접근의 위험이 커질 수 있다.
- 대규모 데이터의 효율적인 백업과 복구가 필요하다.
이러한 문제점들을 하나하나 해결하면서 발전한 서버 프로그램이 바로 데이터베이스이다. 데이터베이스는 위의 한계들을 극복하고, 대량의 데이터를 효율적으로 저장, 관리, 검색할 수 있는 강력한 도구를 제공한다.
'Java > 김영한' 카테고리의 다른 글
| [Java/김영한] 네트워크 프로그램 (0) | 2025.10.14 |
|---|---|
| [Java/김영한] File, Files (0) | 2025.10.12 |
| [Java/김영한] 문자 인코딩 (0) | 2025.10.08 |
| [Java/김영한] 스레드 풀과 Executor 프레임워크 (0) | 2025.10.05 |
| [Java/김영한] 동시성 컬렉션 (0) | 2025.10.02 |