본문 바로가기

Path와 Files로 텍스트 파일 저장소 만들기

Path와 Files로 UTF-8 텍스트 파일을 쓰고 읽는 기본기부터, 회원 목록을 모호하지 않은 한 줄 형식으로 저장하고 임시 파일과 원자적 이동으로 파일을 통째로 안전하게 바꾸는 방법까지 배웁니다.

캡슐화로 회원 저장소 완성하기에서 만든 회원 저장소는 프로그램을 다시 실행하면 비어 있습니다. 회원 목록이 JVM 프로세스의 메모리 안에만 있어서, 프로세스가 끝나면 목록도 함께 사라지기 때문입니다. 다음 실행에서도 회원이 남아 있으려면 목록을 디스크의 파일에 써 두었다가 프로그램이 시작할 때 다시 읽어 와야 합니다. 이처럼 프로세스가 끝난 뒤에도 데이터가 남게 하는 일을 영속화(persistence)라고 합니다.

이 글은 java.nio.file 패키지의 Path와 Files로 텍스트 파일 하나를 쓰고 읽는 데서 출발합니다. 그다음 회원 여러 명을 한 파일에 담으면서 차례로 부딪히는 두 문제, 즉 이름 안에 든 쉼표와 쓰는 도중에 멈춘 프로그램을 해결하며 작은 파일 저장소를 만듭니다. 문자와 바이트, Charset은 23장에서 배운 내용을 그대로 씁니다. 파일 동작은 운영체제와 파일 시스템에 따라 조금씩 다르므로 미리 밝혀 두면, 이 글의 실행 결과는 Linux의 ext4 파일 시스템에서 JDK 21로 얻은 것입니다.

Path로 위치를 정하고 Files로 읽고 쓰기

파일을 다루는 코드는 위치와 작업을 구분하는 데서 시작합니다. Path는 data/hello.txt처럼 파일이나 디렉터리가 있는 위치를 나타내는 값이고, Files는 그 위치에 디렉터리를 만들거나 내용을 읽고 쓰는 정적 메서드를 모아 둔 클래스입니다. Path 객체를 만드는 일은 문자열을 만드는 일과 비슷해서 디스크에는 아무 변화도 없습니다. 파일이 실제로 생기거나 읽히는 것은 Files의 메서드를 호출할 때뿐입니다.

다음 프로그램은 현재 작업 디렉터리 아래에 data 디렉터리를 만들고, 그 안에 hello.txt를 저장한 뒤 다시 읽습니다. 저장하기 전에 Files.exists로 파일이 있는지 먼저 출력해서, Path를 만든 것만으로는 파일이 생기지 않는다는 것도 확인합니다.

src/FirstTextFile.java
import java.io.IOException;
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 IOException {
        Path directory = Path.of("data");
        Path file = directory.resolve("hello.txt");
        System.out.println(file + " exists? " + Files.exists(file));

        Files.createDirectories(directory);
        Files.writeString(file, "안녕하세요, 파일!", StandardCharsets.UTF_8);

        String text = Files.readString(file, StandardCharsets.UTF_8);
        System.out.println(text);
        System.out.println(Files.size(file) + " bytes");
        System.out.println(file.toAbsolutePath());
    }
}

src 폴더가 있는 디렉터리에서 다음 명령으로 컴파일하고 실행합니다. 여기서는 그 디렉터리를 java-lab이라고 부르겠습니다.

javac -d out src/FirstTextFile.java
java -cp out FirstTextFile

처음 실행하면 네 줄이 출력됩니다. 마지막 줄의 절대 경로는 실행한 위치마다 다르므로, 아래에는 앞의 세 줄만 옮겼습니다.

data/hello.txt exists? false
안녕하세요, 파일!
24 bytes

실행이 끝난 뒤 java-lab 디렉터리는 다음과 같습니다. data 디렉터리와 그 안의 hello.txt가 이 프로그램이 만든 것입니다.

java-lab/
├── src/FirstTextFile.java
├── out/FirstTextFile.class
└── data/hello.txt

첫 줄의 false는 Path.of와 resolve가 위치를 계산했을 뿐 파일을 만들지는 않았다는 뜻입니다. resolve는 디렉터리 경로 뒤에 자식 이름을 붙인 새 Path를 돌려주는데, Windows에서는 구분자가 달라서 data\hello.txt로 출력됩니다. 이 경로는 루트에서 시작하지 않는 상대 경로(relative path)라서, 클래스 파일이 있는 곳이 아니라 java 명령을 실행한 디렉터리, 즉 작업 디렉터리(working directory)를 기준으로 해석됩니다.

네 번째 줄의 toAbsolutePath()가 그 해석 결과를 보여 줍니다. 예를 들어 /home/me/java-lab에서 실행했다면 /home/me/java-lab/data/hello.txt가 나옵니다. IDE에서 실행하면 보통 프로젝트 폴더가 작업 디렉터리가 되므로, 파일이 엉뚱한 곳에 생겼다면 이 줄부터 확인해 보세요.

Files.createDirectories는 경로에 필요한 디렉터리를 부모부터 차례로 만들고, 디렉터리가 이미 있으면 아무 일도 하지 않고 정상적으로 돌아옵니다. Files.writeString은 파일이 없으면 새로 만들고, 있으면 기존 내용을 지운 뒤 씁니다. Files.readString은 파일 전체를 읽어 문자열 하나로 돌려줍니다. 셋째 줄의 24바이트는 문자와 바이트를 다룬 글에서 본 UTF-8 규칙 그대로입니다. 한글 음절은 UTF-8에서 한 글자에 3바이트라서 안녕하세요가 15바이트, 파일이 6바이트이고, 쉼표와 공백과 느낌표가 1바이트씩 더해져 모두 24바이트가 됩니다.

같은 명령을 한 번 더 실행하면 첫 줄만 data/hello.txt exists? true로 바뀝니다. 디렉터리는 이미 있으니 createDirectories는 그냥 지나가고, 파일도 이미 있지만 writeString이 내용을 덮어쓰므로 크기는 그대로 24바이트입니다.

터미널의 문자 인코딩이 UTF-8이 아니면 둘째 줄이 ?????, ??!처럼 보일 수 있습니다. 이때도 파일은 멀쩡합니다. System.out이 문자열을 터미널의 인코딩으로 바꾸다가 표현할 수 없는 글자를 ?로 바꾼 것일 뿐이므로, 셋째 줄에 24바이트가 나왔다면 파일에는 한글이 UTF-8로 제대로 저장된 것입니다.

예제는 StandardCharsets.UTF_8을 넘겼지만, Files의 메서드 가운데 Charset을 받지 않는 버전도 실행 환경의 기본값이 아니라 언제나 UTF-8을 씁니다. 그러니 Charset을 빼도 결과는 같습니다. 그래도 인코딩은 파일 형식의 일부이므로 이 글의 예제는 대부분 Charset을 코드에 드러냅니다. 또 readString은 파일 전체를 메모리에 올리므로 크기가 작고 상한이 분명한 파일에만 씁니다. 로그처럼 계속 커지는 파일은 문자 스트림을 다룬 글에서 본 BufferedReader나 Files.lines로 한 줄씩 처리합니다.

덮어쓰기와 이어 쓰기

