본문 바로가기
Java/Java Basic

[Java Basic] 49 - Java I/O Stream2 - Serialization과 File 입출력 스트림 클래스

by Rosmary 2022. 10. 5.
728x90
반응형





석 달의 대장정 끝에 Java Basic의 마지막 포스팅이다. 마지막 포스팅은 Java 입출력 스트림의 직렬화(Serialization)라는 개념을 중점적으로 살펴보려하고, 추가로 파일 입출력 스트림을 편하게 다룰 수 있는 몇 개의 클래스를 간단하게 소개하려 한다. 바로 시작해보자.



1. 직렬화(Serialization)란,

바로 직전의 포스팅에서, 서로 다른 타입의 정보를 스트림으로 저장하기 위해서 DataInputStream 클래스를, 그리고 스트림으로 저장된 서로 다른 타입의 정보를 DataOutputStream으로 불러들여오는 것을 예시로 나타냈었다. 이 예시를 사용하면, 인스턴스 객체에 저장된 인스턴스 변수 정보만을 별도로 저장할 수 있게 되며, 인스턴스 객체가 사라지더라도 저장된 정보를 호출하여 다시 객체를 만드는 것 또한 가능해진다. 즉, 프로그램이 종료되더라도 저장된 정보를 활용하여 프로그램 종료 전 생성한 인스턴스 객체를 다시 생성할 수 있다는 이야기다.

그런데, 클래스의 인스턴스 변수에 대해 하나씩 직렬화를 진행하려하니 - 필자가 늘상 말하는대로 - 너무 귀찮다. 참고로 필자는 귀차니즘이 매우 심한 편이다. 다행히 Java를 만드신 위대한 분들 역시 귀차니즘이 심한 분들이었는지, 필자와 같은 생각을 가지고 인스턴스 객체의 인스턴스 변수만 스트림으로 변환할 수 있는 기능을 추가해놓았다. 이렇게 객체 내 인스턴스 변수를 스트림으로 저장할 수 있도록 하는 방법을 직렬화, Seirialization이라고 한다. 반대로 스트림 정보를 원래 정보로 되돌리는 과정은 역직렬화하고 한다.

직렬화는 객체의 인스턴스 변수 정보만 스트림으로 변환하는데, 그 이유는 인스턴스 객체가 생성되면 메모리(정확히는 Heap이라는 구역)에 올라가는 내용은 인스턴스 변수 뿐인데. 직렬화와 관련된 클래스는 Heap에 올라온 객체 정보만을 참조하여 스트림으로 변환하기 때문이다.

그럼, 직렬화는 어떻게 진행하는 것일까?



2. 직렬화(Serialization) 방법

직렬화 방법은 크게 어렵지 않다. 직렬화를 진행할 클래스는 java.io 패키지에 존재하는 Serializable 인터페이스를 구현한 뒤, main 매서드에서 ObjectOutputStream 보조 스트림을 사용하는 OutputStream을 적용하면 된다.

필자는 어떤 그룹의 회원들에 대한 정보를 스트림으로 저장하고자 한다. 따라서 아래와 같이 Member라는 이름의 클래스를 Serializable 인터페이스를 구현하여 생성했다.

toString() 반환값은 바로 뒤에서 확인할 역직렬화 시 표현할 내용으로 작성했다.


main 매서드는 Object를 스트림 형태로 변환하여 그 내용을 파일로 변환하기 위해 FileOutputStream, ObjectOutputStream 클래스의 인스턴스를 만들어준다. ObjectOutputStream은 DataOutputStream과 동일하게 스트림으로 변환하고자 하는 기본형 타입을 지정할 수도 있고, 추가로 Object에 대한 스트림 변환도 매서드(writeObject())로 지원한다.



코드를 실행하면 프로젝트 폴더 내에 members.txt라는 파일이 만들어지는데, 이 파일을 열어보면 각각의 Member 객체가 스트림 형태로 저장되어 있음을 확인할 수 있다.



