TIL_220805_강의용 게시판 프로젝트 11

창고·2022년 8월 8일
0

들어가기에 앞서
실제 프로젝트 진행했던 코드 내용 및 세부 내용은 일부만 업로드하였습니다.

13. 게시글 기능 구현

(1) 게시글 뷰 구현

  • detail.html
<header id="header">
  헤더 삽입부
  <hr>
</header>

<main id="article-main" class="container">
  <header id="article-header" class="py-5 text-center">
    <h1>첫번째 글</h1>
  </header>

  <div class="row g-5">
    <section class="col-md-5 col-lg-4 order-md-last">
      <aside>
        <p><span id="nickname">Uno</span></p>
        <p><a id="email" href="mailto:djkehh@gmail.com">uno@mail.com</a></p>
        <p><time id="created-at" datetime="2022-01-01T00:00:00">2022-01-01</time></p>
        <p><span id="hashtag">#java</span></p>
      </aside>
    </section>

    <article id="article-content" class="col-md-7 col-lg-8">
      <pre>본문<br><br></pre>
    </article>
  </div>

  <div class="row g-5">
    <section>
      <form class="row g-3">
        <div class="col-8">
          <label for="comment-textbox" hidden>댓글</label>
          <textarea class="form-control" id="comment-textbox" placeholder="댓글 쓰기.." rows="3"></textarea>
        </div>
        <div class="col-auto">
          <label for="comment-submit" hidden>댓글 쓰기</label>
          <button class="btn btn-primary" id="comment-submit" type="submit">쓰기</button>
        </div>
      </form>

      <ul id="article-comments" class="row col-7">
        <li>
          <div>
            <strong>Uno</strong>
            <small><time>2022-01-01</time></small>
            <p>
              Lorem ipsum dolor sit amet, consectetur adipiscing elit.<br>
              Lorem ipsum dolor sit amet
            </p>
          </div>
        </li>
        <li>
          <div>
            <strong>Uno</strong>
            <small><time>2022-01-01</time></small>
            <p>
              Lorem ipsum dolor sit amet, consectetur adipiscing elit.<br>
              Lorem ipsum dolor sit amet
            </p>
          </div>
        </li>
      </ul>

    </section>
  </div>

  <div class="row g-5">
    <nav aria-label="Page navigation example">
      <ul class="pagination">
        <li class="page-item">
          <a class="page-link" href="#" aria-label="Previous">
            <span aria-hidden="true">&laquo; prev</span>
          </a>
        </li>
        <li class="page-item">
          <a class="page-link" href="#" aria-label="Next">
            <span aria-hidden="true">next &raquo;</span>
          </a>
        </li>
      </ul>
    </nav>
  </div>

</main>

<footer id="footer">
  <hr>
  푸터 삽입부
</footer>
  • detail.th.xml
<?xml version="1.0"?>
<thlogic>
    <attr sel="#header" th:replace="header :: header" />
    <attr sel="#footer" th:replace="footer :: footer" />

    <attr sel="#article-main" th:object="${article}">
        <attr sel="#article-header/h1" th:text="*{title}" /> <!-- *을 붙임으로서 앞쪽의 article.을 생략 -->
        <attr sel="#nickname" th:text="*{nickname}" />
        <attr sel="#email" th:text="*{email}" />
        <attr sel="#created-at" th:datetime="*{createdAt}" th:text="*{#temporals.format(createdAt, 'yyyy-MM-dd HH:mm:ss')}" />
        <attr sel="#hashtag" th:text="*{hashtag}" />
        <attr sel="#article-content/pre" th:text="*{content}" />
    </attr>

    <attr sel="#article-comments" th:remove="all-but-first">
        <attr sel="li[0]" th:each="articleComment : ${articleComments}">
            <attr sel="div/strong" th:text="${articleComment.nickname}" />
            <attr sel="div/small/time" th:datetime="${articleComment.createdAt}" th:text="${#temporals.format(articleComment.createdAt, 'yyyy-MM-dd HH:mm:ss')}" />
            <attr sel="div/p" th:text="${articleComment.content}" />
        </attr>
    </attr>
