[2025 백엔드] 헤드퍼스트자바 독서 #10 - 16장. 객체 저장

2025. 8. 17. 01:28·언어공부/Java | Kotlin

그거 아세요? 마치는 아임라이브에 자그마치 3번이나 출연했습니다 대 마 치 를 사 랑 하 세 요

ㅎㅇ

이부분은 GUI 내용이라서 읽을 예정은 아니었는데 하나의 개념은 익혔으면 좋겠어서 14장 일부 내용을 독서해보도록 한다.


내부 클래스

클래스 안에 다른 클래스가 들어갈 수 있다.

 

class OuterClass{
	Integer x;
    
    class InnerClass{
    	void hi() {
        	x = 328;
        }
    }
    
}

이렇게 생겼고

내부클래스는 외부클래스의 인스턴스 변수들과 메서드들을 사용할 수 있다. private로 지정된 것까지 포함해서.

다만 무조건 가능한 것은 아니고 내부 객체, 내부 클래스는 반드시 외부 객체, 외부 클래스와 연결되어야만 한다.

 

같은 인터페이스를 여러 번 구성해야할 때 필요하지만, 때로는 람다로 대체할 수도 있다.


여기서부터 16장

상태 저장하기

상태를 저장해야할 때가 많이 있다. 게임의 상태저장 같을 때

상태를 저장하는 방법은 일반 텍스트 파일로 저장하거나 직렬화를 하는 방법이 있다. 맨날 직렬화니 역직렬화니 이해하기 힘들었는데 여기서 배우는구나

 

직렬화는 객체의 상태를 저장하거나 전송하기 쉬운 형태로 변환하는 것 (바이트로 변환)을 말하며, 이렇게 직렬화된 데이터를 받아서 객체의 상태를 다시 알아내는 것을 역직렬화라고 한다.


또 나오는구나, 스트림(그 스트림 아님)

데이터는 스트림형태로 이동한다. 물론 컬렉션할 때 나온 그 스트림은 아니다.

컴공개 때 마지막 챕터에서 했던 파일을 읽고, 수정하고, 쓰고, 저장하고 관련된 기능 역시 자바 입출력 API에 구현되어 있는데,

 

자바 입출력 API에는 출발지 또는 목적지로의 연결을 나타내는 연결스트림과

다른 스트림에 연쇄되어야만하는 연쇄스트림이 있다.

 

연결스트림은 파일을 불러오고 저장하는 역할을하며, 연쇄스트림은 연결스트림이 전해준 파일데이터의 값을 수정하고 이를 다시 연결스트림에 넘겨 저정하도록 하는 기능을 가진다.

이렇게 연결스트림과 연쇄스트림으로 나뉜 이유는 연결스트림이 매우 저수준이기 때문이다. 또한 한 스트림에 데이터 수정과 값을 저장하는 기능을 모두 욱여넣지 않은 이유는, 객체지향적 관점에서 한 클래스는 하나의 기능만을 처리해야 바람직하기 때문이다. 내가 JWT 기능을 만들면서 JWTProvider가 실제로 JWT토큰을 만들어내지만 서비스 계층에서의 접근은 JWTFilter를 통해서만 가능했던 것처럼..

 

FileOutputStream fileStream = new FileOutputStream("savedStream.ser");
ObjectOutputStream os = new ObjectOutputStream(fileStream);

os.writeObject(object1);
os.writeObject(object2);

os.close();

기본 코드는 이렇다. FileOutputStream이 연결스트림이고, ObjectOutputStream이 연쇄스트림이다. FileOutputStream에서 "savedStream.ser" 파일을 확인하고 존재하지 않으면 자동으로 새로 만든다.

 

ObjectOutputStream은 연쇄스트림이므로 단독으로 쓰일 수 없어 FileOutputStream으로 어느 스트림과 쓰일지 연결해준다. 다른 스트림과 '연쇄'한다고 한다. 연쇄 후에는 object1과 object2를 직렬화하고 savedStream.ser에 저장하고 닫는다.

 

맨 위에 있는 스트림을 닫으면 밑에 있는 스트림도 닫혀서 FileOutputStream도 자동으로 닫힌다.


직렬화란?

