Skip to content

Commit eda0e91

Browse files
authored
[feat] 프로필 이미지 업로드 구현 (#377)
* [feat] 프로필 이미지 업로드 구현 * [fix] 프로필 이미지 삭제 오류 해결
1 parent 340ef3c commit eda0e91

File tree

4 files changed

+215
-0
lines changed

4 files changed

+215
-0
lines changed

src/main/java/com/back/domain/user/controller/UserController.java

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@
1111
import jakarta.validation.Valid;
1212
import lombok.RequiredArgsConstructor;
1313
import lombok.extern.slf4j.Slf4j;
14+
import org.springframework.http.MediaType;
1415
import org.springframework.http.ResponseEntity;
1516
import org.springframework.security.core.annotation.AuthenticationPrincipal;
1617
import org.springframework.web.bind.annotation.*;
18+
import org.springframework.web.multipart.MultipartFile;
1719

1820
@Slf4j
1921
@RestController
@@ -114,4 +116,52 @@ public ResponseEntity<RsData<UserProfileResponse>> getUserPublicProfile(
114116
);
115117
}
116118

119+
/**
120+
* 프로필 이미지 업로드 및 변경
121+
*/
122+
@PostMapping(value = "/me/profile-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
123+
@Operation(
124+
summary = "프로필 이미지 업로드 및 변경",
125+
description = "프로필 이미지를 S3에 업로드하고 사용자 정보를 자동으로 업데이트합니다. " +
126+
"MAIN 타입으로 업로드되며, 썸네일이 자동 생성됩니다."
127+
)
128+
public ResponseEntity<RsData<UserProfileResponse>> uploadProfileImage(
129+
@AuthenticationPrincipal CustomUserDetails userDetails,
130+
@RequestPart MultipartFile file) {
131+
132+
log.info("프로필 이미지 업로드 - userId: {}, filename: {}",
133+
userDetails.getUserId(), file.getOriginalFilename());
134+
135+
UserProfileResponse response = userService.uploadAndUpdateProfileImage(
136+
userDetails.getUserId(),
137+
file
138+
);
139+
140+
return ResponseEntity.ok(
141+
RsData.of("200", "프로필 이미지 업로드 및 변경 성공", response)
142+
);
143+
}
144+
145+
/**
146+
* 프로필 이미지 삭제
147+
*/
148+
@DeleteMapping("/me/profile-image")
149+
@Operation(
150+
summary = "프로필 이미지 삭제",
151+
description = "현재 설정된 프로필 이미지를 삭제하고 기본 이미지로 되돌립니다. S3에서도 이미지가 삭제됩니다."
152+
)
153+
public ResponseEntity<RsData<UserProfileResponse>> deleteProfileImage(
154+
@AuthenticationPrincipal CustomUserDetails userDetails) {
155+
156+
log.info("프로필 이미지 삭제 - userId: {}", userDetails.getUserId());
157+
158+
UserProfileResponse response = userService.deleteProfileImage(
159+
userDetails.getUserId()
160+
);
161+
162+
return ResponseEntity.ok(
163+
RsData.of("200", "프로필 이미지 삭제 성공", response)
164+
);
165+
}
166+
117167
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.back.domain.user.dto.request;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
5+
public record UpdateProfileImageRequest(
6+
@NotBlank(message = "프로필 이미지 URL은 필수입니다.")
7+
String profileImageUrl
8+
) {
9+
}

src/main/java/com/back/domain/user/entity/User.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,20 @@ public void updateProfile(String name, String phone, String address,
298298
}
299299
}
300300

301+
/**
302+
* 프로필 이미지 설정
303+
*/
304+
public void setProfileImage(String profileImageUrl) {
305+
this.profileImageUrl = profileImageUrl;
306+
}
307+
308+
/**
309+
* 프로필 이미지 삭제 (null로 설정)
310+
*/
311+
public void deleteProfileImage() {
312+
this.profileImageUrl = null;
313+
}
314+
301315
/**
302316
* 비밀번호 변경
303317
*/

src/main/java/com/back/domain/user/service/UserService.java

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,17 @@
88
import com.back.domain.user.entity.User;
99
import com.back.domain.user.repository.UserRepository;
1010
import com.back.global.exception.ServiceException;
11+
import com.back.global.s3.FileType;
12+
import com.back.global.s3.S3Service;
13+
import com.back.global.s3.UploadResultResponse;
1114
import lombok.RequiredArgsConstructor;
1215
import lombok.extern.slf4j.Slf4j;
1316
import org.springframework.security.crypto.password.PasswordEncoder;
1417
import org.springframework.stereotype.Service;
1518
import org.springframework.transaction.annotation.Transactional;
19+
import org.springframework.web.multipart.MultipartFile;
20+
21+
import java.util.List;
1622

