projects/travlocks

2. 알림 조회 성능 개선 - 반정규화

hyomee2 2026. 7. 3. 14:08

본 게시물은 트래블록스 프로젝트를 진행하며 발생한 문제 및 개선 사항을 정리한 글입니다.

 

https://hyomee2.tistory.com/132

 

1. 알림 조회 성능 개선 - 인덱스

본 게시물은 트래블록스 프로젝트를 진행하며 발생한 문제 및 개선 사항을 정리한 글입니다. 배경초기 알림 테이블(notifications)에는 별도의 인덱스가 없이 설계되었지만, 대용량 환경을 대비해

hyomee2.tistory.com

 

앞선 글에서는 인덱스를 이용해서 조회 쿼리 시간을 크게 단축시킬 수 있었다.

하지만 실제 사용자가 체감하는 것은 쿼리 실행 시간이 아니라 API 응답 시간이므로,

실제 서비스 관점에서 성능을 검증하고자 k6을 이용해 부하테스트를 진행했다.

 

부하테스트

import http from 'k6/http';
import { check, sleep } from 'k6';
import { Trend } from 'k6/metrics';

const BASE_URL = __ENV.BASE_URL || 'http://3.27.246.45:8080';

export const notificationLatency = new Trend('notification_latency', true);

export const options = {
    scenarios: {
        notification_load_test: {
            executor: 'ramping-vus',  // 동시 사용자수 점진 증가
            startVUs: 0,
            stages: [
                { duration: '20s', target: 20 },   // 워밍업
                { duration: '1m', target: 50 },   // 실제 측정 구간
                { duration: '20s', target: 0 },    // 종료
            ],
            gracefulRampDown: '30s',
        },
    },
    // thresholds: {
    //     // 알림 API에 대해서만 p95 기준 적용 (로그인 제외)
    //     'http_req_duration{name:notifications}': ['p(95)<300'],  // p95가 300ms 미만이어야 성공
    //     'http_req_failed{name:notifications}': ['rate<0.01'],
    // },
};


// 로그인
export function setup() {
    const loginRes = http.post(
        `${BASE_URL}/api/v1/auth/login`,
        JSON.stringify({
            email: 's5763305@naver.com',
            password: 'hyoungmi1015',
        }),
        { headers: { 'Content-Type': 'application/json' } }
    );

    check(loginRes, {
        'login success': (r) => r.status === 200,
    });

    const token = loginRes.json('data.accessToken');

    if (!token) {
        throw new Error('login fail');
    }

    return { token };
}

// 실제 부하테스트
export default function (data) {
    const url = `${BASE_URL}/api/v1/notifications?size=10`;

    const res = http.get(url, {
        headers: {
            Authorization: `Bearer ${data.token}`,
        },
        tags: {
            name: 'notifications',
        },
    });

    notificationLatency.add(res.timings.duration);

    check(res, {
        'status is 200': (r) => r.status === 200
    });

    if (res.status !== 200) {
        console.log(`status: ${res.status}`);
        console.log(`body: ${res.body}`);
    }

    sleep(0.2); // 실제 사용자 행동 간격
}

 

테스트 시나리오는 아래와 같이 구성했다.

1. 점진적으로 동시 사용자 증가

            stages: [
                { duration: '20s', target: 20 },   // 워밍업
                { duration: '1m', target: 50 },   // 실제 측정 구간
                { duration: '20s', target: 0 },    // 종료
            ],

 

처음부터 100명의 사용자를 동시에 생성하지 않고, 

실제 서비스 환경과 최대한 유사하게 진행해보고자 사용자가 점점 증가하는 상황을 가정했다.

- 20초 동안 20명까지 증가 (워밍업)

- 1분 동안 50명 유지하며 실제 성능 측정

- 20초 동안 점진적으로 종료

 

JVM 초기화, 커넥션 풀 생성 등의 영향이 있을 수 있기 때문에 이를 최소화하고 안정적인 상태에서 성능을 측정하고자 워밍업 구간을 두었다.

 

2. 로그인은 한 번만 수행

로그인의 경우는 우리가 테스트하고자 하는 API와 별도이지만 선행되어야 하므로 setup()에서 수행한 후,

모두 동일한 토큰을 이용하도록 구성하여 로그인 API의 성능이 결과에 영향을 주지 않도록 했다.


테스트 결과

아래는 인덱스 적용 전,

 

 

아래는 인덱스(Covering Index + PK Lookup)을 적용한 후의 부하테스트 결과이다.

 

