Java

Java의 I/O

dev-rootable 2023. 9. 15. 16:47

📌 입출력이란

 

컴퓨터 내부 또는 외부의 장치와 프로그램 간의 데이터를 주고받는 것

 

📌 스트림(Stream)

 

데이터를 운반하는데 사용되는 연결통로

 

두 대상 사이에서 데이터를 전달하려면 이들을 연결하는 매개체가 필요하다. 이러한 역할을 수행하는 것이 '스트림'이다. 스트림은 연속적인 데이터의 흐름을 물에 비유해서 붙여진 이름으로, 단방향 통신만 가능하다. 즉, 입력과 출력을 동시에 처리할 수 없다. 이를 해결하려면 서로 반대 방향의 스트림 2개(입력, 출력)가 필요하다. 이를 '입력 스트림(input stream)', '출력 스트림(output stream)'이라 한다.

 

Java application과 파일간의 입출력

 

스트림은 큐와 같은 FIFO(First In First Out) 구조로 되어 있으며, 중간에 건너뜀 없이 연속적으로 데이터를 주고받는다.

 

🔎 InputStream과 OutputStream

 

InputStream과 OutputStream은 추상 클래스로, 입출력 대상에 따라 이들을 확장하여 처리하도록 했다.

 

OutputStream 동일

 

이들은 모두 InputStream과 OutputStream의 자손들인데, 각각 읽고 쓰는데 필요한 추상 메서드를 자신에 맞게 구현해 놓았다. 이처럼 자바는 입출력을 처리할 수 있는 표준화된 방법을 제공함으로써 입출력의 대상이 달라져도 동일한 방법으로 입출력이 가능하다.

 

public abstract class InputStream implements Closeable {
    ...
    
    //입력 스트림으로부터 1 byte를 읽어서 반환, 읽을 수 없으면 -1 반환
    public abstract int read() throws IOException;
    
    //입력 스트림에서 byte 배열 b의 크기만큼 데이터를 읽어 버퍼 배열 b에 저장
    public int read(byte b[]) throws IOException {
        return read(b, 0, b.length);
    }
    
    /**
     * 입력 스트림에서 최대 len 길이의 byte를 읽어들임
     * b : 데이터를 읽어들이는 버퍼
     * off : b에 데이터를 쓰기 시작하는 offset
     * len : 읽어들일 데이터의 최대 길이
     */
    public int read(byte b[], int off, int len) throws IOException {
        ...
        for (int i = off; i < off + len; i++) {
            //read()를 호출해서 데이터를 읽어서 배열을 채움
            b[i] = (byte)read();
        }
    }
    
    ...
}

 

위 코드를 보면 read(byte b[])와 read(byte b[], int off, int len)은 모두 추상 메서드 read()를 호출한다는 것을 알 수 있다. 결론적으로 나머지 메서드들은 추상 메서드를 이용하여 구현한 것이므로, 추상 메서드는 반드시 구현되어야 하는 핵심적인 메서드다. 이것은 OutputStream도 마찬가지다.

 

🔎 보조 스트림

 

스트림의 기능을 향상시키거나 새로운 기능을 추가한 스트림으로, 기반 스트림 없이 스스로 입출력을 수행하지는 못한다.

 

FileInputStream fis = new FileInputStream("test.txt"); //기반 스트림

BufferedInputStream bis = new BufferedInputStream(fis); //보조 스트림 생성

bis.read(); //보조 스트림으로부터 데이터를 읽음

 

실제 입력은 FileInputStream이 수행하고, BufferedInputStream은 버퍼만을 제공한다. 버퍼(바이트배열)를 사용한 입출력은 사용하지 않은 것보다 성능차이가 상당하다. 이러한 보조스트림은 모두 InputStream과 OutputStream의 자손들이므로 입출력 방법이 같다.

 

입력 출력 설명
FilterInputStream FilterOutputStream 필터를 이용한 입출력 처리
BufferedInputStream BufferedOutputStream 버퍼를 이용한 입출력 성능 향상
DataInputStream DataOutputStream primitive type으로 데이터를 처리하는 기능
SequenceInputStream 없음 두 개의 스트림을 하나로 연결
LineNumberInputStream 없음 읽어 온 데이터의 라인 번호를 카운트 (JDK1.1부터 LineNumberReader로 대체)
ObjectInputStream ObjectOutputStream 데이터를 객체 단위로 읽고 쓰는데 사용. 주로 파일을 이용하며 객체 직렬화와 관련있음
없음 printStream 버퍼를 이용, 추가적인 print 관련 기능 (print, printf, println)
PushbackInputStream 없음 버퍼를 이용해서 읽어 온 데이터를 다시 되돌리는 기능 (unread, push back to buffer)

 

