본문 바로가기

국비 교육

2020.9.23 일자 수업 : 파일 입출력

 실습 - 파일 바이너리 입출력 

 

git/eomcs-java-project/mini-pms-30-a

 

저번 실습에는 파일을 Filewriter와 FileReader와 Scanner를 통해 csv파일에 직접 문자를 입출력했으나, 이번에는 모든 데이터를 바이트 형식으로 입출력해볼 것이다. Data Sink Stream Class 중에서도 바이트를 입출력하는 도구인 FileInputStream과 FileOutputStream을 사용하여 save, load 메서드를 구현할 것이다.

 

프로젝트에서 다루는 boardList, memberList, ProjectList, taskList 에 저장되는 각 인스턴스들의 필드 정보를 .data 파일에 저장하려면 각 필드의 데이터 타입을 고려해서 이것들을 바이트로 적절히 변환해야 한다.

훈련 목표

  • 각 객체에 있는 필드를 데이터 타입에 따라서 적절하게 바이트로 변환하여 출력한다.

  • 파일에 있는 바이트를 읽고 적절한 데이터 타입으로 가공하여 객체의 필드로 채워준다.

boardList에 있는 각 Board 객체의 정보(필드값)를 바이트로 변환하여 boardFile에 출력한다. 각 객체의 필드는 다음과 같다.

  • int no
  • String title
  • String content
  • String writer
  • Date registeredDate
  • int viewCount

각 데이터 타입은 다음과 같은 과정으로 바이트로 변환한다.

  • int - write메서드는 int 값의 가장 끝 8자리만을 출력하므로 원하는 int값의 자릿수를 이동하며 write를 네 번 호출한다.
  • String - String  클래스의 메서드 getBytes()를 통해서 UTF-8 형식의 바이트로 변환한 후, 바이트의 개수(2바이트)와 바이트들을 함께 출력한다.
  • Date - toString() 를 호출하여 String 객체로 변환하고 위의 방법으로 출력한다. (단, 바이트 개수는 10개로 정해진다.)
private static void saveBoards() {
    FileOutputStream out = null;

    try {
      out = new FileOutputStream(boardFile);
      int count = 0;

      for (Board board : boardList) {
        
        out.write(board.getNo() >> 24);
        out.write(board.getNo() >> 16);
        out.write(board.getNo() >> 8);
        out.write(board.getNo());
        
        out.write(board.getTitle().getBytes("UTF-8").length >> 8);
        out.write(board.getTitle().getBytes("UTF-8").length);
        out.write(board.getTitle().getBytes("UTF-8"));
        
        out.write(board.getContent().getBytes("UTF-8").length >> 8);
        out.write(board.getContent().getBytes("UTF-8").length);
        out.write(board.getContent().getBytes("UTF-8"));
        
        out.write(board.getWriter().getBytes("UTF-8").length >> 8);
        out.write(board.getWriter().getBytes("UTF-8").length);
        out.write(board.getWriter().getBytes("UTF-8"));
        
        out.write(board.getRegisteredDate().toString().getBytes("UTF-8"));
        
        out.write(board.getViewCount() >> 24);
        out.write(board.getViewCount() >> 16);
        out.write(board.getViewCount() >> 8);
        out.write(board.getViewCount());
        
        count++;
      }
      System.out.printf("총 %d 개의 게시글 데이터를 저장했습니다.\n", count);

    } catch (IOException e) {
      System.out.println("게시글 데이터의 파일 쓰기 중 오류 발생! - " + e.getMessage());

    } finally {
      try {
        out.close();
      } catch (IOException e) {

      }
    }
  }

그리고 이것을 다시 로딩할 때에도 마찬가지이다. 파일에서 읽어온 바이트들을 각 필드의 데이터 타입에 맞게 가공한 후, 변수에 저장한 board 객체를 boardList에 추가해주면 된다. 각 데이터 타입에 따라 바이트들을 가공하는 과정은 다음과 같다.

  • int - 4개의 바이트를 차례로 하나씩 입력 받으면서 각 바이트의 자릿수를 이동시켜 더한다.
  • String - 처음의 두개의 바이트를 읽어 문자열을 표현하는 바이트 개수를 파악한 후, 그 개수만큼 바이트를 읽어 빈 배열에 저장한다. 그리고 이 배열에서 바이트를 저장한 일부를 파라미터로 주어 String 생성자를 호출한다. byte[] -> String 변환할 때, 따르는 문자 코드는 "UTF-8"이다.
  • Date - 10개의 바이트를 읽어 빈 배열에 저장해준 후 그 배열의 저장된 일부만을 파라미터로 주어 String 생성자를 호출한다. 위와 마찬가지로 변환할 때의 문자 코드는 "UTF-8"이다.

