런닝 코스 공유 서비스

[런닝 코스 공유 서비스] - 18. 회원 프로필 업로드

sson-coding 2026. 1. 12. 23:50

🏅오늘의 목표

  • 회원 프로필 사진 등록 기능 구현

진행한 작업

  • 회원 프로필 사진 등록 기능 구현

📃 개발내용

  • 로컬에 프로필 사진을 저장할 수 있도록 구현하고 추후 AWS S3 스토리지에 저장되도록 구현할 예정

MultipartFile

  • SpringFramework 에서 HTTP 멀티파트 요청으로 전송된 파일을 표현하는 인터페이스
  • 파일 처리를 위한 다양한 메서드 제공
  • 메모리 또는 임시 디스크 공간에 파일 내용 저장

ProfileImageService

@Service
@RequiredArgsConstructor
@Slf4j
public class ProfileImageService {

    private static final String PROFILE_IMAGE_DIR = "uploads/profile-images";
    private static final String BASE_URL = "/images/profile-images/";
    private static final long MAX_FILE_SIZE = 5 * 1024 * 1024; //5MB

    @Value("${custom.user.default-profile-image}")
    private String defaultProfileImage;

    public String store(MultipartFile file, UUID userUuid, String oldImageUrl){
        // 파일 검증
        validateFile(file);

        // 기존 파일 삭제
        if(oldImageUrl != null && !oldImageUrl.isEmpty() && !isDefaultImage(oldImageUrl)) {
            deleteFile(oldImageUrl);
        }

        // 파일명 생성
        String extension = getFileExtension(file.getOriginalFilename());
        String fileName = userUuid + "_" + System.currentTimeMillis() + extension;

        // 파일 저장
        try{
            Path dirPath = Paths.get(PROFILE_IMAGE_DIR).toAbsolutePath();
            if(!Files.exists(dirPath)){
                Files.createDirectories(dirPath);
            }

            Path filePath = dirPath.resolve(fileName);
            try(InputStream in  = file.getInputStream()){
                Files.copy(in,filePath, StandardCopyOption.REPLACE_EXISTING);
            }
            log.info("프로필 이미지 저장 성공: userUuid={}, fileName={}", userUuid, fileName);
            return BASE_URL + fileName;
        }catch (IOException e){
            log.error("프로필 이미지 저장 실패: userUuid={}", userUuid, e);
            throw new BaseException(UserErrorCode.FILE_UPLOAD_FAILED);
        }
    }

    // 파일 유효성 검증
    private void validateFile(MultipartFile file) {
        // null 체크
        if (file == null || file.isEmpty()) {
            throw new BaseException(UserErrorCode.FILE_IS_EMPTY);
        }

        // 확장자 검사
        String contentType = file.getContentType();
        if (contentType == null || !contentType.startsWith("image/")) {
            throw new BaseException(UserErrorCode.UPLOAD_ONLY_IMAGE);
        }

        // 파일 크기 제한
        if (file.getSize() > MAX_FILE_SIZE) {
            throw new BaseException(UserErrorCode.UPLOAD_IMAGE_SIZE_UNDER_5MB);
        }
    }

    // 기존 이미지 여부 확인
    private boolean isDefaultImage(String imageUrl) {
        return defaultProfileImage.equals(imageUrl);
    }

    // 기존 파일 삭제
    private void deleteFile(String oldImageUrl) {
        try{
            String fileName = oldImageUrl.replace(BASE_URL,"");
            Path filePath = Paths.get(PROFILE_IMAGE_DIR).toAbsolutePath().resolve(fileName);

            if(Files.exists(filePath)){
                Files.delete(filePath);
                log.info("기존 프로필 이미지 삭제 성공: {}", fileName);
            }
        }catch (IOException e){
            log.warn("기존 프로필 이미지 삭제 실패: {}", oldImageUrl, e);
        }
    }

    // 파일 확장자 추출
    private String getFileExtension(String filename) {
        if (filename == null || filename.isEmpty()) {
            return ".jpg"; // 기본 확장자
        }

        int lastDotIndex = filename.lastIndexOf(".");
        if (lastDotIndex == -1) {
            return ".jpg";
        }

        return filename.substring(lastDotIndex).toLowerCase();
    }
}