일반적으로 힙에 들어있는 객체는 상태가 있다. 직렬화된 객체에는 인스턴스 변숫값이 저장되어 있다. 역직렬화를 통해 힙 안에 이전과 같은 내용을 가진 인스턴스(객체)를 만들 수 있다.

 

class Member {
	String name;
    Integer age;
}

Member member = new Member("MRCH", 28);

이런식으로하면 Member 객체의 레퍼런스 변수인 member에 인스턴스 name과 age이 생기고,

이를 상태를 저장하면서 스트림 형태로 바꾸면 저 인스턴스들이 name = "MRCH", age=28이 스트림의 형태로 전환된다.

 

추가로 Member 객체에서 객체 레퍼런스 인스턴스가 있다고 할 때, Member 객체를 직렬화하면 객체가 참조 중인 객체들이 모두 직렬화된다. Member 객체에서 참조 중인 객체의 Member 객체가 정한 인스턴스 값까지 모두.

 

Serializable 구현하기

직렬화 가능하게 하기 위해서는 Serializable 인터페이스를 구현해야한다.

Serializable 인터페이스는 표지(marker) 또는 태그(tag) 인터페이스라고 부르며, 인터페이스만 있고 정작 구현해야할 메서드는 하나도 없다. 이 인터페이스의 역할은 직렬화 가능한 클래스라는 것만을 명시하면 되기 때문에 메서드가 없다.

 

상속관계의 클래스들에서는 상위클래스 하나만 직렬화 가능하면 하위클래스도 자동으로 직렬화할 수 있다.

 

직렬화하려는 객체의 인스턴스 변수가 어떠한 객체의 레퍼런스 변수라면 그 객체 역시 직렬화가 가능해야한다. 안되면 오류

따라서 협업 등이나 까먹음 등의 이슈로 레퍼런스 객체가 Serializable 하지 않다면 (또는 저장해서는 안되는 데이터라면) transient로 변수를 선언하면 된다. transient String variable; 이런 식으로. 그러면 직렬화 할 때 인스턴스 변수는 건너뛰어진다.

 

transient로 선언된 변수는 프로그램이 종료되면 종료 이전의 의미를 가질 수 없다. 처음부터 새로 만들어야하며, 새로 만들어질 때 기본값 null이 들어간다. 이를 방지하기 위해서는 어떤 기본값을 할당하거나 (객체가 인스턴스 변수에 의존하지 않을 때) 인스턴스 변수의 핵심값을 저장해두고 역직렬화 시에 그 값을 인스턴스에 넣어 새로운 인스턴스를 만드는 방법이 있다. (객체가 인스턴스 변수에 의존 중일 때)

 

직렬화를 할 때 직렬화를 하지 말아야할 특별한 이유가 없다면 하위클래스를 만들어 그 클래스를 직렬화 가능하게 만들고, 상위클래스에서 직렬화하면 안되는 인스턴스만 제외하고 직렬화하게 하면 된다. 이 때 제외된 인스턴스들은 super() 생성자를 통해 상위클래스를 생성하면 (이 때 상위클래스는 직렬화가 가능하지 않다) 상위클래스가 새로이 형성된다.

 

class Member {
	String name;
    Integer age;
    
    //로직
    
}

class MemberForSerial extends Member implements Serializable {
	Integer serialage;
    
    public MemberForSerial(String name, Integer age) {
        super(name);
    	this.serialage = age;
    }
}

이렇게..

직렬화할 인스턴스만 하위클래스의 인스턴스로 따로 설정해서 하위클래스만을 이용해 직렬화시키면 된다.

 

추가로 직렬화된 서로 다른 객체가 같은 객체에 대한 레퍼런스를 가진다면, 직렬화 시 하나만 직렬화되고 역직렬화 할 때 객체 하나에 대한 레퍼런스를 갖게 된다.


역직렬화

FileInputStream fileStream = new FileInputStream("Member.ser");

ObjectInputStream os = new ObjectInputStream(fileStream);

Object m1 = os.readObject();
Object m2 = os.readObject();

Member member1 = (Member) m1;
Member member2 = (Member) m2;

os.close();

직렬화와 비슷한 플로우를 가진다.

