본문 바로가기
프로젝트/Recipository

[Dev] 22.12.09. 게시글 조회

by 규글 2022. 12. 9.

 게시글을 수정하고 삭제하는 기능을 작업하기에 앞서 해야할 것은 게시글을 조회하는 기능을 구현하는 것이다. 게시글을 수정하고 삭제하는 기능은 해당 글을 작성한 사람만 사용할 수 있으며, 해당 기능을 위한 html element 또한 작성한 사람만 볼 수 있어야 한다. 수정 및 삭제를 위한 element는 게시글을 조회할 때 보이도록 할 것이다.(물론 후에는 마이 페이지에서도 삭제가 가능하도록 연결할 계획이 있지만, 기본적인 수정과 삭제는 게시글 페이지로 넘어온 후에 사용할 수 있도록 구성할 것이다.)

 

 

작업

PageController.java

    private final RecipeServiceImpl recipeService;

    public PageController(RecipeServiceImpl recipeService) {
        this.recipeService = recipeService;
    }
    
-------------------------------------------------------------------------------
    
    @GetMapping("/content/{contentId}")
    public ModelAndView goContent(@PathVariable Long contentId,
                                  HttpServletRequest request,
                                  HttpServletResponse response){

        // Request로부터 Cookie array를 받아서 service에 전달
        Cookie[] cookieList = request.getCookies();
        Map<String, Object> map = recipeService.getRecipe(contentId, cookieList);
        // service에서 생성 혹은 수정한 Cookie를 response에 담음
        response.addCookie((Cookie) map.get("visitCookie"));

        ModelAndView mView = new ModelAndView();
        // service에서 data를 가져와서 ModelAndView 객체에 담음
        mView.addObject("recipe", map.get("recipeDto"));
        // 이동하고자 하는 페이지 정보와 함께 return
        mView.setViewName("pages/content");

        return mView;
    }

 조회하고자 하는 게시글의 번호를 path variable로 전달받아서 해당 번호에 맞는 게시글의 정보를 받아서 페이지 정보와 함께 return하는 controller의 method이다. View page를 함께 return 하기 위해 ModelAndView 객체를 return type으로 선택했으며, Response 객체에는 새로고침으로 인한 조회수 방지를 위해 Cookie를 add 했다.

 

