동시성 문제를 해결하기 위해 비관적 락과 낙관적 락 중 어떤 것을 선택해야 할까? 그리고 Lock은 서버 성능에 어떠한 영향을 끼칠까?

개요

v0.1 단계에서는 동시성 문제에 대한 대비 없이 VU 10 ~ 500 까지의 부하를 걸었다. 결과로서 데이터 정합성이 VU가 1 일 때를 제외하고 모두 정합성이 어긋나는 현상을 확인할 수 있었다.

그렇다면 이러한 상황에서 최소한의 방지를 하기 위해 어떤 것을 할 수 있을까?

먼저 동일한 자원을 동시에 수정하는 트랜잭션 간의 충돌을 제어해야 한다.

이를 위해 데이터 변경 시 충돌을 감지하거나, 특정 트랜잭션이 자원을 수정하는 동안 다른 트랜잭션의 충돌 가능한 변경을 제한하는 Lock을 적용할 수 있다.

Lock에는 두 가지 종류가 있다.

  • Optimistic Lock(낙관적 락)
    • 동시성 충돌이 자주 발생하지 않을 것이라고 가정한다. 데이터를 조회할 때는 별도의 Lock을 획득하지 않고, 수정 시점에 Version 값을 비교하여 다른 트랜잭션이 먼저 수정했는지를 확인한다. 충돌이 발생하면 예외를 발생시키거나 재시도한다.
  • Pessimistic Lock(비관적 락)
    • 동시성 충돌이 자주 발생할 것이라고 가정한다. 데이터를 조회하는 시점부터 DB Row Lock을 획득하여 다른 트랜잭션의 충돌 가능한 수정 작업을 대기시킨 뒤 현재 트랜잭션의 작업을 수행한다.

이번 실험에서는 v0.1 의 모놀리식 아키텍처에서 낙관적, 비관적 락만 적용하여 실험을 진행한다. 또한 이러한 적용점이 서버 처리량에 어떠한 영향을 미치는지 알아보고자 한다.

가설

Lock에 대해서 알아본 후, 그 내용을 토대로 v0.1 과 같이 가설을 설정했다.

가설 1. 낙관적 락은 Lost Update를 방지하여 데이터 정합성을 유지할 것이다.

  • 광산의 잔량을 수정하는 Update 연산에 ‘충돌감지’ 라는 완충지대가 생겼다. 이는 현재 작업하고 있는 스레드가 다른 스레드의 결과를 덮어씌우는 것을 방지할 것이다.

가설 2. 낙관적 락은 오류를 반환하여 어떤 유저는 100개를 채굴하지 못 할 것이다.

  • 충돌이 발생하면 재시도하거나 예외를 발생시킨다고 했다. 재시도 횟수 설정이 필요할 것이라는 예상과 함께 설정한 재시도 횟수를 넘기면 오류를 반환할 것 이라는 생각이 들었다.

가설 3. 비관적 락은 Lost Update를 방지하여 데이터 정합성을 유지할 것이다.

  • 비관적 락은 조회하는 시점부터 접근을 제한한다. 정합성을 유지할 것이다.

가설 4. 비관적 락은 부하가 늘어남에 따라 TPS가 떨어질 것이다.

  • 조회하는 시점부터 접근을 제한한다. 이는 동시 요청들이 직렬화 될 것이므로 v0.1(커넥션 풀 10) 보다 TPS가 낮게 나올 것이다.

가설 5. 비관적 락은 Lock 대기가 길어지면 일부 요청에서 타임아웃이 발생할 것이다.

  • 낙관적 락과 마찬가지로 어떤 유저는 100개를 채굴하지 못 할 것이다. 부하가 충분히 커진다면 대기가 길어져 일부 요청에서 Timeout이 발생할 것으로 예상했다.

가설 6. 낙관적, 비관적 락을 적용함으로써 DB에서의 Connection Pool Size와 관련된 병목은 해결될 것이다.

  • v0.1에서 커넥션 풀을 늘리며 스프링 → DB 로 전이된 병목은 동시성 제어가 애플리케이션 레벨에서 수행되며 다시 스프링에서의 병목으로 전이될 것이다. 즉, DB에서의 wait_event 는 그 수가 줄고 Spring에서의 병목이 커질 것으로 예상된다.

실험 방법

  • 실험 방법은 v0.1과 동일하게 진행한다.
  • 단, v0.2/optimistic-lock, v0.2/pessimistic-lock 브랜치에서 각각에 대한 구현을 수행 후 실험한다.
  • Optimistic Lock 과 Pessimistic Lock의 성능을 비교하기 위해 다음을 수행한다.
    • 각 풀 사이즈마다 VU 10으로 Warm-up을 수행한다.
      • 서버 부트 후의 첫 요청에서 다음이 발생하기 때문이다.
      • JVM JIT 컴파일
      • 클래스 로딩
      • Hikari 커넥션 생성
      • SQL 실행 경로 준비
      • Spring 내부 초기화
    • 각 커넥션 실험마다 3회 반복한다.
    • 결과의 비교는 3회의 평균 값을 활용한다.
    • 각 실행마다 System CPU, Process CPU, Heap Usage, Hikari Pool 안정을 확인 후 실행한다.
    • 각 실행마다 DB는 다음의 쿼리로 초기화한다.

        TRUNCATE TABLE app_user, mine, mining_log
        RESTART IDENTITY
        CASCADE;
      

Optimistic Lock

CREATE TABLE mine
(
    id               BIGINT GENERATED ALWAYS AS IDENTITY,
    remaining_amount BIGINT      NOT NULL,
    created_at       TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    version          BIGINT      NOT NULL DEFAULT 0, --추가된 version 컬럼

    CONSTRAINT pk_mine
        PRIMARY KEY (id),

    CONSTRAINT chk_mine_remaining_amount
        CHECK (remaining_amount >= 0)
);
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@FieldDefaults(level = AccessLevel.PRIVATE)
@Table(name = "mine")
public class MineEntity extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    Long id;

    Long remainingAmount;

    @Version
    Long version; // 추가된 필드
        ...

    }
  • Mine 엔티티, 테이블에 version 필드, 컬럼을 추가하여 구현한다.

@Version이 적용되면 Hibernate는 기존 version 값을 UPDATE WHERE 조건에 포함하고, 수정에 성공하면 version 값을 증가시킨다. 이미 다른 트랜잭션이 데이터를 수정하여 조건에 맞는 행이 없다면, 변경된 행의 수가 0이 되며 낙관적 락 예외가 발생한다.