</thlogic>

(2) 로그인 페이지 기능

  • 이미 Spring Security로 구현이 되어 있음 (기능만 추가가 되지 않음)

14. 게시판 페이징 구현

  • 내비게이션의 현재 페이지가 가운데에 오게끔 페이징 기능
  • PaginationService 와 테스트 클래스 작성
  • PaginationService
@Service
public class PaginationService {

    private static final int BAR_LENGTH = 5;

    public List<Integer> getPaginationBarNumbers(int currentPageNumber, int totalPages) {

        return null;
    }

    public int currentBarLength() {
        return BAR_LENGTH;
    }
}
  • PaginationServiceTest
    • SpringBootTest, ParameterizedTest + MethodSouce 사용
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.List;
import java.util.stream.Stream;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.params.provider.Arguments.*;

@DisplayName("비즈니스 로직 - 페이지네이션")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE, classes = PaginationService.class) // 테스트의 무게가 줄어듬
class PaginationServiceTest {

    private final PaginationService sut;

    public PaginationServiceTest(@Autowired PaginationService paginationService) {
        this.sut = paginationService;
    }
    
    @DisplayName("현재 페이지 번호와 총 페이지 수를 주면, 페이지 바 리스트를 만들어줌")
    @MethodSource // 메소드에 소스를 주는 방식
    @ParameterizedTest(name = "[{index}] 현재 페이지 : {0}, 총 페이지 : {1} => {2}") // 파라미터 테스트
    void givenCurrentPageNumberAndTotalPages_whenCalculating_thenReturnsPaginationBarNumbers(int currentPageNumber, int totalPages, List<Integer> expected) {
        // Given

        // When
        List<Integer> actual = sut.getPaginationBarNumbers(currentPageNumber, totalPages);

        // Then
        assertThat(actual).isEqualTo(expected);
    }

    // @MethodSource를 클릭하여 생성
    static Stream<Arguments> givenCurrentPageNumberAndTotalPages_whenCalculating_thenReturnsPaginationBarNumbers() {

        return Stream.of(
                // arguments = Arguments.arguments
                arguments(0, 13, List.of(0,1,2,3,4)), // pagenumber, page의 index는 0부터 시작됨
                arguments(1, 13, List.of(0,1,2,3,4)),
                arguments(2, 13, List.of(0,1,2,3,4)),
                arguments(3, 13, List.of(1,2,3,4,5)),
                arguments(4, 13, List.of(2,3,4,5,6)),
                arguments(5, 13, List.of(3,4,5,6,7)),
                arguments(6, 13, List.of(4,5,6,7,8)),
                arguments(10, 13, List.of(8,9,10,11,12)),
                arguments(11, 13, List.of(9,10,11,12)),
                arguments(12, 13, List.of(10,11,12)) // 13페이지까지이므로 index 기준은 12가 끝
                );
    }

