본문 바로가기

Language/Java

[Java][BufferedReader] 동기화 메커니즘 - 데코레이터 패턴의 적용

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인 이유

  1. 서브클래스에서 접근 가능 - 커스텀 Reader 구현 시 같은 락 사용 가능
  2. 외부에서 직접 접근 불가 - 캡슐화 유지
  3. 유연한 설계 - 필요시 락 객체 교체 가능

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 설계자들이 확장성과 스레드 안전성을 모두 잡기 위해 고민한 흔적이 보이는 부분입니다!


시리즈 마무리

이 시리즈에서 다룬 내용:

  1. 기초편: BufferedReader 사용법과 성능 이점
  2. 내부 동작편: 버퍼 구조와 fill() 메서드
  3. 동기화편: synchronized(lock) 설계 이유

BufferedReader 하나에도 이렇게 많은 설계 고민이 담겨 있습니다. Java 소스 코드를 직접 읽어보는 습관을 들이면, 더 좋은 코드를 작성할 수 있을 거예요!