[Spring] 스프링 부트를 이용한 게시판 프로젝트 - Service, Controller

전주은·2023년 1월 12일
0
post-thumbnail

Service 만들기

👀들어가기전에..

한번 더 복습해보겠습니다! 어떤 서비스를 만들어야할지 알아야하니까요!

1.요구사항 분석

  • 게시글 목록을 보여줄 수 있는 페이지
  • 게시글을 작성할 수 있는 페이지
  • 게시글 상세 정보를 보여줄 수 있는 페이지
  • 게시글 수정, 삭제를 할 수 있는 페이지
  • 댓글을 작성, 수정, 삭제할 수 있는 기능
  • 게시글과 댓글에 대한 검색 기능

2.데이터 모델링

UserAccount : 사용자 정보를 저장하는 엔티티
Article : 게시글 정보를 저장하는 엔티티
ArticleComment : 게시글의 댓글 정보를 저장하는 엔티티

3.API 설계( 오늘 하는 것)

GET /articles : 게시글 목록을 보여주는 API
POST /articles : 게시글을 작성하는 API
GET /articles/{id} : 게시글 상세 정보를 보여주는 API
PUT /articles/{id} : 게시글을 수정하는 API
DELETE /articles/{id} : 게시글을 삭제하는 API
POST /articles/{id}/comments : 게시글에 댓글을 작성하는 API
PUT /comments/{id} : 댓글을 수정하는 API
DELETE /comments/{id} : 댓글을 삭제하는 API
GET /search : 검색 기능을 제공하는 API

4.데이터베이스 구현

UserAccount, Article, ArticleComment 엔티티를 각각의 테이블로 생성하고, 필요한 컬럼들을 정의합니다.

5.비즈니스 로직 구현( 오늘 하는 것)

요구사항에 맞게 API를 구현합니다.
필요한 서비스 클래스와 레포지토리 클래스를 생성하여 로직을 구현합니다.

Service

ArticleService

@Slf4j
@Transactional
@RequiredArgsConstructor
@Service
public class ArticleService {
    private final ArticleRepository articleRepository;
    private final UserAccountRepository userAccountRepository;

    @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);
            case NICKNAME -> articleRepository.findByUserAccount_NicknameContaining(searchKeyword, pageable).map(ArticleDto::from);
            case HASHTAG -> articleRepository.findByHashtag(searchKeyword, pageable).map(ArticleDto::from);
        };
    }

    @Transactional(readOnly = true)
    public ArticleWithCommentsDto getArticleWithComments(Long articleId){
        return articleRepository.findById(articleId)
                .map(ArticleWithCommentsDto::from)
                .orElseThrow(() -> new EntityNotFoundException("게시글이 없습니다 - articleId: " + articleId));
    }

    @Transactional(readOnly = true)
    public ArticleDto getArticle(Long articleId){
        return articleRepository.findById(articleId)
                .map(ArticleDto::from)
                .orElseThrow(() -> new EntityNotFoundException("게시글이 없습니다 - articleId: " + articleId));
    }

    public void saveArticle(ArticleDto dto){
        UserAccount userAccount = userAccountRepository.getReferenceById(dto.userAccountDto().userId());
        articleRepository.save(dto.toEntity(userAccount));
    }

    public void updateArticle(Long articleId, ArticleDto dto){
        try{
            Article article = articleRepository.getReferenceById(articleId);
            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("게시글 업데이트 실패. 게시글을 찾을 수 없음 - dto: {}", dto);
        }
    }

    public void deleteArticle(Long articleId){
        articleRepository.deleteById(articleId);
    }

    public long getArticleCount(){
        return articleRepository.count();
    }

    @Transactional(readOnly = true)
    public Page<ArticleDto> searchArticleViaHashTag(String hashtag, Pageable pageable){
        if(hashtag == null || hashtag.isBlank()){
            return Page.empty(pageable);
        }
        return articleRepository.findByHashtag(hashtag, pageable).map(ArticleDto::from);
    }

    public List<String> getHashtags(){
        return articleRepository.findAllDistinctHashtags();
    }
}

searchArticles 메소드: 검색 조건(SearchType)과 검색어(searchKeyword)를 받아서, 해당 조건에 맞는 게시글 목록을 페이징해서 반환하는 메소드입니다. 검색어가 없을 경우에는 모든 게시글 목록을 페이징해서 반환합니다.

