BufferedReader 내부 동작 원리 - fill()과 버퍼의 비밀
이전 글에서 BufferedReader가 빠른 이유는 "버퍼링" 때문이라고 했습니다. 이번 글에서는 실제로 버퍼가 어떻게 동작하는지, 소스 코드 레벨에서 파헤쳐보겠습니다.
1. BufferedReader의 핵심 필드
BufferedReader 내부를 열어보면 이런 필드들이 있습니다.
public class BufferedReader extends Reader {
private Reader in; // 실제 데이터 소스 (InputStreamReader 등)
private char[] cb; // 문자 버퍼 배열 (기본 8192칸)
private int nChars; // 버퍼에 실제로 채워진 문자 수
private int nextChar; // 다음에 읽을 위치 (인덱스)
private static int defaultCharBufferSize = 8192; // 8KB
}
필드별 역할
| 필드 | 역할 | 초기값 |
| cb | 문자들을 저장하는 버퍼 배열 | new char[8192] |
| nChars | 버퍼에 들어있는 총 문자 수 | 0 |
| nextChar | 다음에 읽을 인덱스 | 0 |
2. 버퍼 상태 시각화
실제로 버퍼가 어떻게 생겼는지 그림으로 보면:
cb[] 배열:
┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ H │ e │ l │ l │ o │ \n│ │ │ │ │
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
0 1 2 3 4 5 6 7 8 9
↑
nextChar=4 nChars=6
- 인덱스 0~5에 "Hello\n" 저장됨 (nChars=6)
- 인덱스 0~3까지 읽음 (nextChar=4)
- 아직 읽지 않은 데이터: "o\n" (인덱스 4~5)
3. read() 메서드 동작
public int read() throws IOException {
synchronized (lock) {
ensureOpen(); // 스트림 열려있는지 확인
// 1. 버퍼에 읽을 게 남아있나?
if (nextChar >= nChars) {
// 2. 없으면 버퍼 새로 채우기
fill();
}
// 3. fill() 해도 데이터 없으면 EOF
if (nextChar >= nChars)
return -1;
// 4. 버퍼에서 한 문자 꺼내고 인덱스 증가
return cb[nextChar++];
}
}
핵심 로직
nextChar >= nChars→ 버퍼 소진 여부 체크- 소진됐으면
fill()호출 - 버퍼에서 문자 하나 반환하고
nextChar++
4. fill() 메서드 - 진짜 I/O가 일어나는 곳
fill()은 BufferedReader의 핵심 중의 핵심입니다.
private void fill() throws IOException {
int dst;
if (markedChar <= UNMARKED) {
// mark 안 된 상태: 버퍼 처음부터 채우기
dst = 0;
} else {
// mark 처리 (나중에 설명)
int delta = nextChar - markedChar;
if (delta >= readAheadLimit) {
markedChar = INVALIDATED;
dst = 0;
} else {
System.arraycopy(cb, markedChar, cb, 0, delta);
markedChar = 0;
dst = delta;
}
}
nextChar = dst;
nChars = dst;
// ⭐ 여기서 실제 I/O 발생!
int n = in.read(cb, dst, cb.length - dst);
if (n > 0)
nChars = dst + n;
}
fill() 동작 과정
[Before - 버퍼 소진됨]
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ H │ e │ l │ l │ o │ \n│ │ │
└───┴───┴───┴───┴───┴───┴───┴───┘
↑
nextChar=6, nChars=6
"nextChar >= nChars 이므로 fill() 호출!"
[After - 새 데이터로 채움]
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ W │ o │ r │ l │ d │ ! │ │ │
└───┴───┴───┴───┴───┴───┴───┴───┘
↑ ↑
nextChar=0 nChars=6
중요한 점: fill()에서 in.read(cb, dst, cb.length - dst)가 호출될 때 시스템 콜이 발생합니다. 이게 실제 I/O입니다.
5. readLine()을 여러 번 해도 fill()은 적게 호출된다
이게 BufferedReader가 빠른 핵심 비밀입니다.
예시 상황
입력 데이터:
"Hello\n" (6자)
"World\n" (6자)
"Java\n" (5자)
────────────────
총 17자
버퍼 크기: 8192자 (기본값)
실행 흐름
═══════════════════════════════════════════════════
[첫 번째 readLine() 호출]
초기 상태:
버퍼: [비어있음]
nextChar=0, nChars=0
→ nextChar >= nChars 이므로 fill() 호출!
→ 17자 전부 버퍼에 로드됨
버퍼: [H][e][l][l][o][\n][W][o][r][l][d][\n][J][a][v][a][\n]
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
nextChar=0, nChars=17
→ '\n' 찾을 때까지 읽음
→ "Hello" 반환
→ nextChar=6
═══════════════════════════════════════════════════
[두 번째 readLine() 호출]
버퍼: [H][e][l][l][o][\n][W][o][r][l][d][\n][J][a][v][a][\n]
↑
nextChar=6, nChars=17
→ nextChar(6) < nChars(17) 이므로 fill() 안 함!
→ 버퍼에서 바로 읽음
→ "World" 반환
→ nextChar=12
═══════════════════════════════════════════════════
[세 번째 readLine() 호출]
→ 마찬가지로 fill() 안 함!
→ "Java" 반환
→ nextChar=17
결과
| readLine() 호출 | fill() 호출 | 실제 I/O |
|---|---|---|
| 3번 | 1번 | 1번 |
17자를 읽는데 시스템 콜은 딱 1번!
6. 대용량 입력에서의 동작
10만 줄 입력 시나리오
// 10만 줄 읽기
for (int i = 0; i < 100000; i++) {
br.readLine();
}
한 줄이 평균 20자라고 가정하면:
- 총 데이터: 100,000 × 20 = 2,000,000자 (약 2MB)
- 버퍼 크기: 8,192자
- fill() 호출 횟수: 2,000,000 ÷ 8,192 ≈ 244번
10만 번 읽어도 시스템 콜은 약 244번!
버퍼 없이 읽었다면 최소 10만 번의 시스템 콜이 필요했을 겁니다.
7. readLine() 내부 구현
readLine()도 내부적으로 버퍼를 활용합니다.
public String readLine() throws IOException {
StringBuilder s = null;
int startChar;
synchronized (lock) {
ensureOpen();
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;
// 버퍼에서 개행 문자 찾기
for (i = nextChar; i < nChars; i++) {
c = cb[i];
if ((c == '\n') || (c == '\r')) {
eol = true;
break;
}
}
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++;
// \r\n 처리 생략
return str;
}
// 개행 못 찾으면 StringBuilder에 추가하고 다시 fill()
if (s == null)
s = new StringBuilder(defaultExpectedLineLength);
s.append(cb, startChar, i - startChar);
}
}
}
핵심 포인트
- 버퍼에서
\n또는\r을 찾을 때까지 탐색 - 찾으면 해당 부분만 String으로 반환
- 버퍼 끝까지 못 찾으면 StringBuilder에 추가하고 fill() 후 계속
8. 버퍼 크기 조절
기본 8KB가 부족하다면 생성자에서 조절 가능합니다.
// 기본 8KB
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
// 64KB로 늘리기
BufferedReader br = new BufferedReader(new InputStreamReader(System.in), 65536);
// 1MB로 늘리기 (대용량 파일)
BufferedReader br = new BufferedReader(new FileReader("huge.txt"), 1024 * 1024);
언제 늘려야 할까?
| 상황 | 권장 크기 |
| 일반적인 알고리즘 문제 | 기본값 (8KB) |
| 매우 긴 줄이 있는 경우 | 64KB~256KB |
| 대용량 파일 처리 | 1MB 이상 |
💡 대부분의 경우 기본값으로 충분합니다.
9. mark()와 reset()
BufferedReader는 읽은 위치를 기억했다가 되돌아가는 기능도 제공합니다.
br.mark(100); // 현재 위치 기억 (100자까지 되돌리기 가능)
br.readLine(); // 읽기
br.readLine(); // 읽기
br.reset(); // mark 위치로 되돌아감
mark가 있을 때 fill() 동작
mark가 설정되어 있으면 fill() 시 기존 데이터를 보존해야 합니다.
[mark 상태에서 버퍼 소진]
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ A │ B │ C │ D │ E │ │ │ │
└───┴───┴───┴───┴───┴───┴───┴───┘
↑ ↑
markedChar=1 nextChar=4 nChars=5
"reset() 대비해서 B,C,D,E는 보존해야 함"
[fill() 후 - 앞으로 복사 + 새 데이터 추가]
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ B │ C │ D │ E │ F │ G │ H │ │
└───┴───┴───┴───┴───┴───┴───┴───┘
↑ ↑
markedChar=0 nextChar=3 nChars=7
이 때문에 mark()를 사용하면 fill()에서 System.arraycopy()가 추가로 호출되어 약간의 오버헤드가 있습니다.
10. 정리
BufferedReader가 빠른 이유를 내부 동작 관점에서 정리하면:
| 포인트 | 설명 |
| 버퍼 배열 | 8KB 크기의 char[] 배열에 데이터 미리 로드 |
| fill() 최소화 | 버퍼 소진 시에만 실제 I/O 발생 |
| 인덱스 관리 | nextChar, nChars로 효율적인 위치 추적 |
| Lazy Loading | 생성 시 버퍼만 만들고, 첫 read() 때 fill() |
10만 줄 읽기:
- 버퍼 없이: 시스템 콜 10만+ 번
- BufferedReader: 시스템 콜 약 244번 (8KB 버퍼 기준)
이게 BufferedReader가 Scanner보다 빠른 근본적인 이유입니다!
다음 글에서는 BufferedReader의 동기화 메커니즘 - 왜 synchronized(lock)을 쓰는지 알아보겠습니다.
'Language > Java' 카테고리의 다른 글
| [Java] Java 스레드의 모든 것 — 스레드 모델부터 Virtual Thread까지 (0) | 2026.03.15 |
|---|---|
| [Java][BufferedReader] 동기화 메커니즘 - 데코레이터 패턴의 적용 (0) | 2025.12.31 |
| [Java][BufferedReader] 완벽 정리 - 왜 Scanner 대신 쓸까? (0) | 2025.12.31 |
| [Java][Collections Framework 완전 정복] Stack 클래스를 사용하지 않는 이유 및 대안 (0) | 2025.01.16 |
| [Java][Collections Framework 완전 정복] Map 인터페이스, Map.Entry 인터페이스 (0) | 2025.01.07 |