개발 기록

사이드 프로젝트 [국비의 모든것] - 게시글 단건 조회 (1) 조회수 증가, 광고, 이전글/다음글, 해시태그 조회 구현

Caffeine Developer 2023. 6. 11. 05:26
반응형

목차

기능 요구사항 분석

이번에는 게시글 목록 페이지에서 게시글 하나를 클릭하였을 때 들어갈 수 있는 게시물 상세보기 페이지 백엔드 부분을 개발해보도록 하겠습니다.

 

먼저 기존에 개발해놓은 게시물 상세보기 페이지 화면은 다음과 같습니다.

게시물 상세보기 페이지

 

상위댓글, 하위댓글을 달았을 때 화면

 

 

관리자가 댓글을 블라인드 처리하였을 경우 화면

 

 

  1. 화면에 출력할 필요한 데이터
    게시글 작성자 : 프로필이미지, 닉네임, 활동점수
    게시글 : 조회수, 제목, 내용, 추천 수, 이전글제목, 이전글번호, 다음글제목, 다음글번호, 댓글목록, 게시물 작성일자
    댓글 작성자 : 프로필이미지, 닉네임, 활동점수
    댓글 : 추천 수, 블라인드 설정 여부, 내용, 부모댓글 번호
    광고 : 광고 이미지 파일명
     
  2. 화면에서는 상위-하위 댓글로만 구성되어있지만 추후 변경될 수 있으므로 계층형 댓글로 구현합니다.
  3. 화면 상세보기 페이지를 들어오면 사용자의 쿠키를 저장한 뒤, 조회수를 1 증가시킵니다. 24시간 내 조회수를 중복으로 증가시킬 수 없게 만듭니다.

조회 수 증가

먼저 조회 수 증가 로직을 살펴보겠습니다.

 

일단 게시글 상세 페이지를 서버사이드로 렌더링하겠습니다.

/** 게시글 단건 조회 */
@GetMapping("/{id}")
public String board(@PathVariable Long id, HttpSession session, Model model, HttpServletRequest request, HttpServletResponse response) {
    //조회 수 증가
    boardService.viewCountUp(id, response, oldCookie);
    return "board/boardDetail.tiles1";
}

필요한 데이터를 담아서 페이지에 보내주어야 하니 Model을 매개변수로 받고, 쿠키를 사용해야 하기 때문에

HttpServletRequest와 HttpServletResponse를 매개변수로 받아주었습니다.

요청이 들어오면 BoardService의 viewCountUp 메소드를 호출합니다.

 

다음은 게시물 조회 수 증가 로직을 살펴보겠습니다.

/** 게시물 조회 수 증가 */
@Transactional
public void viewCountUp(Long id, HttpServletResponse response, HttpServletRequest request) {
    Cookie oldCookie = null;
    Cookie[] cookies = request.getCookies();
    if (cookies != null) {
        for (Cookie cookie : cookies) {
            if (cookie.getName().equals("boardView")) {
                oldCookie = cookie;
            }
        }
    }

    //게시물 조회 수 증가
    if (oldCookie != null) {
        if (!oldCookie.getValue().contains("[" + id.toString() + "]")) {
            Board board = boardRepository.findById(id).orElseThrow(() -> new BaseException(NOT_FOUND_BOARD));
            board.viewCountPlus();
            oldCookie.setValue(oldCookie.getValue() + "_[" + id + "]");
            oldCookie.setPath("/");
            oldCookie.setMaxAge(60 * 60 * 24);
            response.addCookie(oldCookie);
        }
    } else {
        Board board = boardRepository.findById(id).orElseThrow(() -> new BaseException(NOT_FOUND_BOARD));
        board.viewCountPlus();
        Cookie newCookie = new Cookie("boardView","[" + id + "]");
        newCookie.setPath("/");
        newCookie.setMaxAge(60 * 60 * 24);
        response.addCookie(newCookie);
    }
}

요청(request)에 들어있는 쿠키 목록을 가져와서 "boardView"라는 이름의 쿠키가 있는지 검사합니다.

있다면 oldCookie에 넣어주고, "boardView"라는 이름의 쿠키가 없었다면 oldCookie의 값은 그대로 null일 것입니다.

 

oldCookie가 존재한다면 oldCookie의 value값을 확인하여 [게시물번호 id]를 포함하는지 확인합니다.

oldCookie의 값이 [게시물번호 id]를 포함하지 않는다면 id값으로 Board 엔티티를 조회한 후, 엔티티에서 조회수 증가 메소드(viewCountPlus) 메소드를 호출하여 조회수를 1 증가시켜줍니다.

그러고나서 [게시물번호 id] 의 값을 oldCookie의 value에 넣어주고, path와 쿠키 수명을 24시간으로 하여 response에 저장합니다.

 

oldCookie가 존재하지 않는다면 위의 과정과 마찬가지로 조회수를 1 증가시켜주고, name을 "boardView", value를 "[게시물 번호]로 생성하여 path는 "/", 수명은 24시간으로 하여 저장해줍니다.