writeString이 기존 내용을 지우는 것은 열기 옵션(open option)으로 정해진 동작입니다. 옵션을 하나도 넘기지 않으면 CREATE(없으면 만들기), TRUNCATE_EXISTING(있으면 길이를 0으로 자르기), WRITE(쓰기) 세 옵션을 준 것처럼 동작합니다. 기존 내용 뒤에 이어 쓰려면 StandardOpenOption.APPEND를 넘깁니다. 다음 프로그램으로 두 동작을 비교해 보겠습니다. Charset을 생략했으므로 모두 UTF-8로 쓰고 읽습니다.

src/OverwriteOrAppend.java
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;

public final class OverwriteOrAppend {
    public static void main(String[] args) throws IOException {
        Path file = Path.of("data", "log.txt");
        Files.createDirectories(file.getParent());

        Files.writeString(file, "first\n");
        Files.writeString(file, "second\n");
        System.out.print(Files.readString(file));
        System.out.println("--");

        Files.writeString(file, "third\n", StandardOpenOption.CREATE, StandardOpenOption.APPEND);
        System.out.print(Files.readString(file));
    }
}
second
--
second
third

처음 두 번의 writeString은 기본 옵션으로 실행되므로, 두 번째 호출이 first를 지우고 second만 남깁니다. -- 아래에서는 CREATE와 APPEND를 넘겼기 때문에 기존 second 뒤에 third가 붙었습니다. 이 프로그램은 실행할 때마다 첫 writeString이 파일을 비우고 시작하므로 몇 번을 실행해도 결과가 같습니다.

여기서 CREATE를 함께 적은 데는 이유가 있습니다. 옵션을 하나라도 직접 넘기면 기본값인 CREATE와 TRUNCATE_EXISTING은 적용되지 않습니다. 그래서 APPEND만 넘기면 파일이 없을 때 새로 만들지 않고 예외가 나는데, 바로 다음 절에서 확인합니다.

파일 작업이 실패할 때 나오는 예외

Files의 메서드는 실패를 boolean 같은 반환값이 아니라 예외로 알립니다. 파일이 없거나, 이미 있거나, 내용이 지정한 인코딩과 맞지 않는 상황을 일부러 만들어 어떤 예외가 나오는지 보겠습니다. legacy.txt에는 '한글'을 Windows의 옛 한국어 인코딩인 MS949로 저장했을 때의 네 바이트를 씁니다.

src/FileErrors.java
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;

public final class FileErrors {
    public static void main(String[] args) throws IOException {
        Path directory = Files.createDirectories(Path.of("data"));
        Path legacy = directory.resolve("legacy.txt");
        // MS949(윈도우의 옛 한국어 인코딩)로 저장된 "한글"의 바이트
        Files.write(legacy, new byte[] {(byte) 0xC7, (byte) 0xD1, (byte) 0xB1, (byte) 0xDB});

        try {
            Files.readString(directory.resolve("missing.txt"), StandardCharsets.UTF_8);
        } catch (IOException e) {
            System.out.println(e);
        }
        try {
            Files.readString(legacy, StandardCharsets.UTF_8);
        } catch (IOException e) {
            System.out.println(e);
        }
        try {
            Files.writeString(directory.resolve("new.txt"), "x", StandardOpenOption.APPEND);
        } catch (IOException e) {
            System.out.println(e);
        }
        try {
            Files.createDirectories(legacy);
        } catch (IOException e) {
            System.out.println(e);
        }
    }
}
java.nio.file.NoSuchFileException: data/missing.txt
java.nio.charset.MalformedInputException: Input length = 1
java.nio.file.NoSuchFileException: data/new.txt
java.nio.file.FileAlreadyExistsException: data/legacy.txt

첫 줄은 없는 파일을 읽으려 할 때 나는 NoSuchFileException이고, 메시지는 문제가 된 경로입니다. 둘째 줄은 legacy.txt를 UTF-8로 읽다가 난 예외입니다. UTF-8에서 C7은 두 바이트짜리 글자의 첫 바이트인데, 다음 바이트 D1이 뒤따르는 바이트의 모양이 아니므로 readString은 내용을 추측하지 않고 MalformedInputException을 던집니다. 메시지의 Input length = 1은 규칙에 맞지 않는 바이트 하나를 만났다는 뜻입니다.

셋째 줄은 앞 절에서 말한 경우입니다. CREATE 없이 APPEND만 넘겼더니 new.txt를 만들지 않고 실패했고, 실행 뒤 data 디렉터리를 보면 실제로 new.txt가 없습니다. 넷째 줄은 일반 파일 legacy.txt가 있는 자리에 디렉터리를 만들려 한 경우입니다. createDirectories는 디렉터리가 이미 있을 때만 그냥 넘어가고, 같은 이름의 일반 파일이 있으면 FileAlreadyExistsException을 던집니다.

둘째 줄의 동작은 new String(bytes, StandardCharsets.UTF_8)과 다르다는 점도 기억해 두세요. String 생성자는 잘못된 바이트를 만나도 예외 없이 대체 문자 U+FFFD로 바꿉니다. 같은 네 바이트를 이 생성자로 바꾸면 U+FFFD, U+0471, U+FFFD 세 글자가 되는데, 가운데의 U+0471은 우연히 규칙에 맞게 된 두 바이트가 만든 엉뚱한 키릴 문자입니다. 오류가 있었다는 사실 자체가 사라지므로, 파일을 읽을 때는 readString처럼 실패를 알려 주는 쪽이 안전합니다.

자주 만나는 예외를 정리하면 다음과 같습니다.

예외언제 나는가
NoSuchFileException읽거나 열려는 파일, 또는 만들 파일의 부모 디렉터리가 없을 때
FileAlreadyExistsException새로 만들어야 하는 자리에 같은 이름이 이미 있을 때
AccessDeniedException운영체제 권한 때문에 읽기, 쓰기, 삭제가 거부될 때
DirectoryNotEmptyException비어 있지 않은 디렉터리를 지우려 할 때
MalformedInputException바이트가 지정한 Charset의 규칙에 맞지 않을 때

모두 IOException의 하위 클래스이므로 검사 예외이고, 잡아서 처리하거나 throws로 선언해야 합니다. 앞의 네 개는 java.nio.file 패키지의 FileSystemException을 상속하며, getFile()로 문제가 된 경로를 꺼낼 수 있습니다.

FileErrors가 예외를 잡아 출력만 한 것은 종류를 보여 주기 위해서입니다. 실제 코드에서는 그 자리에서 의미 있게 대응할 수 있는 예외만 골라 잡고, 나머지는 throws IOException으로 호출한 쪽에 넘깁니다. 예를 들어 저장소가 처음 시작할 때 파일이 없다는 것은 '아직 회원이 없다'는 뜻이므로 NoSuchFileException을 잡아 빈 목록으로 시작할 수 있습니다. 반대로 권한이 없거나 내용이 깨졌다면, 빈 목록으로 시작했다가 그 상태를 저장해 기존 파일을 덮어쓰는 일이 없도록 실패를 그대로 알려야 합니다. Files.exists로 먼저 확인하고 읽는 방법도 있지만, 확인과 읽기 사이에 다른 프로그램이 파일을 지울 수 있으므로 읽기에서 나는 예외는 어차피 처리해야 합니다.

