티스토리 뷰

1. 서론

처음에는 단지 인기글 3개만 보여주는 구조였기 때문에, 성능상 큰 문제가 없을 것이라 생각했습니다.

게다가 테스트 당시에는 좋아요 수가 많지 않아, API 응답 속도나 쿼리 수에 대한 문제를 명확히 인식하지 못했습니다. 

하지만 더미 데이터를 생성하여 좋아요 수를 Meeting 1,000개에 걸쳐 5만 건 이상 생성하고 테스트해본 결과,
생각보다 심각한 성능 저하와 타임아웃 현상이 발생하기 시작했습니다. 

 

이에 따라 쿼리 구조를 분석하고, 병목의 원인을 찾아 해결해보기로 결정했습니다. 

 

2. 인기글 API를 테스트해보자

Starhub에는 인기글을 확인할 수 있는 페이지가 있습니다.  

사용자는 이 페이지에서 프로젝트 / 스터디 / 마감임박 기준으로 각각 3개씩 인기글을 확인할 수 있습니다. 

인기글 페이지

 

총 9개의 인기글이 한 번에 불러와지는 것이 아니라, 각 카테고리별로 3번의 API 호출이 별도로 이루어지고 있습니다. 

 

📦 테스트 데이터 구성

  • Meeting 1,000개
  • 좋아요(Like) 약 5만 건
  • 기술 스택 연결 약 3,000건 (Meeting당 최대 3개)

 

이 데이터를 기반으로 인기글 API를 호출했을 때, 아래와 같은 결과를 확인할 수 있습니다. 

 

누가 봐도 문제가 많아 보이는 상태입니다......

이 문제를 해결하고자, 우선적으로 쿼리 구조부터 다시 점검하고 개선하는 작업을 시작했습니다. 

 

3. 쿼리 구조를 분석하자 

쿼리 병목을 분석하기 전에, 우선 인기글 페이지에서 사용되고 있는 데이터 구조부터 간단하게 정리해보겠습니다.

 

인기글 카드에는 다음과 같은 정보들이 포함되어 있습니다. 

  • 모임의 기본 정보(제목, 기간, 인원 등)
  • 등록된 기술 스택 목록
  • 로그인한 사용자의 좋아요 여부 

기본적으로 인기글은 좋아요 수 기준으로 상위 3개를 조회하는 방식으로 구성되어 있습니다.

STARHUB ERD 일부분

 

포스트와 기술 스택은 다대다관계로 가운데에 조인 테이블을 두어 관리하고 있습니다. 

좋아요 테이블은 사용자의 좋아요 기록을 저장하며, 인기글 판단의 핵심 기준이 되고 있습니다. 

 

이제 처음에 인기글 API를 어떻게 구현했는지 구조를 살펴보겠습니다. 

초기에는 인기글 기능을 빠르게 구현하는 것이 우선이었기 때문에, 단순하고 직관적인 구조로 접근했습니다. 

  • 좋아요 수 기준으로 인기글을 정렬하고
  • 해당 인기글에 대한 정보(기술 스택, 좋아요 여부 등)를 모두 실시간으로 가져오는 구조였습니다. 

코드를 확인해보면,

public List<MeetingSummaryResponseDto> getPopularMeetingsBeforeOptimizing(String username) {
    // 1. Meeting 전체 조회 (정렬 + 좋아요 수 포함)
    List<MeetingEntity> meetings = meetingRepository.findAll()
            .stream()
            .sorted(Comparator.comparing((MeetingEntity m) ->
                likeRepository.countByMeeting(m)).reversed()) // 좋아요 수 기준 정렬
            .limit(3)
            .collect(Collectors.toList());

    // 2. 기술 스택 + 좋아요 정보 조합
    return meetings.stream()
            .map(meeting -> {
                // 기술 스택
                List<MeetingTechStackEntity> techStacks = meetingTechStackRepository.findByMeeting(meeting);
                List<String> stackNames = techStacks.stream()
                        .map(ts -> ts.getTechStack().getName())
                        .collect(Collectors.toList());

                // 좋아요 수 + isLiked
                Long likeCount = likeRepository.countByMeeting(meeting);
                Boolean isLiked = (username != null) ?
                        likeRepository.existsByMeetingAndUserUsername(meeting, username) : null;

                return MeetingSummaryResponseDto.fromEntity(meeting, stackNames, new LikeDto(likeCount, isLiked));
            })
            .collect(Collectors.toList());
}

 

