티스토리 뷰

이커머스 서비스에 좋아요 기능을 추가하게 됐다. 단순히 좋아요를 저장하는 건 간단했지만, 문제는 그 다음이었다. 상품 조회 시 좋아요 수를 함께 반환해야 했고, 좋아요 순으로 상품을 정렬하는 요구사항도 뒤따랐다. "그냥 COUNT 하면 되지 않나?"라고 생각했는데, 실제로 그렇게 단순하지 않았다.

이 글에서는 좋아요 집계 방식별 조회 성능과 쓰기 비용을 직접 측정하고, 각 방식의 트레이드오프를 비교한다.

좋아요 집계 방식 비교 (실시간 집계 vs 비정규화)

사용자가 상품에 좋아요를 누르면 product_likes 테이블에 한 행씩 쌓인다. 상품 조회 시 좋아요 수를 함께 반환하는 방법은 크게 두 가지를 고려해 볼 수 있다.

  1. 실시간 집계 — 조회할 때마다 product_likes를 COUNT
  2. 비정규화 — products 테이블에 like_count 컬럼을 추가하여 좋아요 추가/삭제 시 동기적으로 갱신

첫 번째 방식은 직관적이다. 조회할 때마다 세면 된다. 하지만 좋아요가 많아지면 매번 세는 비용이 커지지 않을까? 두 번째 방식은 미리 세어두는 것이다. 쓰기가 늘어나는 대신 읽기가 빨라진다. 어느 쪽이 나을지 직접 측정해봤다.

매번 COUNT하는 경우

SELECT p.*,
       (SELECT COUNT(*) FROM product_likes l WHERE l.product_id = p.id) AS like_count
FROM products p
WHERE p.id = 1;
  1. products에서 PK로 1건 조회
  2. product_likes에서 product_id 인덱스를 스캔하여 해당 상품의 좋아요 5건을 읽음
  3. 5건을 COUNT하여 집계

Total Cost: 2.01 / 실행 시간: 0.0226ms

비정규화

SELECT id, name, price, like_count
FROM products
WHERE id = 1;
  1. products에서 PK로 1건 조회 — like_count가 이미 컬럼에 있으므로 추가 조회 없음

Total Cost: 0.0 / 실행 시간: 0.000083ms

성능 비교

방식 Total Cost 실행 시간 스캔 행 수
실시간 집계 2.01 0.0226ms 5
비정규화 0.0 0.000083ms 1

미리 세어두는 방식이 실시간 집계 대비 약 270배 빠르다. 단건 조회에서도 이 정도 차이가 나는데, 상품 목록을 20건씩 조회하면 더 벌어진다.

조회 성능은 비정규화가 압도적이다. 하지만 공짜는 아니다. 좋아요를 누르거나 취소할 때마다 like_count를 같이 갱신해야 한다.

BEGIN;
INSERT INTO product_likes (user_id, product_id) VALUES (1, 1);
UPDATE products SET like_count = like_count + 1 WHERE id = 1;
COMMIT;

쓰기 횟수가 2배가 되는 것보다 더 신경 쓰이는 건 동시성이다. product_likes INSERT는 각자 다른 행이므로 동시 처리가 가능하지만, productslike_count는 공유 자원이다. UPDATE시 같은 행에 대해 락을 잡는다. 동시에 여러 사용자가 같은 상품에 좋아요를 누르면 순차 대기하게 된다. 인기 상품일수록 이 병목이 심해진다. 다만 이커머스에서 좋아요가 쓰기 병목으로 이어질 가능성은 적고, 인스타그램 같은 SNS라면 얘기가 달라질 수 있다.

좋아요 순 정렬 인덱스 최적화

비정규화로 조회 성능을 개선했으니, 이제 좋아요 순 상품 정렬을 구현할 차례다. like_count 컬럼이 있으니 ORDER BY like_count만 붙이면 될 줄 알았다. 10만 건 정도면 인덱스 하나 걸면 빠르겠지, 라고 생각했는데 결과가 예상 밖이었다.

인덱스 생성 전

SELECT *
FROM products
ORDER BY like_count;
  1. 10만 건 전체 읽기
  2. 10만 건을 like_count 기준으로 정렬

Total Cost: 9999.0 / 실행 시간: 113.0ms

인덱스 생성 후

CREATE INDEX idx_like_count ON products (like_count);
  1. 10만 건 전체 읽기
  2. 10만 건을 like_count 기준으로 정렬

