본문 바로가기

JAVA 스터디

[JAVA] I/O (Input/Output)

I/O란

Input/Output의 줄인 말인 I/O은 입력과 출력을 의미합니다.

 

예를 들어, 우리가 컴퓨터에게 데이터를 입력하기 위해 키보드를 입력하는 것을 Input이라 할 수 있고

입력받은 데이터를 적절하게 처리하여 화면에 출력하는 것을 Output이라 할 수 있습니다.

 

자바에서 콘솔 창에 데이터를 출력할 때 System.out.println()을 주로 사용했을 것입니다.

사실 이것은 내부적으로 스트림을 활용한 것입니다.

 

System.out.println()의 경우 매개변수로 PrintStream(데이터를 적절한 문자로 출력하는 문자 기반 스트림)을 받습니다.

그리고 Scanner 클래스 생성 시에 Scanner.in을 받는데, 이게 InputStream 입니다.

 

이러한 동작을 할 수 있도록 도와주는 것이 스트림(Stream), 버퍼(Buffer), 채널(Channel)입니다.

 

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

스트림, 버퍼, 채널은 데이터를 전달하기 위한 일종의 터널 및 저장소라고 할 수 있습니다.

 

스트림(Stream)

스트림이란 데이터가 입력된 순서대로 출력되는 단방향의 터널입니다.

한 방향으로만 통신이 가능하기 때문에 입력과 출력을 동시에 처리할 수는 없습니다.

 

입구는 InputStream이라고 부르고, 출구를 OutputStream이라 합니다.

스트림을 통해 데이터는 byte 또는 byte[] 형태로 전달됩니다.

 

스트림은 *동기적(Asynchronous) 방식*블로킹(Blocking) 방식으로 동작합니다.

데이터를 읽거나 쓰기 위해선 스트림에 해당 동작을 요청하기만 하면 됩니다.

다만, 개발자에 의한 정지 요청이 들어오기 전까지(코드 상 혹은 콘솔 입력 종료 등)

다른 작업을 수행하지 않고 무한정 대기하는 문제가 있습니다.

 

자바에서 모든 기본 I/O는 Stream을 기반으로 하기 때문에 빈번하게 사용되는데,

위처럼 사용을 끝내고 닫아주지 않으면 계속 기다리게 됩니다.

이는 심각한 메모리 누수로 이어지므로 예외처리에 주의를 기울여서 사용해야 합니다.

 

 

버퍼(Buffer)

버퍼는 임시로 데이터를 저장해두는 일종의 큐(Queue)입니다.

 

byte 단위의 데이터가 입력될 때마다 Stream은 즉시 전송하게 되는데

이것은 디스크 접근이나 네트워크 접근 같은 오버헤드(overhead)가 발생합니다.

 

작업이 매우 비효율적이게 되므로 버퍼를 이용해서 입력을 모아 한 번에 출력해줍니다.

덕분에 I/O의 성능을 향상시킬 수 있게 됩니다.

 

// Buffer를 사용하지 않고 출력
public static void nonBufferIO() {
    for(int i = 0; i < 100000; i++) {
        System.out.println(i);            // 입력이 있을 때마다 출력
    }
    System.out.println();
}

// Buffer를 사용하여 출력
public static void bufferIO() {
    StringBuffer sb = new StringBuffer();
    for(int i = 0; i < 100000; i++) {
        sb.append(i);                     // 1. 버퍼에 모두 저장하고
    }
    sb.append("\n");
    System.out.println(sb);               // 2. 출력
}

// 수행속도
nonBufferIO() : 281 (ms)
bufferIO() : 15     (ms)

 

위의 예제는 버퍼의 성능을 확인하기 위한 예제입니다.

0부터 10만까지의 숫자를 출력하게 되는데

nonBufferIO()는 매번 화면에 출력하고, bufferIO()는 모든 입력들을 버퍼에 담은 후 입력이 끝나면 데이터를 출력합니다.

 

밀리 초로 각 메소드의 수행 시간을 측정하면 nonBufferIO()는 281밀리 초, bufferIO()는 15밀리 초로 측정됩니다.

엄청난 속도차가 있음을 확인할 수 있습니다.

 

따라서 버퍼의 장점을 스트림에 적용하여 자바에서는

BufferedInputStream과 BufferedOutputStream을 제공하고 있습니다.

 

