Java File I/O 를 이용하여 코딩을 했어야 했는데, 전에 대충 넘어갔던 게 화살이 되어 다시 날라왔다. 이 기회에 개념을 제대로 파악하고자 한다.
The Java™ Tutorials: Basic I/O 파트는 I/O에 대해 굉장히 잘 설명해 놓았다. 두 파트로 크게 나뉜다. I/O Streams 그리고 File I/O. 공식문서엔 다음과 같이 서두에 말한다.
It first focuses on I/O Streams, a powerful concept that greatly simplifies I/O operations.
I/O 스트림은 핵심 개념이라고 하기 때문에 일단 이 부분에 집중해서 개념들을 살펴 보려고 한다.
Stream 이란 무엇일까? 공식문서에선 이렇게 말한다.
An I/O Stream represents an input source or an output destination.
A stream is a sequence of data.
데이터의 sequence. sequence 는 순서가 있는 흐름이다. 그 순서는
이렇게 두가지이다.
중요한건 이 스트림이 굳이 필요한건지, 필요하다면 왜 필요한건지 아는 것이 중요하다. 그래야 응용이 가능해진다. 왜 필요한건지는 위 링크에서 살짝 언급되어 있다.
A stream can represent many different kinds of sources and destinations
다른 종류의 데이터 소스, 대상을 나타낼 수 있다고 한다.
이 때, 내가 질문했던 건 두가지였다.
순서. 순서가 필요한 작업이라면 그냥 인덱스가 있는 배열이나 문자열로 처리할 수 있는 거 아닌가? 왜 굳이 스트림이 필요할까?
여러 종류의 데이터 소스. 다른 데이터의 종류마다 다 다르게 처리하지 않고 추상화를 하여 관리하는게 편하다는 건 당연한 사실이다. Java I/O 에서는 이걸 어떻게 추상화하였을까?
이 질문에 대한 답은 다음 장에서 찾을 수 있었다.
프로그램은 기본적으로 byte streams를 사용하여 8-bit bytes의 입출력을 수행한다. Java 에서 모든 byte stream class 들은 InputStream, OutputStream 을 기반으로 하고 있다. 이 두개의 클래스들은 abstract class 이다. 왜 abstract class로 설계를 했을까? 그 이유는 앞에서 이미 나왔다.
스트림은 많은 종류의 데이터들을 처리할 수 있다.
읽고 쓰는 기본적인 행위는 모두 공통으로 처리해야 하는 기능이다. InputStream.java 를 확인해보면 다음과 같은 abstract method를 확인할 수 있다.
그리고 InputStream 의 subclass 들의 실제 구현은 그 subclass에 해당하는 데이터 소스에 맞춰서 구현을 하기 위해 이렇게 설계했다. InputStream 의 계층도를 보면 다음과 같다.
클래스의 이름만 봐도 대략적인 기능들을 유추할 수 있다. (ex. FileInputStream 은 파일관련된 기능을 하는 클래스구나.)
이제 실제로 데이터를 입출력할 때 코드에 어떻게 적용하면 되는지 살펴본다. 코드는 기본적으로 공식문서 기반으로 한다.
기본적인 요구사항은 간단하다.
File에 관련된거니까 FileInputStream, FileOutputStream 을 쓰면 될거라고 예상할 수 있다. FileInputStream 의 Javadoc을 보면 생성자의 인자로 무엇을 요구하는지 알 수 있다.