Pessimistic Lock

  • Lock을 적용하고 싶은 도메인의 조회 쿼리에 FOR UPDATE 구문을 적용한다.
    • 예시
      SELECT id, remaining_amount, created_at, version
      FROM mine
      WHERE id = ?
      FOR UPDATE; -- 추가된 FOR UPDATE 구문
    
  • JPA에서는 다음과 같이 적용할 수 있다.
    • 예시 : MineJpaRepository
      • 구현체인 MinRepositoryJpaAdapter 에서는 Optional이 처리된 MineEntity 객체가 반환된다.
      @Lock(LockModeType.PESSIMISTIC_WRITE)
      @Query("""
          select m
          from MineEntity m
          where m.id = :id
      """)
      Optional<MineEntity> findByIdForUpdate(@Param("id") Long id);
    

    @Query 어노테이션을 적용한 이유는 함수 이름을 통해 구분을 하기 위함이다.

    JPA는 메서드 이름 분석JPQL 생성SQL 변환 의 과정을 거치는데, @Query 를 적용함으로써 메서드 이름 분석(생략)JPQL 생성(직접 작성)SQL 변환 의 과정을 거치게된다.

    이를 적용했다고 해서 성능상의 차이가 유의미하게 있다고 보기는 어렵다고 판단하여 함수를 추가했다.

      @Override
          @Transactional
          public void mine(UUID sessionId, Long amount) {
              LockStrategy strategy = LockStrategy.from(configuredLockStrategy);
              try {
                  UserEntity foundUser = userRepository.findBySessionId(sessionId);
      //            기존 조회 코드
      //            MineEntity foundMine = foundUser.getMine();
    
      //          Pessimistic Lock 적용 조회 코드
                  MineEntity foundMine = mineRepository.findByIdForUpdate(foundUser.getMine().getId());
                  ...
              }
             ...
         }
      }
    

    또한 findByIdForUpdate를 실행하기 위해 getId() 함수를 호출하는 부분이 존재한다.

    일반적으로 UserEntityMineEntityLAZY로 참조하고 있기 때문에 getId()를 호출하는 과정에서 추가적인 조회가 발생하는 것이 아닌지 의문을 가질 수 있다.

      public class UserEntity{
          ...
            @ManyToOne(fetch = FetchType.LAZY, optional = false)
            @JoinColumn(name = "mine_id", nullable = false)
            MineEntity mine;
            ...
      }
    

    하지만 Hibernate는 연관 엔티티를 LAZY로 로딩할 경우 실제 엔티티 대신 Proxy 객체를 주입한다. 이 Proxy는 연관 엔티티의 PK 를 이미 알고 있기 때문에 getId() 호출만으로는 Proxy 초기화가 발생하지 않는다.

    즉,

      Long mineId = foundUser.getMine().getId();
    

    MineEntity를 조회하기 위한 SELECT를 수행하지 않으며, Proxy가 보유하고 있는 식별자만 반환한다.

    이후

      MineEntity foundMine =
          mineRepository.findByIdForUpdate(foundUser.getMine().getId());
    

    를 통해 최초 조회부터 PESSIMISTIC_WRITE가 적용된 SELECT ... FOR UPDATE가 수행된다.

    결과적으로 기존 방식

      MineEntity foundMine = foundUser.getMine();
    

    역시 MineEntity를 최초 접근하는 시점에 SELECT가 한 번 수행되고,

    변경된 방식

      MineEntity foundMine =
          mineRepository.findByIdForUpdate(foundUser.getMine().getId());
    

    역시 SELECT ... FOR UPDATE가 한 번 수행된다.

    즉, SQL 실행 횟수는 기존과 동일하다.

    기존에는 MineEntity를 일반 조회한 후 비즈니스 로직을 수행했다면, 변경된 방식은 최초 조회부터 FOR UPDATE를 적용하여 Row Lock을 획득한다는 차이가 있다.

    따라서 getId() 호출로 인해 추가적인 조회가 발생하거나 성능 저하가 발생하지 않으며, 동일한 SQL 실행 횟수로 비관적 락을 적용할 수 있었다.

결과

Process CPU

락 전략별 Process CPU 사용률

낙관적 락과 비관적 락의 Process CPU 사용률 비교

정합성

낙관적 락과 비관적 락의 데이터 정합성 결과

락 전략별 데이터 정합성 검증 결과

Optimistic Lock

첫 Warm-up 시도와 함께 Optimistic Lock 과 관련된 예외가 발생했다.

ObjectOptimisticLockingFailureException 발생 화면

낙관적 락 충돌로 발생한 ObjectOptimisticLockingFailureException

따라서 오류는 그대로 HttpStatus.CONFLICT로 반환하되 후속 실험에서 재시도 로직을 추가하여 대표적인 사례만 비교하고자 한다.