채널(Channel)

자바의 기본 입출력인 스트림은 블로킹(Blocking) 방식non-Buffer의 특징으로 인해 입출력 속도가 매우 느렸습니다.

이런 문제를 해결하기 위해서 자바 4부터 NIO(New Input Output)를 java.nio 패키지에 포함되었습니다.

채널이 바로 NIO으 기본 입출력이 되겠습니다.

 

채널은 스트림과 다르게 데이터가 양방향으로 통합니다.

Input/Output을 구분하지 않고, InputStream과 OutputStream을 따로 구현할 필요가 없습니다.

 

채널은 또한 Buffer를 통해서만 read/write를 수행하는 Buffer 방식이며,

Blocking 방식과 *non-Blocking 방식 모두 가능합니다.

non-Blocking 방식 덕분에 과도한 쓰레드 생성을 피하고 쓰레드를 재사용할 수 있어서 효율적입니다.

 

IO vs NIO

non-Blocking 방식으로 인해 NIO가 절대적으로 효율이 좋아 보이지만 꼭 그렇지는 않습니다.

입출력 처리가 길어질수록 쓰레드를 재사용하여 non-Blocking 방식으로 처리하는 NIO는 성능이 저하될 수 있습니다.

NIO에 할당된 버퍼보다 큰 용량의 데이터를 처리할 경우에도 문제가 발생하고,

입출력이 무조건 버퍼를 거쳐야 하므로 즉시 처리되는 IO보다 복잡합니다.

 

정리
NIO
는 불특정 다수의 클라이언트를 연결하거나 하나의 입출력 처리 작업이 오래 걸리지 않는 경우 사용
IO는 연결 클라이언트 수가 적고 전송되는 데이터가 대용량이면서 순차적으로 처리될 필요성이 있는 경우 사용

 

InputStream과 OutputStream

InputStream은 스트림 중 데이터를 read 하는 입구이고, OutputStream은 데이터를 write 하는 출구입니다.

서로 다른 두 클라이언트가 스트림을 사용하면 아래와 같이 진행될 것입니다.

 

 

InputStream은 바이트 기반 입력 스트림의 최상위 클래스로 추상 클래스입니다.

모든 바이트 기반 입력 스트림은 이 클래스를 상속받아서 만들어집니다.

 

파일 데이터를 읽거나 네트워크 소켓을 통해 데이터를 읽거나 키보드에서 입력한 데이터를 읽을 때 사용합니다.

 

InputStream은 읽기에 대한 다양한 추상 메소드를 정의해 두었습니다.

그리고 InputStream의 추상 메소드를 오버라이딩하여 목적에 따라 데이터를 입력받을 수 있습니다.

 

메소드 내용
int available() 현재 읽을 수 있는 바이트 수를 반환함
void close() 현재 열려있는 InputStream을 닫음
void mark( int readlimit ) InputStream에서 현재의 위치를 표시
boolean markSupported() 해당 InputStream에서 mark()로 지정된 지점이 있는지에 대한 여부를 확인
abstract int read() InputStream에서 한 바이트를 읽어서 int type으로 반환함
int read( byte[] b ) byte[] b 만큼의 데이터를 읽어서 b에 저장하고 읽은 바이트 수를 반환함
int read( byte[], int off, int len ) len만큼 데이터를 읽어서 byte[] b의 off 위치에 저장하고 읽은 바이트 수를 반환함
void reset() mark()를 마지막으로 호출한 위치로 이동
long skip( long n ) InputStream에서 n 바이트만큼 데이터를 스킵하고 바이트 수를 반환함

 

OutputStream은 바이트 기반 출력 스트림의 최상위 추상 클래스입니다.

모든 바이트 기반 출력 스트림 클래스는 이 클래스를 상속받아 기능을 재정의 합니다.

 

메소드 내용
void close() OutputStream을 닫음
void flush() 버퍼에 남아있는 출력 스트림을 출력함
void write( byte[] b ) 버퍼의 내용을 출력함
void write( byte[] b, int off, int len) b 배열 안에 있는 시작 off부터 len만큼 출력함
abstract void write( int b ) 정수 b의 하위 1byte를 출력함

 

InputStream과 OutputStream은 스트림을 이해하는 개념적인 의미이기도 하지만

