공부방/JAVA

I/O - 백기선 자바라이브스터디

EVO. 2023. 10. 10. 00:35

스트림 (Stream) / 버퍼 (Buffer) / 채널 (Channel) 기반의 I/O

스트림이란

자바에서 어느 한 쪽에서 다른 쪽으로 데이터를 전달하려면, 두 대상을 연결하고 데이터를 전송할 수 있는 통로가 필요한데, 이것을 스트림(stream)이라고 정의한다.

  • 스트림은 연속적인 데이터의 흐름을 물에 비유해서 붙여진 이름인데 여기서 마치 물이 한 쪽 방향으로 흐르는 것과 같아 스트림도 역시 단방향 통신만 가능하다. 즉, 하나의 스트림으로 입력과 출력을 동시에 처리할 수 없다. 따라서 양방향 통신을 위해 입력 스트림 출력 스트림 둘 다 필요하다. 
  • 입력스트림으로 바이트나 문자가 들어오는데 큐와 같이 FIFO 구조로 받아온다. 즉 먼저 보낸 데이터를 먼저 받게 된다.

버퍼란

I/O에서 입력 스트림과 출력 스트림만 사용하면 굉장히 비효율적이다. 예를들어 두개의 pc가 채팅을 하고 있다고 가정하자. A PC는 "안녕" 이렇게 총 6바이트를 치고 엔터를 눌렀다. (UTF-8기준 한 문자당 3바이트) 프로그램은 "안녕"이라는 문자를 1바이트씩 입력스트림을 통해 받으면서 그것을 B PC에게 보내기 위해 동시에 1바이트씩 출력 스트림을 통해 보낸다. 

 

여기서 생기는 성능 이슈는 다음과 같다.

  •  프로세스가 커널에 파일 읽기 명령을 내림(시스템 콜) (유저 모드에서 커널모드로 전환): 전환하면서 스레드를 바꾸고, CPU 코어에 있던 레지스터에 있는 데이터들을 기억하기 위해 커널 컨택스트 관련 버퍼에 저장 
  • 모든 파일 데이터가 커널 버퍼에 복사되면 다시 프로세스 안의 버퍼로 복사한다.

위 두개의 비용은 매우 높다. 이것보다는 버퍼를 사용해서 복수 개의 바이트를 한꺼번에 입력받고 (즉 1바이트씩 보내는 것이 아닌 어느정도 쌓이면 그때 버퍼의 내용을 flush 한다) 한번에 출력하는 것이 성능에 이점을 가지게 된다. 

자바에서는 BufferedInputStream과 BufferedOutputStream을 사용한다

 

버퍼는 단독으로 쓰이지 않고 네트워크 통신을 할때 항상 채널과 함께 사용함으로써 진가를 발휘한다.

채널이란

자바의 기본 입출력 방식이었던 Stream은 blocking 방식과 Non-Buffer의 특징으로 인해 입출력 속도가 느렸다. 자바4부터 이를 해결하고자 NIO(New Input Output)가 java.nio 패키지에 포함되어 등장했는데 Channel이 그 NIO의 기본 입출력 방식이다.

  • 데이터가 통과하는 양방향 통로이며, 채널에서 데이터를 주고 받을 때 사용 되는 것이 버퍼이다.
  • 채널에는 소켓과 연결된 SocketChannel, 파일과 연결된 FileChannel, 파이프와 연결된 Pipe.SinkChannel 과 Pipe.SourceChannel 등이 존재하며, 서버소켓과 연결된 ServerSocketChannel 도 존재한다.

IO VS NIO

  • IO 의 방식으로 각각의 스트림에서 read() 와 write() 가 호출이 되면 데이터가 입력 되고, 데이터가 출력되기전까지, 스레드는 블로킹(멈춤) 상태가 된다. 이렇게 되면 작업이 끝날때까지 기다려야 하며, 그 이전에는 해당 IO 스레드는 사용할 수 없게 되고, 인터럽트도 할 수 없다. 블로킹을 빠져나오려면 스트림을 닫는 방법 밖에 없다.
  • NIO 의 블로킹 상태에서는 Interrupt 를 이용하여 빠져나올 수 있다.
입출력 방식 스트림 채널
버퍼 방식 Non-Buffer Buffer
비동기 방식 지원 X O
Blocking/Non-Blocking 방식 Blocking Only 둘 다 가능
사용 케이스 연결 클라이언트가 적고, IO 가 큰 경우(대용량) 연결 클라이언트가 많고, IO 처리가 작은 경우(저용량)

 

