[TIL] 쿠폰 발급 동시성 이슈 해결: MySQL 네임드 락 적용기 (with JDBC vs DataSource)

YJin·2025년 5월 22일

[내배캠 Spring 6기_TIL]

목록 보기
38/56

MySQL 락 종류 요약

1. MySQL 엔진 레벨 락

  • 네임드 락 (GET_LOCK, RELEASE_LOCK)

    • 사용자 레벨 명시적 락
    • 연결(connection) 단위로 잠금
    • 쿼리: SELECT GET_LOCK('lock_key', timeout) → 결과 1(성공), 0(실패), NULL(에러)
    • 쿼리: SELECT RELEASE_LOCK('lock_key') → 결과 1(성공), 0(실패), NULL(존재하지 않음)
  • 테이블 락: LOCK TABLES, 잘 사용하지 않음

2. 스토리지 엔진 레벨 락

  • MySQL 기본 스토리지 엔진: Inno DB
    • Exclusive 락: InnoDB에서 실제로 획득되는 물리적 락



문제 상황

제한된 수량의 쿠폰을 발급 받기 위해 다수의 사용자 요청이 몰리는 상황

테스트 시나리오

쿠폰 수량: 1000개
발급 받으려는 유저: 1200명

기대 결과

발급된 쿠폰 수량(원래 수량 - 남은 수량) = 유저에게 발급된 쿠폰 수량 (발급된 userCoupon 개수)



테스트

JDBC Template 방식 - 트러블 슈팅


트랜잭션 내부 issueCoupon 호출 실패

문제 상황

  • 테스트 환경: issueCouponWithNamedLockAndJdbc()
  • 증상: 트랜잭션이 포함된 상태에서는 issueCoupon() 자체가 호출되지 않음
  • 트랜잭션을 제거하면 호출되지만 유저 쿠폰이 과도하게 발급됨

원인 분석

  • 트랜잭션이 issueCouponWithNamedLockAndJdbc()에 직접 걸려 있었고, 내부에서 비즈니스 로직이 비동기 혹은 별도 트랜잭션 경계로 인해 호출되지 않는 상황 발생

해결 방법

  • 트랜잭션은 issueCoupon()에만 적용하고, 외부에서는 락만 획득하도록 분리
  • 구조: 락 획득 → issueCoupon 호출 (트랜잭션) → 락 해제

결과

  • 트랜잭션 분리 적용 후:

    [TEST END] 남은 쿠폰 수량: 0
    유저 쿠폰 개수: 1000
    성공 횟수: 1000
    실패 횟수: 200

DataSource 방식 - 트러블 슈팅


NamedParameter 사용 오류

문제 상황

  • 테스트 환경: Named Lock 구현 시 JDBCTemplate 사용

  • 시도한 쿼리:

    private static final String GET_LOCK = "SELECT GET_LOCK(:userLockName, :timeoutSeconds)";
  • 발생한 예외:

    Parameter index out of range (1 > number of parameters, which is 0)

원인 분석

  • PreparedStatement? 기반 positional parameter만 지원하며 :paramName 구문은 사용 불가

해결 방법

  • 아래와 같이 쿼리를 수정하여 positional 방식으로 변경

    private static final String GET_LOCK = "SELECT GET_LOCK(?, ?)";
    preparedStatement.setString(1, userLockName);
    preparedStatement.setInt(2, timeoutSeconds);
  • 주의: 파라미터 index는 1부터 시작 (0부터 아님)



공통 성능 문제 및 충돌 이슈

테스트 조건

  • 쿠폰 수량 1000개, 유저 발급 시도 1200건
  • Named Lock + DataSource 기반 테스트 시도

증상

  • 실행 시간 6~10분 이상 소요
  • 성공 143, 실패 1057 등 비정상적 실패율

로그 메시지

USER LEVEL LOCK 쿼리 결과 값이 1이 아닙니다.
  • GET_LOCK 결과가 1이 아닌 경우(= 락 획득 실패)
  • 동시 접근 충돌 혹은 커넥션 리소스 부족 가능성
  • Redis 방식과 비교했을 때 충돌이 잦음

