개요

v0.1 인 이 실험에서의 주된 관찰 내용은 ‘모놀리식 서버에서의 유저 증가에 따른 부하와 동시성’ 이다.

즉, 막 사업을 시작하는 단계의 스타트업에서 겪을 수 있는 서버 관련 이슈를 관찰하고자 했다.

나는 이 실험을 시작하기 전에 몇가지 가설을 세웠다.

가설은 다음과 같다.

가설

  1. 데이터 정합성 문제(동시성 문제) 는 생각보다 빈번히 일어나지 않을 것이다.
  2. 정합성 문제는 초기 잔량 - 채굴 로그 수를 음수로 만들 것이다.
  3. DB 커넥션 풀의 크기를 키운다면 서버의 성능은 그에 비례하여 상승할 것이다.

이 들을 증명하기 위한 실험 방법은 다음과 같다.

전제 상황

  • 각 유저는 접속 후 100개의 광물을 캔다. (= Iteration : 100)
  • 순수한 비즈니스 로직에 대한 부하, 동시성을 확인하기 위해 인증은 수행하지 않는다.
  • 타임아웃은 약 10분, 도달하지 못하는 요청은 없게 한다.

실험 방법

  • DB 커넥션 풀 사이즈는 현실성 있는 사이즈를 기준으로 테스트 한다.
    • 10 : 공식, 권장 사이즈
    • 30: 커넥션 부족 대응
    • 50: 커넥션 부족 대응 2
  • VU(가상 유저) 는 10, 50, 100, 300, 500 명을 기준으로 테스트 한다.
    • 가상 유저는 동시 요청의 수를 의미
    • 동시성 문제가 관측되는 ‘지점’ 을 찾기 위함
    • 부하 증가에 따른 TPS, Latency, Error Rate의 추이를 비교하기 위한 대략적 구간
  • 테스트는 hotspot (단일 자원에 대한 요청) 으로 진행한다.
  • 동시성 문제에 대한 검증 다음과 같이 수행한다.
    • 초기잔량(VU * Iteration) - 채굴 로그 수

실험 환경

Gold-Rush-Lab 실험 환경

실험 대상 로직

Gold-Rush-Lab 실험 대상 로직

실험 대상 로직은 (test) 로직이다.

  1. (setup) hotspot 실행 전 광산을 생성한다.
  2. (setup) VU 만큼 유저를 가입한다.
  3. (test) 생성한 광산에 대해 설정한 VU, Iteration 만큼 요청을 수행한다.
    1. 각 VU는 광산 요청을 한다
    2. 요청을 받은 광산 서비스는 유저를 조회한다.
    3. 각 유저는 할당 받은 광산에서 채굴한다.
    4. 각 유저는 유저의 채굴량, 잔량, 성공 여부를 반환받는다.

결과

각 Connection Pool, VU 에 따른 결과는 다음과 같다.

버전 Hikari Max Pool Size VU TPS System CPU Peak Process CPU Peak Avg Latency Mean P95 P99 Error Rate 초기 잔량 사용자 총 채굴량 계산 잔량 (Init - Log) 실제 잔량 정합성
v0.1 10 1 1.82 req/s 1.96% 0.8% 2.507 ms 7.298 ms 9.395 ms 0 100 100 0 0
v0.1 10 10 18.2 req/s 0.167% 0.1% 8.277 ms 26.098 ms 38.348 ms 0 1000 1000 0 848
v0.1 10 50 90.9 req/s 11.2% 7.4% 35.801 ms 112.547 ms 143.252 ms 0 5000 5000 0 4463
v0.1 10 100 182 req/s 52.7% 32.4% 46.074 ms 213.888 ms 267.354 ms 0 10000 10000 0 8854
v0.1 10 300 545 req/s 32.7% 14.2% 168.674 ms 352.499 ms 374.039 ms 0 30000 30000 0 26699
v0.1 10 500 726 req/s 37.6% 14.3% 220.043 ms 362.461 ms 466.991 ms 0 50000 50000 0 44518
                             
