안동민 개발노트

본문 시작

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

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

파일에 데이터를 저장하려면 먼저 경로와 파일 작업을 구분해야 합니다.

Path는 notes/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처럼 작은 구분을 두면 호출자는 add와 findAll만 알고 메모리와 파일 구현의 차이는 조립 지점에 남습니다.

인터페이스가 있다고 영속성이 자동으로 안전해지는 것은 아닙니다.

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

쉼표가 이름을 두 필드로 나누는 위치

NaiveDelimitedMember의 u1,Kim, Mina,31을 split한 결과와 decode가 기대하는 위치의 불일치입니다.

쉼표가 이름을 두 필드로 나누는 위치
인덱스분리된 문자열decode에서의 의미
fields[0]u1ID로 사용
fields[1]Kim이름으로 사용 · Mina를 잃음
fields[2]␠Mina나이로 정수 변환하다 NumberFormatException
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를 출력하지 못합니다.


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

findAll이 내부 ArrayList를 반환하면 호출자가 clear를 수행해 저장소 상태를 지울 수 있습니다.

저장한 Member가 가변 객체라면 바깥에서 필드를 바꿔 ID 불변식도 깨집니다.

학습 예제부터 불변 record와 List.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)));
    }
}

이 형식은 사람이 바로 읽기는 어렵지만 구분자 이스케이프를 단순화합니다. 이 코덱은 필드 수와 음수 나이는 검사하지만 앞선 Member의 빈 ID·이름 규칙이나 형식 버전은 구현하지 않습니다.

Base64가 정상이어도 복원한 바이트의 잘못된 UTF-8은 new String에서 대체 문자로 바뀔 수 있습니다. 엄격한 손상 검출은 별도 디코더 정책이 필요합니다.

요구가 사람이 편집하는 파일이라면 정식 CSV나 JSON이 더 적합합니다.

저장 형식 선택은 예제 코드의 짧음이 아니라 누가 만들고 읽고 복구하는지에 따라 결정합니다.


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

기존 파일을 직접 덮어쓰다 프로세스가 멈추면 절반만 기록된 파일이 남습니다.

같은 디렉터리에 임시 파일을 완성한 뒤 원자적 이동에 성공하면 독자가 미완성 파일을 보는 구간을 피할 수 있습니다. 기존 대상의 교체 지원과 원자 이동 실패 시 정책은 파일 시스템 계약에 따라 확인해야 합니다.

파일 시스템이 원자 이동을 지원하지 않을 때의 대체 정책도 필요합니다.

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);
        }
    }
}
스냅샷 이동 경로별 보장 범위

AtomicTextSnapshot은 원자 이동을 먼저 시도하고 지원하지 않을 때 일반 이동으로 대체하지만 두 경로의 보장은 같지 않습니다.

스냅샷 이동 경로별 보장 범위
실행 경로보장 또는 처리남는 한계
ATOMIC_MOVE 시도성공하면 이동 자체가 원자적기존 대상의 교체 또는 예외는 구현별로 다름
원자 이동 미지원REPLACE_EXISTING으로 다시 이동일반 이동이므로 원자성 보장 없음
그 밖의 이동 실패예외 전파 · 임시 파일 삭제 시도일반 이동의 I/O 실패 뒤 대상 상태는 확정할 수 없음
ATOMIC_MOVE 시도
보장 또는 처리: 성공하면 이동 자체가 원자적
남는 한계: 기존 대상의 교체 또는 예외는 구현별로 다름
원자 이동 미지원
보장 또는 처리: REPLACE_EXISTING으로 다시 이동
남는 한계: 일반 이동이므로 원자성 보장 없음
그 밖의 이동 실패
보장 또는 처리: 예외 전파 · 임시 파일 삭제 시도
남는 한계: 일반 이동의 I/O 실패 뒤 대상 상태는 확정할 수 없음

ATOMIC_MOVE에서는 다른 이동 옵션이 무시됩니다. main은 대상이 없던 경로에 저장하며, 기존 파일 교체·실패 복구·전원 손실 내구성을 시험하지 않습니다.

원자 이동의 가능성을 높이기 위해 임시 파일을 대상 디렉터리에 만듭니다. 같은 디렉터리라는 사실만으로 모든 공급자의 원자 이동 지원이 보장되지는 않습니다.

원자성이 필수인 제품이라면 원자 이동 미지원도 실패로 처리해야 합니다.

강한 내구성이 필요하면 디렉터리와 파일 동기화까지 검토해야 하며 단순 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());
    }
}

위 풀이는 메모리 저장소에 대한 등록·조회 순서·중복 거부 검사의 출발점입니다. main에는 파일 저장소 실행이 없고 verify에도 반환 목록 변경 시도가 없으므로 문제의 모든 조건을 검사한 것은 아닙니다.

List.copyOf로 반환된 목록은 수정할 수 없습니다. 반환 목록 변경이 거부된 뒤 저장소의 내용도 그대로인지 확인하는 검사와, 파일 구현을 같은 verify에 연결하는 작업이 추가로 필요합니다.

파일에서만 생기는 손상 행, Charset, 원자 이동 검사는 별도의 구현 전용 시나리오로 보충합니다.


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

저장소 추상화의 목적은 구현 클래스를 숨기는 데서 끝나지 않습니다.

중복·조회 순서·반환 소유권·실패 의미를 공통 규칙으로 고정하고, 파일 코덱과 원자 교체를 별도 책임으로 나누면 메모리 학습 예제를 영속 데이터 설계로 안전하게 확장할 수 있습니다.