SpringBoot with JPA 프로젝트(M:N) 6.영화조회,리뷰등록,삭제

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

1.조회 페이지

조회 페이지는 목록 페이지에서 영화의 번호를 클릭하면 "/movie/read" URL처리를 해야한다

1-1.Service 수정


import com.example.mreview2022.entity.Movie;

public interface MovieService {

	//... 생략

    MovieDTO getMovie(Long mno);

}

1-2.ServiceImpl 수정

MovieDTO 를 만들어 내기 위해 MovieRepository에서 가져오는 Movie, MovieImage 리스트, 평점 평균, 리뷰 개수의 리스트를 가공한다

import antlr.PreservingFileWriter;
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.ArrayList;
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 MovieDTO getMovie(Long mno) {

        List<Object[]> result = movieRepository.getMovieWithAll(mno);

        Movie movie = (Movie) result.get(0)[0] ; // Movie 엔티티는 가장 앞에 존재 - 모든 Row가 동일한 값이다

        List<MovieImage> movieImageList = new ArrayList<>(); // 영화의 이미지개수만큼 MovieImage 객체 필요

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

        Double avg = (Double) result.get(0)[2]; //평균 평점 - 모든 Row가 동일한 값
        Long reviewCnt = (Long) result.get(0)[3]; //리뷰 개수 - 모든 Row가 동일한 값

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

}

1-3.Controller 수정

GET 방식으로 '/movie/read?mno=xxx'와 같은 URL을 처리한다(수정 작업에도 동일한 코드가 사용된다)

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.ModelAttribute;
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({"/read", "/modify"})
    public void read(long mno, @ModelAttribute("requestDTO") PageRequestDTO requestDTO,Model model){

        MovieDTO movieDTO = movieService.getMovie(mno);

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

1-4.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="title" th:value="${dto.reviewCnt}" readonly>
        </div>

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

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

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

2.Ajax로 리뷰처리

2-1.ReviewDTO 생성

Review가 Movie 와 Member를 참조하는 구성으로 되어있으므로 ReviewDTO는 엔티티 클래스와 달리 단순 문자열 이나 영화 번호를 참초하는 형태로 변경된다

  • ReviewDTO는 화면에 필요한 모든 내용을 가지고 있어야 하기 때문에 회원이 아이디,닉네임,이메일도 같이 처리할 수 있도록 설계한다
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

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

    //review num
    private Long reviewnum;

    //Movie mno
    private Long mno;

    //Member id
    private Long mid;

    //Member nickname
    private String nickname;

    //Member email
    private String email;

    private int grade;

    private String text;

    private LocalDateTime regDate, modDate;
}

2-2.Review 엔티티 클래스 수정

리뷰평점과 리뷰 내용 수정할 수 있는 기능 추가

import lombok.*;
import javax.persistence.*;

@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@ToString(exclude = {"movie","member"})
public class Review extends BaseEntity{

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long reviewnum;

    @ManyToOne(fetch = FetchType.LAZY)
    private Movie movie;

    @ManyToOne(fetch = FetchType.LAZY)
    private Member member;

    private int grade;

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

2-3.ReviewService interface 생성

기능정의
1) 특정한 영화의 모든 리뷰 가져오는기능
2) 새로운 리뷰를 등록하는 기능
3) 특정 영화 리뷰 수정 기능
4) 특정 리뷰 삭제 기능

import com.example.mreview2022.dto.ReviewDTO;
import com.example.mreview2022.entity.Member;
import com.example.mreview2022.entity.Movie;
import com.example.mreview2022.entity.Review;

import java.util.List;

public interface ReviewService {

    // 영화의 모든 리뷰를 가져온다
    List<ReviewDTO> getListOfMovie(Long mno);

    // 영화 리뷰 추가
    Long register(ReviewDTO movieReviewDTO);

    //특정한 영화리뷰 수정
    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())
                .member(Member.builder().mid(movieReviewDTO.getMid()).build())
                .grade(movieReviewDTO.getGrade())
                .text(movieReviewDTO.getText())
                .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;
    }
}

2-4.ReviewServiceImpl 생성

import com.example.mreview2022.dto.ReviewDTO;
import com.example.mreview2022.entity.Movie;
import com.example.mreview2022.entity.Review;
import com.example.mreview2022.repository.ReviewRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

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

@Service
@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 movieReviewDTO) {

        Review movieReview = dtoToEntity(movieReviewDTO);

        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(movieReviewDTO.getText());

            reviewRepository.save(movieReview);
        }

    }

    @Override
    public void remove(Long reviewnum) {

        reviewRepository.deleteById(reviewnum);

    }
}

2-5.ReviewController 생성

ReviewController는 Ajax로 동작하기 때문에 @RestController로 설계하고 RevieDTO는 JSON 형태로 변환되어 처리한다, 새로운 영화 리뷰 등록 역시 JSON 포맷으로 전송 처리한다