이번에는 스트림으로 저장된 정보를 역직렬화를 진행함으로써 원래 정보로 표시해보자. OutputStream 대신 InputStream으로 스트림 클래스 인스턴스를 생성하고, readObject를 writeObject 객체 호출 순서대로 진행하면 된다. 단, readObject는 반환값이 Object 클래스 객체기 때문에, 반드시 형변환을 통해 출력을 원하는 객체 타입으로 변환해주어야 한다.



직렬화는 객체의 모든 인스턴스 변수를 스트림으로 변환하기 때문에 원치 않는 정보, 예를 들면 위의 예시에서는 몸무게까지 스트림에 저장된다는 문제가 있다. 의외로 이 문제는 키워드 하나를 적용하면 간단히 해결된다. 바로 transient라는 제어자다. 이 제어자가 적용된 인스턴스 변수는 Object 직렬화에도 스트림으로 저장되지 않게 된다.만약 필자가 weight 변수에 transient를 적용하고 스트림으로 저장된 정보를 다시 역직렬화로 확인해보면, 몸무게 정보는 모두 기본 타입의 초기값으로 표시되는 것을 확인할 수 있다.




이번에는 조금 특이한 상황을 만들어보자. Member 객체를 직렬화, 역직렬화하는 주 클래스 파일에 매서드로 만들고(귀찮다면 직렬화, 역직렬화 코드에 각각 주석처리를 진행하자), 직렬화만 먼저 진행해보자.



직렬화 내용이 파일에 작성된 것이 확인되면, Member 클래스 인스턴스 변수 weight에 적용된 transient를 제거해보자. 그리고 main() 매서드에 역직렬화 코드만 작성해서 실행하면 아래와 같이 에러가 발생할 것이다.

글씨가 조금 작게 표시되는데, mySerialization() 매서드 대신 myReverseSerialization() 매서드를 사용했다.


invalidClassException이라는 예외가 발생하는데, 이는 Stream으로 파일에 저장한 객체의 클래스와 파일로부터 불러들인 정보를 변환하는 과정에서 사용한 클래스가 서로 달라 발생한 일이다. 필자는 단지 transient 제어자를 변수에서 제거한 것 밖에 하지 않았지만, 스트림에 저장된 정보가 가지는 객체는 변경 전의 내용으로 저장되었기 때문이다.

Serializable 인터페이스는 텅텅 비어 있는 인터페이스다. 하지만 이 인터페이스를 구현하면 클래스는 serialVersionUID라는 값을 가지게 되는데, 클래스 내용의 일부가 변경되면 이 값 역시 변하게 된다. 직렬화와 역직렬화는 진행 전에 참조할 클래스를 serialVersionUID를 참고하게 되며, 직렬화 전과 후의 값이 다른 경우 변환이 불가능하게 된다. 위의 예시 역시, 기존에 스트림을 진행한 클래스의 serialVersionUID 값이 8849486182156832966지만, 현재 로컬에 위치한 클래스는 serialVersionUID가 -1420631705569630842이기 때문에 변환이 불가능하다고 에러를 발생시킨 것이다.

따라서 내용이 자주 변하지만 Stream으로 전환이 반드시 필요한 클래스의 경우, static final long serialVersionUID로 버전 값을 수동 지정해주어야 한다.



위의 예시에서 Object로 객체 정보를 스트림으로 전환할 때, 객체의 인스턴스 변수 값이 모두 포함되게 된다. transient를 사용하여 원하지 않는 정보를 제외하고 스트림으로 전환하는 방법도 있지만, Serializable을 구현한 클래스 내에 직접 readObject() 매서드와 writeObject() 매서드를 구현하면, 원하는 정보만 스트림으로 변환하는 것이 가능해진다.


