JPA로 질문게시판 만들기 -1 ( 리스트)

Elly·2024년 2월 11일
0
post-thumbnail

들어가기 앞서,
저는 개발자가 되고 싶은 국비학원 비전공자의 취준생입니다.
혹시, 개발 초보 분들 중에 JPA와 Querydsl를 이용하여 게시판을 만들고 싶으신 분들에게 도움이 될까싶어 글을 공개적으로 기록하게 되었습니다.
또한, 저도 도움을 받고 싶어서 올립니다!!
지적과 조언 환영합니다.💛 얼마든지 훈수 둬주시면 감사하겠습니다.

요구사항

1. 질문게시글 목록 페이지
-페이징, 검색(제목, 내용, 작성자), 조회순, 댓글순 정렬을 포함
-이미지 유무, 날짜, 댓글 수, 공감 수, 작성자를 알 수 있음
2. 질문게시글 작성 페이지
-이미지 및 파일 업로드 가능
-공백 유효성 검사
3. 질문게시글 상세 정보 페이지
-게시글 수정, 삭제, 신고, 주소 복사 기능
-조회수 증가(비로그인, 또는 본인 글이 아닐시에만)
4. 댓글
-댓글 공감 순으로 정렬
-댓글에 이미지 업로드 기능
-수정, 삭제, 신고 기능

API 설계

GET /questions : 질문 게시판 목록
GET /questions/save : 질문 게시판 글쓰기 창
POST /questions/save : 질문 게시판 작성
GET /questions/{qbNo} : 질문 게시판 상세 정보
GET /questions/{qbNo}/update : 질문 게시판 수정 글쓰기 창
PUT /questions/{qbNo}/update : 질문 게시판 수정
DELETE /questions/{qbNo} : 질문 게시판 삭제
POST /questions/{qbNo}/comments : 게시글에 댓글을 작성
PUT questions/comments/{qcNo} : 댓글수정
DELETE questions/comments/{qcNo} : 댓글삭제

프로젝트 구조


엔티티 작성

우선 엔티티 패키지에 엔티티를 만듭니다!
여기서 BaseEntity를 상속받았는데 이거는 등록과 수정 시간을 자동화시켜줍니다.
그러나, 저희 프로젝트에서는 BaseEntity는 기록용으로 두고,
수정,등록시간을 따로 관리하기로 했습니다. 참고해주세요.

@Builder
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Entity
@Table(name = "question_board")	
public class QuestionBoard extends BaseEntity {
	
	@Id
	@GeneratedValue (strategy = GenerationType.IDENTITY)
	@Comment("고유번호")
	private Long qbNo;
	
	@Comment("멤버 고유번호")
	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name="memberNo")
	private Member member;
	
	@Comment("작성자 닉네임")
	@Column(length = 100, nullable = false )
	private String qbNickName;
	
	@Comment("질문게시판제목")
	@Column(length = 255, nullable = false )
	private String qbTitle;
	
	@Lob
	@Comment("질문게시판내용")
	@Column(nullable = false)
	private String qbContent;
	
	@Comment("게시글 좋아요")
	@Column(nullable = true)
	private int qbLike;
	
	@Comment("조회수")
	@Column(nullable = true)
	private int qbViews;
	
    @ColumnDefault("'N'")
    @Comment("공지사항 여부")
    @Column(length = 1, nullable = false)
    private String qbNoticeYn;
    
    @ColumnDefault("'N'")
    @Comment("규제여부")
    @Column(length = 1, nullable = false)
    private String qbReportYn;
    
    @ColumnDefault("'N'")
    @Comment("삭제 여부")
    @Column(length = 1, nullable = false)
    private String qbDeleteYn;

    @CreatedDate
    @Column(updatable = false)
    @Comment("등록 날짜")
    private LocalDateTime qbCreateDt;
    
    @LastModifiedDate
    @Comment("수정 날짜")
    private LocalDateTime qbModifyDt;   
    
    private boolean hasThumbnail;
    
    private int commentCount;
    
    public void update(String qbTitle, String qbContent){
        this.qbTitle = qbTitle;
        this.qbContent = qbContent;
    }

    public void delete(String qbDeleteYn){
        this.qbDeleteYn = qbDeleteYn;
    }

    @Builder
    public QuestionBoard(String qbTitle, String qbContent, String qbNickName, Member member, String qbThuqbnail, boolean hasThumbnail){
        this.qbTitle = qbTitle;
        this.qbContent = qbContent;
        this.qbNickName = qbNickName;
        this.qbViews = 0;
        this.qbDeleteYn = "N";
        this.qbNoticeYn = "N";
        this.qbReportYn = "N";
        this.member = member;
        this.hasThumbnail = hasThumbnail;
    }

