[ 파일 업로드에서 경로 설정이 중요한 이유 ]
스프링부트에서 이미지 파일을 업로드할 때
가장 자주 겪는 문제가 있다.
- 내 컴퓨터에서는 업로드/미리보기가 된다.
- 팀원 컴퓨터에서는 업로드가
실패하거나 미리보기가 404가 난다. - 실행 방식(IDE 실행, jar 실행, war 배포)이
바뀌면 저장 위치가 달라진다.
원인은 대부분 '파일이 저장되는 실제 경로'와
'브라우저에서 접근하는 URL 매핑'이
환경마다 달라지기 때문이다.
특히 팀 프로젝트에서는
상대경로/절대경로 선택이 결과에 직접 영향을 준다.
1. 업로드 경로를 잡는 방법 2가지
1-1) 상대경로 방식(프로젝트 기준)
예시
- uploads/profile_temp
- uploads/anime
- uploads/newsThumb
이 방식은 '실행 기준 디렉토리' 아래에
uploads 폴더를 만들고 그 안에 파일을 저장한다.
장점
- 팀원이 프로젝트만 받아도 같은 구조로 동작하기 쉽다.
- Windows/Mac/Linux 등 OS에 덜 종속된다.
- 기본값으로 두기 좋다.
주의점
- 실제 저장 위치는 실행 방식에 따라 달라질 수 있다.
(working directory 영향) - 업로드 폴더를 깃에
실수로 올리지 않도록 관리가 필요하다.
1-2) 절대경로 방식(컴퓨터/서버 고정 경로)
예시
- D:/DoNotUse2/animaletest/uploads/profile_temp
- /var/app/uploads/profile_temp (운영 서버)
이 방식은 특정 디스크/폴더를 고정 저장소로 쓰는 방식이다.
장점
- 저장 위치가 항상 고정이라 관리/디버깅이 쉽다.
- 프로젝트 폴더와 업로드 파일을 분리할 수 있다.
- 운영 서버 구조(예: /var/app/uploads)에 맞추기 쉽다.
주의점
- 다른 PC에 D: 드라이브가 없거나 폴더가 없으면 바로 실패한다.
- OS가 바뀌면 경로 자체가 성립하지 않는다.
- 팀원마다 같은 절대경로를 강제하기가 어렵다
(권한/폴더 생성/드라이브 구성 차이).
2. 상대경로 vs 절대경로 장단점 비교
| 항목 | 상대경로(uploads/...) | 절대경로(D:/..., /var/...) |
| 팀원 PC에서 바로 실행 | 유리 | 불리 (PC마다 경로 다름) |
| OS 독립성 | 유리 | 불리 |
| 저장 위치 고정 | 불리 (실행 기준에 영향) |
유리 (항상 동일) |
| 운영 배포 적합성 | 케이스에 따라 다름 (컨테이너면 위험) |
유리 |
| 관리 난이도 | 낮음 (기본값으로 사용) |
중간~높음 (환경별 설정 필요) |
| 흔한 실패 | 저장 위치가 예상과 다름, URL 매핑 불일치 |
경로 없음, 권한 없음 |
3. 업로드 저장 경로와 URL 매핑(ResourceHandler)
이미지를 업로드한 뒤 브라우저에서 바로
보이게 하려면 다음 3개가 일치해야 한다.
- 저장 경로
(파일이 실제 저장되는 파일시스템 위치) - 접근 URL(/uploads/...)
- ResourceHandler 매핑
(스프링이 URL을 어떤 폴더로 연결하는지)
예를 들어 저장은 uploads/profile_temp에 했는데,
ResourceHandler가 D:/.../uploads만 바라보고 있으면
업로드는 성공해도 미리보기 URL은 404가 된다.
4. 추천 구조: 업로드 루트(root-dir) 1개로 통일하고 환경별로 오버라이드
팀 프로젝트에서 가장 덜 깨지는 방식은 이 구조다.
- 코드에는 특정 경로를 하드코딩하지 않는다.
- 업로드 루트 경로를 설정으로만 관리한다.
(app.upload.root-dir) - 기본값은 상대경로 uploads로 둔다.
- 개인 PC나 운영 서버는 설정으로
절대경로를 오버라이드한다.
5. 설정 및 코드 예시
5-1) application.properties (기본/공유)
# 업로드 루트(기본은 프로젝트 기준 상대경로)
app.upload.root-dir=uploads
# 파일 업로드 크기 제한(예시)
spring.servlet.multipart.max-file-size=5MB
spring.servlet.multipart.max-request-size=7MB
개인 PC에서만 절대경로를 쓰고 싶다면
깃에 올리지 않는 설정 파일로 분리
- application-local.properties
app.upload.root-dir=D:/DoNotUse2/animaletest/uploads
5-2) WebConfig (정적 접근 URL 매핑)
현재 /uploads/** 로 접근 가능하게 만들고,
실제 폴더는 app.upload.root-dir로 결정한다.
package fourcheetah.animale.web.config;
import java.nio.file.Path;
import java.nio.file.Paths;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Value("${app.upload.root-dir:uploads}")
private String uploadRootDir;
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
Path root = Paths.get(uploadRootDir).toAbsolutePath().normalize();
registry.addResourceHandler("/uploads/**")
.addResourceLocations(root.toUri().toString());
}
}
이렇게 하면 URL은 항상 /uploads/... 로 통일되고,
실제 폴더만 환경에 따라 바뀐다.
5-3) 업로드 저장 코드(프로필 임시 업로드 예시)
핵심 포인트
- uploadRootDir 기준으로
하위 폴더(profile_temp)를 만든다. - 저장 전에 Files.createDirectories로 폴더 없으면 생성
- 확장자를 유지한다.
(브라우저 표시/캐시/처리에 유리) - 반환 URL을 /uploads/...로 통일한다.
(ResourceHandler와 일치)
@Value("${app.upload.root-dir:uploads}")
private String uploadRootDir;
@PostMapping(value="/member/profile/upload", consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
public Map<String, Object> upload(@RequestParam("profileImageFile") MultipartFile file,
HttpSession session) {
Map<String, Object> res = new HashMap<>();
Integer memberId = (Integer) session.getAttribute("memberId");
if (memberId == null) {
res.put("result", "FAIL");
res.put("errorMessage", "로그인이 필요합니다.");
return res;
}
try {
if (file == null || file.isEmpty()) {
res.put("result", "FAIL");
res.put("errorMessage", "파일이 없습니다.");
return res;
}
String ct = file.getContentType();
if (ct == null || !ct.startsWith("image/")) {
res.put("result", "FAIL");
res.put("errorMessage", "이미지 파일만 업로드 가능합니다.");
return res;
}
String ext = switch (ct) {
case "image/jpeg" -> ".jpg";
case "image/png" -> ".png";
case "image/webp" -> ".webp";
default -> "";
};
if (ext.isEmpty()) {
res.put("result", "FAIL");
res.put("errorMessage", "지원하지 않는 이미지 형식입니다.");
return res;
}
Path dir = Paths.get(uploadRootDir, "profile_temp").toAbsolutePath().normalize();
Files.createDirectories(dir);
String token = UUID.randomUUID().toString().replace("-", "") + ext;
Path savePath = dir.resolve(token);
file.transferTo(savePath.toFile());
String url = "/uploads/profile_temp/" + token;
res.put("result", "SUCCESS");
res.put("temporaryProfileImageToken", token);
res.put("temporaryProfileImageUrl", url);
return res;
} catch (Exception e) {
res.put("result", "FAIL");
res.put("errorMessage", "업로드 중 오류가 발생했습니다.");
return res;
}
}
6. 운영 및 팀 프로젝트에서 자주 놓치는 체크리스트
1) uploads 폴더는 깃에 올리지 않는다
/uploads/
2) 개인 설정 파일도 깃에 올리지 않는다.
(로컬 절대경로가 들어갈 수 있음)
/src/main/resources/application-local.properties
3) 저장 경로가 실제로 어디인지
한 번은 로그로 확인한다. (상대경로 혼란 방지)
System.out.println(Paths.get(uploadRootDir).toAbsolutePath().normalize());
4) 임시 업로드(profile_temp)는 만료/삭제 정책이 필요하다
- 1일 지난 파일 정리 배치, 최종 저장 완료 후 임시 파일 삭제
5) 권한 문제 확인(운영 서버)
절대경로를 쓰면 해당 디렉토리에
쓰기 권한이 없어서 실패하는 경우가 많다.
[ 마무리 ]
- 상대경로는 팀 프로젝트에서
기본값으로 사용하기 좋다. - 절대경로는 운영 서버처럼
저장 위치 고정이 필요한 환경에서 유리하다. - 코드에 경로를 박지 않고, 설정으로만(app.upload.root-dir)
경로를 관리하면 환경에 따른 실패가 크게 줄어든다. - 저장 경로와 /uploads/** ResourceHandler 매핑이
일치해야 브라우저 미리보기 404를 피할 수 있다.
'개주 훈련일지 > 🏋️ 전집중 호흡 훈련' 카테고리의 다른 글
| Git) 팀 프로젝트 협업 루틴 만들기 (develop 브랜치 기반 체크리스트) (0) | 2026.02.10 |
|---|---|
| Postman 핵심 정리: API 테스트 기본부터 JWT 로그인 자동화, 디버깅까지 (0) | 2026.02.09 |
| Git) Eclipse에서 push로 GitHub에 공유하기 (0) | 2026.02.05 |
| Git) Eclipse에서 Git 연결 후 커밋까지 진행해보기 (0) | 2026.02.03 |
| AniMale 서블릿 프로젝트를 Spring Boot(War)로 전환하는 과정 정리 (0) | 2026.02.03 |