❄️ 트러블 슈팅

[트러블 슈팅]공고 상세 조회 API의 불필요한 DB 쿼리 줄이기

le2donguk 2026. 3. 22. 21:25

문제 상황

공고 상세 조회 API에서 공고 상세 정보(Announce), 사용자의 즐겨찾기 여부(MemberFavorite), 제출 서류 목록(Document) 세 가지 데이터를 함께 반환해야 했습니다.

초기 구현에서는 이 세 가지를 모두 개별 쿼리로 분리하여 조회했습니다.

 
 
sql
SELECT * FROM announce WHERE announce_id = ?

SELECT EXISTS (
    SELECT 1 FROM member_favorite
    WHERE member_id = ? AND announce_id = ?
)

SELECT * FROM document WHERE announce_id = ?

API 요청 한 건당 DB와 3번의 Round Trip이 발생하는 구조였습니다.

 

참고)

사실 exist를 쓰는 것보단 count를 해서 그 값이 1 임을 확인하는 게 성능 상 더 좋다고 들었습니다


문제 원인

트래픽이 늘어날수록 DB 부하가 선형으로 증가하는 구조였습니다. 1,000 RPS 기준으로 DB에는 3,000 QPS가 발생하고,

5,000 RPS라면 15,000 QPS까지 치솟습니다. (RPS = request per second , QPS = query per second)

더 근본적인 문제는 즐겨찾기 여부(is_favorite)가 공고 조회 시 LEFT JOIN으로 충분히 함께 가져올 수 있는 데이터였다는 점입니다.

별도 쿼리로 분리할 이유가 없었는데 불필요하게 한 번 더 쓰고 있었습니다.


해결 방안 비교

총 3가지 방안을 검토했습니다.

 

방안 1. 기존 방식 유지 (3 Query)

  • 구현이 단순하고 쿼리가 명확하지만, 요청당 DB Round Trip이 3번 발생하는 문제가 그대로 남습니다.

방안 2. Announce + Favorite JOIN (2 Query)

  • 공고 조회 시 즐겨찾기 여부를 LEFT JOIN으로 함께 조회하고, Document는 별도로 조회하는 방식입니다.
  • 쿼리 복잡도가 소폭 증가하지만 Round Trip을 1회 줄일 수 있습니다.

방안 3. 모든 테이블 단일 JOIN (1 Query)

  • 직관적으로는 가장 효율적으로 보이지만, Document가 1:N 관계이기 때문에 문제가 생깁니다.
  • Document가 5건이면 Announce 행도 5개 중복 생성되고, 이를 DTO로 매핑하는 로직이 복잡해지며 메모리 사용량도 늘어납니다.
  • 쿼리 수를 줄이는 이득보다 생기는 손해가 더 큽니다.

해결 방법

방안 2인 2 Query 구조를 선택했습니다.

Announce와 Favorite를 LEFT JOIN으로 한 번에 조회하고, Document는 별도로 조회합니다.

 
 
sql
SELECT
    a.*,
    CASE WHEN mf.member_favorite_id IS NULL THEN false ELSE true END AS is_favorite
FROM announce a
LEFT JOIN member_favorite mf
    ON mf.announce_id = a.announce_id
    AND mf.member_id = ?
WHERE a.announce_id = ?
 
 
sql
SELECT * FROM document WHERE announce_id = ?

Querydsl로 즐겨찾기 여부를 확인하는 경우에는 다음과 같이 구현할 수 있습니다.

 
 
java
boolean isFavorite = queryFactory
    .selectOne()
    .from(memberFavorite)
    .where(
        memberFavorite.member.id.eq(memberId),
        memberFavorite.announce.id.eq(announceId)
    )
    .fetchFirst() != null;

 2 Query 구조를 선택한 이유

1:1 또는 단순 존재 여부를 확인하는 데이터는 JOIN으로 합치고, 1:N 관계의 컬렉션은 별도로 조회하는 패턴이 가장 보편적으로 사용됩니다.

상품 상세, 주문 상세, 게시글 상세 등 대부분의 Detail API가 이 구조를 따릅니다. 공고 상세도 이 패턴에 정확히 부합했기 때문에 자연스럽게 2 Query 구조가 최적의 선택이었습니다.


개선 결과

방식Query 수1,000 RPS 기준 DB 쿼리
기존 (3 Query) 3 3,000 QPS
개선 (2 Query) 2 2,000 QPS

DB 부하가 약 33% 감소했습니다.


추가로 챙긴 것 — 인덱스 설정

쿼리 수를 줄이는 것과 함께, 각 쿼리의 실행 속도 자체도 중요합니다. WHERE 조건에 자주 사용되는 칼럼에 인덱스를 추가하여 각 쿼리의 실행 속도도 함께 개선했습니다.

 
 
sql
CREATE INDEX idx_member_favorite_member_announce
ON member_favorite(member_id, announce_id);

CREATE INDEX idx_document_announce
ON document(announce_id);

정리

방식평가

 

3 Query 구현은 단순하나 DB 부하 증가
2 Query ✅ 쿼리 수와 복잡도의 균형이 맞는 최적 구조
1 Query 1:N 관계에서 데이터 중복 문제 발생

단순히 쿼리 수를 줄이는 것이 목표가 아니라, 데이터의 관계 구조를 기준으로 합리적인 쿼리 분리 지점을 찾는 것이 핵심이었습니다. 1:1 관계는 JOIN으로 합치고, 1:N 컬렉션은 별도로 조회하는 패턴을 기억해 두면 유사한 상황에서 빠르게 판단할 수 있습니다.