C++에서 배우는 자바-I/O 입출력

생각보다 단원의 분량이 많아서 좀 놀랐다. 입출력이면 그냥 cin cout만 알고 있었는데 java에서는 상황에 따라, 자료형에 따라 수많은 입출력 방식을 가지고 있었다. 이제부터 하나씩 알아보도록 하자.

스트림

저번 포스팅에서 배웠던 ‘람다와 스트림’의 그 스트림과는 이름은 같지만 다른 개념이다. I/O 입출력에서 스트림은 데이터를 운반하는데 사용되는 연결 통로이다.
스트림은 단방향으로 전달되므로 입력과 출력을 동시에 하기 위해선 입력 스트림, 출력 스트림을 각각 가지고 있어야한다. JAVA는 자료형에 따라, 전달하는 데이터 단위에 따라, 입력인지 출력인지에 따라, 많은 스트림을 지원한다.

바이트 기반 스트림

바이트 단위로 데이터를 전달하는 스트림이며 다음과 같은 종류들이 있다.

  • FileInputStream, FileOutputStream : 파일 입출력
  • ByteArrayInputStream, ByteArrayOutputStream : 메모리(byte 배열) 입출력
  • PipedInputStream, PipedOutputStream : 프로세스간의 통신
  • AudioInputStream, AudioOutputStream : 오디오장치의 입출력

JAVA의 자료구조들이 Collection 클래스의 자손으로써 여러 메서드와 특징을 공유하듯이, 이들은 모두 InputStream과 OutputStream의 자손들로써 사용하는데 필요한 추상메서드를 자신에 맞게 다 구현해놓았다.
예를 들면 InputStream의 abstract int read(), OutputStream의 abstract void write()가 있는데, 입출력 대상에 따라 내부 구조가 달라지므로 각각의 자손들이 알맞게 구현하도록 추상 메서드로 정의되어 있다.

보조스트림

쓰레드 단원에서 배운 데몬 쓰레드와 같이, 입출력 기능은 없지만 스트림의 기능을 향상시키거나, 새로운 기능을 추가해주는 스트림이다. 예를 하나 들어보자.
FileInputStream은 바이트 단위 스트림이다. 이 스트림의 성능을 향상시키기 위해 버퍼가 내장된 BufferedInputStream을 사용하려 한다. 이 경우 보조 스트림은 그 자체만으론 입출력이 불가능하기 때문에 스트림을 먼저 생성한 후 그를 이용하여 보조 스트림을 생성해야 한다.

  
FileInputStream fis = new FileInputStream("text.txt");
BufferedInputStream bis=new BufferedInputStream(fis);
bis.read();

위와 같이 사용하면 될 것이다. BufferedInputStream 또한 타고올라가면 InputStream의 자손이기 때문에 사용 방법이 같다.

문자 기반 스트림

지금까지 알아본 바이트 기반 스트림은 입출력의 단위가 1byte였다. 하지만 C++과 달리 JAVA에서는 char가 2바이트이므로 1바이트 단위의 스트림으로 처리하기엔 어려움이 있다.
이를 보완하기 위해 문자 기반의 스트림이 제공된다. 문자를 입출력할 때엔 문자 기반 스트림을 이용하는 것이 좋다. 문자 기반 스트림의 사용은 아주 간단하다. InputStream, OutputStream 대신에 Reader, Writer를 사용하면 된다. 예를 들어 위의 FileInputStream은 FileReader로, FileOutputStream은 FileWriter로 치환하면 되는 식이다.
문자 기반 스트림도 보조 스트림이 존재하고 같은 방법으로 사용할 수 있다. BufferedInputStream은 BufferedReader로 사용이 가능하다. 문자 기반 스트림은 내부 구조가 조금 다르지만 기본적으로 바이트 단위 스트림과 활용방법이 동일하다.

바이트 기반 스트림의 사용

바이트 기반 스트림, 즉 InputStream과 OutputStream의 중요 메서드들은 다음과 같다.

InputStream

  • int available() : 스트림으로 읽어올 수 있는 데이터의 크기를 반환한다.
  • void close() : 스트림을 닫고 사용하고 있던 자원을 반환한다.
  • void mark(int bytelimit) : 현재 위치를 저장하고 reset()으로 다시 돌아올 수 있다. bytelimit는 되돌아갈 수 있는 바이트의 수이다.
  • abstract int read() : 1byte를 읽어오고 더이상 읽을 것이 없다면 -1을 반환한다. 추상 메서드이므로 자손들이 그에맞게 내부를 구현해야 한다.
  • int read(byte[] b) : 배열 b의 크기만큼 데이터를 읽고 배열에 저장한 후 읽어온 데이터의 수를 반환한다.

