Group Study (2022-2023)/Spring 입문

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

mopipi 2022. 12. 20. 22:55

BDNSook

1. 선택한 기능

게시판 기능에서 꼭 필요하다고 생각하는 기능을 넣자!

  • 댓글
  • 이미지 첨부

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

  • 댓글 기능 관련
    • https://dev-coco.tistory.com/132
    • https://dev-coco.tistory.com/136
    • https://dev-coco.tistory.com/134
    • 댓글을 작성하고, 수정하고 삭제하는 기능이 마치 게시글을 작성, 수정, 삭제하는 기능과 비슷하다고 생각해서 금방 끝낼 수 있을 줄 알았지만 생각보다 어려움을 많이 겪었다. 게시글을 작성하여 등록하는 것까지는 괜찮았지만 게시글을 삭제하고 수정하는 부분에서 많이 해맸던 것 같다. index.js 부분을 잘 몰라서 이 부분을 처리할 때가 조금 애먹었다.
    • 무엇보다 가장 어려웠던 부분은 권한을 설정해줘야 하는 것이었다. 댓글의 권한도 설정해야 하지만 게시글의 수정 권한도 설정해줘야 했기 때문에 게시글 권한 설정을 먼저 해주었다. 댓글은 게시글 1개에 여러 개가 있기 때문에 for문으로 하나씩 comment user와 user를 비교하여 권한 확인을 하는 방식을 사용했다. 하지만 오류가 나서 성공은 못했다.
  • 이미지 관련

3. 기능 구현 계획

  • 기능1 : 댓글 → 문유진

    • 게시글 권한 설정 (댓글을 단 유저와 구별하기 위해)
    • 댓글 작성 시 내용 및 시간표시
    • 댓글 수정 및 삭제
  • 기능2 : 이미지 첨부 → 류현지

    • 게시글 작성 시 이미지 첨부 가능

    • 게시글 수정 시 이미지 변경, 삭제, 추가 가능

    • 게시글 삭제 시 이미지 파일도 함께 삭제

    • 기능 구현 계획

      • 파일 삭제 버튼 클릭 → deleteFlag값 true로 request감
        • flag가 true인 경우 (파일 삭제 버튼 클릭)
          1. 받은 file이 없음(null) → 원래 게시글에서 이미지 삭제 하고 끝
          2. 받은 file 존재 → 기존 이미지 변경
        • flag가 false인 경우 (파일 삭제 버튼 클릭X)
          1. 받은 file이 없음 → 그대로 유지
          2. 받은 file 존재 → 새로운 파일로 교체 수정

4. 규칙

  • 깃허브 컨벤션에 따라 커밋
  • 브랜치는 기능별 분리
    • feature/[이름]_[상세기능]
  • 푸시 하고 PR한 다음 상대방에게 알려주기
    • 커밋하기 전 pull 필수!

