본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
24장 : Path·Files와 파일 영속화

Path·Files와 텍스트 파일 저장소

Path와 Files로 UTF-8 텍스트 파일을 만들고 읽는 첫 단계부터 시작해 회원 저장 요구를 안전한 파일 저장소로 확장합니다.

파일에 데이터를 저장하려면 먼저 경로파일 작업을 구분해야 합니다. Pathnotes/today.txt처럼 파일이나 디렉터리의 위치를 표현하는 값이고, Files는 그 위치에 디렉터리를 만들거나 내용을 읽고 쓰는 작업을 제공합니다. Path를 만들었다고 실제 파일이 생기는 것은 아닙니다.

이 장은 ch23에서 배운 문자 인코딩과 스트림 닫기를 바탕으로 합니다. 먼저 한 파일을 만들고 읽은 뒤, 여러 회원을 저장하는 형식과 저장소 구조로 확장합니다. 처음에는 상대 경로와 UTF-8만 사용하고, 경로 보안과 원자적 교체 같은 심화 주제는 뒤에서 다룹니다.

첫 UTF-8 파일 만들기

다음 프로그램은 현재 작업 디렉터리 아래에 data 디렉터리를 만들고 hello.txt를 저장한 뒤 다시 읽습니다. 상대 경로는 java 명령을 실행한 디렉터리를 기준으로 해석됩니다.

src/FirstTextFile.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 FirstTextFile

resolve는 디렉터리 경로 뒤에 자식 경로를 붙입니다. createDirectories는 필요한 부모 디렉터리를 함께 만들며 이미 존재해도 정상 처리합니다. writeString은 파일이 없으면 만들고 기본적으로 기존 내용을 덮어씁니다. readString은 파일 전체를 문자열로 읽으므로 매우 큰 파일에는 줄 단위 스트림이나 버퍼를 사용해야 합니다.

메모리 목록은 회원 등록과 조회라는 업무 규칙을 빠르게 확인할 수 있지만 프로세스가 끝나면 사라집니다. 파일 구현으로 바꿀 때 콘솔 흐름과 업무 코드를 함께 수정하면 저장 형식 변경이 애플리케이션 전체로 번집니다. MemberRepository처럼 작은 구분을 두면 호출자는 addfindAll만 알고 메모리와 파일 구현의 차이는 조립 지점에 남습니다.

인터페이스가 있다고 영속성이 자동으로 안전해지는 것은 아닙니다. 텍스트 행의 문법, Charset, 중복 ID 정책, 손상 행 처리, 원자적 갱신, 동시 접근을 구현 요구사항으로 정해야 합니다. 예제 크기에서는 단순 추가가 편하지만 운영 데이터에서는 쓰기 중 프로세스 종료와 필드 안 구분자를 반드시 고려합니다.

쉼표 split과 데이터·문법의 혼합

이름에 쉼표가 들어가면 단순 split(",")은 한 레코드를 네 필드로 나눕니다. 빈 문자열 처리, 줄바꿈, 역슬래시도 별도 규칙이 필요합니다. 구분자를 사용자 입력에 절대 들어오지 않는다고 가정하면 데이터가 늘어난 뒤 형식을 바꾸기 어렵습니다.

bad/NaiveDelimitedMember.java
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로 바꾸거나 길이 접두사를 두는 등 모호하지 않은 문법을 정하고 버전까지 기록합니다.

메모리 저장소의 목록 캡슐화

findAll이 내부 ArrayList를 반환하면 호출자가 clear를 수행해 저장소 상태를 지울 수 있습니다. 저장한 Member가 가변 객체라면 바깥에서 필드를 바꿔 ID 불변식도 깨집니다. 학습 예제부터 불변 recordList.copyOf를 사용하면 파일 구현으로 바뀌어도 조회 결과 소유권이 일관됩니다.

src/MemoryMemberRepository.java
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 알파벳에는 탭과 줄바꿈이 없어 구분이 모호하지 않습니다.

src/MemberTextCodec.java
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)));
    }
}

이 형식은 사람이 바로 읽기는 어렵지만 구분자 이스케이프를 단순화합니다. 요구가 사람이 편집하는 파일이라면 정식 CSV나 JSON이 더 적합합니다. 저장 형식 선택은 예제 코드의 짧음이 아니라 누가 만들고 읽고 복구하는지에 따라 결정합니다.

