개요

v0.3 버전에서는 API 서버를 증설하고, 낙관적 락에 대한 재시도 로직을 추가하여 TPS, P95, P99 등 유저에게 직접적으로 영향을 끼치는 성능을 개선하고자 하였다.

하지만 API 서버 증설, 스케일 아웃만으로는 근본적인 DB 병목을 효과적으로 개선하지 못했다. 이로 인해 DB에서의 병목을 완화하고 병목을 관리할 수 있는 애플리케이션 레이어로 이동시키기 위해 분산 락(Distributed Lock)을 적용하기로 했다.


왜 Redis를 선택했을까?

막상 v0.4까지 와서 적용하려니, “왜 Redis여야만 하는지”에 대한 의문이 생겼다. 그래서 분산 락을 구현할 수 있는 수단을 AI를 활용해 검색했더니, 사용 사례까지 친절하게 알려줬다.

방식 특징 사용 사례
Redis 빠르고 구현이 쉬움 가장 많이 사용
ZooKeeper 강한 일관성, 분산 코디네이션 Hadoop, Kafka(예전)
etcd Kubernetes가 사용하는 분산 KV 저장소 Kubernetes, Cloud Native
Consul 서비스 디스커버리 + 분산 락 HashiCorp 생태계
DB Lock DB 자체의 Lock 사용 규모가 작거나 DB 중심 시스템
Hazelcast / Ignite In-memory Data Grid Java 기반 클러스터

우선 이미 실험 과정에 포함되었던, 그리고 v0.4의 취지에 맞지 않는 DB Lock과 Gold-Rush-Lab의 상황에 맞지 않는 etcd는 바로 제외했다. Consul은 MSA에서 동적 스케일 아웃 시 서비스를 등록하는 서비스 디스커버리가 주된 기능이고, 분산 락은 KV 스토어가 존재해서 지원하는 부가적인 기능과 같은 인상을 주었다. Hazelcast는 같은 느낌으로 분산 자료구조, Ignite는 분산 데이터베이스가 주된 기능이되, 분산 락을 지원한다고 한다.

사실상 etcd와 DB Lock을 제외한 모두가 후보군으로 보인다. (Redis와 ZooKeeper에 대해선 이미 알고 있다.) 모두 구현할 수는 없다. Gold-Rush-Lab은 DB와 같은 특정 노드를 제외하고, 메모리도 CPU도 넉넉하지 않아 EC2의 medium급에 해당하는 인스턴스만 사용할 수 있다. 따라서 ‘가용 자원’을 우선적인 기준으로 다시 소거해 나가고자 했다.

그런 의미에서 Consul은 애초에 활용조차 불가능했다.

Consul 공식 문서의 서버 권장 사양

Consul 공식 문서의 서버 권장 사양

Hazelcast도 마찬가지였다.

Hazelcast 공식 문서의 서버 권장 사양

Hazelcast 공식 문서의 서버 권장 사양

Ignite는 공식 문서에서 권장 하드웨어 정보를 찾을 수는 없었지만, 감사하게도 이미 차이를 확인해본 분의 게시글을 통해 소거할 수 있었다. 게시글의 내용을 토대로 Ignite는 위에서 언급한 것과 같이 분산 데이터베이스가 주된 기능이고, 분산 락만 적용하고자 하는 Gold-Rush-Lab에는 다소 무거울 것이라는 결론을 내려 제외하기로 결정했다.

결국 Redis와 ZooKeeper가 최종 후보로 남았다.

두 기술 모두 분산 락을 구현할 수 있었지만, Gold-Rush-Lab에서 분산 락이 필요한 구간은 PostgreSQL에서 동일한 광산 데이터를 조회하고 수정한 뒤 트랜잭션을 커밋하는 짧은 구간뿐이었다.

반면 ZooKeeper는 분산 락 외에도 여러 서버의 역할을 조정하거나(Leader Election), 서버 간 설정을 공유하고(Configuration Management), 장애가 발생한 서버를 감지하는 등 여러 서버가 서로 협력하며 동작하도록 관리하는 기능까지 제공하는 분산 코디네이션 시스템이다.

Gold-Rush-Lab에서는 이러한 기능은 오버엔지니어링이며, Redis만으로도 동일한 데이터에 대한 동시 접근을 효과적으로 제어할 수 있었다. 또한 개발과 운영 복잡도 역시 상대적으로 낮았다.

따라서 이번 실험에서는 프로젝트의 요구사항과 운영 환경에 가장 적합한 선택으로 Redis 기반의 분산 락을 적용하기로 결정했다.


가설

가설 1. DB의 병목(lock wait)이 애플리케이션으로 전이되어, DB 자체의 병목은 해소될 것이다.

  • Redis를 통해 DB의 병목을 앞단인 애플리케이션 레이어로 전이시켰다. 그로 인해 한 번에 발생하는 트랜잭션의 수가 줄어들며 자연스럽게 DB 내부의 병목도 줄어들 것이다.

