본문 바로가기

JAVA/네트워크 프로그래밍

[JAVA] 입출력 스트림(Input Output Stream)

이전 예제에서 사용해 보았던 read() 메소드는 한 번에 한 개의 문자 밖에 읽지 못하였다.

그 이유는 통신과 관계가 깊은데, 자세히 알아보기 위해서는 네트워크와 같이 공부하면 더 좋을 것 같았다.

아래의 내용은 'Java Network Programming'이라는 책에서 공부한 내용을 요약한 것이다.



네트워크 프로그램의 가장 큰 비중을 차지하는 것이 바로 입출력(I/O)이다. 

즉, 하나의 시스템에서 다른 시스템으로 데이터를 이동하는 일을 말한다.


바이트는 바이트일 뿐이며 대부분의 경우 텍스트를 클라이언트로 보내는 것은 

파일에 데이터를 써 넣는 것과 크게 다르지 않다.


자바에서 I/O는 스트림(stream)에 내장되어 있다.

입력 스트림은 데이터를 읽고, 출력 스트림은 데이터를 쓴다.


대개 스트림을 생성한 이후에는 지금 읽고 쓰는 대상이 정확히 무엇인지 신경쓰지 않아도 된다.



스트림은 동기(synchronous)로 동작한다.

즉, 프로그램[스레드(thread)]이 데이터를 읽거나 쓰기 위해 스트림에 요청하면,

스트림은 다른 작업을 수행하기 전에 데이터를 읽거나 쓸 수 있을 때까지 기다린다.


채널과 버퍼는 스트림에 의존적이다.



출력 스트림(Output Stream)


자바의 기본 출력 클래스는 java.io.OutputStream이다.


public abstract class OutputStream


이 클래스는 데이터를 쓰는 데 다음과 같은 기반 메소드를 제공한다.


public abstract void write(int b) throws IOException

public void write(byte[] data) throws IOException

public void write(byte[] data, int offset, int length) throws IOException

public void flush() throws IOException

public void close() throws IOException


OutputStream의 서브클래스(subclass)는 특정 매체에 데이터를 쓰기 위해 이 메소드를 사용하게 된다.

이 서브클래스들은 슈퍼클래스(superclass)인 OutputStream을 반환하도록 선언되어 있다.

이것이 바로 다형성(polymorphism)이며, 슈퍼클래스의 사용법만 알고 있다면, 서브클래스를 사용하는 데 전혀 문제가 없다.



OutputStream의 기반 메소드는 write(int b)이다.

이 메소드는 0에서 255까지의 정수를 인자로 받고 이에 대응하는 바이트를 출력 스트림에 쓴다.


이 메소드는 서브클래스에서 목적지에 맞는 특정 매체를 다루기 위해 변경할 수 있도록 추상메소드로 선언되어 있다.


write(int b) 메소드는 int타입을 매개변수로 받지만, 실제로는 부호없는 바이트를 사용한다.

자바는 부호없는(unsigned) 바이트 타입을 지원하지 않기 때문에 여기에서는 int 타입이 대신 사용되었다.


부호 없는 바이트와 부호 있는 바이트는 단지 값을 해석하는 방식만 다르다.

두 타입 모두 bit로 구성되어 있고, 

write(int b) 함수를 사용하여 네트워크 연결을 통해 int 타입을 써도 결국 8bit만 전송된다.


만약 0에서 255의 범위를 벗어난 int타입의 값이 write(int b) 메소드에 전달되면,

int 타입의 최하위 비트(least significant byte)가 쓰이고 나머지 3바이트는 무시된다.

(int 타입을 byte 타입으로 캐스팅하면서 이러한 효과가 발생한다.)


종종 서드파티(third-party)클래스를 사용할 때 0~255범위를 벗어난 값을 쓰면, IllegalArgumentException 예외가 발생하거나 항상 255가 써 지는 버그가 발견된다. 이를 피하기 위해서는 가능한한 0~255의 범위를 벗어난 정숫값을 쓰지 않도록 주의하는 것이 좋다.