위 코드에서는 크게 두 가지 흐름으로 인기글 정보를 가져오고 있습니다. 

 

1) Meeting 전체 조회 후 Stream 정렬

  • meetingRepository.findAll() 로 모든 모임을 불러온 뒤, -> 💥 불필요한 데이터까지 가져오고 있음
  • 각 모임에 대해 countByMeeting을 호출하여 좋아요 수를 세고 -> 💥 좋아요 수를 Meeting마다 1번씩, 1,000번 호출하고 있음
  • Java Stream에서 좋아요 수 기준으로 정렬한 후 상위 3개만 추출합니다. -> 💥 DB의 집계 기능을 활용하지 못하고 있음

2) 추가 정보(기술 스택, 좋아요 여부) 조회 -> 인기글로 선정된 각 모임에 대해서만

  • meetingTechStackRepository.findByMeeting()으로 기술 스택 리스트 조회
  • 다시 countByMeeting()으로 좋아요 수 조회
  • 로그인한 사용자라면 existsByMeetingAndUserUsername()을 통해 좋아요 여부 조회

이처럼 인기글 API는 단순하고 직관적인 구조이지만, 내부적으로 모든 데이터를 한 번에 불러오고 있으며, 각 항목마다 추가 쿼리를 반복 실행하고 있습니다. 

 

 

4. 구조 개선해보자

개선된 쿼리 구조가 적용된 인기글 조회 로직(Service)

 

1) Meeting 전체 조회 후 Stream 정렬 ->  좋아요 수 기준으로 인기글 ID 3개만 먼저 조회

이전에는 모든 모임 정보를 한 번에 불러오고, 그 안에서 좋아요 수를 실시간으로 계산 후 정렬하는 방식이었습니다.

이렇게 구현할 경우 불필요한 데이터를 지나치게 많이 가져오고, 불필요한 쿼리를 반복 실행하는 구조가 되어버립니다. 

 

이제는 전체 데이터를 불러오는 대신, 좋아요 수 기준으로 인기글 ID 3개만 먼저 조회하는 방식으로 바꾸었습니다.

정렬과 집계는 DB 내부에서 처리하고, JAVA에서는 ID를 받아오는 역할만 수행하게 했습니다. 

 

2) 추가 정보(모임 정보, 기술 스택, 좋아요 관련 정보) 조회 

-> ✅ 인기글 ID가 준비되면, 이제 다음과 같이 필요한 정보를 선별적으로 조회합니다.

 

즉, ID 기반으로 분리 조회하고, ID 기반으로 필요한 데이터만 조합하여 조회하는 방식으로 바꾸었습니다. 

 

 

5. 고민 - 추가 정보를 인기글 ID를 통해 한 번에 가져오는건?

이 경우에 대해서 생각하기 위해서는 실제로 어떤 쿼리가 나가는지 분석을 해보겠습니다. 

 

1) 인기글관련 ID 조회하기 

인기글 관련 ID 조회(Repository)

SELECT m.id 
FROM meeting m
LEFT JOIN like l ON l.meeting_id = m.id
WHERE m.recruitment_type = ? AND m.is_confirmed = false
GROUP BY m.id
ORDER BY COUNT(l.id) DESC
LIMIT 3;

 

-> 모임 테이블과 좋아요 테이블을 활용해 좋아요 수 기준으로 정렬해 상위 3개 ID를 가져옴

 

 

2) 모임 정보 가져오기 

추가 정보: 모임 정보 가져오기