Total Cost: 9999.0 / 실행 시간: 194.0ms

인덱스를 걸었는데 cost가 동일하다. 인덱스를 만들면 당연히 빨라질 거라는 기대와는 정반대의 결과였다.

이유를 찾아보니, 전체 데이터를 조회하면 옵티마이저가 인덱스를 무시한다고 한다. 인덱스를 타면 정렬된 순서대로 읽은 뒤, 각 행의 실제 데이터를 테이블에서 가져와야 하는데 인덱스의 순서와 테이블의 물리적 저장 순서가 다르기 때문에 디스크의 여기저기를 점프하면서 읽게 된다. 랜덤 I/O다. 10만 건 전체를 이렇게 읽으면 순차 스캔 + 메모리 정렬보다 오히려 느려질 수 있어서 옵티마이저가 인덱스 대신 풀스캔을 선택한 것이다.

그렇다면 실제 서비스처럼 조회 건수를 제한하면 어떨까?

SELECT *
FROM products
ORDER BY like_count
LIMIT 20;

인덱스 생성 전

  1. 10만 건 전체 읽기
  2. 10만 건을 like_count 기준으로 정렬
  3. 정렬된 결과에서 상위 20건만 반환

Total Cost: 9999.0 / 실행 시간: 43.6ms

인덱스 생성 후

CREATE INDEX idx_like_count ON products (like_count);
  1. like_count 순으로 정렬된 인덱스에서 20건만 읽기
  2. 그대로 반환

Total Cost: 0.028 / 실행 시간: 1.16ms

LIMIT을 걸자 인덱스가 제 역할을 했다. like_count 기준으로 이미 정렬되어 있으므로 정렬 단계가 사라지고, 필요한 20건만 읽으면 되기 때문이다. Cost가 약 35만배(9999.0 → 0.028), 실행 시간이 약 37배(43.6ms → 1.16ms) 줄었다.

인덱스는 "걸면 빨라진다"가 아니라 "쿼리 패턴에 맞아야 빨라진다"는 걸 체감한 순간이었다.

더 알아보기 : 인덱스 정렬 방향에 따른 성능 차이

인덱스 생성 시 정렬 기준을 정하지 않으면 기본적으로 오름차순(ASC)으로 저장된다. 역방향(ORDER BY like_count DESC)으로 조회하면 인덱스를 뒤에서부터 읽는데, 성능 차이가 있을까?

역방향 스캔 실행 시간: 0.563ms (정방향 1.16ms와 거의 동일)

B-Tree 인덱스의 리프 노드는 양방향 링크드 리스트로 연결되어 있어서 정방향이든 역방향이든 탐색 비용이 거의 같다. 단일 컬럼 인덱스에서는 정렬 방향을 신경 쓰지 않아도 된다.

하지만 복합 인덱스에서는 다르다.

인덱스를 (like_count ASC, created_at ASC)로 생성한 경우:

-- 인덱스 활용 가능: 모든 컬럼의 정렬 방향이 동일
ORDER BY like_count ASC, created_at ASC
ORDER BY like_count DESC, created_at DESC

-- 인덱스 활용 불가: 컬럼별 정렬 방향이 다름
ORDER BY like_count DESC, created_at ASC

역방향 스캔은 인덱스 전체를 뒤집어 읽는 것이므로, 모든 컬럼이 함께 뒤집혀야 한다. 컬럼별로 정렬 방향이 다르면 인덱스 순서와 맞지 않아 별도 정렬이 필요하다.

비정규화 + 인덱스의 트레이드오프

여기까지 비정규화와 인덱스로 조회 성능을 크게 개선했다. 하지만 한 가지 찜찜한 부분이 있었다. 비정규화는 쓰기를 2번 하고, 인덱스는 데이터가 변경될 때마다 갱신된다. 조회가 빨라진 만큼 쓰기에서 대가를 치르는 건 아닌지 궁금했다. "느낌"이 아니라 숫자로 확인하고 싶어서 직접 측정해봤다.

인덱스가 생성된 컬럼의 쓰기 비용

like_count 컬럼에 대한 단건 쓰기 비용을 인덱스 유무로 비교했다. 100회씩 측정.

연산 인덱스 없음 인덱스 있음 차이
INSERT 1.483ms 1.616ms +0.133ms
UPDATE 1.597ms 1.496ms -0.101ms
DELETE 1.509ms 1.428ms -0.081ms

