4. 아티클 전체 조회 기능 구현

2022. 2. 20. 22:46프로젝트 구현/리얼 월드

프로젝트에서 구현해야 할 다양한 기능들 중에서, 아티클(게시글) 전체 조회 기능을 가장 먼저 구현하기로 결정했다.

그 이유는, '게시글 프로젝트'의 정체성이자 가장 중요한 기능이 바로 '게시글 조회 기능'이기 때문이다. 

 한편 엔드포인트와 그에 따른 response 포맷이 공개되어 있으므로, 이 프로젝트는 TDD로 구현하기 매우 적합하다. response 포맷을 그대로 테스트 케이스로 작성한 다음, 테스트를 통과하도록 코드를 구현하면 되기 때문이다.

 

중간 중간의 기록들을 공유하며 코드를 어떻게 짰는지 설명하려 했지만, 불찰로 인해 기록들이 날아가버렸다.(ㅠㅠ) 
현재 남아있는 건 완성된 코드이다.
따라서 이번 글은 코드 작업의 비약이 크니 감안하고 읽어주시길 바란다.

 


 

1. ArticlesController 테스트 코드 작성


ArticlesControllerTest 클래스를 작성한다.

@WebMvcTest(controllers = ArticlesController.class)
class ArticlesControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    ArticleService articleService;
    
    ....

컨트롤러단의 테스트와 스프링 MVC의 동작을 모킹하기 위해, @WebMvcTest 애노테이션을 붙여주고 MockMvc 선언 및 의존관계 주입시켜 준다.

그리고 테스트 코드를 작성하는데, 구현하고자 하는 엔드포인트는 /api/articles 이고 GET 메서드로 요청하며 결과적으로 json 형식의 데이터를 반환한다. 결과 데이터 형식은 다음과 같다.

{
  "articles":[{
    "slug": "how-to-train-your-dragon",
    "title": "How to train your dragon",
    "description": "Ever wonder how?",
    "body": "It takes a Jacobian",
    "tagList": ["dragons", "training"],
    "createdAt": "2016-02-18T03:22:56.637Z",
    "updatedAt": "2016-02-18T03:48:35.824Z",
    "favorited": false,
    "favoritesCount": 0,
    "author": {
      "username": "jake",
      "bio": "I work at statefarm",
      "image": "https://i.stack.imgur.com/xHWG8.jpg",
      "following": false
    }
  }, {
    "slug": "how-to-train-your-dragon-2",
    "title": "How to train your dragon 2",
    "description": "So toothless",
    "body": "It a dragon",
    "tagList": ["dragons", "training"],
    "createdAt": "2016-02-18T03:22:56.637Z",
    "updatedAt": "2016-02-18T03:48:35.824Z",
    "favorited": false,
    "favoritesCount": 0,
    "author": {
      "username": "jake",
      "bio": "I work at statefarm",
      "image": "https://i.stack.imgur.com/xHWG8.jpg",
      "following": false
    }
  }],
  "articlesCount": 2
}

 

