Post
와 Comment
를 post_id
를 FK로 설정하여 1 대 다로 맵핑한다.
그러기 위해서, 스프링 부트의 Post
테이블을 수정하였다.
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 List<Comment> comments;
@OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL)
@JoinColumn(name = "post_id")
private Set<PostLike> postLikes;
@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;
}
}
@OneToMany
어노테이션을 이용하여 Comment
테이블과 맵핑한다.
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column
private Long commentId;
@ManyToOne(targetEntity = User.class)
@JoinColumn(name = "user_id")
private User user;
@Column
private String content;
@Column
@CreatedDate
private LocalDateTime createdAt;
@Column
@LastModifiedDate
private LocalDateTime updatedAt;
@Builder
public Comment(User user, String content) {
this.user = user;
this.content = content;
}
public void update(String content){
this.content = content;
}
}
Comment
테이블은 위와 같이 작성함.
게시글에 새로운 댓글을 추가하기 위해서, 필요한 RequestBody
는 포스트 값을 찾기 위한 post_id
, 유저 정보를 찾기 위한 user_Email
, 댓글 내용인 content
를 DTO
에 포함한다.
@Getter
public class AddCommentRequestDto {
private String token;
private String userEmail;
private Long postId;
private String content;
}
Token 필터링을 추가하여 악의적인 접근 제한
API 호출 시 요청하게 되는 Header의 Authroization 으로 JWT를 이용한다.
기존의 WebSecurityFilter 는JWT
의 유효 여부만 판단하여, 접근을 제어했다.이러한 방식의 문제는 만일 게시글을 삭제하려고 할 때, 혹은 유저 정보를 요청할 때, 다른 사람의 JWT 를 이용하여 요청할 경우 정상적으로 접근을 허가 한다는 것이다.
이유는 다른 사람의 JWT 가 유효한 것이기 때문..이러한 관점에서, 요청하는 JWT의 소유주와 요청하는 대상(
Object
) 이 일치하는 가? 를 검사하여 접근을 제어하고자 한다.
위와 같은 이유로 인증이 필요한 모든 접근에 토큰 필터링을 추가하였다.
public static boolean isTokenOwner(String token, String secretKey, String userEmail) {
String tokenOwner = getClaimUserEmail(token, secretKey);
return userEmail.equals(tokenOwner);
}
==========================================================================================================
if(!JwtUtil.isTokenOwner(requestDto.getToken(), secretKey, requestDto.getUserEmail())){
throw new AppException(ErrorCode.WRONG_ACCEPT, "잘못된 접근입니다.");
}
JWT
에 Claim
으로 등록했던 소유주와 요청하는 자의 userEmail
이 다를 경우 접근을 제어한다.
우선, Post
테이블에서 Comment
테이블 방향으로 맵핑을 했기 때문에 JPA 에선 Comment
객체가 post_id
정보를 가지고 있지 않다. (실제 DB에선 가지고 있음)
그렇기 때문에 새로운 Comment
를 추가하려면, postRepository
로 부터 Post
객체를 찾고, 해당 Post
객체가 가지고 있는 List<Comment>
에 추가하여 저장한다.
@RequiredArgsConstructor
@Service
public class CommentService {
private final PostRepository postRepository;
private final UserRepository userRepository;
@Value("${jwt.secret}")
private String secretKey;
// C
public boolean write(AddCommentRequestDto requestDto){
if(!JwtUtil.isTokenOwner(requestDto.getToken(), secretKey, requestDto.getUserEmail())){
throw new AppException(ErrorCode.WRONG_ACCEPT, "잘못된 접근입니다.");
}
User user = userRepository.findByUserEmail(requestDto.getUserEmail()).orElseThrow(
() -> new AppException(ErrorCode.USER_DONT_EXIST, "존재하지 않는 유저입니다.")
);
Post post = postRepository.findById(requestDto.getPostId()).orElseThrow(
() -> new AppException(ErrorCode.POSTS_DONT_EXIST, "존재하지 않는 포스터입니다."));
List<Comment> comments = post.getComments();
Comment newComment = Comment.builder()
.user(user)
.content(requestDto.getContent())
.build();
comments.add(newComment);
postRepository.save(post);
return true;
}
// R
// U
public boolean modify(ModifyCommentRequestDto requestDto){
if(!JwtUtil.isTokenOwner(requestDto.getToken(), secretKey, requestDto.getUserEmail())){
throw new AppException(ErrorCode.WRONG_ACCEPT, "잘못된 접근입니다.");
}
User user = userRepository.findByUserEmail(requestDto.getUserEmail()).orElseThrow(
() -> new AppException(ErrorCode.USER_DONT_EXIST, "존재하지 않는 유저입니다.")
);
Post post = postRepository.findById(requestDto.getPostId()).orElseThrow(
() -> new AppException(ErrorCode.POSTS_DONT_EXIST, "존재하지 않는 포스터입니다."));
List<Comment> comments = post.getComments();
for (Comment comment : comments) {
if (comment.getCommentId().equals(requestDto.getCommentId())) {
comment.update(requestDto.getContent());
break;
}
}
postRepository.save(post);
return true;
}
// D
}
위와 같이 실제 저장은 postRepository
에서 저장되어진다.
const onClickAddCommentsButton = () => {
axios
.post(
"/api/private/comment/write",
{
token: token,
userEmail: userInfo.userEmail,
postId: postInfo.postId,
content: commentTextAreaRef.current.value,
},
{
headers: {
Authorization: `Bearer ${token}`,
},
}
)
.then((res) => {
console.log(res.data);
window.location.reload();
})
.catch((err) => {
console.error(err);
});
};
const commentInput = (
<section className="commentInputSection">
<span className="title">{postInfo.comments.length}개의 댓글</span>
<textarea
className="content"
ref={commentTextAreaRef}
placeholder="내용을 입력하세요..."
/>
<section className="buttonSection">
<button
className="addCommentsButton"
onClick={onClickAddCommentsButton}
>
댓글 작성
</button>
</section>
</section>
);
댓글을 입력하는 엘레먼트는 위와 같이 작성하여, textarea
의 value
를 내용 값으로 넣어주고 post
요청을 보내준다.
댓글을 나타내는 엘레먼트의 경우, 수정 버튼을 클릭했을 때 화면에 보여지던 댓글의 엘리먼트가 수정할 수 있는 형태로 변해야 하기 때문에 이 점에 유의하여 작성한다.
const comments = postInfo.comments.map((comment) => {
const isEditable = editableComment === comment.commentId;
return (
<section className="commentSection" key={comment.commentId}>
<div className="header">
<div className="userInfo">
<div
className="userIcon"
style={{ backgroundColor: comment.user.userIcon }}
/>
<div className="commentInfo">
<span className="userNickName">
{comment.user.userNickName}
</span>
{fromNow(comment.createdAt)}
</div>
</div>
<div className="commentFunctions">
{!isEditable && (
<button
className="modifyButton"
onClick={() => {
onClickCommentModifyButton(comment.commentId);
}}
>
수정
</button>
)}
<button className="deleteButton">삭제</button>
</div>
</div>
{!isEditable ? (
<span className="content">{comment.content}</span>
) : (
<section className="editCommentSection">
<textarea
className="editCommentTextArea"
ref={editCommentTextAreaRef}
defaultValue={comment.content}
/>
<div className="editCommentButtons">
<button
className="cancelButton"
onClick={() => {
setEditableComment(null);
}}
>
취소
</button>
<button
className="modifyConfirmButton"
onClick={() => {
onClickcommentModifyConfirmButton(comment.commentId);
}}
>
댓글 수정
</button>
</div>
</section>
)}
</section>
);
});
게시글로 부터 댓글 정보를 불러와 댓글 엘레먼트를 생성한다.
이 때, 게시글의 수정 상태 여부를 useState
로 사용하여 동적인 화면을 구현할 수 있도록 한다.
const onClickCommentModifyButton = (commentId) => {
setEditableComment(commentId);
};
const onClickcommentModifyConfirmButton = (commentId) => {
axios
.put(
"/api/private/comment/write",
{
token: token,
userEmail: userInfo.userEmail,
postId: postId,
commentId: commentId,
content: editCommentTextAreaRef.current.value,
},
{ headers: { Authorization: `Bearer ${token}` } }
)
.then((res) => {
console.log(res.data);
setEditableComment(null);
})
.catch((err) => console.log(err));
};
위 처럼 클릭 함수를 생성하고, 상태가 변할 경우 화면을 다시 그릴 수 있도록 작성하였다.
public Map<String, Post> getRecentPostsByUserId(Long userId, Long postId){
User user = userRepository.findById(userId).orElseThrow(() -> new AppException(ErrorCode.USER_DONT_EXIST, "존재하지 않는 계정입니다."));
List<Post> posts = postRepository.findByUserOrderByCreatedAtAsc(user).orElseThrow(() -> new AppException(ErrorCode.POSTS_DONT_EXIST, "포스트가 존재하지 않습니다."));
int postIdx = -1;
for(int i = 0; i < posts.size(); i++){
if(posts.get(i).getPostId().equals(postId)){
postIdx = i;
break;
}
}
Post prev = (postIdx > 0) ? posts.get(postIdx - 1) : null;
Post next = (postIdx < posts.size() - 1) ? posts.get(postIdx + 1) : null;
Map<String, Post> recentPosts = new HashMap<>();
recentPosts.put("prev", prev);
recentPosts.put("next", next);
return recentPosts;
}
특정 유저의 게시글을 작성일을 기준으로 불러와 가장 최근 두개의 게시글을 리턴한다.
이 때, 클라이언트에서 이용하기 쉽도록 Map<String, Post>
형태로 보내준다.
const recentPost = (
<div className="recentSection">
{recentPosts.prev && (
<Link
className="prevContainer"
to={`/post/${postInfo.user.userNickName}/${recentPosts.prev.postId}`}
onClick={onClickPrevPost}
>
<section className="buttonSection">
<button className="buttonPrev">
<FontAwesomeIcon icon={faArrowLeft} />
</button>
</section>
<section className="contentSection">
<span className="title">이전 포스트</span>
<span className="content">{recentPosts.prev.title}</span>
</section>
</Link>
)}
{recentPosts.next && (
<Link
className="nextContainer"
to={`/post/${postInfo.user.userNickName}/${recentPosts.next.postId}`}
onClick={onClickNextPost}
>
<section className="contentSection">
<span className="title">다음 포스트</span>
<span className="content">{recentPosts.next.title}</span>
</section>
<section className="buttonSection">
<button className="buttonNext">
<FontAwesomeIcon icon={faArrowRight} />
</button>
</section>
</Link>
)}
</div>
recentPost
에 prev
, 혹은 next
가 존재할 때만 화면에 인디케이터가 나타날 수 있도록 구현한다.