티스토리 뷰

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);
    }
}
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/12   »
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
글 보관함