바이트 기반 스트림 - InputStream, OutputStream

스트림은 바이트 단위로 데이터를 전송할 수 있으며 입출력의 대상에 따라 여러 입출력 스트림이 있다. 그리고 이들의 부모는 모두 InputStream,OutputStream의 자손이다. 이 둘은 추상클래스라 직접 사용하는 경우는 없고 자손들은 이들의 메서드를 구현한다.

 

입력스트림 출력스트림 입출력 대상의 종류
FileInputStream FileOutputStream 파일
ByteArrayInputStream ByteArrayOutputStream 메모리(byte배열)
PipedInputSteam PipedOutputStream 프로세스(프로세스간 통신)
AudioInputStream AudioOutputStream 오디오 장치

 

바이트 기반 스트림의 부모 메서드 

먼저 OutputStream의 주요 메서드이다.

리턴 타입 메서드 설명
void write(int b) 1byte를 출력
void write(byte[] b) 매개값으로 주어진 배열 b의 모든 바이트를 출력
void write(byte[] b,int off, int len) 매개값으로 주어진 배열 b[off]부터 len 개의 바이트를 출력
void flush() 출력 버퍼에 잔류하는 모든 바이트를 출력
void  close() 출력 스트림을 닫고 사용 메모리 해제

 

write(int b)

매개변수를 보면 int형인 4바이트를 받지만 끝 1byte만 출력한다. 즉, -128~127값의 범위만 출력이 가능하다. 이 출력이 가능하다는 것은 

실제로 파일에 바이트가 저장되지 않는다. 먼저 출력 버퍼에 쌓인다.(버퍼가 있는 스트림 기준) 그래서 flush작업이 필요하다. 굳이 호출할 필요는 없는데 왜냐하면 출력스트림을 닫으면(close) 먼저 flush작업을 하고 그다음 스트림을 닫는 순서를 가진다.

write(byte[] b)

일반적으로 1 바이트를 출력하는 경우는 드물고, 보통 바이트 배열을 통째로 출력하는 경우가 많다.

매개값으로 주어진 배열의 모든 바이트를 출력한다. 

write(byte[] b,int off, int len)

만약 write(b,1,3)을 호출하면 다음과 같다. 


다음은 InputStream의 주요 메서드이다.

리턴 타입 메서드 설명
int  read() 1byte를 읽은 후 읽은 바이트를 리턴
int read(byte[]) 읽은 바이트를 매개값으로 주어진 배열에 저장 후 읽은 바이트 수를 리턴
void close() 입력 스트림을 닫고 사용 메모리 해제

read()

입력스트림으로 부터 1 byte를 읽고 int 타입으로 리턴한다. 따라서 리턴된 4바이트 중 1바이트에만 데이터가 들어 있다. 

 

 

InputStream is = new FileInputStream("파일경로");
while(true){
	int data = is.read(); // 1 바이트를 읽고 리턴
    if(data == -1)break; // 더이상 읽어낼 바이트가 없다면 -1 리턴
}

read(byte[] b)

읽은 바이트를 주어진 배열에 저장하고, 읽은 바이트 수 만큼 리턴한다. 입력 스트림으로 부터 바이트를 더 이상 읽을 수 없다면 -1을 리턴한다. 

 

다음 예시는 해당 메서드를 사용했을 때 자주 생기는 실수이니 유념해야한다. 해결하는 방법은 읽어들인 만큼만 출력하면 되는데 해당 예제는 다음 링크에 있다.

 

문자 기반 스트림 - Reader, Writer

입출력되는 단위가 문자인 것을 제외하고는 바이트 입출력 스트림과 사용 방법은 동일하다.

 

 

Writer 역시 문자 출력 스트림의 최상위 추상 클래스 이므로 상속받는 클래스들을 사용해야 한다.

 

다음 문자 출력 스트림인 Writer의 주요 메서드들이다.

리턴 타입  메서드 설명
void write(int c) 매개값으로 주어진 한 문자를 출력
void write(char[] cbuf) 매개값으로 주어진 배열의 모든 문자를 출력
void write(char[] cbuf, int off, int len) 매개값으로 주어진 배열에서 cbuf[off]부터 len개까지의 문자를 출력
void write(String str) 매개값으로 주어진 문자열을 출력
void write(String str, int off, int len) 매개값으로 주어진 문자열에서 off 순번부터 len개까지의 문자를 출력
void flush() 버퍼에 잔류하는 모든 문자를 출력
void  close() 출력 스트림을 닫고 사용 메모리를 해제
Writer writer = new FileWriter("파일명");
writer.write("Hello, world");