5. 핵심 코드

  • 기능1 : 댓글

    • domain/comments/Comments.java

      • 기능

        댓글에 관련된 정보를 DB의 테이블과 매칭할 클래스

      • 코드

          Builder
          @AllArgsConstructor
          @NoArgsConstructor
          @Getter
          @Entity
          public class Comments extends BaseTimeEntity {
              @Id
              @GeneratedValue(strategy = GenerationType.IDENTITY)
              private Long id;
        
              @Column(columnDefinition = "TEXT", nullable = false)
              private String comment;
        
              @ManyToOne
              @JoinColumn(name = "postId")
              private Posts posts;
        
              @ManyToOne
              @JoinColumn(name = "userId")
              private User user;
        
              // 댓글 수정을 위한 setter
              public void update(String comment) {
                  this.comment = comment;
              }
          }
    • service/comments/CommentsService.java

      • 기능

        댓글을 저장하고 수정하고 삭제하는 비즈니스 로직 수행

      • 코드

          @RequiredArgsConstructor
          @Service
          public class CommentsService {
              private final CommentRepository commentRepository;
              private final PostRepository postRepository;
              private final UserRepository userRepository;
        
              @Transactional
              public Long commentSave(Long id, CommentsRequestDto commentsRequestDto, Long userId) {
                  User user = userRepository.getById(userId);
                  Posts posts = postRepository.findById(id).orElseThrow(() ->
                          new IllegalArgumentException("댓글 쓰기 실패: 해당 게시글이 존재하지 않습니다."+ id));
        
                  commentsRequestDto.setUser(user);
                  commentsRequestDto.setPosts(posts);
        
                  Comments comments = commentsRequestDto.toEntity();
                  commentRepository.save(comments);
        
                  return commentsRequestDto.getId();
              }
        
              @Transactional
              public void commentUpdate(Long id, CommentsRequestDto commentsRequestDto) {
                  Comments comments = commentRepository.findById(id).orElseThrow(() ->
                          new IllegalArgumentException("해당 댓글이 존재하지 않습니다."+ id));
                  comments.update(commentsRequestDto.getComment());
              }
        
              @Transactional
              public void commentDelete(Long id) {
                  Comments comments = commentRepository.findById(id).orElseThrow(() ->
                          new IllegalArgumentException("해당 댓글이 존재하지 않습니다."+ id));
                  commentRepository.delete(comments);
              }
          }
    • resource/templates/post-read.mustache

      • 기능

        상세 게시글을 볼 수 있는 페이지로 글 작성자가 조회했을 때는 목록, 수정, 삭제 버튼이 나오지만, 다른 사용자가 글을 조회하면 목록 버튼만 보여 작성자 이외의 사람은 글을 수정하거나 삭제할 수 없음

      • 코드

          {{>layout/header}}
          <br/>
          <div id="posts_list">
              <br class="col-md-12">
              <form class="card">
                  <div class="card-header d-flex justify-content-between">
                      <label for="id">번호 : {{post.id}}</label>
                      <input type="hidden" id="id" value="{{post.id}}">
                  </div>
                  <div class="card-header d-flex justify-content-between">
                      <label for="writer">작성자 : {{post.author}}</label>
                  </div>
                  <div class="card-body">
                      <label for="title">제목</label>
                      <input type="text" class="form-control" id="title" value="{{post.title}}" readonly>
                      <br/>
                      </br>
                      {{#post.filePath}}
                          <div class="col-md-6">
                              <img src="{{post.filePath}}" alt="imageFile" class="img-fluid">
                          </div>
                      {{/post.filePath}}
                      </br>
                      <label for="content">내용</label>
                      <textarea rows="5" class="form-control" id="content" readonly>{{post.content}}</textarea>
                  </div>
              </form>
              </br>
              {{! Buttons }}
              {{#user}}
                  <a href="/" role="button" class="btn btn-info bi bi-arrow-return-left">목록</a>
                  {{#writer}}
                      <a href="/posts/update/{{id}}" role="button" class="btn btn-primary bi bi-pencil-square">수정</a>
                      <a href="/posts/delete/{{id}}" role="button" class="btn btn-danger bi bi-trash">삭제</a>
                  {{/writer}}
              {{/user}}
              {{^user}}
                  <a href="/" role="button" class="btn btn-info bi bi-arrow-return-left"> 목록</a>
              {{/user}}
        
              {{! Comments }}
              {{>comments/list}}
              {{>comments/form}}
          </div>
          {{>layout/footer}}
  • 기능2 : 이미지 첨부

    • PostsService.java

      • 기능

        포스트 작성 + 이미지 첨부/확인/삭제 서비스 구현

      • 코트

      1. save 기능

        
             public String saveFile(MultipartFile file) throws IOException{
                 String storePath = System.getProperty("user.dir")
                                                         +"\\src\\main\\resources\\static\\images";
                 File storeFolder = new File(storePath);
                 //헤당 폴더 없는 경우에만 생성
                 if (!storeFolder.exists()) {
                     try {
                         storeFolder.mkdirs();
                     } catch (Exception e) {
                         e.getStackTrace();
                     }
                 }
                 //고유 식별자 생성
                 UUID uuid = UUID.randomUUID();
                 String fileName = null;
                 if (file != null) { //파일을 첨부된 경우만 이름 꺼냄
                     fileName = uuid + "_"+ file.getOriginalFilename();
                     file.transferTo(new File(storePath, fileName));
                 }
                 return fileName;
             }
        • 등록한 이미지 파일은 로컬 디렉터리에 함께 저장되고 이때 등록된 저장 경로를 통해 열람이 가능함 → 경로 생성해줌 (없을 경우만)
        • file 클래스를 이용해 파일을 로컬에 저장, 경로 세팅
        • 이때 originalFileName이 동일한 경우 덮어쓰기 방지하기 위해 uuid 사용
      2. update 기능

        • 기능 구현 계획단계에서 정의한 로직 구현
        • 게시글의 첨부파일만 삭제하는 경우를 구현하기 위해 flag를 추가
          • flag가 true/false인 경우 && 파일 존재 를 고려해 기능 고려 및 예외처리
        ...
        @Transactional
         public void update(Long id, PostsUpdateRequestDto requestDto, MultipartFile file, SessionUser accessUser) throws IOException {
             Posts posts = postRepository.findById(id) //수정할 객체 id로 찾아서
                     .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 없습니다. id=" + id));
             //내용 먼저 업데이트
             posts.update_content(requestDto.getTitle(),requestDto.getContent());
             //3. flag 확인
             if (requestDto.getDeleteFlag()) {
                 if(fileDelete(posts)) { //실제 파일 삭제(성공)
                     if (file == null){
                         //엔티티 업데이트(이미지 제거)
                         posts.update_File(null, null);
                     }
                     else{ //변경할 파일이 같이 넘어온 경우
                         String fileName = saveFile(file);
                         posts.update_File("/images/"+fileName, fileName);
                     }}}
             else {//flag = false
                 if(file == null){
        
                 }
                 else{//새로운 파일 존재 = 기존파일 삭제
                     fileDelete(posts);
                     String fileName = saveFile(file);
                     posts.update_File("/images/"+fileName, fileName);
        ...
      • flag 값은 mustache 파일에 hidden 속성으로 생성→ 버튼 눌릴 때 변경
      • flag 사용한 이유
        • 원래 update화면을 출력할 때 model에 file의 value나 default로 기존 파일을 보내 수정 가능하게 하고 싶었는데 불가능했다. 그래서 그냥 flag를 추가했다… 분명 더 좋은 방법이 있을 거 같아서 여유있을 때 수정해보고싶다.
    • PostsApiController.java

      • 기능

        사용자가 요청 보낸 포스트 오브젝트를 response 함

        사용자가 요청한 이미지 저장/삭제 기능 수행

      • 코드

        @PutMapping("/{id}")
          public void update(@PathVariable Long id,
                             @RequestPart(name = "p") PostsUpdateRequestDto requestDto,
                             @RequestPart(name = "f", required = false) MultipartFile file,
                             @LoginUser SessionUser accessUser) {
              try{
                  postsService.update(id,requestDto,file,accessUser);
              }catch (IOException e){
                  System.out.println(e.getMessage());
              }
          }
      • index.js (request) 에서 보낸 파일을 받아 업데이트 처리

        save: function () {
              let data = {
                  title : $('#title').val(),
                  content: $('#content').val()
              }
              let fileImg = $('#filename')[0].files[0];
        
              let formdata = new FormData();
              formdata.append("p", new Blob([JSON.stringify(data)],
                  {type: "application/json"}))
              formdata.append("f", fileImg);
        
              $.ajax({
                  type: 'POST',
                  url: '/api/v1/posts/save',
                  contentType: false,
                  data: formdata,
                  processData:false,
              }).done(function () {
      • index.js (request) 에서 텍스트 데이터와 파일을 함께 전송한다.

        • Blob 사용
        • 파일 전송 → contentType: false, processData:false 설정 필요
    • Posts.java

      • 기능

        포스트 관련 엔티티

      • 코드

        ...
        @ManyToOne(fetch = FetchType.LAZY)
        @JoinColumn(name = "user_id")
        private User author;    
        
        @Column
        private String filePath;
        
        @Column
        private String fileName;
        ...
        
        @Builder
        public Posts(String title, String content, User user, String filePath, String fileName, List<Comments> comments) {
            this.title = title;
            this.content = content;
            this.author = user;
                  this.filePath = filePath;
            this.fileName = fileName;
            this.comments = comments;
          }
        
          public void update_content(String title, String content) {
              this.title = title;
              this.content = content;
          }
          public void update_File(String filePath, String fileName) {
              this.filePath = filePath;
              this.fileName = fileName;
          }
        }
      • User엔티티와 다대일 매핑을 해서 연관관계를 만들어줬다.
        • 화면에 출력할 때는 name 필드를, 고유성을 확인할 때는 id 필드를 사용했다.
        • 선택적 매개변수가 많아 @Builder 를 사용했다.
          • 내용 update와 파일 update를 별도의 메소드로 분리했다.
          • 이미지를 객체로 만들어 매핑시키면 훨씬 더 수정이 수월했을 것 같다….

6. 트러블 슈팅

  • [Error] executing DDL "alter table scomment drop foreign key

    • application.properties에 다음 코드 추가

        spring.jpa.hibernate.ddl-auto=update
        spring.jpa.generate-ddl=true
  • [Error] Validation failed for query for method public abstract

    • PostRepository를 다음으로 수정

        @Modifying
        @Query(value = "update Posts p set p.view = p.view + 1 where p.id = :id", nativeQuery = true)
        int updateView(Long id);
  • Merge conflict

    해결방법

  • Consider defining a bean of type 'org.springframework.security.oauth2.client.registrationClientRegistrationRepository' in your configuration.

    • gitignore에 적용되었던 application-oauth.properties를 추가하는 것 잊지말기
  • IndexController.java 댓글 수정 권한

    • 해결 못함..

      comments.get(i).getUserId()에서 getUserId()를 못 받아옴

        for (int i = 0; i < comments.size(); i++) {
            //댓글 작성자 id와 현재 사용자 id를 비교해 true/false 판단
            boolean isWriter = comments.get(i).getUserId().equals(user.getId());
            log.info("isWriter? : " + isWriter);
            model.addAttribute("isWriter",isWriter);
        }
  • list.mustache class 문제

    • 해결못함

      class=”bi bi-pencil-square"가 적용되지 않음

        {{#isWriter}}
            <a type="button" data-toggle="collapse" data-target=".multi-collapse-{{id}}"
               class="bi bi-pencil-square"></a> {{! 댓글 수정 버튼 }}
            <a type="button" onclick="main.commentDelete({{postsId}},{{id}},{{userId}},{{user.id}})"
               class="bi bi-x-square"></a> {{! 댓글 삭제 버튼 }}
        {{/isWriter}}
  • nullpointException Error

    • 상황) 받은 request에 file이 존재하지 않을 때 발생한 에러

    • 해결) 에러 처리를 해준다고 해줬는데.. 분기문 조건을 !(file.isEmpty())로 작성하여 넘어오는 파일이 없을 경우 getOriginfileName을 실행하지 않도록 예외처리를 했는데 소용이 없었다. 즉 넘어오는 file이 list형태가 아니라 객체 형태라는 것

      → try 1) if (!(file.isEmpty())||(file != null)) 로 수정

      ⇒ 동일한 널 포인터 익셉션 발생.

      → try 2) isEmpty() 도 수행하지 않도록 if (file != null) 로 수정

      ⇒ 에러 해결. 정리해보면 넘어오는 file자체는 list도 아니었다. 따라서 당연히 isEmpty()로 null 핸들링이 불가능했고, null 값에 대고 isEmpty 메소드를 실행했으니 오류가 날 수 밖에… 애초에 뷰에서 전송할 떄 파일 1개만 꺼내 보냈기 때문

        let fileImg = $('#filename')[0].files[0];
  • 비교 연산자 수행 오류

    • 상황) 서비스에서 받은 userId와 postId로 해당 글 작성자일 경우에만 수정 페이지로 이동 가능하게 했는데, 받아온 userId와 post에 매핑된 userId가 같음에도 false를 반환

    • 해결) 판별을 ==기호를 사용해서 했던 것이 문제.

      ‘==’로 비교 지양 ⇒ equals()을 사용하는걸로 …(어차피 object니까 괜찮)

  • MaxUploadSizeExceededException

    • 상황) 파일 업로드 용량 제한

    • 해결) application.properties에 추가

      spring.servlet.multipart.maxFileSize=10MB
      spring.servlet.multipart.maxRequestSize=10MB
  • 사진 로드 오류

7. 팀원마다 이 프로젝트를 통해 얻은 것

  • 류현지
    • 이미지 등록이 게시판의 기본 기능 중 하나라고 생각했지만 생각보다 공부해야 할 것이 더 많았다. 특히 리퀘스트 보내는 js코드나 ajax 관련해서 삽질한 시간이 더 길었던 것 같다… 프론트에 대한 지식도 기본적으로 어느정도 알아야 한다는 것을 뼈저리게 느꼈다... 또 이번에 추가적으로 기능을 구현하며 기존에 작성했던 프로젝트를 다시금 훑어 보고 분석하게 되었는데, 그 당시 공부할 때 제대로 이해가 가지 않았던 부분들을 이해할 수 있었고 동작하는 전반적인 흐름이 직관적으로 와닿은 느낌이라 좋은 복습의 기회가 된 것 같다. 생각보다 복잡했던 두 가지 기능은 혼자하면 어려웠을 것 같은데 팀원과 함께 하니 훨씬 수월하게 마친 것 같았다. 다음에는 코드 리뷰도 더 적극적으로 활용해보고 싶다!
  • 문유진
    • 게시글 뿐만 아니라 댓글 수정, 삭제도 댓글 작성자만 권한을 줄 수 있게 하고 싶었는데 이틀동안 꼬박해도 해결이 되지 않았고 오히려 게시글 권한까지도 오류가 나서 걷잡을 수 없는 오류에 포기를 할 수밖에 없었다. 스터디를 하면서 스프링에 대해 전반적인 내용을 조금은 알았다고 생각했지만 막상 직접 구현을 해보니 구상을 하는 것부터가 쉽지 않았다. 구현을 할 때도 오류를 많이 만났지만 오류를 해결해 나가는 과정이 가장 공부가 많이 되었던 것 같다. 하고 싶었던 기능을 다 구현하지 못해 개인적으로 아쉬움이 큰 프로젝트지만 좋은 팀원을 만나 이정도의 결과물을 낼 수 있었던 것 같다.