getArticleWithComments 메소드: 게시글 ID(articleId)를 받아서, 해당 ID에 해당하는 게시글 정보와 그 게시글에 달린 댓글 목록을 함께 반환하는 메소드입니다.

getArticle 메소드: 게시글 ID(articleId)를 받아서, 해당 ID에 해당하는 게시글 정보를 반환하는 메소드입니다.

saveArticle 메소드: 새로운 게시글 정보를 생성해서 저장하는 메소드입니다.

updateArticle 메소드: 게시글 ID(articleId)와 수정할 게시글 정보(dto)를 받아서, 해당 ID에 해당하는 게시글 정보를 찾아서 수정하는 메소드입니다.

deleteArticle 메소드: 게시글 ID(articleId)를 받아서, 해당 ID에 해당하는 게시글 정보를 삭제하는 메소드입니다.

getArticleCount 메소드: 전체 게시글 수를 반환하는 메소드입니다.

searchArticleViaHashTag 메소드: 해시태그(hashtag)를 받아서, 해당 태그를 포함하는 게시글 목록을 페이징해서 반환하는 메소드입니다.

getHashtags 메소드: 저장된 모든 게시글에서 사용된 해시태그 목록을 반환하는 메소드입니다.

PaginationService

@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);
        int endNumber = Math.min(startNumber + BAR_LENGTH, totalPages);
        return IntStream.range(startNumber, endNumber).boxed().toList();
    }

    public int currentBarLength(){
        return BAR_LENGTH;
    }
}

이 코드는 페이지 번호를 생성하여 페이지 링크를 표시하는 페이징 기능을 구현하는 데 사용되는 서비스 클래스입니다.

getPaginationBarNumbers 메소드는 현재 페이지 번호와 총 페이지 수를 인수로 받아서 페이징 바에 표시될 페이지 번호 리스트를 생성합니다. BAR_LENGTH는 페이징 바에 표시될 페이지 번호의 개수를 의미합니다. currentPageNumber와 totalPages가 주어지면, 현재 페이지를 중심으로 BAR_LENGTH만큼의 페이지 번호를 생성하되, 0보다 작거나 totalPages를 초과하는 숫자는 제외합니다. 마지막으로 생성된 페이지 번호 리스트를 반환합니다.

currentBarLength 메소드는 BAR_LENGTH 값을 반환합니다. 이 값은 페이징 바에 표시될 페이지 번호의 개수를 의미합니다.

ArticleCommentService

@RequiredArgsConstructor
@Transactional
@Service
@Slf4j
public class ArticleCommentService {
    private final ArticleRepository articleRepository;
    private final ArticleCommentRepository articleCommentRepository;
    private final UserAccountRepository userAccountRepository;

    @Transactional(readOnly = true)
    public List<ArticleCommentDto> searchArticleComments(Long articleId){
        return List.of();
    }

    public void saveArticleComment(ArticleCommentDto dto){
        Article article = articleRepository.getReferenceById(dto.articleId());
        UserAccount userAccount = userAccountRepository.getReferenceById(dto.userAccountDto().userId());
        articleCommentRepository.save(dto.toEntity(article, userAccount));
    }

    public void updateArticleComment(ArticleCommentDto dto){
        try{
            Article article = articleRepository.getReferenceById(dto.articleId());
            articleCommentRepository.findById(dto.id())
                    .ifPresent(comment -> {
                        comment.setContent(dto.content());
                        comment.setArticle(article);
                        articleCommentRepository.save(comment);
                    });
        }catch(EntityNotFoundException e){
            log.warn("게시글 댓글 업데이트 실패. 게시글을 찾을 수 없음 - dto: {}", dto);
        }
    }

    public void deleteArticleComment(Long articleCommentId){
        try{
            articleCommentRepository.deleteById(articleCommentId);
        }catch(EntityNotFoundException e){
            log.warn("게시글 댓글 삭제 실패. 댓글을 찾을 수 없음 - articleCommentId: {}", articleCommentId);
        }
    }

}

해당 코드는 댓글 관련 기능을 처리하는 ArticleCommentService 클래스입니다.

ArticleCommentService 클래스는 @Service 어노테이션이 선언되어 있으며, @RequiredArgsConstructor 어노테이션을 통해 생성자 주입 방식으로 ArticleRepository, ArticleCommentRepository, UserAccountRepository 인스턴스를 주입받습니다. 이 클래스에서 제공하는 메서드는 다음과 같습니다.

