개발이군고구마

자바 I/O (1) - 원리 본문

SERVER/Java

자바 I/O (1) - 원리

김구황 2025. 12. 29. 07:48
728x90

스프링 웹 개발을 하다 보면 MultipartFile이나 Jackson 라이브러리가 내부적으로 스트림을 처리해주기 때문에 로우 레벨(Low-level)의 I/O(Input/Output) 처리를 직접 다룰 기회가 적다. 하지만 다음과 같은 상황에서는 직접 제어해야할 필요가 있다. 

  1. 대용량 엑셀/CSV 업로드: 파일 전체를 메모리에 올리면 OutOfMemory가 발생하므로, InputStream이나 BufferedReader로 한 줄씩 읽으며 처리
  2. 외부 API 연동: HttpURLConnection 등을 로우 레벨로 쓸 때 스트림을 직접 닫아야 함

 

1. 기본 개념: Stream과 Reader/Writer

 

▶ 자바의 I/O는 크게 두 가지 흐름

  • Byte Stream (InputStream, OutputStream): 데이터를 1 byte 단위(이진 데이터)로 처리. 이미지, 동영상 등
  • Character Stream (Reader, Writer): 데이터를 2 byte(Char) 단위(문자)로 처리다. 텍스트 파일(CSV, JSON, TXT)
  •   

 

 

  왜 그냥 Reader가 아니라 BufferedReader인가?

 

  • 일반 FileReader: 디스크에서 문자를 한 글자씩 읽어옴. 파일 시스템에 100번 접근해서 100글자를 가져옴. (매우 비효율적, 디스크 I/O 비용 발생)
  • BufferedReader: 내부에 **버퍼(기본 8KB 크기의 배열)**를 가지고 있음.
    • OS로부터 한 번에 많이 읽어서 버퍼를 채운 뒤
    • readLine() 호출이 오면 버퍼 안에서 \n(또는 \r\n)을 찾고
    • 줄 단위 문자열을 만들어 반환
    • 시스템 호출 횟수를 획기적으로 줄여 속도를 높임

 

2. Reader/Stream의 “운영체제 자원” 점유 - 왜 close()를 해야 하는가

 

▶ Java의 Garbage Collector(GC)는 '메모리'만 청소해주지, 'OS 자원'은 청소해주지 않기 때문

  • BufferedReader 자체는 메모리 객체지만, 보통 그 아래에 이런 것들이 붙어 있음
    •   
    • FileReader / Files.newBufferedReader(...) → 파일 디스크립터(Handle)
      • 파일 핸들(File Handle): 자바가 파일을 읽으려면 운영체제(OS)로부터 "이 파일을 쓰겠다"는 허가증(자원)을 빌려야 함. 이를 '파일 핸들' 또는 '파일 디스크립터'라고 함.
      • 이런 핸들은 JVM 메모리(힙)만 쓰는 게 아니라 OS가 관리하는 제한된 자원
    • InputStreamReader(System.in) → 표준 입력 핸들
    • 네트워크 스트림 → 소켓 핸들
  • 리소스 누수(Resource Leak): close()를 안 하면, 자바 프로그램이 끝날 때까지 OS는 "아직 이 파일 쓰고 있구나"라고 생각해서 붙잡고 있음.
    • 서버가 오래 켜져 있으면 파일 핸들이 고갈되어 더 이상 파일을 못 열게 됨.
    • 다른 프로세스가 해당 파일을 수정하거나 삭제하지 못하게 락(Lock)이 걸릴 수 있음.
    • 버퍼가 남아있으면(특히 Writer 쪽) flush가 안 되어 데이터가 덜 써질 수도 있음

 

  close()는 무엇을 하나?

ㄴBufferedReader.close()를 호출하면 보통:

  • 내부 버퍼 참조를 정리하고
  • 감싸고 있던 하위 Reader의 close()를 연쇄 호출 (가장 중요)
  • 그래서 맨 아래의 File handle/Socket 같은 OS 자원이 실제로 해제

 

 

3. try-with-resources 코드로 확인 

public void parseCsvOldStyle(String filePath) {
    BufferedReader br = null;
    try {
        br = new BufferedReader(new FileReader(filePath));
        String line;
        while ((line = br.readLine()) != null) {
            // 로직 수행
        }
    } catch (IOException e) {
        e.printStackTrace();
    } finally {
        // 에러가 나든 안 나든 무조건 실행되는 블록
        try {
            if (br != null) br.close(); // 여기서도 또 예외처리를 해야 함
        } catch (IOException ex) {
            ex.printStackTrace();
        }
    }
}

