본문 바로가기

국비 교육

2020.9.22 일자 수업 : 데코레이터 패턴, 파일 입출력

 파일 입출력 

git/eomcs-java-basic/src/main/java com.ecoms.io.ex09

데코레이터 패턴(Decorator Patter)

GoF의 디자인 패턴 중 하나.

  • 객체의 타입과 호출 가능한 메소드를 그대로 유지하면서 객체에 새로운 책임을 추가할 때 사용한다.
  • 탈부착 가능한 책임을 정의할 때 사용한다.
  • 상속을 통해 서브클래스를 계속 만드는 방법이 비효율적일 때 사용한다.
    • 특히 조합되는 경우의 수가 많으면 서브클래스 수가 폭발적으로 늘어날 수 있다.

 

 

 

  • Concrete Component : 완제품으로 객체를 생성하는데 다른 객체를 필요로 하지않고, 포함할 수도 없다. FileInputStream, ByteArrayInputStream 등이 이에 속한다.
  • Decorator : ConcreteComponent의 기능을 확장하는 객체들의 상위 클래스이면서 동시에 추상 클래스이다.
  • Concrete Decorator : 다른 객체들과 자유롭게 연결되어 기능을 확장하는 역할을 한다. 이렇게 하면 ConcreteDecorator는 ConcreteComponent 뿐만 아니라 다른 ConcreteDecorator까지 포함할 수가 있으며 한번에 다중적으로 기능을 확장할 수가 있다. DataInputStream, BufferedInputStream 등이 이에 속한다.

저번 시간에 만든 입출력 도구의 구조는 다음과 같았다.

 

  • 데이터를 읽고 쓰는 도구(InputStream 하위 객체들)
    • FileInputStream / FileOutputStream : 파일 저장소에서 데이터를 읽고 쓰는 도구
    • ByteArrayInpuStream / ByteArrrayOutputStream : 바이트 배열 저장소에서 데이터를 읽고 쓰는 도구
  • 입출력 도구에 기능을 확장하는 도구
    • DataInputStream / DataOutputStream : byte <-> int, long, String(UTF-8), boolean 편리하게 변환하는 기능
    • BufferedInputStream / BufferedOutputStream : 버퍼를 추가하여 데이터 입출력 시간을 크게 줄이는 기능

그런데 이 구조의 단점은 DataInputStream이 BufferedInputStream을 포함하여 두 기능을 동시에 확장하고자 할 때, 

BufferedInputStream은 InputStream의 하위 객체가 아니므로 포함할 수 없다는 점이다.

 

따라서 기능을 확장하는 도구들이 모두 InputStream을 상속받게 할 것이다. 이렇게 하면 모든 기능 확장 도구들이 또 다른 기능 확장 도구를 포함하는 것이 가능해진다. 예를 들어, 입출력 도구 + BufferedInputStream + DataInputStream 이렇게 연결이 가능하고, 추가되는 두 기능을 모두 사용할 수 있다.

import java.io.IOException;
import java.io.InputStream;

public class BufferedInputStream extends InputStream {

  InputStream 연결부품;

  byte[] buf = new byte[8196];
  int size; // 배열에 저장되어 있는 바이트의 수
  int cursor; // 바이트 읽은 배열의 위치

  public BufferedInputStream(InputStream in) {
    연결부품 = in; // 이 객체와 연결될 부품을 파라미터로 받는다.
  }

  @Override
  public int read() throws IOException {
    if (cursor == size) {
      if ((size = 연결부품.read(buf)) == -1) {
        return -1;
      }
      cursor = 0;
    }
    return buf[cursor++] & 0x000000ff;
  }
}
import java.io.IOException;
import java.io.InputStream;

public class DataInputStream extends InputStream {

  InputStream 연결부품;

  public DataInputStream(InputStream in) {
    연결부품 = in; 
  }

  @Override
  public int read() throws IOException {

    return 연결부품.read();
  }

  public String readUTF() throws Exception {

    int size = 연결부품.read();
    byte[] bytes = new byte[size];
    연결부품.read(bytes);
    return new String(bytes, "UTF-8");
  }

  public int readInt() throws Exception {
    int value = 0;

    value = 연결부품.read() << 24;
    value += 연결부품.read() << 16;
    value += 연결부품.read() << 8;
    value += 연결부품.read();
    return value;
  }

