SpringBoot with JPA 프로젝트(M:N) 5.영화등록,등록된글 리스트처리

📚 공부한 책 : 코드로배우는 스프링 부트 웹프로젝트
❤️ github 주소 : https://github.com/qkralswl689/LearnFromCode/tree/main/mreview2022

1.영화/리뷰 프로젝트 적용하기

  • 영화(Movie)의 등록과 수정에는 파일 업로드 기능을 활용해 영화 포스터 등을 등록할 수 있도록 구성
  • 회원(Member)은 기존 회원들이 존재한다고 가정하고 DB에 존재하는 회원들 이용
  • 회원(Member)은 특정한 영화 조회 페이지에서 평점과 자신의 감상을 리뷰(Review)로 기록할 수 있다
  • 조회 화면에서 회원(Member)은 자신이 기록한 리뷰(Review)의 내용을 수정/삭제할 수 있다

2.영화(Movie)등록 처리

2-1.DTO 클래스 생성

MovieDTO 는 Movie 클래스를 기준으로작성한다, MovieDTO는 화면에 영화 이미지들도 같이 수집해 전달해야 하므로 내부적으로 리스트를 이용해 수집한다

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

import java.util.ArrayList;
import java.util.List;

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

    private Long mno;

    private String title;

    @Builder.Default
    private List<MovieImageDTO> imageDTOList = new ArrayList<>();
}

2-2.MovieImageDTO 클래스 작성

MovieDTO 클래스 내부에는 업로드된 파일들의 정보를 포함해야 하므로 MovieImageDTO 클래스도 같이 추가한다

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

import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;

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

    private String uuid;

    private String imgName;

    private String path;

    public String getImageURL(){

        try {
            return URLEncoder.encode(path + "/" + uuid + "_" + imgName,"UTF-8");

        }catch (UnsupportedEncodingException e){
            e.printStackTrace();
        }
        return "";
    }

    public String getThumbnailURL(){
        try {
            return URLEncoder.encode(path+"/s_" + uuid + "_" +imgName ,"UTF-8");
        }catch (UnsupportedEncodingException e){
            e.printStackTrace();
        }
        return "";
    }
}

2-3.Service interface 작성

Movie를 JPA로 처리하기 위해 MovieDTO를 Movie 객체로 변환해 주어야 하므로 MovieService에 dtoToEntity()를 추가한다

  • 주의점
    -> Movie 객체뿐 아니라 MovieImage 객체들도 같이 처리된다는 점! 한번에 두가지 종류의 객체를 반환해야 하므로 Map 타입을 이용해 반환한다
    => 추가된 dtoToEntity()는 Map 타입으로 Movie 객체와 MovieImage 객체의 리스트를 처리한다
package com.example.mreview2022.service;

import com.example.mreview2022.dto.MovieDTO;
import com.example.mreview2022.dto.MovieImageDTO;
import com.example.mreview2022.entity.Movie;
import com.example.mreview2022.entity.MovieImage;

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public interface MovieService {

    Long register(MovieDTO movieDTO);

    default Map<String, Object> dtoToEntity(MovieDTO movieDTO) { //Map 타입으로 변환

        Map<String,Object> entityMap = new HashMap<>();

        Movie movie = Movie.builder()
                .mno(movieDTO.getMno())
                .title(movieDTO.getTitle())
                .build();

        entityMap.put("movie",movie);

        List<MovieImageDTO> imageDTOList = movieDTO.getImageDTOList();

        //MovieImageDTO 처리
        if(imageDTOList != null && imageDTOList.size() > 0){
            List<MovieImage> movieImageList = imageDTOList.stream().map(movieImageDTO -> {

                MovieImage movieImage = MovieImage.builder()
                        .path(movieImageDTO.getPath())
                        .imgName(movieImageDTO.getImgName())
                        .uuid(movieImageDTO.getUuid())
                        .movie(movie)
                        .build();

                return movieImage;
            }).collect(Collectors.toList());

            entityMap.put("imgList",movieImageList);
        }

        return entityMap;
    }
}

2-4.ServiceImpl 클래스 작성

dtoToEntity()에서 반환한 객체들을 이용해 save()를 처리한다

package com.example.mreview2022.service;

import com.example.mreview2022.dto.MovieDTO;
import com.example.mreview2022.entity.Movie;
import com.example.mreview2022.entity.MovieImage;
import com.example.mreview2022.repository.MovieImageRepository;
import com.example.mreview2022.repository.MovieRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import javax.transaction.Transactional;
import java.util.List;
import java.util.Map;