다만, 파일을 다 읽어 더 이상 읽을 바이트가 없다면 read()가 -1을 리턴하므로 한 개의 객체를 다 읽을 때마다 read()를 호출하여 -1인지 확인한다. while()문의 조건식에서 이미 read()를 한 번 호출했으니 그 호출한 값은 while문 블록 안에서 가장 처음 read()를 호출 하는 자리에 대신 넣는다.

  private static void loadBoards() {
    FileInputStream in = null;
    

    try {
      in = new FileInputStream(boardFile);

      int first;

      try {
        while ((first = in.read()) != -1) {
          
          Board board = new Board();
          
          board.setNo(first << 24 | in.read() << 16 | in.read() << 8 | in.read());
          
          byte[] bytes = new byte[30000];
          
          int size = in.read() << 8 | in.read();
          in.read(bytes, 0, size);
          board.setTitle(new String(bytes, 0, size, "UTF-8"));
          
          size = in.read() << 8 | in.read();
          in.read(bytes, 0, size);
          board.setContent(new String(bytes, 0, size, "UTF-8"));
          
          size = in.read() << 8 | in.read();
          in.read(bytes, 0, size);
          board.setWriter(new String(bytes, 0, size, "UTF-8"));
          
          in.read(bytes, 0, 10);
          board.setRegisteredDate(Date.valueOf(new String(bytes, 0, 10, "UTF-8")));
          
          board.setViewCount(in.read() << 24 | in.read() << 16 | in.read() << 8 | in.read());
          
          boardList.add(board);
        }
      } catch (Exception e) {
          
      }
      System.out.printf("총 %d 개의 게시글 데이터를 로딩했습니다.\n", boardList.size());

    } catch (FileNotFoundException e) {
      System.out.println("게시글 파일 읽기 중 오류 발생! - " + e.getMessage());

    } finally {

      try {
        in.close();
      } catch (Exception e) {

      }
    }
  }

 실습 2 - DataInputStream/DataOutputStream 이용 

 

git/eomcs-java-project/mini-pms-30-b

 

위의 실습에서는 우리가 직접 다양한 데이터 타입을 바이트로 변환, 가공해주면서 파일에 입출력했다. 이 번거로운 과정을 DataInputStream/DataOutputStream을 통해 한번에 줄일 수 있다.

 

DataInputStream/ DataOutputStream은 Data processing Stream 클래스이면서 동시에 바이트를 다루는 입출력 도구이다.

  • DataInputStream : 바이트 -> 자바 원시 데이터 타입 + String
  • DataOutputStream : 자바 원시 데이터 타입 + String -> 바이트

훈련 목표

  • 직접 데이터를 가공하여 입출력하던 코드를 DataInputstream/DataOutputStream에서 제공해주는 메서드를 사용하여 간략하게 한다.