v0.1 30 10 18.2 req/s 0.367% 0.2% 5.121 ms 27.642 ms 41.943 ms 0 1000 1000 0 862
v0.1 30 50 90.9 req/s 46.4% 31.2% 74.649 ms 310.441 ms 432.352 ms 0 5000 5000 0 4834
v0.1 30 100 182 req/s 20.2% 7.1% 136.070 ms 589.793 ms 774.762 ms 0 10000 10000 0 9674
v0.1 30 300 327 req/s 24.3% 8.4% 371.408 ms 1.290 s 1.425 s 0 30000 30000 0 28998
v0.1 30 500 371 req/s 25.7% 8.6% 587.102 ms 1.411 s 1.724 s 0 50000 50000 0 48251
                             
v0.1 50 10 18.8 req/s 0.433% 0.3% 6.152 ms 29.741 ms 46.337 ms 0 1000 1000 0 868
v0.1 50 50 90.9 req/s 17.8% 11.8% 118.167 ms 752.839 ms 1.182 s 0 5000 5000 0 4880
v0.1 50 100 182 req/s 17.8% 6.5% 221.471 ms 1.209 s 1.783 s 0 10000 10000 0 9796
v0.1 50 300 182 req/s 16.7% 5.4% 946.145 ms 2.855 s 3.838 s 0 30000 30000 0 29438
v0.1 50 500 217 req/s 16.5% 5.3% 1.074 ms 2.568 s 3.420 s 0 50000 50000 0 49029

분석

동시성

가설 1. 데이터 정합성 문제(동시성 문제) 는 생각보다 빈번히 일어나지 않을 것이다.

완전 틀렸다.

단일 요청을 제외하고, 모든 상황에서 동시성으로 인한 Lost Update가 일어났다. 이로 인해 실제로 각 유저들이 100개씩의 금을 채굴했음에도 불구하고, 10번의 동시 요청조차 제대로 처리하지 못한 채 UPDATE를 유실해버렸다.

그러나 근소한 차이지만 커넥션 풀 크기에 따른 Lost Update(실제 잔량) 도 더 빈번 하게 발생하는 것 처럼 보이는 현상을 볼 수 있었다.

이는 한 Row에 대한 접근 커넥션 수를 키우게 되면서 따라오는 결과로서 인과관계가 충분한 현상이었으나, 직접 데이터로서 확인하므로써 시스템 내에 정말 다양한 변수가 적용되겠다는 생각이 들었다.

DB Connection Pool Size 증가에 따른 Lost Update

DB Connection Pool Size 증가에 따른 Lost Update

가설 2. 정합성 문제는 초기 잔량 - 채굴 로그 수를 음수로 만들 것이다

다음의 쿼리를 통해서 각 실행마다 테이블을 검증했다.

WITH w_m AS (
    SELECT m.id AS mine_id, COUNT(m.id) * 100 AS initial_remaining, m.remaining_amount, m.created_at
    FROM mine m
             JOIN app_user au ON m.id = au.mine_id
    GROUP BY m.id
)
SELECT wm.mine_id AS "광산_ID"
     , wm.initial_remaining "초기_잔량"
     , SUM(ml.amount) AS "실제_채굴량"
     , wm.initial_remaining - SUM(ml.amount) AS "계산_잔량"
     , wm.remaining_amount AS "실제_잔량"
     , wm.created_at AS "광산_생성일"
FROM w_m wm
         JOIN mining_log ml
              ON wm.mine_id = ml.mine_id
GROUP BY wm.mine_id, wm.created_at, wm.initial_remaining, wm.remaining_amount;

내가 예상한 것은 ‘계산 잔량’ 컬럼이 음수로 되는 것 이었지만, 예상 외로 mine 테이블의 remaining_amount가 update 되지 못하며 Lost Update가 발생했다.

다시 생각해 보면 Lost Update는 하나의 UPDATE가 두 번 수행되는 문제가 아니라, 수행되어야 할 UPDATE가 덮어써져 사라지는 문제이다.

첫번째 요청 → 잔량 : 999

두번째 요청 → 잔량 : 998

세번째 요청 → 잔량 : 997

과 같이 이뤄져야 하는 프로세스가

첫번째 요청 → 잔량 : 999

두번째 요청 → 잔량 : 999

세번째 요청 → 잔량 : 999

와 같이 동일 상태의 값을 여러 스레드가 읽어 가는 문제이다.

