PORTFOLIO/TROUBLESHOOTING

[CodeChef - 트러블 슈팅] 데이터 수호자들 : 동시성 이슈와의 싸움에서 살아남기.

jki09871 2025. 1. 6. 15:45

동시성 제어 방식의 발전 과정 및 성능 분석

이번 성능 테스트는 DB 바로 저장 방식에서 시작하여 낙관적 락, 비관적 락, 분산 락으로 발전하는 과정을 통해, 각 방식이 가진 성능적 특징과 개선 효과를 평가했습니다. 각 방식의 성능을 측정한 결과는 다음과 같습니다.

  1. DB 바로 저장 > 2. 낙관적 락 > 3. 비관적 락 > 4. 분산 락 순으로 개선하였으며, 각 방식의 주요 특징과 개선된 이유를 구체적으로 설명하겠습니다.

1. DB 바로 저장

DB 바로 저장 방식은 동시성 제어가 없이 여러 요청이 동시에 데이터베이스에 접근하는 방식입니다. 간단하게 구현할 수 있지만, 동시성 문제가 발생할 가능성이 큽니다.

  • 스레드 활성화 시간: 활성 스레드가 초반에 급격히 증가하다가 서서히 감소하는 경향을 보입니다. 여러 스레드가 동시에 DB에 접근하면서 충돌이 빈번하게 발생하기 때문에 초기 부하가 상당히 큽니다.
  • 응답 시간: 응답 시간이 매우 불안정하게 증가하며, 스레드 충돌로 인해 응답 속도가 점점 느려지는 경향을 보입니다.
  • 트랜잭션 수: 트랜잭션 수는 초반에 급격히 증가하지만 이후에는 부하가 심해지며 속도가 줄어듭니다.
  • 포인트 지급 상태: 초기에 포인트가 비정상적으로 지급되는 경우가 발생하며, 데이터 일관성에 문제가 있습니다.

테스트 코드 및 결과

@Test
@DisplayName("락없이 테스트")
public void testWithoutLock() throws InterruptedException {
    // 동작 시간 측정 시작
    long startTime = System.currentTimeMillis();

    // given
    Long id = 1L;

    // when
    ExecutorService executorService = Executors.newFixedThreadPool(10);
    CountDownLatch latch = new CountDownLatch(1000);
    for (int i = 0; i < 1000; i++) {
        executorService.execute(() -> {
            try {
                eventService.eventPointsDirectDB(id);
            } finally {
                latch.countDown();
            }
        });
    }
    latch.await();
    executorService.shutdown();

    // then
    User updatedUser = userRepository.findById(1L).orElseThrow();
    System.out.println("저장된 포인트: " + updatedUser.getPoint());

    // 동작 시간 측정 종료
    long endTime = System.currentTimeMillis();
    System.out.println("Execution time (DB 바로 저장): " + (endTime - startTime) + " ms");
}

결과

저장된 포인트: 101000
Execution time (DB 바로 저장): 2834 ms

DB 바로 저장 방식은 성능 저하와 데이터 불일치 문제로 인해 개선이 필요했습니다.


2. 낙관적 락

낙관적 락은 데이터 경합이 발생할 가능성을 낮게 보고, 충돌 시에만 재시도하도록 하는 방식입니다. 동시성 제어를 위한 첫 개선 단계였습니다.

  • 스레드 활성화 시간: 스레드 활성화가 여전히 빠르며, 재시도로 인해 초기에는 안정적이지만 경합이 잦아지면 스레드 수가 급격히 증가하는 경향이 나타났습니다.
  • 응답 시간: 낙관적 락은 초기에는 응답 시간이 안정적이었으나, 충돌이 빈번해질수록 응답 시간이 불안정해지고 지연이 발생했습니다.
  • 트랜잭션 수: 비정상적인 증가 없이 일정 수준을 유지했지만, 충돌이 증가할수록 감소하는 경향이 나타났습니다.
  • 포인트 지급 상태: DB 바로 저장보다 일관성은 높아졌으나, 여전히 일부 요청이 누락되거나 재시도 시 데이터 충돌이 발생할 수 있었습니다.

