Devin.KR

파일 저장과 예외

80분 안팎

학습 목표

UTF-8 파일과 try-with-resources로 저장·복원을 구현합니다.

개념

재실행해도 목록이 남아야 합니다

메모리 목록은 프로세스가 끝나면 사라집니다. 담당자가 입력한 도서를 다음 실행에서 다시 읽으려면 파일 저장 형식과 복원 규칙이 필요합니다. 이번 실습은 서버나 DB 없이 UTF-8 파일에 도서 ID, 제목, 대여 상태를 저장합니다. 앞 모듈 Book과 대여 규칙을 그대로 사용하고 파일 경계에서 새로운 형식 검증을 추가합니다. 파일이 존재하지 않으면 아직 등록한 도서가 없는 것으로 해석합니다. 파일이 있는데 읽을 수 없는 경우는 다른 상황이므로 빈 목록으로 성공 처리하지 않습니다.

파일 형식을 먼저 적습니다

한 도서는 한 행이며 필드 사이에는 탭을 씁니다. 헤더는 없습니다. 예를 들어 1, 한글 노트, true를 탭으로 연결하면 ID 1인 책이 대여 중이라는 뜻입니다. 양수 ID만 허용하고 같은 파일에 같은 ID가 두 번 나오면 전체 읽기를 거부합니다. 제목은 빈 문자열이나 공백만으로 구성할 수 없으며 탭, CR, LF를 포함할 수 없습니다. 제목 양끝 공백은 보존합니다. 쉼표와 따옴표는 특별한 구분자가 아니므로 일반 제목 문자로 저장할 수 있습니다.

이 형식은 임의 문자열을 모두 표현하는 CSV 규격이 아닙니다. 이스케이프 규칙을 만들지 않고 제한된 제목만 저장하는 교육용 TSV입니다. 줄바꿈을 가진 제목까지 지원하려면 검증을 지우는 대신 형식을 바꾸고 읽기·쓰기 계약을 함께 수정해야 합니다. boolean 필드는 소문자 true와 false만 허용합니다. Boolean.parseBoolean은 다른 문자열도 false로 해석하므로 사용 전 명시적인 값 검사가 필요합니다. 파일이 손상된 것을 정상 미대여 상태로 착각하지 않게 하기 위한 선택입니다.

인코딩과 자원 수명을 명시합니다

Files.newBufferedReader(path, StandardCharsets.UTF_8)와 newBufferedWriter의 같은 문자셋을 사용합니다. Java 17의 기본 문자셋을 기대하지 않습니다. 파일 인코딩과 콘솔 표시 환경은 별도 문제이므로 테스트에서는 파일에서 읽은 문자열이 원래 제목과 같은지 직접 비교합니다. 한글이 콘솔에서 깨져 보이면 저장 바이트·읽기 문자셋·터미널 표시를 나누어 확인합니다. 이모지가 있는 제목도 문자열 보존 테스트에 넣어 ASCII만으로 통과했다고 인코딩이 맞는 것으로 보지 않습니다.

try-with-resources는 try 괄호에 선언한 Reader나 Writer를 블록 종료 시 닫습니다. 중간에 예외가 발생해도 닫는 동작이 실행됩니다. writer.close를 정상 경로 끝에만 호출하면 예외 경로에서 자원이 남을 수 있습니다. write 호출 직후 파일 내용이 모두 확정되었다고 생각하지 않습니다. 버퍼를 닫고 나서 파일 교체를 실행합니다. 이 실습의 Reader와 Writer는 한 메서드 안에서 열고 닫으며 반환하지 않습니다. 호출자가 자원 수명을 알 수 없는 구조를 피합니다.

행 번호와 원인 예외를 남깁니다

readLine으로 한 행씩 읽고 처리 전에 행 번호를 증가시킵니다. line.split("\t", -1)은 마지막 빈 필드도 남겨 필드 수를 정확히 검증할 수 있게 합니다. ID 파싱, 필드 수, 중복, 제목, 상태 중 하나라도 잘못되면 IOException에 잘못된 도서 행과 번호를 담고 원인 예외를 cause로 연결합니다. 파일 경로와 본문 전체를 로그에 무심코 출력하기보다 오류 위치를 좁힐 단서를 남깁니다. 이번 도서 목록은 실습 자료지만 후속 서비스의 데이터에는 민감한 내용이 들어갈 수도 있습니다.

읽기 결과는 메서드 내부의 새로운 LinkedHashMap에 쌓습니다. 첫 행은 정상이고 둘째 행이 깨져 있어도 부분 목록을 반환하지 않습니다. 정상적으로 파일 끝까지 도달했을 때만 맵을 반환합니다. IOException을 catch해 빈 맵을 반환하면 담당자가 원래 자료가 없다고 판단하고 빈 목록으로 덮어쓸 위험이 있습니다. 없는 파일과 깨진 파일을 나누는 이유입니다. Permission denied는 접근 권한 문제, NoSuchFileException은 경로 문제이며 형식 오류와 해결 방법이 다릅니다.

검증 후 임시 파일을 씁니다

write는 모든 제목과 ID를 먼저 검증합니다. 기존 파일을 열어 잘라 낸 다음 제목을 검증하면 뒤늦게 실패했을 때 원본을 잃습니다. 검증이 끝나면 목적 파일과 같은 부모 폴더에 임시 파일을 만들고 전체 내용을 기록합니다. 부모 폴더는 실습에서 미리 존재해야 합니다. 기존 파일에 append하면 수정과 삭제가 표현되지 않으므로 이번 저장은 목록 전체 교체 방식입니다. 임시 파일을 만들었다는 사실과 목적 파일 교체에 성공했다는 사실을 구별합니다.

