1. 운영 체제의 접근 권한
파일과 폴더를 다룰 때 운영 체제(OS)는 접근 권한 시스템으로 이를 보호합니다. 이는 모든 프로그램(또는 사용자)이 임의의 파일을 읽거나, 변경하거나, 삭제할 수 있는 것이 아님을 의미합니다.
POSIX(Linux, macOS 등)
POSIX 계열(Unix, Linux, macOS) 시스템에서는 각 파일과 폴더에 대해 다음 세 가지 범주별 권한이 있습니다:
- 소유자 (user)
- 그룹 (group)
- 기타 (others)
각 범주에는 세 가지 유형의 권한이 지정됩니다:
- r — read(읽기)
- w — write(쓰기)
- x — execute(실행)
예:
-rw-r--r--
이는 소유자는 읽기와 쓰기가 가능하고, 나머지는 읽기만 가능함을 의미합니다.
Windows
Windows에서는 ACL(Access Control List) 시스템을 통해 사용자와 그룹에 대한 권한을 설정합니다. 이를 통해 누가 파일이나 폴더를 읽고, 쓰고, 변경하고, 실행할 수 있는지 등을 유연하게 제어할 수 있습니다.
중요:
Java 프로그램은 실행된 사용자 권한의 범위에서 파일을 다룹니다. 사용자에게 해당 파일에 대한 권한이 없다면 프로그램도 그 파일을 읽거나 변경할 수 없습니다.
2. AccessDeniedException과 그 원인
Java에서 파일을 다룰 때(특히 NIO API 사용 시) 다음 예외를 만날 수 있습니다:
java.nio.file.AccessDeniedException
이 예외는 프로그램에 해당 파일이나 디렉터리에 대해 필요한 권한이 없을 때 발생합니다.
주요 원인:
- 파일에 대한 읽기 권한이 없음(예: 읽기 금지 파일).
- 파일이나 폴더에 대한 쓰기 권한이 없음(예: 시스템 폴더에 쓰기를 시도).
- 파일에 대한 실행 권한이 없음(프로그램 실행 시 관련).
- 폴더나 파일이 변경 불가로 설정됨(예: 읽기 전용).
- 파일이나 폴더가 다른 프로세스에 의해 사용 중(특히 Windows에서 자주 발생).
예:
Path path = Paths.get("/etc/shadow"); // Linux 시스템 파일
Files.readAllLines(path); // AccessDeniedException!
무엇을 할 수 있을까요?
- 파일/폴더의 접근 권한을 확인합니다.
- 필요한 권한을 가진 사용자로 프로그램을 실행합니다.
- 필요하지 않다면 시스템 디렉터리에 쓰기를 시도하지 않습니다.
3. Java에서의 권한 확인: Files.isReadable(), isWritable(), isExecutable()
Java는 파일이나 폴더의 권한을 확인하기 위한 편리한 메서드를 제공합니다:
Path path = Paths.get("example.txt");
System.out.println(Files.isReadable(path)); // 읽을 수 있으면 true
System.out.println(Files.isWritable(path)); // 쓸 수 있으면 true
System.out.println(Files.isExecutable(path)); // 실행할 수 있으면 true
이 메서드들은 현재 시점에서 시스템이 인식하는 당신의 권한을 보여 줍니다.
하지만!
이 메서드들이 성공을 보장하지는 않습니다. 파일이 다른 프로그램에 의해 잠겨 있거나, 확인 후 권한이 변경되었거나, 네트워크 드라이브에서는 서버 설정에 따라 결과가 달라질 수 있으며, 때로는 운영 체제가 한 가지를 보고하면서 실제로는 다른 제한을 적용하기도 합니다.
따라서 Java에서는 권한을 미리 확인하더라도 실제 읽기/쓰기 작업은 반드시 try-catch로 감싸 예외를 처리하는 것이 더 안전합니다.
TOCTOU(Time Of Check To Time Of Use) 문제
권한 확인 시점과 실제 사용 시점 사이에 무언가가 바뀌는 상황을 TOCTOU라고 합니다. 예를 들어:
- isWritable로 파일이 쓰기 가능함을 확인했습니다.
- 그 순간 다른 프로세스나 사용자가 권한을 변경해 파일이 보호 상태가 되었습니다.
- 데이터를 쓰려고 하면 AccessDeniedException이 발생합니다.
결론:
권한 확인은 현재 상태에 대한 힌트일 뿐, 성공을 보장하지 않습니다. 파일을 다룰 때는 항상 예외를 처리하세요.
4. “안전한 쓰기” 원칙(atomic write)
왜 안전한(원자적) 쓰기가 필요한가?
파일을 기록하는 중 프로그램이 중단되거나, 정전이 발생하거나, 디스크 공간이 부족할 수 있습니다. 그 결과 파일이 손상되거나 일부만 기록될 수 있습니다. 이는 설정, 데이터베이스, 문서 등 중요한 데이터에는 특히 위험합니다.
안전한 쓰기는 파일이 완전히 갱신되거나 기존 상태 그대로 남도록 보장하는 방법입니다. 이런 접근을 원자적 쓰기(atomic write)라고 부릅니다.
Java에서 안전한 쓰기를 구현하는 방법?
패턴:
- 임시 파일에 데이터를 기록합니다(보통 같은 폴더).
- 기록이 성공하면 임시 파일을 원본 위치로 원자적으로 이동하여 교체합니다.
왜 이것이 효과적인가?
동일한 파일 시스템 내에서의 파일 이동(rename/move)은 보통 원자적입니다. 즉, 파일이 완전히 교체되거나 전혀 교체되지 않습니다. 문제가 생기면 원본 파일은 그대로 유지됩니다.
코드 예시: 안전한 파일 쓰기
import java.nio.file.*;
public class SafeWriteDemo {
public static void safeWrite(Path target, byte[] data) throws Exception {
// 1. 같은 폴더에 임시 파일을 생성
Path tempFile = Files.createTempFile(target.getParent(), "tmp_", ".tmp");
try {
// 2. 데이터를 임시 파일에 기록
Files.write(tempFile, data);
// 3. 임시 파일을 대상으로 원자적으로 이동
Files.move(
tempFile,
target,
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE
);
} finally {
// 문제가 발생하면 임시 파일을 삭제
Files.deleteIfExists(tempFile);
}
}
public static void main(String[] args) throws Exception {
Path file = Paths.get("important.txt");
byte[] content = "매우 중요한 데이터".getBytes();
safeWrite(file, content);
System.out.println("파일이 안전하게 기록되었습니다!");
}
}
유의 사항:
- Files.createTempFile()을 사용해 임시 파일을 생성합니다.
- 이동 시 ATOMIC_MOVE 옵션을 사용합니다. 운영 체제와 파일 시스템이 지원하는 경우 원자성이 보장됩니다.
- 문제가 발생하면 임시 파일을 삭제합니다(Files.deleteIfExists).
언제 특히 중요할까요?
- 구성 파일, 데이터베이스, 로그 파일을 다룰 때.
- 다른 프로세스가 언제든지 해당 파일을 읽을 수 있는 경우.
- 쓰기 중 장애가 발생하면 데이터 손실이나 손상이 발생할 수 있는 경우.
5. 로깅과 접근 오류 처리
접근 오류를 올바르게 처리하는 방법
파일을 다룰 때는 항상 예외 처리(try-catch)를 사용하세요. 이를 통해 다음을 할 수 있습니다:
- 문제를 사용자에게 올바르게 안내(예: “폴더에 쓸 권한이 없습니다”).
- 향후 분석을 위해 로그에 오류를 기록.
- 단일 실패로 전체 프로그램이 중단되는 것을 방지.
예: AccessDeniedException 처리
import java.nio.file.*;
public class FileAccessDemo {
public static void main(String[] args) {
Path file = Paths.get("/etc/shadow"); // Linux 예시
try {
Files.readAllLines(file);
} catch (AccessDeniedException ade) {
System.err.println("접근 오류: 파일을 읽을 권한이 없습니다 " + file);
// 로그에 기록하거나 사용자에게 다른 파일을 선택하도록 안내할 수 있습니다
} catch (Exception e) {
System.err.println("다른 오류: " + e.getMessage());
}
}
}
오류 로깅
실제 애플리케이션에서는 로깅 시스템(예: java.util.logging, Log4j, SLF4J)을 사용하세요. 이를 통해:
- 자세한 정보(스택 트레이스, 시간, 사용자 등)와 함께 오류를 기록할 수 있습니다.
- 문제 식별과 해결을 위해 로그를 분석할 수 있습니다.
- 사용자에게는 불필요하게 “무서운” 메시지를 보여 주지 않고, 로그에만 상세 정보를 남길 수 있습니다.
로깅 예시:
import java.nio.file.*;
import java.util.logging.*;
public class FileLoggerDemo {
private static final Logger logger = Logger.getLogger(FileLoggerDemo.class.getName());
public static void main(String[] args) {
Path file = Paths.get("data.txt");
try {
Files.readAllLines(file);
} catch (AccessDeniedException ade) {
logger.severe("파일에 접근할 수 없습니다: " + file);
} catch (Exception e) {
logger.log(Level.SEVERE, "파일 처리 중 오류", e);
}
}
}
6. 자주 발생하는 실수
실수 1: 파일 작업에서 예외를 무시함.
Files.write(path, data)만 호출하고 try-catch를 생략하지 마세요 — 문제가 발생하면 프로그램이 중단될 수 있습니다.
실수 2: TOCTOU를 고려하지 않은 권한 확인.
Files.isWritable() 같은 메서드에만 의존하지 마세요. “가능”을 반환해도 실제 작업은 실패할 수 있습니다. 항상 예외(AccessDeniedException 등)를 처리하세요.
실수 3: 백업 없이 기존 파일 위에 덮어쓰기.
중요한 파일이라면 기록 전 백업을 만들거나 StandardCopyOption.ATOMIC_MOVE를 사용한 원자적 쓰기를 사용하세요.
실수 4: 장애 이후 임시 파일을 삭제하지 않음.
원자적 쓰기 중 문제가 발생하면 임시 파일이 남을 수 있습니다. finally와 Files.deleteIfExists()를 사용하세요.
실수 5: 접근 오류를 로깅하지 않음.
프로그램이 파일을 읽거나 쓰지 못했다면 사용자는 이를 알아야 하고, 개발자는 로그에서 상세 정보를 확인할 수 있어야 합니다. java.util.logging/SLF4J를 사용해 스택 트레이스와 함께 예외를 기록하세요.
GO TO FULL VERSION