Path·Files와 텍스트 파일 저장소
Path와 Files로 UTF-8 텍스트 파일을 만들고 읽는 첫 단계부터 시작해 회원 저장 요구를 안전한 파일 저장소로 확장합니다.
파일에 데이터를 저장하려면 먼저 경로와 파일 작업을 구분해야 합니다.
Path는 notes/today.txt처럼 파일이나 디렉터리의 위치를 표현하는 값이고, Files는 그 위치에 디렉터리를 만들거나 내용을 읽고 쓰는 작업을 제공합니다.
Path를 만들었다고 실제 파일이 생기는 것은 아닙니다.
이 장은 ch23에서 배운 문자 인코딩과 스트림 닫기를 바탕으로 합니다.
먼저 한 파일을 만들고 읽은 뒤, 여러 회원을 저장하는 형식과 저장소 구조로 확장합니다.
처음에는 상대 경로와 UTF-8만 사용하고, 경로 보안과 원자적 교체 같은 심화 주제는 뒤에서 다룹니다.
첫 UTF-8 파일 만들기
다음 프로그램은 현재 작업 디렉터리 아래에 data 디렉터리를 만들고 hello.txt를 저장한 뒤 다시 읽습니다.
상대 경로는 java 명령을 실행한 디렉터리를 기준으로 해석됩니다.
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
public final class FirstTextFile {
public static void main(String[] args) throws Exception {
Path directory = Path.of("data");
Path file = directory.resolve("hello.txt");
Files.createDirectories(directory);
Files.writeString(file, "안녕하세요, 파일!", StandardCharsets.UTF_8);
String text = Files.readString(file, StandardCharsets.UTF_8);
System.out.println(text);
System.out.println(file.toAbsolutePath());
}
}javac --release 25 -d out src/FirstTextFile.java
java -cp out FirstTextFileresolve는 디렉터리 경로 뒤에 자식 경로를 붙입니다.
createDirectories는 필요한 부모 디렉터리를 함께 만들며 해당 경로가 이미 디렉터리이면 정상 처리합니다. 같은 이름의 일반 파일이 존재하는 경우까지 성공한다는 뜻은 아닙니다.
writeString은 파일이 없으면 만들고 기본적으로 기존 내용을 덮어씁니다.
readString은 파일 전체를 문자열로 읽으므로 매우 큰 파일에는 줄 단위 스트림이나 버퍼를 사용해야 합니다.
메모리 목록은 회원 등록과 조회라는 업무 규칙을 빠르게 확인할 수 있지만 프로세스가 끝나면 사라집니다.
파일 구현으로 바꿀 때 콘솔 흐름과 업무 코드를 함께 수정하면 저장 형식 변경이 애플리케이션 전체로 번집니다.
MemberRepository처럼 작은 인터페이스 경계를 두면 호출자는 add와 findAll만 알고 메모리와 파일 구현의 차이는 조립 지점에 남습니다.
인터페이스가 있다고 영속성이 자동으로 안전해지는 것은 아닙니다.
텍스트 행의 문법, Charset, 중복 ID 정책, 손상 행 처리, 원자적 갱신, 동시 접근을 구현 요구사항으로 정해야 합니다.
예제 크기에서는 단순 추가가 편하지만 운영 데이터에서는 쓰기 중 프로세스 종료와 필드 안 구분자를 반드시 고려합니다.
쉼표 split과 데이터·문법의 혼합
이름에 쉼표가 들어가면 단순 split(",")은 한 레코드를 네 필드로 나눕니다.
빈 문자열 처리, 줄바꿈, 역슬래시도 별도 규칙이 필요합니다.
구분자가 사용자 입력에 절대 들어오지 않는다고 가정하면 데이터가 늘어난 뒤 형식을 바꾸기 어렵습니다.
import java.util.Arrays;
public final class NaiveDelimitedMember {
record Member(String id, String name, int age) {}
static String encode(Member member) {
return member.id() + "," + member.name() + "," + member.age();
}
static Member decode(String line) {
String[] fields = line.split(",");
return new Member(fields[0], fields[1], Integer.parseInt(fields[2]));
}
public static void main(String[] args) {
String line = encode(new Member("u1", "Kim, Mina", 31));
System.out.println(line);
System.out.println(Arrays.toString(line.split(",")));
System.out.println(decode(line));
}
}마지막 호출은 숫자 위치가 바뀌어 실패합니다.
CSV가 필요하면 인용, 이스케이프, 줄바꿈을 구현한 검증된 라이브러리를 사용합니다.
작은 자체 형식이라면 각 문자열을 Base64로 바꾸거나 길이 접두사를 두는 등 모호하지 않은 문법을 정하고 버전까지 기록합니다.
쉼표가 이름을 두 필드로 나누는 위치
split(',')로 인용된 이름이 두 필드로 갈라진 뒤 Mina를 나이로 읽어 NumberFormatException이 납니다. 실제 필드 인덱스와 실패가 발생해 실행이 멈추는 위치를 단계로 확인할 수 있습니다.
NaiveDelimitedMember의 u1,Kim, Mina,31을 split한 결과와 decode가 기대하는 위치의 불일치입니다.
구분자 · 필드 위치 불일치
쉼표가 이름을 두 필드로 나누는 위치
이름의 쉼표가 필드를 하나 더 만들고, 나이 자리에 온 Mina에서 정수 변환이 실패합니다.
split 결과의 위치와 나이 변환이 어긋난다
쉼표가 들어 있는 Kim, Mina가 두 칸으로 갈라진다. decode는 fields[2]의 Mina를 정수로 읽다가 실패하고 fields[3]의 실제 나이 31은 참조하지 않는다.
상태별 상세 해설
fields[0]
- 분리된 문자열
- u1
- decode에서의 의미
- ID로 사용
fields[1]
- 분리된 문자열
- Kim
- decode에서의 의미
- 이름으로 사용 · Mina를 잃음
fields[2]
- 분리된 문자열
- ␠Mina
- decode에서의 의미
- 나이로 정수 변환하다 NumberFormatException
fields[3]
- 분리된 문자열
- 31
- decode에서의 의미
- 원래 나이지만 코드에서 참조하지 않음
␠는 선행 공백입니다. 예외는 decode 안에서 발생하므로 마지막 println은 Member를 출력하지 못합니다.
이 예의 조건
- ␠는 선행 공백입니다. 쉼표를 이스케이프하는 처리는 없습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 쉼표가 이름을 두 필드로 나누는 위치 쉼표가 이름을 두 필드로 나누는 위치 NaiveDelimitedMember의 u1,Kim, Mina,31을 split한 결과와 decode가 기대하는 위치의 불일치입니다. 구분자 · 필드 위치 불일치 쉼표가 이름을 두 필드로 나누는 위치 이름의 쉼표가 필드를 하나 더 만들고, 나이 자리에 온 Mina에서 정수 변환이 실패합니다. fields[0] 분리된 문자열 u1 decode에서의 의미 ID로 사용 fields[1] 분리된 문자열 Kim decode에서의 의미 이름으로 사용 · Mina를 잃음 fields[2] 분리된 문자열 ␠Mina decode에서의 의미 나이로 정수 변환하다 NumberFormatException fields[3] 분리된 문자열 31 decode에서의 의미 원래 나이지만 코드에서 참조하지 않음 ␠는 선행 공백입니다. 예외는 decode 안에서 발생하므로 마지막 println은 Member를 출력하지 못합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 구분자로 분리 u1,Kim, Mina,31을 네 필드로 split합니다. |
|
|
| 잘못된 위치 변환 decode가 fields[2]를 나이로 parse합니다. |
|
|
| 실패 반환 경계 decode 예외 때문에 마지막 println에 도달하지 않습니다. |
|
|
쉼표가 이름을 두 필드로 나누는 위치
split(',')로 인용된 이름이 두 필드로 갈라진 뒤 Mina를 나이로 읽어 NumberFormatException이 납니다. 실제 필드 인덱스와 실패가 발생해 실행이 멈추는 위치를 단계로 확인할 수 있습니다.
NaiveDelimitedMember의 u1,Kim, Mina,31을 split한 결과와 decode가 기대하는 위치의 불일치입니다.
구분자 · 필드 위치 불일치
쉼표가 이름을 두 필드로 나누는 위치
이름의 쉼표가 필드를 하나 더 만들고, 나이 자리에 온 Mina에서 정수 변환이 실패합니다.
split 결과의 위치와 나이 변환이 어긋난다
쉼표가 들어 있는 Kim, Mina가 두 칸으로 갈라진다. decode는 fields[2]의 Mina를 정수로 읽다가 실패하고 fields[3]의 실제 나이 31은 참조하지 않는다.
상태별 상세 해설
fields[0]
- 분리된 문자열
- u1
- decode에서의 의미
- ID로 사용
fields[1]
- 분리된 문자열
- Kim
- decode에서의 의미
- 이름으로 사용 · Mina를 잃음
fields[2]
- 분리된 문자열
- ␠Mina
- decode에서의 의미
- 나이로 정수 변환하다 NumberFormatException
fields[3]
- 분리된 문자열
- 31
- decode에서의 의미
- 원래 나이지만 코드에서 참조하지 않음
␠는 선행 공백입니다. 예외는 decode 안에서 발생하므로 마지막 println은 Member를 출력하지 못합니다.
이 예의 조건
- ␠는 선행 공백입니다. 쉼표를 이스케이프하는 처리는 없습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 쉼표가 이름을 두 필드로 나누는 위치 쉼표가 이름을 두 필드로 나누는 위치 NaiveDelimitedMember의 u1,Kim, Mina,31을 split한 결과와 decode가 기대하는 위치의 불일치입니다. 구분자 · 필드 위치 불일치 쉼표가 이름을 두 필드로 나누는 위치 이름의 쉼표가 필드를 하나 더 만들고, 나이 자리에 온 Mina에서 정수 변환이 실패합니다. fields[0] 분리된 문자열 u1 decode에서의 의미 ID로 사용 fields[1] 분리된 문자열 Kim decode에서의 의미 이름으로 사용 · Mina를 잃음 fields[2] 분리된 문자열 ␠Mina decode에서의 의미 나이로 정수 변환하다 NumberFormatException fields[3] 분리된 문자열 31 decode에서의 의미 원래 나이지만 코드에서 참조하지 않음 ␠는 선행 공백입니다. 예외는 decode 안에서 발생하므로 마지막 println은 Member를 출력하지 못합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 구분자로 분리 u1,Kim, Mina,31을 네 필드로 split합니다. |
|
|
| 잘못된 위치 변환 decode가 fields[2]를 나이로 parse합니다. |
|
|
| 실패 반환 경계 decode 예외 때문에 마지막 println에 도달하지 않습니다. |
|
|
메모리 저장소의 목록 캡슐화
findAll이 내부 ArrayList를 반환하면 호출자가 clear를 수행해 저장소 상태를 지울 수 있습니다.
저장한 Member가 가변 객체라면 바깥에서 필드를 바꿔 ID 불변식도 깨집니다.
학습 예제부터 불변 record와 List.copyOf를 사용하면 파일 구현으로 바뀌어도 조회 결과 소유권이 일관됩니다.
import java.util.ArrayList;
import java.util.List;
public final class MemoryMemberRepository {
public record Member(String id, String name, int age) {
public Member {
if (id == null || id.isBlank()) throw new IllegalArgumentException("id");
if (name == null || name.isBlank()) throw new IllegalArgumentException("name");
if (age < 0) throw new IllegalArgumentException("age");
}
}
private final List<Member> members = new ArrayList<>();
public synchronized void add(Member member) {
if (members.stream().anyMatch(existing -> existing.id().equals(member.id()))) {
throw new IllegalArgumentException("duplicate id: " + member.id());
}
members.add(member);
}
public synchronized List<Member> findAll() {
return List.copyOf(members);
}
public static void main(String[] args) {
var repository = new MemoryMemberRepository();
repository.add(new Member("u1", "Mina", 31));
System.out.println(repository.findAll());
}
}synchronized는 한 인스턴스 안의 간단한 경쟁만 막습니다.
여러 프로세스가 같은 파일을 수정하는 문제는 해결하지 못합니다.
파일 잠금이나 단일 기록기 구조, 데이터베이스 트랜잭션처럼 저장 매체 수준의 조정이 필요합니다.
여기서는 저장소 규칙과 반환 소유권에 집중합니다.
텍스트 코덱과 저장소 책임 분리
레코드 변환 규칙을 별도 코덱으로 두면 파일 접근 없이 문자열만으로 손상 입력을 시험할 수 있습니다.
아래 코덱은 ID와 이름을 URL-안전 Base64로 표현하고 탭으로 고정 세 필드를 나눕니다.
Base64 알파벳에는 탭과 줄바꿈이 없어 구분이 모호하지 않습니다.
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public final class MemberTextCodec {
record Member(String id, String name, int age) {}
private static String text(String value) {
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(value.getBytes(StandardCharsets.UTF_8));
}
private static String value(String encoded) {
return new String(Base64.getUrlDecoder().decode(encoded), StandardCharsets.UTF_8);
}
static String encode(Member member) {
return text(member.id()) + "\t" + text(member.name()) + "\t" + member.age();
}
static Member decode(String line) {
String[] fields = line.split("\t", -1);
if (fields.length != 3) throw new IllegalArgumentException("field count");
int age = Integer.parseInt(fields[2]);
if (age < 0) throw new IllegalArgumentException("negative age");
return new Member(value(fields[0]), value(fields[1]), age);
}
public static void main(String[] args) {
Member source = new Member("id\t1", "Kim, Mina\n팀장", 31);
String row = encode(source);
System.out.println(row);
System.out.println(source.equals(decode(row)));
}
}이 형식은 사람이 바로 읽기는 어렵지만 구분자 이스케이프를 단순화합니다. 이 코덱은 필드 수와 음수 나이는 검사하지만 앞선 Member의 빈 ID·이름 규칙이나 형식 버전은 구현하지 않습니다.
Base64가 정상이어도 복원한 바이트의 잘못된 UTF-8은 new String에서 대체 문자로 바뀔 수 있습니다. 엄격한 손상 검출은 별도 디코더 정책이 필요합니다.
요구가 사람이 편집하는 파일이라면 정식 CSV나 JSON이 더 적합합니다.
저장 형식 선택은 예제 코드의 짧음이 아니라 누가 만들고 읽고 복구하는지에 따라 결정합니다.
임시 파일을 이용한 스냅샷 교체
기존 파일을 직접 덮어쓰다 프로세스가 멈추면 절반만 기록된 파일이 남습니다.
같은 디렉터리에 임시 파일을 완성한 뒤 원자적 이동에 성공하면 독자가 미완성 파일을 보는 구간을 피할 수 있습니다. 기존 대상의 교체 지원과 원자 이동 실패 시 정책은 파일 시스템 계약에 따라 확인해야 합니다.
파일 시스템이 원자 이동을 지원하지 않을 때의 대체 정책도 필요합니다.
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.List;
public final class AtomicTextSnapshot {
static void replace(Path target, List<String> rows) throws Exception {
Path parent = target.toAbsolutePath().getParent();
Files.createDirectories(parent);
Path temp = Files.createTempFile(parent, target.getFileName().toString(), ".tmp");
boolean moved = false;
try {
Files.write(temp, rows, StandardCharsets.UTF_8);
try {
Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException unsupported) {
Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING);
}
moved = true;
} finally {
if (!moved) Files.deleteIfExists(temp);
}
}
public static void main(String[] args) throws Exception {
Path directory = Files.createTempDirectory("members-");
Path file = directory.resolve("members.txt");
try {
replace(file, List.of("v1", "row-a", "row-b"));
System.out.println(Files.readAllLines(file, StandardCharsets.UTF_8));
} finally {
Files.deleteIfExists(file);
Files.deleteIfExists(directory);
}
}
}AtomicTextSnapshot은 원자 이동을 먼저 시도하고 지원하지 않을 때 일반 이동으로 대체하지만 두 경로의 보장은 같지 않습니다.
스냅샷 · 이동 경로별 보장
스냅샷 이동 경로별 보장 범위
원자 이동이 지원되지 않아 일반 이동으로 바뀌면, 원자성 보장도 달라집니다.
ATOMIC_MOVE 시도
- 보장 또는 처리
- 성공하면 이동 자체가 원자적
- 남는 한계
- 기존 대상의 교체 또는 예외는 구현별로 다름
원자 이동 미지원
- 보장 또는 처리
- REPLACE_EXISTING으로 다시 이동
- 남는 한계
- 일반 이동이므로 원자성 보장 없음
그 밖의 이동 실패
- 보장 또는 처리
- 예외 전파 · 임시 파일 삭제 시도
- 남는 한계
- 일반 이동의 I/O 실패 뒤 대상 상태는 확정할 수 없음
ATOMIC_MOVE에서는 다른 이동 옵션이 무시됩니다. main은 대상이 없던 경로에 저장하며, 기존 파일 교체·실패 복구·전원 손실 내구성을 시험하지 않습니다.
원자 이동의 가능성을 높이기 위해 임시 파일을 대상 디렉터리에 만듭니다. 같은 디렉터리라는 사실만으로 모든 공급자의 원자 이동 지원이 보장되지는 않습니다.
원자성이 필수인 제품이라면 원자 이동 미지원도 실패로 처리해야 합니다.
강한 내구성이 필요하면 디렉터리와 파일 동기화까지 검토해야 하며 단순 move만으로 전원 손실 복구를 단정하지 않습니다.
append 로그와 스냅샷의 선택
매 등록마다 한 행을 추가하면 쓰기 비용이 작지만 중복, 삭제, 갱신을 표현하려면 이벤트 종류와 순번이 필요합니다.
시작 때 전체 로그를 재생하므로 파일이 계속 커집니다.
주기적인 압축 스냅샷과 체크섬을 추가하면 복구 시간이 줄지만 구현 복잡도가 늘어납니다.
전체 스냅샷 교체는 현재 상태를 읽기 쉽고 손상 구간이 단순하지만 회원 수가 커질수록 매 변경 비용이 큽니다.
이 지점부터는 파일을 직접 확장하기보다 트랜잭션과 인덱스를 제공하는 데이터베이스가 보통 더 적합합니다.
학습 저장소의 한계를 제품 저장소의 설계 근거로 연결하세요.
연습 문제
같은 검사 함수를 메모리 저장소와 임시 파일 저장소에 실행해 결과가 같은지 확인하세요.
회원 두 명을 등록한 순서대로 조회할 수 있어야 합니다. 같은 ID를 다시 등록하면 거부하고, 거부한 뒤에도 기존 회원이 그대로 남아 있어야 합니다.
조회 결과는 수정할 수 없는 스냅샷으로 반환하세요. 반환 목록에 clear()를 시도해도 저장소의 내용은 보존되어야 하며, 나중에 회원을 추가해도 먼저 받은 목록은 바뀌지 않아야 합니다.
파일 구현에는 탭·쉼표·줄바꿈·한글이 들어간 회원도 저장해 보세요. 같은 파일을 새 저장소 객체로 다시 열어 값이 같은지 확인하고, 검사에 사용한 임시 파일과 디렉터리를 정리합니다.
정답과 해설
아래 코드는 한 파일로 실행하는 독립 예제입니다. 저장소 동작에 집중하기 위해 Member에는 ID와 이름 두 필드만 두므로, 앞의 나이까지 포함한 세 필드 예제와 파일 형식을 섞지 않습니다.
문자열은 앞서 본 UTF-8·Base64 방식으로 바꾸고 두 필드를 탭으로 나눕니다. 파일 구현은 작은 파일 전체를 매번 다시 읽고 쓰며, 한 프로세스가 순서대로 사용하는 경우를 확인합니다.
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
public final class RepositoryContractSolution {
record Member(String id, String name) {}
interface Repository {
void add(Member member) throws IOException;
List<Member> findAll() throws IOException;
}
static void rejectDuplicate(List<Member> members, Member member) {
if (members.stream().anyMatch(value -> value.id().equals(member.id()))) {
throw new IllegalArgumentException("duplicate");
}
}
static final class MemoryRepository implements Repository {
private final List<Member> state = new ArrayList<>();
public void add(Member member) {
rejectDuplicate(state, member);
state.add(member);
}
public List<Member> findAll() { return List.copyOf(state); }
}
static final class FileRepository implements Repository {
private final Path file;
FileRepository(Path file) throws IOException {
this.file = file;
try {
Files.createFile(file);
} catch (FileAlreadyExistsException existing) {
// Reopening the same file keeps its current contents.
}
}
private static String encode(String value) {
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(value.getBytes(StandardCharsets.UTF_8));
}
private static String decode(String value) {
return new String(Base64.getUrlDecoder().decode(value), StandardCharsets.UTF_8);
}
public List<Member> findAll() throws IOException {
List<Member> members = new ArrayList<>();
for (String row : Files.readAllLines(file, StandardCharsets.UTF_8)) {
String[] fields = row.split("\t", -1);
if (fields.length != 2) throw new IllegalArgumentException("field count");
members.add(new Member(decode(fields[0]), decode(fields[1])));
}
return List.copyOf(members);
}
public void add(Member member) throws IOException {
List<Member> members = new ArrayList<>(findAll());
rejectDuplicate(members, member);
members.add(member);
List<String> rows = new ArrayList<>();
for (Member value : members) {
rows.add(encode(value.id()) + "\t" + encode(value.name()));
}
Files.write(file, rows, StandardCharsets.UTF_8);
}
}
static List<Member> verify(Repository repository) throws IOException {
Member alice = new Member("a", "Alice");
Member bob = new Member("b", "Bob");
List<Member> firstTwo = List.of(alice, bob);
repository.add(alice);
repository.add(bob);
List<Member> snapshot = repository.findAll();
if (!snapshot.equals(firstTwo)) throw new AssertionError("registration order");
try {
repository.add(new Member("a", "Again"));
throw new AssertionError("duplicate accepted");
} catch (IllegalArgumentException expected) {
if (!repository.findAll().equals(firstTwo)) {
throw new AssertionError("duplicate changed state");
}
}
try {
snapshot.clear();
throw new AssertionError("mutable result");
} catch (UnsupportedOperationException expected) {
if (!repository.findAll().equals(firstTwo)) {
throw new AssertionError("clear changed state");
}
}
Member special = new Member("c\t3", "김, 미나\n팀장");
repository.add(special);
if (!snapshot.equals(firstTwo)) throw new AssertionError("old snapshot changed");
List<Member> expected = List.of(alice, bob, special);
if (!repository.findAll().equals(expected)) throw new AssertionError("new member");
return expected;
}
public static void main(String[] args) throws IOException {
List<Member> memoryResult = verify(new MemoryRepository());
System.out.println("memory contract passed");
Path directory = Files.createTempDirectory("repository-contract-");
Path file = directory.resolve("members.txt");
try {
List<Member> fileResult = verify(new FileRepository(file));
if (!fileResult.equals(memoryResult)) throw new AssertionError("results differ");
System.out.println("file contract passed");
if (!new FileRepository(file).findAll().equals(fileResult)) {
throw new AssertionError("reopen changed state");
}
System.out.println("file reopen passed");
} finally {
Files.deleteIfExists(file);
Files.deleteIfExists(directory);
}
if (Files.exists(directory)) throw new AssertionError("temporary directory remained");
System.out.println("temporary files removed");
}
}예제 코드를 exercise/RepositoryContractSolution.java로 저장한 뒤 JDK 25에서 실행하세요.
javac --release 25 -encoding UTF-8 -d out exercise/RepositoryContractSolution.java
java -cp out RepositoryContractSolution모든 검사가 통과했을 때의 예상 출력은 다음 네 줄입니다. 중간에 예외가 발생하면 이후 성공 메시지는 출력되지 않습니다.
memory contract passed
file contract passed
file reopen passed
temporary files removedverify는 같은 등록·조회·중복 검사를 두 구현에 적용합니다. snapshot.clear()가 거부되는지만 보지 않고, 그 뒤 저장소를 다시 조회해 처음 두 회원이 남아 있는지도 확인합니다. 세 번째 회원을 추가한 뒤에는 새 조회 결과와 예전에 받은 snapshot을 따로 비교합니다.
file reopen passed는 같은 파일을 새 FileRepository로 열어 세 회원을 다시 읽었다는 뜻입니다. finally에서 임시 파일을 지운 뒤 디렉터리를 지우므로, 마지막 줄은 이 정리까지 정상적으로 끝나야 출력됩니다.
AssertionError가 나오면 메시지가 가리키는 검사부터 살펴보세요. registration order는 등록 순서와 조회 결과, old snapshot changed는 내부 목록을 그대로 반환했는지 확인할 단서입니다. IOException이 나오면 실패한 파일 작업과 임시 디렉터리의 쓰기 권한을 먼저 확인합니다. 컴파일러가 --release 25를 지원하지 않으면 javac -version으로 실제 사용 중인 JDK를 확인하세요.
이 FileRepository는 중복을 먼저 확인한 뒤 쓰므로 같은 ID를 거부할 때는 파일을 변경하지 않습니다. 다만 Files.write로 대상 파일을 직접 덮어씁니다. 앞의 AtomicTextSnapshot.replace를 호출하지 않으므로 쓰기 도중 실패했을 때 이전 파일이 보존되는 구현은 아닙니다. 여러 저장소 객체나 프로세스의 동시 변경, 원자적 교체와 전원 손실 복구도 이 검사에서 확인하지 않습니다.
각 행에서 필드 두 개를 기대하지만, 빈 ID·이름이나 형식 버전은 검사하지 않습니다. 모든 손상 행을 검출하는 정책과 엄격한 UTF-8 처리는 별도의 구현 전용 과제로 남깁니다. 정상 입력의 재읽기가 성공했다는 사실을 손상 파일 복구 성공으로 확대하지 않습니다.
저장소 구현 교체를 승인하는 조건
저장소 추상화의 목적은 구현 클래스를 숨기는 데서 끝나지 않습니다.
중복·조회 순서·반환 소유권·실패 의미를 공통 규칙으로 고정하고, 파일 코덱과 원자 교체를 별도 책임으로 나누면 메모리 학습 예제를 영속 데이터 설계로 안전하게 확장할 수 있습니다.