[SpringBoot] AWS S3를 이용한 프로필 이미지 업로드 로직 구현기
1. ProfileImage 설계
- 사용자는 하나의 프로필 사진을 갖는다. → OneToOne
- ProfileImage에서 사용자를 참조할 일은 없다 → 단방향
- S3에서 객체를 삭제하기 위해서는 버킷에 저장된 이름이 필요하다.
- S3에서 제공하는 객체 url로 이미지에 접근한다.
``객체 url``과 함께 저장할 ``버킷에 저장된 이름`` 정보가 필요하므로 해당 정보를 담은 테이블을 추가하기로 결정했다.
✓ ProfileImage 엔티티
다음과 같이 ProfileImage 엔티티를 추가해주었다.
@Getter
@Entity
@NoArgsConstructor
@Table(name = "profile_images")
public class ProfileImage {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String storedFileName;
private String storedFileUrl;
@Builder
public ProfileImage(String storedFileName, String storedFileUrl) {
this.storedFileName = storedFileName;
this.storedFileUrl = storedFileUrl;
}
}
✓ User 엔티티
User 엔티티에는 다음과 같이 프로필 이미지 정보를 추가해주었다.
@Getter
@Entity
@NoArgsConstructor
@Table(name = "users")
public class User extends Timestamped {
// 생략 ..
@OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "profile_image_id")
private ProfileImage profileImage;
// 생략 ..
public void updateProfileImage(ProfileImage profileImage) {
this.profileImage = profileImage;
}
public void deleteProfileImage() {
this.profileImage = null;
}
}
🔽 fetch = FetchType.LAZY 설정
기본적으로 OneToOne은 EAGER loading이 디폴트로, 연관된 엔티티를 같이 로딩한다.
- 사용자의 프로필 조회를 제외하고는 프로필 이미지 정보가 필요하지 않은 경우가 대부분이다.
- User 데이터를 조회할 때마다 프로필 이미지 테이블을 조인하여 가져오는 것은 불필요하다.
위와 같은 이유로 Lazy 로딩이 되도록 하였고, 프로필 이미지와 함께 User 데이터를 가져오는 레포지토리 메서드를 따로 생성했다.
// UserRepository.java
@EntityGraph(attributePaths = {"profileImage"})
@Query("select u from User u where u.id = :id")
Optional<User> findByIdWithProfileImage(@Param("id") Long id);
🔽 CascadeType.All, orphanRemoval=true 설정
Cascade, orphanRemoval에 대해 모른다면 여기로
- ProfileImage에는 User 정보가 없다, 둘 사이의 결정권은 오직 User가 가진다.
- ProfileImage는 User에게만 귀속된다.
- User쪽에서 모든 작업을 처리해주어야 하므로 위처럼 설정하였다.
2. AWS S3 연결
의존성을 추가한다.
// build.gradle
dependencies {
// 생략 ...
// AWS S3
implementation 'software.amazon.awssdk:s3:2.20.134'
}
application.properties에 접근 사용자 정보와 bucket 정보를 명시한다.
// application-local.properties
# AWS S3
aws.s3.accessKey=<accessKey>
aws.s3.secretKey=<secretKey>
aws.s3.bucket=<bucket 이름>
aws.s3.region=ap-northeast-2
명시한 정보를 바탕으로 S3Client를 생성해서 빈으로 등록한다.
@Configuration
@RequiredArgsConstructor
public class S3Config {
@Value("${aws.s3.accessKey}")
private String accessKey;
@Value("${aws.s3.secretKey}")
private String secretKey;
@Value("${aws.s3.region}")
private String region;
@Bean
public S3Client s3Client() {
return S3Client.builder()
.region(Region.of(region))
.credentialsProvider(
StaticCredentialsProvider
.create(AwsBasicCredentials.create(accessKey, secretKey)))
.build();
}
}
3. S3 업로드 모듈 작성
목표
- 스토리지 서비스 변경 가능성을 고려한 설계
- AWS S3 뿐 아니라 다른 스토리지 서비스로 쉽게 변경할 수 있도록 설계하기
- 특정 플랫폼에 종속되지 않도록 인터페이스 기반 추상화 적용하기
위 목표를 위해 ImageStorageProvider 인터페이스를 만들고, S3Provider가 이를 구현하도록 작성하였다.
public interface ImageStorageProvider {
ImageUploadResult uploadFile(MultipartFile file, String path);
void deleteFile(String fileName);
}
@Slf4j
@Service
@RequiredArgsConstructor
public class S3Provider implements ImageStorageProvider {
private final S3Client s3Client;
@Value("${aws.s3.bucket}")
private String bucketName;
@Value("${aws.s3.region}")
private String region;
@Override
public ImageUploadResult uploadFile(MultipartFile file, String path) {
// 업로드 로직
}
@Override
public void deleteFile(String fileName) {
// 삭제 로직
}
}
4. 서비스 로직 작성
@Service
@RequiredArgsConstructor
public class UserProfileService {
private final UserRepository userRepository;
private final ImageStorageProvider imageStorageProvider;
@Transactional(readOnly = true)
public UserProfileResponse getUserProfile(Long userId) {
User user = getUserById(userId);
return new UserProfileResponse(
user.getId(),user.getEmail(),
user.getNickname(),
user.getProfileImageUrl() // NPE 방지
);
}
@Transactional
public void uploadProfileImage(Long userId, MultipartFile file) {
User user = getUserById(userId);
if(user.getProfileImage() != null) {
throw new InvalidRequestException("Profile image already exists");
}
ImageUploadResult uploadResult = imageStorageProvider.uploadFile(file, FilePath.PROFILE_FILE_PATH);
user.updateProfileImage(new ProfileImage(uploadResult.getStoredFileName(), uploadResult.getStoredFileUrl()));
}
@Transactional
public void deleteProfileImage(Long userId) {
User user = getUserById(userId);
if(user.getProfileImage() == null) {
throw new InvalidRequestException("Profile image does not exist");
}
imageStorageProvider.deleteFile(user.getProfileImage().getStoredFileName());
user.deleteProfileImage();
}
private User getUserById(Long userId) {
return userRepository.findByIdWithProfileImage(userId)
.orElseThrow(() -> new InvalidRequestException("User not found"));
}
}
✓ User 엔티티에 getProfileImageUrl() 추가
- User에는 profileImage 필드가 있고, 사용자가 설정한 프로필 이미지 없을 경우에 profileImage 필드에는 null이 들어간다.
- profileImage로부터 url을 가져오려면 `getStoredFileUrl()`을 해주어야 한다.
- 이 때 프로필 이미지가 없을 경우엔 null을 참조하게 되면서 NullPointerException이 발생할 수 있다.
- 따라서 User에 getProfileImageUrl() 메서드를 추가해주었다.
// User.java
public String getProfileImageUrl() {
if(this.profileImage == null) {
return null;
}
return this.profileImage.getStoredFileUrl();
}
✓ ImageUploadResult DTO의 생성
문제 상황
- 파일명 중복을 방지하기 위해 uniqe한 prefix를 파일명 앞에 붙여서 버킷에 저장하기로 하였다.
- 초기 ImageStorageProvider의 uploadFile 메서드는 파일 저장 후 url을 반환하도록 설계하였다.
public interface ImageStorageProvider {
// 이렇게 말이다
String uploadFile(MultipartFile file, String path);
void deleteFile(String fileName);
}
그런데 ProfileImage를 저장하기 위해서는 url과 함께 버킷에 저장된 file의 이름 정보가 함께 필요하다.
이 때문에 초기 UserProfileService는 unique한 filename를 직접 생성해서 들고 있어야 하는 구조였다.
// UserProfileService.java
// 이렇게 말이다 ..
private ProfileImage uploadProfileImageToStorage(MultipartFile file) {
String uniquePrefix = UUID.randomUUID() + "_";
String storedFileName = FilePath.PROFILE_FILE_PATH + uniquePrefix + file.getOriginalFilename();
String storedFileUrl = imageStorageProvider.uploadFile(
file,
FilePath.PROFILE_FILE_PATH + uniquePrefix
);
return ProfileImage.builder()
.storedFileName(storedFileName)
.storedFileUrl(storedFileUrl)
.build();
}
이처럼 파일 이름 생성의 책임이 Service 계층으로 옮겨와버렸다. 다음과 같은 문제점이 있다.
- 역할 분리 책임이 어긋난다.
- 다른 기능이 추가되어 다른 곳에서 업로드를 구현해야할 때마다 매번 파일 이름을 생성하는 로직을 Service가 또 가져가야 한다.
파일명을 만드는 로직을 S3Provider 내에 옮기고, Service에서는 해당 정보만 얻어오도록 리팩토링이 필요하다.
🛠️ 리팩토링
이 문제를 해결하기 위해, ImageStorageProvider의 책임을 확장했다.
public interface ImageStorageProvider {
ImageUploadResult uploadFile(MultipartFile file, String path);
void deleteFile(String fileName);
}
@Getter
public class ImageUploadResult {
private final String storedFileName;
private final String storedFileUrl;
public ImageUploadResult(String storedFileName, String storedFileUrl) {
this.storedFileName = storedFileName;
this.storedFileUrl = storedFileUrl;
}
}
- `ImageUploadResult` DTO를 생성해서 파일 업로드시 저장된 파일명과 url을 함께 반환하도록 했다.
개선된 S3Provider 전체 코드
@Slf4j
@Service
@RequiredArgsConstructor
public class S3Provider implements ImageStorageProvider {
private final S3Client s3Client;
@Value("${aws.s3.bucket}")
private String bucketName;
@Value("${aws.s3.region}")
private String region;
@Override
public ImageUploadResult uploadFile(MultipartFile file, String path) {
String uniqueFileName = generateUniqueFileName(file.getOriginalFilename());
String storedFileName = path + uniqueFileName;
try {
uploadToS3(file, storedFileName);
return new ImageUploadResult(storedFileName, getFileUrl(storedFileName));
} catch (IOException e) {
throw new ServerException("Failed to upload file.");
} catch (S3Exception e) {
log.error(e.awsErrorDetails().errorMessage());
throw new ServerException("Failed to upload file.");
}
}
private void uploadToS3(MultipartFile file, String fileName) throws IOException {
Map<String, String> metadata = new HashMap<>();
metadata.put("Content-Type", file.getContentType());
s3Client.putObject(
PutObjectRequest.builder()
.bucket(bucketName)
.key(fileName)
.metadata(metadata)
.contentType(file.getContentType())
.build(),
RequestBody.fromBytes(file.getBytes())
);
}
@Override
public void deleteFile(String fileName) {
try {
s3Client.deleteObject(
DeleteObjectRequest.builder()
.bucket(bucketName)
.key(fileName)
.build()
);
} catch (S3Exception e) {
log.error(e.awsErrorDetails().errorMessage());
throw new ServerException("Failed to delete file.");
}
}
private String generateUniqueFileName(String originalFilename) {
if(!StringUtils.hasText(originalFilename)) {
throw new IllegalArgumentException("File name cannot be null or empty.");
}
return UUID.randomUUID() + "_" + originalFilename;
}
private String getFileUrl(String fileName) {
return String.format("https://%s.s3.%s.amazonaws.com/%s",
bucketName,
region,
fileName);
}
}
- 파일 이름 생성의 책임이 S3Provider 내부로 이동
- 업로드 결과는 DTO로 포장되어 반환된다.
- Service는 더 이상 파일 이름을 직접 생성할 필요가 없다.
Service 내 사용 코드
@Transactional
public void uploadProfileImage(Long userId, MultipartFile file) {
User user = getUserById(userId);
if(user.getProfileImage() != null) {
throw new InvalidRequestException("Profile image already exists");
}
ImageUploadResult uploadResult = imageStorageProvider.uploadFile(file, FilePath.PROFILE_FILE_PATH);
user.updateProfileImage(new ProfileImage(uploadResult.getStoredFileName(), uploadResult.getStoredFileUrl()));
}
- 비즈니스 로직만 처리하도록 깔끔하게 바뀌었다
5. 컨트롤러 작성 & 포스트맨 테스트
@RestController
@RequestMapping("/users/profile")
@RequiredArgsConstructor
public class UserProfileController {
private final UserProfileService userProfileService;
@GetMapping("/{userId}")
public ResponseEntity<UserProfileResponse> getUserProfile(
@PathVariable Long userId
) {
return ResponseEntity.ok(userProfileService.getUserProfile(userId));
}
@PostMapping("/image")
public ResponseEntity<Void> uploadProfileImage(
@AuthenticationPrincipal AuthUser authUser,
@RequestParam("file") MultipartFile file
) {
userProfileService.uploadProfileImage(authUser.getId(), file);
return ResponseEntity.ok().build();
}
@DeleteMapping("/image")
public ResponseEntity<Void> deleteProfileImage(
@AuthenticationPrincipal AuthUser authUser
) {
userProfileService.deleteProfileImage(authUser.getId());
return ResponseEntity.ok().build();
}
}
✓ 테스트 결과