한 번에 한 바이트씩 출력하는 방식은 매우 비효율적이다.

예를 들어, 네트워크로 쓸 경우, 네트워크로 전송되는 모든 TCP 세그먼트는 라우팅과 오류정정을 위해 최소 40바이트 이상의 추가적인 데이터를 포함하고 있다.


각 바이트를 개별로 전송하면 실제로 보내야하는 데이터의 크기보다 41배나 많은 데이터가 네트워크로 전송된다.

여기에 호스트-투-네트워크 계층 프로토콜의 부담까지 추가되면 실제 전송 데이터는 훨씬 커진다.


이러한 문제를 해결하기 위해 대부분의 TCP/IP구현은 어느정도의 데이터를 버퍼링(Buffering) 하여 전송한다.

즉, 메모리에 데이터를 쌓아두고, 일정 수치에 도달하거나 특정 시간을 초과할 경우 데이터를 전송한다.



스트림은 또한 네트워크 하드웨어가 아닌 자바 코드 내에서 직접 버퍼링 할 수 있다.


일반적으로 스트림 아래에 BufferedOutputStream이나 BufferedWriter를 연결하여 버퍼링이 가능해진다.

이러한 버퍼링을 사용할 때에 중요한 것이 바로 플러시(flush)이다.


만약에 출력 스트림이 1024바이트 크기의 버퍼를 가지고 있다면, 출력 스트림은 버퍼가 가득 차지 않았기 때문에 버퍼 안의 데이터를 전송하지 않고 추가적인 데이터가 올 때 까지 기다린다.


서버로부터 응답을 받기 전에 추가로 쓸 데이터가 없다면, 요청은 버퍼에 담긴 채로 전송되지 않기 때문에 결코 서버로부터의 응답이 오지 않을 것이다.


flush()메소드는 버퍼가 아직 가득 차지 않은 상황에서 강제로 버퍼의 내용을 전송함으로써 데드락(deadlock) 상태를 해제한다.


스트림이 버퍼링되는지를 판단하여 플러시를 하는 것 보다 항상 플러시를 하는 것이 좋다.

특정 스트림의 참조를 획득하는 방법에 따라 해당 스트림의 버퍼링 여부를 판단하기 어려운 상황도 발생하기 때문이다.

(예를 들어, System.out은 우리의 의도와는 상관없이 버퍼링을 한다.)


플러시가 필요 없는 스트림에 불필요한 플러시가 발생할 수도 있지만, 이 때 발생하는 시스템 부하는 무시해도 되는 수준이다.


그리고 다 쓴 스트림은 닫기 전에 항상 플러시 해야 하며, 그렇지 않은 경우 스트림이 닫힐 때 버퍼 안에 남아 있는 데이터가 손실 될 수 있다.


마지막으로, 스트림 사용이 끝나면 해당 스트림의 close() 메소드를 호출하여 스트림을 닫는다.

close() 메소드는 파일 핸들 또는 포트 같은 해당 스트림과 관련된 리소스를 해제한다.


장시간 실행 중인 프로그램에서 스트림을 닫지 않을 경우, 파일 핸들, 네트워크 포트 또는 다양한 리소스에서 누수(leak)가 발생한다.


자바 7에서는 인스턴스를 정리하기 위한 좀 더 깔끔한 방법으로 try-with-resources 생성자(constructor)가 추가됐다.


try (OutputStream out = new FileOutputStream("/tmp/data.txt")){

//출력 스트림을 이용한 작업....

} catch (IOException ex) {

System.err.println(ex.getMessage());

}


자바는 try 블록 매개변수 목록에 있는 AutoCloseable 객체의 close()를 자동으로 호출한다.

try-with-resources는 해제(dispose)할 필요가 있는 거의 모든 객체를 포함한 Closeable 인터페이스를 구현한 어떤 객체와도 사용될 수 있다.


입력 스트림(Input Stream)


자바가 제공하는 기본 입력 클래스는 java.io.InputStream이다.


