본문 바로가기
IT 서비스/모임 관리 플랫폼

[ MOIM ] 파티셔닝을 통한 알림함 조회 성능 개선 - 1

by agong이 2025. 5. 9.

서론

MOIM의 알림함 기능은 동아리 관리시스템인 만큼 사용자가 모임의 이벤트(알림)을 놓치지 않도록 하는 중요한 기능 중 하나입니다.

하지만 서비스 특성상 다수의 사용자들에게 알림을 보내야하기 때문에 알림 데이터의 양은 가파르게 증가하였고, 읽기 및 필터링 성능 저하 문제가 나타났습니다. 

 

기존에는 모든 알림 데이터를 하나의 테이블에서 모두 관리하고 있습니다.이로 인해 알림 테이터가 수백만 건 이상 쌓이면서 조회 성능이 저하되었습니다. 또한 정리가 늦어질수록 계속해서 데이터가 많아지면 구조 개선이 어려워지기 때문에 우선순위를 높여서 진행하게 되었습니다.

 

알림서비스의 특징은 다음과 같습니다.

1. 모든 종류의 알림을 조회할 수 있다.

2. 읽은 알림, 읽지 않은 알림을 구분해서 조회할 수 있다. 

3. 최근 2주간의 알림을 조회한다.

 

현재는 notification이라는 하나의 테이블에 모든 알림 데이터를 쌓고 있으며, 아래와 같은 방식으로 필터링합니다

SELECT *
FROM notification
WHERE receiver_id = ?
  AND created_at >= NOW() - INTERVAL 14 DAY
  AND is_read = false
ORDER BY created_at DESC

 

문제는 쓰기 작업도 빈번히 일어나기 때문에 인덱스를 함부로 설정하면 쓰기작업이될 수 있었습니다.


이러한 상황을 해결하기 위해, 구조적인 관점에서 수평 파티셔닝을 도입하여 구조를 개선하고자 했습니다.

정리하자면 다음과 같습니다.

 

1. 쓰기 작업이 빈번하게 일어나기 때문에 인덱스 설정에 한계. 구조적인 관점에서 접근.

2. 알림은 로깅성 데이터로 일정 시간이후로 삭제하기 때문에 파티셔닝을 도입할경우 데이터 삭제에 용이.

 

파티셔닝 분할 기준

1. 범위 분할 

- 분할 키 값이 범위 내에 있는지 여부로 구분합니다.
- 예를 들어, 지역 번호를 분할 키로 수평 분할하는 경우가 생각납니다.

2. 목록 분할 

- 값 목록에 파티션을 할당 분할 키 값을 그 목록에 비추어 파티션을 선택한다.
- 예를 들어, Country 라는 컬럼의 값이 Iceland , Korea, China, Finland , Denmark 중 하나에 있는 행을 빼낼 때 대륙별 국가 파티션을 구축 할 수 있습니다.

 

3. 해시 분할 

- 해시 함수의 값에 따라 어떤 파티션에 포함할지 여부를 결정합니다.
- 예를 들어, 4개의 파티션으로 분할하는 경우 해시 함수는 0-3의 정수를 돌려줍니다.

 

4. 합성 분할 (composite partitioning)

- 여러 분할 기술을 결합하는 것을 의미하며, 예를 들면 먼저 범위 분할하고, 다음에 해시 분할 같은 것을 생각할 수 있습니다.
- 더 작은 단위로 관리가 가능하며 병렬처리에 유리합니다.
https://gmlwjd9405.github.io/2018/09/24/db-partitioning.html

 

mysql에서는 기본적으로 4가지 파티션 기법을 모두 제공합니다. (단, 합성분할 같은 경우에는 RANGE + HASH , LIST  + HASH 를 지원합니다)

파티셔닝 장점

파티션 프루닝 : 필요한 파티션만 검색하여 조회성능 향상

삭제 속도 향상 : DELETE 대신 DROP PARTITION 사용 -> 수십 ~ 수백 배 빠름데이터 분산 처리 : 특정 날짜/구간으로 데이터를 물리적으로 분산 

 

🚨사용 주의

반드시 파티션 키는 조회 조건에 포함되어야합니다. (그렇지 않으면 모든 파티션 조회)

 

 

구현 전 성능 체크

테스트 환경

/api/v1/notifications?size=100&page=30

쓰레드 수 : 1000

Ramp-up : 1초

상수 처리량 타이머 : 6000

지속시간 : 180초(3분)

1초에 100개의 요청이 발생되도록 처리하였습니다.

 

데이터

테이블에는 현재 31만개의 데이터가 존재하여 하나의 유저는 310개의 데이터를 가지고 있도록 하였습니다.

 

초당 TPS(좌), 응답 시간 그래프(우)

 

TPS가 15.5/sec로 매우 낮게 측정되었으며 응답 시간도 꾸준히 증가하여 63000ms까지 증가하였습니다.

또한 3분간 요청을 보냈지만 모두 처리하는데는 4분02초가 소요되었습니다.

 

설계

주단위로 범위 파티셔닝을 진행할 예정입니다. 

알림은 로그성 데이터이기 때문에 생성량이 많고 ,읽은 후에는 접근률이 낮습니다.

서비스에서는 최근 14일만 조회합니다.

한달이 지난 알림 테이블은 스케쥴링을 통해 삭제합니다.

 

진행

기존 notification대신에 별도의 테이블을 생성하였습니다.