  public long readLong() throws Exception {

    long value = 0;
    value += (long) 연결부품.read() << 56;
    value += (long) 연결부품.read() << 48;
    value += (long) 연결부품.read() << 40;
    value += (long) 연결부품.read() << 32;
    value += (long) 연결부품.read() << 24;
    value += (long) 연결부품.read() << 16;
    value += (long) 연결부품.read() << 8;
    value += 연결부품.read();
    return value;
  }

  public boolean readBoolean() throws Exception {

    if (연결부품.read() == 1)
      return true;
    else
      return false;
  }
}


이렇게 기능을 확장하는 객체들이 InputStream을 모두 상속받으면 다음과 같이 다중적인 기능 사용이 가능하다.

  • FileInputStream 객체를 생성
  • BufferedInputStream 을 생성하여 FileInputStream을 포함하게 한다.
  • DataInputStream을 생성하여 BufferedInputStream을 포함하게 한다.

'파일을 입출력하는 도구 + 버퍼 사용 기능 + 데이터 가공 기능'이 완성된다. 이것을 통해서 작업을 모두 수행하고 나면 생성해준 역순으로 close()를 호출한다. 

* close()는 가장 마지막에 생성한 입출력 도구만 호출해도 그것을 포함하는 모든 객체들이 자동으로 닫힌다.

import java.io.FileInputStream;

public class Exam0110 {

  public static void main(String[] args) throws Exception {

    FileInputStream in = new FileInputStream("temp/test4.data");

    BufferedInputStream in2 = new BufferedInputStream(in);

    DataInputStream in3 = new DataInputStream(in2); // OK!

    Member member = new Member();
    member.name = in3.readUTF();
    member.age = in3.readInt();
    member.gender = in3.readBoolean();

    in3.close();
    in2.close();
    in.close();

    System.out.println(member);
  }
}

단, 버퍼 기능과 데이터 가공 기능을 함께 사용하고 싶다면

FileInputStream -> BufferedInputStream -> DataInputStream 순서와 다르게 적용해서는 안된다. 예를 다음과 같이 객체를 연결해주면, BufferedInputStream 객체로 readInt(), readLong(), readUTF() 등의 메서드를 호출할 수가 없다.

import java.io.FileInputStream;

public class Exam0320 {
  
  public static void main(String[] args) throws Exception {
    FileInputStream fileIn = new FileInputStream("temp/test7.data");
    DataInputStream in = new DataInputStream(fileIn);
    BufferedInputStream bufIn = new BufferedInputStream(in);
    
    Member member = new Member();
    
    long start = System.currentTimeMillis();
    
    for (int i = 0; i < 100000; i++) {
      member.name = bufIn.readUTF();
      member.age = bufIn.readInt();
      member.gender = bufIn.readBoolean();
      // 컴파일 에러
    }
    
    long end = System.currentTimeMillis();
    
    System.out.println(end - start);
    
    bufIn.close();
  }
}

Generalization - Decorator 추상 클래스

기존 입출력 도구의 기능을 확장하는 각 객체들의 공통분모를 뽑아서 DecoratorInputStream 추상 클래스를 정의(Generalization)할 수 있으며 이는 InputStream을 상속받는다.

 

이렇게 하면 GoF가 정의한 Decorator 모델과 동일해진다.

  • Component = InputStream

  • ConcreteComponent = fileInputStream / ByteArrayInputStream

  • Decorator = DecoratorInputStream

  • ConcreteDecorator = DataInputStream / BufferedInputStream

 

BufferedInputStream, DataInputStream 두 클래스의 공통 분모는 다음과 같다.

  • 포함할 InputStream 을 저장할 필드
  • 포함할 InputStream을 파라미터로 받아 필드에 저장하는 생성자
  • InputStream에서 오버라이딩하는 read() 메서드
  • close() 메서드

이것을 모두 포함하는 DecoratorInputStream 추상 클래스를 정의하고 InputStream을 상속받게 한다.

import java.io.IOException;
import java.io.InputStream;

public abstract class DecoratorInputStream extends InputStream {
  
  InputStream 연결부품;
  
  public DecoratorInputStream(InputStream in) {
    연결부품 = in;
  }
  

  @Override
  public int read() throws IOException {
    return 연결부품.read();
  }
  
  @Override
  public void close() throws IOException {
    연결부품.close();
  }
}