이 클래스는 바이트 데이터를 읽는 데 다음과 같은 기본 메소드를 제공한다.


public abstract int read() throws IOException

public int read(byte[] input) throws IOException

public int read(byte[] input, int offset, int length) throws IOException

public long skip(long n) throws IOException

public int available() throws IOException

public void close() throws IOException


InputStream의 서브클래스는 특정 매체로부터 데이터를 읽기 위해 이 메소드를 사용한다.

java.net.URL의 openStream() 메소드를 포함한 java.net 패키지의 다양한 메소드에 의해 반환되는 값은 InputStream을 반환하도록 선언되어 있고, 구체적인 서브클래스에 대한 인식 없이 동일한 방법으로 위 6개의 메소드를 사용하여 데이터를 읽을 수 있다.


이것도 바로 다형성이 동작하기 때문이다.


서브클래스의 인스턴스는 슈퍼클래스의 인스턴스처럼 투명하게 사용될 수 있으며, 서브클래스에 대한 별다른 지식 없이 사용 할 수 있다.


InputStream의 기본 메소드는 인자가 없는 read()메소드다. 이 메소드는 입력 스트림의 매체로부터 단일 바이트를 읽고, 0에서 255 사이의 값을 int타입으로 반환한다. 그리고 스트림의 끝에 도달할 경우 -1을 반환한다.


read() 메소드는 1바이트도 읽을 것이 없는 경우 프로그램의 실행을 중단하고 기다리며, 이러한 특성 때문에 종종 입출력은 매우 느리고 성능에도 많은 영향을 준다.


속도에 민감한 작업을 처리해야 하는 상황이라면 입출력을 별도의 스레드로 분리하는 것이 좋다.


서브클래스마다 특정 매체를 처리하기 위해 read()메소드를 수정할 필요가 있다.


그래서 read() 메소드는 추상메소드로 선언되어 있다. 예를 들어, ByteArrayInputStream의 read() 메소드는 순수 자바코드를 사용하여 배열로부터 바이트를 복사하도록 구현되고, TelnetStream의 read()메소드는 호스트 플랫폼의 네트워크 인터페이스로부터 데이터를 읽기 위해 네이티브 라이브러리를 사용하여 구현된다.


다음 코드는 InputStream in 에서 10바이트를 읽고, 바이트 배열 input에 저장한다.

그리고 스트림의 끝에 도달하면 루프는 바로 종료된다.


byte[] input = new byte[10];

for (int i = 0; i <input.length; i++) {

input b = in.read()

if (b == -1) break;

input [i] = (byte) b;

}


read()메소드는 비록 바이트값만 읽을 수 있지만 int 타입을 반환한다.

그리고 read()메소드의 반환값을 바이트 배열에 저장하기 전에 byte타입으로 캐스팅한다.


int 타입을 바이트 타입으로 캐스팅하면 0에서 255까지 부호 없는 바이트가 아닌 -128에서 127까지 부호 있는 바이트 값이 생성되며, 부호 없는 바이트 값이 필요한 경우 다음과 같이 변환할 수 있다.


int i = b >= 0 ?b :256+b; //양수일 땐 그대로 b, 음수면 256+b


한 번에 1바이트씩 읽는 것은 한 번에 1바이트씩 쓰는 것과 마찬가지로 매우 비효율적이다.


그 결과, 지정된 스트림으로부터 읽은 다수의 데이터를 배열로 채워 반환하는 

read(byte[] input)read(byte[] input, int offset, int length) 두 개의 오버로드된 메소드가 추가로 존재한다.


첫 번째 메소드는 배열 input 크기만큼 읽기를 시도하며, 

두 번째 메소드는 배열의 offset 위치부터 length 길이만큼 읽기를 시도한다.


이 두 메소드는 배열의 크기만큼 읽어 반환을 시도(attempt)하지만 항상 성공하는 것은 아니다.


흔히 발생하는 경우는 아니지만, 예를 들어 작성한 프로그램이 원격의 서버로부터 데이터를 읽고 있는 동안 ISP에 위치한 스위치 장비의 장애때문에 네트워크가 단절 될 수도 있다. 