@Service
@RequiredArgsConstructor
public class MovieServiceImpl implements MovieService {

    private final MovieRepository movieRepository; // final

    private final MovieImageRepository imageRepository; // final

    @Transactional
    @Override
    public Long register(MovieDTO movieDTO) {

        Map<String,Object> entityMap = dtoToEntity(movieDTO);
        Movie movie = (Movie) entityMap.get("movie");
        List<MovieImage> movieImageList = (List<MovieImage>) entityMap.get("imgList");

        movieRepository.save(movie);

        movieImageList.forEach(movieImage -> {
            imageRepository.save(movieImage);
        });

        return movie.getMno();
    }
}

2-5.Controller 작성

MovieController 에서는 POST 방식으로 전달된 파라미터들을 MovieDTO로 수집해 MovieService 타입 객체의 register()를 호출하도록 작성한다
* 목록페이지는 추후에 작성한다

package com.example.mreview2022.controller;

import com.example.mreview2022.dto.MovieDTO;
import com.example.mreview2022.service.MovieService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

@Controller
@RequestMapping("/movie")
@RequiredArgsConstructor
public class MovieController {

    private final MovieService movieService; //final

    @GetMapping("/register")
    public void register(){

    }

    @PostMapping("/register")
    public String register(MovieDTO movieDTO, RedirectAttributes redirectAttributes){

        Long mno = movieService.register(movieDTO);

        redirectAttributes.addFlashAttribute("msg",mno);

        return "redirect:/movie/list";
    }
}

2-6.Html 작성

화면에서 Sumbit 버튼을 클릭했을 때 작업처리 순서
1) 각 이미지 li 태그의 data- 속성들을 읽는다
2) 읽어 들인 속성값을 이용해 form 태그 내에 input type ='hidden' 태그를 생성한다
3) input type ='hidden'의 이름에는 imageDTOList[0]과 같이 인덱스 번호를 붙혀 처리한다

<!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 Register Page</h1>

        <form th:action="@{/movie/register}" th:method="post"  >
            <div class="form-group">
                <label >Title</label>
                <input type="text" class="form-control" name="title" placeholder="Enter Title">
            </div>

            <div class="form-group fileForm">
                <label >Image Files</label>
                <div class="custom-file">
                    <input type="file"  class="custom-file-input files" id="fileInput" multiple>
                    <label class="custom-file-label" data-browse="Browse"></label>
                </div>
            </div>

            <div class="box">

            </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>

                </ul>
            </div>
            <button type="submit" class="btn btn-primary">Submit</button>
        </form>

        <script>
    $(document).ready(function(e) {

        var regex = new RegExp("(.*?)\.(exe|sh|zip|alz|tiff)$");
        var maxSize = 10485760; //10MB

        function checkExtension(fileName, fileSize){

            if(fileSize >= maxSize){
                alert("파일 사이즈 초과");
                return false;
            }

            if(regex.test(fileName)){
                alert("해당 종류의 파일은 업로드할 수 없습니다.");
                return false;
            }
            return true;
        }

        $(".custom-file-input").on("change", function() {

            var fileName = $(this).val().split("\\").pop();
            $(this).siblings(".custom-file-label").addClass("selected").html(fileName);

            var formData = new FormData();

            var inputFile = $(this);

            var files = inputFile[0].files;

            var appended = false;

            for (var i = 0; i < files.length; i++) {

                if(!checkExtension(files[i].name, files[i].size) ){
                    return false;
                }

                console.log(files[i]);
                formData.append("uploadFiles", files[i]);
                appended = true;
            }

            //upload를 하지 않는다.
            if (!appended) {return;}

            for (var value of formData.values()) {
                console.log(value);
            }

            //실제 업로드 부분
            //upload ajax
            $.ajax({
                url: '/uploadAjax',
                processData: false,
                contentType: false,
                data: formData,
                type: 'POST',
                dataType:'json',
                success: function(result){
                    console.log(result);
                    showResult(result);
                },
                error: function(jqXHR, textStatus, errorThrown){
                    console.log(textStatus);
                }
            }); //$.ajax
        }); //end change event


        function showResult(uploadResultArr){

            var uploadUL = $(".uploadResult ul");

            var str ="";

            $(uploadResultArr).each(function(i, obj) {

                str += "<li data-name='" + obj.fileName + "' data-path='"+obj.folderPath+"' data-uuid='"+obj.uuid+"'>";
                str + " <div>";
                str += "<button type='button' data-file=\'" + obj.imageURL + "\' "
                str += "class='btn-warning btn-sm'>X</button><br>";
                str += "<img src='/display?fileName=" + obj.thumbnailURL + "'>";
                str += "</div>";
                str + "</li>";
            });

            uploadUL.append(str);
        }

        $(".uploadResult ").on("click", "li button", function(e){

            console.log("delete file");

            var targetFile = $(this).data("file");

            var targetLi = $(this).closest("li");

            $.ajax({
                url: '/removeFile',
                data: {fileName: targetFile},
                dataType:'text',
                type: 'POST',
                success: function(result){
                    alert(result);

                    targetLi.remove();
                }
            }); //$.ajax
        });


        //prevent submit
        $(".btn-primary").on("click", function(e) {
            e.preventDefault();

            var str = "";

            $(".uploadResult li").each(function(i,obj){
                var target = $(obj);

                str += "<input type='hidden' name='imageDTOList["+i+"].imgName' value='"+target.data('name') +"'>";

                str += "<input type='hidden' name='imageDTOList["+i+"].path' value='"+target.data('path')+"'>";

                str += "<input type='hidden' name='imageDTOList["+i+"].uuid' value='"+target.data('uuid')+"'>";

            });

            //태그들이 추가된 것을 확인한 후에 comment를 제거
            $(".box").html(str);

            $("form").submit();

        });



    }); //document ready
