현재 진행하는 연구실 공식 블로그 프로젝트(Wasabi)를 개발하던 도중, 꽤나 고민을 했던 부분이 있어 기록하고 구현 방법을 남기려한다.
요구사항에 따르면 게시글이 존재하고, 게시글에는 다수의 이미지를 업로드할 수 있다.
자세한 구현 흐름은 다음과 같다.
단순히 요구사항만 보면 그리 어려워 보이지 않는다. 하지만 자세히 들여다보면?
구현 방법을 살펴보기전, 위 사항에 대한 정책부터 결정하고 가자!
이미지를 S3에 업로드할 것인가 아니면 로컬에 저장할 것인가?
위 문제에 대한 생각은 애초에 거의 정해져 있었다.
로컬에 이미지를 직접 저장하기에는 무리가 있고, 저장 공간도 유한하며 제한적이다.
따라서 공통적으로 관리할 수 있고, 클라우드인 AWS S3에 이미지를 업로드!
하지만, 로컬 DB에도 게시글과의 매핑.. 이미지 조회.. 삭제.. 관리 등을 생각하면 파일명, 이미지가 업로드된 S3 링크 정도는 저장해야 한다.
현재 요구사항에서는 이미지를 게시글보다 먼저 등록하는데, 게시글과 이미지간의 연관관계는?
막상 구현하니 구현하기는 어렵지 않았지만, 방법이 생각하기 쉽지 않았다.
현재 흐름에서는 게시글보다 이미지를 먼저 등록하고, 이미지가 저장된 S3 링크를 내려주어 사용자에게 준다.
따라서 게시글이 저장되기 이전에 이미지가 먼저 등록되고, 이미지 객체가 만들어질 때 게시글은 존재하지 않는다!
따라서 로컬에 먼저 이미지를 저장하고, S3에 나중에 올려서 링크를 변경하는 방식.. 이미지와 게시글의 연관관계를 짓지 않는 방식.. 등을 생각했지만 모두 제한적이었고, 이미지 하나 올리는데 너무 로직이 복잡했다.
추가적으로 생각했던 방법은 임시 저장을 두어 이미지를 임시적으로 저장하는 방법도 있었지만 프론트단에서도 화면이 늘어나고, 처음에 생각했던 요구사항에 맞지 않아 제외!
결국 정한 방법은 다음과 같다.
이미지를 저장할 때는 게시글과의 연관관계를 null로 두고, 나중에 게시글이 저장된다면 그 때 이미지와 게시글을 매핑하자!
임시글을 두지 않는 이상 제일 효율적인 방법이라고 생각한다.
다만, 이렇게 되면 추가적인 문제가 하나 발생한다. 그 문제는 밑에서 알아보자.
사용자가 이미지만 등록하고 막상 게시글은 저장하지 않는다면?
사용자가 이미지만 등록하고 막상 게시글을 저장하지 않는다면 불필요한 이미지 정보가 S3와 로컬에 남게 된다.
추가적으로 악의를 가진 사용자가 이미지를 수백, 수천개를 저장한다면 과금이 유도될 수도 있다.
따라서, 이 문제에 대해 엄청나게 고민했지만 로컬을 한 번 거쳐서 올리지 않는다면 방지할 방법은 특별히 없었다.
하지만 현재 우리 플랫폼은 인증된 사용자만이 가입할 수 있고, 관리자가 승인한 사용자만이 글 작성을 할 수 있으니 이 문제는 보류!
불필요한 이미지에 대해서는 스케줄링을 통해 어느정도 해결을 했다. 이 해결 방법도 밑에서 알아보자!
S3에 이미지 업로드 과정부터 알아보자. 단, S3 버킷 관련 정책 & 세팅은 되어있다는 가정하에 진행한다.
cloud:
aws:
s3:
bucket: 버킷 이름
credentials:
access-key: 버킷 액세스 키
secret-key: 버킷 시크릿 키
region:
static: ap-northeast-2
auto: false
stack:
auto: false
spring:
servlet:
multipart:
max-file-size: 20MB
max-request-size: 20MB
각각 위치에 맞게 생성했던 버킷 이름, 발급받았던 액세스 & 시크릿 키, 리전을 넣어주면 된다.
추가적으로 우리는 파일을 주고받을 때 MultipartFile
타입을 사용할 것이다.
용량이 너무 큰 이미지를 업로드할 때 예외가 발생하도록 최대 파일 크기를 정해주자.
@Configuration
public class S3Config {
@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 AmazonS3 amazonS3Client() {
final BasicAWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey);
return AmazonS3ClientBuilder
.standard()
.withCredentials(new AWSStaticCredentialsProvider(credentials))
.withRegion(region)
.build();
}
}
액세스 키, 시크릿 키, 리전은 @Value
를 통해 주입받아 사용하자.
@Service
@Transactional(readOnly = true)
public class BoardImageServiceImpl implements BoardImageService {
private final AmazonS3 amazonS3;
private final BoardImageRepository boardImageRepository;
private final BoardMapper boardMapper;
@Value("${cloud.aws.s3.bucket}")
private String bucket;
public BoardImageServiceImpl(final AmazonS3 amazonS3,
final BoardImageRepository boardImageRepository,
final BoardMapper boardMapper) {
this.amazonS3 = amazonS3;
this.boardImageRepository = boardImageRepository;
this.boardMapper = boardMapper;
}
@Override
@Transactional
public UploadImageResponse saveImage(final UploadImageRequest request) {
final String originName = request.image().getOriginalFilename();
final String ext = originName.substring(originName.lastIndexOf("."));
final String changedImageName = changeImageName(ext);
final String storeImagePath = uploadImage(request.image(), ext, changedImageName);
final BoardImage boardImage = boardMapper.uploadImageRequestToEntity(changedImageName, storeImagePath);
boardImageRepository.save(boardImage);
return boardMapper.entityToUploadImageResponse(boardImage);
}
private String uploadImage(final MultipartFile image,
final String ext,
final String changedImageName) {
final ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentType("image/" + ext.substring(1));
try {
amazonS3.putObject(new PutObjectRequest(
bucket, changedImageName, image.getInputStream(), metadata)
.withCannedAcl(CannedAccessControlList.PublicRead));
} catch (final IOException e) {
throw BoardExceptionExecutor.BoardImageUploadFail();
}
return amazonS3.getUrl(bucket, changedImageName).toString();
}
private String changeImageName(final String ext) {
final String uuid = UUID.randomUUID().toString();
return uuid + ext;
}
}
생각보다 그리 어렵지 않다.
Dto를 통해 이미지의 원본 이름을 받고, 확장자를 분리한다.
파일 원본 이름이 중복될 때를 대비해서 UUID로 파일 이름을 새롭게 생성해주고, 분리한 확장자를 붙여준다.
그 후로는 S3 라이브러리를 사용해서 어렵지 않게 이미지를 업로드해줄수 있다.
boardMapper
의 경우 필자의 프로젝트는 엔티티 -> DTO, DTO -> 엔티티 로직은 매퍼를 통해 관리하느라 사용했다.
추가적으로 DB에는 UUID로 변환한 파일명, 그리고 S3 이미지 링크를 저장해준다.
단, 주의해야 할 점은 MultipartFile
타입을 받을 때는 @RequestBody
가 아닌 @ModelAttribute
, 혹은 @RequestPart
를 사용해야 한다.
@PostMapping("/image")
public ResponseEntity<Response<UploadImageResponse>> uploadImage(@ModelAttribute final UploadImageRequest request) {
final UploadImageResponse data = boardImageService.saveImage(request);
return ResponseEntity.ofNullable(
Response.of(
ResponseType.BOARD_IMAGE_UPLOAD_SUCCESS,
data
)
);
}
@Override
@Transactional
public WriteBoardResponse writeBoard(final WriteBoardRequest request, final Long memberId) {
final Member member = memberRepository.findById(memberId)
.orElseThrow(MemberExceptionExecutor::MemberNotFound);
final Board board = boardMapper.writeBoardRequestToEntity(request, member);
boardRepository.save(board);
mappingBoardAndImage(request, board);
logger.info("[Result] {}번 회원의 {}번 게시글 작성", memberId, board.getId());
return boardMapper.entityToWriteBoardResponse(board);
}
private void mappingBoardAndImage(final WriteBoardRequest request, final Board board) {
final List<BoardImage> images = boardImageRepository.findAllBoardImagesById(request.imageIds());
images.forEach(image -> image.setBoard(board));
}
포스트맨을 이용해 손쉽게 테스트를 해볼 수 있다.
MultipartFile
은 form-data 형태로 날아가므로, 이것만 주의해서 테스트해보자.
Amazon S3에도 잘 올라간 모습을 볼 수 있다.
이제 이미지 삭제에 이어, 위에서 언급했던 추가적인 문제에 대해 알아보자!
위에서 언급했다싶이 이미지 저장시점에는 게시글과의 연관관계를 짓지 않는다.
따라서, 만약 사용자가 이미지만 등록하고 게시글을 작성하다가 취소한다면? 게시글을 작성하지 않는다면?
게시글과의 연관관계가 null인 불필요한 이미지가 계속해서 S3와 로컬 DB에 남게 된다!
위 문제를 방지하려면 효율적인 방법이 하나 존재한다.
바로, 스케줄링을 주기적으로 돌려 불필요한 이미지를 S3와 DB에서 삭제해주는 것!
@Scheduled(cron = "${cloud.aws.cron}")
@Transactional
public void deleteUnNecessaryImage() {
final List<BoardImage> images = boardImageRepository.findAllBoardImagesByNull();
images.stream()
.filter(image -> Duration.between(image.getCreatedAt(), LocalDateTime.now()).toHours() >= 24)
.forEach(image -> {
final DeleteObjectRequest deleteRequest = new DeleteObjectRequest(bucket, image.getFileName());
amazonS3.deleteObject(deleteRequest);
boardImageRepository.delete(image);
});
}
스케줄링을 돌리는 부분에 대해서는 어렵지 않다. 스프링에서 @Scheduled
라는 손쉽게 사용할 수 있는 어노테이션을 제공해준다.
@Scheduled
의 옵션에 대해서는 cron 표현식.. fixedDelay
.. fixedRate
와 같은 여러 옵션이 있으니 각자 상황에 맞게 적용하자.
필자의 경우 매일 매일 정해진 시간에, 이미지가 생성된 지 24시간이 지난 후에도 관련 게시글이 저장되지 않아 게시글과의 연관관계가 null
이라면 불필요하고 사용자도 게시글들 등록할 의지가 없다고 생각하고 삭제해주었다.
추가적으로 실행 클래스에 다음과 같은 어노테이션을 추가해 스케줄링을 사용한다고 알려야 한다.
@EnableScheduling
@SpringBootApplication
public class WasabiApplication {
public static void main(String[] args) {
SpringApplication.run(WasabiApplication.class, args);
}
}
결론적으로 위 방법을 사용하면 S3 용량에 대한 걱정, 과금에 대한 걱정, 그리고 주기적으로 쌓이는 더미 데이터에 대한 걱정을 어느 정도 해소할 수 있다!
위 과정을 거쳐, 어렵지 않게 S3에 이미지 삭제, 그리고 등록을 해보았다.
분명 이미지만 등록하지 않고 게시글과 연관짓는 사람들이 많을 것이며, 나와 같은 고민을 한 사람들이 많다고 생각하여 글을 포스팅한다.
추가적으로 사용자가 이미지만 올리고 게시글을 저장하지 않을 때, S3에 이미지가 올라가 과금이 되는것을 방지할 수 있는 방법이 있다면 댓글 남겨주면 감사합니다 🙏
자세한 실행 코드들은 링크를 첨부한다.
프로젝트에 어떤 의존성을 추가했는지 버전과 함께 나타내면 좋을 것 같아요~! Good Job~