BufferedReader 동기화 메커니즘 - synchronized(lock)의 비밀
BufferedReader 소스 코드를 보다 보면 synchronized (lock)이라는 구문을 자주 만납니다. 왜 synchronized 메서드를 쓰지 않고 굳이 lock 필드를 사용할까요?
이번 글에서는 Java I/O의 동기화 설계에 대해 깊이 파헤쳐보겠습니다.
1. BufferedReader의 동기화 코드
read() 메서드를 보면:
public int read() throws IOException {
synchronized (lock) { // ← lock 객체로 동기화
ensureOpen();
if (nextChar >= nChars)
fill();
if (nextChar >= nChars)
return -1;
return cb[nextChar++];
}
}
readLine()도 마찬가지:
public String readLine() throws IOException {
synchronized (lock) { // ← 같은 lock 객체
// ...
}
}
2. lock은 어디서 오는가?
BufferedReader는 Reader 클래스를 상속합니다. lock은 Reader에 정의되어 있습니다.
public abstract class Reader {
protected Object lock;
protected Reader() {
this.lock = this; // 기본: 자기 자신을 락으로 사용
}
protected Reader(Object lock) {
if (lock == null)
throw new NullPointerException();
this.lock = lock; // 외부에서 락 객체 지정 가능
}
}
두 가지 생성자
| 생성자 | lock 값 | 용도 |
| Reader() | this | 단독 사용 시 |
| Reader(Object lock) | 전달받은 객체 | 래핑 시 락 공유 |
3. synchronized 메서드를 안 쓰는 이유
방법 비교
// 방식 1: synchronized 메서드
public synchronized int read() {
// 항상 this 객체로 락
}
// 방식 2: synchronized(lock) 블록
public int read() {
synchronized (lock) {
// lock 필드가 가리키는 객체로 락
}
}
synchronized 메서드의 한계
InputStreamReader isr = new InputStreamReader(System.in);
BufferedReader br = new BufferedReader(isr);
// synchronized 메서드라면?
isr.read(); // isr 객체로 락
br.read(); // br 객체로 락
// → 서로 다른 락! 동기화 안 됨!
문제 시나리오:
Thread A: br.read() 호출 → br 객체로 락 획득
Thread B: isr.read() 호출 → isr 객체로 락 획득 (다른 락이라 통과!)
→ 두 스레드가 동시에 같은 스트림 접근
→ 데이터 꼬임 발생!
4. lock 필드로 해결
BufferedReader 생성자를 보면:
public BufferedReader(Reader in) {
super(in); // ← Reader(Object lock) 호출, lock = in
this.in = in;
cb = new char[defaultCharBufferSize];
// ...
}
super(in)이 핵심입니다. 부모 Reader의 lock을 내부 Reader(in)로 설정합니다.
결과
InputStreamReader isr = new InputStreamReader(System.in);
BufferedReader br = new BufferedReader(isr);
// br.lock = isr
// isr.lock = isr (자기 자신)
br.read(); // synchronized(isr)
isr.read(); // synchronized(isr)
// → 같은 락! 동기화 보장!
5. 시각화로 이해하기
synchronized 메서드 사용 시 (문제 있음)
┌─────────────────────┐ ┌─────────────────────┐
│ BufferedReader │ │ InputStreamReader │
│ │ │ │
│ lock = this (br) │────▶│ lock = this (isr) │
│ │ │ │
│ read() 호출 시 │ │ read() 호출 시 │
│ br로 락 │ │ isr로 락 │
└─────────────────────┘ └─────────────────────┘
↓ ↓
서로 다른 락 → 동기화 실패!
lock 필드 사용 시 (올바름)
┌─────────────────────┐ ┌─────────────────────┐
│ BufferedReader │ │ InputStreamReader │
│ │ │ │
│ lock = isr ─────┼────▶│ lock = this (isr) │
│ │ │ │
│ read() 호출 시 │ │ read() 호출 시 │
│ isr로 락 │ │ isr로 락 │
└─────────────────────┘ └─────────────────────┘
↓ ↓
같은 락 → 동기화 성공!
6. 데코레이터 패턴과의 연관
Java I/O는 데코레이터 패턴으로 설계되어 있습니다.
// 스트림을 겹겹이 감싸기
InputStream is = System.in;
InputStreamReader isr = new InputStreamReader(is);
BufferedReader br = new BufferedReader(isr);
LineNumberReader lnr = new LineNumberReader(br);
┌─────────────────────────────────────────────────────┐
│ LineNumberReader │
│ ┌─────────────────────────────────────────────┐ │
│ │ BufferedReader │ │
│ │ ┌─────────────────────────────────────┐ │ │
│ │ │ InputStreamReader │ │ │
│ │ │ ┌─────────────────────────────┐ │ │ │
│ │ │ │ System.in │ │ │ │
│ │ │ └─────────────────────────────┘ │ │ │
│ │ └─────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
이렇게 여러 겹으로 감쌀 때, 모든 레이어가 같은 락을 공유해야 스레드 안전성이 보장됩니다.
7. 알고리즘 문제에서는?
사실 알고리즘 문제는 싱글 스레드 환경입니다. 동기화가 필요 없어요.
// 알고리즘에서는 이 락이 오버헤드
synchronized (lock) { // 불필요하지만 항상 실행됨
// ...
}
극한의 성능이 필요하다면?
// 동기화 없는 직접 구현
class FastReader {
private final byte[] buffer = new byte[1 << 16];
private int bufferPointer = 0;
private int bytesRead = 0;
private final DataInputStream din;
public FastReader() {
din = new DataInputStream(System.in);
}
private byte read() throws IOException {
if (bufferPointer == bytesRead) {
bytesRead = din.read(buffer, 0, buffer.length);
bufferPointer = 0;
}
return buffer[bufferPointer++];
}
public int nextInt() throws IOException {
int ret = 0;
byte c = read();
while (c <= ' ') c = read();
boolean neg = (c == '-');
if (neg) c = read();
do {
ret = ret * 10 + c - '0';
} while ((c = read()) >= '0' && c <= '9');
return neg ? -ret : ret;
}
}
하지만 99% 상황에서 BufferedReader로 충분합니다. 락 오버헤드는 나노초 단위라 거의 영향이 없어요.
8. synchronized의 비용
락 획득/해제 비용
// JVM의 락 최적화
synchronized (lock) {
// Biased Locking: 경쟁 없으면 거의 비용 없음
// Lightweight Locking: 짧은 경쟁 시 스핀락
// Heavyweight Locking: 심한 경쟁 시 OS 레벨 락
}
싱글 스레드에서는 Biased Locking이 적용되어 오버헤드가 거의 없습니다.
실제 측정
// 100만 번 read() 호출 시
BufferedReader (synchronized 있음): ~50ms
직접 구현 (synchronized 없음): ~45ms
// 차이: 약 5ms (무시할 수준)
9. 왜 protected Object lock인가?
public abstract class Reader {
protected Object lock; // protected!
}
protected인 이유
- 서브클래스에서 접근 가능 - 커스텀 Reader 구현 시 같은 락 사용 가능
- 외부에서 직접 접근 불가 - 캡슐화 유지
- 유연한 설계 - 필요시 락 객체 교체 가능
Object 타입인 이유
// 어떤 객체든 락으로 사용 가능
protected Object lock;
// 만약 특정 타입이었다면?
protected ReentrantLock lock; // 제약이 생김
Java의 모든 객체는 모니터를 가지고 있어서 synchronized의 락으로 사용할 수 있습니다.
10. 실무에서의 활용
커스텀 Reader에서 락 공유
public class MyBufferedReader extends Reader {
private final Reader source;
public MyBufferedReader(Reader source) {
super(source); // source를 락으로 사용
this.source = source;
}
@Override
public int read(char[] cbuf, int off, int len) throws IOException {
synchronized (lock) { // source와 같은 락
return source.read(cbuf, off, len);
}
}
// ...
}
여러 Reader가 같은 소스 공유 시
FileReader fr = new FileReader("data.txt");
BufferedReader br1 = new BufferedReader(fr);
BufferedReader br2 = new BufferedReader(fr);
// br1.lock = fr
// br2.lock = fr
// 둘 다 같은 락 → 스레드 안전
11. 정리
| 질문 | 답변 |
| 왜 synchronized 메서드 안 쓰나? | 래핑된 스트림끼리 락 공유 불가 |
| lock 필드의 역할? | 여러 레이어가 같은 락 공유 가능 |
| 성능 영향? | 싱글 스레드에서 거의 없음 (Biased Locking) |
| 알고리즘에서? | 신경 안 써도 됨 |
핵심 포인트
데코레이터 패턴 + 멀티스레드 안전성
= lock 필드를 통한 락 객체 공유
Java I/O 설계자들이 확장성과 스레드 안전성을 모두 잡기 위해 고민한 흔적이 보이는 부분입니다!
시리즈 마무리
이 시리즈에서 다룬 내용:
- 기초편: BufferedReader 사용법과 성능 이점
- 내부 동작편: 버퍼 구조와 fill() 메서드
- 동기화편: synchronized(lock) 설계 이유
BufferedReader 하나에도 이렇게 많은 설계 고민이 담겨 있습니다. Java 소스 코드를 직접 읽어보는 습관을 들이면, 더 좋은 코드를 작성할 수 있을 거예요!
'Language > Java' 카테고리의 다른 글
| [Java] volatile 키워드 완전 정리 - JMM부터 하드웨어 동작까지 (1) | 2026.03.15 |
|---|---|
| [Java] Java 스레드의 모든 것 — 스레드 모델부터 Virtual Thread까지 (0) | 2026.03.15 |
| [Java][BufferedReader] 내부 동작 원리 - fill()과 버퍼의 비밀 (1) | 2025.12.31 |
| [Java][BufferedReader] 완벽 정리 - 왜 Scanner 대신 쓸까? (0) | 2025.12.31 |
| [Java][Collections Framework 완전 정복] Stack 클래스를 사용하지 않는 이유 및 대안 (0) | 2025.01.16 |