본문 바로가기
프로젝트/토이 프로젝트

Spring + Redis + S3 + 이메일 인증 + Docker + CI/CD - 7

by 진꿈청 2024. 2. 26.

이번 포스팅에서는 AWS S3에 사용자의 프로필 이미지를 CRUD(?)한 과정을 담는다.

 

AWS S3가 뭘까?

AWS Simple Storage Service의 줄임말로 Object Storage 개념이 적용되었다고 생각하면 된다.

파일을 저장하고 불러오는 작업이 RESTful API를 통해 간단하고 뛰어난 보안성을 자랑한다.

 

장점

  • 거의 무제한에 가까운 용량이다.(물론 용량 제한도 설정할 수 있다.)
  • 보안성이 아주 좋다.
  • 저렴한 비용
  • 플랫 구조를 사용한 빠른 탐색
  • 높은 객체 가용성


개인적으로 AWS S3는 학부연구생을 하며 Object Storage 개념의 Ceph에 관한 논문을 작성했기에 친숙했다.

 

Object Storage 관련해서는 아래 포스팅을 참고 바란다.

https://hdbstn3055.tistory.com/23

 

Ceph란 무엇인가? (1)

지난해 Ceph와 관련된 논문을 작성하였고 관련 내용을 까먹지 않기 위해 정리하려 한다. 우선, Object/Block/File Storage에 대해 간략히 설명한다. Object Storage Ceph를 설명하기에 앞서 우리는 먼저 Object St

hdbstn3055.tistory.com

 

AWS S3 버킷 생성

 

1. 우선, AWS에 접속하여 S3를 검색한다.

 

S3 검색

 

 

2. 그 후, 버킷 만들기 클릭.

 

 

3. 그 후, 서울로 설정한 뒤 아래처럼 설정한다.

 

실제 실무에서는 퍼블릭 액세스 차단 설정을 조심해야 한다.

잘 읽어보고 판단.

 

 

4. 생성된 버킷 클릭.

 

5. 접속한 뒤 상위에 객체/속성/권한/... 중에 권한을 클릭한 후 버킷 정책 편집을 누른다.

 

그러면 아래와 같은 화면이 나온다.

 

우리는 단순 이미지 CRUD만 할 것이기 때문에 오른쪽 서비스 선택에서

"GetObject", "DeleteObject", "PutObject"만 선택해준다.

 

이때,
Effect는 접근하는 사람을 선택할지 말지를 고르는 것으로 Allow(모두 허용), Deny(선택해서 받음)을 의미한다.

 

Principal는 접근할 수 있는 사람을 말하는 것으로 *(모두), 사람 명시(그 사람만)을 의미한다.

 

필자는 아래와 같이 설정했다.

 

{
	"Version": "2012-10-17",
	"Statement": [
		{
			"Sid": "Statement1",
			"Effect": "Allow",
			"Principal": "*",
			"Action": [
				"s3:GetObject",
				"s3:DeleteObject",
				"s3:PutObject"
			],
			"Resource": "arn:aws:s3:::{버킷 이름}/*"
		}
	]
}

 

 

6. 저장하고 나온 뒤 IAM 검색 및 접속 후 사용자 생성.

 



적절한 사용자 이름을 작성하고 직접 정책 연결을 누른다.

그 후, AmazonS3FullAccess(전체 액세스를 허용하는 권한)를 검색한다.

 

7. 생성한 계정을 클릭한 뒤 보안 자격 증명 탭에가 액세스 키를 생성한다.

 

이때, 받게 되는 AccessKey와 SecretKey는 잘 저장해 놓도록 하자.

 

 

이미지 업로드를 위한 클래스 및 환경설정

 

build.gradle

// AWS S3 설정
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'

 

AWS S3를 사용하기 위해 관련 의존성을 설정해준다.

 

application.yml

 

cloud:
  aws:
    s3:
      bucket: {버킷 이름}
    region:
      static: ap-northeast-2
      auto: false
    stack:
      auto: false
    key:
      access-key: {AccessKey}
      secret-key: {SecretKey}

 