저장소가 지킬 규칙부터 정하기

파일에 저장하는 코드를 쓰기 전에 저장소가 바깥에 보여 줄 모습을 정해 두겠습니다. 회원을 등록하는 add와 전체를 조회하는 findAll만 있는 인터페이스입니다.

interface MemberRepository {
    void add(Member member) throws IOException;
    List<Member> findAll() throws IOException;
}

저장소를 쓰는 코드가 이 인터페이스만 알면, 회원 목록을 메모리에 두든 파일에 두든 호출하는 코드를 바꿀 필요가 없습니다. 어떤 구현을 쓸지는 main처럼 객체를 조립하는 한 곳에서만 정합니다. 인터페이스로 역할과 구현 분리하기에서 본 방식 그대로입니다.

두 메서드에 throws IOException을 붙인 것은 파일 구현이 디스크 오류를 알릴 수 있어야 하기 때문입니다. 메모리 구현은 입출력을 하지 않으므로 메서드를 구현할 때 throws를 빼도 됩니다. 재정의하는 메서드는 원래 메서드보다 적은 검사 예외를 선언할 수 있기 때문입니다. 다만 인터페이스 타입으로 저장소를 쓰는 쪽은 실제 구현이 무엇이든 IOException을 처리해야 하는데, 이것이 입출력을 감춘 추상화의 대가입니다.

인터페이스는 메서드의 이름과 예외만 정할 뿐 동작까지 정하지는 못합니다. 그래서 두 구현이 똑같이 지켜야 할 규칙을 글로 적어 둡니다.

  • 회원은 등록한 순서대로 조회된다.
  • 이미 있는 ID로 등록하면 거부하고, 거부한 뒤에도 기존 회원은 그대로 남는다.
  • findAll이 돌려준 목록은 수정할 수 없고, 나중에 회원을 추가해도 이미 받은 목록은 바뀌지 않는다.

먼저 메모리 구현으로 이 규칙을 지켜 보겠습니다. 한 파일로 바로 실행할 수 있도록 인터페이스 없이 썼고, 인터페이스를 붙여 파일 구현과 함께 검사하는 일은 연습 문제에서 합니다.

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());
    }
}
[Member[id=u1, name=Mina, age=31]]

Member record의 컴팩트 생성자(compact constructor)는 빈 ID나 이름, 음수 나이로는 객체를 만들 수 없게 막습니다. add는 같은 ID가 이미 있는지 확인한 다음에만 목록에 넣으므로, 거부된 등록은 상태를 조금도 바꾸지 않습니다.

findAll이 돌려주는 List.copyOf(members)는 그 순간의 내용을 복사한, 수정할 수 없는 목록입니다(수정할 수 없는 목록과 정렬 결과 다루기). 호출자가 이 목록에 clear()를 부르면 UnsupportedOperationException이 나고, 나중에 저장소에 회원이 추가되어도 이미 받은 복사본은 바뀌지 않습니다. 복사본과 저장소가 같은 Member 객체를 함께 가리키기는 하지만, record는 바꿀 수 없는 객체라서 문제가 되지 않습니다.

synchronized는 한 JVM 안에서 여러 스레드가 add와 findAll을 동시에 불러도 목록이 엉키지 않게 합니다(가시성·원자성·임계 영역). 하지만 프로그램을 두 번 띄워 같은 파일을 고치는 경우처럼 프로세스가 여러 개라면 소용이 없습니다. synchronized의 락은 그 JVM 안에만 있기 때문입니다. 그때는 파일 잠금(FileChannel.lock), 기록을 한 프로세스에만 맡기는 구조, 데이터베이스의 트랜잭션처럼 저장 매체 쪽에서 조정해야 합니다.

쉼표로 이어 쓴 행이 깨지는 이유

이제 회원을 파일에 적을 형식을 정해야 합니다. 가장 먼저 떠오르는 방법은 한 줄에 회원 한 명을 두고 필드를 쉼표로 잇는 것입니다. u1,Mina,31처럼 쓰고 읽을 때는 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));
    }
}
u1,Kim, Mina,31
[u1, Kim,  Mina, 31]
Exception in thread "main" java.lang.NumberFormatException: For input string: " Mina"

예외 메시지 아래에 이어지는 스택 트레이스는 생략했습니다. 첫 줄은 encode가 만든 행인데, 이것만 봐서는 이름이 어디서 끝나는지 알 수 없습니다. split(",")은 쉼표가 데이터의 일부인지 구분자인지 모르므로 둘째 줄처럼 필드를 네 개로 나눕니다.

둘째 줄에서 Kim과 Mina 사이가 두 칸 벌어진 것은, Arrays.toString이 원소 사이에 넣는 쉼표와 공백 뒤에 공백으로 시작하는 필드 " Mina"가 이어졌기 때문입니다. decode는 세 번째 칸 fields[2]를 나이로 읽으려다 " Mina"를 정수로 바꾸지 못해 실패하고, 실제 나이 31이 든 fields[3]은 읽지도 않습니다. 아래 그림이 이 과정을 칸별로 보여 줍니다.

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

split(',')로 인용된 이름이 두 필드로 갈라진 뒤 Mina를 나이로 읽어 NumberFormatException이 납니다. 실제 필드 인덱스와 실패가 발생해 실행이 멈추는 위치를 단계로 확인할 수 있습니다.

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

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

구분자 · 필드 위치 불일치

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

이름의 쉼표가 필드를 하나 더 만들고, 나이 자리에 온 Mina에서 정수 변환이 실패합니다.

split 결과의 위치와 나이 변환이 어긋난다

split 결과의 위치와 나이 변환이 어긋난다쉼표가 들어 있는 Kim, Mina가 두 칸으로 갈라진다. decode는 fields[2]의 Mina를 정수로 읽다가 실패하고 fields[3]의 실제 나이 31은 참조하지 않는다.u1,Kim, Mina,31쉼표로 분리한 실제 네 칸[0] u1ID[1] Kim이름[2] Mina나이 아님[3] 31미참조fields[2] 정수 변환 실패NumberFormatException
split 결과의 위치와 나이 변환이 어긋난다쉼표가 들어 있는 Kim, Mina가 두 칸으로 갈라진다. decode는 fields[2]의 Mina를 정수로 읽다가 실패하고 fields[3]의 실제 나이 31은 참조하지 않는다.u1,Kim, Mina,31쉼표로 분리한 실제 네 칸[0] u1ID[1] Kim이름[2] Mina나이 아님[3] 31미참조fields[2] 정수 변환 실패NumberFormatException

쉼표가 들어 있는 Kim, Mina가 두 칸으로 갈라진다. decode는 fields[2]의 Mina를 정수로 읽다가 실패하고 fields[3]의 실제 나이 31은 참조하지 않는다.

상태별 상세 해설
  1. fields[0]

    분리된 문자열
    u1
    decode에서의 의미
    ID로 사용
  2. fields[1]

    분리된 문자열
    Kim
    decode에서의 의미
    이름으로 사용 · Mina를 잃음
  3. fields[2]

    분리된 문자열
    ␠Mina
    decode에서의 의미
    나이로 정수 변환하다 NumberFormatException
  4. 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합니다.

  • fields0 /1: u1 / Kim
  • fields2 /3: ␠Mina /31
  • 이름 내부 쉼표가 기대한 세 필드를 네 개로 나눕니다.