Member.ser 파일을 읽어오는데 파일이 없으면 에러가 발생한다. 직렬화 때 사용했던 FileOutputStream과 다르다.

 

readObject() 메서드는 호출 시 그 스트림의 다음 객체를 받아온다. 다만 저장된 갯수보다 더 많이 가져오면 에러가 터진다.

또한 반환 타입은 Object 타입. 사용하려면 캐스팅을 해야한다.

 

역직렬화의 흐름

- 스트림으로부터 객체를 읽어와서

- JVM에서 객체의 정보를 통해 객체의 클래스 타입을 지정하고

- 클래스를 찾아서 불러온다. 이 때 클래스가 없으면 에러

- 힙에 메모리 공간을 할당받는다. (다만 생성자는 질행되지 않는다. 실행되는 순간 초기상태로 돌아가버리니..) -> 이 과정에서 객체의 인스턴스는 값을 갖지 않으면 실행이 불가능하다. 공간만 있는 상태. 레퍼런스 변수에서 생성자를 호출하지 않고 인스턴스 레퍼런스 변수만 선언하는 것과 비슷하다.

- 상속트리에서 직렬화 불가능한 클래스가 있으면 그 클래스의 생성자를 실행시킨다. 그 클래스의 생성자가 실행되면 그 클래스의 상위클래스의 생성자까지 줄줄이 실행된다. 이 과정은 자동이라 막을 수 없다.

- 객체의 인스턴스에 직렬화된 상태값이 대입된다. 이제부터 직렬화된 객체가 역직렬화가 완료되어 사용할 수 있으며, transient로 지정된 변수는 기본값이 주어진다.

 

- 정적 변수는 인스턴스 변수와 달리 '클래스마다 하나씩'을 원칙으로하므로 (인스턴스 변수는 '객체마다 하나씩') 직렬화되지 않는다. 클래스를 불러올 때의 그 클래스에 있는 정적변수를 값으로 가지게 되며, 직렬화할 때는 정적변수에 의존하지 말아야한다.


챙깁시다 버전ID

 직렬화-역직렬화 과정에서 클래스는 해당사항이 없다. 그래서 역직렬화할 때 객체의 클래스 타입까지만 직렬화되고 이를 클래스 타입을 보고 클래스를 불러오는 것. 또한 그 클래스가 없으면 오류가 생긴다.

 

  클래스 정보가 절대적인 것은 아니다. 인스턴스의 타입이 변경되거나 삭제되거나, 수정되는 등의 인스턴스 변수에서의 수정이 생기면 역직렬화 때 오류가 생길 수 있다.

 다만 새로운 인스턴스를 추가하거나, 상속트리에 클래스를 추가하거나, transient로 지정했던 인스턴스를 해제한다던가 하는 등의 방식은 오류가 생기지 않고 그냥 역직렬화 때 기본값이 대입되는 선에서 해결된다.

 

따라서 직렬화-역직렬화 과정에서는 클래스 버전ID 라는 것이 저장되는데, 클래스가 수정되면 버전ID도 수정된다. 역직렬화 과정에서는 버전ID를 대조해 맞지 않으면 역직렬화가 되지 않는다.


버전ID 충돌 해결하기

객체 직렬화 후 클래스를 수정해야할 수 있다면, 클래스의 인스턴스로

static final long serialVersionUID = ~~~;

를 추가해주면 된다. 이렇게하면 클래스가 수정되더라도 같은 값을 가지게 된다.

seriablVersionUID를 알아내기 위해서는 serialver (클래스명)을 사용하여 버전을 알아내면 된다.

 

하지만 이렇게되면 클래스와 직렬화된 객체의 호환성에 상당한 주의를 요한다. 역직렬화가 안될수도 있기 때문에..


String 저장하기

스트림으로 저장할 수는 있지만 자바 이외의 프로그램에서도 직렬화된 데이터를 읽을 수 있도록 일반 텍스트파일로 값을 저장할 수 있어야한다. 이 역시 객체 직렬화와 크게 다르지는 않지만 사용해야할 클래스가 약간 다르다.

 