결과를 정리해보면 아래와 같다.

지표 인덱스 적용 전 인덱스 적용 후 개선율
Average 9,670ms 1,980ms 79.5%
p95 15,530ms 3,330ms 78.6%
RPS(http_reqs/s) 3.0req/s 12.8req/s 76.6%

 

표를 확인하면 인덱스 적용 전에 비해 적용 후가 수치적으로 훨씬 개선된 것은 맞다.

하지만 알림 자체를 조회하는 쿼리는 지난 측정에 따르면 0.537ms에 불과한데, 평균 기준 나머지 약 1,980ms는 어디에서 발생했는지 의문이었다.

따라서 아래와 같이 StopWatch를 이용하여 또 다른 병목 지점이 어디인지 파악해보았다.

(이 부분은 %만 확인하면 된다고 생각해서 로컬에서 로그로 찍어보았다)

 

결과는 확실했다.바로 count 쿼리가 대부분의 시간을 차지했다.

따라서 count 병목을 제거하고자 했다.


 

COUNT 집계 쿼리 병목을 어떻게 개선할 것인가?

COUNT 연산을 제거하기 위해 두가지 방식을 고민했다.

1. 반정규화

첫번째로 생각한 방식은 members 테이블에 알림 개수를 저장하여 반정규화 하는 방식이었다.

장점

- DB 트랜잭션 내에서 관리 가능

- 간단하게 조회 가능

2. Redis을 이용한 관리

다음으로는 Redis에 사용자별 알림 개수를 저장하고, 조회 시 DB가 아닌 Redis에서 바로 값을 가져오는 방식을 생각했다.

장점

- 메모리 기반 저장소이므로 매우 빠르게 조회 가능

단점

- Redis와 DB 간 데이터 정합성 추가적으로 관리 필요

- 장애 발생 시 Redis와 DB의 값이 달라질 수 있어 복구 로직 필요

 

3. 결론

이번 COUNT 병목을 해결하는데 있어서는 "반정규화" 방식을 사용하기로 했다.

그 이유는 아래와 같다.

- 사용자별로 카운트 -> 실시간으로 급격히 증가하는 값이 아님

- PK 조회이므로 반정규화로도 충분히 빠를 것으로 판단

- Redis를 도입할 만큼의 트래픽은 아님


COUNT 집계 쿼리 개선 결과

지표 인덱스 적용 후 COUNT 제거 후 개선율
Average 1,980ms 157.0ms 97.4%
p95 3,330ms 179.6ms 97.1%
TPS(http_reqs/s) 12.8req/s 76.2req/s 66.2%
지표 인덱스 적용 전 인덱스 적용 후 COUNT 제거 후 개선율
Average 9,670ms 1,980ms 157.0ms 98.4%
p95 15,530ms 3,330ms 179.6ms 98.8%
RPS(http_reqs/s) 3.0req/s 12.8req/s 76.2req/s 96.1%

마무리

사실 이전에는 데이터베이스에서 정규화에 대한 내용을 배우며 "정규화할 수 있으면 항상 하는게 좋은거 아닌가?" 라는 생각을 했었다.

이론적으로는 상황에 따라 반정규화가 더 적합할 수도 있다고 했지만, 직접 와닿지는 않은 것 같다.

 

하지만 이번 기회를 통해 쿼리 시간은 크게 단축했는데 API 응답 속도는 단축되지 않은 것을 보면서 의아함을 느꼈다.

이 과정에서 사용자가 실제로 체감하는 것은 API 응답 시간일 것이므로,

단순히 쿼리 성능이 좋아졌다는 결과에 만족하는 것이 아니라

실제 병목이 어디에 있는지 파악하고 실제 API 응답 시간을 최적화하는데에 초점을 맞춰야겠다는 깨달음을 얻었다.

 

또한 StopWatch을 이용해 API 내부적으로 구간별 실행 시간을 측정하며 병목 지점을 직접 파악하고 개선하는 것도 의미있었다.

처음에는 "어디가 병목인지 어떻게 파악하지?" 라는 생각으로 "집계 쿼리가 문제인가?" 라는 감을 가지고 시작했지만,

측정을 기반으로 근거를 가지고 병목을 찾아 개선하는 과정의 중요성도 배울 수 있었다.

 

앞으로도 최적화가 필요한 상황에서는 병목 지점이 어디인지 파악하고, 찾은 병목에 대해 가장 적합한 개선 방안을 선택하고자 한다.