TIL

day46 TIL

dalooong 2023. 7. 9. 13:48

23.06.22

Spring Boot로 RESTful 구현

 

종속된 자원 표현하기

이제 Article 서비스를 RESTful하게 구현해 보았습니다. 그런데 만약 저희가 이 게시글 서비스에 댓글을 달고 싶다면 어떻게 해야 할까요?

URL & Method 구성

기본적으로 댓글이라는 데이터는 어떤 게시글에 속해 있을 수 밖에 없습니다. 그러면 저희가 만들고자 하는 댓글을 달기 위한 URL도 거기에 맞춰서 작성해줘야 합니다.

즉 어떤 게시글인지에 대한 URL인 /articles/{ariticleId} 부터 시작해서, 해당 자원이 가지고 있는 댓글이라는 의미의 /comments 를 추가해서 URL을 구성해줄 수 있습니다.

  • /articles/{articleId}/comments : PK가 aritcleId 인 게시글의 댓글에 대한 작업을 위한 URL
  • /articles/{articleId}/comments/{commentId} : PK가 aritcleId 인 게시글의 댓글 중 PK가 commentId 인 댓글에 대한 작업을 위한 URL

여기에 본래 CRUD를 위해 사용하던 Method를 합쳐서 구현해 봅시다.

  • 게시글에 댓글 추가: POST /articles/{articleId}/comments
  • 게시글 댓글 전체 조회: GET /articles/{articleId}/comments
  • 게시글 댓글 수정: PUT /articles/{articleId}/comments/{commentId}
  • 게시글 댓글 삭제: DELETE /articles/{articleId}/comments/{commentId}

댓글 단일 조회 기능은 추가하지 않았습니다.

CommentEntity & CommentDto

아직 엔티티와 엔티티 간의 관계를 표현하는 법을 배우지 않았기 때문에, 비록 JPA를 사용중이지만 엔티티에는 일반적인 관계형 데이터베이스 처럼 article_id 를 나타내는 필드를 작성해서 사용합시다.

댓글 기능 구현하기

CommentController의 기능

CommentController

ArticleController 에 메소드를 추가해도 되지만, 점점 메소드가 많아지기 시작함으로, 다른 클래스를 만들고 거기에서 댓글 관련된 URL만 따로 구성할 수 있습니다.

  • Magic Number Seven  The Magical Number Seven, Plus or Minus Two
  • 인간은 7(±2)개의 항목에 대해서만 효과적으로 처리할 수 있다는 인지심리학자 조지 A 밀러의 논문입니다. 개발자들은 여기에서 메소드나 클래스를 작성하는데 있어서 매개변수나 필드를 일곱개 이하로 유지하고자 하는 의견을 제시합니다.

이때 기본적으로 모든 요청 URL이 /articles/{articleId} 로 시작하는 만큼, @RequestMapping 을 클래스에 추가할 때 해당 내용을 반영할 수 있습니다. 만약 내부 메소드에서 articleId 를 활용해야 하는 상황이 온다면, 평소에 하던데로 @PathVariable 어노테이션을 활용하면 됩니다.

CommentController 전체코드

@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/articles/{articleId}/comments")
public class CommentController {
    private final CommentService service;

    @PostMapping
    public CommentDto create(
            @PathVariable("articleId") Long articleId,
            @RequestBody CommentDto dto
    ) {
        return service.createComment(articleId, dto);
    }

    @GetMapping
    public List<CommentDto> readAll(
            @PathVariable("articleId") Long articleId
    ) {
        return service.readCommentAll(articleId);
    }

    @PutMapping("/{commentId}")
    public CommentDto update(
            @PathVariable("articleId") Long articleId,
            @PathVariable("commentId") Long commentId,
            @RequestBody CommentDto dto
    ) {
        return service.updateComment(articleId, commentId, dto);
    }

    @DeleteMapping("/{commentId}")
    public void delete(
            @PathVariable("articleId") Long articleId,
            @PathVariable("commentId") Long commentId
    ) {
        service.deleteComment(articleId, commentId);
    }
}

