Post

결제 시스템에서의 트랜잭션 설계하기 (3부) — N+1 쿼리와 인덱싱 전략

3부. 성능 최적화: N+1 쿼리와 인덱싱 전략

시리즈 되짚기

1부에서는 트랜잭션을 왜 짧게 가져가야 하는가를 InnoDB 내부(Undo/Redo/WAL/fsync/MVCC)와 락 메커니즘, Spring 전파(REQUIRES_NEW)까지 내려가며 설명했습니다.

2부에서는 Webhook 처리와 조정(Reconciliation) 을 통해 PG와 내부 DB 사이의 불일치를 어떻게 발견하고, 어떤 순서로, 어디까지 자동화할 것인가를 다뤘습니다.

3부에서는 시선을 성능으로 돌립니다. 하지만 단순히 빠르게 만들자는 이야기가 아닙니다.

결제 도메인에서 성능 최적화는 곧 정합성과의 트레이드오프입니다. 인덱스를 하나 추가하면 쿼리는 빨라지지만 락 범위가 바뀌고, 락 범위가 바뀌면 동시성 특성이 바뀌고, 동시성이 바뀌면 데드락 패턴이 달라집니다.

따라서 이 글은 다음을 다룹니다.

  • 결제 확정 시 원장/지갑 엔트리 조회가 N+1로 터지는 패턴
  • 인덱스 설계가 곧 락 범위 설계가 되는 이유 (결제 도메인 관점)
  • p95/p99 지표로 보는 빠르면서 안전한 구조 만들기

그리고 이 모든 것을 B+Tree 내부 구조, EXPLAIN 실행 계획, InnoDB 버퍼 풀, 커버링 인덱스, 쿼리 최적화 원칙까지 원론적으로 풀어냅니다.


왜 3부가 ‘성능’인가 — 빠른 것과 안전한 것의 긴장

1부에서 강조한 핵심 철학을 다시 꺼냅니다.

트랜잭션은 짧게, 커밋은 확실하게. 외부 I/O는 트랜잭션 밖으로.

트랜잭션을 짧게 만들었다는 건, 각 트랜잭션 안의 쿼리가 빨라야 한다는 전제를 깔고 있는 겁니다. 트랜잭션 경계를 아무리 잘 나눠도, 그 안에서 느린 쿼리가 돌면 의미가 없습니다.

1
2
3
4
5
REQUIRES_NEW 트랜잭션 (목표: 50ms 이내)
  ├─ lockByOrderId        → 10ms (인덱스 Record Lock)
  ├─ Ledger append        → 5ms (INSERT)
  ├─ Wallet 잔액 조회       → ???
  └─ Wallet 엔트리 저장      → 5ms (INSERT)

??? 에 해당하는 부분이 바로 이 글의 시작점입니다. 결제 확정 시 원장(Ledger)을 기록하고, 양쪽 계정의 지갑(Wallet) 잔액을 갱신해야 합니다. 이때 지갑의 최신 잔액을 어떻게 조회하느냐에 따라 쿼리 수가 극적으로 달라집니다.


N+1 문제의 본질 — 왜 결제에서 특히 치명적인가

N+1 쿼리란 무엇인가

N+1 문제는 ORM(JPA/Hibernate) 환경에서 흔히 마주치는 패턴입니다. 하지만 그냥 JOIN 쓰면 되지 않느냐고 넘기기엔, 결제 도메인에서의 파급력이 다릅니다.

N+1의 정의는 단순합니다.

1건의 메인 쿼리를 실행한 뒤, 결과 N건 각각에 대해 추가 쿼리 1건씩을 실행하는 패턴. 총 쿼리 수: 1 + N

일반적인 게시판이나 상품 목록에서는 N이 10~50 정도이고, 페이징으로 제한하기 때문에 체감 성능이 나쁘지 않을 수 있습니다.

하지만 결제 시스템에서는 상황이 다릅니다.

결제에서 N+1이 위험한 3가지 이유

1) 트랜잭션 안에서 발생한다

결제 확정(confirm)은 트랜잭션 안에서 실행됩니다. N+1 쿼리가 발생하면 트랜잭션 시간이 N에 비례해 늘어납니다.

1
트랜잭션 시간 = 기본 쿼리 시간 + (N × 추가 쿼리 시간)

1부에서 설명했듯이, 트랜잭션이 길어지면:

  • DB 커넥션 점유 시간 증가 → 커넥션 풀 고갈
  • 락 유지 시간 증가 → 동시성 급락
  • Undo Log 누적 → MVCC 부하 증가

2) 락 유지 시간에 직접 영향

lockByOrderId로 해당 주문에 Record Lock을 걸어놓은 상태에서, 지갑 조회가 느려지면 락을 잡고 있는 시간이 그만큼 늘어납니다. 같은 주문에 대한 Webhook 처리나 취소 요청이 대기하게 됩니다.

3) 결제는 **배치가 아닌 실시간이다**

사용자가 결제 버튼을 누르고 응답을 기다리는 동안 실행되는 로직입니다. p95 200ms, p99 500ms를 넘기면 사용자 경험이 급격히 나빠집니다. N+1은 이 지표를 예측 불가능하게 만듭니다.


결제 확정 흐름에서 N+1이 발생하는 지점

이중부기(Double-Entry Bookkeeping) 복습

1부에서 설계한 결제 확정 후 원장 기록 구조를 다시 살펴봅시다.

결제가 확정되면 다음이 실행됩니다.

  1. LedgerEntry 생성: 누가(from) → 누구에게(to), 얼마(amount)
  2. WalletEntry 생성: from 계정에 -금액, to 계정에 +금액
  3. WalletEntry에는 변경 후 잔액(balanceAfter) 이 기록됨

여기서 핵심은 3번입니다. balanceAfter를 계산하려면, 해당 계정의 현재 잔액을 알아야 합니다. 현재 잔액은 해당 계정의 가장 최근 WalletEntrybalanceAfter입니다.

최초 구현: 계정별로 하나씩 조회

처음에는 자연스럽게 이렇게 구현했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Override
@Transactional
public void append(LedgerEntry entry) {
    ledgerRepo.save(LedgerEntryJpaEntity.from(entry));

    String from = entry.fromAccount();
    String to   = entry.toAccount();
    long amt    = entry.amount();

    // ❌ 쿼리 1: from 계정의 최신 잔액 조회
    long fromBalance = walletRepo
        .findTopByAccountIdOrderByIdDesc(from)
        .map(WalletEntryJpaEntity::getBalanceAfter)
        .orElse(0L);

    // ❌ 쿼리 2: to 계정의 최신 잔액 조회
    long toBalance = walletRepo
        .findTopByAccountIdOrderByIdDesc(to)
        .map(WalletEntryJpaEntity::getBalanceAfter)
        .orElse(0L);

    // ... WalletEntry 생성 및 저장
}