간편하게 file path name을 인자로 대입하여 사용하면 된다는 것을 알 수 있다.
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
public class CopyBytes {
public static void main(String[] args) throws IOException {
FileInputStream in = null;
FileOutputStream out = null;
try {
// 상대경로라 경로 설정을 잘해줘야 된다.
in = new FileInputStream("src/main/resources/xanadu.txt");
out = new FileOutputStream("src/main/resources/outagain.txt");
int c;
while ((c = in.read()) != -1) { // 왜 int 를 반환하지? byte stream 인데?
out.write(c);
}
} finally {
if (in != null) {
in.close();
}
if (out != null) {
out.close();
}
}
}
}
finally 부분에서 더이상 stream 객체가 필요하지 않으면 꼭 닫아줘야 한다는 것을 알 수 있다. 왜냐하면, 안닫아주면 자원(resource)가 날아가 손상될 수 있는 위험이 있기 때문이다. 이 filnally를 생략해 자동으로 이 로직을 처리해주는 것이 try-with-resource 이다. refactoring 하여 간단하게 나타내면 다음과 같다. (intellij에서 자동으로 제안을 해준다.)
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
public class CopyBytes {
public static void main(String[] args) throws IOException {
try (
FileInputStream in = new FileInputStream("src/main/resources/xanadu.txt");
FileOutputStream out = new FileOutputStream("src/main/resources/outagain.txt")
) {
int c;
while ((c = in.read()) != -1) {
out.write(c);
} // 이 다음 finally 문 생략 가능
}
}
}
실제로 이 main 메서드를 실행시키면 outagain.txt 에 xanadu.txt 의 내용과 똑같이 쓰여진다. 한가지 더 주목할 점은 while문의 조건식(condition expression)이다. in.read()는 왜 int타입으로 리턴을 할까? 분명히 나는 바이트 스트림으로 로직을 처리했는데 말이다. 그것은 ByteStream에서 EOF 표현 에서 확인하길 바란다.
정리하자면 모든 데이터들을 다 읽을 때까지 파일을 읽고 쓰는 로직을 ByteStream의 subclass인 FileInputStream 을 이용해서 작성했다. 하지만, 텍스트에 관련된 데이터를 I/O 처리할 때는 Character Streams 을 사용한다.
모든 Character Stream 은 Reader, Writer를 기반으로 한다. 앞서 나온 InputStream, OutputStream 과 비슷하다고 보면 된다. 요번 요구 사항은 앞에 나온 요구 사항에서 딱 한가지 더 추가한다.
앞의 예제에서 바뀐건 딱 두가지이다. 첫번째는 바이트스트림보단 character stream을 사용하는 것이다. 두번째는 한 줄씩 읽고 쓰는 것이다. character stream의 기본적인 Reader를 살펴보면,
이렇게 FileReader가 Reader의 subclass인 InputStreamReader의 subclass로 존재한다는 걸 알 수 있다. 이렇게 바로 유추해서 찾을 수 있게 하는 것이 상속의 장점 중에 하나다. FileReader라고 작명을 하니 파일에 관련된 클래스를 직관적으로 찾을 수 있다.
먼저, 파일을 읽을 때는 바이트단위에서 문자단위로 읽는데, 이 때 이 과정을 돕는 것이 바로 InputStreamReader 이다. 그렇다면 파일에서 한 줄씩 읽고 쓰려면 어떻게 해야 할까? 한 줄이란건 여러 개의 문자들이 합해진 것을 말한다. 그렇다면 여러 문자들을 동시에 읽고 쓰면된다. 이 기능을 하는 게 Reader의 subclass인 BufferedReader이다. 친절하게 Javadoc에 간단한 예제도 남겨줬다.

BufferedReader in = new BufferedReader(new FileReader("foo.in"));
BufferedReader의 생성자 안에 FileReader 인스턴스가 인자로 입력된 걸 알 수 있다. 생성자의 매개변수를 보니 다음과 같았다.

