CodeGym /행동 /JAVA 25 SELF /비동기 파일 작업 시 오류 분석

비동기 파일 작업 시 오류 분석

JAVA 25 SELF
레벨 56, 레슨 4
사용 가능

1. 버퍼 관련 실수: ByteBuffer, position과 limit

비동기 읽기/쓰기 메서드는 ByteBuffer 객체로 동작합니다. 일반 배열과 달리 버퍼에는 “내부 커서” — 즉 position과 limit이 있어 어떤 바이트를 읽고 쓸지를 결정합니다. 이 속성들을 잘못 관리하면 기대한 결과를 얻지 못하거나, 아예 로직이 깨질 수 있습니다.

코드에서는 어떻게 보일까요?

잘못된 사용 예:

ByteBuffer buffer = ByteBuffer.allocate(1024);
channel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() {
    @Override
    public void completed(Integer result, ByteBuffer buf) {
        // 앗! 버퍼에서 바로 문자열을 읽으려 하고 있습니다:
        String str = new String(buf.array()); // 잘못된 방식입니다!
        // ...
    }
    // ...
});

무엇이 문제일까요?

  • 읽기 후 버퍼는 “쓰기” 모드에 있습니다. position은 읽힌 데이터의 끝을 가리키고, limit은 버퍼의 크기를 가리킵니다. 이 상태에서 바로 읽으면 실제로는 10바이트만 읽혔더라도 1024바이트 전체(쓰레기 포함)를 읽게 됩니다.
  • buf.array()는 읽힌 부분만이 아니라 내부 배열 전체를 반환합니다. 게다가 direct 버퍼(ByteBuffer.allocateDirect로 할당)의 경우 array()는 UnsupportedOperationException을 던집니다.

올바른 방법

버퍼에서 데이터를 읽기 전에 buffer.flip()을 호출해 버퍼를 “읽기” 모드로 전환해야 합니다:

public void completed(Integer result, ByteBuffer buf) {
    buf.flip(); // 이제 position = 0, limit = 읽힌 바이트 수
    String str = StandardCharsets.UTF_8.decode(buf).toString();
    // ... 문자열 처리
}

버퍼 재사용

다음 작업에서도 같은 버퍼를 재사용하려면, 데이터를 처리한 뒤 buffer.clear() 또는 buffer.compact()을 호출하는 것을 잊지 마세요:

  • clear() — 경계를 완전히 “초기화”합니다: position=0, limit=capacity(). 기존 데이터는 쓰레기로 간주됩니다.
  • compact() — 아직 읽지 않은 바이트를 버퍼 앞쪽으로 옮겨 보존하고, 이어서 쓸 수 있도록 준비합니다.

주의: result가 -1이면 파일의 끝에 도달한 것이므로 추가 처리는 없습니다.

2. 병렬 접근: 레이스 컨디션과 비일관성

AsynchronousFileChannel은 여러 작업을 병렬로 시작할 수 있습니다. 하지만 이를 제대로 제어하지 않으면 데이터가 “깨지거나” 프로그램이 중단될 수 있습니다.

문제 1: 하나의 버퍼에 대한 동시 읽기

// 동일한 버퍼에 대해 두 번의 동시 읽기
channel.read(buffer, 0, buffer, handler1);
channel.read(buffer, 1024, buffer, handler2);

두 읽기 작업이 같은 버퍼에 기록합니다! 둘이 거의 동시에 끝나면 버퍼의 내용은 예측 불가능해집니다.

문제 2: 하나의 파일에 대한 동시 쓰기

두 스레드가 동일한 파일 영역에 동시에 기록하면, 결과는 어느 작업이 먼저 완료되었는지에 따라 달라집니다. 이것은 고전적인 레이스 컨디션(race condition)으로, 데이터 손상을 야기할 수 있습니다.