1️⃣ 게시글 작성 컨트롤러 ✅

//1. 게시글 작성
    //POST /articles/{articlesId}/comments
    @PostMapping
    public CommentDto create(
            @PathVariable("articleId") Long articleId,
            @RequestBody CommentDto dto
    ) {
        return service.createCommen//TODO 3. 게시글 댓글 수정
    //PUT /articles/{articleId}/comments/{commentsId}

    @PutMapping("/{commentId}")
    public CommentDto update(
            @PathVariable("articleId") Long articleId,
            @PathVariable("commentId") Long commentId,
            @RequestBody CommentDto dto
    ) {
        return service.updateComment(articleId, commentId, dto);
    }(articleId, dto);
    }

2️⃣ 게시글 댓글 전체 조회 컨트롤러

//TODO 2. 게시글 댓글 전체 조회
    //GET /articles/{articleId}/comments
    //반환값 인자 및 필요 어노테이션
    @GetMapping()
    public List<CommentDto> readAll(
            @PathVariable("articleId") Long articleId
    ) {
        return service.readCommentAll(articleId);
    }

3️⃣ 게시글 댓글 수정 컨트롤러

//TODO 3. 게시글 댓글 수정
    //PUT /articles/{articleId}/comments/{commentsId}
    @PutMapping("/{commentId}")
    public CommentDto update(
            @PathVariable("articleId") Long articleId,
            @PathVariable("commentId") Long commentId,
            @RequestBody CommentDto dto
    ) {
        return service.updateComment(articleId, commentId, dto);
    }

4️⃣ 게시글 댓글 삭제 컨트롤러

//TODO 4. 게시글 댓글 삭제
    //DELETE/articles/{articleId}/comments/{commentsId}
    @DeleteMapping("/{commentId")
    public void delete(
            @PathVariable("articleId") Long articleId,
            @PathVariable("commentId") Long commentId,
    ){
        service.deleteComment(articleId, commentId);
    }

컨트롤러의 이름은 CRUD 기능 이름으로 한다.

 

 

CommentService 구현

CommentService

댓글을 달기 위한 CommentService 를 구현합니다. 이때 전달받은 articleId 의 값도 활용해서 기능을 구성해 봅시다.

  • create : 작성 대상 게시글을 지정하는 용도로 활용할 수 있습니다.
  • readAll : 게시글을 기준으로 모든 댓글을 가져올 수 있습니다.
  • update : 수정하고자 하는 댓글이 등록된 대상 article의 PK와 전달받은 articleId 가 다를 경우, 수정을 거부할 수 있습니다.
  • delete : 삭제하고자 하는 댓글이 등록된 대상 article의 PK와 전달받은 articleId 가 다를 경우, 삭제를 거부할 수 있습니다.

이를 염두에 두면서 CommentService 를 구현해 봅시다. 이때 상황에 따라서 ArticleEntity 의 존재 유무를 판단해야 할 수 있기 때문에, ArticleRepository 도 CommentService 에서 함께 사용합니다.

CommentService 전체코드

@Slf4j
@Service
@RequiredArgsConstructor
public class CommentService {
    private final ArticleRepository articleRepository;
    private final CommentRepository commentRepository;

    public CommentDto createComment(Long articleId, CommentDto dto) {
        Optional<ArticleEntity> optionalArticle
                = articleRepository.findById(articleId);
        if (optionalArticle.isEmpty())
            throw new ResponseStatusException(HttpStatus.NOT_FOUND);
        CommentEntity newComment = new CommentEntity();
        newComment.setWriter(dto.getWriter());
        newComment.setContent(dto.getContent());
        newComment.setArticleId(optionalArticle.get().getId());
        return CommentDto.fromEntity(commentRepository.save(newComment));
    }

    public List<CommentDto> readCommentAll(Long articleId) {
        List<CommentDto> commentList = new ArrayList<>();
        for (CommentEntity entity: commentRepository.findAllByArticleId(articleId)) {
            commentList.add(CommentDto.fromEntity(entity));
        }
        return commentList;
    }

