Friday, January 25, 2019

스프링 부트(Spring Boot) JAR 배포 환경에서의 파일 참조 완벽 가이드

개발 환경에서는 분명히 잘 동작하던 코드가 있습니다. IntelliJ나 Eclipse와 같은 IDE에서 애플리케이션을 실행하면 모든 것이 순조롭습니다. 특히, `src/main/resources` 디렉토리에 넣어둔 설정 파일이나 템플릿 파일은 아무 문제 없이 잘 읽어옵니다. 하지만 이 애플리케이션을 `mvn clean install`이나 `gradle build`를 통해 실행 가능한 JAR 파일로 빌드하고, `java -jar my-app.jar` 명령어로 서버에 배포하는 순간, 예상치 못한 `FileNotFoundException`이 개발자를 맞이합니다. 많은 개발자들이 한 번쯤은 겪어봤을 이 당혹스러운 상황의 원인은 무엇이며, 어떻게 해결해야 할까요?

이 문제는 로컬 개발 환경과 실제 배포 환경의 근본적인 차이점에서 비롯됩니다. 개발 환경에서 소스 코드는 일반적인 파일 시스템의 디렉토리 구조 안에 존재하지만, JAR 파일로 패키징되는 순간 모든 리소스 파일들은 하나의 압축된 아카이브 파일 내부에 포함됩니다. 즉, 더 이상 운영체제가 인식하는 개별 '파일'이 아닌, 아카이브 내부의 '항목(Entry)'으로 존재하게 됩니다. 이 미묘하지만 결정적인 차이를 이해하지 못하면, 배포 환경에서 파일을 안정적으로 읽어오는 코드를 작성하기 어렵습니다. 이 글에서는 이 문제의 근원을 깊이 파고들어, 스프링 부트 환경에서 어떠한 상황에서도 리소스 파일을 안정적으로 읽어오는 최선의 방법과 실용적인 예시들을 심도 있게 다룰 것입니다.

문제의 근원: 파일 시스템 경로와 클래스패스 경로의 차이

스프링 부트 애플리케이션에서 파일을 다룰 때, 우리는 두 가지 주요 개념인 '파일 시스템 경로'와 '클래스패스 경로'를 명확히 구분해야 합니다. 이 둘의 차이를 이해하는 것이 문제 해결의 첫걸음입니다.

1. 파일 시스템(File System) 접근 방식과 그 한계

Java의 표준 라이브러리인 `java.io.File` 객체는 운영체제의 파일 시스템에 존재하는 파일이나 디렉토리를 가리키는 데 사용됩니다. 예를 들어, 다음과 같은 코드를 생각해 봅시다.


import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.List;

// IDE(개발 환경)에서는 동작하지만 JAR 환경에서는 실패하는 코드
public void readFileWithFileSystemPath() {
    try {
        // 상대 경로는 프로젝트 루트를 기준으로 함
        File file = new File("src/main/resources/data/my-data.txt");
        System.out.println("Absolute Path: " + file.getAbsolutePath());
        List<String> lines = Files.readAllLines(file.toPath());
        lines.forEach(System.out::println);
    } catch (IOException e) {
        System.err.println("파일을 찾는 데 실패했습니다: " + e.getMessage());
        // JAR 환경에서 이 예외가 발생할 가능성이 매우 높습니다.
    }
}

IDE에서 이 코드를 실행하면 `src/main/resources/data/my-data.txt` 파일이 물리적으로 디스크에 존재하므로 정상적으로 절대 경로를 출력하고 파일 내용을 읽어옵니다. 하지만 이 프로젝트를 JAR 파일로 빌드하면 상황이 완전히 달라집니다. 빌드된 JAR 파일 내부에는 `src/main/resources` 라는 디렉토리 구조가 더 이상 존재하지 않습니다. 대신 `my-data.txt` 파일은 JAR 아카이브 내부의 `BOOT-INF/classes/data/my-data.txt` 와 같은 경로에 압축된 상태로 포함됩니다. 따라서 `java -jar` 명령어로 실행된 애플리케이션이 `new File(...)`을 통해 운영체제에게 "현재 위치에 `src`라는 폴더가 있니?"라고 물으면, 운영체제는 "아니, 그런 폴더는 없어"라고 답하며 `FileNotFoundException`을 던지게 됩니다. 이것이 가장 흔하게 발생하는 오류의 원인입니다.

2. 스프링의 강력한 추상화: Resource 인터페이스

