컨테이너 AccessDeniedException 해결: 읽기전용 파일시스템에서 임시 폴더를 /tmp로 옮기기
로컬에서는 되던 코드가 Docker 컨테이너에서 AccessDeniedException: /app/build 로 멈춘 원인과, 임시 경로·예외 처리·볼륨으로 나눠 고친 과정을 적었습니다. 로컬에서 읽기전용 환경을 미리 재현하는 docker run 옵션도 함께 정리했습니다.
유튜브 구독 채널을 등록하고 새 영상을 자동으로 수집·요약하는 cron을 처음 돌렸더니, 자막을 뽑는 단계에서 AccessDeniedException: /app/build 가 나면서 구독 폴링 전체가 멈췄습니다. 원인은 앱이 컨테이너 안의 자기 설치 폴더(/app) 밑에 임시 폴더를 만들려 했는데, 그 경로가 쓰기 금지였던 것입니다. 임시 폴더를 /tmp(java.io.tmpdir)로 옮기고 폴더 만드는 코드를 try 안으로 넣어서 고쳤습니다.
로컬에서는 같은 코드가 몇 번을 돌려도 멀쩡했습니다. 아래에 그때 확인한 순서와 고친 방법, 고친 뒤 CI에서 한 번 더 막힌 이유까지 적습니다.
AccessDeniedException: /app/build 가 난 상황
수집 서버는 Spring Boot 앱을 Docker 컨테이너로 띄워 둔 구성입니다. 영상 URL 하나를 넣어 요약하는 기능은 이미 잘 돌고 있었고, 이번에 구독 채널을 주기적으로 훑는 cron을 붙였습니다.
에러가 이 시점에야 드러난 이유가 있었습니다. URL 하나를 분석하는 기능은 다른 수집기(UrlCollector) 경로를 타서 문제의 코드를 지나가지 않았습니다. 구독 cron이 yt-dlp로 자막을 뽑는 경로를 실제로 쓴 첫 기능이었고, 그 경로 안에 /app/build 밑에 임시 폴더를 만드는 코드가 있었습니다.
그 폴더 생성 코드가 try-catch 밖에 있었던 것도 문제를 키웠습니다. 임시 폴더 하나 못 만든 예외가 위로 올라가면서 영상 한 건이 아니라 구독 폴링 전체가 멈췄습니다.
컨테이너에서는 왜 앱 폴더에 쓸 수 없나
로컬에서는 내 계정이 프로젝트 폴더의 주인이라 그 밑에 무엇이든 만들 수 있습니다. 컨테이너는 다릅니다. 루트 파일시스템을 읽기 전용으로 띄우거나, 앱 파일이 놓인 경로에 실행 계정이 쓰지 못하게 해 두는 경우가 많습니다.
이건 잘못된 설정이 아니라 권장되는 구성입니다. 앱 바이너리가 있는 경로를 못 쓰게 막아 두면, 침해가 일어나도 바이너리를 바꾸거나 파일을 심어 두기 어려워집니다. Docker는 --read-only 옵션으로, Kubernetes는 securityContext.readOnlyRootFilesystem 으로 같은 설정을 켭니다.
코드는 그대로인데 환경이 달라서, 로컬에서는 우연히 통과하던 가정(“내 폴더에는 당연히 쓸 수 있다”)이 컨테이너에서 깨진 것입니다.
원인 확인 순서
비슷한 에러가 나면 아래 순서로 보면 빠릅니다.
- 에러 메시지의 경로를 봅니다.
AccessDeniedException뒤에 붙은 경로가 앱 설치 경로(/app/...)라면 이 글과 같은 경우일 가능성이 큽니다. - 컨테이너 안에서 직접 써 봅니다.
docker exec <컨테이너이름> touch /app/build/test를 실행해 같은 에러가 나는지 확인합니다.Read-only file system이 나오면 루트가 읽기 전용이고,Permission denied가 나오면 실행 계정에 쓰기 권한이 없는 것입니다. - 실행 계정과 폴더 주인을 봅니다.
docker exec <컨테이너이름> id와docker exec <컨테이너이름> ls -ld /app으로 앱이 어떤 계정으로 돌고, 폴더 주인이 누구인지 확인합니다. /tmp에는 써지는지 봅니다.docker exec <컨테이너이름> touch /tmp/test가 되면 임시 파일을 그쪽으로 옮기는 것으로 해결됩니다.
해결 1: 임시 파일은 java.io.tmpdir(/tmp)에 만든다
임시 데이터를 앱 설치 경로에 두던 것부터 바꿨습니다. JVM이 알려 주는 표준 임시 디렉토리(java.io.tmpdir, 리눅스 컨테이너에서는 보통 /tmp)를 쓰면 앱 경로 권한과 상관없이 동작합니다. 아래 코드는 원리를 보여 주는 Java 예시입니다.
// before: 앱 경로 밑에 임시 폴더 → 읽기전용 FS에서 실패
File tmp = new File("/app/build/tmp");
// after: 표준 임시 디렉토리 사용
File tmp = new File(System.getProperty("java.io.tmpdir"), "myapp");
java.nio.file.Files.createTempDirectory(prefix) 를 써도 같은 임시 디렉토리 밑에 폴더가 만들어집니다. 공식 API 문서에는 이 메서드가 “the temporary-file directory” 에 폴더를 만든다고 적혀 있습니다(Files API 문서).
해결 2: 폴더 생성을 try 안에 넣어 그 건만 건너뛴다
두 번째로 폴더 생성과 쓰기를 try 안으로 넣었습니다. 임시 파일을 못 만들어도 그 영상 한 건만 결과가 비고(null), 나머지 영상 수집은 계속 돕니다.
try {
Files.createDirectories(tmp.toPath());
// ... 임시 파일 쓰기
} catch (IOException e) {
// 이 건만 건너뛰고 나머지는 계속 진행
log.warn("임시 파일 생성 실패, 건너뜁니다", e);
}
AccessDeniedException 은 IOException 의 하위 예외라서 위 catch 에서 같이 잡힙니다. 건너뛸 때 로그는 꼭 남겨야 합니다. 로그 없이 넘기면 이번에는 조용히 빠지는 영상이 생기는데, 이 부분은 조용한 실패를 막는 에러 핸들링 글에 따로 적었습니다.
해결 3: 남겨야 하는 데이터는 볼륨에 쓴다
/tmp 는 컨테이너를 다시 만들면 비워지는 자리입니다. 다운로드한 파일이나 결과물처럼 계속 남겨야 하는 데이터는 볼륨을 마운트하고 그 경로에만 쓰게 해야 합니다.

