공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.
이번 시간에는 서비스와 컨트롤러 테스트 코드를 미리 작성한 것을 토대로 게시글 관리 페이지를 구현한다.
본격적으로 구현에 들어가기 전에 8080 서버의 코드를 다시 검토하면서 자잘하게 수정한 부분이 있어, 이를 먼저 짚고 넘어가겠다.
1. build.gradle 파일 수정
수정 전
dependencies {
implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0"
// implementation "org.springdoc:springdoc-openapi-data-rest:2.6.0"
implementation 'com.github.therapi:therapi-runtime-javadoc:0.15.0'
// queryDSL 설정
implementation "com.querydsl:querydsl-jpa:${querydslVersion}:jakarta"
implementation "com.querydsl:querydsl-core"
implementation "com.querydsl:querydsl-collections"
annotationProcessor "com.querydsl:querydsl-apt:${querydslVersion}:jakarta" // querydsl JPAAnnotationProcessor 사용 지정
}
수정 후
dependencies {
implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0"
implementation 'org.springdoc:springdoc-openapi-starter-common:2.3.0'
// queryDSL 설정
implementation "com.querydsl:querydsl-jpa:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
implementation "com.querydsl:querydsl-core"
implementation "com.querydsl:querydsl-collections"
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" // querydsl JPAAnnotationProcessor 사용 지정
}
- springdoc-openapi-starter 관련 의존성을 최신 버전으로 변경하고, querydsl의 버전을 중앙화된 설정에서 가져오도록 수정했다.
2. 코드 내 자잘한 수정
2-1. JpaConfig 클래스
수정 전
public class JpaConfig {
@Bean
public AuditorAware<String> auditorProvider() {
...
}
수정 후
public class JpaConfig {
@Bean
public AuditorAware<String> auditorAware() {
...
}
- auditorProvider 메서드명을 더 직관적인 auditorAware로 변경했다.
2-2. FormStatus 클래스
수정 전
@Getter private final String description;
@Getter private final boolean update;
FormStatus(String description, boolean update) {}
수정 후
@Getter private final String description;
@Getter private final Boolean update;
FormStatus(String description, Boolean update) {}
- boolean 타입을 Boolean으로 변경해 기본값 처리와 null 가능성을 고려했다.
2-3. ArticleCommentRepository 인터페이스
수정 전
List<ArticleComment> findByArticleId(Long articleId);
수정 후
List<ArticleComment> findByArticle_Id(Long articleId);
- 쿼리 메서드에서 findByArticleId를 findByArticle_Id로 변경해 JPA 필드 명명 규칙을 준수했다.
2-4. UserAccountRepository 인터페이스
수정 전
@RepositoryRestResource(excerptProjection = ArticleCommentProjection.class)
public interface UserAccountRepository extends JpaRepository<UserAccount, String> {
}
수정 후
@RepositoryRestResource(excerptProjection = UserAccountProjection.class)
public interface UserAccountRepository extends JpaRepository<UserAccount, String> {
}
- excerptProjection에서 올바른 프로젝션 클래스를 지정했다.
2-5. ArticleCommentService 클래스
수정 전
@Transactional(readOnly = true)
public List<ArticleCommentDto> searchArticleComments(Long articleId) {
return articleCommentRepository.findByArticleId(articleId)
.stream()
.map(ArticleCommentDto::from)
.toList();
}
수정 후
@Transactional(readOnly = true)
public List<ArticleCommentDto> searchArticleComments(Long articleId) {
return articleCommentRepository.findByArticle_Id(articleId)
.stream()
.map(ArticleCommentDto::from)
.toList();
}
- findByArticleId 메서드를 findByArticle_Id로 변경해 Repository 수정 사항을 반영했다.
이제 이 수정 사항들을 바탕으로 게시글 관리 페이지 구현을 시작하겠다.
이번 게시글 관리 페이지 구현에서는 스프링 부트 버전 차이로 인한 설정 차이와 오류를 해결하는 데 많은 시간이 걸렸다. 특히 java.lang.IllegalStateException: Cannot create a session after the response has been committed 오류와 함께 여러 뷰 템플릿 관련 오류가 발생했다. 처음에는 타임리프 문법을 수정하는 방향으로 접근했지만, 근본적인 문제는 보안 설정에 있었다.
이 오류는 HTTP 응답이 이미 커밋된 후에 세션을 생성하려고 시도할 때 발생하는데, 주로 CSRF 토큰 처리와 관련이 있다. 보안 설정을 수정하여 응답이 너무 빨리 커밋되는 것을 방지함으로써 문제를 해결했다.
보안 설정 수정
CSRF 설정을 추가하여 CookieCsrfTokenRepository.withHttpOnlyFalse()를 사용함으로써 CSRF 토큰 처리 오류를 방지했다.
@Configuration
public class SecurityConfig {
String[] rolesAboveManager ={RoleType.MANAGER.name(), RoleType.DEVELOPER.name(), RoleType.ADMIN.name()};
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
.requestMatchers(HttpMethod.POST, "/**").hasAnyRole(rolesAboveManager)
.requestMatchers(HttpMethod.DELETE, "/**").hasAnyRole(rolesAboveManager)
.anyRequest().permitAll()
)
// 추가
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) // CSRF 설정 추가
)
.formLogin(withDefaults())
.logout(logout -> logout.logoutSuccessUrl("/"))
.oauth2Login(withDefaults())
.build();
}
게시글 관리 페이지 컨트롤러 구현
ArticleManagementController는 /management/articles 경로에 대한 요청을 처리하는 역할을 한다. 관리 페이지에서 8080 서버에서 불러온 게시글 목록을 조회하고, 특정 게시글을 가져오거나 삭제하는 기능을 담당한다. 이를 통해 관리자가 게시글을 효율적으로 관리할 수 있도록 한다.
package org.example.projectboardadmin.controller;
import lombok.RequiredArgsConstructor;
import org.example.projectboardadmin.dto.response.ArticleResponse;
import org.example.projectboardadmin.service.ArticleManagementService;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor
@RequestMapping("/management/articles")
@Controller
public class ArticleManagementController {
private final ArticleManagementService articleManagementService;
@GetMapping
public String articles(Model model) {
model.addAttribute(
"articles",
articleManagementService.getArticles().stream().map(ArticleResponse::withoutContent).toList()
);
return "management/articles";
}
@ResponseBody
@GetMapping("/{articleId}")
public ArticleResponse article(@PathVariable Long articleId) {
return ArticleResponse.withContent(articleManagementService.getArticle(articleId));
}
@PostMapping("/{articleId}")
public String deleteArticle(@PathVariable Long articleId) {
articleManagementService.deleteArticle(articleId);
return "redirect:/management/articles";
}
}
- 위 코드에서 articles 메서드는 관리 화면에 표시할 게시글 목록을 제공한다. Model 객체에 게시글 목록을 추가한 후, 타임리프 템플릿 management/articles로 데이터를 전달한다. 이때, 게시글의 본문 내용은 리스트에서 제외된다.
- article 메서드는 특정 게시글의 상세 정보를 JSON 형태로 반환한다. 이 메서드는 주로 JavaScript에서 Ajax 호출로 사용될 수 있다.
- deleteArticle 메서드는 특정 게시글을 삭제한 후, 다시 게시글 목록 페이지로 리다이렉트한다.
서비스 로직 구현
다음은 ArticleManagementService다. 이 서비스는 8080 서버에서 데이터를 가져오는 역할을 한다.
주요 기능은 게시글 목록 조회, 특정 게시글 조회, 게시글 삭제로 구성된다.
package org.example.projectboardadmin.service;
import lombok.RequiredArgsConstructor;
import org.example.projectboardadmin.dto.ArticleDto;
import org.example.projectboardadmin.dto.properties.ProjectProperties;
import org.example.projectboardadmin.dto.response.ArticleClientResponse;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import java.net.URI;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Optional;
@RequiredArgsConstructor
@Service
public class ArticleManagementService {
private final RestTemplate restTemplate;
private final ProjectProperties projectProperties;
public List<ArticleDto> getArticles() {
URI uri = UriComponentsBuilder.fromHttpUrl(projectProperties.board().url() + "/api/articles")
.queryParam("size", 10000) // TODO: 전체 게시글을 가져오기 위해 충분히 큰 사이즈를 전달하는 방식. 불완전하다.
.build()
.toUri();
ArticleClientResponse response = restTemplate.getForObject(uri, ArticleClientResponse.class);
return Optional.ofNullable(response).orElseGet(ArticleClientResponse::empty).articles();
}
public ArticleDto getArticle(Long articleId) {
URI uri = UriComponentsBuilder.fromHttpUrl(projectProperties.board().url() + "/api/articles/" + articleId)
.build()
.toUri();
ArticleDto response = restTemplate.getForObject(uri, ArticleDto.class);
return Optional.ofNullable(response)
.orElseThrow(() -> new NoSuchElementException("게시글이 없습니다 - articleId: " + articleId));
}
public void deleteArticle(Long articleId) {
URI uri = UriComponentsBuilder.fromHttpUrl(projectProperties.board().url() + "/api/articles/" + articleId)
.build()
.toUri();
restTemplate.delete(uri);
}
}
RestTemplate과 ProjectProperties 사용
- 이 서비스는 RestTemplate을 통해 8080 서버의 API를 호출한다.
- 서버 간의 통신을 쉽게 관리하기 위해 ProjectProperties라는 설정 클래스를 따로 두고, 거기에 8080 서버의 URL을 저장해두었다.
- 이렇게 하면 코드에서 직접 URL을 하드코딩하지 않고도 환경 설정을 쉽게 변경할 수 있다.
- 게시글을 모두 불러오기 위해 사이즈를 10,000으로 지정했다. 물론 이 방식은 임시적이며, 추후 더 나은 방식으로 대체할 계획이다.
- 호출 결과를 받아 Optional로 감싸 NPE를 방지했고, 응답이 없을 경우 빈 리스트를 반환하도록 했다.
특정 게시글 조회 (getArticle 메서드)
- REST API를 통해 특정 게시글을 불러와 DTO로 반환한다. 만약 게시글이 없다면 NoSuchElementException을 던지도록 했다.
- 이렇게 하면 게시글이 존재하지 않을 때 명확하게 에러를 확인할 수 있다.
- URI를 동적으로 생성해 해당 게시글을 삭제한다. 이 부분은 RESTful한 API 구조 덕분에 간단하게 처리할 수 있었다.
ArticleResponse DTO 구현
ArticleResponse 클래스는 게시글 데이터를 클라이언트에 전달하기 위한 DTO(Data Transfer Object)다. 이 클래스는 record를 사용해 불변 객체로 정의되어 있으며, JSON 직렬화 시 null 값을 제외하도록 설정되어 있다. 주로 API 응답에 사용된다.
package org.example.projectboardadmin.dto.response;
import com.fasterxml.jackson.annotation.JsonInclude;
import org.example.projectboardadmin.dto.ArticleDto;
import org.example.projectboardadmin.dto.UserAccountDto;
import java.time.LocalDateTime;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ArticleResponse(
Long id,
UserAccountDto userAccount,
String title,
String content,
LocalDateTime createdAt
) {
public static ArticleResponse of(Long id, UserAccountDto userAccount, String title, String content, LocalDateTime createdAt) {
return new ArticleResponse(id, userAccount, title, content, createdAt);
}
public static ArticleResponse withContent(ArticleDto dto) {
return ArticleResponse.of(dto.id(), dto.userAccount(), dto.title(), dto.content(), dto.createdAt());
}
public static ArticleResponse withoutContent(ArticleDto dto) {
return ArticleResponse.of(dto.id(), dto.userAccount(), dto.title(), null, dto.createdAt());
}
}
JsonInclude 설정
- 이 설정은 JSON 직렬화 시 null 값인 필드는 포함하지 않도록 한다. 예를 들어, content 필드가 null이면 JSON 응답에서 해당 필드가 생략된다.
- 이 설정을 통해 불필요한 데이터 전송을 줄이고, 클라이언트가 받는 데이터의 간결성을 유지할 수 있다.
- record는 Java 14에서 도입된 새로운 데이터 클래스로, 불변 객체를 생성하는 데 유용하다.
- 이 클래스를 사용하면 DTO를 생성할 때 필드 정의와 getter, 생성자 등을 간단하게 자동으로 제공받을 수 있다.
정적 팩토리 메서드
- 이 메서드는 ArticleResponse 객체를 생성하는 정적 팩토리 메서드다. 생성자 대신 of 메서드를 사용하는 이유는 더 명확한 의미 전달과 확장성을 확보하기 위해서다. 필요에 따라 여러 가지 오버로딩된 of 메서드를 제공할 수도 있다.
withContent 메서드
- 이 메서드는 ArticleDto를 받아서 content 필드까지 포함된 ArticleResponse 객체를 생성한다.
- 게시글의 전체 내용을 응답에 포함할 때 사용된다.
withoutContent 메서드
- 이 메서드는 ArticleDto를 받아서 content 필드 없이 ArticleResponse 객체를 생성한다.
- 게시글 목록을 보여줄 때, 본문 내용은 제외하고 제목, 작성자 정보 등만을 전달하는 경우에 사용된다.
테스트 코드
컨트롤러 테스트
기존 ArticleManagementControllerTest에 @WithMockUser를 추가하여 보안 설정이 있는 환경에서 테스트를 진행할 수 있도록 수정했다. 이 어노테이션을 사용하면 테스트 환경에서 가상의 사용자를 생성하여 특정 권한을 가진 상태로 요청을 보낼 수 있다.
...
@DisplayName("컨트롤러 - 게시글 관리")
@Import(TestSecurityConfig.class)
@WebMvcTest(ArticleManagementController.class)
class ArticleManagementControllerTest {
private final MockMvc mvc;
@MockBean private ArticleManagementService articleManagementService;
public ArticleManagementControllerTest(@Autowired MockMvc mvc) {
this.mvc = mvc;
}
@WithMockUser(username = "tester", roles = "USER")
@DisplayName("[view][GET] 게시글 관리 페이지 - 정상 호출")
@Test
void givenNothing_whenRequestingArticleManagementView_thenReturnsArticleManagementView() throws Exception {
// Given
given(articleManagementService.getArticles()).willReturn(List.of());
// When & Then
mvc.perform(get("/management/articles"))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
.andExpect(view().name("management/articles"))
.andExpect(model().attribute("articles", List.of()));
then(articleManagementService).should().getArticles();
}
@WithMockUser(username = "tester", roles = "USER")
@DisplayName("[data][GET] 게시글 1개 - 정상 호출")
@Test
void givenArticleId_whenRequestingArticle_thenReturnsArticle() throws Exception {
// Given
Long articleId = 1L;
ArticleDto articleDto = createArticleDto("title", "content");
given(articleManagementService.getArticle(articleId)).willReturn(articleDto);
// When & Then
mvc.perform(get("/management/articles/" + articleId))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.id").value(articleId))
.andExpect(jsonPath("$.title").value(articleDto.title()))
.andExpect(jsonPath("$.content").value(articleDto.content()))
.andExpect(jsonPath("$.userAccount.nickname").value(articleDto.userAccount().nickname()));
then(articleManagementService).should().getArticle(articleId);
}
@WithMockUser(username = "tester", roles = "MANAGER")
@DisplayName("[view][POST] 게시글 삭제 - 정상 호출")
@Test
void givenArticleId_whenRequestingDeletion_thenRedirectsToArticleManagementView() throws Exception {
// Given
Long articleId = 1L;
willDoNothing().given(articleManagementService).deleteArticle(articleId);
// When & Then
mvc.perform(
post("/management/articles/" + articleId)
.with(csrf())
)
.andExpect(status().is3xxRedirection())
.andExpect(view().name("redirect:/management/articles"))
.andExpect(redirectedUrl("/management/articles"));
then(articleManagementService).should().deleteArticle(articleId);
}
private ArticleDto createArticleDto(String title, String content) {
return ArticleDto.of(
1L,
createUserAccountDto(),
title,
content,
null,
LocalDateTime.now(),
"Eunchan",
LocalDateTime.now(),
"Eunchan"
);
}
private UserAccountDto createUserAccountDto() {
return UserAccountDto.of(
"ecTest",
"eunchan-test@email.com",
"eunchan-test",
"test memo"
);
}
}
@WithMockUser 추가
- @WithMockUser 어노테이션을 사용해 테스트 시 가상의 사용자 정보를 설정했다.
- 이 어노테이션을 사용하면 username과 roles를 설정할 수 있어, 특정 권한을 가진 사용자로서 요청을 시뮬레이션할 수 있다.
- 각 테스트에서 적절한 권한을 가진 가상의 사용자를 설정하여, 권한에 따라 접근 가능한지를 검증했다.
[view][GET] 게시글 관리 페이지 - 정상 호출
- 이 테스트는 /management/articles 경로로 GET 요청을 보냈을 때, 게시글 관리 페이지가 정상적으로 호출되는지 확인한다.
- @WithMockUser(username = "tester", roles = "USER")를 통해 USER 권한을 가진 가상의 사용자를 설정했다. 이렇게 함으로써 해당 페이지가 일반 사용자도 접근 가능한지 검증할 수 있다.
[data][GET] 게시글 1개 - 정상 호출
- 이 테스트는 /management/articles/{articleId} 경로로 GET 요청을 보냈을 때, 특정 게시글의 정보를 JSON 형태로 반환하는지 확인한다.
- @WithMockUser(username = "tester", roles = "USER")를 통해 USER 권한을 가진 사용자가 해당 데이터를 정상적으로 조회할 수 있는지 검증한다.
[view][POST] 게시글 삭제 - 정상 호출
- 이 테스트는 /management/articles/{articleId} 경로로 POST 요청을 보내 게시글을 삭제한 후, 관리 페이지로 리다이렉트되는지 확인한다.
- @WithMockUser(username = "tester", roles = "MANAGER")를 통해 MANAGER 권한을 가진 사용자만이 게시글 삭제 기능을 사용할 수 있도록 검증했다.
- CSRF 토큰이 필요한 요청이기 때문에 .with(csrf())를 포함시켜 테스트했다.
타임리프 코드 추가 (article.th.xml)
이 파일은 게시글 관리 페이지의 테이블을 동적으로 구성하기 위해 작성되었다. 테이블의 헤더와 본문을 타임리프를 이용해 서버에서 렌더링하며, 필요한 데이터는 서버에서 제공하는 articles 리스트를 기반으로 출력된다.
<?xml version="1.0"?>
<thlogic>
<attr sel="#layout-head" th:replace="~{layouts/layout-head :: common_head(~{::title}, (~{::link} ?: ~{}))}" />
<attr sel="#layout-header" th:replace="~{layouts/layout-header :: header}" />
<attr sel="#layout-left-aside" th:replace="~{layouts/layout-left-aside :: aside}" />
<attr sel="#layout-main" th:replace="~{layouts/layout-main-table :: common_main_table('게시글 관리', (~{::#main-table} ?: ~{}))}" />
<attr sel="#layout-modal" th:replace="~{layouts/layout-main-table-modal :: .modal}" />
<attr sel="#layout-right-aside" th:replace="~{layouts/layout-right-aside :: aside}" />
<attr sel="#layout-footer" th:replace="~{layouts/layout-footer :: footer}" />
<attr sel="#layout-scripts" th:replace="~{layouts/layout-scripts :: script}" />
//추가
<attr sel="#main-table">
<attr sel="thead/tr">
<attr sel="th[0]" th:text="'ID'" />
<attr sel="th[1]" th:text="'제목'" />
<attr sel="th[2]" th:text="'작성자'" />
<attr sel="th[3]" th:text="'작성일시'" />
</attr>
<attr sel="tbody" th:remove="all-but-first">
<attr sel="tr[0]" th:each="article : ${articles}">
<attr sel="td[0]" th:text="${article.id}" />
<attr sel="td[1]/a" th:text="${article.title}" th:href="@{#}" th:data-id="${article.id}" />
<attr sel="td[2]" th:text="${article.userAccount.nickname}" />
<attr sel="td[3]/time" th:datetime="${article.createdAt}" th:text="${#temporals.format(article.createdAt, 'yyyy-MM-dd HH:mm:ss')}" />
</attr>
</attr>
</attr>
</thlogic>
- 테이블 선택 및 헤더 설정
- sel="#main-table": id="main-table"인 테이블 요소를 선택한다.
- thead/tr 내의 th 요소들을 선택해 "ID", "제목", "작성자", "작성일시" 등의 헤더 텍스트를 설정한다.
- 본문 데이터 동적 설정
- tbody의 tr[0] 행을 기준으로, articles 리스트를 순회하며 각 게시글의 데이터를 동적으로 출력한다.
- th:each="article : ${articles}"를 통해 게시글 리스트를 순회하면서 각 행을 생성한다.
- 각 열의 데이터 설정
- 첫 번째 열(td[0]): 게시글 ID를 출력.
- 두 번째 열(td[1]/a): 게시글 제목을 링크로 출력하며, th:data-id 속성에 게시글 ID를 저장한다.
- 세 번째 열(td[2]): 작성자의 닉네임을 출력.
- 네 번째 열(td[3]/time): 작성일시를 특정 형식(yyyy-MM-dd HH:mm:ss)으로 포맷팅해 출력.
- 불필요한 요소 제거
- th:remove="all-but-first": 템플릿에 남아있는 샘플 데이터를 제거하고 실제 데이터로만 테이블을 채운다.
이제 서버를 실행하여 게시글 관리 페이지를 확인해보면, 아래와 같이 8080 서버의 게시글 데이터를 잘 불러오고, 게시글을 클릭하면 내용이 모달 형식으로 깔끔하게 표시되는 것을 확인할 수 있다.