🔎 문자 기반 스트림

 

앞서 살펴본 스트림은 바이트기반 스트림으로 입출력 단위가 1byte이다. 자바는 한 문자(char형)가 2byte이기 때문에 바이트기반 스트림으로 처리하기 어렵다.

 

이를 위해 문자데이터를 입출력할 때는 문자기반 스트림을 사용한다.

 

InputStream ➡ Reader
OutputStream ➡ Writer

 

문자기반 보조스트림은 위처럼 바이트기반 보조스트림 이름에서 'InputStream' 부분을 'Reader', 'OutputStream' 부분을 'Writer'로 변경하기만 하면 된다.

 

public abstract class Reader implements Readable, Closeable {
    ...
    
    public int read() throws IOException {
        char[] cb = new char[1];
        return this.read(cb, 0, 1) == -1 ? -1 : cb[0];
    }

    public int read(char[] cbuf) throws IOException {
        return this.read(cbuf, 0, cbuf.length);
    }

    public abstract int read(char[] var1, int var2, int var3) throws IOException;

    ...
    
}

 

추상 메서드가 파라미터가 있는 read()인 점이 다르지만, 추상 메서드를 이용하여 나머지 메서드들이 작성된 점은 동일하다. 문자기반 스트림은 이름만 조금 다를 뿐 활용방법은 거의 같다.

 

문자기반 스트림은 단순히 2 byte로 스트림을 처리하는 것만을 의미하지는 않는다. Reader/Writer 그리고 그 자손들은 여러 종류의 인코딩과 자바에서 사용하는 유니코드(UTF-16) 간의 변환을 자동적으로 처리해 준다. Reader는 특정 인코딩을 읽어서 유티코드로 변환하고 Writer는 유니코드를 특정 인코딩으로 변환하여 저장한다.

 

사용하고 닫지 않은 스트림을 JVM이 자동적으로 닫아 주기는 하지만, 스트림을 사용해서 모든 작업을 마치고 난 후에는 close()를 호출해서 반드시 닫아 주어야 한다. 그러나 ByteArrayInputStream과 같이 메모리를 사용하는 스트림과 System.in, System.out과 같은 표준 입출력 스트림은 닫아 주지 않아도 된다.

 

📌 직렬화(Serialization)

 

객체를 데이터 스트림으로 만드는 것

 

객체에 저장된 데이터를 스트림에 쓰기(write) 위해 연속적인(serial) 데이터로 변환하는 것을 말한다.

 

반대로 스트림으로부터 데이터를 읽어서 객체로 만드는 것을 역직렬화(deserialization)라고 한다.

 

객체에는 메서드나 클래스 변수가 포함되지 않는다. 그래서 객체를 저장한다는 것은 바로 객체의 모든 인스턴스 변수의 값을 저장한다는 것과 같은 의미이다. 이처럼 객체를 생성하려면 객체 생성 후 저장했던 값들을 인스턴스 변수에 넣으면 된다. 그런데 인스턴스 변수가 참조형이라면 이렇게 간단하게 처리되지 않는다. 그래서 자바는 이러한 참조형 변수도 직렬화/역직렬화하는 도구를 제공한다.

 

🔎 ObjectInputStream, ObjectOutputStream

 

  • ObjectOutputStream ➡ 직렬화 ➡ 스트림에 객체를 출력(write)
  • ObjectInputStream ➡ 역직렬화 ➡ 스트림으로부터 객체를 입력(read)

 

각각 InputStream과 OutputStream을 직접 상속받지만 기반스트림을 필요로 하는 보조스트림이다. 그래서 객체를 생성할 때 입출력(직렬화/역직렬화)할 스트림을 지정해주어야 한다.

 

ObjectInputStream(InputStream in)
ObjectOutputStream(OutputStream out)

 

만일 파일에 객체를 저장(직렬화)하고 싶다면 다음과 같이 하면 된다.

 

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

out.writeObject(new UserInfo());

 