OutputStream

  • void close() : 스트림을 닫고 자원을 반환한다.
  • void flush() : 스트림의 버퍼에 있는 모든 내용을 출력한다.
  • abstract void write(int b) : 주어진 값을 출력소스에 출력한다.
  • void write(byte[] b) : 배열 b의 모든 내용을 출력소스에 출력한다.

InputStream과 OutputStream의 자손인 ByteArrayInputStream과 ByteArrayOutputStream을 실제로 사용하며 예시를 들어보자.

  
byte[] insrc={0,1,2,3,4,5,6,7,8,9};
byte[] outsrc=null;

ByteArrayInputStream input=new ByteArrayInputStream(insrc);
ByteArrayOutputStream output=new ByteArrayOutputStream();  

int data=0;  

while((data=input.read())!=-1) {
    output.write(data);
}  
outsrc=output.toByteArray();
System.out.println(outsrc);

read()가 더이상 읽을 것이 없어 -1을 출력할 때까지 output에 write하는 코드이다. 출력 결과 input 그대로 {0,1,2,3,4,5,6,7,8,9}가 나오는 것을 확인할 수 있다. 바이트 기반 스트림은 사용하는 자원이 메모리 뿐이므로 자동으로 가바지 컬렉터에 의해 자원이 반환되기에 close()를 사용하지 않아도 된다.
이러한 방식으로 다른 바이트 기반 스트림도 사용이 가능하다.

바이트 기반의 보조스트림

FilterInputStream과 FilterOutputStream은 InputStream과 OutputStream의 자손이면서 모든 보조스트림의 조상이다. 이 둘의 메서드는 기반스트림의 메서드를 호출할 뿐이므로 그 자체로는 아무런 일을 하지 않는다. 상속받은 자손에서 필요한 메서드를 오버라이딩 해야한다.
보조스트림 중 가장 많이 쓰이고 유용한 것은 Buffered(Input/Output)Stream이다. 한 바이트씩 입출력하는 것보다 버퍼를 이용해 한 번에 여러 바이트를 입출력하는 것이 빠르기 때문이다. 버퍼 크기는 지정해줄 수 있지만 지정하지 않는다면 기본 크기인 8192byte로 초기화된다.
BufferedInputStream은 버퍼 크기만큼을 읽어서 자신의 내부 버퍼에 저장한다. write 출력은 BufferedOutputStream의 버퍼에 저장되고 버퍼가 가득 차면 출력소스에 출력된다.
버퍼가 가득 찼을 때만 출력이 되기 때문에 마지막 부분이 버퍼에 그대로 남은 채로 프로그램이 종료될 수도 있다. 그렇기에 사용 후 close()나 flush()를 이용하여 버퍼에 남은 데이터들을 출력하는 과정을 추가해주어야 한다.

DataInputStream과 DataOutputStream은 각각 FilterInputStream과 FilterOutputStream의 자손이면서 DataInput 인터페이스와 DataOutput 인터페이스를 구현했기 때문에, 데이터를 읽고 쓸 때 byte가 아닌 8가지 기본 자료형의 단위로 데이터를 입출력할 수 있다는 장점이 있다.
DataOutputStream은 각 기본 자료형을 16진수로 나타내어 저장한다. 각 자료형의 크기가 다르므로 읽을 때에는 쓰인 순서에 주의하며 자료형에 맞는 메서드로 읽어주어야 한다.

이외에도 여러 개의 입력 스트림을 하나의 스트림으로 합칠 때 사용하는 SequenceInputStream, 기반 스트림의 데이터를 다양한 형태로 출력할 수 있는 PrintStream 등의 보조 스트림도 있다.

문자 기반 스트림들은 기본적으로 (Input/Output)Stream과 활용방법이 거의 같고, BufferReader는 readLine()을 사용하여 줄 단위로 데이터를 읽을 수 있고 BufferWriter는 newLine()으로 줄바꿈이 가능한 등의 몇몇 추가 기능이 있지만 그때그때 찾아 쓸 수 있는 수준이라 판단하여 따로 기술하지는 않았다.