잘못된 위치 변환

decode가 fields[2]를 나이로 parse합니다.

  • 기대: 정수 나이
  • 실제: ␠Mina
  • 필드 위치 어긋남이 NumberFormatException을 만듭니다.
실패 반환 경계

decode 예외 때문에 마지막 println에 도달하지 않습니다.

  • fields3: 원래 나이 31, 참조 안 함
  • Member 출력: 없음
  • 원 나이 바이트가 남아 있어도 잘못된 위치 해석을 복구하지 않습니다.

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

split(',')로 인용된 이름이 두 필드로 갈라진 뒤 Mina를 나이로 읽어 NumberFormatException이 납니다. 실제 필드 인덱스와 실패가 발생해 실행이 멈추는 위치를 단계로 확인할 수 있습니다.

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

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

구분자 · 필드 위치 불일치

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

이름의 쉼표가 필드를 하나 더 만들고, 나이 자리에 온 Mina에서 정수 변환이 실패합니다.

split 결과의 위치와 나이 변환이 어긋난다

split 결과의 위치와 나이 변환이 어긋난다쉼표가 들어 있는 Kim, Mina가 두 칸으로 갈라진다. decode는 fields[2]의 Mina를 정수로 읽다가 실패하고 fields[3]의 실제 나이 31은 참조하지 않는다.u1,Kim, Mina,31쉼표로 분리한 실제 네 칸[0] u1ID[1] Kim이름[2] Mina나이 아님[3] 31미참조fields[2] 정수 변환 실패NumberFormatException
split 결과의 위치와 나이 변환이 어긋난다쉼표가 들어 있는 Kim, Mina가 두 칸으로 갈라진다. decode는 fields[2]의 Mina를 정수로 읽다가 실패하고 fields[3]의 실제 나이 31은 참조하지 않는다.u1,Kim, Mina,31쉼표로 분리한 실제 네 칸[0] u1ID[1] Kim이름[2] Mina나이 아님[3] 31미참조fields[2] 정수 변환 실패NumberFormatException

쉼표가 들어 있는 Kim, Mina가 두 칸으로 갈라진다. decode는 fields[2]의 Mina를 정수로 읽다가 실패하고 fields[3]의 실제 나이 31은 참조하지 않는다.

상태별 상세 해설
  1. fields[0]

    분리된 문자열
    u1
    decode에서의 의미
    ID로 사용
  2. fields[1]

    분리된 문자열
    Kim
    decode에서의 의미
    이름으로 사용 · Mina를 잃음
  3. fields[2]

    분리된 문자열
    ␠Mina
    decode에서의 의미
    나이로 정수 변환하다 NumberFormatException
  4. 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합니다.

  • fields0 /1: u1 / Kim
  • fields2 /3: ␠Mina /31
  • 이름 내부 쉼표가 기대한 세 필드를 네 개로 나눕니다.
잘못된 위치 변환

decode가 fields[2]를 나이로 parse합니다.

  • 기대: 정수 나이
  • 실제: ␠Mina
  • 필드 위치 어긋남이 NumberFormatException을 만듭니다.
실패 반환 경계

decode 예외 때문에 마지막 println에 도달하지 않습니다.

  • fields3: 원래 나이 31, 참조 안 함
  • Member 출력: 없음
  • 원 나이 바이트가 남아 있어도 잘못된 위치 해석을 복구하지 않습니다.

문제의 뿌리는 구분자가 형식의 문법인데, 사용자 데이터에도 같은 문자가 들어올 수 있다는 데 있습니다. 쉼표만의 문제도 아닙니다. 이름에 줄바꿈이 들어가면 회원 한 명이 두 줄로 쪼개지고, 빈 문자열이나 역슬래시를 어떻게 적을지도 정해야 합니다. '사용자가 쉼표는 넣지 않겠지'라고 가정하고 시작하면, 데이터가 쌓인 뒤에는 형식을 바꾸기가 어렵습니다.

해결하는 방법은 크게 세 가지입니다.

  • CSV 형식(RFC 4180)처럼 구분자나 줄바꿈이 든 필드를 큰따옴표로 감싸고, 필드 안의 큰따옴표는 두 번 적는 인용 규칙을 씁니다. 모든 경우를 올바르게 처리하기가 까다로우므로 직접 만들기보다 검증된 라이브러리를 씁니다.
  • 각 필드를 구분자가 절대 나오지 않는 문자들로 바꿔 적습니다. 다음 절에서 Base64로 이 방법을 구현합니다.
  • 필드마다 길이를 먼저 적고 그만큼만 읽습니다. 다음 글의 DataOutputStream이 문자열을 이렇게 기록합니다.

어느 방법을 고르든 파일 첫 줄에 v1 같은 형식 버전을 적어 두는 것이 좋습니다. 그래야 나중에 형식을 바꿔도 프로그램이 옛 파일과 새 파일을 구별할 수 있습니다.

구분자가 데이터에 섞이지 않는 형식

Base64는 임의의 바이트를 알파벳 대소문자, 숫자, 기호 두 개로 이루어진 64개 문자만으로 적는 인코딩입니다. 탭과 줄바꿈은 이 64개에 들어 있지 않으므로, 문자열을 UTF-8 바이트로 바꾼 뒤 Base64로 적으면 결과에는 탭도 줄바꿈도 나오지 않습니다. 그러면 필드를 탭으로 나누고 회원 한 명을 한 줄에 두어도 모호해질 일이 없습니다.

다음 MemberTextCodec은 이 규칙으로 회원 한 명을 행 하나로 바꾸고(encode), 행을 다시 회원으로 되돌립니다(decode). 이렇게 값과 저장 형식 사이의 변환을 맡는 코드를 코덱(codec)이라고 부릅니다. 파일을 읽고 쓰는 코드와 분리해 두었기 때문에, 파일 없이 문자열만으로 손상된 행을 시험해 볼 수 있습니다.

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)));
    }
}
aWQJMQ	S2ltLCBNaW5hCu2MgOyepQ	31
true

출력 첫 줄이 파일에 들어갈 행입니다. 넓은 공백처럼 보이는 곳이 탭이고, 탭으로 나뉜 세 필드는 차례로 ID, 이름, 나이입니다. aWQJMQ는 탭이 든 ID id\t1의 UTF-8 바이트 69 64 09 31을 Base64로 적은 것이고, S2ltLCBNaW5hCu2MgOyepQ는 쉼표와 줄바꿈과 한글이 모두 든 이름을 적은 것입니다. 원래 값에 있던 탭과 줄바꿈은 결과에 하나도 남지 않았습니다. 나이는 숫자라서 구분자가 들어갈 수 없으므로 그대로 적었습니다.

둘째 줄의 true는 행을 다시 디코딩한 결과가 원래 record와 같다는 뜻입니다. record가 만들어 준 equals가 세 구성 요소를 모두 비교한 결과입니다.