그리고 BufferedInputStream에서 DecoratorInputStream을 상속받게 한다.

  • InputStream을 저장할 필드 생략한다.
  • 생성자는 상위 클래스의 생성자를 호출한다.
  • 상위 클래스의 read()를 오버라이딩하여 버퍼를 통해 바이트를 읽어들이도록 한다.
import java.io.IOException;
import java.io.InputStream;

public class BufferedInputStream extends DecoratorInputStream {
  
  byte[] buf = new byte[8196];
  int size;
  int cursor;
  
  public BufferedInputStream(InputStream in) {
    super(in);
  }
  
  @Override
  public int read() throws IOException {
    if (cursor == size) {
      if ((size = 연결부품.read(buf)) == -1) {
        return -1;
      }
      cursor = 0;
    }
    return buf[cursor++] & 0x000000ff;
  }
}

그리고 DataInputStream에서 DecoratorInputStream을 상속받게 한다.

  • InputStream을 저장할 필드 생략한다.
  • 생성자는 상위 클래스의 생성자를 호출한다.
  • read() 메서드는 상위 클래스에서 구현하였으므로 생략한다.
import java.io.InputStream;

public class DataInputStream extends DecoratorInputStream {

  public DataInputStream(InputStream in) {
    super(in);
  }

  public String readUTF() throws Exception {
    int size = 연결부품.read();
    byte[] bytes = new byte[size];
    연결부품.read(bytes);
    return new String(bytes, "UTF-8");
  }

  public int readInt() throws Exception {
    int value = 0;

    value = 연결부품.read() << 24;
    value += 연결부품.read() << 16;
    value += 연결부품.read() << 8;
    value += 연결부품.read();
    return value;
  }

  public long readLong() throws Exception {
    long value = 0;
    value += (long) 연결부품.read() << 56;
    value += (long) 연결부품.read() << 48;
    value += (long) 연결부품.read() << 40;
    value += (long) 연결부품.read() << 32;
    value += (long) 연결부품.read() << 24;
    value += (long) 연결부품.read() << 16;
    value += (long) 연결부품.read() << 8;
    value += 연결부품.read();
    return value;
  }

  public boolean readBoolean() throws Exception {
    if (연결부품.read() == 1)
      return true;
    else
      return false;
  }
}

JAVA API 사용

이제는 굳이 직접 우리가 입출력 도구를 만들어서 사용할 필요가 없다. 자바 IO API에는 다음과 같이 분류된 클래스가 있으며, 정확히 우리가 만든 것과 같은 기능을 한다. 

java.io 패키지의 클래스들

  • Byte(Binary) Stream Class
    • Data Sink Stream Class
      • InputStream - 데이터를 읽음
        Subclasses - FileInputStream, ByteArrayInputStream, PipedInputStream
      • OutputStream - 데이터를 저장
        Subclasses - FileOutputStream, ByteArrayOutputStream, PipedOutputStream
    • Data Processing Stream Class (=decorator)
      • FilterInputStream - 데이터를 중간에서 가공하여 읽음
        Subclasses - BufferedInputStream, DataInputStream, ObjectInputStream
      • FilterOutputStream - 데이터를 중간에서 가공하여 출력
        Subclasses - BufferedOutputStream, DataOutputStream, ObjectOutputStream, PrintStream
  • Character Stream Class
    • Data Sink Stream Class
      • Reader - 실제 데이터를 읽는 일을 한다.
        Subclasses - FileReader, CharArrayReader, StringReader, PipedReader
      • Writer - 실제 데이터를 읽는 일을 한다.
        Subclasses - FileWriter, CharArrayWriter, StringWriter, PipedWriter
    • Data Processing Stream Class(=decorator) - 데이터를 중간에서 가공하는 일을 한다.
      • BufferedReader, LineNumberReader
      • BufferedWriter, PrintWriter

ObjectOuputStream / ObjectInputStream

ObjectOuputStream / ObjectInputStream은 객체를 데이터화시켜서 원하는 저장소에 입출력할 수 있는 입출력 도구이다.

 

ObjectOuputStream / ObjectInputStream 의 포함관계는 다음과 같다. 

  • ObjectOutputStream -> BufferedOutputStream -> FileOutputStream
  • ObjectInputStream -> BufferedInputStream -> FileInputStream

따라서 ObjectOutputStream에서는 writeObject() 메서드에서 한 클래스의 인스턴스를 파라미터로 받고 그것을 바이트 배열로 바꾼후 BufferOutputStreamwrite() 메서드의 파라미터로 넣어준다.

 

