코드 훔쳐보는 변태 코더
춤 좋아하는 백엔드 개발자(였으면 좋겠다)
스프링부트 + 타임리프로 동기식 댓글수정 기능 구현하기

혼자서 토이 프로젝트로 게시판을 구현하고 있었는데,

유독 막혔던 부분이 있었다.

바로 댓글 수정 기능이었다.

혼자서 뚝딱 만들어가고 있던건 아니고 참고했던 강의, 책등이 있었는데

거기서 공통적으로 패스했던 부분이 댓글수정 이었다.

게시글 수정 같은 경우에는 동기 식으로 폼을 세터로 기존에 존재했던 내용들을 지정해준 뒤에 객체로 전달하면 폼안에 내용이 그대로 들어있는 상태로 편하게 수정 할 수 있었는데

댓글 같은 경우에는 동기식으로 수정을 구현하기 어려웠고, 내가 알고있는것들 안에서 해결 할 수 있는 방법을 찾고 싶었으나 , 대체적으로 다들 Ajax 를 활용하여 비동기식으로 댓글 기능을 구현 하셨어서.. 내가 당장 쏙 빼먹을만한 무언가를 찾기가 힘들었다.

시작해보기

댓글 리퀘스트 Dto 작성

일단 나는 댓글저장,수정 같은 경우에는 Dto 자체를 넘겨주지 않고 입력 수정용 RequestDto 를 따로 만들어서 넘겨주었다.