개선 아이디어

  • timeout 값을 조정하거나 재시도 로직 도입 고려
  • Redis 기반 락으로 전환 시 성능 개선 가능

기타

  • Supplier vs Runnable

    • Runnable: 반환값 없음
    • Supplier<T>: 실행 후 결과 반환 → 락 내 로직에서 값 반환 필요 시 사용



JDBCTemplate vs DataSouce 방식 비교

비교 항목JDBC Template 기반DataSource 직접 구현 방식
트랜잭션 경계 관리트랜잭션 분리 어려움 → @Transactional 적용 시 동작 이상 발생명시적 Connection 사용으로 트랜잭션과 락을 하나의 범위에서 제어 가능
커넥션 공유GET_LOCK과 RELEASE_LOCK에 서로 다른 커넥션 사용 가능성 높음같은 커넥션에서 GET/RELEASE 처리 가능 → 일관성 보장
문제 사례트랜잭션 미적용 시, RELEASE 실패 또는 락 유실 가능성 있음커넥션 수동 관리로 락 획득/해제 정확히 처리 가능
코드 복잡도상대적으로 간단 (템플릿 기반)구현 복잡하지만 명확한 흐름 제어 가능

결론적으로, 분산락 정확성 확보가 중요할 경우 DataSource 기반 직접 구현이 더 안정적이며, JDBCTemplate은 테스트나 간단한 구조에서만 권장됨



MySQL vs Redis 락 방식 비교

테스트/구현 환경

  • 쿠폰 발급 동시성 제어 테스트

  • 시나리오: 쿠폰 수량 1000개, 유저 발급 시도 1200건

  • 구현 방식:

    • JDBC Template 기반 MySQL Named Lock
    • Redis 기반 분산 락 (SETNX, Lua, Redisson 예정)

MySQL vs Redis: 항목별 장단점 비교

비교 항목MySQL Named LockRedis Distributed Lock
속도느림 (디스크 기반, 커넥션 점유)빠름 (메모리 기반 연산)
트랜잭션 연동자연스럽게 연동 (DB 내 일관성 확보 용이)연동 어려움 (트랜잭션 외부에서 관리해야 함)
인프라 구성단일 DB로 충분 (추가 설치 X)별도 Redis 인프라 필요 (클러스터 포함 가능성)
동시성 처리커넥션 수 제한 및 블로킹 가능성 있음TTL, 재시도 기반 유연한 처리 가능 (Redisson 활용 시 효과적)
해제 안정성커넥션 종료 시 자동 해제 (편리하나 의도치 않게 해제될 수 있음)TTL 및 Lua 스크립트 기반 명시적 제어 가능 (설정에 따라 안전성 확보)
운영 편의성단순하나 커넥션 관리 필요운영 난이도 있음 (TTL 조정, 멱등성 확보 등 추가 고려사항)
분산 환경 대응한 DB 내에서만 유효다양한 인스턴스 간 락 공유 가능 (서비스 확장 유리)
성공률테스트 결과: 성공 1000, 실패 200 / 또는 성공 143, 실패 1057 (락 충돌 많음)대부분 성공률 높음 (TTL + 재시도 조합 활용 가능)




MySQL 네임드 락 vs JPA 비관적 락 비교

배경

MySQL 기반 분산락 테스트에서 충돌이 빈번하게 발생함. 같은 코드에서 JPA의 비관적 락을 사용할 때는 충돌 빈도가 상대적으로 낮았고, 이 차이는 락 방식 자체의 구조적 차이에서 비롯됨.


락의 정의

구분설명
네임드 락 (Named Lock)MySQL 엔진 레벨에서 사용자가 지정한 문자열을 잠그는 방식. 데이터 존재 여부와 무관하게 로직 실행 권한을 제어하는 분산락 용도로 주로 사용됨.
비관적 락 (Pessimistic Lock)JPA에서 제공하는 전략으로, DB의 배타적 락(Exclusive Lock)을 활용해 실제 데이터 행(Row)에 대한 접근을 직접 막는 방식.

