IDE 에서는 되는데 jar 에서는 안 돼요 - Java Resource

한 줄 요약: 웬만하면 getResource() 쓰지 말고 getResourceAsStream() 쓰자

기본 폴더 구조

자바에서는 메이븐이 널리 사용되면서 아래와 같은 폴더 구조가 표준처럼 사용되고 있다.

Imgur

src/main/java 폴더 하위에 있는 java 파일은 빌드 후 target/classes 하위에 위치하게 되고,
src/main/resources/static 폴더는 빌드 후 target/static 폴더 바로 아래에 위치하게 된다.

자바 파일이든 그 외 파일이든 결국 빌드 후에는 target 디렉터리가 루트 디렉터리가 된다.

main 클래스

1
2
3
4
5
6
7
8
9
@Slf4j
public class App {

public static void main(String[] args) throws IOException {
ResourceLoader resourceLoader =
new ResourceLoader("/static", Path.of("/static"));
resourceLoader.loadResourceAsFile("/folder1/sample1");
}
}

getResource()

getResource()를 사용해서 파일로 읽어들인 후 출력하는 프로그램은 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Slf4j
@RequiredArgsConstructor
public class ResourceLoader {

private final String root;
private final Path rootPath;


public void loadResourceAsFile(String resourceLocation) throws IOException {
log.info("*** getResource() + File 방식");
log.info("content root: {}", rootPath);
log.info("resourceLocation: {}", resourceLocation);

URL resourceURL = this.getClass().getResource(root + resourceLocation);
log.info("resourceURL: {}", resourceURL);

String fileLocation = resourceURL.getFile();
log.info("fileLocation from URL: {}", fileLocation);

File file = new File(fileLocation);
FileReader fileReader = new FileReader(file);
char[] chars = new char[(int) file.length()];
fileReader.read(chars);

log.info("resource contents: {}", new String(chars));
}
}

IDE 에서 동작 확인

IDE 에서 실행하면 다음과 같이 정상적으로 출력된다.

1
2
3
4
5
6
00:56:15.152 [main] INFO io.homo_efficio.ResourceLoader - *** getResource() + File 방식
00:56:15.155 [main] INFO io.homo_efficio.ResourceLoader - content root: /static
00:56:15.156 [main] INFO io.homo_efficio.ResourceLoader - resourceLocation: /folder1/sample1
00:56:15.158 [main] INFO io.homo_efficio.ResourceLoader - resourceURL: file:/Users/1003604/gitRepo/study/maven-fat-jar-test/target/classes/static/folder1/sample1
00:56:15.158 [main] INFO io.homo_efficio.ResourceLoader - fileLocation from URL: /Users/1003604/gitRepo/study/maven-fat-jar-test/target/classes/static/folder1/sample1
00:56:15.159 [main] INFO io.homo_efficio.ResourceLoader - resource contents: Sample File 1

resourceURL 값이 file: 로 시작한다는 것을 기억해두자.

읽을 파일 경로는 /Users/1003604/gitRepo/study/maven-fat-jar-test/target/classes/static/folder1/sample1로 표시되는데 이는 파일시스템에 실제 존재하는 경로와 일치한다.

fat-jar 에서 동작 확인

하지만 다음과 같이 java -jar 명령으로 fat-jar 파일을 실행하면 다음과 같이 오류가 발생한다. maven에서 fat-jar 만드는 방법은 https://github.com/HomoEfficio/dev-tips/blob/master/Maven-fat-jar.md 를 참고한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
maven-fat-jar-test git:master 🍺🦑🍺🍕🍺 ❯ java -jar target/maven-fat-jar.jar
00:58:11.479 [main] INFO io.homo_efficio.ResourceLoader - *** getResource() + File 방식
00:58:11.481 [main] INFO io.homo_efficio.ResourceLoader - content root: /static
00:58:11.482 [main] INFO io.homo_efficio.ResourceLoader - resourceLocation: /folder1/sample1
00:58:11.484 [main] INFO io.homo_efficio.ResourceLoader - resourceURL: jar:file:/Users/1003604/gitRepo/study/maven-fat-jar-test/target/maven-fat-jar.jar!/static/folder1/sample1
00:58:11.484 [main] INFO io.homo_efficio.ResourceLoader - fileLocation from URL: file:/Users/1003604/gitRepo/study/maven-fat-jar-test/target/maven-fat-jar.jar!/static/folder1/sample1
Exception in thread "main" java.io.FileNotFoundException: file:/Users/1003604/gitRepo/study/maven-fat-jar-test/target/maven-fat-jar.jar!/static/folder1/sample1 (No such file or directory)
at java.base/java.io.FileInputStream.open0(Native Method)
at java.base/java.io.FileInputStream.open(FileInputStream.java:212)
at java.base/java.io.FileInputStream.<init>(FileInputStream.java:154)
at java.base/java.io.FileReader.<init>(FileReader.java:75)
at io.homo_efficio.ResourceLoader.loadResourceAsFile(ResourceLoader.java:38)
at io.homo_efficio.App.main(App.java:19)