광고 조회

사실 광고 조회는 BoardController에서 할 일이 아니긴 하지만, 해당 프로젝트는 거의 대부분을 서버사이드로 진행하였기 때문에 어쩔 수 없이 BoardController에서 AdvertisementService를 주입받아 조회하겠습니다.

 

BoardController에서 위에서 만든 요청 처리 메소드에 다음과 같은 코드를 추가합니다.

// 조건에 맞는 게시물 페이지 광고 5개 조회
LocalDateTime currentDateTime = LocalDateTime.now();
Set<Advertisement> advertisements = advertisementService.findAdvertisement(currentDateTime, Type.BOARD);
List<AdvertisementDto> advertisementList = advertisements.stream().map(a -> new AdvertisementDto().toDto(a)).collect(Collectors.toList());

model.addAttribute("advertisementList", advertisementList);

로직을 설명드리자면, LocalDateTime.now()를 호출하여 현재 날짜와 시간정보를 알아내고, 매개변수로 현재 시간과 광고 타입을 넣어줍니다. 광고 타입은 메인페이지에서 보여질 광고목록인 MAIN과, 게시글 상세보기 페이지에서 보여질 BOARD 가 있습니다.

 

조회된 광고목록은 엔티티가 그대로 노출되면 안되므로, AdvertisementDto를 만들어 List<AdvertisementDto>로 만들어 model에 담아줍니다.

 

광고 목록을 조회해주는 AdvertisementService의 findAdvertisement 메소드를 살펴보겠습니다.

/** 조건에 맞는 메인페이지 광고 5개 조회 */
public Set<Advertisement> findAdvertisement(LocalDateTime currentDateTime, Type type) {
    return advertisementRepository.findTop5ByStartDateLessThanAndEndDateGreaterThanAndType(currentDateTime, currentDateTime, type);
}

 

 Spring Data JPA에서는 메소드 이름을 문법에 맞추어 작성하면 별도로 쿼리를 작성하지 않아도 메소드 이름에 맞는 쿼리를 생성하여 날려줍니다.

그래서 Spring Data JPA의 네이밍 규칙에 맞춰 별도의 쿼리를 작성하지 않고 조건에 맞는 광고 목록을 조회하는 메소드를 만들었는데, 하나하나 의미를 따져보겠습니다. 

 

findTop5ByStartDateLessThanAndEndDateGreaterThanAndType 의미

Top5 : 조회된 목록중 가장 위의 5개의 데이터들만 조회합니다.

ByStartDateLessThan : 광고 시작일자가 첫번째 매개변수보다 작아야 합니다.

AndEndDateGreater : 광고 마감일자가 두번째 매개변수보다 커야합니다.

AndType : 타입이 세번째 매개변수보다 커야합니다.

매개변수로는 현재시간, 현재시간, 타입 이렇게 3가지를 넣어주었고, 쿼리문을 한글로 풀어 쓴다면

광고 시작일자 < 현재시간 < 광고 마감일자 이고, 타입이 일치하는 광고 목록중 5개를 조회

라고 볼 수 있습니다. 기획자가 없기에 혼자 따로 현재 활성화된 광고 목록이 5개 이상이지 않다는 가정하에 진행하였습니다.


 

이전글/다음글 조회

이제 조회수도 증가시켜주었고, 광고 목록도 조회하였으니 다음은 가장 핵심이 되는 로직인 게시물 조회 로직을 살펴보겠습니다.

//게시글 조회
BoardDto boardDto = boardService.findBoardById(id, session);

if(boardDto.getSecondCategory().equals("국비학원")) {
    Academy findAcademy = (Academy) boardRepository.findById(id).orElseThrow(() -> new BaseException(NOT_FOUND_BOARD));
    AcademyDto academy = new AcademyDto().toDto(findAcademy);
    model.addAttribute("academy", academy);
} else if(boardDto.getSecondCategory().equals("교육과정")) {
    Curriculum findCurriculum = (Curriculum) boardRepository.findById(id).orElseThrow(() -> new BaseException(NOT_FOUND_BOARD));
    CurriculumDto curriculum = new CurriculumDto().toDto(findCurriculum);
    model.addAttribute("curriculum", curriculum);
}

model.addAttribute("board", boardDto);

 

먼저 BoardService에서 게시물 id로 게시물을 조회합니다. 여기서는 로그인중일 시 게시물 추천 여부도 알아와야 하기 때문에 session도 파라미터로 넘겨줍니다.

 

조회된 게시물의 카테고리가 "국비학원"  카테고리 일 경우 추가적인 정보가 필요하기 때문에 Academy 엔티티를 조회하여 AcademyDto 로 만든다음, model에 넣어줍니다.

 

조회된 게시물의 카테고리가 "교육과정" 카테고리 일 경우 추가적인 정보가 필요하기 때문에 Curriculum 엔티티를 조회하여 CurriculumDto 로 만든다음, model에 넣어줍니다.

 