차이가 0.1ms 내외로 오차 범위다. 쓰기에서 인덱스 갱신 비용은 체감되지 않는다.

비정규화 쓰기 비용 부하 테스트 (k6)

단건 쓰기 비용은 미미했지만, 앞서 언급한 동시성 병목이 여전히 마음에 걸렸다. 같은 상품에 여러 사용자가 동시에 좋아요를 누르면 UPDATE 락 경합이 발생하는데, 실제로 어느 시점부터 문제가 되는 걸까? k6로 부하테스트를 수행했다. INSERT만 수행하는 엔드포인트와 INSERT + UPDATE(비정규화) 엔드포인트를 분리하여 비교했다.

항목 구성
WAS Spring Boot 1대 (Tomcat max-threads=200, HikariCP max-pool-size=40)
DB MySQL 8.0 1대 (Docker)
부하 테스트 도구 k6 v1.6.1
네트워크 WAS, DB, k6 모두 같은 로컬 머신에서 실행

1) 단일 쓰기 비용 비교
비정규화 전(INSERT 1회)과 비정규화 후(INSERT + UPDATE 트랜잭션)의 응답 시간을 비교한다. 1 VU, 500회 순차 요청.

방식 평균 응답 시간 p95 p99
INSERT만 3.75ms 5.65ms 6.88ms
INSERT + UPDATE (트랜잭션) 5.08ms 7.01ms 8.23ms

2) 동시성 병목 측정 — 같은 상품에 동시 좋아요
같은 상품에 동시 요청 수를 늘리며 응답 시간 변화를 측정한다. 각 VU가 고유한 userId로 1회씩 요청.

동시 요청 수 평균 응답 시간 p95 p99 에러율
10 25.27ms 31.78ms 32.15ms 0.0%
50 91.80ms 126.77ms 130.25ms 0.0%
100 113.35ms 189.18ms 193.91ms 0.0%
500 534.58ms 909.90ms 937.74ms 0.0%
1000 894.90ms 1630.00ms 1680.00ms 0.0%

3) 대조군 — 서로 다른 상품에 동시 좋아요
각 VU가 서로 다른 상품에 좋아요를 누른다. 락 경합이 없는 상태에서의 응답 시간을 측정하여, 2번과의 차이로 락 경합의 순수 비용 측정이 가능하다.

동시 요청 수 평균 응답 시간 p95 p99 에러율
10 21.60ms 22.10ms 22.17ms 0.0%
50 33.57ms 42.06ms 42.70ms 0.0%
100 55.89ms 75.94ms 76.72ms 0.0%
500 187.07ms 315.96ms 331.97ms 0.0%
1000 262.60ms 455.11ms 471.61ms 0.0%

결과 분석

단일 쓰기 비용 차이는 평균 1.33ms(35%)다. UPDATE가 하나 추가되는 셈인데 체감할 수 있는 수준은 아니다.

동시성 병목은 500명부터 급격히 나타난다. 100명까지는 평균 113ms 수준이지만 500명부터 534ms로 급증한다. UPDATE ... SET like_count = like_count + 1이 행 단위 락을 잡기 때문에, 같은 행에 대한 동시 UPDATE가 많아지면 락 대기 시간이 누적된다.

대조군(서로 다른 상품)은 1000명 동시 요청에도 평균 262ms인 반면, 같은 상품 1000명은 894ms다. 약 3.4배 차이로, 순수 락 경합 비용이 약 632ms에 달한다.

같은 상품에 동시에 500명 이상이 좋아요를 누르는 시나리오는 극히 드물다. 일반적인 이커머스에서 비정규화의 쓰기 비용과 동시성 병목은 실질적인 문제가 되지 않는다.

여기서 "충분하다"고 끝낼 수도 있었다. 하지만 한 가지 더 생각해보고 싶었다. 만약 이 서비스가 인스타그램처럼 인기 게시물에 좋아요가 폭발적으로 몰리는 환경이라면? 비정규화의 동기적 UPDATE를 구조적으로 제거할 수 있는 방법은 없을까?

집계 테이블 분리 (Materialized View)

부하 테스트 결과를 다시 보면, 병목의 본질이 보인다. INSERT는 각 사용자가 서로 다른 행에 쓰니까 동시 처리가 가능하지만, like_count UPDATE는 같은 행 하나를 놓고 싸운다. 즉, 병목의 원인은 좋아요 행위와 집계가 하나의 트랜잭션으로 동기적으로 결합되어 있다는 점이다. 이 둘을 분리하면 어떨까?

