게시글 생성 + 파일 업로드(SpringBoot + JPA + AWS S3)

이번 글에서는 게시글 생성 + 파일 업로드(Aws S3)를 다룰 것이다. 🕌

  • Post
@Getter
@NoArgsConstructor(access = PROTECTED)
@Entity
@EntityListeners(AuditListener.class)
public class Post implements Auditable {

    @Id
    @GeneratedValue(strategy = IDENTITY)
    @Column(name = "post_id")
    private Long id;

    @Column(nullable = false)
    private String title;

    @Column(nullable = false, length = 1000)
    private String content;

    @Embedded
    private TimeEntity timeEntity;

    @ColumnDefault("0")
    @Column(name = "view_count",nullable = false)
    private Integer viewCount;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    @ManyToOne(fetch = LAZY,cascade = CascadeType.PERSIST)
    @JoinColumn(name = "post_category_id")
    private PostCategory postCategory;

    @OneToMany(mappedBy = "post", orphanRemoval = true)
    private List<Comment> comments = new ArrayList<>();

    @OneToMany(mappedBy = "post", orphanRemoval = true)
    private List<PostLike> postLikes = new ArrayList<>();

    @OneToMany(mappedBy = "post", orphanRemoval = true)
    private List<Scrap> scraps = new ArrayList<>();

    @OneToMany(mappedBy = "post", orphanRemoval = true)
    private List<PostImage> postImages = new ArrayList<>();


    @Override
    public void setTimeEntity(TimeEntity timeEntity) {
        this.timeEntity = timeEntity;
    }

    @Builder
    public Post(String title, String content, Integer viewCount, User user, List<Comment> comments, PostCategory postCategory) {
        this.title = title;
        this.content = content;
        this.viewCount = viewCount;
        this.user = user;
        this.comments = comments;
        this.postCategory = postCategory;
    }

    /**
     * 생성 메서드
     */
    public static Post createPost(String title, String content, User user, PostCategory postCategory){
        return Post.builder()
                .title(title)
                .content(content)
                .user(user)
                .postCategory(postCategory)
                .build();
    }

    public void updatePost(String title, String content) {
        this.title = title;
        this.content = content;
    }

    /**
     * 초기화 값이 DB 에 추가되지 않는 오류가 있어서
     * persist 하기 전에 초기화
     */
    @PrePersist
    public void prePersistCount(){
        this.viewCount = this.viewCount == null ? 0 : this.viewCount;
    }
}
  • Post엔티티는 위와 같고 자세한 설명은 넘어가겠다.

  • 먼저 게시글을 생성 하려면 제목, 내용, 카테고리 이름, 이미지파일을 프론트에서 넘겨받아야 한다.



  • PostRequest
@ApiModel(description = "게시글 생성 요청 데이터 모델")
@Getter
@Setter
@NoArgsConstructor
public class PostRequest {

    @ApiModelProperty(value = "게시글 제목", example = "모아모아 화이팅!", required = true)
    private String title;

    @ApiModelProperty(value = "게시글 내용", example = "무야호", required = true)
    private String content;

    @ApiModelProperty(value = "커뮤니티 게시글의 카테고리 이름", example = "모아모아", required = true)
    private String categoryName;

    @ApiModelProperty(value = "이미지 파일", required = false)
    private List<MultipartFile> imageFiles = new ArrayList<>();

}
  • 이미지 파일은 List로 여러 개의 파일을 받을 것이다.


  • PostController