조회한 BoardDto를 model에 넣어주었습니다.

 

가장 먼저 호출한 BoardService의 findByBoardId를 메소드를 살펴보겠습니다.

** 게시글 상세보기 페이지에 맞춘 단건조회(추후 광고리스트 조회도 추가 예정)*/
public BoardDto findBoardById(Long id, HttpSession session) {
    boolean likeExist = false;  //게시글 좋아요 여부
    // 게시물 조회
    Board board = boardRepository.findById(id).orElseThrow(() -> new BaseException(NOT_FOUND_BOARD));

    // 이전글, 다음글 조회
    String firstCategory = board.getFirstCategory();
    String secondCategory = board.getSecondCategory();

    PrevAndNextBoardDto prevAndNextBoardDto = boardRepository.findPrevAndNextBoardDto(id, firstCategory, secondCategory);
    
    // 해시태그 조회
    List<String> hashtags = boardHashtagRepository.findTagNamesByBoardId(id);

    // 댓글 조회
    List<Comments> comments = commentsRepository.findAllWithMemberAndParentByBoardIdOrderByParentIdAscNullsFirstCommentIdAsc(id);
    List<CommentsDto> commentsDtoList = null;
    // 로그인중이라면 좋아요 여부 알아오기.
    if(isLogin(session)) { //로그인 중이라면

        LoginMemberDto loginMemberDto = (LoginMemberDto) session.getAttribute(SessionConst.LOGIN_MEMBER);

        Long loginMemberId = loginMemberDto.getId();
        likeExist = boardLikeRepository.existsByBoardIdAndMemberId(id,loginMemberId);

        if(comments.size() > 0) {   //댓글이 있다면
            // 댓글번호들 뽑기
            List<Long> commentIds = comments.stream().map(c -> c.getId()).collect(Collectors.toList());
            // 좋아요 누른 댓글번호 리스트 뽑기
            List<Long> commentsLikeList = commentsLikeRepository.findMyLikeCommentIdsByCommentsIdListAndMemberId(commentIds, loginMemberId);

            commentsDtoList = CommentsDto.convertHierarchy(comments, commentsLikeList);

        }
    } else {    //로그인중이 아니라면.
        commentsDtoList = CommentsDto.convertHierarchy(comments);
    }

    // 조회된 데이터들을 Dto로 조합
    return new BoardDto().toDto(id, likeExist, board, prevAndNextBoardDto, hashtags, commentsDtoList);
}

상당히 길지만 하나하나 살펴보겠습니다.

 

요구사항에서 게시글 상세보기 페이지에서는 이전글 다음글 정보를 조회합니다. 이전글 / 다음글 정보는 해당 카테고리 내에서 이루어지기 때문에 Repository의 조회 메소드에 매개변수로 게시글 id, 카테고리 정보를 넘겨주었습니다.

 

- BoardRepository.java

/**
 * 오라클 네이티브 쿼리 이전글, 다음글 secondCategory가 있을 때
 */
@Query(value = " select previousId, previousSubject, nextId, nextSubject " +
               " from " +
               " ( " +
               "    select board_id, " +
               "           lag(board_id, 1, 0) over(order by board_id desc) as previousId, " +
               "           lag(subject, 1, '이전글이 없습니다.') over (order by board_id desc) as previousSubject," +
               "           lead(board_id, 1, 0) over(order by board_id desc) as nextId, "+
               "           lead(subject, 1, '다음글이 없습니다.') over (order by board_id desc) as nextSubject " +
               "    from (select board_id,subject from board " +
               "          where first_category = :firstCategory and second_category = :secondCategory) A" +
               " ) " +
               " where board_id = :boardId",
        nativeQuery = true)
PrevAndNextBoardDto findPrevAndNextBoardDto(@Param("boardId") Long boardId,
                                            @Param("firstCategory") String firstCategory,
                                            @Param("secondCategory") String secondCategory);

 Repository의 이전글, 다음글 조회 메소드입니다.

이전글 다음글 조회는 Oracle의 lead, lag 함수를 사용하여 조회하였는데, 이는 JPA에서 지원하지 않기 때문에 어쩔 수 없이 네이티브 쿼리로 작성하였습니다.

 

Spring Date JPA에서 네이티브 쿼리를 작성하는 방법은 @Query어노테이션의 value 속성으로 쿼리를 넣고, 뒤에 nativeQuery = true 속성만 추가해주면 편리하게 네이티브 쿼리를 작성할 수 있습니다.

 


해시태그 조회

 

BoardService에는 다음과 같은 코드가 있습니다.

List<String> hashtags = boardHashtagRepository.findTagNamesByBoardId(id);

 

게시물에 해당하는 해시태그도 조회하여야 하기 때문에 BoardHashtagRepository에서 findTagNamesByBoardId 메소드를 호출하였습니다.

 

이름에서도 알 수 있듯이, BoardId에 해당하는 데이터의 TagName목록을 가져옵니다.

반응형