[게시판 프로젝트] ArticleController 코드 뜯어 보기를 통해서, 서비스계층과 레포지토리 DTO간 호출/응답 관계 이해하기

2024. 8. 26. 18:55웹 개발/프로젝트

 

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

}