    @DisplayName("현재 설정되어 있는 페이지네이션 바의 길이를 알려줌")
    @Test
    void givenNothing_whenCalling_thenReturnsCurrentBarLength() {
        // Given

        // When
        int barLength = sut.currentBarLength();

        // Then
        assertThat(barLength).isEqualTo(5); // 상수 처리한 바 길이 5를 명시적으로 알려주기 위해 해당 테스트 작성

    }
}
  • PaginationService 기능 구현
    public List<Integer> getPaginationBarNumbers(int currentPageNumber, int totalPages) {
        int startNumber = Math.max(currentPageNumber - (BAR_LENGTH / 2), 0);
        // 기본 골자는 현재 페이지 - (길이 / 2) 겠지만 0보다 작으면 0 반환
        int endNumber = Math.min(startNumber + BAR_LENGTH, totalPages);
        // startNumber + 길이가 totalPages를 넘지 않게

        return IntStream.range(startNumber, endNumber).boxed().toList();
    }
  • ArticleController 및 테스트 클래스 메소드 추가
    @MockBean // mockito의 mock과 동일, @Autowired 불가, 필드에만 주입
    private ArticleService articleService;
    @MockBean
    private PaginationService paginationService;
  • 내비게이션 바가 들어가는 페이지와 관련된 메소드 수정 (게시판 뷰 테스트 메소드)
    @DisplayName("[VIEW][GET] 게시글 리스트 (게시판 페이지 - 정상 호출")
    @Test
    public void givenNothing_whenRequestingArticlesView_thenReturnsArticlesView() throws Exception {
        // Given
        given(articleService.searchArticles(eq(null), eq(null), any(Pageable.class))).willReturn(Page.empty());
        given(paginationService.getPaginationBarNumbers(anyInt(), anyInt())).willReturn(List.of(0,1,2,3,4));

        // When & Then
        mvc.perform(get("/articles"))
                .andExpect(status().isOk()) // 정상 호출인지
                .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML)) // HTML 파일의 컨텐츠인지 (호환되는 컨텐츠 포함)
                .andExpect(view().name("articles/index")) // 뷰 이름 검사
                .andExpect(model().attributeExists("articles")) // 내부에 값이 있는지 (이름을 articles로 지정)
                .andExpect(model().attributeExists("paginationBarNumbers")); // 내부에 값이 있는지 (이름을 paginationBarNumbers로 지정)
        then(articleService).should().searchArticles(eq(null), eq(null), any(Pageable.class));
        then(paginationService).should().getPaginationBarNumbers(anyInt(), anyInt());
    }
  • 페이지내이션 기능 자체에 대한 테스트 메소드 추가
    @DisplayName("[VIEW][GET] 게시글 리스트 (게시판) 페이지 - 페이징, 정렬 기능")
    @Test
    void givenPagingAndSortingParams_whenSearchingArticlesPage_thenReturnsArticlesPage() throws Exception {
        // Given
        String sortName = "title";
        String direction = "desc";
        int pageNumber = 0;
        int pageSize = 5;
        Pageable pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Order.desc(sortName)));
        List<Integer> barNumbers = List.of(1, 2, 3, 4, 5);
        given(articleService.searchArticles(null, null, pageable)).willReturn(Page.empty());
        given(paginationService.getPaginationBarNumbers(pageable.getPageNumber(), Page.empty().getTotalPages())).willReturn(barNumbers);

        // When & Then
        mvc.perform(
                        get("/articles")
                                .queryParam("page", String.valueOf(pageNumber))
                                .queryParam("size", String.valueOf(pageSize))
                                .queryParam("sort", sortName + "," + direction)
                )
                .andExpect(status().isOk())
                .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
                .andExpect(view().name("articles/index"))
                .andExpect(model().attributeExists("articles"))
                .andExpect(model().attribute("paginationBarNumbers", barNumbers));
        then(articleService).should().searchArticles(null, null, pageable);
        then(paginationService).should().getPaginationBarNumbers(pageable.getPageNumber(), Page.empty().getTotalPages());
    }
  • 테스트 기반 ArticleController 기능 수정
    @GetMapping
    public String articles(
            @RequestParam(required = false) SearchType searchType,
            @RequestParam(required = false) String searchValue,
            @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable,
            ModelMap map
    ) {
        Page<ArticleResponse> articles = articleService.searchArticles(searchType, searchValue, pageable)
                .map(ArticleResponse::from); // dto를 response로 변환하여 페이지로 만듬
        List<Integer> barNumbers = paginationService.getPaginationBarNumbers(pageable.getPageNumber(), articles.getTotalPages());
        // 현재 페이지 번호와 해당 페이지들의 전체 숫자(getTotalPages)를 매개값으로 페이징 내비게이션 바 구축
        map.addAttribute("articles", articles);
        map.addAttribute("paginationBarNumbers", barNumbers);
        return "articles/index";
    }
  • index.html
    <nav id="pagination" aria-label="Page navigation example">
      <ul class="pagination justify-content-center">
        <li class="page-item"><a class="page-link" href="#">Previous</a></li>
        <li class="page-item"><a class="page-link" href="#">1</a></li>
        <li class="page-item"><a class="page-link" href="#">Next</a></li>
      </ul>
    </nav>
  • index.th.xml
    <attr sel="#pagination">
        <attr sel="li[0]/a"
              th:text="'previous'"
              th:href="@{/articles(page=${articles.number - 1})}"
              th:class="'page-link' + (${articles.number} <= 0 ? ' disabled' : '')"

        />  <!-- articles.number가 0보다 작거나 같으면 disabled -->
        <attr sel="li[1]" th:class="page-item" th:each="pageNumber : ${paginationBarNumbers}">
            <attr sel="a"
                  th:text="${pageNumber + 1}"
                  th:href="@{/articles(page=${pageNumber})}"
                  th:class="'page-link' + (${pageNumber} == ${articles.number} ? ' disabled' : '')"
            /> <!-- pageNumber가 articles.number와 같으면 disabled -->
        </attr>
        <attr sel="li[2]/a"
              th:text="'next'"
              th:href="@{/articles(page=${articles.number + 1})}"
              th:class="'page-link' + (${articles.number} >= ${articles.totalPages - 1} ? ' disabled' : '')"
        /> <!-- articles.number가 전체 총합 페이지-1보다 크거나 같으면 disabled -->
    </attr>