필드의 데이터 타입에 따라 DataOutputStream에서 제공해주는 다음과 같은 메서드를 통해 데이터 가공 + 출력을 동시에 한다.

  • writeInt() : int -> byte

  • writeUTF() : String -> byte (UTF-8)

  private static void saveBoards() {
    DataOutputStream out = null;


    try {
      out = new DataOutputStream((new FileOutputStream(boardFile));

      for (Board board : boardList) {
        
        out.writeInt(board.getNo());
        
        out.writeUTF(board.getTitle());
        
        out.writeUTF(board.getContent());
        
        out.writeUTF(board.getWriter());
        
        out.writeUTF(board.getRegisteredDate().toString());
        
        out.writeInt(board.getViewCount());
      }
      
      System.out.printf("총 %d 개의 게시글 데이터를 저장했습니다.\n", boardList.size());

    } catch (IOException e) {
      System.out.println("게시글 데이터의 파일 쓰기 중 오류 발생! - " + e.getMessage());

    } finally {
      try {
        out.close();
      } catch (IOException e) {

      }
    }
  }

필드의 데이터 타입에 따라 DataInputStream에서 제공해주는 다음과 같은 메서드를 통해 데이터 입력 + 가공을 동시에 한다.

  • readInt() : byte -> int 

  • readUTF() : byte (UTF-8) -> String (UCS2)

while 문에서 가장 처음으로 호출되는 readInt() 메서드는 더이상 파일에 읽을 데이터가 없을 경우 예외를 띄우기 때문에 이 예외가 뜰 때 while문을 나갈 수 있도록 한다.

  private static void loadBoards() {
    DataInputStream in = null;
    

    try {
      in = new DataInputStream(new FileInputStream(boardFile));

        while (true) {
          try {
          
          Board board = new Board();
          
          board.setNo(in.readInt());
          
          byte[] bytes = new byte[30000];
          
          board.setTitle(in.readUTF());
          
          board.setContent(in.readUTF());
          
          board.setWriter(in.readUTF());
          
          board.setRegisteredDate(Date.valueOf(in.readUTF()));
          
          board.setViewCount(in.readInt());
          
          boardList.add(board);
          } catch (Exception e) {
            break;
        }
          
      }
      System.out.printf("총 %d 개의 게시글 데이터를 로딩했습니다.\n", boardList.size());

    } catch (FileNotFoundException e) {
      System.out.println("게시글 파일 읽기 중 오류 발생! - " + e.getMessage());
    } finally {
      try {
        in.close();
      } catch (Exception e) {
      }
    }
  }

 실습 3 - BufferedInputStream, BufferedOutputSTream 이용 

 

git/eomcs-java-project/mini-pms-30-c

 

BufferedInputStream과 BufferedOutputStream을 사용하면 버퍼라는 임시 공간을 사용하여 파일을 입출력하기 때문에 규모가 큰 파일을 입출력하는 데 걸리는 시간이 대폭 줄어든다. 따라서 FileOutputStream -> BufferedOutputStream -> DataOutputStream 순으로 포함관계를 갖는 객체들을 이용해 파일을 입출력할 것이다.

 

훈련 목표

  • BufferedOutputStream / BufferedInputStream을 사용하여 파일 입출력 시간을 절약한다.

 

포함관계는 다음과 같다.

  • BufferedInputStream / BufferedOutputStream을 생성하여 FileInputStream / FileOutputStream을 포함하게 한다.

  • DataOutputStream / DataInputStream은 생성된 BufferedInputStream / BufferedOutputStream을 포함하게 한다.

out = new DataOutputStream(new BufferedOutputStream(new FileOutputStream(boardFile)));
in = new DataInputStream(new BufferedInputStream(new FileInputStream(boardFile)));

 

단, 버퍼를 이용하여 파일을 출력할 때에는 버퍼에 잔여 데이터가 남아, 완전히 파일에 출력되지 않는 상황을 방지하기 위해 모든 출력 코드 뒤에는 마지막으로 flush() 메서드를 호출해야 한다.

  private static void saveBoards() {
    DataOutputStream out = null;


    try {
      out = new DataOutputStream(new BufferedOutputStream(new FileOutputStream(boardFile)));

      for (Board board : boardList) {
        
        out.writeInt(board.getNo());
        
        out.writeUTF(board.getTitle());
        
        out.writeUTF(board.getContent());
        
        out.writeUTF(board.getWriter());
        
        out.writeUTF(board.getRegisteredDate().toString());
        
        out.writeInt(board.getViewCount());
        
      }
      
      out.flush()
      
      System.out.printf("총 %d 개의 게시글 데이터를 저장했습니다.\n", boardList.size());

    } catch (IOException e) {
      System.out.println("게시글 데이터의 파일 쓰기 중 오류 발생! - " + e.getMessage());

    } finally {
      try {
        out.close();
      } catch (IOException e) {

      }
    }
  }

 실습 4 - ObjectInputStream, ObjectOutputStream 이용 

 

git/eomcs-java-project/mini-pms-30-d

 

ObjectInputStream / ObjectOutputStream을 사용하면, 굳이 우리가 직접 객체의 필드를 하나하나 입출력 해줄 필요 없이 객체에 대한 정보를 통째로 입출력할 수가 있다. 이 과정에서 객체 정보가 바이트로 변환되는 것을 Serialize / Deseialize 라고 한다. 우리는 이것을 통해 Member, Project, Board, Task 객체를 통째로 입출력할 것이다.

훈련목표

  • 각 도메인 클래스에서 Serializable을 구현하도록 한다.

  • DataInputStrea, DataOutputStream 대신 ObjectInputStream, ObjectOutputStream을 사용한다.

ObjectInputStream / ObjectOutputStream에서 제공하는 메서드로 원하는 객체를 입출력하려면, 해당 클래스가 Serializable 인터페이스를 구현해야한다. 또한 필드를 조금 변경하더라도 JVM이 클래스를 다르게 인식하여 에러를 띄우지 않도록 SerialVersionUID를 명시한다.

public class Board implements Serializable {
  
  private static final long serialVersionUID = 1L;
  .
  .
  .
}

원하는 객체가 Serializable을 구현하도록 설정하였다면 기존에 사용하던 DataOutputStream 대신 ObjectOutputStream을 사용하고, writeObject() 메서드를 호출하여 boardList에 있는 모든 Board 객체에 대한 정보를 파일에 출력한다.

  private static void saveBoards() {
    DataOutputStream out = null;
    try {
      out = new ObjectOutputStream(new BufferedOutputStream(new FileOutputStream(boardFile)));
      for (Board board : boardList) {
        out.writeObject(board);
      }
      out.flush()
      System.out.printf("총 %d 개의 게시글 데이터를 저장했습니다.\n", boardList.size());
    } catch (IOException e) {
      System.out.println("게시글 데이터의 파일 쓰기 중 오류 발생! - " + e.getMessage());
    } finally {
      try {
        out.close();
      } catch (IOException e) {
      }
    }
  }

  private static void loadBoards() {
    DataInputStream in = null;
    try {
      in = new DataInputStream(new FileInputStream(boardFile));
        while (true) {
          try {
          boardList.add((Board) in.readObject());
          } catch (Exception e) {
            break;
        }
      }
      System.out.printf("총 %d 개의 게시글 데이터를 로딩했습니다.\n", boardList.size());
    } catch (FileNotFoundException e) {
      System.out.println("게시글 파일 읽기 중 오류 발생! - " + e.getMessage());
    } finally {
      try {
        in.close();
      } catch (Exception e) {
      }
    }
  }

 실습 5 - 리팩토링 

 

git/eomcs-java-project/mini-pms-30-e

현재 App 클래스에 파일을 입출력하기 위한 메서드는 네 개의 도메인 객체당 두개의 메서드로 총 8개의 메서드가 있다. 사용하고 있는 클래스 타입만 다를 뿐 대부분의 구현된 코드가 비슷하기 때문에 제네릭을 사용하여 모든 도메인 객체를 다룰 수 있는 두 개의 메서드로 줄이고자 한다. 

 

훈련 목표

  • 데이터 출력(저장)에 대한 메서드 : saveBoards, saveMembers, saveProjects, saveTasks -> savsObjects

  • 데이터 입력(로딩)에 대한 메서드 : loadBoards, loadMembers, loadProjects, loadTasks -> loadObjects 

saveObjects 메서드의 구현은 다음과 같다.

  • Serializable 구현체로 한정되는 타입파라미터를 지정한다.

  • 파라미터로 boardList, memberList...등을 받을 Collection<E> 객체와 데이터를 출력할 File 객체를 지정한다.

  • 각 Board, Member, Project, Task 클래스 타입이었던 레퍼런스 변수의 타입을 E로 바꿔준다.

  • 안내 문구를 변경한다.

 private static <E extends Serializable> void saveObjects(Collection<E> list, File file) {
    ObjectOutputStream out = null;


    try {
      out = new ObjectOutputStream(new BufferedOutputStream(new FileOutputStream(file)));

      for (E obj : list) {
        out.writeObject(obj);
      }
      
      System.out.printf("총 %d 개의 데이터를 '%s' 파일에 저장했습니다.\n", list.size(), file.getName());

    } catch (IOException e) {
      System.out.printf("데이터를 '%s'에 쓰 중 오류 발생! - %s\n", file.getName() ,e.getMessage());

    } finally {
      try {
        out.close();
      } catch (IOException e) {
      }
    }
  }

loadObjects 메서드의 구현은 다음과 같다.

  • Serializable 구현체로 한정되는 타입파라미터를 지정한다.

  • 파라미터로 boardList, memberList...등을 받을 Collection<E> 객체와 데이터를 출력할 File 객체를 지정한다.

  • in.readObject() 메서드로 리턴되는 Object 객체를 E로 형변환한다.
  • 안내 문구를 변경한다.

  @SuppressWarnings("unchecked")
  private static <E extends Serializable> void loadObjects(Collection<E> list, File file) {
    ObjectInputStream in = null;
    

    try {
      in = new ObjectInputStream(new BufferedInputStream(new FileInputStream(file)));

        while (true) {
          try {
          
            list.add((E) in.readObject());
          
          } catch (Exception e) {
            break;
        }
          
      }
      System.out.printf("'%s' 파일에서 총 %d 개의 데이터를 로딩했습니다.\n", file.getName(), list.size());

    } catch (IOException e) {
      System.out.printf("데이터를 '%s'에 쓰 중 오류 발생! - %s\n", file.getName(), e.getMessage());
    } finally {
      try {
        in.close();
      } catch (Exception e) {
      }
    }
  }
}

이렇게 메서드를 정의하고 기존의 메서드들은 모두 삭제한다. 그리고 App 클래스의 실행 시작과 끝 지점에서 메서드들을 다음과 같이 호출해준다.

    // App 클래스의 main 메서드 시작 지점
    loadObjects(boardList, boardFile);
    loadObjects(memberList, memberFile);
    loadObjects(projectList, projectFile);
    loadObjects(taskList, taskFile);
    
    // App 클래스의 main 메서드 종료 지점
    saveObjects(boardList, boardFile);
    saveObjects(memberList, memberFile);
    saveObjects(projectList, projectFile);
    saveObjects(taskList, taskFile);