BufferedReader
와 BufferedWriter
는 JDK 1.1
부터 도입된 입출력 클래스로 이름 그대로 버퍼 + 스트림을 통해 입출력의 성능 향상을 이루어냈다.
이 두 개를 가장 많이 접할 수 있는 곳은 아마 Java 코딩테스트일 것이다.
Java와 코테에 익숙하지 않을 땐 Scanner
+ System.out.println
으로 시작하지만, 조금씩 적응하면서 BufferedReader
+ BufferedWriter
조합으로 갈아타게 되는 것이 수순이다. 후자의 성능이 더 좋기 때문이다.
실제로 백준에 코드를 제출하면 BufferedReader
+ BufferedWriter
조합의 수행시간이 더 좋게 나오는 것을 확인할 수 있다.
최근에 슬라이딩 윈도우 개념을 학습하면서 11003번: 최소값 찾기라는 문제를 푼 적이 있었다. 내 기준 굉장히 신박한 문제였고, 열심히 풀어서 제출을 했으나 "시간 초과"가 발생했다.
정답 풀이와의 유일한 차이는 정답의 출력 방법이었다. 나의 경우 StringBuilder
를 사용하였으나, 정답은 BufferedWriter
를 사용했었다.
정답을 제출했을 때도 2448ms
로 제한시간인 2.6s
를 간신히 맞추었다.
이 문제는 입력에 따라 일정한 크기의 구간이 갱신될 때 O(nlog n)
이 아닌 O(n)
으로 최소값을 찾는 방법을 묻는 문제였다.
알고리즘의 시간복잡도는 이미 O(N)
으로 맞췄기 때문에, 정답이 갈린 이유는 StringBuilder
와 BufferdWriter
의 차이라고 생각한다.
그동안 StringBuilder
를 쭉 사용해왔지만, 이 일을 기점으로 BufferedWriter
를 사용해보기로 하였다.
이런 경우가 흔한 것은 아니지만, 이후에도 같은 상황이 발생할 가능성이 아예 없는 것은 아니었다.
위의 상황을 겪고나서 불과 이틀 후에, BufferedWriter
를 사용하면 안되는 문제를 만났다.
1874번: 스택 수열은 1~N까지의 수를 스택에 넣고 뺴면서 특정 수열을 만들 수 있는지 없는지 판별하는 문제이다. 만들 수 있다면 그 과정에서 수행된 push
와 pop
을 각각 +
와 -
로 출력하고, 만들 수 없다면 NO를 출력해야 한다.
이전에 풀어봤던 문제였고, 아이디어 역시 크게 어려운 문제는 아니었기에 쉽게 풀 수 있었다.
하지만, 문제를 제출하자 "출력 초과"가 발생하고 말았다. 백준에서 "출력 초과"가 발생하는 경우는 뭔가 출력되면 안될 것이 출력됐거나 포맷팅이 잘못된 경우이다.
import java.io.*;
import java.util.*;
class Main {
public static void main(String[] args) throws Exception {
// 입력과 출력을 위한 BufferedReader와 BufferedWriter 설정
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(System.out));
// 수열의 길이 N 입력 받기
int N = Integer.parseInt(br.readLine());
// 스택 역할을 할 Deque 생성
Deque<Integer> stack = new ArrayDeque<>();
// 1부터 시작하는 수를 저장할 변수
int num = 1;
for (int i = 0; i < N; i++) {
// 목표 수열의 숫자 입력 받기
int input = Integer.parseInt(br.readLine());
// 스택의 top이 입력값보다 크면 수열을 만들 수 없음
if (!stack.isEmpty() && stack.peek() > input) {
System.out.println("NO");
return;
}
// 입력값까지 숫자를 스택에 push
while (input >= num) {
stack.push(num++);
bw.write("+\n"); // push 연산 기록
}
// 스택의 top이 입력값과 같으면 pop
while (!stack.isEmpty() && stack.peek() == input) {
stack.pop();
bw.write("-\n"); // pop 연산 기록
}
}
// 버퍼에 저장된 모든 내용을 출력
bw.flush();
}
}
처음 제출했던 코드는 다음과 같다. 세부적인 로직이 주된 내용은 아니기에 주석으로 대체한다.
아무리 봐도 출력초과가 날 부분이 없었다. 출력이 복잡한 문제라면 의심이라도 됐을텐데, 아무리 봐도 이유를 알 수 없었다.
질문 게시판을 확인해보자 내 상황와 같은 글을 확인할 수 있었다.
BufferedWriter
는 일정량 이상 쌓이면 비정기적으로 flush하기 때문에, 이번처럼 결과를 중간중간 저장해야 되는 경우엔 적합하지 않다고 한다.
"결과를 중간중간 저장해야되는 경우"는 이전에 풀었던 11003번: 최소값 찾기와도 똑같지 않은가?
어떤 문제는 StringBuilder
를 써서 통과못하고, 어떤 문제는 BufferedWriter
를 쓰면 안된다고 하고..
이쯤되니 BufferedWriter
에 대해 제대로 알고 써야될 필요성을 느낄 수 있었다.
In general, a Writer sends its output immediately to the underlying character or byte stream. Unless prompt output is required, it is advisable to wrap a BufferedWriter around any Writer whose write() operations may be costly, such as FileWriters and OutputStreamWriters.
BufferedWriter
에 관련된 정보는 공식문서에 자세히 나와있다. 위의 대목에서 우리는 BufferedWriter
의 목적에 대해서 알 수 있다.
일반적으로 writer.write()
를 호출하면 바로 출력 스트림에 데이터를 전달한다. 하지만, 즉각적인 출력이 불필요한 상황에서는 이러한 동작으로 원치않은 오버헤드가 발생할 수 있다.
따라서, BufferedWriter
는 Writer
를 랩핑하여 내부 버퍼에 출력물을 모았다가 한꺼번에 출력할 수 있도록 하는 것이 목적이다.
참고로 Writer
는 JDK 1.1에서 BufferdWriter
와 함께 추가된 추상 클래스이다. 우리가 사용하는 BufferedWriter
와 OutputStreamWriter
모두 Writer
를 확장하여 구현하고 있다.
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(System.out));
즉, 우리가 아무렇지 않게 썼던 BufferedWriter
의 생성자에 담긴 의미는 다음과 같다.
출력할 문자를
OutputStreamWriter
를 통해System.out
이라는 출력 스트림(printStream)으로 보낼거야.
그런데 매번 출력을 한다면 오버헤드가 크니까, 중간의 버퍼에 모아두다가 한번에 출력할거야.
OutputStreamWriter
의 write()
는 실제로 StreamEncoder
라는 클래스의 write()
를 호출하는 것 이외에 어떤 동작도 포함되어 있지 않다.
StreamEncoder
는 문자열을 바이트로 인코딩한 후 OutputStream
에 전달하도록 구현되어있다. 세부 구현은 이번 글에서 다룰 내용은 아니기 때문에 생략한다.
이러한 인코딩 작업이 필요한 이유는 외부의 출력 장치와의 통신을 하기 위함이다. JVM에선 문자열을 UTF-16으로 저장하는데, 이 데이터를 바이트로 변환해서 전달해야 외부 시스템이나 장치가 이해하고 처리할 수 있다.
콘솔에서는 자바 어플리케이션에서 넘겨받은 바이트 형태의 데이터를 다시 문자로 변환하여 사용자에게 보여주는 것이다.
BufferedWriter
의 필드와 생성자를 살펴보면 위와 같이 되어있다.
Writer
를 out
이라고 네이밍을 한 것을 봐서 OutputStreamWriter
를 호출하는 것을 사실상 출력과 동일시 한다는 것을 유추해볼 수 있다.
이 외에 cb
는 버퍼, nChars
는 버퍼의 크기, nextChar
는 버퍼의 커서를 의미하는 것으로 보인다.
만약 사이즈를 별도로 지정하지 않으면 defaultCharBufferSize
로 버퍼를 생성하는데, 그 크기가 char[8192] = 16,384 byte(약 16kb)
이다.
write()
의 내부 구현은 다음과 같다. 이 코드의 if문에서 만약 버퍼의 커서가 사이즈를 넘긴다면 flush
를 한다는 것을 확인할 수 있다.
이 코드로 인해 1874번: 스택 수열에서 BufferedWriter
를 썼을 때 비정기적인 flush
가 발생했던 것이다.
이러한 동작은 버퍼를 사용할 때 의도된 동작이다.
개발자가 명시적으로 flush
를 호출하지 않아도 일정량의 데이터가 주기적으로 출력되도록 보장하며, 동시에 불필요한 I/O 작업을 줄여 전체적인 시스템 성능을 향상시킨다.
다시 1874번: 스택 수열을 살펴보자. 입력의 크기는 100,000이다.
최악의 케이스는 입력이 [N, N-1, N-2 ... 1]로 들어오는 경우로, N번의 push 연산과 N번의 pop연산이 발생해 최대 2N
번의 스택 연산이 필요하게 된다. 이 말은 최대 200,000 라인의 출력이 발생한다는 것이다.
실제 +\n
와 -\n
이 BufferedWriter
의 내부 버퍼에 저장되는 형태는 위와 같다.
+
혹은 -
가 한 칸, \n
이 한 칸, 둘을 더해 총 두 칸의 배열 공간이 사용되는 것을 확인할 수 있다.
결론적으로 + or -
+ \n
으로 구성된 200,000라인의 출력을 위해서는 총 200,000 * 2 = 400,000
의 배열 공간이 필요하다는 것을 알 수 있다.
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(System.out), 400_000);
성공적으로 제출이 된 것을 확인할 수 있다.
어쩌다보니 다양한 내용을 깊게 다루게 되었다.
본질은 "그래서 BufferedWriter
를 일반적으로 사용해도 되는가?"이다.
내가 내린 결론은 다음과 같다.
실제 코테에선
StringBuilder
를 사용하자.
BufferedWriter
와StringBuilder
로 합불이 갈릴 가능성은 거의 없다.또한, 프로그래머스는 정답을 출력할
StringBuilder
를 파라미터로 넘겨주기도 한다.하지만, 백준은 입출력이 중요하게 작용될 때가 종종 있다. 따라서, 필요한 버퍼의 크기를 결정할 수 있을 땐
BufferedWriter
도 좋은 선택이다.
습관적으로 BufferedWriter를 사용해왔는데, 비정기적으로 flush를 수행한다는 건 처음 알았어요. 굉장히 유용한 내용이네요!