따라서 내가 처음 작성한 검증 쿼리는 ‘사용자가 얼마나 많이 채굴했는가’만 확인할 뿐, Lost Update가 발생했는지는 확인하지 못하는 쿼리였다.

Lost Update를 검증하려면 실제로 기록된 채굴량과 광산의 잔량이 서로 일치하는지를 확인해야 한다.

검증 쿼리는 다음과 같이 변경되어야 했다.

WITH w_m AS (
    SELECT m.id AS mine_id, COUNT(m.id) * 100 AS initial_remaining, m.remaining_amount, m.created_at
    FROM mine m
             JOIN app_user au ON m.id = au.mine_id
    GROUP BY m.id
)
SELECT wm.mine_id AS "광산_ID"
     , wm.initial_remaining "초기_잔량"
     , SUM(ml.amount) AS "실제_채굴량"
     , SUM(ml.amount) + wm.remaining_amount AS "계산_잔량" -- 변경된 쿼리
     , wm.remaining_amount AS "실제_잔량"
     , wm.created_at AS "광산_생성일"
FROM w_m wm
         JOIN mining_log ml
              ON wm.mine_id = ml.mine_id
GROUP BY wm.mine_id, wm.created_at, wm.initial_remaining, wm.remaining_amount;

여기서 핵심은 다음 식이다.

실제 채굴량 + 실제 잔량 = 초기 잔량

이 식이 항상 성립해야 데이터 정합성이 유지된 것이다.

정상적인 경우라면

50,000(실제 채굴량) + 0(실제 잔량) = 50,000(초기 잔량) 이어야 했으나

이번 실험에서는 다음과 같은 결과가 나타났다.

Lost Update 정합성 검증 결과

이미 모든 채굴이 완료되어 50,000개의 금이 채굴 로그에 기록되었음에도 불구하고, 광산에는 여전히 49,029개의 금이 남아있었다. 대부분의 감소 연산이 UPDATE 과정에서 유실된 것이다.

가설 3. DB 커넥션 풀의 크기를 키운다면 서버의 성능은 그에 비례하여 상승할 것이다.

실험을 진행하며 한가지 놀라운 사실을 발견했다.

CPU 성능이 충분히 남아 있을 때, DB 커넥션을 더 많이 사용함으로써 더 빠른 응답을 할 수 있을 거라고 예상했으나, 예상과는 달랐다.

아래 그래프는 커넥션 풀에 따른 TPS 를 보여준다.

커넥션 풀 크기에 따른 TPS

커넥션 풀 크기에 따른 TPS

커넥션 풀이 10 일 때 가장 높은 TPS를, 높은 격차를 보이며 30, 50 이 그 뒤를 이었다.

이와 함께 CPU 점유율도 함께 확인했다.

커넥션 풀 크기에 따른 CPU 점유율

커넥션 풀 크기에 따른 CPU 점유율

VU 50 구간에서 일시적인 스파이크가 발생한 것을 제외하면, CPU 역시 커넥션 풀 10 > 30 > 50 순으로 높은 점유율을 보였다.

즉, 커넥션을 더 많이 열어도 CPU는 더 열심히 일하지 않았고, 오히려 TPS와 CPU 사용률이 함께 감소하는 현상이 나타났다.

처음에는 측정 오류를 의심했지만, 모든 실험에서 동일한 경향이 반복되었다.

이를 확인하기 위해 가장 극명한 차이를 보이는 VU 500 일 때의 커넥션 풀을 확인했다.

커넥션 풀 10

커넥션 풀 10

커넥션 풀 30

커넥션 풀 30

커넥션 풀 50

커넥션 풀 50

Hikari 커넥션 풀에서는 그 차이를 알기가 어려웠다. 선형적으로 Pending이 늘어나는 것이 아니었기 때문이다. 따라서 각 상황을 재현하고, DB 상태를 확인할 수 있는 pg_stat_activity 에서 찍어보기로 했다.

pg_stat_activity

pg_stat_activity

먼저 시선이 간 곳은 Lock, ‘LWLock’ 이었다. 이 데이터가 등장하는 컬럼인 wait_event_type과 wait_event에 대해서 좀 더 추려보기로 했다.

