1. 이미지 업로드 방식
이미지파일을 업로드하여 저장하는 방법은 크게 두가지로 나뉜다.
첫 번째는 이미지 자체의 데이터를 BLOB형식 그대로 DB에 저장하는 방식이다.
BLOB(Binary Large Object) : 이미지, 동영상등 파일의 미가공 데이터를 나타내며 2진비트로 저장된다)
하지만 이미지 하나 가져올때 마다 DB에 접근해야하고 입출력 시 반드시 프로그램을 통해서 처리를 해야하기 때문에 잘 사용되지 않는다.
두 번째 방법으로는 DB에는 경로 저장방식을 저장하고 실제 파일은 다른 저장위치에 저장하여 이미지를 가져올 때는 DB의 경로로 가서 파일을 가져오는 방식이다.
장점으로는 파일은 효율적인 저장소에 따로 저장하고 DB에는 경로를 저장하여 조회시 DB에 부담이 덜하다는 장점이 있다. 물론 따로 저장소가 요구된다는 단점이 있다.
두번째 방법에도 방식이 나뉘는데 첫번째는 프로젝트 디렉토리 내에 파일을 보관하는 방법이고 두번째는 aws 클라우드 스토리지 서비스인 S3저장소에 파일을 보관하는 방법이다.
나의 경우에는 프론트와 백엔드가 나뉘어 서로 다른 환경에서 작업하는데 이 때문에 배포환경에서 백엔드 자바파일이 실행된다. 이때 만약 파일을 프로젝트 디렉토리내에 저장시키게 된다면 이미지가 얼마나 들어올지 모르는 상황에서 용량이 큰 이미지 파일들이 많아진다면 ec2 용량이 부족해져 좋지 못한 상황이 만들어질것이다.
때문에 S3에 이미지파일들을 저장하고 DB에는 S3스토리지의 이미지파일의 경로를 저장하는 방식으로 이미지 업로드를 구현하였다.
2. S3 생성 + IAM설정
나는 이미 CI/CD 배포과정을 진행하면서 S3와 IAM을 이미 사용하여 생성된 상태라 몇가지 설정만 바꾸어 주었다.
2.1 IAM 권한 정책
IAM권한 정책으로는 이미S3FullAccess가 선택되어있어 추가 설정이 필요 없었다.
2.2 S3 버킷 권한 설정
모든 퍼블릭 액세스 차단 해제
모든 사람 (퍼블릭 액세스)의 객체와 객체 ACL의 권한을 나열 / 읽기
3. 환경변수 설정 및 build.gradle 설정
build.gradle
implementation 'io.awspring.cloud:spring-cloud-starter-aws:2.3.1'
application.properties
IAM 에서 발급받은 액세스 키 쌍을 입력하고 S3버킷 이름을 적는다. application.properties는 민감한 정보인 액세스키가 노출되기 때문에 반드시 유출되면 안되니 git ignore등의 처리를 통해 유출되지 않도록 조심한다.
cloud.aws.credentials.accesskey=S3스토리지에 연결된 IAM사용자의 액세스 키
cloud.aws.credentials.secretkey=S3스토리지에 연결된 IAM사용자의 시크릿 키
cloud.aws.s3.bucket=S3버킷 이름
cloud.aws.region.static=ap-northeast-2
cloud.aws.stack.auto-=false
spring.servlet.multipart.max-file-size=20MB
spring.servlet.multipart.max-request-size=20MB
S3Config
참조한 블로그에서는 @Bean대신 @Autowired를 넣어서 오류가 해결되었다고 써있었는데 나는 오히려 반대였다.
@Configuration
public class S3Config {
@Value("${cloud.aws.credentials.accesskey}")
private String accessKey;
@Value("${cloud.aws.credentials.secretkey}")
private String secretKey;
@Value("${cloud.aws.region.static}")
private String region;
@Bean
public AmazonS3Client amazonS3(){
AWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey);
AmazonS3Client amazonS3Client = (AmazonS3Client) AmazonS3ClientBuilder.standard().withRegion(region).withCredentials(new AWSStaticCredentialsProvider(awsCredentials)).build();
return amazonS3Client;
}
}
S3Upload
이미지를 변환하여 업로드하는 서비스 로직이다. 외부에서 의존성 주입을 통하여 uploadFiles()메소드를 파일에 해당하는 multipartFile과 s3 버킷에 저장될 디렉토리인 dirName와 함께 호출하면 우선 파일을 변환하는 convert()과정과 s3스토리지에 이미지 파일을 저장하는 upload과정을 통해 s3에 이미지 파일을 저장한다. 그리고 최종적으로 업로드된 이미지 파일의 S3 URL주소를 반환한다.
@Slf4j
@RequiredArgsConstructor
@Service
public class S3Upload {
private final AmazonS3Client amazonS3Client;
@Value("${cloud.aws.s3.bucket}")
private String bucket;
// MultipartFile을 전달받아 File로 전환한 후 S3에 업로드
public String uploadFiles(MultipartFile multipartFile, String dirName) throws IOException {
File uploadFile = convert(multipartFile)
.orElseThrow(() -> new IllegalArgumentException("MultipartFile -> File 전환 실패"));
return upload(uploadFile, dirName);
}
private String upload(File uploadFile, String dirName) {
String fileName = dirName + "/" + uploadFile.getName();
String uploadImageUrl = putS3(uploadFile, fileName);
removeNewFile(uploadFile); // 로컬에 생성된 File 삭제 (MultipartFile -> File 전환 하며 로컬에 파일 생성됨)
return uploadImageUrl; // 업로드된 파일의 S3 URL 주소 반환
}
private String putS3(File uploadFile, String fileName) {
amazonS3Client.putObject(
new PutObjectRequest(bucket, fileName, uploadFile)
.withCannedAcl(CannedAccessControlList.PublicRead) // PublicRead 권한으로 업로드 됨
);
return amazonS3Client.getUrl(bucket, fileName).toString();
}
private void removeNewFile(File targetFile) {
if(targetFile.delete()) {
log.info("파일이 삭제되었습니다.");
}else {
log.info("파일이 삭제되지 못했습니다.");
}
}
private Optional<File> convert(MultipartFile file) throws IOException {
String now = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"));
File convertFile = new File(System.getProperty("user.dir") + "/" + now + ".jpg");
if(convertFile.createNewFile()) {
try (FileOutputStream fos = new FileOutputStream(convertFile)) {
fos.write(file.getBytes());
}
return Optional.of(convertFile);
}
return Optional.empty();
}
}
사실여기까지 작성해도 s3스토리지에 파일은 저장된다.
하지만 우리의 목적은 DB에 경로를 저장해놓고 이미지를 조회해야하기 때문에 기존에 있던 컨트롤러와 서비스 클래스 및 엔티티와 Dto를 수정하여 이미지를 조회 할 수 있도록 코드를 수정하겠다.
Entity
기존 id, title, content에서 이미지 파일의 s3저장소 URL을 저장할 imagePath를 추가하였다. 그에 맞춰서 생성자 또한 변경
package com.example.cicdtest.entity;
import com.example.cicdtest.dto.ItemRequestDto;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Getter
@Setter
@NoArgsConstructor
public class Item extends TimeStamped{
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@Column(nullable = false, length = 512)
private String content;
@Column
private String imagePath;
public Item(ItemRequestDto itemRequestDto, String imagePath) {
this.title = itemRequestDto.getTitle();
this.content = itemRequestDto.getContent();
this.imagePath = imagePath;
}
public void updateItem(ItemRequestDto itemRequestDto) {
this.title = itemRequestDto.getTitle();
this.content = itemRequestDto.getContent();
}
}
ItemResponseDto
아이템 조회시 imagePath도 같이 반환하기 위해 수정
@Getter
public class ItemResponseDto {
private Long id;
private String title;
private String content;
private String imagePath;
public ItemResponseDto(Item item) {
this.id = item.getId();
this.title = item.getTitle();
this.content = item.getContent();
this.imagePath = item.getImagePath();
}
}
ItemController
아이템 작성 메소드이다. 이때 form-data 타입으로 요청을 보내는데 이때 title과 content는 key가 "data"인 json으로
이미지 파일은 key가 "image"인 multipart/form-data로 요청한다.
@PostMapping("/api/item")
private ResponseEntity<Result> createItem(@RequestPart("data") ItemRequestDto itemRequestDto, @RequestPart(required = false) MultipartFile image) {
itemService.createItem(itemRequestDto, image);
return ResponseEntity.ok()
.body(Result.success("생성 성공"));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Result> exceptionHandler(Exception e){
return ResponseEntity.badRequest()
.body(Result.error(e.getMessage()));
}
postman 요청시 설정은 다음과 같다.
ItemService
컨트롤러에서 호출하는 createItem메소드부분이다. ItemService에서 S3Upload빈을 주입하여 메소드 호출 시 우선 imagePath를 null로 초기화한다. 만약 create요청 시 image없이 요청하였다면 if문에서 image.isEmpty가 true기 떄문에 바로 null인 상태로 DB에 저장한다. image와 함께 요청한다면 앞서 작성했던 S3Upload의 이미지 변환 및 저장 과정을거치고 반환받은 이미지 경로를 imagePath에 저장하여 DB에 저장한다.
@Service
@RequiredArgsConstructor
public class ItemService {
private final ItemRepository itemRepository;
private final S3Upload s3Upload;
public void createItem(ItemRequestDto itemRequestDto, MultipartFile image) {
String imagePath = null;
if(!image.isEmpty()){
try{
imagePath = s3Upload.uploadFiles(image, "images");
} catch (Exception e) {
e.printStackTrace();
}
}
Item item = new Item(itemRequestDto, imagePath);
itemRepository.save(item);
}
....
}
다음과 같이 DB의 item테이블에는 저장된 이미지의 S3 Url경로가 저장되어 있고
GET요청으로 조회시
imagePath와 함께 응답을 받으며 imagePath로 접속이 이미지를 볼 수 있다.
ItemService에서 s3Upload.uploadFiles()메소드 호출시 두번째 파라미터인 dirName을 "images"로 넘겨주었기 때문에 S3버킷의 images/에 파일들이 저장된것을 볼 수 있다. 이미지 이름은 S3Upload클래스의 convert()과정에서 날짜와 시간을 LocalDateTime객체의 now() 즉 현재 시간을 "yyyyMMdd_HHmmss"로 지정하여서 저장된 날짜와 시간으로 저장되어있다.
트러블 슈팅 포스트 : https://tjsdn9803.tistory.com/72
[TIL]20230719 - 트러블 슈팅 배포환경에서 S3 이미지 업로드 불가능 현상
문제 발생 https://tjsdn9803.tistory.com/71 [TIL]20230718 - Spring Boot 이미지 업로드 구현 1. 이미지 업로드 방식 이미지파일을 업로드하여 저장하는 방법은 크게 두가지로 나뉜다. 첫 번째는 이미지 자체의
tjsdn9803.tistory.com
'백엔드(Back End) > Spring' 카테고리의 다른 글
[TIL]20230726 - 테스트 코드 작성 (0) | 2023.07.28 |
---|---|
[TIL]20230725 - JaCoCo (0) | 2023.07.28 |
[TIL]20230713 - RefreshToken (0) | 2023.07.14 |
[TIL]20230712 - 페이징 처리 (0) | 2023.07.12 |
[TIL]20230711 - 회원탈퇴 기능 구현 (0) | 2023.07.11 |