[코드로 배우는 스프링부트 웹 프로젝트] - 영화 리스트 생성(2) : 조회 페이지 생성

Jongwon·2023년 1월 23일
0

영화 번호를 누르면 조회페이지로 이동합니다. 서비스 계층에서 영화 번호를 이용하여 MovieDTO를 가져오는 작업부터 진행하겠습니다.

MovieService

MovieDTO getMovie(Long mno);

MovieServiceImpl

    @Override
    public MovieDTO getMovie(Long mno) {
        List<Object[]> result = movieRepository.getMovieWithAll(mno);

        Movie movie = (Movie) result.get(0)[0];

        List<MovieImage> movieImageList = new ArrayList<>();

        result.forEach(arr -> {
            MovieImage movieImage = (MovieImage) arr[1];
            movieImageList.add(movieImage);
        });

        Double avg = (Double) result.get(0)[2];
        Long reviewCnt = (Long) result.get(0)[3];

        return entitiesToDTO(movie, movieImageList, avg, reviewCnt);
    }


Controller부분도 구현합니다. 조회 페이지와 수정 페이지의 양식은 동일하기 때문에 Get방식으로 호출하고 수정 페이지에서 Post부분만 새로 작성하면 됩니다.

MovieController

    @GetMapping({"/read", "/modify"})
    public void read(long mno, @ModelAttribute("requestDTO") PageRequestDTO requestDTO, Model model) {
        log.info("mno: " + mno);
        MovieDTO movieDTO = movieService.getMovie(mno);

        model.addAttribute("dto", movieDTO);
    }


조회 페이지는 이전과 비슷한 형태로 구성합니다. 가장 아래에는 사진을 볼 수 있고, Review Count버튼을 누르면 리뷰가 보이게 됩니다.(뒤에 리뷰 페이지에서 세부 구현)

read.html

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<th:block th:replace="~{/layout/basic :: setContent(~{this::content})}">
  <th:block th:fragment="content">

    <h1 class="mt-4">Movie Read Page</h1>

    <div class="form-group">
      <label>Title</label>
      <input type="text" class="form-control" name="title" th:value="${dto.title}" readonly>
    </div>
    <div class="form-group">
      <label>Review Count</label>
      <input type="text" class="form-control" name="reviewCnt" th:value="${dto.reviewCnt}" readonly>
    </div>
    <div class="form-group">
      <label>Avg</label>
      <input type="text" class="form-control" name="avg" th:value="${dto.avg}" readonly>
    </div>

    <style>
            .uploadResult {
                width: 100%;
                background-color: gray;
                margin-top: 10px;
            }
            .uploadResult ul {
                display: flex;
                flex-flow: row;
                justify-content: center;
                align-items: center;
                vertical-align: top;
                overflow: auto;
            }
            .uploadResult ul li {
                list-style: none;
                padding: 10px;
                margin-left: 2em;
            }
            .uploadResult ul li img {
                width: 100px;
            }
        </style>
    <div class="uploadResult">
      <ul>
        <li th:each="movieImage: ${dto.imageDTOList}">
          <img th:if="${movieImage.path != null}" th:src="|/display?fileName=${movieImage.getThumbnailURL()}|">
        </li>
      </ul>
    </div>

    <button type="button" class="btn btn-primary">
      Review Count <span class="badge badge-light">[[${dto.reviewCnt}]]</span>
    </button>
    <script>
      $(document).ready(function(e) {

      });
    </script>
  </th:block>
</th:block>





목록 아래에 리뷰를 추가하게 된다면 서버에 DTO를 통해 전달해야 합니다. ReviewDTO부터 생성하겠습니다.

ReviewDTO

package org.zerock.mreview.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ReviewDTO {

    private Long reviewnum;

    private Long mno;

    private Long id;

    private String nickname;

    private String email;

    private int grade;

    private String text;

    private LocalDateTime regDate, modDate;
}

또한 리뷰를 수정할 수 있도록 Review 엔티티에서 평점과 리뷰를 수정할 수 있는 setter를 구현합니다.

Review

//추가
    public void changeGrade(int grade) {
        this.grade = grade;
    }

    public void changeText(String text) {
        this.text = text;
    }


다음으로는 서비스 계층을 생성하겠습니다. 서비스 계층에서는 리뷰 목록을 가져오는 메서드와, 리뷰 등록, 수정, 삭제 기능을 구현합니다.

ReviewService

package org.zerock.mreview.service;

import org.zerock.mreview.dto.ReviewDTO;
import org.zerock.mreview.entity.Member;
import org.zerock.mreview.entity.Movie;
import org.zerock.mreview.entity.Review;

import java.util.List;

public interface ReviewService {

    List<ReviewDTO> getListOfMovie(Long mno);

    Long register(ReviewDTO reviewDTO);