RecipeServiceImpl.java

    @Override
    public Map<String, Object> getRecipe(Long contentId, Cookie[] cookieList) {
        // link repository로부터 data를 가져올 contentId를 담은 dummy entity
        Recipe recipe = Recipe.builder()
                .contentId(contentId)
                .build();
        // link repository로부터 가져온 List<Link>
        List<Link> linkList = linkRepository.findAllByRecipe(recipe);

        // List<Link> 의 한 Link로부터의 온전한 Recipe entity를 Dto로
        recipe = linkList.get(0).getRecipe();
        RecipeDto recipeDto = recipe.toDto();

        // List<Link> 에서 String link로 List<String> 을 만들어 Dto에 setting
        List<String> strLinkList = new ArrayList<String>();
        linkList.forEach(link -> {
            strLinkList.add(link.getLink());
        });
        recipeDto.setLink(strLinkList);

        // Cookie의 조회수 중복 방지를 위한 작업
        // return을 위한 Cookie variable
        Cookie visitCookie = null;
        // Cookie 존재 여부
        boolean cookieExists = false;
        // Request로부터 넘겨받은 Cookie 중
        for(Cookie cookie : cookieList){
            // "visit" 이라는 이름이 있으면
            if(cookie.getName().equals("visit")){
                cookieExists = true;
                // 그 안에 해당 contentId가 포함되어 있는지 여부 확인해서 없으면
                if(!cookie.getValue().contains(contentId.toString())) {
                    // 값으로 추가
                    cookie.setValue(cookie.getValue() + "_" + contentId);
                    visitCookie = cookie;
                    // 조회수 1 증가 및 repository save(update)
                    recipeDto.setViewCount(recipeDto.getViewCount() + 1);
                    recipeRepository.save(recipeDto.toEntity());
                    break;
                // 확인해서 있으면 그대로 return
                } else {
                    visitCookie = cookie;
                    break;
                }
            }
        }
        // 아예 "visit" 이라는 이름의 Cookie가 없으면
        if(!cookieExists){
            // 새로 만들어서 return
            visitCookie = new Cookie("visit", contentId.toString());
            visitCookie.setDomain("localhost");
            visitCookie.setPath("/content");
            visitCookie.setMaxAge(60*60*24);
            visitCookie.setSecure(true);
            // 조회수 1 증가 및 repository save(update)
            recipeDto.setViewCount(recipeDto.getViewCount() + 1);
            recipeRepository.save(recipeDto.toEntity());
        }

        // Map에 담아 controller로 return
        Map<String, Object> map = new HashMap<>();
        map.put("visitCookie", visitCookie);
        map.put("recipeDto", recipeDto);

        return map;
    }

 우선 Recipe entity로는 Link를 조회할 수 없는 N:1 단방향 연관 관계를 이루고 있어서, contentId data 만을 담은 Recipe dummy entity를 생성하여 link table에서 data를 가져오는 것으로 recipe table을 조회했다. 이렇게 조회한 Recipe entity를 Dto로 변환하고, 동일한 이유로 List<Link> 에서 Link entity의 String link data만을 List<String> 으로 바꿔서 RecipeDto 객체에 setting 했다.

 

 이어서 작업한 것은 조회수 증가에 대한 것이다. 게시글을 조회할 때, 해당 게시글의 내용이 있는 페이지로 이동하면서 동시에 DB recipe table의 조회수에 해당하는 viewCount column의 값을 1 증가시켜야 한다. 이때 단순히 조회한다는 것으로 계속해서 조회수를 올린다면, 새로고침을 눌렀을 때 계속해서 상승하게 된다. 이에 대한 작업은 국비 과정의 프로젝트에서는 따로 조회수를 노출하지 않아서 해보지 않았기때문에 관련 내용을 찾아보기로 했다.

 검색 결과 다른 분들은 Session이나 Cookie로 조회수 반복 count를 방지하고 있었다. 한 블로그에서는 조회수 증가를 방지하는 것에 Session을 활용하는 것은 도끼로 벌레를 잡는 격이라고 언급하며, 과연 조회수 중복 방지를 위해 저장하는 data가 보안이 필요한가에 대한 의문을 제기한다.[각주:1] Cookie의 경우 삭제가 가능하지만, 조회수 중복 방지를 위해 저장하는 data는 게시글에 해당하므로 보안에 큰 문제를 야기하지 않다고 판단하여 Cookie를 활용한 작업을 하기로 했다. 작업을 참고한 블로그이다. [각주:2]

 

 (왼쪽 이미지) 우선 Controller에 전달받은 Request로부터 Cookie array를 넘겨받았다. 그 중에 "visit" 이라는 이름의 Cookie가 있다면 이어서 그 value로 게시글 번호가 있는지 확인하고, 그 value로 게시글 번호를 _ 를 사이에 두면서 이어준 뒤 repository의 viewCount를 수정했다. 해당 이름의 Cookie는 있지만 이미 value에 게시글 번호를 포함하고 있다면 그냥 Cookie를 넘겨준다.

 (오른쪽 이미지) 만약에 Cookie array를 모두 살펴도 해당 이름의 Cookie가 없다면 새롭게 Cookie를 만들고 게시글 번호를 value에 부여한 뒤에 domain과 path, max age, secure 등을 설정한 뒤 repository의 viewCount를 수정했다. Cookie와 repository에서 조회한 RecipeDto를 Map에 담에 Controller로 넘겨주는 Service method이다. Service에 Request나 Response를 넘겨받지 않고 독립적인 logic을 수행할 수 있도록 취한 방식이다.

 

 

Entity 수정

 작업 중에 Recipe Entity에 게시글 등록 시간이 누락되었던 것을 인지했다. 지난 국비 과정에서의 프로젝트에서는 Mybatis와 oracle을 사용했고, DB에 저장할 게시글 등록 시간을 직접 작성해서 sql에 넣어주었다. 하지만 이번에는 jpa를 활용하는 만큼 다른 방식을 찾아보았다. 참고한 블로그이다.[각주:3]

 

BaseTime.java

package com.example.recipository.domain;


import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import javax.persistence.Column;
import javax.persistence.EntityListeners;
import javax.persistence.MappedSuperclass;
import java.time.LocalDateTime;