Credentials 정보는 AWS CLI를 통해서 EC2에 등록 가능하기도 하다.(관련 내용 아래 링크 참고)

https://kim-jong-hyun.tistory.com/131

 

AWS의 accessKey, secretAccessKey를 안전하게 관리하기

Spring으로 웹 애플리케이션 개발을 진행하다보면 이미지, 파일 등 정적 리소스를 저장할 저장소가 필요하게 된다. 가장 많이 쓰여지는 것 중 하나가 AWS에서 제공하는 S3가 있는데 이걸 이용하려

kim-jong-hyun.tistory.com

 

 

SpringBootApplication 클래스

 

@SpringBootApplication
public class SpringtoyApplication {

    static{
        System.setProperty("com.amazonaws.sdk.disableEc2Metadata", "true"); // 설정 추가
    }

    public static void main(String[] args) {
        SpringApplication.run(SpringtoyApplication.class, args);
    }

}

 

아래 에러를 방지하기 위함이다.

com.amazonaws.util.EC2MetadataUtils : Unable to retrieve the requested metadata (/latest/meta-data/instance-id). EC2 Instance Metadata Service is disabled

 

 

FileUploadService 클래스

 

userService와 소통하며 이미지를 S3에 업로드/삭제를 하는 클래스이다.

  • 이미지를 S3에 업로드하고 해당 URL를 반환한다.
  • 이미지를 S3에서 삭제한다.
  • 이미지를 저장된 DB에 업데이트한다.
  • 확장자가 jpg, png인 이미지만 업로드가 가능하다.
  • 객체지향적으로 user의 image 리스트에 추가하면 연쇄적으로 저장된다.
@Service
@Slf4j
@RequiredArgsConstructor
@Transactional
public class FileUploadService {

    private final AmazonS3ResourceStorage amazonS3ResourceStorage;
    private final ImageRepository imageRepository;

    // File은 업로드 처리를 하고 imageUrl를 반환
    public List<ImageDto> save(User user, List<MultipartFile> multipartFiles){

        List<ImageDto> imageUrls = new ArrayList<>();
        for(MultipartFile multipartFile : multipartFiles){
            verifiedExtension(multipartFile);
            String fullPath = MultipartUtil.createPath(multipartFile);
            String imageUrl = amazonS3ResourceStorage.store(fullPath, multipartFile);
            imageUrls.add(new ImageDto(imageUrl));
            user.addImage(Image.createImage(user, imageUrl));
        }

        return imageUrls;
    }

    public ImageDto save(User user, MultipartFile multipartFile){
        verifiedExtension(multipartFile);
        String fullPath = MultipartUtil.createPath(multipartFile);
        String imageUrl = amazonS3ResourceStorage.store(fullPath, multipartFile);
        user.addImage(Image.createImage(user, imageUrl));
        return new ImageDto(imageUrl);
    }

    private void verifiedExtension(MultipartFile multipartFile) {
        String contentType = multipartFile.getContentType();

        // 확장자가 jpeg, png인 파일들만 받아서 처리
        if(ObjectUtils.isEmpty(contentType) | (!contentType.contains("image/jpeg") & !contentType.contains("image/png")))
            throw new BusinessLogicException(ErrorCode.EXTENSION_IS_NOT_VALID);
    }

    public String delete(User user, String fireUrl) throws SdkBaseException {
        amazonS3ResourceStorage.delete(fireUrl);

        Image image = imageRepository.findByImageUrl(fireUrl).orElseThrow(() -> new BusinessLogicException(ErrorCode.IMAGE_NOT_FOUND));
        user.getImages().remove(image);

        return "이미지 삭제 성공했습니다.";
    }

    public ImageDto update(MultipartFile multipartFile, String fireUrl) throws SdkBaseException{
        amazonS3ResourceStorage.delete(fireUrl);

        Image image = imageRepository.findByImageUrl(fireUrl).orElseThrow(() -> new BusinessLogicException(ErrorCode.IMAGE_NOT_FOUND));

        verifiedExtension(multipartFile);
        String fullPath = MultipartUtil.createPath(multipartFile);
        String imageUrl = amazonS3ResourceStorage.store(fullPath, multipartFile);

        image.setImageUrl(imageUrl);

        return new ImageDto(imageUrl);
    }

}

 

