Skip to content

Commit 6f7f3c6

Browse files
authored
Merge pull request #111 from IT-Cotato/feature/100
feat: S3 연동 프로필 이미지 업로드/삭제 API 구현
2 parents c9e3fed + 4e209d8 commit 6f7f3c6

6 files changed

Lines changed: 195 additions & 8 deletions

File tree

build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ dependencies {
7272

7373
// Crawling
7474
implementation 'org.jsoup:jsoup:1.17.2'
75+
76+
// AWS S3
77+
implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3:3.2.1'
7578
}
7679

7780
dependencyManagement {

src/main/java/com/ongil/backend/domain/user/controller/UserController.java

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
package com.ongil.backend.domain.user.controller;
22

3+
import org.springframework.http.MediaType;
34
import org.springframework.http.ResponseEntity;
45
import org.springframework.security.core.annotation.AuthenticationPrincipal;
56
import org.springframework.validation.annotation.Validated;
7+
import org.springframework.web.bind.annotation.DeleteMapping;
68
import org.springframework.web.bind.annotation.GetMapping;
79
import org.springframework.web.bind.annotation.PatchMapping;
810
import org.springframework.web.bind.annotation.PathVariable;
911
import org.springframework.web.bind.annotation.PutMapping;
1012
import org.springframework.web.bind.annotation.RequestBody;
1113
import org.springframework.web.bind.annotation.RequestMapping;
14+
import org.springframework.web.bind.annotation.RequestPart;
1215
import org.springframework.web.bind.annotation.RestController;
16+
import org.springframework.web.multipart.MultipartFile;
1317

1418
import com.ongil.backend.domain.user.dto.request.BodyInfoRequest;
15-
import com.ongil.backend.domain.user.dto.request.UserUpdateProfileRequest;
1619
import com.ongil.backend.domain.user.dto.response.BodyInfoResponse;
1720
import com.ongil.backend.domain.user.dto.response.SizeOptionsResponse;
1821
import com.ongil.backend.domain.user.dto.response.TermsResponse;
@@ -52,13 +55,22 @@ public ResponseEntity<DataResponse<UserInfoResDto>> getUserInfo(
5255
return ResponseEntity.ok(DataResponse.from(res));
5356
}
5457

55-
@PatchMapping("/me/profile-image")
56-
@Operation(summary = "프로필 이미지 수정 API", description = "현재 로그인한 사용자의 프로필 이미지를 수정")
58+
@PatchMapping(value = "/me/profile-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
59+
@Operation(summary = "프로필 이미지 수정 API", description = "현재 로그인한 사용자의 프로필 이미지를 수정 (토큰 필요)")
5760
public ResponseEntity<DataResponse<UserInfoResDto>> updateProfileImage(
5861
@AuthenticationPrincipal Long userId,
59-
@RequestBody UserUpdateProfileRequest request
62+
@RequestPart("image") MultipartFile imageFile
6063
) {
61-
UserInfoResDto res = userService.updateProfileImage(userId, request.profileImageUrl());
64+
UserInfoResDto res = userService.updateProfileImage(userId, imageFile);
65+
return ResponseEntity.ok(DataResponse.from(res));
66+
}
67+
68+
@DeleteMapping("/me/profile-image")
69+
@Operation(summary = "프로필 이미지 삭제 API", description = "프로필 이미지를 삭제하고 기본 이미지로 초기화 (토큰 필요)")
70+
public ResponseEntity<DataResponse<UserInfoResDto>> deleteProfileImage(
71+
@AuthenticationPrincipal Long userId
72+
) {
73+
UserInfoResDto res = userService.deleteProfileImage(userId);
6274
return ResponseEntity.ok(DataResponse.from(res));
6375
}
6476

src/main/java/com/ongil/backend/domain/user/service/UserService.java

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import org.springframework.stereotype.Service;
44
import org.springframework.transaction.annotation.Transactional;
5+
import org.springframework.web.multipart.MultipartFile;
56

67
import com.ongil.backend.domain.user.converter.BodyInfoConverter;
78
import com.ongil.backend.domain.user.converter.UserConverter;
@@ -17,6 +18,7 @@
1718
import com.ongil.backend.domain.user.repository.UserRepository;
1819
import com.ongil.backend.global.common.exception.EntityNotFoundException;
1920
import com.ongil.backend.global.common.exception.ErrorCode;
21+
import com.ongil.backend.global.config.s3.S3ImageService;
2022

2123
import lombok.RequiredArgsConstructor;
2224

@@ -25,6 +27,8 @@
2527
@Transactional(readOnly = true)
2628
public class UserService {
2729
private final UserRepository userRepository;
30+
private final S3ImageService s3ImageService;
31+
2832
// 1. 내 정보 조회
2933
public UserInfoResDto getUserInfo(Long userId) {
3034
User user = userRepository.findById(userId)
@@ -34,12 +38,38 @@ public UserInfoResDto getUserInfo(Long userId) {
3438
}
3539

3640
// 2. 프로필 이미지 변경
37-
@Transactional // 쓰기 권한 부여
38-
public UserInfoResDto updateProfileImage(Long userId, String newImageUrl) {
41+
@Transactional
42+
public UserInfoResDto updateProfileImage(Long userId, MultipartFile imageFile) {
3943
User user = findUser(userId);
4044

45+
// 1. 새 이미지 먼저 S3 업로드 (실패 시 기존 이미지 보존)
46+
String newImageUrl = s3ImageService.upload(imageFile);
47+
48+
// 2. 기존 프로필 이미지 URL 백업
49+
String oldImageUrl = user.getProfileImg();
50+
51+
// 3. DB 업데이트
4152
user.updateProfileImage(newImageUrl);
4253

54+
// 4. 기존 이미지가 있으면 S3에서 삭제
55+
if (oldImageUrl != null) {
56+
s3ImageService.delete(oldImageUrl);
57+
}
58+
59+
return UserConverter.toUserInfoResDto(user);
60+
}
61+
62+
// 2-1. 프로필 이미지 삭제 (기본 이미지로 초기화)
63+
@Transactional
64+
public UserInfoResDto deleteProfileImage(Long userId) {
65+
User user = findUser(userId);
66+
67+
if (user.getProfileImg() != null) {
68+
String oldImageUrl = user.getProfileImg();
69+
user.updateProfileImage(null);
70+
s3ImageService.delete(oldImageUrl);
71+
}
72+
4373
return UserConverter.toUserInfoResDto(user);
4474
}
4575

src/main/java/com/ongil/backend/global/common/exception/ErrorCode.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@ public enum ErrorCode {
8383

8484
// PRICE_ALERT
8585
PRICE_ALERT_NOT_FOUND(HttpStatus.NOT_FOUND, "설정된 가격 알림이 없습니다.", "ALERT-001"),
86+
87+
// FILE / S3
88+
FILE_IS_EMPTY(HttpStatus.BAD_REQUEST, "파일이 비어 있습니다.", "FILE-001"),
89+
INVALID_FILE_EXTENSION(HttpStatus.BAD_REQUEST, "허용되지 않는 파일 확장자입니다. (jpg, jpeg, png만 가능)", "FILE-002"),
90+
S3_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "이미지 업로드에 실패했습니다.", "S3-001"),
91+
S3_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "이미지 삭제에 실패했습니다.", "S3-002"),
8692
;
8793

8894
private final HttpStatus httpStatus;
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package com.ongil.backend.global.config.s3;
2+
3+
import java.io.IOException;
4+
import java.util.List;
5+
import java.util.UUID;
6+
7+
import org.springframework.beans.factory.annotation.Value;
8+
import org.springframework.stereotype.Service;
9+
import org.springframework.web.multipart.MultipartFile;
10+
11+
import com.ongil.backend.global.common.exception.AppException;
12+
import com.ongil.backend.global.common.exception.ErrorCode;
13+
14+
import lombok.RequiredArgsConstructor;
15+
import lombok.extern.slf4j.Slf4j;
16+
import software.amazon.awssdk.core.exception.SdkException;
17+
import software.amazon.awssdk.core.sync.RequestBody;
18+
import software.amazon.awssdk.services.s3.S3Client;
19+
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
20+
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
21+
22+
@Slf4j
23+
@Service
24+
@RequiredArgsConstructor
25+
public class S3ImageService {
26+
27+
private final S3Client s3Client;
28+
29+
@Value("${spring.cloud.aws.s3.bucket}")
30+
private String bucket;
31+
32+
@Value("${spring.cloud.aws.region.static}")
33+
private String region;
34+
35+
private static final List<String> ALLOWED_EXTENSIONS = List.of("jpg", "jpeg", "png");
36+
private static final String PROFILE_DIRECTORY = "profile";
37+
38+
/**
39+
* 이미지를 S3에 업로드하고 공개 URL을 반환한다.
40+
*/
41+
public String upload(MultipartFile file) {
42+
validateFile(file);
43+
44+
String extension = extractExtension(file.getOriginalFilename());
45+
String key = PROFILE_DIRECTORY + "/" + UUID.randomUUID() + "." + extension;
46+
47+
try {
48+
PutObjectRequest putRequest = PutObjectRequest.builder()
49+
.bucket(bucket)
50+
.key(key)
51+
.contentType(file.getContentType())
52+
.build();
53+
54+
s3Client.putObject(putRequest,
55+
RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
56+
} catch (IOException | SdkException e) {
57+
log.error("S3 업로드 실패: {}", e.getMessage());
58+
throw new AppException(ErrorCode.S3_UPLOAD_FAILED);
59+
}
60+
61+
return generateUrl(key);
62+
}
63+
64+
/**
65+
* S3에서 기존 이미지를 삭제한다.
66+
*/
67+
public void delete(String imageUrl) {
68+
String key = extractKey(imageUrl);
69+
70+
try {
71+
DeleteObjectRequest deleteRequest = DeleteObjectRequest.builder()
72+
.bucket(bucket)
73+
.key(key)
74+
.build();
75+
76+
s3Client.deleteObject(deleteRequest);
77+
log.info("S3 이미지 삭제 완료: {}", key);
78+
} catch (SdkException e) {
79+
log.error("S3 이미지 삭제 실패: {}", e.getMessage());
80+
throw new AppException(ErrorCode.S3_DELETE_FAILED);
81+
}
82+
}
83+
84+
private void validateFile(MultipartFile file) {
85+
if (file == null || file.isEmpty()) {
86+
throw new AppException(ErrorCode.FILE_IS_EMPTY);
87+
}
88+
89+
String extension = extractExtension(file.getOriginalFilename());
90+
if (!ALLOWED_EXTENSIONS.contains(extension.toLowerCase())) {
91+
throw new AppException(ErrorCode.INVALID_FILE_EXTENSION);
92+
}
93+
}
94+
95+
private String extractExtension(String originalFilename) {
96+
if (originalFilename == null || !originalFilename.contains(".")) {
97+
throw new AppException(ErrorCode.INVALID_FILE_EXTENSION);
98+
}
99+
return originalFilename.substring(originalFilename.lastIndexOf(".") + 1);
100+
}
101+
102+
/**
103+
* S3 공개 URL에서 key(경로) 부분만 추출한다.
104+
* 예: "https://ongil-bucket.s3.ap-northeast-2.amazonaws.com/profile/abc.jpg"
105+
* -> "profile/abc.jpg"
106+
*/
107+
private String extractKey(String imageUrl) {
108+
String prefix = generatePrefix();
109+
if (!imageUrl.startsWith(prefix)) {
110+
log.error("예상 외 이미지 URL 형식: {}", imageUrl);
111+
throw new AppException(ErrorCode.S3_DELETE_FAILED);
112+
}
113+
return imageUrl.substring(prefix.length());
114+
}
115+
116+
private String generatePrefix() {
117+
return "https://" + bucket + ".s3." + region + ".amazonaws.com/";
118+
}
119+
120+
private String generateUrl(String key) {
121+
return generatePrefix() + key;
122+
}
123+
}

src/main/resources/application.yml

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ spring:
77
active: ${SPRING_PROFILES_ACTIVE:local}
88
elasticsearch:
99
uris: ${SPRING_ELASTICSEARCH_URIS:http://localhost:9200}
10+
servlet:
11+
multipart:
12+
max-file-size: 5MB
13+
max-request-size: 5MB
1014
data:
1115
redis:
1216
host: ${SPRING_DATA_REDIS_HOST:localhost}
@@ -36,4 +40,13 @@ google:
3640
client-secret: ${GOOGLE_CLIENT_SECRET}
3741
redirect-uri: ${GOOGLE_REDIRECT_URI}
3842
authorization-grant-type: authorization_code
39-
scope: email, profile
43+
scope: email, profile
44+
45+
spring.cloud.aws:
46+
credentials:
47+
access-key: ${AWS_ACCESS_KEY}
48+
secret-key: ${AWS_SECRET_KEY}
49+
region:
50+
static: ap-northeast-2
51+
s3:
52+
bucket: ${AWS_S3_BUCKET}

0 commit comments

Comments
 (0)