티스토리 뷰

기존 프로젝트의 요구 사항이 바뀌어 짐에 따라서 이미지를 저장하는 정책이 변경되는 이슈가 발생했습니다.

프로젝트에는 이미지를 저장, 변경하는 도메인들이 존재했고 프로필 이미지를 업데이트 하거나, 정보에 관련된 이미지를 업데이트 하는 서비스를 제공 중 이였습니다

 

따라서 추가로 발생할지 모르는 정책변경과 기존 정책의 활용 가능성으로 인해 imageService를 인터페이스로 공통화 하고 이미지를 사용하는 domain들을 공통 처리를 하는 과정을 작성해 보겠습니다.

 

저의 목표는 다음과 같습니다.

1. 이미지 사용 도메인들을 다형성을 사용하여 공통화

Q) 제네릭을 사용하면 안되는 가? 

A) imageService를 bean에 올려놓고 사용할 것이기 때문에 이는 불가 합니다.

 

추가적으로 이미지 서비스를 사용하지 않는 도메인들이 이미지 서비스를 사용하려고 한다면 컴파일 단계에서 에러를 잡아낼 수 있습니다.

 

BaseImageEntity 로 공통화

 

2. Spring 컨테이너를 통한 DI를 활용하여 imageService에 의존하는 클래스들이 코드 변경없이 유연하게 정책을 변경 할 수 있도록 만들자.

 

먼저 현재 도입하고자 하는 정책은 2가지 입니다. Local Server PC 에 저장, Amazon S3 에 저장.

 

이미지 서비스 정책

 

따라서 ImageService Interface 를 통해 2가지를 공통적으로 묶어 보겠습니다.

ImageService 에서 제공해야하는 내용은 다음과 같습니다.

1. Profile 사진에 대한 변경, 저장 (바이너리 파일을 클라이언트에서 받은 후 서버 or S3에 저장)

2. Info 사진에 대한 변경, 저장 (바이너리 파일을 클라이언트에서 받은 후 서버 or S3에 저장)

3. Profile 사진 가져오기 (URL 반환)

4. Info 사진 가져오기 (URL 반환)

 

여기서 1, 2 번에 대해 클라이언트에서 바로 S3에 저장을 하면 되지 않느냐? 라고 의구심이 생길 수 있는데, domain 의 이미지 URL 을 데이터베이스에서 관리를 해야하고, S3의 버킷과 키를 정하는 과정이 Server 의 정책에 따라서 정해지는게 맞다고 생각을 하여 서버에서 바이너리 파일을 직접 받아 수행했습니다.

 

해당 Interface 를 살펴보면 다음과 같아 집니다.

 

public interface ImageService {
    String saveInfoImages(List<MultipartFile> images, BaseImageEntity entity);
    String saveProfileImage(MultipartFile image, BaseImageEntity entity);
    String getProfileImage(BaseImageEntity entity);
    List<String> getInfoImages(BaseImageEntity entity);
}

 

여기서 BaseImageEntity 는 이미지 서비스를 사용하는 domain 들에 대한 부모클래스로 이미지 서비스를 지원하지 않는 도메인들이 이미지 서비스를 사용하지 못하도록 컴파일 단계에서 잡아주게 됩니다.

 

JPA를 사용하기 때문에 @MappedBySuperClass 를 사용해 줍시다.

@Getter
@MappedSuperclass
public class BaseImageEntity {

    @Lob
    protected String infoImagePath;
    protected String profileImagePath;
    protected Integer imgCnt;

    public void updateInfoImagePath(Integer imgCnt, String infoImagePath) {
        this.imgCnt = imgCnt;
        this.infoImagePath = infoImagePath;
    }

    public void updateProfileImagePath(String profileImagePath) {
        this.profileImagePath = profileImagePath;
    }
}

이미지 서비스를 사용하려고 하는 도메인들은 모두 이 BaseImageEntity 클래스를 사용하게 됩니다. 현재 BaseImageEntity 는 일반 클래스로 작성되어 있으나 해당 클래스는 독자적으로 사용할 일이 없으므로 추상클래스를 사용하는 것을 권장 드립니다.

 

이제 Local Image Service와 S3 Image Service에 대한 ImageService 구현체를 만들어 보겠습니다.

먼저 S3 를 사용하기 위해서는 Amazon S3 에서 제공하는 AmazonS3Client 를 통해서 이미지 저장을 실행하게 되므로 해당 의존성을 추가합니다.

 

// AWS S3 사용을 위한 의존성 추가 Spring-Cloud-AWS 추가
implementation 'io.awspring.cloud:spring-cloud-starter-aws:2.3.1'

 

추가적으로 S3에 사용되는 Bucket 과 key 값들을 세팅하기 위해 application-s3.yml 을 생성하고, application.yml 에 import 를 해줍니다.

application.yml

cloud:
  aws:
    credentials:
      accessKey: ???
      secretKey: ???
    s3:
      bucket: #버킷 이름
    region:
      static: #지역 이름
    stack:
      auto: false
    prefix: # url 정보

 

추가적으로 AmazonS3Client 를 Bean 등록을 해줍니다.

 

@Configuration
public class S3Config {
    @Bean
    public AmazonS3Client amazonS3Client( @Value("${cloud.aws.credentials.accessKey}") String accessKey,
                                          @Value("${cloud.aws.credentials.secretKey}") String secretKey,
                                          @Value("${cloud.aws.region.static}") String region) {
        BasicAWSCredentials awsCreds = new BasicAWSCredentials(accessKey, secretKey);
        return (AmazonS3Client) AmazonS3ClientBuilder.standard()
                .withRegion(region)
                .withCredentials(new AWSStaticCredentialsProvider(awsCreds))
                .build();
    }
}

