Group Study (2022-2023)/Spring 입문

[spring 입문] 토이프로젝트 - TwoYeon

walbe0528 2022. 12. 20. 23:45

투연 - with 한채연, 황연진

깃허브 링크

https://github.com/TwoYeon/GDSC-ToyProject

 

GitHub - TwoYeon/GDSC-ToyProject: 우당탕탕 스프링 초보들의 고군분투 레포

우당탕탕 스프링 초보들의 고군분투 레포. Contribute to TwoYeon/GDSC-ToyProject development by creating an account on GitHub.

github.com

 

선택한 기능

  • 대댓글 기능
  • 이미지를 포함한 게시글 작성 및 조회

 

팀 규칙

  • 이슈 → 코딩 → PR 순서
  • 이슈 템플릿, PR 템플릿 사용
  • 커밋 컨벤션

 

기능을 구현하기 위해 조사하면서 얻은 인사이트

채연

 

연진


기능 구현 계획

기능1 : 댓글 기능

  • 댓글 작성자 닉네임 보여주기
  • 댓글 작성 시간과 날짜 함께 보여주기
  • 대댓글 작성
  • 댓글 삭제

기능2 : 이미지를 포함한 게시글 작성 및 조회

  • 게시글 작성 시 이미지 1장 첨부
  • 조회 화면에서 제목 눌렀을 때 제목, 내용과 함께 이미지 보여주기

핵심 코드

댓글

  • Entity
    • Comment 엔티티
    package com.gdsc.study.gdscspringbootstudy.domain.comments;
    
    import com.gdsc.study.gdscspringbootstudy.domain.BaseTimeEntity;
    import com.gdsc.study.gdscspringbootstudy.domain.posts.Posts;
    import lombok.Builder;
    import lombok.Getter;
    import lombok.NoArgsConstructor;
    import lombok.Setter;
    import org.hibernate.annotations.ColumnDefault;
    
    import javax.persistence.*;
    import java.time.LocalDateTime;
    
    @Setter
    @Getter
    @NoArgsConstructor
    @Entity
    public class Comments extends BaseTimeEntity {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    
        @ManyToOne(fetch = FetchType.LAZY)
        @JoinColumn(name = "posts_id")
        private Posts posts;
    
        @Column(columnDefinition = "TEXT", length = 500, nullable = false)
        private String content_c;
    
        private String username;
    
        @Column(nullable = false)
        private Long groupNum;
    
        @Column(nullable = false)
        private int groupOrder;
    
        @Column(nullable = false)
        private int groupDepth;
    
        @Column(nullable = false)
        private String deleted;
    
        @Builder
        public Comments(Posts posts, String content_c, String username, Long groupNum, int groupOrder, int groupDepth, String deleted){
            this.posts = posts;
            this.content_c = content_c;
            this.username = username;
            this.groupNum = groupNum;
            this.groupOrder = groupOrder;
            this.groupDepth = groupDepth;
            this.deleted = deleted;
        }
    
    }
    
    
  •  Dto
    • CommentsSaveRequestDto
package com.gdsc.study.gdscspringbootstudy.web.dto;

import lombok.Getter;

@Getter
public class CommentsSaveRequestDto {  // 댓글 작성 dto

    private String content_c;
    private String username;
    private int groupOrder = 0;  // 대댓글의 순서
    private int groupDepth = 0;  // 깊이 (원댓글=0, 대댓글=1)
}
  • CommentsListResponseDto
package com.gdsc.study.gdscspringbootstudy.web.dto;

import com.gdsc.study.gdscspringbootstudy.domain.comments.Comments;
import lombok.Getter;

import java.time.LocalDateTime;

@Getter
public class CommentsListResponseDto {

    private Long id;
    private String content_c;
    private String username;
    private LocalDateTime createdDate;

    public CommentsListResponseDto(Comments commentsEntity){
        this.id = commentsEntity.getId();
        this.content_c = commentsEntity.getContent_c();
        this.username = commentsEntity.getUsername();
        this.createdDate = commentsEntity.getCreatedDate();
    }
}
    • ReCommentsSaveRequestDto
package com.gdsc.study.gdscspringbootstudy.web.dto;

import lombok.Getter;

@Getter
public class ReCommentsSaveRequestDto {

    private String content_c;
    private String username;
    private int groupDepth = 1;  // 깊이 (원댓글=0, 대댓글=1)
}
  • Service
    • CommentsService
