Day 38 — 파일 입출력: 프로그램에 "기억"을 남기는 java.io
목차 19
지난 시간 Day 37 에서 우리는 가상 스레드 를 배웠어요. 막혀도 부담 없는 가벼운 스레드라, 수만 개를 만들어도 끄떡없었죠. 그러면서 한 가지를 흘려뒀어요. 가상 스레드가 빛나는 곳이 "막히는 작업" 이라고 했는데, 그 막힘의 대표 선수가 누구냐는 거였어요. 지금까지 sleep 으로 "외부 응답을 기다리는 척" 흉내 냈던 그 기다림의 진짜 주인공 말이에요.
바로 파일을 읽고 쓰는 일 이에요. 디스크는 메모리보다 한참 느려서, 파일 한 줄을 읽는 동안에도 프로그램은 한참을 기다려요. 그런데 오늘은 "느려서 막힌다" 가 핵심이 아니에요. 더 근본적인 이야기예요. 지금까지 우리가 만든 프로그램은 전원을 끄면 모든 게 사라졌어요. 변수에 담은 게시물도, 리스트에 모은 댓글도, 프로그램이 끝나는 순간 연기처럼 없어졌죠.
인스타그램이 그러면 안 되겠죠. 예전에 올린 사진이 앱을 껐다 켰다고 사라지면 큰일이에요. 그래서 데이터를 파일에 저장 해요. 파일은 전원을 꺼도 남아 있어서, 프로그램에게 "기억" 을 만들어줘요. 오늘은 자바로 파일을 읽고 쓰는 도구 모음인 java.io 를 배워요. 시작해봐요!
🎯 학습 목표
- 메모리(휘발성)와 파일(영구 저장)의 차이를 알고, 왜 데이터를 파일에 저장하는지 이해해요.
- 스트림(stream) 이 무엇인지 — 데이터가 한 방향으로 흐르는 파이프로 그려봐요.
- 바이트 스트림(
InputStream/OutputStream)으로 파일에 바이트를 쓰고 읽어요. - 문자 스트림(
Reader/Writer)과 인코딩(UTF-8)을 다루고, 한글이 왜 깨지는지 알아요. BufferedReader/BufferedWriter로 줄 단위로 빠르게 읽고 써요.try-with-resources로 자원을 빠짐없이 안전하게 닫는 법을 익혀요.Serializable로 객체를 통째로 저장하고, 그 함정과 현대적 대안(JSON)을 구분해요.
오늘의 로드맵
- Step 1 — 왜 파일을 다루나: 프로그램의 "기억" 과 스트림이라는 파이프.
- Step 2 — 바이트 스트림:
InputStream/OutputStream으로 파일에 쓰고 읽기. - Step 3 — 문자 스트림:
Reader/Writer와 인코딩(한글이 깨지는 이유). - Step 4 — 버퍼링:
BufferedReader/BufferedWriter로 한 줄씩 빠르게. - Step 5 — try-with-resources 실전: 자원을 안전하게 닫기.
- Step 6 — 텍스트로 저장하고 불러오기: 게시물을 파일에 (수동 직렬화).
- Step 7 — 객체 직렬화:
Serializable로 객체를 통째로 저장. - Step 8 — 직렬화의 함정과 현대적 대안: 함부로 쓰면 안 되는 이유.
Step 1: 왜 파일을 다루나 — 프로그램의 "기억" 과 스트림이라는 파이프
먼저 "왜" 부터 짚어볼게요. 지금까지 우리가 다룬 데이터는 전부 메모리(RAM) 에 있었어요. 변수, 배열, 리스트, 객체 모두요. 메모리는 아주 빠르지만 한 가지 약점이 있어요. 프로그램이 끝나거나 전원이 꺼지면 깨끗이 사라진다는 거예요. 이런 성질을 "휘발성" 이라고 해요. 휘발유가 날아가듯 사라진다는 뜻이죠.
반대로 파일(디스크) 에 저장한 데이터는 전원을 꺼도 남아 있어요. 그래서 우리가 한 번 저장해두면, 프로그램을 다시 켰을 때 그대로 불러올 수 있어요. 메모리가 "지금 머릿속 생각" 이라면, 파일은 "공책에 적어둔 메모" 예요.
[메모리 RAM] 빠르지만 전원을 끄면 사라진다 (임시)
│
│ 파일에 저장 (write)
▼
[파일 디스크] 느리지만 전원을 꺼도 남는다 (영구) = 프로그램의 "기억"
그럼 자바는 파일을 어떻게 다룰까요? 여기서 스트림(stream) 이라는 개념이 나와요. 스트림은 "데이터가 한 방향으로 줄지어 흐르는 통로" 예요. 수도관을 떠올리면 좋아요. 물이 한 방향으로 흐르듯, 데이터도 한 글자(또는 한 바이트)씩 차례로 흘러가요. 파일에 쓸 때는 프로그램에서 파일 쪽으로 흐르고, 읽을 때는 파일에서 프로그램 쪽으로 흘러요.
쓰기: [프로그램] ──────▶ [파일] (OutputStream / Writer)
읽기: [프로그램] ◀────── [파일] (InputStream / Reader)
데이터가 한 방향으로 줄지어 흐르는 "파이프" = 스트림(stream)
자바의 스트림은 두 가지 기준으로 나뉘어요. 첫째는 방향 — 읽기냐(입력, Input) 쓰기냐(출력, Output). 둘째는 단위 — 바이트(byte) 단위냐 문자(char) 단위냐. 이 둘을 조합하면 네 가지 기본 도구가 나와요. 오늘 이 넷을 차례로 만나요.
| 단위 \ 방향 | 입력 (읽기) | 출력 (쓰기) |
|---|---|---|
| 바이트 (byte) | InputStream |
OutputStream |
| 문자 (char) | Reader |
Writer |
이름이 헷갈릴 텐데 규칙이 있어요. 이름에 Stream 이 붙으면 바이트 단위, Reader/Writer 로 끝나면 문자 단위예요. 사진처럼 글자가 아닌 데이터는 바이트로, 캡션처럼 글자인 데이터는 문자로 다룬다고 기억해두면 편해요. 다음 Step 부터 하나씩 직접 써볼게요.
Step 2: 바이트 스트림 — InputStream/OutputStream 으로 파일에 쓰고 읽기
가장 바탕이 되는 바이트 스트림부터 시작해요. 컴퓨터 안의 모든 데이터는 결국 바이트(0~255 범위의 숫자) 덩어리예요. 사진, 동영상, 음악처럼 글자가 아닌 데이터는 이 바이트를 그대로 파일에 쓰고 읽어요. 그때 쓰는 도구가 FileOutputStream(쓰기)과 FileInputStream(읽기)이에요.
아래는 바이트 배열을 파일에 통째로 쓰고, 다시 전부 읽어 돌려주는 코드예요. 파일 위치는 java.io.File 로 가리켜요. 파일을 가리키는 주소라고 보면 돼요.
// com/instagram/javabasic/io/RawByteStore.java
// 바이트 배열을 통째로 파일에 써요. try-with-resources 가 끝나면 알아서 닫혀요.
public void writeBytes(File file, byte[] data) throws IOException {
try (FileOutputStream out = new FileOutputStream(file)) {
out.write(data);
}
}
// 파일에 든 바이트를 처음부터 끝까지 전부 읽어 돌려줘요.
public byte[] readBytes(File file) throws IOException {
try (FileInputStream in = new FileInputStream(file)) {
return in.readAllBytes();
}
}
핵심은 세 가지예요. 쓸 때는 out.write(data) 로 바이트 배열을 흘려보내고, 읽을 때는 in.readAllBytes() 로 파일 끝까지 한 번에 가져와요. 그리고 양쪽 다 try (...) 소괄호 안에서 스트림을 열었죠. 이게 Day 23 에서 배운 try-with-resources 예요. 소괄호 안에 자원을 열어두면, 그 블록이 끝날 때 자바가 알아서 close() 를 불러줘요.
{10, 20, 30, 40, 50} 을 써두고 다시 읽으면, 바이트 5개가 순서 그대로 돌아와요. 코드베이스 RawByteStoreTest.java 에서 음수·경계값(-1, 255)까지 섞은 배열도 한 바이트도 변형 없이 라운드트립되는 걸 확인해뒀어요. 즉, 바이트 스트림은 데이터를 "있는 그대로" 옮겨요. 해석하지 않고 바이트만 나르는 거죠.
🙋 학생 질문 — "튜터님, 스트림을 꼭 닫아야 하나요? 안 닫으면 어떻게 돼요?"
꼭 닫아야 해요. 파일 스트림은 단순한 자바 객체가 아니라, 운영체제(OS)가 내어준 진짜 자원(파일 핸들)을 붙들고 있어요. 다 쓰고 닫지 않으면 그 자원이 반납되지 않아요.
하나 정도는 괜찮아 보여도, 서버처럼 파일을 수천 번 여닫는 프로그램에서 닫기를 빠뜨리면 열어둔 파일이 계속 쌓여요. 결국 OS 가 "더는 파일을 못 열어" 하고 막아버리는 상황까지 가요. 또 쓰기 스트림은 닫을 때 남은 데이터를 마저 파일에 밀어 넣기도 해서, 안 닫으면 내용이 일부 빠질 수도 있어요.
그래서 닫기를 사람이 기억하는 대신, try-with-resources 에게 맡기는 거예요. Step 5 에서 이 안전장치를 더 깊이 다뤄요.
💡 바이트 스트림(
InputStream/OutputStream)은 데이터를 해석하지 않고 바이트 그대로 나르는, 모든 입출력의 바탕이에요. 사진·동영상 같은 바이너리 데이터에 써요. 그리고 스트림은try-with-resources로 열어 자동으로 닫는 게 안전해요.
Step 3: 문자 스트림 — Reader/Writer 와 인코딩 (한글이 깨지는 이유)
이제 글자를 다뤄볼게요. 게시물 캡션이나 댓글처럼 사람이 읽는 글자도 바이트로 저장하긴 해요. 그런데 여기에 함정이 하나 있어요. 글자를 바이트로 바꿀 때 "어떤 약속으로 바꿀지" 를 정해야 한다는 거예요. 이 약속을 인코딩(encoding) 이라고 해요.
영어 알파벳은 한 글자가 1바이트라 단순해요. 그런데 한글은 한 글자를 여러 바이트로 표현해요. 그래서 쓸 때 쓴 약속과 읽을 때 읽는 약속이 다르면, 한글이 와장창 깨져요. 인코딩이 안 맞으면 "안녕하세요" 가 "안녕" 같은 외계어로 보이는 거죠. 이걸 막으려면 항상 같은 약속을 쓰면 돼요. 오늘날의 표준은 UTF-8 이에요.
글자를 다루는 도구는 FileWriter(쓰기)와 FileReader(읽기)예요. 인코딩은 StandardCharsets.UTF_8 로 직접 넘겨서 항상 UTF-8 을 쓰도록 고정해요.
// com/instagram/javabasic/io/TextCaptionStore.java
// 문자열(캡션)을 UTF-8 로 파일에 써요.
public void writeText(File file, String caption) throws IOException {
try (FileWriter writer = new FileWriter(file, StandardCharsets.UTF_8)) {
writer.write(caption);
}
}
// 파일에 든 글자를 UTF-8 로 한 글자씩 끝까지 읽어 하나의 문자열로 돌려줘요.
public String readText(File file) throws IOException {
StringBuilder sb = new StringBuilder();
try (FileReader reader = new FileReader(file, StandardCharsets.UTF_8)) {
int c;
while ((c = reader.read()) != -1) {
sb.append((char) c);
}
}
return sb.toString();
}
reader.read() 는 한 글자씩 읽어 정수로 돌려줘요. 더 읽을 글자가 없으면 -1 이 와서, 그때 반복을 멈춰요. 이렇게 끝까지 모은 글자를 StringBuilder(Day 17 에서 배웠죠)로 이어 붙여 문자열로 만들어요. "안녕하세요 🌙 첫 게시물" 처럼 한글과 이모지가 섞인 캡션도 UTF-8 로 쓰고 읽으면 깨짐 없이 그대로 돌아와요.
여기서 재미있는 사실 하나. 같은 글자라도 "글자 수" 와 "바이트 수" 는 달라요. UTF-8 에서 한글 한 글자는 보통 3바이트거든요.
한글 "안" 한 글자를 저장하면
문자 스트림 (Reader/Writer) '안' ← 글자 1개로 다룸
바이트 스트림 (InputStream) [0xEC][0x95][0x88] ← 바이트 3개로 다룸 (UTF-8)
코드베이스 TextCaptionStoreTest.java 를 보면, 한글 "사진"(2글자)을 UTF-8 바이트로 재면 6바이트예요. 글자 수의 3배죠. 반대로 영문 "post42" 는 글자 수와 바이트 수가 똑같아요. 이 차이를 알아두면, 나중에 "글자 수 제한" 과 "용량 제한" 이 왜 다른지 이해하는 바탕이 돼요.
🙋 학생 질문 — "튜터님, 그냥 바이트 스트림으로 글자도 읽으면 안 되나요?"
읽을 수는 있는데, 직접 인코딩을 풀어야 해서 번거롭고 실수하기 쉬워요. 바이트 스트림은 [0xEC, 0x95, 0x88] 같은 숫자 덩어리를 줄 뿐이에요. 이게 한글 "안" 이라는 걸 알려면, 우리가 직접 "UTF-8 규칙으로 이 3바이트를 한 글자로 묶어라" 하고 해석해야 해요.
문자 스트림(Reader/Writer)은 그 해석을 대신 해줘요. UTF-8 약속만 알려주면, 바이트를 글자로(읽을 때) 또 글자를 바이트로(쓸 때) 알아서 변환해주죠. 그래서 글자는 문자 스트림으로 다루는 게 편하고 안전해요. 바이트 스트림은 사진처럼 해석이 필요 없는 데이터에 써요.
💡 글자(텍스트)는 문자 스트림(
Reader/Writer)으로, 인코딩은 항상UTF-8로 명시해요. 그래야 한글이 깨지지 않아요. 그리고 "글자 수 ≠ 바이트 수" — UTF-8 에서 한글 한 글자는 3바이트라는 점을 기억해두세요.
Step 4: 버퍼링 — BufferedReader/BufferedWriter 로 한 줄씩 빠르게
Step 3 의 read() 는 글자를 하나씩 읽었어요. 작동은 하지만 느려요. 왜냐하면 글자 하나를 읽거나 쓸 때마다 디스크까지 한 번씩 다녀와야 하거든요. 디스크 왕복은 메모리 접근보다 수천 배 느려서, 글자 하나하나 왕복하면 손해가 커요.
그래서 버퍼(buffer) 를 써요. 버퍼는 "잠깐 모아두는 임시 통" 이에요. 글자를 통에 모았다가 가득 차면 한 번에 디스크로 보내고, 읽을 때도 한 덩어리를 통에 미리 가져다 놓고 거기서 꺼내 써요. 왕복 횟수가 확 줄어서 훨씬 빨라져요.
버퍼 없이: 글자 하나마다 디스크까지 왕복 (왕복 수십 번 → 느림)
'첫' ▶ 디스크 '줄' ▶ 디스크 '캡' ▶ 디스크 ...
버퍼 사용: 임시 통에 모았다가 한 번에 (왕복 1번 → 빠름)
'첫 줄 캡션 ...' ▶ [버퍼] ▶ (가득 차면) ▶ 디스크
버퍼를 입혀주는 도구가 BufferedWriter(쓰기)와 BufferedReader(읽기)예요. 기존 FileWriter/FileReader 를 한 번 감싸서 만들어요. 게다가 덤으로 편한 기능이 따라와요. BufferedReader 의 readLine() 은 한 줄을 통째로 읽어주고, BufferedWriter 의 newLine() 은 줄바꿈을 넣어줘요. 캡션을 여러 줄로 저장하고 한 줄씩 읽기에 딱이에요.
// com/instagram/javabasic/io/LineCaptionStore.java
// 여러 줄을 한 줄씩 쓰고, 줄마다 줄바꿈을 넣어줘요.
public void writeLines(File file, List<String> lines) throws IOException {
try (BufferedWriter writer = new BufferedWriter(
new FileWriter(file, StandardCharsets.UTF_8))) {
for (String line : lines) {
writer.write(line);
writer.newLine();
}
}
}
// readLine() 으로 한 줄씩 읽어 리스트에 담아 돌려줘요. 더 읽을 게 없으면 null 이 와요.
public List<String> readLines(File file) throws IOException {
List<String> lines = new ArrayList<>();
try (BufferedReader reader = new BufferedReader(
new FileReader(file, StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
lines.add(line);
}
}
return lines;
}
new BufferedWriter(new FileWriter(...)) 처럼 도구를 도구로 감싸는 모양을 잘 봐두세요. 안쪽 FileWriter 가 실제로 파일에 쓰고, 바깥 BufferedWriter 가 버퍼 기능을 더해주는 거예요. 읽을 때도 똑같이 BufferedReader 로 FileReader 를 감싸요. 읽기 반복은 readLine() 이 null 을 돌려줄 때까지 도는데, null 은 "더 읽을 줄이 없다" 는 신호예요.
세 줄짜리 캡션을 저장하고 다시 읽으면, 순서도 개수도 그대로 세 줄이 돌아와요. 코드베이스 LineCaptionStoreTest.java 에서 줄 순서·개수가 보존되는 것과, 빈 목록을 쓰면 읽을 줄이 하나도 없다는 것까지 확인해뒀어요.
💡 디스크 왕복은 비싸기 때문에,
BufferedReader/BufferedWriter로 데이터를 모아 한 번에 주고받으면 훨씬 빨라요. 덤으로readLine()으로 줄 단위 읽기가 쉬워져요. 텍스트 파일을 다룰 땐 거의 항상 버퍼를 입혀요.
Step 5: try-with-resources 실전 — 자원을 안전하게 닫기
지금까지 코드마다 try (...) 소괄호 안에서 스트림을 열었죠. 이 try-with-resources 를 이번엔 제대로 파볼게요. Day 23 에서 잠깐 만났는데, 파일 입출력에서 진가가 드러나거든요.
핵심 원리는 단순해요. try 의 소괄호 안에 연 자원은, 그 블록이 끝나는 순간 자바가 자동으로 close() 를 불러줘요. 도중에 예외가 터져도 닫아줘요. 그래서 "닫기를 까먹는" 사고가 원천적으로 안 생겨요. 단, 자동으로 닫히려면 그 자원이 AutoCloseable 이라는 인터페이스를 구현해야 해요. 모든 스트림은 이미 이걸 구현하고 있어요.
원리를 눈으로 보려고, 우리가 직접 AutoCloseable 을 구현한 작은 자원을 만들어봤어요. 열릴 때와 닫힐 때 공유 로그에 이름을 남겨서, 닫히는 순서를 관찰할 수 있게요.
// com/instagram/javabasic/io/ManagedResource.java
public class ManagedResource implements AutoCloseable {
private final String name;
private final List<String> log;
private boolean open;
// 생성되는 순간 "열림" 으로 표시하고, 로그에 "open:이름" 을 남겨요.
public ManagedResource(String name, List<String> log) {
this.name = name;
this.log = log;
this.open = true;
log.add("open:" + name);
}
// try 블록이 끝나면 자동으로 불려요. "닫힘" 으로 바꾸고 로그에 "close:이름" 을 남겨요.
@Override
public void close() {
this.open = false;
log.add("close:" + name);
}
}
이제 자원 두 개를 한 try 에서 열어볼게요. A 를 먼저 열고 B 를 나중에 열면, 닫힐 때는 선언한 역순 으로 닫혀요. 즉 B 가 먼저, A 가 나중에 닫혀요. 늦게 연 것부터 닫는 거예요. 옷을 입은 역순으로 벗는 것과 똑같아요.
try (A 열기; B 열기) { ← 여는 순서: A → B
... 작업 ...
} ← 닫는 순서: B → A (역순!)
기록된 로그 → [open:A, open:B, close:B, close:A]
코드베이스 ManagedResourceTest.java 에서 이 역순 닫힘과, 블록을 벗어나면 두 자원이 모두 닫혀 있다(isOpen() 이 false)는 걸 확인해뒀어요. 왜 하필 역순일까요? 나중에 연 자원이 먼저 연 자원에 기대고 있을 수 있기 때문이에요. 그래서 의존하는 쪽(나중에 연 것)을 먼저 정리하는 게 안전해요.
예전에는 이걸 finally 블록에서 손으로 닫았어요. 그런데 자원이 여러 개면 닫는 코드가 길어지고, 하나라도 빠뜨리거나 순서를 틀리기 쉬웠어요. try-with-resources 는 그 모든 걸 자바가 대신 해줘요. 자원을 소괄호에 나열하기만 하면 끝이에요.
🙋 학생 질문 — "튜터님, finally 로 직접 close() 하는 거랑 뭐가 그렇게 다른가요?"
결과는 비슷하지만, 안전함과 코드 양에서 차이가 커요. finally 로 직접 닫으면 두 가지 함정이 있어요.
첫째, 사람이 기억해야 해요. 자원을 열고 finally 에서 닫는 코드를 까먹으면 그냥 누수가 나요. 둘째, 자원이 여러 개면 finally 안에서 각각 null 검사를 하고 따로 try 로 감싸야 해서 코드가 길고 지저분해져요. 닫다가 또 예외가 날 수도 있거든요.
try-with-resources 는 소괄호에 자원을 나열만 하면, 닫기·역순·예외 상황까지 자바가 알아서 처리해요. 그래서 요즘 자바에서 파일이나 연결을 다룰 땐 거의 항상 이 방식을 써요. "열었으면 소괄호 안에" 만 기억하면 돼요.
💡
AutoCloseable자원을try소괄호에 열면, 블록이 끝나거나 예외가 나도 자바가 선언 역순으로 자동으로 닫아줘요. 닫기를 까먹는 사고가 사라져요.finally로 손수 닫던 시대의 안전한 후계자예요.
Step 6: 텍스트로 저장하고 불러오기 — 게시물을 파일에 (수동 직렬화)
이제 배운 걸 모아 실전을 해봐요. 지금까지는 바이트나 글자를 날랐는데, 우리가 진짜 저장하고 싶은 건 객체 죠. 게시물(Post) 객체를 파일에 저장하고, 다시 불러와 객체로 되살리고 싶어요.
문제는, 파일은 글자나 바이트만 담을 수 있다는 거예요. 객체를 통째로 넣을 수는 없어요. 그래서 객체를 "글자 줄" 로 바꿔서 저장하고, 읽을 때 그 글자 줄을 다시 객체로 조립해야 해요. 객체를 저장 가능한 모양으로 바꾸는 걸 직렬화(serialization), 그걸 다시 객체로 되살리는 걸 역직렬화(deserialization) 라고 해요.
[Post 객체] id=1, author="jaehoon", caption="첫 게시물"
│ 직렬화 (저장): 객체 → 텍스트 한 줄
▼
"1|jaehoon|첫 게시물" ← 파일에 저장되는 모습
│ 역직렬화 (복원): 텍스트 한 줄 → 객체
▼
[Post 객체] 다시 원래대로
이번엔 포맷을 우리가 직접 정해볼게요. 게시물 하나를 id|작성자|캡션 형태의 한 줄로 만들어요. 막대(|)로 칸을 나누는 거죠. 읽을 때는 막대 기준으로 잘라서 다시 Post 로 조립해요.
// com/instagram/javabasic/io/PostTextStore.java
// 게시물 목록을 한 줄씩 텍스트로 저장해요.
public void saveAll(File file, List<Post> posts) throws IOException {
try (BufferedWriter writer = new BufferedWriter(
new FileWriter(file, StandardCharsets.UTF_8))) {
for (Post post : posts) {
writer.write(post.id() + DELIMITER + post.author() + DELIMITER + post.caption());
writer.newLine();
}
}
}
// 한 줄씩 읽어 막대(|) 기준으로 잘라 Post 로 복원해요.
public List<Post> loadAll(File file) throws IOException {
List<Post> posts = new ArrayList<>();
try (BufferedReader reader = new BufferedReader(
new FileReader(file, StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
// limit 3 으로 잘라 앞 두 칸만 구분하고 캡션은 통째로 남겨요.
String[] parts = line.split("\\" + DELIMITER, 3);
long id = Long.parseLong(parts[0]);
String author = parts[1];
String caption = parts[2];
posts.add(new Post(id, author, caption));
}
}
return posts;
}
Step 4 의 버퍼링과 readLine() 을 그대로 썼어요. 저장할 땐 객체의 필드를 막대로 이어 한 줄로 만들고, 읽을 땐 split 으로 잘라 Post 생성자에 넣어요. split("\\|", 3) 에서 숫자 3 은 "최대 3조각까지만 자르라" 는 뜻이에요. 그래야 캡션 안에 우연히 다른 글자가 있어도 앞의 id·작성자만 정확히 떼어내고 나머지는 캡션으로 남길 수 있어요. 코드베이스 PostTextStoreTest.java 에서 게시물 목록이 id·작성자·캡션 그대로 라운드트립되는 걸 확인해뒀어요.
그런데 이 손수 만든 포맷엔 약점이 있어요. 만약 캡션 안에 막대(|)가 들어 있으면? 칸 구분이 꼬여서 복원이 망가져요. 날짜, 좋아요 수, 댓글 목록까지 늘어나면 포맷 규칙도 점점 복잡해지고요. 여기서 자연스러운 생각이 들죠. "이런 변환을 자바가 알아서 해주면 좋을 텐데?" 바로 다음 Step 의 주제예요.
💡 객체를 저장 가능한 모양으로 바꾸는 게 직렬화, 되살리는 게 역직렬화 예요. 포맷을 직접 정하면 사람이 읽기 좋지만, 구분자 충돌 같은 한계가 있어요. 그래서 더 안전한 방법이 필요해져요.
Step 7: 객체 직렬화 — Serializable 로 객체를 통째로 저장
자바에는 객체를 자동으로 직렬화해주는 기능이 내장돼 있어요. 우리가 손으로 막대 포맷을 만들 필요 없이, "이 객체를 저장해줘" 하면 자바가 객체의 모양을 통째로 바이트로 바꿔 저장하고, 읽을 때 그대로 되살려줘요. 도구는 ObjectOutputStream(저장)과 ObjectInputStream(복원)이에요.
단, 한 가지 조건이 있어요. 저장할 객체가 Serializable 을 구현해야 해요. Serializable 은 메서드가 하나도 없는 특별한 인터페이스예요. "이 객체는 바이트로 바꿔 저장해도 됩니다" 라고 표시만 하는 약속이라, 마커 인터페이스(marker interface) 라고 불러요. 우리 Post record 에 이 표시를 붙여뒀어요.
// com/instagram/javabasic/io/Post.java
public record Post(long id, String author, String caption) implements Serializable {
private static final long serialVersionUID = 1L;
}
serialVersionUID 는 "저장한 모양과 불러오는 모양이 같은 버전인지" 확인하는 번호예요. 잠시 뒤 Step 8 에서 이 번호가 왜 중요한지 짚을게요. 이제 저장과 복원 코드를 볼게요. 놀랄 만큼 짧아요.
// com/instagram/javabasic/io/PostObjectStore.java
// 게시물 객체 하나를 통째로 파일에 저장해요.
public void save(File file, Serializable post) throws IOException {
try (ObjectOutputStream out = new ObjectOutputStream(
new FileOutputStream(file))) {
out.writeObject(post);
}
}
// 파일에서 객체를 되살려 돌려줘요. (불러올 타입은 호출하는 쪽이 알고 있어요.)
@SuppressWarnings("unchecked")
public <T extends Serializable> T load(File file) throws IOException, ClassNotFoundException {
try (ObjectInputStream in = new ObjectInputStream(
new FileInputStream(file))) {
return (T) in.readObject();
}
}
out.writeObject(post) 한 줄로 객체가 통째로 저장되고, in.readObject() 한 줄로 되살아나요. 막대로 자르고 조립하던 Step 6 의 수고가 통째로 사라졌죠. 코드베이스 PostObjectStoreTest.java 에서 Post 객체가 모든 필드 그대로 복원되는 걸 확인해뒀어요. 참고로 ObjectOutputStream 은 바이트를 다루므로, 안쪽을 FileReader 가 아니라 FileOutputStream(바이트 스트림)으로 감쌌다는 점도 눈여겨보세요.
여기서 한 가지 더. 객체의 어떤 필드는 저장하고 싶지 않을 때가 있어요. 예를 들어 화면 표시용으로 잠깐 만들어둔 값은, 다시 계산할 수 있으니 저장할 필요가 없죠. 그런 필드 앞에 transient 키워드를 붙이면 "이건 저장하지 마세요" 라는 뜻이 돼요. 그래서 불러오면 그 필드는 비어 있어요(객체 참조면 null).
// com/instagram/javabasic/io/CachedPost.java
public class CachedPost implements Serializable {
private static final long serialVersionUID = 1L;
private final long id;
private final String author;
private final String caption;
// 저장에서 빠지는 임시 값이에요 → 불러오면 null 로 돌아와요.
private transient String displayLabel;
// ... 생성자에서 displayLabel = author + " | " + caption 으로 채워둠 ...
}
displayLabel 은 작성자와 캡션을 합친 표시용 문자열이에요. 저장 전에는 값이 있지만, transient 라서 직렬화에서 빠져요. 그래서 저장했다 불러오면 id·author·caption 은 그대로인데 displayLabel 만 null 이에요. PostObjectStoreTest.java 에서 이 동작을 확인해뒀어요.
🙋 학생 질문 — "튜터님, Serializable 은 메서드도 없는데 왜 굳이 붙여요?"
좋은 의문이에요. Serializable 은 기능을 주는 게 아니라 허락을 표시 하는 인터페이스예요. 자바한테 "이 객체는 바이트로 바꿔 저장해도 괜찮아요" 라고 알려주는 도장 같은 거죠. 그래서 메서드가 하나도 없어요.
만약 이 표시 없이 객체를 writeObject 하려고 하면, 자바가 NotSerializableException 을 던지며 거부해요. 왜 이렇게 까다롭게 굴까요? 아무 객체나 함부로 저장하면 안 되는 것들이 있기 때문이에요. 예를 들어 열려 있는 파일 연결이나 비밀번호 같은 건 바이트로 저장하면 위험하거나 의미가 없어요. 그래서 자바는 "직렬화해도 안전하다고 개발자가 명시적으로 표시한" 객체만 저장하게 해뒀어요. Step 8 에서 이 안전 문제를 더 깊이 봐요.
💡
Serializable을 붙인 객체는ObjectOutputStream.writeObject()/ObjectInputStream.readObject()로 통째로 저장·복원돼요. 손수 포맷을 만들 필요가 없어요. 저장에서 빼고 싶은 필드엔transient를 붙이면 역직렬화 후 비어 있어요.
Step 8: 직렬화의 함정과 현대적 대안 — 함부로 쓰면 안 되는 이유
Serializable 은 정말 편했어요. 한 줄로 객체를 저장하고 되살렸죠. 그런데 편하다고 아무 데나 쓰면 안 돼요. 실무에서는 오히려 "신뢰할 수 없는 데이터엔 자바 직렬화를 쓰지 마라" 가 상식이에요. 왜 그럴까요? 세 가지 함정이 있어요.
첫째, 보안 위험이에요. readObject() 는 바이트를 받아 객체를 만들어내요. 그런데 그 바이트가 외부에서, 그러니까 믿을 수 없는 곳에서 왔다면? 공격자가 악의적으로 조작한 바이트를 흘려보내 프로그램이 원치 않는 객체를 만들고 동작하게 만들 수 있어요. 이걸 "역직렬화 공격" 이라고 하는데, 2026년 현재도 자바에서 가장 조심해야 할 보안 약점으로 꼽혀요.
둘째, 버전이 깨져요. Step 7 의 serialVersionUID 기억나죠? 클래스를 저장한 뒤에 필드를 추가하거나 바꾸면 이 번호가 안 맞게 돼요. 그러면 옛날에 저장한 파일을 불러올 때 InvalidClassException 이 나면서 못 읽어요. 즉, 자바 직렬화로 저장한 파일은 클래스가 바뀌면 무용지물이 되기 쉬워요.
셋째, 자바 전용이에요. ObjectOutputStream 이 만든 바이트는 자바만 이해해요. 파이썬이나 자바스크립트로 만든 다른 프로그램과는 이 데이터를 주고받을 수 없어요. 요즘처럼 여러 언어로 만든 서비스가 데이터를 주고받는 환경엔 안 맞죠.
그래서 실무에서는 데이터를 외부와 주고받거나 오래 보관할 때 JSON 같은 포맷을 더 많이 써요. JSON 은 사람이 읽을 수 있고, 어떤 언어든 이해하며, 역직렬화 공격에서도 자바 직렬화보다 안전해요. (JSON 을 다루는 법은 나중에 spring-boot 과목에서 본격적으로 배워요. 지금은 "이런 대안이 있다" 만 알아두면 충분해요.)
| 저장 방식 | 장점 | 단점 | 언제 쓰나 |
|---|---|---|---|
| 수동 텍스트 (Step 6) | 단순, 사람이 읽기 쉬움 | 구분자 충돌 등 포맷 한계 | 아주 단순한 데이터 |
| Serializable (Step 7) | 코드 한 줄로 객체 통째로 | 보안 위험·버전 깨짐·자바 전용 | 내부의 믿을 수 있는 데이터에 한정 |
| JSON (실무 표준) | 언어 무관·범용·비교적 안전 | 별도 라이브러리 필요 | 외부 교환·장기 보관 |
그럼 Serializable 은 배울 필요가 없었을까요? 그렇진 않아요. 자바 내부에서 신뢰할 수 있는 데이터를 잠깐 저장하거나, 자바끼리만 주고받는 좁은 상황에서는 여전히 간편하게 쓰여요. 무엇보다 "객체를 저장하려면 직렬화가 필요하다" 는 개념 자체가 중요해요. 다만 "외부에서 온 데이터를 함부로 역직렬화하지 않는다" 는 원칙을 꼭 기억해두세요.
💡 자바 직렬화(
Serializable)는 편하지만 보안·버전·언어 종속의 함정이 있어요. 그래서 신뢰 경계를 넘는 데이터(외부 입력·장기 보관)엔 JSON 같은 범용 포맷을 써요.Serializable은 자바 내부의 믿을 수 있는 데이터에 한정해서 쓰는 게 안전해요.
마무리
오늘은 프로그램에 "기억" 을 만들어주는 파일 입출력(java.io) 을 배웠어요. 전원을 꺼도 사라지지 않는 파일에 데이터를 저장하고, 다시 불러오는 법을 손에 넣었죠. 지난 시간 sleep 으로 흉내 냈던 "막힘" 의 진짜 주인공이 바로 이 파일 입출력이었어요.
- 메모리 vs 파일 — 메모리는 빠르지만 휘발성, 파일은 느리지만 영구 저장. 그래서 파일이 프로그램의 "기억" 이 돼요.
- 스트림 — 데이터가 한 방향으로 흐르는 파이프. 방향(입력/출력) × 단위(바이트/문자)로 네 가지 기본 도구가 나와요.
- 바이트 스트림 —
FileInputStream/FileOutputStream. 사진 같은 바이너리를 있는 그대로 나르기. - 문자 스트림 —
FileReader/FileWriter+ UTF-8. 한글이 깨지지 않게 인코딩을 명시해요. - 버퍼링 —
BufferedReader/BufferedWriter. 디스크 왕복을 줄여 빠르게,readLine()으로 줄 단위 처리. - try-with-resources —
AutoCloseable자원을 소괄호에 열면 선언 역순으로 자동으로 닫혀요. - 직렬화 — 수동 텍스트 포맷, 자동
Serializable, 그리고 실무 표준 JSON. 함정과 대안을 구분해요.
그런데 오늘 파일 한 줄을 읽는 데에도 BufferedReader 를 감싸고, while 로 돌리고, readLine() 으로 줄을 모으는 과정이 꽤 길었죠. "파일 내용을 그냥 통째로 줘" 같은 단순한 일에도 손이 많이 갔어요. 다음 시간엔 이 수고를 확 줄여주는 모던 파일 API(java.nio) 를 배워요. Files.readString(파일) 한 줄이면 파일 전체를 문자열로 읽고, 디렉토리 안의 파일 목록을 훑는 것도 간단해져요. 오늘의 java.io 가 든든한 바탕이 되어줄 거예요. 수고 많으셨어요!
과제
오늘 배운 파일 입출력을 직접 다뤄볼 시간이에요. 셋 다 인스타그램에서 실제로 일어날 법한 상황이에요. 막히면 본문의 데모 코드를 참고하되, 먼저 스스로 흐름을 그려보세요. 파일 경로는 java.io.File 로 가리키고, 모든 스트림은 try-with-resources 로 열어 자동으로 닫히게 하세요.
과제 1 (기초): 댓글을 파일에 저장하고 다시 세기
상황 배경: 게시물에 달린 댓글을 파일에 한 줄씩 저장해두었다가, 앱을 다시 켜면 불러와 "댓글 N개" 를 보여주고 싶어요. Step 4 의 LineCaptionStore 를 참고하세요.
🎯 해결 미션:
CommentFileStore같은 클래스에saveComments(File file, List<String> comments)와loadComments(File file)메서드를 두세요.- 저장은
BufferedWriter+newLine()으로 한 줄에 댓글 하나씩, 인코딩은StandardCharsets.UTF_8로 명시하세요. - 읽기는
BufferedReader+readLine()으로null이 올 때까지 한 줄씩 모아List<String>으로 돌려주세요. - 댓글 세 개를 저장하고 다시 불러왔을 때 개수가
3이고, 한글 댓글이 깨지지 않는지 확인하세요. 글자 하나씩read()로 읽지 않고readLine()을 쓰면 무엇이 편한지 한 줄로 적어보세요.
과제 2 (응용): 좋아요 수까지 담아 게시물 저장하기
상황 배경: 게시물을 저장할 때 작성자·캡션뿐 아니라 좋아요 수도 함께 보관하고 싶어요. Step 6 의 PostTextStore 처럼 한 줄 텍스트 포맷을 직접 정해 저장하고 복원해봐요.
🎯 해결 미션:
id,author,caption,likes(int) 네 가지를 담은record LikedPost(...)를 만드세요.LikedPostStore에saveAll(File, List<LikedPost>)와loadAll(File)을 두고, 한 게시물을id|author|caption|likes형태 한 줄로 저장하세요.- 읽을 때는 막대(
|) 기준으로 잘라 다시LikedPost로 조립하세요. 숫자 칸은Long.parseLong/Integer.parseInt로 바꿔야 해요. - 게시물 두 건을 저장하고 복원했을 때 네 필드가 모두 그대로인지 확인하세요. 만약 캡션 안에 막대(
|)가 들어 있으면 무슨 문제가 생길지, 그리고 어떻게 피할 수 있을지 한 줄로 적어보세요.
과제 3 (심화): 프로필을 직렬화하되 비밀번호는 빼고 저장하기
상황 배경: 사용자 프로필 객체를 파일에 통째로 저장하려고 해요. 그런데 비밀번호 같은 민감한 값은 파일에 남기면 안 돼요. transient 로 그 필드만 저장에서 빼봐요. Step 7 의 PostObjectStore 와 CachedPost 를 참고하세요.
🎯 해결 미션:
Serializable을 구현한UserProfile클래스를 만드세요. 필드는username,bio(소개글), 그리고password예요.password필드 앞에transient를 붙여 저장에서 빠지게 하세요.serialVersionUID도 선언하세요.ProfileStore에save(File, UserProfile)와load(File)을 두고ObjectOutputStream/ObjectInputStream으로 저장·복원하세요.- 저장 전
password에 값을 넣고, 저장했다 불러온 뒤username·bio는 그대로인데password는null인지 확인하세요. 민감한 값을transient로 빼는 게 왜 중요한지 한 줄로 적어보세요.
생각해볼 주제
혼자 고민해도 좋고, 동료와 토론해도 좋아요. 정답을 외우기보다 "나라면 어떻게 설명할까" 를 떠올리며 읽어보세요.
1. 바이트 스트림과 문자 스트림, 왜 둘 다 필요할까?
오늘 사진은 바이트 스트림으로, 캡션은 문자 스트림으로 다뤘어요. 그런데 글자도 결국 바이트로 저장되잖아요. 그러면 그냥 바이트 스트림 하나로 모든 걸 다루면 안 될까요?
힌트는 Step 3 의 "인코딩" 에 있어요. 바이트만 다루는 도구로 한글을 읽으려면 누가 [0xEC, 0x95, 0x88] 을 "안" 으로 묶어줘야 할까요? 반대로, 사진 데이터를 문자 스트림으로 읽으면 어떤 일이 벌어질지도 생각해보세요. 두 도구가 각자 어떤 데이터에 어울리는지 보일 거예요.
2. 버퍼는 왜 빠를까, 그리고 닫기를 빠뜨리면 왜 위험할까?
Step 4 에서 버퍼를 쓰면 빨라진다고 했어요. 그런데 버퍼는 "모아뒀다가 한 번에" 보내는 임시 통이라고도 했죠. 그렇다면 아직 통이 가득 차지 않았는데 프로그램이 스트림을 닫지 않고 끝나버리면, 통에 남아 있던 데이터는 어떻게 될까요?
BufferedWriter 로 글을 썼는데 파일을 열어보니 내용이 일부 비어 있다면, 무엇을 빠뜨린 걸까요? 버퍼가 "빠름" 을 주는 원리와, close()(또는 try-with-resources)가 왜 단순한 정리 이상으로 중요한지 연결해서 설명해보세요.
3. 자바 직렬화는 편한데, 실무에선 왜 JSON 을 더 많이 쓸까?
Step 7 의 Serializable 은 객체를 한 줄로 저장하고 되살렸어요. 정말 편했죠. 그런데 Step 8 에서는 신뢰할 수 없는 데이터엔 쓰지 말라고 했어요. 편한 도구를 왜 굳이 멀리할까요?
세 가지 함정(보안·버전·언어 종속)을 떠올려보세요. 특히 "다른 회사 서버에서 보낸 데이터를 우리 자바 프로그램이 받아 객체로 되살린다" 는 상황을 상상하면, 왜 위험한지 그리고 왜 JSON 같은 범용 포맷이 더 안전한지 분명해질 거예요. 편리함과 안전함 사이에서 무엇을 기준으로 골라야 할지 정리해보세요.
✅ 예시 답안정답 보기
아래는 과제와 생각해볼 주제의 예시답안이에요. 정답이 하나만 있는 건 아니니, 내 코드와 흐름을 비교하며 "왜 이렇게 했는지" 를 짚어보세요.
과제 예시답안
과제 1 예시답안 — 댓글을 파일에 저장하고 다시 세기
핵심 접근
댓글을 한 줄에 하나씩 저장하고, 읽을 때 한 줄씩 모으는 게 목표예요. 쓰기는 BufferedWriter 로 버퍼링하고 newLine() 으로 줄바꿈을 넣어요. 읽기는 BufferedReader 의 readLine() 을 null 이 올 때까지 반복하면 돼요. 인코딩은 양쪽 다 StandardCharsets.UTF_8 로 명시해야 한글 댓글이 깨지지 않아요. 그리고 스트림은 전부 try-with-resources 로 열어 자동으로 닫히게 해요.
예시 구현
// com/instagram/javabasic/io/solution/day38/CommentFileStore.java
// 댓글 목록을 한 줄에 하나씩 저장해요.
public void saveComments(File file, List<String> comments) throws IOException {
try (BufferedWriter writer = new BufferedWriter(
new FileWriter(file, StandardCharsets.UTF_8))) {
for (String comment : comments) {
writer.write(comment);
writer.newLine();
}
}
}
// readLine() 으로 한 줄씩 읽어 리스트에 담아 돌려줘요. null 이 오면 끝이에요.
public List<String> loadComments(File file) throws IOException {
List<String> comments = new ArrayList<>();
try (BufferedReader reader = new BufferedReader(
new FileReader(file, StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
comments.add(line);
}
}
return comments;
}
댓글 세 개를 저장하고 불러오면 개수가 3 이고, 한글 댓글도 그대로 돌아와요.
채점 포인트
BufferedWriter/BufferedReader로 버퍼링하고, 쓸 때newLine()으로 줄을 나눴는가- 읽기를
readLine()이null을 돌려줄 때까지 반복했는가 - 인코딩을
StandardCharsets.UTF_8로 명시했는가 (한글 깨짐 방지) - 스트림을
try-with-resources로 열어 자동으로 닫았는가
흔한 실수
- 인코딩을 명시하지 않아 OS 기본 인코딩을 따라가는 경우. 환경에 따라 한글이 깨질 수 있어요. 항상 UTF-8 을 넘기세요.
readLine()의 종료 신호인null검사를 빼먹는 경우.null이 와도 계속 읽으려 하면 오류가 나요.- "
readLine()이 편한 이유" 의 답 — 글자 하나씩read()로 읽으면 줄바꿈을 직접 찾아 끊어야 하지만,readLine()은 한 줄을 통째로 돌려주니 댓글 같은 줄 단위 데이터를 다루기 훨씬 간단해요.
과제 2 예시답안 — 좋아요 수까지 담아 게시물 저장하기
핵심 접근
본문 PostTextStore 에 칸을 하나 더 붙이는 과제예요. id, author, caption 에 likes(int) 를 더해 네 가지를 담은 record LikedPost 를 만들고, 한 게시물을 id|author|caption|likes 한 줄로 저장해요. 읽을 때는 막대(|) 기준으로 잘라 다시 조립하는데, 숫자 칸은 글자가 아니라 숫자로 바꿔야 하니 Long.parseLong 과 Integer.parseInt 를 써요.
예시 구현
// com/instagram/javabasic/io/solution/day38/LikedPost.java
public record LikedPost(long id, String author, String caption, int likes) {
}
// com/instagram/javabasic/io/solution/day38/LikedPostStore.java
// 게시물 목록을 한 줄씩 텍스트로 저장해요.
public void saveAll(File file, List<LikedPost> posts) throws IOException {
try (BufferedWriter writer = new BufferedWriter(
new FileWriter(file, StandardCharsets.UTF_8))) {
for (LikedPost post : posts) {
writer.write(post.id() + DELIMITER + post.author() + DELIMITER
+ post.caption() + DELIMITER + post.likes());
writer.newLine();
}
}
}
// 한 줄씩 읽어 막대(|) 기준으로 잘라 LikedPost 로 복원해요. 숫자 칸은 형 변환해요.
public List<LikedPost> loadAll(File file) throws IOException {
List<LikedPost> posts = new ArrayList<>();
try (BufferedReader reader = new BufferedReader(
new FileReader(file, StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
String[] parts = line.split("\\" + DELIMITER, 4);
long id = Long.parseLong(parts[0]);
String author = parts[1];
String caption = parts[2];
int likes = Integer.parseInt(parts[3]);
posts.add(new LikedPost(id, author, caption, likes));
}
}
return posts;
}
게시물 두 건을 저장하고 복원하면 네 필드가 모두 그대로예요.
채점 포인트
- 네 가지 필드를 담은
record LikedPost를 만들었는가 - 저장 포맷을
id|author|caption|likes로 일관되게 만들었는가 - 읽을 때 숫자 칸을
Long.parseLong·Integer.parseInt로 형 변환했는가 split의limit(여기선 4) 를 줘서 칸 수를 통제했는가
흔한 실수
- 숫자 칸을 문자열 그대로 둬서
likes비교나 계산이 어긋나는 경우. 파일에서 읽은 값은 전부 글자라서, 숫자로 쓰려면 반드시 변환해야 해요. - 캡션이 마지막이 아니라 중간에 있어서, 캡션 안의 막대(
|)가 칸을 더 쪼개버리는 경우. 이게 손수 만든 포맷의 한계예요. - "캡션에
|가 있으면?" 의 답 — 칸 구분이 꼬여 복원이 망가져요. 피하는 방법은 ① 다른 구분자를 쓰거나 ② 자유 텍스트인 캡션을 맨 뒤에 두고limit으로 묶거나 ③ 아예 JSON 같은 검증된 포맷을 쓰는 거예요. 손수 만든 포맷의 약점을 정확히 짚었다면 충분해요.
과제 3 예시답안 — 프로필을 직렬화하되 비밀번호는 빼고 저장하기
핵심 접근
객체를 통째로 저장하되 민감한 값만 빼는 과제예요. UserProfile 에 Serializable 을 붙여 직렬화가 가능하게 하고, password 필드 앞에 transient 를 붙여 저장에서 빠지게 해요. 저장·복원은 ObjectOutputStream/ObjectInputStream 으로 하면 한 줄씩이면 충분해요. 불러오면 username·bio 는 그대로지만 password 는 null 로 돌아와요.
예시 구현
// com/instagram/javabasic/io/solution/day38/UserProfile.java
public class UserProfile implements Serializable {
private static final long serialVersionUID = 1L;
private final String username;
private final String bio;
// 민감 정보라 파일에 남기지 않아요 → 역직렬화 후 null 로 돌아와요.
private transient String password;
public UserProfile(String username, String bio, String password) {
this.username = username;
this.bio = bio;
this.password = password;
}
// username() · bio() · password() 접근자 생략
}
// com/instagram/javabasic/io/solution/day38/ProfileStore.java
// 프로필 객체를 통째로 파일에 저장해요.
public void save(File file, UserProfile profile) throws IOException {
try (ObjectOutputStream out = new ObjectOutputStream(
new FileOutputStream(file))) {
out.writeObject(profile);
}
}
// 파일에서 프로필 객체를 되살려 돌려줘요.
public UserProfile load(File file) throws IOException, ClassNotFoundException {
try (ObjectInputStream in = new ObjectInputStream(
new FileInputStream(file))) {
return (UserProfile) in.readObject();
}
}
저장 전 password 에 값을 넣고 저장했다 불러오면, username·bio 는 그대로인데 password 만 null 이에요.
채점 포인트
UserProfile에Serializable을 구현하고serialVersionUID를 선언했는가password필드에transient를 붙여 저장에서 뺐는가ObjectOutputStream/ObjectInputStream으로 저장·복원했는가- 복원 후
password가null, 나머지 필드는 그대로임을 확인했는가
흔한 실수
Serializable을 빼먹는 경우. 그러면writeObject에서NotSerializableException이 나며 거부돼요.transient를 엉뚱한 필드에 붙이거나, 빼야 할password에 안 붙이는 경우. 그러면 비밀번호가 파일에 그대로 남아 위험해요.- "민감한 값을
transient로 빼는 게 왜 중요한가" 의 답 — 파일은 전원을 꺼도 남으니, 비밀번호가 저장되면 그 파일이 유출됐을 때 그대로 노출돼요.transient로 빼면 객체엔 잠깐 들고 있되 파일엔 남기지 않을 수 있어요.
생각해볼 주제 예시답안
생각해볼 주제 1 예시답안 — 바이트 스트림과 문자 스트림, 왜 둘 다 필요할까?
[문제 상황 요약]
사진은 바이트 스트림으로, 캡션은 문자 스트림으로 다뤘어요. 그런데 글자도 결국 바이트로 저장되니, 바이트 스트림 하나로 전부 다루면 안 되나 하는 의문이에요.
[튜터의 가이드 및 해설]
핵심은 "해석이 필요한가" 예요. 바이트 스트림은 데이터를 숫자 덩어리 그대로 나를 뿐, 그게 무슨 뜻인지 해석하지 않아요. 사진처럼 애초에 글자가 아닌 데이터는 이게 딱 맞아요. 해석할 필요 없이 바이트를 있는 그대로 옮기면 되니까요.
그런데 글자는 사정이 달라요. 한글 "안" 은 UTF-8 에서 [0xEC, 0x95, 0x88] 세 바이트인데, 바이트 스트림으로 읽으면 그냥 숫자 세 개를 받을 뿐이에요. 이걸 "안" 이라는 한 글자로 묶으려면 누군가 "UTF-8 규칙으로 해석하라" 고 해줘야 해요. 문자 스트림(Reader/Writer)이 바로 그 해석을 대신 해줘요. 인코딩만 알려주면 바이트를 글자로, 글자를 바이트로 알아서 변환하죠.
반대로 사진 데이터를 문자 스트림으로 읽으면 어떻게 될까요? 글자가 아닌 바이트를 억지로 글자로 해석하려다 데이터가 망가져요. 그래서 둘은 경쟁 관계가 아니라 역할 분담이에요. 해석이 필요 없는 바이너리엔 바이트 스트림, 인코딩 해석이 필요한 텍스트엔 문자 스트림.
🎯 면접관을 홀리는 핵심 멘트
"바이트 스트림은 데이터를 해석 없이 그대로 나르고, 문자 스트림은 그 위에 인코딩 해석을 얹은 거예요. 사진 같은 바이너리는 해석이 필요 없으니 바이트 스트림, 한글 텍스트는 UTF-8 같은 인코딩으로 바이트와 글자를 변환해야 하니 문자 스트림을 씁니다. 텍스트를 바이트 스트림으로 다루면 인코딩을 직접 풀어야 하고, 바이너리를 문자 스트림으로 다루면 데이터가 깨져요. 그래서 둘 다 필요합니다."
생각해볼 주제 2 예시답안 — 버퍼는 왜 빠를까, 그리고 닫기를 빠뜨리면 왜 위험할까?
[문제 상황 요약]
버퍼는 "모아뒀다가 한 번에" 보내는 임시 통이라 빠르다고 했어요. 그렇다면 통이 가득 차기 전에 스트림을 닫지 않고 프로그램이 끝나면, 통에 남아 있던 데이터는 어떻게 될까 하는 질문이에요.
[튜터의 가이드 및 해설]
먼저 버퍼가 빠른 이유부터 짚어요. 디스크에 한 번 다녀오는 건 메모리 접근보다 수천 배 느려요. 글자 하나마다 디스크를 왕복하면 그 느린 왕복이 수십, 수백 번 쌓여요. 버퍼는 글자를 메모리의 임시 통에 모아뒀다가 가득 차면 한 번에 보내요. 느린 디스크 왕복 횟수를 확 줄이는 게 빠름의 비결이에요.
그런데 바로 이 "모아둔다" 가 함정이 될 수 있어요. 버퍼에 글을 썼는데 아직 통이 가득 차지 않았다면, 그 데이터는 메모리에만 있고 파일엔 아직 안 닿았어요. 이때 스트림을 닫지 않고 프로그램이 끝나면, 통에 남아 있던 데이터는 디스크로 가지 못한 채 사라져요. 그래서 파일을 열어보면 마지막 부분이 비어 있는 사고가 나죠.
close() 는 단순한 정리가 아니에요. 닫을 때 버퍼에 남은 데이터를 마저 파일로 밀어 넣는(flush) 중요한 일을 해요. try-with-resources 를 쓰면 블록이 끝날 때 이 close() 가 자동으로 불려서, 남은 데이터까지 안전하게 저장돼요. "버퍼는 빠름을 주는 대신 닫기를 요구한다" 고 기억하면 좋아요.
🎯 면접관을 홀리는 핵심 멘트
"버퍼는 느린 디스크 왕복을 줄이려고 데이터를 메모리에 모았다가 한 번에 내보내는 구조예요. 그래서 빠르지만, 닫지 않으면 버퍼에 남은 데이터가 디스크에 안 써진 채 사라질 수 있어요. close() 는 닫으면서 남은 데이터를 flush 해주는 역할을 하고, try-with-resources 가 이걸 자동으로 보장합니다. 빠름의 대가가 곧 닫기의 책임인 셈이죠."
생각해볼 주제 3 예시답안 — 자바 직렬화는 편한데, 실무에선 왜 JSON 을 더 많이 쓸까?
[문제 상황 요약]
Serializable 은 객체를 한 줄로 저장하고 되살릴 만큼 편했어요. 그런데 실무에서는 신뢰할 수 없는 데이터엔 쓰지 말라고 해요. 편한 도구를 왜 굳이 멀리하는지에 대한 질문이에요.
[튜터의 가이드 및 해설]
세 가지 함정을 차례로 떠올려봐요. 첫째는 보안이에요. readObject() 는 받은 바이트로 객체를 만들어내는데, 그 바이트가 외부에서 왔다면 공격자가 조작한 데이터로 프로그램이 원치 않는 객체를 만들고 동작하게 할 수 있어요. 이게 역직렬화 공격이고, 지금도 자바에서 가장 조심하는 보안 약점이에요.
둘째는 버전 문제예요. 클래스에 필드를 추가하거나 바꾸면 serialVersionUID 가 안 맞아, 예전에 저장한 파일을 못 읽고 오류가 나요. 셋째는 언어 종속이에요. 자바 직렬화가 만든 바이트는 자바만 이해해서, 파이썬이나 자바스크립트로 만든 프로그램과는 주고받을 수 없어요.
이제 "다른 회사 서버가 보낸 데이터를 우리 자바 프로그램이 받아 되살린다" 는 상황을 상상해봐요. 보낸 쪽이 자바가 아닐 수도 있고, 그 데이터를 믿을 수도 없죠. 자바 직렬화로는 위험하고 호환도 안 돼요. 반면 JSON 은 사람이 읽을 수 있고, 어떤 언어든 이해하며, 객체를 함부로 만들어내지 않아 더 안전해요. 그래서 신뢰 경계를 넘거나 오래 보관할 데이터는 JSON 을 쓰고, 자바 직렬화는 자바 내부의 믿을 수 있는 데이터에만 한정해서 써요.
🎯 면접관을 홀리는 핵심 멘트
"자바 직렬화는 편하지만 세 가지 약점이 있어요. 신뢰할 수 없는 바이트를 readObject 하면 역직렬화 공격에 노출되고, 클래스가 바뀌면 serialVersionUID 가 안 맞아 못 읽고, 자바끼리만 통하죠. 그래서 신뢰 경계를 넘는 데이터나 외부 교환에는 언어 무관하고 더 안전한 JSON 을 쓰고, Serializable 은 자바 내부의 믿을 수 있는 데이터로 한정합니다. 편리함보다 안전과 호환을 기준으로 고르는 거예요."