이제 S3ImageService를 개발 해보겠습니다.

 

bucket 설정이외에도, 프로젝트에서는 이미지 저장에 대한 유지보수를 위해 domain 별로 key(도메인 이름) 를 나누고, id를 통해 추가적으로 구분을 하게 됩니다. 다음과 같은 구성입니다.

 

S3 url ------ UserProfile --- id(1)                           # UserId 가 1 인 유저의 ProfileImage

                     |                           |--- id(2)

                     |                           |--- ...

                     |

                     |- UserInfo----

                     |                      |- id(1) --- image1           # UserId가 1인 유저의 정보 사진들 모음

                     |                      |           |--- image2 

                     |                      |           | --- ...

                     |                      | - id(2) --- image1 

 

따라서 domain 클래스의 이름, id 값이 필요하게 됩니다.

 

S3 구현체의 image 저장 부분을 간단하게 살펴봅시다.

@RequiredArgsConstructor
@Service
@Primary
public class S3ImageService implements ImageService {

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

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

    private final AmazonS3Client amazonS3Client;

    public String saveProfileImage(MultipartFile image, BaseImageEntity entity) {
        // null 이거나 비어있다면 return
        if (image == null || image.isEmpty()) {
            entity.updateProfileImagePath(null);
            return null;
        }
        // image fullpath 완성
        String destPath = getProfileDestPath(image, entity);
        // 이미지 저장 로직 + entity 업데이트 추가 작업 필요
        saveImage(image, destPath);
        updateProfileImagePath(entity, destPath);
        return null;
    }

    private void saveImage(MultipartFile image, String destPath) {
        ObjectMetadata objectMetadata = new ObjectMetadata();
        objectMetadata.setContentLength(image.getSize());
        objectMetadata.setContentType(image.getContentType());
        try (InputStream inputStream = image.getInputStream()) {
            amazonS3Client.putObject(new PutObjectRequest(bucket, destPath, inputStream, objectMetadata)
//                    .withCannedAcl(CannedAccessControlList.PublicRead)
            );
        } catch (IOException e) {
            throw new ImageException(ImageErrorResult.IMAGE_SAVE_FAILED);
        }
    }
    
}

getProfileDestPath 를 통해 ProfileImage에 대한 full Url Path 를 완성 시키는 것을 확인해 볼 수 있습니다.

가령 UserId 1 의 ProfileImage를 저장하게 될 경우

https://xxx.s3.ap-northeast-2.amazonaws.com/UserProfile/1의 Full path 가 완성되는 것을 확인 할 수 있습니다. 

 

저의 경우에 BaseImageEntity 의 다형성과 java Reflection 을 활용하여 Domain 에 대한 이름 추출과 Id 값에 대한 추출을 진행 하였습니다. 

 

java Reflection 을 사용할 경우 컴파일 시점에 오류를 잡을 수 없고 런타임시점에 클래스에 대한 메타 데이터를 가져오므로 신중하게 결정이 필요 할 듯 합니다.

 

필요 정보를 추출하는 abstractEntityId 와 abstractEntityName 매서드를 살펴봅시다.

public class S3ImageService implements ImageService {

    protected Long abstractEntityId(BaseImageEntity entity) {
        try {
            Class<?> clazz = entity.getClass();
            Field[] declaredFields = clazz.getDeclaredFields();
            while(true) {
                for (Field field : declaredFields) {
                    Annotation annotation = field.getAnnotation(Id.class);
                    if (annotation != null) {
                        field.setAccessible(true);
                        Long entityId = (Long) field.get(entity);
                        return entityId;
                    }
                }
                clazz = clazz.getSuperclass();
                declaredFields = clazz.getDeclaredFields();
            }
        } catch (IllegalAccessException exception) {
            throw new ImageException(ImageErrorResult.IMAGE_ANALYZE_FAILED);
        }
    }

    /**
     * 엔티티 class 이름 추출
     */
    protected String abstractEntityName(Class<? extends BaseImageEntity> clazz) {
        String absoluteName = clazz.getName();
        String[] split = absoluteName.split("[.]");
        String entityName = split[split.length - 1];
        return entityName.toLowerCase();
    }

    protected String getProfileFolder(BaseImageEntity entity) {
        String className = abstractEntityName(entity.getClass());
        String folder = className + "Profile" + '/';
        return folder;
    }

    public String getProfileDestPath(MultipartFile multipartFile, BaseImageEntity entity) {
        String profileFolderPath = getProfileFolder(entity);
        Long entityId = abstractEntityId(entity);
        String ext = extractExt(multipartFile.getOriginalFilename());
        return profileFolderPath + entityId + '.' + ext;
    }
}

LocalImageService 또한 해당 정책에 맞게 구현을 하였으며, 이제 Spring DI 를 이용하여 ImageService를 사용하는 클래스들의 변경 없이 imageService를 제공할 수 있게 되었습니다.

 

S3ImageService 에 @Primary 를 명시하게 되었으므로 모든 ImageService 의존 객체들은 S3ImageService를 이용하게 됩니다.

 

post 의 ImageService 사용

제대로 S3ImageService를 의존하고 있는지 살펴 보겠습니다.

 

 

잘 적용이 된 것을 확인 할 수 있습니다.

댓글
05-20 11:54
Total
Today
Yesterday
링크