클래스의 인스턴스 -> byte[] 변환 과정을 serialize(marshalling , 직렬화)라고 부른다.

이런 과정을 거쳐 만들어진 byte 배열에는 다음과 같은 정보들이 들어간다.

  • 클래스 정보 
  • 인스턴스 변수 정보
  • 인스턴스 변수 값

다음과 같이 Member 인스턴스를 writeObject() 의 파라미터로 주면, 인스턴스의 필드 정보들이 바이트로 변환되어 파일에 출력된다.

import java.io.BufferedOutputStream;
import java.io.FileOutputStream;
import java.io.ObjectOutputStream;

public class Exam0310 {

  public static void main(String[] args) throws Exception {
    FileOutputStream fileOut = new FileOutputStream("temp/test10.data");
    BufferedOutputStream bufOut = new BufferedOutputStream(fileOut);
    ObjectOutputStream out = new ObjectOutputStream(bufOut);

    Member member = new Member();
    member.name = "AB가각간";
    member.age = 27;
    member.gender = true;

    out.writeObject(member);

    out.close();

    System.out.println("출력 완료!");
  }
}

 

단, ObjectOutputStream을 통해 Object를 serialize를 할 수 있는 객체는 Serializable 인터페이스의 구현체로 한정된다.

Serializable 인터페이스에는 어떤 추상 메서드도 없다. 그저 Serialize를 할 수 있는 조건을 활성화시키는 역할을 할 뿐이다.

 // 원하는 객체가 Serializable을 구현하지 않으면 다음과 같은 에러가 뜬다.
 java.io.NotSerializableException

반면, ObjectInputStream에서 readObject()를 호출하면, BufferedInputStream에서 read()이 호출되어 byte 배열을 리턴하는 데,  이 byte 배열을 리턴하고자하는 객체의 인스턴스로 바꾸어 리턴한다.

 

이 과정에서 byte[] -> 클래스의 인스턴스 변환 과정을 deserialize(unmarshalling)이라고 한다.

 

다음과 같이 이전에 예제에서 출력한 바이트들을 readObject() 메서드를 통해 다시 읽어서 Member 객체로 받을 수 있다. 다만, 리턴 타입은 Object이므로 원래 형태로 형변환을 해준다. 

 

ObjectInputStream의 readObject()를 통해서 바이트 배열에서 객체 인스턴스로 deserialize되는 과정에서 해당 클래스의 생성자를 호출하지 않는다. 내부적으로 메모리를 만들뿐이다.

import java.io.BufferedInputStream;
import java.io.FileInputStream;
import java.io.ObjectInputStream;

public class Exam0320 {

  public static void main(String[] args) throws Exception {
    FileInputStream fileIn = new FileInputStream("temp/test10.data");
    BufferedInputStream bufIn = new BufferedInputStream(fileIn);
    ObjectInputStream in = new ObjectInputStream(bufIn);

    Member member = (Member) in.readObject();

    in.close();

    System.out.println(member);
  }
}

 

그런데 객체의 인스턴스들을 파일에 출력하고 난 후 클래스의 필드(메서드는 상관없음)에 변화가 생긴 상태에서 파일을 다시 읽으려고 하면 다음과 같은 에러가 뜬다.

java.io.InvalidClassException

deserialize 할 때, 즉 readObject()를 통해 바이트 배열을 읽어 객체를 생성할 때, JVM은 현재 존재하는 클래스와 바이트 배열로 저장된 클래스의 고유번호(Serial Version UID)을 비교하여 같은 클래스인지 검사한다. 

stream classdesc serialVersionUID = 1853598344548890701, local class serialVersionUID = 4300687066866269554
  • stream classdesc serialVersionUID -> 바이트 배열에 저장된 클래스의 고유 번호
  • local class serialVersionUID -> 현재 존재하는 클래스의 고유 번호

Serial Version UID

Serializable 구현체를 정의할 때 변수를 직접 선언하지 않으면 임의의 값을 갖는 채로 자동 추가가 되고, 클래스의 필드가 변경되면 이 변수의 값도 변경된다. 대신 다음과 같은 경고를 띄운다.

The serializable class Member does not declare a static final serialVersionUID field of type long

