Java에서 입력을 처리할 때 자주 사용하는 Scanner 클래스와 BufferedReader 클래스는 내부 구현과 동작 방식에서 큰 차이를 보입니다. 이 분석에서는 JVM 수준에서 두 클래스의 저수준 동작을 비교하여 다음과 같은 측면을 심층적으로 살펴보겠습니다:
각 항목별로 상세히 설명하고, 가능하면 예시 코드와 함께 비교 분석합니다. 마지막에는 내부 구조와 객체 생성 차이를 표로 정리하여 한눈에 비교합니다.
기본적으로 BufferedReader는 8192 characters(약 8KB) 크기의 문자 배열 버퍼를 사용하고, Scanner는 1024 characters(1KB) 크기의 버퍼를 사용합니다. BufferedReader의 버퍼 크기는 생성자에서 변경 가능하며 기본값도 비교적 크기 때문에 대부분의 목적에 충분하도록 설계되어 있습니다. 반면 Scanner의 버퍼는 기본 1024 문자로 작으며, 사용자 설정 옵션이 없습니다. 다만 Scanner는 내부적으로 버퍼가 부족하면 크기를 두 배로 동적 확장하도록 구현되어 있어, 아주 긴 토큰을 만나면 버퍼를 증가시킬 수 있습니다. 초기 버퍼 크기 차이로 인해, BufferedReader는 한 번에 더 많은 데이터를 읽어 들이고, Scanner는 더 자주 작은 청크(chunk)씩 읽게 됩니다.
BufferedReader는 단순한 char[] 배열을 버퍼로 사용하고, 입력을 받을 때 이 배열에 문자를 채웁니다. 이 배열은 JVM 힙에 할당되며 재사용됩니다. 반면 Scanner는 java.nio.CharBuffer를 사용하여 버퍼를 관리하며, 내부적으로 역시 힙에 저장된 char[]로 구현됩니다. 두 경우 모두 버퍼 자체는 자바 힙 메모리를 사용하고 off-heap 메모리를 사용하지 않습니다.
BufferedReader는 일반적으로 InputStreamReader와 함께 사용되어 문자 스트림으로 변환된 입력을 버퍼링합니다. 예를 들어 System.in과 같이 바이트 스트림이라면 BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); 형태로 감싸서 쓰며, 내부적으로 8192자 버퍼를 채울 때까지 read() 호출을 수행합니다. 한 번 fill() 동작이 일어나면 최대 8192자까지 대량으로 읽어 버퍼를 채우므로, 네이티브 I/O 호출 횟수를 줄여주는 효과가 있습니다.
Scanner도 생성자에 InputStream을 넘기면 내부에서 new InputStreamReader(source)를 호출하여 문자 스트림으로 변환한 뒤 사용합니다. 그러나 Scanner는 토큰 단위로 읽도록 설계되었기 때문에, 필요한 만큼씩 반복적으로 읽기를 수행합니다. Scanner의 내부 구현을 보면, Readable 인터페이스(InputStreamReader가 이를 구현)에 정의된 read(CharBuffer)를 호출하여 버퍼에 데이터를 채우며, 이때 남은 버퍼 용량이 부족하면 compact()하거나 버퍼를 확장한 후 계속 읽습니다. 기본적으로 1KB씩 읽고, 토큰이 완료될 때마다 처리를 멈추고, 토큰 완료를 위해 필요하면 다시 읽는 식입니다. 그 결과 네이티브 호출(예: OS로부터 데이터 읽기)이 BufferedReader보다 빈번하게 일어날 수 있습니다.
예를 들어, 같은 양의 데이터를 읽는 경우 BufferedReader는 8KB씩 끊어서 읽을 수 있지만, Scanner는 기본적으로 1KB씩 여러 번 읽어야 할 수 있습니다. 이러한 차이로 인해 입출력 성능에서 BufferedReader가 유리합니다 (아래 성능 섹션에서 상세 설명). 또한 Scanner는 기본적으로 구분자(delimiter)를 경계로 입력을 끊어 읽기 때문에, 더 많은 제어 로직이 개입됩니다.
BufferedReader는 개행 문자(\n 또는 \r\n)를 경계로 한 줄씩 읽는 readLine() 메서드를 제공하며, 이를 위해 내부 버퍼를 순차적으로 탐색하면서 개행 문자를 찾습니다. 즉, 저장된 문자 배열을 인덱스 증가시키며 '\n' 또는 '\r' 문자가 나타나는지 확인하고, 나타나면 한 줄의 끝으로 간주합니다. 개행 문자를 발견하면 그 위치까지의 문자를 모아 문자열로 반환하고, 개행 문자는 소비하지만 결과 문자열에는 포함하지 않습니다. 이 과정에서 시스템 콜이나 정규식 처리 없이 단순한 char 비교로 개행을 인식하므로 오버헤드가 낮습니다.
반면 Scanner는 토큰 구분자를 정의하고 그 패턴에 따라 입력을 나눕니다. 기본 구분자는 공백 패턴(\p{javaWhitespace}+)으로, 공백이나 개행을 모두 토큰 구분자로 취급합니다. Scanner는 내부적으로 이 구분자 패턴을 나타내는 Pattern 객체와 Matcher를 사용하여 입력 버퍼에서 패턴 매치를 수행함으로써 토큰 경계를 식별합니다. 예를 들어 scanner.next()를 호출하면, 먼저 현재 버퍼 내용에서 구분자 패턴과 매치되는 부분(선행 공백들)을 정규식 매칭으로 스킵한 후 다음 구분자까지의 문자를 토큰으로 추출합니다. 이러한 정규식 기반 처리는 유연성을 주는 대신, 각 토큰 추출마다 Matcher.find() 등의 연산이 필요하여 추가 연산 오버헤드가 있습니다.
Scanner.nextLine()의 경우 Scanner의 구분자 설정과 무관하게 한 줄 전체를 읽어오는데, 내부 구현은 다른 토큰 메서드와 달리 정규식 구분자를 이용하지 않고 개행 문자까지의 모든 문자열을 반환하도록 특별 처리됩니다. 즉, nextLine()는 남은 입력을 개행 문자가 나타날 때까지 읽어서 반환하고, 개행 문자는 버퍼에서 제거합니다. 이 동작은 BufferedReader.readLine()와 비슷한 결과를 주지만, 구현 상으로는 findWithinHorizon 메서드를 이용해 (?s).* 패턴으로 개행 전까지를 한번에 찾는 등의 방식으로 처리됩니다 (정규식을 사용할 수 있으나, 기본 whitespace 구분자와는 별개로 동작). 따라서 nextLine()는 토큰 구분과 상관없이 라인 단위로 처리하고, 다른 nextX() 메서드들과 달리 정규식 구분자의 영향을 받지 않는다는 점을 유의해야 합니다. (예: 앞서 nextInt() 등으로 일부 읽은 후 남은 개행을 처리할 때 nextLine()가 빈 문자열을 반환하는 흔한 상황이 발생함).
저수준에서 BufferedReader는 큰 고정 크기의 버퍼에 문자를 채워 단순 루프로 개행을 탐지하고, Scanner는 작은 버퍼를 시작으로 필요시 확장해가며 정규식 매칭으로 토큰이나 라인을 식별합니다. 이로 인해 BufferedReader는 I/O 호출이 적고 구현이 단순하며, Scanner는 I/O 호출이 상대적으로 빈번하고 복잡한 처리를 거칩니다.
두 클래스의 내부 동작 방식은 객체 생성 패턴과 GC 부담 면에서도 차이가 납니다.
Scanner는 입력 파싱을 위해 정규식 Pattern과 Matcher 객체를 활용합니다. 기본적으로 구분자 패턴(디폴트 공백 패턴)은 Pattern.compile("\p{javaWhitespace}+") 형태로 정적 컴파일되어 Scanner 클래스에 하나만 존재합니다. 따라서 여러 Scanner 인스턴스를 만들더라도 기본 구분자용 Pattern은 재사용되며, 이를 통해 구분자를 스킵합니다. 또한 Scanner는 최근에 사용된 패턴들을 다시 컴파일하지 않도록 내부에 LRU 캐시(최대 7개)를 두어 관리합니다. 예를 들어 nextInt() 등을 호출할 때 쓰이는 숫자 패턴이나 사용자가 useDelimiter()로 지정한 패턴 등이 캐시되어, 반복 사용 시 매번 새로운 Pattern을 생성하지 않도록 최적화하고 있습니다. 그럼에도 불구하고 Scanner는 토큰을 읽는 매 반복마다 일부 객체를 생성하거나 사용합니다:
입력 토큰 문자열: Scanner.next()나 nextLine() 등은 결국 입력의 일부를 잘라 새로운 String 객체로 반환합니다. 이 문자열은 힙에 새로 할당되며, 짧은 수명으로 GC에 놓입니다. 예컨대 100만개의 토큰을 읽으면 100만개의 String 객체가 생기는 셈입니다 (파싱한 숫자는 int와 같은 기본형으로 받더라도, 그 전에 문자열로 존재했다가 버려집니다).
Matcher 객체: Scanner는 내부에 하나의 Matcher 인스턴스를 유지하면서 필요할 때마다 다른 Pattern으로 재설정하여 사용합니다. 구분자를 스킵할 때도, 토큰을 찾을 때도 동일한 Matcher를 재활용하지만 matcher.reset()이나 usePattern()을 호출하여 패턴과 영역을 조정합니다. 따라서 구분자 탐지 자체로 매번 새로운 Matcher를 만들지는 않지만, 정규식 매칭 동작은 여전히 수행됩니다. 이 과정에서 Matcher는 내부 상태를 가지므로 스레드마다 공유하지 않고, (scanner 인스턴스 내에서만 사용되므로) 토큰 처리 시 동시 생성되는 객체는 아니지만, 매 token 처리마다 Matcher.find() 등의 연산과 상태 변경이 일어납니다. 또한 Matcher가 매칭을 완료하면 개발자가 scanner.match()를 통해 MatchResult를 얻을 수 있는데, 이때 별도의 MatchResult 객체(사실상 Matcher의 복사본)가 생성됩니다. 일반적인 nextX() 사용에서는 MatchResult를 잘 쓰지 않으므로 이는 부수적 영향에 그칩니다.
Pattern 객체: 기본 구분자 패턴은 정적이지만, 숫자나 특정 타입을 판별하는 패턴은 동적으로 컴파일됩니다. 예를 들어 Scanner.nextInt()는 정수 형식에 맞는 패턴 (기본 10진수의 경우 -?\d+)을 필요로 하는데, JDK 구현은 이러한 패턴을 필요 시 생성하여 patternCache에 넣어둡니다. 한 번 생성된 후에는 캐시를 통해 재사용하지만, 서로 다른 타입 (nextInt, nextLong, nextFloat 등)이나 Locale에 따라 다른 패턴을 사용할 수 있어 몇 가지 Pattern 객체를 만들게 됩니다. 이들도 기본적으로 오래 지속되지는 않지만 캐시에 남아 있을 수 있습니다. 다행히 캐시 크기가 7로 제한되어 있어 무한정 늘어나지는 않습니다.
파싱 중 임시 객체: 만약 숫자 입력에 지역화된 형태(예: 천 단위 구분자나 비ASCII 숫자)를 Scanner가 지원하는 경우, 그 처리를 위해 추가 문자열 연산이나 변환이 이뤄집니다. 예를 들어 Scanner는 유니코드 숫자까지 인식하기 때문에, 만약 아라비아 숫자 등의 문자가 포함된 숫자 토큰을 읽으면 NON_ASCII_DIGIT 패턴으로 이를 감지하고 Character.digit()를 이용해 일반 '0'~'9'로 변환하는 과정을 거칩니다
. 이 과정에서도 새로운 String이 만들어질 수 있습니다. 또한 scanner.useLocale(...)에 따라 음수/양수 기호, 소수점, 천단위 구분자 문자열을 미리 설정해 두고 (negativePrefix, groupSeparator 등 필드) 숫자 패턴을 구성하는데 활용합니다. 이러한 설정으로 인해 복잡한 정규식 (예: 실수의 경우 여러 선택지를 포함한 패턴)이 만들어지고 매칭되므로, 파싱 비용과 이에 따른 임시 객체 생성이 증가합니다.
종합하면, Scanner는 편의성과 범용성 때문에 상당히 많은 단기 객체를 생성하며, 이것들이 가비지 컬렉션 대상이 됩니다. 특히 입력 토큰이 많을수록 그에 비례하여 String 객체 (및 필요 시 기타 객체)를 생성하므로 GC의 Young Generation이 자주 차게 되어 GC 빈도가 높아질 수 있습니다. 대용량 입력을 다룰 때 Scanner의 성능이 떨어진다는 보고는 주로 이러한 메모리 할당과 해제 부담에서 기인합니다.
BufferedReader는 입력을 있는 그대로 읽기만 하고 별도의 파싱을 하지 않기 때문에, 객체 생성 측면에서 Scanner보다 훨씬 단순합니다. 내부적으로는 고정된 char 버퍼 하나를 만들어 놓고 계속 재사용하며, 데이터를 읽을 때마다 이 버퍼를 채우고 내용물을 소비합니다. readLine() 메서드를 호출하면 새로운 String 객체가 반환되는데, 이는 해당 줄의 내용을 담기 위해 생성됩니다. 줄이 비교적 짧을 경우 BufferedReader는 그 줄의 문자를 바로 String으로 복사해서 반환합니다. 만약 한 줄이 버퍼 크기(8192)를 초과할 경우에만 내부에서 StringBuffer(JDK 11 이후로는 StringBuilder)를 추가로 할당하여, 버퍼에 읽힌 앞부분을 누적시키고 버퍼를 리필한 다음 이어지는 부분을 읽는 식으로 처리합니다. 즉, 아주 긴 한 줄을 처리할 때에만 일시적으로 가변 문자열 버퍼 객체 하나를 만들고 사용하며, 그 결과로 최종 완성된 String을 반환한 뒤 그 StringBuffer는 버려집니다. 평범한 짧은 행의 경우에는 추가 객체를 만들지 않고 곧바로 String으로 반환합니다.
예시: "Hello\n"라는 줄을 읽으면 BufferedReader는 내부 버퍼에 'H','e','l','l','o','\n'을 채우고, 루프에서 '\n'을 발견하면 new String(buffer, start, length)를 호출하여 "Hello" 문자열 객체를 생성해 반환합니다. 이 과정에서 생성된 객체는 결과 String 하나뿐입니다.
반면 동일한 입력을 Scanner로 읽으면, Scanner.next()나 nextLine() 호출 시 내부에서 공백 구분자 매칭을 하고 "Hello"를 추출하여 새로운 String을 만들고 반환합니다. 이때 결과 String 하나가 생성되는 것은 같지만, Matcher 상태 변경이나 정규식 엔진 동작 등 부가 연산이 있었습니다. 짧은 입력에서는 체감되지 않을 수준이지만, 대량 데이터에서는 이러한 부가적인 연산이 누적됩니다.
정규 표현식의 매칭 동작 자체가 객체를 많이 생성하지는 않지만(대부분의 연산이 원시 타입 계산으로 이뤄짐), 정규식 엔진의 복잡도와 추가적인 문자열 처리가 CPU를 더 사용하게 만들고, 결과적으로 처리가 느려지면 객체들의 생존 시간이 길어질 가능성이 있습니다. 예를 들어, Scanner로 입력을 읽어 여러 객체를 생성하면, 생성된 객체들이 GC에 수거되기까지 오래 걸린다면 Young 영역을 넘어 Old 영역으로 갈 수도 있습니다. 특히 한꺼번에 많은 입력을 짧은 시간에 Scanner로 생성하면 Young GC가 자주 발생하고, 운 나쁘게 GC 타이밍이 밀리면 객체가 Old Gen으로 승격되어 나중에 Full GC때까지 살아남는 등 메모리 관리 복잡성이 증가할 수 있습니다. BufferedReader는 이러한 면에서 매우 단순한 패턴을 보입니다. 매 readLine() 호출당 한 개의 String만 만들고, 길면 일시적으로 StringBuilder 하나 정도 추가로 사용할 뿐입니다. 그리고 그 String의 크기도 입력 줄 길이에 정확히 비례하며 불필요한 여유 공간을 가지지 않습니다. 반면 Scanner는 기본 버퍼(1KB 단위로 증가)의 여유, 정규식 객체들의 여유 공간 등으로 인해 추가 메모리 오버헤드를 가질 수 있습니다.
Scanner는 편리한 토큰화와 파싱 기능을 제공하지만 내부에서 다수의 단명 객체(short-lived objects)를 만들어내며 GC에 부담을 줍니다. 반대로 BufferedReader는 객체 생성과 메모리 재사용을 최소화하도록 구현되어 있어, 대용량 입력 처리 시 메모리 효율과 GC 면에서 유리합니다. 실제로 입력 처리가 방대한 상황(예: 온라인 저지의 입출력)에서 Scanner보다 BufferedReader가 권장되는 이유가 이러한 GC 부하와 성능 차이 때문입니다.
Scanner와 BufferedReader 모두 기본적으로 스레드 안전하지 않습니다. 다만 둘의 구현에서 동기화 사용 여부가 다르며, 이는 멀티스레드 환경에서의 동작에 영향을 줍니다.
BufferedReader는 내부에서 대부분의 메서드를 동기화해서 구현하고 있습니다. 사실 BufferedReader는 Reader를 상속하는데, Reader 클래스가 보호용 객체 lock을 가지고 있어 입출력 연산시 동기화하도록 설계되어 있습니다. BufferedReader 생성 시 이 lock을 전달받은 하위 Reader (InputStreamReader 등)로 설정하여, 결국 같은 락 객체를 통해 동기화합니다. 예를 들어 readLine() 메서드는 내부에서 synchronized(lock) 블록으로 구현되어 한 번에 한 스레드만 버퍼를 읽고 수정하도록 보장합니다. 이러한 구현 덕분에 BufferedReader 객체는 스레드 안전(thread-safe)하게 동작하며, 여러 스레드가 동시에 한 BufferedReader를 호출하더라도 내부 버퍼의 일관성이 깨지지 않습니다. 즉, BufferedReader의 메서드들은 synchronized 키워드로 보호되어 있어 기본적으로 thread-safe임이 문서상에도 언급됩니다.
하지만 스레드 안전하다는 것이 곧 여러 스레드에서 효율적으로 사용할 수 있다는 뜻은 아닙니다. 예를 들어 두 스레드가 하나의 BufferedReader로 동시에 readLine()을 호출하면, 락에 의해 순차적으로 실행되겠지만 어느 스레드가 어느 줄을 읽게 될지 예측하기 어렵고, 심지어 교대로 호출할 경우 줄이 섞일 위험도 있습니다. 실제 OpenJDK 버그 리포트 중에는 다중 스레드가 동시에 readLine()을 호출할 때 빈 문자열을 반환하는 레이스 컨디션 이슈가 언급되기도 했습니다. 따라서 설계적으로 thread-safe이긴 하나 동시에 같은 Reader를 읽는 패턴은 권장되지 않습니다. 그래도 최소한, 락으로 보호된 덕분에 잘못된 동작(예: 내부 버퍼 인덱스가 엉켜 예외 발생 등)은 피할 수 있습니다.
Scanner는 BufferedReader와 달리 내부에 동기화가 전혀 적용되지 않도록 설계되었습니다. Scanner의 메서드 구현을 보면 synchronized를 사용하거나 전역 락으로 보호하는 부분이 없습니다. 따라서 하나의 Scanner 인스턴스를 두 개 이상의 스레드가 동시에 사용하면 안전하지 않습니다. 예컨대 한 스레드가 scanner.nextLine()을 호출하는 도중 다른 스레드가 같은 scanner로 nextInt()를 호출하면, 내부 버퍼 position이나 matcher 상태가 서로 간섭하여 정상적인 토큰 분리가 어려워지거나 예외가 발생할 수 있습니다. 공식 JavaDocs에서도 Scanner는 스레드 안전하지 않으므로 멀티스레드 상황에서 공유하지 말 것을 언급합니다.
만약 부득이 하나의 Scanner 객체를 여러 스레드에서 써야 한다면, 호출 측에서 직접 동기화를 해주어야 합니다. 예를 들어 다음과 같이 사용할 수 있습니다:
Scanner sc = new Scanner(...);
...
synchronized(sc) {
if (sc.hasNext()) {
String token = sc.next();
// or int number = sc.nextInt();
}
}
이처럼 외부에서 Scanner 객체를 락으로 감싸서 사용하면 동시 접근을 제어할 수 있습니다. 그러나 이 경우 차라리 BufferedReader처럼 동기화가 내장된 클래스를 사용하는 편이 더 낫고, 근본적으로는 입력을 동시에 여러 스레드가 나눠서 읽는 상황 자체를 재고하는 것이 좋습니다. 일반적으로 입력 처리 성능을 높이려 멀티스레딩을 시도하기보다는, 하나의 스레드가 빠르게 읽어들인 후 처리만 여러 스레드로 분배하는 식의 접근이 권장됩니다.
여러 스레드가 동시에 입력을 읽어야 하는 경우, 각 스레드마다 별도의 Scanner/BufferedReader 인스턴스를 사용하는 것이 가장 안전합니다. 예를 들어 각각 다른 파일이나 소켓을 읽는 거라면 상관없지만, 동일한 소스로부터 읽는 것이라면 한 스레드가 다 읽은 후 다른 스레드가 읽거나, 작업을 분할해서 한 스레드가 파일의 전반부, 다른 스레드가 후반부를 읽도록 명시적으로 분할해야 합니다.
BufferedReader는 thread-safe이지만, 읽는 순서가 중요하다면 결국 동기화된 블록에서 순차적으로 처리할 수밖에 없습니다. 그러므로 동시성의 이점을 얻기 어렵습니다.
Scanner는 thread-safe하지 않으므로 공유 불가가 원칙이며, 공유해야 한다면 직접 락을 사용해야 합니다. 하지만 이런 경우 차라리 BufferedReader를 사용하거나 입력을 미리 한 곳에서 읽어 버퍼(queue)에 넣고 작업자 스레드가 그 버퍼에서 소비하는 방식을 고려해야 합니다.
정리하면, 두 클래스 모두 멀티스레드 입력 읽기에 적합하지 않으며 필요 시 외부 동기화가 필요하지만, BufferedReader 쪽이 내부적으로 락을 가지고 있어 그나마 안전합니다. Scanner는 내부 동기화가 전혀 없어, 멀티스레드에서 공유하지 않는 것이 원칙입니다.
두 클래스의 핵심 메서드 구현을 JDK 소스 레벨에서 비교해 보면, 앞서 설명한 개념들이 어떻게 코드로 나타나는지 알 수 있습니다.
BufferedReader.readLine() 메서드는 루프를 돌면서 버퍼 내 문자를 검사하는 전형적인 구현을 가지고 있습니다. JDK 소스를 단순화하여 표현하면 다음과 같습니다 (설명 편의를 위해 pseudo-code 형태로 표현):
public synchronized String readLine() throws IOException {
StringBuffer s = null;
while (true) {
if (nextChar >= nChars) { // 버퍼를 모두 소비했다면
fill(); // 버퍼 채우기 (InputStream에서 읽어옴)
if (nextChar >= nChars) { // EOF 도달
return (s != null && s.length() > 0) ? s.toString() : null;
}
}
boolean eol = false;
char c = 0;
// 버퍼에서 개행 문자 탐색
for (int i = nextChar; i < nChars; i++) {
c = cb[i];
if (c == '\n' || c == '\r') {
eol = true;
// i 위치에서 개행 발견
break;
}
}
int startChar = nextChar;
nextChar = (eol ? i + 1 : nChars); // 개행이면 해당 위치까지, 아니면 버퍼 끝까지
if (eol) {
String str;
if (s == null) {
// 지금까지 누적된 게 없다면 바로 버퍼에서 문자열 생성
str = new String(cb, startChar, i - startChar);
} else {
// 이미 앞 버퍼 조각들을 누적한 상태라면 현재 부분 추가 후 문자열 생성
s.append(cb, startChar, i - startChar);
str = s.toString();
}
if (c == '\r') {
// \r\n 처리: \r 다음에 \n 나오면 이후 읽을 때 건너뛰도록 플래그 세팅
skipLF = true;
}
return str;
}
// 개행을 못찾은 경우 현재 버퍼 내용을 누적하고 루프 계속
if (s == null) s = new StringBuffer();
s.append(cb, startChar, nChars - startChar);
// loop continues to fill again
}
}
위 pseudo-code에서 볼 수 있듯이, readLine()은 개행 문자를 발견하기 전까지 내부 버퍼 cb 배열을 인덱스로 훑으며(charLoop) 확인합니다. 개행을 찾으면 그 부분까지 또는 직전까지의 문자열을 만들어 반환합니다. 개행을 찾지 못하고 버퍼 끝에 도달하면, 일단 현재 버퍼 내용을 StringBuffer s에 누적하고 (append 호출) 버퍼를 새로 채운 후 다시 루프를 돕니다. 이 과정이 끝나면 StringBuffer s에 여러 번에 걸쳐 누적된 한 줄 전체가 담기게 되고 s.toString()으로 최종 문자열을 얻습니다. 줄이 짧아서 한 번의 버퍼에서 끝나면 s는 null인 상태로 남고, 그 경우 new String(cb, start, length)로 직접 문자열을 생성하여 반환합니다.
주목할 점은:
동기화: 모든 작업이 synchronized(lock) 블록 내에서 이루어지므로, 동시에 둘 이상의 스레드가 이 메서드에 들어올 수 없습니다.
네이티브 I/O: fill() 메서드는 내부의 Reader in (실제로는 InputStreamReader)로부터 데이터를 읽어 cb에 채우는데, 필요한 경우에만 호출됩니다. 즉, 버퍼를 다 소비할 때까지는 추가 읽기가 없고, 읽을 때에도 in.read(char[] ...)을 호출하여 OS 레벨의 읽기를 수행합니다. 한번 읽을 때 최대 8192 char까지 읽으므로, 효율적입니다.
객체 생성: 개행을 찾았을 때 새로 만드는 객체는 String 한 개이며 (필요 시 StringBuffer를 사용하지만 이것도 한 줄당 최대 1개만 생성), 이미 만들어져 있는 버퍼 cb나 StringBuffer s 등을 재사용하기 때문에 불필요한 할당이 적습니다.
한편, 캐리지리턴-라인피드 "\r\n" 시퀀스 대응을 위해 skipLF나 omitLF 등의 플래그 처리가 들어가 있지만, 이는 구현상의 디테일입니다. 요컨대 BufferedReader.readLine()은 간결한 루프와 조건문들로 구성되어 있고, 문자열 찾기 작업을 자바의 기본 연산으로 수행합니다.
Scanner의 동작은 앞서 설명한 대로 정규식 매칭을 기반으로 합니다. Scanner.nextLine()의 경우 JDK 문서상 "현재 라인을 넘어가며 그 내용을 반환"하도록 정의돼 있는데, 실제 구현에서는 특수한 패턴을 사용하거나 버퍼를 직접 탐색하여 개행까지의 내용을 얻습니다. (공개된 소스 코드 기준으로는 findWithinHorizon 메서드를 이용해 "\r\n|[\r\n]" 패턴을 찾고 그 앞까지를 반환하는 방식으로 구현되어 있습니다.) 쉽게 말해, nextLine()은 남은 입력을 개행 문자가 나타날 때까지 하나의 토큰으로 간주하고 반환합니다. 디폴트 구분자 패턴(공백)은 nextLine()에는 적용되지 않으며, newline 자체가 토큰 종료 조건이 됩니다. 구현 세부사항을 pseudo-code로 나타내면 다음과 같습니다:
public String nextLine() {
if (sourceClosed && bufferEmpty) throw new NoSuchElementException();
String result = "";
if (position < buf.limit()) {
// 버퍼 내에 개행 문자가 있는지 확인
// (직접 루프 탐색 또는 정규식 패턴 "(?s)(.*?)(\r\n|[\r\n])" 활용 가능)
if (foundNewlineInBuffer) {
result = substring(buf, currentPos, newlinePos);
position = newlinePos + newlineLength; // 개행 건너뜀
return result;
}
}
// 버퍼에 개행이 없으면 반복해서 읽어옴
StringBuilder sb = new StringBuilder();
while (true) {
if (position < buf.limit()) {
// 현재 버퍼의 남은 부분을 모두 sb에 추가
sb.append(buf.subSequence(position, buf.limit()));
position = buf.limit();
}
if (sourceClosed) {
// EOF 도달
break;
}
readInput(); // CharBuffer buf를 채움 (fillBuffer)
// 새로 읽은 부분에 개행이 있는지 확인
if (foundNewlineInNewData) {
sb.append(buf.subSequence(0, newlinePos));
position = newlinePos + newlineLength;
result = sb.toString();
return result;
}
// 개행 없으면 루프 계속
}
result = sb.toString();
// sb가 비어있으면 (EOF이고 아무 데이터 없음) NoSuchElement
if (result.isEmpty()) throw new NoSuchElementException();
return result;
}
위 pseudo-code는 이해를 돕기 위한 것이며, 실제 구현은 findWithinHorizon라는 정규식 기반 메서드로 간결하게 처리됩니다. 핵심은 Scanner.nextLine()도 결국 버퍼를 채우고 개행을 찾아 문자열을 만드는 과정을 거친다는 점입니다. 다만 Scanner의 일반 토큰 처리(공백 구분)와 달리 여기서는 공백도 데이터로 간주하므로, 구분자 패턴 매칭을 잠시 무시하고 개행 문자만 특별 취급합니다. 이렇듯 nextLine()은 사실상 BufferedReader.readLine()와 유사한 로직을 수행하지만, Scanner 특유의 버퍼 관리와 예외 처리를 따르는 차이가 있습니다.
한편, 일반적인 토큰 읽기 예인 Scanner.nextInt()의 구현을 살펴보면, 의외로 단순합니다. Scanner는 숫자 토큰을 읽을 때 다음 토큰을 문자열로 받아 Integer.parseInt()를 호출해 변환합니다. 즉, 내부적으로 nextInt()는 (radix가 기본 10일 때) 다음 토큰을 next()로 읽어오고, 그 문자열을 Integer.parseInt(토큰)으로 파싱한 후 반환합니다. 실제 JDK Scanner.nextInt() 코드는 예외 처리를 포함하여 대략 다음과 같습니다:
public int nextInt() {
// hasNextInt와 유사하게 구현; 여기서는 단순화
String token = next(); // 공백 구분자로 다음 토큰 읽기
try {
return Integer.parseInt(token);
} catch (NumberFormatException nfe) {
// 토큰이 int로 변환 불가 -> InputMismatchException throw
throw new InputMismatchException(nfe.getMessage());
}
}
JDK 1.5 도입 당시 Scanner의 의도대로, 정수 변환은 자바 내장 파서를 그대로 이용하는 방식입니다. 이 과정에서 이미 정규식으로 토큰이 -?\d+ 형태임을 보장했다면 숫자 변환은 빠르게 완료될 것이고, 만약 정규식 검증 없이 parseInt를 했다가 NumberFormatException이 발생하면 이를 InputMismatchException으로 바꿔 던지도록 되어 있습니다. 실제 구현은 hasNextInt()에서 정규식 검증을 하고 캐시에 결과를 담아두었다가 nextInt() 호출 시 사용하거나, 그렇지 않으면 위와 같이 직접 변환하는 형태를 취합니다. 중요한 점은 결국 Scanner도 문자열 -> 숫자 변환에 Integer.parseInt나 Double.parseDouble 등을 사용한다는 것입니다. 따라서 파싱 과정에서 불필요한 객체를 더 만들진 않지만, 변환할 문자열 자체는 이미 Scanner 내부에서 생성되었다는 점은 동일합니다.
Scanner가 토큰을 어떻게 구분하는지는 소스 코드의 getCompleteTokenInBuffer(Pattern pattern) 메서드 부분을 보면 잘 나타납니다. 요약하면 다음과 같습니다:
현재 position부터 delimPattern (구분자 패턴)으로 lookingAt()(정규식 매칭이 버퍼 시작에서 연속적으로 되는지 확인)을 수행합니다. 즉, 현 위치에서 연속된 공백을 매칭하여 스킵하려 합니다.
만약 매칭이 성공하면 position = matcher.end()로 설정하여 구분자 부분을 건너뜁니다. 이 과정에서 matcher.hitEnd()가 참이면 (즉 구분자 패턴이 버퍼 끝까지 매치되었는데 입력이 더 있을 수 있는 상황) needInput = true로 설정하고 토큰 추출을 보류합니다. 이는 구분자가 버퍼 끝에 걸쳐 있을 경우(예를 들어 공백이 아주 길어서 버퍼를 넘을 때) 추가 입력을 받아 완전한 구분자 시퀀스를 확인하려는 의도입니다.
구분자 스킵이 완료되면 skipped = true 플래그를 세워 더 이상 동일 호출 내에서 구분자 스킵을 반복하지 않.
구분자를 넘고 난 뒤 position == buf.limit()이면 버퍼에 남은 토큰이 없다는 의미이므로 (EOF가 아니면) needInput = true로 표시하고 처리를 중단(추가 입력 대기)합니다.
그 다음, 찾고자 하는 토큰 패턴(pattern 매개변수)이 주어져 있다면 (예: hasNextInt의 경우 숫자 패턴), 해당 패턴과 일치하는 토큰을 찾아야 합니다. 하지만 Scanner는 토큰 경계를 찾는 방식으로 구현되어 있습니다. 따라서 먼저 다음 구분자를 찾습니다:
foundNextDelim이 true이면, matcher.start()가 바로 현재 토큰의 끝 위치가 됩니다. 이 값을 tokenEnd로 저장합니다. 여기서도 matcher.requireEnd() 체크를 통해 “추가 입력이 없으면 현재 매치가 유지되지만, 더 입력이 들어오면 달라질 수 있는 경우”를 판단하여 필요 시 needInput = true로 토큰 확정을 미룹니다. (예: 구분자 패턴이 \s+인데 버퍼 끝이 공백으로 끝났고 더 공백이 이어질 수 있는 상황 등.)
이와 같이 Scanner는 토큰 경계를 찾기 위해 현재 토큰의 다음 구분자 위치를 탐색하는 방식을 취합니다. 이것이 가능한 이유는 구분자 패턴이 토큰 구분 역할을 하기 때문인데, 숫자 패턴같이 특정한 패턴을 찾는 경우에는 먼저 그 패턴을 만족하는지 확인하고, 실패 시 InputMismatchException을 던지는 식으로 구현되어 있습니다 (이 부분은 hasNextInt()와 nextInt()의 협조로 동작함).
BufferedReader.readLine()은 문자를 직접 다루는 저수준 루프를 통해 개행을 검출하고, 간단명료한 로직으로 문자열을 반환합니다. 시간 복잡도는 O(n) (n은 읽는 문자 수)로 매우 예측 가능하며, 불필요한 분기나 메서드 호출이 적습니다.
Scanner의 토큰 읽기 (nextLine() 포함)는 정규식 엔진을 거치기 때문에 내부적으로 많은 분기와 메서드 호출이 이루어집니다. 입력이 같은 O(n)이라도 정규식 매칭은 단순 루프에 비해 상수 시간이 더 크고, 패턴에 따라 복잡도가 늘어날 수 있습니다. 또한 Scanner 구현은 상태 변수를 여러 개 관리하며 (예: position, matcher, skipped, needInput 등) 토큰 하나를 반환하기까지 수차례 함수를 호출합니다. 이는 코드의 복잡도로 이어지고, JIT 컴파일러가 최적화하기에도 상대적으로 어려운 구조입니다.
결론적으로, 소스 코드 수준에서도 BufferedReader는 읽기 작업에 집중된 단순한 흐름을 가지고 있고, Scanner는 다목적 파싱기 역할을 하느라 복잡한 상태 머신처럼 동작함을 알 수 있습니다. 이러한 차이는 성능 및 최적화 측면에도 그대로 영향을 끼칩니다.
앞선 내용을 종합하면, Scanner와 BufferedReader의 성능 차이는 크게 I/O 읽기 효율과 파싱 오버헤드로 요약할 수 있습니다. 일반적으로 BufferedReader가 Scanner보다 빠르고 가벼운 것으로 알려져 있습니다. 여기서는 JVM의 최적화 측면에서 그 차이를 바라보겠습니다.
I/O 성능 (버퍼 크기 및 호출 횟수): BufferedReader는 큰 버퍼를 사용하여 디스크나 네트워크로부터 읽기 호출을 최소화하므로, OS 레벨에서의 문맥 전환과 시스템 콜 오버헤드가 줄어듭니다. 예를 들어, 1MB의 데이터를 읽을 때 BufferedReader는 약 128번(=1024KB/8KB) read 호출이면 충분하지만, Scanner는 기본 설정으로는 1024번(=1024KB/1KB) 호출이 필요할 수 있습니다 (실제는 버퍼 확장으로 조금 줄어들 수 있지만 대략적인 비교). 시스템 콜은 상대적으로 비용이 크므로, 이러한 차이는 성능에 영향을 줍니다.
JVM의 JIT 컴파일러가 이 부분을 직접 최적화할 방법은 제한적입니다. 다만, BufferedReader의 read(char[]) 호출이나 Scanner의 source.read(CharBuffer) 호출 자체는 각각 한두 단계의 자바 메서드를 거쳐 네이티브 함수로 이어지는데, JIT는 이 네이티브 경계(native boundary)를 넘어 최적화할 수 없습니다. 따라서 I/O 호출 횟수는 애플리케이션 레벨에서 결정된 그대로 수행됩니다. 결국 버퍼 크기 설정이 성능 결정 요소가 되며, BufferedReader의 우위는 흔히 JVM 최적화가 아닌 설계상의 이점입니다.
Scanner가 느린 주된 이유는 토큰화를 위한 추가 연산입니다. JIT 컴파일러는 이러한 자바 레벨 연산들을 최적화할 수는 있지만, 완전히 없앨 수는 없습니다. 몇 가지 가능한 JVM 최적화 포인트를 살펴보겠습니다:
메서드 인라이닝(Inlining): JIT는 호출 빈도가 높고 본문이 작은 메서드를 인라인화하여 호출 오버헤드를 없앱니다. BufferedReader.readLine()의 경우 내부에서 여러 private 메서드 (fill(), read1() 등)를 호출하지만, JIT가 충분히 인라인할 수 있습니다. 결과적으로 실제 머신 코드에서는 루프와 조건문만 남고 함수 호출이 제거되어 매우 효율적으로 동작할 수 있습니다. 반대로 Scanner의 구현은 다양한 메서드 (hasNext, hasNextPattern, getCompleteTokenInBuffer, readInput 등)로 나뉘어 있고, 각 메서드의 코드도 비교적 복잡합니다. JIT는 너무 복잡한 메서드는 인라인하지 않거나 부분만 인라인하므로, Scanner의 토큰 추출 경로 전체를 하나의 연속된 코드로 펼쳐두기 어려울 수 있습니다. 그럼에도 JIT는 루프 내에서 자주 호출되는 작은 메서드 (예: matcher.lookingAt(), matcher.find() 같은 호출)을 인라인하거나, 상수를 전달하는 경우 상수 전파(Constant propagation)를 통해 분기를 단순화하는 등 최적화를 시도할 것입니다. 하지만 정규식 엔진의 로직 자체는 상태 기계(유한 상태 머신)로 구현되어 있어, JIT가 근본적인 알고리즘을 바꾸지는 못합니다.
Escape Analysis 및 객체 할당 최적화: JVM은 Escape Analysis를 통해 메서드 내에서 생성된 객체가 외부로 스코프를 벗어나지 않으면 스택 할당하거나 아예 제거할 수 있습니다. BufferedReader.readLine()에서는 StringBuffer s와 최종 반환하는 String이 생성됩니다. String은 반환값으로 escape하므로 제거할 수 없지만, StringBuffer s는 메서드 내부에서만 사용되고 escape하지 않기 때문에, JIT가 이를 분석해 스칼라로 치환(scalar replacement)할 수 있습니다. 예컨대 JIT는 StringBuffer의 내부 char 배열을 스택에 올리고, 호출 종료 시 그 내용을 String으로 복사하는 형태로 최적화 가능할 수 있습니다. 특히 JDK 11 이후로 StringBuffer 대신 StringBuilder를 사용하고 있어 동기화 오버헤드도 없으므로, 이러한 최적화 여지가 큽니다. 이 최적화는 개발자가 느끼지는 못하지만, 큰 줄을 처리할 때 불필요한 메모리 복사를 줄여주는 효과가 있을 수 있습니다.
Scanner의 경우도 마찬가지로 내부에서 생성하는 많은 단기 객체들(Pattern, Matcher 등)이 대개 메서드 로컬이므로 Escape Analysis 대상입니다. 예컨대 Integer.parseInt(token) 과정에서 생성되는 Integer 객체는 없고 (기본형 int 반환), NumberFormatException은 예외 상황에만 생성됩니다. Matcher는 재사용하므로 오히려 한 번 할당된 후 계속 유지됩니다. 다만, Scanner.next()나 nextLine()이 반환하는 String은 결과로 escape하고, 그 이전 단계에서 토큰 추출을 위해 잠깐 생성했다 폐기하는 임시 StringBuilder 같은 객체는 없습니다 (Scanner는 버퍼 확장과 substring으로 처리하여 임시 빌더를 사용하지 않음). 따라서 Scanner 측면에서는 Escape Analysis로 제거될만한 큰 객체는 별로 없고, 최적화 여지는 주로 인라인과 분기 정리에 있습니다.
락 제거 및 병렬 실행: BufferedReader의 동기화는 JIT에 의해 락 엘리전(lock elision) 또는 락 coarsening의 대상이 될 수 있습니다. 만약 BufferedReader 객체가 스레드 간 공유되지 않고, JIT가 해당 락이 경합하지 않는다는 것을 알게 되면, 내부의 synchronized 블록을 실제 락 없이 실행하도록 최적화할 수 있습니다. 현대 HotSpot JVM에서는 편향 락(biased locking)과 경합이 없을 시 경량 락 최적화가 기본 적용되므로, 단일 스레드에서 BufferedReader를 쓴다면 락 획득/해제 비용은 매우 미미합니다. 그 결과 BufferedReader의 thread-safe 구현이 성능에 거의 영향 주지 않게 됩니다. Scanner는 원래 동기화를 하지 않으므로 이런 최적화는 필요 없지만, BufferedReader 쪽은 JIT 덕분에 동기화 비용 차이가 사실상 제거되는 효과가 있습니다. 따라서 "BufferedReader는 synchronized 때문에 느릴 것이다"라고 짐작할 수 있지만 실제로는 그렇지 않습니다 (단일 쓰레드 사용 시).
분기 예측 및 폴링 루프 최적화: BufferedReader의 코드 흐름은 비교적 단순하여 분기 예측에 유리합니다. 예를 들어 대부분의 라인은 8192자보다 훨씬 짧으므로, 위 readLine 루프에서 eol을 발견하는 분기가 빠르게 일어나고, fill()은 자주 호출되지 않는 드문 경로입니다. CPU 입장에서 예측하기 쉽고, JIT도 그 패턴을 유지합니다. 반면 Scanner의 정규식 매칭은 각 문자별 검사라기보다는 상태 전이 기반이라 분기 패턴이 복잡하고, 최악의 경우 backtracking 등으로 인해 예측 실패가 누적될 수 있습니다. JIT는 이러한 패턴까지는 제어 못하지만, 잘 동작하는 정규식 (예: \s+나 \d+ 같은 단순 패턴)은 내부적으로 효율적인 DFA로 실행되므로 분기 비용이 과도하지는 않습니다.
메모리 사용량: Scanner는 더 많은 임시 객체와 더 큰 GC 부하를, BufferedReader는 적은 객체와 낮은 GC 부하를 보여줍니다. 이는 메모리가 한정된 환경에서 OutOfMemory 위험이나 GC pause 시간 차이로 이어질 수 있습니다.
CPU 시간: 다수의 벤치마크에서 Scanner로 숫자 100만 개 읽기 등이 BufferedReader보다 수배 이상 느리게 나타납니다. 이러한 차이는 위에서 분석한 바와 같이 작은 버퍼 + 정규식 매칭 + 파싱 단계가 누적된 결과입니다. JIT 최적화가 들어가도 기본 알고리즘 차이를 뒤집을 정도는 아니어서, Scanner의 편리함을 얻는 대가로 일정 정도 성능을 포기해야 합니다.
한 가지 예로, Scanner에서 많은 토큰을 읽는 루프가 JIT에 의해 최적화될 경우, Integer.parseInt 호출은 매우 빈번하므로 JIT가 이를 인라인하고 심지어 정수 파싱 루프 자체를 최적화할 가능성이 있습니다. HotSpot은 Integer.parseInt의 바이트코드를 직접 CPU 명령으로 변환하진 않지만, 함수 호출 없이 숫자 변환이 이뤄지도록 할 수는 있습니다. 그럼에도 불구하고 Scanner의 구조적 복잡성은 남아 있어서, 근본적인 I/O 비용이나 정규식 비용을 없앨 수는 없습니다.
정리: