락 전략에 따른 속도 비교

Rookedsysc·2024년 7월 5일
0

서론

Post ( 1 : N ) Comment 관계에서 Comment가 작성이 될 때마다 Post 테이블의 Comment Count가 올라갈 수 있도록 역정규화를 해놓았다. 이런 상황에서 Comment가 동시에 작성이 되면 분명 1000건의 데이터를 작성했지만 Comment Count가 171건이 되는 상황이 발생했다. 따라서 락 전략에 따른 성능 테스트를 해보고 상황에 맞는 락 전략을 도입하고자 성능테스트를 진행한다.

100명의 유저가 1000건의 데이터를 입력 함

락이 걸리지 않았을 경우

낙관적락을 사용한 경우

Retryable

사실 이론은 예전에 배웠지만 낙관적 락을 시도해본게 이번이 처음인데 낙관적락을 걸었을 때 CAS 알고리즘에 의해서 Version이 다르면 아예 기록이 안된다는 사실을 처음 알았다. Retry를 어떻게 구현해야 하나.. 고민하면서 구글링을 하던 와중 이미 구현된 Retryable이라는 패키지가 있다는 사실을 알게 되었다.

  • 패키지 추가
implementation 'org.springframework.retry:spring-retry'
implementation 'org.springframework:spring-aspects'
  • Retryable
    @Retryable(
            retryFor = {
                    ObjectOptimisticLockingFailureException.class,
                    StaleObjectStateException.class,
                    ObjectOptimisticLockingFailureException.class},
            maxAttempts = 1000, // 최대 시도 횟수
            backoff = @Backoff(1000) // 재시도 간의 대기 시간 (1000ms)
    )
    public Comment save(CommentSaveDto request, Member member) { ... }
  • EnableRetry
@EnableRetry
@EnableJpaAuditing
@SpringBootApplication
public class GamemoonchulApplication {

    public static void main(String[] args) {
        SpringApplication.run(GamemoonchulApplication.class, args);
    }

}

테스트 결과

현재 100명의 유저가 1000건의 데이터를 입력한다는 테스트 기준으로 Retryable을 최대한 늘려서 했음에도 성공률도 낮고 굉장히 오래 걸린다는 것을 알 수 있다.

비관적 락을 사용한 경우

테스트 조건 변경

아주 근소한 차이기는 하지만 지금 상황은 하나의 Post에 대한 CommentCount만 올리는 상황으로 비관적락이 절대적으로 유리한 상황이다.
왜냐하면 낙관적락은 계속해서 경합이 발생하고 Retry를 하는 비용이 발생하기 때문이다.
때문에 낙관적락이 비관적락보다 실패율도 높고 API 호출에도 더 많은 시간이 걸리는 것이다.
그래서 테스트 조건을 실상황과 비슷하게 바꿔보기로 했다.

  • 만약에 경합 문제가 거의 발생하지 않고 사용량만 높은 상황이라면 어떨까? 또는 락이 거의 필요하지 않을만큼 경합 문제가 발생하지 않는다면?

위 조건을 기반으로 테스트 코드를 아래와 같이 변경해봤다.
Post ID를 랜덤으로 줘서 경합이 거의 일어나지 않는 상황을 가정했다.
하지만, 비관적락은 DB락 때문에 성능은 계속 떨어질 것이다.

import http from "k6/http";
import { check, sleep } from "k6";

const env = JSON.parse(open("./env.json"));
const authorizationToken = env.authorizationToken;
const baseUrl = env.baseUrl;

export let options = {
  vus: 100, // 동시에 실행할 가상 사용자 수
  iterations: 1000, // 반복 횟수 (총 요청 수 : 반복 횟수 / 동시 사용자 수)
  duration: "10m", // 최대 테스트 지속 시간
};

