BufferedWriter 사용 시 주의점

guswls·2025년 1월 14일
2

배경지식

목록 보기
2/2
post-thumbnail

BufferedReader와 BufferedWriter

BufferedReaderBufferedWriterJDK 1.1부터 도입된 입출력 클래스로 이름 그대로 버퍼 + 스트림을 통해 입출력의 성능 향상을 이루어냈다.

이 두 개를 가장 많이 접할 수 있는 곳은 아마 Java 코딩테스트일 것이다.

Java와 코테에 익숙하지 않을 땐 Scanner + System.out.println으로 시작하지만, 조금씩 적응하면서 BufferedReader + BufferedWriter 조합으로 갈아타게 되는 것이 수순이다. 후자의 성능이 더 좋기 때문이다.

실제로 백준에 코드를 제출하면 BufferedReader + BufferedWriter 조합의 수행시간이 더 좋게 나오는 것을 확인할 수 있다.



BufferedWriter를 써야되는 문제

최근에 슬라이딩 윈도우 개념을 학습하면서 11003번: 최소값 찾기라는 문제를 푼 적이 있었다. 내 기준 굉장히 신박한 문제였고, 열심히 풀어서 제출을 했으나 "시간 초과"가 발생했다.

정답 풀이와의 유일한 차이는 정답의 출력 방법이었다. 나의 경우 StringBuilder를 사용하였으나, 정답은 BufferedWriter를 사용했었다.

정답을 제출했을 때도 2448ms로 제한시간인 2.6s를 간신히 맞추었다.

이 문제는 입력에 따라 일정한 크기의 구간이 갱신될 때 O(nlog n)이 아닌 O(n)으로 최소값을 찾는 방법을 묻는 문제였다.

알고리즘의 시간복잡도는 이미 O(N)으로 맞췄기 때문에, 정답이 갈린 이유는 StringBuilderBufferdWriter의 차이라고 생각한다.

그동안 StringBuilder를 쭉 사용해왔지만, 이 일을 기점으로 BufferedWriter를 사용해보기로 하였다.

이런 경우가 흔한 것은 아니지만, 이후에도 같은 상황이 발생할 가능성이 아예 없는 것은 아니었다.



BufferedWriter를 쓰면 안되는 문제

위의 상황을 겪고나서 불과 이틀 후에, BufferedWriter를 사용하면 안되는 문제를 만났다.

1874번: 스택 수열은 1~N까지의 수를 스택에 넣고 뺴면서 특정 수열을 만들 수 있는지 없는지 판별하는 문제이다. 만들 수 있다면 그 과정에서 수행된 pushpop을 각각 +-로 출력하고, 만들 수 없다면 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에 대해 제대로 알고 써야될 필요성을 느낄 수 있었다.



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()를 호출하면 바로 출력 스트림에 데이터를 전달한다. 하지만, 즉각적인 출력이 불필요한 상황에서는 이러한 동작으로 원치않은 오버헤드가 발생할 수 있다.

따라서, BufferedWriterWriter를 랩핑하여 내부 버퍼에 출력물을 모았다가 한꺼번에 출력할 수 있도록 하는 것이 목적이다.

참고로 Writer는 JDK 1.1에서 BufferdWriter와 함께 추가된 추상 클래스이다. 우리가 사용하는 BufferedWriterOutputStreamWriter 모두 Writer를 확장하여 구현하고 있다.

BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(System.out));

즉, 우리가 아무렇지 않게 썼던 BufferedWriter의 생성자에 담긴 의미는 다음과 같다.

출력할 문자를 OutputStreamWriter를 통해 System.out이라는 출력 스트림(printStream)으로 보낼거야.
그런데 매번 출력을 한다면 오버헤드가 크니까, 중간의 버퍼에 모아두다가 한번에 출력할거야.



OutputStreamWriter.write()의 구현

OutputStreamWriterwrite()는 실제로 StreamEncoder라는 클래스의 write()를 호출하는 것 이외에 어떤 동작도 포함되어 있지 않다.

StreamEncoder 는 문자열을 바이트로 인코딩한 후 OutputStream에 전달하도록 구현되어있다. 세부 구현은 이번 글에서 다룰 내용은 아니기 때문에 생략한다.

이러한 인코딩 작업이 필요한 이유는 외부의 출력 장치와의 통신을 하기 위함이다. JVM에선 문자열을 UTF-16으로 저장하는데, 이 데이터를 바이트로 변환해서 전달해야 외부 시스템이나 장치가 이해하고 처리할 수 있다.

콘솔에서는 자바 어플리케이션에서 넘겨받은 바이트 형태의 데이터를 다시 문자로 변환하여 사용자에게 보여주는 것이다.



BufferedWriter가 중간에 flush를 하는 이유

BufferedWriter의 필드와 생성자를 살펴보면 위와 같이 되어있다.

Writerout이라고 네이밍을 한 것을 봐서 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-\nBufferedWriter의 내부 버퍼에 저장되는 형태는 위와 같다.

+ 혹은 -가 한 칸, \n이 한 칸, 둘을 더해 총 두 칸의 배열 공간이 사용되는 것을 확인할 수 있다.

결론적으로 + or - + \n으로 구성된 200,000라인의 출력을 위해서는 총 200,000 * 2 = 400,000의 배열 공간이 필요하다는 것을 알 수 있다.

BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(System.out), 400_000);

성공적으로 제출이 된 것을 확인할 수 있다.



무엇이 정답일까?

어쩌다보니 다양한 내용을 깊게 다루게 되었다.

본질은 "그래서 BufferedWriter를 일반적으로 사용해도 되는가?"이다.

내가 내린 결론은 다음과 같다.

실제 코테에선 StringBuilder를 사용하자.

BufferedWriterStringBuilder로 합불이 갈릴 가능성은 거의 없다.

또한, 프로그래머스는 정답을 출력할 StringBuilder를 파라미터로 넘겨주기도 한다.

하지만, 백준은 입출력이 중요하게 작용될 때가 종종 있다. 따라서, 필요한 버퍼의 크기를 결정할 수 있을 땐 BufferedWriter도 좋은 선택이다.

profile
안녕하세요

2개의 댓글

comment-user-thumbnail
2025년 1월 15일

습관적으로 BufferedWriter를 사용해왔는데, 비정기적으로 flush를 수행한다는 건 처음 알았어요. 굉장히 유용한 내용이네요!

1개의 답글