커넥션 풀 50 pg_stat_activity

커넥션 풀 50 pg_stat_activity

여기서 Gold-Rush-Lab 의 db에서 확인 할 수 있었던 값은 총 5개

  • IO
  • LWLock
  • Lock
  • Client

위의 내용들을 gpt, 공식문서에서 알아보니 다음과 같은 설명들이 나왔다.

wait_event_type 설명
Client 서버 프로세스가 사용자 애플리케이션과 연결된 소켓(Socket)에서 입출력이 발생하기를 기다리는 상태이다. 즉, PostgreSQL 내부가 아니라 클라이언트의 동작을 기다리는 경우이다.
IO 디스크 읽기/쓰기 등의 I/O 작업이 완료되기를 기다리는 상태이다. wait_event에는 어떤 I/O 작업을 기다리는지가 기록된다.
Lock Heavyweight Lock 획득을 기다리는 상태이다. Heavyweight Lock은 테이블, 인덱스 등 SQL에서 보이는 객체를 보호하는 데 사용되며, 일부 내부 작업(예: Relation 확장)의 상호 배제에도 사용된다. wait_event에는 어떤 Lock을 기다리는지가 기록된다.
LWLock Lightweight Lock 획득을 기다리는 상태이다. 대부분 공유 메모리의 내부 자료구조(Buffer, WAL 등)를 보호하기 위해 사용된다. wait_event에는 어떤 내부 자원을 기다리는지가 기록된다.
Timeout 설정된 타이머 또는 대기 시간이 만료되기를 기다리는 상태이다. wait_event에는 어떤 Timeout을 기다리는지가 기록된다.

또 아래는 그에 할당되어있는 wait_event에 대한 설명이다.

wait_event 설명 주로 발생하는 상황
LockManager PostgreSQL의 Lock Manager 내부 자료구조에 접근하기 위해 Lightweight Lock을 기다리는 상태이다. 여러 Backend가 동시에 Lock 정보를 조회하거나 갱신할 때 발생한다. 많은 트랜잭션이 동시에 Lock을 획득하거나 해제하는 경우
transactionid 다른 트랜잭션이 보유한 Transaction ID Lock(Heavyweight Lock) 이 해제되기를 기다리는 상태이다. 일반적으로 해당 트랜잭션의 Commit 또는 Rollback을 기다린다. 동일한 Row를 여러 트랜잭션이 동시에 UPDATE 또는 DELETE하는 경우
ClientRead 클라이언트(애플리케이션)로부터 다음 요청(SQL 등)이 도착하기를 기다리는 상태이다. PostgreSQL 내부 병목이 아니라 애플리케이션의 입력을 기다리는 정상적인 대기 상태이다. JDBC Connection이 유휴(Idle) 상태이거나 다음 SQL을 기다리는 경우
SLRURead Simple LRU(SLRU) 저장소에서 필요한 페이지를 디스크로부터 읽어오기를 기다리는 상태이다. SLRU는 Transaction Status, MultiXact 등의 메타데이터를 관리하기 위한 내부 저장소이다. 필요한 Transaction Status 또는 MultiXact 정보를 메모리에 올리는 경우
MultiXactMemberSLRU MultiXact Member SLRU 자료구조에 접근하기 위해 Lightweight Lock을 기다리는 상태이다. 여러 Transaction이 동일 Row를 공유 잠금(Shared Lock) 등으로 접근할 때 사용하는 MultiXact 메타데이터를 보호한다. Foreign Key 검사, SELECT ... FOR SHARE, FOR KEY SHARE 등으로 MultiXact 정보에 접근하는 경우

결국, 정리하자면 어느 순간에는 50개의 커넥션 중 45개의 커넥션이 대기하고 있던 것이다. (null 은 wait_event가 없는, 활동중인 커넥션)

  • 17 개의 커넥션은 UPDATE를 하기위해 기다린다.(Lock)
  • 28 개의 커넥션은 트랜잭션의 Lock을 관리하는 LockManager의 처리를 기다린다. (LWLock)

그래서 커넥션 병목은 어떻게 TPS, CPU 점유율에 영향을 준걸까?

원인은 실험 조건에 있었다.

