[미니프로젝트] 이미지 추가 및 게시글 추천 기능 구현하기.

박제현·2024년 1월 14일
0

jelog 만들기

목록 보기
13/15
post-thumbnail

MultiPartFile로 이미지를 업로드 🖼️

클라이언트로 부터 file 형태로 이미지를 서버로 업로드 받기 위해서, 스프링 부트 프로젝트는 아래와 같이 작성하였다.

 @PostMapping("/upload")
    public ResponseEntity<Map<String, String>> uploadImage(@RequestParam("file") MultipartFile file){
        try{
            File directory = new File(uploadDir);
            if(!directory.exists()){
                directory.mkdirs();
            }

            String fileName = file.getOriginalFilename();
            String filePath = directory.getAbsolutePath() + "/" + fileName;

            File destination = new File(filePath);
            file.transferTo(destination);

            Map<String, String> response = new HashMap<>();

            response.put("accessPath", accessPath);
            response.put("fileName", fileName);

            return ResponseEntity.ok(response);
        } catch (Exception e) {
            e.printStackTrace();
            return ResponseEntity.badRequest().body(new HashMap<>());
        }
    }

Post 방식으로 이미지 데이터를 file 형태로 받아온다.
그 뒤, 업로드 된 파일을 저장할 디렉토리를 찾고 없으면 생성한다.
클라이언트로 부터 받은 파일을 원하는 위치에 저장한 후, Response 로 돌려줄 Map 객체를 생성한다.
Response 에는 서버에 저장된 이미지에 접근할 수 있는 accessPathfileName 이 담겨있다.

  const uploadImage = async () => {
    const fileInput = document.querySelector(".imageSelector");
    if (fileInput.files[0]) {
      const file = await resizeImage(fileInput.files[0]);
      const formData = new FormData();
      console.log(file);
      formData.append("file", file);

      await axios
        .post("api/image/upload", formData, {
          headers: {
            "Content-Type": "multipart/form-data",
            Authorization: `Bearer ${token}`,
          },
        })
        .then(async (res) => {
          console.log(res.data);
          console.log("InsertImageToEditor Start");
          await insertImageToEditor(res.data.accessPath, res.data.fileName);
          console.log("InsertImageToEditor End");
        })
        .catch((err) => console.error(err));
    }
  };

클라이언트에서 서버로 업로드를 요청하기 위한 코드는 위와 같이 작성하였다.
기존의 코드에서 수정 된 점은 크게 없고, 요청 헤더에 jwt 만 추가하였다.

const insertImageToEditor = async (accessPath, fileName) => {
    const imgTag = document.createElement("img");

    imgTag.src = accessPath + "?fileName=" + fileName;

    const sel = window.getSelection();
    let range;

    // 현재 선택된 커서 위치를 가져옴.
    if (sel.rangeCount) {
      range = sel.getRangeAt(0);
    }

    // 커서가 contentTextareRef 내부에 있는지 확인
    if (
      range &&
      contentTextareaRef.current.contains(range.commonAncestorContainer)
    ) {
      range.insertNode(imgTag);

      // 이미지 뒤로 커서 설정
      range.setStartAfter(imgTag);
      range.collapse(true);
      sel.removeAllRanges();
      sel.addRange(range);
    } else {
      // 커서가 밖에 있거나, 선택되지 않은 경우
      contentTextareaRef.current.appendChild(imgTag);

      range = document.createRange();
      range.setStartAfter(imgTag);
      range.collapse(true);
      sel.removeAllRanges();
      sel.addRange(range);
    }

    const imageLoadPromise = new Promise((resolve) => {
      imgTag.onload = resolve;
    });

    await imageLoadPromise;
    await handleContentChange();
    enterKeyPress(contentTextareaRef.current);
    contentTextareaRef.current.focus();
  };

파일이 정상적으로 업로드 되면, response 로 받아온 정보로 부터 이미지에 접근할 수 있는 경로를 받게 된다.
해당 경로를 img 의 src 로 등록하면 이미지 업로드와 이미지 불러오기가 가능해진다.

이렇게 바로 업로드와 다운로드를 동시에 진행하는 이유는, DB에 게시글 내용을 저장할 때, 전체 div 태그의 outerHTML 로 저장하기 때문이다.

이 부분은 아마도 정답은 아닐 것이라고 생각되며, 추후에 수정이 필요할 것 같긴 하다.