</script>

    </th:block>

</th:block>
  • 실행화면

    현재 목록 리스트 화면이 구현되어있지않아 Submit 버튼 클릭시 에러화면이 뜨지만 DB를 조회해보면 저장된 것을 알 수 있다
    - 전체과정
    1) 파일업로드가 되면 li 태그가 구성된다
    2) Submit 버튼 클릭시 form 태그 내의 태그들 생성
    3) MovieController에서 POST 방식으로 전달된 데이터는 MovieImageDTO 로 수집된다
    4) MovieService에서 MovieImageDTO들은 Movie 엔티티 객체 내에 MovieImage로 처리된다
    5) JPA에 의해 save()처리 후 DB에 저장된다

3.목록 처리와 평균 평점

3-1.페이지 처리

N:1 프로젝트에서 사용했던 PageRequestDTO 와 PageResultDTO 를 추가해준다

3-2.MovieDTO 수정

MovieService 의 getList() 는 Movie,MovieImage,Double,Long을 Object[] 배열을 리스트에 담은 형태이다
각 Object[]을 MovieDTO라는 하나의 객체로 처리해야 한다 MovieDTO에는 Double 타입의 평점 평균과 리뷰이 개수를 처리하는 파라미터, 날짜 관련된 부분도 추가해준다

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

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

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

    private Long mno;

    private String title;

    @Builder.Default
    private List<MovieImageDTO> imageDTOList = new ArrayList<>();

    //영화의 평균 평점
    private double avg;

    //리뷰 수 jpa의 count()
    private int reviewCnt;

    private LocalDateTime regDate;

    private LocalDateTime modDate;
}

3-3.Service 수정

JPA를 통해 나오는 엔티티 객체들과 Double,Long 등의 값을 MovieDTO로 변환하는 entitiesToDto()를 추가하고 컨트롤러가 호출할 때 사용할 getList()를 추가한다

  • entitiesToDto() 가 받는 파라미터들
    -> Movie 엔티티, List<MovieImage">엔티티 , Double 타입의 평점 평균,Long 타입의 리뷰 개수
  • List<MovieImage">엔티티 리스트로 받은 이유 : 조회화면에서 여러 개의 MovieImage를 처리하기 위해

import com.example.mreview2022.dto.MovieDTO;
import com.example.mreview2022.dto.MovieImageDTO;
import com.example.mreview2022.dto.PageRequestDTO;
import com.example.mreview2022.dto.PageResultDTO;
import com.example.mreview2022.entity.Movie;
import com.example.mreview2022.entity.MovieImage;

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public interface MovieService {

    Long register(MovieDTO movieDTO);

    PageResultDTO<MovieDTO,Object[]> getList(PageRequestDTO requestDTO); // 목록처리

    default MovieDTO entitiesToDTO(Movie movie,List<MovieImage> movieImages,Double avg,Long reviewCnt){

        MovieDTO movieDTO = MovieDTO.builder()
                .mno(movie.getMno())
                .title(movie.getTitle())
                .regDate(movie.getRegDate())
                .modDate(movie.getModDate())
                .build();

        List<MovieImageDTO> movieImageDTOList = movieImages.stream().map(movieImage -> {
            return MovieImageDTO.builder().imgName(movieImage.getImgName())
                    .path(movieImage.getPath())
                    .uuid(movieImage.getUuid())
                    .build();
        }).collect(Collectors.toList());

        movieDTO.setImageDTOList(movieImageDTOList);
        movieDTO.setAvg(avg);
        movieDTO.setReviewCnt(reviewCnt.intValue());

        return movieDTO;
    }

}