@ApiOperation(value = "게시글 생성", notes = "Form Data 값을 받아와서 글을 생성하는 API",
            consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    @ApiResponses({
            @ApiResponse(responseCode = "200", description = "해당 게시글이 정상적으로 생성된 경우"),
            @ApiResponse(responseCode = "404", description = "회원의 Id를 찾지 못한 경우")
    })
    @PostMapping
    public PostCreateResponse createPost(@ModelAttribute PostRequest request){
        return postService.createPost(request);
    }
  • 필자는 Swagger 를 사용해서 API 문서화를 하고 있다.

  • 이미지 파일을 전송할 때는 FormData 방식으로 전달을 해야 하고, Json 타입의 제목, 내용, 카테고리 이름을 같이 보내기 때문에 @RequestPart를 사용해서 받을 수 있으나, Swagger로 명확하게 볼 수 없기 때문에 @ModelAttribut를 사용했다.



  • PostService
@Transactional
    public PostCreateResponse createPost(PostRequest postRequest) {
        PostCategory postCategory = postCategoryRepository.findByCategoryName(postRequest.getCategoryName())
                .orElseGet(() -> PostCategory.createCategory(postRequest.getCategoryName()));
        User user = userUtil.findCurrent();
        Post post = postRepository.save(Post.createPost(postRequest.getTitle(), postRequest.getContent(), user, postCategory));
        List<String> postImages = uploadPostImages(postRequest, post);

        return new PostCreateResponse(post.getId(), "게시글 작성이 완료되었습니다.", postImages);
    }
  • 먼저 위에 postCategoryRepository.findByCategoryName()은 게시글의 카테고리가 존재하는지 확인하고 없다면 카테고리를 새로 생성해서 가져오는 로직인데 위의 코드는 다시 변경을 할 예정이다.(DB에서 미리 만들어놓고 사용 예정)

  • 여기서 중요한 점은 필자는 JWT를 사용하고 있어서 사용자의 정보를 따로 넘겨주지는 않고, HttpHeader에서 AccessToken을 받아서 그걸로 사용자에 대한 정보를 검증을하고, 검증된 결과를 SecurityContext에 담아주기 때문에 userUtil.findCurrent() 메서드로 유저에 대한 정보를 받아올 수 있다.

  • Request로부터 받아온 값들을 PostRepository.save()로 게시글을 생성한다.

uploadImages()

    private List<String> uploadPostImages(PostRequest postRequest, Post post) {
        return postRequest.getImageFiles().stream()
                .map(image -> s3Uploader.upload(image, "post"))
                .map(url -> createPostImage(post, url))
                .map(postImage -> postImage.getImageUrl())
                .collect(Collectors.toList());
    }

createPostImage()

 private PostImage createPostImage(Post post, String url) {
        return postImageRepository.save(PostImage.builder()
                .imageUrl(url)
                .storeFilename(StringUtils.getFilename(url))
                .post(post)
                .build());
    }
  • 먼저 request로 받아온 이미지 파일들을 stream()을 사용해서 미리 설정한 S3 경로에 저장한 후 PostImage 엔티티를 만들어 주고, 그 이미지의 경로를 List로 반환하게 된다.
  • PostImage
@Entity
@Getter
@NoArgsConstructor(access = PROTECTED)
@EntityListeners(AuditListener.class)
public class PostImage implements Auditable {

    @Id
    @GeneratedValue(strategy = IDENTITY)
    @Column(name = "post_image_id")
    private Long id;

    private String imageUrl;

    private String storeFilename;

    @Embedded
    private TimeEntity timeEntity;

    @ManyToOne
    @JoinColumn(name = "post_id")
    private Post post;

    @Builder
    public PostImage(String imageUrl, String storeFilename, Post post) {
        this.imageUrl = imageUrl;
        this.storeFilename = storeFilename;
        this.post = post;
    }

    @Override
    public void setTimeEntity(TimeEntity timeEntity) {
        this.timeEntity = timeEntity;
    }
}


Postman 테스트

  • @ModelAttribute로 지정을 해 주었다면 위와같이 form-data 방식으로 key + value 방식으로 보내주어야한다.

  • 파일의 Content Type = multiparty/form-data

  • 나머지는 Json 방식으로 보내기 때문에 위와같이 설정을 했다.

결과 응답



게시글 수정 🏡

  • PostUpdateRequest
@ApiModel(description = "게시글 수정 요청 데이터 모델")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class PostUpdateRequest {

    @ApiModelProperty(value = "게시글 PK", example = "1", required = true)
    @NotNull
    private Long postId;

    @ApiModelProperty(value = "게시글 제목", example = "모아모아 화이팅!")
    private String title;

    @ApiModelProperty(value = "게시글 내용", example = "무야호")
    private String content;

    @ApiModelProperty(value = "삭제한 이미지 경로를 제외한 남아있는 게시글 이미지 경로")
    private List<String> saveImageUrl = new ArrayList<>();

    @ApiModelProperty(value = "게시글 이미지", required = false)
    private List<MultipartFile> imageFiles = new ArrayList<>();

}
  • 수정을 할 때는 위와 같이 삭제한 이미지 경로를 제외한 남아있는 게시글 이미지의 경로를 List로 보내주어야 한다.

PostController

 @ApiOperation(value = "게시글 수정", notes = "Request Body 값을 받아와서 글을 수정하는 API")
    @ApiResponses({
            @ApiResponse(responseCode = "200", description = "해당 게시글이 정상적으로 수정된 경우"),
            @ApiResponse(responseCode = "404", description = "회원 OR 게시글의 Id를 찾지 못한 경우")
    })
    @PatchMapping
    public PostUpdateResponse updatePost(@ModelAttribute PostUpdateRequest request) {
        return postService.updatePost(request);
    }

postService

@Transactional
    public PostUpdateResponse updatePost(PostUpdateRequest request) {
        User user = userUtil.findCurrent();
        Post post = postRepository.findByIdAndUser(request.getPostId(), user.getId())
                .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_POST));

        validateDeletedImages(request);
        uploadPostImages(request, post);
        List<String> saveImages = getSaveImages(request);

        post.updatePost(request.getTitle(), request.getContent());

        return new PostUpdateResponse(post.getId(), "게시글 변경이 완료되었습니다.", saveImages);
    }
  • 게시글 수정은 게시글 생성보다 더 까다롭게 흘러간다.
  1. 유저 정보를 찾는다

  2. 유저와 게시글의 관계를 찾는다(수정할 권한이 있는지)

  3. Request 로 받아온 이미지 경로랑 저장 되어있던 이미지 경로랑 일치하지 않는다면 모두 삭제

  4. 파일을 추가할 경우S3에 업로드 및 PostImage 생성

  5. PostImage 테이블에 저장 되어있는 이미지 경로를 추출

  6. 게시글의 변경사항을 JPA의 변경감지 기능을 이용하여 update

