Java를 처음 공부할때 처음 배우는 내용이 콘솔에 메시지를 출력하는
System.out.printlan(Hello World)와 사용자의 입력을 받는scanner.next(Helle World)일것이다.
처음에는 단순히 출력하는 코드와 입력하는 코드 정도로만 생각했을지 모르지만 두 코드는 Java의 입출력(I/O)의 시작점이다. 지금부터 Java의 입출력(I/O)에 대해 알아보자.
I/O(Input/Output) 는 프로그램이 외부와 데이터를 주고받는 과정을 의미한다.
| 구분 | 설명 | 예시 |
|---|---|---|
| Input | 데이터를 외부에서 읽어옴 | 키보드 입력, 파일 읽기, 네트워크 수신 |
| Output | 데이터를 외부로 보냄 | 콘솔 출력, 파일 저장, 서버 응답 |
Java 입장에서 외부에서 데이터를 입력받는 것이 Input이고, 데이터를 외부로 보내는 것이 Output이다.
우리가 처음 Java를 배울때 흔하게 사용했던 System.out.println()과 Scanner.next()역시 모두 I/O의 한 형태다.
System.out.println()은 콘솔로 데이터를 출력하는 Output 작업이고, Scanner.next()는 사용자 입력을 받아들이는 Input 작업이다.
System.out.println("Hello World"); // 출력(Output)
Scanner scanner = new Scanner(System.in);
String input = scanner.nextLine(); // 입력(Input)
Java는 어떠한 방식으로 외부에서 데이터를 주고 받을 수 있는 것일까? 지금부터 Java에서 데이터를 어떻게 주고 받는지 알아보자
예를 들어 Java에서 텍스트 파일을 저장하거나 불러올려면 어떻게 해야야될까? 이렇게 외부에 값을 저장하거나 값을 불러올 때 사용하는 것이 스트림(Stream)이다.
스트림(Stream)은 데이터가 한 방향으로 흐르는 데이터 통로를 의미한다.
Java에서는 파일 읽기, 키보드 입력, 네트워크 통신 등 모든 데이터 입출력을 스트림이라는 공통된 방식으로 처리하도록 설계되어 있다.
자바 스트림은 입력을 받는 InputStream과 출력을 하는 OutputStream이 추상클래스로 제공되며 각각의 구체 클래스들은 이 추상 클래스를 상속받아 파일, 네트워크, 메모리 등 다양한 입출력 기능을 제공한다.

