[게시판 프로젝트] ArticleController 코드 뜯어 보기를 통해서, 서비스계층과 레포지토리 DTO간 호출/응답 관계 이해하기
2024. 8. 26. 18:55ㆍCS/프로젝트
ArticleController의 코드를 뜯어보며,
controller와 service, repository, DTO간
어떻게 호출하고 응답하는지 관계를 알아보자
ArticleController
클래스 선언부
- @RequiredArgsConstructor: final로 선언된 모든 필드를 매개변수로 갖는 생성자를 자동으로 생성해줍니다. 따라서 ArticleService와 PaginationService가 생성자 주입을 통해 주입됩니다.
- @Controller: 이 클래스가 Spring MVC의 컨트롤러로 동작함을 나타냅니다. 웹 요청을 처리하고, 뷰(View)에 데이터를 전달하는 역할을 합니다.
- @RequestMapping("/articles"): 이 컨트롤러 내의 모든 메소드들은 /articles로 시작하는 URL에 매핑됩니다.
필드 선언부
- ArticleService: 게시글 관련 비즈니스 로직을 처리하는 서비스 클래스입니다.
- PaginationService: 페이지네이션 처리를 담당하는 서비스 클래스입니다.
메소드
- articles : 게시글 리스트 페이지/ 정상호출
- article : 게시글 단건(상세) 페이지/정상호출
- searchArticleHashtag : 해시태그로 게시글 검색
- articleForm : 게시글 작성 폼
- postNewArticle : 새로운 게시글 작성
- updateArticleForm : 게시글 수정 폼
- updateArticle : 게시글 수정
- deleteArticle : 게시글 삭제
[GET] 게시글 리스트 페이지 - 정상호출
Page<ArticleResponse> articles
- articleService.searchArticles(searchType, searchValue, pageable)를 호출하여 Page<Article> 객체를 반환받습니다.
- 이 결과를 map(ArticleResponse::from)을 사용하여 ArticleResponse 객체로 변환합니다.
- Page<ArticleResponse>는 페이지네이션된 ArticleResponse 객체의 목록을 포함합니다.
@Transactional(readOnly = true) //게시글을 검색하면, 게시글 페이지를 반환한다.
public Page<ArticleDto> searchArticles(SearchType searchType, String searchKeyword, Pageable pageable) {
//검색어 없이 게시글 검색
if (searchKeyword == null || searchKeyword.isBlank()) {
return articleRepository.findAll(pageable).map(ArticleDto::from);
}
//검색어와 함께 게시글 검색
return switch (searchType) {
case TITLE -> articleRepository.findByTitleContaining(searchKeyword, pageable).map(ArticleDto::from);
case CONTENT -> articleRepository.findByContentContaining(searchKeyword, pageable).map(ArticleDto::from);
case ID ->
articleRepository.findByUserAccount_UserIdContaining(searchKeyword, pageable).map(ArticleDto::from);//Article 안 UserAccount
case NICKNAME ->
articleRepository.findByUserAccount_NicknameContaining(searchKeyword, pageable).map(ArticleDto::from);//Article 안 UserAccount
case HASHTAG -> articleRepository.findByHashtag("#" + searchKeyword, pageable).map(ArticleDto::from);//#자동삽입
};
}
public record ArticleResponse(
Long id,
String title,
String content,
String hashtag,
LocalDateTime createdAt,
String email,
String nickname
) {
public static ArticleResponse of(Long id, String title, String content, String hashtag, LocalDateTime createdAt, String email, String nickname) {
return new ArticleResponse(id, title, content, hashtag, createdAt, email, nickname);
}
public static ArticleResponse from(ArticleDto dto) {
String nickname = dto.userAccountDto().nickname();
if (nickname == null || nickname.isBlank()) { // 닉네임은 optional 이라 null 인 경우 userId 받아오기
nickname = dto.userAccountDto().userId();
}
return new ArticleResponse(
dto.id(),
dto.title(),
dto.content(),
dto.hashtag(),
dto.createdAt(),
dto.userAccountDto().email(),
nickname
);
}
}
List<Integer> barNumbers
- paginationService.getPaginationBarNumbers(pageable.getPageNumber(), articles.getTotalPages())를 호출하여 페이지네이션 바에 표시할 페이지 번호 목록을 계산합니다.
- pageable.getPageNumber()는 현재 페이지 번호를 반환하고, articles.getTotalPages()는 전체 페이지 수를 반환합니다.
@Service
public class PaginationService {
private static final int BAR_LENGTH = 5;
public List<Integer> getPaginationBarNumbers(int currentPageNumber,int totalPages) {//리스트 형태 숫자로 내려줌
int startNumber = Math.max(currentPageNumber - (BAR_LENGTH / 2),0);//currentPageNumber 를 중앙에 오도록 + 음수를 방어하기
int endNumber = Math.min(startNumber + BAR_LENGTH, totalPages);
return IntStream.range(startNumber,endNumber).boxed().toList();
}
public int currentBarLength(){
return BAR_LENGTH;
}
}
[GET] 게시글 단건(상세) 페이지 - 정상호출
ArticleWithCommentsResponse
- articleService.getArticleWithComments(articleId) 메소드를 호출하여 특정 articleId에 해당하는 게시글과 관련된 댓글 정보를 가져옵니다.
- 이 정보를 ArticleWithCommentsResponse 객체로 변환합니다.
- ArticleWithCommentsResponse.from(...)는 Article 객체를 ArticleWithCommentsResponse로 변환하는 정적 팩토리 메소드입니다.
@Transactional(readOnly = true)
public ArticleWithCommentsDto getArticleWithComments(Long articleId) {
//단건조회
return articleRepository.findById(articleId)
.map(ArticleWithCommentsDto::from)
.orElseThrow(() -> new EntityNotFoundException("게시글이 없습니다. - articleId: " + articleId));//게시글 없으면 예외 던짐
}
public record ArticleWithCommentsResponse(
Long id,
String title,
String content,
String hashtag,
LocalDateTime createdAt,
String email,
String nickname,
String userId,
Set<ArticleCommentResponse> articleCommentsResponse
) {
public static ArticleWithCommentsResponse of(Long id, String title, String content, String hashtag, LocalDateTime createdAt, String email, String nickname, String userId, Set<ArticleCommentResponse> articleCommentsResponse) {
return new ArticleWithCommentsResponse(id, title, content, hashtag, createdAt, email, nickname, userId, articleCommentsResponse);
}
public static ArticleWithCommentsResponse of(Long id, String title, String content, String hashtag, LocalDateTime createdAt, String email, String nickname, Set<ArticleCommentResponse> articleCommentsResponse) {
return new ArticleWithCommentsResponse(id, title, content, hashtag, createdAt, email, nickname, null, articleCommentsResponse);
}
public static ArticleWithCommentsResponse from(ArticleWithCommentsDto dto) {
String nickname = dto.userAccountDto().nickname();
if (nickname == null || nickname.isBlank()) {
nickname = dto.userAccountDto().userId();
}
return new ArticleWithCommentsResponse(
dto.id(),
dto.title(),
dto.content(),
dto.hashtag(),
dto.createdAt(),
dto.userAccountDto().email(),
nickname,
dto.userAccountDto().userId(),
dto.articleCommentDtos().stream()
.map(ArticleCommentResponse::from)
.collect(Collectors.toCollection(LinkedHashSet::new))
);
}
}
map.addAttribute("article", article):
- article이라는 이름으로 ArticleWithCommentsResponse 객체를 뷰에 전달합니다. 뷰에서 이 객체를 통해 게시글의 세부 정보를 표시할 수 있습니다.
map.addAttribute("articleComments", article.articleCommentsResponse()):
- articleComments라는 이름으로 게시글에 대한 댓글 리스트를 뷰에 전달합니다. article.articleCommentsResponse()는 해당 게시글에 대한 댓글 목록을 반환합니다. 뷰에서 이 리스트를 통해 댓글들을 표시할 수 있습니다.
public static ArticleWithCommentsResponse from(ArticleWithCommentsDto dto) {
String nickname = dto.userAccountDto().nickname();
if (nickname == null || nickname.isBlank()) {
nickname = dto.userAccountDto().userId();
}
return new ArticleWithCommentsResponse(
dto.id(),
dto.title(),
dto.content(),
dto.hashtag(),
dto.createdAt(),
dto.userAccountDto().email(),
nickname,
dto.userAccountDto().userId(),
dto.articleCommentDtos().stream()
.map(ArticleCommentResponse::from)
.collect(Collectors.toCollection(LinkedHashSet::new))
);
}
map.addAttribute("totalCount", articleService.getArticleCount()):
- totalCount라는 이름으로 전체 게시글의 수를 뷰에 전달합니다. 이 값을 이용해 게시글 목록의 전체 개수 등을 표시할 수 있습니다.
public long getArticleCount() {
return articleRepository.count();
}
map.addAttribute("searchTypeHashtag", SearchType.HASHTAG):
- searchTypeHashtag라는 이름으로 해시태그 검색 타입을 뷰에 전달합니다. 이 값은 뷰에서 해시태그 검색을 위한 링크나 버튼을 만들 때 사용할 수 있습니다.
public enum SearchType {
TITLE("제목"), CONTENT("본문"), ID("유저 ID"), NICKNAME("닉네임"), HASHTAG("해시태그");
@Getter
private final String description;
SearchType(String description) {
this.description = description;
}
}
[GET] 해시태그로 게시글 검색
@RequestParam(required = false) String searchValue:
- URL의 쿼리 파라미터를 메소드의 인자로 받을 수 있게 해줍니다.
- searchValue: 검색하려는 해시태그의 값을 의미합니다. required = false로 설정되어 있어, 이 파라미터가 요청에 포함되지 않아도 오류가 발생하지 않습니다
public enum SearchType {
TITLE("제목"), CONTENT("본문"), ID("유저 ID"), NICKNAME("닉네임"), HASHTAG("해시태그");
@Getter
private final String description;
SearchType(String description) {
this.description = description;
}
}
@PageableDefault
- Pageable 객체의 기본값을 설정합니다.
- size = 10: 한 페이지에 10개의 게시글을 표시합니다.
- sort = "createdAt": 게시글을 생성일자(createdAt)를 기준으로 정렬합니다.
- direction = Sort.Direction.DESC: 내림차순으로 정렬합니다. 최신 게시글이 먼저 표시됩니다.
- Pageable pageable: 페이지네이션을 처리하기 위한 객체로, 페이지 번호와 페이지 크기, 정렬 방식을 포함합니다.
List<String> hashtags = articleService.getHashtags();
- articleService.getHashtags() 메소드를 호출하여 사용 가능한 해시태그 목록을 가져옵니다. 이 리스트는 검색 페이지에 표시되어, 사용자가 쉽게 다른 해시태그를 선택할 수 있게 합니다
public List<String> getHashtags() {
return articleRepository.findAllDistinctHashtags();
}
[GET] 게시글 작성 폼
public enum FormStatus {
CREATE("저장", false),
UPDATE("수정", true);
@Getter private final String description;
@Getter
private final Boolean update;
FormStatus(String description, Boolean update) {
this.description = description;
this.update = update;
}
}
- @GetMapping("/form"): /articles/form로 접속하면 호출됩니다.
- map.addAttribute("formStatus", FormStatus.CREATE): 폼 상태를 "CREATE"로 설정하여 뷰에 전달합니다.
- return "articles/form";: articles/form이라는 뷰를 반환합니다. 이 뷰는 게시글 작성 폼입니다.
[POST] 새로운 게시글 작성
@PostMapping("/form")
- HTTP POST 요청을 처리하는 메소드임을 나타냅니다.
- /articles/form 경로로 들어오는 POST 요청이 있을 때 이 메소드가 호출됩니다.
- 이 요청은 일반적으로 사용자가 게시글 작성 폼을 제출할 때 발생합니다.
public String postNewArticle(ArticleRequest articleRequest, @AuthenticationPrincipal BoardPrincipal boardPrincipal) {
- public String postNewArticle(...)
- 이 메소드는 String 타입을 반환하며, 뷰(View)의 이름이나 리다이렉트 경로를 의미합니다.
- ArticleRequest articleRequest:
- 이 매개변수는 클라이언트(즉, 웹 페이지 폼)로부터 전달된 게시글 작성 데이터를 받아옵니다.
- ArticleRequest는 주로 폼 데이터와 매핑되는 객체입니다.
- @AuthenticationPrincipal BoardPrincipal boardPrincipal
- 이 매개변수는 현재 인증된 사용자의 정보를 담고 있는 객체입니다.
- Spring Security를 통해 로그인한 사용자의 세부 정보를 가져옵니다.
public record ArticleRequest(
String title,
String content,
String hashtag
) {
public static ArticleRequest of(String title, String content, String hashtag) {
return new ArticleRequest(title, content, hashtag);
}
public ArticleDto toDto(UserAccountDto userAccountDto) {
return ArticleDto.of(
userAccountDto,
title,
content,
hashtag
);
}
}
public record BoardPrincipal(
String username,
String password,
Collection<? extends GrantedAuthority> authorities,
String email,
String nickname,
String memo
) implements UserDetails {
public static BoardPrincipal of(String username, String password, String email, String nickname, String memo) {
Set<RoleType> roleTypes = Set.of(RoleType.USER);
return new BoardPrincipal(
username,
password,
roleTypes.stream()
.map(RoleType::getName)
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toUnmodifiableSet()),
email,
nickname,
memo
);
}
public static BoardPrincipal from(UserAccountDto dto){
return BoardPrincipal.of(
dto.userId(),
dto.userPassword(),
dto.email(),
dto.nickname(),
dto.memo()
);
}
public UserAccountDto toDto(){
return UserAccountDto.of(
username,
password,
email,
nickname,
memo
);
}
@Override public String getUsername() {return username;}
@Override public String getPassword() {return password;}
@Override public Collection<? extends GrantedAuthority> getAuthorities() {return authorities;}
@Override public boolean isAccountNonExpired() {return true;}
@Override public boolean isAccountNonLocked() {return true;}
@Override public boolean isCredentialsNonExpired() {return true;}
@Override public boolean isEnabled() {return true;}
public enum RoleType{
USER("ROLE_USER");
@Getter private final String name;
RoleType(String name) {
this.name = name;
}
}
}
articleService.saveArticle(articleRequest.toDto(boardPrincipal.toDto()));
- articleService.saveArticle(...): 실제로 게시글을 저장하는 비즈니스 로직을 수행합니다.
- articleRequest.toDto(boardPrincipal.toDto()):
- articleRequest.toDto(...): ArticleRequest 객체를 ArticleDto로 변환합니다. DTO(Data Transfer Object)는 주로 데이터 계층 간 전송에 사용됩니다.
- boardPrincipal.toDto(): BoardPrincipal 객체를 BoardPrincipalDto로 변환합니다. 이를 통해 현재 로그인한 사용자의 정보를 게시글 작성 데이터와 함께 저장할 수 있습니다.
- 결국, articleRequest.toDto(boardPrincipal.toDto())는 작성된 게시글 정보와 함께 작성자의 정보를 DTO 객체로 묶어 saveArticle 메소드에 전달합니다.
- articleService.saveArticle(...): ArticleService에서 이 DTO를 사용하여 새 게시글을 데이터베이스에 저장합니다.
public void saveArticle(ArticleDto dto) {
UserAccount userAccount = userAccountRepository.getReferenceById(dto.userAccountDto().userId());
articleRepository.save(dto.toEntity(userAccount));
}
return "redirect:/articles";
- return "redirect:/articles";: 이 줄은 게시글이 성공적으로 저장된 후, 사용자를 게시글 목록 페이지로 리다이렉트(redirect)합니다.
- "redirect:/articles": 이 문자열은 Spring MVC에서 리다이렉트를 나타냅니다. 사용자가 /articles 경로로 다시 이동하게 되며, 게시글 목록이 표시됩니다.
[GET] 게시글 수정 폼
- @GetMapping("/{articleId}/form"): /articles/{articleId}/form으로 접속하면 호출됩니다.
- @PathVariable Long articleId: URL에서 articleId를 받아옵니다.
- map.addAttribute("article", article): 게시글 데이터를 뷰에 전달합니다.
- map.addAttribute("formStatus", FormStatus.UPDATE): 폼 상태를 "UPDATE"로 설정하여 뷰에 전달합니다.
- return "articles/form";: articles/form이라는 뷰를 반환합니다. 이 뷰는 게시글 수정 폼입니다.
[POST] 게시글 수정
public void updateArticle(Long articleId, ArticleDto dto) {
try {
Article article = articleRepository.getReferenceById(articleId);
UserAccount userAccount = userAccountRepository.getReferenceById(dto.userAccountDto().userId());
if(article.getUserAccount().equals(userAccount)) {
//인증된 사용자와 게시글의 사용자가 동일한지 검사
if (dto.title() != null) {
article.setTitle(dto.title());
}
if (dto.content() != null) {
article.setContent(dto.content());
}
article.setHashtag(dto.hashtag());
}
} catch (EntityNotFoundException e) {
log.warn("게시판 업데이트 실패, 게시글을 수정하는데 필요한 정보를 찾을 수 없습니다. - {}", e.getLocalizedMessage());
}
}
[POST] 게시글 삭제
public void deleteArticle(long articleId, String userId) {
articleRepository.deleteByIdAndUserAccount_UserId(articleId,userId);
}
'CS > 프로젝트' 카테고리의 다른 글
[게시판 프로젝트] 페이지네이션(Pagination) 구현 (0) | 2024.08.26 |
---|---|
[게시판 프로젝트] ArticleRequest와 ArticleResponse를 사용하는 상황의 차이 이해하기, DTO를 따로 만들어 사용하는 이유 (0) | 2024.08.26 |
[게시판 프로젝트] 뷰 구현(3) - Boostrap 프레임워크 라이브러리 사용해서 CSS 적용하기 (0) | 2024.08.17 |
[게시판 프로젝트] 뷰 구현(2) - Spring Security를 사용하여 로그인 페이지 구현 (0) | 2024.08.17 |
[게시판 프로젝트] 뷰 구현(1) - 타임리프를 사용하여 게시판/게시글 페이지 구현 (0) | 2024.08.17 |