예방 방법

  • 각 비동기 작업마다 별도의 버퍼(ByteBuffer)를 사용하여 스레드가 서로의 메모리를 침범하지 않도록 하세요.
  • 같은 파일 범위에 대한 병렬 쓰기를 시작하지 마세요. 오프셋을 분리하거나 접근을 동기화하세요.
  • 엄격한 순서가 중요하다면, 이전 작업이 끝난 뒤에만 다음 작업을 시작하세요 — 예를 들어 CompletionHandler의 completed(...)에서 시작합니다.

3. 리소스 누수: 채널을 닫지 않음

비동기 채널은 시스템 리소스입니다. 이를 닫지 않으면(channel.close()) 파일이 시스템에서 “점유” 상태로 남고, 메모리 누수가 발생할 수 있으며, Windows에서는 다른 프로그램에서 파일이 잠길 수도 있습니다.

전형적인 실수:

AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, ...);
// ... 작업을 시작함
// 모든 작업이 끝난 뒤 channel.close() 호출을 잊음!

올바른 방법

try-with-resources를 사용하고, 블록을 빠져나가기 전에 모든 작업이 완료될 때까지 반드시 기다리세요:

CountDownLatch latch = new CountDownLatch(1);

try (AsynchronousFileChannel channel =
         AsynchronousFileChannel.open(path, StandardOpenOption.READ)) {

    ByteBuffer buf = ByteBuffer.allocate(4096);
    channel.read(buf, 0, buf, new CompletionHandler<Integer, ByteBuffer>() {
        @Override public void completed(Integer r, ByteBuffer b) {
            // 처리...
            latch.countDown();
        }
        @Override public void failed(Throwable ex, ByteBuffer b) {
            ex.printStackTrace();
            latch.countDown();
        }
    });

    latch.await(); // 비동기 작업 완료를 기다림
}
// 닫기는 자동으로 처리됩니다

4. 예외 처리: CompletionHandler에서 오류를 무시함

비동기 코드에서 오류는 메인 스레드로 “튀어나오지” 않고 CompletionHandler의 failed(...) 메서드로 전달됩니다. 이 메서드를 구현하지 않거나 비워 두면, 오류가 그냥 사라지고 프로그램이 이상하게 동작할 수 있습니다.

“보이지 않는” 오류의 예:

channel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() {
    @Override
    public void completed(Integer result, ByteBuffer buf) {
        // ... 결과 처리
    }
    @Override
    public void failed(Throwable exc, ByteBuffer buf) {
        // 어라, 비어 있습니다! 오류가 사라졌습니다
    }
});

올바른 방법

@Override
public void failed(Throwable exc, ByteBuffer buf) {
    System.err.println("파일 읽기 중 오류: " + exc.getMessage());
    exc.printStackTrace();
    // 필요하다면: 채널 닫기, 메트릭 갱신, 사용자 알림 등
}

5. Future/CompletionHandler에 대한 참조 분실

Future를 통해 비동기 작업을 시작했는데 참조를 저장하지 않으면, 작업을 취소하거나 완료를 기다릴 수 없습니다. 마찬가지로 CompletionHandler를 사용할 때 모든 작업의 완료를 동기화하지 않으면 프로그램이 너무 일찍 종료될 수 있습니다.

예:

channel.read(buffer, 0, buffer, handler); // handler: 익명이며 어디에도 저장하지 않음
// 읽기 완료를 기다리지 않고 곧바로 프로그램이 종료됨

올바른 방법

  • Future<Integer>를 사용할 때: 참조를 저장하고 필요 시 future.get() 또는 future.cancel(true)을 사용하세요.
  • CompletionHandler를 사용할 때: CountDownLatch, Semaphore 등의 동기화 수단을 사용하여, 채널/프로그램을 닫기 전에 모든 작업이 완료될 때까지 올바르게 대기하세요.

6. 인코딩 관련 오류

텍스트 파일을 읽고 쓸 때는 인코딩을 올바르게 다뤄야 합니다. 바이트를 문자열로 “그냥” 읽으면 문자가 깨지거나, 파일을 조각내어 읽을 때 일부 데이터가 손실될 수 있습니다.

