[Spring] 동시성 처리 방법 비교해보기

Tak Jeon·2025년 5월 27일
0

Spring

목록 보기
7/8
post-thumbnail

실생활에서는 다양한 위치에서 동시성 처리를 필요로 하는 곳이 많습니다.

예를 들어, 쿠폰 선착순 발급 시스템, 티켓 예매 시스템, 은행 거래 처리 시스템 등등 다양한 분야에서 사용되고 있습니다.

이번 포스팅에서는 동시성 처리를 구현할 수 있는 방법에 대하여 알아보고, 테스트 프로젝트를 만들어 각 방법 별 성능 비교를 해보는 시간을 가져보겠습니다.

동시성 처리란?

동시성 처리는 여러 사용자 또는 Thread가 동시에 공유 자원에 접근할 때 발생할 수 있는 Race Condition을 방지하고, 데이터 무결성을 보장하며 충돌을 방지하는 기술입니다.

동시성 문제는 중복 발급, 초과 발급, 데이터 불일치로 이어질 수 있으므로, 락이나 원자적 연산을 통해 관리할 수 있습니다.

예를 들어, 티켓팅 시스템이나 반짝 세일 시스템에서 동시성 처리가 존재하지 않는다면 좌석이 중복 예약 되거나 재고가 초과 소진될 수 있습니다.

아래에서는 동시성 처리를 구현하기 위한 7가지 방법에 대하여 알아보겠습니다.


동시성 처리 방법

Java ReentrantLock

ReentrantLockjava.util.concurrent.locks 패키지에 포함된 락 매커니즘으로, 단일 JVM 내에서 스레드 간 공유 자원 접근을 제어합니다.

재진입 가능 락(Reentrant Lock)이라는 이름 처럼, 동일 스레드가 락을 여러번 획득할 수 있어 동시성 제어에 사용될 수 있습니다.

해당 방법은 단일 서버 환경에서 간단한 동시성 제어에 적합하지만, 다중 서버로 구성된 분산 환경에서는 동작하지 않습니다.

따라서 단일 서버 환경에서 동시성 처리를 한다고 가정할 경우 활용될 수 있습니다.

장단점 및 적합성

장점단점
단일 JVM 내에서는 빠른 동기화가 가능분산 환경에서는 동작이 불가
구현이 간단함스케일링이 제한됨
  • 단일 서버에서 간단한 동시성 제어에 적합합니다.
  • 수십만건의 요청을 다중 서버로 처리하는 분산 환경에서는 사용이 불가능 합니다.
  • 따라서, 단일 서버로 테스트하거나 소규모 시스템에서만 유효합니다.

구현 예시

엔티티

@Entity
public class CouponEvent {

    @Id 
    private Long id;
    private int totalCoupons = 10000;
    private int issuedCoupons = 0;
    
}

서비스

@Service
@RequiredArgsConstructor
public class CouponService {
    private final ReentrantLock lock = new ReentrantLock();
    private final CouponEventRepository couponEventRepository;
    private final CouponIssueRepository couponIssueRepository;

    public void issueCoupon(Long eventId, Long userId) {
        lock.lock();
        try {
            if (issueRepository.existsByEventIdAndUserId(eventId, userId)) {
                throw new RuntimeException("이미 발급받은 쿠폰");
            }
            CouponEvent couponEvent = couponEventRepository.findById(eventId)
                .orElseThrow(() -> new RuntimeException("이벤트 없음"));
            if (couponEvent.getIssuedCoupons() >= couponEvent.getTotalCoupons()) {
                throw new RuntimeException("쿠폰 소진");
            }
            event.setIssuedCoupons(couponEvent.getIssuedCoupons() + 1);
            couponEventRepository.save(couponEvent);
            couponIssueRepository.save(new CouponIssue(eventId, userId));
        } finally {
            lock.unlock();
        }
    }
}

MySQL Optimistic Lock(낙관적 락)

Optimistic Lock은 데이터 충돌이 드물다고 가정하고, 데이터 수정 시 @Version 필드를 사용해 데이터 충돌을 감지하고, 충돌 시 재시도를 통해 동시성을 관리합니다.