@Transactional
public Long save(Long postId, CommentsSaveRequestDto commentsSaveRequestDto){
    Comments commentEntity = Comments.builder()
            .posts(postsRepository.findById(postId).get())
            .content_c(commentsSaveRequestDto.getContent_c())
            .username(commentsSaveRequestDto.getUsername())
            .groupOrder(commentsSaveRequestDto.getGroupOrder())  // 댓글이면 0
            .groupNum(0L)
            .groupDepth(commentsSaveRequestDto.getGroupDepth())
            .deleted("X")
            .build();

    commentsRepository.save(commentEntity);
    commentEntity.setGroupNum(commentEntity.getId());
    return commentsRepository.save(commentEntity).getId();
}

@Transactional
public Long saveRecomment(Long postId, Long commentId, ReCommentsSaveRequestDto reCommentsSaveRequestDto){
    Optional<Comments> originComment = commentsRepository.findById(commentId);  // 원댓글 가져오기
    Long num = originComment.get().getId();
    commentsRepository.mComments(num);

    Comments commentEntity = Comments.builder()
            .posts(postsRepository.findById(postId).get())
            .content_c(reCommentsSaveRequestDto.getContent_c())
            .username(reCommentsSaveRequestDto.getUsername())
            .groupOrder(commentsRepository.mComments(num))  // 대댓글 순서, 1부터 시작
            .groupNum(num)
            .groupDepth(reCommentsSaveRequestDto.getGroupDepth())
            .deleted("X")
            .build();

    return commentsRepository.save(commentEntity).getId();
}

@Transactional
public void delete(Long id) {
    Comments comments = commentsRepository.findById(id)
            .orElseThrow(() -> new IllegalArgumentException("해당 댓글이 없습니다. id="+id));
    commentsRepository.delete(comments);
}

@Transactional
public Long deleteComment(Long commentId){
    Comments commentEntity = commentsRepository.findById(commentId)
            .orElseThrow(() -> new IllegalArgumentException("해당 댓글이 없습니다. id=" + commentId));
    commentEntity.setDeleted("삭제된 댓글입니다");
    return commentEntity.getId();
}
  • Controller
    • IndexController
@GetMapping("/posts/update/{id}")
public String postsUpdate(@PathVariable Long id, Model model) {
    PostsResponseDto dto = postsService.findById(id);
    model.addAttribute("post", dto);
    return "posts-update";
}

@GetMapping("/posts/detail/{id}")
public String postsDetail(@PathVariable Long id, Model model) {
    PostsResponseDto dto = postsService.findById(id);
    model.addAttribute("detail", dto);
    model.addAttribute("comments", commentsService.findAllDesc(id));
    return "posts-detail";
}
  • CommentsApiController
@PostMapping("")  // 댓글 작성 api
    public Long save(@PathVariable Long postId, @RequestBody CommentsSaveRequestDto commentsSaveRequestDto){
//        System.out.println("http://localhost:8080/api/v1/comments/1 요청=================");
        return commentsService.save(postId, commentsSaveRequestDto);
    }

    @PostMapping("{commentId}/recomment")  // 대댓글 작성 api
    public Long save(@PathVariable Long postId, @PathVariable Long commentId, @RequestBody ReCommentsSaveRequestDto reCommentsSaveRequestDto){
        return commentsService.saveRecomment(postId, commentId, reCommentsSaveRequestDto);
    }


    @PostMapping("{commentId}")  // 댓글 삭제 api =>
    public Long deleteComment(@PathVariable Long postId, @PathVariable Long commentId){
        return commentsService.deleteComment(commentId);
    }

    @DeleteMapping("{commentId}")  // 대댓글 삭제 api
    public Long delete(@PathVariable Long commentId) {
        commentsService.delete(commentId);
        return commentId;
    }
  • Mustache
    • posts-detail.mustache : 댓글과 대댓글 UI 및 데이터 전달