에러 메시지를 보면 파일 경로(정확히는 URL)가 file:/Users/1003604/gitRepo/study/maven-fat-jar-test/target/maven-fat-jar.jar!/static/folder1/sample1 라고 표시된다. fat-jar 파일이 중간에 mavan-fat-jar.jar! 로 표시돼 있는데 이렇게 !가 포함된 경로는 실제로 존재하지 않기 때문에 위와 같은 에러가 발생하게 된다.

즉 IDE에서 실행할 때는 실제 파일시스템 기준 경로를 따르므로 에러가 발생하지 않지만, jar 파일을 읽을 때는 jar 파일이 !와 함께 표시되기 때문에 실제 파일시스템 경로에 맞지 않아 에러가 발생한다.

rsourceURL 값이 IDE 에서 실행할 때는 file: 로 시작했는데, jar 로 실행할 때는 jar:file:로 시작한다. 이것도 기억해두자.

어쨌든 자바 프로그램은 실제로는 대부분 jar 로 만들어져서 실행될텐데, jar 에서 제대로 실행이 안 된다면 이를 어쩐다?

getResourceAsStream()

getResource() 는 기본적으로 URL 을 반환한다. URL은 위와 같이 jar 파일을 !와 함께 표시하기 때문에, jar 실행 시 에러가 발생한다.