이 코드의 문제점은 명확합니다. 계정이 2개니까 쿼리가 2번 나갑니다.

겨우 2번인데 뭐가 문제냐고 생각할 수 있습니다. 맞습니다 — 지금은.

N+1로 확장되는 시나리오

하지만 결제 시스템은 확장됩니다.

시나리오 1: 수수료 분배

1
2
사용자 → 판매자: -10,000
사용자 → 플랫폼(수수료): -1,000

이 경우 계정이 3개이므로 쿼리 3번.

시나리오 2: 다중 판매자 주문

1
2
3
4
사용자 → 판매자 A: -5,000
사용자 → 판매자 B: -3,000
사용자 → 판매자 C: -2,000
사용자 → 플랫폼(수수료): -500

계정 5개, 쿼리 5번.

시나리오 3: 정산 배치

1
2
정산 대상 판매자 100명
각 판매자별 최신 잔액 조회

이 순간 N+1은 1 + 100 = 101번의 쿼리가 됩니다.

즉, 현재는 2건이지만 구조적으로 N+1 패턴을 안고 있으면 미래에 반드시 문제가 됩니다.

쿼리 로그로 확인하는 N+1

실제 JPA 쿼리 로그를 확인해봅시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
-- 1) Ledger INSERT
INSERT INTO ledger_entry (order_id, from_account, to_account, amount, occurred_at, memo)
VALUES ('ORD-1001', 'USER:42', 'MERCHANT:eco', 10000, '2026-02-27 10:00:00', 'confirm');

-- 2) from 계정 최신 잔액 (N+1의 +1)
SELECT * FROM wallet_entry
WHERE account_id = 'USER:42'
ORDER BY id DESC
LIMIT 1;

-- 3) to 계정 최신 잔액 (N+1의 +1)
SELECT * FROM wallet_entry
WHERE account_id = 'MERCHANT:eco'
ORDER BY id DESC
LIMIT 1;

-- 4) WalletEntry INSERT × 2
INSERT INTO wallet_entry (account_id, delta, balance_after, occurred_at, order_id, memo)
VALUES ('USER:42', -10000, 90000, ...);
INSERT INTO wallet_entry (account_id, delta, balance_after, occurred_at, order_id, memo)
VALUES ('MERCHANT:eco', 10000, 10000, ...);

4번의 쿼리가 보입니다. 매 결제마다 이 패턴이 반복되고, 계정 수가 늘면 SELECT가 비례해서 늘어납니다.


N+1을 구조적으로 해결하기 전에 — DB 인덱스의 원론

N+1 해결의 핵심은 하나의 쿼리로 여러 계정의 최신 잔액을 가져오는 것입니다. 하지만 이 쿼리가 실제로 빠르려면, 인덱스가 올바르게 설계되어야 합니다.

여기서 잠깐 멈추고, DB 인덱스가 내부적으로 어떻게 작동하는지를 짚고 가겠습니다. 이를 이해하지 않으면 인덱스를 추가해도 왜 빨라졌는지, 어떤 부작용이 있는지 알 수 없기 때문입니다.

B+Tree: InnoDB 인덱스의 물리적 구조

InnoDB의 모든 인덱스는 B+Tree 자료구조로 저장됩니다. B+Tree는 다음과 같은 특성을 가집니다.

1
2
3
4
5
6
         [30 | 60]                    ← 루트 노드 (Internal)
        /    |    \
   [10|20] [40|50] [70|80]            ← 내부 노드 (Internal)
    / | \   / | \   / | \
  [리프] [리프] [리프] [리프] [리프] ...   ← 리프 노드 (Leaf)
     ↔      ↔      ↔      ↔          ← 리프끼리 양방향 연결 리스트

핵심 특성 4가지를 짚겠습니다.

1) 모든 실제 데이터(또는 PK 포인터)는 리프 노드에만 있다

내부 노드는 탐색 경로를 위한 키만 가지고 있습니다. 실제 레코드(또는 PK 값)는 리프에만 존재합니다.

2) 리프 노드끼리 양방향 연결 리스트로 연결

범위 검색(BETWEEN, >, <, ORDER BY)이 효율적인 이유입니다. 한 리프에서 시작해 다음 리프로 순차 이동하면 됩니다.

3) 트리 높이는 보통 3~4

100만 건 테이블도 트리 높이는 3 정도입니다. 즉, 어떤 레코드든 3~4번의 페이지 접근으로 찾을 수 있습니다.

4) 각 노드 = 하나의 페이지(16KB)

InnoDB는 데이터를 페이지(page) 단위로 관리합니다. 기본 페이지 크기는 16KB이며, B+Tree의 각 노드가 하나의 페이지에 대응합니다.

클러스터 인덱스 vs 보조 인덱스

InnoDB에서 이 두 가지 인덱스의 차이를 이해하는 것은 매우 중요합니다.

클러스터 인덱스 (Clustered Index)

  • 테이블당 하나만 존재
  • 보통 PK가 클러스터 인덱스
  • 리프 노드에 실제 레코드(모든 컬럼) 전체가 저장
  • 즉, 클러스터 인덱스 = 테이블 자체
1
2
3
4
클러스터 인덱스 (PK = id)
리프: [id=1, account_id='USER:42', delta=-10000, balance_after=90000, ...]
      [id=2, account_id='MERCHANT:eco', delta=10000, balance_after=10000, ...]
      [id=3, ...]

보조 인덱스 (Secondary Index)

  • 여러 개 생성 가능
  • 리프 노드에는 인덱스 키 + PK 값만 저장
  • 실제 레코드를 읽으려면 PK로 다시 클러스터 인덱스를 탐색 (= 랜덤 I/O)
1
2
3
4
5
보조 인덱스 (account_id)
리프: [account_id='MERCHANT:eco', PK=2]
      [account_id='USER:42', PK=1]
      [account_id='USER:42', PK=5]
      [account_id='USER:42', PK=9]

이때, account_id = 'USER:42'로 검색하면:

  1. 보조 인덱스에서 PK 목록을 찾음 → [1, 5, 9]
  2. 각 PK로 클러스터 인덱스를 다시 탐색 → 3번의 랜덤 I/O

이 2단계 탐색을 인덱스 룩업(Index Lookup) 이라 부릅니다.

커버링 인덱스: 2단계를 1단계로 줄이는 기법

만약 쿼리에서 필요한 모든 컬럼이 인덱스 자체에 포함되어 있다면? 클러스터 인덱스를 다시 탐색할 필요가 없습니다. 이를 커버링 인덱스(Covering Index) 라 합니다.

1
2
3
4
5
6
-- 이 쿼리가 필요한 컬럼: account_id, balance_after
SELECT balance_after
FROM wallet_entry
WHERE account_id = 'USER:42'
ORDER BY id DESC
LIMIT 1;

