[TIL] MySQL 으로 분산락 구현

YJin·2025년 5월 23일
0

[내배캠 Spring 6기_TIL]

목록 보기
40/64
post-thumbnail

Redis 대신 MySQL 을 이용해 Lock 구현

  • MySQL 로 Lock 을 구현할 경우 장단점이 무엇인지 꼭! 확인하기
    💡 Hint.
    - JPA 비관적 Lock
    - MySQL Exclusive Lock

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 Lock): InnoDB에서 실제로 획득되는 물리적 락


Redis vs MySQL Lock 비교

항목RedisMySQL
락 레벨서비스 레벨 (분산락)DB 레벨 (스토리지/엔진 락)
구현 방식SETNX, Lua 스크립트GET_LOCK, RELEASE_LOCK, 배타적 락(Exclusive Lock, 비관적 락 전략)
인프라 요구Redis 별도 구축 필요기존 DB 사용 가능
성능메모리 기반이라 빠름성능 병목 우려 있음 (연결 수 제한 등)
유지보수Redis 인프라 관리 필요별도 관리 불필요
사용 목적분산 시스템에서의 경량 락DB 트랜잭션 보호 및 동시성 제어



구현

문제 상황

🎟️ 제한된 수량의 쿠폰을 발급 받으려 다수의 사용자가 몰려드는 상황.

테스트 시나리오

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

기대 결과

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


구현 방식

1️⃣ JDBC Template

@Slf4j
@Component
@RequiredArgsConstructor
public class NameLockWithJdbcTemplate {

    private static final String GET_LOCK = "SELECT GET_LOCK(:userLockName, :timeoutSeconds)";
    private static final String RELEASE_LOCK = "SELECT RELEASE_LOCK(:userLockName)";

    private final NamedParameterJdbcTemplate jdbcTemplate;

    public <T> T executeWithLock(
        String userLockName, int timeoutSeconds, Supplier<T> supplier) {
        try {
            getLock(userLockName, timeoutSeconds);
            return supplier.get();
        } finally {
            releaseLock(userLockName);
        }
    }

    private void getLock(String userLockName, int timeoutSeconds) {
        Map<String, Object> params = new HashMap<>();
        params.put("userLockName", userLockName);
        params.put("timeoutSeconds", timeoutSeconds);

        log.info("LOCK 획득: " + userLockName + " TIME: " + timeoutSeconds);

        Integer result = jdbcTemplate.queryForObject(GET_LOCK, params, Integer.class);
        checkResult(result, userLockName, "GetLock");
    }

    private void releaseLock(String userLockName) {
        Map<String, Object> params = new HashMap<>();
        params.put("userLockName", userLockName);

        log.info("LOCK 해제: " + userLockName);

        Integer result = jdbcTemplate.queryForObject(RELEASE_LOCK, params, Integer.class);
        checkResult(result, userLockName, "GetLock");
    }

    private void checkResult(Integer result, String userLockName, String type) {
        if (result == null) {
            log.info("USER LEVEL LOCK 쿼리 결과값이 없습니다. type = " + type, " userLockName: ",
                userLockName);
            throw new BaseException(ExceptionCode.INTERNAL_SERVER_ERROR);
        }
        if (result != 1) {
            log.info("USER LEVEL LOCK 쿼리 결과값이 1이 아닙니다. type = " + type, " userLockName: ",
                userLockName);
            throw new BaseException(ExceptionCode.INTERNAL_SERVER_ERROR);
        }
    }
}



2️⃣ DataSource

@Slf4j
@Component
@RequiredArgsConstructor
public class NameLockWithDataSource {

    private static final String GET_LOCK = "SELECT GET_LOCK(?, ?)";
    private static final String RELEASE_LOCK = "SELECT RELEASE_LOCK(?)";

    private final DataSource dataSource;

    public <T> T executeWithLock(String userLockName, int timeoutSeconds, Supplier<T> supplier) {
        try (Connection connection = dataSource.getConnection()) {
            try {
                log.info("LOCK 획득 시작 KEY: " + userLockName + " __ CONNECTION: " + connection);
                getLock(connection, userLockName, timeoutSeconds);
                log.info("LOCK 획득 성공 KEY: " + userLockName + " __ CONNECTION: " + connection);
                return supplier.get();
            } finally {
                releaseLock(connection, userLockName);
                log.info("LOCK 해제 성공 KEY: " + userLockName + " __ CONNECTION: " + connection);
            }
        } catch (SQLException | RuntimeException e) {
            throw new RuntimeException(e.getMessage(), e);
        }
    }

    private void getLock(Connection connection, String userLockName, int timeoutSeconds) {
        try (PreparedStatement preparedStatement = connection.prepareStatement(GET_LOCK)) {
            preparedStatement.setString(1, userLockName);
            preparedStatement.setInt(2, timeoutSeconds);

            checkResult(userLockName, preparedStatement, "GetLock_");
        } catch (SQLException | RuntimeException e) {
            throw new RuntimeException(e.getMessage(), e);
        }
    }