문제:

// 파일을 1024바이트씩 읽은 뒤 문자열로 변환
String chunk = new String(buffer.array(), "UTF-8");

문자 하나가 두 버퍼 사이에 나뉘는 경우(예: UTF-8 문자의 한 바이트가 한 버퍼 끝에, 나머지가 다음 버퍼 시작에 있는 경우) 잘못된 문자나 디코딩 오류가 발생합니다.

올바른 방법 CharsetDecoder를 사용하고, 미완성 문자의 “잔여분”을 읽기 사이에 보존하세요:

CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder()
        .onMalformedInput(CodingErrorAction.REPORT)
        .onUnmappableCharacter(CodingErrorAction.REPORT);

ByteBuffer byteBuf = ByteBuffer.allocate(4096);
CharBuffer charBuf = CharBuffer.allocate(4096);

// 각 completed(... ) 호출 시:
byteBuf.flip();
CoderResult cr = decoder.decode(byteBuf, charBuf, false); // false — 입력의 끝이 아님
if (cr.isError()) {
    cr.throwException();
}
byteBuf.compact();
charBuf.flip();
String text = charBuf.toString();
charBuf.clear();

// 더 이상 입력이 없을 때:
decoder.flush(charBuf);

7. 프로그램의 조기 종료

비동기 작업은 다른 스레드에서 실행됩니다. 메인 스레드가 모든 작업이 끝나기 전에 종료되면, 프로그램이 결과를 기다리지 않고 종료됩니다.

예:

// 비동기 읽기 시작
channel.read(buffer, 0, buffer, handler);
// 메인 스레드가 곧바로 종료됨 — 프로그램이 닫혀 작업이 완료되지 않음

올바른 방법

CountDownLatch, Semaphore 또는 데모 목적이라면 Thread.sleep(...)이라도 사용해 모든 작업이 끝날 때까지 기다리세요:

CountDownLatch latch = new CountDownLatch(1);
channel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() {
    @Override public void completed(Integer result, ByteBuffer buf) { /* ... */ latch.countDown(); }
    @Override public void failed(Throwable exc, ByteBuffer buf) { /* ... */ latch.countDown(); }
});
latch.await(); // 작업 완료를 기다림

8. ExecutorService와의 좋지 않은 통합

AsynchronousFileChannel은 이벤트 처리를 위한 자체 ExecutorService를 지정할 수 있습니다. 스레드 수가 너무 적거나 하나뿐이면 모든 작업이 순차적으로 실행되고, 너무 크면 컨텍스트 스위칭 오버헤드가 증가합니다.

예:

ExecutorService executor = Executors.newSingleThreadExecutor();
AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, options, executor);
// 모든 비동기 작업이 사실상 동기적으로 동작합니다!

올바른 방법

  • 실제 부하와 동시에 진행되는 작업 수에 맞춰 풀 크기를 조정하세요.
  • 대부분의 작업에는 ForkJoinPool.commonPool()이나 Executors.newCachedThreadPool()이 적합합니다.
  • 전달한 ExecutorService는 콜백(completed/failed) 호출을 실행할 뿐, 디스크 I/O 자체를 수행하는 것이 아니라는 점을 기억하세요.
1
과제
JAVA 25 SELF, 레벨 56, 레슨 4
잠금
즉시 메시지 전송: 리소스가 안전하게 해제되는 비동기 기록 ✉️
즉시 메시지 전송: 리소스가 안전하게 해제되는 비동기 기록 ✉️
1
과제
JAVA 25 SELF, 레벨 56, 레슨 4
잠금
시스템 시작 동기화: 구성 로드 대기 ⚙️
시스템 시작 동기화: 구성 로드 대기 ⚙️
1
설문조사/퀴즈
파일 비동기 작업, 레벨 56, 레슨 4
사용 불가능
파일 비동기 작업
파일 비동기 작업
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION