클라이언트로 부터 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 에는 서버에 저장된 이미지에 접근할 수 있는 accessPath
와 fileName
이 담겨있다.
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
테이블을 참조하는 형태로 방향을 잡았는데, 그렇게 설정하면 Post
가 PostLike
정보를 갖고 있고, 또 PostLike
가 Post
정보를 담고 있어 무한 StackOverFlow
가 발생하였다.
이 점을 수정하기 위해서, PostLike
에는 userId
와 postId
컬럼만 갖을 수 있도록 수정하였다.
@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>
를 컬럼을 가지게 된다.
🚨🚨🚨 여기서부터 문제 발생.. 🚨🚨🚨
기존에는 PostLike
가 Post
정보와 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
훅의 연결은 매우 안좋은 코딩이라고 어디선가 들은 것 같은데... 도무지 방법을 모르겠다 🥲
다음은 댓글 기능을 추가해보자.