writer를 닫은 뒤 Files.move에 ATOMIC_MOVE와 REPLACE_EXISTING을 지정합니다. 같은 폴더의 원자적 이동을 지원하는 파일 시스템에서 부분 파일이 노출되는 위험을 줄이려는 선택입니다. 환경이 이를 지원하지 않으면 AtomicMoveNotSupportedException 같은 IOException을 보고합니다. 자동으로 비원자 덮어쓰기로 우회하지 않습니다. ATOMIC_MOVE를 지정하면 다른 옵션은 무시되며 기존 대상 교체 여부는 구현에 따라 다릅니다. 파일 시스템마다 원자 이동의 대상 교체 동작이 다를 수 있으므로 실습 환경에서 실제 교체 테스트가 통과하는지 확인합니다. 원자 교체라는 단어가 정전 시 디스크 내구성까지 보장하지는 않습니다.

실패 경로에서 임시 파일을 정리합니다

finally에서 Files.deleteIfExists(temp)를 호출해 남은 임시 파일을 정리합니다. 이동 성공 후에는 임시 경로가 없어 아무 것도 지우지 않습니다. 기록 또는 이동 실패 후에는 남아 있는 파일을 제거합니다. 정리 작업 자체도 I/O 오류를 낼 수 있다는 점은 후속 운영 검토 대상이며 이 구현이 모든 OS 오류 상황을 완벽히 복구하는 것은 아닙니다. 저장소는 한 프로세스의 한 작성자가 사용하는 범위이고 동시에 여러 작성자가 마지막 파일을 덮는 문제는 이번 단계에서 해결하지 않습니다.

테스트가 파일에 닿는 범위를 제한합니다

JUnit의 @TempDir가 테스트마다 독립된 폴더를 제공합니다. 프로젝트 바깥 실제 목록이나 운영 경로를 지정하지 않습니다. BookFileTest는 한글·공백·이모지·대여 상태의 왕복, 없는 파일·빈 파일, 중복 ID·잘못된 ID·빈 제목·상태 오류·필드 수 오류, 잘못된 UTF-8, 금지 문자 저장 실패 후 원본 보존을 검증합니다. 실습 starter는 대여 상태 복원이 빠져 있어 unicodeAndState가 실패합니다. 제목만 동일하게 읽힌다고 전체 도서가 복원된 것은 아닙니다.

오류를 수정하고 결과를 제출합니다

expected true but was false이면 read가 true 필드를 확인하고 Book.borrow를 호출했는지 봅니다. malformed input 관련 오류는 UTF-8로 해석할 수 없는 바이트가 있음을 의미합니다. 파일을 빈 목록으로 바꾸어 테스트를 통과시키지 않습니다. 누적 테스트 44개가 실행되고 실패·오류·건너뜀이 0인지 확인한 뒤 형식 계약과 실패 처리 순서를 README에 정리합니다. 저장소로 연결하는 책임은 다음 레슨에서 다룹니다.

API 확인: Java 17 Files 공식 문서에서 문자셋 지정과 원자 이동 옵션을 확인했습니다. 존재 여부의 false는 확인 불가 상황도 포함하므로 read는 Files.exists로 조용히 반환하지 않고 Reader를 열 때의 NoSuchFileException만 빈 목록으로 처리합니다. 다른 IOException은 호출자에게 전달합니다.

따라하기

실습 경로와 실패 테스트 읽기

파일 실습 starter 폴더에서 BookFileTest의 unicodeAndState 실패를 확인합니다. 제목은 복원되지만 대여 상태가 false로 남는지 expected·actual을 비교합니다. 파일 형식의 true 필드와 read의 상태 복원 분기를 연결해 읽습니다.

chmod +x mvnw
./mvnw test

TODO를 계약에 맞게 구현

README와 src/test/java/lab의 테스트를 읽습니다. BookFile.read에서 true 필드인 도서는 borrow를 호출합니다. 기존 Book과 테스트를 유지하고 전체 테스트를 다시 실행합니다.

./mvnw test

독립 관찰 예제 실행

수정 후 빌드된 target/classes에서 제공 관찰 프로그램을 실행합니다. 파일 예제는 임시 폴더를 사용하고 실행이 끝나면 정리합니다.

java -cp target/classes lab.FileDemo

실행 결과

한글 노트
true
잘못된 도서 행: 1

누적 테스트 결과 확인

전체 테스트를 실행하고 바로 종료 상태를 확인합니다. target/surefire-reports에서 제공 테스트 44개와 Failures·Errors·Skipped가 모두 0인지 확인합니다. 새 테스트를 추가했다면 개수가 늘어납니다.

./mvnw -q test
echo $?

실행 결과

0

확인 문제

실습

한글 제목을 저장하고 재로딩하며 깨진 파일을 오류로 보고합니다. starter의 TODO를 완성하고 README에 정상·경계·실패 경로와 실패 뒤 원본이 보존되는 이유를 작성합니다. 제공 테스트와 앞 모듈의 도서 규칙을 유지합니다.

시작 코드·테스트 내려받기

실행 명령

./mvnw test

기대 결과

44개 테스트 실행, 실패·오류·건너뜀 0, 종료 코드 0입니다.

모범 답안모범 답안 내려받기

더 읽기

면접 질문

  • try-with-resources는 읽기 중 예외가 발생했을 때 자원을 어떻게 처리하나요?
  • 없는 파일과 형식이 깨진 파일을 서로 다르게 처리하는 이유는 무엇인가요?
  • 기존 파일에 바로 쓰는 대신 임시 파일을 거쳐 교체하는 이유는 무엇인가요?