    private void releaseLock(Connection connection, String userLockName) {
        try (PreparedStatement preparedStatement = connection.prepareStatement(RELEASE_LOCK)) {
            preparedStatement.setString(1, userLockName);

            checkResult(userLockName, preparedStatement, "ReleaseLock");
        } catch (SQLException | RuntimeException e) {
            throw new RuntimeException(e.getMessage(), e);
        }
    }

    private void checkResult(String userLockName, PreparedStatement ps, String type)
        throws SQLException {
        try (ResultSet resultSet = ps.executeQuery()) {
            if (!resultSet.next()) {
                log.error(
                    "USER LEVEL LOCK 쿼리 결과 값이 없습니다. type = " + type
                        + "_ userLockName = " + userLockName
                        + " _ connection: " + ps.getConnection());
                throw new RuntimeException("USER LEVEL LOCK 쿼리 결과 값이 없습니다.");
            }
            int result = resultSet.getInt(1);
            if (result != 1) {
                log.error(
                    "USER LEVEL LOCK 쿼리 결과 값이 1이 아닙니다. type = " + type
                        + " _ result = " + result + "_ userLockName = " + userLockName
                        + " _ connection: " + ps.getConnection());
                throw new RuntimeException("USER LEVEL LOCK 쿼리 결과 값이 1이 아닙니다.");
            }
        }
    }
}



트러블 슈팅

트랜잭션 내부 issueCoupon 호출 실패

상황

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

분석

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

해결

  • 트랜잭션은 issueCoupon()에만 적용하고, 외부에서는 락만 획득하도록 분리
  • 구조: 락 획득 → issueCoupon 호출 (트랜잭션) → 락 해제
[TEST END] 남은 쿠폰 수량: 0
유저 쿠폰 개수: 1000
성공 횟수: 1000
실패 횟수: 200



NamedParameter 사용 오류

상황

  • DataSource 기반 네임 락 구현

  • 시도한 쿼리:

    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건

  • 구현 방식:

    • MySQL Named Lock (JDBC Template, DataSource)
    • Redis 기반 분산 락



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의 비관적 락을 사용할 때는 충돌 빈도가 상대적으로 낮았고, 이 차이는 락 방식 자체의 구조적 차이에서 비롯됨.


락의 정의

배타적 락

: 데이터를 다른 트랜잭션이 건드릴 수 없도록 선제적으로 잠금을 거는 방식.

  • 동시성 낮음, 안전성 높음
  • 성능은 떨어질 수 있지만, 데이터 정합성 우선

비관적 락

: 데이터에 쓰기 작업을 하는 동안, 다른 트랜잭션이 읽거나 쓰지 못하게 하는 락. 주로 데이터베이스에서 내부적으로 자동 적용되며, 쓰기(write) 연산을 위한 락

  • 동시 접근 차단: 해당 리소스에 대해 오직 하나의 트랜잭션만 접근 가능
  • 잠금 대상은 행(row) 혹은 테이블 단위



핵심 차이점

항목MySQL 배타적 락 (USER-LEVEL LOCK 등)JPA 비관적 락 (@Lock(PESSIMISTIC_WRITE))
락 대상DB 연결(Connection) 단위. 세션 기반 이름 지정 잠금실제 행(Row)에 대한 DB 락 (SELECT FOR UPDATE)
충돌 발생 시점이름 중복 시 GET_LOCK 실패 → 클라이언트가 직접 재시도 필요쿼리 실행 시 락 대기 가능 → 트랜잭션 내부에서 자동으로 순서 조정됨
동시성 대처 방식락 실패 시 즉시 실패(반환값 0) → 충돌률 높고 재시도 어려움DB 레벨에서 트랜잭션 스케줄링 수행 → 대기 큐에 의해 순서 보장 가능성 있음
락 보장 범위락 이름만 동일하면 전역적 → 행 단위 제어가 아님특정 엔티티(행)에 한정된 락 제어 가능
사용자 제어락 획득, 해제를 모두 수동으로 제어해야 함트랜잭션과 함께 자동 관리 (rollback 시 해제 등)

충돌 많은 이유 - MySQL 방식의 구조적 원인

  • GET_LOCK() 방식은 락 획득 실패를 직접 핸들링해야 하며, 기본적으로 락 대기 큐를 보장하지 않음
  • 여러 스레드가 동시에 동일한 userLockName으로 접근 시, 거의 동시에 실패하거나 재시도 충돌이 반복됨
  • JPA 비관적 락은 DB 자체가 트랜잭션 격리 수준에 따라 충돌을 순차 처리하기 때문에 상대적으로 충돌율이 낮음



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

MySQL 락 적합 상황

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

Redis 락 적합 상황

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



📌 결론 요약

  • DataSource 기반 구현이 안정성과 락 일관성 확보에 가장 유리
  • Redis는 멀티 인스턴스/고성능 환경에서 락 충돌 회피 측면에서 강점
  • JPA 비관적 락은 DB 레벨에서 순차적 처리되므로 충돌에 강함
  • 상황에 따라 GET_LOCK() 기반보다 @Lock(PESSIMISTIC_WRITE) 또는 Redis 기반 접근이 효율적일 수 있음




참고

profile
백엔드 개발도 락이다

0개의 댓글