📢 중요한 부분!
Entity클래스에는 절대 절대 Setter를 만들지 않아요! 대신 해당 필드 값 변경이 필요하면 명확히 그 목적과 의도를 나타낼 수 있는 메소드를 추가해야합니다.(ex: update나 delete메소드)
그럼 Setter가 없는 상황에서 어떻게 값을 채워 DB에 삽입할까요?
생성자를 통해 최종 값을 채운 후 DB에 삽입하는것이 기본적인 구조입니다.
저는 여기서 Bulider패턴을 생성자 대신 이용하여 기본 값을 초기화했습니다. (@Bulider를 붙여서 사용합니다.)

이 Entity클래스를 DB에 접근 시켜줄 Repository는 (Mybatis의 DAO같은) 곧 만들도록 할게요.

리스트 조회

다시보는 요구사항
1. 질문게시글 목록 페이지
-페이징, 검색(제목, 내용, 작성자), 조회순, 댓글순 정렬을 포함
-이미지 유무, 날짜, 댓글 수, 작성자를 알 수 있음


저는 Pageable 인터페이스와 Querydsl를 이용하여 위의 요구사항을 구현할 생각입니다.

우선 데이터를 담을 DTO를 만들게요.

QuestionListDto

@Getter
public class QuestionListDto {
  
  private Long qbNo;
  private String qbTitle;
  private Member member;
  private String qbNickName;
  private int qbViews;
  private LocalDateTime qbModifyDt;
  private String qbReportYn;
  private String qbDeleteYn;
  private int commentCount;
  private boolean hasThumbnail;
  
  @QueryProjection
  public QuestionListDto(Long qbNo, String qbTitle, Member member, String qbNickName,
                     LocalDateTime qbModifyDt, int qbViews, String qbReportYn, String qbDeleteYn, int commentCount, boolean hasThumbnail) {
      this.qbNo = qbNo;
      this.qbTitle = qbTitle;
      this.member = member;
      this.qbNickName = qbNickName;
      this.qbModifyDt = qbModifyDt;
      this.qbViews = qbViews;
      this.qbReportYn = qbReportYn;
      this.qbDeleteYn = qbDeleteYn;
      this.commentCount = commentCount;
      this.hasThumbnail = hasThumbnail;
  }
  }
  

리스트 조회용이기 때문에 content(내용) 변수는 사용하지 않았습니다. 또 이미지 유무를 알려줄 변수로 hasThumbnail를 사용할게요. Querydsl를 사용하는 방법은 다양하지만 저는 DTO에 @QueryProjection를 사용하여 QDTO를 사용하겠습니다.
(DTO가 Querydsl에 의존적이게 된다는 문제점이 있지만, 저는 보다시피 변수가 많아서 제일 좋은 방법이라고 생각했습니다..!)

SearchDto

검색요청을 담을 searchDto도 만들어주겠습니다.

@Getter
@Setter
public class SearchDto {
  private String Keyword; -> 검색키워드
  private String searchVal; ->검색값
}

Controller

  /**
   * 질문게시판 리스트 조회
   * 
   * @param pageable
   * @param model
   * @param searchDto
   * @param category
   * @return
   */
  @GetMapping("/questions")
  public String QuestionBoard(@PageableDefault(size = 10) Pageable pageable, Model model,
      SearchDto searchDto, String category) {
    Page<QuestionListDto> results =
        questionBoardService.getQuestionBoardList(searchDto, pageable, category);
    model.addAttribute("list", results);
    //검색 키워드 및 입력값
    model.addAttribute("searchDto", searchDto);
    // 검색결과 갯수
    model.addAttribute("count", results.getTotalElements());
    return "board/question/list";
  }