만약 인덱스가 (account_id, id, balance_after)를 포함한다면, 보조 인덱스 리프만으로 결과를 만들 수 있습니다.

EXPLAIN에서 Extra: Using index가 나오면 커버링 인덱스가 적용된 것입니다.

인덱스와 정렬의 관계

B+Tree의 리프 노드는 정렬된 상태로 유지됩니다. 따라서 인덱스 순서와 쿼리의 ORDER BY가 일치하면 추가 정렬 없이 결과를 반환합니다.

1
2
3
4
5
-- 인덱스: (account_id, id DESC)
SELECT * FROM wallet_entry
WHERE account_id = 'USER:42'
ORDER BY id DESC
LIMIT 1;

이 쿼리는:

  1. account_id = 'USER:42' 범위의 마지막 리프부터 시작
  2. 역방향으로 1건만 읽고 종료
  3. 정렬 비용 = 0

EXPLAIN에서 Extra: Using filesort없어야 정상입니다.

반대로, 인덱스 순서와 ORDER BY가 불일치하면:

1
Extra: Using filesort    ← 디스크/메모리 정렬 발생

이건 결제 트랜잭션 안에서는 절대 허용하면 안 되는 상황입니다.


N+1 해결: 단일 쿼리로 여러 계정의 최신 잔액 조회

해결 전략: GROUP BY + 서브쿼리

여러 계정의 최신 WalletEntry를 한 번에 가져오는 쿼리를 설계합니다.

핵심 아이디어는 다음과 같습니다.

각 계정별로 가장 큰 id(= 가장 최신)를 먼저 구하고, 그 id에 해당하는 레코드를 한 번에 가져온다.

1
2
3
4
5
6
7
8
SELECT w.*
FROM wallet_entry w
WHERE w.id IN (
    SELECT MAX(w2.id)
    FROM wallet_entry w2
    WHERE w2.account_id IN ('USER:42', 'MERCHANT:eco')
    GROUP BY w2.account_id
)

이 쿼리를 JPQL로 옮기면 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
@Query("""
    SELECT w FROM WalletEntryJpaEntity w
    WHERE w.id IN (
        SELECT MAX(w2.id)
        FROM WalletEntryJpaEntity w2
        WHERE w2.accountId IN :accountIds
        GROUP BY w2.accountId
    )
    """)
List<WalletEntryJpaEntity> findLatestByAccountIds(
    @Param("accountIds") List<String> accountIds
);

쿼리 수 비교

방식계정 2개계정 5개계정 100개
개별 조회 (N+1)25100
배치 조회 (IN 서브쿼리)111

계정 수와 무관하게 항상 1번의 쿼리로 해결됩니다.

리팩토링된 append 메서드

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
32
33
34
35
36
37
38
39
@Override
@Transactional
public void append(LedgerEntry entry) {
    // 1) Ledger 저장 (항상 남긴다: 감사를 위한 단일 출처)
    ledgerRepo.save(LedgerEntryJpaEntity.from(entry));

    // 2) 양쪽 계정의 최신 잔액을 한 번에 조회 (N+1 → 1)
    String from = entry.fromAccount();
    String to   = entry.toAccount();
    long amt    = entry.amount();

    List<WalletEntryJpaEntity> latestWallets =
        walletRepo.findLatestByAccountIds(Arrays.asList(from, to));

    Map<String, Long> balanceMap = latestWallets.stream()
        .collect(Collectors.toMap(
            WalletEntryJpaEntity::getAccountId,
            WalletEntryJpaEntity::getBalanceAfter
        ));

    long fromPrev = balanceMap.getOrDefault(from, 0L);
    long toPrev   = balanceMap.getOrDefault(to, 0L);

    // 3) WalletEntry 생성 (from: 음수, to: 양수)
    WalletEntry fromEntry = WalletEntry.of(
        from, -amt, fromPrev - amt,
        entry.occurredAt(), entry.orderId(), entry.memo()
    );
    WalletEntry toEntry = WalletEntry.of(
        to, +amt, toPrev + amt,
        entry.occurredAt(), entry.orderId(), entry.memo()
    );

    // 4) 배치 저장
    walletRepo.saveAll(Arrays.asList(
        WalletEntryJpaEntity.from(fromEntry),
        WalletEntryJpaEntity.from(toEntry)
    ));
}

변경의 핵심은 두 가지입니다.

  1. findTopByAccountIdOrderByIdDesc × N → findLatestByAccountIds × 1
  2. save() × N → saveAll() × 1 (JDBC 배치 INSERT 가능)

이 쿼리는 진짜 빠른가 — EXPLAIN으로 검증

쿼리를 바꿨다고 해서 끝이 아닙니다. EXPLAIN으로 실행 계획을 확인하지 않으면 인덱스를 제대로 타는지 알 수 없습니다.

EXPLAIN 기초 — 읽는 법

EXPLAIN은 MySQL이 쿼리를 어떻게 실행할 계획인지를 보여줍니다.

1
2
3
4
5
6
7
8
9
EXPLAIN
SELECT w.*
FROM wallet_entry w
WHERE w.id IN (
    SELECT MAX(w2.id)
    FROM wallet_entry w2
    WHERE w2.account_id IN ('USER:42', 'MERCHANT:eco')
    GROUP BY w2.account_id
);

결과의 핵심 컬럼들을 하나씩 짚겠습니다.

type — 접근 방식

type의미결제에서의 허용 여부
const / eq_refPK/유니크 인덱스로 1건이상적
ref비유니크 인덱스로 여러 건허용
range인덱스 범위 스캔허용 (주의)
index인덱스 풀 스캔지양
ALL테이블 풀 스캔절대 불가

결제 트랜잭션 안의 쿼리에서 ALL이 나오면 즉시 수정 대상입니다. 트랜잭션 안의 풀 스캔은 곧 전체 테이블에 대한 락 확산을 의미하기 때문입니다.

key — 실제 사용된 인덱스

1
key: idx_wallet_account_id

이 필드가 NULL이면 인덱스를 사용하지 않은 것입니다.

rows — 예상 스캔 행 수

MySQL 옵티마이저가 이 쿼리를 실행하기 위해 검사해야 할 행 수의 추정치입니다. 이 수가 클수록 느리고, 락 범위도 넓어질 수 있습니다.

Extra — 추가 정보

Extra의미결제 영향
Using index커버링 인덱스가장 빠름
Using where인덱스 후 추가 필터링보통
Using filesort정렬 필요위험
Using temporary임시 테이블 생성매우 위험

인덱스 없이 EXPLAIN

인덱스가 account_id 컬럼에 없는 상태에서 EXPLAIN을 실행하면:

