본문 바로가기

Project

[Independe] Main Post 첫 번째 성능개선

자취생 커뮤니티를 약 90% 완성하면서 성능에 대한 고민을 하기 시작했다. 특히 메인 화면 조회 성능이 가장 큰 고민거리였다. 메인 화면은 사용자들이 보통 처음 들어오는 페이지이기 때문에 빠르게 응답을 하지 않으면 사용자가 떠날 것이라 생각했고 요청에 대한 응답이 1초를 넘어가지 않는 것을 목표했다.

그러나 메인 화면은 다양한 게시글들을 보여줘야 하기 때문에 발생하는 쿼리도 많아 개선하기 어려워보였다. 우선 메인 화면에 필요한 데이터는 다음과 같다.

- 인기 게시글 10개

- 추천 수 자취 게시글 10개

- 전체 카테고리 지역 게시글 5개

- 전체 카테고리가 아닌 지역 게시글 5개

- 인기 검색어 10개

- 자취 관련 영상

이중 게시글과 관련된 데이터는 게시글을 10개 조회하면서 게시글당 추천 수, 댓글 수, 이미지 등록 여부 또한 필요하다. 초기에 개발했던 기존의 코드는 아래와 같다.

@Operation(summary = "메인화면 조회")
@GetMapping("/api/posts/main")
public Result mainPost(HttpServletResponse response) {

    LocalDateTime today = LocalDateTime.now(); // 오늘
    LocalDateTime yesterday = LocalDateTime.now().minusDays(1); // 어제
    LocalDateTime lastWeek = LocalDateTime.now().minusDays(7);

    // 인기 게시글(10개)
    List<Post> findAllPopularPosts = postApiRepository.findAllPopularPosts(yesterday, today, 0, 10);
    List<PopularPostDto> popularPostDto = findAllPopularPosts.stream()
            .map(p -> new PopularPostDto(
                    p.getId(),
                    p.getTitle(),
                    (p.getIndependentPostType() == null) ? null : p.getIndependentPostType().getDescription(),
                    (p.getRegionType() == null) ? null : p.getRegionType().getDescription(),
                    (p.getRegionPostType() == null) ? null : p.getRegionPostType().getDescription(),
                    p.getIndependentPostType(),
                    p.getRegionType(),
                    p.getRegionPostType(),
                    p.getViews(),
                    /* 변경해야할 부분 */
                    recommendPostService.countAllByPostIdAndIsRecommend(p.getId()),
                    commentService.countAllByPostId(p.getId()),
                    !filesService.findAllFilesByPostId(p.getId()).getS3Urls().isEmpty()
            )).collect(Collectors.toList());

    // 추천수 자취 게시글 10개
    List<Post> findAllIndependentPostByRecommendCount = postApiRepository.findAllIndependentPostByRecommendCount(yesterday, today, 0, 10);
    List<PopularIndependentPostsDto> popularIndependentPostsDto = findAllIndependentPostByRecommendCount.stream()
            .map(p -> new PopularIndependentPostsDto(
                    p.getId(),
                    p.getTitle(),
                    p.getIndependentPostType().getDescription(),
                    p.getIndependentPostType(),
                    /* 변경해야할 부분 */
                    recommendPostService.countAllByPostIdAndIsRecommend(p.getId()),
                    commentService.countAllByPostId(p.getId()),
                    !filesService.findAllFilesByPostId(p.getId()).getS3Urls().isEmpty()
            )).collect(Collectors.toList());

    // 전체 지역 게시글 5개
    List<Post> findAllRegionPostByRecommendCount = postApiRepository.findAllRegionAllPostByRecommendCount(yesterday, today, 0, 5);
    List<RegionAllPostDto> regionAllPostDto = findAllRegionPostByRecommendCount.stream()
            .map(p -> new RegionAllPostDto(
                    p.getId(),
                    p.getTitle(),
                    /* 변경해야할 부분 */
                    recommendPostService.countAllByPostIdAndIsRecommend(p.getId()),
                    commentService.countAllByPostId(p.getId()),
                    !filesService.findAllFilesByPostId(p.getId()).getS3Urls().isEmpty()
            )).collect(Collectors.toList());

    // 전체 아닌 지역 게시글 5개
    List<Post> findRegionNotAllPostByRecommendCount = postApiRepository.findRegionNotAllPostByRecommendCount(yesterday, today, 0, 5);
    List<RegionNotAllPostDto> regionNotAllPostDto = findRegionNotAllPostByRecommendCount.stream()
            .map(p -> new RegionNotAllPostDto(
                    p.getId(),
                    p.getTitle(),
                    p.getRegionType().getDescription(),
                    p.getRegionPostType().getDescription(),
                    p.getRegionType(),
                    p.getRegionPostType(),
                    /* 변경해야할 부분 */
                    recommendPostService.countAllByPostIdAndIsRecommend(p.getId()),
                    commentService.countAllByPostId(p.getId()),
                    !filesService.findAllFilesByPostId(p.getId()).getS3Urls().isEmpty()
            )).collect(Collectors.toList());

    // 인기 검색어 10개
    List<KeywordDto> keywordsDto = keywordService.findKeywordsByGroup();

    // 영상
    List<Video> findAllForMain = videoService.findAllForMain();
    List<VideoMainDto> videoMainDto = findAllForMain.stream()
            .map(v -> new VideoMainDto(
                    v.getVideoTitle(),
                    v.getVideoUrl()
            )).collect(Collectors.toList());

    MainPostDto mainPostDto = new MainPostDto(
            "다가오는 장마철 천연 정화석을 구비하여 습기를 제거해보세요",
            popularPostDto,
            regionAllPostDto,
            regionNotAllPostDto,
            popularIndependentPostsDto,
            keywordsDto,
            videoMainDto
    );

    return new Result(mainPostDto);
}

기존의 코드를 간략하게 설명하면 다음과 같다.

- 인기 / 자취 / 전체 / 지역 게시글을 DB 에서 가져온다.

- Stream 을 이용해 게시글 수 만큼 루프를 돌며 DTO 로 변환한다.

- * 루프 내부에서 각 게시글에 대한 추천 수, 댓글 수, 이미지 등록 여부를 조회한다. *

- 인기 검색어와 영상을 조회해 DTO 로 변환한다.

--> 3번째 설명한 코드에서 큰 문제가 발생한다.

 

우선 내가 개발한 서버가 정확히 어느정도의 성능을 가졌는지 테스트 했다. 테스트는 Mysql에 10000개의 더미 데이터를 넣고 JMeter 를 이용해 간단하게 동시 사용자 수를 늘려 테스트를 해보았다. 작은 커뮤니티 사이트라도 동시에 100명 정도는 충분히 처리할 수 있어야 된다는 생각으로 테스트를 해보았으나 결과는 충격적이였고 서버가 다운되기 직전이였다.

위 사진은 기존 서버에 Main 화면을 30명의 사용자가 동시에 요청을 보낼 시 결과이다. 최소 응답시간이 1233ms 로 1초가 넘어가고 평균은 1.8초이다. 즉 내 서버에 30명만 동시에 접속해도 1초 이상의 시간이 소요된다. 메인화면의 경우 어떻게든 1초 이하의 응답시간을 유지하고 싶었다.

곧바로 원인을 분석해보았고 제일 처음 눈에띈건 메인 화면 조회 시 발생하는 쿼리의 수였다. 메인 화면을 조회 시 인기 게시글을 10개 조회하고 각각의 게시글당 추천 수, 댓글 수, 이미지 등록 여부를 추가로 DB 에 조회했다. 

@Around("execution(* community.independe..repository..*.*(..))")
public Object logRepositoryExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
    long startTime = System.currentTimeMillis();

    Object result = joinPoint.proceed();

    long endTime = System.currentTimeMillis();
    long elapsedTime = endTime - startTime;

    String methodName = joinPoint.getSignature().getName();
    log.info("Execution Repository time for method " + methodName + ": " + elapsedTime + " milliseconds");

    return result;
}

Spring 의 AOP 를 활용해 Repository 가 호출될 때 마다 DB 접근 시간이 얼마나 되는지 로그를 찍었다.

그 결과 한번의 요청에 98번의 Repository 접근이 발생했다. 한번의 Repository 접근이 2ms 여도 98 회면 약 200ms가 소요된다. 10개의 인기 게시글을 가져온 뒤 각각의 게시글에 추천수, 댓글수, 이미지 등록 여부를 추가로 조회했고 이를 자취 / 전체 / 지역 게시글에 대해 추가로 진행했기에 발생한 결과였다. 성능 개선을 위해 DB 접근 횟수를 줄이는 것이 중요하다고 생각했다.

