-
[Java] I/O 작동 원리 HDD 구조부터 시작하기JAVA 2025. 5. 29. 22:27728x90반응형
Java I/O의 시작 - File과 하드디스크의 이해
Java의 I/O(Input/Output) 기능은 운영체제를 통해 하드웨어 자원에 접근하며, 주로 파일, 네트워크, 콘솔 등의 입출력 작업을 수행하는 데 사용되며 가장 기본적인 파일에 관해 하드디스크부터 천천히 알아보겠습니다.
하드디스크(HDD)의 논리적 구조
하드디스크는 회전하는 원형 디스크(platter)와 이를 읽고 쓰는 기계적 팔(arm)으로 구성되어 있으며 플래터는 여러 개의 트랙(track)과 섹터(sector)로 나뉘어서 데이터를 저장할 수 있는 논리적 단위를 형성합니다.
- 트랙(Track): 디스크의 중심에서 바깥 방향으로 동심원 형태로 번호가 매겨집니다 (0, 1, 2, ...)
- 섹터(Sector): 각 트랙을 나누는 조각이며, 시계 방향으로 번호가 붙습니다. (0, 1, 2, ...)
결국 파일이 저장된다는 것은 "몇 번 트랙의 몇 번 섹터에 데이터가 저장되어 있다"는 의미입니다.
이때 파일은 디스크 내 연속된 공간에 저장되지 않을 수 있기 때문에 조각난 형태로 저장될 수 있으며 이를 단편화(fragmentation)라고 합니다.
옛날에 컴퓨터가 느려지면 자주 사용되던 "디스크 조각 모음(Defragmentation)"은 이러한 단편화된 데이터를 연속된 공간으로 재배치하여 I/O 성능을 향상하기 위한 작업이었습니다.
Disk 파일 저장 방식
Disk에 데이터를 저장할 때는 OS가 File System을 통해 파일을 관리하며 이 과정에서 데이터는 블록 단위로 Disk에 기록됩니다.
일반적으로 파일은 처음 생성되면 크기가 0이며, 이후 데이터를 추가하면 그에 따라 파일의 크기가 증가합니다.
- 기본적으로 append 방식으로 데이터를 저장합니다.
- offset을 지정하게 되면 특정 위치의 데이터를 덮어쓰는 것도 가능합니다.
- Java에서는 RandomAccessFile, FileChannel은 offset을 활용해서 덮어쓰기 가능합니다.
Buffered I/O vs Non-Buffered I/O
Java I/O의 성능을 결정짓는 중요한 요소 중 하나는 버퍼링(buffering)입니다.
공통적인 작동 흐름을 먼저 알아보겠습니다.
- Process(JVM)이 OS에 "어떠한 파일을 쓰겠다"라고 요청합니다.
- OS는 해당 요청을 File System에 전달합니다.
- File System은 Driver에 전달합니다.
- Driver가 실제 디스크에 쓰기 작업을 시작합니다.
- 작업 완료 후 OS > Process에게 알려줍니다.
이때 일반적으로 커널 스레드가 파일을 읽거나 쓰기 위해 디스크 I/O를 요청하면 디스크는 CPU보다 속도가 느리기 때문에 해당 커널 스레드는 Blocked 상태로 전환됩니다.
이후 디스크 작업이 완료되면 디스크는 인터럽트(interrupt)를 통해 CPU에게 완료를 알리고 커널은 해당 스레드를 Runnable 상태로 되돌려 스케줄링할 수 있도록 합니다.
이러한 방식으로 인해 Java의 전통적인 파일 I/O는 본질적으로 Blocking I/O로 분류됩니다.Buffered I/O
사용자 임시 메모리 공간을 만들어서 입출력 작업의 효율을 높일 수 있습니다.
OS에 요청하기 전 데이터를 모아서 한 번에 OS에 요청하게 됩니다.
- 디스크 접근 횟수를 줄일 수 있기 때문에 CPU Wait Time도 줄어듭니다.
Non-Buffered I/O
데이터 단위 단위로 OS에 요청을 하게 됩니다.
- 디스크 접근 횟수가 늘어나기 때문에 CPU Wait Time이 늘어날 수 있습니다.
- 하지만 특정 상황(게임)에서는 성능을 포기하더라도 즉각적인 저장을 위해 사용하게 됩니다.
Blocking I/O 와 Non-Blocking I/O에 대한 고찰
Buffered I/O 관련하여 알아본 바에 따르면 기본적으로 Blocking I/O를 사용한다는 걸 알 수 있습니다.
해당 문제를 좀 더 효율적으로 풀기 위한 선택지는 멀티 스레드와 Non-Blocking I/O입니다.
멀티 스레드
스레드를 나눠서 하나는 CPU Wait Time과 상관없이 작동하게 하고 다른 스레드가 Blocking I/O를 수행하면 효율적이지 않을까 생각할 수 있지만 이는 컨텍스트 스위칭 비용이 발생하게 됩니다.
JAVA의 컨텍스트 스위칭이 비싼 이유?
공부를 하다 보면 "Java의 컨텍스트 스위칭 비용이 비싸다"라는 말을 자주 듣곤 하는데 이번 글에서는 왜 Java의 컨텍스트 스위칭 비용이 비싼지 정리해보았습니다.JAVA의 Thread 구조Java에서는 java.la
jangto.tistory.com
Non-Blocking I/O
흐름을 보면 Process(JVM)은 하드웨어를 직접 제어할 수 없기 때문에 OS에 요청하는 것을 알 수 있는데 OS 차원에서 Non-Blocking 모드를 제공하기 때문에 컨텍스트 스위칭 없이 가능합니다.
- 성공 여부 및 시점을 알 수 없기 때문에 로직이 복잡해질 수 있습니다.
- 콜백(callback) 또는 Future/Promise 패턴 등을 통해 비동기적으로 결과를 처리해야 합니다.
조금 더 자세히 Non-Blocking I/O가 빠른 이유에 대해 알아보겠습니다.
유저 스레드 A가 파일을 읽거나 쓰려고 하면 OS에 요청 후 커널 모드로 전환되고 관심 있는 I/O 이벤트를 Selector에 등록한 뒤 즉시 반환됩니다.
이후 Selector 스레드가 이벤트 발생 여부를 감지하고 데이터가 커널 버퍼에 준비되면 유저 스레드 A에게 I/O 가능 여부를 알려주게 되고 유저 스레드 A는 다시 read()를 호출하여 커널 모드로 전환되며 커널이 커널 버퍼의 데이터를 유저 공간으로 복사하여 전달합니다.
주요 Stream 클래스 소개
1. Buffered Stream
Buffered Stream은 데코레이터 패턴을 기반으로 성능 향상을 위해 내부 버퍼를 제공하는 기능을 하는 래퍼 클래스입니다.
예를 들어 FileInputStream만 사용하여 read() 메서드로 1바이트씩 반복적으로 파일을 읽으면 각 read() 호출이 System Call로 이어지게 되는데 이렇게 잦은 Sytem Call은 OS와의 반복적인 통신을 유발하고 각 호출마다 발생하는 컨텍스트 스위칭(cpu가 Disk에게 읽도록 시키고 wait이 되는 그 잠깐이지만 cpu를 놀게 둘 수 없기 때문에 다른 스레드로 바꾸기 때문에 발생합니다.) 등의 오버헤드로 인해 애플리케이션 성능이 저하됩니다.
이러한 문제를 해결하기 위해 BufferedInputStream으로 FileInputStream을 감싸주면, BufferedInputStream은 자신의 내부 버퍼(기본 8KB) 크기만큼 OS로부터 (FileInputStream을 통해) 데이터를 한 번에 읽어오기 때문에 Systen Call 횟수가 크게 줄어들어 성능이 향상됩니다.
- 예시) BufferedInputStream, BufferedOutputStream
BufferedInputStream bis = new BufferedInputStream(new FileInputStream(filePath));bis.read()는 내부적으로 한 번에 8192바이트(기본값)를 읽지만 read()는 그중에 1byte를 읽어가게 됩니다.
따라서 대량 처리 시 버퍼 배열을 명시적으로 지정해 줘야 FileInputStream이 OS로부터 받은 데이터(읽을 수 있는 데이터는 BufferedInputStream의 내부 버퍼에 있습니다.)를 한 번에 읽을 수 있기 때문에 성능이 좋아집니다.
byte[] buffer = new byte[8192]; int bytesRead = bis.read(buffer);2. Byte Stream
Byte Stream은 이름 그대로 모든 종류의 데이터를 byte 단위로 처리합니다.
텍스트, 이미지, 오디오, 비디오, 실행 파일까지 모든 것을 byte 데이터로 저장되고 처리되기 때문에 해당 Stream은 데이터를 직접 다룰 때 사용됩니다.
아래에서 Byte Stream에서 가장 흔하게 사용되는 FileInputStream 예시를 살펴보겠습니다.
버퍼 없이 파일 읽기 (비효율)
다음은 이미지 파일을 버퍼링 없이 1바이트씩 읽어 화면에 (문자로 변환하여) 출력하는 말도 안 되는 예시이지만 각 바이트를 읽을 때마다 시스템 콜이 발생할 수 있는 비효율적인 예시를 위해 작성하였습니다.
public void nonBufferedFileRead() { String filePath = "/Users/janghyeonseong/Desktop/D5IfBZbUYAAXd75.jpg"; try (FileInputStream fis = new FileInputStream(filePath)) { int byteRead; while ((byteRead = fis.read()) != -1) { System.out.print((char) byteRead); } } catch (IOException e) { e.printStackTrace(); } }버퍼를 사용해서 파일 읽기 (1차 개선)
BufferedInputStream을 사용하면 내부 버퍼를 통해 한 번에 많은 양의 데이터를 미리 읽어오므로 실제 System Call 횟수를 줄여 성능을 크게 향상할 수 있습니다.
public void bufferedFileRead() { String filePath = "/Users/janghyeonseong/Desktop/D5IfBZbUYAAXd75.jpg"; try (FileInputStream fis = new FileInputStream(filePath); BufferedInputStream bis = new BufferedInputStream(fis)) { int byteRead; while ((byteRead = bis.read()) != -1) { System.out.print((char) byteRead); } } catch (IOException e) { e.printStackTrace(); } }버퍼를 사용하여 파일 복사 (2차 개선)
파일을 복사할 때는 입력과 출력 모두에 버퍼 스트림을 사용하고 추가로 byte []를 사용하여 한 번에 JVM 메모리에 이쓴 byte를 한 번에 읽고 쓰는 것이 효율적입니다.
public void bufferedFileCopy() { String sourceFilePath = "/Users/janghyeonseong/Desktop/D5IfBZbUYAAXd75.jpg"; String destinationFilePath = "/Users/janghyeonseong/Desktop/copy.jpg"; try (FileInputStream fis = new FileInputStream(sourceFilePath); BufferedInputStream bis = new BufferedInputStream(fis); FileOutputStream fos = new FileOutputStream(destinationFilePath); BufferedOutputStream bos = new BufferedOutputStream(fos)) { byte[] buffer = new byte[8192]; int bytesRead; while ((bytesRead = bis.read(buffer)) != -1) { bos.write(buffer, 0, bytesRead); } System.out.println("이미지 파일 복사 완료!"); } catch (IOException e) { e.printStackTrace(); } }
메모리 기반 Byte Stream (ByteArrayInputStream)
주로 JVM 내부에서 데이터를 임시로 저장하고 읽는 데 활용되며 이러한 메모리 기반 스트림은 이미 데이터가 메모리에 있으므로 디스크 I/O와 같은 외부 장치 접근이 없습니다.
따라서 BufferedInputStream 등으로 감싸도 성능 향상 효과는 거의 없으며 오히려 추가적인 버퍼 복사로 약간의 오버헤드가 발생할 수 있습니다.
byte[] buffer = new byte[10]; Random rnd = new Random(); for (int i = 0; i < buffer.length; i++) { buffer[i] = (byte) rnd.nextInt(); } ByteArrayInputStream b = new ByteArrayInputStream(buffer); System.out.println("모든 요소 보기 :"); int num; while ((num = b.read()) != -1) { System.out.print(num + " "); }메모리 기반 Byte Stream (SequenceInputStream)
SequenceInputStream은 여러 개의 InputStream을 연결하여 하나의 InputStream처럼 다룰 수 있게 해 줍니다.
byte[] arr1 = {0, 1, 2}; byte[] arr2 = {3, 4, 5}; byte[] arr3 = {6, 7, 8}; byte[] outSrc = null; List<ByteArrayInputStream> streamList = List.of(new ByteArrayInputStream(arr1), new ByteArrayInputStream(arr2), new ByteArrayInputStream(arr3)); Enumeration<ByteArrayInputStream> e = Collections.enumeration(streamList); try(SequenceInputStream input = new SequenceInputStream(e); ByteArrayOutputStream output = new ByteArrayOutputStream()) { byte[] buffer = new byte[1024]; int data; while((data = input.read(buffer)) != -1) { output.write(buffer, 0, data); } outSrc = output.toByteArray(); } catch(IOException ex) { ex.printStackTrace(); } System.out.println("여러개 합쳐진 결과 : " + Arrays.toString(outSrc));3. Character Stream
텍스트 데이터를 처리하는 데 특화되어 있으며 1byte가 아닌 문자(char) 단위로 데이터를 읽고 씁니다.
Java에서 char는 2byte 유니코드(UTF-16)를 사용하기 때문에 byte stream과 달리 인코딩(Encoding)을 고려해야 하기 때문에 데이터를 읽거나 쓸 때 어떤 문자 인코딩(예: UTF-8, EUC-KR)을 사용할지 지정할 수 있습니다.
주로 텍스트 파일, 네트워크를 통한 문자열 전송, 콘솔 입출력 등 문자 기반의 데이터를 다룰 때 사용됩니다.
주요 클래스 알아보기
- Reader : 문자 입력 스트림의 추상 클래스 ( 최상위 )
- FileReader : 파일에서 문자 데이터를 읽습니다.
- BufferedReader : 버퍼링 기능 제공하여 성능을 향상합니다.
- InputStreamReader : Byte Stream을 문자 Stream으로 변환합니다. (Encoding 지정 가능)
- Writer : 문자 출력 스트림의 추상 클래스 ( 최상위 )
- FileWriter : 파일에 문자 데이터를 쓸 때 사용합니다.
- BufferedWriter : 버퍼링 기능 제공하여 성능을 향상합니다.
- OutputStreamWriter : 문자 Stream을 Byte Stream으로 변환합니다. (Encoding 지정 가능)
[Java] 인코딩 디코딩 다시 이해하기
컴퓨터는 오직 0 or 1 즉 바이트(byte)를 처리할 수 있고 사람이 알아보는 문자는 char 또는 String입니다. 쉽게 말하면 인코딩은 문자를 바이트로 디코딩은 바이트를 문자로 바꾸는 작업입니다.간단
jangto.tistory.com
FileReader 예시
FileReader는 시스템의 기본 문자 인코딩을 사용하여 파일을 읽습니다.
더 효율적인 입출력을 위해 BufferedReader로 감싸서 사용합니다.
String filePath = "/Users/janghyeonseong/Desktop/test.txt"; try (FileReader fileReader = new FileReader(filePath); BufferedReader bufferedReader = new BufferedReader(fileReader)) { String line; System.out.println("--- 파일 내용 시작 ---"); while ((line = bufferedReader.readLine()) != null) { System.out.println(line); } System.out.println("--- 파일 내용 끝 ---"); } catch (IOException e) { e.printStackTrace(); }FileWriter 예시
FileWriter는 시스템의 기본 문자 인코딩을 사용하여 파일에 씁니다.
BufferedWriter로 감싸서 쓰기 성능을 향상할 수 있습니다.
newLine() 메서드로 플랫폼에 맞는 줄 바꿈을 쉽게 추가할 수 있습니다.
String filePath = "/Users/janghyeonseong/Desktop/newtest.txt"; try (FileWriter fileWriter = new FileWriter(filePath); BufferedWriter bufferedWriter = new BufferedWriter(fileWriter)) { bufferedWriter.write("아 배가 고파요...."); bufferedWriter.newLine(); bufferedWriter.write("월요일은 싫어요.........."); bufferedWriter.newLine(); bufferedWriter.write("치킨이 먹고 싶어요.........."); System.out.println("텍스트 파일 작성 완료!"); } catch (IOException e) { e.printStackTrace(); }728x90반응형'JAVA' 카테고리의 다른 글
java.util.concurrent.locks (0) 2025.07.17 [Java] Thread에 대해 알아보자 (3) 2025.06.26 [Java] 인코딩 디코딩 다시 이해하기 (0) 2025.05.08 [Java 파먹기] 정렬 기준 : Comparable & Comparator (1) 2025.04.17 [레거시 코드와 데이트] SimpleDateFormat의 함정 (0) 2025.02.21