자바 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으로 양방향 실시간 통신
  • 백프레셰어로 클라이언트 부하 제어
  • 이벤트 버퍼링으로 네트워크 효율성 확보