1. 파일을 부분적으로 읽기: ByteBuffer와 인코딩
오늘날 우리는 작은 텍스트 파일을 다룰 일이 드뭅니다. 보통은 거대한 서버 로그, 보고서, CSV 파일, 혹은 기가바이트 단위의 데이터 덤프를 다루게 됩니다. 따라서 단순히 파일을 읽는 것 이상으로, 애플리케이션이 멈추지 않도록 효율적으로 읽는 것이 중요합니다.
비동기 방식은 바로 이 점에서 도움이 됩니다. 메인 스레드(UI나 서버 로직)를 블로킹하지 않고, 큰 데이터를 병렬로 읽고 쓸 수 있게 하며, 여러 파일을 동시에 처리해야 할 때 애플리케이션의 확장성을 높여 줍니다.
중요한 점은, 비동기 입출력이 디스크 자체를 빠르게 만들지는 않는다는 것입니다. 단지 디스크가 작업을 수행하는 동안 프로그램이 기다리며 한가하게 있지 않고, 그 시간에 다른 일을 할 수 있도록 해 줄 뿐입니다.
비동기 읽기는 어떻게 동작할까?
비동기 채널(AsynchronousFileChannel)은 문자열이 아니라 바이트 블록을 ByteBuffer로 읽습니다. 마치 단어가 아니라 글자 상자를 통째로 옮기는 것과 같습니다. 읽은 뒤에는 인코딩을 고려하여 이 바이트들을 문자열로 변환해야 합니다.
예제: 파일을 블록 단위로 비동기 읽기
4096바이트씩 파일을 비동기로 읽어 콘솔에 출력하는 가장 간단한 예제를 작성해 보겠습니다.
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousFileChannel;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.concurrent.Future;
import java.nio.charset.StandardCharsets;
import java.io.IOException;
public class AsyncTextReadExample {
public static void main(String[] args) throws Exception {
Path path = Path.of("bigfile.txt");
try (AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, StandardOpenOption.READ)) {
ByteBuffer buffer = ByteBuffer.allocate(4096);
int position = 0;
Future<Integer> future = channel.read(buffer, position);
while (future.get() > 0) {
buffer.flip();
// 바이트를 문자열로 변환 (UTF-8)
String chunk = StandardCharsets.UTF_8.decode(buffer).toString();
System.out.print(chunk);
buffer.clear();
position += chunk.getBytes(StandardCharsets.UTF_8).length;
future = channel.read(buffer, position);
}
}
}
}
중요한 포인트:
- 파일을 전체가 아니라 블록(버퍼) 단위로 읽습니다.
- 읽은 바이트는 Charset을 사용해 문자열로 디코딩합니다.
- buffer.clear()를 잊지 마세요 — 그렇지 않으면 다음 read가 동작하지 않습니다!
왜 단순히 바이트를 디코딩하는 것만으로는 충분하지 않을까?
문자열이 두 블록 사이에서 잘릴 수 있기 때문입니다. 특히 다바이트 인코딩(예: "UTF-8")을 사용할 때 마지막 바이트가 한 글자의 일부만 담고 있으면, 다음 블록은 그 글자의 나머지로 시작하게 됩니다. 별도 처리가 없으면 “깨진 문자”가 생기거나 디코딩 오류가 날 수 있습니다.
2. 바이트를 문자열로 변환하기: 경계 끊김 처리
문자열 경계 끊김 문제
예를 들어, 문자열 "안녕\n세계\n"이 있는데 버퍼가 "안"에서 끝나고, "녕\n세계\n"이 다음 블록에 담겼다고 해 봅시다. 단순히 문자열을 이어 붙이면 문자가 유실되거나 잘못된 문자열이 될 수 있습니다.
해결: CharsetDecoder 사용
Java는 이런 경우를 올바르게 처리할 수 있는 CharsetDecoder 클래스를 제공합니다. 이 디코더는 디코딩되지 않은 바이트를 “기억”해 두었다가 블록 경계에서 문자를 정확히 복원합니다.
CharsetDecoder 사용 예
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
import java.nio.CharBuffer;
import java.nio.ByteBuffer;
CharsetDecoder decoder = Charset.forName("UTF-8").newDecoder();
ByteBuffer buffer = ... // 사용할 바이트
CharBuffer charBuffer = CharBuffer.allocate(buffer.capacity());
decoder.decode(buffer, charBuffer, false);
// 이제 charBuffer에는 올바르게 디코딩된 문자가 들어 있습니다
실제 과제에서는 읽기 사이에 “남은 바이트”를 보관해 두고 이를 고려해 디코딩해야 합니다.
3. 텍스트 파일 비동기 쓰기
읽기만으로 끝나지 않습니다. 쓰기도 문자열을 먼저 바이트로(인코딩) 변환한 후, 바이트 블록 단위로 수행합니다.
예제: 문자열을 파일에 비동기로 쓰기
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousFileChannel;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.concurrent.Future;
import java.nio.charset.StandardCharsets;
public class AsyncTextWriteExample {
public static void main(String[] args) throws Exception {
Path path = Path.of("output.txt");
String text = "안녕하세요, 세계!\n";
ByteBuffer buffer = ByteBuffer.wrap(text.getBytes(StandardCharsets.UTF_8));
try (AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, StandardOpenOption.WRITE, StandardOpenOption.CREATE)) {
Future<Integer> future = channel.write(buffer, 0);
// 데모를 위해 완료를 기다립니다(보통은 필요 없음!)
future.get();
System.out.println("데이터가 비동기적으로 기록되었습니다.");
}
}
}
설명: 실제 비동기 시나리오에서는 메인 스레드에서 future.get()을 호출하지 않는 것이 좋습니다. 이렇게 하면 비동기 코드가 동기 코드로 바뀌기 때문입니다. 대신 CompletionHandler를 사용하세요(이전 강의 참고).
4. 실습: 큰 텍스트 파일을 비동기로 읽고 줄 수 세기
실습으로, 큰 텍스트 파일을 비동기로 읽으면서 줄 구분자("\n")의 개수를 세고, 결과를 콘솔에 출력해 보겠습니다.
CompletionHandler를 사용하는 예제
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousFileChannel;
import java.nio.channels.CompletionHandler;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.StandardCharsets;
import java.nio.CharBuffer;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicLong;
public class AsyncLineCounter {
public static void main(String[] args) throws IOException {
Path path = Path.of("bigfile.txt");
AsynchronousFileChannel channel = AsynchronousFileChannel.open(path, StandardOpenOption.READ);
ByteBuffer buffer = ByteBuffer.allocate(4096);
AtomicLong position = new AtomicLong(0);
CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder();
StringBuilder leftover = new StringBuilder();
AtomicLong lines = new AtomicLong(0);
channel.read(buffer, position.get(), null, new CompletionHandler<Integer, Object>() {
@Override
public void completed(Integer result, Object attachment) {
if (result == -1) {
// 파일을 끝까지 읽었습니다
if (leftover.length() > 0) lines.incrementAndGet();
System.out.println("파일의 줄 수: " + lines.get());
try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
return;
}
buffer.flip();
CharBuffer charBuffer = CharBuffer.allocate(buffer.remaining());
decoder.decode(buffer, charBuffer, false);
charBuffer.flip();
String chunk = leftover.toString() + charBuffer.toString();
leftover.setLength(0);
// 줄 수를 셉니다
int last = 0;
int idx;
while ((idx = chunk.indexOf('\n', last)) != -1) {
lines.incrementAndGet();
last = idx + 1;
}
// 남은 부분(마지막 \n 이후의 문자열 조각)
if (last < chunk.length()) {
leftover.append(chunk.substring(last));
}
buffer.clear();
position.addAndGet(result);
channel.read(buffer, position.get(), null, this);
}
@Override
public void failed(Throwable exc, Object attachment) {
System.err.println("읽기 오류: " + exc.getMessage());
try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
}
});
// 프로그램이 너무 일찍 종료되지 않도록(예시용!)
try { Thread.sleep(2000); } catch (InterruptedException e) {}
}
}
- CompletionHandler를 사용해 진정한 비동기 코드를 작성합니다.
- 각 읽기 이후 버퍼를 CharsetDecoder로 디코딩합니다.
- "\n"으로 끝나지 않은 줄의 “남은 부분”은 다음 블록으로 넘겨서 이어 처리합니다.
- 파일 끝에 도달한 뒤 leftover에 무언가 남아 있다면 그것도 한 줄로 계산합니다.
- 간단히 보여 주기 위해 예제에서는 2000ms 동안 “잠들게” 했습니다. 실제 애플리케이션에서는 보통 메인 루프나 UI가 있어 이런 처리가 필요 없습니다.
5. 결과를 파일에 비동기로 기록하기
예를 들어, 결과(줄 수 등)를 새 파일에 비동기로 기록하고 싶다고 가정해 봅시다.
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousFileChannel;
import java.nio.channels.CompletionHandler;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.nio.charset.StandardCharsets;
import java.io.IOException;
public class AsyncWriteResult {
public static void main(String[] args) throws IOException {
String result = "파일의 줄 수: 12345\n";
ByteBuffer buffer = ByteBuffer.wrap(result.getBytes(StandardCharsets.UTF_8));
Path path = Path.of("result.txt");
AsynchronousFileChannel channel = AsynchronousFileChannel.open(
path, StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
channel.write(buffer, 0, null, new CompletionHandler<Integer, Object>() {
@Override
public void completed(Integer written, Object attachment) {
System.out.println("결과가 비동기적으로 기록되었습니다!");
try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
}
@Override
public void failed(Throwable exc, Object attachment) {
System.err.println("쓰기 오류: " + exc.getMessage());
try { channel.close(); } catch (IOException e) { e.printStackTrace(); }
}
});
try { Thread.sleep(500); } catch (InterruptedException e) {}
}
}
6. 부분 데이터와 인코딩 처리 팁
블록 사이의 부분 문자열
문자열이 두 블록 사이에서 분리되었을 때 바이트를 수동으로 “붙이려” 하지 마세요. CharsetDecoder를 사용하면 부족한 바이트를 안전하게 처리하고 문자를 잃지 않습니다.
여러 인코딩 다루기
"UTF-8"은 현대 애플리케이션의 표준입니다. 하지만 파일이 다른 인코딩(예: "Windows-1251" 또는 "UTF-16")이라면, 해당 Charset을 사용해야 합니다:
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
Charset charset = Charset.forName("Windows-1251");
CharsetDecoder decoder = charset.newDecoder();
CharsetDecoder와 CharsetEncoder 사용
데이터를 부분적으로 읽거나 쓸 때는 인코딩을 정확히 다루는 것이 중요합니다. 문자가 두 블록 사이에서 나뉠 수 있으며, 추가 처리가 없으면 바이트가 뒤섞여 잘못된 결과가 나올 수 있습니다.
이를 방지하기 위해 CharsetDecoder와 CharsetEncoder를 사용합니다.
읽기에서는 decode(ByteBuffer, CharBuffer, endOfInput)를, 쓰기에서는 encode(CharBuffer, ByteBuffer, endOfInput)를 호출합니다.
이 API들이 문자 경계가 블록 사이에서 나뉘어도 제대로 조립하고 처리되도록 보장합니다.
7. 텍스트 파일 비동기 처리 시 흔한 실수
오류 1: 남은 문자열을 무시함. 블록 사이의 “꼬리” 문자열을 저장하지 않으면 일부 줄이 유실되거나 잘못 디코딩될 수 있습니다.
오류 2: 버퍼를 잘못 다룸. 처리 후 buffer.clear() 호출을 잊으면 다음 read가 작동하지 않거나 데이터가 잘못될 수 있습니다.
오류 3: 부적절한 인코딩 사용. 파일을 기록할 때 사용한 Charset과 다른 인코딩으로 디코딩하면 “깨진 문자”가 생기거나 오류가 발생할 수 있습니다.
오류 4: 메인 스레드를 블로킹함. UI 스레드에서 future.get()이나 Thread.sleep()을 호출하면 비동기의 의미가 사라집니다. CompletionHandler나 리액티브 접근을 사용하세요.
오류 5: 작업 완료 후 채널을 닫지 않음. 모든 작업이 끝난 뒤(오류가 있어도) 항상 채널(channel.close())을 닫으세요.
GO TO FULL VERSION