try{
	FileWriter writer = new FileWriter("Hi.txt");
    
    writer.write("Hi hello!");
    
    writer.close();
} catch (IOException e) {
	e.printStackTrace();
}

입출력 관련코드들은 FileInputStream과 다르게 IOException이 터질 수 있어서 try-catch 문을 추가해주어야 한다.

"Hi.txt" 파일을 불러오거나, 없으면 새로 만들며, String을 인자로 받아서 값을 저장한다.

 

또한 FileWriter는 ObjectOutputStream에 연쇄시키지 않아도 사용할 수 있다.

FileOutputStream fileStream = new FileOutputStream("savedStream.ser");
ObjectOutputStream os = new ObjectOutputStream(fileStream);

os.writeObject(object1);
os.writeObject(object2);

os.close();

ObjectOutputStream과 비교

큰 틀에서는 생긴게 비슷하다.


java.io.File 패키지

java.io.File 패키지는 File 클래스를 지원하는데, java.nio.File 패키지로 현재는 거진 교체되었으나 File 클래스를 쓰는 부분은 많이 볼 수 있다. File 객체는 실제 파일의 내용의 수정 등을 지원하는게 아니라 경로명을 지정하는 역할을 한다. 읽기, 쓰기 등의 기능은 없지만 안전한 파일 표현이 가능하다.

File 객체는 파일 자체를 나타내는 것이 아니라 파일의 주소, 어디에 있는지를 나타내는 것이다.

 

FileWriter나 FileInputStream 등의 파일명을 받는 클래스는 File 객체를 받을 수 있어 File 객체를 전달해도 된다.

 

File file = new File("Members.txt");

File dir = new File("students");
dir.mkdir();

if (dir.isDirectory()) {
	String[] dirContents = dir.list();
    for (String dirContent : dirContents) {
    	System.out.println(dirContent);
    }
}

boolean isDeleted = file.delete();

이런식으로 사용할 수 있는데,

new File을 통해 이미 존재하는 파일을 나타내며, 이 때 생성자로는 파일의 이름을 표현한다.

 

mkdir() 메서드를 통해서 새로운 디렉토리를 만든다. (파일이 아니다!)

디렉토리를 만드는데 성공하면 true, 중복된 이름이 있다거나 하는 등으로 디렉토리를 만드는데 실패하면 false를 반환한다.

File 객체는 '디렉토리'를 포장하는 역할을하고, mkdir()메서드는 디렉토리를 만드는 역할을하므로 File 객체에 무슨 값이 들어가든 (member.txt 같은 파일명이어도 됨) 그 이름으로 된 디렉토리를 만든다.

 

.isDirectory() 메서드는 File 객체가 디렉토리일 경우에 true, 아니면 false를 반환한다.

반대로 파일의 경우에는 .isFile() 메서드를 사용하면 된다.

 

.list() 메서드는 디렉토리일 경우에는 디렉토리 내의 파일과 폴더 이름들을 String 배열로 반환하는 메서드다. 디렉토리가 아닌 폴더일 경우에는 null을 반환한다.

 

delete는 디렉토리를 삭제하며, 성공한 경우에는 true를 반환한다.


버퍼

FileWriter만 가지고도 파일 쓰기작업을 진행할 수 있다. 하지만 문자열 하나하나 전달할 때마다 FileWriter로 파일 열어서 쓰고 닫고를하면 과부하가 생길 수 밖에 없고, 메모리에서 데이터 조작이 디스크 저장보다 훨씬 빠르게 때문에 디스크 저장은 가급적이면 최소한 적게 가져가야하는 편이 좋다.

 

버퍼는 데이터를 저장하는 홀더가 가득찰 때까지 데이터를 보관하다가 한 번에 데이터를 처리하는 역할을 한다. 약간의 임시 저장용 메모리 공간이라고 볼 수 있다.

 

BufferedWriter writer = new BufferedWriter(new FileWriter(aFile));

FileWriter의 레퍼런스를 만들지 않아도 되고, BufferedWriter만 나중에 닫으면 (.close() 메서드를 통해서) 나머지 연쇄스트림들도 알아서 닫힌다

 

이렇게하면 FileWriter의 작업이 BufferedWriter의 버퍼가 가득 찰 때까지 대기하게되고, 버퍼가 찬 후에 쓰기 작업이 진행된다.