실행 결과 표 (스압)
버전 Hikari Max Pool Size VU TPS (req/s) System CPU Peak (%) Process CPU Peak (%) JVM Heap Peak (%) Avg Latency (ms) P95 (ms) P99 (ms) Error Rate (%) 초기 잔량 사용자 총 채굴량 Mining Log 총 채굴량 실제 잔량 정합성
v0.2_optimistic-lock 10 10 483.325 1.49 1.005 12.089 19.956 30.196 37.097 82.3 1000 177 177 823
v0.2_optimistic-lock 10 10 646.412 9.496 9.548 9.724 14.705 24.429 34.75 82.5 1000 175 175 825
v0.2_optimistic-lock 10 10 784.314 74.51 66.01 11.411 12.092 19.044 26.589 83 1000 170 170 830
v0.2_optimistic-lock 10 10 853.242 47.777 41.089 11.848 11.221 17.081 22.333 83.6 1000 164 164 836
v0.2_optimistic-lock 10 10 914.077 0.5 1 7.607 10.435 15.587 17.809 83.2 1000 168 168 832
v0.2_optimistic-lock 10 50 915.583 98.505 84.653 11.099 52.199 112.411 146.722 84.5 5000 775 775 4225
v0.2_optimistic-lock 10 50 1077.586 46.465 38.889 9.832 44.297 93.719 124.143 84.84 5000 758 758 4242
v0.2_optimistic-lock 10 50 1213.592 97.025 79.602 10.404 39.471 86.014 113.473 85.16 5000 742 742 4258
v0.2_optimistic-lock 10 50 1238.85 57.639 47.264 11.379 38.462 86.167 114.494 85.2 5000 740 740 4260
v0.2_optimistic-lock 10 50 1468.429 87.245 69.388 8.974 32.486 69.17 91.337 85.92 5000 704 704 4296
v0.2_optimistic-lock 10 100 1647.989 96.99 69.802 10.133 57.352 129.953 175.004 85.95 10000 1405 1405 8595
v0.2_optimistic-lock 10 100 1707.65 96.059 71.5 11.888 55.702 121.798 170.831 86.02 10000 1398 1398 8602
v0.2_optimistic-lock 10 100 1665.002 92.195 64.878 9.738 57.417 128.699 169.961 86.03 10000 1397 1397 8603
v0.2_optimistic-lock 10 100 1722.356 94.54 65.842 9.729 55.427 121.22 169.774 86.1 10000 1390 1390 8610
v0.2_optimistic-lock 10 100 1656.726 91.779 64.251 12.119 57.32 126.453 172.352 85.77 10000 1423 1423 8577
v0.2_optimistic-lock 10 300 1694.341 95.039 70.297 14.449 168.905 397.067 507.532 86.163 30000 4151 4151 25849
v0.2_optimistic-lock 10 300 1692.811 95.074 66.337 17.038 169.197 390.56 499.097 86.11 30000 4167 4167 25833
v0.2_optimistic-lock 10 300 1675.51 93.525 67.164 16.364 170.599 393 497.968 86.293 30000 4112 4112 25888
v0.2_optimistic-lock 10 300 1621.446 94.189 67.476 13.927 177.247 410.809 530.22 86.05 30000 4185 4185 25815
v0.2_optimistic-lock 10 300 1663.156 91.714 62.319 16.162 173.007 398.443 504.387 86.027 30000 4192 4192 25808
v0.2_optimistic-lock 10 500 1669.672 95.434 67.662 17.992 291.206 522.438 634.987 86.098 50000 6951 6951 43049
v0.2_optimistic-lock 10 500 1666 92.67 65.104 18.538 291.834 521.254 627.034 85.944 50000 7028 7028 42972
v0.2_optimistic-lock 10 500 1692.162 94.118 66.01 18.306 287.281 516.879 628.025 86.258 50000 6871 6871 43129
v0.2_optimistic-lock 10 500 1668.613 92.921 64.455 18.139 291.046 523.209 627.758 86.148 50000 6926 6926 43074
v0.2_optimistic-lock 10 500 1685.204 95.02 68.02 19.736 287.888 514.186 618.194 86.112 50000 6944 6944 43056
v0.2_optimistic-lock 30 10 458.085 1.01 1.508 8.131 21.022 33.119 39.746 82.3 1000 177 177 823
v0.2_optimistic-lock 30 10 668.449 1.99 1 10.359 14.372 23.023 30.661 83 1000 170 170 830
v0.2_optimistic-lock 30 10 759.878 1.99 0.503 11.724 12.504 21.435 31.061 82.9 1000 171 171 829
v0.2_optimistic-lock 30 10 843.17 21.5 18.5 11.097 11.391 16.964 21.078 83.5 1000 165 165 835
v0.2_optimistic-lock 30 10 900.09 83.746 72.727 12.02 10.608 15.826 20.657 84.1 1000 159 159 841
v0.2_optimistic-lock 30 50 1025.431 99.495 83.824 7.521 46.727 86.187 104.415 89.52 5000 524 524 4476
v0.2_optimistic-lock 30 50 1105.461 99.505 84.08 11.707 43.826 77.392 95.668 89.16 5000 542 542 4458
v0.2_optimistic-lock 30 50 1235.178 99.5 83.582 11.817 39.052 71.343 88.318 89.72 5000 514 514 4486
v0.2_optimistic-lock 30 50 1348.072 18.812 16.832 12.15 35.813 63.692 80.005 89.94 5000 503 503 4497
v0.2_optimistic-lock 30 50 1507.386 92.424 75 12.474 31.892 59.972 79.229 89.74 5000 513 513 4487
v0.2_optimistic-lock 30 100 1687.479 97.505 76.617 12.421 56.642 128.3 166.535 89.92 10000 1008 1008 8992
v0.2_optimistic-lock 30 100 1721.17 97.5 71.5 11.984 55.716 121.433 158.444 90.07 10000 993 993 9007
v0.2_optimistic-lock 30 100 1759.324 96.049 69.802 10.829 54.607 116.276 148.869 90.09 10000 991 991 9009
v0.2_optimistic-lock 30 100 1747.335 83.756 58.794 9.711 54.706 116.147 150.798 90.12 10000 988 988 9012
v0.2_optimistic-lock 30 100 1725.03 96.069 70.647 11.271 55.511 116.43 146.509 90.06 10000 994 994 9006
v0.2_optimistic-lock 30 300 1740.846 96.117 70.388 15.101 165.721 372.076 472.363 90.053 30000 2984 2984 27016
v0.2_optimistic-lock 30 300 1746.827 96.04 69.118 17.192 164.254 365.278 463.372 89.967 30000 3010 3010 26990
v0.2_optimistic-lock 30 300 1699.909 92.137 65.816 15.736 169.269 375.022 477.505 90.257 30000 2923 2923 27077
v0.2_optimistic-lock 30 300 1740.745 95.098 68.293 16.305 164.953 365.236 463.027 90.143 30000 2957 2957 27043
v0.2_optimistic-lock 30 300 1777.883 97 70.149 16.884 161.995 361.325 455.793 90.107 30000 2968 2968 27032
v0.2_optimistic-lock 30 500 1713.091 96.939 69.388 17.364 283.931 501.614 602.795 90.05 50000 4975 4975 45025
v0.2_optimistic-lock 30 500 1687.251 92.676 67.5 17.305 288.067 505.189 605.988 90.188 50000 4906 4906 45094
v0.2_optimistic-lock 30 500 1707.417 93.768 66.341 18.561 284.375 496.288 596.171 90.076 50000 4962 4962 45038
v0.2_optimistic-lock 30 500 1747.824 96.116 67.317 19.554 278.29 485.248 584.456 90.038 50000 4981 4981 45019
v0.2_optimistic-lock 30 500 1756.235 96.469 69.036 19.824 276.924 484.397 582.98 90.22 50000 4890 4890 45110
v0.2_optimistic-lock 50 10 458.716 95.455 87.5 7.664 21.004 33.03 43.237 81.9 1000 181 181 819
v0.2_optimistic-lock 50 10 600.601 32.828 29.851 9.469 15.898 24.805 32.736 83.1 1000 169 169 831
v0.2_optimistic-lock 50 10 697.35 0.5 0.5 10.332 13.634 22.208 31.829 82.3 1000 177 177 823
v0.2_optimistic-lock 50 10 844.595 1.49 0.995 11.047 11.338 17.048 24.781 83.5 1000 165 165 835
v0.2_optimistic-lock 50 10 844.595 9.545 8.04 12.429 11.177 18.879 25.9 83 1000 170 170 830
v0.2_optimistic-lock 50 50 937.559 96.597 83.824 11.153 50.639 104.727 135.669 90.36 5000 482 482 4518
v0.2_optimistic-lock 50 50 1089.799 99.505 85.5 12.157 43.779 84.047 105.068 90.92 5000 454 454 4546
v0.2_optimistic-lock 50 50 1186.24 36.275 29.412 10.579 40.517 76.796 95.352 90.58 5000 471 471 4529
v0.2_optimistic-lock 50 50 1362.027 98.015 79.602 13.112 35.006 74.809 109.93 91.62 5000 419 419 4581
v0.2_optimistic-lock 50 50 1464.987 99.479 81.538 9.909 32.365 68.511 102.62 91.8 5000 410 410 4590
v0.2_optimistic-lock 50 100 1522.302 99.005 79.104 12.77 63.592 129.485 165.052 90.91 10000 909 909 9091
v0.2_optimistic-lock 50 100 1581.028 88.319 60.976 12.886 60.866 122.594 179.642 91.46 10000 854 854 9146
v0.2_optimistic-lock 50 100 1676.165 96.078 70.647 12.59 57.746 108.141 134.612 90.7 10000 930 930 9070
v0.2_optimistic-lock 50 100 1685.204 92.939 67.347 12.022 57.331 108.657 134.156 90.89 10000 911 911 9089
v0.2_optimistic-lock 50 100 1633.72 87.435 60 11.164 59.089 114.661 155.577 91.43 10000 857 857 9143
v0.2_optimistic-lock 50 300 1549.107 96.454 72.959 16.383 186.694 389.911 489.449 92.81 30000 2157 2157 27843
v0.2_optimistic-lock 50 300 1638.002 91.318 65.347 18.45 176.829 362.787 449.236 90.79 30000 2763 2763 27237
v0.2_optimistic-lock 50 300 1624.519 92.751 66.502 18.711 177.698 368.644 465.187 91.327 30000 2602 2602 27398
v0.2_optimistic-lock 50 300 1712.818 95.928 68.02 18.971 169.359 351.371 441.05 91.377 30000 2587 2587 27413
v0.2_optimistic-lock 50 300 1764.291 97.475 69.5 17.256 163.222 341.969 427.999 90.627 30000 2812 2812 27188
v0.2_optimistic-lock 50 500 1702.649 92.195 65.5 20.057 286.611 478.722 565.85 91.332 50000 4334 4334 45666
v0.2_optimistic-lock 50 500 1651.473 91.95 65.072 18.415 295.292 491.214 586.819 91.594 50000 4203 4203 45797
v0.2_optimistic-lock 50 500 1707.825 92.54 66 19.77 286.183 476.408 562.898 90.964 50000 4518 4518 45482
v0.2_optimistic-lock 50 500 1623.166 91.638 64.563 21.403 300.184 498.886 595.214 92.566 50000 3717 3717 46283
v0.2_optimistic-lock 50 500 1677.008 92.829 64.286 22.092 290.92 488.595 581.021 91.354 50000 4323 4323 45677