public List<Post> findAllPopularPosts(LocalDateTime yesterday, LocalDateTime today, int first, int max) {
    return em.createQuery("select p from Post p" +
            " where p.createdDate BETWEEN :yesterday AND :today" +
                    " order by p.views DESC", Post.class)
            .setFirstResult(first)
            .setMaxResults(max)
            .setParameter("yesterday", yesterday)
            .setParameter("today", today)
            .getResultList();
}

해당 코드는 10개의 인기 게시글을 가져오는 쿼리이다. 처음엔 위 현상을 페치 조인을 이용해 해결해보려 했다. 하지만 게시글 : 추천, 게시글 : 댓글, 게시글 : 이미지 모두 1 : N 관계였고 1 : N 페치조인 시 데이터의 중복이 발생하여 페이징 시 모든 데이터를 메모리에서 페이징하기 때문에 사용하지 않았다. 

 

난 이를 해결하기 위해 지연로딩과 default_batch_fetch_size 를 활용했다.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Post extends BaseEntity {

    @Id @GeneratedValue
    @Column(name = "post_id")
    private Long id;

    private String title;
    private int views; // 조회수

    @Column(columnDefinition = "text") // 텍스트 타입
    private String content;

    @Enumerated(EnumType.STRING)
    private IndependentPostType independentPostType; // 자취 게시판 카테고리

    @Enumerated(EnumType.STRING)
    private RegionType regionType; // 지역 게시판 지역 정보

    @Enumerated(EnumType.STRING)
    private RegionPostType regionPostType; // 지역 게시판 카테고리

    //== 연관 관계 ==//
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member; // 게시글, 회원 N : 1 다대일 단방향 매핑

    @OneToMany(mappedBy = "post") // 게시글, 게시글 추천
    private List<RecommendPost> recommendPosts = new ArrayList<>();

    @OneToMany(mappedBy = "post")
    private List<Comment> comments = new ArrayList<>();

    @OneToMany(mappedBy = "post")
    private List<Files> files = new ArrayList<>();

    @Builder
    public Post(String title, String content, IndependentPostType independentPostType, RegionType regionType, RegionPostType regionPostType, Member member) {
        this.title = title;
        this.content = content;
        this.independentPostType = independentPostType;
        this.regionType = regionType;
        this.regionPostType = regionPostType;
        this.member = member;
        this.views = 0;
    }
}
  jpa:
    hibernate:
      ddl-auto: none
    properties:
      hibernate:
        show_sql: true
        format_sql: true
        default_batch_fetch_size: 100

게시글 엔티티에 댓글과 파일 엔티티에 대한 양방향 연관관계를 추가하고 yml 파일에 default_batch_fetch_size 를 100으로 설정했다.

default_batch_fetch_size 는 지연로딩 시 연관 데이터를 바로 조회하는 것이 아닌 설정값 만큼 In 쿼리를 이용하여 연관 데이터를 한번에 불러온다.