이번 테스트는 모든 요청이 하나의 광산을 수정하는 Hotspot 환경이었다.

커넥션 풀이 커질수록 더 많은 스레드가 동시에 동일한 Row에 접근하게 되고, 동시에 같은 데이터를 읽고 UPDATE를 수행하려고 시도한다.

그러나 PostgreSQL은 하나의 Row에 대해 동시에 여러 UPDATE를 수행할 수 없기 때문에, 결국 대부분의 스레드는 대기하거나 이미 읽어온 오래된 값을 기반으로 UPDATE를 수행하게 된다.

즉, 커넥션을 늘린다고 해서 병렬 처리량이 증가한 것이 아니라,

  • 실제 UPDATE 보다 대기시간이 증가하고
  • 대기시간이 늘어남에 따라 CPU는 기다리는 시간이 많아져,

결과적으로 TPS는 감소하고 CPU 점유율이 낮아지게 되었고 DB Connection Pool을 늘리는 것이 성능 향상으로 이어지지 않았다.

오히려 하나의 Hotspot에 대해서는 필요 이상의 커넥션은 경쟁만 증가시켜 TPS를 떨어뜨릴 수 있다는 점을 확인할 수 있었다.

결론

  1. Lost Update(동시성 문제)는 예상보다 훨씬 쉽게 발생했다.
    단일 요청을 제외한 모든 동시 요청 환경에서 Lost Update가 발생했다. 특히 VU 10 수준에서도 동일한 Row를 여러 트랜잭션이 동시에 조회하고 수정하면서 대부분의 감소 연산이 유실되었다. 이를 통해 동시성 문제는 반드시 대규모 트래픽에서만 발생하는 것이 아니라, 공유 자원에 대한 접근이 집중되는 순간 낮은 동시성에서도 충분히 발생할 수 있다는 점을 확인했다.
  2. 데이터 정합성은 단순히 요청 수나 로그 수만으로 판단할 수 없다. 처음에는 채굴 로그가 초기 잔량을 초과하는지만 확인했지만, Lost Update는 중복 실행이 아니라 수행된 UPDATE가 다른 UPDATE에 의해 덮어써지는 문제였다. 따라서 정합성을 검증하려면 요청 성공 여부뿐 아니라 서로 연관된 데이터가 최종적으로 동일한 상태를 나타내는지까지 검증해야 한다.
  3. DB 커넥션 풀은 hotspot과 같은 상황에서 “크게 잡을수록 성능이 좋아지는 튜닝 값”이 아니다.
    오히려
    • 애플리케이션에서 몇 개의 요청을 대기시킬지
    • DB에 몇 개의 작업을 동시에 전달할지
    • DB 내부에서 어느 정도의 경합을 허용할지 와 같은 시스템의 특성과 비즈니스 상황에 고려하여 설정해야하는 독립변수에 가깝다.
  4. 병목은 제거되는 것이 아니라 다른 계층으로 이동할 수 있다.
    커넥션 풀을 작게 설정하면 요청이 애플리케이션의 HikariCP에서 대기한다. 반대로 풀을 크게 설정하면 더 많은 요청이 PostgreSQL로 전달되지만, DB가 해당 동시성을 처리하지 못하면 Lock과 내부 공유 자원 경합이 증가한다.
  5. CPU 사용률이 낮다는 것은 처리 여유가 있다는 뜻이 아닐 수 있다.
    처음에는 CPU 사용률이 낮으므로 커넥션을 늘리면 더 많은 작업을 처리할 수 있을 것으로 예상했다. 그러나 실제로는 대부분의 DB 세션이 Lock을 기다리고 있었기 때문에 CPU를 적극적으로 사용하지 못한 것이었다.
  6. 동시성 실험은 결과뿐 아니라 측정 조건까지 함께 검증해야 한다.
    TPS, 평균 응답시간, CPU 사용률만으로는 원인을 판단하기 어려웠다. HikariCP의 Active·Pending과 PostgreSQL의 pg_stat_activity, wait_event_type, wait_event를 함께 확인한 뒤에야 성능 저하가 DB 내부 Lock 경합과 연결되어 있다는 사실을 확인할 수 있었다.

댓글남기기