빌드노트
JAR 안에 템플릿이 있는데 ClassPathResource.getFile()은 실패한다
classpath 자원이 존재해도 getFile()은 실패할 수 있다. 디렉터리와 일반 실행 JAR에서 URL·File·스트림을 직접 비교하고, 스트림 읽기와 실제 File 입력을 고르는 기준을 정리한다.
IDE에서 new ClassPathResource("templates/mail.txt").getFile()로 읽던 템플릿이 JAR로 실행하면 FileNotFoundException을 낼 수 있다. 파일이 패키징에서 빠졌다는 뜻으로만 읽으면 원인을 놓친다. 자원이 존재하는 것과 디스크의 File로 해석할 수 있는 것은 별개다. JAR 안에 들어 있는 항목이라면 내용을 읽을 때 getInputStream()을 쓰고, 다음 API가 실제 File을 요구하는지도 확인해야 한다.
실패한 호출 앞에서 URL을 본다
먼저 같은 실행 환경에서 템플릿의 존재 여부와 위치를 확인한다. Spring을 이미 사용하는 애플리케이션의 읽기 메서드에 다음 코드를 넣는다. templates/mail.txt는 자신의 classpath 자원명으로 바꾸고, 메서드에서 IOException을 처리하거나 throws IOException을 선언한다.
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
Resource template = new ClassPathResource("templates/mail.txt");
System.out.println("exists=" + template.exists());
System.out.println("url=" + template.getURL());
ClassPathResource 생성자에는 classpath 내부 이름을 넣는다. 이 예제에서 소스 파일이 src/main/resources/templates/mail.txt에 있더라도 그 소스 디렉터리 이름까지 넣지는 않는다. IDE 실행 결과만 보지 말고, 실패하는 배포본에서도 같은 조회를 해야 한다.
URL이 file:/.../templates/mail.txt라면 자원이 파일시스템에 풀려 있는 경우다. jar:file:/.../app.jar!/templates/mail.txt라면 JAR 내부 항목이다. ! 뒤는 JAR 안의 이름이며 그 텍스트 파일이 디스크에 따로 생겼다는 뜻은 아니다. Spring 6.2.12 ClassPathResource 문서 (새 탭에서 열림)는 파일시스템에 있는 자원과 JAR 내부 자원의 File 지원을 구분한다.
exists=false이고 getURL()부터 실패한다면 경로·대소문자·최종 JAR의 포함 여부와 실행 classpath를 조사할 차례다. 반대로 URL을 찾았고 스트림으로 내용도 읽히는데 getFile()만 실패한다면, 자원을 다시 넣기보다 읽는 API를 바꿀 근거가 생긴다. exists=true만으로 이후 읽기의 성공까지 보장하지는 않으므로 실제 내용 읽기도 확인한다.
같은 텍스트를 디렉터리와 일반 실행 JAR에 넣었다
차이를 확인하려고 새 예제에 hello from classpath와 줄바꿈 하나가 든 templates/mail.txt를 만들었다. 먼저 클래스와 자원을 디렉터리 classpath로 실행했다. 이어 JDK의 jar 명령으로 클래스와 동일한 자원 바이트를 묶고 java -jar로 실행했다. Spring 라이브러리는 JAR 바깥에 두고 manifest의 Class-Path로 연결했다.
아래는 실제 출력 중 핵심 부분이다. 긴 앞 디렉터리 경로만 ...로 줄였고, \n은 읽은 내용 끝의 줄바꿈을 표시한 것이다.
mode=exploded
exists=true
url=file:/.../resources/templates/mail.txt
isFile=true
getFile=OK: ...\resources\templates\mail.txt
getInputStream=hello from classpath\n
mode=jar
exists=true
url=jar:file:/.../resource-probe.jar!/templates/mail.txt
isFile=false
getFile=java.io.FileNotFoundException: class path resource [templates/mail.txt] cannot be resolved to absolute file path because it does not reside in the file system: jar:file:/.../resource-probe.jar!/templates/mail.txt
getInputStream=hello from classpath\n
JAR 목록에도 templates/mail.txt가 있었고 원본과 바이트가 같았다. 실패한 것은 그 자원을 디스크 파일로 해석하는 호출이었다. Resource.getFile() 문서 (새 탭에서 열림)도 파일로 해석할 수 없을 때 FileNotFoundException이 발생할 수 있다고 설명한다. 예외 이름만 보고 누락으로 결론내리면 이번 두 조건을 구별할 수 없다.
별도 대조로 JAR에 넣지 않은 templates/absent.txt도 조회했다. 이때는 exists=false였고 getURL()과 getInputStream()도 모두 실패했다. 존재하는 JAR 항목의 getFile() 실패와는 관찰이 달랐다.
내용을 읽는 데 File이 꼭 필요한가
텍스트 내용을 읽으려는 목적이라면 getFile()을 거쳐 FileInputStream을 만들 필요가 없다. 앞에서 얻은 template에 대해 스트림을 직접 열고 닫으면 된다. 다음 코드는 작은 UTF-8 텍스트를 문자열로 읽는 수정안이며, 같은 메서드 안에서 사용한다.
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
String text;
try (InputStream in = template.getInputStream()) {
text = new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
이 방식으로 디렉터리와 JAR 양쪽에서 같은 내용을 읽었다. readAllBytes()는 전체 내용을 메모리에 올리므로 여기서는 작은 템플릿에 한정했다. 소비 API가 InputStream이나 Reader를 받는다면 그 API로 넘기고, 실제로 읽는 작업이 끝날 때까지 스트림을 열어 둔다.
스트림은 호출자가 닫아야 한다. Spring Resource 설명 (새 탭에서 열림)은 그 책임을 명시한다. 실험에서는 실제 스트림을 감싼 추적 래퍼로 정상 읽기와 읽는 도중 의도적으로 예외를 낸 경우를 각각 실행했다. 두 경우 모두 try-with-resources가 원래 스트림의 close()를 호출하고 반환한 것을 확인했다.
다음 라이브러리에 File을 받는 메서드만 있다면 선택이 달라진다. JAR 항목을 임시 파일로 복사한 뒤 그 파일을 넘기거나, 애초에 외부 디스크 파일을 입력으로 받도록 구성해야 한다. 이번 예제에서는 JAR 스트림을 새 임시 파일로 복사하고, 동기식 File 소비 코드로 같은 내용을 읽은 뒤 파일을 삭제하는 경로까지 실행했다. 복사 스트림은 닫았고 삭제 뒤 파일이 남지 않은 것도 확인했다. 비동기 소비자라면 메서드가 반환했다고 즉시 삭제하지 말고 실제 소비 완료까지 파일 수명을 유지해야 한다.
URL 문자열을 잘라 new File(...)에 넣는 것으로는 JAR 안의 항목이 디스크 파일로 추출되지 않는다. 배포 후에도 사람이 수정해야 하는 템플릿이라면 외부 파일을 명시적으로 받는 구성이 목적에도 맞는다. 반면 앱에 묶인 작은 텍스트를 읽기만 한다면 스트림으로 읽는 수정부터 적용할 수 있다.
직접 비교한 환경은 Windows 11, OpenJDK 17.0.2, Spring Framework 6.2.12다. 실행한 포맷은 직접 만든 일반 실행 JAR이며 Spring Boot의 중첩 JAR은 실행하지 않았다. Boot 로더가 만드는 URL 모양과 다른 서버·운영 배포본의 동작은 이번 관찰에 포함하지 않는다. 자신의 실행본에서는 URL과 실제 읽기 결과를 다시 확인해야 한다.