ProfileImageService

  • 프로필 이미지 파일의 저장, 삭제, 검증을 담당
  • 파일 시스템에 직접 접근하여 이미지 관리
  • 주요 상수
    • PROFILE_IMAGE_DIR: 파일이 실제로 저장되는 디렉토리 경로
    • BASE_URL: 클라이언트가 이미지에 접근할 때 사용하는 URL 경로
    • MAX_FILE_SIZE: 업로드 가능한 최대 파일 크기 (5MB)
    • defaultProfileImage : 기본 프로필 이미지 경로

store()

  • 프로필 이미지를 파일 시스템에 저장하고 URL 반환
  • 흐름
    1. 파일 검증
    2. 기존 파일 삭제 여부 판단
    3. 기존 파일 삭제
    4. 새 파일명 생성
    5. 파일 저장
    6. URL 반환
  • 파일 저장
    • Paths.get() : 문자열 경로를 Path 객체로 변환
    • toAbsoultePath() : 상대 경로 → 절대 경로 변환
    • Files.createDirectories() : 디렉토리를 재귀적으로 생성 , 중간 디렉토리 자동 생성
    • resolve() : 두 경로를 결합하여 새로운 Path 생성
    • InputStream in = file.getInputStream : 업로드된 파일 내용
    • StandardCopyOption : 복사 옵션
      • REPLACE_EXISTING : 파일이 존재하면 덮어쓰기

validateFile() - 파일 유효성 검증

  • 업로드된 파일이 안전하고 유효한지 검증
  • 흐름
    1. null 및 빈 파일인지 체크
    2. 파일 타입 검증
      1. null 인지, image/ 로 시작하는지 확인
    3. 파일 크기 제한

isDefaultImage() - 기본 이미지 여부 확인

  • 이미지 URL 이 기본 프로필 이미지인지 확인

deleteFile() - 기존 파일 삭제

  • 파일 시스템에서 기존 프로필 이미지를 삭제
  • 흐름
    1. URL 에서 파일명 추출
    2. 절대 경로 생성
    3. 파일 존재 확인
    4. 파일 삭제

getFileExtension() - 파일 확장자 추출

  • 원본 파일명에서 확장자만 추출
  • 흐름
    1. null , 빈 문자열 체크
    2. 마지막 점(.) 위치 찾기
      1. 점이 없으면 .jpg 반환 : 확장자가 없어도 안전하게 처리
    3. 점 이후 문자열 추출
    4. 소문자로 변환
      1. 대소문자 혼용 문제 방지
    5. 확장자 반환
      1. 한글, 공백, 특수문자 문제 해결

application.yml

  • 기본 프로필 이미지를 지정하기 위한 설정
custom:
  user:
    default-profile-image: /images/profile-images/default.jpg

UserService - updateProfileImage 추가

// 프로필 이미지 업로드
    @Transactional
    public String updateProfileImage(UUID userUuid, MultipartFile file){
        User user = userRepository.findByUuid(userUuid)
            .orElseThrow(() -> new BaseException(UserErrorCode.USER_NOT_FOUND));

        String oldImageUrl = user.getProfile().getProfileUrl();
        String newImageUrl = profileImageService.store(file,userUuid,oldImageUrl);

        user.getProfile().updateProfileUrl(newImageUrl);

        return newImageUrl;
    }
  • 흐름
    1. 유저 찾기
    2. 기존 프로필 이미지 가져오기
    3. 새로운 이미지로 교체

UserController - uploadProfileImage 추가

// 프로필 업로드
    @PostMapping("/profile-image")
    public ResponseEntity<ApiResponse<String>> uploadProfileImage(
        @AuthenticationPrincipal CustomUserDetails customUserDetails,
        @RequestParam("file") MultipartFile file
    ){
        String imageUrl = userService.updateProfileImage(customUserDetails.getUserUuid(), file);
        return ResponseEntity.ok(new ApiResponse<>("프로필 이미지 업로드 성공", imageUrl));
    }
  • 인증된 사용자만 업로드 가능
  • 업로드 성공 시 이미지 접근 URL 반환

📝 테스트


참고자료

https://mangkyu.tistory.com/441

https://velog.io/@dani0817/Spring-%ED%8C%8C%EC%9D%BC%EC%97%85%EB%A1%9C%EB%93%9CMultipartFile

https://ddururiiiiiii.tistory.com/620