| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |
- 단위테스트
- MVC요청플로우
- Ajax
- TDD
- springsecurity
- Transaction
- 설정세팅
- 이벤트핸들러this
- 사면초가
- JPA
- model
- DDD
- 이벤트핸들러등록
- SEQUENCE
- 공통규약
- OOP
- 커뮤니티서버
- 공통기술
- 로그백
- 냄새라도
- 문제선택근거
- string
- 전전긍긍
- 클린아키텍처
- AOP
- JDBC
- 디스크i/o
- MVC
- index
- SpringLegacy
- Today
- Total
개발이군고구마
자바 I/O (1) - 원리 본문
스프링 웹 개발을 하다 보면 MultipartFile이나 Jackson 라이브러리가 내부적으로 스트림을 처리해주기 때문에 로우 레벨(Low-level)의 I/O(Input/Output) 처리를 직접 다룰 기회가 적다. 하지만 다음과 같은 상황에서는 직접 제어해야할 필요가 있다.
- 대용량 엑셀/CSV 업로드: 파일 전체를 메모리에 올리면 OutOfMemory가 발생하므로, InputStream이나 BufferedReader로 한 줄씩 읽으며 처리
- 외부 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 자체는 메모리 객체지만, 보통 그 아래에 이런 것들이 붙어 있음
- 리소스 누수(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
'SERVER > Java' 카테고리의 다른 글
| 응집도/결합도 분석으로 본 '인터페이스를 사용하는 이유' (0) | 2026.01.09 |
|---|---|
| 자바 I/O (2) - (문자) 파싱 구현체 만들기 (0) | 2026.01.07 |
| [자료구조] LIST 원리 (0) | 2025.12.26 |
| [자료구조] MAP 원리 (0) | 2025.12.19 |
| [JVM] 자바에서 생성한 코드는 어떻게 실행되는가 (0) | 2025.05.20 |