좋아요는 실시간 정합성이 필요한가?

분리하려면 먼저 질문 하나에 답해야 한다. "좋아요 수가 실시간으로 정확해야 하는가?"

상품의 좋아요가 279개인데 278개로 표시된다고 해서 사용자가 불편을 느끼진 않는다. 좋아요에서 실시간 정합성은 중요한 쟁점이 아니다. 특정 시점에 데이터가 일시적으로 불일치하더라도, 일정 시간이 지나면 최종적으로 일관된 상태에 도달하면 된다. 이를 Eventual Consistency(최종적 일관성)라고 한다.

좋아요를 누르면 product_likes에만 데이터를 삽입 하고, like_count는 별도 시점에 집계한다. 이렇게 하면 좋아요 쓰기 시점에 로우 단위 락 경합이 사라진다. 별도 집계 방법은 집계 테이블, 비동기 처리(메시지 큐), Redis 등 여러 가지가 있는데, 이 글에서는 DB 레벨에서의 집계 방식(MV)을 다룬다.

Materialized View란

Materialized View(MV)는 일반 뷰와 달리 쿼리 결과를 물리적으로 저장하는 뷰다.

Oracle, PostgreSQL은 MV를 제공하지만, MySQL은 제공하지 않아 집계용 테이블을 직접 만들어야 한다.