테스트 코드 및 결과

@Test
@DisplayName("낙관락 테스트")
public void testOptimistic() throws InterruptedException {

    // 동작 시간 측정 시작
    long startTime = System.currentTimeMillis();

    // given
    Long id = 2L;
    AtomicInteger totalFailures = new AtomicInteger(0);

    // when
    ExecutorService executorService = Executors.newFixedThreadPool(100);
    CountDownLatch latch = new CountDownLatch(1000);

    for (int i = 0; i < 1000; i++) {
        executorService.execute(() -> {
            try {
                int attempts = eventService.eventPointsOptimisticLock(id);
                totalFailures.addAndGet(attempts);
            } finally {
                latch.countDown();
            }
        });
    }
    latch.await();
    executorService.shutdown();

    // then
    User updatedUser = userRepository.findById(id).orElseThrow();
    System.out.println("Total attempts: " + totalFailures.get());
    System.out.println("Final point count (optimistic): " + updatedUser.getPoint());

    // 동작 시간 측정 종료
    long endTime = System.currentTimeMillis();
    System.out.println("Execution time (Optimistic Lock): " + (endTime - startTime) + " ms");
}

결과

Total attempts(재시도 횟수): 11239
Final point count (optimistic): 1000000
Execution time (Optimistic Lock): 11026 ms

낙관적 락을 통해 일관성 문제를 개선했지만, 충돌 발생 시 성능 저하가 있어 비관적 락으로 개선이 필요했습니다.


3. 비관적 락

비관적 락은 데이터에 접근할 때마다 락을 걸어 다른 스레드가 접근하지 못하도록 제어합니다. 데이터 일관성은 높아졌지만 락 대기로 인한 성능 저하가 발생할 수 있습니다.

  • 스레드 활성화 시간: 활성 스레드가 일정 수준으로 유지되며, 다른 스레드가 대기 상태로 줄어듭니다. 스레드 경합이 줄어 안정적인 경향을 보입니다.
  • 응답 시간: 안정적이지만 락 대기로 인해 응답 시간이 일정 수준에서 제한되며, 평균 응답 시간이 상승합니다.
  • 트랜잭션 수: 안정적이지만 락 대기 시간이 길어짐에 따라 처리 속도가 느려졌습니다.
  • 포인트 지급 상태: 데이터의 일관성은 보장되지만, 응답 지연 문제가 발생하여 더욱 빠르고 효율적인 방법으로의 개선이 필요했습니다.

테스트 코드 및 결과

@Test
@DisplayName("비관락 테스트")
public void testPessimistic() throws InterruptedException {
    // Final like count (pessimistic): 1300000
    //Execution time (Pessimistic Lock): 7452 ms

    long startTime = System.currentTimeMillis();

    // given
    Long id = 3L;

    // when
    ExecutorService executorService = Executors.newFixedThreadPool(10);
    CountDownLatch latch = new CountDownLatch(1000);

    for (int i = 0; i < 1000; i++) {
        executorService.execute(() -> {
            try {
                eventService.eventPointsPessimisticLock(id);
            } finally {
                latch.countDown();
            }
        });
    }
    latch.await();
    executorService.shutdown();

    // then
    User updatedUser = userRepository.findById(id).orElseThrow();
    System.out.println("Final like count (pessimistic): " + updatedUser.getPoint());

    // 동작 시간 측정 종료
    long endTime = System.currentTimeMillis();
    System.out.println("Execution time (Pessimistic Lock): " + (endTime - startTime) + " ms");
}

결과

Final like count (pessimistic): 1000000
Execution time (Pessimistic Lock): 8182 ms

비관적 락은 데이터 일관성 측면에서는 효과적이지만, 락 대기로 인해 성능이 저하되어 더 나은 성능을 위해 분산락을 도입했습니다.


4. 분산 락

분산 락은 Redisson의 분산 락 기능을 사용하여 멀티 서버 환경에서도 동시성 제어가 가능하도록 하였으며, 최종적으로 데이터 일관성과 성능을 동시에 달성할 수 있었습니다.

  • 스레드 활성화 시간: 스레드 수가 일정하게 유지되며 효율적으로 감소합니다. 락을 통해 경합이 제어되면서도 멀티 서버 환경에서 안정적인 스레드 수를 유지할 수 있습니다.
  • 응답 시간: 응답 시간이 가장 안정적이며, 락 대기 시간을 최적화하여 일관성 있는 응답 시간을 제공합니다.
  • 트랜잭션 수: 트랜잭션 수가 일정하게 유지되며, 스레드 수와 응답 시간 모두에서 효율적인 성능을 보여줍니다.
  • 포인트 지급 상태: 데이터 일관성을 완벽히 보장하면서도 성능과 응답 시간이 모두 안정적입니다.

테스트 코드 및 결과

@Test
@DisplayName("분산락 테스트")
public void 분산락_사용() throws InterruptedException {
    // User points after event: 100000
    // Execution time (Distributed Lock): 4558 ms

    // 동작 시간 측정 시작
    long startTime = System.currentTimeMillis();

    Long id = 5L;

    ExecutorService executorService = Executors.newFixedThreadPool(10);
    CountDownLatch latch = new CountDownLatch(1000);

    for (int i = 0; i < 1000; i++) {
        executorService.submit(() -> {
            try {
                eventService.eventPoints(id);
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                latch.countDown();
            }
        });
    }
    latch.await();
    executorService.shutdown();

    User updatedUser = userRepository.findById(id).orElseThrow();
    System.out.println("User points after event: " + updatedUser.getPoint());

    // 동작 시간 측정 종료
    long endTime = System.currentTimeMillis();
    System.out.println("Execution time (Distributed Lock): " + (endTime - startTime) + " ms");
}

결과

User points after event: 1000000
Execution time (Distributed Lock): 5235 ms

분산 락을 통해 멀티 서버 환경에서의 동시성 문제와 데이터 일관성을 최적화할 수 있었으며, 가장 효과적인 성능을 보였습니다.


결론 및 인사이트

각 방식이 발전하면서 성능 및 일관성 문제가 점진적으로 개선되었습니다.

  • DB 바로 저장: 성능과 일관성 문제가 모두 발생하여 개선 필요성이 있었습니다.
  • 낙관적 락: 충돌 시 성능 저하가 발생하여, 비관적 락으로 발전했습니다.
  • 비관적 락: 데이터 일관성은 개선되었으나 락 대기로 성능이 저하되었습니다.
  • 분산 락: 멀티 서버 환경에서 성능과 일관성을 모두 최적화할 수 있었습니다.

최종적으로 분산 락이 가장 안정적이고 성능이 우수하여, 대규모 동시성 환경에서 적합 했습니다.

Redis 분산 락은 데이터 충돌을 방지하고 빠른 성능을 제공하지만, 요청 순서가 보장되지 않는다는 한계가 있습니다. 이 단점은 Redis Stream을 활용한 작업 순서 관리, 타임스탬프 정렬, 또는 **메시지 큐(Kafka 등)**를 도입해 추후 개선할 계획입니다. 현재 프로젝트에서는 정확성과 성능이 더 중요하다고 판단하여, Redis 분산 락이 가장 적합한 선택으로 평가되었습니다.

 

방식 응답 시간 (Execution Time) 트랜잭션 수 (Transactions) 최종 포인트 지급 상태 (Final Points)

방식 응답 시간 (Execution Time) 트랜잭션 수 (Transactions) 최종 포인트 지급 상태 (Final Points)
DB 바로 저장 2834 ms 급격히 증가 후 감소 101000
낙관적 락 11026 ms 일정하게 유지 1000000
비관적 락 7452 ms 일정하게 유지 1000000
분산 락 5235 ms 안정적이고 최적화된 성능 1000000