post 약 100만 건, comment 약 1,180만 건이 적재된 상태에서 실제 서비스에 더 가까운 조건을 만들기 위해 hot post/comment 기준으로 likes 450만 건을 추가한 뒤 부하 테스트를 진행했습니다.
테스트는 JMeter 100개 스레드, ramp-up 300초, duration 600초 조건으로 진행했습니다. 서버와 MySQL은 EC2 t2.micro 동일 인스턴스의 Docker 환경에서 실행했고, HikariCP 최대 커넥션은 30으로 설정했습니다.
likes 추가 전과 동일한 조건으로 테스트를 돌리자 에러가 대량 발생했습니다.
에러율: 15.53%
평균 응답시간: 10,460ms
P90: 30,000ms
Throughput: 8.88 req/s
HikariPool-1 - Connection is not available, request timed out after 30000ms
(total=30, active=30, idle=0, waiting=...)
DB 커넥션이 소진되며 요청이 줄을 서다 타임아웃으로 실패하는 패턴이었습니다.
Slow Query Log 기준: long_query_time=1s + log_queries_not_using_indexes=ON
Slow Query Log를 보니 likes 테이블에서 450만 건을 전체 스캔하는 쿼리가 찍혀 있었습니다.
Query_time: 47.2s Rows_examined: 4,500,000
SELECT l.target_id,
SUM(CASE WHEN l.type = 'LIKE' THEN 1 ELSE 0 END),
SUM(CASE WHEN l.type = 'DISLIKE' THEN 1 ELSE 0 END),
MAX(CASE WHEN l.user_id = :userId AND l.type = 'LIKE' THEN 1 ELSE 0 END),
MAX(CASE WHEN l.user_id = :userId AND l.type = 'DISLIKE' THEN 1 ELSE 0 END)
FROM likes l
WHERE l.target_type = 'COMMENT'
AND l.target_id IN (...)
GROUP BY l.target_id
댓글 반응 집계 쿼리가 likes 테이블 전체를 풀스캔하고 있었습니다.
당시 likes 테이블에는 Hibernate가 자동 생성한 UK 두 개만 있었습니다.
UK (user_id, target_type, target_id, type)
UK (guest_identifier, target_type, target_id, type)
집계 쿼리의 WHERE 조건은 target_type, target_id로 시작합니다. UK의 선두 컬럼은 user_id / guest_identifier이므로 Leftmost Prefix Rule에 의해 두 UK 모두 사용할 수 없었습니다. 450만 건 데이터가 추가되기 전까지는 풀스캔이 문제가 되지 않았지만, 규모가 커지면서 병목이 드러났습니다.
집계 쿼리에 맞는 인덱스를 추가했습니다.
@Table(
name = "likes",
indexes = {
@Index(name = "idx_like_target", columnList = "target_type,target_id")
},
uniqueConstraints = { ... }
)
결과
| 지표 | 인덱스 없음 | 인덱스 추가 |
|---|---|---|
| 에러율 | 15.53% | 0% |
| 평균 응답시간 | 10,460ms | 163ms |
| P90 | 30,000ms | 540ms |
| Throughput | 8.88 req/s | 96.7 req/s |
에러가 사라지고 응답시간이 정상화됐습니다. 하지만 이 인덱스는 문제를 해결한 것이 아니라 덮은 것에 가까웠습니다.
풀스캔을 유발한 쿼리는 다음 레포지토리 메서드였습니다.
@Query(value = """
SELECT l.target_id AS targetId,
SUM(CASE WHEN l.type = 'LIKE' THEN 1 ELSE 0 END) AS likeCount,
SUM(CASE WHEN l.type = 'DISLIKE' THEN 1 ELSE 0 END) AS dislikeCount,
MAX(CASE WHEN l.user_id = :userId AND l.type = 'LIKE' THEN 1 ELSE 0 END) AS likedByMe,
MAX(CASE WHEN l.user_id = :userId AND l.type = 'DISLIKE' THEN 1 ELSE 0 END) AS dislikedByMe
FROM likes l
WHERE l.target_type = 'COMMENT'
AND l.target_id IN (:commentIds)
GROUP BY l.target_id
""", nativeQuery = true)
List<CommentReactionSummary> findReactionSummaryForMember(
@Param("commentIds") Collection<Long> commentIds,
@Param("userId") Long userId);
쿼리 수를 줄이기 위해 likeCount, dislikeCount(집계)와 likedByMe, dislikedByMe(개인화)를 한 쿼리에서 한꺼번에 처리하다 보니 WHERE 조건이 target_type, target_id로 시작할 수밖에 없었고, user_id 선두의 UK는 사용하지 못했습니다.
UK 컬럼 순서를 바꾸는 방법은 왜 안 되나
UK 순서를 target_type → target_id → user_id → type으로 바꾸면 이 집계 쿼리는 인덱스를 탈 수 있습니다. 하지만 그러면 개인화 쿼리(WHERE user_id = ?)가 선두 조건이 맞지 않아 풀스캔이 됩니다.
| UK 선두 | 집계 쿼리 (WHERE target_type, target_id) | 개인화 쿼리 (WHERE user_id) |
|---|---|---|
user_id (현재) | 풀스캔 ❌ | 인덱스 ✅ |
target_type | 인덱스 ✅ | 풀스캔 ❌ |
집계와 개인화의 WHERE 선두가 서로 달라, 하나의 UK로 두 쿼리를 동시에 커버할 수 없습니다. 어느 방향이든 인덱스를 하나 더 추가해야 하는 구조입니다.
WHERE user_id = ?를 집계 쿼리에 추가하는 방법은 왜 안 되나
SUM(likeCount)는 특정 유저가 아닌 전체 유저의 좋아요 합계입니다. WHERE user_id = ?를 추가하면 해당 유저의 행만 남아 집계 값이 깨집니다. 집계와 개인화를 한 쿼리에서 처리하는 한, WHERE 선두는 target_type, target_id일 수밖에 없습니다.
결국 인덱스로 이 구조를 해결하려면 idx_like_target을 별도로 관리해야 하고, 데이터가 커질수록 이 인덱스의 유지 비용도 함께 커집니다. 쿼리 구조 자체를 바꾸는 것이 더 근본적인 해결이었습니다.
그런데 comment 테이블에는 이미 like_count, dislike_count 컬럼이 비정규화되어 저장되어 있었습니다.
likes 테이블을 집계해야 하는 것 → likedByMe, dislikedByMe (사용자별)
comment 테이블에 이미 있는 것 → likeCount, dislikeCount (비정규화 컬럼)
count 조회를 위해 450만 건 likes 테이블을 집계할 이유가 없었습니다. 쿼리를 분리했습니다.
① count → comment 테이블 직접 조회
SELECT c.id, c.like_count, c.dislike_count
FROM comment c
WHERE c.id IN (:commentIds)
② 개인화 → likes 테이블, user_id 선두 조건
SELECT l.target_id,
MAX(CASE WHEN l.type = 'LIKE' THEN 1 ELSE 0 END) AS likedByMe,
MAX(CASE WHEN l.type = 'DISLIKE' THEN 1 ELSE 0 END) AS dislikedByMe
FROM likes l
WHERE l.user_id = :userId
AND l.target_type = 'COMMENT'
AND l.target_id IN (:commentIds)
GROUP BY l.target_id
user_id가 선두 조건이 되어 UK (user_id, target_type, target_id, type)를 그대로 사용할 수 있습니다. idx_like_target 인덱스가 더 이상 필요 없어졌습니다.
인덱스 구조 정리
// 변경 전
@Table(
name = "likes",
indexes = {
@Index(name = "idx_like_target", columnList = "target_type,target_id") // 제거
},
uniqueConstraints = { ... }
)
// 변경 후: UK만으로 충분
@Table(
name = "likes",
uniqueConstraints = {
@UniqueConstraint(name = "uk_like_user_target_type",
columnNames = {"user_id", "target_type", "target_id", "type"}),
@UniqueConstraint(name = "uk_like_guest_target_type",
columnNames = {"guest_identifier", "target_type", "target_id", "type"})
}
)
likes 인덱스 구성을 기준으로 3가지를 비교했습니다.
| 구성 | likes 인덱스 |
|---|---|
| ① 쿼리 분리 전 / No-Index | UK만 |
| ② 쿼리 분리 전 / Index | UK + idx_like_target |
| ③ 쿼리 분리 후 / No-Index | UK만 |
| 지표 | ① 분리 전 / No-Index | ② 분리 전 / Index | ③ 분리 후 / No-Index |
|---|---|---|---|
| 에러율 | 15.53% | 0% | 0% |
| 평균 응답시간 | 10,460ms | 163ms | 167ms |
| P90 | 30,000ms | 540ms | 593ms |
| Throughput | 8.88 req/s | 96.7 req/s | 96.5 req/s |
②와 ③의 수치가 동등합니다. 쿼리 분리 후에는 idx_like_target 없이 UK만으로도 동일한 성능을 유지했습니다. likes 풀스캔이 구조적으로 제거됐음을 확인했습니다.
count를 comment 테이블에서 읽기 때문에 likes 테이블과 comment 테이블의 like_count / dislike_count가 정합성을 유지해야 합니다. 좋아요 요청이 실패하거나 비정상 종료되는 경우 두 값이 어긋날 수 있습니다.
쿼리가 하나에서 둘로 늘었습니다. comment 조회와 likes 개인화 조회가 분리되어 트랜잭션 내에서 두 번의 DB 왕복이 발생합니다. 다만 집계 인덱스(idx_like_target)를 별도로 관리할 필요가 없어졌고, 두 쿼리 모두 기존 인덱스(PK, UK)를 활용합니다.
비정규화 구조의 정합성 위험을 보완하기 위해 매일 새벽 4시에 likes 테이블을 재집계해 post.likeCount, comment.likeCount와 비교하고, 불일치가 있으면 자동으로 보정하는 배치 job을 추가했습니다.
-- post 기준 (comment도 동일한 구조)
UPDATE post p
JOIN (
SELECT target_id,
SUM(CASE WHEN type = 'LIKE' THEN 1 ELSE 0 END) AS like_count,
SUM(CASE WHEN type = 'DISLIKE' THEN 1 ELSE 0 END) AS dislike_count
FROM likes
WHERE target_type = 'POST'
GROUP BY target_id
) l ON p.id = l.target_id
SET p.like_count = l.like_count,
p.dislike_count = l.dislike_count
WHERE p.like_count != l.like_count
OR p.dislike_count != l.dislike_count
불일치가 발생한 경우 보정 건수를 warn 로그로 남겨 이상 징후를 추적할 수 있도록 했습니다.
현재 배치는 likes 테이블 전체를 GROUP BY로 집계하는 구조라 데이터 규모가 커질수록 실행 시간이 선형으로 증가합니다. 규모가 커지면 배치 자체가 slow query가 되어 MySQL 전체 성능을 끌어내릴 수 있습니다.
다음 단계로 고려할 수 있는 방향은 두 가지입니다.
likes 테이블에 updated_at 컬럼을 추가하고, 마지막 배치 실행 시각 이후 변경된 target_id만 재집계합니다. likes 총량이 늘어도 배치 범위는 "오늘 변동분"으로 고정되어 실행 시간이 데이터 규모에 비례하지 않습니다.likes INSERT/DELETE 이벤트 발생 시 실시간으로 카운트를 갱신합니다. 배치 자체가 필요 없어지지만 Kafka + Debezium 인프라가 추가되므로 현재 규모에서는 적합하지 않습니다.또한 배치 실행 시간이 24시간을 초과하는 경우 다음 스케줄과 겹칠 수 있습니다. @Scheduled(cron = "...") 대신 @Scheduled(fixedDelay = ...) 방식으로 변경하면 이전 실행이 완료된 후 다음 실행이 시작되도록 보장할 수 있습니다.
이번 문제는 단순히 인덱스가 부족해서가 아니라, 집계와 개인화를 하나의 쿼리에서 함께 처리하면서 likes 테이블이 불필요하게 넓은 범위를 읽도록 만든 구조에서 시작됐습니다. idx_like_target을 추가하자 에러율은 15.53%에서 0%로, 평균 응답시간도 10,460ms에서 163ms로 정상화됐지만, 이는 구조적 문제를 인덱스로 보완한 임시 해결에 가까웠습니다.
쿼리를 분리한 뒤에는 count는 comment 테이블의 비정규화 컬럼에서, 개인화는 user_id 선두 조건으로 기존 UK를 활용하게 되어, 별도 집계 인덱스 없이도 평균 167ms, 에러율 0%를 유지할 수 있었습니다. 비정규화 count의 정합성을 위해 재집계 배치가 필요하다는 트레이드오프는 남아 있습니다.