Skip to content

Commit 5423506

Browse files
Merge pull request #138 from prgrms-web-devcourse-final-project/feat/#124
[User] 유저 프로필 이미지 AWS S3 활용한 업로드 처리
2 parents 4e03614 + 72da317 commit 5423506

12 files changed

Lines changed: 173 additions & 19 deletions

File tree

.github/workflows/ci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ jobs:
4242
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
4343
GOOGLE_REDIRECT_URI: ${{ secrets.GOOGLE_REDIRECT_URI }}
4444

45+
AWS_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY_ID }}
46+
AWS_SECRET_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
47+
AWS_S3_BUCKET_NAME: ${{ secrets.AWS_S3_BUCKET_NAME }}
48+
4549
steps:
4650
- name: Checkout
4751
uses: actions/checkout@v4

build.gradle.kts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ dependencies {
7474
// Spotify
7575
implementation("se.michaelthelin.spotify:spotify-web-api-java:8.4.1")
7676

77+
// AWS S3
78+
implementation("software.amazon.awssdk:s3")
79+
implementation("io.awspring.cloud:spring-cloud-aws-s3:3.0.2")
80+
81+
// 서버 정상 작동 확인용
7782
implementation("org.springframework.boot:spring-boot-starter-actuator")
7883
}
7984

src/main/java/com/back/web7_9_codecrete_be/domain/users/controller/UserController.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import io.swagger.v3.oas.annotations.tags.Tag;
1313
import jakarta.validation.Valid;
1414
import lombok.RequiredArgsConstructor;
15+
import org.springframework.http.MediaType;
1516
import org.springframework.web.bind.annotation.*;
1617
import org.springframework.web.multipart.MultipartFile;
1718

@@ -42,10 +43,10 @@ public RsData<?> updateNickname(
4243
return RsData.success("사용자 닉네임 변경 완료", response);
4344
}
4445