자바에서는 바이트 시퀀스를 읽거나 쓰기 위한 기본 기능을 정의해 놓은 추상 클래스임을 다시 말씀드립니다.

즉, 자바의 모든 바이트 스트림은 InputStream 또는 OutputStream 위에 빌드됩니다.

 

자바에서 제공하는 스트림 클래스들은 다형성을 이용해 스트림을 계층화가 가능하여,

더 큰 데이터 유형 처리와 같은 더 높은 수준의 기능을 제공할 수도 있습니다.

 

Byte와 Character 스트림

프로그램은 Byte 스트림을 사용하여 8-bit byte의 입력 및 출력을 수행합니다.

모든 Byte 스트림 클래스는 InputStream 및 OutputStream을 상속합니다.

 

자바 1.0.2의 InputStream과 OutputStream은 문자열을 읽고 쓰는 메소드를 포함하고 있었지만,

이것은 스트림에서 16-bit 유니코드 문자와 8-bit의 byte가 동등하다고 가정하고 동작했습니다.

그래서 오직 Latin-1(ISO8859-1) 문자에서만 작동했고, 자바 1.1부터 이러한 문제를 해결한

문자 스트림 클래스인 Reader와 Writer가 도입됩니다.

 

InputStreamReader와 OutputStreamWriter가 그것입니다.

이 두 클래스는 Character 스트림의 세계와 Byte 스트림의 세계를 연결해주는 bridge 역할을 해줍니다.

 

 

HDD는 데이터를 byte 단위로 읽습니다.

그래서 사용자가 입력한 문자나 기호들을 컴퓨터가 이용할 수 있게

변환해주는 작업이 필요한데 그것이 바로 인코딩(Incoding)입니다.

그 반대는 디코딩(Decoding)이라고 합니다.

 

InputStreamReader와 OutputStreamWriter는 각각 인코딩과 디코딩을 해주어 bridge 역할을 수행하는 것입니다.

 

public static void main(String[] args) throws IOException {
    try(BufferedReader br = new BufferedReader(new InputStreamReader(System.in))) {
        String s = br.readLine();
        System.out.println(s);
    }
}

 

위의 코드는 문자열을 키보드로부터 입력받아 화면에 출력해주는 코드입니다.

여기에는 아까 말한 스트림의 계층화가 적용되는데 System.in이라는 스트림을 통해

입력된 문자들을 InputStreamReader로 인코딩을 한 번 하고 또 인코딩 된 byte들을

"\n"을 만날 때까지 버퍼에 담아두는 BufferedReader로 감쌌습니다.

 

결국 위의 코드는 BufferedReader가 제공하는 readLine() 하나로 한 줄의 문자열을 입력받아 출력합니다.

 

InputStreamReader와 OutputStreamReader는 인자로 인코딩 방식을 주입할 수 있으며

기본적으로 System의 기본 인코딩 체계를 사용합니다.

 

표준 스트림(System.in, System.out, System.err)

System에는 in, out, err라는 스트림이 일반적으로 입력 및 출력 용도로 사용됩니다.

가장 일반적으로 사용되는 것은 System.out으로 CLI 프로그램을 작성할 때, 콘솔에 출력을 쓰는 데 사용합니다.

 

System.in / System.out / System.err은 JVM이 시작될 때 자바 런타임에 의해  초기화되므로

스트림을 직접 초기화할 필요가 없으며, 런타임에 교체 또한 가능합니다.

 

System.in

System.in은 일반적으로 콘솔 프로그램의 키보드 입력에 연결되는 InputStream입니다.

명령줄에서 자바 애플리케이션을 실행하고 CLI에 포커스가 있는 동안 키보드를 통해서 무언가를 입력하면

해당 애플리케이션은 System.in을 통해서 키보드 입력을 읽을 수 있습니다.

그러나 다른 애플리케이션의 키보드 입력은 읽을 수 없습니다.

 

System.in은 일반적으로 CLI에서만 전달되기 때문에 자주 사용되지 않습니다.

 

System.out

System.out은 문자를 쓸 수 있는 PrintStream입니다.

일반적으로 CLI나 콘솔에 데이터를 출력합니다.

System.out은 실행결과를 사용자에게 표시하는 방법으로 CLI에서 많이 사용됩니다.

또, 디버그를 위한 로그를 출력할 때 자주 사용되곤 합니다.

 

System.err