물론 .flush() 메서드를 사용하면 강제로 데이터를 보낼 수 있다


텍스트 파일 읽기

File file = new File("member.txt");
FileReader fileReader = new FileReader(file);

BufferedReader reader = new BufferedReader(fileReader);

String line;
while ((line = reader.readLine()) != null) {
	System.out.println(line);
}
reader.close();

이런식으로 기본구성이 이루어지는데,

FileReader 객체를 통해 파일을 읽는다. (BufferedReader를 통해 버퍼 크기만큼 문서에 가져온다. / 버퍼를 사용하지 않으면 fileReader가 글자 하나하나 가져오기 때문에 부하가 걸리기 쉽고 \n 등의 특수기호 등도 따로 처리해야한다. BufferedReader는 버퍼의 메모리를 확인하고 그만큼만 파일에서 긁어오기 때문에 시스템 부하가 적다.)

 

또 BufferedReader는 한 줄 씩 읽는 readLine() 메서드를 지원하기 때문에.. null을 리턴할 때까지 하나씩 읽으면서 내려가면 된다.


NIO.2

자바 7에서 추가된 패키지이다.

컴퓨터의 파일, 디렉토리와 관련된 메타데이터를 조작할 수 있다. 권한 설정이라던지..

 

뿐만 아니라 nio에서 봤던 텍스트 파일 읽기/쓰기와 디렉토리 구조 역시 조작할 수 있다. 다음의 세 유형을 주로 사용한다.

- Path 인터페이스 : 사용할 디렉토리나 파일 위치 지정

- Files 클래스 : Reader, Writer 등 파일 생성 / 수정 / 검색 기능 등을 제공한다.

- Path 클래스 : Files 클래스에 있는 메서드를 이용할 때 Path 객체를 만들기 위해 Paths.get() 메서드를 사용해야함

 

Path path = Path.get("member.txt");
//또는
Path path = Path.get("/members", "student", "member.txt");

BufferedWriter writer = Files.newBufferedWriter(path);

이런식으로 불러올 수 있다.

Path.get() 메서드를 통해서 디렉토리에 접근한다. 이 때 파일명을 순수하게 적을 수도 있고 경로를 지정해야할 경우에는 또는 아래쪽의 메서드 호출부분을 보면 된다. 위 구조는 /members/student/member.txt 파일을 읽어오는 구조다.

 

Path 객체는 파일의 위치를 지정하는 용도로 사용되며, 파일을 가져오거나 하지는 않는다.

 

마지막으로 Files 클래스의 newBufferedWriter 클래스를 호출해 BufferedWriter 인스턴스를 생성한다.

 

이외에도 다양한 메서드들이 있는데, 헤드퍼스트자바 16장 615쪽에 간략하게 나온 코드를 참고하면 된다. 약간 이렇게 할 수 있구나 정도?


TWR 사용해서 마무리처리하기

자바에서 BufferedWriter나 FileWriter 등을 사용할 때 에러가 터질 수 있기 때문에 try-catch 문을 통해서 에러를 잡아내는 과정을 거쳤다. 그런데 try문에서 열린 스트림을 닫아주지만, catch 블록에서 잡아주지 않는다면? 에러가 터지면 스트림이 계속 열려있을 것이다.

 

따라서 finally 블록을 추가해 에러가 터지는 것에 상관없이 스트림을 닫아줄 수 있게해야하나, 스트림을 닫는 것에서 또 에러가 터질 수 있어 이것도 try-catch 문을 써야한다.

 

그러면 try-catch-finally가 있는데 finally 블록 안에 try-catch 문이 또 있는, 매우 복잡하고 어려운 구조가 나온다. 이를 개선하기 위해 나온 것이 TWR (Try-With-Resources) 명령문이다. 자바 7에서 추가되었다.

 

TWR은 try-catch문에서 Autocloseable을 구현하는 타입의 객체를 선언해야한다.

try (BufferedWriter writer =
		new BufferedWriter(new FileWriter(file))) {
        //로직
} catch (Exception ex) {
	//에러처리
}

