1. 멀티스레드가 도움이 될 때
멀티스레딩은 동시에 수행할 수 있는 작업이 많을 때 필요합니다. 예를 들어 수십 개의 파일을 복사하거나, 재계산하거나, 분석해야 한다면 모든 것을 순차적으로 처리하는 것보다 서로 다른 부분을 서로 다른 스레드에 맡기는 편이 더 쉽습니다. 사진 아카이브를 한 친구가 혼자 정리하는 대신 다섯 명을 한꺼번에 부른 것과 같습니다. 일이 더 빨라지고 덜 지루합니다.
특히 파일 배치 처리, 큰 파일을 조각 내어 다운로드/복사하는 경우, 또는 데이터를 읽은 뒤 서로 다른 부분에 대해 병렬 계산이 필요할 때 유용합니다.
하지만 멀티스레딩이 항상 도움이 되지는 않습니다. 작은 파일 하나를 위해 스레드 열 개를 띄우는 것은 의미가 없습니다. 디스크나 네트워크가 이미 한계까지 사용 중이라면 새 스레드는 오히려 전체를 느리게 만들 뿐입니다. 또한 여러 스레드가 동기화 없이 같은 파일에 동시에 쓰면 데이터가 손상되는 진짜 혼란을 초래할 수 있습니다.
요컨대 멀티스레딩은 도구입니다. 망치처럼 못을 잘 박을 수도 있지만 손가락을 칠 수도 있습니다. 중요한 것은 언제, 어떻게 사용할지 아는 것입니다.
2. 멀티스레드 I/O를 위한 Java 도구
Java에는 작업을 병렬로 실행하는 여러 방법이 있습니다:
- 고전적인 Thread — 스레드를 직접 생성합니다.
- ExecutorService로 구성하는 스레드 풀 — 현대적이고 유연하며 편리한 방식.
- CompletableFuture와 병렬 스트림(Stream API) — 고급 작업에 적합합니다(자세한 내용은 다음 강의에서 다룹니다).
가장 쉬운 것부터 시작해 보겠습니다: 여러 파일을 서로 다른 스레드에서 처리하기.
예제 1: 고전적인 Thread
public class FileCopyTask extends Thread {
private final Path source;
private final Path target;
public FileCopyTask(Path source, Path target) {
this.source = source;
this.target = target;
}
@Override
public void run() {
try {
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
System.out.println("파일이 복사되었습니다: " + source);
} catch (IOException e) {
System.err.println("복사 오류 " + source + ": " + e.getMessage());
}
}
}
// 여러 복사를 개별 스레드에서 실행
List<Path> filesToCopy = List.of(
Path.of("log1.txt"), Path.of("log2.txt"), Path.of("log3.txt")
);
for (Path file : filesToCopy) {
new FileCopyTask(file, Path.of("backup_" + file.getFileName())).start();
}
장점: 간단하고 이해하기 쉽습니다.
단점: 많은 스레드를 수동으로 관리하기 불편하며, 동시에 동작하는 스레드 수를 제어하기 어렵습니다.
예제 2: ExecutorService — 스레드 풀
ExecutorService를 사용하면 작업을 스레드 풀에 위임할 수 있고, 풀은 동시에 사용할 스레드 수를 스스로 결정합니다.
import java.util.concurrent.*;
public class MultiFileCopier {
public static void main(String[] args) throws InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(4); // 최대 4개 스레드
List<Path> filesToCopy = List.of(
Path.of("log1.txt"), Path.of("log2.txt"), Path.of("log3.txt")
);
for (Path file : filesToCopy) {
executor.submit(() -> {
try {
Files.copy(file, Path.of("backup_" + file.getFileName()), StandardCopyOption.REPLACE_EXISTING);
System.out.println("복사됨: " + file);
} catch (IOException e) {
System.err.println("오류: " + file + " " + e.getMessage());
}
});
}
executor.shutdown(); // 더 이상 작업을 받지 않음
executor.awaitTermination(1, TimeUnit.MINUTES); // 모든 작업이 끝날 때까지 대기
}
}
장점:
- 손쉽게 확장 가능(필요한 스레드 수를 지정할 수 있음).
- 작업 종료를 편리하게 제어(메서드 shutdown(), awaitTermination(...)).
- 수백, 수천 개의 파일 처리에 적합.
3. 멀티스레드 I/O의 문제와 제약
리소스 경합
동기화 없이 여러 스레드가 동시에 같은 파일을 읽거나 쓰려고 하면 결과는 예측할 수 없습니다. 마치 두 사람이 같은 책 페이지에 동시에 글을 쓰는 것과 같습니다. synchronized, 명시적 락, 전용 기록 스레드 등을 사용해 조율하세요.
파일 시스템과 OS의 제약
- 모든 파일 시스템이 하나의 파일에 대한 동시 쓰기를 잘 지원하는 것은 아닙니다.
- 운영체제는 동시에 열 수 있는 파일의 개수를 제한할 수 있습니다.
- 하드디스크(특히 HDD)는 무작위 접근이 많아지면 성능이 급격히 떨어집니다.
공유 자원에 기록할 때의 동기화
여러 스레드가 하나의 파일(예: 로그)에 쓴다면 반드시 동기화를 사용하세요(synchronized, 락, 전용 기록 스레드 등).
작은 파일에서는 비효율적
작은 파일에 대해서는 스레드 생성 및 컨텍스트 스위칭 오버헤드가 병렬 처리로 얻는 이득보다 클 수 있습니다.
4. 실전 예제
파일 병렬 복사
아카이브 디렉터리로 복사해야 하는 로그가 가득한 폴더가 있다고 합시다.
import java.nio.file.*;
import java.util.List;
import java.util.concurrent.*;
public class ParallelFileCopier {
public static void main(String[] args) throws InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(4);
List<Path> filesToCopy = List.of(
Path.of("log1.txt"), Path.of("log2.txt"), Path.of("log3.txt")
// ... 원하는 만큼 파일을 추가하세요
);
for (Path file : filesToCopy) {
executor.submit(() -> {
try {
Path target = Path.of("archive", file.getFileName().toString());
Files.copy(file, target, StandardCopyOption.REPLACE_EXISTING);
System.out.println("복사됨: " + file);
} catch (IOException e) {
System.err.println("오류: " + file + " " + e.getMessage());
}
});
}
executor.shutdown();
executor.awaitTermination(10, TimeUnit.MINUTES);
}
}
설명:
- 4개의 스레드로 구성된 풀을 사용합니다 — 일반적으로 디스크를 충분히 활용하면서 시스템을 과부하시키지 않는 수준입니다.
- 1000개의 파일이라면 풀을 8개까지 늘릴 수 있지만, 너무 크게 만들지는 마세요.
Stream API로 파일의 각 줄을 병렬 처리
Java 8부터 파일 내용을 처리할 때 병렬 스트림을 사용할 수 있습니다:
import java.nio.file.*;
import java.io.IOException;
public class ParallelLineProcessing {
public static void main(String[] args) throws IOException {
Path path = Path.of("biglog.txt");
// Files.lines는 Stream<String>을 반환합니다 — 파일의 각 줄에 대한 스트림
Files.lines(path)
.parallel() // 병렬 스트림으로 전환
.filter(line -> line.contains("ERROR"))
.forEach(line -> System.out.println("오류: " + line));
}
}
중요:
- 병렬 스트림은 읽기 자체(I/O-bound)가 아니라 많은 계산(CPU-bound)이 필요한 처리일 때 속도를 높여줍니다.
- 처리가 단순하다면(예: System.out.println으로 단순 출력) 체감 성능 향상이 없을 수 있습니다.
하나의 큰 파일을 여러 조각으로 읽기/쓰기
Java에서는 FileChannel과 포지셔닝 메서드를 사용해 하나의 파일의 서로 다른 구간을 동시에 읽거나 쓸 수 있습니다. 다소 고급 주제이지만 원리는 간단합니다. 각 스레드는 파일의 자기 구간만 다룹니다.
import java.nio.channels.FileChannel;
import java.nio.file.*;
import java.io.*;
import java.nio.ByteBuffer;
public class FileChunkReader implements Runnable {
private final Path path;
private final long position;
private final long size;
public FileChunkReader(Path path, long position, long size) {
this.path = path;
this.position = position;
this.size = size;
}
@Override
public void run() {
try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) {
ByteBuffer buffer = ByteBuffer.allocate((int) size);
channel.read(buffer, position);
System.out.println("위치 " + position + "에서 크기 " + size + "의 청크를 읽었습니다");
// 여기에서 buffer를 처리할 수 있습니다
} catch (IOException e) {
System.err.println("청크 읽기 오류: " + e.getMessage());
}
}
}
// 실행 예: 4개의 스레드로 파일을 1MB 단위로 읽기
Path file = Path.of("bigdata.bin");
long fileSize = Files.size(file);
long chunkSize = 1024 * 1024; // 1MB
int chunks = (int) Math.ceil((double) fileSize / chunkSize);
ExecutorService executor = Executors.newFixedThreadPool(4);
for (int i = 0; i < chunks; i++) {
long position = i * chunkSize;
long size = Math.min(chunkSize, fileSize - position);
executor.submit(new FileChunkReader(file, position, size));
}
executor.shutdown();
executor.awaitTermination(10, TimeUnit.MINUTES);
설명:
- 각 스레드는 자신의 파일 조각만 읽어 서로 간섭하지 않습니다.
- 이 접근 방식은 예를 들어 토렌트나 다운로드 매니저에서 사용됩니다.
공용 파일에 기록할 때의 동기화
여러 스레드가 같은 파일(예: 로그)에 동시에 쓰면 문자열이 “뒤죽박죽”이 될 수 있으므로 반드시 접근을 동기화해야 합니다:
import java.io.*;
public class SafeLogger {
private final Writer writer;
public SafeLogger(String filename) throws IOException {
this.writer = new BufferedWriter(new FileWriter(filename, true));
}
public synchronized void log(String message) throws IOException {
writer.write(message);
writer.write(System.lineSeparator());
writer.flush();
}
public void close() throws IOException {
writer.close();
}
}
설명:
- log 메서드는 synchronized로 표시되어, 현재 시점에 오직 하나의 스레드만 파일에 쓸 수 있습니다.
- 이 방법은 동작하지만 스레드가 많아지면 병목이 될 수 있습니다 — 서로 다른 파일에 쓰고 나중에 병합하는 것이 더 낫습니다.
5. 멀티스레드를 쓰지 말아야 할 때
멀티스레드는 유혹적입니다. 스레드가 많으면 뭐든 빨라질 것 같죠! 하지만 실제로는 항상 그렇지 않습니다. 작은 파일 몇 개를 처리한다면 순차적으로 하는 것이 더 간단하고 신뢰할 수 있습니다. 스레드를 시작하고 조율하는 데 드는 시간이 오히려 손해일 수 있습니다.
문제가 아예 디스크가 아니라 네트워크일 때도 있습니다 — 이 경우 병목(bottleneck)은 다른 곳에 있으므로 스레드를 늘려도 빨라지지 않습니다. 또 다른 함정은 같은 파일에 병렬로 쓰는 것입니다. 동기화 경험이 적다면 시도하지 않는 편이 낫습니다. 데이터가 손상될 가능성이 큽니다.
마지막으로, 디스크나 파일 시스템이 수십 개의 스레드가 동시에 접근하는 것을 싫어한다면 멀티스레딩은 도움이 되지 않고 상황을 더 악화시킬 뿐입니다.
요컨대 “스레드가 많을수록 좋다”는 생각은 대부분 틀립니다. 때로는 하나의 차분한 스레드가 서둘러 움직이는 열 개의 스레드보다 더 깔끔하고 빠르며 신뢰성 있게 일을 끝냅니다.
6. 고급 작업을 위한 FileChannel 간단 소개
패키지 java.nio.channels의 FileChannel은 임의 위치에서 데이터를 읽고 쓸 수 있는 저수준 파일 작업 도구입니다. 이를 사용하면 큰 파일을 청크 단위로 병렬 다운로드하거나 처리하는 것을 구현할 수 있습니다.
예:
try (FileChannel channel = FileChannel.open(Path.of("bigfile.bin"), StandardOpenOption.READ)) {
ByteBuffer buffer = ByteBuffer.allocate(1024);
long position = 0;
int bytesRead = channel.read(buffer, position); // 위치 0에서 1024바이트를 읽음
// buffer 처리
}
중요:
- FileChannel은 동기화되어 있지 않습니다 — 여러 스레드가 하나의 채널로 작업한다면 동기화를 직접 구현해야 합니다.
- 병렬 작업에는 스레드마다 별도의 채널을 여는 편이 더 간단합니다.
7. 멀티스레드 I/O에서 흔한 실수
오류 №1: 하나의 파일에 대한 비동기화된 쓰기 접근.
결과적으로 데이터가 손상되고, 이상한 문자가 섞이며, 파일을 읽지 못하는 경우도 생깁니다. 항상 접근을 동기화하거나 서로 다른 파일에 쓰세요.
오류 №2: 스레드가 지나치게 많음.
1000개의 파일을 복사한다고 해서 1000개의 스레드를 열면 시스템이 버틸 수 없습니다(OutOfMemoryError, 심각한 지연, 크래시). 스레드 풀(ExecutorService)을 사용하고 개수를 제한하세요.
오류 №3: 스레드/파일을 닫지 않음.
열린 스레드나 파일 핸들은 OS 자원입니다. 닫지 않으면 "Too many open files" 오류가 발생할 수 있습니다. try-with-resources를 사용하거나 close() 호출을 잊지 마세요.
오류 №4: 프로그램이 너무 일찍 종료됨.
모든 스레드의 종료를 기다리지 않으면(예: executor.awaitTermination(...)을 호출하지 않음) 모든 파일이 복사되기 전에 프로그램이 끝날 수 있습니다.
오류 №5: 위치를 고려하지 않은 동일 구간에 대한 병렬 쓰기.
여러 스레드가 파일의 같은 영역에 쓰면 데이터가 뒤섞입니다. 위치별 쓰기에는 채널을 사용하고 범위를 명확히 분할하세요.
GO TO FULL VERSION