1
2
3
4
5
6
7
8
+----+-------------+-------+------+------+------+----------+-------------+
| id | select_type | table | type | key  | rows | filtered | Extra       |
+----+-------------+-------+------+------+------+----------+-------------+
|  1 | PRIMARY     | w     | ALL  | NULL | 50000|   100.00 | Using where |
|  2 | SUBQUERY    | w2    | ALL  | NULL | 50000|    50.00 | Using where;|
|    |             |       |      |      |      |          | Using temp; |
|    |             |       |      |      |      |          | Using fsort |
+----+-------------+-------+------+------+------+----------+-------------+

진단 결과:

  • type: ALL → 두 테이블 모두 풀 스캔
  • Using temporary → GROUP BY를 위한 임시 테이블 생성
  • Using filesort → 임시 테이블 정렬
  • rows: 50000 → 5만 건 전부 스캔

이건 결제 트랜잭션 안에서 실행되면 재앙입니다. 5만 건을 스캔하는 동안 락이 확산되고, 커넥션 점유 시간이 폭증합니다.

적절한 인덱스 설계

1
CREATE INDEX idx_wallet_account_id ON wallet_entry (account_id, id);

복합 인덱스(Composite Index) 는 다음을 가능하게 합니다.

  1. account_id IN (...) → 인덱스 범위 스캔
  2. GROUP BY account_id → 인덱스 순서와 일치, 추가 정렬 불필요
  3. MAX(id) → 각 account_id 그룹의 마지막 리프 노드에서 즉시 획득

(account_id, id) 순서인가?

1
2
3
4
B+Tree 리프 (account_id, id) 정렬 순서:

[MERCHANT:eco, 2] → [MERCHANT:eco, 7] → [MERCHANT:eco, 15]
→ [USER:42, 1] → [USER:42, 5] → [USER:42, 9] → [USER:42, 22]
  • account_id로 먼저 정렬되므로, IN 조건으로 원하는 계정들만 빠르게 찾음
  • 같은 account_id 안에서 id 순으로 정렬되므로, MAX(id)는 마지막 엔트리에서 바로 읽음

인덱스 적용 후 EXPLAIN

1
2
3
4
5
6
+----+-------------+-------+-------+------------------------+------+----------+-------------+
| id | select_type | table | type  | key                    | rows | filtered | Extra       |
+----+-------------+-------+-------+------------------------+------+----------+-------------+
|  1 | PRIMARY     | w     | eq_ref| PRIMARY                |    2 |   100.00 | NULL        |
|  2 | SUBQUERY    | w2    | range | idx_wallet_account_id  |    4 |   100.00 | Using index |
+----+-------------+-------+-------+------------------------+------+----------+-------------+

진단 결과:

  • type: range → 인덱스 범위 스캔 (풀 스캔 제거)
  • Using index → 커버링 인덱스 (클러스터 인덱스 접근 불필요)
  • rows: 4 → 단 4건만 스캔 (vs 이전 50,000건)
  • 임시 테이블/파일 정렬 없음

5만 건 스캔이 4건으로 줄었습니다. 이것이 인덱스의 힘이고, 결제 트랜잭션의 생존 조건입니다.


인덱스 설계가 곧 락 범위 설계인 이유

1부에서 이미 InnoDB는 레코드가 아니라 인덱스를 잠근다고 설명했습니다. 이 원칙을 3부의 맥락으로 다시 풀겠습니다.

원칙 1: 쿼리가 스캔한 인덱스 범위 = 락 범위

InnoDB의 REPEATABLE READ 격리 수준에서, SELECT ... FOR UPDATEUPDATE 문이 실행되면 쿼리가 스캔한 인덱스 엔트리 전체에 락이 걸립니다.

이때, 인덱스를 어떻게 타느냐에 따라 락 범위가 극적으로 달라집니다.

Case A: 인덱스가 없는 경우

1
2
3
4
UPDATE wallet_entry
SET balance_after = 90000
WHERE account_id = 'USER:42'
AND id = (SELECT MAX(id) FROM wallet_entry WHERE account_id = 'USER:42');

인덱스가 없으면 InnoDB는 클러스터 인덱스 풀 스캔을 수행합니다. 풀 스캔 중 지나간 모든 레코드에 락이 걸립니다.

1
2
3
락 범위: wallet_entry 테이블의 모든 레코드
→ 사실상 테이블 락
→ 다른 결제의 Wallet 업데이트가 전부 대기

Case B: account_id 단일 인덱스

1
-- 인덱스: (account_id)

account_id 범위만 스캔하므로, 해당 계정의 모든 WalletEntry에 락이 걸립니다.

1
2
락 범위: account_id = 'USER:42'인 모든 엔트리 (수백~수천 건)
+ Gap Lock (해당 account_id 범위 앞뒤)

Case C: 복합 인덱스 (account_id, id)

1
-- 인덱스: (account_id, id)

account_id + id의 조합으로 정확히 1건만 스캔합니다.

1
2
3
락 범위: account_id = 'USER:42'이고 id = MAX인 딱 1건
→ Record Lock 1건
→ 동시성 최대

원칙 2: 결제에서 락 범위 = 동시 처리량

이를 수치로 표현해봅시다.

동시에 100건의 결제가 실행되고, 각각 다른 계정 쌍을 다루는 상황에서:

인덱스 전략락 범위동시 실행 가능 건수
인덱스 없음테이블 전체1건 (나머지 99건 대기)
(account_id)계정별 전체 엔트리계정이 겹치지 않으면 100건
(account_id, id)정확히 1건100건 전부

인덱스 하나가 동시 처리량을 100배까지 차이 나게 합니다.

원칙 3: Gap Lock과 INSERT의 관계

REPEATABLE READ에서 범위 쿼리를 실행하면 Gap Lock이 걸립니다. Gap Lock은 해당 범위에 새로운 레코드가 INSERT되는 것을 차단합니다.

WalletEntry는 append-only 테이블입니다. 매 결제마다 새 엔트리를 INSERT합니다.

만약 잔액 조회 쿼리가 넓은 범위의 Gap Lock을 걸면:

1
2
3
Gap Lock: account_id = 'USER:42', id > 22 인 범위 전체
→ 이 계정의 새로운 WalletEntry INSERT가 차단
→ 같은 계정에 대한 다른 결제가 Wallet 저장 시 대기

따라서 잔액 조회는 가능한 한 좁은 범위를 스캔해야 합니다. (account_id, id) 복합 인덱스 + MAX(id) 서브쿼리는 이 조건을 만족합니다.


InnoDB 버퍼 풀과 워킹 셋 — 메모리 관점의 성능

인덱스가 있어도 디스크에서 읽으면 느립니다. InnoDB는 버퍼 풀(Buffer Pool) 이라는 메모리 캐시를 통해 이 문제를 해결합니다.

버퍼 풀의 기본 원리