<div class="card my-4">
            <div class="card">
                <div class="card-header bi bi-chat-dots"> Comments</div>
                <ul class="list-group-flush">
                    {{#comments}}
                        <li class="list-group-item">
                            <span>
                                <span style="font-size: small">{{username}}</span>
                                <span style="font-size: xx-small">{{createdDate}}</span>&nbsp;
                                <button class="badge bi bi-trash" id="btn-recomment" data-toggle="collapse" href="#recomment_collapse{{id}}" aria-expanded="false" aria-controls="recomment_collapse"> 대댓글</button>&nbsp;
                                <button class="badge bi bi-trash" id="btn-comment-delete"> 삭제</button>
                                <input type="hidden" name="comments_id" id="comments_id" value="{{id}}"/>
                            </span>
                            <div>{{content_c}}</div>
                            <div class="collapse" id="recomment_collapse{{id}}">
                                <div class="card-body">
                                    <form name="comment-form" action="/{{detail.id}}" method="post" autocomplete="off">
                                        <div class="form-group">
                                            <label for="username"> 작성자 </label>
                                            <input type="text" class="form-control" id="username_re" placeholder="작성자 이름을 입력하세요">
                                            <br>
                                            <input type="hidden" name="posts_id" id="id" value="{{detail.id}}"/>
                                            <input type="hidden" name="comments_id" id="comments_id" value="{{id}}"/>
                                            <textarea name="content_c" class="form-control" rows="3" id="content_c_re" placeholder="대댓글을 입력하세요"></textarea>
                                        </div>
                                        <button type="button" class="btn btn-primary" id="btn-recomment-save">등록</button>
                                    </form>
                                </div>
                            </div>
                        </li>
                    {{/comments}}
                </ul>
            </div>

            <div class="card-body">
                <form name="comment-form" action="/{{detail.id}}" method="post" autocomplete="off">
                    <div class="form-group">
                        <label for="username"> 작성자 </label>
                        <input type="text" class="form-control" id="username" placeholder="작성자 이름을 입력하세요">
                        <br>
                        <input type="hidden" name="posts_id" id="id" value="{{detail.id}}"/>
                        <textarea name="content_c" class="form-control" rows="3" id="content_c"
                                  placeholder="댓글을 입력하세요"></textarea>
                    </div>
                    <button type="button" class="btn btn-primary" id="btn-comment-save">등록</button>
                </form>
            </div>
        </div>
  • posts-save.mustache
{{>layout/header}}

<h1>게시글 등록</h1>

<div class="col-md-12">
    <div class="col-md-4">
        <form>
            <div class="form-group">
                <label for="title">제목</label>
                <input type="text" class="form-control" id="title" placeholder="제목을 입력하세요">
            </div>
            <div class="form-group">
                <label for="author"> 작성자 </label>
                <input type="text" class="form-control" id="author" placeholder="작성자를 입력하세요">
            </div>
            <div class="form-group">
                <label for="content"> 내용 </label>
                <textarea class="form-control" id="content" placeholder="내용을 입력하세요"></textarea>
            </div>
            <div class="form-group">
                <label for="image"> 첨부파일 </label>
                <input type="file" class="form-control-file border" name="file">
            </div>
        </form>
        <a href="/" role="button" class="btn btn-secondary">취소</a>
        <button type="button" class="btn btn-primary" id="btn-save">등록</button>
    </div>
</div>

{{>layout/footer}}
  • js
comment_save : function () {
        var data = {
            username: $('#username').val(),
            content_c: $('#content_c').val()
        };

        var id = $('#id').val();

        $.ajax({
            type: 'POST',
            url: '/api/v1/comments/'+id,
            dataType: 'json',
            contentType:'application/json; charset=utf-8',
            data: JSON.stringify(data)
        }).done(function() {
            window.location.href = '/posts/detail/'+id;
        }).fail(function (error) {
            alert(JSON.stringify(error));
        });
    },
    comment_delete : function () {
        var id = $('#id').val();
        var comments_id = $('#comments_id').val()

        $.ajax({
            type: 'DELETE',
            url: '/api/v1/comments/'+id+"/"+comments_id,
            dataType: 'json',
            contentType:'application/json; charset=utf-8'
        }).done(function() {
            alert('댓글이 삭제되었습니다.');
            window.location.href = '/posts/detail/'+id;
        }).fail(function (error) {
            alert(JSON.stringify(error));
        });
    },
    recomment_save : function () {
        var data = {
            username: $('#username_re').val(),
            content_c: $('#content_c_re').val()
        };

        var id = $('#id').val();
        var comments_id = $('#comments_id').val()

        $.ajax({
            type: 'POST',
            url: '/api/v1/comments/'+id+'/'+comments_id+'/recomment',
            dataType: 'json',
            contentType:'application/json; charset=utf-8',
            data: JSON.stringify(data)
        }).done(function() {
            window.location.href = '/posts/detail/'+id;
        }).fail(function (error) {
            alert(JSON.stringify(error));
        });
    },

 

이미지

  • Entity
    • Posts
private String imgName;

private String imgUrl;

@Builder
public Posts(String title, String content, String author, String imgName, String imgUrl) {
    this.title = title;
    this.content = content;
    this.author = author;
    this.imgName = imgName;
    this.imgUrl = imgUrl;
}
  • Dto
    • PostsSaveRequestDto
package com.gdsc.study.gdscspringbootstudy.web.dto;

import com.gdsc.study.gdscspringbootstudy.domain.posts.Posts;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.web.multipart.MultipartFile;

@Getter
@NoArgsConstructor
public class PostsSaveRequestDto {
    private String title;
    private String content;
    private String author;
    @Builder
    public PostsSaveRequestDto(String title, String content, String author, String imgName, String imgUrl) {
        this.title = title;
        this.content = content;
        this.author = author;
    }

    public Posts toEntity(String imgUrl) {
        return Posts.builder()
                .title(title)
                .content(content)
                .author(author)
                .imgUrl(imgUrl)
                .build();
    }
}
  • Service
    • PostsService
private String uploadFolder = "C:/workspace/springbootwork/upload/";

@Transactional
public Long save(PostsSaveRequestDto requestDto, MultipartFile multipartFile) {
    UUID uuid = UUID.randomUUID();
    String imageFileName = uuid + "_" + multipartFile.getOriginalFilename();

    System.out.println("이미지 파일 이름: " + imageFileName);

    Path imageFilePath = Paths.get(uploadFolder + imageFileName);

    try{
        Files.write(imageFilePath, multipartFile.getBytes());
    } catch (Exception e){
        e.printStackTrace();
    }

    Posts posts = requestDto.toEntity();
    postsRepository.save(posts);

    return posts.getId();
}
  • Controller
    • PostsApiController
@PostMapping
public Long save(@RequestBody PostsSaveRequestDto requestDto, @RequestPart("image")MultipartFile image) {
    return postsService.save(requestDto, image);
}

트러블 슈팅

  • 댓글, 대댓글 DB 설계시 테이블을 따로 만들어야 할지, 아님 하나의 테이블에 같이 만들고 .구분을 해줘야 할지 많이 고민이 되었다.
    • 이것에 대해 인사이트를 굉장히 많이 찾아본 기억이 있는데, 취향 차이인 것 같았다. 테이블을 같이 만들어주면 속성이 3개가 필요하고, 테이블을 따로 만들어주면 속성이 2개가 필요한 대신 리스트를 다뤄야해서 우리가 조금 더 다루기 쉬운 것으로 선택하였다.
  • 대댓글을 작성할 때 댓글의 ID 번호와 대댓글의 Group Number가 같게 매핑이 되어야 하는데 NULL 값이 입력되었다.
    • CommentsRepository 파일의 쿼리문에서 GROUP_NUM을 소문자에서 대문자로 수정하였다. 대문자로 작성하지 않아 데이터베이스와 매핑이 되지 않았던 것이다.
  • mustache 파일에서 대댓글 화면을 만들 때 한 댓글의 대댓글 버튼 클릭 시 모든 댓글에서 대댓글 입력 창이 떴다.
    • 대댓글 버튼에 댓글의 고유 아이디 번호를 연동하여 각 댓글마다 대댓글이 등록되도록 코드를 수정하였다.

이 프로젝트를 통해 얻은 것

채연

직접 구현을 해보며 이전에 만들어둔 프로젝트를 다시 보며 구조에 대해 다시 복습해보는 시간을 가질 수 있어 좋았다. 토이플젝을 하면서 너무 많은 에러를 봐서 이전보다 에러를 무서워하는 태도가 많이 없어졌고, 이전에 사용해보지 못한 깃허브의 이슈와 pr기능을 활용하여 다른 사람의 코드에 피드백을 달고, 피드백 받는 코드리뷰를 할 수 있어 좋았다. 시간이 촉박해 원하는 기능들을 모두 구현하지 못했지만, 적극적이고 좋은 팀원을 만나 협업 할 수 있어 뜻깊은 경험이였다!

  • 연진

    처음에 책으로 공부했을 때는 코드를 따라쳐 본 수준이라, 막상 프로젝트를 시작하려고 하니 어디서부터 어떻게 시작해야할지, 어떤 순서로 개발해야 할 지 몰라서 많이 헤맸다. 서버를 개발은 처음이었기 때문에 DB 설계부터 Controller, Service, API를 개발하는 모든 과정이 낯설었다. 생각보다 자잘한 오류가 굉장히 많이 나서 프론트엔드를 공부할 때보다 더 많은 구글링을 거쳤던 것 같은데, 미리 리서치라던가, 인사이트를 찾아보는 것이 계획이나 설계를 할 때 많은 도움이 된다는 것을 알게 되었다. 내가 많이 부족하기도 하고 서버 경험이 없어서 중간중간 채연이가 많이 도와주었는데 너무 고마웠고, 깃허브의 코드리뷰나 미트에서 서로 트러블 슈팅에 대해 얘기하면서 적절한 피드백과 서로를 칭찬하는 소통 스킬을 얻었다.