3-4.ServiceImpl 수정

import com.example.mreview2022.dto.MovieDTO;
import com.example.mreview2022.dto.PageRequestDTO;
import com.example.mreview2022.dto.PageResultDTO;
import com.example.mreview2022.entity.Movie;
import com.example.mreview2022.entity.MovieImage;
import com.example.mreview2022.repository.MovieImageRepository;
import com.example.mreview2022.repository.MovieRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;

import javax.transaction.Transactional;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.function.Function;

@Service
@RequiredArgsConstructor
public class MovieServiceImpl implements MovieService {

    @Autowired
    private final MovieRepository movieRepository; // final

    @Autowired
    private final MovieImageRepository imageRepository; // final
    
    //... 생략

    @Override
    public PageResultDTO<MovieDTO, Object[]> getList(PageRequestDTO requestDTO) {

        Pageable pageable = requestDTO.getPageable(Sort.by("mno").descending());

        Page<Object[]> result = movieRepository.getListPage(pageable);

        Function<Object[], MovieDTO> fn = (arr -> entitiesToDTO(
                (Movie) arr[0],
                (List<MovieImage>) (Arrays.asList((MovieImage) arr[1])),
                (Double) arr[2],
                (Long) arr[3])
        );
        return new PageResultDTO<>(result,fn);
    }

}

3-6.Controller 수정

import com.example.mreview2022.dto.MovieDTO;
import com.example.mreview2022.dto.PageRequestDTO;
import com.example.mreview2022.service.MovieService;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.NotNull;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

@Controller
@RequestMapping("/movie")
@RequiredArgsConstructor
public class MovieController {

    @Autowired
    private final MovieService movieService; //final

	//... 생략

    @GetMapping("/list")
    public void list(PageRequestDTO pageRequestDTO, @NotNull Model model){

        model.addAttribute("result",movieService.getList(pageRequestDTO));
    }
}

3-6.html 수정

목록에 출력하는 데이터는 Model에 담겨있으므로 Model result를 이용해 출력한다

<!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 List Page
            <span>
                <a th:href="@{/movie/register}">
                    <button type="button" class="btn btn-outline-primary">REGISTER
                    </button>
                </a>
            </span>
        </h1>

        <form action="/movie/list" method="get" id="searchForm">
            <input type="hidden" name="page" value="1">
        </form>

        <table class="table table-striped">
            <thead>
            <tr>
                <th scope="col">#</th>
                <th scope="col">Picture</th>
                <th scope="col">Review Count</th>
                <th scope="col">AVG Rating</th>
                <th scope="col">Regdate</th>
            </tr>
            </thead>
            <tbody>

            <tr th:each="dto : ${result.dtoList}" >
                <th scope="row">
                    <a th:href="@{/movie/read(mno = ${dto.mno}, page= ${result.page})}">
                        [[${dto.mno}]]
                    </a>
                </th>
                <td><img th:if="${dto.imageDTOList.size() > 0 && dto.imageDTOList[0].path != null }"
                         th:src="|/display?fileName=${dto.imageDTOList[0].getThumbnailURL()}|" >[[${dto.title}]]</td>
                <td><b>[[${dto.reviewCnt}]]</b></td>
                <td><b>[[${dto.avg}]]</b></td>
                <td>[[${#temporals.format(dto.regDate, 'yyyy/MM/dd')}]]</td>
            </tr>

            </tbody>
        </table>

        <ul class="pagination h-100 justify-content-center align-items-center">

            <li class="page-item " th:if="${result.prev}">
                <a class="page-link" th:href="@{/movie/list(page= ${result.start -1})}" tabindex="-1">Previous</a>
            </li>

            <li th:class=" 'page-item ' + ${result.page == page?'active':''} " th:each="page: ${result.pageList}">
                <a class="page-link" th:href="@{/movie/list(page = ${page})}">
                    [[${page}]]
                </a>
            </li>

            <li class="page-item" th:if="${result.next}">
                <a class="page-link" th:href="@{/movie/list(page= ${result.end + 1} )}">Next</a>
            </li>
        </ul>


        <script th:inline="javascript">

        </script>
    </th:block>

</th:block>
  • 실행결과

좋은 웹페이지 즐겨찾기