System.err는 PrintStream이지만 일반적으로 오류 텍스트를 출력하는 데만 사용합니다.

몇몇 프로그램은 이런 표준 에러 출력을 빨간색 텍스트로 표시하여 오류 텍스트임을 보여줍니다.

 

파일 읽고 쓰기

파일 I/O byte 스트림인 FileInputStream과 FileOutputStream을 예제로 들어보겠습니다.

 

아래의 예제는 Byte 스트림을 사용해서 한 번에 1byte씩

inTest.txt에 있는 데이터를 outTest.txt로 복사하는 예제입니다.

 

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;

public class CopyBytes {

    // I/O 예외를 처리하기 위해 throws IOException
    public static void main(String[] args) throws IOException {
        FileInputStream in = null;
        FileOutputStream out = null;
        
        try {
            in = new FileInputStream("inTest.txt");       // Input Stream 설정
            out = new FileOutputStream("outTest.txt");    // Output Stream 설정
            int c;                                        // read한 데이터를 저장할 임시 변수
            
            // inTest.txt 파일에서 더 이상 읽어들일 데이터가 없을 때까지
            // 1byte 단위씩 읽어와서
            while ((c = in.read()) != -1) {
                out.write(c);    // 쓰기
            }
        } finally {                     // 사용이 끝난 Input/Output Stream은 닫아주기
            if (in != null) {
                in.close();
            }
            if (out != null) {
                out.close();
            }
        }
    }
}

 

컴파일 단계에서 Syntex error는 확인되지 않지만, 런타임 단계에서 I/O 예외가 발생할 수 있습니다.

따라서 main() 메소드에서 IOException 예외처리를 적용해 주었습니다. 

 

그리고 꼭 지켜야 할 마지막 단계로 사용이 끝난 스트림은 반드시 닫아줘야 합니다.

스트림을 닫아줌으로써 메모리 누수로 인해 발생하는 프로그램의 심각한 문제를 방지해줍니다.

 

예를 들어, 위의 CopyBytes 예제에서 사용하는 inTest.txt와 outTest.txt 중 하나 또는 둘 모두 열 수 없게 됩니다.

이 경우 파일에 해당하는 스트림 변수는 초기 null 값에서 변경되지 않습니다.

따라서 close()를 호출하기 전에 if 문을 통해 각 스트림 변수에 개체 참조가 포함되어 있는지 확인하는 이유입니다.

 

추가

*동기적(Asynchronous)

동기적(Asynchronous) 방식은 요청과 그 결과가 동시에 일어난다는 뜻입니다.
어떤 객체 또는 함수 내부에서 다른 함수를 호출했을 때 이 함수의 결과를 호출한 쪽에서 처리합니다.
Scanner sc = new Scanner(System.in);
int num = sc.nextInt();

 

사용자가 정수형 데이터를 입력하면(요청) sc.nextInt()은 변수 num에 저장합니다.(결과)

nextInt() 메소드를 호출하고 그 결과를 자신이 직접 처리했으므로 위의 코드는 동기적 방식이라 합니다.

 

*블로킹(Blocking)

블로킹(Blocking)은 자신의 수행 결과가 끝날 때까지 제어권을 보유하는 것을 말합니다.
Scanner sc = new Scanner(System.in);
int num = sc.nextInt();

 

위의 동기 예제 코드에서는 사용자가 입력할 때까지 프로그램은 어떠한 동작도 하지 않습니다.

즉, 사용자가 데이터를 입력할 때까지 프로그램의 제어권을 nextInt()가 쥐고 있게 됩니다.

 

이처럼 수행 결과가 끝날 때까지 프로그램의 제어권을 갖고 있는 것이 blocking 방식입니다.

 

*non-Blocking

non-Blocking은 블로킹과 반대되는 개념으로,

자신의 수행결과가 끝나지 않아도 프로그램의 실행을 일시중지시키지 않는 것을 말합니다.

'JAVA 스터디' 카테고리의 다른 글

[JAVA] 람다식(Lambda)  (0) 2021.08.06
[JAVA] 어노테이션(Annotation)  (0) 2021.07.21
[JAVA] Enum(열거형)  (0) 2021.07.09
[JAVA] 멀티쓰레드 프로그래밍(Multi Thread Programming)  (0) 2021.06.30
[JAVA] 예외처리(Exception)  (0) 2021.06.26