들어가며
SMU의 채점 Worker는 사용자가 제출한 코드를 Docker 컨테이너 안에서 실행합니다. 이때 사용자는 Solution 클래스의 메서드만 작성하면 되고, 입력 파싱은 Worker의 CodeGenerator가 자동으로 Main 클래스를 생성하여 처리합니다.
정수 배열을 입력받아 모든 원소의 합을 반환하는 문제로 가정해보겠습니다.
//input/001.txt
1 2 3 4 5
CodeGenerator는 이 테스트 케이스를 파싱하여 사용자의 Solution 메서드에 넘기는 Main 클래스를 자동 생성합니다.
stdin: "1 2 3 4 5" → int[] nums = {1, 2, 3, 4, 5}
int result = solution.arraySum(nums);
System.out.println(result); → "15" → output/001.txt의 기대 출력과 비교
하지만 nextInt()와 nextLine()을 혼용하다보니 개행이 소비되지 않아 빈 문자열이 반환되는데 이를 방어하기 위한 코드가 모든 배열 타입마다 반복되면서 CodeGenerator의 가독성이 떨어지고 있었습니다.
더하여 사용자는 입력 파싱은 채점 워커에서 담당하기 때문에 따라서 파싱이 느리면 사용자의 정답 코드도 TLE를 받을 수 있습니다.
기존 코드
// Scanner 사용
String line_arr = sc.nextLine().trim();
if (line_arr.isEmpty() && sc.hasNextLine()) line_arr = sc.nextLine().trim();
String[] tokens_arr = line_arr.split("\\s+");
int[] arr = new int[tokens_arr.length];
for (int i = 0; i < tokens_arr.length; i++) {
arr[i] = Integer.parseInt(tokens_arr[i]);
}
가독성의 문제와 대량 입력에서의 성능 문제 해결을 위해 Scanner에서 BufferedReader로 로직을 개선하기로 했습니다,
Scanner와 BufferedReader의 차이점
먼저 Scanner와 BufferedReader의 차이점을 내부 동작과 함께 알아보겠습니다.
Scanner.nextInt()
nextInt()를 호출하면 내부적으로 integerPattern()이라는 정규식 패턴을 만들어서 next(Pattern)에 넘깁니다.
// java.util.Scanner
public int nextInt(int radix) {
String s = next(integerPattern()); // 정규식 Pattern을 넘김
return Integer.parseInt(s, radix);
}
이 integerPattern()이 만드는 정규식이 단순하지 않고 지역화된 숫자, 그룹 구분자, 부호까지 고려한 복잡한 패턴을 동적으로 생성합니다.
// java.util.Scanner — 정수 패턴 동적 생성
private String buildIntegerPatternString() {
String radixDigits = digits.substring(0, radix);
String digit = "((?i)[" + radixDigits + "\\p{javaDigit}])";
String groupedNumeral = "(" + non0Digit + digit + "?" + digit + "?("
+ groupSeparator + digit + digit + digit + ")+)";
String numeral = "((" + digit + "++)|" + groupedNumeral + ")";
String javaStyleInteger = "([-+]?(" + numeral + "))";
String negativeInteger = negativePrefix + numeral + negativeSuffix;
String positiveInteger = positivePrefix + numeral + positiveSuffix;
return "(" + javaStyleInteger + ")|("
+ positiveInteger + ")|("
+ negativeInteger + ")";
}
그리고 실제로 토큰을 찾는 getCompleteTokenInBuffer()에서는 구분자를 정규식으로 찾고, 찾아낸 토큰을 다시 정규식으로 검증합니다.
// java.util.Scanner
private String getCompleteTokenInBuffer(Pattern pattern) {
matcher.usePattern(delimPattern); // 1. 구분자 정규식으로 전환
boolean foundNextDelim = matcher.find(); // 2. 다음 구분자를 정규식으로 탐색
matcher.usePattern(pattern); // 3. 정수 패턴 정규식으로 전환
matcher.region(position, tokenEnd);
if (matcher.matches()) { // 4. 토큰이 정수 패턴에 맞는지 검증
return matcher.group();
}
}
nextInt() 1회 호출에 정규식 매칭이 최소 2회 발생하는 구조입니다.
BufferedReader.readLine()
반면 BufferedReader의 readLine()은 내부 char[] 버퍼에서 개행 문자를 찾는 것이 전부입니다.
// java.io.BufferedReader
private String implReadLine(boolean ignoreLF, boolean[] term) throws IOException {
for (;;) {
if (nextChar >= nChars)
fill(); // char[] 버퍼를 스트림에서 채움
for (i = nextChar; i < nChars; i++) {
c = cb[i];
if ((c == '\n') || (c == '\r')) { // 단순 char 비교 — 정규식 없음
eol = true;
break;
}
}
if (eol)
return new String(cb, startChar, i - startChar);
}
}
정리하면 Scanner는 정수 하나를 읽는 데 정규식 매칭이 최소 2회 발생하지만 BufferedReader는 단순 문자 비교로 한 줄을 통째로 읽고 이후 split()과 Integer.parseInt()로 직접 파싱합니다. 입력이 커질수록 이 차이가 누적되어 성능이 선형적으로 느려질 것입니다.
전환 결과
1차원 배열을 Scanner에서 BufferedReader로 파싱하는 코드입니다.
// BufferedReader 사용
String[] tokens_arr = br.readLine().trim().split("\\s+");
int[] arr = new int[tokens_arr.length];
for (int i = 0; i < tokens_arr.length; i++) {
arr[i] = Integer.parseInt(tokens_arr[i]);
}
실제로 얼마나 차이가 나는지, 알고리즘 로직 없이 파싱만 수행하는 조건에서 JVM 워밍업 3회 후 10회 반복 평균으로 측정했습니다.
성능 측정
=== INT_ARRAY (N=1,000) ===
Scanner avg: 53.1 ms
BufferedReader avg: 36.5 ms
→ BufferedReader가 1.45x 빠름
=== INT_ARRAY (N=100,000) ===
Scanner avg: 105.6 ms
BufferedReader avg: 71.1 ms
→ BufferedReader가 1.49x 빠름
=== INT_2D_ARRAY (N=250,000) ===
Scanner avg: 174.0 ms
BufferedReader avg: 84.9 ms
→ BufferedReader가 2.05x 빠름
=== INT_ARRAY (N=1,000,000) ===
Scanner avg: 293.4 ms
BufferedReader avg: 133.5 ms
→ BufferedReader가 2.20x 빠름
입력이 커질수록 격차가 벌어지는 것을 확인할 수 있습니다. N=1,000에서는 1.45배 차이지만, N=1,000,000에서는 2.20배까지 벌어져 유의미하게 개선했다는 것을 알 수 있습니다.