decode의 세부 사항도 짚어 보겠습니다. split("\t", -1)의 두 번째 인자 -1은 끝에 오는 빈 문자열을 버리지 말라는 뜻입니다. 이 인자가 없으면 마지막 필드가 빈 "a\tb\t" 같은 행이 필드 두 개로 계산되어, 행에 실제로 무엇이 들어 있었는지 정확히 셀 수 없습니다. 필드가 세 개가 아니거나, 나이가 숫자가 아니거나, Base64에 없는 문자가 섞여 있으면 decode는 예외를 던집니다. Base64 디코더는 잘못된 문자를 만나면 IllegalArgumentException을 던지고 Integer.parseInt의 NumberFormatException도 그 하위 클래스이므로, 손상된 행은 IllegalArgumentException 하나로 잡을 수 있습니다.

Base64.getUrlEncoder()는 기호로 -와 _를 쓰는 URL 안전 알파벳을 고르고, withoutPadding()은 끝에 붙는 = 채움 문자를 생략합니다. 기본 인코더 Base64.getEncoder()를 써도 탭과 줄바꿈은 나오지 않으므로 이 형식에서는 똑같이 동작하며, 두 설정은 행을 조금 짧고 파일 이름이나 URL에도 쓸 수 있는 모양으로 만들 뿐입니다. 다만 Base64.getMimeEncoder()는 76글자마다 줄바꿈(CRLF)을 넣으므로 한 줄에 회원 한 명이라는 규칙을 깨뜨립니다.

이 형식에도 한계가 있습니다. 사람이 파일을 열어 읽거나 고치기 어려우므로, 사람이 편집해야 하는 파일이라면 정식 CSV 라이브러리나 JSON이 더 알맞습니다(저장 형식을 고르는 글). 또 이 코덱의 Member는 짧게 쓰려고 검사 없는 record로 만들었고, 형식 버전 행도 다루지 않습니다. 실제 저장소라면 디코딩한 값으로 앞의 검사하는 Member를 만들어 빈 이름 같은 값을 거르고, 파일 첫 줄의 버전도 확인해야 합니다. 형식을 고르는 기준은 코드가 얼마나 짧은가가 아니라, 누가 이 파일을 만들고 읽고 망가졌을 때 복구하는가입니다.

더 깊이: 잘못된 UTF-8을 조용히 넘기지 않으려면

value 메서드의 new String(bytes, StandardCharsets.UTF_8)은 앞에서 본 것처럼 잘못된 바이트를 예외 없이 U+FFFD로 바꿉니다. 그래서 Base64 부분이 멀쩡해도, 다른 프로그램이 UTF-8이 아닌 바이트를 Base64로 적어 넣었다면 깨진 이름이 아무 경고 없이 저장소에 들어옵니다. 엄격하게 검사하려면 CharsetDecoder를 직접 씁니다. newDecoder()로 새로 만든 디코더는 잘못된 입력을 다른 글자로 바꾸지 않고 오류로 알리도록 설정되어 있습니다.

private static String value(String encoded) throws CharacterCodingException {
    byte[] bytes = Base64.getUrlDecoder().decode(encoded);
    return StandardCharsets.UTF_8.newDecoder()
            .decode(ByteBuffer.wrap(bytes))
            .toString();
}

java.nio.ByteBuffer와 java.nio.charset.CharacterCodingException을 import해야 하고, CharacterCodingException은 IOException의 하위 클래스인 검사 예외라서 decode에도 선언이 필요합니다. 이렇게 바꾸면 decode는 행의 구조가 틀렸을 때 IllegalArgumentException을, 바이트가 UTF-8이 아닐 때 MalformedInputException을 던집니다. Files.readString이 파일 전체에 대해 하는 검사를 필드마다 하는 셈입니다.

임시 파일에 쓰고 한 번에 바꾸기

회원을 등록할 때마다 파일 전체를 현재 목록으로 다시 쓴다고 해 봅시다. Files.write(target, rows)로 바로 덮어쓰면, 파일을 여는 순간 TRUNCATE_EXISTING 때문에 기존 내용이 0바이트로 잘리고 그다음에 새 내용이 채워집니다. 그 사이에 프로그램이 죽거나 강제로 종료되거나 디스크가 가득 차면, 파일은 비어 있거나 절반만 쓰인 채 남습니다. 새 목록을 못 쓴 것은 물론이고 원래 있던 목록까지 잃는 것입니다. 쓰는 도중에 다른 프로그램이 파일을 읽으면 절반짜리 내용을 보게 되는 문제도 있습니다.

해결 방법은 기존 파일을 건드리지 않은 채 같은 디렉터리에 임시 파일을 만들어 새 내용을 끝까지 쓰고, 다 쓴 다음에 임시 파일을 원래 이름으로 옮기는 것입니다. 이 이동이 중간 상태 없이 한 번에 일어난다면, 즉 원자적(atomic)이라면 이동 전에 파일을 여는 쪽은 옛 내용을, 이동 후에 여는 쪽은 새 내용을 보게 되고 반쪽짜리 파일이 보일 틈이 없습니다. 다음 프로그램은 이 과정을 단계별로 실행하며 매번 디렉터리의 내용을 출력합니다.

src/SnapshotSteps.java
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public final class SnapshotSteps {
    public static void main(String[] args) throws IOException {
        Path directory = Files.createDirectories(Path.of("snapshot"));
        Path target = directory.resolve("members.txt");
        Files.write(target, List.of("v1", "row-a"), StandardCharsets.UTF_8);
        show("1. 기존 파일", directory);

        Path temp = Files.createTempFile(directory, "members.txt", ".tmp");
        Files.write(temp, List.of("v2", "row-a", "row-b"), StandardCharsets.UTF_8);
        show("2. 임시 파일 완성", directory);

        Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE);
        show("3. 이동 후", directory);
        System.out.println(Files.readAllLines(target, StandardCharsets.UTF_8));
    }

    static void show(String label, Path directory) throws IOException {
        List<String> entries = new ArrayList<>();
        try (DirectoryStream<Path> stream = Files.newDirectoryStream(directory)) {
            for (Path entry : stream) {
                entries.add(entry.getFileName() + " " + Files.size(entry) + "B");
            }
        }
        Collections.sort(entries); // 디렉터리를 읽는 순서는 정해져 있지 않다
        System.out.println(label + ": " + entries);
    }
}
1. 기존 파일: [members.txt 9B]
2. 임시 파일 완성: [members.txt 9B, members.txt4498940007800298888.tmp 15B]
3. 이동 후: [members.txt 15B]
[v2, row-a, row-b]

show는 Files.newDirectoryStream으로 디렉터리 안의 항목을 하나씩 읽습니다. 이 스트림도 닫아야 하는 자원이므로 try-with-resources로 감쌌고, 읽는 순서가 정해져 있지 않아서 이름순으로 정렬해 출력합니다.

1단계의 9바이트는 v1과 row-a 두 줄에 줄바꿈 문자를 하나씩 더한 크기입니다. 2단계에서는 새 내용 세 줄, 15바이트를 담은 임시 파일이 기존 파일 옆에 생겼고, 기존 파일은 그대로입니다. createTempFile은 접두사와 접미사 사이에 임의의 숫자를 넣어 이름이 겹치지 않게 하므로 가운데 숫자는 실행할 때마다 달라집니다. 3단계에서는 임시 파일이 사라지고 members.txt가 15바이트짜리 새 내용으로 바뀌었습니다.