트랜잭션 시작 시 데이터를 읽고, 수정 시 저장된 버전과 현재 버전을 비교해 일치하면 업데이트를 허용합니다.

충돌이 발생하면 OptimisticLockException이 발생하며, 재시도 로직을 통해 문제를 해결합니다.

해당 방법은 락 대기 없이 높은 동시성을 제공하지만, 충돌 빈도가 높을 경우에 재시도 오버헤드가 발생할 수 있습니다.

낙관적 락은 데이터베이스 자체에서 동시성을 제어하므로, 분산 환경에서도 동작하며 Redis 같은 추가 인프라가 필요 없다는 장점이 존재합니다.

장단점 및 적합성

장점단점
락을 기다리는 대기가 없기 때문에 높은 동시성 처리가 가능충돌 시 재시도 과정에서 오버헤드가 발생할 수 있음
DB만으로도 구현이 가능함재시도 로직을 구현해야 함
  • 수십만건 요청 시 락 대기 없이 처리가 가능합니다.
  • 충돌 빈도가 높을 경우 재시도 로직을 사용해야 하기 때문에 성능 저하가 일어날 수 있습니다.
  • 따라서, 충돌 가능성이 낮은 환경 또는 DB 중심 시스템에서 사용이 가능합니다.

구현 예시

엔티티

@Entity
public class CouponEvent {

    @Id 
    private Long id;
    private int totalCoupons = 10000;
    private int issuedCoupons = 0;
    @Version 
    private Long version;
    
}

서비스

@Service
@RequiredArgsConstructor
public class CouponService {
    private final CouponEventRepository couponEventRepository;
    private final CouponIssueRepository couponIssueRepository;

    @Transactional
    public void issueCoupon(Long eventId, Long userId) {
        if (couponIssueRepository.existsByEventIdAndUserId(eventId, userId)) {
            throw new RuntimeException("이미 발급받은 쿠폰");
        }
        CouponEvent couponEvent = couponEventRepository.findById(eventId)
            .orElseThrow(() -> new RuntimeException("이벤트 없음"));
        if (couponEvent.getIssuedCoupons() >= couponEvent.getTotalCoupons()) {
            throw new RuntimeException("쿠폰 소진");
        }
        couponEvent.setIssuedCoupons(couponEvent.getIssuedCoupons() + 1);
        couponEventRepository.save(couponEvent);
        couponIssueRepository.save(new CouponIssue(eventId, userId));
    }

    public void issueCouponWithRetry(Long eventId, Long userId, int maxRetries) {
        int attempt = 0;
        while (attempt < maxRetries) {
            try {
                issueCoupon(eventId, userId);
                return;
            } catch (OptimisticLockException e) {
                attempt++;
                if (attempt >= maxRetries) {
                    throw new RuntimeException("재시도 실패");
                }
                Thread.sleep(50);
            }
        }
    }
}

MySQL Pessimistic Lock(비관적 락)

Pessimistic Lock은 데이터 충돌을 사전에 방지하기 위해 특정 행을 락 걸어 다른 트랜잭션이 접근하지 못하도록 차단하는 방식입니다.

Spring Data JPA에서는 @Lock(LockModeType.PESSIMISTIC_WRITE)를 사용하거나, 직접 SELECT … FOR UPDATE 쿼리를 실행해 락을 설정합니다.

락이 걸린 동안 다른 트랜잭션은 해당 데이터에 접근할 수 없으므로 데이터 무결성이 보장되는 방식입니다.

하지만 락 대기로 인한 병목으로 성능 저하가 발생할 수 있습니다.

장단점 및 적합성

장점단점
강력한 데이터 무결성이 가능함락 대기로 병목이 발생 가능함
Spring JPA와 통합이 용이함대규모 요청 시 성능 저하
  • 데이터 무결성을 최우선으로 하는 프로젝트에 적합합니다.
  • 하지만 대규모 요청 시 락 대기로 인하여 병목이 발생할 수 있고 그에 따라 성능 저하가 일어날 수 있습니다.
  • 따라서 소규모 요청과 높은 무결성을 요구하는 프로젝트에 적합합니다.