Pageable객체를 사용하려면 타입을 Page로 해야합니다.
getTotalElements() : 전체 데이터의 개수를 반환합니다.
pageable의 기본은 20개씩 부르는 거여서 저는 @PageableDefault(size = 10) 를 통해 10개로 지정했습니다.

참고하면 좋을 블로그
https://colour-my-memories-blue.tistory.com/10

Service

  //질문게시판리스트조회
  public Page<QuestionListDto> getQuestionBoardList(SearchDto searchDto, Pageable pageable,
      String category) {
    return questionBoardRepository.findQuestionBoardList(searchDto, pageable, category);
  }

Repository

여기서 중요한것은, 저는 querydsl을 사용하기 때문에

이렇게 QustionBoardRepositoryCustom에서 메소드를 선언하고 impl에서 구현해주어야합니다.
자세한 설명은
https://ssow93.tistory.com/69
저도 여기를 보았습니다. 헤헤.

QustionBoardRepositoryCustomImpl

@Repository
public class QuestionBoardRepositoryImpl implements QuestionBoardRepositoryCustom  {
   
  private JPAQueryFactory queryFactory;

    public QuestionBoardRepositoryImpl(EntityManager em) {
      this.queryFactory = new JPAQueryFactory(em);
  }
    
    //질문게시판 리스트조회
    @Override
    public Page<QuestionListDto> findQuestionBoardList(SearchDto searchDto, Pageable pageable, String category) {
        List<QuestionListDto> content = getQuestionBoardList(searchDto, pageable, category);
        Long count = getCount(searchDto);
        return new PageImpl<>(content, pageable, count);
    }

    //검색한 것에 대한 총 갯수반환
   private Long getCount(SearchDto searchDto){	
        Long count = queryFactory
                .select(questionBoard.count())
                .from(questionBoard)
                .where(containsSearch(searchDto))
                .fetchOne();
        return count;
    }
   

 //QLISTDTO반환 (offset=시작위치, getPafeSize=한페이지당 최대항목수)
    private List<QuestionListDto> getQuestionBoardList(SearchDto searchDto, Pageable pageable, String category) {
      
      return queryFactory
              .select(new QQuestionListDto(
                      questionBoard.qbNo,
                      questionBoard.qbTitle,
                      questionBoard.member,
                      questionBoard.qbNickName,
                      questionBoard.qbModifyDt,
                      questionBoard.qbViews,
                      questionBoard.qbReportYn,
                      questionBoard.qbDeleteYn,
                      questionBoard.commentCount,
                      questionBoard.hasThumbnail
              ))
              .from(questionBoard)
              .leftJoin(questionBoard.member, member)
              .orderBy(orderExpression(category),questionBoard.qbNo.desc())
              .where(containsSearch(searchDto))
              .offset(pageable.getOffset())
              .limit(pageable.getPageSize())
              .fetch();
  }
    
    //정렬기능, category가 없으면 고유번호 내림차순으로 정렬
    private OrderSpecifier<?> orderExpression(String category) {
      if (category != null) {
          switch (category) {
              case "view":
                  return new OrderSpecifier<>(Order.DESC, questionBoard.qbViews);
              case "comment":
                  return new OrderSpecifier<>(Order.DESC, questionBoard.commentCount);
              default:
                  return new OrderSpecifier<>(Order.DESC, questionBoard.qbNo);
          }
      } else {
          return new OrderSpecifier<>(Order.DESC, questionBoard.qbNo);
      }
  }