import com.example.mreview2022.dto.ReviewDTO;
import com.example.mreview2022.service.ReviewService;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

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

    @Autowired
    private final ReviewService reviewService;

    @GetMapping("/{mno}/all") // 결과데이터 : ReviewDTO 리스트, 해당영화의 모든 리뷰 반환
    public ResponseEntity<List<ReviewDTO>> getList(@PathVariable("mno") Long mno){

        List<ReviewDTO> reviewDTOList = reviewService.getListOfMovie(mno);

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

    @PostMapping("/{mno}") // 결과데이터 : 생성된 리뷰 번호 , 새로운 리뷰등록
    public ResponseEntity<Long> addReview(@RequestBody 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 movieReviewDTO){

        reviewService.modify(movieReviewDTO);

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

	
    @DeleteMapping("/{mno}/{reviewnum}") // 리뷰 삭제 
    public ResponseEntity<Long> removieReview(@PathVariable Long reviewnum){

        reviewService.remove(reviewnum);

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

2-6.html 수정

2개의 모달창 추가한다 -> 리뷰등록 모달창, 영화 이미지 클릭했을 때의 모달창

  • reviewModal : 실제 영화 리뷰에 대한 처리를 하기 때문에 회원 아이디(mid), 별점,리뷰 내용을 입력할 수 있는 태그를 가진다
  • imageModal : 단순히 이미지(원본이미지)를 화면에 보여주는 용도로 작성
  • 별점처리 라이브러리 : http://dobtco.github.io/starrr/라이브러리 사용
    => starrr 라이브러리는 jQuery의 플러그인의 형태로 동작하므로 starrr()를 이용해 별점의 값이 변하는 이벤트를 처리할 수 있다, grade라는 변수로 별점을 처리한다
  • 리뷰 등록 : reviewSaveBtn을 클릭하면 회원의 아이디,점수,내용을 JSON데이터로 만들어 전송하고 데이터 처리가 성공하면 self.location.reload()를 이용해 URL을 다시호출하여 영화 리뷰가 등록된 후 변화하는 평균평점과 리뷰의 개수를 갱신하게 된다
  • 리뷰 리스트 보여주기 : getMovieReviews()를 호출하여 페이지가 열리면 jQuery의 getJSON()을 이용해 MovieReviewController 를 호출하고 reviewList라는 클래스 속성으로 지정된 div 태그에 내용물을 채운다
  • 특정 리뷰 선택 : 리뷰를 선택하면 해당 리뷰의 정보를 가져와 reviewModal로 셋팅하고 모달창을 보여준다
  • 리뷰의 수정, 삭제 : reviewModal 창에 나오는 리뷰의 수정과 삭제작업 모두 Ajax를 통해 PUT OR DELETE 방식으로 동작한다. 수정과 삭제 작업이 모두 처리된 후에는 현재 페이지를 다시 호출해 서버로부터 변경된 데이터를 받도록 처리한다
<!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="title" th:value="${dto.reviewCnt}" readonly>
        </div>

        <div class="form-group">
            <label >Avg </label>
            <input type="text" class="form-control" name="title" 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}" th:data-file="${movieImage.getThumbnailURL()}">
                    <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>

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

        <div class="list-group reviewList">

        </div>


        <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='starrr'></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>


        <script th:src="@{/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"); 미리 보기용

                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');
                });


                $('.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();


                //modify reveiw

                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');
                });


                $(".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();

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

                $(".uploadResult li").click(function() {

                    var file = $(this).data('file');

                    console.log(file);

                    $('.imageModal .modal-body').html("<img style='width:100%' src='/display?fileName="+file+"&size=1' >")

                    $(".imageModal").modal("show");

                });



            });
        </script>

    </th:block>

</th:block>

2-7.이미지의 원본이미지 보기 - UploadController 수정

String size 파라미터를 추가해 원본파일인지 섬네일인지 구분할 수 있도록 구성한다
-> 만약 size 변수의 값이 1인 경우 원본 파일을 전송한다

import com.example.mreview2022.dto.UploadResultDTO;
import net.coobird.thumbnailator.Thumbnailator;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.util.FileCopyUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

@RestController
public class UploadController {

    @Value("${com.example.upload.path}") // application.properties의 변수
    private String uploadPath;

	//...생략
    
    @GetMapping("/display")
    public ResponseEntity<byte[]> getFile(String fileName, String size){

        ResponseEntity<byte[]> result = null;

        try {

            String srcFileName = URLDecoder.decode(fileName,"UTF-8");

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

            if(size != null && size.equals("1")){
                file = new File(file.getParent(),file.getName().substring(2));
            }

            HttpHeaders header = new HttpHeaders();

            //MIME타입 처리
            header.add("Content-Type", Files.probeContentType(file.toPath()));

            //파일 데이터 처리
            result = new ResponseEntity<>(FileCopyUtils.copyToByteArray(file), header, HttpStatus.OK);
        }catch (Exception e){

            return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
        }
        return result;
    }


	//...생략
}
  • 실행결과

좋은 웹페이지 즐겨찾기