Develop

[Spring Boot] 게시판 - 08. 파일 업로드 구현하기 (feat. 프로필 이미지 업로드) 본문

백엔드/게시판 만들기

[Spring Boot] 게시판 - 08. 파일 업로드 구현하기 (feat. 프로필 이미지 업로드)

230801 2025. 11. 28. 06:33

안녕하세요 .ᐟ 

오늘은 Spring Boot에서 파일 업로드를 어떻게 안전하게 구현하는지 알아보겠습니다.

파일 업로드는 프로필 이미지, 게시글 첨부파일 등 다양한 곳에서 사용되는 필수기능입니다.

 

 

구현할 기능:

  • 프로필 이미지 업로드 API
  • 파일 검증 (크기, 확장자, 보안)
  • UUID 기반 파일명 생성

 


 

1. 파일 업로드 설정

application.properties

# 파일 업로드 설정
spring.servlet.multipart.enabled=true
spring.servlet.multipart.max-file-size=10MB        # 개별 파일 최대 크기
spring.servlet.multipart.max-request-size=50MB     # 전체 요청 최대 크기
spring.servlet.multipart.file-size-threshold=2KB   # 메모리 임계값

# 파일 저장 경로
file.upload-dir=./uploads
file.profile-dir=${file.upload-dir}/profiles
file.post-dir=${file.upload-dir}/posts

 

설정 의미

  • max-file-size: 너무 큰 파일로 서버 과부하 방지
  • max-request-size: 여러 파일을 동시에 업로드할 때의 전체 크기 제한
  • file-size-threshold: 작은 파일은 메모리에서 처리하여 성능 향상
  • 상대 경로(./uploads): 프로젝트 루트 기준, 개발/배포 환경 모두 대응

 


 

2. 파일 저장 유틸리티 구현

 

FileStorageUtil.java

  • 파일 저장의 핵심 로직을 담당하는 유틸리티 클래스입니다.
  • UUID 파일명 생성
    • 파일명 충돌 방지
    • OS별 인코딩 이슈 해소 (한글 파일명)
    • 파일명으로 개인정보 노출 방지
String storedFilename = UUID.randomUUID().toString() + "." + fileExtension;
// 예: 550e8400-e29b-41d4-a716-446655440000.jpg

 

  • 화이트리스트 방식으로 확장자 검증 (허용된 이미지 형식만 업로드)
    • 실행 파일이나 스크립트 업로드 차단
private static final List<String> ALLOWED_EXTENSIONS = 
    Arrays.asList("jpg", "jpeg", "png", "gif", "webp", "heic");

 

  • 경로 traversal 공격 방어
    • ../../etc/passwd 같은 상위 디렉토리 접근 시도 차단
if (originalFilename.contains("..")) {
    throw new FileStorageException("파일명에 부적절한 경로가 포함되어 있습니다");
}

 

  • 파일 크기 제한
    • 서버 디스크 용량 보호
    • DoS(서비스 거부) 공격 방지
if (file.getSize() > MAX_FILE_SIZE) {
    throw new FileStorageException("파일 크기가 너무 큽니다");
}

 


 

3. 통일된 응답 구조

  • 모든 API가 일관된 응답 형식을 가지도록 래퍼 클래스를 만들었습니다.

 

ApiResponse.java

  • 일관성
    • 프론트엔드에서 항상 같은 구조로 응답 받음
    • API 명세서의 가독성이 높아짐
  • 타입 안정성
    • 컴파일 타임에 타입체크
    • 제네릭으로 타입 보장
  • 에러 처리 통일
    • 성공/실패를 success 필드의 boolean 으로 구분
    • 성공
{
  "success": true,
  "message": "프로필 이미지가 업로드되었습니다.",
  "data": {
    "id": 1,
    "nickname": "길동이",
    "profileImage": "550e8400-e29b-41d4-a716-446655440000.jpg"
  }
}

 

  • 실패
{
  "success": false,
  "message": "파일 크기가 너무 큽니다. 최대 10MB까지 업로드 가능합니다.",
  "data": null
}

 

4. 서비스 계층 구현

 

FileService.java

  • 단일 책임 원칙
    • FileService: 파일 관련 비즈니스 로직
    • FileStorageUtil: 실제 파일 I/O 처리
  • 추후 스토리지 변경 시 FileStorageUtil 만 교체
  • 테스트 시 Mock 으로 대체 가능
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class FileService {

    private final FileStorageUtil fileStorageUtil;

    public String uploadProfileImage(MultipartFile file) {
        return fileStorageUtil.storeProfileImage(file);
    }

    public String uploadPostImage(MultipartFile file) {
        return fileStorageUtil.storePostImage(file);
    }

    public void deleteFile(String filename, boolean isProfile) {
        fileStorageUtil.deleteFile(filename, isProfile);
    }
}

 

 

 

 


 

FileController.java (임시 파일 업로드)

  • 게시글 작성 시 에디터에서 이미지를 먼저 업로드 하게 함
    • 사용자가 에디터에 이미지 드래그앤드롭
    • POST /files/temp로 즉시 서버에 업로드 → 파일명 반환
    • 나중에 게시글 작성 시 파일명을 포함해서 전송
@RestController
@RequestMapping("/files")
@RequiredArgsConstructor
public class FileController {

    private final FileService fileService;

    @PostMapping("/temp")
    public ResponseEntity<ApiResponse<Map<String, String>>> uploadTempFile(
            @RequestParam("file") MultipartFile file) {
        
        String filename = fileService.uploadPostImage(file);
        
        Map<String, String> data = new HashMap<>();
        data.put("filename", filename);
        data.put("url", "/uploads/posts/" + filename);
        
        return ResponseEntity.ok(ApiResponse.success(
                data,
                "파일이 업로드되었습니다."
        ));
    }
}

 

 

 

File upload 확인

uploads/
├── profiles/    # 프로필 이미지
└── posts/       # 게시글 이미지

 

 

5. 테스트

HTTP Client Test

### 1. 프로필 이미지 업로드
POST http://localhost:8080/users/1/profile-image
Content-Type: multipart/form-data; boundary=boundary

--boundary
Content-Disposition: form-data; name="file"; filename="profile.png"
Content-Type: image/png

< /Users/cho/Pictures/profile.png
--boundary--

### 2. 사용자 조회 (이미지 확인)
GET http://localhost:8080/users/1

 

 

느낀 점

  • MultipartFile 처리 흐름: Spring이 multipart/form-data를 어떻게 파싱하는지 이해
  • 파일 보안의 중요성: 확장자 검증, 경로 traversal 방어 필수
  • UUID 활용: 파일명 충돌 방지 및 보안성 향상
  • static 메서드 패턴: Factory Pattern과 제네릭을 조합한 편의 메서드
  • ApiResponse 적용: 일관된 응답 구조로 프론트엔드 개발 편의성 증가