| 데이터 | 쓰는 곳 | 컨테이너를 다시 만들면 |
|---|---|---|
| 앱 파일 | /app (쓰지 않음) | 이미지에서 다시 옴 |
| 임시 파일 | /tmp | 사라짐 |
| 남길 데이터 | 마운트한 볼륨 | 남음 |
고친 뒤 CI에서 다시 막힌 이유: 커버리지 100% 게이트
고친 코드를 올렸는데 컨테이너에서는 같은 에러가 계속 났습니다. 확인해 보니 배포가 아예 되지 않았습니다. CI에서 koverVerify FAILED 로 빌드가 실패해서, 서버는 전날 만든 옛 이미지를 그대로 쓰고 있었습니다.
이 프로젝트는 라인 커버리지 100%를 빌드 조건으로 걸어 두었습니다. 처음 고칠 때 예외 상황을 더 꼼꼼히 다루려고 분기를 추가했는데, 테스트가 그 분기를 지나가지 않아 커버리지가 99.7%로 떨어졌습니다.
경로 한 줄만 바꾸는 최소 수정으로 다시 고치니 코드 구조가 그대로라 커버리지도 유지됐고, 배포가 정상적으로 됐습니다. 에러가 안 사라질 때는 코드보다 먼저 이미지 날짜부터 보는 게 빠릅니다.
로컬에서 읽기전용 컨테이너를 미리 재현하는 법
같은 일을 배포 전에 잡으려면, 로컬에서도 읽기전용으로 띄워 보면 됩니다. Docker 공식 문서 기준으로 --read-only 는 컨테이너의 루트 파일시스템을 읽기 전용으로 마운트하고, --tmpfs 는 지정한 경로에 메모리 기반 임시 파일시스템을 붙입니다(docker container run 문서, 2026년 10월 확인).
# 루트는 읽기 전용, /tmp만 쓰기 가능하게 띄워 보기
docker run --read-only --tmpfs /tmp my_image
# 문서의 예: 볼륨으로 쓰기 가능한 경로를 따로 연다
docker run --read-only -v /icanwrite busybox touch /icanwrite/here
이렇게 띄워서 기능을 한 번 돌려 보면, 앱이 어디에 파일을 쓰는지 바로 드러납니다. Kubernetes에서는 readOnlyRootFilesystem: true 를 켜고, 써야 하는 경로에만 emptyDir 같은 볼륨을 붙이는 방식으로 같은 구성을 만듭니다(Security Context 문서).
주의할 점
--read-only만 켜면/tmp도 같이 읽기 전용이 됩니다. 임시 파일을 쓰는 앱이면--tmpfs /tmp나 볼륨을 함께 붙여야 합니다.tmpfs는 메모리를 씁니다. 큰 파일을 임시로 내려받는 앱이라면 크기 옵션(문서 예시는size=65536k)을 정하거나 디스크 볼륨을 쓰는 편이 안전합니다./tmp와tmpfs는 컨테이너를 다시 만들면 비워집니다. 남겨야 하는 파일을 여기 두면 안 됩니다.- 외부 도구를 부르는 코드는 작업 폴더를 따로 만드는 경우가 많습니다. 이번 임시 폴더도 yt-dlp로 자막을 받기 위한 자리였습니다. 새 기능을 붙일 때는 그 경로가 어디인지 같이 확인합니다.
컨테이너를 다시 만들 때 생기는 다른 문제로는 nginx가 옛 IP를 붙잡아 502가 나는 경우도 있습니다.
자주 묻는 질문
컨테이너에서 AccessDeniedException이 뜨는데 로컬에선 왜 멀쩡했나요?
로컬은 앱이 실행되는 폴더에 대부분 쓰기 권한이 있습니다. 반면 컨테이너 파일시스템은 읽기 전용이거나 앱 경로에 쓰기 권한이 없는 경우가 많습니다. 그래서 앱이 자기 폴더 밑에 임시 폴더를 만들려다 권한 거부로 죽는 것입니다.
임시 파일은 어디에 만들어야 안전한가요?
java.io.tmpdir이 가리키는 경로, 보통 /tmp에 만드는 것이 안전합니다. 앱 설치 경로(/app 아래) 대신 쓰기가 허용된 임시 디렉토리를 명시적으로 쓰면 읽기전용 루트 파일시스템에서도 문제가 없습니다.
readOnlyRootFilesystem을 켜면 앱이 아예 못 쓰게 되는 것 아닌가요?
루트 파일시스템을 읽기 전용으로 두더라도, 앱이 써야 하는 경로만 별도로 열어 주면 됩니다. /tmp를 쓰기 가능한 임시 볼륨으로 두거나, 영속 저장이 필요한 경로에 볼륨을 마운트하는 식입니다. 앱이 어디에 쓰는지 명시적으로 설계하는 것이 핵심입니다.