Java7 이전에는 무조건 닫게 만들었음 

 

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;

public void parseCsvSmart(String filePath) {
    // try 옆의 괄호 () 안에 리소스 선언.
    try (BufferedReader br = new BufferedReader(new FileReader(filePath))) {
        
        String line;
        // readLine()은 더 이상 읽을 라인이 없으면 null을 반환.
        while ((line = br.readLine()) != null) {
            // 1. CSV 파싱 (쉼표로 분리)
            String[] data = line.split(",");
            
            // 2. JSON 객체로 변환 로직 (예시)
            // System.out.println("Name: " + data[0] + ", Age: " + data[1]);
        }
        
    } catch (IOException e) {
        // 파일이 없거나 읽기 실패 시 예외 처리
        System.err.println("파일을 읽는 중 오류 발생: " + e.getMessage());
    } 
    // 여기서 자동으로 br.close()가 호출. (성공하든 실패하든)
}

AutoCloseable 인터페이스를 구현한 객체(Reader, Writer 등)는 try(...) 괄호 안에 선언하면, 블록이 끝날 때 알아서 close()를 호출

 

 

 

4. (추가) 대용량 처리 (DB 에 저장하는 경우) 

 

▶ 배치 처리(Batch Processing) 패턴 : Read - Buffer - Flush

  • Bad (List에 모두 담기): readAllLines()로 1억 줄을 List에 다 넣고 저장. $\rightarrow$ 100% 메모리 초과(OOM) 발생.
  • Bad (한 줄씩 DB 저장): readLine() 한 줄 읽고 -> insert 쿼리 1번 날리기. $\rightarrow$ 1억 번 DB 접속. 네트워크 오버헤드로 인해 엄청나게 느림.
  • Good (Chunk 지향 처리): 한 줄씩 읽어서 메모리 리스트(Chunk)에 임시로 담다가, **1,000개가 모이면 DB에 한 번에 저장(Flush)**하고 리스트를 비움.

 

▶ 코드 예시

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

public void parseAndSaveLargeCsv(String filePath) {
    // 1. 배치 사이즈 설정 (보통 1000~5000 정도가 적당함)
    final int BATCH_SIZE = 1000;
    List<UserDto> buffer = new ArrayList<>(); // 임시 물통(Buffer)

    try (BufferedReader br = new BufferedReader(new FileReader(filePath))) {
        String line;
        
        // 2. 한 줄씩 스트리밍으로 읽기 (메모리에는 현재 읽는 1줄만 로드됨)
        while ((line = br.readLine()) != null) {
            
            // 데이터 파싱
            UserDto user = parseCsvToDto(line);
            
            // 3. 물통에 담기✨ (DB 저장하지 않는 경우, 여기까지) 
            buffer.add(user);
			
            ---------------------DB-------------------------------
            // 4. 물통이 꽉 찼는지 확인
            if (buffer.size() >= BATCH_SIZE) {
                saveToDb(buffer); // DB에 한 번에 털어넣기 (Flush)
                buffer.clear();   // 물통 비우기 (메모리 확보)
            }
        }

        // 5. 반복문이 끝난 후, 물통에 남은 짜투리 데이터 처리
        // (예: 총 1050개면, 1000개 저장 후 남은 50개를 여기서 저장)
        if (!buffer.isEmpty()) {
            saveToDb(buffer);
        }

    } catch (IOException e) {
        e.printStackTrace(); // 실무에선 로깅 처리
    }
}

// 가상의 DB 저장 메서드
private void saveToDb(List<UserDto> users) {
    // 실제로는 여기서 JDBC Batch Insert 쿼리가 실행됩니다.
    // Query: INSERT INTO users (name, age) VALUES (?, ?), (?, ?), ... 
    System.out.println(users.size() + "건을 DB에 저장했습니다."); 
}

private UserDto parseCsvToDto(String line) {
    // 파싱 로직 구현
    return new UserDto(); 
}

 

스프링 프레임워크에는 이러한 반복적인 패턴(읽기 -> 가공 -> 쓰기)을 표준화해 둔 Spring Batch라는 하위 프로젝트 존재.

👉 실무에서 주기적인 대용량 작업은 보통 Spring Batch를 사용.

 

 

출처: 

이미지 1 - https://devprofessional.hashnode.dev/io-stream

이미지 2 - https://velog.io/@myway00/posts