1
2
3
4
5
쿼리 실행
  ↓
페이지가 버퍼 풀에 있는가?
  ├─ Yes → 메모리에서 바로 읽음 (Buffer Hit) ← ~0.1ms
  └─ No  → 디스크에서 읽어 버퍼 풀에 적재 (Buffer Miss) ← ~5~10ms

결제 시스템에서 p95/p99를 안정적으로 유지하려면 워킹 셋(Working Set) 이 버퍼 풀에 상주해야 합니다.

워킹 셋이란?

일상적인 운영에서 자주 접근하는 데이터의 총합

결제 시스템의 워킹 셋:

  • 최근 N일간의 Payment 레코드
  • 활성 계정의 최신 WalletEntry
  • 최근 LedgerEntry
  • 모든 인덱스의 루트/내부 노드

워킹 셋 크기 추정

1
2
3
4
5
6
7
8
9
10
WalletEntry 테이블:
- 일 평균 5,000건 INSERT
- 레코드 크기: ~200 bytes
- 30일 워킹 셋: 5,000 × 30 × 200 = 30MB

인덱스 (account_id, id):
- 엔트리 크기: ~50 bytes
- 30일: 5,000 × 30 × 50 = 7.5MB

합계 워킹 셋: ~40MB

버퍼 풀이 128MB 이상이면 워킹 셋이 메모리에 충분히 상주합니다. 이 상태에서는 거의 모든 쿼리가 Buffer Hit으로 실행되어 디스크 I/O 없이 0.1ms 수준으로 응답합니다.

버퍼 풀 히트율 모니터링

1
SHOW STATUS LIKE 'Innodb_buffer_pool_read%';
1
2
Innodb_buffer_pool_read_requests: 1000000   (버퍼 풀 요청 수)
Innodb_buffer_pool_reads:         1000      (디스크에서 읽은 수)
1
히트율 = (1,000,000 - 1,000) / 1,000,000 = 99.9%

결제 시스템에서는 99.5% 이상을 유지해야 합니다. 히트율이 떨어지면 p99가 급격히 올라가는 패턴이 나타납니다.


결제 도메인에서의 인덱스 전략 총정리

테이블별 인덱스 설계

이제 결제 시스템의 핵심 테이블 3개에 대한 인덱스 전략을 정리합니다.

1. Payment 테이블

1
2
3
4
5
6
7
8
9
10
11
12
13
-- PK: id (클러스터 인덱스)
-- UNIQUE: order_id (Record Lock 용)
-- 인덱스: created_at (조정/정산 배치 조회용)
-- 인덱스: status, created_at (미완료 결제 조회용)

CREATE UNIQUE INDEX idx_payment_order_id
    ON payment (order_id);

CREATE INDEX idx_payment_created_at
    ON payment (created_at);

CREATE INDEX idx_payment_status_created
    ON payment (status, created_at);

각 인덱스의 존재 이유:

인덱스용도락 영향
order_id UNIQUElockByOrderIdRecord Lock 1건 (최소 락)
created_atfindByDateRange (정산)범위 스캔, 트랜잭션 밖에서 사용
(status, created_at)미완료 결제 조회 (조정)트랜잭션 밖에서 사용

lockByOrderId가 유니크 인덱스를 사용하므로:

  • Gap Lock 없음 (유니크 조건이므로 1건만 매칭)
  • Record Lock 1건 (최소 범위)
  • 동시에 다른 주문의 결제가 전혀 영향 받지 않음

2. LedgerEntry 테이블

1
2
3
4
5
-- PK: id (클러스터 인덱스)
-- 인덱스: order_id (결제별 원장 조회)

CREATE INDEX idx_ledger_order_id
    ON ledger_entry (order_id);

원장은 INSERT-only이므로 UPDATE/DELETE 시 락 고려가 불필요합니다. order_id 인덱스는 조회 전용이며, 조정(Reconciliation) 배치에서 사용됩니다.

3. WalletEntry 테이블

1
2
3
4
5
-- PK: id (클러스터 인덱스)
-- 인덱스: (account_id, id) → 최신 잔액 조회 + N+1 해결

CREATE INDEX idx_wallet_account_id
    ON wallet_entry (account_id, id);

이 인덱스는 다음 두 쿼리를 모두 커버합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
-- 단일 계정 최신 잔액 (기존)
SELECT * FROM wallet_entry
WHERE account_id = ?
ORDER BY id DESC LIMIT 1;

-- 다중 계정 최신 잔액 (N+1 해결)
SELECT w.* FROM wallet_entry w
WHERE w.id IN (
    SELECT MAX(w2.id)
    FROM wallet_entry w2
    WHERE w2.account_id IN (?, ?)
    GROUP BY w2.account_id
);

인덱스를 추가하면 생기는 비용

인덱스는 공짜가 아닙니다. 추가할 때마다 다음 비용이 발생합니다.

1) INSERT 성능 저하

매 INSERT마다 모든 인덱스의 B+Tree에 새 엔트리를 추가해야 합니다.

1
2
3
인덱스 0개: INSERT ~0.5ms
인덱스 1개: INSERT ~0.7ms
인덱스 3개: INSERT ~1.2ms

WalletEntry는 매 결제마다 2건씩 INSERT되므로, 인덱스가 많으면 INSERT 비용이 누적됩니다.

2) 디스크 공간

인덱스는 별도 B+Tree로 디스크에 저장됩니다. 테이블 크기의 30~100% 추가 공간이 필요합니다.

3) 버퍼 풀 경쟁

인덱스 페이지도 버퍼 풀에 캐시됩니다. 인덱스가 많으면 버퍼 풀에서 데이터 페이지가 밀려나 히트율이 떨어질 수 있습니다.

따라서 인덱스는 필요한 만큼만, 정확하게 설계해야 합니다.

결제 도메인의 인덱스 원칙

이러한 트레이드오프를 고려해 다음 원칙을 세웠습니다.

  1. 트랜잭션 안의 쿼리는 반드시 인덱스를 태운다
  2. 락이 필요한 쿼리는 PK/유니크 인덱스를 사용한다
  3. 범위 조회는 트랜잭션 밖에서 실행한다
  4. INSERT 빈도가 높은 테이블은 인덱스를 최소화한다
  5. 배치/정산 쿼리용 인덱스는 운영 데이터와 분리를 고려한다

saveAll()과 JDBC 배치 INSERT

N+1 해결의 두 번째 축은 저장입니다. save() × N을 saveAll()로 바꾸면 끝일까요?

JPA saveAll()의 실체

Spring Data JPA의 saveAll()은 기본적으로 루프를 돌며 save()를 호출합니다.

1
2
3
4
5
6
7
8
// SimpleJpaRepository 내부
@Transactional
public <S extends T> List<S> saveAll(Iterable<S> entities) {
    for (S entity : entities) {
        result.add(save(entity));  // 개별 save
    }
    return result;
}