즉, BufferedReader 안에 또 다른 Reader의 subclass를 넣을 수 있게 해준다. 그렇다면 위의 두가지를 결합하면 결국 원하는 로직을 만들어 낼 수 있다는 것이다. 왜냐하면 FileReader는 Reader의 subclass이기 때문이다. 이걸 토대로 로직을 작성하면 다음과 같다.
public class FileToFileIO {
public static void main(String[] args) throws IOException {
// txt 파일로부터 한줄씩 입력받아 다른 파일에 한줄씩 출력한다.
try (
BufferedReader br = new BufferedReader(new FileReader("src/main/resources/a.txt"));
BufferedWriter bw = new BufferedWriter(new FileWriter("src/main/resources/b.txt"));
) {
String line = null;
while ((line = br.readLine()) != null) {
bw.write(line + '\n');
}
}
}
}
이제 Java I/O 를 어떻게 활용하면 되는지, Java가 개발자들에게 어떻게 많은 입출력 로직들을 작성해야 하는지 대략적으로 감을 잡았다. 약간의 예제를 더 작성해보자. 요구사항은 다음과 같다.
둘 다 한줄씩이란 것이 포함되어 있다. 한줄씩이란 것은 Buffered 를 떠올리면 된다. 바로 위의 예제와 다른 것은 요번엔 키보드로 입력을 받는 것이다. 키보드로 입력을 받는 것은 크게 두가지로 나눌 수 있다.
1번부터 살펴본다. 키보드로 데이터를 입력한다는 것은 문자를 운영체제에 입력하는 것이라고 볼 수 있다. 왜냐하면 당장 키보드만 보더라도 전부 문자로 되어 있고 실제로 우리가 문자를 입력하기 위해 키보드로 입력하고 있기 때문이다. 그리고 콘솔에다가 입력을 한다는게 운영체제에서 처리를 하는 것이기 때문이다. 콘솔은 커맨드라인으로 입력하는 것이다. 이와 관련된 로직은 Java에서 I/O from the Command Line에 나와 있다. 입력의 로직은 Standard input인 System.in이다.
이제 2번을 보자. 문자를 Java에서 입력처리한다고 한다. 제일 먼저 생각해야 할 것은 바이트가 아닌 문자니까 Char Streams인 Reader이다. 그렇다면 1번을 생각하면, Reader의 subclass중에 생성자에 System.in을 인자로 넣을 수 있는 클래스를 찾으면 되겠다.

System 클래스에서 in의 타입을 찾아보니, InputStream이란것을 알 수 있었다. 즉, Reader의 subclass중 생성자의 매개변수로 InputStream 타입을 갖고 있는 클래스를 찾으면 된다.

먼저, BufferedReader는 한줄씩 입력, 출력하는데에 쓰기 때문에 패스한다. CharArrayReader의 생성자를 관찰해보자.

아쉽게도 char[]타입이다. 다음은 FilterReader이다.

생성자의 매개변수로 Reader를 가지고 있다. 이것도 역시 아니다. 다음은 InputStreamReader이다.

이렇게 InputStreamReader의 생성자에 InputStream이 있다는 걸 발견할 수 있었다.
정리하자면 한줄씩(Buffered) -> Java가 문자를 읽는데(InputStreamReader) -> 그 문자는 키보드에서 입력받은 문자(System.in)다. 코드로 나타내면 다음과 같다.
public class KeyBoardAndFileIO {
public static void main(String[] args) throws IOException {
try (
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
BufferedWriter bw = new BufferedWriter(new FileWriter("src/main/resources/keyboardandfileio.txt"))
) {
String line = null;
while ((line = br.readLine()) != null) {
bw.write(line + '\n');
}
}
}
}
결국 Java에서는 I/O를 잘 활용하게끔 하기 위해 파일을 읽고 쓸건지, 키보드에서 입력을 받을건지와 같은 리소스에 따라 생성자의 인자를 계속 갈아끼우게 함으로써 개발자를 편리하게 해준다. 그리고 이렇게 하게 하기 위해 I/O 의 계층구조엔 하나의 패턴이 관찰되는데 이게 바로 Decorator pattern 이다.
위의 Decorator 패턴을 소개시켜주는 사이트의 글을 코드와 함께 나타내보면 다음과 같다. 일단 기본적인 알림 서비스 기능이 있다.
public class BasicNotifier {
private String email;
public BasicNotifier(String email) {
this.email = email;
}
public void send(String message) {
System.out.println("이메일: " + this.email + ", 메시지: " + message);
}
}
그리고 페이스북, 문자, 슬랙에 알림 서비스를 구현하기 위해 상속으로 해결을 할 수 있다. 하지만, 불이났을 때를 대비하여 모든 곳에 동시에 알람이 가게 하려면 어떻게 할까? 1차적으로 생각나는건 다음과 같이 구현하는 것이다.
public class SlackAndFaceBookAndSMSNotifier extends BasicNotifier {
String phoneNumber;
String facebookId;
String slackChannel;
public SlackAndFaceBookAndSMSNotifier(
String email,
String phoneNumber,
String facebookId,
String slackChannel
) {
super(email);
this.phoneNumber = phoneNumber;
this.facebookId = facebookId;
this.slackChannel = slackChannel;
}
@Override
public void send(String message) {
super.send(message);
System.out.println("폰번호: " + phoneNumber + ", 메시지: " + message);
System.out.println("페이스북 id: " + facebookId + ", 메시지: " + message);
System.out.println("슬랙 채널: " + slackChannel + ", 메시지: " + message);
}
}
Java는 다중상속을 지원하지 않기 때문에 이러한 문제가 생긴다. 어떠한 요구사항이 생길 때마다 새로 상속을 받아서 이름에 그 기능을 명시하는 클래스를 생성해야 한다. 그렇다면 나중에 SlackAndFaceBookAndSMSAndKaKaoAndDiscordNotifier 와 같은 괴물낙타를 보게될 것이다. 이 문제를 해결하기 위해 Decorator 패턴을 적용할 수 있다.