스프링 프레임워크는 이러한 파일 시스템 종속성 문제를 해결하기 위해 강력한 추상화 계층인 `org.springframework.core.io.Resource` 인터페이스를 제공합니다. `Resource`는 파일 시스템의 파일, 클래스패스 상의 리소스, URL, 바이트 배열 등 다양한 종류의 저수준 리소스에 대한 일관된 접근 방법을 제공하는 매우 중요한 개념입니다. 즉, 리소스가 물리적인 파일이든, JAR 파일 내부에 있든, 심지어 원격 웹 서버에 있든 상관없이 동일한 방식으로 다룰 수 있게 해줍니다.

스프링은 여러 `Resource` 구현체를 제공하며, 대표적인 것은 다음과 같습니다.

  • ClassPathResource: 클래스패스를 기준으로 리소스를 찾습니다. JAR 환경에서 내부 파일을 읽는 핵심적인 해결책입니다.
  • FileSystemResource: 파일 시스템 경로를 통해 파일에 접근합니다. `java.io.File`을 사용하는 것과 유사하지만 스프링의 `Resource` 추상화에 통합됩니다.
  • UrlResource: URL(http, ftp, file 등)을 통해 리소스에 접근합니다.
  • ServletContextResource: 웹 애플리케이션 환경에서 `ServletContext`를 기준으로 리소스를 찾습니다. (WAR 배포 시 사용)

이 중 JAR 환경 문제를 해결하는 열쇠는 바로 `ClassPathResource`에 있습니다.

잘못된 접근법: `ResourceUtils.getFile`의 함정

간혹 구글링이나 오래된 자료를 통해 `org.springframework.util.ResourceUtils` 클래스를 사용하는 해결책을 접할 수 있습니다. 특히 `ResourceUtils.getFile("classpath:...")`와 같은 코드가 널리 퍼져있습니다. 이 방법은 개발 환경에서는 마법처럼 동작하기 때문에 많은 개발자들이 선호하지만, 이는 사실상 가장 피해야 할 함정 중 하나입니다.


import org.springframework.util.ResourceUtils;
import java.io.File;
import java.io.FileNotFoundException;

// JAR 배포 시 반드시 실패하므로 절대 사용해서는 안 되는 코드
public void readFileWithResourceUtils() {
    try {
        // 이 코드는 클래스패스 리소스를 실제 파일 시스템의 파일로 변환하려고 시도합니다.
        File file = ResourceUtils.getFile("classpath:config/app-config.json");
        // ... 파일 처리 로직 ...
    } catch (FileNotFoundException e) {
        // JAR 내부의 리소스는 파일 시스템에 실제 파일로 존재하지 않으므로 여기서 예외가 발생합니다.
        System.err.println("JAR 환경에서는 이 방법으로 파일을 찾을 수 없습니다: " + e.getMessage());
    }
}

왜 이 코드는 실패할까요? `ResourceUtils.getFile()` 메소드의 내부 동작을 들여다보면 명확해집니다. 이 메소드의 목적은 주어진 리소스 위치(예: `classpath:`)를 실제 `java.io.File` 객체로 변환하는 것입니다. 개발 환경에서는 `src/main/resources` 디렉토리가 `target/classes`와 같은 빌드 출력 디렉토리로 복사되어 클래스패스에 포함되고, 이 경로는 실제 파일 시스템 경로이므로 변환이 성공합니다.

하지만 JAR 파일 내부의 리소스는 앞서 설명했듯이 압축된 아카이브의 일부일 뿐, 독립적인 파일이 아닙니다. 따라서 `ResourceUtils.getFile()`은 JAR 내부의 리소스에 대한 파일 시스템 경로를 찾지 못하고 `FileNotFoundException: ... cannot be resolved to absolute file path because it does not reside in the file system` 과 유사한 메시지와 함께 예외를 던지게 됩니다. 결론적으로, 배포 환경의 호환성을 고려한다면 `ResourceUtils.getFile("classpath:...")`는 절대로 사용해서는 안 됩니다.

올바른 해결책: `Resource`와 스트림(Stream)을 활용한 접근법

그렇다면 올바른 방법은 무엇일까요? 해답은 리소스를 `File` 객체로 변환하려 하지 않고, 리소스의 내용물에 직접 접근할 수 있는 `InputStream`을 얻어와 사용하는 것입니다. 이는 스프링의 `Resource` 인터페이스가 제공하는 가장 중요한 기능 중 하나입니다.

해결책 1: `ClassPathResource` 직접 사용하기