Reader도 역시 추상 클래스이다. 상속받은 클래스들을 사용해야 한다.

다음은 Reader 클래스의 주요 메서드이다.

리턴 형태 메서드 설명
int read() 1개의 문자를 읽고 리턴
int read(char[] cbuf) 읽은 문자들을 매개값으로 주어진 문자 배열에 저장하고 읽은 문자 수를 리턴
void close() 입력 스트림을 닫고, 사용 메모리 해제

보조 스트림

보조 스트림이란 다른 스트림과 연결되어 여러 가지 편리한 기능을 제공해주는 스트림을 말한다. 보조 스트림은 자체적으로 입출력을 수행할 수 없기 때문에 입출력 소스로부터 직접 생성된 입출력 스트림에 연결해서 사용해야 한다.

 

바이트기반의 보조 스트림

  • FilterInputStream / FilterOutputStream : 모든 보조스트림의 조상이다. 자체로는 어떤 로직도 없어서 상속을 통해 원하는 작업을 수행하도록 읽고 쓰는 메서드를 오버라이딩 한다.
  • BufferedInputStream / BufferedOutputStream : 스트림의 입출력 효율을 높이기 위해 버퍼를 사용하는 보조 스트림이다. 한 바이트씩 입출력하는 것 보다 버퍼를 이용해서 한 번에 여러 바이트를 입출력하는 것이 빠르기 때문에 대부분의 입출력 작업에 사용된다. 반드시 close()나 flush()를 호출해서 마지막에 버퍼에 있는 모든 내용이 출력 소스에 출력되도록 해야 한다.

try{
	OutputStream fos = new FileOutputStream("파일 경로");
    // BufferedOutputStream의 버퍼 크기를 5로 한다.
    BufferedOutputStream bos = new BufferedOutputStream(fos,5);
    // 파일 경로에 1부터 9까지 출력
    for(int i='1'; i<='9'; i++){
    	bos.write(i);
     }
  }catch(IOException e){
  	e.printStackTrace();
  }finally{
  	//무조건 실행할 수 있게 finally구문에 close()한다. 또는 try-with-resources로 해결하기
    try{
    	if(fos!=null) fos.close();
     }catch(IOException ie){ie.printStackTrace();}
    //fos는 닫을 필요가 없는게 보조스트림만 닫아도 기반 스트림도 close()를 자동 호출한다
  }
  • DataInputStream / DataOutputStream : 데이터를 읽고 쓰는 데 있어서 byte단위가 아닌, 8가지 기본 자료형의 단위로 읽고 쓸 수 있다는 장점이 있다. 이때 입출력되는 값들은 16진수로 표현하여 저장한다. 주의할 것은 여러 가지 종류의 자료형으로 출력한 경우, 읽을 때는 반드시 쓰인 순서대로 읽어야 한다. 
FileOutputStream fos = null;
DataOutputStream dos = null;

// 디렉토리의 파일 경로를 얻어오기 위해 리플렉션 사용
String filePath = getClass().getClassLoader().getResource("sample.txt").getPath();
try{
	fos = new FileOutputStream(filePath);
    dos = new DataOutputStream(fos);
    dos.writeInt(10);
    dos.writeFloat(0.2f);
    dos.writeBoolean(true);
    
    FileInputStream fis = new FileInputStream("sample.txt");
    DataInputStream dis = new DataInputStream(fis);
    
    System.out.println(dis.readInt());
    System.out.println(dis.readFloat());
    
}catch(IOException e){e.printStackTrace();}
finally{
	try{
    	if(dis != null) dis.close();
     }catch(IOExcetion ie){ie.printStackTrace();}
}
  • SequenceInputStream
  • PrintStream : 데이터를 기반 스트림에 다양한 형태로 출력할 수 있는 print,println,printf와 같은 메서드를 오버로딩하여 제공. PrintStream은 데이터를 적절한 문자로 출력하기 때문에 문자기반 스트림의 역할을 수행한다. 그것보다 더 향상된 문자기반스트림인 PrintWriter가 JDK1.1에서 추가되었으나, 그동안 빈번히 사용되던게 System.out이고 이 out static 멤버는 PrintStream이다 보니 둘 다 쓰고 있는 중이다.  