위 코드는 'objectfile.ser'이라는 파일에 UserInfo 객체를 직렬화하여 저장한다. 출력할 스트림(FileOutputStream)을 생성해서 이를 기반스트림으로 하는 ObjectOutputStream을 생성한다.

 

ObjectOutputStream의 writeObject(Object obj)를 사용하면 객체가 파일에 직렬화되어 저장된다.

 

역직렬화도 위 방법과 비슷하다. 입력 스트림을 사용하고 writeObject 대신 readObject()를 사용하여 저장된 데이터를 읽기만 하면 객체로 역직렬화된다. 다만 readObject()의 반환타입이 Object이기 때문에 객체 원래의 타입으로 형변환 해주어야 한다.

 

FileInputStream fis = new FileInputStream("objectfile.ser");
ObjectInputStream in = new ObjectInputStream(fis);

UserInfo info = (UserInfo) in.readObject();

 

readObject()와 writeObject()를 사용한 자동 직렬화가 편하기는 하지만 직렬화 작업시간이 오래 걸린다. 이를 단축시키려면 직렬화하고자 하는 객체의 클래스에 추가적으로 아래의 2개 메서드를 직접 구현해주어야 한다.

 

private void writeObject(ObjectOutputStream out) throws IOException {
    //write 메서드를 사용해서 직렬화를 수행
}

private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
    //read 메서드를 사용해서 역직렬화를 수행
}

 

🔎 구현

 

Serializable 인터페이스를 구현하면 직렬화가 가능한 클래스가 된다. 이를 상속받은 클래스도 가능하다.

 

public class SuperUserInfo implements Serializable {
    String name;
    String password;
}

public class UserInfo extends SuperUserInfo {
    int age;
}

 

이때, 상위 클래스의 멤버 변수도 직렬화 대상에 포함되는데, 만약 상위 클래스가 Serializable을 구현하지 않았다면 포함되지 않는다.

 

✔ 직렬화가 불가능한 케이스

 

직렬화 대상 클래스더라도 사용하는 클래스가 직렬화가 불가능하다면 NotSerializableException 에러가 발생한다.

 

public class UserInfo implements Serializable {
    String name;
    String password;
    int age;
    Object obj = new Object(); //직렬화 불가
}

 

하지만 아래처럼 타입 변환을 했을 때, 직렬화가 가능한 객체라면 가능하다.

 

public class UserInfo implements Serializable {
    String name;
    String password;
    int age;
    Object obj = new String("abc"); //직렬화 가능
}

 

직렬화는 타입이 아닌 실제로 연결된 객체의 종류에 의해서 결정된다.

 

✔ 직렬화 대상에서 제외하기

 

transient 키워드를 통해 특정 멤버를 직렬화 대상에서 제외시킬 수 있다.

 

보안 목적으로 사용할 수 있다.

 

transient가 붙은 인스턴스변수의 값은 그 타입의 기본값으로 직렬화된다.

 

public class UserInfo implements Serializable {
    String name;
    transient String password; //제외
    int age;
    transient Object obj = new Object(); //제외
}

 

위 경우, obj와 password는 그 타입의 기본값으로 직렬화된다. 즉, 역직렬화했을 때 null 값이 되는 것이다.

 

🔎 직렬화

 

import java.io.Serializable;

public class UserInfo implements Serializable {
    String name;
    String password;
    int age;

    public UserInfo() {
        this("Unknown", "1111", 0);
    }

    public UserInfo(String name, String password, int age) {
        this.name = name;
        this.password = password;
        this.age = age;
    }

    @Override
    public String toString() {
        return "UserInfo{" +
                "name='" + name + '\'' +
                ", password='" + password + '\'' +
                ", age=" + age +
                '}';
    }
}

 

import java.io.*;
import java.util.ArrayList;

public class SerialEx1 {