가장 기본적인 방법은 `ClassPathResource` 객체를 직접 생성하는 것입니다. 이때 경로는 `src/main/resources`를 루트(`'/'`)로 간주하는 클래스패스 상대 경로를 사용합니다.


import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.stream.Collectors;

// 개발 환경과 JAR 환경 모두에서 완벽하게 동작하는 가장 표준적인 방법
public void readClasspathResource() {
    // 'src/main/resources/' 폴더 하위의 'data/my-data.txt' 파일을 가리킴
    Resource resource = new ClassPathResource("data/my-data.txt");

    try (InputStream inputStream = resource.getInputStream()) {
        // InputStream을 직접 다루기 때문에 리소스가 파일 시스템에 있든, JAR 내부에 있든 상관없음
        BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
        String content = reader.lines().collect(Collectors.joining("\n"));
        System.out.println("성공적으로 파일을 읽었습니다. 내용:");
        System.out.println(content);

    } catch (IOException e) {
        System.err.println("리소스를 읽는 중 오류가 발생했습니다: " + e.getMessage());
    }
}

이 코드의 핵심은 `resource.getInputStream()`입니다. 이 메소드는 리소스가 어디에 있든지 간에 해당 리소스의 내용을 바이트 단위로 읽어올 수 있는 통로(`InputStream`)를 열어줍니다. JAR 파일 내부에 있더라도 `JarURLConnection` 등을 통해 내부적으로 스트림을 가져오기 때문에 개발자는 리소스의 물리적 위치를 전혀 신경 쓸 필요가 없습니다. 얻어온 `InputStream`을 `BufferedReader`나 Jackson의 `ObjectMapper` 같은 라이브러리에 전달하여 원하는 작업을 수행하면 됩니다.

해결책 2: `ResourceLoader`를 이용한 스프링 방식의 접근

`ClassPathResource`를 직접 사용하는 것도 좋지만, 스프링 컨테이너가 관리하는 빈(Bean)에서는 더 '스프링스러운' 방법이 있습니다. 바로 `ResourceLoader`를 사용하는 것입니다. 모든 스프링의 `ApplicationContext`는 `ResourceLoader` 인터페이스를 구현하므로, 어떤 빈에서든 `@Autowired`를 통해 주입받을 수 있습니다.


import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import org.springframework.util.FileCopyUtils;

@Service
public class ResourceReaderService {

    @Autowired
    private ResourceLoader resourceLoader;

    @PostConstruct // 빈이 생성되고 의존성 주입이 완료된 후 실행
    public void init() {
        // resourceLoader.getResource()는 접두사를 사용하여 리소스 유형을 동적으로 결정함
        // "classpath:" 접두사는 ClassPathResource를 사용하도록 지시함
        Resource resource = resourceLoader.getResource("classpath:config/app-config.json");

        try (InputStream inputStream = resource.getInputStream()) {
            byte[] bdata = FileCopyUtils.copyToByteArray(inputStream);
            String data = new String(bdata, StandardCharsets.UTF_8);
            System.out.println("ResourceLoader를 통해 읽은 설정 파일 내용:");
            System.out.println(data);
        } catch (IOException e) {
            System.err.println("ResourceLoader를 사용하여 리소스를 읽는 데 실패했습니다.");
            // 실제 애플리케이션에서는 로깅 및 예외 처리를 해야 함
            throw new RuntimeException("초기 설정 파일을 읽지 못했습니다.", e);
        }
    }
}

`ResourceLoader` 사용의 장점은 다음과 같습니다.

  • 유연성: `getResource()` 메소드에 전달하는 문자열 경로에 `classpath:`, `file:`, `http:`와 같은 접두사(prefix)를 붙여 리소스의 위치를 동적으로 지정할 수 있습니다. 예를 들어, 외부 설정 파일을 사용해야 할 경우 코드 수정 없이 경로만 `file:/etc/config/app-config.json`과 같이 변경하면 됩니다. 이는 애플리케이션의 설정 유연성을 크게 향상시킵니다.
  • 의존성 주입 활용: 스프링의 DI(Dependency Injection) 컨테이너를 활용하므로 코드가 더 깔끔해지고 테스트하기 용이해집니다. 테스트 코드에서는 `MockResourceLoader` 등을 주입하여 리소스 로딩 로직을 쉽게 모의(mock)할 수 있습니다.
  • 일관성: 애플리케이션 전반에 걸쳐 리소스를 로드하는 방식을 일관되게 유지할 수 있습니다.