    //검색결과 중 삭제되지 않은 것 반환
    private BooleanExpression containsSearch(SearchDto searchDto) {
      if (searchDto != null && searchDto.getSearchVal()!="" && searchDto.getKeyword() != null) {
          switch (searchDto.getKeyword()) {
              case "title":
                  return questionBoard.qbTitle.contains(searchDto.getSearchVal()).and(questionBoard.qbDeleteYn.eq("N"));
              case "nickname":
                  return questionBoard.qbNickName.contains(searchDto.getSearchVal()).and(questionBoard.qbDeleteYn.eq("N"));
          }
      }
      return questionBoard.qbDeleteYn.eq("N");
  }
    
}

⭑・゚゚・:༅。.。༅:゚::✼✿ 참고하면 좋을 공부 ✿✼:゚:༅。.。༅:*・゚゚・⭑
.fetchOne(); = 단일결과를 반환하는 쿼리에서 사용
OrderSpecifier<?> = Querydsl에서 동적 정렬에 이용하는 클래스
BooleanExpression = Querydsl에서 조건식에 이용되는 인터페이스 동적검색에 이용한다. (직관적으로 코드를 볼 수 있어서 좋은것같아요!)
.orderBy(orderExpression(category),questionBoard.qbNo.desc()) = 이렇게하면 category가 많은 순으로 정렬을 해준후에 만약 값이 같다면 qbNo(고유번호)순으로 내림차순해줍니다.

짠!! 서버 작업은 끝났습니다. 이제 html을 보도록 하겠습니다.
저는 타임리프를 사용했어요.

list.html

<div layout:fragment="Content" class="item Content">
    <form th:action="@{/questions}" method="get">
        <nav class="container">
            <span th:if="${searchDto.searchVal != null and searchDto.searchVal ne ''}">
                <div class="serachResultBox"><span class="searchResult" th:text="${count}"></span>
                    건의 결과가 있어요.
                </div>
            </span>
            <!--검색창-->
            <span class="search-wrap">
                <!--===========검색키워드==============-->
                <select name="keyword" th:value="${searchDto.keyword}" class="form-select">
                    <option value="title">제목</option>
                    <option value="nickname">작성자명</option>
                </select>
                <!--===========검색키워드==============-->
            <div class="input-group">
                <input type="text" name="searchVal" th:value="${searchDto.searchVal}" class="form-control" placeholder="검색어를 입력하세요.">
                <button type="submit" class="btn" id="dd">검색</button>
            </div>
            </span>
            <!--검색창끝-->
            <div class="btnWrite">
                <a class="vv" href="/questions/save">글쓰기</a>
            </div>
            <div class="sort-write">
                <a th:href="@{/questions(searchVal=${searchDto.searchVal},keyword=${searchDto.keyword},category='comment')}">
                    <button type="button" class="orderLikes" id="ddd"> · 댓글순</button>
                </a>
                <a th:href="@{/questions(searchVal=${searchDto.searchVal},keyword=${searchDto.keyword}, category='view')}">
                    <button type="button" class="orderLikes" id="ddd"> · 조회순</button>
                </a>
            </div>
            <table class="table table-hover">
                <colgroup>
                    <col width="3%" />
                    <col width="23%" />
                    <col width="5%" />
                    <col width="5%" />
                    <col width="2%" />
                </colgroup>
                <thead>
                    <tr>
                        <th style="color: white;">#</th>
                        <th>제목</th>
                        <th>글쓴이</th>
                        <th>작성일</th>
                        <th>조회</th>
                    </tr>
                </thead>
                <tbody>
                    <tr th:each="list, index : ${list}">
                        <td id="qbno" th:text="${list.qbNo}"></td>
                        <td style="text-align: left;"><a id="mm1" th:text="${list.qbTitle}" th:href="@{/questions/{qbNo}(qbNo=${list.qbNo})}"></a>
                            <span th:if="${list.commentCount} gt 0">
                                <span class="commentCount" th:text="${list.commentCount}"></span>
                            </span>
                            <span th:if="${list.hasThumbnail} eq true">
                                <img src="/img/image.png" alt="이미지있어요" class="imageicon">
                            </span>
                            <span th:if="${#temporals.format(list.qbModifyDt, 'yyyy-MM-dd').equals(#temporals.format(#temporals.createNow(), 'yyyy-MM-dd'))}">
    							<img src="/img/new.png" alt="새글입니다" class="imageicon">
							</span>
							</td>
							<td th:text="${list.qbNickName}"></td>
							<td class="mm" th:text="${list.formattedModifyDt}"></td>
							<td class="mm" th:text="${list.qbViews}"></td>
						</tr>
					</tbody>
				</table>
				<!--===============페이징===============-->
