일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | ||||
4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 |
- S3
- spring websocket nginx 설정
- 도메인 주도 개발
- 관점 지향 프로그래밍
- logout
- GoormIDE
- fastapi
- session
- jwt
- OpenAI API
- spring boot
- presigned url
- 소셜 로그인
- oauth2.0
- 자바 orm
- validation
- 이미지 업로드
- ec2 nginx websocket reverse proxy
- CustomException
- @Valid
- 스프링부트
- wss 연결 실패
- 구글 로그인
- 패러다임 불일치
- AWS
- 예외 처리
- Flask
- springboot
- 백준 10815 # 백준 Java
- 개발 프로젝트
- Today
- Total
개발세발은 안되요
[Spring Boot]S3 이미지 업로드/다중 이미지 업로드/삭제 본문
오늘은 AWS S3에 이미지를 업로드하는 방법을 기록해보도록 하겠습니다!
이번 프로젝트를 진행하면서 이미지,, 파일 업로드 기능을 처음 만들어보아서 정말 많이 해매었어서,, 누군가에게 이 글이 도움이 되었으면 합니다 ㅠㅠ
S3란?
공식 홈페이지에서 설명하고 있는 바는 다음과 같습니다!
Amazon Simple Storage Service(Amazon S3)는 업계 최고의 확장성, 데이터 가용성, 보안 및 성능을 제공하는 객체 스토리지 서비스입니다. 모든 규모와 업종의 고객은 Amazon S3를 사용하여 데이터 레이크, 웹 사이트, 모바일 애플리케이션, 백업 및 복원, 아카이브, 엔터프라이즈 애플리케이션, IoT 디바이스, 빅 데이터 분석 등 다양한 사용 사례에서 원하는 양의 데이터를 저장하고 보호할 수 있습니다. Amazon S3는 특정 비즈니스, 조직 및 규정 준수 요구 사항에 맞게 데이터에 대한 액세스를 최적화, 구조화 및 구성할 수 있는 관리 기능을 제공합니다.
저는 이번 프로젝트에서 서버에 이미지를 올리기 위해 S3 버킷을 이용하였습니다. 프로젝트에서는 이미지 등의 파일을 업로드하기 위해 많이 사용되는 듯합니다.
S3와 관련한 기본적인 설정 및 생성 등은 마쳤다고 생각하고 코드...? 위주로 설명해보겠습니다.
application.yml 수정하기
#S3
cloud:
aws:
credentials:
instance-profile: false
accessKey: # AWS IAM AccessKey
secretKey: # AWS IAM SecretKey
s3:
bucket:
region:
static: ap-northeast-2
stack:
auto: false
이렇게 추가해주시면 됩니다.
accessKey 와 secretKey 는 IAM 설정 시 얻으실 텐데요, 그대로 넣어주시면 됩니다.
bucket에는 이미지를 올리실 bucket의 이름을 넣는다고 생각하시면 됩니다.
s3:
bucket: bageasy
저희 팀의 경우 프로젝트 이름인 bageasy로 버킷을 생성하였고, 실제로 AWS S3에 접속해보면,
이렇게 생성되는 것을 확인할 수 있습니다!
프로젝트 로직!
구체적으로 코드를 설명드리기 전에 프로젝트의 기본적인 로직을 설명해드리려 합니다. 저의 경우 이제 막 개발 공부를 시작한 사람으로서, 아직 다른 사람의 코드만 딱 본다고 이해가 잘 되지는 않더라구요! 아래의 코드를 이해하기 좀 더 쉬워지도록 ㅎㅎ 참고해보셔도 좋을 것 같아요.
이미지를 올릴 수 있는 게시글을 생성하는 것이 목표였습니다. (당근 마켓이나 에브리타임처럼!)
1. 게시글(post)를 생성할 때 여러 장의 이미지를 함께 업로드할 수 있도록 한다
2. 게시글(post)를 수정할 때 기존의 이미지를 삭제하거나 새로운 이미지를 업로드할 수 있다.
3. 게시글(post)를 삭제할 때는 해당 게시글과 함께 업로드된 이미지가 삭제된다.
추가로, 버킷에는 업로드한 이미지의 url이 생성되어 저장됩니다.
하지만 버킷에 이미지url이 저장되는 것과는 별개로 DB(저의 경우 MySQL)에도 생성된 이미지의 url을 저장해주어야 하는데요, 후에 게시글을 조회하거나 게시글을 삭제할 때 해당되는 이미지를 찾는 데에 이용됩니다. S3에는 이미지의 url만 저장되고, 해당 이미지가 어떤 게시글에 달린 것인지는 알 수 없으니까요!
어떤 게시글과 함께 생성된 것인지를 알기 위해 이미지 url과 해당 이미지가 게시된 게시글(post)의 id를 함께 저장할 수 있도록 구현했습니다.
S3Service.java
@Slf4j
@RequiredArgsConstructor
@Component
@Service
public class S3Service {
private final AmazonS3Client amazonS3Client;
@Value("${cloud.aws.s3.bucket}")
private String bucket;
@Value("${cloud.aws.region.static}")
private String region;
/* 이미지 업로드 */
public List<String> upload(List<MultipartFile> multipartFile) {
List<String> imgUrlList = new ArrayList<>();
// forEach 구문을 통해 multipartFile로 넘어온 파일들 하나씩 fileNameList에 추가
for (MultipartFile file : multipartFile) {
String fileName = createFileName(file.getOriginalFilename());
ObjectMetadata objectMetadata = new ObjectMetadata();
objectMetadata.setContentLength(file.getSize());
objectMetadata.setContentType(file.getContentType());
try(InputStream inputStream = file.getInputStream()) {
amazonS3Client.putObject(new PutObjectRequest(bucket+"/post/image", fileName, inputStream, objectMetadata)
.withCannedAcl(CannedAccessControlList.PublicRead));
imgUrlList.add(amazonS3Client.getUrl(bucket+"/post/image", fileName).toString());
} catch(IOException e) {
throw new CustomException(FILE_UPLOAD_ERROR);
}
}
return imgUrlList;
}
/* 이미지 삭제 */
public void deleteImage(String imageUrl) throws IOException{
String imageName = getFileNameFromURL(imageUrl);
try {
amazonS3Client.deleteObject(bucket,"post/image/"+imageName);
}catch (SdkClientException e){
throw new CustomException(FILE_DELETE_ERROR);
}
}
/* url 로부터 이미지 이름 얻기 */
public static String getFileNameFromURL(String url) {
return url.substring(url.lastIndexOf('/') + 1, url.length());
}
}
1. 이미지 업로드
public List<String> upload(List<MultipartFile> multipartFile)
여러 장의 이미지를 한번에 올리기 위해 list 형식으로 파일을 받아 업로드할 수 있도록 구현했습니다.
for 문 안을 보면 전달받은 file로부터 파일의 이름을 생성하고, objectMetaData에 파일의 크기와 유형을 저장해두는 부분이있습니다.
try-catch 문에서는 bucket의 /post/image/ 경로(폴더라고 생각하시면 편합니다!)에 public 접근권한으로 이미지를 올리는 코드가 있습니다. 그리고 imgUrlList에 생성한 이미지의 경로를 저장하여 반환합니다.
실제로 S3에는 이렇게 올라갑니다.
2. 이미지 삭제
사실 저는 이미지 삭제하는 기능에서 한참 해맸습니다 ㅜㅜ 어떻게 버킷에서 특정 이미지에 접근하여 삭제하는지 이해하지 않고 무작정 다른 사람의 코드만 보고 있었거든요,, 그래서 DB에서만 이미지가 삭제되고, S3 버킷에서는 삭제되지 않는 문제를 겪었습니다. 그래도 어찌저찌 구현했네요,,
public void deleteImage(String imageUrl)
앞서 설명드린 것처럼 DB에는 이미지의 url이 저장되어있습니다. S3에서 이미지를 삭제하기 위해서는 이미지의 url로부터 버킷에 저장되어있는 이미지의 이름을 추출하고, 그 이름을 이용해 삭제할 이미지 객체에 접근해야 합니다. (저는 이 부분에 대한 이해 없이 구현하다가 시간을 많이 날렸습니다,,,)
MySQL과 같은 db에 저장되어있는 url은 예를 들어 다음과 같습니다.
https://s3.ap-northeast-2.amazonaws.com/버킷 이름/post/image/1234_1234_1234_1234_1234.jpeg |
여기서 S3 버킷에 저장되어있는 이미지 객체의 이름은 노란색으로 표시되어있는 부분입니다.
public static String getFileNameFromURL(String url)
이 함수를 통해 url로부터 노란색으로 표시되어있는 이미지의 이름을 추출하여 imageName에 저장합니다.
그리고 try-catch문에서 이미지를 버킷에 접근하여 해당 이미지를 삭제합니다.
이때 업로드할 때와 동일하게 post/image/라는 경로를 명시해주어야 합니다!
S3버킷에 담겨있는 이미지의 key가 post/image/1234_1234_1234_1234_1234.jpeg 이기 때문입니다.
(저는 경로를 명시하지 않아서 계속 실패했던 것이었어요... ^^)
이제 컨트롤단과 서비스단에서 위에서 만들어둔 메소드들을 이용하면 됩니다!
엔티티는 참고만 하시면 될 것 같아요. 저의 경우 cascade로 매핑 시켜두었습니다.
Post.java
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Post extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long postId;
@Column(nullable = false)
private String title;
@Column
private String content;
@Column(name = "is_sold", nullable = false)
@ColumnDefault("false")
private Boolean isSold;
@Column
private Long price;
@Column(name = "school", nullable = false)
private String school;
@Column(name = "member_id", nullable = false)
private Long memberId;
@Column(name = "buyer_id")
private Long buyerId;
@Transient
private final List<Image> imageList = new ArrayList<>();
@Builder
public Post(Long postId, String title, String content, Boolean isSold, Long price,
String school, Long memberId, Long buyerId, List<Image> images){
this.postId=postId;
this.title=title;
this.content=content;
this.isSold=isSold;
this.price=price;
this.school=school;
this.memberId=memberId;
this.buyerId=buyerId;
}
public Post(String title, String content, Long price, Long memberId,String school){
this.title=title;
this.content=content;
this.price=price;
this.memberId=memberId;
this.isSold=false;
this.school= school;
}
}
Image.java
@Entity
@NoArgsConstructor
@Getter
public class Image {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long imageId;
@Column(name = "image_url", nullable = false)
private String imageUrl;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id",nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private Post post;
public Image(String fileUrl , Post post){
this.imageUrl=fileUrl;
this.post = post;
}
}
이제 구체적인 기능 구현 부분입니다!
PostController.java
@RestController
@RequestMapping("/posts")
@RequiredArgsConstructor
public class PostController {
private final PostService postService;
/* 게시글 생성 */
@PostMapping
@ResponseStatus(value = HttpStatus.CREATED)
public PostResponseDto createPost(
@AuthUser Member member,
@RequestPart(value="dto") PostRequestDto requestDto,
@RequestPart(value="image") List<MultipartFile> images) throws IOException {
return postService.addPost(member,requestDto,images);
}
/* 게시글 삭제 */
@DeleteMapping("/{postId}")
@ResponseStatus(HttpStatus.OK)
public String deletePost(@AuthUser Member member, @PathVariable Long postId) throws IOException {
postService.deletePost(member,postId);
return "성공적으로 삭제되었습니다!";
}
}
거의 서비스단에서 이루어지기 때문에 코드만 대강 보시면 될 것 같습니다. 어떤 형태로 정보를 받아오는지만 참고하시면 될 것 같아요!
1. 게시글 생성
게시글의 내용을 담은 dto와 이미지 파일을 받아 서비스단으로 넘겨 게시글을 생성합니다.
이때 json과 file을 함께 전달받을 수 있도록 하기 위해 @RequestPart 를 이용했습니다.
→ json - 게시글 내용(제목, 내용 등) / file - 이미지
@AuthUser은 로그인한 회원의 정보를 받아오기 위해 쓰였다고 보시면 됩니다. (구글 로그인을 이용했어요!)
이미지를 받아올 때는 MultipartFile 로 받아옵니다!
2. 게시글 삭제
이 부분도 설명할 부분이 많지는 않고, 코드 보시면 될 것 같습니다.. 삭제할 게시글의 id를 회원 정보와 함께 서비스단에 넘겨 삭제를 한 후 삭제되었다는 문구를 출력한다고 보시면 될 것 같습니다.
PostService.java
@Service
@RequiredArgsConstructor
public class PostService {
private final ImageService imageService;
private final MemberService memberService;
private final MemberRepository memberRepository;
private final PostRepository postRepository;
private final ImageRepository imageRepository;
private final HeartRepository heartRepository;
@Autowired
private final S3Service s3Service;
/* 게시글 생성 */
@Transactional
public PostResponseDto addPost(Member member, PostRequestDto requestDto ,List<MultipartFile> images) throws IOException {
if(images == null){
throw new IOException("이미지가 없습니다.");
}
List<String> imgPaths = s3Service.upload(images);
String title = requestDto.getPostTitle();
String content = requestDto.getPostContent();
Long price = requestDto.getPrice();
Long memberId=member.getMemberId();
String school = requestDto.getSchool();
Post post = new Post(title,content,price,memberId,school);
postRepository.save(post);
for(String imgUrl : imgPaths){
Image image = new Image(imgUrl,post);
imageRepository.save(image);
}
return makeDto(post);
}
/* 게시글 삭제 */
public void deletePost(Member member, Long postId) throws IOException {
Post post = findPost(postId);
if(post.getMemberId() != member.getMemberId()){
throw new CustomException(ErrorCode.INVALID_ACCESS);
}
else{
heartRepository.findByPostId(postId)
.forEach(heartRepository::delete);
List<Image> imageList = imageService.findPostImage(post);
for(Image image:imageList){
s3Service.deleteImage(image.getImageUrl());
}
postRepository.delete(post);
}
}
public PostResponseDto makeDto(Post post){
Member member = memberService.findMemberById(post.getMemberId());
List<Image> imageList = imageService.findPostImage(post);
String buyerNickName = null;
if(post.getBuyerId() != null) buyerNickName = memberService.findNicknameById(post.getBuyerId());
Long heartCount = countHeart(post.getPostId());
return new PostResponseDto(post,imageList,member,buyerNickName,heartCount);
}
}
1. 게시글 생성
List<String> imgPaths = s3Service.upload(images);
앞서 만들어둔 메소드를 이용해 S3 버킷에 이미지를 업로드합니다. 그리고 업로드한 이미지의 각 url이 imgPaths에 담깁니다. 아직 DB에 이미지의 url이 저장되지는 않았습니다.
그리고 post객체를 만들고 DB에 저장하는 부분이 나옵니다. 여기는 이미지 업로드와 관련이 크지는 않아서.. 그냥 저런 정보를 가진 게시글을 하나 생성해서 저장하는구나~ 정도만 보셔도 될 것 같아요!
for문 안에서 DB에 이미지 정보를 저장합니다. 어떤 게시글에 달린 이미지인지와 이미지 url을 DB에 저장할 것입니다. 저의 경우 바로 전에 생성한 post를 그대로 넘겨주었습니다
그리고 생성한 post를 이용하여 dto를 생성하여 반환해줍니다!
2. 게시글 삭제
전달받은 post의 id 를 이용해 DB에서 삭제해야 할 게시글을 찾습니다.
그리고 그 게시글을 작성한 사람의 id와 현재 접근한 회원의 id를 비교해서 게시글의 작성자가 아닌 경우 접근 권한이 없다는 의미의 에러를 발생시킵니다.
저의 경우 이미지와 게시글을 cascade로 연관관계 매핑을 시켜두었기 때문에 게시글을 DB에서 삭제하면 해당 게시글의 id를 가지고 있는 이미지도 DB에서 삭제됩니다. 하지만 DB에서만 삭제되고, S3 버킷에서는 삭제되지 않죠 그렇기 때문에 삭제하려는 게시글의 id를 통해 해당 게시글에 달린 모든 이미지를 찾은 후 S3에서 삭제한 후 게시글을 삭제해주어야 합니다.
그래서 간단히 요약하면,
1. 삭제하려는 게시글을 id를 통해서 DB에서 찾는다.
2. 게시글 작성자와 게시글을 삭제하려는 member가 동일한지 확인한다.
3. 삭제하려는 게시글에 달린 모든 이미지를 찾아 imageList에 저장한다.
4. imageList를 이용하여 S3 버킷에서 이미지를 삭제한다. ( 이 부분은 앞에서 설명했어요!)
5. 게시글을 삭제한다. 이때 DB에 저장되어있던 이미지도 함께 삭제된다.
(중간에 heart는 게시글에 눌린 좋아요와 관련한 부분입니다.)
<번외? dto 만들기> ← 궁금하면 보세요! ^^
public PostResponseDto makeDto(Post post)
지금 위에는 게시글 생성, 삭제 메소드만 나와있는데요, 훨씬 많은 메소드가 PostService에 만들어져 있습니다. 그리고 거의 대부분의 메소드가 post를 조회하는 메소드입니다. 그래서 dto를 만들어 반환하는 작업이 자주 쓰이더라구요..? 그래서 응답 dto를 만들어주는 메소드를 만들어서 사용했습니다. post와 관련한 모든 정보를 반환해주는데요! post의 내용, post에 달려있는 이미지 리스트를 반환합니다.
이 프로젝트는 일종의 중고거래 사이트를 만드는 것이었는데, buyerNickName은 거래가 성사된 경우에만 채워지도록 되어있는 것이구요!
위의 코드에 모두 나와있는 것은 아니지만, member의 id가 노출되는 것이 좀 우려되어서, dto에는 member의 id가 아닌 nickname이 반환될 수 있도록 했습니다. 저희 팀의 경우 member 마다 nickname이 중복 없이 존재할 수 있도록 했기 때문에,,, 가능했답니다
https://github.com/EFUB-SURFERS/BagEasy-back.git
전체 코드를 보고 싶으시다면 위의 링크 참고하시면 될 것 같습니다!
저도 처음 구현해본 기능이어서, 엄청 버벅대고 어려워했는데요 그래도 구글링 열심히 하다보니 어찌저찌 완성할 수 있었습니다!
피드백은 환영이니 얼마든지 달아주세요~! 피드백 주시면 정말 감사하겠습니다 :)