    public static void main(String[] args) {
        try {
            String fileName = "UserInfo.ser";
            FileOutputStream fos = new FileOutputStream(fileName);
            BufferedOutputStream bos = new BufferedOutputStream(fos);

            ObjectOutputStream out = new ObjectOutputStream(bos);

            UserInfo u1 = new UserInfo("JavaMan", "1234", 30);
            UserInfo u2 = new UserInfo("JavaWoman", "4321", 26);

            ArrayList<UserInfo> list = new ArrayList<>();
            list.add(u1);
            list.add(u2);

            //객체 직렬화
            out.writeObject(u1);
            out.writeObject(u2);
            out.writeObject(list);
            out.close();
            System.out.println("직렬화가 끝났습니다.");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

}

 

생성된 파일

 

🔎 역직렬화

 

import java.io.*;
import java.util.ArrayList;

public class SerialEx2 {

    public static void main(String[] args) {
        try {
            String fileName = "UserInfo.ser";
            FileInputStream fis = new FileInputStream(fileName);
            BufferedInputStream bis = new BufferedInputStream(fis);

            ObjectInputStream in = new ObjectInputStream(bis);

            //객체 읽기 (순서 유지)
            UserInfo u1 = (UserInfo) in.readObject();
            UserInfo u2 = (UserInfo) in.readObject();
            ArrayList list = (ArrayList) in.readObject();

            System.out.println(u1);
            System.out.println(u2);
            System.out.println(list);
            in.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

}

 

(주의!) 객체를 역직렬화할 때는 직렬화할 때의 순서와 일치해야 한다.

 

그래서 직렬화할 객체가 많을 때는 각 객체를 개별적으로 직렬화하는 것보다 ArrayList와 같은 컬렉션에 저장해서 직렬화하는 것이 좋다.

 

 

🔎 직접 직렬화/역직렬화 구현

 

부모 클래스가 직렬화를 구현하지 않았을 경우, 직렬화/역직렬화 메서드를 직접 구현해야 한다.

 

import java.io.*;

class SuperInfo {
    String name;
    String password;

    public SuperInfo() {
        this("Unknown", "1111");
    }

    public SuperInfo(String name, String password) {
        this.name = name;
        this.password = password;
    }
}

public class UserInfo2 extends SuperInfo implements Serializable {

    int age;

    public UserInfo2() {
        this("Unknown", "1111", 0);
    }

    public UserInfo2(String name, String password, int age) {
        super(name, password);
        this.age = age;
    }

    @Override
    public String toString() {
        return "UserInfo2{" +
                "name='" + name + '\'' +
                ", password='" + password + '\'' +
                ", age=" + age +
                '}';
    }

    @Serial
    private void writeObject(ObjectOutputStream out) throws IOException {
        out.writeUTF(name);
        out.writeUTF(password);
        out.defaultWriteObject();
    }

    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        name = in.readUTF();
        password = in.readUTF();
        in.defaultReadObject();
    }
}

 

위와 같이 writeObject()와 readObject()를 추가하여 조상으로부터 상속 받은 변수를 직접 직렬화/역직렬화할 수 있다. 그리고 접근 제어자가 private인 것은 단순히 미리 정해진 규칙일 뿐이다.

 

defaultWriteObject()와 defaultReadObject()는 자신의 인스턴스 변수에 대한 직렬화/역직렬화를 수행한다.

 

🔎 직렬화 버전관리

 

직렬화 대상 클래스에 변경이 발생하면 역직렬화에 실패한다.

 

객체가 직렬화될 때 클래스에 정의된 멤버들의 정보를 이용해서 serialVersionUID라는 클래스의 버전을 자동생성해서 직렬화 내용에 포함된다. 그래서 역직렬화할 때 클래스의 버전을 비교함으로써 직렬화할 때의 클래스의 버전과 일치하는지 확인할 수 있는 것이다.

 

그러나 static 변수나 상수 또는 transient가 붙은 인스턴스 변수는 버전에 영향을 주지 않는다.

 

중요한 것은 네트워크로 객체를 직렬화하여 전송하는 경우, 송신과 수신 측이 모두 같은 버전의 클래스를 가지고 있어야 하는데 클래스가 조금만 변경되어도 해당 클래스를 재배포하는 것은 프로그램을 관리하기 어렵게 한다.

 

✔ 버전 관리

 

그래서 아래와 같이 직접 serialVersionUID를 정의하면 클래스의 내용이 바뀌어도 클래스의 버전이 자동생성된 값으로 변경되지 않는다.

 

class MyData implements Serializable {
    static final long serialVersionUID = 35187312490427502L;
    int value1;
}

 

serialVersionUID는 정수값이면 어떠한 값으로도 지정할 수 있지만 중복을 피하기 위해 serialver.exe를 사용해서 생성된 값을 사용하는 것이 보통이다.

 

Reference: