알림 목록 API 쿼리 최적화 — 34컬럼 Projection · `EXISTS` semi-join · `DISTINCT` IN 절
전체 보기접기
- 01채팅 모듈 조회 성능 개선기 — 배치 페칭과 레이어 정리
- 02인덱스가 안 타는 이유 — 만들어도 느린 쿼리의 공통 원인
- 03채팅방 목록 요약 API 성능 개선기 - 응답 경량화와 N+1 제거
- 04실행 계획 완전 정복 — EXPLAIN으로 쿼리 옵티마이저의 선택을 읽는 법
- 05N+1 쿼리 문제 완전 정복 — 왜 느려지고 어떻게 해결할까
- 06페이지네이션 완전 정복 — OFFSET/LIMIT이 느려지는 이유와 커서 기반 조회 설계
- 07MySQL 파티셔닝 완전 정복 — 수직/수평/범위 분할은 어떻게 쓰나요?
- 08반정규화 완전 정복 — 언제 쓰고, 무엇을 감수해야 하나요?
- 09관리자 예약 목록 API `Broken pipe` 해결기 — 루프 안 N+1과 공유 DTO 반복 생성
- 10알림 목록 API 쿼리 최적화 — 34컬럼 Projection · `EXISTS` semi-join · `DISTINCT` IN 절읽는 중
이런 증상을 겪고 계신가요?
알림 목록 같은 "단순 조회" API인데 특정 경로만 유독 느려지는 증상입니다.
- 한 API의 P95가 10초를 넘고, 특정 조건일 때만 재현됩니다
- 쿼리 로그에 컬럼이 30개 넘게 딸려 오지만, 후속 로직은 한 컬럼만 씁니다
IN절에 같은 ID가 수십 번 중복으로 들어가 있습니다- 두 테이블이 같은 DB에 있는데 두 번의 쿼리로 분리되어 앱 ↔ DB 왕복이 추가됩니다
- 쿼리의
ORDER BY결과가 호출자 쪽에서 다시 정렬되어 버려집니다
이 글은 DB 스키마·인덱스 변경 없이, 결과 집합 동일성을 100% 유지한 채, 앱 레벨 캐시도 도입하지 않고, 오직 쿼리와 호출 흐름을 재구성해서 위 증상들을 해결한 과정을 정리합니다. 동작 동일성이 가장 엄격한 제약이었고, 그래서 검토했지만 선택하지 않은 대안도 함께 기록합니다.
기준: 이 글은 Spring Data JPA + Hibernate 6 + MySQL 8.4 InnoDB 환경을 가정합니다. 클라이언트 코드는 Kotlin. 공식 문서 기준은 MySQL 8.4 —
EXISTSandNOT EXISTSSubqueries, Optimizing Subqueries with Semijoin Transformations, Hibernate 6 User Guide — §10. Query Language를 참고했습니다. N+1과 배치 조회 기본 원리는 N+1 글과 N+1 해결 도구 글에서 이미 다뤘습니다.
Phase 1. 병목 네 가지를 먼저 나열하고 시작합니다
문제 파악 단계에서 원인이 네 축에 걸쳐 있다는 걸 확인했습니다. 글 읽기 편하도록 먼저 정리해 두고, Phase마다 하나씩 해체합니다.
| # | 병목 | 징후 |
|---|---|---|
| 1 | 불필요한 34컬럼 fetch + 엔티티 hydration | SELECT * 로 TEXT 컬럼까지 딸려옴 |
| 2 | IN 절 중복 폭증 |
고유 75개 ID가 100개 이상으로 부풀어 있음 |
| 3 | 두 번의 DB 왕복 | 같은 DB 두 테이블을 두 쿼리로 분리 |
| 4 | 버려지는 ORDER BY idx DESC |
호출자가 다른 컬럼으로 재정렬 |
각 병목은 독립적으로 작은 비용이지만, 같은 요청에서 겹치면 10초 로 확대됩니다. 순서대로 제거합니다.
Phase 2. 첫 번째 병목 — 34컬럼을 전부 가져와 엔티티로 만든다
문제: 실제로 쓰는 컬럼은 하나뿐
문제의 Repository 쿼리는 이런 구조였습니다.
// Before — 엔티티 전체를 읽음
@Query("""
select n from Notification n
where n.userId = :userId
and n.type in :typeIn
and n.subscriptionId in :subscriptionIds
and n.itemId not in :bannedItems
order by n.idx desc
""")
fun findByKeywordSubscriptions(
userId: String,
typeIn: List<NotificationType>,
subscriptionIds: List<String>,
bannedItems: List<String>,
): List<Notification>
select n은 엔티티 전체를 가져옵니다. 이 엔티티는 본문(body), 템플릿 파라미터, 원본 에러/성공 메시지 등 TEXT 컬럼을 포함한 34개 필드 를 갖고 있었습니다. 그런데 호출자 코드를 따라가 보면 결과에서 실제로 쓰는 건 딱 하나였습니다.
val itemIds = allNotifications.mapNotNull { it.itemId }
34개 컬럼을 가져와서 34개 필드를 채우고, 34개 필드 중 1개만 읽는 구조. 불필요한 네트워크 전송과 JVM 메모리 부담이 붙습니다.
더 큰 부담은 Hibernate 엔티티 hydration 입니다. 각 행이 Notification 엔티티로 materialize되면서 다음 일들이 일어납니다.
- 1차 캐시(영속성 컨텍스트)에 등록 — 행 수만큼 메모리 점유
- 변경 감지(
dirty checking)의 스냅샷 생성 - 이후
flush시점에 비교 대상이 됨
변경할 생각이 없는 엔티티를 로드만 하는 행위가 이미 비용 입니다. 영속성 컨텍스트의 동작 원리는 앞 글에서 자세히 다뤘습니다.
해결: 단일 컬럼 Projection으로 바꾼다
itemId 하나만 뽑아내는 프로젝션 쿼리로 교체합니다. 반환 타입도 List<String> 으로 단순화하고, 이 단계에서 중복 제거 DISTINCT 도 같이 적용합니다.
// After — 1 컬럼 projection, 엔티티 hydration 없음
@Query("""
select distinct n.itemId from Notification n
where n.userId = :userId
and n.type in :typeIn
and n.itemId is not null
and n.itemId not in :bannedItems
""")
fun findItemIdsByActiveSubscriptions(
userId: String,
typeIn: List<NotificationType>,
bannedItems: List<String>,
): List<String>
ORDER BY 절과 subscriptionId IN (...) 절은 Phase 4·5에서 이어서 다룹니다.
왜 이 방식이 유효한가요?
- 네트워크 전송량이 줄어듭니다. 34컬럼(TEXT 포함) → 1컬럼. 한 행당 수 KB에서 수십 바이트로 축소
- 엔티티 hydration이 완전히 사라집니다.
List<String>매핑은 영속성 컨텍스트를 거치지 않습니다 - 후속
IN쿼리의 파라미터가 작아집니다.DISTINCT로 중복itemId가 미리 제거됩니다
Projection은 "필요한 컬럼만 뽑는 최적화"처럼 가볍게 들리지만, 실제로는 Hibernate가 엔티티로 처리할지 값 목록으로 처리할지를 바꾸는 구조적 변화 입니다.
Phase 3. 두 번째 병목 — IN 절에 같은 ID가 수십 번 반복된다
문제: 상위에서 중복을 걸러주지 않음
쿼리 파라미터를 실측해 보면 IN 절이 이렇게 찍히고 있었습니다.
subscriptionId IN (
'sub_0001',
'sub_0002',
...
'sub_0075',
'sub_0075', -- 같은 ID가 반복
'sub_0075',
...
)
고유한 ID는 약 75개인데, 실제 전달되는 리스트 크기는 100개+ 였습니다. 원인은 상위 코드에서 리스트를 조합할 때 .distinct() 가 누락된 것이었습니다.
// 문제가 있던 상위 코드
val subscriptionIds = activeSubscriptions.map { it.id } // 중복 그대로
activeSubscriptions 는 여러 조건이 합쳐진 집합이라 같은 id가 반복 등장할 수 있었습니다. IN 절의 중복은 아래 세 곳에서 비용을 만듭니다.
- 쿼리 파서/플래너 비용 —
IN절이 클수록 파싱·상수 테이블 구성 비용이 증가 - 옵티마이저 판단 비용 — MySQL의
IN절 처리는 리스트 크기에 따라 range/ref/index_merge 등 다른 접근을 선택하므로 크기가 플랜을 흔들 수 있음 - 네트워크 전송량 — 각 UUID가 수십 바이트, 수백 개면 KB 단위 오버헤드
해결: 수집 단계에서 .distinct()
val subscriptionIds = activeSubscriptions
.mapNotNull { it.id }
.distinct()
val bannedItems = itemPort.listBySellerInAndStatusIn(bannedUserIds, ...)
.mapNotNull { it.id }
.distinct()
"너무 당연한 수정 아니냐"고 느낄 수 있지만, 실무에서 놀랄 만큼 자주 누락되는 패턴 입니다. 리스트 조합이 여러 단계를 거칠수록 어디서 중복이 들어오는지 파악하기 어렵고, 컴파일러가 경고하지도 않습니다.
IN 절에 들어가는 컬렉션은 수집 직후 반드시 .distinct() 를 한 번 거친다 — 이 습관만 잡아도 상당수의 비용이 사라집니다.
Phase 4. 세 번째 병목 — 같은 DB 두 테이블을 두 번에 나눠 조회
문제: 앱 ↔ DB 왕복 1회가 불필요
기존 흐름은 이렇게 두 단계로 나뉘어 있었습니다.
Step 1. (DB) keyword_subscription 조회
WHERE userId = :userId
AND isActive = true
AND keywordIdx IN :keywordIdxes
→ 결과 id 리스트를 앱으로 가져옴 (75개)
Step 2. (DB) notification 조회
WHERE ...
AND subscriptionId IN (75개 UUID)
→ 결과 itemId 리스트를 앱으로 가져옴
두 테이블이 같은 DB 인스턴스 에 있고, 개념적으로 semi-join 관계입니다. 그런데 앱이 중간에 id를 한 번 받아 다시 IN 절에 밀어넣는 구조 때문에 다음이 추가 비용으로 붙습니다.
- 앱 ↔ DB 추가 왕복 1회
keyword_subscription의id75개를 서버 → 앱 → 서버 로 두 번 전송IN (…)절의 상수 리스트가 쿼리 플래너를 다시 파싱하게 만듦
해결: EXISTS 서브쿼리로 semi-join을 한 번에
두 쿼리를 하나로 합치는 표준 도구가 EXISTS 서브쿼리 입니다. JPQL도 그대로 지원합니다.
@Query("""
select distinct n.itemId from Notification n
where n.userId = :userId
and n.type in :typeIn
and n.itemId is not null
and n.itemId not in :bannedItems
and exists (
select 1 from KeywordSubscription k
where k.id = n.subscriptionId
and k.userId = :userId
and k.isActive = true
and k.keywordIdx in :keywordIdxes
)
""")
fun findItemIdsByActiveSubscriptions(
userId: String,
typeIn: List<NotificationType>,
keywordIdxes: List<Long>,
bannedItems: List<String>,
): List<String>
왜 이 방식이 유효한가요?
- 앱 ↔ DB 왕복 1회 제거 — 왕복 비용(네트워크 RTT + 앱 스레드 점유)이 한 번 사라짐
- 중간에 떠다니던 UUID 리스트가 사라짐 —
IN (?, ?, …)에 담기던 75개 문자열 상수가 제거됨 - 기존 인덱스가 그대로 적중 —
keyword_subscription테이블의(userId, isActive, keywordIdx)인덱스를 서브쿼리가 그대로 탑니다. 스키마 변경 없이 자연스럽게 semi-join 성립
MySQL 8 이후 EXISTS 는 semijoin 변환 으로 내부적으로 최적화됩니다. 자세한 내용은 MySQL 문서 — Semijoin Transformations를 참고하세요.
Phase 5. 네 번째 병목 — 쓰지도 않는 ORDER BY
문제: 호출자가 다시 정렬한다
원래 쿼리는 ORDER BY n.idx DESC 로 정렬했습니다. 그런데 이 결과를 받는 호출자는 아래처럼 다른 컬럼으로 재정렬 하고 있었습니다.
// 호출자 쪽 쿼리
itemPort.listByIdInAndStatusesInWithOffset(
idIn = itemIds, // 알림 쿼리에서 받은 리스트
statuses = statuses,
offset = offset,
limit = limitSize,
)
// itemPort 내부 쿼리
// SELECT * FROM item
// WHERE id IN :idIn AND status IN :statuses
// ORDER BY firstPublishedAt DESC
// LIMIT :limitSize OFFSET :offsetSize
두 가지 관찰.
IN :idIn은 집합 연산 입니다. 리스트의 순서도 중복도 결과에 영향을 주지 않습니다- 최종 정렬은
item.firstPublishedAt기준으로 호출자가 수행 합니다
즉 알림 쿼리에서 ORDER BY n.idx DESC 로 정렬한 결과는 그 다음 단계에서 그대로 버려집니다. filesort 비용만 낭비되는 셈입니다.
해결: 알림 쿼리의 ORDER BY 제거
Phase 2·4에서 만든 최종 쿼리에는 ORDER BY 가 이미 없습니다.
@Query("""
select distinct n.itemId from Notification n
where ...
and exists (...)
""") // ORDER BY 없음
안전성 증명 — 왜 ORDER BY 를 빼도 결과가 달라지지 않나요?
- 알림 쿼리의 반환값
itemIds는 다음 단계의IN절 입력 으로만 쓰임 IN은 순서/중복 모두에 무관한 집합 연산- 최종 페이지 결과는
item.firstPublishedAt DESC + LIMIT/OFFSET으로 결정
따라서 알림 쿼리 내 정렬은 최종 응답에 아무 영향이 없습니다. 제거가 안전합니다.
Phase 6. 검토했지만 선택하지 않은 대안들
성능만 보면 더 공격적인 선택지들이 있었지만, 제약이 있었습니다. "결과 집합과 동작이 100% 동일해야 한다" 는 원칙. 아래 세 안은 이 기준에서 탈락했습니다. 왜 탈락시켰는지가 이 글의 가장 중요한 교훈이기도 합니다.
대안 A — NOT IN 절을 앱 레벨 차집합으로 변환
DB에 NOT IN (…bannedItems…) 를 보내는 대신, 알림을 먼저 읽고 앱에서 bannedItems 를 빼는 방식.
- correctness 리스크 — 기존 코드에는
bannedItems.ifEmpty { listOf("") }같은 방어 로직이 있었음. 앱 차집합으로 옮기면 이 의미가 달라질 수 있음 - 성능 역효과 가능성 —
bannedItems가 크면NOT IN이 걸러내 주던 행들이 전부 앱으로 올라와 메모리에서 걸러짐 → 네트워크 전송량과 GC 부담이 오히려 증가
성능이 단조로 좋아진다고 확신할 수 없었고, 잠재적 buggy 경로도 있어 제외.
대안 B — Redis 캐시 도입 (bannedUsers, activeSubscriptions)
읽기가 빈번한 목록 캐싱.
- staleness 문제 — 캐시 TTL 안에서는 "차단 직후에도 해당 유저의 아이템이 알림에 노출" 같은 관측 가능한 동작 변경 이 발생
- 완벽한 invalidation도 race가 남음 — 분산 환경에서 캐시 갱신 시점과 쓰기 시점 사이의 틈
캐시 전략·무효화·race condition의 일반 논의는 캐시 전략 글과 캐시 스탬피드 글에서 다뤘습니다. 여기서는 동작 동일성 제약을 깨뜨린다 는 이유로 제외.
대안 C — 선행 조회 병렬화 (coroutineScope { async { ... } } 등)
keyword_subscription 조회와 bannedItems 조회를 병렬로 실행.
- 트랜잭션 스냅샷 경계 변화 — 순차 실행에서는 동일 트랜잭션·동일 스냅샷으로 읽던 두 데이터가, 병렬화 시 서로 다른 커넥션에서 실행될 수 있음
- 읽기 일관성 미세 차이 —
REPEATABLE READ기준에서 트랜잭션이 갈리면 읽기 스냅샷 시점이 달라져, 경계 사례에서 기존과 다른 결과가 나올 수 있음
"아무도 눈치 못 챌 수준"이지만 읽기 일관성 의미가 달라진다는 사실 자체 가 이 제약에서는 선택할 수 없는 근거가 됩니다. 격리 수준과 스냅샷의 의미는 격리 수준 글에서 다뤘습니다.
배제의 공통 원칙
세 대안을 배제한 이유는 결국 하나입니다.
성능 이득이 확실하지 않거나, 성능 이득이 있더라도 동작이 "관측 가능하게" 달라지는 변경은 선택하지 않는다.
성능 개선은 correctness 위에서만 의미가 있고, correctness를 양보한 성능은 나중에 더 큰 버그 비용으로 돌아옵니다.
Phase 7. 최종 코드
Phase 2–5 를 모두 합친 결과입니다.
interface NotificationRepository : JpaRepository<Notification, Long> {
/**
* 활성 키워드 구독에 연결된 알림에서 itemId만 projection.
* EXISTS로 semi-join, ORDER BY 제거, 단일 컬럼 반환.
*/
@Query("""
select distinct n.itemId from Notification n
where n.userId = :userId
and n.type in :typeIn
and n.itemId is not null
and n.itemId not in :bannedItems
and exists (
select 1 from KeywordSubscription k
where k.id = n.subscriptionId
and k.userId = :userId
and k.isActive = true
and k.keywordIdx in :keywordIdxes
)
""")
fun findItemIdsByActiveSubscriptions(
userId: String,
typeIn: List<NotificationType>,
keywordIdxes: List<Long>,
bannedItems: List<String>,
): List<String>
}
class NotificationService(
private val notificationRepository: NotificationRepository,
private val subscriptionPort: SubscriptionPort,
private val itemPort: ItemPort,
) {
fun listKeywordNotifications(
userId: String,
keywords: List<String>,
): List<Item> {
// 1) 입력 파라미터 수집 단계에서 .distinct()
val keywordIdxes =
(if (keywords.isNotEmpty()) subscriptionPort.list(keywords)
else subscriptionPort.list(userId))
.mapNotNull { it.keywordIdx }
.distinct()
val bannedItems = itemPort
.listBySellerInAndStatusIn(loadBannedUsers(userId), ...)
.mapNotNull { it.id }
.distinct()
// 2) 단일 컬럼 projection + EXISTS semi-join
val itemIds = notificationRepository.findItemIdsByActiveSubscriptions(
userId, NOTIFICATION_TYPES, keywordIdxes, bannedItems,
)
// 3) 최종 정렬·페이징은 item 쪽에서 수행
return itemPort.listByIdInAndStatusesInWithOffset(
itemIds, ACTIVE_STATUSES, offset, limitSize,
)
}
}
핵심 변경을 한 줄로 요약하면 네 가지입니다.
- Repository:
select n→select distinct n.itemId+EXISTS서브쿼리 +ORDER BY제거 - Service: 선행
subscription.list호출 제거 (EXISTS로 흡수됨) - Service:
IN절에 들어가는 모든 리스트에.distinct()적용 - Repository: 반환 타입
List<Notification>→List<String>
결과
| 항목 | Before | After |
|---|---|---|
| SELECT 컬럼 수 | 34 | 1 |
| 엔티티 hydration | 행 수만큼 | 0 |
IN 절 크기 (고유/실제) |
75 / 100+ | 75 / 75 |
| DB 왕복 | 2회 | 1회 |
ORDER BY (본 쿼리) |
idx DESC (버려짐) |
없음 |
| 인덱스 변경 | — | 없음 |
| 스키마 변경 | — | 없음 |
낙관적 시나리오에서 P95 10초 → 1~3초 범위를 목표로 했고, 실측도 그 범위 안에 들어왔습니다. 비관적 시나리오(문제의 유저 매칭 행 수가 수만 건일 때)에서도 엔티티 hydration 제거와 왕복 감소의 효과는 상수로 보장됩니다.
배포 전 점검
같은 종류의 쿼리 최적화를 할 때 권장하는 검증 순서입니다.
- 스테이징에서
EXPLAIN비교 — 변경 전/후 실행 계획 확인- 서브쿼리가 기존 인덱스를 타는지
filesort가 사라졌는지type이ref/range/eq_ref등 의도한 접근인지
- 실제 응답시간 측정 — 문제 유저·파라미터로 before/after P50/P95 비교
- 결과 동일성 회귀 테스트 — 동일 입력에 대해 기존 구현과 새 구현의 응답을 diff. 최종 아이템 리스트(순서 포함)가 일치하는지 확인
특히 3번은 "동작 동일성을 깨지 않겠다"는 제약을 실제로 보증하는 유일한 방법입니다.
교훈
이번 개선에서 일반화할 수 있는 점 다섯 가지.
- "SELECT n" 은 모든 컬럼 fetch + 엔티티 hydration — 필요한 컬럼이 한두 개뿐이면 projection으로 바꾸는 것이 거의 항상 이득입니다. JPQL의
select distinct n.column형태를 적극 활용 IN절에 들어가는 컬렉션은 수집 직후.distinct()— 쿼리 파서·옵티마이저·네트워크 세 군데 모두에서 비용을 만드는 중복은, 한 줄로 막을 수 있는 흔한 실수- 두 번 왕복하는 흐름은
EXISTS또는 JOIN으로 한 번에 — 같은 DB의 두 테이블을 앱에서 왕복 합칠 이유가 없다면 쿼리 레벨 semi-join으로 합치는 것이 자연스러운 정답 - 호출자가 재정렬하는 쿼리의
ORDER BY는 지울 수 있습니다 — filesort는 공짜가 아닙니다. "어차피 버려지는 정렬"인지 호출 체인을 한번 따라가 보면 간단히 드러납니다 - 성능 개선 옵션을 평가할 때 correctness cost를 가장 먼저 본다 — 캐시·병렬화·앱 차집합처럼 매력적인 옵션일수록 "관측 가능한 동작 변경" 이 숨어 있는지 먼저 점검. 성능은 correctness 위에서만 의미가 있습니다
마지막 교훈을 한 문장으로 줄이면 이렇게 됩니다.
"빠르지만 미묘하게 다르게 동작하는" 코드는 언젠가 더 비싼 버그가 됩니다. 동작 동일성이 가장 값싼 최적화입니다.
다음으로 읽어볼 글
관리자 예약 목록 API `Broken pipe` 해결기 — 루프 안 N+1과 공유 DTO 반복 생성
관리자용 예약 목록 API가 Broken pipe를 뱉던 원인을 추적해 루프 안 N+1 · 공유 Venue DTO 반복 생성 · 무제한 조회 세 가지를 구조로 풀고, DB 쿼리를 2,500회에서 5회로 줄인 과정을 정리합니다.
채팅방 목록 요약 API 성능 개선기 - 응답 경량화와 N+1 제거
채팅 목록 전용 요약 API가 이름만 가벼웠던 이유를 추적하고, 응답 필드 최적화와 배치 조회로 DB 쿼리를 약 80% 줄인 과정을 정리합니다.
채팅 모듈 조회 성능 개선기 — 배치 페칭과 레이어 정리
`innerJoin`으로 인한 누락, 불필요한 Redis 캐싱, 중복 모델 문제를 쿼리 최적화와 레이어 정리로 해결한 과정을 공유합니다.