구현 예시

엔티티

@Entity
public class CouponEvent {

    @Id 
    private Long id;
    private int totalCoupons = 10000;
    private int issuedCoupons = 0;
    @Version 
    private Long version;
    
}

서비스

@Service
@RequiredArgsConstructor
public class CouponService {
    private final CouponEventRepository couponEventRepository;
    private final CouponIssueRepository couponIssueRepository;

    @Transactional
    public void issueCoupon(Long eventId, Long userId) {
        if (couponIssueRepository.existsByEventIdAndUserId(eventId, userId)) {
            throw new RuntimeException("이미 발급받은 쿠폰");
        }
        CouponEvent couponEvent = couponEventRepository.findByIdWithLock(eventId)
            .orElseThrow(() -> new RuntimeException("이벤트 없음"));
        if (couponEvent.getIssuedCoupons() >= couponEvent.getTotalCoupons()) {
            throw new RuntimeException("쿠폰 소진");
        }
        couponEvent.setIssuedCoupons(couponEvent.getIssuedCoupons() + 1);
        couponEventRepository.save(couponEvent);
        couponIssueRepository.save(new CouponIssue(eventId, userId));
    }
}

리포지토리

public interface CouponEventRepository extends JpaRepository<CouponEvent, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT e FROM CouponEvent e WHERE e.id = :id")
    Optional<CouponEvent> findByIdWithLock(@Param("id") Long id);
}

MySQL NamedLock

MySQL의 NamedLockGET_LOCK / RELEASE_LOCK을 활용한 애플리케이션 수준에서 락을 관리하는 방식입니다.

특정 테이블 행을 락 걸지 않고, 사용자 정의 락을 통해 락을 생성합니다.

해당 방법은 데이터베이스에서 락을 관리하므로, 분산 환경에서 동시성을 제어할 수 있습니다.

또한, Redis 같은 추가 인프라가 없는 환경에서도 동작이 가능하다는 장점이 존재합니다.

GET_LOCK은 락을 획득하고, RELEASE_LOCK은 락을 해제합니다.

장단점 및 적합성

장점단점
Redis 없이 분산 락 구현이 가능함DB에 부하가 증가함
구현이 간단함대규모 요청 시 병목이 발생 가능
  • Redis 인프라 없이 분산 락 구현이 가능합니다.
  • DB 부하로 대규모 요청 처리 시 병목 현상이 발생 가능합니다.
  • 따라서 DB 기반 시스템 및 Redis를 사용하지 않는 환경에 적합합니다.

사용 예시

엔티티

@Entity
public class CouponEvent {

    @Id 
    private Long id;
    private int totalCoupons = 10000;
    private int issuedCoupons = 0;
    @Version 
    private Long version;
    
}

NamedLock

@Component
@RequiredArgsConstructor
public class MySQLNamedLock {
    private final DataSource dataSource;

    public void acquireLock(String lockName, long timeoutSeconds) {
        try (Connection conn = dataSource.getConnection()) {
            conn.createStatement().executeUpdate(
                "SELECT GET_LOCK('" + lockName + "', " + timeoutSeconds + ")");
        } catch (SQLException e) {
            throw new RuntimeException("락 획득 실패", e);
        }
    }

    public void releaseLock(String lockName) {
        try (Connection conn = dataSource.getConnection()) {
            conn.createStatement().executeUpdate(
                "SELECT RELEASE_LOCK('" + lockName + "')");
        } catch (SQLException e) {
            throw new RuntimeException("락 해제 실패", e);
        }
    }
}

서비스

@Service
@RequiredArgsConstructor
public class CouponService {
    private final MySQLNamedLock namedLock;
    private final CouponEventRepository couponEventRepository;
    private final CouponIssueRepository couponIssueRepository;