SAVE와 DELETE는 객체지향적으로 설계했지만.. 차마 UPDATE는 객체지향적으로 설계하지 못했다.
마무리하며에서 다룰 예정이다.

 

MulipartUtil

 

public class MultipartUtil {
    private static final String BASE_DIR = "images";

    // 로컬에서의 사용자 홈 디렉토리 경로를 반환
    // OS X의 경우 파일 시스템 쓰기 권한 문제로 인해 필히 사용자 홈 디렉토리 또는 쓰기가 가능한 경로로 설정
    public static String getLocalHomeDirectory() {
        return System.getProperty("user.home");
    }

    // 파일 고유 ID 생성
    public static String createFileId() {
        return UUID.randomUUID().toString();
    }

    // 확장자만 잘라냄
    public static String getFormat(String contentType) {
        if (StringUtils.hasText(contentType)) {
            return contentType.substring(contentType.lastIndexOf('/') + 1);
        }
        return null;
    }

    // 파일 경로 생성
    public static String createPath(MultipartFile multipartFile) {
        final String fileId = MultipartUtil.createFileId();
        final String format = MultipartUtil.getFormat(multipartFile.getContentType());
        return String.format("%s/%s.%s", BASE_DIR, fileId, format);
    }
}
  • getLocalHomeDirectory(): MultipartFile이 File로 전환되는 과정에서 디스크에 쓰여진다. 이때, 저장될 위치를 등록하는 것으로 위 설정이 없으면 파일 시스템 권한 문제가 발생할 수 있다.
    • 홈 디렉토리에 images 디렉토리를 설정해야 한다.
  • createFileId(): S3에 저장할 때 사용할 파일 고유 ID를 생성한다. 위 값은 Image 테이블에 유니크한 값으로 저장된다.
  • getFormat(): 확장자를 자를 때 사용되는 메서드이다.
  • createPath(): 위 내용을 종합한 전체 경로를 생성한다.

 

AmazonS3ResourceStorage

 

 

이미지를 S3에 업로드/삭제를 하는 실질적인 작업이 이뤄지는 클래스이다.

@Slf4j
@Component
@RequiredArgsConstructor
public class AmazonS3ResourceStorage {
    private final AmazonS3Client amazonS3Client;

    @Value("${cloud.aws.s3.bucket}")
    private String bucket;

    public String store(String fullPath, MultipartFile multipartFile){
        File file = new File(MultipartUtil.getLocalHomeDirectory(), fullPath);

        try{
            multipartFile.transferTo(file);
            amazonS3Client.putObject(new PutObjectRequest(bucket, fullPath, file));
        } catch(Exception e){
            log.error(e.getMessage());
            throw new BusinessLogicException(ErrorCode.FAILED_TO_UPLOAD_FILE);
        } finally {
            if(file.exists())
                removeNewFile(file); // 로컬에 있는 파일 삭제
        }

        return amazonS3Client.getUrl(bucket, fullPath).toString(); // S3에 업로드된 이미지 URL 반환
    }


    private void removeNewFile(File targetFile) {
        if (targetFile.delete()) log.info("The file has been deleted.");
        else {
            log.info("Failed to delete file.");
        }
    }

    public void delete(String fileUrl) {
        try {
            String key = fileUrl.substring(62);
            try {
                amazonS3Client.deleteObject(new DeleteObjectRequest(bucket, key));
            } catch (AmazonServiceException e) {
                log.error(e.getErrorMessage());
                exit(1);
            }
            log.info(String.format("[%s] deletion complete", key));
        } catch (Exception e) {
            throw new BusinessLogicException(ErrorCode.FAILED_TO_DELETE_FILE);
        }
    }

}
  • MultipartFile을 File 객체 형태로 변환할 때 파일이 복사되어 로컬에 저장된다. 따라서, 로컬에 있는 이미지 삭제가 필요하다.
  • store(): AmazonS3Client를 활용하여 이미지 저장
  • delete(): AmazonS3Client를 활용하여 이미지 삭제
  • removeNewFile(): 로컬에 저장되어 있는 파일을 삭제한다. 

 

