네이버 카페 등의 게시판을 주로 보면 질문에 대한 답글이 게시되어 있는 기능을 종종 볼 수 있습니다.
이번에는 답글을 달 수 있는 게시판을 제작해 보겠습니다.
단, 간단한 예제를 위해 답글은 1번만 달 수 있도록 제한하고 테이블 설계 및 코딩을 하도록 하겠습니다.
요소 기술 및 테이블 설계는 아래와 같습니다.
요소 기술 :
- 프로젝트 키워드 : ReplyBoard
- 프론트엔드 : 리액트 & 타입스크립트
- 벡엔드 : 스프링부트 & JPA & Oracle 18xe(Oracle Cloud 19c)
프론트 작업
1) 답변형 게시판 TYPE 정하기
IReplyBoard.ts 파일을 types 폴더에 저장합니다.
export default interface IReplyBoard {
bid?: any | null, // 게시글 번호
boardTitle: string, // 게시글 제목
boardContent: string, // 게시글 내용
boardWriter: string, // 게시글 작성자
viewCnt: number, // 조회수
boardGroup: any | null, // 게시글 그룹 번호
boardParent: any | null // 부모 게시글 번호
}
2) 답변형 게시판 axios CRUD 서비스 코딩
ReplyBoardService.ts 파일을 service 폴더에 저장합니다.
여기서 생성(저장)함수는 부모 게시글 생성, 답변글 생성 함수를 2개 작성해야하며, 삭제 함수의 경우도 부모+답변글 모두 삭제 기능 함수와, 답변 삭제 기능 함수 총 2개를 작성하여야 합니다.
2-1) ReplyBoard type import / axios 공통 파일 import
import IReplyBoard from "../../types/normal/IReplyBoard";
import http from "../../utils/http-common";
2-2) 전체조회함수 + like 검색(페이징 기능)
// 전체 조회 + like 검색(paging 기능 : page(현재페이지), size(한 페이지당 개수))
const getAll = (boardTitle:string, page:number, size:number) => {
return http.get<Array<IReplyBoard>>(`/normal/reply-board?boardTitle=${boardTitle}&page=${page}&size=${size}`);
};
2-3) 상세조회
// 상세 조회
const get = (bid:any) => {
return http.get<IReplyBoard>(`/normal/reply-board/${bid}`);
};
2-4) 저장함수 : 부모 게시글 저장
// 저장 함수 : 게시물 생성(부모글)
const createBoard = (data:IReplyBoard) => {
return http.post<IReplyBoard>("/normal/reply-board", data);
};
2-5) 저장함수 : 답변글 저장
// 저장 함수 : 답변글 생성(자식글)
const create = (data:IReplyBoard) => {
return http.post<IReplyBoard>("/normal/reply", data);
};
2-6) 수정함수
// 수정 함수
const update = (bid:any, data:IReplyBoard) => {
return http.put<any>(`/normal/reply-board/${bid}`, data);
};
2-7) 삭제함수 : 부모글 + 답변글 모두 삭제
그룹번호 : 부모글과 자식글은 모두 그룹번호가 같음
// 삭제 함수 : 게시물(부모글) + 답변글(자식글) 모두 삭제
// 그룹 번호 : 부모글과 자식글은 모두 그룹 번호가 같음
const removeBoard = (boardGroup:any) => {
return http.delete<any>(`/normal/reply-board/deletion/${boardGroup}`);
};
2-8) 삭제함수 : 답변글만 삭제
// 삭제 함수 : 답변글만 삭제
const remove = (bid:any) => {
return http.delete<any>(`/normal/reply/deletion/${bid}`);
};
2-9) 함수 내보내기
const ReplyBoardService = {
getAll,
get,
createBoard,
create,
update,
removeBoard,
remove,
};
export default ReplyBoardService;
3) 답변형 게시글 리스트 페이지 작성
ReplyBoardList.tsx 파일을 reply-board 폴더에 저장합니다.
import React, { useEffect, useState } from "react";
import TitleCom from "../../../components/common/TitleCom";
import { Pagination } from "@mui/material";
import { Link } from "react-router-dom";
import IReplyBoard from "../../../types/normal/IReplyBoard";
import ReplyBoardService from "../../../services/normal/ReplyBoardService";
function ReplyBoardList() {
// todo ) 변수정의
// 답변형 게시판(게시물+답변) 배열 변수
// 답변글 1개만 달리게 제한
const [replyBoard, setReplyBoard] = useState<Array<IReplyBoard>>([]);
// 검색어 변수
const [searchBoardTitle, setSearchBoardTitle] = useState<string>("");
// todo 공통 변수 : page(현재페이지 번호), count(총 페이지 건수), pageSize(3,6,9 배열)
const [page, setPage] = useState<number>(1);
const [count, setCount] = useState<number>(1);
const [pageSize, setPageSize] = useState<number>(3); // 한 페이지당 개수
// todo 공통 pageSizes : 배열 (selectbox에 사용)
const pageSizes = [3, 6, 9];
// todo ) 함수정의
// 1) 컴포넌트가 mounted 될때 한번만 실행됨 : useEffect(() => {},[])
// 2) 컴포넌트의 변수값이 변할 때 실행됨 : useEffect(() => {실행문},[감시할 변수])
useEffect(() => {
retrieveReplyBoard(); // 전체 조회
}, [page, pageSize]);
// 전체 조회 함수
const retrieveReplyBoard = () => {
// 벡엔드 매개변수 전송 : 현재페이지(page), 1페이지당 개수(pageSize)
ReplyBoardService.getAll(searchBoardTitle, page - 1, pageSize) // 벡엔드 전체조회요청
.then((response: any) => {
// 벡엔드 성공시 실행됨
// es6(모던js) 문법 : 객체 분해 할당
// 원래 코드
// const dept = response.data.dept; // 부서배열
// const totalPage = response.data.totalPages; // 전체페이지수
const { replyBoard, totalPages } = response.data;
// dept 저장
setReplyBoard(replyBoard);
setCount(totalPages);
// 로그 출력
console.log("response", response.data);
})
.catch((e: Error) => {
// 벡엔드 실패시 실행됨
console.log(e);
});
};
// 검색어 수동 바인딩 함수
const onChangeSearchBoardTitle = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchBoardTitle(e.target.value);
};
// todo handlePageSizeChange(공통) : pageSize 값 변경시 실행되는 함수
// select 태그 수동바인딩 : 화면 값을 변수에 저장하는 것
const handlePageSizeChange = (e: any) => {
setPageSize(e.target.value); // 1 페이지당 개수 저장(3, 6, 9)
setPage(1); // 현재페이지 번호 : 1로 강제설정
};
// todo Pagination 수동바인딩(공통)
// page 번호를 누르면 => page 변수에 값 저장
const handlePageChange = (e: any, value: number) => {
// value == 화면의 페이지번호
setPage(value);
};
// -------------------------------------------------------
// todo :: 답변 변수 정의
// 1) reply 객체 초기화
const initialReply = {
bid: null,
boardTitle: "",
boardContent: "",
boardWriter: "",
viewCnt: 0,
boardGroup: null,
boardParent: 0,
};
// 2) 답변 글 입력 객체 변수
const [reply, setReply] = useState(initialReply);
// 3) reply 버튼 클릭시 상태 저장할 변수 : true/false
const [replyClicked, setReplyClicked] = useState(false);
// todo :: 답변 함수 정의
// 1) input 수동 바인딩 함수
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = event.target; // 화면값
setReply({ ...reply, [name]: value }); // 변수저장
};
// 2) 답변글 생성 함수(insert)
const saveReply = () => {
// 임식 객체
let data = {
boardTitle: reply.boardTitle,
boardContent: reply.boardContent,
boardWriter: reply.boardWriter,
viewCnt: 0,
// 그룹번호(부모글 == 자식글)
// rule : 1) 부모글 최초생성 또는 답변글 없을때 0 저장
// 2) 답변글 생성이면 부모글 게시판 번호(bid)를 저장
boardGroup: reply.bid,
// 부모글번호
// rule : 1) 부모글 최초생성 또는 답변글 없을때 자신의 게시판 번호(bid) 저장
// 2) 답변글 생성이면 부모글 번호(bid) 저장
boardParent: reply.bid,
};
ReplyBoardService.create(data) // 백엔드 답변글 저장 요청
.then((response: any) => {
alert("답변글이 생성되었습니다.");
// 전체 재조회 실행
retrieveReplyBoard();
console.log(response.data);
})
.catch((e: Error) => {
console.log(e);
});
};
// 3) 게시물 reply 버튼 클릭시 화면에 답변입력창 보이게 하는 함수
const newReply = (data: any) => {
// 매개변수 데이터(객체) 수정 : boardContent: "" 으로 수정
setReply({ ...data, boardContent: "" });
// 답변 입력창 화면 보이기 : replyClicked : true
setReplyClicked(true);
};
// 4) 답변 입력창 숨기기
const closeReply = () => {
// 답변 화면창 숨기기
setReplyClicked(false);
};
return (
<div>
{/* 제목 start */}
<TitleCom title="Reply Board List" />
{/* 제목 end */}
{/* search start (검색어 입력창)*/}
<div className="row mb-5 justify-content-center">
<div className="col-12 w-50 input-group mb-3">
<input
type="text"
className="form-control"
placeholder="Search by title"
value={searchBoardTitle}
onChange={onChangeSearchBoardTitle}
/>
<button
className="btn btn-outline-secondary"
type="button"
onClick={retrieveReplyBoard}
>
Search
</button>
</div>
</div>
{/* search end */}
{/* page start (페이지 번호)*/}
<div className="mt-3">
{"Items per Page: "}
<select onChange={handlePageSizeChange} value={pageSize}>
{pageSizes.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<Pagination
className="my-3"
count={count}
page={page}
siblingCount={1}
boundaryCount={1}
variant="outlined"
shape="rounded"
onChange={handlePageChange}
/>
</div>
{/* page end */}
{/* 게시판(폼1) + 답변글(폼2) */}
<div className="col-md-12">
{/* table start(게시판) */}
<table className="table">
<thead>
<tr>
<th scope="col">board No</th>
<th scope="col">board Title</th>
<th scope="col">board Content</th>
<th scope="col">board Writer</th>
<th scope="col">view Cnt</th>
<th scope="col">reply</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
{replyBoard &&
replyBoard.map((data, index) => (
// 키값 추가 않하면 react 에서 경고를 추가 : 키는 내부적으로 리액트가 rerending 할때 체크하는 값임
<tr key={index}>
<td>{data.bid}</td>
<td>{data.boardTitle}</td>
<td>{data.boardContent}</td>
<td>{data.boardWriter}</td>
<td>{data.viewCnt}</td>
<td>
{/* 클릭하면 아래 답변 폼이 열림 */}
{data.boardParent == 0 && (
<Link to={"#"}>
{/* 리액트 : onClick={함수명} : 매개변수 없으면 */}
{/* 리액트 : onClick={()=> 함수명(매개변수)} : 매개변수 있으면 */}
<span
className="badge bg-warning"
onClick={() => newReply(data)}
>
Reply
</span>
</Link>
)}
</td>
<td>
{/* 클릭시 상세화면 이동 */}
<Link
to={
"/reply-board/bid/" +
data.bid +
"/boardParent/" +
data.boardParent
}
>
<span className="badge bg-success">Edit</span>
</Link>
</td>
</tr>
))}
</tbody>
</table>
{/* table end */}
{/* reply form start (답변글)*/}
<div>
{/* 변수명 && 태그 : 변수명 = true 태그가 보이고 */}
{/* 변수명 && 태그 : 변수명 = false 태그가 안보임 */}
{replyClicked && (
<div className="col-md-12 row">
<div className="col-md-12 row mt-2">
<label htmlFor="bid" className="col-md-2 col-form-label">
bid
</label>
<div className="col-md-10">
<input
type="text"
className="form-control-plaintext"
id="bid"
placeholder={reply.bid || ""}
disabled
name="bid"
/>
</div>
</div>
<div className="col-md-12 row mt-2">
<label htmlFor="boardTitle" className="col-md-2 col-form-label">
board Title
</label>
<div className="col-md-10">
<input
type="text"
className="form-control-plaintext"
id="boardTitle"
disabled
placeholder={reply.boardTitle}
name="boardTitle"
/>
</div>
</div>
<div className="col-md-12 row mt-2">
<label
htmlFor="boardContent"
className="col-md-2 col-form-label"
>
board Content
</label>
<div className="col-md-10">
<input
type="text"
className="form-control"
id="boardContent"
required
value={reply.boardContent}
onChange={handleInputChange}
name="boardContent"
/>
</div>
</div>
<div className="col-md-12 row mt-2">
<label
htmlFor="boardWriter"
className="col-md-2 col-form-label"
>
board Writer
</label>
<div className="col-md-10">
<input
type="text"
className="form-control"
id="boardWriter"
required
value={reply.boardWriter}
onChange={handleInputChange}
name="boardWriter"
/>
</div>
</div>
<div className="row px-4 mt-2">
<button
onClick={saveReply}
className="btn btn-success mt-3 col-md-5"
>
Submit
</button>
<div className="col-md-2"></div>
<button
onClick={closeReply}
className="btn btn-danger mt-3 col-md-5"
>
Close
</button>
</div>
</div>
)}
</div>
{/* reply form end */}
</div>
</div>
);
}
export default ReplyBoardList;
4) 프론트 서버 시작 후 화면 확인
백엔드 작업
1) ReplyBoart 엔티티 생성
package com.example.simpledms.model.entity.normal;
import com.example.simpledms.model.common.BaseTimeEntity;
import lombok.*;
import org.hibernate.annotations.DynamicInsert;
import org.hibernate.annotations.DynamicUpdate;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.Where;
import javax.persistence.*;
/**
* packageName : com.example.simpledms.model.entity.normal
* fileName : ReplyBoard
* author : GGG
* date : 2023-10-26
* description :
* 요약 :
* <p>
* ===========================================================
* DATE AUTHOR NOTE
* —————————————————————————————
* 2023-10-26 GGG 최초 생성
*/
@Entity
@Table(name="TB_REPLY_BOARD")
@SequenceGenerator(
name = "SQ_REPLY_BOARD_GENERATOR"
, sequenceName = "SQ_REPLY_BOARD"
, initialValue = 1
, allocationSize = 1
)
@Getter
@Setter
@ToString
@Builder
@NoArgsConstructor
@AllArgsConstructor
@DynamicInsert
@DynamicUpdate
// soft delete
@Where(clause = "DELETE_YN = 'N'")
@SQLDelete(sql = "UPDATE TB_REPLY_BOARD SET DELETE_YN = 'Y', DELETE_TIME=TO_CHAR(SYSDATE, 'YYYY-MM-DD HH24:MI:SS') WHERE BID = ?")
public class ReplyBoard extends BaseTimeEntity {
// 속성 추가
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "SQ_REPLY_BOARD_GENERATOR")
private Integer bid; // 기본키, 시퀀스
private String boardTitle; // 제목
private String boardContent; // 내용
private String boardWriter; // 작성자
private Integer viewCnt; // 조회수
private Integer boardGroup; // 트리구조 최상위 부모 노드( 부모가 있을 경우 : 부모번호, 없을 경우 : 자신의 게시판번호 ) (정렬)
private Integer boardParent; // 자신의 부모 노드 ( 부모가 있을 경우 : 부모번호, 없을 경우 : 0 ) (핵심)
}
2) ReplyBoard Repository 생성 및 ReplyBoard DTO 생성
📝 ReplyBoardRepository.java
package com.example.simpledms.repository.normal;
import com.example.simpledms.model.dto.normal.ReplyBoardDto;
import com.example.simpledms.model.entity.normal.ReplyBoard;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
/**
* packageName : com.example.simpledms.repository.normal
* fileName : ReplyBoardRepository
* author : GGG
* date : 2023-10-26
* description :
* 요약 :
* <p>
* ===========================================================
* DATE AUTHOR NOTE
* —————————————————————————————
* 2023-10-26 GGG 최초 생성
*/
@Repository
public interface ReplyBoardRepository extends JpaRepository<ReplyBoard, Integer> {
// 계층형 조회(특수) 쿼리 : @Query(, nativeQuery=true)
@Query(value = "SELECT BID AS bid " +
" , LPAD(' ', (LEVEL-1))|| board_title AS BoardTitle " +
" , board_content AS boardContent " +
" , board_writer AS boardWriter " +
" , view_cnt AS viewCnt " +
" , board_group AS boardGroup " +
" , board_parent AS boardParent " +
"FROM TB_REPLY_BOARD " +
"WHERE BOARD_TITLE LIKE '%'|| :boardTitle ||'%' " +
"AND DELETE_YN = 'N' " +
"START WITH BOARD_PARENT = 0 " +
"CONNECT BY PRIOR BID = BOARD_PARENT " +
"ORDER SIBLINGS BY BOARD_GROUP DESC", nativeQuery = true)
Page<ReplyBoardDto> selectByConnectByPage(
@Param("boardTitle") String boardTitle,
Pageable pageable);
}
답변형 게시판의 경우 레포지토리 생성시 계층형 쿼리를 이용하여 코딩합니다.
-- 계층형 쿼리 : level 의사(가상)컬럼(예 : 부모 1 ~ 2,3,4 ...)
-- START WITH BOARD_PARENT(부모컬럼) = 0 (최초시작값: 부모)
-- CONNECT BY PRIOR BID(게시판 번호) = BOARD_PARENT(부모 번호)
SELECT BID AS bid
, LPAD('└', (LEVEL-1))|| board_title AS BoardTitle
, board_content AS BoardContent
, board_writer AS BoardWriter
, view_cnt AS viewCnt
, board_group AS boardGroup
, board_parent AS boardParent
FROM TB_REPLY_BOARD
WHERE BOARD_TITLE LIKE '%%'
AND DELETE_YN = 'N'
START WITH BOARD_PARENT = 0
CONNECT BY PRIOR BID = BOARD_PARENT
ORDER SIBLINGS BY BOARD_GROUP DESC;
SQL Developer 에서 계층형 쿼리 실행 결과
📝 ReplyBoardDto.java
package com.example.simpledms.model.dto.normal;
/**
* packageName : com.example.simpledms.model.dto.normal
* fileName : ReplyBoardDto
* author : GGG
* date : 2023-10-26
* description : 계층형 쿼리 DTO
* 요약 :
* <p>
* ===========================================================
* DATE AUTHOR NOTE
* —————————————————————————————
* 2023-10-26 GGG 최초 생성
*/
public interface ReplyBoardDto {
// 속성 x => getter 함수
public Integer getBid();
public String getBoardTitle();
public String getBoardContent();
public String getBoardWriter();
public Integer getViewCnt();
public Integer getBoardGroup();
public Integer getBoardParent();
}
3) ReplyBoardService 생성
package com.example.simpledms.service.normal;
import com.example.simpledms.model.dto.normal.ReplyBoardDto;
import com.example.simpledms.repository.normal.ReplyBoardRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
/**
* packageName : com.example.simpledms.service.normal
* fileName : ReplyBoardService
* author : GGG
* date : 2023-10-26
* description :
* 요약 :
* <p>
* ===========================================================
* DATE AUTHOR NOTE
* —————————————————————————————
* 2023-10-26 GGG 최초 생성
*/
@Service
public class ReplyBoardService {
@Autowired
ReplyBoardRepository replyBoardRepository;
// 계층형 쿼리 조회(DTO) : like 검색
public Page<ReplyBoardDto> selectByConnectByPage(String boardTitle, Pageable pageable){
Page<ReplyBoardDto> page
= replyBoardRepository.selectByConnectByPage(boardTitle, pageable);
return page;
}
}
4) ReplyBoardController 생성
package com.example.simpledms.controller.normal;
import com.example.simpledms.model.dto.normal.ReplyBoardDto;
import com.example.simpledms.model.entity.basic.Dept;
import com.example.simpledms.service.normal.ReplyBoardService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
/**
* packageName : com.example.simpledms.controller.normal
* fileName : ReplyBoardController
* author : GGG
* date : 2023-10-26
* description :
* 요약 :
* <p>
* ===========================================================
* DATE AUTHOR NOTE
* —————————————————————————————
* 2023-10-26 GGG 최초 생성
*/
@RestController
@Slf4j
@RequestMapping("/api/normal")
public class ReplyBoardController {
@Autowired
ReplyBoardService replyBoardService;
// 전체 조회(계층형, DTO) : LIKE 검색
@GetMapping("/reply-board")
public ResponseEntity<Object> selectByConnectByPage(
@RequestParam(defaultValue = "") String boardTitle,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "3") int size
) {
try {
Pageable pageable = PageRequest.of(page, size);
Page<ReplyBoardDto> replyBoardDtoPage
= replyBoardService
.selectByConnectByPage(boardTitle, pageable);
Map<String, Object> response = new HashMap<>();
response.put("replyBoard", replyBoardDtoPage.getContent());
response.put("currentPage", replyBoardDtoPage.getNumber());
response.put("totalItems", replyBoardDtoPage.getTotalElements());
response.put("totalPages", replyBoardDtoPage.getTotalPages());
// 신호 보내기
if (replyBoardDtoPage.isEmpty() == false) {
// 성공
return new ResponseEntity<>(response, HttpStatus.OK);
} else {
// 데이터 없음
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}
} catch (Exception e) {
log.debug(e.getMessage());
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
}
}
}
5) API 테스트
GET http://localhost:8000/api/normal/reply-board
HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Content-Type: application/json
Transfer-Encoding: chunked
Date: Thu, 26 Oct 2023 07:13:59 GMT
Keep-Alive: timeout=60
Connection: keep-alive
{
"totalItems": 8,
"replyBoard": [
{
"boardWriter": "작성자",
"boardParent": 0,
"bid": 8,
"boardTitle": "제목",
"boardContent": "내용",
"viewCnt": 0,
"boardGroup": 8
},
{
"boardWriter": "작성자",
"boardParent": 0,
"bid": 7,
"boardTitle": "제목",
"boardContent": "내용",
"viewCnt": 0,
"boardGroup": 7
},
{
"boardWriter": "작성자",
"boardParent": 0,
"bid": 5,
"boardTitle": "제목",
"boardContent": "내용",
"viewCnt": 0,
"boardGroup": 5
}
],
"totalPages": 3,
"currentPage": 0
}
응답 파일이 저장되었습니다.
> 2023-10-26T161359.200.json
Response code: 200; Time: 241ms (241 ms); Content length: 395 bytes (395 B)
6) 프론트 + 백엔드 연동테스트
'Spring Boot > 스프링부트 예제' 카테고리의 다른 글
답변형 게시판 구현 (3) (0) | 2023.10.27 |
---|---|
답변형 게시판 구현 (2) (1) | 2023.10.27 |
QnA 다양한 검색 게시판 CRUD (1) (1) | 2023.10.24 |
게시판 페이징 처리 (1) | 2023.10.23 |
front + backend 게시판 CRUD 구현 (2) (0) | 2023.10.20 |