실전 활용 시나리오별 예제 코드

이제 이론을 바탕으로 실제 프로젝트에서 마주할 수 있는 다양한 시나리오에 대한 해결책을 구체적인 코드로 살펴보겠습니다.

시나리오 1: Firebase Admin SDK 초기화를 위한 비공개 키 파일 읽기

Firebase, Google Cloud Platform(GCP) 등 외부 서비스 연동 시 필요한 인증용 JSON 키 파일은 보통 `src/main/resources`에 포함시켜 함께 배포합니다. 이 파일은 보안상 민감하므로 외부 노출을 최소화해야 하며, JAR 내부에 안전하게 포함시키는 것이 일반적입니다.


import com.google.auth.oauth2.GoogleCredentials;
import com.google.firebase.FirebaseApp;
import com.google.firebase.FirebaseOptions;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import javax.annotation.PostConstruct;
import java.io.IOException;
import java.io.InputStream;

@Configuration
public class FirebaseConfig {

    // firebase-adminsdk.json 파일이 src/main/resources/firebase/ 하위에 있다고 가정
    private static final String FIREBASE_CONFIG_PATH = "firebase/firebase-adminsdk.json";

    @PostConstruct
    public void initializeFirebase() {
        try {
            // ClassPathResource를 사용하여 JAR 내부의 리소스에 대한 InputStream을 가져온다.
            ClassPathResource resource = new ClassPathResource(FIREBASE_CONFIG_PATH);
            InputStream serviceAccount = resource.getInputStream();

            FirebaseOptions options = new FirebaseOptions.Builder()
                    .setCredentials(GoogleCredentials.fromStream(serviceAccount))
                    .build();

            // FirebaseApp이 이미 초기화되지 않았을 경우에만 초기화
            if (FirebaseApp.getApps().isEmpty()) {
                FirebaseApp.initializeApp(options);
                System.out.println("Firebase Admin SDK가 성공적으로 초기화되었습니다.");
            }

        } catch (IOException e) {
            System.err.println("Firebase 설정 파일 로딩에 실패했습니다.");
            throw new RuntimeException("Firebase 초기화 오류", e);
        }
    }
}

이 코드는 `GoogleCredentials.fromStream()` 메소드가 `InputStream`을 직접 인자로 받는다는 점을 완벽하게 활용합니다. 파일을 디스크에 임시로 저장하는 과정 없이 메모리 상에서 바로 스트림을 전달하므로 효율적이고 안전합니다. `ResourceUtils.getFile()`을 사용했다면 JAR 배포 환경에서 100% 실패했을 코드입니다.

시나리오 2: 클래스패스의 YAML/JSON 설정 파일을 객체로 매핑하기

`application.yml` 외에 비즈니스 로직에 필요한 별도의 설정 파일을 두고 이를 자바 객체(POJO)로 변환하여 사용하는 경우는 매우 흔합니다. Jackson 라이브러리와 `ResourceLoader`를 함께 사용하면 이 작업을 매우 우아하게 처리할 수 있습니다.

먼저 `src/main/resources/custom-config/mail-templates.yml` 파일이 있다고 가정해봅시다.


welcome:
  subject: "저희 서비스에 오신 것을 환영합니다!"
  templatePath: "templates/mail/welcome.html"
passwordReset:
  subject: "비밀번호 재설정 안내입니다."
  templatePath: "templates/mail/reset.html"

이제 이 YAML 파일을 읽어와 객체로 변환하는 서비스 코드를 작성해 보겠습니다. `spring-boot-starter-web` 의존성이 있다면 Jackson이 포함되어 있고, YAML 파싱을 위해 `jackson-dataformat-yaml` 의존성을 추가해야 합니다.


import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.io.InputStream;
import java.util.Map;

@Component
public class MailTemplateManager {

    private final ResourceLoader resourceLoader;
    private Map<String, MailTemplateInfo> templateConfig;

    // 생성자 주입을 통한 ResourceLoader 의존성 주입
    @Autowired
    public MailTemplateManager(ResourceLoader resourceLoader) {
        this.resourceLoader = resourceLoader;
        loadTemplates();
    }