이러한 상황에서 읽기는 실패하며 IOException이 발생한다. 


좀 더 일반적인 상황으로는 요청한 데이터의 전체가 아닌 일부만 읽을 수 있는 실패도 성공도 아닌 애매한 상황이 발생하기도 한다.


예를 들어, 네트워크 연결로부터 1024바이트만큼 읽기를 시도했지만, 실제로는 512바이트만 서버로 도착했고 나머지 512바이트는 전송중인 상태가 발생할 수 있다.


전송중인 나머지 512바이트도 결국 도착할 테지만, 지금 이 순간에는 읽을 수 없다. 이런 경우 멀티바이트 read()메소드는 실제로 읽은 바이트 수를 반환한다.


다음 코드를 살펴보자


byte[] input = new byte[1024];

int byteRead = in.read(input);


이 코드는 InputStream in에서 1024바이트만큼 읽어 input 배열에 저장하려고 한다.


그러나 만약 512바이트만 이용 가능한 상황이라면, read 함수는 512바이트만 읽고 반환한다.


실제 읽고자 하는 바이트의 크기가 보장되어야 하는 상황이라면, 배열이 가득 찰 때까지 반복해서 시도하는 루프 안에 read 메소드를 위치시켜 이러한 문제를 해결할 수 있다.


예를 들면, 다음 코드와 같다.


int bytesRead = 0;

int bytesToRead = 1024;

byte[] input = new byte[bytesToRead];

while (bytesRead < bytesToRead) {

bytesRead += in.read(input, bytesRead, bytesToRead - bytesRead);

}


이 방법은 네트워크 스트림에서 읽을 때 자주 사용된다. 물론 파일을 읽을 때에도 사용될 수 있다.


그러나 네트워크를 통한 이동이 CPU 보다 훨씬 느리고 종종 데이터가 모두 도착하기도 전에 네트워크 버퍼를 비우는 프로그램으로 인해 네트워크 스트림에서 좀 더 유용하게 사용된다.


이 두 메소드는 임시적으로 비어있는 열린 네트워크 버퍼에 대해 읽기를 시도할 경우 일반적으로 0을 반환하며, 읽을 데이터는 없지만 스트림은 여전히 열려 있는 상태를 나타낸다.


이러한 동작 방식은 같은 상황에서 스레드의 실행을 중지시키는 단일 바이트 read() 보다 종종 유용하게 사용된다.



이 세가지 모든 read()메소드는 스트림의 끝에 도달할 경우 -1을 반환한다.


아직 읽지 않은 데이터가 남아있는 상태에서 스트림이 종료될 경우 멀티바이트 read() 메소드는 버퍼를 비울 때 까지 데이터를 모두 읽어 반환한다.


그리고 다시 read() 함수를 호출하면 -1을 반환한다.


-1이 반환될 경우 배열에 어떠한 값도 반환되지 않으며, 배열의 값은 호출 전 상태로 남아있게 된다.

위의 예제는 1024바이트 이하의 데이터가 전송되고 스트림이 종료되는 상황을 고려하지 않고 있다.


read()메소드의 반환값을 bytesRead에 더하기 전에 검사하여 이러한 상황을 처리할 수 있다.


다음 코드를 보도록 하자.


int bytesRead = 0;

int bytesToRead = 1024;

byte[] input = new byte[bytesToRead];

while (bytesRead < bytesToRead) {

int result = in.read(input, bytesRead, bytesToRead - bytesRead);

if (result == -1) break; //스트림의 끝

bytesRead += result;

}


필요한 데이터를 모두 읽을 수 있을 때까지 실행이 대기되는 상황을 원하지 않을 경우, 대기없이 즉시 읽을 수 있는 바이트 수를 반환하는 available() 메소드를 사용하여 읽을 바이트 수를 결정할 수 있다.


이 메소드는 읽을 수 있는 최소 바이트 수를 반환한다.