UserController 클래스

 

Image는 실질적으로 User와 관련이 깊기 때문에 UserController가 전담한다.

@Slf4j
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {

    ...
    private final FileUploadService fileUploadService;

    ...


    @PostMapping("/images")
    public ResponseEntity setProfileImages(@AuthenticationPrincipal CustomUserDetails user,
                                        @RequestPart("file") List<MultipartFile> multipartFiles){

        List<ImageDto> response = userService.profileImagesSave(user.getEmail(), multipartFiles);

        return new ResponseEntity<>(new SingleResponseDto<>(response), HttpStatus.OK);
    }

    @PostMapping("/image")
    public ResponseEntity setProfileImage(@AuthenticationPrincipal CustomUserDetails user,
                                           @RequestPart("file") MultipartFile multipartFile){

        ImageDto response = userService.profileImageSave(user.getEmail(), multipartFile);

        return new ResponseEntity<>(new SingleResponseDto<>(response), HttpStatus.OK);
    }

    @DeleteMapping("/image")
    public ResponseEntity removeImage(@AuthenticationPrincipal CustomUserDetails user,
                                      @RequestParam("imageUrl") String imageUrl){
        String response = userService.delete(user.getEmail(), imageUrl);

        return new ResponseEntity<>(new SingleResponseDto<>(response), HttpStatus.OK);
    }

    @GetMapping("/images")
    public ResponseEntity findUserWithImages(@AuthenticationPrincipal CustomUserDetails user){
        List<ImageDto> response = userService.findUserWithImages(user.getEmail());

        return new ResponseEntity<>(new SingleResponseDto<>(response), HttpStatus.OK);
    }

    @PatchMapping("/image")
    public ResponseEntity updateProfileImage(@RequestPart("file") MultipartFile multipartFile,
                                             @RequestParam("imageUrl") String imageUrl){
        ImageDto response = userService.update(multipartFile, imageUrl);

        return new ResponseEntity<>(new SingleResponseDto<>(response), HttpStatus.OK);
    }
}

 

User의 프로필 사진을 등록하는 것이기 때문에 사실 MultipartFile의 리스트로 받을 필요는 없다.

하지만, 다중 이미지를 실험해보고 싶어서 구현해보았다.

 

@RequestPart

  • @RequestBody는 데이터 형식을 JSON 형태로 전달받기 때문에 만약 파일을 Body로 받게 된다면 원하는 결과를 얻기 힘들다.
  • @RequestParm도 기본적으로 문자열 데이터 처리하는데 사용.
  • 따라서, @RequestPart를 활용해 온전한 미디어 파일을 받아오는 것이 좋다.
  • @RequestPart는 HTTP request body에 multipart/form-data가 포함되어 있는 경우에 사용하는 어노테이션.
  • MultipartFile이 포함되어 있는 경우 MultipartResolver가 동작하여 역직렬화를 하게 된다.
  • 만약 MultipartFile이 포함되어 있지 않다면 @RequestBody와 마찬가지로 동작.

 

UserService 클래스

 

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserService {

    private final FileUploadService fileUploadService;

    public User findUserByEmail(String email){
       return userRepository.findUserByEmail(email).orElseThrow(NoSuchElementException::new);
    }
    
    ...


    public List<ImageDto> findUserWithImages(String email) {
        User user = userRepository.findUserWithImages(email).orElseThrow(() -> new BusinessLogicException(ErrorCode.USER_NOT_FOUND));

        List<ImageDto> result = user.getImages().stream()
                .map(i -> new ImageDto(i.getImageUrl()))
                .collect(Collectors.toList());

        return result;
    }

    @Transactional
    public ImageDto update(MultipartFile multipartFile, String imageUrl) {

        return fileUploadService.update(multipartFile, imageUrl);
    }

    @Transactional
    public ImageDto profileImageSave(String email, MultipartFile multipartFile) {
        User user = this.findUserByEmail(email);

        return fileUploadService.save(user, multipartFile);
    }

    @Transactional
    public List<ImageDto> profileImagesSave(String email, List<MultipartFile> multipartFiles) {
        User user = this.findUserByEmail(email);
        return fileUploadService.save(user, multipartFiles);
    }

    @Transactional
    public String delete(String email, String imageUrl) {
        User user = this.findUserByEmail(email);
        return fileUploadService.delete(user, imageUrl);
    }
}

 