@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTime {
    @CreatedDate
    @Column(updatable = false, nullable = false)
    private LocalDateTime regDate;
}

 BaseTime이라는 abstract class를 만들고, 이를 Recipe Entity에서 entends 하도록 했다.

  • @MappedSuperclass : 이 class를 extends한 entity에서 이곳의 field 또한 column으로 인식하도록 한다.
  • @EntityListeners(AuditingEntityListener.class) : 이 class에 Auditing 기능을 포함시킨다. Auditing이란 컴퓨터 관련 용어에서는 '감사' 의 의미로 쓰인다. 지속적으로 확인하게끔 한다는 의미로 받아들이면 좋을 것 같다.
  • @CreatedDate : Entity가 생성되어 저장될 때 자동으로 시간을 저장하도록 한다.
  • @Column : 등록일은 수정할 수 없어야하므로 updatable 속성을 false로 했다.
  • @LastModifiedDate : Entity를 수정할 때 자동으로 시간을 저장하도록 한다.

 

Recipe.java

@Entity
public class Recipe extends BaseTime{

    (...)

    @Column(nullable = false)
    private LocalDateTime modDate;
    
    (...)
    
    @Override
    public LocalDateTime getRegDate() {
        return super.getRegDate();
    }
    
    (...)
    
        return RecipeDto.builder()
                .contentId(contentId)
                .title(title)
                .writer(writer)
                .content(content)
                .imagePath(imagePath)
                .viewCount(viewCount)
                .likeCount(likeCount)
                .link(linkList)
                .category(category)
                .bePublic(bePublic)
                .regDate(getRegDate().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")))
                .modDate(getModDate().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")))
                .build();

 만든 BaseTime abstract class를 Recipe 에서 extends 하도록 했다. 이곳에서 따로 regDate에 대한 field를 작성하지 않아도 super class의 field를 column으로 인식해서 table을 형성해준다.

 게시글 등록 시간에 이어지는 작업은 수정 시간이었다. 수정 시간도 BaseTime에 넣어도 되지만, 그럴 경우에는 게시글을 조회하는 것만으로도 수정 시간이 update된다. 따라서 이 항목에 대해서는 따로 field를 만들어주었으며, Dto로 변환할 때는 저장된 LocalDateTime이 '연도-월-일T시간:분:초:밀리초' 의 format으로 출력되어 원하는 pattern으로 client로 넘겨주기 위해 format을 변경하여 return 하도록 했다.

 

RecipeDto.java

        LocalDateTime ldtModDate;
        if(this.modDate != null){
            DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
            ldtModDate = LocalDateTime.parse(this.modDate, formatter);
        } else {
            ldtModDate = LocalDateTime.now();
        }

        return Recipe.builder()
                .contentId(contentId)
                .title(title)
                .writer(writer)
                .content(content)
                .imagePath(imagePath)
                .viewCount(viewCount)
                .likeCount(likeCount)
                .link(linkList)
                .category(category)
                .bePublic(bePublic)
                .modDate(ldtModDate)
                .build();

 반대로 RecipeDto에서 Entity로의 변환시에는 String을 LocalDateTime으로 바꿔주어야 했다. 수정 시간을 Dto로 어디에서인가 받을 일이 있을 것 같아서 분기했는데, 지금에서 생각해보니 client로부터 받을 일이 없어서 불필요한 부분인 것 같다. 특정 format의 String을 LocalDateTime으로 parsing 하는 내용인데 일단 작성은 해둔다. [각주:4]

 보통은 null일테니 현재 time을 저장하도록 한다.

 

RepositoryApplication.java

package com.example.recipository;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@EnableJpaAuditing
@SpringBootApplication
public class RecipositoryApplication {

	public static void main(String[] args) {
		SpringApplication.run(RecipositoryApplication.class, args);
	}
}

 이렇게 작성했으면 Jpa Auditing을 위한 annotation을 활성화하기 위해서 Application에 @EnableJpaAuditing annotation을 명시하는 것으로 작업은 마무리 된다.

 

 

View Page 구성

content.html

<!DOCTYPE html>
<html lang="en"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>write.html</title>
    <meta name="_csrf" th:content="${_csrf.token}">
    <meta name="_csrf_header" th:content="${_csrf.headerName}">
</head>
<link rel="stylesheet" href="/lib/css/bootstrap.css">
<link rel="stylesheet" href="/lib/css/contentform.css">
<style>
    .search_form{
      display:inline;
    }
    a{
        text-decoration: none;
    }
</style>
<body>
<div class="container">
    <div th:replace="fragments/navibar :: navi"></div>
    <h2 class="row col-8">게시글</h2>
    <div class="row col-8">
        <div th:if="${recipe.writer} == ${#authentication.name}">
            <a th:href="@{/user/delete/{contentId}(contentId = ${recipe.contentId})}"
               class="btn btn-light float-end">삭제</a>
            <a th:href="@{/user/content_updateform/{contentId}(contentId = ${recipe.contentId})}"
               class="btn btn-light float-end">수정</a>
        </div>
    </div>
    <div class="row col-8">
        <label for="title">제목</label>
        <div class="col">
            <input type="text" id="title" name="title" class="form-control"
                   style="background-color: white;" th:value="${recipe.title}" disabled>
        </div>
    </div>
    <div class="row col-8">
        <label for="writer">작성자</label>
        <div class="col">
            <input type="text" id="writer" name="" class="form-control"
                   style="background-color: white;" th:value="${recipe.writer}" disabled>
        </div>
    </div>
    <div class="row col-8">
        <label for="regDate">작성 일시</label>
        <div class="col">
            <input type="text" id="regDate" name="" class="form-control"
                   style="background-color: white;" th:value="${recipe.regDate}" disabled>
        </div>
        <label for="modDate">수정 일시</label>
        <div class="col">
            <input type="text" id="modDate" name="" class="form-control"
                   style="background-color: white;" th:value="${recipe.modDate}"disabled>
        </div>
    </div>
    <div class="row col-8">
        <label for="content">내용</label>
        <div class="col-8 input">
            <textarea name="content" id="content" cols="30" rows="10"
                      class="form-control" style="height: 240px;background-color: white;"
                      th:text="${recipe.content}"disabled></textarea>
        </div>
        <div class="col-4" style="padding-left: 35px;">
            <a href="#" data-bs-toggle="modal" data-bs-target="#exampleModal">
                <img src="/lib/image/no_image.png" alt="" id="image"
                     name="image" style="width: 240px; height: 240px;">
            </a>
            <div class="row">
            <div class="col">
                <span>조회수 :
                    <span th:text="${recipe.viewCount}"></span>
                </span>
            </div>
            <div class="col">
                <span>좋아요 :
                    <span th:text="${recipe.likeCount}"></span>
                </span>
            </div>
            </div>
        </div>
    </div>
    <div class="row col-8" style="float: none; margin:0 auto;">
        <label for="" style="float: none; margin:0 auto;">카테고리</label>
    </div>
    <div class="row col-8">
        <label for="link" style="width: 150px;">참고 링크</label>
        <ul id="link">
            <li th:each="link : ${recipe.link}">
                <a href="" class="refLink form-control" th:text="${link}"></a>
            </li>
        </ul>
    </div>
</div>
<!-- Modal -->
<div class="modal fade" id="exampleModal" tabindex="-1"
     aria-labelledby="exampleModalLabel" aria-hidden="true">
    <div class="modal-dialog modal-dialog-centered">
        <div class="modal-content">
            <div class="modal-header">
                <h1 class="modal-title fs-5" id="exampleModalLabel" th:text="${recipe.title}">Modal title</h1>
                <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
            </div>
            <img src="/lib/image/no_image.png" alt="">
        </div>
    </div>
</div>
</body>
<script src="/lib/js/bootstrap.bundle.js"></script>
</html>

 아직은 댓글 작업도 되어있지 않아서 더 작업해야할 부분이긴 하다. 게시글 작성 form과 이어서 작업하는 바람에 bootstrap이나 기타 css 작업은 이미 되어있는 상태이다.

 

 별다른 특별할 것은 없지만 짚고 넘어갈 부분은 위 이미지 하나로 충분할 것 같다.

  • ${ } : Server의 controller부터 ModelAndView로 RecipeDto를 recipe라는 이름으로 return 받은 data를 ${ } 안에서 받아 사용할 수 있다.
  • ${ } : Spring Security를 통해 로그인한 사용자의 name을 sec:authentication 의 sec tag로도 가능하지만 #authentication 또한 가능하다.[각주:5] [각주:6] [각주:7] 현재는 email이 노출되고 그 email value로 비교하고 있지만, 세 번째 블로그의 #authentication.principal.xxx 을 활용한다면 다른 data를 노출하는 것도 가능해보인다.
  • /{contentId} (contentId = ${recipe.contentId} : 원하는 경로 요청에 contentId를 넣는 방식이다. 당연히 hard coding 할 수 없는 부분이기때문에 전적으로 넘겨받는 data에 의존해야하는데, 이 방식으로 각 게시물에 대한 contentId data를 경로에 담을 수 있다.

 

 이어서 게시글을 삭제하고 수정하는 작업을 해보겠다.

 

Reference

댓글