네이버 카페 등의 게시판을 주로 보면 질문에 대한 답글이 게시되어 있는 기능을 종종 볼 수 있습니다. 이번에는 답글을 달 수 있는 게시판을 제작해 보겠습니다. 단, 간단한 예제를 위해 답글은 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 검색(페이징 기능)
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 ( ) {
const [replyBoard, setReplyBoard] = useState<Array <IReplyBoard>>([]);
const [searchBoardTitle, setSearchBoardTitle] = useState<string >("" );
const [page, setPage] = useState<number >(1 );
const [count, setCount] = useState<number >(1 );
const [pageSize, setPageSize] = useState<number >(3 );
const pageSizes = [3 , 6 , 9 ];
useEffect(() => {
retrieveReplyBoard();
}, [page, pageSize]);
const retrieveReplyBoard = () => {
ReplyBoardService.getAll(searchBoardTitle, page - 1 , pageSize)
.then((response: any ) => {
const { replyBoard, totalPages } = response.data;
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);
};
const handlePageSizeChange = (e: any ) => {
setPageSize(e.target.value);
setPage(1 );
};
const handlePageChange = (e: any , value: number ) => {
setPage(value);
};
const initialReply = {
bid : null ,
boardTitle : "" ,
boardContent : "" ,
boardWriter : "" ,
viewCnt : 0 ,
boardGroup : null ,
boardParent : 0 ,
};
const [reply, setReply] = useState(initialReply);
const [replyClicked, setReplyClicked] = useState(false );
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement> ) => {
const { name, value } = event.target;
setReply({ ...reply, [name]: value });
};
const saveReply = () => {
let data = {
boardTitle : reply.boardTitle,
boardContent : reply.boardContent,
boardWriter : reply.boardWriter,
viewCnt : 0 ,
boardGroup : reply.bid,
boardParent : reply.bid,
};
ReplyBoardService.create(data)
.then((response: any ) => {
alert("답변글이 생성되었습니다." );
retrieveReplyBoard();
console .log(response.data);
})
.catch((e: Error ) => {
console .log(e);
});
};
const newReply = (data: any ) => {
setReply({ ...data, boardContent : "" });
setReplyClicked(true );
};
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.*;
@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
@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;
}
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;
@Repository
public interface ReplyBoardRepository extends JpaRepository <ReplyBoard , Integer > {
@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) ;
}
답변형 게시판의 경우 레포지토리 생성시 계층형 쿼리를 이용하여 코딩합니다.
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;
public interface ReplyBoardDto {
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;
@Service
public class ReplyBoardService {
@Autowired
ReplyBoardRepository replyBoardRepository;
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;
@RestController
@Slf4j
@RequestMapping("/api/normal" )
public class ReplyBoardController {
@Autowired
ReplyBoardService replyBoardService;
@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:
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) 프론트 + 백엔드 연동테스트