<nav class="container d-flex align-items-center justify-content-center" aria-label="Page navigation example">
    <ul class="pagination">
        <li th:if="${list.number > 0}" class="page-item">
            <a th:href="@{/questions(page=0,searchVal=${searchDto.searchVal},keyword=${searchDto.keyword})}" class="page-link" aria-label="First">
                <span aria-hidden="true">&laquo;&laquo;</span>
            </a>
        </li>
        <li th:if="${list.number > 0}" class="page-item">
            <a th:href="@{/questions(page=${list.number - 10},searchVal=${searchDto.searchVal},keyword=${searchDto.keyword})}" class="page-link" aria-label="Previous">
                <span aria-hidden="true">&laquo;</span>
            </a>
        </li>
        <li th:each="i : ${#numbers.sequence(0, list.totalPages - 1)}" class="page-item"
            th:classappend="${i == list.number} ? 'active'">
            <a th:href="@{/questions(page=${i},searchVal=${searchDto.searchVal},keyword=${searchDto.keyword})}" th:text="${i + 1}" class="page-link"></a>
        </li>
        <li th:if="${list.number + 1 < list.totalPages}" class="page-item">
            <a th:href="@{/questions(page=${list.number + 10},searchVal=${searchDto.searchVal},keyword=${searchDto.keyword})}" class="page-link" aria-label="Next">
                <span aria-hidden="true">&raquo;</span>
            </a>
        </li>
        <li th:if="${list.number + 1 < list.totalPages}" class="page-item">
            <a th:href="@{/questions(page=${list.totalPages - 1},searchVal=${searchDto.searchVal},keyword=${searchDto.keyword})}" class="page-link"
               aria-label="Last">
                <span aria-hidden="true">&raquo;&raquo;</span>
            </a>
        </li>
    </ul>
</nav>
            </nav> <!--게시판컨테이너-->
        </form>
</div>

날짜와 이미지사진표시 등등은 다음에 설명하고, 이번 게시글에서는 페이징과 검색결과만 설명하도록 하겠습니다.

우선 검색결과는 get방식 form을 이용해서 쿼리로 데이터를 넣습니다.
/questions?keyword=값&searchVal=값
이런식으로 name속성과 th:value속성으로 url가 위 처럼 작성이 됩니다.

따라서 페이징을 할 때도,

        <li th:if="${list.number + 1 < list.totalPages}" class="page-item">
            <a th:href="@{/questions(page=${list.totalPages - 1},searchVal=${searchDto.searchVal},keyword=${searchDto.keyword})}" class="page-link"
               aria-label="Last">
                <span aria-hidden="true">&raquo;&raquo;</span>
            </a>
        </li>

이런식으로 쿼리파라미터를 적어서 서버에 요청해야합니다.
number는 page인터페이스의 메서드로 현재 페이지의 인덱스를 반환합니다.
totalPages는 전체 페이지수를 나타냅니다.

결과화면

"게시글"로 검색 및 조회순으로 정렬했을 때 화면

아쉬운 점

아쉬운 점은 조회 성능 향상을 위해 listDto에 글 내용을 넣지 않았는데, (서머노트 이용할 예정이라 양이 엄청남)
그러면 글내용으로 검색하기는 어떻게 만들면 좋을지ㅠ_ㅠ 우선 다 개발한 후에 수정하도록 하겠습니다.

🌼참고🌼

https://minchul-son.tistory.com/494
https://aamoos.tistory.com/675
책"스프링 부트와 AWS로 혼자 구현하는 웹 서비스"

profile
반성적 사고하며 성장하는 개발자

0개의 댓글

관련 채용 정보