회원 프로필 수정기능 구현에서 프로필 사진을 수정하기 위해서 기존의 이미지를 S3에서 삭제하고 새로운 이미지를 S3에 업로드 하는 기능을 구현해야했다.
이 과정에서 공부한 내용을 정리한다.
이미지를 업로드 하기 위해서는 클라이언트에서 서버로 요청을 보낼때 헤더의 contentType은 multipart/form-data
이다.
이 방식은 이진 파일(예: 이미지, 오디오, 비디오)및 대용량 데이터를 전송하는데 적합하다.
POST /upload HTTP/1.1
Host: example.com
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Length: [총 길이]
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="text_field"
text value
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file_field"; filename="example.txt"
Content-Type: text/plain
[파일 내용]
------WebKitFormBoundary7MA4YWxkTrZu0gW--
요청 형식은 위와 같다.
헤더의 Content-Type에 booundary는 ----WebKitFormBoundary7MA4YWxkTrZu0gw로 되어있다.
이는 body를 나누는 기준이다.
구분된 각 body에는 Content-Disposition헤더로 시작하고 form-data 형식의 데이터와 해당 데이터의 name을 포함한다.
파일 업로드의 경우에는 filename과 Content-Type도 포함한다.
마지막 경계 문자열은 ------WebKitFormBoundary7MA4YWxkTrZu0gW--와 같이 마지막에 --로 끝난다.
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
AWS와 통합하는데 사용되는 스프링 부트 스타터 패키지이다.
cloud:
aws:
s3:
bucket:
credentials:
access-key:
secret-key:
region:
static: ap-northeast-2
auto: false
stack:
auto: false
@Configuration
public class S3Configuration {
@Value("${cloud.aws.credentials.access-key}")
private String accessKey;
@Value("${cloud.aws.credentials.secret-key}")
private String secretKey;
@Value("${cloud.aws.region.static}")
private String region;
@Bean
public AmazonS3Client amazonS3Client() {
BasicAWSCredentials basicAWSCredentials = new BasicAWSCredentials(accessKey, secretKey);
return (AmazonS3Client) AmazonS3ClientBuilder
.standard()
.withRegion(region)
.withCredentials(new AWSStaticCredentialsProvider(basicAWSCredentials))
.build();
}
}
@ThreadSafe
public class AmazonS3Client extends AmazonWebServiceClient implements AmazonS3 {
를 보면 AmazonS3Client는 AmazonS3를 구현하는 구현체이다. 따라서 AmazonS3Client를 Bean으로 등록해두면 AWSS3Uploader.class에서 AmazonS3에 DI가 된다.
@Slf4j
@Component
@RequiredArgsConstructor
public class AWSS3Uploader implements ImageUploader {
@Value("${cloud.aws.s3.bucket}")
private String bucket;
private final AmazonS3 amazonS3;
private static final String DEFAULT_DIRECTORY = "images/";
@Override
public List<String> upload(String directory, List<MultipartFile> multipartFiles) {
if (multipartFiles.isEmpty()) {
return List.of();
}
List<String> uploadedImageUrl = new ArrayList<>();
multipartFiles.forEach(
multipartFile -> {
String originalFilename = multipartFile.getOriginalFilename(); //파일의 이름
ObjectMetadata objectMetadata = new ObjectMetadata(); //ObjectMetadata는 파일의 메타 정보
objectMetadata.setContentLength(multipartFile.getSize());
objectMetadata.setContentType(multipartFile.getContentType());
try {
amazonS3.putObject(bucket, DEFAULT_DIRECTORY + directory + originalFilename, multipartFile.getInputStream(), objectMetadata);
} catch (IOException e) {
log.warn("[Warning] Image Upload to S3 has some exception", e);
throw new AmazonServiceException("Some Error occurred during Upload Image to S3 Server", e);
}
uploadedImageUrl.add(convertURL(amazonS3.getUrl(bucket, originalFilename).toString(), directory));
}
);
return uploadedImageUrl;
}
// 원하는 부분을 찾아 새 경로를 추가
public static String convertURL(String url, String directory) {
String searchPattern = ".com/";
int index = url.indexOf(searchPattern) + searchPattern.length();
String newPart = DEFAULT_DIRECTORY + directory;
String modifiedURL = url.substring(0, index) + newPart + url.substring(index);
return modifiedURL;
}
@Override
public void delete(String directory, List<String> imageUrls) {
String splitStr = ".com/";
imageUrls.stream()
.map(imageUrl -> imageUrl.substring(imageUrl.lastIndexOf(splitStr) + splitStr.length()))
.map(fileName -> new DeleteObjectRequest(bucket, fileName))
.forEach(amazonS3::deleteObject);
}
}
프로필 이미지 수정 시 추가되는 이미지는 하나이다. 하지만 다이어리 기능에서 이미지를 여러개 추가해야하기 때문에 List<MultipartFile> multipartFiles
와 같이 parameter를 List로 받았다.
upload메서드의 경우 List를 순회하면서 S3에 이미지를 upload하고 Url을 uploadedImageUrl에 담아 리스트를 반환한다.
delete메서드의 경우 imageUrls 리스트를 순회하면서 해당 주소의 이미지들을 삭제한다.
@SneakyThrows
@PatchMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ApiResponse<Void>> editMemberProfile(
@RequestPart(value = "images", required = false) List<MultipartFile> profileImage,
@RequestPart(value = "texts") @Valid MemberProfileEditRequest request,
@LoginUser SessionUser sessionUser) {
log.debug("profileImage null check : {}", profileImage == null);
memberService.updateMemberProfile(profileImage, request.toServiceRequest(), sessionUser.memberId());
return ApiResponse.ok(
linkTo(methodOn(MemberController.class).editMemberProfile(profileImage, request, sessionUser)).withSelfRel(),
linkTo(MemberController.class.getMethod("getMemberProfile", SessionUser.class)).withRel("get member profile")
);
}
profileImage와 변경할 회원의 정보인 MemberProfileEditRequest를 파라미터로써 받는다.
@Transactional
public void updateMemberProfile(List<MultipartFile> profileImages, MemberProfileEditServiceRequest serviceRequest, Long memberId) {
Member findMember = validateMemberId(memberId);
String oldProfileImageUrl = findMember.getImageUrl();
String profileImageUrl = updateProfileImage(profileImages, oldProfileImageUrl);
updateMemberProfile(profileImageUrl, serviceRequest, findMember);
eventPublisher.publishEvent(new MemberupdatedEvent(findMember));
}
private String updateProfileImage(List<MultipartFile> profileImages, String oldProfileImageUrl) {
if (profileImages != null) {
imageUploader.delete("member/", List.of(oldProfileImageUrl));
checkCountOfImage(profileImages);
return uploadImage(profileImages);
}
return oldProfileImageUrl;
}
private String uploadImage(List<MultipartFile> profileImage) {
return imageUploader.upload("member/", profileImage).get(0);
}
private void checkCountOfImage(List<MultipartFile> multipartFileList) {
if (multipartFileList.size() > 1) {
throw new IllegalArgumentException("프로필 사진 수정시 이미지는 하나만 필요합니다.");
}
}
private Member validateMemberId(Long memberId) {
return memberRepository.findById(memberId)
.orElseThrow(() -> new EntityNotFoundException("존재하지 않는 id 입니다."));
}
private void updateMemberProfile(String profileImageUrl, MemberProfileEditServiceRequest serviceRequest, Member findMember) {
findMember.updateProfile(
profileImageUrl,
serviceRequest.nickname(),
serviceRequest.birthday(),
serviceRequest.mbti(),
serviceRequest.calendarColor());
}
회원 프로필 수정 시 이미지도 수정을 한다면 이전의 이미지를 S3에서 삭제해준다. 그렇지 않으면 S3에 파일들이 계속 쌓인다.