사실 좀 더 많은 바이트를 읽을 수도 있겠지만, 최소한 available() 메소드가 제안하는 만큼은 읽을 수 있음을 의미한다.


다음 예제를 보자.


int bytesAvailable = in.available();

byte[] input = new byte[bytesAvailable];

int bytesRead = in.read(input, 0, bytesAvailable);

//대기 없이 프로그램의 실행을 계속한다.


이 예제에서 bytesRead의 값이 bytesAvailable의 값과 정확히 같다고 기대할 수 있지만 항상 그런 것은 아니다.


스트림의 끝에 이를 경우 available() 메소드는 0을 반환한다.


그러나 일반적으로 read(byte[] input, int offset, int lnegth) 메소드는 스트림의 끝에 이를 경우 -1을 반환하고 읽을 데이터가 없는 경우 0을 반환한다.



종종 일부 데이터를 읽지 않고 건너 뛰어야 할 경우가 있다. 이 때는 skip() 메소드를 사용할 수 있다.


이 메소드는 파일로부터 읽을 때보다 네트워크 연결에 사용할 때 다소 유용하지 못하다.


네트워크 연결은 순차적이며 일반적으로 상당히 느리다.


그러므로 데이터를 건너뛴다고해서 성능에 크게 영향을 주지않는다.


파일을 읽을 때 사용하면 좀 더 유용하긴 하지만, 파일은 네트워크와 달리 임의 위치 접근이 가능하기 때문에 이 경우에도 건너 뛰는 형식보다는 파일 포인터의 위치를 재지정 하는 편이 낫다.



출력 스트림과 마찬가지로, 입력 스트림을 다 쓴 후에는 스트림 자신의 close() 메소드를 호출하여 닫아야한다.


close() 메소드는 파일 핸들 또는 포트 같은 스트림과 관련된 리소스를 해제한다.


입력 스트림을 닫은 후에는 추가적인 읽기 시도가 있는 경우 IOException이 발생한다.



참고


위치표시와 재설정


InputStream은 일반적으로 잘 사용되지 않는 다음 세 가지 메소드를 제공한다.


이 메소드를 사용하여 프로그램은 스트림의 위치를 표시(mark)하거나 이미 읽은 데이터를 다시 읽을 수 있다.


public void mark(int readAheadLimit)

public void reset() throws IOException

public boolean markSupported()


데이터를 다시 읽기 위해, 먼저 mark() 메소드를 사용하여 스트림의 현재 위치를 표시한다.


그리고 나중에 필요한 시점에서 reset() 메소드를 호출하여 표시된 위치로 스트림을 재설정한다.


그 다음에 읽기를 시도하면 표시된 위치에서 읽은 데이터가 반환된다.


물론 스트림의 위치 재설정이 항상 성공하는 것은 아니다.


표시된 위치로부터 읽을 수 있는 바이트 수와 여전히 재설정 가능한지 여부는 mark() 메소드 호출 시 제공한 readAheadLimit 매개변수에 의해 결정된다.


표시된 위치로부터 너무 많이 읽은 후에 재설정을 시도하면 IOExcepiton이 발생한다.


게다가 스트림은 동시에 하나의 위치만 표시할 수 있다. 두 번째 위치를 표시하면 첫 번째 위치는 사라진다.



스트림의 위치표시와 재설정은 일반적으로 표시된 위치에서부터 읽은 모든 데이터를 내부 버퍼에 저장하는 방식으로 구현된다.


그러나 모든 입력 스트림이 이 기능을 지원하는 것은 아니다. 


이 기능을 사용하기 전에 markSupported() 메소드를 사용하여 지원 유무를 확인 해야 한다.


이 기능을 지원하지 않는 입력 스트림에 mark() 메소드를 호출하면 아무런 일도 발생하지 않지만 reset() 메소드는 IOException을 발생시킨다.


java.io 내에서 스트림 위치의 표시를 항상 지원하는 유일한 입력스트림 클래스는 BufferedInputStream과 ByteArrayInputStream이다.