표준 입출력

표준 입출력콘솔을 통한 데이터의 입력과 출력을 의미한다. JAVA는 표준 입출력을 위해 3가지 입출력 스트림인 System.in, System.out, System.err을 제공하는데, 이들은 자바의 실행과 동시에 자동으로 생성되기 때문에 따로 생성해주지 않아도 사용이 가능하다.
in, out, err는 System 클래스 내부의 클래스 변수인데, 선언부분은 InputStream, PrintStream이지만 실제로는 BufferedInputStream, BufferedOutputStream 인스턴스를 사용한다.
read()를 사용하면 바이트 단위로 입력을 받으므로 enter를 입력했을 때 특수문자들이 들어가는 문제가 있다. 이러한 문제를 방지하기 위해선 BufferReader의 readLine()으로 라인 단위로 읽어오면 된다.
기본적으로 표준 입출력은 콘솔을 대상으로 하지만 아래 메서드들을 이용하여 입출력 대상을 바꿔줄 수도 있다.

  • static void setOut(PrintStream out) : System.out의 출력을 지정된 PrintStream으로 전환
  • static void setErr(PrintStream out) : System.err의 출력을 지정된 PrintStream으로 전환
  • static void setIn(InputStream in) : System.in의 입력을 지정된 InputStream으로 전환

RandomAccessFile

RandomAccessFile 클래스는 DataInput,DataOutput 인터페이스를 모두 구현했기 때문에 하나의 클래스로 읽기와 쓰기가 모두 가능하고, 기본 자료형 단위로 입출력을 하는 것이 가능하다.
하지만 가장 큰 장점은 파일의 어느 위치에서나 읽기/쓰기가 가능하다는 것이다. 다른 입출력 클래스들은 입출력소스에 순차적으로 읽기/쓰기를 하기 때문에 제한적이지만 RandomAccessFile은 그러한 제한이 없다. 내부적으로 파일 포인터를 사용하여 파일 포인터가 위치한 곳에서부터 입출력 작업을 시작하기 때문이다. RandomAccessFile의 중요 메서드들은 다음과 같다.

  • RandomAccessFile((File file/String filename), String mode) : 생성자이고 mode에는 r, rws, rwd, rw만이 지정 가능하다. r은 파일을 읽기만 할 때, rw는 파일에 읽기와 쓰기를 수행할 때, rws와 rwd는 파일에 지연없이 출력되도록 하는데 사용된다.
  • long getFilePointer() : 현재 파일포인터의 위치를 알려준다.
  • void seek(long pos) : 파일 포인터의 위치를 변경한다. 파일의 첫 부분에서 pos byte만큼 떨어진 곳으로 변경한다.
  • void setLength(long newLength) : 파일의 크기를 지정한 길이로 변경한다.

File

파일은 기본적이고 가장 많이 사용되는 입출력 대상이다. 자바에서는 File 클래스를 이용하여 파일과 디렉토리를 다룬다. 그만큼 굉장히 다양하게 활용이 가능하고 사용 례가 너무 방대하고 다양하므로 중요한 메서드 몇 가지만 정리하고 넘어가도록 하겠다.

  • File(String filename) : 지정된 이름을 갖는 파일을 위한 File 인스턴스를 생성한다.
  • String getName() : 파일 이름을 반환한다.
  • String getPath() : 파일 경로를 반환한다.
  • boolean delete() : 파일을 삭제한다.
  • int compareTo(File pathname) : 주어진 파일 또는 디렉토리와 비교하여 같으면 0, 아니면 1 또는 -1을 반환한다.
  • String[] list() : 디렉토리의 파일 목록을 반환한다.
  • boolean mkdir() : 파일에 지정된 경로로 디렉토리를 생성한다. 성공하면 true 반환
  • boolean renameTo(File dest) : 지정된 파일로 이름을 변경한다.
  • Path toPath() : 파일을 Path로 변환하여 반환
  • URI toURI() : 파일을 URI로 변환하여 반환

직렬화