가설 2. Lost Update는 일어나지 않고, 모든 유저들은 성공적으로 100개의 금을 채굴할 것이다.

  • Redis 분산 락은 하나의 광산에 대해 동시에 하나의 요청만 트랜잭션을 수행하도록 보장한다. 이는 동일한 자원을 여러 사용자가 동시에 수정하더라도 요청이 직렬화되어 Lost Update가 발생하지 않으며, 모든 사용자가 정상적으로 100개의 금을 채굴할 수 있을 것이다.

가설 3. TPS, P95, P99는 비관적 락과 비슷한 수치를 보일 것이다.

  • Redis 분산 락 역시 하나의 광산에 대해 요청을 순차적으로 처리하므로, Hot Spot 환경에서는 동시에 하나의 요청만 실제 비즈니스 로직을 수행하게 된다. 따라서 처리 방식이 비관적 락과 유사해져 TPS와 P95, P99 또한 비슷한 수준을 보일 것으로 예상하였다.

가설 4. 애플리케이션의 대기 스레드가 늘어남에 따라 가용 스레드 또한 줄어들 것이다.

  • Redis 분산 락은 락을 획득하지 못한 요청이 애플리케이션에서 대기하게 된다. 대기 중인 요청은 스레드를 점유하므로 동시 요청이 증가할수록 대기 스레드 수가 증가하고, 반대로 즉시 요청을 처리할 수 있는 가용 스레드 수는 감소할 것이다.

실험 방법

  • 분산 락 구현
@Service
@RequiredArgsConstructor
public class DistributedMiningService {

    private final RedissonClient redissonClient;
    private final MineService mineService;