    public CommentDto updateComment(Long articleId, Long commentId, CommentDto dto) {
        Optional<CommentEntity> optionalComment
                = commentRepository.findById(commentId);
        if (optionalComment.isEmpty())
            throw new ResponseStatusException(HttpStatus.NOT_FOUND);
        CommentEntity comment = optionalComment.get();
        if (!articleId.equals(comment.getArticleId()))
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST);

        comment.setContent(dto.getContent());
        comment.setWriter(dto.getWriter());
        return CommentDto.fromEntity(commentRepository.save(comment));
    }

    public void deleteComment(Long articleId, Long commentId) {
        Optional<CommentEntity> optionalComment
                = commentRepository.findById(commentId);
        if (optionalComment.isEmpty())
            throw new ResponseStatusException(HttpStatus.NOT_FOUND);
        CommentEntity comment = optionalComment.get();
        if (!articleId.equals(comment.getArticleId()))
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST);

        commentRepository.deleteById(commentId);
    }
}

1️⃣ 게시글 작성 서비스 ✅

//1. 게시글 작성
public CommentDto createComment(Long articleId, CommentDto dto){
        //articleId를 ID를 가진 ArticleEntity가 존재 하는지?
        if (!articleRepository.existsById(articleId))
            //찾지 못하면 404
            throw new ResponseStatusException(HttpStatus.NOT_FOUND);

        CommentEntity newComment = new CommentEntity();
        newComment.setWriter(dto.getWriter());
        newComment.setArticleId(dto.getArticleId());
        newComment.setContent(dto.getContent());
        newComment = commentRepository.save(newComment);
        return CommentDto.fromEntity(newComment);
    }

2️⃣ 게시글 댓글 전체 조회 서비스

//TODO 게시글 댓글 전체 조회 GET
    //반환 타입 이름 인자
    public List<CommentDto> readCommentAll(Long articleId){
        List<CommentEntity> commentEntities = commentRepository.findAllByArticleId(articleId);
        List<CommentDto> commentList = new ArrayList<>();
        for (CommentEntity entity : commentEntities) {
            commentList.add(CommentDto.fromEntity(entity));
        }
        return commentList;

    }

3️⃣ 게시글 댓글 수정 서비스

//  TODO 게시글 댓글 수정 PUT
    // 수정하고자 하는 댓글이 지정한 게시글에 있는지 확인할 목적으로
    // articleId도 첨부한다.
    public CommentDto updateComment(
            Long articleId,
            Long commentId,
            CommentDto dto
    ) {
        // 요청한 댓글이 존재하는지
        Optional<CommentEntity> optionalComment
                = commentRepository.findById(commentId);
        // 존재하지 않으면 예외 발생
        if (optionalComment.isEmpty())
            throw new ResponseStatusException(HttpStatus.NOT_FOUND);

        // 아니면 로직 진행
        CommentEntity comment = optionalComment.get();

        // 대상 댓글이 대상 게시글의 댓글이 맞는지
        if (!articleId.equals(comment.getArticleId()))
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST);

        comment.setContent(dto.getContent());
        comment.setWriter(dto.getWriter());
        return CommentDto.fromEntity(commentRepository.save(comment));
    }

4️⃣ 게시글 댓글 삭제 서비스

//TODO 게시글 댓글 삭제 DELETE
    //deleteComment() 자유롭게 만들기
    public void deleteComment(Long articleId, Long commentId) {
        Optional<CommentEntity> optionalComment
                = commentRepository.findById(commentId);

        if (optionalComment.isEmpty()) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND);
        }

        CommentEntity comment = optionalComment.get();
        if (!articleId.equals(comment.getArticleId())) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
        }

        commentRepository.deleteById(commentId);
    }
`CommentService`

 

@RequestParam

Query Component이란?