InputStream을 상속받아 구현된 FileInputStream, ByteArrayInpustream, SocketInputSream과 OutputStream을 상속받아 구현된 FileOutputStream, ByteArrayOutputStream, SocketOutputStream 등이 있다.
클래스의 이름을 살펴보면 xxxInputSream 혹은 xxxOutputStream처럼 입력과 출력이 쌍을 이루는 형태로 구성되어 있다는 것을 알 수 있다. 이제 각각의 스트림에 대해 알아보자
파일의 값을 읽거나 저장할때 사용하는 스트림이다.
파일 스트림 코드
public class StreamMain {
private static final String FILE_NAME = "hello.txt";
public static void main(String[] args) throws IOException {
FileOutputStream fos = new FileOutputStream(FILE_NAME);
String outputData = "Hello World 안녕";
byte[] bytes = outputData.getBytes(UTF_8);
fos.write(bytes);
fos.close();
FileInputStream fis = new FileInputStream(FILE_NAME);
byte[] inputBytes = fis.readAllBytes();
String inputData = new String(inputBytes, UTF_8);
System.out.println("inputData = " + inputData);
}
}
FileOutputStream의 write()를 사용하면 FILE_NAME 경로의 파일에 값을 저장할 수 있다. FILE_NAME 존재하면 해당 파일을 사용 없으면 생성한다.Stream을 통한 모든 입출력은 Byte로 진행되기 때문에 outputData를 UTF-8로 인코딩한 것을 확인할 수 있다.FileInputStream으로 읽은 inputBytes 또한 Byte이기 때문에 UTF-8로 디코딩하여 문자열로 복원한 것을 확인할 수 있다.close()를 사용하여 자원을 정리해 줘야 한다.실행 결과
inputData = Hello World 안녕
바이트 배열에 저장된 데이터를 읽기 위한 입력 스트림이다.
ByteArray Stream 코드
public class StreamMain {
public static void main(String[] args) throws IOException {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
String outputData = "Hello World 안녕";
byte[] bytes = outputData.getBytes(UTF_8);
bos.write(bytes);
ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());
byte[] inputBytes = bis.readAllBytes();
String inputData = new String(inputBytes, UTF_8);
System.out.println("inputData = " + inputData);
}
}
Input/Output Stream을 상속받아 생성되었기 때문에 파일 스트림과 사용방법이 동일한 것을 확인할 수 있다.ByteArray Input/Output Stream의 경우 파일 스트림과 달리 외부 자원을 사용하는 것이 아니기 때문에 GC 대상이다. 그렇기에 close()를 호출하지 않아도 된다.실행 결과
inputData = Hello World 안녕
네트워크 환경에서 데이터를 주고받기 위한 가장 기본적인 방식은 TCP 소켓 통신이다. 자바는 이를 위해 Socket과 ServerSocket 클래스를 제공하며, 통신이 연결되면 내부적으로 바이트 기반 스트림인 SocketInputStream과 SocketOutputStream을 통해 데이터가 흐르게 된다.
| 스트림 | 역할 | 생성 방법 |
|---|---|---|
SocketInputStream | 상대방이 보낸 데이터 수신 | socket.getInputStream() |
SocketOutputStream | 상대방에게 데이터 전송 | socket.getOutputStream() |
Socket과 ServerSocket간단 정리
| 클래스 | 사용 위치 | 역할 |
|---|---|---|
ServerSocket | 서버 | 클라이언트 연결을 기다리며, 연결 요청이 오면 Socket을 생성하여 통신을 시작 |
Socket | 클라이언트 & 서버 | 실제 데이터 송수신이 이루어지는 통신 객체 |
ServerSocket은 연결을 받아주는 문지기 역할이고, 실제로 데이터가 흐르는 통로는 Socket에서 만들어진 입출력 스트림이다.
public class Server {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(12345);
Socket socket = serverSocket.accept();
DataInputStream input = new DataInputStream(socket.getInputStream());
DataOutputStream output = new DataOutputStream(socket.getOutputStream());
String inputData = input.readUTF();
System.out.println("inputData = " + inputData);
String outputData = inputData + " World!!!";
output.writeUTF(outputData);
input.close();
output.close();
socket.close();
serverSocket.close();
}
}
public class Client {
public static void main(String[] args) throws IOException {
Socket socket = new Socket("localhost", 12345);
DataInputStream input = new DataInputStream(socket.getInputStream());
DataOutputStream output = new DataOutputStream(socket.getOutputStream());
String outputData = "Hello";
output.writeUTF(outputData);
System.out.println("inputData = " + input.readUTF());
input.close();
output.close();
socket.close();
}
}
ServerSocket은 클라이언트의 연결을 대기하기 위한 소켓이며 accept()가 호출되면 연결 요청이 올 때까지 블로킹된다.
accept()를 통해 반환되는 Socket 객체는 실제로 데이터를 주고받는 통신 담당 소켓이다.
socket.getInputStream()/getOutputStream()을 호출하면 내부적으로 SocketInputStream과 SocketOutputStream이 생성된다.
InputStream/OutputStream은 바이트 기반 스트림이기 때문에 문자열 전송을 편리하게 하기 위해 보조 스트림인DataInputStream과 DataOutputStream을 감싸서 사용했다.
네트워크 통신도 파일과 마찬가지로 외부 자원을 사용하므로 GC 대상이 아니다. 반드시 close()를 호출하여 자원을 해제해야 한다.
서버 클래스 실행 결과
inputData = Hello
클라이언트 클래스 실행 결과
inputData = Hello World!!!
앞서 살펴본 InputStream과 OutputStream은 모두 바이트 단위로 데이터를 처리하는 스트림이다. 바이트 스트림은 문자 데이터를 처리할 때 매번 문자열을 바이트로 혹은 바이트를 문자열로 변경해야 하는 번거로움이 있었다.
byte[] bytes = fis.readAllBytes();
String inputData = new String(bytes, UTF_8);
이처럼 문자열 처리 시 불편함을 줄이기위해 문자 전용 스트림인 Reader/Wirter를 별도로 제공한다.