    public void issueCoupon(Long eventId, Long userId) {
        String lockName = "coupon:lock:" + eventId;
        namedLock.acquireLock(lockName, 10);
        try {
            if (couponIssueRepository.existsByEventIdAndUserId(eventId, userId)) {
                throw new RuntimeException("이미 발급받은 쿠폰");
            }
            CouponEvent couponEvent = couponEventRepository.findById(eventId)
                .orElseThrow(() -> new RuntimeException("이벤트 없음"));
            if (couponEvent.getIssuedCoupons() >= couponEvent.getTotalCoupons()) {
                throw new RuntimeException("쿠폰 소진");
            }
            couponEvent.setIssuedCoupons(couponEvent.getIssuedCoupons() + 1);
            couponEventRepository.save(couponEvent);
            couponIssueRepository.save(new CouponIssue(eventId, userId));
        } finally {
            namedLock.releaseLock(lockName);
        }
    }
}

Redis Lettuce

Lettuce는 Spring Data Redis와 함께 사용되는 Redis Client로, Redis의 SETNX 명령어를 활용해 분산 락을 구현할 수 있습니다.

SETNX는 키가 존재하지 않을 때만 값을 설정하는 원자적 연산으로, 이를 통해 락을 획득하고 해제할 수 있습니다.

Lettuce는 Redisson에 비해 경량화 되어 있어 간단한 락 구현에 적합합니다.

락 타임아웃과 해제를 직접 관리 해야한다는 단점은 존재합니다.

장단점 및 적합성

장점단점
처리 속도가 빠름락에 대한 관리를 직접 구현해야함
분산 환경에서도 지원Redisson보다 기능이 제한 됨
  • 분산 락 구현이 간단하고, 처리 속도가 빠릅니다.
  • 하지만 락 Timeout, 재시도 로직을 직접 관리해야 합니다.
  • 따라서 Redis 인프라가 구축된 환경 및 간단한 락만 필요할 경우에 적합합니다.

사용 예시

build.gradle

dependencies {
		//Spring Data Redis 추가
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}

엔티티

@Entity
public class CouponEvent {

    @Id 
    private Long id;
    private int totalCoupons = 10000;
    private int issuedCoupons = 0;
    @Version 
    private Long version;
    
}

서비스

@Service
@RequiredArgsConstructor
public class CouponService {
    private final StringRedisTemplate redisTemplate;
    private final CouponEventRepository couponEventRepository;
    private final CouponIssueRepository couponIssueRepository;

    public void issueCoupon(Long eventId, Long userId) {
        String lockKey = "coupon:lock:" + eventId;
        String lockValue = UUID.randomUUID().toString();
        try {
            Boolean locked = redisTemplate.opsForValue()
                .setIfAbsent(lockKey, lockValue, 30, TimeUnit.SECONDS);
            if (locked != null && locked) {
                if (couponIssueRepository.existsByEventIdAndUserId(eventId, userId)) {
                    throw new RuntimeException("이미 발급받은 쿠폰");
                }
                CouponEvent couponEvent = couponEventRepository.findById(eventId)
                    .orElseThrow(() -> new RuntimeException("이벤트 없음"));
                if (couponEvent.getIssuedCoupons() >= couponEvent.getTotalCoupons()) {
                    throw new RuntimeException("쿠폰 소진");
                }
                couponEvent.setIssuedCoupons(couponEvent.getIssuedCoupons() + 1);
                couponEventRepository.save(couponEvent);
                couponIssueRepository.save(new CouponIssue(eventId, userId));
            } else {
                throw new RuntimeException("락 획득 실패");
            }
        } finally {
            String currentValue = redisTemplate.opsForValue().get(lockKey);
            if (lockValue.equals(currentValue)) {
                redisTemplate.delete(lockKey);
            }
        }
    }
}

Redisson 분산 락

RedissonRedis 기반의 고급 분산 락 라이브러리로, 자동 락 연장, 재진입 락 등 다양한 기능을 제공합니다.

자동 락 연장 기능으로 락 유지 시간을 동적으로 관리하며, 재진입 락, 공정 락 등 고급 기능을 지원합니다.

Redis의 빠른 처리 속도와 결합해 대규모 동시 요청에 안정적인 장점을 가지고 있습니다.

장단점 및 적합성