URL의 구성 요소 중 ?(물음표) 뒤에 요소입니다.

  • 요구하는 자원에 대한 동적인 조건을 전달하는 용도로 사용
  • 페이지, 검색 등
  • ?(물음표) 뒤에 key(인자이름) = value(값) 형식으로 활용, 각 인자는 & 로 구분
    • query : keyword
    • limit : 20

✅ @RequestParam

Spring의 RequestMapping 메소드에서는 @RequestParam을 이용해 Query의 인자를 받아올 수 있다.

  • QueryController 클래스 생성
imp
@Slf4j
@RestController
public class QueryController {
    //GET /path?query=keyword&limit=20
    @GetMapping("/path")
    public Map<String, Object> queryParams(
            @RequestParam("query") String query,
            @RequestParam("limit") Integer limit
    ){
        log.info("query = " + query);
        log.info("limit = " + limit);

        Map<String,Object> response = new HashMap<>();
        response.put("query", query);
        response.put("limit", limit);
        return response;
    }
}

<aside> 💡 defaultValue: 기본값 설정

required: 필수 포함 여부

</aside>

✅ Pagisation

조회할 데이터의 갯수가 많을 때, 조회 되는 데이터의 갯수를 한정 시켜 페이지 단위로 나누는 기법으로 Paiging이라고 불린다.

  • 조회할 데이터의 갯수가 줄어들기 때문에 성능 향상을 꾀할 수 있습니다.
  • 사용자가 데이터를 확인하는 과정에서 확인해야 하는 데이터를 줄여 UX가 향상됩니다.

✅ JPA Pagination

💡 목표 : aritcles중 클라이언트의 조건에 따라 스무 개씩 나누어서 제공하기

1 ~ 20 / 21~ 40 / 41 ~ 60로 정렬

1️⃣ JpaRepository 를 이용하여 받아올 수 있는 데이터의 갯수 조절하기

  • Query Method를 활용해서 가져올 데이터의 갯수를 한정시켜 조회
  • ArticleRepository 인터페이스
public interface ArticleRepository
        extends JpaRepository<ArticleEntity, Long> {
    // ID가 큰 순서대로 최상위 20개 정렬
    List<ArticleEntity> findTop20ByOrderByIdDesc();
    // ID가 특정 값보다 작은 데이터 중 큰 순서대로 최상위 20개 정렬
    List<ArticleEntity> findTop20ByIdLessThanOrderByIdDesc(Long id);
}

💡 주의) 메소드 이름을 정하는 규칙을 지켜야 실행이 된다.

findTop20ByOrderByIdDesc(); 
// ID가 큰 순서대로 최상위 20개 정렬
findTop20ByIdLessThanOrderByIdDesc(Long id);
// ID가 특정 값보다 작은 데이터 중 큰 순서대로 최상위 20개 정렬 

📌 JpaRepository 를 이용하여 조회 해보기

  • ArticleService 클래스
//1번 방식 ArrayList 사용
public List<ArticleDto> readArticlePaged(){
        //JPA Query Method 방식(비추)
        List<ArticleDto> articleDtoList =
                new ArrayList<>();
        for (ArticleEntity entity : repository.findTop20ByOrderByIdDesc()){
            articleDtoList.add(ArticleDto.fromEntity(entity));
        }
        return articleDtoList;
    }

  • ArticleController 클래스
//GET /articles/page-test
    @GetMapping("/page-test")
    public List<ArticleDto> readPageTest(){
        return service.readArticlePaged();
    }

➡️ 이 방법을 사용할 경우 조회할 기준이 되는 ID를 클라이언트가 제공하거나, 클라이언트가 페이지 번호를 전달 할 경우 어떤 식으로든 백엔드에서 기준점을 만들어야해서 추천하지 않는 방법입니다.

 

2️⃣ pageable 객체 방법

📌 pageable 사용 하기

PageReqeust.of()라는 메서드에 page, size, sort를 원하는대로 파라미터로 넘겨서 생성하면 됩니다.

📌 사용 방법

