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 자체를 수행하는 것이 아니라는 점을 기억하세요.
GO TO FULL VERSION