Linux에서 이 이동은 파일 이름을 바꾸는 시스템 호출 rename 한 번으로 처리되어, members.txt라는 이름이 옛 파일에서 새 파일로 한 번에 넘어갑니다. 시스템 호출을 추적하는 도구 strace로 이 프로그램을 실행해 보면 Files.move가 rename을 딱 한 번 호출하는 것을 볼 수 있습니다.

실제 저장소에서 쓰려면 이 과정을 메서드 하나로 묶고, 실패했을 때 임시 파일을 치우는 코드까지 넣어야 합니다. 다음 replace는 대상 파일의 경로와 새 행 목록을 받아 파일을 통째로 교체합니다.

src/AtomicTextSnapshot.java
import java.io.IOException;
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 IOException {
        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 IOException {
        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);
        }
    }
}
[v1, row-a, row-b]

main은 임시 디렉터리 안의, 아직 없는 members.txt에 행 세 개를 저장하고 다시 읽은 뒤 파일과 디렉터리를 지웁니다. 기존 파일을 교체하는 경우는 앞의 SnapshotSteps에서 확인했으니, 여기서는 replace의 코드를 위에서부터 보겠습니다.

첫 줄이 target.getParent()가 아니라 target.toAbsolutePath().getParent()인 데는 이유가 있습니다. Path.of("members.txt")처럼 이름 하나뿐인 상대 경로는 getParent()가 null을 돌려주고, 이 null을 createTempFile에 넘기면 NullPointerException이 납니다. 절대 경로로 바꾼 다음 부모를 구하면 언제나 실제 디렉터리를 얻습니다.

Files.write(temp, rows, StandardCharsets.UTF_8)은 목록의 원소를 한 줄씩 쓰고, 줄마다 운영체제의 줄 구분자를 붙입니다. Linux와 macOS에서는 LF, Windows에서는 CRLF입니다. readAllLines는 두 가지를 모두 줄 끝으로 알아보므로 이 저장소 안에서는 문제가 없습니다. 하지만 파일을 다른 시스템과 주고받는다면 Files.writeString(temp, String.join("\n", rows) + "\n", StandardCharsets.UTF_8)처럼 줄 구분자를 직접 정해 쓰는 편이 형식을 하나로 고정합니다.

Files.move와 ATOMIC_MOVE의 약속

핵심은 Files.move(temp, target, ATOMIC_MOVE, REPLACE_EXISTING)입니다. Files.move의 Javadoc은 ATOMIC_MOVE에 대해 세 가지를 정해 둡니다.

  • 이동을 원자적인 파일 시스템 연산으로 수행하고, 함께 넘긴 다른 옵션은 모두 무시합니다. 그래서 이 호출의 REPLACE_EXISTING은 원자적 이동이 성공할 때는 아무 역할도 하지 않습니다.
  • 대상 파일이 이미 있을 때 그 파일을 교체할지, IOException을 던지며 실패할지는 구현에 따라 다릅니다.
  • 원자적으로 옮길 수 없으면 AtomicMoveNotSupportedException을 던집니다. 대상이 다른 파일 저장소(FileStore)에 있어서 파일을 복사해야 하는 경우가 대표적입니다.

두 번째 항목이 운영체제와 파일 시스템에 따라 달라지는 부분입니다. Linux에서는 rename이 기존 파일을 한 번에 교체하므로, SnapshotSteps는 REPLACE_EXISTING 없이 ATOMIC_MOVE만 넘겼는데도 기존 members.txt를 교체했습니다. 하지만 Javadoc이 약속하는 것은 '교체하거나 실패한다'까지입니다. 다른 환경에서는 대상이 이미 있다는 이유로 예외가 날 수도 있다고 생각하고 코드를 써야 합니다.

같은 디렉터리에 임시 파일을 만드는 이유

세 번째 항목은 임시 파일의 위치와 관련이 있습니다. 파일 이름을 바꾸는 rename은 같은 파일 시스템 안에서만 동작하므로, replace는 디렉터리를 지정하지 않는 Files.createTempFile(prefix, suffix) 대신 대상과 같은 디렉터리에 임시 파일을 만듭니다. 디렉터리를 지정하지 않으면 임시 파일이 시스템 임시 디렉터리(Linux에서는 보통 /tmp)에 생기는데, 이 디렉터리는 메모리 기반 파일 시스템인 tmpfs처럼 저장할 디렉터리와 다른 파일 시스템일 수 있습니다. 실제로 Linux의 tmpfs인 /dev/shm에 만든 파일을 디스크의 data/members.txt로 ATOMIC_MOVE하면 다음 예외가 납니다.

java.nio.file.AtomicMoveNotSupportedException: /dev/shm/members5441640177002590121.tmp -> data/members.txt: Invalid cross-device link

메시지 끝의 Invalid cross-device link는 운영체제가 rename을 거부하며 돌려준 오류 문구로, 서로 다른 장치(파일 시스템) 사이에서는 이름을 바꾸는 방식으로 옮길 수 없다는 뜻입니다. 같은 디렉터리에 만들면 이런 일이 일어날 가능성은 거의 없습니다. 그래도 Javadoc이 모든 파일 시스템에서 원자적 이동을 약속하지는 않으므로 이 예외에는 여전히 대비해야 합니다.

원자적으로 옮길 수 없을 때의 선택

replace는 AtomicMoveNotSupportedException을 잡아 REPLACE_EXISTING만 준 일반 이동으로 다시 시도합니다. 저장 자체는 성공시키려는 선택이지만 대가가 있습니다. 일반 이동은 원자성을 약속하지 않고, 파일 시스템을 넘나드는 이동이라면 JDK가 내부에서 파일을 복사한 뒤 원본을 지우는 방식으로 처리합니다.

Javadoc은 원자적이지 않은 이동이 IOException으로 실패하면 파일 상태가 정해지지 않는다고 적고 있습니다. 원본과 대상이 둘 다 남을 수도 있고, 대상이 덜 복사된 채 남을 수도 있다는 뜻입니다. 아래 그림이 이동 경로마다 달라지는 약속을 정리합니다.

스냅샷 이동 경로별 보장 범위

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

스냅샷 · 이동 경로별 보장

스냅샷 이동 경로별 보장 범위

원자 이동이 지원되지 않아 일반 이동으로 바뀌면, 원자성 보장도 달라집니다.

ATOMIC_MOVE 시도

보장 또는 처리
성공하면 이동 자체가 원자적
남는 한계
기존 대상의 교체 또는 예외는 구현별로 다름

원자 이동 미지원

보장 또는 처리
REPLACE_EXISTING으로 다시 이동
남는 한계
일반 이동이므로 원자성 보장 없음

그 밖의 이동 실패

보장 또는 처리
예외 전파 · 임시 파일 삭제 시도
남는 한계
일반 이동의 I/O 실패 뒤 대상 상태는 확정할 수 없음

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

어느 쪽을 택할지는 애플리케이션이 정할 일입니다. 반쪽짜리 파일이 절대 생기면 안 되는 제품이라면 AtomicMoveNotSupportedException을 잡지 말고 저장 실패로 처리해야 합니다. 반대로 실패해도 다시 저장하면 그만인 데이터라면 일반 이동으로 물러서는 것도 합리적입니다. 어느 쪽이든 finally의 moved 검사 덕분에 이동이 끝나기 전에 실패하면 임시 파일은 deleteIfExists로 지워지므로, 디렉터리에 임시 파일이 쌓이지 않습니다.