Pageable pageable = PageRequest.*of*(0, 20); 
➡️ 20개씩 데이터를 나눌 때 0번 페이지를 달라고 요청하는 기준 Pageable

Page<ArticleEntity> articleEntityPage = repository.findAll(pageable);
➡️ 객체를 findAll()메소드에 인자로 전달하면 Page<ArticleEntity> 객체를 반환해줍니다. 

📌 사용 예시

Page<Entity> pleaseGiveMePages(int page, int size) {
	Pageable pageable = PageRequest.of(page,size);
    	Page<Entiy> page = entityRepository.findAll(pageable);
        return page;
}
  • ArticleService에 사용해보기
//pageable 사용 방식 
public Page<ArticleEntity>  readArticlePaged(){
        //pagingAndSortingRepository 메소드에 전달하는 용도
        //조회하고 싶은 페이지의 정보를 담는 객체
        //20개씩 데이터를 나눌 때 0번 페이지를 달라고 요청하는 기준 Pageable
        Pageable pageable = PageRequest.of(0, 20);
        Page<ArticleEntity> articleEntityPage = repository.findAll(pageable);
        return articleEntityPage;
    }
  • ArticleController
//GET /articles/page-test
    @GetMapping("/page-test")
    public Page<ArticleEntity> readPageTest(){
        return service.readArticlePaged();
    }
  • 실행결과

✅ Search

  • ArticleService
public Page<ArticleDto> readArticlePaged() {
        // PagingAndSortingRepository 메소드에 전달하는 용도
        // 조회하고 싶은 페이지의 정보를 담는 객체
        // 20개씩 데이터를 나눌때 0번 페이지를 달라고 요청하는 Pageable
        Pageable pageable = PageRequest.of(
                0, 20, Sort.by("id").descending());
        Page<ArticleEntity> articleEntityPage
                = repository.findAll(pageable);
        // map: 전달받은 함수를 각 원소에 인자로 전달한 결과를
        // 다시 모아서 Stream으로
        // Page.map: 전달받은 함수를 각 원소에 인자로 전달한 결과를
        // 다시 모아서 Page로
        Page<ArticleDto> articleDtoPage
                = articleEntityPage.map(ArticleDto::fromEntity);
       return articleDtoPage;
    }
  • 페이지 단위로 실행 되는 걸 확인 할 수 있다.
  •  

  • ArticleController

defaultValue = “ 문자열로 할당해야한다. ”

@GetMapping
    public Page<ArticleDto> readAll(
            @RequestParam(value = "page", defaultValue = "0") Integer page,
            @RequestParam(value = "limit", defaultValue = "20") Integer limit
    ) {
        return service.readArticlePaged(page, limit);
    }
  • ArticleService
public Page<ArticleDto> readArticlePaged(
            Integer pageNumber, Integer pageSize
    ) {
        // PagingAndSortingRepository 메소드에 전달하는 용도
        // 조회하고 싶은 페이지의 정보를 담는 객체
        // 20개씩 데이터를 나눌때 0번 페이지를 달라고 요청하는 Pageable
        Pageable pageable = PageRequest.of(
                pageNumber, pageSize, Sort.by("id").descending());
        Page<ArticleEntity> articleEntityPage
                = repository.findAll(pageable);
        // map: 전달받은 함수를 각 원소에 인자로 전달한 결과를
        // 다시 모아서 Stream으로
        // Page.map: 전달받은 함수를 각 원소에 인자로 전달한 결과를
        // 다시 모아서 Page로
        Page<ArticleDto> articleDtoPage
                = articleEntityPage.map(ArticleDto::fromEntity);
        return articleDtoPage;
    }

실행결과


Search

간단한 검색기능은 WHERE절을 활용하여 구성할 수 있습니다.

JPA Query Method

JpaRepository에 Query Method를 추가하는 방식으로 쉽게 구현할 수 있습니다.

List<ArticleEntity> findAllByTitleContains(String title);

JpaRepository를 사용하면, Query Method에 Pageable 인자를 추가할 수 있습니다.