위 이미지와 같이 이미지 삽입이 동작한다.

게시글 좋아요, 싫어요 기능 추가 ❤️💔

벨로그나 여타 다른 커뮤니티 게시글을 보면 좋아요 기능이 있는 것을 볼 수 있다.
그러한 좋아요 기능을 추가해보았다.

우선 테이블 부터 수정이 필요했다.
기존에는 PostLike 테이블이 User, Post 테이블을 참조하는 형태로 방향을 잡았는데, 그렇게 설정하면 PostPostLike 정보를 갖고 있고, 또 PostLikePost 정보를 담고 있어 무한 StackOverFlow 가 발생하였다.

이 점을 수정하기 위해서, PostLike 에는 userIdpostId 컬럼만 갖을 수 있도록 수정하였다.

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Table
@Entity
public class PostLike {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column
    private Long postLikeId;

    @Column
    private Long userId;

    @Builder
    public PostLike(Long userId) {
        this.userId = userId;
    }
}
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
@Getter
@Setter
@Table
@Entity
public class Post {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column
    private Long postId;

    @ManyToOne(targetEntity = User.class)
    @JoinColumn(name = "user_id")
    private User user;

    @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL)
    @JoinColumn(name = "post_id")
    private Set<PostLike> postLike;

    @Column
    @CreatedDate
    private LocalDateTime createdAt;

    @Column
    @LastModifiedDate
    private LocalDateTime updatedAt;

    @Column
    private String title;

    @Column
    private String content;

    @Column
    @Convert(converter = StringListConverter.class)
    private List<String> tags;

    @Builder
    public Post(User user, String title, String content, List<String> tags) {
        this.user = user;
        this.title = title;
        this.content = content;
        this.tags = tags;
    }

    public void update(String title, String content, List<String> tags){
        this.title = title;
        this.content = content;
        this.tags = tags;
    }
}

위 코드와 같이 단방향 맵핑의 방향을 Post 에서 PostLike 방향으로 수정하였다.
이렇게 수정하게 되면 Post 엔티티는 여러개의 PostLike 를 담은 Set<PostLike> 를 컬럼을 가지게 된다.

🚨🚨🚨 여기서부터 문제 발생.. 🚨🚨🚨

기존에는 PostLikePost 정보와 User 정보를 담고 있어, 누가 어떤 포스트를 좋아요 했는 지 바로 알 수 있었으나, 기존의 맵핑 방향을 수정하고 나니 JPA 에서는 PostLike 자체로 postId 를 참조? getPostId() 할 수 없었다.


하지만 DB에는 위와 같이 저장됨.

그래서, Post 테이블에 있는 Set<PostLike> 로 부터 클라이언트가 원하는 PostLike 객체를 찾기 위해서 JPA 쿼리를 사용하였다.

public interface PostLikeRepository extends JpaRepository<PostLike, Long> {
    @Query(value = "select * from post_like where post_id = :postId and user_id = :userId", nativeQuery = true)
    Optional<PostLike> findByPostIdAndUserId(Long postId, Long userId);
}

쿼리를 해석하면 post_like 테이블에서 :postId (입력 받는 postId) 와 :userId (입력 받은 userId) 에 일치하는 PostLike 객체를 보내준다.

PostLike 를 새로 추가할 때, 중복 체크를 하므로 중복된, 다수의 데이터가 반환 되는 경우는 없다.

    public boolean likePost(LikePostRequestDto requestDto) {
        Post post = postRepository.findById(requestDto.getPostId()).orElseThrow(() -> new AppException(ErrorCode.POSTS_NOTEXIST, "존재하지 않는 포스터"));

        Set<PostLike> postLikes = post.getPostLike();

        boolean alreadyLikedUser = postLikes.stream().anyMatch(postLike -> postLike.getUserId().equals(requestDto.getUserId()));

        if (!alreadyLikedUser) {
            postLikes.add(PostLike.builder().userId(requestDto.getUserId()).build());
            postRepository.save(post);

            return true;
        }

        return false;
    }

