이번 프로필 등록에 왜 S3가 필요하나요?
첫째로, 확장성입니다. 로컬 서버의 저장 공간은 한정되어 있어 서비스가 성장함에 따라 디스크 용량이 빠르게 소진될 수 있습니다. 반면에 Amazon S3는 사실상 무제한의 스토리지 용량을 제공하여, 대량의 이미지나 파일 데이터를 저장하는 데 제약이 없습니다. 이는 서비스 사용자가 늘어나거나 데이터 양이 급증해도 안정적으로 대응할 수 있다는 것을 의미합니다.
둘째로, 고가용성과 안정성을 제공합니다. Amazon S3는 데이터 내구성이 99.999999999%에 달하여 데이터 손실의 위험이 거의 없습니다. 또한 AWS의 글로벌 인프라를 통해 전 세계 어디에서나 빠르고 안정적으로 데이터에 접근할 수 있습니다. 이는 사용자들에게 일관된 서비스 품질을 제공하는 데 필수적입니다.
셋째로, 서버 부하 감소에 기여합니다. 이미지를 로컬 서버에 저장하면 디스크 공간뿐만 아니라 I/O 리소스도 많이 소모하게 되어 서버 성능에 부정적인 영향을 줄 수 있습니다. S3에 이미지를 저장하면 서버는 핵심 비즈니스 로직 처리에 집중할 수 있고, 정적 파일 제공은 S3가 담당하여 전체적인 시스템 효율이 향상됩니다.
넷째로, 서비스 확장과 유지보수의 용이성을 제공합니다. 로컬 스토리지를 사용할 경우 서버를 추가하거나 교체할 때마다 파일 동기화나 데이터 마이그레이션 등의 복잡한 작업이 필요합니다. 그러나 S3를 사용하면 모든 서버가 동일한 스토리지에 접근하므로 이러한 문제를 효과적으로 해결할 수 있습니다. 이는 개발 및 운영 인력의 부담을 줄이고 서비스 가용성을 높입니다.
다섯째로, 보안과 접근 제어 측면에서 우수합니다. Amazon S3는 버킷과 객체 수준에서 세밀한 권한 설정이 가능하여 데이터에 대한 접근을 철저히 통제할 수 있습니다. 또한 전송 중 및 저장 시 데이터 암호화를 지원하여 보안을 한층 강화합니다. 이는 사용자 개인정보나 민감한 데이터를 다루는 서비스에서 매우 중요한 요소입니다.
여섯째로, 백업과 복구가 용이합니다. S3는 데이터를 여러 시설에 복제하여 저장하므로, 한 곳에 문제가 발생하더라도 데이터가 안전하게 보호됩니다. 또한 버전 관리 기능을 통해 실수로 삭제하거나 수정된 파일을 이전 상태로 복원할 수 있습니다.
마지막으로, 비용 효율성입니다. S3는 사용량 기반 과금 모델을 채택하고 있어 초기 투자 비용이 적고, 서비스 규모에 따라 비용을 유연하게 관리할 수 있습니다. 또한 액세스 빈도에 따라 다양한 스토리지 클래스를 선택하여 비용을 최적화할 수 있습니다.
S3 클래스 구조
package nbc_final.gathering.common.config;
import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class S3Config {
@org.springframework.beans.factory.annotation.Value("${cloud.aws.s3.credentials.access-key}")
private String accessKey;
@org.springframework.beans.factory.annotation.Value("${cloud.aws.s3.credentials.secret-key}")
private String secretKey;
@Value("${cloud.aws.s3.region.static}")
private String region;
@Bean
public AmazonS3 amazonS3() {
BasicAWSCredentials awsCreds = new BasicAWSCredentials(accessKey, secretKey);
return AmazonS3ClientBuilder.standard()
.withRegion(region)
.withCredentials(new AWSStaticCredentialsProvider(awsCreds))
.build();
}
}
AWS S3의 설정 클래스입니다. 여기서 AWS 엑세스, 시크릿 키, 리전 정보를 받습니다.
AmazonS3의 메서드는 어떤구조인가요?
BasicAWSCredentials 객체를 생성하여 AWS 자격 증명을 설정합니다.
AmazonS3ClientBuilder를 사용하여 AmazonS3 클라이언트를 빌드합니다.
withRegion(region): S3 서비스가 위치한 리전을 설정합니다.
withCredentials(new AWSStaticCredentialsProvider(awsCreds)): 앞서 생성한 자격 증명을 설정합니다.
최종적으로 AmazonS3 객체를 반환하여 빈으로 등록합니다.
컨트롤러 클래스(유저)
package nbc_final.gathering.domain.example.attachment.controller;
import lombok.RequiredArgsConstructor;
import nbc_final.gathering.common.dto.AuthUser;
import nbc_final.gathering.common.exception.ApiResponse;
import nbc_final.gathering.domain.example.attachment.dto.AttachmentResponseDto;
import nbc_final.gathering.domain.example.attachment.service.UserAttachmentService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
@RestController
@RequestMapping("/api/v1")
@RequiredArgsConstructor
public class UserAttachmentController {
private final UserAttachmentService attachmentService;
/**
* 사용자 프로필 이미지를 업로드
*
* @param authUser 인증된 사용자 정보
* @param file 업로드할 이미지 파일 (RequestPart로 전달)
* @return 업로드된 파일의 URL을 포함한 응답
* @throws IOException 파일 처리 중 발생할 수 있는 예외
*/
@PostMapping("/users/upload/userFile")
public ResponseEntity<?> userUploadFile(
@AuthenticationPrincipal AuthUser authUser,
@RequestPart("file") MultipartFile file
) throws IOException {
AttachmentResponseDto responseDto = attachmentService.userUploadFile(authUser, file);
return ResponseEntity.ok(ApiResponse.createSuccess(responseDto));
}
/**
* 사용자 프로필 이미지를 수정
*
* @param authUser 인증된 사용자 정보
* @param file 수정할 이미지 파일 (RequestPart로 전달)
* @return 수정된 파일의 URL을 포함한 응답
* @throws IOException 파일 처리 중 발생할 수 있는 예외
*/
@PutMapping("/users/uploadUpdate/userFile")
public ResponseEntity<?> userUpdateFile(
@AuthenticationPrincipal AuthUser authUser,
@RequestPart("file") MultipartFile file
) throws IOException {
AttachmentResponseDto responseDto = attachmentService.userUpdateFile(authUser, file);
return ResponseEntity.ok(ApiResponse.createSuccess(responseDto));
}
/**
* 사용자 프로필 이미지를 삭제
*
* @param authUser 인증된 사용자 정보
* @return 응답 없이 No Content 상태 반환
*/
@DeleteMapping("/users/delete/userFile")
public ResponseEntity<?> userDeleteFile(
@AuthenticationPrincipal AuthUser authUser
) {
attachmentService.userDeleteFile(authUser); // 파일 삭제 메서드 호출
return ResponseEntity.noContent().build();
}
}
왜 파일 받아오는 어노테이션을 @Requestparam이 아니라 @Requestpart로 가져왔나요?
일단 @Requestparam을 사용하지 않은 이유는 @Requestparam은 기본적으로 문자열이나 단순한 값 타입으로 처리가 가능하기 때문에 파일과 같은 바이너리 데이터를 직접처리하기에 한계가 있습니다. 또한 대부분 웹 사이트에서 프로필 이미지 등록할때 문자열로 즉 URL로 등록하는것이 아닌 파일(jpg, png등등)으로 등록하기에 파일 업로드 시에는 @Requestpart를 사용하여 멀티파트 요청의 파일 데이터를 처리하게 하였습니다.
package nbc_final.gathering.domain.example.attachment.service;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.ObjectMetadata;
import io.jsonwebtoken.io.IOException;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import nbc_final.gathering.common.dto.AuthUser;
import nbc_final.gathering.common.exception.ResponseCode;
import nbc_final.gathering.common.exception.ResponseCodeException;
import nbc_final.gathering.domain.example.attachment.dto.AttachmentResponseDto;
import nbc_final.gathering.domain.example.attachment.entity.Attachment;
import nbc_final.gathering.domain.example.attachment.repository.AttachmentRepository;
import nbc_final.gathering.domain.user.entity.User;
import nbc_final.gathering.domain.user.repository.UserRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.util.Arrays;
import java.util.List;
@Service
@RequiredArgsConstructor
public class UserAttachmentService {
private final AmazonS3 amazonS3;
private final AttachmentRepository attachmentRepository;
private final UserRepository userRepository;
@Value("${cloud.aws.s3.bucket}")
private String bucketName;
// 지원되는 파일 형식과 크기 제한
private static final List<String> SUPPORTED_FILE_TYPES = Arrays.asList("image/jpeg", "image/png", "image/jpg");
private static final long MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
// 유저 프로필 등록
@Transactional
public AttachmentResponseDto userUploadFile(AuthUser authUser, MultipartFile file) throws IOException, java.io.IOException {
validateFile(file);
User user = userRepository.findById(authUser.getUserId())
.orElseThrow(() -> new ResponseCodeException(ResponseCode.NOT_FOUND_USER));
String fileUrl = uploadToS3(file);
user.setProfileImagePath(fileUrl);
userRepository.save(user);
Attachment attachment = saveUserAttachment(authUser, fileUrl);
return new AttachmentResponseDto(attachment);
}
// 유저 이미지 수정
@Transactional
public AttachmentResponseDto userUpdateFile(AuthUser authUser, MultipartFile file) throws IOException, java.io.IOException {
validateFile(file);
// User 엔티티 조회
User user = userRepository.findById(authUser.getUserId())
.orElseThrow(() -> new ResponseCodeException(ResponseCode.NOT_FOUND_USER));
// 기존 Attachment 조회 및 삭제
Attachment existingAttachment = attachmentRepository.findByUser(authUser);
if (existingAttachment != null) {
deleteFromS3(existingAttachment.getProfileImagePath());
attachmentRepository.delete(existingAttachment);
}
// 새로운 파일 업로드 및 Attachment 저장
String fileUrl = uploadToS3(file);
// User 엔티티 업데이트
user.setProfileImagePath(fileUrl);
userRepository.save(user);
AttachmentResponseDto responseDto = userUploadFile(authUser, file);
return responseDto;
}
// 유저 이미지 삭제
@Transactional
public void userDeleteFile(AuthUser authUser) {
// 기존 파일 찾기
Attachment existingAttachment = attachmentRepository.findByUser(authUser);
if (existingAttachment != null) {
deleteFromS3(existingAttachment.getProfileImagePath());
attachmentRepository.delete(existingAttachment);
}
}
// 유저 이미지 예외처리
private void validateFile(MultipartFile file) {
if (!SUPPORTED_FILE_TYPES.contains(file.getContentType())) {
throw new IllegalArgumentException("지원하지 않는 파일 형식입니다.");
}
if (file.getSize() > MAX_FILE_SIZE) {
throw new IllegalArgumentException(("파일 크기가 너무 큽니다."));
}
}
// 유저 삭제 메서드
private void deleteFromS3(String fileUrl) {
String fileName = fileUrl.substring(fileUrl.lastIndexOf("/") + 1);
amazonS3.deleteObject(bucketName, fileName);
}
// s3 업로드 메서드
private String uploadToS3(MultipartFile file) throws IOException, java.io.IOException {
String fileName = file.getOriginalFilename();
String fileUrl = "https://" + bucketName + "/profile-images/" + fileName;
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentType(file.getContentType());
metadata.setContentLength(file.getSize());
amazonS3.putObject(bucketName, fileName, file.getInputStream(), metadata);
return fileUrl;
}
// 유저 첨부파일 저장 메서드
private Attachment saveUserAttachment(AuthUser authUser, String fileUrl) {
// userId를 이용해 User 엔티티를 조회
User user = userRepository.findById(authUser.getUserId())
.orElseThrow(() -> new ResponseCodeException(ResponseCode.NOT_FOUND_USER));
Attachment attachment = new Attachment();
attachment.setUser(user);
attachment.setProfileImagePath(fileUrl);
attachmentRepository.save(attachment);
return attachment;
}
}
각각 메서드의 역할
유저 프로필 등록(userUploadFile)
@Transactional
public AttachmentResponseDto userUploadFile(AuthUser authUser, MultipartFile file) throws IOException {
validateFile(file);
User user = userRepository.findById(authUser.getUserId())
.orElseThrow(() -> new ResponseCodeException(ResponseCode.NOT_FOUND_USER));
String fileUrl = uploadToS3(file);
user.setProfileImagePath(fileUrl);
userRepository.save(user);
Attachment attachment = saveUserAttachment(authUser, fileUrl);
return new AttachmentResponseDto(attachment);
}
기능: 사용자의 프로필 이미지를 업로드하고 관련 정보를 저장합니다.
동작 순서
1. 파일 유효성 검사: validateFile(file)을 호출하여 파일 형식과 크기를 검증합니다.
2. 사용자 조회: authUser의 userId를 통해 User 엔티티를 조회합니다.
3. 파일 업로드: uploadToS3(file)을 호출하여 S3에 파일을 업로드하고, 파일 URL을 반환받습니다.
4. 사용자 정보 업데이트: user의 profileImagePath를 업데이트하고 저장합니다.
5. 첨부파일 정보 저장: saveUserAttachment(authUser, fileUrl)을 호출하여 Attachment 엔티티를 저장합니다.
6. 응답 반환: AttachmentResponseDto를 생성하여 반환합니다.
유저 이미지 수정 (userUpdateFile)
@Transactional
public AttachmentResponseDto userUpdateFile(AuthUser authUser, MultipartFile file) throws IOException {
validateFile(file);
// User 엔티티 조회
User user = userRepository.findById(authUser.getUserId())
.orElseThrow(() -> new ResponseCodeException(ResponseCode.NOT_FOUND_USER));
// 기존 Attachment 조회 및 삭제
Attachment existingAttachment = attachmentRepository.findByUser(authUser);
if (existingAttachment != null) {
deleteFromS3(existingAttachment.getProfileImagePath());
attachmentRepository.delete(existingAttachment);
}
// 새로운 파일 업로드 및 Attachment 저장
String fileUrl = uploadToS3(file);
// User 엔티티 업데이트
user.setProfileImagePath(fileUrl);
userRepository.save(user);
AttachmentResponseDto responseDto = userUploadFile(authUser, file);
return responseDto;
}
기능: 기존 프로필 이미지를 삭제하고 새로운 이미지로 업데이트합니다.
동작 순서:
1. 파일 유효성 검사: validateFile(file)을 호출하여 파일을 검증합니다.
2. 사용자 조회: User 엔티티를 조회합니다.
3. 기존 파일 삭제:
- attachmentRepository.findByUser(authUser)로 기존 Attachment를 조회합니다.
- 기존 파일이 있으면 deleteFromS3로 S3에서 파일을 삭제하고, attachmentRepository.delete(existingAttachment)로 엔티티를 삭제합니다.
4. 새로운 파일 업로드 및 저장:
- uploadToS3(file)로 새로운 파일을 업로드합니다.
- 사용자 프로필 이미지를 업데이트하고 저장합니다.
- userUploadFile(authUser, file)을 호출하여 새로운 첨부파일 정보를 저장하고 응답을 반환합니다.
유저 이미지 삭제 (userDeleteFile)
@Transactional
public void userDeleteFile(AuthUser authUser) {
// 기존 파일 찾기
Attachment existingAttachment = attachmentRepository.findByUser(authUser);
if (existingAttachment != null) {
deleteFromS3(existingAttachment.getProfileImagePath());
attachmentRepository.delete(existingAttachment);
}
}
기능: 사용자의 프로필 이미지를 삭제합니다.
동작 순서:
1. 기존 파일 조회: attachmentRepository.findByUser(authUser)로 기존 첨부파일을 조회합니다.
2. 파일 및 엔티티 삭제:
- 파일이 존재하면 deleteFromS3로 S3에서 파일을 삭제합니다.
- attachmentRepository.delete(existingAttachment)로 데이터베이스에서 엔티티를 삭제합니다.
유틸리티 메서드 분석
파일 유효성 검사 (validateFile)
private void validateFile(MultipartFile file) {
if (!SUPPORTED_FILE_TYPES.contains(file.getContentType())) {
throw new ResponseCodeException(ResponseCode.NOT_SERVICE);
}
if (file.getSize() > MAX_FILE_SIZE) {
throw new ResponseCodeException(ResponseCode.TOO_LARGE_SIZE_FILE);
}
}
기능: 업로드된 파일의 형식과 크기를 검증합니다.
검증 내용
- 지원되는 파일 형식인지 확인합니다.
- 파일 크기가 최대 크기를 초과하지 않는지 확인합니다.
예외 처리: 조건에 맞지 않으면 커스텀 어노테이션인 ResponseCodeException을 발생시킵니다.
S3에서 파일 삭제 (deleteFromS3)
private void deleteFromS3(String fileUrl) {
String fileName = fileUrl.substring(fileUrl.lastIndexOf("/") + 1);
amazonS3.deleteObject(bucketName, fileName);
}
기능: S3 버킷에서 특정 파일을 삭제합니다.
동작
1. 파일 URL에서 파일명을 추출합니다.
2. amazonS3.deleteObject를 호출하여 파일을 삭제합니다.
S3에 파일 업로드 (uploadToS3)
private String uploadToS3(MultipartFile file) throws IOException {
String fileName = file.getOriginalFilename();
String fileUrl = "https://" + bucketName + "/profile-images/" + fileName;
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentType(file.getContentType());
metadata.setContentLength(file.getSize());
amazonS3.putObject(bucketName, fileName, file.getInputStream(), metadata);
return fileUrl;
}
기능: 파일을 S3에 업로드하고 파일 URL을 반환합니다.
동작
1. 파일명을 얻고, 파일 URL을 생성합니다.
2. 파일의 메타데이터를 설정합니다.
3. amazonS3.putObject를 통해 파일을 업로드합니다.
4. 업로드된 파일의 URL을 반환합니다.
다만 여기서 좀 더 보완해야 할 것은 파일명 중복 문제를 해결해야되기 때문에 UUID를 통해 고유한 값을 부여하는방식으로 수정할 계획이다.
유저 첨부파일 저장 (saveUserAttachment)
private Attachment saveUserAttachment(AuthUser authUser, String fileUrl) {
// userId를 이용해 User 엔티티를 조회
User user = userRepository.findById(authUser.getUserId())
.orElseThrow(() -> new ResponseCodeException(ResponseCode.NOT_FOUND_USER));
Attachment attachment = new Attachment();
attachment.setUser(user);
attachment.setProfileImagePath(fileUrl);
attachmentRepository.save(attachment);
return attachment;
}
기능: 첨부파일 정보를 데이터베이스에 저장합니다.
동작
1. User 엔티티를 조회합니다.
2. 새로운 Attachment 객체를 생성하고, 사용자와 파일 경로를 설정합니다.
3. attachmentRepository.save(attachment)로 엔티티를 저장합니다.
4. 저장된 Attachment 객체를 반환합니다.
그래서 해당 클래스의 전체적인 흐름이 어떻게 되나?
프로필 이미지 업로드 경우
- 파일 유효성 검사를 수행합니다.
- 사용자 정보를 조회합니다.
- 파일을 S3에 업로드하고 파일 URL을 받습니다.
- 사용자 엔티티의 프로필 이미지 경로를 업데이트하고 저장합니다.
- 첨부파일 정보를 데이터베이스에 저장합니다.
- 응답 DTO를 생성하여 반환합니다.
프로필 이미지 수정
- 기존에 업로드된 파일이 있으면 삭제합니다.
- 새로운 파일을 업로드하고 정보를 갱신합니다.
프로필 이미지 삭제
- 첨부 파일정보를 조회하고, 존재하면 S3와 데이터베이스에서 삭제합니다.
그래서 post로 이미지 등록을 하였을때 attachment_id값에 부여받고 Authuser를 통해 받은 user_id값에 해당하는 유저에게 form-data로 받은 이미지를 1번 유저에게 할당이 된다.
'우리 지금 만나' 카테고리의 다른 글
소모임 단건 조회에 RedisLimiter를 적용 후 Jmeter 테스트-(6)(우리 지금 만나) (0) | 2024.10.30 |
---|---|
소모임 다건 목록 Redis 적용 후 Jmeter 테스트 정리-(5)(우리 지금 만나) (0) | 2024.10.30 |
인프라 난항-(4)(우리 지금 만나) (2) | 2024.10.27 |
인프라 설계 -(3)(우리 지금 만나) (8) | 2024.10.23 |
프로젝트 초안 작성-(1)(우리 지금 만나) (8) | 2024.10.21 |