로컬 개발 환경인 IntelliJ나 Eclipse에서는 정상적으로 동작하던 코드가 mvn package 혹은 gradle build를 통해 생성된 JAR 파일로 실행될 때, java.io.FileNotFoundException을 발생시키는 현상은 백엔드 엔지니어들이 흔히 겪는 배포 이슈 중 하나입니다. 이는 애플리케이션이 실행되는 파일 시스템의 컨텍스트가 변경되었기 때문입니다. 개발 환경에서는 운영체제의 파일 시스템에 직접 접근할 수 있지만, JAR로 패키징 된 이후에는 애플리케이션이 아카이브 파일 내부에서 구동되므로 물리적인 파일 경로(Path) 개념이 달라집니다. 본 글에서는 이러한 차이가 발생하는 근본적인 원인을 파일 시스템 프로토콜 관점에서 분석하고, 스프링 프레임워크가 제공하는 추상화 계층을 활용하여 환경에 구애받지 않는 견고한 리소스 로더를 구현하는 방법을 다룹니다.
1. 파일 시스템과 아카이브 내부의 차이
문제의 핵심은 자원이 위치한 물리적 구조의 변화에 있습니다. IDE에서 실행될 때 src/main/resources 디렉토리의 파일들은 컴파일 후 target/classes 혹은 build/resources/main과 같은 물리적 디렉토리로 복사됩니다. 이때는 운영체제의 표준 파일 입출력(Standard I/O)이 해당 경로를 인식할 수 있습니다.
그러나 JAR 파일로 빌드되면 상황이 달라집니다. JAR(Java Archive)는 본질적으로 ZIP 압축 파일입니다. 리소스 파일은 독립적인 파일이 아니라, JAR 파일이라는 하나의 거대한 파일 안에 포함된 바이트 스트림 엔트리(Entry)로 존재합니다. 따라서 java.io.File 객체를 사용하여 경로 기반으로 접근하려는 시도는 실패할 수밖에 없습니다.
로컬 환경의 리소스는
file:/Users/dev/project/build/resources/main/config.json 형태로 접근 가능하지만, JAR 내부의 리소스는 jar:file:/app/myapp.jar!/BOOT-INF/classes!/config.json과 같은 형태의 URI를 갖습니다. 느낌표(!) 이후의 경로는 파일 시스템 경로가 아닌 아카이브 내부의 논리적 주소입니다.
2. 안티 패턴: getFile() 메서드의 오용
가장 빈번하게 발생하는 오류 패턴은 스프링의 Resource 인터페이스를 사용하면서도 내부적으로 getFile()을 호출하는 경우입니다. 많은 개발자가 ClassPathResource를 통해 리소스를 획득한 뒤, 습관적으로 File 객체로 변환하려 합니다.
// [Anti-Pattern] JAR 환경에서 100% 실패하는 코드
public void readConfigFail() throws IOException {
Resource resource = new ClassPathResource("data/config.json");
// 로컬에서는 동작하지만 JAR 실행 시 FileNotFoundException 발생
// 원인: JAR 내부의 파일은 파일 시스템 상의 'File' 객체가 아님
File file = resource.getFile();
// 이후 로직 실행 불가
Files.readAllLines(file.toPath());
}
위 코드가 로컬에서 작동하는 이유는, 로컬 클래스패스가 실제 디렉토리를 가리키고 있기 때문입니다. 하지만 JAR 환경에서 resource.getFile()은 "class path resource [data/config.json] cannot be resolved to absolute file path because it does not reside in the file system: jar:..."라는 에러 메시지와 함께 예외를 던집니다. 이는 기술적 결함이 아니라, 자바의 파일 I/O 설계 원칙에 따른 정상적인 동작입니다.
Resource.getFile() 메서드는 절대로 사용해서는 안 됩니다. 이는 이식성(Portability)을 심각하게 저해합니다.
3. 해결책: InputStream 기반의 접근 전략
리소스 파일이 JAR 내부에 있든 외부에 있든, 가장 확실한 접근 방법은 스트림(Stream)을 사용하는 것입니다. 자바는 리소스를 바이트의 흐름으로 취급하는 InputStream을 통해 위치 투명성을 보장합니다. 스프링 프레임워크는 이를 더욱 쉽게 다룰 수 있는 도구들을 제공합니다.
3-1. ResourceLoader와 InputStream 활용
ResourceLoader를 사용하면 접두어(prefix)를 통해 리소스 위치를 명시적으로 지정할 수 있으며, getInputStream()을 통해 안전하게 내용을 읽을 수 있습니다. 다음은 이를 구현한 예제입니다.
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.util.FileCopyUtils;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
@Service
@RequiredArgsConstructor
public class ResourceService {
private final ResourceLoader resourceLoader;
public String readResourceFile() {
// classpath: 접두어를 사용하여 위치 지정
Resource resource = resourceLoader.getResource("classpath:data/config.json");
// getFile() 대신 getInputStream() 사용
try (InputStream inputStream = resource.getInputStream();
InputStreamReader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8)) {
// Spring의 유틸리티 클래스를 활용하여 문자열로 변환
return FileCopyUtils.copyToString(reader);
} catch (IOException e) {
throw new RuntimeException("Failed to read resource", e);
}
}
}
위 코드에서 FileCopyUtils는 스트림 처리를 단순화해 주는 스프링의 유틸리티입니다. 만약 Apache Commons IO를 사용 중이라면 IOUtils.toString()을 사용할 수도 있습니다. 핵심은 File 객체를 생성하지 않고 스트림을 직접 소비한다는 점입니다.
3-2. @Value 어노테이션을 통한 선언적 주입
파일 내용을 읽어오는 로직을 비즈니스 로직과 분리하고 싶다면, 스프링의 @Value 어노테이션을 사용하여 리소스 객체 자체를 필드에 주입받을 수 있습니다. 이는 설정과 구현을 분리하는 IoC(Inversion of Control) 관점에서 매우 유용합니다.
@Service
public class ConfigService {
// 리소스 객체 주입
@Value("classpath:data/templates/email-template.html")
private Resource emailTemplate;
public void printTemplateInfo() {
try {
// 존재 여부 확인
if (!emailTemplate.exists()) {
throw new IllegalStateException("Template file not found");
}
// 스트림으로 내용 읽기
String content = new String(emailTemplate.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
System.out.println(content);
} catch (IOException e) {
// 로깅 및 예외 처리
log.error("Error reading template", e);
}
}
}
@Value를 사용하여 컴파일 타임 혹은 애플리케이션 시작 시점에 리소스 위치를 확정 짓는 것이 유지보수에 유리합니다.
4. 비교 및 트레이드오프 분석
다양한 리소스 읽기 방식에는 각각의 장단점이 존재합니다. 상황에 맞는 적절한 전략을 선택해야 합니다.
| 접근 방식 | 장점 (Pros) | 단점 (Cons) | 권장 시나리오 |
|---|---|---|---|
File 객체 사용 |
익숙한 API, 랜덤 액세스 가능 | JAR 내부 접근 불가, 이식성 없음 | 로컬 임시 파일 처리 |
InputStream (ClassLoader) |
표준 Java API, 높은 이식성 | 보일러플레이트 코드 발생, 스트림 관리 필요 | 외부 라이브러리 의존성 최소화 시 |
ResourceLoader (Spring) |
추상화된 경로 처리 (classpath:, file: 등) | Spring 프레임워크 의존성 | 동적 경로 로딩 필요 시 |
@Value (Spring) |
코드가 간결함, 선언적 방식 | 컴파일 타임에 경로가 고정됨 | 설정 파일, 템플릿 로딩 |
5. 대용량 파일 및 바이너리 데이터 처리 시 주의사항
설정 파일이나 간단한 텍스트 템플릿은 String으로 한 번에 변환해도 메모리 부담이 적지만, 이미지나 PDF와 같은 바이너리 파일, 혹은 수십 MB 이상의 대용량 텍스트 파일을 다룰 때는 주의가 필요합니다.
readAllBytes()나 FileCopyUtils.copyToByteArray() 메서드는 파일 크기만큼의 힙 메모리를 즉시 할당합니다. 동시 접속자가 많은 상황에서 이러한 코드가 실행되면 OutOfMemoryError를 유발할 수 있습니다. 따라서 대용량 파일은 버퍼(Buffer)를 사용하여 청크(Chunk) 단위로 읽거나, 스트림 자체를 응답 객체(예: HttpServletResponse)로 바로 연결해야 합니다.
// 대용량 파일 다운로드 처리 예시 (메모리 효율적)
public void downloadFile(HttpServletResponse response) throws IOException {
Resource resource = resourceLoader.getResource("classpath:large-data.csv");
response.setContentType("text/csv");
response.setHeader("Content-Disposition", "attachment; filename=\"data.csv\"");
// InputStream을 Outputstream으로 직접 복사 (Zero-Copy와 유사한 효과)
try (InputStream is = resource.getInputStream();
OutputStream os = response.getOutputStream()) {
StreamUtils.copy(is, os); // Spring의 StreamUtils 활용
}
}
결론: 환경 독립적인 코드가 핵심
스프링 부트 애플리케이션에서 리소스를 다룰 때 가장 중요한 원칙은 "코드가 실행되는 환경에 의존하지 않아야 한다"는 것입니다. src/main/resources에 있는 파일은 빌드 프로세스를 거치며 아카이브의 일부가 된다는 사실을 인지해야 합니다. java.io.File에 대한 미련을 버리고 Resource 인터페이스와 InputStream을 활용하십시오. 이는 단순히 에러를 피하는 것을 넘어, 컨테이너 환경, 클라우드 배포, 그리고 다양한 OS 환경에서도 일관되게 동작하는 견고한 애플리케이션을 만드는 기초가 됩니다.
Post a Comment