따라서, /api/articles 로 api 요청을 한 결과 아티클이 2개라는 가정 하에, 다음과 같이 테스트 코드를 작성할 수 있다.

 

    @Test
    @DisplayName("/api/articles 로 GET 요청 시 아티클들을 가져온다")
    void findArticles_default_test() throws Exception {

        // when & then
        mockMvc.perform(
            get("/api/articles"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.articlesCount", is(2)));
    }

 

ArticleController 클래스가 없기 때문에, 당연히 에러가 뜰 것이다. 

 


 

2. ArticlesController 코드 작성


에러를 해결하기 위해 ArticleController 클래스를 구현해야할 차례이다. 각각의 아티클 정보를 담을 클래스가 필요하므로, 아티클 클래스를 하나 생성한다. 이 아티클 클래스는 DB에 직접 접근하는 클래스가 아닌, client <-> controller <-> service 사이 데이터를 전송하기 위한 클래스이므로 DTO 객체로 분류할 수 있다. 또한 이 클래스는 GET 요청에 대한 응답(response)값을 받아오므로, 클래스명을 ArticleRes라고 지었다.

@Builder
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class ArticleRes {

    private String slug;
    private String title;
    private String description;
    private String body;
    private Boolean favorited;
    private Integer favoritesCount;

    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;

    private List<String> tagList;
    private ProfileRes author;
}

 

또한, 여러 개의 아티클들을 하나로 묶어 주고 친절히 아티클 갯수까지 알려주는 기능을 수행하는 ArticlesRes 클래스를 만든다.

@Builder
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class ArticlesRes {

    private List<ArticleRes> articles;
    private int articlesCount;

    public ArticlesRes(List<ArticleRes> articles) {
        this.articles = articles;
        this.articlesCount = articles.size();
    }
}

 

이 두 개의 클래스를 이용하여, ArticlesController 클래스는 다음과 같이 작성한다.

@RestController
@RequestMapping("/api/articles")
public class ArticlesController {

    @GetMapping
    public ArticlesRes findArticles() {
        List<ArticleRes> articles = new ArrayList<>();
        articles.add(new ArticleRes());
        articles.add(new ArticleRes());


        return new ArticlesRes(articles);
    }
}

 

테스트를 통과했다. 물론 이 코드는 당장 테스트를 통과하기 위해 임시적으로 박아 넣은 코드이기 때문에, 아티클 갯수가 3개 이상이거나 갯수가 유동적으로 변하는 경우에는 통과하지 못한다. 또한, 컨트롤러 클래스의 의의는 클라이언트의 요청에 대한 로직의 분기이므로 컨트롤러단에서 응답 객체를 생성하고 보내고 하는 작업을 수행하는 것은 바람직하지 못하다.

 

여기서, 객체에 대한 핸들링 작업이나 로직 실행은 service 클래스에 위임하고 controller 클래스는 단지 service 클래스의 응답 데이터만 클라이언트에게 전해주는 방식을 도출해 볼 수 있다. ArticlesController 클래스를 다음과 같이 개선(리팩토링)하자.

@RestController
@RequestMapping("/api/articles")
public class ArticlesController {

    final ArticleService articleService;

    public ArticlesController(ArticleService articleService) {
        this.articleService = articleService;
    }


    @GetMapping
    ArticlesRes findArticles() {
        return new ArticlesRes(articleService.findArticles());
    }
}

 

ArticleService 클래스가 없으니 당연히 코드가 통과하지 않을 것이다. 당연히 ArticleService 클래스를 만들고, ArticleService를 테스트할 ArticleServiceTest 클래스도 생성한다. 한편, ArticlesControllerTest에서 articleService의 기능을 모킹해서 사용하기 위해, articleService 를 @mockBean으로 등록한다.

...

@WebMvcTest(controllers = ArticlesController.class)
class ArticlesControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    ArticleService articleService;

...

 

 

 이후 ArticlesControllerTest 코드를 수정 & 추가하고, ArticleService 클래스와 테스트 코드를 작성하고, (데이터를 얻어오기 위한) 메서드와 SQL문 매핑을 위한 매퍼 인터페이스, 매퍼 xml을 작성한다. 

 


 

3. 결과 코드

 


코드 : https://github.com/SangHoonly/real-world/commit/cf1cb496f437c4db013e7d75777968ef50dcd811

 

Feat: GET /api/articles 구현 · SangHoonly/real-world@cf1cb49

Permalink This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository. Browse files Feat: GET /api/articles 구현 Loading branch information Showing 18 changed files with 468 additions and 3 deletions.

github.com

 

게시물 조회 기능에 필터링 기능을 적용한 ArticlesController 결과 코드이다.

@RestController
@RequestMapping("/api/articles")
public class ArticlesController {

    final ArticleService articleService;

    public ArticlesController(ArticleService articleService) {
        this.articleService = articleService;
    }


    @GetMapping
    ArticlesRes findArticles(@RequestParam(required = false) Map<String, Object> params) {
        if (!params.containsKey("limit"))
            params.put("limit", 20);

        if (!params.containsKey("offset"))
            params.put("offset", 0);

        return new ArticlesRes(articleService.findArticles(params));
    }
}

 

파라미터로 넘어 오는 키워드들이 6개라(5개 + 현재 사용자의 id) 이 키워드들을 일일이 다 지정하는 건 번거롭고 반복되는 작업이므로, Map 객체로 묶어 한번에 받는 방법을 사용했다. 또한 파라미터에 limit, offset 키워드가 있는지 확인 후에 기본값을 세팅하는 로직을 수행하는데, 물론 이 로직을 sql문으로 처리해줄 수도 있지만 limit와 offset 기본값을 조금 더 명시적으로 보고 싶어 controller 클래스에서 처리하도록 했다. 허나 코드 자체가 깔끔하지는 못해서, 좀 더 깔끔하게 처리하는 방법이 있는지 생각중이다.

 

// ArticleService 클래스.
@Service
public class ArticleService {

    private final ArticleMapper articleMapper;

    public ArticleService(ArticleMapper articleMapper) {
        this.articleMapper = articleMapper;
    }

    public List<ArticleRes> findArticles(Map<String, Object> params) {
        return articleMapper.findArticles(params);
    }
}


// ArticleMapper 인터페이스.
@Mapper
public interface ArticleMapper {

    public List<ArticleRes> findArticles(Map<String, Object> params);
}

 

ArticleService 클래스는 큰 로직 없이, 단지 매퍼로부터 받아온 결과를 반환하는 작업을 수행한다.

 

매퍼 xml은 다음과 같다.

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
  "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="LeeJerry.realWorld.model.mapper.ArticleMapper">
  <resultMap id="author" type="LeeJerry.realWorld.model.dto.ProfileRes">
<!--    <id property="id" column="author_id" />-->
    <result property="username" column="author_name" />
    <result property="bio" column="author_bio" />
    <result property="image" column="author_image" />
    <result property="following" column="author_following" />
  </resultMap>

  <resultMap id="article" type="LeeJerry.realWorld.model.dto.ArticleRes">
    <result property="slug" column="article_slug" />
    <result property="title" column="article_title" />
    <result property="description" column="article_description" />
    <result property="body" column="article_body" />

    <result property="createdAt" column="article_created_at" />
    <result property="updatedAt" column="article_updated_at" />

    <result property="favorited" column="article_favorited" />
    <result property="favoritesCount" column="article_favoritesCount" />

    <association property="author" resultMap="author" />

    <collection property="tagList" ofType="java.lang.String">
      <result column="tag_name" />
    </collection>

  </resultMap>

  
  <select id="findArticles" resultMap="article">
    select * , T.name as tag_name from
    (select
    Art.id   as article_id,
    Art.slug as article_slug,
    Art.title as article_title,
    Art.description as article_description,
    Art.body as article_body,
    Art.created_at as article_created_at,
    Art.updated_at as article_updated_at,

    IFNULL(F.status, 0) as article_favorited,
    IFNULL(FCNT.favoritesCount, 0) as article_favoritesCount,

    U.id as author_id,
    U.name as author_name,
    U.bio as author_bio,
    U.picture as author_image,
    IF(U.following = 0, false, true) as author_following


    from articles Art
    left outer join favorites F on F.article_id = Art.id and (F.user_id IS NULL or (#{userId} is not null and F.user_id = #{userId}))
    left outer join (select article_id, count(id) as favoritesCount from favorites group by article_id) as FCNT on FCNT.article_id = Art.id
    left outer join (select author.id, author.name, author.bio, author.picture, IFNULL(f2.id, 0) as
    following from users as author left outer join follows f2 on author.id = f2.followee_id and (f2.follower_id IS NULL or f2.follower_id = #{userId})) as U on U.id = Art.author_id

    <where>
        <if test="author != null"> U.name = #{author} </if>
        <if test="tag != null">  and #{tag} in (select name from tags where tags.article_id = Art.id) </if>
        <if test="favorited != null"> and #{favorited} in
        (select name from users inner join favorites f on users.id = f.user_id and f.article_id = Art.id) </if>
    </where>

    order by Art.created_at desc
    limit #{limit} offset #{offset}
    ) as R left outer join tags T on R.article_id = T.article_id
        <if test="tag != null"> and #{tag} in (select name from tags where tags.article_id = R.article_id) </if>
</select>
</mapper>

 

쿼리 부분을 살펴보면, 여기저기 서브 쿼리도 많고 중복되는 쿼리도 있다. 불필요한 컬럼에 접근하거나, 랜덤 IO를 많이 수행하는 등 성능 비효율에 대한 탐색과 분석은 필수적이며, 이 글에 다 담기에는 글이 너무 길어질 것 같으므로 따로 포스팅하겠다.