즉, 기본 상태에서는 save() × N과 동일합니다. 실제 배치 INSERT를 위해서는 추가 설정이 필요합니다.

JDBC 배치 활성화

1
2
3
4
5
6
7
8
spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          batch_size: 50
        order_inserts: true
        order_updates: true
설정역할
batch_size: 50INSERT를 50건씩 묶어서 실행
order_inserts: true같은 엔티티 INSERT를 연속 배치
order_updates: true같은 엔티티 UPDATE를 연속 배치

이 설정이 켜지면:

1
2
3
4
5
6
7
8
-- Before (2번의 네트워크 왕복)
INSERT INTO wallet_entry (...) VALUES ('USER:42', ...);
INSERT INTO wallet_entry (...) VALUES ('MERCHANT:eco', ...);

-- After (1번의 네트워크 왕복)
INSERT INTO wallet_entry (...) VALUES
    ('USER:42', ...),
    ('MERCHANT:eco', ...);

IDENTITY 전략과 배치의 비호환

여기서 매우 중요한 함정이 있습니다.

JPA의 @GeneratedValue(strategy = GenerationType.IDENTITY)를 사용하면 Hibernate 배치 INSERT가 비활성화됩니다.

이유는:

  1. IDENTITY = DB의 AUTO_INCREMENT
  2. INSERT 후 DB가 생성한 ID를 받아야 영속성 컨텍스트에 등록 가능
  3. 따라서 각 INSERT를 개별 실행해야 ID를 알 수 있음
  4. 배치로 묶으면 중간 INSERT의 ID를 알 수 없음

해결 방안:

전략배치 가능비고
IDENTITY불가MySQL 기본, 배치와 비호환
SEQUENCE가능시퀀스 미리 할당 후 배치
TABLE가능시퀀스 테이블 사용, 성능 이슈
UUID 직접 생성가능DB 의존성 없음

MySQL은 네이티브 시퀀스를 지원하지 않으므로, 배치 INSERT가 필수적인 경우 SEQUENCE + allocationSize 또는 애플리케이션에서 ID를 직접 생성하는 방식을 고려해야 합니다.

다만, 현재 결제 시스템에서 WalletEntry INSERT는 건당 2건이므로 배치 비활성화의 영향이 크지 않습니다. 네트워크 왕복 1회 추가(~0.5ms) 수준이기 때문입니다.

결제 건수가 극적으로 늘어나 정산 배치에서 수천 건을 한 번에 INSERT해야 하는 상황이 오면, 그때 ID 전략을 재검토하는 것이 현실적입니다.


p95/p99 지표로 보는 빠르면서 안전한 구조

왜 평균이 아니라 p95/p99인가

성능 지표에서 평균(mean) 은 거짓말을 합니다.

1
2
3
4
5
6
응답 시간 (ms): 10, 12, 11, 10, 13, 10, 12, 11, 500, 10

평균: 59.9ms ← "음, 나쁘지 않은데?"
p50: 11ms
p95: 500ms ← "5% 사용자가 0.5초를 기다림"
p99: 500ms ← "100명 중 1명은 0.5초"

결제 시스템에서 100명 중 1명이 0.5초를 대기하는 것은 심각한 문제입니다. 그 1명이 결제를 포기하거나, 이중 클릭을 하거나, CS 문의를 남기기 때문입니다.

따라서 결제 시스템의 성능 목표는:

지표목표의미
p50< 50ms절반의 요청이 50ms 이내
p95< 200ms95%의 요청이 200ms 이내
p99< 500ms99%의 요청이 500ms 이내

p99 500ms를 넘기면 알림 발생 수준으로 설정합니다.

p95/p99를 악화시키는 주범들

1) N+1 쿼리

N+1은 p50에는 거의 영향이 없습니다. 평소에는 N이 작고, 각 쿼리도 빠르기 때문입니다.

하지만 p99를 파괴합니다.

  • 특정 계정의 WalletEntry가 10만 건 → 정렬 비용 증가
  • 버퍼 풀 미스 발생 → 디스크 I/O 추가
  • 이 두 가지가 겹치면 1건의 쿼리가 100ms+로 점프

N+1 해결 전후의 p99 변화를 예측하면:

1
2
3
4
5
6
7
8
9
Before (N+1):
  계정별 최신 잔액 조회 × 2 = ~10ms (평상시)
  버퍼 미스 시: ~50ms × 2 = 100ms
  + 테이블 풀 스캔 시: ~200ms+

After (단일 쿼리):
  배치 조회 × 1 = ~5ms (평상시)
  버퍼 미스 시: ~15ms
  → p99 기여분이 200ms+ → 15ms로 감소

2) 인덱스 미스로 인한 풀 스캔

앞서 EXPLAIN에서 봤듯이, 인덱스 없는 상태에서는 5만 건을 전부 스캔합니다. 이것이 p99에 미치는 영향:

1
2
3
4
데이터 50,000건:
- 인덱스 있음: ~1ms (4건 스캔)
- 인덱스 없음: ~50ms (50,000건 스캔)
- 인덱스 없음 + 버퍼 미스: ~200ms+

3) 락 대기(Lock Wait)

p95/p99의 숨은 주범입니다. 쿼리 자체는 빠르지만, 다른 트랜잭션이 락을 잡고 있으면 대기합니다.

1
2
3
4
TX-A: lockByOrderId('ORD-1') → 락 획득 → PG 호출 중... (1초)
TX-B: lockByOrderId('ORD-1') → 락 대기... → 1초 후 획득

TX-B의 체감 응답 시간: 쿼리 10ms + 락 대기 1000ms = 1010ms

이것이 1부에서 PG 호출은 트랜잭션 밖에서 해야 한다고 강조한 이유입니다. PG 호출이 트랜잭션 안에 있으면, 같은 주문에 대한 모든 요청이 PG 응답 시간만큼 대기합니다.

p95/p99 개선 전략

이러한 주범들을 종합하면, 다음과 같은 계층적 전략이 나옵니다.

Layer 1: 쿼리 최적화 (이 글의 핵심)

  • N+1 → 배치 쿼리
  • 풀 스캔 → 인덱스 설계
  • filesort → 인덱스 정렬 활용

Layer 2: 트랜잭션 최적화 (1부의 핵심)

  • 외부 I/O 분리 → 락 시간 최소화
  • REQUIRES_NEW → 커넥션 점유 최소화
  • PK 기반 락 → 동시성 최대화

Layer 3: 인프라 최적화

  • 버퍼 풀 크기 조정 → 히트율 99.5%+ 유지
  • 커넥션 풀 크기 조정 → 피크 시 고갈 방지
  • 슬로우 쿼리 로그 → p99 원인 추적