export default function () {
  const apiUrl = baseUrl + "/api/comments";
  const params = {
    headers: {
      Authorization: "Bearer " + authorizationToken,
      "Content-Type": "application/json",
    },
  };

  // 151부터 650까지 임의의 postId 생성
  const postId = Math.floor(Math.random() * 1000) + 151;
  const payload = JSON.stringify({
    content: "Test 1",
    postId: postId,
  });

  const res = http.post(apiUrl, payload, params);
  check(res, {
    "is status 200": (r) => r.status === 200,
  });

  // 트랜잭션 시간 늘리기 위해 지연 추가
  sleep(Math.random() * 2);
}

낙관락 성능 테스트 결과

비관락 성능 테스트 결과

왜 별 차이 없지? 왜 심지어 비관락이 더 빠르지?

락 경합이 아예 발생안한다면?

모든 postId를 다르게 해서 락 경합이 아예 발생 안한다는 기준으로 테스트를 했다.
__vu는 가상 유저 아이디로 1부터 세팅된다.
아래는 가상유저아이디 + 150을 해서 151~251번까지의 포스팅에 댓글을 1개씩 동시에 작성하는 코드이다.

import http from "k6/http";
import { check, sleep } from "k6";

const env = JSON.parse(open("./env.json"));
const authorizationToken = env.authorizationToken;
const baseUrl = env.baseUrl;

export let options = {
  vus: 100, // 동시에 실행할 가상 사용자 수
  iterations: 100, // 반복 횟수 (총 요청 수 : 반복 횟수 / 동시 사용자 수)
  duration: "10m", // 최대 테스트 지속 시간
};

export default function () {
  const apiUrl = baseUrl + "/api/comments";
  const params = {
    headers: {
      Authorization: "Bearer " + authorizationToken,
      "Content-Type": "application/json",
    },
  };

  // 151부터 650까지 임의의 postId 생성
  const postId = __VU + 150;
  const payload = JSON.stringify({
    content: "Test 1",
    postId: postId,
  });

  const res = http.post(apiUrl, payload, params);
  check(res, {
    "is status 200": (r) => r.status === 200,
  });
}

낙관적 락을 사용한 경우

비관적 락을 사용한 경우

다행히(?) 비관적 락이 더 오래 걸리는 경우가 발생했다.
경합이 전혀 발생 안하는 상황에서 비관적 락이 더 오래 걸리는 이유는 락 획득, 락 관리, 락 해제에 드는 비용이 기본적으로 있기 때문이다.

비관적 락 기본 비용

  1. 락 획득
  • 락 요청 : SELECT ... FOR UPDATE 문을 사용해서 락 요청
  • 락 관리자: 데이터베이스의 락 관리자(lock manager)는 락 요청을 처리합니다. 락 관리자는 해당 자원에 대한 현재 락 상태를 확인
  • 락 부여: 자원이 사용 중이 아니면, 락 관리자는 트랜잭션에 배타적 락을 부여
  1. 락 관리
  • 락 테이블 유지: 데이터베이스 시스템은 락 테이블(lock table)이라는 내부 구조를 사용하여 각 자원의 락 상태를 기록

    	- 이 테이블은 자원의 ID, 락을 소유한 트랜잭션 ID, 락 타입(공유 또는 배타적) 등을 포함
Resource IDTransaction IDLock Type
11001EXCLUSIVE
21002SHARED
  • 락 대기열 유지: 만약 자원이 이미 다른 트랜잭션에 의해 락이 걸려 있다면, 락 관리자(lock manager)는 현재 트랜잭션을 대기열에 추가
  1. 락 해제
  • 락 해제 요청: 트랜잭션이 종료되면, 락 해제 요청이 락 관리자에게 전송
  • 락 해제: 락 관리자는 락 테이블에서 해당 트랜잭션의 락을 제거합니다. 자원이 해제되고, 대기 중인 다른 트랜잭션에게 락이 부여

결론

10000 dau 서비스를 꿈꾸며 비관락을 사용하기로 했다.

0개의 댓글