문제 상황
공고 상세 조회 API에서 공고 상세 정보(Announce), 사용자의 즐겨찾기 여부(MemberFavorite), 제출 서류 목록(Document) 세 가지 데이터를 함께 반환해야 했습니다.
초기 구현에서는 이 세 가지를 모두 개별 쿼리로 분리하여 조회했습니다.
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는 별도로 조회합니다.
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 = ?
SELECT * FROM document WHERE announce_id = ?
Querydsl로 즐겨찾기 여부를 확인하는 경우에는 다음과 같이 구현할 수 있습니다.
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 구조가 최적의 선택이었습니다.
개선 결과
| 기존 (3 Query) | 3 | 3,000 QPS |
| 개선 (2 Query) | 2 | 2,000 QPS |
DB 부하가 약 33% 감소했습니다.
추가로 챙긴 것 — 인덱스 설정
쿼리 수를 줄이는 것과 함께, 각 쿼리의 실행 속도 자체도 중요합니다. WHERE 조건에 자주 사용되는 칼럼에 인덱스를 추가하여 각 쿼리의 실행 속도도 함께 개선했습니다.
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 컬렉션은 별도로 조회하는 패턴을 기억해 두면 유사한 상황에서 빠르게 판단할 수 있습니다.
'❄️ 트러블 슈팅' 카테고리의 다른 글
| [트러블 슈팅] SOS 상세 조회 API의 Row Explosion 문제 해결 (0) | 2026.03.22 |
|---|---|
| [트러블슈팅] getReferenceById를 이용한 연관 관계 저장 최적화 (0) | 2026.03.22 |
| [JWT 트러블슈팅] 매 요청마다 DB 조회가 발생하는 문제와 해결 방법 (0) | 2026.03.22 |
| [트러블슈팅] @OneToOne(mappedBy) LAZY 로딩이 동작하지 않는 이유와 해결 방법 (0) | 2026.03.22 |
| [트러블슈팅] JPA Cascade와 Aggregate Root 적용하기 (0) | 2026.03.22 |