Pessimistic Lock

실행 결과 표 (스압)
Hikari Max Pool Size VU TPS (req/s) System CPU Peak (%) Process CPU Peak (%) JVM Heap Peak (%) Avg Latency (ms) P95 (ms) P99 (ms) Error Rate (%) 초기 잔량 사용자 총 채굴량 Mining Log 총 채굴량 실제 잔량 정합성
10 10 264.69 61.65 50.481 11.472 36.722 75.852 94.368 0 1000 1000 1000 0
10 10 336.7 78.802 65.385 12.21 28.545 60.417 84.16 0 1000 1000 1000 0
10 10 374.672 78.268 62.085 8.894 25.405 47.567 65.727 0 1000 1000 1000 0
10 10 405.515 71.3 54.286 12.53 23.467 48 64.501 0 1000 1000 1000 0
10 10 433.839 63.633 42.925 11.481 21.741 45.011 59.117 0 1000 1000 1000 0
10 50 482.765 73.215 55.981 12.432 101.897 137.831 164.872 0 5000 5000 5000 0
10 50 515.996 45.603 21.127 12.178 95.511 125.497 140.106 0 5000 5000 5000 0
10 50 522.63 44.985 26.19 9.448 94.175 123.73 153.164 0 5000 5000 5000 0
10 50 526.205 48.144 22.791 11.244 93.503 123.458 140.997 0 5000 5000 5000 0
10 50 527.037 48.832 25.822 10.786 93.29 121.089 139.954 0 5000 5000 5000 0
10 100 522.903 47.664 23.256 12.602 188.729 220.035 246.377 0 10000 10000 10000 0
10 100 533.646 47.043 22.749 12.307 185.188 214.762 234.261 0 10000 10000 10000 0
10 100 532.51 47.412 23.364 12.595 185.412 214.242 234.955 0 10000 10000 10000 0
10 100 533.191 46.931 23.223 12.756 185.372 215.475 234.216 0 10000 10000 10000 0
10 100 531.887 46.045 23.333 12.667 185.548 217.251 333.618 0 10000 10000 10000 0
10 300 522.339 46.122 22.222 13.777 559.684 950.958 1288.799 0 30000 30000 30000 0
10 300 506.406 47.226 23.81 14.106 578.067 993.698 1284.62 0 30000 30000 30000 0
10 300 523.716 47.064 22.072 14.644 557.561 951.299 1291.236 0 30000 30000 30000 0
10 300 518.744 46.537 22.222 14.425 562.779 956.14 1288.496 0 30000 30000 30000 0
10 300 525.357 46.724 22.43 15.346 555.556 948.539 976.372 0 30000 30000 30000 0
10 500 527.437 47.358 22.535 15.853 931.02 1324.831 1659.504 0 50000 50000 50000 0
10 500 528.793 46.979 22.857 16.752 928.619 1319.871 1657.138 0 50000 50000 50000 0
10 500 528.362 47.923 26.168 16.527 929.453 1321.106 1653.319 0 50000 50000 50000 0
10 500 529.341 46.204 23.474 16.6 927.631 1319.927 1653.405 0 50000 50000 50000 0
10 500 529.078 46.3 22.43 17.5 928.327 1318.806 1652.585 0 50000 50000 50000 0
30 10 276.549 82.856 72.906 13.918 34.561 66.706 83.688 0 1000 1000 1000 0
30 10 334.672 62.202 49.282 14.122 28.699 57.906 90.935 0 1000 1000 1000 0
30 10 380.373 16.154 12.255 14.259 25.374 55.183 74.55 0 1000 1000 1000 0
30 10 411.692 0.495 0.503 11.889 22.861 46.991 64.036 0 1000 1000 1000 0
30 10 440.335 0.99 0 12.855 20.937 44.881 65.388 0 1000 1000 1000 0
30 50 483.325 60.28 37.5 14.473 98.054 262.753 406.489 0 5000 5000 5000 0
30 50 514.774 50.905 25 9.534 91.808 258.958 408.495 0 5000 5000 5000 0
30 50 523.78 47.485 21.596 11.553 90.257 250.881 398.169 0 5000 5000 5000 0
30 50 524.384 52.813 28.111 14.524 90.26 258.515 390.696 0 5000 5000 5000 0
30 50 528.318 47.443 22.43 12.819 89.784 252.765 390.179 0 5000 5000 5000 0
30 100 525.597 46.976 21.801 14.862 184.428 354.526 506.411 0 10000 10000 10000 0
30 100 527.287 46.513 20.93 14.909 184.173 354.005 507.003 0 10000 10000 10000 0
30 100 528.513 46.084 21.327 14.262 183.481 356.412 507.983 0 10000 10000 10000 0
30 100 526.815 46.971 21.495 13.14 184.324 350.98 492.472 0 10000 10000 10000 0
30 100 529.297 46.759 20.853 13.052 182.99 349.888 496.712 0 10000 10000 10000 0
30 300 517.215 49.065 27.103 16.358 566.233 933.768 1181.082 0 30000 30000 30000 0
30 300 522.73 47.464 22.535 17.07 560.709 924.346 1174.662 0 30000 30000 30000 0
30 300 522.266 46.534 21.86 16.972 560.747 921.314 1172.912 0 30000 30000 30000 0
30 300 522.839 47.01 22.326 16.871 560.293 921.814 1173.56 0 30000 30000 30000 0
30 300 523.469 48.177 22.222 17.484 559.431 923.755 1171.828 0 30000 30000 30000 0
30 500 522.706 47.425 21.963 17.668 940.471 1315.279 1563.52 0 50000 50000 50000 0
30 500 522.854 47.96 22.897 17.593 940.571 1308.353 1558.165 0 50000 50000 50000 0
30 500 522.237 47.884 22.699 18.797 941.952 1310.172 1557 0 50000 50000 50000 0
30 500 522.171 46.768 22.685 19.713 941.578 1310.848 1553.148 0 50000 50000 50000 0
30 500 516.705 45.992 22.326 20.148 952.707 1309.231 1569.608 0 50000 50000 50000 0
50 10 267.881 0.5 0.503 10.303 35.701 76.761 111.083 0 1000 1000 1000 0
50 10 345.066 77.824 62.5 10.306 27.478 58.829 87.371 0 1000 1000 1000 0
50 10 372.995 78.259 62.019 11.521 25.35 56.353 83.185 0 1000 1000 1000 0
50 10 415.455 70.507 52.133 12.364 22.549 55.685 113.313 0 1000 1000 1000 0
50 10 442.674 63.15 41.509 12.26 21.462 45.605 58.762 0 1000 1000 1000 0
50 50 468.121 74.519 60.386 11.78 100.061 331.414 538.325 0 5000 5000 5000 0
50 50 502.917 50.472 23.944 12.831 91.4 307.747 481.653 0 5000 5000 5000 0
50 50 506.894 47.614 23.077 13.28 91.813 299.958 444.389 0 5000 5000 5000 0
50 50 500.25 46.984 18.692 10.694 93.196 313.696 481.347 0 5000 5000 5000 0
50 50 511.3 46.441 19.159 13.444 91.63 285.471 464.803 0 5000 5000 5000 0
50 100 499.6 49.056 25.701 12.058 192.89 424.657 603.514 0 10000 10000 10000 0
50 100 494.829 47.923 21.918 13.545 193.93 427.088 622.355 0 10000 10000 10000 0
50 100 495.909 50.212 25.926 12.432 194.761 424.855 620.377 0 10000 10000 10000 0
50 100 496.081 49.336 22.172 13.577 194.417 423.102 622.366 0 10000 10000 10000 0
50 100 497.636 49.091 21.429 12.316 193.82 412.312 588.599 0 10000 10000 10000 0
50 300 497.249 48.901 20.961 14.652 589.066 967.476 1178.925 0 30000 30000 30000 0
50 300 495.54 46.825 21.86 15.424 590.187 965.091 1172.53 0 30000 30000 30000 0
50 300 449.001 47.685 24.444 14.906 654.873 1092.45 1392.663 0 30000 30000 30000 0
50 300 485.57 46.521 22.535 15.453 602.966 978.092 1212.009 0 30000 30000 30000 0
50 300 417.223 45.997 23.113 16.561 699.39 1182.596 1452.286 0 30000 30000 30000 0
50 500 452.435 47.345 25.11 16.782 1087.633 1538.136 1811.782 0 50000 50000 50000 0
50 500 453.141 45.966 25.238 17.333 1087.942 1522.567 1789.442 0 50000 50000 50000 0
50 500 468.27 47.473 22.541 18.078 1050.641 1455.055 1692.177 0 50000 50000 50000 0
50 500 468.384 48.823 24 18.735 1051.139 1443.575 1655.91 0 50000 50000 50000 0
50 500 486.519 46.975 23.913 18.059 1012.998 1395.309 1613.651 0 50000 50000 50000 0

