우테캠 WAS 미션 구현 중 정적 리소스 파일을 HttpResponse에 반환해야 하는 일이 있었다.
정적 리소스를 상대 경로로 불러와서 사용했으나, 어째서인지 AWS에서 Jar 파일로 실행 시 Static 파일을 찾을 수 없다는 메시지가 떴다. 분명히 Intellij 환경에선 잘 실행이 되었는데 무슨 문제일까?
아래는 문제의 코드이다.
private static final String BASE_DIRECTORY = "src/main/resources/static";
...
private static File getFile(final String path) {
String staticPath = BASE_DIRECTORY + path;
File file = new File(staticPath);
if (file.isDirectory()) {
file = new File(staticPath + "/index.html");
}
if (!file.exists()) {
throw new IllegalArgumentException("Not found static file");
}
return file;
}
문제를 파악하기 위해 Jar란 무엇인지 알아보자.
Jar는 여러개의 자바 클래스 파일과, 클래스들이 이용하는 관련 리소스(텍스트, 그림 등) 및 메타데이터를 하나의 파일로 모아서 자바 플랫폼에 응용 소프트웨어나 라이브러리를 배포하기 위한 소프트웨어 패키지 파일 포맷이다.
Jar 파일은 자바 런타임이 효율적으로 애플리케이션을 배포할 수 있는 수단으로 설계되었는데, 자바 애플리케이션을 구성하는 클래스와 관련 리소스들을 단일 파일로 묶어 압축된 형태로 한 차례의 요청으로 애플리케이션 전체를 다운로드 할 수 있게 해준다.
출처 - 위키피디아
위의 글을 보면 Jar는 하나의 압축 파일인데 왜 절대 경로, 상대 경로로 파일을 가져올 수 없는 것일까?
log.debug("path = {}", Paths.get("").toAbsolutePath());
IDE 내에서 경로를 탐색 했을 때
Static path = /Users/woowatech14/Desktop/java-was
Static file getAbsolutePath: /Users/woowatech14/Desktop/java-was/src/main/resources/static
프로젝트 내 build/libs 에서 jar를 시작한 경우 - 에러 !
Static path = /Users/woowatech14/Desktop/java-was/build/libs
Static file getAbsolutePath: /Users/woowatech14/Desktop/java-was/build/libs/src/main/resources/static
Jar 파일을 바탕화면에서 실행 했을 때 - 에러 !
path = /Users/woowatech14/Desktop
Static file getAbsolutePath: /Users/woowatech14/Desktop/src/main/resources/static
private static final String ROOT = "/static";
private static File getFile(final String path) {
// 현재 클래스의 위치에서 ROOT 경로를 기준으로 절대 경로를 구합니다.
Path absolutePath = Path.of(ROOT).toAbsolutePath();
// 구한 절대 경로와 요청된 path를 결합하여 리소스의 URL을 얻습니다.
URL url = StaticHandler.class.getResource(ROOT + path);
// URL에서 파일 경로를 추출합니다.
String fileResource = url.getFile();
// 추출한 파일 경로로 File 객체를 생성합니다.
File file = new File(fileResource);
if (file.isDirectory()) {
file = new File(staticPath + "/index.html");
}
if (!file.exists()) {
throw new IllegalArgumentException("Not found static file");
}
return file;
}
IDE로 실행할 경우
Static path = /Users/woowatech14/Desktop/java-was
Static file getAbsolutePath: /Users/woowatech14/Desktop/java-was/out/production/resources/static
build/libs에서 실행할 경우 - 에러 !
Static path = /Users/woowatech14/Desktop/java-was/build/libs
Static file getAbsolutePath: file:/Users/woowatech14/Desktop/java-was/build/libs/java-was-1.0-SNAPSHOT.jar!/static
바탕화면에서 jar를 실행할 경우 - 에러 !
Static path = /Users/woowatech14/Desktop
Static file getAbsolutePath: file:/Users/woowatech14/Desktop/java-was-1.0-SNAPSHOT.jar!/static
둘 다 jar로 실행할 경우 resource 파일을 가져오지 못했다.
그러나 로그를 자세히 보면 절대 경로를 가져오는 코드로 Jar 파일로 실행하는 경우 file:/Users/woowatech14/Desktop/java-was-1.0-SNAPSHOT.jar!/static
절대 경로가 반복되면서 jar! 라는 새로운 경로가 추가되었다. 바로 Jar에 대한 특별한 경로라고 한다.
그런데 왜 static 파일을 가져올 수 없는 것일까?
위의 Jar 파일의 정의를 다시 보자
… 자바 애플리케이션을 구성하는 클래스와 관련 리소스들을 단일 파일로 묶어 압축된 형태 …
여기서 주목해야 할 건 압축된 파일이라는 것이다. 자바에서의 File 객체는 파일 시스템의 파일이나 디렉토리를 추상화한 것인데 File 객체는 실제 파일 시스템에 존재하는 파일이나 디렉토리에만 작동한다.
그러나 Jar 파일 내부의 경로는 실제 파일 시스템에 존재하지 않아서 File 객체로는 접근이 불가능하고 압축된 형태를 읽을 수 있는 특별한 방법을 사용해야 한다. 이를 보여주는 예시가 java-was-1.0-SNAPSHOT.jar!/static
이 부분이다. 실제 파일 시스템엔 !가 포함된 경로는 존재하지 않고 java-was-1.0-SNAPSHOT.jar
여기서 끝이 나버린다.
따라서 압축된 파일을 위해 자바에선 다양한 패키지를 제공하는데 java.utiil.zip부터 이를 상속한 java.util.jar까지 제공한다.
결론까지 많은 지식을 보았다. 이제 Jar 안의 파일을 어떻게 가져올 것인가에 대해 얘기해볼 차례이다.
내가 선택한 방법은 클래스 로더를 사용하여 리소스를 읽는 것이다.
public static byte[] getStaticFiles(final String path) throws IOException {
ClassLoader classLoader = StaticHandler.class.getClassLoader();
String resourcePath = "static" + path;
try (InputStream resourceAsStream = classLoader.getResourceAsStream(resourcePath)) {
if (resourceAsStream == null) {
throw new IllegalArgumentException("Static file not found");
}
return resourceAsStream.readAllBytes();
}
}
코드를 한줄 한줄 설명해보면 ClassLoader를 이용해서 애플리케이션 클래스 로더로 내가 작성한 클래스 패스에 있는 리소스를 로드하고 나의 클래스 패스에서 resourcePath를 검색한다.
클래스 로더의 대한 자세한 설명은 여기로 가도록 하자.
그 후 classLoader.getResourceAsStream(resourcePath);
이렇게 Stream으로 가져오는데 왜 getResource()
로 가져오게 되면 URL로 반환하기 때문에 jar 파일의 !와 함께 표시되어 에러가 발생한다.
따라서 getResourceAsStream()
로 읽어서 byte[]로 변환한다.
왜 이렇게 사용하는지는 아래를 보면 이해 할 수 있다.
getResourceAsStream
코드를 따라 가면 openStream()
이 있는데 이를 내부적으로 추적하면 Openconnection을 다형적으로 처리한다.
이때 handler가 URL을 다형적으로 작동해 JarURLConnection
으로 만들어서 URL이 jar:file:
로 시작해서 파일에서도 jar에서도 잘 동작할 수 있다.
사실 getResource()
를 사용하더라도 openStream()
을 사용하면 jar에서도 잘 동작한다. 그러나 getResource()
로 URL을 가져와 File
객체를 생성하면 JarFile
로 다형적으로 작동되지 않아서 IDE에서만 되고 InputStream
으로 사용하면 다형성이 아래와 같이 openStream()
을 타면서 적용된다.
JarURLInputStream
을 가져온다.결론은 Jar에 대한 이해 부족이 만들어진 에러였다. 매번 인텔리제이 또는 스프링부트의 도움으로 Jar를 실행했는데 이런 경우가 있는지 처음 알게 되었다. 이러한 툴에 의해서 매번 동작 원리도 모른체 썼던 것 같은데 이번 기회에 알게 되었으니,, 기본을 항상 잘 다지도록 하자.
이번 글을 쓰면서 Docs와 디버그를 활용하면서 한줄 한줄 들어갔을 때 굉장히 다형적인 코드가 신기했다. 또한 인텔리제이에서 Jar 파일을 디버그를 하거나 원격으로 디버그를 하는 기능도 알게 되었는데 잘 활용할 거 같다 !