@Operation(summary = "메인화면 조회")
@GetMapping("/api/posts/main")
public Result mainPost(HttpServletResponse response) {

    LocalDateTime today = LocalDateTime.now(); // 오늘
    LocalDateTime yesterday = LocalDateTime.now().minusDays(1); // 어제
    LocalDateTime lastWeek = LocalDateTime.now().minusDays(7);

    // 인기 게시글(10개)
    List<Post> findAllPopularPosts = postApiRepository.findAllPopularPosts(yesterday, today, 0, 10);
    List<PopularPostDto> popularPostDto = findAllPopularPosts.stream()
            .map(p -> new PopularPostDto(
                    p.getId(),
                    p.getTitle(),
                    (p.getIndependentPostType() == null) ? null : p.getIndependentPostType().getDescription(),
                    (p.getRegionType() == null) ? null : p.getRegionType().getDescription(),
                    (p.getRegionPostType() == null) ? null : p.getRegionPostType().getDescription(),
                    p.getIndependentPostType(),
                    p.getRegionType(),
                    p.getRegionPostType(),
                    p.getViews(),
                    /* 변경된 부분 */
                    Long.valueOf(p.getRecommendPosts().size()),
                    Long.valueOf(p.getComments().size()),
                    (p.getFiles().isEmpty()) ? false : true
            )).collect(Collectors.toList());

    // 추천수 자취 게시글 10개
    List<Post> findAllIndependentPostByRecommendCount = postApiRepository.findAllIndependentPostByRecommendCount(yesterday, today, 0, 10);
    List<PopularIndependentPostsDto> popularIndependentPostsDto = findAllIndependentPostByRecommendCount.stream()
            .map(p -> new PopularIndependentPostsDto(
                    p.getId(),
                    p.getTitle(),
                    p.getIndependentPostType().getDescription(),
                    p.getIndependentPostType(),
                    /* 변경된 부분 */
                    Long.valueOf(p.getRecommendPosts().size()),
                    Long.valueOf(p.getComments().size()),
                    (p.getFiles().isEmpty()) ? false : true
            )).collect(Collectors.toList());

    // 전체 지역 게시글 5개
    List<Post> findAllRegionPostByRecommendCount = postApiRepository.findAllRegionAllPostByRecommendCount(yesterday, today, 0, 5);
    List<RegionAllPostDto> regionAllPostDto = findAllRegionPostByRecommendCount.stream()
            .map(p -> new RegionAllPostDto(
                    p.getId(),
                    p.getTitle(),
                    /* 변경된 부분 */
                    Long.valueOf(p.getRecommendPosts().size()),
                    Long.valueOf(p.getComments().size()),
                    (p.getFiles().isEmpty()) ? false : true
            )).collect(Collectors.toList());

    // 전체 아닌 지역 게시글 5개
    List<Post> findRegionNotAllPostByRecommendCount = postApiRepository.findRegionNotAllPostByRecommendCount(yesterday, today, 0, 5);
    List<RegionNotAllPostDto> regionNotAllPostDto = findRegionNotAllPostByRecommendCount.stream()
            .map(p -> new RegionNotAllPostDto(
                    p.getId(),
                    p.getTitle(),
                    p.getRegionType().getDescription(),
                    p.getRegionPostType().getDescription(),
                    p.getRegionType(),
                    p.getRegionPostType(),
                    /* 변경된 부분 */
                    Long.valueOf(p.getRecommendPosts().size()),
                    Long.valueOf(p.getComments().size()),
                    (p.getFiles().isEmpty()) ? false : true
            )).collect(Collectors.toList());

    // 인기 검색어 10개
    List<KeywordDto> keywordsDto = keywordService.findKeywordsByGroup();

    // 영상
    List<Video> findAllForMain = videoService.findAllForMain();
    List<VideoMainDto> videoMainDto = findAllForMain.stream()
            .map(v -> new VideoMainDto(
                    v.getVideoTitle(),
                    v.getVideoUrl()
            )).collect(Collectors.toList());

    MainPostDto mainPostDto = new MainPostDto(
            "다가오는 장마철 천연 정화석을 구비하여 습기를 제거해보세요",
            popularPostDto,
            regionAllPostDto,
            regionNotAllPostDto,
            popularIndependentPostsDto,
            keywordsDto,
            videoMainDto
    );

    return new Result(mainPostDto);
}

이후 Controller 에서 게시글 마다 DB 를 새로 조회하는 것이 아닌 size() 와 isEmpty() 를 사용해 추천, 댓글 수 와 이미지 등록 여부를 가져왔다.

select r1_0.post_id,r1_0.recommend_post_id,r1_0.created_date,r1_0.is_recommend,r1_0.member_id 
	from recommend_post r1_0 
    where r1_0.post_id in(10000,9999,9998,9997,9996,9995,9994,9993,9992,9991);

select c1_0.post_id,c1_0.comment_id,c1_0.content,c1_0.created_date,c1_0.last_modified_date,c1_0.member_id,c1_0.parent_id 
	from comment c1_0 
    where c1_0.post_id in(10000,9999,9998,9997,9996,9995,9994,9993,9992,9991);

select f1_0.post_id,f1_0.file_id,f1_0.s3url,f1_0.store_filename,f1_0.upload_filename 
	from files f1_0 
	where f1_0.post_id in(10000,9999,9998,9997,9996,9995,9994,9993,9992,9991);

그 결과 Repository 접근 횟수는 8회로 줄었고 인기 / 자취 / 전체 / 지역 게시글에 대해 추가 IN 쿼리가 3번씩 나가 총 DB 접근 횟수가 98회에서 20회로 줄었다.

이후 JMeter 를 이용해 30명의 사용자가 동시에 접속한 결과 평균 응답속도가 944 ms 로 기존보다 약 50% 빨라졌음을 볼 수있다.

 

하지만 아직 30명의 사용자에 대한 테스트일 뿐이며 100명의 사용자가 동시에 접속했을 경우 여전히 느린 응답시간을 보여준다. 향후 캐싱, Index 등을 적용해 서버를 더욱 빠르게 개선할 예정이다.