searchArticleComments :해당 게시글에 등록된 댓글을 조회합니다.
@Transactional(readOnly = true) 어노테이션을 통해 해당 메서드의 트랜잭션은 읽기 전용입니다.

saveArticleComment :새로운 댓글을 저장합니다.
ArticleCommentDto 객체를 인자로 받아서 해당 DTO를 ArticleComment 엔티티로 변환하고, 이를 ArticleCommentRepository를 통해 DB에 저장합니다.

updateArticleComment :기존 댓글을 업데이트합니다.
ArticleCommentDto 객체를 인자로 받아서 해당 DTO의 ID값을 통해 DB에서 댓글 엔티티를 조회하고, DTO의 정보를 엔티티에 반영합니다.

deleteArticleComment :댓글을 삭제합니다.
삭제할 댓글의 ID를 인자로 받아서 해당 ID의 댓글 엔티티를 DB에서 조회하고, 이를 articleCommentRepository를 통해 삭제합니다.

모든 메서드는 @Transactional 어노테이션을 통해 트랜잭션 처리가 되며, updateArticleComment 메서드와 deleteArticleComment 메서드는 댓글 엔티티가 존재하지 않을 경우 EntityNotFoundException을 처리합니다. 또한, 해당 클래스에서는 @Slf4j 어노테이션을 통해 로그를 기록합니다.

Controller

ArticleController


@RequestMapping("articles")
@Controller
@RequiredArgsConstructor
public class ArticleController {

    private final ArticleService articleService;
    private final PaginationService paginationService;

    @GetMapping
    public String articles(
            @RequestParam(required = false)SearchType searchType,
            @RequestParam(required = false)String searchValue,
            @PageableDefault(size = 10, sort = "id", direction = Sort.Direction.DESC) Pageable pageable, ModelMap map
    ){
//        map.addAttribute("articles", articleService.searchArticles(searchType, searchValue, pageable).map(ArticleResponse::from));
        Page<ArticleResponse> articles = articleService.searchArticles(searchType, searchValue, pageable).map(ArticleResponse::from);
        List<Integer> barNumbers = paginationService.getPaginationBarNumbers(pageable.getPageNumber(), articles.getTotalPages());
        map.addAttribute("articles", articles);
        map.addAttribute("paginationBarNumbers", barNumbers);
        map.addAttribute("searchTypes", SearchType.values());
        return "articles/index";
    }

    @GetMapping("/{articleId}")
    public String article(@PathVariable Long articleId, ModelMap map){
        ArticleWithCommentResponse article = ArticleWithCommentResponse.from(articleService.getArticleWithComments(articleId));
        map.addAttribute("article", article);
        map.addAttribute("articleComments", article.articleCommentsResponses());
        map.addAttribute("totalCount", articleService.getArticleCount());
        return "articles/detail";
    }

    @GetMapping("/search-hashtag")
    public String searchArticleHashtag(
            @RequestParam(required = false)String searchValue,
            @PageableDefault(size = 10, sort = "id", direction = Sort.Direction.DESC) Pageable pageable, ModelMap map
    ){
        Page<ArticleResponse> articles = articleService.searchArticleViaHashTag(searchValue, pageable).map(ArticleResponse::from);
        List<Integer> barNumbers = paginationService.getPaginationBarNumbers(pageable.getPageNumber(), articles.getTotalPages());
        List<String> hashTags = articleService.getHashtags();

        map.addAttribute("articles", articles);
        map.addAttribute("hashtags", hashTags);
        map.addAttribute("paginationBarNumbers", barNumbers);
        map.addAttribute("searchTypes", SearchType.values());
        return "articles/search-hashtag";
    }

    @GetMapping("form")
    public String articleForm(ModelMap map){
        map.addAttribute("formStatus", FormStatus.CREATE);
        return "articles/form";
    }

    @PostMapping ("/form")
    public String postNewArticle(@AuthenticationPrincipal BoardPrincipal boardPrincipal,
                                 ArticleRequest articleRequest
    ) {
        articleService.saveArticle(articleRequest.toDto(boardPrincipal.toDto()));
        //                                              -------ueraccountdto---
        //                        ----------저 애가쓴 article--------------------
        return "redirect:/articles";
    }