15. 게시글 페이징 구현

  • ArticleServiceTest
    @DisplayName("게시글 수를 조회하면, 게시글 수를 반환한다")
    @Test
    void givenNothing_whenCountingArticles_thenReturnsArticleCount() {
        // Given
        long expected = 0L;
        given(articleRepository.count()).willReturn(expected);

        // When
        long actual = sut.getArticleCount();

        // Then
        assertThat(actual).isEqualTo(expected);
        then(articleRepository).should().count();
    }
  • ArticleService 추가
    public long getArticleCount() {
        return articleRepository.count();
    }
  • ArticleControllerTest 수정
    @DisplayName("[VIEW][GET] 게시글 상세 페이지 - 정상 호출")
    @Test
    public void givenNothing_whenRequestingArticleView_thenReturnsArticleView() throws Exception {
        // Given
        Long articleId = 1L;
        Long totalCount = 1L;
        given(articleService.getArticle(articleId)).willReturn(createArticleWithCommentsDto());
        given(articleService.getArticleCount()).willReturn(totalCount);

        // When & Then
        mvc.perform(get("/articles/" + articleId))
                .andExpect(status().isOk()) // 정상 호출인지
                .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML)) // HTML 파일의 컨텐츠인지 (호환되는 컨텐츠 포함)
                .andExpect(view().name("articles/detail")) // 뷰 이름 검사
                .andExpect(model().attributeExists("article")) // 내부에 값이 있는지 (이름을 articles로 지정)
                .andExpect(model().attributeExists("articleComments"))
                .andExpect(model().attributeExists("articleComments"))
                .andExpect(model().attribute("totalCount", totalCount));
        then(articleService).should().getArticle(articleId);
        then(articleService).should().getArticleCount();
    }
  • ArticleController 메소드 추가
    @GetMapping("/{articleId}")
    public String article(@PathVariable Long articleId, ModelMap map) {
        ArticleWithCommentsResponse article = ArticleWithCommentsResponse.from(articleService.getArticle(articleId));

        map.addAttribute("article", article);
        map.addAttribute("articleComments", article.articleCommentsResponses());
        map.addAttribute("totalCount", articleService.getArticleCount());
        
        return "articles/detail";
    }
  • detail.html
  <div class="row g-5">
    <nav id="pagination" aria-label="Page navigation">
      <ul class="pagination">
        <li class="page-item">
          <a class="page-link" href="#" aria-label="Previous">
            <span aria-hidden="true">&laquo; prev</span>
          </a>
        </li>
        <li class="page-item">
          <a class="page-link" href="#" aria-label="Next">
            <span aria-hidden="true">next &raquo;</span>
          </a>
        </li>
      </ul>
    </nav>
  </div>
  • detail.th.xml
  <attr sel="#article-main" th:object="${article}">
    <attr sel="#article-header/h1" th:text="*{title}" />
    <attr sel="#nickname" th:text="*{nickname}" />
    <attr sel="#email" th:text="*{email}" />
    <attr sel="#created-at" th:datetime="*{createdAt}" th:text="*{#temporals.format(createdAt, 'yyyy-MM-dd HH:mm:ss')}" />
    <attr sel="#hashtag" th:text="*{hashtag}" />
    <attr sel="#article-content/pre" th:text="*{content}" />

    <attr sel="#article-comments" th:remove="all-but-first">
      <attr sel="li[0]" th:each="articleComment : ${articleComments}">
        <attr sel="div/strong" th:text="${articleComment.nickname}" />
        <attr sel="div/small/time" th:datetime="${articleComment.createdAt}" th:text="${#temporals.format(articleComment.createdAt, 'yyyy-MM-dd HH:mm:ss')}" />
        <attr sel="div/p" th:text="${articleComment.content}" />
      </attr>
    </attr>

    <attr sel="#pagination">
      <attr sel="ul">
        <attr sel="li[0]/a"
              th:href="*{id} - 1 <= 0 ? '#' : |/articles/*{id - 1}|"
              th:class="'page-link' + (*{id} - 1 <= 0 ? ' disabled' : '')"
        />
        <attr sel="li[1]/a"
              th:href="*{id} + 1 > ${totalCount} ? '#' : |/articles/*{id + 1}|"
              th:class="'page-link' + (*{id} + 1 > ${totalCount} ? ' disabled' : '')"
        />
      </attr>
    </attr>
  </attr>
  • 기타 변경점 : AuthControllerTest 변경
