🏅오늘의 목표
- 회원 프로필 사진 등록 기능 구현
✅ 진행한 작업
- 회원 프로필 사진 등록 기능 구현
📃 개발내용
- 로컬에 프로필 사진을 저장할 수 있도록 구현하고 추후 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 반환
- 흐름
- 파일 검증
- 기존 파일 삭제 여부 판단
- 기존 파일 삭제
- 새 파일명 생성
- 파일 저장
- URL 반환
- 파일 저장
- Paths.get() : 문자열 경로를 Path 객체로 변환
- toAbsoultePath() : 상대 경로 → 절대 경로 변환
- Files.createDirectories() : 디렉토리를 재귀적으로 생성 , 중간 디렉토리 자동 생성
- resolve() : 두 경로를 결합하여 새로운 Path 생성
- InputStream in = file.getInputStream : 업로드된 파일 내용
- StandardCopyOption : 복사 옵션
- REPLACE_EXISTING : 파일이 존재하면 덮어쓰기
validateFile() - 파일 유효성 검증
- 업로드된 파일이 안전하고 유효한지 검증
- 흐름
- null 및 빈 파일인지 체크
- 파일 타입 검증
- null 인지, image/ 로 시작하는지 확인
- 파일 크기 제한
isDefaultImage() - 기본 이미지 여부 확인
- 이미지 URL 이 기본 프로필 이미지인지 확인
deleteFile() - 기존 파일 삭제
- 파일 시스템에서 기존 프로필 이미지를 삭제
- 흐름
- URL 에서 파일명 추출
- 절대 경로 생성
- 파일 존재 확인
- 파일 삭제
getFileExtension() - 파일 확장자 추출
- 원본 파일명에서 확장자만 추출
- 흐름
- null , 빈 문자열 체크
- 마지막 점(.) 위치 찾기
- 점이 없으면 .jpg 반환 : 확장자가 없어도 안전하게 처리
- 점 이후 문자열 추출
- 소문자로 변환
- 대소문자 혼용 문제 방지
- 확장자 반환
- 한글, 공백, 특수문자 문제 해결
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;
}
- 흐름
- 유저 찾기
- 기존 프로필 이미지 가져오기
- 새로운 이미지로 교체
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
'런닝 코스 공유 서비스' 카테고리의 다른 글
| [런닝 코스 공유 서비스] - 20. User 도메인 PR 후 리팩토링 (1) | 2026.01.15 |
|---|---|
| [런닝 코스 공유 서비스] - 19. 회원 탈퇴 (0) | 2026.01.15 |
| [런닝 코스 공유 서비스] - 17. Oauth 로그인 (1) | 2026.01.12 |
| [런닝 코스 공유 서비스] - 16. AccessToken 블랙리스트 (0) | 2026.01.11 |
| [런닝 코스 공유 서비스] - 15. Refresh Token (0) | 2026.01.11 |