문자기반의 보조 스트림

  • BufferedReader / BufferedWriter : 버퍼를 이용해서 입출력의 효율을 높일 수 있도록 해주는 역할을 한다. 여기에는 특히 readLine()이라는 메서드가 있는데 라인 단위로 읽을 수 있는 좋은 메서드 이다.
  • InputStreamReader / OutputStreamWriter : 바이트기반 스트림을 문자기반 스트림으로 연결시켜주는 역할을 한다. 그리고 바이트 기반 스트림의 데이터를 지정된 인코딩의 문자데이터로 변환하는 작업을 수행한다.
//InputStream인 System.in을 연결 
InputStreamReader isr = new InputStreamReader(System.in);

//BufferedReader의 readLine()을 이용하기 위해 사용자의 화면 입력을 라인단위로 입력받기위해 사용
//JDK 1.5부터는 Scanner가 추가되어 이와 같은 방식을 사용하지 않아도 된다.
BufferedReader br = new BufferedReader(isr);

표준 스트림 - System.in, System.out, System.err

표준입출력 스트림의 종류는 java.lang 패키지의  System클래스 내부에 static으로 선언되어 있다.

public final class System {
  public static final InputStream in = null;
  public static final PrintStream out = null;
  public static final PrintStream err = null;
  ....
}

선언부를 보면 in, out, err의 타입이 InputStream과 PrintStream이지만 실제로는 버퍼를 이용하는 BufferedInputStream과 BufferedOutputStream의 인스턴스를 사용한다.

그리고 해당 newPrintStream은 처음 System클래스를 만들고 초기화할때 사용된다.

private static void initPhase1{
...


setOut0(newPrintStream(fdOut,props.getProperty("sun.stdout.encoding")));
...

 

setOut(),setErr(),setIn()

최초 초기에는 System.in, System.out, System.err 의 입출력대상이 콘솔화면이지만,

setIn(), setOut(), setErr() 를 사용하면 입출력을 콘솔 이외에 다른 입출력 대상으로 변경하는 것이 가능하다.

static void setOut(PrintStream out) System.out의 출력을 지정된 PrintStream으로 변경
static void setErr(PrintStream err) System.err의 출력을 지정된 PrintStream으로 변경
static void setIn(InputStream in) System.in의 입력을 지정한 InputStream으로 변경

 

파일 읽고 쓰기

자바에서는 File클래스를 통해서 파일과 디렉토리를 다룰 수 있도록 하고 있다. 그래서 File인스턴스는 파일 일 수도 있고 디렉토리일 수도 있다.

File 클래스를 사용하는 이유는 첫번째로 파일이나 디렉토리에 대한 정보를 얻을려고 사용한다. 두번째로 api에서 생성자의 매개변수로 File을 받기도 하기 때문이다.

생성자/메서드 설명
File(String fileName) fileName을 이름으로 갖는 파일을 위한 File인스턴스를 생성한다. 파일 뿐만 아니라 디렉토리도 같은 방법으로 다룬다.
fileName은 경로를 포함해서 지정해주지만, 파일이름만 사용해도 되는데 이 경우 프로그램이 실행되는 위치가 경로로 간주된다.
File(String pathName,String fileName)
File(File pathName,String fileName)
파일의 경로와 이름을 따로 분리해서 지정
File(URI uri) 지정된 uri로 파일을 생성
String getName() 파일이름을 String으로 반환
String getPath() 파일의 경로를 String으로 반환
String getAbsolutePath()
File getAbsolutePath()
파일의 절대경로를 String으로 반환
파일의 절대경로를 File로 반환
... 조상 디렉토리를 반환
... 파일의 정규경로를 반환

 

메서드

isDirectory() 디렉토리인지 여부 확인
isFile() 파일인지 여부 확인
delete() 파일또는 디렉토리 삭제
mkdirs() 경로 상에 없는 모든 디렉토리 생성

 

이제 파일을 읽고 쓸려면 아까 공부했던 기반,보조스트림들과 결합하면 된다.

...
File[] files = dir.listFiles();
FileReader fr = new FileReader(files[i]);
BufferedReader br = new BufferedReader(fr);

...

while((data=br.readLine())!=null){
...
}

 

만약 이진파일 이라면

BufferedInputStream is = new BufferedInputStream(new FileInputStream("a.jpg"));
BufferedOutputStream os = new BufferedOutputStream(new FileOutputStream("b.jpg"));
byte[] buffer = new byte[16384];
while (is.read(buffer) != -1) {
  os.write(buffer);
}