    void modify(ReviewDTO movieReviewDTO);

    void remove(Long reviewnum);

    default Review dtoToEntity(ReviewDTO movieReviewDTO) {
        Review movieReview = Review.builder()
                .reviewnum(movieReviewDTO.getReviewnum())
                .movie(Movie.builder().mno(movieReviewDTO.getMno()).build())
                .text(movieReviewDTO.getText())
                .grade(movieReviewDTO.getGrade())
                .member(Member.builder().mid(movieReviewDTO.getMid()).build())
                .build();

        return movieReview;
    }

    default ReviewDTO entityToDTO(Review movieReview) {
        ReviewDTO movieReviewDTO = ReviewDTO.builder()
                .reviewnum(movieReview.getReviewnum())
                .mno(movieReview.getMovie().getMno())
                .mid(movieReview.getMember().getMid())
                .nickname(movieReview.getMember().getNickname())
                .email(movieReview.getMember().getEmail())
                .grade(movieReview.getGrade())
                .text(movieReview.getText())
                .regDate(movieReview.getRegDate())
                .modDate(movieReview.getModDate())
                .build();

        return movieReviewDTO;
    }
}

ReviewServiceImpl

package org.zerock.mreview.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.zerock.mreview.dto.ReviewDTO;
import org.zerock.mreview.entity.Movie;
import org.zerock.mreview.entity.Review;
import org.zerock.mreview.repository.ReviewRepository;

import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

@Service
@Log4j2
@RequiredArgsConstructor
public class ReviewServiceImpl implements ReviewService {

    @Autowired
    private final ReviewRepository reviewRepository;

    @Override
    public List<ReviewDTO> getListOfMovie(Long mno) {
        Movie movie = Movie.builder().mno(mno).build();

        List<Review> result = reviewRepository.findByMovie(movie);

        return result.stream().map(movieReview -> entityToDTO(movieReview)).collect(Collectors.toList());
    }

    @Override
    public Long register(ReviewDTO reviewDTO) {
        Review movieReview = dtoToEntity(reviewDTO);

        reviewRepository.save(movieReview);

        return movieReview.getReviewnum();
    }

    @Override
    public void modify(ReviewDTO movieReviewDTO) {
        Optional<Review> result = reviewRepository.findById(movieReviewDTO.getReviewnum());

        if(result.isPresent()) {
            Review movieReview = result.get();
            movieReview.changeGrade(movieReviewDTO.getGrade());
            movieReview.changeText(movieReview.getText());

            reviewRepository.save(movieReview);
        }
    }

    @Override
    public void remove(Long reviewnum) {
        reviewRepository.deleteById(reviewnum);
    }
}

많이 작성해본 CRUD 기능이므로 설명은 생략하겠습니다.

다음으론 각 영화의 상세 페이지에서 리뷰 버튼을 눌렀을 때 리뷰 목록과 추가, 수정, 삭제가 가능하도록 Controller를 작성하겠습니다.

ReviewController

package org.zerock.mreview.controller;

import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.zerock.mreview.dto.ReviewDTO;
import org.zerock.mreview.service.ReviewService;

import java.util.List;

@RestController
@Log4j2
@RequestMapping("/reviews")
@RequiredArgsConstructor
public class ReviewController {

    private final ReviewService reviewService;

    @GetMapping("/{mno}/all")
    public ResponseEntity<List<ReviewDTO>> getList(@PathVariable("mno") Long mno) {
        log.info("-------------list-------------");
        log.info("MNO: " + mno);
        List<ReviewDTO> reviewDTOList = reviewService.getListOfMovie(mno);

        return new ResponseEntity<>(reviewDTOList, HttpStatus.OK);
    }

    @PostMapping("/{mno}/all")
    public ResponseEntity<Long> getList(@RequestBody ReviewDTO movieReviewDTO) {
        log.info("---------------list---------------");
        log.info("reviewDTO: " + movieReviewDTO);

        Long reviewNum = reviewService.register(movieReviewDTO);

        return new ResponseEntity<>(reviewNum, HttpStatus.OK);
    }

    @PutMapping("/{mno}/{reviewnum}")
    public ResponseEntity<Long> modifyReview(@PathVariable Long reviewnum, @RequestBody ReviewDTO reviewDTO) {
        log.info("----------------modify review-------------------");
        log.info("reviewDTO: " + reviewDTO);

        reviewService.modify(reviewDTO);

        return new ResponseEntity<>(reviewnum, HttpStatus.OK);
    }

    @DeleteMapping("/{mno}/{reviewnum}")
    public ResponseEntity<Long> removeReview(@PathVariable Long reviewnum) {
        log.info("-------------remove review-------------------");
        log.info("reviewnum: " + reviewnum);

        reviewService.remove(reviewnum);

        return new ResponseEntity<>(reviewnum, HttpStatus.OK);
    }
}