분석

가설 1. 낙관적 락은 Lost Update를 방지하여 데이터 정합성을 유지할 것이다.

낙관적 락은 성공적으로 Lost Update를 방지했다. 하지만 Error Rate Peak 값 92.81%, DB 커넥션 사이즈별(10, 30, 50) 각각 86%, 90%, 91%대의 높은 수치를 보였다. VU가 달라짐에 따라서는 유의미한 상승폭을 보이지 않았다.

초기 잔량 VU 사용자 총 채굴량 실제 잔량 정합성
30000 300 2157 27843 ✅ (2157 + 27843 = 30000)
50000 500 3717 46283 ✅ (3717 + 46283 = 50000)

가설 2. 낙관적 락은 오류를 반환하여 어떤 유저는 100개를 채굴하지 못 할 것이다.

실제로 가설1 에서의 표를 참고했을 때, 어떤 유저가 아닌 거의 모든 유저가 목표량(100개)를 채굴하지 못했다. 최소 관측 VU(10)에서도 높은 확률로 에러가 발생했으며, 거의 모든 유저가 목표량(100개)을 채굴하지 못했다.

Hikari Max Pool Size 초기 잔량 VU 사용자 총 채굴량 실제 잔량 Error Rate (%)
10 1000 10 164 836 83.6
10 1000 10 168 832 83.2
10 1000 10 170 830 83
10 1000 10 175 825 82.5
10 1000 10 177 823 82.3
30 1000 10 159 841 84.1
30 1000 10 165 835 83.5
30 1000 10 170 830 83
30 1000 10 171 829 82.9
30 1000 10 177 823 82.3
50 1000 10 165 835 83.5
50 1000 10 169 831 83.1
50 1000 10 170 830 83
50 1000 10 177 823 82.3
50 1000 10 181 819 81.9