FileUploadService와 소통하며 이미지 업로드/업데이트/삭제를 수행한다.

 

 

UserRepository

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    ...

    @Query("select u from User u join fetch u.images where u.email = :email")
    Optional<User> findUserWithImages(String email);
    
}

 

이미지를 fetch join으로 한 번에 읽어옴으로 N + 1 문제를 방지한다.

 

 

이로써 이미지를 AWS S3에 업로드하고 토이 프로젝트에 적용한 것을 끝마쳤다!!!

 

 

마무리하며

 

서론에서 말했듯이 Ceph를 잘 알고 있는 나에게 AWS S3는 그래도 좀 친숙했다.

물론, 사용이 친숙하다기 보다 개념이 친숙하다는 말..

 

관련 비즈니스 로직을 작성하며 꽤 오랜시간 동안 클린 코드와 객체지향적 설계에 대해서 고민을 했다.

 

User와 Image는 일대다의 관계이다. 따라서, 관계의 주인은 Image이다.

그러나, 실질적으로 User에서 많이 접속하게 된다.

 

그렇기에 Image에 대한 CRUD가 일어나도 User에서 관련 행동들을 해줘야 한다.

그렇다고 Image에서 처리하기에는 Image가 단독으로 처리하는 부분들이 없어 애매하다.

 

그래서 나는 User에서 Image를 다루는 것으로 트레이드 오프했다.

 

CREATE, DELETE를 객체지향적으로 설계했다.

User의 이미지 리스트에 addImage, removeImage의 방식으로 Cascade를 설정하여 처리했다.

하지만, 실제로 Cascade는 굉장히 위험하여 잘 사용하지 않는다고 한다.

이미지 리스트에서 remove하는 것도 결국 순차 탐색이기 때문에 리스트가 굉장히 길다면 이런 객체지향적 설계가 옳을까..?

난 아닌 것 같다...

 

 

그러나, 차마 UPDATE에 대해선 객체지향적으로 설계하고 싶지 않았다.

그냥 ImageRepository에서 Image를 읽어와 set메소드를 사용하면 쿼리 한 번으로 끝나는 것을

객체지향적 설계로 인해 해당 Image와 동일한 값을 찾기 위해 리스트를 순차 탐색하는 것도 맘에 들지 않고

결국 쿼리가 한 번더 나가기 때문에 도저히 이것만은 하고 싶지 않았다.

 

그래서, UPDATE는 그냥 User객체를 거치는 것이 아닌 직접 수정으로 하였다...

 

실제 실무에서는 이런 상황에 어떻게 적용하는지 궁금하다.

 

 

고작 토이 프로젝트지만 진행할수록 어떻게 해야 클린 코드가 될지, 객체지향적 설계가 될지 점점 더 고민하게 된다.

내 실력을 보면 배보다 배꼽이 더 크지만.. 뭐랄까 그냥 넘어가기에 내가 배운 것들을 부정당하는 느낌이라서 넘어가고 싶지 않다.

 

무섭다 무서워

 

다음 글부터 해서 아마 OAuth 설정을 하고 Spring Rest Docs, Postman, ERD 정리를 하고 슬슬 토이 프로젝트가 종료될 것 같다.

 

정처기 때문에 생각했던거보다 너무 늦어졌다.. 정처기 이후로는 하루종일 이것만 하고 있는데

 

다른 개념도 자세히 공부하면서 하니 오래걸린다..

 

 

그래도 화이팅!!