티스토리 뷰
1. html 부분
<!DOCTYPE html>
<html lang="en" xmlns:sec="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>READ</title>
<script th:src="@{/base/jq/jquery-3.7.1.min.js}"></script>
<link rel="stylesheet" href="../base/css/read.css">
</head>
<body>
<!-- 확인용 -->
[[${board}]]
<div class="container">
<h1 style="text-align: center">[ 글 읽기 ]</h1>
<!-- 글 내용 출력 부분 -->
<div class="post-info">
<div>
<label>글번호: </label>
<span th:text="${board.boardNum}"></span>
</div>
<div>
<label>작성자: </label>
<span th:text="${board.memberId}"></span>
</div>
<div>
<label>작성일: </label>
<span th:text="${#temporals.format(board.createDate, 'yyyy-MM-dd')}"></span>
</div>
<div>
<label>수정일: </label>
<span th:text="${#temporals.format(board.updateDate, 'yyyy-MM-dd')}"></span>
</div>
<div>
<label>조회수: </label>
<span th:text="${board.viewCount}"></span>
</div>
<div>
<label>좋아요: </label>
<span th:text="${board.likeCount}"></span>
</div>
<div>
<label>싫어요: </label>
<span th:text="${board.dislikeCount}"></span>
</div>
</div>
<hr>
<div class="content">
<p th:text="${board.contents}"></p>
</div>
<div class="attachments">
<label>파일첨부:</label>
<a th:text="${board.originalName}"></a>
</div>
<hr>
<!-- 수정/삭제 버튼(본인 글인 경우에만 보임) -->
<div th:if="${#authentication.name == board.memberId}">
<form th:action="@{/board/read/{boardNum}/revise(boardNum=${board.boardNum})}" method="get">
<button class="reviseButton">수정</button>
</form>
<form th:action="@{/board/read/{boardNum}/delete(boardNum=${board.boardNum})}" method="post">
<button class="deleteButton">삭제</button>
</form>
</div>
<!-- 리플 작성 폼(로그인한 사람한테만 보임) -->
<div sec:authorize="isAuthenticated()" class="comment">
<form th:action="replySave" method="post">
<!-- input type=hidden : 쓰는 이유는 DB에 가져와서 사용자에게 보여주고 값을 숨긴다음 다른대서 값을 그대로 이용하기 위할 때 -->
<input type="hidden" name="boardNum" th:value="${board.boardNum}">
<label>리플 달기: </label>
<input style="width: 500px" type="text" name="contents">
<button>저장</button>
</form>
<!-- 리플 출력 폼(아무한테나 다 보임) -->
<table>
<tr>
<th>ReplyNum</th>
<th>ID</th>
<th>내용</th>
<th>작성일시</th>
<th>삭제</th>
</tr>
<tr th:each="reply : ${board.replyDTOList}">
<td><span name="replyNum" th:text="${reply.replyNum}"></span></td>
<td>
<span th:text="${reply.memberName}" name="reMemberName"></span>
</td>
<td><span th:text="${reply.contents}" name="reContents"></span></td>
<td><span th:text="${#temporals.format(reply.createTime, 'yyyy-MM-dd')}" name="reCreateTime"></span></td>
<td>
<form action="replyDelete" method="post">
<input type="hidden" name="replyNum" th:value="${reply.replyNum}">
<input type="hidden" name="boardNum" th:value="${reply.boardNum}">
<button type="submit" th:if="${#authentication.name == reply.memberId}">❌</button>
</form>
<!-- ※ form 안에 form을 만들지 못한다 ※ -->
</td>
</tr>
</table>
</div>
</div>
</body>
</html>
2. Controller 부분
package net.datasa.web5.controller;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.datasa.web5.domain.dto.BoardDTO;
import net.datasa.web5.domain.dto.ReplyDTO;
import net.datasa.web5.security.AuthenticatedUser;
import net.datasa.web5.service.BoardService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@Controller
@Slf4j
@RequestMapping("board")
@RequiredArgsConstructor
public class BoardController {
private final BoardService boardService;
/* 게시물을 10개씩 보여주기
2번이상 쓰는 상수는 선언해놓는게 좋다
*/
@Value("${board.pageSize}") // 타임리프를 통하여 괄호안에 한 페이지당 게시글 갯수가 정해짐. 위에 변수를 직접 선언한 것과 같은 효과
int pageSize;
@Value("${board.linkSize}") // 페이지 이동 링크 수
int linkSize;
@Value("${board.uploadPath}") // 첨부파일 저장 경로
String uploadPath;
/**
* 글 목록 보기
*
* @param model
* @return 글 목록 출력 HTML 파일
*/
@GetMapping("list")
public String list(Model model,
@RequestParam(name = "page", defaultValue = "1") int page,
@RequestParam(name = "searchType", defaultValue = "") String searchType,
@RequestParam(name = "searchWord", defaultValue = "") String searchWord) {
log.debug("properties 값 : pageSize={} linkSize={} uploadPath={}", pageSize, linkSize, uploadPath); // 설정 로그값 출력
log.debug("요청 파라미터 : page={} searchType={} searchWord={}", page, searchType, searchWord); // 요청 파라미터 로그 출력
// 현재페이지, 페이지당 글수, 검색대상, 검색어
Page<BoardDTO> boardPage = boardService.getList(page, pageSize, searchType, searchWord);
log.debug("목록정보 getContent() : {}", boardPage.getContent());
log.debug("현재페이지 getNumber() : {}", boardPage.getNumber());
log.debug("전체 글 개수 getTotalElements() : {}", boardPage.getTotalElements());
log.debug("전체 페이지수 getTotalPages() : {}", boardPage.getTotalPages());
log.debug("한 페이지당 글 수 getSize() : {}", boardPage.getSize());
log.debug("이전페이지 존재 여부 hasPrevious() : {}", boardPage.hasPrevious());
log.debug("다음페이지 존재 여부 hasNext() : {}", boardPage.hasNext());
// 검색부분 보여줄 것들
model.addAttribute("page", page); // 현재 페이지
model.addAttribute("linkSize", linkSize); // 페이지 이동링크 수
model.addAttribute("searchType", searchType); // 검색기준
model.addAttribute("searchWord", searchWord); // 검색어
// 글 목록부분 보여줄 것
model.addAttribute("boardPage", boardPage); // 출력할 글정보
return "boardView/list";
}
/**
* 글쓰기 폼으로 이동
*
* @return 글쓰기폼을 출력하는 HTML파일
*/
@GetMapping("write")
public String write() {
return "boardView/writeForm";
}
/**
* 게시글 저장
*/
@PostMapping("write")
public String writesave(@ModelAttribute BoardDTO boardDTO, @AuthenticationPrincipal AuthenticatedUser user) {
boardDTO.setMemberId(user.getId()); // 작성자 ID 설정
boardService.write(boardDTO); // 게시글 저장
return "redirect:/"; // 홈으로 리다이렉트
}
/**
* 선택한 게시글 보기
*
* @param model Model 객체로 뷰에 데이터를 전달
* @param boardNum 게시글 번호
* @return 게시글을 출력하는 HTML 파일 이름
*/
@GetMapping("read")
public String read(Model model, @RequestParam("boardNum") Integer boardNum) {
try {
BoardDTO boardDTO = boardService.getBoard(boardNum);
model.addAttribute("board", boardDTO); // 모델에 저장
return "boardView/read";
} catch (Exception e) {
e.printStackTrace(); // 문제가 생겼을 때 로그를 확인하기 위해서 남기는 것
return "redirect:list"; // 해당 URL로 리다이렉트
}
}
/**
* 게시글 수정 폼을 반환합니다.
*
* @param boardNum 수정할 게시글의 번호
* @param user 현재 인증된 사용자 정보
* @param boardDTO 수정할 게시글의 정보를 담을 DTO
* @param model 모델 객체, 뷰에 전달할 데이터를 설정하는 데 사용
* @return 수정 폼을 보여주는 HTML 파일 이름 (boardView/reviseForm)
*/
@GetMapping("/read/{boardNum}/revise")
public String revise(@PathVariable("boardNum") Integer boardNum,
@AuthenticationPrincipal AuthenticatedUser user,
@ModelAttribute BoardDTO boardDTO,
Model model) {
try {
// 게시글 번호를 사용하여 해당 게시글 정보를 조회합니다.
BoardDTO dto = boardService.getBoard(boardNum);
// 현재 사용자가 해당 게시글을 수정할 권한이 있는지 확인합니다.
if (boardService.select(boardNum, user.getUsername())) {
// 조회한 게시글 정보를 모델에 추가하여 뷰에서 사용할 수 있도록 합니다.
model.addAttribute("boardDTO", dto);
// 수정 폼 HTML 페이지로 이동합니다.
return "boardView/reviseForm";
}
} catch (Exception e) {
// 예외가 발생한 경우, 스택 트레이스를 출력하여 문제를 진단합니다.
e.printStackTrace();
// 오류가 발생하면 게시글 목록 페이지로 리다이렉트합니다.
return "redirect:/board/list";
}
// 게시글 수정 권한이 없는 경우 게시글 목록 페이지로 리다이렉트합니다.
return "redirect:/board/list";
}
/**
* 게시글 수정 요청을 처리합니다.
*
* @param boardDTO 수정된 게시글 정보를 담고 있는 DTO
* @return 수정된 게시글을 보여주는 페이지로 리다이렉트
*/
@PostMapping("/read/{boardNum}/revise")
public String revise(@ModelAttribute BoardDTO boardDTO) {
// 전달된 DTO 객체의 값을 디버그 로그로 출력합니다.
// log.debug("전달된 값:{}", boardDTO);
// DTO의 정보를 사용하여 데이터베이스의 게시글을 업데이트합니다.
boardService.revise(boardDTO);
// 게시글 수정이 완료된 후, 수정된 게시글 정보를 보기 위한 페이지로 리다이렉트합니다.
return "redirect:/board/list";
}
/**
* 게시글 삭제 요청을 처리합니다.
*
* @param boardNum 삭제할 게시글의 번호
* @param user 현재 인증된 사용자 정보
* @param redirectAttributes 리다이렉트 시 플래시 속성을 추가하기 위한 객체
* @return 게시글 목록 페이지로 리다이렉트
*/
@PostMapping("/read/{boardNum}/delete")
public String delete(@PathVariable("boardNum") Integer boardNum,
@AuthenticationPrincipal AuthenticatedUser user,
RedirectAttributes redirectAttributes) {
try {
// 게시글을 삭제합니다.
boardService.delete(boardNum, user.getUsername());
// 삭제 성공 메시지를 플래시 속성으로 추가합니다.
redirectAttributes.addFlashAttribute("msg", "삭제 성공했습니다.");
} catch (Exception e) {
// 예외가 발생한 경우, 스택 트레이스를 출력하여 문제를 진단합니다.
e.printStackTrace();
// 삭제 실패 메시지를 플래시 속성으로 추가합니다.
redirectAttributes.addFlashAttribute("msg", "삭제 실패했습니다.");
}
// 게시글 목록 페이지로 리다이렉트합니다.
return "redirect:/board/list";
}
/**
* 리플 저장
*/
@PostMapping("replySave")
public String replySave(@ModelAttribute ReplyDTO replyDTO,
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam("boardNum") Integer boardNum) {
replyDTO.setMemberId(user.getId()); // 작성자 ID 설정
replyDTO.setMemberName(user.getName());
replyDTO.setBoardNum(boardNum);
// log.debug("저장할 리플정보 {}", replyDTO); // 저장할 정보 로그 출력
// DTO의 boardNum이나 content 값이 null인지 확인
if (replyDTO.getBoardNum() == null || replyDTO.getContents() == null) {
log.error("BoardNum or contents is null");
return "redirect:/";
}
boardService.replySave(replyDTO);
return "redirect:read?boardNum=" + boardNum;
}
/**
* 리플 삭제
*/
@PostMapping("replyDelete")
public String replyDelete(@RequestParam("boardNum") Integer boardNum,
@RequestParam("replyNum") Integer replyNum,
@AuthenticationPrincipal AuthenticatedUser user) {
log.debug("Attempting to delete reply with replyNum={} by userId={}", replyNum, user.getId());
try {
boolean isAuthenticatedUser = boardService.isAuthenticatedReplyUser(user.getId(), replyNum);
if (isAuthenticatedUser) {
// 리플 삭제
boardService.replydelete(replyNum);
// 삭제 성공 메시지를 플래시 속성으로 추가
return "redirect:/board/read?boardNum=" + boardNum;
} else {
// 권한이 없는 경우의 처리
return "redirect:/board/list";
}
} catch (Exception e) {
// 예외가 발생한 경우, 스택 트레이스를 출력하여 문제를 진단
e.printStackTrace();
// 삭제 실패 메시지를 플래시 속성으로 추가
return "redirect:/";
}
}
}
3. Service 부분
package net.datasa.web5.service;
import jakarta.persistence.EntityNotFoundException;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.datasa.web5.domain.dto.BoardDTO;
import net.datasa.web5.domain.dto.ReplyDTO;
import net.datasa.web5.domain.entity.BoardEntity;
import net.datasa.web5.domain.entity.MemberEntity;
import net.datasa.web5.domain.entity.ReplyEntity;
import net.datasa.web5.repository.BoardRepository;
import net.datasa.web5.repository.MemberRepository;
import net.datasa.web5.repository.ReplyRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Service // Spring에서 서비스 계층을 나타내는 어노테이션
@Transactional // 클래스 내의 모든 메서드가 트랜잭셔널(트랜잭션 범위 내에서 실행)하게 함
@RequiredArgsConstructor // Lombok 어노테이션으로, final로 선언된 모든 필드를 파라미터로 갖는 생성자를 자동으로 생성
@Slf4j // Lombok 어노테이션으로, 로깅을 위한 로거를 자동으로 생성
public class BoardService {
private final BoardRepository boardRepository; // 게시판 관련 DB 작업을 처리하는 Repository
private final MemberRepository memberRepository; // 회원 관련 DB 작업을 처리하는 Repository
private final ReplyRepository replyRepository; // 리플 관련 DB 작업을 처리하는 Repository
/**
* 게시판 글 저장
*
* @param dto 저장할 글 정보
*/
public void write(BoardDTO dto) {
log.info("Writing a new board entry: {}", dto); // 글 작성 로그 출력
// 글 작성자 정보 조회
MemberEntity memberEntity = memberRepository.findById(dto.getMemberId())
.orElseThrow(() -> new EntityNotFoundException("Member not found")); // 작성자 정보가 없을 시 예외 처리
BoardEntity entity = BoardEntity.builder()
.member(memberEntity) // 글 작성자 정보 설정
.title(dto.getTitle()) // 글 제목 설정
.contents(dto.getContents()) // 글 내용 설정
.originalName(dto.getOriginalName()) // 첨부파일 원본 이름 설정
.fileName(dto.getFileName()) // 첨부파일 이름 설정
.build();
boardRepository.save(entity); // 게시글 저장
log.debug("저장되는 엔티티 : {}", entity); // 저장되는 엔티티 로그 출력
// TODO: 첨부파일 처리할 것
}
/**
* 게시글 목록 조회
*
* @param page 현재 페이지
* @param pageSize 한 페이지당 글 수
* @param searchType 검색 대상 (제목 : title, 내용 : contents, 작성자 : memberName)
* @param searchWord 검색어
* @return 글정보가 한 페이지 분량 저장된 Page객체
*/
public Page<BoardDTO> getList(int page, int pageSize, String searchType, String searchWord) {
// Pageable : 한페이지를 끊어오기 위한 정보
// Sort.Direction.DESC, "boardNum" : 정렬 객체
// Spring에서는 첫 페이지가 '0'이므로, 가독성을 위해 page-1해줘야 '1' 페이지부터 시작
// p : 한 페이지당 pageSize개씩 boardNum기준으로 내림차순하여 페이지 정보 생성
Pageable p = PageRequest.of(page - 1, pageSize, Sort.Direction.DESC, "boardNum");
// 검색 타입에 따라 적절한 메서드 호출
Page<BoardEntity> entityPage = null;
if (searchType.equals("title")) {
entityPage = boardRepository.findByTitleContaining(searchWord, p);
} else if (searchType.equals("contents")) {
entityPage = boardRepository.findByContentsContaining(searchWord, p);
} else if (searchType.equals("memberName")) {
entityPage = boardRepository.findByMember_MemberName(searchWord, p);
} else {
entityPage = boardRepository.findAll(p);
}
// entityPage를 DTO로 변환하여 반환
Page<BoardDTO> dtoPage = entityPage.map(this::convertToDTO);
return dtoPage;
}
/**
* BoardEntity 객체를 전달받아 BoardDTO객체로 변환하여 리턴
* @param entity DB에서 읽은 정보를 담은 엔티티 객체
* @return 출력용 정보를 담은 DTO객체
*/
private BoardDTO convertToDTO(BoardEntity entity) {
return BoardDTO.builder() // Builder 패턴을 사용하여 BoardDTO 객체 생성
.boardNum(entity.getBoardNum())
.memberId(entity.getMember().getMemberId())
.memberName(entity.getMember().getMemberName())
.title(entity.getTitle())
.contents(entity.getContents())
.viewCount(entity.getViewCount())
.likeCount(entity.getLikeCount())
.dislikeCount(entity.getDislikeCount())
.originalName(entity.getOriginalName())
.fileName(entity.getFileName())
.createDate(entity.getCreateDate())
.updateDate(entity.getUpdateDate())
.build();
}
/**
* 글 1개 조회
* @param boardNum 글 번호
* @return 글 정보 DTO
*/
public BoardDTO getBoard(Integer boardNum) {
// 글 번호로 BoardEntity 조회 없으면 예외 처리 있으면 BoardDTO로 변환하여 리턴
BoardEntity boardEntity = boardRepository.findById(boardNum).orElseThrow(()
-> new EntityNotFoundException("글이 없습니다."));
// 조회결과 출력 (순환참조)
log.debug("조회된 게시글 정보 : {}", boardEntity);
// 조회된 엔티티(글 정보)를 DTO로 변환하여 반환
BoardDTO boardDTO = convertToDTO(boardEntity);
// 리플 목록을 DTO로 변환하여 추가
List<ReplyDTO> replyDTOList = new ArrayList<>();
for (ReplyEntity replyEntity : boardEntity.getReplyEntityList()) {
ReplyDTO replyDTO = ReplyDTO.builder()
.replyNum(replyEntity.getReplyNum())
.boardNum(replyEntity.getBoard().getBoardNum())
.memberId(replyEntity.getMember().getMemberId())
.memberName(replyEntity.getMember().getMemberName())
.contents(replyEntity.getContents())
.createTime(replyEntity.getCreateDate())
.build();
replyDTOList.add(replyDTO);
}
boardDTO.setReplyDTOList(replyDTOList); // boardDTO에 리플 리스트를 담음
return boardDTO;
}
/**
* 게시글 수정
*/
public boolean select(Integer boardNum, String username) {
// Primary key 기준으로 찾는 JPA 메서드 findById(). SQL의 select * from where 구문 역할
BoardEntity entity = boardRepository.findById(boardNum)
.orElseThrow(() -> new EntityNotFoundException("해당 게시글 없음"));
if(entity.getMember().getMemberId().equals(username)) {
return true; // DTO 반환
} else {
return false;
}
}
/**
* 게시글 수정
* @param boardDTO 수정할 정보
*/
public void revise(BoardDTO boardDTO) {
BoardEntity entity = boardRepository.findById(boardDTO.getBoardNum())
.orElseThrow(() -> new EntityNotFoundException("없는 게시글"));
// dto에서 수정할 정보를 가져와 entity에 설정
entity.setTitle(boardDTO.getTitle());
entity.setContents(boardDTO.getContents());
entity.setOriginalName(boardDTO.getOriginalName());
entity.setFileName(boardDTO.getFileName());
entity.setUpdateDate(boardDTO.getUpdateDate());
// 업데이트된 entity를 데이터베이스에 저장
boardRepository.save(entity);
}
/**
* 게시글 삭제
*/
public void delete(Integer boardNum, String username) throws EntityNotFoundException {
// throws EntityNotFoundException : 예외 처리를 해줘야하지만, 여기서는 안써줘도 작동함(생략되어있는 것뿐)
// 전달된 번호로 글 정보 조회
// 글이 없으면 예외
// find가 들어가는 것은 select문과 관련이 있음
// DB에 가서 select * from table명 where primary key 등
BoardEntity entity = boardRepository.findById(boardNum)
.orElseThrow(() -> new EntityNotFoundException("해당 글이 없습니다."));
// 글이 있으면 비밀번호 비교
// 비밀번호 틀리면 예외
if (!username.equals(entity.getMember().getMemberId())) {
throw new RuntimeException("작성자가 일치하지 않습니다.");
}
// 맞으면 글 삭제
boardRepository.delete(entity);
}
/**
* 리플 저장
*/
public void replySave(ReplyDTO replyDTO) {
log.info("new Reply: {}", replyDTO); // 글 작성 로그 출력
BoardEntity boardEntity = boardRepository.findById(replyDTO.getBoardNum())
.orElseThrow(() -> new EntityNotFoundException("글이 없습니다."));
MemberEntity memberEntity = memberRepository.findById(replyDTO.getMemberId())
.orElseThrow(() -> new EntityNotFoundException("해당하는 아이디가 없습니다."));
ReplyEntity replyEntity = new ReplyEntity();
replyEntity.setBoard(boardEntity);
replyEntity.setMember(memberEntity);
replyEntity.setContents(replyDTO.getContents());
replyRepository.save(replyEntity);
}
/**
* 리플 삭제 service
*/
public void replydelete(Integer replyNum) throws EntityNotFoundException {
ReplyEntity replyEntity = replyRepository.findById(replyNum)
.orElseThrow(() -> new EntityNotFoundException("해당 리플이 없습니다."));
replyRepository.delete(replyEntity);
}
/**
* 댓글 작성자와 현재 로그인 중인 사용자가 동일인물인지 확인해 참거짓 반환하는 서비스
* @return 참/거짓
*/
public boolean isAuthenticatedReplyUser(String username, Integer replyNum) {
// 삭제하고자 하는 리플넘버로 리플 정보 불러옴
ReplyEntity replyEntity = replyRepository.findById(replyNum)
.orElseThrow(() -> new EntityNotFoundException("존재하지 않는 리플입니다."));
// 현재 로그인 중인 사용자 정보 불러옴
MemberEntity memberEntity = memberRepository.findById(username)
.orElseThrow(() -> new EntityNotFoundException("존재하지 않는 사용자입니다."));
// 각 아이디를 불러와 비교해, 동일하면 참 다르면 거짓을 반환
String rId = replyEntity.getMember().getMemberId();
String mId = memberEntity.getMemberId();
return rId.equals(mId);
}
}
'SCIT > 8월' 카테고리의 다른 글
8/7 [게시판] 게시글 첨부파일 다운로드 (0) | 2024.08.07 |
---|---|
8.6[게시판] 파일 첨부 및 삭제 기능 추가 (0) | 2024.08.06 |
8/5 [프로젝트] 프로젝트 이름 바꾸기 (0) | 2024.08.05 |
8.2 [게시판] 게시글 리플달기 (0) | 2024.08.02 |
8.1 [게시판] 게시글 10개씩 보여주기 (0) | 2024.08.01 |
공지사항
최근에 올라온 글
최근에 달린 댓글
- Total
- Today
- Yesterday
링크
TAG
- html
- css
- 백준
- MySQL
- springboot
- javascript
- Modal
- DB
- JPA
- 2739번
- 반복문
- 오븐시계
- setting
- 2480
- Spring boot
- 조건문
- Intellij idea
- Linux
- if문
- data science academy
- ajax
- 가계부만들기
- java
- backjoon
- Spring
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |
글 보관함