[팀프로젝트]모바일앱 게시판 만들기 - 3편 : 게시판 CRUD 개발하기
- -
이번 글부터 게시판 개발 내용을 진행합니다.
현재까지 추가된 API는 다음과 같습니다.
- [POST] /oauth/kakao : 로그인
- [POST] /user/join : 회원가입
- [GET] /user/profile/{userId} : 사용자 정보 조회
포스팅 내용이 아래 순서대로 진행되고 있으니 이전 글을 확인해 주세요.
- 테이블 설계하기
- 로그인/회원가입 개발하기
- 게시판 CRUD 개발하기
1. 테이블 생성
게시판 기능에 연관된 테이블을 생성하겠습니다. 실제 앱에는 아래 테이블외에 추가된 내용이 더 있습니다.
여기서는 게시판, 게시판 댓글, 게시판 좋아요와 관련된 내용만 소개해보겠습니다.
모든 테이블은 회원 번호(USER_NO)를 통해 회원(USERS) 테이블과 연결됩니다.
댓글 테이블은 대댓글 기능 구현을 위해 부모-자식 관계(COMMENT_SEQ - PARENT_SEQ)로 이루어져있습니다. PARENT_SEQ가 0이면 루트르 고정되며, 0이 아닌 다른 값이 온다면 자식 댓글로 인식합니다.
단순화를 위해 대댓글의 깊이를 1까지만 허용했습니다.
CREATE TABLE `BOARD` (
`BOARD_SEQ` bigint NOT NULL AUTO_INCREMENT COMMENT 'BOARD_SEQ',
`BOARD_TITLE` varchar(30) NOT NULL COMMENT '제목',
`BOARD_CONTENT` text COMMENT '내용',
`MARKET_CODE` varchar(10) DEFAULT NULL COMMENT '도매시장코드',
`COMPANY_CODE` varchar(10) DEFAULT NULL COMMENT '법인코드',
`USER_NO` bigint DEFAULT NULL COMMENT '회원번호',
`CREATE_DATE` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '게시일',
PRIMARY KEY (`BOARD_SEQ`)
) COMMENT='게시판';
CREATE TABLE `BOARD_COMMENTS` (
`COMMENT_SEQ` bigint NOT NULL AUTO_INCREMENT COMMENT 'COMMENT_SEQ',
`BOARD_SEQ` bigint NOT NULL COMMENT 'BOARD_SEQ',
`PARENT_SEQ` bigint DEFAULT NULL COMMENT '부모COMMENT_SEQ',
`USER_NO` bigint NOT NULL COMMENT '회원번호',
`COMMENT_CONTENT` text NOT NULL COMMENT '내용',
`CREATE_DATE` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '게시일',
PRIMARY KEY (`COMMENT_SEQ`),
KEY `board_comments_USER_ID_IDX` (`USER_NO`) USING BTREE,
KEY `BOARD_COMMENTS_BOARD_SEQ_IDX` (`BOARD_SEQ`) USING BTREE
) COMMENT='게시판 댓글';
CREATE TABLE `BOARD_LIKES` (
`LIKE_SEQ` bigint NOT NULL AUTO_INCREMENT COMMENT 'LIKE_SEQ',
`BOARD_SEQ` bigint NOT NULL COMMENT 'BOARD_SEQ',
`USER_NO` bigint NOT NULL COMMENT '아이디',
`CREATE_DATE` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '게시일',
PRIMARY KEY (`LIKE_SEQ`),
KEY `board_likes_BOARD_SEQ_IDX` (`BOARD_SEQ`,`USER_NO`) USING BTREE
) COMMENT='게시판 좋아요';
2. 게시판 API 만들기
2-1. Entity, Dto, Mapper 추가
테이블을 토대로 Entity, Dto 클래스를 생성해보겠습니다. 이전 글에서 언급했듯 Dto와 Entity 매핑을 위해서 MapStruct를 사용합니다.
테이블의 PK는 자동 생성 전략을 사용했습니다.
[Entity]
@Getter
@Entity
@Builder(toBuilder = true)
@Table(name = "BOARD")
@AllArgsConstructor
@NoArgsConstructor
public class Board {
@Id
@Column(name = "BOARD_SEQ")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long boardSeq;
@Column(name = "BOARD_TITLE")
private String boardTitle;
@Column(name = "BOARD_CONTENT")
private String boardContent;
@Column(name = "MARKET_CODE")
private String marketCode;
@Column(name = "COMPANY_CODE")
private String companyCode;
@Column(name = "USER_NO")
private Long userNo;
@Column(name = "CREATE_DATE")
@UpdateTimestamp
private LocalDateTime createDate;
}
@Getter
@Entity
@Builder(toBuilder = true)
@Table(name = "BOARD_COMMENTS")
@AllArgsConstructor
@NoArgsConstructor
public class BoardComments {
@Id
@Column(name = "COMMENT_SEQ")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long commentSeq;
@Column(name = "BOARD_SEQ")
private Long boardSeq;
@Column(name = "PARENT_SEQ")
private Long parentSeq;
@Column(name = "USER_NO")
private Long userNo;
@Column(name = "COMMENT_CONTENT")
private String commentContent;
@Column(name = "CREATE_DATE")
@UpdateTimestamp
private LocalDateTime createDate;
}
@Getter
@Entity
@Builder(toBuilder = true)
@Table(name = "BOARD_LIKES")
@AllArgsConstructor
@NoArgsConstructor
public class BoardLikes {
@Id
@Column(name = "LIKE_SEQ")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long likeSeq;
@Column(name = "BOARD_SEQ")
private Long boardSeq;
@Column(name = "USER_NO")
private Long userNo;
@Column(name = "CREATE_DATE")
@UpdateTimestamp
private LocalDateTime createDate;
}
[Dto]
@Data
public class BoardDto {
private Long boardSeq;
private String boardTitle;
private String boardContent;
private String marketCode;
private String companyCode;
private Long userNo;
}
@Data
@Builder
public class CommentsDto {
private Long commentSeq;
private Long boardSeq;
private Long parentSeq;
private Long userNo;
private String userId;
private String nickName;
private String marketName;
private String companyName;
private String profileImageUrl;
private String commentContent;
private String createDate;
private List<CommentsDto> subCommentsList;
}
@Data
public class LikesDto {
private Long boardSeq;
private Long userNo;
}
[Mapper]
@Mapper(componentModel = "spring")
public interface BoardMapper {
BoardDto toBoardDto(Board entity);
Board toBoardEntity(BoardDto dto);
LikesDto toLikesDto(BoardLikes entity);
BoardLikes toLikesEntity(LikesDto dto);
CommentsDto toCommentsDto(BoardComments entity);
BoardComments toCommentsEntity(CommentsDto dto);
}
2-2. Repository 추가
테이블 조인이 많이 사용되는 경우 편의를 위해 네이티브 쿼리를 이용했습니다.
BoardRepository : 게시판
- countByBoardTitleLike : 제목 검색시 게시글 개수
- countByBoardContentLike : 내용 검색시 게시글 개수
- getBoardList : 게시글 리스트 조회
- getBoardInfo : 게시글 내용 조회
@Repository
public interface BoardRepository extends JpaRepository<Board, Long> {
int countByBoardTitleLike(String boardTitle);
int countByBoardContentLike(String boardContent);
@Query(value = ""
+ "SELECT MAX(b1.BOARD_SEQ) AS boardSeq \n"
+ "\t , MAX(b1.BOARD_TITLE) AS boardTitle \n"
+ "\t , LEFT(MAX(b1.BOARD_CONTENT), 50) AS boardContent \n"
+ "\t , MAX(b1.MARKET_CODE) AS marketCode \n"
+ "\t , MAX(b1.COMPANY_CODE) AS companyCode \n"
+ "\t , MAX(u1.USER_ID) AS userId \n"
+ "\t , MAX(u1.NICK_NAME) AS nickName \n"
+ "\t , MAX(m1.MARKET_NAME) AS marketName \n"
+ "\t , MAX(c1.COMPANY_NAME) AS companyName \n"
+ "\t , MAX(u1.PROFILE_IMAGE_URL) AS profileImageUrl \n"
+ "\t , IFNULL(b2.LIKE_COUNT, 0) AS likeCount \n"
+ "\t , IFNULL(b3.COMMENT_COUNT, 0) AS commentCount\n"
+ "\t , 0 AS isLiked \n"
+ "\t , DATE_FORMAT(MAX(b1.CREATE_DATE), '%Y%m%d%H%i') AS createDate \n"
+ " FROM BOARD b1 \n"
+ " \t INNER JOIN USERS u1 \n"
+ " \t ON b1.USER_NO = u1.USER_NO \n"
+ " \t LEFT OUTER JOIN MARKETS m1 \n"
+ " \t ON u1.MARKET_CODE = m1.MARKET_CODE \n"
+ " \t LEFT OUTER JOIN COMPANIES c1 \n"
+ " \t ON u1.COMPANY_CODE = c1.COMPANY_CODE\t \n"
+ " \t LEFT OUTER JOIN \n"
+ " \t (\n"
+ " \t SELECT bl.BOARD_SEQ, COUNT(bl.LIKE_SEQ) AS LIKE_COUNT \n"
+ " \t FROM BOARD_LIKES bl \n"
+ " \t INNER JOIN USERS u \n"
+ " \t ON bl.USER_NO = u.USER_NO \n"
+ " \t GROUP BY bl.BOARD_SEQ \n"
+ " \t ) b2\n"
+ " \t ON b1.BOARD_SEQ = b2.BOARD_SEQ \n"
+ " \t LEFT OUTER JOIN \n"
+ " \t (\n"
+ " \t SELECT bc.BOARD_SEQ, COUNT(bc.COMMENT_SEQ) AS COMMENT_COUNT \n"
+ " \t FROM BOARD_COMMENTS bc \n"
+ " \t INNER JOIN USERS u \n"
+ " \t ON bc.USER_NO = u.USER_NO \n"
+ " \t GROUP BY bc.BOARD_SEQ \n"
+ " \t ) b3\n"
+ " \t ON b1.BOARD_SEQ = b3.BOARD_SEQ"
+ " WHERE CASE WHEN :search = 'title' THEN b1.BOARD_TITLE LIKE %:text% \n"
+ " \t WHEN :search = 'content' THEN b1.BOARD_CONTENT LIKE %:text% \n"
+ " \t ELSE :search = ''\n"
+ " END "
+ " GROUP BY b1.BOARD_SEQ\n"
+ " ORDER BY b1.BOARD_SEQ DESC\n"
+ " LIMIT 10 OFFSET :offset", nativeQuery = true)
List<BoardInfo> getBoardList(
@Param("offset") int offset,
@Param("search") String search,
@Param("text") String text);
@Query(value = ""
+ "SELECT MAX(b1.BOARD_SEQ) AS boardSeq \n"
+ "\t , MAX(b1.BOARD_TITLE) AS boardTitle \n"
+ "\t , MAX(b1.BOARD_CONTENT) AS boardContent \n"
+ "\t , MAX(b1.MARKET_CODE) AS marketCode \n"
+ "\t , MAX(b1.COMPANY_CODE) AS companyCode \n"
+ "\t , MAX(u1.USER_ID) AS userId \n"
+ "\t , MAX(u1.NICK_NAME) AS nickName \n"
+ "\t , MAX(u1.PROFILE_IMAGE_URL) AS profileImageUrl \n"
+ "\t , MAX(m1.MARKET_NAME) AS marketName \n"
+ "\t , MAX(c1.COMPANY_NAME) AS companyName \n"
+ "\t , IFNULL(b2.LIKE_COUNT, 0) AS likeCount \n"
+ "\t , IFNULL(b3.COMMENT_COUNT, 0) AS commentCount \n"
+ "\t , COUNT(b4.LIKE_SEQ) AS isLiked \n"
+ "\t , DATE_FORMAT(MAX(b1.CREATE_DATE), '%Y%m%d%H%i') AS createDate \t\t\n"
+ " FROM BOARD b1\n"
+ " \t INNER JOIN USERS u1 \n"
+ " \t ON b1.USER_NO = u1.USER_NO \n"
+ " \t LEFT OUTER JOIN MARKETS m1 \n"
+ " \t \t ON u1.MARKET_CODE = m1.MARKET_CODE \n"
+ " \t LEFT OUTER JOIN COMPANIES c1 \n"
+ " \t \t ON u1.COMPANY_CODE = c1.COMPANY_CODE\t \n"
+ " \t LEFT OUTER JOIN \n"
+ " \t (\n"
+ " \t SELECT bl.BOARD_SEQ, COUNT(bl.LIKE_SEQ) AS LIKE_COUNT \n"
+ " \t FROM BOARD_LIKES bl \n"
+ " \t INNER JOIN USERS u \n"
+ " \t ON bl.USER_NO = u.USER_NO \n"
+ " \t GROUP BY bl.BOARD_SEQ \n"
+ " \t ) b2\n"
+ " \t ON b1.BOARD_SEQ = b2.BOARD_SEQ \n"
+ " \t LEFT OUTER JOIN \n"
+ " \t (\n"
+ " \t SELECT bc.BOARD_SEQ, COUNT(bc.COMMENT_SEQ) AS COMMENT_COUNT \n"
+ " \t FROM BOARD_COMMENTS bc \n"
+ " \t INNER JOIN USERS u \n"
+ " \t ON bc.USER_NO = u.USER_NO \n"
+ " \t GROUP BY bc.BOARD_SEQ \n"
+ " \t ) b3\n"
+ " \t ON b1.BOARD_SEQ = b3.BOARD_SEQ"
+ " \t LEFT OUTER JOIN BOARD_LIKES b4 \n"
+ " \t ON b1.BOARD_SEQ = b4.BOARD_SEQ \n"
+ " \t AND b4.USER_NO = :userNo \n"
+ " WHERE b1.BOARD_SEQ = :seq \n"
+ " GROUP BY b1.BOARD_SEQ ", nativeQuery = true)
BoardInfo getBoardInfo(@Param("seq") Long seq, @Param("userNo") Long userNo);
}
BoardCommentsRepository : 댓글
- getCommentsList : 댓글 리스트 조회(댓글에 페이징은 적용되지 않았습니다)
- deleteByBoardSeq : 게시글 삭제시 댓글 삭제
- deleteByParentSeq : 댓글 삭제시 자식 댓글 삭제
@Repository
public interface BoardCommentsRepository extends JpaRepository<BoardComments, Long> {
@Query(value = ""
+ "SELECT b1.COMMENT_SEQ AS commentSeq \n"
+ " \t , b1.BOARD_SEQ AS boardSeq \n"
+ " \t , b1.PARENT_SEQ AS parentSeq \n"
+ " \t , u1.USER_ID AS userId \n"
+ " \t , u1.NICK_NAME AS nickName \n"
+ "\t , m1.MARKET_NAME AS marketName \n"
+ "\t , c1.COMPANY_NAME AS companyName \n"
+ " \t , u1.PROFILE_IMAGE_URL AS profileImageUrl \n"
+ " \t , b1.COMMENT_CONTENT AS commentContent \n"
+ " \t , DATE_FORMAT(b1.CREATE_DATE, '%Y%m%d%H%i') AS createDate \n"
+ " FROM BOARD_COMMENTS b1\n"
+ " \t INNER JOIN USERS u1\n"
+ " \t ON b1.USER_NO = u1.USER_NO \n"
+ " \t LEFT OUTER JOIN MARKETS m1 \n"
+ " \t ON u1.MARKET_CODE = m1.MARKET_CODE \n"
+ " \t LEFT OUTER JOIN COMPANIES c1 \n"
+ " \t ON u1.COMPANY_CODE = c1.COMPANY_CODE\t \n"
+ " \t LEFT OUTER JOIN BOARD_COMMENTS b2 \n"
+ " \t ON b1.PARENT_SEQ = b2.BOARD_SEQ \n"
+ " WHERE b1.BOARD_SEQ = :boardSeq \n"
+ " ORDER BY b1.COMMENT_SEQ DESC", nativeQuery = true)
List<CommentsInfo> getCommentsList(@Param("boardSeq") Long boardSeq);
void deleteByBoardSeq(Long seq);
void deleteByParentSeq(Long seq);
}
BoardCommentsRepository : 좋아요
- findByBoardSeqAndUserNo : 게시글에 사용자가 좋아요를 한 이력이 있는지 체크
- deleteByBoardSeq : 게시글 삭제시 연관 좋아요 데이터 삭제
@Repository
public interface BoardLikesRepository extends JpaRepository<BoardLikes, Long> {
BoardLikes findByBoardSeqAndUserNo(Long boardSeq, Long userNo);
void deleteByBoardSeq(Long seq);
}
2-3. Controller 추가
@RestController
@RequestMapping("board")
@RequiredArgsConstructor
public class BoardController {
private final BoardService boardService;
/**
* 게시판 리스트 조회
*
* @param page
* @param search
* @param text
* @return
*/
@GetMapping(value = "/list")
public ResponseEntity<MessageDto> getBoardList(
@RequestParam(value = "page", required = false, defaultValue = "1") int page,
@RequestParam(value = "search", required = false, defaultValue = "") String search,
@RequestParam(value = "text", required = false, defaultValue = "") String text
) {
Map<String, Object> resultMap = boardService.getBoardList(page, search, text);
return ResponseEntity.ok().body(new MessageDto("success", resultMap));
}
/**
* 게시글 내용 조회
*
* @param seq
* @return
*/
@GetMapping(value = "/info")
public ResponseEntity<MessageDto> getBoardInfo(
@RequestParam(value = "seq") Long seq
) {
BoardInfo boardInfo = boardService.getBoardInfo(seq);
return ResponseEntity.ok().body(new MessageDto("success", boardInfo));
}
/**
* 게시글 저장
*
* @param boardDto
* @return
*/
@PostMapping(value = "/save")
public ResponseEntity<MessageDto> saveBoard(
@RequestBody BoardDto boardDto
) {
Board board = boardService.saveBoard(boardDto);
return ResponseEntity.ok().body(new MessageDto("success", ResponseDto.builder()
.id(board.getBoardSeq())
.createdDate(board.getCreateDate()).build()
));
}
/**
* 게시글 수정
*
* @param seq
* @param boardDto
* @return
* @throws Exception
*/
@PutMapping(value = "/save/{seq}")
public ResponseEntity<MessageDto> updateBoard(
@PathVariable("seq") Long seq,
@RequestBody BoardDto boardDto
) throws Exception {
Board board = boardService.updateBoard(seq, boardDto);
return ResponseEntity.ok().body(new MessageDto("success", ResponseDto.builder()
.id(board.getBoardSeq())
.createdDate(board.getCreateDate()).build()
));
}
/**
* 게시글 삭제
*
* @param seq
* @return
* @throws Exception
*/
@DeleteMapping(value = "/item/{seq}")
public ResponseEntity<MessageDto> deleteBoard(
@PathVariable("seq") Long seq
) throws Exception {
boardService.deleteBoard(seq);
return ResponseEntity.ok().body(new MessageDto("success", seq));
}
/**
* 좋아요 등록
*
* @param likesDto
* @return
*/
@PostMapping(value = "/like")
public ResponseEntity<MessageDto> saveLike(
@RequestBody LikesDto likesDto
) {
boardService.saveLikes(likesDto);
return ResponseEntity.ok().body(new MessageDto("success", likesDto));
}
/**
* 게시판 댓글 리스트 조회
*
* @param seq
* @return
*/
@GetMapping(value = "/comment/list")
public ResponseEntity<MessageDto> getCommentsList(
@RequestParam(value = "seq") Long seq
) {
List<CommentsDto> commentsList = boardService.getCommentsList(seq);
return ResponseEntity.ok().body(new MessageDto("success", commentsList));
}
/**
* 댓글 저장
*
* @param commentDto
* @return
*/
@PostMapping(value = "/comment")
public ResponseEntity<MessageDto> saveComment(
@RequestBody CommentsDto commentDto
) {
BoardComments comments = boardService.saveComment(commentDto);
return ResponseEntity.ok()
.body(new MessageDto("success", ResponseDto.builder()
.id(comments.getCommentSeq())
.createdDate(comments.getCreateDate()).build()
));
}
/**
* 댓글 수정
*
* @param seq
* @param commentDto
* @return
* @throws Exception
*/
@PutMapping(value = "/comment/{seq}")
public ResponseEntity<MessageDto> saveComment(
@PathVariable("seq") Long seq,
@RequestBody CommentsDto commentDto
) throws Exception {
BoardComments comments = boardService.updateComment(seq, commentDto);
return ResponseEntity.ok()
.body(new MessageDto("success", ResponseDto.builder()
.id(comments.getCommentSeq())
.createdDate(comments.getCreateDate()).build()
));
}
/**
* 댓글 삭제
*
* @param seq
* @return
* @throws Exception
*/
@DeleteMapping(value = "/comment/{seq}")
public ResponseEntity<MessageDto> deleteComment(
@PathVariable("seq") Long seq
) throws Exception {
boardService.deleteComment(seq);
return ResponseEntity.ok().body(new MessageDto("success", seq));
}
}
2-4. Service 추가
@Service
@RequiredArgsConstructor
public class BoardService {
private final BoardRepository boardRepository;
private final BoardLikesRepository boardLikesRepository;
private final BoardCommentsRepository boardCommentsRepository;
private final BoardMapper boardMapper;
public Map<String, Object> getBoardList(
int page,
String search,
String text
) {
Map<String, Object> resultMap = new HashMap<>();
long totalCount = 0;
int offset = (page - 1) * 10;
List<BoardInfo> boardList = boardRepository.getBoardList(offset, search, text);
switch (search) {
case "title":
totalCount = boardRepository.countByBoardTitleLike("%" + text + "%");
break;
case "content":
totalCount = boardRepository.countByBoardContentLike("%" + text + "%");
break;
default:
totalCount = boardRepository.count();
break;
}
resultMap.put("boardList", boardList);
resultMap.put("boardCount", totalCount);
return resultMap;
}
public BoardInfo getBoardInfo(Long seq) {
Long userNo = SecurityUtil.getUserNo();
return boardRepository.getBoardInfo(seq, userNo);
}
public Board saveBoard(BoardDto boardDto) {
boardDto.setUserNo(SecurityUtil.getUserNo());
Board board = boardMapper.toBoardEntity(boardDto);
boardRepository.save(board);
return board;
}
public Board updateBoard(
Long seq,
BoardDto boardDto
) throws Exception {
Board existBoard = boardRepository.findById(seq)
.orElseThrow(() -> new Exception("게시글이 존재하지 않습니다."));
Long userNo = SecurityUtil.getUserNo();
if (!Objects.equals(userNo, existBoard.getUserNo())) {
throw new Exception("수정 권한이 없습니다.");
}
Board updateBoard = existBoard.toBuilder()
.boardTitle(boardDto.getBoardTitle())
.boardContent(boardDto.getBoardContent())
.marketCode(boardDto.getMarketCode())
.companyCode(boardDto.getCompanyCode()).build();
boardRepository.save(updateBoard);
return updateBoard;
}
@Transactional
public void deleteBoard(Long seq) throws Exception {
Board existBoard = boardRepository.findById(seq)
.orElseThrow(() -> new Exception("게시글이 존재하지 않습니다."));
Long userNo = SecurityUtil.getUserNo();
if (!Objects.equals(userNo, existBoard.getUserNo())) {
throw new Exception("삭제 권한이 없습니다.");
}
// 게시글 삭제
boardRepository.delete(existBoard);
// 게시글 좋아요 삭제
boardLikesRepository.deleteByBoardSeq(seq);
// 게시글 댓글 삭제
boardCommentsRepository.deleteByBoardSeq(seq);
}
public void saveLikes(LikesDto likesDto) {
Long userNo = SecurityUtil.getUserNo();
BoardLikes existLike = boardLikesRepository.findByBoardSeqAndUserNo(likesDto.getBoardSeq(),
userNo);
if (existLike == null) {
likesDto.setUserNo(userNo);
BoardLikes likes = boardMapper.toLikesEntity(likesDto);
boardLikesRepository.save(likes);
} else {
boardLikesRepository.delete(existLike);
}
}
public List<CommentsDto> getCommentsList(Long boardSeq) {
List<CommentsInfo> commentList = boardCommentsRepository.getCommentsList(boardSeq);
List<CommentsDto> buildList = commentList.stream()
.map(comment -> CommentsDto.builder()
.commentSeq(comment.getCommentSeq())
.boardSeq(comment.getBoardSeq())
.parentSeq(comment.getParentSeq())
.userId(comment.getUserId())
.nickName(comment.getNickName())
.marketName(comment.getMarketName())
.companyName(comment.getCompanyName())
.profileImageUrl(comment.getProfileImageUrl())
.commentContent(comment.getCommentContent())
.createDate(comment.getCreateDate()).build()
)
.collect(Collectors.toList());
return buildCommentsList(buildList, 0);
}
private List<CommentsDto> buildCommentsList(
List<CommentsDto> templateList,
long parentSeq
) {
return templateList.stream()
.filter(node -> node.getParentSeq() == parentSeq)
.peek(node -> node.setSubCommentsList(
buildCommentsList(templateList, node.getCommentSeq())))
.collect(Collectors.toList());
}
@Transactional
public BoardComments saveComment(CommentsDto commentsDto) {
// 댓글 저장
commentsDto.setUserNo(SecurityUtil.getUserNo());
BoardComments comments = boardMapper.toCommentsEntity(commentsDto);
boardCommentsRepository.save(comments);
return comments;
}
public BoardComments updateComment(
long seq,
CommentsDto commentDto
) throws Exception {
BoardComments existComment = boardCommentsRepository.findById(seq)
.orElseThrow(() -> new Exception("답변이 존재하지 않습니다."));
BoardComments updateComment = existComment.toBuilder()
.commentContent(commentDto.getCommentContent()).build();
boardCommentsRepository.save(updateComment);
return updateComment;
}
@Transactional
public void deleteComment(Long seq) throws Exception {
BoardComments existComment = boardCommentsRepository.findById(seq)
.orElseThrow(() -> new Exception("답변이 존재하지 않습니다."));
Long userNo = SecurityUtil.getUserNo();
if (!Objects.equals(userNo, existComment.getUserNo())) {
throw new Exception("삭제 권한이 없습니다.");
}
// 댓글 삭제
boardCommentsRepository.delete(existComment);
// 자식 댓글 삭제
boardCommentsRepository.deleteByParentSeq(seq);
}
}
서비스의 중요한 메소드만 체크해보겠습니다.
- getBoardList(int page, String search, String text) : 게시글 조회시 리스트(10개씩)와 함께 게시글 총 개수를 리턴합니다. 검색 사용시에 search/text를 통해 제목/내용 검색을 처리합니다.
- saveLikes(LikesDto likesDto) : 해당 사용자가 게시글에 좋아요를 저장한 이력이 있는지 체크합니다. 이력이 없다면 저장을, 있다면 삭제합니다.
- getCommentsList(Long boardSeq) : 게시글에 댓글 리스트를 조회합니다. buildCommentsList를 통해 대댓글이 있는 경우 subCommentsList에 값이 담길 수 있도록 처리했습니다.
테스트를 통해 추가된 API들을 확인해보겠습니다.
3. 테스트
[GET] /board/list?page={page} : 게시판 리스트 조회
curl --location 'http://localhost:9090/board/list?page=1' \
--header 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0MTIzIiwiaWF0IjoxNzE4NzAwOTg0LCJleHAiOjE3MTg3MDI3ODR9.GKfz1MGlQE0fVi2Km58pchw3-fSOdcH-u25b2Ec8RILvLvvylaZHSVqnDT30770DgMexQAcu5wHbAx0JKmrL-Q' \
--header 'Cookie: refresh-token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIiLCJpYXQiOjE3MTg3NzM4MDEsImV4cCI6MTcyMTM2NTgwMX0.5cxXO4y2oU2XdHd4dI76kbdI3wY7Att4OpH2iDVq7PZr4f8y1Qoxo2C6_OqCSxk-o1JStVktMo-ROvh_DCxZbw'
[POST] /board/save : 게시글 저장
curl --location 'http://localhost:9090/board/save' \
--header 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiI0IiwiaWF0IjoxNzE5NTUxNTIzLCJleHAiOjE3MTk1NTMzMjN9.bpUvKP8jrjMs5gzn9IaM86FG1qgr8ZX6HN5F9WuSwN8IDwd3N_KAuNR0UKYSTSFV7sHL-xebO0--qCrMImmZ8A' \
--header 'Content-Type: application/json' \
--header 'Cookie: refresh-token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIiLCJpYXQiOjE3MTg3NzM4MDEsImV4cCI6MTcyMTM2NTgwMX0.5cxXO4y2oU2XdHd4dI76kbdI3wY7Att4OpH2iDVq7PZr4f8y1Qoxo2C6_OqCSxk-o1JStVktMo-ROvh_DCxZbw' \
--data '{
"boardTitle": "제목 테스트",
"boardContent": "안녕하세요",
"marketCode": "",
"companyCode": "",
"userNo": 14
}'
[PUT] /board/save/{seq} : 게시글 수정
curl --location --request PUT 'http://localhost:9090/board/save/44' \
--header 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiI0IiwiaWF0IjoxNzE5NTUxNTIzLCJleHAiOjE3MTk1NTMzMjN9.bpUvKP8jrjMs5gzn9IaM86FG1qgr8ZX6HN5F9WuSwN8IDwd3N_KAuNR0UKYSTSFV7sHL-xebO0--qCrMImmZ8A' \
--header 'Content-Type: application/json' \
--header 'Cookie: refresh-token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIiLCJpYXQiOjE3MTg3NzM4MDEsImV4cCI6MTcyMTM2NTgwMX0.5cxXO4y2oU2XdHd4dI76kbdI3wY7Att4OpH2iDVq7PZr4f8y1Qoxo2C6_OqCSxk-o1JStVktMo-ROvh_DCxZbw' \
--data '{
"boardTitle": "제목 테스트 수정해봐요",
"boardContent": "안녕히가세요",
"marketCode": "",
"companyCode": "",
"userNo": 14
}'
[GET] /board/info?seq={seq} : 게시글 내용 조회
curl --location 'http://localhost:9090/board/info?seq=44' \
--header 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiI0IiwiaWF0IjoxNzE5NTUxNTIzLCJleHAiOjE3MTk1NTMzMjN9.bpUvKP8jrjMs5gzn9IaM86FG1qgr8ZX6HN5F9WuSwN8IDwd3N_KAuNR0UKYSTSFV7sHL-xebO0--qCrMImmZ8A' \
--header 'Cookie: refresh-token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIiLCJpYXQiOjE3MTg3NzM4MDEsImV4cCI6MTcyMTM2NTgwMX0.5cxXO4y2oU2XdHd4dI76kbdI3wY7Att4OpH2iDVq7PZr4f8y1Qoxo2C6_OqCSxk-o1JStVktMo-ROvh_DCxZbw'
[DELETE] /board/item/{seq} : 게시글 삭제
curl --location --request DELETE 'http://localhost:9090/board/item/44' \
--header 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiI0IiwiaWF0IjoxNzE5NTUxNTIzLCJleHAiOjE3MTk1NTMzMjN9.bpUvKP8jrjMs5gzn9IaM86FG1qgr8ZX6HN5F9WuSwN8IDwd3N_KAuNR0UKYSTSFV7sHL-xebO0--qCrMImmZ8A' \
--header 'Content-Type: application/json' \
--header 'Cookie: refresh-token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIiLCJpYXQiOjE3MTg3NzM4MDEsImV4cCI6MTcyMTM2NTgwMX0.5cxXO4y2oU2XdHd4dI76kbdI3wY7Att4OpH2iDVq7PZr4f8y1Qoxo2C6_OqCSxk-o1JStVktMo-ROvh_DCxZbw' \
--data '{
}'
[POST] /board/like : 좋아요 등록
curl --location 'http://localhost:9090/board/like' \
--header 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiI0IiwiaWF0IjoxNzE5NTUxNTIzLCJleHAiOjE3MTk1NTMzMjN9.bpUvKP8jrjMs5gzn9IaM86FG1qgr8ZX6HN5F9WuSwN8IDwd3N_KAuNR0UKYSTSFV7sHL-xebO0--qCrMImmZ8A' \
--header 'Content-Type: application/json' \
--header 'Cookie: refresh-token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIiLCJpYXQiOjE3MTg3NzM4MDEsImV4cCI6MTcyMTM2NTgwMX0.5cxXO4y2oU2XdHd4dI76kbdI3wY7Att4OpH2iDVq7PZr4f8y1Qoxo2C6_OqCSxk-o1JStVktMo-ROvh_DCxZbw' \
--data '{
"boardSeq": "33",
"userId": "test123"
}'
[GET] /board/comment/list?seq={seq} : 게시판 댓글 리스트 조회
curl --location 'http://localhost:9090/board/comment/list?seq=21' \
--header 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiI0IiwiaWF0IjoxNzE5NTUxNTIzLCJleHAiOjE3MTk1NTMzMjN9.bpUvKP8jrjMs5gzn9IaM86FG1qgr8ZX6HN5F9WuSwN8IDwd3N_KAuNR0UKYSTSFV7sHL-xebO0--qCrMImmZ8A' \
--header 'Cookie: refresh-token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIiLCJpYXQiOjE3MTg3NzM4MDEsImV4cCI6MTcyMTM2NTgwMX0.5cxXO4y2oU2XdHd4dI76kbdI3wY7Att4OpH2iDVq7PZr4f8y1Qoxo2C6_OqCSxk-o1JStVktMo-ROvh_DCxZbw'
[POST] /board/comment : 댓글 저장
curl --location 'http://localhost:9090/board/comment' \
--header 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiI0IiwiaWF0IjoxNzE5NTUxNTIzLCJleHAiOjE3MTk1NTMzMjN9.bpUvKP8jrjMs5gzn9IaM86FG1qgr8ZX6HN5F9WuSwN8IDwd3N_KAuNR0UKYSTSFV7sHL-xebO0--qCrMImmZ8A' \
--header 'Content-Type: application/json' \
--header 'Cookie: refresh-token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIiLCJpYXQiOjE3MTg3NzM4MDEsImV4cCI6MTcyMTM2NTgwMX0.5cxXO4y2oU2XdHd4dI76kbdI3wY7Att4OpH2iDVq7PZr4f8y1Qoxo2C6_OqCSxk-o1JStVktMo-ROvh_DCxZbw' \
--data '{
"boardSeq": 21,
"parentSeq": 0,
"userNo": 14,
"commentContent" : "안녕하세요. 반가워요."
}'
[PUT] /board/comment/{seq} : 댓글 수정
curl --location --request PUT 'http://localhost:9090/board/comment/62' \
--header 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiI0IiwiaWF0IjoxNzE5NTUxNTIzLCJleHAiOjE3MTk1NTMzMjN9.bpUvKP8jrjMs5gzn9IaM86FG1qgr8ZX6HN5F9WuSwN8IDwd3N_KAuNR0UKYSTSFV7sHL-xebO0--qCrMImmZ8A' \
--header 'Content-Type: application/json' \
--header 'Cookie: refresh-token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIiLCJpYXQiOjE3MTg3NzM4MDEsImV4cCI6MTcyMTM2NTgwMX0.5cxXO4y2oU2XdHd4dI76kbdI3wY7Att4OpH2iDVq7PZr4f8y1Qoxo2C6_OqCSxk-o1JStVktMo-ROvh_DCxZbw' \
--data '{
"boardSeq": 21,
"parentSeq": 0,
"userNo": 14,
"commentContent" : "댓글 수정해볼까요"
}'
[DELETE] /board/comment/{seq} : 댓글 삭제
curl --location --request DELETE 'http://localhost:9090/board/comment/62' \
--header 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiI0IiwiaWF0IjoxNzE5NTUxNTIzLCJleHAiOjE3MTk1NTMzMjN9.bpUvKP8jrjMs5gzn9IaM86FG1qgr8ZX6HN5F9WuSwN8IDwd3N_KAuNR0UKYSTSFV7sHL-xebO0--qCrMImmZ8A' \
--header 'Content-Type: application/json' \
--header 'Cookie: refresh-token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIiLCJpYXQiOjE3MTg3NzM4MDEsImV4cCI6MTcyMTM2NTgwMX0.5cxXO4y2oU2XdHd4dI76kbdI3wY7Att4OpH2iDVq7PZr4f8y1Qoxo2C6_OqCSxk-o1JStVktMo-ROvh_DCxZbw' \
--data '{
}'
이제 게시판 CRUD에 필요한 API 개발이 완료되었습니다. 다음 글에서 게시판 화면을 만들고 API 기능을 사용한 과정을 알아보겠습니다.
본 포스팅은 스터디를 통해 작성하게 되었습니다. 피드백은 언제나 환영입니다 :)
'토이프로젝트' 카테고리의 다른 글
[팀프로젝트]모바일앱 게시판 만들기 - 3편 : 게시판 CRUD 개발하기 (1) | 2024.07.01 |
---|---|
[팀프로젝트]모바일앱 게시판 만들기 - 2편 : 로그인/회원가입 개발하기 (0) | 2024.06.27 |
[팀프로젝트]모바일앱 게시판 만들기 - 2편 : 로그인/회원가입 개발하기 (0) | 2024.06.25 |
[팀프로젝트]모바일앱 게시판 만들기 - 1편 : 설계 (0) | 2024.06.23 |
[토이프로젝트]대출이자계산기 만들어보기 - 5편 : 플레이스토어와 앱스토어에 어플리케이션 배포하기 (0) | 2024.05.21 |
소중한 공감 감사합니다.