가설 3. 비관적 락은 Lost Update를 방지하여 데이터 정합성을 유지할 것이다.

가설 4. 비관적 락은 부하가 늘어남에 따라 TPS가 떨어질 것이다.

비관적 락은 Lost Update를 방지하여 데이터 정합성을 성공적으로 유지하면서도, 에러를 발생시키지 않았다. 하지만 그 Trade Off로 TPS가 감소했고, P95 수치가 약 3배가량 크게 증가했다. 경험상 수강 신청, 티켓팅과 같은 도메인은 용인될 수 있겠지만(요청에 성공만 한다면) 게임과 같은 형식을 지니는 Gold-Rush-Lab에서는 이와 같은 상황이 벌어진다면 유저 수를 크게 잃을 가능성이 크다는 생각이 들었다.

락 전략별 P95 응답 시간

낙관적 락과 비관적 락의 P95 응답 시간 비교

락 전략별 오류율

낙관적 락과 비관적 락의 Error Rate 비교

가설 5. 비관적 락은 Lock 대기가 길어지면 일부 요청에서 타임아웃이 발생할 것이다.

가설은 맞지 않았다.

모든 부하 조건에서 Error Rate는 0%를 유지했으며, 타임아웃으로 인해 실패한 요청은 관측되지 않았다.

다만 부하가 증가할수록 Lock을 획득하기 위한 대기 시간이 증가하면서 응답 지연이 크게 증가하였다. 특히 P95 수치는 Connection Pool Size 10 기준 약 55 ms(VU 10) 에서 1,320 ms(VU 500) 까지 증가했으며, Pool Size 50에서는 1,470 ms 수준까지 증가하였다.

즉, 이번 실험 환경에서는 비관적 락이 요청을 실패시키기보다 대기(Blocking) 시키는 방식으로 동작하였으며, 타임아웃이 발생할 정도로 Lock Wait Timeout 설정에 도달하지는 않았다.

따라서 비관적 락의 특징은 “실패를 허용하지 않는 대신 응답 시간이 증가한다.”는 점으로 해석할 수 있다.

가설 6. 낙관적, 비관적 락을 적용함으로써 DB에서의 Connection Pool Size와 관련된 병목은 해결될 것이다.

이번 실험 결과를 통해 Connection Pool Size를 증가시키는 것만으로는 병목이 해결되지 않음을 확인하였다.

낙관적 락에서는 Connection Pool Size를 10에서 30, 50으로 증가시켜도 TPS는 거의 증가하지 않았으며, 오히려 동시에 더 많은 트랜잭션이 동일한 데이터(Row)에 접근하면서 Version 충돌이 증가하여 Error Rate가 약 86% → 93% → 95% 수준으로 높아졌다.

락 전략별 TPS

낙관적 락과 비관적 락의 TPS 비교

비관적 락에서도 Connection Pool Size를 증가시킨다고 처리량이 증가하지는 않았다. 오히려 Pool Size가 커질수록 동시에 Lock을 기다리는 트랜잭션 수가 증가하면서 P95 Latency가 더욱 증가하는 경향을 보였다. 특히 Pool Size 50에서는 TPS가 소폭 감소하는 반면 대기 시간은 가장 크게 증가하였다.

이는 두 락 방식 모두 Connection Pool이 병목의 원인이 아니라, 하나의 Row에 대한 Hotspot 경쟁 자체가 병목임을 의미한다. Connection Pool은 병목의 위치를 바꾸거나 경쟁 양상을 변화시킬 뿐, Hotspot 환경에서는 근본적인 해결책이 될 수 없었다.

따라서 Scale-out이나 Distributed Lock과 같은 다음 단계의 실험은 Connection Pool을 조정하는 것이 아니라 Hotspot 자체를 어떻게 완화할 것인가를 검증하는 과정이라고 볼 수 있다.

결론

1. 락과 TPS

락을 적용하지 않은 버전 v0.1과 낙관적 락, 비관적 락을 적용한 v0.2의 TPS는 그 성능이 높은 순으로 나열 했을 때 다음과 같았다.

  • DB Pool Size 10, VU 500, 평균 값 기준, req/s(소수점 아래 생략)
  • Optimistic Lock(1676) > No Lock(669) ≥ Pessimistic Lock(528)
  • 단, No Lock과 Pessimistic Lock은 다른 VU, Connection Pool Size에서 순위가 뒤바뀌기도 하므로 ‘같거나 크다’ 로 표현하였다.

먼저 낙관적 락은 가장 높은 TPS를 보여줬다. 이는 현재 수정하려는 엔티티의 버전이 충돌하는 경우 대기하지 않고 바로 클라이언트에게 409_Conflict 를 전달했기 때문에 가능한 수치로 확인되었다.

빠른 TPS를 기록하는 대신 오류율이라는 Trade Off가 존재했다.

다음으로 락을 획득하지 않은채로 처리하는 v0.1, No Lock 버전이다. 이 글을 읽는 사람이라면 이미 알고 있는 사실이겠지만 v0.1은 사실 데이터 정합성, 그중에서도 Lost Update를 고려한다면 비교 대상이 될 수 없다. 또한 비관적 락과 비교했을 때 TPS가 극적으로 차이 나지 않았기 때문에, 동시성 이슈 가능성이 있을 수 있는 엔티티, 그리고 그에 대한 회피 정책이 마련되어 있지 않다면 낙관적 락이 그 첫 번째 고려 대상이 될 것이다.

다음으로 근소하게 가장 낮은 TPS를 보인 비관적 락은 어떠한 오류와 동시성 문제 없이 테스트를 마쳤다. 하지만 역시 낮은 TPS 라는 Trade Off 가 존재했다.

성능과 그 파급효과를 고려하였을 때, 낙관적 락과 비관적 락의 선택은 정책과 도메인의 특성에 달려 있다.

  • 낙관적 락
    • 요청 후, 성공하지 못하더라도 빠른 피드백이 필요한 경우
    • 요청 후, 실패했다면 또 다른 비즈니스 로직으로 유도 되어야 하는경우
    • 높은 처리량(TPS)이 중요한 서비스
    • 프로필 수정, 게시글 수정 등
  • 비관적 락
    • 요청 후, 늦더라도 항상 성공해야하는 경우
    • 동일 자원에 대한 동시 수정 자체를 허용하면 안 되는 경우
    • 계좌 이체, 쿠폰 선착순 지급, 좌석 예약 등

2. 락과 DB 병목

DB에서의 병목이 ‘해결’ 될 것 이라는 예상과는 달리 완화된 형태로 여전히 존재했다.

낙관적 락의 PostgreSQL 대기 이벤트

낙관적 락 실행 중 관측된 PostgreSQL 대기 이벤트