BufferedWriter 클래스는 Autocloseable을 구현하며, 16장에서 사용한 모든 I/O 클래스들은 이를 구현하며, 거의 모든 입출력 클래스가 이를 지원한다.

try 블록에서 객체를 선언할 때 두 개 이상의 객체를 선언하려면 ';'으로 구분한다.

 

try 블록에서 메서드를 구현하면 끝. 딱히 제약도 없고 평소처럼 구현하면 된다.

TRW에서 선언된 스트림은 자동으로 닫히며, 선언 순서의 역순으로 닫힌다.


알아야할 것

Path 클래스는 위치를 명시해서 포장할 뿐이지 위치를 지정하지는 않는다.

BufferedWriter는 FileWriter에 연쇄시킬 수 있다.


후아...

이번 장은 좀 어렵다고 해야하나?

파이썬을 쓸 때 이 파일 입출력이 좀 어려웠는데, 역시 이부분도 좀 어렵다. 당장 CRUD16이 끝나고 첫 개인프로젝트를 진행할 때 이 입출력 기능도 넣어놓고 연습해야겠다. 내용이 좀 어렵다.

 

아무래도 다시 한 번 읽어봐야겠는걸. 이번에는 노트 같은데에다가 확실히 정리를 해야겠다. 물론 1장부터 다시 돌아가서 할 것이긴 한데... 암튼 내용도 좀 어렵고 지루하고 재미가 없다 16장 끝

'언어공부 > Java | Kotlin' 카테고리의 다른 글

[CS] 자바의정석 독서 #2 - 포매팅  (0) 2025.09.18
[CS] 자바의 정석 독서 #1 - 2장 - 변수  (0) 2025.09.18
[2025 백엔드] 헤드퍼스트자바 독서 #9 - 13장. 위험한 행동  (4) 2025.08.13
[2025백엔드] 헤드퍼스트자바 독서 #8 - 12장. 람다와 스트림  (4) 2025.08.13
[2025백엔드] 헤드퍼스트자바 독서 #7 - 11장. 자료구조  (5) 2025.08.13
'언어공부/Java | Kotlin' 카테고리의 다른 글
  • [CS] 자바의정석 독서 #2 - 포매팅
  • [CS] 자바의 정석 독서 #1 - 2장 - 변수
  • [2025 백엔드] 헤드퍼스트자바 독서 #9 - 13장. 위험한 행동
  • [2025백엔드] 헤드퍼스트자바 독서 #8 - 12장. 람다와 스트림
Radiata
Radiata
개발을 합니다.
  • Radiata
    DDD
    Radiata
  • 전체
    오늘
    어제
    • 분류 전체보기 (211)
      • 신년사 (3)
        • 2025년 (2)
        • 2026년 (1)
      • CS (59)
        • JVM (12)
        • 백엔드 (20)
        • 언어구현 (1)
        • 객체지향 (1)
        • 논리회로 (5)
        • 컴퓨터구조 (9)
        • 데이터베이스 (1)
        • 컴퓨터 네트워크 (10)
      • 언어공부 (64)
        • Java | Kotlin (48)
        • JavaScript | TypeScript (9)
        • C | C++ (6)
      • 개인 프로젝트 (11)
        • [2025] Happy2SendingMails (3)
        • [2026] 골든리포트! (8)
        • [2026] 순수자바로 개발하기 (0)
        • 기타 이것저것 (0)
      • 팀 프로젝트 (29)
        • [2025][GDG]홍대 맛집 아카이빙 프로젝트 (29)
      • 알고리즘 (13)
        • 백준풀이기록 (11)
      • 놀이터 (0)
      • 에러 수정일지 (2)
      • 고찰 (24)
        • CEOS 23기 회고록 (2)
  • 블로그 메뉴

    • CS
    • 언어공부
    • 개인 프로젝트
    • 팀 프로젝트
    • 알고리즘
    • 고찰
    • 신년사
    • 컬러잇 개발블로그
  • 링크

    • 컬러잇 개발블로그
  • 공지사항

  • 인기 글

  • 태그

    144
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
Radiata
[2025 백엔드] 헤드퍼스트자바 독서 #10 - 16장. 객체 저장
상단으로

티스토리툴바