이 방법을 사용하면 검색 결과를 페이지 단위로 나눠서 결과를 받을 수 있습니다.

Page<ArticleEntity> findAllByTitleContains(String title, Pageable pageable);

반환값은 Page와 List 둘다 사용 가능합니다.

 

실습

QueryController 전체코드

@GetMapping("/path")
    public Map<String, Object> queryParam(@RequestParam(value = "query", defaultValue = "hello") String query,
                                          @RequestParam(value = "limit", required = false) Integer limit) {
        log.info("query = {}", query);
        log.info("limit = {}", limit);

        Map<String, Object> response = new HashMap<>();
        response.put("query", query);
        response.put("limit", limit);

        return response;
    }

    @GetMapping("/serch")
    public Page<ArticleDto> search(
            @RequestParam("query") String query, // 검색어는 필수
            @RequestParam(value = "page", defaultValue = "0")
            Integer pageNumber
    ){

        return service.search(query.pageNumber);
    }
}

ArticleRepository 전체코드

public interface ArticleRepository
        extends JpaRepository<ArticleEntity, Long> {
    // ID가 큰 순서대로 최상위 20개 정렬
    List<ArticleEntity> findTop20ByOrderByIdDesc();
    // ID가 특정 값보다 작은 데이터 중 큰 순서대로 최상위 20개 정렬
    List<ArticleEntity> findTop20ByIdLessThanOrderByIdDesc(Long id);

    //제목에 title이 들어가는 article 검사
    Page<ArticleEntity> findAllByTitleContains(String title, Pageable pageable);
}

ArticleService 전체코드

import com.example.likelion.week10day3.dto.ArticleDto;
import com.example.likelion.week10day3.entity.ArticleEntity;
import com.example.likelion.week10day3.repository.ArticleRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

@Service
@RequiredArgsConstructor
public class ArticleService {

    private final ArticleRepository repository;

    // Spring Data Jpa 쿼리 메소드로 페이지 조회하는것은 좋지않은 방법
//    public List<ArticleDto> readArticlePaged() {
//        List<ArticleDto> articleDtoList = new ArrayList<>();
//        for (ArticleEntity entity : repository.findTop20ByOrderByIdDesc()) {
//            articleDtoList.add(ArticleDto.fromEntity(entity));
//        }
//        return articleDtoList;
//    }

//    public List<ArticleDto> readArticlePaged() {
//        // 조회하고 싶은 페이지의 정보를 담는 객체
//        // 20개씩 데이터를 나눌때 0번 페이지를 달라고 요청하는 Pageable
//        Pageable pageable = PageRequest.of(0, 20, Sort.by("id").descending());
//
//        Page<ArticleEntity> articleEntityPage
//                = repository.findAll(pageable);
//
//        List<ArticleDto> articleDtoList = new ArrayList<>();
//        for (ArticleEntity entity : articleEntityPage) {
//            articleDtoList.add(ArticleDto.fromEntity(entity));
//        }
//
//        return articleDtoList;
//    }

    public Page<ArticleDto> readArticlePaged(
            Integer pageNumber, Integer pageSize
    ) {
        Pageable pageable = PageRequest.of(pageNumber, pageSize, Sort.by("id").descending());

        Page<ArticleEntity> articleEntityPage
                = repository.findAll(pageable);

        // map : 전달받은 함수를 각 원소에 인자로 전달된 결과를
        // 다시 모와서 Stream 객체로

        Page<ArticleDto> articleDtoPage
                = articleEntityPage.map(ArticleDto::fromEntity);

        return articleDtoPage;
    }

    public Page<ArticleDto> search (
            String query, Integer pageNumber
    ){
        Pageable pageable
                = PageRequest.of(pageNumber, 20, Sort.by("id").descending());

        Page<ArticleEntity> articleEntityPage
                = repository.findAll(pageable);

        return repository.findAllByTitleContent(query, pageable).map(ArticleDto::fromEntity);
    }
}

실행 결과