어떠한 쿼리가 실제로 병목의 원인이 되는지 파악 하기 위해 아래 쿼리를 통해 확인해보았다.

SELECT
    blocked.pid AS blocked_pid,
    blocked.wait_event_type,
    blocked.wait_event,
    now() - blocked.query_start AS blocked_duration,
    blocked.query AS blocked_query,

    blocker.pid AS blocker_pid,
    now() - blocker.query_start AS blocker_duration,
    blocker.query AS blocker_query
FROM pg_stat_activity blocked
         CROSS JOIN LATERAL unnest(pg_blocking_pids(blocked.pid)) AS bpid
         JOIN pg_stat_activity blocker
              ON blocker.pid = bpid
WHERE blocked.wait_event_type = 'Lock'
ORDER BY blocked_duration DESC;

낙관적 락의 병목 쿼리 조회 결과

pg_stat_activity로 확인한 낙관적 락의 병목 쿼리

실제로 병목을 일으키는 위 이미지의 쿼리를 통해 낙관적 락의 작동방식과 병목의 원인을 이해하게 되었다.

update mine set remaining_amount=$1,version=$2 where id=$3 and version=$4

낙관적 락을 이해하는 방식을 보통 다음과 같이 이해하고는 한다.

  • 데이터 조회 → 데이터 수정 → 검증 → 수정 성공

하지만 실제 쿼리의 구조는 다음과 같다.

  • 데이터 조회 → (데이터 수정, 검증) → 수정 성공

검증은 version을 UPDATE 구문의 WHERE 조건절에 함께 끼워 넣음으로써 현재 버전을 판단한다.

또한 버전을 미리 검사하고 UPDATE 쿼리를 DB로 전송하는 것이 아니기 때문에, UPDATE 쿼리는 DB에서 처리하게 된다.

위 과정중에서 한번의 쿼리가 또 들어오게 되면, 이미 실행중인 트랜잭션과 같은 hotspot row 에 대해서 commit 이나 rollback을 기다려야 한다. 경합이 일어나는 쿼리의 수는 결과적으로 v0.1, No Lock과 같다는 것이다.

그렇다면 왜 v0.1보다 낙관적 락이 더 높은 TPS를 보이는 것일까 ?

하나의 row에 대한 UPDATE 는 하나의 트랜잭션에만 영향을 받는다. 그 근거는 공식문서의 두 섹션으로부터 얻을 수 있었다.

… Note that a transaction can hold conflicting locks on the same row, even in different subtransactions; but other than that, two transactions can never hold conflicting locks on the same row. Row-level locks do not affect data querying; they block only writers and lockers to the same row. …

하나의 트랜잭션은 서로 충돌하는 Lock을 동일한 행(Row) 에 대해 보유할 수 있으며, 이는 서로 다른 서브트랜잭션(Subtransaction) 간에도 가능하다. 그러나 이를 제외하면, 두 개의 서로 다른 트랜잭션이 동일한 행에 대해 서로 충돌하는 Lock을 동시에 보유하는 일은 절대 발생하지 않는다.

Row-level Lock은 데이터 조회(Query) 에는 영향을 주지 않는다. 대신 동일한 행을 수정하려는 쓰기 작업(Writer)이나 해당 행에 Lock을 획득하려는 작업(Locker)만 차단(Block) 한다.

Postgres 공식문서 - 13.3.2. Row-Level Locks

UPDATEDELETESELECT FOR UPDATE, and SELECT FOR SHARE commands behave the same as SELECT in terms of searching for target rows: they will only find target rows that were committed as of the command start time. However, such a target row might have already been updated (or deleted or locked) by another concurrent transaction by the time it is found. In this case, the would-be updater will wait for the first updating transaction to commit or roll back (if it is still in progress). If the first updater rolls back, then its effects are negated and the second updater can proceed with updating the originally found row. If the first updater commits, the second updater will ignore the row if the first updater deleted it, otherwise it will attempt to apply its operation to the updated version of the row. The search condition of the command (the WHERE clause) is re-evaluated to see if the updated version of the row still matches the search condition. If so, the second updater proceeds with its operation using the updated version of the row. In the case of SELECT FOR UPDATE and SELECT FOR SHARE, this means it is the updated version of the row that is locked and returned to the client. …

*UPDATE, DELETE, SELECT FOR UPDATE, SELECT FOR SHARE 명령은 대상 Row를 검색하는 방식에서는 SELECT와 동일하게 동작한다. 즉, 명령이 시작된 시점까지 Commit된 Row만 검색 대상이 된다.*

그러나 검색된 대상 Row는, 조회된 시점에는 존재했더라도 다른 동시 실행 트랜잭션에 의해 이미 수정(UPDATE), 삭제(DELETE) 또는 Lock이 획득되었을 수도 있다.

이 경우, 후행 트랜잭션은 선행 트랜잭션이 Commit 또는 Rollback될 때까지 기다린다(아직 실행 중이라면).선행 트랜잭션이 Rollback되면, 해당 변경 사항은 취소되므로 후행 트랜잭션은 원래 검색했던 Row를 그대로 수정할 수 있다. 선행 트랜잭션이 Commit되면, 후행 트랜잭션은 선행 트랜잭션이 Row를 삭제한 경우에는 해당 Row를 무시하고, 그렇지 않은 경우에는 갱신된(Row의 최신 버전)에 대해 자신의 작업을 수행하려고 시도한다.

*이때 명령문의 검색 조건(WHERE 절)은 갱신된 Row에 대해 다시 평가(re-evaluate)된다.갱신된 Row가 여전히 WHERE 조건을 만족하면, 후행 트랜잭션은 최신 버전의 Row를 대상으로 작업을 계속 수행한다.SELECT FOR UPDATESELECT FOR SHARE의 경우에는 최신 버전의 Row에 Lock을 획득한 뒤, 그 Row를 클라이언트에 반환한다.

Postgres 공식문서 - 13.2.1 Read Committed Isolation Level

요약하자면, 하나의 Row는 하나의 트랜잭션 만이 수정할 수 있고, 두 쿼리가 동시에 들어온 경우 선행 트랜잭션이 Commit 되거나 Rollback 될 때 까지 Row Lock을 획득하기 위해 대기 후 재평가한 다는 것이다.

이를 토대로 다시 v0.1에서 발생한 쿼리를 바라보면 다음과 같은 모습일 것이다.

update mine set remaining_amount=$1 where id=$2
                           
                           |
                           | 경합 발생! ROW LOCK 획득을 대기한다.
                           | (transactionid wait)
                                             |
update mine set remaining_amount=$1 where id=$2
                                                    ...(DB Connection Pool  커질수록 대기도 길어진다.)