더 깊이: 원자적 교체가 해 주지 않는 것

원자적 이동은 실행 중인 프로세스들이 보기에 옛 파일과 새 파일 사이에 중간 상태가 없다는 뜻이지, 데이터가 디스크에 안전하게 기록되었다는 뜻은 아닙니다. Files.write가 돌아왔을 때 데이터는 운영체제의 메모리(페이지 캐시)에만 있고 아직 디스크에 없을 수 있습니다. 전원이 나가는 상황까지 견뎌야 한다면 이동하기 전에 임시 파일의 내용을 디스크로 내려야 하며, 파일을 FileChannel로 열어 쓴 뒤 force(true)를 호출하거나 StandardOpenOption.SYNC로 열어 쓰는 방법이 있습니다.

이름이 바뀌었다는 디렉터리의 변경까지 디스크에 내리는 방법은 표준 API에 정해져 있지 않습니다. Linux의 OpenJDK에서는 디렉터리를 FileChannel.open(directory, StandardOpenOption.READ)로 열어 force(true)를 부를 수 있지만, 명세가 약속하는 동작이 아니어서 다른 운영체제에서는 디렉터리를 이렇게 열지 못할 수 있습니다.

권한도 바뀝니다. createTempFile은 POSIX 파일 시스템에서 소유자만 읽고 쓸 수 있는 권한(rw-------)으로 파일을 만듭니다. 이 표기는 소유자, 그룹, 나머지 사용자의 읽기(r)·쓰기(w)·실행(x) 권한을 세 글자씩 적은 것이고 -는 그 권한이 없다는 뜻으로, 리눅스 권한 체계에서 자세히 다룹니다. 그래서 이 글의 실행 환경에서 Files.write로 처음 만든 파일은 rw-r--r--였지만, SnapshotSteps가 임시 파일로 교체한 members.txt는 rw-------가 되었습니다. 다른 사용자나 프로그램이 이 파일을 읽어야 한다면, 이동하기 전에 Files.setPosixFilePermissions로 임시 파일의 권한을 맞춰 둡니다.

마지막으로, 원자적 교체는 반쪽짜리 파일을 막을 뿐 동시에 저장하는 두 프로그램 사이의 경쟁은 막지 못합니다. 두 프로세스가 같은 옛 목록을 읽고 각자 회원을 한 명씩 추가해 교체하면, 나중에 이동한 쪽의 파일이 남고 먼저 추가한 회원은 사라집니다. 이런 갱신 손실(lost update)을 막으려면 앞에서 말한 파일 잠금이나 단일 기록기, 데이터베이스가 필요합니다.

한 줄씩 덧붙일까, 통째로 바꿀까

지금까지 만든 방식은 변경이 있을 때마다 현재 상태 전체를 새 파일로 쓰는 스냅샷(snapshot) 방식입니다. 파일 내용이 곧 현재 회원 목록이라 읽기 쉽고, 임시 파일과 원자적 이동을 쓰면 옛 상태와 새 상태 가운데 하나만 남습니다. 대신 회원이 10만 명이면 한 명을 추가할 때도 10만 줄을 다시 써야 하므로, 데이터가 커질수록 변경 한 번의 비용이 커집니다.

다른 방법은 CREATE와 APPEND로 변경 내용만 한 줄씩 덧붙이는 로그(log) 방식입니다. 쓰기는 가볍지만 삭제나 수정까지 기록하려면 행마다 변경의 종류를 적어야 합니다. 예를 들어 다음 로그는 u1과 u2를 추가한 뒤 u1을 삭제했다는 뜻이고, 필드는 앞에서 만든 MemberTextCodec처럼 Base64로 적었습니다.

ADD	dTE	TWluYQ	31
ADD	dTI	Qm9i	28
DEL	dTE

현재 상태를 알려면 프로그램이 시작할 때 로그 전체를 처음부터 다시 적용해야 하고, 로그 파일은 지운 회원의 기록까지 품은 채 계속 커집니다. 그래서 주기적으로 현재 상태를 스냅샷으로 저장하고 로그를 새로 시작하는 압축(compaction) 작업이 필요합니다. 또 덧붙이는 도중에 프로그램이 멈추면 마지막 줄이 잘린 채 남을 수 있으므로, 읽는 쪽은 잘린 마지막 줄을 알아보고 버리거나 체크섬으로 손상을 찾아낼 수 있어야 합니다.

회원 수가 많아지고, 여러 프로그램이 동시에 기록하고, ID로 한 명을 빨리 찾아야 하는 요구가 생기면 이 모든 것을 파일 코드로 직접 만들기보다 트랜잭션과 인덱스를 제공하는 데이터베이스를 쓰는 편이 대개 낫습니다. 파일과 데이터베이스를 고르는 기준은 저장 형식을 고르는 글에서 다시 다룹니다.

정리

  • Path는 위치를 나타내는 값일 뿐이고, 파일을 만들고 읽고 쓰는 일은 Files의 메서드가 합니다. 상대 경로는 java 명령을 실행한 작업 디렉터리를 기준으로 해석됩니다.
  • Files.writeString은 옵션이 없으면 파일을 만들거나 기존 내용을 지우고 씁니다. 이어 쓰려면 CREATE와 APPEND를 함께 넘깁니다. Charset을 받지 않는 Files 메서드는 언제나 UTF-8을 씁니다.
  • 파일 작업의 실패는 NoSuchFileException, FileAlreadyExistsException, MalformedInputException 같은 IOException의 하위 클래스로 알려집니다. 대응할 수 있는 예외만 골라 잡고, 나머지는 호출한 쪽에 넘깁니다.
  • 구분자가 데이터에 들어갈 수 있으면 행을 나누는 방법이 모호해집니다. 인용 규칙(CSV), 구분자가 없는 문자로 바꾸기(Base64), 길이 접두사 가운데 하나로 형식을 정하고 버전을 적어 둡니다.
  • 파일을 통째로 바꿀 때는 같은 디렉터리의 임시 파일에 끝까지 쓴 뒤 ATOMIC_MOVE로 옮깁니다. 기존 대상을 교체하는지는 구현마다 다르고, 원자적으로 옮길 수 없으면 AtomicMoveNotSupportedException이 납니다.
  • 원자적 교체는 반쪽짜리 파일을 막아 줄 뿐, 전원 손실에 대비한 디스크 기록이나 여러 프로세스의 동시 수정까지 해결하지는 않습니다.