이번 게시글 관리 페이지 구현에서는 여러 가지 중요한 기능과 개선 사항을 성공적으로 적용했다. 8080 서버의 데이터를 8081 서버에서 효율적으로 불러와 관리할 수 있게 했으며, 게시글 클릭 시 모달을 통해 내용을 쉽게 확인할 수 있도록 사용자 경험을 개선했다. 게시글 조회, 상세 보기, 삭제 기능이 모두 원활하게 동작하여 관리자 페이지로서의 핵심 기능을 충실히 구현했다.
특히 Spring Security 3.x 버전에 맞춰 시큐리티 설정을 업데이트하는 과정에서 어려움이 있었지만, 최종적으로 CSRF 설정을 추가하여 문제를 해결했다. 구체적으로 .csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())) 설정을 추가함으로써 버전 호환성 문제를 극복하고 보안을 강화했다. 이러한 과정을 통해 최신 버전의 프레임워크에 대한 이해도를 높이고, 문제 해결 능력을 향상시킬 수 있었다.
다음 단계로는 댓글 관리 페이지를 구현하여 더욱 완성도 높은 관리 시스템을 구축할 예정이다. 이번 경험을 토대로 앞으로의 개발 과정에서도 버전 호환성과 보안 설정에 더욱 주의를 기울일 것이다.
'BackEnd > Project' 카테고리의 다른 글
[Admin] Ch03. 회원 관리 페이지 구현 (0) | 2024.08.24 |
---|---|
[Admin] Ch03. 댓글 관리 페이지 구현 (0) | 2024.08.23 |
[Admin] Ch03. 게시글 관리 페이지 구현(2) (0) | 2024.08.21 |
[Admin] Ch03. 게시글 관리 페이지 구현(1) (0) | 2024.08.21 |
[Admin] Ch03. 게시글 관리 페이지 구현(1) (0) | 2024.08.21 |