@DisplayName("View 컨트롤러 - 인증")
@Import(SecurityConfig.class)
@WebMvcTest(Void.class)
public class AuthControllerTest {

    private final MockMvc mvc;

    public AuthControllerTest(@Autowired MockMvc mvc) {
        this.mvc = mvc;
    }


    @DisplayName("[VIEW][GET] 로그인 페이지 - 정상 호출")
    @Test
    public void givenNothing_whenTryingToLogin_thenReturnsLogInView() throws Exception {
        // Given

        // When & Then
        mvc.perform(get("/login"))
                .andExpect(status().isOk()) // 정상 호출인지
                .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML)) // HTML 파일의 컨텐츠인지 (호환되는 컨텐츠 포함)
                .andDo(MockMvcResultHandlers.print());
    }
}

16. 게시판 정렬 기능 구현

  • index.html 수정
  <header id="header">
    헤더 삽입부
    <hr>
  </header>

  <main class="container">

    <div class="row">
      <div class="card card-margin search-form">
        <div class="card-body p-0">
          <form id="card search-form">
            <div class="row">
              <div class="col-12">
                <div class="row no-gutters">
                  <div class="col-lg-3 col-md-3 col-sm-12 p-0">
                    <label for="search-type" hidden>검색 유형</label>
                    <select class="form-control" id="search-type">
                      <option>제목</option>
                      <option>본문</option>
                      <option>id</option>
                      <option>닉네임</option>
                      <option>해시태그</option>
                    </select>
                  </div>
                  <div class="col-lg-8 col-md-6 col-sm-12 p-0">
                    <label for="search-value" hidden>검색어</label>
                    <input type="text" placeholder="검색어..." class="form-control" id="search-value" name="search-value">
                  </div>
                  <div class="col-lg-1 col-md-3 col-sm-12 p-0">
                    <button type="submit" class="btn btn-base">
                      <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-search">
                        <circle cx="11" cy="11" r="8"></circle>
                        <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
                      </svg>
                    </button>
                  </div>
                </div>
              </div>
            </div>
          </form>
        </div>
      </div>
    </div>

    <table class="table" id="article-table">
      <thead>
        <tr>
          <th class="title col-6"><a>제목</a></th>
          <th class="hashtag col-2"><a>해시태그</a></th>
          <th class="user-id col"><a>작성자</a></th>
          <th class="created-at col"><a>작성일</a></th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td class="title"><a>첫글</a></td>
          <td class="hashtag">#Java</td>
          <td class="user-id">mrcocoball</td>
          <td class="created-at"><time>2022-08-01</time></td>
        </tr>
        <tr>
          <td>두번째</td>
          <td>#Javascript</td>
          <td>Jio</td>
          <td>2022-08-02</td>
        </tr>
        <tr>
          <td>세번째</td>
          <td>#Python</td>
          <td>HYK</td>
          <td>2022-08-03</td>
        </tr>
      </tbody>
    </table>
  • index.th.xml
