Java를 이용해 알고리즘 문제를 풀다 보면 Scanner를 쓰지 말고 BufferedReader를 이용하라는 이야기를 많이 듣습니다. 실제로 Scanner보다 BufferedReader가 더 좋은 입력 성능을 가지고 있고, Scanner를 이용하면 시간 초과가 되는 문제도 있죠. BufferedReader가 더 좋다는 것은 알고 있었지만, 어떤 이유로 인해서 좋은지에 대해서는 크게 생각해보지 않았던 것 같아 이번 기회에 Scanner와 BufferedReader의 차이점과 BufferedReader가 왜 더 빠른지를 정리해보고자 합니다.
Docs로 알아보는 Scanner와 BufferedReader
둘의 성능 차이의 원인을 알아보기 이전에 Java의 공식문서에서는 두 클래스에 대해서 어떻게 이야기하는지 한번 알아봅시다.
Scanner
A simple text scanner which can parse primitive types and strings using regular expressions. A Scanner breaks its input into tokens using a delimiter pattern, which by default matches whitespace. The resulting tokens may then be converted into values of different types using the various next methods. - Java 8 Scanner Docs
문서에는 굉장히 많은 내용이 담겨있지만, 그 중에서 Scanner라는 클래스를 설명해 주는 내용입니다. 위 내용에서 알 수 있는 Scanner의 특징은 크게 4가지가 있습니다.
- A simple text scanner : 간단한 텍스트 스캐너(입력기)이다. 스캐너는 내부의 버퍼 사이즈가 1024이기 때문에, 대용량 처리를 하기에 적합하지는 않습니다.
public final class Scanner implements Iterator<String>, Closeable {
(생략)
// Size of internal character buffer
private static final int BUFFER_SIZE = 1024; // change to 1024;
(생략)
}
- can parse primitive types and strings : 손쉽게 원시타입과 String으로 변환이 가능합니다. 대표적으로 nextInt(), nextLong()과 같은 메서드가 있습니다.
- A Scanner breaks its input into tokens using a delimiter pattern, which by default matches whitespace. : 구분 문자를 기준으로 토큰을 분리합니다. 이후 등장할 BufferedReader가 한 문장 내에서 구분을 짓기 위해 StringTokenizer나 String.split을 이용해야 하는 것과는 대조적입니다.
- using regular expressions : 정규표현식을 사용합니다. 정규표현식을 통해 더 풍부한 값들을 처리해줄 수 있습니다. 예를 들면 "1,234,567"을 정수 1234567로도 변환할 수 있습니다.
이 내용들을 종합해보면, Scanner는 개발자가 손쉽게 입력 값에 대한 처리를 할 수 있는 기능을 제공하는 클래스라는 것을 알 수 있습니다.
BufferedReader
Reads text from a character-input stream, buffering characters so as to provide for the efficient reading of characters, arrays, and lines. The buffer size may be specified, or the default size may be used. The default is large enough for most purposes. In general, each read request made of a Reader causes a corresponding read request to be made of the underlying character or byte stream. It is therefore advisable to wrap a BufferedReader around any Reader whose read() operations may be costly, such as FileReaders and InputStreamReaders.
For example, BufferedReader in = new BufferedReader(new FileReader("foo. in"));
will buffer the input from the specified file. Without buffering, each invocation of read() or readLine() could cause bytes to be read from the file, converted into characters, and then returned, which can be very inefficient. - Java 8 Docs BufferedReader
이 문서에서 주로 이야기 하는 내용은 BufferedReader는 Buffer를 이용해 대용량 텍스트 데이터를 읽고, 작업하는데 효율적이라는 이야기입니다.
- Reads text from a character-input stream : BufferedReader가 문자를 다룬다는 이야기입니다. Java에서 사용하는 스트림은 바이트 스트림과 문자 스트림으로 나뉘는데, 그중에서 바이트를 유니코드로 변환해 문자로 이용하는 문자 스트림을 이용한다는 것을 명시합니다.
- buffering characters so as to provide for the efficient reading of characters, arrays, and lines. : 문자들을 버퍼하여 읽기 작업에 있어서 효율적인 동작을 한다는 것을 나타냅니다.
- The buffer size may be specified, or the default size may be used. : 버퍼의 사이즈를 조절할 수 있고, 아니면 기본 버퍼 사이즈를 이용한다고 말합니다. 여기서 기본 버퍼 사이즈는 8192 사이즈이고, 그 다음 문장에서 기본 버퍼 사이즈도 충분히 커서 웬만한 목적에서는 이용할 수 있다고 합니다.
- It is therefore advisable to wrap a BufferedReader around any Reader : Reader 인터페이스의 구현체로 읽기 작업을 수행할 때엔 BufferedReader로 덮어서 이용하는 것을 추천합니다. 이는 앞선 버퍼 작업의 효율성과 동일합니다. 마지막 문장에서 버퍼를 이용하지 않으면 굉장히 비효율적이라는 이야기를 합니다. 버퍼를 이용하지 않을 경우 파일 혹은 키보드에서 내용을 읽고 이를 문자로 변환을 합니다. 이 과정에서 IO 작업이 굉장히 많이 발생하는데, BufferedReader를 이용하면 파일의 내용들을 버퍼 사이즈만큼 불러온 후, 버퍼에서 변환 작업을 하기 때문에, 효율적이라는 것을 보여줍니다.
스캐너에 비해서 내용이 좀 길어졌는데, 앞서 작성했던 것처럼 버퍼를 이용하기 때문에, 효율적인 사용이 가능하다는 것을 알 수 있습니다. 하지만 버퍼를 이용해 텍스트를 읽을 뿐, 스캐너가 주는 형변환, 토큰화와 같은 다양한 기능은 따로 없는 것 같습니다.
둘의 성능 차이 : Buffer의 사이즈 차이일까?
지금까지 두 클래스에 대한 설명을 보면 뭔가 Buffer의 사이즈 차이로 인해 성능 차이가 발생하는 것 같습니다. 실제로 많은 블로그 글에서도 둘의 성능 차이의 원인으로 Buffer의 크기를 지목합니다. 저 역시도 이번 글 작성을 위한 자료조사 이전에는 그렇게 생각했었습니다.
그럼 실제로 어느정도의 차이가 나는지 테스트를 해봅시다.
테스트에서는 보다 정확한 시간을 계산하기 위해 키보드 입력이 아닌, 파일을 통해 받은 문자열을 출력하는 방식으로 했으며, 파일의 크기는 대략 30MB고, 대략 1300만 자가 담긴 텍스트 파일을 읽게 했습니다.
public class ScannerExample {
public static void main(String[] args) throws IOException {
File file = new File("sample.txt");
Scanner sc = new Scanner(file);
long start = System.currentTimeMillis();
while (sc.hasNextLine()) {
String line = sc.nextLine();
System.out.println(line);
}
long end = System.currentTimeMillis();
System.out.println(end-start); // 1159ms
}
}
public class BufferedReaderExample {
public static void main(String[] args) throws Exception {
BufferedReader br = new BufferedReader(new FileReader("sample.txt"));
long start = System.currentTimeMillis();
String str;
while ((str = br.readLine()) != null) {
System.out.println(str);
}
System.out.println(System.currentTimeMillis() - start); // 570ms
}
}
성능 차이가 발생할 것이라고 생각했는데, 실제로 Scanner의 경우 1159ms가, BufferedReader의 경우 570ms가 나며 대략 2배의 차이가 발생했습니다.
그런데 앞서서 BufferedReader는 버퍼의 사이즈를 조절할 수 있다고 했는데, 스캐너와 동일한 사이즈의 버퍼를 이용하면 스캐너와 비슷한 성능이 발생할 지가 궁금해졌습니다.
public class BufferedReaderExample {
public static void main(String[] args) throws Exception {
BufferedReader br = new BufferedReader(new FileReader("sample.txt"), 1024);
long start = System.currentTimeMillis();
String str;
while ((str = br.readLine()) != null) {
System.out.println(str);
}
System.out.println(System.currentTimeMillis() - start); // 611ms
}
}
예상외로 버퍼 사이즈가 동일해도 BufferedReader와 Scanner의 성능 차이는 압도적이었습니다. 기본 사이즈였을 때보다 40ms수준의 차이밖에 발생하지 않았기 때문에, Buffer의 사이즈가 두 라이브러리의 압도적인 성능 차이를 보여주는 대목은 아니라고 생각이 들었습니다.
Scanner와 BufferedReader의 속도 차이의 진짜 이유
앞선 실험을 통해 Buffer의 사이즈가 주된 원인은 아닐 것이라는 것을 알 수 있었습니다. 속도 차이가 나는 진짜 이유는 정규 표현식의 차이라고 생각했습니다. 이는 알고리즘 고인물분들의 대화를 통해서도 확인할 수 있었고, 관련해서 이야기를 나눈 외국 글에서도 확인할 수 있었습니다.
약간 비슷한 이유로는 Java에서 토큰화를 하는 방법에서 정규표현식을 이용하는 `String.split()`이 `StringTokenizer` 보다 느린 이유와 비슷할 것 같습니다.
도대체 Scanner에서 어떤 방식으로 정규표현식이 동작하길래 이럴까에 대해서 한번 알아봅시다.
대표적인 예시로 Scanner에서 숫자를 파싱하는 과정에 대해서 알아봅시다. Scanner는 `1,234,567`과 같은 형식이 들어간 문자도 숫자로 파싱이 가능한데, 이 과정에서도 정규표현식이 사용됩니다.
아래 메서드는 Scanner에서 숫자를 입력받는 `nextInt()` 메서드입니다. 그중에서 `String s = next(integerPattern());`가 Integer에 패턴에 해당하는 String을 받아오는 코드이고, 이후 `Integer.parseInt()`로 형변환을 한 후 리턴해줍니다.
그리고 `integerPattern()` 에서도 지속적으로 정규표현식을 나타내는 `Pattern` 클래스를 이용하는 것을 볼 수 있습니다. 코드 내용이 너무 길어질 것 같아 추가하지 않았지만, String타입의 토큰을 받아오는 `next()` 메서드에서 앞서 가져온 Pattern을 이용해 지속적으로 정규표현식 검사를 하며 버퍼에서 토큰을 추출하고 있습니다. 관련된 내용은 Scanner의 `next()` 메서드와 `getCompleteTokenInBuffer()` 메서드에서 확인할 수 있습니다.
public int nextInt(int radix) {
// Check cached result
if ((typeCache != null) && (typeCache instanceof Integer)
&& this.radix == radix) {
int val = ((Integer)typeCache).intValue();
useTypeCache();
return val;
}
setRadix(radix);
clearCaches();
// Search for next int
try {
String s = next(integerPattern());
if (matcher.group(SIMPLE_GROUP_INDEX) == null)
s = processIntegerToken(s);
return Integer.parseInt(s, radix);
} catch (NumberFormatException nfe) {
position = matcher.start(); // don't skip bad token
throw new InputMismatchException(nfe.getMessage());
}
}
private Pattern integerPattern() {
if (integerPattern == null) {
integerPattern = patternCache.forName(buildIntegerPatternString());
}
return integerPattern;
}
Scanner에서 굉장히 복잡하게 입력 값을 받아오는 것과 달리 BufferedReader의 경우 인덱스를 두고, 인덱스에 해당하는 char를 StringBuilder에 지속적으로 저장한 후, eol이 나왔을 때 리턴하는 것을 확인할 수 있습니다. 정규표현식을 이용하는 것보다 간결하게 동작하기 때문에, 이 점이 성능 차이의 원인이라고 생각이 들었습니다.
String readLine(boolean ignoreLF, boolean[] term) throws IOException {
StringBuilder s = null;
int startChar;
synchronized (lock) {
ensureOpen();
boolean omitLF = ignoreLF || skipLF;
if (term != null) term[0] = false;
bufferLoop:
for (;;) {
if (nextChar >= nChars)
fill();
if (nextChar >= nChars) { /* EOF */
if (s != null && s.length() > 0)
return s.toString();
else
return null;
}
boolean eol = false;
char c = 0;
int i;
/* Skip a leftover '\n', if necessary */
if (omitLF && (cb[nextChar] == '\n'))
nextChar++;
skipLF = false;
omitLF = false;
charLoop:
for (i = nextChar; i < nChars; i++) {
c = cb[i];
if ((c == '\n') || (c == '\r')) {
if (term != null) term[0] = true;
eol = true;
break charLoop;
}
}
startChar = nextChar;
nextChar = i;
if (eol) {
String str;
if (s == null) {
str = new String(cb, startChar, i - startChar);
} else {
s.append(cb, startChar, i - startChar);
str = s.toString();
}
nextChar++;
if (c == '\r') {
skipLF = true;
}
return str;
}
if (s == null)
s = new StringBuilder(defaultExpectedLineLength);
s.append(cb, startChar, i - startChar);
}
}
}
마무리
지금까지 Scanner와 BufferedReader의 성능 차이의 원인을 알아보기 위해, 각 클래스가 어떤 목적을 위한 클래스였는지, 주된 성능 차이의 원인이라고 알려져있던 Buffer는 원인이 맞았는지, 그리고 진짜 원인이 무엇인지에 대해서 알아보았습니다.
이번 조사를 통해 두 클래스의 특징에 대해서 알아볼 수 있었습니다. 특히 Scanner가 제공하는 다양한 편의기능은 알고리즘 문제 해결에서는 큰 도움이 되지 않았지만, 간단하게 이용하기에는 좋다고 생각이 들었고, 실제로 대학시절 Java를 처음 배울 때 Scanner를 자주 이용했던 추억이 생각났네요. 각 라이브러리의 특징을 잘 파악하고, 현재 상황에 더 적합한 방법이 무엇인지에 대해서 선택하는 것의 중요성을 다시 한번 느낄 수 있었던 경험이었습니다.
참고자료
코딩수첩 - Java 입출력, Scanner와 BufferedReader의 비교