CREATE TABLE product_likes_count (
    product_id BIGINT PRIMARY KEY,
    like_count INT NOT NULL DEFAULT 0,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

이 테이블에 데이터를 어떻게 채울 것인가? 가장 단순한 방법은 DB 스케줄(EVENT)로 매시간 product_likes의 전체 좋아요를 집계하여 저장하는 것이다.

CREATE EVENT ev_update_product_likes_count
ON SCHEDULE EVERY 1 HOUR
DO
REPLACE INTO product_likes_count (product_id, like_count)
SELECT product_id, COUNT(*)
FROM product_likes
GROUP BY product_id;
-- REPLACE INTO는 PK가 존재하면 UPDATE, 없으면 INSERT 해주는 구문이다.

전체 집계 쿼리를 직접 실행해서 소요 시간을 측정했다. 좋아요 100,000건 기준 약 25ms다.

10만 건 기준 25ms면 나쁘지 않다. 하지만 서비스가 성장하면 어떨까? 100만 건, 1000만 건이 되면 매 스케줄마다 전체를 집계하는 비용이 부담될 수 있다. 이를 보완하기 위해 증분 갱신을 고려할 수 있다. 지난 1시간 동안 추가된 좋아요만 집계하여 반영하는 방식이다.

INSERT INTO product_likes_count (product_id, like_count)
SELECT product_id, COUNT(*)
FROM product_likes
WHERE created_at >= NOW() - INTERVAL 1 HOUR
GROUP BY product_id
ON DUPLICATE KEY UPDATE like_count = like_count + VALUES(like_count);

그러나 증분 갱신은 정합성 관리가 복잡하다.

  1. 삭제된 좋아요가 반영되지 않는다 — 증분 갱신은 추가된 데이터만 집계하므로, 좋아요 취소(삭제)는 반영되지 않는다. 이를 보정하기 위해 주기적으로(예: 하루 한 번) 전체 갱신을 수행해야 한다.
  2. 전체 갱신과 집계 범위가 겹칠 수 있다 — 삭제 보정을 위한 전체 갱신이 0시에 모든 데이터를 집계한 뒤, 0시 30분에 증분 갱신이 NOW() - INTERVAL 1 HOUR(23시 30분 이후)이 수행되면 23시 30분 ~ 0시 사이의 데이터가 중복으로 더해진다.

증분 갱신의 복잡성을 보면서, 다른 접근을 생각해봤다. 스케줄이 아니라 조회 시점에 집계하면 어떨까?

  1. 상품 조회 시 집계 테이블에 데이터가 없으면 → product_likes를 COUNT하여 집계 테이블에 삽입 (삽입 시점 기록)
  2. 집계 테이블에 데이터가 있으면 → 바로 반환
  3. 주기적으로 삽입 시점이 일정 시간(예: 1시간)을 초과한 데이터를 삭제
  4. 삭제된 데이터는 다음 조회 시 다시 집계 → 자연스럽게 최신 데이터로 갱신
-- 1. 집계 테이블에서 조회
SELECT like_count FROM product_likes_count WHERE product_id = 1;

-- 2. 없으면 COUNT 집계 후 삽입하고, 집계한 값을 반환
SELECT COUNT(*) FROM product_likes WHERE product_id = 1;
INSERT INTO product_likes_count (product_id, like_count, created_at) VALUES (1, {집계 결과}, NOW());

-- 3. 주기적으로 만료된 데이터 삭제
DELETE FROM product_likes_count WHERE created_at < NOW() - INTERVAL 1 HOUR;

이 방식은 조회 시 데이터를 적재하고, 일정 시간이 지나면 삭제하여 갱신하는 구조로 캐시의 TTL과 비슷한 효과를 낼 수 있다. 만료 관리와 정리 스케줄을 직접 구현해야 하는 부담은 있지만, 증분 갱신의 정합성 문제(삭제 미반영, 집계 범위 중복)를 피할 수 있다는 점에서 더 단순하다고 판단했다.

나는 조회 시 집계 방식을 선택했다. 스케줄 기반은 "변경이 없는 데이터도 주기적으로 집계"하지만, 조회 시 집계는 "실제로 조회되는 데이터만 집계"한다. 조회되지 않는 상품까지 불필요하게 집계하지 않아도 되는 셈이다.

좋아요 순 상품 조회 수정

MV로 좋아요 집계 데이터를 관리하면서 좋아요 순 상품 정렬 조회 시 JOIN이 불가피해졌다.

SELECT p.*, plc.like_count
FROM product_likes_count plc
JOIN products p ON p.id = plc.product_id AND p.deleted_at IS NULL
ORDER BY plc.like_count DESC
LIMIT 20 OFFSET 0;

집계 테이블과 상품 테이블을 JOIN하면서 deleted_at IS NULL 필터를 적용해야 삭제된 상품이 제외된다.

  1. like_count 인덱스에서 역순으로 한 행 읽기
  2. 그 행의 product_idproducts PK 조회
  3. deleted_at IS NULL 체크 — 통과하면 결과에 포함, 아니면 버림
  4. 1~3을 반복하다가 20건이 모이면 멈춤

Total Cost: 12510.0 / 실행 시간: 1.06ms

offset에 따른 성능 비교

OFFSET 비정규화 MV JOIN
0 1.43ms 1.29ms
100 0.94ms 0.93ms
1000 3.28ms 1.78ms
5000 9.63ms 9.47ms
10000 10.70ms 13.76ms

OFFSET이 커져도 두 방식의 성능 차이는 유사하다. 둘 다 OFFSET 기반 페이징의 한계로 느려지는 거지, MV JOIN이라서 특별히 더 느려지는 건 아니다.

MV로 분리해도 조회 성능은 비정규화와 거의 동일하면서, 쓰기 시 행 락 경합이 사라지는 것을 확인할 수 있었다. 좋아요처럼 실시간 정합성이 중요하지 않다면 고려해볼 만한 방식이다.

정리

"좋아요 수를 어떻게 보여줄까?"라는 단순한 질문에서 출발했지만, 파고들수록 선택지가 갈라졌다.

  • 실시간 집계는 단순하지만 느리다. 비정규화 대비 약 270배 느린 조회 성능은 상품 목록 API에서 치명적이다.
  • 비정규화는 쓰기 비용은 거의 없다. 비용은 평균 1.33ms 수준으로, 인덱스 갱신 오버헤드(0.1ms 내외)와 함께 실질적으로 미미하다.
  • 비정규화의 진짜 비용은 락 경합이다. 500명 동시 요청부터 응답 시간이 급증하지만, 일반적인 이커머스에서는 현실적으로 문제가 되지 않는다.
  • Eventual Consistency를 허용하면 구조가 유연해진다. 좋아요 행위와 집계를 분리하여 쓰기 병목을 제거하면서도 조회 성능은 유지할 수 있다.

비정규화만으로도 대부분의 이커머스에서는 충분하다. 쓰기 병목이 문제가 된다면 MV 분리를 고려하면 된다.

결국 어떤 방식이 맞는지는 서비스의 요구 수준과 트래픽 패턴등 다양한 요소에 따라 달라질 수 있다. 같은 "좋아요"라도 이커머스와 SNS에서 요구하는 것이 다르고, 그에 따라 설계도 달라져야 한다. 이번 비교처럼 실제 비용을 측정하고 방식별 트레이드오프를 따져보는 과정이 결국 최적의 선택으로 이어진다고 생각한다.

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2026/05   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
글 보관함