1723
/**
1824
* 사용자 관리 서비스
@@ -25,6 +31,7 @@ public class UserService {
2531

2632
private final UserRepository userRepository;
2733
private final PasswordEncoder passwordEncoder;
34+
private final S3Service s3Service;
2835

2936
/**
3037
* 사용자 조회
@@ -291,4 +298,139 @@ public void deleteUser(Long userId) {
291298
log.info("계정 삭제: userId={}", userId);
292299
}
293300

301+
/**
302+
* 프로필 이미지 업로드 및 변경
303+
*/
304+
@Transactional
305+
public UserProfileResponse uploadAndUpdateProfileImage(Long userId, MultipartFile file) {
306+
User user = getUserById(userId);
307+
308+
// 1. 파일 검증 (크기 및 형식)
309+
validateImageFile(file);
310+
311+
// 2. 기존 프로필 이미지 삭제 (S3에서)
312+
deleteOldProfileImage(user);
313+
314+
// 3. S3에 새 이미지 업로드 (MAIN 타입 - 썸네일 자동 생성됨)
315+
List<UploadResultResponse> uploadResults = s3Service.uploadFiles(
316+
List.of(file),
317+
"profile-images",
318+
List.of(FileType.MAIN)
319+
);
320+
321+
if (uploadResults.isEmpty()) {
322+
throw new ServiceException("500", "이미지 업로드에 실패했습니다.");
323+
}
324+
325+
// 4. MAIN 타입의 이미지 URL 추출
326+
String profileImageUrl = uploadResults.stream()
327+
.filter(result -> result.type() == FileType.MAIN)
328+
.findFirst()
329+
.map(UploadResultResponse::url)
330+
.orElseThrow(() -> new ServiceException("500", "업로드된 이미지를 찾을 수 없습니다."));
331+
332+
// 5. 프로필 이미지 URL 업데이트
333+
user.setProfileImage(profileImageUrl);
334+
335+
log.info("프로필 이미지 업로드 및 변경 완료 - userId: {}, imageUrl: {}",
336+
userId, profileImageUrl);
337+
338+
return UserProfileResponse.from(user);
339+
}
340+
341+
/**
342+
* 기존 프로필 이미지 삭제 (S3)
343+
*/
344+
private void deleteOldProfileImage(User user) {
345+
if (user.getProfileImageUrl() == null || user.getProfileImageUrl().isBlank()) {
346+
return;
347+
}
348+
349+
try {
350+
// URL에서 S3 Key 추출하여 삭제
351+
String oldKey = extractS3KeyFromUrl(user.getProfileImageUrl());
352+
s3Service.deleteFile(oldKey);
353+
354+
// 썸네일도 있다면 삭제
355+
String thumbnailKey = oldKey.replace("profile-images/", "profile-images/thumbnail-");
356+
try {
357+
s3Service.deleteFile(thumbnailKey);
358+
} catch (Exception e) {
359+
log.debug("썸네일 이미지 없음 또는 삭제 실패 - key: {}", thumbnailKey);
360+
}
361+
362+
log.info("기존 프로필 이미지 삭제 완료 - key: {}", oldKey);
363+
} catch (Exception e) {
364+
log.warn("기존 프로필 이미지 삭제 실패 - userId: {}", user.getId(), e);
365+
// 삭제 실패해도 진행 (새 이미지는 업로드)
366+
}
367+
}
368+
369+
/**
370+
* 프로필 이미지 삭제
371+
*/
372+
@Transactional
373+
public UserProfileResponse deleteProfileImage(Long userId) {
374+
User user = getUserById(userId);
375+
376+
// 1. 현재 프로필 이미지가 없으면 에러
377+
if (user.getProfileImageUrl() == null || user.getProfileImageUrl().isBlank()) {
378+
throw new ServiceException("400", "삭제할 프로필 이미지가 없습니다.");
379+
}
380+
381+
// 2. S3에서 이미지 삭제
382+
deleteOldProfileImage(user);
383+
384+
// 3. DB에서 프로필 이미지 URL을 null로 설정
385+
user.deleteProfileImage();
386+
387+
log.info("프로필 이미지 삭제 완료 - userId: {}", userId);
388+
389+
return UserProfileResponse.from(user);
390+
}
391+
392+
/**
393+
* S3 URL에서 Key 추출 헬퍼 메서드
394+
*/
395+
private String extractS3KeyFromUrl(String url) {
396+
try {
397+
log.debug("S3 URL 파싱 시도: {}", url);
398+
399+
// .com/ 이후의 문자열 추출 (리터럴 문자열 사용)
400+
int index = url.indexOf(".com/");
401+
402+
if (index != -1 && index + 5 < url.length()) {
403+
String key = url.substring(index + 5);
404+
log.debug("추출된 S3 Key: {}", key);
405+
return key;
406+
}
407+
408+
log.error("URL 파싱 실패: '.com/' 구분자를 찾을 수 없음. URL: {}", url);
409+
} catch (Exception e) {
410+
log.error("S3 URL 파싱 중 예외 발생: {}", url, e);
411+
}
412+
throw new ServiceException("400", "잘못된 S3 URL 형식입니다. URL: " + url);
413+
}
414+
415+
/**
416+
* 파일 크기 검증
417+
*/
418+
private void validateImageFile(MultipartFile file) {
419+
// 1. 파일이 비어있는지 검증
420+
if (file == null || file.isEmpty()) {
421+
throw new ServiceException("400", "업로드할 파일이 없습니다.");
422+
}
423+
424+
// 2. 파일 크기 검증 (5MB 제한)
425+
if (file.getSize() > 5 * 1024 * 1024) {
426+
throw new ServiceException("400", "이미지 크기는 5MB를 초과할 수 없습니다.");
427+
}
428+
429+
// 3. 이미지 파일 형식 검증 (Content-Type)
430+
String contentType = file.getContentType();
431+
if (contentType == null || !contentType.startsWith("image/")) {
432+
throw new ServiceException("400", "이미지 파일만 업로드 가능합니다.");
433+
}
434+
}
435+
294436
}

0 commit comments

Comments
 (0)