핵심 차이점

항목MySQL 네임드 락 (GET_LOCK)JPA 비관적 락 (@Lock)
락 대상DB 연결(Connection) 단위. 세션 기반의 가상 이름 잠금실제 행(Row) 단위. 물리적 레코드 잠금 (SELECT FOR UPDATE)
충돌 발생 시점이름 중복 시 GET_LOCK 즉시 실패 (0 반환)쿼리 실행 시 DB 대기 큐에서 대기 (순차 처리)
동시성 대처클라이언트가 직접 재시도 로직을 구현해야 함DB 엔진이 트랜잭션 스케줄링을 통해 순서 보장
락 보장 범위동일한 '이름'에 대해 전역적 제어특정 엔티티(행) 데이터에 한정된 제어
사용자 제어사용자가 수동으로 획득/해제 (DataSource 관리 필요)트랜잭션 종료 시 자동으로 해제/롤백 관리

충돌 많은 이유

  • GET_LOCK() 방식은 락 획득 실패를 직접 핸들링해야 하며, 기본적으로 락 대기 큐를 보장하지 않음
  • 여러 스레드가 동시에 동일한 userLockName으로 접근 시, 거의 동시에 실패하거나 재시도 충돌이 반복됨
  • JPA 비관적 락은 DB 자체가 트랜잭션 격리 수준에 따라 충돌을 순차 처리하기 때문에 상대적으로 충돌율이 낮음
  • 네임드 락은 락을 유지하는 동안 별도의 커넥션을 계속 점유해야 함. 1,200건의 동시 요청 상황에서 커넥션 풀이 부족할 경우 락 획득 시도조차 못 하고 실패할 가능성이 높음.

결론

  • MySQL 네임드 락은 데이터(행) 단위가 아닌 락 이름 단위의 전역 제어 방식. 데이터와 분리된 로직 자체를 보호하는 데 적합하지만, 대기 큐가 없어 충돌 시 재시도 로직을 직접 구현해야 함. 정밀한 동시성 제어보다는 간단한 분산 제어에 적합.
  • JPA 비관적 락데이터 행 단위로 락이 걸리며, DB 내부 트랜잭션 스케줄링 덕분에 충돌이 더 적음.
  • 대규모 동시 요청이 예상될 경우, 락 획득 실패 시 즉시 에러를 내는 방식보다 트랜잭션 내부에서 @Lock(PESSIMISTIC_WRITE) 기반으로 처리하는 것이 충돌 최소화에 더 효과적임.
  • 단, 네임드 락과 비관적 락 모두 DB 커넥션을 점유한다는 한계가 있음. 시스템 규모가 커진다면 DB 부하를 줄이기 위해 Redis(Redisson) 기반의 분산락을 고려할 필요가 있음.



그러면 어떤 상황에 적합할까?

MySQL 네임드 락 적합 상황

  • 단일 서버 환경
  • 락 유지 시간 짧고, 트랜잭션과 긴밀하게 묶여야 할 때
  • 간단한 구조로 빠르게 개발 필요할 때

Redis 락 적합 상황

  • 분산 서버 구조에서 동시성 이슈 방지가 필요할 때
  • 고속 처리와 락 충돌 최소화가 핵심일 때
  • Redisson 등 라이브러리로 TTL/자동해제/재시도 처리까지 포함해 구성 가능할 때


결론

  • MySQL은 간단하고 트랜잭션과 연계하기 좋아서 개발 초기나 단일 환경에 유리함
  • Redis는 분산 환경과 고성능이 필요한 실서비스에서 강력함
  • MySQL 내부에서도 DataSource 기반 직접 구현이 정확성과 안정성 측면에서 우위에 있음

최종 선택은 환경과 요구 사항에 따라 달라지며, 각 방식의 단점은 설정 및 아키텍처 설계로 일정 부분 보완 가능함




참고 링크

profile
백엔드 개발도 락이다

0개의 댓글