Layer 4: 아키텍처 최적화

  • Read Replica → 조회 트래픽 분리
  • 정산 배치 분리 → 실시간 결제에 영향 없도록
  • 캐시(Redis) → 조회 빈도 높은 데이터 분리

슬로우 쿼리 로그 — p99 원인을 찾는 도구

설정

1
2
3
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0.1;  -- 100ms 이상을 슬로우로 기록
SET GLOBAL log_queries_not_using_indexes = 'ON';  -- 인덱스 미사용 쿼리도 기록
설정이유
long_query_time0.1 (100ms)결제 p95 목표 200ms의 절반
log_queries_not_using_indexesON풀 스캔 쿼리 조기 발견

슬로우 쿼리 분석

1
2
3
4
5
6
7
# Time: 2026-02-27T10:23:45.123456Z
# User@Host: ecozone[ecozone] @ localhost
# Query_time: 0.234  Lock_time: 0.150  Rows_sent: 1  Rows_examined: 48732
SET timestamp=1740651825;
SELECT * FROM wallet_entry
WHERE account_id = 'USER:42'
ORDER BY id DESC LIMIT 1;

이 로그에서 읽어야 할 핵심 정보:

필드의미
Query_time0.234s쿼리 실행 234ms
Lock_time0.150s그 중 150ms가 락 대기
Rows_examined48,73248,732건을 스캔 (인덱스 미사용 의심)
Rows_sent1결과는 1건

Rows_examined: 48732 / Rows_sent: 1 의 비율 = 48,732:1

이 비율이 100:1을 넘으면 인덱스 설계를 의심해야 합니다. 이상적으로는 1:1에 가까워야 합니다.


결제 정산 배치에서의 대량 조회 최적화

실시간 결제 외에, 정산(Settlement) 배치에서도 성능이 중요합니다. 다만 최적화 방향이 다릅니다.

실시간 vs 배치의 차이

구분실시간 결제정산 배치
쿼리 패턴PK 기반 단건범위 기반 대량
트랜잭션짧게, REQUIRES_NEW청크 단위, 길어질 수 있음
락 전략Record Lock (최소)락 회피 (읽기 전용)
지표p95/p99총 처리 시간

정산 배치 쿼리

1
2
3
4
5
6
7
@Override
public List<Payment> findByDateRange(Instant from, Instant to) {
    return paymentRepo.findByCreatedAtBetween(from, to)
            .stream()
            .map(PaymentJpaEntity::toDomain)
            .toList();
}

이 쿼리는 created_at 범위로 결제를 조회합니다.

주의: 이 쿼리를 결제 트랜잭션 안에서 실행하면 안 됩니다.

범위 조건 + REPEATABLE READ = Next-Key Lock 확산. 하루치 결제가 1만 건이면, 1만 개의 인덱스 엔트리에 락이 걸립니다.

따라서 정산 배치는:

  1. 트랜잭션 밖 또는 READ-ONLY 트랜잭션에서 실행
  2. 커서(Cursor) 기반 페이징으로 메모리 관리
  3. 청크(Chunk) 단위 처리로 OOM 방지
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Transactional(readOnly = true)
public void settle(LocalDate date) {
    Instant from = date.atStartOfDay(ZoneId.systemDefault()).toInstant();
    Instant to   = from.plus(1, ChronoUnit.DAYS);

    // 청크 단위 처리
    Pageable page = PageRequest.of(0, 500, Sort.by("id"));
    Page<PaymentJpaEntity> chunk;

    do {
        chunk = paymentRepo.findByCreatedAtBetween(from, to, page);
        chunk.forEach(this::processSettlement);
        page = page.next();
    } while (chunk.hasNext());
}

@Transactional(readOnly = true)의 효과:

  • InnoDB에서 쓰기 락을 걸지 않음
  • Undo Log 생성 불필요
  • MVCC 스냅샷만 사용 → 동시 결제에 영향 없음

쿼리 최적화의 함정 — 과도한 최적화가 오히려 위험한 경우

함정 1: 캐시로 N+1을 숨기는 것

1
2
3
4
5
6
7
@Cacheable("walletBalance")
public long getBalance(String accountId) {
    return walletRepo
        .findTopByAccountIdOrderByIdDesc(accountId)
        .map(WalletEntryJpaEntity::getBalanceAfter)
        .orElse(0L);
}

캐시를 적용하면 N+1 쿼리가 캐시 히트로 대체되어 빨라집니다. 하지만 결제 도메인에서 이건 매우 위험합니다.

문제: 캐시 무효화 타이밍

1
2
3
1. TX-A: 잔액 조회 → 캐시: 100,000원
2. TX-B: 동시에 다른 결제 → 잔액 90,000원으로 변경
3. TX-A: 캐시에서 100,000원 사용 → 잘못된 잔액으로 계산

결제에서 잔액은 트랜잭션 시점의 정확한 값이어야 합니다. 캐시된 값을 사용하면 정합성이 깨집니다.

캐시가 안전한 경우:

  • 읽기 전용 데이터 (상품 정보, 환율 등)
  • 통계/리포트용 집계 데이터
  • 멱등키 중복 확인 (이미 처리된 결제 체크)

캐시가 위험한 경우:

  • 잔액, 재고, 포인트 등 경합 가능한 상태 값
  • 결제 상태 조회 (AUTHORIZED → CONFIRMED 전이 직전)

함정 2: 비동기 처리로 트랜잭션을 빠르게 만드는 것

1
2
3
4
@Async
public void appendLedgerAsync(LedgerEntry entry) {
    append(entry);
}

Ledger 기록을 비동기로 빼면 confirm 트랜잭션이 빨라집니다. 하지만:

  • Ledger가 실패하면? → 원장 없는 결제가 됨
  • 서버가 죽으면? → 비동기 큐의 작업이 유실
  • 조정(Reconciliation)에서 불일치로 잡힘

결제의 핵심 경로(Critical Path)에서는 동기 + 같은 트랜잭션이 원칙입니다. 비동기는 알림, 로그, 통계 등 비핵심 경로에만 사용합니다.

함정 3: Read Replica로 잔액을 조회하는 것

1
2
결제 쓰기 → Primary
잔액 읽기 → Replica

Replica는 Primary에 비해 복제 지연(Replication Lag) 이 있습니다. 보통 수~수백 ms이지만, 결제에서는 이 지연이 치명적입니다.

1
2
3
4
1. Primary에 WalletEntry INSERT (잔액 90,000)
2. Replica는 아직 반영 안 됨 (잔액 100,000)
3. 다음 결제가 Replica에서 잔액 조회 → 100,000원
4. 잔액 부족 체크를 통과 → 초과 결제 발생

따라서 결제의 쓰기 경로에서 잔액 조회는 반드시 Primary에서 해야 합니다. Read Replica는 정산 리포트, 통계 대시보드 등에만 사용합니다.