서버의 작업은 완료하였고, 클라이언트 페이지를 작성하겠습니다.

read.html

<div class="reviewModal modal" tabindex="-1" role="dialog">
  <div class="modal-dialog" role="document">
    <div class="modal-content">
      <div class="modal-header">
        <h5 class="modal-title">Movie Review</h5>
        <button type="button" class="close" data-dismiss="modal" aria-label="Close">
          <span aria-hidden="true">&times;</span>
        </button>
      </div>
      <div class="modal-body">
        <div class="form-group">
          <label>Reviewer ID</label>
          <input type="text" class="form-control" name="mid">
        </div>
        <div class="form-group">
          <label>
            Grade
            <span class="grade"></span>
          </label>
          <div class="stars"></div>
        </div>
        <div class="form-group">
          <label>Review Text</label>
          <input type="text" class="form-control" name="text" placeholder="Good Movie!">
        </div>
      </div>
      <div class="modal-footer">
        <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
        <button type="button" class="btn btn-primary reviewSaveBtn">Save Changes</button>
        <button type="button" class="btn btn-warning modifyBtn">Modify</button>
        <button type="button" class="btn btn-danger removeBtn">Remove</button>
      </div>
    </div>
  </div>
</div>

<div class="imageModal modal" tabindex="-2" role="dialog">
  <div class="modal-dialog modal-lg" role="document">
    <div class="modal-content">
      <div class="modal-header">
        <h5 class="modal-title">Picture</h5>
        <button type="button" class="close" data-dismiss="modal" aria-label="Close">
          <span aria-hidden="true">&times;</span>
        </button>
      </div>
      <div class="modal-body"></div>
      <div class="modal-footer">
        <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
      </div>
    </div>
  </div>
</div>

Read 페이지에 모달을 2개 추가합니다. reviewModal은 review 등록 등의 처리가 되었을 때 보여지는 모달창이고, imageModal은 사진을 눌렀을 때 확대하여 보여주는 모달창입니다.

별점 처리는 starrr이라는 라이브러리를 사용합니다.
https://github.com/dobtco/starrr
위의 깃허브 링크에서 dist 폴더 내에있는 css와 js파일을 각각 static 아래 css와 js폴더로 복사합니다.

read 페이지에 starrr 라이브러리와 font-awesome 링크를 추가합니다.

read.html

...
    <script th:src="@{/js/starrr.js}"></script>
    <link th:href="@{/css/starrr.css}" rel="stylesheet">
    <link rel="stylesheet" href="http://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.2.0/css/font-awesome.min.css">
    <script>
      $(document).ready(function(e) {
        var grade = 0;
        var mno = [[${dto.mno}]];

        $('.starrr').starrr({
          rating: grade,
          change: function(e, value) {
            if(value) {
              console.log(value);
              grade = value;
            }
          }
        });
      
      $(".reviewModal").modal("show");
...

영화 조회 페이지로 들어가면 위와 같은 모달창이 보여지고, 원하는 별점을 클릭하면 콘솔창에 Grade가 표시됩니다.



리뷰 등록 버튼을 추가하고, 버튼을 눌렀을 때 모달창이 로딩되도록 작성합니다.
read.html

<button type="button" class="btn btn-primary">
  Review Count <span class="badge badge-light">[[${dto.reviewCnt}]]</span>
</button>

//추가
<button type="button" class="btn btn-info addReviewBtn">
  Review Register
</button>

<div class="list-group reviewList"></div>

...
//script에 추가

//$(".reviewModal").modal("show");

var reviewModal = $(".reviewModal");
var inputMid = $('input[name="mid"]');
var inputText = $('input[name="text"]');

$(".addReviewBtn").click(function() {
  inputMid.val("");
  inputText.val("");

  $(".removeBtn, .modifyBtn").hide();
  $(".reviewSaveBtn").show();

  reviewModal.modal('show');
});

이 모달창은 숨겨져있다가 Register 버튼을 눌렀을 때 활성화되도록 스크립트를 추가합니다. 또한 모달창에서 save 버튼을 눌렀을 때 Controller의 POST로 전송되어 리뷰가 저장되도록 합니다.

read.html

$('.reviewSaveBtn').click(function() {
  var data = {mno:mno, grade:grade, text:inputText.val(), mid:inputMid.val()};
  console.log(data);

  $.ajax({
    url: '/reviews/'+mno,
    type: "POST",
    data: JSON.stringify(data),
    contentType: "application/json; charset=utf-8",
    dataType: "text",
    success: function(result) {
      console.log("result: " + result);
      self.location.reload();
    }
  })
  reviewModal.modal('hide');
});


리뷰가 추가되면 새로운 리스트로 다시 가져오도록 스크립트를 추가합니다.

  function getMovieReviews() {
    function formatTime(str) {
      var date = new Date(str);

      return date.getFullYear() + '/' +
        (date.getMonth() + 1) + '/' +
        date.getDate() + ' ' +
        date.getHours() + ':' +
        date.getMinutes();
    }

    $.getJSON("/reviews/" + mno + "/all", function(arr) {
      var str = "";

      $.each(arr, function(idx, review) {
        console.log(review);
        str += '<div class="card-body" data-reviewnum=' + review.reviewnum + ' data-mid=' + review.mid + '>';
        str += '<h5 class="card-title">' + review.text + ' <span>' + review.grade + '</span></h5>';
        str += '<h6 class="card-subtitle mb-2 text-muted">' + review.nickname + '</h6>';
        str += '<p class="card-text">' + formatTime(review.regDate) + '</p>';
        str += '</div>';
      });
      $(".reviewList").html(str);
    });
  }
  getMovieReviews();
});