Reader Writer 코드
public class StreamMain {
private static final String FILE_NAME = "hello.txt";
public static void main(String[] args) throws IOException {
FileWriter fw = new FileWriter(FILE_NAME, UTF_8);
fw.write("Hello World!!");
fw.close();
FileReader fr = new FileReader(FILE_NAME, UTF_8);
StringBuilder sb = new StringBuilder();
int ch;
while ((ch = fr.read()) != -1) {
sb.append((char) ch);
}
System.out.println(sb);
}
}
FILE_NAME 경로에 텍스트 데이터를 저장하기 위한 문자 출력 스트림이다. write() 메서드로 문자열을 그대로 저장할 수 있으며 바이트 변환 과정을 신경쓰지 않아도 된다.read() 메서드는 한 글자(char)를 정수(int)로 반환하는데 파일의 끝이면 -1을 반환한다.Reader와 Writer를 사용한 위 코드를 보면 바이트 기반 스트림InputStream/OutputStream을 직접 다룰 때보다 훨씬 간편한 것을 확인할 수 있다. 문자열을 바이트 배열로 변환하거나 인코딩을 지정하는 과정 없이 곧바로 읽고 쓸 수 있기 때문이다.
하지만 주의할 점이 하나 있다.
Reader와 Writer는 독립적으로 동작하는 별도의 입출력 방식이 아니다. 내부적으로는 여전히 바이트 기반 스트림을 사용하고 있으며, 그 위에서 문자 변환(인코딩/디코딩)을 도와주는 역할을 할 뿐이다.
즉 Reader와 Writer는 문자 처리를 쉽게 해주는 Wrapper일 뿐이고, 실제 데이터는 결국 바이트 단위로 입출력된다.
실행 결과
Hello World!!
앞에서 살펴본 InputStream, OutputStream, Reader, Writer는 모두 기본 스트림이다. 기본 스트림은 데이터의 출발지나 도착지(파일, 메모리, 네트워크 등)와 직접 연결되는 스트림이다.
하지만 기본 스트림만으로는 다음과 같은 불편함이 있다.
이러한 한계를 개선하기 위해 자바는 보조 스트림을 제공한다.
보조 스트림의 종류는 아래와 같다.
| 종류 | 설명 | 예시 클래스 |
|---|---|---|
| 버퍼링 스트림 | 입출력 성능 향상 | BufferedInputStream, BufferedOutputStream, BufferedReader, BufferedWriter |
| 변환 스트림 | 바이트 ↔ 문자 변환 | InputStreamReader, OutputStreamWriter |
| 기본 타입 스트림 | int, double 등 기본형 입출력 지원 | DataInputStream, DataOutputStream |
| 객체 스트림 | 객체 단위로 데이터 전송 | ObjectInputStream, ObjectOutputStream |
| 기타 필터 스트림 | 기능 추가 | PrintWriter 등 |
보조 스트림 사용 전
public class StreamMain {
private static final String FILE_NAME = "hello.txt";
private static final int FILE_SIZE = 1024 * 1024 * 10; // 10MB
public static void main(String[] args) throws IOException {
FileOutputStream fos = new FileOutputStream(FILE_NAME);
long outputStartTime = System.currentTimeMillis();
for (int i = 0; i < FILE_SIZE; i++) { //1바이트씩 작성
fos.write(0);
}
long outputEndTime = System.currentTimeMillis();
System.out.println("실행 결과:" + (outputEndTime - outputStartTime) + "ms");
fos.close();
long inputStartTime = System.currentTimeMillis();
FileInputStream fis = new FileInputStream(FILE_NAME);
int ch;
while ((ch = fis.read()) != -1) { // 1바이트씩 읽기
}
long inputEndTime = System.currentTimeMillis();
System.out.println("실행 결과:" + (inputEndTime - inputStartTime) + "ms");
fis.close();
}
}
실행결과
실행 결과:17409ms
실행 결과:5726ms
10MB 파일을 쓰고 읽는데 각각 17초, 5.7초가 걸린 것을 확인할 수 있다. 이렇게 오래걸린 이유는 1바이트씩 데이터를 디스크에 전달하고 읽었기 때문이다.
FileOutputStream/FileInputStream을 1바이트 단위로 호출하고 있다. 10MB 파일이면 write()/read()가 1000만 번 이상 호출되어 시스템 호출 수가 폭증하고, 오랜시간이 걸린것이다.이 문제를 어떻게 해결하면 좋을까? 바로 한번에 더 많은 데이터들을 보내면 된다.
Buffered Reader/Writer 사용
public class StreamMain {
private static final String FILE_NAME = "hello.txt";
private static final int FILE_SIZE = 1024 * 1024 * 10; // 10MB
public static void main(String[] args) throws IOException {
BufferedWriter bw = new BufferedWriter(new FileWriter(FILE_NAME));
long outputStartTime = System.currentTimeMillis();
for (int i = 0; i < FILE_SIZE; i++) {
bw.write(0);
}
long outputEndTime = System.currentTimeMillis();
System.out.println("실행 결과:" + (outputEndTime - outputStartTime) + "ms");
bw.close();
long inputStartTime = System.currentTimeMillis();
BufferedReader br = new BufferedReader(new FileReader(FILE_NAME));
int ch;
while ((ch = br.read()) != -1) {
}
long inputEndTime = System.currentTimeMillis();
System.out.println("실행 결과:" + (inputEndTime - inputStartTime) + "ms");
br.close();
}
}
실행 결과
실행 결과:170ms
실행 결과:159ms
실행 결과를 살펴보면 각각 0.17초(쓰기), 0.15초(읽기)로 기존 FileOutputStream, FileInputStream을 사용했을 때와 비교하여 압도적으로 빠른 속도를 보여준다. 그렇다면 BufferedWriter와 BufferedReader는 어떻게 이렇게 빠른 입출력을 가능하게 했을까?
버퍼란?
BufferedWriter와 BufferedReader가 빠른 이유는 내부에 버퍼(buffer) 라는 임시 저장 공간을 사용하기 때문이다. 버퍼는 데이터를 바로 목적지로 보내지 않고 일정량을 메모리에 모아두었다가 한 번에 처리하는 역할을 한다.
일반적인 I/O는 디스크나 운영체제와 직접 상호작용해야 하기 때문에 매우 비용이 크다. 예를 들어 아래처럼 10MB 파일을 1바이트씩 쓰는 경우를 생각해보자.
for (int i = 0; i < FILE_SIZE; i++) {
fos.write(0); // 1바이트씩 기록
}
이 방식은 write() 메서드가 1000만 번 호출되며 매번 디스크 쓰기 요청이 발생해 성능이 매우 느려진다.
왜 Buffered Stream은 빠른가?
파일 입출력은 운영체제의 도움 없이 할 수 없기 때문에 반드시 시스템 콜을 거쳐야 한다. 그러나 시스템 콜은 일반 메서드 호출과 비교할 수 없을 만큼 비용이 큰 작업이다.
BufferedWriter와 BufferedReader는 데이터를 입력하거나 출력할 때 매번 디스크와 직접 통신하지 않는다. 대신 내부에 마련된 버퍼에 데이터를 임시로 저장해 두었다가 버퍼가 가득 차거나 close(), flush()가 호출되는 시점에 한 번에 처리한다.
이 방식 덕분에 시스템 콜 횟수가 크게 줄어들며 I/O 오버헤드가 감소하고 전체 성능이 크게 향상되는 것이다.
앞에서는 스트림을 사용해 파일의 내용을 읽고 쓰는 방법을 살펴보았다. 그런데 실제 파일 작업은 내용 입출력뿐만 아니라 파일 생성, 삭제, 이름 변경, 디렉토리 탐색, 존재 여부 확인 같은 기능도 필요하다. 이러한 파일과 디렉토리 자체를 다루는 작업은 File 클래스와 Files 유틸리티를 통해 수행할 수 있다.
File 클래스는 다음과 같은 작업에 사용된다.
| 기능 | 예시 |
|---|---|
| 파일/디렉토리 정보 확인 | exists(), length(), isFile() |
| 파일 생성/삭제 | createNewFile(), delete() |
| 디렉토리 생성 | mkdir(), mkdirs() |
| 경로 탐색 | listFiles() |
File file = new File("example.txt");
System.out.println(file.getName()); // 파일 이름
System.out.println(file.getAbsolutePath()); // 절대 경로
System.out.println(file.exists()); // 존재 여부 확인
System.out.println(file.isFile()); // 파일인지 확인
System.out.println(file.isDirectory()); // 디렉토리인지 확인
Java 7에서 도입된 파일 유틸리티 클래스로, File 클래스를 보완하기 위해 만들어졌다. Files는 파일 복사, 이동, 삭제, 읽기/쓰기 같은 실제 파일 작업을 간결하게 처리할 수 있다.
Path path = Path.of("example.txt");
// 파일 생성
Files.createFile(path);
// 파일 쓰기
Files.writeString(path, "Hello Files!");
// 파일 읽기
String content = Files.readString(path);
System.out.println(content);
// 파일 복사
Files.copy(path, Path.of("backup.txt"), REPLACE_EXISTING);
// 파일 삭제
Files.delete(path);
InputStream과 OutputStream으로 외부와 데이터를 주고 받는다.InputStream과 OutputStream은 모두 바이트를 기반으로 데이터를 주고 받는다.Reader/Wirter가 있다.Reader와 Wirter도 내부적으로 Stream을 사용한다.제가 공부한 내용을 정리한 것이라 틀린 내용이 있을 수 있습니다. 보시고 틀린 내용을 알려주시면 감사하겠습니다.
참고자료
김영한의 실전 자바 - 고급 2편