<?xml version="1.0"?>
<thlogic>
    <attr sel="#header" th:replace="header :: header" />
    <attr sel="#footer" th:replace="footer :: footer" />

    <attr sel="main" th:object="${articles}">
        <attr sel="#article-table">
            <attr sel="thead/tr">
                <attr sel="th.title/a" th:text="'제목'" th:href="@{/articles(
            page=${articles.number},
            sort='title' + (*{sort.getOrderFor('title')} != null ? (*{sort.getOrderFor('title').direction.name} != 'DESC' ? ',desc' : '') : '')
        )}"/>
                <attr sel="th.hashtag/a" th:text="'해시태그'" th:href="@{/articles(
            page=${articles.number},
            sort='hashtag' + (*{sort.getOrderFor('hashtag')} != null ? (*{sort.getOrderFor('hashtag').direction.name} != 'DESC' ? ',desc' : '') : '')
        )}"/>
                <attr sel="th.user-id/a" th:text="'작성자'" th:href="@{/articles(
            page=${articles.number},
            sort='userAccount.userId' + (*{sort.getOrderFor('userAccount.userId')} != null ? (*{sort.getOrderFor('userAccount.userId').direction.name} != 'DESC' ? ',desc' : '') : '')
        )}"/>
                <attr sel="th.created-at/a" th:text="'작성일'" th:href="@{/articles(
            page=${articles.number},
            sort='createdAt' + (*{sort.getOrderFor('createdAt')} != null ? (*{sort.getOrderFor('createdAt').direction.name} != 'DESC' ? ',desc' : '') : '')
        )}"/>
            </attr>

            <attr sel="tbody" th:remove="all-but-first">
                <attr sel="tr[0]" th:each="article : ${articles}">
                    <attr sel="td.title/a" th:text="${article.title}" th:href="@{'/articles/' + ${article.id}}" />
                    <attr sel="td.hashtag" th:text="${article.hashtag}" />
                    <attr sel="td.user-id" th:text="${article.nickname}" />
                    <attr sel="td.created-at/time" th:datetime="${article.createdAt}" th:text="${#temporals.format(article.createdAt, 'yyyy-MM-dd')}" />
                </attr>
            </attr>
        </attr>
    </attr>

    <attr sel="#pagination">
        <attr sel="li[0]/a"
              th:text="'previous'"
              th:href="@{/articles(page=${articles.number - 1})}"
              th:class="'page-link' + (${articles.number} <= 0 ? ' disabled' : '')"
        />
        <attr sel="li[1]" th:class="page-item" th:each="pageNumber : ${paginationBarNumbers}">
            <attr sel="a"
                  th:text="${pageNumber + 1}"
                  th:href="@{/articles(page=${pageNumber})}"
                  th:class="'page-link' + (${pageNumber} == ${articles.number} ? ' disabled' : '')"
            />
        </attr>
        <attr sel="li[2]/a"
              th:text="'next'"
              th:href="@{/articles(page=${articles.number + 1})}"
              th:class="'page-link' + (${articles.number} >= ${articles.totalPages - 1} ? ' disabled' : '')"
        />
    </attr>
</thlogic>
  • 정렬 기능은 결국 각종 Dto를 타고 가서 도달하게 된 도메인의 정보를 토대로 진행한다
    (Dto -> Repository -> 도메인)
  • CSS 적용 table-header.css (정렬 버튼 관련)
/* 게시글 테이블 제목 */
#article-table > thead a {
  text-decoration: none;
  color: black;
}
  • index.html에 css 추가
  <link href="/css/table-header.css" rel="stylesheet">
profile
공부했던 내용들을 모아둔 창고입니다.

0개의 댓글