위 와 같이 새로운 postLike 객체를 추가하기 위해서, requestDto 를 통해 원하는 post 객체를 찾아내고 해당 post 객체의 Set<PostLike> 컬럼에 새로운 PostLike 객체를 추가하는 것이다.

    public boolean unLikePost(UnlikePostRequestDto requestDto) {
        Post post = postRepository.findById(requestDto.getPostId()).orElseThrow(() -> new AppException(ErrorCode.POSTS_NOTEXIST, "존재하지 않는 포스트"));

        Set<PostLike> postLikes = post.getPostLike();

        boolean alreadyLikedUser = postLikes.stream().anyMatch(postLike -> postLike.getUserId().equals(requestDto.getUserId()));

        if(alreadyLikedUser) {
            PostLike target = postLikeRepository.findByPostIdAndUserId(requestDto.getPostId(), requestDto.getUserId()).orElseThrow(() -> new AppException(ErrorCode.WRONG_ACCEPT, "잘못된 접근"));

            postLikes.remove(target);

            postRepository.save(post);
           return true;
        }

        return false;
    }

좋아요 취소 기능은 위와 비슷한 방식으로 작성했다.

    @PostMapping("/like/add")
    public ResponseEntity<String> likePost(@RequestBody LikePostRequestDto requestDto){
        boolean result = postService.likePost(requestDto);

        return ResponseEntity.ok(String.format("추천이 반영 되었는가 ? %b", result));
    }

    @DeleteMapping("/like/delete")
    public ResponseEntity<String> UnlikePost(@RequestBody UnlikePostRequestDto requestDto) {
        boolean result = postService.unLikePost(requestDto);

        return ResponseEntity.ok(String.format("추천이 반영 되었는가 ? %b", result));
    }

API 컨트롤러는 위와 같이 작성함.

리액트에서 동적으로 좋아요 기능 추가하기. 🏃

유저의 좋아요 반응을 실시간으로 포스트에 적용 시키기 위해서, useState() 훅을 사용한다.

  useEffect(() => {
    axios
      .get(`/api/post/read/${userNickName}/${postId}`)
      .then((res) => {
        console.log("첫 로딩");
        const result = isAlreadyLiked(res.data);
        setIsLiked(result);
      })
      .catch((err) => console.error(err));
  }, []);

  const isAlreadyLiked = (data) => {
    if (data.postLike.find((like) => like.userId === userInfo.userId)) {
      return true;
    }
    return false;
  };

가장 처음 화면이 로드될 때, 유저의 좋아요 여부를 체크한다. (로그인 된 상태일 경우)

  useEffect(() => {
    axios
      .get(`/api/post/read/${userNickName}/${postId}`)
      .then((res) => {
        console.log(res.data);
        setPostInfo(res.data);
      })
      .catch((err) => console.error(err));
  }, [isLiked]);

그 다음 좋아요 여부가 설정되었으면, 해당 여부를 토대로 게시글 엘레먼트들을 생성한다.

useEffect(() => {
    if (postInfo != null) {
      const contentElement = new DOMParser()
        .parseFromString(postInfo.content, "text/html")
        .querySelector("div");

      const tagElement = postInfo.tags.map((tag) => {
        return <div className="tag">{tag}</div>;
      });
      const post = (
        <div>
          <section className="postContainer">
            <section className="sideBarSection">
              <div className="sideBarMenu">
                {isLiked ? (
                  <button
                    className="buttonUnlike"
                    onClick={onClickUnlikeButton}
                  >
                    <FontAwesomeIcon icon={faHeart} />
                  </button>
                ) : (
                  <button className="buttonLike" onClick={onClickLikeButton}>
                    <FontAwesomeIcon icon={faHeart} />
                  </button>
                )}
                <span ref={likesCountRef} className="likesCount">
                  {postInfo.postLike.length}
                </span>
                <button className="buttonShare">
                  <FontAwesomeIcon icon={faShare} />
                </button>
              </div>
            </section>
... 이하 생략
      setPostElement(post);
    }
  }, [postInfo]);

유저가 이미 좋아요를 누른 상태라면 좋아요를 취소할 수 있도록, 그렇지 않다면 좋아요를 누를 수 있도록 작성한다.

여러개의 useState 훅을 연결하여 비로소 동적인 좋아요 기능이 동작하였다.

하지만, useState 훅의 연결은 매우 안좋은 코딩이라고 어디선가 들은 것 같은데... 도무지 방법을 모르겠다 🥲

다음은 댓글 기능을 추가해보자.

profile
닷넷 새싹

0개의 댓글