하지만 getResourceAsStream()은 InputStream 을 반환한다. 그리고 Java 9 에서 추가된 InputStream.readAllBytes()를 사용하면 편리하게 InputStream 을 읽어서 byte[] 에 저장할 수 있다.(물론 대용량 데이터를 readAllBytes()로 읽어들이면 망함. 대용량 파일 처리는 https://homoefficio.github.io/2019/02/27/Java-NIO-Direct-Buffer를-이용해서-대용량-파일-행-기준으로-쪼개기/ 를 참고하자)

1
2
3
4
5
6
7
8
9
10
11
// ResourceLoader.java

public void loadResourceAsStream(String resourceLocation) throws IOException {
log.info("OOO getResourceAsStream() 방식");
log.info("content root: {}", root);
log.info("resourceLocation: {}", resourceLocation);

InputStream resourceAsStream = this.getClass().getResourceAsStream(root + resourceLocation);
byte[] bytes = resourceAsStream.readAllBytes();
log.info("resource contents: {}", new String(bytes, StandardCharsets.UTF_8));
}

IDE 에서 동작 확인

IDE 에서 실행하면 다음과 같이 정상적으로 실행된다.

1
2
3
4
23:55:45.443 [main] INFO io.homo_efficio.ResourceLoader - OOO getResourceAsStream() 방식
23:55:45.443 [main] INFO io.homo_efficio.ResourceLoader - content root: /static
23:55:45.443 [main] INFO io.homo_efficio.ResourceLoader - resourceLocation: /folder1/sample1
23:55:45.443 [main] INFO io.homo_efficio.ResourceLoader - resource contents: Sample File 1

fat-jar 에서 동작 확인

fat-jar 실행 시에도 정상적으로 실행된다.

1
2
3
4
5
maven-fat-jar-test git:master 🍺🦑🍺🍕🍺 ❯ java -jar target/maven-fat-jar.jar
00:01:31.774 [main] INFO io.homo_efficio.ResourceLoader - OOO getResourceAsStream() 방식
00:01:31.775 [main] INFO io.homo_efficio.ResourceLoader - content root: /static
00:01:31.776 [main] INFO io.homo_efficio.ResourceLoader - resourceLocation: /folder1/sample1
00:01:31.778 [main] INFO io.homo_efficio.ResourceLoader - resource contents: Sample File 1

따라서 getResource() 보다는 getResourceAsStream()을 사용하자. 끝.

속사정

혹시 왜 이런 차이가 발생하는지 궁금한 사람들은 이어서 읽어보자.

getResourceAsStream() 호출을 따라가보면 Java 14 기준 BuiltinClassLoader 클래스에서 아래와 같은 코드를 만나게 되는데,

Imgur

openStream() 을 따라가면 왜 되는지 알 수 있다. openConnection()은 URLConnection 을 반환하는데, 이 URLConnection 에는 여러가지 SubClass가 있어서 다형적으로 동작할 수 있다.

Imgur

앞에서 IDE 에서 실행할 때는 URL 값이 file: 로 시작하고, jar 로 실행할 떄는 URL 값이 jar:file: 로 시작하는 것을 기억해두자고 한 것을 상기해보면 답이 보일 것이다.

URL 이 file: 로 시작하는 IDE 에서는 FileURLConnection 이 사용되고, URL 이 jar:file: 로 시작하는 jar 실행에서는 JarURLConnection 이 사용된다. getResourceAsStream()은 다형적으로 동작하도록 구현돼 있어서 두 상황 모두에서 잘 동작할 수 있다.

그럼 getResource()는?

사실 문제는 getResource() 가 아니라 getResource() 이 반환하는 URL 을 어떻게 쓰느냐에 있다. 똑같이 getResource() 를 사용하더라도 다음과 같이 openStream() 을 사용하면 getResource() 을 사용해도 jar 에서도 잘 동작한다.

즉, URL 에서 File 을 생성하면 다형성이 적용되지 않아 IDE 에서는 되지만 jar 에서는 안 되는 상황이 연출되고,
URL 에서 InputStream 을 뽑아서 사용하면 다형성이 적용돼서 IDE, jar 모두에서 잘 동작한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// ResourceLoader.java

public void loadResourceAsFile(String resourceLocation) throws IOException {
log.info("*** getResource() + File 방식");
log.info("content root: {}", rootPath);
log.info("resourceLocation: {}", resourceLocation);

URL resourceURL = this.getClass().getResource(root + resourceLocation);
log.info("resourceURL: {}", resourceURL);

// String fileLocation = resourceURL.getFile();
// log.info("fileLocation from URL: {}", fileLocation);
//
// File file = new File(fileLocation);
// FileReader fileReader = new FileReader(file);
// char[] chars = new char[(int) file.length()];
// fileReader.read(chars);
//
// log.info("resource contents: {}", new String(chars));

InputStream inputStream = resourceURL.openStream();
byte[] bytes = inputStream.readAllBytes();
log.info("resource contents: {}", new String(bytes, StandardCharsets.UTF_8));
}

Jackson

자바에서 JSON 처리에 널리 사용되는 Jackson 은 어떨까?

다음과 같이 URL 을 readValue() 메서드에 인자로 넘겨주는 방식으로 구현하면 IDE, jar 모두에서 잘 동작한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ResourceLoader.java

public void loadConfig(String resourceLocation) {
log.info("*** getResource() + Jackson 방식");
log.info("content root: {}", rootPath);
log.info("resourceLocation: {}", resourceLocation);

URL configURL = this.getClass().getResource(root + resourceLocation);
log.info("resourceURL: {}", configURL);

try {
ObjectMapper objectMapper = new ObjectMapper();
Config config = objectMapper.readValue(configURL, Config.class);
log.info("title in config: {}", config.getTitle());
log.info("tags in config: [{}]", String.join(", ", config.getTags()));
} catch (IOException e) {
throw new RuntimeException("설정 파일 로딩에 실패했습니다.", e);
}
}

IDE 실행 결과

1
2
3
4
5
6
12:09:01.465 [main] INFO io.homo_efficio.ResourceLoader - *** getResource() + Jackson 방식
12:09:01.465 [main] INFO io.homo_efficio.ResourceLoader - content root: /static
12:09:01.465 [main] INFO io.homo_efficio.ResourceLoader - resourceLocation: /folder1/config.json
12:09:01.466 [main] INFO io.homo_efficio.ResourceLoader - resourceURL: file:/Users/1003604/gitRepo/study/maven-fat-jar-test/target/classes/static/folder1/config.json
12:09:01.617 [main] INFO io.homo_efficio.ResourceLoader - title in config: Java Resource Handling
12:09:01.617 [main] INFO io.homo_efficio.ResourceLoader - tags in config: [Java, Resource, fat, jar]

jar 실행 결과

1
2
3
4
5
6
7
maven-fat-jar-test git:master 🍺🦑🍺🍕🍺 ❯ java -jar target/maven-fat-jar.jar
12:09:25.075 [main] INFO io.homo_efficio.ResourceLoader - *** getResource() + Jackson 방식
12:09:25.075 [main] INFO io.homo_efficio.ResourceLoader - content root: /static
12:09:25.075 [main] INFO io.homo_efficio.ResourceLoader - resourceLocation: /folder1/config.json
12:09:25.076 [main] INFO io.homo_efficio.ResourceLoader - resourceURL: jar:file:/Users/1003604/gitRepo/study/maven-fat-jar-test/target/maven-fat-jar.jar!/static/folder1/config.json
12:09:25.213 [main] INFO io.homo_efficio.ResourceLoader - title in config: Java Resource Handling
12:09:25.213 [main] INFO io.homo_efficio.ResourceLoader - tags in config: [Java, Resource, fat, jar]

Properties

.properties 파일을 읽을 때 사용하는 Properties 클래스에는 load(Reader r), load(InputStream i) 두 가지 메서드가 있다. IDE, jar 모두에서 동작하려면 어느 것을 써야할지 이젠 해보지 않아도 알 수 있을 것 같다.

디렉터리 내 파일 목록

개별 파일은 위와 같이 대응할 수 있다는 걸 알게 됐다. 그런데 디렉터리 내 파일 목록을 읽어서 원하는 대로 처리하는 것도 IDE, jar 에서 모두 가능할까?

이미 구구절절 많이 떠들었으니 바로 코드로 살펴보자. jar 파일 내에서 목록 단위로 처리하려면 JarFile이 필요하다는 점만 기억해두자. 나머지 주의해서 볼 점은 주석에 표시해놨다.

마지막에 나오는 코드 enumerationAsStream() 메서드는 Enumeration을 copy를 유발하지 않고 Stream 으로 쓸 수 있게 해주는 유틸 메서드다.(자바는 왜 이런 걸 공식 SDK에 포함하지 않는 건가..)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
public void loadDirectoryAsStream(String dir) throws IOException {
log.info("OOO getResourceAsStream() + Directory");
log.info("content root: {}", root);
log.info("dir: {}", dir);

// IDE 에서는 잘 동작, jar 에서는 에러는 발생하지 않으나 esourceAsStream.readAllBytes() 값이 비어있음
log.info("USING naive getResourceAsStream(String directory) -----");
InputStream resourceAsStream = this.getClass().getResourceAsStream(root + dir);
byte[] bytes = resourceAsStream.readAllBytes();
log.info("resource contents length: {}", bytes.length);

if (bytes.length > 0) {
log.info("resource contents: {}", new String(bytes, StandardCharsets.UTF_8));
} else {
// jar 에서는 잘 동작
// IDE 에서는 예외 발생: java.io.FileNotFoundException: /Users/1003604/gitRepo/study/maven-fat-jar-test/target/classes/io/homo_efficio (Is a directory)
// 따라서 bytes.length = 0 일 때만 실행하도록
log.info("USING JarFile -----");
String path = this.getClass().getResource("").getPath();
log.info("resourcePath: {}", path);

int exclamationIndex = path.lastIndexOf("!") > 0 ? path.lastIndexOf("!") : path.length();
String jarFilePath = path.substring(0, exclamationIndex).replaceAll("file:", "");
log.info("jarFilePath : {}", jarFilePath);

LocalDateTime start = LocalDateTime.now();
log.info("jarFile start: {}", start);
JarFile jarFile = new JarFile(jarFilePath);
LocalDateTime end = LocalDateTime.now();
log.info("jarFile end : {}", end);

enumerationAsStream(jarFile.entries())
.filter(entry -> entry.getRealName().startsWith((root + dir).substring(1)))
.forEach(entry -> log.info("jarEntry: {}", entry.getRealName()));
}
}

// From https://stackoverflow.com/a/23276455
static <T> Stream<T> enumerationAsStream(Enumeration<T> e) {
return StreamSupport.stream(
Spliterators.spliteratorUnknownSize(
new Iterator<T>() {
@Override
public boolean hasNext() {
return e.hasMoreElements();
}

@Override
public T next() {
return e.nextElement();
}
},
Spliterator.ORDERED
), false
);
}

크리에이티브 커먼즈 라이선스HomoEfficio가 작성한 이 저작물은(는) 크리에이티브 커먼즈 저작자표시-비영리-동일조건변경허락 4.0 국제 라이선스에 따라 이용할 수 있습니다.