실습 - character stream class
git/eomcs-java-project/mini-pms-31-a
바이너리보다는 포맷이 가독성이 높아지고, 다른 언어와 플랫폼 간에도 호환성이 높아지는 장점이 있기 떄문에 이번에는 바이트 단위가 아닌 캐릭터 단위로 데이터를 파일에 입출력해볼 것이다. 이때 사용되는 data sink stream class는 FileWriter / FileReader이다.
텍스트 포맷과 바이너리 포맷의 차이점
- 가독성
바이너리 포맷은 사람이 보기가 불편하지만 텍스트 포맷은 사람이 직접 보고 편집할 수 있다. - 전용 애플리케이션
바이너리 포맷은 그 포맷을 이해하는 애플리케이션을 이용해야만 읽고 쓸 수 있지만, 텍스트 포맷은 메모장과 같은 텍스트 에디터만 있으면 읽고 쓸 수 있다. - 파일 크기
바이너리 포맷은 텍스트 포맷에 비해 메모리가 절약되며, 데이터 크기를 개발자가 쉽게 규정할 수가 있다.
반면 텍스트 포맷은 데이터를 구분하는 메타 데이터로 인해 파일의 크기가 커질 수 있다. - 이기종 언어, 플랫폼 간 호환성
일반 바이너리 포맷과 택스트 포맷은 모두 이기종 프로그래밍 언어간 교환이 가능하다.
그러나 자바의 serialize 와 같은 특정 포맷, 혹은 특정 어플리케이션 전용 포맷은 다른 언어에서 읽고 쓸 수 없다.
보통 텍스트 포맷이 이기종 플랫폼(OS)이나 애플리케이션 간에 데이터를 교환할 때 주로 사용한다.
예) XML, CSV, HTML 등
훈련 목표
-
바이너리 포맷으로 데이터를 파일에 입출력하던 것을 텍스트 파일(csv)로 바꿔 char 단위로 출력한다.
save 메서드에서 char 단위로 데이터를 파일에 출력할 수 있는 도구 FileWriter 객체를 생성한다. boardList에서 Board 객체를 하나씩 꺼내서 객체의 필드를, 쉼표를 구분자로 하여 한줄씩 출력한다.
private static void saveBoards() {
FileWriter out = null;
try {
out = new FileWriter(boardFile);
for (Board board : boardList) {
String line = String.format("%d,%s,%s,%s,%s,%d\n",
board.getNo(),
board.getTitle(),
board.getContent(),
board.getWriter(),
board.getRegisteredDate(),
board.getViewCount());
out.write(line);
}
System.out.printf("총 %d 개의 게시글 데이터를 저장했습니다.\n", boardList.size());
} catch (IOException e) {
System.out.println("게시글 데이터의 파일 쓰기 중 오류 발생! - " + e.getMessage());
} finally {
try {
out.close();
} catch (IOException e) {
}
}
}
단, write() 메서드를 호출할 때, JVM은 환경 변수 'file.eoncoding'에 설정된 문자집합으로 인코딩하여 바이트 배열을 출력한다. 따라서 JBM을 실행할 떄 다음과 같이 실행하면 환경 변수의 값을 설정할 수 있다.
$ java -d bin/main -Dfile.encoding=UTF-8 ...
또한 이클립스에서 자바 프로그램을 실행하면 위와 같이 JVM 환경 변수가 자동 추가된다. 만약 CLI에서 이 옵션 없이 실행하면 운영체제에 따라서 다음과 같이 자동으로 문자 집합이 설정된다.
- Windows: MS949
- Linux/macOS/Unix: UTF-8
load 메서드에서는 char 단위로 파일을 읽는 FileReader와 파일에서 읽어온 것을 String 으로 변환하여 리턴하는 Scanner 객체를 생성한다. Scanner의 nextLine() 메서드를 호출하여 리턴받은 줄을 구분자 ","로 구별하여 String 배열을 만든다. 그리고 이 요소들을 Board 객체의 필드에 채운 후에 boardList에 추가해주면 된다. while문을 돌리며 계속 한줄씩 읽다가 더이상 읽을 줄이 없으면 NoSuchElementException 예외를 띄우는데 이 예외가 뜰 때 while문을 나간다.
private static void loadBoards() {
Scanner in = null;
try {
in = new Scanner(new FileReader(boardFile));
while (true) {
try {
String line = in.nextLine();
String[] fields = line.split(",");
Board board = new Board();
board.setNo(Integer.parseInt(fields[0]));
board.setTitle(fields[1]);
board.setContent(fields[2]);
board.setWriter(fields[3]);
board.setRegisteredDate(Date.valueOf(fields[4]));
board.setViewCount(Integer.parseInt(fields[5]));
boardList.add(board);
} catch (NoSuchElementException e) {
break;
}
}
System.out.printf("총 %d 개의 게시글 데이터를 로딩했습니다.\n", boardList.size());
} catch (Exception e) {
System.out.println("게시글 파일 읽기 중 오류 발생! - " + e.getMessage());
} finally {
try {
in.close();
} catch (Exception e) {
}
}
}
다른 도메인 객체에 관해서 위와 같은 방식으로 save / load 메서드를 만든다.
실습 2 - BufferdWriter / BufferedReader
git/eomcs-java-project/mini-pms-31-b
캐릭터 단위로 데이터를 입출력하는 도구에도 버퍼를 사용하는 도구가 있는데, 바로 BufferedWriter / BufferedReader이다. 버퍼를 사용하면 한번에 통째로 많은 데이터를 읽어 임시공간에 저장한 후 사용하기 때문에 입출력 시간을 훨씬 단축한다. 따라서 기존에 사용하던 FileWriter / FileReader를 Decorator 패턴으로 BufferedWriter / BufferedReader에 포함시켜 사용할 것이다.
훈련 목표
-
버퍼를 사용하기 위해 FileWriter / FileReader를 포함한 BufferedWriter / BufferedReader를 사용한다.
save 메서드에서는 FileWriter 객체를 포함하는 BufferedWriter를 생성하여 사용하고, 잔여 데이터가 버퍼에 남지 않고, 온전히 출력될 수 있도록 flush() 메서드를 close() 전에 호출한다.
close() 메서드 안에도 flush() 를 호출하지만, close()를 바로 바로 하지 않을 상황을 대비해서 습관화하는 것이 좋다.
private static void saveBoards() {
BufferedWriter out = null;
try {
out = new BufferedWriter(new FileWriter(boardFile));
for (Board board : boardList) {
String record = String.format("%d,%s,%s,%s,%s,%d\n",
board.getNo(),
board.getTitle(),
board.getContent(),
board.getWriter(),
board.getRegisteredDate(),
board.getViewCount());
out.write(record);
}
out.flush();
System.out.printf("총 %d 개의 게시글 데이터를 저장했습니다.\n", boardList.size());
} catch (IOException e) {
System.out.println("게시글 데이터의 파일 쓰기 중 오류 발생! - " + e.getMessage());
} finally {
try {
out.close();
} catch (IOException e) {
}
}
}
load 메서드에서는 기존에 쓰던 Scanner 대신 BufferedReader를 써주면 된다. Scanner에서 nextLine() 기능과 같은 작업을 수행하는 readLine()이 있기 때문이다. 따라서 FileReader를 포함하는 BufferedReader를 생성하여 사용한다. 단, readLine()은 더 이상 읽을 줄이 없을 때 예외를 띄우지 않고 null을 리턴하기 때문에 null을 리턴하면 반복문을 멈춰야한다.
*Scanner는 Decorator와 비슷한 역할을 하지만, Decorator는 아니다. Decorator는 모두 같은 상위 클래스를 갖기 때문이다.
private static void loadBoards() {
BufferedReader in = null;
try {
in = new BufferedReader(new FileReader(boardFile));
while (true) {
String record = in.readLine();
if (record == null)
break;
String[] fields = record.split(",");
Board board = new Board();
board.setNo(Integer.parseInt(fields[0]));
board.setTitle(fields[1]);
board.setContent(fields[2]);
board.setWriter(fields[3]);
board.setRegisteredDate(Date.valueOf(fields[4]));
board.setViewCount(Integer.parseInt(fields[5]));
boardList.add(board);
}
System.out.printf("총 %d 개의 게시글 데이터를 로딩했습니다.\n", boardList.size());
} catch (Exception e) {
System.out.println("게시글 파일 읽기 중 오류 발생! - " + e.getMessage());
} finally {
try {
in.close();
} catch (Exception e) {
}
}
}
다른 도메인 객체에 관해서도 위와 같은 방식으로 save / load 메서드를 수정한다.
실습 3 - 리팩토링
git/eomcs-java-project/mini-pms-31-c
save / load 메서드를 도메인 객체와 상관없이 하나의 메서드로 통일시키기 위해서는, 객체마다 다른 각각의 필드를 String 으로 변환하거나 String을 객체의 필드로 변환하는 코드를 별도의 메서드로 추출(extract method)해주는 것이 추후의 코드의 일반화에 도움이 된다.
훈련 목표
-
save와 load 메서드에서 도메인 객체 <-> String 한줄 변환을 수행하는 코드를 추출하여 각 도메인 클래스 내의 메서드로 정의한다.
save 메서드에서 한 도메인 객체에 대한 필드 값을 한줄의 String으로 변환하는 코드를 추출하고 write 파라미터에 도메인 객체에서 정의할 메서드 toCsvString() 을 호출한다. 다만 toCSvString에서 리턴하는 String 은 줄바꿈 기호를 포함하지 않기 때문에 따로 줄바꿈 기호를 출력하는 write를 호출한다.
private static void saveBoards() {
BufferedWriter out = null;
try {
out = new BufferedWriter(new FileWriter(boardFile));
for (Board board : boardList) {
out.write(board.toCsvString());
out.write("\n");
}
.
.
.
그리고 도메인 클래스에서 toCsvString() 인스턴스 메서드를 정의하고 아까 추출한 코드를 몸체에 넣는다. String 가장 뒤에는 \n을 빼준다.
public String toCsvString() {
return String.format("%d,%s,%s,%s,%s,%d",
this.getNo(),
this.getTitle(),
this.getContent(),
this.getWriter(),
this.getRegisteredDate(),
this.getViewCount());
}
한편, load 메서드에서도 String 한줄 -> 도메인 객체로 변환하는 코드를 추출하고 add() 파라미터 자리에 도메인 클래스에서 정의할 valueOfCsv() 메서드를 호출한다.
private static void loadBoards() {
BufferedReader in = null;
try {
in = new BufferedReader(new FileReader(boardFile));
while (true) {
String record = in.readLine();
if (record == null) {
break;
}
boardList.add(Board.valueOfCsv(record));
}
.
.
.
도메인 클래스에서는 valueOfCsv 스태틱 메서드를 정의하고 추출한 코드를 몸체에 넣어준 후, 초기화된 객체를 리턴한다.
public static Board valueOfCsv(String csv) {
String[] fields = csv.split(",");
Board board = new Board();
board.setNo(Integer.parseInt(fields[0]));
board.setTitle(fields[1]);
board.setContent(fields[2]);
board.setWriter(fields[3]);
board.setRegisteredDate(Date.valueOf(fields[4]));
board.setViewCount(Integer.parseInt(fields[5]));
return board;
}
다른 도메인 객체와 관련된 save / load 메서드도 위와 같은 방식으로 리팩토링한다.
실습 4 - 메서드 레퍼런스
git/eomcs-java-project/mini-pms-31-d
save / load 메서드를 도메인 클래스에 상관없이 객체를 파일에 입출력하는 하나의 메서드로 통일 시키기 위해서 toCsvString 메서드(객체의 필드 값 -> String)를 갖는 인터페이스와 create() 메서드(String -> 객체의 필드 값)를 갖는 인터페이스를 만들어 각각을 save / load 메서드에 사용할 것이다. 만들어지는 인터페이스는 다음과 같다.
- CsvObject : toCsvString()을 갖는 functional interface로, 각 도메인 객체가 이를 구현한다.
- ObjectFactory : create(String) 을 갖는 functional interface로, 도메인 객체가 아닌 익명 클래스로 그때그때마다 구현하여 사용할 것이다. (도메인 객체의 valueOfCsv() 메서드는 스태틱 메서드로, create() 추상 메서드를 구현할 수가 없기 때문이다.) ObjectFactory는 CSV 형식의 String 을 갖고 객체를 생성하기 때문에 ObjectFactory라는 이름을 갖는다.
이렇게 인터페이스를 만들고 이것들의 구현체를 사용하여 메서드를 호출하는 방식으로 saveObjects / loadObjects 메서드를 구현할 수 있다.
훈련 목표
-
toCsvString() 메서드를 갖는 CsvObject 와 create(String) 메서드를 갖는 ObjectFactory 인터페이스를 만든다.
-
CsvObject를 각 도메인 객체가 구현하게 한다.
-
CsvObject, ObjectFactory의 구현체를 사용하여 saveObjects() 메서드와 loadObjects()를 정의한다.
1단계 : toCsvString 메서드를 가진 CsvObject 인터페이스를 정의한다.
public interface CsvObject {
String toCsvString();
}
2단계 : 각 도메인 클래스가 CsvObject를 구현하도록 한 후, toCsvString 이 올바르게 오버라이딩을 하고 있는 지 확인하기 위해 메서드 위에 애노테이션을 추가한다.
public class Board implements CsvObject {
.
.
.
@Override
public String toCsvString() {
// CSV 문자열을 만들 때 줄 바꿈 코드를 붙이지 않는다.
// 줄바꿈 코드는 CSV 문자열을 받아서 사용하는 쪽에서 다룰 문제다.
return String.format("%d,%s,%s,%s,%s,%d",
this.getNo(),
this.getTitle(),
this.getContent(),
this.getWriter(),
this.getRegisteredDate(),
this.getViewCount());
}
}
3단계 : saveObjects 메서드를 정의하고 다음과 같이 몸체를 구현한다.
-
메서드에 CsvObject 구현체에 한정한 타입파라미터를 지정한다.
타입 파라미터는 인터페이스를 구현하든, 클래스를 상속받든 <E extends ___> 로 표현된다. -
메서드의 파라미터로는 boardList, memberList...등을 받을 Collection<T> 객체와 출력하고자 하는 파일을 받을 File 객체를 지정한다.
-
출력할 객체의 타입을 T로 지정 후, 이 객체에 대해 toCsvString 메서드를 호출하여 각 필드의 정보를 String 으로 변환한다.
-
안내문을 변경한다.
private static <T extends CsvObject> void saveObjects(Collection<T> list, File file) {
BufferedWriter out = null;
try {
out = new BufferedWriter(new FileWriter(file));
for (T csvObject : list) {
out.write(csvObject.toCsvString());
out.write("\n");
}
out.flush();
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) {
}
}
}
4단계 : create(String) 메서드를 갖는 ObjectFactory<T> 인터페이스를 정의한다.
public interface ObjectFactory<T> {
T create(String csv);
}
5단계 : loadObjects 메서드를 정의하고 다음과 같이 몸체를 구현한다.
-
메서드에 타입파라미터를 지정한다.
-
메서드의 파라미터로는 boardList, memberList...등을 받을 Collection<T> 객체와 출력하고자 하는 파일을 받을 File 객체, 그리고 String을 T 객체로 변환해줄 ObjectFactory의 구현체를 지정한다.
-
파일을 한줄씩 읽은 String 객체마다 factory의 create 메서드의 파라미터로 주어 T 객체를 리턴받은 후 list에 추가한다.
-
안내문을 변경한다.
private static <T> void loadObjects(Collection<T> list, File file, ObjectFactory<T> factory) {
BufferedReader in = null;
try {
in = new BufferedReader(new FileReader(file));
while (true) {
String record = in.readLine();
if (record == null) {
break;
}
list.add(factory.create(record));
}
System.out.printf("'%s' 파일에서 총 %d 개의 객체를 로딩했습니다.\n", file.getName(), list.size());
} catch (Exception e) {
System.out.printf("'%s' 파일에 객체를 읽기 중 오류 발생! - %s\n", file.getName(), e.getMessage());
} finally {
try {
in.close();
} catch (Exception e) {
}
}
}
6단계 : App 클래스의 main 메서드에서 save(도메인 클래스) / load(도메인 클래스) 메서드를 호출하던 자리에 saveObjects / loadObjects메서드를 호출한다.
public class App {
public static void main(String[] args) {
.
.
.
saveObjects(boardList, boardFile);
saveObjects(memberList, memberFile);
saveObjects(projectList, projectFile);
saveObjects(taskList, taskFile);
}
}
특히 loadObjects를 호출할 때, ObjectFactory 구현체를 받는 파라미터 자리에서 람다를 사용하면, Board의 valueOfCsv 메서드를 통해 리턴 받은 값을 create에서 리턴하는 인터페이스 구현체를 생성하여 넘겨줄 수 있다.
또한 각 도메인 클래스에서 String 타입의 파라미터를 받고 valueOfCsv와 같은 몸체를 구현한 생성자를 정의했다면 valueOfCsv 메서드 대신 생성자를 람다식에 사용할 수 있다.
public class App {
public static void main(String[] args) {
loadObjects(boardList, boardFile, Board::valueOfCsv);
loadObjects(memberList, memberFile, Member::valueOfCsv);
loadObjects(projectList, projectFile, Project::valueOfCsv);
loadObjects(taskList, taskFile, Task::valueOfCsv);
// valueOfCsv 대신 생성자를 만들어 똑같이 몸체를 똑같이 구현해주면 다음과 같이 사용할 수 있다.
loadObjects(boardList, boardFile, Board::new);
loadObjects(memberList, memberFile, Member::new);
loadObjects(projectList, projectFile, Project::new);
loadObjects(taskList, taskFile, Task::new);
.
.
.
}
}
'국비 교육' 카테고리의 다른 글
2020.9.25 일자 수업 : JSON, 네트워크 (0) | 2020.09.26 |
---|---|
2020.9.23 일자 수업 : 파일 입출력 (0) | 2020.09.23 |
2020.9.22 일자 수업 : 데코레이터 패턴, 파일 입출력 (0) | 2020.09.23 |