CREATE TABLE p_notification_partition (
                                id BIGINT NOT NULL AUTO_INCREMENT,
                                tracking_id VARCHAR(36) NOT NULL,
                                notification_type VARCHAR(50) NOT NULL,
                                access_tracking_id VARCHAR(36) NOT NULL,
                                receiver_tracking_id VARCHAR(36) NOT NULL,
                                content TEXT NOT NULL,
                                is_read BOOLEAN NOT NULL,
                                created_at DATETIME NOT NULL,
                                deleted_at DATETIME DEFAULT NULL,
                                PRIMARY KEY (id, created_at),
                                INDEX idx_receiver_created_isread (receiver_tracking_id, created_at, is_read)
)
    PARTITION BY RANGE (TO_DAYS(created_at)) (
        PARTITION p_20250428 VALUES LESS THAN (TO_DAYS('2025-05-05')),
        PARTITION p_20250505 VALUES LESS THAN (TO_DAYS('2025-05-12')),
        PARTITION p_20250512 VALUES LESS THAN (TO_DAYS('2025-05-19')),
        PARTITION p_20250519 VALUES LESS THAN (TO_DAYS('2025-05-26')),
        PARTITION p_20250526 VALUES LESS THAN (TO_DAYS('2025-06-01')),
        PARTITION p_max VALUES LESS THAN MAXVALUE
);

 

기존 데이터 옮기기 ( 10만건씩 옮김)

INSERT INTO p_notification_partition (
    id,
    tracking_id,
    notification_type,
    access_tracking_id,
    receiver_tracking_id,
    content,
    is_read,
    created_at,
    deleted_at
)
SELECT
    id,
    tracking_id,
    notification_type,
    access_tracking_id,
    receiver_tracking_id,
    content,
    is_read,
    created_at,
    deleted_at
FROM p_notification
ORDER BY id
LIMIT 100000 OFFSET 0;

 

더보기

데이터가 제대로 들어갔는지 확인해보니?

EXPLAIN
SELECT DATE(created_at), COUNT(*)
FROM p_notification_partition
WHERE created_at >= '2025-05-01' AND created_at < '2025-06-01'
GROUP BY DATE(created_at)
ORDER BY DATE(created_at);

 

해당 sql문으로 테스트 해보았지만 총 31만개의 데이터가 존재해야하지만 rows가 305845개만 존재하였습니다.

살펴보니 'EXPLAIN(실행 계획)이란 옵티마이저가 SQL문을 어떤 방식으로 어떻게 처리할 지를 계획한 걸 의미한다.' 

였습니다. 이 실행 계획을 보고 비효율적으로 처리하는 방식이 있는 지 점검하고, 비효율적인 부분이 있다면 더 효율적인 방법으로 SQL문을 실행하게끔 튜닝을 할 수 있습니다.

 

즉, Explain(실행 계획)은 데이터베이스가 SQL 쿼리를 실행할 때 어떤 경로로 데이터에 접근하고 처리하는지를 보여주는 일종의 “설계도”라고 할 수 있는.

즉, 쿼리가 어떻게 실행될지에 대한 세부적인 계획을 제공한다.

 

이 실행 계획을 보고 비효율적으로 처리하는 방식이 있는 지 점검하고, 비효율적인 부분이 있다면 더 효율적인 방법으로 SQL문을 실행하게끔 튜닝을 하는 게 목표입니다.

즉, 쿼리가 어떻게 실행될지에 대한 세부적인 계획을 제공합니다.

데이터베이스에 어떤 SQL 쿼리를 보내면, 데이터베이스는 그 쿼리를 실행할 때 최적의 경로를 선택해야 하는데, 이 경로는 여러 가지 요소에 따라 달라진다. 예를 들어, 테이블이 크다면 인덱스를 사용할지, 아니면 테이블 전체를 스캔할지 등을 결정해야 한다. 

실행 계획(Explain)을 사용하면 데이터베이스가 쿼리를 처리하는 과정을 미리 알 수 있고, 이를 통해 성능을 최적화할 수 있다. 예를 들어, 실행 계획을 보면 쿼리가 테이블 전체를 스캔하고 있는지, 아니면 인덱스를 잘 활용하고 있는지를 확인할 수 있다.

 

"내가 이 쿼리 돌릴 때, created_at 범위 조건으로 얼마나 row를 스캔해야 할 것 같다."

말그대로 실행 계획이기 때문에 실제로 실행한 결과가 아니여서 실제 데이터 개수와 오차가 발생하는 것이었습니다.

 

다음과 같은 쿼리로 올바른 결과를 얻을 수 있었습니다.

SELECT COUNT(*) FROM p_notification_partition
WHERE created_at >= '2025-05-01' AND created_at < '2025-05-05';

성능테스트

파티셔닝만으로는 효과가 미비했습니다. 그이유는 아직 데이터가 31만개로 많이 차지하지않고 내부적으로는 테이블이 분리되어있어 join을 통해 조회하기 때문입니다.하지만 장기적인 관점에서 계속해서 데이터가 많아진다면 반드시 필요하고 로그성 알림 데이터를 주기적으로 삭제할때 성능을 위하여 적용하였습니다. 추가로 인덱싱을 함께 추가하여 조회 성능은 크게 향상되었습니다.

ADD INDEX idx_receiver_created_isread (receiver_tracking_id, created_at, is_read);

 

기존 15.5/sec 에서 99.9/sec로 1초에 전송하는 100개의 요청을 모두 처리하였습니다.

 

마무리

세삼 다시한번 인덱싱의 중요성을 느끼게 되었고, 잘 설계된다면 파티셔닝을 통해 DB를 안정적으로 관리할수 있을 것 같습니다.

 다음 글에서는 스케쥴러를 통해 파티션을 추가하고, 삭제하는 부분에 대해 성능을 분석하면서 진행해보려고 합니다. 그럼이만

댓글