readObject(), writeObject()는 별도로 정의할 경우 private 제어자를 붙여야하며, main에서 정의한 ObjectIntputStream / OutputStream이 각 매서드의 매개인자로 동작한다. 위의 예시를 보면 writeObject에서 객체 정보 중 회원번호(memberNo), 이름(name) 및 나이(age) 정보만 스트림으로 변환하도록 정의하였기 때문에 members.txt 파일 역시 해당 내용만 스트림으로 변환된 것이 확인된다. readObject() 매서드 역시 읽어들이는 정보를 writeObject()와 동일하게 설정하였기 때문에, 최종적으로 화면에 나타나는 값은 memberNo, name, age만 표시된다.




3. RandomAccessFile, File 클래스

파일의 경우 입력과 출력이 매우 빈번하게 일어난다. 따라서 입력 스트림과 출력스트림 객체를 따로 만들어야하는 FileInputStream과 FileOutputStream은 아무래도 사용이 불편할 수 밖에 없다. 이러한 이유로 나온 클래스가 RandomAccessFile 클래스이며, 이 클래스는 내부에 입/출력 스트림을 모두 가지고 있다.



RandomAccessFile은 생성자로 파일명과 모드(String 객체)를 받는다. 여기서 모드는 - Python의 File 클래스를 사용하셨던 분들이라면 매우 익숙한 - 읽기 및 쓰기 여부를 지정하는 것으로 읽기와 쓰기 모두 진행할 경우 "rw", 읽기만 진행할 경우 "r"로 표시한다.



RandomAccessFile의 데이터를 읽고 쓰는 매서드는 여타 Stream 클래스와 별반 다르지 않지만, 파일 내부의 정보를 참조할 때 FilePointer라는 개념이 들어가서 사용이 조금 어렵다.

필자가 RandomAccessFile로 읽고 쓰기가 가능한 test.txt라는 파일을 하나 만들고, 여기에 문자열을 저장해보려한다. 한 줄 씩 문자열을 저장하는데, 저장이 끝나면 RandomAccessFile의 getFilePointer() 매서드로 포인터가 어떻게 변하는지 확인해보려한다.



RandomAccessFile 내 FilePointer의 값은 입력한 자료형 크기와 길이에 비례하여 증가하고 있음을 알 수 있다. write() 매서드로 계속해서 연달아 파일의 내용을 작성할 수 있는 이유도, FilePointer가 현재 가리키는 지점이 마지막이기 때문이다. 이 포인터의 위치를 조정할 수 있는 seek()이라는 매서드가 있는데, 매개인자로 Pointer 번호를 기입하면, FilePointer가 그 위치로 이동한다. 만약 필자가 Hello. My name is Java\n 바로 아랫줄에 Nice to Meet You를 입력하고자 한다면 raf.seek(29)로 이동한 뒤, writeUTF(), writeChars() 등의 매서드로 값을 입력하면 된다.



출력 역시 마찬가지다. 만약 FilePointer가 마지막 위치에 존재하는데, readUTF()나 readLine() 등의 읽기 매서드를 호출하면 각각 null 값이 반환되거나, EOF(End of File) Exception 오류가 발생한다.



따라서 첫 내용부터 출력하기 위해 FilePointer의 위치를 0으로 재지정해주어야 한다.




파일에 대한 직접적인 내용의 쓰기와 읽기를 RandomAccessFile로 한 번에 진행한다면, 파일의 속성 확인 및 변경 등은 File 클래스를 이용한다. File이라는 이름 때문에 File만 제어한다고 여기기 쉽지만, 실제로는 폴더도 제어가 가능하기 때문에 특정 폴더 내에 있는 파일 목록을 추출한다던가, 원하는 경로(폴더)에 파일을 만들고 지우거나 읽기 쓰기 권한을 변경하는 등의 작업도 File로 가능하다.




사용에 있어 크게 어려운 매서드는 없어서 별도의 설명은 생략한다.




이 포스팅을 마지막으로 Java의 기본적인 사용법과 관련된 내용은 여기서 마무리한다.



Fin.

반응형

댓글