45-
@Operation(summary = "내 프로필 이미지 수정", description = "프로필 이미지를 수정합니다.")
46-
@PatchMapping("/profile-image")
46+
@Operation(summary = "내 프로필 이미지 수정", description = "프로필 이미지를 multipart/form-data 형식으로 업로드하여 수정합니다.")
47+
@PatchMapping(value = "/profile-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
4748
public RsData<?> updateProfileImage(
48-
@RequestPart MultipartFile file
49+
@RequestPart("file") MultipartFile file
4950
) {
5051
User user = rq.getUser();
5152
String imageUrl = userService.updateProfileImage(user, file);
@@ -62,7 +63,6 @@ public RsData<?> updatePassword(
6263
return RsData.success("비밀번호가 변경되었습니다.");
6364
}
6465

65-
6666
@Operation(summary = "회원 탈퇴", description = "현재 로그인된 사용자를 탈퇴 처리합니다.")
6767
@DeleteMapping("/me")
6868
public RsData<?> deleteMyAccount() {

src/main/java/com/back/web7_9_codecrete_be/domain/users/dto/response/UserResponse.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ public class UserResponse {
1313
private String email;
1414
private String nickname;
1515
private LocalDate birthdate;
16+
private String profileImageUrl;
1617
private String status;
1718

1819
public static UserResponse from(User user) {
@@ -21,6 +22,7 @@ public static UserResponse from(User user) {
2122
.email(user.getEmail())
2223
.nickname(user.getNickname())
2324
.birthdate(user.getBirth())
25+
.profileImageUrl(user.getProfileImage())
2426
.status(user.getStatus().name())
2527
.build();
2628
}

src/main/java/com/back/web7_9_codecrete_be/domain/users/service/UserService.java

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import com.back.web7_9_codecrete_be.global.error.exception.BusinessException;
1414
import com.back.web7_9_codecrete_be.global.storage.FileStorageService;
1515
import lombok.RequiredArgsConstructor;
16+
import lombok.extern.slf4j.Slf4j;
1617
import org.springframework.security.crypto.password.PasswordEncoder;
1718
import org.springframework.stereotype.Service;
1819
import org.springframework.transaction.annotation.Transactional;
@@ -21,6 +22,7 @@
2122
import java.time.LocalDateTime;
2223
import java.util.UUID;
2324

25+
@Slf4j
2426
@Service
2527
@RequiredArgsConstructor
2628
@Transactional
@@ -32,6 +34,8 @@ public class UserService {
3234
private final UserRestoreTokenRedisRepository userRestoreTokenRedisRepository;
3335
private final EmailService emailService;
3436

37+
private static final long MAX_PROFILE_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB
38+
3539
// 내 정보 조회
3640
@Transactional(readOnly = true)
3741
public UserResponse getMyInfo(User user) {
@@ -75,14 +79,39 @@ public String updateProfileImage(User user, MultipartFile file) {
7579
throw new BusinessException(UserErrorCode.INVALID_PROFILE_IMAGE);
7680
}
7781

78-
String imageUrl = fileStorageService.upload(file);
82+
if (file.getSize() > MAX_PROFILE_IMAGE_SIZE) {
83+
throw new BusinessException(UserErrorCode.PROFILE_IMAGE_SIZE_EXCEEDED);
84+
}
85+
86+
// 기존 이미지 URL 보관
87+
String oldImageUrl = user.getProfileImage();
88+
89+
// 새 이미지 업로드
90+
String newImageUrl = fileStorageService.upload(file, "users/profile");
7991

80-
user.updateProfileImage(imageUrl);
92+
// DB 업데이트
93+
user.updateProfileImage(newImageUrl);
8194
userRepository.save(user);
8295

83-
return imageUrl;
96+
// 기존 이미지 즉시 삭제
97+
// TODO: 추후 지연 삭제 스케줄러로 리팩토링
98+
if (oldImageUrl != null) {
99+
try {
100+
fileStorageService.delete(oldImageUrl);
101+
} catch (Exception e) {
102+
log.warn(
103+
"기존 프로필 이미지 삭제 실패 userId={}, url={}",
104+
user.getId(),
105+
oldImageUrl,
106+
e
107+
);
108+
}
109+
}
110+
111+
return newImageUrl;
84112
}
85113

114+
86115
// 활성 사용자 검증
87116
private void validateActiveUser(User user) {
88117
if (user.getIsDeleted()) {
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.back.web7_9_codecrete_be.global.config;
2+
3+
import org.springframework.beans.factory.annotation.Value;
4+
import org.springframework.context.annotation.Bean;
5+
import org.springframework.context.annotation.Configuration;
6+
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
7+
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
8+
import software.amazon.awssdk.regions.Region;
9+
import software.amazon.awssdk.services.s3.S3Client;
10+
11+
@Configuration
12+
public class S3Config {
13+
14+
@Value("${cloud.aws.credentials.access-key}")
15+
private String accessKey;
16+
17+
@Value("${cloud.aws.credentials.secret-key}")
18+
private String secretKey;
19+
20+
@Value("${cloud.aws.region.static}")
21+
private String region;
22+
23+
@Bean
24+
public S3Client s3Client() {
25+
return S3Client.builder()
26+
// 지역 설정
27+
.region(Region.of(region))
28+
// 자격 증명자 설정
29+
.credentialsProvider(StaticCredentialsProvider.create(
30+
// accessKey와 secretKey로 자격 증명 생성
31+
AwsBasicCredentials.create(accessKey,secretKey)
32+
))
33+
.build();
34+
}
35+
}

src/main/java/com/back/web7_9_codecrete_be/global/error/code/UserErrorCode.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ public enum UserErrorCode implements ErrorCode {
1818

1919
// 3xx - 입력값 / 파일
2020
INVALID_PROFILE_IMAGE(HttpStatus.BAD_REQUEST, "U-110", "유효하지 않은 프로필 이미지입니다."),
21+
PROFILE_IMAGE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "U-111", "프로필 이미지 크기가 10MB를 초과했습니다."),
2122

2223
// 2xx - 인증 / 비밀번호
2324
INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "U-120", "현재 비밀번호가 일치하지 않습니다.");

src/main/java/com/back/web7_9_codecrete_be/global/security/SecurityConfig.java

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,15 +57,13 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
5757
"/h2-console/**", // H2 Console
5858
"/api/v1/location/**", //location 정보 조회 도메인(임시)
5959
"/api/v1/concerts/**", // concert 정보 조회 도메인
60-
"/api/v1/artists/**" // artist 정보 저장 도메인(임시)
60+
"/api/v1/artists/**", // artist 정보 저장 도메인(임시)
61+
"/api/v1/users/**"
6162
).permitAll()
6263

6364
// ADMIN 전용
6465
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
6566

66-
// USER, ADMIN 허용
67-
.requestMatchers("/api/v1/users/**").hasAnyRole("USER", "ADMIN")
68-
6967
.anyRequest().authenticated()
7068
)
7169

src/main/java/com/back/web7_9_codecrete_be/global/storage/FileStorageService.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
import org.springframework.web.multipart.MultipartFile;
44

55
public interface FileStorageService {
6-
//임시 구현
7-
String upload(MultipartFile file);
6+
7+
// 파일 업로드 메서드
8+
String upload(MultipartFile file, String basePath);
9+
10+
void delete(String fileUrl);
811
}
Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,71 @@
11
package com.back.web7_9_codecrete_be.global.storage;
22

3+
import lombok.RequiredArgsConstructor;
4+
import org.springframework.beans.factory.annotation.Value;
35
import org.springframework.stereotype.Service;
46
import org.springframework.web.multipart.MultipartFile;
7+
import software.amazon.awssdk.core.sync.RequestBody;
8+
import software.amazon.awssdk.services.s3.S3Client;
9+
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
510

11+
import java.io.IOException;
612
import java.util.UUID;
713

814
@Service
15+
@RequiredArgsConstructor
916
public class S3FileStorageService implements FileStorageService {
17+
18+
private final S3Client s3Client;
19+
20+
@Value("${cloud.aws.s3.bucket}")
21+
private String bucket;
22+
23+
@Value("${cloud.aws.region.static}")
24+
private String region;
25+
26+
// 공용 업로드 메서드
27+
@Override
28+
public String upload(MultipartFile file, String basePath) {
29+
30+
String fileName = UUID.randomUUID() + "_" + file.getOriginalFilename();
31+
String key = basePath + "/" + fileName;
32+
33+
try {
34+
PutObjectRequest putObjectRequest = PutObjectRequest.builder()
35+
.bucket(bucket)
36+
.key(key)
37+
.contentType(file.getContentType())
38+
.build();
39+
40+
s3Client.putObject(
41+
putObjectRequest,
42+
RequestBody.fromInputStream(file.getInputStream(), file.getSize())
43+
);
44+
45+
} catch (IOException e) {
46+
throw new RuntimeException("S3 파일 업로드 실패", e);
47+
}
48+
49+
return "https://" + bucket + ".s3." + region + ".amazonaws.com/" + key;
50+
}
51+
1052
@Override
11-
public String upload(MultipartFile file) {
53+
public void delete(String fileUrl) {
54+
55+
if (fileUrl == null || fileUrl.isBlank()) {
56+
return;
57+
}
58+
59+
String prefix = "https://" + bucket + ".s3." + region + ".amazonaws.com/";
60+
if (!fileUrl.startsWith(prefix)) {
61+
throw new IllegalArgumentException("잘못된 S3 파일 URL입니다.");
62+
}
1263

13-
// 임시 URL 생성
14-
String fakeFileName = UUID.randomUUID() + "_" + file.getOriginalFilename();
64+
String key = fileUrl.substring(prefix.length());
1565

16-
return "https://dummy-cdn.codecrete.com/profile/" + fakeFileName;
66+
s3Client.deleteObject(builder -> builder
67+
.bucket(bucket)
68+
.key(key)
69+
);
1770
}
1871
}

0 commit comments

Comments
 (0)