장점단점
락 기능 존재(자동 연장 등)Redis 인프라가 필요함
대규모 요청에도 안정적으로 작동함추가 라이브러리 의존성이 필요함
  • 대규모 요청 처리가 가능하고, 분산 환경에 적합합니다.
  • 하지만 Redis 의존성이 필요합니다.
  • 따라서 Redis 인프라가 구축된 환경 및 복잡한 락 관리를 사용할 경우 적합합니다.

사용 예시

build.gradle

dependencies {
    //Redisson
    implementation 'org.redisson:redisson-spring-boot-starter:{version}'
}

엔티티

@Entity
public class CouponEvent {

    @Id 
    private Long id;
    private int totalCoupons = 10000;
    private int issuedCoupons = 0;
    @Version 
    private Long version;
    
}

서비스

@Service
@RequiredArgsConstructor
public class CouponService {
    private final RedissonClient redissonClient;
    private final CouponEventRepository couponEventRepository;
    private final CouponIssueRepository couponIssueRepository;

    public void issueCoupon(Long eventId, Long userId) {
        RLock lock = redissonClient.getLock("coupon:lock:" + eventId);
        try {
            if (lock.tryLock(10, 30, TimeUnit.SECONDS)) {
                if (couponIssueRepository.existsByEventIdAndUserId(eventId, userId)) {
                    throw new RuntimeException("이미 발급받은 쿠폰");
                }
                CouponEvent couponEvent = couponEventRepository.findById(eventId)
                    .orElseThrow(() -> new RuntimeException("이벤트 없음"));
                if (couponEvent.getIssuedCoupons() >= couponEvent.getTotalCoupons()) {
                    throw new RuntimeException("쿠폰 소진");
                }
                couponEvent.setIssuedCoupons(couponEvent.getIssuedCoupons() + 1);
                couponEventRepository.save(couponEvent);
                couponIssueRepository.save(new CouponIssue(eventId, userId));
            } else {
                throw new RuntimeException("락 획득 실패");
            }
        } catch (InterruptedException e) {
            throw new RuntimeException("락 처리 중 오류", e);
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

Redis INCR OR DECR

RedisINCR 명령어는 원자적 연산으로 카운터를 증가시키며, 락 없이도 데이터 무결성을 보장합니다.

RedisDECR 명령어는 원자적 연산으로 카운터를 감소시키며, 락 없이도 데이터 무결성을 보장합니다.

해당 방법은 락 오버헤드가 없어 매우 빠르고 Redis의 원자적 연산으로 경쟁 조건을 방지합니다.

하지만 사용자 별 중복 발급 방지와 같은 추가 로직은 별도로 구현해야 한다는 단점이 존재합니다.

장단점 및 적합성

장점단점
락 없이 처리가 가능함복잡한 로직을 추가로 구현해야 함
대규모 요청에도 안정적으로 작동함추가 라이브러리 의존성이 필요함
  • 락 오버헤드 없이 빠른 처리가 가능합니다.
  • 하지만 사용자 별 중복 방지 로직이 별도로 구현되어야 합니다.
  • 따라서 간단한 카운터 기반 발급 및 고성능이 요구 되는 경우에 적합합니다.

사용 예시

엔티티

build.gradle

dependencies {
		//Spring Data Redis 추가
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}

엔티티

@Entity
public class CouponEvent {

    @Id 
    private Long id;
    private int totalCoupons = 10000;
    private int issuedCoupons = 0;
    @Version 
    private Long version;
    
}

서비스

@Service
@RequiredArgsConstructor
public class CouponService {
    private final StringRedisTemplate redisTemplate;
    private final CouponIssueRepository couponIssueRepository;

    public void issueCoupon(Long eventId, Long userId) {
        if (couponIssueRepository.existsByEventIdAndUserId(eventId, userId)) {
            throw new RuntimeException("이미 발급받은 쿠폰");
        }
        String counterKey = "coupon:counter:" + eventId;
        Long issuedCount = redisTemplate.opsForValue().increment(counterKey);
        if (issuedCount == null || issuedCount > 10000) {
            redisTemplate.opsForValue().decrement(counterKey);
            throw new RuntimeException("쿠폰 소진");
        }
        couponIssueRepository.save(new CouponIssue(eventId, userId));
    }
}

Redis 초기화 로직

public void initializeEvent(Long eventId) {
    redisTemplate.opsForValue().set("coupon:counter:" + eventId, "0");
}

public void initializeEvent(Long eventId, Long amount) {
    redisTemplate.opsForValue().set("coupon:counter:" + eventId, amount);
}

예제 프로젝트로 성능 테스트 해보기

해당 포스팅에서 소개한 최적화 기법들을 테스트할 Spring Boot 프로젝트를 구성해보고. 테스트 결과를 알아보겠습니다.

테스트 프로젝트는 선착순 쿠폰을 발급받는 기능을 구현하여 테스트 하겠습니다.

환경 및 설정

프로젝트 환경

Java : 17
Gradle : 8.13
Spring Boot : 3.5.0
MySQL : 8.0
Redis : 7.0
Docker Compose : 3.8
Grafana : 11.0.0
Prometheus : 2.53.0

Dependency

dependencies {

    // Spring Data JPA
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

    // Spring Data Redis
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'

    // Spring Web
    implementation 'org.springframework.boot:spring-boot-starter-web'

    // Redisson
    implementation 'org.redisson:redisson-spring-boot-starter:3.37.0'

    // Lombok
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'

    // MySQL
    runtimeOnly 'com.mysql:mysql-connector-j'

    // Swagger
    implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6'

    // Test
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

application.yml

spring:
  application:
    name: Concurrent
  # MySQL
  datasource:
    url: jdbc:mysql://localhost:3306/concurrent-mysql?rewriteBatchedStatements=true
    username: test
    password: test
    driver-class-name: com.mysql.cj.jdbc.Driver
    hikari:
      maximum-pool-size: 50
      minimum-idle: 10

  # JPA
  jpa:
    hibernate:
      ddl-auto: create
    show-sql: false
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MySQLDialect
        format_sql: true

  # Redis
  data:
    redis:
      host: localhost
      port: 6379

docker-compose.yml

version: '3.8'

services:
  db:
    image: mysql:8.0
    container_name: concurrent-mysql
    environment:
      - "MYSQL_RANDOM_ROOT_PASSWORD=1111"
      - "MYSQL_DATABASE=concurrent-mysql"
      - "MYSQL_USER=test"
      - "MYSQL_PASSWORD=test"
    ports:
      - "3306:3306"
    healthcheck:
      test: [ "CMD-SHELL", "mysqladmin ping -h localhost -u root -p1111" ]
      interval: 5s
      retries: 10
    restart: on-failure

  redis:
    image: redis:7.0
    container_name: concurrent-redis
    ports:
      - "6379:6379"
    healthcheck:
      test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ]
      interval: 5s
      retries: 10
    restart: on-fa

테스트 설정 및 구성

유저

import com.example.concurrent.common.entity.BaseEntity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Table(name = "users")
@Getter
@NoArgsConstructor
public class User extends BaseEntity {

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

    @Column(nullable = false)
    private String username;

    @Column(nullable = false)
    private Integer age;

    private User(String username, Integer age) {
        this.username = username;
        this.age = age;
    }

    public static User create(String username, Integer age) {
        return new User(username, age);
    }
}

쿠폰

import com.example.concurrent.common.entity.BaseEntity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Table(name = "coupons")
@Getter
@NoArgsConstructor
public class Coupon extends BaseEntity {

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

    @Column(nullable = false)
    private String couponName;

    @Column(nullable = false)
    private Long discountPrice;

    @Column(nullable = false)
    private Integer totalAmount;

    @Column(nullable = false)
    private Integer issuedAmount;

    public Coupon(String couponName, Long discountPrice, Integer totalAmount, Integer issuedAmount) {
        this.couponName = couponName;
        this.discountPrice = discountPrice;
        this.totalAmount = totalAmount;
        this.issuedAmount = issuedAmount;
    }

    public static Coupon create(String couponName, Long discountPrice, Integer totalAmount, Integer issuedAmount) {
        return new Coupon(couponName, discountPrice, totalAmount, issuedAmount);
    }
}

유저쿠폰

import com.example.concurrent.common.entity.BaseEntity;
import com.example.concurrent.domain.coupon.entity.Coupon;
import com.example.concurrent.domain.user.entity.User;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Table(name = "usercoupons")
@Getter
@NoArgsConstructor
public class UserCoupon extends BaseEntity {

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

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "coupon_id", nullable = false)
    private Coupon coupon;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    private UserCoupon(Coupon coupon, User user) {
        this.coupon = coupon;
        this.user = user;
    }

    public static UserCoupon create(Coupon coupon, User user) {
        return new UserCoupon(coupon, user);
    }
}

데이터 초기 설정

데이터 처리 테스트를 하려면 데이터가 존재해야하기때문에, 유저 1000건, 쿠폰 생성을 하여 저장합니다.

유저 데이터 삽입

public UserCreateResponse createUsers(Integer amount) {
        if (userRepository.count() != 0) {
            throw new CustomException(HttpStatus.BAD_REQUEST, "이미 유저가 생성되어있습니다.");
        }

        List<User> users = new ArrayList<>();
        for (int i = 0; i < amount; i++) {
            String name = "Name_" + UUID.randomUUID().toString().substring(0, 8);
            int age = 18 + ThreadLocalRandom.current().nextInt(82);
            users.add(User.create(name, age));

            if (users.size() >= Const.BATCH_SIZE) {
                userJdbcRepository.saveAll(users);
                users.clear();
            }
        }
        if (!users.isEmpty()) {
            userRepository.saveAll(users);
        }

        return UserCreateResponse.of(amount + "개 유저 생성에 성공하였습니다.");
    }

쿠폰 데이터 삽입

public CouponCreateResponse createCoupon(String couponName, Integer totalAmount, Long discountPrice) {
        if(couponRepository.existsByCouponName(couponName)) {
            throw new CustomException(HttpStatus.BAD_REQUEST, "이미 존재하는 쿠폰입니다.");
        }

        Coupon coupon = Coupon.create(couponName, discountPrice, totalAmount, 0);
        couponRepository.save(coupon);
        userCouponService.initializeRedisCounter(coupon.getId(), totalAmount);
        return CouponCreateResponse.of(couponName + "으로 " + discountPrice + "원의 " + totalAmount + "개가 생성 되었습니다.");
    }

k6를 이용한 부하 테스트 결과

k6를 사용하여 테스트를 실행했습니다.

테스트는 처음 30초동안 0에서 100까지 가상 사용자 수가 증가하고, 이후 1분동안 100에서 200까지 가상 사용자 수가 증가합니다.

마지막 30초에서는 200에서 0까지 가상 사용자수가 감소하는 테스트를 구현했습니다.

따라서 총 2분동안 최대 200의 가상 사용자가 요청을 보내는 테스트를 진행하였습니다.

결과

JAVA Reentrant Lock

MySQL Optimistic Lock (낙관적 락)

MySQL Pessimistic Lock (비관적 락)

MySQL Named Lock

Redis Lettuce

Redisson

Redis DECR

결과 정리표

방식총 발급 건수초당 처리 횟수HTTP 요청 평균 응답 시간평균 반복 시간
JAVA Reentrant Lock59966499.55/s124.74ms224.94ms
MySQL Optimistic Lock58079483.60/s132.01ms232.26ms
MySQL Pessimistic Lock60132500.99/s124.13ms224.32ms
MySQL Named Lock55770464.36/s141.73ms241.93ms
Redis Lettuce35854298.61/s276.82ms377.04ms
Redisson54610455.03/s146.88ms247.07ms
Redis DECR61420511.70/s119.42ms219.61ms

다음과 같은 결과가 나왔습니다.

전체적으로 비슷한 결과가 나왔지만, Lettuce를 사용했을 경우 다른 방식과 비교하여 약 60%정도의 성능을 보여주었습니다.

가장 많은 발급을 한 방식은 Redis DECR을 사용한 방식입니다.

해당 방식은 원자적이고 락 없이 카운팅을 처리하므로 처리량이 빨랐을 것으로 예상됩니다.

흔히 사용하는 Redisson방식은 54610으로 성능이 약간 저조했습니다.

해당 방식은 결국 락 획득 대기를 하는 과정에서 지연이 발생했을 것으로 예상됩니다.

MySQL NamedLock의 경우는 HikariCP pool 설정에 따라 성능이 달라질 수 있습니다.

해당 프로젝트에서는 최대 50개의 pool로 설정하였을 때 다음과 같은 결과가 나왔습니다.

가장 적은 발급을 한 방식은 Redis Lettuce를 사용한 방식입니다.

해당 방식은 재시도 로직이 효과적이지 않았거나, 연걸 풀이 부족하여 지연이 발생했을 가능성이 존재합니다.

MySQL 방식 성능 요인

MySQL 관련 동시성 처리 방식(Pessimistic Lock, Optimistic Lock)의 성능이 비교적 우수했던 이유는 로컬 환경에서의 테스트로 네트워크 지연이 적었기 때문으로 보입니다.

로컬 환경에서는 DB 연결 속도가 빨라 락 관리와 트랜잭션 처리 속도가 향상되었으며, 리소스 경쟁이 없어 안정적인 성능을 유지했습니다.

그러나 원격 환경에서는 네트워크 지연과 리소스 경쟁으로 성능이 하락할 가능성이 있으므로 추가 검증이 필요합니다.


결론

이번 테스트는 다양한 동시성 처리 방식을 비교하여 쿠폰 발급 시 성능을 분석했습니다.

테스트 결과, 각 방식은 동시성 제어 방식과 구현 환경에 따라 성능 차이를 보였습니다.

가장 높은 성능을 기록한 Redis DECR 방식은 총 61,420건의 발급을 달성하며, 초당 511.70건의 처리량과 119.42ms의 평균 응답 시간을 기록했습니다.

이는 락 없이 원자적 INCR 연산을 활용해 동시성 충돌을 최소화했기 때문입니다.

반면, 가장 낮은 성능을 보인 Redis Lettuce 방식은 35,854건에 그쳤으며, 평균 응답 시간이 276.82ms로 다른 방식의 약 60% 수준의 성능을 보여주었습니다.

이는 락 획득 재시도 로직의 비효율성과 Redis 연결 풀 부족으로 인한 지연이 원인으로 보여집니다.

Redisson은 54,610건으로 안정적인 성능을 보였으나, 락 획득 대기 과정에서 발생한 지연으로 인해 Redis DECR에 비해 성능이 저조했습니다.

MySQL Named Lock은 55,770건으로 중간 수준의 성능을 기록했으나, HikariCP 연결 풀 설정(최대 50개)에 따라 성능이 제한되었으며, 연결 풀 고갈로 인해 응답 시간이 141.73ms로 다소 길어졌습니다.

MySQL Pessimistic Lock(60,132건)과 JAVA Reentrant Lock(59,966건)은 각각 안정적인 처리량(500.99/s, 499.55/s)을 보여주었으며, DB와 JVM 수준의 락 관리로 인해 비교적 균형 잡힌 성능을 나타냈습니다.

결론적으로, Redis DECR은 락 없는 원자적 연산으로 동시성 처리에 가장 적합했으며, 높은 처리량과 낮은 응답 시간을 요구하는 환경에 적합한 것을 나타났습니다.

그러나 Redis Lettuce와 같이 락 기반 방식은 재시도 로직과 연결 풀 최적화가 필수적이며, MySQL 기반 방식은 HikariCP 설정과 DB 연결 관리가 성능에 큰 영향을 미치는 것으로 나타났습니다.

동시성 처리 방식 선택 시, 시스템 환경과 요구사항을 고려해야 하며, 이번 테스트 결과를 바탕으로 적절한 최적화를 진행하면 더 나은 성능을 기대할 수 있을 것입니다.

해당 프로젝트는 Concurrent에 정리해두었으니, 참고하셔도 좋을 것 같습니다.

profile
문제 해결을 좋아하는 개발자 입니다 :)

0개의 댓글