자바 입출력 (Java I/O) - 심화 가이드
자바 I/O 완전 가이드
1. I/O 기본 개념과 스트림 아키텍처
Java I/O는 데이터의 입력과 출력을 처리하는 핵심 API입니다. 스트림 기반의 동기적 I/O 모델을 기반으로 하며, 데이터의 연속적인 흐름을 추상화합니다.
스트림의 특징:
- 단방향성: 입력 스트림과 출력 스트림이 분리
- 순차적 접근: 데이터를 순서대로 읽고 쓰기
- 블로킹: I/O 작업이 완료될 때까지 스레드 대기
2. 스트림 계층구조와 주요 클래스
바이트 스트림 (Binary Data)
// 추상 클래스
InputStream / OutputStream
// 구현 클래스
FileInputStream / FileOutputStream
BufferedInputStream / BufferedOutputStream
ByteArrayInputStream / ByteArrayOutputStream
ObjectInputStream / ObjectOutputStream
문자 스트림 (Text Data)
// 추상 클래스
Reader / Writer
// 구현 클래스
FileReader / FileWriter
BufferedReader / BufferedWriter
CharArrayReader / CharArrayWriter
StringReader / StringWriter
3. 실무 레벨 I/O 패턴과 성능 최적화
버퍼링 전략
// 성능 차이 비교: 기본 vs 버퍼링
// 기본 방식 (느림)
try (FileInputStream fis = new FileInputStream("large-file.txt")) {
int data;
while ((data = fis.read()) != -1) {
// 한 바이트씩 시스템 콜 발생
}
}
// 버퍼링 방식 (빠름)
try (BufferedInputStream bis = new BufferedInputStream(
new FileInputStream("large-file.txt"), 8192)) {
int data;
while ((data = bis.read()) != -1) {
// 내부 버퍼에서 읽기, 시스템 콜 최소화
}
}
메모리 효율적인 대용량 파일 처리
public void processLargeFile(String filePath) throws IOException {
try (BufferedReader reader = Files.newBufferedReader(
Paths.get(filePath), StandardCharsets.UTF_8)) {
String line;
while ((line = reader.readLine()) != null) {
// 한 줄씩 처리하여 메모리 사용량 최소화
processLine(line);
}
}
}
4. NIO (New I/O) vs Traditional I/O
Traditional I/O의 한계
- 블로킹: 스레드가 I/O 완료까지 대기
- 스트림 기반: 바이트 단위의 순차적 처리
- 단방향: 입력과 출력 스트림 분리
NIO의 장점
// 채널 기반의 양방향 통신
try (FileChannel channel = FileChannel.open(
Paths.get("data.txt"), StandardOpenOption.READ)) {
ByteBuffer buffer = ByteBuffer.allocate(1024);
while (channel.read(buffer) > 0) {
buffer.flip(); // 읽기 모드로 전환
// 버퍼 데이터 처리
buffer.clear(); // 다음 읽기 준비
}
}
// 논블로킹 I/O와 셀렉터
Selector selector = Selector.open();
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.configureBlocking(false);
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
selector.select(); // 준비된 채널만 처리
Set<SelectionKey> readyKeys = selector.selectedKeys();
// 이벤트 기반 처리
}
5. NIO.2 (Java 7+) - Files API
// 현대적인 파일 조작
Path source = Paths.get("source.txt");
Path target = Paths.get("target.txt");
// 원자적 파일 복사
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
// 스트림 API와 결합
try (Stream<String> lines = Files.lines(Paths.get("data.txt"))) {
lines.filter(line -> line.contains("error"))
.forEach(System.out::println);
}
// 파일 속성 처리
BasicFileAttributes attrs = Files.readAttributes(source, BasicFileAttributes.class);
System.out.println("Size: " + attrs.size());
System.out.println("Created: " + attrs.creationTime());
6. 직렬화(Serialization) 심화
커스텀 직렬화
public class Employee implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private transient String password; // 직렬화 제외
private int salary;
// 커스텀 직렬화 로직
private void writeObject(ObjectOutputStream oos) throws IOException {
oos.defaultWriteObject();
oos.writeObject(encrypt(password)); // 암호화된 패스워드 저장
}
private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
ois.defaultReadObject();
password = decrypt((String) ois.readObject()); // 복호화
}
}
직렬화 보안 고려사항
// 신뢰할 수 있는 클래스만 역직렬화
ObjectInputStream ois = new ObjectInputStream(inputStream) {
@Override
protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException {
if (!isWhitelisted(desc.getName())) {
throw new InvalidClassException("Unauthorized deserialization attempt", desc.getName());
}
return super.resolveClass(desc);
}
};
7. 성능 벤치마킹과 모니터링
I/O 성능 측정
public class IOPerformanceTest {
public void compareIOPerformance() {
long start, end;
// 전통적 I/O
start = System.nanoTime();
copyWithTraditionalIO("source.txt", "dest1.txt");
end = System.nanoTime();
System.out.println("Traditional I/O: " + (end - start) / 1_000_000 + "ms");
// NIO
start = System.nanoTime();
copyWithNIO("source.txt", "dest2.txt");
end = System.nanoTime();
System.out.println("NIO: " + (end - start) / 1_000_000 + "ms");
// NIO.2
start = System.nanoTime();
copyWithNIO2("source.txt", "dest3.txt");
end = System.nanoTime();
System.out.println("NIO.2: " + (end - start) / 1_000_000 + "ms");
}
}
8. 면접 대비 핵심 질문들
Q1: InputStream과 Reader의 차이점은?
- InputStream: 바이트 스트림, 모든 종류의 데이터 처리 가능
- Reader: 문자 스트림, 문자 데이터만 처리, 인코딩 처리 내장
Q2: NIO가 기존 I/O보다 빠른 이유는?
- 채널과 버퍼 기반으로 운영체제의 네이티브 I/O 활용
- 논블로킹 모드 지원으로 스레드 효율성 향상
- 메모리 맵드 파일을 통한 직접 메모리 접근
Q3: 대용량 파일을 메모리 효율적으로 처리하는 방법은?
- 스트림을 이용한 청크 단위 처리
- NIO의 MappedByteBuffer 활용
- Files.lines()를 이용한 지연 로딩
Q4: 직렬화의 보안 문제점과 해결 방안은?
- 역직렬화 공격 위험 (신뢰하지 않는 데이터)
- 화이트리스트 기반 클래스 검증 구현
- SerialVersionUID 관리로 버전 호환성 제어
9. 실제 프로덕션 환경 고려사항
리소스 관리
// Try-with-resources로 자동 리소스 해제
public String readFileContent(String filePath) throws IOException {
try (BufferedReader reader = Files.newBufferedReader(Paths.get(filePath))) {
return reader.lines()
.collect(Collectors.joining("\n"));
}
// 자동으로 리소스 해제됨
}
예외 처리 전략
public void robustFileProcessing(String filePath) {
try {
processFile(filePath);
} catch (FileNotFoundException e) {
logger.error("파일을 찾을 수 없습니다: " + filePath, e);
// 대체 로직 또는 기본값 처리
} catch (IOException e) {
logger.error("파일 처리 중 I/O 오류 발생", e);
// 재시도 로직 또는 페일오버
} catch (SecurityException e) {
logger.error("파일 접근 권한 없음: " + filePath, e);
// 권한 요청 또는 에러 응답
}
}
10. 최신 Java 버전의 I/O 개선사항
Java 11+의 Files API 확장
// Files.readString() / writeString()
String content = Files.readString(Paths.get("config.txt"));
Files.writeString(Paths.get("output.txt"), "Hello World");
// Files.mismatch() - 파일 비교
long mismatchPos = Files.mismatch(path1, path2);
Virtual Threads (Project Loom) 와 I/O
// Java 21+에서 Virtual Thread를 이용한 대량 I/O 처리
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = new ArrayList<>();
for (String file : fileList) {
futures.add(executor.submit(() -> {
return Files.readString(Paths.get(file)); // 블로킹 I/O도 효율적
}));
}
// 결과 수집
List<String> results = futures.stream()
.map(f -> f.get())
.collect(Collectors.toList());
}
11. 웹 개발자를 위한 실무 I/O 패턴
HTTP 클라이언트 I/O 처리
// HttpClient를 이용한 비동기 REST API 호출
public class RestApiClient {
private final HttpClient httpClient;
public RestApiClient() {
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
}
// 비동기 JSON 응답 처리
public CompletableFuture<String> fetchUserData(String userId) {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/users/" + userId))
.header("Content-Type", "application/json")
.GET()
.build();
return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(HttpResponse::body);
}
// 대용량 파일 다운로드 (스트리밍)
public CompletableFuture<Path> downloadLargeFile(String url, Path destination) {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.GET()
.build();
return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofFile(destination));
}
}
JSON 스트리밍 처리 (대용량 데이터)
// Jackson을 이용한 스트리밍 JSON 파싱
public void processLargeJsonArray(InputStream inputStream) throws IOException {
JsonFactory factory = new JsonFactory();
try (JsonParser parser = factory.createParser(inputStream)) {
if (parser.nextToken() != JsonToken.START_ARRAY) {
throw new IllegalStateException("Expected array start");
}
while (parser.nextToken() == JsonToken.START_OBJECT) {
// 객체 하나씩 처리하여 메모리 효율성 확보
User user = parser.readValueAs(User.class);
processUser(user); // 비즈니스 로직 처리
}
}
}
// JSON 스트리밍 생성 (무한 스크롤, 페이징)
public void streamJsonResponse(HttpServletResponse response, List<User> users)
throws IOException {
response.setContentType("application/json");
try (JsonGenerator generator = new JsonFactory()
.createGenerator(response.getOutputStream())) {
generator.writeStartArray();
for (User user : users) {
generator.writeStartObject();
generator.writeStringField("id", user.getId());
generator.writeStringField("name", user.getName());
generator.writeEndObject();
generator.flush(); // 즉시 클라이언트로 전송
}
generator.writeEndArray();
}
}
멀티파트 파일 업로드 처리
@RestController
public class FileUploadController {
// 단일 파일 업로드 (메모리 효율적)
@PostMapping("/upload")
public ResponseEntity<String> uploadFile(@RequestParam("file") MultipartFile file) {
try {
// 임시 파일로 저장하여 메모리 사용량 최소화
Path tempFile = Files.createTempFile("upload-", ".tmp");
try (InputStream inputStream = file.getInputStream()) {
Files.copy(inputStream, tempFile, StandardCopyOption.REPLACE_EXISTING);
// 파일 검증 및 처리
validateAndProcessFile(tempFile);
// 최종 저장 위치로 이동
Path finalPath = Paths.get("uploads", file.getOriginalFilename());
Files.move(tempFile, finalPath);
return ResponseEntity.ok("파일 업로드 성공");
}
} catch (IOException e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("파일 업로드 실패: " + e.getMessage());
}
}
// 대용량 파일 청크 업로드
@PostMapping("/upload/chunk")
public ResponseEntity<String> uploadChunk(
@RequestParam("chunk") MultipartFile chunk,
@RequestParam("chunkNumber") int chunkNumber,
@RequestParam("totalChunks") int totalChunks,
@RequestParam("fileName") String fileName) throws IOException {
Path chunkDir = Paths.get("chunks", fileName);
Files.createDirectories(chunkDir);
Path chunkFile = chunkDir.resolve("chunk-" + chunkNumber);
chunk.transferTo(chunkFile);
// 모든 청크가 업로드되면 병합
if (chunkNumber == totalChunks - 1) {
mergeChunks(chunkDir, fileName, totalChunks);
}
return ResponseEntity.ok("청크 업로드 완료");
}
private void mergeChunks(Path chunkDir, String fileName, int totalChunks)
throws IOException {
Path outputFile = Paths.get("uploads", fileName);
try (FileChannel outputChannel = FileChannel.open(outputFile,
StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {
for (int i = 0; i < totalChunks; i++) {
Path chunkFile = chunkDir.resolve("chunk-" + i);
try (FileChannel chunkChannel = FileChannel.open(chunkFile,
StandardOpenOption.READ)) {
chunkChannel.transferTo(0, chunkChannel.size(), outputChannel);
}
}
}
// 임시 청크 파일들 삭제
Files.walk(chunkDir)
.sorted(Comparator.reverseOrder())
.forEach(path -> {
try { Files.delete(path); } catch (IOException e) { /* ignore */ }
});
}
}
서버 사이드 이벤트 (SSE) 스트리밍
@RestController
public class EventStreamController {
@GetMapping(value = "/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamEvents() {
SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);
// 비동기로 이벤트 전송
CompletableFuture.runAsync(() -> {
try {
for (int i = 0; i < 100; i++) {
Thread.sleep(1000);
String eventData = "Event " + i + " at " + Instant.now();
emitter.send(SseEmitter.event()
.name("update")
.data(eventData));
}
emitter.complete();
} catch (Exception e) {
emitter.completeWithError(e);
}
});
return emitter;
}
// 실시간 로그 스트리밍
@GetMapping("/logs/stream")
public SseEmitter streamLogs(@RequestParam String logFile) {
SseEmitter emitter = new SseEmitter();
try {
Path logPath = Paths.get("logs", logFile);
WatchService watchService = FileSystems.getDefault().newWatchService();
logPath.getParent().register(watchService, StandardWatchEventKinds.ENTRY_MODIFY);
CompletableFuture.runAsync(() -> {
try (BufferedReader reader = Files.newBufferedReader(logPath)) {
// 파일 끝으로 이동
reader.skip(Files.size(logPath));
while (!emitter.isClosed()) {
String line = reader.readLine();
if (line != null) {
emitter.send(line);
} else {
// 파일 변경 대기
WatchKey key = watchService.take();
key.pollEvents();
key.reset();
}
}
} catch (Exception e) {
emitter.completeWithError(e);
}
});
} catch (IOException e) {
emitter.completeWithError(e);
}
return emitter;
}
}
데이터베이스 결과셋 스트리밍
@Service
public class UserExportService {
// 대용량 데이터 CSV 스트리밍 내보내기
@Transactional(readOnly = true)
public void exportUsersToCSV(HttpServletResponse response) throws IOException {
response.setContentType("text/csv");
response.setHeader("Content-Disposition", "attachment; filename=users.csv");
try (PrintWriter writer = response.getWriter()) {
// CSV 헤더
writer.println("ID,Name,Email,CreatedDate");
// 스트리밍 쿼리로 메모리 효율적 처리
userRepository.findAllStream().forEach(user -> {
writer.printf("%d,%s,%s,%s%n",
user.getId(),
user.getName(),
user.getEmail(),
user.getCreatedDate());
writer.flush(); // 즉시 전송
});
}
}
// JSON 스트리밍 API 응답
public void streamUsersAsJson(OutputStream outputStream) throws IOException {
try (JsonGenerator generator = new JsonFactory().createGenerator(outputStream)) {
generator.writeStartArray();
// 페이징을 이용한 배치 처리
int page = 0;
int size = 1000;
Page<User> userPage;
do {
userPage = userRepository.findAll(PageRequest.of(page, size));
for (User user : userPage.getContent()) {
generator.writeStartObject();
generator.writeNumberField("id", user.getId());
generator.writeStringField("name", user.getName());
generator.writeStringField("email", user.getEmail());
generator.writeEndObject();
generator.flush();
}
page++;
} while (userPage.hasNext());
generator.writeEndArray();
}
}
}
12. REST API 개발자를 위한 I/O 성능 튜닝
HTTP 커넥션 풀 최적화
@Configuration
public class HttpClientConfig {
@Bean
public HttpClient httpClient() {
return HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.executor(Executors.newFixedThreadPool(50)) // 커넥션 풀 크기 조정
.build();
}
@Bean
public RestTemplate restTemplate() {
HttpComponentsClientHttpRequestFactory factory =
new HttpComponentsClientHttpRequestFactory();
factory.setConnectTimeout(5000);
factory.setReadTimeout(10000);
// Apache HttpClient 커넥션 풀 설정
PoolingHttpClientConnectionManager connectionManager =
new PoolingHttpClientConnectionManager();
connectionManager.setMaxTotal(100); // 최대 커넥션 수
connectionManager.setDefaultMaxPerRoute(20); // 라우트당 최대 커넥션
CloseableHttpClient httpClient = HttpClients.custom()
.setConnectionManager(connectionManager)
.build();
factory.setHttpClient(httpClient);
return new RestTemplate(factory);
}
}
비동기 파일 처리와 응답 최적화
@RestController
public class OptimizedFileController {
// 비동기 파일 압축 및 다운로드
@GetMapping("/download/async")
public CompletableFuture<ResponseEntity<Resource>> downloadAsync(
@RequestParam List<String> fileIds) {
return CompletableFuture.supplyAsync(() -> {
try {
// 임시 ZIP 파일 생성
Path zipFile = Files.createTempFile("download-", ".zip");
try (ZipOutputStream zos = new ZipOutputStream(
Files.newOutputStream(zipFile))) {
for (String fileId : fileIds) {
Path filePath = getFilePath(fileId);
ZipEntry entry = new ZipEntry(filePath.getFileName().toString());
zos.putNextEntry(entry);
Files.copy(filePath, zos);
zos.closeEntry();
}
}
Resource resource = new FileSystemResource(zipFile.toFile());
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=files.zip")
.body(resource);
} catch (IOException e) {
throw new RuntimeException(e);
}
});
}
// 파일 업로드 진행률 추적
@PostMapping("/upload/progress")
public ResponseEntity<String> uploadWithProgress(
@RequestParam("file") MultipartFile file,
HttpServletRequest request) throws IOException {
String sessionId = request.getSession().getId();
// 업로드 진행률을 별도 스레드에서 추적
CompletableFuture.runAsync(() -> {
try (InputStream inputStream = file.getInputStream()) {
long totalSize = file.getSize();
long uploadedSize = 0;
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
uploadedSize += bytesRead;
int progress = (int) ((uploadedSize * 100) / totalSize);
// WebSocket 또는 SSE로 진행률 전송
notifyProgress(sessionId, progress);
}
} catch (IOException e) {
notifyError(sessionId, e.getMessage());
}
});
return ResponseEntity.accepted().body("업로드 시작됨");
}
}
13. 웹 개발자 면접 필수 질문
Q1: REST API에서 대용량 파일을 처리할 때 고려사항은?
- 스트리밍 처리로 메모리 사용량 최소화
- 청크 업로드/다운로드 지원
- 비동기 처리로 서버 리소스 효율성 확보
- 진행률 추적 및 중단/재개 기능
Q2: JSON 파싱에서 성능 이슈가 발생한다면?
- Jackson의 스트리밍 API 활용 (JsonParser/JsonGenerator)
- 큰 배열은 배치 처리로 분할
- 불필요한 필드는 @JsonIgnore로 제외
- 커스텀 디시리얼라이저로 메모리 최적화
Q3: HTTP 클라이언트 성능을 최적화하는 방법은?
- 커넥션 풀 크기 조정 (MaxTotal, MaxPerRoute)
- Keep-Alive 활용으로 커넥션 재사용
- 비동기 호출로 스레드 효율성 확보
- 타임아웃 설정으로 리소스 누수 방지
Q4: 실시간 데이터 스트리밍 구현 방법은?
- Server-Sent Events (SSE)로 단방향 스트리밍
- WebSocket으로 양방향 실시간 통신
- 백프레셰어로 클라이언트 부하 제어
- 이벤트 버퍼링으로 네트워크 효율성 확보