public record ArticleCommentRequest( //JPA BUDDY 를 이용해 DTO 생성
        Long articleId
        ,
        String content) implements Serializable {

    public static ArticleCommentRequest of(Long articleId, String content) {
        return new ArticleCommentRequest(articleId, content);
    }


    public ArticleCommentDto toDto(UserAccountDto userAccountDto) { //로그인 세션
        return ArticleCommentDto.of(
                articleId,      
                userAccountDto,
                content
        );
    }

댓글 입력과 수정 삭제 등등은 모두 로그인 된 상태에서만 동작하기 때문에, 현재 로그인 되어있는 계정의 정보를 집어 넣어준다.

계정 Dto 작성

public record BoardPrincipal(
        String username,
        String password,
        Collection<? extends GrantedAuthority> authorities,
        String email,
        String nickname,
        String memo
)  implements UserDetails {
    public static BoardPrincipal of(String username, String password, String email, String nickname, String memo) {
       
        Set<UserAccountRole> roleTypes = Set.of(UserAccountRole.USER);

        return new BoardPrincipal(
                username,
                password,
                roleTypes.stream()
                        .map(UserAccountRole::getValue)
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toUnmodifiableSet())
                ,
                email,
                nickname,
                memo
        );
    }
    public static BoardPrincipal from(UserAccountDto dto) {
        return BoardPrincipal.of(
                dto.userId(),
                dto.userPassword(),
                dto.email(),
                dto.nickname(),
                dto.memo()
        );
    }

    public UserAccountDto toDto() {
        return UserAccountDto.of(username,password, email, nickname, memo);
    }

UserDetails 인터페이스를 구현한 세션용 dto 를 만든다.
저장 데이터는 계정 테이블에 존재하는 컬럼들을 넣어주면 된다. (저 RoleType은 당장 필요 없을듯..)

@RequiredArgsConstructor
@Transactional
@Service
public class UserSecurityService implements UserDetailsService {
    private final UserAccountRepository userAccountRepository;

    @Override
    public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
            Optional<UserAccount> _account = userAccountRepository.findById(userId);
            if (_account.isEmpty()) {
                throw new UsernameNotFoundException("사용자를 찾을수 없습니다.");
            }
            UserAccount account = _account.get();
            List<GrantedAuthority> authorities = new ArrayList<>();
            if ("admin".equals(userId)) {
                authorities.add(new SimpleGrantedAuthority(UserAccountRole.ADMIN.getValue()));
            } else {
                authorities.add(new SimpleGrantedAuthority(UserAccountRole.USER.getValue()));
            }
        PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
        return new BoardPrincipal(account.getUserId(), account.getUserPassword(), authorities, account.getEmail(), account.getNickname(), account.getMemo());
        }

저 BoardPrincipal 없이도 단순히 UserDetailsService 구현으로 반환값을 new User 로 주면 될거같긴한데.. 안해봐서 모르겠다. (이 클래스는 로그인 되었을때 로그인된 계정 정보를 넘겨주는 역할을 한다)

컨트롤러와 서비스 클래스 작성

 @GetMapping("/{articleId}")
    public String article(@PathVariable Long articleId, ModelMap map , ArticleCommentRequest dto,
                          CommentForm commentForm){
        ArticleWithCommentResponse article =  ArticleWithCommentResponse.from(articleService.getArticle(articleId));
        map.addAttribute("article",article);
        map.addAttribute("articleComments", article.articleCommentsResponse());
        map.addAttribute("dto",dto);
        return "articles/detail";
    }
    

댓글은 보통 한 페이지에 댓글만 달랑 있는게 아니라 게시글 밑에 댓글이 존재하니 Get 메소드로 게시글 하나를 조회할때 리퀘스트 Dto 도 같이 넘겨준다.
코멘트 폼은 @Valid 전용으로 넣어준 값인데.. 활용이 되는듯 안되는듯 (에러가 생기면 전 페이지로 돌아가고 문구가 출력되어야 하는데 되돌아가기만 한다.)

    @PreAuthorize("isAuthenticated()")
    @PostMapping("/{articleId}")
    public String writeArticleComment(@PathVariable Long articleId,ArticleCommentRequest dto,
                                      @Valid CommentForm commentForm,
                                      BindingResult bindingResult,
                                      @AuthenticationPrincipal BoardPrincipal principal){
        if (bindingResult.hasErrors()) {
            return "redirect:/articles/"+articleId;
        }
        articleCommentService.saveArticleComment(dto.toDto(principal.toDto()));
        return "redirect:/articles/"+articleId;
    } //현재 접속한 사용자의 정보를 받아와 DTO로 넘겨주고 , 댓글을 작성함
    
    
    @PreAuthorize("isAuthenticated()")
    @PostMapping("/update/{articleId}/{articleCommentId}")
    public String updateArticleComment(@PathVariable Long articleCommentId
            ,@PathVariable Long articleId
            ,@Valid CommentForm commentForm, BindingResult bindingResult,ArticleCommentRequest request){
        if (bindingResult.hasErrors()) {
            return "redirect:/articles/"+articleId;
        }
        articleCommentService.updateArticleComment(articleCommentId,request.content());
        return "redirect:/articles/"+articleId;
    }

post 메소드로 댓글을 저장할때 쓸 메소드를 선언해준다. 댓글이 달리고 바로 보고있던 게시글을 다시 출력하게 게시글 아이디와 , 검증용 BindingResult 도 추가해준다.
@AuthenticationPrincipal 어노테이션을 붙여서 UserDetails 서비스에서 아까 리턴해준 값들을 들고온다.

수정은 단순히 입력값만 String 값으로 넣어준다.

    public void saveArticleComment(ArticleCommentDto dto) {
        try {
            Article article = articleRepository.getReferenceById(dto.articleId());
            UserAccount userAccount = userAccountRepository.findById(dto.userAccountDto().userId()).orElseThrow();
            ArticleComment articleComment = ArticleComment.of(article,userAccount,dto.content());
            articleCommentRepository.save(articleComment);
        } catch (EntityNotFoundException e) {
            log.warn("댓글 저장 실패. 댓글 작성에 필요한 정보를 찾을 수 없습니다 - {}", e.getLocalizedMessage());
        }
    }
    
    public void updateArticleComment(Long id,String content) {
        try {
            ArticleComment articleComment = articleCommentRepository.getReferenceById(id);
            if (content != null) { articleComment.setContent(content);}
        } catch (EntityNotFoundException e) {
            log.warn("댓글 업데이트 실패. 게시글을 찾을 수 없습니다 - dto: {}", e.getLocalizedMessage());
        }
    }

ArticleCommentRequest 에서 변환된 Dto에서 엔티티를 뽑아내서 댓글 Repository 에 저장 혹은 기존에 있던 댓글을 뽑아내와서 수정한다.

솔직히 여기까지는 큰 문제가 없다.

타임리프로 뷰에서 기능을 구현하는게 가장 골치아프다고 해야되나 ..

댓글 작성 뷰 작성과 기능 구현

<p class="comment">댓글</p>
        <div>
            <p class="comment2" sec:authorize="isAnonymous()">로그인 후 댓글을 작성할 수 있습니다.</p>
        </div>
        <div>
            <form sec:authorize="isAuthenticated()"
                  th:action="@{|/articles/comments/${article.id}|}" th:object="${dto}" method="post">
                <div class="input-group" style="width:auto">

                    <label for="content" class="form-label mt-4" hidden>댓글 작성</label>
                    <input type="text" th:field="*{content}" class="form-control" id="content" name="content"
                           placeholder="1자에서 100자 이내로 입력해주세요.">
                    <div class="field-error" th:errors="*{content}">
                        오류2
                    </div>
                    <button type="submit" class="btn btn-outline-dark">작성</button>
                    <br>
                </div>
            </form>

순서대로 설명해보자면 .. 로그인 되어있지 않은 상태에서는 로그인 후에 댓글을 작성할 수 있다는 문구를 출력해준다.

로그인 되어있는 상태라면, 댓글 작성 url 을 포스트 메소드로 걸어놓고, 내용 입력 태그에 타임리프 문법으로 content 라는 값에 입력할 필드라는 th:field 를 입력해준다.

 

(그 밑 th:errors 는 Bingding Results 가 에러를 가질때 에러 문구를 출력할 자리인데.. 댓글폼은 동작을 안하더라 ㅠㅠ)

여기까지도 딱히 큰 문제는 없다.. 진짜 문제는 댓글 수정 ..

 

댓글 수정 같은 경우에는 , 타임리프로 each 문을 돌려서, 모델맵으로 넘겨준 댓글 리스트에서 댓글을 하나씩 뽑아와서 출력하게 했고, 그 댓글 닉네임 출력부분 옆에, 로그인 상태이고 댓글 작성자가 로그인된 아이디와 같다면 수정 삭제 버튼이 출력되도록 했다.

 

내가 구현하고 싶었던건 , 수정 버튼을 누르면 댓글 하나가 내용이 폼 입력칸으로 바뀌면서 기존에 입력되있던 값을 갖고와서 원하는부분만 수정하는쪽으로 하고싶었지만,

 

그렇게 하려면 Jquery 를 이용해서 비동기식으로 다들 하시는거 같았다. 근데 나에겐 너무 복잡했고 이미 동기식으로 처리를 다 해놓은 나는 그렇게 하려면 바꿔야할게 너무 많았다고 생각했다.

 

그래서 생각했던게, 부트스트랩 콜랩스를 이용해서 수정 버튼을 누르면 입력칸이 밑으로 뾰로롱~ 펼쳐져서 수정을 하면 좋겠거니~ 라고 생각했다.

그래서 이렇게까지 구현을 했었다.

근데 이렇게 하니까 문제점이 뭘까 ..?

내가 수정이 가능한 모든 댓글에 입력칸이 등장했다...........

문제가 뭘까 하고 온갖 방법을 시도해보았었다. 하지만 결국엔 해결을 못하고 있다가

th:id 라는 문법이 있다는걸 발견했다.

 

그리고 가장 큰 문제는 내가 타임리프 문법속 each 문에 다른것들과 똑같이 콜랩스를 적용시켜도 콜랩스는 그 각각 칸으로 적용되는게 아닌 딱 그 태그 하나만 실행이 되는것 같았다. 결론적으론 프론트 문제였다.

 

온갖 검색을 동원한 결과..

<button th:attr="data-bs-target=|#c${articleComment.id}">
...
<div class="collapse" th:id="c + ${articleComment.id}">

이런 식으로 각 댓글 div 마다 댓글id 를 고유값으로 입력하게 하여서 one to many 에서 one to one 으로 변경을 해주었다.

그 결과 이렇게 각각 칸마다 수정을 할 수 있게 콜랩스 기능이 동작했다!

마무리

타임리프로 이렇게 댓글 수정을 구현 하는 글은 찾지 못했고,

도저히 못해결 하겠어서 고민하다가.. 스택오버플로우에 나처럼 비슷하게 콜랩스를 각 객체마다 넣고싶은데, 그게 안돼서 고민하시던 몇몇분들의 글을 참고해서 해결해보았다.

수정 관련 html 소스는 길고 또 너무 드러워서 .. (가독성이 안좋음..) 개인 기록용으로 글을 남기지만 혹시 필요한 분이 계시면 언제든 도와드리니 댓글 남겨주세용

  Comments,     Trackbacks