send(String messasge) method를 선언하여 생성자에 주입된 Notifier 타입의 객체가 실제로 send를 하게끔 한다.코드로 나타내면 다음과 같다.
public interface Notifier {
void send(String message);
}
public abstract class NotifierDecorator implements Notifier {
private Notifier notifier;
public NotifierDecorator(Notifier notifier) {
this.notifier = notifier;
}
public void send(String message) {
notifier.send(message); // Decorator 의 인자로 들어오는 애가 send 기능을 하게끔 해준다.
}
}
public class SMSDecorator extends NotifierDecorator {
private String phoneNumber;
public SMSDecorator(Notifier notifier, String phoneNumber) {
super(notifier);
this.phoneNumber = phoneNumber;
}
@Override
public void send(String message) {
super.send(message);
System.out.println("폰번호: " + phoneNumber + ", 메시지: " + message);
}
}
public class FacebookDecorator extends NotifierDecorator {
private String facebookId;
public FacebookDecorator(Notifier notifier, String facebookId) {
super(notifier);
this.facebookId = facebookId;
}
@Override
public void send(String message) {
super.send(message);
System.out.println("페이스북 id: " + facebookId + ", 메시지: " + message);
}
}
public class SlackDecorator extends NotifierDecorator {
private String slackChannel;
public SlackDecorator(Notifier notifier, String slackChannel) {
super(notifier);
this.slackChannel = slackChannel;
}
@Override
public void send(String message) {
super.send(message);
System.out.println("슬랙 채널: " + slackChannel + ", 메시지: " + message);
}
}
실제로 SMS, FaceBook, Slack 에서 알림을 받고 싶으면 다음과 같이 인스턴스를 생성하면 된다.
public class Main {
public static void main(String[] args) {
Notifier notifier = new SlackDecorator(
new FacebookDecorator(
new SMSDecorator(
new BasicNotifier("email@google.com"),
"010-0000-0000"
),
"id"),
"#123"
);
notifier.send("집에 불이 났습니다.!!!!!!");
}
}

오직 Decorator의 상속을 받지 않고 바로 구현을 한 BasicNotifier 의 생성자만 Notifier 를 갖고 있지 않다. 따라서, 기본적으로 email 에 알림을 가게끔한다. 그리고 추가로 SMS, FaceBook, Slack 같은게 계속 더해지는 것이다.
위에서 말한 Reader의 계층도도 위와 완전히 동일하진 않지만 어느정도 일맥상통하다.


Reader 란 abstract class의 subclass 들 중에 BufferedReader같이 Reader를 생성자의 매개변수로 갖고 있는 subclass가 장식이다. 반대로 FileReader 처럼 생성자에 Reader를 매개변수로 갖고 있지 않는 것은 기본 기능으로서 역할을 해낼 수 있게 된다.