최적화 전후 성능 비교 정리

쿼리 수 비교

항목BeforeAfter감소율
Wallet 잔액 조회N번 (계정 수)1번1/N
Wallet 저장N번 (개별 save)1번 (saveAll)1/N
총 쿼리 수 (2계정 기준)5340% ↓
총 쿼리 수 (5계정 기준)11373% ↓

예상 응답 시간 비교

지표BeforeAfter개선
p50~30ms~20ms33% ↓
p95~150ms~50ms67% ↓
p99~500ms+~100ms80%+ ↓

p99의 극적인 개선은 다음 요인들의 복합 효과입니다:

  • N+1 제거 → 쿼리 수 자체가 줄어 디스크 I/O 확률 감소
  • 인덱스 적용 → 스캔 범위 50,000건 → 4건
  • 락 범위 축소 → 락 대기 시간 감소
  • 트랜잭션 시간 단축 → 커넥션 점유 감소 → 풀 경쟁 완화

동시 처리량 비교

지표BeforeAfter
단일 결제 트랜잭션 시간~100ms~30ms
초당 가능 TPS (커넥션 10개)~100~333
데드락 확률높음 (넓은 락 범위)낮음 (Record Lock)

모니터링: 운영에서 성능을 지키는 방법

최적화를 했으면 이를 지속적으로 모니터링해야 합니다. 성능은 데이터가 쌓이면서, 트래픽이 변하면서 언제든 악화될 수 있기 때문입니다.

핵심 모니터링 지표

1) 쿼리 성능

1
2
-- 슬로우 쿼리 수 추이
SHOW GLOBAL STATUS LIKE 'Slow_queries';

2) 버퍼 풀 히트율

1
2
-- 히트율 = 1 - (reads / read_requests)
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%';

3) 락 대기

1
2
3
4
5
6
-- 현재 락 대기 중인 트랜잭션
SELECT * FROM information_schema.INNODB_TRX
WHERE trx_state = 'LOCK WAIT';

-- 락 대기 시간 추이
SHOW GLOBAL STATUS LIKE 'Innodb_row_lock_time_avg';

4) 커넥션 풀

1
2
3
4
HikariCP 메트릭:
- hikaricp_connections_active: 활성 커넥션 수
- hikaricp_connections_pending: 대기 중인 요청 수
- hikaricp_connections_timeout: 타임아웃 발생 수

알림 기준

지표정상경고위험
p99 응답 시간< 500ms500ms~1s> 1s
버퍼 풀 히트율> 99.5%99~99.5%< 99%
슬로우 쿼리/분< 55~20> 20
락 대기 평균< 10ms10~50ms> 50ms
커넥션 풀 대기01~5> 5

성능 최적화의 핵심 요약과 결제 도메인의 교훈

1. N+1은 지금이 아니라 구조의 문제다

현재 계정이 2개라서 쿼리 2번이더라도, N+1 패턴을 안고 있으면 미래에 반드시 문제가 됩니다.

수수료 분배, 다중 판매자, 정산 배치가 들어오는 순간 쿼리 수는 비례해서 증가하고, p99가 예측 불가능해집니다.

따라서 N+1은 성능이 나쁠 때 고치는 게 아닌, 설계 시점에 구조적으로 제거해야 합니다.

2. 인덱스 설계 = 락 범위 설계 = 동시성 설계

결제 도메인에서 인덱스는 단순한 성능 도구가 아닙니다.

1
2
3
인덱스 없음 → 풀 스캔 → 테이블 락 → 동시 처리 불가
잘못된 인덱스 → 넓은 범위 스캔 → Gap Lock 확산 → 데드락
정확한 인덱스 → 1건 스캔 → Record Lock → 최대 동시성

따라서 인덱스를 추가할 때는 항상 다음을 함께 생각해야 합니다.

  • 이 인덱스로 인해 락 범위가 어떻게 바뀌는가?
  • 이 쿼리가 트랜잭션 안에서 실행되는가, 밖에서 실행되는가?
  • 동시에 같은 데이터에 접근하는 다른 트랜잭션이 있는가?

3. p99를 보라, 평균에 속지 마라

결제 시스템의 성능은 최악의 경우가 사용자 경험을 결정합니다. 평균 30ms인데 p99가 2초이면, 100명 중 1명은 결제 포기 직전입니다.

p99를 악화시키는 주범:

  • N+1 쿼리 (데이터 편차에 따라 폭발)
  • 인덱스 미스 (풀 스캔 + 디스크 I/O)
  • 락 대기 (다른 트랜잭션의 PG 호출 시간에 종속)
  • 버퍼 풀 미스 (워킹 셋 > 버퍼 풀 크기)

4. 빠른 것과 안전한 것을 동시에 추구하라

이 글의 제목이 빠르면서 안전한 구조인 이유입니다.

결제에서 빠르지만 위험한 최적화:

  • 캐시로 잔액 조회 → 정합성 깨짐
  • 비동기로 원장 기록 → 유실 위험
  • Replica에서 잔액 읽기 → 복제 지연으로 초과 결제
  • 락 안 걸기 → 동시 변경 충돌

결제에서 빠르면서 안전한 최적화:

  • N+1 → 배치 쿼리 → 쿼리 수 감소, 정합성 유지
  • 적절한 인덱스 → 스캔 범위 최소화, 락 범위 최소화
  • PK/유니크 기반 락 → 동시성 최대화, 데드락 최소화
  • 트랜잭션 분리 → 외부 I/O와 DB 격리, 커넥션 풀 안정화

5. 결제 시스템은 측정 → 개선 → 검증의 반복이다

1
2
3
4
1. 측정: EXPLAIN, 슬로우 쿼리 로그, p95/p99 메트릭
2. 개선: 인덱스 추가, 쿼리 리팩토링, 트랜잭션 분리
3. 검증: EXPLAIN 재확인, 부하 테스트, 프로덕션 모니터링
4. 반복: 데이터가 쌓이면 다시 1번부터

이 사이클을 돌릴 수 있는 시스템이 곧 운영 가능한 결제 시스템입니다.


다음 글 예고: 4부 — 결제 시스템 보안

4부에서는 결제 시스템의 보안을 다룹니다.

  • 웹훅 위변조 방지(서명 검증), 리플레이 공격 방지
  • 결제 금액 변조, orderId 변조, 중간자 공격을 어떻게 막는가
  • 결제 시스템에서 신뢰의 경계를 어디에 둘 것인가

성능이 아무리 좋아도, 보안이 뚫리면 의미가 없습니다. 결제 시스템에서 신뢰란 무엇인지, 어디까지를 시스템이 책임져야 하는지를 4부에서 이어가겠습니다.

This post is licensed under CC BY 4.0 by the author.