리뷰를 클릭하면 수정과 삭제를 할 수 있는 모달창이 생성되도록 코드를 추가합니다.

var reviewnum;

$(".reviewList").on("click", ".card-body", function() {
  $(".reviewSaveBtn").hide();
  $(".removeBtn, .modifyBtn").show();

  var targetReview = $(this);
  reviewnum = targetReview.data("reviewnum");

  console.log("reviewnum: " + reviewnum);
  inputMid.val(targetReview.data("mid"));
  inputText.val(targetReview.find('.card-title').clone().children().remove().end().text());

  var grade = targetReview.find('.card-title span').html();
  $(".starrr a:nth-child("+grade+")").trigger('click');

  $('.reviewModal').modal('show');
});

Modify버튼을 누르면 수정되고, Remove버튼을 누르면 삭제되도록 이벤트를 추가합니다.

$(".modifyBtn").on("click", function() {
  var data = {reviewnum: reviewnum, mno:mno, grade:grade, text:inputText.val(), mid:inputMid.val()};
  console.log(data);
  $.ajax({
    url:'/reviews/'+mno+"/"+reviewnum,
    type:"PUT",
    data:JSON.stringify(data),
    contentType:"application/json; charset=utf-8",
    dataType:"text",
    success: function(result) {
      console.log("result: " + result);
      self.location.reload();
    }
  })
  reviewModal.modal('hide');
});
$(".removeBtn").on("click", function() {
  var data = {reviewnum: reviewnum};
  console.log(data);
  
  $.ajax({
    url: '/reviews/'+mno+"/"+reviewnum,
    type: "DELETE",
    contentType: "application/json; charset=utf-8",
    dataType:"text",
    success: function(result) {
      console.log("result: " + result);
      self.location.reload();
    }
  })
  removeModal.modal('hide');
});





마지막으로 이미지를 클릭했을 때 원본이미지를 모달창에서 보여주도록 설계합니다.

먼저 UploadController의 getFile()에서 매개변수 size를 추가하여, size = "1"일 때는 원본 이미지를 가져올 수 있도록 Controller를 수정합니다.

UploadController

@GetMapping("/display")
public ResponseEntity<byte[]> getFile(String fileName, String size) {
    ResponseEntity<byte[]> result = null;
    log.info(fileName);
    try {
        String srcFileName = URLDecoder.decode(fileName, "UTF-8");

        log.info("fileName: " + srcFileName);

        File file = new File(uploadPath + File.separator + srcFileName);

//추가
        if( size != null && size.equals("1")) {
            file = new File(file.getParent(), file.getName().substring(2));
        }
        log.info("file: " + file);

        HttpHeaders header = new HttpHeaders();

        header.add("Content-Type", Files.probeContentType(file.toPath()));
        result = new ResponseEntity<>(FileCopyUtils.copyToByteArray(file), header, HttpStatus.OK);
    } catch (Exception e) {
        log.error(e.getMessage());
        return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
    }

    return  result;
}


read.html에서 이미지 li 부분에 th:data-file 애트리뷰트를 추가하여 썸네일 파일 경로를 저장합니다. 그리고 스크립트에서 이 경로와 size=1을 제공한 이미지 URL을 호출하여 Controller에서 원본 이미지를 가져오도록 합니다.

read.html

    <div class="uploadResult">
      <ul>
        //수정
        <li th:each="movieImage: ${dto.imageDTOList}" th:data-file="${movieImage.getThumbnailURL()}">
          <img th:if="${movieImage.path != null}" th:src="|/display?fileName=${movieImage.getThumbnailURL()}|">
        </li>
      </ul>
    </div>
...

![](https://velog.velcdn.com/images/tank3a/post/eb495bcd-b398-4b2e-8ea0-ca8f23f850cf/image.png)
profile
Backend Engineer

0개의 댓글