    @GetMapping("/{articleId}/form")
    public String updateArticleForm(@PathVariable Long articleId, ModelMap map){
        ArticleResponse article = ArticleResponse.from(articleService.getArticle(articleId));
        map.addAttribute("article", article);
        map.addAttribute("formStatus", FormStatus.UPDATE);
        return "articles/form";
    }

    @PostMapping("/{articleId}/delete")
    public String deleteArticle(@PathVariable Long articleId){
        articleService.deleteArticle(articleId);
        return "redirect:/articles";
    }
}

이 코드는 게시글(Article) 관련 기능을 처리하는 Spring MVC의 컨트롤러(Controller)입니다.

GET 요청으로 "/articles" 주소를 받으면 게시글 목록을 화면에 출력해주고, 게시글 검색 기능을 제공합니다.
또한 "/articles/{articleId}" 주소로 GET 요청을 받으면 해당 게시글과 댓글을 화면에 출력하고, 게시글 수정을 위한 폼도 제공합니다.

GET 요청으로 "/articles/search-hashtag" 주소를 받으면 해시태그 검색을 통해 게시글 목록을 화면에 출력해주고, 해시태그 목록도 함께 제공합니다.

POST 요청으로 "/articles/form" 주소를 받으면 새로운 게시글을 작성하여 DB에 저장하고, 작성한 게시글을 화면에 출력합니다.
또한, "/articles/{articleId}/form" 주소로 GET 요청을 받으면 해당 게시글을 수정할 수 있는 폼을 제공하며, POST 요청으로 "/articles/{articleId}/delete" 주소를 받으면 해당 게시글을 삭제합니다.

컨트롤러에서는 ArticleService와 PaginationService를 사용하여 데이터를 처리합니다. 이 코드는 또한 Lombok 어노테이션을 사용하여 코드의 가독성을 높이고, RequiredArgsConstructor 어노테이션을 통해 생성자를 자동으로 생성합니다.

ArticleCommentController


@RequiredArgsConstructor
@RequestMapping("/comments")
@Controller
public class ArticleCommentController {
    private final ArticleCommentService articleCommentService;

    @PostMapping("/new")
    public String postNewArticleComment(ArticleCommentRequest articleCommentRequest) {
        articleCommentService.saveArticleComment(articleCommentRequest.toDto(UserAccountDto.of(
                "banana", "2222", "banana@banana.com", null, null
        )));
        return "redirect:/articles/" + articleCommentRequest.articleId();
    }

    @PostMapping ("/{commentId}/delete")
    public String deleteArticleComment(@PathVariable Long commentId, Long articleId) {
        articleCommentService.deleteArticleComment(commentId);
        return "redirect:/articles/" + articleId;
    }
}

이 코드는 댓글을 처리하는 ArticleCommentController 클래스입니다.

이 컨트롤러는 댓글의 작성과 삭제를 처리합니다.

'postNewArticleComment' 메소드는 새로운 댓글을 작성합니다. 사용자가 작성한 댓글 내용을 담고 있는 ArticleCommentRequest 객체를 인자로 받습니다.

댓글의 작성자 정보는 미리 정해진 UserAccountDto 객체로 설정되어 있으며, 여기에서는 사용자 이름, 비밀번호, 이메일 주소만 넣어서 생성됩니다.

그런 다음 articleCommentService 객체의 saveArticleComment 메소드를 호출하여 새로운 댓글을 저장합니다. 마지막으로 댓글을 단 게시물의 상세 페이지로 리다이렉트합니다.

'deleteArticleComment' 메소드는 댓글을 삭제합니다. 삭제할 댓글의 ID와 삭제 후 이동할 게시물의 ID를 인자로 받습니다. articleCommentService 객체의 deleteArticleComment 메소드를 호출하여 댓글을 삭제합니다. 마지막으로 게시물의 상세 페이지로 리다이렉트합니다.

추가된 enum class

수정인지 작성인지 구분해줄 enum class

FormStatus


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;
    }
}

SearchType

검색한 타입이 어떤 것인지 구분해줄 enum class


public enum SearchType {
    TITLE("제목"),
    CONTENT("본문"),
    ID("유저ID"),
    NICKNAME("닉네임"),
    HASHTAG("해시태그");

    @Getter private final String description;

    SearchType(String description){
        this.description = description;
    }
}

0개의 댓글