    public void mine(UUID userSessionId, Long mineId) {

        RLock lock = redissonClient.getLock(
                "gold-rush:mine:" + mineId);

        boolean acquired = false;

        try {
            // 최대 3초간 락 획득 대기
            acquired = lock.tryLock(3, TimeUnit.SECONDS);

            if (!acquired) {
                throw new LockAcquireException();
            }

            mineService.mine(userSessionId, mineId);

        } catch (InterruptedException e) {

            Thread.currentThread().interrupt();
            throw new RuntimeException(e);

        } finally {
            // 현재 권한이 있는 스레드라면 락 해제
            if (acquired && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}
@RestController
@RequiredArgsConstructor
public class MineController {

    private final UserService userService;
    private final MineService mineService;
    private final DistributedMiningService distributedMiningService;

    ...

    @PostMapping("/mine")
    public ApiResponse<MineRequestDto> mine(
            @RequestParam(name = "sessionId") UUID sessionId
            ) {
//        mineService.mine(sessionId, 1L); // 기존 mine 함수 비활성화
        distributedMiningService.mine(sessionId, 1L); // 분산 락 적용 mine 활성화
        UserEntity foundUser = userService.findBySessionId(sessionId);

        Long totalMinedGold = foundUser.getTotalMinedGold();
        Long remainingAmount = foundUser.getMine().getRemainingAmount();

        return ApiResponse.success(new MineRequestDto(1L, totalMinedGold, remainingAmount));
    }
}
  • 지표 추가 - Lock Wait
    • 자동화 스크립트에서 DB 내부의 Lock Wait를 측정하기 위해 pg_lockspg_stat_activity를 조회하는 쿼리를 추가하였다.

        SELECT
              l.pid,
              a.backend_xid,
              l.virtualtransaction,
              a.xact_start,
              l.waitstart,
              clock_timestamp() - l.waitstart AS lock_wait_duration,
              l.locktype,
              l.mode,
              l.relation::regclass AS relation,
              l.page,
              l.tuple,
              l.transactionid,
              pg_blocking_pids(l.pid) AS blocking_pids,
              a.query
          FROM pg_locks l
          JOIN pg_stat_activity a
            ON a.pid = l.pid
          WHERE l.granted = false
            AND l.waitstart IS NOT NULL
          ORDER BY l.waitstart;
      
    • 해당 쿼리를 1초마다 폴링(Polling) 하여 락 대기 시간(lock_wait_duration), 락 종류(locktype), 락 모드(mode), 대기 중인 Relation, Blocking Session 등을 수집하였다.
    • 이를 통해 TPS와 응답 시간뿐만 아니라 DB 내부에서 실제로 얼마나 오랫동안 Lock을 기다렸는지를 함께 분석할 수 있도록 하였다.
    • 이를 v0.3에 추가하여 재측정한 결과를 바탕으로 비교한다.

실험 환경

Gold-Rush-Lab v0.4 분산 락 실험 환경

Gold-Rush-Lab v0.4 분산 락 실험 환경

  • Host Machine
    • OS: Windows 11
    • CPU: AMD Ryzen 5 5600X
    • Memory: 32 GB
  • Virtualization
    • Hyper-V
  • Network
    • External Virtual Switch, 모든 VM은 동일한 Hyper-V Virtual Switch를 사용

  • APP VM-01, APP VM-02
    • OS: Ubuntu Server 24.04
    • vCPU: 2
    • Memory: 2 GB
  • DB VM
    • OS: Ubuntu Server 24.04
    • vCPU: 2
    • Memory: 2 GB
  • LB(LoadBalancer) VM
    • OS: Ubuntu Server 24.04
    • vCPU: 2
    • Memory: 2 GB
  • Redis VM
    • OS: Ubuntu Server 24.04
    • vCPU: 2
    • Memory: 2 GB
  • Monitoring VM
    • OS: Ubuntu Server 24.04
    • vCPU: 2
    • Memory: 2 GB

  • Load Generator
    • OS: macOS Tahoe 26
    • CPU: M1
    • Memory: 8GB

결과

⚠️ Lock Wait 측정 시 주의: Lock Wait는 PostgreSQL의 pg_lockspg_stat_activity를 1초 간격으로 Polling하여 수집한 보조 지표이다. Polling 기반 측정 특성상 모든 Lock Wait를 포착하지 못할 수 있으므로, 절대적인 수치보다는 병목의 발생 여부와 경향을 분석하기 위한 지표로 활용하였다.**

Scale-Out : Distributed Lock 결과 기록 표(스압)
Record Type Target Type Target Version Hikari Max Pool Size VU Run TPS (req/s) System CPU Peak (%) Process CPU Peak (%) JVM Heap Peak (%) Tomcat Available Threads Min Tomcat Available Threads Avg Hikari Active Peak Avg Latency (ms) P95 (ms) P99 (ms) Error Rate (%) DB Observed Lock Waits DB Lock Wait Total (ms) DB Lock Wait Avg (ms) DB Lock Wait Max (ms) DB Lock Wait Poll Interval (s) Redis Lock Wait Samples Redis Lock Wait Total (ms) Redis Lock Wait Avg (ms) Redis Lock Wait Max (ms) Redis Lock Timeout Count 초기 잔량 사용자 총 채굴량 Mining Log 총 채굴량 실제 잔량 정합성
WARMUP LOAD_BALANCER http://192.168.0.47/api v0.4 20 10 1 63.694 req/s           0 140.984 662.981 832.294 0 0 0 0 0 1 108.55 12699.375 116.991 750.592 0 100 100 100 0
WARMUP BACKEND 192.168.0.41:8080 v0.4   10 1 12.192 req/s 3.005 2.538 10.255 194 198.2                                        
WARMUP BACKEND 192.168.0.46:8080 v0.4   10 1 0.000 req/s 1.5 1.508 13.405 193 198.3                                        
RUN LOAD_BALANCER http://192.168.0.47/api v0.4 20 10 1 157.654 req/s           0 60.565 178.841 270.668 0 0 0 0 0 1 1076.923 55103.501 51.168 750.592 0 1000 1000 1000 0
RUN BACKEND 192.168.0.41:8080 v0.4   10 1 84.891 req/s 49.091 39.72 13.433 190 197.214                                        
RUN BACKEND 192.168.0.46:8080 v0.4   10 1 84.891 req/s 54.735 48.095 12.766 189 196.5                                        
RUN LOAD_BALANCER http://192.168.0.47/api v0.4 20 10 2 200.965 req/s           1 47.317 136.205 229.933 0 0 0 0 0 1 1083.333 43426.704 40.086 750.592 0 1000 1000 1000 0
RUN BACKEND 192.168.0.41:8080 v0.4   10 2 108.856 req/s 47.685 38.785 12.999 193 197.769                                        
RUN BACKEND 192.168.0.46:8080 v0.4   10 2 108.856 req/s 46.181 37.736 14.153 193 197.385                                        
RUN LOAD_BALANCER http://192.168.0.47/api v0.4 20 10 3 222.519 req/s           1 42.344 123.754 196.244 0 0 0 0 0 1 1083.333 38996.751 35.997 426.456 0 1000 1000 1000 0
RUN BACKEND 192.168.0.41:8080 v0.4   10 3 120.531 req/s 43.683 34.112 12.752 190 197.077                                        
RUN BACKEND 192.168.0.46:8080 v0.4   10 3 120.531 req/s 36.738 25 14.116 193 198                                        
RUN LOAD_BALANCER http://192.168.0.47/api v0.4 20 10 4 233.481 req/s           1 41.169 118.427 177.375 0 0 0 0 0 1 1090.909 38093.429 34.919 391.093 0 1000 1000 1000 0
RUN BACKEND 192.168.0.41:8080 v0.4   10 4 127.353 req/s 51.451 43.128 14.968 190 197.5                                        
RUN BACKEND 192.168.0.46:8080 v0.4   10 4 127.353 req/s 59.016 50.943 15.041 196 198.417                                        
RUN LOAD_BALANCER http://192.168.0.47/api v0.4 20 10 5 252.461 req/s           0 37.445 99.979 144.396 0 0 0 0 0 1 1090.909 34756.179 31.86 346.578 0 1000 1000 1000 0
RUN BACKEND 192.168.0.41:8080 v0.4   10 5 137.706 req/s 3.96 2.5 13.337 191 196.667                                        
RUN BACKEND 192.168.0.46:8080 v0.4   10 5 137.706 req/s 19.8 14.01 14.488 193 198.083                                        
AVERAGE LOAD_BALANCER http://192.168.0.47/api v0.4 20 10 AVERAGE 213.416 req/s           0.6 45.768 131.441 203.723 0 0 0 0 0 1 1085.082 42075.313 38.806 533.062 0 1000 1000 1000 0
AVERAGE BACKEND 192.168.0.41:8080 v0.4   10 AVERAGE 115.867 req/s 39.174 31.649 13.498 190.8 197.245                                        
AVERAGE BACKEND 192.168.0.46:8080 v0.4   10 AVERAGE 115.867 req/s 43.294 35.157 14.113 192.8 197.677                                        
RUN LOAD_BALANCER http://192.168.0.47/api v0.4 20 50 1 265.520 req/s           1 177.42 546.744 925.057 0 0 0 0 0 1 5192.308 891979.778 171.789 2099.804 0 5000 5000 5000 0
RUN BACKEND 192.168.0.41:8080 v0.4   50 1 137.866 req/s 39.066 28.571 16.363 151 178.815                                        
RUN BACKEND 192.168.0.46:8080 v0.4   50 1 137.866 req/s 36.817 24.091 15.579 164 188.556                                        
RUN LOAD_BALANCER http://192.168.0.47/api v0.4 20 50 2 284.139 req/s           2 163.309 556.542 918.643 0 0 0 0 0 1 5200 823829.694 158.429 2099.804 0 5000 5000 5000 0
RUN BACKEND 192.168.0.41:8080 v0.4   50 2 147.752 req/s 41.092 31.019 17.513 150 173.308                                        
RUN BACKEND 192.168.0.46:8080 v0.4   50 2 147.752 req/s 46.138 35.484 16.219 180 195.231                                        
RUN LOAD_BALANCER http://192.168.0.47/api v0.4 20 50 3 295.735 req/s           1 158.505 583.258 998.158 0 0 0 0 0 1 5208.333 801520.686 153.892 2099.804 0 5000 5000 5000 0
RUN BACKEND 192.168.0.41:8080 v0.4   50 3 154.029 req/s 27.193 15.596 16.182 149 170.84                                        
RUN BACKEND 192.168.0.46:8080 v0.4   50 3 154.029 req/s 27.837 15.455 16.059 180 195.48                                        
RUN LOAD_BALANCER http://192.168.0.47/api v0.4 20 50 4 301.550 req/s           1 156.061 578.926 885.703 0 0 0 0 0 1 5208.333 788893.342 151.468 1676.817 0 5000 5000 5000 0
RUN BACKEND 192.168.0.41:8080 v0.4   50 4 157.057 req/s 25.339 13.953 13.838 149 172.4                                        
RUN BACKEND 192.168.0.46:8080 v0.4   50 4 157.057 req/s 26.241 12.273 16.428 170 194.12                                        
RUN LOAD_BALANCER http://192.168.0.47/api v0.4 20 50 5 300.643 req/s           1 155.813 552.36 834.331 0 0 0 0 0 1 5200 786753.006 151.299 1837.93 0 5000 5000 5000 0
RUN BACKEND 192.168.0.41:8080 v0.4   50 5 156.335 req/s 24.885 12.558 17.104 150 173.385                                        
RUN BACKEND 192.168.0.46:8080 v0.4   50 5 156.335 req/s 28.25 14.953 16.654 176 195.731                                        
AVERAGE LOAD_BALANCER http://192.168.0.47/api v0.4 20 50 AVERAGE 289.518 req/s           1.2 162.222 563.566 912.378 0 0 0 0 0 1 5201.795 818595.301 157.375 1962.832 0 5000 5000 5000 0
AVERAGE BACKEND 192.168.0.41:8080 v0.4   50 AVERAGE 150.608 req/s 31.515 20.34 16.2 149.8 173.749                                        
AVERAGE BACKEND 192.168.0.46:8080 v0.4   50 AVERAGE 150.608 req/s 33.057 20.451 16.188 174 193.823                                        
RUN LOAD_BALANCER http://192.168.0.47/api v0.4 20 100 1 299.043 req/s           2 313.694 978.89 1641.81 0 0 0 0 0 1 10243.902 3166135.617 309.075 3229.33 0 10000 10000 10000 0
RUN BACKEND 192.168.0.41:8080 v0.4   100 1 153.168 req/s 35.332 24.651 18.188 100 151.286                                        
RUN BACKEND 192.168.0.46:8080 v0.4   100 1 153.168 req/s 25.426 12.963 16.595 103 171.024                                        
RUN LOAD_BALANCER http://192.168.0.47/api v0.4 20 100 2 298.374 req/s           1 312.247 1152.257 1893.436 0 0 0 0 0 1 10243.902 3133865.395 305.925 3481.192 0 10000 10000 10000 0
RUN BACKEND 192.168.0.41:8080 v0.4   100 2 152.826 req/s 25.331 13.023 19.057 99 134.905                                        
RUN BACKEND 192.168.0.46:8080 v0.4   100 2 152.826 req/s 26.939 13.303 18.815 142 188.048                                        
RUN LOAD_BALANCER http://192.168.0.47/api v0.4 20 100 3 300.933 req/s           2 309.926 996.736 1638.3 0 0 0 0 0 1 10243.902 3128333.4 305.385 3765.873 0 10000 10000 10000 0
RUN BACKEND 192.168.0.41:8080 v0.4   100 3 154.136 req/s 24.654 13.084 19.949 100 140.048                                        
RUN BACKEND 192.168.0.46:8080 v0.4   100 3 154.136 req/s 25.926 13.889 19.191 122 184.857                                        
RUN LOAD_BALANCER http://192.168.0.47/api v0.4 20 100 4 301.405 req/s           2 312.367 955.056 1515.567 0 0 0 0 0 1 10250 3154849.727 307.79 3765.873 0 10000 10000 10000 0
RUN BACKEND 192.168.0.41:8080 v0.4   100 4 154.470 req/s 24.851 12.617 20.346 119 173.854                                        
RUN BACKEND 192.168.0.46:8080 v0.4   100 4 154.470 req/s 24.967 13.488 19.433 101 148.415                                        
RUN LOAD_BALANCER http://192.168.0.47/api v0.4 20 100 5 301.178 req/s           1 310.824 1173.146 1882.615 0 0 0 0 0 1 10243.902 3137078.229 306.239 3873.55 0 10000 10000 10000 0
RUN BACKEND 192.168.0.41:8080 v0.4   100 5 154.262 req/s 25.179 12.903 20.569 99 130.571                                        
RUN BACKEND 192.168.0.46:8080 v0.4   100 5 154.262 req/s 28.176 15.068 20.269 138 192.619                                        
AVERAGE LOAD_BALANCER http://192.168.0.47/api v0.4 20 100 AVERAGE 300.186 req/s           1.6 311.812 1051.217 1714.346 0 0 0 0 0 1 10245.122 3144052.474 306.883 3623.164 0 10000 10000 10000 0
AVERAGE BACKEND 192.168.0.41:8080 v0.4   100 AVERAGE 153.772 req/s 27.07 15.256 19.622 103.4 146.133                                        
AVERAGE BACKEND 192.168.0.46:8080 v0.4   100 AVERAGE 153.772 req/s 26.287 13.742 18.861 121.2 176.992                                        
RUN LOAD_BALANCER http://192.168.0.47/api v0.4 20 300 1 297.359 req/s           2 948.632 3184.498 4656.585 0 0 0 0 0 1 30275.229 21660211.78 715.443 8652.835 0 30000 30000 30000 0
RUN BACKEND 192.168.0.41:8080 v0.4   300 1 150.044 req/s 26.708 14.865 24.888 0 21.709                                        
RUN BACKEND 192.168.0.46:8080 v0.4   300 1 150.044 req/s 25.452 13.615 20.983 60 182.2                                        
RUN LOAD_BALANCER http://192.168.0.47/api v0.4 20 300 2 292.597 req/s           2 960.714 3324.88 4755.627 0 0 0 0 0 1 30272.727 20803519.89 687.203 8852.417 0 30000 30000 30000 0
RUN BACKEND 192.168.0.41:8080 v0.4   300 2 147.629 req/s 29.83 16.981 27.682 0 20.892                                        
RUN BACKEND 192.168.0.46:8080 v0.4   300 2 147.629 req/s 31.258 18.265 21.977 70 190.333                                        
RUN LOAD_BALANCER http://192.168.0.47/api v0.4 20 300 3 291.783 req/s           2 969.064 3243.116 4754.233 0 0 0 0 0 1 30270.27 21794924.79 720.011 9609.087 0 30000 30000 30000 0
RUN BACKEND 192.168.0.41:8080 v0.4   300 3 147.206 req/s 26.096 13.441 29.318 0 24.402                                        
RUN BACKEND 192.168.0.46:8080 v0.4   300 3 147.206 req/s 26.147 14.884 23.393 41 180.446                                        
RUN LOAD_BALANCER http://192.168.0.47/api v0.4 20 300 4 295.264 req/s           2 954.394 3009.419 4612.313 0 0 0 0 0 1 30275.229 22890226.26 756.071 9609.087 0 30000 30000 30000 0
RUN BACKEND 192.168.0.41:8080 v0.4   300 4 148.986 req/s 26.699 13.158 30.199 0 39.836                                        
RUN BACKEND 192.168.0.46:8080 v0.4   300 4 148.986 req/s 29.179 15.982 25.411 0 151.718                                        
RUN LOAD_BALANCER http://192.168.0.47/api v0.4 20 300 5 295.960 req/s           2 949.961 3278.374 4696.975 0 0 0 0 0 1 30275.229 20757765.37 685.635 11450.975 0 30000 30000 30000 0
RUN BACKEND 192.168.0.41:8080 v0.4   300 5 149.338 req/s 26.022 13.757 31.82 0 21.673                                        
RUN BACKEND 192.168.0.46:8080 v0.4   300 5 149.338 req/s 26.306 13.084 26.731 78 189.973                                        
AVERAGE LOAD_BALANCER http://192.168.0.47/api v0.4 20 300 AVERAGE 294.593 req/s           2 956.553 3208.057 4695.147 0 0 0 0 0 1 30273.737 21581329.62 712.873 9634.88 0 30000 30000 30000 0
AVERAGE BACKEND 192.168.0.41:8080 v0.4   300 AVERAGE 148.641 req/s 27.071 14.44 28.781 0 25.702                                        
AVERAGE BACKEND 192.168.0.46:8080 v0.4   300 AVERAGE 148.641 req/s 27.669 15.166 23.699 49.8 178.934                                        
RUN LOAD_BALANCER http://192.168.0.47/api v0.4 20 500 1 295.170 req/s           2 1596.51 4593.353 6043.085 0 0 0 0 0 1 50279.33 35776160.78 711.548 11450.975 0 50000 50000 50000 0
RUN BACKEND 192.168.0.41:8080 v0.4   500 1 148.409 req/s 28.103 16.216 30.966 0 99.654                                        
RUN BACKEND 192.168.0.46:8080 v0.4   500 1 148.409 req/s 27.077 14.352 28.781 0 185.511                                        
RUN LOAD_BALANCER http://192.168.0.47/api v0.4 20 500 2 294.048 req/s           1 1605.846 4654.524 6055.355 0 0 0 0 0 1 50280.899 35062504.55 697.332 11451.658 0 50000 50000 50000 0
RUN BACKEND 192.168.0.41:8080 v0.4   500 2 147.850 req/s 25.965 13.115 30.23 0 95.773                                        
RUN BACKEND 192.168.0.46:8080 v0.4   500 2 147.850 req/s 25.997 13.242 27.655 0 191.078                                        
RUN LOAD_BALANCER http://192.168.0.47/api v0.4 20 500 3 293.522 req/s           2 1606.983 4602.889 6066.439 0 0 0 0 0 1 50279.33 36520197.35 726.346 11451.658 0 50000 50000 50000 0
RUN BACKEND 192.168.0.41:8080 v0.4   500 3 147.581 req/s 26.374 13.761 31.514 0 88.111                                        
RUN BACKEND 192.168.0.46:8080 v0.4   500 3 147.581 req/s 27.218 13.889 27.655 0 182.794                                        
RUN LOAD_BALANCER http://192.168.0.47/api v0.4 20 500 4 294.891 req/s           2 1603.981 4654.92 6053.922 0 0 0 0 0 1 50280.899 35746890 710.944 10872.922 0 50000 50000 50000 0
RUN BACKEND 192.168.0.41:8080 v0.4   500 4 148.274 req/s 26.683 12.844 36.378 0 86.333                                        
RUN BACKEND 192.168.0.46:8080 v0.4   500 4 148.274 req/s 26.072 12.963 29.778 0 186.408                                        
RUN LOAD_BALANCER http://192.168.0.47/api v0.4 20 500 5 298.315 req/s           2 1579.832 4579.682 6044.282 0 0 0 0 0 1 50284.091 35699509.72 709.956 11570.5 0 50000 50000 50000 0
RUN BACKEND 192.168.0.41:8080 v0.4   500 5 150.078 req/s 26.866 13.5 33.841 0 88                                        
RUN BACKEND 192.168.0.46:8080 v0.4   500 5 150.005 req/s 32.147 18.062 31.531 0 187.486                                        
AVERAGE LOAD_BALANCER http://192.168.0.47/api v0.4 20 500 AVERAGE 295.189 req/s           1.8 1598.63 4617.073 6052.617 0 0 0 0 0 1 50280.91 35761052.48 711.225 11359.543 0 50000 50000 50000 0
AVERAGE BACKEND 192.168.0.41:8080 v0.4   500 AVERAGE 148.438 req/s 26.798 13.887 32.586 0 91.574                                        
AVERAGE BACKEND 192.168.0.46:8080 v0.4   500 AVERAGE 148.424 req/s 27.702 14.501 29.08 0 186.655                                        
RUN BACKEND 192.168.0.41:8080 v0.4   500 2 147.85 req/s 25.965 13.115 30.23 0 95.773                                        
RUN BACKEND 192.168.0.46:8080 v0.4   500 2 147.85 req/s 25.997 13.242 27.655 0 191.078                                        
RUN LOAD_BALANCER http://192.168.0.47/api v0.4 20 500 3 293.522 req/s           2 1606.983 4602.889 6066.439 0 0 0 0 0 1 50279.33 36520197.35 726.346 11451.658 0 50000 50000 50000 0
RUN BACKEND 192.168.0.41:8080 v0.4   500 3 147.581 req/s 26.374 13.761 31.514 0 88.111                                        
RUN BACKEND 192.168.0.46:8080 v0.4   500 3 147.581 req/s 27.218 13.889 27.655 0 182.794                                        
RUN LOAD_BALANCER http://192.168.0.47/api v0.4 20 500 4 294.891 req/s           2 1603.981 4654.92 6053.922 0 0 0 0 0 1 50280.899 35746890 710.944 10872.922 0 50000 50000 50000 0
RUN BACKEND 192.168.0.41:8080 v0.4   500 4 148.274 req/s 26.683 12.844 36.378 0 86.333                                        
RUN BACKEND 192.168.0.46:8080 v0.4   500 4 148.274 req/s 26.072 12.963 29.778 0 186.408                                        
RUN LOAD_BALANCER http://192.168.0.47/api v0.4 20 500 5 298.315 req/s           2 1579.832 4579.682 6044.282 0 0 0 0 0 1 50284.091 35699509.72 709.956 11570.5 0 50000 50000 50000 0
RUN BACKEND 192.168.0.41:8080 v0.4   500 5 150.078 req/s 26.866 13.5 33.841 0 88                                        
RUN BACKEND 192.168.0.46:8080 v0.4   500 5 150.005 req/s 32.147 18.062 31.531 0 187.486                                        
AVERAGE LOAD_BALANCER http://192.168.0.47/api v0.4 20 500 AVERAGE 295.189 req/s           1.8 1598.63 4617.073 6052.617 0 0 0 0 0 1 50280.91 35761052.48 711.225 11359.543 0 50000 50000 50000 0
AVERAGE BACKEND 192.168.0.41:8080 v0.4   500 AVERAGE 148.438 req/s 26.798 13.887 32.586 0 91.574                                        
AVERAGE BACKEND 192.168.0.46:8080 v0.4   500 AVERAGE 148.424 req/s 27.702 14.501 29.08 0 186.655                                        

분석

가설 1. DB의 병목(lock wait)이 애플리케이션으로 전이되어, DB 자체의 병목은 해소될 것이다.

버전별 DB Lock Wait 평균

버전별 DB Lock Wait 평균

버전별 DB Lock Wait 최댓값

버전별 DB Lock Wait 최댓값

v0.4의 분산 락은 낙관적 락과 비관적 락에서 발생하던 DB 내부 병목을 애플리케이션으로 이동시킨 것으로 확인되었다. 이는 Redis가 성공적으로 채굴 함수의 호출을 직렬화하여, DB 내부에서 하나의 Row에 대한 경합을 제거한 것으로 보인다.

가설 2. Lost Update는 일어나지 않고, 모든 유저들은 성공적으로 100개의 금을 채굴할 것이다.

버전별 실제 광산 잔량

버전별 실제 광산 잔량

Lost Update는 발생하지 않았으며 모든 유저가 100개의 금을 성공적으로 채굴했다. (타임아웃 60초) 이는 위에서 언급한 것과 같이 Redis 분산 락이 적절히 작동하여 하나의 Row에 대해 하나의 트랜잭션을 발생시켰다는 것을 확인할 수 있는 점이었다.

하지만 이는 타임아웃을 60초로 설정한 경우이며, 아래 그래프는 그 설정값에 따라 실패율이 달라질 수 있었음을 보여준다.

Redis Lock Wait 최댓값

Redis Lock Wait 최댓값: 일부 요청은 약 12초 동안 대기

Redis Lock Wait 평균

Redis Lock Wait 평균: VU 300 이상에서 약 0.7초의 평균 대기 시간

가설 3. 분산 락의 TPS, P95, P99는 비관적 락과 비슷한 수치를 보일 것이다.

TPS, P95, P99는 약 2배에서 최대 3배의 차이로 분산 락의 성능이 더 낮았다.

분산 락은 mine() 함수를 실행시키기 위해 락을 획득한 이후부터 DB에 접근할 수 있기 때문에, 같은 광산에 대한 요청이 애플리케이션 레이어에서 직렬화되었다. 버전별 DB Lock Wait Max 그래프와 Redis Lock Wait Max 이미지를 통해 v0.4의 DB Lock Wait는 사라졌지만 Redis Lock Wait이라는 새로운 병목으로 전환되었다.

v0.3 비관적 락과 v0.4 분산 락의 TPS 비교

분산 락의 처리량은 비관적 락보다 약 2배 낮게 측정

v0.3 비관적 락과 v0.4 분산 락의 P95 비교

분산 락의 P95는 비관적 락보다 2배 이상 느리게 측정

v0.3 비관적 락과 v0.4 분산 락의 P99 비교

분산 락의 P99는 비관적 락보다 약 3배 느리게 측정

분산 락은 DB의 경합을 효과적으로 감소시켰지만, 병목 자체는 Redis로 전이됐을 뿐이었다. 따라서 “핫스팟 환경에서 성능을 개선하기 위해 분산 락을 도입했다”는 적절한 트레이드오프가 아님을 확인할 수 있었다.

가설 4. 애플리케이션의 대기 스레드가 늘어남에 따라 가용 스레드 또한 줄어들 것이다.

가설과 달리, 분산 락(v0.4)은 평균적으로 가장 많은 Tomcat 가용 스레드를 유지했다.

이는 분산 락이 Redis에서 동일한 자원에 대한 접근을 먼저 직렬화하면서, DB까지 동시에 도달하는 요청 수 자체를 줄였기 때문으로 판단된다. 그 결과 애플리케이션 내부에서 동시에 비즈니스 로직을 수행하는 요청 수가 감소했고, 스레드 풀 사용량 또한 상대적으로 낮게 유지되었다.

락 전략별 Tomcat 가용 스레드 최솟값

VU 300 기준 가용 스레드 최솟값: 분산 락 > 낙관적 락 > 비관적 락

락 전략별 Tomcat 가용 스레드 평균값

VU 500 기준 가용 스레드 평균값: 분산 락 ≥ 비관적 락 > 낙관적 락

반면 낙관적 락과 비관적 락은 더 많은 요청이 동시에 애플리케이션 내부에서 실행되며 DB까지 진입했기 때문에, 평균 가용 스레드가 더 적게 나타났다.

다만 가용 스레드를 가장 많이 유지했음에도 불구하고, 분산 락의 TPS는 비관적 락 대비 약 절반 수준으로 가장 낮았다. 이는 매 요청마다 Redis와의 네트워크 왕복(락 획득, 해제)이 추가되면서 발생하는 오버헤드 때문으로 보인다.

핫스팟 환경에서 이 네트워크 비용이 누적되어 전체 처리 시간을 크게 증가시켰다고 판단되며, 결과적으로 스레드 효율성은 높았지만 실제 처리량(TPS)과 응답 시간(P95, P99)은 오히려 저하되는 결과를 가져왔다.


결론

1. 락 전략은 비즈니스보다 앞서지 않는다.

이번 실험에서 적용한 낙관적 락, 비관적 락, 분산 락은 모두 서로 다른 장단점을 가지고 있었다.

낙관적 락은 가장 높은 TPS와 빠른 응답 속도를 보였지만, 재시도 로직이 없다면 높은 실패율을 감수해야 했다. 반대로 비관적 락과 분산 락은 모든 요청의 정합성을 보장했지만 처리량과 응답 시간에서는 큰 손해를 감수해야 했다.

결국 어떤 락이 가장 좋은가를 찾는 것이 아니라, 서비스가 어떤 비즈니스 요구사항을 가지고 있는가가 먼저 결정되어야 한다는 점을 확인할 수 있었다.

Gold-Rush-Lab에서는 모든 사용자가 반드시 100개의 금을 획득해야 한다는 요구사항이 존재했기 때문에, 단순히 TPS가 높다는 이유만으로 낙관적 락을 선택할 수는 없었다.

결론적으로 동시성 제어 전략은 성능을 위해 존재하는 것이 아니라 비즈니스 요구사항을 만족시키기 위한 수단이라는 점을 확인할 수 있었다.

2. 미들웨어는 문제를 마법처럼 해결해주지 않는다.

Redis를 적용한 이후 DB Lock Wait는 사라졌다. 하지만 TPS와 P95, P99는 오히려 악화되었고, Redis Lock Wait라는 새로운 대기 시간이 발생하였다.

Redis는 병목을 제거한 것이 아니라 DB에서 발생하던 병목을 Redis 앞단으로 이동시킨 것이었다. 이번 실험을 통해 특정 기술을 도입한다고 해서 병목이 자동으로 해결되는 것은 아니라는 점을 확인할 수 있었다.

미들웨어는 시스템의 병목을 없애는 것이 아니라 보다 관리하기 쉬운 위치로 이동시키는 역할을 수행할 뿐이며, 결국 전체 처리량은 시스템에서 가장 느린 지점의 영향을 받는다.

3. 병목은 제거되는 것이 아니라 이동한다.

Gold-Rush-Lab은 버전이 올라갈수록 하나의 병목을 해결하면 새로운 병목이 나타나는 과정을 반복했다.

  • v0.1: Lost Update 발생
  • v0.2: DB Lock 병목 발생
  • v0.3: API 증설, DB Lock 병목 유지
  • v0.4: DB Lock Wait 제거, Redis Lock Wait 발생

병목은 하나의 기술로 제거되는 것이 아니다. 시스템 구조에 따라 다른 위치로 이동하는 특성을 가진다는 점을 확인할 수 있었으며 그로 인해 성능 개선의 목표는 병목을 완전히 없애는 것이 아니라, 현재 시스템에서 가장 관리 가능하고 예측 가능한 위치로 이동시키는 것임을 알 수 있었다.

이러한 결과를 바탕으로 다음 버전(v0.5)에서는 요청을 동기적으로 처리하는 구조 자체를 변경하여, Kafka를 이용한 비동기 처리와 Queue 기반의 병렬 처리를 통해 Redis Lock Wait까지 완화할 수 있는지 실험하고자 한다.

또한, 후속 실험을 통해 요구사항을 추가하고 분산 락이 적절히 활용될 수 있는 비즈니스 상황을 재현해보고자 한다.

댓글남기기