SELECT * FROM meeting WHERE id IN (?, ?, ?);

 

-> 이 경우 간단하니 쿼리문만 확인하고 넘어가겠습니다.

 

 

3) 기술 스택 가져오기

추가 정보: 기술 스택 가져오기(Service & Repository)

SELECT mts.*, ts.name 
FROM meeting_tech_stack mts
LEFT JOIN tech_stack ts ON mts.tech_stack_id = ts.id
WHERE mts.meeting_id IN (?, ?, ?);

 

-> 기술 스택 정보를 조회하기 위해 meeting_tech_stack 조인 테이블과 tech_stack 테이블을 LEFT JOIN하여 기술 스택명과 함께 한 번에 조회하는 구조입니다. 

이때 필요한 것은 모임 ID 3개에 대한 기술 스택 목록이므로, ID를 기준으로 한 번에 조회한 뒤, Java 코드에서는 이를Map<meetingId, Set<String>> 구조로 정리해 활용합니다.

 

 

4) 좋아요 관련 정보 가져오기 

추가 정보: 좋아요 관련 정보 가져오기(Service & Repository)

SELECT COUNT(*) FROM like WHERE meeting_id = ?;

SELECT EXISTS (
  SELECT 1 FROM like WHERE meeting_id = ? AND user_id = ?
);

 

->  좋아요 정보같은 경우 앞선 본 추가 정보들과 다르게 모든 사용자에게 동일하게 보여지는 데이터가 아니라,

로그인한 사용자에 한해서만 보여줘야 하는 개인화된 정보입니다.

 

따라서 서비스 로직에서는 익명 사용자라면 좋아요 여부(isLiked)를 아예 조회하지 않도록 설계되어 있으며,
이로 인해 좋아요 수 조회와 좋아요 여부 조회는 별개의 쿼리로 분리되어 있습니다.

 

5) 구조 고민

처음에는 "인기글 ID 3개만 있으면, 필요한 모든 정보를 한 번의 JOIN 쿼리로 가져올 수 있지 않을까?" 라는 고민도 했습니다. 

하지만 실제로 시도해보면 JOIN, COUNT, EXISTS 등이 얽힌 복잡한 쿼리가 생성되고, 이로 인해 쿼리의 가독성은 물론 유지보수와 테스트 측면에서도 부담이 상당히 커집니다. 

 

또한 좋아요 여부는 로그인 사용자만을 위한 정보이므로 익명 사용자는 이 쿼리를 생략하게 됩니다. 즉 이 쿼리는 조건적으로 분기되는 쿼리이기 때문에 기타 정보들과 한 번에 묶어 가져오기엔 구조가 오히려 더 복잡해진다고 생각했습니다. 

 

기술 스택도 JOIN보다는 분리 조회가 더 깔끔했는데, 이번 API에서는 항상 인기글 3개만 조회되기 때문에, 모임 ID를 기준으로 IN (...) 조건으로 한 번에 조회하고, Java에서 groupingBy(meetingId)로 정리하는 것이 훨씬 단순하고 빠릅니다.

JOIN으로 가져온다고 해도 N:N 관계 정리 로직은 결국 코드에서 또 필요하므로 쿼리 통합의 이점이 거의 없는 구조입니다.

 

모임 수가 고정(3개)이기 때문에 좋아요 수 계산이나 좋아요 여부 확인 쿼리 역시 3번만 반복되면 됩니다. 이 정도 수준의 반복 쿼리는 성능에 큰 영향을 주지 않을 것으로 판단했습니다. 

 

장기적으로 서비스 확장이나 캐싱 적용에도 유연하게 대응할 수 있는 설계라고 판단했습니다. 

 

6. 결과를 확인해보자

개선된 쿼리 구조의 결과값

 

구조를 바꾼 덕분에 총 쿼리 수는 약 1000회 이상 → 9회 수준으로 감소했고,
전체 응답 시간도 평균 5초에서 약 80ms 이내로 줄어드는 효과를 얻을 수 있었습니다.