게시글 작성 시 이미지 넣는 기능 추가하기
[AWS] Spring boot 에서 AWS S3 로 이미지 저장하기
[Spring] Spring Boot AWS S3 사진 업로드 하는 법
[Spring] 파일 및 이미지 업로드
[Spring] Json with MultipartFile
이미지 데이터는 어떻게 처리해야 하는가에 대한 고민이 많았다.
나는 단순하게 DB에 이미지를 넣으면 될 줄 알았는데,,, 이미지 raw를 저장하는건 지양해야 한다고 하니 하드디스크를 따로 파서 어떻게 보관하며 이미지 전용 서버를 구축하는 경우는 또 어떻게 해야하는 건지,, 너무 막막하다 😭
열심히 구글링 하다 보니까 S3를 이용하여 이미지를 저장해줘야 한다는 것을 알아냈다..생각보다 갈길이 멀었다,,
//aws s3 사용자 권한 설정
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
aws.yml
파일 생성하고 aws S3 접근 관련 내용 설정하기cloud:
aws:
credentials:
accessKey: IAM 사용자 엑세스 키
secretKey: IAM 사용자 비밀 엑세스 키
s3:
bucket: 버킷 이름
region:
static: ap-northeast-2
stack:
auto: false
이때 IAM 사용자 엑세스 키와 비밀 엑세스키는 IAM 사용자를 생성할 때 받는 csv 파일에서 복붙하면 된다!
4. aws.yml
값을 읽어와 AmazonS3Client
객체를 bean으로 등록할 configuration
파일 생성
import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.services.s3.AmazonS3Client;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class AmazonS3Config {
@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 AmazonS3Client amazonS3Client() {
BasicAWSCredentials awsCreds = new BasicAWSCredentials(accessKey, secretKey);
return (AmazonS3Client) AmazonS3ClientBuilder.standard()
.withRegion(region)
.withCredentials(new AWSStaticCredentialsProvider(awsCreds))
.build();
}
}
config
디렉토리를 따로 생성하고 그 안에 위의 클래스를 넣어주었다.
5.aws.yml
파일도 읽어와 설정하도록 MainApplication
내용 수정
@SpringBootApplication
public class KnockKnockApplication {
public static final String APPLICATION_LOCATIONS = "spring.config.location="
+ "classpath:application.yml,"
+ "classpath:aws.yml";
public static void main(String[] args) {
new SpringApplicationBuilder(KnockKnockApplication.class)
.properties(APPLICATION_LOCATIONS)
.run(args);
}
}
S3Uploader
클래스 생성import com.amazonaws.services.s3.AmazonS3Client;
import com.amazonaws.services.s3.model.CannedAccessControlList;
import com.amazonaws.services.s3.model.PutObjectRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.Optional;
import java.util.UUID;
@Slf4j
@RequiredArgsConstructor
@Component
public class S3Uploader {
private final AmazonS3Client amazonS3Client;
@Value("${cloud.aws.s3.bucket}")
public String bucket; // S3 버킷 이름
public String upload(MultipartFile multipartFile, String dirName) throws IOException {
File uploadFile = convert(multipartFile) // 파일 변환할 수 없으면 에러
.orElseThrow(() -> new IllegalArgumentException("error: MultipartFile -> File convert fail"));
return upload(uploadFile, dirName);
}
// S3로 파일 업로드하기
private String upload(File uploadFile, String dirName) {
String fileName = dirName + "/" + UUID.randomUUID() + uploadFile.getName(); // S3에 저장된 파일 이름
String uploadImageUrl = putS3(uploadFile, fileName); // s3로 업로드
removeNewFile(uploadFile);
return uploadImageUrl;
}
// S3로 업로드
private String putS3(File uploadFile, String fileName) {
amazonS3Client.putObject(new PutObjectRequest(bucket, fileName, uploadFile).withCannedAcl(CannedAccessControlList.PublicRead));
return amazonS3Client.getUrl(bucket, fileName).toString();
}
// 로컬에 저장된 이미지 지우기
private void removeNewFile(File targetFile) {
if (targetFile.delete()) {
log.info("File delete success");
return;
}
log.info("File delete fail");
}
// 로컬에 파일 업로드 하기
private Optional<File> convert(MultipartFile file) throws IOException {
File convertFile = new File(System.getProperty("user.dir") + "/" + file.getOriginalFilename());
if (convertFile.createNewFile()) { // 바로 위에서 지정한 경로에 File이 생성됨 (경로가 잘못되었다면 생성 불가능)
try (FileOutputStream fos = new FileOutputStream(convertFile)) { // FileOutputStream 데이터를 파일에 바이트 스트림으로 저장하기 위함
fos.write(file.getBytes());
}
return Optional.of(convertFile);
}
return Optional.empty();
}
}
이 부분의 코드는 이해해야 할 내용이 많아서 찬찬히 정리해보자...🥰
우선 우리는 controller에서 S3에 업로드할 때 upload
라는 메소드에 업로드할 multipartfile
형식의 이미지 파일과 해당 이미지를 저장할 s3 저장소의 directory 명을 입력 파라미터로 넘길 것이다.
이 upload
메서드가 하는 일은 convert
메서드를 실행하여 로컬 저장소에 MultipartFile
을 File
형태로 변환하여 저장해준 후, File 형태의 입력 파라미터를 받는 또 다른 upload
메서드를 실행+ return 하여 해당upload
메서드에서 putS3
메서드를 활용하여 File형태로 변환된 이미지 파일을 S3에 업로드하고 removeNewfile
메서드를 사용하여 로컬 저장소에 일시적으로 저장했던 File 을 삭제하는 것이다.
@RequiredArgsConstructor
@RestController
public class HelloController {
private final S3Uploader s3Uploader;
@PostMapping("/images")
public String upload(@RequestParam("images") MultipartFile multipartFile) throws IOException {
s3Uploader.upload(multipartFile, "static");
return "test";
}
}
파일을 업로드 할 때 api 통신을 통해 받아올 객체의 타입은 MultipartFile이다.
upload
메소드의 두번째 파라미터 (위의 예시에서는 static
)의 이름에 따라 S3 bucket 내부에 이미지를 담을 해당 이름의 directory가 생성된다.
이제 postman에서 해당 이미지가 잘 s3에 담기는지 확인해보려는데..!
다음과 같은 에러가 났따 하아,,, 구글링해보니 S3에서 인증된 사용자그룹은 ACL 권한을 나열, 읽기 허용해주어야 한다고 해서 해줬다.
그러고 다시 해보려는데
오류 어게인,,,ㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎ🥺
이 오류가 나는 원인은 의외였는데
내가 넣으려는 파일 이름이 java라서 오류가 나는 것이였다.
실제로 다른 이름의 파일들을 넣으면 오류 없이 s3의 static 폴더에 잘 담겼는데 위 파일만 담기지 않고 오류가 났다.
현재 프로젝트 경로에 업로드 하려는 사진의 이름(java)과 동일한 파일이 있어서 같은 파일로 간주하고 에러를 발생시키는 것 같다.
따라서 S3Uploader
클래스에서 로컬에 파일을 임시 저장하는 과정에서 파일의 실제 파일명을 쓰는 것이 아니라 임시 코드를 써서 기존 로컬에 있는 파일들과 중복이 일어나지 않도록 설정해주었다.
public class S3Uploader {
private final AmazonS3Client amazonS3Client;
@Value("${cloud.aws.s3.bucket}")
public String bucket; // S3 버킷 이름
public String upload(MultipartFile multipartFile, String dirName) throws IOException {
File uploadFile = convert(multipartFile) // 파일 변환할 수 없으면 에러
.orElseThrow(() -> new IllegalArgumentException("error: MultipartFile -> File convert fail"));
return upload(uploadFile, dirName, multipartFile.getOriginalFilename()); //📌 파일의 originalName을 바로 넘기도록 설정
}
// S3로 파일 업로드하기
private String upload(File uploadFile, String dirName,String originalName) { //📌입력 파라미터에 originalName 추가
String fileName = dirName + "/" + UUID.randomUUID() + originalName; // S3에 저장된 파일 이름 📌random 값 + 기존의 파일명 으로 설정. 기존의 파일명은 upload 메서드 당시 multipartFile 에서 바로 getOriginalFileName으로 가져와서 입력 파라미터로 받기
String uploadImageUrl = putS3(uploadFile, fileName); // s3로 업로드
removeNewFile(uploadFile);
return uploadImageUrl;
}
// S3로 업로드
private String putS3(File uploadFile, String fileName) {
amazonS3Client.putObject(new PutObjectRequest(bucket, fileName, uploadFile).withCannedAcl(CannedAccessControlList.PublicRead));
return amazonS3Client.getUrl(bucket, fileName).toString();
}
// 로컬에 저장된 이미지 지우기
private void removeNewFile(File targetFile) {
if (targetFile.delete()) {
log.info("File delete success");
return;
}
log.info("File delete fail");
}
// 로컬에 파일 업로드 하기
private Optional<File> convert(MultipartFile file) throws IOException {
File convertFile = new File(System.getProperty("user.dir") + "/" + UUID.randomUUID()); //📌 local에 저장할때도 randomUUID를 쓰도록 설정
if (convertFile.createNewFile()) { // 바로 위에서 지정한 경로에 File이 생성됨 (경로가 잘못되었다면 생성 불가능)
try (FileOutputStream fos = new FileOutputStream(convertFile)) { // FileOutputStream 데이터를 파일에 바이트 스트림으로 저장하기 위함
fos.write(file.getBytes());
}
return Optional.of(convertFile);
}
return Optional.empty();
}
}
수정된 S3Uploader 코드이다. 📌
핀 꽂혀있는 부분 설명을 읽어보면 플로우는 어렵지 않다!
짠 이제 java 이름의 이미지 파일도 잘 들어가는 것을 확인할 수 있다.
물론 프로젝트에서 local이랑 겹칠만한 파일이 들어올 확률은 정말 드물겠지만,, 혹시나 하는 마음에 예외사항을 두고싶지 않아서 수정했다..ㅎㅎㅎㅎㅎ 나중에 가서 고치려고 하면 뭐가 문제인지도 모를거 같아서 😅
public class Image {
@Id
@GeneratedValue
@Column(name="image_id")
private Long id;
String imgurl;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id")
private Post post;
}
이 Entity는 post에 종속되는 entity이기 때문에 Post Entity 클래스에서 CASCADE를 사용하여 OneToMany 매핑을 한다.
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL)
private List<Image> img = new ArrayList<>();
@Repository
public interface ImageRepository extends JpaRepository<Image,Long> {
}
Repository는 단순한 기능만 활용할 수 있도록 구현했다.
@Service
public class ImageService extends S3Uploader {
private ImageRepository imageRepository;
public ImageService(AmazonS3Client amazonS3Client,ImageRepository imageRepository) {
super(amazonS3Client);
this.imageRepository = imageRepository;
}
public String saveImage(MultipartFile multipartFile, String dirName, Post post) throws IOException {
String uri = super.upload(multipartFile, dirName);
Image img = new Image(uri,post);
imageRepository.save(img);
return uri;
}
}
@PostMapping(value = "api/post/{userId}",consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.MULTIPART_FORM_DATA_VALUE})
public PostSaveResponse savePost(@RequestPart @Valid PostWriteRequest request , @RequestPart MultipartFile image,
@PathVariable("userId") String writerId) throws IOException {
PostSaveRequest postSaveRequest = new PostSaveRequest(request.getTitle(),request.getContent(),writerId,request.getHashtag());
Post post = postService.save(postSaveRequest);
imageService.saveImage(image,"knockknock",post);
PostSaveResponse response = new PostSaveResponse(post.getId(),post.getPostwriter().getUserId(),post.getTitle(),post.getContent(),post.getTimestamp());
String[] hashtag = postSaveRequest.getHashTags().split(" ");
List<String> hashtags = new ArrayList<>();
for(String tag: hashtag)hashtags.add(tag);
response.setHashtag(hashtags);
return response;
}
위에까지 구현하고 postman 으로 테스트를 해보려고 하면 다음과 같은 오류가 난다.
Content Type를 명시해주지 않았기 때문이다!
아래와 같이 Content Type를 명시해줘야 API 통신이 원활하게 이루어지는 것을 확인할 수 있었다.
나중에 안드로이드에서 보낼 때도 Content Type을 명확하게 해야할 것 같다..
아무튼 이렇게 서버쪽 개발은 끝났다 이제 내일 안드로이드쪽 개발 부수기 파이팅 💪🐱🏍