validateDeletedImages()

    /**
     * @Request로 받아온 이미지 경로랑 저장 되어있던 이미지 경로랑 일치하지 않는다면 모두 삭제
     */
    private void validateDeletedImages(PostUpdateRequest request) {
        postImageRepository.findBySavedImageUrl(request.getPostId()).stream()
                .filter(image -> !request.getSaveImageUrl().stream().anyMatch(Predicate.isEqual(image.getImageUrl())))
                .forEach(url -> {
                    postImageRepository.delete(url);
                    s3Uploader.deleteImage(url.getImageUrl());
                });
    }
  • stream을 사용해서 request로 받아온 이미지 경로랑 PostImage 테이블에 저장 되어있는 경로랑 일치하는지 검증을 한 후 일치하지 않는다면 모두 삭제하는 로직이다.

uploadPostImages()

    /**
     * S3에 업로드 및 PostImage 생성
     */
    private void uploadPostImages(PostUpdateRequest request, Post post) {
        request.getImageFiles()
                .stream()
                .forEach(file -> {
                    String url = s3Uploader.upload(file, "post");
                    createPostImage(post, url);
                });
    }

getSaveImages()

    /**
     * PostImage 테이블에 저장 되어있는 이미지 경로를 추출
     */
  private List<String> getSaveImages(PostUpdateRequest request) {
        return postImageRepository.findBySavedImageUrl(request.getPostId())
                .stream()
                .map(image -> image.getImageUrl())
                .collect(Collectors.toList());
    }


Postman 테스트

  • 기존에 파일을 2개를 저장해놓은 상태이고, 이미지 경로를 하나만 넣어서 하나만 삭제를하고, 2개의 파일을 더 넣을것이다 그러면 총 3개의 이미지경로가 반환이 되어야한다.

결과 화면



S3에 대한 설정 및 로직들은 다른 블로그에 많이 올라와있으니 참고하시길.. 🍎

좋은 웹페이지 즐겨찾기