해당 url을 타고 들어가면 ...


👀 S3에 orphan 이미지가 남는 문제는 어떻게 처리할까?
S3내에 불필요한 파일이 남지 않도록 정리하는 로직을 구현할 필요성을 느꼈다.
- S3에 업로드는 성공하였으나, DB에 ProfileImage 정보를 저장하는 데 실패한 경우
- User 삭제 시 orphanRemoval에 의해 DB에서만 ProfileImage가 삭제되고, S3 파일은 여전히 존재하는 경우
위와 같은 예외상황이 반복되면 실제 사용하지 않은 이미지 파일들이 S3에 차곡차곡 쌓이게 되고 결과적으로 불필요한 스토리지 비용이 발생하게 된다.
여러 방법들을 리서치해보았는데 현제 과제 프로젝트 내의 프로필 이미지 로직은 매우매우 간단해서 아주 단순한 비교 로직만으로 해결할 수 있을 것 같다.
- DB에 저장된 모든 `storedFileName` 조회
- S3 버킷에서 profile/ 경로에 저장된 모든 파일 이름 조회
- DB에는 없고, S3에만 있는 파일들 삭제 처리
해당 로직을 매일 정각 혹은 사용자 이용률이 가장 적은 시간에 배치 작업을 통해 돌리면 될 것으로 예상된다 << 아마두 ...
시간 상 현재 해당 부분까지 구현해보진 못 했지만 추후 구현해 볼 예정이다
또, 다음 프로젝트에서 S3 업로드 로직을 구현할 일이 생긴다면 관련된 내용을 더 깊게 공부해보고싶다 !!