Unity를 사용할 때 C#에서 한번 사용해봤던 기능이지만 정확히 무엇인지는 몰랐다. 직렬화(Serialization)은 객체를 스트림에 실을 수 있도록 연속적인 데이터로 변환하는 것을 뜻한다.
객체를 생성하면 객체는 여러 인스턴스 변수로 이루어져 있고, 메서드는 객체에 탑재되지 않는다. 인스턴스마다 같은 내용의 메서드를 메모리를 할당해가며 탑재할 필요가 없기 때문이다.
그러므로 객체를 저장한다는 것은 객체의 모든 인스턴스 변수의 값을 저장한다는 것과 같은 뜻이다. 객체가 기본형만 가지고 있다면 객체를 저장하는 일이 간단하겠지만, 참조형과 배열 등을 가지고 있다면 일이 복잡해질 것이다.
하지만 JAVA에서는 객체를 직렬화/역직렬화가 가능한 ObjectInputStream/ObjectOutputStream을 지원하므로 이를 사용하는 방법만 알면 된다.

ObjectInputStream/ObjectOutputStream

직렬화(스트림에 객체를 출력)할 때에는 ObjectOutputStream을 사용하고, 역직렬화(스트림으로부터 객체를 입력)할 때에는 ObjectInputStream을 사용한다.
ObjectInputStream/ObjectOutputStream는 InputStream, OutputStream을 상속받지만, 기반 스트림을 필요로 하는 보조 스트림이다. 따라서 사용할 때엔

  
FileOutputStream fos = new FileOutputStream("objectfile.ser");
ObjectOutputStream out=new ObjectOutputStream(fos);
out.writeObject(new UserInfo());

위와 같이 사용하면 된다. 위 코드는 UserInfo 객체를 직렬화하여 objectfile.ser이라는 파일에 저장한다. 출력할 스트림을 생성하고 이를 기반으로 하는 ObjectOutputStream을 생성한다.
역직렬화는 위 코드와 달리 입력 스트림을 사용하고 writeObject대신 readObject를 사용하여 데이터를 읽어주면 된다. 대신 readObject()는 반환타입이 Object이므로 객체에 맞게 형변환 해주어야 한다.

직렬화 가능 클래스

위와 같은 방법으로 직렬화가 가능한 클래스를 만들기 위해선 java.io.Serializable 인터페이스를 클래스가 구현하면 된다. 예를 들어 위에서 사용한 UserInfo 클래스를 구현해본다면

  
public class UserInfo 
    implements java.io.Serializable {
        String name;
        String password;
        int age;
}

위와 같이 작성하면 된다. Serialization 인터페이스는 내용물이 텅 빈 마커 인터페이스로써 직렬화를 염두에 두고 작성한 클래스인지 구분하는 척도가 된다.
조상 클래스가 Serializable을 구현했다면 자손은 구현하지 않아도 된다. 하지만 만약 조상 클래스에서는 구현하지 않고 자손 클래스에서 구현했다면 조상 클래스에서 정의한 인스턴스 변수들은 직렬화 대상에서 빠지게 된다.
직렬화 대상은 인스턴스 변수가 아닌 직접 연결된 객체의 타입에 의해서 결정된다. 즉

  
Object obj1=new Object();
Object obj=new String("abc");

위의 obj1은 직렬화가 불가능한 Object 타입의 객체이므로 직렬화가 불가능하지만 아래의 obj는 실제 연결된 객체가 직렬화가 가능한 String 타입이기에 직렬화가 가능하다.

직렬화 클래스의 버전관리

직렬화한 클래스가 내용이 변경되었다면, 즉 버전이 달라졌다면 역직렬화가 실패하고 예외가 발생한다. 객체가 직렬화될 때 자동으로 생성되는 serialVersionUID를 이용하여 클래스간의 버전을 비교하고 다르다면 예외를 발생시키기 때문이다.
이럴 때에는 아래와 같이 serialVersionUID를 직접 초기화 시켜주면 된다.

  
class myData implements java.io.Serializable {
    static final long serialVersionUID=231241512512;
    int value;
}

위와 같이 직접 초기화해준다면 클래스 내부의 변경이 있어 버전이 달라져도 serialVersionUID가 자동으로 바뀌지 않는다.

지금까지 JAVA의 입출력에 대해 알아보았다. 나는 프로젝트를 많이 해보지 않아 입출력에 대해 공부할 일이 없었는데 JAVA를 공부하며 알아갈 일이 생겨 다행인 것 같다. 아마 C++, C#에서도 내가 알지 못했지만 지금까지 공부한 것과 같이 상황 별로 다양한 입출력 방식이 있을 것이다.
다음 포스팅은 내가 잘 모르고, 꼭 필요했던 네트워크 부분에 대해 포스팅할 예정이다.