    private void loadTemplates() {
        Resource resource = resourceLoader.getResource("classpath:custom-config/mail-templates.yml");
        // YAML 파싱을 위한 ObjectMapper 생성
        ObjectMapper objectMapper = new ObjectMapper(new YAMLFactory());

        try (InputStream inputStream = resource.getInputStream()) {
            // InputStream으로부터 직접 Map 객체로 변환
            this.templateConfig = objectMapper.readValue(inputStream,
                    objectMapper.getTypeFactory().constructMapType(Map.class, String.class, MailTemplateInfo.class));
            System.out.println("메일 템플릿 설정을 성공적으로 로드했습니다.");
        } catch (IOException e) {
            throw new IllegalStateException("메일 템플릿 설정을 로드할 수 없습니다.", e);
        }
    }

    public MailTemplateInfo getTemplateInfo(String key) {
        return templateConfig.get(key);
    }

    // YAML 파일 구조와 매핑될 DTO
    public static class MailTemplateInfo {
        private String subject;
        private String templatePath;

        // Getters and Setters
        public String getSubject() { return subject; }
        public void setSubject(String subject) { this.subject = subject; }
        public String getTemplatePath() { return templatePath; }
        public void setTemplatePath(String templatePath) { this.templatePath = templatePath; }
    }
}

시나리오 3: `@Value` 어노테이션을 활용한 가장 간결한 방법

간단히 리소스 자체를 빈의 필드로 주입받고 싶다면, 스프링의 `@Value` 어노테이션을 사용하는 것이 가장 간결합니다. 스프링 컨테이너는 `classpath:` 접두사를 해석하여 적절한 `Resource` 객체를 자동으로 주입해 줍니다.


import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Component;
import org.springframework.util.StreamUtils;

import javax.annotation.PostConstruct;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;

@Component
public class SqlScriptLoader {

    // @Value 어노테이션으로 클래스패스 리소스를 직접 주입받음
    @Value("classpath:db/migration/V1__initial_schema.sql")
    private Resource initialSchemaSql;

    private String sqlScript;

    @PostConstruct
    public void loadSql() throws IOException {
        try (InputStream inputStream = initialSchemaSql.getInputStream()) {
            // StreamUtils는 InputStream을 다루는 편리한 유틸리티
            this.sqlScript = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
            System.out.println("SQL 스크립트 로드 완료.");
            // System.out.println(sqlScript); // 필요시 내용 출력
        }
    }
    
    public String getSqlScript() {
        return this.sqlScript;
    }
}

이 방법은 `ResourceLoader`를 직접 주입받는 것보다 코드가 훨씬 간결해지는 장점이 있습니다. 내부적으로는 스프링의 `ResourceEditor`가 `@Value`의 문자열을 `Resource` 객체로 변환해주는 원리이며, 이는 `ResourceLoader`를 사용하는 것과 동일한 효과를 냅니다.

결론: 안정적인 리소스 관리를 위한 핵심 원칙

스프링 부트 애플리케이션을 개발하고 배포할 때, 리소스 파일 관련 오류는 매우 흔하지만 그 원인과 해결책은 명확합니다. 안정적이고 이식성 높은 애플리케이션을 만들기 위해 다음의 핵심 원칙들을 반드시 기억해야 합니다.

  1. java.io.File을 사용하지 마라: 클래스패스 내부에 있는 리소스에 접근할 때 `new File()`과 같은 파일 시스템 경로 기반의 API는 JAR 배포 환경에서 실패의 주범입니다.
  2. ResourceUtils.getFile()을 피하라: 이 유틸리티는 리소스를 파일 시스템의 `File` 객체로 변환하려는 시도를 하므로, JAR 내부의 리소스에 대해서는 `FileNotFoundException`을 유발합니다.
  3. 스프링 Resource 추상화를 적극 활용하라: ClassPathResource, `ResourceLoader`, `@Value("classpath:...")`는 개발 환경과 배포 환경 모두에서 일관되게 동작하는 표준적이고 올바른 방법입니다.
  4. InputStream으로 작업하라: resource.getInputStream()을 통해 리소스의 내용물에 직접 접근하는 것이 핵심입니다. 파일을 객체로 변환하지 않고 스트림 자체를 처리함으로써 메모리 효율성과 호환성을 모두 확보할 수 있습니다.

"내 컴퓨터에서는 잘 됐는데..."라는 말은 더 이상 프로페셔널한 개발자의 변명이 될 수 없습니다. 개발 환경(IDE)과 배포 환경(JAR)의 차이점을 명확히 인지하고, 스프링이 제공하는 강력하고 유연한 `Resource` 추상화 체계를 올바르게 활용하는 습관을 들인다면, 파일 경로 문제로 인한 예기치 못한 야근을 피하고 견고한 애플리케이션을 구축할 수 있을 것입니다.


0 개의 댓글:

Post a Comment