임시 파일을 이용한 스냅샷 교체

기존 파일을 직접 덮어쓰다 프로세스가 멈추면 절반만 기록된 파일이 남습니다. 같은 디렉터리에 임시 파일을 완성하고 flushclose가 끝난 뒤 최종 경로로 이동하면 독자는 이전 버전 또는 새 버전 중 하나를 보게 할 수 있습니다. 파일 시스템이 원자 이동을 지원하지 않을 때의 대체 정책도 필요합니다.

src/AtomicTextSnapshot.java
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);
        }
    }
}

원자 이동은 같은 파일 시스템 안에서 시도해야 하므로 임시 파일을 대상 디렉터리에 만듭니다. 대체 이동은 교체 중 짧은 위험이 있을 수 있어 애플리케이션 요구에 따라 실패로 처리할 수도 있습니다. 강한 내구성이 필요하면 디렉터리와 파일 동기화까지 검토해야 하며 단순 move만으로 전원 손실 복구를 단정하지 않습니다.

append 로그와 스냅샷의 선택

매 등록마다 한 행을 추가하면 쓰기 비용이 작지만 중복, 삭제, 갱신을 표현하려면 이벤트 종류와 순번이 필요합니다. 시작 때 전체 로그를 재생하므로 파일이 계속 커집니다. 주기적인 압축 스냅샷과 체크섬을 추가하면 복구 시간이 줄지만 구현 복잡도가 늘어납니다.

전체 스냅샷 교체는 현재 상태를 읽기 쉽고 손상 구간이 단순하지만 회원 수가 커질수록 매 변경 비용이 큽니다. 이 지점부터는 파일을 직접 확장하기보다 트랜잭션과 인덱스를 제공하는 데이터베이스가 보통 더 적합합니다. 학습 저장소의 한계를 제품 저장소의 설계 근거로 연결하세요.

연습 문제

동일한 시나리오를 메모리 저장소와 임시 파일 저장소에 실행해 결과가 같은지 확인하는 검사 함수를 작성하세요. ID 중복은 둘 다 거부하고 조회 목록을 호출자가 수정해도 내부 상태가 변하지 않아야 합니다.

정답과 해설
exercise/RepositoryContractSolution.java
import java.util.ArrayList;
import java.util.List;

public final class RepositoryContractSolution {
    record Member(String id, String name) {}
    interface Repository { void add(Member member); List<Member> findAll(); }

    static final class MemoryRepository implements Repository {
        private final List<Member> state = new ArrayList<>();
        public void add(Member member) {
            if (state.stream().anyMatch(value -> value.id().equals(member.id()))) {
                throw new IllegalArgumentException("duplicate");
            }
            state.add(member);
        }
        public List<Member> findAll() { return List.copyOf(state); }
    }

    static void verify(Repository repository) {
        repository.add(new Member("a", "Alice"));
        repository.add(new Member("b", "Bob"));
        if (!repository.findAll().equals(List.of(
                new Member("a", "Alice"), new Member("b", "Bob")))) {
            throw new AssertionError("contract mismatch");
        }
        try {
            repository.add(new Member("a", "Again"));
            throw new AssertionError("duplicate accepted");
        } catch (IllegalArgumentException expected) {
            System.out.println("duplicate rejected");
        }
    }

    public static void main(String[] args) {
        verify(new MemoryRepository());
    }
}

파일 구현에도 같은 검사를 적용하면 저장 매체 교체가 업무 의미를 바꾸지 않았는지 확인할 수 있습니다. 파일에서만 생기는 손상 행, Charset, 원자 이동 검사는 별도의 구현 전용 시나리오로 보충합니다.

저장소 구현 교체를 승인하는 조건

저장소 추상화의 목적은 구현 클래스를 숨기는 데서 끝나지 않습니다. 중복·조회 순서·반환 소유권·실패 의미를 공통 규칙으로 고정하고, 파일 코덱과 원자 교체를 별도 책임으로 나누면 메모리 학습 예제를 영속 데이터 설계로 안전하게 확장할 수 있습니다.