Serial Version UID를 개발자가 클래스 안에서 명시한다면 필드를 변경해도 같은 클래스로 인식한다. 다음과 같이 Serial Version UID를 명시할 수 있다.

private static final long serialVersionUID = 1280L;

단, 너무 많은 변경사항이 생겨, 이 파일을 읽는 데 부적합하다고 판단되면 Serial Version UID를 변경하여 파일을 읽을 때 에러를 띄우게 한다.

transient modifier

객체의 인스턴스 필드 중에서도 다른 변수 값을 갖고 계산한 결과를 저장하는 변수가 있는데, 이런 변수는 serialize 대상에서 제외되어야 한다. 이 변수의 값은 다른 변수의 값에 변화가 생길 때마다 다시 계산되어야 하는데, 파일 내에서 이 변수에 영향을 주어야하는 다른 변수의 값이 독자적으로 바뀌어 값이 왜곡되는 결과를 막기 위해서이다. 이러한 이유로 serialize 대상에서 제외되어야하는 변수 앞에는 transient modifier(변경자)를 붙이면 자동으로 serialize 대상에서 제외된다.

 

예를 들어, Score라는 클래스 안에는 한 학생의 국영수 점수와 세 과목 점수의 합계, 평균 필드가 있다. 합계와 평균은 국영수 과목을 갖고 계산되는 인스턴스 필드로 serialize 대상에서 제외되어한다. 따라서 두 필드 앞에 transient를 붙인다.

import java.io.Serializable;

public class Score implements Serializable {
  private static final long serialVersionUID = 1L;
  
  String name;
  int kor;
  int eng;
  int math;

  transient int sum;
  transient float aver;

  public void compute() {
    this.sum = this.kor + this.eng + this.math;
    this.aver = this.sum / 3f;
  }

  @Override
  public String toString() {
    return "Score [name=" + name + ", kor=" + kor + ", eng=" + eng + ", math=" + math + ", sum="
        + sum + ", aver=" + aver + "]";
  }
}

이 클래스의 인스턴스를 ObjectOutputStream을 통해서 serialize 한다고 할 때 transient가 붙은 sum과 aver은 serialize 되지 않으며 마찬가지로 파일에도 입력되지 않는다.

import java.io.BufferedOutputStream;
import java.io.FileOutputStream;
import java.io.ObjectOutputStream;

public class Exam0510 {

  public static void main(String[] args) throws Exception {
    FileOutputStream fileOut = new FileOutputStream("temp/test12.data");
    BufferedOutputStream bufOut = new BufferedOutputStream(fileOut);
    ObjectOutputStream out = new ObjectOutputStream(bufOut);

    Score s = new Score();
    s.name = "홍길동";
    s.kor = 99;
    s.eng = 80;
    s.math = 92;
    s.compute();

    out.writeObject(s);

    out.close();
    System.out.println("출력 완료!");
  }
}

파일에 출력된 바이트들을 다시 ObjectInputStream의 readObject()로 읽어오면 다음과 같이 sum 과 aver의 값은 비어있음을 알 수 있다.

import java.io.BufferedInputStream;
import java.io.FileInputStream;
import java.io.ObjectInputStream;

public class Exam0520 {

  public static void main(String[] args) throws Exception {
    FileInputStream fileIn = new FileInputStream("temp/test12.data");
    BufferedInputStream bufIn = new BufferedInputStream(fileIn);
    ObjectInputStream in = new ObjectInputStream(bufIn);

    Score s = (Score) in.readObject();
    
    in.close();
    System.out.println(s);
  }
}
// 결과!
// Score [name=홍길동, kor=99, eng=80, math=92, sum=0, aver=0.0]

 따라서 sum과 aver은 파일에서 객체를 읽어온 후에 직접 각 필드의 값을 계산하는 작업을 수행해줘야 한다.

import java.io.BufferedInputStream;
import java.io.FileInputStream;
import java.io.ObjectInputStream;

public class Exam0520 {

  public static void main(String[] args) throws Exception {
    FileInputStream fileIn = new FileInputStream("temp/test12.data");
    BufferedInputStream bufIn = new BufferedInputStream(fileIn);
    ObjectInputStream in = new ObjectInputStream(bufIn);

    Score s = (Score) in.readObject();
    in.close();

    s.compute();
    System.out.println(s);

  }
}

// 결과!
// Score [name=홍길동, kor=99, eng=80, math=92, sum=271, aver=90.333336]