연습 문제

  1. 다음 코드는 log.txt가 있을 때는 잘 동작하는데, 처음 실행할 때는 실패합니다. 어떤 예외가 나는지, 왜 그런지, 어떻게 고치는지 설명하세요.

    Files.writeString(Path.of("log.txt"), "started\n", StandardOpenOption.APPEND);
  2. 동료가 AtomicTextSnapshot.replace의 임시 파일을 만드는 줄을 Files.createTempFile("members", ".tmp")로 바꿨습니다. /tmp가 저장 디렉터리와 다른 파일 시스템인 Linux 서버에서 replace를 호출하면 무슨 일이 일어나는지, 원래 코드와 무엇이 달라지는지 설명하세요.

  3. 같은 검사 함수를 메모리 저장소와 파일 저장소에 실행해 두 구현이 앞에서 정한 규칙을 똑같이 지키는지 확인하는 프로그램을 작성하세요. 검사할 내용은 다음과 같습니다.

    • 회원 두 명을 등록하면 등록한 순서대로 조회된다.
    • 같은 ID를 다시 등록하면 거부하고, 거부한 뒤에도 기존 회원이 그대로 남는다.
    • 조회 결과에 clear()를 시도해도 저장소의 내용은 그대로이고, 나중에 회원을 추가해도 먼저 받은 목록은 바뀌지 않는다.
    • 파일 저장소에 탭, 쉼표, 줄바꿈, 한글이 들어간 회원도 저장하고, 같은 파일을 새 저장소 객체로 다시 열어도 값이 같다.
    • 검사에 쓴 임시 파일과 디렉터리를 정리한다.
  4. 3번에서 만든 파일 저장소의 add가 파일을 Files.write로 바로 덮어쓰지 않고 AtomicTextSnapshot.replace로 교체하도록 바꾸면 무엇이 좋아지고, 무엇은 그대로 남는지 설명하세요.

해설 보기

1번. 파일이 없으면 java.nio.file.NoSuchFileException: log.txt가 납니다. writeString은 옵션을 하나도 넘기지 않았을 때만 CREATE, TRUNCATE_EXISTING, WRITE를 기본으로 쓰고, 옵션을 하나라도 넘기면 기본값인 CREATE가 빠집니다. APPEND만 넘겼으니 '없으면 만들라'는 지시가 없어서 파일을 열지 못하는 것입니다. StandardOpenOption.CREATE, StandardOpenOption.APPEND를 함께 넘기면 처음에는 파일을 만들고 그 뒤로는 끝에 이어 씁니다. 본문의 FileErrors에서 data/new.txt로 같은 상황을 확인했습니다.

2번. 디렉터리를 지정하지 않으면 임시 파일이 시스템 임시 디렉터리 /tmp에 생깁니다. 저장 디렉터리와 파일 시스템이 다르면 rename을 할 수 없으므로 ATOMIC_MOVE가 AtomicMoveNotSupportedException을 던지고, replace는 이를 잡아 일반 이동으로 넘어갑니다. 파일 시스템 사이의 일반 이동은 복사한 뒤 원본을 지우는 방식이라, 원자적으로 교체한다는 이 메서드의 목적이 사라집니다. 복사 도중 실패하면 대상 파일이 덜 쓰인 채 남을 수 있고, 다른 프로그램이 복사 중인 파일을 읽을 수도 있습니다. 대체 이동이 없는 코드였다면 저장이 매번 실패했을 것입니다. 원래 코드처럼 대상과 같은 디렉터리에 임시 파일을 만들어야 같은 파일 시스템 안에서 이름만 바꿀 수 있습니다.

3번. 아래 코드는 한 파일로 실행하는 독립 예제입니다. 저장소의 동작에 집중하려고 Member에는 ID와 이름 두 필드만 두었으므로, 나이까지 포함한 본문의 세 필드 형식과 파일을 섞어 쓰지 않습니다. 문자열은 본문처럼 UTF-8 바이트를 Base64로 적고 두 필드를 탭으로 나눕니다. 파일 구현은 작은 파일 전체를 매번 다시 읽고 쓰며, 한 프로세스가 차례로 사용하는 경우만 다룹니다.

exercise/RepositoryContractSolution.java
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 MemberRepository {
        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 MemberRepository {
        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 MemberRepository {
        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(MemberRepository 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로 저장하고 다음 명령으로 컴파일해 실행합니다.

javac -d out exercise/RepositoryContractSolution.java
java -cp out RepositoryContractSolution

모든 검사를 통과하면 다음 네 줄이 출력됩니다. 중간에 검사가 실패하면 예외가 나고, 그 뒤의 성공 메시지는 출력되지 않습니다.

memory contract passed
file contract passed
file reopen passed
temporary files removed

verify는 같은 등록, 조회, 중복 검사를 두 구현에 똑같이 적용합니다. snapshot.clear()가 거부되는지만 보지 않고, 거부된 뒤 저장소를 다시 조회해 처음 두 회원이 남아 있는지도 확인합니다. 세 번째 회원을 추가한 뒤에는 새 조회 결과와 예전에 받은 snapshot을 따로 비교해서, 내부 목록을 그대로 내주는 실수를 잡아냅니다. file reopen passed는 같은 파일을 새 FileRepository로 열어 세 회원을 다시 읽었다는 뜻이고, 마지막 줄은 finally에서 파일과 디렉터리를 지운 뒤 디렉터리가 정말 없어졌는지까지 확인해야 출력됩니다.

AssertionError가 나면 메시지가 가리키는 검사부터 살펴보세요. 예를 들어 registration order는 등록 순서와 조회 결과가 다르다는 뜻이고, old snapshot changed는 findAll이 내부 목록을 복사하지 않고 그대로 돌려줬다는 단서입니다. IOException이 나면 어떤 파일 작업이 실패했는지와 임시 디렉터리의 쓰기 권한을 먼저 확인합니다.

이 FileRepository는 중복을 먼저 확인한 뒤에 쓰므로, 같은 ID를 거부할 때는 파일을 바꾸지 않습니다. 각 행에 필드가 두 개인지는 검사하지만 빈 ID나 이름, 형식 버전은 검사하지 않고 UTF-8도 엄격하게 검사하지 않습니다. 정상 입력을 다시 읽는 데 성공했다고 해서 손상된 파일까지 알아본다는 뜻은 아니라는 점에 주의하세요.

4번. 좋아지는 점은 쓰는 도중에 실패해도 기존 파일이 그대로 남는다는 것입니다. 지금 코드의 Files.write는 파일을 열자마자 내용을 0바이트로 자르므로, 쓰기 도중 예외가 나거나 프로그램이 멈추면 회원 목록 전체를 잃습니다. replace를 쓰면 새 내용은 임시 파일에 쓰이고 이동이 성공해야만 members.txt가 바뀌므로, 다른 프로그램이 읽는 도중에 반쪽짜리 파일을 볼 일도 없습니다.

그대로 남는 것도 있습니다. 저장소 객체 두 개나 프로세스 두 개가 동시에 add를 호출하면, 둘 다 같은 옛 목록을 읽어 각자 교체하므로 먼저 추가한 회원이 사라지는 갱신 손실은 여전히 생깁니다. 이동 전에 디스크로 내리는 코드가 없으니 전원 손실에도 대비되지 않고, POSIX 시스템에서는 교체할 때마다 파일 권한이 rw-------로 바뀝니다. 원자적 이동을 지원하지 않는 파일 시스템에서는 replace가 일반 이동으로 물러서므로 원자성도 약속할 수 없습니다.

다음 글

다음 글에서는 숫자와 문자열을 텍스트로 바꾸지 않고 정해진 바이트로 기록하는 DataOutputStream으로 같은 회원 정보를 저장합니다. 구분자 대신 길이를 먼저 적는 방식이 이 글의 쉼표 문제를 어떻게 피하는지, 그리고 파일 앞에 형식 버전을 적어 두는 것이 왜 중요한지도 살펴봅니다.