공식문서에서 낙관적 락의 의문을 해결해줄 또 하나의 핵심적인 문장을 확인할 수 있었다. 이때 명령문의 검색 조건(WHERE 절)은 갱신된 Row에 대해 다시 평가(re-evaluate)된다.

이제 v0.2 optimistic lock 을 적용한 쿼리를 바라보면 다음과 같은 모습일 것이다.

update mine set remaining_amount=$1,version=$2 where id=$3 and version=1 -- 수행! 수정 성공, version 변경
                                                                                                                                                 -- version = 2
update mine set remaining_amount=$1,version=$2 where id=$3 and version=1 -- affected 0 row 실패!
update mine set remaining_amount=$1,version=$2 where id=$3 and version=1 -- affected 0 row 실패!

결국, v0.1과 v0.2 optimistic lock 사이에서 병목 발생의 수는 비슷할 것이나, 낙관적 락은 충돌한 요청을 빠르게 실패시켜 트랜잭션의 대기 시간을 줄이고 TPS를 보장했다.

마지막으로 v0.2 pessimistic lock은 어떨까? 비관적 락에서는 새로운 형태의 wait_event를 확인할 수 있었다.

비관적 락의 PostgreSQL 대기 이벤트

비관적 락 실행 중 관측된 PostgreSQL 대기 이벤트

또 한번, 병목이 되는 쿼리를 확인하기 위해 pg_stat_activity 테이블을 확인했다.

비관적 락의 병목 쿼리 조회 결과

pg_stat_activity로 확인한 비관적 락의 병목 쿼리

이미지에서의 blocker, blocked 쿼리는 동일하게 다음과 같았다.

select me1_0.id,me1_0.created_at,me1_0.remaining_amount,me1_0.version
from mine me1_0
where me1_0.id=$1
for no key update of me1_0

하나의 의외인 점이 존재했다. transactionid에 의해 블로킹되는 쿼리는 UPDATE일 것이라 예상했는데, 이 또한 tuple과 동일한 SELECT 구문이었다.

처음에는 SELECT 문이 어떻게 transactionid를 기다릴 수 있는지 의문이 들었다. 일반적인 SELECT는 단순 조회만 수행하며 Row-Level Lock을 획득하지 않기 때문이다.

하지만 Hibernate가 생성한 SQL을 확인해보니, 실제 실행된 쿼리는 단순 SELECT가 아니라 FOR NO KEY UPDATE가 포함된 Locking Read였다.

FOR NO KEY UPDATE는 조회와 동시에 대상 Row에 Row Lock을 획득하는 명령이다. 따라서 여러 트랜잭션이 동일한 Row에 대해 동시에 해당 쿼리를 수행하면, Lock을 먼저 획득한 트랜잭션을 제외한 나머지 트랜잭션은 대기 상태에 진입하게 된다.

select me1_0.id,me1_0.created_at,me1_0.remaining_amount,me1_0.version
from mine me1_0
where me1_0.id=$1
for no key update of me1_0
             (Row Lock 획득 !)
            |
            | (Row Lock 획득 대기..(tuple) -> 선행 트랜잭션 종료 대기(transactionid)
select me1_0.id,me1_0.created_at,me1_0.remaining_amount,me1_0.version
from mine me1_0
where me1_0.id=$1
for no key update of me1_0

이 과정에서 PostgreSQL은 Lock 획득 단계에 따라 서로 다른 wait_event를 기록한다. 먼저 동일한 Tuple에 대한 Lock 획득을 시도하는 과정에서는 tuple이 관측될 수 있으며, 이미 다른 트랜잭션이 해당 Row를 점유하고 있는 것이 확인되면 선행 트랜잭션의 종료를 기다리는 단계로 전환되어 transactionid가 기록된다.

따라서 tupletransactionid는 서로 다른 병목이 아니라, 동일한 Row-Level Lock 획득 과정에서 관측되는 서로 다른 대기 단계라고 볼 수 있다.

즉, 비관적 락에서는 Row-Level Lock을 UPDATE 시점이 아니라 SELECT ... FOR NO KEY UPDATE 단계에서 미리 획득한다. 이로 인해 동일 Row에 접근하는 후행 트랜잭션은 비즈니스 로직을 수행하기 전부터 Lock 대기에 진입하게 된다. 결과적으로 Lock을 보유하는 시간이 가장 길어지고 병렬성이 크게 감소하여, 세 가지 방식 중 가장 낮은 TPS를 기록하였다.

추가

CPU와 관련된 이슈

v0.2에서의 실험만 138 회 실험을 모두 마친후에 VM에서 vCPU의 수를 ec2의 vCPU와 맞추지 않은 것을 알아챘다..

  • DB Connection Pool(10, 30, 50)
  • VU (warm-up, 10, 50, 100, 300, 500)
  • 총 재시도 횟수 3회
  • 3(1 + (3 * 3 * 5))

실의에 빠져 있다가, 이 참에 실험을 자동화하면 휴먼 에러도 줄고, 실험과 동시에 결과를 분석하는 시간을 가질 수 있을 것이라는 생각이 들어 Codex와 함께 자동화했다.

자동화

자동화는 DB 초기화 → 요청 → 기록 → csv 생성 → VU 증가 와 같은 흐름으로 구성했다. 처음에는 k6의 TPS와 Grafana의 TPS가 일치하지 않아서 꽤 많은 시간을 사용했지만 두 가지 문제가 있는 것을 확인했다.

  • Prometheus의 갱신 주기, Grafana의 갱신 주기가 맞지 않음 → Grafana에서의 해상도 저하
  • Grafana가 그래프에 표현하기 위해 TPS를 계산하는 구간이 1분으로 설정 → Grafana에서의 해상도 저하

Grafana에서의 갱신 간격은 모두 1초로 맞추었지만, TPS의 최댓값을 얻어내기에 가장 정확한 것은 k6를 통해 얻는 순간적인 TPS라서, CSV 내에서는 k6의 기록을 활용하기로 했다.

Optimistic-Lock 에서의 재시도

Optimistic-Lock에서는 version 충돌 시 ObjectOptimisticLockingFailureException 반환하기 때문에 try-catch를 통해서 재시도 로직을 통해 기다릴지, 해당 요청을 버릴지 등 정책을 적용할 수 있다.

다만 이 실험에서는 낙관적 락의 기본 특성만으로 실험하고자 했기 때문에 연달아 실험하지 않았다.

추후 재시도 처리를 적용한 낙관적 락과 비관적 락의 차이점도 알아볼 예정이다. (Lost Update, 데이터 정합성과 관련한 문제는 둘 모두 해결 할 수 있지만 조회 시점에서 획득한 락: 비관적 락이 성능 상 조금 더 유리 할 것으로 예상이된다.)

댓글남기기